程序员社区

Java并发编程之显式锁

1.前言

       使用Java内置锁的时候,不需要通过Java代码显式地对同步对象的监视器(Monitor)进行抢占和释放,因为这些工作由JVM层面来完成。而且任何一个Java对象都能作为一个内置锁来使用,所以,Java的对象锁使用起来很方便。但是,Java内置锁的功能相对单一,不具备一些比较高级的锁功能:

  • 限时抢锁:在抢锁时设置超时时长,如果超时还未获得锁就放弃,不至于无限等下去
  • 中断抢锁:在抢锁时,外部线程给抢锁线程发出一个中断信号,就能唤起等待锁的线程,并且终止抢占过程。
  • 多个等待队列:为锁维持多个等待队列,以便提高锁的效率。比如在生产者消费者模式实现中,生产者和消费者共用一把锁,该锁上维持两个等待队列,一个生产者队列,一个消费者队列。

       除了以上的功能问题之外,Java对象锁还存在性能问题。在竞争稍微激烈的情况下,Java对象锁会膨胀为重量级锁(基于操作对象的Mutex Lock实现),而重量级锁的线程阻塞和唤醒操作,需要进程在内核状态和用户态之间来回切换,导致性能非常低。所以这个时候就需要引入一种新的锁。
       Java显式锁就是为了解决这些Java对象的功能问题、性能问题而生。Lock是Java代码级别的锁。为了和Java对象锁区分,Lock接口叫显式锁接口,其对象实例叫显式锁对象。

2.Lock的实现

       Lock本质上是一个接口,它定义了释放锁和获得锁的抽象方法,实现Lock接口的类有很多,以下是几个常见的锁实现:
ReentrantLock:表示重入锁,它是唯一一个实现了Lock接口的类。重入锁指的是线程在获得锁之后,再次获取该锁不需要阻塞,而是直接关联一次计算器增加重入次数。
ReentrantReadWriteLock:重入读写锁。它实现了ReadWriteLock接口。在这个类中维护了两个锁,一个是ReadLock,一个是WriteLock,它们分别实现了Lock接口。读写锁是一种适合读多写少的场景下解决线程安全问题的工具,基本原则是:读和读不互斥,读和写互斥,写和写互斥。也就是说涉及到影响数据变化的操作都会存在互斥。
StampedLock:stampedLock 是 JDK8 引入的新的锁机制,可以简单认为是读写锁的一个改进版本,读写锁虽然通过分离读和写的功能使得读和读之间可以完全并发,但是读和写是有冲突的,如果大量的读线程存在,可能会引起写线程的饥饿。stampedLock 是一种乐观的读策略,使得乐观锁完全不会阻塞写线程。

3. 显式锁的分类

       显式锁有很多种,从不同的角度来看,显式锁大概有以下几种分类:可重入锁与不可重入锁、悲观锁和乐观锁、公平锁和非公平锁、共享锁和独占锁、可中断锁和不可中断锁。

3.1 可重入锁和不可重入锁

从同一个线程是否可以重复占有同一个锁对象的角度分,显式锁可以分为可重入锁与不可重入锁。
可重入锁,也叫做递归锁,指的一个线程可以多次抢占同一个锁。例如,线程 A 在进入外层函数抢占了一个 Lock 显式锁之后,当线程 A 继续进入内层函数时,如果遇到有抢占同一个 Lock显式锁的代码,线程 A 依然可以抢到该 Lock 显式锁。
不可重入锁与可重入锁相反,指的一个线程只能抢占一次同一个锁。例如,线程 A 在进入外层函数抢占了一个 Lock显式锁之后,当线程 A 继续进入内层函数时,如果遇到有抢占同一个 Lock显式锁的代码,线程 A 不可以抢到该 Lock 显式锁。除非,线程 A 提前释放了该 Lock 显式锁,才能第二次抢占该锁。
JUC 的 ReentrantLock 类是可重入锁的一个标准实现类。

3.2 悲观锁和乐观锁

