【JUC】七、读写锁的演化 && 锁降级

文章目录

  • 1、读写锁
  • 2、读写锁的特点
  • 3、锁演变的代码体现
  • 4、锁的演变总结
  • 5、读写锁的降级
  • 6、写锁饥饿现象
  • 7、复习:悲观锁和乐观锁

1、读写锁

JUC下的锁包的ReadWriteLock接口,以及其实现类ReentrantReadWriteLock

【JUC】七、读写锁的演化 && 锁降级_第1张图片

  • ReadWriteLock 维护了一对相关的锁,即读锁写锁,使得并发和吞吐相比一般的排他锁有了很大提升
  • 读锁属于共享锁
  • 写锁属于独占锁
  • 相比前面的ReentrantLock适用于一般场合,ReadWriteLock 适用于读多写少的场景

关于ReadWriteLock接口的两个方法:

  • 返回用于读的锁
Lock readLock()
  • 返回用于写的锁
Lock writeLock()

2、读写锁的特点

  • 读读共享:允许多个线程同时对同一个资源进行读,不用等前面的线程释放读锁,后面的线程就能获取到读锁,并执行加了读锁的代码
  • 读写互斥:一个线程获取了读锁,未释放前,不允许另一个线程同时来获取写锁进行写操作
  • 写写互斥:不允许多个线程对同一个资源进行写,必须等到前面线程释放写的锁,后面的线程才能获取到写锁并执行加了写锁的代码

写个demo开两个线程去获取读锁和写锁,调试验证下,这里两个线程都不释放自己获取到的读锁或者写锁:

public class ReadWriteDemo2 {

    public static void main(String[] args) {

        //可重入读写锁
        ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
        //读锁
        ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();
        //写锁
        ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();

        //获取读锁
        readLock.lock();
        System.out.println("reading....");

        new Thread(() -> {
            //另一线程获取写锁
            writeLock.lock();
            System.out.println("write....");
        }).start();

        //释放写锁
        //writeLock.unlock();

        //释放读锁
        //readLock.unlock();


    }
}

【JUC】七、读写锁的演化 && 锁降级_第2张图片

前面线程先获取写锁,另一线程去获取读锁:

public class ReadWriteDemo2 {

    public static void main(String[] args) {

        //可重入读写锁
        ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
        //读锁
        ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();
        //写锁
        ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();

        //获取写锁
        writeLock.lock();
        System.out.println("write....");


        new Thread(() -> {
            //另一线程获取读锁
            readLock.lock();
            System.out.println("reading....");
        }).start();

        //释放写锁
        //writeLock.unlock();

        //释放读锁
        //readLock.unlock();


    }
}

【JUC】七、读写锁的演化 && 锁降级_第3张图片

同时获取读锁:

public class ReadWriteDemo2 {

    public static void main(String[] args) {

        //可重入读写锁
        ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
        //读锁
        ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();
        //写锁
        ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();

        //获取读锁
        readLock.lock();
        System.out.println("reading....");


        new Thread(() -> {
            //另一线程也获取读锁
            readLock.lock();
            System.out.println("reading....");
        }).start();

        //释放写锁
        //writeLock.unlock();

        //释放读锁
        //readLock.unlock();


    }
}

【JUC】七、读写锁的演化 && 锁降级_第4张图片

同时获取写锁:

public class ReadWriteDemo2 {

    public static void main(String[] args) {

        //可重入读写锁
        ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
        //读锁
        ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();
        //写锁
        ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();

        //获取写锁
        writeLock.lock();
        System.out.println("writing....");


        new Thread(() -> {
            //另一线程也获取读锁
            writeLock.lock();
            System.out.println("writing....");
        }).start();

        //释放写锁
        //writeLock.unlock();

        //释放读锁
        //readLock.unlock();


    }
}


【JUC】七、读写锁的演化 && 锁降级_第5张图片

3、锁演变的代码体现

写Demo,从运行效果分析各种锁的利弊,从而一步步推出为什么要这么演变。

无锁 ⇒ 独占锁 ⇒ 读写锁 ⇒ 邮戳锁

先看无锁时,开多个线程对同一个资源进行读和写:

//资源类
class MyCache{

    //map模拟redis
    private volatile Map<String,Object> map = new HashMap<>();

