并发机制底层实现整理

1. 缓存一致性问题

 
          硬件内存架构
  • 现代计算机一般都有 2 个以上 CPU,而且每个 CPU 还有可能包含多个核心。因此,如果应用是多线程的话,这些线程可能会在各个 CPU 核心中并行运行。
  • 在 CPU 内部有一组 CPU 寄存器,也就是 CPU 的储存器。
    • CPU 操作寄存器的速度要比操作计算机主存快的多。
    • 在主存和 CPU 寄存器之间还存在一个 CPU 缓存,CPU 操作 CPU 缓存的速度快于主存但慢于 CPU 寄存器。某些 CPU 可能有多个缓存层(一级缓存和二级缓存)。计算机的主存也称作 RAM,所有的 CPU 都能够访问主存,而且主存比上面提到的缓存和寄存器大很多。
    • 当一个 CPU 需要访问主存时,会先读取一部分主存数据到 CPU 缓存,进而在读取 CPU 缓存到寄存器。当 CPU 需要写数据到主存时,同样会先 flush 寄存器到
      CPU 缓存,然后再在某些节点把缓存数据 flush 到主存。
 缓存架构
  • 缓存大大缩小了高速 CPU 与低速内存之间的差距。以三层缓存架构为例。除三级缓存外,各厂商实现的硬件架构中还存在多种多样的缓存,都存在类似的可见性问题。例如,寄存器就相当于 CPU 与 L1 Cache 之间的缓存。
    • Core0 与 Core1 命中了内存中的同一个地址,那么各自的 L1 Cache 会缓存同一份数据的副本。
    • Core0 修改了数据,两份缓存中的数据不同了,Core1 L1 Cache 中的数据相当于失效了。
  • 除三级缓存外,各厂商实现的硬件架构中还存在多种多样的缓存,都存在类似的可见性问题。例如,寄存器就相当于 CPU 与 L1 Cache 之间的缓存。

1.2 MESI 协议

  • MESI(Modified Exclusive Shared Or Invalid,缓存的四种状态)协议的基本原理。MESI 协议可以解决 CPU 缓存层面的一致性问题。
    • Core0 修改数据 v 后,发送一个信号,将 Core1 缓存的数据 v 标记为失效,并将修改值写回内存。
    • Core0 可能会多次修改数据 v,每次修改都只发送一个信号(发信号时会锁住缓存间的总线),Core1 缓存的数据 v 保持着失效标记。
    • Core1 使用数据 v 前,发现缓存中的数据 v 已经失效了,得知数据 v 已经被修改,于是重新从其他缓存或内存中加载数据 v。
 MESI 协议可以解决 CPU 缓存层面的一致性问题。
MESI 协议缓存状态
状态说明
M(修改,Modified) 本地处理器已经修改缓存行, 即是脏行, 它的内容与内存中的内容不一样. 并且此 cache 只有本地一个拷贝(专有)。
E(专有,Exclusive) 缓存行内容和内存中的一样, 而且其它处理器都没有这行数据。
S(共享,Shared) 缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝。
I(无效,Invalid) 缓存行失效, 不能使用。

2. 优化重排序问题

  • 在执行程序时,为了提高性能,处理器和编译器会对指令做重排序。
    • 指令级并行的重排序。如果不存在数据依赖性,处理器 可以改变语句对应机器指令的执行顺序。
    • 编译器优化的重排序编译器 在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
    • 内存系统的重排序。处理器使用 缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。(导致的可见性问题也可以通过 MESI 协议解决)
重排序
  • 重排序不是随意重排序,它需要满足以下两个条件。

数据依赖性

  • 如果两个操作访问同一个变量,其中一个为写操作,此时这两个操作之间存在数据依赖性。编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序,即不会重排序。

as-if-serial

  • 所有的动作(Action)都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身(单线程下的执行)的应有结果是一致的,编译器、runtime 和处理器都必须遵守 as-if-serial 语义。

2.1 指令级并行的重排序(处理器)

指令级并行的重排序
  • 只要不影响程序单线程、顺序执行的结果,就可以对两个指令重排序。
  • 乱序执行技术是处理器为提高运算速度而做出违背代码原有顺序的优化。
不优化时的执行过程优化时的执行过程
指令获取。 指令获取。
如果输入的运算对象是可以获取的(比如已经存在于寄存器中),这条指令会被发送到合适的功能单元。如果一个或者更多的运算对象在当前的时钟周期中是不可获取的(通常需要从主内存获取),处理器会开始等待直到它们是可以获取的。 指令被发送到一个指令序列(也称执行缓冲区或者保留站)中。
指令在合适的功能单元中被执行。 指令将在序列中等待,直到它的数据运算对象是可以获取的。然后,指令被允许在先进入的、旧的指令之前离开序列缓冲区。(此处表现为乱序)
功能单元将运算结果写回寄存器。 指令被分配给一个合适的功能单元并由之执行。
  结果被放到一个序列中。
  仅当所有在该指令之前的指令都将他们的结果写入寄存器后,这条指令的结果才会被写入寄存器中。(重整乱序结果)

2.2 编译器优化的重排序

  • 和处理器乱序执行的目的是一样的,与其等待阻塞指令(如等待缓存刷入)完成,不如先执行其他指令。与处理器乱序执行相比,编译器重排序能够完成更大范围、效果更好的乱序优化。
  • 编译器层面的重排序,自然可以由编译器控制。使用 volatile 做标记,就可以禁用编译器层面的重排序。
  • JVM 自己维护的 内存模型 中也有可见性问题,使用 volatile 做标记,取消 volatile 变量的缓存,就解决了 JVM 层面的可见性问题。

3. 内存模型

  • 可以把内存模型理解为在特定操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。
  • 在并发编程需要处理的两个关键问题是:线程之间如何通信 和 线程之间如何同步

通信

  • 通信是指线程之间以何种机制来交换信息。
  • 命令式编程中,线程之间的通信机制有两种,是 共享内存消息传递
    • 共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的 公共状态隐式 进行通信。
    • 消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的 发送消息显式 进行通信。

同步

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

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

3.1 顺序一致性内存模型

  • 顺序一致性内存模型有两大特性。
    • 一个线程中的所有操作必须按照程序的顺序来执行。
    • (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。
顺序一致性内存模型
  • 在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作。
  • 在任意时间点最多只能有一个线程可以连接到内存。
  • 当多个线程并发执行时,开关装置能把所有线程的所有内存读/写操作串行化。
一致性模型执行效果
  • 假设这两个线程使用监视器锁来正确同步:A 线程的三个操作执行后释放监视器锁,随后 B 线程获取同一个监视器锁。那么程序在顺序一致性模型中的执行效果。
未同步程序在一致性模型中执行效果
  • 未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。
  • 之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。
    • 在 JMM 中就没有这个保证。未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,在还没有刷新到主内存之前,这个写操作仅对当前线程可见。
    • 从其他线程的角度来观察,会认为这个写操作根本还没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。
    • 在这种情况下,当前线程和其它线程看到的操作执行顺序将不一致。
  • 在顺序一致性模型中,所有操作完全按程序的顺序执行。而在 JMM 中,临界区内的代码可以重排序(但 JMM 不允许临界区内的代码 " 逸出 " 到临界区之外,那样会破坏监视器的语义)。JMM 会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图。
  • JMM 在具体实现上的基本方针:在不改变(正确同步的)程序执行结果的前提下,尽可能的为编译器和处理器的优化打开方便之门。
  • JMM 中与在顺序一致性内存模型中的执行结果的异同。
JMM 中与在顺序一致性内存模型中的执行结果的异同

4. 抽象结构(JMM)

  • 不同架构的物理计算机可以有不一样的内存模型,Java 虚拟机也有自己的内存模型。
    • Java 虚拟机规范中试图定义一种 Java 内存模型(Java Memory Model,简称 JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果,不必因为不同平台上的物理机的内存模型的差异,对各平台定制化开发程序。
    • Java 内存模型提出目标在于,定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。
  • Java 线程之间的通信由 Java 内存模型(JMM)控制,JMM 决定一个线程对共享变量(实例域、静态域和数组)的写入何时对其它线程可见。
    • 从抽象的角度来看,JMM 定义了线程和主内存 Main Memory(堆内存)之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有自己的本地内存(工作内存) Local Memory(只是一个抽象概念,物理上不存在),存储了该线程的共享变量副本。
    • 本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。
    • JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过插入特定类型的 Memory Barrier(内存屏障)来禁止特定类型的编译器重排序和处理器重排序,为上层提供一致的内存可见性保证。

Java内存模型(Java Memory Model,JMM)

Java内存模型与硬件架构关联

Java 内存模型抽象
  • 线程 A 和线程 B 之间需要通信的话,必须经过两个步骤:
    • 线程 A 把本地内存(工作内存) A 中更新过的共享变量副本刷新到主内存中。
    • 线程 B 到主内存中读取线程 A 之前更新过的共享变量。
    两个步骤实质上是线程 A 在向线程 B 发送消息,而这个通信过程必须经过主内存。
    • JMM 通过控制主内存与每个线程的本地内存(工作内存)之间的交互,来为 Java 程序员提供内存可见性保证。

4.1 happens-before 关系(先行发生原则)

  • 一会是编译器重排序一会是处理器重排序,如果让程序员再去了解这些底层的实现以及具体规则,那么程序员的负担就太重了,严重影响了并发编程的效率。因此,JMM 为程序员在上层提供了 8 个规则,这样我们就可以根据规则去推论跨线程的内存可见性问题,而不用再去理解底层重排序的规则。

  • 从 jdk5 开始,Java 使用新的 JSR-133 内存模型,基于 happens-before 的概念来阐述操作之间的内存可见性。

  • 在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

  • JMM 的设计示意图。

 
JMM 的设计示意图
  • JMM 可以通过 happens-before 关系向程序员提供跨线程的内存可见性保证(如果线程 A 的写操作 a 与 B 线程的读操作 b 之间存在 happens-before 关系,尽管 a 操作和 b 操作在不同的线程中执行,但 JMM 向程序员保证 a 操作将对 b 操作可见)。

  • 具体的定义为。

    • 1)如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
      • 这是 JMM 对程序员的承诺。从程序员的角度来说,可以这样理解 happens-before 关系:如果 A happens-before B,那么 Java 内存模型将向程序员保证,A 操作的结果将对 B 可见,且 A 的执行顺序排在 B 之前。注意,这只是 Java 内存模型向程序员做出的保证。
    • 2)两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM 允许这种重排序)。
      • 这是 JMM 对编译器和处理器重排序的约束原则。JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM 这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before 关系本质上和 as-if-serial 语义是一回事。
  • happens-before 的具体规则如下。

