
Java 并发的内容我已经整理了三期本来以为能写的话题也就那几样了结果每次面试复盘、帮同事排查线上问题总能看到一些看似基础、深挖全是坑的并发点。所以又有了这一篇4。这一期不打算讲入门概念重点放在几个高频翻车的位置AQS 的排队和唤醒到底怎么运作、synchronized 锁升级那些过时说法、线程池参数与“16C32G 服务器能撑多少并发”的估算逻辑以及库存超卖里 Java 锁、数据库锁、Redis Lua 各自的分工边界。适合正在准备 Java 面试的人也适合被线上高并发问题折腾过的后端同事对照自查。1. AQS并发工具的地基到底在工作什么先说 AQSAbstractQueuedSynchronizer。Java 并发包里几乎所有工具类——ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock——底层都是它。你可以把它理解成一个“排队系统 状态机”谁拿到 state 谁就干活拿不到的去队列里等着。搞清楚 AQS等于同时看懂了五六个并发工具的源码这也是为什么面试官特别喜欢从它开始深挖。1.1 state 变量不同同步器里的“一把锁”各有各的含义AQS 内部维护了一个 volatile int 类型的 state所有同步语义都围绕这个变量展开。但注意AQS 本身并不定义 state 到底代表什么它把这个语义完全交给子类实现。子类需要重写 tryAcquire、tryRelease、tryAcquireShared、tryReleaseShared 这几个方法AQS 只负责“获取失败后怎么排队”“释放成功后怎么唤醒”这些通用逻辑。同步器state 的含义获取与释放逻辑ReentrantLock0 表示未持有锁N 表示持有线程重入了 N 次tryAcquire 里 CAS 从 0 改 1重入则 state1释放时减到 0 才算真正释放Semaphore剩余可用许可数tryAcquireShared 里尝试减 1许可不足则小于 0进入等待CountDownLatch还需要等待的 count 值countDown 将 state 减 1减到 0 时唤醒所有等待线程ReentrantReadWriteLock高 16 位表示读锁持有次数低 16 位表示写锁持有次数读写锁各自维护 state 的不同位段通过位运算拆开state 为什么必须是 volatile因为多个线程要无锁地读到它的最新值同时配合 CAS 才能完成“检查-更新”的原子操作。很多人只记住了 state 是个 int忽略了 volatile 才是它在多线程环境下可见性的前提。面试时如果能主动说出这一层通常能比只会背“state 表示资源状态”的人更有说服力。1.2 CLH 变体队列公平性的来源与唤醒的正确姿势AQS 里真正让人犯晕的是那个等待队列。它用的是 CLH 锁的变体本质是一个双向链表每个节点Node代表一个等待中的线程。节点里保存了线程引用、等待状态 waitStatus、前驱节点 prev 和后继节点 next。为什么要用双向链表而不是简单数组或单向链表因为线程在等待过程中可能被中断、超时需要从队列中取消取消后必须让后继节点知道“我的前驱变了”双向结构才能高效处理这种依赖关系。而且唤醒后继节点时要从 head 往后找有效节点入队时却要先设置 prev 再通过 CAS 更新 tail两个方向都需要指针才能串起来。有个细节特别容易被忽略释放锁后唤醒后继节点时源码是从 tail 往前找第一个有效节点的而不是直接从 head 往后遍历。原因在于入队操作不是原子的线程先把自己的 prev 指向前驱节点再 CAS 修改 tail。如果一个线程刚好把 prev 设置完、还没来得及把 tail 指向自己此时从 head 往后找可能会碰到 prev 尚未完整建立的节点导致遍历中断。所以源码里老老实实从尾往前扫保证一定能找到需要唤醒的线程。这个点我在面试里问过不少人十个有八个答不上来但凡是手写过 AQS 源码分析的基本都知道。1.3 从源码逻辑看独占锁的获取与释放以 ReentrantLock 为例加锁入口其实是 AQS 的 acquire(1)先调用子类实现的 tryAcquire(1)尝试直接修改 state。如果修改失败就把当前线程封装成 Node 加入队尾。入队后进入自旋只有当前节点的前驱是 head 时才再次尝试获取锁。获取成功后把自己的节点设为新的 head原 head 出队。获取失败则调用 LockSupport.park 挂起线程等待前驱释放锁后 unpark。为什么只有前驱是 head 的节点才能再次尝试获取锁因为队列内部维护的是公平的 FIFO 顺序如果所有等待线程都反复抢锁就会出现“队尾线程插队成功、队头线程饿死”的乱象。让每个节点只在轮到自己时才竞争既避免了无谓的自旋也保证了公平性需要的最基本前提。释放流程是反过来的tryRelease 把 state 减到 0此时应该唤醒后继线程。源码里 unparkSuccessor 那个方法专门处理了“后继节点为 null 或被取消”的情况然后从 tail 往前找第一个有效节点执行 unpark。这也就是上一节说那个特殊遍历顺序的落点。1.4 面试官挖坑最多的地方公平锁与非公平锁的本质差别讲 AQS 绕不开公平锁和非公平锁。ReentrantLock 默认是非公平的这是有意的非公平模式下一个新来的线程在 acquire 的第一步会直接 CAS 抢一次 state如果刚好抢到就省去了入队挂起唤醒的完整流程吞吐量更高。只有在抢失败后才进入队列排队。而公平锁的 tryAcquire 里会先判断队列里有没有等待者有就乖乖去排队绝不插队。那 AQS 队列本身是不是公平的是的。队列内部严格按照 FIFO 依次唤醒所以 ReentrantLock 非公平体现在“入口处抢一次”而不是“队列内部乱序”。很多人把这两个概念搞混面试时回答“非公平锁会导致排队的线程饿死”严格来说是夸大了它只会让排队线程多等一小段时间并不会永久饿死。另一个高频衍生问题是 LockSupport.park 和 Object.wait 的区别。park 不释放当前线程已经持有的锁它等待的是“许可”wait 必须放在 synchronized 块内会释放 monitor并且依靠 notify/notifyAll 唤醒。使用 Condition 时底层用的就是 park/unpark 机制理解了这一层就不会再问出“为什么 await 之后还能被别的线程抢到锁”这种问题。2. synchronized 的锁升级链路与两个过时观念synchronized 大概是 Java 并发面试里出现频率最高的关键字。可惜的是很多资料讲锁升级还在用十年前的结论导致不少人拿着老黄历回答问题。先把最新的结论放前面偏向锁从 JDK 15 开始默认禁用JDK 18 之后基本移除现在你在新版 JDK 上分析锁状态实际上只有无锁、轻量级锁、重量级锁三个阶段。但这不影响我们完整理解升级链路的思路因为存量代码和面试题里仍然大量出现这一整套演进历史。2.1 从无锁到重量级锁的完整路线JDK 6 之前的 synchronized 是纯粹的重量级锁每次加锁都直接走 ObjectMonitor 的竞争流程涉及线程阻塞和操作系统唤醒效率很低。HotSpot 从 JDK 6 开始引入锁升级机制来做优化整个链条大致是这样阶段触发条件开销解除方式无锁对象刚创建无竞争无一直无锁偏向锁同一个线程反复获取同一把锁一次 CAS 写线程 ID极低有竞争时撤销偏向锁轻量级锁少量线程交替持有锁竞争不激烈CAS 抢锁 自旋低自旋失败或竞争升级时膨胀重量级锁多线程长时间竞争线程挂起唤醒高释放锁时唤醒后继节点偏向锁的设计思路很直白如果一把锁从头到尾只有一个线程在用那么每次加锁都做 CAS 都是浪费干脆在对象头 Mark Word 里记录这个线程的 ID之后该线程再进来就直接放行。但它的问题也很致命——一旦有另一个线程来竞争撤销偏向锁需要等到全局安全点SafePoint这个停顿在很多高并发服务上反而成了拖累所以后来被弃用。轻量级锁则是给“多线程交替执行”的场景准备的。线程在自己的栈帧里生成一个 Lock Record然后尝试 CAS 把对象头 Mark Word 改成指向 Lock Record 的指针。如果 CAS 成功就拿到锁如果失败说明锁已经被别人占用线程会自旋一段时间等待释放。自旋超过阈值还没抢到就膨胀为重量级锁。从这里能看出一个重要的性能真相synchronized 慢不慢取决于有没有走到重量级锁。2.2 “synchronized 性能不如 Lock”是过时观念我经常在技术群里看到有人提问“现在是不是都用 ReentrantLock 不用 synchronized 了”这个问题本身就是误区。JDK 6 之后 HotSpot 对 synchronized 做了大量优化包括锁升级、偏向锁后来废弃、锁消除、锁粗化加上底层调用已经非常轻量在低竞争场景下和 ReentrantLock 的性能差距几乎可以忽略甚至在部分场景下表现更好。那 Lock 的价值体现在哪体现在功能上。synchronized 是隐式的一旦进入临界区就只能执行完或者抛异常才能退出没有“尝试加锁”“超时放弃”的能力。ReentrantLock 则支持 tryLock(2, TimeUnit.SECONDS) 这种带超时的获取支持 lockInterruptibly 响应中断还能创建多个 Condition 条件队列实现精细的等待通知。简单互斥场景用 synchronized代码更简洁需要超时控制、可中断、多条件队列时再换成 ReentrantLock。选型依据是功能匹配度而不是过时的性能印象。还有一个经典扩展重量级锁的底层是 ObjectMonitorJVM 用 CXQ、EntryList、WaitSet 管理阻塞线程和等待线程。synchronized 同时承担了“锁 条件等待”两个职责而 ReentrantLock 把这两个职责拆开了——锁是 AQS条件是 Condition。理解这个拆分之后再看“为什么 Lock 可以有多个条件队列而 synchronized 只能有一个 wait set”答案自然就出来了。2.3 锁消除与锁粗化JIT 愿意帮你做的小动作很多人不知道 JIT 会帮我们优化 synchronized。锁消除发生在逃逸分析之后如果 JVM 判断一个对象只在线程内部使用根本不会被其他线程共享那它就会把 synchronized 块里的加锁动作直接去掉。一个最典型的例子是方法内部新建 StringBuffer 做局部字符串拼接StringBuffer 的 append 是线程安全的但对象没有逃逸出当前方法JIT 分析后很可能连锁都不加。锁粗化恰好相反它针对的是“同一把锁被反复加解锁”的问题。比如在循环体里对同一个对象加锁JIT 会把锁的范围扩大到循环外面减少加锁解锁的次数。这两个优化告诉我们写并发代码时不需要过度追求极端细粒度的锁JVM 会帮你做一部分优化但也不代表可以随意扩大锁范围因为粗化之后的临界区变长真实竞争激烈时阻塞的概率更大。核心思路仍然是能缩小锁范围就缩小把锁内部的耗时压到最低。3. 线程池参数调优与“16C32G 能撑多少并发”的估算逻辑热搜里经常有人问“16C32G 服务器支持多少并发”说实话这个问题本身没有标准答案因为它和业务类型、单请求耗时、中间件性能、内存模型都强相关。但我们可以用一套系统化的估算逻辑把“支持多少并发”从感性拍脑袋变成有依据的测算。这比直接背一个数字有意义得多。3.1 先按任务类型定线程数线程池最核心的参数是 corePoolSize它决定正常情况下保留多少个线程。业界流传最广的经验公式有两个CPU 密集型任务线程数 CPU 核数 1。因为线程只做计算CPU 一直在干活线程再多只会增加上下文切换。IO 密集型任务线程数 CPU 核数 × (1 平均等待时间 / 平均计算时间)。等 IO 的时间越长可以安排的线程就越多因为线程在等待 IO 时不占用 CPU。这个公式的逻辑基础很容易理解。一个线程发出数据库查询后要等 100ms 才能拿到结果这段时间 CPU 其实是空闲的如果线程池里只放 16 个线程CPU 就在那白等。多放一些线程让一个线程等 IO 时其他线程能继续用 CPU吞吐自然上去了。注意公式只是起点生产环境一定要配合压测来调整因为 JVM 本身还有 GC、锁竞争、内存分配这些额外开销。线程池的七个参数每一个都有实际含义corePoolSize 是核心线程数maximumPoolSize 是最大线程数keepAliveTime 决定非核心线程空闲多久回收workQueue 是等待队列threadFactory 决定线程命名和是否为守护线程handler 是拒绝策略。这七个参数不是独立存在的队列长度和最大线程数是一组配套设计先到先执行线程不够就进队列队列满了才创建临时线程临时线程也满才触发拒绝。3.2 队列与拒绝策略无界队列为什么往往是炸弹很多初学者喜欢用 Executors.newFixedThreadPool因为它写起来方便但内部使用的是 LinkedBlockingQueue 无界队列。无界意味着任务可以无限堆积当请求洪峰来临时线程池最大的线程数永远都用不上因为新任务都排队去了堆内存却在持续膨胀最终可能把服务直接打挂而且全程不会触发拒绝策略表现得“服务没死但请求全部卡死”。生产环境我自己的习惯是用有界队列比如 ArrayBlockingQueue容量根据业务承受能力设置同时配一个自定义的拒绝策略。默认的 AbortPolicy 会直接抛 RejectedExecutionException如果调用方没有捕获就可能影响正常业务流程。我推荐 CallerRunsPolicy它会让提交任务的线程自己执行被拒绝的任务。这样做的效果等于天然限流线程池忙不过来时调用方自己也得干活它的处理速度自然会降下来外部感知到的就是“系统背压了”。如果场景是希望任务不要堆叠、立刻开新线程处理可以用 SynchronousQueue 配合较大的 maximumPoolSize。SynchronousQueue 内部没有容量每个任务必须直接交给某个线程处理所以线程池会快速创建临时线程来应对高峰。优点是延迟低、无堆叠缺点是线程数量可能暴涨需要设置好 keepAliveTime 及时回收。3.3 用 Littles Law 估算并发承载量回到“16C32G 能撑多少并发”这个问题。先分清两个概念并发数和 QPS 不是一回事。QPS 是每秒处理的请求数并发数是在同一个时刻系统里正在处理的请求数。它们之间有一个经典关系叫 Littles LawL λ × W。L 是平均在途请求数λ 是吞吐率W 是平均响应时间。举个例子假设业务接口平均响应时间是 100ms目标是支撑 2000 QPS那么在途并发请求数就是 2000 × 0.1 200。也就是说系统的线程池需要同时处理大约 200 个请求才能撑住这个吞吐量。如果你的线程池只有 50 个线程那理论 QPS 上限就是 500怎么调 JVM 参数都突破不了这个上限。反过来也一样延迟越低同样的线程数就能扛更高 QPS所以优化接口性能往往比盲目加线程数更有效。那 16C32G 到底多少合适根据上面的公式如果任务是 IO 密集型16 核机器线程池可以配置到 32~64 个活跃线程每个线程默认栈大小是 1MB200 个线程约占用 200MB 内存32G 完全够用。但还要算上堆内对象、线程池队列、连接池占用、GC 预留所以线上建议先按单线程内存占用粗略估算再用压测找出真实瓶颈。如果是 IM 这种长连接场景并发数的含义又变了它更多指连接数而不是业务请求数Netty 用少量线程管理数万连接是很正常的不能套用 HTTP 请求的线程池公式。3.4 压测验证JMeter 的并发设置怎么理解热搜里有不少人在问“JMeter 怎么进行接口并发测试”这里简单说下关键设置。线程组里的线程数代表模拟的并发用户数Ramp-Up Period 是启动这些线程所用的时间。比如 200 个线程、Ramp-Up 设 10 秒就表示 10 秒内把 200 个并发用户逐步拉起来。压测不能只看最终总请求数要阶梯加压先 50 并发跑 3 分钟再 100 并发再 200 并发看哪个拐点上 RT 突然上涨、错误率开始出现。如果想模拟不同参数的请求用 CSV Data Set Config 做参数化就可以了。压测的时候盯的指标优先级是CPU 使用率、GC 频率、RT 的分位数p95、p99、错误率、线程池活跃线程数。如果发现线程池出现大量排队而 CPU 还没跑满通常不是线程数不够而是下游依赖数据库、外部接口的延迟太高此时加线程数只会让下游更堵应该优先优化下游时长或者做限流降级。4. 从库存超卖场景看 Java 锁、数据库锁与 Redis Lua 的边界“ERP 库存场景高并发的解决方案”“数据库并发锁”“Java 怎么保证数据一致性”这些热搜词背后都指向同一个经典业务场景库存扣减。这个场景既适合讲并发原理也适合讲不同层面锁的边界想清楚之后很多电商、ERP、报名系统的并发问题都能举一反三。4.1 超卖问题到底发生在哪一步库存超卖的根因可以浓缩成一句话检查库存与扣减库存没有构成一个原子操作。典型的流程是这样的从数据库读取当前库存比如 stock 1。判断 stock 1库存充足进入扣减逻辑。执行 update 将 stock 减为 0。两个请求同时走到第 1 步都读到 stock 1都判断库存充足然后都执行扣减最终库存就变成了 -1。这不是“并发编程”独有的问题而是“检查与动作分离”带来的竞态条件。理解了这一步再看各种解决方案你会发现大家做的事情本质上都是同一个目标把“检查库存是否足够”和“扣除库存”合并成一个不可分割的步骤。4.2 数据库层的三种常用做法数据库层解决超卖常见有三种方案适用场景完全不同。第一种是悲观锁使用 select ... for update 锁定库存记录直到事务提交才释放。优点是逻辑简单直接不容易出错缺点是事务持锁时间太长并发吞吐极低还容易出现锁等待和死锁。适合后台管理端对账、发货这种低并发场景不适合高并发抢购。第二种是乐观锁通常用版本号实现update t set stock stock - 1, version version 1 where id ? and version ?。执行失败说明版本号已经变了需要重试。它依赖冲突很少发生的假设冲突多了重试成本会很高。第三种是我在业务中最常用的原子扣减update t set stock stock - n where id ? and stock n。这条 SQL 直接利用了数据库单条 update 的原子性where 条件里的 stock n 保证了“库存不足时不更新”。由于 InnoDB 会锁定命中的那行记录并发更新会被串行化后面的 update 执行时 stock 已经被减掉了where 条件不成立自然扣不动。这是成本最低、效果最直接的做法。注意这个方案的前提库存字段所在的表必须有可用索引否则锁的范围会扩大事务要尽量短不要在事务里调用外部接口或者做耗时计算否则还是会被数据库行锁拖死。4.3 分布式场景Redis Lua 扣减与核心注意点如果库存提前加载到了 Redis比如热点商品预热那么 Java 层的锁全都失效了因为多个应用节点各自有各自的 JVM 锁根本锁不到一起。这时候只能依赖 Redis 自身的原子性。Redis 单线程执行命令Lua 脚本可以保证多条命令在同一个执行流程里完成不会被其他客户端命令插队。简单扣减脚本可以这样写local stock tonumber(redis.call(GET, KEYS[1])) if stock and stock tonumber(ARGV[1]) then redis.call(DECRBY, KEYS[1], ARGV[1]) return 1 end return 0脚本里的判断和扣减是原子执行的不会出现两个请求同时读到同一个剩余库存然后都扣减成功的情况。使用时有几个容易翻车的点一是从 MySQL 到 Redis 的库存预热要设置合理的过期时间避免缓存和数据库长时间不一致二是扣减成功后需要异步把最终结果同步回数据库最常见的方式是发送 MQ 消息由消费端更新或者定时做对账三是 Lua 脚本要写到 Redis Cluster 里时必须保证所有 key 在同一个哈希槽否则会报跨 slot 错误。4.4 另一个容易被忽视的问题扣减成功之后的链路一致性有了原子扣减超卖问题解决了但业务链路往往不止一步。比如用户下单后先扣库存再创建订单再调支付。如果扣库存成功、创建订单失败库存就得回补如果回补失败账面上就少了库存。这种跨多个子系统的数据一致性已经超出了单点并发方案的能力边界需要依赖本地消息表、MQ 事务消息或者 Seata 这类分布式事务框架来兜底。面试里能把“原子扣减解决了超卖但跨系统最终一致性需要另外设计”这句话说清楚比单纯背十种锁更有价值。因为生产环境最怕的就是把解决方案用错了边界拿 JVM 锁去解决集群并发问题或者拿一个分布式事务去扛高并发扣减都是典型的架构级翻车。5. 并发场景下最容易翻车的几个细节前面几节聊的偏原理和场景最后再整理几个我在实际项目里踩过、也看别人踩过的小坑。这些细节往往不在教科书的第一屏但线上出了问题很多时候就藏在它们身上。5.1 ThreadLocal 的内存泄漏与正确清理姿势ThreadLocal 的原理是用线程自己的 ThreadLocalMap 保存数据Entry 的 key 是 ThreadLocal 对象的弱引用value 是强引用。当外部不再持有 ThreadLocal 对象时key 会被 GC 回收但 value 仍然强引用在 Entry 上。如果这个线程是业务线程池里的线程长期存活那这些 value 就永远无法被回收时间一长就变成老年代里的“内存黑洞”。我见过不止一次线上案例一段定时任务代码用 ThreadLocal 存了用户上下文跑完后没有 remove线上稳定运行几周后老年代持续上涨最后 Full GC 频繁服务响应变慢。排查定位到 ThreadLocal 时才发现是当年无意间埋的雷。正确做法很简单无论正常返回还是抛异常都在 finally 里调用 remove。尤其是线程池复用线程的场景set 之后不清理等于给其他请求留下了脏数据。5.2 CAS 的 ABA 问题大多数情况下其实没你想的严重CAS 有个著名的先天缺陷叫 ABA 问题值从 A 变成 B 又变回 ACAS 会因为看到的值是 A 就认为没被改过。教科书给出的标准解法是使用 AtomicStampedReference 加版本号。但我想说的是真实业务里 ABA 并没有那么常见大多数简单状态位的场景根本不需要防 ABA。比如一个订单状态字段从“待支付”变成“已支付”再从“已支付”变回业务上本身就不允许ABA 自然不可能发生。真正需要担心的是链式结构或涉及外部资源的场景比如用 CAS 操作一个链表头节点同一个地址被回收后重新分配就可能把无效节点误判为有效节点。所以不要一听到 ABA 就去加版本号先想清楚“这个值在业务上被改了个循环回来后结果是否等价”。如果等价那 ABA 只是一个理论问题不是实际风险。5.3 CompletableFuture 的默认线程池陷阱CompletableFuture 确实是异步编程的利器但它的默认执行器是 ForkJoinPool.commonPool并行度等于 CPU 核数减一。这个线程池适合处理 CPU 密集型任务一旦你在 supplyAsync 或 thenApplyAsync 里放了远程调用、数据库查询、HTTP 请求commonPool 的线程会被 IO 阻塞占满其他依赖 CompletableFuture 的异步任务全部排队整个应用的异步响应都会变慢。这是我踩过最频繁的一个坑一个接口里同时跑了三个并行任务每个任务都要查数据库上线后压测发现 CPU 使用率很低但接口 RT 却很高一查线程栈所有 commonPool 线程都卡在数据库连接等待上。后来改成显式传入自定义线程池性能立刻恢复正常。代码非常简单ExecutorService bizPool new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(200), r - new Thread(r, biz-async- r.hashCode()) ); CompletableFuture.supplyAsync(() - queryOrder(), bizPool)我给所有异步任务都单独建了带业务前缀的线程池这样线程名写进日志里线上排查时一眼就能看出是哪个线程池在拖后腿。记住这一点比背一百道八股题都实在。