深入解析线程并发安全:从竞态条件到线程池实践
如果你长时间写多线程代码大概率撞过这类灵异事件一个计数器明明加了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 跨线程操作UIAndroid、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又改回1A的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核数1I/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-1ben-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规范和容器实现的角度去验证。最后再说一个小技巧也是我这些年养成的习惯核心代码里凡是涉及多线程共享的变量一律在声明处加注释说明“由谁写、由谁读、通过什么机制保护”。有了这三行字三个月后你自己回来维护代码或者同事接手都不会因为“看起来没锁”而在改代码时顺手把并发安全破坏掉。并发安全这个事本质上是把多线程的不确定性通过规范、工具和纪律重新拉回到人的认知可控范围之内。