ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java后端面试知识地图:JVM内存、GC与集合框架核心梳理

Java后端面试知识地图:JVM内存、GC与集合框架核心梳理

简介:《Java核心知识整理(常考八股文)》面向准备Java后端面试和系统复习核心机制的开发者,围绕JVM、集合框架、多线程与设计模式等高频考点组织内容,适合面试前查漏补缺。资源为1个PDF文件,压缩包约10.71MB,按目录分章展开,便于定位知识点;已有650人学习。文档从JVM架构切入,梳理类加载器、执行引擎与运行时数据区,逐项说明程序计数器、虚拟机栈、本地方法区、堆和方法区的线程共享关系;运行时内存覆盖新生代Eden、SurvivorFrom、SurvivorTo及MinorGC复制—清空—互换流程,并延伸至老年代、永久代与Java 8元数据等易混概念。读者可借其串联JVM内存模型与常见面试问法,形成可复述的答题要点。它也能作为后续复习集合、多线程和设计模式的索引笔记。

1. 知识地图:从 JVM 内存到集合容器,八股文其实只有两条主干

复习 Java 核心知识、准备后端面试的时候,最容易犯的错是把八股文当成一张背诵清单:JVM 背一遍、集合背一遍、多线程再背一遍,考场上换个问法就串不起来。这份资料的目录很长,但真正撑起内容的主干只有两条。第一条是“对象从创建到回收”的 JVM 线:五个运行时数据区、新生代三区、MinorGC 的三步走、四种回收算法、七种收集器,一路延伸到类加载和双亲委派。第二条是“数据放哪儿、怎么取”的集合线:从 List / Set / Map 的接口继承关系,到 HashMap 从数组加链表演进到数组加链表加红黑树,再到 ConcurrentHashMap 的分段锁与 CAS。两条线各成闭环、交叉点很少,适合先分开复习、最后合并复述。往下按这两条线拆模块,每章给一张能照着念的要点表。

2. JVM 内存区域:五个数据区的线程归属与一次 MinorGC 的完整走位

被问到“讲讲 JVM 内存”,多数人从“堆和栈”开始答,答到方法区就开始含糊。真正拉开差距的不是记住了几个名字,而是能说清三件事:每个区域属于线程私有还是共享、里面到底放什么、什么情况下会抛哪种错误。这一章按照“区域 → 分代 → 类加载”的顺序铺开,把这三件事一次讲透。

2.1 五个运行时数据区与线程私有/共享的划分

区域线程归属存放内容可能抛出的错误
程序计数器私有当前线程所执行字节码的行号指示器;执行 native 方法时值为空唯一不会抛 OutOfMemoryError 的区域
虚拟机栈私有栈帧:局部变量表、操作数栈、动态链接、方法返回地址StackOverflowError / OutOfMemoryError
本地方法栈私有native 方法的栈帧同上
堆共享对象实例、数组OutOfMemoryError: Java heap space
方法区(JDK8 起为元空间)共享类元数据、运行时常量池、静态变量OutOfMemoryError: Metaspace

程序计数器之所以被设计成线程私有,是因为 CPU 在多个线程之间切换,切回来时必须知道上一条字节码执行到哪。它是唯一在规范里明确“不会发生内存溢出”的区域,这一点经常被当成送分题,也是最容易被反着答的题。虚拟机栈和本地方法栈的区别只在于服务对象:前者服务 Java 方法,后者服务 native 方法,HotSpot 在实现上把两者合并到了一起,所以回答时点到“规范上是两块、实现上常合并”就够了。

堆是唯一被所有线程共享、也是 GC 的主战场。它从逻辑上分代,从物理上可以不连续,-Xms与-Xmx控制初始与最大值。生产环境通常把两者设成相等,避免运行期反复扩容带来的抖动。

方法区的变化是版本差异最集中的地方:JDK7 及以前用永久代实现,受-XX:PermSize/-XX:MaxPermSize限制;JDK7 把字符串常量池移到了堆里;JDK8 用元空间取代永久代,改走本地内存,默认不设上限,靠-XX:MaxMetaspaceSize兜底。所以“方法区会不会 OOM”这个问题,要分版本回答,直接说“会”或“不会”都是不完整的。