规则说明
程序次序规则 一个线程内,按照代码顺序,书写在前面的操作 happens-before 书写在后面的操作。
锁定规则 一个 unLock 操作 happens-before 后面对同一个锁的 lock 操作。
volatile 变量规则 对一个变量的写操作 happens-before 后面对这个变量的读操作。
传递规则 如果操作 A happens-before 操作 B,而操作 B 又 happens-before 操作 C,则可以得出操作 A happens-before 操作 C。
线程启动规则 Thread 对象的 start() 方法 happens-before 此线程的每个一个动作。
线程中断规则 对线程 interrupt() 方法的调用 happens-before 被中断线程的代码检测到中断事件的发生。
线程终结规则 线程中所有的操作都 happens-before 线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止执行。
对象终结规则 一个对象的初始化完成 happens-before 它的 finalize() 方法的开始。
 
happens-before 与 JMM 的关系
  • 一个 happens-before 规则对应于一个或多个编译器和处理器重排序规则。对于Java 程序员来说,happens-before 规则简单易懂,它避免 Java 程序员为了理解JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法。

4.1.1 as-if-serial 和 happens-before 比较

  • as-if-serial 语义保证单线程内程序的执行结果不被改变,happens-before 关系保证正确同步的多线程程序的执行结果不被改变。
  • as-if-serial 语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before 关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按 happens-before 指定的顺序来执行的。
  • as-if-serial 语义和 happens-before 这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

4.2 Java 内存模型的实现

  • 关于主内存与本地内存(工作内存)之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节。
    • Java 内存模型中定义了下面 8 种操作来完成:
8 种基本操作
操作作用
lock (锁定) 作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
unlock (解锁) 作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read (读取) 作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
load (载入) 作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
use (使用) 作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时就会执行这个操作。
assign (赋值) 作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store (存储) 作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后 write 操作使用。
write (写入) 作用于主内存的变量,它把 Store 操作从工作内存中得到的变量的值放入主内存的变量中。
  • 内存交互基本操作的 3 个特性:

原子性

  • 原子性即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。原子是世界上的最小单位,具有不可分割性。
  • 在 Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。

可见性

  • 可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
  • JMM 是通过在线程 A 变量工作内存修改后将新值同步回主内存,线程 B 在变量读取前从主内存刷新变量值,这种依赖主内存作为传递媒介的方式来实现可见性。

有序性

  • 有序性规则表现在以下两种场景:

    • 线程内,从某个线程的角度看方法的执行,指令会按照一种叫 " 串行 "(as-if-serial)的方式执行,此种方式已经应用于顺序编程语言。
    • 线程间,这个线程 " 观察 " 到其他线程并发地执行非同步的代码时,由于指令重排序优化,任何代码都有可能交叉执行。
    • 唯一起作用的约束是:对于同步方法,同步块(synchronized 关键字修饰)以及 volatile 字段的操作仍维持相对有序。
  • Java 内存模型的一系列运行规则,都是围绕原子性、可见性、有序性特征建立。是为了实现共享变量的在多个线程的工作内存的数据一致性,多线程并发,指令重排序优化的环境中程序能如预期运行。

4.3 Memory Barrier(内存屏障)

  • 内存屏障(Memory Barrier),又称内存栅栏,是一个 CPU 指令。
  • 基本内存屏障可以分为:LoadLoad 屏障、LoadStore 屏障、StoreStore 屏障和 StoreLoad 屏障。
  • 内存屏障有两个作用。
    • 阻止屏障两侧的指令重排序,插入一条 Memory Barrier 会告诉编译器和 CPU,不管什么指令都不能和这条 Memory Barrier 指令重排序。
    • 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。如一个 Write-Barrier(写入屏障)将刷出所有在 Barrier 之前写入 cache 的数据,因此,任何 CPU 上的线程都能读取到这些数据的最新版本。
  • 内存屏障阻碍了 CPU 采用优化技术来降低内存操作延迟,因此必定会带来性能损失。
 
Memory Barrier
屏障类型指令示例说明
LoadLoad 屏障 Load1; LoadLoad; Load2 在 Load2 及后续读取操作要读取的数据被访问前,保证 Load1 要读取的数据被读取完毕。
StoreStore 屏障 Store1; StoreStore; Store2 在 Store2 及后续写入操作执行前,保证 Store1 的写入操作对其它处理器可见。
LoadStore 屏障 Load1; LoadStore; Store2 在 Store2 及后续写入操作被执行前,保证 Load1 要读取的数据被读取完毕。
StoreLoad 屏障 Store1; StoreLoad; Load2 在 Load2 及后续所有读取操作执行前,保证 Store1 的写入对所有处理器可见。它的开销是四种屏障中最大的(冲刷写缓冲器,清空无效化队列)。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
  • Java 中对内存屏障的使用常见的有 volatile 和 synchronized 关键字修饰的代码块,还可以通过 Unsafe 这个类来使用内存屏障。

4.3.1 x86架构的内存屏障

  • x86架构并没有实现全部的内存屏障。

Store Barrier

  • sfence 指令实现了Store Barrier,相当于 StoreStore Barriers。
    • 强制所有在 sfence 指令之前的 store 指令,都在该 sfence 指令执行之前被执行,发送缓存失效信号,并把 store buffer 中的数据刷出到 CPU 的 L1 Cache 中。
    • 所有在 sfence 指令之后的 store 指令,都在该 sfence 指令执行之后被执行。即禁止对 sfence 指令前后 store 指令的重排序跨越 sfence 指令,使所有 Store Barrier 之前发生的内存更新都是可见的。这里的 " 可见 ",指修改值可见(内存可见性)且操作结果可见(禁用重排序)。

Load Barrier

  • lfence 指令实现了 Load Barrier,相当于 LoadLoad Barriers。
    • 强制所有在 lfence 指令之后的 load 指令,都在该 lfence 指令执行之后被执行,并且一直等到 load buffer 被该 CPU 读完才能执行之后的 load 指令(发现缓存失效后发起的刷入)。即禁止对 lfence 指令前后 load 指令的重排序跨越 lfence 指令,配合 Store Barrier,使所有 Store Barrier 之前发生的内存更新,对 Load Barrier 之后的 load 操作都是可见的。

Full Barrier

  • mfence 指令实现了 Full Barrier,相当于 StoreLoad Barriers。
    • mfence 指令综合了 sfence 指令与 lfence 指令的作用,强制所有在 mfence 指令之前的 store/load 指令,都在该 mfence 指令执行之前被执行。
    • 所有在 mfence 指令之后的 store/load 指令,都在该 mfence 指令执行之后被执行。即禁止对 mfence 指令前后 store/load 指令的重排序跨越 mfence 指令,使所有 Full Barrier 之前发生的操作,对所有 Full Barrier 之后的操作都是可见的。

4.4 同步规则

  • JMM 在执行前面 4.1 中介绍的 8 种基本操作时,为了保证内存间数据一致性,JMM 中规定需要满足以下规则。
