☰
JavaSE多线程与并发:锁、线程池及实战排查精讲
2026/10/9 9:12:41 网站建设 项目流程

先交代一句:这篇文章聊的是JavaSE系列里的最后一个硬骨头,也是整个JavaSE知识树里最容易被问倒、最容易翻车的篇章——多线程与并发。学JavaSE前期的语法、集合、IO,本质上都是单线程的“线性思维”,到了这里,整个思维模型要从“一辆车跑一条道”变成“好多辆车抢几条道”,很多人的崩溃就是从这儿开始的。

这篇文章适合三类人:一是正在学JavaSE、准备从面向对象过渡到并发编程的同学;二是已经把基础语法刷完、但不知道线程安全到底是啥的初级开发者;三是准备面试、想快速把多线程知识盘成体系的人。我尽量用“人能听懂的话”来讲,把锁、线程池、原子类这些概念全部落到能跑通的代码上。

1. 线程的底层逻辑与三种创建方式

很多教材上来就讲Thread、Runnable,这是纯API视角,学完容易“会写但不懂”。我从操作系统角度先把线程这层窗户纸捅破,你会发现后面所有的锁、同步、通信全是围着这层逻辑转的。

1.1 进程和线程的关系,用餐厅打个比方

一个进程相当于一间餐厅,它有自己的后厨、仓库、收银台、门面——这就是独立的内存空间。线程相当于餐厅里的员工,所有员工共享同一个后厨和仓库(共享堆内存),但各有各的工作服和工牌(线程栈、程序计数器)。餐厅可以只有老板一个人干活,也可以请十个服务员同时接客,这就是单线程和多线程的关系。

线程为什么存在?核心原因是让程序同时干好几件事:一边接收用户输入,一边渲染界面,一边在后台下载文件。如果是单线程,你得等下载完才能点按钮,这体验基本是灾难。

Java里线程的最小单位是Thread对象,每个Thread实例对应操作系统一个原生线程。你new一个Thread并不立即开始跑,只有调用start()方法,JVM才会通过底层操作系统创建真正的线程去执行run()里的代码。这里有个最常见的坑:直接调用run()不会新起线程,它只是普通方法调用,在主线程里跑而已。

1.2 Thread继承和Runnable接口的取舍

创建线程最经典的方式是两种:继承Thread类和实现Runnable接口。

// 方式一:继承Thread,重写run方法 class MyThread extends Thread { @Override public void run() { System.out.println("线程运行中:" + Thread.currentThread().getName()); } } // 使用:new MyThread().start(); // 方式二:实现Runnable,传给Thread class MyTask implements Runnable { @Override public void run() { System.out.println("任务运行中:" + Thread.currentThread().getName()); } } // 使用:new Thread(new MyTask()).start();

这两种方式的取舍很简单:Java单继承,你继承了Thread就不能继承别的类了,所以真正写项目几乎没人用继承Thread,全是实现Runnable。Runnable把“任务”和“执行者”解耦了——线程是Thread,任务是Runnable,逻辑上更干净。后面线程池接收的也是Runnable或Callable,而不是Thread,这本身就说明设计倾向。

1.3 lambda改造与Callable的补位

Runnable接口只有一个抽象方法run(),是标准的函数式接口,所以Java 8之后可以直接用lambda简化:

new Thread(() -> System.out.println("lambda线程:" + Thread.currentThread().getName())).start();

但Runnable有个尴尬:run()方法没有返回值,也无法抛受检异常。你想把线程算出来的结果拿回来用,它做不到。所以Java 5引入了Callable:

Callable<Integer> task = () -> { Thread.sleep(1000); return 42; }; FutureTask<Integer> futureTask = new FutureTask<>(task); new Thread(futureTask).start(); Integer result = futureTask.get(); // 阻塞等待结果 System.out.println("结果:" + result);

