1. 高并发系统到底在“高”什么先搞清楚敌人长什么样1.1 并发量、QPS和响应时间三个让你少走三年弯路的指标高并发这三个字很多刚工作一两年的 Java 开发会把“多线程”和它画等号这是新手最常踩的坑。多线程只是实现手段高并发的本质是单位时间内系统能够正确处理的请求数量也就是吞吐量。而吞吐量由两个因素决定并发度和单次请求的响应时间。我给你算一笔账。一个 ERP 库存接口单次“读库存 校验 扣减 写流水”的完整逻辑要花 30ms那你单线程每秒最多处理 33 个请求听起来很弱。但这 30ms 里真正让 CPU 忙活的时间只有大概 10ms剩下 20ms 都是在等数据库返回。如果我把一次请求拆成“发起查询、等响应、发起更新、等响应”几个阶段中间等待的时间完全可以用来处理别的请求——这就是并发度的价值。假设通过连接池和线程池把并发度提到 50这个接口的 QPS 就能冲到 1600 左右。所以高并发优化从来不是“开更多线程”这么简单而是把等待时间让出去让 CPU 一直有活干。这也是为什么 NIO、协程、响应式编程这些技术能火起来核心还是那个字等。这里我要强调一个经验先量化再优化。很多人一上来就加缓存、上消息队列结果压测一看瓶颈根本不在数据库而是连接池被撑爆缓存加了等于没加。我自己的习惯是先用 JMeter 或 LoadRunner 打一次基线把接口的 QPS、平均响应时间、TP99、错误率记下来再看是 CPU 先到瓶颈还是内存先到瓶颈还是数据库连接先到瓶颈。指标没拉出来之前所有优化方向都是拍脑袋。1.2 两种最容易翻车的业务场景IM 聊天和 ERP 库存热词里反复出现“高并发 im”和“erp 库存场景高并发的解决方案”这两个场景几乎是 Java 高并发面试和实战的必考题目原因是它们的并发特征完全相反放一起对比特别能看出问题。IM 场景是典型的读多写多、长连接密集。用户在线状态要实时感知消息发出去以后要推送到几百上千个在线用户难点不在数据库而在推送通道和内存。你不可能给每个在线用户都单独建立一个 TCP 长连接到业务服务器否则连接数一多线程资源和文件描述符直接耗尽。这个场景要用到 Netty 这种基于 NIO 的网络框架配合 Redis 的 pub/sub 或者消息队列做广播推送。IM 系统的性能瓶颈往往是连接数、线程数、内存占用而不是 SQL 本身。ERP 库存场景则是典型的写多读少、强一致性。一个商品 SKU 的库存可能只有几百件但抢购请求一秒过来几万个每一单都要执行“读库存—扣减—写流水”。难的地方在于多线程同时扣同一个数字怎么保证不超卖这里的关键在锁和原子操作MySQL 的行锁、Redis 的原子命令、分布式锁都在这个场景里有用武之地。后面我会专门展开讲怎么落地。1.3 服务器的真实瓶颈在哪里CPU、IO、连接数排查高并发问题先要知道资源瓶颈的长什么样。我习惯把瓶颈分成三类CPU 密集型瓶颈加锁、序列化、复杂计算、频繁 GC 都会让 CPU 飙升。表现是 CPU 使用率打满QPS 上不去。IO 密集型瓶颈数据库慢查询、网络延迟、磁盘读写慢。表现是 CPU 使用率不高但响应时间很长线程大量阻塞在 IO 等待上。连接数瓶颈数据库连接池、HTTP 连接池、线程池被占满。表现是新请求进不来老的请求又处理不完系统像被堵死一样。这三类瓶颈的排查思路完全不一样。CPU 高要看线程栈IO 慢要看慢查询和网络指标连接数打满要查连接泄漏和线程池配置。所以接到一个性能问题我从来不看单点先把 CPU、内存、IO、网络连接数四个指标全部拉出来用排除法定位到具体类型再深入查。2. 底层逻辑JMM、对象头与 AQS 的来龙去脉2.1 为什么你写的并发代码看起来对跑起来错Java 代码编译成字节码由 JVM 解释执行或 JIT 编译成本地指令这中间存在编译器重排序、CPU 指令重排序和内存可见性问题。你用肉眼看着“先写 a 再写 b”的代码在另一个线程眼里可能是“先看到 b 变化a 还是老样子”。Java 内存模型JMM就是用来规定线程之间怎么通信的。它要求每个线程有自己的工作内存变量的读写都在工作内存和主内存之间来回同步。如果两个线程同时读写一个没有被 volatile 修饰也没有加锁保护的变量那完全可能一个线程改了值另一个线程读到的还是旧值。这不是 JVM 出了 bug而是 JMM 为了换取 CPU 性能故意放开的行为。理解 JMM 最重要的落点是要保证并发安全只有三条路——加锁、用 volatile 或者用原子类。脱离这三条路去谈什么“我运气好没出问题”那就是在赌线上迟早要还。2.2 synchronized 与 volatile熟悉的关键字不熟悉的底层volatile的作用是保证可见性和禁止指令重排序但它不保证原子性。最典型的例子是 i即使 i 被 volatile 修饰两个线程各执行 10000 次 i结果大概率不是 20000。因为 i 在字节码层面是“读 i、加 1、写回 i”三步操作volatile 只管读写可见管不住中间被插队。synchronized是可重入的互斥锁从 JDK 1.6 开始引入了偏向锁、轻量级锁、自旋锁、锁消除等一系列优化。它的锁信息存放在 Java 对象头里的 Mark Word 中。Mark Word 是个特别值得玩味的东西64 位空间在不同锁状态下存的内容都不一样。无锁状态存 hashCode 和分代年龄偏向锁存线程 ID轻量级锁存指向栈中锁记录的指针重量级锁存 Monitor 对象的指针。面试官喜欢问“一个对象占多少字节”答案核心就是对象头、实例数据、对齐填充三部分。在实际调优里synchronized 需要关注的是锁竞争粒度。比如你用 synchronized 锁了一个很大的方法里面只有一句关键代码需要保护那大量的线程都在外面排队等着进入这个方法白白浪费 CPU。正确做法是把锁的范围缩小到最小必要代码块甚至可以用LongAdder这种分段式的原子类来降低竞争。2.3 AQSReentrantLock、CountDownLatch、Semaphore 的共同祖先热词里专门有人搜“aqs java”这是 Java 并发面试的重灾区。AQSAbstractQueuedSynchronizer是java.util.concurrent里 Lock、Semaphore、CountDownLatch、CyclicBarrier 这些同步器共用的骨架。AQS 的核心是一个volatile int state变量加一个 CLH 变体的双向等待队列。加锁的逻辑就是 CAS 尝试把 state 从 0 改成 1成功就抢到锁失败就把当前线程封装成 Node 放到队列尾部然后通过LockSupport.park阻塞自己。释放锁时唤醒队列头部等待的线程。理解了 AQS你能明白一件事Java 加锁并不总是用操作系统的互斥量大量并发场景下 CAS 自旋就够了只有竞争激烈时才会挂起线程、进入内核态。这也是为什么 ReentrantLock 在某些场景比 synchronized 更灵活——支持超时获取锁、支持可中断、支持公平锁还支持多个 Condition 条件队列。比如你要实现“连接池里没有连接时线程等待一有连接归还就唤醒”这种需求用 Condition 比用 synchronized 的 wait/notify 要优雅得多。3. 化被动为主动从 CPU 飙高到线程卡死的问题排查链路3.1 一套可以抄作业的现场处置流程生产环境出问题第一要务不是找原因而是止损——摘流量、降级、重启。但止损之前一定要保留现场线程栈、GC 日志、堆 dump、CPU 使用率、磁盘 IO、网络连接数能存多少存多少。很多人一慌直接重启什么现场都没了后面就只能靠猜。猜出来的根因十有八九是错的。我的一套排查顺序是固定下来的top看哪个 Java 进程 CPU 高、内存高。top -Hp pid看进程内的哪个线程在消耗资源。jstack pid导出线程栈把高 CPU 线程的 nid 转成十六进制去栈里找对应线程在干嘛。jstat -gcutil pid 1000看 GC 频率和内存区间变化。必要时jmap -dump:formatb,fileheap.bin pid导出堆用 MAT 分析大对象和泄漏路径。这套流程看着简单但能解决大部分线上性能问题。我见过太多同事一上来就开 MAT 分析堆 dump搞了半天发现是垃圾回收太频繁导致的 CPU 飙升而这一步本来jstat -gcutil一眼就能看出来。3.2 一次典型的 CPU 100% 排查过程讲一个我印象很深的案例。有个线上服务CPU 占用突然飙到 100%但 QPS 并没有明显增长这说明代码里大概率出现了某种无效循环或者重复计算。我用top -Hp定位到有一个线程一直占用接近 80% CPU再用jstack导出线程栈看到了某个序列化工具类的堆栈。进一步分析发现那段代码在循环里反复调用Method.invoke做反射调用而且每次都会重新解析参数类型数组JIT 没法把它优化成直接调用于是 CPU 被反复的反射安全检查消耗掉了。解决办法其实很简单把反射调用改成编译期生成好的直接调用或者用一个 Map 缓存住Method对象避免每次都解析。但这件事真正让我记住的不是解决方案而是我犯过的错误一开始我看线程栈里有一堆 jit 编译相关的帧就怀疑是 JIT 出了鬼完全没往反射那边想。后来我养成了一个习惯——怀疑 GC 问题就看jstat的FGC和FGCT如果 Full GC 次数很少就不要再往内存方向猜。用数据排除掉错误方向比盲目深挖一个可疑点要高效得多。3.3 线程阻塞、死锁与连接池耗尽线程卡死比 CPU 高更隐蔽因为服务表面还有响应只是越来越慢。最常见的现场是应用日志里偶尔出现“获取数据库连接超时”或者接口平均响应时间从 50ms 涨到 5s。这时候jstack导出来你会看到大量线程卡在WAITING状态等着某个锁或者被信号量挡住。排查阻塞问题的核心技巧是打印线程栈的时候一定要看堆栈里那个锁对象到底是什么。synchronized 的锁信息在栈帧里不一定显眼但 ReentrantLock 一般会有明确的waiting to lock 0x...提示能直接看出锁对象是被谁持有的。死锁的情况更麻烦。两个线程互相等待对方持有的锁谁都不释放。jstack会在文件末尾直接输出Found one Java-level deadlock的提示并把两个线程的栈都打出来这种情况下定位是简单的。但实际工作中死锁经常不是两个 synchronized 块直接互等而是线程 A 拿着数据库连接等 Redis 锁Redis 锁又被持有连接的线程 B 等着释放——这种跨资源的死锁才是真难点排查难度直线上升。遇到这种场景我建议画一张资源等待图每个线程占有什么资源、等待什么资源把所有线程的等待关系画出来死锁链路就一目了然了。4. 从数据一致性到架构解耦解决方案的完整梳理4.1 缓存先行但缓存不是银弹高并发场景第一个想到的自然就是缓存。读多写少的接口把热点数据放到 Redis数据库 QPS 能从几千降到几十这个数字上的对比就是缓存最大的价值。缓存的坑主要在三个地方缓存穿透、缓存击穿、缓存雪崩。穿透查询一个不存在的数据缓存没有、数据库也没有每次请求都打到库上恶意攻击特别喜欢用这招。解法是布隆过滤器先拦一道或者把空值也缓存起来。击穿某个热点 key 过期的一瞬间大量请求同时打到数据库。解法是互斥锁——第一个请求重建缓存其他请求等待缓存重建完成后再读取或者用逻辑过期物理上不删 key而是用一个逻辑过期时间让后台线程去刷新。雪崩大量 key 同时过期或者 Redis 整体不可用请求全量压到数据库。解法是过期时间加随机值避免集中过期多级缓存本地兜底Redis 集群 熔断降级机制。用缓存还有一个很容易忽略的小点缓存和数据库的一致性问题。先更新数据库再删缓存还是先删缓存再更新数据库网上的说法很多。我踩过坑之后的结论是优先采用“先更新数据库再删除缓存”的策略并给删除操作加重试或者用订阅数据库 binlog 的异步删除方案。极端情况下会有短暂不一致但概率低、时间短业务上通常可以接受。4.2 消息队列削峰填谷但要接受最终一致性消息队列的核心作用是削峰填谷和异步解耦。秒杀场景里如果“创建订单”和“扣库存”都走同步调用数据库会被瞬间打挂但把创建订单的请求先丢进 MQ后续用固定速率的消费者慢慢处理系统整体就稳住了。这就像水库蓄水——洪峰进来慢慢放水下游不会被冲垮。用了 MQ就必须面对一个现实你再也无法保证强一致了只能接受最终一致。下单成功后消息发到 MQ消费者扣库存失败怎么办必须引入一整套补偿机制生产者本地事务里同时写业务表和消息表事务提交后再把消息可靠发送到 MQ。消费者消费失败自动重试重试多次还失败就进入死信队列。死信队列里的消息由补偿任务扫描处理必要时人工介入。幂等也是必须做的。因为 MQ 为了保证“不丢消息”可能重复投递消费者端必须用唯一订单号做去重。比如在订单表里加唯一索引重复消息插入时直接冲突报错被当作已处理跳过这就是最简单可靠的幂等方案。4.3 分布式锁不是所有锁都需要但库存扣减真的需要回到库存场景。单体应用可以用数据库行锁或者 JVM 的 ReentrantLock 解决但服务一旦多实例部署本地的锁就没有意义了——每个实例各锁各的互相之间根本感知不到照样超卖。这时候需要分布式锁。分布式锁的选型我按复杂度排个序简单场景用 Redis 的SET key value NX EX原子命令一个请求完成加锁和过期时间设置不要分两条命令否则中间宕机会导致死锁。释放锁时用 Lua 脚本先比较 value 再删除防止误删别人的锁。复杂一点用 Redisson 的RLock它内置看门狗机制可以自动续期避免业务没执行完锁就过期了。对一致性要求极高且能接受性能损耗的用 ZooKeeper 或者 etcd 的临时节点锁靠会话心跳和节点自动删除来保证锁的正确性。Redis 分布式锁在极端情况下有问题主节点宕机数据还没同步到从节点锁就丢了。面试官经常追问这个问题业界给过 Redlock 方案但 Redlock 本身也有争议。我务实一点说锁只用来保护短的临界区加锁时间控制在几十毫秒内数据库的唯一约束继续兜底才是工程上最稳的组合。不要指望一个分布式锁解决所有问题它只是防线之一。4.4 限流降级给系统一条活路高并发系统一定是“有损服务”的系统。你不可能保证所有请求都成功所以必须提前设计哪些请求可以丢弃、哪些功能可以降级。限流常见维度单机 QPS 限流Guava RateLimiter 或者自研令牌桶算法。集群限流Redis Lua 脚本实现滑动窗口或者令牌桶保证多个实例加起来不超过阈值。网关层限流Nginx 的limit_req、Spring Cloud Gateway 的 RequestRateLimiter、Sentinel 都可以做统一入口限制。降级则要考虑业务语义。库存查询失败的时候能不能直接返回“近期售罄”这种静态页推送服务不可用的时候能不能降级成客户端轮询拉取这些策略是产品和架构一起定下来的不能临时拍脑袋。限流和降级不是性能优化而是稳定性建设。它们存在的意义是在系统扛不住的时候保住核心链路让损失可控。很多团队不做这一步出了生产事故才手忙脚乱地上限流结果限流阈值拍脑袋连正常业务流量都误伤了。5. 数据库层的一致性保障Java 代码之外的另一道防线5.1 悲观锁与乐观锁怎么选库存扣减最经典的 SQL 变体是这一条-- 悲观锁行锁 UPDATE stock SET count count - ? WHERE sku_id ? AND count ?;这一条 SQL 是原子的数据库做了行锁保护哪怕几千个扣减请求同时来也会被数据库串行化执行。count ?这个条件又天然避免了超卖。这是最硬的后腰任何缓存方案和分布式锁方案都要建立在数据库这层防线上。但很多系统的问题是“先查库存再扣减”这两种操作是分开的中间隔着网络、业务逻辑甚至缓存这之间的竞态非常大。所以最推荐的做法是把查询和扣减合并成一条 SQL或者干脆用 CAS 式的乐观锁UPDATE stock SET count count - ?, version version 1 WHERE sku_id ? AND version ? AND count ?;乐观锁适合冲突不频繁、重试成本低的场景。库存扣减这种冲突极高的场景用乐观锁会导致大量请求更新失败然后重试反而拖垮系统。所以我的个人倾向是库存扣减用悲观锁 合理的库存预扣不搞乐观锁的重试风暴。5.2 数据库隔离级别与锁的关系MySQL InnoDB 默认隔离级别是可重复读它通过间隙锁和 Next-Key Lock 来防止幻读。在高并发写场景真正引发死锁的很多时候不是代码写错而是索引设计问题导致锁的范围过大。经典案例一条UPDATE语句没有走唯一索引结果 InnoDB 锁住了多行甚至全表。两个事务互相等待对方释放间隙锁死锁率会突然飙升。所以排查死锁的第一优先动作不是改 Java 代码而是用EXPLAIN看 SQL 有没有走对索引。索引对了锁范围就小索引错了锁范围失控你怎么优化代码都没用。5.3 事务消息、本地消息表与最终一致性如果支付、扣库存、积分三个操作分布在三个服务里你就只能用最终一致性方案。最常用的是本地消息表 消息队列业务操作和消息状态记录在同一个数据库事务里事务提交后由后台任务把消息可靠地发给 MQ消费者的处理逻辑保证幂等。这样即使 MQ 挂了本地表里还能查到未发送的消息补偿任务可以继续重发。这套方案有成熟的开源实现比如 RocketMQ 的事务消息生产者先发一条“半消息”本地事务成功后再提交确认消息MQ 才会真正把消息投递给消费者。理解了这个原理不管用什么消息中间件思路都是通用的。很多团队上来就只用 MQ 的普通异步发送也不做补偿消息丢了就只能用户来投诉这是典型的对消息可靠性理解不到位。6. 线程池与编排高并发代码最容易翻车的地方6.1 线程池参数设计的坑Java 高并发代码里线程池的使用频率极高但参数设计经常出问题。很多人的线程池参数是这么写的核心线程数 10最大线程数 200队列容量 Integer.MAX_VALUE。这种配置等于把线程池当成一个无限队列的缓冲流量一冲进来队列越堆越长响应时间从几毫秒一路涨到几十秒最后全部超时。线程池参数没有万能公式但有一个基本判断逻辑CPU 密集型任务核心线程数约等于 CPU 核数太多线程反而增加上下文切换开销。IO 密集型任务核心线程数可以适当放大2 倍 CPU 核数起步因为线程大部分时间在等 IO多开线程能提升吞吐。队列容量要根据业务容忍的等待时间反推。比如每秒 1000 个任务进来每个任务 10ms队列容量 1000 意味着最多等 1 秒超过 1 秒的积压就该触发拒绝策略。拒绝策略也要认真选。默认的AbortPolicy直接抛异常在业务上往往是不可接受的CallerRunsPolicy让提交任务的线程自己执行反而起到天然背压的作用更适合对吞吐有要求但不想丢任务的场景。6.2 别把 CompletableFuture 用成花架子Java 8 之后的CompletableFuture让异步编排方便了很多能写出thenApply、thenCombine、whenComplete这种链式调用。但有一点很容易忽略CompletableFuture默认使用 ForkJoinPool.commonPool 执行任务公共线程池的线程数默认是CPU 核数 - 1一旦被大量异步任务占满整个应用里所有用 commonPool 的地方都会互相影响。我踩过这个坑之后凡是核心链路的异步任务都显式传入自定义线程池。supplyAsync(supplier, executor)和thenApplyAsync(fn, executor)这两个带 executor 参数的重载方法才是生产环境该用的。另外一个经验是用CompletableFuture做编排时一定要给所有异步分支加超时控制和异常处理。默认情况下一个分支抛异常整条链路的后续步骤都不会执行但异常是否被感知要看有没有exceptionally或handle。不加这些你会在日志里发现链路默默失败排查半天也找不到原因。7. 这几年排障下来我最想说的几句话如果你问我Java 高并发系统最值钱的技能是什么我会说“定位问题的能力”比“方案设计能力”更稀缺。框架、中间件、设计模式都可以速成但真正能让系统稳住的是出了事故之后你还能冷静地用jstack、jstat、jmap一步步把根因找出来而不是靠猜。我见过太多人一遇到并发问题就说是 Redis 挂了、数据库慢了结果排查半天发现是代码里一个无意识的死循环。另一个体会是高并发优化没有银弹方案是组合拳。缓存负责抗读MQ 负责削峰分布式锁负责一致性限流负责保命分库分表负责扩容。它们各管一段必须根据业务特征组合使用而不是听说某个中间件火就往上套。最后分享一个我一直在用的检查清单新接口上线前问自己三个问题——数据一致性怎么保证接口被突发流量打满了怎么办关键依赖挂了系统会不会整体不可用三个问题都有答案这接口才敢上生产。Java 高并发这条路底层逻辑是基础排查手段是核心解决方案是储备。希望这篇梳理能让你少走几个我当年走过的弯路。如果你也有自己印象深刻的排查经历欢迎留言交流。