JAVA -- 锁🔒相关问题

310 阅读17分钟

为什么需要锁

并发地访问共享资源,如果不加以管理,其结果是混乱的。就如上图,多方将对方块进行修改的代码范围,称为临界区,对于要进入临界区的并进行修改的行为,称为竞争条件为了消除竞争条件所带来的影响,使程序具有确定性,我们需要对共享资源加锁,以保证任意时刻只有一个线程进行访问

当只有拿到锁的线程才可访问临界区,其结果会如上图一样,而不是产生正在将方块改变成红色时,其他线程将其改变成了绿色的混乱行为。

Java也为各类可能的并发场景提供了不同的锁方案,掌握这些内容令我们能从容面对大多的场景,并当我们想完成更具侵略性的目标时,还能自行实现更合适的锁方案。

锁的特性

Java中的锁以SynchronizedLock接口为区分,其间能看到如内置锁、共享锁、可重入锁等等诸如此类的描述,实际上,它们所阐述的是特性,而不是某一种锁的具体实现。就如一个人可以具有多种身份,他是博士、是CEO、是父亲一样,锁也可以具有多种特性,是共享的、是可重入的等等。

Java中的锁可能具有如下的特性:

  • 悲观锁与乐观锁:悲观锁意味着,觉得自已在使用共享资源的时候,其他线程将会修改数据,因此为了避免篡改,将其他要进入临界区的线程阻塞住,直到完成使用。乐观锁与之相反,认为自己在使用共享资源的时候,其他线程不会修改数据,因此,乐观锁只进行锁竞争,而不会将其他线程阻塞住。

  • 独占锁与共享锁:取决于是否允许多个线程同时拿到锁,访问共享资源。如果能预料到接下来的时间不会发生写操作,无疑允许多方访问更能提高并发效率。虽然大家都进入了临界区,但是没有产生竞争条件,毕竟,读操作不会改变任何数据。

  • 公平锁与不公平锁区分于是否按照申请锁的顺序,为线程分配锁,即是否允许插队。如果允许插队,将可能使一些线程饥饿,迟迟不能分配到锁继续运行下去;如果不允许插队,那么一些需要紧急处理的线程任务,则被延迟处理,毕竟生活中,我们也需要VIP通道。

  • 可重入锁与不可重入锁:区分获取锁的线程,还没释放锁之前,能否重复地获取锁。如果一个递归方法需要上锁,而当这个锁是不可重入锁时,是无法递归的。以生活例子举例,能理解为加塞行为是否允许。当在餐厅进食中,发现需要加菜,餐厅是允许这样的加塞行为,也是合意的。可如果你挂了号看医生,在诊断完后,你对医生说“帮我朋友也看一下呗”,你看医生理不理你,如此,这样的加塞行为是不允许的。

  • 可中断锁与不可中断锁:是否允许在迟迟申请不到锁,或线程发生中断时能进行响应。可中断锁赋予了线程决定自己等待锁的时间,以及对中断的响应。

当然,以上以较大的方向区分了Java中锁的特性,但是并不代表的所有的特性。特别指明是因为,当实现任意的锁时,这些特性是不得不加以权衡的。后续的内容,还会出现锁的其他特性,但大部分是指某一类锁所具有的独特特性。

CAS(Compare and Set)

不仅是Java,操作系统的并发手段也离不开CAS,不夸张地说,CAS是原子操作的基石。各式各样的锁方案要进一步提升性能时,都可看到CAS的身影。因此也为了之后的行文流畅,先将CAS单独说明。

CAS,意为比较并交换,由硬件支持的原子指令保证原子性:

  • 核心思想

我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。

比较 A 与 V 是否相等。(比较) 如果比较相等,将 B 写入 V。(交换) 返回操作是否成功。 当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。可见 CAS 其实是一个乐观锁。 CAS的特性,使得它可以保证单个变量操作的原子性。

screenshot-20211018-135930.png

如上图中,主存中保存V值,线程中要使用V值要先从主存中读取V值到线程的工作内存A中,然后计算后变成B值,最后再把B值写回到内存V值中。多个线程共用V值都是如此操作。CAS的核心 是在将B值写入到V之前要比较A值和V值是否相同,如果不相同证明此时V值已经被其他线程改变,重新将V值赋给A,并重新计算得到B,如果相同,则将B值赋给V。

  • CAS 缺点

    1. ABA 的问题,就是一个值从A变成了B又变成了A,使用CAS操作不能发现这个值发生变化了,处理方式是可以使用携带类似时间戳的版本AtomicStampedReference
    2. 性能问题,我们使用时大部分时间使用的是 while true 方式对数据的修改,直到成功为止。优势就是相应极快,但当线程数不停增加时,性能下降明显,因为每个线程都需要执行,占用CPU时间。 JAVA 中的 CAS

