# 三、锁

# 1. Lock

# 1.1 简介

  • 锁是一种工具,用于控制对共享资源的访问。
  • Lock 和 synchronized,这两个是最常见的锁,它们都可以达到线程安全的目的,但是在使用上和功能上又有较大的不同。
  • Lock 并不是用来代替 synchronized 的,而是当 synchronized 不适合或不足以满足要求的时候,来提供高级功能的。
  • Lock 接口中最常见的实现类是 ReentrantLock
  • 通常情况下,Lock 只允许一个线程来访问这个共享资源。不过有的时候,一些特殊的实现也可允许并发访问,比如 ReadWriteLock 里面的 ReadLock。

# 1.2 为什么 synchronized 不够用?

  1. 效率低:锁的释放情况少、试图获得锁时不能设定超时、不能中断一个正在尝试获得锁的线程。
  2. 不够灵活(读写锁更灵活):加锁和释放的时机单一,每个锁仅有单一的条件(某个对象),可能是不够的。
  3. 无法知道是否成功获取到锁。

# 1.3 主要方法

  • void lock()

    获取锁。如果锁已被其他线程获取,则等待。

    需要主动在 finally 中释放锁。

    lock() 方法不能被中断,这会带来很大的隐患:一旦陷入死锁,lock() 就会陷入永久等待。

  • boolean tryLock()

    尝试获取锁,如果当前锁没有被其他线程占用,则获取成功,则返回 true,否则返回 false,代表获取失败。

  • boolean tryLock(long time, TimeUnit unit)

    在一定时间内一直尝试获取锁,超时则放弃。

  • void lockInterruptibly() throws InterruptedException

    相等于 tryLock(long time, TimeUnit unit) 把时间设置为无限。在等待锁的过程中,线程可以被中断。

  • void unLock()

    尝试释放锁。

ReentrantLock 的拓展方法

  • boolean isHeldByCurrentThread()

    判断锁是否被当前线程持有。

  • int getQueueLength()

    可以返回当前正在等待这把锁的队列有多长。

# 1.4 可见性的保证

image-20210319120402944

# 2. 锁的分类

image-20210319120531375

# 2.1 乐观锁和悲观锁

# 2.1.1 悲观锁的劣势

  • 阻塞和唤醒带来的性能劣势。
  • 永久阻塞:如果持有锁的线程被永久阻塞,比如遇到了无限循环、死锁等活跃性问题,那么等待该线程释放锁的那几个悲催的线程,将永远也得不到执行。
  • 可能造成优先级反转。

# 2.1.2 乐观锁

想假设不会有问题,继续操作,操作完再检查有没有问题,没问题就直接提交,有问题再具体处理。

乐观锁的实现一般都是利用 CAS 算法来实现的。

# 2.1.3 使用场景

  • 悲观锁
    • 适合并发写入多的情况,适用于临界区持锁时间比较长的情况,悲观锁可以避免大量无用自旋等消耗,典型情况:
      • 临界区有 IO 操作
      • 临界区代码复杂或者循环量大
      • 临界区竞争非常激烈
  • 乐观锁
    • 适合并发写入少、大部分是读取的场景,不加锁能让性能大幅度提高。

# 2.2 可重入锁和非可重入锁

# 2.2.1 可重入

  • 同一个线程获得锁后,可以在不释放锁的情况下再次获得该锁。

# 2.2.2 源码对比:可重入锁 ReentrantLock 和非可重入锁 ThreadPoolExecutor 的 Worker 类

image-20210319210225187

# 2.3 公平锁和非公平锁

# 2.3.1 什么是公平和非公平

公平指的是按照线程请求的顺序来分配锁。

非公平是指不完全按照请求的顺序,在一定情况下可以插队。

PS:非公平是指“在合适的时机”插队,而不是盲目插队。

只有在申请锁的那一瞬间可以插队,一旦进入了等待队列,就不能插队了。

# 2.3.2 为什么要有非公平锁

  • 提高效率
  • 避免唤醒带来的空档期。

