Java并发编程系列-(8) JMM和底层实现原理

8. JMM和底层实现原理

8.1 线程间的通讯与同步

线程之间的通讯

线程的通讯是指线程之间以何种机制来交换信息。在编程中,线程之间的通讯机制有两种,共享内存和消息传递。java

共享内存的并发模型里,线程之间共享程序的公共状态,线程之间经过写-读内存中的公共状态来隐式进行通讯,典型的共享内存通讯方式就是经过共享对象进行通讯。git

消息传递的并发模型里,线程之间没有公共状态,线程之间必须经过明确的发送消息来显式进行通讯,在java中典型的消息传递方式就是wait()和notify()。程序员

线程之间的同步

同步是指程序用于控制不一样线程之间操做发生相对顺序的机制。github

在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码须要在线程之间互斥执行。
消息传递的并发模型里,因为消息的发送必须在消息的接收以前,所以同步是隐式进行的。面试

注意到,Java的并发采用的是共享内存模型,接下来将会主要进行介绍。算法

8.2 Java内存模型(JMM)

JMM

Java的内存模型以下图所示:数据库

每一个Java线程拥有对应的工做内存,工做内寸经过Save和Load操做和主内存进行数据交互。编程

Picture1.png

在JVM内部,Java内存模型把内存分红了两部分:线程栈区和堆区后端

Picture1.png

JVM中运行的每一个线程都拥有本身的线程栈,线程栈包含了当前线程执行的方法调用相关信息,咱们也把它称做调用栈。随着代码的不断执行,调用栈会不断变化。数组

线程栈还包含了当前方法的全部局部变量信息。一个线程只能读取本身的线程栈,也就是说,线程中的本地变量对其它线程是不可见的。即便两个线程执行的是同一段代码,它们也会各自在本身的线程栈中建立局部变量,所以,每一个线程中的局部变量都会有本身的版本。

堆中的对象能够被多线程共享,若是一个线程得到一个对象的应用,它即可访问这个对象的成员变量。若是两个线程同时调用了同一个对象的同一个方法,那么这两个线程即可同时访问这个对象的成员变量,可是对于局部变量,每一个线程都会拷贝一份到本身的线程栈中。

JMM带来的问题

上述介绍的JMM也带来了一些问题:

1. 共享对象对各个线程的可见性

在一个线程中修改了共享数据后,如何保证对另一个线程可见?

Picture1.png

2. 共享对象的竞争现象

对于同一个共享数据,如何保证两个线程正确的修改?

Picture1.png

指令重排

除了以前提到的JMM中存在的两个问题以外,指令重排也可能对程序的正确性产生影响。

Picture111.png

如上图所示,JVM中为了提升指令执行的效率,可能在不改变运行结果的状况下,重排部分指令的顺序达到并发执行的效果。

单线程下,这种指令重排序会听从如下两个规则:

1. 数据依赖性

在下面三种状况,数据存在依赖关系,指令重排必须保证这种状况下的正确性。

Picture231.png

2. 控制依赖性

对于控制依赖性,好比下面的例子,b的值依赖于a的状态,这种状况下,指令重排也会保证这种关系的正确性。

if (a == 1){
    b = 2;
}

as-if-serial语义:无论怎么重排序(编译器和处理器为了提升并行度),(单线程)程序的执行结果不会改变。在as-if-serial语义下,编译器和处理器不会对存在数据依赖关系的操做作重排序,由于这种重排序会改变执行结果。

可是在指令重排并不保证并发执行的正确性,所以可能带来比较严重的问题,好比下面的例子中,use()经过判断flag是否为true,来获取初始化完成的信息。可是因为指令重排,可能拿到错误的a的值。

Picture451.png

Picture4441.png

在并发状况下,为了解决重排序带来的问题,引入了内存屏障来阻止重排序:

Screen Shot 2019-12-14 at 4.08.25 PM.png

8.3 Happens-Before

定义

用happens-before的概念来阐述操做之间的内存可见性。在JMM中,若是一个操做执行的结果须要对另外一个操做可见,那么这两个操做之间必需要存在happens-before关系 。

两个操做之间具备happens-before关系,并不意味着前一个操做必需要在后一个操做以前执行!happens-before仅仅要求前一个操做(执行的结果)对后一个操做可见,且前一个操做按顺序排在第二个操做以前(the first is visible to and ordered before the second)。

