CodeGym /课程 /JAVA 25 SELF /StampedLock 与低争用计数器

StampedLock 与低争用计数器

JAVA 25 SELF
第 58 级 , 课程 3
可用

1. 为什么 ReadWriteLock 并不总是够用

写入竞争高的问题

ReadWriteLock(最常见的是 ReentrantReadWriteLock)在大多数线程只是读取、写入者很少时效果很好。在这种情况下,多个线程可以同时读取,而写入只会在短时间内阻塞访问。

问题在于,当写入的线程比预期更多,或它们频繁在读与写之间切换时,如果写操作耗时较长,线程会更久地等待锁的释放。结果是对数据的竞争加剧,读写都变慢,ReadWriteLock 的效率下降。

昂贵的锁模式切换

当线程从读取模式切换到写入模式(或反之)时,底层会进行复杂的检查:必须确保没有其它线程在写入、所有读者已退出,才允许写入。

如果这样的切换很频繁,就会产生延迟。线程开始排队,性能下降——尤其是在同时存在大量读写操作时。

ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();

// 读取
readLock.lock();
try {
    // 读取数据
} finally {
    readLock.unlock();
}

// 写入
writeLock.lock();
try {
    // 修改数据
} finally {
    writeLock.unlock();
}

每当线程在 readLockwriteLock 之间切换时,系统都会执行所有检查以避免冲突。若切换过多——代价很高。

2. StampedLock:现代同步方式

StampedLock 是 Java 8 引入的现代同步机制。它融合了 ReadWriteLock 的思想,但新增了乐观读模式,并且不是用传统的锁对象,而是使用“戳记”(stamps)——需要显式释放的特殊令牌。

关键特性:

  • 三种模式:write lock(独占写)、read lock(共享读)、optimistic read(无锁的乐观读)。
  • 不支持可重入:同一线程不能重复进入同一把锁。
  • 在读多写少时具有很高的性能。
  • 需要对戳记(stamp)进行显式管理。

乐观读:tryOptimisticRead + validate

乐观读是一种不加任何锁就去读取数据的模式,假定此时没有线程在写。读取结束后必须通过 validate(stamp) 检查读期间是否发生过写入。

import java.util.concurrent.locks.StampedLock;

public class Point {
    private double x, y;
    private final StampedLock lock = new StampedLock();

    public double distanceFromOrigin() {
        long stamp = lock.tryOptimisticRead();
        double currentX = x;
        double currentY = y;
        // 检查读取期间是否发生写入
        if (!lock.validate(stamp)) {
            // 如果发生了写入——获取常规的读锁
            stamp = lock.readLock();
            try {
                currentX = x;
                currentY = y;
            } finally {
                lock.unlockRead(stamp);
            }
        }
        return Math.hypot(currentX, currentY);
    }
}

工作原理:

  • tryOptimisticRead() 返回一个戳记(long),只要没有写入,它就“有效”。
  • 读取 xy 的值。
  • validate(stamp) 检查从开始读到结束读之间是否发生过写入。
  • 如果一切正常——使用已读取的值;如果发生过写入——获取常规的 readLock 并重新读取。

当写入极少而读取很多时,这非常有利。多数情况下 validate 会返回 true,读取几乎是免费的。

“读多写少”的场景

  • 数据很少变化,但经常被读取(例如缓存、坐标、元数据)。
  • 需要尽量降低读取延迟。
  • 一旦发生写入,可以接受“重读”。

回退到读锁

如果乐观读失败(validate 返回 false),线程会回退到常规的 readLock。这确保了数据正确性,但通常较少发生。

3. StampedLock 的坑点

不支持可重入

ReentrantReadWriteLock 不同,StampedLock 不支持重复进入。如果线程已经持有锁仍尝试再次获取——会发生死锁。

long stamp1 = lock.writeLock();
long stamp2 = lock.writeLock(); // DEADLOCK! 线程在等待自己

对中断的敏感性

StampedLock 对线程中断的响应不同于传统锁。如果线程在等待获取锁时被中断,它并不总能立刻“醒来”。在需要快速响应中断的任务中,请使用其他机制。

正确释放戳记

每次调用 readLock()writeLock()tryOptimisticRead() 都会返回一个唯一的戳记(long)。必须将它传入对应的解锁方法:

  • unlockRead(stamp)
  • unlockWrite(stamp)

错误: 如果混用戳记或忘记调用解锁——会导致锁泄漏并使程序卡死。

4. 与 ReentrantReadWriteLock 的对比

特性 ReentrantReadWriteLock StampedLock
可重入(重复进入)
乐观读
高竞争下的性能 中等 高(写入很少时)
显式锁管理 否(自动) 是(戳记)
对中断的响应 不总是
公平性(fairness) 是(可开启)

公平模式与对饥饿的影响

  • ReentrantReadWriteLock 中可以开启“公平”模式(fair),让线程按队列顺序被唤醒与处理。这可以避免饥饿(starvation)。
  • StampedLock 不提供公平性:如果其他线程一直“抢到”锁,某些线程可能等待更久,存在少见的饥饿可能。

5. 计数器:LongAdder/LongAccumulator vs AtomicLong

高竞争下 AtomicLong 的问题

AtomicLong 是保证线程安全自增的原子变量。但当许多线程同时调用 incrementAndGet() 时,大家都在“争”同一个变量,导致性能下降。

LongAdder:分段(striped)计数器

