Java多线程与并发编程:从JMM到线程池的实战指南 Java多线程与并发这个话题在面试和实战里被翻来覆去地问、反反复复地踩。很多人背了一堆八股文从Thread到ThreadPoolExecutor从synchronized到Lock看似什么都懂真到了线上排查问题、设计一个高并发接口的时候脑子里的知识点全变成了浆糊。我也带过不少新人看过太多“面试造火箭、入职拧螺丝”的尴尬落差。这篇东西不打算给你念经式地堆概念而是把Java多线程和并发编程这条线从头捋一遍搞清楚每个核心机制到底为了解决什么问题、底层是怎么转的、实际项目里怎么用才不会出事。无论你是准备Java基础面试的应届生还是被高并发IM、ERP库存这类场景折磨的业务开发这篇文章都能给你一套可以落地的思考框架。1. 并发问题的根源不只是“线程安全”四个字1.1 为什么多线程这么难搞先想一个问题单线程程序为什么简单因为代码从上往下执行每一步的结果都是确定的你知道变量在这个时刻的值是多少。多线程一掺和进来顺序就乱了两个线程同时读写一个变量最后的结果谁也说不准。这个“说不准”不是玄学而是由计算机硬件的三个底层事实决定的CPU缓存不一致每个CPU核心或每个线程上下文有一份高速缓存。线程A改了变量的值可能还留在自己的Cache里线程B在另一个核心上读到的还是旧值。指令重排序编译器和CPU为了优化执行效率会把没有数据依赖的指令重新排序。单线程下不影响结果多线程下可能就乱了套。线程切换的随机性操作系统随时可能把CPU时间片从线程A切走线程A执行到一半的状态对线程B来说可能是个“半成品”。这三件事组成了并发Bug的三大来源原子性操作没执行完就被切走、可见性一个线程的修改另一个线程看不到和有序性指令被重排了。我在前面加粗了这三个词是因为它们几乎贯穿了Java并发编程的所有知识点。理解了它们你再看synchronized、volatile、Lock、CAS这些概念就会有一种豁然开朗的感觉原来都是在解决这三个问题中的一个或几个。1.2 Java内存模型JMM到底在讲什么Java为了解决“上面三个底层事实导致的混乱”专门定义了一套自己的规则叫Java内存模型。不需要背晦涩的定义你只需要知道JMM干了两件事第一规定了主内存和工作内存的抽象模型。所有变量存在主内存每个线程有自己的工作内存线程操作变量必须先拷贝到工作内存再写回主内存。这跟CPU的Cache模型是对应的。第二提供了happens-before规则。这是一组“人情规定”只要符合这些规则前面的操作结果对后面的操作就是可见的。比如同一个锁的解锁happens-before后续的加锁写volatile变量happens-before后续读这个变量线程的start()happens-before线程内所有操作线程内按代码顺序前面的happens-before后面的。注意有了happens-before不是说物理上就一定按这个顺序执行而是说在语义上保证了可见性和有序性。这是理解内存屏障Memory Barrier的起点——虚拟机在指令层面插入屏障禁止特定类型的重排序。为什么我要花这么多篇幅讲JMM因为太多人面试的时候被问“什么是JMM”就只会背“主内存、工作内存、原子性、可见性、有序性”一问“为什么需要它”就卡住了。你得明白JMM不是语法糖它是Java跨平台并发语义的基石也是我们排查并发问题时的“法典”。2. 核心同步机制深挖synchronized、volatile、CAS2.1 synchronized从重量级到轻量级JVM做了什么synchronized可能是你用得最多的关键字但它的实现原理远比关键字本身复杂。在JDK 1.6之前它被称为“重量级锁”因为底层依赖操作系统的Mutex互斥量线程阻塞和唤醒都要陷入内核态切换性能很差。后来HotSpot虚拟机引入了锁升级机制让synchronized在大多数场景下都是轻量级的。锁升级路径是无锁 → 偏向锁 → 轻量级锁 → 重量级锁而且这个状态只能升级不能降级。偏向锁同一个线程反复进入同步块没有其他线程竞争时锁会偏向这个线程记录线程ID之后进入不需要CAS操作。轻量级锁一旦发生竞争偏向锁撤销升级为轻量级锁。线程通过CAS自旋去获取锁如果没有抢到就自旋等待避免立即进入内核态阻塞。重量级锁自旋超过阈值或者自旋线程数过多锁就膨胀为重量级锁进入内核态的同步队列。这个升级设计的核心思想是绝大多数同步块根本不存在多线程竞争或者竞争时间极短没必要一开始就动用最昂贵的方案。实际编码中的经验是synchronized加在实例方法上锁的是this对象加在静态方法上锁的是Class对象。锁错对象是最常见的坑。不要在同步块里做耗时操作IO、远程调用、大计算否则锁的持有时间太长并发性能直接崩掉。锁粒度能小就小。2.2 volatile只解决可见性不说原子性volatile这个关键字经常被误解。很多人以为它能让变量变成“线程安全”其实它只做了两件事保证对该变量的读写在主内存层面是可见的、禁止指令重排序。它不保证原子性。经典的例子是volatile int count两个线程同时执行count最终结果依然可能小于20000。因为count底层分三步读值、加一、写回volatile只保证了读和写的可见性但没有机制阻止“读-改-写”三步之间被线程切换。那volatile适合什么场景答案是一写多读的状态标志比如volatile boolean shutdown false; // 线程A void close() { shutdown true; } // 线程B while (!shutdown) { // 处理任务 }这种场景下写操作没有依赖之前的读值不存在复合操作volatile就非常合适。还有一个必须提的点volatile可以作为轻量级的“内存屏障”比如Double-Checked Locking单例模式instance声明为volatile就是为了防止“分配内存”、“初始化对象”、“赋值引用”这三级被重排序导致另一个线程拿到一个还没初始化完成的半成品对象。2.3 CAS无锁并发的地基CASCompare And Swap是并发编程里一块绕不开的地基它让“无锁”成为可能。CAS的操作逻辑是比较内存当前值是否等于预期值如果相等就更新为新值否则什么都不做。这是一个CPU原子指令AtomicInteger、ConcurrentHashMap、ReentrantLock、AQS全都是建立在这个基础之上的。但CAS有个著名的ABA问题线程A读到值1线程B把它改成2又改回1线程A再CAS时发现还是1于是认为“没人动过”实际上这个值已经绕了一圈。在大多数业务场景下ABA无害但如果你要“指针复用”这类精确场景就得用AtomicStampedReference或AtomicMarkableReference额外加个版本号。CAS另一个隐患是自旋带来的CPU开销。高争用场景下大量线程都在do-while循环里空转CPU飙升反而比阻塞式同步更糟糕。所以我自己在实际项目中通常会先压测看争用程度高并发且短临界区的场景选CAS临界区长或冲突率高的场景老老实实用锁。3. 线程池把线程管理交给框架3.1 手写线程池的教训刚工作那会儿我也干过“每来一个请求就new Thread(...)去处理”这种事。代码确实能跑但很快就发现不对劲。一个是线程创建销毁的开销new一个Thread涉及系统调用和内存分配在高频场景下这开销非常可观另一个是资源失控风险并发请求一多线程数无上限地涨轻则GC压力大重则OOM把整个应用拖垮。后来老老实实用线程池才算把线程管理这块给理顺了。线程池的核心思想是线程复用 控制资源水位。先创建一批常驻线程有任务就分发给他们执行任务多就排队等线程不够用就适当增补闲下来再回收。这样一来线程数量可控任务吞吐稳定这是任何高并发应用的“保命底裤”。3.2 ThreadPoolExecutor参数与任务执行顺序Java里的ThreadPoolExecutor是线程池的完整实现参数有七个corePoolSize核心线程数线程池中常驻的线程数即使空闲也不会被回收除非设置了allowCoreThreadTimeOut。maximumPoolSize最大线程数线程上限。keepAliveTime非核心线程空闲存活时间。unit时间单位。workQueue任务等待队列。threadFactory创建线程的工厂可以自定义线程名强烈建议自己设置否则排查问题时看到pool-1-thread-2这种名字完全无头绪。handler饱和策略任务满了怎么办。很多人被这道经典面试题问住“如果核心线程数为2最大线程数为4队列容量为3提交5个任务会怎样”关键在于记住任务的执行顺序核心线程还没满时会创建新线程执行任务。核心线程满了任务进入等待队列。队列满了且线程数还没到最大创建新线程执行任务。线程数和队列都满了执行拒绝策略。也就是说线程池不是“先开满最大线程数再排队的”而是先让核心线程干活再排队排不下了才继续加非核心线程。这一点在京东、阿里系的二面里出现频率极高很容易踩坑。3.3 线程池参数怎么定参数设计没有银弹但我提供一个经过大量项目检验的计算思路首先分析任务类型CPU密集型任务线程数约为CPU核心数 1考虑线程切换的成本或者说核心数 * 2 以内的区间去压测确定。IO密集型任务线程数可以放大常见公式是核心数 * (1 IO等待时间 / CPU计算时间)我曾在一个网关服务里按这个公式把线程数从16拉到64吞吐直接翻倍。这只是初始值最终一定要用压测去验证。我一直强调线程池参数是调出来的不是算出来的。我常用的做法是先按公式估算一个初值然后用JMeter做并发压测观察CPU使用率、线程等待时间、队列积压情况再微调参数。上线后还要利用监控指标持续观察。另外建议线程池用独立的ThreadFactory和统一的拒绝策略比如丢弃任务前记录日志而不是裸奔的AbortPolicy直接抛异常让调用方懵掉。4. 并发容器与锁工具AQS家族的秘密4.1 ConcurrentHashMap凭什么能扛高并发HashMap在多线程下会出各种幺蛾子扩容时可能出现循环链表JDK 7及之前CPU直接爆掉JDK 8改成尾插法解决了循环链表问题但依然会有数据覆盖和modCount不一致的坑。所以并发场景必须用ConcurrentHashMap。JDK 8后的ConcurrentHashMap数据结构是数组 链表 红黑树并发控制粒度细化到每个桶Node数组的每个槽位利用CAS synchronized完成并发安全插入时如果对应的桶位为空用CAS直接存入新Node无锁如果桶位不为空用synchronized锁住这个桶的头节点再进行插入锁粒度很小当链表长度超过阈值8链表转红黑树容量达到64时才会转否则先扩容数组。这个设计的巧妙之处在于读操作基本无锁Node的value被volatile修饰写操作只在对应的桶上加锁。不同线程操作不同桶时完全不会互相阻塞。因此它在高并发读多写少场景下表现非常亮眼。4.2 AQS是锁工具的统一骨架ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock这些工具的底层全是AQSAbstractQueuedSynchronizer。AQS的核心是一个volatile int state变量 一个双向等待队列以ReentrantLock为例state0表示锁空闲state0表示被线程持有值表示重入次数。线程抢锁就是通过CAS把state从0改成1抢不到就进入等待队列挂起。以Semaphore为例state表示剩余许可证数量每次acquire则CAS减一release则加一。同样是AQS不同的state语义。理解了AQS等于理解了整个Java锁工具家族的设计脉络。它的哲学是把线程的排队、阻塞、唤醒这套通用机制从具体业务里抽象出来让不同的同步器只需关心state的语义变化。对于日常开发我不建议你直接依赖Lock的底层实现去手写同步逻辑但一定要做到“看得懂、选得对”。比如需要公平排队用ReentrantLock(true)读多写少的缓存场景用ReentrantReadWriteLock但写锁饥饿要注意StampedLock的乐观读是更好的选择多个线程同时到达再一起执行用CountDownLatch多线程协作分批处理用CyclicBarrier。4.3 ThreadLocal线程隔离的“私有空间”ThreadLocal在高并发场景里也是高频选手尤其是解决SimpleDateFormat这类非线程安全工具的并发问题或者在线程池里传递一些非上下文信息。它的机制是每个Thread内部维护一个ThreadLocalMap以ThreadLocal实例为key存储各自的副本值。线程之间互相看不见。但ThreadLocal有一个必须警惕的坑内存泄漏。ThreadLocalMap的key是弱引用WeakReference而value是强引用。如果ThreadLocal实例不再被外部持有key会被GC回收变成null但value依然被线程的Map持有无法被回收。一旦线程池里的线程存活时间很长就会产生大量无法回收的value对象造成内存泄漏。标准做法是用完必须remove()。尤其是在线程池场景下线程复用如果不清理上一个任务的ThreadLocal值下一个任务可能会读到“脏数据”。我在项目里定的规矩是谁set谁负责在finally里remove绝不留尾巴。5. 从理论到项目实战高并发IM与库存超卖的场景剖析5.1 高并发IM场景多线程如何组织热词里有“高并发IM”这个场景非常适合用来串知识点。IM系统的核心特点是连接数极高且长连接多比如WebSocket消息写入频繁但每一条消息的数据量不大消息的实时性要求高不能因为锁竞争导致明显延迟。这种场景下多线程的组织方式首先要解决连接和线程的映射问题。通常不是“一个连接一个线程”那样线程数会爆炸而是采用Netty这种Reactor模型少数IO线程一般等于CPU核心数负责处理网络事件业务处理丢给线程池异步执行。这正是“高并发”和“多线程”的经典分层IO线程要轻业务线程要控数。在线程池选择上IM这种IO密集场景线程池的核心线程数可以设得偏高公式参考上文但队列要设置上限不能无限积压。因为消息是有时效性的积压超过几百毫秒用户就感知到卡了。我见过一个IM服务线程池队列用的是无界LinkedBlockingQueue结果高峰期内存直接飙到把堆塞满这是妥妥的“技术债”。另外IM里的“已读”和“在线状态”天然适合用ConcurrentHashMap做节点本地缓存配合分布式缓存做最终一致。每个连接的状态是写多读多的热点数据本地缓存用并发容器能挡住大部分读压力。5.2 ERP库存场景超卖问题怎么解另一个热词是“ERP库存场景高并发解决方案”这个场景是并发业务的经典考题。假设只有一个商品库存只有10件同时来了20个下单请求怎么保证不超卖先说最笨的方案数据库行锁。下单时执行UPDATE stock SET count count - 1 WHERE sku_id ? AND count 0利用数据库的行锁保证原子性。这个方案简单可靠但在极高并发下会把数据库拖垮——毕竟数据库的连接数是瓶颈。常用的优化分层如下本地内存预扣每个服务节点维护一份热点库存的本地缓存先在内存里做CAS式扣减用AtomicLong或LongAdder扣减成功才继续走后端流程。这一步能挡掉绝大部分无效流量。Redis原子操作对于分布式环境用Redis的DECR或Lua脚本保证扣减的原子性相当于把库存扣减操作前置到一个高性能的原子服务上。数据库兜底最终落库时仍然用条件更新count 0来兜底防止Redis万一出现超卖。在整个链路里Java多线程的核心作用在于第一层用并发安全的计数器和锁把“扣减库存”这个过程做成原子操作。很多人问为什么AtomicLong能扛住高并发因为它内部就是CAS自旋没有线程阻塞所以在高并发短操作场景下它比synchronized要快得多。但要注意高冲突场景下CAS自旋会浪费CPU这时可以考虑LongAdder它通过分段累加的方式减少冲突吞吐量更高——不过它返回的只是近似值热点记录场景足够强一致的数据统计就不要用它。5.3 压测是验证并发设计的唯一标准不管理论设计得多漂亮上不上得了台面必须跑一轮压测再说。热词里挂着“jmeter怎么进行接口并发测试”、“数据库并发锁”说明大家都想用工具验证自己的设计。JMeter操作本身不复杂创建线程组设置线程数和循环次数模拟并发用户添加HTTP请求取样器配置接口地址和参数添加聚合报告观察吞吐量TPS、平均响应时间、错误率。但压测的关键不在于点按钮而在于如何设计压测场景线程数从50、100、200、500逐级往上找到“拐点”即吞吐量不再增长甚至下降的那个点同时监控服务端的CPU、内存、GC日志、线程池活跃线程数。如果CPU没满但TPS上不去说明瓶颈在锁竞争或IO等待如果GC频繁但内存不断增长大概率有对象泄漏或线程池内存膨胀问题。压测结果出来后我习惯第一时间看tp9999%请求的响应时间平均值会掩盖长尾问题。tp99一旦出现尖刺多半就是锁竞争加剧或者线程池排队严重。6. Java多线程面试高频点与套路拆解6.1 必须张嘴就来的核心八股结合热词里的“java面试八股文”、“多线程面试题”我整理了一份自己面试别人时最常问、也最喜欢听你讲出层次感的清单创建线程的几种方式Thread、Runnable、Callable、ExecutorService。注意不要再回答“FutureTask也是一种创建方式”——它本质是包装了Callable的一个任务类不是新的线程创建方式。面试官问这个题是想听你区分“线程”和“任务”的概念。线程的生命周期新建、就绪、运行、阻塞、等待、超时等待、终止。重点区分sleep()和wait()sleep不释放锁wait释放锁并进入等待队列sleep是Thread静态方法wait是Object方法。如何优雅停止线程不要用stop()强制终止会破坏对象的内部一致性。正确姿势是用volatile boolean标志位或者interrupt()配合Thread.interrupted()检查中断状态让线程自行判断何时安全退出。锁的宏观家族对比synchronized自动加解锁、JVM层面、ReentrantLock可中断、可超时、可公平、需手动解锁必须放在finally里、ReadWriteLock读写分离、StampedLock乐观读进一步降低锁竞争。线程池的核心参数和执行流程如前面3.2节所述这块几乎必考必须能够画着流程讲。ThreadLocal的作用和内存泄漏问题必须说清弱引用和强引用的区别以及remove的必要性。死锁如何产生和排查死锁四个必要条件互斥、持有并等待、不可剥夺、循环等待要背熟。排查一定要提jstack命令用jstack pid看线程Dump文件里的“Found one Java-level deadlock”这是最直接的工具。6.2 回答面试题的套路别背结论讲场景我给所有准备面试的读者的建议只有一条每个知识点都要准备一个“为什么”和一段“真实案例”。比如问你volatile和synchronized的区别别只背“volatile不能保证原子性”。你可以这样回答volatile解决的是可见性和有序性synchronized解决的是原子性、可见性和有序性。但synchronized是阻塞式实现会引发线程上下文切换在临界区极短的场景下开销较大。我之前处理过一个计数器场景单线程写、多线程读用volatile就够了但如果是多个线程并发累加就必须用AtomicInteger或者synchronized了。这种回答既有原理深度又有工程判断比干巴巴背定义强太多。再比如被问“线程池的拒绝策略有哪些”别只报名字AbortPolicy直接抛异常默认CallerRunsPolicy调用者自己执行相当于把任务退回提交线程DiscardPolicy静默丢弃DiscardOldestPolicy丢弃最老的尚未处理的任务。你可以补一句实战心得在日志系统里我通常用CallerRunsPolicy让生产者自己背着任务跑反而可以反向压测生产者速度在实时性要求高的场景我更喜欢DiscardOldestPolicy因为最新任务比最早任务的业务价值高。这种“场景化表达”就是区分高分答案和平庸答案的分水岭。7. 常见问题与排查技巧实录7.1 线上CPU飙高怎么定位线程问题查CPU飙升几乎必然要动线程Dump。标准流程top -Hp pid找到占用CPU最高的线程ID。将线程ID转成十六进制printf %x\n tid。jstack pid | grep -A 20 十六进制tid找到对应线程栈。分析栈顶如果是GC线程那就去看GC日志和heap dump如果是业务线程极大概率是死循环、频繁GC、CtrlC级自旋或者正则回溯等耗CPU操作。这里我想分享一个真实案例有一个服务CPU突然跑到99%压测都压不出这个效果。用上面这套流程定位后发现是一个HashMap在高并发下扩容导致死循环JDK 7的遗留代码线程栈里停在一个诡异的位置。换成ConcurrentHashMap后CPU立刻恢复正常。排查多线程问题最怕“拍脑袋”按流程一步步切定位效率才高。7.2 线程阻塞了但找不到死锁有时候线程池拒绝执行任务、接口迟迟不返回但jstack里找不到死锁这大概率是等待队列积压。任务都堵在LinkedBlockingQueue里了线程池所有线程都在处理前面积压的任务新任务无法被执行接口自然超时。对这种问题的排查我会看三样东西线程池的活跃线程数如果长期等于maximumPoolSize说明线程数达到上限负载过高队列大小如果持续增长说明生产速度远超消费速度等待时间任务在队列里等待时间过长说明消费能力不足或者下游依赖数据库、外部API变慢。处理方式也有三种路径扩容线程池加线程数优化单任务的耗时减少锁竞争、批量处理或者让生产者做熔断降级丢弃或拒绝堆积任务。我个人的经验是大部分“线程卡死”问题根子其实在下游——数据库连接池耗尽、外部接口超时重试导致线程池的线程全部卡在IO等待上而不是线程池本身出了问题。所以排查这类问题先往下游链路的耗时看比盲目调大线程池参数靠谱得多。7.3 数据库并发锁与多线程的交互热词里有“数据库并发锁”很多业务开发的第一步锁是数据库锁然后才意识到需要Java层面的并发控制。数据库的悲观锁SELECT ... FOR UPDATE和乐观锁版本号 条件更新各有适用场景。在高并发Java服务里对数据库锁的优化方向就是“把并发尽量挡在数据库外面”——用Java的并发容器、分布式缓存、本地锁或Redis原子操作来削峰让真正打到数据库的请求量降下来。Java层面的锁和数据库锁会形成叠加关系Java锁的作用是控制同一JVM内的线程不并发修改数据库锁的作用是控制跨节点的并发修改比如多个服务实例操作同一行记录。两者不是互斥而是协作关系。如果你在一个分布式环境里只靠synchronized锁本机线程根本没有用别的机器照样并发修改这个时候就要引入分布式锁Redis的SETNX Lua或者ZooKeeper的顺序节点这也是高并发面试的热门延伸点——从单机并发到分布式并发思维方式要切换。8. 写在最后的经验之谈从一个“会用synchronized”的初级选手到一个“能设计整套并发方案”的工程师我走了不少弯路。回头总结有几点经验特别想分享给你第一永远不要迷信某个工具是银弹。ConcurrentHashMap再好不适合的场景硬用反而徒增复杂度volatile再轻量复合操作照样翻车。每一种并发技术都有自己的边界能说出它的适用场景和失效条件才是真懂。第二并发场景首先设计数据共享策略再选同步工具。很多并发Bug的根源是设计上就没想清楚“这个数据该不该被多个线程共享”。能不用共享就不共享比如用ThreadLocal、用不可变对象共享尽量缩小范围剩下的才轮到锁、CAS这些工具上场。我发现一个有意思的现象很多代码之所以复杂但并发问题不断就是因为本来不需要共享的数据硬是塞进了一个全局容器里。第三排障工具练成本能。线上出问题时jstack、jstat、jmap这些命令要信手拈来不要等到服务器冒烟了才去翻文档。我平时会定期给服务做线程Dump看看线程池状态和锁竞争情况这种“预防式体检”能提前发现很多隐患。最后再送一个小技巧写多线程代码时习惯性地给线程起有业务含义的名字。看起来是件小事但真到了线上排障一堆thread-1、thread-2的命名会让人抓狂如果是order-async-process-pool-3这种名字你一眼就能定位到是哪个业务模块在干活。实践里我见过因为线程名太随意排障多花了几个小时的真实案例这种低级成本的节省值得从第一天开始养成。