Java多线程进阶:从锁机制到线程池的高并发实践
2026/9/18 21:29:04 网站建设 项目流程

1. 内容整体设计与思路拆解

1.1 为什么写这篇“初识多线程(下)”

上篇我们聊完了线程的创建方式、生命周期、状态切换,还有start()run()的区别这些入门基础。不少读者在评论区里追问:线程我会建了,但多个线程之间怎么配合?数据竞争怎么处理?什么情况下该用锁?线程池又是怎么一回事?

这些问题恰好就是高并发编程从“会用”走向“会用对”的分水岭。很多同学简历上写着“熟悉多线程”,一问到wait/notify的底层机制、volatile到底保证了什么、线程池的workQueue满了会发生什么,就明显底气不足。说白了,就是知其然而不知其所以然。

这篇“下篇”我打算换个讲法。不按教科书那种“先讲概念再讲API”的顺序,而是沿着一条主线走:多个线程同时跑起来之后,它们之间会产生哪些冲突,业界又是怎么一步一步解决这些冲突的。把这根线捋清楚了,synchronizedvolatileLockCountDownLatch、线程池这些东西就不是零散的知识点,而是一整套顺理成章的解决方案。

适合看这篇文章的人,我大致分三类:一是刚学完 Java 基础、准备啃高并发这块硬骨头的初学者;二是面试前想系统梳理多线程知识体系的求职者;三是写了好几年业务代码、线程池和锁一直在用但想搞明白原理的后端开发。不管你属于哪一类,这篇文章的目标都是让你读完能建立起自己的多线程知识框架,而不是背了一堆零散的面试题答案。

1.2 从“多线程”到“高并发”的认知升级

先聊一个很多人没想透的问题:多线程和高并发到底是不是一回事?

不是一回事,但它们是递进关系。多线程是一种编程手段,关注的是“如何在一个进程内同时执行多个任务”;高并发是系统的一种能力指标,关注的是“系统在面对大量并发请求时,如何保持低延迟、高吞吐、不崩溃”。要想支撑高并发,多线程几乎是绕不开的手段,但仅仅会用多线程,离高并发还很远。

举个例子。你写了一个接口,每个请求进来创建一个新线程去处理。单测的时候没问题,并发量一上来,线程数量爆炸,内存溢出,CPU 频繁切换上下文,系统直接雪崩。这就是典型的“用了多线程但没考虑高并发”,问题就出在线程管理策略上。

所以在讲具体 API 之前,我想先帮大家建立一个整体认知框架:一个线程安全的程序,需要同时满足三个条件——原子性(操作不可分割)、可见性(一个线程的修改能被其他线程看到)、有序性(代码执行的顺序符合预期)。这三者统称并发编程的三大特性,后面讲的所有工具和技术,本质都是在解决这三个问题中的一个或几个。

下篇的内容就围绕着这三个特性展开。看到synchronized时,你要知道它同时保证了原子性和可见性;看到volatile时,你要知道它只解决了可见性和有序性,但解决不了原子性;看到ConcurrentHashMap时,你要知道它是通过细粒度锁和 CAS 算法在并发度和安全性之间取了一个平衡。带着这个框架去学,效率会高很多。

2. 核心细节解析与实操要点

2.1 线程通信:wait/notify 的底层逻辑与正确用法

多个线程各自跑各自的,互相之间怎么打招呼?Java 提供了一套基于**监视器(Monitor)**的协作机制,核心就是Object类上的三个方法:wait()notify()notifyAll()

先把底层机制讲清楚。每个 Java 对象都有一个内置锁(也叫监视器锁),线程进入synchronized代码块时会持有这个锁。当你调用wait()时,当前线程会做三件事:

  1. 释放当前持有的监视器锁;
  2. 线程状态从 RUNNABLE 变为 WAITING;
  3. 该线程被放入该对象的**等待集(Wait Set)**中。