# 2.3.3 演示

  • 公平锁

    /**
     * 演示公平锁
     *
     * @author Hedon Wang
     * @create 2021-03-19 9:17 PM
     */
    public class FairLock {
    
        public static void main(String[] args) throws InterruptedException{
            PrintQueue printQueue = new PrintQueue();
            Thread[] threads = new Thread[10];
            for (int i = 0; i < threads.length; i++) {
                threads[i] = new Thread(new Job(printQueue));
            }
    
            for (int i = 0; i < threads.length; i++) {
                threads[i].start();
                Thread.sleep(100);
            }
            
        }
        
    }
    
    class Job implements Runnable{
        private PrintQueue printQueue;
        public Job(PrintQueue printQueue){
            this.printQueue = printQueue;
        }
        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName() + "开始打印");
            printQueue.printJob(printQueue);
            System.out.println(Thread.currentThread().getName() + "打印完毕");
        }
    }
    
    class PrintQueue{
    
        //true 表示公平
        private Lock queueLock = new ReentrantLock(true);
    
        public void printJob(Object document){
          
          	//打印第一次
            queueLock.lock();
            try {
                Long duration = (long)new Random().nextInt(10000)+1000;
                System.out.println(Thread.currentThread().getName() + "正在打印1,需要" + duration/1000 + "s");
                Thread.sleep(duration);
            }catch(InterruptedException e) {
                e.printStackTrace();
            }finally {
                queueLock.unlock();
            }
    				
          	//打印第二次
            queueLock.lock();
            try {
                Long duration = (long)Math.random() * 10000;
                System.out.println(Thread.currentThread().getName() + "正在打印2,需要" + duration/1000 + "s");
                Thread.sleep(duration);
            }catch(InterruptedException e) {
                e.printStackTrace();
            }finally {
                queueLock.unlock();
            }
        }
    }
    

    结果:可以看出在公平锁的情况下,10个线程都进入队列,本来线程 Thread0 要打印 2 次的,但是因为打印完第 1 次后就释放锁了,锁就被排在队列的后面那个线程 Thread1 拿到了,然后当前线程 Thread0 想在获取锁就只能去队列后面继续排队了,所以打印顺序非常整齐。

    Thread-0开始打印
    Thread-0正在打印1,需要2s
    Thread-1开始打印
    Thread-2开始打印
    Thread-3开始打印
    Thread-4开始打印
    Thread-5开始打印
    Thread-6开始打印
    Thread-7开始打印
    Thread-8开始打印
    Thread-9开始打印
    Thread-1正在打印1,需要7s         
    Thread-2正在打印1,需要2s
    Thread-3正在打印1,需要9s
    Thread-4正在打印1,需要3s
    Thread-5正在打印1,需要10s
    Thread-6正在打印1,需要4s
    Thread-7正在打印1,需要9s
    Thread-8正在打印1,需要3s
    Thread-9正在打印1,需要4s
    Thread-0正在打印2,需要0s
    Thread-0打印完毕
    Thread-1正在打印2,需要0s
    Thread-1打印完毕
    Thread-2正在打印2,需要0s
    Thread-2打印完毕
    Thread-3正在打印2,需要0s
    Thread-3打印完毕
    Thread-4正在打印2,需要0s
    Thread-4打印完毕
    Thread-5正在打印2,需要0s
    Thread-5打印完毕
    Thread-6正在打印2,需要0s
    Thread-6打印完毕
    Thread-7正在打印2,需要0s
    Thread-7打印完毕
    Thread-8正在打印2,需要0s
    Thread-8打印完毕
    Thread-9正在打印2,需要0s
    Thread-9打印完毕
    
  • 非公平锁

    /**
     * 演示非公平锁
     *
     * @author Hedon Wang
     * @create 2021-03-19 9:17 PM
     */
    public class UnFairLock {
    
        public static void main(String[] args) throws InterruptedException{
            UnFairPrintQueue unFairPrintQueue = new UnFairPrintQueue();
            Thread[] threads = new Thread[10];
            for (int i = 0; i < threads.length; i++) {
                threads[i] = new Thread(new UnFairJob(unFairPrintQueue));
            }
    
            for (int i = 0; i < threads.length; i++) {
                threads[i].start();
                Thread.sleep(100);
            }
            
        }
        
    }
    
    class UnFairJob implements Runnable{
        private UnFairPrintQueue unFairPrintQueue;
        public UnFairJob(UnFairPrintQueue unFairPrintQueue){
            this.unFairPrintQueue = unFairPrintQueue;
        }
        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName() + "开始打印");
            unFairPrintQueue.printJob(unFairPrintQueue);
            System.out.println(Thread.currentThread().getName() + "打印完毕");
        }
    }
    
    class UnFairPrintQueue{
    
        //false 表示不公平,默认就是 false
        private Lock queueLock = new ReentrantLock(false);
    
        public void printJob(Object document){
            queueLock.lock();
            try {
                Long duration = (long)new Random().nextInt(10000)+1000;
                System.out.println(Thread.currentThread().getName() + "正在打印1,需要" + duration/1000 + "s");
                Thread.sleep(duration);
            }catch(InterruptedException e) {
                e.printStackTrace();
            }finally {
                queueLock.unlock();
            }
    
            queueLock.lock();
            try {
                Long duration = (long)Math.random() * 10000;
                System.out.println(Thread.currentThread().getName() + "正在打印2,需要" + duration/1000 + "s");
                Thread.sleep(duration);
            }catch(InterruptedException e) {
                e.printStackTrace();
            }finally {
                queueLock.unlock();
            }
        }
    }
    

    结果:可以看到在非公平锁的情况下,因为唤醒线程需要开销,而且开销比较大, 而当 Thread0 释放锁后想再次尝试获取锁,它是不需要唤醒线程的,效率非常快,所以就插队成功,获取到锁了。于是就出现了 Thread0 连续打印 2 次的情况了。

    Thread-0开始打印
    Thread-0正在打印1,需要10s
    Thread-1开始打印
    Thread-2开始打印
    Thread-3开始打印
    Thread-4开始打印
    Thread-5开始打印
    Thread-6开始打印
    Thread-7开始打印
    Thread-8开始打印
    Thread-9开始打印
    Thread-0正在打印2,需要0s
    Thread-0打印完毕
    Thread-1正在打印1,需要6s
    Thread-1正在打印2,需要0s
    Thread-1打印完毕
    Thread-2正在打印1,需要8s
    Thread-2正在打印2,需要0s
    Thread-2打印完毕
    Thread-3正在打印1,需要7s
    Thread-3正在打印2,需要0s
    Thread-3打印完毕
    Thread-4正在打印1,需要6s
    Thread-4正在打印2,需要0s
    Thread-4打印完毕
    Thread-5正在打印1,需要10s
    Thread-5正在打印2,需要0s
    Thread-5打印完毕
    Thread-6正在打印1,需要6s
    Thread-6正在打印2,需要0s
    Thread-6打印完毕
    Thread-7正在打印1,需要9s
    Thread-8正在打印1,需要2s
    Thread-8正在打印2,需要0s
    Thread-8打印完毕
    Thread-9正在打印1,需要4s
    Thread-9正在打印2,需要0s
    Thread-9打印完毕
    Thread-7正在打印2,需要0s			//第二次打印前申请锁的时候没有抢到锁,所以只能进入队列里面按“公平锁”的方式等待。
    Thread-7打印完毕
    

