这份笔记刚流传出来的时候,标题确实吸引眼球,但真正让我觉得有价值的不是“终极版”三个字,而是里面反复出现的故障案例和参数权衡。高并发编程从来不是背会几个API就能过关的事。我在参与某个模拟秒杀系统的压测时,就亲眼看着一个看起来很正常的线程池配置,在流量冲上来的五分钟内把整个服务拖垮。线程阻塞、队列打满、锁竞争加剧、下游超时,最后接口全线超时,监控页面一片飘红。
从那以后我对“进阶”这个词有了新的理解:会用并发工具只是入门,能在线下压测和线上故障中把工具用好、用稳,才是真正的进阶。这笔记据传来自某个头部电商平台的内部技术沉淀,内容围绕线程池、锁、并发容器、异步化、稳定性治理这几个核心主题展开,基本覆盖了高并发系统从编码到治理的全链路。无论是刚工作两三年想往深处走的后端开发,还是已经在处理线上性能问题、想建立体系化排查思路的工程师,都有可抄作业的地方。
1. 一次线上事故,让我重新理解“高并发编程进阶”
1.1 那次“压测全通过、上线秒崩溃”的故障
先说那次让我印象极深的事故。当时我在做一个模拟订单服务X,这个服务在压测环境里表现很好,P95耗时只有90毫秒左右,线程池指标也很健康。结果上线后赶上运营活动,流量比预估值高了三倍,系统在短短几分钟内就出现大面积超时。
复盘时查线程dump,发现真相并不复杂:核心线程数设置得太小,任务一多全部挤进阻塞队列,而队列长度设置得又特别大,导致请求在队列里排队时间越来越长。数据库连接池的等待线程也在不断堆积,锁竞争把CPU大量消耗在上下文切换上,最终整个服务就像堵死的路口,谁也进不去、出不来。
这个故障让我意识到,高并发场景下最常见的崩溃点往往不是业务逻辑本身,而是并发基础设施的配置失当。线程池参数、队列长度、锁的粒度、连接池大小,这些平时不起眼的配置,在流量洪峰面前就是生死线。
1.2 进阶笔记到底补的是哪一块能力
很多同学学并发编程,路径大概是先学Thread和Runnable,再学synchronized和Lock,然后背一背ConcurrentHashMap和BlockingQueue的特点。这些内容没错,但还不够。它们解决的是“怎么用这些工具”的问题,而高并发进阶需要解决的是“在真实流量下怎么不被打死”的问题。
这两种能力差了不止一截。会用API是语法层面的能力,进阶笔记补的是工程层面的能力:线程池参数怎么定、为什么这样定、队列选有界还是无界、锁的粒度怎么划分、异步化怎么拆解链路、限流怎么做兜底、熔断降级怎么保护全局。
我把这套笔记重新整理了一遍,按主题拆成了六个部分。大多数内容在一些内部分享里零零散散出现过,但能一次性串成体系的不多,这篇笔记相当于把散落的经验归拢了。
2. 线程池调优:参数的每一个变化都牵动系统生死
2.1 七个参数之间的联动关系
线程池是Java并发编程里最常用也最容易被用错的组件。很多人能背出corePoolSize、maximumPoolSize、keepAliveTime、workQueue、threadFactory、rejectedExecutionHandler这几个参数的名字,但真正到配置的时候,很少能说清楚它们之间的联动关系。
这里有个关键认知:线程数并不是越多越好,核心线程数与队列是配合关系,而不是独立配置。当一个任务提交到线程池时,ThreadPoolExecutor的执行逻辑是:先判断当前线程数是否小于核心线程数,是则创建新线程执行任务;否则尝试把任务放进阻塞队列;队列满了,才尝试创建非核心线程;如果线程数已经达到最大线程数,才会触发拒绝策略。
这个流程决定了几个容易被误判的点。第一,核心线程数设置得再大,如果没有压满,非核心线程根本不会创建;第二,队列长度直接决定了任务的排队深度,有界队列和无界队列在高流量下的表现天差地别;第三,拒绝策略不是用来“兜底难看”的,它应该是系统自我保护的最后一道闸门。
一个直观的类比:线程池就是一个处理站,核心线程是固定工位,队列是等待区,最大线程数是高峰期能临时增加的工位,拒绝策略就是实在接待不了时对外喊停的方式。工位太少会排队严重,等待区太大会积压爆仓,临时工位加太多反而会让整个处理站更混乱。
2.2 如何算出一个“够用且抗冲击”的线程池
线程池参数没有标准答案,但有标准的估算思路。业界常用的估算方式是区分CPU密集型和IO密集型。CPU密集型任务主要消耗CPU,线程数一般设为Ncpu + 1;IO密集型任务因为大量时间在等待IO,线程数可以适当放大,常见公式是Ncpu * (1 + WT/CT),其中WT是等待时间,CT是计算时间。
实际项目中,IO密集型服务的WT/CT往往远大于1,因为一次远程调用可能要等几十毫秒,而本地计算只要几百微秒。比如一个订单查询服务,单次请求的等待时间是80毫秒,计算时间是5毫秒,那么每个核心建议配置的线程数就是1 + 80/5 = 17,如果物理机有8核,理论值就是136左右。
我在模拟订单服务X里就是按照这个思路配置了核心线程数和最大线程数,队列选择有界队列,容量设为2000。这里要特别强调一下:压测时不要只压“刚好够用”的流量,还要压“超出预期”的流量,观察线程池在队列满、触发拒绝策略时的表现。
参数配置还讲究“留有余量”。线上流量很难精准预测,配置得太满,一次流量抖动就会触发拒绝策略;配置得太松,资源利用率又不够。折中的做法是核心线程数按正常峰值的70%来估算,最大线程数按正常峰值的1.5倍来留,队列容量按最大线程数下排队时间不超过某个阈值来反推。
提示:压测时除了看吞吐量和耗时,一定要看线程池的活跃线程数、队列积压量、拒绝次数这三个指标。活跃线程数打满不一定是坏事,队列积压持续上涨才是危险信号。
2.3 线程池使用中最常见的三个坑
第一个坑是无脑用Executors.newFixedThreadPool或newCachedThreadPool。FixedThreadPool的队列是LinkedBlockingQueue,默认容量是Integer.MAX_VALUE,相当于无界队列。任务一多,队列可以无限堆积,内存迟早被打爆。CachedThreadPool的特性是来多少任务建多少线程,在流量洪峰下可能创建几千上万个线程,光是上下文切换就能把CPU耗干。
第二个坑是自定义线程池但不自定义线程名。线上排查线程问题的时候,如果线程池里的线程全部叫pool-1-thread-1,那基本没法干活。规范的做法是自定义ThreadFactory,在创建线程时设置有业务含义的名称,比如order-pool-thread-1。这样出现线程阻塞时,一眼就能看出是哪个业务线的线程池出了问题。
第三个坑是拒绝策略处理得太粗糙。默认的AbortPolicy会直接抛异常,如果核心接口用了这个策略,流量一超出预期就抛异常,调用方看到的就是接口报错。更合理的做法是采用CallerRunsPolicy,让提交任务的线程自己执行任务,或者自定义拒绝策略,把被拒绝的任务降级写入本地表或MQ,等高峰期过去后再重试。
线程池配置示例:
ThreadPoolExecutor orderPool = new ThreadPoolExecutor( 16, 32, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(2000), new ThreadFactory() { private final AtomicInteger idx = new AtomicInteger(); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r); t.setName("order-pool-thread-" + idx.getAndIncrement()); return t; } }, new ThreadPoolExecutor.CallerRunsPolicy() );CallerRunsPolicy不是没有代价,它意味着提交线程可能被阻塞在任务执行上。但相比直接丢请求,这种“放缓但不丢弃”的方式在业务上更容易接受。如果配合服务内部的降级开关,还能实现流量大时自动进入半降级状态。
3. 锁与原子性:从 synchronized 到无锁化
3.1 锁的代价到底在哪里
锁这东西,平时写着顺手,一遇到高并发就容易出乱子。很多新手不理解为什么锁竞争会让系统性能急剧下降:无非就是等待一下,怎么会有这么严重的后果?
锁的代价主要在三处。第一是线程阻塞和唤醒,这个操作由操作系统调度完成,涉及用户态与内核态的切换,一次切换的耗时远高于普通指令执行。第二是上下文切换,线程被挂起后,CPU要保存当前线程的执行现场,再恢复另一个线程的现场,现场越大开销越高。第三是频繁锁竞争会让CPU大量时间花在“抢锁”而不是“干活”上,线程越多抢得越凶,性能反而越差。
Java的synchronized经过优化,已经引入了偏向锁、轻量级锁、重量级锁的升级机制。在低竞争场景下,偏向锁和轻量级锁的开销很小。可一旦竞争变得激烈,锁就会升级为重量级锁,所有争抢锁的线程都会被挂起,性能断崖式下跌。
3.2 减少锁冲突的四个套路
第一个套路是缩小临界区。把不需要锁保护的代码移出同步块,只锁住真正需要原子操作的那几行。比如更新库存时,先校验库存再扣减,这两步必须原子执行,但前面的参数校验、订单生成这些操作完全没必要放在锁里面。
第二个套路是细粒度锁。一个锁保护全部数据,在高并发下必然竞争激烈。可以把数据拆成多个分片,每个分片一把锁,典型代表就是1.8版本的ConcurrentHashMap,它把锁粒度从整个Map缩小到了每个桶。再比如读写锁,读多写少的场景下允许多个读线程并行,只保证写线程独占。
第三个套路是无锁化。CAS(Compare And Swap)是最常见的无锁手段,通过CPU指令级别的原子操作来更新变量值。Java里的AtomicInteger、AtomicLong以及LongAdder底层都依赖CAS。无锁不意味着没有竞争,而是竞争失败后不需要挂起线程,可以直接重试或返回。
第四个套路是锁消除和锁粗化。这两个是JVM层面的优化手段。锁消除针对的是“不可能有竞争”的锁,JVM会直接把synchronized去掉;锁粗化则相反,把多个连续的加锁解锁操作合并成一次大范围的加锁,减少重复的锁操作开销。但这属于JVM自动优化,业务代码里不用刻意追求。
这里要提一个容易忽视的对比:AtomicLong与LongAdder。AtomicLong在高竞争下的CAS重试次数非常多,性能会大打折扣。LongAdder的设计思路是把单一热点拆成多个热点单元,竞争时分散到不同的cell上,最后sum时再汇总。实测在16线程并发递增的场景下,LongAdder的吞吐量可以比AtomicLong高一个数量级,适合计数类场景,比如统计某个接口的调用次数、记录某个商品的浏览量。
LongAdder counter = new LongAdder(); // 每个请求进来 counter.increment(); // 定时上报 long total = counter.sum();3.3 一次死锁事故的排查实录
死锁是高并发编程里最典型的锁问题之一。我印象最深的一次,是在一个账户服务里同时持有了两个锁,一个用于扣减账户余额,一个用于更新账户积分。表面上看两个锁互不相关,但实际业务链路里存在一条反向路径:积分更新成功后会触发一次余额查询,这条路径又尝试获取余额锁。
两路请求互相持有对方等待的锁,形成了经典的循环等待。线程dump里能看到两个线程一个在waiting for balanceLock,一个在waiting for pointLock,谁都不让谁。
排查死锁的常规手段就是抓线程dump,然后看锁的持有与等待关系。用jstack工具打印线程快照,搜索“Found one Java-level deadlock”字样,就能看到死锁循环。
预防死锁比排查死锁更重要。最实用的两条规则:一是按固定顺序获取锁,所有路径都先获取锁A再获取锁B,破坏循环等待条件;二是使用带超时的锁获取方式,比如Lock接口的tryLock(long time, TimeUnit unit),获取不到就放弃或重试,而不是无限期等下去。
注意:业务代码里尽量避免一个方法同时持有多个锁。如果不可避免,至少保证所有获取锁的顺序一致,并且每个锁的持有时间越短越好。
4. 并发容器:别再用HashMap顶雷了
4.1 为什么并发HashMap会出问题而不是迭代慢
写业务代码时,很多人图方便直接用HashMap做缓存、做计数,以为并发量不大会出问题。其实HashMap连“并发安全”的基本能力都没有保证,在高并发写入时不仅可能丢数据,还可能让链表形成环,导致下次遍历时死循环。
1.8之前HashMap扩容时采用头插法,并发扩容会形成环形链表,一旦发生get操作就会无限循环。1.8之后改成了尾插法,解决了环的问题,但put操作在并发场景下依然可能互相覆盖,底层数组里的元素会丢失。ThemodCount也会在迭代时被修改,触发fail-fast异常。
我见过一个线上案例:一个统计日活的模块用HashMap保存用户ID和最近访问时间,并发写入一上来,HashMap的size直接变小,最终统计数据少了将近30%。这种错误非常隐蔽,因为不是所有请求都会失败,只有两个线程同时碰撞到同一个槽位时才触发,抽样日志根本看不出来。
所以在高并发场景下,更新频繁的共享数据不要碰HashMap。
4.2 ConcurrentHashMap的锁粒度与适用边界
1.8版本的ConcurrentHashMap内部结构已经和HashMap一致,都是Node数组加链表或红黑树。区别在于它引入了CAS和synchronized做并发控制:插入时对桶位的头节点加锁,锁粒度从1.7的Segment级别细化到了单个桶级别。
这样的好处是,不同线程如果操作的是不同桶位,完全无锁并行,只有操作同一个桶位时才竞争。即便有竞争,锁也仅限于那一个桶,不会影响其他桶的访问。
但它有个工程使用边界:ConcurrentHashMap不是万能替代品。它只保证单次操作的线程安全,不保证复合操作的原子性。比如先检查后写入(check-then-act)这种逻辑,两个线程同时发现key不存在,然后同时写入,结果可能被后者覆盖。要保证复合操作的原子性,要么用computeIfAbsent这类提供原子语义的API,要么在外部加锁。
另外,size()方法和mappingCount()的计算也不是实时的,它会在竞争激烈时通过CAS重试或加锁来统计,代价并不低。如果业务场景需要精确计数,建议单独维护LongAdder做计数器,而不是每次都调用size()。
4.3 高并发队列与写入放大问题
Java并发包里提供了丰富的阻塞队列:ArrayBlockingQueue有界数组队列、LinkedBlockingQueue链表队列、SynchronousQueue无缓冲直接移交,还有PriorityBlockingQueue、DelayQueue等特殊场景队列。队列在高并发系统里的作用,常见的是用于生产者消费者模式、批量聚合和背压控制。
选型时要重点看三个维度:队列是否有界、吞吐量如何、是否支持优先级。ArrayBlockingQueue有界且初始化时指定容量,适合做线程池的排队缓冲;LinkedBlockingQueue默认无界,除非显式指定容量;SynchronousQueue不存任务,生产者直接找消费者对接,常用于把任务直接从提交线程交给执行线程。
还有CopyOnWriteArrayList,它的读操作完全不加锁,写操作会把整个数组复制一份再修改。这种策略适合读多写极少、但对数据一致性要求又不高的场景,比如配置监听器列表。如果写频繁,每次add都要复制整份数组,会产生明显的写入放大问题,内存和GC都会很吃力。
实际选型时,不要只盯着并发容器本身,还要结合业务特点。比如一个缓存列表,读频率每秒几万次,写频率每分钟几次,CopyOnWriteArrayList就合适;如果写频率每秒几百次,那还是加锁或用其他结构更靠谱。
5. 异步化与削峰填谷:消息队列是并发系统的“减压阀”
5.1 高并发链路为什么要异步
同步调用模型里,每个请求占用一个线程,从入口到数据库整个链路都串行完成。这种模型有两个致命问题:一是线程资源被长时间占用,而大部分时间线程其实是在等待下游响应;二是链路中任何一个环节变慢,整个请求都会被拖住。
异步化的核心思路是把“请求-响应的即时体验”与“业务逻辑的完成时间”解耦。下单请求进来,只要订单数据落库,就立刻返回“下单成功”,后续的库存扣减、优惠券核销、消息通知全部异步执行。用户感受到的响应时间大幅下降,系统的并发容量也上去了。
这不是投机取巧,而是高并发系统普遍采用的工程实践。毕竟用户只关心订单是否创建成功,并不关心库存扣减是不是在同一毫秒内完成。把强一致的环节保留同步,把可以最终一致的环节放到异步链路,是分布式系统设计的基本功。
5.2 削峰、解耦、最终一致性的工程落地
消息队列在高并发系统里最核心的价值有三个:削峰填谷、异步解耦、最终一致性。
削峰填谷是秒杀场景下最典型的应用。瞬时涌入的十万QPS如果直接打到数据库,再好的数据库也会被打挂。引入消息队列后,前端请求先落到队列,消费端按照自己能承受的速度去消费,流量被削平,数据库的压力自然降下来。用户端即使看到“稍后发货”也没关系,体验不垮。
解耦更容易理解。下单系统不需要知道通知系统会执行多久,也不需要关心通知系统当时是否稳定,只需要把“订单创建成功”这个事件发到消息队列,由下游各自订阅。系统之间不再强耦合,一个系统宕机也不会阻塞另一个系统。
最终一致性是这个方案的工程化保障。订单系统和库存系统是两个独立的存储,不可能靠数据库事务来保证一致性。通常在订单创建后先保存本地事务消息表,再异步把消息发到消息队列;消费端处理成功后回调确认,未确认的消息会按一定策略重试,最终达到两边数据一致。
这里贴一个典型的订单异步化改造伪代码:
@Transactional public void createOrder(OrderDTO dto) { // 1. 订单表插入数据 orderDao.insert(dto); // 2. 本地消息表记录待发送消息 orderEventDao.insert(new OrderEvent(dto.getOrderId(), "CREATED")); // 3. 事务提交后发送MQ消息 eventPublisher.publish(dto.getOrderId()); }5.3 消息队列使用中的三个经典事故
消息队列用得不好,也会引入一堆问题。第一个经典事故是重复消费导致重复扣款。消息中间件为了保证不丢消息,通常采用“至少一次”的投递语义,消费端必须做幂等处理。最简单的做法是在消费逻辑里先查一下订单状态,只处理“未处理”的订单;更稳的做法是维护一张消息去重表,用业务唯一键做唯一约束,重复消息直接忽略。
第二个事故是消费积压没人发现。平时的流量不高,消费速度跟得上生产速度,积压量一直在个位数。等到运营活动流量一冲,积压量瞬间涨到几十万,等业务方发现时,订单状态的通知已经延迟了几十分钟。这个问题的关键在于监控,给每个消费组配置积压量告警,积压量超过预设阈值就触发告警,而不是等用户投诉了才知道。
第三个事故是顺序消息乱序。订单系统里同一个订单的状态变更必须按顺序执行:待支付、已支付、已发货、已完成。如果这些消息被并发消费,很容易出现“已发货”比“已支付”先处理的情况。解决方式一般是按订单ID做分区,同一个订单的消息发到同一个分区内,消费端在这个分区内串行处理,保证局部有序。
经验:消费端逻辑里尽量少做远程调用,远程调用越慢,消费能力越低。如果一个消费端需要查询多个服务,建议先查本地缓存,再查远程,最后才考虑回源数据库。
6. 稳定性治理:限流、熔断、降级与容量评估
6.1 限流算法落地:从令牌桶到滑动窗口
限流是保护系统不被过量流量打垮的最后一道防线。常见的算法有计数器、滑动窗口、漏桶、令牌桶。
计数器的实现最简单,固定时间窗口内累加请求数,超过阈值就拒绝。但它有个明显问题:窗口切换的瞬间可能出现两倍阈值的流量。滑动窗口把时间切得更细,比如把1秒分成10个小格,每格独立计数,再滑动统计最近1秒的总量,能有效缓解临界突变问题。
漏桶算法把请求像水一样装进桶里,以固定速率流出,出来的流量永远不超过处理能力,优点是削峰非常平稳,缺点是即使桶里有空闲容量,请求也必须等待固定的流出速率。
令牌桶算法则相反,桶里放着令牌,请求来了先拿令牌,有令牌就执行,没令牌就拒绝或等待。它的优势是允许一定程度的突刺:只要桶里还有令牌,一批请求可以瞬间跑完,这对应对瞬时流量尖峰很友好。
实际项目里,单机限流常使用Guava RateLimiter,但分布式中它的局限性很明显,因为限流状态不共享。更常见的方案是用Redis+Lua实现分布式限流,把计数和比较操作放在Lua脚本里原子执行。下面是一个基于滑动窗口思路的Redis限流脚本:
local key = KEYS[1] local window = tonumber(ARGV[1]) local limit = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local current = redis.call('INCR', key) if current == 1 then redis.call('EXPIRE', key, window) end if current > limit then return 0 end return 1这个脚本实现了固定时间窗口限流。如果要更精确的滑动窗口,可以用ZSET结构记录每个请求的时间戳,每次请求时移除窗口外的记录,再统计窗口内数量是否超限。性能上虽然稍差一些,但在大多数业务场景下完全够用。
6.2 熔断降级的核心目标:保护全局而不是保护单次调用
限流挡住的是超过系统处理能力的流量,熔断和降级解决的则是依赖服务不可用时的系统自我保护问题。
熔断器的核心思想是快速失败。当某个下游服务连续失败率达到阈值时,熔断器打开,之后的请求不再调用该服务,而是直接返回降级结果。打开一段时间后进入半开状态,允许少量请求试探下游是否恢复,如果恢复则关闭熔断器,否则继续熔断。
这里需要理解一个容易被忽略的点:熔断的核心目标是保护全局,不是保护单次调用。下游服务可能已经出现故障,如果上游继续频繁调用,只会让下游雪上加霜,也会拖垮上游自己的线程池。所以看到熔断打开时,不要着急调大超时时间,而要先问一问下游为什么会倒。
降级策略常与熔断配合使用。超时降级是指调用下游超过阈值就返回默认值;开关降级是在大促前主动关闭非核心功能;数据兜底也叫静态化降级,把热点数据提前缓存到本地或推荐引擎,下游挂了直接读兜底数据。
举例来说,商品详情页的下单链路里,推荐位服务挂了,如果不下发推荐就不会影响下单,那么这一块直接降级为空数据,用户订单流程完全不受影响。降级不是“系统崩溃了才做的事”,而是高可用架构里的标配能力。
6.3 容量评估与全链路压测的一点经验
限流、熔断、降级是治理手段,但根本前提是知道系统能扛多大的流量,也就是容量评估。容量评估没有捷径,只能靠科学压测。
我的习惯是分三步走。第一步做单机基线压测,把所有业务功能关掉,只留核心接口,测出单机纯性能上限;第二步做全链路压测,模拟真实业务比例和流量模型,找到全链路瓶颈;第三步做容灾压测,模拟单台机器宕机或单个机房不可用,验证降级和熔断是否生效。
全链路压测有个容易被忽视的细节:测试数据和线上数据必须隔离。如果不做影子表或影子流量隔离,压测流量直接写进了生产的订单表、账户表,轻则污染统计数据,重则影响真实用户的数据。一般的做法是压测流量打上特殊标记,在中间件层识别后路由到影子表。
我踩过的另一个坑是压测时只关注整体QPS,忽略了慢查和长尾延迟。有些接口的平均耗时很好看,但P99可能已经涨了好几倍。高并发系统里,P99和P999才是决定用户体验的关键指标,平均耗时被少数快请求拉低,掩盖了大量慢请求的问题。
建议:每次压测后都要复盘“退化指标”,即吞吐量提升时,延迟指标是否同步变差。如果压测到1倍流量时P99已经飙升,那说明系统的缓冲资源已接近耗尽,而不是继续往上压。
写到最后想再分享一个个人体会:这份笔记里展示的并发编程,已经超过了代码语法的层面,更像是一整套工程权衡的方法论。线程池参数要按业务模型算,锁粒度要按竞争激烈程度调,异步化要在一致性要求之间取舍,限流熔断要对上下游的依赖关系有清晰认知。这些能力不是看几篇文章就能养成的,而是要在真实的故障和压测中积累出来的。如果你还在起步阶段,建议先从自己的项目入手,把线程池队列、锁的适用场景、接口限流逐个排查一遍,再结合这篇笔记的思路去调整,很快就能感受到系统性改善带来的变化。