直到其他线程调用同一个对象上的notify()notifyAll(),等待集中的线程才有机会被唤醒,重新去竞争锁。这里需要注意一个关键点:notify()是从等待集中随机唤醒一个线程,而notifyAll()是唤醒所有线程。被唤醒的线程并不会立即执行,它要和其他线程一起重新竞争锁,拿到锁之后才能继续执行。

有一个经典的坑:wait()notify()必须在synchronized代码块或方法中调用,否则会抛出IllegalMonitorStateException。很多初学者不理解这是为什么,其实从设计角度看很好解释:既然要操作监视器锁和等待集,那你必须先证明你持有了这个锁,synchronized就是你的“入场凭证”。

再分享一个我踩过很多次的坑——wait 必须放在循环里,而不是用 if 判断。这是《Effective Java》里明确强调过的规范。原因是:线程被唤醒后,竞争到锁继续执行时,条件可能已经被其他线程改变了。如果用 if,线程唤醒后会直接执行后续逻辑,不做二次检查,就会产生错误的业务结果。我见过一个真实案例,某团队做消息队列消费时用 if 包裹 wait,高并发下偶发重复消费和漏消费,排查了三天,最后发现就是这个原因。

synchronized (queue) { // 必须用 while 而不是 if while (queue.isEmpty()) { queue.wait(); } Message msg = queue.poll(); }

2.2 并发三大特性与 JMM 的深度理解

有时候线程之间并不需要这么复杂的通信,但数据还是会出错,为什么?这就引出了Java 内存模型(JMM, Java Memory Model)

JMM 规定,所有变量都存储在主内存中,每个线程有自己的工作内存(可以类比为 CPU 缓存)。线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,不能直接读写主内存中的变量。而且不同线程之间无法直接访问对方的工作内存,线程间变量值的传递需要通过主内存来完成。

这个模型带来了一个严重的问题:可见性。线程 A 修改了一个变量,但这个修改还停留在 A 的工作内存里,没有刷新到主内存;线程 B 读这个变量时,读到的是主内存中的旧值。于是 B 就看到了一个“过期”的数据。

我常用一个生活化类比来解释这层关系:主内存相当于公司公告栏,线程的工作内存相当于每个人手里的便签本。你把自己的想法写在便签本上,同事想了解你的进度,必须等你去公告栏更新。你如果一直不更新,同事看到的永远是个旧版本。

解决方案有三个层次。第一层是volatile关键字,它保证了对变量的写操作会立即刷新到主内存,读操作会从主内存重新加载,相当于强制你每次改完便签本必须马上同步到公告栏。第二层是synchronizedLock,它们通过锁机制保证同一时刻只有一个线程操作,并且在释放锁时把工作内存中的修改强制刷新到主内存。第三层是final关键字,它提供了初始化安全性的保证——一个对象安全发布后,其他线程看到的final字段一定是初始化后的值。

2.3 volatile 的适用边界:它到底解决了什么

volatile是面试高频考点,但它也是最容易被误用的关键字。先说它保证的两件事:

第一,可见性。对 volatile 变量的写入,JMM 会在写入后插入一个内存屏障,强制把工作内存中的新值刷新到主内存。对 volatile 变量的读取,会在读取前插入内存屏障,强制从主内存加载最新值。

第二,有序性。JMM 会对 volatile 变量相关的指令进行重排序限制,禁止指令重排序越过 volatile 的读写边界。这个特性的典型应用场景是双重检查锁单例模式(DCL)。

volatile不保证原子性。经典的count++问题:即使你把 count 声明为 volatile,多线程执行count++依然会出问题。因为count++在底层是三步操作——读取 count 的值、加 1、写回 count——volatile 保证了每一步的可见性,但无法保证这三步作为一个整体不被其他线程打断。线程 A 在读和写之间,线程 B 可能已经完成了整轮操作,导致 A 写回的是过期值加 1 的结果。

那什么时候可以放心用 volatile?我总结了几个典型场景:

  • 状态标记位:比如用 boolean 变量控制线程的启停,一个线程写,其他线程读;
  • 单例模式中的 instance 声明;
  • 发布不可变对象的引用,比如把某个配置对象赋值给 volatile 引用,其他线程读到的都是完整发布的不可变对象。

除了这些场景,如果你需要保证复合操作的原子性,老老实实用锁或者原子类。

3. 实操过程与核心环节实现

3.1 锁的演进与升级:从偏向锁到重量级锁

Java 的synchronized在 JDK 1.6 之后做过一次大优化,引入了锁升级机制。这背后其实是 JVM 对现实场景的一个洞察:大部分锁在大部分时候,都只被同一个线程竞争。既然大多数场景是“无竞争”或“低竞争”,那每次都走操作系统内核态去加锁,成本就太高了。

锁的升级路径是这样的:无锁 → 偏向锁 → 轻量级锁 → 重量级锁

偏向锁的核心思想是:如果同一个线程多次获取同一个锁,就让这个锁“偏向”它,后续它再来的时候不需要任何 CAS 操作,直接进入。只有当另一个线程也来竞争这个锁时,偏向锁才会被撤销。撤销偏向锁要等到安全点(Safe Point),这是偏向锁在 JDK 15 里被废弃的主要原因之一——在高竞争场景下,偏向锁的撤销代价反而高于收益。

轻量级锁的核心思想是:线程进入临界区时,先在栈帧中创建锁记录(Lock Record),然后通过 CAS 尝试将对象头中的 Mark Word 替换为指向锁记录的指针。如果 CAS 成功,获取锁成功;如果失败,说明有其他线程持有锁,锁会膨胀为重量级锁。重量级锁就是依赖操作系统的互斥量(Mutex)来实现,线程会进入阻塞状态,涉及用户态和内核态的切换,开销最大。

很多面试题会问到“synchronized 是公平锁还是非公平锁”,答案是非公平锁。因为新来的线程会先去尝试 CAS 抢一次锁,抢不到才进入等待队列。这种设计的好处是减少了线程唤醒的开销,吞吐量更高,代价是可能会产生“插队”现象,某些等待时间很长的线程会一直等不到锁。

3.2 ReentrantLock 的可控性与 AQS 原理

ReentrantLockjava.util.concurrent.locks包下的可重入锁,它和synchronized都能实现线程同步,但 ReentrantLock 提供了更丰富的功能。我从实际使用出发,把这几个差异点列成了表:

对比维度synchronizedReentrantLock
锁释放方式自动释放,代码块执行完自动解锁必须手动unlock(),通常在 finally 中释放
锁的获取方式隐式获取支持lock()tryLock()lockInterruptibly()三种方式
公平性非公平构造方法传入 true 可实现公平锁
条件队列只有一个等待队列支持多个Condition,可以精准唤醒指定线程
中断响应不响应中断支持响应中断

ReentrantLock底层依赖的是AQS(AbstractQueuedSynchronizer),这是整个 JUC 包的地基。AQS 的核心设计是:用一个volatile int state表示同步状态,一个 FIFO 双向队列存放等待线程。加锁就是通过 CAS 把 state 从 0 改成 1,解锁就是把 state 从 1 改回 0。

我刚开始学 AQS 的时候也觉得抽象,后来换了个角度理解就通了:AQS 就像是一个银行叫号系统。来了一个客户(线程),先尝试直接办理业务(CAS 获取锁)。如果柜台被占用了,就领一个号(封装成 Node 节点),在等候区(CLH 队列)排队。柜台办完一个(释放锁),就通知下一个叫号办理。公平锁就是不允许插队,非公平锁就是新来的客户可以试一下能不能抢在排队的人前面。

3.3 CAS 与原子类:无锁并发方案的实践

CAS(Compare-And-Swap,比较并交换)是无锁并发编程的核心。它的逻辑很简单:比较内存中的值是否等于期望值,如果相等,就替换成新值;如果不相等,就什么都不做。整个过程是原子操作,由 CPU 指令直接支持,比如 x86 架构下的CMPXCHG指令。

java.util.concurrent.atomic包下的原子类,比如AtomicIntegerAtomicLongAtomicReference,就是用 CAS 实现的。用AtomicInteger替换int来做计数器,可以避免加锁的性能损耗:

public class AtomicCounter { private AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); } public int getCount() { return count.get(); } }