    //写
    public void put(String key,Object value){
        System.out.println(Thread.currentThread().getName() + "线程正在进行写操作==>" + key);
        //暂停一会儿
        try {
            TimeUnit.MICROSECONDS.sleep(300);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //放数据
        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);
        //暂停一会儿
        try {
            TimeUnit.MICROSECONDS.sleep(300);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        result = map.get(key);
        System.out.println(Thread.currentThread().getName() + "线程读完了-->" + key);
        return result;
    }
}

创建5个线程来读,5个线程来写:

public class ReadWriteLockDemo {
    public static void main(String[] args) {
        MyCache myCache = new MyCache();
        //创建5个线程来写数据
        for (int i = 1; i < 6; i++) {
            final int num = i;  //临时变量,直接put一个变量i报错(Lambda表达式)
            new Thread(() -> {
                myCache.put(num +"",num+"");
            },String.valueOf(i)).start();
        }
        //创建5个线程来读数据
        for (int i = 1; i < 6; i++) {
            final int num = i;
            new Thread(() -> {
                myCache.get(num +"");
            },String.valueOf(i)).start();
        }
    }

}

运行发现:没写完就开始读,此时肯定读不到,符合不加锁时多线程访问资源类混乱的情况。(线程X正在写,还没写完,另一个线程就进来读或者写了)

【JUC】七、读写锁的演化 && 锁降级_第6张图片
改用synchronized或者Lock接口的ReentrantLock:

//资源类 + synchronized独占锁
class MyCache {

    //map模拟redis
    private volatile Map<String, Object> map = new HashMap<>();

    //写
    public void put(String key, Object value) {
        synchronized (this) {
            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();
            }
        }

    }

    //取
    public synchronized Object get(String key) {
        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();
        }
        return result;
    }
}

运行发现:线程A读的时候,不再会被其他线程中途插进来干扰,不管是读还是写,都是一个线程用完走了,另一个线程才能进来:

【JUC】七、读写锁的演化 && 锁降级_第7张图片

加入读锁和写锁:

//资源类
class MyCache{

    //map模拟redis
    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;
    }
}

和上面一样,再执行main:创建5个线程来读,5个线程来写:

【JUC】七、读写锁的演化 && 锁降级_第8张图片

再调整代码,让读一次耗时从300ms变成3000ms,模拟读线程梳理很多,而写线程梳理很少的效果(一个意思,不管是读线程很多,还是读线程少但耗时很久,最终的效果都是读线程长期占有锁,而写线程抢不到锁一直饿着)

【JUC】七、读写锁的演化 && 锁降级_第9张图片

开线程调用get和put这两个读和写的方法:


public class ReadWriteLockDemo {
    public static void main(String[] args) {
        MyCache myCache = new MyCache();
        //创建5个线程来写数据
        for (int i = 1; i < 6; i++) {
            final int num = i;  //临时变量,直接put一个变量i报错
            new Thread(() -> {
                myCache.put(num +"",num+"");
            },String.valueOf(i)).start();
        }
        //创建5个线程来读数据
        for (int i = 1; i < 6; i++) {
            final int num = i;
            new Thread(() -> {
                myCache.get(num +"");
            },String.valueOf(i)).start();
        }

        //新的写线程,此时,读线程占着锁,写线程一直抢不到锁,即写锁饥饿
        for (int i = 0; i < 3; i++) {
            final int num = i;
            new Thread(() -> {
                myCache.put(num + "",num + "");
            },"新的写线程-->" + i).start();
        }
    }

}

运行发现,读锁没有完成的时候,写锁无法获得(读写互斥),放大这一点,读线程很多,写线程很少,就会导致写锁的锁饥饿

【JUC】七、读写锁的演化 && 锁降级_第10张图片

读写锁的这个缺点,后面章节介绍邮戳锁来解决。

4、锁的演变总结

演化流程: 无锁 一> 独占锁 一> 读写锁 一> 邮戳锁

【JUC】七、读写锁的演化 && 锁降级_第11张图片

  • 无锁情况下,多线程抢夺资源,造成混乱且有数据安全问题
  • 独占锁下,两个写线程不能共享,这很合理,但如果全是读的线程,也排队读,就有待改进了
  • 读写锁:读写互斥,写写互斥,合理,但优化成了写写可以共享,解决了上面的改进点。读多写少的时候,就很合适。但读写锁也有缺点:写锁饥饿问题、锁降级
  • 针对读写锁的缺点,出现邮戳锁StampedLock(后面章节写)

5、读写锁的降级

前面提到,不同线程下,读读共享,读写互斥,写写互斥。