规则说明
规则 1 如果要把一个变量从主内存中复制到工作内存,就需要按顺序的执行 read 和 load 操作,如果把变量从工作内存中同步回主内存中,就要按顺序的执行 store 和 write 操作。但 Java 内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
规则 2 不允许 read 和 load、store 和 write 操作之一单独出现。
规则 3 不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须同步到主内存中。
规则 4 不允许一个线程无原因的(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中。
规则 5 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或 assign )的变量。即对一个变量实施 use 和 store 操作之前,必须先执行过了 load 或 assign 操作。
规则 6 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。所以 lock 和 unlock 必须成对出现。
规则 7 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值。
规则 8 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量。
规则 9 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)。
  • 规则 1、规则 2,工作内存中的共享变量作为主内存的副本。
    • 主内存变量的值同步到工作内存需要 read 和 load 一起使用。
    • 工作内存中的变量的值同步回主内存需要 store 和 write 一起使用。
    • 这 2 组操作各自都是一个固定的有序搭配,不允许单独出现。
  • 规则 3、规则 4,由于工作内存中的共享变量是主内存的副本,为保证数据一致性,当工作内存中的变量被字节码引擎重新赋值,必须同步回主内存。
    • 如果工作内存的变量没有被更新,不允许无原因同步回主内存。
  • 规则 5,由于工作内存中的共享变量是主内存的副本,必须从主内存诞生。
  • 规则 6、7、8、9,为了并发情况下安全使用变量,线程可以基于 lock 操作独占主内存中的变量,其他线程不允许使用或 unlock 该变量,直到变量被线程 unlock。

1. hsdis

  • 通过 hsdis 可以查看 Java 编译后的机器指令。
    • window 32/64 位,可以下载 hsdis-amd64.dll/hsdis-i386.dll
      • 下载后拷贝至 $JAVA_HOME\jre\bin\server 目录下。
    • 或者 linxu 下载 hsdis-amd64.so
      • 下载后拷贝至 $JAVA_HOME/jre/lib/amd64/server 目录下。
  • 拷贝完成后,运行 java -XX:+UnlockDiagnosticVMOptions。
    • 提示 Could not load hsdis-amd64.dll; library not loadable; PrintAssembly is disabled,则没有加载成功,检查文件名称是否正确。
    • 或者设置 export LD_LIBRARY_PATH=$JAVA_HOME/jre/lib/amd64/server/
    • 安装成功后提示。
......
    -agentpath:<pathname>[=<options>]
                  load native agent library by full pathname
    -javaagent:<jarpath>[=<options>]
                  load Java programming language agent, see java.lang.instrument
    -splash:<imagepath>
                  show splash screen with specified image
See http://www.oracle.com/technetwork/java/javase/documentation/index.html for more details.  0x00007fd34511a20f: mov    %rsp,%rdx
  0x00007fd34511a212: and    $0xfffffffffffffff0,%rsp
  0x00007fd34511a216: callq  0x00007fd35a935a40  ;   {runtime_call}
  0x00007fd34511a21b: hlt    
[Deopt Handler Code]
  0x00007fd34511a21c: mov    $0x7fd34511a21c,%r10  ;   {section_word}

  0x00007fd34511a226: push   %r10
  0x00007fd34511a228: jmpq   0x00007fd345047240  ;   {runtime_call}
  0x00007fd34511a22d: hlt    
  0x00007fd34511a22e: hlt    
  0x00007fd34511a22f: hlt
  • 可以运行以下命令生成日志。
    • Test 为 class 文件名称。
java -XX:+UnlockDiagnosticVMOptions -XX:+TraceClassLoading -XX:+LogCompilation -XX:LogFile=jit.log -XX:-BackgroundCompilation -XX:+PrintAssembly Test

4.5 volatile 关键字

  • volatile 是一个类型修饰符。volatile 的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略。

4.5.1 volatile 的特性

  • volatile 的语义是 保证可见性禁止进行指令重排序,不能确保 原子性

保证可见性

  • 保证了不同线程对该变量操作的内存可见性。
    • 线程对变量进行修改之后,要立刻回写到主内存。
    • 线程对变量读取的时候,要从主内存中读,而不是从线程的工作内存。

禁止进行指令重排序

  • 当程序执行到 volatile 变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见。在其后面的操作肯定还没有进行。
  • 在进行指令优化时,不能将在对 volatile 变量访问的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行。
    • 普通的变量仅仅会保证该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证赋值操作的顺序与程序代码中的执行顺序一致。

不能确保原子性

  • 例如自增 i++ 操作。将该操作拆分步骤为:
    • 1)从主内存读取 volatile 变量值 i 到工作内存。
    • 2)增加变量 i 的值。
    • 3)把工作内存的值写回主内存,让其它的线程可见。
  • 因为 happens-before 中的 volatile 变量规则只规定了 对一个变量的写操作 happens-before 后面对这个变量的读操作。所以中间的过程(从 Load 到 Store)是不安全的。中间如果其他的 CPU 修改了值将会丢失。例如执行到步骤 2 时,线程B 对变量 i 进行了修改,但是线程 A 是不会感知的。只有线程 A 在下一次读取时,由于可见性才会感知到被线程 B 修改后的新值。

4.5.1 volatile 的实现原理

volatile
  • 具体实现方式是在编译期生成字节码时,会在指令序列中增加 内存屏障 来保证。
    • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。该屏障除了保证了屏障之前的写操作和该屏障之后的写操作不能重排序,还会保证了 volatile 写操作之前,任何的读写操作都会先于 volatile 被提交。
    • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。该屏障除了使 volatile 写操作不会与之后的读操作重排序外,还会刷新处理器缓存,使 volatile 变量的写更新对其他线程可见。
    • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。该屏障除了使 volatile 读操作不会与之前的写操作发生重排序外,还会刷新处理器缓存,使 volatile 变量读取的为最新值。
    • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。该屏障除了禁止了 volatile 读操作与其之后的任何写操作进行重排序,还会刷新处理器缓存,使其他线程 volatile 变量的写更新对 volatile 读操作的线程可见。

2.2.1 volatile 可见性实现

  • volatile 变量的内存可见性是基于内存屏障(Memory Barrier)实现。

    • 内存屏障,又称内存栅栏,是一个 CPU 指令。
    • 在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM 为了保证在不同的编译器和 CPU 上有相同的结果,通过插入特定类型的内存屏障来禁止特定类型的编译器重排序和处理器重排序,插入一条内存屏障会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序。
  • 写一段简单的 Java 代码,声明一个 volatile 变量,并赋值。

public class Test {
    private volatile int a;
    public void update() {
        a = 1;
    }
    public static void main(String[] args) {
        Test test = new Test();
        test.update();
    }
}
  • 通过 hsdis 和 jitwatch 工具可以得到编译后的汇编代码。
......
  0x0000000002951563: and    $0xffffffffffffff87,%rdi
  0x0000000002951567: je     0x00000000029515f8
  0x000000000295156d: test   $0x7,%rdi
  0x0000000002951574: jne    0x00000000029515bd
  0x0000000002951576: test   $0x300,%rdi
  0x000000000295157d: jne    0x000000000295159c
  0x000000000295157f: and    $0x37f,%rax
  0x0000000002951586: mov    %rax,%rdi
  0x0000000002951589: or     %r15,%rdi
  0x000000000295158c: lock cmpxchg %rdi,(%rdx)  //在 volatile 修饰的共享变量进行写操作的时候会多出 lock 前缀的指令
  0x0000000002951591: jne    0x0000000002951a15
  0x0000000002951597: jmpq   0x00000000029515f8
  0x000000000295159c: mov    0x8(%rdx),%edi
  0x000000000295159f: shl    $0x3,%rdi
  0x00000000029515a3: mov    0xa8(%rdi),%rdi
  0x00000000029515aa: or     %r15,%rdi
......
  • lock 前缀的指令在多核处理器下会引发两件事情。
    • 1)将当前处理器缓存行的数据写回到系统内存。
    • 2)写回内存的操作会使在其他 CPU 里缓存了该内存地址的额数据无效。
  • 为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2 或其他)后再进行操作,但操作完不知道何时会写到内存。
  • 如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。
  • 为了保证各个处理器的缓存是一致的,实现了缓存一致性协议(MESI),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
    • 所有多核处理器下还会完成:3)当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。
  • volatile 变量通过这样的机制就使得每个线程都能获得该变量的最新值。

2.2.1.1 lock 指令

  • 在 Pentium 和早期的 IA-32 处理器中,lock 前缀会使处理器执行当前指令时产生一个 LOCK# 信号,会对总线进行锁定,其它 CPU 对内存的读写请求都会被阻塞,直到锁释放。
  • 后来的处理器,加锁操作是由高速缓存锁代替总线锁来处理。
    • 因为锁总线的开销比较大,锁总线期间其他 CPU 没法访问内存。
    • 这种场景多缓存的数据一致通过缓存一致性协议(MESI)来保证。

2.2.1.2 缓存一致性

  • 缓存是分段(line)的,一个段对应一块存储空间,称之为缓存行,它是 CPU 缓存中可分配的最小存储单元,大小 32 字节、64 字节、128 字节不等,这与 CPU 架构有关,通常来说是 64 字节。
  • LOCK# 因为锁总线效率太低,因此使用了多组缓存。
    • 为了使其行为看起来如同一组缓存那样。因而设计了 缓存一致性协议
    • 缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于 " 嗅探(snooping)" 协议。
  • 所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线。
  • 缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个 CPU 缓存可以读写内存)。
  • CPU 缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。
  • 当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。
    • 只要某个处理器写内存,其它处理器马上知道这块内存在它们的缓存段中已经失效。

