多线程基础
开篇:为什么需要多线程?
想象你去超市买东西,整个超市只有一个收银台。不管排了多少人,所有人只能一个接一个地结账。这就是单线程的世界——所有任务排成一队,挨个执行。
现在超市开了 5 个收银台,5 个人可以同时结账,效率直接翻倍。这就是多线程——多个任务可以"同时"推进。
从硬件来看,CPU 主频从 2003 年起就不再翻倍了(摩尔定律失效),取而代之的是多核架构。你的笔记本可能有 8 核、16 核,但如果程序只用单线程,那剩下的核心全在"摸鱼"。想让程序跑得快,就必须用并行或并发编程。
从软件来看,高并发系统(电商秒杀、即时通讯)天然需要多线程来处理海量请求。
在正式开始之前,先厘清三个经常被混淆的概念:
- 进程:操作系统资源分配的最小单位。打开一个 Chrome 浏览器就是启动了一个进程。
- 线程:操作系统调度的最小单位。一个进程中可以有多个线程,它们共享进程的内存和资源。
- 管程(Monitor):一种同步机制,保证同一时间只有一个线程可以访问被保护的代码。Java 中的
synchronized就是基于管程实现的。
Java 程序运行在 JVM 上,一个 JVM 就是一个进程。所以我们说 Java 是单进程、多线程的。
一、线程的创建方式
Java 中创建线程主要有四种方式。我们逐一来看,最后做个对比。
1.1 继承 Thread 类
最直接的方式,直接继承 Thread,重写 run() 方法:
public class MyThread extends Thread {
@Override
public void run() {
System.out.println(getName() + " 正在执行");
}
public static void main(String[] args) {
new MyThread().start();
}
}简单粗暴,但有个致命缺点:Java 是单继承的,继承了 Thread 就不能再继承其他类了。
当我们调用 start() 的时候,底层实际上调用了 private native void start0(),这是一个 native 方法。往下追溯到 JVM 源码,最终会发现:Java 创建线程其实就是让操作系统创建一个内核线程。所以线程的创建、调度、切换都是有成本的。
1.2 实现 Runnable 接口
更推荐的方式,把"任务"和"线程"解耦:
public class MyTask implements Runnable {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " 正在执行");
}
public static void main(String[] args) {
new Thread(new MyTask(), "工作线程").start();
}
}Runnable 是接口,不影响继承其他类,而且同一个 Runnable 可以交给多个线程执行,更灵活。
1.3 通过 Callable + FutureTask
Runnable 的 run() 没有返回值,也不能抛受检异常。如果你需要线程执行完毕后拿到一个结果,就得用 Callable:
Callable<String> task = () -> {
Thread.sleep(1000);
return "任务完成";
};
FutureTask<String> futureTask = new FutureTask<>(task);
new Thread(futureTask, "callable线程").start();
System.out.println(futureTask.get()); // 阻塞等待结果注意 get() 会阻塞当前线程直到拿到结果。如果不想无限等待,可以用带超时的版本:futureTask.get(2, TimeUnit.SECONDS)。
Runnable 和 Callable 的核心区别:Runnable 的 run() 无返回值且不能抛受检异常,Callable 的 call() 有返回值且可以抛受检异常。
1.4 通过线程池
实际开发中最常用的方式。把"线程管理"交给线程池,开发者只需要关注"任务":
ExecutorService pool = Executors.newFixedThreadPool(3);
pool.submit(() -> System.out.println("线程池中的任务"));
// 带返回值
Future<String> future = pool.submit(() -> {
Thread.sleep(500);
return "线程池返回结果";
});
System.out.println(future.get());
pool.shutdown();1.5 四种方式对比
| 方式 | 有返回值 | 可复用线程 | 推荐程度 |
|---|---|---|---|
| 继承 Thread | 否 | 否 | 不推荐 |
| 实现 Runnable | 否 | 否 | 一般推荐 |
| Callable + FutureTask | 是 | 否 | 需要返回值时用 |
| 线程池 | 可选 | 是 | 生产首选 |
归根结底,创建线程底层只有两条路:继承 Thread 或实现 Runnable。Callable 和线程池都是在此基础上的封装。但面试时回答"四种"更全面。
二、线程的生命周期
一个线程从出生到死亡,会经历以下六种状态。这是 Thread.State 枚举里明确定义的。
逐一解释:
- NEW:
new Thread()之后,还没调用start()。此时线程对象已创建,但还没有对应的操作系统线程。 - RUNNABLE:调用
start()后进入此状态。它包含两个子状态——就绪(READY,等待 CPU 时间片)和运行中(RUNNING,正在执行)。Java 没有单独的 RUNNING 状态,因为 CPU 时间片只有 10~20 毫秒,一个线程一秒内可能切换几百次就绪和运行,区分没有实际意义。 - BLOCKED:线程试图获取
synchronized锁但被其他线程占用时,进入此状态。注意:等ReentrantLock的线程状态是 WAITING,不是 BLOCKED。 - WAITING:线程调用了
wait()、join()或LockSupport.park()后进入无限期等待。必须被其他线程显式唤醒(notify()、notifyAll()、unpark())。 - TIMED_WAITING:和 WAITING 类似,但有超时时间。调用
sleep(n)、wait(n)、join(n)后进入此状态,到时间自动醒。 - TERMINATED:线程的
run()方法执行完毕,或者抛出了未捕获的异常。
WAITING 和 TIMED_WAITING 的区别
这两个状态都会让出 CPU。区别在于:WAITING 必须等别人唤醒(被动醒),TIMED_WAITING 到时间自己醒(主动醒 + 被动醒皆可)。
另一个关键区别:sleep() 不释放锁,wait() 释放锁。这也是为什么 wait() 定义在 Object 类上(因为锁加在对象上),而 sleep() 定义在 Thread 类上。
三、线程核心方法
3.1 sleep vs wait
这是高频面试题,一张表说清楚:
| 对比项 | sleep() | wait() |
|---|---|---|
| 所属类 | Thread | Object |
| 释放锁 | 不释放 | 释放 |
| 使用场景 | 任何地方 | 必须在 synchronized 块内 |
| 唤醒方式 | 时间到自动醒 | 需要 notify/notifyAll |
| 线程状态 | TIMED_WAITING | WAITING |
为什么 wait 定义在 Object 上? 因为 Java 的锁是加在对象上的,wait 要释放锁,自然要和对象绑定。sleep 不涉及锁,所以定义在 Thread 上。notify 和 notifyAll 也是同理,定义在 Object 上。
补充一个小知识点:Thread.sleep(0) 有什么作用?它会让当前线程释放 CPU 时间片,然后重新参与竞争。这在某些长时间运行的循环中可以用来给其他线程一个执行的机会。
3.2 join
join() 的含义是"我等你执行完再继续"。就像你在食堂打饭,你让朋友先打,等他打完你再打——这就是 join。
Thread t1 = new Thread(() -> {
try { Thread.sleep(2000); } catch (InterruptedException e) {}
System.out.println("t1 执行完毕");
});
t1.start();
t1.join(); // 主线程阻塞在这里,等 t1 跑完
System.out.println("主线程继续");输出一定是先 t1 执行完毕,后 主线程继续。
join 的一个常见应用是保证多个线程的执行顺序。比如想让 T1、T2、T3 按顺序执行:
Thread t1 = new Thread(() -> System.out.println("T1"), "T1");
Thread t2 = new Thread(() -> {
try { t1.join(); } catch (InterruptedException e) {}
System.out.println("T2");
}, "T2");
Thread t3 = new Thread(() -> {
try { t2.join(); } catch (InterruptedException e) {}
System.out.println("T3");
}, "T3");
t3.start(); t2.start(); t1.start();
// 无论启动顺序如何,输出一定是 T1 T2 T33.3 yield
yield() 表示"我愿意让出 CPU",但注意是建议而非命令。操作系统可以完全无视这个建议。
yield 和 sleep(0) 的区别:
sleep(0)可以被中断(抛 InterruptedException),yield()不可以yield()在某些操作系统上效果有限(如 Linux 的 CFS 调度器对 yield 支持有限)sleep(0)会先释放 CPU 再重新竞争
3.4 interrupt 中断机制
Java 中停止线程的正确姿势不是 stop()(已废弃),而是中断机制。
stop() 为什么被废弃?因为它像 kill -9 一样粗暴,会立即终止线程,带来三个严重问题:
- 破坏对象状态一致性(比如转账操作执行了扣款但没入账就被终止)
- 强制释放所有锁,导致其他线程读到脏数据
finally块可能不会执行,导致资源泄漏
中断是一种协作机制:调用 t.interrupt() 只是把线程 t 的中断标志位设为 true,至于线程收到信号后怎么做,由线程自己决定。
场景一:线程在正常运行中
Thread t = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
System.out.println("工作中...");
}
System.out.println("收到中断信号,优雅退出");
});
t.start();
Thread.sleep(100);
t.interrupt();场景二:线程正在阻塞中(sleep/wait)
如果线程正在 sleep() 或 wait() 中,调用 interrupt() 会抛出 InterruptedException 并自动清除中断标志。所以在 catch 块中,通常需要重新设置中断状态:
Thread t = new Thread(() -> {
try {
while (!Thread.currentThread().isInterrupted()) {
Thread.sleep(1000);
System.out.println("工作中...");
}
} catch (InterruptedException e) {
System.out.println("睡眠中被中断,准备退出");
Thread.currentThread().interrupt(); // 恢复中断状态
}
System.out.println("线程安全退出");
});
t.start();
Thread.sleep(2500);
t.interrupt();场景三:volatile 标志位
另一种常见的停止线程的方式:
private static volatile boolean running = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (running) {
System.out.println("工作中...");
}
System.out.println("标志位变化,线程退出");
});
t.start();
Thread.sleep(2000);
running = false;
}volatile 方式的局限:如果线程卡在阻塞操作中(如 sleep、IO),它无法及时检查标志位。所以实际开发中常将 volatile 标志位和 interrupt 结合使用。
三个相关方法的区别:
| 方法 | 类型 | 作用 |
|---|---|---|
interrupt() | 实例方法 | 设置中断标志为 true |
isInterrupted() | 实例方法 | 检查中断标志,不清除 |
Thread.interrupted() | 静态方法 | 检查中断标志,并清除 |
3.5 notify vs notifyAll
当线程调用 wait() 进入等待后,必须由其他线程来唤醒它:
notify():随机唤醒一个等待中的线程notifyAll():唤醒所有等待中的线程
被唤醒的线程只是进入锁的竞争队列,并不会立即执行。它们还要竞争到锁才能继续。
一般推荐使用 notifyAll(),因为 notify() 可能唤醒一个不合适的线程,导致其他线程永远等不到唤醒("信号丢失"问题)。
四、线程池
4.1 为什么用线程池?
想象你开了一家餐厅。每来一位客人就现招一个服务员,客人走了就把服务员开除——这太荒谬了。正常做法是招一批固定的服务员,客人多的时候全部上阵,客人少的时候轮流休息。
线程池就是这个"服务员团队"。它的核心价值:
- 降低资源消耗:复用已有线程,避免反复创建和销毁(每次创建线程都要进内核态)
- 提高响应速度:任务来了直接分配线程,不用等创建
- 方便管理:可以控制最大并发数,防止系统被压垮
4.2 七大参数详解
ThreadPoolExecutor 的构造方法有七个参数,这是面试的重灾区。我们用餐厅来类比:
public ThreadPoolExecutor(
int corePoolSize, // 正式员工数量
int maximumPoolSize, // 正式员工 + 临时工的最大总人数
long keepAliveTime, // 临时工空闲多久后被辞退
TimeUnit unit, // keepAliveTime 的时间单位
BlockingQueue<Runnable> workQueue, // 等候区(排队的任务)
ThreadFactory threadFactory, // 招聘标准(如何创建线程)
RejectedExecutionHandler handler // 等候区也满了怎么办
)任务提交后的执行流程:
注意一个反直觉的细节:不是先扩容线程再排队,而是先排队再扩容。 核心线程满了之后,新任务先进队列;队列也满了,才会创建临时线程。这是因为创建线程本身也有成本,能排队就不创建。
七个参数的详细说明:
| 参数 | 含义 | 类比 |
|---|---|---|
| corePoolSize | 核心线程数,即使空闲也不会被回收 | 餐厅正式员工 |
| maximumPoolSize | 线程总数上限 | 正式工 + 临时工总数 |
| keepAliveTime | 临时线程空闲存活时间 | 临时工没活干多久后辞退 |
| unit | keepAliveTime 的时间单位 | 秒/分/时 |
| workQueue | 任务等待队列 | 餐厅等候区 |
| threadFactory | 线程创建工厂 | 如何招人(命名、优先级等) |
| handler | 拒绝策略 | 等候区满了怎么处理新客人 |
4.3 四种拒绝策略
当线程池的线程和队列都满了,新任务怎么办?JDK 提供了四种内置策略:
| 策略 | 行为 | 适用场景 |
|---|---|---|
| AbortPolicy(默认) | 直接抛 RejectedExecutionException | 不允许丢失任务 |
| CallerRunsPolicy | 让提交任务的线程自己执行 | 想降低提交速度,有自我保护效果 |
| DiscardPolicy | 默默丢弃新任务,不抛异常 | 可以容忍丢失 |
| DiscardOldestPolicy | 丢弃队列中最老的任务,重试提交 | 只关心最新任务 |
实际开发中,默认的 AbortPolicy 最常用,因为它至少能让你知道出了问题。也可以自定义拒绝策略,比如记录日志、发告警、持久化到数据库等。
4.4 Executors 的坑
JDK 提供了 Executors 工具类来快速创建线程池,比如 newFixedThreadPool、newCachedThreadPool 等。但阿里巴巴 Java 开发手册明确禁止使用它们,原因如下:
| 方法 | 底层队列/线程数 | 风险 |
|---|---|---|
newFixedThreadPool | LinkedBlockingQueue(无界) | 任务堆积导致 OOM |
newSingleThreadExecutor | LinkedBlockingQueue(无界) | 同上 |
newCachedThreadPool | SynchronousQueue + maxSize=MAX_VALUE | 线程暴涨导致 OOM |
newScheduledThreadPool | DelayedWorkQueue + maxSize=MAX_VALUE | 同上 |
正确做法是使用 ThreadPoolExecutor 手动指定参数,根据业务场景合理配置队列容量和最大线程数。
4.5 如何合理配置线程数?
这取决于任务类型:
CPU 密集型(大量计算,如加密、压缩、图像处理):
线程数 = CPU 核心数 + 1多出的 1 个线程是为了应对偶尔的页缺失等中断。线程太多反而会因为频繁切换上下文而变慢。
IO 密集型(网络请求、数据库查询、文件读写):
线程数 = CPU 核心数 * 2或者用更精确的公式:
线程数 = CPU 核心数 / (1 - 阻塞系数)阻塞系数一般在 0.8~0.9 之间。IO 操作时线程在等待,不占用 CPU,所以可以配更多线程来充分利用 CPU。
获取 CPU 核心数:Runtime.getRuntime().availableProcessors()
五、守护线程 vs 用户线程
Java 中有两种线程:用户线程和守护线程。
- 用户线程:干活的"正式员工",是系统的工作线程
- 守护线程:在后台默默服务的"后勤人员",最典型的就是 GC 垃圾回收线程
核心规则:当所有用户线程都结束后,JVM 会直接退出,不管守护线程有没有跑完。
Thread daemon = new Thread(() -> {
while (true) {
System.out.println("守护线程在后台运行...");
try { Thread.sleep(500); } catch (InterruptedException e) {}
}
});
daemon.setDaemon(true); // 必须在 start() 之前设置!
daemon.start();
Thread.sleep(2000);
System.out.println("主线程结束,JVM 即将退出");
// 主线程(用户线程)结束后,守护线程也会随之终止几个注意事项:
setDaemon()必须在start()之前调用,否则抛IllegalThreadStateException- 守护线程中创建的子线程默认也是守护线程
- 不要在守护线程中执行 IO 操作或持有锁,因为它可能在任何时刻被终止
六、上下文切换
上下文切换是指 CPU 从一个线程切换到另一个线程时,需要保存当前线程的状态(程序计数器、寄存器、栈指针等),并恢复另一个线程的状态。
这个过程是有开销的。虽然单次切换很快(几微秒),但如果切换太频繁,累积起来会显著影响性能。所以,线程数并不是越多越好。
减少上下文切换的方法:
- 合理设置线程数:通过线程池控制,避免创建过多线程
- 使用无锁编程:如 CAS 操作,避免线程因等待锁而被挂起
- 使用虚拟线程(JDK 21+):虚拟线程的切换发生在 JVM 层面,不需要操作系统参与,开销远小于平台线程的上下文切换
七、常见面试题精选
Q1:创建线程有几种方式?
四种:继承 Thread、实现 Runnable、Callable + FutureTask、线程池。但本质上只有两种——继承 Thread 和实现 Runnable,其他都是封装。
Q2:run() 和 start() 的区别?
start() 会创建一个新的操作系统线程,在新线程中执行 run()。直接调用 run() 就是在当前线程中同步执行,不会创建新线程。
Q3:sleep 和 wait 的区别?
最核心的区别:sleep 不释放锁,wait 释放锁。sleep 是 Thread 的方法,wait 是 Object 的方法。sleep 必须指定时间,wait 可以不指定(需要 notify 唤醒)。
Q4:如何优雅地停止一个线程?
两种推荐方式:中断机制(interrupt())或 volatile 标志位。不要用 stop(),它已被废弃——会强制释放所有锁,导致数据不一致、资源泄漏。
Q5:什么是死锁,如何避免?
死锁是指两个或多个线程互相持有对方需要的锁,导致所有线程永远阻塞。就像丈母娘要求先买房才结婚,但女婿说先结婚才买房——谁也不让步。
产生死锁的四个必要条件:互斥、占有且等待、不可抢占、循环等待。
最有效的预防方式是破坏循环等待——让所有线程按照相同的顺序获取锁。比如事务 1 和事务 2 都按 A -> B -> C 的顺序加锁,就不会死锁。
Q6:JDK 21 中的虚拟线程是怎么回事?
传统的 Java 线程(平台线程)和操作系统线程一对一映射,创建和切换的开销很大。虚拟线程是 JDK 实现的轻量级线程,多个虚拟线程映射到少量操作系统线程上,创建成本极低。
一组对比数据:10000 个任务,平台线程约 1000ms,虚拟线程约 200ms。差距立竿见影。
关键特性:
- 虚拟线程总是守护线程,不能改为用户线程
- 优先级固定为 normal,不能更改
- 不建议和线程池一起使用(虚拟线程太轻量,没必要池化)
- JDK 24 之前,虚拟线程在 synchronized 块中会被"钉住"(pinned),建议用 ReentrantLock 代替
- JDK 24 起(JEP 491),synchronized 不再导致 pinning
创建方式:
// 方式一:直接启动
Thread.startVirtualThread(() -> {
System.out.println("虚拟线程执行中");
});
// 方式二:Builder API
Thread vt = Thread.ofVirtual().name("vt-1").start(() -> {
System.out.println("虚拟线程通过 Builder 创建");
});
// 方式三:虚拟线程执行器
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
});
}
}Q7:为什么不能在 try-catch 中捕获子线程的异常?
因为子线程和主线程是独立的执行单元,异常只影响抛出它的线程。Java 通过线程隔离来保证一个线程的异常不会导致整个进程退出。
如果需要在主线程感知子线程异常,有三种方案:
- 使用 Future.get(),它会把子线程的异常包装成 ExecutionException 抛出
- 使用
UncaughtExceptionHandler,为线程设置未捕获异常处理器 - 使用 CompletableFuture 的
exceptionally()或handle()方法
Q8:三个线程如何保证顺序执行?
常见方案有四种:
- join:T2 中
t1.join(),T3 中t2.join() - CountDownLatch:每个线程执行完后 countDown,下一个线程 await
- 单线程线程池:
Executors.newSingleThreadExecutor(),按提交顺序执行 - CompletableFuture:
thenRun()链式调用
最简洁的写法:
CompletableFuture.runAsync(() -> System.out.println("T1"))
.thenRun(() -> System.out.println("T2"))
.thenRun(() -> System.out.println("T3"))
.get();小结
本文覆盖了多线程的基础知识体系:
- 创建方式:四种方式各有适用场景,生产首选线程池
- 生命周期:六种状态和它们之间的转换条件,注意没有 RUNNING 状态
- 核心方法:sleep/wait/join/yield/interrupt,尤其注意 sleep 与 wait 的锁行为差异
- 线程池:七大参数的含义和任务提交流程、四种拒绝策略、Executors 的 OOM 风险、线程数配置公式
- 守护线程:所有用户线程结束,JVM 即退出
- 上下文切换:线程数不是越多越好
- 虚拟线程:JDK 21+ 的轻量级线程,面向高并发 IO 场景
这些是并发编程的地基。后续文章会在此基础上,继续讲 Future 与异步编程、锁与 Synchronized、JMM 与 CAS 等进阶主题。
附录:更多线程知识补充
并发 vs 并行
这两个词经常被混用,但含义不同。
并发(Concurrent):一个 CPU 在多个任务之间快速切换,看起来像是"同时执行"。就像一个人交替看两本书。本质上是分时复用。
并行(Parallel):多个 CPU 真正同时执行多个任务。就像两个人同时各看一本书。
Erlang 之父 Joe Armstrong 有一张经典图:并发是两队人交替用一台咖啡机,并行是两队人同时用两台咖啡机。
在单核 CPU 上,多线程只能实现并发。在多核 CPU 上,多线程可以实现真正的并行。
线程安全的概念
线程安全是指某个函数或对象在多线程环境中被调用时,能够正确地处理多个线程对共享变量的访问,使程序功能正确完成。
简单说:多个线程同时操作共享数据,得到的结果和预期一样,就是线程安全的。
要保证线程安全,需要满足三个条件:
- 原子性:一段操作要么全部执行完,要么都不执行,中间不能被打断
- 可见性:一个线程修改了共享变量,其他线程能立即看到
- 有序性:程序按代码顺序执行,不会被指令重排打乱
注意:并发编程中的"原子性"和数据库 ACID 的"原子性"不一样。数据库的原子性是"要么都执行要么都回滚",并发编程的原子性是"操作不可拆分、不被中断"。
线程安全的解决方案
遇到线程安全问题,有以下几种解决思路:
- 单线程:从根源杜绝问题(如 Redis 命令执行用单线程)
- 互斥锁:synchronized、ReentrantLock,让线程排队
- 读写分离:CopyOnWriteArrayList,读不加锁,写时复制
- 原子操作:AtomicInteger 等,基于 CAS 实现
- 不可变模式:String 是典型代表,没有写操作就没有线程安全问题
- 线程隔离:ThreadLocal,每个线程有自己的副本,不存在共享
int a = 1 是原子操作吗?
在 Java 中,int a = 1 是原子操作——简单赋值操作不可分割。
但 User a = new User() 不是原子操作。它实际上包含三步:分配内存、初始化对象、将引用赋给变量。如果发生指令重排,可能导致其他线程拿到一个未初始化完成的对象。这就是为什么 DCL 单例需要 volatile 的原因(后文 JMM 篇会详细讲)。
而 i++ 同样不是原子操作。它分为三步:读取值、加一、写回。要保证 i++ 的线程安全,可以用 AtomicInteger、synchronized 或 ReentrantLock。
线程的调度方式
操作系统调度线程主要有两种模型:
- 协同式调度:线程执行完才主动通知系统切换。好处是实现简单,没有同步问题;坏处是一个线程如果不让出 CPU,整个系统就卡住了。
- 抢占式调度:系统来决定每个线程的执行时间,时间片到了就强制切换。Java 虚拟机采用的就是抢占式调度。
虽然 Java 提供了线程优先级设置(Thread.MIN_PRIORITY 到 Thread.MAX_PRIORITY),但这只是给调度器的"建议"。不同操作系统的优先级映射可能不同,所以不要依赖优先级来控制线程执行顺序。
如何保证多线程下 i++ 的结果正确?
三种方案:
方案一:AtomicInteger(推荐)
private static AtomicInteger count = new AtomicInteger(0);
public static void increment() {
count.incrementAndGet(); // CAS 操作,无锁线程安全
}方案二:synchronized
private static int count = 0;
public static synchronized void increment() {
count++;
}方案三:ReentrantLock
private static int count = 0;
private static final Lock lock = new ReentrantLock();
public static void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}注意:volatile 不能保证 i++ 的正确性。volatile 只保证可见性和有序性,不保证原子性。i++ 包含读-改-写三步操作,volatile 无法保证这三步不被打断。
线程同步的工具一览
除了 synchronized 和 ReentrantLock,Java 还提供了丰富的同步工具:
| 工具 | 作用 | 特点 |
|---|---|---|
| CountDownLatch | 等待一组线程完成 | 一次性的,计数到 0 后不可重置 |
| CyclicBarrier | 让一组线程在屏障处汇合 | 可重用 |
| Semaphore | 控制同时访问资源的线程数 | 适合限流 |
| Phaser | 更灵活的屏障 | 支持动态注册/注销参与者 |
这些工具在后面的 AQS 篇章中会详细展开。
new Thread().start() 背后发生了什么?
操作系统层面大致分四步:
- 分配内核线程:创建线程控制块(TCB),包含线程 ID、寄存器状态、调度优先级等
- 分配栈空间:用户态栈(默认 1MB,可通过
-Xss调整)+ 内核栈(8~16KB) - 设置初始上下文:程序计数器指向 JVM 的线程入口函数
- 加入调度器就绪队列:等待被 CPU 调度执行
所以创建线程是有真实成本的——这也是为什么要用线程池来复用线程。
线程出现异常,进程会退出吗?
不会。Java 天然支持多线程,每个线程是独立的执行单元。一个线程抛出未捕获异常,只会影响这个线程本身,其他线程继续执行,JVM 进程不会退出。
这也是为什么一个线程的异常不能被另一个线程的 try-catch 捕获——它们是隔离的。
但如果发生 OOM,情况就复杂了。OOM 不一定导致 JVM 退出,要看具体是哪种 OOM 以及它影响了哪些线程。
ScopedValue:ThreadLocal 的替代者(JDK 25)
JDK 25 正式引入了 ScopedValue(JEP 429)。它的核心思想是"作用域"——一个值绑定到一个代码块内,退出作用域后自动失效。
final static ScopedValue<String> USER = ScopedValue.newInstance();
ScopedValue.runWhere(USER, "张三", () -> {
System.out.println(USER.get()); // 输出 "张三"
// 在这个作用域内,USER 的值是 "张三"
});
// 退出后,USER 的绑定自动消失相比 ThreadLocal,ScopedValue 有几个优势:
- 不会内存泄漏:作用域结束自动清理,不需要手动 remove()
- 对虚拟线程更友好:值不存储在线程中,而是存储在作用域中
- 父子线程传递更自然:结构化并发中,子任务自动继承父作用域的值
- 性能更好:不可变值,JVM 可以优化到接近局部变量的访问速度
活锁 vs 死锁
死锁是线程"卡住不动",活锁是线程"在动但没有进展"。
想象两个人在独木桥上相遇:
- 死锁:两人都不让路,谁也过不去
- 活锁:两人都想让路,A 往左让,B 也往左让,又撞上了;A 往右让,B 也往右让,还是撞上了——一直在"协作"但谁也过不去
活锁和 CAS 自旋不一样。CAS 自旋是每个人都在尝试挤过去(没有协作逻辑),活锁是两个人过度礼让导致的僵局。
避免活锁的方法:
- 引入随机退避时间
- 为线程设定优先级或固定顺序
- 限制重试次数
有哪些实现线程安全的方案?
总结一下前面提到的所有方案,形成一个完整的知识图谱:
线程安全方案
├── 互斥同步(阻塞)
│ ├── synchronized
│ └── ReentrantLock
├── 非阻塞同步
│ ├── CAS(AtomicInteger 等)
│ └── 自旋锁
├── 无同步方案
│ ├── 不可变对象(String、final 变量)
│ ├── 线程封闭(ThreadLocal)
│ ├── 栈封闭(局部变量)
│ └── 读写分离(CopyOnWriteArrayList)
└── 并发工具
├── CountDownLatch / CyclicBarrier / Semaphore
└── 并发集合(ConcurrentHashMap 等)选择哪种方案取决于具体场景:读多写少用读写锁或 COW,简单计数器用 AtomicInteger,需要保护复杂代码段用 synchronized 或 ReentrantLock。没有银弹,只有最合适的方案。