Synchronized的优化

Monitor的依赖于底层操作系统的实现,申请锁与释放锁,阻塞与唤醒,将产生系统调用而有可观的开销,这种方式的Synchronized也称为重量级锁。如果频繁地使用Synchronized申请与释放锁,必然拉低系统性能

既然Synchronized是语言级别实现的,那么它的实现方式将有很大的想象空间。也引出了接下来的内容,偏向锁/轻量级锁/重量级锁/

Synchronized将根据实际运行情况,锁将经历从偏向锁,到轻量级锁,再到重量级锁的锁膨胀过程。

如果在相当长的一段时间内,只有一个线程要进入临界区,或者说并发到来得没那么快时,访问临界区应该像没有锁一样。Mark Word的预留了位置记录锁的状态,因此可以知道当前的锁是什么锁。

偏向锁

在程序的一开始,处于无锁状态。紧接着,有一个线程申请锁,此时通过CAS竞争锁(CAS保证了此竞争行为的原子性),获取锁成功,Mark Word 将标记为偏向锁。当同样的线程再次到来,发现是锁的持有者并且是偏向锁,直接进入临界区。

因此,偏向锁意味着,不会发生竞争条件,因为只有一个线程。

轻量级锁

随着程序的运行,有新的线程要进入临界区,通过CAS竞争锁失败。Mark Word立即将偏向锁标记锁为轻量级锁,因为已经发生了竞争条件。紧接着,会反复同通过CAS为线程获取锁,如果占有锁的线程在临界区待的时间很短,那么申请锁的线程将很快拿到锁。

因此,轻量级锁意味着,有竞争条件,但是大家能很快地被分配到锁。

重量级锁

当然,申请锁的线程并不总是能很快地获取到锁,与其反复地CAS重试而浪费CPU时间,不如直接将线程阻塞住。那么,在轻量级锁的情况下,如果有线程超过一定次数的重试还是获取不到锁,Mark Word立即将轻量级锁标记为重量级锁,此后所有获取不到锁的线程将被阻塞,需要Monitor的参与。

因此,重量级锁意味着,在有竞争条件的情况下,线程不能很快地被分配到锁。

  • Synchronized的锁只能膨胀,不能收缩。偏向锁和轻量锁为乐观锁,重量级锁为悲观锁。

  • Synchronized的好处在于,它的优化、锁申请释放、锁的分配都是自动的,开发者能快速地使用。

Lock

Synchronized虽然能完成大多的并发场景,但是却可能造成线程阻塞且时长不可知。“如果去餐厅吃饭,客满了我想离开而不是等待”,Synchronized就满足不了这样的场景。并且,有时候我们想控制锁的分配过程,更甚地,我们喜欢VIP通道,希望让一些线程更优先地获取到锁。

Lock也就有了它的舞台:

public interface Lock {

    void lock(); // 获取锁,获取不到会被阻塞

    void lockInterruptibly() throws InterruptedException; // 获取锁,可被中断,获取不到会被阻塞

    boolean tryLock(); // 获取锁,无论结果如何不会被阻塞

    boolean tryLock(long time, TimeUnit unit) throws InterruptedException; // 获取锁,最多在unit时间内返回结果,可被中断

    void unlock(); // 释放锁

    Condition newCondition(); // 支持满足一定条件后,再去获取锁

}

Lock接口提供了一套实现一种锁,所应具有的方法语义,实现一种锁时,应当考虑如何满足Lock所表达的功能,并具备本身的特点。

一种Lock锁所应具有的特点为:

-   可以像Synchronized一样,获取不到就阻塞,以lock()表达语义

-   也可以在获取锁的过程,对中断进行响应,以lockInterruptibly()和tryLock()表达表达

-   还可以在获取不到锁时,自行抉择等待多久,然后做进一步打算,以tryLock()表达语义

-   并支持了一种条件锁,让线程等待时机,等一种事件达成,然后再去获取锁,看起来就如栅栏一样,以Condition表达语义

