重构与并发编程:重构线程安全代码

并发编程,说白了就是让多个任务同时跑起来。但多线程一多,问题就来了——数据竞争、死锁、可见性问题,一个比一个头疼。我做了十几年架构,见过太多因为线程安全问题导致的线上事故。今天咱们就聊聊,怎么把那些“看起来能跑,但跑着跑着就崩”的代码,重构得既安全又高效。

线程安全问题的根源

先说说为什么会有线程安全问题。你想想看,多个线程同时访问同一个变量,一个在读,一个在写,结果会怎样?

我举个例子:

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 很难复现,但一旦出现就是大问题。我吃过这个亏,希望你不用再吃。