Lock 的一百种玩法(剖析 Java Lock 原理)

大家都知道 Java 中有 synchronized 实现锁,也有 Lock 接口来实现显示的锁。synchronized 关键字更多贴近 Java 虚拟机,而 Lock 则更多贴近我们的 Java 代码。Lock 也具备了很多传统 synchronized 不具备的功能,本身也包含了很多的设计思维。学习 Lock 可以很好地提升一个人的 Java 功底,也能从中隐示地提高一个人的编程素养。

学习忌浮躁

附(作者的话):

原本我是想用 一篇文章 来让大家了解和深入理解 Lock 接口的各种知识。但是后来我深入学习后才发现,关于 Lock 的知识点,确实是 过于庞大,光靠一篇文章的简单叙述实在是难以详尽。
所以这篇文章我会更多的让大家去理解 Lock 锁的 基本实现原理设计思维,等你理解了这些,就足够去应对一些 中型 企业(因为要去大厂面试,基本上你的竞争对手也都懂得这些)。

我花费的更多精力写的更详尽的文章,是对 JDK 逐行 源码 中的 细节 做更多探讨(因为多线程难就难在这很多很多细小的情况,各种复杂的逻辑分析,各种运行不确定和错误难以重现)。
所以,这篇文章可以作为学习两大 lock 源码的基础,看完我这篇博客,然后再去阅读我的源码解读的文章。
ReentrantLock 的源码解读
ReentrantReadWriteLock 的源码解读

锁的本质意义

锁是对共享变量的一种保护:
我们在对共享变量进行操作的时候,会产生各种各样的安全问题。为了保证对资源的安全访问,因此出现了锁。
很多人可能会对线程、资源、锁的各种关系分缠不清,我们可以这样来理解:

  • 首先锁是对资源的保护,因而锁住的是资源(也就是我们的对象)
  • 线程是程序的执行者,它负责操作各种各样的资源,因而线程需要去获取锁,是锁的拥有者
  • 线程为了保护资源,因此需要持有锁,锁住宝贵的资源

锁从资源的访问角度可以分为 独占锁共享锁(读锁、写锁):

独占锁

独占锁(又叫互斥锁、排它锁):
对资源的保护需求不同,或者线程的操作要求不同。有时,我们必须保证一个资源的拥有者 只能有一个线程。这时线程就要自己独占资源,将其他线程隔绝在外。

比如:我们抢车位,一个停车位,在同一时刻,只能停下一辆汽车。我们必须在其他车来之前,获取停车位,成功占有停车位。此时,其他车子如果需要这个停车位,就只能等待。等到你的车子开走 了,他们中的一个才能再次占据车位,然后其他车次继续等待。
独占锁

共享锁:

有些情况下,我们对资源虽然需要保护,但是没有达到一个人持有才不会出错的保护状态。也就是 多个线程一起共享,也不会出错。
就比如,我们同时需要看一部小电影。虽然这部电影的所有人很珍惜它,生怕被弄坏了,尽力保护。不过他发现,把电影传在网上,让别人一起去看,也不会对资源本身造成破坏。也就是可以很多人聚在一块一起看,这样就 增加了资源的利用
共享锁

Lock 接口概览

在 locks 包中,关键的就是 ReentrantLock 和 ReentrantReadWriteLock。
锁的实现都是基于 Lock 接口,虽然 ReentrantReadWriteLock 没有继承 Lock 接口,不过,它的方法就是用来构建出一对 Lock,ReadLock 和 WriteLock,而 read 和 write 的锁就是实现 Lock 接口,所以要理解明白 JDK 中的锁,就要从 Lock 接口开始研究。
lock
lock 接口的各大方法:

  • void lock();
    获取锁的时候不死不休,只有获取到锁之后才会继续往下执行。
  • boolean tryLock();
    获取锁的时候浅尝辄止,如果能获取就获取,获取不到就不去获取了。
  • boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    获取的时候只有一部分耐心,等了太久就不等了。
  • void lockInterruptibly() throws InterruptedException;
    获取锁的时候没有决心,别人一打断,不让他获取就不获取了。
  • void unlock();
    释放锁
  • Condition newCondition();
    lock