从线程进入临界区前是否锁住同步资源的角度来分,显式锁可以分为悲观锁和乐观锁。
悲观锁是就是悲观思想,每次去入临界区操作数据的时候都认为别的线程会修改,所以线程每次在读写数据时都会上锁,锁住同步资源,这样其他线程需要读写这个数据时就会阻塞,一直 等到拿到锁。总体来说,悲观锁适用于写多读少的场景,遇到高并发写时性能高。
Java 的 Synchronized 重量级锁是一种悲观锁。
乐观锁是一种乐观思想,每次去拿数据的时候都认为别的线程不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。总体来说,乐观锁适用于读多写少的场景,遇到高并发写的可能性低。
Java 中的乐观锁基本都是通过 CAS 自旋操作实现的。CAS 是一种更新原子操作,比较当前值跟传入值是否一样,是则更新,不是则失败。在争用激烈的场景下,CAS 自旋会出现大量的空自旋,会导致乐观锁性能大大降低。
Java 的 Synchronized 轻量级锁是一种乐观锁。另外,JUC 中基于抽象队列同步器(AQS)实现的显式锁(如 ReentrantLock)都是乐观锁。

3.3 公平锁和非公平锁

       “公平锁”是指“不同的线程抢占锁的机会是公平的、平等的”,从抢占时间上来说,先对锁进行抢占的线程一定被先满足,抢锁成功的次序体现为 FIFO(先进先出)顺序。简单来说,,公平锁就是保障了各个线程获取锁都是按照顺序来的,先到的线程先获取锁。
       使用公平锁,比如线程 A、B、C、D 依次去获取锁,线程 A 首先获取到了锁,然后它处理完成释放锁之后,会唤醒下一个线程 B 去获取锁。后续不断重复前面的过程,C、D 依次获取锁。
       “非公平锁”是指不同的线程抢占锁的机会是非公平的、不平等的,从抢占时间上来说,先对锁进行抢占的线程不一定被先满足,抢锁成功的次序不会体现为 FIFO(先进先出)顺序。
       使用公平锁,比如线程 A、B、C、D 依次去获取锁, 假如此时持有锁的是线程 A,然后线程B、C、D 尝试获取锁,就会进入一个等待队列。当线程 A 释放掉锁之后,会唤醒下一个线程 B 去获取锁。在唤醒线程 B 的这个过程中,如果有别的线程 E 尝试去请求锁,那么线程 E 是可以先获取到的,这就是插队。为什么可以线程 E 可以插队呢?因为 CPU 唤醒线程 B 需要进行线程的上下文切换,这个操作需要一定的时间,线程 E 可能与线程 A、B 不在同一个 CPU Core 执行,而是在其他的 Core 上执行,所以不需要进行线程的上下文切换。在线程 A 释放锁和线程 B 被唤醒的这段时间,锁是空闲的,其他 Core 上的线程 E 此时就能趁机获取非公平锁,这样做的目的主要是利用锁的空档期,提高其利用效率 。
       默认情况下 ReentrantLock 实例是非公平锁,但是,如果在实例构造时传入了参数 true,所得到就是公平锁。另外,ReentrantLock 的 tryLock()方法是一个特例,一但有线程释放了锁,那正在tryLock 的线程就能优先取到锁,即使已经有其他线程在等待队列中。

3.4 可中断锁和不可中断锁

什么是可中断锁?如果某一线程 A 正占有锁在执行临界区代码,另一线程 B 正在阻塞式抢占锁,可能由于等待时间过长,线程 B 不想等待了,想先处理其他事情,我们可以让它中断自己的阻塞等待,这种就是可中断锁。
什么是不可中断锁?一旦这个锁被其他线程占有,如果自己还想抢占,自己只能选择等待或者阻塞,直到别的线程释放这个锁,如果别的线程永远不释放锁,那么自己只能永远等下去,并且没有办法终止等待或阻塞。
简单来说,在抢锁过程中能通过某些方法去终止抢占过程,那就是可中断锁,否则就是不可中断锁。
Java 的 synchronized 内置锁就是一个不可中断锁,而 JUC 的显式锁(如 ReentrantLock) 是一个可中断锁。

