在开发中,很多团队一遇到多线程读写链表,二话不说就上 synchronized。这当然没错,但如果你仔细观察,就会发现不同场景下锁的代价完全不一样。今天我们就用几段代码和几个生活化的比喻,把锁粒度这个话题聊透。

一、给链表加锁,最粗的锁长什么样?

1.1 用一个synchronized包住所有操作

想象一下,一个办公室里只有一把钥匙,谁要进门都得从别人手里拿钥匙,用完再还回去。这种“全校一把锁”的做法,对应到代码里就是直接在链表操作的方法上加 synchronized。下面我们使用Java技术栈写一个最简单的线程安全链表。

import java.util.LinkedList;

public class SimpleSyncList<T> {
    private final LinkedList<T> list = new LinkedList<>();

    // 整个方法处于同一把锁,所有增删查操作都会排队
    public synchronized void add(T item) {
        list.add(item);
    }

    public synchronized void remove(T item) {
        list.remove(item);
    }

    public synchronized boolean contains(T item) {
        return list.contains(item);
    }

    public synchronized int size() {
        return list.size();
    }
}

这种做法有个极大的好处:写起来简单,不会出幺蛾子。任何一个线程进来,其他线程都得等在门口。坏处也很明显:如果读操作很多,比如大家都在查链表里有没有某个元素,明明不修改数据,却也要排队,这就白白浪费了并发能力。

1.2 粗锁的优点和问题

粗锁的优点是“心智负担低”。团队里任何一个人都能看懂,不会因为多把锁的交互产生死锁。问题在于,当线程一多,所有读写都挤在一起,CPU时间大量浪费在等待和唤醒上,吞吐量上不去。

二、锁粒度:划多大范围、锁多长时间

2.1 两个维度

锁粒度不是抽象概念,它由两个维度决定:一是锁保护的数据范围,二是锁持有的时间。范围好理解,比如刚才整张链表都在一把锁的保护下;时间呢?比如你在 add 之前打印日志、读配置文件,这些和链表无关的操作也放在锁里,就相当于扩大了持有时间。范围和时间越大,锁越“粗”。

2.2 为什么不能盲目调细

细粒度锁听起来很美好,但代价同样存在。你要把链表分成若干段,每一段一把锁,那么操作前得先确认要锁哪几段,如果两把锁之间还有顺序关系,就可能死锁。另外,获取锁本身也有开销。如果临界区只有一行代码,锁的获取成本可能比业务逻辑还高,这时候把锁拆细反而更慢。说白了,调锁粒度不是越细越好,而是要看临界区里的“活”到底重不重。

调试锁粒度时,你可以先把临界区代码抽出来量一下耗时。比如用 System.nanoTime 前后记录,如果每个操作只有微秒级别,那把锁拆细的意义其实不大;如果每个操作要逐条遍历上千个节点,那大锁就会成为巨大的瓶颈。

三、偏向锁:它是JVM的“偷懒”机制

3.1 偏向锁在帮我们省什么

聊到锁,不得不提JVM对 synchronized 的优化,其中最值得关注的就是偏向锁。它的逻辑是:如果一把锁一直被同一个线程拿,那JVM就把锁“偏向”这个线程,在对象头上记下这个线程的ID。下次这个线程再来,只要核对一下ID是自己,直接进入,连CAS这些原子操作都跳过。

3.2 代码演示单线程反复获取

public class BiasedLockDemo {
    private final StringBuilder buffer = new StringBuilder();

    // 这个锁在单线程重复调用时,偏向锁会起效
    public synchronized void append(String s) {
        buffer.append(s);
        // 模拟少量工作
        if (buffer.length() > 100000) {
            buffer.delete(0, 50000);
        }
    }

    public static void main(String[] args) {
        // 生产环境建议加 -XX:BiasedLockingStartupDelay=0 观察效果
        BiasedLockDemo demo = new BiasedLockDemo();
        long start = System.nanoTime();
        // 只有一个线程自己玩,没有竞争
        for (int i = 0; i < 1000000; i++) {
            demo.append("a");
        }
        long cost = System.nanoTime() - start;
        System.out.println("耗时(ms): " + cost / 1_000_000);
    }
}

