JUC并发编程(6):ReadWriteLock 读写锁、锁降级

1,143 阅读14分钟

ReadWriteLock 读写锁

1、乐观锁和悲观锁

参考developer.51cto.com/article/654…

悲观锁(synchronized关键字和Lock的实现类都是悲观锁)

  • 什么是悲观锁?认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改,这样别人想拿数据就被挡住,直到悲观锁被释放,悲观锁中的共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程
  • 但是在效率方面,处理加锁的机制会产生额外的开销,还有增加产生死锁的机会。另外还会降低并行性,如果已经锁定了一个线程A,其他线程就必须等待该线程A处理完才可以处理
  • 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确(写操作包括增删改)、显式的锁定之后再操作同步资源
  • synchronized关键字和Lock的实现类都是悲观锁,数据库中的行锁,表锁,读锁(共享锁),写锁(排他锁),均为悲观锁,表锁会发生死锁,读锁和写锁都会发生死锁现象。
  • 悲观锁不支持并发

image.png

乐观锁

  • 概念:乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作
  • 乐观锁在Java中通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的,适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅度提升

image.png

乐观锁一般有两种实现方式

  • 采用版本号机制
  • CAS算法实现
//悲观锁的调用方式
public synchronized void m1(){
    //加锁后的业务逻辑
}

//保证多个线程使用的是同一个lock对象的前提下
ReetrantLock lock=new ReentrantLock();
public void m2(){
    lock.lock();
    try{
        //操作同步资源
    }finally{
        lock.unlock();
    }
}

//乐观锁的调用方式
//保证多个线程使用的是同一个AtomicInteger
private  AtomicInteger atomicIntege=new AtomicInteger();
atomicIntege.incrementAndGet();

2、读写锁ReadWriteLock

2.1、读写锁概述

我们开发中应该能够遇到这样的一种情况,对共享资源有读和写的操作,且写操作没有读操作那么频繁。在没有写操作的时候,多个线程同时读一个资源没有任何问题,所以应该允许多个线程同时读取共享资源;但是当一个写者线程在写这些共享资源时,就不允许其他线程进行访问

读写锁实际是一种特殊的自旋锁,它把对共享资源的访问者划分成读者和写者,读者只对共享资源进行读访问,写者则需要对共享资源进行写操作。我们将读操作相关的锁,称为读锁,因为可以共享读,我们也称为“共享锁”,将写操作相关的锁,称为写锁、排他锁、独占锁每次可以多个线程的读者进行读访问,但是一次只能由一个写者线程进行写操作,即写操作是独占式的。

读写锁适合于对数据结构的读次数比写次数多得多的情况。因为读模式锁定时可以共享, 以写模式锁住时意味着独占, 所以读写锁又叫共享-独占锁

2.2、ReadWriteLock读写锁

针对上面这种场景,Java的并发包下提供了读写锁 ReadWriteLock(接口) | ReentrantReadWriteLock(实现类)。

ReadWriteLock 维护了一对相关的锁,一个用于只读操作, 另一个用于写入操作。只要没有 writer读取锁可以由多个 reader 线程同时保持。写入锁是独占的。

public interface ReadWriteLock {
	// 读锁
    Lock readLock();
	// 写锁
    Lock writeLock();
}

ReadWriteLock 读取操作通常不会改变共享资源,但执行写入操作时,必须独占方式来获取锁

  • 对于读取操作占多数的数据结构。 ReadWriteLock 能提供比独占锁更高的并发性
  • 而对于只读的数据结构,其中包含的不变性可以完全不需要考虑加锁操作。
  • 读/写锁使用后都需要分别关闭,跟Lock最后也需要手动关闭是一样一样的。
  • ReadWriteLock是比lock锁更加细粒度的控制

2.3、ReentrantReadWriteLock实现类

ReentrantReadWriteLock实现了ReadWriteLock接口,下面是它的源码

