【Java】有关StampedLock的笔记+StampedLock的队列与AQS有什么区别

0. Why StampedLock

ReadWriteLock 适合读多写少的高并发场景,但读写互斥(读时不允许写,写时不允许读)

  • 写饥饿问题:一个线程”写“的时候,其它线程不能读也不能写(阻塞该线程,会影响性能)

StampedLock :在读的时候如果发生了写,应该通过重试的方式来获取新的值,而不应该阻塞写操作(乐观读,无锁编程,类似CAS的思想)

  • 支持多个线程申请乐观读的同时,还允许一个线程申请写锁
  • 避免了写饥饿,适合读多写少且写线程非常少的场景(比ReadWriteLock更快):
    • 没有写只有读时:不用加锁读
    • 写操作后:加锁读
  • 底层并不是基于AQS的
  • 读写都不可重入

1. StampedLock概念

1.1 三种访问模式

  • 乐观读
    • 在多个线程读取共享变量时,允许一个线程对共享变量进行写操作
    • 不加锁,直接操作数据:
      • 操作数据前也不CAS设置锁状态,仅位运算测试stamp。
      • 获取该 stamp 后在具体操作数据前还需要调用validate 方法验证该 stamp 是否己经不可用
  • 读锁(悲观)与写锁:
    • ReadWriteLock 类似:只允许一个线程获取写锁(独占),写锁和读锁也是互斥的
    • ReadWriteLock 不同:StampedLock获取读锁或写锁成功后,都会返回一个Long,释放锁时需要传入这个Long。且StampedLock不可重入

1.2 stamp 印戳

StampedLock 获取锁后,返回一个long类型的stamp,通过位运算来确定锁的状态

  • 结果为0:如果当前没有线程持有写锁
  • 结果不为0:如果当前有线程持有写锁,只能悲观读写

2. StampedLock的使用

2.1 读写锁的获取与使用

public class StampedLockDemo{
    //创建StampedLock锁对象
    public StampedLock stampedLock = new StampedLock();
    
    //获取、释放读锁
    public void testGetAndReleaseReadLock(){
        long stamp = stampedLock.readLock();
        try{
            //执行获取读锁后的业务逻辑
        }finally{
            //释放锁
            stampedLock.unlockRead(stamp);
        }
    }
    
    //获取、释放写锁
    public void testGetAndReleaseWriteLock(){
        long stamp = stampedLock.writeLock();
        try{
            //执行获取写锁后的业务逻辑。
        }finally{
            //释放锁
            stampedLock.unlockWrite(stamp);
        }
    }
}

2.2 乐观读

如果在执行乐观读操作时,另外的线程对共享变量进行了写操作,则会把乐观读升级为悲观读锁,比如下面的distanceFromOrigin

package org.example.LockEx;

import java.util.concurrent.locks.StampedLock;

public class StampedLockEx {
    private double x, y;
    private final StampedLock sl = new StampedLock();

    void move(double deltaX, double deltaY) {
        // 写锁 exclusive
        long stamp = sl.writeLock();
        try{
            x += deltaX;
            y += deltaY;
        }finally {
            sl.unlockWrite(stamp);
        }
    }

    double distanceFromOrigin() {
        // 获取乐观读锁,此时可以有另一个线程持有读锁
        long stamp = sl.tryOptimisticRead();
        double currentX = x, currentY = y;
        if (!sl.validate(stamp)) {
            // 获取乐观读锁后,如果stamp失效
            // 即被持有写锁的线程修改了,validate返回false
            // 乐观读锁转为悲观读锁
            stamp = sl.readLock();
            try {
                // 悲观读锁跟写锁是互斥的,重新读一遍资源,后面就不会被写锁修改了
                currentX = x;
                currentY = y;
            }finally {
                // 释放悲观读锁
                sl.unlock(stamp);
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }

    void moveIfAtOrigin(double newX, double newY) { // upgrade
        // Could instead start with optimistic, not read mode
        long stamp = sl.readLock();
        try {
            while (x == 0.0 && y == 0.0) {
                long ws = sl.tryConvertToWriteLock(stamp);
                if (ws != 0L) {
                    stamp = ws;
                    x = newX;
                    y = newY;
                    break;
                }
                else {
                    sl.unlockRead(stamp);
                    stamp = sl.writeLock();
                }
            }
        } finally {
            sl.unlock(stamp);
        }
    }

}

2.2.1 为什么乐观读要升级为悲观读锁而不像CAS一样自旋?

乐观读期间遇到另一个线程写时会升级为悲观读锁,此时读写互斥

因为如果不升级,程序会在循环中反复执行乐观读,直到乐观读期间没有线程执行写操作,类似CAS自旋。

但是前面说过,StampLock适合读多写少且写线程较少的场景,它性能之所以比ReadWriteLock好,就是因为大量的读操作线程可以通过乐观读的方式无锁进行。因此可能会有大量乐观读的线程,如果全都在循环执行,会消耗大量的CPU资源

  • 这里不是说StampLock 里不用CAS,内部关于stamp的操作(比如我们获取锁时返回stamp的方法)都涉及到CAS

3. StampLock的实现:state+队列

state

核心就是使用戳记(stamp)的方式来标记数据的版本,乐观读的时候就是对比stamp来保证线程安全,而获取锁的方法返回的stamp则是通过state属性位运算得到的

state是一个int,默认值是256(1 0000 0000),共32位:

  • 前24位表示版本号
  • 低8位表示锁
    • 低8位的第1位表示是否为写锁:1表示写锁、0表示没有写锁
    • 剩下7位表示悲观读锁的个数

队列

StampedLock内部是基于CLH锁实现的,CLH是一种自旋锁,且是公平的(保证FIFO,不会有锁饥饿)

注意StampedLock 并不通过AQS实现,但是AQS内部的队列也是CLH的变体,所以还是有很多类似的地方

StampLock 的CLH就是维护了一个线程的等待队列,所有申请锁但是没有成功的线程都会包装成一个节点存入队列(类似AQS)。AQS中,每个节点有各种状态(SIGNAL、CONDITION等等),而CLH为每个节点维护了一个locked 属性,true代表获取到锁,false表示成功释放锁

当一个线程试图获得锁时,取得等待队列的尾部节点作为其前序节点,并循环判断前一节点是否成功释放锁:

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

这也是跟AQS不同的地方,AQS中排队等待获取锁的线程节点是前一线程释放锁后unpark() 唤醒的。而StampLock 的CLH中,等待的节点是一直在询问前一节点是否释放锁

注意事项

  • 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();
    }
    

Reference

【高并发】高并发场景下一种比读写锁更快的锁
StampedLock(印戳锁)详解

  • 9
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值