搞Java并发编程这块,我这些年踩过的坑比写过的代码还多。很多人一上来就背八股文,什么synchronized和ReentrantLock的区别、volatile的可见性,背得滚瓜烂熟,结果一上线,系统一压测就各种幺蛾子。线程池拒绝策略没配好、锁粒度太大导致吞吐量上不去、ConcurrentHashMap用错了方法导致CPU飙到100%,这些问题我在实际项目里全遇到过。
这篇东西不打算跟你扯那些面试官最爱问的底层原理,那些你自己去看《Java并发编程的艺术》就行。我重点讲的是,拿到一个高并发需求的时候,你的设计思路应该是什么,线程池参数到底怎么算出来的,锁在什么场景下该用哪种,以及线上出问题的时候怎么排查。这套东西是给我自己团队的新人做培训用的,今天整理出来分享给正在准备Java面试或者正在做高并发系统的朋友。
1. 高并发的本质:性能指标与设计目标
1.1 先搞懂你要优化什么
做高并发系统之前,你得先想清楚一件事:你追求的到底是吞吐量还是延迟? 这两个指标经常是矛盾的。
吞吐量指的是系统单位时间内能处理的请求数,一般用TPS(每秒事务数)或者QPS(每秒查询数)来衡量。延迟则是一个请求从发出去到收到响应所花费的时间,一般看平均延迟和99分位延迟。
打个比方,你去银行办业务。如果你开10个窗口,每个窗口办理速度比较慢,但能同时接待很多人,这是高吞吐。如果你只开1个窗口,但每个客户进去30秒就办完出来了,这是低延迟。高并发场景下,你往往需要两者兼顾,但设计思路上得有个侧重点。
我在做电商秒杀系统的时候,核心目标就是单机支撑1万QPS,平均响应时间控制在200毫秒以内。这个指标反过来决定了你要用什么样的线程模型、什么样的缓存策略,甚至选什么样的垃圾回收器。所以,第一步永远是定指标,不是写代码。
1.2 可伸缩性的三个层次
可伸缩性这个概念也经常被误解。很多人以为加机器就是可伸缩,其实没那么简单。
可伸缩性分三个层次:第一是垂直伸缩,也就是给单台机器加CPU、加内存,这个有物理上限,而且成本是超线性的;第二是水平伸缩,也就是加机器,但这要求你的应用是无状态的,session不能存在本地,数据不能只落在某一台机器上;第三是数据层的伸缩,这个最难,往往是分库分表、读写分离、最终一致性这些事儿。
最让我头疼的系统基本都死在数据层。应用层无状态化很简单,把session丢到Redis里就行,但数据库一旦成为瓶颈,加多少台应用服务器都没用。所以后面我会专门讲怎么从架构层面提前规避这个问题。
1.3 并发编程的三个核心问题
从并发编程的角度来看,不管你的业务多复杂,归根到底要解决三个问题:原子性、可见性、有序性。
原子性问题指的是多个线程同时修改同一个共享变量时,彼此的操作可能会交错,导致最终结果错误。典型例子就是i++,底层的字节码是读取、加一、写回三步,两个线程同时执行的时候,后写的会把先写的覆盖掉。
可见性问题指的是一个线程修改了共享变量,另一个线程读到的还是旧值。这是因为CPU有缓存,线程可能在自己的工作内存里操作,没有及时刷回主内存。volatile关键字就是干这个用的。
有序性问题指的是编译器或者CPU为了优化性能,可能会对指令进行重排序,导致代码的执行顺序跟你想的不一样。DCL(双重检查锁)单例模式为什么要加volatile,就是为了防止对象初始化时的指令重排序。
这三个问题其实是后面所有并发技术的出发点。理解了它们,你才能看明白synchronized、Lock、volatile、CAS这些机制到底在解决什么问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 线程池:并发系统的第一道闸门
2.1 为什么不能直接用new Thread()
我见过很多初级工程师写的代码,每次请求来了就new一个Thread去处理,觉得这样很爽,简单直接。但在高并发场景下,这基本等于自杀。
线程的创建和销毁是有开销的,包括操作系统分配线程控制块、创建内核栈、初始化线程局部存储等等。如果一个请求要处理100毫秒,而线程创建就要花1毫秒,看起来不多,但当QPS到几千甚至上万的时候,线程创建销毁的开销就被无限放大了。更致命的是,无限制地创建线程会导致系统资源耗尽,最终触发OutOfMemoryError。
线程池解决的就是两个问题:一是复用线程,减少创建销毁的开销;二是通过队列和拒绝策略来缓冲流量,防止系统被打垮。
2.2 核心参数的计算方法
线程池的核心参数有七个,我这里不念文档,直接说怎么算。
假设你的机器是4核8G,现在要处理一个请求,这个请求里有大概30%的时间在做IO操作(查数据库、调远程接口),70%的时间在做CPU计算。
核心线程数的计算公式是:CPU核心数 /(1 - 阻塞系数)。阻塞系数一般取0.8到0.9之间,对应IO密集型和CPU密集型的场景。所以这里的核心线程数大概是 4 / (1 - 0.3) ≈ 5.7,向上取整就是6。
如果你是纯粹的CPU密集型任务,核心线程数就设成N+1,N是CPU核心数,多出来的那一个是为了防止某个线程因缺页中断或者其他原因被挂起时,CPU还能保持忙碌。
最大线程数需要考虑极端流量。根据业界经验,最大线程数 = 核心线程数 + 队列容量 /(单个任务处理时间 × 目标响应时间)。这个公式的意思是,当队列满了以后,还能有多少线程来消化积压的任务。实际项目中我一般会把最大线程数设为核心线程数的2倍左右,然后再用压力测试来校准。
队列容量要看你接受多大的延迟。队列越长,能缓冲的请求就越多,但请求在队列里等待的时间也越长。如果你的目标响应时间是200毫秒,单个任务处理时间是50毫秒,那队列里最多能排4个任务,超过4个的就得触发拒绝策略了。
注意:Executors.newFixedThreadPool用的默认队列是LinkedBlockingQueue,容量是Integer.MAX_VALUE,相当于无界队列。流量高峰期请求全堆在队列里,内存会被慢慢吃光,最终OOM。这就是阿里的Java开发规范里明确禁止使用Executors创建线程池的原因。
2.3 四种拒绝策略怎么选
线程池的拒绝策略有四种:AbortPolicy(默认,直接抛异常)、CallerRunsPolicy(调用者执行)、DiscardPolicy(默默丢弃)、DiscardOldestPolicy(丢弃最老的)。
我实际用得比较多的是CallerRunsPolicy。它的意思是当线程池满了,任务不被处理时,就回到提交任务的线程去执行。这个策略的好处是不会丢任务,坏处是如果提交任务的线程是Tomcat的工作线程,那么这个线程会被阻塞住,相当于把压力回传给了上层。
有一种场景我会用DiscardPolicy,比如日志上报系统,丢了几个日志无所谓,但队列不能堵死。选拒绝策略的核心就是搞清楚你的业务能不能接受丢数据。
3. 锁与同步:正确姿势与常见误区
3.1 synchronized的异常机制与锁升级
synchronized是Java并发里最基础的关键字,但你真的了解它在高并发下的表现吗?JDK 1.6之后,synchronized引入了锁升级机制:偏向锁 → 轻量级锁 → 重量级锁。
偏向锁的意思是,同一个线程反复进入同步块时,不需要每次都做同步操作,直接对比线程ID就行。如果另一个线程也来竞争,就升级为轻量级锁,通过CAS自旋来获取锁。自旋到一定次数还没拿到锁,就升级为重量级锁,也就是依赖操作系统的互斥量,线程会被挂起,涉及到用户态和内核态的切换,开销非常大。
这个过程的启发是:锁的竞争激烈程度决定了性能。同一把锁只有很少的线程竞争时,synchronized的性能和ReentrantLock没太大区别;但一旦竞争激烈,频繁发生锁升级和线程挂起,性能就会急剧下降。
所以不要一上来就否定synchronized,它在低竞争场景下写起来最简单、最不容易犯错。真正需要升级使用ReentrantLock的,是你需要可中断获取锁、超时获取锁、公平锁这些特性的时候。
3.2 ReentrantLock和synchronized怎么选
我团队里有个规矩:能锁方法就不锁代码块,能锁代码块就不锁整个类。锁粒度越小,并发度越高。
ReentrantLock相比synchronized有几个优势:可以响应中断、可以设置超时时间、可以实现公平锁、可以绑定多个Condition条件。举个实际例子,我在做一个生产者-消费者组件的时候,需要对满队列和空队列分别做等待和唤醒,用synchronized没法直接对一个锁挂多个等待条件,但ReentrantLock+Condition可以,一个condition表示队列不满,另一个表示队列非空,代码清晰多了。
不过ReentrantLock的坑也很明显:必须手动释放锁。我见过有人try没有配finally,锁没释放,整个系统直接卡死。所以用ReentrantLock的时候,我强烈建议使用try-finally块包裹,在finally里unlock。
3.3 避免死锁的四个工程技巧
死锁是并发编程里最头疼的问题之一。两个线程各自持有一把锁,然后互相等待对方手里的锁,谁也跑不了。
教科书上讲死锁的四个必要条件:互斥、持有并等待、不可剥夺、循环等待。打破任何一个就能避免死锁。工程上我常用的做法是:
- 固定锁的获取顺序。多个锁需要同时获取时,约定必须按照相同的顺序获取。比如先锁订单再锁用户,谁都不能反着来。
- 使用tryLock设置超时。拿不到锁就放弃,不无限等待。拿到所有需要的锁再继续执行,否则释放已经拿到的锁。
- 缩小同步块范围。把不需要同步的代码移出同步块,减少持锁时间。
- 用并发工具类代替手动加锁。比如AtomicLong、ConcurrentHashMap自带的原子方法,能不用锁就不用锁。
排查死锁最直接的办法是用jstack命令,它会打印线程的锁依赖关系,也能直接检测出死锁。
4. 并发容器的正确使用姿势
4.1 ConcurrentHashMap到底怎么用
ConcurrentHashMap是Java并发容器里用得最多的一个,但很多人只会把它当成线程安全的HashMap用。JDK 1.8之后,它的实现改成了CAS + synchronized锁头节点,并发度得到了很大的提升。
我在实际开发中发现一个高频坑:putIfAbsent和computeIfAbsent的区别。假如你想在concurrentHashMap里存一个List,没有就创建,有就往里加:
这是错误的写法:
java复制ConcurrentMap<String, List<String>> map = new ConcurrentHashMap<>();
List<String> list = map.get(key);
if (list == null) {
list = new ArrayList<>();
map.put(key, list);
}
list.add(value);
这段代码的问题在于get完了之后,另一个线程可能也发现list为空,也创建了新的list,然后put覆盖,导致部分数据丢失。正确的写法是:
java复制List<String> list = map.computeIfAbsent(key, k -> new ArrayList<>());
list.add(value);
computeIfAbsent保证的是同一个key只有一个线程会执行创建逻辑,其他线程拿到的是创建好的那个实例。这里还有个细节要注意,computeIfAbsent的第二个参数里不能再对这个map做写操作,否则会有并发修改的问题,逻辑上会出现难以预料的死锁或者结果错误。
4.2 CopyOnWriteArrayList的优缺点
CopyOnWriteArrayList用的是读写分离的思想,读的时候不加锁,写的时候加锁复制整个数组。这样读操作永远不会阻塞,很适合读多写少的场景,比如配置信息的存储。
但它的致命缺点是:每次写操作都会复制整个底层数组,内存开销很大。如果你的列表有10万个元素,每秒写100次,光复制数组的内存开销就够把年轻代撑爆了。所以写多读少的场景,一定要慎用CopyOnWriteArrayList。
4.3 ThreadLocal的内存泄漏问题
ThreadLocal在并发编程里是个利器,比如SimpleDateFormat不是线程安全的,每个线程存一个自己的实例就能避免竞争。但ThreadLocal有个著名的坑:在线程池场景下,线程不会销毁,ThreadLocal的key是弱引用,但value是强引用,导致value无法被回收,最终内存泄漏。
解决方法是:用完ThreadLocal之后必须调用remove()方法。我团队里的代码规范是,使用ThreadLocal必须配套try-finally,在finally里调用remove。
还有一个相关的坑是,ThreadLocal的key如果是static的,它就是强引用,不会被回收,反而不会泄漏;如果key不是static的,每次创建实例的时候,key都是新的,但是线程池里的线程会一直持有ThreadLocalMap的引用,value一直没法释放。这个坑排查起来非常隐蔽,线上内存一直涨,dump下来发现是一堆SimpleDateFormat的实例占着内存,就这原因。
5. 从并发到可伸缩:架构层面的优化方向
5.1 无状态化设计
高并发系统第一原则:永远不要存状态在应用服务器本地。Session、本地缓存、内存队列这些东西,一旦服务器重启就丢了,而且你怎么做负载均衡都别扭。
我在一个项目里吃过这个亏,刚开始图省事,把用户信息放在了应用的内存Map里。后来要上多节点部署,发现Session对不上,用户登录状态一会儿有一会儿没有,排查了好久才找到原因。后来把状态全部迁移到Redis,应用服务器彻底变成无状态的了,加机器就变得非常轻松。
5.2 异步化和削峰填谷
高并发场景下,不是所有请求都需要即时处理。比如下单成功后的发短信通知、发优惠券、更新积分这些事情,完全可以异步处理。
异步化的手段有两种:一种是消息队列,把任务丢给MQ,由消费者去处理;另一种是使用CompletableFuture或者协程,在应用内部做异步编排。
消息队列最大的价值在于削峰填谷。比如秒杀场景,瞬时流量是平时的100倍,如果让所有请求都打到数据库,数据库肯定扛不住。但把请求丢进MQ之后,消费端可以按照数据库能承受的速率去消费,这样系统就不会被打垮。当然,这意味着你需要接受最终一致性,短时间内数据可能不是最新的,但这个取舍是值得的。
5.3 分级缓存策略
缓存是高并发系统的命根子。没有缓存的时候,一个热点商品的详情页可能要查10次数据库,有了缓存之后,只需要查一次Redis,剩下的都走缓存。
我使用的缓存策略是两级缓存:本地缓存(Caffeine) + 分布式缓存(Redis)。本地缓存的访问速度是纳秒级的,Redis是毫秒级的,差了好几个数量级。先用本地缓存命中,不中了再去查Redis,redis也没有才去查数据库,然后回填到两级缓存里。
这里有个经典的坑叫缓存击穿:某个热点key刚好过期,结果大量请求同时查到数据库,数据库瞬间被打爆。解决办法是互斥锁,只有一个线程去查数据库,其他线程等待并重新读取缓存。
缓存雪崩是另一回事,大量的key在同一时刻过期,导致打到数据库的流量超过数据库能承受的极限。解决方法是过期时间加一个随机值,打散过期时间。
5.4 数据库层的水平伸缩
前面说了,应用层做无状态化之后加机器很容易,但数据库才是真正决定系统极限的地方。当单库单表的数据量超过千万级别的时候,SQL查询性能会明显下降。这时候就需要分库分表。
分库分表的核心是选择一个好的分片键,比如用户ID或者订单ID。然后用哈希或者范围的方式把数据分散到多个库表里。这一层引入的复杂度非常高,比如跨分片的查询就没办法直接通过SQL完成,需要走中间件或者代码层聚合。
如果系统还没到必须分库分表的程度,我建议先用读写分离来扛。一主多从,主库负责写,从库负责读。大部分系统的读请求远远多于写请求,读写分离之后,主库的压力会大大降低。
6. 常见问题排查与性能调优实录
6.1 线上CPU飙到100%的排查思路
高并发系统最常遇到的问题就是CPU飙高。我之前遇到过几次,简单分享一下排查思路。
第一步,用top -Hp找到CPU占用最高的进程和线程。假设进程号是12345,执行top -Hp 12345能看到这个进程下所有线程的CPU占用情况,找到那个占用最高的线程ID,记下来,假设是23456。
第二步,把线程ID转成十六进制:printf "%x\n" 23456,结果是5BA0。
第三步,执行jstack 12345 | grep -A 20 "5BA0",就能看到这个线程在干什么了。如果是在跑GC,那就是内存分配压力太大;如果是在执行一个死循环,那大概率是你写的某个方法一直在spin;如果是在等锁,看看等的是哪把锁,是不是死锁了。
排查的过程比解决问题本身更重要。养成一个习惯,遇到线上CPU飙高先别盲目重启,先jstack抓现场。
6.2 JVM参数调优的实用配置
高并发系统对JVM参数的依赖非常大。我一般按这个思路来配置:
堆内存大小:-Xms和-Xmx设置成相同的值,避免运行期动态扩容带来的性能损耗。到底设多大,取决于应用的实际情况。一般的经验值是系统物理内存的一半到三分之二,剩下的留给操作系统和直接内存。
垃圾回收器选择:如果是JDK 8,我倾向于用G1;如果是JDK 11及以上,可以试试ZGC,特别是对延迟比较敏感的场景。G1的目标是把GC停顿时间控制在一个可预期的范围内,通过-XX:MaxGCPauseMillis=50来指定。
打印GC日志是必须的。人传的这种配置,加上这几个参数:
code复制-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/to/gc.log
有了GC日志,排查问题的时候才能知道是不是GC导致的长停顿。
6.3 压测时容易忽略的瓶颈点
我自己做压测的经验是,永远不要只压一个接口。只压下单接口可能性能很好,但一旦混入查询接口、写入接口,系统表现完全不一样。因为不同的请求对CPU、内存、IO的消耗完全不同,混合压测才能模拟真实场景。
还有一个很容易被忽略的瓶颈是连接池。数据库连接池、Redis连接池、HTTP连接池这些,如果最大连接数没调好,系统在高峰期会频繁等待获取连接,表现为RT突然飙升。排查方法是看一下连接池的监控数据,看看活跃连接数是否已经打满,打满了就调大最大连接数,或者优化SQL的耗时,缩持连接的时间。
我曾经遇到过一个线上问题,QPS一上去Redis连接就直接超时,把最大连接数调大也没用。最后排查发现是某个请求里面泄露了Redis连接,没归还到连接池,导致连接全部被占死。这类问题不靠压测是根本暴露不出来的。
6.4 高并发下日志系统的优化
日志是高并发系统里容易被忽视但影响很大的部分。同步打印日志在高峰期会占用大量的CPU和IO,甚至导致请求超时。
我推荐的方案是异步日志,用log4j2或者logback的AsyncAppender之类的机制。日志先写入一个内存队列,由后台线程消费并写入磁盘,业务线程不需要等待IO完成。
异步日志也有个坑:队列满了之后会丢弃日志或者阻塞。我一般在压测的时候会重点关注这个问题,把队列大小调到足够大,同时监控日志队列的积压情况。
还有一点,打印日志不能太频繁。比如在一个QPS很高的循环体里打印debug日志,性能会暴跌一个量级。日志的关键是要输出有排查价值的信息,而不是事无巨细地全打出来。
6.5 压测结果分析速查表
我自己整理了一张压测结果分析的小表,每次压测完对照着看,能快速定位问题出在哪一层。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| CPU使用率100%,QPS很低 | 线程数配置过大,频繁上下文切换 | 检查线程池核心线程数,看jstack线程状态 |
| 平均RT正常,但99分位RT很高 | 有少量慢请求拖尾 | 排查GC停顿、外部依赖超时、锁等待 |
| 每分钟RT都在上升 | 内存泄漏或者缓存击穿导致DB压力大 | dump堆内存,检查GC日志,看DB连接池监控 |
| 大量请求返回超时 | 线程池拒绝或者连接池耗尽 | 检查线程池拒绝统计,连接池活跃连接数 |
| 数据库CPU飙高但慢SQL很少 | 缓存穿透,大量请求打到DB | 检查缓存命中率,看是否有恶意key |
压测就是让问题在正式上线之前暴露出来。与其上线后被用户投诉,不如压测的时候多跑几轮,多发现问题。
最后再分享一个我个人的小习惯:我在做任何一个新项目的时候,都会把并发编程的核心组件单独抽一个模块出来,包括线程池的封装、缓存的封装、分布式锁的封装、MQ消息的封装。这样每个项目的并发基础能力都是一致的,踩过的坑也都能沉淀下来。这套东西我用了好几年,就算业务代码写得不那么优雅,并发这一层基本没出过什么大问题。
