尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Java并发常见问题复盘:可见性、锁、线程池与排查实战
并发编程难难在它不会按照我们写代码时的顺序来执行。我写Java这七八年里真正让我觉得“自己敢碰并发”的不是背了哪本面试题而是线上事故一个一个踩出来的。今天这篇所谓“Java 并发常见问题总结”我更愿意把它当成一份个人复盘记录把我在真实项目里反复撞上的那些坑——可见性问题、锁的误用、线程池参数拍脑袋、并发集合用错场景、多线程协作工具踩边界——挨个摊开讲清楚包括现象、原因、排查过程和最终修法。如果你也是那种“看了很多原理但还是不敢下手写并发代码”的人这篇文章应该能帮你省下不少弯路。1. 内存可见性那些“看起来没问题”的代码是怎么挂掉的1.1 一个布尔标志引发的线上事故先说一件我早年遇到的事。某个后台任务需要根据一个配置开关决定是否执行代码写得很“标准”public class ConfigHolder { private boolean enabled true; public void disable() { enabled false; } public boolean isEnabled() { return enabled; } }主线程在某个时刻调用了disable()而工作线程循环里不断检查isEnabled()。本地测试一切正常但上线后出现了诡异现象配置已经改成关闭任务却还在继续跑。当时第一反应是“配置中心没刷新”但日志显示disable()确实被执行了。后来用jstack看到工作线程一直停留在循环里才意识到问题出在内存可见性上。Java 内存模型里每个线程都有自己的工作内存线程读取变量时不一定是直接读主内存可能是读自己工作内存里的副本。enabled这个变量没有被volatile修饰工作线程就可能长期读到旧值。更麻烦的是JIT 在发现循环内读取的变量没有变化时还可能做激进优化把isEnabled()的结果直接内联成常量导致循环根本感知不到外部修改。1.2 理解 Java 内存模型主内存和工作内存的关系很多教程喜欢把 Java 内存模型说得很玄其实用一个类比就清楚想象一个办公室里有块白板主内存每个员工桌上还有自己的笔记本工作内存。正常情况下员工读数据应该看白板但为了减少去白板前的次数他可能先把某个数字抄在笔记本上后面一直看笔记本。别的员工改了白板上的数字这个员工如果不知道去重新抄一遍就会一直用旧数字工作。volatile干的事情就是强制每次读写都到白板上操作并且保证“改完白板之后其他员工重新抄”的时序不乱。它解决的是可见性和有序性但解决不了原子性。比如count这种读改写复合操作就算变量是volatile两个线程同时读到 5各自加 1 后写回 6最后结果还是 6 而不是 7。1.3 volatile、synchronized、Atomic 三者到底怎么选这是我面试别人时最喜欢问的问题也是自己踩过坑之后才真正想明白的。单写多读、只关心可见性的场景用volatile最轻量比如开关标志、状态位。多个线程都要读写、且操作是复合的光靠volatile不够要加锁或使用原子类。原子类AtomicInteger、AtomicLong等本质上用 CAS 实现适合争抢不激烈的计数器场景比如统计请求量。遇到“多个字段要一起保持一致”的情况比如账户的余额和流水要同时更新那无论如何都要加锁。原子类只能保证单个变量保证不了多个变量之间的业务一致性。那次事故修起来很简单把enabled加上volatile就恢复了。但这只是第一层教训后面我遇到更多线程安全问题几乎没有一个是靠“背结论”能避开的。2. 锁的选型与死锁现场从 synchronized 到显式 Lock2.1 synchronized 进化到今天其实已经很能打很多老教程还在强调 synchronized 性能差要尽量用 Lock。这个观点放在 JDK 5 时代没错但到了 JDK 6 之后synchronized 引入了偏向锁、轻量级锁、重量级锁的升级路径无竞争时开销极低有了竞争才逐步升级。在大多数场景下它跟 ReentrantLock 的性能差距已经很小了。我自己的选择原则是这样只有一个临界区、不需要超时控制、不需要公平锁优先用 synchronized代码简洁不易出错。需要尝试获取锁拿不到就干别的、需要等待超时、需要多个条件变量才考虑ReentrantLock。读多写少的场景可以用ReentrantReadWriteLock或StampedLock但要注意这类锁的适用边界后面我会提到。synchronized 的一个隐藏优点是可重入。一个线程已经持有着某个对象的锁再调用同一个对象锁保护的另一个方法不会把自己锁死。ReentrantLock 同样可重入但如果是自己用Lock接口实现一个不可重入锁那就要小心嵌套调用导致的“自己锁自己”问题。2.2 一个教科书式死锁在真实项目里的样子死锁的教科书定义是两个线程各自持有一把锁同时等待对方持有的锁。理论看着简单真在项目里遇到时代码往往绕得很。比如某个转账业务按照约定先锁fromAccount再锁toAccount但另一个接口可能先锁toAccount再锁fromAccount。两个线程同时执行时就会出现互相等待。这种崩盘特征是接口突然卡住请求堆积CPU 并不高但 RT 无限拉长。排查死锁有一个固定的操作顺序用jstack pid抓线程快照或者用jcmd pid Thread.print。搜索Found one Java-level deadlock关键字。看堆栈里每个线程正在等待的锁和已经持有的锁。找到锁顺序不一致的代码位置。我见过不少团队遇到死锁第一反应是重启这能临时解决但复发概率极高。正确做法是从堆栈里把锁顺序找出来统一全局加锁顺序。如果实在无法统一可以用tryLock带超时拿不到锁就回滚重试避免无限等待。2.3 显式 Lock 的坑忘记释放、锁中断、条件变量误用ReentrantLock 用起来比 synchronized 麻烦的地方在于需要手动释放锁。常规操作是下面这样Lock lock new ReentrantLock(); lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }很多人会写漏 finally导致某次异常后锁一直不释放其他线程全部卡死。这类 bug 在日志里表现为“某个线程卡了很久”但它的堆栈往往停在parkAndCheckInterrupt或者LockSupport.park上。还有一个容易被忽略的点lock.lockInterruptibly()和lock.lock()的区别。前者在等待锁时如果线程收到中断信号会抛出InterruptedException后者不会理会中断。某些框架的线程池在停机时会发送中断如果业务代码用的是lock()就可能出现“停机时线程还卡在等锁”的情况。手动解锁时还要注意千万不要把 unlock 放在 try 外面。我见过有人觉得“放在外面可以减少嵌套”结果业务代码抛异常后锁就永远释放不了了。想优雅一点就用try-with-resources风格的封装类Java 8 之后很多项目会自己封装一个AutoCloseable的锁工具类。3. 线程池参数不是拍脑袋一次接口变慢的排查记录3.1 那次事故的背景和现象某天我负责的一个内部系统突然开始大面积超时接口 RT 从 50ms 涨到 5 秒。先看监控系统内存、CPU、数据库都正常唯一异常的是线程池被占满了。接着查代码发现这个线程池是这样初始化的ExecutorService executor Executors.newFixedThreadPool(4);4 个线程的业务线程池配合一个无界阻塞队列。平时请求量不大没问题那天业务方做了一次批量数据导入一次性丢了大量任务进来4 个线程处理不完队列里积压了几万个任务。新请求只能排队积压越来越多接口自然变慢。这个案例暴露了两个典型问题Executors 的静态工厂方法太方便容易让人忽略内部队列行为。newFixedThreadPool默认用无界LinkedBlockingQueue任务永远进得了队列但线程数又固定积压到内存耗尽也只是时间问题。线程池参数没有根据业务估算。3.2 核心线程数和队列长度到底怎么估我后来给自己定了一套比较稳的估算方法CPU 密集型任务核心线程数可以取CPU 核数 1再根据任务执行时间微调。IO 密集型任务核心线程数通常设成CPU 核数 × 2起步如果 IO 等待占比很高可以再调大。网络调用、数据库查询、文件读写都算 IO。队列长度要结合“最大线程数”和“任务积压容忍度”一起设计。常见做法是设一个有界队列比如 1000再配一个拒绝策略。队列打满说明系统扛不住应该触发拒绝而不是无限堆积。具体的数没有标准答案但我强烈建议做一次压测。用 100、200、500 并发分别打一轮看吞吐量和 RT 的拐点线程池参数按拐点附近取远比拍脑袋靠谱。3.3 线程池里的第三个隐藏坑ThreadLocal 泄漏线程池还有一个特别容易踩的坑是ThreadLocal 被线程复用。ThreadLocal 的语义是“每个线程保存一份自己的变量副本”。可线程池里的线程是会重复使用的任务 A 往 ThreadLocal 里塞了一个值任务 B 开始执行时如果不清理B 就会读到 A 留下的脏数据。这个坑在用了线程池的系统里非常容易出现而且特别难查因为本地测试时线程是新建的不一定复用到同一个线程。建议做法在任务执行完的 finally 块里调用threadLocal.remove()。编码约定同一个 ThreadLocal 变量只能在一个明确的生命周期内使用跨任务传递信息要显式传参。如果用 Spring 的ThreadPoolTaskExecutor可以考虑设置TaskDecorator在任务开始前清理或备份 ThreadLocal结束后恢复。3.4 拒绝策略选错导致的静默丢失ThreadPoolExecutor提供了四种拒绝策略我实际开发里真正该用的只有两种CallerRunsPolicy队列满了后任务不丢弃由提交任务的线程自己去执行。好处是不会丢任务坏处是提交线程会被拖慢相当于一个天然的背压机制。自定义拒绝策略打日志、告警、写入本地缓冲队列给运维一个补救机会。最危险的是默认的AbortPolicy——直接抛异常。如果提交任务的代码没捕获任务就静默丢了。线上一次偶发丢单很有可能就是拒绝策略没配好任务抛出RejectedExecutionException后没人处理。我之前在某个项目里就犯过这个错以为默认策略“会提醒”结果上层调用方很早就把异常吞了导致一批导入任务神秘消失最后翻日志才发现是线程池拒绝。从那之后我的标准配置变成了“有界队列 自定义拒绝策略打印 warn 日志 写入待重试表”。4. 并发集合ConcurrentHashMap 用不对照样翻车4.1 为什么不直接给 HashMap 加锁很多人刚学并发时会想HashMap 线程不安全那我在每个操作外面加 synchronized 不就行了技术上可行但并发粒度太大读和写全串行化性能会很差。这才有了 JUC 包里的并发集合。ConcurrentHashMap在 JDK 8 之后的设计大致是数组 链表/红黑树 CAS synchronized 锁桶头节点。读操作大多无锁写操作只锁住对应的桶bin不同桶的写入可以并行。这也是它比Hashtable全局锁高效的原因。4.2 “我先检查再操作”的复合动作ConcurrentHashMap单个方法如get、put、putIfAbsent都是线程安全的但多个方法组合在一起就不是了。最典型的错误写法if (!map.containsKey(key)) { map.put(key, value); }两个线程可能同时通过 containsKey 的检查然后都执行 put结果与预期不符。正确姿势是用compute或putIfAbsent把判断和插入合成一个原子操作map.compute(key, (k, v) - v null ? value : v);4.3 size() 和 迭代器的语义别当实时数据用ConcurrentHashMap 的size()返回的是一个近似值不是严格准确值。它内部会先不加锁地统计各个桶如果中间发生并发修改会通过多次尝试得到一个相对稳定的估算值。多数业务场景下这个精度够用但如果你的逻辑是“根据 size 判断要不要继续往里面放”那可能出错。迭代器也有同样的语义弱一致不保证迭代期间一定反映最新修改。好处是迭代过程中不会抛ConcurrentModificationException坏处是如果你一边遍历一边修改你看到的数据可能是前几秒的快照。这对于统计类任务是可以接受的但对于那种“遍历一遍然后算总额来做决策”的场景最好在遍历前用锁或者拷贝快照。4.4 CopyOnWriteArrayList 的适用边界CopyOnWriteArrayList读多写少的场景确实好用写的时候复制整个数组读的时候无锁。但它的写开销极高每次 add 都是数组复制元素多了之后成本惊人。我见过有人用它当普通 List 用几千个元素、每秒写几十次写线程 CPU 直接飙到顶。正确用法是读频率远高于写频率且元素数量不大。典型如配置项列表、白名单、黑白名单这类低频更新高频读取的数据。如果写频率高老老实实加锁或改用ConcurrentLinkedQueue之类的结构。5. 多线程协作工具里最常见的三个错误5.1 CountDownLatch 是一次性的别重复用CountDownLatch设计出来就是“一次性门闩”计数归零后就失效了不能重置。我曾在一个周期任务里用同一个 latch 做“等待三个子任务完成”的逻辑第一轮正常第二轮发现主线程直接冲过去了因为 latch 的计数已经归零。处理方式有两种每次循环 new 一个新的 latch或者用CyclicBarrier它自带重置能力更适合“多线程互相等待、到了就一起开跑”的重复性场景。简单区分让一个线程等待其他线程完成用 CountDownLatch让一组线程互相等待然后同时执行用 CyclicBarrier。5.2 Semaphore 许可忘了释放连接池直接枯竭Semaphore 常用来做限流比如限制某个接口同时只有 10 个并发。它的 acquire 和 release 必须成对出现千万注意在 finally 里释放semaphore.acquire(); try { // 业务逻辑 } finally { semaphore.release(); }有一次系统突然出现“所有请求都被挡在 semaphore.acquire() 前面”CPU 和内存都正常数据库连接数却一直飙升。查下去才发现某个异常分支里抛了异常没执行到 release许可从 10 慢慢耗到 0后面所有请求都在等待。恢复很简单——重启进程就好了——但根因是不规范的使用方式后来把 release 挪到 finally 里之后这个故障再没出现过。5.3 CompletableFuture 的异常处理比想象中要小心CompletableFuture 用起来很爽但它的异常处理有几个容易炸的细节如果异步任务里抛了异常而你没有在链上调用exceptionally、handle或whenComplete异常会被吞掉最终结果是 future 处于异常完成状态但没人知道。thenApply里抛异常会沿链传导如果链很长要确保每个环节都有异常兜底。join()会在异常完成时包装成CompletionException很多人只捕获了普通 Exception导致真正的错误被包了一层后漏出去。我自己的习惯是异步链路统一用一个公共的异常处理CompletableFuture.supplyAsync(() - doSomething(), executor) .thenApply(result - parse(result)) .exceptionally(ex - { log.error(async chain failed, ex); return defaultValue; });这样至少保证异常不会静默丢失也方便定位是哪个环节出的问题。6. 并发 Bug 排查思路别只看代码要抓现场证据6.1 高 CPU 占用时的第一反应抓线程栈线上并发 bug 往往不像单线程问题那么好复现。我的建议是不要一上来就阅读代码找逻辑漏洞先抓现场证据。CPU 飙高时按这个顺序走top -Hp pid找到 CPU 最高的线程 ID记下来。把线程 ID 转成十六进制printf %x\n tid。jstack pid抓线程快照搜索对应的 nid。看堆栈里循环、锁等待、GC 等线索。这套流程能快速定位“哪个线程在忙”然后才轮到“代码为什么忙”。有一次我排查一个 CPU 90% 的问题顺利找到了一个被while(true)包着的空转方法——业务代码在等一个永不成立的标志位纯空转靠这句话一眼定位到问题。6.2 死锁与线程池阻塞jstack 的关键字线程阻塞场景下jstack 是最直接的铁证。找java.lang.Thread.State: BLOCKED看看它在等哪个锁。找Found one Java-level deadlock确认是不是死锁。找WAITING (parking)看是不是等在某个 future 或锁上。有一次某个项目凌晨定时任务没跑完我抓了线程栈看到一堆线程都停在同一个对象的wait方法上顺着栈找到代码发现有人在临界区里调用了Thread.sleep把锁hold住的时间拉长后面所有线程都在排队。修法是把临界区缩小长时间操作挪到锁外面。6.3 从“现象”反推“内存模型问题”的鉴别方法有些并发问题靠线程栈看不出来比如“数据偶尔不对”这类问题要看复现的规律如果问题只在多线程同时跑时才出现优先怀疑原子性和可见性。如果问题在不同机器上表现不一样可能在 JIT 优化和内存模型层面。如果问题在加了日志后消失很可能是日志改变了时序掩盖了竞态条件。早年有个印象深刻的案例统计数据的计数偶尔少了代码逻辑无论怎么看都合理后来发现是i并发执行丢了更新。改成AtomicInteger之后数据就稳了说明问题确实出在原子性。这类问题排查时有一个有用的辅助手段故意提高并发压力比如把线程数从 10 加到 100看看问题出现的概率是否上升。竞态条件通常在高并发下更容易暴露但也要注意压力过高可能引发另一个排队瓶颈这时候要综合判断。实践心得并发编程有限度地用务实地查我自己这几年最大的收获不是掌握了多少并发工具而是学会了克制。能用单线程解决的任务绝不为“显得高级”引入并发能用简单加锁解决的地方不上花哨的无锁结构线程池参数必定有依据绝不拍脑袋。遇到并发问题第一反应不是重写代码而是先保住现场抓线程栈、看日志、查监控。大多数并发 bug 的根因并不复杂难的是在混乱中保持冷静用证据链把问题钉死。如果你看完这篇总结能记住三件事就够了可见性靠 volatile 或锁原子性靠锁或原子类排查靠线程快照和现场证据。剩下的就交给一次次真实项目的打磨吧。
RELATED

相关推荐

SSM框架社区信息管理系统毕设全流程实战指南

SSM框架社区信息管理系统毕设全流程实战指南

每年毕业设计季,SSM框架的“社区信息管理系统”都是出镜率极高的一个题目。很多人觉得它“太土”“太基础”,但换个角度看,这个题目恰恰是最适合用来稳过毕设的那一类:技术栈经典、需求明确、工作量可控,论文又能围绕完…

📅 2026/10/13 19:23:01
Codex插件实战:12个提升开发效率的配置方案与避坑指南

Codex插件实战:12个提升开发效率的配置方案与避坑指南

最近一直在折腾Codex的插件机制,前后试了三十多个插件,最后沉淀下来十二个真正每天都在用的。这篇文章直接把这十二个插件按使用场景拆开讲,包括每个插件解决什么问题、TOML配置怎么写、踩过哪些坑,全部是基于真实使用经验的总结。…

📅 2026/10/13 19:23:01
LS信道估计在大规模MIMO中的Matlab实现与参数影响分析

LS信道估计在大规模MIMO中的Matlab实现与参数影响分析

简介:面向大规模多输入多输出(MIMO)系统的最小二乘(LS)信道估计算法MATLAB实现资源,适合通信工程专业学生、研究人员及算法工程师用于学习与实验验证。包内包含完整的发射端处理、信道生成与加噪、最小二乘…

📅 2026/10/13 19:17:57
MORE NEWS

更多资讯

📰

Windows 连接 SAP HANA 最小驱动方案:ODBC 链路配置与排错指南

简介:这是面向SAP HANA数据库的64位Windows客户端安装包,内含ODBC驱动,适合需要在Windows平台上连接SAP HANA的开发、运维及数据分析人员,用于解决驱动安装、数据源配置和多语言接口连接等常见问题。整个压缩包共六十四个文件&…

📰

LiteLLM统一模型网关实战:解决多模型接入与治理难题

多数接触大模型应用开发的团队,迟早都会遇到同一个坎:模型服务商不止一家,调用格式五花八门,切换一个模型几乎要重写一遍接入层代码。我自己在做一个多模型模拟项目X的时候就被这个问题卡过很久,后来用litellm把整个调…

📰

《数据库原理与应用(第四版)》实操指南:从SQL验证到生产调优

简介:本资源是《数据库原理与应用(第四版)》核心知识点精要整理PDF,面向计算机专业本科生、数据库初学者及备考人员,系统解决数据库基础概念理解难、知识体系零散、应试要点不清晰等痛点。文件共1个PDF,大小…

📰

Litellm实战:用开源API网关统一大模型接入与治理

1. 先说说我为什么盯上了litellm:多模型接入的混乱必须治过去两个月我们团队手上有三条AI业务线在并行推进,每一条连接的模型来源都不一样。第一条业务线用的是某头部云厂商的闭源模型接口,第二条业务线接了一个开源模型的私有化部署&#xf…

📰

用LiteLLM构建统一LLM API网关:模型路由与成本追踪实战

1. 为什么我在项目里选择了litellm 先聊个实际场景。做过AI应用开发的兄弟几乎都经历过这个阶段:最开始接OpenAI的API,跑通了,效果不错;过两天想对比一下其他家模型的效果,发现要改一堆代码;再往后&#xf…

📰

达梦数据库TPCC基准测试:BenchmarkSQL 5.0改版实战指南

简介:这是一份面向数据库管理员、测试工程师与IT选型人员的BenchmarkSQL达梦适配版工具包,用于对达梦数据库开展标准化基准测试,评估其在高并发事务场景下的TPS、响应时间等性能指标,并可与Oracle、MySQL、PostgreSQL等主流数据库…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