Java 核心内功 — 小白讲解 + 面试题精解
Java 核心内功 — 小白讲解 + 面试题精解
定位:假设你只会写 Spring Boot CRUD,所有概念从零讲起,配代码示例 + 面试题。 覆盖:集合框架 → JUC 并发 → JVM → Java 基础四大模块。 用法:先读讲解理解概念 → 跑代码验证 → 再看面试题自测。
第一章:集合框架
面试官最爱从这里开场。问你 HashMap 底层,答得好说明你不止是“调 API”。
1.1 HashMap
小白讲解
一句话:HashMap 就像一个“带编号的抽屉柜”,每个抽屉里放一个链表。
详细原理:
put("name", "张三") 的过程:
第 1 步:算 hash 值
"name".hashCode() → 某个整数
再做扰动:hash = (h = key.hashCode()) ^ (h >>> 16)
作用:让高位也参与运算,减少碰撞
第 2 步:定位桶(抽屉)
index = (n - 1) & hash // n 是数组长度,必须是 2 的幂
比如数组长度 16,hash=35,index = 15 & 35 = 3
→ 放到第 3 号桶
第 3 步:放入桶
桶是空的 → 直接放
桶里有数据 → 遍历链表:
key 相同(equals 判断)→ 覆盖旧值
key 不同 → 追加到链表尾部
链表长度 ≥ 8 且数组长度 ≥ 64 → 链表转红黑树
JDK 1.8 的数据结构:
数组(table)
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │
└─────┴─────┴─────┴──┬──┴─────┴─────┴─────┴─────┘
│
Node
┌───┐
│K,V│ → Node → Node → Node → null
└───┘ (链表,长度≥8 转红黑树)
扩容机制:
// 什么时候扩容?
// size > capacity * loadFactor(默认 16 * 0.75 = 12)
// 扩容后:capacity 翻倍(16 → 32 → 64 ...)
// 扩容时元素怎么迁移?
// JDK 1.8 优化:看 hash 的新增高位
// 如果高位是 0 → 原位置
// 如果高位是 1 → 原位置 + 旧容量
// 不需要重新计算 hash,非常高效
代码验证
public class HashMapDemo {
public static void main(String[] args) {
// 默认初始容量 16,负载因子 0.75
HashMap<String, Integer> map = new HashMap<>();
// 面试题:为什么容量必须是 2 的幂?
// 因为用 (n-1) & hash 代替 hash % n,位运算比取模快
// put 过程
map.put("apple", 1);
map.put("banana", 2);
map.put("apple", 3); // 覆盖,返回旧值 1
System.out.println(map.get("apple")); // 3
// 面试题:put 返回什么?
Integer oldValue = map.put("banana", 99);
System.out.println(oldValue); // 2(返回被覆盖的旧值)
// 面试题:HashMap 允许 null 键和 null 值吗?
map.put(null, 0); // 允许,null 的 hash 固定为 0
map.put("nullVal", null); // 允许
System.out.println(map); // {null=0, apple=3, banana=99, nullVal=null}
}
}
面试题
Q1:HashMap 的底层原理?(高频 ⭐⭐⭐⭐⭐)
答:JDK 1.8 的 HashMap 是“数组 + 链表 + 红黑树”。
- 数组的每个位置叫“桶”(bucket),通过
(n-1) & hash定位桶下标 - 发生哈希碰撞时,用链表存储(尾插法,JDK 1.7 是头插法)
- 链表长度 ≥ 8 且数组长度 ≥ 64 时,链表转红黑树(查找从 O(n) 降到 O(log n))
- 红黑树节点数 ≤ 6 时退化回链表
- 默认初始容量 16,负载因子 0.75,扩容翻倍
Q2:为什么负载因子是 0.75?(中频 ⭐⭐⭐)
答:空间和时间的折中。
- 如果是 1.0:数组满了才扩容,桶里链表很长,查找慢
- 如果是 0.5:用了一半就扩容,空间浪费大
- 0.75 是数学上的最佳平衡点,同时 0.75 * 16 = 12 是整数,方便计算
Q3:JDK 1.7 和 1.8 的 HashMap 有什么区别?(高频 ⭐⭐⭐⭐)
| 对比项 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 数据结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
| 插入方式 | 头插法 | 尾插法 |
| 扩容计算 | 重新计算 hash | 原位置或原位置+旧容量 |
| 并发问题 | 头插法导致链表成环(死循环) | 尾插法不会成环,但仍然不安全 |
Q4:HashMap 为什么线程不安全?(高频 ⭐⭐⭐⭐)
答:
- JDK 1.7:并发扩容时头插法导致链表成环,get 操作死循环
- JDK 1.8:尾插法解决了成环问题,但并发 put 仍会数据覆盖(两个线程同时 put 到同一个空桶)
- size 丢失:两个线程同时 put,size 只加了 1
- 解决方案:用
ConcurrentHashMap
Q5:HashMap 的 key 用自定义对象需要注意什么?(中频 ⭐⭐⭐)
答:
- 必须同时重写
hashCode()和equals() - 只重写 equals 不重写 hashCode → 两个“相等”的对象 hash 不同,被放到不同桶,get 不到
- 不可变最佳:如果 key 的属性被修改导致 hashCode 变化,会找不到原来的值
- String 之所以适合做 key,因为它不可变且重写了 hashCode 和 equals
1.2 ConcurrentHashMap
小白讲解
一句话:ConcurrentHashMap 是线程安全的 HashMap,JDK 1.8 用 CAS + synchronized 实现。
JDK 1.7 的做法(Segment 分段锁):
把整个数组分成 16 段(Segment),每段一把锁
不同段的操作可以并行,最多 16 个线程同时写
ConcurrentHashMap
├── Segment[0] (锁) → HashEntry[]
├── Segment[1] (锁) → HashEntry[]
├── ...
└── Segment[15] (锁) → HashEntry[]
缺点:并发度固定为 16,锁粒度还是太粗
JDK 1.8 的做法(CAS + synchronized):
去掉 Segment,直接对每个桶(Node)加锁
锁粒度从"段"细化到"桶",并发度大大提高
put 流程:
1. key 的 hash 定位桶
2. 桶为空 → CAS 写入(无锁)
3. 桶不为空 → synchronized 锁住桶头节点 → 链表/树操作
4. 正在扩容 → 帮忙迁移数据(多线程并发扩容)
代码示例
public class ConcurrentHashMapDemo {
public static void main(String[] args) throws InterruptedException {
// 线程安全的计数器
ConcurrentHashMap<String, LongAdder> counter = new ConcurrentHashMap<>();
// 100 个线程各加 1000 次
ExecutorService pool = Executors.newFixedThreadPool(10);
CountDownLatch latch = new CountDownLatch(100);
for (int i = 0; i < 100; i++) {
pool.submit(() -> {
for (int j = 0; j < 1000; j++) {
// computeIfAbsent:不存在时创建,原子操作
counter.computeIfAbsent("count", k -> new LongAdder()).increment();
}
latch.countDown();
});
}
latch.await();
System.out.println(counter.get("count")); // 100000,线程安全
pool.shutdown();
}
}
面试题
Q1:ConcurrentHashMap JDK 1.7 vs 1.8 的区别?(高频 ⭐⭐⭐⭐⭐)
| 对比项 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 实现方式 | Segment 分段锁 | CAS + synchronized |
| 锁粒度 | Segment(段) | Node(桶头节点) |
| 并发度 | 固定 16 | 等于桶数量 |
| 数据结构 | Segment[] + HashEntry[] + 链表 | Node[] + 链表 + 红黑树 |
| 查询效率 | 两次 hash(先找 Segment 再找桶) | 一次 hash |
Q2:为什么 JDK 1.8 用 synchronized 而不是 ReentrantLock?(中频 ⭐⭐⭐⭐)
答:
- 锁粒度更细:只锁一个桶头节点,冲突概率低
- JVM 优化:synchronized 经过偏向锁 → 轻量级锁 → 重量级锁的升级,低竞争时性能很好
- 内存占用少:ReentrantLock 需要额外的 AQS 对象(state + 队列),synchronized 是 JVM 内置的
- 更简单:不需要手动 unlock,不会忘记释放锁
Q3:ConcurrentHashMap 的 size() 是精确的吗?(低频 ⭐⭐)
答:不精确。
- JDK 1.8 用 baseCount + CounterCell[] 累加,多线程下可能有微小误差
- 如果需要精确值,可以用
mappingCount()返回 long 类型 - 实际开发中很少在并发环境下依赖精确的 size
1.3 ArrayList vs LinkedList
小白讲解
ArrayList:底层是数组,像“一排连续的座位”。
- 查询快:直接算下标 O(1)
- 插入/删除慢:要移动后面的元素 O(n)
- 扩容:默认容量 10,满了后扩容 1.5 倍(
oldCapacity + oldCapacity >> 1)
LinkedList:底层是双向链表,像“手拉手的一排人”。
- 查询慢:从头/尾遍历 O(n)
- 插入/删除快:改前后指针就行 O(1)(但要先找到位置)
- 不需要扩容
代码对比
public class ListCompareDemo {
public static void main(String[] args) {
// ArrayList 扩容过程
ArrayList<Integer> list = new ArrayList<>(); // 初始容量 10
for (int i = 0; i < 15; i++) {
list.add(i);
// 当 add 第 11 个元素时,扩容到 15(10 * 1.5)
// 当 add 第 16 个元素时,扩容到 22(15 * 1.5)
}
// 面试题:ArrayList 的 remove 陷阱
List<Integer> list2 = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5));
// 正确删除方式
list2.removeIf(i -> i % 2 == 0); // 删除偶数 [1, 3, 5]
// 错误方式:for + remove 会导致 ConcurrentModificationException
// 或者跳过元素(因为 remove 后 index 前移了)
}
}
面试题
Q1:ArrayList 和 LinkedList 的区别?(高频 ⭐⭐⭐⭐)
| 对比项 | ArrayList | LinkedList |
|---|---|---|
| 底层 | 动态数组 | 双向链表 |
| 查询 | O(1) | O(n) |
| 插入/删除(尾部) | 均摊 O(1) | O(1) |
| 插入/删除(中间) | O(n) | 查找 O(n) + 插入 O(1) |
| 内存 | 连续,缓存友好 | 每个节点多两个指针 |
| 线程安全 | 不安全 | 不安全 |
Q2:ArrayList 扩容机制?(中频 ⭐⭐⭐)
答:
- 无参构造:初始容量 10(JDK 1.7 直接创建 10 的数组,1.8 延迟到第一次 add)
- 扩容为原来的 1.5 倍:
newCapacity = oldCapacity + (oldCapacity >> 1) - 用
Arrays.copyOf复制旧数据到新数组 - 如果指定了初始容量,可以避免频繁扩容
第二章:并发编程(JUC)
这是 CRUD 开发者最容易被问穿的领域。 简历写了“CompletableFuture”“DCL”“线程池”,面试官一定会深挖。
2.1 线程基础
小白讲解
什么是线程?
把进程想象成一个工厂,线程就是工厂里的工人。一个工厂可以有多个工人,他们共享工厂的资源(内存),但各自做不同的活。
线程的生命周期:
NEW(新建)
│ start()
↓
RUNNABLE(就绪/运行)←─── 时间片用完 ───→
│ ↑
├─ wait() ──→ WAITING ──notify()─────────┤
├─ wait(timeout) → TIMED_WAITING ─────────┤
├─ LockSupport.park() → WAITING ──────────┘
└─ run() 结束 → TERMINATED(终止)
代码示例
public class ThreadDemo {
public static void main(String[] args) throws InterruptedException {
// 方式 1:继承 Thread
Thread t1 = new Thread(() -> {
System.out.println("线程1运行:" + Thread.currentThread().getName());
}, "my-thread-1");
// 方式 2:实现 Runnable(推荐)
Runnable task = () -> System.out.println("线程2运行");
// 方式 3:Callable + FutureTask(有返回值)
FutureTask<Integer> futureTask = new FutureTask<>(() -> {
Thread.sleep(1000);
return 42;
});
Thread t3 = new Thread(futureTask);
t1.start();
new Thread(task).start();
t3.start();
// get() 会阻塞直到线程执行完
System.out.println("Callable 返回值:" + futureTask.get()); // 42
// 面试题:start() 和 run() 的区别?
// start():启动新线程,JVM 调用 run()
// run():普通方法调用,在当前线程执行,不会启动新线程
// 调两次 start() 会抛 IllegalThreadStateException
}
}
面试题
Q1:run() 和 start() 的区别?(高频 ⭐⭐⭐⭐)
答:
start():启动一个新线程,JVM 自动调用run()方法。一个线程只能 start 一次run():普通方法,在当前线程同步执行。可以直接调用但没有多线程效果
Q2:为什么有了 Runnable 还要有 Callable?(中频 ⭐⭐⭐)
答:
- Runnable 的
run()方法没有返回值,也不能抛出 checked exception - Callable 的
call()方法有返回值,可以抛出异常 - 配合 Future/FutureTask 可以获取异步执行结果
Q3:线程的 sleep() 和 wait() 的区别?(高频 ⭐⭐⭐⭐)
| 对比项 | sleep() | wait() |
|---|---|---|
| 所属类 | Thread | Object |
| 释放锁 | 不释放 | 释放 |
| 使用位置 | 任意 | synchronized 块内 |
| 唤醒方式 | 超时自动 | notify/notifyAll 或超时 |
| 用途 | 暂停当前线程 | 线程间通信 |
2.2 synchronized vs ReentrantLock
小白讲解
synchronized 是 Java 内置的关键字,像“公共厕所的门锁”——进去就锁门,出来自动开锁。
ReentrantLock 是 JDK 提供的类,像“可预约的门锁”——可以设超时、可以响应中断、可以有多个条件变量。
synchronized 的三种使用方式:
1. 修饰实例方法 → 锁的是当前实例对象(this)
public synchronized void method() { ... }
2. 修饰静态方法 → 锁的是 Class 对象
public static synchronized void method() { ... }
3. 修饰代码块 → 锁的是指定对象
synchronized(obj) { ... }
synchronized 的底层原理:
对象头里有一个 Mark Word,存储锁信息:
无锁状态 → 偏向锁 → 轻量级锁 → 重量级锁
偏向锁: 只有一个线程访问,直接记录线程 ID(几乎无开销)
轻量级锁: 多个线程交替访问,用 CAS 自旋(不阻塞)
重量级锁: 真正竞争,操作系统层面的互斥量(线程阻塞/唤醒)
代码对比
public class LockCompareDemo {
private int count = 0;
private final ReentrantLock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
// 方式 1:synchronized
public synchronized void syncIncrement() {
count++;
}
// 方式 2:ReentrantLock
public void lockIncrement() {
lock.lock();
try {
count++;
} finally {
// 必须在 finally 中释放锁!
lock.unlock();
}
}
// ReentrantLock 独有功能:可中断的锁获取
public void interruptibleLock() throws InterruptedException {
// 如果线程被 interrupt,会抛 InterruptedException,不会死等
lock.lockInterruptibly();
try {
// do something
} finally {
lock.unlock();
}
}
// ReentrantLock 独有功能:超时获取
public boolean tryLockWithTimeout() {
try {
// 最多等 3 秒
return lock.tryLock(3, TimeUnit.SECONDS);
} catch (InterruptedException e) {
return false;
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
// ReentrantLock 独有功能:公平锁
// new ReentrantLock(true) → 先到先得
// new ReentrantLock(false) → 非公平,可能插队(默认)
}
面试题
Q1:synchronized 和 ReentrantLock 的区别?(高频 ⭐⭐⭐⭐⭐)
| 对比项 | synchronized | ReentrantLock |
|---|---|---|
| 类型 | 关键字 | 类(JUC) |
| 释放锁 | 自动 | 手动(必须 finally) |
| 可中断 | 不可 | lockInterruptibly() |
| 超时获取 | 不可 | tryLock(timeout) |
| 公平锁 | 只有非公平 | 可选公平/非公平 |
| 条件变量 | 1 个(wait/notify) | 多个(Condition) |
| 锁绑定 | 对象 | Lock 对象 |
| 性能 | JDK 1.6 后优化很大 | 竞争激烈时略优 |
Q2:synchronized 的锁升级过程?(高频 ⭐⭐⭐⭐)
答:
- 无锁:对象刚创建
- 偏向锁:第一个线程访问,在 Mark Word 记录线程 ID。下次同一线程进入不需要 CAS
- 轻量级锁:出现竞争(第二个线程),撤销偏向锁,用 CAS 自旋获取
- 重量级锁:自旋超过阈值(默认 10 次)或有第三个线程竞争,升级为重量级锁,未获取锁的线程进入阻塞队列
注意:锁只能升级不能降级(GC 除外)。
Q3:什么是 DCL(双重检查锁)单例?(高频 ⭐⭐⭐⭐⭐)
简历直接写了 DCL,必须会答!
public class Singleton {
// 为什么 volatile?看下方解释
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查:避免不必要的加锁
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查:防止重复创建
instance = new Singleton(); // 非原子操作!
}
}
}
return instance;
}
}
为什么必须加 volatile?
instance = new Singleton() 不是原子操作,分为 3 步:
- 分配内存空间
- 初始化对象
- 将引用指向内存地址
如果没有 volatile,指令重排序可能让 2 和 3 互换顺序:
- 线程 A 执行到步骤 3(引用已赋值,但对象还没初始化)
- 线程 B 第一次检查
instance != null,直接返回未初始化的对象 → NPE!
volatile 通过内存屏障禁止重排序,保证对象完全初始化后才对其他线程可见。
2.3 volatile
小白讲解
一句话:volatile 保证两个事——可见性和有序性,但不保证原子性。
可见性:
每个线程有自己的 CPU 缓存(工作内存)
普通变量:线程 A 修改了值,线程 B 可能看不到(读的是自己的缓存)
volatile 变量:线程 A 修改后,强制刷回主内存;线程 B 读时强制从主内存读
底层实现:x86 架构下,volatile 写会生成 lock 前缀指令
→ 触发缓存一致性协议(MESI)→ 其他 CPU 缓存行失效
有序性(禁止指令重排序):
编译器和 CPU 为了优化性能,可能会打乱指令顺序(单线程下结果不变)
volatile 通过插入内存屏障(Memory Barrier)禁止重排序:
- volatile 写之前的操作,不能重排到写之后
- volatile 读之后的操作,不能重排到读之前
代码验证
public class VolatileDemo {
// 不加 volatile:子线程可能永远看不到 flag 变成 false
// private static boolean flag = true;
// 加 volatile:主线程修改后,子线程立即可见
private static volatile boolean flag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
System.out.println("子线程启动,等待 flag 变 false...");
while (flag) {
// 空循环,不加 volatile 时可能一直读缓存
}
System.out.println("子线程检测到 flag = false,退出");
}).start();
Thread.sleep(1000);
System.out.println("主线程设置 flag = false");
flag = false;
// volatile 不保证原子性:
// volatile int count = 0;
// count++ 不是原子操作(读-改-写三步)
// 多线程下 count++ 仍然会丢失更新
}
}
面试题
Q1:volatile 能保证原子性吗?(高频 ⭐⭐⭐⭐)
答:不能。count++ 是“读-改-写”三步操作,volatile 只保证每一步可见,但三步之间可以被其他线程打断。要保证原子性用 AtomicInteger 或 synchronized。
Q2:volatile 和 synchronized 的区别?(中频 ⭐⭐⭐)
| 对比项 | volatile | synchronized |
|---|---|---|
| 原子性 | 不保证 | 保证 |
| 可见性 | 保证 | 保证 |
| 有序性 | 保证 | 保证 |
| 阻塞 | 不阻塞 | 可能阻塞 |
| 编译优化 | 禁止 JIT 对该变量的优化 | 不影响 |
| 使用场景 | 状态标志位、DCL | 复合操作、临界区 |
2.4 CAS 与 AQS
小白讲解
CAS(Compare And Swap):比较并交换,是乐观锁的核心思想。
生活类比:你去取快递,快递柜上贴的取件码是 1234
你输入 1234(期望值)→ 柜子验证匹配 → 开门取件(更新值)
如果有人在你之前取了(值已变)→ 验证不匹配 → 你取不了
CAS(V, E, N):
V = 内存中的值
E = 期望的值
N = 要更新的新值
如果 V == E → 把 V 更新为 N,返回 true
如果 V != E → 说明被别人改了,不更新,返回 false
ABA 问题:
线程 1 读到值 A
线程 2 把 A 改成 B,又改回 A
线程 1 CAS(A → C) 成功
问题:线程 1 以为没人动过,但实际上值变化过
解决:AtomicStampedReference(加版本号)
A(版本1) → B(版本2) → A(版本3)
线程 1 CAS 时发现版本号变了,CAS 失败
AQS(AbstractQueuedSynchronizer):
AQS 是 JUC 锁和同步器的基础框架
ReentrantLock、CountDownLatch、Semaphore、CyclicBarrier 底层都是 AQS
核心思想:
一个 volatile int state 表示同步状态
一个 FIFO 双向队列存储等待的线程
以 ReentrantLock 的非公平锁为例:
lock():
1. CAS 尝试把 state 从 0 改为 1
2. 成功 → 获取锁,记录持有线程
3. 失败 → 加入等待队列,park 阻塞
unlock():
1. state 减 1
2. 如果 state == 0 → 释放锁,唤醒队列头部的线程
代码示例
public class CASDemo {
public static void main(String[] args) {
AtomicInteger atomicInt = new AtomicInteger(0);
// CAS 操作
boolean success = atomicInt.compareAndSet(0, 10);
System.out.println(success); // true,当前值=0,更新为 10
System.out.println(atomicInt.get()); // 10
success = atomicInt.compareAndSet(0, 20);
System.out.println(success); // false,当前值=10≠0,不更新
System.out.println(atomicInt.get()); // 10
// getAndIncrement 底层就是 CAS 自旋
// 伪代码:
// do {
// oldValue = get();
// newValue = oldValue + 1;
// } while (!compareAndSet(oldValue, newValue));
// 如果 CAS 失败就重试,直到成功
// ABA 问题演示
AtomicInteger value = new AtomicInteger(1);
// 线程 1
new Thread(() -> {
int v = value.get(); // 读到 1
try { Thread.sleep(100); } catch (Exception e) {}
// 在此期间线程 2 把 1→2→1
boolean result = value.compareAndSet(v, 10);
System.out.println("线程1 CAS 结果:" + result); // true,但中间被改过
}).start();
// 线程 2
new Thread(() -> {
value.compareAndSet(1, 2); // 1→2
value.compareAndSet(2, 1); // 2→1
}).start();
}
}
面试题
Q1:CAS 的原理和存在的问题?(高频 ⭐⭐⭐⭐⭐)
答:
- 原理:CAS 是无锁优化,通过 CPU 的 cmpxchg 指令保证原子性。三个参数:内存值 V、期望值 E、新值 N。V==E 则更新为 N,否则不操作
- 问题 1 - ABA:值被改了又改回来,CAS 检测不到。用
AtomicStampedReference加版本号解决 - 问题 2 - 自旋开销:CAS 失败会自旋重试,竞争激烈时 CPU 空转。JDK 1.8 后的自适应自旋会根据历史成功率调整
- 问题 3 - 只保证一个变量原子:多个变量需要用锁或
AtomicReference封装成对象
Q2:AQS 的原理?(高频 ⭐⭐⭐⭐⭐)
答:
- state:volatile int,表示同步状态。ReentrantLock 用 0=未锁定、>0=锁定次数;Semaphore 用剩余许可数
- CLH 队列:双向链表,存储等待获取锁的线程(封装为 Node)
- 独占模式:同一时刻只有一个线程能获取资源(ReentrantLock)
- 共享模式:多个线程可同时获取资源(Semaphore、CountDownLatch)
- 模板方法模式:AQS 定义了获取/释放的流程,子类只需实现
tryAcquire/tryRelease等
Q3:公平锁和非公平锁的区别?(高频 ⭐⭐⭐⭐)
答:
- 非公平锁(默认):新线程直接 CAS 尝试获取锁,成功了就“插队”。优点:吞吐量高;缺点:可能导致队列中的线程长时间拿不到锁
- 公平锁:新线程先检查队列中有没有等待的线程,有的话排到队尾。优点:不会饥饿;缺点:每次要唤醒队列线程,吞吐量低
- ReentrantLock 默认非公平,
new ReentrantLock(true)可设公平
2.5 线程池(ThreadPoolExecutor)
小白讲解
为什么用线程池?
不用线程池:每个任务 new Thread(),用完就销毁
→ 创建/销毁线程开销大(约 1ms/次)
→ 无法控制线程数量,高并发时可能创建几千个线程直接 OOM
→ 无法统一管理(无法监控、无法限流)
用线程池:线程复用,统一管理
→ 降低资源消耗(线程创建/销毁从 N 次降到 corePoolSize 次)
→ 提高响应速度(任务来了直接用已有线程,不用等创建)
→ 便于管理(统一监控、调优、限流、命名)
ThreadPoolExecutor 的 7 个参数:
ThreadPoolExecutor(
int corePoolSize, // 核心线程数(不会被回收,除非 allowCoreThreadTimeOut)
int maximumPoolSize, // 最大线程数(核心 + 非核心)
long keepAliveTime, // 非核心线程的空闲存活时间
TimeUnit unit, // 时间单位
BlockingQueue<Runnable> workQueue, // 任务队列
ThreadFactory threadFactory, // 线程工厂(给线程起名字,方便排查)
RejectedExecutionHandler handler // 拒绝策略
)
任务提交后的执行流程(面试必画):
提交任务
│
↓
当前线程数 < corePoolSize?
│ 是 → 创建核心线程执行任务
│ 否 ↓
│
任务队列没满?
│ 是 → 任务放入队列等待
│ 否 ↓
│
当前线程数 < maximumPoolSize?
│ 是 → 创建非核心线程执行任务
│ 否 ↓
│
执行拒绝策略
关键点:队列在核心线程之后、非核心线程之前。这意味着如果队列是无界的,maximumPoolSize 永远不生效——这就是
Executors.newFixedThreadPool的问题。
线程池的 5 种状态
// ThreadPoolExecutor 源码中的状态定义
private static final int RUNNING = -1 << COUNT_BITS; // 111 → 接收新任务,处理队列任务
private static final int SHUTDOWN = 0 << COUNT_BITS; // 000 → 不接收新任务,但处理队列任务
private static final int STOP = 1 << COUNT_BITS; // 001 → 不接收新任务,不处理队列任务,中断正在执行的任务
private static final int TIDYING = 2 << COUNT_BITS; // 010 → 所有任务已终止,线程数为 0
private static final int TERMINATED = 3 << COUNT_BITS; // 011 → terminated() 方法执行完毕
RUNNING ──shutdown()──→ SHUTDOWN ──队列空+线程空──→ TIDYING ──terminated()──→ TERMINATED
│ │
└──shutdownNow()──→ STOP ──线程空──→ TIDYING ──terminated()──→ TERMINATED
shutdown() vs shutdownNow():
| 方法 | 队列中等待的任务 | 正在执行的任务 | 返回值 |
|---|---|---|---|
| shutdown() | 继续执行完 | 等它们自然结束 | void |
| shutdownNow() | 不执行了,返回未执行的任务列表 | 尝试中断(interrupt) | List |
4 种拒绝策略
| 策略 | 行为 | 使用场景 |
|---|---|---|
| AbortPolicy(默认) | 抛 RejectedExecutionException | 严肃系统,不允许丢任务 |
| CallerRunsPolicy | 由提交任务的线程执行 | 不想丢任务,可以接受降速(提交线程被拖慢→自然限流) |
| DiscardPolicy | 直接丢弃,不抛异常 | 允许丢失(日志收集等) |
| DiscardOldestPolicy | 丢弃队列最老的任务,重新提交 | 只关心最新任务(实时行情推送) |
队列选择(面试常考)
| 队列 | 特点 | 对线程池的影响 | 适用场景 |
|---|---|---|---|
| LinkedBlockingQueue(无界) | 默认容量 Integer.MAX_VALUE | maximumPoolSize 永远不生效,任务堆积→OOM | 不推荐生产使用 |
| LinkedBlockingQueue(有界) | 指定容量 | 队列满后才会创建非核心线程 | 大多数场景的推荐选择 |
| ArrayBlockingQueue(有界) | 数组实现,固定容量 | 同上 | 需要精确控制队列大小 |
| SynchronousQueue(无容量) | 不存储任务,直接交给线程 | 每个任务都必须立即有线程处理,否则创建新线程 | 要求任务立即执行(如 CachedThreadPool) |
| PriorityBlockingQueue | 优先级队列 | 按优先级出队 | 任务有优先级区分 |
// 推荐写法:有界队列 + 明确的最大线程数 + 明确的拒绝策略
ThreadPoolExecutor pool = new ThreadPoolExecutor(
8, // 核心 8 线程
16, // 最大 16 线程
60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(200), // 有界队列,最多排队 200 个
new ThreadFactoryBuilder().setNameFormat("order-pool-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy() // 满了由提交线程执行,自然限流
);
submit() vs execute() 的异常差异(面试重点)
// execute():异常直接抛到控制台,不会被吞
pool.execute(() -> {
throw new RuntimeException("execute 的异常");
});
// 控制台直接打印异常堆栈
// 线程会销毁,线程池会补充新线程
// submit():异常被封装到 Future 中,不会抛出
Future<?> future = pool.submit(() -> {
throw new RuntimeException("submit 的异常");
});
// 控制台什么都不打印!异常被吞了
// 必须调用 future.get() 才能看到异常
future.get(); // 这里会抛 ExecutionException,包裹着原始异常
生产建议:用
execute()提交不需要返回值的任务(异常直接暴露),用submit()时必须future.get()或 try-catch。
线程池参数动态调整
// 核心线程数和最大线程数支持运行时动态修改(无需重启)
pool.setCorePoolSize(16); // 动态扩容核心线程
pool.setMaximumPoolSize(32); // 动态扩容最大线程
// 可以配合 Nacos 配置中心实现动态调参
@NacosValue(value = "${threadpool.coreSize:8}", autoRefreshed = true)
private int coreSize;
@NacosConfigListener(dataId = "app.yaml")
public void onConfigChange(String config) {
pool.setCorePoolSize(newCoreSize);
pool.setMaximumPoolSize(newMaxSize);
}
线程池监控(生产必备)
// 定期打印线程池状态
ScheduledExecutorService monitor = Executors.newScheduledThreadPool(1);
monitor.scheduleAtFixedRate(() -> {
log.info("线程池状态: active={}/{}, queue={}/{}, completed={}",
pool.getActiveCount(), // 当前活跃线程数
pool.getPoolSize(), // 当前总线程数
pool.getQueue().size(), // 队列中等待的任务数
pool.getQueue().remainingCapacity(), // 队列剩余容量
pool.getCompletedTaskCount() // 已完成的任务总数
);
}, 0, 10, TimeUnit.SECONDS);
// 关键告警指标:
// 1. 队列持续堆积 → 处理不过来,需要扩容或优化
// 2. activeCount 长期 = maximumPoolSize → 线程池打满,触发拒绝策略
// 3. 拒绝策略触发次数 → 需要扩容或降级
代码示例
public class ThreadPoolDemo {
public static void main(String[] args) {
// 自定义线程池(阿里规范:不要用 Executors 工具类)
ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, // 核心线程数
4, // 最大线程数
30, TimeUnit.SECONDS, // 空闲存活时间
new LinkedBlockingQueue<>(10), // 任务队列容量 10
new ThreadFactoryBuilder() // Guava 的线程工厂
.setNameFormat("biz-pool-%d")
.build(),
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);
// 提交任务
for (int i = 0; i < 20; i++) {
final int taskId = i;
pool.submit(() -> {
System.out.println("任务 " + taskId + " 由 "
+ Thread.currentThread().getName() + " 执行");
try { Thread.sleep(1000); } catch (Exception e) {}
});
}
pool.shutdown();
// 面试题:为什么阿里禁止用 Executors.newFixedThreadPool()?
// 因为 LinkedBlockingQueue 默认是 Integer.MAX_VALUE
// 任务堆积会导致 OOM
// newCachedThreadPool 最大线程数是 Integer.MAX_VALUE,也会 OOM
}
}
项目实战场景:DAG 指标计算线程池设计(结合简历)
// 简历项目三:DAG 指标计算引擎,用 CompletableFuture 做异步编排
// 实际生产中的线程池设计:
// 1. 计算线程池:CPU 密集型,核心数 + 1
ThreadPoolExecutor computePool = new ThreadPoolExecutor(
Runtime.getRuntime().availableProcessors() + 1, // 核心 = CPU 核数 + 1
Runtime.getRuntime().availableProcessors() + 1, // 最大 = 核心(不扩)
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(500),
new ThreadFactoryBuilder().setNameFormat("metric-compute-%d").build(),
new ThreadPoolExecutor.AbortPolicy()
);
// 2. IO 线程池:查数据库/缓存,IO 密集型,核心数 * 2
ThreadPoolExecutor ioPool = new ThreadPoolExecutor(
Runtime.getRuntime().availableProcessors() * 2, // IO 密集,可以多一些
Runtime.getRuntime().availableProcessors() * 4, // 允许突发扩容
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
new ThreadFactoryBuilder().setNameFormat("metric-io-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy() // 满了让提交线程执行,自然限流
);
// 3. DAG 计算使用
CompletableFuture<MetricResult> future = CompletableFuture
.supplyAsync(() -> queryDeviceData(deviceId), ioPool) // IO 操作走 IO 池
.thenApplyAsync(data -> calculateMetrics(data), computePool) // 计算走计算池
.exceptionally(ex -> {
log.error("指标计算失败: deviceId={}", deviceId, ex);
return MetricResult.empty();
});
面试题
Q1:线程池的核心参数和执行流程?(超高频 ⭐⭐⭐⭐⭐)
答:
- 任务来了先看核心线程是否已满,没满就创建核心线程执行
- 核心线程满了,放入任务队列
- 队列也满了,看是否达到最大线程数,没满就创建非核心线程
- 最大线程数也满了,执行拒绝策略
Q2:为什么阿里禁止用 Executors 创建线程池?(高频 ⭐⭐⭐⭐⭐)
答:
newFixedThreadPool和newSingleThreadExecutor:队列是LinkedBlockingQueue,默认容量Integer.MAX_VALUE,可能堆积大量任务导致 OOMnewCachedThreadPool和newScheduledThreadPool:最大线程数是Integer.MAX_VALUE,可能创建大量线程导致 OOM- 应该用
ThreadPoolExecutor构造函数手动指定参数
Q3:核心线程数怎么设置?(中频 ⭐⭐⭐⭐)
答:
- CPU 密集型(计算多):
核心数 + 1,线程多了反而增加上下文切换 - IO 密集型(网络/数据库):
核心数 * 2或核心数 / (1 - 阻塞系数),阻塞系数一般 0.8~0.9 - 混合型:拆分成 CPU 密集和 IO 密集两个线程池
// 获取 CPU 核心数
int cpuCores = Runtime.getRuntime().availableProcessors();
// CPU 密集型
ThreadPoolExecutor cpuPool = new ThreadPoolExecutor(
cpuCores + 1, cpuCores + 1, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(100));
// IO 密集型
ThreadPoolExecutor ioPool = new ThreadPoolExecutor(
cpuCores * 2, cpuCores * 2, 30L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(500));
Q4:核心线程能被回收吗?(低频 ⭐⭐)
答:默认不能。但 allowCoreThreadTimeOut(true) 可以让核心线程在空闲超时后被回收。适用于低峰期节省资源的场景。
Q5:submit() 和 execute() 有什么区别?(高频 ⭐⭐⭐⭐)
答:三个核心区别:
- 返回值:execute() 无返回值,submit() 返回 Future
- 异常处理:execute() 的异常直接抛出(线程会销毁并补充新线程);submit() 的异常被封装到 Future 中,不调 get() 就永远看不到——这是生产环境最常见的坑
- 底层实现:submit() 内部把 Runnable 包装成 FutureTask,最终还是调用 execute()
Q6:线程池的队列怎么选?(高频 ⭐⭐⭐⭐)
答:
- 大多数场景:
LinkedBlockingQueue(capacity)有界队列——任务可以排队等待,队列满后才扩容线程,最后才触发拒绝策略,行为最可控 - 要求立即执行:
SynchronousQueue——任务不排队,直接给线程或创建新线程,适合不能容忍排队延迟的场景 - 绝对不要:无界队列(默认 LinkedBlockingQueue)——maximumPoolSize 永远不生效,任务无限堆积最终 OOM
Q7:shutdown() 和 shutdownNow() 的区别?(中频 ⭐⭐⭐)
答:
shutdown():平滑关闭。不再接收新任务,但会把队列中的任务执行完,等所有任务完成后线程池才终止shutdownNow():强制关闭。不再接收新任务,队列中的任务不执行了(作为返回值返回),正在执行的任务通过 interrupt 尝试中断- 注意:shutdownNow() 的 interrupt 只是设置中断标志,如果任务没有响应中断(比如没有 sleep/wait),线程不会真正停止
Q8:生产环境怎么做线程池监控和动态调参?(高频 ⭐⭐⭐⭐)
答:
- 监控:定时采集
getActiveCount()(活跃线程)、getQueue().size()(队列积压)、getCompletedTaskCount()(完成任务数),接入 Prometheus/Grafana 告警。关键告警:队列持续积压、活跃线程长期打满、拒绝策略触发次数上升 - 动态调参:
setCorePoolSize()和setMaximumPoolSize()支持运行时修改,配合 Nacos 配置中心可以实现不重启动态调整。美团开源的dynamic-tp框架就是干这个的
Q9:CompletableFuture 默认用什么线程池?有什么风险?(高频 ⭐⭐⭐⭐)
答:
- 不传线程池时使用
ForkJoinPool.commonPool(),默认线程数 = CPU 核数 - 1 - 风险:所有不传线程池的 CompletableFuture 共享这个公共池,一个慢任务会阻塞其他所有任务
- 风险:commonPool 的线程是 daemon 线程,JVM 退出时不等任务完成
- 建议始终传入自定义线程池,特别是 IO 密集型任务
2.6 CompletableFuture
小白讲解
一句话:CompletableFuture 是 Java 8 的异步编程利器,可以像写同步代码一样写异步逻辑。
传统方式:Future + 线程池
Future<String> future = pool.submit(() -> queryDb());
String result = future.get(); // 阻塞等待
// 不能在完成后自动回调,不能链式操作
CompletableFuture:可以编排异步任务
CompletableFuture.supplyAsync(() -> queryDb()) // 异步查 DB
.thenApply(data -> process(data)) // 查完后处理
.thenCompose(processed -> saveToCache(processed)) // 处理完存缓存
.thenAccept(saved -> log.info("done: {}", saved)) // 存完后记录日志
.exceptionally(ex -> { log.error("出错", ex); return null; }); // 异常处理
简历相关:你的 DAG 指标计算流水线用了 CompletableFuture,面试官会问“怎么编排的”。
代码示例
public class CompletableFutureDemo {
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(4);
// 场景:并行调用 3 个接口,汇总结果
// 1. supplyAsync:异步执行有返回值
CompletableFuture<String> userFuture = CompletableFuture
.supplyAsync(() -> {
sleep(500);
return "用户信息";
}, pool);
CompletableFuture<String> orderFuture = CompletableFuture
.supplyAsync(() -> {
sleep(800);
return "订单信息";
}, pool);
CompletableFuture<String> couponFuture = CompletableFuture
.supplyAsync(() -> {
sleep(300);
return "优惠券信息";
}, pool);
// 2. allOf:等所有任务完成
CompletableFuture<Void> allDone = CompletableFuture
.allOf(userFuture, orderFuture, couponFuture);
// 3. thenApply:完成后转换结果
String result = allDone.thenApply(v ->
userFuture.join() + " | " +
orderFuture.join() + " | " +
couponFuture.join()
).get();
System.out.println(result);
// 总耗时约 800ms(最慢的那个),而不是 500+800+300=1600ms
// 其他常用方法:
// thenAccept:消费结果,无返回值
// thenCombine:合并两个 future 的结果
// thenCompose:串联(类似 flatMap)
// anyOf:任一完成即返回
// exceptionally:异常处理
// whenComplete:完成时回调(成功/失败都会执行)
pool.shutdown();
}
static void sleep(long ms) {
try { Thread.sleep(ms); } catch (Exception e) {}
}
}
面试题
Q1:CompletableFuture 和 Future 的区别?(高频 ⭐⭐⭐⭐)
答:
| 对比项 | Future | CompletableFuture |
|---|---|---|
| 主动完成 | 不能 | complete()/completeExceptionally() |
| 回调通知 | 不能,必须 get() 阻塞 | thenApply/thenAccept 等回调 |
| 链式编排 | 不能 | thenCompose/thenCombine |
| 组合多个 | 不能 | allOf/anyOf |
| 异常处理 | get() 抛 ExecutionException | exceptionally/whenComplete |
Q2:supplyAsync 不传线程池会怎样?(中频 ⭐⭐⭐)
答:默认用 ForkJoinPool.commonPool(),线程数为 CPU 核心数 - 1。问题:
- 所有不传线程池的 CompletableFuture 共享这个池,容易互相阻塞
- 适合 CPU 密集型,IO 密集型任务会打满线程
- 建议始终传入自定义线程池
2.7 ThreadLocal
小白讲解
一句话:ThreadLocal 给每个线程一份独立的变量副本,互不干扰。
生活类比:每个人有自己的水杯,不共用一个杯子喝水
ThreadLocal<String> threadLocal = new ThreadLocal<>();
线程 A set("张三") → 线程 A 的 ThreadLocalMap 里存 "张三"
线程 B set("李四") → 线程 B 的 ThreadLocalMap 里存 "李四"
线程 A get() → "张三"
线程 B get() → "李四"
底层原理:
每个 Thread 对象内部有一个 ThreadLocalMap
Thread.threadLocals → ThreadLocalMap
ThreadLocalMap 的 key 是 ThreadLocal 对象(弱引用)
ThreadLocalMap 的 value 是实际存储的值(强引用)
set 流程:
获取当前线程 → 获取线程的 ThreadLocalMap → 以 this(ThreadLocal)为 key 存入 value
代码示例
public class ThreadLocalDemo {
// 典型场景:存用户登录信息,全链路传递
private static final ThreadLocal<UserContext> userContext =
ThreadLocal.withInitial(() -> new UserContext("anonymous"));
public static void main(String[] args) {
// 线程 A
new Thread(() -> {
userContext.set(new UserContext("张三"));
try {
System.out.println("线程A读取:" + userContext.get().name); // 张三
// 调用业务方法,不需要传参,直接从 ThreadLocal 取
businessMethod();
} finally {
userContext.remove(); // 用完必须 remove!
}
}).start();
// 线程 B
new Thread(() -> {
userContext.set(new UserContext("李四"));
try {
System.out.println("线程B读取:" + userContext.get().name); // 李四
businessMethod();
} finally {
userContext.remove();
}
}).start();
}
static void businessMethod() {
// 不需要传 UserContext 参数,直接从 ThreadLocal 取
UserContext ctx = userContext.get();
System.out.println("业务方法处理用户:" + ctx.name);
}
static class UserContext {
String name;
UserContext(String name) { this.name = name; }
}
}
面试题
Q1:ThreadLocal 的内存泄漏问题?(高频 ⭐⭐⭐⭐⭐)
答:
Thread → ThreadLocalMap → Entry(key=WeakRef<ThreadLocal>, value=强引用)
如果 ThreadLocal 对象被回收(key=null),但 value 还在
→ Thread 一直存活 → value 无法回收 → 内存泄漏
在线程池场景下,线程会复用,如果不清除 ThreadLocal
→ 上一个任务的 value 残留 → 数据串号 + 内存泄漏
解决:每次用完在 finally 中调用 remove()
Q2:ThreadLocal 的 key 为什么用弱引用?(中频 ⭐⭐⭐⭐)
答:
- 如果用强引用:ThreadLocal 对象即使没有外部引用了,Thread 的 ThreadLocalMap 还持有它,无法回收 → 内存泄漏
- 用弱引用:ThreadLocal 对象只被 ThreadLocalMap 的弱引用指向时,GC 会回收它
- 但 value 是强引用,所以 key 被回收后 value 仍在 → 这就是为什么必须 remove()
Q3:ThreadLocal 用在什么场景?(中频 ⭐⭐⭐)
答:
- 用户上下文传递:登录信息、租户 ID,避免方法签名层层传参
- 数据库连接管理:Spring 的事务管理器用 ThreadLocal 绑定 Connection,保证同一线程用同一个连接
- 链路追踪:SkyWalking 用 ThreadLocal 传递 traceId
- 日期格式化:SimpleDateFormat 非线程安全,用 ThreadLocal 每个线程一份
第三章:JVM
简历写了“JVM调优、G1/ZGC”,面试官一定会问内存结构、GC 算法、类加载。
3.1 JVM 内存结构
小白讲解
JVM 内存结构(JDK 1.8+)
┌─────────────────────────────────────────────────────────┐
│ JVM 进程内存 │
│ │
│ ┌──────────────────┐ ┌──────────────────────────────┐│
│ │ │ │ 堆(Heap) ││
│ │ 方法区/元空间 │ │ ┌────────┬────────┐ ││
│ │ (Metaspace) │ │ │ 年轻代 │ 老年代 │ ││
│ │ │ │ │ Eden+S0+S1 │ │ ││
│ │ - 类信息 │ │ └────────┴────────┘ ││
│ │ - 常量池 │ │ 所有对象实例+数组 ││
│ │ - 静态变量 │ │ ││
│ └──────────────────┘ └──────────────────────────────┘│
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────────────┐│
│ │ 虚拟机栈 │ │ 本地方法栈 │ │ 直接内存(NIO) ││
│ │ (VM Stack) │ │(Native Stack)│ │ (Direct Memory) ││
│ │ │ │ │ │ ││
│ │ 栈帧: │ │ Native 方法 │ │ Buffer.allocate() ││
│ │ - 局部变量表 │ │ 的调用栈 │ │ 不受 JVM 堆管理 ││
│ │ - 操作数栈 │ │ │ │ ││
│ │ - 动态链接 │ │ │ │ ││
│ │ - 返回地址 │ │ │ │ ││
│ └────────────┘ └────────────┘ └────────────────────┘│
│ │
│ ┌──────────────┐ │
│ │ 程序计数器 PC │ ← 每个线程一个,记录当前执行的字节码行号 │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────┘
各区域的作用和特点:
| 区域 | 线程共享? | 存什么 | OOM 类型 |
|---|---|---|---|
| 堆 | 共享 | 对象实例、数组 | OutOfMemoryError: Java heap space |
| 方法区/元空间 | 共享 | 类信息、常量池、静态变量 | OOM: Metaspace |
| 虚拟机栈 | 私有 | 方法调用的栈帧 | StackOverflowError / OOM |
| 本地方法栈 | 私有 | Native 方法的栈帧 | 同上 |
| 程序计数器 | 私有 | 当前字节码地址 | 不会 OOM |
| 直接内存 | 共享 | NIO 的 DirectByteBuffer | OOM: Direct buffer memory |
面试题
Q1:描述 JVM 内存结构?(高频 ⭐⭐⭐⭐⭐)
答:JVM 内存分为 5 块(JDK 1.8+):
- 堆:所有对象实例和数组,GC 主战场。分为年轻代(Eden + S0 + S1)和老年代
- 元空间(替代永久代):类信息、常量池、静态变量。使用本地内存
- 虚拟机栈:每个方法调用创建一个栈帧(局部变量表 + 操作数栈 + 动态链接 + 返回地址)
- 本地方法栈:为 Native 方法服务
- 程序计数器:记录当前线程执行的字节码行号
Q2:JDK 1.8 为什么要用元空间替换永久代?(高频 ⭐⭐⭐⭐)
答:
- 永久代大小固定(
-XX:MaxPermSize),容易 OOM;元空间用本地内存,大小受限于物理内存 - 永久代的 GC 效率低,Full GC 时才回收;元空间改进了类卸载机制
- 方便 JRockit 和 HotSpot 融合(JRockit 没有永久代)
Q3:对象一定分配在堆上吗?(中频 ⭐⭐⭐⭐)
答:不一定。JVM 有“逃逸分析”优化:
- 对象只在方法内部使用(未逃逸)→ 可分配在栈上(栈上分配),方法结束自动释放
- 可以标量替换:把对象拆成基本类型,直接用局部变量
- 目的是减少 GC 压力
3.2 垃圾回收(GC)
小白讲解
3 个核心问题:什么对象该回收?怎么回收?什么时候回收?
1. 什么对象该回收?—— 可达性分析
从 GC Roots 开始遍历对象引用链
能遍历到的 → 存活
遍历不到的 → 垃圾
GC Roots 包括:
- 虚拟机栈中的局部变量
- 方法区的静态变量
- 方法区的常量
- 本地方法栈中的 JNI 引用
- 活跃线程
- 同步锁持有的对象
2. 怎么回收?—— GC 算法
标记-清除(Mark-Sweep):
标记垃圾 → 直接清除
缺点:内存碎片
复制(Copying):
内存分两半,存活对象复制到另一半,清空原来的
缺点:浪费一半空间
用于:年轻代(Eden + Survivor)
标记-整理(Mark-Compact):
标记垃圾 → 存活对象向一端移动 → 清理边界外的
优点:无碎片
用于:老年代
3. 什么时候回收?—— 分代回收
年轻代(Eden : S0 : S1 = 8 : 1 : 1)
新对象先分配在 Eden
Eden 满 → Minor GC:
Eden 存活对象 + S0 存活对象 → 复制到 S1
清空 Eden + S0
S0 和 S1 交换角色
每经历一次 GC,年龄 +1
年龄 ≥ 15(默认)→ 晋升到老年代
老年代
存放长期存活的对象和大对象
老年代满 → Major GC / Full GC
Full GC 比 Minor GC 慢 10 倍以上
4. 垃圾回收器对比
| 回收器 | 年轻代 | 老年代 | 特点 | 适用场景 |
|---|---|---|---|---|
| Serial | 复制 | 标记整理 | 单线程,STW | 客户端/小应用 |
| Parallel Scavenge | 复制 | 标记整理 | 多线程,吞吐量优先 | 后台计算 |
| CMS | ParNew | 标记清除 | 低延迟,并发标记 | 对延迟敏感(已废弃) |
| G1 | 分区 | 分区 | 可预测停顿,Region 化 | 大堆内存(简历写了) |
| ZGC | 分区 | 分区 | 着色指针,<10ms 停顿 | 超大堆(简历写了) |
3.3 G1 垃圾收集器详解
为什么需要 G1?
CMS 的痛点:
1. 内存碎片严重 → 大对象无法分配 → 提前触发 Full GC(Serial Old 单线程整理,停顿几秒)
2. 停顿时间不可控 → 堆越大,Full GC 越慢
3. 无法设置停顿目标 → 运维只能祈祷
G1 的设计目标:
"在可控的停顿时间内,尽可能多地回收垃圾"
→ 可以设置 -XX:MaxGCPauseMillis=200(目标停顿 200ms)
→ G1 会根据这个目标,选择最有价值的 Region 优先回收
Region:G1 的核心创新
传统 GC(CMS/Parallel):
堆 = 连续的年轻代 + 连续的老年代(物理上固定划分)
G1:
堆 = 最多 2048 个 Region(每个 1~32MB,必须是 2 的幂)
每个 Region 可以是 Eden、Survivor、Old、Humongous 中的任意一种
Region 的角色是动态的,不再是物理固定
Region 类型:
┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
│ Eden │ Eden │ S0 │ Old │ Old │Humong│ Free │ Free │ ← 物理上离散
│(2MB) │(2MB) │(2MB) │(2MB) │(2MB) │(4MB) │(2MB) │(2MB) │
└──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
Humongous Region:存大对象(超过 Region 容量 50% 的对象)
一个 Humongous 对象可能占多个连续 Region
G1 的 Young GC(Minor GC)
触发条件:Eden Region 占满
过程(全部 STW):
1. 扫描 GC Roots
2. 处理 Remembered Set(找到老年代→年轻代的引用)
3. 将 Eden + Survivor 中的存活对象复制到新的 Survivor Region
4. 年龄达标的对象晋升到 Old Region
5. 清空 Eden Region 和旧 Survivor Region
特点:
- 整个 Young GC 都是 STW 的,但很快(因为只处理年轻代 Region)
- 通过 Remembered Set 避免扫描整个老年代
Remembered Set(RSet):G1 的关键数据结构
问题:Young GC 时需要找到老年代对象引用了哪些年轻代对象
如果扫描整个老年代 → 太慢
解决:每个 Region 维护一个 RSet
RSet 记录了"哪些其他 Region 的对象引用了我"
例如:Old Region A 的某个对象引用了 Eden Region B 的对象
→ Region B 的 RSet 记录 "Region A 引用了我"
Young GC 时:
只需扫描 Eden Region 的 RSet 中的 Old Region
不需要扫描所有 Old Region
底层实现:
RSet 基于 Card Table(卡表)
把堆分成 512 字节的 Card(卡片)
如果 Card 中有对象被修改(写屏障),标记为 Dirty
GC 时只扫描 Dirty Card 对应的老年代区域
写屏障(Write Barrier):
每次给引用类型字段赋值时,JVM 插入一段额外代码
记录这个修改到 Card Table
代价:每次写操作多几条指令(性能开销约 5%~10%)
G1 的 Mixed GC(混合回收)
触发条件:老年代占用达到阈值(默认整堆的 45%,-XX:InitiatingHeapOccupancyPercent)
过程(4 个阶段):
阶段1:初始标记(Initial Mark,STW)
- 标记 GC Roots 直接可达的对象
- 借用 Young GC 的 STW 完成(piggyback)
- 很快,通常 < 5ms
阶段2:并发标记(Concurrent Marking,非 STW)
- 从 GC Roots 开始遍历整个堆的对象图
- 与应用线程并发执行
- 使用 SATB(Snapshot-At-The-Beginning)算法保证正确性
- 耗时较长,但不影响应用
阶段3:最终标记(Remark,STW)
- 处理并发标记期间应用线程造成的引用变更
- 使用 SATB 的写屏障记录的变更日志
- 通常几十毫秒
阶段4:筛选回收(Cleanup / Evacuation,STW)
- 统计每个 Region 的垃圾比例和回收价值
- 优先回收垃圾最多的 Region(Garbage First 名字的由来)
- 回收方式:把存活对象复制到空 Region,然后整体清空旧 Region
- 这一步决定停顿时间是否达标(MaxGCPauseMillis)
SATB(Snapshot-At-The-Beginning):
在并发标记开始时,给对象图拍一个"快照"
标记过程只处理快照中的引用关系
并发期间新分配的对象直接标记为存活(不处理)
并发期间引用变更通过写屏障记录到 SATB Buffer
最终标记阶段统一处理 SATB Buffer 中的变更
G1 的 Full GC(要尽量避免)
触发条件:
1. Mixed GC 回收速度跟不上对象分配速度 → 老年代被占满
2. Humongous 对象分配失败(找不到连续 Region)
3. 元空间不足
Full GC 的行为:
G1 的 Full GC 是单线程的(JDK 9 及以前)
JDK 10+ 改为多线程(Parallel Full GC)
停顿时间可能是秒级甚至十几秒
如何避免:
1. 增大堆内存
2. 提前触发 Mixed GC(降低 IHOP 阈值)
3. 增加并发标记线程数(-XX:ConcGCThreads)
4. 避免大对象(Humongous 分配)
G1 常用参数
# 启用 G1
-XX:+UseG1GC
# 目标最大停顿时间(默认 200ms)
-XX:MaxGCPauseMillis=200
# Region 大小(默认自动计算,范围 1MB~32MB,必须是 2 的幂)
-XX:G1HeapRegionSize=4m
# 触发 Mixed GC 的老年代占用阈值(默认 45%)
-XX:InitiatingHeapOccupancyPercent=40
# 并发标记阶段的 GC 线程数
-XX:ConcGCThreads=4
# STW 阶段的 GC 线程数
-XX:ParallelGCThreads=8
# G1 保留的空闲 Region 比例(防止晋升失败,默认 10%)
-XX:G1ReservePercent=15
# 打印 GC 日志
-Xlog:gc*:file=gc.log:time,level,tags
G1 面试题
Q1:G1 的 Region 是怎么设计的?为什么要用 Region?(高频 ⭐⭐⭐⭐⭐)
答:
- G1 把堆划分为最多 2048 个大小相等的 Region(每个 1~32MB),不再是物理上连续的年轻代和老年代
- 每个 Region 可以动态扮演 Eden、Survivor、Old、Humongous 角色
- 好处:
- 可预测停顿:G1 可以精确计算每个 Region 的回收价值和回收耗时,在 MaxGCPauseMillis 约束下选择最有价值的 Region 组合进行回收
- 无碎片:回收时把存活对象复制到空 Region,然后整体释放旧 Region,相当于整体是复制算法
- 灵活:Region 大小可调(-XX:G1HeapRegionSize),适应不同大小的堆
Q2:G1 的 Young GC 和 Mixed GC 有什么区别?(高频 ⭐⭐⭐⭐⭐)
| 对比项 | Young GC | Mixed GC |
|---|---|---|
| 回收范围 | 只回收年轻代 Region | 年轻代 + 部分老年代 Region |
| 触发条件 | Eden 满 | 老年代达到 IHOP 阈值(默认 45%) |
| 标记方式 | 全 STW,利用 RSet 快速定位 | 并发标记(SATB)+ STW 筛选 |
| 停顿时间 | 短(通常 < 50ms) | 取决于回收的 Region 数量 |
| 频率 | 高 | 低 |
Q3:什么是 Remembered Set?解决什么问题?(高频 ⭐⭐⭐⭐)
答:
- 问题:Young GC 需要找到老年代对象引用了哪些年轻代对象(跨代引用),如果扫描整个老年代太慢
- 方案:每个 Region 维护一个 RSet,记录“哪些其他 Region 的对象引用了我”
- 实现:基于 Card Table + 写屏障。每次修改引用字段时,写屏障将对应 Card 标记为 Dirty,GC 时只扫描 Dirty Card
- 代价:写屏障带来约 5%~10% 的性能开销,但远小于扫描整个老年代
Q4:G1 的 SATB 算法是什么?(中频 ⭐⭐⭐)
答:
- SATB(Snapshot-At-The-Beginning)是 G1 并发标记的核心算法
- 在并发标记开始时,对对象图拍一个“逻辑快照”
- 标记过程只处理快照中的引用关系,并发期间新创建的对象直接视为存活
- 并发期间的引用变更通过写屏障记录到 SATB Buffer
- 最终标记(Remark)阶段统一处理 Buffer 中的变更
- 对比 CMS 的增量更新(Incremental Update):SATB 的 Remark 阶段更快,因为只需要处理 Buffer 中的变更,不需要重新扫描
Q5:G1 什么时候会触发 Full GC?怎么避免?(高频 ⭐⭐⭐⭐)
答:
- 触发场景:
- Mixed GC 回收速度跟不上对象分配速度 → 老年代被占满(Evacuation Failure)
- Humongous 对象分配失败(找不到连续空闲 Region)
- 元空间不足触发
- 避免措施:
- 增大堆内存
- 降低 IHOP 阈值让 Mixed GC 更早触发
- 增加并发标记线程(-XX:ConcGCThreads)加速标记
- 避免创建大对象(超过 Region 50% 的对象)
- 增大 G1ReservePercent 预留更多空闲 Region
Q6:G1 和 CMS 的核心区别?(高频 ⭐⭐⭐⭐⭐)
| 对比项 | CMS | G1 |
|---|---|---|
| 算法 | 标记-清除 | 标记-复制 + 标记-整理 |
| 内存划分 | 物理分代(连续年轻代/老年代) | Region 逻辑分代(离散) |
| 碎片 | 有碎片,需定期 Full GC 整理 | 无碎片(Region 级复制) |
| 停顿控制 | 不可预测 | 可设目标停顿时间 |
| 并发标记 | 增量更新 | SATB 快照 |
| 跨代引用 | Card Table | Remembered Set(更强) |
| 堆大小 | < 8GB | 6GB+ |
| Full GC | 单线程 Serial Old | JDK 10+ 多线程 |
| 状态 | JDK 9 废弃 | JDK 9+ 默认 |
3.4 ZGC 垃圾收集器详解
为什么需要 ZGC?
G1 的局限:
- 停顿时间目标 200ms,实际可能 50~300ms
- 堆越大,停顿越长(扫描和复制的工作量与堆大小成正比)
- TB 级堆 → 停顿不可接受
ZGC 的设计目标:
- 停顿时间 < 10ms(无论堆多大,4GB 还是 4TB)
- 停顿时间不与堆大小成正比
- 支持 TB 级堆内存
核心思路:
"把最耗时的操作全部并发化,STW 只做最少量的事"
→ 标记、转移、重映射全部并发执行
→ STW 只做:初始标记(几个 GC Roots)、最终转移前的准备
着色指针(Colored Pointers):ZGC 的核心创新
传统 GC:对象头中存储 GC 标记信息(Mark Word)
→ 需要修改对象头 → 需要 STW 或写屏障
ZGC:直接在指针中嵌入 GC 标记信息
64 位指针中只有 44 位用于寻址(支持 16TB 内存)
剩余 20 位中的 4 位用于 GC 标记:
┌──────────────────────────────────────────────────────────┐
│ 63 44 43 42 41 40 39 0 │
│ 未使用 │ M1 │ M0 │ R │ F │ 对象地址(44位) │
└──────────────────────────────────────────────────────────┘
M0/M1 (Marked0/Marked1):标记阶段使用,标识对象是否存活
R (Remapped):重映射阶段使用,标识指针是否已指向新地址
F (Finalizable):标识对象是否只能通过 Finalizer 访问
好处:
- 不需要修改对象头 → 标记和转移可以并发
- 读指针时就能知道对象的 GC 状态 → 读屏障自动处理
读屏障(Load Barrier):ZGC 的关键机制
传统 GC 的写屏障:在写引用时插入额外代码(记录 Card Table)
ZGC 的读屏障:在读引用时插入额外代码
每次从堆中读取一个对象引用时,JVM 会:
1. 检查指针的标记位(M0/M1/R/F)
2. 如果指针是"好的"(已标记、已重映射)→ 直接使用
3. 如果指针是"坏的"(未标记、需要重映射)→ 自动修正
- 标记阶段:把对象加入标记队列,修正指针标记
- 转移阶段:查转发表(Forwarding Table),获取新地址,更新指针
读屏障的伪代码:
Object readBarrier(Object* ptr) {
if (isGoodPtr(ptr)) {
return *ptr; // 直接使用,无额外开销
} else {
return slowPath(ptr); // 修正指针
}
}
性能影响:
- 大部分读操作是"好的指针" → 只需一次位运算检查
- 实测性能开销约 5%~15%
- 但换来了 < 10ms 的 STW 停顿
ZGC 的 4 个阶段
┌─────────────────────────────────────────────────────────┐
│ ZGC 完整 GC 周期 │
├─────────────────────────────────────────────────────────┤
│ │
│ 阶段1:初始标记(STW,< 1ms) │
│ - 标记 GC Roots 直接引用的对象 │
│ - 只扫描几十个 GC Roots,所以极快 │
│ │
│ 阶段2:并发标记(非 STW,耗时与堆大小成正比) │
│ - 从 GC Roots 遍历整个对象图 │
│ - 使用 M0/M1 标记位标识存活对象 │
│ - 读屏障自动处理应用线程的并发修改 │
│ - 与应用线程并发执行,不影响业务 │
│ │
│ 阶段3:再标记(STW,< 1ms) │
│ - 处理并发标记期间的引用变更 │
│ - 完成最后的标记工作 │
│ - 决定哪些 Region 需要回收 │
│ │
│ 阶段4:并发转移(非 STW) │
│ ├─ 初始转移(STW,< 1ms):转移少量 GC Roots 直接引用的对象│
│ └─ 并发转移(非 STW): │
│ - 把存活对象从旧 Region 移动到新 Region │
│ - 旧 Region 中的对象通过转发表(Forwarding Table) │
│ 记录旧地址→新地址的映射 │
│ - 应用线程读到旧地址时,读屏障自动查转发表获取新地址 │
│ - 旧 Region 清空后可以立即复用 │
│ │
│ 总 STW 时间:初始标记 + 再标记 + 初始转移 ≈ < 10ms │
│ 总耗时:并发标记 + 并发转移,与堆大小成正比 │
│ │
└─────────────────────────────────────────────────────────┘
转发表(Forwarding Table)
对象从旧地址转移到新地址后,旧 Region 中保留一个转发表:
旧地址 → 新地址
应用线程通过读屏障访问旧地址时:
1. 发现指针指向已转移的 Region
2. 查转发表获取新地址
3. 更新指针指向新地址(自愈)
4. 返回新地址的对象
当所有指向旧 Region 的指针都被修正后:
→ 转发表可以删除
→ 旧 Region 可以完全回收
这个过程叫"重映射"(Remapping)
ZGC vs G1 对比
| 对比项 | G1 | ZGC |
|---|---|---|
| 停顿目标 | 200ms(可配) | < 10ms |
| 停顿与堆大小 | 成正比 | 几乎无关 |
| 最大堆 | ~32GB 推荐 | ~16TB |
| 并发能力 | 部分并发(标记并发,回收 STW) | 几乎全并发(标记+转移都并发) |
| 标记方式 | 修改对象头 + SATB | 着色指针 + 读屏障 |
| 内存碎片 | 无碎片(Region 复制) | 无碎片(Region 转移) |
| 吞吐量损失 | ~10% | ~15% |
| 适用 JDK | 9+ | 11+(15 转正) |
| 适用场景 | 大堆 + 可接受百毫秒停顿 | 超大堆 + 极低延迟 |
ZGC 常用参数
# 启用 ZGC(JDK 15+ 正式可用)
-XX:+UseZGC
# JDK 17+ 推荐启用分代 ZGC(Generational ZGC)
-XX:+UseZGC -XX:+ZGenerational
# 堆大小(ZGC 支持 TB 级)
-Xmx16g
# GC 线程数
-XX:ConcGCThreads=4
# 打印 GC 日志
-Xlog:gc*:file=zgc.log:time,level,tags
ZGC 面试题
Q1:ZGC 为什么能做到 < 10ms 停顿?(高频 ⭐⭐⭐⭐⭐)
答:三大核心技术:
- 着色指针:把 GC 标记信息直接嵌入 64 位指针中(M0/M1/R/F 四个标记位),不需要修改对象头。标记和转移时可以并发读取指针状态,不需要 STW
- 读屏障:每次读取对象引用时,自动检查指针标记位。如果是“坏指针”(需要标记或重映射),读屏障自动修正。大部分读操作只需一次位运算,开销极小
- 并发转移:对象的移动和重映射都与应用线程并发执行。STW 只做初始标记(扫描 GC Roots)和再标记(处理并发变更),这两个操作与堆大小无关,所以停顿时间与堆大小无关
Q2:ZGC 的着色指针和 G1 的 SATB 有什么区别?(中频 ⭐⭐⭐)
| 对比项 | G1 SATB | ZGC 着色指针 |
|---|---|---|
| 标记位置 | 对象头(Mark Word) | 指针本身(64 位中的 4 位) |
| 并发标记 | 需要写屏障 + SATB Buffer | 需要读屏障 |
| 并发转移 | 不支持(回收时 STW) | 支持(读屏障 + 转发表) |
| 额外内存 | SATB Buffer | 转发表(Forwarding Table) |
| 性能开销 | ~10% | ~15% |
Q3:ZGC 的读屏障是怎么工作的?(高频 ⭐⭐⭐⭐)
答:
- 每次从堆中读取对象引用时,JVM 插入读屏障代码
- 检查指针的标记位(M0/M1/R/F),判断指针是否“好的”
- 好的指针:直接使用(约 99% 的情况,开销极小)
- 坏的指针分两种:
- 标记阶段:对象未被标记 → 加入标记队列,修正标记位
- 转移阶段:对象已移动 → 查转发表获取新地址,更新指针(自愈)
- 修正后下次再读同一个指针就是“好的”了,不会重复修正
Q4:ZGC 有什么缺点?(中频 ⭐⭐⭐)
答:
- 吞吐量损失:读屏障带来约 10%~15% 的吞吐量下降,对 CPU 敏感的应用有影响
- 内存开销:转发表需要额外内存;多重映射(multi-mapping)技术会使内存使用率略高于 G1
- 不支持分代(JDK 15-20):早期 ZGC 是单代的,无法利用分代假设优化,导致 GC 频率较高。JDK 21 引入了分代 ZGC(Generational ZGC)解决这个问题
- 不适合小堆:4GB 以下的堆用 G1 或 Parallel 更划算,ZGC 的优势在大堆才体现
- JDK 版本要求:JDK 15 才正式可用,JDK 17+ 才推荐生产使用
Q5:G1 和 ZGC 怎么选?(高频 ⭐⭐⭐⭐)
答:
- 堆 < 8GB:用 G1,停顿时间已经够低(< 100ms),ZGC 的优势发挥不出来
- 堆 8~32GB:用 G1,设置 MaxGCPauseMillis=200 能满足大多数低延迟需求
- 堆 > 32GB 或延迟要求 < 10ms:用 ZGC,停顿时间与堆大小无关,TB 级堆也能保持低延迟
- JDK 17+ 且堆 > 32GB:用分代 ZGC(Generational ZGC),兼顾低停顿和高吞吐
- 实际项目:简历写了 G1/ZGC,面试时重点讲 G1(用得多),ZGC 讲原理即可
3.3 类加载机制
小白讲解
类加载的 5 个阶段:
加载 → 验证 → 准备 → 解析 → 初始化
│ │
└────── 使用 ────── 卸载 ────────────┘
1. 加载:把 .class 文件读入内存,生成 Class 对象
2. 验证:检查字节码格式、元数据、符号引用等
3. 准备:为静态变量分配内存并赋默认值(0/null)
注意:static int x = 10; 此时 x=0
4. 解析:符号引用 → 直接引用
5. 初始化:执行 <clinit>() 方法,给静态变量赋实际值
static int x = 10; 此时 x=10
双亲委派模型:
BootstrapClassLoader(加载 rt.jar,Java 核心类)
↑
ExtClassLoader(加载 ext/*.jar,扩展类)
↑
AppClassLoader(加载 classpath,应用类)
↑
自定义 ClassLoader
收到类加载请求时:
1. 先委托给父加载器
2. 父加载器加载不了,自己才加载
为什么?
- 安全:防止用户写一个 java.lang.String 替代核心类
- 唯一性:同一个类只会被加载一次
代码示例
public class ClassLoaderDemo {
public static void main(String[] args) {
// 查看类的加载器
System.out.println("String 的加载器: "
+ String.class.getClassLoader());
// null → BootstrapClassLoader(C++ 实现,Java 层拿不到)
System.out.println("当前类的加载器: "
+ ClassLoaderDemo.class.getClassLoader());
// sun.misc.Launcher$AppClassLoader
System.out.println("AppClassLoader 的父加载器: "
+ ClassLoaderDemo.class.getClassLoader().getParent());
// sun.misc.Launcher$ExtClassLoader
// 面试题:能自己写一个 java.lang.String 吗?
// 不能。双亲委派会先让 BootstrapClassLoader 加载核心的 String
// 如果自定义 ClassLoader 重写 loadClass 绕过双亲委派
// → JVM 会报 SecurityException(核心包名受保护)
// Tomcat 打破了双亲委派:
// 每个 WebApp 有自己的 ClassLoader,优先加载自己的类
// 原因:不同应用可能依赖同一个库的不同版本
}
}
面试题
Q1:什么是双亲委派模型?为什么这样设计?(高频 ⭐⭐⭐⭐⭐)
答:
- 机制:类加载请求先委托给父加载器处理,父加载器处理不了才自己加载
- 目的:
- 安全性:防止核心类被篡改(用户写的
java.lang.String不会被加载) - 唯一性:保证同一个类在 JVM 中只有一份(用 ClassLoader + 类全限定名做唯一标识)
- 层次清晰:核心类由 Bootstrap 加载,扩展类由 Ext 加载,应用类由 App 加载
- 安全性:防止核心类被篡改(用户写的
Q2:哪些场景打破了双亲委派?(中频 ⭐⭐⭐⭐)
答:
- Tomcat:每个 Web 应用一个 WebAppClassLoader,优先加载自己 WEB-INF/classes 的类(不同应用可能依赖同一个库的不同版本)
- SPI 机制:JDBC 的
DriverManager由 BootstrapClassLoader 加载,但具体驱动实现在 classpath 下。用Thread.getContextClassLoader()打破 - OSGi:模块化加载,网状结构而非树状
- 热部署:JRebel 等,创建新的 ClassLoader 加载新版本类
3.4 JVM 调优
常用参数
# 堆大小
-Xms4g # 初始堆大小(建议和 Xmx 一样,避免动态扩容)
-Xmx4g # 最大堆大小
-Xmn2g # 年轻代大小
-XX:MetaspaceSize=256m # 元空间初始大小
-XX:MaxMetaspaceSize=512m # 元空间最大大小
# GC 选型
-XX:+UseG1GC # 使用 G1
-XX:MaxGCPauseMillis=200 # G1 目标停顿时间
-XX:+UseZGC # 使用 ZGC(JDK 15+)
# GC 日志
-Xlog:gc*:file=gc.log:time,uptime,level:filecount=10,filesize=50M # JDK 9+
# 诊断
-XX:+HeapDumpOnOutOfMemoryError # OOM 时自动 dump
-XX:HeapDumpPath=/tmp/heapdump.hprof
面试题
Q1:线上 OOM 怎么排查?(高频 ⭐⭐⭐⭐⭐)
答:
- 事前配置:
-XX:+HeapDumpOnOutOfMemoryError让 OOM 时自动生成 dump 文件 - 事后排查:
jps找到 Java 进程 PIDjmap -dump:format=b,file=heap.hprof <pid>导出堆快照- 用 MAT (Memory Analyzer Tool) 或 VisualVM 分析 hprof 文件
- 找到占用内存最大的对象 → 查看引用链 → 定位代码
- 常见原因:
- 大量数据加载到内存(如一次查 100 万条记录)
- 静态集合无限增长(Map/List 只 put 不 remove)
- ThreadLocal 未 remove(线程池场景)
- 内存泄漏(监听器未注销、连接未关闭)
- 在线诊断:用 Arthas 的
dashboard/heapdump/thread命令
Q2:线上 CPU 100% 怎么排查?(高频 ⭐⭐⭐⭐⭐)
答:
# 1. 找到 CPU 最高的 Java 进程
top
# 2. 找到该进程中 CPU 最高的线程
top -Hp <pid>
# 3. 将线程 PID 转为十六进制
printf "%x\n" <thread_pid>
# 4. 用 jstack 导出线程栈
jstack <pid> | grep -A 30 <hex_pid>
# 5. 看堆栈定位到代码行
或者直接用 Arthas:
arthas → thread -n 3 # 直接显示 CPU 最高的 3 个线程
第四章:Java 基础高频考点
4.1 String
小白讲解
String 不可变的原因:
1. 安全性:String 用作 HashMap 的 key、类加载器加载类名、数据库 URL
如果可变,改了之后 HashMap 找不到值、加载错类
2. 线程安全:不可变天然线程安全,不需要同步
3. 字符串常量池:不可变才能共享,节省内存
String s1 = "abc"; String s2 = "abc"; → 指向常量池同一个对象
4. hashCode 缓存:不可变所以 hashCode 只需算一次
String vs StringBuilder vs StringBuffer
String:不可变,每次拼接都创建新对象
StringBuilder:可变,非线程安全,性能最好
StringBuffer:可变,线程安全(synchronized),性能略低
面试题
Q1:String s = new String(“abc”) 创建了几个对象?(高频 ⭐⭐⭐⭐)
答:
- 如果常量池没有 “abc”:创建 2 个对象。一个在堆(new 出来的),一个在常量池
- 如果常量池已有 “abc”:创建 1 个对象(只在堆中)
Q2:String 的 equals() 和 == 的区别?(高频 ⭐⭐⭐)
答:
==比较引用地址(是否是同一个对象)equals()比较内容(字符序列是否相同)intern()方法:返回常量池中的引用。s.intern()如果常量池有则返回,没有则放入并返回
4.2 泛型
面试题
Q1:什么是类型擦除?(中频 ⭐⭐⭐⭐)
答:Java 泛型是编译期的语法糖,运行时没有泛型信息。
List<String> list1 = new ArrayList<>();
List<Integer> list2 = new ArrayList<>();
// 运行时 list1.getClass() == list2.getClass() → 都是 ArrayList.class
- 编译后
List<String>和List<Integer>都变成List(原始类型) - 泛型参数
<T extends Number>擦除后用 Number 代替 <T>擦除后用 Object 代替
Q2:泛型通配符 ?、extends、super 的区别?(中频 ⭐⭐⡡)
答:
List<?>:未知类型,只能读不能写(除了 null)List<? extends Number>:上界,可以读 Number,不能写(不确定具体子类型)List<? super Number>:下界,可以写 Number 及其子类,读出来是 Object
PECS 原则:Producer Extends, Consumer Super
- 频繁读取用
extends - 频繁插入用
super
4.3 反射与动态代理
小白讲解
反射:在运行时动态获取类信息、调用方法、访问字段
动态代理:在不修改源码的情况下,在运行时为目标对象创建代理
JDK 动态代理:基于接口(目标类必须实现接口)
CGLIB 动态代理:基于继承(生成子类,不能代理 final 类/方法)
代码示例
public class ReflectionDemo {
public static void main(String[] args) throws Exception {
// 反射获取类信息
Class<?> clazz = Class.forName("java.lang.String");
// 创建实例
Object str = clazz.getDeclaredConstructor(String.class)
.newInstance("hello");
// 调用方法
Method lengthMethod = clazz.getMethod("length");
int len = (int) lengthMethod.invoke(str);
System.out.println("length: " + len); // 5
// JDK 动态代理
UserService target = new UserServiceImpl();
UserService proxy = (UserService) Proxy.newProxyInstance(
target.getClass().getClassLoader(),
target.getClass().getInterfaces(),
(proxyObj, method, args1) -> {
System.out.println("前置:记录日志");
Object result = method.invoke(target, args1);
System.out.println("后置:记录耗时");
return result;
}
);
proxy.findById(1);
// 输出:
// 前置:记录日志
// UserServiceImpl findById: 1
// 后置:记录耗时
// Spring AOP 的底层就是动态代理:
// 目标类有接口 → JDK 动态代理
// 目标类无接口 → CGLIB 动态代理
}
}
interface UserService { Object findById(Long id); }
class UserServiceImpl implements UserService {
public Object findById(Long id) {
System.out.println("UserServiceImpl findById: " + id);
return new Object();
}
}
面试题
Q1:JDK 动态代理和 CGLIB 的区别?(高频 ⭐⭐⭐⭐)
| 对比项 | JDK 动态代理 | CGLIB |
|---|---|---|
| 原理 | 接口 + 反射 | 继承 + ASM 字节码生成 |
| 要求 | 目标类必须实现接口 | 不需要接口,不能是 final 类 |
| 性能 | 创建快,调用稍慢 | 创建慢,调用快 |
| Spring AOP | 有接口时默认用 | 无接口时用 |
4.4 异常体系
Throwable
├── Error(不该 catch)
│ ├── OutOfMemoryError
│ ├── StackOverflowError
│ └── VirtualMachineError
└── Exception
├── RuntimeException(非受检,编译器不强制处理)
│ ├── NullPointerException
│ ├── ClassCastException
│ ├── ArrayIndexOutOfBoundsException
│ └── IllegalArgumentException
└── 其他 Exception(受检,编译器强制 try-catch 或 throws)
├── IOException
├── SQLException
└── ClassNotFoundException
面试题
Q1:checked 和 unchecked 异常的区别?(中频 ⭐⭐⡡)
答:
- checked exception:继承 Exception 但非 RuntimeException。编译器强制处理(try-catch 或 throws)。如 IOException、SQLException
- unchecked exception:继承 RuntimeException。编译器不强制处理。如 NPE、IllegalArgumentException
Q2:try-catch-finally 的执行顺序?(高频 ⭐⭐⭐⭐)
答:
- try 正常 → finally 执行 → 返回 try 的值
- try 异常 → catch 执行 → finally 执行 → 返回 catch 的值
- 注意:如果 finally 中有 return,会覆盖 try/catch 的 return
- 注意:finally 在 return 之前执行,但 return 的值已经计算好了。如果 finally 修改了基本类型变量不影响返回值,但修改对象属性会影响
4.5 Java IO / NIO
小白讲解
BIO(Blocking IO):
每个连接一个线程,read() 阻塞直到有数据
适合连接数少且固定的场景
NIO(Non-blocking IO):
多路复用器(Selector)+ Channel + Buffer
一个线程管理多个连接,只有有数据时才处理
适合连接数多但传输轻量的场景
AIO(Async IO):
操作系统完成后回调通知
适合连接数多且传输重的场景
面试题
Q1:BIO、NIO、AIO 的区别?(中频 ⭐⭐⭐)
| 对比项 | BIO | NIO | AIO |
|---|---|---|---|
| 通信方式 | 流(Stream) | 通道(Channel) | 通道(Channel) |
| 数据方式 | 面向流 | 面向缓冲(Buffer) | 面向缓冲 |
| 阻塞 | 阻塞 | 非阻塞(多路复用) | 异步 |
| 线程模型 | 一连接一线程 | 一线程多连接 | 回调通知 |
| 适用场景 | 连接少 | 连接多传输少 | 连接多传输多 |
附:面试高频考点速查表
按出现频率排序
| 排名 | 考点 | 一句话答案 |
|---|---|---|
| 1 | HashMap 底层原理 | 数组+链表+红黑树,JDK 1.8 尾插法,链表≥8转树 |
| 2 | 线程池参数和流程 | 7 参数,核心→队列→最大→拒绝 |
| 3 | synchronized vs Lock | 关键字 vs 类,自动释放 vs 手动,不可中断 vs 可中断 |
| 4 | volatile 原理 | 可见性+有序性,不保证原子性,内存屏障 |
| 5 | CAS 原理 | 比较并交换,CPU 指令 cmpxchg,ABA 问题 |
| 6 | AQS 原理 | state+CLH 队列,模板方法模式 |
| 7 | JVM 内存结构 | 堆+元空间+栈+本地方法栈+PC |
| 8 | GC 算法 | 标记清除/复制/标记整理,分代回收 |
| 9 | G1 vs CMS | Region 化 vs 物理分代,可预测停顿 vs 不可预测 |
| 10 | 双亲委派 | 先委托父加载器,安全+唯一性 |
| 11 | DCL 单例 | 双重检查+volatile 防指令重排 |
| 12 | ConcurrentHashMap | JDK 1.8 CAS+synchronized 锁桶头节点 |
| 13 | ThreadLocal 内存泄漏 | key 弱引用 value 强引用,必须 remove |
| 14 | 对象进入老年代 | 年龄≥15/大对象/动态年龄/担保失败 |
| 15 | 线程生命周期 | NEW→RUNNABLE→(WAITING/TIMED_WAITING)→TERMINATED |
使用建议:先通读讲解部分理解概念,然后对照代码示例自己写一遍,最后用面试题自测。 如果某个面试题答不上来,回头再看对应讲解。重点是理解“为什么”而不是背诵“是什么”。