读写锁、邮戳锁

锁的演进过程

无锁->独占锁->读写锁->邮戳锁

独占锁

ReentrantLock、synchronized:有序,数据一致性,每次只能来一个线程,不管什么操作

缺点:读操作效率低

ReentrantReadWriteLock

读写互斥,读读可以共享,提升大面积的共享性能,同时多人读。

ReentrantReadWriteLock 实现了 ReadWriteLock ,是一个可重入的读写锁,既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。

读写锁进行并发控制的规则:读读不互斥、读写互斥、写写互斥(只有读读不互斥)

缺点:由于读写互斥,所以容易导致写线程饥饿(大多数情况是读多写少,读的过程中,如果没有释放,写线程不可以获得锁;必须读完后,才能有机会写)
锁降级

参考:

ReentrantReadWriteLock中锁降级的理解

锁升级

ReentrantReadWriteLock不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程)。目的也是保证数据可见性,如果读锁已被多个线程获取,其中任意线程获取了写锁并更新了数据,则其更新对其他获取到的读锁的线程数不可见的。

我们知道读写锁的特点是如果线程都申请读锁,是可以多个线程同时持有的,可是如果是写锁,只能有一个线程持有,并且不可能存在读锁和写锁同时持有的情况。正是因为不可能有读锁和写锁同时持有的情况,所以升级写锁的过程中,需要等到所有的读锁都释放,此时才能进行升级。
假设有 A,B 和 C 三个线程,它们都已持有读锁。假设线程 A 尝试从读锁升级到写锁。那么它必须等待 B 和 C 释放掉已经获取到的读锁。如果随着时间推移,B 和 C 逐渐释放了它们的读锁,此时线程 A 确实是可以成功升级并获取写锁。但是我们考虑一种特殊情况。假设线程 A 和 B 都想升级到写锁,那么对于线程 A 而言,它需要等待其他所有线程,包括线程 B 在内释放读锁。而线程 B 也需要等待所有的线程,包括线程 A 释放读锁。这就是一种非常典型的死锁的情况。谁都愿不愿意率先释放掉自己手中的锁。
但是读写锁的升级并不是不可能的,也有可以实现的方案,如果我们保证每次只有一个线程可以升级,那么就可以保证线程安全。只不过最常见的 ReentrantReadWriteLock 对此并不支持。

可以锁降级

遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁。如果一个线程占有了写锁,在不释放写锁的情况下,它还能占有读锁,即写锁降级为读锁。

锁降级的目的:锁降级是为了让当前线程感知到数据的变化,目的是保证数据可见性(写后立即可以读,把写锁降为读锁,保证其他写进程无法进来写,没有读完,写不进去)
为什么要锁降级?

由于对于数据比较敏感, 线程需要在对数据修改以后, 获取到修改后的值, 并进行接下来的其它操作。后面我们对于 data 仅仅是读取。如果还一直使用写锁的话,就不能让多个线程同时来读取了,持有写锁是浪费资源的,降低了整体的效率,所以这个时候利用锁的降级是很好的办法,可以提高整体性能。

ReentrantReadWriteLock规定读写互斥、写写互斥,即写锁和读锁不能同时存在,那为什么锁降级在拥有写锁的同时可以获取读锁呢?
上面所谓的读写互斥针对的是不同线程,即不同线程之间读写和写写是互斥的,然而对于锁降级是发生在同一个线程内,由于当前线程获取了写锁,所以其他线程无法获取写锁和读锁,此时当前线程获取读锁是不会出现线程安全问题。

不能锁升级

当读锁被使用时,如果有线程尝试获取写锁,该写线程会被阻塞。所以,需要释放所有读锁,才可获取写锁。还可能会有死锁问题发生。举个例子:假设两个线程的读锁都想升级写锁,则需要对方都释放自己锁,而双方都不释放,就会产生死锁。并且ReentrantReadWriteLock是读写互斥的。

总结
一个ReentrantReadWriteLock同时只能存在一个写锁但是可以存在多个读锁,但不能同时存在写锁和读锁。也即一个资源可以被多个读操作访问或一个写操作访问,但两者不能同时进行。
只有在读多写少情境之下,读写锁才具有较高的性能体现。

