一、同步机制基础
??线程安全问题的产生前提是多个线程并发访问共享数据。因此,有种保障线程安全的方法:将多个线程对共享数据的并发访问转换为串行访问,即一个共享数据一次只能被一个线程访问,该线程访问结束后其他线程才能对其访问,锁 就是利用这种思路保证线程安全的线程同步机制。 ??一个线程在访问共享数据前必须申请相应的锁,一个锁一次只能被一个线程持有。一个线程获得某个锁,就称该线程为相应锁的持有线程 ,锁的持有线程可以对该锁所保护的共享数据进行访问,访问结束后该线程必须释放相应的锁。 ??锁的持有线程在其获得锁之后和释放锁之前这段时间内所执行的代码被称为临界区 。因此,共享数据值允许在临界区内进行访问,临界区一次只能被一个线程执行。图示: ??锁具有排他性,即一个锁一次只能被一个线程持有,这种锁就叫作排他锁 或互斥锁 。这种锁的实现方式代表了锁的基本原理,读写锁是排他锁的改进实现方式。 ??按照锁的实现方式划分,锁包括隐式锁和显式锁。内部锁是通过synchronized关键字实现的;显式锁是通过Lock的实现类实现的 。
1.1 锁的作用
??锁能够保护共享数据以实现线程安全,其作用包括保障原子性、保障可见性和保障有序性。
- 1、保障原子性
??锁是通过互斥保障原子性的,互斥指一个锁只能被一个线程持有,这保证了临界区代码一次只能被一个线程执行,使得临界区代码锁执行的操作自然具有不可分割的特性,即具备了原子性。 ??从互斥的角度看,锁其实是将多个线程对共享数据的访问由本来的并发改成串行 。 - 2、保障可见性
??可见性的保障是通过写线程重刷处理器缓存和读线程刷新处理器缓存这两个动作实现的。其实,锁的获得隐含着刷新处理器缓存这个动作 ,这使得读线程在执行临界区代码之前(获得锁之后)可以将写线程对共享变量所做的更新同步到该线程执行处理器的高速缓存中;而锁的释放隐含着冲刷处理器缓存这个动作 ,这使得写线程对共享变量所做的更新能够被“推送”到该线程执行处理器的高速缓存中,从而对读线程同步。因此,锁能够保证可见性。 - 3、保障有序性
??锁能够保障有序性。写线程在临界区中所执行的一系列操作在读线程所执行的临界区看起来像是完全按照源代码顺序执行的,即读线程对这些写线程操作的感知顺序和源代码顺序一致,这是锁对原子性和可见性保障的结果。
??锁保障可见性、原子性和有序性的前提:
- 这些线程在访问同一组共享数据的时候,必须使用同一个锁。
- 这些线程中的任意一个线程,即使其仅仅是读取这组共享数据,而没有对其进行更新的话,也需要在读取时持有相应的锁。
1.2 可重入性
??可重入性 :一个线程在其持有一个锁的时候,能否再次(或者多次)申请该锁 。如果一个线程持有一个锁的时候还能继续成功申请该锁,那么就称该锁是可重入的,否则就是非可重入的。
??可重入锁可以被理解为一个对象,该对象包含一个计数器属性。计数器属性的初始值是0,表示相应的锁还没有被任何线程持有。每次线程获得一个可重入锁的时候,该锁的计数器值会+1.每次一个线程释放锁的时候,该锁的计数器属性值会-1。
??Java中锁的调度策略也包括公平策略和非公平策略,相应的锁被称为公平锁和非公平锁。 ??隐式锁属于非公平锁,显式锁既支持公平锁也支持非公平锁 。 ??一个锁可以保护一个或多个共享数据,一个锁所保护的共享数据的数量大小称为该锁的粒度 。一个锁保护的共享数据量大,就称该锁的粒度粗,否则就称该锁的粒度细。 ??锁的开销包括锁的申请和释放锁产生的开销,以及锁可能导致的上下文切换的开销,这些开销主要是处理器时间。同时,锁的不正确使用也会导致一些线程活性故障:
- 锁泄漏。锁泄漏是指一个线程某个锁后,由于程序的错误、缺陷导致该锁一直无法被释放而导致其他线程一直无法获得该锁的现象。
- 锁的不正确使用还可能导致死锁、锁死线程等活性故障。
二、同步关键字之synchronized
2.1 synchronized 的作用
??在 Java 中,synchronized 关键字是用来控制线程同步的,就是在多线程的环境下,控制 synchronized 代码段不被多个线程同时执行,以达到保证并发安全的效果 。 ??synchronized关键字实现的锁称为内部锁,内部锁是一种排他锁,能够保证原子性、可见性和有序性。 之所以被称为内部锁,是因为线程对内部锁的申请与释放的动作都是由Java虚拟机负责实现的,开发者看不到这个锁的获取和释放过程。
??在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,Java的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。 ??在JDK1.6之后,JVM 层面对synchronizedj进行了较大优化,所以现在的 synchronized 锁效率也不错。具体而言,JDK1.6对锁的实现引入了大量的优化,如:自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术,大大减少了锁操作的开销。
??synchronized的缺点:
效率低 :锁的释放情况少,试图获得锁时不能设定超时,不能中断一个正在试图获得锁的线程;不够灵活(读写锁更灵活) :加锁和释放的时机单一,每个锁仅有单一的条件(某个对象),可能是不够的`;无法知道是否成功获取到锁 。
2.2 synchronized的使用位置
??synchronized可使用在代码块和方法中。
2.2.1 同步方法
??synchronized关键字修饰的方法称为同步方法,synchronized修饰的静态方法被称为同步静态方法,synchronized修饰实例方法被称为同步实例方法。 ??代码示例:
public synchronized void method(){
}
public static synchronized void method(){
}
2.2.2 同步代码块
??当synchronized关键字用到代码块上时,被锁的对象可以是类的实例对象、类对象或任意的对象。 ??锁住该类实例对象的代码示例:
synchronized(this){
}
??如果锁的是类对象的话,尽管new多个实例对象,但他们仍然是属于同一个类依然会被锁住,即线程之间保证同步关系。
synchronized(CurrentClass.class){
}
??作为锁的变量通常用final修饰 ,因为锁变量的值一旦改变,会导致执行同一个同步块的多个线程实际上使用不同的锁。 ??尽量不要使用synchronized(String a) 。因为在Java内存模型中的字符串常量池具有缓存功能,这意味着不相关的线程可能会去竞争同一个锁。
final Object lock = new Object();
synchronized(lock){
}
2.2.3 同步方法和同步块的选择
??在进行两者的选择之前,先看下两者的区别:
同步方法默认用this或者当前类class对象作为锁 ,同步方法使用关键字 synchronized修饰方法,而同步代码块主要是修饰需要进行同步的代码,用 synchronized(object){代码内容}进行修饰;同步代码块比同步方法粒度更细 ,可以选择只同步会发生同步问题的部分代码而不是整个方法。
??同步块是更好的选择,因为它不会锁住整个对象(当然你也可以让它锁住整个对象) 。同步方法会锁住整个对象,哪怕这个类中有多个不相关联的同步块,这通常会导致他们停止执行并需要等待获得这个对象上的锁。 ??记住一条原则:同步的范围越小越好 。
2.3 synchronized的底层实现原理
2.3.1 monitorenter/monitorexit
??在使用synchronized的过程中并没有看到显式的加锁和解锁过程。此时可以使用javap 命令,查看字节码文件,来了解synchronized加锁和解锁的过程。 ??看一个简单的例子:
public class SynchronizedDemo {
public void method() {
synchronized(this) {
System.out.println("synchronized 代码块");
}
}
}
??用javap -v SynchronizedDemo.class 命令可以查看该class文件的内容,主要关注一下method方法相关的字节码,图示: ??可以看出在执行同步代码块之前之后都有一个monitor字样,这意味着:一个线程也执行同步代码块,首先要获取锁,而获取锁的过程就是monitorenter ,在执行完代码块之后,要释放锁,释放锁就是执行monitorexit指令 。 ??为什么会有两个monitorexit呢?主要是防止在同步代码块中线程因异常退出,而锁没有得到释放,这必然会造成死锁 。因此最后一个monitorexit是保证在异常情况下,锁也可以得到释放,避免死锁。 ??ACC_SYNCHRONIZED 标志表明线程执行该方法时,需要monitorenter,退出该方法时需要monitorexit。
2.3.2 synchronized可重入原理
??synchronized可重入的原理 :重入锁是指一个线程获取到该锁之后,该线程可以继续获得该锁。底层原理维护一个计数器,当线程获取该锁时,计数器加一,再次获得该锁时继续加一,释放锁时,计数器减一,当计数器值为0时,表明该锁未被任何线程所持有,其它线程可以竞争获取锁。 ??synchronized可重入在代码中的具体意义是:在一个synchronized方法中可以调用同一个锁的另一个synchronized方法 ,示例:
synchronized void m1() {
System.out.println("m1 start");
m2();
System.out.println("m1 end");
}
synchronized void m2() {
System.out.println("m2");
}
- 当一个线程进入一个对象的synchronized方法A之后,其它线程是否可进入此对象的synchronized方法B?
??不能。其它线程只能访问该对象的非同步方法,同步方法则不能进入。因为非静态方法上的 synchronized 修饰符要求执行方法时要获得对象的锁,如果已经进入A 方法说明对象锁已经被取走,那么试图进入 B 方法的线程就只能在等锁池(注意不是等待池)中等待对象的锁。
2.4 CAS和自旋锁
??此处介绍CAS和自旋锁 ,是为了接下来介绍synchronized锁升级做铺垫。
2.4.1 CAS是什么
??CAS(compare and swap),即比较交换。 ??CAS是一种基于乐观锁的操作。在Java中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问 。乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源 ,比如通过给记录加 version 来获取数据,性能较悲观锁有很大的提高。 ??CAS 要解决的问题就是保证原子操作 。 ??CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和 A 的值是一样的,那么就将内存里面的值更新成 B。CAS是通过无限循环来获取数据的,若果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。示例: ??JUC包下的类大多是使用 CAS 操作来实现的,如:AtomicInteger、AtomicBoolean、AtomicLong等。
2.4.2 CAS的适用场景
CAS 适合简单对象的操作 ,比如布尔值、整型值等;CAS 适合线程冲突较少的情况 ,如果太多线程在同时自旋,会导致 CPU 开销很大。
2.4.3 自旋锁是什么
??自旋锁:是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。 ??CAS 是实现自旋锁的基础 ,CAS 利用CPU指令保证了操作的原子性,以达到锁的效果。
2.4.4 自旋锁的问题和优点
??自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
- 如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。因此,
自旋锁使用不当会造成CPU使用率极高 。 - 如果自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。
不公平的锁会存在“线程饥饿”问题 。
- 自旋锁的优点
??自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是活跃的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快。
2.4.5 CAS会产生什么问题
- 1、ABA 问题
??比如说一个线程threadOne从内存地址V中取出地址中的值A,这时候另一个线程threadTwo也从该内存中取出值A,并且threadTwo进行了一些操作将地址V中的值变成了B,然后threadTwo又将地址V的数据变成A【即由于threadTwo的操作,导致地址中的值经历了一个A -> B -> A 的变化过程】。 ??这时候线程threadOne进行CAS操作发现内存中仍然是A,然后threadOne操作成功。尽管线程threadOne的 CAS 操作成功,但可能存在潜藏的问题。 ??从JDK1.5开始,JDK 的 atomic包里提供了一个类 AtomicStampedReference,该类是解决 ABA 问题的方法之一。 ??此处简单说下AtomicStampedReference,AtomicStampedReference它内部不仅维护了对象值,还维护了一个时间戳 (时间戳,实际上它可以使任何一个整数,它使用整数来表示状态值)。当AtomicStampedReference对应的数值被修改时,除了更新数据本身外,还必须要更新时间戳 。当AtomicStampedReference设置对象值时,对象值以及时间戳都必须满足期望值,写入才会成功。因此,即使对象值被反复读写,写回原值,只要时间戳发生变化,就能防止不恰当的写入。 - 2、循环时间长开销大
??对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源 ,效率低于 synchronized。 - 3、只能保证一个共享变量的原子操作
??当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁。
2.4 synchronized锁升级
??上个小节介绍了CAS,要了解synchronized锁升级的原理,还得了解Java对象头 。
2.4.1 Java对象头
??在同步的时候是获取对象的monitor,即获取到对象的锁。对象的锁信息就存储在Java对象的对象头中。 ??Java对象头里的Mark Word里默认的存放的对象的hashcode、对象分代年龄和锁标志位等信息。32位虚拟机中Mark Word默认存储结构为:
锁状态 | 25bit | 4bit | 1bit 是否是偏向锁 | 2bit 锁标识位 |
---|
无锁状态 | 对象的hashcode | 对象分代年龄 | 0 | 01 |
??JDK1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态-->偏向锁状态-->轻量级锁状态-->重量级锁状态,这几个状态会随着竞争情况逐渐升级 。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。 ??锁升级过程中,对象的MarkWord变化为:
2.4.2 无锁
??没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功,其他修改失败的线程会不断重试直到修改成功。
2.4.3 偏向锁
??大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得。因此,为了让线程获得锁的代价更低,引入了偏向锁。 ??偏向锁,指的就是偏向第一个加锁线程,该线程是不会主动释放偏向锁的,只有当其他线程尝试竞争偏向锁才会被释放 。
- 1、偏向锁的获取
??当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。 - 2、偏向锁的撤销和升级
??当线程1访问代码块并获取锁对象 时,会在Java对象头和栈帧中记录偏向的锁的threadID ,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程要竞争锁对象,而偏向锁不会主动释放,因此还是存储的线程1的threadID)。 ??此时,需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程可以竞争将其设置为偏向锁 ;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。 - 3、偏向锁的取消
??偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0 。 ??如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false ,那么程序默认会进入轻量级锁状态。
2.4.4 轻量级锁
- 1、偏向锁升级为轻量级锁
??轻量级锁是指当锁是偏向锁的时候,被第二个线程B访问,此时偏向锁就会升级为轻量级锁,线程B会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能。 - 2、轻量级锁的升级
??线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间,然后使用CAS把对象头中的内容替换为线程1存储的锁记录的地址; ??如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。 ??但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁 。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。
2.4.5 重量级锁
??一个线程获取重量级锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。 ??重量级锁通过对象内部的监视器(monitor)实现,而其中 monitor 的本质是依赖于底层操作系统的 Mutex Lock 实现,操作系统实现线程之间的切换需要从用户态切换到内核态,切换成本非常高。 ??synchronized 锁升级原理:
在锁对象的对象头里面有一个 threadid 字段,线程在第一次访问的时候 threadid 为空,jvm 让其持有偏向锁,并将 threadid 设置为其线程 id 【此时为偏向锁】;线程尝试再次获取锁的时候,会先判断 threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁 【此时为轻量级锁】;线程通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁 【此时为重量级锁】。
??当JVM检测到线程间不同的竞争状态时,就会根据需要自动切换到合适的锁,这种切换就是锁的升级。升级是不可逆的,也就是说只能从低到高,也就是偏向-->轻量级-->重量级,不能降级 。 ??锁的升级的目的是:减低锁带来的性能消耗。锁升级是在JDK1.6版本上实现的。 ??不同锁的对比:
| 偏向锁 | 轻量级锁 | 重量级锁 |
---|
适用场景 | 只有一个线程进入同步块 | 多个线程进入同步块,但是线程进入同步块时间错开而未争抢锁 | 多个线程进入同步块并争用锁 | 本质 | 取消同步操作 | CAS操作代替互斥同步 | 互斥同步 | 优点 | 不阻塞,执行效率高(只有第一次获取偏向锁时需要CAS操作,后面只是对比threadid) | 不会阻塞 | 不会空耗CPU | 缺点 | 适用场景局限,若竞争产生,会有额外的偏向锁撤销的消耗 | 长时间获取不到锁,会空耗CPU | 阻塞,上下文切换,消耗系统资源 |
三、同步关键字之volatile
??volatile可用于修饰共享可变变量,即没有用final修饰的实例变量或静态变量。 ??volatile关键字常被称为轻量级锁,其作用于锁的作用有相同的地方:保证可见性和有序性。不同的是,在原子性方面,它仅能保证写volatile变量操作的原子性,但没有锁的排他性;其次volatile关键字的使用不会引起上下文切换(这也正是volatile被称为轻量级的原因)。因此,volatile更像是一个轻量级简易(功能比锁有限)锁。 ??volatile的作用包括:保障可见性、保障有序性和保证long/double型变量写操作的原子性 。
3.1 保证内存可见性
??内存可见性(Memory Visibility):所有线程都能看到共享内存的最新状态 。 ??volatile关键字修饰的变量看到的随时是自己的最新值。线程1中对变量v的最新修改,对线程2是可见的。示例代码:
public class T01_HelloVolatile {
boolean running = true;
void m() {
System.out.println("m start");
while(running) {
}
System.out.println("m end!");
}
public static void main(String[] args) {
T01_HelloVolatile t = new T01_HelloVolatile();
new Thread(t::m, "t1").start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
t.running = false;
}
}
??volatile关键字在可见性方面仅仅是保证读线程能够读取到共享变量的相对新值。对于引用型变量和数组变量,volatile关键字并不能保证读线程能够读取到相应对象的字段(实例变量、静态变量)、元素的相对新值。
3.2 禁止指令重排序
??volatile在保障有序性方面,也可以从禁止重排序角度理解,即volatile禁止了如下重排序:
- 写volatile变量操作与该操作之前的任何读、写操作不会被重排序。
- 读volatile变量操作与该操作之后的任何读、写操作不会被重排序。
??如果volatile修饰的变量是个数组,那么volatile关键字只能对数组引用本身的操作(读取数组引用和更新数组引用)起作用,而无法对数组元组的操作(读、更新数组元素)起作用。 ??如果要使对数组元素的读、写操作也能起到volatile关键字的作用,可以使用AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray。
3.3 保证long/double型变量写操作的原子性
??用volatile修饰long/double型变量,可以保证其写操作的原子性。
3.4 volatile禁止指令重排序实现原理
??JVM级别的内存屏障 分为四类:
屏障类型 | 指令示例 | 说明 |
---|
LoadLoad Barriers | Load1;LoadLoad;Load2 | 确保Load1的数据的装载先于Load2及所有后续装载指令的装载 | StoreStoreBarriers | Store1;StoreStore;Store2 | 确保Store1数据对其他处理器可见(刷新到内存)先于Store2及所有后续存储指令的存储 | LoadStore Barriers | Load1;LoadStore;Store2 | 确保Load1的数据的装载先于Store2及所有后续存储指令的存储 | StoreLoad Barriers | Store1;StoreLoad;Load2 | 确保Store1的数据对其他处理器可见(刷新到内存)先于Load2及所有后续的装载指令的装载 |
??Java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM会针对编译器制定volatile重排序规则表: ??"NO"表示禁止重排序。为了实现volatile内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。JMM采取的策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障;
- 在每个volatile写操作的后面插入一个StoreLoad屏障;
- 在每个volatile读操作的后面插入一个LoadLoad屏障;
- 在每个volatile读操作的后面插入一个LoadStore屏障。
??volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障。
- 1、volatile写屏障
?StoreStore屏障:禁止上面的普通写和下面的volatile写重排序; ?StoreLoad屏障:防止上面的volatile写与下面可能有的volatile读/写重排序。 - 2、volatile读屏障
?LoadLoad屏障:禁止下面所有的普通读操作和上面的volatile读重排序; ?LoadStore屏障:禁止下面所有的普通写操作和上面的volatile读重排序。
3.5 volatile的应用场景
??volatile除了用于保障long/double型变量的读、写操作的原子性,其典型使用场景还包括以下几个:
- 1、使用volatile变量作为状态标志
- 2、使用volatile保障可见性
??该场景中,多个线程共享一个可变状态变量,其中一个线程更新了该变量之后,其他线程在无须加锁的情况下也能看到该变量的更新。 - 3、使用volatile变量代替锁
??volatile关键字并非锁的替代品,volatile关键字和锁各自有其适用条件。前者更适合于多个线程共享一个状态变量(对象),而后者更适合于多个线程共享一组状态变量。 - 4、使用volatile实现简易版读写锁
??volatile 常用于多线程环境下的单次操作(单次读或者单次写)。 ??在该场景中,读写锁是通过混合使用锁和volatile变量来实现的,其中锁用于保障共享变量写操作的原子性,volatile变量用于保障共享变量的可见性。因此,与ReentrantReadWriteLock所实现的读写锁不通过的,这种简易版读写锁仅涉及一个共享变量并且允许一个线程读取这个共享变量时其他线程可以更新该变量(这是因为读线程没有加锁)。因此,这种读写锁允许读线程可以读取到共享变量的非最新值。该场景的一个典型例子是计数器。示例代码:
public class Counter{
private volatile long count;
public long value(){
return count;
}
public void increment(){
synchronized(this){
count++;
}
}
}
- 5、和 CAS 结合使用
??可以参考JUC包下的类,比如 AtomicInteger。 - 6、单例模式
??单例模式的双重校验锁也用到了volatile。 ??在双重校验锁中,如果不加volatile,会可能发生:对象未初始化完全的时候,就赋值给变量了。加了volatile后,可以保证对象初始化完全后,再赋值给变量。
3.6 Java 中能创建volatile数组吗?
??能。Java 中可以创建 volatile 类型数组,不过volatile修饰的只是一个指向数组的引用,而不是整个数组。
四、同步关键字之final
4.1 final变量
- 1、final成员变量
??通常每个类中的成员变量可以分为静态变量(类变量)以及实例变量。这两种类型的变量赋初值的时机是不同的,类变量的赋值时机:
- 声明变量的时候直接赋初值。
- 在静态代码块中给类变量赋初值。
??实例变量的赋值时机:
- 声明变量的时候给实例变量赋初值。
- 非静态初始化块中赋初值。
- 构造器中赋初值。
??总的来说,类变量有两个时机赋初值,而实例变量则可以有三个时机赋初值。当final变量未初始化时系统不会进行隐式初始化,会出现报错。看个例子: ??图片已经整理了每种情况,显示红色波浪线的地方可以在IDE里看详细的报错信息。
- 2、final局部变量
??final局部变量同样由开发进行显式赋值,如果final局部变量已经进行了初始化则后面就不能再次进行更改,如果final变量未进行初始化,可以进行赋值,当且仅有一次赋值,一旦赋值之后再次赋值就会出错。示例: - 3、final基本数据类型 VS final引用数据类型
??当final修饰基本数据类型变量时,不能对基本数据类型变量重新赋值,因此基本数据类型变量不能被改变 。 ??对于引用类型变量而言,它仅仅保存的是一个引用,final只保证这个引用类型变量所引用的地址不会发生改变,即一直引用这个对象,但这个对象属性是可以改变的 。 - 4、宏变量
??在满足一下三个条件时,该变量就会成为一个“宏变量”,即是一个常量:
- 使用final修饰;
- 在定义该final变量时就指定了初始值;
- 该初始值在编译时就能够唯一指定。
??当程序中其他地方使用该宏变量的地方,编译器会直接替换成该变量的值。
4.2 final方法
- 父类的final方法是不能够被子类重写的。
- final方法是可以被重载的。示例::
public class FinalExampleParent {
public final void test() {
}
public final void test(String str) {
}
}
4.3 final类
??当一个类被final修饰时,表明该类是不能被子类继承的。
4.4 final域重排序规则
??先看final域为基本类型的情况,示例:
public class FinalDemo {
private int a;
private final int b;
private static FinalDemo finalDemo;
public FinalDemo() {
a = 1;
b = 2;
}
public static void writer() {
finalDemo = new FinalDemo();
}
public static void reader() {
FinalDemo demo = finalDemo;
int a = demo.a;
int b = demo.b;
}
}
??假设线程A在执行writer()方法,线程B执行reader()方法。
- 1、写final域重排序规则
??写final域的重排序规则禁止对final域的写重排序到构造函数之外,这个规则的实现主要包含了两个方面:
- JMM禁止编译器把final域的写重排序到构造函数之外;
- 编译器会在final域写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障可以禁止处理器把final域的写重排序到构造函数之外。
??writer方法,做了两件事情:
- 构造了一个FinalDemo对象;
- 把这个对象赋值给成员变量finalDemo。
??下面是一种可能的程序执行时序图: ??由于a,b之间没有数据依赖性,普通域(普通变量)a可能会被重排序到构造函数之外,线程B就有可能读到的是普通变量a初始化之前的值(零值),这样就可能出现错误。而final域变量b,根据重排序规则,会禁止final修饰的变量b重排序到构造函数之外,从而b能够正确赋值,线程B就能够读到final变量初始化后的值。 ??写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域就不具有这个保障 。比如在上例,线程B有可能就是一个未正确初始化的对象finalDemo。
- 2、读final域重排序规则
??读final域重排序规则为:在一个线程中,初次读对象引用和初次读该对象包含的final域,JMM会禁止这两个操作的重排序 。处理器会在读final域操作的前面插入一个LoadLoad屏障。实际上,读对象的引用和读该对象的final域存在间接依赖性,一般处理器不会重排序这两个操作。 ??read()方法主要包含了三个操作:
- 初次读引用变量finalDemo;
- 初次读引用变量finalDemo的普通域a;
- 初次读引用变量finalDemo的final与b;
??假设线程A写过程没有重排序,那么线程A和线程B有一种的可能执行时序为下图: ??读对象的普通域被重排序到了读对象引用的前面就会出现线程B还未读到对象引用就在读取该对象的普通域变量,这显然是错误的操作。而final域的读操作就“限定”了在读final域变量前已经读到了该对象的引用,从而就可以避免这种情况。 ??读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读这个包含这个final域的对象的引用 。
- 3、对final修饰的对象的成员域写操作
??针对引用数据类型,final域写针对编译器和处理器重排序增加了这样的约束:在构造函数内对一个final修饰的对象的成员域的写入,与随后在构造函数之外把这个被构造的对象的引用赋给一个引用变量,这两个操作是不能被重排序的。注意这里的是“增加”也就说前面对final基本数据类型的重排序规则在这里还是使用。示例:
public class FinalReferenceDemo {
final int[] arrays;
private FinalReferenceDemo finalReferenceDemo;
public FinalReferenceDemo() {
arrays = new int[1];
arrays[0] = 1;
}
public void writerOne() {
finalReferenceDemo = new FinalReferenceDemo();
}
public void writerTwo() {
arrays[0] = 2;
}
public void reader() {
if (finalReferenceDemo != null) {
int temp = finalReferenceDemo.arrays[0];
}
}
}
??针对上面的代码,线程线程A执行wirterOne方法,执行完后线程B执行writerTwo方法,然后线程C执行reader方法。下图就以这种执行时序出现的一种情况来讨论: ??由于对final域的写禁止重排序到构造方法外,因此1和3不能被重排序。由于一个final域的引用对象的成员域写入不能与随后将这个被构造出来的对象赋给引用变量重排序,因此2和3不能重排序。
- 4、对final修饰的对象的成员域读操作
??JMM可以确保线程C至少能看到写线程A对final引用的对象的成员域的写入,即能看下arrays[0] = 1,而写线程B对数组元素的写入可能看到可能看不到。JMM不保证线程B的写入对线程C可见,线程B和线程C之间存在数据竞争,此时的结果是不可预知的。如果可见的,可使用锁或者volatile。 - 5、final重排序的总结
??基本数据类型:
- final域写:禁止final域写与构造方法重排序,即禁止final域写重排序到构造方法之外,从而保证该对象对所有线程可见时,该对象的final域全部已经初始化过。
- final域读:禁止初次读对象的引用与读该对象包含的final域的重排序。
??引用数据类型:
??额外增加约束:禁止在构造函数对一个final修饰的对象的成员域的写入与随后将这个被构造的对象的引用赋值给引用变量重排序。
4.5 为什么final引用不能从构造函数中“溢出”
??上面对final域写重排序规则可以确保我们在使用一个对象引用的时候该对象的final域已经在构造函数被初始化过了。但是这里其实是有一个前提条件的,也就是:在构造函数,不能让这个被构造的对象被其他线程可见,也就是说该对象引用不能在构造函数中“溢出”。以下面的例子来说:
public class FinalReferenceEscapeDemo {
private final int a;
private FinalReferenceEscapeDemo referenceDemo;
public FinalReferenceEscapeDemo() {
a = 1;
referenceDemo = this;
}
public void writer() {
new FinalReferenceEscapeDemo();
}
public void reader() {
if (referenceDemo != null) {
int temp = referenceDemo.a;
}
}
}
??可能的执行时序如图所示: ??假设一个线程A执行writer方法另一个线程执行reader方法。因为构造函数中操作1和2之间没有数据依赖性,1和2可以重排序,先执行了2,这个时候引用对象referenceDemo是个没有完全初始化的对象,而当线程B去读取该对象时就会出错。尽管依然满足了final域写重排序规则:在引用对象对所有线程可见时,其final域已经完全初始化成功。但是,引用对象“this”溢出,该代码依然存在线程安全的问题。
五、对比synchronized和volatile
5.1 synchronized 和 volatile 的区别是什么
??synchronized表示只有一个线程可以获取作用对象的锁,执行临界区代码,阻塞其他线程。 ??volatile表示变量在CPU的寄存器中是不确定的,必须从主存中读取。常见的作用是:保证多线程环境下变量的可见性和禁止指令重排序。 ??两者的区别:
- volatile只能修饰变量;synchronized可以修饰类、方法、变量。
volatile仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量的修改可见性和原子性 。volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞 。volatile标记的变量能够保证变量前后代码的有序性;synchronized标记的变量不能保证 。- volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。
??synchronized关键字在JDK1.6进行了锁升级优化,执行效率有了显著提升,实际开发中还是使用synchronized的场景更多一些。
5.2 从原子性/有序性/可见性比较synchronized和volatile
- 1、原子性
??synchronized可以保证原子性 ,因为同一时刻只有一个线程能执行synchronized修饰的代码。 ??关于volatile是否能保证原子性,看个例子:
private static volatile int counter = 0;
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 1000; i++)
counter++;
}
});
thread.start();
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(counter);
}
??这段代码的意思很简单:开启10个线程,每个线程都自增1000次,如果不出现线程安全的问题最终的结果应该就是:10*1000 = 10000。但事实是每次的运行结果都小于10000。 ??上篇文章已经说过,自增操作并不是原子性的。因此,在该例子中,一个线程A对变量进行了自增操作后,另一个线程B可能读取到的是自增前的值,这就会造成最后的结果小于10000。 ??通过这个例子,可以看出volatile并不能保证原子性 。
- 2、有序性
??synchronized能够保证同一时刻只有一个线程执行临界区代码,synchronized语义就要求线程在访问读写共享变量时只能“串行”执行,因此synchronized能够保证有序性 。 ??volatile可以通过禁止指令重排序的方式,保证有序性 。 - 3、可见性
??synchronized和volatile都可以保证可见性 。
六、同步工具Lock
??在并发编程中,JUC包下有很多类可以直接使用,JUC目录结构图: ??JUC目录下的这些同步工具类的实现主要是依赖于volatile以及CAS ,从整体上这些类的实现逻辑:
6.1 Lock API及使用
??在Lock接口出现之前,主要是靠synchronized关键字实现锁功能。在JDK1.5之后,出现了Lock接口,Lock也能实现锁功能。相比synchronized,Lock的灵活性更好。 ??Lock接口中声明的API:
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
Condition newCondition();
unlock();
??在实现Lock接口的类中,ReentrantLock可能是最常见的实现类:
public class ReentrantLock implements Lock, java.io.Serializable
??了解了这些内容后,就可以看一下Lock的常用方式:
Lock lock = new ReentrantLock();
lock.lock();
try {
.......
} finally {
lock.unlock();
}
??Lock API使用总结:
创建Lock接口的实例 。如没有特别的要求,可以使用实现类ReentrantLock的实例作为显式锁使用,这是一个可重入锁;在访问共享数据前申请相应的显式锁 。在临界区中访问共享数据,一般将上面的try代码作为临界区 。共享数据访问结束后释放锁,为了避免锁泄漏,在finally块中释放锁 。
6.2 Lock相比synchronized的优势
??synchronized同步块执行完成或者遇到异常是锁会自动释放,Lock必须调用unlock()方法释放锁,常在finally块中释放锁 。 ??和synchronized相比,Lock的优势有:
- 可以使用
公平锁策略 ; - 获取锁的过程中
可以响应中断 ; - 可以让线程尝试获取锁,并在无法获取锁的时候立即返回或者等待一段时间(
超时等待 ); - 可以在不同的范围,以不同的顺序获取和释放锁。
??整体上来说 Lock 是 synchronized 的扩展版,Lock 提供了无条件的、可轮询的(tryLock 方法)、定时的(tryLock 带参方法)、可中断的(lockInterruptibly)、可多条件队列的(newCondition 方法)锁操作。同时,Lock 的实现类基本都支持非公平锁(默认)和公平锁,synchronized 只支持非公平锁。当然,在大部分情况下,非公平锁是高效的选择。
七、 AQS
??在上一小节,提到了Lock的常用实现类是ReentrantLock,接下来就看下ReentrantLock的实现。在ReentrantLock源码中,很多方法都试调用sync 变量来实现的,该变量的声明:
private final Sync sync;
??这个Sync是ReentrantLock的静态内部类,其继承关系为:
abstract static class Sync extends AbstractQueuedSynchronizer
??看到这里就找到了我们想要了解的主角:队列同步器AbstractQueuedSynchronizer(AQS,简称同步器) 。 ??同步器主要维护了volatile int state (代表共享资源 / 状态)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)。
7.1 AQS中的设计模式
- 1、模板方法模式
??AQS的设计是使用模板方法模式 。AQS是一个抽象类,它将一些方法开放给子类进行重写,而AQS中的模板方法又会调用被子类所重写的方法。举个例子,AQS中需要重写的方法tryAcquire: ??AQS中的tryAcquire()方法:
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
??ReentrantLock中NonfairSync(继承AQS)会重写该方法为:
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
??AQS中的模板方法acquire():
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
??继承AQS的NonfairSync调用模板方法acquire时,就会调用已经被NonfairSync重写的tryAcquire方法。
方法 | 作用 |
---|
protected boolean tryAcquire(int arg) | 独占式获取同步状态,实现该方法需要查询当前状态并判断同步状态是否符合预期,然后再进行CAS设置同步状态 | protected boolean tryRelease(int arg) | 独占式释放同步状态,等待获取同步状态的线程有机会获得同步状态 | protected int tryAcquireShared(int arg) | 共享式获取同步状态,返回大于等于0的值,表示获取成功,反之获取失败 | protected boolean tryReleaseShared(int arg) | 共享式释放同步状态 | protected boolean isHeldExclusively() | 当前同步器是否在独占模式下被线程占用,一般该方法表示是否被当前线程所独占 |
- 3、AQS提供的模板方法
??在同步组件的实现中,AQS是核心部分,同步组件的实现者通过使用AQS提供的模板方法实现同步组件语义,AQS则实现了对同步状态的管理,以及对阻塞线程进行排队,等待通知等等一些底层的实现处理 。
方法 | 作用 |
---|
public final void acquire(int arg) | 独占式获取同步状态,如果当前线程获取同步状态成功,则由该方法返回,否则,将进入同步队列等待,该方法将会调用AQS子类重写的tryAcquire(int arg)方法 | public final void acquireInterruptibly(int arg) | 与acquire(int arg)相似,但是该方法能响应中断,当前线程未获取到同步状态而进入同步队列中。如果当前线程被中断,则该方法会抛出InterruptedException | public final boolean tryAcquireNanos(int arg, long nanosTimeout) | 在acquireInterruptibly(int arg) 基础上增加了超时限制,如果当前线程在超时时间内没有获取到同步状态,那么将返回false,获取到了返回true | public final void acquireShared(int arg) | 共享式地获取同步状态,如果当前线程未获取到同步状态,将会进入同步队列等待,与独占式获取的主要区别是在同一时刻可以有多个线程获取到同步状态 | public final void acquireSharedInterruptibly(int arg) | 与acquireShared(int arg)相似,该方法可以响应中断 | public final boolean tryAcquireSharedNanos(int arg, long nanosTimeout) | 在acquireSharedInterruptibly(int arg)基础上增加了超时限制 | public final boolean release(int arg) | 独占式地释放同步状态,该方法会在释放同步状态之后,将同步队列中的第一个节点包含的线程唤醒 | public final boolean releaseShared(int arg) | 共享式地释放同步状态 | public final Collection< Thread > getQueuedThreads() | 获取等待在同步队列上的线程集合 |
??AQS提供的模板方法可以分为3类:
独占式获取与释放同步状态 ;共享式获取与释放同步状态 ;查询同步队列中等待线程情况 。
??AQS提供出来的模板方法,可以按独占式锁和共享式锁分类。
- 独占式锁模板方法
void acquire(int arg);
void acquireInterruptibly(int arg);
boolean tryAcquireNanos(int arg, long nanosTimeout);
boolean release(int arg);
- 共享式锁模板方法
void acquireShared(int arg);
void acquireSharedInterruptibly(int arg);
boolean tryAcquireSharedNanos(int arg, long nanosTimeout);
boolean releaseShared(int arg);
- 4、AQS和同步组件的关系
??AQS的子类,常常被定义为自定义同步组件的静态内部类(如ReentrantLock中的Sync,及Sync的子类NonfairSync和FairSync),同步器(AQS)自身没有实现任何同步接口,它仅仅是定义了若干同步状态的获取和释放方法来供自定义同步组件的使用,同步器既支持独占式获取同步状态,也可以支持共享式获取同步状态,这样就可以方便的实现不同类型的同步组件 。 ??AQS和同步组件的关系可以归纳总结为这么几点:
- 同步组件(这里不仅仅指锁,还包括CountDownLatch等)的实现依赖于同步器AQS,在同步组件实现中,使用AQS的方式被推荐定义继承AQS的静态内存类;
- AQS采用模板方法进行设计,AQS的protected修饰的方法需要由继承AQS的子类进行重写实现,当调用AQS的子类的方法时就会调用被重写的方法;
- AQS负责同步状态的管理,线程的排队,等待和唤醒这些底层操作,而Lock等同步组件主要专注于实现同步语义;
- 在重写AQS的方法时,使用AQS提供的getState()、setState()、compareAndSetState()方法进行修改同步状态。
7.2 同步队列
??当共享资源被某个线程占有,其他请求该资源的线程将会阻塞,从而进入同步队列。AQS中的同步队列则是通过链式方式进行实现。 ??在AQS有一个静态内部类Node ,Node结点是对每一个等待获取资源的线程的封装,其包含了需要同步的线程本身及其等待状态,如是否被阻塞、是否等待唤醒、是否已经被取消等。Node的一些属性:
static final Node SHARED = new Node();
static final Node EXCLUSIVE = null;
volatile int waitStatus;
volatile Node prev;
volatile Node next;
volatile Thread thread;
Node nextWaiter;
??waitStatus的取值有以下几种:
static final int CANCELLED = 1;
static final int SIGNAL = -1;
static final int CONDITION = -2;
static final int PROPAGATE = -3;
static final int INITIAL = 0;
??AQS中有两个成员变量:
private transient volatile Node head;
private transient volatile Node tail;
??从这两个变量可以看出:每个节点拥有其前驱和后继节点,因此这是一个双向队列 。 ??AQS实际上通过头尾指针来管理同步队列 ,同步队列示意图: ??节点如何进行入队和出队这对应着锁的获取和释放两个操作:获取锁失败进行入队操作,获取锁成功进行出队操作 。
7.3 独占锁
7.3.1 独占锁的获取
??比如调用ReentrantLock的lock()方法,实际上会调用AQS的acquire()方法。这个方法的含义是获取独占式锁,获取失败就将当前线程加入同步队列,成功则线程执行。acquire()方法源码:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
??acquire()方法流程:
- tryAcquire()尝试直接去获取资源,如果成功则直接返回(这里体现了非公平锁,每个线程获取锁时会尝试直接抢占加塞一次,而队列中可能还有别的线程在等待);
- 如果获取资源失败,addWaiter()将该线程加入等待队列的尾部,并标记为独占模式;
- acquireQueued()使线程阻塞在等待队列中获取资源,一直获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。
- 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。
- 1、tryAcquire(int)
??此方法尝试去获取独占资源。如果获取成功,则直接返回true,否则直接返回false。这也正是tryLock()的语义,当然不仅仅只限于tryLock()。 - 2、addWaiter(Node)
??当线程获取独占式锁失败后,将当前线程加入到等待队列的队尾,并返回当前线程所在的结点。源码:
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
??addWaiter方法的逻辑主要分为两个部分:
- 当前同步队列的尾节点为null,调用方法enq()插入;
- 当前队列的尾节点不为null,则采用尾插入(compareAndSetTail方法)的方式入队。
??同时还会有一个逻辑:如果 if (compareAndSetTail(pred, node)) 为false,会继续执行到enq()方法,compareAndSetTail是一个CAS操作,通常来说如果CAS操作失败会继续自旋进行重试。因此,enq()方法可能承担两个任务:
- 处理当前同步队列尾节点为null时进行入队操作;
- 如果CAS尾插入节点失败后负责自旋进行尝试。
- 3、enq(final Node node)
??此方法用于将node加入队尾,源码:
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) {
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
??第1步中会先创建头结点,说明同步队列是带头结点的链式存储结构。
??带头结点与不带头结点相比,会在入队和出队的操作中获得更大的便捷性,因此同步队列选择了带头结点的链式存储结构。
??带头节点的队列初始化时机是在tail为null时,即当前线程是第一次插入同步队列。compareAndSetTail(t, node) 方法会利用CAS操作设置尾节点,如果CAS操作失败会在for (;;)for死循环中不断尝试,直至成功return返回为止。因此,enq()方法的逻辑:
- 在当前线程是第一个加入同步队列时,调用compareAndSetHead(new Node())方法,完成链式队列的头结点的初始化;
- 自旋不断尝试CAS尾插入节点直至成功为止。
- 4、acquireQueued(final Node node, int arg)
??通过tryAcquire()和addWaiter(),该线程获取资源失败,已经被放入等待队列尾部了,j接下来就要考虑怎么获取资源了,也就是acquireQueued的逻辑:
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null;
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
??整体来看这是一个这又是一个自旋的过程(for循环),代码首先获取当前节点的先驱节点,如果先驱节点是头结点的并且成功获得同步状态的时候(if (p == head && tryAcquire(arg))),当前节点所指向的线程能够获取锁。反之,获取锁失败进入等待状态。 ??在acquireQueued(final Node node, int arg) 方法里,有一段代码是表示:获取锁的节点出队:
setHead(node);
p.next = null;
failed = false;
return interrupted;
- 5、shouldParkAfterFailedAcquire(Node pred, Node node)和parkAndCheckInterrupt()
??从acquireQueued方法中得知:当获取锁失败的时候会调用shouldParkAfterFailedAcquire()方法和parkAndCheckInterrupt()方法。shouldParkAfterFailedAcquire()方法源码:
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
return true;
if (ws > 0) {
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
??shouldParkAfterFailedAcquire方法主要用于检查状态,如果前驱结点的状态不是SIGNAL,那么自己就不能安心去休息,需要去找个安心的休息点,同时可以再尝试下看有没有机会轮到自己拿号。 ??如果线程找好安全休息点后,那就可以安心去休息了。parkAndCheckInterrupt方法就是让线程去休息,真正进入等待状态:
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}
??park()会让当前线程进入waiting状态。在此状态下,有两种途径可以唤醒该线程:1)被unpark();2)被interrupt()。 ??到这里,就可以总结:acquireQueued()在自旋过程中主要完成了两件事情:
- 如果当前节点的前驱节点是头节点,并且能够获得同步状态的话,当前线程能够获得锁该方法执行结束退出;
- 获取锁失败的话,先将节点状态设置成SIGNAL,然后调用LookSupport.park方法使得当前线程阻塞。
- 6、acquire()方法的执行流程
7.3.2 独占锁的释放
- 1、release(int arg)
??比如ReentrantLock中的unlock方法,最终会调用到AQS中的release方法,release会释放指定量的资源,如果彻底释放了(即state=0),它会唤醒等待队列里的其他线程来获取资源。这也正是unlock()的语义,当然不仅仅只限于unlock()。源码:
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
??跟tryAcquire()一样,tryRelease方法是需要独占模式的自定义同步器去实现的。
- 2、unparkSuccessor(Node node)
??此方法用于唤醒等待队列中下一个线程,源码:
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
if (s != null)
LockSupport.unpark(s.thread);
}
??源码的关键信息请看注释,首先获取头节点的后继节点,当后继节点存在的时候会调用LookSupport.unpark()方法,该方法会唤醒该节点的后继节点所包装的线程。因此,每一次锁释放后就会唤醒队列中该节点的后继节点所引用的线程,从而进一步证明获得锁的过程是一个FIFO(先进先出)的过程。
7.3.3 独占锁的获取和释放总结
??独占锁的获取和释放过程的总结:
- 线程获取锁失败,线程被封装成Node进行入队操作,核心方法在于addWaiter()和enq(),同时enq()完成对同步队列的头结点初始化工作以及CAS操作失败的重试;
- 线程获取锁是一个自旋的过程,当且仅当 当前节点的前驱节点是头结点并且成功获得同步状态时,节点出队即该节点引用的线程获得锁,否则,当不满足条件时就会调用LookSupport.park()方法使得线程阻塞;
- 释放锁的时候会唤醒后继节点。
??总体来说:在获取同步状态时,AQS维护一个同步队列,获取同步状态失败的线程会加入到队列中进行自旋;移除队列(或停止自旋)的条件是前驱节点是头结点并且成功获得了同步状态。在释放同步状态时,同步器会调用unparkSuccessor()方法唤醒后继节点 。
7.3.4 可中断式获取锁
??lock相较于synchronized有一些更方便的特性,比如能响应中断以及超时等待等特性 ,可响应中断式锁可调用方法lock.lockInterruptibly()来响应中断,该方法其底层会调用AQS的acquireInterruptibly方法,源码:
public final void acquireInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
if (!tryAcquire(arg))
doAcquireInterruptibly(arg);
}
??在获取同步状态失败后就会调用doAcquireInterruptibly方法:
private void doAcquireInterruptibly(int arg)
throws InterruptedException {
final Node node = addWaiter(Node.EXCLUSIVE);
boolean failed = true;
try {
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null;
failed = false;
return;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
throw new InterruptedException();
}
} finally {
if (failed)
cancelAcquire(node);
}
}
??与acquire方法逻辑几乎一致,唯一的区别是当parkAndCheckInterrupt返回true时,即线程阻塞时该线程被中断,代码抛出被中断异常。
7.3.5 超时等待式获取锁
??通过调用lock.tryLock(timeout,TimeUnit)方式达到超时等待获取锁的效果,该方法会在三种情况下才会返回:
- 在超时时间内,当前线程成功获取了锁;
- 当前线程在超时时间内被中断;
- 超时时间结束,仍未获得锁返回false。
??该方法会调用AQS的方法tryAcquireNanos(),源码:
public final boolean tryAcquireNanos(int arg, long nanosTimeout)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
return tryAcquire(arg) ||
doAcquireNanos(arg, nanosTimeout);
}
??这段源码最终是靠doAcquireNanos方法实现超时等待的效果,该方法源码:
private boolean doAcquireNanos(int arg, long nanosTimeout)
throws InterruptedException {
if (nanosTimeout <= 0L)
return false;
final long deadline = System.nanoTime() + nanosTimeout;
final Node node = addWaiter(Node.EXCLUSIVE);
boolean failed = true;
try {
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null;
failed = false;
return true;
}
nanosTimeout = deadline - System.nanoTime();
if (nanosTimeout <= 0L)
return false;
if (shouldParkAfterFailedAcquire(p, node) &&
nanosTimeout > spinForTimeoutThreshold)
LockSupport.parkNanos(this, nanosTimeout);
if (Thread.interrupted())
throw new InterruptedException();
}
} finally {
if (failed)
cancelAcquire(node);
}
}
??程序逻辑: ??该方法逻辑同独占锁可响应中断式获取基本一致,唯一的不同在于获取锁失败后,对超时时间的处理上,在第1步会先计算出按照现在时间和超时时间计算出理论上的截止时间,比如当前时间是8:10,超时时间是10分钟,那么根据deadline = System.nanoTime() + nanosTimeout 计算出刚好达到超时时间时的系统时间就是8:10+10 = 8:20。 ??然后根据deadline - System.nanoTime() 就可以判断是否已经超时了,比如,当前系统时间是8:30,很明显已经超过了理论上的系统时间8:20,deadline - System.nanoTime() 计算出来就是一个负数,自然而然会在3.2步中的If判断之间返回false。如果还没有超时即3.2步中的if判断为true时就会继续执行3.3步通过LockSupport.parkNanos使得当前线程阻塞,同时在3.4步增加了对中断的检测,若检测出被中断直接抛出被中断异常。
7.4 共享锁
7.4.1 共享锁的获取
- 1、acquireShared(int arg)
??共享锁的获取方法为acquireShared:
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
??在该方法中会首先调用tryAcquireShared方法,tryAcquireShared返回值是一个int类型,当返回值为大于等于0的时候方法结束说明获得成功获取锁,否则,表明获取锁失败,会执行doAcquireShared方法,进入等待队列,直到获取到资源为止才返回。doAcquireShared方法的源码为:
- 2、doAcquireShared(int arg)
private void doAcquireShared(int arg) {
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head) {
int r = tryAcquireShared(arg);
if (r >= 0) {
setHeadAndPropagate(node, r);
p.next = null;
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
??逻辑几乎和独占式锁的获取一模一样,这里的自旋过程中能够退出的条件是当前节点的前驱节点是头结点并且tryAcquireShared(arg)返回值大于等于0即能成功获得同步状态。不过多了个自己拿到资源后,还会去唤醒后继线程的操作。 ??acquireShared()的流程:
- tryAcquireShared()尝试获取资源,成功则直接返回;
- 失败则通过doAcquireShared()进入等待队列park(),直到被unpark()/interrupt()并成功获取到资源才返回。整个等待过程也是忽略中断的。
7.4.2 共享锁的释放
- 1、releaseShared(int arg)
??共享锁的释放在AQS中会调用方法releaseShared:
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
??该方法会释放指定量的资源,如果成功释放且允许唤醒等待线程,它会唤醒等待队列里的其他线程来获取资源。一句话总计的话:释放掉资源后,唤醒后继 。 ??跟独占模式下的release()相似,但有一点稍微需要注意:独占模式下的tryRelease()在完全释放掉资源(state=0)后,才会返回true去唤醒其他线程,这主要是基于独占下可重入的考量;而共享模式下的releaseShared()则没有这种要求,共享模式实质就是控制一定量的线程并发执行,那么拥有资源的线程在释放掉部分资源时就可以唤醒后继等待结点。 ??例如,资源总量是13,A(5)和B(7)分别获取到资源并发运行,C(4)来时只剩1个资源就需要等待。A在运行过程中释放掉2个资源量,然后tryReleaseShared(2)返回true唤醒C,C一看只有3个仍不够继续等待;随后B又释放2个,tryReleaseShared(2)返回true唤醒C,C一看有5个够自己用了,然后C就可以跟A和B一起运行。而ReentrantReadWriteLock读锁的tryReleaseShared()只有在完全释放掉资源(state=0)才返回true,所以自定义同步器可以根据需要决定tryReleaseShared()的返回值。
- 2、doReleaseShared()
??当成功释放同步状态之后即tryReleaseShared会继续执行doReleaseShared方法:
private void doReleaseShared() {
for (;;) {
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue;
unparkSuccessor(h);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue;
}
if (h == head)
break;
}
}
??此方法主要用于唤醒后继。
|