StampedLock使用及源码分析:号称比读写锁还要快的锁

81 阅读15分钟

文章目录

一、StampedLock锁概述

1、StampedLock锁简介

StampedLock类是在JDK8引入的一把新锁,其是对原有ReentrantReadWriteLock读写锁的增强,增加了一个乐观读模式,内部提供了相关API不仅优化了读锁、写锁的访问,也可以让读锁与写锁间可以互相转换,从而更细粒度的控制并发。

也叫邮戳锁、票据锁。

2、ReentrantReadWriteLock回顾

  • 读写锁适用于读多写少的场景,内部有写锁和读锁。
  • 读锁是一把共享锁,当一个线程持有某一个数据的读锁时,其他线程也可以对这条数据进行读取,但是不能写。
  • 写锁是一把独占锁,一个线程持有某一个数据的写锁时,其他线程是不可以获取到这条数据的写锁和读锁的。
  • 对于锁升级来说,当一个线程在没有释放读锁的情况下,就去申请写锁,是不支持的。
  • 对于锁降级来说,当一个线程在没有释放写锁的情况下,去申请读锁,是支持的。

3、ReentrantReadWriteLock导致锁饥饿问题

在使用读写锁时,还容易出现写线程饥饿的问题。主要是因为读锁和写锁互斥。比方说:当线程 A 持有读锁读取数据时,线程 B 要获取写锁修改数据就只能到队列里排队。此时又来了线程 C 读取数据,那么线程 C 就可以获取到读锁,而要执行写操作线程 B 就要等线程 C 释放读锁。由于该场景下读操作远远大于写的操作,此时可能会有很多线程来读取数据而获取到读锁,那么要获取写锁的线程 B 就只能一直等待下去,最终导致饥饿。

也就是说,ReentrantReadWriteLock一旦读操作比较多的时候,想要获取写锁就变得比较困难了。假如说当前1000个线程,999个读,1个写,有可能999个读线程长时间抢到了锁,那1个写线程就悲剧了,因为当前有可能会一直存在读锁,而无法获得写锁,根本没机会写。

4、锁饥饿问题的缓解

对于写线程饥饿问题,可以通过公平锁进行一定程度的解决,但是它是以牺牲系统吞吐量为代价的。

new ReentrantReadWriteLock(true);

5、StampedLock与ReentrantReadWriteLock的对比

ReentrantReadWriteLock允许多个线程同时读,但是只允许一个线程写,在线程获取到写锁的时候,其他写操作和读操作都会处于阻塞状态,读锁和写锁也是互斥的,所以在读的时候是不允许写的,读写锁比传统的Synchronized速度要快很多,原因就是在于ReentrantReadWriteLock支持读并发,读读可以共享

ReentrantReadWriteLock的读锁被占用的时候,其他线程尝试获取写锁的时候会被阻塞。
但是StampedLock采取乐观获取锁后,其他线程尝试获取写锁时不会被阻塞,这其实是对读锁的优化,所以,在获取乐观读锁后,还需要对结果进行校验

总之,对短的只读代码段,使用乐观模式通常可以减少争用并提高吞吐量

6、StampedLock特点

获取锁的方法,会返回一个票据(stamp),当该值为0代表获取锁失败,其他值都代表成功。

释放锁的方法,都需要传递获取锁时返回的票据(stamp),这个stamp必须是和成功获取锁时得到的Stamp一致,从而控制是同一把锁。

StampedLock是不可重入的,如果一个线程已经持有了写锁,再去获取写锁就会造成死锁。

StampedLock提供了三种模式控制读写操作:写锁、悲观读锁、乐观读锁

在StampedLock中读锁和写锁可以相互转换,而在ReentrantReadWriteLock中,写锁可以降级为读锁,而读锁不能升级为写锁。

7、StampedLock的缺点

  • StampedLock不支持重入。
  • StampedLock的悲观读锁和写锁都不支持条件变量(Condition)。
  • 使用StampedLock一定不要调用中断操作,即不要使用interrupt()方法,会影响性能。

二、StampedLock的使用

1、StampedLock的三种模式介绍

(1)写锁

使用类似于ReentrantReadWriteLock,是一把独占锁,当一个线程获取该锁后,其他请求线程会阻塞等待。 对于一条数据没有线程持有写锁或悲观读锁时,才可以获取到写锁,获取成功后会返回一个票据,当释放写锁时,需要传递获取锁时得到的票据。

(2)悲观读锁

