Skip to content

并发编程、JUC 与线程安全

这篇笔记聚焦 Java 里最容易“会用线程,但讲不清为什么会出问题”的一条主线:

  1. 线程和进程是什么关系
  2. JUC 到底是什么,和 Threadsynchronized、线程池分别是什么关系
  3. 为什么多线程会引入竞态问题
  4. synchronizedvolatileLock 分别解决什么问题
  5. 线程池为什么重要
  6. 并发编程里最常见的误区是什么

1. 为什么并发会成为 Java 的核心主线

因为业务系统一旦进入服务端场景,就很少只有单线程逻辑。

你会很快遇到:

  1. 一个服务同时处理多个请求
  2. 多个线程共享同一份数据
  3. 多个任务需要异步执行
  4. 一张总表系统既要正确,又要有吞吐

这说明并发编程解决的不是“怎么同时写多个方法”,而是 在多个执行单元同时工作时,如何平衡正确性、可见性和性能

1.1 JUC 到底是什么

JUCjava.util.concurrent 的常见简称。

可以把它理解成 Java 并发编程里一组更偏工程化的工具包

它主要覆盖几类能力:

  1. 锁和同步器,例如 Lock(锁接口)、CountDownLatch(倒计时门闩)、Semaphore(信号量)
  2. 原子类,例如 AtomicInteger(原子整数)、AtomicReference(原子引用)
  3. 并发容器,例如 ConcurrentHashMap(并发哈希表)、BlockingQueue(阻塞队列)
  4. 线程池与异步编排,例如 ExecutorService(执行器服务接口)、ThreadPoolExecutor(线程池执行器)、CompletableFuture(可编排的异步结果容器)

所以很多人平时说“学 Java 并发”,工程上大多就是在学这两层东西:

  1. Java 并发基础,例如 Threadsynchronizedvolatile
  2. JUC 提供的更高层并发工具

1.2 JUCThreadsynchronized 是什么关系

这几个词很容易混在一起,但它们不在同一层。

可以这样区分:

  1. Thread 是线程这个执行单元本身
  2. synchronizedvolatile 更偏语言和内存语义层的基础能力
  3. JUC 更像建立在这些基础之上的并发工具箱

更贴近工程实践的理解是,先有线程和共享数据问题,再有同步原语,最后才是 JUC 把常见并发场景包装成更容易复用的工具


2. Thread 是什么

Thread 可以看成 Java 中表示线程的类

但如果只记住“它是个类”,还是太薄了。Thread 同时有两层含义:

  1. 从概念上看,它表示程序中的一条执行路径
  2. 从代码上看,它是 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("主线程执行");
    }
}

这段代码可以这样理解:

  1. main 方法本身就在主线程里执行
  2. new Thread(...) 创建了一个线程对象
  3. start() 才是真正启动新线程
  4. 启动之后,主线程和子线程都可能并发执行

2.2 为什么不能直接调用 run()

这是理解 Thread 时特别容易混的地方。

例如:

java
t.run();

这不会启动新线程,它只是普通方法调用。
真正会启动新线程的是:

java
t.start();

所以这里直接记住:

  1. run():线程执行的任务内容
  2. start():让 JVM 真正启动这个线程

3. 线程和进程到底有什么区别

进程 可以理解成 操作系统中资源分配的基本单位

线程 可以理解成 进程内部真正执行任务的调度单位

可以这样记:

  1. 进程更像一个运行中的应用实例
  2. 线程更像这个应用实例里的执行流

Java 程序启动后,通常就是一个进程里包含多个线程。


4. 线程安全到底是什么意思

线程安全 可以理解成 多个线程同时访问同一份数据或同一段逻辑时,程序仍然能保持正确行为

它解决的是:

  1. 并发修改导致数据错乱
  2. 某个线程看不到别的线程刚更新的数据
  3. 多步操作在并发下被打断

例如:

java
count++;

这看起来像一步,但实际上通常包含:

  1. 读取
  2. 加一
  3. 写回

所以它不是天然原子操作。


5. 并发里最常见的三个问题

5.1 原子性

原子性 可以理解成 一个操作要么完整执行,要么完全不执行,中间不能被打断

5.2 可见性

可见性 可以理解成 一个线程对共享变量的修改,其他线程能不能及时看到

5.3 有序性

有序性 可以理解成 程序执行顺序是否完全按你写下的代码顺序表现

在编译器优化、CPU 重排序和多线程环境下,这个问题会变得很重要。

5.4 Java 内存模型和 happens-before 为什么重要

前面的原子性、可见性和有序性,其实都不是孤立概念。

它们背后真正的统一语境,是 Java Memory Model,也就是 Java 内存模型,常写成 JMM

JMM 不是 JVM 里的某一块真实物理内存,而是 Java 为多线程读写共享变量定义的一套可见性、有序性和同步规则

如果不先建立这层认知,后面再看 volatilesynchronizedLock,就很容易变成:

  1. 知道它们能用
  2. 但讲不清为什么它们能保证线程安全

可以抓住两个最核心的判断:

  1. 线程不会总是直接从主内存读写共享变量
  2. 多线程想要结果正确,必须有一套“什么时候对别的线程可见、什么时候顺序不能乱”的规则

happens-before 可以理解成 JMM 用来描述“前一个操作的结果,对后一个操作一定可见,并且执行顺序不能被打乱”的规则

这不是单纯的“时间上谁先执行”,而是 内存语义上谁先于谁成立

