主题
并发问题怎么想:从串行瓶颈到锁粒度取舍的设计方法论
面试总有人问「你怎么设计一个 XX」
面试里「设计一个高并发 XX」几乎必考。秒杀扣库存、热点计数、日志合并写……换着花样来,但核心问题就一个:共享可变状态在并发下怎么保证正确且高效。
很多人被问住不是因为不会写 synchronized,而是脑子里没有一套推导框架——上来就上锁,锁了又嫌慢,换成 CAS 发现 ABA 又慌了。到最后要么锁太粗性能垮了,要么代码太复杂自己都看不懂。
这篇不讨论具体 API,而是给一套可复用的决策树。再看设计题,按框架走一遍,至少不会跑偏。
第一步:能不能不共享
讨论并发安全之前,先问自己一个问题:这个状态真的需要共享吗?
大多数并发问题的根源不是「锁没用好」,而是「数据本来就不该共享」。能避免共享,比任何锁技巧都强。
线程封闭
每个线程只操作自己的独立副本,不接触其他线程的数据。最典型的例子是 SimpleDateFormat——它不是线程安全的,但如果在每个方法里 new 一个,GC 压力大。用 ThreadLocal 封闭成线程私有实例:
java
private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
// 每个线程拿到自己的实例,不用加锁
public String format(Date date) {
return DATE_FORMAT.get().format(date);
}ThreadLocal 的底层是 ThreadLocalMap,每个线程有自己的 Map 副本,天然无竞争。类似的还有数据库连接池里每个线程拿自己的连接、Web 请求上下文用 RequestContextHolder 存当前用户信息。
不可变对象
如果对象的状态在创建后就不变了,那它天然线程安全。不需要同步,不需要锁,所有线程看到的是同一个对象,但谁也没法改它。
java
// 不可变对象,线程安全
public final class UserInfo {
private final String userId;
private final String name;
private final List<String> roles; // 防御性拷贝
public UserInfo(String userId, String name, List<String> roles) {
this.userId = userId;
this.name = name;
this.roles = Collections.unmodifiableList(new ArrayList<>(roles));
}
public String getUserId() { return userId; }
public String getName() { return name; }
public List<String> getRoles() { return roles; }
}类声明 final 防止继承、所有字段 final、不暴露修改方法、集合字段做防御性拷贝返回。满足了这些,对象在多线程之间随便传,不需要任何锁。
事实:大部分读多写少的数据可以走这条分支
配置信息、路由表、权限元数据、字典数据——这些数据加载一次后基本不变。用不可变对象或者 CopyOnWrite 容器就够了,不需要上锁。
第二步:必须共享时,按代价排序
如果数据确实要共享(比如全局计数器、库存余额),那进入第二步。锁是有代价的——争用锁会引起上下文切换、缓存失效、线程阻塞。下面的策略按代价从低到高排列,优先选代价低的。
1. CAS + 原子类(最低代价)
对单个变量做原子读写,CAS 是硬件级别的原子操作,不涉及线程挂起。Java 的 AtomicInteger、AtomicLong、AtomicReference 等已封装好。
java
public class AtomicCounter {
private final AtomicLong count = new AtomicLong(0);
public long increment() {
return count.incrementAndGet();
}
public long get() {
return count.get();
}
}适用于:计数器、序号生成器、状态标记。CAS 的问题是自旋开销——高争用下大量线程空转消耗 CPU,这种情况下反而需要退回到锁。
2. 分段 / 细粒度锁
把一把大锁拆成多把小锁,每个小锁只保护一部分数据。ConcurrentHashMap 是经典案例——JDK 7 用 Segment 分段锁,JDK 8 改成桶锁(CAS + synchronized 只锁一个桶)。
java
// 分段锁的思路:一个简单实现
public class StripedCounter {
private final StripedLock locks = StripedLock.lazyWeakStripes(16);
private final long[] counters = new long[16];
public void increment(int key) {
int slot = key & 15; // 取模定位到段
Lock lock = locks.get(slot);
lock.lock();
try {
counters[slot]++;
} finally {
lock.unlock();
}
}
public long sum() {
long total = 0;
for (int i = 0; i < counters.length; i++) {
total += counters[i];
}
return total;
}
}Guava 的 Striped 锁可以做到按 key 分片,同一个 key 才会竞争同一把锁,不同 key 之间完全并行。适用于热点数据分布均匀的场景。
3. 读写分离
如果读远远多于写,用读写锁让读之间不互斥:
java
public class ConfigCache {
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private Map<String, String> config = new HashMap<>();
public String get(String key) {
rwLock.readLock().lock();
try {
return config.get(key);
} finally {
rwLock.readLock().unlock();
}
}
public void put(String key, String value) {
rwLock.writeLock().lock();
try {
config.put(key, value);
} finally {
rwLock.writeLock().unlock();
}
}
}读锁不互斥,100 个线程并发读也不会阻塞。写锁排他,写的时候阻塞所有读。适合配置中心、路由表这类读多写少的数据。如果读极高且写非常少,CopyOnWriteArrayList 的思路更激进——写的时候复制整个数组,读完全无锁。
StampedLock 在读写锁基础上加了乐观读,读的时候不加锁,只记一个版本号。写的时候检查版本号,如果版本号没变说明没有写并发,可以安全使用数据;如果变了则退化为悲观读锁:
java
long stamp = stampedLock.tryOptimisticRead();
int value = data; // 先读
if (!stampedLock.validate(stamp)) {
stamp = stampedLock.readLock();
try {
value = data;
} finally {
stampedLock.unlockRead(stamp);
}
}4. 单线程化热点
如果以上都不够,把热点串行化。不是用锁,而是用队列把并发请求转成单线程处理。
LongAdder 的底层就是热点分离 + 单线程化——它维护一个 base 值和一组 Cell 数组,每个线程更新自己的 Cell(CAS 操作自己那一个),最后 sum 时把所有 Cell 累加。JDK 8 引入后,在高并发计数场景下性能是 AtomicLong 的 5-10 倍。
第三步:异步排队削峰
有些场景不是因为锁不够高效,而是瞬时流量太大了,任何锁都扛不住。这时候思路要从「并发」转向「排队」。
典型的场景:秒杀扣库存。如果直接对数据库减库存,数据库行锁在几十万的 QPS 下必然崩溃。正确的做法是请求先入消息队列,后端单线程消费,库存扣减在消息队列里变成串行操作:
java
// 发送端:请求入队
public boolean trySeckill(String userId, String skuId) {
return mqProducer.send("seckill_order", userId + ":" + skuId);
}
// 消费端:单线程消费,不存在并发扣减问题
@RabbitListener(queues = "seckill_order")
public void handleOrder(String message) {
String[] parts = message.split(":");
String userId = parts[0];
String skuId = parts[1];
// 减库存 → 创建订单,全部在单线程里完成
inventoryService.deduct(skuId, 1);
orderService.create(userId, skuId);
}队列削峰的本质是用延迟换吞吐——把一瞬间的并发请求拉平到一段时间内处理。用户可能要多等几秒看到结果,但系统不会被打垮。
实战:三道上手设计题
题一:秒杀扣库存
需求:10 万用户抢 100 件商品,库存准确、不超卖、系统不崩。
推导:
- 能不能不共享?库存是全局资源,必须共享。pass。
- CAS 原子类?AtomicInteger 在 10 万并发下自旋爆炸,CPU 打满但吞吐上不去。pass。
- 分段锁?库存只有一个商品 ID,没法分。pass。
- 读写分离?写操作远多于读,读写锁也扛不住。pass。
- 单线程化排队:消息队列把请求串行化,消费端单线程逐一扣减。同时,库存预热到 Redis,用 Lua 脚本原子扣减以避免网络开销:
lua
-- Lua 脚本:Redis 原子扣库存
local key = KEYS[1]
local stock = redis.call('GET', key)
if stock and tonumber(stock) > 0 then
redis.call('DECR', key)
return 1
end
return 0最终方案:Nginx 限流 + Redis Lua 扣库存 + 消息队列异步落单。Redis 的 Lua 脚本保证原子性,QPS 可达 10 万级别。
题二:热点日志合并写
需求:100 个线程并发产生日志,最终写入同一个文件,不能丢行。
推导:
- 能不能不共享?每个线程写自己的临时文件,最后合并。可以,但合并时有 IO 瓶颈。
- 单线程化:生产者-消费者模式,各线程把日志写进阻塞队列,一个 IO 线程消费写入。实现简单,但单线程写 IO 受限于磁盘带宽。
- 更优解:Disruptor 无锁环形缓冲区 + 攒批写入。Log4j2 的异步 Appender 就是这么干的,每秒百万级吞吐。
方案:BlockingQueue 或 Disruptor 做队列,IO 线程攒批 flush。生产环境用 Log4j2 异步 Appender 开箱即用。
题三:热点计数
需求:统计每个商品 ID 的实时访问次数,QPS 100 万,读延迟要求 < 10ms。
推导:
- 能不能不共享?计数是全局的,必须共享。
- CAS 原子类?一个商品对应一个 key,AtomicLong 在单 key 高并发下自旋严重。
- 分段锁?LongAdder 已经做了:每个 Cell 只处理一部分请求,最后 sum 聚合。但 sum 是 O(n) 的,如果频繁读可能不合适。
- 读写分离?考虑用 Redis 的 INCR 命令,单线程处理,天然无竞争:
java
// Redis 单线程处理 INCR,天然无锁竞争
public long incrementViewCount(String productId) {
return redisTemplate.opsForValue()
.increment("view:count:" + productId);
}
public long getViewCount(String productId) {
return redisTemplate.opsForValue()
.get("view:count:" + productId);
}方案:Redis INCR(单线程处理,毫秒级延迟,撑住 10 万 QPS)或 LongAdder(本地内存,适合读低频场景)。如果既要高并发写又要低延迟读,考虑本地 LongAdder + 定期回写到 Redis。
总结
「并发设计怎么想」这件事,核心就这几步:
- 能不共享就不共享——线程封闭、不可变对象、CopyOnWrite,先排掉 80% 的问题
- 必须共享时按代价排序——CAS → 分段锁 → 读写锁 → 单线程化,用最轻的锁解决问题
- 流量太大就排队——消息队列削峰、Redis 单线程 INCR,把并发转成串行
- 最后才考虑锁的竞争优化——锁粗化、锁消除、减少锁持有时间、缩小锁粒度
面试设计题时,边说边推导。把决策树走一遍,告诉面试官你考虑了哪些方案、为什么选最后那个。
参考
- Brian Goetz《Java Concurrency in Practice》第 2-3 章
- Doug Lea 的《Concurrent Programming in Java》
- LongAdder 源码分析
- ConcurrentHashMap 1.7 vs 1.8
- 系列前篇:1000 线程写一个文件怎么设计