3.5 共享锁和独占锁

       “独占锁”指的是每次只能有一个线程能持有的锁。独占锁是一种悲观保守的加锁策略,它不必要地限制了读/读竞争,如果某个只读线程获取锁,则其他的读线程都只能等待,这种情况下就限制了读操作的并发性,因为读操作并不会影响数据的一致性。
JUC 的 ReentrantLock 类,是一个标准的“独占锁”实现类。
“共享锁”允许多个线程同时获取锁,容许线程并发进入临界区。与独占锁不同,共享锁则是一种乐观锁,它放宽了加锁策略,并不限制读/读竞争,允许多个执行读操作的线程同时访问共享资源。
       JUC 的 ReentrantReadWriteLock(读写锁)类,是一个“共享锁”实现类。使用该读写锁时,读操作可以有很多线程一起读,但是写操作只能有一个线程去写,而且在写入的时候,别的线程也不能进行读的操作。 用 ReentrantLock 锁替代 ReentrantReadWriteLock 锁虽然可以保证线程安全,但是也会浪费一部分资源,因为多个读操作并没有线程安全问题,所以在读的地方使用读锁,在写的地方用写锁,可以提高程序执行效率。

4. ReentrantLock

4.1 类继承层次

相关类之间的继承层次,如下图所示:

Java并发编程之显式锁插图
类继承层次

4.2 ReentrantLock基本用法

4.2.1 使用 lock( )方法抢锁的模板代码

通常情况下,大家会使用 lock( )方法的进行阻塞式的锁抢占,其模板代码如下:

        Lock lock = new ReentrantLock();
        lock.lock(); //step1:抢占锁
        try {
            //step2:抢锁成功,执行临界区代码
        } finally {
            lock.unlock(); //step3:释放锁
        }

以上抢锁模板代码,有以下几个需要注意的要点:
(1)释放锁操作 lock.unlock() 必须在 try-catch 结构的 finally 块中执行,否则,如果临界区代码抛出异常,锁就有可能永远得不到释放。
(2)抢占锁操作 lock.lock( ) 必须在 try 语句块之外,而不是放在 try 块之内。为什么呢?
原因之一是 lock( ) 方法是没有申明抛出异常,所以可以不包含到 try 块中;
原因之二是 lock( ) 方法并不是一定能够抢占锁成功,如果没有抢占成功,当然也就不需要释放锁,而且,在没有占有锁的情况下去释放锁,可能会导致运行时异常。
(3)在抢占锁操作 lock.lock( )和 try 语句之间,不要插入任何代码,避免抛出异常而导致释
放锁操作 lock.unlock() 执行不到,导致锁无法被释放。

4.2.2 使用 tryLock( )方法非阻塞抢锁的模板代码

       lock( )是阻塞式抢占,在没有抢到锁的情况下,当前线程会阻塞。如果不希望线程阻塞,可以使用 tryLock()方法抢占锁。tryLock( ) 是非阻塞抢占,在没有抢到锁的情况下,当前线程会立即返回,不会别阻塞。
使用 tryLock()方法非阻塞抢占锁,大致的模板代码如下:

        Lock lock = new ReentrantLock();
        if (lock.tryLock()) { //step1:尝试抢占锁
            try {
                //step2:抢锁成功,执行临界区代码
            } finally {
                lock.unlock(); //step3:释放锁
            }
        } else {
            //step4:抢锁失败,执行后备动作
        }

4.2.3 使用 tryLock(long time, TimeUnit unit)方法抢锁的模板代码

       tryLock(long time, TimeUnit unit)方法用于限时抢锁,该方法在抢锁时会进行一段时间的阻塞等待,其 time 参数代表最大的阻塞时长,其 unit 参数为时长的单位(如秒)。
使用 tryLock(long time, TimeUnit unit)方法限时抢锁,其大致的代码模板如下:

        Lock lock = new ReentrantLock();
        if (lock.tryLock(1, TimeUnit.SECONDS)) {
            try {
                //step2:抢锁成功,执行临界区代码
            } finally {
                lock.unlock(); //step3:释放锁
            }
        } else {
            //step4:抢锁失败,执行后备动作
        }