# 一次典型的启动参数配置,注释里写清每个参数的作用 java -Xms256m -Xmx256m \ # 堆初始值与最大值设成相等,避免运行期扩容 -Xmn128m \ # 新生代大小,等价于把 NewRatio 固定下来 -XX:SurvivorRatio=8 \ # Eden : 单个 Survivor = 8 : 1,新生代合计 8:1:1 -XX:MetaspaceSize=64m \ # 元空间首次扩容的触发阈值,不是初始大小 -XX:MaxMetaspaceSize=256m \ # 元空间上限,防止类加载泄漏吃光本地内存 -XX:+PrintGCDetails \ # 打印 GC 明细,排查时最常用的开关 -jar app.jar

参数里有两点容易误解。-Xmn和-XX:NewRatio是互相覆盖的关系,同时写以-Xmn为准,调优时选一个就好。MetaspaceSize不是“元空间初始容量”,而是第一次触发元空间 GC 的水位线,设得太小会导致启动阶段频繁 Full GC,看着像玄学,其实是参数没设对。

2.2 新生代三区与 MinorGC 的三步走

新生代被切成 Eden、SurvivorFrom、SurvivorTo 三块,默认比例 8:1:1。为什么要留两个 Survivor?因为新生代用的是复制算法,复制算法必须有一块空的内存作为转移目标,两个 Survivor 轮流当“from”和“to”,才能保证任何时候都有一块是空的。

一次 MinorGC 的过程可以拆成三步,这三步建议背到能画出来:

  1. 把 Eden 和 SurvivorFrom 中仍然存活的对象复制到 SurvivorTo,同时把对象的年龄加 1;
  2. 清空 Eden 和 SurvivorFrom 这两块区域;
  3. SurvivorTo 和 SurvivorFrom 互换角色,原来的 To 变成下一次的 From。

对象不会永远待在新生代。晋升老年代有三条路:年龄达到阈值(-XX:MaxTenuringThreshold,默认 15,CMS 下默认值会调小)、大对象直接进入老年代(-XX:PretenureSizeThreshold,只对 Serial 和 ParNew 生效)、动态年龄判定——Survivor 中所有相同年龄对象的大小总和超过 Survivor 空间的一半,年龄大于等于该值的对象直接晋升。第三条最容易被漏掉,也是最容易在追问“为什么没到 15 岁就进老年代了”时救场的一条。

// 观察分配与晋升:-Xmx20m -Xmn10m -XX:SurvivorRatio=8 -XX:+PrintGCDetails // 新生代 10MB,Eden 约 8MB,两个 Survivor 各约 1MB byte[] a = new byte[2 * 1024 * 1024]; // 在 Eden 分配 2MB,age = 0 byte[] b = new byte[2 * 1024 * 1024]; // Eden 累计 4MB byte[] c = new byte[2 * 1024 * 1024]; // Eden 累计 6MB byte[] d = new byte[2 * 1024 * 1024]; // Eden 放不下,触发一次 MinorGC

这段代码的价值不在业务逻辑,而在于配合-XX:+PrintGCDetails能亲眼看到“Eden 满了 → MinorGC → 存活对象进 Survivor 或老年代”的全过程。注意byte[]申请的是连续内存,会被算作大对象,实际观察时很可能直接进老年代,而不是按年龄慢慢晋升——这恰好是理解“大对象直接晋升”最直观的例子。

提示:分析 GC 日志先看三个数——新生代回收前后占用、老年代占用变化、单次停顿时间。只看“有没有 Full GC”会漏掉最关键的晋升速率问题。

2.3 类加载五个阶段与双亲委派

类从磁盘上的字节码变成能用的 Class 对象,要走加载、验证、准备、解析、初始化五个阶段。考试里最爱考的是准备和初始化的区别:准备阶段给静态变量分配内存并赋零值(int赋 0,引用赋 null),而static final修饰的编译期常量在准备阶段就直接赋成定义的值;真正的赋值动作被收进<clinit>()方法,在初始化阶段执行,并且由静态变量赋值语句和静态代码块按源码顺序合并而成。

解析阶段把常量池里的符号引用替换成直接引用,它和初始化之间没有严格的先后约束,这一点常被拿来当“顺序能不能打乱”的判断题。初始化的触发时机有明确清单:new、读取或设置静态字段(被 final 修饰、已在编译期放入常量池的除外)、调用静态方法、反射、初始化子类时父类先初始化、作为启动类的 main 类。接口的初始化则不会连累父接口,只有当真正用到父接口里定义的字段时才会触发。

双亲委派是这一节的收口。它的流程写在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); // 到顶了就交给启动类加载器 } } catch (ClassNotFoundException ignored) { // 父加载器找不到,才轮到自己 } if (c == null) { c = findClass(name); // 3. 自己加载,通常重写这个方法 } } if (resolve) resolveClass(c); return c; } }

