Java多线程之StampedLock讲解

1 StampedLock

1.1 简介

ReadWriteLock 锁允许多个线程同时读取共享变量,但是在读取共享变量的时候,不允许另外的线程对共享变量进行写操作,更多的适合于读多写少的环境中。那么,在读多写少的环境中,有没有一种比ReadWriteLock更快的锁呢?
答案当然是有!那就是——JDK1.8中新增的StampedLock

StampedLockReadWriteLock相比,在读的过程中也允许后面的一个线程获取写锁对共享变量进行写操作,为了避免读取的数据不一致,使用StampedLock读取共享变量时,需要对共享变量进行是否有写入的检验操作,并且这种读是一种乐观读

总之,StampedLock是一种在读取共享变量的过程中,允许后面的一个线程获取写锁对共享变量进行写操作,使用乐观读避免数据不一致的问题,并且在读多写少的高并发环境下,比ReadWriteLock更快的一种锁。

1.2 StampedLock三种锁模式

1.2.1 简介

这里,我们可以简单对比下StampedLockReadWriteLockReadWriteLock支持两种锁模式:一种是读锁,另一种是写锁,并且ReadWriteLock允许多个线程同时读共享变量,在读时,不允许写,在写时,不允许读,读和写是互斥的,所以,ReadWriteLock中的读锁,更多的是指悲观读锁

  • 写锁 (Write Lock)
    描述: 一种独占锁(exclusive lock),同一时刻只允许一个线程持有写锁,其他线程(无论是读还是写)都会被阻塞。
    获取方法: lockWrite()tryLockWrite()。释放方法: unlockWrite(long stamp)
    特点:
    • 类似于传统读写锁中的写锁,适用于需要修改共享资源的场景。
    • 返回一个 stamp(时间戳),用于后续解锁操作。
  • 悲观读锁 (Pessimistic Read Lock)
    描述: 一种共享锁(shared lock),允许多个线程同时持有读锁,但会阻塞写锁的获取。
    获取方法: lockRead() 或 tryLockRead()。释放方法: unlockRead(long stamp)。
    特点:
    • 类似于 ReentrantReadWriteLock 的读锁,保证读操作期间不会有写操作干扰。
    • 返回一个 stamp,用于解锁或验证锁状态。
  • 乐观读锁 (Optimistic Read Lock)
    描述: 一种无锁(lock-free)的读取模式,不真正加锁,而是通过验证数据是否在读取期间被修改来确保一致性。
    获取方法: tryOptimisticRead()。验证方法: validate(long stamp)。
    特点:
    • 不阻塞写操作,性能最高,但需要配合验证机制(validate)来检查数据是否被修改。
    • 如果验证失败(即数据被写锁修改),通常需要回退并获取悲观读锁。
    • 不需要显式释放锁。
锁模式独占/共享阻塞性性能使用场景
写锁独占阻塞读和写较低数据写入/修改
悲观读锁共享阻塞写,不阻塞读中等一致性要求高的读取
乐观读锁无锁不阻塞任何操作最高读多写少的高性能场景

1.2.2 示例

import java.util.concurrent.locks.StampedLock;

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

    // 写锁示例:修改坐标
    public void move(double deltaX, double deltaY) {
        long stamp = lock.lockWrite(); // 获取写锁
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            lock.unlockWrite(stamp); // 释放写锁
        }
    }

    // 悲观读锁示例:读取坐标
    public double[] getPosition() {
        long stamp = lock.lockRead(); // 获取悲观读锁
        try {
            return new double[]{x, y};
        } finally {
            lock.unlockRead(stamp); // 释放读锁
        }
    }