2.2.2 volatile 有序性实现

2.2.2.1 volatile 的 happens-before 关系

  • happens-before 规则中有一条是 volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
//假设线程A执行writer方法,线程B执行reader方法
class VolatileExample {
    int a = 0;
    volatile boolean flag = false;
    
    public void writer() {
        a = 1;              // 1 线程A修改共享变量
        flag = true;        // 2 线程A写volatile变量
    } 
    
    public void reader() {
        if (flag) {         // 3 线程B读同一个volatile变量
        int i = a;          // 4 线程B读共享变量
        ……
        }
    }
}
  • 根据 happens-before 规则,上面过程会建立 3 类 happens-before 关系。
    • 根据程序次序规则:1 happens-before 2 且 3 happens-before 4。
    • 根据 volatile 规则:2 happens-before 3。
    • 根据 happens-before 的传递性规则:1 happens-before 4。
VolatileExample
  • 因为以上规则,当线程 A 将 volatile 变量 flag 更改为 true 后,线程 B 能够迅速感知。

2.2.2.2 volatile 禁止重排序

  • 为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。JMM 提供了内存屏障阻止这种重排序。
  • Java 编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。
  • JMM 会针对编译器制定 volatile 重排序规则表。
volatile 重排序规则表
  • " NO " 表示禁止重排序。
    • 为了实现 volatile 内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
    • 对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM 采取了保守的策略。
      • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
      • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
      • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
      • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。
  • volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。
内存屏障说明
StoreStore 屏障 禁止上面的普通写和下面的 volatile 写重排序。
StoreLoad 屏障 防止上面的 volatile 写与下面可能有的 volatile 读/写重排序。
LoadLoad 屏障 禁止下面所有的普通读操作和上面的 volatile 读重排序。
LoadStore 屏障 禁止下面所有的普通写操作和上面的 volatile 读重排序。
 
volatile 写插入内存屏障
 
volatile 读插入内存屏障

2.3 volatile 的应用场景

  • 使用 volatile 必须具备的条件

    • 对变量的写操作不依赖于当前值。
    • 该变量没有包含在具有其他变量的不变式中。
  • 只有在状态真正独立于程序内其他内容时才能使用 volatile。

  • 模式 #1 状态标志

    • 也许实现 volatile 变量的规范使用仅仅是使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机。
volatile boolean shutdownRequested;
......
public void shutdown() { shutdownRequested = true; }
public void doWork() { 
    while (!shutdownRequested) { 
        // do stuff
    }
}
  • 模式 #2 一次性安全发布(one-time safe publication)
    • 缺乏同步会导致无法实现可见性,这使得确定何时写入对象引用而不是原始值变得更加困难。在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。(这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象)。
public class BackgroundFloobleLoader {
    public volatile Flooble theFlooble;
 
    public void initInBackground() {
        // do lots of stuff
        theFlooble = new Flooble();  // this is the only write to theFlooble
    }
}
 
public class SomeOtherClass {
    public void doWork() {
        while (true) { 
            // do some stuff...
            // use the Flooble, but only if it is ready
            if (floobleLoader.theFlooble != null) 
                doSomething(floobleLoader.theFlooble);
        }
    }
}
  • 模式 #3:独立观察(independent observation)
    • 安全使用 volatile 的另一种简单模式是定期 发布 观察结果供程序内部使用。例如,假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。
public class UserManager {
    public volatile String lastUser;
 
    public boolean authenticate(String user, String password) {
        boolean valid = passwordIsValid(user, password);
        if (valid) {
            User u = new User();
            activeUsers.add(u);
            lastUser = user;
        }
        return valid;
    }
}
  • 模式 #4 volatile bean 模式
    • 在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须非常普通 —— 除了获取或设置相应的属性外,不能包含任何逻辑。此外,对于对象引用的数据成员,引用的对象必须是有效不可变的。(这将禁止具有数组值的属性,因为当数组引用被声明为 volatile 时,只有引用而不是数组本身具有 volatile 语义)。对于任何 volatile 变量,不变式或约束都不能包含 JavaBean 属性。
@ThreadSafe
public class Person {
    private volatile String firstName;
    private volatile String lastName;
    private volatile int age;
 
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }
 
    public void setFirstName(String firstName) { 
        this.firstName = firstName;
    }
 
    public void setLastName(String lastName) { 
        this.lastName = lastName;
    }
 
    public void setAge(int age) { 
        this.age = age;
    }
}
  • 模式 #5 开销较低的读-写锁策略
    • volatile 的功能还不足以实现计数器。因为 ++x 实际上是三种操作(读、添加、存储)的简单组合,如果多个线程凑巧试图同时对 volatile 计数器执行增量操作,那么它的更新值有可能会丢失。
    • 如果读操作远远超过写操作,可以结合使用内部锁和 volatile 变量来减少公共代码路径的开销。
    • 安全的计数器使用 synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销。
@ThreadSafe
public class CheesyCounter {
    // Employs the cheap read-write lock trick
    // All mutative operations MUST be done with the 'this' lock held
    @GuardedBy("this") private volatile int value;
 
    public int getValue() { return value; }
 
    public synchronized int increment() {
        return value++;
    }
}
  • 模式 #6 双重检查(double-checked)

    • 单例模式的一种实现方式,但很多人会忽略 volatile 关键字,因为没有该关键字,程序也可以很好的运行,只不过代码的稳定性总不是 100%,说不定在未来的某个时刻,隐藏的 bug 就出来了。
class Singleton {
    private volatile static Singleton instance;
    public static Singleton getInstance() {
        if (instance == null) {
            syschronized(Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    } 
}
  • 推荐懒加载优雅写法 Initialization on Demand Holder(IODH)。
public class Singleton {  
    static class SingletonHolder {  
        static Singleton instance = new Singleton();  
    }  
      
    public static Singleton getInstance(){  
        return SingletonHolder.instance;  
    }  
}

4.6 final 关键字

final 域的重排序规则

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

写 final 域的重排序规则

  • JMM 禁止编译器把 final 域的写重排序到构造函数之外。
    • final 成员变量必须在声明的时候初始化或者在构造器中初始化,否则就会报编译错误。
    • 被 final 修饰的字段在声明时或者构造器中,一旦初始化完成,那么在其他线程无须同步就能正确看见 final 字段的值。
  • 编译器会在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 屏障。这个屏障禁止处理器把 final 域的写重排序到构造函数之外。

读 final 域的重排序规则

  • 初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序。编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障。

  • 当构造函数结束时,final 类型的值是被保证其他线程访问该对象时,它们的值是可见的。

  • final 类型的成员变量的值,包括那些用 final 引用指向的 collections 的对象,是读线程安全而无需使用 synchronized 的。

4.6.1 使用 final 的限制条件和局限性

  • 当声明一个 final 成员时,必须在构造函数退出前设置它的值。
public class MyClass {
  private final int myField = 1;
  public MyClass() {
    ...
  }
}

或者

public class MyClass {
  private final int myField;
  public MyClass() {
    ...
    myField = 1;
    ...
  }
}
  • 将指向对象的成员声明为 final 只能将该引用设为不可变的,而非所指的对象。

下面的方法仍然可以修改该 list。

private final List myList = new ArrayList();
myList.add("Hello");

声明为 final 可以保证如下操作不合法

myList = new ArrayList();
myList = someOtherList;
  • 如果一个对象将会在多个线程中访问并且你并没有将其成员声明为 final,则必须提供其他方式保证线程安全。
  • " 其他方式 " 可以包括声明成员为 volatile,使用 synchronized 或者显式 Lock 控制所有该成员的访问。

4.7 synchronized 关键字

  • synchronized 是 Java 中的关键字,是一种同步锁。
    • 用来修饰一个代码块,被修饰的代码块称为 同步语句块,其作用的范围是 大括号 {} 括起来的代码,作用的对象是 大括号中的对象。一次只有一个线程进入该代码块,此时,线程获得的是 成员锁
    • 用来修饰一个方法,被修饰的方法称为 同步方法,锁是当前 实例对象。线程获得的是 成员锁,即一次只能有一个线程进入该方法,其他线程要想在此时调用该方法,只能排队等候。
    • 用来修饰一个静态的方法,其作用的范围是整个 静态方法,锁是当前 Class 对象。线程获得的是 对象锁,即一次只能有一个线程进入该方法(该类的所有实例),其他线程要想在此时调用该方法,只能排队等候。
  • 当 synchronized 锁住一个对象后,别的线程如果也想拿到这个对象的锁,就必须等待这个线程执行完成释放锁,才能再次给对象加锁,这样才达到线程同步的目的。
  • 在使用 synchronized 关键字的时候,能缩小代码段的范围就尽量缩小,能在 代码段 上加同步就不要再整个方法上加同步。
  • 无论 synchronized 关键字加在方法上还是对象上,它取得的锁都是对象,而不是把一段代码或函数当作锁。而且同步方法很可能还会被其他线程的对象访问。
  • 实现同步是要很大的系统开销作为代价的,甚至可能造成死锁,所以尽量避免无谓的同步控制。

1.1 双重检查锁实现单例

  • 使用 volatile 变量,保证先行发生关系(happens-before relationship)。对于 volatile 变量 singleton,所有的写(write)都将先行发生于读(read),在 Java 5 之前使用双重检查锁是有问题的。
  • 第一次校验不是线程安全的,也就是说可能有多个线程同时得到 singleton 为 null 的结果,接下来的同步代码块保证了同一时间只有一个线程进入,而第一个进入的线程会创建对象,等其他线程再进入时对象已创建就不会继续创建。
class LockSingleton{
    private volatile static LockSingleton singleton;
    private LockSingleton(){}
    public static LockSingleton getInstance(){
        if(singleton==null){
            synchronized(LockSingleton.class){
                if(singleton==null){
                    singleton=new LockSingleton();
                }
            }
        }
        return singleton;
    }
}

1.2 枚举实现单例

  • Java 中的枚举和其它语言不同,它是一个对象。早期的 Java 是没有枚举类型的,用类似于单例的方式来实现枚举,简单的说就是让构造 private 化,在 static 块中产生多个 final 的对象实例,通过比较引用(或 equals)来进行比较,这种模式跟单例模式相似。

  • 早期用类的方式实现的枚举

public class MyEnum {
    public static MyEnum NumberZero;
    public static MyEnum NumberOne;
    public static MyEnum NumberTwo;
    public static MyEnum NumberThree;

    static {
        NumberZero = new MyEnum(0);
        NumberOne = new MyEnum(1);
        NumberTwo = new MyEnum(2);
        NumberThree = new MyEnum(3);
    }

    private final int value;

    private MyEnum(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }
}
  • 从 Java 5 开始有枚举类型之后,类似的实现
public enum MyEnum {
    NumberZero(0),
    NumberOne(1),
    NumberTwo(2),
    NumberThree(3);

    private final int value;

    MyEnum(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }
}
  • 更简单的实现方式
public enum MyEnum {
    NumberZero,
    NumberOne,
    NumberTwo,
    NumberThree;

    public int getValue() {
        return ordinal();
    }
}
  • 枚举的单例实现
enum EnumSingleton{
    INSTANCE;
    public void doSomeThing(){
    }
}

1.3 synchronized 的实现原理

  • JVM 中的同步(synchronized )基于进入和退出管程(Monitor)对象实现,无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块)还是隐式同步都是如此。

