学完线程停止,我又掉进了锁的坑里

上篇博客写完线程怎么安全停止之后,我以为我对多线程已经有点数了。事实证明,我想多了。

下一个让我头疼的问题是:

一开始我觉得锁没什么好学的,不就是 synchronized 吗?直到有一天,我在网上看到一个帖子说 synchronized 太重了,推荐用别的锁。我一看评论,两边吵起来了,有人坚持 synchronized 够用,有人说不用 ReentrantLock 就不算会多线程。

我站在中间,一脸懵。

后来老老实实花了一周,把 Java 里常见的几种锁都过了一遍。这篇就按我学的顺序来讲。


先从 synchronized 开始:最简单的锁

synchronized 是 Java 关键字,啥都不用引入就能用。它的用法就三种:

java 复制代码
public class SynchronizedExample {
    private int count = 0;

    // 1. 修饰方法:整个方法上锁
    public synchronized void incrementMethod() {
        count++;
    }

    // 2. 修饰静态方法:锁的是整个类
    public static synchronized void staticMethod() {
        // 同步逻辑
    }

    // 3. 修饰代码块:只锁一小段
    public void incrementBlock() {
        synchronized (this) {
            count++;
        }
    }
}

我一开始觉得,"这不就够用了吗?清清楚楚的。"

而且 synchronized 有个特好的地方------锁会自动释放。不管是正常执行完,还是方法里抛了异常,JVM 都会帮我把锁解开。对新手来说这太友好了,完全不用担心"忘了 unlock 怎么办"。

后来我才知道,synchronized 在 JDK 1.6 之后被大幅优化过。JVM 底层有一套 锁升级 机制:

无锁 → 偏向锁(只有一个线程反复进来)→ 轻量级锁(几个线程交替进,用自旋避免阻塞)→ 重量级锁(真的抢起来了,交给操作系统管)

翻译一下就是:synchronized 一开始很轻,随着锁竞争越来越激烈,它才慢慢变重。并不是一开始就很重。

所以网上说 synchronized 太重,其实说的大部分是 JDK 1.6 之前的事。现在很少遇到"用了 synchronized 导致性能出问题"的情况。


然后遇到了 ReentrantLock:想要更多控制的时候

synchronized 虽然简单,但有个问题:它的控制能力太少了。

比如我想试一下"如果锁抢不到就放弃,不在这死等",synchronized 做不到。又比如我想让等着拿锁的线程能响应中断(像上篇博客里说的 interrupt),synchronized 也做不到。

这个时候就轮到 ReentrantLock 上场了。

用法长这样:

java 复制代码
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockExample {
    private final ReentrantLock lock = new ReentrantLock();
    private int count = 0;

    public void standardIncrement() {
        lock.lock();        // 手动上锁
        try {
            count++;
        } finally {
            lock.unlock();  // 手动解锁,不写就是事故现场
        }
    }

    public void tryIncrement() {
        if (lock.tryLock()) {  // 拿得到就拿,拿不到就走
            try {
                count++;
            } finally {
                lock.unlock();
            }
        } else {
            System.out.println("没抢到锁,我干点别的去");
        }
    }
}

第一次看到这个代码的时候,我内心的想法是:这也太容易出 bug 了

lock() 和 unlock() 是分开写的,如果忘记在 finally 里 unlock,或者代码中间抛了个异常没走到 unlock,锁就永远不会释放。其他等着这把锁的线程就全卡死了。

所以 ReentrantLock 虽然功能强,但用的时候得自己多留个心眼。不过它确实解决了 synchronized 做不到的事:

  • tryLock() --- 尝试拿锁,拿不到就干别的
  • lockInterruptibly() --- 等锁的时候也能响应中断
  • 可以设置公平锁 (先来后到,不插队)------传入 true 就行:new ReentrantLock(true)
  • 还能配合 Condition 做精准的线程唤醒,这个后面再聊

这些东西 synchronized 都做不到。所以选哪个,就看你的场景需不需要这些功能。


再后来接触到 ReadWriteLock:读写分离的思路

学到这,我以为锁就是"一把锁管所有"。但有个场景让我重新想了想:

假如我有一个缓存,里面存了一些数据。大部分时候,多个线程只是在读取 数据。偶尔才有一个线程来更新数据。

如果用普通的锁,读和读之间也要互相等着------明明读操作不改变数据,为啥不能同时读呢?

