JAVA 多线程与高并发学习笔记(十三)——JUC显式锁

前面孩子生病了,一直照顾孩子。然后自己又感冒了,嗓子难受的要死。今天终于好多了。

显式锁介绍

JDK5 引入了 Lock 接口,与内置加锁机制不同的是,Lock 提供了一种无条件的、可轮询的、定时的以及可中断的锁获取操作,所有加锁和解锁的方法都是显式的。

显式锁 Lock 接口

Lock 接口位于 java.util.concurrent.locks 包中,是 JUC 显式锁的一个抽象,主要抽象方法如下表。

方法 说明
void lock() 获取锁,成功则向下运行,失败则阻塞抢锁线程
void lockInterruptibly() throws InterruptedException 可中断的获取锁,和lock()方法的不同之处在于该方法会响应中断,即在锁的获取中可以中断当前线程
boolean tryLock() 尝试非阻塞的获取锁,调用该方法后立刻返回,如果能够获取则返回true,否则返回false
boolean tryLock(long time, TimeUnit unit) throws InterruptedException 限时获取锁,到达超时时间返回false,并且此限时抢锁方法也可以响应中断信息
void unlock() 释放锁
Condition newCondition() 获取与限时锁绑定的Condition对象,用于“等待-通知”方式的线程间通信

显式锁相比Java内置锁多了以下优势:

  • 可中断获取锁。
  • 可非阻塞获取锁。
  • 可显示抢锁。

可重入锁ReentrantLock

ReentrantLock 是JUC包提供的显式锁的一个基础实现类,它是一个可重入的独占锁,具体含义是:

  • 可重入的含义:表示该所能够会吃一个线程对资源的重复加锁,同一个线程可以多次进入同一个锁所同步的临界区代码块。比如,同一个线程在外层函数获得锁后,在内层函数能再次获取该所,甚至多次抢占同一把锁。
  • 独占的含义:同一时刻只能有一个线程获取到锁,其它线程只能等待。

使用显式锁的模板代码

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

lock方法进行阻塞式的锁抢占模板代码如下:

// 创建某个锁对象
Lock lock = new SomeLock();
lock.lock(); // 抢占锁
try {
    // 抢锁成功,执行临界区代码
    ...
} finally {
    lock.unlock(); // 释放锁
}

其中有几个注意点:

  • 释放锁操作 lock.unlock() 必须在 try-catch 结构的 finally 块中执行,否则,如果临界区代码抛出异常,锁就有可能永远得不到释放。
  • 抢占锁的操作 lock.lock() 必须在try语句块之外。一方面是因为 lock() 方法没有声明抛出异常,所以可以不包含到try块中,另一方面是 lock() 方法并不一定能够抢占锁成功,如果没有抢占成功,当然也不需要释放锁。
  • 在抢占锁操作 lock.lock() 和 try 语句之间不要插入任何代码,避免抛出异常而导致释放锁操作 lock.unlock() 执行不到,导致锁无法被释放。

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

tryLock() 是非阻塞抢占,在没有抢到锁的情况下,当前线程会立即返回,不会被阻塞。

模板代码大致如下:

// 创建某个锁对象
Lock lock = new SomeLock();

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

调用 tryLock(long time, TimeUnit uint) 方法抢锁的模板代码

tryLock(long time, TimeUnit uint) 方法用于限时抢锁,该方法在抢锁时会进行一段时间的阻塞等待。

大致代码模板如下:

// 创建某个锁对象
Lock lock = new SomeLock();

if(lock.tryLock(1, TimeUnit.SECONDS)) { // 限时阻塞抢占
    try {
        // 抢锁成功,执行临界区代码
        ...
    } finally {
        lock.unlock(); // 释放锁
    }
} else {
    // 抢锁失败,执行后备动作
    ...
}

Condition 接口

基于Lock显式锁,JUC也为大家提供了一个用于线程间进行“等待-通知”方式通信的接口——java.util.concurrent.locks.Condition

Condition接口的主要方法如下:

public interface Condition {
    // 方法1:等待。此方法在功能上与 Object.wait() 语义等效
    // 使当前线程加入 await() 等待队列中,并释放当前锁
    // 当其它线程调用 signal() 时,等待队列中的某个线程会被唤醒,重新去抢锁。
    void await() throws InterruptedException;

    // 方法2:通知。此方法在功能上与 Object.notofy() 语义等效
    // 唤醒一个在 await() 等待队列中的线程
    void signal();

    // 方法3:通知全部。唤醒await()等待队列中所有的线程
    // 此方法与object.notifyAll()语义上等效
    void signalAll();