Lock与Synchronized最鲜明的对比为可中断,不强制阻塞,并表达了Synchronized所不支持的条件锁特性。

AQS基础

锁的处理分为了两部分,一部分为如何加解锁,另一部分为把锁分配给谁。在Synchronized时,这两部分都是透明的,只是以关键字进行了标记。而当要实现一种锁时,就不得不周全这两部分的内容,其中将有种种需要注意的细节。

为了将更多的精力放在“如何加解锁”上,以表达不同的锁的特性,Java抽象出了AQS(AbstractQueuedSynchronizer)来协助实现Lock。AQS解决了“将锁分配给谁”的问题。

以下,就为AQS的运行机制的概要。

1.  当申请锁,即调用了与acquire()类似语义的方法时,AQS将询问子类是否上锁成功,成功则继续运行。否则,AQS将以Node为粒度,记录这个申请锁的请求,将其插入自身维护的CLH队里中并挂起这个线程。
2.  在CLH队列中,只有最靠近头节点的未取消申请锁的节点,才有资格申请锁。
3.  当线程被唤醒时,会尝试获取锁,如果获取不到继续挂起;获取得到则继续运行。
4.  当一个线程释放锁,即调用release()类似语义的方法时,AQS将询问子类是否解锁成功,有锁可以分配,如果有,AQS从CLH队列中主动唤起合适的线程,过程为235.  如果需要等待条件满足再去申请锁,即调用了wait()类似语义的方法时,在AQS中表现为,以Node为粒度,维护一个单向等待条件队列,把Node所代表的线程挂起。
6.  当条件满足时,即调用了signal()类似语义的方法时,唤醒等待条件队列最前面的未取消等待的Node,执行17.  子类可以维护AQS的state属性来记录加解锁状态,AQS也提供了CAS的方法compareAndSetState()抢占更新state。

关键点在于,通过AQS申请锁的线程,都可通过CAS进行锁竞争,state表达分配了多少把锁,CAS能保证代表锁状态的state的原子性,那么,就可以在有必要的时候将线程挂起。当线程被唤醒时,再次参与锁竞争流程。从外部看,就如入口方法被阻塞住并在合适的未来被恢复了一样。

有了AQS,可以看其他锁,是如何实现Lock语义并具有哪些特性。

ReentranLock(可重入锁)

ReentranLock实现了Lock语义,并具AQS的特性,是悲观锁、独占锁、可重入锁,是否公平与是否可中断则取决于使用者。

ReentranLock以其内部类Sync继承AQS特性,在实例化时,可以通过参数决定是否公平。ReentranLock只允许一个线程持有锁,因此它是独占锁,其他申请锁的线程将因此而挂起等待。

ReentranLock的可重入性表现在,当锁被线程持有,AQS询问是否加锁成功时,Sync如果发现申请的线程与持有锁的线程是同一个,它将通过CAS更新state状态再次分配锁,并回复加锁成功。也就实现了重入。

是否公平体现在,在向AQS申请分配锁时,有一次询问是否加锁成功的机会,在此时是否忽略CLH队列中等待的线程,就代表了是否给予插队的机会。

ReentrantReadWriteLock(读写锁)

ReentrantReadWriteLock也实现了Lock语义,具备了AQS的特性,ReentrantReadWriteLock是可重入锁。

ReentrantReadWriteLock即是悲观锁,也是乐观锁;即是独占锁,也是共享锁。何出此言?

ReentrantReadWriteLock的应用场景,是针对于读操作远多于写操作的场景,以读锁和写锁共同协作。整体来看,ReentrantReadWriteLock锁具有的特性,就取决择于观察的时间段。

只有读锁

在一段时间里,如果只有读锁,那么ReentrantReadWriteLock是共享锁,是乐观锁。这是容易理解的,读操作并不会改变数据的状态,也就没有竞争条件,此时,大家都能获取到锁,通过临界区,CLH队列里没有线程在排队。

只有写锁

在一段时间里,如果只有写锁那么ReentrantReadWriteLock是悲观锁,是独占锁。在这种情况下ReentrantReadWriteLock表现得与ReentranLock一样。因为此时竞争条件激烈,只能让线程逐个通过临界区。

读写锁都有

在一段时间里,如果读写锁都有,那么ReentrantReadWriteLock是悲观锁。虽然读锁不会有竞争条件,但因会读到过期的数据,因此需要等写锁完成后才进行分配,大家都需要进入CLH队列排队。