StampedLock

比读写锁更快的锁,解决了读写锁的饥饿问题

stamp (戳记,long类型) 代表了锁的状态。当stamp返回零时,表示线程获取锁失败。并且,当释放锁或者转换锁的时候,都要传入最初获取的stamp值。

  • 所有获取锁的方法,都返回一个邮戳(Stamp) ,Stamp为零表示获取失败,其余都表示成功

  • 所有释放锁的方法,都需要一个邮戳(Stamp) ,这个Stamp必须是和成功获取锁时得到的Stamp一致。

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

StampedLock 的性能之所以比 ReadWriteLock 还要好,其关键是 StampedLock 支持乐观读的方式。ReadWriteLock 支持多个线程同时读,但是当多个线程同时读的时候,所有的写操作会被阻塞;而 StampedLock 提供的乐观读,是允许一个线程获取写锁的,也就是说不是所有的写操作都被阻塞。注意这里,我们用的是“乐观读”这个词,而不是“乐观读锁”,是要提醒你,乐观读这个操作是无锁的,所以相比较 ReadWriteLock 的读锁,乐观读的性能更好一些。

文中下面这段代码是出自 Java SDK 官方示例,并略做了修改。在 distanceFromOrigin() 这个方法中,首先通过调用 tryOptimisticRead() 获取了一个 stamp,这里的 tryOptimisticRead() 就是我们前面提到的乐观读。之后将共享变量 x 和 y 读入方法的局部变量中,不过需要注意的是,由于 tryOptimisticRead() 是无锁的,所以共享变量 x 和 y 读入方法局部变量时,x 和 y 有可能被其他线程修改了。因此最后读完之后,还需要再次验证一下是否存在写操作,这个验证操作是通过调用 validate(stamp) 来实现的。

class Point {
    
    
  private int x, y;
  final StampedLock sl = new StampedLock();
  //计算到原点的距离  
  int distanceFromOrigin() {
    
    
    // 乐观读
    long stamp = sl.tryOptimisticRead();
    // 读入局部变量,
    // 读的过程数据可能被修改
    int curX = x, curY = y;
    //判断执行读操作期间,
    //是否存在写操作,如果存在,
    //则sl.validate返回false
    if (!sl.validate(stamp)){
    
    
      // 升级为悲观读锁
      stamp = sl.readLock();
      try {
    
    
        curX = x;
        curY = y;
      } finally {
    
    
        //释放悲观读锁
        sl.unlockRead(stamp);
      }
    }
    return Math.sqrt(
      curX * curX + curY * curY);
  }
}

由于多数情况是读多写少,所以代码中的升级为悲观读锁往往不会执行,所以效率会比可重入读写锁要高的多。

对于读多写少的场景 StampedLock 性能很好,简单的应用场景基本上可以替代 ReadWriteLock,但是 StampedLock 的功能仅仅是 ReadWriteLock 的子集,在使用的时候,还是有几个地方需要注意一下。

  • StampedLock不支持重入( Reentrant)
  • StampedLock的悲观读锁,写锁不支持条件变量
  • 如果线程阻塞在StampedLock的readLock()或writeLock()上时,此时调用该阻塞线程的interrupt()会导致CPU飙升

例如下面的代码中,线程 T1 获取写锁之后将自己阻塞,线程 T2 尝试获取悲观读锁,也会阻塞;如果此时调用线程 T2 的 interrupt() 方法来中断线程 T2 的话,你会发现线程 T2 所在 CPU 会飙升到 100%。

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

所以,使用 StampedLock 一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读锁readLockInterruptibly() 和写锁 writeLockInterruptibly()。这个规则一定要记清楚。

非中断锁线程调用interrupt()

个人理解:
正常情况(没有打断)下:线程而执行readLock获取读锁,会执行
上面代码执行readLock时会执行acquireRead(false, 0L)操作,acquireRead内部会执行U.park(false, time)操作,该操作表示当前线程无法阻塞,但是当前线程已经被写锁阻塞,所以会导致线程不断死循环。

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

猜你喜欢

转载自blog.csdn.net/weixin_43424325/article/details/129487825