    // 方法4:限时等待,此方法与await()语义等效
    // 不同点在于,在指定时间time等待超时后,如果没有被唤醒,线程将中止等待
    boolean await(long time, TimeUnit uint) throws InterruptedException;
}

Condition 对象是基于显式锁的,所以不能独立建立一个Condition对象,而是需要借助显式锁实例。

LockSupport

LockSupport 是JUC提供的一个线程阻塞与唤醒的工具类,该工具类可以让线程在任意位置阻塞和唤醒,其所有方法都是静态方法。

LockSupport 常用方法

常用方法如下:

// 无限期阻塞当前线程
public static void park();

// 唤醒某个被阻塞的线程
public static void unpark(Thread thread);

// 阻塞当前线程,有超时时间限制
public static void parkNanos(long nanos);

// 阻塞当前线程,知道某个时间
public static void parkUntil(long deadline);

// 无限期阻塞当前线程,带blocker对象,用于给诊断工具确定线程受阻塞的原因
public static void park(Object blocker);

// 获取被阻塞线程的blocker对象,用于分析阻塞的原因。
public static Object getBlocker(Thread t);

LockSupport.park() 和 Thread.sleep() 的区别

LockSupport.park()Thread.sleep() 方法类似,都是让线程阻塞,二者区别如下:

  • Thread.sleep() 没法从外部唤醒,只能自己醒过来,而被 LockSupport.park() 方法阻塞的线程可以通过调用
    LockSupport.unpark() 方法给唤醒。

  • Thread.sleep() 方法声明了 InterruptepException 中断异常,这是一个受检异常,调用者需要补货这个异常或者再抛出,而调用 LockSupport.park() 方法不需要捕获中断异常。

  • 二者对中断信号的响应方式不同,Thread.sleep() 会抛出 InterruptepException 中断异常。

  • LockSupport.park() 相比 Thread.sleep()能更精准、更加灵活地阻塞、唤醒指定的线程。

  • Thread.sleep() 本身是一个 Native 方法,LockSupport.park() 不是,他只是调用了一个 Unsafe 类的 Native 方法去实现。

  • LockSupport.park()方法还允许设置一个 Blocker 对象,主要用来供监视工具或诊断工具确定线程受阻的原因。

LockSupport.park()与Object.wait()的区别

从功能上来说,LockSupport.park()Object.wait() 方法也类似,都是让线程阻塞,二者的区别如下:

  • Object.wait() 方法需要在 synchronized 块中执行,而 LockSupport.park() 可以在任意地方执行。

  • 当被阻塞线程中断时,Object.wait() 方法抛出了中断异常,调用者需要捕获或者在抛出,LockSupport.park() 不会抛出异常。

  • 如果线程在没有被 Object.wait() 阻塞之前被 Object.notify() 唤醒,也就是说在 Object.wait() 执行之前去执行 Object.notify(),就会抛出 IllegalMonitorStateException 异常,是不被允许的。而线程在没有被 LockSupport.park() 阻塞之前被 LockSupport.unpark() 唤醒不会抛出异常,是被允许的。

显式锁的分类

从不同的角度来看,显式锁有以下几种分类:

  • 可重入锁和不可重入锁,从同一个线程是否可以重复占有同一个锁对象的角度来分,显式锁可以分为可重入锁和不可重入锁。

  • 悲观锁和乐观锁,从线程机内临界区前是否锁住同步资源的角度来分,显式锁可以分为悲观锁和乐观锁。

  • 公平锁和非公平锁,公平锁是指不同的线程抢占锁的机会是公平的、平等的,供抢占时间上来说,先对锁进行抢占的线程一定先被满足。

  • 可中断锁和不可中断锁,如果临界区代码被其他线程占有,本线程由于等待时间过长,不想等待,可以中断自己的阻塞等待,这就是可中断锁。

  • 共享锁和独占锁,独占锁是指每次只有一个线程能持有的锁,共享锁允许多个线程同时获取锁。

悲观锁和乐观锁

独占锁就是一种悲观锁,Java的 synchronized 是悲观锁。悲观锁可以确保无论哪个线程持有锁,都能独占式访问临界区。

悲观锁存在的问题

悲观锁存在以下问题:

  1. 在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。

  2. 一个线程持有锁或,会导致其他所有抢占此锁的线程挂起。

  3. 如果一个优先级高的线程等待一个优先级低的线程释放锁,就会导致线程的优先级倒置,从而引发性能风险。