ReentrantLock 基本使用

ReentrantLock 有两种,公平锁和非公平锁。

  • 公平锁保证线程按照顺序获取到锁,先来先得,后来等待。
  • 非公平锁则会出现插队现象。一个线程来了之后,不管三七二十一,直接抢锁,抢到了就开心,抢不到再回去等待。
    因为这样可能存在另一个线程唤醒的期间,这个新来的线程已经将任务执行完了,这是候另一个线程唤醒成功,执行它的任务。(这样在唤醒一个线程执行它的任务的时候,一次性执行完了两个任务,从而使得非公平锁效率更高。)
Lock lock = new ReentrantLock(true);  // 公平锁
Lock lock = new ReentrantLock(false); // 非公平锁
Lock lock = new ReentrantLock();      // 默认非公平锁

加锁解锁

与 synchronized 不同的是,ReentrantLock 需要显示地加锁和解锁
并且加锁几次,就需要解锁几次

lock.lock();   // 加锁
lock.lock();   // 再次加锁
lock,unlock(); // 解锁
lock,unlock(); // 一定要再解锁一次

等待与唤醒

Lock 接口有个 newCondition(); 方法,我在上面并没有详细述说。
说道这个,其实我们就能再想到 synchronized 关键字,我们知道,Lock 本身就是在 Java 语言层面实现了并扩展 synchronized 关键字,最初是为了提升性能和提供更高级的用法。
所以要了解 Condition,只要会使用 synchronized 关键字下的 wait() 和 notify() 方法即可。

在调用 lock() 方法上锁之后,就可以调用 condition.await() 方法,释放持有的锁,并进入等待。另一个线程就可以调用 lock() 方法获取到锁,然后调用 condition.signal() 或者 condition.signalAll() 方法唤醒一个或所有的线程。此时,由于锁还被线程占有,所以一般这个线程去叫醒其他线程后,自己就释放锁,让叫醒的线程去获取。
(和 wait notify 几乎没有差别)

public class Demo {
    private static Lock lock = new ReentrantLock();
    private static Condition condition = lock.newCondition();

    public static void main(String[] args) throws InterruptedException {
        Thread th = new Thread(new Runnable() {
            @Override
            public void run() {
                lock.lock(); // 获取锁
                System.out.println("condition.await()");
                try {
                    condition.await(); // 释放锁,开始等待
                    System.out.println("我又被叫回来了");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }finally {
                    lock.unlock();
                }
            }
        });
        th.start();
        
        Thread.sleep(2000L); // 睡两秒,先让子线程获取锁,然后释放等待
        lock.lock(); // 获取锁
        condition.signalAll(); // 叫醒线程
        lock.unlock(); // 释放锁,让子线程获取到继续执行
    }
}

与 synchronized 关键字的 wait notify 一样,这里同样容易出现死锁。
假如先 notify(也就是这里的 signal),然后再 wait(这里的 await),就会出现永久等待,因为线程先唤醒过它了,之后不去唤醒了,而它才开始等待,这样就永远不会结束。

public class Demo {
    private static Lock lock = new ReentrantLock();
    private static Condition condition = lock.newCondition();

    public static void main(String[] args) throws InterruptedException {
        Thread th = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(2000L); // 在这两秒主线程已经执行了唤醒操作
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                lock.lock();
                try {
                    System.out.println("获得锁,调用condition.await()\n");
                    condition.await();      // 不会再被唤醒了
                    System.out.println("唤醒了...\n");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    lock.unlock();
                }
            }
        });

        th.start();
        lock.lock();
        condition.signal(); // 唤醒子线程,但是子线程还没有开始等待
        lock.unlock();
    }
}

多条件等待与唤醒