这段代码说明了两个常被误解的点。第一,“父加载器”不是继承关系,而是组合关系,通过parent字段持有,所以自定义加载器用getParent()拿上级,不是getSuperclass()。第二,想自定义加载逻辑,正确姿势是重写findClass而不是loadClass;重写loadClass等于把双亲委派整个拆掉,JDK9 之后模块化体系下更容易翻车。

打破双亲委派的经典场景有三类:SPI 机制(核心类库里加载不到业务类,只能把线程上下文类加载器借出来)、OSGi 的模块化热插拔(每个 Bundle 一个加载器,网状委派)、以及各种容器与热部署框架的自定义加载器。三者共同点是“父加载器可见的范围内拿不到目标类”,答题时点到这个本质,比罗列名词更容易得分。

3. 垃圾回收:判定标准、四种算法与七种收集器的取舍表

GC 这块的题目有个特点:名词都认识,组合起来就说不清。原因是没有先建立“判定 → 算法 → 收集器”的层次,直接背收集器名字,自然记不住谁配谁。这一章按这个层次往下推,最后落到四种引用类型,把“对象什么时候死”这件事说完整。

3.1 如何确定垃圾:引用计数与可达性分析

引用计数法给每个对象维护一个计数器,被引用加一,引用失效减一,归零即可回收。它实现简单、回收及时,但有个致命缺陷:两个对象互相引用时,计数器永远不为零,这块内存就回收不掉。Java 没有采用它,而是用可达性分析。

可达性分析从一组称为 GC Roots 的对象出发向下遍历,遍历不到的节点就是可回收的。GC Roots 的清单要能张口就来:虚拟机栈中局部变量表引用的对象、方法区中静态属性引用的对象、方法区中常量引用的对象、本地方法栈中 JNI 引用的对象、虚拟机内部引用(基本类型对应的 Class 对象、常驻异常对象等)、被同步锁持有的对象。答题时能补上后面两条,就已经超出平均水平了。

一个容易被忽略的细节是:可达性分析判定为不可达,也只是“第一次标记”,对象还有一次自救机会——如果它重写了finalize()且没被执行过,会被放进 F-Queue 里等待二次判定。实际开发里没人靠这个复活对象,但面试官问“对象一定会被回收吗”,这是标准答案的落点。

3.2 四种基础算法与分代收集的搭配

算法思路优点缺点主要用在哪
标记-清除标记可回收对象,逐个清除实现简单,不需要移动对象产生内存碎片,标记和清除效率都不高CMS 的并发清除阶段
复制内存分两块,存活对象复制到另一块后整块清空无碎片,分配简单可用内存减半,存活率高时复制开销大新生代(Eden + 两个 Survivor)
标记-整理标记后把存活对象向一端移动无碎片移动对象要更新引用,停顿更长老年代(Serial Old、Parallel Old)
分代收集按对象生命周期分区,各区用合适算法扬长避短需要处理跨代引用绝大多数商用虚拟机

分代收集不是一种独立算法,而是一种组织方式。它成立的前提是弱分代假说:绝大多数对象朝生夕灭,熬过越多次回收的对象越难死。于是新生代对象存活率低,用复制算法最划算;老年代存活率高,用标记-整理避免复制开销。

跨代引用是分代收集的配套问题:新生代对象被老年代引用时,做 Young GC 不能去遍历整个老年代。解决办法是在老年代维护一个记忆集(Remembered Set / Card Table),把存在跨代引用的区域标记出来,Young GC 时只扫这些小片。这个点在讲 G1 的时候会被追问,提前铺垫好会顺很多。

3.3 七种收集器与它们的组合关系

收集器回收区域算法线程模型要点
Serial新生代复制单线程客户端模式默认,STW 明显,简单高效
ParNew新生代复制多线程Serial 的多线程版,CMS 的搭档
Parallel Scavenge新生代复制多线程吞吐量优先,适合后台批处理
Serial Old老年代标记-整理单线程Serial 的老年版,CMS 的后备方案
Parallel Old老年代标记-整理多线程与 Parallel Scavenge 配对,吞吐优先
CMS老年代标记-清除并发停顿优先,碎片与浮动垃圾是硬伤
G1全堆(Region)整体标记-整理 + 局部复制并发可预测停顿模型,取代 CMS 的主力

