Java多线程与并发核心解析
本文整合多线程与并发的核心知识点,涵盖线程基础、线程池、锁机制、并发工具、volatile、CAS、ThreadLocal七大模块,每个模块聚焦核心要点、规避冗余描述,便于系统梳理与回顾。
一、线程基础
1. 线程创建的三种方式
方式 1:继承 Thread 类
核心原理:Thread 类是 Java 对线程的核心封装类,继承该类并重写run()方法定义线程执行逻辑,通过start()方法启动新线程(JVM 会调度新线程执行run(),直接调用run()仅为普通方法执行)。
// 继承Thread创建线程(核心代码+详细注释)
class MyThread extends Thread {
// 重写run方法:线程的核心执行逻辑
@Override
public void run() {
// 打印当前执行线程名称,验证是否为新线程
System.out.println("Thread方式执行线程:" + Thread.currentThread().getName());
}
}
public class ThreadCreateDemo {
public static void main(String[] args) {
// 1. 创建线程实例
MyThread thread1 = new MyThread();
// 2. 设置线程名称(便于调试)
thread1.setName("自定义Thread线程");
// 3. 启动线程(必须调用start(),而非直接run())
thread1.start();
// 【核心坑点】直接调用run():无新线程,在主线程执行
// thread1.run(); // 执行结果:Thread方式执行线程:main
}
}
优缺点:
- 优点:实现简单,直接继承即可;
- 缺点:Java 单继承限制,继承 Thread 后无法继承其他类,耦合度高。
方式 2:实现 Runnable 接口
核心原理:将线程执行逻辑(run())与线程对象(Thread)解耦,Runnable 仅定义执行逻辑,通过 Thread 类包装后启动线程,规避单继承限制。
// 实现Runnable创建线程(推荐方案)
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("Runnable方式执行线程:" + Thread.currentThread().getName());
}
}
public class RunnableCreateDemo {
public static void main(String[] args) {
// 1. 创建Runnable实例(仅封装执行逻辑)
MyRunnable runnable = new MyRunnable();
// 2. 包装为Thread对象(线程载体)
Thread thread2 = new Thread(runnable, "自定义Runnable线程");
// 3. 启动线程
thread2.start();
// 简化写法:匿名内部类(日常开发常用)
new Thread(() -> {
System.out.println("匿名Runnable执行线程:" + Thread.currentThread().getName());
}, "匿名线程").start();
}
}
优缺点:
- 优点:规避单继承,执行逻辑与线程解耦,可共享 Runnable 实例;
- 缺点:无返回值,无法直接获取任务执行结果。
方式 3:实现 Callable 接口(带返回值)
核心原理:Callable 定义call()方法(支持返回值、可抛出异常),结合 FutureTask 包装为可执行任务,通过 Thread 启动后,调用get()获取执行结果(阻塞等待任务完成)。
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
// 实现Callable创建线程(需获取任务结果时使用)
class MyCallable implements Callable<Integer> {
// 任务执行逻辑,返回计算结果(支持抛异常)
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 1; i <= 10; i++) {
sum += i;
}
return sum;
}
}
public class CallableCreateDemo {
public static void main(String[] args) throws Exception {
// 1. 创建Callable实例
MyCallable callable = new MyCallable();
// 2. 包装为FutureTask(兼具Runnable和Future特性)
FutureTask<Integer> futureTask = new FutureTask<>(callable);
// 3. 启动线程
Thread thread3 = new Thread(futureTask, "自定义Callable线程");
thread3.start();
// 4. 获取返回值(阻塞等待任务执行完成)
Integer result = futureTask.get();
System.out.println("Callable任务结果(1-10求和):" + result); // 输出55
}
}
优缺点:
- 优点:支持返回值、支持异常抛出,适合需要获取任务结果的场景;
- 缺点:get()方法会阻塞主线程,需合理控制等待时间。
三种创建方式对比+线程池
| Thread | 继承、无返回值、单继承限制 | 简单测试场景,不推荐生产使用 | 单继承被占用,无法复用,资源浪费 |
| Runnable | 实现、无返回值、解耦 | 大多数普通并发场景(推荐) | 简单任务可用,但手动创建线程仍浪费资源 |
| Callable | 实现、有返回值、可抛异常 | 需要获取任务执行结果的场景 | 支持返回值与受检异常,与线程池天然搭配,实现异步计算结果 |
| 线程池(下文说) | 复用线程 + 控制并发 + 任务队列 + 拒绝策略 | 高并发、异步任务、批量 IO/计算、需要返回值或限流的生产场景。 | 复用线程、可控并发、性能高、功能全(返回值、异常、定时等) |
注:线程池是生产环境唯一答案。
阿里 Java 手册原话
【强制】:线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。
【说明】:使用线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源的开销,解决资源不足的问题。
2. 线程生命周期(6 种状态)
核心状态定义(Thread.State 枚举)
public enum State {
NEW, // 新建:创建线程但未调用start()
RUNNABLE, // 可运行:调用start()后,包含“就绪(等CPU调度)”和“运行中”
BLOCKED, // 阻塞:等待获取synchronized锁
WAITING, // 等待:调用wait()/join()/LockSupport.park(),无超时
TIMED_WAITING,// 超时等待:调用sleep(long)/wait(long)/join(long)
TERMINATED; // 终止:线程执行完成或异常终止
}
状态流转核心规则
状态验证实战代码
// 线程生命周期状态验证(核心流转)
public class ThreadStateDemo {
public static void main(String[] args) throws InterruptedException {
// 1. NEW状态:创建线程但未启动
Thread t = new Thread(() -> {
try {
// 3. TIMED_WAITING状态:sleep(2000)
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "状态验证线程");
// 打印NEW状态
System.out.println("创建后未start:" + t.getState()); // NEW
// 2. RUNNABLE状态:调用start()
t.start();
System.out.println("调用start后:" + t.getState()); // RUNNABLE
// 等待1秒,让线程进入sleep(TIMED_WAITING)
Thread.sleep(1000);
System.out.println("线程sleep中:" + t.getState()); // TIMED_WAITING
// 等待线程执行完成,打印TERMINATED
t.join();
System.out.println("线程执行完成:" + t.getState()); // TERMINATED
}
}
核心注意点
- start()方法仅能调用一次,重复调用会抛出IllegalThreadStateException;
Thread t=new Thread(()-> System.out.println("Runnable方式:"+Thread.currentThread().getName()));
t.start();
// t.start(); //解除注释后自己运行查看
- 线程进入 TERMINATED 状态后,无法再次启动;
- BLOCKED 仅针对 synchronized 锁,等待其他锁(如 可重入锁 (ReentrantLock:见 三.2))会进入 WAITING/TIMED_WAITING。
二、线程池
1. 线程池核心认知
1.1 核心价值
- 复用线程:避免频繁创建 / 销毁线程的性能开销;
- 控制并发:限制同时运行的线程数,防止 CPU 满载、OOM;
- 统一管理:集中管控任务提交、执行、拒绝流程。
1.2 核心类关系
- 顶层接口:Executor(仅定义execute(Runnable)提交任务);
- 核心接口:ExecutorService(扩展生命周期管理、带返回值任务提交);
- 实现类:ThreadPoolExecutor(线程池核心实现:重要);
- 工具类:Executors(默认线程池创建工具,生产环境禁用)。
2. ThreadPoolExecutor 核心参数
2.1 核心构造方法(逐参数解析)
import java.util.concurrent.*;
// ThreadPoolExecutor核心构造方法(详细注释)
public class ThreadPoolParamExplain {
public static void main(String[] args) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, // 1. corePoolSize:核心线程数(常驻,空闲不回收)
5, // 2. maximumPoolSize:最大线程数(核心+临时线程上限):在这里临时线程数为:5-2=3个
60L, // 3. keepAliveTime:临时线程空闲存活时间
TimeUnit.SECONDS, // 4. unit:keepAliveTime的时间单位
new ArrayBlockingQueue<>(3), // 5. workQueue:有界任务队列(核心满时任务入队)
// 6. threadFactory:自定义线程工厂(命名便于调试)
new ThreadFactory() {
private int threadCount = 1;
@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread(r);
thread.setName("pool-thread-" + threadCount++); // 自定义线程名
thread.setPriority(Thread.NORM_PRIORITY); // 默认优先级
return thread;
}
},
new ThreadPoolExecutor.AbortPolicy() // 7. handler:拒绝策略(默认抛异常,见 二.4)
);
}
}
2.2 参数协作逻辑
提交任务 → 核心线程池是否满?
→ 否:创建核心线程执行
→ 是:任务队列是否满?
→ 否:任务入队等待
→ 是:最大线程池是否满?
→ 否:创建临时线程执行
→ 是:执行拒绝策略
简单理解:
把线程池想成一家快递分拣站,就明白了:
快递(任务)一到,他们立刻上手分拣,长期在岗。
正式工忙不过来时,新来的包裹被扔进货框排队;
货框里没有人,只有等待的包裹。
货框也满了,站点马上雇临时工,一起分拣;
空闲一段时间后,临时工被辞退。
所以:
- 跑动的只有正式工 + 临时工(线程)
- 货框里只有包裹(任务),没人在那里跑任务!
于是「正在跑」的任务数 = 2(core)+ 3(队列里等)+ 3(临时线程)= 8
第 9 个任务来时,队列满、线程满,只能触发 拒绝策略。
2.3 参数协作验证代码
import java.util.concurrent.*;
// 线程池参数协作验证(核心2+队列3+最大5,第9个任务触发拒绝)
public class ThreadPoolWorkDemo {
public static void main(String[] args) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, 5, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(3),
r -> new Thread(r, "demo-thread-" + Thread.currentThread().getId()),
new ThreadPoolExecutor.AbortPolicy()
);
// 提交8个任务(核心2+队列3+临时3,刚好处理)
for (int i = 1; i <= 8; i++) {
int taskNum = i;
executor.execute(() -> {
try {
TimeUnit.SECONDS.sleep(1); // 模拟任务执行
System.out.println("任务" + taskNum + " → 执行线程:" + Thread.currentThread().getName());
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
// 提交第9个任务(触发拒绝策略)
try {
executor.execute(() -> System.out.println("任务9执行"));
} catch (RejectedExecutionException e) {
System.out.println("任务9 → 触发拒绝策略:" + e.getMessage());
}
// 关闭线程池(规范流程)(二.5有详细流程解释)
executor.shutdown();
try {
if (!executor.awaitTermination(3, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
}
}
}
3. Executors 的坑(生产环境禁用)
3.1 三种默认线程池的致命问题
| newFixedThreadPool(n) | core=max=n,无界 LinkedBlockingQueue | 任务无限入队 | 任务堆积导致 OOM |
| newSingleThreadExecutor() | core=max=1,无界 LinkedBlockingQueue | 单线程 + 无界队列 | 任务堆积导致 OOM |
| newCachedThreadPool() | core=0,max=Integer.MAX_VALUE,SynchronousQueue | 无限制创建临时线程 | 线程数过多导致 CPU 满载 / OOM |
3.2 生产环境正确做法
- 禁用Executors,直接使用ThreadPoolExecutor;
- 任务队列必须用有界队列(如ArrayBlockingQueue);
- 核心线程数规则:CPU 密集型 = 核心数 + 1,IO 密集型 = 核心数 ×2。
4. 拒绝策略
4.1 四种默认拒绝策略
| AbortPolicy | 抛 RejectedExecutionException | 核心业务,需感知提交失败 |
| CallerRunsPolicy | 提交任务的线程执行任务 | 非核心业务,允许降级 |
| DiscardPolicy | 静默丢弃最新任务 | 非核心业务,允许丢失 |
| DiscardOldestPolicy | 丢弃队列最旧任务,尝试入队新任务 | 任务有先后顺序,允许丢旧任务 |
4.2 自定义拒绝策略
import java.util.concurrent.*;
// 自定义拒绝策略(日志+告警+持久化)
public class CustomRejectPolicyDemo {
public static void main(String[] args) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, 5, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(3),
Executors.defaultThreadFactory(),
(runnable, pool) -> {
// 1. 记录日志
System.err.println("任务被拒绝:" + runnable.toString());
System.err.println("线程池状态:核心=" + pool.getCorePoolSize()
+ ",活动=" + pool.getActiveCount()
+ ",队列=" + pool.getQueue().size());
// 2. 告警(对接钉钉/邮件)、3. 持久化任务(落地MQ重试)
}
);
// 提交9个任务触发拒绝
for (int i = 1; i <= 9; i++) {
int taskNum = i;
executor.execute(() -> System.out.println("任务" + taskNum + "执行"));
}
executor.shutdown();
}
}
5. 线程池关闭规范
// 线程池关闭正确流程(避免任务丢失/线程泄漏)
public class ThreadPoolShutdownDemo {
public static void main(String[] args) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, 5, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(3)
);
// 1. 提交任务(省略)
// 2. 关闭流程
//拒绝再提交新任务(再 submit 会抛 RejectedExecutionException,
//已入队的任务继续执行,正在跑的任务不被中断线程池状态 → SHUTDOWN
executor.shutdown();
try {
//主线程阻塞等待,最多给 5 秒让剩余任务跑完,超时则强制关闭。返回 true → 全部任务正常结束;false → 超时仍有任务未完成
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
//发送 interrupt() 中断正在执行的任务,把队列里还没跑的任务以 List 形式返回,可做补偿/持久化
//线程池状态 → STOP
executor.shutdownNow(); // 中断执行中任务,清空队列
//给被中断的线程最后一次机会响应中断、退出,若仍超时,说明有任务“顽固”不响应中断,记录日志人工排查
if (!executor.awaitTermination(1, TimeUnit.SECONDS)) {
System.err.println("线程池未正常关闭");
}
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt(); // 恢复中断状态
}
}
}
6. 线程池核心总结
三、锁机制
1. 内置锁(synchronized)
1.1 核心原理
synchronized 是 Java 内置的互斥锁(监视器锁),基于对象头 Mark Word 和监视器(Monitor)实现,JDK6 引入锁升级机制优化性能,保证多线程下临界区代码的原子性、可见性和有序性。
“一个操作是不可分割的:它要么完全执行,要么完全不执行,不会被任何其他操作观察到中间状态。”
“一个线程对共享变量所做的修改,必须对其他线程立即可见;即读线程必须能看到最新写入的值。”
“程序的执行必须保持源代码的顺序语义;编译器和处理器对指令的重排序不得改变单线程程序的 observable behavior,且必须遵守 happens-before 规则。”
1.2 使用方式
// synchronized三种使用方式(详细注释)
public class SynchronizedUsageDemo {
// 全局锁对象(代码块锁专用)
private final Object lock = new Object();
// 共享资源(模拟多线程竞争)
private int count = 0;
// 方式1:修饰实例方法 —— 锁当前对象(this)
// 特性:多线程调用同一实例的该方法会互斥,不同实例无互斥
public synchronized void addCount1() {
count++; // 临界区:原子执行
System.out.println("实例方法锁:" + Thread.currentThread().getName() + ",count=" + count);
}
// 方式2:修饰静态方法 —— 锁类对象(SynchronizedUsageDemo.class)
// 特性:所有实例共享该锁,多线程调用均互斥
public static synchronized void addCount2() {
System.out.println("静态方法锁:" + Thread.currentThread().getName());
}
// 方式3:修饰代码块 —— 锁指定对象(灵活控制锁粒度)
// 特性:仅锁定临界区代码,非临界区代码可并发执行,性能更高
public void addCount3() {
synchronized (lock) {
count++;
System.out.println("代码块锁:" + Thread.currentThread().getName() + ",count=" + count);
}
// 非临界区:多线程可并发执行
System.out.println("非临界区:" + Thread.currentThread().getName() + "自由执行");
}
// 测试方法
public static void main(String[] args) {
SynchronizedUsageDemo demo = new SynchronizedUsageDemo();
// 线程1:调用实例方法锁
new Thread(demo::addCount1, "线程1").start();
// 线程2:调用代码块锁
new Thread(demo::addCount3, "线程2").start();
// 线程3:调用静态方法锁
new Thread(SynchronizedUsageDemo::addCount2, "线程3").start();
}
}
synchronized 保证同一时刻只有一个线程能进临界区,count++ 是原子、正确,数据不会错。它不规定“谁先谁后”——公平性不归锁管,归调度器管。
如果你既要正确性又要严格先来后到,得用公平锁(new ReentrantLock(true)),代价是吞吐量下降。
默认的 synchronized 是非公平锁,追求的是性能,不是排队顺序。
1.3 锁升级机制
核心流程:无锁 → 偏向锁 → 轻量级锁 → 重量级锁(单向升级,不可降级)
| 无锁 | 无线程竞争 | 无额外开销 | 最优 |
| 偏向锁 | 单线程重复获取锁 | 标记线程 ID 在对象头,避免 CAS | 接近无锁 |
| 轻量级锁 | 多线程交替竞争锁 | 线程自旋(CAS)获取锁,无阻塞 | 中等 |
| 重量级锁 | 多线程同时竞争锁 | 操作系统互斥锁,线程阻塞 / 唤醒 | 最差 |
1.4 核心注意点
- 锁的本质是 “对象”,而非代码:不同锁对象不互斥;
- 实例方法锁(this)与静态方法锁(类对象)互不干扰;
- 锁升级不可逆,避免频繁切换锁状态带来的性能损耗。
2. 可重入锁(ReentrantLock)
2.1 核心原理
ReentrantLock 是基于 AQS(抽象队列同步器)实现的可重入互斥锁,支持公平 / 非公平锁模式,提供比 synchronized 更灵活的锁控制能力(中断、超时、条件变量)。
2.2 核心用法
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;
/**
* ReentrantLock核心用法(详细注释)
*/
public class ReentrantLockDemo {
// 创建非公平锁(默认),公平锁:new ReentrantLock(true)
// 公平锁:按排队顺序获取锁;非公平锁:允许插队,性能更高
private final ReentrantLock lock = new ReentrantLock();
private int count = 0;
// 基础用法:lock() + unlock()(必须在finally释放)
public void basicUsage() {
lock.lock(); // 获取锁(不可中断,死等)
try {
count++;
System.out.println("基础用法:" + Thread.currentThread().getName() + ",count=" + count);
// 可重入特性:同一线程可多次获取锁
lock.lock();
try {
count++;
System.out.println("可重入特性:" + Thread.currentThread().getName() + ",count=" + count);
} finally {
lock.unlock(); // 对应第二次加锁,必须解锁
}
} finally {
lock.unlock(); // 对应第一次加锁,必须解锁
}
}
// 进阶用法1:超时获取锁(tryLock)
public void tryLockUsage() {
try {
// 尝试获取锁,最多等待3秒,超时返回false
if (lock.tryLock(3, TimeUnit.SECONDS)) {
try {
count++;
System.out.println("超时锁成功:" + Thread.currentThread().getName() + ",count=" + count);
} finally {
lock.unlock();
}
} else {
// 锁获取失败,执行降级逻辑
System.out.println("超时锁失败:" + Thread.currentThread().getName() + "放弃抢锁");
}
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupt(); // 恢复中断状态
}
}
// 进阶用法2:可中断获取锁(lockInterruptibly)
public void interruptibleLockUsage() {
try {
// 获取锁期间线程被中断,直接抛InterruptedException
lock.lockInterruptibly();
try {
count++;
System.out.println("可中断锁成功:" + Thread.currentThread().getName() + ",count=" + count);
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
System.out.println("可中断锁:" + Thread.currentThread().getName() + "被中断,放弃抢锁");
Thread.currentThread().interrupt();
}
}
// 测试方法
public static void main(String[] args) throws InterruptedException {
ReentrantLockDemo demo = new ReentrantLockDemo();
// 测试基础用法(可重入)
new Thread(demo::basicUsage, "线程1").start();
// 测试超时获取锁
new Thread(demo::tryLockUsage, "线程2").start();
// 测试可中断锁
Thread thread3 = new Thread(demo::interruptibleLockUsage, "线程3");
thread3.start();
Thread.sleep(100); // 让线程3进入抢锁状态
thread3.interrupt(); // 中断线程3的抢锁过程
}
}
2.3 synchronized vs ReentrantLock
| 锁释放 | 自动释放(方法 / 代码块执行完) | 手动释放(必须在 finally 中 unlock) |
| 抢锁中断 | 不支持 | 支持(lockInterruptibly) |
| 超时等锁 | 不支持 | 支持(tryLock (long, unit)) |
| 锁类型 | 仅非公平锁 | 公平 / 非公平锁(默认非公平) |
| 条件变量 | 单一(Object.wait/notify) | 多个(Condition) |
| 性能 | JDK6 后与 ReentrantLock 接近 | 高并发下略优 |
3. 读写锁(ReentrantReadWriteLock)
3.1 核心原理
基于 AQS 实现的读写分离锁,读锁为共享锁、写锁为独占锁,适用于 “读多写少” 场景,通过分离读写操作提升并发性能。核心规则:
- 读锁共享:多个线程可同时获取读锁;
- 写锁独占:仅一个线程可获取写锁,写锁持有期间,读 / 写锁均无法获取;
- 写锁优先级高于读锁:避免写线程 “饿死”。
3.2 核心用法
import java.util.concurrent.locks.ReentrantReadWriteLock;
// 读写锁核心用法(读多写少场景专用)
public class ReadWriteLockDemo {
// 创建读写锁实例
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
// 读锁(共享锁)
private final ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();
// 写锁(独占锁)
private final ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();
// 共享数据(模拟商品价格)
private int price = 99;
// 读操作:获取读锁(多线程可并发)
public void readPrice() {
readLock.lock(); // 获取读锁
try {
// 多线程可同时执行读操作,性能提升
System.out.println("读锁:" + Thread.currentThread().getName() + ",价格=" + price);
TimeUnit.MILLISECONDS.sleep(500); // 模拟读耗时
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
readLock.unlock(); // 释放读锁
}
}
// 写操作:获取写锁(单线程独占)
public void writePrice(int newPrice) {
writeLock.lock(); // 获取写锁
try {
// 仅单线程可执行写操作,保证数据一致性
price = newPrice;
System.out.println("写锁:" + Thread.currentThread().getName() + ",修改价格为=" + price);
TimeUnit.MILLISECONDS.sleep(1000); // 模拟写耗时
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
writeLock.unlock(); // 释放写锁
}
}
// 测试方法:5个读线程 + 1个写线程
public static void main(String[] args) {
ReadWriteLockDemo demo = new ReadWriteLockDemo();
// 5个读线程(并发执行,总耗时≈500ms)
for (int i = 1; i <= 5; i++) {
new Thread(demo::readPrice, "读线程" + i).start();
}
// 1个写线程(独占执行,阻塞所有读/写操作)
new Thread(() -> demo.writePrice(89), "写线程1").start();
}
}
3.3 核心注意点
- 读写锁不可降级为写锁:持有读锁时直接获取写锁会导致死锁;
- 写锁可降级为读锁:持有写锁时可获取读锁(写完后读,保证数据一致性);
- 仅适用于读多写少场景:写操作频繁时,读写锁性能与普通锁无差异。
4. 锁机制核心总结
5. 锁机制高频面试题
5.1 基础必问(考察核心概念)
1. synchronized 关键字的作用是什么?底层实现原理是什么?
参考答案:
-
核心作用:保证多线程环境下临界区代码的原子性、可见性、有序性,实现线程间互斥,避免并发安全问题。
-
底层原理:
- JDK6 前:基于对象的 Monitor(监视器)实现,未获取锁的线程会进入阻塞状态,性能较低;
- JDK6 后:引入锁升级机制,基于对象头的 Mark Word 存储锁状态(无锁→偏向锁→轻量级锁→重量级锁),减少阻塞 / 唤醒的性能开销。
- 具体来说,synchronized 修饰方法时,通过方法区的 ACC_SYNCHRONIZED 标识实现;修饰代码块时,通过 monitorenter/monitorexit 指令实现。
2. synchronized 的锁升级流程是什么?为什么要设计锁升级?
参考答案:
-
锁升级流程
(单向不可逆):
- 无锁:对象刚创建,无线程竞争,Mark Word 存储对象哈希值等信息;
- 偏向锁:单线程重复获取锁,Mark Word 记录该线程 ID,线程下次获取锁时直接使用,无需竞争;
- 轻量级锁:多个线程交替竞争锁,线程通过 CAS 自旋获取锁,避免阻塞;
- 重量级锁:多个线程同时竞争锁,自旋耗性能,升级为操作系统级互斥锁,未抢到锁的线程进入阻塞状态。
-
设计原因:针对不同竞争场景适配不同锁状态,避免 “用重量级锁处理轻竞争” 的性能浪费,兼顾单线程、轻并发、高并发场景的性能。
3. ReentrantLock 与 synchronized 的区别?各自的适用场景是什么?
参考答案:
| 锁释放 | 自动释放(方法 / 代码块执行完) | 手动释放(必须在 finally 中 unlock) |
| 抢锁中断 | 不支持 | 支持(lockInterruptibly 方法) |
| 超时等锁 | 不支持 | 支持(tryLock (long, unit) 方法) |
| 锁类型 | 仅非公平锁 | 公平 / 非公平锁(默认非公平) |
| 条件变量 | 单一(Object.wait/notify) | 多个(Condition 接口,可精准唤醒线程) |
| 性能 | JDK6 后与 ReentrantLock 接近 | 高并发下略优 |
-
适用场景:
- synchronized:普通并发场景(如简单的计数器、单例模式),代码简洁,无需手动释放锁,降低出错概率;
- ReentrantLock:复杂并发场景(如需要超时等待、中断抢锁、公平锁、多条件唤醒),例如分布式任务调度、限时抢锁的业务。
4. 什么是可重入锁?synchronized 和 ReentrantLock 是可重入锁吗?举例说明。
参考答案:
-
可重入锁定义:同一线程获取锁后,可再次获取同一把锁而不发生死锁,锁会记录 “加锁次数”,解锁次数需与加锁次数一致才能完全释放。
-
两者都是可重入锁:
// synchronized 可重入示例
public class ReentrantSyncDemo {
public synchronized void method1() {
System.out.println("method1 获取锁");
method2(); // 同一线程再次获取锁,不会死锁
}
public synchronized void method2() {
System.out.println("method2 重入锁");
}
}// ReentrantLock 可重入示例
public class ReentrantLockDemo {
private final ReentrantLock lock = new ReentrantLock();
public void method1() {
lock.lock();
try {
System.out.println("method1 获取锁");
method2(); // 重入锁
} finally {
lock.unlock();
}
}
public void method2() {
lock.lock();
try {
System.out.println("method2 重入锁");
} finally {
lock.unlock();
}
}
}
5.2 进阶考察(考察深度理解)
1. 读写锁(ReentrantReadWriteLock)的核心规则是什么?适用于什么场景?
参考答案:
-
核心规则:
- 读锁(共享锁):多个线程可同时获取读锁,并发读,无互斥;
- 写锁(独占锁):仅一个线程可获取写锁,写锁持有期间,读 / 写锁均无法获取;
- 写锁优先级高于读锁:有写线程等待时,新的读线程会排队,避免写线程 “饿死”。
-
适用场景:读多写少的场景,例如商品详情页查询(大量读)、库存修改(少量写)、缓存读写、日志查询等。
-
反例:写操作频繁的场景(如高频下单、实时数据写入),读写锁性能与普通锁无差异,无需使用。
2. 为什么读写锁中 “读锁不能直接升级为写锁”?举例说明可能的问题。
参考答案:
-
原因:读锁是共享锁,若多个线程持有读锁时,其中一个线程尝试获取写锁,会导致死锁(所有读线程等写锁释放,写线程等读锁释放)。
-
示例:
public class ReadWriteLockDeadLock {
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();
private final ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();public void readThenWrite() {
readLock.lock(); // 获取读锁
System.out.println("获取读锁,准备升级为写锁");
writeLock.lock(); // 尝试获取写锁,死锁!
try {
System.out.println("执行写操作");
} finally {
writeLock.unlock();
readLock.unlock();
}
}public static void main(String[] args) {
ReadWriteLockDeadLock demo = new ReadWriteLockDeadLock();
new Thread(demo::readThenWrite).start();
new Thread(demo::readThenWrite).start(); // 两个线程均死锁
}
} -
解决方案:先释放读锁,再获取写锁(解锁→加写锁),保证写锁独占。
3. ReentrantLock 的公平锁和非公平锁有什么区别?为什么默认是非公平锁?
参考答案:
-
区别:
- 公平锁:线程按 “排队顺序” 获取锁,先到先得,无插队;
- 非公平锁:线程尝试直接获取锁(插队),失败后再排队,可能导致后到的线程先获取锁。
-
默认非公平锁的原因:
- 性能更高:公平锁需维护排队队列,增加锁获取的开销;非公平锁减少线程切换,吞吐量更高;
- 减少唤醒延迟:非公平锁中,刚释放锁的线程可能直接再次获取锁,避免线程阻塞 / 唤醒的耗时。
-
公平锁适用场景:对 “顺序性” 要求极高的场景(如售票系统、任务排队调度),避免线程饥饿。
4. synchronized 中锁的对象可以是哪些?锁 String 常量、Integer 包装类有什么坑?
参考答案:
-
可作为锁的对象:任意非 null 对象(因为锁基于对象的 Monitor 实现,null 无对象头),常用:
- 自定义 Object 实例(推荐);
- this(当前实例);
- 类对象(如 Xxx.class)。
-
坑点(String 常量 / Integer 包装类):
- String 常量:String 常量池会复用相同字符串,不同线程可能意外共享同一把锁,导致非预期的互斥;
- Integer 包装类:Integer 缓存了 -128~127 的值,该范围内的 Integer 会复用对象,同样导致锁共享。
-
示例:
// 错误示例:String 常量作为锁
public class StringLockPitfall {
public void method1() {
synchronized ("lock") { // 常量池复用"lock"
System.out.println("method1 执行");
}
}
}
public class AnotherClass {
public void method2() {
synchronized ("lock") { // 与上面共享同一把锁,导致互斥
System.out.println("method2 执行");
}
}
} -
解决方案:使用 new Object () 作为锁对象,避免复用。
5.3 实战场景题(考察落地能力)
1. 如何优化高并发下 synchronized 的性能?
参考答案:
2. 线上系统中,ReentrantLock 忘记在 finally 中 unlock 会导致什么问题?如何排查?
参考答案:
-
问题:
- 若 try 块中抛出异常,锁无法释放,其他线程永远抢不到锁,导致业务卡死;
- 线程池中的核心线程持有锁,会导致线程池功能异常,任务堆积。
-
排查方法:
- 查看线程栈(jstack 进程 ID):阻塞的线程状态为 WAITING (parking),持有锁的线程状态为 RUNNABLE;
- 监控锁的获取 / 释放:通过 ReentrantLock 的 getHoldCount ()(加锁次数)、isLocked ()(是否锁定)方法排查;
- 日志排查:在 lock/unlock 处加日志,确认解锁逻辑是否执行。
-
解决方案:严格遵循 “lock () 在 try 前,unlock () 在 finally 中” 的规范。
3. 如何实现一个 “限时抢购” 的功能,要求高并发下库存修改线程安全,且读库存的性能尽可能高?
参考答案:
-
方案:使用读写锁(ReentrantReadWriteLock),读库存用读锁(并发读),修改库存用写锁(独占写)。
-
核心代码:
public class SeckillService {
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();
private final ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();
private int stock = 1000; // 库存// 读库存(高并发,读锁)
public int getStock() {
readLock.lock();
try {
return stock;
} finally {
readLock.unlock();
}
}// 扣减库存(写锁,保证原子性)
public boolean deductStock() {
writeLock.lock();
try {
if (stock > 0) {
stock—;
System.out.println("扣减库存成功,剩余:" + stock);
return true;
} else {
System.out.println("库存不足");
return false;
}
} finally {
writeLock.unlock();
}
}
} -
优化点:
- 写锁中增加库存校验,避免超卖;
- 结合线程池控制并发写的数量,避免写锁竞争过烈;
- 读库存可增加本地缓存,进一步降低读锁的使用频率。
核心总结(面试题关键)
四、并发工具深度解析
1. CountDownLatch(倒计时门闩)
1.1 核心原理与底层实现
CountDownLatch 基于 AQS(抽象队列同步器) 实现,其内部维护一个「共享状态变量」(对应倒计时数):
- 初始化时,将 AQS 的 state 设为指定的倒计时数;
- 调用 countDown() 时,通过 CAS 操作将 state 减 1,当 state 变为 0 时,唤醒所有阻塞在 AQS 等待队列中的线程;
- 调用 await() 时,当前线程会判断 state 是否为 0:若不为 0,则进入 AQS 等待队列阻塞,直到 state 为 0 被唤醒。
1.2 通俗理解
像一场马拉松比赛:裁判(等待线程)需等所有参赛选手(工作线程)都冲过终点线(执行 countDown()),才能统计最终成绩(等待线程继续执行),少一个选手冲线,裁判都要等。
1.3 完整实战代码(生产级)
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* CountDownLatch生产级示例:批量数据导入,等待所有导入线程完成后校验数据
* 核心:超时控制 + 异常处理 + 线程池复用
*/
public class CountDownLatchProductionDemo {
// 核心线程数=CPU核心数*2(IO密集型任务)
private static final int CORE_POOL_SIZE = Runtime.getRuntime().availableProcessors() * 2;
private static final ExecutorService executor = Executors.newFixedThreadPool(CORE_POOL_SIZE);
public static void main(String[] args) {
// 1. 初始化:5个数据分片导入任务
int taskCount = 5;
CountDownLatch latch = new CountDownLatch(taskCount);
// 数据导入结果标识
volatile boolean importSuccess = true;
// 2. 提交批量导入任务
for (int i = 0; i < taskCount; i++) {
int shardIndex = i;
executor.submit(() -> {
try {
System.out.println("开始导入第" + (shardIndex + 1) + "个数据分片");
// 模拟数据导入(可能抛出异常)
importData(shardIndex);
System.out.println("第" + (shardIndex + 1) + "个数据分片导入完成");
} catch (Exception e) {
importSuccess = false;
System.err.println("第" + (shardIndex + 1) + "个数据分片导入失败:" + e.getMessage());
} finally {
// 无论成功/失败,都要计数递减(避免永久阻塞)
latch.countDown();
}
});
}
// 3. 等待所有任务完成(超时时间3分钟,适配生产场景)
try {
System.out.println("等待所有数据分片导入完成…");
boolean awaitResult = latch.await(3, TimeUnit.MINUTES);
if (!awaitResult) {
importSuccess = false;
System.err.println("数据导入超时,部分分片未完成");
}
} catch (InterruptedException e) {
importSuccess = false;
System.err.println("主线程被中断,数据导入流程终止");
Thread.currentThread().interrupt(); // 恢复中断状态
}
// 4. 校验导入结果
if (importSuccess) {
System.out.println("所有数据分片导入成功,开始校验数据完整性");
validateData();
} else {
System.out.println("数据导入失败,执行回滚操作");
rollbackData();
}
// 5. 关闭线程池
executor.shutdown();
}
// 模拟数据导入(随机抛出异常)
private static void importData(int shardIndex) throws Exception {
Thread.sleep((long) (Math.random() * 5000));
// 模拟第3个分片导入失败
if (shardIndex == 2) {
throw new Exception("数据库连接超时");
}
}
// 模拟数据校验
private static void validateData() {
System.out.println("数据校验完成,所有数据一致");
}
// 模拟数据回滚
private static void rollbackData() {
System.out.println("数据回滚完成,恢复到导入前状态");
}
}
1.4 核心扩展点
(1)与 Thread.join () 的区别
- Thread.join():仅能等待单个线程结束,且需持有线程引用,不适合线程池场景(线程池线程复用,join 无意义);
- CountDownLatch:可等待任意数量线程完成,无需持有线程引用,适配线程池、批量任务场景。
(2)进阶用法:多阶段等待
// 阶段1:等待数据准备完成
CountDownLatch prepareLatch = new CountDownLatch(3);
// 阶段2:等待数据处理完成
CountDownLatch processLatch = new CountDownLatch(5);
// 阶段3:等待数据汇总完成
CountDownLatch summaryLatch = new CountDownLatch(2);
// 按阶段依次等待,实现复杂任务流程管控
(3)性能优化
- 避免创建过多 CountDownLatch 实例:可复用实例(需结合业务拆分阶段);
- 倒计时数不宜过大:建议按 “业务批次” 拆分(如 1000 个任务拆为 10 批,每批 100 个),减少 AQS 等待队列压力。
1.5 典型面试考点
-
问:CountDownLatch 的 countDown () 放在 finally 外会有什么问题?
答:若工作线程抛出异常,countDown () 未执行,state 无法归 0,等待线程会永久阻塞;生产环境必须将 countDown () 放在 finally 中。
-
问:如何实现 CountDownLatch 的重置功能?
答:CountDownLatch 本身不支持重置,可通过「创建新实例」或「自定义可重置的 CountDownLatch(基于 AQS 重写)」实现。
2. CyclicBarrier(循环屏障)
2.1 核心原理与底层实现
CyclicBarrier 基于 ReentrantLock(独占锁) + Condition(条件队列) 实现:
- 内部维护「等待线程数」和「屏障状态」,通过 ReentrantLock 保证计数线程安全;
- 线程调用 await() 时,先加锁,等待线程数 +1,若未达到屏障数,则调用 Condition.await () 阻塞;
- 当等待线程数达到屏障数时,执行回调任务(若有),然后调用 Condition.signalAll () 唤醒所有线程,并重置等待线程数(实现循环)。
2.2 通俗理解
像团队团建的 “接力挑战”:5 人一组完成 3 轮挑战,每轮都需 5 人全部到达挑战起点(屏障点),才能一起开始挑战;一轮完成后,下一轮重新等待 5 人集合,直到所有轮次结束。
2.3 完整实战代码(生产级)
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* CyclicBarrier生产级示例:多阶段数据清洗(采集→清洗→入库),每阶段需所有线程就绪
* 核心:循环屏障 + 屏障破损处理 + 任务重试
*/
public class CyclicBarrierProductionDemo {
private static final int THREAD_NUM = 4; // 4个数据处理线程
private static final ExecutorService executor = Executors.newFixedThreadPool(THREAD_NUM);
// 初始化屏障:4个线程 + 阶段完成回调
private static final CyclicBarrier barrier = new CyclicBarrier(THREAD_NUM, () -> {
System.out.println("\\n===== 当前阶段所有线程执行完成,进入下一阶段 =====");
});
public static void main(String[] args) {
// 定义3个处理阶段
String[] stages = {"数据采集", "数据清洗", "数据入库"};
// 提交4个处理线程
for (int i = 0; i < THREAD_NUM; i++) {
int threadId = i;
executor.submit(() -> {
try {
// 循环执行3个阶段(利用CyclicBarrier的循环特性)
for (String stage : stages) {
System.out.println("线程" + threadId + "开始" + stage + "阶段");
// 执行当前阶段任务(模拟耗时+随机异常)
executeStageTask(threadId, stage);
System.out.println("线程" + threadId + "完成" + stage + "阶段,等待其他线程");
// 到达屏障点(处理屏障破损异常)
try {
barrier.await();
} catch (BrokenBarrierException e) {
System.err.println("线程" + threadId + ":屏障破损,尝试重置并重新等待");
// 重置屏障(仅主线程重置,避免多线程冲突)
if (threadId == 0) {
barrier.reset();
}
// 重新等待(重试当前阶段)
barrier.await();
}
}
System.out.println("线程" + threadId + "所有阶段执行完成");
} catch (Exception e) {
System.err.println("线程" + threadId + "执行异常:" + e.getMessage());
}
});
}
// 关闭线程池
executor.shutdown();
}
// 执行阶段任务(模拟异常)
private static void executeStageTask(int threadId, String stage) throws InterruptedException {
Thread.sleep((long) (Math.random() * 3000));
// 模拟线程2在“数据清洗”阶段抛出异常,导致屏障破损
if (threadId == 2 && stage.equals("数据清洗")) {
throw new RuntimeException("数据格式错误");
}
}
}
2.4 核心扩展点
(1)屏障破损(BrokenBarrierException)的处理
-
原因:线程中断、超时、抛出异常,导致屏障无法正常触发;
-
解决方案:
- 捕获 BrokenBarrierException,调用 barrier.reset() 重置屏障;
- 对异常线程执行任务重试,避免整体流程中断;
- 生产环境建议为 await() 设置超时,防止个别线程阻塞导致屏障永久破损。
(2)与 CountDownLatch 的核心差异(面试高频)
| 复用性 | 一次性,计数归 0 后失效 | 可循环,触发后自动重置计数 |
| 线程协作方式 | 单向等待(等待方等工作方) | 双向等待(线程互相等待) |
| 底层实现 | AQS(共享模式) | ReentrantLock + Condition |
| 回调能力 | 无 | 支持屏障触发后执行回调任务 |
| 异常影响 | 仅影响未执行 countDown () 的线程 | 单个线程异常会导致屏障 “破损” |
(3)进阶用法:动态调整屏障数
CyclicBarrier 不支持动态修改屏障数,可通过「自定义 CyclicBarrier」或「拆分多个屏障」实现:
// 场景:根据任务量动态调整屏障数
int dynamicParties = getDynamicTaskCount() > 10 ? 10 : getDynamicTaskCount();
CyclicBarrier dynamicBarrier = new CyclicBarrier(dynamicParties);
2.5 典型面试考点
-
问:CyclicBarrier 的 reset () 方法有什么作用?
答:reset () 会重置屏障状态:① 将等待线程数清零;② 清除屏障的 “破损” 状态;③ 唤醒所有阻塞线程(抛出 BrokenBarrierException),适用于屏障破损后的恢复。
-
问:CyclicBarrier 的回调任务由哪个线程执行?
答:由最后一个到达屏障点的线程执行,需保证回调任务逻辑简单,避免阻塞其他线程。
3. Semaphore(信号量)
3.1 核心原理与底层实现
Semaphore 基于 AQS(抽象队列同步器) 实现,其核心是「许可证数量」(对应 AQS 的 state 变量):
- 公平模式:线程按排队顺序获取许可证,AQS 等待队列按 FIFO 顺序唤醒;
- 非公平模式(默认):线程尝试直接 CAS 获取许可证,失败后进入等待队列,可能插队(吞吐量更高);
- acquire():尝试将 state 减 1,若 state < 0 则阻塞;release():将 state 加 1,唤醒等待队列线程。
3.2 通俗理解
像高速路的 “收费站”:收费站有 10 个收费口(许可证数量 = 10),每辆车(线程)需进入收费口才能上高速;最多同时通过 10 辆车,超出的车需在收费站排队,有收费口空闲后依次通过。
3.3 完整实战代码(生产级)
import java.util.concurrent.Semaphore;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* Semaphore生产级示例:Redis连接池限流,控制最多8个并发连接
* 核心:公平模式 + 超时获取 + 许可证归还校验 + 连接池监控
*/
public class SemaphoreProductionDemo {
// Redis连接池最大连接数(许可证数量)
private static final int MAX_CONNECTIONS = 8;
// 公平模式:保证线程按顺序获取连接,避免饥饿
private static final Semaphore semaphore = new Semaphore(MAX_CONNECTIONS, true);
private static final ExecutorService executor = Executors.newFixedThreadPool(20);
public static void main(String[] args) {
// 模拟20个并发请求获取Redis连接
for (int i = 0; i < 20; i++) {
int requestId = i;
executor.submit(() -> {
// 声明连接对象(模拟)
Object redisConn = null;
try {
// 1. 超时获取许可证(3秒超时,避免永久阻塞)
boolean acquireSuccess = semaphore.tryAcquire(3, TimeUnit.SECONDS);
if (!acquireSuccess) {
System.err.println("请求" + requestId + "获取Redis连接超时,触发降级策略");
// 执行降级逻辑(如读取本地缓存)
fallbackToLocalCache(requestId);
return;
}
// 2. 获取连接并执行业务逻辑
redisConn = getRedisConnection();
System.out.println("请求" + requestId + "获取Redis连接成功,剩余连接数:" + semaphore.availablePermits());
executeRedisOperation(requestId, redisConn);
} catch (InterruptedException e) {
System.err.println("请求" + requestId + "被中断,放弃获取Redis连接");
Thread.currentThread().interrupt();
} finally {
// 3. 归还许可证 + 释放连接(必须放finally)
if (redisConn != null) {
releaseRedisConnection(redisConn);
semaphore.release();
System.out.println("请求" + requestId + "释放Redis连接,剩余连接数:" + semaphore.availablePermits());
}
}
});
}
// 4. 监控连接池状态(定时打印)
new Thread(() -> {
while (true) {
try {
Thread.sleep(1000);
System.out.println("\\n【连接池监控】剩余连接数:" + semaphore.availablePermits() +
",等待线程数:" + (20 – semaphore.availablePermits() – semaphore.getQueueLength()));
} catch (InterruptedException e) {
break;
}
}
}).start();
// 关闭线程池
executor.shutdown();
}
// 模拟获取Redis连接
private static Object getRedisConnection() {
return new Object();
}
// 模拟执行Redis操作
private static void executeRedisOperation(int requestId, Object conn) throws InterruptedException {
Thread.sleep((long) (Math.random() * 2000));
}
// 模拟释放Redis连接
private static void releaseRedisConnection(Object conn) {
// 释放连接逻辑
}
// 模拟降级逻辑(读取本地缓存)
private static void fallbackToLocalCache(int requestId) {
System.out.println("请求" + requestId + "读取本地缓存完成");
}
}
3.4 核心扩展点
(1)多许可证获取 / 归还(批量资源控制)
// 场景:某任务需占用2个Redis连接(批量操作)
semaphore.acquire(2); // 获取2个许可证
// 执行批量操作
semaphore.release(2); // 归还2个许可证
// 注意:acquire和release的数量必须一致,否则会导致许可证数量异常
(2)公平 vs 非公平模式选型
- 公平模式:线程按 FIFO 顺序获取许可证,无饥饿,但吞吐量低(需维护队列),适用于秒杀、排队等场景;
- 非公平模式:允许线程插队,吞吐量高,但可能导致个别线程饥饿,适用于普通接口限流、资源池管理。
(3)与线程池的协同使用
线程池控制 “最大线程数”,Semaphore 控制 “最大并发资源数”,双重限流更安全:
// 线程池:最多20个线程
ExecutorService executor = Executors.newFixedThreadPool(20);
// Semaphore:最多8个并发连接
Semaphore semaphore = new Semaphore(8);
// 效果:20个线程竞争8个连接,避免连接池过载
(4)常见坑点与解决方案
| 超额释放许可证 | 严格保证 release () 与 acquire () 数量一致,可封装工具类校验 |
| 未释放许可证 | 将 release () 放在 finally 中,结合资源对象非空校验 |
| 永久阻塞 | 优先使用 tryAcquire (long, TimeUnit) 超时获取 |
| 公平模式性能低 | 非核心场景用非公平模式,核心场景通过 “队列长度监控” 优化 |
3.5 典型面试考点
-
问:Semaphore 的 tryAcquire () 和 acquire () 的区别?
答:
① acquire()是阻塞式获取,无许可证则一直等,响应中断;
② tryAcquire()是非阻塞式获取,无许可证直接返回 false,可避免线程阻塞;
③ 生产环境优先用 tryAcquire(long, TimeUnit),兼顾阻塞和超时控制。
-
问:如何用 Semaphore 实现限流器?
答:
- 初始化 Semaphore 为限流阈值(如 100 QPS);
- 每个请求先调用 tryAcquire(),成功则处理请求,失败则返回限流;
- 结合定时任务动态调整许可证数量(如高峰期增加阈值)。
4. 三大并发工具综合对比与工程实践
4.1 核心能力全景对比
| 核心目标 | 等待多线程完成 | 线程组同步执行 | 控制并发数 |
| 复用性 | 一次性 | 可循环 | 可重复使用 |
| 异常处理 | 计数未归 0 导致等待线程阻塞 | 线程异常导致屏障破损 | 未释放许可证导致资源泄漏 |
| 性能 | 高(AQS 共享模式) | 中(ReentrantLock + Condition) | 高(AQS 共享模式) |
| 适用场景 | 一次性批量任务等待 | 多阶段循环任务同步 | 资源限流、并发控制 |
| 扩展能力 | 无回调,需手动实现多阶段 | 支持回调,可循环 | 支持公平 / 非公平,多许可证控制 |
4.2 工程实践最佳实践
(1)统一避坑准则
(2)选型决策树
(3)生产环境性能优化
4.3 面试高频综合题
-
问:如何用 CountDownLatch + Semaphore 实现一个高并发的批量任务处理系统?
答:
-
Semaphore 控制同时执行的任务数(如 50 个),避免系统过载;
-
CountDownLatch 等待所有批量任务完成,汇总结果;
-
流程:
- 初始化 Semaphore (50)、CountDownLatch (1000);
- 提交 1000 个任务,每个任务先 acquire () 许可证,执行后 countDown () 并 release () 许可证;
- 主线程 await () 所有任务完成,汇总结果。
-
问:CyclicBarrier 相比 CountDownLatch 更适合什么场景?举例说明。
答:
适合 “多阶段、可循环” 的任务同步场景,如大数据处理的 “采集→清洗→入库→汇总” 四阶段流程:
- 每个阶段都需所有线程就绪后执行,CyclicBarrier 可循环使用,无需创建多个 CountDownLatch;
- 每个阶段完成后可执行回调任务(如数据校验),简化流程管控。
五、volatile
volatile 是 Java 中的轻量级同步关键字,只能修饰变量(不能修饰方法、类),核心作用是解决多线程环境下的“变量可见性”和“指令重排”问题,但无法保证原子性,是并发编程中入门级的同步手段,性能优于 synchronized(无需阻塞线程)。
关键定位:适合“单线程写、多线程读”的场景(比如标记位、状态变量),不能单独用于解决多线程修改共享变量的安全问题。
1. 核心特性一:保证可见性
什么是可见性?
通俗说:当一个线程修改了 volatile 修饰的变量,其他线程能立即看到该变量的最新值,不会读取到自己本地缓存中的“旧值”。
举个反例(无 volatile,无可见性):
// 全局变量(无 volatile 修饰)
private static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
// 线程1:修改 flag 的值
new Thread(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true; // 线程1修改了flag,但线程2可能看不到
System.out.println("线程1:flag 已改为 true");
}).start();
// 线程2:读取 flag 的值
new Thread(() -> {
while (!flag) {
// 如果看不到 flag 的修改,会一直死循环
}
System.out.println("线程2:读取到 flag 为 true,退出循环");
}).start();
}
运行结果:线程1会输出,但线程2可能一直死循环——因为线程1修改的 flag 只存在自己的本地缓存,没有同步到主内存,线程2一直读取自己的本地缓存(旧值 false)。
volatile 如何保证可见性?
当变量被 volatile 修饰后,会触发两个操作,保证可见性:
- 线程修改 volatile 变量时,会立即将修改后的值刷新到主内存(不再存放在线程本地缓存);
- 其他线程读取该变量时,会强制从主内存读取最新值(不再使用自己本地缓存中的旧值)。
修改上面的示例(给 flag 加 volatile):private static volatile boolean flag = false;,运行后线程2会立即读取到 flag 的最新值,正常退出循环。
2. 核心特性二:禁止指令重排
什么是指令重排?
JVM 为了提高程序运行效率,在不影响单线程执行结果的前提下,会对代码的执行顺序进行“重新排列”(编译期、运行期都可能发生)。
通俗说:你写的代码顺序是 A→B→C,但 JVM 可能改成 B→A→C(只要单线程下结果不变),单线程下毫无问题,但多线程下会出bug。
指令重排的多线程问题(示例)
经典场景:双重检查锁单例模式(无 volatile 时的隐患):
public class Singleton {
// 无 volatile 修饰
private static Singleton instance;
private Singleton() {}
// 双重检查锁
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 问题出在这里
}
}
}
return instance;
}
}
问题解析:instance = new Singleton(); 看似是一步操作,实际被 JVM 拆分为3步:
JVM 可能进行指令重排,改成 1→3→2:此时 instance 已经不为 null,但对象还未初始化完成;如果另一个线程刚好进入第一次检查(instance != null),会直接返回一个“未初始化完成的对象”,导致程序报错。
volatile 如何禁止指令重排?
volatile 会在修饰的变量前后,插入“内存屏障”(一种特殊的指令),内存屏障的作用是:阻止屏障前后的指令被 JVM 重排,保证代码的执行顺序与你编写的顺序完全一致。
解决上面的单例问题:给 instance 加 volatile 修饰 private static volatile Singleton instance;,禁止 1→3→2 的重排,确保对象初始化完成后,instance 才会指向内存空间,避免多线程隐患。
3. 核心特性三:不保证原子性
什么是原子性?
原子性:一个操作(可能是多步),要么全部执行完成,要么全部不执行,中间不会被其他线程打断。
比如:i = 1; 是原子操作(一步完成);但 i++;不是原子操作(拆分为 读取i → 计算i+1 → 赋值给i 三步)。
volatile 为什么不保证原子性?(示例验证)
示例:多线程执行 i++(i 用 volatile 修饰),看是否会出现线程安全问题:
public class VolatileAtomicTest {
// volatile 修饰的变量 i
private static volatile int i = 0;
// 线程任务:执行 1000 次 i++
private static void increment() {
for (int j = 0; j < 1000; j++) {
i++;
}
}
public static void main(String[] args) throws InterruptedException {
// 启动10个线程,每个线程执行1000次 i++
Thread[] threads = new Thread[10];
for (int k = 0; k < 10; k++) {
threads[k] = new Thread(VolatileAtomicTest::increment);
threads[k].start();
}
// 等待所有线程执行完毕
for (Thread thread : threads) {
thread.join();
}
// 预期结果:10 * 1000 = 10000
System.out.println("最终 i 的值:" + i);
}
}
运行结果:几乎每次都小于 10000(比如 9876、9953),说明出现了线程安全问题——volatile 无法保证 i++ 的原子性。
问题原因
i++ 是三步操作(读→算→写),volatile 只能保证“读”和“写”的可见性,但无法保证这三步操作的“原子性”:
比如:线程1读取 i=100,计算 i+1=101;此时线程2也读取 i=100(因为线程1还没写完),计算 i+1=101;然后线程1写入 101,线程2也写入 101——两次 i++,最终只加了1,出现计数偏差。
解决方案(两种常用方式)
- 方式1:加锁(synchronized 或 ReentrantLock),将 i++ 操作变成原子操作(阻止其他线程打断);
- 方式2:使用原子类(AtomicInteger),其内部基于 CAS 实现原子操作,无需手动加锁,示例修改:
// 替换 volatile int i = 0;
private static AtomicInteger i = new AtomicInteger(0);
// 替换 i++
private static void increment() {
for (int j = 0; j < 1000; j++) {
i.incrementAndGet(); // AtomicInteger 的原子自增方法
}
}
4. 基础小结(快速记忆)
- volatile 三大特性:保可见、禁重排、不原子;
- 适用场景:单线程写、多线程读(比如状态标记位、停止线程的标记);
- 易踩坑点:不要用 volatile 解决多线程修改共享变量的问题(比如 i++),需配合锁或原子类;
- 核心关联:volatile 是轻量级同步手段,性能优于锁,但功能更单一(只解决可见性和重排,不解决原子性)。
进阶提升
1. volatile 底层原理进阶:深入内存屏障
内存屏障(Memory Barrier):属于 JVM 层面的指令级屏障,无具体 API,看不见摸不着,作用是 “禁止指令重排” 和 “保证内存可见性”,解决的是 “多线程下指令执行顺序错乱” 和 “变量缓存不一致” 的问题。
1.1 内存屏障的4种类型(JVM层面)
JVM 定义了4种内存屏障,volatile 只用到其中2种,核心作用是“禁止重排”和“刷新内存”:
- LoadLoad 屏障:禁止读操作重排(比如:先读a,再读b,屏障保证a的读在b之前);
- StoreStore 屏障:禁止写操作重排(比如:先写a,再写b,屏障保证a的写在b之前);
- LoadStore 屏障:禁止读操作和后续写操作重排;
- StoreLoad 屏障:禁止写操作和后续读操作重排(最核心,volatile 主要依赖它)。
1.2 volatile 变量的内存屏障插入规则
当变量被 volatile 修饰时,JVM 会在变量的“写操作”和“读操作”前后插入屏障,具体规则:
- 写操作后:插入 StoreLoad 屏障 → 保证写操作完成后,再执行后续的读/写操作(刷新主内存,保证可见性);
- 读操作前:插入 LoadLoad 屏障 → 保证读操作之前,所有的读操作都已完成(禁止重排,保证读取到最新值)。
通俗理解:屏障相当于“隔离带”,把 volatile 变量的读写操作与其他操作隔离开,不让 JVM 随意重排,同时强制刷新内存,保证可见性。
1.3 volatile 与 CPU 缓存一致性协议(补充)
底层延伸(面试可选):volatile 的可见性,本质也依赖 CPU 的“缓存一致性协议”(比如 MESI 协议):
当一个 CPU 核心修改了 volatile 变量(写入主内存),会通过 MESI 协议通知其他 CPU 核心,让它们缓存中的该变量副本“失效”;其他 CPU 核心读取该变量时,发现副本失效,就会重新从主内存读取最新值——这也是可见性的底层硬件支撑。
2. 高频面试题
整理 volatile 最常考的6道面试题,覆盖基础+进阶,适配校招/初阶开发面试,答案简洁精准,避免冗余。
面试题1:volatile 关键字的作用是什么?(基础必考题)
标准答案:volatile 是 Java 轻量级同步关键字,只能修饰变量,核心作用有3点:
面试题2:volatile 为什么不保证原子性?如何解决?(高频易考题)
标准答案:
- 不保证原子性的原因:volatile 只能保证读写操作的可见性,但无法保证多步操作(比如 i++,拆分为读、算、写三步)的原子性,多线程下会出现“读取-计算-赋值”的交叉执行,导致结果偏差;
- 解决方案:① 加锁(synchronized 或 ReentrantLock),将多步操作转为原子操作;② 使用 Atomic 原子类(如 AtomicInteger),基于 CAS 实现原子操作,无需手动加锁。
面试题3:volatile 与 synchronized 的区别?(核心对比题)
标准答案(4点核心区别,简洁好记):
| 修饰对象 | 只能修饰变量 | 可修饰方法、代码块、类 |
| 原子性 | 不保证 | 保证 |
| 可见性 | 保证 | 保证(释放锁时刷新主内存) |
| 有序性 | 保证(禁止重排) | 保证(单线程执行+锁竞争) |
| 性能 | 轻量级,无阻塞,性能高 | 重量级(JDK1.8优化后有提升),可能阻塞,性能略低 |
面试题4:双重检查锁单例模式中,为什么要用 volatile 修饰 instance?(实战场景题)
标准答案:核心是禁止 instance = new Singleton() 的指令重排:
new 操作会被 JVM 拆分为“分配内存→初始化对象→引用指向内存”三步,若不加 volatile,JVM 可能重排为“分配内存→引用指向内存→初始化对象”;此时 instance 已不为 null,但对象未初始化完成,其他线程第一次检查时会返回未初始化的对象,导致程序报错;加 volatile 后,禁止该重排,确保对象初始化完成后,引用才会指向内存,避免多线程隐患。
面试题5:volatile 能替代锁吗?为什么?(反问高频题)
标准答案:不能替代。原因:
volatile 只保证可见性和禁止重排,不保证原子性;而锁(synchronized/ReentrantLock)既能保证可见性、有序性,也能保证原子性。对于多线程修改共享变量的场景(比如 i++、多步赋值),volatile 无法解决线程安全问题,必须用锁或原子类;只有“单线程写、多线程读”的场景,volatile 才能替代锁(提升性能)。
面试题6:volatile 修饰引用类型变量,能保证引用指向的对象内部属性的可见性吗?(进阶题)
标准答案:不能。volatile 修饰引用类型时,只能保证“引用本身”的可见性和禁止重排(即引用指向的地址变化能被其他线程立即看到),但无法保证引用指向的对象内部属性的可见性。
示例:volatile User user = new User(); 若线程1修改 user.setName(“张三”),线程2读取 user.getName(),可能读取到旧值——因为 volatile 只管 user 这个引用,不管 User 对象内部的 name 属性;解决:要么给 name 加 volatile,要么给 set/get 方法加锁。
3. 常见易错点
整理新手学习、使用 volatile 时最容易踩的5个坑,结合场景说明错误原因和正确做法,避免重复踩坑。
易错点1:认为 volatile 能保证原子性,用它解决多线程计数问题
错误示例:用 volatile int i 实现多线程自增(如入门版中的 i++ 示例),认为加了 volatile 就不会有线程安全问题;
错误原因:混淆了“可见性”和“原子性”,volatile 只能保证读写可见,无法保证 i++ 三步操作的原子性;
正确做法:用 AtomicInteger 原子类,或给 i++ 加锁(synchronized/ReentrantLock)。
易错点2:volatile 修饰引用类型,认为能保证对象内部属性的可见性
错误示例:volatile User user = new User(); 线程1修改 user 的属性,线程2读取属性,依赖 volatile 保证可见性;
错误原因:volatile 只作用于“引用本身”,不作用于引用指向的对象内部;
正确做法:给对象内部的属性单独加 volatile,或对属性的读写操作加锁。
易错点3:在多线程写、多线程读的场景,单独使用 volatile
错误示例:多个线程同时修改 volatile 修饰的变量(如多线程同时执行 i++),认为能保证线程安全;
错误原因:volatile 不保证原子性,多线程写会出现交叉执行,导致结果偏差;
正确做法:volatile 只适合“单线程写、多线程读”,多线程写必须配合锁或原子类。
易错点4:认为 volatile 能禁止所有指令重排
错误示例:认为加了 volatile,整个方法的指令都不会被重排;
错误原因:volatile 只能禁止“屏障前后”的指令重排,不影响屏障之外的指令重排(只要不影响单线程结果);
正确认知:volatile 只保证“自身相关的指令”不被重排,不保证全局指令不重排。
易错点5:替代 synchronized 时,忽略 volatile 的适用场景
错误示例:用 volatile 替代 synchronized 修饰方法,认为能提升性能且保证线程安全;
错误原因:synchronized 能保证原子性,volatile 不能,替代后会出现线程安全问题;
正确做法:只有“单线程写、多线程读”的场景,才能用 volatile 替代 synchronized(提升性能),其他场景必须用锁。
4. 实战解决方案
结合实际开发场景,整理3种常见场景的解决方案,适配 volatile 的正确使用,避免踩坑。
场景1:单线程写、多线程读(状态标记位/停止线程)
适用场景:线程1修改状态标记,线程2/3/4 读取状态并执行对应逻辑(如停止线程、开关控制);
解决方案:用 volatile 修饰标记位,无需加锁,提升性能;
实战示例(停止线程):
public class VolatileStopThread {
// volatile 修饰停止标记(单线程写,多线程读)
private static volatile boolean stop = false;
public static void main(String[] args) throws InterruptedException {
// 线程1:执行任务,直到 stop 为 true
Thread taskThread = new Thread(() -> {
int i = 0;
while (!stop) {
i++;
// 执行任务逻辑
}
System.out.println("线程停止,执行次数:" + i);
});
taskThread.start();
// 主线程(单线程写):3秒后修改 stop,停止任务线程
Thread.sleep(3000);
stop = true;
}
}
场景2:多线程修改共享变量(计数/累加)
适用场景:多个线程同时对一个变量进行自增、累加操作(如接口调用计数、任务执行次数统计);
解决方案:不用 volatile 单独解决,推荐用 Atomic 原子类(简单高效),或加锁(复杂场景);
实战示例(AtomicInteger 计数):
public class AtomicCountExample {
// 用 AtomicInteger 替代 volatile int,保证原子性
private static AtomicInteger count = new AtomicInteger(0);
// 原子自增方法
private static void increment() {
count.incrementAndGet(); // 原子操作,无需加锁
}
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[20];
for (int i = 0; i < 20; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 500; j++) {
increment();
}
});
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println("最终计数:" + count.get()); // 预期 20*500=10000
}
}
场景3:双重检查锁单例模式(线程安全+高效)
适用场景:单例对象的创建(需保证线程安全,且避免频繁加锁影响性能);
解决方案:双重检查锁 + volatile 修饰单例引用,禁止指令重排,保证线程安全;
实战示例(正确的双重检查锁单例):
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;
}
}
整体总结
六、CAS、ABA问题与Atomic类
1. CAS
什么是CAS?
CAS 是一种无锁同步机制(无需像synchronized那样阻塞线程),核心思想是“先比较、再交换”——通过三个核心参数,判断共享变量是否被其他线程修改,若未被修改,则执行修改操作;若已被修改,则放弃本次操作(可选择重试),本质是“乐观地认为不会有并发冲突,冲突后再重试”。
对比理解(帮你衔接之前知识):
- synchronized:悲观锁,认为一定会有并发冲突,直接阻塞其他线程,直到当前线程释放锁(重量级,性能略低);
- CAS:乐观锁,认为大概率不会有并发冲突,不阻塞线程,通过“比较-交换”验证,冲突后重试(轻量级,性能高)。
CAS 的三个核心参数(必记)
CAS 操作必须包含三个参数,缺一不可,核心逻辑围绕这三个参数展开:
- 内存地址 V:存储共享变量的内存地址(对应JVM中的主内存地址,存储着共享变量的最新值);
- 预期值 A:当前线程读取到的共享变量的值(线程从主内存读取到本地缓存的值,认为这是当前变量的“真实值”);
- 新值 B:当前线程想要将共享变量修改为的值(线程计算后的新结果)。
CAS 的执行流程:
CAS 的执行逻辑非常简单,全程无阻塞,核心是“比较”和“交换”两个步骤,具体流程如下:
1. 线程从主内存的地址 V 中,读取共享变量的值,存入本地缓存,记为预期值 A;
2. 线程对预期值 A 进行计算(比如自增、赋值等),得到想要修改的新值 B;
3. 线程再次访问主内存,对比地址 V 中的当前值,与自己本地的预期值 A 是否相等;
4. ·若相等:说明从线程读取A到准备修改期间,没有其他线程修改过该变量,直接将新值 B 写入内存地址 V(修改成功);
5. ·若不相等:说明有其他线程修改过该变量,当前线程的预期值 A 已经“过时”,放弃本次修改操作(修改失败);
6. 修改失败后,线程可选择“重试”(重新读取A、计算B、再次执行CAS),或“直接放弃”(根据业务场景决定)。
CAS 的示例验证
Java 中没有直接暴露CAS的API(底层由Unsafe类实现,不推荐直接使用),但我们可以通过模拟CAS逻辑,理解其核心流程(实际开发中无需写这个,直接用Atomic类即可):
/**
* 模拟CAS操作(仅用于理解原理,实际开发不用写)
*/
public class CASDemo {
// 共享变量(内存地址V对应的变量)
private static int value = 0;
/**
* CAS核心方法
* @param expect 预期值A
* @param update 新值B
* @return true:修改成功;false:修改失败
*/
public static boolean compareAndSwap(int expect, int update) {
// 1. 读取内存地址V中的当前值(这里直接读取value,模拟主内存读取)
int currentValue = value;
// 2. 比较:预期值A(expect)与当前值(currentValue)是否相等
if (currentValue == expect) {
// 3. 相等:将新值B(update)写入内存地址V
value = update;
return true;
}
// 4. 不相等:修改失败
return false;
}
public static void main(String[] args) {
// 线程1:尝试将value从0修改为1
boolean success1 = compareAndSwap(0, 1);
System.out.println("线程1修改结果:" + success1 + ",修改后value:" + value); // true,1
// 线程2:尝试将value从0修改为2(预期值A=0已过时,修改失败)
boolean success2 = compareAndSwap(0, 2);
System.out.println("线程2修改结果:" + success2 + ",修改后value:" + value); // false,1
// 线程3:尝试将value从1修改为2(预期值A=1与当前值相等,修改成功)
boolean success3 = compareAndSwap(1, 2);
System.out.println("线程3修改结果:" + success3 + ",修改后value:" + value); // true,2
}
}
运行结果和注释一致,能直观看到:CAS只在“预期值与内存当前值相等”时才会修改成功,完美避免了多线程修改的冲突(本质是保证了“读-算-写”的原子性)。
CAS 的优缺点
优点:
- 无锁机制,不阻塞线程,并发性能优于synchronized(尤其在“读多写少”“冲突概率低”的场景);
- 底层由CPU硬件指令支持(如x86的cmpxchg指令),执行效率高;
- 解决了volatile“不保证原子性”的问题,可实现原子操作。
缺点:
- 存在ABA问题(核心缺点,后续重点讲解);
- 自旋重试消耗CPU资源:若并发冲突频繁,线程会一直重试(自旋),占用大量CPU;
- 只能保证单个变量的原子操作,无法保证多个变量的组合操作(比如同时修改两个共享变量,CAS无法保证原子性)。
2. ABA 问题
什么是ABA问题?
ABA问题是CAS的核心隐患,本质是CAS仅对比“值是否相等”,无法判断“值是否被修改过”。当共享变量从A变为B、再从B变回A时,CAS会误判“值未修改”并执行更新,进而导致逻辑错误(尤其依赖“值的状态变化”的场景)。
ABA 问题的具体场景
结合多线程操作共享变量,拆解ABA问题全过程,直观理解危害:
1. 初始状态:共享变量value(内存地址V)的值为A(A=10);
2. 线程1:读取value=A(预期值10),拟修改为C(30),随即被阻塞;
3. 线程2:读取value=A,通过CAS将其改为B(20),操作成功;
4. 线程2:再次通过CAS将value从B改回A(10),操作成功;
5. 线程1:解除阻塞,CAS对比发现value仍为10(与预期一致),误判未修改,将其改为C(30)。
核心问题:线程1误以为value始终是A,实则已被线程2修改(A→B→A)。CAS只关注“最终值”,忽略“中间修改过程”,若业务需关注状态变化,便会出错。
ABA 问题的实际危害(真实业务场景)
以简化版银行转账为例,看ABA问题的实际影响:
– 你的账户余额(共享变量)为100元(A=100);
– 你(线程1)发起转账:读取余额100→验证可转→拟通过CAS改为0;
– 期间朋友(线程2)给你转100元,余额变为0→再变回100(A→B→A);
– 线程1解除阻塞,CAS误判余额未变,执行转账将其改为0;
– 危害:原本仅转100元,最终连朋友转的100元也被转走,出现业务错误。
提醒:仅关注“最终值是否正确”的场景(如简单计数),ABA问题无危害;若需关注“值的状态变化”,则必须解决。
ABA 问题的解决方案(两种常用方式,重点掌握)
方案1:版本号机制(核心方案,推荐)
核心思想:给共享变量绑定“版本号”,每次修改变量时,同步将版本号自增1;CAS操作时,同时对比“变量值”和“版本号”,两者均一致才执行更新。
用版本号解决上述ABA场景,流程如下:
1. 初始状态:value=10(A=10),版本号=1;
2. 线程1:读取value=10、版本号=1,拟修改为30;
3. 线程2:读取value=10、版本号=1,CAS将其改为20、版本号=2;
4. 线程2:再次CAS将value改为10、版本号=3;
5. 线程1:执行CAS,value=10(一致)但版本号=1≠3(当前),修改失败;
6. 线程1重试:读取最新value=10、版本号=3,重新执行CAS,避免错误。
关键:版本号自增不重复,即便变量值回滚,版本号也会变化,CAS可精准判断中间是否被修改。
方案2:时间戳机制(与版本号原理一致)
核心思想:用“时间戳”替代版本号,每次修改变量时记录当前精确时间戳;CAS同时对比“变量值”和“时间戳”,两者一致才更新。
适用场景:需记录“修改时间”的业务(如订单状态修改),既能解决ABA问题,又能留存修改时间记录。
ABA问题是CAS操作的核心隐患,本质是“CAS只比较‘值是否相等’,无法判断‘值是否被修改过’”——一个共享变量的值从A变成B,再从B变回A,CAS会认为“值没有被修改过”,从而执行修改操作,导致逻辑错误(尤其在有“值的状态关联”的场景)。
Java 中的实现:AtomicStampedReference(重点)
Java 已经基于“版本号机制”,封装了AtomicStampedReference类,专门用于解决ABA问题,无需手动实现版本号逻辑,直接调用API即可,示例如下:
import java.util.concurrent.atomic.AtomicStampedReference;
// AtomicStampedReference 解决ABA问题示例
public class ABAProblemSolution {
public static void main(String[] args) {
// 1. 初始化:参数1=初始值(A=10),参数2=初始版本号(1)
AtomicStampedReference<Integer> atomicStampedRef = new AtomicStampedReference<>(10, 1);
// 线程1:模拟ABA问题中的“原操作线程”
new Thread(() -> {
// 获取当前值和版本号(预期值A=10,预期版本号=1)
int expectValue = atomicStampedRef.getReference();
int expectStamp = atomicStampedRef.getStamp();
int newStamp = expectStamp + 1; // 新版本号
int newValue = 30; // 新值
try {
// 阻塞1秒,让线程2先执行修改(模拟线程1被阻塞)
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// CAS操作:同时比较“值”和“版本号”
boolean success = atomicStampedRef.compareAndSet(
expectValue, // 预期值
newValue, // 新值
expectStamp, // 预期版本号
newStamp // 新版本号
);
System.out.println("线程1修改结果:" + success +
",当前值:" + atomicStampedRef.getReference() +
",当前版本号:" + atomicStampedRef.getStamp());
}, "线程1").start();
// 线程2:模拟修改值(A→B→A)
new Thread(() -> {
// 第一次修改:10→20,版本号1→2
atomicStampedRef.compareAndSet(10, 20, 1, 2);
System.out.println("线程2第一次修改:值=20,版本号=2");
// 第二次修改:20→10,版本号2→3
atomicStampedRef.compareAndSet(20, 10, 2, 3);
System.out.println("线程2第二次修改:值=10,版本号=3");
}, "线程2").start();
}
}
运行结果:
结论:线程1因为“版本号不匹配”(预期版本号1≠当前版本号3),修改失败,成功解决了ABA问题。
3. Atomic 原子类
什么是Atomic类?(衔接CAS原理)
Atomic类是Java java.util.concurrent.atomic包下的原子操作工具类,底层基于CAS原理实现,无需手动编写CAS逻辑,也无需加锁(synchronized/ReentrantLock),直接调用方法即可保证共享变量操作的原子性,完美解决了volatile“不保证原子性”的问题。
核心优势:简单、高效、线程安全——比synchronized性能高(无阻塞),比手动实现CAS简单(无需关注底层细节)。
常用Atomic类分类(3类核心,重点掌握前2类)
Atomic类围绕“不同数据类型”和“不同场景”封装,无需全部记忆,重点掌握以下3类常用的,能覆盖90%的实战场景:
用于解决“基本类型变量(int、long、boolean)”的原子操作问题,替代volatile+锁的组合,核心类3个:
| AtomicInteger | int | get()、set()、incrementAndGet()、decrementAndGet()、addAndGet(int delta) | 原子自增、自减、累加,解决i++线程安全问题 |
| AtomicLong | long | get()、set()、incrementAndGet()、decrementAndGet()、addAndGet(long delta) | long类型原子操作(比如统计接口调用次数) |
| AtomicBoolean | boolean | get()、set()、compareAndSet(boolean expect, boolean update) | boolean类型原子判断+修改(比如状态标记位) |
实战示例(AtomicInteger解决i++线程安全问题,对比volatile):
import java.util.concurrent.atomic.AtomicInteger;
//AtomicInteger 实战示例(解决多线程自增安全问题)
public class AtomicIntegerDemo {
// 用AtomicInteger替代volatile int,保证原子性
private static AtomicInteger count = new AtomicInteger(0);
// 原子自增方法(无需加锁)
private static void increment() {
// incrementAndGet():原子自增,返回自增后的值
count.incrementAndGet();
}
public static void main(String[] args) throws InterruptedException {
// 启动20个线程,每个线程执行500次自增
Thread[] threads = new Thread[20];
for (int i = 0; i < 20; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 500; j++) {
increment();
}
});
threads[i].start();
}
// 等待所有线程执行完毕
for (Thread thread : threads) {
thread.join();
}
// 预期结果:20 * 500 = 10000(无线程安全问题)
System.out.println("最终计数:" + count.get());
}
}
运行结果:每次都输出10000,完美解决了volatile修饰int时i++的线程安全问题(对比之前volatile的示例,无需加锁,简单高效)。
用于解决“引用类型变量”的原子操作问题,其中AtomicStampedReference专门解决ABA问题,核心类3个:
- AtomicReference:普通引用类型原子类,用于引用对象的原子修改(不解决ABA问题);
- AtomicStampedReference:带版本号的引用类型原子类,核心用于解决ABA问题(前面讲解ABA解决方案时的示例);
- AtomicMarkableReference:带标记的引用类型原子类,用boolean标记“是否被修改过”(简化版的版本号机制,适合只需判断“是否修改”,无需记录修改次数的场景)。
补充:AtomicReference示例(简单引用对象原子修改):
import java.util.concurrent.atomic.AtomicReference;
class User {
private String name;
private int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
// getter/setter 省略
@Override
public String toString() {
return "User{name='" + name + "', age=" + age + "}";
}
}
public class AtomicReferenceDemo {
public static void main(String[] args) {
// 初始化引用对象
User user1 = new User("张三", 20);
AtomicReference<User> atomicRef = new AtomicReference<>(user1);
// 尝试将user1修改为user2(原子操作)
User user2 = new User("李四", 22);
boolean success = atomicRef.compareAndSet(user1, user2);
System.out.println("修改结果:" + success + ",当前对象:" + atomicRef.get()); // true,User{name='李四', age=22}
// 尝试将user1修改为user3(修改失败,因为当前引用已不是user1)
User user3 = new User("王五", 25);
boolean success2 = atomicRef.compareAndSet(user1, user3);
System.out.println("修改结果:" + success2 + ",当前对象:" + atomicRef.get()); // false,User{name='李四', age=22}
}
}
用于解决“数组元素”的原子操作问题,核心类2个:AtomicIntegerArray(int数组)、AtomicLongArray(long数组),用法和基本类型原子类类似,示例简单了解:
import java.util.concurrent.atomic.AtomicIntegerArray;
public class AtomicIntegerArrayDemo {
public static void main(String[] args) {
int[] arr = {1, 2, 3};
// 初始化原子数组(传入普通数组)
AtomicIntegerArray atomicArr = new AtomicIntegerArray(arr);
// 原子修改数组索引0的值(从1改为10)
atomicArr.compareAndSet(0, 1, 10);
// 原子自增数组索引1的值(2→3)
atomicArr.incrementAndGet(1);
System.out.println("原子数组:" + atomicArr); // 输出:[10, 3, 3]
System.out.println("原数组:" + java.util.Arrays.toString(arr)); // 原数组不变:[1, 2, 3]
}
}
提醒:原子数组修改的是“自身内部的数组副本”,不会修改原数组(这点需要注意,避免踩坑)。
Atomic类的易错点:避坑
易错点1:认为Atomic类能解决所有原子操作问题
错误原因:Atomic类只能保证“单个变量”的原子操作,无法保证“多个变量的组合操作”(比如同时修改count1和count2,AtomicInteger无法保证两者的原子性);正确做法:多个变量组合操作,需加锁(synchronized/ReentrantLock),或使用AtomicReference封装多个变量为一个对象。
易错点2:混淆AtomicStampedReference和AtomicMarkableReference的用法
错误原因:两者都能解决ABA问题相关场景,但适用场景不同;正确做法:需要记录“修改次数”→ 用AtomicStampedReference(版本号);只需判断“是否被修改过”→ 用AtomicMarkableReference(boolean标记)。
易错点3:使用AtomicLong时,忽略高并发下的性能问题
错误原因:高并发场景下,多个线程同时调用AtomicLong的incrementAndGet(),会出现大量CAS重试,消耗CPU;正确做法:高并发计数场景,用LongAdder替代AtomicLong(LongAdder底层分段锁,性能更高,JDK1.8新增)。
易错点4:认为Atomic类不需要关注线程安全
错误原因:Atomic类只保证“自身方法的原子性”,若多个Atomic方法组合使用,仍可能出现线程安全问题;示例:atomicInteger.get() + atomicInteger.incrementAndGet(),这两个方法单独是原子的,但组合起来不是,多线程下可能出现逻辑错误。
进阶提升
适配面试,衔接之前volatile笔记
CAS 与 volatile 的关联(必记|高频)
两者都是并发编程中的轻量级同步手段,紧密关联,互补不足:
面试题:AtomicInteger为什么能保证线程安全?
- 标准答案:AtomicInteger底层结合了CAS原理和volatile关键字;用volatile修饰内部的value变量,保证可见性和禁止指令重排,确保线程能读取到value的最新值;用CAS操作(compareAndSwapInt)保证value修改的原子性,无需加锁,实现轻量级线程安全。
高频面试题
面试题1:什么是CAS?CAS的执行流程是什么?
标准答案:CAS(Compare And Swap)是一种无锁同步机制,核心是“先比较、再交换”,通过内存地址V、预期值A、新值B三个参数实现原子操作;执行流程:① 线程读取V地址的变量值,得到预期值A;② 计算得到新值B;③ 比较V地址的当前值与A,相等则将B写入V,修改成功;不相等则修改失败,可选择重试。
面试题2:CAS 有什么优缺点?
标准答案:优点:1. 无锁机制,不阻塞线程,并发性能优于synchronized;2. 底层由CPU硬件指令支持,执行效率高;3. 解决volatile不保证原子性的问题。缺点:1. 存在ABA问题;2. 并发冲突频繁时,自旋重试消耗大量CPU;3. 只能保证单个变量的原子操作,无法保证多个变量的组合操作。
面试题3:什么是ABA问题?如何解决ABA问题?
标准答案:ABA问题是CAS的核心隐患,指共享变量的值从A变为B,再从B变回A,CAS会认为值未被修改,从而执行修改操作,导致逻辑错误。解决方案:1. 版本号机制:给变量加版本号,修改时版本号自增,CAS同时比较值和版本号;2. 时间戳机制:用时间戳替代版本号,原理和版本号一致;Java中可用AtomicStampedReference类直接实现。
面试题4:AtomicInteger 和 volatile 的区别?
标准答案:1. 原子性:AtomicInteger保证原子性,volatile不保证;2. 可见性:两者都保证可见性;3. 有序性:两者都保证有序性(volatile禁止重排,Atomic类依赖volatile);4. 用法:AtomicInteger用于需要原子操作的场景(如计数),volatile用于单线程写、多线程读的状态标记场景;5. 底层:AtomicInteger基于CAS+volatile实现,volatile基于内存屏障实现。
面试题5:高并发场景下,AtomicLong 和 LongAdder 选哪个?为什么?
标准答案:选LongAdder。原因:AtomicLong底层基于CAS实现,高并发下多个线程同时重试,会消耗大量CPU资源;LongAdder底层采用“分段锁”机制,将计数器拆分为多个分段,每个线程操作不同的分段,减少CAS重试,并发性能远高于AtomicLong;适合高并发计数场景,普通并发场景两者均可。
整体总结
七、ThreadLocal
1.ThreadLocal 核心原理
1.1 基本定义
ThreadLocal 是 Java 提供的一个线程本地存储工具类,它的核心作用是:为每个使用该变量的线程都创建一个独立的变量副本。线程之间的副本相互隔离,互不干扰,从根本上避免了多线程共享变量带来的并发安全问题。
可以用一个通俗的比喻理解:ThreadLocal 就像每个线程的 “专属储物柜”,线程只能存取自己柜子里的东西,不会和其他线程的柜子混淆。
1.2 核心存储结构
ThreadLocal 本身不存储数据,真正的存储载体是 Thread 类中的 ThreadLocalMap 成员变量。
- Thread 类:每个线程对象内部都持有一个 ThreadLocalMap 实例(Thread.threadLocals)。
- ThreadLocalMap:是 ThreadLocal 的静态内部类,本质是一个自定义的哈希表,键是 ThreadLocal 实例(弱引用),值是线程专属的变量副本。
- ThreadLocal:只是操作这个哈希表的 “工具类”,提供 set()、get()、remove() 等方法。
1.3 核心方法执行流程(以 set() 为例)
public class ThreadLocalDemo {
// 创建 ThreadLocal 实例,泛型指定存储的数据类型
private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
// 线程1
new Thread(() -> {
threadLocal.set("线程1的专属数据");
System.out.println(Thread.currentThread().getName() + ": " + threadLocal.get());
threadLocal.remove(); // 用完及时移除,避免内存泄漏
}, "线程1").start();
// 线程2
new Thread(() -> {
threadLocal.set("线程2的专属数据");
System.out.println(Thread.currentThread().getName() + ": " + threadLocal.get());
threadLocal.remove();
}, "线程2").start();
}
}
执行结果:
线程1: 线程1的专属数据
线程2: 线程2的专属数据
核心方法拆解:
set(T value):
get():
remove():
1.4 核心特点
- 线程隔离:每个线程的变量副本独立,线程安全;
- 懒加载:ThreadLocalMap 直到线程第一次调用 set()/get() 时才会创建;
- 生命周期:变量副本的生命周期默认和线程一致(线程结束,副本才会被回收)。
2. ThreadLocal 内存泄漏问题
2.1 内存泄漏的定义
内存泄漏是指:程序中已分配的内存空间由于某种原因无法被 JVM 回收,导致内存占用越来越高,最终可能引发 OOM(内存溢出)。
2.2 Java的四种引用
在JDK1.2之前,“引用”的概念过于狭隘,如果Reference类型的数据存储的是另外一块内存的起始地址,就称该Reference数据是某块地址、对象的引用,对象只有两种状态:被引用、未被引用。
这样的描述未免过于僵硬,对于这一类对象则无法描述:内存足够时暂不回收,内存吃紧时进行回收。例如:缓存数据。
在JDK1.2之后,Java对引用的概念做了一些扩充,将引用分为四种,由强到弱依次为:
-
强引用(Strongly Reference)
指代码中普遍存在的赋值行为,如:Object o = new Object(),只要强引用关系还在,对象就永远不会被回收。
-
软引用(Soft Reference)
还有用处,但是非必须存活的对象,JVM会在内存溢出前对其进行回收,例如:缓存。
-
弱引用(Weak Reference)
非必须存活的对象,引用关系比软引用还弱,不管内存是否够用,下次GC一定回收。
-
虚引用(Phantom Reference)
也称“幽灵引用”、“幻影引用”,最弱的引用关系,完全不影响对象的回收,等同于没有引用,虚引用的唯一的目的是对象被回收时会收到一个系统通知。
2.2 为什么会出现内存泄漏?
综上所述,由于ThreadLocal对象是弱引用,如果外部没有强引用指向它,它就会被GC回收,导致Entry的Key为null,如果这时value外部也没有强引用指向它,那么value就永远也访问不到了,按理也应该被GC回收,但是由于Entry对象还在强引用value,导致value无法被回收,这时「内存泄漏」就发生了,value成了一个永远也无法被访问,但是又无法被回收的对象。
Entry对象属于ThreadLocalMap,ThreadLocalMap属于Thread,如果线程本身的生命周期很短,短时间内就会被销毁,那么「内存泄漏」立刻就会得到解决,只要线程被销毁,value也会随之被回收。问题是,线程本身是非常珍贵的计算机资源,很少会去频繁的创建和销毁,一般都是通过线程池来使用,这就将线程的生命周期大大拉长,「内存泄漏」的影响也会越来越大。
关键在于 ThreadLocalMap 的键的引用类型:
- ThreadLocalMap 的键(ThreadLocal 实例)是 弱引用(WeakReference);
- ThreadLocalMap 的值(变量副本)是 强引用。
引用类型补充:
- 强引用:普通引用(如 Object obj = new Object()),只要强引用存在,对象永远不会被 GC 回收;
- 弱引用:GC 时只要发现弱引用指向的对象,无论内存是否充足,都会回收该对象。
2.3 内存泄漏的触发流程
核心问题:
- 键被回收后,值的强引用还在,且线程可能长期存活(如线程池中的核心线程),导致值永远无法被回收,最终内存泄漏。
2.4 如何避免内存泄漏?
核心原则:使用完 ThreadLocal 后,务必调用 remove() 方法。
正确使用示例:
public class SafeThreadLocalDemo {
private static ThreadLocal<Long> threadLocal = new ThreadLocal<>();
public static void doSomething() {
try {
threadLocal.set(System.currentTimeMillis());
// 业务逻辑处理
System.out.println("当前线程数据:" + threadLocal.get());
} finally {
// 无论是否异常,都移除数据
threadLocal.remove();
}
}
public static void main(String[] args) {
doSomething();
}
}
额外建议:
2.5 JDK 的兜底措施
JDK 为了缓解内存泄漏问题,在 ThreadLocalMap 的 set()/get()/remove() 方法中,会检查并清理 null 键对应的键值对,但这只是兜底,不能替代主动调用 remove()(比如线程长期不操作 ThreadLocalMap,兜底逻辑不会触发)。