之所以 Lock 厉害,除了高性能,还有一点就是扩展了 synchronized 关键字。
上面展示的 Condition 只是与 synchronized 相同之处,但是更厉害的地方在于,一个 Lock 可以不仅仅只创建一个 Condition,它可以创建多个 Condition,然后线程 await 时可以选择不同的 Condition,signal 的时候也可以指定去唤醒特定的 Condition 下等待的线程。

就如同很多人去排队,可以分成很多的队列排队。然后,每次服务员给客户服务的时候,都可以选择一条指定的队伍去服务。
这样就更加灵活,不同需求的人可以分开,比如按男女分开。这样每次叫人都很轻松,而不用全都挤在一起,每次要找特定的人就会很难。比如要找女生,就去女生队列,要找男生,就去男生队列。如果都在一块,就很容易叫错人。

ReadWriteLock 锁降级

加锁解锁大家很容易理解,在没有写锁时,所有的读锁都不会阻塞,可以同时加一把锁。
在写锁来时,会阻塞其他的读锁和写锁。

这里主要提及一下锁降级。
当一个锁持有读锁时,是不能够直接获取读锁的,因为读锁是共享的,所以很可能其他线程也持有读锁,这样写锁仍然会阻塞。
但是一个线程如果持有写锁,那就保证了,其他线程不会持有读锁,那么它就可以安全地获取读锁,实现锁降级。

锁的底层原理

设置状态值

我们可以用这样一种方式来实现锁

if(null == owner)
    owner = Thread.currentThread();

这样,通过一个标记来表示是谁获取了锁。其他线程来时,由于锁的持有者已经不是 null,它们就无法获取锁。
不过,代码直接这么写是不行的,这不是原子操作,会出现线程不安全的问题。
可能两个线程都执行到第一步,判断 owner == null。然后其中一个线程修改了 owner 的值,但是另一个线程刚刚也判断过了,也会来修改 owner 的值。
这样就出现了问题,锁的持有者在持有状态时竟然被别人给夺走了!

要解决这样一个非原子问题,有两个解决办法。
一个是加锁
一个是 CAS

显然,加锁是不可取的。我们总不能用一把锁去实现另一把锁吧。
所以就需要 CAS 登场。

CAS

CAS(Compare And Swap):比较并交换。
在对一个共享变量的修改中,只有一个线程能够修改成功。它会拿现有的值和期望值比较,如果现有的值是期望修改的那个值,才会被修改。否则,如果被别的线程修改,就不会再是期望的值,就会修改失败。
比如,两个线程同时要将 0 改为 1。由于一个线程先把 0 改成了 1,第二个线程再去修改时,值已经不是 0,所以修改失败。
cas
所以之前的代码就可以这样来表示

cas(null, Thread.currentThread());

可重入

通过 CAS 虽然保证了线程能安全持有锁。
但是仍有一个问题,当线程已经获取了这个锁的时候,如果在获取锁,就会获取不到。那在自己内部就会死锁,这是非常不友好的。
所以之前的一个变量并不能满足我们的需求。

为了能够使锁可以重入,我们可以设置一个 int 类型的变量。0 表示没有锁,1 表示有线程持有锁,2、3、4 等等表示锁重入的次数。
此时获取锁应写为

compareAndSwap(0, 1);

在已经持有锁的情况下,重入时直接加 1 就行了。

自旋

通过之前的 CAS 方法,可以保证获取锁是安全的。但是,如果一个线程获取不到锁,就会直接返回 false,也就是实现了 tryLock() 的方法。
不过更多时候我们都是需要线程去等待,直到获取锁了之后才返回。
这时候我们可以用自旋:while() 循环让它重复获取锁

while(!compareAndSwap(0, 1)) {
    // 获取不到锁,反复尝试
}

不过这样还是有一个缺点,就是十分耗 CPU。因为线程获取不到锁,一直在额外的重复加锁的动作,而这时候持锁者可能需要很久才会释放锁。这段时间的疯狂加锁操作就是白白浪费了宝贵的 CPU 资源。

yield+自旋

为了让其他线程不用疯狂无故消耗 CPU 资源,我们稍微对代码加一点润色。