public class ReentrantReadWriteLock implements ReadWriteLock,
java.io.Serializable {
    /** 读锁 */
    private final ReentrantReadWriteLock.ReadLock readerLock;
    /** 写锁 */
    private final ReentrantReadWriteLock.WriteLock writerLock;
    final Sync sync;

    /** 使用默认(非公平)的排序属性创建一个新的
		ReentrantReadWriteLock */
    public ReentrantReadWriteLock() {
        this(false);
    }
    /** 使用给定的公平策略创建一个新的 ReentrantReadWriteLock */
    public ReentrantReadWriteLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
        readerLock = new ReadLock(this);
        writerLock = new WriteLock(this);
    }
    /** 返回用于写入操作的锁 */
    public ReentrantReadWriteLock.WriteLock writeLock() { return
        writerLock; }

    /** 返回用于读取操作的锁 */
    public ReentrantReadWriteLock.ReadLock readLock() { return
        readerLock; }
    abstract static class Sync extends AbstractQueuedSynchronizer {}
    static final class NonfairSync extends Sync {}
    static final class FairSync extends Sync {}
    public static class ReadLock implements Lock, java.io.Serializable {}
    public static class WriteLock implements Lock, java.io.Serializable {}
}

2.4、读写锁注意点

当读写锁是写加锁状态时, 在这个锁被解锁之前, 所有试图对这个锁加锁的线程都会被阻塞

当读写锁在读加锁状态时,所有试图以读模式对它进行加锁的线程都可以得到访问权,但是如果线程希望以写模式对此锁进行加锁, 它必须直到所有的线程释放锁

线程想要进入读锁的前提条件:

  • 不存在其他线程的写锁
  • 没有写请求, 或者有写请求,但调用线程和持有锁的线程是同一个(可重入锁)

线程进入写锁的前提条件:

  • 没有读者线程正在访问
  • 没有其他写者线程正在访问

通常, 当读写锁处于读模式锁住状态时,如果有另外线程试图以写模式加锁,读写锁通常会阻塞随后的读模式锁请求,这样可以避免读模式锁长期占用,而等待的写模式锁请求长期阻塞

2.5、特点

公平选择性:

  • 非公平模式(默认)
    • 当以非公平初始化时,读锁和写锁的获取的顺序是不确定的。非公平锁主张竞争获取,可能会延缓一个或多个读或写线程,但是会比公平锁有更高的吞吐量。
  • 公平模式
    • 当以公平模式初始化时,线程将会以队列的顺序获取锁。当当前线程释放锁后,等待时间最长的写锁线程就会被分配写锁;或者有一组读线程组等待时间比写线程长,那么这组读线程组将会被分配读锁。
    • 当有写线程持有写锁或者有等待的写线程时,一个尝试获取公平的读锁(非重入)的线程就会阻塞。这个线程直到等待时间最长的写锁获得锁后并释放掉锁后才能获取到读锁。

可重入

  • 读锁和写锁都支持线程重进入。但是写锁可以获得读锁,读锁不能获得写锁。因为读锁是共享的,写锁是独占式的。

锁降级

  • 遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁。

支持中断锁的获取

  • 在读锁和写锁的获取过程中支持中断

监控

  • 提供一些辅助方法,例如hasQueuedThreads方法查询是否有线程正在等待获取读锁或写锁、isWriteLocked方法查询写锁是否被任何线程持有等等

2.6、案例演示

场景:我们通过一个缓存的小案例来,在没有使用锁的情况下,实现存储和读取的功能,并通过在多个线程的并发下。:使用 ReentrantReadWriteLock 对一个 hashmap 集合进行读和写的并发操作

volatile关键字:表示数据会不断发生变化,多个线程可见性,禁止指令重排序

没有锁的情况

//资源类
class  ReentrantReadWriteLockDemo{

    //创建 map 集合
    private volatile Map<String, Object> map = new HashMap<>();

    //放数据
    public void put(String key, Object value) {
        System.out.println(Thread.currentThread().getName() + "正在写数据" + key);
        //放数据
        map.put(key, value);
        System.out.println(Thread.currentThread().getName() + "写完了" + key);
    }

    //取数据
    public Object get(String key) {
        Object result = null;
        System.out.println(Thread.currentThread().getName() + "正在取数据" + key);
        result = map.get(key);
        System.out.println(Thread.currentThread().getName() + "取完数据了" + key);
        return result;
    }

