从零开始学Java_第四篇 聊聊锁机制
今天我们来聊聊 Java 中的锁机制。相信大家对于锁这个概念并不陌生,Java 中实现同步的方式主要有两种,一种是使用关键字 syncronized 修饰方法或代码块,另一种是使用 Lock 方法实现线程同步。 这两种方式的实现原理、实现区别在文中我们都会涉及。
Lock 中常用的是 ReenTrantLock (可重入锁),所以本文以 ReenTrantLock 作为例,来简要分析下 Lock 与 synchronize 两种方式的实现原理。
-
ReenTrantLock
在高竞争条件下有更好的性能,控制锁粒度更精细,可中断。ReentrantLock 是基于AQS 实现的。
AQS(AbstractQueueSynchronizer)是基于 FIFO 的队列实现,或是构建锁同步容器的基础框架。对于队列的各种操作在 AQS 中都已经实现了,一般子类只用重写 tryAcquire 获取对象状态(参数:int )和tryRelease释放(参数:int)方法。
ReenTrantLock 可以通过构造方法创建公平锁和非公平锁,因为公平锁会额外消耗资源,所以如果没有特殊情况都是使用的非公平锁。
如果线程 a 调用了 ReenTrantLock 的 lock() 方法,那么线程 a 将独占锁,整个调用链十分简单:
NonFairSync.lock() -----> AbstractQueuedSynchronizer.compreAndSetState(0,1) -----> AbstractOwnableSynchronizer.setExclusiveOwnerThread(Thread.currentThread)
第一个获取锁的线程做两件事:
1 设置 AbstractQueuedSynchronizer的 state 为1 (同步状态,0表示未锁)
2 设置 AbstractOwnableSynchronizer的 thread 为当前线程 (AbstractOwnableSynchronizer表示独占模式的当前拥有者)
精简代码如下:
final void lock(){
if(compareAndSetState(0,1)){
setExclusiveOwnerThread(Thread.currentThread());
}else{
acquire(1);
}
}
当第二个线程想要获取锁时,会执行 else,调用 acquire 方法,进而调用 acquire 中的 tryAcquire 方法,若 tryAquire 返回失败,则将其加入等待队列。
-
synchronized
synchronized 修饰的代码块在编译的时候会在该代码块开始和结束的时候插入 moniterenter 和 moniterexit 。任何对象都有 monitor 与之关联,当一个 monitor 被持有后,就处于锁定状态。在执行 enter 指令时首先要尝试获取对象锁。如果这个对象没被锁定,或当前线程已经有这个对象的锁,则锁的计数器加 1,相应的在执行 exit 时计数器减 1,当计数器为 0 时,释放锁。
重量级锁,是 JDK1.6 之前,内置锁的实现方式。重量级锁就是采用互斥量来控制对互斥资源的访问。然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)如果每次都调用Mutex Lock那么将严重的影响程序的性能。所以在 JDK1.6 之后对锁进行了优化。
优化后锁主要存在四中状态,依次是:
-
无锁状态
-
偏向锁状态
-
轻量级锁状态
-
重量级锁状态
还引进了一些锁优化策略如锁粗化,锁消除,轻量锁,偏向锁,自旋锁等。
-
锁粗化
将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
如 vector 每次 add 的时候都需要加锁操作,JVM 检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到 for 循环之外。
-
锁消除
在有些情况下,JVM 检测到不可能存在共享数据竞争,这是 JVM 会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。
在使用一些 JDK 的内置 API 时,如 StringBuffer、Vector、HashTable 等,这个时候会存在隐形的加锁操作。
在运行这段代码时,JVM 可以明显检测到变量 vector 没有逃逸出方法 vectorTest() 之外,所以 JVM 可以大胆地将 vector 内部的加锁操作消除。
-
轻量锁
理解轻量锁必须从 HotSpot 虚拟机对象头的内存布局来介绍,HotSpot 虚拟机的对象头由两部分组成。
第一部分是存储对象自身运行时的数据,如 hash 码,GC 分带年龄,锁标记等官方成为 Mark Word,第二个部分是指向到对象的 Class的元数据信息。如下图:
-
在代码进入同步块时,如果同步对象没有被锁定,虚拟机首先将在当前线程的栈帧中建立一个名为锁标记(Lock Record)的空间
-
虚拟机将使用 CAS 操作尝试将对象头中的 MarkWord 更新为指向当前线程的 Lock Record 指针,如果更新成功了,那么这个线程就拥有了这个对象的锁,并将 Mark Word 中锁标记位改成 00,表示对象处于轻量级锁状态。
-
如果更新状态失败了,虚拟机将会检查对象中的 Mark Word 是否指向当前线程的栈帧。
-
如果是则直接进入代码块执行。
-
如果不是说明有线程竞争。
-
如果有两个以上的线程在抢占资源,那轻量级锁就不再有效,要膨胀为重量锁,所状态更改为 10;
-
Mark Word 中存储的就是指向重量级锁的指针,后面等待的锁就要进入阻塞状态。
大致流程图如下:
轻量级锁性能提升的依据是 “对于绝大多数的锁”在整个同步周期中都是不存在竞争的,这是一个经验依据。如果没有竞争,轻量锁使用 CAS 操作避免使用系统互斥量的开销。
-
偏向锁
轻量锁的引入是为了提升在没有现车竞争的情况下,执行同步代码块的效率。那么还有一种特殊情况就是始终只有一个线程在执行同步块。在这种情况下,即使使用轻量锁也需要多个 CAS 操作的。
当开启了偏向锁功能,当代码进入同步块的时候,虚拟机会检查当前线程是否处于无锁状态(01),是否没有锁标记位(锁标记位是0)。
如果线程处于无锁状态并且没有锁标记,JVM 利用 CAS 操作,把获取到这个对象锁的线程 ID 记录在对象的 Mark Word 中。这样,当线程下次再次进入同步块的时候不需要进行任何获取锁的操作,即可访问互斥资源。节约了频繁获取锁和释放锁的开销。
偏向锁在获取锁之后,直到有竞争出现才会释放锁。也就是说,如果长期没有竞争,偏向锁是一直持有锁的。当另一个线程获取该对象的锁时,偏向锁模式就会宣告结束。
-
自旋锁
轻量锁竞争锁的失败的线程,并不会真实的在操作系统层面挂起等待,而是 JVM 会让线程做几个空循环(基于预测在不久的将来就能获得),在经过若干次循环后,如果可以获得锁,那么进入临界区,如果还不能获得锁,才会真实的将线程在操作系统层面进行挂起。。
为什么用自旋锁?
线程的阻塞和唤醒需要 CPU 从用户态转为核心态,频繁的阻塞和唤醒对 CPU 来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。
同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。
所以引入自旋锁。
自旋锁可以减少线程的阻塞,适用于锁竞争不激烈,且占用锁时间非常短的代码块来说,有较大的性能提升,因为自旋的消耗会小于线程阻塞挂起操作的消耗。
如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用 CPU 做无用功,线程自旋的消耗大于线程阻塞挂起操作的消耗,造成 CPU 的浪费。
在单核 CPU 上,自旋锁是无用,因为当自旋锁尝试获取锁不成功会一直尝试,这会一直占用 CPU,其他线程不可能运行,同时由于其他线程无法运行,所以当前线程无法释放锁。
扩展知识:
-
CAS 原理是什么 ? 为什么 CAS 操作会延迟本地调用?
这要从SMP(对称多处理器)架构说起,下图大概表明了SMP的结构:
其意思是所有的 CPU 会共享一条系统总线(BUS),靠此总线连接主存。每个核都有自己的一级缓存,各核相对于 BUS 对称分布,因此这种结构称为“对称多处理器”。
而 CAS的 全称为 Compare-And-Swap,是一条 CPU 的原子指令,其作用是让 CPU 比较后原子地更新某个位置的值,经过调查发现,其实现方式是基于硬件平台的汇编指令,就是说 CAS 是靠硬件实现的,JVM 只是封装了汇编调用,那些 AtomicInteger 类便是使用了这些封装后的接口。
Core1 和Core2 可能会同时把主存中某个位置的值 Load 到自己的 L1 Cache 中,当 Core1 在自己的 L1 Cache 中修改这个位置的值时,会通过总线,使 Core2 中 L1 Cache 对应的值“失效”,而 Core2 一旦发现自己 L1 Cache 中的值失效(称为 Cache 命中缺失)则会通过总线从内存中加载该地址最新的值,大家通过总线的来回通信称为“Cache一致性流量”,因为总线被设计为固定的“通信能力”,如果 Cache 一致性流量过大,总线将成为瓶颈。而当 Core1 和 Core2 中的值再次一致时,称为“Cache 一致性”,从这个层面来说,锁设计的终极目标便是减少 Cache 一致性流量。
而 CAS 恰好会导致 Cache 一致性流量,如果有很多线程都共享同一个对象,当某个 Core CAS 成功时必然会引起总线风暴,这就是所谓的本地延迟,本质上偏向锁就是为了消除 CAS ,降低 Cache 一致性流量。
-
volatile 与 syncronized
volatile 本质是在告诉 JVM 当前变量在寄存器中的值是不确定的,需要从主存中读取。只能修饰变量,实现变量的修改可见性,但不能具备原子性,不会造成线程阻塞。
synchronize 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞,可以保证修改可见性和原子性。
深入了解 volatile 之前,需要先了解 JVM 在运行时内存的分配过程。
JVM 中有一个内存区域是 JVM 虚拟机栈,每一个线程运行时都有一个线程栈,线程栈保存了线程运行时的变量值信息。
当线程访问某一个对象时候值的时:
-
首先通过对象的引用找到对应在堆内存的变量的值;
-
然后把堆内存变量的具体值 load 到线程本地内存中,建立一个变量副本;
-
之后线程就不再和对象在堆内存变量值有任何关系,而是直接修改副本变量的值;
-
在修改完之后的某一个时刻(线程退出之前),自动把线程变量副本的值回写到对象在堆中变量。这样在堆中的对象的值就产生变化了。
如下图所示:
以上就是 JVM 在运行时内存分配过程,我们可以看出如果变量不加 volatile 修饰,线程修改的其实是自己线程栈中的副本值,改来改去都与主存中的值无关,只有最后在线程退出前的某一时刻才把线程栈中的值写回到主存中。
而 volatile 关键字的意义就在于线程在每次使用变量的时候,都会从主存中读取,所以读取到的值都是变量修改后的值,当其他线程来读时也是从主存中读取,这样就保证了变量的可见性。
当且仅当满足以下条件市,才应该使用 volatile 变量:
-
对变量的写入操作不依赖变量的当前值,或者你能保证只有单个线程更新变量的值
-
该变量不会与其他状态变量一起纳入不变性条件中
-
在访问变量时不需要加锁
参考资料:
https://www.cnblogs.com/daxin/p/3364014.html
---- volatile关键字作用
https://www.open-open.com/lib/view/open1352431526366.html
---- JVM底层是如何实现synchronized的
https://blog.csdn.net/hsuxu/article/details/9472389
---- Java轻量级锁原理详解
关注一下,我写的就更来劲儿啦