解决这些问题的方法是用乐观锁代替悲观锁。例如数据库操作的带版本号更新,JUC包的原子类,都适用乐观锁。

乐观锁的操作包括两个步骤:

  1. 冲突检测。
  2. 数据更新。

JUC的CAS原子操作就体现了乐观锁思想,它可以分为两个步骤:

  1. 检测位置V的值是否为A。
  2. 如果是,就将位置V更新为B值,否则不要更改该位置。

一般情况下,需要进行自旋操作,即不断循环重试CAS操作直到成功,这也叫CAS自旋。

通过CAS自旋,在不使用锁的情况下实现多线程之间的变量同步,也就是说在没有现成被阻塞的情况下实现变量的同步,这叫做“非阻塞同步”(Non-Blocking Synchronization),使用基于CAS自旋的乐观锁进行同步控制,属于无锁编程(Lock Free)。

不可重入的自旋锁

自旋锁的基本含义为:当一个现成在获取锁的时候,如果锁已经被其他线程获取,调用者就一直在那里循环检查该锁是否已经释放,一直到获取到锁才会退出循环。

CAS自旋锁就是抢锁线程不断进行CAS自旋操作去更新锁的owner,更新成功就表明抢锁成功,不成功就一直循环。

下面给出一个简单版本的不可重入自旋锁:

public class SpinLock implements Lock {
    /**
     * 当前锁的拥有者,使用Thread作为同步状态
     */
    private AtomicReference owner = new AtomicReference<>();

    /**
     * 抢占锁
     */
    @Override
    public void lock() {
        Thread t = Thread.currentThread();
        // 自旋
        while(owner.compareAndSet(null, t)) {
            // Do Nothing
            Thread.yield();
        }
    }

    /**
     * 释放锁
     */
    @Override
    public void unlock() {
        Thread t = Thread.currentThread();
        // 只有拥有者才能释放锁
        if(t == owner.get()) {
            owner.set(null);
        }
    }
    
    ...
}

这个锁是不支持冲入的,即当一个线程第一次已经获取了该锁,在锁没有被释放之前,如果又一次重新获取该锁,第二次将不能成功获取到。

可重入的自旋锁

为了实现可重入锁,引入一个计数器,用来记录一个线程获取锁的次数。

一个简单的可重入的自旋锁代码大致如下:

public class ReetrajtSpinLock implements Lock {
    /**
     * 当前锁的拥有者,使用Thread作为同步状态
     */
    private AtomicReference owner = new AtomicReference<>();

    /**
     * 记录一个线程重复获取锁的次数
     * 此变量为同一个线程在操作,没有必要加上 volatile 保障可见性和有序性
     */
    private int count = 0;

    /**
     * 抢占锁
     */
    @Override
    public void lock() {
        Thread t = Thread.currentThread();
        // 如果是重入,增加重入次数后返回
        if(t == owner.get()) {
            ++count;
            return;
        }
        // 自旋
        while(owner.compareAndSet(null, t)) {
            // Do Nothing
            Thread.yield();
        }
    }

    /**
     * 释放锁
     */
    @Override
    public void unlock() {
        Thread t = Thread.currentThread();
        // 只有拥有者才能释放锁
        if(t == owner.get()) {
            if(count > 0) {
                // 如果重入次数大于0,减少重入次数后返回
                --count;
            } else {
                // 设置拥有者为空
                owner.set(null);
            }
            
        }
    }
    
    ...
}

自旋锁的特点:线程获取锁的时候,如果锁被其他线程持有,当前线程将循环等待,直到获取到锁,线程抢锁期间状态不会改变,一直是运行状态,在操作系统层面线程处于用户态。

自旋锁的问题:在争用激烈的场景下,如果某个线程持有锁的事件太长,就会导致其他空自旋的线程耗尽CPU资源。另外,如果大量的线程进行空自旋,还可能导致硬件层面的“总线风暴”。

CAS可能导致“总线风暴”

前面讲到,CPU会通过MESI协议保障变量的缓存一致性。不同的内核需要通过总线来回通信,所产生的流量一般被称为“缓存一致性流量”。因为总线被设计为固定的“通信能力”,如果缓存一致性流量过大,总线将成为瓶颈。这就是所谓的“总线风暴”。

使用 lock 前缀指令的Java操作(包括CAS、volatile)恰恰会产生缓存一致性流量。

在竞争激烈的场景下,Java轻量级锁会快速膨胀为重量级锁,其本质上一是为了减少CAS空自旋,二是为了避免同一时间大量CAS操作所导致的总线风暴。