值得注意的是,如果写锁前面有读锁没有释放,写锁就要进行等待,在读锁处理的过程中,数据也不应当过期,这样,就提供了一个时间窗口让读锁安心处理,也让写锁更具独占的意义。

可重入性与是否公平

是否公平与ReentranLock一样,借助AQS解决把锁分配给谁的实现类,都可通过在首次请求锁时,选择是否忽略CLH队列中的情况,实现是否插队。

在实现可重入性时,写锁因是独占的,可以直接通过state维护,而当是读锁,是分享锁时,就需要借助其他内容记录每一个线程的重入情况。ReentrantReadWriteLock就通过ThreadLocal在各个线程内部维护了类型为HoldCounter的对象记录此信息。

特别的,拥有都读锁的线程可以继续申请写锁,反之则不行。

Semaphore(信号量)

Semaphore的内部类Sync继承了AQS的特性,实现了除条件锁外的Lock语义(但没有直接声明implementation)。

Semaphore是具有不可重入的特性,特点为一次可申请多个锁,是所看到的锁方案中难见到的不支持重入的锁。

Semaphore的场景为,如何并发地占用有限的共享资源。比如餐位,如果没有餐位了,就不会接待新一批的客人。

Semaphore不支持重入的原因在于,因为资源的有限性,重入可能引起死锁。以一个极端的餐位例子举例:如果正在进食的客人,都要求申请更多的餐位,但此时已没有更多的餐位,那么,申请不到餐位引起等待,而等待的客人不愿完成进食放出餐位。

Semaphore公平与不公平的特性,也是取决于首次去向AQS申请锁时,是否考虑CLH队列的情况。

其他特性

除了以上的,锁应考虑具有的特性之外,还有其他的一些,锁所具有的独特特性,代表一种具体实现。

条件锁

条件锁意味着,等待条件达成的线程,在条件满足前,都将被挂起。当条件满足后,放过一些线程去申请锁,这使得条件锁很像栅栏。

Java提供了Condition作为条件锁的方法语义模板,以await()表达等待条件,以signal()表达条件达成信号。

借助AQS实现的条件锁亦是如此。其中维护了一个条件等待队列,所有await()的线程以Node的形式进入队列,并在signal()信号到来后,让某些Node进入到CLH队列。

自旋锁

自旋锁属于无锁状态,得益于CAS能保证单一变量的原子性,那么其他仅依赖单一变量的临界区就可以使用CAS加解锁。其操作为,通过不断循环地尝试CAS,直到成功,也称为自旋。

自旋锁基于一种假设,线程处于临界区足够短,通过不断地浪费CPU时间自旋至获取锁成功更有效率。因为在自旋锁的要针对的场景里,比起阻塞、唤起线程的上下文切换所引起的性能消耗,自旋浪费CPU时间的消耗反而更小。

分段锁

有时候,没必要把所有的共享资源都放在同一个位置,如同去银行办理业务,可以选择不同的柜台。这也是分段锁的意义:将共享资源存于不同的区域,细化锁的粒度,使得对一部分资源的竞争,不会影响到另一部分资源。

以ConccurrentHashMap在JDK7中的实现为例,就以Segment为类型的数据结构对数据分段,并且每个Segment是一个ReentrantLock。如此,不同的数据分布在不同的区域,相应的访问者到对应的位置进行竞争。

总结

  • 在考虑实现一种锁时,需要考虑悲观与乐观、独占与共享、公平与否、是否重入、是否可中断的特性,还可以进一步考虑是否支持条件锁的语义。

  • synchronized通过 偏向锁 -> 轻量级锁 -> 重量级锁 的锁膨胀过程提升了效率,因其为底层实现,将有更多的想象空间。

  • 实现Lock语义的锁,通过AQS解决了将锁分配给谁的问题,得以聚焦于自身的加解锁方式上,满足并形成了各种锁之间的不同特性。掌握了AQS之后,大可以借助其实现具有业务特性的锁。

当然,还会看到各式各样对于锁的称呼,那么就需要考虑这种锁的特性,是作为锁要考虑的共同特性之一,还是它仅有的独特特点。

在提升锁的效率的方案中,处处可以见到CAS的身影,借以说明了没有锁才是期望的锁。那么,在面对并发时,可以从 是否需要锁 -> 是否CAS可以解决 -> 是否可以不阻塞 -> 是否需要某种特性的阻塞锁,这样的选择路径寻找更合适的方案。