Java 关键字 volatile、synchronized

volatile, synchronized

1. 内存模型的相关概念

  • CPU 的高速缓存解决了内存读写数据慢的问题。但在多核 CPU 或多线程中, 会有缓存一致性问题:
    • 如果一个变量在多个 CPU中 都存在缓存 ( 此变量称为共享变量 ),那么就可能存在缓存不一致的问题。
  • 通常来说有以下 2 种解决方法 ( 硬件层面上提供的方式 ):
    1. 通过在总线加 LOCK# 锁的方式
      • 在锁住总线期间,其他 CPU 无法访问内存,会导致效率低下
    2. 通过缓存一致性协议
      • 当CPU写数据时,如果发现其他 CPU 中也存在该变量的副本,会发出信号通知其他 CPU 将该变量的缓存行置为无效状态,因此当其他 CPU 需要读取这个变量时会从内存重新读取。

2. 并发编程三大性质

1. 原子性

  • 一个操作或者多个操作, 要么全部执行并且执行的过程不会被任何因素打断, 要么就都不执行。

2. 可见性

  • 当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

3. 有序性

  • 即程序执行的顺序按照代码的先后顺序执行。

  • JVM 优化时可能会发生指令重排序(Instruction Reorder)

    • 不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是会保证程序最终执行结果和代码顺序执行的结果是一致的 ( as-if-serial语义 )。

    • 指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。

    • 以下代码中, 指令重排序可能会导致语句 1 在语句 2 之后执行, 使得线程 2 doSomethingwithconfig(context);context 未初始化导致程序出错

    • 1
      2
      3
      4
      5
      6
      7
      8
      9
      //线程1:
      context = loadContext(); //语句1
      inited = true; //语句2

      //线程2:
      while(!inited ){
      sleep()
      }
      doSomethingwithconfig(context);

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

  • 在 Java 虚拟机规范中试图定义一种 Java 内存模型来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。
  • 在 Java 内存模型中,也会存在缓存一致性问题和指令重排序的问题。

Java 对原子性, 可见性, 有序性提供的保证

1. 原子性

  • 除 long 和 double 外的基本类型的赋值操作
  • 所有引用 reference 的赋值操作
  • java.concurrent.Atomic.* 包中所有类的一切操作

2. 可见性

  • 当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
  • 通过 synchronized 和 Lock 也能够保证可见性,synchronized 和 Lock 能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。

3. 有序性

  • 可以通过 volatile 关键字来保证一定的有序性

  • 可以通过 synchronized 和 Lock 来保证有序性

  • Java 内存模型具备一些先天的“有序性”,称为 happens-before 原则。如果两个操作的执行次序无法从 happens-before 原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

    happens-before原则(先行发生原则), 摘自《深入理解 Java 虚拟机》:

    • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
    • 锁定规则:一个 unLock 操作先行发生于后面对同一个锁的 lock 操作
    • volatile 变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
    • 传递规则:如果操作 A 先行发生于操作 B,而操作 B 又先行发生于操作 C,则可以得出操作 A 先行发生于操作 C
    • 线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每个一个动作
    • 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生
    • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止执行
    • 对象终结规则:一个对象的初始化完成先行发生于他的 finalize() 方法的开始

4. 深入剖析 volatile 关键字

1. volatile 关键字的两层语义

  • 一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰之后,那么就具备了两层语义:

  1. 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

​ 2. 禁止进行指令重排序。

2. volatile 不能保证原子性

3. volatile 能在一定程度上保证有序性

  • volatile 关键字能禁止指令重排序,所以 volatile 能在一定程度上保证有序性。
1
2
3
4
5
6
7
8
// x、y 为非 volatile 变量
// flag 为 volatile 变量

x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
x = 4; //语句4
y = -1; //语句5
  • volatile 关键字能保证,执行到语句 3 时,语句 1 和语句 2 必定是执行完毕了的 ( 1 和 2 的执行顺序不保证 ),且语句 1 和语句 2 的执行结果对语句 3、语句 4、语句 5 是可见的。

4. volatile 的原理和实现机制

volatile 是如何保证可见性和禁止指令重排序, 摘自《深入理解 Java 虚拟机》:

  • “ 观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码发现,加入 volatile 关键字时,会多出一个 lock 前缀指令 ”

  • lock 前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供 3 个功能:

  1. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

  2. 它会强制将对缓存的修改操作立即写入主存;

  3. 如果是写操作,它会导致其他 CPU 中对应的缓存行无效。

5. volatile 的使用场景

  • 保证操作是原子性操作,才能保证使用 volatile 关键字的程序在并发时能够正确执行。
  1. 状态标记量
1
2
3
4
5
6
7
8
9
10
volatile boolean inited = false;
//线程1:
context = loadContext();
inited = true;

//线程2:
while(!inited ){
sleep();
}
doSomethingwithconfig(context);
  1. Double Check
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Singleton{
private volatile static Singleton instance = null;

private Singleton() {

}

public static Singleton getInstance() {
if(instance == null) {
synchronized (Singleton.class) {
if(instance == null)
instance = new Singleton();
}
}
return instance;
}
}
  • 加入 volatile 原因: 由于指令重排, instance = new Singleton(); 语句的内部的实现可能会在完成对象初始化之前就已经将其赋值给 instance 引用,恰好另一个线程进入方法判断 instance 引用不为 null,然后就将其返回使用,导致出错。

5. synchronized 关键字

1. 使用场景

2. 对象锁(monitor)机制

  • 锁的重入性,即在同一锁程中,线程不需要再次获取同一把锁。Synchronized 先天具有重入性。

    1
    2
    3
    4
    5
    6
    7
    8
    class MyClass {
    public synchronized void method1() {
    method2();
    }
    public synchronized void method2() {

    }
    }
  • 每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一。

  • 对于 synchronized 方法或者 synchronized 代码块,当出现异常时,JVM 会自动释放当前线程占用的锁,因此不会由于异常导致出现死锁现象。

参考:

https://www.cnblogs.com/dolphin0520/p/3920373.html

https://juejin.im/post/5ae6dc04f265da0ba351d3ff#heading-1