写点什么

我所理解的 Java 锁

  • 2021 年 11 月 12 日
  • 本文字数:5247 字

    阅读完需:约 17 分钟

在将编译好的.class 文件,通过命令


javap -verbose class 路径


进行解析之后,可以看到个方法被翻译后的样子。被 synchronized 修饰的方法,会在方法的标志位进行标记:


flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED


其中的 ACC_SYNCHRONIZED 就代表这是一个同步方法,进入此方法需要拿到锁。


而锁住一段代码块的,就会有如下的一段指令行


// x,y,z 代表数字...x: monitorenter...y: monitorexit...z: monitorexit...


其中,monitorenter 代表执行之后的指令需要拿到锁,进入同步代码块,monitorexit 代表退出了同步代码块,释放了锁。而之所以有两个 monitorexit,是因为需要有异常出口,避免同步代码块里的代发发生了异常而没有释放锁,而导致其他线程一直阻塞。


而最终的实现,需进一步了解。

Synchronized 在运行时如何实现

在 Java 中,一切皆对象,一种类型,也可以表示为 Class 类型的一个对象。对象在虚拟机中的存储布局可以分为三个区域:对象头(Header)、实例数据(Instance Data)、对齐补充(Padding)。


Header 就存储了业务无关的信息,分成 Mark Word 和 Class MetaData Address(类型指针),如果对象是数组类型,还会有 Array length 表示数组长度。


通过 Synchronized 上锁,即是对对象上了锁,某一具体类型则作为 Class 类型的对象,因此区分了对象锁和类锁。


Mark Word 包含了信息如:哈希码(HashCode)、GC 分代年龄、锁状态标识、持有锁的线程、偏向线程 ID、偏向时间戳等等


因此,一个锁谁持有,通过 Mark Word 就可以知道。那么,如何使用这些信息呢?


Java 将每一个对象都与一个 Monitor 进行关联,可有多种实现,可以伴随对象一起创建销毁,或在线程试图获取锁时生成。意味着每一个对象天生就可以成为一把锁,受 Monitor 监视。其主要数据结构可见 ObjectMonitor.hpp


ObjectMonitor() {..._WaitSet = NULL; //处于 wait 状态的线程集合_owner // 拿到锁的线程_EntryList = NULL ; //处于等待锁 block 状态的线程集合...}



  1. 当一个线程申请锁时,进入_EntryListd 集合等待,然后参与锁竞争

  2. 当线程获取到锁时,_Owner 就标记了获得锁的线程

  3. 如果获得锁的线程调用了 wait()方法,则进入_WaitSet 集合,同时释放锁,并等待被唤醒

  4. 当_WaitSet 的线程被唤醒时,重新参与所竞争


由此,就能看出 Synchronized,依赖于 Mark Word 的使用方式以及 Monitor 的具体实现。

Synchronized 的优化

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


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


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


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

偏向锁

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


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

轻量级锁

随着程


【一线大厂Java面试题解析+后端开发学习笔记+最新架构讲解视频+实战项目源码讲义】
浏览器打开:qq.cn.hn/FTf 免费领取
复制代码


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


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

重量级锁

当然,申请锁的线程并不总是能很快地获取到锁,与其反复地 CAS 重试而浪费 CPU 时间,不如直接将线程阻塞住。那么,在轻量级锁的情况下,如果有线程超过一定次数的重试还是获取不到锁,Mard Work 立即将轻量级锁标记为重量级锁,此后所有获取不到锁的线程将被阻塞,需要 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 的运行机制的概要,更具体的可以参考:一文了解AQS(AbstractQueuedSynchronizer)



  1. 当申请锁,即调用了与 acquire()类似语义的方法时,AQS 将询问子类是否上锁成功,成功则继续运行。否则,AQS 将以 Node 为粒度,记录这个申请锁的请求,将其插入自身维护的 CLH 队里中并挂起这个线程。

  2. 在 CLH 队列中,只有最靠近头节点的未取消申请锁的节点,才有资格申请锁。

  3. 当线程被唤醒时,会尝试获取锁,如果获取不到继续挂起;获取得到则继续运行。

  4. 当一个线程释放锁,即调用 release()类似语义的方法时,AQS 将询问子类是否解锁成功,有锁可以分配,如果有,AQS 从 CLH 队列中主动唤起合适的线程,过程为 2、3。

  5. 如果需要等待条件满足再去申请锁,即调用了 wait()类似语义的方法时,在 AQS 中表现为,以 Node 为粒度,维护一个单向等待条件队列,把 Node 所代表的线程挂起。

  6. 当条件满足时,即调用了 signal()类似语义的方法时,唤醒等待条件队列最前面的未取消等待的 Node,执行 1。

  7. 子类可以维护 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 队列中等待的线程,就代表了是否给予插队的机会。


具体的实现原理可见:ReentranLock

ReentrantReadWriteLock(读写锁)

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


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


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

只有读锁

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

只有写锁


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

读写锁都有


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


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

可重入性与是否公平

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


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


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


具体实现原理可见:ReentrantReadWriteLock

Semaphore(信号量)

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


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


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



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


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


具体实现可参考:Semaphore

其他特性

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

条件锁

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



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


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

自旋锁

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


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

分段锁

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


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

总结

评论

发布
暂无评论
我所理解的Java锁