简介:面向Java后端求职者与初中级开发者的核心知识点梳理PDF,围绕面试常考的「八股文」体系组织内容,适合在简历投递前系统复盘,也可作为日常查阅手册。全包仅含1个PDF文件,压缩后约10.71MB,正文以目录加章节的形式编排,从第19页起进入JVM专题:先讲类加载器、执行引擎与运行时数据区的整体架构,再逐项拆解程序计数器、虚拟机栈、本地方法区、堆与方法区这五块线程私有或共享的区域,并延伸到新生代Eden、SurvivorFrom、SurvivorTo与老年代、永久代(Java 8元数据)的划分,配合MinorGC「复制—清空—互换」流程说明对象的晋升过程。目录层级编号清晰,便于按考点跳转定位,也可顺着章节顺序通读,建立知识框架。目前已有650人学习下载,适合准备Java后端面试、需要快速补齐JVM内存模型与垃圾回收基础认知的读者。
1. Java 后端面试的八股文:为什么背下来的结论一追问就散
「新生代用复制算法,因为对象朝生夕死」——这句话在 Java 开发语言的后端面试里出现频率极高,背下来不难,被追问一句「那为什么非要两块 Survivor、比例还得是 8:1:1」时卡住的人也不少。复制算法的成本是「存活对象数量 × 拷贝开销」,只有存活率极低时它才比标记清除划算;8:1:1 的布局,本质是拿「闲置 10% 的新生代空间」去换「零内存碎片 + 极低拷贝量」。这份 Java 核心知识整理的价值不在于把结论列全,而在于它按运行时内存 → 回收算法 → 收集器 → 类加载 → 集合与并发的顺序编排,让每条结论都挂在前一条的因果链上。下面从最容易背错的一处讲起。
2. 运行时数据区:为什么栈溢出能捕获,堆溢出却常常救不回来
同一个OutOfMemoryError,写法上只差一个单词,实际处理起来是两种命运。StackOverflowError抛出时,出问题的那个线程的栈已经回退,异常可以被catch住,进程还能继续跑;而java.lang.OutOfMemoryError: Java heap space抛出时,往往是整个堆已经被强引用占满,业务线程即使捕获了异常,也没有多余空间去做日志、序列化现场或者降级返回。要理解这个差别,得先看清楚 JVM 把内存切成了哪几块、每一块归谁管、什么时候被回收。
2.1 线程私有与线程共享:划分依据是生命周期,并非重要程度
程序计数器被划成线程私有,是因为它存的是「当前线程执行到哪条字节码」的行号指示器。CPU 时间片轮转后线程要能从中断处恢复,这个位置就必须跟着线程走,而不能放在共享区让别的线程覆盖。分支、循环、异常跳转、线程切换恢复,全靠这块极小的内存,它也是运行时数据区里唯一不会抛OutOfMemoryError的区域。
虚拟机栈同理。一个线程的方法调用链是它自己独有的,A 线程在orderService.create()里调了五层,B 线程在userService.query()里调了三层,这两条调用链没有任何共享的必要。所以栈帧(局部变量表、操作数栈、动态链接、方法返回地址)随线程创建而分配,随线程结束而销毁。本地方法栈服务的是 native 方法,机制类似,只是栈帧内容由本地方法决定。
堆和方法区则是共享的。堆存对象实例和数组,方法区存类的结构信息、运行时常量池、静态变量、JIT 编译后的代码。共享的理由不是「重要」,而是「只有一份才正确」——同一个类的Class对象在整個 JVM 里必须唯一,否则synchronized锁类、静态变量、反射比较全都会失效。
2.2 栈帧:一次方法调用在内存里留下了什么
局部变量表按槽位(slot)分配,int、reference、returnAddress占一个槽,long、double占两个。编译期就确定了最大槽位数,所以栈帧大小在方法进入时是固定的,这也是为什么递归深度可以被估算。操作数栈是执行字节码时的工作区,iadd这类指令就是从操作数栈弹出两个数、压回一个结果。
栈的大小由-Xss决定。默认值在不同平台和位数上不一样,常见是 512KB 到 1MB。1MB 的栈大概能撑几千个普通栈帧,帧越大(局部变量越多、参数越多)能撑的层数越少。这解释了为什么同样一段递归,在本地跑得好好的,挪到线程栈更小的环境里就崩。
public class StackDepth { private int depth = 0; // 每次调用都会在当前线程的虚拟机栈上压入一个新栈帧,且没有终止条件 public void dive() { depth++; dive(); } public static void main(String[] args) { StackDepth s = new StackDepth(); try { s.dive(); } catch (StackOverflowError e) { // 栈深度超限,错误可被捕获,进程不会立刻退出 System.out.println("max depth = " + s.depth); } } }这段代码里,dive()每调用一次就压一个栈帧,直到超过-Xss限制。注意depth是成员变量而不是局部变量——如果写成局部变量,每帧都要多占一个槽,能压的层数会明显变少。捕获到StackOverflowError之后,栈已经回退,可以继续打印、继续处理下一个请求,这是它和堆溢出最本质的区别。调参的时候把-Xss调大能撑更深,但每个线程都要独占这么大的栈空间,线程数一多,总内存反而先炸,这是典型的血泪经验。
2.3 堆:分配、TLAB 与逃逸分析
堆被进一步切成新生代和老年代,新生代内部再分成 Eden 和两块 Survivor,默认比例 8:1:1,由-XX:SurvivorRatio=8控制。绝大多数对象在 Eden 分配,Eden 满了触发 Minor GC,存活对象被复制到 Survivor。这个设计的核心假设是「绝大多数对象活不过一次 GC」,所以给存活对象准备的空间只需要 10%。
为了减少多线程在 Eden 上分配对象时的指针碰撞竞争,主流 JVM 实现会给每个线程预分一块私有区域,也就是 TLAB(Thread Local Allocation Buffer)。对象优先在 TLAB 里分配,TLAB 用完再申请新的,只有大对象才直接在共享 Eden 上分配。这是一个典型的用空间换同步开销的取舍——每个线程浪费一点内存,换来分配路径上几乎不需要 CAS。
逃逸分析则是另一层优化:如果一个对象只在方法内部使用、没有被外部引用,JIT 可以把它拆成若干标量放在栈上(标量替换),或者直接消除锁。所以「对象一定分配在堆上」这句话在开启逃逸分析后已经不严格成立,它更多是语义上的说法。
2.4 两种崩法的边界:看参数、看栈轨迹、看能不能恢复
public class HeapOom { public static void main(String[] args) { java.util.List<byte[]> keep = new java.util.ArrayList<>(); while (true) { // 1MB 的数组,且被 keep 强引用持有,GC 无法回收 keep.add(new byte[1024 * 1024]); } } }配合-Xmx64m跑这段代码,几秒内就会抛OutOfMemoryError: Java heap space。堆里每一个 1MB 数组都还被ArrayList的elementData强引用着,可达性分析判定它们全部存活,回收器无能为力。这里能做的只有加大-Xmx、或者用-XX:+HeapDumpOnOutOfMemoryError在崩溃瞬间把堆 dump 下来,事后用分析工具定位到底是谁在持有。
提示:线上排查先看错误类型。
Java heap space指向对象过多或泄漏,Metaspace指向动态生成类过多,unable to create new native thread指向线程数超限或栈空间不足,三者方向完全不同。
两个参数值得记住:-Xms和-Xmx设成相同值,避免堆反复伸缩带来的额外停顿;-XX:MaxMetaspaceSize给元空间设上限,防止动态生成类把机器内存吃光。至于能不能恢复——栈溢出可以,堆溢出基本只能靠重启加事后分析,这就是第 1 章说的「一追问就散」的地方:结论背下来容易,知道它在什么条件下才成立,才是真正理解。
3. 分代回收:MinorGC 的「复制—清空—互换」到底在换什么
GC 这块最容易被背成名词表:标记清除、复制、标记整理、分代收集,四个词并列摆着,看不出谁依赖谁。实际上它们是一条决策链——先解决「谁是垃圾」,再解决「怎么收才不产生碎片」,最后解决「怎么收才不让停顿时间长到业务不可接受」。这条链上的每一步都有明确的取舍,取舍的理由才值得记。
3.1 判定垃圾:引用计数为什么过不了循环引用这一关
引用计数的思路最直观:每个对象维护一个计数器,被引用加一,引用失效减一,减到零就回收。它的优点是回收时机确定、不用暂停整个应用;缺点是两个:一是每次赋值都要维护计数,如果多线程并发修改还得保证原子性,开销不小;二是无法处理循环引用。
class Node { Node next; } public class CycleRef { public static void main(String[] args) { Node a = new Node(); Node b = new Node(); a.next = b; b.next = a; // a 和 b 互相引用,形成环 a = null; b = null; // 从 GC Roots 出发已经不可达,但两者计数仍为 1 System.gc(); // 只是建议,不保证立即执行 } }如果是引用计数,a和b的计数始终为 1,即使外部已经没有任何引用指向它们,内存也收不回来。可达性分析换了个角度:从一组 GC Roots 出发做图遍历,走不到的对象就是垃圾。GC Roots 包括虚拟机栈中局部变量表里的引用、方法区中静态变量和常量引用的对象、本地方法栈中 JNI 引用的对象、活跃线程、同步锁持有的对象等。环的问题自然消失——遍历进不去的节点,不管内部怎么连,一律回收。
代价是必须暂停用户线程才能做一致性快照,否则遍历过程中对象引用关系还在变,会漏标或错标。这就引出了后面的停顿问题。
3.2 Eden 与两块 Survivor:三步操作与 8:1:1 的账
新生代的回收过程可以拆成三步:
- 把 Eden 和 From Survivor 中仍然存活的对象复制到 To Survivor,同时把对象的年龄加一;
- 清空 Eden 和 From Survivor;
- From 和 To 互换角色。
为什么需要两块 Survivor 而不是一块?因为复制算法要求「目标区域在复制期间不能被同时写入」。如果只有一块 Survivor,回收时 Eden 的存活对象往 Survivor 里拷,同时这块 Survivor 本身可能还有上一轮留下的对象,空间怎么算都会乱。用两块交替,保证总有一块是干净的空地。
8:1:1 的账是这样算的:假设新生代 10 份,Eden 占 8 份,两块 Survivor 各占 1 份。一次 Minor GC 时,可用的复制目标是那 1 份空的 Survivor。如果存活对象超过 1 份,就得靠「空间分配担保」——老年代必须保证有足够空间接收放不下的对象,否则退化成 Full GC。所以「存活率极低」不是一句定性描述,它是一道硬约束:存活对象的大小不能超过一个 Survivor 的容量。真实业务里出现「对象突然大批存活」,往往就是从这道约束被突破开始的。
3.3 晋升:年龄阈值、动态判定与大对象
对象在 Survivor 之间每复制一次,年龄加一,默认加到 15 就进老年代,由-XX:MaxTenuringThreshold控制。但不是非要等到 15——HotSpot 有一个动态年龄判定:如果 Survivor 中所有同龄对象的大小总和超过这块 Survivor 的一半,那么年龄大于等于该年龄的对象直接晋升。这条规则的目的是避免 Survivor 被慢慢填满,宁可提前把「看起来会活很久」的对象挪走。
另外,超过-XX:PretenureSizeThreshold的大对象直接在老年代分配,避免在 Eden 和 Survivor 之间反复复制大块内存。这个参数只对 Serial 和 ParNew 这类收集器生效,因为并行收集器判断大对象的逻辑不一样。
老年代的对象存活率高、没有备用空间可以做复制,所以只能走标记整理或标记清除。这就是分代收集的本质:不同区域的对象存活率不同,用同一种算法必然是次优解。
3.4 从标记清除到 CMS 四阶段:并发换来的浮动垃圾
标记清除(Mark-Sweep)先标记所有存活对象,再统一清除未标记的,缺点是产生大量不连续碎片,碎片多到放不下一个大对象时还得提前触发一次 GC。标记整理(Mark-Compact)在清除后把存活对象往一端挪,解决碎片,代价是移动对象要更新所有引用,停顿更长。复制算法在前面已经说过,适合存活率低的新生代。
老年代早期用的是 Serial Old(单线程标记整理)和 Parallel Old(多线程标记整理),真正的转折点是 CMS。CMS 把整个过程拆成四段:
| 阶段 | 是否暂停用户线程 | 主要动作 |
|---|---|---|
| 初始标记 | 是,极短 | 只标记 GC Roots 直接关联的对象 |
| 并发标记 | 否 | 从直接关联对象出发遍历整个对象图 |
| 重新标记 | 是,较短 | 修正并发标记期间因用户线程继续运行而变动的记录 |
| 并发清除 | 否 | 清除不可达对象,不移动存活对象 |
并发标记和并发清除两段不暂停用户线程,这是 CMS 能把停顿压下来的原因。代价有三个:一是并发期间用户线程还在产生新垃圾,这些垃圾本轮收不掉,只能留到下一次,也就是「浮动垃圾」;二是并发清除不移动对象,碎片问题依旧存在;三是如果并发期间老年代剩余空间不够,CMS 来不及等下一轮,就会退化成 Serial Old 做一次全程停顿的回收,日志里表现为Concurrent Mode Failure。生产中看到这个词,基本等于一次秒级以上的卡顿。
3.5 G1 的分区思想:把连续内存切成 Region
G1 的突破点在于不再要求新生代和老年代在物理上连续。整个堆被切成大小相等的 Region(通常 1MB 到 32MB),每个 Region 在逻辑上归属新生代或老年代,但位置可以打散。回收时 G1 维护一个优先列表,按「回收收益 / 停顿成本」排序,优先回收垃圾最多的 Region,并受-XX:MaxGCPauseMillis约束——这就是所谓的可预测停顿模型。
跨 Region 引用靠 Remembered Set 记录,避免每次回收都要扫全堆;并发标记阶段用 SATB(原始快照)来处理引用变动,保证不漏标。从 CMS 的「增量更新」到 G1 的「SATB」,本质是为了在并发标记期间用不同方式记录「哪些引用变了」,一个偏向处理新增引用,一个偏向保留旧快照,选哪个取决于收集器整体的设计目标。
四种引用类型也是同一套逻辑的延伸:强引用是默认的,只要可达就不回收;软引用在内存不足时被回收,适合做缓存;弱引用下一次 GC 就回收,ThreadLocal的 Entry 和WeakHashMap都靠它;虚引用的唯一用途是在对象被回收时收到通知,通常配合引用队列做资源清理的兜底。
4. 类加载:双亲委派为什么能保证「同一个类只加载一次」
「双亲委派」被背成「先给父加载器,父加载器加载不了再自己加载」,这话没错,但它只描述了顺序,没解释为什么这个顺序不能反。要回答这个问题,得先知道「加载」到底产出了什么,以及「同一个类」在 JVM 里是怎么被定义的。
4.1 五个阶段:真正执行代码的是最后一步
类加载分加载、验证、准备、解析、初始化五步。
加载阶段做三件事:通过类的全限定名获取二进制字节流(可以来自文件、网络、动态代理生成)、把字节流代表的静态存储结构转成方法区的运行时数据结构、在堆中生成代表这个类的Class对象,作为方法区数据的访问入口。注意,数组类不通过类加载器创建,由 JVM 直接生成。
验证阶段确保字节流符合规范,包括文件格式验证、元数据验证、字节码验证、符号引用验证。这一阶段是可以被-Xverify:none关闭的(不推荐,会跳过关键检查)。
准备阶段给静态变量分配内存并设零值。这里有一个常被忽略的细节:public static int value = 123;在准备阶段之后value是 0,赋 123 的动作在初始化阶段才执行。但如果加了final,编译期就已经把常量值写进常量池,准备阶段直接赋 123。
解析阶段把常量池里的符号引用替换成直接引用。符号引用是一组字面量,比如类的全限定名、字段名和描述符;直接引用是直接指向方法区中目标的内存地址、偏移量或句柄。解析时机可以延迟到第一次真正使用某个符号引用时,这也是为什么某些类加载错误要到运行很久之后才暴露。
初始化阶段执行类构造器<clinit>(),由编译器自动收集类中所有静态变量的赋值动作和静态代码块合并生成。JVM 保证父类的<clinit>先于子类执行,接口除外(接口不要求先执行父接口的<clinit>,只有用到父接口中定义的静态变量时才初始化)。并且 JVM 会给<clinit>加锁,保证多线程下只执行一次。
class Parent { static { System.out.println("parent init"); } static int p = 1; } class Child extends Parent { static { System.out.println("child init"); } static int c = p + 1; // 触发 Parent 的 <clinit> } public class InitOrder { public static void main(String[] args) { System.out.println(Child.c); // 输出顺序:parent init -> child init -> 2 } }这段代码能看到两件事:父类初始化一定在子类之前;Child.c的初值依赖Parent.p,所以读Child.c会先触发父类初始化。如果改成Child.c换成Child中定义的编译期常量(static final int C = 2;),则连Child的<clinit>都不会触发,因为常量在编译期已经被内联到调用方。
4.2 双亲委派的实现:那几行代码的顺序为什么不能反
ClassLoader.loadClass()的逻辑简化后大致是这样:
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 按类名加锁,避免并发重复加载 Class<?> c = findLoadedClass(name); // 1. 先查本地是否已加载 if (c == null) { try { if (parent != null) { c = parent.loadClass(name, false); // 2. 委派给父加载器 } else { c = findBootstrapClassOrNull(name); // 3. 父为空则交给启动类加载器 } } catch (ClassNotFoundException ignored) { // 父加载器找不到,继续往下走 } if (c == null) { c = findClass(name); // 4. 父加载器都找不到,才自己加载 } } if (resolve) { resolveClass(c); // 链接阶段,可延迟 } return c; } }关键点在第 1 步和第 4 步的位置。先查已加载,保证了同一个加载器对同一个类名只产出一次Class对象;先委派再自己加载,保证了「核心类永远由启动类加载器加载」。如果没有这层委派,任何人都可以写一个java.lang.String放到 classpath 里,加载后就能拿到一个假的String类,所有依赖String的安全检查全部失效。
JVM 判定两个类是否相同,条件是「类的全限定名相同 且 定义它的类加载器相同」。这两个条件缺一不可——同一个.class文件被两个不同的加载器加载,得到的两个Class对象互不相等。这正是容器隔离、热部署能工作的基础,也是很多人排查ClassCastException: xxx cannot be cast to xxx时想不通的地方:类名一模一样,加载器不是同一个。
4.3 打破双亲委派:SPI、容器隔离与热替换
双亲委派不是铁律,至少有三种场景必须打破它。
第一种是 SPI,典型是 JDBC 的驱动加载。核心接口java.sql.Driver在启动类加载器的范围内,但驱动实现(比如某个数据库的驱动 jar)在应用 classpath 上,启动类加载器根本看不到。解决办法是用线程上下文类加载器(Thread.currentThread().getContextClassLoader())去加载实现类,等于父加载器反过来请求子加载器。这是「父加载器委派给子加载器」的经典反例。
第二种是容器隔离。某个 Web 应用服务器给每个应用分配独立的类加载器,应用之间的同名类互不干扰,即使全限定名相同也各自加载一份。这时加载顺序往往变成「先自己找,找不到再委派给父」,与默认顺序相反。
第三种是热替换。OSGi 的思路是把每个模块做成独立单元,模块可以动态安装、卸载、更新。卸载一个模块时,把它对应的类加载器整个丢掉,它加载的所有类随之变得不可达,被 GC 回收。要实现这个效果,必须让模块的类加载器不遵守默认委派,否则类会挂在父加载器上,永远卸不掉。
要保留双亲委派,重写findClass();要打破双亲委派,才重写loadClass()。这个区分是排查类加载问题的分水岭,写错一个方法,结果可能是「类莫名其妙加载了两次」或者「补丁打上去没生效」。
注意:自定义加载器如果不重写
findLoadedClass的保护逻辑,或者命名空间设计不当,很容易出现同一个类被并行加载多个副本,进而引发静态变量状态不一致这种玄学问题。
5. 排查:五个背得出却答不对的常见误区
面试里的坑,多数不是「完全不知道」,而是「背了一个过去成立、现在不成立的结论」。下面五条都在这份整理覆盖的范围内,但也是线上排查时最容易走错方向的地方。
5.1 把方法区等同于永久代:Java 8 之后这块内存去了哪里
现象:线上报java.lang.OutOfMemoryError: Metaspace,有人翻出旧笔记说「永久代溢出,调大-XX:MaxPermSize」,改完重启发现参数根本不被识别。
原因:方法区是 JVM 规范里的逻辑概念,永久代是 HotSpot 在 Java 8 之前对方法区的一种实现,用的是堆内内存,受-XX:MaxPermSize限制。Java 8 起永久代被移除,方法区改由元空间实现,用的是本地内存,对应的参数变成-XX:MetaspaceSize和-XX:MaxMetaspaceSize。字符串常量池也从方法区挪到了堆里。
解决:先用jstat -gcutil <pid>或者对应的 JVM 监控看 Metaspace 的已用比例,再确认是不是有大量动态生成类(反射、动态代理、字节码增强框架)没有释放。-XX:MaxMetaspaceSize必须设一个上限,否则元空间会一直吃机器内存,直到操作系统层面被干掉,这种现场比堆溢出难查得多。
5.2 认为 System.gc() 一定能触发 Full GC
现象:代码里手动调了System.gc(),以为能立刻把大对象清掉,结果内存曲线没有任何变化。
原因:System.gc()只是向 JVM 发出「建议做一次 GC」的请求,是否执行、执行哪种回收,由收集器决定。如果启动参数带了-XX:+DisableExplicitGC,这个调用会被直接忽略;在 CMS 下它大概率触发一次 Full GC,但在 G1 下行为又不一样。把它当成资源释放手段,本身就是误用。
解决:不要依赖显式 GC 做业务逻辑。真要处理大对象释放,应该断开强引用(把集合置空、把字段置 null),让可达性分析自然判定为垃圾。如果怀疑 DirectByteBuffer 这类堆外内存没释放,那是 Cleaner 机制的问题,跟System.gc()也不是一回事——-XX:+DisableExplicitGC反而可能让堆外内存回收被推迟,这是一个隐藏的坑。
5.3 以为 finalize 是可靠的资源回收钩子
现象:某个连接对象重写了finalize()来关闭句柄,压测一段时间后句柄数持续上涨。
原因:finalize()的执行时机完全不确定,它依赖 Finalizer 线程,而这个线程优先级低、执行慢。对象被判定不可达后要先进入 F-Queue,等待 Finalizer 线程调用finalize(),调用完还要再经历一次可达性分析才能真正回收。高峰期对象产生速度远大于 Finalizer 线程的处理速度,队列积压,句柄自然泄漏。更糟的是,finalize()里如果重新让对象可达,这个对象就「复活」了。
解决:资源释放走try-with-resources或者显式的close(),finalize()只适合做最后的兜底告警,不适合做主力清理。Cleaner机制也是同样的定位,不要把它当成确定性的析构函数。
5.4 觉得 HashMap 扩容只影响容量
现象:多线程环境下往HashMap里并发 put,CPU 打到 100%,线程栈里全是HashMap.resize,重启后无法复现。
原因:Java 7 的HashMap在扩容时用头插法把旧链表元素迁移到新数组,并发情况下两个线程同时 resize,可能让链表节点互相指向,形成环形链表。之后任何一次get落到这个桶上,就会在环里无限循环。Java 8 改成了尾插法,并且引入红黑树,环的问题基本消失,但并发 put 依然可能丢数据、size 计算不准。
解决:并发场景一律用ConcurrentHashMap,不要指望「读多写少所以 HashMap 也还行」。单线程场景下,扩容的另一个影响是 rehash 成本——初始容量要给够,避免反复扩容。用new HashMap<>(expectedSize / 0.75f + 1)这种写法预估容量,比事后调优省事得多。
5.5 把 NIO 的非阻塞当成异步
现象:用了Selector,以为网络 IO 就不占线程了,压测时发现吞吐上不去,线程 dump 里大量线程卡在业务处理上。
原因:NIO 的「非阻塞」指的是read/write不会因为数据没准备好而阻塞调用线程,Selector做的是多路复用——一个线程可以监听多个 Channel 的就绪事件。但数据就绪之后的读取、解码、业务处理,仍然是在这个线程上同步执行的。它解决的是「连接数多但活跃度低」时的线程浪费问题,不是「把工作丢给别的线程去做」。
解决:如果单连接数据量大、处理耗时长,纯 Reactor 单线程会成为瓶颈,需要主从 Reactor 或者把业务处理丢给独立线程池。如果是连接数不多但每个连接都很繁忙,多路复用反而增加复杂度,传统阻塞 IO 配线程池可能更简单。选型要看连接数和活跃比例,不要看哪个词更新。
6. 锁粒度收缩:ConcurrentHashMap 为什么从分段锁走向 CAS + synchronized
ConcurrentHashMap的两代实现,是观察「锁粒度」这个设计维度最好的样本。
Java 7 用 Segment 分段锁:整个表切分成若干个 Segment,默认 16 个,每个 Segment 继承ReentrantLock,内部是一个HashEntry数组。写操作只锁住目标 Segment,不同 Segment 之间可以并行。并发度就是 Segment 的数量,理论上最多 16 个线程同时写。这个设计的取舍很清楚:用固定的分段数换来了写并行,代价是内存上多了一层结构,而且并发度一旦设定就不好改,16 这个数字在写多场景下很容易成为天花板。
Java 8 换了个思路:去掉 Segment,直接用Node数组,锁的粒度降到「单个桶的头节点」。写操作先看目标桶是不是空的,空的话用 CAS 直接放进去,不加锁;桶非空才synchronized锁住头节点,然后遍历链表或红黑树。这样一来,只要哈希散得开,不同桶的写操作几乎不冲突,并发度不再受一个固定数字限制。
| 维度 | Java 7 分段锁 | Java 8 CAS + synchronized |
|---|---|---|
| 锁对象 | Segment(默认 16 个) | 单个桶的头节点 |
| 并发度 | 等于 Segment 数,固定 | 随桶数量变化,理论上更高 |
| 空桶写入 | 需要加锁 | CAS,无锁 |
| 冲突退化 | 链表 | 链表长度到阈值转红黑树 |
| size 统计 | 先尝试两次不加锁,再全表加锁 | baseCount + CounterCell 分散计数 |
这张表里最值得琢磨的是空桶写入走 CAS 这一行。它背后的判断是:绝大多数 put 落在空桶或者短链表上,为这种情况加一次锁不值得。只有真正发生哈希冲突时才升级到synchronized。而synchronized在经历过锁消除、锁粗化、轻量级锁这些优化之后,在低竞争场景下的开销已经和 CAS 相差不大,这是敢把锁粒度做到这么细的前提。
粒度变小不是没有代价。CAS 失败要重试,高竞争下重试次数上升,CPU 空转;size()的统计从「加锁算一次」变成分散计数再汇总,弱一致性更明显;红黑树的维护代码复杂度远高于链表。所以这是一个典型的「读多写少 + 哈希分散」场景下的最优解,换成写极度集中、哈希质量差的场景,收益会被吃掉大半。
我自己的习惯是,评估任何并发容器之前,先问清楚三件事:读写比例是多少、key 的哈希分布是否均匀、对 size 的实时性要求有多高。这三个答案决定的不只是选哪个类,还决定了初始容量、负载因子这些参数怎么给。有一次线上接口在高峰期出现毛刺,查了两天才发现是初始容量给小了导致反复扩容,从那以后我每次新建容器都强制走一遍容量估算,哪怕看起来有点小题大做。这份整理里把集合和并发放在 JVM 之后,大概也是同样的用意——先把内存和回收的账算明白,再看容器怎么设计,顺序反了就只能死记结论。希望帮到你。
本文还有配套的精品资源,点击获取