对 lock( )、tryLock( )、tryLock(long time, TimeUnit unit)的三个方法的总结如下:
(1)lock( )方法用于阻塞抢锁,抢不到锁时线程会一直阻塞。
(2)tryLock( )方法用于尝试抢锁,该方法有返回值,如果成功则返回 true,如果失败(即锁已被其他线程获取)则返回 false。此方法无论如何都会立即返回,在抢不到锁时,线程不会像使用 lock( )方法那样一直被阻塞。
(3)tryLock(long time, TimeUnit unit)方法和 tryLock()方法是类似的,只不过这个方法在抢不到锁时时会阻塞一段时间。如果在阻塞期间获取到锁立即返回 true,超时则返回 false。

5. 读写锁 ReentrantReadWriteLock

       通过 ReentrantReadWriteLock 类能获取其读锁和写锁,其读锁是可以多线程共享的共享锁,而其写锁是排他锁,在被占时候不允许其他线程再抢占操作。然而其读锁和写锁之间是有关系的:同一时刻不允许读锁和写锁同时被抢占,二者之间是互斥的。

5.1 类继承层次

ReadWriteLock是一个接口,内部由两个Lock接口组成。

public interface ReadWriteLock {
    Lock readLock(); 
    Lock writeLock(); 
}
Java并发编程之显式锁插图1
类图

ReentrantReadWriteLock实现了该接口,使用方式如下:

    ReadWriteLock readWriteLock = new ReentrantReadWriteLock(); 
    Lock readLock = readWriteLock.readLock(); 
    readLock.lock(); 
    // 进行读取操作 
    readLock.unlock(); 
    
    Lock writeLock = readWriteLock.writeLock(); 
    writeLock.lock(); 
    // 进行写操作 
    writeLock.unlock();

也就是说,当使用 ReadWriteLock 的时候,并不是直接使用,而是获得其内部的读锁和写锁,然后分别调用lock/unlock。

5.2 ReentrantReadWriteLock的使用

       通过 ReentrantReadWriteLock 类能获取其读锁和写锁,其读锁是可以多线程共享的共享锁,而其写锁是排他锁,在被占时候不允许其他线程再抢占操作。然而其读锁和写锁之间是有关系的:同一时刻不允许读锁和写锁同时被抢占,二者之间是互斥的。
接着先来个代码演示下,读锁是共享锁,写锁是排他锁:

public class ReadWriteLockTest {
    //创建一个 Map,代表共享数据
    final static Map<String, String> MAP = new HashMap<String, String>();
    //创建一个读写锁
    final static ReentrantReadWriteLock LOCK = new
            ReentrantReadWriteLock();
    //获取读锁
    final static Lock READ_LOCK = LOCK.readLock();
    //获取写锁
    final static Lock WRITE_LOCK = LOCK.writeLock();

    //对共享数据的写操作
    public static Object put(String key, String value) {
        //抢写锁
        WRITE_LOCK.lock();
        try {
            System.out.println("[" + Thread.currentThread().getName() + "]" + getNowTime() + " 抢占了 WRITE_LOCK,开始执行 write 操作");
            Thread.sleep(1000);
            //写入共享数据
            String put = MAP.put(key, value);
            return put;
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            WRITE_LOCK.unlock();
        }
        return null;
    }

    //对共享数据的读操作
    public static Object get(String key) {
        //抢占读锁
        READ_LOCK.lock();
        try {
            System.out.println("[" + Thread.currentThread().getName() + "]"  + getNowTime() + " 抢占了 READ_LOCK,开始执行 read 操作");
            Thread.sleep(1000);
            //读取共享数据
            String value = MAP.get(key);
            return value;
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            //释放读锁
            READ_LOCK.unlock();
        }
        return null;
    }

    public static String getNowTime() {
        //HH表示用24小时制,如18;hh表示用12小时制
        SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss");
        return sdf.format(System.currentTimeMillis());
    }