ReadWriteLock 就是来解决这个问题的。它维护了两把锁:

  • 读锁:多个线程可以同时持有,大家一起读
  • 写锁:只能一个线程持有,写的时候不能读,读的时候不能写

规则很简单:读读共享、读写互斥、写写互斥。

java 复制代码
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockExample {
    private final Map<String, String> cache = new HashMap<>();
    private final ReadWriteLock rwLock = new ReentrantReadWriteLock();

    public String get(String key) {
        rwLock.readLock().lock();  // 拿读锁
        try {
            return cache.get(key);
        } finally {
            rwLock.readLock().unlock();
        }
    }

    public void put(String key, String value) {
        rwLock.writeLock().lock();  // 拿写锁
        try {
            cache.put(key, value);
        } finally {
            rwLock.writeLock().unlock();
        }
    }
}

我第一次看到这个的时候觉得,"哇,这也太聪明了吧。"

实际用的时候也有要注意的:如果读锁已经被一堆线程拿着了,写锁就得等着------等所有人都读完。如果读操作特别多、一直有人读,写操作可能一直等不到机会(这就是"写锁饥饿"问题)。


两个思路:悲观锁 vs 乐观锁

学到这个阶段,我发现前面讨论的其实都是悲观锁------总觉得会有别的线程来改数据,所以先锁上再说。

但还有人想:"如果冲突概率很低,那我能不能先干活,最后更新的时候再检查一下有没有人动过?"

这个思路就是乐观锁------先把事干了,提交的时候检查冲突。有冲突就重试。

Java 里最典型的乐观锁实现是 AtomicInteger

java 复制代码
import java.util.concurrent.atomic.AtomicInteger;

public class OptimisticLockExample {
    private final AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet();
    }
}

它底层用的是 CAS(Compare-And-Swap) 算法。大概逻辑是:

  1. 读取当前的值为 A
  2. 计算出新值 B(A + 1)
  3. 更新的时候检查:现在的值还是 A 吗?
    • 如果是,更新为 B
    • 如果不是(被别人改了),重新读取再试一次

这段过程是 CPU 指令级别的原子操作,所以不怕并发问题。

听起来好像乐观锁比悲观锁好?不一定。

  • 乐观锁 适合冲突很少的场景。如果老冲突,就一直在那里自旋重试,CPU 白白浪费了。
  • 悲观锁 适合冲突很多的场景。直接挂起线程等,比自旋耗 CPU 划算。

没有谁绝对好,看场景。


最后是自旋锁:不等了,我也不睡,我就站这儿等

最后一个,自旋锁

它的想法很简单:锁被占着的时候,我不把线程挂起(挂起和唤醒挺耗时间的),我就站在这儿一直问:"好了没?锁还了没?"

java 复制代码
import java.util.concurrent.atomic.AtomicReference;

public class SpinLock {
    private final AtomicReference<Thread> owner = new AtomicReference<>();

    public void lock() {
        Thread currentThread = Thread.currentThread();
        while (!owner.compareAndSet(null, currentThread)) {
            // 啥也不干,就这么转圈等
        }
    }

    public void unlock() {
        Thread currentThread = Thread.currentThread();
        if (currentThread.equals(owner.get())) {
            owner.compareAndSet(currentThread, null);
        }
    }
}

你看到那个 while 循环了吗?它就一直在那转,不停地 CAS,直到拿到锁为止。

自旋的好处是不用把线程挂起又恢复,省了上下文切换的开销。但缺点是:如果锁一直被人占着,它就一直在那转,CPU 被白白吃掉。

所以自旋锁只适合一个场景:持有锁的时间非常非常短。短到"挂起线程再恢复"的时间,比"自旋等着"的时间还长的时候,自旋才是划算的。


学完之后我自己的理解

这些锁学下来,我最大的感觉是:没有一把锁是万能的。

什么时候用
synchronized 大多数普通情况,简单够用,JVM 替你管锁
ReentrantLock 需要超时、中断、公平锁、Condition 这些高级功能
ReadWriteLock 读很多、写很少的场景,比如缓存
乐观锁(CAS) 冲突很少,追求高性能
自旋锁 锁只占用一瞬间,多核机器

synchronized 说它重的,那是十多年前的事了。ReentrantLock 说它灵活的,代价是要自己记着 unlock。乐观锁听起来高效,冲突多了一样跪。自旋锁省了切换,但转起来 CPU 是真的在烧。

所以现在有人问我"用哪个锁好"的时候,我会先问:"你的场景是什么样的?"

这个问题比我当初想的要重要得多。