使用类似于ReentrantReadWriteLock,是一把共享锁,多个线程可以同时持有该锁。当一个数据没有线程获取写锁的情况下,多个线程可以同时获取到悲观读锁,当获取到后会返回一个票据,并且阻塞线程获取写锁。当释放锁时,需要传递获取锁时得到的票据。

(3)乐观读锁

这把锁是StampedLock新增加的。可以把它理解为是一个悲观锁的弱化版。当没有线程持有写锁时,可以获取乐观读锁,并且返回一个票据。值得注意的是,它认为在获取到乐观读锁后,数据不会发生修改,获取到乐观读锁后,其并不会阻塞写入的操作。

那这样的话,它是如何保证数据一致性的呢? 乐观读锁在获取票据时,会将需要的数据拷贝一份,在真正读取数据时,会调用StampedLock中的API,验证票据是否有效。如果在获取到票据到使用数据这期间,有线程获取到了写锁并修改数据的话,则票据就会失效。 如果验证票据有效性时,当返回true,代表票据仍有效,数据没有被修改过,则直接读取原有数据。当返回flase,代表票据失效,数据被修改过,则重新拷贝最新数据使用。

乐观读锁适用于一些很短的只读代码,它可以降低线程之间的锁竞争,从而提高系统吞吐量。但对于读锁获取数据结果必须要进行校验。

2、官方案例

public class Point {
    //定义共享数据
    private double x, y;

    //实例化锁
    private final StampedLock sl = new StampedLock();

    //写锁案例
    void move(double deltaX, double deltaY) {

        //获取写锁
        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 = sl.readLock(); //如果有,我们再次获得一个读悲观锁
            try {
                currentX = x; // 将两个字段读入本地局部变量
                currentY = y; // 将两个字段读入本地局部变量
            } finally {
                sl.unlockRead(stamp);
            }
        }
        return Math.sqrt(currentX * currentX
                + currentY * currentY);
    }

    //使用悲观读锁并锁升级案例
    void moveIfAtOrigin(double newX, double newY) {

        // 获取悲观读锁
        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);
        }
    }
}

3、使用案例

public class StampedLockDemo {

    static int number = 10;
    static StampedLock stampedLock = new StampedLock();

    // 写锁案例
    public void write() {
        // 写锁,获取stamp
        long stamp = stampedLock.writeLock();
        System.out.println(Thread.currentThread().getName() + "\t" + "写线程准备修改");
        try {
            // 写操作
            number = number + 1;
        } finally {
            // 释放锁
            stampedLock.unlockWrite(stamp);
        }
        System.out.println(Thread.currentThread().getName() + "\t" + "写线程结束修改");
    }