这个示例里,main线程一直自己调用 append,锁永远偏向它。如果你用JMH去做基准测试,会发现偏向锁开启时,这种单线程连续加锁的吞吐量轻松超过普通锁。但是请注意:这里只是演示偏向锁的行为,并不代表你的业务一定适合。

3.3 什么时候偏向锁会帮倒忙

偏向锁最怕的就是“多个线程轮流访问同一把锁”。因为一旦另一个线程出现,JVM就得撤销偏向,让所有等待线程重新走一遍完整的锁竞争流程。撤销过程中会触发安全点,可能造成微小的停顿。所以如果多个线程频繁交替读写同一个链表,偏向锁不仅没有正面作用,反而可能拖后腿。此时还不如一开始就关掉偏向锁(-XX:-UseBiasedLocking)或者直接选用别的并发数据结构。

四、分离锁:读读不排队,写写才排队

4.1 用读写锁做锁分离

粗锁太保守,偏向锁又是JVM内部的事,那我们可以自己设计细粒度锁。一个经典思路是“分离锁”——把读写行为分开。读锁和读锁不互斥,写锁和其他锁才互斥。这就好比把办公室的“进门钥匙”改成“门禁卡”,一个人进门不锁门,大家都能进;但如果有人要搬家具,就必须关门改造。

import java.util.LinkedList;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockList<T> {
    private final LinkedList<T> list = new LinkedList<>();
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    private final ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
    private final ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();

    // 添加是写操作,需要独占
    public void add(T item) {
        writeLock.lock();
        try {
            list.add(item);
        } finally {
            writeLock.unlock();
        }
    }

    public void remove(T item) {
        writeLock.lock();
        try {
            list.remove(item);
        } finally {
            writeLock.unlock();
        }
    }

    // contains只读,多个线程可以同时拿读锁
    public boolean contains(T item) {
        readLock.lock();
        try {
            return list.contains(item);
        } finally {
            readLock.unlock();
        }
    }

    public int size() {
        readLock.lock();
        try {
            return list.size();
        } finally {
            readLock.unlock();
        }
    }
}

这个实现里,多个线程同时查 contains 是没问题的,互不影响。只有写线程之间或读写之间才会排队。对于读多写少的场景,并发度有了质的提升。

4.2 分段锁是另一种分离思路

读写锁是“按操作类型分离”,分段锁则是“按数据范围分离”。比如将链表按某种规则拆成N段,每段一把锁。写线程如果操作的是第一段,只需要锁第一段,不需要管其他段。这种思路在 ConcurrentHashMap 里用得很熟。然而对单链表本身做分段其实很别扭,因为链表节点之间的引用是连续的,按区间分段会让遍历变得特别复杂。如果你真的需要分段,更适合的做法是换一个类似HashMap的数据结构,而不是硬把链表拆开。

五、性能差异的本质:竞争和临界区

5.1 一个粗糙的对比测试

下面这段代码是为了说明问题,不是严谨的基准测试。它模拟一个大锁和读写锁在不同读写比例下的表现。