组合关系其实只有几条硬规则:Serial 配 Serial Old、ParNew 配 CMS(或 Serial Old 兜底)、Parallel Scavenge 配 Parallel Old,其余组合在参数校验阶段就会被拒绝。G1 独立成套,不分新生代老年代的物理连续空间。记住这条,比背七张图有用得多。

CMS 的四个阶段是高频考点,顺序不能乱:初始标记(STW,只标记 GC Roots 直接关联的对象,很快)、并发标记(从直接关联对象出发做可达性遍历,与用户线程并行)、重新标记(STW,修正并发标记期间因用户线程运行导致的变动,靠增量更新实现)、并发清除(清除不可达对象,与用户线程并行)。它的三个问题也要能顺着说出来:对 CPU 资源敏感、无法处理浮动垃圾(并发清除期间新产生的垃圾只能等下一次)、标记-清除带来的空间碎片导致大对象分配失败,从而触发 Full GC,也就是 Concurrent Mode Failure。

G1 的思路换了个方向:把堆切成大小相等的 Region(1MB 到 32MB,必须是 2 的幂),每个 Region 按需扮演 Eden、Survivor、Old、Humongous 角色,回收时按“回收价值”排序,优先回收收益高的 Region,并允许通过-XX:MaxGCPauseMillis设定目标停顿时间。它整体上按标记-整理看,两个 Region 之间按复制看,所以不会产生碎片。代价是需要维护每个 Region 的记忆集,内存占用比传统分代更高。

注意:回答“为什么 CMS 用标记-清除而不是标记-整理”时,要落到“并发清除阶段对象还在移动,用户线程无法接受引用地址变化”,这是设计上的取舍,不是实现偷懒。

3.4 四种引用类型与它们对回收的影响

引用类型回收时机典型用途
强引用只要 GC Roots 可达,永不回收日常 new 出来的对象
软引用内存不足时才回收缓存(如图片、文件缓存)
弱引用下一次 GC 必然回收WeakHashMap、ThreadLocalMap 的 key
虚引用随时可能被回收,无法取得对象配合引用队列做回收通知、直接内存清理

四者的强度依次递减,判断标准是“GC 发生时是否给对象留活路”。软引用适合做“内存够就留着”的缓存,弱引用适合做“一旦没人用就立刻清掉”的辅助结构。虚引用的get()永远返回 null,它的唯一价值是在对象被回收时收到一条通知,Cleaner清理直接内存就是这套机制。

// 弱引用:只要发生 GC,不管内存充不充足都会被回收 Object obj = new Object(); WeakReference<Object> weak = new WeakReference<>(obj); obj = null; // 断掉强引用,此时只剩弱引用指向对象 System.gc(); // 触发一次 GC(仅建议,不保证立即执行) System.out.println(weak.get()); // 大概率输出 null

这段代码演示了弱引用的判定逻辑,也解释了一个常见困惑:System.gc()只是建议,不保证立刻执行,所以偶尔会输出对象而不是 null。线上代码不该依赖它的时机,但理解这个不确定性,能帮你在写缓存时避免“以为对象已经回收了”的误判。

4. 集合框架:List、Set、Map 的实现取舍与 HashMap 的两代结构

集合部分的题目看似零散,其实都在问同一件事:在“查找快、插入快、有序、线程安全”这几个诉求之间,你选了哪个、牺牲了哪个。搞清楚每种容器拿什么换什么,题目就变成了一道取舍题,而不是记忆题。

4.1 接口继承关系与三兄弟的定位

Collection 下面分三支:List 有序可重复、Set 无序不可重复、Queue 按队列语义进出;Map 独立成一支,存键值对,不算 Collection 的子接口。这个“Map 不属于 Collection”是个送分点,但答题时卡壳的人不少。

接口是否有序是否允许重复是否允许 null常见实现
List有序(按插入位置)允许允许ArrayList、LinkedList、Vector
Set一般不保证顺序不允许HashSet 允许一个 nullHashSet、TreeSet、LinkedHashSet
Map不保证键顺序键唯一HashMap 允许一个 null 键HashMap、TreeMap、ConcurrentHashMap

有序性这一列要分清楚“有序”的两种含义:List 的有序指元素位置固定、可按索引访问;TreeSet / TreeMap 的有序指按比较器排序;LinkedHashSet / LinkedHashMap 的有序指按插入(或访问)顺序。三者不是一回事,混着说就容易露怯。

4.2 List 三兄弟:ArrayList、Vector、LinkedList

