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

8. JMM和底层实现原理

8.1 线程间的通信与同步

线程之间的通信

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

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

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

线程之间的同步

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

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

注意到,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的状态,这种情况下,指令重排也会保证这种关系的正确性。

1
2
3
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