    public static void main(String[] args) {
        //创建 Runnable 异步可执行目标实例
        Runnable writeTarget = () -> put("key", "value");
        Runnable readTarget = () -> get("key");
        //创建 4 条读线程
        for (int i = 0; i < 4; i++) {
            new Thread(readTarget, "读线程" + i).start();
        }
        //创建 2 条写线程,并启动
        for (int i = 0; i < 2; i++) {
            new Thread(writeTarget, "写线程" + i).start();
        }
    }
}

运行程序,结果如下:

[读线程 2]:09:33:20 抢占了 READ_LOCK,开始执行 read 操作
[读线程 1]:09:33:20 抢占了 READ_LOCK,开始执行 read 操作
[读线程 0]:09:33:20 抢占了 READ_LOCK,开始执行 read 操作
[写线程 1]:09:33:21 抢占了 WRITE_LOCK,开始执行 write 操作
[读线程 3]:09:33:22 抢占了 READ_LOCK,开始执行 read 操作
[写线程 0]:09:33:23 抢占了 WRITE_LOCK,开始执行 write 操作

从输出结果可以看出:
(1)读线程 0、读线程 1、读线程 2 同时获取了读锁,说明可以同时进行共享数据的读操作。
(2)写线程 1、写线程 0 只能依次获取写锁,说明共享数据的写操作不能同时进行。
(3)读线程 3 必须等待写线程 1 释放写锁后才能获取到读锁,说明读写操作是互斥的。

5.3 锁的升级与降级

       锁升级是指读锁升级为写锁,锁降级指的是写锁降级为读锁。在 ReentrantReadWriteLock 读写锁中,只支持写锁降级为读锁,而不支持读锁升级为写锁。具体的演示代码如下:

public class ReadWriteLockTest2 {
    //创建一个 Map,代表共享数据
    final static Map<String, String> MAP = new HashMap<String, String>();
    //创建一个读写锁
    final static ReentrantReadWriteLock LOCK = new
            ReentrantReadWriteLock();
    //获取读锁
    final static Lock READ_LOCK = LOCK.readLock();
    //获取写锁
    final static Lock WRITE_LOCK = LOCK.writeLock();

    public static String getNowTime() {
        //HH表示用24小时制,如18;hh表示用12小时制
        SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss");
        return sdf.format(System.currentTimeMillis());
    }

    //对共享数据的写操作
    public static Object put(String key, String value) {
        WRITE_LOCK.lock();
        try {
            System.out.println("[" + Thread.currentThread().getName() + "]"  + getNowTime() +  " 抢占了 WRITE_LOCK,开始执行 write 操作");
            Thread.sleep(1000);
            String put = MAP.put(key, value);
            System.out.println("[" + Thread.currentThread().getName() + "]" +  "尝试降级写锁为读锁");
            //写锁降级为读锁(成功)
            READ_LOCK.lock();
            System.out.println("[" + Thread.currentThread().getName() + "]" +  "写锁降级为读锁成功");
            return put;
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            READ_LOCK.unlock();
            WRITE_LOCK.unlock();
        }
        return null;
    }


    public static Object get(String key)
    {
        READ_LOCK.lock();
        try
        {
            Print.tco(DateUtil.getNowTime()
                    + " 抢占了 READ_LOCK,开始执行 read 操作");
            Thread.sleep(1000);
            String value = MAP.get(key);
            System.out.println("[" + Thread.currentThread().getName() + "]" +  "尝试升级读锁为写锁");
            // 读锁升级为写锁(失败)
            WRITE_LOCK.lock();
            System.out.println("[" + Thread.currentThread().getName() + "]" +  "读锁升级为写锁成功");
            return value;
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            WRITE_LOCK.unlock();
            READ_LOCK.unlock();
        }
        return null;
    }

    public static void main(String[] args) {
        //创建 Runnable 可执行实例
        Runnable writeTarget = () -> put("key", "value");
        Runnable readTarget = () -> get("key");
        //创建 1 条写线程,并启动
        new Thread(writeTarget, "写线程").start();
        //创建 1 条读线程
        new Thread(readTarget, "读线程").start();
    }
}

运行控制台输出:

[写线程]:09:51:42 抢占了 WRITE_LOCK,开始执行 write 操作
[写线程]:写线程尝试降级写锁为读锁
[写线程]:写线程写锁降级为读锁成功
[读线程]:09:51:43 抢占了 READ_LOCK,开始执行 read 操作
[读线程]:读线程尝试升级读锁为写锁

       通过结果可以看出:ReentrantReadWriteLock 不支持读锁的升级,主要是避免死锁,例如两个线程 A 和 B 都占了读锁并且都需要升级成写锁, A 升级要求 B 释放读锁,B 升级要求 A 释放读锁,二者就会由于互相等待形成死锁。
总结起来,与 ReentrantLock 相比,ReentrantReadWriteLock 更适合于读多写少的场景,可以
提高并发读的效率;而 ReentrantLock 更适合于读写比例相差不大、或写比读多的场景。

6. StampedLock

StampedLock(印戳锁)其是对 ReentrantReadWriteLock 读写锁的一种改进,主要的改进为:在没有写只有读的场景下,StampedLock 支持不用加读锁而是直接进行读操作,最大程度提升读的效率;只有在发生过写操作之后,再加读锁才能进行读操作。
StampedLock 三种模式:
(1)悲观读锁:与 ReadWriteLock 的读锁类似,多个线程可以同时获取悲观读锁,悲观读锁是一个共享锁。
(2)乐观读:相当于直接操作数据,不加任何锁,连读锁都不要。
(3)写锁:与 ReadWriteLock 的写锁类似,写锁和悲观读锁是互斥的;虽然写锁与乐观读不会互斥,但是在数据被更新之后,之前通过乐观读所获得的数据,已经变成了脏数据。

6.1 StampedLock 与 ReentrantReadWriteLock 对比

StampedLock 与 ReentrantReadWriteLock 语义类似,不同的是,StampedLock 并没有实现ReadWriteLock 接口,而是定义了自己的锁操作 API,主要如下:
(1)悲观读锁的获取与释放

//获取普通读锁(悲观读锁),返回 long 类型的印戳值
public long readLock()
//释放普通读锁(悲观读锁),以取锁时的印戳值作为参数
public void unlockRead(long stamp)

(2)写锁的获取与释放

//获取写锁,返回 long 类型的印戳值
public long writeLock()
//释放写锁,以获取写锁时的印戳值作为参数
public void unlockWrite(long stamp)

(3)乐观读的印戳获取与有效性判断

//获取乐观读,返回 long 类型的印戳值,返回 0 表示当前锁处于写锁模式,不能乐观读
public long tryOptimisticRead()
//判断乐观读的印戳值是否有效,以 tryOptimisticRead 返回的印戳值作为参数
public long tryOptimisticRead()

6.2 StampedLock 的演示案例

public class StampedLockTest {
    //创建一个 Map,代表共享数据
    final static Map<String, String> MAP = new HashMap<String, String>();
    //创建一个印戳锁
    final static StampedLock STAMPED_LOCK = new StampedLock();

    public static String getNowTime() {
        //HH表示用24小时制,如18;hh表示用12小时制
        SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss");
        return sdf.format(System.currentTimeMillis());
    }
    public static void sleepSeconds(int second) {
        LockSupport.parkNanos(second * 1000L * 1000L * 1000L);
    }

    //对共享数据的写操作
    public static Object put(String key, String value) {
        //尝试获取写锁的印戳
        long stamp = STAMPED_LOCK.writeLock();
        try {
            System.out.println("[" + Thread.currentThread().getName() + "]"  + getNowTime() +  " 抢占了 WRITE_LOCK,开始执行 write 操作");
            Thread.sleep(1000);
            String put = MAP.put(key, value);
            return put;
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            System.out.println("[" + Thread.currentThread().getName() + "]"  + getNowTime() +  "  释放了 WRITE_LOCK");
            //释放写锁
            STAMPED_LOCK.unlockWrite(stamp);
        }
        return null;
    }

