如果有人问我,Java里最值得反复琢磨的内容是什么,我的答案大概率不是Spring,也不是微服务那套东西,而是Java并发编程。这个领域表面上看门槛不高,new几个线程跑起来谁都会,可一旦线上出现数据错乱、接口超时、CPU飙升,排查到最后往往都指向并发层面的一两个细节。我自己就被这些坑教育过很多次,所以这篇指南不讲虚的,把并发编程里最核心的概念、最常用的工具,以及我踩过的坑一次讲清楚。文章面向Java后端开发、打算系统学习并发的读者,也适合面试前快速过一遍重点。读完你手里应该能有一套可以直接落到代码里的并发处理思路,而不是一堆飘在空中的概念。
1. 多线程到底难在哪里:先看三个坑
并发编程的所有问题,归根结底逃不出三个维度:原子性、可见性、有序性。我习惯把这三个特性比作餐厅后厨的操作规范——原子性保证下单、做菜、出餐是一个完整动作,中间不能被打断;可见性保证改完菜单后所有厨师都能看到新版本,而不是各看各的旧菜单;有序性保证你按“先热锅再倒油”的顺序执行,不会被乱七八糟的优化打乱。只要其中一环出了问题,程序就会出现诡异行为,而且越诡异越难复现。
1.1 原子性:一个操作真的“不可分割”吗
Java里最经典的原子性坑就是i++。表面上这是一行代码,但字节码层面它分成了“读取变量、计算加一、写回变量”三步。两个线程同时执行,完全可能都读到旧值,结果只加了一次。我当年在一个统计接口里用AtomicInteger替代int,修掉了一个累计值不准的bug,改动不大,但排查过程折腾了大半天。所以现在养成了一个习惯:看到多线程共享的可变计数,第一反应就是检查有没有使用原子类或加锁保护。
如果写一段多线程安全的计数逻辑,我的优先级大概是:AtomicLong/AtomicInteger能解决就先用原子类,解决不了再上锁,不要一上来就 synchronized 包一个方法。原子类基于CAS实现,在高并发下比加锁开销小得多,适合“读改写”这种短操作。当然CAS也有ABA问题,简单场景用版本号即可解决,后面讲锁的时候我会再提到。
实操心得:不要迷信“一行赋值就是原子操作”。判断一个操作是否原子,要去想它是否可能被拆成多步,任何“读-改-写”组合都要警惕。这也是面试里爱问
i++是否线程安全的原因,它考察的其实是你能不能看穿字节码层面的真实动作。
1.2 可见性:线程之间真的“共享”变量吗
第二个坑是可见性。每个线程都有自己的工作内存,可以简单理解为CPU缓存,线程读到的值可能不是主内存里的最新值。经典案例是while循环里的退出标志:主线程改了标志位,子线程却一直跑不停。这时候给标志位加volatile就能解决,它的语义是每次读取都强制从主内存拿最新值,同时写操作会刷新到主内存。
但有个认知必须纠正:volatile只保证可见性和有序性,不保证原子性。它适合“一个线程写、其他线程读”的状态标志场景,不适合复合操作。我见过有人用volatile修饰计数器,结果统计还是错,这就是没分清volatile和原子类的使用边界。你可以在代码里把volatile当成一个轻量级的同步信号灯,但别把它当成万能锁。
再补充一点,Java内存模型(JMM)定义了一组 happens-before 规则,比如锁的解锁先于后续加锁、volatile写入先于后续读取、线程start()先于其内部操作。这些规则不需要背得滚瓜烂熟,但理解它们能帮你判断一个并发问题到底是不小心破坏了可见性,还是破坏了原子性,排查方向会清晰很多。
1.3 有序性:指令重排造成的“反常识”现象
有序性这个点,虚拟机和CPU为了优化性能会调整指令顺序,单线程里感觉不到,多线程下却可能出现匪夷所思的现象。最典型的例子就是双重检查锁(DCL)实现单例,如果instance字段没有用volatile修饰,一个线程可能拿到一个“分配了内存但还没完成构造”的半成品对象。这个问题教科书里讲烂了,但实际项目里依然有人写错,尤其是翻老代码的时候经常能看到。
解决办法很简单:DCL单例的实例字段用volatile修饰,或者直接用枚举/静态内部类实现。我个人不太推荐在项目里手写这个看似精妙的并发技巧,问题在于太容易出细节纰漏。从工程角度讲,简单可靠的方案才是好方案,没有炫技的必要。
再延伸一下,很多看起来“不相关”的字段错乱,根子都在指令重排上。如果你怀疑是有序性问题,别急着改业务代码,先看看共享字段有没有正确的内存屏障手段(volatile、锁、原子类都自带屏障语义)。知道“哪里加了屏障”比背诵屏障指令有意义得多。
2. 线程与线程池:从手动 new 到自动管理
理解了三个核心问题,接下来看看实际工作中我们怎么跟线程打交道。很多初学者喜欢直接new Thread起一个线程跑任务,这在小demo里没有错,但放到生产环境里就是灾难:频繁创建销毁线程开销巨大,线程数量一旦失控,系统直接卡死。这也是为什么线程池几乎是每个Java后端项目都绕不开的基础设施。
2.1 理解线程状态,排查一半问题都在这里
线程有六种状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。我排查线上问题时,第一步几乎都是看线程卡在哪个状态。比如大量线程处于BLOCKED,大概率是锁竞争激烈;大量线程处于WAITING,可能是在等某个条件通知,比如调用了wait()或join();TIMED_WAITING 则是带超时地等待,比如sleep()或await()。
我见过很多同事一上来就看业务代码,绕了半天才发现是锁没释放。其实先看一眼线程状态,方向马上就有了。这里有个容易忽略的点:RUNNABLE不代表线程一定在跑,它可能是等待CPU调度的就绪状态,也可能正卡在操作系统层面的IO上。所以看到大量RUNNABLE也不能直接断定没问题。
2.2 线程池核心参数:每一个选择都要有依据
线程池我推荐直接用ThreadPoolExecutor,它的七个参数每调一个都有讲究:核心线程数、最大线程数、空闲存活时间、时间单位、工作队列、线程工厂、拒绝策略。先记住线程池的执行流程:核心线程全忙,任务进队列;队列满了,才创建非核心线程;线程总数到最大上限,再来的任务走拒绝策略。很多人背不下来,其实只要想清楚一个道理——线程池的每一步都是把压力从核心线程往队列、往临时线程、最后往拒绝策略传导。
核心线程数的设置,业界有个粗略经验:CPU密集型任务可以设成“CPU核数+1”,让线程尽量少切换;IO密集型任务可以设大一些,比如“CPU核数x2”,因为线程大部分时间在等IO。但这个公式只能做起点,真正准不准要靠压测。最大线程数要结合任务队列长度一起看,如果队列用的是无界队列,那么最大线程数基本没有意义,因为任务永远不会触发创建非核心线程的逻辑。
拒绝策略默认是AbortPolicy,直接抛异常。实际项目里我更偏向结合业务做降级,比如记录日志、把任务写入延迟队列稍后重试,或者用CallerRunsPolicy让提交任务的线程自己执行,这样能起到天然限流的作用。不要小看拒绝策略,线上突发流量时,它就是系统最后的保护网。
经验之谈:Executors 提供的几个快捷方法(
newFixedThreadPool、newCachedThreadPool等)在demo里用很方便,但生产环境我不推荐直接用。它们要么使用无界队列导致OOM风险,要么允许创建无限线程把事情越拖越糟。手写ThreadPoolExecutor并没有多几行代码,却能把边界攥在自己手里。
2.3 线程池关闭与监控:线上崩溃的前置防线
线程池关闭也是个细节活。shutdown()会等已提交任务执行完再关闭,shutdownNow()则尝试中断正在执行的任务并返回未执行的任务列表。很多线上事故就是“服务重启时线程池被强杀,任务丢了一批”。如果你的业务对任务完整性有要求,重启前一定要走优雅关闭流程,并且给已经跑着的任务留出足够的时间。
监控方面,我建议把线程池的几个指标接进告警:activeCount活跃线程数、queue.size()队列积压量、completedTaskCount完成任务数、largestPoolSize历史最大线程数。队列积压持续上涨而活跃线程已经打满,基本就是线程池容量不够的信号,这时候再想临时改参数就晚了。另外还有个坑:通过submit()提交的任务,如果里面抛了异常,你没有调用Future.get()去取结果,异常会被吞掉,日志里什么都看不到。所以要么用execute()配合自定义异常处理器,要么提交任务后一定要记得处理Future返回的结果。
3. 锁的正确打开方式:synchronized与Lock
锁是并发编程里最常用的工具,也是问题最多的工具。Java里有两套锁体系,一套是synchronized关键字,一套是java.util.concurrent.locks包下的Lock接口实现。很多初学者纠结“哪个性能更好”,其实两者在JDK较新版本里性能已经没啥本质差距,真正的区别在于功能灵活性和使用场景。
3.1 synchronized 其实比你想象中聪明
很多人对synchronized的印象还停留在“重量级锁”上,那是老黄历了。JDK 6之后对内置锁做了大量优化,引入了偏向锁、轻量级锁、重量级锁的升级过程。简单理解:一个锁一开始偏向于第一个获取它的线程,如果一直没有竞争,就保持在偏向状态;一旦有其他线程来竞争,会先尝试用CAS自旋升级为轻量级锁;自旋失败且竞争激烈,才膨胀为重量级锁,进入操作系统层面的阻塞唤醒。所以大部分低竞争场景下,synchronized的开销比想象中小得多。
使用synchronized时最重要的是锁对象的选择。锁的是实例对象还是Class对象,效果完全不同;锁粒度越小,并发度越高。我见过有人把整个方法都加上synchronized,在方法里又做了耗时很长的外部调用,结果并发能力被锁拖死。正确姿势是尽量缩小同步块的覆盖范围,只把真正需要保护的共享变量操作放进锁里。
3.2 Lock 在什么时候必须上
ReentrantLock的优势体现在几个场景:需要尝试获取锁而不是死等(tryLock带超时)、需要可中断的加锁(lockInterruptibly)、需要公平锁、需要多个条件队列。其中tryLock是我认为最实用的能力。比如两个线程同时抢一个资源,抢不到就做别的,而不是无限阻塞,这种设计能大大降低死锁风险。
Condition也是Lock体系里非常实用的东西。用synchronized时只能有wait和notifyAll,而且被唤醒的线程并不一定在等自己该等的条件,典型就是生产者消费者模型下所有线程都被叫醒,又被没满足条件的消费者继续挂起,白白浪费一堆唤醒开销。ReentrantLock可以创建多个Condition,生产者等“不满”条件,消费者等“不空”条件,各等各的,精准唤醒,代码也更清晰。
3.3 锁粒度的取舍:少用锁嵌套,多用无锁方案
聊到锁就不得不提粒度问题。我的实践原则是:能不用锁就不用锁,能用小锁不用大锁,能用无锁数据结构就不用锁。写操作非常多的场景下,锁竞争不可避免,但我们可以减少锁内代码量,把耗时操作挪到锁外面执行。
锁嵌套是死锁的高发区。两个线程各自持有一把锁,又都在等对方手里的锁,两边都动弹不得。排查这类问题,最直观的方法是看代码里是否存在“锁的顺序一致性问题”——如果所有线程都按同一个顺序获取多把锁,死锁理论上就能避免。但更稳妥的做法还是尽量避免一次持有两把以上的锁,通过设计层面消解嵌套。另外像StampedLock这种乐观读锁也值得了解,它的读操作在无竞争时完全不加锁,只有在写操作抢锁时才退化,适合读多写少但对一致性要求高的场景。
还有一个很多人忽略的点:synchronized、ReentrantLock这些都是单机进程内的锁,微服务架构下多个服务实例共享资源时是锁不住的。这时候要么引入分布式锁,要么用数据库乐观锁,要么用Redis等中间件的原子操作。我在项目里见过有人拿本地锁去控多实例任务的唯一性,结果线上两个实例同时把任务跑了一遍,最后还得靠数据库唯一索引兜底。
4. 并发容器的选型清单
JDK 在java.util.concurrent包里提供了一大堆并发容器,很多人记不住、也不会选。我的经验是先搞清楚“线程安全”到底意味着什么,再按场景去挑容器,而不是所有地方都无脑上ConcurrentHashMap。
4.1 ConcurrentHashMap 为什么快,又为什么不万能
ConcurrentHashMap是并发编程里出场率最高的容器。JDK 7 时代它用分段锁提升并发度,JDK 8 之后改成了更细的粒度:数组头节点用CAS保证可见性,哈希冲突的链表或红黑树节点用synchronized加锁。读操作几乎不加锁,所以在读多写少的场景下性能非常可观。
但ConcurrentHashMap不等于“所有操作都线程安全”。我用它踩过一个经典的坑:先containsKey判断不存在,再put插入,这两个操作合在一起并不是原子的。两个线程可能同时判断不存在,然后各自插入,直接覆盖数据。这种“判断+操作”的复合步骤,哪怕在并发容器里也必须要加锁或者改用putIfAbsent这类原子方法。记住:并发容器解决的是单步操作的线程安全问题,不是业务逻辑的原子性问题。
4.2 按场景选容器:读多写少、队列、延迟任务
读多写少的场景,CopyOnWriteArrayList值得了解一下。它的原理是写操作时复制一份新数组,写完再替换引用,读操作在旧数组上跑,根本不需要加锁。代价是每次写操作都要整数组复制,写频繁时性能反而不好。所以它适合“配置项列表、监听器列表”这种改动极少、读取极多的场景。
生产者消费者场景,最常用的是BlockingQueue体系。ArrayBlockingQueue有界且公平性可配,适合做线程池队列;LinkedBlockingQueue默认无界,容易堆积任务;SynchronousQueue根本不存任务,直接交给线程,适合“不缓冲”的直传模式;DelayQueue里的元素到期才能被取出,可以拿来设计延迟任务。选哪个队列,本质上是选“缓冲多少、是否堆积、取走的时机”,要结合业务对数据的实时性要求来定。
4.3 容器之外容易忽略的线程安全问题
容器选对了,别忘了一些基础类的并发隐患。最典型的就是SimpleDateFormat,它内部用了Calendar对象,多个线程共用时会抛异常或产生错乱日期。我通常在项目里直接禁止共享SimpleDateFormat,要么用ThreadLocal包一层,要么直接用 Java 8 的DateTimeFormatter,它本身就是线程安全的。
再一个是老版本的HashMap并发put会导致 CPU 100% 的问题,JDK 7 里扩容时形成环形链表,get操作就会死循环。JDK 8 之后结构变了,死循环问题大幅缓解,但并发下HashMap丢数据、覆盖数据的问题依然存在。所以只要是会被多线程操作的非线程安全集合,一律换成并发容器,不要抱有侥幸心理。另外,并发容器的迭代器是弱一致性的,迭代过程中可能看不到某些最新修改,这个特性在业务上要注意,别拿“遍历时立即看到全部修改”当预期。
5. 实战复盘:从设计到问题排查
聊完理论、工具和容器,最后用两个真实场景串一串。这些案例都是我实际处理过的并发问题浓缩出来的,照着这个思路去设计,很多坑是可以提前规避的。
5.1 库存扣减的三种写法
先看一个经典题:秒杀场景下库存超卖怎么防。最笨的写法是先查库存、判断大于0、再扣减,三步之间没有原子性,并发请求一来库存就超了。第一种方案是把整个扣减包进synchronized或数据库行锁里,简单但性能一般,适合小并发单体应用。第二种方案是数据库乐观锁,也就是更新语句里带上库存条件:UPDATE stock SET count = count - 1 WHERE id = ? AND count > 0,靠数据库行锁和受影响行数判断是否成功。这种方式吞吐量比纯应用层锁好,也是我目前在实际项目里比较推荐的单体方案。第三种方案是引入Redis原子操作做预扣减,再异步同步到数据库,适合超高并发的场景,但引入了数据一致性的复杂度,需要做补偿。
这里要说清楚:没有“绝对正确”的选型,只有“适合当前业务”的选型。如果公司流量还在起步阶段,为了炫技上Redis预扣减,反而会让系统多出很多不必要的维护成本。
5.2 死锁排查现场:一张线程快照搞定
死锁问题的排查,我一般用应急三板斧:jps找到目标Java进程的PID,jstack打印线程堆栈,然后在日志里搜索deadlock关键字。有一次线上接口响应越来越慢,我拉完线程堆栈,很快就看到两个线程互相持锁等待对方释放,堆栈里清清楚楚地标着“Found one Java-level deadlock”。解决方式也很直接,调整代码里获取两把锁的顺序,让所有线程都按相同顺序加锁,问题就没了。
排查线程问题是个熟练活,除了jstack,我还常用jstat看GC是否频繁、jmap看堆内存分布。如果发现大量线程阻塞在同一个锁上,再用jstack连续抓几次,对比哪些线程一直卡着不动,基本就能锁定是谁占着锁不释放。这里有个小技巧:线上出问题时,先保留现场再重启,慌乱之下重启机器会把最关键的证据丢掉,这是很多团队踩过的坑。
5.3 流量高峰下线程池的真实状态
高峰流量下线程池的表现,更像是一场压力测试。有一次活动上线后,我通过监控看到线程池的活跃线程数打满,队列积压持续攀升,但接口调用方却在疯狂报超时。当时我第一反应不是直接调大最大线程数,而是先确认机器CPU和内存是否还有余量。盲目调大线程数只会让CPU频繁切换线程,反而把吞吐量拖得更低。后来我先排查了任务里是否有慢调用(比如外部接口或数据库慢查询),把瓶颈修复后,线程池自然就恢复健康了。
从那次之后,我建立了一个习惯:线上线程池指标必须提前接入监控,并且设定好队列积压阈值告警。线程池不是一配了之的东西,它需要配合业务流量做动态调整。JDK 提供了setCorePoolSize和setMaximumPoolSize方法,热更新参数本身是可行的,但前提是你在调整之前清楚知道当前瓶颈是什么。
我个人在实际操作中的体会是,Java并发编程最难的从来不是某个API不会用,而是面对一个看起来随机出现的线上问题时,能不能快速判断它属于原子性、可见性、有序性里的哪一类,然后对症下药。平时写代码前多想一想共享什么、在哪里并发、用什么机制保护边界,可能比出了问题再去翻一堆资料有效得多。多线程这个东西,平时给它足够多的敬畏,线上它就会少给你添麻烦。