对于happens-before,能够从下面两个方面去理解:

  • 对用户来说:若是一个操做happens-before另外一个操做,那么第一个操做的执行结果将对第二个操做可见,并且第一个操做的执行顺序排在第二个操做以前。

  • 对编译器和处理器来讲:两个操做之间存在happens-before关系,并不意味着Java平台的具体实现必需要按照happens-before关系指定的顺序来执行。若是重排序以后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序是容许的

Happens-Before规则

下面几种规则,无需任何同步手段就能够保证:

1)程序顺序规则:一个线程中的每一个操做,happens-before于该线程中的任意后续操做。

2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。

3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

4)传递性:若是A happens-before B,且B happens-before C,那么A happens-before C。

5)start()规则:若是线程A执行操做ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操做happens-before于线程B中的任意操做。

6)join()规则:若是线程A执行操做ThreadB.join()并成功返回,那么线程B中的任意操做happens-before于线程A从ThreadB.join()操做成功返回。

7 )线程中断规则:对线程interrupt方法的调用happens-before于被中断线程的代码检测到中断事件的发生。

8.4 volatile的内存语义

volatile变量自身具备下列特性:

  • 可见性。对一个volatile变量的读,老是能看到(任意线程)对这个volatile变量最后的写入。

  • 原子性:对任意单个volatile变量的读/写具备原子性,但相似于volatile++这种复合操做不具备原子性。

具体来看,能够把对volatile变量的单个读/写,当作是使用同一个锁对这些单个读/写操做作了同步。以下面的例子所示:

Pictu23442re1.png

等价于:

Pictur57e1.png

volatile写与读

  1. volatile写的内存语义以下:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。

Pict45654664re1.png

  1. volatile读的内存语义以下:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

Pict4678467ure1.png

volatile内存语义的实现

JMM经过内存屏障插入策略,来实现volatile的读写语义。

  • 在每一个volatile写操做的前面插入一个StoreStore屏障。在每一个volatile写操做的后面插入一个StoreLoad屏障。

Pictur234e1.png

  • 在每一个volatile读操做的后面插入一个LoadLoad屏障。在每一个volatile读操做的后面插入一个LoadStore屏障。

Pictuadfgbre1.png

volatile的底层实现原理:
有volatile变量修饰的共享变量进行写操做的时候会使用CPU提供的Lock前缀指令。

  • 将当前处理器缓存行的数据写回到系统内存
  • 这个写回内存的操做会使在其余CPU里缓存了该内存地址的数据无效。

volatile语义进一步:

  • 保证变量对全部线程可见:注意因为一条字节ma

8.5 锁的内存语义

  • 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。

sfrrth.png

  • 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。

sfgtnms.png

8.5 final的内存语义

编译器和处理器要遵照两个重排序规则:

  • 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操做之间不能重排序。
  • 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操做之间不能重排序

final域为引用类型

增长了以下规则:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操做之间不能重排序。

final语义在处理器中的实现

  • 会要求编译器在final域的写以后,构造函数return以前插入一个StoreStore障屏。
  • 读final域的重排序规则要求编译器在读final域的操做前面插入一个LoadLoad屏障。

8.6 Synchronized的实现原理

synchronized底层如何实现?什么是锁的升级、降级?

这是一个很是常见的面试题,标准回答以下:

synchronized 代码块是由一对monitorenter/monitorexit 指令实现的,Monitor 对象是同步的基本实现单元。

在 Java 6 以前,Monitor 的实现彻底是依靠操做系统内部的互斥锁,由于须要进行用户态到内核态的切换,因此同步操做是一个无差异的重量级操做。

现代的(Oracle)JDK 中,JVM进行了大量改进,提供了三种不一样的 Monitor 实现,也就是常说的三种不一样的锁:偏斜锁(Biased Locking)、轻量级锁和重量级锁,大大改进了其性能。

所谓锁的升级、降级,就是 JVM 优化 synchronized 运行的机制,当 JVM 检测到不一样的竞争情况时,会自动切换到适合的锁实现,这种切换就是锁的升级、降级。

当没有竞争出现时,默认会使用偏斜锁。JVM 会利用 CAS 操做(compare and swap),在对象头上的 Mark Word 部分设置线程 ID,以表示这个对象偏向于当前线程,因此并不涉及真正的互斥锁。这样作的假设是基于在不少应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏斜锁能够下降无竞争开销。