工程上最常用、最值得直接记住的几条 happens-before 规则包括:

  1. 同一个线程内,前面的操作先于后面的操作
  2. 对同一个锁的解锁,先于后续对这把锁的加锁
  3. volatile 变量的写,先于后续对这个变量的读
  4. Thread.start() 先于子线程中的执行
  5. 子线程中的执行先于 Thread.join() 返回

这一节最想建立的主线是 并发工具之所以能工作,不是因为它们“神奇地更安全”,而是因为它们在 JMM 规则内建立了可见性和有序性保障


6. 锁到底是什么,为什么并发里总会讲到锁

可以看成 一种在并发场景下控制共享资源访问顺序的机制

它解决的问题是 当多个线程都想同时修改同一份共享数据时,如何避免彼此打断,导致结果错乱

如果不用锁,像下面这种操作就很容易出问题:

java
count++;

因为这不是一步完成的,它往往包含:

  1. 读取当前值
  2. 执行加一
  3. 写回结果

如果两个线程同时做这件事,就可能出现“少加一次”的竞态问题。

所以锁的核心价值,不是“让程序更慢”,而是 在需要保护共享数据的时候,用受控的方式换取正确性

6.1 什么叫加锁

加锁 可以理解成 先拿到某种访问许可,再进入那段关键代码;没拿到许可的线程需要等待

这里的关键代码,本质上就是前面提到的 临界区

也就是说:

  1. 拿到锁的线程先执行
  2. 没拿到锁的线程先等
  3. 前一个线程释放锁后,后面的线程才有机会继续

6.2 锁主要解决哪些问题

锁最主要解决的是:

  1. 互斥访问
  2. 复合操作的原子性
  3. 一定程度上的可见性保障

但锁不是没有代价。

它也可能带来:

  1. 阻塞等待
  2. 上下文切换
  3. 锁竞争
  4. 死锁风险

所以并发设计里真正重要的不是“能不能加锁”,而是:该不该加、加多大范围、用哪一种锁。

6.3 Java 里常见的锁类型怎么理解

1. 悲观锁

悲观锁 可以理解成 默认认为并发冲突很容易发生,所以把资源保护起来,再让别人访问

很多传统互斥锁都偏这种思路。

2. 乐观锁

乐观锁 可以理解成 默认认为并发冲突没那么频繁,先尝试更新,失败了再重试或放弃

它通常不一定是“真正把线程锁住”,而更像是一种并发控制策略。

Java 里常见会和 CAS 一起讨论。

CASCompare And Swap 的缩写,中文常译作:比较并交换。

可以理解成 先比较内存中的值是不是我预期的旧值,如果是,再原子地更新成新值

3. 可重入锁

可重入锁 可以理解成 同一个线程在已经持有锁的前提下,再次进入同一把锁保护的代码时,不会把自己堵死

这很重要,因为很多方法调用链会出现:

  1. 外层方法已加锁
  2. 内层方法也尝试获取同一把锁

如果锁不可重入,线程可能把自己卡住。

synchronizedReentrantLock 都是可重入的。

这里的 ReentrantLock 直译可以理解成 可重入锁

4. 公平锁和非公平锁

公平锁 可以理解成 更倾向于按等待顺序分配锁

非公平锁 可以理解成 新来的线程也可能直接抢到锁,不严格按排队顺序来

公平锁更强调顺序感,非公平锁通常吞吐更高。

5. 读写锁

读写锁 可以理解成 把“读”和“写”这两类访问区分开处理的锁

它的核心思路是:

  1. 读和读通常可以并发
  2. 写和读、写和写通常需要互斥

这类锁更适合 读多写少

的场景。

Java 里常见实现是 ReadWriteLockReentrantReadWriteLock

其中:

  1. ReadWriteLock 可以理解成 读写锁接口
  2. ReentrantReadWriteLock 可以理解成 可重入读写锁

如果只停在这两个名字上,还是不够落地。

更具体一点:

  1. ReadWriteLock 负责定义两类入口:readLock()writeLock()
  2. ReentrantReadWriteLock 是最常见的具体实现,业务代码里通常直接用它
  3. 这里的 Reentrant 表示“可重入”,也就是同一个线程已经拿到对应锁后,再次进入同一把锁保护的代码时,不会把自己卡住

可以把它理解成这样一组规则:

  1. 没有线程持有写锁时,多个线程通常可以同时拿到读锁
  2. 只要有线程持有写锁,其他线程通常既不能继续读,也不能继续写
  3. 写锁本质上还是互斥锁,它保护的是“修改共享数据”的临界区

这也是为什么它更适合 读多写少

的共享数据场景,例如本地缓存、配置快照、字典表数据。

下面这个例子更接近工程里常见的用法。

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;
    }
}

这段代码想表达的重点是:

  1. 读操作先走读锁,让多个查询线程可以并发读取缓存
  2. 缓存未命中后,要先释放读锁,再去竞争写锁
  3. 拿到写锁后还要再检查一次缓存,避免多个线程重复回填同一份数据
  4. loadFromDb() 这里没有展开实现,因为这个例子真正要说明的是“读写锁怎么协同保护共享数据”

为什么要先释放读锁,再去拿写锁?

因为 ReentrantReadWriteLock 更适合做锁降级,而不是随意做锁升级

这里可以这样记:

  1. 锁降级:线程已经拿到写锁,再去拿读锁,然后释放写锁
  2. 锁升级:线程已经拿到读锁,再直接尝试获取写锁

工程上要注意的是:

  1. ReentrantReadWriteLock 常见用法里,锁降级可以做
  2. 从读锁直接升级到写锁,通常不是推荐路径,处理不当很容易把自己卡住
  3. 如果读缓存未命中,更常见的做法就是像上面的示例一样:先释放读锁,再单独获取写锁

