Appearance
并发编程、JUC 与线程安全
这篇笔记聚焦 Java 里最容易“会用线程,但讲不清为什么会出问题”的一条主线:
- 线程和进程是什么关系
JUC到底是什么,和Thread、synchronized、线程池分别是什么关系- 为什么多线程会引入竞态问题
synchronized、volatile、Lock分别解决什么问题- 线程池为什么重要
- 并发编程里最常见的误区是什么
1. 为什么并发会成为 Java 的核心主线
因为业务系统一旦进入服务端场景,就很少只有单线程逻辑。
你会很快遇到:
- 一个服务同时处理多个请求
- 多个线程共享同一份数据
- 多个任务需要异步执行
- 一张总表系统既要正确,又要有吞吐
这说明并发编程解决的不是“怎么同时写多个方法”,而是 在多个执行单元同时工作时,如何平衡正确性、可见性和性能。
1.1 JUC 到底是什么
JUC 是 java.util.concurrent 的常见简称。
可以把它理解成 Java 并发编程里一组更偏工程化的工具包。
它主要覆盖几类能力:
- 锁和同步器,例如
Lock(锁接口)、CountDownLatch(倒计时门闩)、Semaphore(信号量) - 原子类,例如
AtomicInteger(原子整数)、AtomicReference(原子引用) - 并发容器,例如
ConcurrentHashMap(并发哈希表)、BlockingQueue(阻塞队列) - 线程池与异步编排,例如
ExecutorService(执行器服务接口)、ThreadPoolExecutor(线程池执行器)、CompletableFuture(可编排的异步结果容器)
所以很多人平时说“学 Java 并发”,工程上大多就是在学这两层东西:
- Java 并发基础,例如
Thread、synchronized、volatile JUC提供的更高层并发工具
1.2 JUC 和 Thread、synchronized 是什么关系
这几个词很容易混在一起,但它们不在同一层。
可以这样区分:
Thread是线程这个执行单元本身synchronized、volatile更偏语言和内存语义层的基础能力JUC更像建立在这些基础之上的并发工具箱
更贴近工程实践的理解是,先有线程和共享数据问题,再有同步原语,最后才是 JUC 把常见并发场景包装成更容易复用的工具。
2. Thread 是什么
Thread 可以看成 Java 中表示线程的类。
但如果只记住“它是个类”,还是太薄了。Thread 同时有两层含义:
- 从概念上看,它表示程序中的一条执行路径
- 从代码上看,它是 Java 提供的线程类,用来创建和控制线程
这里的 执行路径 可以理解成 代码被一行一行往下执行的那条运行轨迹。
如果程序里只有一条执行路径,那就是单线程。
如果同一个进程里同时有多条执行路径在推进,那就是多线程。
所以 Thread 最核心的价值是 把某个任务放到独立执行流里运行。
2.1 一个最简单的 Thread 示例
java
public class Demo {
public static void main(String[] args) {
Thread t = new Thread(() -> {
System.out.println("子线程执行");
});
t.start();
System.out.println("主线程执行");
}
}这段代码可以这样理解:
main方法本身就在主线程里执行new Thread(...)创建了一个线程对象start()才是真正启动新线程- 启动之后,主线程和子线程都可能并发执行
2.2 为什么不能直接调用 run()
这是理解 Thread 时特别容易混的地方。
例如:
java
t.run();这不会启动新线程,它只是普通方法调用。
真正会启动新线程的是:
java
t.start();所以这里直接记住:
run():线程执行的任务内容start():让 JVM 真正启动这个线程
3. 线程和进程到底有什么区别
进程 可以理解成 操作系统中资源分配的基本单位。
线程 可以理解成 进程内部真正执行任务的调度单位。
可以这样记:
- 进程更像一个运行中的应用实例
- 线程更像这个应用实例里的执行流
Java 程序启动后,通常就是一个进程里包含多个线程。
4. 线程安全到底是什么意思
线程安全 可以理解成 多个线程同时访问同一份数据或同一段逻辑时,程序仍然能保持正确行为。
它解决的是:
- 并发修改导致数据错乱
- 某个线程看不到别的线程刚更新的数据
- 多步操作在并发下被打断
例如:
java
count++;这看起来像一步,但实际上通常包含:
- 读取
- 加一
- 写回
所以它不是天然原子操作。
5. 并发里最常见的三个问题
5.1 原子性
原子性 可以理解成 一个操作要么完整执行,要么完全不执行,中间不能被打断。
5.2 可见性
可见性 可以理解成 一个线程对共享变量的修改,其他线程能不能及时看到。
5.3 有序性
有序性 可以理解成 程序执行顺序是否完全按你写下的代码顺序表现。
在编译器优化、CPU 重排序和多线程环境下,这个问题会变得很重要。
5.4 Java 内存模型和 happens-before 为什么重要
前面的原子性、可见性和有序性,其实都不是孤立概念。
它们背后真正的统一语境,是 Java Memory Model,也就是 Java 内存模型,常写成 JMM。
JMM 不是 JVM 里的某一块真实物理内存,而是 Java 为多线程读写共享变量定义的一套可见性、有序性和同步规则。
如果不先建立这层认知,后面再看 volatile、synchronized、Lock,就很容易变成:
- 知道它们能用
- 但讲不清为什么它们能保证线程安全
可以抓住两个最核心的判断:
- 线程不会总是直接从主内存读写共享变量
- 多线程想要结果正确,必须有一套“什么时候对别的线程可见、什么时候顺序不能乱”的规则
而 happens-before 可以理解成 JMM 用来描述“前一个操作的结果,对后一个操作一定可见,并且执行顺序不能被打乱”的规则。
这不是单纯的“时间上谁先执行”,而是 内存语义上谁先于谁成立。
工程上最常用、最值得直接记住的几条 happens-before 规则包括:
- 同一个线程内,前面的操作先于后面的操作
- 对同一个锁的解锁,先于后续对这把锁的加锁
- 对
volatile变量的写,先于后续对这个变量的读 Thread.start()先于子线程中的执行- 子线程中的执行先于
Thread.join()返回
这一节最想建立的主线是 并发工具之所以能工作,不是因为它们“神奇地更安全”,而是因为它们在 JMM 规则内建立了可见性和有序性保障。
6. 锁到底是什么,为什么并发里总会讲到锁
锁 可以看成 一种在并发场景下控制共享资源访问顺序的机制。
它解决的问题是 当多个线程都想同时修改同一份共享数据时,如何避免彼此打断,导致结果错乱。
如果不用锁,像下面这种操作就很容易出问题:
java
count++;因为这不是一步完成的,它往往包含:
- 读取当前值
- 执行加一
- 写回结果
如果两个线程同时做这件事,就可能出现“少加一次”的竞态问题。
所以锁的核心价值,不是“让程序更慢”,而是 在需要保护共享数据的时候,用受控的方式换取正确性。
6.1 什么叫加锁
加锁 可以理解成 先拿到某种访问许可,再进入那段关键代码;没拿到许可的线程需要等待。
这里的关键代码,本质上就是前面提到的 临界区。
也就是说:
- 拿到锁的线程先执行
- 没拿到锁的线程先等
- 前一个线程释放锁后,后面的线程才有机会继续
6.2 锁主要解决哪些问题
锁最主要解决的是:
- 互斥访问
- 复合操作的原子性
- 一定程度上的可见性保障
但锁不是没有代价。
它也可能带来:
- 阻塞等待
- 上下文切换
- 锁竞争
- 死锁风险
所以并发设计里真正重要的不是“能不能加锁”,而是:该不该加、加多大范围、用哪一种锁。
6.3 Java 里常见的锁类型怎么理解
1. 悲观锁
悲观锁 可以理解成 默认认为并发冲突很容易发生,所以把资源保护起来,再让别人访问。
很多传统互斥锁都偏这种思路。
2. 乐观锁
乐观锁 可以理解成 默认认为并发冲突没那么频繁,先尝试更新,失败了再重试或放弃。
它通常不一定是“真正把线程锁住”,而更像是一种并发控制策略。
Java 里常见会和 CAS 一起讨论。
CAS 是 Compare And Swap 的缩写,中文常译作:比较并交换。
可以理解成 先比较内存中的值是不是我预期的旧值,如果是,再原子地更新成新值。
3. 可重入锁
可重入锁 可以理解成 同一个线程在已经持有锁的前提下,再次进入同一把锁保护的代码时,不会把自己堵死。
这很重要,因为很多方法调用链会出现:
- 外层方法已加锁
- 内层方法也尝试获取同一把锁
如果锁不可重入,线程可能把自己卡住。
synchronized 和 ReentrantLock 都是可重入的。
这里的 ReentrantLock 直译可以理解成 可重入锁。
4. 公平锁和非公平锁
公平锁 可以理解成 更倾向于按等待顺序分配锁。
非公平锁 可以理解成 新来的线程也可能直接抢到锁,不严格按排队顺序来。
公平锁更强调顺序感,非公平锁通常吞吐更高。
5. 读写锁
读写锁 可以理解成 把“读”和“写”这两类访问区分开处理的锁。
它的核心思路是:
- 读和读通常可以并发
- 写和读、写和写通常需要互斥
这类锁更适合 读多写少
的场景。
Java 里常见实现是 ReadWriteLock 和 ReentrantReadWriteLock。
其中:
ReadWriteLock可以理解成读写锁接口ReentrantReadWriteLock可以理解成可重入读写锁
如果只停在这两个名字上,还是不够落地。
更具体一点:
ReadWriteLock负责定义两类入口:readLock()和writeLock()ReentrantReadWriteLock是最常见的具体实现,业务代码里通常直接用它- 这里的
Reentrant表示“可重入”,也就是同一个线程已经拿到对应锁后,再次进入同一把锁保护的代码时,不会把自己卡住
可以把它理解成这样一组规则:
- 没有线程持有写锁时,多个线程通常可以同时拿到读锁
- 只要有线程持有写锁,其他线程通常既不能继续读,也不能继续写
- 写锁本质上还是互斥锁,它保护的是“修改共享数据”的临界区
这也是为什么它更适合 读多写少
的共享数据场景,例如本地缓存、配置快照、字典表数据。
下面这个例子更接近工程里常见的用法。
java
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* 演示一个读多写少的本地缓存。
*/
public class UserProfileCache {
private final Map<Long, String> cache = new HashMap<>();
private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private final Lock readLock = readWriteLock.readLock();
private final Lock writeLock = readWriteLock.writeLock();
/**
* 先读缓存,未命中时再切换到写锁回填。
*
* @param userId 用户 ID
* @return 用户名
*/
public String getUserName(Long userId) {
readLock.lock();
try {
String cached = cache.get(userId);
if (cached != null) {
return cached;
}
} finally {
readLock.unlock();
}
writeLock.lock();
try {
String cached = cache.get(userId);
if (cached == null) {
cached = loadFromDb(userId);
cache.put(userId, cached);
}
return cached;
} finally {
writeLock.unlock();
}
}
/**
* 主动更新缓存中的用户信息。
*
* @param userId 用户 ID
* @param userName 用户名
*/
public void putUserName(Long userId, String userName) {
writeLock.lock();
try {
cache.put(userId, userName);
} finally {
writeLock.unlock();
}
}
/**
* 语义化占位:表示从数据库查询用户信息。
*
* @param userId 用户 ID
* @return 查询到的用户名
*/
private String loadFromDb(Long userId) {
return "user-" + userId;
}
}这段代码想表达的重点是:
- 读操作先走读锁,让多个查询线程可以并发读取缓存
- 缓存未命中后,要先释放读锁,再去竞争写锁
- 拿到写锁后还要再检查一次缓存,避免多个线程重复回填同一份数据
loadFromDb()这里没有展开实现,因为这个例子真正要说明的是“读写锁怎么协同保护共享数据”
为什么要先释放读锁,再去拿写锁?
因为 ReentrantReadWriteLock 更适合做锁降级,而不是随意做锁升级。
这里可以这样记:
锁降级:线程已经拿到写锁,再去拿读锁,然后释放写锁锁升级:线程已经拿到读锁,再直接尝试获取写锁
工程上要注意的是:
ReentrantReadWriteLock常见用法里,锁降级可以做- 从读锁直接升级到写锁,通常不是推荐路径,处理不当很容易把自己卡住
- 如果读缓存未命中,更常见的做法就是像上面的示例一样:先释放读锁,再单独获取写锁
如果你还想继续细分它的行为边界,还可以记住两个工程判断:
- 读写锁不是“比互斥锁更高级”,它只是更适合读多写少
- 如果写操作很频繁,读线程也会经常被写锁阻塞,最后不一定比普通互斥锁更划算
- 如果共享数据本身可以改造成无状态、不可变对象或消息传递,很多时候会比上锁更省心
🌟 一个很实用的判断方式是:
共享数据被频繁读取、偶尔更新,而且读操作明显多于写操作时,再优先考虑 ReentrantReadWriteLock。
6.4 锁并不是唯一解
并发问题常见的治理手段不只有锁,还包括:
volatileCAS- 原子类
- 并发容器
- 无状态设计
- 线程隔离
也就是说,锁很重要,但不是所有并发问题都应该先想到“上锁”。
7. synchronized 到底解决什么问题
synchronized 可以理解成Java 提供的一种内置同步机制,用来保护临界区代码在并发下的执行。
如果从名字上理解,synchronized 本身就是 Java 关键字,直译可以理解成 同步。
它主要解决的是:
- 临界区互斥访问
- 保证一定的可见性
- 保证进入和退出同步块时的内存语义
这里的 临界区 可以理解成那段同时只能让一个线程安全进入执行的代码区域。
如果再说得更具体一点,synchronized 其实在做两件事:
- 让同一时刻只有一个线程进入被保护的代码
- 让线程进入和退出同步区域时,工作内存和主内存之间建立更明确的可见性关系
所以它不只是“防止同时执行”,也和并发下的数据可见性有关。
7.1 synchronized 锁的到底是什么
这是最容易糊涂的点之一。
synchronized 锁的不是代码本身,而是某个对象的监视器锁。
这里的 监视器,可以看成JVM 用来和对象绑定的一种同步控制机制。
所以 synchronized 真正的关键不是写了这个关键字,而是你到底让多个线程去竞争哪一把锁。
如果多个线程竞争的是同一把锁,它们才会互斥。 如果竞争的不是同一把锁,那即使都写了 synchronized,也未必能互相保护。
7.2 三种最常见的写法分别锁什么
1. 同步实例方法
java
public synchronized void increment() {
count++;
}这种写法锁的是:当前对象实例,也就是 this。
也就是说:
- 同一个对象上的多个线程调用这个方法,会互斥
- 不同对象实例之间,互不影响
2. 同步代码块
java
public void increment() {
synchronized (this) {
count++;
}
}这种写法锁的是:括号里明确指定的那个对象。
它的好处是:你可以精确控制只锁住一小段关键代码,而不是整个方法。
3. 同步静态方法
java
public static synchronized void add() {
total++;
}这种写法锁的不是某个实例,而是:当前类对应的 Class 对象。
也就是类锁。
所以它更像是在保护:整个位于类层面的共享状态。
7.3 一个特别容易记错的点:对象锁和类锁不是一回事
例如下面两种方法:
java
public synchronized void methodA() {
}
public static synchronized void methodB() {
}它们看起来都用了 synchronized,但锁的不是同一个东西:
methodA()锁的是实例对象methodB()锁的是类对象
所以它们之间默认并不会互相阻塞。
7.4 synchronized 为什么不仅仅是“互斥”
很多人会把 synchronized 理解成“只是加了一把锁”,这不够完整。
它还隐含了一层很重要的并发语义:线程退出同步块时,对共享变量的修改更容易对之后获取同一把锁的线程可见。
你可以不用死背内存模型术语,先记住这个工程结论:同一把锁,不仅保护临界区互斥,也建立了一定的可见性边界。
7.5 一个最典型的 synchronized 示例:保护计数器
java
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}这段代码可以这样理解:
increment()和getCount()都被同一把对象锁保护- 同一时刻,只有一个线程能进入这些同步方法
count++这种复合操作因此不会被多个线程同时打断
如果你更想只锁一小段代码,而不是整个方法,也可以这样写:
java
public void increment() {
synchronized (this) {
count++;
}
}这个写法强调的是:锁的范围可以细到某一段关键代码,而不一定是整个方法。
7.6 一个更具体的例子:卖票场景
java
public class TicketSeller {
private int tickets = 10;
public synchronized void sell() {
if (tickets > 0) {
System.out.println(Thread.currentThread().getName() + " 卖出第 " + tickets + " 张票");
tickets--;
}
}
}这段代码的意义在于:
tickets是共享资源- 多个线程都可能同时调用
sell() synchronized保证“判断还有没有票”以及“卖出并减一”这一整段逻辑不会被别的线程中途打断
如果不加锁,就可能出现:
- 两个线程都看到
tickets > 0 - 同一张票被卖两次
7.7 一个更能体现“锁对象选择”的例子
java
public class Room {
private final Object studyLock = new Object();
private final Object sleepLock = new Object();
public void study() {
synchronized (studyLock) {
System.out.println(Thread.currentThread().getName() + " 正在学习");
}
}
public void sleep() {
synchronized (sleepLock) {
System.out.println(Thread.currentThread().getName() + " 正在睡觉");
}
}
}这个例子想说明的是:不同业务动作不一定非要抢同一把锁。
如果两个动作访问的不是同一份关键共享资源,就可以用不同锁对象减少无意义阻塞。
这也是为什么很多并发优化最后会落到 缩小锁范围,或者拆分锁。
7.8 一个静态同步方法的例子
java
public class CounterService {
private static int total = 0;
public static synchronized void add() {
total++;
}
public static synchronized int getTotal() {
return total;
}
}这段代码适合帮助理解:
total是类级别共享变量- 所有实例都共享这一份数据
- 所以更适合使用类锁来保护
7.9 synchronized 适合什么场景
它通常适合:
- 保护共享可变状态
- 临界区逻辑不算太复杂
- 希望写法直接、清晰
- 不需要太多高级锁控制能力
很多普通业务代码里,如果只是:
- 保护一个计数器
- 保护一个余额字段
- 保护一个小范围的状态更新
synchronized 往往已经够用了。
7.10 synchronized 的常见误区
1. 以为写了 synchronized 就一定线程安全
不一定。
关键要看:多个线程是不是在竞争同一把锁。
2. 锁范围过大
如果把不需要保护的代码也一起锁住,会带来不必要的阻塞和吞吐下降。
3. 锁对象选错
例如每次都锁一个新对象:
java
public void test() {
synchronized (new Object()) {
// ...
}
}这种写法几乎没有起到预期保护作用,因为每次进来拿到的都是不同的锁对象。
4. 以为 synchronized 太老就不该用了
这也不对。
在很多普通并发场景里,synchronized 仍然是非常自然、清晰而且可靠的选择。
8. volatile 又解决什么问题
volatile 可以理解成 告诉 JVM 和 CPU,这个变量的读写不能随便缓存或重排序,要尽量保证线程之间可见。
它也是 Java 关键字。
从字面上看,volatile 常被翻成:
易变的 或 易失的。
它主要解决的是:
- 可见性
- 一定程度上的有序性
但它不解决:复合操作的原子性
所以 volatile 不能直接替代锁。
8.1 一个最典型的 volatile 示例:线程停止标记
java
public class Worker implements Runnable {
private volatile boolean running = true;
@Override
public void run() {
while (running) {
// 执行任务
}
System.out.println("worker stopped");
}
public void stop() {
running = false;
}
}这段代码的重点是:
- 一个线程在
while (running)中持续工作 - 另一个线程调用
stop()把running改成false - 因为
running是volatile,工作线程能及时看到这个变化
这个例子特别适合理解:
volatile 更擅长解决“一个线程改了值,另一个线程要尽快看见”这类问题。
但它不适合直接拿来做这种操作:
java
volatile int count = 0;
count++;因为 count++ 仍然不是原子操作。
8.2 为什么双重检查锁通常要配合 volatile
double-checked locking 可以理解成 先不加锁检查一次,只有在对象还没创建时才进入同步块再次检查,从而减少每次获取实例都加锁的成本。
它经常出现在单例模式里。
但这里有一个很关键的边界:如果实例引用没有用 volatile 修饰,可能出现“引用已经赋值,但对象还没完全初始化完成”的重排序问题。
示例:
java
public class Singleton {
/**
* 保存全局单例实例。
* 使用 volatile 是为了禁止关键初始化步骤被重排序,
* 避免其他线程读到“引用非空,但对象还没初始化完成”的半初始化对象。
*/
private static volatile Singleton instance;
/**
* 模拟需要初始化的业务字段。
*/
private final String config;
/**
* 构造方法负责完成对象初始化。
*/
private Singleton() {
this.config = "ready";
}
/**
* 获取单例对象。
* 第一次判空避免每次都进入同步块;
* 第二次判空避免多个线程排队进入后重复创建对象。
*
* @return 全局唯一的 Singleton 实例
*/
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
/**
* 返回初始化后的配置值。
*
* @return 配置内容
*/
public String getConfig() {
return config;
}
}这个例子最值得记住的不是“单例怎么背”,而是:
volatile不只是让别的线程“看到最新值”- 它还会约束关键的重排序
- 某些并发写法之所以正确,本质上依赖的是
JMM的可见性和有序性规则
9. Lock 和 synchronized 有什么区别
Java 里常见会把 synchronized 和 ReentrantLock 放在一起讨论。
可以这样理解:
synchronized:语言内置同步机制Lock:更灵活的显式锁接口
这里的名字也可以记清:
Lock可以理解成锁接口ReentrantLock可以理解成可重入锁
ReentrantLock 常见优势包括:
- 更灵活的获取和释放控制
- 可中断锁
- 可尝试获取锁
- 可配合多个条件队列
但它也意味着:你要自己更谨慎地管理加锁和释放。
9.1 一个最典型的 Lock 示例:显式加锁和释放
java
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Account {
private final Lock lock = new ReentrantLock();
private int balance = 100;
public void withdraw(int amount) {
lock.lock();
try {
if (balance >= amount) {
balance -= amount;
}
} finally {
lock.unlock();
}
}
public int getBalance() {
return balance;
}
}这段代码的重点是:
- 先显式
lock.lock() - 在
try里执行临界区逻辑 - 无论中间是否异常,都在
finally里unlock()
这个写法特别值得记住,因为 Lock 和 synchronized 最大的使用区别之一就是:
Lock 需要你自己显式释放,忘记释放就会有问题。
如果你想尝试“拿不到锁就先不等”,Lock 还可以这样写:
java
if (lock.tryLock()) {
try {
// 拿到锁才执行
} finally {
lock.unlock();
}
} else {
// 没拿到锁,走降级或重试逻辑
}这个例子能帮助理解为什么说 Lock 比 synchronized 更灵活:
它不只是能加锁,还能让你控制是否等待、等多久、失败后怎么处理。
10. Java JUC 中还有哪些常见的多线程治理方案
如果只把并发理解成:
synchronizedvolatileLock
那还不够完整。在真实项目里,多线程问题并不总是通过“上一把锁”来解决。
如果把视角切到 JUC,最值得先建立的认知是:JUC 不是某一个单点 API,而是一整组围绕并发问题组织出来的工具。
Java 常见的并发治理方案可以分成几类:
- 同步控制
- 原子更新
- 并发容器
- 线程协作
- 异步编排
- 线程隔离
- 从设计上减少共享冲突
如果你想先抓最核心的主干,也可以按下面这 4 大类记:
- 同步控制:
synchronized、Lock - 原子与可见性:
volatile、Atomic* - 线程协作:
CountDownLatch、Semaphore、BlockingQueue - 设计降冲突:
ThreadLocal、不可变对象、无状态设计
这个四分类的价值在于:先快速判断当前问题到底是共享资源保护、变量更新、线程配合,还是应该从设计上减少共享。
如果继续往下拆,才会细分出并发容器、异步编排、线程池等更具体的工具和场景。
10.1 原子类:解决单变量原子更新
常见类包括:
AtomicInteger:原子整数AtomicLong:原子长整数AtomicBoolean:原子布尔值AtomicReference:原子引用
它们主要解决的是:单个共享变量在并发场景下的原子更新问题。
例如:
java
import java.util.concurrent.atomic.AtomicInteger;
public class VisitCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet();
}
public int get() {
return count.get();
}
}这个例子适合帮助理解:如果只是一个简单计数器,不一定非要上锁,也可以直接用原子类。
但它的边界也要记住:原子类更擅长保护单个变量,不适合直接解决多个变量之间的一致性问题。
10.2 LongAdder:解决高并发计数热点
LongAdder 可以理解成 在高并发计数场景下,减少单点竞争的一种计数方案。
从名字上看,它可以看成 长整型累加器。
它常见于:
- QPS 统计
- 访问次数统计
- 高频计数器
如果只是一般计数,AtomicLong 往往已经够用;
如果是特别高并发的统计型计数,LongAdder 往往更有优势。
10.3 并发容器:解决共享集合的线程安全问题
常见代表包括:
ConcurrentHashMap:并发哈希表CopyOnWriteArrayList:写时复制数组列表ConcurrentLinkedQueue:并发链式队列BlockingQueue:阻塞队列
它们解决的是:多个线程共享同一个容器时,如何避免普通集合那种直接并发访问带来的问题。
例如:
ConcurrentHashMap:并发映射CopyOnWriteArrayList:读多写少列表BlockingQueue:生产者消费者协作
这里最值得建立的判断是:如果问题本质上是“共享容器怎么安全访问”,优先先想并发容器,而不一定是自己在外面包一层粗粒度大锁。
10.4 线程协作工具:解决等待、同步和限流问题
这一类工具的重点不是“互斥修改共享变量”,而是:多个线程之间如何协调节奏。
常见代表包括:
CountDownLatch:倒计时门闩CyclicBarrier:循环屏障Semaphore:信号量Phaser:阶段同步器
为什么现代 JUC 更常用这些工具,而不是直接手写 wait/notify
在 Object 这一层,Java 很早就提供了:
wait()notify()notifyAll()
它们也能做线程协作,但要注意边界:wait/notify 解决的是“持有同一把对象锁的线程,如何在条件不满足时挂起,条件满足后再被唤醒”。`
这套机制能用,但在工程上经常有几个问题:
- 必须和
synchronized配合使用 - 容易写出漏通知、错误唤醒、条件判断不严谨的问题
- 一把对象锁通常只能承载一组粗粒度等待语义
所以 JUC 更常见的思路是:把“互斥访问”和“条件等待”拆开表达,让协作语义更清晰。
Condition 和 wait/notify 是什么关系
可以这样理解:
wait/notify是基于对象监视器的等待通知机制Condition是Lock体系里更清晰的条件队列抽象
这里的 Condition 可以理解成 条件变量 或 条件队列。
它们解决的是同一类问题:线程在条件不满足时等待,在条件满足时被唤醒。
但 Condition 更适合复杂场景,因为:
- 它显式绑定某把
Lock - 同一把锁下可以创建多组条件队列
- 代码结构通常更容易看出“为什么等待、何时唤醒”
例如下面这个“有界消息盒子”示例,就比手写 wait/notify 更容易看懂:
java
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class MessageBox {
/**
* 保护消息盒子状态的互斥锁。
*/
private final Lock lock = new ReentrantLock();
/**
* 盒子为空时,消费者在这个条件上等待。
*/
private final Condition notEmpty = lock.newCondition();
/**
* 盒子已满时,生产者在这个条件上等待。
*/
private final Condition notFull = lock.newCondition();
/**
* 盒子里当前保存的消息。
* 这里用 null 表示“空盒子”。
*/
private String message;
/**
* 放入一条消息。
* 如果盒子里已经有消息,生产者就先等待消费者取走。
*
* @param newMessage 要放入盒子的消息内容
* @throws InterruptedException 等待过程中如果线程被中断则抛出
*/
public void put(String newMessage) throws InterruptedException {
lock.lock();
try {
while (message != null) {
notFull.await();
}
message = newMessage;
notEmpty.signal();
} finally {
lock.unlock();
}
}
/**
* 取出一条消息。
* 如果盒子为空,消费者就先等待生产者放入消息。
*
* @return 取出的消息内容
* @throws InterruptedException 等待过程中如果线程被中断则抛出
*/
public String take() throws InterruptedException {
lock.lock();
try {
while (message == null) {
notEmpty.await();
}
String result = message;
message = null;
notFull.signal();
return result;
} finally {
lock.unlock();
}
}
}这个例子最关键的阅读点是:
- 生产者和消费者等待的是不同条件
- 等待条件必须用
while重试,而不是if signal()/await()的表达力比wait()/notify()更贴近业务语义
AQS 是什么,为什么很多 JUC 工具都绕不开它
AQS 是 AbstractQueuedSynchronizer 的缩写,中文常译作:
抽象队列同步器。
可以理解成 JUC 里一套用来搭建锁和同步器的基础骨架。
它本身不是直接给业务代码使用的高层工具,而是很多 JUC 组件的底层支撑,例如:
ReentrantLockSemaphoreCountDownLatchReentrantReadWriteLock
可以把它理解成三件事的组合:
- 一个表示同步状态的
state - 一个在获取失败后排队等待的线程队列
- 一套获取、释放、唤醒后继节点的模板流程
为了帮助理解,可以看一个“去掉细节后的获取流程”:
java
public final void acquire(int arg) {
if (!tryAcquire(arg)) {
Node node = addWaiter();
boolean interrupted = false;
for (;;) {
if (node.prev == head && tryAcquire(arg)) {
setHead(node);
if (interrupted) {
selfInterrupt();
}
return;
}
if (shouldParkAfterFailedAcquire(node)) {
parkCurrentThread();
}
if (Thread.interrupted()) {
interrupted = true;
}
}
}
}这段代码不是 JDK 源码的完整拷贝,而是压缩后的示意版本。它想表达的核心是:
- 先尝试直接拿同步状态
- 拿不到就进入等待队列
- 排到自己且条件满足时再尝试获取
- 还不满足就挂起等待唤醒
所以很多 JUC 工具看起来 API 不一样,但底层常常有相通的结构:先定义状态语义,再定义获取失败后的排队与唤醒规则。
CountDownLatch
CountDownLatch 这个名字可以拆成:
CountDown:倒计时Latch:门闩、闭锁
适合 一个线程等待一组任务完成。
例如主线程等待多个子任务结束后,再统一汇总结果。
CyclicBarrier
CyclicBarrier 可以理解成 可循环使用的屏障。
适合 多个线程都到达某个阶段后,再一起继续往下执行。
Semaphore
Semaphore 可以理解成 信号量。
适合 限制同时访问某个资源的线程数量。
例如:
- 限制并发请求数
- 限制数据库连接占用数
- 限制某个外部资源的并发访问量
10.5 线程池和异步编排:解决任务调度与复用问题
并发不只是“多个线程同时改数据”,还包括:任务怎么被分发、复用和编排。
常见工具包括:
ThreadPoolExecutor:线程池执行器Future:异步结果占位对象Callable:带返回值的可调用任务接口CompletableFuture:可编排、可组合的异步结果容器
这里可以建立一个分工:
线程池:解决线程复用和任务调度Future / Callable:解决异步执行并拿返回值CompletableFuture:解决更复杂的异步任务编排和结果组合
10.6 ThreadLocal:解决线程隔离问题
ThreadLocal 可以理解成 给每个线程一份独立变量副本,而不是让多个线程共享同一份数据。
从名字上看,ThreadLocal 可以理解成 线程本地变量。
它解决的问题不是“多个线程如何安全共享”,而是:有些数据干脆就不共享。
典型场景包括:
- 用户上下文
- TraceId
- 数据库连接上下文
- 每线程独立格式化器
所以 ThreadLocal 的关键思路是:通过隔离来减少并发冲突。
10.7 从设计上减少并发问题
这部分不是某个具体 API,但在工程里往往更重要。
常见思路包括:
- 无状态设计
- 不可变对象
- 消息传递
- 任务队列
无状态设计
如果对象不保存共享可变状态,很多线程安全问题根本不会出现。
不可变对象
如果对象创建后状态不再变化,并发访问的风险会明显降低。
消息传递
如果线程之间不直接共享数据,而是通过队列传任务、传消息,也能有效减少锁竞争。
10.8 一张总表
为了避免只看到“它能解决什么”,却忽略“它要付出什么代价”,可以用下面这张表建立整体判断:
| 方案 | 主要解决什么问题 | 典型场景 | 主要代价 / 局限 |
|---|---|---|---|
synchronized / Lock | 互斥访问共享资源 | 余额扣减、共享状态保护 | 阻塞等待、锁竞争、可能死锁 |
volatile | 可见性、轻量状态同步 | 停止标记、配置开关 | 不能保证复合操作原子性 |
Atomic* | 单变量原子更新 | 计数器、状态位 | 不擅长多个变量一致性 |
LongAdder | 高并发计数热点 | 访问量统计 | 更适合统计,不适合复杂一致性场景 |
| 并发容器 | 共享容器安全访问 | 缓存、队列、监听器 | 复合逻辑仍可能需要额外同步 |
CountDownLatch / CyclicBarrier / Semaphore | 线程协作与同步 | 汇总、分阶段同步、限流 | 工具选错后流程容易变复杂 |
线程池 / CompletableFuture | 任务复用与异步编排 | 并发任务执行、结果组合 | 调度链路更复杂,参数配置不当会出问题 |
ThreadLocal | 线程隔离 | 上下文传递 | 在线程池场景要注意残留和清理 |
| 无状态 / 不可变对象 / 消息传递 | 从设计上减少共享冲突 | 高并发服务设计 | 对设计要求更高,改造成本更大 |
10.9 这些方案各自有什么优缺点
这些方案并不是“谁更高级就替代谁”,而是各自解决不同问题,同时也各自带来不同代价。多线程治理方案的本质,是在下面几件事之间做权衡:
- 正确性
- 性能
- 实现复杂度
- 可维护性
1. 同步控制:synchronized、Lock
优点:
- 最直接地解决共享资源互斥访问问题
- 语义清晰,适合保护临界区
synchronized写法简单,可靠性高Lock更灵活,支持中断、超时、尝试获取等能力
缺点:
- 会带来阻塞、上下文切换和锁竞争
- 用不好容易死锁
- 锁范围过大时吞吐会明显下降
Lock需要手动释放,忘记释放会出问题
2. 原子与可见性:volatile、Atomic*
优点:
- 比显式加锁更轻量
- 适合简单状态同步和单变量原子更新
- 并发性能通常更好
- 简单场景下代码更简洁
缺点:
volatile只能解决可见性和部分有序性,不能解决复合操作原子性Atomic*更擅长保护单个变量,不擅长多个变量一致性CAS在高竞争下可能自旋重试,带来 CPU 开销- 一旦场景变复杂,很容易误以为“没加锁也安全”
3. 线程协作:CountDownLatch、Semaphore、BlockingQueue
优点:
- 更适合解决线程之间怎么配合,而不是谁来抢同一把锁
- 语义通常比手写
wait/notify更清晰 - 对等待、限流、生产消费这类问题表达力更强
BlockingQueue还能顺带解耦生产者和消费者
缺点:
- 不适合拿来替代所有同步控制
- 工具选错时,流程会变得很绕
- 协作链条一复杂,排查问题会变难
- 某些工具有明确生命周期限制,例如
CountDownLatch不能重复复用
4. 设计降冲突:ThreadLocal、不可变对象、无状态设计
优点:
- 从根上减少共享,自然减少锁竞争
- 往往比“出问题后补锁”更优雅
- 更容易扩展,也更适合高并发系统
- 不可变对象天然更安全
缺点:
- 不是所有业务都能做到无状态
ThreadLocal容易被误用,还要注意线程池中的残留问题- 不可变对象可能带来更多对象创建成本
- 这种方案对设计能力要求更高,不是简单换个 API 就能解决
10.10 一张优缺点对比表
| 方案类别 | 优点 | 缺点 |
|---|---|---|
| 同步控制 | 直接、清晰,适合保护共享可变状态 | 有阻塞、锁竞争、死锁和复杂度代价 |
| 原子与可见性 | 轻量、性能好,适合简单共享变量 | 能力边界窄,不适合复杂一致性问题 |
| 线程协作 | 更适合表达等待、同步、限流和生产消费 | 工具选型不当时流程会变复杂 |
| 设计降冲突 | 从根上减少共享,长期通常更优 | 设计要求更高,并非所有场景都适用 |
10.11 怎么理解这些优缺点
synchronized / Lock:最直接,但有阻塞代价volatile / Atomic*:更轻,但能力边界更窄CountDownLatch / Semaphore / BlockingQueue:擅长线程协作,不是单纯保护共享变量ThreadLocal / 无状态 / 不可变对象:从设计上减少冲突,长期往往更优
所以并发里通常没有“万能最优解”,只有:
- 当前问题到底是共享状态保护,还是线程协作
- 你更在意正确性、吞吐,还是实现复杂度
- 能不能通过设计减少共享,而不是一上来就加锁
10.12 一句话总结
Java 里解决多线程问题,不只有“加锁”这一条路。
常见方案有:
- 互斥和同步
- 原子更新
- 容器级并发支持
- 线程协作工具
- 异步任务编排
- 线程隔离
- 从设计上减少共享
真正成熟的并发设计,往往不是“到处加锁”,而是 先判断问题到底属于共享状态保护、线程协作,还是任务编排,再选最合适的工具。
11. 线程池为什么重要
如果每来一个任务就新建一个线程,系统很快会遇到:
- 线程创建和销毁成本高
- 线程数量失控
- 调度开销增大
- 内存占用上升
线程池 可以理解成 提前准备并复用一批线程,由它们持续执行任务。
它解决的是:
- 线程复用
- 并发数量控制
- 任务排队和削峰
11.1 线程池到底是什么
线程池 可以看成 提前准备并复用一批线程,用来持续执行任务,而不是每来一个任务就临时创建一个新线程。
它本质上管理的是两件事:
- 线程资源
- 任务调度
可以这样想象:
- 线程池里先有一批工作线程
- 外部不断往线程池提交任务
- 工作线程从任务队列里取任务执行
- 执行完之后线程不销毁,而是继续等下一个任务
所以线程池的关键,不只是“有很多线程”,而是 线程被统一复用,任务被统一调度。
11.2 为什么不用 new Thread() 直接启动
例如下面这种写法:
java
new Thread(task).start();偶尔执行一次任务没问题,但如果系统里任务很多,这种方式会很快带来几个问题:
- 线程创建和销毁有明显成本
- 线程数量容易失控
- 大量线程会带来上下文切换开销
- 内存占用会持续升高
所以线程池真正解决的是 不要把“任务数量”直接等同于“线程数量”。
11.3 一个最简单的线程池示例
java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadPoolDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(3);
for (int i = 1; i <= 5; i++) {
int taskId = i;
executor.submit(() -> {
System.out.println(Thread.currentThread().getName() + " 执行任务 " + taskId);
});
}
executor.shutdown();
}
}这段代码可以这样理解:
newFixedThreadPool(3)创建了一个固定 3 个线程的线程池- 一共提交了 5 个任务
- 同一时刻最多只有 3 个线程并发执行
- 多出来的任务会先进入队列等待
shutdown()表示线程池不再接收新任务,但会把已提交任务执行完
11.4 ExecutorService 是什么
ExecutorService 可以理解成 Java 中更常用的一层线程池抽象接口。
从命名上看,它可以记成:执行器服务接口。
它解决的是 你通常不需要直接手动管理每个线程,而是把任务交给执行器统一处理。
所以现代 Java 并发代码里,更常见的是:
- 提交任务给
ExecutorService - 由它内部调度线程执行
而不是自己到处 new Thread()
11.5 一个更完整的 ThreadPoolExecutor 示例
java
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class ThreadPoolExecutorDemo {
public static void main(String[] args) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2,
4,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(2),
new ThreadPoolExecutor.AbortPolicy()
);
for (int i = 1; i <= 6; i++) {
int taskId = i;
executor.execute(() -> {
System.out.println(Thread.currentThread().getName() + " 执行任务 " + taskId);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
executor.shutdown();
}
}这个例子想表达的是:线程池不是只有“几个线程”这么简单,它其实还包括核心线程数、最大线程数、任务队列和拒绝策略这些运行规则。
这里的 ThreadPoolExecutor 也可以直接从名字上理解成:线程池执行器。
11.6 线程池最核心的几个参数是什么意思
1. corePoolSize
核心线程数。
可以理解成 线程池平时优先维持的线程数量。
2. maximumPoolSize
最大线程数。
表示线程池最多能扩到多少线程。
3. keepAliveTime
空闲存活时间。
通常用于控制:那些超过核心线程数的额外线程,空闲多久后可以被回收。
4. workQueue
任务队列。
当核心线程都忙时,新任务通常先进入队列等待。
5. RejectedExecutionHandler
拒绝策略。
它解决的是 当线程数已经到上限、队列也满了,再来新任务该怎么办。
11.7 一个带返回值的线程池示例
java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class FutureDemo {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(2);
Future<Integer> future = executor.submit(() -> {
Thread.sleep(1000);
return 100;
});
System.out.println("主线程先做别的事...");
Integer result = future.get();
System.out.println("任务结果: " + result);
executor.shutdown();
}
}这段代码适合帮助理解:
- 线程池不只是能执行
Runnable - 它也可以执行有返回值的任务
- 主线程可以继续做别的事情
- 后面再通过
future.get()取到结果
11.8 线程池适合什么场景
常见场景包括:
- 服务端异步任务
- 批量任务处理
- 并发调用多个下游服务
- 日志处理、消息消费
- 定时或后台任务执行
这里有一个很容易混淆的点:线程池适合做进程内异步,不等于它可以替代消息队列。
更具体一点:
- 线程池解决的是当前 Java 进程里的任务并发、线程复用和资源控制
- 消息队列解决的是跨系统异步、服务解耦、流量削峰和可靠投递
例如:
- 一个接口里并行查询多个下游服务,通常更适合线程池
- 订单创建后通知库存、积分、短信多个系统,通常更适合消息队列
如果你对这两个概念还容易混在一起,建议顺着看一眼消息队列总览里的这一节:
11.9 Java 中常见的线程池方案有哪些
如果继续往下看,Java 里的线程池并不只有一种固定写法。
最常见的方案可以分成这几类:
1. ThreadPoolExecutor
这是最核心、最通用的线程池实现。
特点:
- 可自定义核心线程数、最大线程数、队列和拒绝策略
- 控制粒度最细
- 最适合生产环境中的精细化配置
适合场景:
- 业务线程池
- 服务端异步任务
- 需要明确控制资源上限的场景
2. FixedThreadPool
固定大小线程池。
特点:
- 线程数固定
- 适合并发量相对稳定的场景
- 不会像缓存线程池那样无限扩张线程数
- 但任务太多时,任务可能在队列里持续堆积
适合场景:
- 稳定并发任务处理
- 希望限制线程数量的普通业务场景
3. SingleThreadExecutor
单线程线程池。
特点:
- 永远只有一个工作线程
- 任务按顺序串行执行
- 能保证执行顺序
适合场景:
- 顺序敏感任务
- 串行消费
- 单线程状态机式处理
4. CachedThreadPool
可缓存线程池。
特点:
- 空闲线程会复用
- 没有空闲线程时会新建线程
- 线程数可能快速增长
- 吞吐高,但失控风险也更高
适合场景:
- 大量短生命周期任务
- 突发型异步任务
5. ScheduledThreadPool
定时线程池。
特点:
- 支持延迟执行
- 支持周期执行
- 能替代简单定时器方案
适合场景:
- 定时任务
- 周期轮询
- 延迟执行任务
6. SingleThreadScheduledExecutor
单线程定时线程池。
特点:
- 定时执行
- 同时保持串行顺序
- 不会并发执行多个定时任务
适合场景:
- 既要求定时,又要求顺序的任务
- 避免多个定时任务并发冲突
7. ForkJoinPool
分治型线程池。
特点:
- 适合任务拆分后再合并
- 使用工作窃取思想
- 更适合计算密集型任务
适合场景:
- 分治计算
- 并行计算
- 大任务拆小任务执行
8. newWorkStealingPool
本质上基于 ForkJoinPool 的工作窃取线程池。
特点:
- 空闲线程会“偷取”其他线程队列里的任务
- 更适合大量小任务并行处理
- 吞吐通常较好,但执行顺序不容易直观控制
适合场景:
- 大量独立小任务
- 并行计算场景
11.10 一张线程池方案对比表
| 方案 | 特点 | 适合场景 |
|---|---|---|
ThreadPoolExecutor | 可高度定制,控制最细 | 生产环境通用线程池 |
FixedThreadPool | 固定线程数 | 稳定并发任务 |
SingleThreadExecutor | 单线程串行执行 | 顺序敏感任务 |
CachedThreadPool | 线程可快速扩张 | 大量短任务、突发任务 |
ScheduledThreadPool | 支持延迟和周期任务 | 定时任务 |
SingleThreadScheduledExecutor | 单线程定时执行 | 顺序定时任务 |
ForkJoinPool | 分治、工作窃取 | 计算密集型并行任务 |
WorkStealingPool | 多线程偷取任务 | 大量独立小任务 |
11.11 怎么选线程池方案
可以这样记:
- 生产环境优先考虑
ThreadPoolExecutor - 固定并发场景优先想
FixedThreadPool - 必须串行执行时优先想
SingleThreadExecutor - 大量短任务才考虑
CachedThreadPool - 定时任务优先想
ScheduledThreadPool - 并行计算优先想
ForkJoinPool
一个很重要的工程结论是:很多 Executors 提供的工厂方法虽然方便,但默认参数不一定适合生产环境。
所以在真实项目里,更常见也更稳妥的做法往往是:基于 ThreadPoolExecutor 显式配置线程数、任务队列和拒绝策略。
11.12 Java 定时任务到底在解决什么问题
Java 里的定时任务,核心不是“过一会儿执行一段代码”这么简单,而是:
- 某个任务要不要延迟执行
- 某个任务要不要周期执行
- 任务执行时,线程资源怎么复用
- 多个定时任务并存时,如何避免每个任务都自己起线程
🌟 一个很重要的边界是:Java 原生定时任务更具体一点是进程内调度。
它更适合解决:
- 当前 Java 进程里的延迟任务
- 当前服务实例里的周期轮询
- 轻量后台补偿任务
它不直接解决:
- 集群里只能执行一份
- 任务持久化恢复
- 可视化任务管理
11.13 ScheduledExecutorService 和 ScheduledThreadPoolExecutor 是什么关系
如果你在 Java 里想写定时任务,更常见的现代做法通常不是 Timer,而是:
- 面向
ScheduledExecutorService这个调度接口编程 - 底层通常使用
ScheduledThreadPoolExecutor
可以这样理解:
ScheduledExecutorService定义了“延迟执行、固定频率执行、固定间隔执行”这些能力ScheduledThreadPoolExecutor是它最常见的实现Executors.newScheduledThreadPool(...)本质上也是帮你创建一个ScheduledThreadPoolExecutor
这条线和普通线程池的关系是:它本质上还是线程池,只是在线程复用之外,又补了调度能力。
11.14 一个延迟任务和周期任务示例
下面这个例子演示两种很常见的任务:
- 订单创建后,延迟关闭超时未支付订单
- 每隔一段时间执行一次失败通知补偿
java
import java.time.LocalTime;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* 演示 Java 原生定时任务的两种典型用法:
* 1. 延迟执行一次
* 2. 按固定间隔重复执行
*/
public class OrderTaskSchedulerDemo {
public static void main(String[] args) throws InterruptedException {
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
try {
// 订单创建 15 秒后还未支付,就触发一次超时关闭检查。
scheduler.schedule(
OrderTaskSchedulerDemo::closeTimeoutOrders,
15,
TimeUnit.SECONDS);
// 每轮任务执行结束后,间隔 30 秒再做一次失败通知补偿。
scheduler.scheduleWithFixedDelay(
OrderTaskSchedulerDemo::retryFailedNotifications,
0,
30,
TimeUnit.SECONDS);
// 这里只是为了让 demo 主线程暂时不要退出,方便观察输出。
Thread.sleep(70_000);
} finally {
scheduler.shutdown();
}
}
/**
* 关闭超时未支付订单。
*/
private static void closeTimeoutOrders() {
System.out.println(LocalTime.now() + " 开始扫描超时订单");
// findTimeoutUnpaidOrders();
// closeOrder(orderId);
}
/**
* 补偿失败的通知任务。
*/
private static void retryFailedNotifications() {
System.out.println(LocalTime.now() + " 开始补偿失败通知");
// loadFailedNotifications();
// resendNotification(notificationId);
}
}这段代码最值得注意的是:
schedule(...)适合执行一次性的延迟任务scheduleWithFixedDelay(...)更适合上一轮执行结束后,再等一段时间继续下一轮- 多个任务可以复用同一个调度线程池,而不是每个任务自己 new 线程
11.15 scheduleAtFixedRate 和 scheduleWithFixedDelay 有什么区别
这两个 API 很容易混。
| 方法 | 下次执行的基准点 | 更适合什么场景 |
|---|---|---|
scheduleAtFixedRate | 以上一次计划开始时间为基准 | 固定节奏采集、指标上报 |
scheduleWithFixedDelay | 以上一次实际执行结束时间为基准 | 补偿扫描、轮询、批处理 |
例如:
java
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class ScheduleModeDemo {
public static void main(String[] args) throws InterruptedException {
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
try {
// 希望每 10 秒触发一次指标采集,更关注“节奏固定”。
scheduler.scheduleAtFixedRate(
ScheduleModeDemo::collectMetrics,
0,
10,
TimeUnit.SECONDS);
// 希望每轮同步结束后休息 10 秒,更关注“不要挤在一起执行”。
scheduler.scheduleWithFixedDelay(
ScheduleModeDemo::syncReport,
0,
10,
TimeUnit.SECONDS);
// 只是为了让示例在主线程退出前能观察到几轮执行效果。
Thread.sleep(30_000);
} finally {
scheduler.shutdown();
}
}
private static void collectMetrics() {
// collectJvmMetrics();
}
private static void syncReport() {
// syncDailyReport();
}
}🌟 可以这样记:
- 想要更稳定的时间节奏,优先想
scheduleAtFixedRate - 想要上一轮做完再等下一轮,优先想
scheduleWithFixedDelay
11.16 Java 定时任务的常见边界
定时任务最容易被误解的地方,是以为“代码能定时跑起来”,就等于任务系统已经设计好了。
要注意的是:
- 它通常只保证当前进程里的调度,不保证集群只跑一份
- 如果应用重启,任务状态和调度进度默认不会自动持久化恢复
- 任务执行时间过长,会影响下一轮调度节奏
- 线程池配得太小,多个任务之间会互相阻塞
所以更自然的选择通常是:
- 进程内轻量任务,优先考虑
ScheduledExecutorService - 如果在 Spring 项目里写业务调度,更常见的是用
@Scheduled - 如果已经进入集群、多实例、任务治理场景,就要继续考虑分布式调度方案
相关内容可以继续看:
11.17 线程池的常见误区
1. 以为线程越多越好
不对。
线程太多会带来:
- 上下文切换开销
- 内存占用增加
- 调度负担加重
2. 以为线程池只是“把线程存起来”
不够准确。
线程池真正重要的还有:
- 任务队列
- 拒绝策略
- 并发上限控制
- 生命周期管理
3. 忘记关闭线程池
如果线程池不关闭,相关线程和资源可能一直不释放。
11.18 一句话总结
线程池本质上就是:用一组可复用的线程来执行一批任务,从而降低线程创建成本、控制并发数量,并统一管理任务执行。
12. ConcurrentHashMap 为什么常被拿来讲
因为它是 Java 并发容器里非常典型的一类。
从名字上看,ConcurrentHashMap 可以理解成 并发哈希表。
它解决的问题是 在多线程场景下,如何让哈希表既尽量保证线程安全,又不至于像粗粒度全表加锁那样严重拖垮性能。
这里不展开底层所有细节,但至少要先建立一个判断:
HashMap 适合单线程或外部同步场景,ConcurrentHashMap 更适合并发读写场景。
12.1 它和 HashMap 的关键差别,不只是“线程安全”
如果只记一句“ConcurrentHashMap 是线程安全的 HashMap”,还是太薄了。
更值得建立的判断是:
- 它不是给整张表加一把粗粒度大锁
- 它会尽量让无冲突读写并发进行
- 它大量依赖
CAS、分桶结构以及必要时的局部同步
所以它真正解决的是:在保证并发安全的前提下,尽量不要把所有访问都串行化。
12.2 为什么它不允许 null
ConcurrentHashMap 不允许:
null keynull value
原因不是“语法上不想支持”,而是:在并发场景下,null 很难区分“这个 key 不存在”还是“这个 key 对应的 value 恰好就是 null”。
普通 HashMap 里这个歧义还可以勉强靠上下文理解; 但并发读写场景下,这会让结果判断和重试逻辑变得更混乱。
12.3 它线程安全,也不代表复合操作天然原子
这一点特别容易误解。
例如下面这种“先查再放”的写法,在并发下仍然可能有问题:
java
if (userCache.get(userId) == null) {
userCache.put(userId, queryUserById(userId));
}问题不在于单次 get() 或 put() 不安全,而在于:这是一段复合逻辑,中间仍然可能被其他线程插入。
更合适的写法通常是使用这类原子复合方法:
putIfAbsentcomputeIfAbsentreplace
例如:
java
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;
public class RouteCounter {
/**
* 保存接口路径和访问次数的映射关系。
*/
private final ConcurrentHashMap<String, LongAdder> counters = new ConcurrentHashMap<>();
/**
* 记录某个接口的一次访问。
* 先用 computeIfAbsent 原子化地初始化计数器,
* 再对已经存在的 LongAdder 执行高并发自增。
*
* @param path 接口路径
*/
public void record(String path) {
counters.computeIfAbsent(path, key -> new LongAdder()).increment();
}
/**
* 返回某个接口当前累计访问次数。
*
* @param path 接口路径
* @return 当前累计值
*/
public long count(String path) {
LongAdder adder = counters.get(path);
return adder == null ? 0 : adder.sum();
}
}这个例子想表达的重点是:
ConcurrentHashMap适合做共享映射容器- 复合逻辑最好优先使用容器自带的原子复合方法
- 高并发计数热点常常会和
LongAdder组合使用
12.4 一个简化过的 put 思路怎么理解
如果从源码视角抓主线,可以把它理解成下面几步:
- 先定位桶位
- 目标桶为空时,优先尝试
CAS放入 - 目标桶有元素时,再进入该桶对应的同步流程
- 冲突链过长时,可能转成树结构
所以它不是“完全无锁”,而是:尽量让无冲突路径走得更轻,只有在必要时才进入局部同步。
13. 死锁到底是什么
死锁 可以理解成 多个线程互相等待对方释放资源,最终谁也无法继续执行。
最典型的场景是:
- 线程 A 持有锁 1,等待锁 2
- 线程 B 持有锁 2,等待锁 1
这时就形成了循环等待。
常见治理思路包括:
- 固定加锁顺序
- 减少锁粒度和持锁时间
- 尽量避免嵌套锁过深
14. 工程上最常见的几个误区
14.1 误区一:用了 volatile 就线程安全了
不对。
它主要解决可见性,不直接保证复合操作原子性。
14.2 误区二:并发就是多开几个线程
真正困难的是 共享数据在多线程下还能不能保持正确。
14.3 误区三:线程越多吞吐越高
线程太多反而会带来:
- 上下文切换开销
- 锁竞争加剧
- 内存压力上升
15. 一句话总结
并发编程与线程安全这条主线,本质上是在解决:
多个线程同时工作时,数据如何保持正确、线程如何安全协作,以及系统如何在性能和正确性之间找到平衡。