我所理解的Java锁

为什么需要锁

并发带来的混乱.png

当并发地访问共享资源,如果不加以管理,其结果是混乱的。就如上图,双方均想改变方块的颜色,但在任意时刻,我们并不能确定方块接下来是什么颜色,因为我们无法确定谁的时间片最终改变了方块的颜色。显然,这样的不确定性对于程序来说是不能接受的。我们所期望的,是红色方先过后,方块成了红色。接着绿色方过后,方块成了绿色,或相反的顺序。

那么,多方将对方块进行修改的代码范围,称为临界区,对于要进入临界区的并进行修改的行为,称为竞争条件。为了消除竞争条件所带来的影响,使程序具有确定性,我们需要对共享资源加锁,以保证任意时刻只有一个线程进行访问

解决并发的混乱.png

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

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

锁的特性

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

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

  • 悲观锁与乐观锁:悲观锁意味着,觉得自已在使用共享资源的时候,其他线程将会修改数据,因此为了避免篡改,将其他要进入临界区的线程阻塞住,直到完成使用。乐观锁与之相反,认为自己在使用共享资源的时候,其他线程不会修改数据,因此,乐观锁只进行锁竞争,而不会将其他线程阻塞住。
  • 独占锁与共享锁:取决于是否允许多个线程同时拿到锁,访问共享资源。如果能预料到接下来的时间不会发生写操作,无疑允许多方访问更能提高并发效率。虽然大家都进入了临界区,但是没有产生竞争条件,毕竟,读操作不会改变任何数据。
  • 公平锁与不公平锁:区分于是否按照申请锁的顺序,为线程分配锁,即是否允许插队。如果允许插队,将可能使一些线程饥饿,迟迟不能分配到锁继续运行下去;如果不允许插队,那么一些需要紧急处理的线程任务,则被延迟处理,毕竟生活中,我们也需要VIP通道。
  • 可重入锁与不可重入锁:区分获取锁的线程,还没释放锁之前,能否重复地获取锁。如果一个递归方法需要上锁,而当这个锁是不可重入锁时,是无法递归的。以生活例子举例,能理解为加塞行为是否允许。当在餐厅进食中,发现需要加菜,餐厅是允许这样的加塞行为,也是合意的。可如果你挂了号看医生,在诊断完后,你对医生说“帮我朋友也看一下呗”,你看医生理不理你,如此,这样的加塞行为是不允许的。
  • 可中断锁与不可中断锁:是否允许在迟迟申请不到锁,或线程发生中断时能进行响应。可中断锁赋予了线程决定自己等待锁的时间,以及对中断的响应。

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

CAS(Compare and Set)

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

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

CAS过程.png

CAS接受三个参数,内存地址,期望中的内存值,要更新的内存值。首先,通过内存地址找到数据的存储位置;接着,比较内存地址的值,是否与自己预期的一样;如果不一样,返回false;如果一样,设置新的值并返回true。整个过程中,CPU将锁住内存总线,这样在指令结束前,其他CPU无法访问此内存,而保证了指令操作的原子性。

当然,CAS会有ABA问题,即当数据经历了从 A 改变为 B 再改变 A的过程后,对于一些调用方来说,A已经不是预期的A。可以通过增加版本号,将 A->B->A 变成了 1A->2B->3A,以此得以区分。Java中ABA的解决方式则以AtomicStampedReference来支持。

CAS的特性,使得它可以保证单个变量操作的原子性。

Synchronized的实现

首当其冲地,Synchronized是再熟悉不过的并发手段。Synchronized具有独占、不公平、可重入、不可中断的特性。它作用于对方法、代码块、对象。

Synchronized属于一种管程,Synchronized的实现对于使用者是完全透明的。它符合了管程所具有的特点:以语言特性提供标记方式,经编辑器进行翻译识别,由语言实现保证原子性。简单地说Synchronized是由语言实现级别进行的保证——同一时间只有一个线程能访问标记范围。

// 类锁
public static synchronized void A(){ index = 1; }

// 类锁
public static void B(){
synchronized (SData.class){index = 2;}
}

public void C(){
// 对象锁
synchronized (this){index = 3;}
}

// // 对象锁
public synchronized void D(){
index = 4;
}

public void E(){
// 对象锁
synchronized (lock){index = 5;}
}

public void F(){
// 类锁
synchronized (SData.class){index = 6;}
}

以上是Synchronized的所有可能的使用情况,可将其分为类锁对象锁,其中ABF属于前者,CDE属于后者。

在将编译好的.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状态的线程集合

}

获取Synchronized锁.jpg

  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 将标记为偏向锁。当同样的线程再次到来,发现是锁的持有者并且是偏向锁,直接进入临界区。

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

轻量级锁

随着程序的运行,有新的线程要进入临界区,通过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)

AQS运行概要.png

  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队列里没有线程在排队。

只有写锁

只有写锁.png

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

读写锁都有

读写锁都有.png

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

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

可重入性与是否公平

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

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

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

具体实现原理可见:ReentrantReadWriteLock

Semaphore(信号量)

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

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

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

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

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

具体实现可参考:Semaphore

其他特性

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

条件锁

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

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

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

自旋锁

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

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

分段锁

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

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

总结

文章以为什么需要锁开始,陈述了synchronizedLock的实现,窥探了Java中锁存在的形式。

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

synchronized通过 偏向锁 -> 轻量级锁 -> 重量级锁 的锁膨胀过程提升了效率,因其为底层实现,将有更多的想象空间。
实现Lock语义的锁,通过AQS解决了将锁分配给谁的问题,得以聚焦于自身的加解锁方式上,满足并形成了各种锁之间的不同特性。掌握了AQS之后,大可以借助其实现具有业务特性的锁。
当然,还会看到各式各样对于锁的称呼,那么就需要考虑这种锁的特性,是作为锁要考虑的共同特性之一,还是它仅有的独特特点。
在提升锁的效率的方案中,处处可以见到CAS的身影,借以说明了没有锁才是期望的锁。那么,在面对并发时,可以从 是否需要锁 -> 是否CAS可以解决 -> 是否可以不阻塞 -> 是否需要某种特性的阻塞锁,这样的选择路径寻找更合适的方案。
以上,错误之处,不吝赐教。
参考

Synchronized原理
java CAS原理
JAVA 中的 CAS

最后

小编这些年深知大多数初中级工程师,想要提升自己,往往是自己摸索成长,自己不成体系的自学效果低效漫长且无助。

因此我收集整理了一份《2024年Java全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你需要这些资料,⬅专栏获取
多数初中级工程师,想要提升自己,往往是自己摸索成长,自己不成体系的自学效果低效漫长且无助。**

因此我收集整理了一份《2024年Java全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

[外链图片转存中…(img-FJfIIwRH-1719687531018)]

[外链图片转存中…(img-LGraLbjv-1719687531018)]

[外链图片转存中…(img-36OkRpYX-1719687531019)]

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你需要这些资料,⬅专栏获取

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值