    public static void main(String[] args) {

        ReentrantReadWriteLockDemo demo = new ReentrantReadWriteLockDemo();

        for (int i = 1; i <= 5; i++) {
            final int number = i;
            new Thread(() -> {//5个线程放数据
                demo.put(String.valueOf(number), number);
            }, String.valueOf(i)).start();
        }

        for (int i = 1; i <= 5; i++) {
            final int number = i;
            new Thread(() -> {//5个线程取数据
                demo.get(String.valueOf(number));
            }, String.valueOf(i)).start();
        }
    }
}

结果

1正在写数据1
3正在写数据3
2正在写数据2
3写完了3
1写完了1
5正在写数据5
4正在写数据4
2写完了2
4写完了4
5写完了5
1正在取数据1
2正在取数据2
1取完数据了1
2取完数据了2
3正在取数据3
4正在取数据4
4取完数据了4
3取完数据了3
5正在取数据5
5取完数据了5

可以看出在一个写线程写数据的时候,有其他线程进入,这显然是不行的。

使用ReadWriteLock读/写锁解决缓存并发问题

//资源类
class  ReentrantReadWriteLockDemo{
    
    //创建 map 集合
    private volatile Map<String, Object> map = new HashMap<>();

    //创建读写锁对象
    private ReadWriteLock rwLock = new ReentrantReadWriteLock();

    //放数据
    public void put(String key, Object value) {
        //添加写锁
        rwLock.writeLock().lock();
        try {
            System.out.println(Thread.currentThread().getName() + "正在写数据" + key);
            //暂停一会
            TimeUnit.MICROSECONDS.sleep(300);
            //放数据
            map.put(key, value);
            System.out.println(Thread.currentThread().getName() + "写完了" + key);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            //释放写锁
            rwLock.writeLock().unlock();
        }
    }

    //取数据
    public Object get(String key) {
        //添加读锁
        rwLock.readLock().lock();
        Object result = null;
        try {
            System.out.println(Thread.currentThread().getName() + "正在取数据" + key);
            //暂停一会
            TimeUnit.MICROSECONDS.sleep(300);
            result = map.get(key);
            System.out.println(Thread.currentThread().getName() + "取完数据了" + key);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            //释放读锁
            rwLock.readLock().unlock();
        }
        return result;
    }

    public static void main(String[] args) {
        
        ReentrantReadWriteLockDemo demo = new ReentrantReadWriteLockDemo();

        for (int i = 1; i <= 5; i++) {
            final int number = i;
            new Thread(() -> {//5个线程放数据
                demo.put(String.valueOf(number), number);
            }, String.valueOf(i)).start();
        }

        for (int i = 1; i <= 5; i++) {
            final int number = i;
            new Thread(() -> {//5个线程取数据
                demo.get(String.valueOf(number));
            }, String.valueOf(i)).start();
        }
    }
}

结果

1正在写数据1
1写完了1
2正在写数据2
2写完了2
3正在写数据3
3写完了3
4正在写数据4
4写完了4
5正在写数据5
5写完了5
1正在取数据1
2正在取数据2
4正在取数据4
3正在取数据3
5正在取数据5
2取完数据了2
1取完数据了1
5取完数据了5
3取完数据了3
4取完数据了4

从结果可以看出,写操作是唯一独占的,多个线程不能同时写,必须等一个线程写完了另外一个线程才能进去,而读的时候是共享的,多个线程可以一起读数据。

2.7、总结(重点)

与传统锁不同的是读写锁的规则是可以共享读,但只能一个写,即不能同时存在读写线程,总结起来为:读读不互斥,读写互斥,写写互斥。而一般的传统独占锁是:读读互斥,读写互斥,写写互斥,而场景中往往读远远大于写,读写锁就是为了这种优化而创建出来的一种机制。注意是读远远大于写,一般情况下独占锁的效率低来源于高并发下对临界区的激烈竞争导致线程上下文切换。因此当并发不是很高的情况下,读写锁由于需要额外维护读锁的状态,可能还不如独占锁的效率高。因此需要根据实际情况选择使用。