LongAdder 以另一种方式解决问题:把计数器拆成多个“段”(stripe),每个段服务于一组线程。线程只增加其中一个段,Final value是所有段的求和。

优势:

  • 在高竞争下,线程几乎互不干扰。
  • 性能远高于 AtomicLong
import java.util.concurrent.atomic.LongAdder;

LongAdder adder = new LongAdder();

Runnable task = () -> {
    for (int i = 0; i < 100_000; i++) {
        adder.increment();
    }
};

Thread[] threads = new Thread[8];
for (int i = 0; i < threads.length; i++) {
    threads[i] = new Thread(task);
    threads[i].start();
}
for (Thread t : threads) t.join();

System.out.println("Final value: " + adder.sum());

LongAccumulator

LongAccumulatorLongAdder 的泛化版本,可以自定义累积函数(例如Max value、最小值等)。

import java.util.concurrent.atomic.LongAccumulator;

LongAccumulator max = new LongAccumulator(Long::max, Long.MIN_VALUE);

max.accumulate(10);
max.accumulate(42);
max.accumulate(7);

System.out.println("Max value: " + max.get()); // 42

分段(striped)锁与降低竞争

striped locks 技术将共享资源划分为多个独立部分(段,stripes),每部分由各自的锁或变量保护。线程均匀分布到各段,从而降低竞争、提升性能。LongAdderLongAccumulator 正是采用了这一思路。

6. 实践:读取占优的缓存

任务:我们有一张映射(Map)存放数据以及元数据(例如访问计数器)。读取频繁,写入稀少。

基于 StampedLock 的实现:

import java.util.*;
import java.util.concurrent.locks.StampedLock;
import java.util.concurrent.atomic.LongAdder;

public class MetadataCache<K, V> {
    private final Map<K, V> map = new HashMap<>();
    private final StampedLock lock = new StampedLock();
    private final LongAdder hits = new LongAdder();

    public V get(K key) {
        long stamp = lock.tryOptimisticRead();
        V value = map.get(key);
        if (!lock.validate(stamp)) {
            stamp = lock.readLock();
            try {
                value = map.get(key);
            } finally {
                lock.unlockRead(stamp);
            }
        }
        if (value != null) hits.increment();
        return value;
    }

    public void put(K key, V value) {
        long stamp = lock.writeLock();
        try {
            map.put(key, value);
        } finally {
            lock.unlockWrite(stamp);
        }
    }

    public long getHits() {
        return hits.sum();
    }
}
  • 读取使用乐观模式:若无人写入——读取几乎免费。
  • 写入使用 write lock。
  • 访问计数使用 LongAdder:即便在高竞争下自增也互不干扰。

7. 负载下 LongAdder vs AtomicLong 的评测

测试:8 个线程,每个 100 万次递增

import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;

public class CounterBenchmark {
    public static void main(String[] args) throws InterruptedException {
        int threads = 8;
        int increments = 1_000_000;

        // AtomicLong
        AtomicLong atomic = new AtomicLong();
        long start = System.nanoTime();
        Thread[] t1 = new Thread[threads];
        for (int i = 0; i < threads; i++) {
            t1[i] = new Thread(() -> {
                for (int j = 0; j < increments; j++) atomic.incrementAndGet();
            });
            t1[i].start();
        }
        for (Thread t : t1) t.join();
        long timeAtomic = System.nanoTime() - start;

        // LongAdder
        LongAdder adder = new LongAdder();
        start = System.nanoTime();
        Thread[] t2 = new Thread[threads];
        for (int i = 0; i < threads; i++) {
            t2[i] = new Thread(() -> {
                for (int j = 0; j < increments; j++) adder.increment();
            });
            t2[i].start();
        }
        for (Thread t : t2) t.join();
        long timeAdder = System.nanoTime() - start;

        System.out.printf("AtomicLong: %d ms, LongAdder: %d ms%n", timeAtomic / 1_000_000, timeAdder / 1_000_000);
    }
}

典型结果:

AtomicLong: 2500 ms, LongAdder: 200 ms

结论:在高竞争下,LongAdderAtomicLong 快数倍。

8. 使用 StampedLock 与 LongAdder 的常见错误

错误 1:忘记调用 unlockRead/unlockWrite。 如果不释放戳记,其他线程会一直等待。务必使用 try/finally

错误 2:尝试可重入。 StampedLock 不支持重复进入。不要在同一线程中二次获取同一把锁。

错误 3:错误使用 validate。 如果在 tryOptimisticRead 之后不检查 validate,可能得到不一致的数据。

错误 4:在高竞争下仍使用 AtomicLong。 AtomicLong 适用于 1–2 个线程,但在 8+ 个线程时会成为“瓶颈”。请使用 LongAdder

错误 5:忽视分段锁。 如果你实现自己的 striped-lock,务必确保线程均匀分布到各段,否则某些段会过载而另一些段空闲。

错误 6:期望 StampedLock 提供公平性。 StampedLock 不保证线程服务顺序。极少数情况下可能发生饥饿。

1
任务
JAVA 25 SELF, 第 58 级, 课程 3
已锁定
飞行控制中心:高度缓存系统 🛰️
飞行控制中心:高度缓存系统 🛰️
1
任务
JAVA 25 SELF, 第 58 级, 课程 3
已锁定
无人机导航:乐观位置计算 🚁
无人机导航:乐观位置计算 🚁
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION