读写锁性能之王 —— StampedLock

读写锁性能之王 —— StampedLock

一. StampedLock概述

1.1 简介

StampedLock是JDK1.8中新增的一个读写锁,也是对JDK1.5中的读写锁ReentrantReadWriteLock的优化。

主要包括读写锁之间的转换及更加细粒度并发控制等,前者提供的是不可重入锁,后者的是可重入锁,但是前者通过了乐观读锁在多线程并发中的读多情况下有更好的性能,因为StampedLock获取乐观读锁时,居然不需要通过CAS操作来设置锁的状态,只是简单地通过测试状态即可。

当调用获取锁之类的方法后,会返回一个long型变量:stamp(戳记,long类型),代表了锁的状态。当stamp返回0时,表示线程获取锁失败。并且,当释放锁或者转换锁的时候,都要传入最初获取的stamp值。

1.2 特点

StampedLock提供三种模式的锁,如下。

  • 写锁writeLock(): 是一个独占锁,即某一时刻只有一个线程可以持有该锁,在该线程释放写锁的之前,其他请求读锁和写锁的线程必须等待(与ReentrantReadWriteLock中的写锁相似,不过StampedLock的写锁是不可冲入的)。该锁会返回一个stamp(用来表示该锁的版本),可以在释放写锁的时候调用unlockWrite(long)方法,传入stamp参数。

  • 悲观读锁readLock(): 是一个共享锁,当没有线程获取独占写锁的时候,多个线程可以同时获取该读锁,如果有线程已经持有写锁,那么所有请求获取该读锁的线程都会被阻塞。悲观是指,具体操作数据之前,会悲观的认为其他线程可能对自己要操作的数据进行修改,所以需要先对数据加锁。当请求成功的时候,会返回一个stamp变量表示该锁的版本,当释放该锁的时候需要条用unlockRead方法并传递stamp参数。

  • 乐观读锁tryOptimisticRead(): 该锁在操作数据之前,并没有通过CAS设置锁的状态,仅仅通过位运算测试。如果当前没有线程持有写锁,则简单的返回一个非0的stamp参数,然后在具体操作数据之前还需要拿此参数调用一下validate方法,来验证该stamp是否可用。该锁适用于读多写少的场景,因为获取该锁不涉及CAS操作,所以性能会好很多,但是由于没有使用真正的锁,在保证数据一致性上需要复制一份要操作的变量到方法栈里,同时在操作数据时可能有其他线程已经修改了数据,而我们操作的是数据的快照,所以最多就是返回的不是最新的数据,但是一致性还是能够得以保证的。

StampedLock还支持这三种锁在一定条件下进行相互转换。例如tryConvertToWriteLock(long)期望把stamp标识的锁升级为写锁,这个方法会在以下几种情况下返回一个有效的stamp:

  • 当前锁已经为写锁。
  • 当前锁为读锁,并且没有其他线程是读锁模式。
  • 当前锁是乐观锁模式,并且锁是可用的。

 

二. StampedLock示例

TestStampedLock类里有两个成员变量x,y,组成一个坐标,实例化一个StampedLock对象来保证操作的原子性。

public class TestStampedLock {

    private double x,y;
    private final StampedLock sl = new StampedLock();

}

move方法:改变x,y的值。首先获取了写锁,然后修改x,y的值,最后释放写锁。由于StampedLock的写锁是独占锁,当其他线程调用move方法时,会被阻塞。也保证了其他线程不能获取读锁来读取x,y的值,保证了对x,y操作的原子性和数据的一致性。

    /**
     * 独占锁
     *
     * @param deltaX
     * @param deltaY
     */
    public void move(double deltaX, double deltaY) {
        long stamp = sl.writeLock();
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            sl.unlockWrite(stamp);
        }
    }

distanceFromOrigin方法:计算当前坐标到原点的距离。(1)获取了乐观读锁,如果当前没有其他线程获取到了写锁,那么(1)的返回值就是非0,(2)复制了坐标变量到本地方法栈中。

(3)检查(1)获取到的stamp值是否还有效。之所以要检测,是因为(1)获取乐观读锁的时候没有通过CAS修改状态,而是通过为运算符返回一个stamp,在这里校验是看在获取stamp后判断前是否有其他线程持有写锁,如果有的话,则stamp无效。

(7)在计算期间,也有可能其他线程在这段时间里获取了写锁,并修改了x,y值,而(7)操作的是方法栈里的值,也就是快照而已,并不是最新的值。

(3)校验失败后,会获取悲观读锁,这时候如果有其他线程持有了写锁,则(4)会一直阻塞至其他线程释放了写锁,否则,当前线程获取到了读锁,执行(5)(6)。代码在执行(5)的时候,由于加了读锁,所以在这期间其他线程获取写锁的时候会阻塞,这保证了数据的一致性。

另外,这里的x,y没有被声明volatile会不会内存不可见,答案是不会的,因为加锁的语义保存了内存可见性。

当然,最后计算的值,依然有可能不是最新的。

    /**
     * 乐观锁
     */
    public double distanceFromOrigin() {

        // (1)尝试获取乐观读锁
        long stamp = sl.tryOptimisticRead();
        // (2)将变量复制到方法栈中
        double currentX = x, currentY = y;
        // (3)检查获取读锁后,锁有没被其他线程排他性占抢
        if (!sl.validate(stamp)) {
            // (4)如果被抢占则获取一个共享读锁
            stamp = sl.readLock();
            try {
                // (5) 将成员变量复制到方法体内
                currentX = x;
                currentY = y;
            } finally {
                // (6) 释放共享读锁
                sl.unlockRead(stamp);
            }
        }
        // (7)返回计算结果
        return Math.sqrt(currentX * currentX + currentY * currentY);

    }

moveAtOrigin方法:如果当前坐标在原点,则移动坐标。(1)获取悲观读锁,保证其他线程不能获取写锁来修改x,y的值。(2)判断是否在原点,是的话,则(3)尝试升级读锁为写锁,因为这时候可能有多个线程持有该悲观读锁,所以不一定能升级成功。当多个线程都执行到(3)时,则只有一个可以升级成功,然后执行(4)更新stamp,修改坐标值,退出循环。失败的话,执行(5),先释放读锁,再申请写锁,再循环。最后执行(6)释放锁。

    /**
     * 使用悲观锁获取读锁,并尝试转换为写锁
     */
    public void moveAtOrigin(double newX, double newY) {
        // (1)
        long stamp = sl.readLock();
        try {
            // (2)如果当前点在原点则移动
            while (x == 0.0 && y == 0.0) {
                // (3)尝试将获取的读锁升级为写锁
                long ws = sl.tryConvertToWriteLock(stamp);
                if (ws != 0L) {
                    // (4)升级成功,更新戳记,并设置坐标,退出循环
                    stamp = ws;
                    x = newX;
                    y = newY;
                    break;
                } else {
                    // (5)读锁升级写锁失败,显示获取独占锁,循环重试
                    sl.unlockRead(stamp);
                    stamp = sl.writeLock();
                }
            }
        } finally {
            sl.unlock(stamp);
        }

    }

这里在使用乐观锁的时候,要考虑得比较多,必须要保证以下顺序:

// 非阻塞获取乐观锁
long stamp = lock.tryOptimisticRead();
// 复制变量到本地方法栈中
copy();
// 校验stamp是否生效
if (!validate(stamp)) {
    // 获取悲观读锁
    long ws = lock.readLock();
    try {
        // 复制变量到本地方法栈中
        copy();
    } finally {
        // 释放悲观锁
        lock.unlock(stamp);
    }
}

三. StampedLock总结

  1. 所有获取锁的方法,都会返回一个stamp戳记,stamp为0表示获取失败,其余均表示成功。
  2. 所有释放锁的方法,都需要一个stamp戳记,这个stamp必须和成功获取锁时返回的stamp一样。
  3. StampedLock是不可重入的锁,并且读性能比ReentrantReadWriteLock好,
  4. StampedLock支持读锁和写锁的互相转换。
  5. ReentrantReadWriteLock的锁被占用的时候,如果其他线程尝试获取写锁的时候,会被阻塞,但是,StampedLock在乐观获取锁后,其他线程尝试获取写锁,也不会被阻塞,这其实是对读锁的优化,所以,在获取乐观读锁后,还需要对结果进行校验。
posted @ 2019-07-06 15:23  Kobelieve  阅读(1120)  评论(0编辑  收藏  举报