Java后端核心知识因果链:JVM内存、GC、类加载与并发容器

发布时间:2026/11/2 3:45:22
Java后端核心知识因果链:JVM内存、GC、类加载与并发容器 简介面向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:SurvivorRatio8控制。绝大多数对象在 Eden 分配Eden 满了触发 Minor GC存活对象被复制到 Survivor。这个设计的核心假设是「绝大多数对象活不过一次 GC」所以给存活对象准备的空间只需要 10%。为了减少多线程在 Eden 上分配对象时的指针碰撞竞争主流 JVM 实现会给每个线程预分一块私有区域也就是 TLABThread Local Allocation Buffer。对象优先在 TLAB 里分配TLAB 用完再申请新的只有大对象才直接在共享 Eden 上分配。这是一个典型的用空间换同步开销的取舍——每个线程浪费一点内存换来分配路径上几乎不需要 CAS。逃逸分析则是另一层优化如果一个对象只在方法内部使用、没有被外部引用JIT 可以把它拆成若干标量放在栈上标量替换或者直接消除锁。所以「对象一定分配在堆上」这句话在开启逃逸分析后已经不严格成立它更多是语义上的说法。2.4 两种崩法的边界看参数、看栈轨迹、看能不能恢复public class HeapOom { public static void main(String[] args) { java.util.Listbyte[] 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 SurvivorFrom 和 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 的分区思想把连续内存切成 RegionG1 的突破点在于不再要求新生代和老年代在物理上连续。整个堆被切成大小相等的 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里并发 putCPU 打到 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 synchronizedConcurrentHashMap的两代实现是观察「锁粒度」这个设计维度最好的样本。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 之后大概也是同样的用意——先把内存和回收的账算明白再看容器怎么设计顺序反了就只能死记结论。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询