简介:这份PDF资料面向Java后端开发者与求职面试人群,系统梳理Java核心知识中的高频考点,帮助读者在面试前快速回顾JVM、集合、多线程、设计模式等常考内容。资源为单个PDF文件,压缩包约10.71MB,内容以文字与目录结构为主,便于按章节检索和打印复习。目录从JVM展开,涵盖线程、JVM内存区域(程序计数器、虚拟机栈、本地方法区、堆、方法区)、JVM运行时内存(新生代Eden区、ServivorFrom、ServivorTo、MinorGC复制清空互换流程、老年代、永久代及Java8元数据)等模块,并延伸至Java语法、集合框架、多线程与设计模式等后续知识点。已有646人学习下载,适合需要集中突破八股文、构建知识框架的初中级开发者对照查漏补缺。
1. Java核心知识整理:为什么面试官总在JVM和并发这两块反复追问
工作五年以上的Java工程师,简历上写“精通Java”的不少,但真到面试环节,被问到JVM内存模型和并发编程细节时能答利索的,比例并不高。这不是知识储备不够,而是大多数人的知识是“用出来的”——平时写业务代码,Spring帮你管了对象生命周期,线程池帮你管了线程调度,你很少需要直接面对OutOfMemoryError或者死锁。但面试官恰恰喜欢在这些“你平时不直接碰、但出了问题就是大问题”的地方深挖,因为这里能区分出“会用Java”和“懂Java”的人。
这份整理面向两类人:一是准备跳槽、需要系统梳理Java核心知识点的开发者;二是工作几年后发现自己对Java底层机制理解模糊、想补课的一线工程师。内容覆盖JVM内存结构与垃圾回收、并发编程核心机制、集合框架源码要点、以及高频“八股文”背后的真实原理。不堆砌概念,每个知识点都落到“面试怎么问、代码怎么写、参数怎么调、坑在哪”这四个维度上。
2. JVM内存结构与垃圾回收:从对象分配到GC调优的完整链路
2.1 运行时数据区到底怎么划分,哪些区域会OOM
JVM运行时数据区分为线程私有和线程共享两大部分。线程私有的包括程序计数器、虚拟机栈、本地方法栈;线程共享的包括堆和方法区(JDK 8之后方法区的实现叫元空间Metaspace)。程序计数器是唯一不会抛OutOfMemoryError的区域,它记录当前线程执行的字节码行号。虚拟机栈描述的是Java方法执行的内存模型,每个方法执行时创建一个栈帧,栈帧里存局部变量表、操作数栈、动态链接、方法出口等信息。
栈深度超限抛StackOverflowError,常见于递归调用没有正确终止条件。堆和方法区内存不足则抛OutOfMemoryError。JDK 8之后永久代被元空间取代,元空间使用本地内存,默认不限制大小,但可以通过-XX:MaxMetaspaceSize控制。这意味着以前永久代OOM的场景(比如动态生成大量类)现在变成了元空间OOM,排查思路类似但参数不同。
// 模拟栈溢出:递归没有终止条件 public class StackOverflowDemo { private static int depth = 0; public static void recursive() { depth++; recursive(); // 无限递归,最终抛StackOverflowError } public static void main(String[] args) { try { recursive(); } catch (StackOverflowError e) { System.out.println("栈深度: " + depth); // 输出实际达到的深度 } } }这段代码用来观察栈深度。默认栈大小通常是512KB到1MB(取决于平台和JVM版本),可以通过-Xss调整。每个栈帧的大小取决于局部变量表大小和操作数栈深度。实际面试中常问“栈帧里有什么”,答出局部变量表、操作数栈、动态链接、返回地址这四个核心部分就够了。
堆是GC的主战场。对象优先在Eden区分配,当Eden区没有足够空间时触发Minor GC。大对象直接进入老年代,可以通过-XX:PretenureSizeThreshold设置阈值,但这个参数只对Serial和ParNew收集器有效。长期存活的对象晋升到老年代,晋升年龄阈值由-XX:MaxTenuringThreshold控制,默认15。动态年龄判定规则是:如果Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象直接进入老年代。
2.2 垃圾回收算法与收集器选型:CMS、G1、ZGC怎么选
标记-清除、标记-复制、标记-整理是三种基础算法。标记-清除产生内存碎片,标记-复制浪费一半空间但效率高,标记-整理没有碎片但移动对象成本高。实际JVM实现是这三种算法的组合。
Serial收集器是单线程的,进行GC时必须暂停所有用户线程(Stop The World)。ParNew是Serial的多线程版本,主要配合CMS使用。Parallel Scavenge关注吞吐量,适合后台计算型任务。CMS以最短停顿时间为目标,使用标记-清除算法,会产生内存碎片,当碎片过多时退化为Serial Old,造成长时间停顿。G1把堆分成多个Region,整体是标记-整理,局部是标记-复制,可以预测停顿时间。ZGC和Shenandoah追求极低停顿(10ms以内),使用染色指针和读屏障实现并发整理。
选型建议:堆内存小于4G、追求低延迟可以用CMS;堆内存大于6G、希望可控停顿用G1;对停顿极其敏感(比如实时交易系统)考虑ZGC。但ZGC在JDK 15之前是实验特性,生产环境使用需要评估版本。
# 常用GC参数组合示例 # 使用G1收集器,目标最大停顿200ms,堆大小4G java -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -Xms4g -Xmx4g -jar app.jar # 使用CMS收集器,老年代使用CMS,新生代使用ParNew java -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -Xms2g -Xmx2g -jar app.jar # 打印GC日志(JDK 9之前) java -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log -jar app.jar # JDK 9+统一日志格式 java -Xlog:gc*:file=gc.log:time,uptime,level,tags -jar app.jar参数说明:-XX:MaxGCPauseMillis是G1的目标停顿时间,不是硬性保证,G1会尽量达成但可能失败。-Xms和-Xmx设为相同值可以避免堆动态扩容带来的额外开销。GC日志分析时重点关注Full GC频率和耗时,如果Full GC频繁且每次回收效果差,可能是内存泄漏。
2.3 一个内存泄漏排查的完整过程
内存泄漏的典型表现是老年代占用持续上升,Full GC后回收效果不明显。排查步骤:先用jps找到进程ID,然后用jstat -gcutil <pid> 1000观察GC频率和各区域使用率。如果老年代使用率在Full GC后仍然很高,用jmap -dump:format=b,file=heap.bin <pid>导出堆快照,再用MAT或JVisualVM分析支配树,找到占用最大的对象和引用链。
// 一个典型的内存泄漏场景:静态集合持有对象引用 public class MemoryLeakDemo { private static final List<byte[]> CACHE = new ArrayList<>(); public void addToCache() { while (true) { CACHE.add(new byte[1024 * 1024]); // 每次添加1MB,永不释放 } } }这段代码模拟静态集合导致的内存泄漏。静态变量的生命周期与类相同,如果集合只增不减,对象永远无法被回收。解决方法是使用WeakHashMap或者定期清理,或者改用Guava Cache并设置过期时间。面试中常问“ThreadLocal为什么会内存泄漏”,原理类似:ThreadLocalMap的key是弱引用,但value是强引用,如果线程池中的线程长期存活且没有调用remove(),value就会一直存在。
3. 并发编程核心机制:从synchronized到AQS的底层逻辑
3.1 synchronized锁升级过程与Monitor机制
synchronized的锁状态分为无锁、偏向锁、轻量级锁、重量级锁。偏向锁在JDK 15之后默认禁用,JDK 18之后移除,所以面试中如果问偏向锁,可以提一下这个变化。轻量级锁通过CAS自旋尝试获取锁,适合竞争不激烈的场景。重量级锁依赖操作系统的互斥量,线程阻塞和唤醒需要用户态到内核态切换,开销大。
Monitor是synchronized的底层实现,每个对象都有一个Monitor与之关联。当线程进入同步代码块时,执行monitorenter指令尝试获取Monitor的所有权;退出时执行monitorexit释放。Monitor内部有EntryList、WaitSet、Owner等结构。竞争失败的线程进入EntryList阻塞,调用wait()的线程进入WaitSet等待被notify()唤醒。
public class SynchronizedDemo { private final Object lock = new Object(); private int count = 0; public void increment() { synchronized (lock) { // monitorenter count++; } // monitorexit } // 等价写法:同步方法 public synchronized void decrement() { count--; } }字节码层面,同步代码块编译出monitorenter和monitorexit指令,同步方法通过方法访问标志ACC_SYNCHRONIZED实现。面试中常问“synchronized和ReentrantLock的区别”,核心区别是:synchronized是JVM层面实现,自动释放锁;ReentrantLock是JDK层面实现,需要手动释放,但支持公平锁、可中断、超时获取、条件变量等高级功能。
3.2 AQS原理与ReentrantLock源码要点
AQS(AbstractQueuedSynchronizer)是并发包的核心基础类,ReentrantLock、Semaphore、CountDownLatch都基于它实现。AQS内部维护一个volatile int类型的state变量和一个FIFO双向队列。state的含义由子类定义:ReentrantLock中state表示重入次数,Semaphore中state表示可用许可数。
ReentrantLock的公平锁和非公平锁区别在tryAcquire的实现。非公平锁直接CAS尝试修改state,失败才进入队列;公平锁先检查队列中是否有前驱节点,有则直接排队。非公平锁吞吐量更高,但可能导致线程饥饿。
// 用AQS实现一个简单的不可重入锁 public class SimpleLock extends AbstractQueuedSynchronizer { @Override protected boolean tryAcquire(int arg) { // CAS将state从0改为1 if (compareAndSetState(0, 1)) { setExclusiveOwnerThread(Thread.currentThread()); return true; } return false; } @Override protected boolean tryRelease(int arg) { if (getState() == 0) throw new IllegalMonitorStateException(); setExclusiveOwnerThread(null); setState(0); // 释放锁 return true; } public void lock() { acquire(1); } public void unlock() { release(1); } }这段代码展示了AQS的核心用法:继承并重写tryAcquire和tryRelease,用acquire和release作为对外接口。acquire方法会先调用tryAcquire,失败后把线程包装成Node加入队列并阻塞。release成功后会唤醒队列中的后继节点。面试中常问“AQS的队列是双向还是单向”,答案是双向链表,因为需要从尾部添加节点、从头部唤醒节点,同时节点需要知道前驱节点来判断是否应该阻塞。
3.3 线程池参数设置与拒绝策略选择
ThreadPoolExecutor的七个核心参数:corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。执行流程是:任务提交时,如果当前线程数小于corePoolSize,直接创建新线程执行;如果线程数达到corePoolSize,任务进入workQueue;如果队列满了且线程数小于maximumPoolSize,创建新线程;如果队列满了且线程数达到maximumPoolSize,执行拒绝策略。
// 一个适合IO密集型任务的线程池配置 ThreadPoolExecutor executor = new ThreadPoolExecutor( 8, // corePoolSize: CPU核数 * 2 16, // maximumPoolSize: 峰值扩容 60L, TimeUnit.SECONDS, // 空闲线程存活时间 new LinkedBlockingQueue<>(200), // 有界队列,避免OOM new ThreadFactory() { private final AtomicInteger counter = new AtomicInteger(1); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r, "biz-pool-" + counter.getAndIncrement()); t.setDaemon(false); return t; } }, new ThreadPoolExecutor.CallerRunsPolicy() // 调用者执行,起到背压作用 );参数说明:IO密集型任务corePoolSize可以设为CPU核数乘以2,因为IO等待时CPU可以切换执行其他线程。队列必须用有界队列,LinkedBlockingQueue不指定容量时默认是Integer.MAX_VALUE,任务积压会导致OOM。拒绝策略中CallerRunsPolicy让提交任务的线程自己执行,能减缓任务提交速度。AbortPolicy直接抛异常,适合需要感知拒绝的场景。DiscardOldestPolicy丢弃最老的任务,适合允许丢弃的场景。
4. 集合框架与高频八股:HashMap、ConcurrentHashMap的源码细节
4.1 HashMap的put流程与扩容机制
HashMap的底层结构是数组+链表+红黑树。put流程:计算key的hash值((h = key.hashCode()) ^ (h >>> 16)),然后(n - 1) & hash得到数组下标。如果该位置为空,直接插入;如果不为空,遍历链表或红黑树,找到相同key则覆盖,否则尾插。当链表长度达到8且数组长度达到64时,链表转为红黑树;当红黑树节点数降到6时,退化为链表。
扩容机制:当元素数量超过阈值(容量乘以负载因子,默认0.75)时,容量翻倍。扩容时重新计算每个元素的位置,JDK 8之后采用高低位拆分,不需要重新计算hash,效率更高。
// HashMap的hash计算和下标定位 static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } // 下标计算:n是数组长度,必须是2的幂 // index = (n - 1) & hashhash方法把高16位和低16位异或,是为了让高位也参与运算,减少碰撞。数组长度必须是2的幂,这样(n-1) & hash等价于hash % n,但位运算效率更高。面试中常问“为什么负载因子是0.75”,这是空间和时间成本的折中:太高容易碰撞,太低浪费空间。
4.2 ConcurrentHashMap的线程安全实现
JDK 7的ConcurrentHashMap使用分段锁,把整个Map分成多个Segment,每个Segment独立加锁。JDK 8之后改为CAS+synchronized,锁粒度细化到每个数组槽。put操作时,如果槽为空,用CAS插入;如果不为空,用synchronized锁住头节点,然后遍历插入。
// ConcurrentHashMap的putVal核心逻辑(简化) final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) throw new NullPointerException(); int hash = spread(key.hashCode()); for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; if (tab == null || (n = tab.length) == 0) tab = initTable(); // 懒初始化 else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value))) break; // CAS成功,插入完成 } else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); // 协助扩容 else { synchronized (f) { // 锁住头节点 // 遍历链表或红黑树插入 } } } addCount(1L, binCount); // 更新计数,可能触发扩容 return null; }关键点:ConcurrentHashMap不允许null键和null值,因为无法区分“不存在”和“值为null”。size()方法返回的是估计值,因为并发环境下精确计数代价太高。扩容时支持多线程协助迁移,每个线程负责一部分槽位。
4.3 ArrayList与LinkedList的选型边界
ArrayList基于数组,支持随机访问,尾部插入效率高,中间插入需要移动元素。LinkedList基于双向链表,头尾插入效率高,随机访问需要遍历。大多数场景下ArrayList更优,因为CPU缓存对数组更友好。只有在频繁在头部插入且数据量大的场景,LinkedList才有优势。
// 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; if (newCapacity - MAX_ARRAY_SIZE > 0) newCapacity = hugeCapacity(minCapacity); elementData = Arrays.copyOf(elementData, newCapacity); }面试中常问“ArrayList的默认容量是多少”,答案是10,但注意这个容量是在第一次add时才分配的,不是构造时。ensureCapacity可以在批量添加前手动扩容,减少多次扩容的开销。
5. 避坑与排查:Java核心知识面试中的五个高频翻车点
5.1 把“String不可变”答成“final修饰”
现象:面试官问String为什么不可变,回答“因为String类被final修饰”。原因:final修饰类只表示不能被继承,不代表对象状态不可变。String的不可变性来自内部private final char[] value(JDK 9之后是byte[])以及所有修改方法都返回新对象。解决:答出“final类+final数组+不提供修改数组元素的方法”三个层面。
5.2 线程池用了无界队列导致OOM
现象:线上服务运行一段时间后频繁Full GC,最终OOM。原因:创建线程池时用了new LinkedBlockingQueue<>(),默认容量是Integer.MAX_VALUE,任务积压导致内存耗尽。解决:改用有界队列并设置合理容量,配合CallerRunsPolicy或自定义拒绝策略。排查时用jstack看线程池状态,用jmap看队列中积压的任务对象。
5.3 HashMap并发扩容导致死循环(JDK 7)
现象:多线程环境下HashMap的CPU使用率飙升到100%。原因:JDK 7的HashMap在扩容时采用头插法,多线程同时扩容可能导致链表形成环。解决:并发场景使用ConcurrentHashMap。JDK 8之后改为尾插法,死循环问题不再出现,但并发下仍可能丢失数据。
5.4 ThreadLocal没有remove导致内存泄漏
现象:线程池中的线程复用,ThreadLocal中存储的大对象一直不释放。原因:ThreadLocalMap的key是弱引用,但value是强引用,线程长期存活时value无法回收。解决:使用完ThreadLocal后必须在finally块中调用remove()。排查时用jmap导出堆快照,查找ThreadLocalMap$Entry对象。
5.5 误用subList导致原集合被修改
现象:对ArrayList调用subList得到子列表后,修改子列表导致原列表也变化。原因:subList返回的是原列表的视图,不是独立副本。解决:如果需要独立列表,用new ArrayList<>(list.subList(...))创建副本。这个坑在面试中常以“subList和substring的区别”形式出现。
6. 用JMH做微基准测试:验证你对Java核心知识的理解是否准确
面试中经常需要比较不同实现的性能,比如“String拼接用+还是StringBuilder”“ArrayList和LinkedList谁更快”。凭感觉回答容易翻车,用JMH(Java Microbenchmark Harness)做基准测试才能得到可靠结论。
import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.runner.Runner; import org.openjdk.jmh.runner.options.Options; import org.openjdk.jmh.runner.options.OptionsBuilder; import java.util.concurrent.TimeUnit; @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) @State(Scope.Thread) @Warmup(iterations = 3, time = 1) @Measurement(iterations = 5, time = 1) @Fork(1) public class StringConcatBenchmark { private String a = "hello"; private String b = "world"; @Benchmark public String testPlus() { return a + b; // 编译后实际使用StringBuilder } @Benchmark public String testStringBuilder() { return new StringBuilder().append(a).append(b).toString(); } public static void main(String[] args) throws Exception { Options opt = new OptionsBuilder() .include(StringConcatBenchmark.class.getSimpleName()) .build(); new Runner(opt).run(); } }参数说明:@Warmup做预热,让JIT编译器充分优化代码;@Measurement做实际测量;@Fork指定fork的JVM进程数,避免进程间干扰。@BenchmarkMode(Mode.AverageTime)表示测量平均耗时。运行后会输出每次操作的平均纳秒数,对比两种实现的差异。
JMH的使用有几个关键点:不要在测试方法里写循环,JMH会自动迭代;用@State管理测试状态;避免死代码消除,JMH会自动处理返回值。如果测试结果和预期不符,先检查是否触发了JIT的常量折叠或方法内联。
我自己的习惯是:面试前把常问的性能对比点用JMH跑一遍,记住数量级差异而不是具体数字。比如StringBuilder比+快多少,取决于循环次数和字符串长度,但通常在一个数量级以内。这样回答时既能给出结论,又能说明测试条件,比背答案更有说服力。希望帮到你。
本文还有配套的精品资源,点击获取