如果你长时间写多线程代码,大概率撞过这类灵异事件:一个计数器明明加了1000次,最后却是997;两个线程同时往列表里塞数据,读出来比塞进去的少;线上服务突然卡死,一查是两个线程在互相等对方释放锁。这些现象背后的共同元凶,就是“线程中的并发安全”。
这篇文章我想把并发安全这摊子事从头到尾拆一遍,包含进程与线程的地基概念、竞态条件与死锁的形成机理、AtomicInteger和锁的正确用法、线程池的配置与阻塞队列选型、虚拟线程和Python自由线程这类新模型,以及Android、C#、易语言等场景下跨线程操作UI的标准姿势。无论你是搞Java后端、写Python脚本、做Android开发还是维护老系统,都能在里面找到能直接抄作业的代码和避坑清单。
1. 线程与进程:先搞清楚并发安全的地基概念
并发安全这个事,本质上是由线程的“共享访问”引发的。所以在聊任何防护手段之前,必须先分清进程和线程到底有什么不同,以及“并发”和“并行”这两个天天被混用的词,在技术上其实指的不是一回事。
1.1 进程和线程的本质区别
进程是操作系统分配资源的基本单位,它拥有一套独立的地址空间、文件描述符和内核数据结构。你可以把进程理解成一个“独立店铺”,有自己的收银台、仓库和员工。而线程是CPU调度的基本单位,它寄生在进程内部,同一个进程里的多个线程共享这块内存、这组文件句柄。说白了就是一个店铺里干活的多个员工,共用同一个收银台和货架。
这个“共享”正是并发安全问题的根源。进程之间因为地址空间隔离,几乎没有“抢数据”的可能(跨进程通信走的是管道、消息队列、共享内存这些显式机制);而同一进程内的线程天然共享堆内存,两个线程同时读写同一个对象,冲突就不可避免。
另外还有一个关键差异:切换成本。进程切换要换地址空间、刷新TLB(页表缓存),开销很大;线程切换只保存和恢复寄存器、栈指针等少量上下文,成本低一两个数量级。这也是为什么现代后端服务普遍选择“多线程 + 共享内存”而不是“多进程 + 消息传递”来应对高并发,虽然共享带来安全隐患,但性能收益实在太诱人了。
新手最容易犯的概念错误是把“线程安全”等同于“正确”。实际上,几个线程能跑完只是最低要求,跑完之后数据对不对、会不会死锁,才是并发编程真正考验人的地方。
1.2 并发与并行:从CPU调度到GPU的warp
并发(Concurrency)和并行(Parallelism)的区别,很多人误以为是一回事。打个比方:一个人一边烧水一边刷手机,这是并发,因为他通过快速切换来“同时”推进多件事;两个人一个烧水一个刷手机,这才是并行。
在操作系统层面,即使是单核CPU,也能通过时间片轮转实现并发,但因为线程随时可能被抢占,所以并发安全问题在单核机器上照样存在。而并行依赖多核处理器,多个线程物理上同时执行。问题就出在这:并行放大了数据竞争的可能性,两个线程在同一时刻真的在读写同一个变量,连“碰巧错开”的侥幸都不存在了。
让我把话题稍微扩展到GPU编程领域,因为热词里提到“理解线程、线程块、网格和warp”。在CUDA这类模型里,线程的概念和CPU线程完全不同:GPU的基本执行单位是warp,通常包含32个线程,它们在同一时钟周期执行同一条指令(SIMT模式)。线程块(block)是warp的集合,网格(grid)是块的集合。这个体系里没有传统意义上的锁和互斥量——任务的并行度完全由硬件决定,编程模型强制你按“海量轻量线程 + 显式同步屏障”的方式来思考。理解这套层级,能帮你建立一种“线程粒度差异”的意识:同样是“线程”这个词,在操作系统、Java、Python、GPU里含义天差地别,讨论并发安全时先明确语境,否则很容易鸡同鸭讲。
1.3 为什么“线程安全”这么难:三大根本原因
一个变量被多线程访问后出错,背后无非三个原因:
第一是竞态条件。一条高级语言语句往往对应多条机器指令,比如count++在字节码层面是“读取、加一、写回”三步。线程A刚读完count的值,线程B就把它改掉了,A再写回时就把B的更新覆盖了。这就是典型的“读-改-写”竞争。
第二是可见性。现代CPU多级缓存结构下,每个核有自己的L1/L2缓存,一个线程改了变量,另一个线程可能长时间读到旧值。Java里经典的while(!flag)死循环,就是因为flag没有被volatile修饰,写线程的修改迟迟没有刷新到读线程核心里。
第三是指令重排。编译器和CPU为了优化性能,会打乱指令执行顺序。比如先给对象的地址赋值、再去初始化它的字段,这在单线程下没问题,但另一个线程拿到半初始化对象就翻车了。
所以“线程安全”不是加个锁就完事,而是要从“原子性、可见性、有序性”三个维度同时兜住。缺了任何一个,程序都可能在某个特定硬件、特定调度时机下才随机崩溃,这类Bug极难复现和排查。
2. 并发问题从哪来:典型场景与根源剖析
这一节我直接上实战案例。并发问题的表象千奇百怪,但骨子里就那么几类:竞态条件、死锁、线程互斥不当、跨线程操作共享资源(尤其是UI控件)。把这几类认熟了,排查问题的时候就能飞快锁定方向。
2.1 竞态条件与丢失更新:一个计数器引发的血案
先看最经典的例子:1000个线程同时对同一个共享变量执行count++,最后结果经常小于1000。原因前面说了,这个操作不是原子的。我见过有人把变量从int换成volatile后跑来问我:怎么还是不对?因为volatile只能解决可见性,解决不了“读改写”三步的原子性问题。这就好比多人共用一个记事本,volatile保证你写的字别人能看见,但没法保证你写字的时候别人不在本子上写。
正确的解法是用java.util.concurrent.atomic.AtomicInteger,它靠CAS(乐观锁)保证比较并交换的原子性。这也是“AtomicInteger线程安全吗”的标准答案:它对于incrementAndGet这类方法是线程安全的,任何线程调用它都不会产生丢失更新。但它并不代表“整个业务逻辑”安全——如果你先get再基于旧值做判断,再compareAndSet,那中间依然可能被人插一脚,那是复合操作,需要配合锁或updateAndGet这类方法搞定。
竞态条件的另一类高发场景是懒加载。比如单例对象的双重检查锁,DCL写法看起来逻辑严谨,如果不加volatile,还是会踩指令重排的坑,拿到一个“半初始化”的单例。这个案例我强烈建议每个想说自己懂并发的人亲手复现一遍,印象会深很多。
2.2 死锁:两个线程抱着锁互相等待
死锁是并发问题里最让人头皮发麻的一种。它发生在两个或以上线程各自持有一个锁,又在等待对方持有的锁,形成一个循环等待。典型的场景是银行转账:线程A要给账户X转账需要锁X和Y,线程B要给账户Y转账也需要锁X和Y,如果A锁了X等Y、B锁了Y等X,两边就僵死了。
教科书上把死锁总结为四个必要条件:互斥条件(资源只能被一个线程占用)、占有并等待(线程占有资源同时等待其他资源)、不可剥夺(资源不能被强行抢走)、循环等待(多个线程形成环形等待链)。四个条件同时满足才死锁,所以破局思路就是打破任意一个。最常见的实践是“全局顺序加锁”:所有线程都按照账户ID从小到大的顺序拿锁,就不会有循环等待了。
排查死锁的方法,我后面第5章会详细说。这里先提一个核心工具——线程Dump。Java里用jstack、jcmd,或者在出问题时用kill -3 PID把Dump打到标准输出。Dump文件里会有明确的Found one Java-level deadlock字样,并且能看出每个线程在等哪个锁。线上遇到死锁千万不用慌着重启机器,先把Dump捞下来再说。
2.3 线程互斥与“线程方程组”:把同步问题建模
热词里有个“线程方程组”,这个词不常见,但思路很好:把并发同步问题抽象成一组条件和约束,像解方程组一样去分析和验证。比如生产者消费者问题,本质上就是两个信号量(满/空)组成的方程组;哲学家就餐问题,本质是5个互斥锁和5个线程的环形依赖约束。实际工程里常见的“线程互斥”,狭义上是指同时只能有一个线程进入临界区,广义上也包括信号量控制同时N个线程访问。
这个数学化思维的大用处,在于解释“为什么我的锁顺序不会死锁”。你可以把所有的锁获取操作画成一张有向图,节点是锁,边是“持有A后请求B”。只要图里有环,就存在死锁可能;没有环,理论上是安全的。这套分析不依赖运气,靠逻辑推导。
实际编码中,“线程互斥”最常见的实现是synchronized和ReentrantLock。一个容易忽略的细节是,互斥只对同一个锁对象生效,synchronized在A对象上加锁,另外一个线程在B对象上加锁,它们之间等于没有互斥。我见过好几个人写synchronized (new Object()),每次都锁一个全新对象,等于裸奔。
2.4 跨线程操作UI:Android、C#、易语言的共同痛点
UI框架几乎都有一个铁律:UI控件只能在主线程(事件循环线程)中操作。因为控件内部状态不是线程安全的,两个线程同时改控件属性,轻则显示错乱,重则崩溃。热词里提到的几个场景,本质解法是相通的。
Android里最常见的是在Fragment中开线程做耗时操作,然后在线程里直接textView.setText(),这必然会触发CalledFromWrongThreadException。正确做法有好几种:一是用runOnUiThread把操作投递回主线程;二是用Handler;三是在Fragment里配合viewLifecycleOwner.lifecycleScope.launch,用协程切回主线程。注意在Fragment的异步任务里,回调时可能Fragment已经销毁,必须用viewLifecycleOwner或者判isAdded(),否则轻则泄漏,重则crash。
C#里对应的是Control.Invoke(同步)和Control.BeginInvoke(异步),它们本质是把委托投递到UI线程的消息循环里执行。WPF和WinForms都遵守这套机制。实际中我用BeginInvoke更多,因为它不会让后台线程阻塞等UI处理完。
易语言里“子线程怎么让主线程操作UI控件”这个问题也很经典。易语言没有Java和C#那么完善的UI线程调度框架,常见方案有二:一是通过窗口消息投递,比如自定义一个消息编号,子线程用PostMessage发给窗口,主线程在窗口事件里处理UI更新;二是利用易语言的“标签反馈事件”或“线程许可”来串行化访问。核心思路依然是“别在子线程里动控件,把操作传给主线程去做”。
这里我多写一句:跨线程UI操作的本质,是把并发问题转化为串行问题。主线程的循环天然是一个单消费者队列,你把更新UI的任务按序排队进去,就避免了所有竞争。这也是所有UI框架统一的底层逻辑。
3. 并发安全的防护手段:原子类、锁与线程池
现在进入“工具选型”环节。怎么选防护手段,取决于你的场景:简单的计数器用原子类,临界区很短用锁,任务量大用线程池,超大规模I/O并发用虚拟线程。选错工具,程序要么性能差要么内存爆,都是事故现场。
3.1 AtomicInteger到底线程安全吗:CAS的边界
直接回答:对于它提供的单一原子操作,是线程安全的。AtomicInteger基于CAS实现,CAS是CPU提供的compare-and-swap指令,在线程尝试更新时,如果发现内存值跟预期值不一样,说明有人改过了,就重试。整个过程无需加锁,所以它又被称为“无锁并发编程”。
但用AtomicInteger有几个边界要注意。第一是ABA问题:线程A读到值1,线程B改成2又改回1,A的CAS依然成功,但它可能错过了“值被动过”这个事实。可以用AtomicStampedReference加版本号解决。第二是“复合操作”问题,我先get()再做判断再compareAndSet(),中间就是有窗口的。第三,如果竞争非常激烈,CAS自旋次数暴增,性能反而下降,这时可以考虑LongAdder——它把热点打散到多个单元,适合统计类场景。AtomicInteger不是万能药,理解其内部机制才能在正确的地方用它。
3.2 锁的选型与锁粒度:synchronized、ReentrantLock、读写锁
Java的synchronized是JVM内置锁,使用方法最简单,只要在方法或代码块上加关键字就行。它的锁是自动释放的(退出代码块或异常都释放),也没有“忘记unlock”的烦恼。JDK 6以后synchronized有偏向锁、轻量级锁、重量级锁的升级路径,锁竞争不激烈时性能并不差。
与之相对,ReentrantLock是JDK提供的显式锁,需要手动lock()和unlock()(必须写在finally里),但它提供了更丰富的能力:定时锁(tryLock(timeout))可以在拿不到锁时放弃而不是无限等,避免死锁;可中断(lockInterruptibly);公平锁模式;持锁等待可以被Condition精细控制。我的经验是:默认优先用synchronized,简单安全;一旦涉及到“获取锁必须带超时”或“多个条件队列”这种复杂需求,再换ReentrantLock。
还有一类场景是读多写少,用读写锁ReentrantReadWriteLock能大幅提升并发度,因为多个读线程可以同时持有读锁。但要注意,读锁和写锁的升级(持有读锁再拿写锁)是不支持的,容易死锁。JDK 8以后还有更高效的StampedLock,支持乐观读,但API更复杂,业务代码里非必要不推荐。
最后说锁粒度。锁太大,比如锁住整个List,会有大量线程排队;锁太小,比如只保护单次赋值,业务上多个操作之间又可能有逻辑关联。这就是个权衡。一个常见优化是分段锁:ConcurrentHashMap默认把数据分成16段,每段独立的锁,只有操作到同一段时才竞争,大幅降低锁争用。
3.3 ThreadPoolExecutor:线程池配置与阻塞队列选择
现在聊聊内置线程池ThreadPoolExecutor。我见过太多团队直接用Executors.newFixedThreadPool()或者newCachedThreadPool(),然后线上内存溢出或者任务全部排队积压,就是因为不了解参数。线程池的核心参数有七个:核心线程数corePoolSize、最大线程数maximumPoolSize、空闲存活时间keepAliveTime、时间单位、阻塞队列workQueue、线程工厂threadFactory、拒绝策略handler。
它们的关系是第一层过滤器:提交一个新任务时,如果当前线程数小于核心线程数,直接创建新线程执行;超过核心线程数,任务进队列;队列满了,才开始扩充到最大线程数;再满,触发拒绝策略。注意这个顺序,很多人以为是“线程满了才进队列”,其实是“先队列后扩容”。理解了这点,配置线程池就心里有谱了。
这里的关键决策点是阻塞队列选型:
LinkedBlockingQueue无界队列:任务数无上限,队列无限堆积,核心线程数永远不扩容,最大线程数形同虚设。风险是内存耗尽。Executors.newFixedThreadPool就是用它,量一大就爆队列。ArrayBlockingQueue有界队列:这是我最推荐的,强制你思考“最多积压多少任务”,积压满了就触发拒绝策略,系统从“不可控的排队”变成“可感知的过载”。SynchronousQueue无容量的直接交接队列:提交的任务必须立即有线程接手,否则就阻塞,通常配CachedThreadPool那种“来一个建一个”的模式。PriorityBlockingQueue:按优先级出队,但要注意它本质上还是无界队列,只是换了排队规则。
拒绝策略也有讲究。AbortPolicy默认直接抛异常;CallerRunsPolicy把任务退回调用者执行,适合不想丢任务的场景;DiscardPolicy和DiscardOldestPolicy则是静默丢弃,一般不建议用。我实际项目里常用CallerRunsPolicy:线程池满的时候让提交任务的业务线程自己执行,降低吞吐但至少不打折扣。
还有一个看着小但排查时救命的关键:自定义ThreadFactory,给线程起名字。默认线程池的线程叫pool-1-thread-1,线上出问题查Dump时根本不知道是哪个业务线程。改成order-process-3这种名字,日志里看一眼线程名就知道是哪个模块,排查效率翻倍。用ThreadFactoryBuilder一行就能搞定。
3.4 虚拟线程与自由线程:新一代线程模型带来的变化
既然是聊“线程中的并发安全”,必须提到现在最热的两个新模型:Java虚拟线程(Virtual Threads)和Python 3.13的“自由线程”(free-threaded build)。
虚拟线程的原理是:把一个操作系统线程(平台线程)当作载体,虚拟线程是由JVM调度的用户态线程,创建和切换成本极低。Java里你有10万个阻塞IO任务,以前要开10万个OS线程,现在只需要少数几个载体线程,配合JVM在虚拟线程阻塞时自动切换,用一套“同步代码”就解决了高并发。它最适合的就是I/O密集型任务,比如RPC调用、数据库访问。但有两个坑:一是synchronized会让虚拟线程“钉住”在载体线程上,阻塞时无法让出,建议用ReentrantLock替代;二是不可能替代CPU密集型计算的并行,计算密集还是得靠平台线程。
Python的“自由线程”是CPython去掉全局解释锁的尝试。传统CPython因为GIL存在,多线程在CPU密集场景下无法真正并行,只能用于I/O等待。3.13的实验性free-threaded build移除了GIL,让多线程可以真正跑在多核上,但这会打破很多“依赖GIL保护”的库的线程安全假设,纯Python的数据结构操作可能需要显式加锁。如果你的项目大量使用C扩展库,在free-threaded模式下一定要做充分的兼容性测试。这里我想强调:GIL是一把双刃剑,它限制了Python的并行能力,也给了开发者“自带线程安全”的错觉,自由线程时代才是对Python并发编程基本功的真正考验。
4. 代码级实操:从线程名到线程编排的完整演练
理论讲得再多,不如亲手敲一遍。这一节我按“从单个线程到线程编排、再到线程生命周期管理”的顺序,把几个高频实操点逐个过一遍,代码都放进来了,可以直接照着跑。
4.1 获取当前线程名与线程中断的正确姿势
热词里的“java获取当前线程名”,是排查日志时的第一课。在任何代码里用Thread.currentThread().getName()就能拿到线程名字。但名字是好习惯的产物:如果你用默认线程池,日志里只会看到pool-1-thread-1,我建议在每一个线程池的ThreadFactory里设置业务前缀,比如"biz-order-"。
获取名字容易,线程中断才是真正的门道。Java里interrupt()不是“暴力终止线程”,它只是设置了一个中断标志位。如果目标线程正阻塞在sleep()、wait()、join()这类方法上,会抛出InterruptedException;如果线程在正常运行,中断标志位会被设置但线程不会自己停下来。
那么如何正确响应中断?有一条规范我建议所有团队写进代码规范:捕获InterruptedException后,要么立即恢复中断状态(Thread.currentThread().interrupt()),要么把异常向上抛,不要吃掉中断信号。因为中断是一种协作机制,调用方需要知道目标线程已经被请求停止,否则任务取消就失效了。我见过最恶劣的写法是catch后打印日志然后继续循环,导致线程池shutdown时死活关不掉。千万别用已废弃的Thread.stop(),它会在线程任意位置释放所有锁,留下灾难性的不完整状态。
4.2 等待所有线程完成:join、CountDownLatch与线程池关闭
“java线程等待都完成”是另一大类刚需。最简单原始的办法是thread.join():主线程调用A的join,就会阻塞到A结束。但它只能等单个线程;要等一批线程,可以循环join,也可以选择CountDownLatch。CountDownLatch的原理是计数器,每个工作线程在完成前调用countDown(),主线程调用await()阻塞直到计数归零。注意latch的计数器必须确保每个线程都执行到countDown,如果有个线程挂掉了不执行,主线程就会永远等下去,通常要给await(timeout)加超时保险。
在实际业务中,线程的载体往往是线程池而不是裸线程,那就要熟悉ExecutorService的生命周期管理:shutdown()是拒绝新任务但让已提交任务继续执行;shutdownNow()是尝试中断正在执行的任务并返回未执行的任务列表。只有两者都不能“等待完成”,要搭配awaitTermination(timeout, unit)——它会阻塞直到所有任务真正结束,或者到达超时时间。推荐的关闭写法是:先shutdown(),再awaitTermination(30, TimeUnit.SECONDS),没结束就shutdownNow()强杀。这套流程能最大限度避免服务下线时还有任务在跑,也能避免长时间挂起。
4.3 守护线程:setDaemon的正确用法
热词里有“java编写守护线程”,这是个容易起误会的话题。守护线程的特点是:JVM内所有非守护线程全部结束之后,JVM会直接退出,不管守护线程还活着没。最典型的守护线程是垃圾回收线程、心跳清理线程、监控统计线程。
设置方式是在start()之前调用thread.setDaemon(true)。烦人的是,很多人把wait或sleep循环写进守护线程,以为JVM会“优雅地”等它退出,其实JVM是直接终止,所有finally块都可能不执行。所以守护线程里不要安排“必须要落地的收尾工作”,比如写数据库、刷缓存、发送确认消息。我的经验是:临时辅助性的线程可以设为守护;如果它承载业务状态,那就不能是守护线程,否则服务退出时会静默丢数据。
4.4 Python线程嵌套线程与“纯Python线程安全”
Python的多线程因为GIL,长期处于一种很微妙的境地。热词里的“python线程嵌套线程”值得单独说:一个线程内部又去创建子线程,这种结构在业务上偶尔会出现,比如一个爬虫线程对每个URL再开一个下载线程。它的问题在于线程数量会指数级膨胀,而且父线程结束了,子线程还在跑,程序什么时候结束完全不可控。
我建议用threading.BoundedSemaphore或者ThreadPoolExecutor来限制并发度,而不是无限地嵌套创建raw thread。Raw thread创建本身是有成本的,到了几百上千个以后,切换开销和处理能力都会急剧下降。另外,thread.join()对嵌套的子线程同样适用,但如果忘记了join,主进程结束时可能直接丢弃还在运行的非守护线程(取决于解释器的退出机制)。
至于“纯Python线程安全”,说的是在自由线程模式下,纯Python对象的保护需要你自己来。传统CPython因为有GIL,很多“看起来原子的”操作实际上是安全的,比如列表的append不会崩,但新自由线程模型把这块遮羞布扯掉了。所以即使你用Python写脚本,也该养成“共享可变状态必加锁”的习惯,而不是指望解释器帮你兜底。
4.5 获取当前时间的线程安全细节
这个热词看着偏门,但实际翻车率极高。Java里,老牌的时间格式化类SimpleDateFormat是典型的非线程安全类,内部使用了共享的Calendar对象。两个线程同时format,会出现时间错乱甚至ArrayIndexOutOfBoundsException。我把这个坑归为“隐性并发问题”——没有明显的计数或共享集合,但内部状态已经炸了。
正确做法是使用Java 8的java.time包,LocalDateTime、DateTimeFormatter都是不可变且线程安全的,可以包成静态常量直接共享,没有任何隐患。如果还在维护老代码,至少也要给SimpleDateFormat加锁,或者用ThreadLocal包一层。
C#那边稍好,DateTime.Now本身是当前时间快照,取值操作底层足够安全;Python的datetime.now()同理。但有个更隐蔽的点是“时钟源”的选择:业务上如果要用时间戳计算耗时或做超时判断,优先用单调时钟(Java的System.nanoTime),它不受系统时间回拨影响;如果要记录时间点对外展示,才用系统时钟(System.currentTimeMillis)。时间线程安全不光是“类是否安全”,还包括“时钟语义是否一致”。
5. 常见问题排查与避坑指南
这一章把我在实际项目里踩过、帮别人排查过的典型问题集中整理一下。每一个都是真实的“翻车现场”,绝对是常规文档里不写的经验。我尽量按“问题现象、定位思路、解决步骤、预防方法”四条线来写清楚。
5.1 线程池配置翻车现场
我见过最多的线上事故,根源都是线程池参数配置不当。一种典型是核心线程数设置过大,比如32核机器配了64个核心线程,每个线程都带数据库连接或大内存缓冲,结果还没到来任务,线程就先把内存和连接池吃光了。另一种是队列选无界,比如newFixedThreadPool默认LinkedBlockingQueue无界,用户秒杀活动一来,几百万个请求全部排队,接口RT越来越高,但线程池一个扩容线程都没有,因为队列“还没满”。
实操建议:初始化线程池之前,先认真评估业务QPS和单个任务耗时,定一个核心线程数(经验公式:CPU密集用CPU核数+1,I/O密集可以用CPU核数 × 2 + 1起步再压测调优);队列一定要有界,让你能感知过载;对拒绝策略一定要有监控、告警和日志。至少写一个setRejectedExecutionHandler,在拒绝时打出任务内容,否则你可能完全不知道任务被丢了多少。
还要提一句,不要用Executors工具类的默认工厂方法创建线程池。newCachedThreadPool用SynchronousQueue,任务量一大就会创建无限多线程;newScheduledThreadPool默认无界队列。阿里规范里也明确禁止用这几个快捷方法,核心原因就是很难控制队列和线程数边界。自己写ThreadPoolExecutor就好,参数自己算,再配合ThreadFactory命名,既安全又好排查。
5.2 死锁的现场实录与jstack分析
有一次线上服务突然所有请求卡住,CPU不高、监控面板上线程数也不异常,但新请求全部超时。我第一反应是数据库连接池被占满,查了之后发现连接池还健康。最终靠jstack PID > dump.txt抓到了现场。Dump里两个线程都停在Locked ownable synchronizers和waiting to lock状态,日志明明白白写着:
- "ben-http-4" waiting to lock <0x000000074929a970> (a java.util.concurrent.locks.ReentrantLock)
- held by "ben-rpc-1"
- "ben-rpc-1" waiting to lock <0x0000000749b92890> (a java.util.concurrent.locks.ReentrantLock)
- held by "ben-http-4"
看到“waiting to lock”和“held by”互相咬合,死锁已经明牌了。修复方法说起来很简单:保证所有线程按固定顺序获取多个锁,不要交叉。实际操作中,最有效的措施是减少多个锁的嵌套获取——能用一个锁就别用两个;必须用两个,就按全局ID排序;能用tryLock(timeout)就加上超时,拿不到锁就回滚释放已持有的锁,而不是死等。
预防死锁还可以从设计层面下手:引入“线程转储定期采样”,在系统卡顿期间自动抓Dump并归档;或者在代码review时,对涉及多锁的路径专门画锁的有向依赖图,凡是成环的,必须重写。死锁是能通过设计消灭的,不要等到线上卡死才后悔。
5.3 容易被忽略的“隐性并发问题”
并发安全不只在你自己写的代码里,框架和工具类里全是大坑。我总结了三类最常见的隐性并发问题,列个表供自查:
| 问题类型 | 典型例子 | 隐患 | 解决方式 |
|---|---|---|---|
| 非线程安全工具类 | SimpleDateFormat、HashMap、ArrayList | 内部状态或多线程读写时数据错乱 | 用java.time不可变类、ConcurrentHashMap、CopyOnWriteArrayList,或加锁 |
| 静态可变状态 | 全局缓存Map、ID生成器、配置项 | 多线程同时写全局静态变量,偶发空指针或陈旧数据 | 加锁、用原子类、替换为线程安全容器 |
| 第三方SDK回调 | MQ消息回调线程、RPC响应线程 | 回调线程不是你的主线程,直接改UI或业务状态会炸 | 确认回调线程模型,把UI操作切回主线程,业务操作加锁或队列化 |
我印象最深的是有个项目里用了一个全局静态HashMap做“请求去重”,平时测试一点问题没有,一上生产流量一大,偶发读取到不完整数据。原因就是多个线程同时put时HashMap桶扩容期间出现竞态。后来换成ConcurrentHashMap,问题立刻消失。很多“玄学Bug”最后都能归到这类容器的非线程安全上。
另一个容易被忽略的点是日志库。很多日志框架在异步Appender模式下有自己的队列和缓冲,如果没配好丢弃策略或队列容量,高并发下日志本身就会成为瓶颈,甚至拖垮业务线程。生产环境一定要确认日志框架的基础参数,比如Logback的AsyncAppender队列大小和丢弃阈值。
5.4 线程安全的工程实践清单
最后我会用一张自查清单来总结“怎么写并发代码不容易出事”,这算是我多年实践沉淀的要点:
- 共享可变状态越少越好,优先使用不可变对象。Java的record、C#的record、Python的namedtuple都是好工具。
- 如果不需要共享,用
ThreadLocal隔离。比如每个线程独立的数据库连接、日期格式化器,绕开竞争本身就是最优解。 - 使用并发集合而非手工加锁的普通集合。ConcurrentHashMap、CopyOnWriteArrayList这些JDK原生实现经过充分验证,比你自己拿HashMap加锁靠谱得多。
- 原子操作交给
Atomic*类,复合操作交给锁,不要把两者混着乱用。 - 线程池统一有界队列+有名字+拒绝策略+监控。线程池不会自己告诉你它满了,你需要日志和告警。
- 关于锁,做到:范围最小化、持有时间最小化、获取顺序固定化、最好带超时。
- 凡是在异步回调中更新UI或共享状态,先回答“这行代码运行在哪个线程”再动手。
- 学会看线程Dump和堆栈,这是并发排障的基本功,任何监控工具都替代不了。
清单看起来简单,但每一条背后都是一次线上事故。我自己最大的教训是:永远不要用自己的直觉判断“这里应该没问题”,而是要从JMM规范和容器实现的角度去验证。
最后再说一个小技巧,也是我这些年养成的习惯:核心代码里,凡是涉及多线程共享的变量,一律在声明处加注释说明“由谁写、由谁读、通过什么机制保护”。有了这三行字,三个月后你自己回来维护代码,或者同事接手,都不会因为“看起来没锁”而在改代码时顺手把并发安全破坏掉。并发安全这个事,本质上是把多线程的不确定性,通过规范、工具和纪律,重新拉回到人的认知可控范围之内。