如果你还想继续细分它的行为边界,还可以记住两个工程判断:

  1. 读写锁不是“比互斥锁更高级”,它只是更适合读多写少
  2. 如果写操作很频繁,读线程也会经常被写锁阻塞,最后不一定比普通互斥锁更划算
  3. 如果共享数据本身可以改造成无状态、不可变对象或消息传递,很多时候会比上锁更省心

🌟 一个很实用的判断方式是:

共享数据被频繁读取、偶尔更新,而且读操作明显多于写操作时,再优先考虑 ReentrantReadWriteLock。

6.4 锁并不是唯一解

并发问题常见的治理手段不只有锁,还包括:

  1. volatile
  2. CAS
  3. 原子类
  4. 并发容器
  5. 无状态设计
  6. 线程隔离

也就是说,锁很重要,但不是所有并发问题都应该先想到“上锁”。


7. synchronized 到底解决什么问题

synchronized 可以理解成Java 提供的一种内置同步机制,用来保护临界区代码在并发下的执行。

如果从名字上理解,synchronized 本身就是 Java 关键字,直译可以理解成 同步

它主要解决的是:

  1. 临界区互斥访问
  2. 保证一定的可见性
  3. 保证进入和退出同步块时的内存语义

这里的 临界区 可以理解成那段同时只能让一个线程安全进入执行的代码区域。

如果再说得更具体一点,synchronized 其实在做两件事:

  1. 让同一时刻只有一个线程进入被保护的代码
  2. 让线程进入和退出同步区域时,工作内存和主内存之间建立更明确的可见性关系

所以它不只是“防止同时执行”,也和并发下的数据可见性有关。

7.1 synchronized 锁的到底是什么

这是最容易糊涂的点之一。

synchronized 锁的不是代码本身,而是某个对象的监视器锁。

这里的 监视器,可以看成JVM 用来和对象绑定的一种同步控制机制。

所以 synchronized 真正的关键不是写了这个关键字,而是你到底让多个线程去竞争哪一把锁。

如果多个线程竞争的是同一把锁,它们才会互斥。 如果竞争的不是同一把锁,那即使都写了 synchronized,也未必能互相保护。

7.2 三种最常见的写法分别锁什么

1. 同步实例方法

java
public synchronized void increment() {
    count++;
}

这种写法锁的是:当前对象实例,也就是 this。

也就是说:

  1. 同一个对象上的多个线程调用这个方法,会互斥
  2. 不同对象实例之间,互不影响

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,但锁的不是同一个东西:

  1. methodA() 锁的是实例对象
  2. 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;
    }
}

这段代码可以这样理解:

  1. increment()getCount() 都被同一把对象锁保护
  2. 同一时刻,只有一个线程能进入这些同步方法
  3. 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--;
        }
    }
}

这段代码的意义在于:

  1. tickets 是共享资源
  2. 多个线程都可能同时调用 sell()
  3. synchronized 保证“判断还有没有票”以及“卖出并减一”这一整段逻辑不会被别的线程中途打断

如果不加锁,就可能出现:

  1. 两个线程都看到 tickets > 0
  2. 同一张票被卖两次

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;
    }
}

这段代码适合帮助理解:

  1. total 是类级别共享变量
  2. 所有实例都共享这一份数据
  3. 所以更适合使用类锁来保护

7.9 synchronized 适合什么场景

它通常适合:

  1. 保护共享可变状态
  2. 临界区逻辑不算太复杂
  3. 希望写法直接、清晰
  4. 不需要太多高级锁控制能力

很多普通业务代码里,如果只是:

  1. 保护一个计数器
  2. 保护一个余额字段
  3. 保护一个小范围的状态更新

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 常被翻成:

易变的易失的

它主要解决的是:

  1. 可见性
  2. 一定程度上的有序性

但它不解决:复合操作的原子性

所以 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;
    }
}

这段代码的重点是:

  1. 一个线程在 while (running) 中持续工作
  2. 另一个线程调用 stop()running 改成 false
  3. 因为 runningvolatile,工作线程能及时看到这个变化

这个例子特别适合理解:

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;
    }
}

这个例子最值得记住的不是“单例怎么背”,而是:

  1. volatile 不只是让别的线程“看到最新值”
  2. 它还会约束关键的重排序
  3. 某些并发写法之所以正确,本质上依赖的是 JMM 的可见性和有序性规则

9. Lock 和 synchronized 有什么区别

Java 里常见会把 synchronizedReentrantLock 放在一起讨论。

可以这样理解:

  1. synchronized:语言内置同步机制
  2. Lock:更灵活的显式锁接口

这里的名字也可以记清:

  1. Lock 可以理解成 锁接口
  2. ReentrantLock 可以理解成 可重入锁

ReentrantLock 常见优势包括:

  1. 更灵活的获取和释放控制
  2. 可中断锁
  3. 可尝试获取锁
  4. 可配合多个条件队列

但它也意味着:你要自己更谨慎地管理加锁和释放。

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;
    }
}

这段代码的重点是:

  1. 先显式 lock.lock()
  2. try 里执行临界区逻辑
  3. 无论中间是否异常,都在 finallyunlock()

这个写法特别值得记住,因为 Locksynchronized 最大的使用区别之一就是:

Lock 需要你自己显式释放,忘记释放就会有问题。

如果你想尝试“拿不到锁就先不等”,Lock 还可以这样写:

java
if (lock.tryLock()) {
    try {
        // 拿到锁才执行
    } finally {
        lock.unlock();
    }
} else {
    // 没拿到锁,走降级或重试逻辑
}

