
线程安全这东西,看着是基础,实则是后端开发的“隐形门槛”。今天就结合我这几年踩过的坑、解决过的线上问题,给你掰扯清楚:到底怎么才能让多线程代码既安全又高效?尤其是3个经过实战验证的方法,学会了能让你少走80%的弯路。
从“锁”开始:最常用也最容易用错的线程安全方案
提到线程安全,90%的人第一反应都是“加锁”。但我见过太多开发者把“加锁”当成万金油,结果要么锁不住(安全问题),要么锁住了但性能崩了(效率问题)。其实锁就像家里的门锁,选错了型号、装错了位置,要么防不住贼,要么自己进出都费劲。
先搞懂“锁什么”:别让你的锁成了“虚设”
你肯定写过这样的代码:在方法上加个synchronized
,觉得万事大吉。但去年我帮一个支付项目排查问题时,发现他们的“转账接口”虽然加了synchronized
,但锁的是方法内部创建的局部对象——这就等于没锁!因为每个线程进来都会new一个新对象,线程间的锁根本不互斥。这就像你给每个访客发一把新钥匙,说“用这把锁门”,结果谁也锁不住谁。
正确的锁对象得满足“线程共享”:要么是类的静态对象(锁类),要么是实例对象(锁实例),或者用ReentrantLock
显式创建锁对象。比如下面这段代码,锁的是this
(当前实例),多个线程调用同一个实例的方法时才会互斥:
public class OrderService {
// 正确:锁当前实例,同一个实例的多线程会互斥
public synchronized void createOrder() {
// 订单创建逻辑
}
}
但如果你的服务是多实例部署(比如Spring Boot默认是单例,但集群环境下会有多个JVM实例),本地锁就失效了——这时候得用分布式锁,比如Redis的SET NX
或者ZooKeeper的临时节点,不过这是后话了,今天先聚焦单机线程安全。
锁的“粒度”:别把整个房间都锁起来
很多人加锁时图省事,直接把整个方法包起来,结果导致“大锁争用”。我之前接手一个物流系统,发现他们的“更新物流状态”接口用synchronized
修饰了整个方法,里面既包含数据库操作,又有调用第三方物流API的逻辑(耗时200ms+)。高并发时,线程全堵在锁这里,TPS直接从预期的500降到50,用户下单后半天看不到物流更新。
这就像你想进房间拿个充电器,结果把整个房子的门锁了,别人想进去喝口水都得等你——完全没必要。正确的做法是“锁粒度最小化”:只锁共享资源修改的那几行代码。比如上面的物流系统,其实只需要锁“更新数据库状态”这一步,调用第三方API的逻辑可以放外面:
public void updateLogisticsStatus(Long orderId, String status) {
// 非共享操作:调用第三方API(无需加锁)
LogisticsInfo info = logisticsApi.getInfo(orderId);
// 只锁共享资源修改部分
synchronized (this) {
// 共享资源:数据库订单状态
orderMapper.updateStatus(orderId, status, info.getTraceId());
}
}
如果你觉得synchronized
不够灵活,还可以用ReentrantLock
,它支持“尝试获取锁”(tryLock()
)和“可中断锁”,避免线程无限等待。比如下面这个例子,尝试5秒获取锁,拿不到就直接返回失败,比synchronized
死等更可控:
private final Lock lock = new ReentrantLock();
public boolean deductStock(Long productId) {
try {
// 尝试5秒获取锁,超时返回false
if (lock.tryLock(5, TimeUnit.SECONDS)) {
try {
// 扣减库存逻辑
return stockMapper.deduct(productId) > 0;
} finally {
lock.unlock(); // 必须在finally中释放锁!
}
} else {
log.warn("获取锁超时,商品ID:{}", productId);
return false;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 恢复中断状态
return false;
}
}
为了帮你理清synchronized
和ReentrantLock
的区别,我整理了一张表,你可以根据场景选择:
对比维度 | synchronized | ReentrantLock |
---|---|---|
锁释放 | 自动释放(代码块执行完或异常) | 手动释放(需在finally中unlock) |
灵活性 | 低(仅支持非公平锁,不可中断) | 高(支持公平锁/非公平锁、可尝试获取、可中断) |
性能 | JDK 1.6后优化(偏向锁/轻量级锁),低竞争下和ReentrantLock差不多 | 高竞争下性能更优,支持更细粒度控制 |
适用场景 | 简单场景,代码简洁优先 | 复杂场景(超时控制、中断、公平锁) |
读写分离:让“读”和“写”互不打扰
如果你的场景里“读多写少”,比如商品详情页(大量用户读取,少量管理员更新),用普通锁会导致读操作也被阻塞,体验很差。这时候可以用“读写锁”(ReentrantReadWriteLock
)——多个线程可以同时读,但写的时候会独占锁,读和写、写和写之间互斥。
我之前做一个内容管理系统,文章详情页每天有10万+访问量,但编辑每天只更新几十篇文章。一开始用synchronized
锁整个文章缓存的读取和更新,结果高峰期用户打开页面要等2-3秒。后来换成读写锁,读操作并行处理,响应时间直接降到50ms以内,CPU使用率也从80%降到30%。
读写锁的用法很简单,核心是readLock()
和writeLock()
两个方法:
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
private Map articleCache = new HashMap();
// 读操作:允许多线程同时读
public Article getArticle(Long id) {
readLock.lock();
try {
return articleCache.get(id);
} finally {
readLock.unlock();
}
}
// 写操作:独占锁,写时其他线程不能读/写
public void updateArticle(Article article) {
writeLock.lock();
try {
articleCache.put(article.getId(), article);
} finally {
writeLock.unlock();
}
}
不过要注意,读写锁不是万能的——如果写操作非常频繁(比如每秒几十次),读锁会经常被写锁抢占,反而可能比普通锁性能还差。这时候就得结合业务评估,或者考虑其他方案了。
不用“锁”也能安全?无锁编程与线程隔离的思路
虽然锁是最常用的方案,但有时候“不用锁”反而更高效。尤其是在高并发场景下,锁竞争会导致线程上下文切换(从运行态到阻塞态),而上下文切换一次就要消耗几微秒到几毫秒,积少成多也是笔不小的开销。这两年我在做高频交易系统时,就大量用到了无锁编程和线程隔离,今天也给你讲讲这两种“非主流但很好用”的思路。
无锁编程:用“CAS”实现线程安全
你可能听过“CAS”(Compare And Swap,比较并交换),这是无锁编程的核心。简单说,它的逻辑是:“我认为变量现在的值是A,如果是的话就把它改成B,否则不修改并告诉我现在的值是多少”。CPU直接支持CAS指令,所以它是原子操作,不需要加锁就能保证线程安全。
Java里的AtomicInteger
、AtomicLong
等原子类就是基于CAS实现的。比如你想实现一个计数器,用AtomicInteger
就比synchronized
+int
高效得多:
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // 原子操作,无需加锁
}
但CAS也有坑,最典型的就是“ABA问题”:线程1准备把变量从A改成B,结果线程2先把A改成C,再改回A,线程1 CAS时发现还是A,就误以为没被修改,从而执行修改操作。虽然大部分场景下ABA问题影响不大(比如计数器),但在涉及状态变更的场景(比如链表节点删除)就可能出问题。
解决ABA问题的办法是加“版本号”,Java里的AtomicStampedReference
就是干这个的——它不仅比较值,还比较版本号,只有值和版本号都匹配才修改。比如下面这个例子,给库存加个版本号,避免超卖时的ABA问题:
// 初始化库存:值为100,版本号为0
private AtomicStampedReference stock = new AtomicStampedReference(100, 0);
public boolean deductStock() {
int currentStamp;
int currentStock;
do {
currentStock = stock.getReference();
currentStamp = stock.getStamp();
if (currentStock <= 0) {
return false; // 库存不足
}
} while (!stock.compareAndSet(currentStock, currentStock
1, currentStamp, currentStamp + 1));
return true;
}
这里的do-while
循环是CAS的常见写法,因为可能有多个线程同时修改,需要重试直到成功。不过要注意,重试次数不能太多,否则会浪费CPU——如果并发特别高, 结合“自旋锁”+“退让策略”(比如重试5次后让出CPU),或者干脆用锁。
线程隔离:让线程“各玩各的”,根本不共享资源
如果说锁是“让线程排队访问共享资源”,那线程隔离就是“干脆不让线程共享资源”——每个线程用自己的专属资源,自然就不会有安全问题。这就像你和同事各用各的电脑办公,就不用担心抢鼠标键盘了。
最典型的线程隔离方案就是ThreadLocal
,它能让变量在每个线程中都有独立副本。比如用户登录后,我们需要在整个请求链路中传递用户ID,这时候用ThreadLocal
就比在方法参数里传来传去优雅得多:
public class UserContext {
private static final ThreadLocal USER_ID = new ThreadLocal();
public static void setUserId(Long userId) {
USER_ID.set(userId);
}
public static Long getUserId() {
return USER_ID.get();
}
public static void remove() {
USER_ID.remove();
}
}
但ThreadLocal
是把双刃剑,用不好会出大问题。去年我帮一个团队排查内存泄漏,发现他们的服务运行一周后就OOM,dump内存一看,ThreadLocal
里存的用户信息占了2G多!一问才知道,他们用了线程池(比如Tomcat的线程池),但没用remove()
——线程池会复用线程,导致ThreadLocal
里的变量一直被线程持有,不会被GC回收,越积越多直到内存溢出。
所以用ThreadLocal
必须记住:用完一定要在finally中调用remove()
,尤其是在线程池环境下。正确的用法应该是这样:
public void handleRequest(HttpServletRequest request) {
try {
Long userId = parseUserId(request);
UserContext.setUserId(userId); // 设置线程变量
// 业务逻辑处理...
} finally {
UserContext.remove(); // 无论成功失败,必须清理!
}
}
除了ThreadLocal
,“对象池”也是线程隔离的一种思路。比如数据库连接池,每个线程从池里拿一个连接,用完归还,线程间不共享连接,避免了并发操作同一个连接的问题。不过连接池的隔离是“逻辑隔离”(连接对象可复用),和ThreadLocal
的“物理隔离”(每个线程独立副本)不太一样,但核心思想都是“减少共享”。
什么时候该用无锁/线程隔离,什么时候该用锁?
很多人纠结“选锁还是选无锁”,其实没有绝对答案,得看场景。我 了一个简单的判断标准:
AtomicInteger
等原子类(CAS)效率最高,代码也简洁。 synchronized
或ReentrantLock
)更稳妥,避免CAS重试导致CPU飙升。 ThreadLocal
,根本不用考虑锁的问题。 你可以把这几种方案想象成工具箱里的工具:锤子(锁)万能但可能砸到手,螺丝刀(CAS)适合拧螺丝但拧不了钉子,扳手(ThreadLocal)专门拧螺母——没有最好的,只有最合适的。
你平时开发中最常遇到哪种线程安全问题?是死锁、数据竞争,还是锁性能问题?可以在评论区说说,我给你具体分析分析怎么解。
我之前做一个电商库存系统的时候,团队里有个新人纠结了好几天ABA问题,非说要给所有CAS操作都加上版本号,结果代码越改越复杂,上线后反而因为版本号判断多了一层逻辑,性能掉了15%。其实ABA这东西,就像你出门买东西,回家发现门锁没换,但中间可能有小偷进来又出去了——如果家里东西没少(比如只是数值没变),其实不用太在意;但如果家里的布局被偷偷动过(比如状态依赖顺序),那就得小心了。
你可以这么理解ABA问题:变量一开始是A,线程1准备用CAS把它改成B,这时候线程2突然插进来,先把A改成B,处理完自己的逻辑后又改回A。等线程1执行CAS时,发现“现在的值还是A”,就以为“没人动过”,直接改成B了。如果只是简单的数值操作,比如库存从10→5→10,线程1想把10改成9,最终结果还是9,其实不影响正确性——就像你数钱,别人拿了5块又放回来,你最后数的总数对就行。但如果是依赖“状态连续性”的场景,比如链表删除节点,就麻烦了:假设链表是A→B→C,线程1想删除A,准备把A的next指向C;这时候线程2突然把B删了,链表变成A→C,然后又把B加回去变成A→B→C。线程1的CAS一看“A的next还是B”,就执行删除,结果可能把C给弄丢了——这就像你想拆家里的书架隔板,别人偷偷拆了又装回去,你按原来的位置拆,可能把旁边的板子也带下来了。
这种时候就得用AtomicStampedReference,给变量加个“版本号”,每次修改不仅改值,还得把版本号+1。CAS的时候既要比较值,也要比较版本号,就算值变回A,版本号变了也会失败。我去年做一个分布式任务调度系统,任务状态有“待执行→执行中→已完成”的流转,就遇到过ABA问题:任务从“执行中”被改成“已完成”,又被回滚成“执行中”,CAS操作误判状态没变导致重复执行。后来用AtomicStampedReference给状态加了版本号,每次状态变更版本+1,问题就解决了。所以记住:数值类场景不用瞎折腾,状态流转类场景才需要版本号兜底。
如何判断自己的代码是否存在线程安全问题?
可以从两个角度排查:一是看是否有“共享可变资源”——多个线程能同时访问且修改同一个变量/对象(如全局变量、静态变量、数据库记录等);二是观察运行时是否出现“偶发异常”——比如数据错乱(库存负数、订单重复)、日志中出现ConcurrentModificationException、死锁导致线程阻塞等。实际调试时,可通过添加详细日志(记录线程ID、变量修改前后值)或使用工具(如Java的jstack查看线程状态、Arthas的thread命令)定位问题。
synchronized和ReentrantLock哪个性能更好?该怎么选?
没有绝对的“谁更好”,取决于场景。低并发或简单场景下,两者性能接近:synchronized是JVM原生支持,代码简洁且自动释放锁,适合新手;ReentrantLock需手动释放(finally中unlock),但支持超时获取、中断、公平锁等高级功能,适合复杂场景(如需要控制锁等待时间)。高并发下,若锁竞争激烈,ReentrantLock的灵活性(如尝试获取锁失败后降级处理)能减少线程阻塞,性能优势更明显。可参考文章中的表格根据具体需求选择。
ThreadLocal为什么会导致内存泄漏?怎么避免?
ThreadLocal的内存泄漏通常是因为“线程池复用线程+未清理变量”。ThreadLocal的变量存储在Thread的ThreadLocalMap中,key是弱引用(会被GC回收),但value是强引用。若线程池中的线程长期存活(如Tomcat线程池),且用完ThreadLocal后未调用remove(),key被GC回收后,value会变成“无主对象”,一直被线程持有无法回收,最终导致内存溢出。避免方法很简单:在finally块中调用ThreadLocal的remove()方法,确保无论业务逻辑是否异常,都能清理变量。
CAS的ABA问题在实际开发中需要特别处理吗?
多数场景下不需要,但状态变更类场景需注意。ABA问题指变量从A被改为B再改回A,CAS操作会误以为“未修改”。如果是简单的数值操作(如计数器、库存扣减),ABA不影响结果(比如库存从10→5→10,再扣减1变成9,结果正确);但如果是依赖“状态连续性”的场景(如链表节点删除、事务状态流转),ABA可能导致逻辑错误(比如误删中间节点)。这种情况需用AtomicStampedReference添加版本号,比较值的同时比较版本,确保状态未被篡改过。
分布式系统中,线程安全问题和单机环境有什么区别?
单机线程安全解决的是“同一JVM内多线程共享资源”的问题,用本地锁(synchronized、ReentrantLock)、CAS、ThreadLocal即可;分布式系统中,服务通常多实例部署(多个JVM),线程安全扩展为“跨JVM的资源共享”问题(如多实例操作同一数据库记录),本地锁完全失效。此时需用分布式锁(如Redis的SET NX命令、ZooKeeper的临时节点)来保证跨实例的互斥,同时要处理锁超时、重入、释放等问题,比单机场景更复杂。实际开发中,分布式锁需注意“锁释放的原子性”(避免业务未完成锁已释放)和“死锁预防”(设置合理超时时间)。