# 2.3.4 特例

针对 tryLock() 方法,它不遵守设定的公平的规则。

当有线程执行 tryLock() 的时候,一旦有现场释放了锁,那么这个正在 tryLock 的线程就能获取到锁,即使在它之前已经有其他线程在等待队列里了。

# 2.3.5 对比

image-20210319212810726

# 2.3.6 源码

image-20210319212926134

# 2.4 共享锁和排它锁

# 2.4.1 什么是共享锁和排它锁

  • 排它锁:独占锁、独享锁。
  • 共享锁:又称为读锁。获得共享锁后,可以查看但无法修改和删除数据,其他线程此时也可以获取到共享锁,也可以查看但无法修改和删除数据。
  • ReentrantReadWriteLock,其中读锁是共享锁,写锁是排它锁。

# 2.4.2 作用

  • 在没有读写锁之前,我们假设使用 ReentrantLock,那么虽然我们保证了线程安全,但是也浪费了一定的资源:多个读同时进行,并没有线程安全问题。
  • 在读的地方使用读锁,在写的地方使用写锁,灵活控制,提高了程序的执行效率。

# 2.4.3 规则

  1. 多个线程只申请读锁,都可以申请到。
  2. 如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待读锁的释放。
  3. 如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或读锁,则申请的线程会一直等待释放写锁。
  4. 要么多读,要么一写。

