一、引言
并发编程是后端面试的必考题,但很多开发者只是死记硬背答案,遇到深入追问就露馅了。今天我要拆解三个高频并发问题,不仅告诉你答案,更要讲清楚背后的原理。
核心观点:理解并发问题的关键在于从底层原理出发——线程池参数要结合业务场景,锁升级要理解对象头结构,伪共享要明白 CPU 缓存机制。
二、线程池参数:核心线程数到底怎么算?
2.1 错误认知:CPU 核数 + 1
很多人背的答案是"CPU 核心数 + 1",但这只适用于特定场景。线程池参数的设置需要结合任务类型来决定。
2.2 任务类型分类
| 任务类型 | 特点 | 典型场景 |
|---|---|---|
| CPU 密集型 | 任务主要消耗 CPU 资源,几乎不等待 | 数据计算、排序、加密解密 |
| IO 密集型 | 任务大部分时间在等待 IO 操作 | 数据库查询、网络请求、文件读写 |
| 混合型 | CPU 计算和 IO 操作都有 | Web 服务处理、API 网关 |
2.3 计算公式
CPU 密集型任务:
核心线程数 = CPU 核心数 + 1
理由:当一个线程被暂停(如页缺失),额外的一个线程可以利用这段时间继续执行,最大化 CPU 利用率。
IO 密集型任务:
核心线程数 = CPU 核心数 × (1 + 等待时间 / 计算时间)
简化公式:
核心线程数 = CPU 核心数 × 2
理由:IO 密集型任务大部分时间在等待,多个线程可以交替执行,充分利用 CPU。
2.4 实战计算示例
假设 CPU 核心数 = 8,一个 HTTP 请求的处理时间为 100ms,其中计算时间 10ms,等待时间 90ms:
核心线程数 = 8 × (1 + 90/10) = 8 × 10 = 80
2.5 完整线程池配置
public class ThreadPoolConfig {
public static ExecutorService createThreadPool() {
int cpuCores = Runtime.getRuntime().availableProcessors();
int corePoolSize = cpuCores * 2;
int maxPoolSize = cpuCores * 4;
long keepAliveTime = 60L;
TimeUnit unit = TimeUnit.SECONDS;
LinkedBlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(1000);
ThreadFactory threadFactory = new ThreadFactory() {
private final AtomicInteger counter = new AtomicInteger(0);
@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread(r, "business-thread-" + counter.incrementAndGet());
thread.setDaemon(false);
return thread;
}
};
RejectedExecutionHandler handler = new ThreadPoolExecutor.CallerRunsPolicy();
return new ThreadPoolExecutor(
corePoolSize,
maxPoolSize,
keepAliveTime,
unit,
queue,
threadFactory,
handler
);
}
}
2.6 参数调优建议
| 参数 | 调优原则 |
|---|---|
| corePoolSize | 根据任务类型计算,IO 密集型可设为 CPU 核数的 2~4 倍 |
| maxPoolSize | 通常为 corePoolSize 的 2 倍,防止线程过多导致上下文切换 |
| queueCapacity | 避免过大导致内存溢出,建议设为有界队列 |
| keepAliveTime | 非核心线程的空闲存活时间,根据业务特性调整 |
| handler | 生产环境建议使用 CallerRunsPolicy 而非默认的 AbortPolicy |
三、锁升级:synchronized 的三级锁
3.1 锁的类型和性能对比
| 锁类型 | 状态标志 | 性能特点 | 适用场景 |
|---|---|---|---|
| 无锁 | 01 | 无锁竞争,性能最好 | 单线程访问 |
| 偏向锁 | 01 (biased) | 无 CAS 操作,几乎零开销 | 同一线程重复获取 |
| 轻量级锁 | 00 | CAS 自旋,无系统调用 | 短时间、低竞争 |
| 重量级锁 | 10 | 操作系统互斥锁,有系统调用开销 | 长时间、高竞争 |
3.2 对象头 Mark Word 结构
在 64 位 JVM 中,对象头的 Mark Word 占 8 字节(64 位),结构如下:
┌─────────────────────────────────────────────────────────────────┐
│ Mark Word (64 bits) │
├─────────┬─────────┬─────────┬───────────────────────────────────┤
│ unused │ hash │ unused │ lock │
│ (25bit) │ (31bit) │ (1bit) │ (2bit) │
└─────────┴─────────┴─────────┴───────────────────────────────────┘
锁标志位 (lock) 含义:
- 01:无锁或偏向锁(取决于 biased bit)
- 00:轻量级锁
- 10:重量级锁
- 11:GC 标记
偏向锁模式下的额外字段:
┌─────────────────────────────────────────────────────────────────┐
│ Mark Word (biased) │
├─────────────────────────────────────────────────────────────────┤
│ Thread ID (54bit) │
├─────────────────────────────────────────────────────────────────┤
│ epoch (2bit) │ biased (1bit) │ lock (2bit = 01) │
└─────────────────────────────────────────────────────────────────┘
3.3 锁升级路径
阶段 1:无锁 → 偏向锁
public class LockUpgradeDemo {
private Object lock = new Object();
public void method() {
// 第一次进入,Mark Word 从无锁升级为偏向锁
// 将当前线程 ID 写入 Mark Word
synchronized (lock) {
// 偏向锁状态:lock=01, biased=1
// Thread ID = 当前线程 ID
}
}
}
阶段 2:偏向锁 → 轻量级锁
当另一个线程尝试获取同一个锁时:
public class LockUpgradeDemo {
private Object lock = new Object();
public void method() {
synchronized (lock) {
// 线程 A 获取偏向锁后
// 线程 B 尝试获取锁,发现偏向锁已被占用
// 偏向锁撤销,升级为轻量级锁
// 线程 A 和 B 开始 CAS 自旋
}
}
}
阶段 3:轻量级锁 → 重量级锁
当自旋超过一定次数(默认 10 次)或线程数超过 CPU 核心数的一半时:
public class LockUpgradeDemo {
private Object lock = new Object();
public void method() {
synchronized (lock) {
// CAS 自旋失败,升级为重量级锁
// 线程阻塞,进入等待队列
// 触发操作系统的 mutex lock
}
}
}
3.4 锁升级流程图
┌──────────────────────┐
│ 线程访问对象 │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ 无锁状态 (01) │
└──────────┬───────────┘
│ 第一次获取
┌──────────▼───────────┐
│ 偏向锁 (01,biased) │
│ 写入 Thread ID │
└──────────┬───────────┘
│ 其他线程竞争
┌──────────▼───────────┐
│ 轻量级锁 (00) │
│ CAS 自旋 │
└──────────┬───────────┘
│ 自旋失败/超时
┌──────────▼───────────┐
│ 重量级锁 (10) │
│ OS mutex lock │
└──────────────────────┘
3.5 关键知识点
偏向锁撤销条件:
- 其他线程尝试获取锁
- 调用
hashCode()方法(因为 hashCode 会写入 Mark Word) - 对象被 GC
轻量级锁自旋条件:
- 线程数 ≤ CPU 核心数的一半
- 自旋次数 ≤ 10 次(可通过
-XX:PreBlockSpin调整)
锁消除:JIT 编译时如果检测到某个锁对象没有逃逸出方法,会自动消除锁。
四、伪共享:CPU 缓存行的陷阱
4.1 什么是伪共享
CPU 的缓存是以缓存行(Cache Line)为单位的,通常是 64 字节。当多个线程访问不同的数据但这些数据恰好位于同一个缓存行时,就会发生伪共享。
┌────────────────────────────────────────────────────────────────┐
│ CPU Cache Line (64 bytes) │
├────────────────────────────────────────────────────────────────┤
│ Thread A: counter1 (8 bytes) | Thread B: counter2 (8 bytes) │
│ ... (其他数据) ... │
└────────────────────────────────────────────────────────────────┘
问题:Thread A 修改 counter1,导致整个缓存行失效
Thread B 访问 counter2 时,需要重新从主存加载
即使 counter1 和 counter2 是独立的数据!
4.2 伪共享的性能影响
public class FalseSharingDemo {
public static class Counter {
public volatile long value = 0L;
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (long i = 0; i < 100000000L; i++) {
counter.value++;
}
});
Thread t2 = new Thread(() -> {
for (long i = 0; i < 100000000L; i++) {
counter.value++;
}
});
long start = System.currentTimeMillis();
t1.start();
t2.start();
t1.join();
t2.join();
long end = System.currentTimeMillis();
System.out.println("耗时: " + (end - start) + "ms");
System.out.println("结果: " + counter.value);
}
}
现象:两个线程操作同一个 volatile 变量,由于缓存一致性协议(MESI),每次写操作都会导致缓存行失效,性能极差。
4.3 使用 @Contended 解决伪共享
JDK 8:
import sun.misc.Contended;
public class FalseSharingFixed {
@Contended
public volatile long counter1 = 0L;
@Contended
public volatile long counter2 = 0L;
}
JDK 9+:
import jdk.internal.vm.annotation.Contended;
public class FalseSharingFixed {
@Contended
public volatile long counter1 = 0L;
@Contended
public volatile long counter2 = 0L;
}
注意事项:
- JDK 8:需要添加
-XX:-RestrictContendedJVM 参数 - JDK 9+:需要添加
--add-opens java.base/jdk.internal.vm.annotation=ALL-UNNAMED - 包路径变化:JDK 9+ 从
sun.misc.Contended移动到jdk.internal.vm.annotation.Contended
4.4 手动填充缓存行
如果不想依赖内部 API,可以手动添加填充字段:
public class CacheLinePaddedCounter {
// 填充前面的缓存行(7 个 long = 56 字节)
private long p1, p2, p3, p4, p5, p6, p7;
public volatile long value = 0L;
// 填充后面的缓存行
private long p8, p9, p10, p11, p12, p13, p14;
public void increment() {
value++;
}
public long get() {
return value;
}
}
原理:每个 long 占 8 字节,7 个 long = 56 字节,加上 value 的 8 字节正好占满一个缓存行。
4.5 使用继承方式填充
public class BasePadding {
protected long p1, p2, p3, p4, p5, p6, p7;
}
public class Counter extends BasePadding {
public volatile long value = 0L;
public void increment() {
value++;
}
}
注意:JIT 编译器可能会优化掉未使用的字段,需要确保这些字段被引用。
4.6 性能对比测试
public class FalseSharingBenchmark {
public static void main(String[] args) throws InterruptedException {
int iterations = 100000000;
// 伪共享版本:两个计数器在同一个对象中,可能共享缓存行
FalseSharingCounters shared = new FalseSharingCounters();
long sharedTime = runFalseSharingBenchmark(shared, iterations);
// 缓存行填充版本:两个计数器各自独立,不会共享缓存行
CacheLinePaddedCounters padded = new CacheLinePaddedCounters();
long paddedTime = runPaddedBenchmark(padded, iterations);
System.out.println("伪共享版本耗时: " + sharedTime + "ms");
System.out.println("缓存行填充版本耗时: " + paddedTime + "ms");
System.out.println("性能提升: " + String.format("%.2f", sharedTime * 1.0 / paddedTime) + "倍");
}
// 伪共享测试:线程1操作counter1,线程2操作counter2,两者在同一对象中
private static long runFalseSharingBenchmark(FalseSharingCounters counters, int iterations)
throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < iterations; i++) {
counters.counter1++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < iterations; i++) {
counters.counter2++;
}
});
long start = System.currentTimeMillis();
t1.start();
t2.start();
t1.join();
t2.join();
return System.currentTimeMillis() - start;
}
// 缓存行填充测试:两个计数器各自独立
private static long runPaddedBenchmark(CacheLinePaddedCounters counters, int iterations)
throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < iterations; i++) {
counters.counter1.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < iterations; i++) {
counters.counter2.increment();
}
});
long start = System.currentTimeMillis();
t1.start();
t2.start();
t1.join();
t2.join();
return System.currentTimeMillis() - start;
}
// 伪共享计数器:两个volatile变量在同一对象中,可能共享缓存行
static class FalseSharingCounters {
volatile long counter1 = 0;
volatile long counter2 = 0;
}
// 缓存行填充计数器:每个计数器都有独立的缓存行
static class CacheLinePaddedCounters {
CacheLinePaddedCounter counter1 = new CacheLinePaddedCounter();
CacheLinePaddedCounter counter2 = new CacheLinePaddedCounter();
}
// 单个缓存行填充的计数器
static class CacheLinePaddedCounter {
long p1, p2, p3, p4, p5, p6, p7;
volatile long value = 0;
long p8, p9, p10, p11, p12, p13, p14;
void increment() {
value++;
}
}
}
关键区别:
- 伪共享版本:线程1和线程2分别操作同一个对象的不同字段(
counter1和counter2),由于它们在内存中相邻,可能共享同一个缓存行 - 缓存行填充版本:每个计数器都是独立对象,且通过填充字段确保每个
value都独占一个缓存行
预期结果:缓存行填充版本的性能通常是伪共享版本的 3~5 倍。
五、总结
5.1 线程池参数
| 任务类型 | 核心线程数公式 | 关键要点 |
|---|---|---|
| CPU 密集型 | N + 1 | 最大化 CPU 利用率 |
| IO 密集型 | N × (1 + WT/ST) | 充分利用等待时间 |
| 混合型 | 根据实际比例调整 | 建议压测确定 |
5.2 锁升级
核心路径:无锁 → 偏向锁 → 轻量级锁 → 重量级锁
关键要点:
- 偏向锁:消除同一线程的锁获取开销
- 轻量级锁:CAS 自旋,避免系统调用
- 重量级锁:操作系统互斥锁,线程阻塞
5.3 伪共享
核心问题:多个线程访问同一缓存行的不同数据,导致缓存行频繁失效。
解决方案:
@Contended注解(需 JVM 参数)- 手动填充缓存行(7 个 long 字段)
- 使用继承方式填充
💡 互动话题:你在面试中遇到过哪些并发相关的问题?你是怎么回答的?欢迎在评论区分享你的经历!
