搞 Java 这么多年,回回面试几乎都绕不开并发。工作里更是躲不掉:线上库存超卖、缓存被“打爆”、接口突然变慢、数据对不上账……说实在的,并发相关的坑,十有八九不是因为你不会用synchronized或者ConcurrentHashMap,而是因为你对“某个具体业务场景下并发问题是怎么一步步发生的”缺乏体系化的理解。
这篇文章我想换个思路聊 Java 并发。不讲那些嚼烂了的volatile可见性原理,也不罗列AQS源码,而是从工作里最常遇到的几个并发场景切入,把“场景长什么样、问题为什么会发生、标准解法是什么、面试官还可能会追问什么”串成一条线。不管你是刚准备面试,还是已经工作两年想补一补实战短板,这篇文章都适合你。
1. 从面试到实战:并发场景到底在考什么
很多人在准备 Java 并发面试时有个误区:以为并发就是背八股,什么CAS、AQS、synchronized锁升级,能说出个大概就足够了。可真到面试官问一句“你们项目里的库存扣减是怎么防超卖的”,或者“缓存击穿你实际是怎么处理的”,立刻就觉得浑身难受。为什么?因为八股讲的是“知识点”,而面试官和工作要的是“场景解法”。
在真实业务里,并发不是一个独立的技术栈,而是“多线程同时访问共享资源”时引发的一系列连锁反应。同一个ConcurrentHashMap,在缓存击穿场景里能不能用锁解决?同一个线程池,在异步编排场景里参数该怎么定?同一个 Redis 分布式锁,在并发量波动大的时候会不会抖动?这些问题没有一个能靠死记硬背解决,全得靠“理解场景 → 拆解并发点 → 选合适方案 → 预留容错”这条链条来解决。
所以这篇文章我干脆把高频场景按业务类型拆开来讲。每个场景会先还原一个实际业务画面,然后分析并发是怎么把画面搞砸的,再给出可以直接落地的方案,最后把面试官顺着这个话题容易追问的问题也一起点了。等这套场景串下来,你再回头看并发知识点,会明显感觉到是“知识在业务里长了脚”,不再是悬空的理论。
2. 场景一:库存扣减与超卖问题
2.1 超卖是怎么发生的
先还原一个最经典的电商场景:某商品库存只剩 5 件,结果同一瞬间有 10 个用户同时下单。如果你的逻辑是先查库存select stock from product where id = ?,然后判断if (stock > 0),再执行update product set stock = stock - 1 where id = ?,那么在并发量大时,完全有可能出现多个请求同时读到库存为 5,然后一起认为“还有库存”,一起执行扣减,最终卖出去了 8 件甚至 10 件。
这就是典型的“读改写”竞态,也叫 check-then-act。问题根源不在于哪一步写错了,而在于“检查库存”和“扣减库存”这两个动作不是一个原子操作。两个线程可以在中间任意切换,导致检查结果全部基于同一个旧值。
在项目里第一次遇到超卖时,我第一反应是加synchronized,把扣减逻辑锁住。这能解决单机问题,但很快会碰到两个新问题:一是性能,所有下单请求全串行化了,压测一上 QPS 立刻难看;二是分布式环境下,请求会经过负载均衡打到不同机器上,JVM 锁只对本机线程生效,根本锁不住别的机器上的线程,超卖照旧。
2.2 从数据库层面解决超卖
既然要原子性,最简单的思路就是让数据库帮我们完成“检查 + 扣减”的原子操作。SQL 层面常见的招数是乐观锁和直接条件更新。
乐观锁版本号方案:给库存表加一个version字段,每次更新携带旧版本号:
update product set stock = stock - 1, version = version + 1 where id = ? and version = ?;这条 SQL 的意思是:只有当版本号还是读取时的那个值时才更新,否则影响行数为 0,程序就能感知到更新失败,提示用户“手慢了,库存不足”。好处是并发能力比悲观锁强,坏处是失败后要重试,写代码时要额外处理重试逻辑。
直接条件更新方案:不加版本号,直接在 SQL 里带库存条件:
update product set stock = stock - 1 where id = ? and stock > 0;影响行数等于 1 就是扣成功了,等于 0 就是库存不够。这种写法更简洁,也是很多高并发秒杀系统在数据库层兜底的经典手段。你必须清楚,它只解决了“扣减”这一步的原子性,如果后续流程里还有“创建订单、清购物车、通知仓库”等一大堆操作,数据库行锁持有时间会很长,可能会拖垮数据库。
实际项目中我通常这么设计分层:秒杀等高并发入口不直接打数据库,而是先走 Redis 预扣减,再用异步消息把最终扣减结果同步回数据库。Redis 的decr是原子操作,天然适合做库存水位控制。但 Redis 不是数据库,Redis 扣成功了不代表数据库一定扣成功,所以必须有一个对账补偿机制,把两边数据最终对齐。这个在 2.3 节我会展开讲。
2.3 缓存 + 异步扣减的进阶方案
当单库行锁扛不住流量时,就得引入缓存前置。
整体链路设计大概是:
- 商品库存初始化时,把库存数量写入 Redis,比如
stock:1001 = 5。 - 用户下单时,先调用
redisTemplate.opsForValue().decrement("stock:1001"),返回值大于等于 0 表示扣减成功。 - 扣减成功后再发一条 MQ 消息,内容包含商品 ID、扣减数量、订单号。
- 独立消费服务收到消息后,再对数据库执行扣减,并更新订单状态。
- 如果数据库扣减失败,要能触发补偿,把 Redis 库存回滚或标记异常单。
这里有一个必须说的坑:Redis 扣减和数据库扣减不是原子的,中间任何一个环节挂了都可能导致两边不一致。常见的应对就是把 Redis 当作“流量过滤层”,数据库当作“最终事实层”,通过消息的可靠投递 + 消费幂等来做最终一致。
幂等怎么做?最稳妥的办法是在订单表里加一个唯一业务编号,比如order_no,消费消息时先insert一条带唯一键的记录,成功了才继续更新库存;重复消费时唯一键冲突直接忽略。我第一次做这块时偷懒没做幂等,结果网络抖动导致 MQ 重复投递,库存被多扣了一次,对账时查了很久才定位到。这个教训非常深刻。
2.4 这个场景面试官还想听到什么
面试官问完“怎么防超卖”,通常会顺着往下追问几个点:
- Redis 扣减和数据库扣减不一致怎么办:你要能说出最终一致 + 对账 + 补偿,不能只说“发 MQ 就行”。
- 为什么不用
synchronized:这题考的是你对锁粒度和分布式环境的理解。 - 乐观锁失败重试会不会导致数据库压力大:如果直接 while 重试,确实会放大压力,应该限制重试次数,或者在缓存层做前置过滤。
- 库存预热怎么做:也就是 Redis 里的初始库存从哪里来,怎么防止活动开始瞬间缓存没有数据导致全部请求打到数据库。
这些问题本质上是在验证你有没有真的处理过并发场景,而不是只会背“乐观锁乐观锁”。能把“缓存水位 + 最终一致性 + 幂等消费”这一整套链路讲清楚,面试官对你的实战判断力会明显高看一线。
3. 场景二:缓存并发三兄弟——穿透、击穿、雪崩
3.1 怎么准确区分三种异常
缓存问题经常被混在一起说,但先得把三个概念理清楚:缓存穿透、缓存击穿、缓存雪崩。
缓存穿透:查询一个根本不存在的数据。缓存和数据库里都没有,所以每次请求都会跑到数据库,流量一大数据库就扛不住了。比如恶意攻击者拿一堆不存在的用户 ID 刷接口。
缓存击穿:某一个热点 key 的缓存刚好在某一刻过期,而那一刻有大量请求同时访问这个 key。请求全部穿透到数据库,数据库瞬间压力飙升。注意关键词:一个热点 key、过期瞬间、高并发同时访问。
缓存雪崩:一大批 key 在同一时间段集中过期,或者缓存节点直接挂了,导致大量请求打到数据库。和击穿的区别是“数量级”不同,雪崩是整个范围的 cache 失效,不是某一个 key。
这个区分我在面试里被问过无数次,很多人容易把击穿和雪崩搞混。我自己的记忆方式是:击穿是“单点狙击”,雪崩是“群体塌方”。穿透是“查了个寂寞”,根本没有数据,自然存不了缓存。
3.2 缓存击穿的互斥锁与逻辑过期
针对击穿,最经典且面试高频的方案有两种:互斥锁和逻辑过期。
互斥锁的思路是:当发现缓存没有数据时,不是所有请求都抢着去查数据库,而是只有一个线程能拿到锁,其他线程短暂等待或返回旧值。可以用 Redis 的SETNX实现,也可以用本地锁实现,但分布式环境必须用 Redis 锁。
public String getData(String key) { String value = redis.get(key); if (value != null) { return value; } String lockKey = "lock:" + key; boolean locked = redis.setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS); if (!locked) { // 没抢到锁:短暂休眠后重查缓存 Thread.sleep(50); return redis.get(key); } try { // 双重检查,防止上一个线程已经重建了缓存 value = redis.get(key); if (value != null) { return value; } // 查数据库并回填缓存 value = queryDb(key); redis.set(key, value, 30, TimeUnit.MINUTES); return value; } finally { redis.delete(lockKey); } }这段代码要注意两个细节:一是抢到锁后要“二次检查”,因为可能上一个持锁线程刚好已经重建完缓存,不检查的话会白白查一次数据库;二是锁一定要设置过期时间,否则持锁线程异常宕机,锁永远不释放,整个接口就死锁了。
逻辑过期方案不太一样:缓存里存的数据不设置 TTL,而是额外存一个逻辑过期时间字段。比如 value 是 JSON,里面带expireTime。读的时候发现逻辑时间已经过期,不会立刻删缓存,而是尝试获取互斥锁,抢到锁的线程去更新缓存;没抢到锁的线程直接返回旧数据。这个方案的好处是“读多写少”场景下用户体验更好,坏处是数据在短暂时间窗口内不一致,要业务能忍受。
我在项目中用逻辑过期比较多,因为很多热点数据即使过期几十毫秒,对用户来说几乎没有感知,但互斥锁方案高并发下等待线程的数量一多,等待时间可能还挺明显的。
3.3 实际项目中的组合拳
真实项目不会只靠某一个方案,而是多种手段组合使用:
- 缓存穿透:用布隆过滤器前置拦截不存在的 key,或者在查不到数据时也写一个短期空缓存,比如 TTL 60 秒。
- 缓存击穿:热点 key 预热 + 互斥锁 / 逻辑过期兜底。
- 缓存雪崩:过期时间加随机值,比如
30 + new Random().nextInt(300)秒,避免大批 key 同时过期;同时对缓存集群做主从或哨兵,防止节点单点故障。
还有一个容易忽略的点:热点 key 的识别。你可以在 Redis 侧开启hotkey分析,或者在代理层统计 key 的访问频次。提前识别热点 key 后做本地缓存(比如caffeine一级缓存),效果非常明显。我遇到过某个商品详情页接口在促销时 QPS 从几百飙到几万,Redis 访问压力极大,后来加了本地缓存 + 短 TTL,Redis 压力直接降了一个量级。
4. 场景三:线程池与异步编排实战
4.1 线程池参数真的要用默认的吗
工作中聊到异步任务,十个里八个会甩一句“我直接用Executors.newFixedThreadPool不就完了吗”。这句话在项目里非常危险。Executors提供的快捷方法有很多坑:newFixedThreadPool用的是无界LinkedBlockingQueue,任务积压时队列无限增长,打爆内存是迟早的事;newCachedThreadPool用的是SynchronousQueue,能创建的线程数没有上限,突发流量一来直接创建几百上千个线程,系统资源很快被耗尽。
除非你在写 Demo,否则线程池一定自己建:
ThreadPoolExecutor executor = new ThreadPoolExecutor( 8, // 核心线程数 16, // 最大线程数 60L, TimeUnit.SECONDS, // 空闲线程存活时间 new ArrayBlockingQueue<>(100), // 有界队列 new ThreadFactoryBuilder().setNameFormat("biz-pool-%d").build(), new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 );参数为什么这么定?核心线程数参考任务类型,如果是 CPU 密集型任务设CPU 核数 + 1左右,如果是 IO 密集型可以适当放大到核数两倍甚至更高。最大线程数等于“核心线程数 + 队列容量能承载的缓冲余量”,前提是你要清楚队列满了之后拒绝策略怎么处理。拒绝策略里AbortPolicy会直接抛异常,你得在代码里捕获;CallerRunsPolicy是把任务交给调用方线程执行,相当于变相降速,更适合对数据不丢有要求的场景。
setNameFormat也很重要,线程名一旦可辨识,排查问题时看日志一眼就能判断是哪个池子的线程,否则只会看到pool-3-thread-5这种毫无信息量的名字。这个习惯我在早期项目里没养成,后来线上出问题时对着一堆匿名线程找代码,痛苦得不行。
4.2 常见异步编排场景:并行查询
异步编排最多见的场景是“一个接口要聚合多个数据源”。比如用户详情页要同时查用户基本信息、订单列表、优惠券、地址,这几个数据分别来自不同的服务或表。串行查询的话,总耗时是四个查询时间相加;如果改成并行查询,总耗时约等于最慢的那个查询。
用CompletableFuture做并行编排是很顺手的:
CompletableFuture<UserInfo> userFuture = CompletableFuture.supplyAsync(() -> userService.getUser(userId), executor); CompletableFuture<List<Order>> orderFuture = CompletableFuture.supplyAsync(() -> orderService.getOrders(userId), executor); CompletableFuture<List<Coupon>> couponFuture = CompletableFuture.supplyAsync(() -> couponService.getCoupons(userId), executor); CompletableFuture.allOf(userFuture, orderFuture, couponFuture).join(); UserInfo user = userFuture.get(); List<Order> orders = orderFuture.get(); List<Coupon> coupons = couponFuture.get();这段代码看着简单,坑在join和get。如果其中一个 future 抛异常,join会直接抛CompletionException,你得在外面包一层try/catch或者用exceptionally给每个子任务一个兜底值。更常见的问题是:如果没传自定义线程池,CompletableFuture.supplyAsync默认使用ForkJoinPool.commonPool(),这是全 JVM 共享的线程池,任务一多会被别的模块拖累,也可能拖累别人。线上环境一定显式传线程池。
4.3 异步编排中的异常处理
我早期做异步编排时,觉得“反正每个子任务都 try/catch 了就行”,结果漏了一个很重要的点:父任务对子任务的异常感知。如果子任务静默 catch 掉异常后返回 null,上游拿到 null 还要做各种空判断,一旦漏判,NPE 又来了。
更好的做法是让子任务按“成功返回数据、失败返回默认值或可辨识的失败标记”来设计,用CompletableFuture的handle或者exceptionally统一处理:
CompletableFuture<List<Order>> orderFuture = CompletableFuture.supplyAsync(() -> orderService.getOrders(userId), executor) .exceptionally(ex -> { log.error("query orders failed", ex); return Collections.emptyList(); });这样主链路不用关心某个子查询失败,并且日志里有明确的异常堆栈。对接口整体而言,某个非核心数据挂了不应该拖垮整个页面,返回空列表比直接报错更合理。但注意“非核心”三个字,如果是核心数据查询失败,绝不能静默吞掉,必须让主链路感知并返回失败。
另外,每个子任务最好都设置超时时间:orTimeout(3, TimeUnit.SECONDS)。这个orTimeout很实用,超时会触发TimeoutException,配合异常处理可以防止一个慢接口把整个聚合接口拖死。
5. 场景四:分布式锁的那些坑
5.1 为什么需要分布式锁
有些并发场景,多个进程(或服务实例)共享同一份资源,比如一个定时任务在多台机器上都部署了,如果没锁,任务到点后每台机器都会执行一遍。再比如两个服务同时操作同一个用户余额,单机锁根本管不住别的主机。这时需要一把“跨进程”的锁,也就是分布式锁。
常见实现有基于数据库、基于 ZooKeeper、基于 Redis 三种。数据库实现最简单但不适合高并发,因为依赖唯一索引和事务,性能上不去。ZooKeeper 的锁基于临时顺序节点,可靠性好,但多一次网络交互,性能不如 Redis。所以大多数项目首选 Redis 分布式锁。
5.2 基于 Redis 的分布式锁与 Redisson
用 Redis 实现分布式锁要满足两个基本要求:互斥和可释放。最简单的加锁命令是:
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 30, TimeUnit.SECONDS);注意这里有个大坑:value 不要随便存一个固定值,而是要存一个唯一标识,比如UUID。为什么?因为释放锁时要校验“是不是我这把锁”,如果锁被后来者覆盖了,前面线程的删除操作可能会误删别人的锁。用唯一标识可以在删除前做一次校验:
String value = redisTemplate.opsForValue().get(lockKey); if (clientId.equals(value)) { redisTemplate.delete(lockKey); }但这里仍有原子性问题:“获取 value + 校验 + 删除”三步操作不是原子的。如果正好在校验完还没删除时锁过期了,新线程又设置成功,当前线程执行 delete 就把新线程的锁删掉了。所以删除必须用 Lua 脚本保证原子性。
绝大多数项目不会自己写 Lua,而是直接上 Redisson。Redisson 内置了RLock,加锁、释放、续期都封装好了:
RLock lock = redissonClient.getLock("lock:user:" + userId); boolean locked = false; try { locked = lock.tryLock(2, 30, TimeUnit.SECONDS); if (!locked) { throw new BizException("系统繁忙,请稍后再试"); } // 业务逻辑 } finally { if (locked && lock.isHeldByCurrentThread()) { lock.unlock(); } }Redisson 的tryLock(waitTime, leaseTime, unit)里有个“看门狗”机制:默认如果没指定 leaseTime,锁会有一个 30 秒的自动续期逻辑,防止业务执行到一半锁过期了,别的线程趁虚而入。这个设计非常实用,但要注意一点:如果你手动指定了 leaseTime,看门狗就不会续期,得自己保证业务时间不超过 leaseTime,否则锁会在业务执行期间失效。
5.3 锁的粒度、续期和可重入
使用分布式锁,最容易出问题的其实是“锁粒度”。锁粒度太粗,比如把所有用户余额操作都锁同一把锁lock:balance,必然导致大量请求互相排队,性能直线下降。锁粒度太细,比如按订单项加锁,可能又锁不住需要跨多项操作的场景。
我自己的经验是:先找“最小共享资源单位”,再让锁的 key 尽量贴合这个单位。比如操作单个用户余额:lock:user:{userId};秒杀商品库存:lock:stock:{skuId}。如果业务上需要锁住“用户 + 商品”这种组合维度,key 可以设计成lock:user:{userId}:sku:{skuId}。这里没有标准公式,核心是让锁的粒度尽量贴近被保护的临界区。
还有可重入问题。如果一个线程已经持有锁,同一个线程再次加同一把锁,如果不支持重入,就会死锁。Redisson 的RLock是支持可重入的,内部会记录加锁次数。而自己用SETNX实现的锁默认不支持可重入,需要自己用ThreadLocal计数。这也是我为什么建议团队直接用 Redisson,别自己造轮子的原因之一。
6. 面试追问清单与排障工具
6.1 一页纸梳理的追问路线
面试时面试官不会只问“你会不会用”,更爱顺着场景往下追问。我整理了一份自己复盘用的追问路线,适合用来查漏补缺:
| 场景入口 | 常见追问 | 考察点 |
|---|---|---|
| 库存扣减 | 乐观锁还是悲观锁?失败了重试吗?Redis 和 DB 怎么对齐? | 原子操作、最终一致性、幂等 |
| 缓存击穿 | 互斥锁和逻辑过期各自优缺点?热点 key 怎么发现? | 缓存策略、锁的合理使用 |
| 缓存穿透 | 布隆过滤器原理?空缓存 TTL 设置多久? | 数据结构基础、工程权衡 |
| 线程池 | 核心线程数怎么定?拒绝策略选哪个?队列为什么用有界? | JVM 内存认知、压测经验 |
| 异步编排 | 子任务异常怎么处理?超时怎么做?哪些任务该并行? | CompletableFuture 熟练度 |
| 分布式锁 | 锁过期了怎么办?看门狗原理是什么?可重入怎么实现? | Redisson 细节、分布式一致性 |
这份清单不是让你背答案,而是每个问题都能结合自己的项目讲出一个真实发生的场景。比如“锁过期了怎么办”这个问题,你可以说:“我们有一次业务执行时间超过了锁的 leaseTime,导致锁提前释放,后来换成了 Redisson 的看门狗模式,并且给业务添加了超时监控。”这种回答比背一百遍原理都管用。
6.2 常用排障手段
工作里遇到并发问题,光会写代码不够,还得会查问题。我自己的排障顺序一般是:
- 先看日志有没有异常堆栈:很多并发问题会以
Exception的形式暴露出来,比如TooManyResultsException、DeadlockLoserDataAccessException、RedisConnectionFailureException。日志是最快定位入口。 - 再看线程状态:
jstack抓现场,看是否有大量BLOCKED或WAITING线程。如果很多线程卡在同一个锁对象上,基本可以断定是锁竞争或死锁问题。 - 再看监控曲线:QPS、RT、线程活跃数、GC 频率。并发问题往往伴随着 RT 突刺或 GC 异常,监控曲线能帮你快速判断是锁竞争、资源瓶颈还是内存问题。
- 最后用压测复现:本地写并发脚本,比如用
CountDownLatch模拟 N 个线程同时请求,复现不了的话可以尝试降低阈值或增加循环次数。
还有一个很容易被忽略的技巧:线上并发问题如果一时定位不到,先保留现场。不要把服务直接重启,至少把jstack、jmap、GC 日志保留下来,很多问题重启后就没法再查了。我第一次处理死锁时,就是太着急重启了服务,导致再也抓不到现场,后来学乖了,先备份现场再处置。
7. 从问题到方案:我的一点个人体会
回到最初那句话:Java 并发问题从来不是单一知识点的考查,而是综合能力的体现。我见过不少开发者能背出synchronized的锁升级过程,但在真实项目里面对一个热点 key 过期时,依然不知道用互斥锁还是逻辑过期。反过来说,如果你能把每个业务场景的并发问题拆解成“共享资源是什么、并发点在哪里、方案怎么选、挂了怎么兜底”,哪怕原理细节记得不那么精确,解决问题时也比只会背八股的人强很多。
我个人在实践里形成的一个习惯是:每接手一个新项目,先画一张“并发风险点清单”,把库存、缓存、异步任务、分布式锁、幂等这几个方向列出来,逐个标注当前实现用了什么手段、还有什么风险。有了这张清单,不管是日常开发还是面试复盘,都清晰很多。
这些话可能有点偏“方法论”,但确实是我踩过不少坑之后最想分享的东西。并发这块没有银弹,唯一靠得住的就是对每个场景都多问几个“为什么”,然后亲手去压测、去线上排查、去优化。希望这篇内容能帮你在下一个项目或下一次面试里,少走几步弯路。