CAS 有一个著名的坑叫ABA 问题:线程 A 读到值是 1,准备改成 2;此时线程 B 把值从 1 改成 2,又从 2 改回 1;线程 A 的 CAS 比较发现值还是 1,CAS 成功。但在这个过程里,值其实已经被改过两次了。对于某些场景来说,这不影响结果;但如果你关心的是“值是否被改过”而不是“值当前是多少”,ABA 问题就会导致逻辑错误。解决方案是用AtomicStampedReference,它会额外维护一个版本号,每次修改版本号都会变化。

再说说 CAS 在并发竞争激烈时的性能问题。CAS 失败后会自旋重试,如果竞争太激烈,自旋次数过多会消耗大量 CPU。JDK 8 之后引入了LongAdder,它把热点数据拆分到多个 Cell 里,每个线程操作自己的 Cell,最后汇总。这种方式在超高并发场景下的性能远优于AtomicLong

3.4 线程池:高并发场景下必须掌握的武器

搞清楚了锁和原子类,我们再来看高并发场景里最常用、也最容易被用错的工具——线程池

先明确一个观点:在高并发场景下,永远不要用 new Thread() 创建线程。线程的创建和销毁是有成本的,包括分配内存、初始化、系统调用,而且线程本身占用大约 1MB 左右的栈内存。高并发下如果每个请求都新建一个线程,系统资源很快就会被打满。线程池的核心价值就是复用线程,降低线程创建销毁的频繁开销,同时通过队列缓冲控制并发量。