    • 在 Java 语言中,同步用的最多的地方是被 synchronized 修饰的同步方法。
      • 但是同步方法并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现。
  • Java 对象保存在内存中时,由以下三部分组成。

    • Java 对象头。
    • 实例数据
    • 对齐填充字节。
  • Java 对象头 和 Monitor 是实现 synchronized 的基础。

1.3.1 Java 对象头

  • Java 对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
    • Klass Pointer 是对象指向它的类(Class)元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
    • Mark Word 用于存储对象自身的运行时数据。
      • 如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。
  • 对象头一般占有 两个机器码(在 32 位虚拟机中,1 个机器码等于 4 字节,也就是 32 bit),但是如果对象是数组类型,则需要 三个机器码,因为 JVM 可以通过 Java 对象的元数据信息确定 Java 对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度
  • 对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word 会随着程序的运行发生变化,变化状态如下(32 位虚拟机)。
 
对象头信息
  • synchronized 是重量级锁,保存了指向 Monitor 的指针。

1.3.2 Monitor(管程或监视器锁)

  • Monitor 的重要特点是,同一个时刻,只有一个进程/线程能进入 Monitor 中定义的 临界区,这使得 Monitor 能够达到 互斥 的效果。
    • 但仅仅有互斥的作用是不够的,无法进入 Monitor 临界区的进程/线程,它们应该被阻塞,并且在必要的时候会被唤醒。
    • Monitor 作为一个同步工具,也提供了这样的管理进程/线程状态的机制。
  • Monitor 机制需要几个元素来配合。
    • 临界区。
    • Monitor 对象及锁。
    • 条件变量以及定义在 Monitor 对象上的 wait,signal 操作。

临界区

  • 被 synchronized 关键字修饰的方法、代码块,就是 Monitor 机制的临界区。

Monitor 对象 / 锁 / 条件变量等

  • synchronized 关键字在使用的时候,往往需要指定一个对象与之关联。这个对象就是 Monitor 对象。
    • Monitor 的机制中,Monitor 对象充当着维护 mutex 以及定义 wait/signal API 来管理线程的阻塞和唤醒的角色。
    • Java 语言中的 java.lang.Object 类,便是满足这个要求的对象,任何一个 Java 对象都可以作为 Monitor 机制的 Monitor 对象。
  • java.lang.Object 类定义的 wait(),notify(),notifyAll() 方法,这些方法的具体实现,依赖于一个叫 ObjectMonitor(内置锁) 模式的实现,这是 JVM 内部基于 C++ 实现的一套机制,基本原理如下。
 
ObjectMonitor
  • 当一个线程需要获取 Object 的锁时,会被放入 EntrySet 中进行等待,如果该线程获取到了锁,成为当前锁的 owner。如果根据程序逻辑,一个已经获得了锁的线程缺少某些外部条件,而无法继续进行下去(例如生产者发现队列已满或者消费者发现队列为空),那么该线程可以通过调用 wait 方法将锁释放,进入 wait set 中阻塞进行等待,其它线程在这个时候有机会获得锁,去干其它的事情,从而使得之前不成立的外部条件成立,这样先前被阻塞的线程就可以重新进入 EntrySet 去竞争锁。这个外部条件在 Monitor 机制中称为条件变量。

1.3.2.1 Monitor 与 Java对象及线程的关联

  • 如果一个 Java 对象被某个线程锁住,则该 Java 对象的 Mark Word 字段中 LockWord 指向 Monitor 的起始地址。
  • Monitor 的 owner 字段存放拥有相关联对象锁的线程 ID。

1.3.2.2 ObjectMonitor(内置锁) 的具体实现

  • 在 Java 虚拟机(HotSpot)中,ObjectMonitor 的主要数据结构如下(位于 HotSpot 虚拟机源码 ObjectMonitor.hpp 文件,C++ 实现)
ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
}
  • ObjectMonitor 中有两个队列,_WaitSet 和 _EntryList,用来保存 ObjectWaiter 对象列表( 每个等待锁的线程都会被封装成 ObjectWaiter 对象),_owner 指向持有 ObjectMonitor 对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的 Monitor 后进入 _owner 区域并把 Monitor 中的 owner 变量设置为当前线程,同时 Monitor 中的计数器 count 加 1。若线程调用 wait() 方法,将释放当前持有的 Monitor,owner 变量恢复为 null,count 自减 1,同时该线程进入 WaitSet 集合中等待被唤醒。若当前线程执行完毕也将释放 Monitor(锁)并复位变量的值,以便其他线程进入获取 Monitor(锁)。
ObjectMonitor 方法说明
enter方法 获取锁。
exit 方法 释放锁。
wait 方法 为 Java 的 Object 的 wait 方法提供支持。
notify 方法 为 Java 的 Object 的 notify 方法提供支持。
notifyAll 方法 为 Java 的 Object 的 notifyAll 方法提供支持。

显式同步

  • 从字节码中可知同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,执行同步代码块后首先要先执行 monitorenter 指令,退出的时候执行 monitorexit 指令。
  • 值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。
    • 为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。
  • 编写一个简单 Java 类。
public class Test {
    public static void main(String[] args) {
        synchronized (Test.class) {
        }
    }
}
  • 通过 javap -v 查看编译字节码。
......
public static void main(java.lang.String[]);
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: ldc_w         #2                  
         3: dup           
         4: astore_1      
         5: monitorenter     // 同步模块开始   
         6: aload_1       
         7: monitorexit        // 同步模块结束
         8: goto          16
        11: astore_2      
        12: aload_1       
......

隐式同步

  • 方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。
  • JVM 可以从方法常量池中的方法表结构(method_info Structure)中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。
    • 当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有 Monitor, 然后再执行方法,最后在方法完成(无论是正常完成还是异常完成)时释放 Monitor。
    • 方法执行期间,执行线程持有了 Monitor,其他任何线程都无法再获得同一个 Monitor。
    • 如果一个同步方法执行期间抛出异常,并且在方法内部无法处理此异常,那这个同步方法所持有的 Monitor 将在异常抛到同步方法之外时自动释放。
  • 编写一个简单 Java 类。
public class Test {
    public static void main(String[] args) {
        test();
    }

    public synchronized static void test() {
    }
}
  • 通过 javap -v 查看编译字节码。
......
public static synchronized void test();
    flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED     // 检查访问标志
    Code:
      stack=0, locals=0, args_size=0
         0: return        
      LineNumberTable:
        line 13: 0
......

1.3.3 类型指针

  • 对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,如果对象访问定位方式是句柄访问,那么该部分没有,如果是直接访问,该部分保留。