若是有另外的线程试图锁定某个已经被偏斜过的对象,JVM 就须要撤销(revoke)偏斜锁,并切换到轻量级锁实现。轻量级锁依赖 CAS 操做 Mark Word 来试图获取锁,若是重试成功,就使用普通的轻量级锁;不然,进一步升级为重量级锁。

Synchronized原理详细总结

1. monitor和对象头

Java对象头和monitor是实现synchronized的基础。

synchronized用的锁是存在Java对象头里的。JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。在实现时,使用到了monitorenter和monitorexit指令,monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处。

Java对象头

synchronized用的锁是存在Java对象头里的,Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。

  • Klass Point是是对象指向它的类元数据的指针,虚拟机经过这个指针来肯定这个对象是哪一个类的实例。
  • Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键,其中储存的数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。

Java对象头通常占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),可是若是对象是数组类型,则须要三个机器码,由于JVM虚拟机能够经过Java对象的元数据信息肯定Java对象的大小,可是没法从数组的元数据来确认数组的大小,因此用一块来记录数组长度。下图是Java对象头的存储结构(32位虚拟机):

Picture1.png

对象头信息是与对象自身定义的数据无关的额外存储成本,可是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽可能多的数据,它会根据对象的状态复用本身的存储空间,也就是说,Mark Word会随着程序的运行发生变化,变化状态以下(32位虚拟机):

Screen Shot 2019-12-14 at 5.38.08 PM.png

Monitor

Monitor能够把它理解为一个同步工具,也能够描述为一种同步机制,它一般被描述为一个对象。
与一切皆对象同样,全部的Java对象是天生的Monitor,每个Java对象都有成为Monitor的潜质,由于在Java的设计中 ,每个Java对象自生成后就自带一种看不见的锁,它叫作内部锁或者Monitor锁。
Monitor 是线程私有的数据结构,每个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段存放拥有该锁的线程的惟一标识,表示该锁被这个线程占用。其结构以下:

Screen Shot 2019-12-14 at 5.41.55 PM.png

  • Owner:初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程惟一标识,当锁被释放时又设置为NULL;
  • EntryQ:关联一个系统互斥锁(semaphore),阻塞全部试图锁住monitor record失败的线程。
  • RcThis:表示block或waiting在该monitor record上的全部线程的个数。
  • Nest:用来实现重入锁的计数。
  • HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)。
  • Candidate:用来避免没必要要的阻塞或等待线程唤醒,由于每一次只有一个线程可以成功拥有锁,若是每次前一个释放锁的线程唤醒全部正在阻塞或等待的线程,会引发没必要要的上下文切换(从阻塞到就绪而后由于竞争锁失败又被阻塞)从而致使性能严重降低。Candidate只有两种可能的值0表示没有须要唤醒的线程1表示要唤醒一个继任线程来竞争锁。

2. 偏向锁&轻量级锁&重量级锁

Java SE 1.6为了减小得到锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”:锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。

偏向锁

HotSpot的做者通过研究发现,大多数状况下,锁不只不存在多线程竞争,并且老是由同一线程屡次得到。偏向锁是为了在只有一个线程执行同步块时提升性能。

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,之后该线程在进入和退出同步块时不须要进行CAS操做来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。

引入偏向锁是为了在无多线程竞争的状况下尽可能减小没必要要的轻量级锁执行路径,由于轻量级锁的获取及释放依赖屡次CAS原子指令,而偏向锁只须要检查是否为偏向锁、锁标识为以及ThreadID便可。

获取锁的流程:

  1. 检测Mark Word是否为可偏向状态,即偏向锁的标识是否设置成1,锁标志位是否为01——确认为可偏向状态。
  2. 若为可偏向状态,则测试线程ID是否为当前线程ID,若是是,则执行步骤(5),不然执行步骤(3);
  3. 若是线程ID不为当前线程ID,则经过CAS操做竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,不然执行线程(4);
  4. 经过CAS竞争锁失败,证实当前存在多线程竞争状况,当到达全局安全点(这个时间点是上没有正在执行的代码),得到偏向锁的线程被挂起,偏向锁升级为轻量级锁,而后被阻塞在安全点的线程继续往下执行同步代码块;
  5. 执行同步代码块

释放锁

偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,须要等待其余线程来竞争。偏向锁的撤销须要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤以下:

  1. 暂停拥有偏向锁的线程,判断锁对象是否还处于被锁定状态;
  2. 撤销偏向锁,恢复到无锁状态(01)或者轻量级锁的状态;