ThreadPoolExecutor的核心参数有七个:

参数含义设置建议
corePoolSize核心线程数CPU 密集型设为 N+1,IO 密集型设为 2N(N 为 CPU 核数)
maximumPoolSize最大线程数核心线程数 + 队列容量能承受的极限值
keepAliveTime非核心线程空闲存活时间根据任务到达间隔和业务容忍度设置
unit存活时间单位配合 keepAliveTime 使用
workQueue任务队列有界队列或无界队列,各有优劣
threadFactory线程工厂建议自定义,方便问题排查
handler拒绝策略有四种标准策略可选

线程池的执行流程是:提交一个新任务时,如果当前线程数小于 corePoolSize,创建新线程执行;如果达到 corePoolSize,任务进入 workQueue 排队;如果队列也满了,并且线程数小于 maximumPoolSize,创建新线程执行;如果线程数已经达到 maximumPoolSize,执行拒绝策略。

这里的核心矛盾是:核心线程数设置多少合适。我经常遇到团队直接把核心线程数设为 200、300,结果应用启动后莫名卡顿,线程大量阻塞等待 IO,CPU 却跑不满。正确的思路是根据任务类型区分:

  • CPU 密集型任务:线程数设为 CPU 核数 + 1,让每个线程都在计算,没有太多线程切换的必要;
  • IO 密集型任务:线程数设为 CPU 核数 × 2。因为 IO 等待期间 CPU 是空闲的,可以用更多线程来填充等待时间;
  • 混合型任务:如果能拆分就拆分,不能拆分就按 IO 密集型的上限来设置。

实际生产环境还要考虑内存限制、下游服务的承受能力,单机硬件的核心数只是一个起点,最终要配合压测来微调参数。我们团队的做法是:先按公式设置一个初始值,然后做压测,看 CPU、内存、接口 RT、线程池活跃度这些指标,再反推调整。

3.5 JUC 工具类实战:从并发协作到流量控制

java.util.concurrent包下除了锁和原子类,还有几个非常高频使用的工具类。