while(!compareAndSwap(0, 1)) {
    Thread.yield();
}

每次获取不到锁,主动退出 CPU,通过线程调度使真正需要 CPU 资源的线程可以充分利用。
但是,这个方法仍旧是不靠谱的。

  • 假设只有两个线程,当 yield() 调度之后,在 CPU 时间切片过后,仍然会切换线程,再次回到该线程执行,然后该线程再次 CAS,再次 yield() 调度。往复循环,仍然会浪费掉很多计算资源和线程的切换资源。
  • 假设线程数量很多,同时又很多线程争抢锁。这时,一个线程让出 CPU,线程调度到另一个争抢的线程,然后同样的,再次切换到另一个争抢的线程……如此往复,仍然是起不到好的效果。

sleep+自旋

于是,为了让 CPU 可以充分地供给给真正需要使用的线程。诞生了如下写法。

while(!compareAndSwap(0, 1)) {
    Thread。sleep(???);
}

让线程得不到锁就睡一会。
但是,仍然有很大问题。因为,这个睡眠时间是没法确定的。

  • 假设持有锁的线程执行了一小时,每次线程获取不到锁睡眠 10 秒。那么,这么长的时间里,它仍然要重复很多次。
  • 假设持有锁的线程只执行了 1 毫秒,但是另一个线程睡了 10 秒。这样就浪费掉了原本有 9999 个任务的执行。

阻塞+自旋

为了真正达到只在有任务需要 CPU 时,才让线程执行。最终出现如下方法。

while(!compareAndSwap(0, 1)) {
    park();
}

在获取不到锁的时候,直接阻塞线程。只有等到另一个线程执行完了,再唤醒它。
这样就保证了线程不浪费 CPU 资源。
(当然,实际上,线程的阻塞与唤醒也是有很多消耗的。所以在自旋锁与阻塞锁之间,都需要分析取舍)

ReentrantLock 基本实现原理(实现模型)

ReentrantLock 主要是基于一个 int数值变量 来表示加锁的状态:
0 表示没有被加锁,1 表示被加锁,2、3、4… 表示持有锁的线程重入的次数

加锁的过程就是线程通过 CAS 去将这个变量的值从 0,修改为 1,这样,只有修改成功的线程能够持有锁。
其他线程就要自旋或者阻塞等待。

等待的线程必须被保存起来,这样将来释放锁之后,才能够找到那些等待的线程,去将其唤醒。
因此,为了让线程排队,就需要一个线程安全的队列,去存放阻塞的线程。
(再放置一个变量,用于保存当前的持锁线程)
ReentrantLock

ReentrantReadWriteLock 实现模型

ReadWriteLock 有两把锁,一把读锁,一把写锁。
其中读锁共享,写锁互斥。

那么我们就需要两个变量去分别表示 共享锁 和 互斥锁 分别的持有数目
在 互斥锁 没有被持有的情况下,多个线程去同时获取读锁,是不用阻塞,可以一起获取到的
ReadWriteLock
而如果有 写锁(互斥锁)来获取锁,那么 读锁(包括其他写锁)就必须被阻塞
ReadWriteLock

AQS 的设计思维(设计模式)

AQS 内部用链表维护了一个队列,用于实现对线程的排队、阻塞、与唤醒。

不仅如此,AQS 内部封装了 state 变量值,并且封装了 加互斥锁,加共享锁,解锁,可中断锁 的模板方法。
像 ReentrantLoc、ReentrantReadWriteLock,都是采用了一个内部类,去继承 AQS,重写没有实现的自己需要的方法。
所以要学会 Java 中的 锁,就必须明白 AQS。

作者的话

这篇博客写得相对简单,因为确实觉得内容较为基础,大家应该是都很理解的。
很多地方,应该只要大致扫一眼就能懂,可以全当复习。

我将其写完,主要是为了衔接一下我写的两篇 ReentrantLock 和 ReentrantReadWriteLock 的源码解读,将其中的一些基础的 lock 实现的大致原理描述,这样,大家在阅读源码的时候就不容易迷失方向。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值