重构与并发编程:重构线程安全代码
并发编程,说白了就是让多个任务同时跑起来。但多线程一多,问题就来了——数据竞争、死锁、可见性问题,一个比一个头疼。我做了十几年架构,见过太多因为线程安全问题导致的线上事故。今天咱们就聊聊,怎么把那些“看起来能跑,但跑着跑着就崩”的代码,重构得既安全又高效。
线程安全问题的根源
先说说为什么会有线程安全问题。你想想看,多个线程同时访问同一个变量,一个在读,一个在写,结果会怎样?
我举个例子:
public class Counter {
private int count = 0;
public void increment() {
count++; // 这不是原子操作!
}
public int getCount() {
return count;
}
}
这段代码,两个线程同时调用 increment(),你以为结果是2,实际可能是1。为什么?因为 count++ 在底层是三步操作:读、加、写。两个线程交叉执行,数据就乱了。
核心问题三要素:
- 原子性:操作不可分割,要么全做,要么全不做
- 可见性:一个线程的修改,其他线程能立刻看到
- 有序性:指令重排可能导致意想不到的结果
重构策略一:用锁保护共享数据
最简单的办法,加锁。但锁怎么加,有讲究。
我个人习惯,能用 synchronized 就别用 Lock,除非你需要超时、可中断等高级功能。为什么?synchronized 语法简单,不容易出错。
public class SafeCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
嗯,这里要注意:锁的范围要尽量小。别把整个方法都锁住,尤其是那些耗时的操作。我在项目中遇到过,有人把网络请求也放在同步块里,结果性能直接崩了。
避坑指南:我曾经重构过一个系统,里面用了大量的 synchronized(this)。结果不同方法之间互相阻塞,死锁频发。后来我改成用专门的锁对象,问题就解决了。
重构策略二:用不可变对象消除共享
锁虽然好用,但性能有损耗。有没有更好的办法?有——让数据不可变。
说白了,如果数据创建后就不能修改,那多线程访问还有什么问题?没有写操作,自然就没有竞争。
public final class ImmutablePoint {
private final int x;
private final int y;
public ImmutablePoint(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() { return x; }
public int getY() { return y; }
}
你看,所有字段都是 final 的,没有 setter 方法。这样的对象,随便多少个线程读,都不会有问题。
注意:不可变对象不是万能的。如果对象内部包含可变对象(比如 List),那就要小心了。记得在构造函数里做防御性拷贝。
重构策略三:用线程安全容器
很多时候,我们不需要自己造轮子。Java 提供了丰富的线程安全容器,直接用就好。
| 场景 | 推荐容器 | 说明 |
|---|---|---|
| 键值对存储 | ConcurrentHashMap | 分段锁,性能远高于 Hashtable |
| 队列 | LinkedBlockingQueue | 生产者-消费者模式首选 |
| 列表 | CopyOnWriteArrayList | 读多写少场景,写时复制 |
| 计数器 | AtomicInteger | CAS 操作,无锁并发 |
我记得有一次重构,一个系统里用了 HashMap 做缓存,结果线上频繁出现死循环。换成 ConcurrentHashMap 后,问题直接消失。你想想看,一个简单的替换,省了多少排查时间。
重构策略四:用原子类替代锁
对于简单的计数器、累加器,用 AtomicInteger 比用锁更高效。它底层用的是 CAS(Compare-And-Swap),无锁并发,性能更好。
public class AtomicCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet();
}
public int getCount() {
return count.get();
}
}
这段代码,没有锁,但线程安全。为什么?因为 incrementAndGet() 是原子操作,底层 CPU 指令保证的。
性能对比:在我之前的一个项目中,用 synchronized 的计数器,QPS 只能到 5000;换成 AtomicInteger 后,直接飙到 20000。差距就是这么大。
重构策略五:避免死锁
死锁是并发编程里最头疼的问题。两个线程互相等待对方释放锁,谁也跑不了。
我总结了几条避坑经验:
- 固定锁顺序:所有线程按同样的顺序获取锁
- 使用超时锁:
tryLock(timeout)避免无限等待 - 减少锁的粒度:能锁一个变量,就别锁整个对象
我曾经在一个支付系统里遇到过死锁。两个线程分别持有账户A和账户B的锁,都想获取对方的锁。结果所有转账都卡住了。后来我改成按账户ID排序后加锁,问题就解决了。
知识体系总览
下面这张图,概括了线程安全重构的核心思路:
总结
线程安全重构,说白了就是三件事:减少共享、保护共享、消除竞争。我个人经验是,优先用不可变对象和线程安全容器,实在不行再加锁。加锁时注意粒度,别锁太大,也别锁太小。
嗯,最后提醒一句:重构线程安全代码,一定要写单元测试。多线程的 bug 很难复现,但一旦出现就是大问题。我吃过这个亏,希望你不用再吃。