在并发编程中,锁是一种非常重要的机制,Java提供了种类丰富的锁,每种锁因其特性不同,在适当的场景下能够展现出非常高的效率,下面针对不同的特性,对锁和相关概念进行分类介绍。
在多线程中,多个线程抢资源时根据是否同步资源,分为悲观锁、乐观锁;假如是悲观锁,锁住资源后,没抢到锁的资源有的会直接阻塞,有的不会阻塞而是不断尝试去获取锁,这叫自旋锁,假如长时间不断自旋会占用CPU时间片,为了优化这缺点,退出了适应性自旋锁;多个线程抢锁时根据是否排队先来后到获取锁,分为公平锁、非公平锁;假如允许一个线程在获得锁后又多次重获得锁,则该锁成为可重入锁,否则为非可重入锁;假如允许多个线程共享同一把锁,则该锁为共享锁,否则为排他锁(或者叫互斥锁)。
sychronized
是Java中一种非常重要的加锁机制,它在使用过程中根据不同情况,有无锁、偏向锁、轻量级锁、重量级锁四种状态。
综上所述,总结如下:
一、锁消除与锁粗化
在程序运行中,如果只有一个线程反复抢锁释放锁,执行次数到了一定级别,JVM就会认为,不需要锁,触发锁消除的优化,如下代码所示:
public class LockElimination {
public void test(StringBuffer stringBuffer) {
//StringBuilder线程不安全,StringBuffer用了synchronized,是线程安全的
// jit 优化, 消除了锁
// 没有线程抢锁,单线程重复执行到一定次数触发JIT优化,将StringBuffer中的锁消除
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append("a");
stringBuffer.append("b");
stringBuffer.append("c");
stringBuffer.append("a");
stringBuffer.append("b");
stringBuffer.append("c");
stringBuffer.append("a");
stringBuffer.append("b");
stringBuffer.append("c");
// System.out.println(stringBuffer.toString());
}
public static void main(String[] args) throws InterruptedException {
StringBuffer stringBuffer = new StringBuffer();
for (int i = 0; i < 1000000; i++) {
new LockElimination().test(stringBuffer);
}
}
}
StringBuffer
是线程安全类,其 append
方法使用sychronized做同步,执行上面代码,会不断加锁解锁,加解锁是有性能开销的,如果只是单线程在执行这个程序,不加锁也能保证线程安全,所以JVM消除了锁,提供了执行效率。
锁粗化:是合并使用相同锁对象的相邻同步块的过程。如果编译器不能使用锁省略(Lock Elision)消除锁,那么可以使用锁粗化来减少开销,如下代码所示:
//锁粗化(运行时 jit 编译优化)
//jit 编译后的汇编内容, jitwatch可视化工具进行查看
public class LockCoarsening {
public void test() {
int i = 0;
synchronized (this) {
i++;
}
synchronized (this) {
i--;
}
synchronized (this) {
System.out.println("dfasdfad");
}
synchronized (this) {
i++;
}
synchronized (this) {
i++;
i--;
System.out.println("fsfdsdf....");
System.out.println("dfasdfad");
i++;
}
}
}
上面的代码在同个方法体中不断加锁解锁,如果每个同步代码快计算逻辑都比较简单不耗时,就会触发JVM的锁粗化,合并多个相邻的同步代码块,减少加解锁的次数,从而提高性能,代码有可能被优化成下面的形式:
synchronized (this) {
i++;
i--;
System.out.println("dfasdfad");
i++;
i++;
i--;
System.out.println("fsfdsdf....");
System.out.println("dfasdfad");
i++;
}
二、乐观锁 vs 悲观锁
在很多技术中,都有乐观锁和悲观锁的概念,体现了看待线程同步的不同角度。
对于同一个数据的并发操作,悲观锁认为自己在尝试获取资源的时候一定有别的线程来修改数据,因此在获取数据时先加锁,确保数据不会被别的线程修改,在Java中,sychronized
关键字和JDK API Lock
的实现类都是悲观锁。
而乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入;如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或自动重试)。
乐观锁在Java中是通过使用无锁编程来实现,最常使用的是CAS算法,Java的J.U.C包的原子类都是基于 Unsafe
类提供的通过CAS自旋实现的方法来实现的。
根据上面的概念描述可以发现:
- 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确
- 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升
// ------------------------------------------ 悲观锁的调用方式 ----------------------------------------------
// sychronized
public sychronized void testMethod() {
// 操作同步资源
}
// ReentrantLock
private ReentrantLock lock = new ReentrantLock(); // 需要保证多个线程使用同一个锁
public void modifyPublicResources() {
lock.lock();
// 操作同步资源
lock.unlock();
}
// ------------------------------------------ 乐观锁的调用方式 ----------------------------------------------
private AtomicInteger atomicInteger = new AtomicInteger(); // 需要保证多个线程使用同一个AtomicInteger
atomicInteger.incrementAndGet(); // 执行自增1
Java 中悲观锁基本上都是在显式地锁定之后再操作同步资源,加锁的方式有 sychronized
、Lock
,而乐观锁则直接去操作同步资源,乐观锁的实现方式主要是基于 CAS
原子操作。
CAS原理
CAS全称 Compare And Swap(比较和交换),是一种无锁算法,从字面上包含了两个操作,但通过调用底层硬件指令,保证了比较和交换作为一个整体的原子操作被执行。使用CAS能够实现在不加锁的情况下实现多线程之间的变量同步,JUC包的原子类就是基于CAS来实现的。
CAS算法涉及到三个操作数:
- 需要读写的内存值 V
- 进行比较的值 A
- 要写入的新值 B
当且仅当 V 的值等于 A 时,CAS 通过原子方式用新值 B 来更新 V 的值,否则不会执行任何操作。通常 “更新” 操作了失败了会不断重新尝试,是一个自旋的过程。
java.util.concurrent包中的原子类,就是通过CAS来实现了乐观锁,进入原子类AtomicInteger的源码,看一下AtomicInteger的定义:
各成员属性的作用如下:
- unsafe: 获取并操作内存的数据。
- valueOffset: 存储value在AtomicInteger中的偏移量。
- value: 存储AtomicInteger的int值,该属性需要借助volatile关键字保证其在线程间是可见的。
接下来,我们查看AtomicInteger的自增函数incrementAndGet()的源码时,发现自增函数底层调用的是unsafe.getAndAddInt()。但是由于JDK本身只有Unsafe.class,只通过class文件中的参数名,并不能很好的了解方法的作用,所以我们通过OpenJDK 8 来查看Unsafe的源码:
根据OpenJDK 8的源码我们可以看出,getAndAddInt()循环获取给定对象o中的偏移量处的值v,然后判断内存值是否等于v。如果相等则将内存值设置为 v + delta,否则返回false,继续循环进行重试,直到设置成功才能退出循环,并且将旧值返回。整个“比较+更新”操作封装在compareAndSwapInt()中,在JNI里是借助于一个CPU指令完成的,属于原子操作,可以保证多个线程都能够看到同一个变量的修改值。
后续JDK通过CPU的cmpxchg指令,去比较寄存器中的 A 和 内存中的值 V。如果相等,就把要写入的新值 B 存入内存中。如果不相等,就将内存值 V 赋值给寄存器中的值 A。然后通过Java代码中的while循环再次调用cmpxchg指令进行重试,直到设置成功为止。
CAS虽然很高效,但是它也存在三大问题,这里也简单说一下:
1、ABA问题
CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但实际上
是有变化的。通常解决思路是为变量增加版本号,每次写操作时版本号加1,这样变化过程就从 “A - B - A”
变成了 “1A - 2B - 3A”
, CAS操作时检查值和版本号,两者一致才成功。
JDK从1.5开始提供了 AtomicStampedReference
类来解决ABA问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。
2、循环时间长开销大
CAS操作如果长时间不成功,会导致其一直自旋,占用CPU时间片,给CPU带来非常大的开销。
3、只能保证一个共享变量的原子操作
对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。
Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
三、自旋锁 vs 适应性自旋锁
阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间。如果同步代码块中的内容过于简单,导致状态转换消耗的时间比用户代码执行的时间还长,这种情况下切换线程状态就非常不划算。
在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的下场呢很难过同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。
而为了让当前线程 “稍等以下”,我们需要让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销,这就是适用于锁定时间短的自旋锁。
自旋锁与非自旋锁
自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白白浪费处理器资源。所以自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用 -XX:PreBlockSpin
来更改)没有成功获得锁,就应当挂起线程。
自旋的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。
自旋锁在JDK1.4.2中引入,使用 -XX:+UseSpinning
来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。
自适应意味这自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持有相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
在自旋锁中 另有三种常见的锁形式:TicketLock
、CLHlock
和 MCSlock
。
四、sychronized锁机制:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
在Java 5.0之前,协调对共享对象的访问时可以采用的机制只有 sychronized
和 volatile
,sychronized
使用的是Java对象内置的监视器锁,由于内置锁会导致抢不到锁的线程进行阻塞状态,线程切换消耗大,所以性能不能让人满意,所以Java 5.0引入了新的机制:ReentrantLock
,其性能可以达到内置锁的数倍。到Java 6.0使用了改进的算法来管理内置锁,使得它能够根据不同锁竞争的激烈程度采取不同的策略处理,提高了可伸缩性,性能大幅提升到与 ReentrantLock
不相上下的水平。
为什么 sychronized 能实现线程同步?
sychronized
使用的是JVM内置的监视器对象作为锁,当一个线程访问同步代码块或同步方法时,需要先拿到锁才能执行同步代码,退出或抛出异常时释放锁,sychronized
的用法主要有几种:
- (1)普通同步方法,锁是当前实例对象
- (2)静态同步方法,锁是当前类的class对象
- (3)同步方法块,锁是括号里面的对象
// 普通同步方法
public sychronzied test() {
// ...
}
// 静态同步方法
public static sychronized test() {
// ...
}
// 同步代码块
public void test() {
sychronized (lock) {
// ...
}
}
sychronized
锁处理机制比较复杂,在完整描述该机制之前,先来了解两个重要的概念:Java对象头
和 monitor
。
1、Java对象头
synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的,那么什么是对象头?以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)
、Klass Pointer(类型指针)
。如果对象是数组类型,那么还会多一个Array Length(数组长度)
。
(1)Mark Word
用于存储对象自身的运行数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,一般占用一个机器码(机器码占多少位取决于虚拟机位数,32位的虚拟机中1个机器码为4个字节即32位)。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化,以占32位的机器码为例,Mark Word的存储结构如下图所示:
Mark Word数据结构
(2)Klass Pointer
对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
如果对象是普通对象类型,JVM可以通过Java对象的元数据信息确定Java对象的大小,对象头只需要两个机器码,一个存储 Mark Word
,另一个存储 Klass Pointer
;如果对象是数据,则对象头需要三个机器码的存储空间,因为JVM无法从数组的元数据来确认数组的大小,需要一个额外的机器码来记录数据长度 Array Length
,对象头在JVM内存中所处位置如图所示。
2、Monitor
Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都自带一把看不见的锁,成为内部锁或Monitor锁。
Monitor是线程私有的数据结构,每一个线程都有一个可用的 monitor record 列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个 monitor 关联(对象头中的 monitor address 指向monitor的起始位置),同时 monitor 中有一个 Owner 字段存放拥有该锁的线程的唯一标识,表示这个锁被这个线程占用。其数据结构如下:
Monitor数据结构
- Owner:初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁又被释放时又重置为NULL。
- EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住 monitor record 失败的线程,当一个抢锁的线程判断到Owner非空后就会进入阻塞队列中排队等待获得锁。
- RcThis:表示 blocked 或 waiting 在该monitor record上的所有线程的个数。
- Nest:sychronzied是可重入锁,用来实现重入锁的计数。
- HashCode:保存从对象头拷贝过来的HashCode(可能还包含GC age)。
-
Wait Set:当持有锁的线程在sychronized修饰的同步方法或同步代码块中调用对象继承自
Object
的wait()
方法时,Owner线程就会释放锁,并进入等待线程集合中,等待后面获得锁的线程通知。 -
Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值,0表示没有需要唤醒的线程,1表示要唤醒一个继任线程来竞争锁。
3、内置锁的四种状态
在Java6.0之前,由于内置的监视器锁处理复杂,阻塞或唤醒线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理时间性能底下,如果同步代码块中的内容比较简单,状态转换消耗的时间可能比用户代码执行的时间还要长。因此在Java6.0中引入了大量优化,包括上面提到的锁粗化、锁消除、自旋锁、适应性自旋锁,还有下面讲的偏向锁、轻量级锁等技术来减少锁操作的开销。
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。锁状态会随着竞争的激烈而逐渐升级,并且只能升级不能降级,这种策略是为了提高获得锁和释放锁的效率。
(1)无锁
无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
无锁的特点就是修改操作在循环内进行,线程会不断地尝试修改共享资源。如果没有冲突就修改成功并退出,否则继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。CAS原理和应用就是经典的无锁的实现,无锁无法全面代替有锁,但无锁在修改操作比较简单的情况下(比如原子地加1)性能很高。
(2)偏向锁
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
假如有一个线程每次在获取锁的时候都没有其他线程跟它竞争,这种场景适合使用偏向锁。当一个线程访问同步代码并获得偏向锁时,会在 Mark Word 里存储锁偏向的线程ID,在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。引入偏向锁的目的:为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。
线程获取偏向锁的流程如下:
- a、检测 Mark Word 是否为可偏向状态,即是否为偏向锁1,此时的锁标识为
01
; - b、若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤(e),否则执行步骤(c);
- c、如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将 Mark Word 中的线程ID替换为当前线程ID,否则执行步骤(d);
- d、通过CAS竞争锁失败,证明当前存在多线程竞争情况,当达到全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞的安全点的线程继续往下执行同步代码块;
- e、执行同步代码块
偏向锁的释放采用了一种只有竞争才会释放的机制,持有偏向锁的线程在同步代码块执行完毕之后不会主动释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点上是没有正在执行的代码),其步骤如下:
- a、暂停拥有偏向锁的线程,判断锁对象是否还处于被锁定状态(即当前线程是否在执行同步代码块);
- b、撤销偏向锁后,如果处于未锁定状态,则恢复到无锁状态(01),否则升级到轻量级锁状态(00)
(3)轻量级锁
引入轻量级锁的主要目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当偏向锁发生竞争时,偏向锁会升级为轻量级锁,这是一种乐观锁,没有获得锁的线程会通过自旋的方式尝试获取锁,而不会直接进入阻塞状态,从而提高性能。获取锁的步骤如下:
- a、判断当前对象是否处于无锁状态(hashcode、0、01),若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word),否则执行步骤(c);
- b、JVM利用CAS操作尝试将对象的 Mark Word 更新为指向Lock Record的指正,如果成功表示竞争到锁,
则将锁标志为变为00(表示此对象处于轻量级锁状态),执行同步操作,如果失败则执行步骤(c); - c、判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时线程会不断重复尝试获取锁,达到一定的次数轻量级锁会膨胀为重量级锁,锁标志位变为10,后面等待的线程将会进入阻塞状态
释放锁轻量级锁的释放也是通过CAS操作来进行的,步骤如下:
- a、取出在获取轻量级锁保存在Displaced Mark Word中的数据;
- b、用CAS操作将取出的数据替换对象头中的Mark Word,如果成功,则说明锁释放成功,否则执行(c);
- c、如果CAS操作替换失败,说明有其他线程尝试获取锁(
此时锁已经膨胀为重量级锁
),那就要在释放锁的同时,唤醒被挂起的线程。
对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。
轻量级锁的获取和释放过程(4)重量级锁
轻量级锁升级为重量级锁时,锁状态的标志值会变为 “10”,重量级锁的实现依赖于对象内部监视器(monitor),此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞队列(线程状态为Blocked),如果持有锁的线程在同步代码块中调用了 Object.wait()
方法,就会释放锁进入等待线程集合中(线程状态为Waiting),如下图所示:
线程想持有重量级锁,需要先判断Mark Word执行的Monitor对象中的Owner是否为空,如果为空,则可以尝试获取锁,将线程ID写入Owner中;否则线程会阻塞,并进入阻塞队列中排队等待获得锁。由于竞争的线程是先判断Owner再进入EntryQ,因此从这点看,重量级锁是一个非公平锁。monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。
sychronized
整体的锁状态升级如下:
综上,偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作;而轻量级锁通过用CAS加锁和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能;重量级锁是将除了拥有锁的线程以外的线程都阻塞。
【注意】sychronized 的锁升级到轻量级锁后不可逆,即只能升级不能降级。
五、公平锁 vs 非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。
【优点】等待锁的线程不会饿死。
【缺点】整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。
但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的情况。
【优点】减少唤醒线程的开销,整体吞吐率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。
【缺点】处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
举个例子说明以下公平锁和非公平锁,如下图所示,假设有一口水井,有管理员看守,管理员有一把锁,只有拿到锁的人才能够打水,打完水要把锁还给管理员。每个过来打水的人都要管理员允许并拿到锁之后才能去打水,如果前面有人正在打水,那么这个想要打水的人就必须先排队。管理员会查看下一个要去打水的人是不是队伍里排最前面的人,如果是的话,才会给他锁去打水;如果不是排第一的人,就必须去队尾排队,这就是公平锁。
但是对于非公平锁,管理员对打水的人没有要求。即使等待队伍里有排队等待的人,但如果在上一个人刚打完睡把锁还给管理员而且管理员还没有允许等待队伍里下一个人去打水时,刚好来了一个插队的人,这个插队的人是可以直接从管理员那里拿到锁去打水,不需要排队,原本排队等待的人只能继续等待,如下图所示:
非公平锁
ReentrantLock
实现了公平锁和非公平锁,其内部定义了一个继承了AQS(AbstractQueuedSychronizer)的类 Sync
,添加锁和释放锁的大部分操作实际上都是在Sycn中实现的。它有公平锁 FailSync
和非公平锁 NonfailSync
两个子类,ReentrantLock默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。
从下面公平锁和非公平锁实现的源码中可以看出,公平锁多了一步判断是否存在排队的前继者,如果不存在并且使用CAS操作抢锁成功,则将执行Owener线程指向当前线程,否则返回false表示尝试抢锁失败。 公平锁和非公平锁源码
hasQueuedPredecessors()
方法的源码如下:
t是指向队尾的指针,h是指向队头的指针,如果h = t,表示队列为空,没有需要排队等待获取锁的线程,返回false;如果队列非空,则判断队列中等待锁的第一个节点对应的线程是否为当前线程,如果不是返回true,如果是返回false。
综上,公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况。
六、可重入锁 vs 非可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock
和synchronized
都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。
在上面的代码中,
test1
、test2
两个方法都被sychronized
所修饰,在test1的方法体中调用了test2,如果sychronized不是可重入锁,会导致死锁,从执行结果可以看出,sychronized是可重入锁。为什么可重入锁可以在嵌套调用时自动获得锁?下面通过示例和源码分析。
还是打水的例子,有多个人在排队打水,此时管理员允许锁和同一个人的多个水桶绑定。这个人用多个水桶打水时,第一个水桶和锁绑定并打完水后,第二个水桶也可以直接和锁绑定并开始打水,所有的水桶都打完水之后打水人才会将锁还给管理员。这个人的所有打水流程都能成功执行,后续等待的人也能够打到水,这就是可重入锁。
但如果是非可重入锁,此时管理员只允许锁和同一个人的一个水桶绑定。第一个水桶和锁绑定打完水之后并不会释放锁,导致第二个水桶不能和锁绑定也无法打水。当前线程出现死锁,整个等待队列中的所有线程都无法被唤醒。
非可重入锁
之前我们说过ReentrantLock和synchronized都是重入锁,那么我们通过重入锁
ReentrantLock
以及非可重入锁NonReentrantLock
的源码来对比分析一下为什么非可重入锁在重复调用同步资源时会出现死锁。
首先ReentrantLock和NonReentrantLock都继承父类AQS,其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。
先来看看ReentrantLock
,当线程尝试获取锁时,先判断status是否等于0,如果为0则尝试CAS操作修改status值,如果成功表示获得了锁,否则失败;如果status不为0,则判断已获得该锁的线程是否为当前线程,如果是执行status+1,表示当前线程获取锁重入次数+1。
释放锁时,可重入锁同样先获取当前status的值,在当前线程是持有锁的线程的前提下。如果status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。
可重入锁释放锁
NonReentrantLock
(org.jboss.netty.util.internal)的获取释放锁的代码很简洁,尝试对status做CAS操作,将其值从0更新为1,如果更新成功表示获得了锁,将执行线程设置为当前线程,否则获得锁失败;释放锁时,先判断持有锁的线程是否当前线程,再将status的值更新为0,源码如下:非可重入锁获取和释放锁
七、独享锁 vs 共享锁
独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。
如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。
共享锁是指该锁可被多个线程所持有。
如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。
独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。ReentrantReadWriteLock
中持有了两把锁:ReadLock和WriteLock,由词知意,一个读锁一个写锁,合称“读写锁”。再进一步观察可以发现ReadLock和WriteLock是靠内部类Sync实现的锁。Sync是AQS的一个子类,这种结构在CountDownLatch、ReentrantLock、Semaphore里面也都存在。
在ReentrantReadWriteLock里面,读锁和写锁的锁主体都是Sync,但读锁和写锁的加锁方式不一样。读锁是共享锁,写锁是独享锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以ReentrantReadWriteLock的并发性相比一般的互斥锁有了很大提升。
ReentrantReadWriteLock类内部定义的 ReadLock
、WriteLock
都实现了Lock接口,并内置了Sync成员,根据ReentrantReadWriteLock构造方法指定的公平或非公平策略,注入FairSync或NonfairSync对象,FairSync、NonfairSync继承了内部类Sync,Sync又继承了 AbstractQueuedSychronizer
,AQS内部有个status成员,是个int类型有32位,state变量“按位切割”切分成了两个部分,高16位表示读锁状态(读锁个数),低16位表示写锁状态(写锁个数)。
获取写锁时,会调用 tryAcquire
方法,源码如下:
-
c是当前锁的个数,w是获取到的写锁的个数(通过位运算对后16位做与运算获取)
-
如果c不为0,且w为0,证明当前存在读锁;如果w不为0,但是持有写锁的线程不是当前线程,无法获得锁,这两种情况下获取写锁都失败
-
如果当前有写锁且持有锁的线程是当前线程,则可以增加锁的可重入数,但是由于存储写锁次数的空间为16位(最大存储2的16次方-1即65535),所以如果重入数大于该数字,则抛出一个Error;否则可以正常获取锁成功
-
如果c为0,说明既没有写锁也没有读锁,需要先判断线程是否需要阻塞,如果是非公平锁,不需要阻塞,直接尝试CAS操作增加写线程次数,成功则获取锁成功;如果是公平锁,在并发条件下虽然当前还没有线程拥有写锁,但是所有争抢锁的线程抢锁之前都要先进入队列排队,能否抢到锁,取决于是否在队列中排第一位,如果是才能进行CAS操作增加写线程次数
在写锁的实现中,跟读锁实现了互斥,如果存在读锁,则写锁不能被获取,原因在于:必须确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。
因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态为0时表示写锁已被释放,然后等待的读写线程才能够继续访问读写锁,同时前次写线程的修改对后续的读写线程可见。
获取读锁时,会调用 tryAcquireShared
方法,源码如下:
可以看到在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增加读状态,成功获取读锁,读锁每次都是增加“1<<16”。读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是“1<<16”。所以读写锁才能实现读读的过程共享,而读写、写读、写写的过程互斥。
回头看一下互斥锁ReentrantLock中公平锁和非公平锁的加锁源码:
公平锁和非公平锁源码
不管是公平锁还是非公平锁,添加的都是独享锁。根据源码所示,当某一个线程调用lock方法获取锁时,如果同步资源没有被其他线程锁住,那么当前线程在使用CAS更新state成功后就会成功抢占该资源。而如果公共资源被占用且不是被当前线程占用,那么就会加锁失败。所以可以确定ReentrantLock无论读操作还是写操作,添加的锁都是都是独享锁。
[参考文献]
网友评论