1. 多线程从哪里开始:进程、线程与Java线程模型的底层关系
如果你学Java学到集合、IO、异常这些都还能hold住,但一碰到多线程就感觉代码开始"不讲道理"——明明按顺序写的代码,跑起来结果却随机变化;明明是两行赋值语句,执行顺序却能颠倒过来。那恭喜你,你终于撞上了并发编程这堵墙。这堵墙几乎是每个Java工程师都要撞一遍的,区别只在于有人撞完之后花一周调出了那个诡异的bug,有人撞完之后去翻源码,终于把JMM(Java内存模型)、锁升级、线程池这些概念串成了一条线。这一篇就来把这堵墙拆开给你看,讲讲多线程与JUC里真正决定成败的底层逻辑。
先明确这篇的受众:已经会写Java基础语法,知道类、接口、异常、泛型是怎么回事,但对多线程还停留在"new Thread再start一下"这个层面的读者。如果你已经能熟练使用synchronized和线程池,这篇文章可以帮你把原理补全——尤其是AQS和并发容器那两块,面试和排查线上问题时都绕不过去。
进程和线程这两个词人人都听过,但真正能说清楚区别的人不多。一句话总结:**进程是资源分配的基本单位,线程是CPU调度的基本单位。**进程给你分到一块独立的内存空间、文件句柄、环境变量,像是租了一间独立办公室;线程则是在这间办公室里面干活的人。多线程就是多个员工共享同一间办公室,共用电脑、共用打印机、共用饮水机——共用这些资源时一旦协调不好,就会打架。
Java的线程模型在JDK 1.2之后变成了1:1模型,即一个Java线程直接对应一个操作系统原生线程。你做new Thread()时,JVM会向操作系统申请创建一个内核线程,由操作系统的线程调度器统一分配CPU时间片。这意味着创建Java线程是有真金白银成本的——栈空间、内核资源、上下文切换。有人统计过,创建一条线程需要几十微秒到上百微秒的耗时和几十KB的内存开销。这就是为什么"每次异步处理都new一个线程"这种写法在大并发下一定会出问题的根本原因,后文讲线程池时还会细说。
那单核CPU是不是就不需要多线程了?恰恰相反。现代CPU单核也能并发跑多个线程,靠的是时间片轮转——每个线程执行一小段时间就换下一个,切换得足够快,人的感知就是"同时"在执行。多线程提升的从来不只是"利用多核"这一件事,还包括:**当线程在做IO等待(读文件、等数据库、等网络响应)时,让出CPU给其他线程用。**一台应用服务器动辄几百上千的并发连接,核心线程数可能只有几十个,靠的就是这种"阻塞时让位"的调度模式。
2. 线程创建的三种姿势与生命周期管理:别再把new Thread当唯一选择
先从最基础的三段代码说起。创建线程有三种正统姿势:继承Thread类、实现Runnable接口、实现Callable接口。
// 方式一:继承Thread class MyThread extends Thread { @Override public void run() { System.out.println("方式一:继承Thread"); } } // 启动 new MyThread().start(); // 方式二:实现Runnable class MyRunnable implements Runnable { @Override public void run() { System.out.println("方式二:实现Runnable"); } } // 启动 new Thread(new MyRunnable()).start(); // 方式三:实现Callable class MyCallable implements Callable<String> { @Override public String call() throws Exception { return "方式三:实现Callable"; } } // 启动 FutureTask<String> task = new FutureTask<>(new MyCallable()); new Thread(task).start(); String result = task.get(); // 阻塞等待执行结果三种方式的核心区别要拎清楚:继承Thread是"改造成一个线程",实现Runnable是"给线程一个任务",实现Callable是"给线程一个能返回结果、能抛异常的任务"。Java是单继承的,你继承了Thread就不能继承别的类了,而Runnable和Callable都是接口,可扩展性完全不同。从设计角度,任务和线程应当分离——任务是什么,用什么线程去跑,本质上应该是两件事。线程池轮转执行任务,就是典型的"任务与线程分离"。
Callable和Runnable最直观的差异在于:Runnable.run()没有返回值,也不能抛受检异常;Callable.call()可以返回结果,也可以抛异常。中间人是FutureTask,它实现了RunnableFuture接口,既是一个Runnable又能持有异步执行的结果。调用task.get()时会阻塞当前线程,直到任务执行完毕或抛出异常。这里有一个经典坑:不要先去get()再去做后续的耗时操作,否则"异步"就变成了"同步等待",线程池的性能优势直接被打没。
线程的生命周期比很多新手以为的更复杂一点,Java官方定义了六个状态:NEW(新建)、RUNNABLE(可运行)、BLOCKED(阻塞)、WAITING(等待)、TIMED_WAITING(限时等待)、TERMINATED(终止)。
NEW:创建了Thread对象,但还没调用start()。RUNNABLE:调了start()之后,线程已就绪,等待CPU调度执行。BLOCKED:想进入synchronized同步块/方法时,锁被别人持有,卡在"等锁"上。WAITING:执行了wait()、join()、LockSupport.park(),需要别人来唤醒。TIMED_WAITING:执行了sleep(ms)、wait(ms)、join(ms)或LockSupport.parkNanos(),时间到了自动醒。TERMINATED:run()正常执行完,或抛出异常而终止。
这里藏着两个面试高频陷阱。第一个是:RUNNABLE其实包含了操作系统里的"运行中"和"就绪"两个状态——Java层面不区分,但你可以通过线程Dump和监控工具进一步分辨。第二个是最容易答错的:sleep()和wait()的区别。sleep()不释放锁,它只是把CPU时间让出去,睡醒之后继续执行;wait()必须先持有锁,调用后释放锁并进入等待队列,直到notify()/notifyAll()唤醒它重新去抢锁。两者一个是"闭眼休息但手里的东西不放",一个是"把东西交出去躺平等别人叫"。
还有一个所有初学者都踩过的坑:为什么调用的是start()而不是run()?因为start()才是创建新线程的入口,它会触发JVM调用操作系统的线程创建机制,在这个新线程里执行run()方法体;而直接调用run()只是在当前线程里做了一次普通的方法调用,根本没有新线程产生。判断一个多线程bug可以从这里入手:如果加断点跟进去发现栈里只有主线程,八成就是直接把run()当普通方法调了。
3. 并发三大问题的本质:原子性、可见性与有序性是怎么被破坏的
写多线程代码时遇到的百分之八十的诡异现象,都可以归结为并发编程的三大问题:原子性、可见性、有序性。这三个词不是抽象概念,而是现代CPU和编译器的物理限制和优化策略造成的直接后果。
先说原子性。一个操作要么全部执行且不被任何线程打断,要么全部不执行,这就是原子性。经典的i++就完全没有原子性——它在字节码层面是四步操作:读取i、计算i+1、写回i、同步回主内存,中间任意一步都可能被打断。你说我写的是i++,机器执行的是"读-算-写"三步走,两个线程同时执行就会互相覆盖。这正是"并发环境下计数器结果不对"的根因。
public class Counter { private int count = 0; public void increment() { count++; } // 就这一行,并发下必错 public int get() { return count; } }这段代码开100个线程各调一次increment(),最终结果大概率小于100。解决办法要么给increment()加synchronized,要么用AtomicInteger的incrementAndGet()——后者的CAS机制保证读改写三步在硬件层面成为一个不可分割的指令序列。后续讲JUC时细说。
再说可见性。现代CPU有三级缓存,L1/L2是每个核心私有,L3是多个核心共享。线程A在某个核上修改了变量,修改先停留在该核的L1缓存里,线程B在另一个核上读到的可能还是主内存里的旧值。这就是不可见——一个线程的修改,其他线程看不到。JVM里最典型的可见性bug就是死循环停不下来:
public class VisibilityTest { private boolean flag = true; // 改成volatile后就能正常退出 public void stop() { flag = false; } public void runForever() { while (flag) { // 如果flag对当前线程不可见,这里就永不退出 // 忙循环 } } }synchronized能解决可见性,是因为它在加锁和锁释放时都会强制把当前线程的私有缓存与主内存做同步;volatile能解决可见性,是因为它每次读写都直接操作主内存。这两者都顺手保证了"有序性"的一部分。
最后是有序性。为了充分利用CPU流水线,编译器和处理器会把没有依赖关系的指令重排执行。单线程内重排影响不了最终结果,可一旦多线程共享数据,重排就会造成匪夷所思的结果。最经典的案例就是双重检查锁定的单例:
public class Singleton { private static volatile Singleton instance; // 这个volatile不能省 private Singleton() {} public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { if (instance == null) { // 第二次检查 instance = new Singleton(); // A线程执行到这 } } } return instance; } }正常思维里,new Singleton()的步骤是:分配一块内存、调用构造器初始化对象、把引用赋值给instance。但指令重排可能把后两步调换,先赋引用、后初始化。另一个线程在第一次检查时发现instance不为null,直接return,拿到的却是一个尚未初始化完成的对象,使用时就会爆出各种离奇的NullPointerException。volatile在这里有两层语义,保证可见性:所有线程都能看到最新的instance值;禁止指令重排:编译器在volatile变量的读写操作周边插入内存屏障,确保写之前的操作不能越过写、读之后的操作不能越过读。所以那个volatile是刚需,不是玄学。
这三个问题总结成一张表可以这么记:
| 问题 | 本质原因 | 解决的钥匙 |
|---|---|---|
| 原子性 | CPU时间片切换,操作被打断 | synchronized、Lock、CAS |
| 可见性 | CPU缓存与主内存不一致 | volatile、synchronized、Lock |
| 有序性 | 编译器和CPU为了性能指令重排 | volatile、Happens-Before规则 |
Happens-Before原则值得单独讲一句:它是一种抽象的内存模型约定,只要满足某个Happens-Before关系,前一个操作对后一个操作就可见。比如"同一锁的unlock先于后续的lock"、"volatile写先于对volatile的读"、"线程的start()先于该线程的任何动作"、"线程内按代码顺序先行"。理解这个原则后,很多并发问题的判断就变成了一套推理题,而不是靠猜。
4. 从synchronized到Lock:锁的本质、升级路径与选择逻辑
synchronized是Java内置的同步机制,它的底层不是简单的"加个锁标记",而是基于对象头的Mark Word和监视器(Monitor)实现。每个Java对象都可以被视为一把锁,JVM通过检查对象头的Mark Word来判断当前对象是否被某个线程持有锁。
synchronized有三种用法,锁的位置决定了锁的是谁:
// 1. 修饰实例方法:锁的是当前实例对象this public synchronized void methodA() { } // 2. 修饰静态方法:锁的是当前类的Class对象 public static synchronized void methodB() { } // 3. 修饰代码块:锁的是指定的对象 public void methodC() { synchronized (this) { } }这三种用法的锁粒度完全不同。修饰实例方法,意味着同一个实例的多个同步方法之间互斥,但不同实例之间不互斥;修饰静态方法,锁被放大到整个类的级别,所有实例都共享这一把锁,并发度更低;修饰代码块则最灵活,可以精确控制需要保护的代码范围。
JDK 6之前,很多人对synchronized的印象是"重量级锁,性能差",那说的是每次竞争都直接挂起线程、阻塞唤醒。JDK 6之后,HotSpot做了大规模锁优化,引入了锁升级机制:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。偏向锁假设只有一个线程访问同步块,在Mark Word里记录线程ID,无需CAS操作;一旦另一个线程来竞争,偏向锁撤销并升级为轻量级锁;轻量级锁采用CAS自旋而非阻塞线程,适合短临界区;自旋超过一定次数仍抢不到锁,就膨胀为重量级锁,挂起线程进入等待队列。这套升级机制意味着:绝大多数没那么激烈的竞争场景下,synchronized并不慢,反而因为自旋省去了线程上下文切换而更快。
但synchronized在功能上确实有边界:不能中断一个正在等待锁的线程;不能限制等待时间;锁的获得和释放只能配对用在同一个方法的进入和退出上,无法跨多个方法手动控制;唤醒操作是随机的、全体的,做不到精确通知特定线程。java.util.concurrent.locks.Lock接口和它的实现ReentrantLock就是为了把这些能力补上。
ReentrantLock lock = new ReentrantLock(); lock.lock(); // 获得锁 try { // 临界区代码 } finally { lock.unlock(); // 必须手动释放 }使用ReentrantLock时,把unlock()放在finally里是一条铁律。因为如果临界区抛了异常,没有finally兜底,锁就永远不会被释放,别的线程全部卡死,之后的问题排查会非常痛苦。这是线上事故的高发原因之一。
ReentrantLock的名字里有个"Reentrant",即可重入,同一个线程可以重复获得同一把锁,每获得一次计数加一,释放一次计数减一,只有计数归零才算真正释放。这种设计对递归调用和嵌套临界区非常友好。
ReentrantLock真正的杀手锏是Condition。它把锁内的等待队列拆成了多个独立的条件队列,能做到精确唤醒:
ReentrantLock lock = new ReentrantLock(); Condition condition = lock.newCondition(); // 线程1 lock.lock(); try { condition.await(); // 释放锁并等待 } finally { lock.unlock(); } // 线程2 lock.lock(); try { condition.signal(); // 唤醒一个等待的线程 } finally { lock.unlock(); }synchronized对应wait()/notify()只能"全家广播",而Condition的signal()可以指定唤醒哪个队列上的线程。这就是后文生产者消费者模型里的精准通知基石。
那到底怎么选?我的经验是:如果需求只是互斥访问共享变量,首选synchronized——代码简洁,JVM帮你管理锁升级,不容易漏释放。如果涉及可中断获取锁、限时等待、多个条件的精准唤醒、公平锁,或者在高竞争的写锁场景下需要更细粒度控制,就上ReentrantLock。日常工程里大概七八成的场景,synchronized就够了,为了显得"高级"而用Lock,反而容易埋坑。
5. JUC的三板斧:AQS、CAS与并发容器的底层逻辑
JUC是java.util.concurrent包的简称,Oracle官方把它定位为Java并发工具包,从JDK 5开始引入。这个包里的几乎所有并发工具——锁、信号量、倒计时门闩、读写锁、并发容器——底层都踩在同一套地基上:CAS(Compare-And-Swap)和AQS(AbstractQueuedSynchronizer)。理解这两个东西,就相当于拿到了并发编程世界里万能钥匙。
CAS是乐观锁的核心实现。简单说,它做这样一件事:比较当前内存值是不是预期值A,如果是,就把它更新成新值B;如果不是,说明被人改过了,本次更新失败。整个过程由CPU的原子指令支持,不会被线程调度打断。AtomicInteger的incrementAndGet()内部就是一个自旋CAS:
public final int incrementAndGet() { return unsafe.getAndAddInt(this, valueOffset, 1) + 1; }底层用Unsafe类访问内存地址,通过compareAndSwapInt完成比较并交换。CAS最大的好处是无锁、无阻塞,避免了线程挂起和唤醒的开销;缺点是三个:只能保证一个共享变量的原子操作、自旋在竞争激烈时白白消耗CPU、会产生ABA问题。
ABA问题值得展开说说。假设共享变量初始值是A,线程1读取到A,这时线程2把它改成了B又改回A,线程1执行CAS时发现内存值还是A,就认为没人动过,更新成功。这个"改了个寂寞"的干扰对一般数值结果可能没影响,但对某些场景——比如链表的头节点引用——就可能导致严重逻辑错误。解决方案是用AtomicStampedReference或AtomicMarkableReference,给变量加上版本号:
AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(100, 0); int stamp = ref.getStamp(); // 第0版 ref.compareAndSet(100, 200, stamp, stamp + 1); // 版本也参与比较AQS是JUC里更底层的地基,ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock全都建立在它之上。它的核心设计用三个词概括:一个volatile的state状态变量、一个CLH变种的双向等待队列、一组由子类实现的模板方法。
state代表"资源量":ReentrantLock里state表示锁的重入次数;Semaphore里state表示剩余许可数;CountDownLatch里state表示还差多少个事件才能放行。获取资源时通过CAS尝试修改state,如果成功,线程直接进入临界区;如果失败,线程会被包装成Node节点插入等待队列尾部,排队挂起。释放资源时修改state,然后唤醒等待队列的头节点线程。这个设计意味着写一个新的并发工具,理论上只需要继承AQS并实现tryAcquire和tryRelease等方法,就能获得完整的线程排队、唤醒、中断处理能力。这也是JUC能保持"小而美"的原因。
public class SharedLock { private static class Sync extends AbstractQueuedSynchronizer { @Override protected boolean tryAcquire(int arg) { return compareAndSetState(0, 1); // 抢到state=0就成功 } @Override protected boolean tryRelease(int arg) { setState(0); // 释放锁 return true; } } private final Sync sync = new Sync(); public void lock() { sync.acquire(1); } public void unlock() { sync.tryRelease(1); } }你没看错,寥寥十行就能手写一把互斥锁。这就是AQS封装实力的体现。
站在AQS和CAS之上,JUC还提供了一系列并发容器,其中最值得深挖的是ConcurrentHashMap和CopyOnWriteArrayList。
老版本的ConcurrentHashMap从JDK 5到JDK 7用的是分段锁设计——把整个哈希表切成16个Segment,每个Segment自带一把锁,不同Segment的读写互不干扰,并发度就是Segment数。JDK 8之后推翻重来:数组 + 链表/红黑树 + CAS + synchronized。数组的每个桶在未被访问时不加锁,只有写入某个桶才会synchronized锁住该桶的头节点。这样锁的粒度就是单个桶,比分段锁又细了一级。扩容时还引入了多线程协助扩容机制,利用多个线程一起搬元素。
日常里值得记几个ConcurrentHashMap的实用经验:它不允许key或value为null;size()和isEmpty()不是绝对准确,只是近似值;putIfAbsent是比先containsKey再put更安全的复合操作;做复合操作时优先用computeIfAbsent,它保证目标key不存在时,映射函数在整个Map的锁保护下执行,不会有竞态条件。
CopyOnWriteArrayList是另一种思路,专门优化"读多写少"场景。读操作直接读数组,永远不加锁;写操作时先copy一份新数组,在副本上修改,修改完再把底层数组引用指向新数组。由于volatile保证引用可见性,读线程要么看到旧数组要么看到新数组,永远不会看到中间态。代价是每次写都要复制整个数组,数组越大写成本越高。所以它适合配置中心、订阅列表、监听器列表这类读的频率远超写的场景,不适合大集合频繁写。
6. 生产者消费者模型:从wait/notify到BlockingQueue的演进
多线程处理业务时,最常见的协作模式非生产者消费者模型莫属。它的本质是:生产者线程产生数据,消费者线程消费数据,两者通过一个共享缓冲区解耦。缓冲区的存在让生产者和消费者的速度不必完全匹配——消费慢了数据在队列里排队,生产慢了消费者从队列里取,谁也不用等谁。
问十个面试者如何实现生产者消费者,八个会写以下经典版本:
public class PreJucModel { private final Queue<Integer> queue = new LinkedList<>(); private final int capacity = 10; public synchronized void produce(int value) throws InterruptedException { while (queue.size() == capacity) { wait(); // 队列满了,生产线程等待 } queue.offer(value); notifyAll(); // 队列有货了,唤醒等待的消费者 } public synchronized int consume() throws InterruptedException { while (queue.isEmpty()) { wait(); // 队列空了,消费线程等待 } int value = queue.poll(); notifyAll(); // 队列有空间了,唤醒等待的生产者 return value; } }这段代码有两个必须注意的细节。第一,用while循环检查条件,而不是if。这里有著名的**虚假唤醒(spurious wakeup)**问题:wait()可能在没有任何线程调用notify的情况下被操作系统调度机制唤醒,哪怕JVM规范说这事可以发生,你也要当成随时可能发生来对待。如果用的是if,线程被唤醒后不会再检查条件是否成立,队列可能仍旧是满的,生产线程往满队列里塞数据,数据被丢弃或整体逻辑崩溃。写成while就是唤醒后重新检查条件,不满足继续等,万无一失。第二,notifyAll()而不是notify()。因为notify()只唤醒一个线程,如果唤醒的是一个生产者,而队列还是满的,它会再次进入等待,可能造成所有线程都在互相等待的死锁局面。
这个版本的痛点也很明显:所谓"synchronized + wait/notify"只能做到整体锁内互斥,生产者之间、消费者之间、生产者和消费者之间全部挤在同一把锁上,等待被唤醒的线程还精确不到具体类型。JUC给出的方案是Condition精确等待唤醒:
ReentrantLock lock = new ReentrantLock(); Condition notFull = lock.newCondition(); // 队列不满 Condition notEmpty = lock.newCondition(); // 队列不空 public void produce(int value) throws InterruptedException { lock.lock(); try { while (queue.size() == capacity) { notFull.await(); // 生产者等待"不满" } queue.offer(value); notEmpty.signal(); // 精准唤醒一个消费者 } finally { lock.unlock(); } } public int consume() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); // 消费者等待"不空" } int value = queue.poll(); notFull.signal(); // 精准唤醒一个生产者 return value; } finally { lock.unlock(); } }注意这里signal()可以安全使用,因为notFull和notEmpty是两个独立的等待队列,唤醒的必然是另一类型的线程。这种设计比notifyAll()全家广播高效得多,也精准得多。
然而真实工程里,你大概率不需要手写这么一套。JUC已经把这套模型封装成了BlockingQueue接口,实现类里最常用的两个是ArrayBlockingQueue(数组有界队列)和LinkedBlockingQueue(链表队列,默认无界)。它们内部就是"锁 + 条件队列"的成熟组合。用它们实现生产者消费者,核心逻辑一行就够:
ArrayBlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10); // 生产者 queue.put(value); // 满时阻塞 queue.offer(value, 1, TimeUnit.SECONDS); // 限时阻塞,超过返回false // 消费者 int v = queue.take(); // 空时阻塞 Integer v2 = queue.poll(1, TimeUnit.SECONDS); // 限时取,空返回nullArrayBlockingQueue内部只有一把锁,put()和take()互斥,同时只有一个线程在操作数组;LinkedBlockingQueue则用了两把锁(takeLock和putLock),生产者的put和消费者的take可以并发执行,吞吐更高,代价是代码复杂度更高。
我建议初学阶段这么练:先用synchronized + wait/notify手写一版,再用ReentrantLock + Condition手写一版,最后用ArrayBlockingQueue替换。三版都跑通,你对"锁为什么存在""等待唤醒为什么需要""JUC封装的粒度有多大"就会有直觉。再进一步可以做点实战联动:模拟一个异步日志系统——业务线程是生产者,不断往阻塞队列里丢日志消息;一个消费者线程不停从队列取日志写文件。这样业务线程不会因为IO而阻塞,日志写入也变成了单线程有序操作。队列用一个有界的ArrayBlockingQueue,满了可以定义丢弃策略、记录警告甚至触发指标上报,比直接同步打日志性能高出一个量级。
7. 线程池的正确打开方式:参数、拒绝策略与实战调优
前面反复提到"别频繁new Thread",那不用new用什么?答案是线程池。线程池之所以高效,是因为它做了两件事:复用已创建的线程,避免反复创建销毁的系统开销;通过队列削峰填谷,让任务在生产速度突增时排队等待,而不是直接打死系统。
Java里的核心实现是ThreadPoolExecutor。它最完整的构造函数有七个参数,我按实际使用的优先级来讲:
new ThreadPoolExecutor( corePoolSize, // 常驻核心线程数 maximumPoolSize, // 最大线程数 keepAliveTime, // 非核心线程空闲存活时间 unit, // 存活时间的单位 workQueue, // 任务阻塞队列 threadFactory, // 线程工厂,用于命名线程 handler // 拒绝策略 );这七个参数的正确理解就是线程池的工作流程:新任务提交进来,如果当前线程数小于corePoolSize,创建核心线程执行;超过corePoolSize且核心线程都在忙,任务进入workQueue排队;队列满了,创建非核心线程(最多到maximumPoolSize)去执行;线程数达到maximumPoolSize且队列也满了,触发拒绝策略;非核心线程空闲时间超过keepAliveTime就会被回收。
很多人面试挂在这里:线程池是先填满队列,不是先加线程。很多开发者凭直觉写了"核心线程满了就加线程到最大",这是错的。加线程出现在队列已满之后。这个顺序直接决定了你在配置池大小时要重点考虑队列还是有界缓存,把线程数留给突发尖峰。
四个拒绝策略,面试也常考,但更要理解它们各自适合什么场景:
| 策略 | 行为 | 适用场景 |
|---|---|---|
| AbortPolicy(默认) | 抛RejectedExecutionException | 任务不能丢,失败必须暴露 |
| CallerRunsPolicy | 谁提交谁执行,任务回到调用线程 | 不想丢任务,且能接受调用线程阻塞 |
| DiscardPolicy | 静默丢弃新任务 | 能容忍任务丢失 |
| DiscardOldestPolicy | 丢弃队列头部的旧任务,重新提交新任务 | 保留最新任务,丢弃积压旧任务 |
实践中最常见的痛点是任务突然打进线程池来。我建议在提交任务的入口处写一个统一的兜底:捕获RejectedExecutionException,配合CallerRunsPolicy或自定义策略,宁可让调用方多等一会儿,也不要静默丢数据。数据丢了查起来比慢更痛苦。
接着是一个很多教程不会明说的坑:Executors工具类提供的快捷方法在工程里并不推荐。Executors.newFixedThreadPool(10)底层用的是无界LinkedBlockingQueue,队列可以无限堆积任务,高峰期内存会直接被撑爆,OOM后整个进程挂掉,连拒绝策略都没机会触发;Executors.newCachedThreadPool()用的是SynchronousQueue,没有队列缓冲,任务一多就疯狂创建线程,同样有资源耗尽风险。既然推荐手动传ThreadPoolExecutor,那就意味着你要理解每一个参数的含义。用阿里的规范来说,就是:禁止使用Executors的快捷方法,一律通过ThreadPoolExecutor手动配置。这不是教条,是用无界队列堆出来的线上事故换来的教训。
我在实际项目中一般这样封装线程池:
ThreadFactory namedFactory = new ThreadFactory() { private final AtomicInteger seq = new AtomicInteger(1); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r); t.setName("order-async-worker-" + seq.getAndIncrement()); t.setDaemon(false); t.setUncaughtExceptionHandler((thread, e) -> log.error("线程[{}]执行异常", thread.getName(), e)); return t; } }; ThreadPoolExecutor executor = new ThreadPoolExecutor( 8, 16, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000), namedFactory, new ThreadPoolExecutor.CallerRunsPolicy() );给线程命名是排查问题时的救命稻草:在jstack线程Dump(Java虚拟机自带的线程快照工具,通过jstack <pid>命令获取,能显示线上当前每个线程的栈状态和锁等待情况)出现"order-async-worker-5 WAITING"时,你一看就知道是订单模块的异步工作线程在等待任务,不用大海捞针地猜。设置UncaughtExceptionHandler则确保任务抛出任何异常都不会静默吞掉,日志里会留下完整栈。
线程池调参是最玄学的部分,因为没有万能公式,只能给两个实践标准。CPU密集型任务,核心线程数设置为CPU核心数 + 1,因为线程几乎不阻塞,开太多线程只会增加上下文切换成本。IO密集型任务,核心线程数设置为CPU核心数 * 2左右甚至更高,因为线程大部分时间在等待IO,等待期间可以让其他线程占用CPU。用Runtime.getRuntime().availableProcessors()动态获取CPU数,避免硬编码到配置文件里导致换机器后行为不一致。之后再通过压测观察线程池指标(活跃线程数、队列积压量、拒绝次数)来调整,参数不是一次定死的。
再说一次优雅关闭,很多人线上重启服务时直接System.exit(),任务跑到一半全部丢失。正确做法是先executor.shutdown(),它不再接受新任务,但已提交的任务会执行完毕;等待一段时间后如果还有任务卡着,再executor.shutdownNow(),这会中断正在执行的任务并返回队列里未执行的任务列表。你可以记录这些未完成任务,做补偿处理或持久化,下次启动时恢复。这是我踩过一次很深的坑才学会的——关闭线程池是业务完整性的一部分,不是可有可无的收尾动作。
8. 并发排查与避坑实录:一次诡异的计数丢失是怎么定位的
讲了这么多原理,最后放一个真实案例,也算把这篇的知识点串起来。之前某服务有个统计接口,用了一个简单的Map<String, Integer>加synchronized方法记录各维度的请求次数,上线后发现高峰期统计数值总是比真实的请求数少,少得还很不规律。代码长这样:
private final Map<String, Integer> counter = new HashMap<>(); public synchronized void inc(String key) { counter.merge(key, 1, Integer::sum); // 看似很稳 }synchronized保护了merge方法本身,但同步方法组合在复合操作上依然有坑吗?这里其实不是锁的问题,而是统计口径的读取时机与并发的配合问题。监控系统每秒从外部读counter的值来绘图,读操作没有加锁。在生产端不断更新和监控端不断读取之间,HashMap的内部结构可能正处于调整过程中——虽然inc()是synchronized的,但监控线程读counter时没有同步,于是读到了一个中间状态:有的桶已经更新、有的桶还没更新,甚至可能在扩容的临界处读到不完整的桶链表。统计数字于是变得比真实值小,且随机。
这不是理论问题,是我真真切切线上排查过的问题。当时的排查链路是:先看业务日志,发现所有请求都执行到了inc()这行,排除了业务漏调用;去掉synchronized后错误率从千分之几飙升到百分之几,排除了锁本身失效;最后把读操作也包进同一个synchronized,或者换成ConcurrentHashMap,统计数字才变准。
这里有一个通用判断经验:凡是先写后读的共享状态,读操作和写操作必须处于同一个并发控制机制下。你给写加了锁,读却是裸奔的,那锁就跟没加一样——因为竞争条件发生在写线程和读线程之间,而不是写线程和写线程之间。很多"并发数据不对"的问题,都出在这个"读写不对称"上。
如果再用jstack看看当时的线程Dump,会发现大量线程阻塞在synchronized方法入口等锁,而读线程却不受任何限制地直穿进去取数据——画面感特别强。这也解释了为什么我建议给线程起名字:看到线程栈里"metric-writer-1"在BLOCKED、"metric-reader-1"在RUNNING,整个问题链路在一张截图里就完全讲清楚了。
这个案例想说明的其实是:并发编程调试的关键不是背更多的类库,而是把三大问题(原子性、可见性、有序性)、锁机制和容器选择放在脑子里当"故障字典"。线上遇到诡异现象时,先按这套字典枚举可能原因,再逐个验证,而不是一头扎进代码里翻来覆去地看。多线程代码写得出来不算本事,能快速定位并修复并发问题才是真功夫。这篇列出的所有底层机制、锁升级过程、JUC容器设计思路,最终都要为"出问题时你能快速找到答案"这个目标服务。多动手写生产消费者模型、多模拟DCL单例、多拿线程池压压测,这些技能体系就会慢慢长在自己身上。