线程安全、可见性、锁竞争——这些词对刚接触Java多线程的人来说,每一个都像绕口令。可一旦系统开始承担真实流量,或者你负责的模块需要处理大量异步任务,你就会发现多线程不是“会用new Thread().start()”就行,而是需要你对线程的生命周期、同步机制、线程池参数乃至内存模型都有清晰认知。这篇文章我想用一套“从门到屋”的路径,把Java多线程的核心骨架讲清楚,重点是每一个设计背后的原因和我在实际项目里踩过的坑,适合正在从初级迈向中级、准备系统复习并发的开发者参考。
很多人学多线程的第一个障碍,是觉得“并发”跟“并行”没区别。其实并发强调的是一段时间内多个任务交替执行,而并行强调的才是真正在同一时刻同时执行。Java多线程里,我们更多时候在解决并发问题,比如多个线程同时修改一个变量,或者多个线程同时从同一个队列里取数据。搞清楚这一点,后面很多概念——比如锁、队列、可见性——就都好理解了。
1. 为什么Java开发者必须迈过多线程这道坎
1.1 从单线程到多线程:系统瓶颈的真实来源
单线程程序的好处是简单,逻辑是一条直线,出了问题也好排查。但现实场景不允许你一直单线程:比如一个网关服务,每秒钟要接收上千个请求,每个请求都要查库、调接口、做计算,如果所有请求都排队处理,一个慢请求会堵住后面所有请求,这就是阻塞式系统最让人头疼的地方。
多线程解决的核心问题,我总结为三点:
- 提高吞吐量:多个请求可以并行处理,单个请求变慢不会拖垮全局。
- 充分利用CPU:现代服务器基本都是多核CPU,单线程只能跑满一个核,线程切分才能吃满多核资源。
- 提升响应速度:耗时操作放到独立线程里,主线程可以继续响应用户操作。
但代价也很直白:资源竞争、数据不一致、死锁、线程切换开销。这些都是引入线程数量后新增的成本,所以“多线程”从来不是免费的午餐,而是一个用复杂度换性能的博弈。
1.2 进程、线程与协程:先搞清楚对象
- 进程是操作系统分配资源的最小单位,有独立的内存空间。
- 线程是CPU调度的最小单位,同属一个进程的多个线程共享堆内存和方法区,拥有独立的虚拟机栈和程序计数器。
- 协程则是在线程之上又做了一层用户态调度,Java里目前尚无原生的轻量级协程支持(传统JVM上通过框架模拟),但理解这个概念有助于你明白线程切换成本有多高。
这里要记住一个关键点:线程之间共享堆内存,但各自的栈和局部变量是私有的。所以线程安全问题的根源,就出在那个“共享堆内存”上。
2. 线程的创建方式与生命周期:基础里的坑
2.1 三种创建线程的典型方式对比
很多人一上来就背“继承Thread、实现Runnable、实现Callable”,但真正写代码的时候却不知道选择依据。我的建议很直接:
- 继承Thread:适合简单场景,但Java是单继承,你继承Thread之后就没法继承别的类了,所以不推荐作为常规手段。
- 实现Runnable:最常用的方式,因为可以配合线程池使用,也保留了类的继承空间。
- 实现Callable:需要返回结果、需要抛出异常的时候用,配合Future或者FutureTask使用。
用代码说明一下三种方式的核心区别:
// 方式一:继承Thread class MyThread extends Thread { @Override public void run() { System.out.println("继承Thread方式"); } } new MyThread().start(); // 方式二:实现Runnable class MyTask implements Runnable { @Override public void run() { System.out.println("实现Runnable方式"); } } new Thread(new MyTask()).start(); // 方式三:实现Callable + FutureTask class MyCallable implements Callable<String> { @Override public String call() throws Exception { return "实现Callable方式"; } } FutureTask<String> futureTask = new FutureTask<>(new MyCallable()); new Thread(futureTask).start(); String result = futureTask.get();注意一个高频错误:调用run()不是启动线程,它只是普通方法调用,在调用线程里同步执行。只有调用start()才会创建新线程,并由JVM自动调用run()。这个点,很多工作了两三年的人还会搞混。
2.2 线程生命周期六状态的迁移路径
Java的线程状态有六种:NEW(新建)、RUNNABLE(就绪/运行)、BLOCKED(阻塞)、WAITING(等待)、TIMED_WAITING(超时等待)、TERMINATED(终止)。
常见的理解误区是认为RUNNABLE只代表“正在执行”。实际上Java把操作系统层面的“就绪”和“运行”合并成了RUNNABLE,也就是说线程可能正在CPU上跑,也可能在等待被分配CPU时间片。这一点在一些性能排查场景里很重要,因为你会发现大量线程处于RUNNABLE并不代表它们都在干活,有可能只是排队等着核。
状态迁移的关键路径:
NEW -> RUNNABLE:调用start()。RUNNABLE -> BLOCKED:进入synchronized同步块或方法时,锁被别的线程持有。RUNNABLE -> WAITING:调用wait()、join()或LockSupport.park()。RUNNABLE -> TIMED_WAITING:调用sleep(时间)、wait(timeout)、join(timeout)。- 任何状态 ->
TERMINATED:run()正常结束或者抛出未捕获异常。
我用一个实际场景解释状态流转的意义:排查“线程卡死”的问题时,如果线程死在BLOCKED,大概率是锁竞争;如果死在WAITING,多半是有人忘记notify();如果死在TIMED_WAITING,则要考虑是否超时时间设置不合理。拿到线程转储(thread dump),先看状态,能省很多瞎猜的功夫。
3. 线程安全的核心:可见性、原子性与有序性
3.1 JMM与可见性:为什么加了变量还是读到旧值
很多初学多线程的人会问:我用多线程改了一个普通变量,为什么别的线程读不到最新值?这就是可见性问题。
Java内存模型(JMM)规定,每个线程都有自己独立的工作内存(线程本地缓存的概念),线程操作共享变量时不是直接操作主内存,而是先把主内存的变量拷贝到自己工作内存里,操作完再刷回主内存。如果不做同步处理,一个线程改了值,另一个线程可能还在用自己工作内存里的旧值,这就产生了可见性问题。
用生活类比来解释:主内存是一个公共公告板,每个线程是小办公室,公告板上的信息必须复印到办公室之后才能看,看完在复印件上改好再贴回公告板。问题在于,贴回去这个动作什么时候发生,别人什么时候再去复印,完全不受约束。
解决可见性的手段:
- volatile:保证线程对变量的修改能立刻对其他线程可见,同时禁止指令重排序。
- synchronized:进入同步块时清空工作内存,退出时把工作内存的内容刷回主内存。
- Lock接口及其实现类:同样有内存屏障语义。
volatile不保证原子性。这是最容易被忽略的。常见的例子是
volatile int count++,这个操作包含“读-改-写”三步,volatile只能让每一步的中间结果可见,但无法阻止两个线程同时读到同一个旧值然后各自加一,最终只加了一次。要保证原子操作,得用Atomic类或锁。
3.2 原子性:从CPU指令到CAS实现
原子性指的是一个操作不可分割。i++在字节码层面其实是多个指令:读取、计算、写回。所以即使变量声明为volatile,也无法保证i++的原子性。
Java提供了两条路:
- 悲观锁:synchronized、ReentrantLock,保证同一时刻只有一个线程操作共享数据。
- 乐观锁:基于CAS(Compare And Swap)实现的Atomic类,比如
AtomicInteger、AtomicLong、AtomicReference。
CAS的核心思想是:比较当前值是否等于期望值,如果相等则更新为新值,否则重试。这个操作是CPU指令级别的原子操作,不需要加锁。它的问题是ABA问题——某个线程把值从A改成B又改回A,另一个线程此时读取到的值还是A,它以为没人动过,实际上已经被动过两次。
解决ABA问题的方式是使用带版本号的原子类,比如AtomicStampedReference。这个在实际并发编程中用得不多,但在写无锁数据结构时是绕不开的点。
3.3 有序性:指令重排序是怎样坑你的
编译器和CPU为了性能会调整指令执行顺序,这在单线程环境下不影响结果,但在多线程环境下就可能出问题。经典场景就是“双检锁单例模式”,如果不加volatile,就可能因为instance = new Singleton()这一步涉及到分配内存、初始化对象、赋值给引用三个步骤,而指令重排序可能导致引用先被赋值但对象还没初始化完毕,另一个线程拿到的就是一个半初始化的对象。
// 正确的双检锁单例 public class Singleton { 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; } }volatile在这里的第二个作用就是禁止这一块指令的重排序。这也是为什么我一直强调:不要随便去掉volatile,你以为只是性能优化,实际上可能会挖出隐蔽的内存安全坑。
4. synchronized与Lock:锁到底该怎么选
4.1 synchronized的三种用法与锁升级机制
synchronized有三种使用位置:
- 修饰实例方法:锁对象是当前实例。
- 修饰静态方法:锁对象是类的Class对象。
- 修饰代码块:锁对象是指定的对象实例。
需要明白的是,synchronized获取的是对象监视器锁(Monitor)。在JVM层面,锁并不是一上来就是重量级锁,而是有一个升级路径:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
这个升级过程背后的逻辑是:大多数时候锁是被同一个线程反复获取的,所以先给偏向锁,减少不必要的CAS开销;如果出现多线程竞争,再升级到轻量级锁(自旋等待);自旋超过一定次数或者线程太多,就升级成重量级锁,进入操作系统内核态的阻塞队列。
实际编码中,你不需要手动选择锁的级别,但你要知道为什么“synchronized不是一上来就那么慢”,以及为什么在低竞争场景下synchronized往往已经足够。很多人一听synchronized就觉得性能差,那是老黄历了,现代JDK对synchronized做了大量优化。
4.2 ReentrantLock的特性与适用场景
ReentrantLock是JUC(java.util.concurrent)包下的显式锁,功能比synchronized更丰富:
- 可中断:
lockInterruptibly()可以在等待锁时响应中断。 - 可超时:
tryLock(timeout, unit)可以设置等待锁的最长时间,避免死锁。 - 公平锁:构造时传入
true可启用公平策略,让等待时间最长的线程先获得锁。 - 多个条件队列:可以创建多个Condition,实现精确唤醒。
我的选择经验是:
- 功能只要“锁住和释放”,优先synchronized,代码更简洁。
- 需要尝试获取锁、需要超时控制、需要公平锁、需要多个条件队列的场景,才使用ReentrantLock。
- 使用ReentrantLock务必在finally块中释放锁,否则出异常时锁不会自动释放,会造成更大的故障。
ReentrantLock lock = new ReentrantLock(); lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }4.3 synchronized与Lock对比速查
| 对比项 | synchronized | ReentrantLock |
|---|---|---|
| 锁的释放 | 自动释放 | 必须手动释放 |
| 中断响应 | 不支持 | 支持 |
| 超时获取 | 不支持 | 支持 |
| 公平性 | 非公平 | 可设置公平 |
| 条件变量 | 一个锁一个条件(wait/notify) | 可多Condition |
| 性能表现 | JDK优化后低竞争场景几乎无差别 | 高竞争下控制力更强 |
这里要纠正一个常见的理解错误:不是所有情况下ReentrantLock都比synchronized快。性能取决于竞争激烈程度和临界区大小,不要因为“显式锁看起来高级”就一律使用显式锁。
5. 线程间的协作:wait、notify与Condition
5.1 生产者消费者的经典写法
线程不是孤立存在的,它们经常需要协作。最经典的协作场景是生产者消费者模式:生产者往队列里放数据,消费者从队列里取数据,队列空时消费者要等待,队列满时生产者要等待。
初学阶段最容易踩坑的就是直接用while(true)循环去轮询队列状态,这样做CPU占用高,而且判断条件可能被多个线程同时读到造成数据不一致。正确做法是使用wait()和notifyAll()进行线程间通信。
class SharedQueue { private LinkedList<String> queue = new LinkedList<>(); private int capacity = 10; public synchronized void produce(String item) throws InterruptedException { while (queue.size() == capacity) { wait(); } queue.add(item); notifyAll(); } public synchronized String consume() throws InterruptedException { while (queue.isEmpty()) { wait(); } String item = queue.removeFirst(); notifyAll(); return item; } }注意这里用了while而不是if来判断等待条件。原因是线程被唤醒之后不一定就能立刻拿到锁,可能被其他线程抢先修改了状态。用while重新检查条件,可以避免“假唤醒”问题。这是面试常问、日常也容易忽略的细节。
5.2 为什么wait和notify必须放在同步块里
wait()方法会释放当前持有的锁,而notify()也需要在持有锁的情况下调用。如果不在同步代码块里调用,运行时就会抛出IllegalMonitorStateException。
更深层的原因是:wait()释放锁并进入等待队列,如果不在同步块里,就无法保证“检查条件”和“调用wait让出锁”这两个动作是原子的,就会产生竞态条件。比如消费者在检查队列为空之后、调用wait之前,生产者插入了数据并调用notify,那么消费者就永远错过了这次通知,直接陷入永久等待。
5.3 Condition:更精细的线程等待与唤醒
ReentrantLock配合Condition可以替代wait/notify,而且能做到“定向唤醒”。比如在一个有界队列里,生产者只唤醒生产者等待队列里的线程,消费者只唤醒消费者等待队列里的线程,而不是像notifyAll那样把所有人都叫起来再各自检查条件。
ReentrantLock lock = new ReentrantLock(); Condition notEmpty = lock.newCondition(); Condition notFull = lock.newCondition(); public void produce(String item) throws InterruptedException { lock.lock(); try { while (queue.size() == capacity) { notFull.await(); } queue.add(item); notEmpty.signal(); } finally { lock.unlock(); } } public String consume() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); } String item = queue.removeFirst(); notFull.signal(); return item; } finally { lock.unlock(); } }这段代码看起来和wait/notify很像,但区别在于:await()释放的是Condition关联的锁,signal()唤醒的是等待在同一个Condition上的线程,不会出现“唤醒错人”的无效竞争。在高并发有界队列场景里,这个性能差异非常明显。
6. 线程池:不要手动new Thread
6.1 为什么需要线程池
每个请求都new Thread()会是灾难。原因有三:
- 创建耗时:线程的创建和销毁涉及系统调用,频繁创建会大量消耗时间。
- 资源开销:每个线程都有独立的栈空间,默认大小通常是1MB,大量线程直接导致内存紧张。
- 不可控:线程数量无上限,系统负载上来时,线程过多反而让CPU忙于上下文切换,吞吐量断崖式下跌。
线程池的本质是“复用一批线程处理多个任务”。核心思想是把线程创建好之后放在池子里,任务提交时直接交给空闲线程,执行完线程不销毁,而是回到池子里等下一个任务。
6.2 ThreadPoolExecutor核心参数解析
创建线程池时,最关键的类就是ThreadPoolExecutor。它的核心参数有六个:
corePoolSize:核心线程数。即使没有任务,这些线程也会保留。maximumPoolSize:最大线程数。核心线程满了之后,才会创建非核心线程。keepAliveTime:非核心线程空闲存活时间。unit:时间的单位。workQueue:任务阻塞队列。核心线程都在忙时,新任务先进队列。threadFactory:创建线程的工厂,通常用来给线程起有意义的名字。handler:拒绝策略。队列满且线程数达到最大值时触发。
实际运行逻辑可以归纳为一句话:先让核心线程跑,核心线程满了任务进队列,队列满了创建非核心线程,线程总数到了最大值还有任务进来就执行拒绝策略。
其中最容易翻车的地方是workQueue的选择。很多人直接用new LinkedBlockingQueue<>(),这是个无界队列,意味着队列永远不会满,那maximumPoolSize参数就形同虚设,非核心线程永远不会被创建。任务堆积过多时,内存很快被撑爆。
6.3 拒绝策略的四种选择
JUC内置了四种拒绝策略:
AbortPolicy:直接抛异常,默认策略。CallerRunsPolicy:由提交任务的线程自己执行任务。DiscardPolicy:静默丢弃任务。DiscardOldestPolicy:丢弃队列里最老的任务,再尝试提交新任务。
我的建议是:大多数业务场景用CallerRunsPolicy,因为它让提交者亲身体会一下“线程池满了”的感受,同时保证任务不丢失,还能天然限流。当然,如果任务本身允许丢弃,比如日志上报类,可以用DiscardPolicy,但必须明确任务丢失带来的后果是可接受的。
6.4 线程池参数怎么定
参数没有标准答案,要根据业务特征来调。我提供一套我自己常用的估算思路:
- 任务偏CPU密集(计算为主):核心线程数设为CPU核数+1或核数。因为CPU密集任务跑满核就跑不动了,线程再多只会增加切换开销。
- 任务偏IO密集(大量等待网络、磁盘):核心线程数可以设大一些,比如CPU核数*2甚至更高。因为IO等待时线程并没有用满CPU,多个线程可以交替利用空闲的CPU时间片。
如果知道任务阻塞率,可以使用公式:线程数 = CPU核数 * (1 + 等待时间/计算时间)。但这个公式用在真实系统里还是需要压测调优,它只能给你一个起点。
在实际项目中,不要使用
Executors.newFixedThreadPool()这种快捷方法创建线程池,原因就是它底层默认使用了无界队列。一旦任务积压,可能直接导致内存溢出。用ThreadPoolExecutor显式指定队列大小和拒绝策略,才能保证行为可控。
7. ThreadLocal:线程私有的变量存储
7.1 ThreadLocal的原理
ThreadLocal用来给每个线程独立存放一份变量副本。它的内部机制是:每个线程对象里都有一个ThreadLocalMap,存储着一组Entry,Entry的key是ThreadLocal对象,value是你传入的值。所以同一个ThreadLocal对象在不同的线程中读到的值互不相同。
ThreadLocal<Integer> threadLocal = new ThreadLocal<>(); threadLocal.set(1); Integer value = threadLocal.get();使用场景典型的有:
- 数据库连接管理,每个线程拿到自己的Connection。
- 事务上下文或用户会话信息在链路中传递。
- 一些框架层用来临时保存请求上下文。(比如某个Web框架处理每个请求时,需要把当前请求对象绑定到当前线程上。)
7.2 ThreadLocal的内存泄漏隐患
ThreadLocal有一个经典的坑:内存泄漏。原因是Entry的key是ThreadLocal对象,它是一个弱引用,而value是强引用。如果ThreadLocal对象不再被外部强引用,GC时key会被回收,但value还挂在ThreadLocalMap里。如果线程一直存活,比如线程池里的线程,那么这个value就永远无法被回收。
解决方式很简单:每次用完ThreadLocal都要调用remove()。尤其是在线程池复用线程的场景下,不清理,下一次任务就可能在同一个线程里读到上一次遗留的值,造成非常诡异的业务bug。
我的实际经验是:在finally块里调用remove()是必须的,和释放锁一样重要。
try { threadLocal.set("临时数据"); // 业务逻辑 } finally { threadLocal.remove(); }7.3 线程池中使用ThreadLocal的注意点
线程池中的线程是复用的,ThreadLocal的生命周期会被拉长。一个请求执行完,线程回到池里,ThreadLocal里的数据还在。如果线程池的大小有限,这些残留数据有可能被下一次请求读到。所以使用ThreadLocal前要思考:数据是否被多线程共享?是否需要随任务结束清理?
此外,如果子线程需要继承父线程的ThreadLocal值,普通的ThreadLocal做不到,得用InheritableThreadLocal。但它在线程池场景下也有坑,因为线程池线程复用时,子线程的初始值可能不是最新父线程的值。需要谨慎使用,或者使用框架层的包装实现。
8. 并发工具类:Java提供给我们的“积木”
8.1 CountDownLatch:倒计时门栓
CountDownLatch适合“等待N个任务都完成后继续”的场景。比如批量更新任务,主线程等所有子线程完成后再汇总。
CountDownLatch latch = new CountDownLatch(3); ExecutorService pool = Executors.newFixedThreadPool(3); for (int i = 0; i < 3; i++) { pool.submit(() -> { try { // 模拟任务执行 Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { latch.countDown(); } }); } latch.await();要点是countDown()必须放在finally块里,否则任务抛异常会导致latch永远不归零,主线程永远等下去。
8.2 CyclicBarrier:循环栅栏
CyclicBarrier是“等所有人都到了再一起出发”,可以循环使用。适合多个线程各自完成一部分工作,然后一起进入下一个阶段。
区别在于:
- CountDownLatch是“多个线程等一个事件”,计数减到0就开门。
- CyclicBarrier是“多个线程互相等待”,所有线程都到达栅栏位置才放行,放行后可以重置继续下一轮。
8.3 Semaphore:信号量限流
Semaphore用来控制同时访问某个资源的线程数。比如某个接口最大并发数限制为5,超出线程必须等待。
Semaphore semaphore = new Semaphore(5); for (int i = 0; i < 20; i++) { executor.submit(() -> { semaphore.acquire(); try { // 模拟访问受限资源 } finally { semaphore.release(); } }); }这个工具用来做简单的限流特别顺手,比手写计数器更可靠,因为acquire和release是原子操作。
9. 并发场景的典型疑难与排查思路
9.1 死锁的产生与排查
死锁的四个必要条件:互斥、持有并等待、非抢占、循环等待。实际编码中最常见的死锁是“两把锁交叉获取”。比如线程A先拿了锁1再拿锁2,线程B先拿了锁2再拿锁1,当双方各自持有一把锁并等待另一把时,死锁就发生了。
排查死锁时,最直接的手段是使用JVM自带的工具获取线程转储。线程转储中会明确打印“Found one Java-level deadlock”以及涉及哪几个线程、哪两把锁。看到这个输出,基本就能定位到代码位置。
预防死锁的思路:
- 加锁顺序全局一致:所有线程都按相同顺序获取锁。
- 使用tryLock加超时:拿不到锁就先放弃,避免永久等待。
- 缩小锁范围:临界区越小,持锁时间越短,碰撞概率越低。
- 必要时粗化锁:某些情况下多次加锁解锁反而性能差,还不如把边界相近的临界区合并成一次。
注意:缩小锁范围是性能优化的大方向,但加锁本身有固定开销。锁太细碎,频繁加解锁的消耗有时候比锁竞争还大。这个平衡要靠压测数据说话,不要凭感觉。
9.2 活锁与线程饥饿
死锁大家都很熟悉,但活锁和饥饿容易被忽略。活锁是线程没阻塞却一直在互相谦让,比如两个线程检测到对方也在尝试拿同一批锁,都主动让出,结果谁也执行不了。饥饿则是一个线程长期得不到CPU时间或锁资源,因为其他线程老是优先抢到。
应对方式:
- 活锁可以通过随机退避来打破,类似网络冲突重传机制。
- 饥饿可以通过公平锁来缓解,比如ReentrantLock构造时传true,让等待时间最长的线程先获得锁。
9.3 高并发场景的性能指标观察
排查并发性能问题时,我通常按这个顺序看:
- 线程数是不是远超合理值?线程太多会让CPU浪费在上下文切换上。
- 锁竞争是否激烈?可以通过工具检查线程在BLOCKED状态的比例。
- 队列长度是否持续飙升?线程池的队列长时间占用很高,说明处理速度跟不上提交速度。
- GC频率是否异常?并发任务频繁创建对象会造成大量GC,反过来拖慢所有线程。
有一次我排查一个接口延迟升高的问题,发现线程池配置了无界队列,任务积压到几十万,内存快被撑爆。这就是典型的“线程池参数失守”案例——不是逻辑写错了,而是参数选型不对。经验总结下来就是:先用合理估算参数启动,再通过压测观察吞吐量和响应时间,持续调整线程数、队列长度和拒绝策略,直到指标稳定。
10. 进阶能力:从会用工具到理解底层
10.1 锁的底层实现与AQS
JUC里大部分同步工具,比如ReentrantLock、Semaphore、CountDownLatch,底层都依赖一个叫AQS(AbstractQueuedSynchronizer)的框架。AQS的核心是维护了一个volatile int类型的state状态字段和一个双向等待队列。获取资源时,如果state允许,直接执行;如果不允许,当前线程会被封装成节点入队并暂停。释放资源时,会唤醒队列头部的等待线程。
理解AQS的意义在于:你不需要背它的源码,但有了这层认识,再看ReentrantLock、Semaphore的用法就容易很多,因为你会发现它们其实都是在控制state这个字段的语义。
10.2 无锁与原子类
除了加锁,并发编程里还有一条路:无锁编程。Java里的Atomic类就是典型代表,它利用CAS指令实现线程安全的原子操作。优点是开销比锁小,没有线程阻塞和唤醒的开销;缺点是写复杂数据结构时难度极高,而且存在ABA问题。
日常开发中,如果只是简单的计数器、计数器累积,直接用AtomicInteger或LongAdder。LongAdder在竞争激烈的场景下比AtomicInteger性能更好,因为它把热点的state拆成了多个cell,分散了写竞争。高并发统计场景下,我更倾向于用LongAdder,比如流量计数、指标累计这类多写少读的场景。
10.3 学习路径的建议
多线程的知识体系其实是一张网:线程创建 -> 生命周期 -> 可见性/原子性 -> 锁 -> 协作工具 -> 线程池 -> 并发容器 -> JUC工具类 -> 底层原理。很多人跳着学,这里看一点那里看一点,结果遇到线上问题的时候,根本凑不齐排查思路。
我建议按下面顺序推进:
- 先写一个线程安全的计数器,搞懂synchronized、volatile各自解决什么问题。
- 手动实现一个有界阻塞队列,走一遍生产者消费者,体会wait/notify和Condition的区别。
- 用ThreadPoolExecutor搭建一个简单的任务处理系统,调整参数观察指标变化。
- 阅读AQS的源码注释,至少把ReentrantLock的获取释放流程看明白。
- 拿线程转储工具学习排查死锁和长时间等待的状态判断。
11. 最后分享几条实操经验
回到我自己的经历,多线程相关的线上故障,几乎没有一次是“线程没跑起来”这种初级问题,反而是三种情况最常见:一是共享变量没有做好同步,二是线程池参数配置不当导致资源耗尽,三是ThreadLocal没有清理造成数据串线。
这几类问题的共性是,代码在本地环境很难触发,因为本地并发量不够,问题都被掩盖了。所以你问我多线程要怎么学,我的回答从来都是:必须主动在代码里制造并发量,然后观察现象,而不是等线上出了问题再去补课。
另外一个小小的实战技巧:在开发环境,所有线程都要设置一个有辨识度的名称,比如“order-process-thread-1”。排查问题的时候,靠线程名能快速锁定是哪类任务在占用资源。这在生产环境中能帮你省下大量时间。
多线程的进阶,本质上是从“能写”到“能分析”再到“能设计”的过程。先把基础原理吃透,把常用的并发工具用熟,把线程池参数选型理解到位,后面遇到再复杂的并发问题,你至少知道从哪里下手去找答案。