实现底层结构线程安全扩容策略随机访问中间插入/删除
ArrayList动态数组否1.5 倍,默认容量 10(首次 add 才分配)O(1)O(n),需要移动元素
Vector动态数组是,方法加 synchronized2 倍O(1)O(n)
LinkedList双向链表否无扩容概念O(n)O(1)(前提是已定位到节点)

ArrayList 的“默认容量 10”有个前提常被漏掉:构造时并不分配数组,用的是空数组常量,第一次add才真正扩容到 10。这个懒加载设计是为了减少空列表的内存占用,被问到“new ArrayList<>() 占多少内存”时能答上来是加分项。

// ArrayList 扩容核心逻辑:新容量 = 旧容量 + 旧容量右移一位,即 1.5 倍 private void grow(int minCapacity) { int oldCapacity = elementData.length; int newCapacity = oldCapacity + (oldCapacity >> 1); // 1.5 倍,位运算比除法快 if (newCapacity - minCapacity < 0) { newCapacity = minCapacity; // 1.5 倍仍不够,直接用需求量 } if (newCapacity - MAX_ARRAY_SIZE > 0) { newCapacity = hugeCapacity(minCapacity); // 处理接近上限的边界 } elementData = Arrays.copyOf(elementData, newCapacity); // 底层是数组复制,O(n) }

扩容是 ArrayList 最贵的操作,因为要申请新数组并整体复制。工程上的应对是预估容量、构造时直接给定初始大小,避免反复扩容。同理,LinkedList的“插入 O(1)”也有前提:你得先花 O(n) 找到位置,否则总代价还是 O(n)。这个前提漏掉,答案就从“及格”掉到“背课文”。

4.3 Set 三种实现与它们的底层依赖

Set 的三种实现没有一个是独立造轮子,全是包了一层 Map:

  • HashSet 底层是 HashMap,元素当 key,value 统一放一个PRESENT常量对象,所以add的返回值来自map.put(...) == null;
  • TreeSet 底层是 TreeMap,红黑树结构,元素必须实现 Comparable 或构造时传入 Comparator,查找 O(log n),支持排序与范围查询;
  • LinkedHashSet 继承 HashSet,内部用的是 LinkedHashMap,靠一条双向链表维持插入顺序。

这套“套壳”设计带来的直接后果是:HashSet 的去重依赖hashCode()和equals(),自定义对象必须同时重写这两个方法,只重写一个就会出现“明明 equals 相等却存进去两个”。这是实践中最常见的翻车点,比任何算法细节都值得记住。

4.4 HashMap:Java7 的数组+链表与 Java8 的红黑树

对比项Java7Java8
数据结构数组 + 链表数组 + 链表 + 红黑树
插入方式头插法尾插法
hash 扰动多次移位与异或(h = key.hashCode()) ^ (h >>> 16),一次异或
扩容后重排重新计算索引按hash & oldCap拆成高低位两条链,索引不变或加 oldCap
并发扩容风险头插法可能形成环形链表,导致查询死循环尾插法避免成环,但仍非线程安全

Java8 的树化有两个前提,缺一不可:链表长度达到 8(TREEIFY_THRESHOLD),并且数组容量不小于 64(MIN_TREEIFY_CAPACITY)。容量不够时优先扩容而不是树化,因为小数组下树化的收益抵不过维护成本。反过来,树节点数降到 6 以下会退化回链表,这个 6 与 8 之间的差值是为了避免在临界值反复转换。

// 树化的两个条件:链表长度 >= 8 只是入场券,还要看数组容量是否 >= 64 if (binCount >= TREEIFY_THRESHOLD - 1) { // TREEIFY_THRESHOLD = 8 treeifyBin(tab, hash); } final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) { resize(); // 容量不足 64,先扩容,不树化 } else if ((e = tab[index = (n - 1) & hash]) != null) { // 容量达标,才真正把链表转成红黑树 } }

(n - 1) & hash这个索引计算方式,是 HashMap 容量必须为 2 的幂的根本原因:只有 n 是 2 的幂时,n - 1的二进制才是一串连续的 1,按位与才等价于取模,同时还能让散列结果分布更均匀。用构造器传入非 2 的幂的初始容量时,内部会用tableSizeFor向上取到最近的 2 的幂,所以“传 17 会得到 32”是正常行为,不是 bug。

负载因子 0.75 是空间与时间的折中:调高省空间但冲突变多,调低冲突少但数组浪费。这个数字不是随意定的,而是泊松分布下链表长度达到 8 的概率已经低到千万分之一量级,所以树化阈值定在 8 才划算。

4.5 ConcurrentHashMap:从 Segment 分段锁到 CAS + synchronized