句柄访问方式

 
句柄访问方式

直接访问方式

 
直接访问方式

1.3.4 synchronized 的语义

  • synchronized 同时保证了线程在同步块之前或者期间写入动作,对于后续进入该代码块的线程是可见的(对同一个 Monitor 对象而言)。在一个线程退出同步块时,线程释放 Monitor 对象,它的作用是把 CPU 缓存数据(本地缓存数据)刷新到主内存中,从而实现该线程的行为可以被其它线程看到。在其它线程进入到该代码块时,需要获得 Monitor 对象,它在作用是使 CPU 缓存失效,从而使变量从主内存中重新加载,然后就可以看到之前线程对该变量的修改。
  • synchronized 还有一个语义是禁止指令的重排序(不改变程序的语义的情况下,编译器和执行器可以为了性能优化代码执行顺序),对于编译器来说,同步块中的代码不会移动到获取和释放 Monitor 的外面。
  • 对于多个线程,同步块中的对象,必须是同一个对象,在相同的 Monitor 对象上同步才能够正确的设置 happens-before 关系。

1.4 synchronized 的优化

  • 通过 synchronzied 实现同步用到了对象的内置锁(ObjectMonitor),而在 ObjectMonitor 的函数调用中会涉及到 mutex lock 等特权指令,那么这个时候就存在操作系统用户态和核心态的转换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,这也是早期 synchronized 效率低的原因。在 JDK 1.6 之后,从 JVM 层面做了很大的优化。

1.4.1 CAS

  • CAS(Compare and Swap),即比较并替换,是非阻塞算法 (nonblocking algorithms,一个线程的失败或者挂起不应该影响其他线程的失败或挂起的算法)。
  • CAS 有 3 个操作数,内存值(假设为 V),预期值(假设为 A),修改的新值(假设为 B)。当且仅当预期值 A 和内存值 V 相同时,将内存值 V 修改为 B,否则什么都不做。
  • Unsafe,是 CAS 的核心类,通过调用 JNI 的代码实现,通过本地(native)方法来访问,Unsafe 可以直接操作特定内存的数据。
  • 利用 CPU 的 CAS 指令,同时借助 JNI 来完成 Java 的非阻塞算法。其它原子操作都是利用类似的特性完成的。而整个 J.U.C 都是建立在 CAS 之上的,因此对于 synchronized 阻塞算法,J.U.C 在性能上有了很大的提升。
/**
     * Atomically update Java variable to <tt>x</tt> if it is currently
     * holding <tt>expected</tt>.
     * @return <tt>true</tt> if successful
     */
    public final native boolean compareAndSwapInt(Object o, long offset,
                                                  int expected,
                                                  int x);
  • Unsafe 类中的 compareAndSwapInt,是一个本地方法,该方法的实现位于 unsafe.cpp 中。
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
  UnsafeWrapper("Unsafe_CompareAndSwapInt");
  oop p = JNIHandles::resolve(obj);
  jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
  return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

1.4.1.1 CAS 存在的问题

  • ABA 问题。
    • 因为 CAS 需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是 A,变成了 B,又变成了 A,那么使用 CAS 进行检查时会发现它的值没有发生变化,但是实际上却变化了。
    • ABA 问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么 A->B->A 就会变成 1A->2B->3A。
    • 从 Java1.5 开始 JDK 的 atomic 包里提供了一个类 AtomicStampedReference 来解决 ABA 问题。这个类的 compareAndSet 方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
  • 循环时间长开销大。
    • 自旋 CAS 如果长时间不成功,会给 CPU 带来非常大的执行开销。
    • 如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升,pause 指令有两个作用。
      • 第一它可以延迟流水线执行指令(de-pipeline),使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。
      • 第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起 CPU 流水线被清空(CPU pipeline flush),从而提高 CPU 的执行效率。
  • 只能保证一个共享变量的原子操作。
    • 当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。
    • 比如有两个共享变量 i=2,j=a,合并一下 ij=2a,然后用 CAS 来操作 ij。从 Java1.5 开始
      JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行 CAS 操作。

1.4.2 偏向锁

  • Java 偏向锁(Biased Locking)是 Java 6 引入的一项多线程优化。
    • 它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程不需要触发同步,这种情况下,会给线程加一个偏向锁。
    • 在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程被挂起,JVM 会消除它身上的偏向锁,将锁升级到标准的轻量级锁。
    • 它通过消除资源无竞争情况下的同步,进一步提高了程序的运行性能。
    • 撤销偏向锁的时候会导致 stop the world 操作,高并发的应用应禁用掉偏向锁
  • 开启偏向锁 -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
  • 关闭偏向锁 -XX:-UseBiasedLocking

1.4.2.1 偏向锁的获取

 
偏向锁的获取
  • 步骤 1. 访问 Mark Word 中偏向锁的标识是否设置成 1,锁标志位是否为 01,确认为可偏向状态。
  • 步骤 2. 如果为可偏向状态,则测试线程 ID 是否指向当前线程。
    • 如果是,进入步骤 5,
    • 否则进入步骤 3。
  • 步骤 3. 如果线程 ID 并未指向当前线程,则通过 CAS 操作竞争锁。
    • 如果竞争成功,则将 Mark Word 中线程 ID 设置为当前线程 ID,然后执行步骤 5。
    • 如果竞争失败,执行步骤 4。
  • 步骤 4. 如果 CAS 获取偏向锁失败,则表示有竞争。
    • 当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致 stop the world,时间很短)
  • 步骤 5. 执行同步代码。

1.4.2.2 偏向锁的释放

 
偏向锁的释放
  • 偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁(等待竞争出现才释放锁的机制),线程不会主动去释放偏向锁。
    • 偏向锁的撤销,需要等待全局安全点(这个时间点上没有字节码正在执行),暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为 " 01 ")或轻量级锁(标志位为 " 00 ")的状态。

1.4.2.3 安全点停顿日志

  • 要查看安全点停顿,可以打开安全点日志,通过设置 JVM 参数。

    • -XX:+PrintGCApplicationStoppedTime,打印出系统停止的时间。
    • -XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1,打印出详细信息,可以查看到使用偏向锁导致的停顿,时间非常短暂,但是争用严重的情况下,停顿次数也会非常多。
    • 在生产系统上还需要增加四个参数。
      • -XX:+UnlockDiagnosticVMOptions
      • -XX: -DisplayVMOutput
      • -XX:+LogVMOutput
      • -XX:LogFile=/dev/shm/vm.log
  • 两个运行的线程同时执行同步代码块,就能出现偏向锁撤销操作,造成安全点停顿。

    • 默认是偏向锁是关闭的,需要开启偏向锁才能看到日志。
         vmop                    [threads: total initially_running wait_to_block]    [time: spin block sync cleanup vmop] page_trap_count
0.036: EnableBiasedLocking              [       7          0              1    ]      [     0     0     0     0     0    ]  0   
Total time for which application threads were stopped: 0.0000860 seconds, Stopping threads took: 0.0000180 seconds
         vmop                    [threads: total initially_running wait_to_block]    [time: spin block sync cleanup vmop] page_trap_count
0.071: RevokeBias                       [       9          0              1    ]      [     0     0     0     0     0    ]  0   
Total time for which application threads were stopped: 0.0000810 seconds, Stopping threads took: 0.0000220 seconds
         vmop                    [threads: total initially_running wait_to_block]    [time: spin block sync cleanup vmop] page_trap_count
0.071: RevokeBias                       [       9          0              1    ]      [     0     0     0     0     0    ]  0   
Total time for which application threads were stopped: 0.0001330 seconds, Stopping threads took: 0.0001090 seconds
         vmop                    [threads: total initially_running wait_to_block]    [time: spin block sync cleanup vmop] page_trap_count
0.071: no vm operation                  [       7          1              1    ]      [     0     0     0     0    10    ]  0   
  • RevokeBias 就是撤销偏向锁造成的安全点停顿。
参数说明
vmop Java 虚拟机操作类型(时间戳:操作类型)。
threads 线程概况(安全点里的总线程数(total) ;安全点开始时正在运行状态的线程数(initially_running) ;在 Java 虚拟机操作开始前需要等待其暂停的线程数(wait_to_block))。
time 执行操作时间(等待线程响应 safepoint 号召的时间(spin);暂停所有线程所用的时间(block);等于 spin + block,这是从开始到进入安全点所耗的时间,可用于判断进入安全点耗时(sync);清理所用时间(cleanup);真正执行 Java 虚拟机操作的时间(vmop))。

1.4.2.4 偏向锁小结

  • 一个对象刚开始实例化的时候,没有任何线程来访问它的时候,它是可偏向的,当第一个
    线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。线程在修改对象头成为偏向锁的时候使用 CAS 操作,并将对象头中的 ThreadID 改成自己的 ID,之后再次访问这个对象时,只需要对比 ID,不需要再使用 CAS 在进行操作。
  • 一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象是偏向状态,这时表明在这个对象上已经存在竞争了,检查原来持有该对象锁的线程是否依然存活,如果挂了,则将对象变为无锁状态,然后重新偏向新的线程。
  • 如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的)。如果不存在使用了,则可以将对象回复成无锁状态,然后重新偏向。

