简介:这份PDF资料面向Java后端开发者与求职面试人群,系统梳理Java核心知识中的高频考点,适合用于面试前的集中复习与知识查漏补缺。内容以JVM为主线展开,涵盖JVM架构中的类加载器、执行引擎与运行时数据区,并逐层讲解程序计数器、虚拟机栈、本地方法区、堆、方法区等内存区域,以及新生代Eden区、ServivorFrom、ServivorTo与老年代的划分和MinorGC复制、清空、互换的完整流程,同时延伸至Java语法、集合框架、多线程与设计模式等模块。资源包为1个PDF文件,大小约10.71MB,目录层级清晰,便于按章节定位知识点。目前已有646人学习下载,可作为面试八股文速查与体系化复习的参考材料。
1. 从一份 283 页的 Java 核心知识整理说起:它到底能帮你解决什么
面试前一周,你打开收藏夹里十几个零散的博客链接,发现 JVM 内存模型看了三遍还是记混,HashMap 的扩容机制每次都要重新推一遍,ConcurrentHashMap 在 JDK7 和 JDK8 里的实现差异更是像一团乱麻。这不是你记性差,是知识没有形成结构。这份《Java核心知识整理(常考八股文)》就是冲着这个场景来的——它把 JVM、Java 集合、多线程、设计模式这些后端面试里反复出现的核心考点,按知识模块整理成了一份 283 页的系统性文档。适合正在准备 Java 后端面试的开发者,也适合工作两三年后想回头把基础重新捋一遍的人。它不是那种读完就忘的速查表,而是按「JVM → 集合 → 多线程 → 设计模式」的递进顺序,把每个知识点的来龙去脉讲清楚。下面我从这份资料的实际结构出发,拆解怎么用它、哪些地方容易翻车、以及怎么把里面的内容真正变成自己的东西。
2. JVM 内存区域与垃圾回收:从运行时数据区到 G1 收集器的完整链路
2.1 线程私有与共享区域的分界逻辑
JVM 内存区域这块,很多人背了「程序计数器、虚拟机栈、本地方法栈、堆、方法区」这五个名字,但面试官一问「哪些是线程私有的、为什么」就卡住了。这份资料在 2.2 节里把每个区域的归属和职责列得很清楚,我建议你按下面这个顺序去理解,而不是死记硬背。
程序计数器是唯一不会抛 OutOfMemoryError 的区域,它存的是当前线程执行的字节码指令地址。虚拟机栈描述的是 Java 方法执行的内存模型,每个方法执行时都会创建一个栈帧,里面放局部变量表、操作数栈、动态链接和方法出口。本地方法栈跟虚拟机栈作用类似,只不过服务的是 native 方法。这三块都是线程私有的,生命周期跟线程绑定。
堆和方法区是线程共享的。堆存放对象实例和数组,是 GC 的主战场。方法区在 JDK8 之前叫永久代,JDK8 之后被元数据区取代,主要存类的元信息、常量池、静态变量。这里有个常见的翻车点:很多人把「方法区」和「永久代」当成一回事,实际上永久代只是 HotSpot 虚拟机对方法区的一种实现方式,JDK8 换成元数据区之后,方法区的概念还在,但实现变了。
提示:面试时如果被问到「JDK8 为什么用元数据区替换永久代」,可以从内存回收效率、字符串常量池位置变化、以及永久代容易 OOM 这几个角度回答。
2.2 新生代复制算法与 MinorGC 的三步走
新生代的内存布局是这份资料里讲得比较细的部分。Eden 区、ServivorFrom 区、ServivorTo 区,默认比例是 8:1:1。新创建的对象先放在 Eden 区,当 Eden 区满了触发 MinorGC,过程分三步:
第一步,把 Eden 区和 ServivorFrom 区中存活的对象复制到 ServivorTo 区,同时把这些对象的年龄加 1。第二步,清空 Eden 区和 ServivorFrom 区。第三步,ServivorTo 区和 ServivorFrom 区互换角色,原来的 ServivorTo 变成下一次 GC 的 ServivorFrom。
这个「复制 → 清空 → 互换」的流程,资料里用文字描述了一遍,但光看文字容易晕。我一般会建议用下面这段代码去实际观察一下对象在新生代里的晋升过程:
// 配合 -Xmn10m -XX:SurvivorRatio=8 -XX:+PrintGCDetails 运行 public class MinorGCTest { private static final int _1MB = 1024 * 1024; public static void main(String[] args) { byte[] allocation1 = new byte[2 * _1MB]; byte[] allocation2 = new byte[2 * _1MB]; byte[] allocation3 = new byte[2 * _1MB]; // 触发 MinorGC,观察 Eden 区和 Survivor 区的变化 byte[] allocation4 = new byte[4 * _1MB]; } }这段代码的关键在 JVM 参数:-Xmn10m把新生代设为 10MB,-XX:SurvivorRatio=8让 Eden 和每个 Survivor 的比例为 8:1,也就是 Eden 占 8MB,两个 Survivor 各占 1MB。-XX:+PrintGCDetails会把 GC 日志打出来。运行后你会看到 allocation4 分配时 Eden 区不够用,触发 MinorGC,前三个 2MB 的对象因为 Survivor 区放不下,直接通过分配担保机制进了老年代。这个实验比背十遍文字都管用。
2.3 垃圾收集器的选型与 CMS 的四个阶段
资料 2.7 节把 Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1 都列了一遍。实际面试里被问得最多的是 CMS 和 G1。
CMS 收集器的工作流程分四个阶段:初始标记、并发标记、重新标记、并发清除。初始标记只标记 GC Roots 能直接关联到的对象,速度很快,需要 Stop The World。并发标记阶段做可达性分析,耗时最长,但不需要停顿。重新标记阶段修正并发标记期间因为用户程序继续运行而导致标记变动的那部分对象,需要 Stop The World。最后并发清除阶段回收垃圾对象。
CMS 的坑在于并发清除阶段产生的浮动垃圾,以及标记清除算法带来的内存碎片。如果碎片过多,CMS 会退化成 Serial Old 来做一次 Full GC,停顿时间反而更长。G1 收集器把堆分成多个 Region,按 Region 回收,可以预测停顿时间,适合大堆场景。
| 收集器 | 适用区域 | 算法 | 特点 |
|---|---|---|---|
| Serial | 新生代 | 复制 | 单线程,适合客户端 |
| ParNew | 新生代 | 复制 | Serial 的多线程版 |
| Parallel Scavenge | 新生代 | 复制 | 关注吞吐量 |
| CMS | 老年代 | 标记清除 | 关注停顿时间 |
| G1 | 全堆 | 分区 | 可预测停顿 |
选型上没有绝对的好坏,关键看你的应用是更在意吞吐量还是停顿时间。Web 应用一般对停顿更敏感,CMS 或 G1 是常见选择;离线计算任务更看重吞吐量,Parallel 系列更合适。
3. 集合框架的底层结构:HashMap 扩容、ConcurrentHashMap 分段锁与红黑树
3.1 HashMap 在 JDK7 与 JDK8 中的实现差异
HashMap 是集合框架里被问得最多的一个类。资料 3.4.1 节把 JDK7 和 JDK8 的实现分开讲,这个处理是对的,因为两者的底层结构确实变了。
JDK7 的 HashMap 是数组加链表。每个数组元素是一个 Entry 节点,发生哈希冲突时用头插法把新节点插入链表头部。扩容时,如果多个线程同时触发扩容,头插法可能导致链表形成环,这是 JDK7 HashMap 线程不安全的经典表现。
JDK8 改成了数组加链表加红黑树。当链表长度超过 8 且数组长度大于等于 64 时,链表转为红黑树,查询复杂度从 O(n) 降到 O(log n)。扩容时改用尾插法,避免了成环问题。但 JDK8 的 HashMap 仍然不是线程安全的,多线程并发 put 可能导致数据覆盖。
// 观察 HashMap 扩容时的容量变化 public class HashMapResizeTest { public static void main(String[] args) throws Exception { HashMap<Integer, String> map = new HashMap<>(4); for (int i = 0; i < 12; i++) { map.put(i, "value" + i); // 通过反射获取当前容量和阈值 Field tableField = HashMap.class.getDeclaredField("table"); tableField.setAccessible(true); Object[] table = (Object[]) tableField.get(map); System.out.println("size=" + map.size() + ", capacity=" + (table == null ? 0 : table.length)); } } }这段代码用反射拿到 HashMap 内部的 table 数组,观察每次 put 之后容量的变化。默认负载因子是 0.75,初始容量 4,当元素个数超过 4×0.75=3 时触发扩容,容量翻倍。运行后你会看到容量从 4 到 8 到 16 的变化过程。注意反射访问私有字段在 JDK9 之后需要额外处理模块化限制,如果跑不起来,加--add-opens java.base/java.util=ALL-UNNAMED参数。
3.2 ConcurrentHashMap 的分段锁与 CAS 演进
ConcurrentHashMap 在 JDK7 和 JDK8 里的实现思路完全不同。JDK7 用的是分段锁,整个容器分成 16 个 Segment,每个 Segment 继承 ReentrantLock,各自独立加锁。这样多个线程访问不同 Segment 时不会互相阻塞,并行度默认是 16。
JDK8 放弃了分段锁,改用 CAS + synchronized。put 操作时,如果当前桶为空,用 CAS 直接写入;如果桶不为空,用 synchronized 锁住当前桶的头节点,再操作链表或红黑树。锁的粒度从 Segment 级别细化到了桶级别,并发度更高。
资料里 3.4.2 节提到了「并行度默认 16」,这是 JDK7 的概念。JDK8 里没有并行度这个参数了,因为锁的粒度已经足够细。面试时如果被问到 ConcurrentHashMap 的线程安全实现,要分版本回答,不能混在一起说。
注意:JDK8 的 ConcurrentHashMap 在扩容时支持多线程协助扩容,通过 ForwardingNode 标记正在迁移的桶,其他线程遇到 ForwardingNode 时会加入扩容。这个机制在面试里是加分项。
3.3 ArrayList 与 LinkedList 的选型边界
ArrayList 底层是数组,支持随机访问,查询时间复杂度 O(1),但插入和删除需要移动元素,平均复杂度 O(n)。LinkedList 底层是双向链表,插入和删除只需要改指针,复杂度 O(1),但查询需要遍历,复杂度 O(n)。
选型上,如果读多写少,用 ArrayList;如果频繁在头部或中间插入删除,用 LinkedList。但实际开发中 LinkedList 用得并不多,因为它的内存开销比 ArrayList 大,每个节点都要额外存前后指针,而且 CPU 缓存局部性差。大部分场景下 ArrayList 加合理的初始容量就够了。
Vector 是 ArrayList 的线程安全版本,所有方法都加了 synchronized,性能差,已经基本被淘汰。需要线程安全的 List 时,用 CopyOnWriteArrayList 或者 Collections.synchronizedList 更合适。
4. 多线程与并发:从线程状态到锁升级的排查思路
4.1 线程生命周期与状态转换的实际观察
Java 线程有六种状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。资料里对线程部分着墨不多,但这块在面试和实际排查里都很重要。
NEW 是线程创建了但还没调用 start()。RUNNABLE 是线程正在执行或等待 CPU 时间片。BLOCKED 是线程等待获取 synchronized 锁。WAITING 是调用了 Object.wait()、Thread.join() 或 LockSupport.park() 后无限期等待。TIMED_WAITING 是带超时参数的等待。TERMINATED 是线程执行完毕。
实际排查线程问题时,我一般用 jstack 导出线程快照,然后看线程状态分布。如果大量线程处于 BLOCKED 状态,说明锁竞争严重;如果大量线程处于 WAITING 状态,可能是线程池配置不合理或者有死锁。
# 导出线程快照,统计各状态线程数 jstack <pid> > thread_dump.txt grep "java.lang.Thread.State" thread_dump.txt | sort | uniq -c | sort -rn这段命令先导出线程快照,然后统计每种状态的线程数量。如果 BLOCKED 状态的线程数异常高,就需要去看具体是哪个锁被争抢。jstack 输出里会显示waiting to lock和locked的地址,对照着找就能定位到具体的代码行。
4.2 synchronized 锁升级与 volatile 的内存语义
synchronized 在 JDK6 之后做了大量优化,引入了偏向锁、轻量级锁、重量级锁的升级过程。偏向锁是只有一个线程访问时,把线程 ID 记在对象头里,下次同一线程进入不需要 CAS。轻量级锁是多个线程交替访问时,用 CAS 自旋获取锁。重量级锁是竞争激烈时,线程被挂起,进入操作系统层面的等待队列。
锁升级的过程是不可逆的,一旦升级到重量级锁就不会降级。这个设计是为了避免频繁升级降级带来的开销。实际开发中,如果 synchronized 块里的代码执行时间很短,轻量级锁的自旋是有意义的;如果执行时间长,自旋会浪费 CPU,不如直接挂起。
volatile 保证的是可见性和有序性,不保证原子性。写 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新回主内存;读 volatile 变量时,JMM 会把该线程对应的本地内存置为无效,从主内存中读取。volatile 还通过内存屏障禁止指令重排序,典型应用是双重检查锁定的单例模式。
public class Singleton { // volatile 禁止指令重排序,避免拿到未初始化完成的对象 private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }这段双重检查锁定里,instance = new Singleton()实际上分三步:分配内存、初始化对象、把引用指向内存地址。如果没有 volatile,编译器或 CPU 可能把第二步和第三步重排序,导致另一个线程拿到一个引用不为 null 但对象还没初始化完成的情况。加上 volatile 之后,重排序被禁止,这个隐患就消除了。
4.3 线程池参数设置与拒绝策略的选择
线程池的核心参数有七个:corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。任务提交时的处理顺序是:核心线程未满则创建核心线程;核心线程满了则入队列;队列满了则创建非核心线程;非核心线程也满了则执行拒绝策略。
拒绝策略有四种:AbortPolicy 直接抛异常,CallerRunsPolicy 让提交任务的线程自己执行,DiscardPolicy 直接丢弃,DiscardOldestPolicy 丢弃队列里最老的任务再尝试提交。默认是 AbortPolicy。
实际配置时,CPU 密集型任务的核心线程数一般设为 CPU 核数加 1,IO 密集型任务设为 CPU 核数乘以 2。但这只是经验值,具体还要看任务的阻塞系数。队列建议用有界队列,避免任务无限堆积导致 OOM。
5. 避坑与排查:这份资料在实际使用中的五个常见问题
5.1 把「背答案」当成「理解原理」
现象:面试时能把 JVM 内存区域背出来,但被追问「为什么程序计数器不会 OOM」就答不上来。
原因:这份资料是知识点整理,不是教程,它把结论直接摆出来了,但没有展开推导过程。如果只记结论,遇到变体问题就容易露馅。
解决:每看完一个知识点,自己问三个问题——这个设计解决了什么问题?如果不这样设计会怎样?有没有例外情况?比如程序计数器不会 OOM,是因为它只存一个指令地址,所需内存极小且在编译期就能确定,不存在动态扩展的需求。
5.2 混淆 JDK 版本间的实现差异
现象:面试官问 ConcurrentHashMap 的线程安全实现,你说了分段锁,面试官说「那是 JDK7 的,JDK8 呢」。
原因:资料里有些章节把不同版本的实现放在一起讲,没有明确标注版本边界。读者如果跳着看,很容易把不同版本的内容混在一起。
解决:看集合和并发章节时,先确认当前讲的是哪个 JDK 版本。HashMap 的 JDK7 和 JDK8 实现、ConcurrentHashMap 的分段锁和 CAS、方法区的永久代和元数据区,这三组是版本差异最容易被问到的点,建议单独整理成对比表。
5.3 忽略 GC 日志的实际解读
现象:知道 CMS 有四个阶段,但线上出现 Full GC 频繁时,看不懂 GC 日志,不知道从哪下手。
原因:资料偏理论,没有给出 GC 日志的解读方法。很多人背了收集器的原理,但没实际看过日志。
解决:在本地用-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log跑一个 demo,观察 MinorGC 和 FullGC 的日志格式。重点看 GC 前后各区域的内存变化、GC 耗时、以及触发 GC 的原因。线上环境可以用jstat -gcutil <pid> 1000每秒打印一次 GC 统计,观察 Eden、Old、Metaspace 的使用率变化趋势。
5.4 线程池参数照搬网上模板
现象:按照「CPU 核数 × 2」设置了线程池大小,结果线上任务堆积严重。
原因:这个经验值只适用于 IO 密集型任务,而且没有考虑队列容量和任务的实际执行时间。如果任务执行时间长、队列又是有界的,核心线程数不够时任务会堆积在队列里,而不是创建新线程。
解决:先估算任务的 QPS 和平均执行时间,用利特尔法则算出需要的并发线程数。然后设置合理的队列容量,配合拒绝策略做降级。上线后通过ThreadPoolExecutor的getActiveCount()、getQueue().size()监控线程池状态,根据实际数据调整。
5.5 死记设计模式而不会识别适用场景
现象:能说出单例模式、工厂模式、观察者模式的定义,但拿到一个实际业务场景不知道用哪个。
原因:设计模式部分如果只背 UML 图和定义,不结合具体代码场景,很难形成直觉。
解决:每学一个模式,去 JDK 源码里找对应的实现。比如单例模式看Runtime.getRuntime(),工厂模式看Calendar.getInstance(),观察者模式看java.util.Observer和Observable。把模式跟真实代码对应起来,比背定义管用得多。
6. 把 283 页读薄:用自测清单和版本对比表做二次消化
这份资料最大的价值在于结构完整,但 283 页的体量如果从头到尾线性读,很容易读了后面忘了前面。我的习惯是把它当字典用,而不是当教材用。具体做法分三步。
第一步,先花半天时间把目录过一遍,在脑子里建立知识地图。JVM 部分从内存区域到垃圾回收再到类加载机制,是一条完整的链路;集合部分从 List 到 Set 到 Map,重点在 HashMap 和 ConcurrentHashMap;多线程部分从线程状态到锁到线程池,核心是并发安全和性能的权衡。
第二步,针对每个模块做一份自测清单。下面这张表是我自己整理的高频考点自测表,你可以照着填,填不出来的就是需要重点补的地方。
| 模块 | 自测问题 | 掌握程度 |
|---|---|---|
| JVM 内存 | 程序计数器为什么不会 OOM? | |
| JVM 内存 | 方法区和永久代的区别是什么? | |
| 垃圾回收 | MinorGC 的三步流程是什么? | |
| 垃圾回收 | CMS 的四个阶段哪些需要 STW? | |
| 集合 | HashMap 在 JDK8 中何时转红黑树? | |
| 集合 | ConcurrentHashMap 在 JDK8 中如何保证线程安全? | |
| 多线程 | synchronized 锁升级的过程是什么? | |
| 多线程 | volatile 如何保证可见性和有序性? | |
| 线程池 | 任务提交后的处理顺序是什么? | |
| 类加载 | 双亲委派模型的工作流程是什么? |
第三步,做版本对比表。Java 面试里有一类问题专门考版本差异,比如「HashMap 在 JDK7 和 JDK8 中的区别」「ConcurrentHashMap 在 JDK7 和 JDK8 中的区别」「方法区在 JDK8 前后的变化」。这些点如果混在一起记,面试时很容易说错。我一般会单独拉一张表,左边写 JDK7 的实现,右边写 JDK8 的实现,中间写变化的原因。比如 HashMap 的对比:JDK7 是数组加链表、头插法、扩容时可能成环;JDK8 是数组加链表加红黑树、尾插法、扩容时保持顺序。变化的原因是解决哈希冲突严重时的查询效率问题和并发扩容成环问题。
最后说一个我自己的教训。刚开始准备面试时,我总想把每个知识点都背得一字不差,结果面试时一紧张就全忘了。后来我改成用自己的话把每个知识点讲一遍,讲不顺的地方就回去翻资料,直到能像聊天一样说出来。从那以后我每次复习新技术都强制走一遍「自测清单 + 版本对比表 + 口头复述」的流程,比单纯看资料效率高得多。希望帮到你。
本文还有配套的精品资源,点击获取