ReentrantReadWriteLock和Synchonized、ReentrantLock比较起来有哪些区别呢?或者有哪些优势呢?

  • Synchonized、ReentrantLock是属于传统独占锁,读、写操作每次都只能是一个人访问,效率比较低。
  • 而ReentrantReadWriteLock读操作可以共享,提升性能,允许多人一起读操作,而写操作还是每次一个人访问。
  • 当然ReentrantReadWriteLock优势是有,但是也存在一些缺陷,容易造成锁饥饿,因为如果是读线程先拿到锁的话,并且后续有很多读线程,但只有一个写线程,很有可能这个写线程拿不到锁,它可能要等到所有读线程读完才能进入,就可能会造成一种一直读,没有写的现象。

3、锁降级

3.1、概述

概念:

锁降级的意思就是写锁降级为读锁。而读锁是不可以升级为写锁的。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程,最后释放读锁的过程。

编程模型:

获取写锁—>获取读锁—>释放写锁—>释放读锁

代码演示

public class ReadWriteLockDemo2 {

    public static void main(String[] args) {
        ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
        // 获取读锁
        ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
        // 获取写锁
        ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
        
        //1、获取到写锁
        writeLock.lock();
        System.out.println("获取到了写锁");
        
        //2、获取到读锁
        readLock.lock();
        System.out.println("继续获取到读锁");
        //3、释放写锁
        writeLock.unlock();
	   //4、 释放读锁
        readLock.unlock();
    }
}

结果:

获取到了写锁
继续获取到读锁

也许大家觉得看不出什么,但是如果将获取读锁那一行代码调到获取写锁上方去,可能结果就完全不一样拉。

public class ReadWriteLockDemo2 {

    public static void main(String[] args) {
        ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
        // 获取读锁
        ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
        // 获取写锁
        ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();

        //1、 获取到读锁
        readLock.lock();
        System.out.println("获取到了读锁");

        writeLock.lock();
        System.out.println("继续获取到写锁");

        writeLock.unlock();
        readLock.unlock();
    }
}

结果:执行到读锁就停止了,即读锁不能升级为写锁。

获取到了读锁

原因:

因为在线程持有读锁的情况下,该线程不能取得写锁(因为获取写锁的前提条件是,当前没有读者线程,也没有其他写者线程,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。

但是在线程持有写锁的情况下,该线程可以继续获取读锁(获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)。

当线程获取读锁的时候,可能有其他线程同时也在持有读锁,因此不能把获取读锁的线程“升级”为写锁;而对于获得写锁的线程,它一定独占了读写锁,因此可以继续让它获取读锁,当它同时获取了写锁和读锁后,还可以先释放写锁继续持有读锁,这样一个写锁就“降级”为了读锁。

3.2、使用场景

对于数据比较敏感, 需要在对数据修改以后, 获取到修改后的值, 并进行接下来的其它操作

我们来看个比较实在的案例:

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class CacheDemo {
    /**
     * 缓存器,这里假设需要存储1000左右个缓存对象,按照默认的负载因子0.75,则容量=750,大概估计每一个节点链表长度为5个
     * 那么数组长度大概为:150,又有雨设置map大小一般为2的指数,则最近的数字为:128
     */
    private Map<String, Object> map = new HashMap<>(128);
    private ReadWriteLock rwl = new ReentrantReadWriteLock();
    private Lock writeLock=rwl.writeLock();
    private Lock readLock=rwl.readLock();

    public Object get(String id) {
        Object value = null;
        readLock.lock();//首先开启读锁,从缓存中去取
        try {
            //如果缓存中没有  释放读锁,上写锁
            if (map.get(id) == null) { 
                readLock.unlock();
                writeLock.lock();
                try {
                    //防止多写线程重复查询赋值
                    if (value == null) {
                        //此时可以去数据库中查找,这里简单的模拟一下
                        value = "redis-value";  
                    }
                    //加读锁降级写锁,不明白的可以查看上面锁降级的原理与保持读取数据原子性的讲解
                    readLock.lock(); 
                } finally {
                    //释放写锁
                    writeLock.unlock(); 
                }
            }
        } finally {
            //最后释放读锁
            readLock.unlock(); 
        }
        return value;
    }
}

如果不使用锁降级功能,如先释放写锁,然后获得读锁,在这个获取读锁的过程中,可能会有其他线程竞争到写锁 或者是更新数据 则获得的数据是其他线程更新的数据,可能会造成数据的污染,即产生脏读的问题。

3.3、锁降级的必要性

锁降级中读锁的获取是否必要呢?

答案是必要的。主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁, 假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。