# 2.4.4 使用示例

/**
 * 演示读写锁的用法
 *
 * @author Hedon Wang
 * @create 2021-03-20 11:00 AM
 */
public class ReadWriteLock {

    private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
    private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
    private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();

    private static void read(){
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到了读锁。");
            try {
                Thread.sleep(1000);
            }catch (InterruptedException e){
                e.printStackTrace();
            }
        }finally {
            System.out.println(Thread.currentThread().getName() + "释放读锁。");
            readLock.unlock();
        }
    }

    private static void write(){
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到了写锁。");
            try {
                Thread.sleep(1000);
            }catch (InterruptedException e){
                e.printStackTrace();
            }
        }finally {
            System.out.println(Thread.currentThread().getName() + "释放写锁。");
            writeLock.unlock();
        }
    }

    public static void main(String[] args) {
        new Thread(()->read()).start();
        new Thread(()->read()).start();
        new Thread(()->write()).start();
        new Thread(()->write()).start();
    }

}

结果:写锁可以共存,读锁只能有一个。

Thread-0得到了读锁。
Thread-1得到了读锁。
Thread-1释放读锁。
Thread-0释放读锁。
Thread-2得到了写锁。
Thread-2释放写锁。
Thread-3得到了写锁。
Thread-3释放写锁。

# 2.4.5 读写锁交互方式

# ① 读写锁插队策略
  • 公平锁:不插队

  • 非公平:写锁可以随时插队,读锁仅在等待队列头结点不是想获取写锁的线程的时候可以插队。

    • 假设线程2和线程4正在同时读取,线程3想要写入,拿不到锁,于是进入等待队列。线程5不在队列,现在过来想要读取。

      • 策略[1]:让5插队。效率高,但是容易造成饥饿。
      image-20210320110911731
      • 策略[2]:不让5插队。避免饥饿,但是效率较低。(ReentrantReadWriteLock 默认采用该策略
      image-20210320111114564
# ② 锁的升降级策略
  • 升级:读锁 -> 写锁(ReentrantReadWriteLock 不支持
    • 读的话大家可以一起读,写只能一个写,读升级写的锁,就可能存在读和写共存的状态,所以要求其他读线程要释放读锁。
    • 如果这个时候2个读锁都要升级,那么这2个都要求对方释放读锁,就造成死锁。
  • 降级:写锁 -> 读锁(ReentrantReadWriteLock 支持
    • 写完后想读又不想被打断,可以提高性能。

# 2.5 自旋锁和阻塞锁

# 2.5.1 自旋锁的意义

  • 阻塞和唤醒一个 Java 线程需要操作系统切换 CPU 状态来完成,这种状态转换需要耗费处理器时间。
  • 如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长
  • 在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复线程的花费可能会让系统得不偿失。
  • 如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃 CPU 的执行时间,看看持有锁的线程是否很快就会释放锁。
  • 而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成之前前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而直接获取同步资源,从而避免切换线程的开销,这就是自旋锁。

# 2.5.2 自旋锁的缺点

  • 如果锁被占用的时间很长,那么自旋的线程只会白白浪费 CPU 资源。

# 2.5.3 自旋锁的应用

  • 在 java1.5 版本及以上的并发框架 JUC 和 atomic 包下的类基本都是自旋锁的实现。

  • AtomicInteger 中自旋锁的实现原理是 CAS。

    • AtomicInteger 中调用 unsafe 进行自增操作的源码中的 do-while 循环就是一个自旋操作,如果修改过程中遇到其他线程竞争导致没修改成功,就在 while 里死循环,直至修改成功。

      public final int getAndAddInt(Object var1, long var2, int var4) {
          int var5;
          do {
              var5 = this.getIntVolatile(var1, var2);
          } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
      
          return var5;
      }
      

# 2.5.4 手写一个自旋锁

/**
 * 实现一个自旋锁
 *
 * @author Hedon Wang
 * @create 2021-03-20 12:04 PM
 */
public class CreateSpinLock {

    private AtomicReference<Thread> sign = new AtomicReference<Thread>();

    /**
     * 加锁
     */
    public void lock(){
        Thread current = Thread.currentThread();
      	//CAS 操作,锁就是期望将锁赋值为当前线程
        while (!sign.compareAndSet(null,current)){
            System.out.println(current.getName() + "正在自旋");
        }
    }

    /**
     * 解锁
     */
    public void unlock(){
        Thread current = Thread.currentThread();
      	//CAS 操作,就是希望将当前锁设置为 null,释放
        sign.compareAndSet(current,null);
    }

    public static void main(String[] args) {
        CreateSpinLock spinLock = new CreateSpinLock();

        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + "尝试获取自旋锁");
                spinLock.lock();
                System.out.println(Thread.currentThread().getName() + "获得了自旋锁");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    spinLock.unlock();
                    System.out.println(Thread.currentThread().getName() + "释放了自旋锁");
                }
            }
        };

        Thread t1 = new Thread(runnable);
        Thread t2 = new Thread(runnable);
        t1.start();
        t2.start();
    }
}