1.4.3 轻量级锁

  • 轻量级锁是由偏向锁升级而来,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁。

1.4.3.1 轻量级锁的加锁过程

  • 步骤 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 ",即表示此对象处于轻量级锁定状态。
 
图 A 线程堆栈与对象头的状态
  • 步骤 5. 如果更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为 " 10 " ,Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。

1.4.3.2 轻量级锁的释放

从释放锁的线程(已经获得轻量级锁的线程)的角度理解

  • 轻量级锁释放时,会使用原子的 CAS 操作来将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争(在持有锁的期间有其他线程来尝试获取锁了,并且该线程对 Mark Word 做了修改),锁就会膨胀成重量级锁。
 
轻量级锁的释放
 
轻量级锁的膨胀

从尝试获取锁线程的角度理解

  • 如果线程尝试获取锁的时候,轻量锁正被其他线程占有,那么它就会修改 Mark Word,修改为重量级锁,表示该进入重量锁了。
    • 这个修改是通过自旋完成的,自旋达到一定次数 CAS 操作仍然没有成功,才会进行修改。(轻量锁的线程不会阻塞,会一直自旋等待锁

1.4.3.3 轻量级锁小结

  • 当获取到锁的线程执行同步体之内的代码的时候,另一个线程也完成了上面创建锁记录空间,将对象头中的 Mark Word 复制到自己的锁记录中,尝试用 CAS 将对象头中的 Mark Word修改为指向自己的锁记录的指针,但是由于之前获取到锁的线程已经将 Mark Word 中的记录修改过了(并且现在还在执行同步体中的代码),与这个现在试图将 Mark Word 替换为自己的锁记录的线程自己的锁记录中的 Mark Word 的值不符,CAS 操作失败,因此这个线程就会不停地循环使用 CAS 操作试图将 Mark Word 替换为自己的记录。这个循环是有次数限制的,如果在循环结束之前 CAS 操作成功,那么该线程就可以成功获取到锁,如果循环结束之后依然获取不到锁,则锁获取失败,Mark Word 中的记录会被修改为指向重量级锁的指针,然后这个获取锁失败的线程就会被挂起,阻塞了。
  • 当持有锁的那个线程执行完同步体之后想用 CAS 操作将 Mark Word 中 的记录改回它自己的栈中最开始复制的记录的时候会发现 Mark Word 已被修改为指向重量级锁的指针,因此 CAS
    操作失败,该线程会释放锁并唤起阻塞等待的线程,开始新一轮夺锁之争,而此时,轻量级锁已经膨胀为重量级锁,所有竞争失败的线程都会阻塞,而不是自旋。
  • 轻量级锁一旦膨胀为重量级锁,则不可逆转。因为轻量级锁状态下,自旋是会消耗 CPU 的,但是锁一旦膨胀,说明竞争激烈,大量线程都做无谓的自旋对 CPU 是一个极大的浪费。

1.4.4 重量级锁

1.4.4.1 重量级锁的获取

 
重量级锁的获取

1.4.4.2 重量级锁的释放

 
重量级锁的释放

1.4.4.3 各种锁比较

优点缺点适用场景
偏向锁 加锁解锁不需要额外消耗 如果线程间存在竞争,会带来额外的锁撤销消耗 一个线程访问同步块的场景
轻量级锁 竞争线程不会阻塞,提高程序响应速度 得不到锁竞争的线程,会自旋消耗 CPU 追求响应时间,同步块执行速度很快的场景
重量级锁 线程竞争不适用自旋,不消耗 CPU 线程阻塞,响应速度慢 追求吞吐量,同步块执行时间较长的场景

1.4.5 自旋锁

  • 如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
  • 线程自旋是需要消耗 CPU 的,如果一直获取不到锁,那线程也不能一直占用 CPU 自旋做无用功,所以需要设定一个自旋等待的最大时间。
  • 如果持有锁的线程执行的时间超过自旋等待的最大时间仍没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。
  • 自旋锁的开启
    • JDK 1.6 中 -XX:+UseSpinning 开启自旋锁。-XX:PreBlockSpin=10 设置自旋次数。
    • JDK 1.7后,去掉此参数,由 JVM 控制。

1.4.5.1 自旋锁的优缺点

  • 自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为 自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生 两次上下文切换
  • 如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用 CPU 做无用功,同时有大量线程竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要 CPU 的线程又不能获取到 CPU,造成 CPU 的浪费。所以这种情况下需要关闭自旋锁。

1.4.5.2 自旋锁时间阈值

  • 自旋锁的目的是为了占着 CPU 的资源不释放,等到获取到锁立即进行处理。
  • 在 JDK 1.6 引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不在是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。
    • 如果平均负载小于 CPUs 则一直自旋。
    • 如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞。
    • 如果正在自旋的线程发现 owner 发生了变化则延迟自旋时间(自旋计数)或进入阻塞。
    • 如果 CPU 处于节电模式则停止自旋。
    • 自旋时间的最坏情况是 CPU 的存储延迟(CPU A 存储了一个数据,到 CPU B 得知这个数据直接的时间差)
    • 自旋时会适当放弃线程优先级之间的差异。

1.4.6 锁的优化

减少锁的时间

  • 不需要同步执行的代码,能不放在同步快里面执行就不要放在同步快内,让锁尽快释放。

减少锁的粒度

  • 将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。(用空间来换时间)
    • 拆锁的粒度不能无限拆,最多可以将一个锁拆为当前 CPU 数量个锁即可。

锁粗化

  • 循环内的操作需要加锁,应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率会非常差。

使用读写锁

  • ReentrantReadWriteLock 是一个读写锁,读操作加读锁,可以并发读,写操作使用写锁,只能单线程写。

读写分离

  • CopyOnWrite 容器即写时复制的容器。当往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行 Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是可以对 CopyOnWrite 容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以 CopyOnWrite 容器也是一种读写分离的思想,读和写不同的容器。
    • CopyOnWrite 并发容器用于读多写少的并发场景,因为,读的时候没有锁,但是对其进行更改的时候是会加锁的,否则会导致多个线程同时复制出多个副本,各自修改各自的。

使用 CAS

  • 如果需要同步的操作执行速度非常快,并且线程竞争并不激烈,这时候使用 CAS 效率会更高,因为加锁会导致线程的上下文切换,如果上下文切换的耗时比同步操作本身更耗时,且线程对资源的竞争不激烈,使用 volatiled + cas 操作会是非常高效的选择。

消除缓存行的伪共享

  • 在 JDK 1.8 中通过添加 sun.misc.Contended 注解来解决这个问题。若要使该注解有效必须在 JVM 中添加以下参数。
    • -XX:-RestrictContended
  • sun.misc.Contended 注解会在变量前面添加 128 字节的 padding 将当前变量与其他变量进行隔离。

消除锁

  • 消除锁是 JVM 一种锁的优化,这种优化更彻底,JVM 在 JIT 编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。
    • 如 StringBuffer 的 append 是一个同步方法,但是在 add 方法中的 StringBuffer 属于一个局部变量,并且不会被其他线程所使用,因此 StringBuffer 不可能存在共享资源竞争的情景,JVM 会自动将其锁消除。

1.5 synchronized 的关键点

synchronized 的可重入性

  • 从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态。
  • 当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功。
    • synchronized 是基于原子性的内部锁机制,是可重入的。
    • 一个线程调用 synchronized 方法的同时在其方法体内部调用该对象另一个 synchronized 方法,一个线程得到一个对象锁后再次请求该对象锁,是允许的。

中断与 synchronized

  • 对于 synchronized 来说,如果一个线程在等待锁,那么结果只有两种,要么它获得这把锁继续执行,要么它就保存等待,即使调用中断线程的方法,也不会生效。

等待唤醒机制与 synchronized

  • notify / notifyAll /wait 方法,必须处于 synchronized 代码块或者 synchronized 方法中,否则就会抛出 IllegalMonitorStateException 异常。
    • 因为调用这几个方法前必须拿到当前对象的 Monitor 对象,也就是说 notify / notifyAll / wait 方法依赖于 Monitor 对象。
    • Monitor 存在于对象头的 Mark Word 中(存储 Monitor 的引用指针),而 synchronized 关键字可以获取 Monitor。

4.8 锁

  • 锁释放和获取的内存语义。

    • 当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中。
    • 当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须要从主内存中去读取共享变量。
  • concurrent 包的源代码实现通用化的实现模式。

    • 首先,声明共享变量为 volatile。
    • 然后,使用 CAS 的原子条件更新来实现线程之间的同步。
    • 同时,配合以 volatile 的读/写和 CAS 所具有的 volatile 读和写的内存语义来实现线程之间的通信。
  • AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic 包中的类),这些 concurrent 包中的基础类都是使用这种模式来实现的,而 concurrent 包中的高层类又是依赖于这些基础类来实现的。

concurrent 包的源码实现