这个例子能帮助理解为什么说 Locksynchronized 更灵活:

它不只是能加锁,还能让你控制是否等待、等多久、失败后怎么处理。


10. Java JUC 中还有哪些常见的多线程治理方案

如果只把并发理解成:

  1. synchronized
  2. volatile
  3. Lock

那还不够完整。在真实项目里,多线程问题并不总是通过“上一把锁”来解决。

如果把视角切到 JUC,最值得先建立的认知是:JUC 不是某一个单点 API,而是一整组围绕并发问题组织出来的工具。

Java 常见的并发治理方案可以分成几类:

  1. 同步控制
  2. 原子更新
  3. 并发容器
  4. 线程协作
  5. 异步编排
  6. 线程隔离
  7. 从设计上减少共享冲突

如果你想先抓最核心的主干,也可以按下面这 4 大类记:

  1. 同步控制:synchronizedLock
  2. 原子与可见性:volatileAtomic*
  3. 线程协作:CountDownLatchSemaphoreBlockingQueue
  4. 设计降冲突:ThreadLocal、不可变对象、无状态设计

这个四分类的价值在于:先快速判断当前问题到底是共享资源保护、变量更新、线程配合,还是应该从设计上减少共享。

如果继续往下拆,才会细分出并发容器、异步编排、线程池等更具体的工具和场景。

10.1 原子类:解决单变量原子更新

常见类包括:

  1. AtomicInteger:原子整数
  2. AtomicLong:原子长整数
  3. AtomicBoolean:原子布尔值
  4. 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 可以理解成 在高并发计数场景下,减少单点竞争的一种计数方案

从名字上看,它可以看成 长整型累加器

它常见于:

  1. QPS 统计
  2. 访问次数统计
  3. 高频计数器

如果只是一般计数,AtomicLong 往往已经够用;
如果是特别高并发的统计型计数,LongAdder 往往更有优势。

10.3 并发容器:解决共享集合的线程安全问题

常见代表包括:

  1. ConcurrentHashMap:并发哈希表
  2. CopyOnWriteArrayList:写时复制数组列表
  3. ConcurrentLinkedQueue:并发链式队列
  4. BlockingQueue:阻塞队列

它们解决的是:多个线程共享同一个容器时,如何避免普通集合那种直接并发访问带来的问题。

例如:

  1. ConcurrentHashMap:并发映射
  2. CopyOnWriteArrayList:读多写少列表
  3. BlockingQueue:生产者消费者协作

这里最值得建立的判断是:如果问题本质上是“共享容器怎么安全访问”,优先先想并发容器,而不一定是自己在外面包一层粗粒度大锁。

10.4 线程协作工具:解决等待、同步和限流问题

这一类工具的重点不是“互斥修改共享变量”,而是:多个线程之间如何协调节奏。

常见代表包括:

  1. CountDownLatch:倒计时门闩
  2. CyclicBarrier:循环屏障
  3. Semaphore:信号量
  4. Phaser:阶段同步器

为什么现代 JUC 更常用这些工具,而不是直接手写 wait/notify

Object 这一层,Java 很早就提供了:

  1. wait()
  2. notify()
  3. notifyAll()