关闭偏向锁

偏向锁在Java 6和Java 7里是默认启用的。因为偏向锁是为了在只有一个线程执行同步块时提升性能,若是你肯定应用程序里全部的锁一般状况下处于竞争状态,能够经过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

轻量级锁

轻量级锁是为了在线程近乎交替执行同步块时提升性能。

加锁过程

  1. 在代码进入同步块的时候,若是同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中创建一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态以下图所示。

  2. 拷贝对象头中的Mark Word复制到锁记录中。
  3. 拷贝成功后,虚拟机将使用CAS操做尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。若是更新成功,则执行步骤(4),不然执行步骤(5)。
  4. 若是这个更新动做成功了,那么这个线程就拥有了该对象的锁,而且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态以下图所示。

  5. 若是这个更新操做失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,若是是就说明当前线程已经拥有了这个对象的锁,那就能够直接进入同步块继续执行。不然说明多个线程竞争锁,若当前只有一个等待线程,则可经过自旋稍微等待一下,可能另外一个线程很快就会释放锁。 可是当自旋超过必定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程之外的线程都阻塞,防止CPU空转,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

解锁过程

  1. 经过CAS操做尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word。
  2. 若是替换成功,整个同步过程就完成了。
  3. 若是替换失败,说明有其余线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。

自旋锁
1.基于乐观状况下推荐使用,即锁竞争不强,锁等待时间不长的状况下推荐使用
2.单cpu无效,由于基于cas的轮询会占用cpu,致使没法作线程切换
3.轮询不产生上下文切换,若是可估计到睡眠的时间很长,用互斥锁更好

重量级锁

如上轻量级锁的加锁过程见轻量级锁的步骤(5),轻量级锁所适应的场景是线程近乎交替执行同步块的状况,若是存在同一时间访问同一锁的状况,就会致使轻量级锁膨胀为重量级锁。Mark Word的锁标记位更新为10,Mark Word指向互斥量(重量级锁)。

Synchronized的重量级锁是经过对象内部的一个叫作监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操做系统的Mutex Lock(互斥锁)来实现的。而操做系统实现线程之间的切换须要从用户态转换到核心态,这个成本很是高,状态之间的转换须要相对比较长的时间,这就是为何Synchronized效率低的缘由。

3. 三种锁的切换

  • 一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它如今认为只可能有一个线程来访问它,因此当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操做,并将对象头中的ThreadID改为本身的ID,以后再次访问这个对象时,只须要对比ID,不须要再使用CAS在进行操做。
  • 一旦有第二个线程访问这个对象,由于偏向锁不会主动释放,因此第二个线程能够看到对象时偏向状态,这时代表在这个对象上已经存在竞争了。检查原来持有该对象锁的线程是否依然存活,若是挂了,则能够将对象变为无锁状态,而后从新偏向新的线程。若是原来的线程依然存活,则立刻执行那个线程的操做栈,检查该对象的使用状况,若是仍然须要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待得到该轻量级锁。
  • 轻量级锁认为竞争存在,可是竞争的程度很轻,通常两个线程对于同一个锁的操做都会错开,或者说稍微等待一下(自旋),另外一个线程就会释放锁。 可是当自旋超过必定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程之外的线程都阻塞,防止CPU空转。

偏向所锁,轻量级锁都是乐观锁,重量级锁是悲观锁。

悲观锁
老是假设最坏的状况,每次去拿数据的时候都认为别人会修改,因此每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了不少这种锁机制,好比行锁,表锁等,读锁,写锁等,都是在作操做以前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
乐观锁
老是假设最好的状况,每次去拿数据的时候都认为别人不会修改,因此不会上锁,可是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样能够提升吞吐量,像数据库提供的相似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
乐观锁适用于写比较少的状况下(多读场景),即冲突真的不多发生的时候,这样能够省去了锁的开销,加大了系统的整个吞吐量。但若是是多写的状况,通常会常常产生冲突,这就会致使上层应用会不断的进行retry,这样反却是下降了性能,因此通常多写的场景下用悲观锁就比较合适。


参考连接:


本文由『后端精进之路』原创,首发于博客 http://teckee.github.io/ , 转载请注明出处

搜索『后端精进之路』关注公众号,马上获取最新文章和价值2000元的BATJ精品面试课程

后端精进之路.png

相关文章
相关标签/搜索