4.9 long 和 double 型变量

  • Java 内存模型要求 lock、unlock、read、load、assign、use、store、write 这 8 种操作都具有原子性。但是对于 64 位的数据类型(long 和 double),在模型中特别定义相对宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作分为 2 次 32 位的操作来进行。也就是说虚拟机可选择不保证 64 位数据类型的 load、store、read 和 write 这 4 个操作的原子性。
    • 由于这种非原子性,有可能导致其他线程读到同步未完成的 " 32 位的半个变量 " 的值。
    • 不过实际开发中,Java 内存模型强烈建议虚拟机把 64 位数据的读写实现为具有原子性。
    • 目前各种平台下的商用虚拟机都选择把 64 位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不需要把用到的 long 和 double 变量专门声明为 volatile。

4.10 常见处理器内存模型

  • JMM 是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性内存模型是一个理论参考模型。
  • 如果完全按照顺序一致性模型来实现处理器和 JMM,那么很多处理器和编译器优化都要被禁止,这对性能将会有很大的影响。
  • 根据不同类型的读/写操作组合的执行顺序的放松,可以把常见处理器的内存模型划分为以下几种类型。
    • 放松程序中写-读操作的顺序。产生 Total Store Ordering 内存模型(TSO)。
    • 在上面的基础上,继续放松程序中的写-写操作的顺序。产生 Partial Store Order 内存模型(PSO)。
    • 在前面两条的基础上,继续放松程序中的读-写 和 读-读操作的顺序。产生 Relaxed Memory Order 内存模型(RMO)和 PowerPc 内存模型。
  • 注意:这里处理器对读/写的放松,是以两个操作之间不存在数据依赖性为前提的。
内存模型的强弱对比
  • 常见处理器内存模型的细节特征。
内存模型名称对应的处理器Store-Load 重排序Store-Store重排序Load-Load 和Load-Store重排序可以更早读取到其它处理器的写可以更早读取到当前处理器的写
TSO sparc-TSO X64 Y     Y
PSO sparc-PSO Y Y     Y
RMO ia64 Y Y Y   Y
PowerPC PowerPC Y Y Y Y Y

4.11 伪共享

  • CPU 缓存系统中是以缓存行(cache line)为单位存储的。
    • 目前主流的 CPU 缓存的缓存行大小都是 64 Bytes。
    • 在多线程情况下,如果需要修改 " 共享同一个缓存行的变量 ",就会无意中影响彼此的性能,这就是伪共享(False Sharing)。

CPU 的三级缓存

  • 由于 CPU 的速度远远大于内存速度,所以 CPU 设计者们就给 CPU 加上了缓存(CPU 缓存),以免运算被内存速度拖累。
  • CPU 缓存分成了三个级别:L1,L2,L3。
    • 级别越小越接近 CPU,速度更快, 同时容量越小。
    • CPU 获取数据是依次从 L1,L2,L3 中查找,找不到则直接向内存查找。

缓存行

  • 由于共享变量在 CPU 缓存(Cache)中的存储是以缓存行(Cache line)为单位,一个缓存行可以存储 多个变量(存满当前缓存行的字节数)。
    • 每个缓存行除了包含数据,还包含 TAG(地址信息)和状态信息(MESI 协议)。
    • 而 CPU 对缓存的修改又是以 缓存行 为最小单位,所以会出现伪共享问题。
  • 今天的 CPU 不再按字节访问内存,大部分 x86 CPU 是以 64 字节 为单位的块(chunk)拿取,称为一个缓存行(Cache line),可以理解为 CPU 缓存中的最小缓存单位。
  • 当读一个特定的内存地址,整个缓存行将从主存换入(拷贝至)缓存,并且访问同一个缓存行内的其它值的开销会很小,例如。
int[] arr = new int[64 * 1024 * 1024];
long start = System.nanoTime();
for (int i = 0; i < arr.length; i++) {
     arr[i] *= 3;
}
System.out.println(System.nanoTime() - start);
long start2 = System.nanoTime();
for (int i = 0; i < arr.length; i += 16) {
      arr[i] *= 3;
}
System.out.println(System.nanoTime() - start2);
  • 表面上看,第二个循环工作量为第一个循环的 1/16,但是执行时间却相差不大。
    • 假设在内存规整的情况下,每 16 个 int 占用 4 × 16 = 64 字节,正好一个缓存行,那么两个循环访问内存的次数是一致的,导致耗时相差不大。

缓存关联方式(Associativity)

  • 缓存的替换策略决定了主存中的数据块会拷贝到缓存中的某个位置,目前常用的缓存设计是 N 路组关联(N-Way Set Associative Cache),即内存中的某一块数据可能在缓存中的 N 个位置出现。
    • 原理是把一个缓存(Cache)按照 N 个缓存行(Cache line)作为一组(Set),缓存按组划为等分。每个内存块能够被映射到相对应的 Set 中的任意一个缓存行中。
      • 比如一个 16 路缓存,16 个缓存行作为一个 Set,每个内存块能够被映射到 相对应的 Set 中的 16 个缓存行中的任意一个
      • 一般具有一定相同低 bit 位地址的内存块将共享同一个 Set。
  • 一个 2 组 2 路关联缓存如下。
 
2 组 2 路关联缓存
  • 缓存分成两组,每组包含两个缓存行(Cache line),也称为两路(2 ways)。
  • 主存中的每个数据块只能位于两个 Set 中的某一个,但可以在指定 Set 中的任意一个缓存行中。
  • AMD Athlon 的 L1 cache 所采用的就是类似这种 2 路组相联的映射方式。

伪共享问题

  • 假设多核 CPU 在多线程情况下,x 和 y 两个共享变量在同一个缓存行(Cache line)中,核 a 修改变量 x,会导致核 b,核 c 中的 x 和 y 变量同时失效。
  • 核 a 上运行的线程,仅仅只是修改了变量 x,却导致同一个缓存行中的所有变量都无效,需要重新刷缓存(并不一定代表每次都要从内存中重新载入,也有可能是从其他缓存中导入数据,具体的实现要看各个芯片厂商的实现)。
  • 假设此时在核 b 上运行的线程,正好也修改变量 y,那么就会出现相互竞争,相互失效的情况,这就是 伪共享

伪共享的传统解决方案

public final class Test implements Runnable {

    private final static int num = 4;

    private final static long arrayValue = 500L * 1000L * 1000L;

    private final int arrayIndex;

    private static VolatileLong[] longs = new VolatileLong[num];

    static {
        for (int i = 0; i < longs.length; i++) {
            longs[i] = new VolatileLong();
        }
    }

    public Test(int arrayIndex) {
        this.arrayIndex = arrayIndex;
    }

    public static void main(String[] arg) throws InterruptedException {
        final long start = System.nanoTime();
        runTest();
        System.out.println("duration = " + (System.nanoTime() - start));
    }

    private static void runTest() throws InterruptedException {
        Thread[] threads = new Thread[num];

        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new Test(i));
        }

        for (Thread t : threads) {
            t.start();
        }

        for (Thread t : threads) {
            t.join();
        }

    }

    @Override
    public void run() {
        long i = arrayValue;
        while (0 != --i) {
            longs[arrayIndex].value = i;
        }
    }

    public final static class VolatileLong {
        public volatile long value = 0L;
        public long p1, p2, p3, p4, p5, p6;
    }
}
  • 执行结果。
16526073665
  • 如果注释掉 VolatileLong 中不使用的 6 个 long 变量
public final static class VolatileLong {
        public volatile long value = 0L;
        // public long p1, p2, p3, p4, p5, p6;
}
  • 执行结果
31183015124
  • 两个程序逻辑完全一致,只是注释了几个没有使用到的变量,导致性能相差很大。
  • 一条缓存行有 64 字节, 而 Java 程序的对象头固定占 8 字节(32 位系统)或 12 字节(64 位系统默认开启压缩),不开压缩为 16 字节。
  • 为对象 VolatileLong 增加 6 个无用的长整型,6 × 8 = 48 字节。
  • 让不同的 VolatileLong 对象处于不同的缓存行,这样就可以避免伪共享。
  • 这种办法叫做补齐(Padding)。

Java 8 中的解决方案

  • Java 8 中已经提供了官方的解决方案。
    • 新增了一个注解 @sun.misc.Contended
    • 加上这个注解的类会自动补齐缓存行,需要注意的是此注解默认是无效的,需要在 JVM 启动时设置 -XX:-RestrictContended 才会生效。
@sun.misc.Contended
public final static class VolatileLong {
        public volatile long value = 0L;
        // public long p1, p2, p3, p4, p5, p6;
}

 

 

参考来源

https://www.jianshu.com/p/64240319ed60
https://www.jianshu.com/p/d3fda02d4cae
https://www.jianshu.com/p/d52fea0d6ba5
https://blog.csdn.net/suifeng3051/article/details/52611310
http://www.cnblogs.com/zhiji6/p/10037690.html
https://blog.csdn.net/hanmindaxiongdi/article/details/81159314

 

发布了195 篇原创文章 · 获赞 2 · 访问量 6752
展开阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 大白 设计师: CSDN官方博客

分享到微信朋友圈

×

扫一扫,手机浏览