JUC系列(八)| 读写锁-ReadWriteLock

1,365 阅读11分钟

这是我参与8月更文挑战的第23天,活动详情查看:8月更文挑战

多线程一直Java开发中的难点,也是面试中的常客,趁着还有时间,打算巩固一下JUC方面知识,我想机会随处可见,但始终都是留给有准备的人的,希望我们都能加油!!!

沉下去,再浮上来,我想我们会变的不一样的。

关于封面:一个非常喜欢的女孩子拍的照片

作者:次辣条吗

JUC系列

一、读写锁

1)概述:

我们开发中在大都数场景中,都是遇到这样的一个场景,对一个资源而言,读的次数往往比比写的次数要多, 在没有写操作的时候,多个线程同时读一个资源没有任何问题,所以应该允许多个线程同时读取共享资源;但是当一个写者线程在写这些共享资源时,就不允许其他线程进行访问。

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

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

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

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

ReentrantReadWriteLock这个得自己去看哈,这里给出一个整体架构哈😁。

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)使用相关:

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

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

  3. 如果线程想要进入读锁的前提条件:

    • 不存在其他线程的写锁

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

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

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

3)特点:

🛫公平选择性:

  1. 非公平模式(默认)

    • 当以非公平初始化时,读锁和写锁的获取的顺序是不确定的。非公平锁主张竞争获取,可能会延缓一个或多个读或写线程,但是会比公平锁有更高的吞吐量。
  2. 公平模式

    • 当以公平模式初始化时,线程将会以队列的顺序获取锁。当当前线程释放锁后,等待时间最长的写锁线程就会被分配写锁;或者有一组读线程组等待时间比写线程长,那么这组读线程组将会被分配读锁。

    • 当有写线程持有写锁或者有等待的写线程时,一个尝试获取公平的读锁(非重入)的线程就会阻塞。这个线程直到等待时间最长的写锁获得锁后并释放掉锁后才能获取到读锁。

🛬可重入

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

💺锁降级

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

🚤支持中断锁的获取

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

🛸监控

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

二、案例实现

一个特别简单的案例哈。

🍟代码

场景: 使用 ReentrantReadWriteLock 对一个 hashmap 进行读和写操作

package com.crush.juc06;

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

//资源类
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(() -> {
                demo.put(String.valueOf(number), number);
            }, String.valueOf(i)).start();
        }

        for (int i = 1; i <= 5; i++) {
            final int number = i;
            new Thread(() -> {
                demo.get(String.valueOf(number));
            }, String.valueOf(i)).start();
        }
    }
}
/**
5正在进行写操作5
5写完了5
4正在进行写操作4
4写完了4
3正在进行写操作3
3写完了3
2正在进行写操作2
2写完了2
1正在进行写操作1
1写完了1
1正在取数据1
4正在取数据4
3正在取数据3
5正在取数据5
2正在取数据2
1取完数据了1
4取完数据了4
2取完数据了2
5取完数据了5
3取完数据了3
 */

写是唯一的,而读的时候是共享的。

🍔小总结

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

  • Synchonized、ReentrantLock是属于独占锁,不管是读操作还是写操作,都只能一个人进行访问,这样导致效率极低。

  • 而ReentrantReadWriteLock读操作可以共享,而写操作还是每次一个人访问,这样的情况下,性能方面比起独占锁就要好的多。

当然ReentrantReadWriteLock优势是有,但是也存在一些缺陷,容易造成锁饥饿,因为如果是读线程先拿到锁的话,并且后续有很多读线程,但只有一个写线程,很有可能这个写线程拿不到锁,它可能要等到所有读线程读完才能进入,就可能会造成一种一直读,没有写的现象

三、锁降级

🍜概念:

锁降级的意思就是写锁降级为读锁。而读锁是不可以升级为写锁的

如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。

锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程,最后释放读锁的过程

编程模型:

  • 获取写锁--->获取读锁--->释放写锁--->释放读锁

简单的代码:

/**
 * @Author: crush
 * @Date: 2021-08-21 9:04
 * version 1.0
 */
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();
        // 释放写锁
    }
}

image-20210821163022544.png

🍿原因:

为什么会出现上面这一幕呢?

  1. 因为在线程持有读锁的情况下,该线程不能取得写锁(因为获取写锁的前提条件是,当前没有读者线程,也没有其他写者线程,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。
  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 static void main(String[] args) {
    }

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

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

🍖锁降级的必要性:

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

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

四、自言自语

最近又开始了JUC的学习,感觉Java内容真的很多,但是为了能够走的更远,还是觉得应该需要打牢一下基础。

最近在持续更新中,如果你觉得对你有所帮助,也感兴趣的话,关注我吧,让我们一起学习,一起讨论吧。

你好,我是博主宁在春,Java学习路上的一颗小小的种子,也希望有一天能扎根长成苍天大树。

希望与君共勉😁

我们:待别时相见时,都已有所成

参考

并发库应用之五 & ReadWriteLock场景应用

读写锁的使用场景及锁降级

深入理解读写锁—ReadWriteLock源码分析