    // 悲观读锁案例,读的过程不允许写锁介入
    public void read() {
        // 读锁
        long stamp = stampedLock.readLock();
        System.out.println(Thread.currentThread().getName() + "\t" + "读线程begin");
        try {
            System.out.println(Thread.currentThread().getName() + "\t" + "正在读取 4s");
            Thread.sleep(4000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        try {
            int result = number;
            System.out.println(Thread.currentThread().getName() + "\t" + "读取结束" + result);
        } finally {
            // 释放读锁
            stampedLock.unlockRead(stamp);
        }

    }

    // 乐观读,读的过程允许写锁介入
    public void read2() {
        // 乐观读
        long stamp = stampedLock.tryOptimisticRead();
        // 乐观的认为,读取中没有线程修改过值
        int result = number;
        try {
            System.out.println(Thread.currentThread().getName() + "\t" + "正在读取 3s");
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //检查发出乐观读锁后同时是否有其他写锁发生,true为无修改,false为有修改
        if(!stampedLock.validate(stamp)) {
            System.out.println(Thread.currentThread().getName() + "\t" + "乐观读失败,数据有变动,升级为悲观读");
            try {
                // 变更锁,升级为悲观锁
                stamp = stampedLock.readLock();
                result = number;
            } finally {
                // 解锁
                stampedLock.unlockRead(stamp);
            }

        }

        System.out.println(Thread.currentThread().getName() + "\t" + "读取的数据" + result);


    }

    public static void main(String[] args) {
        StampedLockDemo stampedLockDemo = new StampedLockDemo();
        // 读线程
        new Thread(() -> {
            stampedLockDemo.read2();
        }, "readThread1").start();

        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 写线程
        new Thread(() -> {
            stampedLockDemo.write();
        }, "writeThread").start();
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 读线程
        new Thread(() -> {
            stampedLockDemo.read2();
        }, "readThread2").start();
    }
}

三、源码分析

1、实例化

StampedLock是基于CLH自旋锁实现,锁会维护一个等待线程链表队列,所有没有成功申请到锁的线程都以FIFO的策略记录到队列中,队列中每个节点代表一个线程,节点保存一个标记位,判断当前线程是否已经释放锁。

当一个线程试图获取锁时,首先取得当前队列的尾部节点作为其前序节点,并判断前序节点是否已经释放锁,如果前序节点没有释放锁,则当前线程还不能执行,进入自旋等待。如果前序节点已经释放锁,则当前线程执行。

首先需要先了解一些StampedLock类的常量值,方便与后面源码的理解。
在这里插入图片描述
另外还有两个很重要的属性:state、readerOverFlow:
state:当前锁的状态,是由写锁占用还是由读锁占用。其中long的倒数第八位是1,则表示由写锁占用(00000001),前七位由读锁占用(1-126)。
readerOverFlow:当读锁的数量超过了范围,通过该值进行记录。

当实例化StampedLock时,会设置节点状态值为ORIGIN(0000 0000)。
在这里插入图片描述

2、获取锁过程分析

假设现在有四个线程:ThreadA获取写锁、ThreadB获取读锁、ThreadC获取读锁、ThreadD获取写锁。

(1)ThreadA获取写锁

该方法用于获取写锁,如果当前读锁和写锁都未被使用的话,则获取成功并更新state,返回一个long值,代表当前写锁的票据,如果获取失败,则调用acquireWrite()将写锁放入等待队列中。

因为当前还没有任务线程获取到锁,所以ThreadA获取写锁成功。

// java.util.concurrent.locks.StampedLock#writeLock
public long writeLock() {
    long s, next;  // bypass acquireWrite in fully unlocked case only
    return ((((s = state) & ABITS) == 0L &&
             U.compareAndSwapLong(this, STATE, s, next = s + WBIT)) ?
            next : acquireWrite(false, 0L));
}

(2)ThreadB获取读锁

// java.util.concurrent.locks.StampedLock#readLock
public long readLock() {
    long s = state, next;  // bypass acquireRead on common uncontended case
    return ((whead == wtail && (s & ABITS) < RFULL &&
             U.compareAndSwapLong(this, STATE, s, next = s + RUNIT)) ?
            next : acquireRead(false, 0L));
}

该方法用于获取读锁,如果写锁未被占用,则获取成功,返回一个long值,并更新state,如果有写锁存在,则调用acquireRead(),将当前线程包装成一个WNODE放入等待队列,线程会被阻塞。

因为现在ThreadA已经获取到了写锁并且没有释放,所以ThreadB在获取读锁时,一定会阻塞,被包装成WNode进入等待队列中。

在acquireRead()内部会进行两次for循环进行自旋尝试获取锁,每次for循环次数由CPU核数决定,进入到该方法后,首先第一次自旋会尝试获取读锁,获取成功,则直接返回。否则,ThreadB会初始化等待队列,并创建一个WNode,作为队头放入等待队列,其内部模式为写模式,线程对象为null,status为0【初始化】。同时还会将当前线程ThreadB包装为WNode放入等待队列的队尾中,其内部模式为读模式,thread为当前ThreadB对象,status为0。

在这里插入图片描述
当进入到第二次自旋后,还是先尝试获取读锁,如果仍没有获取到,则将前驱节点的状态设置为-1【WAITING】,用于代表当前ThreadB已经进入等待阻塞。

在这里插入图片描述

(3)ThreadC获取读锁

ThreadC在获取读锁时,其过程与ThreadB类似,因为ThreadA的写锁没有释放,ThreadC也会进入等待队列。但与ThreadB不同的是,ThreadC不会占用等待队列中的一个新节点,因为其前面的ThreadB也是一个读节点,它会赋值给用于表达ThreadB的WNode中的cowait属性,实际上构成一个栈。
在这里插入图片描述

(4)ThreadD获取写锁

由于ThreadA的写锁仍然没有释放,当ThreadD调用writeLock()获取写锁时,内部会调用acquireWrite()。

acquireWrite()内部的逻辑和acquireRead()类似,也会进行两次自旋。第一次自旋会先尝试获取写锁,获取成功则直接返回,获取失败,则会将当前线程TheadD包装成WNode放入等待队列并移动队尾指针,内部属性模式为写模式,thread为ThreadD对象,status=0【初始化】。
在这里插入图片描述
当进入到第二次自旋,仍然会尝试获取写锁,如果获取不到,会修改其前驱节点状态为-1【等待】,并阻塞当前线程。
在这里插入图片描述

3、释放锁过程分析

(1)ThreadA释放写锁

当要释放写锁时,需要调用unlockWrite(),其内部首先会判断,传入的票据与获取锁时得到的票据是否相同,不同的话,则抛出异常。如果相同先修改state,接着调用release(),唤醒等待队列中的队首节点【即头结点whead的后继节点】

// java.util.concurrent.locks.StampedLock#unlockWrite
public void unlockWrite(long stamp) {
    WNode h;
    if (state != stamp || (stamp & WBIT) == 0L)
        throw new IllegalMonitorStateException();
    state = (stamp += WBIT) == 0L ? ORIGIN : stamp;
    if ((h = whead) != null && h.status != 0)
        release(h);
}
// 唤醒队列的队首节点【头结点whead的后继节点】
private void release(WNode h) {
    if (h != null) {
        WNode q; Thread w;
        U.compareAndSwapInt(h, WSTATUS, WAITING, 0); // 将头结点状态从-1变为0,标识要唤醒其后继节点
        if ((q = h.next) == null || q.status == CANCELLED) { // 判断头结点的后继节点是否为null或状态为取消
            for (WNode t = wtail; t != null && t != h; t = t.prev) // 从队尾查找距头结点最近的状态为等待的节点
                if (t.status <= 0)
                    q = t; // 赋值
        }
        if (q != null && (w = q.thread) != null)
            U.unpark(w); // 唤醒队首节点
    }
}

在release()中,它会先将头结点whead的状态修改从-1变为0,代表要唤醒其后继节点,接着会判断头结点whead的后继节点是否为null或者其后继节点的状态是否为1【取消】。 如果不是,则直接调用unpark()唤醒队首节点,如果是的话,再从队尾开始查找距离头结点最近的状态<=0【WAITING或初始化】的节点。
在这里插入图片描述
当ThreadB被唤醒后,它会从cowait中唤醒栈中的所有线程,因为读锁是一把共享锁,允许多线程同时占有。

在这里插入图片描述
当所有的读锁都被唤醒后,头结点指针会后移,指向ThreadB这个WNode,并将原有的头结点移出等待队列
在这里插入图片描述
此时ThreadC已经成为了孤立节点,最终会被GC。最终队列结构:
在这里插入图片描述

(2)ThreadB和ThreadC释放读锁

读锁释放需要调用unlockRead(),其内部先判断票据是否正确,接着会对读锁数量进行扣减,当读锁数量为0,会调用release()唤醒队首节点。

public void unlockRead(long stamp) {
    long s, m; WNode h;
    for (;;) {
        if (((s = state) & SBITS) != (stamp & SBITS) ||
            (stamp & ABITS) == 0L || (m = s & ABITS) == 0L || m == WBIT)
            throw new IllegalMonitorStateException();
        if (m < RFULL) {
            if (U.compareAndSwapLong(this, STATE, s, s - RUNIT)) {
                if (m == RUNIT && (h = whead) != null && h.status != 0)
                    release(h);
                break;
            }
        }
        else if (tryDecReaderOverflow(s) != 0L)
            break;
    }
}

其内部同样会先将头结点状态从-1该为0,标识要唤醒后继节点
在这里插入图片描述
当ThreadD被唤醒获取到写锁后,头结点指针会后移指向ThreadD,并原有头部节点移出队列。
在这里插入图片描述

4、乐观读锁解析

在ReentrantReadWriteLock中,只有写锁和读锁的概念,但是在读多写少的环境下,容易出现写线程饥饿问题,虽然能够通过公平锁解决,但会造成系统吞吐量降低。

乐观读锁只需要获取,不需要释放。在获取时,只要没有线程获取写锁,则可以获取到乐观读锁,同时将共享数据储存到局部变量中。同时在获取到乐观读锁后,并不会阻塞其他线程对共享数据进行修改。

因为就会造成当使用共享数据时,出现数据不一致的问题。因为在使用乐观读锁时,要反复的对数据进行校验。

public long tryOptimisticRead() {
    long s; // 没有线程获取写锁,则乐观读锁获取成功,返回票据
    return (((s = state) & WBIT) == 0L) ? (s & SBITS) : 0L;
}

public boolean validate(long stamp) {
    U.loadFence(); // 传入乐观读锁stamp,验证是否有线程获取到写锁
    return (stamp & SBITS) == (state & SBITS);
}