import java.util.LinkedList;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class LockCompareDemo {
    static LinkedList<Integer> sharedList = new LinkedList<>();
    static Object bigLock = new Object();
    static ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();

    public static void main(String[] args) throws Exception {
        // 写入一些初始数据
        for (int i = 0; i < 1000; i++) sharedList.add(i);

        long t1 = testBigLock(0.8);
        long t2 = testRwLock(0.8);
        System.out.println("写多(80%写): 大锁 " + t1 + "ms, 读写锁 " + t2 + "ms");

        long t3 = testBigLock(0.2);
        long t4 = testRwLock(0.2);
        System.out.println("读多(20%写): 大锁 " + t3 + "ms, 读写锁 " + t4 + "ms");
    }

    // 用一个全员大锁,读写都在锁里
    static long testBigLock(double writeRatio) throws Exception {
        long start = System.currentTimeMillis();
        Thread[] threads = new Thread[8];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    synchronized (bigLock) {
                        // 模拟写或读
                        if (Math.random() < writeRatio) {
                            sharedList.add(j);
                        } else {
                            sharedList.contains(j);
                        }
                    }
                }
            });
            threads[i].start();
        }
        for (Thread t : threads) t.join();
        return System.currentTimeMillis() - start;
    }

    // 用读写锁做分离,读不阻塞读
    static long testRwLock(double writeRatio) throws Exception {
        long start = System.currentTimeMillis();
        ReentrantReadWriteLock.ReadLock read = rwLock.readLock();
        ReentrantReadWriteLock.WriteLock write = rwLock.writeLock();
        Thread[] threads = new Thread[8];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    if (Math.random() < writeRatio) {
                        write.lock();
                        try {
                            sharedList.add(j);
                        } finally {
                            write.unlock();
                        }
                    } else {
                        read.lock();
                        try {
                            sharedList.contains(j);
                        } finally {
                            read.unlock();
                        }
                    }
                }
            });
            threads[i].start();
        }
        for (Thread t : threads) t.join();
        return System.currentTimeMillis() - start;
    }
}

你如果本地跑一遍,大概率会看到:写多的时候,读写锁并没有比大锁快多少,甚至会慢一点;读多的时候,读写锁有明显优势。原因是写多时,锁本身从“读读兼容”变成了“读写互斥”,读锁的存在还会让写锁等待更长,反而多了一层调度。

5.2 从测试看本质

性能差异的本质其实不是“用没用好锁”,而是你如何对待共享资源。大锁把所有操作串行化,简单可靠;读写锁把读操作并行化,适合读多写少;偏向锁只是降低了无竞争时的锁获取成本,救不了真正的竞争。这里有一条很朴素的规律:临界区越短、并发越高,越应该考虑更细的锁;临界区越长、并发越低,用大锁反而更稳妥。

六、应用场景、优缺点和注意事项

6.1 适用场景

偏向锁最适合“一个线程反复获取同一把锁”的场景,比如后台定时任务里只由单一线程更新一个缓存链表。读写锁适合读多写少且读操作占比很高的场景,比如配置信息链表、路由表这类基础设施。分离锁的真正用武之地是那些热点分散的复杂数据结构,比如按key散列的分段结构。如果你只是需要一个简单的线程安全链表,建议优先考虑 ConcurrentLinkedQueue,它是无锁的,但那是另一个话题了。

6.2 优缺点清单

大锁的好处是简单、不易出错;坏处是并发能力差。偏向锁的好处是单线程重复访问时开销极小;坏处是竞争切换时有额外撤销成本。读写锁的好处是读读并行;坏处是写锁获取较慢,写多时会饿死或性能下降。分段锁的好处是不同段互不干扰;坏处是实现复杂、遍历困难、容易出现锁顺序问题。

6.3 注意事项

第一,不要拿着一把锁的测试成绩去套另一个场景。LockCompareDemo 里的结果会受操作系统、JVM版本、CPU核数影响。第二,注意公平性和饥饿问题。ReentrantReadWriteLock 默认是非公平的,写线程在大量读线程下可能长期等待。第三,代码里一定要把 unlock 放在 finally 中,避免异常导致锁泄漏。第四,如果对性能有执念,建议用JMH做多次预热后的基准测试,而不是自己用 System.currentTimeMillis 掐个大概。第五,不要盲目模仿别人的锁设计,你看到的也许只是别人为了特定业务硬扛出来的方案。

七、文章总结

回到开头的问题:并发读写链表时,锁粒度怎么权衡?答案不是非黑即白。大锁简单,但会拖慢读多的场景;读写锁适合读多写少,但写多时没优势;偏向锁是JVM的优化,本身不能拯救激烈竞争。从性能差异看本质,你要做的只有两件事:第一,搞清楚业务里读写比例是多少;第二,搞清楚临界区里到底做了多久的活。然后带着数据去选锁,而不是别人用什么你就用什么。毕竟锁是给你业务用的,不是给简历加分的。