FutureTask的get()是个阻塞方法,等线程算完才有结果,这是异步转同步的标准姿势。你现在可能觉得这写法啰嗦,先记住,后面线程池里的submit()就是干这个的,本质思路完全一致。

2. 线程安全:锁的演进与同步机制

线程一旦多了,最怕的事就是“抢资源”。多个线程同时改同一个变量,轻则数据错乱,重则程序崩溃。这是整个并发的核心矛盾——线程安全。

2.1 从竞态条件到临界区

先看一个最经典的翻车场景:

public class Counter { private int count = 0; public void increment() { count++; // 看似一行,底层是“读值-加一-写回”三步 } }

两个线程同时执行increment(),理想顺序是:线程A读count=0,A改成1,写回;线程B读count=1,改成2,写回。但现实可能是:A读count=0,B也读count=0,A写回1,B也写回1,最终count只有1。两次加一,结果只加一,这就是竞态条件。

产生竞态的本质是:count++这行代码不是原子的,它在字节码层面是好几条指令。多个线程同时进入同一段操作共享数据的代码区域,这段区域叫临界区。解决思路就一个:保证临界区内的操作在同一时刻只有一个线程能进入——加锁。

2.2 synchronized的三种形态与锁升级

synchronized是Java最基础的锁,用法有三层:

// 1. 修饰实例方法,锁的是this对象 public synchronized void increment() { count++; } // 2. 修饰静态方法,锁的是Class对象 public static synchronized void staticMethod() { } // 3. 同步代码块,锁的是指定对象 public void increment() { synchronized (this) { count++; } }

锁的本质是“对象的监视器锁”(monitor)。每个Java对象都有一个monitor,线程进入synchronized代码块前必须拿到这个monitor,拿不到就阻塞等待。这就是互斥。

很多同学纠结“锁对象选谁”,核心原则是:多个线程竞争的资源是谁,就锁谁拥有的那个对象。比如多个线程都在改同一个Counter对象的count,那就锁这个Counter实例;如果改的是静态变量,那就锁Class对象,因为静态变量属于类级别,实例锁不管用。

JDK 6之后的synchronized做了锁升级优化:从无锁到偏向锁,再到轻量级锁,再膨胀到重量级锁。简单理解,锁初期很轻量,多线程没真正竞争时效率很高;只有真打起来才升级成重量级锁,阻塞线程。这也是为什么现在很多场景synchronized性能并不输给Lock——别一听“老朋友”就嫌它慢。

2.3 volatile:轻量级同步方案

volatile是另一个高频词汇,它解决的是可见性问题,不是原子性问题。

Java内存模型里,每个线程在工作内存中会缓存变量副本,不会每次都直接读主存。线程A改了变量,线程B可能还读着旧副本,这就是可见性问题。volatile关键字强制线程每次读写都直接操作主存,保证一个线程改了、其他线程立刻看到。

public class FlagTest { private volatile boolean running = true; public void stop() { running = false; } public void work() { while (running) { // 循环体 } } }

但volatile不保证原子性,上面的count++问题用volatile解决不了,因为读改写三步不是原子的。volatile适用的场景是:一个线程写、多个线程读的标记位。它的开销比锁小得多,能用它解决的问题别上锁。

2.4 从synchronized到Lock:谁才是项目首选

synchronized虽好,但有几个痛点:无法中断一个正在等待锁的线程;无法设置等待超时;多个条件(condition)的精确唤醒不好搞。于是Java 5提供了Lock接口:

Lock lock = new ReentrantLock(); lock.lock(); try { // 临界区 } finally { lock.unlock(); }

注意一个铁律:Lock的解锁必须放在finally里,否则中途抛异常锁永远不会释放,直接死锁。ReentrantLock是juc包里最常用的锁实现,名字里的Reentrant表示“可重入”,同一个线程可以反复获取同一把锁,这一点和synchronized一致。

还有两个扩展方法值得用:tryLock(timeout, unit),等待超时就放弃,避免死锁;lockInterruptibly(),允许线程在等待锁时响应中断。这些synchronized全都没有。

实际项目里我的习惯是:简单的同步方法直接用synchronized,代码少、可读性强;需要超时控制、多条件唤醒、公平锁策略时,才用ReentrantLock。

3. 线程间通信:生产者消费者完整落地

多线程不是各跑各的,更多时候需要协作——一个线程产出数据,另一个线程消费数据。这就是经典的生产者-消费者模型。这一节我给出完整可跑的代码,手把手拆解细节。

3.1 用wait/notify写能跑的生产者消费者

需求:一个仓库最多存10个数字,生产者往里放,消费者往外取,仓库满时生产者等待,仓库空时消费者等待。

public class ProducerConsumerDemo { private static final int CAPACITY = 10; private final LinkedList<Integer> queue = new LinkedList<>(); public synchronized void produce(int value) throws InterruptedException { while (queue.size() == CAPACITY) { wait(); // 仓库满了,生产者挂起等待 } queue.addLast(value); System.out.println("生产:" + value + ", 当前库存:" + queue.size()); notifyAll(); // 唤醒一个/所有等待的消费者 } public synchronized int consume() throws InterruptedException { while (queue.isEmpty()) { wait(); // 仓库空了,消费者挂起等待 } int value = queue.removeFirst(); System.out.println("消费:" + value + ", 剩余库存:" + queue.size()); notifyAll(); return value; } public static void main(String[] args) { ProducerConsumerDemo demo = new ProducerConsumerDemo(); // 两个生产者 for (int i = 0; i < 2; i++) { final int seed = i; new Thread(() -> { try { for (int j = 0; j < 20; j++) { demo.produce(seed * 100 + j); Thread.sleep(50); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }).start(); } // 两个消费者 for (int i = 0; i < 2; i++) { new Thread(() -> { try { for (int j = 0; j < 20; j++) { demo.consume(); Thread.sleep(100); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }).start(); } } }

这套代码值得背下来,它是所有线程通信代码的母版。

3.2 为什么wait必须放在while循环里而不是if里

教科书和面试官最爱问的一个点就是:wait()外面套的是if还是while?标准答案必须是while,有三层原因:

第一层是虚假唤醒。JVM规范允许wait()在没有notify的情况下被意外唤醒,这不是bug,是底层行为。用if的话,被虚假唤醒后直接往下走,而条件可能仍不满足(比如库存还是满的),就直接出错。while会重新检查条件,不满足继续wait。

第二层是多线程竞争。即使你是被notifyAll唤醒的,但仓库里只有一个空位,三个生产者一起醒来,如果走if,三个都会往队列里塞数据,容量就爆了。while保证只有一个线程能通过条件检查。

第三层是编程习惯的统一。你在while循环里检查条件是“守住了再干活”,如果条件不满足就再次等待,这是一套完整的自洽逻辑。记住口诀:wait永远在循环里,循环条件就是资源条件。

3.3 Lock+Condition的精确唤醒版本

synchronized的wait/notify有个粗糙的地方:notifyAll会唤醒所有线程,然后大家互相挤。如果用Lock的Condition,可以实现精确唤醒——只唤醒生产者或只唤醒消费者。

public class ConditionDemo { private static final int CAPACITY = 10; private final LinkedList<Integer> queue = new LinkedList<>(); private final Lock lock = new ReentrantLock(); private final Condition notFull = lock.newCondition(); // 仓库未满条件 private final Condition notEmpty = lock.newCondition(); // 仓库非空条件 public void produce(int value) { lock.lock(); try { while (queue.size() == CAPACITY) { notFull.await(); } queue.addLast(value); notEmpty.signal(); // 只派发给一个正在等待的消费者 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { lock.unlock(); } } public int consume() { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); } int value = queue.removeFirst(); notFull.signal(); // 产出一个空位,唤醒一个生产者 return value; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return -1; } finally { lock.unlock(); } } }

我把两个案例摆在一起,你能直观感受到区别:synchronized版本是“全喊一遍,大家自己抢”;Condition版本是“精准点名,生产者找生产者,消费者找消费者”。后者的线程调度开销更小,复杂系统里优先用它。

4. 原子类、并发容器与线程池

有了锁和通信的基础,现在进入工程级工具。这三样东西是并发编程里真正高频使用的组件:原子类解决简单计数问题,并发容器替代老旧的Vector/Hashtable,线程池接管线程的生命周期。

4.1 CAS原理:原子类的灵魂

AtomicInteger的本质是CAS(Compare And Swap)操作,即比较并交换。它的流程是:先读当前值,计算新值,在写回之前再确认一次当前值没被改过,没改就写回,改过了就重试。这是乐观锁的思路——不阻塞,反复尝试。

AtomicInteger count = new AtomicInteger(0); // 等价于 count++,但线程安全 int newValue = count.incrementAndGet(); // 自定义CAS操作 count.updateAndGet(x -> x * 2);

CAS有三个问题要心里有数:ABA问题(值被改成其他值又改回来,CAS无法感知,解决要靠版本号AtomicStampedReference);自旋开销(高并发下反复重试,CPU空转);只能操作单个变量(多个变量联动还是要锁)。

JDK 8之后推荐LongAdder:它在高并发下把单个计数变量拆成多个单元,线程各自累加自己在的那份,最后sum()汇总。简单场景用AtomicInteger就够,但超高并发计数场景用LongAdder性能能提升一个量级。我曾经压测过一个热点计数,AtomicInteger的吞吐是每秒2000万,LongAdder能到7000万以上,代价是sum()不是精确的实时值。取舍看业务:对数值精确性要求苛刻、读多写少选原子类;写多、不要求瞬间精确选LongAdder。

4.2 ConcurrentHashMap比Hashtable强在哪

先纠正一个误区:不要在项目里用Hashtable,也不要用Collections.synchronizedMap()包HashMap。它们的同步方式都是锁整个map,读也要锁,并发大了全堵在一个入口。

ConcurrentHashMap的思路是分段/分桶锁。JDK 7是每段一个锁,JDK 8改成了CAS + synchronized锁桶头节点,锁粒度细到“每个哈希桶”。桶之间互不影响,读操作无锁化,写操作只锁住要写入的那个桶。8核下同时写8个不同桶是完全并行的。

Map<String, Integer> map = new ConcurrentHashMap<>(); map.put("key", 1); // 线程安全写 Integer value = map.computeIfAbsent("key", k -> 1); // 原子计算

注意computeIfAbsent是个原子操作,可以替代“先get再put”的复合操作。我自己踩过坑:之前用putIfAbsent写缓存,但value的构建逻辑可能执行多次,改用computeIfAbsent后,构建逻辑在键不存在时才执行且只执行一次。

4.3 线程池:手写一个ThreadPoolExecutor

线程池解决了两个核心问题:复用线程(不反复创建销毁,省耗时);控制并发数(防止几百个线程把CPU拖垮)。Java的线程池顶层接口是ExecutorService,最常用的实现是ThreadPoolExecutor。

ThreadPoolExecutor executor = new ThreadPoolExecutor( 2, // 核心线程数 8, // 最大线程数 30, TimeUnit.SECONDS, // 非核心线程空闲存活时间 new ArrayBlockingQueue<>(100), // 任务队列 Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy() // 拒绝策略 );

核心参数有七个,但最关键是四个:corePoolSize、maximumPoolSize、workQueue、rejectedExecutionHandler。提交任务的流程是:先让核心线程跑;核心线程忙了,任务进队列;队列满了,创建非核心线程;线程数到上限,走拒绝策略。

选参数没有万能公式,但有经验参考:CPU密集型任务设N+1(N是CPU核数),IO密集型设2N或更多;队列长度要结合任务的峰值排队时长来算。拒绝策略这里坑最多:CallerRunsPolicy会让提交线程自己跑任务,适合不想丢任务的场景;DiscardPolicy静默丢任务,一般别用;最严肃的是AbortPolicy,直接抛RejectedExecutionException,我习惯自定义个策略打日志+降级。

4.4 大厂面试版线程池题:为什么禁止用Executors

Executors是工具类,几个快捷方法看着省事,但它们都有隐患:

// 坑一:无界队列,任务堆积会把内存打爆 ExecutorService pool = Executors.newFixedThreadPool(10); // 内部用的是 LinkedBlockingQueue 无界队列 // 坑二:最大线程数是Integer.MAX_VALUE,等于无上限 ExecutorService pool = Executors.newCachedThreadPool(); // 内部用的 SynchronousQueue,线程数可以无限膨胀

newFixedThreadPool的无界队列意味着:任务永远排得下,线程数永远不增长,如果任务持续堆积,内存直接OOM。newCachedThreadPool的SynchronousQueue会把任务直接交给线程处理,线程不够就新建,最多可以建20多亿个线程,系统必挂。

正确做法是像我上面那样显式new ThreadPoolExecutor,把队列、线程数、拒绝策略全部掌控在自己手里。“不要用Executors”这句话在面试里说出来,比你说一万个API都有分量。

5. 易错点、排查技巧与性能误区

多线程的内容如果只讲API,那你学完还是会写出一堆线上事故。这节是真正的实战经验合集,全是我在实际项目里踩过、查过、压测过的真实教训。

5.1 三大经典死锁场景

死锁的公式是:两个或多个线程,各自持有一把锁,等待对方手里的锁,谁都不让。

场景一:锁顺序不一致。线程A先锁a再要b,线程B先锁b再要a,两边卡死。解决:所有线程按相同顺序加锁,先a后b,就破局了。

场景二:在持锁状态下调用外部方法。你锁着订单表,然后去调库存接口,库存那边又锁着库存表等订单结果,死锁就在追逐中诞生。解决:不要在持锁时做耗时操作,把锁的范围缩到最小。

场景三:tryLock超时被忽略。这其实是代码bug:

lock.tryLock(3, TimeUnit.SECONDS); // 没拿到锁,但你继续往下走,照样操作共享资源

有人以为tryLock一定成功,实际上返回false时得做兜底处理。用Lock的场合,我习惯写成:

if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 临界区 } finally { lock.unlock(); } } else { // 超时处理:重试、降级或者记日志 }

5.2 线程卡死后的排查流程

线上出现了线程全部卡住、接口毫无响应,第一步别重启,先抓线程栈。线程栈是判断死锁和阻塞最直接的证据。

Linux下用jstack:先jps找到Java进程ID,再jstack PID > thread.log。Windows下可以用jconsole或jvisualvm。打开线程栈文件后,搜“Found one Java-level deadlock”,如果你写的是规范代码,这里会直接指出哪些线程互相持有哪把锁。搜不到死锁关键字,就看线程状态:

  • BLOCKED:正在等待锁,看它等的是哪把锁,谁拿着这把锁;
  • WAITING:在等待唤醒,对应wait/await;
  • RUNNABLE:正常跑着,如果集中在某个GC线程,那就是GC停顿问题。

我处理过一次线上事故:所有业务线程全是BLOCKED,等同一把数据库连接池的锁,根因是数据库连接泄露,连接池耗尽。线程栈不会直接告诉你“连接泄露”,但你能从“所有线程都在获取连接”倒推过去。

5.3 性能误区:锁粒度越小越好吗

很多人追求“无锁”,无锁确实快,但代码复杂度和BUG率会上去。锁的代价不只是性能,还有正确性。没有充分的压测数据支撑,不要轻易去锁重构。

锁粒度方面也有个反向教训:synchronized方法锁整个方法体,你把不相关的计算逻辑也锁住了,这是锁没细化;但如果你把锁拆成三个小锁,本来一个事务性操作被拆成三步,中间步骤别的线程插进来改了数据,业务就错了。锁粒度不是越小越好,而是“临界区正好覆盖需要原子保护的逻辑,不扩大不缩小”。

线程数设置的经验公式我见过太多误解,真实的准则是:CPU密集型用“核数+1”,IO密集型用“核数*2”起步,但最终都以压测为准。我用一个项目举例:日志写入的异步线程池,IO密集,核数8,起了16个线程,QPS达标且线程占用稳定在40%以下,完美。后来又贪爽加了4个线程想再提QPS,结果线程切换开销上来了,QPS反而掉了5%。调线程池参数一定要压测,别拍脑袋。

5.4 高并发下的日志打印要小心

日志是排查问题的重要工具,但高并发下有个隐蔽问题:日志框架的异步队列也可能成为瓶颈和内存炸弹。我见过log4j2异步队列默认配置,高峰流量下队列堆积上百万条日志,直接把内存撑爆。

经验做法是:业务日志打印量要控制在每秒1万条以内,超过就要采样或者降级;日志内容别打印大对象(比如整个请求体),打MDC关键字段就够了;异步队列有个上限,满了要抛弃日志而不是阻塞业务线程。

排查高并发问题时,很多同学第一反应是加日志看中间状态,这恰恰最危险——加了日志可能会改变线程时序,让问题“消失”或“加重”。正确姿势是:优先看业务metrics,再抓线程栈,最后才考虑有条件的日志采样。

6. 我能给到的最后几条实战心得

JavaSE的多线程学到这,知识地图基本齐了:线程模型、锁机制、通信方式、并发工具、线程池、问题排查。接下来分享几条我在实际项目中沉淀下来的判断原则和使用偏好,希望能帮你少走弯路。

第一,能不用锁就不用锁,优先用无锁方案。AtomicInteger、ThreadLocal、CopyOnWriteArrayList这些都是为了少用锁存在的。CopyOnWriteArrayList适合读多写极少的场景,比如缓存配置列表:写的时候复制一个新数组替换引用,读的时候数组内容永远不变,完全无锁。但写频繁就别用它,每次写都复制数组,成本高到吓人。

第二,ThreadLocal用完必须remove,否则线程池回收不了ThreadLocalMap里的value,会内存泄露。为什么线程池尤其严重?因为线程池的线程长时间存活,你往ThreadLocal里塞的每个值都绑定在线程上,线程不死,值永远挂在老地方。我踩过的坑是:用ThreadLocal存用户信息,用户A的数据被线程池里的用户B看到了,这就是典型的线程复用的脏数据问题。正确的关闭习惯是finally里remove()。

第三,真正高难度的并发问题是“顺序”和“一致”,不是“性能”。锁、CAS、队列解决得再好,如果业务逻辑本身的前置后置条件没理清,照样出错。用synchronized去包裹一段复杂业务前,先画清楚:哪些操作是一个不可分割的整体,哪些操作之间有顺序依赖,这一步做扎实,后面写代码就是水到渠成的事。

第四,建议你养成读线程dump的习惯。不需要等到出事故才看,平时写复杂并发代码时,可以故意加个sleep然后jstack看线程状态,理解每个状态长什么样。这比背十遍并发编程的艺术都有用。

最后说个很多人忽略的点:JavaSE的多线程是整个Java并发体系的地基。后续学Spring、学Netty、学各种中间件,底层全在并发和IO上绕圈。地基打牢了,后面任何框架都只是API层面的使用,你看到的是一个又一个基于线程池、锁、IO模型的组合,而不是一堆陌生名词。这节啃下来,JavaSE就真的收官了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询