# 2.6 可中断和不可中断锁

  • 如果某一线程 A 正在执行锁中的代码,另一线程 B 正在等待获取该锁。可能由于等待时间过长,线程 B 不想等待了,想先处理其他的事情,我们可以中断它,这就是可中断锁。
  • synchronized 就是不可中断锁,ReentrantLock 就是可中断锁。

# 2.7 锁优化

# 2.7.1 自旋锁和自适应

自旋:如果线程可以很快获取锁,那么可以不在 OS 层挂起线程,而是让线程做几个忙循环,这就是自旋。

自适应自旋:自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间和锁的拥有者状态来决定。

  • 如果锁被占用的时间很短,自旋成功,那么能节省线程挂起以及切换时间,从而提升系统性能。
  • 如果锁被占用时间很长,自旋失败,会白白耗费处理器资源,降低系统性能。

# 2.7.2 锁消除

在编译代码的时候,JVM 检查到根据不存在共享数据竞争,自然也就无需同步加锁了,JVM 会帮我们把我们加的锁去掉。

-XX:+EliminateLocks 来开启。

同时要使用 -XX:+DoEscapeAnalysis 开启逃逸分析,所谓逃逸分析:

  • 如果一个方法中定义的一个对象,可能被外部方法引用,称为方法逃逸。
  • 如果对象可能被其他外部线程访问,称为线程逃逸。
    • 比如赋值给类变量或者可以在其他线程中访问的实例变量。

(因为一旦出现逃逸,就不能轻易消除锁了,无法得知外部是如何使用我们的资源了,该同步还是得同步。)

# 2.7.3 锁粗化

通常我们都要求同步块要小,但一系列连续的操作导致对一个对象反复的加锁和解锁,这会导致不必要的性能损耗,这种情况建议把锁同步的范围加大到整个操作序列。

# 2.7.4 轻量级锁

轻量级是相对于传统锁机制而言的,本意是没有多线程竞争的情况下,减少传统锁机制使用 OS 实现互斥所产生的性能损耗。

实现原理很简单,就是类似乐观锁的方式。

如果轻量级锁失败,表示存在竞争,那么 JVM 就会将其升级为重量级锁,导致性能下降(因为多了一次尝试)。

# 2.7.5 偏向锁

偏向锁是在无竞争情况下,直接把整个同步消除了,连乐观锁都不用,从而提高性能。

所谓偏向,就是偏向,即锁会偏向于当前已经占有锁的线程。

只要没有竞争,获得偏向锁的线程在将来进入同步块也不需要做同步。

当有其他线程请求相同的锁时,偏向模式结束。

如果程序中大多数锁总是被多个线程访问的时候,也就是竞争比较激烈,偏向锁反而会降低性能。

使用 -XX:-UseBiasedLocking 来禁用偏向锁,默认是开启的。

# 2.7.6 JVM 中获取锁的步骤

st=>start: 尝试偏向锁
e=>end: 尝试普通锁,使用 OS 互斥量在操作系统层挂起
op2=>operation: 尝试轻量级锁
op3=>operation: 尝试自旋锁
st->op2->op3->e
上次更新: 9/17/2021, 12:28:06 PM