并发编程是Java面试的重灾区。这篇文章帮你建立完整的并发知识图谱。
前言
并发编程大概是Java面试中最"玄学"的部分------知识点多、概念抽象、还容易搞混。
但其实并发问题的本质就三个词:原子性、可见性、有序性。所有的并发工具和机制,都是在解决这三个问题。搞清楚这个主线,面试时就能以不变应万变。
一、并发问题的本质
为什么会有并发问题?
根本原因:CPU、内存、IO的速度差异
CPU >> 内存 >> IO
为了弥补速度差异,硬件和编译器做了优化:
1. CPU缓存 → 导致可见性问题
2. 线程切换 → 导致原子性问题
3. 编译优化(指令重排) → 导致有序性问题
Java内存模型(JMM)
线程A 线程B
┌──────────┐ ┌──────────┐
│ 工作内存 │ │ 工作内存 │
│ (CPU缓存) │ │ (CPU缓存) │
└─────┬────┘ └────┬─────┘
│ read/write │
├──────────┬────────────┤
│ ▼ │
│ ┌───────────┐ │
│ │ 主内存 │ │
│ │ (堆/变量) │ │
│ └───────────┘ │
问题:线程A修改了变量x,线程B可能读到旧值(缓存未同步)
解决:volatile、synchronized、Lock
二、synchronized深入
synchronized的三种用法
java
// 1. 修饰实例方法 → 锁的是this对象
public synchronized void method() { }
// 2. 修饰静态方法 → 锁的是Class对象
public static synchronized void method() { }
// 3. 修饰代码块 → 锁的是指定对象
synchronized (lockObj) { }
synchronized的锁升级过程(JDK6+优化)
无锁 → 偏向锁 → 轻量级锁 → 重量级锁
(只能升级,不能降级)
1. 偏向锁:
- 场景:只有一个线程访问
- 实现:在Mark Word中记录线程ID
- 第二次进入同步块时,对比线程ID即可,无需CAS
- 升级条件:有其他线程来竞争
2. 轻量级锁:
- 场景:多线程交替执行,没有真正竞争
- 实现:CAS将Mark Word复制到栈帧的Lock Record
- 不会阻塞线程(自旋等待)
- 升级条件:自旋超过一定次数/自旋线程数超过CPU核心数一半
3. 重量级锁:
- 场景:真正的并发竞争
- 实现:通过Monitor(ObjectMonitor)实现
- 获取不到锁的线程会被阻塞(涉及用户态→内核态切换)
面试追问:synchronized和Lock的区别?
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 实现 | JVM层面(monitorenter/exit) | API层面(AQS) |
| 加锁释放 | 自动释放 | 手动unlock(必须finally) |
| 可中断 | 不支持 | lockInterruptibly() |
| 超时 | 不支持 | tryLock(timeout) |
| 公平性 | 非公平 | 可选公平/非公平 |
| 条件变量 | wait/notify(单个) | Condition(多个) |
| 性能 | JDK6后优化好了,差不多 | 差不多 |
三、volatile关键字
volatile的作用
java
// 1. 保证可见性
volatile boolean flag = false;
// 线程A修改flag=true后,线程B能立即看到
// 2. 禁止指令重排
// 经典案例:双重检查锁的单例模式
public class Singleton {
private static volatile Singleton instance; // 必须加volatile!
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 可能重排序!
}
}
}
return instance;
}
}
为什么单例要加volatile?
instance = new Singleton() 实际分三步:
1. 分配内存空间
2. 初始化对象
3. 将引用指向内存空间
指令重排后可能变成 1→3→2:
- 线程A执行了1和3,还没执行2
- 线程B判断instance != null,直接返回
- 线程B拿到了一个未初始化的对象 → 出bug
volatile禁止重排 → 保证顺序是1→2→3
volatile不保证原子性
java
volatile int count = 0;
// ❌ 这不是线程安全的!
count++; // 实际是 read → +1 → write 三步操作
// ✅ 要保证原子性,用AtomicInteger
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // CAS原子操作
四、CAS与原子类
CAS原理
CAS(Compare And Swap):比较并交换
操作:CAS(内存地址V, 期望值A, 新值B)
含义:如果V的值==A,就把V改为B,返回true
如果V的值!=A(被别人改了),不修改,返回false
底层:CPU指令(cmpxchg),硬件级别保证原子性
AtomicInteger源码核心
java
public class AtomicInteger {
private volatile int value; // volatile保证可见性
public final int incrementAndGet() {
// Unsafe.getAndAddInt → 自旋CAS
return U.getAndAddInt(this, VALUE, 1) + 1;
}
}
// Unsafe.getAndAddInt的实现
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset); // 读取当前值
} while (!compareAndSwapInt(o, offset, v, v + delta)); // CAS直到成功
return v;
}
CAS的三大问题
问题1:ABA问题
- A→B→A,CAS认为没变过
- 解决:AtomicStampedReference(版本号)
问题2:自旋开销
- 高竞争下一直CAS失败,浪费CPU
- 解决:LongAdder(分段CAS,减少竞争)
问题3:只能保证单个变量的原子性
- 多个变量怎么办?
- 解决:AtomicReference(把多个变量封装为对象)
五、AQS框架(AbstractQueuedSynchronizer)
AQS是什么
AQS = 抽象队列同步器
是Java并发包的基石:
基于AQS的实现:
- ReentrantLock
- ReentrantReadWriteLock
- Semaphore
- CountDownLatch
- CyclicBarrier
AQS核心设计
核心组成:
1. state变量(volatile int):表示同步状态
- ReentrantLock中:0=未锁定,>0=锁定(可重入次数)
- Semaphore中:剩余许可数
- CountDownLatch中:剩余计数
2. CLH队列(双向链表):存放等待的线程
head ←→ Node(Thread1) ←→ Node(Thread2) ←→ tail
3. 两种模式:
- 独占模式(Exclusive):只有一个线程能获取锁(如ReentrantLock)
- 共享模式(Shared):多个线程可同时获取(如Semaphore)
AQS获取锁流程(独占模式)
java
// ReentrantLock.lock() 最终调用 AQS.acquire()
public final void acquire(int arg) {
if (!tryAcquire(arg) && // 1. 尝试获取锁(子类实现)
acquireQueued( // 3. 在队列中自旋/阻塞
addWaiter(Node.EXCLUSIVE), // 2. 获取失败,加入等待队列
arg))
selfInterrupt();
}
// 非公平锁的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()) {
// 可重入:当前线程已持有锁
setState(c + acquires);
return true;
}
return false;
}
公平锁 vs 非公平锁
java
// 公平锁:新来的线程必须排队
if (c == 0) {
if (!hasQueuedPredecessors() && // 队列中没有等待的线程才尝试
compareAndSetState(0, acquires)) {
...
}
}
// 非公平锁:新来的线程直接尝试CAS,失败了才排队
if (c == 0) {
if (compareAndSetState(0, acquires)) { // 直接抢
...
}
}
// 为什么默认非公平?
// 性能好!减少了线程唤醒的开销
// 新来的线程如果能直接拿到锁,就不用进队列→挂起→唤醒
六、线程池
线程池参数详解
java
public ThreadPoolExecutor(
int corePoolSize, // 核心线程数(即使空闲也不回收)
int maximumPoolSize, // 最大线程数
long keepAliveTime, // 非核心线程空闲多久回收
TimeUnit unit, // 时间单位
BlockingQueue<Runnable> workQueue, // 任务队列
ThreadFactory threadFactory, // 线程工厂
RejectedExecutionHandler handler // 拒绝策略
)
任务提交流程
提交任务
│
▼
核心线程数满了吗? ──否──→ 创建核心线程执行
│是
▼
任务队列满了吗? ──否──→ 放入队列等待
│是
▼
达到最大线程数了吗? ──否──→ 创建非核心线程执行
│是
▼
执行拒绝策略
面试常问:线程池参数怎么设置?
java
// CPU密集型任务(计算多、IO少)
int corePoolSize = Runtime.getRuntime().availableProcessors() + 1;
// IO密集型任务(等待IO多、计算少)
int corePoolSize = Runtime.getRuntime().availableProcessors() * 2;
// 混合型(推荐公式)
int corePoolSize = Ncpu * Ucpu * (1 + W/C);
// Ncpu = CPU核心数
// Ucpu = 目标CPU利用率(0-1)
// W/C = 等待时间/计算时间
// 实际生产中:压测!理论公式只是起点
四种拒绝策略
java
// 1. AbortPolicy(默认):抛RejectedExecutionException
// 2. CallerRunsPolicy:调用者线程执行(降级但不丢任务)
// 3. DiscardPolicy:静默丢弃
// 4. DiscardOldestPolicy:丢弃队列中最老的任务
// 生产建议:自定义拒绝策略
new RejectedExecutionHandler() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
// 1. 记录日志
log.error("线程池已满,任务被拒绝: {}", r.toString());
// 2. 持久化到数据库/MQ,后续补偿
saveToRetryQueue(r);
// 3. 告警
alertService.notify("线程池任务拒绝");
}
};
七、并发工具类
CountDownLatch(倒计时器)
java
// 场景:主线程等待N个子任务完成
CountDownLatch latch = new CountDownLatch(3);
// 子任务执行完调用 countDown()
executor.submit(() -> {
doTask1();
latch.countDown(); // 计数-1
});
executor.submit(() -> {
doTask2();
latch.countDown();
});
executor.submit(() -> {
doTask3();
latch.countDown();
});
// 主线程等待
latch.await(); // 阻塞直到计数为0
System.out.println("所有任务完成");
CyclicBarrier(循环栅栏)
java
// 场景:N个线程相互等待,到齐后一起继续
CyclicBarrier barrier = new CyclicBarrier(3, () -> {
System.out.println("所有线程到达屏障,开始下一阶段");
});
// 每个线程执行到await()时等待
executor.submit(() -> {
phase1();
barrier.await(); // 等待其他线程
phase2(); // 到齐后继续
});
Semaphore(信号量)
java
// 场景:限制同时访问的线程数(如数据库连接池)
Semaphore semaphore = new Semaphore(10); // 最多10个线程同时访问
public void accessDatabase() {
semaphore.acquire(); // 获取许可(没有则阻塞)
try {
doQuery();
} finally {
semaphore.release(); // 释放许可
}
}
三者对比
| 工具 | 计数变化 | 可重用 | 典型场景 |
|---|---|---|---|
| CountDownLatch | 减到0 | ❌一次性 | 主线程等待子任务 |
| CyclicBarrier | 到齐后重置 | ✅可循环 | 多阶段并行任务 |
| Semaphore | acquire减/release加 | ✅ | 限流/连接池 |
八、ThreadLocal
原理
java
// 每个Thread对象有一个ThreadLocalMap
// key = ThreadLocal对象(弱引用)
// value = 实际存储的值
public class Thread {
ThreadLocal.ThreadLocalMap threadLocals; // 每个线程独立的Map
}
// set时:
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = t.threadLocals;
map.set(this, value); // this = 当前ThreadLocal对象
}
内存泄漏问题(面试必问)
ThreadLocalMap的Entry:
key = ThreadLocal的弱引用
value = 实际值的强引用
当ThreadLocal对象被GC回收后:
key变为null,但value还在!
→ 这个Entry永远不会被访问到,但也不会被回收 → 内存泄漏
解决:用完之后必须调用 threadLocal.remove()
// 特别是在线程池中:
try {
threadLocal.set(user);
doSomething();
} finally {
threadLocal.remove(); // 必须remove!线程会被复用!
}
九、面试高频连环问
Q: 线程安全的实现方式有哪些?
1. 不可变对象:String、Integer(天生线程安全)
2. synchronized/Lock:互斥同步
3. CAS + volatile:无锁并发(Atomic类)
4. ThreadLocal:线程封闭(每个线程有自己的副本)
5. 并发容器:ConcurrentHashMap、CopyOnWriteArrayList
Q: 死锁的四个条件和排查方法?
四个必要条件:
1. 互斥:资源不能共享
2. 持有并等待:持有一个资源同时等待另一个
3. 不可剥夺:不能强行抢走别人的资源
4. 循环等待:A等B,B等A
排查方法:
1. jstack <pid> → 看有没有 "Found one Java-level deadlock"
2. Arthas的 thread -b → 找到阻塞的线程
3. VisualVM/JConsole → 图形化检测死锁
预防方法:
1. 按固定顺序加锁
2. 设置超时时间(tryLock(timeout))
3. 使用Lock而不是synchronized(可以中断和超时)
写在最后
Java并发的核心知识点:
底层原理:JMM、happens-before、volatile、CAS
锁机制:synchronized(锁升级)、AQS(ReentrantLock)
线程池:参数含义、执行流程、拒绝策略
工具类:CountDownLatch、CyclicBarrier、Semaphore
线程安全:ThreadLocal、并发容器、原子类
面试时能把每个点讲清楚"是什么→为什么→怎么用→有什么坑",就够了。
并发是Java面试重中之重,建议收藏反复看!下篇写Spring全家桶~