vault backup: 2026-08-08 19:01:04
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
---
|
||||
tags: [java/lang, thread-pool, ThreadPoolExecutor, rejection-policy, task-queue]
|
||||
create time: 2026-08-08 18:00
|
||||
update time: 2026-08-08 18:00
|
||||
---
|
||||
|
||||
# 线程池参数与拒绝策略
|
||||
|
||||
## 概述
|
||||
|
||||
线程池是 Java 并发编程的核心工具类,通过复用预先创建的线程来降低频繁创建/销毁线程的开销。ThreadPoolExecutor 是所有线程池工厂(Executors)背后的真正实现,直接暴露了全部可调参数——理解它的运行机制是编写可靠并发代码的第一步。
|
||||
|
||||
## 核心原理
|
||||
|
||||
### ThreadPoolExecutor 七大参数
|
||||
|
||||
```java
|
||||
public ThreadPoolExecutor(
|
||||
int corePoolSize, // 核心线程数
|
||||
int maximumPoolSize, // 最大线程数
|
||||
long keepAliveTime, // 非核心线程空闲存活时间
|
||||
TimeUnit unit, // 存活时间单位
|
||||
BlockingQueue<Runnable> workQueue, // 工作队列
|
||||
ThreadFactory threadFactory, // 线程工厂
|
||||
RejectedExecutionHandler handler // 拒绝策略
|
||||
) {}
|
||||
```
|
||||
|
||||
这七个参数中,前五个决定了线程池的**容量模型**和**任务调度逻辑**,后两个属于**扩展点**。
|
||||
|
||||
### 任务提交流程
|
||||
|
||||
当调用 `execute(Runnable)` 时,任务按以下优先级流转:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["提交任务"] --> B{"当前线程数 < corePoolSize?"}
|
||||
B -->|是| C["创建核心线程执行任务"]
|
||||
B -->|否| D{"队列是否已满?"}
|
||||
D -->|否| E["放入工作队列等待"]
|
||||
D -->|是| F{"当前线程数 < maxPoolSize?"}
|
||||
F -->|是| G["创建非核心线程执行任务"]
|
||||
F -->|否| H["执行拒绝策略"]
|
||||
C --> I[完成]
|
||||
E --> J{"核心线程<br/>超时检测"}
|
||||
J -->|未超时| E
|
||||
J -->|已超时| K["回收线程"]
|
||||
G --> I
|
||||
H --> L["记录异常 / 告警"]
|
||||
```
|
||||
|
||||
**流程解读**:
|
||||
|
||||
1. **第一步**:如果当前运行线程数小于 `corePoolSize`,即使有空闲线程也创建新线程执行任务(除非设置了 `allowCoreThreadTimeOut`)。
|
||||
2. **第二步**:如果线程数已达核心数且队列未满,将任务放入工作队列排队。
|
||||
3. **第三步**:如果队列已满但线程数未达到 `maximumPoolSize`,创建非核心线程处理。
|
||||
4. **第四步**:如果线程数达到上限且队列仍满,触发拒绝策略。
|
||||
|
||||
> [!NOTE]
|
||||
> 这是面试高频坑点:`Executors.newFixedThreadPool()` 使用的是无界队列 `LinkedBlockingQueue`(capacity = Integer.MAX_VALUE),导致第 3、4 步永远不会发生——所有任务都在队列中堆积,线程池永远只有 corePoolSize 个线程,`maximumPoolSize` 形同虚设。这在流量突增时会导致 OOM。
|
||||
|
||||
### 四种拒绝策略
|
||||
|
||||
| 策略 | 行为 | 适用场景 |
|
||||
|------|------|---------|
|
||||
| `AbortPolicy`(默认) | 抛出 `RejectedExecutionException` | 要求必须处理的场景,快速失败 |
|
||||
| `CallerRunsPolicy` | 由提交任务的线程直接执行 | 降速缓冲,让提交方承担处理成本 |
|
||||
| `DiscardPolicy` | 静默丢弃任务 | 可丢失的非关键任务 |
|
||||
| `DiscardOldestPolicy` | 丢弃队列中最老的任务,再尝试提交 | 保最新数据的批处理场景 |
|
||||
|
||||
> [!WARNING]
|
||||
> `CallerRunsPolicy` 是最"温和"的策略——但它会将任务回退到提交线程(通常是客户请求线程),如果任务耗时较长会阻塞整个业务链路。使用时务必确认调用方的线程性质。
|
||||
|
||||
### 工作队列类型
|
||||
|
||||
| 队列 | 类型 | 特点 |
|
||||
|------|------|------|
|
||||
| `ArrayBlockingQueue` | 有界数组 | FIFO,公平可控,推荐生产使用 |
|
||||
| `LinkedBlockingQueue` | 无界链表 | 默认 capacity = MAX_VALUE,易导致堆积 |
|
||||
| `SynchronousQueue` | 同步传输 | 不存储元素,直接交接,配合 `newCachedThreadPool` |
|
||||
| `PriorityBlockingQueue` | 优先队列 | 支持自定义优先级排序 |
|
||||
|
||||
```java
|
||||
// 推荐的线程池配置示例
|
||||
ExecutorService pool = new ThreadPoolExecutor(
|
||||
8, // corePoolSize
|
||||
16, // maximumPoolSize
|
||||
60L, TimeUnit.SECONDS, // keepAliveTime
|
||||
new ArrayBlockingQueue<>(100), // 有界队列,明确上限
|
||||
new ThreadFactoryBuilder()
|
||||
.setNameFormat("biz-pool-%d")
|
||||
.build(),
|
||||
new ThreadPoolExecutor.CallerRunsPolicy() // 降级而非崩溃
|
||||
);
|
||||
```
|
||||
|
||||
### CPU 密集型 vs IO 密集型调优公式
|
||||
|
||||
线程数的合理设定直接影响吞吐量。经验公式如下:
|
||||
|
||||
| 任务类型 | 公式 | 说明 |
|
||||
|----------|------|------|
|
||||
| CPU 密集型 | N + 1(N = CPU 核心数) | 多一个线程可以容忍页缺失等偶发停顿 |
|
||||
| IO 密集型 | N / (1 - 阻塞系数) | 阻塞系数通常取 0.8~0.9,即 N × 5 ~ N × 10 |
|
||||
| 混合型 | 根据各阶段占比加权平均 | CPU 阶段用 N+1,IO 阶段用大倍数 |
|
||||
|
||||
举例:在 8 核机器上处理 IO 密集型任务(阻塞系数 0.85):
|
||||
```
|
||||
线程数 ≈ 8 / (1 - 0.85) ≈ 53 个线程
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> 面试常考点:为什么 CPU 密集型只需 N+1?因为 CPU 密集型的线程几乎一直在计算,不需要等待 IO。多余线程只会增加上下文切换开销,不会提升吞吐。
|
||||
|
||||
## 实践场景
|
||||
|
||||
**常见反模式排查清单**:
|
||||
|
||||
1. 用 `Executors.newFixedThreadPool()` 接高并发请求 → 换为有界队列的 `ThreadPoolExecutor`
|
||||
2. 用 `new CachedThreadPool()` 处理持久化任务 → 缓存池会在空闲 60s 后回收所有线程,反复创建带来额外开销
|
||||
3. 任务没有设置超时机制 → 配合 `Future.get(timeout)` 或CompletableFuture.orTimeout()
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[01.Java/concurrent/锁升级与 CAS 机制]]
|
||||
@@ -0,0 +1,254 @@
|
||||
---
|
||||
tags: [java/lang, lock-escalation, CAS, AQS, optimistic-lock]
|
||||
create time: 2026-08-08 18:00
|
||||
update time: 2026-08-08 18:00
|
||||
---
|
||||
|
||||
# 锁升级与 CAS 机制
|
||||
|
||||
## 概述
|
||||
|
||||
Java 的 synchronized 关键字从 JDK 1.5 到 JDK 1.6 经历了一次重大升级——引入锁升级机制,让锁在低竞争时以极轻量方式运行,在高竞争时平滑过渡到重量级互斥锁。这一设计的核心是 **CAS(Compare And Swap)** 原子操作和 **AQS(AbstractQueuedSynchronizer)** 框架,它们共同构成了 Java 并发包的底层基石。
|
||||
|
||||
## 核心原理
|
||||
|
||||
### 锁升级之路:偏向 → 轻量级 → 重量级
|
||||
|
||||
synchronized 的锁状态不是静态的,它会随着竞争程度逐步"膨胀":
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> 无锁状态
|
||||
无锁状态 --> 偏向锁: 第一个线程获取锁
|
||||
偏向锁 --> 偏向锁: 同一线程重复获取\n(时间戳比较)
|
||||
偏向锁 --> 轻量级锁: 其他线程尝试获取\n(发生竞争)
|
||||
轻量级锁 --> 轻量级锁: CAS自旋成功\n(少数竞争者)
|
||||
轻量级锁 --> 重量级锁: CAS失败次数过多\n或自旋超时
|
||||
重量级锁 --> 轻量级锁: 竞争减弱\n(Monitor退出队列)
|
||||
```
|
||||
|
||||
#### 第一阶段:偏向锁(Biased Locking)
|
||||
|
||||
**触发条件**:第一个线程进入同步块时,JVM 会在对象头中记录当前线程 ID,后续该线程再次进入时无需任何同步操作。
|
||||
|
||||
- JDK 6 默认启用,需要 `-XX:+UseBiasedLocking`。
|
||||
- JDK 15 中被标记为废弃,JDK 17 中被移除。因为实际场景中"永远只有一个人访问"的概率很低,而偏向锁的撤销代价较高(需全局 STW 撤销所有偏向)。
|
||||
|
||||
**对象头结构(HotSpot,64位)**:
|
||||
|
||||
| 偏移 | 字段 | 大小 |
|
||||
|------|------|------|
|
||||
| 0-2 bit | Mark Word 低 3 位 | 锁标志位 |
|
||||
| 3-31 bit | 偏向锁标记 + ThreadID | 优先权 + 线程 ID |
|
||||
| 32-63 bit | 分代年龄 + hashCode | 64 位扩展信息 |
|
||||
|
||||
当有其他线程尝试获取偏向锁时,JVM 会撤销该对象的偏向状态,升级为轻量级锁。
|
||||
|
||||
#### 第二阶段:轻量级锁(Lightweight Locking)
|
||||
|
||||
**触发条件**:偏向锁被剥夺后,或者从一开始就存在多个线程竞争。
|
||||
|
||||
核心机制:**利用 CAS 替换对象头中的 Mark Word**。
|
||||
|
||||
流程:
|
||||
1. 线程在栈帧中创建 **Lock Record**,复制对象头的 Mark Word 到其中。
|
||||
2. 使用 CAS 将对象头的 Mark Word 替换为指向 Lock Record 的指针。
|
||||
3. 如果 CAS 成功,当前线程获得锁;如果失败,说明有竞争。
|
||||
4. 竞争发生时,当前线程自旋重试(最多循环指定次数)。
|
||||
5. 如果自旋超过阈值(默认 10 次,可通过 `-XX:PreBlockSpin` 调整),升级为重量级锁。
|
||||
|
||||
> [!NOTE]
|
||||
> "轻量级"并不意味着不需要操作系统内核帮助——只是在没有竞争时使用用户态 CAS,避免了上下文切换开销。一旦竞争激烈,它最终会退化为重量级锁。
|
||||
|
||||
#### 第三阶段:重量级锁(Heavyweight Locking)
|
||||
|
||||
**触发条件**:轻量级锁的 CAS 和自旋都未能成功获取锁。
|
||||
|
||||
此时 Monitor 对象成为真正的互斥锁:
|
||||
- 未获得锁的线程会被阻塞挂起(进入 OS 的等待队列)。
|
||||
- 阻塞/唤醒操作需要切换到内核态,这是性能损耗最大的环节。
|
||||
|
||||
### Unsafe 类与 CAS 原理
|
||||
|
||||
`Unsafe` 是 JVM 提供的一个"后门"类,允许 Java 代码直接操作内存和执行原子操作。其中的 CAS 原语是所有乐观锁的基础。
|
||||
|
||||
**核心方法签名**:
|
||||
|
||||
```java
|
||||
public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object x);
|
||||
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);
|
||||
public final native boolean compareAndSwapLong(Object o, long offset, long expected, long x);
|
||||
```
|
||||
|
||||
`offset` 是通过 `objectFieldOffset(Field)` 计算出的字段在对象内存布局中的字节偏移量。
|
||||
|
||||
**原理**:CAS 是 CPU 级别的原子指令(x86 下对应 `cmpxchg` 汇编),在多核环境下通过总线锁或缓存锁保证原子性。JVM 内部通过 `Atomic*` 类封装了这些操作,开发者无需直接使用 Unsafe。
|
||||
|
||||
```java
|
||||
// AtomicInteger.incrementAndGet 的核心逻辑(简化版)
|
||||
private volatile int value;
|
||||
public final int incrementAndGet() {
|
||||
int prev, next;
|
||||
do {
|
||||
prev = get(); // 读取当前值
|
||||
next = prev + 1; // 计算新值
|
||||
} while (!compareAndSet(prev, next)); // CAS 更新
|
||||
return next;
|
||||
}
|
||||
```
|
||||
|
||||
这段代码用 12 行实现了线程安全的自增——没有使用任何 synchronized。其关键保障来自 `while` 循环:如果 CAS 失败(说明中间有其他线程修改了 value),就重新读取、重新计算、重新尝试,直到成功。
|
||||
|
||||
> [!WARNING]
|
||||
> CAS 的三个问题:
|
||||
> 1. ABA 问题(见下文)
|
||||
> 2. 只能保证一个共享变量的原子操作,无法做多变量联合更新
|
||||
> 3. 长时间自旋会增加 CPU 开销——这就是为什么有界锁最终会升级为重量级锁
|
||||
|
||||
#### ABA 问题与 AtomicStampedReference
|
||||
|
||||
ABA 问题是 CAS 的经典缺陷:线程 T1 读取值为 A,另一个线程 T2 把值改为 B 再改回 A,T1 的 CAS 检查时发现值仍是 A,误以为没有被修改过。
|
||||
|
||||
解决思路:**给值加版本号**——每次变更版本号加 1。即使值回到 A,版本号也已不同。
|
||||
|
||||
```java
|
||||
// 伪代码示意 AtomicStampedReference 用法
|
||||
AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 0);
|
||||
int[] stampHolder = new int[1];
|
||||
String current = ref.get(stampHolder); // 返回 ["A", 0]
|
||||
int stamp = stampHolder[0];
|
||||
|
||||
// CAS 时需要同时匹配值和版本号
|
||||
ref.compareAndSet("A", "B", stamp, stamp + 1); // [A, 0] -> [B, 1]
|
||||
ref.compareAndSet("B", "A", stamp + 1, stamp + 2); // [B, 1] -> [A, 2]
|
||||
```
|
||||
|
||||
### AQS(AbstractQueuedSynchronizer)核心设计
|
||||
|
||||
AQS 是 `java.util.concurrent` 包的基石——ReentrantLock、CountDownLatch、CyclicBarrier、Semaphore 全部基于它构建。
|
||||
|
||||
#### CLH 队列模型
|
||||
|
||||
AQS 维护了一个 FIFO 的等待队列(CLH 变体),每个节点代表一个等待资源的线程:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
H["head"] --> N1["Node 1<br/>WAITING"]
|
||||
N1 --> N2["Node 2<br/>SIGNAL"]
|
||||
N2 --> N3["Node 3<br">CONDITION"]
|
||||
N3 --> NULL["null (tail)"]
|
||||
|
||||
style H stroke-dasharray: 5 5
|
||||
```
|
||||
|
||||
核心规则:
|
||||
- **头节点(head)** 是当前持有锁的节点,它的线程正在执行。
|
||||
- **尾节点(tail)** 是新入队节点的插入位置。
|
||||
- 前驱节点释放锁时会唤醒后继节点(通过 `park()` → `unpark()`)。
|
||||
- 节点状态包括:`CANCELLED`(已取消)、`SIGNAL`(后继需 unpark)、`CONDITION`(等待条件变量)、`PROPAGATE`(共享模式传播)。
|
||||
|
||||
#### state 状态机
|
||||
|
||||
AQS 的核心是一个 `volatile int state` 变量,表示同步状态:
|
||||
|
||||
| 同步器 | state 含义 |
|
||||
|--------|-----------|
|
||||
| ReentrantLock | 持有锁的次数(重入计数) |
|
||||
| CountDownLatch | 计数器初始值,递减到 0 触发 |
|
||||
| Semaphore | 可用许可证数量 |
|
||||
| ReentrantReadWriteLock | 高 16 位读计数,低 16 位写计数 |
|
||||
|
||||
#### tryAcquire / tryRelease 模板方法
|
||||
|
||||
子类只需实现这两个抽象方法,AQS 处理所有队列管理细节:
|
||||
|
||||
```java
|
||||
// ReentrantLock 的非公平锁 tryAcquire 核心逻辑(简化)
|
||||
protected final boolean tryAcquire(int acquires) {
|
||||
Thread current = Thread.currentThread();
|
||||
int c = getState();
|
||||
if (c == 0) {
|
||||
// 无竞争时直接用 CAS 拿锁
|
||||
if (compareAndSetState(0, acquires)) {
|
||||
setExclusiveOwnerThread(current);
|
||||
return true;
|
||||
}
|
||||
} else if (current == getExclusiveOwnerThread()) {
|
||||
// 重入:累加 state
|
||||
setState(c + acquires);
|
||||
return true;
|
||||
}
|
||||
return false; // 竞争发生,走 AQS 排队流程
|
||||
}
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> 面试常考点:AQS 的 `setState()` 用了 `volatile` 但非 CAS——因为重入场景下只增加不减少,且由独占线程自己操作,不存在多线程竞写问题。
|
||||
|
||||
### 三大常用工具类的 AQS 应用
|
||||
|
||||
#### CountDownLatch
|
||||
|
||||
单向计数器,减到 0 后释放所有等待线程。**不可重置**,适合"等齐事件"场景。
|
||||
|
||||
```java
|
||||
// 主线程等待 5 个初始化任务完成
|
||||
CountDownLatch latch = new CountDownLatch(5);
|
||||
for (int i = 0; i < 5; i++) {
|
||||
executor.submit(() -> {
|
||||
doInit();
|
||||
latch.countDown(); // 每完成一个减 1
|
||||
});
|
||||
}
|
||||
latch.await(); // 阻塞直到计数器归零
|
||||
```
|
||||
|
||||
#### CyclicBarrier
|
||||
|
||||
可循环使用的栅栏,到达指定数量后统一放行。**可以复用**,适合多阶段并行计算。
|
||||
|
||||
```java
|
||||
// 三组数据并行处理,完成后合并结果
|
||||
CyclicBarrier barrier = new CyclicBarrier(3, resultMerger::merge);
|
||||
for (DataSource ds : dataSources) {
|
||||
executor.submit(() -> {
|
||||
Result r = ds.process();
|
||||
barrier.await(); // 等待同伴
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### Semaphore
|
||||
|
||||
控制并发访问的资源信号量。常用于限流——限制同时运行的任务数。
|
||||
|
||||
```java
|
||||
// 限制同时执行 10 个 IO 请求
|
||||
Semaphore sem = new Semaphore(10);
|
||||
executor.submit(() -> {
|
||||
sem.acquire(); // 拿令牌,不足则阻塞
|
||||
try {
|
||||
ioRequest.send();
|
||||
} finally {
|
||||
sem.release(); // 归还令牌
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 实践场景
|
||||
|
||||
**面试高频对比题**:
|
||||
|
||||
| 维度 | synchronized | ReentrantLock |
|
||||
|------|-------------|---------------|
|
||||
| 实现层级 | JVM 内置关键字 | JDK API 层 |
|
||||
| 锁升级 | 偏向→轻量级→重量级 | 无(始终 AQS + CAS) |
|
||||
| 公平性 | 非公平 | 可选公平/非公平 |
|
||||
| 中断响应 | 不响应中断 | `lockInterruptibly()` 支持 |
|
||||
| 条件变量 | wait()/notify() | 多个 Condition |
|
||||
| 超时获取 | 不支持 | `tryLock(timeout)` |
|
||||
| 可重入 | 是 | 是 |
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[01.Java/concurrent/线程池参数与拒绝策略]]
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
tags: [java/lang, jvm-memory, heap, metaspace, oom, gc-roots]
|
||||
create time: 2026-08-08 18:00
|
||||
update time: 2026-08-08 18:00
|
||||
---
|
||||
|
||||
# JVM 内存模型
|
||||
|
||||
## 概述
|
||||
|
||||
JVM 内存模型定义了程序运行时的数据布局——对象住在哪、方法代码存在哪、线程的局部变量放在哪。理解这块是调试 OOM、排查 GC 异常、调优堆大小的前提。本文将从运行时数据区的划分讲起,覆盖对象创建的生命周期和常见 OOM 类型的触发条件。
|
||||
|
||||
## 运行时数据区
|
||||
|
||||
JVM 将内存划分为多个逻辑区域,各自有明确的生命周期和用途。可以按"是否线程私有"分成两大阵营。
|
||||
|
||||
### 线程共享区域
|
||||
|
||||
**堆(Heap)**:所有线程共享,是 JVM 中最大的一块内存,存放所有对象实例和数组。堆又被进一步细分为新生代(Young Gen)和老年代(Old Gen),新生代再分为 Eden 区和两个 Survivor 区(From / To)。这是垃圾收集的主要舞台。
|
||||
|
||||
> [!NOTE]
|
||||
> 堆大小由 `-Xms`(初始堆)和 `-Xmx`(最大堆)控制,通常建议两者设为同一值,避免运行时动态扩缩带来的性能开销。
|
||||
|
||||
**方法区(Method Area)**:存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存。JDK 7 时期方法区还在永久代(PermGen)中实现;JDK 8 之后彻底移除了永久代,用**元空间(Metaspace)**代替,元空间使用本地内存(Direct Memory),通过 `-XX:MaxMetaspaceSize` 限制上限。
|
||||
|
||||
> [!NOTE]
|
||||
> 为什么要把元空间搬到本地内存?永久代大小固定且容易撑爆(尤其是动态代理场景下 Class 数量爆炸时),换成元空间后理论上只受限于本机内存总量,大幅降低了 OOM 的概率。
|
||||
|
||||
### Java 虚拟机栈(JVM Stack)
|
||||
|
||||
每个线程创建时都会创建一个虚拟机栈,描述 Java 方法的调用过程。每个方法被执行时都会创建一个栈帧,存储在栈中,包含局部变量表、操作数栈、动态链接、方法出口等信息。**栈帧随着方法调用进入而压栈,返回时弹栈**。栈溢出会抛出 `StackOverflowError`。
|
||||
|
||||
**本地方法栈(Native Method Stack)**:与虚拟机栈功能类似,但服务的是 Native 方法(通常是用 C/C++ 编写的 JNI 方法)。HotSpot 直接将本地方法栈和虚拟机栈合二为一。
|
||||
|
||||
**程序计数器(Program Counter Register)**:一块很小的内存空间,记录当前线程执行的字节码行号。如果执行的是 Native 方法,计数器值为空。它是唯一不会发生 OutOfMemoryError 的区域。
|
||||
|
||||
## GC Roots 判定标准
|
||||
|
||||
GC Roots Tracing 算法通过可达性分析判断对象是否存活——从一组根节点出发,沿着引用链搜索,被搜到的标记为存活,搜不到的标记为死亡。以下是 JVM 规范中定义的 GC Roots 来源:
|
||||
|
||||
| 来源 | 说明 |
|
||||
|------|------|
|
||||
| 虚拟机栈中引用的对象 | 各线程栈帧中局部变量表里引用的对象实例 |
|
||||
| 方法区类静态属性引用的对象 | `static` 字段所指向的对象 |
|
||||
| 方法区常量引用的对象 | `final static` 常量关联的引用 |
|
||||
| 本地方法栈 JNI 引用的对象 | Native 方法通过 JNI 传入的句柄 |
|
||||
|
||||
> [!NOTE]
|
||||
> 一个对象的死亡通常需要两次标记:第一次确认没有 GC Roots 路径可达;第二次才是真正回收。如果这个对象重写了 `finalize()` 方法且尚未执行过,虚拟机会在第二次标记时帮它触发一次——不过 finalize() 在 Java 9+ 已被标记为废弃。
|
||||
|
||||
> [!TIP]
|
||||
> 面试常考点:这 5 个区域中,只有堆和方法区可能 OOM,栈和计数器不会。程序计数器的设计决定了它只需要极小的空间——因为它是线程私有的,切换时只需恢复计数值即可。
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph 线程共享["线程共享区域"]
|
||||
H["堆 Heap\n对象实例/数组"]
|
||||
MA["方法区/元空间\n类信息/常量/静态变量"]
|
||||
end
|
||||
subgraph 线程私有["线程私有区域"]
|
||||
JS["Java 虚拟机栈\n栈帧/局部变量/操作数栈"]
|
||||
NMS["本地方法栈\nNative 方法"]
|
||||
PCR["程序计数器\n字节码行号"]
|
||||
end
|
||||
JS -->|访问| H
|
||||
MA -->|引用| H
|
||||
```
|
||||
|
||||
## 对象创建过程
|
||||
|
||||
在堆中分配一个对象并非简单的 `malloc`,而是经历了完整的一系列检查与初始化步骤。
|
||||
|
||||
**第一步:类加载检查**。当 JVM 遇到 `new` 指令时,首先检查参数能否在常量池中定位到这个类的符号引用,并检查该符号引用代表的类是否已被加载、解析和初始化。如果未加载,则执行对应的类加载流程。
|
||||
|
||||
**第二步:分配内存**。类检查通过后,就在堆中划出一块确定大小的空间。内存分配有两种主流方式:
|
||||
- **指针碰撞(Bump the Pointer)**:堆内存规整时使用,空闲空间和已占用空间各占一端,分配时把指针向空闲方向移动对象大小的距离。这种方式效率高,前提是堆必须规整。
|
||||
- **空闲列表(Free List)**:堆非规整时(如采用标记-清除算法的收集器),维护一个列表记录哪些内存块可用,分配时从中选择一块足够大的空间。
|
||||
|
||||
> [!WARNING]
|
||||
> 热点问题的背后往往是一个取舍:G1 和 ZGC 等现代收集器为了做到堆规整,选择在回收阶段做整理,代价是 STW 时间或额外的屏障开销。
|
||||
|
||||
**第三步:初始化零值**。分配到的内存必须清零(置为 0 值),这一步确保了对象的字段在 Java 代码中不用一开始就赋值也能有默认值(int 为 0、reference 为 null 等)。
|
||||
|
||||
**第四步:设置对象头**。HotSpot 虚拟机会在对象头上设置一些自身运行时的数据,包括:
|
||||
- HashCode(延迟计算)
|
||||
- 分代年龄(达到阈值后晋升老年代)
|
||||
- 锁状态标志位
|
||||
- 指向锁对象监控器的指针
|
||||
- 偏向锁的 ThreadID
|
||||
- 指向栈中 VMEntryFrame 的指针
|
||||
|
||||
**第五步:执行 `init` 方法**。按照程序员的意愿对对象进行初始化,设置好各个字段的真正值。
|
||||
|
||||
整个流程可以用下图概括:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["new 指令"] --> B["类加载检查\n常量池定位+加载"]
|
||||
B --> C["分配内存\n指针碰撞 / 空闲列表"]
|
||||
C --> D["零值初始化\nmemset 到 0"]
|
||||
D --> E["设置对象头\nHash/分代年龄/锁标志"]
|
||||
E --> F["执行 init\n字段赋初值"]
|
||||
F --> G["对象可被访问"]
|
||||
```
|
||||
|
||||
## OOM 常见类型
|
||||
|
||||
`OutOfMemoryError` 是一个 Error 而非 Exception,表示 JVM 已经无法继续分配内存。它有几个不同的子类,各自对应不同的内存区域和问题场景。
|
||||
|
||||
### `java.lang.OutOfMemoryError: Java heap space`
|
||||
|
||||
**最常见**的 OOM。通常是对象存活数量过多、生命周期过长,或者存在内存泄漏(比如集合类持续 add 却不 remove)。也可能仅仅是因为堆设置得太小。
|
||||
|
||||
排查思路:用 MAT 或 JProfiler 导出堆快照(heap dump),分析 Dominator Tree 找到持有大量引用的大对象。
|
||||
|
||||
### `java.lang.OutOfMemoryError: Metaspace`
|
||||
|
||||
JDK 8 之后出现。元空间耗尽说明加载的 Class 太多,常见于:
|
||||
- 大量动态生成了 Class(如 MyBatis 扫描了超大包路径、频繁使用 CGLIB 动态代理)
|
||||
- 使用了过多的 OSGi 模块或者热部署框架(如 Spring Boot DevTools)
|
||||
|
||||
调优参数:`-XX:MaxMetaspaceSize` 设大一点,或者从根本上减少 Class 数量。
|
||||
|
||||
### `java.lang.StackOverflowError`
|
||||
|
||||
严格来说这不是一个 OutOfMemoryError,而是栈深度超限。通常由无限递归或递归过深触发——每个方法调用会压入一个栈帧,超出 `-Xss` 设定的单线程栈大小时抛出此错误。排查思路:审查递归逻辑是否有正确的退出条件,或适当增大 `-Xss`。
|
||||
|
||||
### `java.lang.OutOfMemoryError: unable to create new native thread`
|
||||
|
||||
JVM 尝试创建新的 OS 线程失败。通常不是 JVM 内存不够,而是操作系统级别的线程数限制触顶(Linux 的 `ulimit -u`、Windows 的用户会话极限)。每个 Java 线程最终映射为一个原生线程,消耗约 1MB 的栈内存(由 `-Xss` 决定)。
|
||||
|
||||
解决方案:减少并发线程数、增大 `-Xss`(但会增加单个线程的内存消耗)、或者提升系统的线程数上限。
|
||||
|
||||
### `java.lang.OutOfMemoryError: GC overhead limit exceeded`
|
||||
|
||||
当 GC 花费超过 98% 的时间却只回收了不到 2% 的堆内存时触发。本质上是一种自我保护机制——JVM 发现自己在做无效回收,干脆抛错而不是无限循环。
|
||||
|
||||
通常意味着堆仍然有少量空间,但这些空间被一群几乎不会死掉的对象占据着。加大堆容量或修复内存泄漏是根本办法。也可以通过 `-XX:-UseGCOverheadLimit` 关闭这个检查,但这只是掩耳盗铃。
|
||||
|
||||
### `java.lang.OutOfMemoryError: Direct buffer memory`
|
||||
|
||||
NIO 的 DirectByteBuffer 走的是堆外内存(通过 `Unsafe.allocateMemory` 分配),不受 `-Xmx` 控制。常见的触发场景是使用 Netty、gRPC 等大流量网络框架时直接 Buffer 分配过快。
|
||||
|
||||
调参:`-XX:MaxDirectMemorySize` 控制上限。
|
||||
|
||||
## 实践场景
|
||||
|
||||
### 秋招面试高频问题
|
||||
|
||||
| 问题 | 回答要点 |
|
||||
|------|---------|
|
||||
| JDK 7 和 JDK 8 在方法区上的区别? | 7 用永久代、8 用元空间;永久代在堆内、元空间在本机内存 |
|
||||
| 如何判断一段内存属于哪个区域? | 对象实例在堆、类信息在元空间、局部变量在栈帧、计数器只存一行号 |
|
||||
| 给一个 OOM 的排查流程 | GC Log → Dump 堆快照 → MAT 打开 → Dominator Tree → 找最大对象链 |
|
||||
|
||||
### 实战技巧
|
||||
|
||||
线上出现 OOM 时,可以通过 JVM 启动参数自动 dump:
|
||||
|
||||
```java
|
||||
// JVM 参数(不需要写在 Java 代码里,这里是示意配置)
|
||||
// -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dumps/
|
||||
// -XX:OnError="jstack %p > /data/logs/jstack.txt"
|
||||
```
|
||||
|
||||
配合 `jmap -dump:format=b,file=heap.bin <pid>` 可以手动导出堆快照,然后用 Eclipse MAT 分析对象留存情况。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[垃圾回收算法与收集器]]
|
||||
@@ -0,0 +1,201 @@
|
||||
---
|
||||
tags: [java/lang, gc-algorithm, cms, g1, zgc, young-gen]
|
||||
create time: 2026-08-08 18:00
|
||||
update time: 2026-08-08 18:00
|
||||
---
|
||||
|
||||
# 垃圾回收算法与收集器
|
||||
|
||||
## 概述
|
||||
|
||||
垃圾回收是 JVM 自动管理内存的核心机制。理解 GC 算法的优缺点和不同收集器的设计哲学,能帮助你在生产环境中做出正确的选型决策——没有最好的收集器,只有最适合业务场景的收集器。
|
||||
|
||||
## 核心原理
|
||||
|
||||
### 标记-清除 / 标记-复制 / 标记-整理
|
||||
|
||||
三种经典 GC 算法各有侧重,对应不同的内存碎片和空间利用率权衡。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph 标记清除["标记-清除算法"]
|
||||
direction TB
|
||||
MS1["1. 标记存活对象"]
|
||||
MS2["2. 清除未标记区域"]
|
||||
end
|
||||
|
||||
subgraph 标记复制["标记-复制算法"]
|
||||
direction TB
|
||||
MC1["1. 标记存活对象"]
|
||||
MC2["2. 复制到半区"]
|
||||
MC3["3. 清理整块原区"]
|
||||
end
|
||||
|
||||
subgraph 标记整理["标记-整理算法"]
|
||||
direction TB
|
||||
MI1["1. 标记存活对象"]
|
||||
MI2["2. 存活对象向一端移动"]
|
||||
MI3["3. 清理边界外内存"]
|
||||
end
|
||||
```
|
||||
|
||||
#### 标记-清除(Mark-Sweep)
|
||||
|
||||
最直接的方案:先标记所有存活对象,然后统一清除未被标记的对象。
|
||||
|
||||
| 优点 | 缺点 |
|
||||
|------|------|
|
||||
| 实现简单,不需要额外空间 | 产生大量内存碎片,大对象分配可能提前触发 GC |
|
||||
| 适合对象存活率高的场景 | 两次扫描(标记 + 清除),STW 时间较长 |
|
||||
|
||||
#### 标记-复制(Mark-Copy)
|
||||
|
||||
将可用内存分为大小相等的两块,每次只用其中一块。GC 时把存活对象复制到另一块,然后清空已用区域。新生代 Eden + Survivor 就是基于此思想。
|
||||
|
||||
| 优点 | 缺点 |
|
||||
|------|------|
|
||||
| 不会产生内存碎片 | 可用内存减半,浪费严重 |
|
||||
| 复制即整理,天然紧凑 | 对象在 Survivor 间来回复制,增加 CPU 开销 |
|
||||
|
||||
> [!TIP] HotSpot 通过 Eden:S0:S1 = 8:1:1 的比例设计,让绝大多数新对象直接分配到 Eden,只有少量"长寿"对象才进入 Survivor,大大缓解了空间浪费问题。
|
||||
|
||||
#### 标记-整理(Mark-Compact)
|
||||
|
||||
标记阶段同标记-清除,但后续步骤是让存活对象向内存一端移动,然后清理掉边界外的内存。老年代主要采用此算法。
|
||||
|
||||
| 优点 | 缺点 |
|
||||
|------|------|
|
||||
| 无内存碎片 | 移动对象需要更新所有引用指针,代价高 |
|
||||
| STW 时间短于标记-清除 | 涉及对象拷贝,CPU 占用较高 |
|
||||
|
||||
### 分代理论的依据
|
||||
|
||||
现代 JVM 采用分代收集策略:**收集器选择不同的算法作用于不同的代**。这基于一个经验结论——**对象的生命周期呈现明显的分层分布**:
|
||||
|
||||
- **朝生夕死**:绝大部分对象在 Eden 区出生后即死亡,存活率极低。
|
||||
- **中途夭折**:部分对象经过几次 Minor GC 后仍然存活,但很快会死亡。
|
||||
- **长生不老**:少数对象经历多次 Minor GC 后依然存活,最终进入老年代。
|
||||
|
||||
基于此,JVM 将堆划分为新生代和老年代,分别使用标记-复制和标记-整理算法,达到整体最优。
|
||||
|
||||
### CMS 收集器(Concurrent Mark Sweep)
|
||||
|
||||
CMS 的目标是最小化 STW 时间,适用于对响应时间敏感的业务场景。它是 JDK 7 时代默认的低延迟收集器。
|
||||
|
||||
**四个阶段**:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> 初始标记: 触发 CMSCollectionBeginning
|
||||
初始标记 --> 并发标记: STW 极短
|
||||
并发标记 --> 预标记: 用户线程同时运行
|
||||
预标记 --> 并发清除: STW 较短
|
||||
并发清除 --> 并发重置
|
||||
并发重置 --> [*]: 回收完成
|
||||
```
|
||||
|
||||
| 阶段 | 说明 | STW |
|
||||
|------|------|-----|
|
||||
| 初始标记(Initial Mark) | 标记 Direct GC Roots,需要停顿 | 短 |
|
||||
| 并发标记(Concurrent Mark) | 从 GC Roots 开始遍历标记树 | 无 |
|
||||
| 预标记(Re-Mark) | 修正并发标记期间因用户程序操作导致的变化 | 中 |
|
||||
| 并发清除(Concurrent Sweep) | 清除标记信息为空的区间 | 无 |
|
||||
|
||||
> [!WARNING] CMS 有三个显著缺陷:
|
||||
> 1. **浮动垃圾**:并发清扫阶段又有新对象产生,这些"浮动垃圾"只能等下一次 GC 处理。
|
||||
> 2. **内存碎片**:基于标记-清除算法,容易产生碎片,可能导致大对象无法分配而提前触发 Full GC。
|
||||
> 3. **CPU 资源敏感**:并发阶段和用户代码共享 CPU,负载过高时会导致平均响应时间变长。
|
||||
|
||||
CMS 在 JDK 9 中被标记为废弃,JDK 14 中被彻底移除。
|
||||
|
||||
### G1 收集器(Garbage-First)
|
||||
|
||||
G1 是 JDK 9 默认收集器,面向多核处理器和大容量堆(通常 ≥ 6GB)。它将堆划分为多个大小相等的 Region,不再物理分隔新生代和老年代。
|
||||
|
||||
**关键概念**:
|
||||
|
||||
| 概念 | 说明 |
|
||||
|------|------|
|
||||
| Region | G1 将堆划分为最多 2048 个 Region(每个大小 1~32MB),每个 Region 可以扮演 Eden、Survivor、Old 或 Humongous 角色 |
|
||||
| Humongous Region | 超过半个 Region 大小的超大对象,直接分配到 Humongous Region,避免碎片问题 |
|
||||
| RSet(Remembered Set) | 记录跨 Region 引用,使 G1 能精确知道哪些 Region 引用了其他 Region 的对象 |
|
||||
| Mixed GC | G1 特有的回收模式,一次性回收多个 Region(包括 Young + Old) |
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["分配对象到 Eden Region"] --> B{"是否够分配?"}
|
||||
B -->|否| C["Minor GC<br/>回收年轻代 Regions"]
|
||||
C --> D{是否仍有空间?}
|
||||
D -->|是| E["分配成功"]
|
||||
D -->|否| F["Full GC"]
|
||||
B -->|是| E
|
||||
|
||||
G["Major / Mixed GC"] --> H["根据Region ROI排序"]
|
||||
H --> I["优先回收价值最大的Region"]
|
||||
```
|
||||
|
||||
G1 的优势在于:
|
||||
- **可预测的暂停时间**:通过 `-XX:MaxGCPauseMillis` 设定目标,G1 内部会自动调整各区域回收策略。
|
||||
- **无碎片**:Mixed GC 后会进行整理。
|
||||
- **并行与并发**:充分利用多核能力。
|
||||
|
||||
### ZGC(Z Garbage Collector)
|
||||
|
||||
ZGC 从 JDK 11 起实验性引入,JDK 15 成为生产级收集器。它的核心创新在于将原本沉重的写屏障移到加载时机,借助两个黑科技:**染色指针**和**加载屏障**。
|
||||
|
||||
**染色指针(Colored Pointers)**:在指针的高位 bit 上编码颜色信息(指向、已访问、重定位、预留),这样在读取对象引用时就能快速判断状态。
|
||||
|
||||
**加载屏障(Load Barrier)**:当线程读取一个引用时,如果发现有重定位标记,就执行重定位操作。这一步发生在读引用之前而非写引用之后,因此无需在所有写入口处插入屏障。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as 线程
|
||||
participant M as 内存对象
|
||||
participant LB as 加载屏障
|
||||
participant RC as 重定位
|
||||
|
||||
T->>+LB: 读取引用地址
|
||||
alt 有重定位标记
|
||||
LB-->>RC: 发现需要重定位
|
||||
RC->>M: 更新对象位置
|
||||
RC-->>LB: 清除重定位标记
|
||||
end
|
||||
LB-->>T: 返回新地址
|
||||
LB->>-T: 继续执行
|
||||
```
|
||||
|
||||
ZGC 的特点:
|
||||
- **暂停时间不超过 10ms**,无论堆大小(JDK 11 支持最大 320GB,JDK 15+ 支持 TB 级)。
|
||||
- **与应用程序并发执行**,几乎不停顿用户线程。
|
||||
- **不支持优先级队列**,不适合对延迟极度敏感的场景(如游戏服务器)。
|
||||
|
||||
> [!NOTE] ZGC 的染色指针依赖于平台指针压缩。x86_64 有足够高位可用,但 ARM 架构可能需要不同实现。目前仅支持 x86_64、AArch64 和 SPARC64。
|
||||
|
||||
### 收集器对比选型表
|
||||
|
||||
| 维度 | CMS | G1 | ZGC | Shenandoah |
|
||||
|------|-----|----|-----|-----------|
|
||||
| 首次引入 | JDK 1.4.1 | JDK 7u4 (JDK 9默认) | JDK 11 (JDK 15正式) | JDK 12 |
|
||||
| 最大堆 | ~16GB | ~64GB+ | ~320GB (JDK 11) | ~320GB |
|
||||
| 最大暂停 | 100-500ms | 目标可配 | < 10ms | < 10ms |
|
||||
| 吞吐量 | 中等 | 较高 | 略低 | 略低 |
|
||||
| 内存开销 | 低 | 中(RSet) | 低(染色指针) | 中高 |
|
||||
| 堆碎片 | 有 | 无 | 无 | 无 |
|
||||
| 适用场景 | 遗留系统 | 通用后端服务 | 超大堆、超低延迟 | 同 ZGC |
|
||||
| 推荐度 | 不推荐 | **首选** | 需求匹配时首选 | 备选 |
|
||||
|
||||
## 实践场景
|
||||
|
||||
**选型决策树**:
|
||||
|
||||
1. 堆 < 4GB → Parallel GC(吞吐优先)或 CMS(如果你还在 JDK 8 且必须低延迟)
|
||||
2. 4GB ≤ 堆 ≤ 32GB → G1(大多数生产环境的默认选择)
|
||||
3. 堆 > 32GB 且要求暂停 < 10ms → ZGC
|
||||
4. 对延迟极其敏感且不在意吞吐量 → ZGC 或 Shenandoah
|
||||
5. 离线批处理、追求最高吞吐 → Parallel Old
|
||||
|
||||
> [!TIP] 面试常考点:为什么不建议在生产环境使用 Parallel GC?因为它的所有 GC 动作都在一个线程内串行完成,STW 时间与堆大小呈正相关,不适合交互式应用。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[JVM 内存模型]]
|
||||
Reference in New Issue
Block a user