    //对共享数据的悲观读操作
    public static Object pessimisticRead(String key) {
        System.out.println("[" + Thread.currentThread().getName() + "]"  + getNowTime() +  "LOCK 进入过写模式,只能悲观读");
        //进入了写锁模式,只能获取悲观读锁
        //尝试获取读锁的印戳
        long stamp = STAMPED_LOCK.readLock();
        try {
            //成功获取到读锁,并重新获取最新的变量值
            System.out.println("[" + Thread.currentThread().getName() + "]"  + getNowTime() +  " 抢占了 READ_LOCK");
            String value = MAP.get(key);
            return value;
        } finally {
            System.out.println("[" + Thread.currentThread().getName() + "]"  + getNowTime() +  " 释放了 READ_LOCK");
            //释放读锁
            STAMPED_LOCK.unlockRead(stamp);

        }
    }

    //对共享数据的乐观读操作
    public static Object optimisticRead(String key) {
        String value = null;
        //尝试进行乐观读
        long stamp = STAMPED_LOCK.tryOptimisticRead();
        if (0 != stamp) {
            System.out.println("[" + Thread.currentThread().getName() + "]"  + getNowTime() +  "乐观读的印戳值,获取成功");
            //模拟耗费时间 1 秒
            sleepSeconds(1);
            value = MAP.get(key);
        } else {// 0 == stamp 表示当前为写锁模式
            System.out.println("[" + Thread.currentThread().getName() + "]"  + getNowTime() +  "乐观读的印戳值,获取失败");
            //LOCK 已经进入写模式,使用悲观读方法
            return pessimisticRead(key);
        }
        //乐观读操作已经间隔了一段时间,期间可能发生写入
        //所以,需要验证乐观读的印戳值是否有效,即判断 LOCK 是否进入过写模式
        if (!STAMPED_LOCK.validate(stamp)) {
            //乐观读的印戳值无效,表明写锁被占用过
            System.out.println("[" + Thread.currentThread().getName() + "]"  + getNowTime() +  "乐观读的印戳值,已经过期");
            //写锁已经被抢占,进入了写锁模式,只能通过悲观读锁,再一次读取最新值
            return pessimisticRead(key);
        } else {
            //乐观读的印戳值有效,表明写锁没有被占用过
            //不用加悲观读锁而直接读,减少了读锁的开销
            System.out.println("[" + Thread.currentThread().getName() + "]"  + getNowTime() +  " 乐观读的印戳值,没有过期");
            return value;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        //创建 Runnable 可执行实例
        Runnable writeTarget = () -> put("key", "value");
        Runnable readTarget = () -> optimisticRead("key");
        //创建 1 条写线程,并启动
        new Thread(writeTarget, "写线程").start();
        //创建 1 条读线程
        new Thread(readTarget, "读线程").start();
    }
}

运行以上程序,结果如下:

[写线程]:12:55:45 抢占了 WRITE_LOCK,开始执行 write 操作
[读线程]:12:55:45 获取乐观读的印戳值,获取失败
[读线程]:12:55:45 LOCK 进入过写模式,只能悲观读
[写线程]:12:55:46 释放了 WRITE_LOCK
[读线程]:12:55:46 抢占了 READ_LOCK
[读线程]:12:55:46 释放了 READ_LOCK

6.3 为什么引入StampedLock?

StampedLock是在JDK8中新增的,有了读写锁,为什么还要引入StampedLock呢?

并发度
ReentrantLock 读读互斥,读写互斥,写写互斥
ReentrantReadWriteLock 读读不互斥,读写互斥,写写互斥
StampedLock 读读不互斥,读写不互斥,写写互斥

可以看到,从ReentrantLock到StampedLock,并发度依次提高。
另一方面,因为ReentrantReadWriteLock采用的是“悲观读”的策略,当第一个读线程拿到锁之后,第二个、第三个读线程还可以拿到锁,使得写线程一直拿不到锁,可能导致写线程“饿死”。虽然在其公平或非公平的实现中,都尽量避免这种情形,但还有可能发生。
       StampedLock引入了“乐观读”策略,读的时候不加读锁,读出来发现数据被修改了,再升级为“悲观读”,相当于降低了“读”的地位,把抢锁的天平往“写”的一方倾斜了一下,避免写线程被饿死。

赞(0) 打赏
未经允许不得转载:IDEA激活码 » Java并发编程之显式锁

一个分享Java & Python知识的社区