Java7 的实现是分段锁:整个桶数组被切成若干个 Segment,默认 16 个,每个 Segment 继承 ReentrantLock 并各自持有一个 HashEntry 数组。写操作只锁住对应的那一段,段与段之间互不影响,并发度等于 Segment 的数量。代价是内存开销大、跨段操作(比如 size)很别扭——要先无锁统计两遍,结果不一致再加锁重算。

Java8 把 Segment 整个去掉,回到“一个 Node 数组”,锁的粒度细到单个桶:桶为空时用 CAS 直接放入首节点,桶非空时用synchronized锁住头节点再操作链表或红黑树。计数也不再是一个 AtomicInteger,而是baseCount加一个CounterCell[]数组,思路与 LongAdder 一致,把竞争分散到不同槽位上。

对比项Java7Java8
锁粒度Segment 段(默认 16)单个桶的头节点
锁实现ReentrantLockCAS + synchronized
数据结构Segment + HashEntry 数组 + 链表Node 数组 + 链表 + 红黑树
并发度等于段数,受限于构造参数等于桶数量,随扩容提升
计数方式两次统计 + 加锁重算baseCount + CounterCell 分散计数

ConcurrentHashMap 不允许 null 键和 null 值,这一点和 HashMap 相反。原因不在实现难度,而在语义歧义:并发场景下get返回 null 时,你无法区分是“这个 key 不存在”还是“这个 key 存在但值就是 null”,而加锁再查一遍会破坏并发设计的初衷。这条回答比“源码里写了不允许”有说服力得多。

5. 易错点核查:内存归属、分代搭配与树化阈值最容易被答反的地方

这一章把前面几块里最容易记串、最容易在追问中翻车的点单独拎出来。每一条都按“错在哪、为什么错、怎么补”写,建议对照自己的笔记逐条打钩。

5.1 内存区域的线程归属与“谁不会 OOM”

易漏点:把方法区说成线程私有,或者斩钉截铁地说程序计数器也会抛 OutOfMemoryError;再或者把 JDK8 之后的元空间仍然当成堆内的一块。

为什么漏:多数教材的图只画了区域,没强调归属;而“程序计数器不 OOM”属于规范里的一句话,不复习根本想不起来。元空间的变化又是版本差异,背旧资料的人会一直停留在永久代。

怎么补:用一句话把所有区域串起来——两个栈加一个计数器归线程私有,堆和方法区归线程共享,其中计数器是唯一不 OOM 的。再补一句版本线:JDK7 把字符串常量池挪到堆,JDK8 用元空间取代永久代并改走本地内存。这两句话能覆盖八成相关追问。

5.2 分代算法与收集器的错配

易漏点:说老年代也用复制算法;把 CMS 说成标记-整理;把 Parallel Scavenge 说成停顿优先。

为什么漏:四种算法的名字太熟,背的时候按顺序过,没有和“哪个区用它”绑定。收集器的名字又是另一组名词,两组之间没有建立映射,答题时就容易自由组合。

怎么补:记三条硬绑定——新生代存活率低所以用复制,老年代存活率高所以用标记-整理,CMS 为了并发清除只能选标记-清除并接受碎片。收集器这边记两句话:Parallel Scavenge 的目标是吞吐量(-XX:GCTimeRatio),CMS 和 G1 的目标是停顿时间(-XX:MaxGCPauseMillis)。目标和算法对上了,组合就不会错。

5.3 HashMap 的树化条件与容量细节

易漏点:只说“链表长度到 8 就转红黑树”,漏掉数组容量必须不小于 64;或者把退化阈值也说成 8。

为什么漏:TREEIFY_THRESHOLD = 8这个常量太显眼,看完就记住了,MIN_TREEIFY_CAPACITY藏在treeifyBin的分支里,不点进去根本看不到。退化阈值 6 更是只有看untreeify才会遇到。

怎么补:把树化记成“两个条件 + 一个缓冲带”:长度到 8 且容量到 64 才树化,节点数降到 6 才退化,中间的 7 是防止反复转换的缓冲。再顺手记住扩容后重排靠hash & oldCap拆高低位,索引要么不变要么加 oldCap,这条能把“扩容为什么高效”一起答掉。

5.4 类加载的“父”与双亲委派的边界

易漏点:把父加载器理解成继承关系;认为双亲委派是绝对不能打破的铁律;或者把重写loadClass当成自定义加载器的标准做法。

为什么漏:中文里“父”字天然带继承联想,而 `

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进