CountDownLatch是一个倒计数器门闩,核心场景是“主线程等待多个子线程都完成之后,再继续执行”。我之前做过一个数据汇总功能:主任务拆成 10 个子任务分别查不同数据源的数据,全部查完后统一合并。用 CountDownLatch(10) 初始化,每个子任务执行完调用countDown(),主任务调用await()等待计数归零。这里的底层是 AQS 的共享锁模式,state 初始化为 10,每次 countDown 对 state 减 1,减到 0 就唤醒等待的线程。热词里提到“线程等待都完成”,指的就是这个场景。

CyclicBarrier跟 CountDownLatch 容易混淆。两者的核心区别是:CountDownLatch 是“等人齐了开工”,计数只能减一次;CyclicBarrier 是“人齐了各自开工,可以循环使用”。CyclicBarrier 的典型场景是分批处理数据:每批 5 个线程都到达屏障点后,统一执行下一个阶段,然后屏障自动重置,等待下一批。

Semaphore是信号量,用来控制同时访问某个资源的线程数。它常被用作限流器。我之前在对接第三方支付接口时,就通过 Semaphore(50) 限制同时发起的 HTTP 请求数,避免把对方接口打挂。acquire() 获取许可,release() 释放许可,逻辑非常直观。

生产者-消费者模式是这些工具类的最佳练习场景。用有界阻塞队列ArrayBlockingQueue作为缓冲区,生产者线程负责往队列里放数据,消费者线程从队列里取数据。队列满时,生产者的 put 操作会阻塞;队列空时,消费者的 take 操作会阻塞。这种设计天然地实现了线程间的解耦,还能起到削峰填谷的作用。

下面是我在实际项目里写过一个简化版实现:

public class ProducerConsumerDemo { private static final int CAPACITY = 10; private static final BlockingQueue<String> queue = new ArrayBlockingQueue<>(CAPACITY); public static void main(String[] args) { // 2个生产者线程 for (int i = 0; i < 2; i++) { new Thread(() -> { try { String data = "数据-" + Thread.currentThread().getName(); queue.put(data); System.out.println("生产: " + data); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, "Producer-" + i).start(); } // 3个消费者线程 for (int i = 0; i < 3; i++) { new Thread(() -> { try { String data = queue.take(); System.out.println("消费: " + data); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, "Consumer-" + i).start(); } } }

这里有一个容易被忽略的细节:捕获到InterruptedException后,应该调用Thread.currentThread().interrupt()重新设置中断标志位,而不是简单地打印日志或者忽略它。因为中断标志被清除后,上层代码就无法感知线程曾经被中断过,这在需要优雅停机的场景下会造成线程无法及时退出。

3.6 ConcurrentHashMap:并发容器的正确选择

集合类的线程安全性是高并发编程里的高频话题。HashMap在多线程并发写的情况下,JDK 7 及之前版本可能因为头插法在扩容时产生环形链表,导致 CPU 100% 的死循环问题。JDK 8 改成了尾插法,解决了死循环问题,但并发写依然会丢数据。所以并发场景下,别再纠结 HashMap 为什么不安全了,直接用并发容器才是正路。

HashtableCollections.synchronizedMap()虽然线程安全,但它们是给整个 Map 加一把大锁,所有操作串行化,并发度极低。ConcurrentHashMap就是为了解决这个痛点而生的。

JDK 8 中,ConcurrentHashMap采用了CAS + synchronized的组合方案。它的底层数据结构是数组加链表加红黑树。写入时,如果对应位置的桶为 null,直接用 CAS 写入,不需要加锁,这是写入最快的路径。如果桶不为 null,就对桶的头节点加 synchronized 锁,锁粒度非常细,不同的 key 落到了不同的桶上,可以并行写入。

每次扩容的时候,JDK 8 的 ConcurrentHashMap 支持多线程协同扩容。它会把整个数组划分为多个区间,每个线程负责一段区间的数据迁移,迁移完成后协助其他线程,而不是一个线程单独把所有数据搬完。这个设计在大容量场景下能显著缩短扩容时间。

我之前在监控告警项目里,用一个 ConcurrentHashMap 维护所有实时在线设备的状态,写入并发量大约每秒 5 万次,一直很稳。如果换成 Hashtable 或者加了大锁的同步 Map,光是锁竞争就能把 CPU 打满。

还需要注意一点:ConcurrentHashMap的弱一致性问题。它的get操作不加锁,因此在并发写的过程中,读到的可能是某个时刻的旧值,但是不会读到脏数据。如果你的业务场景要求读取的强一致性,也就是必须读到最新写入的值,简单的方案是改用读写锁,但并发度会有所下降,需要根据业务容忍度来取舍。

4. 常见问题与排查技巧实录

4.1 面试高频考点速查

聊完了多线程的核心知识点,我整理一些面试里出现频率极高的问题和参考思路,供大家自查。这些问题都是我在面人时经常问的,也是我自己面试中遇到过的。

问:wait() 和 sleep() 有什么区别?

wait()是 Object 的方法,必须配合 synchronized 使用,调用后会释放监视器锁,线程进入 WAITING 状态;sleep()是 Thread 的静态方法,不释放锁,线程进入 TIMED_WAITING 状态。高并发场景下,如果你需要基于某个条件等待,用 wait;如果只是让当前线程暂停,用 sleep。

问:synchronized 和 ReentrantLock 怎么选?

这是一个经典的取舍问题。如果只是简单的方法同步、代码块同步,synchronized 足够,而且 JVM 会自动释放锁,不容易出错。如果你需要可中断获取锁、超时获取锁、公平锁、多个条件队列这些高级功能,ReentrantLock 是更好的选择。另外,JDK 版本的演进让 synchronized 在多数场景下的性能已经不输 ReentrantLock,所以在选型时不必过度纠结性能差异,主要看功能需要。

问:什么是死锁?如何避免?

死锁是多个线程相互持有对方需要的锁,同时又在等待对方释放锁,导致所有线程都无法继续执行。经典的四个必要条件是:互斥、持有并等待、不可剥夺、循环等待。排查死锁,先用jps找到 Java 进程号,再用jstack piddump 线程栈,搜索“deadlock”关键字就能定位。避免死锁的常用手段是:尽量缩小锁的范围、按固定顺序加锁、使用tryLock带超时时间来获取锁。

4.2 线程池线程名优化与线上排查实战

线程池有一个常被忽视的问题:默认的线程名是pool-1-thread-1这种格式。一旦出问题,比如某个线程 CPU 飙高、死锁、OOM,你 dump 线程栈之后根本看不出来这个线程是干什么的。

我强烈建议在创建线程池时,自定义 ThreadFactory,把线程名设置成有业务含义的格式:

ThreadFactory factory = new ThreadFactory() { private final AtomicInteger seq = new AtomicInteger(0); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r); t.setName("order-async-processor-" + seq.getAndIncrement()); t.setUncaughtExceptionHandler((thread, throwable) -> System.out.println("线程 " + thread.getName() + " 发生未捕获异常: " + throwable.getMessage())); return t; } };

我踩过一个真实的坑:某次线上服务出现间歇性超时,用 jstack 一查,发现有几个线程一直卡在数据库连接池的获取连接处。正常情况下获取连接是毫秒级,但那次等了几十秒。继续排查发现,连接池大小只有 10,但业务线程池的核心线程和最大线程都设成了 50,大量线程同时去拿连接,把连接池打满,后面的请求全部排队等待。最后把连接池大小调大,并给线程池设置了合理的拒绝策略,问题才解决。

4.3 常见并发问题排查速查表

问题现象可能原因排查思路
CPU 使用率过高线程死循环、CAS 自旋过度jstack 查看线程栈,定位堆栈热点
接口响应变慢锁竞争激烈、线程池队列积压jstat 查看 GC 情况,dump 线程看锁等待
数据错乱/丢失并发写共享变量、HashMap 并发操作检查代码中共享变量加锁情况
内存溢出线程数过多、无界队列堆积jmap 查看堆内存,排查线程池是否用了无界队列
程序卡死死锁、线程阻塞jstack 搜索 deadlock 或 BLOCKED 状态