CLH自旋锁

使用队列对抢锁线程排队,可以减少总线风暴。CLH锁就是一种基于队列(具体为单向链表)排队的自旋锁。

简单的CLH锁可以基于单向链表实现,申请加锁的线程首先会通过CAS操作在单向链表的尾部增加一个节点,之后该线程只需要在其前驱节点上进行普通自旋,等待前驱节点释放锁即可。由于CLH锁只有在节点入队时进行一下CAS的操作,在节点加入队列之后,抢锁线程不需要进行CAS自旋,只需普通自旋即可。因此,在争用激烈的场景下,CLH锁能大大减少CAS操作的数量,以避免CPU的总线风暴。

下面实现一个CLH锁的简单版本:

// 虚拟等待队列的节点
public class Node {

    private volatile boolean locked;
    private Node prevNode;

    public Node(boolean locked, Node prevNode) {
        this.locked = locked;
        this.prevNode = prevNode;
    }

    public static final Node EMMPTY = new Node(false, null);

    public boolean isLocked() {
        return locked;
    }

    public void setLocked(boolean locked) {
        this.locked = locked;
    }

    public Node getPrevNode() {
        return prevNode;
    }

    public void setPrevNode(Node prevNode) {
        this.prevNode = prevNode;
    }
}

public class CLHLock implements Lock {

    // 当前节点的线程本地变量
    private static ThreadLocal curNodeLocal = new ThreadLocal<>();

    // CLH队列的尾部指针,使用AtomicReference方便进行CAS操作
    private AtomicReference tail = new AtomicReference<>(null);

    public CLHLock() {
        // 设置尾部节点
        tail.getAndSet(Node.EMMPTY);
    }

    @Override
    public void lock() {
       Node curNode = new Node(true, null);
       Node prevNode = tail.get();

       // CAS自旋,将当前节点插入队列的尾部
       while(!tail.compareAndSet(prevNode, curNode)) {
           prevNode = tail.get();
       }
       // 设置前缀节点
       curNode.setPrevNode(prevNode);

       // 自旋
       while(curNode.getPrevNode().isLocked()) {
           // 让出CPU时间片,提高性能
           Thread.yield();
       }
       System.out.println("获取到了锁!");

       // 将当前节点缓存在线程本地变量中,释放锁会用到
       curNodeLocal.set(curNode);
    }

    @Override
    public void unlock() {
        Node curNode = curNodeLocal.get();
        curNode.setLocked(false);
        curNode.setPrevNode(null); // help for gc
        curNodeLocal.set(null); // 方便下一次抢锁


    }
}

CLH算法有以下几个要点:

  1. 初始状态队列尾部属性执行一个EMPTY节点。
  2. Thread 在抢锁时会创建一个新的Node加入等待队列尾部:tail指向新的Node,同时新的Node的prevNode属性指向tail之前指向的节点,并且以上操作通过CAS自旋完成,确保操作成功。
  3. Thread加入抢锁队列之后,会在前驱节点上自旋;循环判断前驱节点的locked属性是否为false,如果为false就表示前驱节点释放了锁,当前线程抢占到锁。
  4. 抢到锁之后,它的locked属性一直为true,一直到临界区代码执行完,然后调用 unlock() 方法释放锁,释放之后其locked属性才为false。

公平锁与非公平锁

理解起来很简单,不展开了~~

可中断锁与不可中断锁

锁的可中断抢占

JUC 显示锁 Lock 接口中,有两个方法可以用于可中断抢占:

  • void lockInterruptibly() throws InterruptedException,可中断的获取锁,和lock()方法的不同之处在于该方法会响应中断,即在锁的获取中可以中断当前线程。

  • boolean tryLock(long time, TimeUnit unit) throws InterruptedException,限时获取锁,到达超时时间返回false,并且此限时抢锁方法也可以响应中断信息。

死锁的监测与中断

死锁是指两个或两个以上线程因抢占锁而造成的相互等待的现象。多个县城通过AB-BA模式抢占两个锁是造成多线程死锁比较普遍的原因。

JDK8中包含的 ThreadMXBean 接口提供了多种监视线程的方法,其中包括两个死锁监测的方法,具体如下:

  1. findDeadLockedThreads,用于检测由于抢占JUC显式锁、Java内置锁引起死锁的线程。

  2. findMonitorDeadlockedThreads 仅仅用于检测由于抢占 Java 内置锁引起死锁的线程。

ThreadMXBean 的实例可以通过 JVM 管理工厂 ManagementFactory 去获取,具体的获取方法如下:

public static ThreadMXBean mbean = ManagementFactory.getThreadMXBean();

JVM 管理工厂 ManagementFactory 提供静态方法,返回各种后去JVM信息的Bean实例。

共享锁与独占锁

独占锁

独占锁在同一时刻只能被一个线程所持有。Sychronized 内置锁和 ReetrantLock 显式锁都是独占锁。

共享锁 Semaphore

共享锁就是在同一时刻允许多个线程持有的锁。获得共享锁的线程只能读取临界区的数据,不能修改临界区的数据。

JUC中的共享锁包括 Semaphore(信号量)、ReadLock(读写锁)中的读锁、CountDownLatch(倒数闩)。

Semaphore 可以用来控制在同一时刻访问共享资源的线程数量,通过协调各个线程以保证共享资源的合理使用。Semaphore维护了一组虚拟许可,它的数量可以通过构造器的参数指定,线程在访问共享资源前必须调用Semaphore的acquire方法获取许可,如果许可数量为0,该线程就一直阻塞额,线程访问完资源后,必须调用Semaphore的release方法释放许可。

共享锁 CountDownLatch

CountDownLatch 是一个常用的共享锁,其功能相当于多线程环境下的倒数门闩,CountDownLatch 可以指定一个计数值,在并发环境下由线程进行减一操作,当计数值变为0之后,被 await 方法阻塞的线程就会唤醒,从而实现线程间的计数同步。

读写锁

读写锁的内部包含两把锁:一把是读(操作)锁,是一种共享锁;另一把是写(操作)锁,是一种独占锁。

读写锁 ReentrantReadWriteLock

通过 ReentrantReadWriteLock 类能获取读锁和写锁。下面为一个例子。


public class ReadWriteLockTest {

    // 创建一个map,代表共享数据
    final static Map MAP = new HashMap();
    // 创建一个读写锁
    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("抢占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("抢占READ_LOCK,开始执行read操作");
            Thread.sleep(1000);
            String value = MAP.get(key); // 读取共享数据
            return value;
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            READ_LOCK.unlock();
        }
        return null;
    }

    public static void main(String[] args) {
        Runnable writeTarget = () -> put("key", "value");
        Runnable readTarget = () -> get("key");

        for(int i = 0; i < 4; i++) {
            new Thread(readTarget, "读线程" + i).start();
        }

        for(int i = 0; i < 2; i++) {
            new Thread(writeTarget, "写线程" + i).start();
        }
    }

}

锁的升级与降级

锁升级是指读锁升级为写锁,锁降级是指写锁降级为读锁。在 ReentrantReadWriteLock 读写锁中,只支持写锁降级为读锁,而不支持读锁升级为写锁。

不支持读锁的升级,主要是避免死锁。

总结来说,与 ReentrantLock 相比,ReentrantReadWriteLock 更适合读多写少的场景,可以提高并发读的效率,而 ReentrantLock 更适合读写比例相差不大或写比读多的场景。

StampedLock

StampLock(印戳锁)是对ReentrantReadWriteLock读写锁的一种改造,主要的改进为:在没有写只有读的场景下,StampLock 支持不用加读锁而是直接进行读操作,最大程度提升读的效率,只有在发生过写操作之后,再加读锁才能进行读操作。

StampLock 的三种模式如下:

  1. 悲观读锁:与ReadWriteLock的读锁类似,多个线程可以同时获取悲观读锁,悲观读锁是一个共享锁。
  2. 乐观读锁:相当于直接操作数据,不加任何锁,连读锁都不要。
  3. 写锁:与 ReadWriteLock 的写锁类似,写锁和悲观读锁是互斥的。虽然写锁与乐观读锁不会互斥,但是在数据被更新之后,之前通过乐观读锁获得的数据已经变成了脏数据。

StampLock 没有实现 ReadWriteLock 接口,而是定义了自己的锁操作API,主要如下:

  • 悲观读锁的获取与释放:
// 获取普通读锁(悲观读锁),返回long类型的印戳值
public long readLock()

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

// 释放写锁,以获取写锁时的印戳值作为参数
public void unlockWrite(long stamp)
  • 乐观读的印戳获取与有效性判断
// 获取乐观锁,返回龙类型的印戳值,返回0表示当前处于写锁模式,不能乐观读
public long tryOptimisticRead()

// 判断乐观度的印戳值是否有效,以tryOptimisticRead返回的印戳值作为参数
public long tryOptimisticRead()

你可能感兴趣的:(JAVA 多线程与高并发学习笔记(十三)——JUC显式锁)