多线程并发篇
1.1 synchronized的实现原理?有哪些优化?
✅ 正确回答思路:
synchronized是Java最基本的同步机制,我从使用方式、底层原理、锁优化三个方面详细说明。
一、synchronized的三种使用方式
java
// 1. 修饰实例方法(锁的是this)
public synchronized void method1() {
// 临界区
}
// 2. 修饰静态方法(锁的是Class对象)
public static synchronized void method2() {
// 临界区
}
// 3. 修饰代码块(锁的是指定对象)
public void method3() {
synchronized (this) { // 或者其他对象
// 临界区
}
}
二、synchronized的底层实现(JVM层面)
1. 同步代码块:monitorenter和monitorexit指令
java
public void method() {
synchronized (this) {
System.out.println("hello");
}
}
// 编译后的字节码:
0: aload_0
1: dup
2: astore_1
3: monitorenter ← 进入同步块
4: getstatic
7: ldc
9: invokevirtual
12: aload_1
13: monitorexit ← 正常退出
14: goto 22
17: astore_2
18: aload_1
19: monitorexit ← 异常退出
20: aload_2
21: athrow
22: return
2. 同步方法:ACC_SYNCHRONIZED标志
java
public synchronized void method() {
System.out.println("hello");
}
// 方法标志:
flags: ACC_PUBLIC, ACC_SYNCHRONIZED ← 有这个标志
三、对象头和Monitor(核心原理)
Java对象在内存中的结构:
对象 = 对象头 + 实例数据 + 对齐填充
对象头 = Mark Word + Class Pointer(类型指针)
Mark Word(重点!):
在32位JVM中,Mark Word占32bit,存储对象的哈希码、GC年龄、锁信息等。
无锁状态:
|----------------------------------------|
| hashCode(25bit) | GC年龄(4bit) | 0 01 |
|----------------------------------------|
偏向锁:
|----------------------------------------|
| 线程ID(23bit) | Epoch(2) | GC(4) | 1 01 |
|----------------------------------------|
轻量级锁:
|----------------------------------------|
| 指向栈中锁记录的指针(30bit) | 00 |
|----------------------------------------|
重量级锁:
|----------------------------------------|
| 指向Monitor对象的指针(30bit) | 10 |
|----------------------------------------|
GC标记:
|----------------------------------------|
| 空 | 11 |
|----------------------------------------|
最后2bit是锁标志位:
- 01:无锁或偏向锁
- 00:轻量级锁
- 10:重量级锁
- 11:GC标记
Monitor对象(重量级锁):
每个对象都关联一个Monitor对象
Monitor {
_owner: 持有锁的线程
_EntryList: 等待获取锁的线程队列(阻塞队列)
_WaitSet: 调用wait()的线程队列
_count: 重入次数
}
加锁流程:
1. 线程尝试获取Monitor
2. 获取成功:_owner指向该线程,_count++
3. 获取失败:进入_EntryList阻塞等待
4. 释放锁:_count--,为0时唤醒_EntryList中的线程
四、synchronized的锁升级(JDK 1.6优化,面试高频!)
JDK 1.6之前,synchronized就是重量级锁,性能差。JDK 1.6引入了锁升级机制:
无锁 → 偏向锁 → 轻量级锁 → 重量级锁
单向升级,不可降级!(除非GC)
1. 偏向锁(Biased Locking)
场景:大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得。
原理:
1. 第一次获取锁时,在对象头Mark Word中记录线程ID
2. 之后该线程再次获取锁时,检查线程ID是否是自己
3. 是:直接获取锁,不需要CAS操作
4. 不是:撤销偏向锁,升级为轻量级锁
为什么叫偏向? 因为锁"偏向"于第一个获取它的线程。
偏向锁的撤销:
当有其他线程尝试获取偏向锁时:
1. 等待全局安全点(STW)
2. 检查持有偏向锁的线程是否还活着
- 不活着:对象头设为无锁状态
- 活着:
- 线程不在同步块中:撤销偏向,设为无锁
- 线程在同步块中:升级为轻量级锁
开启/关闭偏向锁:
bash
# JDK 6/7 默认开启偏向锁
# 关闭偏向锁
-XX:-UseBiasedLocking
2. 轻量级锁(Lightweight Locking)
场景:多线程交替执行同步块,不存在竞争(线程A用完,线程B才用)。
原理:
加锁流程:
1. 在当前线程的栈帧中创建锁记录(Lock Record)
2. 复制对象头的Mark Word到锁记录中
3. 用CAS操作尝试把对象头的Mark Word替换为指向锁记录的指针
4. CAS成功:获取轻量级锁
5. CAS失败:
- 检查对象头是否指向当前线程的栈帧(重入)
- 是:直接获取锁
- 不是:锁膨胀为重量级锁
解锁流程:
1. 用CAS操作把锁记录中的Mark Word替换回对象头
2. CAS成功:释放锁
3. CAS失败:说明有竞争,膨胀为重量级锁,唤醒等待线程
自旋优化:
轻量级锁如果CAS失败,不会立即膨胀为重量级锁,而是自旋(循环尝试获取锁)。
java
// 自旋伪代码
int spinCount = 0;
while (spinCount < MAX_SPIN_COUNT) {
if (CAS成功) {
获取锁;
break;
}
spinCount++;
}
if (spinCount >= MAX_SPIN_COUNT) {
膨胀为重量级锁;
}
自适应自旋(JDK 1.6):
不再固定自旋次数,而是动态调整:
- 如果上次自旋成功了,这次可以多自旋几次
- 如果上次自旋失败了,这次少自旋或不自旋
3. 重量级锁(Heavyweight Locking)
场景:多线程同时竞争锁。
原理:
1. 膨胀为重量级锁(在对象头中指向Monitor对象)
2. 竞争失败的线程进入Monitor的_EntryList阻塞
3. 持有锁的线程释放锁后,唤醒_EntryList中的线程
4. 被唤醒的线程竞争锁
重量级锁的代价:
- 线程阻塞/唤醒需要CPU从用户态切换到内核态
- 切换开销大
五、锁升级的完整流程图
线程A第一次获取锁
↓
无锁 → 偏向锁(Mark Word记录线程A的ID)
↓
线程A再次获取锁:检查线程ID,是自己,直接获取
↓
线程B尝试获取锁
↓
线程A还持有锁?
├─ 是:升级为轻量级锁,线程B自旋等待
│ ↓
│ 自旋成功?
│ ├─ 是:线程B获取锁
│ └─ 否:升级为重量级锁,线程B阻塞
│
└─ 否:撤销偏向锁,设为无锁或轻量级锁
六、synchronized的其他优化
1. 锁粗化(Lock Coarsening)
java
// ❌ 锁粒度太细
for (int i = 0; i < 100; i++) {
synchronized (this) {
// 操作
}
}
// ✅ JVM优化后:锁粗化
synchronized (this) {
for (int i = 0; i < 100; i++) {
// 操作
}
}
2. 锁消除(Lock Elimination)
java
public String concatString(String s1, String s2) {
StringBuffer sb = new StringBuffer(); // 局部变量,不会逃逸
sb.append(s1); // StringBuffer的append是synchronized的
sb.append(s2);
return sb.toString();
}
// JVM优化:检测到sb不会逃逸出方法,消除StringBuffer内部的synchronized
七、synchronized vs Lock
| 对比项 | synchronized | ReentrantLock |
|---|---|---|
| 使用 | 关键字,自动加锁解锁 | 类,手动lock/unlock |
| 灵活性 | 不灵活 | 灵活(可中断、超时、公平锁) |
| 性能 | JDK 1.6后和Lock差不多 | 稍高(JDK 1.6之前明显高) |
| 可重入 | 支持 | 支持 |
| 锁释放 | 自动(异常也能释放) | 手动(需要finally) |
| 条件变量 | 1个(wait/notify) | 多个(Condition) |
| 推荐 | 优先使用 | 需要高级功能时使用 |
八、实际项目经验:
"我们项目中优先使用synchronized,因为JDK 1.8环境下,synchronized的性能已经很好了,而且使用简单,不容易出错。只有在需要tryLock()、可中断锁、公平锁等高级功能时,才用ReentrantLock。"
💡 总结:
- synchronized通过Monitor对象实现同步
- JDK 1.6引入了偏向锁、轻量级锁、自旋锁等优化
- 锁会单向升级:无锁 → 偏向锁 → 轻量级锁 → 重量级锁
- 现代JVM中,synchronized性能已经很好,优先使用
1.2 volatile的作用是什么?如何保证可见性和有序性?
✅ 正确回答思路:
volatile是Java提供的轻量级同步机制,我从作用、底层实现、使用场景三个方面来答。
一、volatile的两大作用
1. 保证可见性(Visibility) 2. 禁止指令重排序(Ordering)
注意 :volatile不保证原子性!
二、什么是可见性问题?
Java内存模型(JMM):
CPU1 CPU2 CPU3
↓ ↓ ↓
工作内存1 工作内存2 工作内存3
↓ ↓ ↓
|---------------主内存(共享)---------------|
可见性问题示例:
java
public class VisibilityTest {
private static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (!flag) {
// 线程1一直循环,等待flag变为true
}
System.out.println("线程1结束");
}).start();
Thread.sleep(1000);
flag = true; // 主线程修改flag
System.out.println("主线程修改flag为true");
}
}
// 可能的结果:线程1永远不会结束!
// 原因:线程1把flag读到自己的工作内存中,一直用工作内存中的值(false)
// 主线程修改了主内存中的flag,但线程1看不到
加上volatile解决:
java
private static volatile boolean flag = false; // ← 加上volatile
// 现在线程1能看到主线程的修改,会正常结束
volatile如何保证可见性?
底层实现 :通过内存屏障(Memory Barrier)。
1. 写volatile变量时:
- 在写操作之后插入一个Store Barrier
- 作用:强制把CPU缓存中的数据刷新到主内存
2. 读volatile变量时:
- 在读操作之前插入一个Load Barrier
- 作用:强制从主内存读取最新值,而不是用CPU缓存
具体实现(x86架构):
- 会在写volatile变量的指令后面加上一个 lock 前缀指令
- lock 前缀指令会:
1. 锁定缓存行
2. 把修改写回主内存
3. 使其他CPU的缓存行无效(MESI协议)
字节码层面:
java
// volatile变量
private volatile int count = 0;
// 字节码中会有 ACC_VOLATILE 标志
private volatile int count;
descriptor: I
flags: ACC_VOLATILE ← 这个标志
三、什么是指令重排序问题?
为了提高性能,编译器和CPU可能会对指令重排序:
java
// 原始代码
int a = 1; // 1
int b = 2; // 2
int c = a + b; // 3
// 可能重排序为:
int b = 2; // 2
int a = 1; // 1
int c = a + b; // 3
// 只要保证单线程语义不变,就可以重排
多线程下的重排序问题(经典的双重检查锁定):
java
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // ← 问题在这里!
}
}
}
return instance;
}
}
new Singleton()分三步:
1. 分配内存空间
2. 初始化对象
3. 把instance指向内存空间
可能重排序为:
1. 分配内存空间
3. 把instance指向内存空间(此时对象还没初始化!)
2. 初始化对象
问题:
线程A执行到步骤3,instance != null了,但对象还没初始化
线程B判断instance != null,直接返回,使用了未初始化的对象!
加上volatile解决:
java
private static volatile Singleton instance; // ← 加上volatile
// volatile禁止 步骤2和步骤3 重排序,保证instance指向的一定是初始化完成的对象
volatile如何禁止重排序?
内存屏障(Memory Barrier):
JMM在volatile读写操作前后插入内存屏障:
写volatile变量:
StoreStore Barrier
写volatile变量
StoreLoad Barrier
读volatile变量:
LoadLoad Barrier
读volatile变量
LoadStore Barrier
四种内存屏障:
1. LoadLoad Barrier
Load1; LoadLoad; Load2
→ 保证Load1的数据加载先于Load2
2. StoreStore Barrier
Store1; StoreStore; Store2
→ 保证Store1的数据对其他处理器可见,先于Store2
3. LoadStore Barrier
Load1; LoadStore; Store2
→ 保证Load1的数据加载先于Store2
4. StoreLoad Barrier(最重的屏障)
Store1; StoreLoad; Load2
→ 保证Store1的数据对所有处理器可见,先于Load2
volatile的happens-before规则:
对volatile变量的写 happens-before 对这个变量的读
意思是:
线程A写volatile变量之前的所有操作,对线程B读这个变量之后的所有操作可见
四、volatile不保证原子性
问题示例:
java
public class VolatileAtomicTest {
private volatile int count = 0;
public void increment() {
count++; // 不是原子操作!
}
public static void main(String[] args) throws InterruptedException {
VolatileAtomicTest test = new VolatileAtomicTest();
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
test.increment();
}
});
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println(test.count); // 期望10000,实际<10000
}
}
为什么count++不是原子操作?
count++ 分三步:
1. 读取count的值到工作内存
2. 在工作内存中+1
3. 写回主内存
虽然volatile保证了可见性,但不能保证这三步的原子性:
线程A读取count=0
线程B读取count=0
线程A计算count=1
线程B计算count=1
线程A写回count=1
线程B写回count=1
结果:两次++,count只增加了1
解决办法:
java
// 方案1:synchronized
public synchronized void increment() {
count++;
}
// 方案2:AtomicInteger
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // 原子操作
}
// 方案3:Lock
private Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
五、volatile的使用场景
场景1:状态标志
java
private volatile boolean shutdown = false;
public void shutdown() {
shutdown = true;
}
public void doWork() {
while (!shutdown) {
// 工作
}
}
场景2:双重检查锁定(DCL)
java
private volatile Helper helper;
public Helper getHelper() {
if (helper == null) {
synchronized(this) {
if (helper == null) {
helper = new Helper();
}
}
}
return helper;
}
场景3:一次性安全发布
java
private volatile int value;
public void setValue(int value) {
this.value = value;
}
public int getValue() {
return value;
}
不适合的场景:
java
// ❌ 不适合:需要原子性
private volatile int count = 0;
public void increment() {
count++; // 不是原子操作
}
// ❌ 不适合:依赖当前值
public void setFlag() {
flag = !flag; // 读-改-写,不是原子操作
}
六、volatile vs synchronized
| 对比项 | volatile | synchronized |
|---|---|---|
| 作用对象 | 变量 | 方法或代码块 |
| 原子性 | 不保证 | 保证 |
| 可见性 | 保证 | 保证 |
| 有序性 | 部分保证(禁止重排) | 保证 |
| 性能 | 高(轻量级) | 较低(JDK 1.6后优化) |
| 阻塞 | 不阻塞 | 可能阻塞 |
七、实际项目经验:
"我们项目中用volatile的地方主要是双重检查锁定的单例模式,还有一些状态标志位。对于需要原子性的操作,比如计数器,我们用AtomicInteger而不是volatile int。"
💡 总结:
- volatile通过内存屏障保证可见性和禁止重排序
- volatile不保证原子性
- 适用于状态标志、DCL、一次性安全发布
- 需要原子性时用synchronized或Atomic类
1.3 线程池的核心参数有哪些?线程池的工作流程是什么?
✅ 正确回答思路:
线程池是Java并发编程的核心工具,我从参数、工作流程、拒绝策略、实际使用四个方面详细说明。
一、ThreadPoolExecutor的7大核心参数
java
public ThreadPoolExecutor(
int corePoolSize, // 核心线程数
int maximumPoolSize, // 最大线程数
long keepAliveTime, // 非核心线程空闲存活时间
TimeUnit unit, // 时间单位
BlockingQueue<Runnable> workQueue, // 任务队列
ThreadFactory threadFactory, // 线程工厂
RejectedExecutionHandler handler // 拒绝策略
)
详细说明每个参数:
1. corePoolSize(核心线程数)
线程池的基本大小
- 即使线程空闲,也不会被回收(除非设置allowCoreThreadTimeOut)
- 新任务到来时,如果线程数<corePoolSize,会创建新线程
2. maximumPoolSize(最大线程数)
线程池允许创建的最大线程数
- 线程数 = 核心线程 + 非核心线程
- 当任务队列满了,且线程数<maxPoolSize时,会创建非核心线程
3. keepAliveTime + unit(非核心线程存活时间)
非核心线程空闲多久后被回收
- 当线程数>corePoolSize时,多余的空闲线程等待时间超过keepAliveTime就会被销毁
- 如果设置allowCoreThreadTimeOut(true),核心线程也会被回收
4. workQueue(任务队列)
用于保存等待执行的任务
常用的队列类型:
1. ArrayBlockingQueue
- 有界队列,基于数组
- 需要指定容量
2. LinkedBlockingQueue
- 可选有界队列,基于链表
- 不指定容量时,默认Integer.MAX_VALUE(相当于无界)
3. SynchronousQueue
- 不存储元素的阻塞队列
- 每个插入操作必须等待一个移除操作
- 适合任务数量不确定的场景
4. PriorityBlockingQueue
- 优先级队列
- 按优先级顺序执行任务
5. DelayQueue
- 延迟队列
- 元素到达延迟时间后才能被取出
5. threadFactory(线程工厂)
用于创建新线程
作用:
- 可以自定义线程名称、优先级、是否守护线程等
- 方便问题排查
示例:
ThreadFactory namedThreadFactory = new ThreadFactoryBuilder()
.setNameFormat("my-pool-%d")
.build();
6. handler(拒绝策略)
当任务队列满了,且线程数达到maximumPoolSize时,如何处理新任务
JDK提供了4种拒绝策略:
1. AbortPolicy(默认)
- 抛出RejectedExecutionException异常
2. CallerRunsPolicy
- 由调用线程自己执行任务(不丢弃任务,但会降低新任务提交速度)
3. DiscardPolicy
- 静默丢弃任务,不抛异常
4. DiscardOldestPolicy
- 丢弃队列中最老的任务,然后尝试提交当前任务
二、线程池的工作流程(面试必考!)
1. 提交任务到线程池
2. 判断核心线程数是否已满?
├─ 否:创建核心线程执行任务
└─ 是:进入下一步
3. 判断任务队列是否已满?
├─ 否:任务加入队列等待
└─ 是:进入下一步
4. 判断最大线程数是否已满?
├─ 否:创建非核心线程执行任务
└─ 是:执行拒绝策略
图示(文字版):
新任务到来
↓
线程数 < corePoolSize?
├─ 是 → 创建核心线程执行
└─ 否 ↓
队列未满?
├─ 是 → 加入队列
└─ 否 ↓
线程数 < maximumPoolSize?
├─ 是 → 创建非核心线程执行
└─ 否 → 执行拒绝策略
举例说明:
java
ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, // corePoolSize
5, // maximumPoolSize
60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(10) // 队列容量10
);
// 提交任务的流程:
// 第1个任务:线程数0 < core2,创建核心线程1执行
// 第2个任务:线程数1 < core2,创建核心线程2执行
// 第3个任务:核心线程满了,加入队列,队列1/10
// ...
// 第12个任务:核心线程满了,加入队列,队列10/10(队列满了!)
// 第13个任务:队列满了,线程数2 < max5,创建非核心线程3执行
// 第14个任务:队列满了,线程数3 < max5,创建非核心线程4执行
// 第15个任务:队列满了,线程数4 < max5,创建非核心线程5执行
// 第16个任务:队列满了,线程数5 = max5,执行拒绝策略!
三、常见的线程池类型(Executors工厂方法)
java
// 1. newFixedThreadPool:固定线程数
ExecutorService pool = Executors.newFixedThreadPool(10);
// 等价于:
new ThreadPoolExecutor(10, 10, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());
// 2. newCachedThreadPool:可缓存线程池
ExecutorService pool = Executors.newCachedThreadPool();
// 等价于:
new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>());
// 3. newSingleThreadExecutor:单线程
ExecutorService pool = Executors.newSingleThreadExecutor();
// 等价于:
new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());
// 4. newScheduledThreadPool:定时任务线程池
ScheduledExecutorService pool = Executors.newScheduledThreadPool(10);
为什么不推荐用Executors创建线程池?(阿里巴巴Java开发手册强制规定)
原因:
1. FixedThreadPool 和 SingleThreadExecutor:
- 使用LinkedBlockingQueue,默认容量Integer.MAX_VALUE
- 可能堆积大量请求,导致OOM
2. CachedThreadPool 和 ScheduledThreadPool:
- maximumPoolSize是Integer.MAX_VALUE
- 可能创建大量线程,导致OOM
推荐:手动创建ThreadPoolExecutor,明确指定参数
四、如何合理设置线程池参数?
CPU密集型任务:
线程数 = CPU核心数 + 1
原因:
- CPU密集型任务,线程一直在运行,不会阻塞
- CPU核心数+1,是为了当某个线程因为页缺失或其他原因阻塞时,额外的线程可以顶上
示例:
int processors = Runtime.getRuntime().availableProcessors();
ThreadPoolExecutor pool = new ThreadPoolExecutor(
processors + 1,
processors + 1,
...
);
IO密集型任务:
线程数 = CPU核心数 * 2
或者
线程数 = CPU核心数 / (1 - 阻塞系数) // 阻塞系数一般0.8-0.9
原因:
- IO密集型任务,线程大部分时间在等待IO(网络、磁盘),CPU利用率低
- 可以创建更多线程,提高CPU利用率
示例:
int processors = Runtime.getRuntime().availableProcessors();
ThreadPoolExecutor pool = new ThreadPoolExecutor(
processors * 2,
processors * 2,
...
);
实际项目中的调优方法:
1. 压测
- 模拟真实流量,观察CPU、内存、响应时间
- 逐步调整线程数,找到最优值
2. 监控
- 监控线程池的activeCount、queueSize、rejectedCount
- 根据监控数据调整
3. 动态调整
- 线程池支持动态修改参数
- 可以通过配置中心动态调整
五、实际项目经验:
"我们项目用了自定义的ThreadPoolExecutor:
java
ThreadPoolExecutor pool = new ThreadPoolExecutor(
10, // 核心线程数:根据业务量压测得出
20, // 最大线程数:高峰期可以扩展到20
60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100), // 有界队列,防止OOM
new ThreadFactoryBuilder()
.setNameFormat("business-pool-%d")
.build(),
new ThreadPoolExecutor.CallerRunsPolicy() // 让调用线程执行,降低提交速度
);
// 监控
pool.submit(() -> {
log.info("活跃线程数: {}", pool.getActiveCount());
log.info("队列任务数: {}", pool.getQueue().size());
log.info("已完成任务数: {}", pool.getCompletedTaskCount());
});
遇到过一次拒绝策略触发导致任务丢失的问题,排查后发现是队列容量设置太小了,后来调大到500,问题解决。"
💡 总结:
- 线程池7大参数:核心线程数、最大线程数、存活时间、队列、线程工厂、拒绝策略
- 工作流程:核心线程 → 队列 → 非核心线程 → 拒绝策略
- 不推荐用Executors创建线程池,手动创建更可控
- CPU密集型: CPU核心数+1,IO密集型: CPU核心数*2