这里分享一个通用的排查流程,也是我自己的习惯:遇到线上问题,第一步不是看代码,而是先把现场数据留好。用jstack连续 dump 三次线程栈,每次间隔 5 秒,观察线程状态的演变趋势。如果线程状态一直停留在 BLOCKED 或 WAITING,大概率是锁的问题;如果线程状态频繁切换但 CPU 很高,可能是业务逻辑计算密集或者自旋。再配合jstat -gcutil看 GC 情况和jmap -heap看堆内存,基本能定位大部分问题。

5. 关于并发编程的学习路径与踩坑心得

很多读者会问我同一个问题:多线程的知识点太多了,学了后面忘了前面,到底应该怎么学?

我的建议是:不要按知识分类去学,要按问题场景去学。先搞清楚“多个线程同时修改一个变量会怎样”,然后去接触 synchronized;再问“synchronized 性能太差怎么办”,然后去学锁优化和并发容器;再问“线程创建销毁开销大怎么办”,然后去学线程池。每一步都是为了解决一个具体问题,知识点之间自然形成关联,记忆成本会低很多。

再说一个我观察到的现象。很多初学者写多线程代码,喜欢在一段代码里同时用多种并发工具,好像用的工具越多显得越专业。这实际上是错误的。并发编程的第一原则是能用简单方案就不要用复杂方案。在低并发场景下,synchronized已经足够了,没必要为了“性能”强行引入ReentrantLock。在高并发场景下,能使用无锁方案(原子类、不可变对象、线程封闭)就不要用锁;必须用锁时,尽量缩小临界区的范围。

举一个线程安全的反面例子:我之前接手过一个优惠券发放系统,原来的开发为了保证并发安全,把整段发券逻辑都放在了一个大 synchronized 块中,包括查库存、校验用户、锁账户、写流水、扣库存,逻辑本身没问题,但并发量一上来,所有请求全部串行执行,接口响应时间从 30ms 涨到 3 秒。后来我们把锁粒度做了细化,只锁住扣减库存那一段,其他的操作都移出临界区,接口响应时间降到了 80ms 左右,吞吐量提升了整整一个数量级。

除了粒度,还需要关注锁的顺序。多个线程如果都以不同的顺序获取多把锁,就很容易产生死锁。比如线程 A 持有锁 1 再去获取锁 2,线程 B 持有锁 2 再去获取锁 1,这就是教科书级的死锁模型。解决思路是让所有线程按照相同的顺序获取锁,或者在获取锁时设置超时时间,获取不到就释放已经持有的锁,避免无限等待。

还有一个常见误区是过度使用 volatile。volatile 不适合做计数器,也解决不了复合操作的原子性。我在代码评审中经常看到有人用 volatile 修饰一个 List 或者一个业务对象,期望实现线程安全,这显然是不现实的。volatile 只能让你看到最新的引用,但无法保证你拿到这个对象之后,对象内部的字段不被并发修改。正确的做法是使用并发容器、锁,或者把对象设计成不可变对象。

踩了这么多坑之后,我最大的体会是:多线程编程的难点不在于 API 记不记得住,而在于建立并发环境下的思维习惯。写代码的时候,时刻问自己三句话:这个变量有没有可能被多个线程同时读写?这段操作是不是原子的?如果线程被中断或者锁获取失败,程序能不能自愈?

最后再分享一个我坚持了很多年的习惯。每次写完一段多线程核心逻辑,我都会做三件事:第一,用线程名标识好每个线程的职责;第二,模拟并发场景写一个小测试,至少跑 100 万次操作验证数据一致性;第三,用jstack观察一下运行中的线程分布和状态是否跟预期相符。这个习惯帮我提前发现了不少潜在问题,也让我在面试聊多线程的时候,比单纯背八股文的人多了一份“实战感”。如果你也在学 Java 多线程,建议从今天开始也试着做起来。

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

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

立即咨询