//如果在执行乐观读操作时,另外的线程对共享变量进行了写操作,
//则会把`乐观读`升级为`悲观读锁`,如下代码片段所示。
    // 乐观读锁示例:计算距离
    public double distanceFromOrigin() {
        long stamp = lock.tryOptimisticRead(); // 获取乐观读锁
        double currentX = x;
        double currentY = y;
        //判断是否有线程对变量进行了写操作
    	//如果有线程对共享变量进行了写操作
    	//则sl.validate(stamp)会返回false
        if (!lock.validate(stamp)) { // 验证数据是否被修改
            stamp = lock.lockRead(); // 如果验证失败,升级为悲观读锁
            try {
                currentX = x;
                currentY = y;
            } finally {
                lock.unlockRead(stamp);
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }

    public static void main(String[] args) {
        StampedLockExample example = new StampedLockExample();
        example.move(3.0, 4.0);
        System.out.println("Position: " + Arrays.toString(example.getPosition()));
        System.out.println("Distance from origin: " + example.distanceFromOrigin());
    }
}

乐观读锁乐观读升级为悲观读锁的方式相比一直使用乐观读的方式更加合理,如果不升级为悲观读锁,则程序会在一个循环中反复执行乐观读操作,直到乐观读操作期间没有线程执行写操作,而在循环中不断的执行乐观读会消耗大量的CPU资源,升级为悲观读锁是更加合理的一种方式。

1.3 StampedLock实现思想

StampedLock 内部是基于CLH锁实现的,CLH是一种自旋锁,能够保证没有 饥饿现象 (线程长期无法获取资源,任务无法完成) 的发生,并且能够保证 FIFO(先进先出)的服务顺序。

CLH中,锁维护一个等待线程队列,所有申请锁,但是没有成功的线程都会存入这个队列中,每一个节点代表一个线程,保存一个标记位(locked),用于判断当前线程是否已经释放锁,当locked标记位为true时, 表示获取到锁,当locked标记位为false时,表示成功释放了锁。

当一个线程试图获得锁时,取得等待队列的尾部节点作为其前序节点,并使用类似如下代码判断前序节点是否已经成功释放锁:

while (pred.locked) {
    //省略操作 
}

只要前序节点(pred)没有释放锁,则表示当前线程还不能继续执行,因此会自旋等待;反之,如果前序线程已经释放锁,则当前线程可以继续执行。
释放锁时,也遵循这个逻辑,线程会将自身节点的 locked 位置标记为false,后续等待的线程就能继续执行了,也就是已经释放了锁。

1.4 StampedLock的注意事项

在读多写少的高并发环境下,StampedLock的性能确实不错,但是它不能够完全取代ReadWriteLock。在使用的时候,也需要特别注意以下几个方面。

  • StampedLock不支持重入
    没错,StampedLock 是不支持重入的,也就是说,在使用StampedLock时,不能嵌套使用,这点在使用时要特别注意。
  • StampedLock不支持条件变量
    StampedLock不支持条件变量,无论是读锁还是写锁,都不支持条件变量。
  • StampedLock使用不当会导致CPU飙升
    在使用时需要特别注意:如果某个线程阻塞在StampedLockreadLock()或者writeLock()方法上时,此时调用阻塞线程的interrupt()方法中断线程,会导致CPU飙升到100%。

例如,下面的代码所示。

public void testStampedLock() throws Exception{
    final StampedLock lock = new StampedLock();
    Thread thread01 = new Thread(()->{
        // 获取写锁
        lock.writeLock();
        // 永远阻塞在此处,不释放写锁
        LockSupport.park();
    });
    thread01.start();
    // 保证thread01获取写锁
    Thread.sleep(100);
    Thread thread02 = new Thread(()->
                           //阻塞在悲观读锁
                           lock.readLock()
                          );
    thread02.start();
    // 保证T2阻塞在读锁
    Thread.sleep(100);
    //中断线程thread02
    //会导致线程thread02所在CPU飙升
    thread02.interrupt();
    thread02.join();
}

运行上面的程序,会导致thread02线程所在的CPU飙升到100%。
这里,有很多小伙伴不太明白为啥LockSupport.park();会导致thread01会永远阻塞。这里,冰河为你画了一张线程的生命周期图,如下所示。
在这里插入图片描述

这下明白了吧?在线程的生命周期中,有几个重要的状态需要说明一下。

  • NEW:初始状态,线程被构建,但是还没有调用start()方法。
  • RUNNABLE:可运行状态,可运行状态可以包括:运行中状态和就绪状态。
  • BLOCKED:阻塞状态,处于这个状态的线程需要等待其他线程释放锁或者等待进入synchronized。
  • WAITING:表示等待状态,处于该状态的线程需要等待其他线程对其进行通知或中断等操作,进而进入下一个状态。
  • TIME_WAITING:超时等待状态。可以在一定的时间自行返回。
  • TERMINATED:终止状态,当前线程执行完毕。

所以,在使用StampedLock时,一定要注意避免线程所在的CPU飙升的问题。那如何避免呢?

那就是使用StampedLock的readLock()方法或者读锁和使用writeLock()方法获取写锁时,一定不要调用线程的中断方法来中断线程,如果不可避免的要中断线程的话,一定要用StampedLockreadLockInterruptibly()方法获取可中断的读锁和使用StampedLockwriteLockInterruptibly()方法获取可中断的悲观写锁。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值