new Object() 占多少字节?★★三部分:对象头、实例数据、对齐填充。对象头里是 8 字节的 Mark Word 加上类型指针,类型指针开了压缩是 4 字节、不开是 8 字节,如果是数组还要多 4 字节存长度。实例数据就是各个字段,HotSpot 会做字段重排,把相同宽度的挨在一起省空间。最后补齐到 8 的整数倍。
所以 new Object() 是 8 + 4 + 0,补 4 字节填充,一共 16 字节。有意思的是不开压缩也是 16(8 + 8 + 0),得用带字段的对象才能看出差别:只含一个 int 的对象,开压缩 16 字节,不开就变成 24。
压缩指针只在堆 32G 以内有效,因为它存的不是真实地址,而是以 8 字节为单位的偏移量。对象总是 8 字节对齐,地址低 3 位必然是 0,可以省掉,所以 32 位能覆盖 2³² × 8 = 32G。这里有个实际的调优坑:堆设成 32G 会让 JVM 自动关掉压缩指针,每个引用多占 4 字节,反而比设 30G 装的对象更少。所以堆要么压到 31G 以内,要么直接上 40G 才划算。
想验证就用 JOL,ClassLayout.parseInstance(obj).toPrintable() 直接把布局打出来。
new 一个对象,JVM 内部经历了哪些步骤?★★★五步:类加载检查 → 分配内存 → 初始化零值 → 设置对象头 → 执行构造方法。
先检查这个类有没有被加载解析初始化过,没有就先走类加载流程。然后在堆上划一块内存,大小在类加载完成后就是确定的。接着把这块内存全部置零值,这一步保证了对象的字段即使不显式赋值也能直接用,int 就是 0、引用就是 null。然后填对象头,写入类型指针、分代年龄、锁标志这些。最后才执行 <init>,也就是构造方法和实例初始化代码,把字段赋成你写的值。
所以字段其实被赋值了两次:准备阶段的零值和构造阶段的实际值。这个顺序解释了一个现象——构造方法里调用一个被子类重写的方法时,读到的子类字段还是零值,因为子类的构造方法还没执行。这也是「构造器里不要调用可被重写的方法」这条规则的来源。
分配内存有两个问题要解决,这是这题的深度所在。
一是怎么划分空闲空间:如果堆是规整的(用了压缩整理的收集器,比如 Serial、ParNew),用指针碰撞——指针往后挪一段就行,极快。如果堆有碎片(标记清除的收集器,比如 CMS),就得维护一个空闲列表,找一块够大的给它,慢一些。所以收集器的选择会反过来影响分配效率。
二是并发分配的线程安全。多个线程同时申请内存,指针碰撞会撞车。解法有两个:CAS 加失败重试,或者用 TLAB(Thread Local Allocation Buffer)——给每个线程在 Eden 区预先划一小块私有区域,线程在自己的 TLAB 里分配,完全不需要同步。TLAB 用完了才去申请新的一块,这时候才需要加锁。
TLAB 是默认开启的(-XX:+UseTLAB),这也是为什么 Java 的对象分配能做到极快,接近 C 的栈分配速度。对象太大装不进 TLAB 时,才直接在 Eden 甚至老年代上分配。理解 TLAB 之后就明白为什么「Java 里 new 对象很便宜」这句话是成立的。
Mark Word 是一块复用空间,本质上是个联合体,同样这 64 位在不同锁状态下含义完全不同。无锁状态存 hashCode 和分代年龄;偏向锁状态用 54 位存线程 ID;轻量级锁存指向栈里 Lock Record 的指针;重量级锁存指向 ObjectMonitor 的指针。加轻量级或重量级锁时,原来的 Mark Word 会被搬到别处备份,解锁时再还原回去。
因为空间是挤着用的,就产生两个经典结论。一是 GC 分代年龄只有 4 位,最大 15,这就是 MaxTenuringThreshold 不能超过 15 的根本原因。二是 hashCode 和偏向锁天生互斥,偏向锁要用的那段位置正好压在 hashCode 上。
所以如果一个对象已经调用过 Object.hashCode(),它就没法再进偏向锁了,加锁时直接膨胀成轻量级锁。反过来,一个已经偏向的对象你去调 hashCode,会强制撤销偏向锁、膨胀成重量级锁,把 hashCode 存进 ObjectMonitor 里。注意这里说的是那个 native 的身份哈希,如果类自己重写了 hashCode() 返回字段计算值,不占 Mark Word,也就不影响偏向锁。
主要是永久代的容量太僵硬,MaxPermSize 得提前拍板,设小了 OOM、设大了白占堆空间,而且它的回收依附 Full GC,效率低收益小。加上 JDK 7 已经把字符串常量池挪出永久代了,它的存在感只剩累赘,另外 Oracle 要合并 HotSpot 和 JRockit,而 JRockit 没有永久代概念。挪到本地内存后默认不设上限,按需扩展。
还是会 OOM 的。一种是你显式设了 MaxMetaspaceSize 而类加载量超了,生产环境我一般建议设,不设的话一旦泄漏会把宿主机内存吃穷,容器里直接被 OOMKilled,比抛异常更难查。另一种就是类加载器泄漏,这个更常见。
类要卸载的条件极其严格:实例全被回收、加载它的 ClassLoader 被回收、Class 对象没人引用,三条得同时满足。而 CGLIB 和字节码增强每次都新建 ClassLoader 生成类,Tomcat 热部署每次换一个 WebappClassLoader,只要有任何一个静态字段或者 ThreadLocal 不小心持有了旧 ClassLoader,这一整批类就永远卸不掉,元空间只涨不降。排查我一般用 Arthas 的 classloader 命令看实例数,或者 jcmd VM.classloader_stats。
intern() 行为有什么变化?★★★JDK 6 在永久代,JDK 7 挪到了堆,JDK 8 之后还在堆里,只是那张哈希表本身在本地内存。
行为差异是关键:JDK 6 的 intern() 会把字符串拷贝一份到永久代池里,返回的是池中的新对象,所以返回值永远不等于原对象。JDK 7 之后改成如果池里没有,就把堆中这个对象的引用登记进池,返回的还是原来那个堆对象,这样就可能相等了。
面试常考这段代码:
String s1 = new String("he") + new String("llo");
System.out.println(s1.intern() == s1); // true
String s2 = new String("he") + new String("llo");
System.out.println(s2 == s2.intern()); // false
第一个是 true,因为拼接走的是 StringBuilder,结果在运行期才在堆上生成,此时池里只有 "he" 和 "llo",没有 "hello",intern() 就把 s1 的引用登记进池并返回 s1 自己。第二个是 false,因为池里已经被 s1 占了位,s2.intern() 返回的是 s1,跟 s2 不是一个对象。如果面试官把顺序改一下,先写一句 String x = "hello";,两个就都变 false 了,他就是靠这个区分背过和真懂。
顺带说,编译期能确定的拼接会被常量折叠直接进池。业务代码里我不建议随便用 intern(),把订单号、用户输入这类随机字符串钉进池里,本身就是个泄漏源。
加载 → 验证 → 准备 → 解析 → 初始化,后面才是使用和卸载。中间三步合起来叫连接。
加载是读字节流生成 Class 对象。验证检查字节码合法性,防止恶意或损坏的字节码把虚拟机搞崩。准备给静态变量分配空间并赋零值——注意这里赋的是 0 或 null,不是你写的初始值,这是个高频考点。解析把常量池里的符号引用换成直接引用。初始化才是真正执行 <clinit>,也就是静态变量的实际赋值和静态代码块。
有个例外要记住:static final 修饰且编译期能确定值的常量,在准备阶段就直接赋上了真实值,因为它被放进了常量池,压根不需要等到初始化。
触发初始化的时机就那么几个:new 对象、读写非常量的静态字段、调用静态方法、反射调用、初始化子类时会先初始化父类、以及作为程序主类启动。
不触发的情况反而更常考:通过子类访问父类的静态字段,只初始化父类不初始化子类;访问 static final 常量,编译时已经折叠进调用方的常量池,压根不会触发;创建数组 new SomeClass[10] 只是初始化了数组类型,不初始化元素类型;用 ClassLoader.loadClass() 只加载不初始化,而 Class.forName() 默认会初始化(这个区别在 JDBC 驱动注册的场景里很关键)。
执行顺序也常一起问:父类静态 → 子类静态 → 父类实例代码块和构造器 → 子类实例代码块和构造器。静态部分整个生命周期只走一次,实例部分每次 new 都走。
实际价值在于理解「零值 + 实际值」的两阶段赋值:它解释了为什么构造器里调用被子类重写的方法时,读到的子类字段还是零值,也解释了单例模式里静态内部类为什么天然线程安全——类初始化过程由 JVM 加锁保证只执行一次。
加载一个类时先往上委托给父加载器,父加载器加载不了才自己动手。这么做一是保证核心类库的唯一性,你自己写个 java.lang.String 也没法把真的替换掉;二是避免同一个类被不同加载器重复加载,因为 JVM 判断两个类是否相同,看的是全限定名加类加载器两者一起。
Tomcat 打破它是因为需求正好相反:一个 Tomcat 要跑多个 Web 应用,每个应用可能依赖同一个库的不同版本,必须做到相互隔离。所以每个应用有自己的 WebappClassLoader,加载 WEB-INF 下的类时先自己找,找不到才委托父级。这样两个应用各自的类互不干扰。当然 JDK 核心类还是照规矩委托上去,不然就乱了。
另一种打破的场景是 SPI。DriverManager 在 rt.jar 里,由 Bootstrap 加载,但它要加载的 MySQL 驱动在应用的 classpath 上,Bootstrap 看不见。按双亲委派的方向根本没法往下找,所以用线程上下文类加载器来「反向」拿到应用类加载器,绕过这个限制。JDBC、JNDI、日志框架都是这个套路。
根源是两条分代假说,这是整个分代设计的地基。弱分代假说:绝大多数对象朝生夕灭。强分代假说:熬过越多次 GC 的对象越难死。这两条是从大量实际程序里观察出来的统计规律,不是理论推导。
既然对象的生存特征差异这么大,就该分区域用不同策略:把朝生夕灭的放一起,每次回收能腾出绝大部分空间;把长寿的放一起,降低回收频率。这样既提高效率又减少停顿。
三种基础算法各有取舍。标记清除最简单,但会留下碎片,效率也随对象数量下降。标记复制把空间分两半,回收时把存活对象复制到另一半然后整块清空——不产生碎片、分配时能用指针碰撞,但代价是浪费一半空间,而且存活对象越多复制成本越高。标记整理是标记后把存活对象向一端移动,没有碎片也不浪费空间,但移动对象必须 STW,还要更新所有引用,成本高。
所以搭配就很自然了。新生代用标记复制:存活对象极少(通常不到 10%),复制成本极低,而且浪费的空间可以压缩——不用一比一分两半,而是 Eden 加两个 Survivor 按 8:1:1 划分,只浪费 10% 而不是 50%。老年代用标记清除或标记整理:存活率高,复制的成本受不了,只能原地回收。CMS 用标记清除(换低停顿,代价是碎片),Parallel Old 和 Serial Old 用标记整理。
要补一句分代不是绝对真理:ZGC 早期就是不分代的,因为它的核心矛盾是停顿时间而不是吞吐,而分代要维护跨代引用(记忆集加写屏障)反而是负担。但 JDK 21 的分代 ZGC 又转正了,说明实际负载下分代带来的吞吐收益还是划算的。这个反复过程恰好说明分代是工程权衡,不是必然。
强引用就是普通的 new,只要引用还在就绝不回收,宁可 OOM。软引用在内存不够时会被回收。弱引用只要发生 GC 就回收,不管内存够不够。虚引用完全拿不到对象(get() 永远返回 null),唯一作用是配合引用队列在对象被回收时收到通知。
实际用途要说具体的,光背定义没分。
虚引用最典型的是 DirectByteBuffer 的 Cleaner:堆外内存不受 GC 管理,所以用虚引用监听那个堆内的包装对象,一旦它被回收就触发回调去释放对应的堆外内存。JDK 9 之后换成了 java.lang.ref.Cleaner,机制一样。这是「用引用队列做资源清理」的标准范式。
弱引用最典型的是 ThreadLocalMap 的 Entry 用弱引用做 key:这样 ThreadLocal 变量本身没人引用时,key 能被回收,不至于整条链锁死。但要注意value 还是强引用,所以泄漏问题依然存在,用完必须 remove。WeakHashMap 也是这个思路,适合做「key 没人用了这条记录就该消失」的缓存,比如按 Class 缓存元数据。
软引用理论上适合做缓存,但实际上我不推荐:回收时机完全由 JVM 决定,你控制不了,而且回收前通常会先触发一次 Full GC 尝试腾空间,反而制造停顿。现在做本地缓存直接用 Caffeine,容量、过期、淘汰策略都可控,比靠 SoftReference 撞运气靠谱得多。
另外要知道引用队列(ReferenceQueue)是这套机制真正好用的地方:软弱虚引用都可以注册到队列里,对象被回收时引用对象本身会被放进队列,你可以从队列里取出来做后续清理。没有队列的话,你压根不知道对象什么时候被回收了。
四条路径:年龄超过阈值(MaxTenuringThreshold,默认 15)、大对象直接进(由 PretenureSizeThreshold 控制,避免在新生代反复复制)、Survivor 装不下(一次 Young GC 后存活对象超过 Survivor 容量,直接晋升)、动态年龄判定命中。
动态年龄判定是最容易被忽略但线上影响最大的一条:Survivor 区里从小到大累加各年龄段对象的大小,一旦累加值超过 Survivor 的一半,那么大于等于当前这个年龄的对象全部直接晋升,不用等到 15。
这条规则解释了一个常见的困惑:「Survivor 明明没满,为什么对象提前进老年代了」。因为判定的门槛是「一半」,不是「满」。
由此得出的调优思路很实用:如果发现老年代涨得比预期快、Full GC 变频繁,先别急着调 MaxTenuringThreshold,而是看是不是 Survivor 太小导致动态年龄频繁命中。适当调大新生代或者调整 SurvivorRatio(让 Survivor 更大)往往更有效。反过来,如果业务对象确实大部分都是长寿的,强行留在新生代反复复制也是浪费,那就该让它们早点晋升。
还有两个配套知识点。年龄存在对象头的 Mark Word 里,只有 4 位,所以最大值是 15,这就是阈值不能设更大的硬限制。还要注意 G1 的行为差异:G1 也有这套晋升逻辑,但它是按 Region 管理的,而且会根据停顿目标动态调整新生代大小,所以手动调 SurvivorRatio 在 G1 下通常不生效也不推荐——这一点很多人会拿 CMS 时代的调优经验套到 G1 上,是错的。
三色标记是为了让标记阶段能和用户线程并发跑,不用全程 STW。白色是还没访问到、黑色是自己和所有字段都扫完了、灰色是自己扫了但字段还没扫完。
并发带来两个问题。多标不要紧,就是本该回收的对象这轮活下来了,叫浮动垃圾,下轮再收就行。漏标是致命的,会把还在用的对象当垃圾回收掉。漏标必须同时满足两个条件:一是把一个黑色对象指向了某个白色对象(黑色已经扫过,不会再扫),二是同时删掉了所有从灰色对象到这个白色对象的引用(没有别的路径能再走到它)。这样这个白对象就永远没机会变灰了。
解法就是打破其中一个条件。CMS 用增量更新,在写屏障里记下「黑色对象新增了引用」,把黑色重新变灰,重新扫一遍,破坏的是第一个条件。G1 用 SATB 原始快照,在写屏障里记下「被删除的那条引用」,按照标记开始那一刻的快照来算,破坏第二个条件。
代价不一样。增量更新需要在重新标记阶段重新扫描这些对象,STW 时间相对长;SATB 的重新标记阶段只需要处理那个记录队列,停顿更短,但因为按旧快照走,会多留一些浮动垃圾。G1 选 SATB 就是为了停顿时间可控,这跟它的设计目标一致。
STW 就是把所有用户线程都停下来。之所以必须在安全点停,是因为 GC 需要一个「引用关系不再变化」的一致性快照,而且要能准确知道栈上哪些位置是引用。JVM 只在特定位置生成了这些映射信息,比如方法调用、循环回跳、异常跳转这些地方,这些位置就是安全点。线程如果处于 sleep 或 blocked 状态,跑不到安全点,就用安全区域标记一下,GC 可以直接不管它。
实际排查中有个很坑的现象:GC 日志里真正的回收时间很短,但整体停顿很长,多出来的时间花在等所有线程都跑到安全点上。最典型的原因是可数循环,就是用 int 做计数的循环,JIT 认为它很快能结束,为了性能不在里面插安全点检查。如果循环体里做了大量计算,这个线程就迟迟到不了安全点,其他线程只能干等着。
定位办法是加 -XX:+PrintSafepointStatistics,或者新版用 -Xlog:safepoint,看 spin 和 block 阶段的耗时。确认是这个问题的话,把循环计数变量从 int 改成 long 就行,因为不可数循环 JIT 会老老实实插安全点检查。这题答出来加分很多,因为它明显是踩过坑才知道的。
G1 把堆切成一堆大小相等的 Region,逻辑上还分新生代老年代,但物理上不再连续。这样带来的核心能力是可以只回收一部分 Region,而且能挑「垃圾最多、收益最高」的先回收,这就是 Garbage First 的意思。有了这个,才可能实现 MaxGCPauseMillis 这种停顿时间预测模型。
触发时机上,Eden 满了触发 Young GC,回收整个新生代。老年代占用超过 InitiatingHeapOccupancyPercent(默认 45%)会启动并发标记周期,标记完进入 Mixed GC,回收全部新生代加上一部分收益高的老年代 Region。Full GC 是兜底的失败情况,早期版本还是单线程串行的,非常慢,JDK 10 之后才并行化。
跨 Region 引用靠 Remembered Set 解决。每个 Region 有自己的 RSet,记的是「谁引用了我」,这样回收这个 Region 时不用扫全堆。Card Table 是更细的粒度划分,配合写屏障把跨 Region 的引用变更记进 RSet。代价是 RSet 本身会吃掉一部分堆空间,通常百分之几到百分之十几,这也是 G1 内存占用比 CMS 高的原因。
Humongous 对象是个常见的坑:超过半个 Region 大小的对象会被单独放进连续的 Humongous Region,这类对象分配失败很容易直接触发 Full GC。所以遇到 G1 频繁 Full GC,我会先看有没有大对象,考虑把 G1HeapRegionSize 调大。
核心是把几乎所有工作都变成并发的,STW 只剩下扫描 GC Roots 这一小段,而 Roots 的数量跟堆多大基本没关系,所以停顿时间跟堆大小解耦了,几十 G 和几 T 的堆停顿差不多。
实现上靠两个东西。一是染色指针,把标记信息、是否重映射这些状态直接存在指针的高位里,不需要额外的对象头空间和 Card Table,对象一旦被回收,它的元数据也就没了。二是读屏障,程序每次读引用时都会插一段检查,发现这个引用指向的对象已经被搬走了,就当场修正指针并让程序继续,这样对象移动就不需要停顿等所有引用都改完了,可以边跑边改。
代价就是读屏障有运行时开销,吞吐量比 G1 略低一点,一般在百分之几到百分之十。另外染色指针要占用地址位,所以早期 ZGC 不支持压缩指针、最小堆也比较大,后来分代 ZGC(JDK 21 转正)把这些短板补了不少,现在小堆场景也能用了。
Shenandoah 的思路类似但用的是 Brooks 转发指针加写屏障,OpenJDK 系发行版里更常见。选型上我的看法是:延迟敏感、堆比较大就上 ZGC,追求吞吐、堆中等规模 G1 依然够用。
我的顺序是先定位到线程,再定位到代码。top 找到进程,然后 top -Hp <pid> 找到最耗 CPU 的线程,把线程 ID 转成十六进制,去 jstack 的输出里搜这个 nid,就能看到它正在执行哪段代码。如果搜到的是 GC 线程,说明 CPU 是被 GC 本身吃掉的,问题在内存;如果是业务线程,那就是死循环或者热点计算。
确认是 GC 问题的话,先 jstat -gcutil <pid> 1000 看各区占用和 GC 次数,重点看 Full GC 之后老年代能不能降下来。降不下来基本就是泄漏或者内存真的不够。然后 jmap -histo:live 快速看对象数量排行,或者 jmap -dump 出来用 MAT 分析。现在我更多直接上 Arthas,dashboard 和 thread -n 3 比一套命令敲下来快得多。
只有一次 dump 想区分泄漏和容量不足,看两点:一是对象的支配树,泄漏通常表现为某个集合持有巨量对象,而且这些对象的类型和业务上「应该临时存在」的东西对不上;二是看这些对象到 GC Root 的引用链,MAT 的 Path to GC Roots 里排除掉弱引用软引用,如果发现是静态集合、ThreadLocal、缓存这类天生长生命周期的东西持有着大量短期数据,就是泄漏。如果堆里全是正常业务对象、分布也合理,只是总量确实超了,那就是容量不够,加内存或者优化数据结构。
所以生产环境一定要提前加上 -XX:+HeapDumpOnOutOfMemoryError 和 -XX:HeapDumpPath,出事时自动留下现场,不然重启一下证据就没了。
基础几个:-Xms 和 -Xmx 设成一样,避免堆反复伸缩带来的抖动;选定收集器,比如 -XX:+UseG1GC 加一个 MaxGCPauseMillis 目标;GC 日志必须开,新版本用 -Xlog:gc*:file=...:time,uptime:filecount=10,filesize=50m 做滚动;然后 -XX:+HeapDumpOnOutOfMemoryError 加 HeapDumpPath;元空间给个上限防止无声泄漏。
容器里最关键的是内存感知。早期 JDK 8 版本读的是宿主机的内存和 CPU 核数,容器限制 2G 但 JVM 以为有 64G,按四分之一算默认堆就是 16G,结果堆还没到上限容器就先被 OOMKilled,进程直接消失,连异常都没有,特别难查。JDK 8u191 之后有了 UseContainerSupport 并默认开启,能正确读 cgroup 限制。
所以容器里我不写死 -Xmx,而是用 -XX:MaxRAMPercentage=70 这种比例的写法,这样调整容器规格时不用改启动参数。留出的那 30% 不是浪费,是给元空间、线程栈、直接内存、JIT 代码缓存这些堆外部分用的,只算堆不算堆外是容器 OOM 最常见的原因。
JMM 解决的是多线程下的可见性、有序性和原子性。根源在于 CPU 有多级缓存、编译器和处理器都会重排序,一个线程的写不保证另一个线程立刻能看到,代码的执行顺序也不一定是你写的顺序。JMM 就是一套规范,屏蔽底层硬件差异,给程序员一个统一的内存可见性保证。
happens-before 是 JMM 的核心,它定义的是「前一个操作的结果对后一个操作可见」,注意不是说执行时间上一定在前面。主要几条规则:同一线程内前面的操作先于后面(程序次序规则);解锁先于后续对同一把锁的加锁;volatile 写先于后续对该变量的读;线程 start() 先于该线程内所有操作;线程内所有操作先于其他线程通过 join() 观察到它结束;还有传递性。
as-if-serial 说的是单线程语义:不管怎么重排,单线程的执行结果不能变,所以重排序对单线程程序员是透明的。两者的关系是,as-if-serial 保证单线程正确,happens-before 在此基础上定义了跨线程的可见性边界。只要你的代码在关键位置建立了 happens-before 关系,就不用关心底层到底怎么重排。
volatile 保证什么、不保证什么,底层怎么实现的?★★★保证可见性和有序性,不保证原子性。所以 i++ 加了 volatile 依然不是线程安全的,因为它是读、改、写三步,中间可能被插入。要原子性得用 AtomicInteger 或者加锁。
实现上,字节码层面就是给字段加个 ACC_VOLATILE 标志,真正的活是 JVM 做的:在 volatile 写前面插 StoreStore 屏障、后面插 StoreLoad 屏障,在 volatile 读后面插 LoadLoad 和 LoadStore 屏障。这些屏障禁止特定方向的重排序,同时保证写立刻刷回主内存、读必须从主内存拿。
再往下到 x86,因为 x86 本身是相对强的内存模型,只有 StoreLoad 需要真正的指令,实现上用的是 lock addl $0x0, (%rsp) 这种带 lock 前缀的空操作。lock 前缀会锁总线或者缓存行,并且触发缓存一致性协议把其他核的这行缓存置为无效,这就同时达到了「刷回主内存」和「让别人看到」的效果。所以说 volatile 的可见性最终是靠 MESI 这类缓存一致性协议兜底的。
性能上 volatile 读几乎和普通读一样,写会贵一些,但比加锁便宜很多。
volatile?★★★因为 instance = new Singleton() 不是一步完成的,它至少分三步:分配内存、初始化对象、把引用指向内存。第二步和第三步可能被重排,变成先赋值引用再初始化。
一旦重排,线程 A 刚把引用赋上但还没初始化完,线程 B 走到第一个 if (instance != null) 发现不为 null,直接返回并使用,拿到的就是一个字段还是零值的半成品对象,用起来可能报空指针,而且这种 bug 概率极低、极难复现。加了 volatile 之后,写操作前后的屏障禁止了这个重排,同时保证初始化完成的结果对其他线程可见。
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第一次检查,避免每次加锁
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查,防止重复创建
instance = new Singleton();
}
}
}
return instance;
}
其实实际项目里我更愿意写静态内部类:外部类加载时不会初始化内部类,第一次访问 Holder.INSTANCE 才触发,而类初始化过程由 JVM 保证加锁互斥,所以天然线程安全又是懒加载,代码还短。要防反射和反序列化攻击就用枚举,因为反射创建枚举实例会被 newInstance 显式拒绝,反序列化也是按名字查找已有实例而不是新建。
BLOCKED 和 WAITING 有什么区别?★★六种:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。
第一个要点是 Java 的 RUNNABLE 把操作系统的「运行中」和「就绪」合并了,JVM 不区分线程是真的在 CPU 上跑还是在等 CPU 调度。更容易被忽略的是:线程在做阻塞 IO 时,状态依然是 RUNNABLE,因为 JVM 看不到操作系统层面的 IO 等待。所以 jstack 里看到一堆 RUNNABLE 的线程,不代表 CPU 真的很忙,得看栈顶在干什么——如果卡在 SocketRead 上,那其实是在等网络。这个认知在排查线上问题时很关键。
BLOCKED 和 WAITING 的区别是核心考点。BLOCKED 是在等一把 synchronized 的锁,它没有主动放弃什么,只是进不去同步块,锁一释放它就能竞争。WAITING 是主动放弃了 CPU 并且在等别人通知,典型来源是 Object.wait()、Thread.join()、LockSupport.park(),必须靠 notify、unpark 或者目标线程结束才能醒。
一个容易搞混的细节:ReentrantLock 抢不到锁时线程是 WAITING 而不是 BLOCKED,因为它底层用的是 LockSupport.park(),不是 monitor。所以看到 WAITING 别急着以为是在等通知,得结合栈判断。
还有 wait 和 sleep 的对比也常一起问:wait 是 Object 的方法、会释放锁、必须在同步块里调、靠 notify 唤醒;sleep 是 Thread 的静态方法、不释放锁、时间到自动醒。「sleep 不释放锁」这一点是很多死锁的成因。
实践价值在于看 jstack:大量 BLOCKED 说明锁竞争激烈,要找持有锁的那个线程在干什么;大量 WAITING 在同一个位置通常是线程池空闲等任务(正常)或者在等一个永远不会来的通知(异常)。
synchronized 的锁升级过程是怎样的,偏向锁为什么被移除了?★★★无锁到偏向锁到轻量级锁到重量级锁,而且只能升不能降。偏向锁的场景是「始终只有一个线程加锁」,第一次加锁把线程 ID 记进 Mark Word,之后这个线程再进来只需比对一下 ID,几乎零成本。一旦有第二个线程来竞争,撤销偏向、升级成轻量级锁,靠 CAS 加自旋来抢。自旋一定次数还抢不到,说明竞争激烈,升级成重量级锁,走操作系统的互斥量,抢不到的线程直接阻塞挂起。
这套设计的出发点是,绝大多数锁其实根本没有竞争,为无竞争场景直接上操作系统级互斥太浪费,因为线程阻塞唤醒要陷入内核态,开销很大。
偏向锁被废弃的原因是收益变了、成本没变。撤销偏向锁必须在安全点进行,要 STW 遍历栈去修改 Mark Word,成本相当高。在现在这个大量使用线程池、并发容器的时代,「一个锁只被单线程访问」的情况变少了,而一旦竞争就要付撤销代价,很多应用算下来是净亏。再加上它让 Mark Word 的状态机变复杂,维护成本高,所以 JDK 15 默认关闭、JDK 18 彻底删掉。
这也是个信号:现在写代码不用再刻意为了「触发偏向锁」而设计,新版本 JDK 上 synchronized 直接从轻量级锁起步。
一个 volatile int state 表示同步状态,加一个 CLH 变体的双向队列存放等待线程,加上模板方法模式。AQS 把「排队、阻塞、唤醒」这些通用逻辑全实现了,子类只需要定义 state 的含义以及怎么获取释放,也就是重写 tryAcquire 和 tryRelease 这几个方法。
比如 ReentrantLock 的 state 是重入次数,Semaphore 的 state 是剩余许可数,CountDownLatch 的 state 是剩余计数,ReentrantReadWriteLock 把 state 拆成高低 16 位分别表示读锁和写锁。同一套骨架,语义完全不同,这就是它设计得漂亮的地方。
流程上,线程尝试 CAS 改 state,失败就包装成 Node 入队,然后在队列里判断自己前驱是不是头节点,是就再试一次,还不行就 LockSupport.park() 挂起。释放时头节点唤醒后继。独占模式一次只放一个,共享模式会向后传播唤醒。
关于队列为什么用双向链表:因为要支持取消节点(超时或中断),取消时需要把自己从链上摘掉并让前驱接上后继,这个操作必须能找到前驱。而 Condition 的条件队列只做「按顺序转移到同步队列」这件事,不需要反向查找,所以单向就够了,省一个指针。
ReentrantLock 比 synchronized 多了什么,公平和非公平差别在哪?★★多了四个能力:可中断地获取锁(lockInterruptibly)、可超时(tryLock(time))、可选公平策略、可以有多个条件队列(多个 Condition)。代价是必须手动在 finally 里 unlock,忘了就死锁,而 synchronized 由 JVM 保证异常时也会释放。
公平和非公平的实现差别其实就一行:非公平的 tryAcquire 一上来就直接 CAS 抢 state,不管队列里有没有人在等;公平的会先调 hasQueuedPredecessors() 检查前面有没有排队的,有就老实入队。
非公平吞吐更高,因为存在这样一个时间差:持有锁的线程释放锁、唤醒队首线程,这个唤醒过程要经过内核态切换,有几微秒的空档。这时候刚好有个新线程来抢,直接就拿到了,锁一刻都没闲着。如果讲公平,这段时间锁是空转的,整体吞吐就下来了。代价是排队的线程可能一直被插队,极端情况饿死,所以默认非公平,需要严格顺序时才开公平。
StampedLock 好在哪?★★★把 32 位的 state 拆开:高 16 位记读锁的持有数量,低 16 位记写锁的重入次数。所以读锁最多 65535 个。判断有没有写锁就看低 16 位是否为 0,取读锁数量就右移 16 位。至于每个线程各自重入了多少次读锁,光靠这个数字记不下来,是用 ThreadLocal 单独存的。
写锁饥饿的问题在于,读锁是共享的,只要源源不断有读线程进来,写线程就永远等不到没有读锁的时刻。JDK 的实现里公平模式能缓解,非公平模式下会做一个判断:如果队首是想拿写锁的线程,后来的读线程就不许直接插队,算是个折中。
StampedLock 的关键改进是乐观读:tryOptimisticRead() 压根不加锁,只拿一个版本戳,读完再用 validate(stamp) 检查这期间有没有人写过,没写过就直接用,写过了才降级成真正的读锁重读一遍。读多写少的场景下,这样避免了读锁本身的 CAS 开销,性能提升明显。
它不可重入是因为它的 stamp 是基于版本号设计的,同一个线程再次获取会拿到不同的 stamp,语义上没法做重入计数。用它要特别小心两点:不支持条件变量,而且乐观读期间读到的数据可能是不一致的中间状态,所以读的过程中必须把字段先拷到局部变量,validate 通过之后才能用,不能边读边算。
LongAdder 为什么比 AtomicLong 快?★★★CAS 就是比较并交换,靠 CPU 的 cmpxchg 指令保证原子性,是无锁并发的基础。三个问题:ABA、自旋开销、只能保证一个变量的原子性。
ABA 是说值从 A 变成 B 又变回 A,CAS 检查不出中间发生过变化。很多场景下不影响,但在链式结构里会出事,比如无锁栈里节点被弹出又压回,中间指向的后继已经变了,CAS 成功但链表结构已经错了。解法是加版本号,用 AtomicStampedReference,每次修改版本号递增,比较值的同时比较版本。
自旋开销是说竞争激烈时大量线程反复失败重试,白烧 CPU。LongAdder 解决的就是这个:AtomicLong 所有线程去争同一个变量,不仅 CAS 冲突多,还有伪共享问题,多个核反复争抢同一条缓存行的所有权。LongAdder 的思路是分散热点,内部维护一个 Cell 数组,不同线程按哈希落到不同的 Cell 上各自累加,冲突时还会扩容数组,取值的时候才把 base 和所有 Cell 加起来。Cell 上还加了 @Contended 注解做缓存行填充,避免伪共享。
代价是 sum() 拿到的不是强一致的瞬时值,因为累加过程中别的线程还在改,而且内存占用比一个 long 大得多。所以高并发计数统计用 LongAdder,需要精确的 CAS 语义比如 compareAndSet 还是得用 AtomicLong。
ConcurrentHashMap 为什么放弃分段锁?★★★JDK 7 是 Segment 数组套 HashEntry 数组,每个 Segment 自己是一把 ReentrantLock,锁的粒度是一整段。JDK 8 改成了 synchronized 加 CAS,锁的粒度细化到单个桶的头节点,也就是说只要两个线程操作的不是同一个桶,就完全不互斥,并发度从「段的数量」变成了「桶的数量」,提升很大。
另外三个原因。一是 Segment 那层结构本身占内存,而且定位元素要两次哈希;二是引入红黑树后,链表过长退化的问题解决了,锁住单个桶的最坏耗时可控,不再需要靠分段来摊平;三是 JDK 6 之后 synchronized 有了锁升级优化,无竞争时开销很低,反而比 ReentrantLock 更省内存(不需要 Node 对象)。
说到 sizeCtl 这个字段,它一个变量兼了好几个职责:等于 0 表示还没初始化;负数表示正在初始化或扩容,其中 -1 是正在初始化,小于 -1 时低 16 位减 1 是参与扩容的线程数;正数表示下次扩容的阈值。用一个变量做这么多事,就是为了能用一次 CAS 完成状态流转。
扩容是可以多线程协助的,这是它设计最精彩的部分:transferIndex 从后往前给每个线程分配一段桶区间去搬迁,搬完一个桶就在原位置放一个 ForwardingNode。这个节点的作用有两个,一是告诉别的写线程「这里正在扩容,去帮忙」,二是让读线程顺着它的 nextTable 指针到新表去找,所以扩容期间读操作完全不受阻塞。
ArrayBlockingQueue 是有界数组,用一把锁加两个 Condition,读写互斥。LinkedBlockingQueue 是链表,默认容量是 Integer.MAX_VALUE 相当于无界,关键区别是它用了两把锁,读一把写一把,所以入队出队可以真正并发,吞吐更高。代价是每个元素要包装成 Node 对象,内存占用和 GC 压力都比数组版大。
SynchronousQueue 容量为 0,不存元素,put 必须等到有人 take 才能返回,本质是一个线程间的直接交接点,靠内部的栈或队列做配对。newCachedThreadPool 用的就是它,效果是任务来了必须立刻有线程接手,没有就新建。
PriorityBlockingQueue 是无界的堆,按优先级出队。DelayQueue 也是基于优先队列,元素到期才能取出,可以做延迟任务。LinkedTransferQueue 是功能最全的,transfer() 能等到消费者真的接手才返回。
DelayQueue 做定时任务的劣势在于,插入和删除都是 O(log n),任务量大的时候开销明显,而且只有一个线程在轮询队首。时间轮是把任务按到期时间散到一堆槽位里,插入删除都是 O(1),指针一格一格走,非常适合海量、短周期、精度要求不高的定时任务,比如给每个网络连接挂一个超时检测。Netty 的 HashedWheelTimer 就是这个思路。
ThreadLocal 的原理是什么,为什么会内存泄漏?★★★数据不是存在 ThreadLocal 对象里,而是存在每个 Thread 自己的 ThreadLocalMap 里,key 是 ThreadLocal 实例,value 是你的值。所以线程隔离是天然的,因为大家读写的本来就是各自的 map。
它的 map 用的是开放地址法线性探测,不是链表。原因是这个 map 通常只有很少几个 entry,用数组加线性探测更省内存也更快,不需要为链表节点额外分配对象。而且它的 key 是弱引用,配合线性探测可以在探测过程中顺手清理掉那些 key 已经被回收的过期 entry。
泄漏的链条是这样:Entry 的 key 是弱引用,ThreadLocal 对象没人引用时 key 会被回收变成 null,但value 是强引用,还挂在 map 里。如果这个线程一直活着,这个 value 就一直不能回收。线程池里的线程是复用的、几乎不销毁,所以这个问题被无限放大。JDK 虽然在 get/set 时会顺手清理一些 key 为 null 的 entry,但不保证清得干净。所以规矩就是用完必须在 finally 里 remove。
InheritableThreadLocal 只在创建子线程那一刻做一次拷贝,而线程池的线程是提前创建好复用的,任务提交时根本没有创建线程这个动作,所以传不过去。阿里的 TTL 解决办法是包装 Runnable:提交任务时把当时的上下文抓下来存进包装对象,任务真正执行前再塞进执行线程,执行完恢复原样。链路追踪的 traceId 能穿透线程池就靠这个。
核心线程数、最大线程数、空闲存活时间、时间单位、工作队列、线程工厂、拒绝策略。
流程是:来了任务先看当前线程数是否小于核心数,是就直接新建线程执行,注意即使有空闲线程也会新建,直到达到核心数;核心满了就往队列里放;队列也满了才继续新建线程直到最大线程数;最大线程数也到了,执行拒绝策略。空闲超过存活时间的非核心线程会被回收,如果设了 allowCoreThreadTimeOut,核心线程也会。
为什么是「先入队再加线程」而不是先把线程加满?因为线程是昂贵资源,创建和上下文切换都有成本,而入队几乎免费。这个顺序背后的假设是「任务是短暂的高峰」,先用队列缓冲,撑不住了再加线程作为应急,这样多数时候只需要核心线程就够跑。
这个设计有个必须知道的后果:如果你用了无界队列,队列永远不会满,最大线程数这个参数就完全失效了,永远只有核心线程在干活。Tomcat 就因为不满意这个逻辑,自己重写了队列,改成「线程没到最大就优先加线程」,因为 Web 请求是 IO 密集的,多开线程比排队更合适。
Executors 那几个快捷方法?★★★CPU 密集型设成核数加一,多的那个是为了在某个线程偶尔缺页中断或者被暂停时还有线程能顶上,再多就是纯粹的上下文切换浪费。IO 密集型可以远大于核数,理论公式是核数乘以(1 加上等待时间除以计算时间),一个线程 90% 时间在等 IO 的话,理论上能开十倍核数。
但我不会真按公式拍一个数就上线。实际做法是先按公式估个起点,然后压测看 QPS 和 RT 的拐点,同时盯住下游承受能力——很多时候线程数上限不是由本机 CPU 决定的,而是由数据库连接池或者下游服务的容量决定的,本机开一百个线程把下游打挂了毫无意义。
Executors 被禁主要是 OOM 风险。newFixedThreadPool 和 newSingleThreadExecutor 用的是无界 LinkedBlockingQueue,任务堆积时队列无限涨,直接堆内存 OOM,而且这个过程是无声的,你看不到任何拒绝,只看到延迟越来越大直到进程挂掉。newCachedThreadPool 反过来,最大线程数是 Integer.MAX_VALUE,任务一多就疯狂创建线程,最后报 unable to create new native thread。newScheduledThreadPool 同样是无界队列。
所以规范是手动 new ThreadPoolExecutor,队列必须有界,拒绝策略明确,线程工厂给个有业务含义的名字。最后这点很实用:线程名带上业务标识,出问题时 jstack 一眼就能看出是哪个池的线程卡住了,用默认的 pool-1-thread-5 根本没法定位。
因为用了 submit。submit 会把任务包装成 FutureTask,任务里抛出的异常被 catch 住存进 FutureTask 的 outcome 字段里了,只有你调用 get() 的时候才会以 ExecutionException 的形式重新抛出来。如果代码里从来不调 get(),这个异常就被彻底吞掉,日志里一片安静,任务默默失败。这是我见过最多的线上隐性故障之一。
execute 不一样,异常会一路抛到线程的 run 方法外面,触发 UncaughtExceptionHandler,默认行为是打印到标准错误输出,同时这个线程会终止,线程池再补一个新的。
所以 UncaughtExceptionHandler 在 submit 下不生效,就是因为异常压根没抛出来,被 FutureTask 拦下了。
实践上我的处理是:不关心结果就用 execute;用 submit 就一定要处理返回的 Future。更稳妥的做法是在任务的最外层自己包一个 try-catch 把异常记下来,不依赖框架。另外还可以重写线程池的 afterExecute 方法统一兜底,那里能同时拿到 execute 抛出的异常和从 Future 里取出的异常。
shutdown 和 shutdownNow 的区别,怎么优雅停机?★★shutdown 是温和的:不再接受新任务,但已提交的(包括队列里排队的)都会执行完。shutdownNow 是强硬的:清空队列并把未执行的任务作为返回值给你,同时对正在执行的线程发中断信号。注意中断只是信号,如果任务代码里没有响应中断,它照样会跑到自然结束。
优雅停机的标准套路是先 shutdown(),然后 awaitTermination 等一个合理的时间,超时了再 shutdownNow() 兜底:
@PreDestroy
public void destroy() {
pool.shutdown();
try {
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
pool.shutdownNow(); // 超时强制中断
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt(); // 别忘了恢复中断状态
}
}
光有这段还不够。容器环境下要保证 Kubernetes 的 terminationGracePeriodSeconds 大于你这里等待的时间,否则容器先被 SIGKILL 了,代码根本没机会跑完。另外要配合先从注册中心摘除流量、再停机,不然停机过程中还有新请求打进来。Spring Boot 的 server.shutdown=graceful 处理的是 Web 容器那一层,自建的线程池得自己管。
CompletableFuture 几个方法怎么选,异常怎么处理?★★★thenApply 是拿到结果做转换,返回普通值。thenCompose 用在「转换函数本身又返回一个 CompletableFuture」的场景,作用是拍平嵌套,相当于 flatMap,不然你会得到一个套一层的 Future。thenCombine 是等两个独立的任务都完成,把结果合并。带 Async 后缀的区别是在哪个线程执行:不带的可能在上一步的完成线程里直接跑,带的会重新提交到线程池。
不指定线程池默认走 ForkJoinPool.commonPool(),这是个全 JVM 共享的池,默认并行度是核数减一。风险很明确:你的阻塞 IO 任务会把这个公共池占满,连带影响所有用到它的地方,包括并行流。所以业务代码里我一定显式传自己的线程池,这是硬性习惯。
异常处理上,exceptionally 只在出异常时触发,用来提供兜底值;handle 不管成功失败都会执行,两个参数一个是结果一个是异常;whenComplete 类似 handle 但不能改变结果,适合做日志或清理。异常会沿着链条往后传,中间某一步失败,后面的 thenApply 都会跳过直接传到异常处理节点。
allOf 返回的是 CompletableFuture<Void>,拿不到结果,要自己在它完成之后遍历原来那些 future 逐个 join——此时它们都已完成,join 不会阻塞。还有个坑:allOf 在任意一个失败时才抛异常,如果你想「有一个失败就整体失败并快速返回」,得配合 anyOf 或者手动处理。
解决的是「高并发 IO 场景下线程太贵」的问题。平台线程一比一映射到操作系统线程,每个要占 1M 左右栈空间,创建和上下文切换都要走内核,所以只能开几千个。但 Web 服务的线程大部分时间在等数据库、等下游,纯粹是浪费。以前的解法是异步响应式编程,用少量线程压榨吞吐,代价是代码全变成回调链,调试和排错极其痛苦,栈都看不明白。
虚拟线程的思路是把线程做成 JVM 层面的轻量对象,创建成本极低,可以开几十万上百万个。它需要跑的时候被挂载到一个平台线程(载体线程)上执行,一遇到阻塞操作就自动卸载,把栈拷到堆里,让载体线程去跑别的虚拟线程,等 IO 就绪了再重新挂载回来继续。载体线程由一个专门的 ForkJoinPool 调度,默认并行度是核数。
关键价值在于,这套切换是 JVM 在底层自动做的,你的代码还是那个熟悉的同步阻塞写法,一行都不用改成回调,但拿到了接近响应式的吞吐。所以它和 Reactor 是竞争关系,目标是让大多数场景不再需要响应式编程;和 Kotlin 协程思路很像,区别是协程靠编译器做状态机变换、需要 suspend 标记,虚拟线程在运行时层面做,对代码完全透明。
要强调的是它只优化 IO 密集场景,CPU 密集的活该多久还是多久,因为最终还是那几个核在算。
pinning 是指虚拟线程被「钉」在载体线程上没法卸载。这时候如果它又去做阻塞操作,载体线程就跟着一起被阻塞,等于退化成了平台线程,还白搭一层调度开销。最坏情况是所有载体线程都被钉住阻塞,整个应用卡死。
早期 JDK 21 有两个主要原因会导致 pinning:一是在 synchronized 块或方法里执行阻塞操作,二是调用了 native 方法或者外部函数。原因是 synchronized 的实现依赖操作系统线程的身份,monitor 记的是载体线程,虚拟线程一卸载这个对应关系就乱了,所以 JVM 干脆不让它卸载。
所以当时的建议是把 synchronized 换成 ReentrantLock,因为它是纯 Java 实现的、基于 AQS 和 LockSupport.park,而这套机制已经被适配过,park 一个虚拟线程会正确触发卸载。这个建议对老代码很不友好,因为 synchronized 到处都是,包括很多第三方库里。
这块后来在 JDK 24 通过 JEP 491 解决了,synchronized 里阻塞也能正常卸载,不再需要为了虚拟线程去改锁。不过 native 方法调用造成的 pinning 依然存在。排查手段是加 -Djdk.tracePinnedThreads=full,能把被钉住的栈打出来。
没有匹配的题目,换个关键词试试。
模块 01 · 共 35 题 · 题目与答案分离,建议先自答再展开