而同一线程中,在持有写锁未解锁的情况下,可以获取读锁。按照如下步骤:

  • 同一个线程持有了写锁
  • 再没有释放写锁的情况下,该线程还可以继续获取读锁
  • 现在该线程再释放写锁
  • 手里就只剩下一个读锁
  • 写锁被成功降级成了读锁

如图:

【JUC】七、读写锁的演化 && 锁降级_第12张图片

在同一个线程中,写锁就被过渡降级到了读锁,读写锁的降级,其目的是为了解决,持有写锁时,其他线程无法获得读锁,影响性能。

public class ReadWriteDemo {

    public static void main(String[] args) {

        //可重入读写锁
        ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
        //读锁
        ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();
        //写锁
        ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();

        //锁降级
        //1.获取写锁
        writeLock.lock();
        System.out.println("write....");

        //2.获取读锁
        readLock.lock();
        System.out.println("reading....");

        //3.释放写锁
        writeLock.unlock();

        //4.释放读锁
        readLock.unlock();


    }
}

补充:

可能会有个疑问,既然释放写锁,干嘛非要手里纂一个读锁后才释放写锁,为何不:

持有写锁 -> 释放写锁 -> 持有读锁 -> 释放读锁

这样的坑在于,你释放完写锁,被另一线程T拿到并写了些数据,等你再拿到读锁时(不是你一释放写锁就一定能给自己拿到读锁,不同线程,读写互斥!),读到的已经是被修改了N手的数据。降级是,你什么时候想要读锁,你就什么时候获取读锁(因为写锁在你手里,你主动的),而如果你先释放写锁,想再获取读锁,那就不是想要就能立马拿到的了。

一句话,同一个线程自己持有写锁时,可再拿读锁,本质相当于重入,


到此,关于ReentrantReadWriteLock这个读写锁接口的实现类的名字,应该有以下三点理解:

【JUC】七、读写锁的演化 && 锁降级_第13张图片

6、写锁饥饿现象

如果写锁被释放时,执行读锁的线程非常多,而需要执行写锁的线程非常少,则会导致读锁一直被使用不被释放,从而造成写线程无法获取写锁,造成写线程一直等待获取,造成写线程“饥饿”。


这个就像某一站地铁,上来100个人,下1个人,结果车一停(类比写锁或者读锁一释放),100个人往进涌(类比其他线程来获取读锁了),把地铁门堵到发车(好多线程,读锁半天没有全部释放完),导致这一个下车的人也愣是没下去(类比少数其他想获取写锁的线程半天获取不到,因为不同线程,读写互斥)。

读写锁的导致的写锁饥饿现象,后面交给邮戳锁StampedLock来完善,其在别的线程读的过程也允许写锁介入,当然,这样可能回导致读的数据不一致,需要用额外的方法来判断读取的过程是否有写入,检测到有写入就需要重读一遍。相当于一种乐观锁。

7、复习:悲观锁和乐观锁

最后,梳理下其他相关的锁。

悲观锁

心态悲观,认为自己在使用数据的时候一定有别的线程来修改数据,因此,次次操作前都上锁,即共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。如Java中的synchronized、ReentrantLock就属于悲观锁的范畴(独占锁)。

乐观锁

总是假设好的方向,即认为自己在使用数据时不会有别的线程修改数据或资源,因此也不上锁,只是在更新时会去判断一下有没人在这期间更新过这个数据,这个"判断",可以使用版本号机制或者CAS算法。

如果判断这个数据已经被其它线程更新,则根据不同的实现方式执行不同的操作,比如放弃修改、重试抢锁等等。用于读操作多的场景,使得读操作的性能大幅提升。

其中:版本号即多维护个字段:

# version=version+1
# where xx=#{xx} and version=#{version}
update table set x=x+1, version=version+1 where id=#{id} and version=#{version};  

CAS算法,CAS 即compare and swap,比较与交换,是一种无锁算法,实现了不用锁的情况下进行多线程变量同步,也称非阻塞同步。其实现思路是一种自旋的思想,即不断的重试(这同时也是乐观锁的一个缺点,长时间不成功并重试CPU开销变大)。CAS算法的三个数:

  • 需要读写的内存值 V
  • 进行比较的值 A
  • 拟写入的新值 B

当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。

ABA问题:

如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回A,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的 "ABA"问题。

根据悲观锁与乐观锁的特点,可以知道:

  • 悲观锁适用于多写少读的场景
  • 乐观锁适用于多读少写的场景

【JUC】七、读写锁的演化 && 锁降级_第14张图片

你可能感兴趣的:(JUC,java,JUC,线程安全,读写锁,锁降级)