简介:《Java核心知识整理(常考八股文)》PDF面向Java后端开发者与准备技术面试的求职者,围绕高频考点做系统梳理,适合面试前查漏补缺和日常巩固基础。资源包共1个PDF文件,约10.71MB,从JVM架构、类加载器、执行引擎和运行时数据区讲起,细化程序计数器、虚拟机栈、本地方法区、堆与方法区的线程归属,并说明新生代Eden区、两个Survivor区及新生代回收中复制、清空、互换流程,兼及老年代、永久代和Java8元数据等易混知识。目录还覆盖Java语法、集合框架、多线程、设计模式等模块,便于按知识块推进复习;对JVM内存划分、垃圾回收触发过程等常见追问,可借助章节定位快速核对。已有650人学习,适合需要搭建知识框架、对照八股问题查漏补缺的初中级开发者使用。
1. 从「八股文」这个词说起:Java 核心知识到底在考什么
「八股文」听起来像贬义,但做过几年 Java 后端的人大多有个共同体会:真正让人在面试里哑火的,往往不是没写过业务代码,而是被问到「HashMap 扩容为什么容量必须是 2 的幂」「CMS 为什么会产生浮动垃圾」时答不上来。这类问题的共同点是,它们考的不是背诵能力,而是你对 Java 运行时那套机制的熟悉程度。
打个比方,写业务代码像开车,知道踩油门、打方向就能上路;而 JVM、集合框架、类加载这些核心知识像发动机舱,平时不用打开,可一旦车抖了、油耗高了、要上赛道了,不掀开盖子就只能干着急。这份整理做的就是发动机舱的图纸:堆和栈怎么划分、对象什么时候被判死、类从磁盘进内存要走几步、HashMap 从链表变红黑树的临界点在哪。下面按「是什么、为什么、怎么区分」的顺序,把这些概念一个个拆开讲。
2. JVM 为什么是 Java 的心脏:字节码、内存与类加载
2.1 字节码这层「中间商」到底解决了什么
Java 源码经过编译器处理后,产出的不是某个操作系统能直接执行的机器码,而是一份.class文件,里面装的是字节码。字节码是一套面向虚拟机的指令集,它不认识 Windows 也不认识 Linux,只认识 JVM。真正把字节码翻译成当前机器指令的,是 JVM 里的执行引擎。
这层「中间商」解决的核心问题是平台差异。如果每种操作系统都要为同一份源码单独编译一次,那分发包的数量会随平台数量成倍增长。字节码相当于一个通用插头标准,插座那头换成什么形状,由 JVM 这层转换器负责适配。
代价也很实在。多一层翻译就意味着多一层开销,所以 JVM 不会老老实实逐条解释。它先用解释器快速跑起来,同时统计哪些方法被反复调用,把这类「热点代码」交给即时编译器编译成本地机器码,下次再执行就直接走编译后的版本。这就是常说的「先解释、后编译」的混合模式。
这套机制带来两个直接后果,做后端的人必须知道。第一是启动阶段偏慢,因为热点还没攒够,服务刚起来那几分钟性能不在最佳状态,这也是容量评估时不能只看峰值 QPS 的原因。第二是运行越久越快,同样一个接口,跑了一小时的服务和刚启动的服务,响应时间可能差出一截。理解了这一点,再看那些 JVM 调优参数,就不是玄学,而是在调节「解释多少、编译多少、给编译留多少线程」。
JVM 的整体架构可以粗分成三块:类加载器负责把类文件搬进内存,执行引擎负责把字节码变成机器指令,运行时数据区负责存放运行过程中的一切数据。三者里最容易出问题、也最常被问到的,是第三块。
2.2 内存区域的分界线:哪些归线程、哪些归所有线程
JVM 在运行时会划出若干块内存,划分依据只有一条:这块数据是每个线程各有一份,还是所有线程共用一份。这条线分错了,后面所有关于线程安全和垃圾回收的理解都会跟着错。
| 区域 | 归属 | 主要存放内容 | 典型异常 |
|---|---|---|---|
| 程序计数器 | 线程私有 | 当前线程执行到哪条字节码指令 | 无 |
| 虚拟机栈 | 线程私有 | 方法调用时的栈帧、局部变量表、操作数栈 | StackOverflowError / OutOfMemoryError |
| 本地方法栈 | 线程私有 | 本地方法调用时的栈帧 | StackOverflowError / OutOfMemoryError |
| 堆 | 线程共享 | 对象实例、数组 | OutOfMemoryError |
| 方法区 | 线程共享 | 类元数据、静态变量、常量池 | OutOfMemoryError |
程序计数器是唯一一块在规范里不会抛内存溢出错误的区域。它小到几乎可以忽略,作用是记录当前线程执行到哪条指令。多线程环境下 CPU 来回切换,切回来时得知道从哪儿接着跑,靠的就是它。线程私有是必须的,否则两个线程互相覆盖对方的执行位置,程序立刻乱套。
虚拟机栈描述的是方法执行的内存模型。每调用一个方法,就压入一个栈帧,方法返回就弹出。栈帧里装着局部变量表、操作数栈、动态链接和方法出口信息。栈的深度是有限的,递归写得没收尾,或者方法调用链太深,就会撞上 StackOverflowError;而如果栈可以动态扩展却申请不到内存,报的则是 OutOfMemoryError。这两个错误经常被混为一谈,但触发条件完全不同。
堆是所有线程共享的最大一块区域,几乎所有的对象实例都在这里分配。它进一步被划成新生代和老年代,新生代又分成 Eden 区、SurvivorFrom 区和 SurvivorTo 区。这种划分不是为了好看,而是为了让不同存活特征的对象用不同的回收策略,后面单独讲。
方法区在逻辑上属于堆的一部分,但物理上可以不在堆里。它存的是类结构信息、运行时常量池、静态变量这些「跟类有关而不是跟对象有关」的数据。很多人把方法区和永久代当成同义词,这个混淆后面会专门拆开说。
提示:判断某块内存归谁,最快的办法是问一句「两个线程同时跑,这块数据会互相干扰吗」。会,就是共享区;不会,就是私有区。
2.3 类加载的五步与双亲委派
一个.class文件从磁盘进到内存、变成能被使用的类,要走五步:加载、验证、准备、解析、初始化。顺序是固定的,但解析阶段有时会推迟到第一次真正使用时才做。
加载阶段做三件事:通过类的全限定名拿到二进制字节流,把字节流里的静态存储结构转成方法区的运行时数据结构,再在堆里生成一个代表这个类的对象作为访问入口。验证阶段检查字节码是否合法、是否安全,防止有人手改 class 文件搞破坏。准备阶段给静态变量分配内存并设零值,注意是零值不是代码里写的初始值。解析阶段把常量池里的符号引用换成直接引用,符号引用是一串字符串形式的描述,直接引用才是真正的内存地址或偏移量。初始化阶段才真正执行类构造器,把静态变量赋成代码里写的值,并跑静态代码块。
下面这段代码能把这几个阶段的差别演示清楚。
public class LoadOrder { // 准备阶段先赋零值 0,初始化阶段才赋成 100 static int value = 100; // 编译期常量,直接进常量池,引用它不会触发类初始化 static final int CONST = 200; static { // 静态块在初始化阶段执行,此时 value 已经是 100 System.out.println("静态块执行,value = " + value); } public static void main(String[] args) { System.out.println("main 方法执行"); } }运行结果是先打印「静态块执行,value = 100」,再打印「main 方法执行」。如果只把CONST拿去用,类根本不会被初始化,静态块也不会跑。这就是准备阶段和初始化阶段的分水岭:一个只管把内存清零,另一个才执行你写的赋值逻辑。
类加载由类加载器完成,分成三层。启动类加载器负责加载 Java 核心库,通常用本地代码实现,开发者拿不到它的引用。扩展类加载器负责加载扩展目录下的类。应用程序类加载器负责加载用户类路径上的类,平时写的代码基本都是它加载的。
双亲委派说的是加载请求的传递顺序:一个类加载器收到请求,先不自己加载,而是委派给父加载器,父加载器再往上委派,一直到启动类加载器;父加载器加载不了,才轮到子加载器自己动手。这个机制的价值有两条。一是安全,用户自己写一个和核心库同名的类,请求一路委派上去被启动类加载器拦下,你写的那个版本根本没机会被加载,防止核心 API 被替换。二是避免重复,同一个类被不同加载器重复加载,会出现类型冲突。
这套规则并非铁板一块。有些模块化框架为了让同一个类在不同模块里能加载不同版本,会打破双亲委派,改成网状结构,模块可以热插拔、动态改变构造。代价是加载逻辑复杂很多,排查问题时要先搞清楚是哪个加载器在干活。
3. 垃圾回收为什么这么难:判定、算法与收集器
3.1 怎么判断一个对象该不该死
垃圾回收的第一步不是回收,而是判断谁该被回收。这一步走错,后面所有优化都是空谈。
最直觉的办法是引用计数:给每个对象配一个计数器,被引用一次加一,引用失效减一,减到零就说明没人用了。逻辑简单,回收也及时。但它有个致命缺陷,两个对象互相引用,计数器都停在一,实际上它们已经和外界断了联系,却永远回收不掉。所以主流的 JVM 都不把它当主方案。
真正在用的是可达性分析。思路是把所有「活着的起点」找出来,从这些起点出发沿着引用链往下走,能走到的对象算存活,走不到的就是垃圾。这些起点叫 GC Roots,主要包括虚拟机栈里局部变量表引用的对象、方法区里静态属性引用的对象、常量引用的对象、本地方法栈里引用的对象。这个思路等于把「判断谁死」换成了「标记谁活」,剩下的自然就是垃圾,绕开了循环引用的问题。
判定的结果还取决于引用的强度。Java 把引用分成四档,强度依次递减。
| 引用类型 | 回收时机 | 常见用途 |
|---|---|---|
| 强引用 | 只要引用链还在,永不回收 | 普通对象引用 |
| 软引用 | 内存不够时才回收 | 缓存 |
| 弱引用 | 下次回收一定被收走 | 弱引用表、临时关联数据 |
| 虚引用 | 随时可能被回收,只能收到回收通知 | 跟踪对象被回收的时机 |
软引用适合做缓存,内存紧张时自动让路,不会把程序拖垮。弱引用的生命周期更短,只要回收动作发生,不管内存够不够都会被清掉,适合存放那种「有就用、没有就重建」的关联信息。虚引用最弱,拿不到对象本身,唯一的作用是在对象被回收时收到一条通知,用来做清理动作。
3.2 新生代为什么用复制、老年代为什么用标记整理
判完死活,接下来是回收动作。最基础的三套算法各有各的脾气。
标记清除算法分两步:先标记所有需要回收的对象,再统一清除。实现简单,但清完之后内存里会留下一堆不连续的空洞,也就是内存碎片。碎片多了,明明总空闲空间够,却找不到一块连续区域放下一个大对象,只能提前触发下一次回收,形成恶性循环。
复制算法把内存一分为二,只用其中一半。回收时把存活对象整块复制到另一半,然后一次性清空当前这半。好处是没有碎片,分配内存时直接指针挪一挪就行。坏处是可用内存直接砍半,而且存活对象越多,复制成本越高。
标记整理算法先标记,然后把存活对象都往一端挪,最后清掉边界之外的部分。它既没有碎片,也不需要把内存砍半,代价是移动对象要更新所有指向它们的引用,速度比前两种都慢。
三种算法没有绝对优劣,关键看对象的存活率。新生代的对象基本都是朝生夕死,一次回收下来存活的可能不到一成,复制算法只需要搬走这一小撮,成本极低,还能顺便把内存整理干净,所以新生代用复制算法。老年代的对象恰恰相反,经过多轮筛选还活着的都是硬骨头,存活率高,复制起来成本巨大,所以老年代用标记整理或者标记清除。
具体到新生代内部,Eden 区、SurvivorFrom 区、SurvivorTo 区的默认比例是 8:1:1。新对象优先在 Eden 区分配,一次新生代回收的过程可以拆成三步。
第一步,把 Eden 区和 SurvivorFrom 区里还活着的对象复制到 SurvivorTo 区,同时给每个对象的年龄加一。第二步,清空 Eden 区和 SurvivorFrom 区。第三步,把 SurvivorFrom 区和 SurvivorTo 区的角色互换,原来的 To 变成下一次的 From。整个过程只有 Eden 加一块 Survivor 会被清空,另一块 Survivor 始终留着当备份,所以实际可用空间是 90%,比复制算法理论上的 50% 高得多。
对象在 Survivor 区每熬过一次回收,年龄就加一,加到阈值默认十五岁,就晋升到老年代。除了年龄,还有两种情况会提前晋升:一是对象太大,新生代放不下,直接进老年代;二是 Survivor 区里相同年龄的对象总大小超过了一半,年龄大于等于这个值的对象一起晋升。这套动态判定是为了防止 Survivor 区被撑爆。
还有一条路线叫分区收集,代表是 G1。它不再把堆按新生代、老年代做物理上的连续划分,而是切成一个个大小相等的 Region,每个 Region 可以扮演 Eden、Survivor 或老年代的角色,角色是动态的。这样做的好处是回收时不必整块整块地扫,可以挑垃圾最多的 Region 优先处理,把停顿时间控制在设定目标内。分代是按对象年龄做物理隔离,分区是按内存块做逻辑划分,两者解决的是同一个问题的不同侧面。
3.3 收集器不是越新越好:Serial 到 G1 的演进逻辑
算法是策略,收集器是把策略落到实处的执行者。不同收集器的差别,本质上是在吞吐量和停顿时间之间做取舍。
| 收集器 | 线程模型 | 主要算法 | 作用区域 | 特点 |
|---|---|---|---|---|
| Serial | 单线程 | 复制 | 新生代 | 简单高效,适合小堆 |
| ParNew | 多线程 | 复制 | 新生代 | Serial 的多线程版,常配 CMS |
| Parallel Scavenge | 多线程 | 复制 | 新生代 | 吞吐量优先,适合后台计算 |
| Serial Old | 单线程 | 标记整理 | 老年代 | Serial 的老年代版本 |
| Parallel Old | 多线程 | 标记整理 | 老年代 | 吞吐量优先的老年代版本 |
| CMS | 多线程 | 标记清除 | 老年代 | 停顿短,有碎片和浮动垃圾 |
| G1 | 多线程 | 分区 + 复制/整理 | 整堆 | 可预测停顿,适合大堆 |
Serial 是最早的收集器,单线程,回收时会暂停所有用户线程,也就是常说的 Stop The World。堆不大的时候,这点停顿可能只有几十毫秒,完全能接受,所以它至今仍在客户端场景里有一席之地。新收集器并不自动等于更好的选择,堆只有几百兆却硬上 G1,复杂的分区维护反而成了负担。
CMS 的设计目标是缩短停顿,它把回收拆成四个阶段。初始标记只标记 GC Roots 直接关联的对象,速度很快,需要停顿。并发标记从这些对象出发遍历整个引用链,耗时最长,但可以和用户线程同时跑。重新标记修正并发标记期间因为用户线程继续运行而变动的那部分记录,需要停顿,但比初始标记长不了太多。并发清除一边清理垃圾一边让用户线程继续工作。整个流程里真正停顿的只有初始标记和重新标记两段。
CMS 的代价也很明显。并发清除阶段用户线程还在产生新垃圾,这些垃圾本轮收不掉,只能留给下一次,叫浮动垃圾。它用的是标记清除算法,跑久了必然产生碎片,碎片严重时会退化成 Serial Old 做一次全堆整理,那一下停顿可能比不用 CMS 还长。所以用了 CMS 还得配几个碎片整理相关的参数,这又是一堆踩坑点。
G1 把堆切成 Region 之后,维护了一张表记录每个 Region 的回收价值,每次回收优先挑价值最高的那些,同时用户可以在启动参数里设一个期望的最大停顿时间,G1 会尽量在这个时间内完成。它适合堆特别大、又要求停顿可控的服务。
# 启用 G1 收集器,并把单次停顿的目标上限定为 200 毫秒 # 注意这是目标值不是硬保证,实际停顿仍受对象存活情况影响 java -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -jar service.jarMaxGCPauseMillis设得越小,G1 每次愿意回收的 Region 就越少,回收频率相应变高,总体吞吐会下降。这个参数没有普适的最优值,得结合服务的延迟要求实测。参数只是旋钮,拧之前得先知道当前卡在哪。
4. 集合框架为什么必须懂:从 ArrayList 到 ConcurrentHashMap
4.1 List 与 Set:数组、链表、哈希表的三种性格
集合框架的顶层分两条线:Collection 下面是 List 和 Set,Map 自成一体不继承 Collection。List 讲究有序可重复,Set 讲究不重复,Map 存的是键值对。
ArrayList 底层是一个数组。数组的特点是内存连续,所以按下标访问是常数时间,速度快得没有悬念;但往中间插入或删除一个元素,后面的所有元素都得整体挪位,元素越多越慢。它还有一个扩容机制,数组装满了就申请一个更大的新数组,把老数据复制过去,新容量是原容量的 1.5 倍。这个 1.5 倍是有讲究的,太小会导致频繁扩容复制,太大又会浪费空间。
Vector 同样是数组实现,但每个方法都加了同步锁,所以线程安全。代价是单线程环境下也要承担加锁开销,性能比 ArrayList 差一截。它属于早期版本的遗留类,新代码基本不再用它来做线程安全的列表。
LinkedList 底层是双向链表,每个节点记住前后两个邻居。它的强项是在头尾增删,改几个指针就完事;弱项是随机访问,想拿第十个元素,得从头一个接一个数过去。所以它的使用场景很明确:频繁在两端插入删除、很少按位置读取。
Set 家族里,HashSet 是最常用的。它内部其实就是一个 HashMap,把元素当成键存进去,值统一放一个占位的常量对象。所以 HashSet 去重的原理和 HashMap 的键去重完全是同一套。TreeSet 底层是红黑树,元素按大小排好序,代价是每次插入都要维护树的平衡,比哈希表慢。LinkedHashSet 在 HashSet 的基础上额外用链表记录了插入顺序,遍历时能按插入的先后输出。
4.2 HashMap 的两次进化:Java 7 与 Java 8 的差别
HashMap 是整个集合框架里被问得最细的一个,因为它的实现前后改过两次,两次的差异恰好能说明很多设计取舍。
Java 7 的实现是数组加链表。数组的每个位置叫一个桶,键经过哈希计算落到某个桶里,如果桶里已经有元素,就用头插法挂到链表最前面。头插法的好处是插入快,坏处是扩容时可能把链表顺序颠倒。这在单线程下无所谓,但在并发扩容的场景里,两个线程同时搬运同一条链表,就有可能把链表节点互相指成环,之后任何一次查询都会死循环,CPU 直接打满。这是一类经典线上故障,排查时表现为 CPU 飙高但看不出来在算什么。
Java 8 改成了数组加链表加红黑树,插入方式也从头插改成尾插。链表长度超过八并且数组长度不小于六十四时,这条链表会转成红黑树,查询复杂度从线性降到对数级。等树上的节点减少到六个以下,又会退化回链表。八和六这两个阈值留了缓冲,避免在临界值附近反复转换结构。
哈希计算这块还有个细节。键的哈希值是一个三十二位整数,但数组容量通常只有几十上百,直接拿哈希值当索引会越界,所以要取模。取模用除法慢,而容量取二的幂时,可以用位运算代替,速度差好几倍。为了让位运算的结果分布均匀,代码里会先把哈希值的高十六位异或到低十六位上,让高位的信息也参与运算,避免高半段不同、低半段相同的键全挤到一个桶里。
容量取二的幂、索引用位运算、哈希值先扰动,这三件事是连在一起的一套设计,拆开任何一件都会出问题。这也是为什么初始化时可以指定容量,但内部会把它向上取到最近的二的幂。
// 明确知道大约要放 1000 个元素时,直接按容量初始化 // 传入 1024 会被保留,传入 1000 会被内部调整到 1024 // 这样能避免默认容量 16 反复扩容带来的多次数组复制 Map<String, Integer> counter = new HashMap<>(1024); // 负载因子默认 0.75,元素数超过 容量 × 0.75 时触发扩容 // 调大能减少扩容次数但增加哈希冲突,调小则相反,一般不建议改动负载因子零点七五是个经验平衡点。设成 1 意味着数组全满才扩容,冲突率会明显上升;设成 0.5 意味着空间只用一半就要翻倍,内存浪费严重。零点七五在冲突概率和空间利用率之间取了折中,没有特殊需求就别动它。
4.3 ConcurrentHashMap:从分段锁到更细粒度锁的演进
单线程用 HashMap 没问题,一旦多个线程同时写,就必须换线程安全的容器。最老的选择是 Hashtable,它在每个方法上加了同步锁,等于给整张表上锁,同一时刻只能有一个线程操作,并发度高的时候全在排队。
ConcurrentHashMap 的思路是把锁的范围缩小。早期版本引入了 Segment 段的概念,一个 ConcurrentHashMap 里包含多个 Segment,每个 Segment 继承自可重入锁,各自管着自己那一小片桶。默认并行度是十六,也就是允许十六个线程同时写,只要它们落在不同的 Segment 上就不冲突。读取大多不加锁,靠可见性保证。这个设计把并发粒度从「整张表」降到「一段」。
后来的版本又往前推了一步,放弃了 Segment,直接在数组的每个桶上用 CAS 加同步代码块控制。数组为空的位置用 CAS 抢着写入,失败了就重试;已经有元素的位置只锁住那个桶的头节点,粒度细到单个桶。同时它也引入了红黑树,冲突多了照样转树。并发度实际上受数组长度限制,数组越长能同时写的线程越多。
元素个数的统计也做了改动。并发场景下用一个计数器累加会成为瓶颈,所以它维护了一组分散的计数单元,每个线程更新自己那一格,需要总数时再加起来。这和单纯加个原子变量相比,热点被打散了。
注意:ConcurrentHashMap 只保证单个操作的原子性,像「先检查再写入」这种复合操作,仍然要自己加锁或用它提供的方法,别想当然地以为换成线程安全容器就万事大吉。
5. 五个最容易混的知识点:名字相近,含义差很远
5.1 内存区域里的两组混淆
易混点一:方法区、永久代、元空间。这三个词经常被当成同一件事,为什么混?因为它们描述的是同一块逻辑区域在不同时期的不同实现。方法区是规范里定义的一个概念,说的是「存放类元数据的那块内存」,它只规定逻辑作用,不规定用什么技术实现。永久代是早期某个 JVM 实现用来落地方法区的一块堆内区域,因为放在堆里,受堆大小限制,类加载多了就容易溢出。元空间是后来换上的实现,把这块内容挪到了本地内存,不再占堆空间。怎么区分?记住一句话:方法区是接口,永久代和元空间是实现。看到「永久代溢出」要知道那是老版本的事,新版本对应的是元空间涨到本地内存上限。
易混点二:堆和栈。很多初学者以为基本类型放栈、对象放堆。为什么混?因为局部变量的确有存在栈上的。准确的说法是:局部变量表里存的是变量的槽位,基本类型的值直接存在槽里,对象则存一个指向堆的引用,对象本体永远在堆上。栈的生命周期跟着方法走,方法返回栈帧就没了;堆的生命周期跟着对象走,得等垃圾回收。怎么区分?问一句「方法返回之后这个数据还需要存在吗」,需要,就在堆上;不需要,栈上就够了。
5.2 回收机制里的两组混淆
易混点三:回收算法和垃圾收集器。这两个词在讨论里经常互换着用,但层次完全不同。算法是抽象的步骤,比如标记清除、复制、标记整理,讲的是「怎么找垃圾、怎么清」;收集器是具体产品,比如某个单线程收集器、某个并发收集器,讲的是「在哪块区域、用几个线程、按什么策略执行算法」。为什么混?因为很多收集器的名字里直接带着算法名,读起来像一个东西。怎么区分?算法是菜谱,收集器是厨师。同一个厨师可以按不同菜谱做菜,同一个菜谱也能被不同厨师用不同火候执行。
易混点四:分代收集和分区收集。两者都是把堆拆开处理,所以特别容易糊在一起。分代的依据是对象的存活年龄,把堆物理上隔成新生代和老年代,不同代用不同算法,这是一条按时间维度做区分的思路。分区的依据是内存块,把堆切成大小相等的 Region,每个 Region 动态扮演不同角色,这是一条按空间维度做区分的思路。怎么区分?看划分依据是「对象活了多久」还是「内存在哪个位置」。前者关心生命周期,后者关心回收价值。
5.3 集合里的一组混淆
易混点五:HashMap、Hashtable、ConcurrentHashMap。三个名字长得像,用途都是键值存储,差别全在并发处理上。HashMap 完全不加锁,单线程最快,多线程写会出问题。Hashtable 在方法级别加锁,安全但并发能力差,基本被淘汰。ConcurrentHashMap 把锁细化到桶级别,读操作大多不加锁,是并发场景的首选。为什么混?因为三者对外暴露的方法名几乎一样,put、get、remove换个类照样能编译通过,不报错就以为等价了。怎么区分?看使用场景:确定单线程用 HashMap,需要线程安全用 ConcurrentHashMap,Hashtable 只在维护老代码时才会遇到。还有一个细节常被忽略,HashMap 允许一个空键和多个空值,另外两个都不允许空键。
6. 什么时候该翻这份资料:三种场景与两条边界
先给一张场景对照表,把「该用」和「别指望」摆在一起看。
| 场景 | 适合怎么用 | 不适合怎么用 |
|---|---|---|
| 面试前的系统复习 | 按 JVM、集合两大块梳理脉络,把每个概念的「为什么」讲顺 | 只背结论不看推导,被追问一层就断 |
| 线上问题排查 | 拿内存区域划分和回收流程当排查地图,先定位是哪块区域出问题 | 指望靠它直接给出修复方案,具体参数还得看现场 |
| 补基础、读源码前 | 建立整体框架,知道每个类、每个参数在体系里的位置 | 用它替代官方文档和源码,细节永远以源码为准 |
第一种场景最直接。面试官问「说一下 JVM 内存模型」,他要的不是你把五块区域的名字报一遍,而是想听你说清哪些是线程私有、为什么私有、哪块会抛什么异常、堆为什么要分代。这套资料的价值在于它把这些问题串成了一条线,你顺着线讲,比零散背诵要稳得多。我见过不少人把「程序计数器不会 OOM」背得滚瓜烂熟,但被问「那它为什么要线程私有」就卡住了,这就是只记结论没建立联系。
第二种场景更实际。线上出现内存溢出,第一反应不该是加内存,而是先判断是哪块区域溢出了。堆溢出通常是对象太多或者有泄漏,栈溢出通常是递归太深或者调用链太长,元空间溢出通常是动态生成类太多。判断清楚方向,再去翻对应的那部分内容,效率比从头读到尾高得多。类似的,GC 日志里频繁出现新生代回收但对象就是晋升不上去,那就该去看 Survivor 区比例和晋升阈值,而不是盲目换收集器。
第三种场景是给自己打底。集合框架的源码动辄几千行,直接啃很容易迷路。先把接口继承关系、每种实现的底层结构、时间复杂度的取舍搞清楚,再去看具体实现,会顺畅很多。比如知道了 HashMap 的容量必须是二的幂是为了用位运算取模,再去看那段扩容代码,就不会觉得那些位运算是故弄玄虚。
两条边界也得说清楚。第一,这套整理是知识地图不是操作手册,它能告诉你 CMS 有浮动垃圾这个问题,但你的服务该不该用 CMS、参数设多少,得结合堆大小、对象存活率、延迟要求实测,别人机器上的最优参数搬到你这儿很可能直接翻车。第二,背下来不等于会写,并发编程尤其如此。知道 ConcurrentHashMap 是线程安全的,不代表你写的「先查后改」逻辑就是原子的,这类坑只有真正写过、错过才会长记性。
从那以后我每次准备面试或者排查内存问题,都强制自己先拿张纸把五块内存区域和它们的归属默画一遍,再把新生代回收的三步走一遍,画不出来的地方就是没真懂的地方,回头补哪一块一目了然。这份整理适合当那张纸的底稿,顺着它把概念一个个过一遍,比漫无目的地刷题要省力。希望帮到你。
本文还有配套的精品资源,点击获取