它们也能做线程协作,但要注意边界:wait/notify 解决的是“持有同一把对象锁的线程,如何在条件不满足时挂起,条件满足后再被唤醒”。`

这套机制能用,但在工程上经常有几个问题:

  1. 必须和 synchronized 配合使用
  2. 容易写出漏通知、错误唤醒、条件判断不严谨的问题
  3. 一把对象锁通常只能承载一组粗粒度等待语义

所以 JUC 更常见的思路是:把“互斥访问”和“条件等待”拆开表达,让协作语义更清晰。

Conditionwait/notify 是什么关系

可以这样理解:

  1. wait/notify 是基于对象监视器的等待通知机制
  2. ConditionLock 体系里更清晰的条件队列抽象

这里的 Condition 可以理解成 条件变量条件队列。

它们解决的是同一类问题:线程在条件不满足时等待,在条件满足时被唤醒。

Condition 更适合复杂场景,因为:

  1. 它显式绑定某把 Lock
  2. 同一把锁下可以创建多组条件队列
  3. 代码结构通常更容易看出“为什么等待、何时唤醒”

例如下面这个“有界消息盒子”示例,就比手写 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();
        }
    }
}

这个例子最关键的阅读点是:

  1. 生产者和消费者等待的是不同条件
  2. 等待条件必须用 while 重试,而不是 if
  3. signal() / await() 的表达力比 wait() / notify() 更贴近业务语义

AQS 是什么,为什么很多 JUC 工具都绕不开它

AQSAbstractQueuedSynchronizer 的缩写,中文常译作:

抽象队列同步器。

可以理解成 JUC 里一套用来搭建锁和同步器的基础骨架

它本身不是直接给业务代码使用的高层工具,而是很多 JUC 组件的底层支撑,例如:

  1. ReentrantLock
  2. Semaphore
  3. CountDownLatch
  4. ReentrantReadWriteLock

可以把它理解成三件事的组合:

  1. 一个表示同步状态的 state
  2. 一个在获取失败后排队等待的线程队列
  3. 一套获取、释放、唤醒后继节点的模板流程

为了帮助理解,可以看一个“去掉细节后的获取流程”:

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 源码的完整拷贝,而是压缩后的示意版本。它想表达的核心是:

  1. 先尝试直接拿同步状态
  2. 拿不到就进入等待队列
  3. 排到自己且条件满足时再尝试获取
  4. 还不满足就挂起等待唤醒

所以很多 JUC 工具看起来 API 不一样,但底层常常有相通的结构:先定义状态语义,再定义获取失败后的排队与唤醒规则。

CountDownLatch

CountDownLatch 这个名字可以拆成:

  1. CountDown:倒计时
  2. Latch:门闩、闭锁

适合 一个线程等待一组任务完成

例如主线程等待多个子任务结束后,再统一汇总结果。

CyclicBarrier

CyclicBarrier 可以理解成 可循环使用的屏障

适合 多个线程都到达某个阶段后,再一起继续往下执行

Semaphore

Semaphore 可以理解成 信号量

适合 限制同时访问某个资源的线程数量

例如:

  1. 限制并发请求数
  2. 限制数据库连接占用数
  3. 限制某个外部资源的并发访问量

10.5 线程池和异步编排:解决任务调度与复用问题

并发不只是“多个线程同时改数据”,还包括:任务怎么被分发、复用和编排。

常见工具包括:

  1. ThreadPoolExecutor:线程池执行器
  2. Future:异步结果占位对象
  3. Callable:带返回值的可调用任务接口
  4. CompletableFuture:可编排、可组合的异步结果容器

这里可以建立一个分工:

  1. 线程池:解决线程复用和任务调度
  2. Future / Callable:解决异步执行并拿返回值
  3. CompletableFuture:解决更复杂的异步任务编排和结果组合

10.6 ThreadLocal:解决线程隔离问题

ThreadLocal 可以理解成 给每个线程一份独立变量副本,而不是让多个线程共享同一份数据

从名字上看,ThreadLocal 可以理解成 线程本地变量

它解决的问题不是“多个线程如何安全共享”,而是:有些数据干脆就不共享。

典型场景包括:

  1. 用户上下文
  2. TraceId
  3. 数据库连接上下文
  4. 每线程独立格式化器

所以 ThreadLocal 的关键思路是:通过隔离来减少并发冲突。

10.7 从设计上减少并发问题

这部分不是某个具体 API,但在工程里往往更重要。

常见思路包括:

  1. 无状态设计
  2. 不可变对象
  3. 消息传递
  4. 任务队列

无状态设计

如果对象不保存共享可变状态,很多线程安全问题根本不会出现。

不可变对象

如果对象创建后状态不再变化,并发访问的风险会明显降低。

消息传递

如果线程之间不直接共享数据,而是通过队列传任务、传消息,也能有效减少锁竞争。

10.8 一张总表

为了避免只看到“它能解决什么”,却忽略“它要付出什么代价”,可以用下面这张表建立整体判断:

方案主要解决什么问题典型场景主要代价 / 局限
synchronized / Lock互斥访问共享资源余额扣减、共享状态保护阻塞等待、锁竞争、可能死锁
volatile可见性、轻量状态同步停止标记、配置开关不能保证复合操作原子性
Atomic*单变量原子更新计数器、状态位不擅长多个变量一致性
LongAdder高并发计数热点访问量统计更适合统计,不适合复杂一致性场景
并发容器共享容器安全访问缓存、队列、监听器复合逻辑仍可能需要额外同步
CountDownLatch / CyclicBarrier / Semaphore线程协作与同步汇总、分阶段同步、限流工具选错后流程容易变复杂
线程池 / CompletableFuture任务复用与异步编排并发任务执行、结果组合调度链路更复杂,参数配置不当会出问题
ThreadLocal线程隔离上下文传递在线程池场景要注意残留和清理
无状态 / 不可变对象 / 消息传递从设计上减少共享冲突高并发服务设计对设计要求更高,改造成本更大

10.9 这些方案各自有什么优缺点

这些方案并不是“谁更高级就替代谁”,而是各自解决不同问题,同时也各自带来不同代价。多线程治理方案的本质,是在下面几件事之间做权衡:

  1. 正确性
  2. 性能
  3. 实现复杂度
  4. 可维护性

1. 同步控制:synchronizedLock

优点:

  1. 最直接地解决共享资源互斥访问问题
  2. 语义清晰,适合保护临界区
  3. synchronized 写法简单,可靠性高
  4. Lock 更灵活,支持中断、超时、尝试获取等能力

缺点:

  1. 会带来阻塞、上下文切换和锁竞争
  2. 用不好容易死锁
  3. 锁范围过大时吞吐会明显下降
  4. Lock 需要手动释放,忘记释放会出问题

2. 原子与可见性:volatileAtomic*

优点:

  1. 比显式加锁更轻量
  2. 适合简单状态同步和单变量原子更新
  3. 并发性能通常更好
  4. 简单场景下代码更简洁

缺点:

  1. volatile 只能解决可见性和部分有序性,不能解决复合操作原子性
  2. Atomic* 更擅长保护单个变量,不擅长多个变量一致性
  3. CAS 在高竞争下可能自旋重试,带来 CPU 开销
  4. 一旦场景变复杂,很容易误以为“没加锁也安全”

3. 线程协作:CountDownLatchSemaphoreBlockingQueue

优点:

  1. 更适合解决线程之间怎么配合,而不是谁来抢同一把锁
  2. 语义通常比手写 wait/notify 更清晰
  3. 对等待、限流、生产消费这类问题表达力更强
  4. BlockingQueue 还能顺带解耦生产者和消费者

缺点:

  1. 不适合拿来替代所有同步控制
  2. 工具选错时,流程会变得很绕
  3. 协作链条一复杂,排查问题会变难
  4. 某些工具有明确生命周期限制,例如 CountDownLatch 不能重复复用

4. 设计降冲突:ThreadLocal、不可变对象、无状态设计

优点:

  1. 从根上减少共享,自然减少锁竞争
  2. 往往比“出问题后补锁”更优雅
  3. 更容易扩展,也更适合高并发系统
  4. 不可变对象天然更安全

缺点:

  1. 不是所有业务都能做到无状态
  2. ThreadLocal 容易被误用,还要注意线程池中的残留问题
  3. 不可变对象可能带来更多对象创建成本
  4. 这种方案对设计能力要求更高,不是简单换个 API 就能解决

10.10 一张优缺点对比表

方案类别优点缺点
同步控制直接、清晰,适合保护共享可变状态有阻塞、锁竞争、死锁和复杂度代价
原子与可见性轻量、性能好,适合简单共享变量能力边界窄,不适合复杂一致性问题
线程协作更适合表达等待、同步、限流和生产消费工具选型不当时流程会变复杂
设计降冲突从根上减少共享,长期通常更优设计要求更高,并非所有场景都适用

10.11 怎么理解这些优缺点

  1. synchronized / Lock:最直接,但有阻塞代价
  2. volatile / Atomic*:更轻,但能力边界更窄
  3. CountDownLatch / Semaphore / BlockingQueue:擅长线程协作,不是单纯保护共享变量
  4. ThreadLocal / 无状态 / 不可变对象:从设计上减少冲突,长期往往更优

所以并发里通常没有“万能最优解”,只有:

  1. 当前问题到底是共享状态保护,还是线程协作
  2. 你更在意正确性、吞吐,还是实现复杂度
  3. 能不能通过设计减少共享,而不是一上来就加锁

10.12 一句话总结

Java 里解决多线程问题,不只有“加锁”这一条路。
常见方案有:

  1. 互斥和同步
  2. 原子更新
  3. 容器级并发支持
  4. 线程协作工具
  5. 异步任务编排
  6. 线程隔离
  7. 从设计上减少共享

真正成熟的并发设计,往往不是“到处加锁”,而是 先判断问题到底属于共享状态保护、线程协作,还是任务编排,再选最合适的工具


11. 线程池为什么重要

如果每来一个任务就新建一个线程,系统很快会遇到:

  1. 线程创建和销毁成本高
  2. 线程数量失控
  3. 调度开销增大
  4. 内存占用上升

线程池 可以理解成 提前准备并复用一批线程,由它们持续执行任务

它解决的是:

  1. 线程复用
  2. 并发数量控制
  3. 任务排队和削峰

11.1 线程池到底是什么

线程池 可以看成 提前准备并复用一批线程,用来持续执行任务,而不是每来一个任务就临时创建一个新线程

它本质上管理的是两件事:

  1. 线程资源
  2. 任务调度

可以这样想象:

  1. 线程池里先有一批工作线程
  2. 外部不断往线程池提交任务
  3. 工作线程从任务队列里取任务执行
  4. 执行完之后线程不销毁,而是继续等下一个任务

所以线程池的关键,不只是“有很多线程”,而是 线程被统一复用,任务被统一调度

11.2 为什么不用 new Thread() 直接启动

例如下面这种写法:

java
new Thread(task).start();

偶尔执行一次任务没问题,但如果系统里任务很多,这种方式会很快带来几个问题:

  1. 线程创建和销毁有明显成本
  2. 线程数量容易失控
  3. 大量线程会带来上下文切换开销
  4. 内存占用会持续升高

所以线程池真正解决的是 不要把“任务数量”直接等同于“线程数量”

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();
    }
}

这段代码可以这样理解:

  1. newFixedThreadPool(3) 创建了一个固定 3 个线程的线程池
  2. 一共提交了 5 个任务
  3. 同一时刻最多只有 3 个线程并发执行
  4. 多出来的任务会先进入队列等待
  5. shutdown() 表示线程池不再接收新任务,但会把已提交任务执行完

11.4 ExecutorService 是什么

ExecutorService 可以理解成 Java 中更常用的一层线程池抽象接口

从命名上看,它可以记成:执行器服务接口。

它解决的是 你通常不需要直接手动管理每个线程,而是把任务交给执行器统一处理

所以现代 Java 并发代码里,更常见的是:

  1. 提交任务给 ExecutorService
  2. 由它内部调度线程执行

而不是自己到处 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();
    }
}

这段代码适合帮助理解:

  1. 线程池不只是能执行 Runnable
  2. 它也可以执行有返回值的任务
  3. 主线程可以继续做别的事情
  4. 后面再通过 future.get() 取到结果

11.8 线程池适合什么场景

常见场景包括:

  1. 服务端异步任务
  2. 批量任务处理
  3. 并发调用多个下游服务
  4. 日志处理、消息消费
  5. 定时或后台任务执行

这里有一个很容易混淆的点:线程池适合做进程内异步,不等于它可以替代消息队列。

更具体一点:

  1. 线程池解决的是当前 Java 进程里的任务并发、线程复用和资源控制
  2. 消息队列解决的是跨系统异步、服务解耦、流量削峰和可靠投递

例如:

  1. 一个接口里并行查询多个下游服务,通常更适合线程池
  2. 订单创建后通知库存、积分、短信多个系统,通常更适合消息队列

如果你对这两个概念还容易混在一起,建议顺着看一眼消息队列总览里的这一节:

11.9 Java 中常见的线程池方案有哪些

如果继续往下看,Java 里的线程池并不只有一种固定写法。

最常见的方案可以分成这几类:

1. ThreadPoolExecutor

这是最核心、最通用的线程池实现。

特点:

  1. 可自定义核心线程数、最大线程数、队列和拒绝策略
  2. 控制粒度最细
  3. 最适合生产环境中的精细化配置

适合场景:

  1. 业务线程池
  2. 服务端异步任务
  3. 需要明确控制资源上限的场景

2. FixedThreadPool

固定大小线程池。

特点:

  1. 线程数固定
  2. 适合并发量相对稳定的场景
  3. 不会像缓存线程池那样无限扩张线程数
  4. 但任务太多时,任务可能在队列里持续堆积

适合场景:

  1. 稳定并发任务处理
  2. 希望限制线程数量的普通业务场景

3. SingleThreadExecutor

单线程线程池。

特点:

  1. 永远只有一个工作线程
  2. 任务按顺序串行执行
  3. 能保证执行顺序

适合场景:

  1. 顺序敏感任务
  2. 串行消费
  3. 单线程状态机式处理

4. CachedThreadPool

可缓存线程池。

特点:

  1. 空闲线程会复用
  2. 没有空闲线程时会新建线程
  3. 线程数可能快速增长
  4. 吞吐高,但失控风险也更高

适合场景:

  1. 大量短生命周期任务
  2. 突发型异步任务

5. ScheduledThreadPool

定时线程池。

特点:

  1. 支持延迟执行
  2. 支持周期执行
  3. 能替代简单定时器方案

适合场景:

  1. 定时任务
  2. 周期轮询
  3. 延迟执行任务

6. SingleThreadScheduledExecutor

单线程定时线程池。

特点:

  1. 定时执行
  2. 同时保持串行顺序
  3. 不会并发执行多个定时任务

适合场景:

  1. 既要求定时,又要求顺序的任务
  2. 避免多个定时任务并发冲突

7. ForkJoinPool

分治型线程池。

特点:

  1. 适合任务拆分后再合并
  2. 使用工作窃取思想
  3. 更适合计算密集型任务

适合场景:

  1. 分治计算
  2. 并行计算
  3. 大任务拆小任务执行

8. newWorkStealingPool

本质上基于 ForkJoinPool 的工作窃取线程池。

特点:

  1. 空闲线程会“偷取”其他线程队列里的任务
  2. 更适合大量小任务并行处理
  3. 吞吐通常较好,但执行顺序不容易直观控制

适合场景:

  1. 大量独立小任务
  2. 并行计算场景

11.10 一张线程池方案对比表

方案特点适合场景
ThreadPoolExecutor可高度定制,控制最细生产环境通用线程池
FixedThreadPool固定线程数稳定并发任务
SingleThreadExecutor单线程串行执行顺序敏感任务
CachedThreadPool线程可快速扩张大量短任务、突发任务
ScheduledThreadPool支持延迟和周期任务定时任务
SingleThreadScheduledExecutor单线程定时执行顺序定时任务
ForkJoinPool分治、工作窃取计算密集型并行任务
WorkStealingPool多线程偷取任务大量独立小任务

11.11 怎么选线程池方案

可以这样记:

  1. 生产环境优先考虑 ThreadPoolExecutor
  2. 固定并发场景优先想 FixedThreadPool
  3. 必须串行执行时优先想 SingleThreadExecutor
  4. 大量短任务才考虑 CachedThreadPool
  5. 定时任务优先想 ScheduledThreadPool
  6. 并行计算优先想 ForkJoinPool

一个很重要的工程结论是:很多 Executors 提供的工厂方法虽然方便,但默认参数不一定适合生产环境。

所以在真实项目里,更常见也更稳妥的做法往往是:基于 ThreadPoolExecutor 显式配置线程数、任务队列和拒绝策略。

11.12 Java 定时任务到底在解决什么问题

Java 里的定时任务,核心不是“过一会儿执行一段代码”这么简单,而是:

  1. 某个任务要不要延迟执行
  2. 某个任务要不要周期执行
  3. 任务执行时,线程资源怎么复用
  4. 多个定时任务并存时,如何避免每个任务都自己起线程

🌟 一个很重要的边界是:Java 原生定时任务更具体一点是进程内调度。

它更适合解决:

  1. 当前 Java 进程里的延迟任务
  2. 当前服务实例里的周期轮询
  3. 轻量后台补偿任务

它不直接解决:

  1. 集群里只能执行一份
  2. 任务持久化恢复
  3. 可视化任务管理

11.13 ScheduledExecutorServiceScheduledThreadPoolExecutor 是什么关系

如果你在 Java 里想写定时任务,更常见的现代做法通常不是 Timer,而是:

  1. 面向 ScheduledExecutorService 这个调度接口编程
  2. 底层通常使用 ScheduledThreadPoolExecutor

可以这样理解:

  1. ScheduledExecutorService 定义了“延迟执行、固定频率执行、固定间隔执行”这些能力
  2. ScheduledThreadPoolExecutor 是它最常见的实现
  3. Executors.newScheduledThreadPool(...) 本质上也是帮你创建一个 ScheduledThreadPoolExecutor

这条线和普通线程池的关系是:它本质上还是线程池,只是在线程复用之外,又补了调度能力。


11.14 一个延迟任务和周期任务示例

下面这个例子演示两种很常见的任务:

  1. 订单创建后,延迟关闭超时未支付订单
  2. 每隔一段时间执行一次失败通知补偿
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);
    }
}

这段代码最值得注意的是:

  1. schedule(...) 适合执行一次性的延迟任务
  2. scheduleWithFixedDelay(...) 更适合上一轮执行结束后,再等一段时间继续下一轮
  3. 多个任务可以复用同一个调度线程池,而不是每个任务自己 new 线程

11.15 scheduleAtFixedRatescheduleWithFixedDelay 有什么区别

这两个 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();
    }
}

🌟 可以这样记:

  1. 想要更稳定的时间节奏,优先想 scheduleAtFixedRate
  2. 想要上一轮做完再等下一轮,优先想 scheduleWithFixedDelay

11.16 Java 定时任务的常见边界

定时任务最容易被误解的地方,是以为“代码能定时跑起来”,就等于任务系统已经设计好了。

要注意的是:

  1. 它通常只保证当前进程里的调度,不保证集群只跑一份
  2. 如果应用重启,任务状态和调度进度默认不会自动持久化恢复
  3. 任务执行时间过长,会影响下一轮调度节奏
  4. 线程池配得太小,多个任务之间会互相阻塞

所以更自然的选择通常是:

  1. 进程内轻量任务,优先考虑 ScheduledExecutorService
  2. 如果在 Spring 项目里写业务调度,更常见的是用 @Scheduled
  3. 如果已经进入集群、多实例、任务治理场景,就要继续考虑分布式调度方案

相关内容可以继续看:

  1. Spring 定时任务与任务调度
  2. 分布式锁、ID 与任务调度

11.17 线程池的常见误区

1. 以为线程越多越好

不对。

线程太多会带来:

  1. 上下文切换开销
  2. 内存占用增加
  3. 调度负担加重

2. 以为线程池只是“把线程存起来”

不够准确。

线程池真正重要的还有:

  1. 任务队列
  2. 拒绝策略
  3. 并发上限控制
  4. 生命周期管理

3. 忘记关闭线程池

如果线程池不关闭,相关线程和资源可能一直不释放。

11.18 一句话总结

线程池本质上就是:用一组可复用的线程来执行一批任务,从而降低线程创建成本、控制并发数量,并统一管理任务执行。


12. ConcurrentHashMap 为什么常被拿来讲

因为它是 Java 并发容器里非常典型的一类。

从名字上看,ConcurrentHashMap 可以理解成 并发哈希表

它解决的问题是 在多线程场景下,如何让哈希表既尽量保证线程安全,又不至于像粗粒度全表加锁那样严重拖垮性能

这里不展开底层所有细节,但至少要先建立一个判断:

HashMap 适合单线程或外部同步场景,ConcurrentHashMap 更适合并发读写场景。

12.1 它和 HashMap 的关键差别,不只是“线程安全”

如果只记一句“ConcurrentHashMap 是线程安全的 HashMap”,还是太薄了。

更值得建立的判断是:

  1. 它不是给整张表加一把粗粒度大锁
  2. 它会尽量让无冲突读写并发进行
  3. 它大量依赖 CAS、分桶结构以及必要时的局部同步

所以它真正解决的是:在保证并发安全的前提下,尽量不要把所有访问都串行化。

12.2 为什么它不允许 null

ConcurrentHashMap 不允许:

  1. null key
  2. null value

原因不是“语法上不想支持”,而是:在并发场景下,null 很难区分“这个 key 不存在”还是“这个 key 对应的 value 恰好就是 null”。

普通 HashMap 里这个歧义还可以勉强靠上下文理解; 但并发读写场景下,这会让结果判断和重试逻辑变得更混乱。

12.3 它线程安全,也不代表复合操作天然原子

这一点特别容易误解。

例如下面这种“先查再放”的写法,在并发下仍然可能有问题:

java
if (userCache.get(userId) == null) {
    userCache.put(userId, queryUserById(userId));
}

问题不在于单次 get()put() 不安全,而在于:这是一段复合逻辑,中间仍然可能被其他线程插入。

更合适的写法通常是使用这类原子复合方法:

  1. putIfAbsent
  2. computeIfAbsent
  3. replace

例如:

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();
    }
}

这个例子想表达的重点是:

  1. ConcurrentHashMap 适合做共享映射容器
  2. 复合逻辑最好优先使用容器自带的原子复合方法
  3. 高并发计数热点常常会和 LongAdder 组合使用

12.4 一个简化过的 put 思路怎么理解

如果从源码视角抓主线,可以把它理解成下面几步:

  1. 先定位桶位
  2. 目标桶为空时,优先尝试 CAS 放入
  3. 目标桶有元素时,再进入该桶对应的同步流程
  4. 冲突链过长时,可能转成树结构

所以它不是“完全无锁”,而是:尽量让无冲突路径走得更轻,只有在必要时才进入局部同步。


13. 死锁到底是什么

死锁 可以理解成 多个线程互相等待对方释放资源,最终谁也无法继续执行

最典型的场景是:

  1. 线程 A 持有锁 1,等待锁 2
  2. 线程 B 持有锁 2,等待锁 1

这时就形成了循环等待。

常见治理思路包括:

  1. 固定加锁顺序
  2. 减少锁粒度和持锁时间
  3. 尽量避免嵌套锁过深

14. 工程上最常见的几个误区

14.1 误区一:用了 volatile 就线程安全了

不对。

它主要解决可见性,不直接保证复合操作原子性。

14.2 误区二:并发就是多开几个线程

真正困难的是 共享数据在多线程下还能不能保持正确

14.3 误区三:线程越多吞吐越高

线程太多反而会带来:

  1. 上下文切换开销
  2. 锁竞争加剧
  3. 内存压力上升

15. 一句话总结

并发编程与线程安全这条主线,本质上是在解决:

多个线程同时工作时,数据如何保持正确、线程如何安全协作,以及系统如何在性能和正确性之间找到平衡。

基于 VitePress 构建的个人技术笔记。