大家好,我是二哥呀。
每次登录牛客,看到最多的就是各种 Java 后端岗位的喜讯,美团 OC了、快手 OC 了、就连腾讯 OC 的都是 Java 岗,我怀疑牛客是不是给我打了“只报喜不报忧”的标签?
星球里也有不少球友给我发来喜讯,难道说每年都在凉凉的 Java 后端又承担起了就业的重任?!
不可能,绝对不可能,这一切都是假象!反正我敢肯定,还有不少同学在嗷嗷叫,尤其是 24 届春招还没上岸的,25 届没找到暑期实习的(😭)。
我只能说拿到 offer 的,这个假期就放肆几天吧;没拿到的要不就苟一苟,继续背背八股,优化优化简历?也许好运就要降临到你头上了。
这次我们就以《Java 面试指南——携程面经》为例,继续来看看携程这家不错的互联网中厂面试官都喜欢问哪些问题,好做到知彼知己百战不殆,我会用通俗易懂+手绘图的方式,让天下所有的面渣都能逆袭 😁
二哥的 Java 面试指南
内容较长,建议正在冲刺 24 届春招和 25 届暑期实习、秋招的同学先收藏起来,面试的时候大概率会碰到,
当我们使用 new 关键字创建一个对象的时候,JVM 首先会检查 new 指令的参数是否能在常量池中定位到一个类的符号引用,然后检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,就先执行相应的类加载过程。
如果已经加载,JVM 会为新生对象分配内存,内存分配完成之后,JVM 将分配到的内存空间初始化为零值(成员变量,数值类型是 0,布尔类型是 false,对象类型是 null),接下来设置对象头,对象头里包含了对象是哪个类的实例、对象的哈希码、对象的 GC 分代年龄等信息。
最后,JVM 会执行构造方法(<init>
),将成员变量赋值为预期的值,这样一个对象就创建完成了。
二哥的 Java 进阶之路:对象的创建过程
对象创建完成后,就可以通过引用来访问对象的方法和属性,当对象不再被任何引用指向时,对象就会变成垃圾。
垃圾收集器会通过可达性分析算法判断对象是否存活,如果对象不可达,就会被回收。
垃圾收集器会通过标记清除、标记复制、标记整理等算法来回收内存,将对象占用的内存空间释放出来。
常用的垃圾收集器有 CMS、G1、ZGC 等,它们的回收策略和效率不同,可以根据具体的场景选择合适的垃圾收集器。
在堆内存分配对象时,主要使用两种策略:指针碰撞和空闲列表。
三分恶面渣逆袭:指针碰撞和空闲列表
①、指针碰撞(Bump the Pointer)
假设堆内存是一个连续的空间,分为两个部分,一部分是已经被使用的内存,另一部分是未被使用的内存。
在分配内存时,Java 虚拟机维护一个指针,指向下一个可用的内存地址,每次分配内存时,只需要将指针向后移动(碰撞)一段距离,然后将这段内存分配给对象实例即可。
②、空闲列表(Free List)
JVM 维护一个列表,记录堆中所有未占用的内存块,每个空间块都记录了大小和地址信息。
当有新的对象请求内存时,JVM 会遍历空闲列表,寻找足够大的空间来存放新对象。
分配后,如果选中的空闲块未被完全利用,剩余的部分会作为一个新的空闲块加入到空闲列表中。
指针碰撞适用于管理简单、碎片化较少的内存区域(如年轻代),而空闲列表适用于内存碎片化较严重或对象大小差异较大的场景(如老年代)。
三分恶面渣逆袭:Concurrent Mark Sweep收集器运行示意图
CMS(Concurrent Mark Sweep)分 4 大步进行垃圾收集:
G1 收集器的运行过程大致可划分为这几个步骤:
①、并发标记,G1 通过并发标记的方式找出堆中的垃圾对象。并发标记阶段与应用线程同时执行,不会导致应用线程暂停。
②、混合收集,在并发标记完成后,G1 会计算出哪些区域的回收价值最高(也就是包含最多垃圾的区域),然后优先回收这些区域。这种回收方式包括了部分新生代区域和老年代区域。
选择回收成本低而收益高的区域进行回收,可以提高回收效率和减少停顿时间。
③、可预测的停顿,G1 在垃圾回收期间仍然需要「Stop the World」。不过,G1 在停顿时间上添加了预测机制,用户可以 JVM 启动时指定期望停顿时间,G1 会尽可能地在这个时间内完成垃圾回收。
三分恶面渣逆袭:G1收集器运行示意图
ThreadLocal 是 Java 中提供的一种用于实现线程局部变量的工具类。它允许每个线程都拥有自己的独立副本,从而实现线程隔离,用于解决多线程中共享对象的线程安全问题。
三分恶面渣逆袭:ThreadLocal线程副本
ThreadLocal 本身并不存储任何值,它只是作为一个映射,来映射线程的局部变量。当一个线程调用 ThreadLocal 的 set 或 get 方法时,实际上是访问线程自己的 ThreadLocal.ThreadLocalMap。
二哥的 Java 进阶之路
ThreadLocalMap 是 ThreadLocal 的静态内部类,它内部维护了一个 Entry 数组,key 是 ThreadLocal 对象,value 是线程的局部变量本身。
三分恶面渣逆袭:ThreadLoca结构图
早期的 ThreadLocal 不是这样的,它的 ThreadLocalMap 中使用 Thread 作为 key,这也是最简单的实现方式。
黑马:JDK 早期设计
优化后的方案有两个好处,一个是 Map 中存储的键值对变少了;另一个是 ThreadLocalMap 的生命周期和线程一样长,线程销毁的时候,ThreadLocalMap 也会被销毁。
Entry 继承了 WeakReference,它限定了 key 是一个弱引用,弱引用的好处是当内存不足时,JVM 会回收 ThreadLocal 对象,并且将其对应的 Entry 的 value 设置为 null,这样在很大程度上可以避免内存泄漏。
使用完 ThreadLocal 后,及时调用 remove()
方法释放内存空间。
try {
threadLocal.set(value);
// 执行业务操作
} finally {
threadLocal.remove(); // 确保能够执行清理
}
remove()
方法会将当前线程的 ThreadLocalMap 中的所有 key 为 null 的 Entry 全部清除。
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}
public void clear() {
this.referent = null;
}
使用多线程的目的是为了充分利用 CPU,但是我们知道,并发其实是一个 CPU 来应付多个线程。
三分恶面渣逆袭:线程切换
为了让用户感觉多个线程是在同时执行的, CPU 资源的分配采用了时间片轮转也就是给每个线程分配一个时间片,线程在时间片内占用 CPU 执行任务。当线程使用完时间片后,就会处于就绪状态并让出 CPU 让其他线程占用,这就是上下文切换。
三分恶面渣逆袭:上下文切换时机
CAS(Compare-and-Swap)是一种乐观锁的实现方式,全称为“比较并交换”,是一种无锁的原子操作。
在 Java 中,我们可以使用 synchronized关键字和 CAS
来实现加锁效果。
synchronized 是悲观锁,尽管随着 JDK 版本的升级,synchronized 关键字已经“轻量级”了很多,但依然是悲观锁,线程开始执行第一步就要获取锁,一旦获得锁,其他的线程进入后就会阻塞并等待锁。
CAS 是乐观锁,线程执行的时候不会加锁,它会假设此时没有冲突,然后完成某项操作;如果因为冲突失败了就重试,直到成功为止。
在 CAS 中,有这样三个值:
比较并交换的过程如下:
判断 V 是否等于 E,如果等于,将 V 的值设置为 N;如果不等,说明已经有其它线程更新了 V,于是当前线程放弃更新,什么都不做。
这里的预期值 E 本质上指的是“旧值”。
这个比较和替换的操作是原子的,即不可中断,确保了数据的一致性。
如果一个位置的值原来是 A,后来被改为 B,再后来又被改回 A,那么进行 CAS 操作的线程将无法知晓该位置的值在此期间已经被修改过。
可以使用版本号/时间戳的方式来解决 ABA 问题。
比如说,每次变量更新时,不仅更新变量的值,还更新一个版本号。CAS 操作时不仅要求值匹配,还要求版本号匹配。
Java 的 AtomicStampedReference 类就实现了这种机制,它会同时检查引用值和 stamp 是否都相等。
二哥的 Java 进阶之路:AtomicStampedReference
当一个变量被声明为 volatile 时,Java 内存模型会确保所有线程看到该变量时的值是一致的。
深入浅出 Java 多线程:Java内存模型
也就是说,当线程对 volatile 变量进行写操作时,JMM 会在写入这个变量之后插入一个 Store-Barrier(写屏障)指令,这个指令会强制将本地内存中的变量值刷新到主内存中。
三分恶面渣逆袭:volatile写插入内存屏障后生成的指令序列示意图
当线程对 volatile 变量进行读操作时,JMM 会插入一个 Load-Barrier(读屏障)指令,这个指令会强制让本地内存中的变量值失效,从而重新从主内存中读取最新的值。
三分恶面渣逆袭:volatile写插入内存屏障后生成的指令序列示意图
例如,我们声明一个 volatile 变量 x:
volatile int x = 0
线程 A 对 x 写入后会将其最新的值刷新到主内存中,线程 B 读取 x 时由于本地内存中的 x 失效了,就会从主内存中读取最新的值,内存可见性达成!
三分恶面渣逆袭:volatile内存可见性
三分恶面渣逆袭:JDK 8 HashMap 数据结构示意图
HashMap 的核心是一个动态数组(Node[] table
),用于存储键值对。这个数组的每个元素称为一个“桶”(Bucket),每个桶的索引是通过对键的哈希值进行哈希函数处理得到的。
当多个键经哈希处理后得到相同的索引时,会发生哈希冲突。HashMap 通过链表来解决哈希冲突——即将具有相同索引的键值对通过链表连接起来。
不过,链表过长时,查询效率会比较低,于是当链表的长度超过 8 时(且数组的长度大于 64),链表就会转换为红黑树。红黑树的查询效率是 O(logn),比链表的 O(n) 要快。数组的查询效率是 O(1)。
红黑树有两种方式保持平衡:旋转
和染色
。
①、旋转:旋转分为两种,左旋和右旋
三分恶面渣逆袭:左旋
三分恶面渣逆袭:右旋
②、染⾊:
三分恶面渣逆袭:染色