volatile

  1. 前面我们讲过的 JMM、Happen-before,JMM 是规范,有个细则叫 happen-before ,用来保证有序性的是 volatile、synchronized 关键字来捍卫
  2. volatile 凭什么可以保证有序性和可见性,靠的是内存屏障,内存屏障分为 loadload、StoreLoad、LoadStore、StoreStore

被 volatile 修饰的变量有2大特点

  • 特点:可见性、有序性、不保证原子性

  • volatile的内存语义

  • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值 立即刷新回主内存 中。

  • 当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从 主内存中读取共享变量

    所以 volatile 的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取

内存屏障

什么是内存屏障

  1. 内存屏障(也称内存栅栏 , 内存栅障 , 屏障指令等 , 是一类同步屏障指令 , 是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作),避免代码重排序。内存屏障其实就是一种JVM指令,Java 内存模型的重排规则会要求 Java 编译器在生成 JVM 指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile 实现了 Java 内存模型中的可见性和有序性,但 volatile 无法保证原子性

  2. 内存屏障之前的所有写操作都要回写到主内存,内存屏障之后的所有读操作都能获得。内存屏障之前的所有写操作的最新结果(实现了可见性)

  3. 一句话:对一个 volatile 域的写, happens-before 于任意后续对这个 volatile 域的读,也叫写后读

内存屏障源码分析

接口规范实现,落地

  • 落地是由 volatile 关键字,而 volatile 关键字靠的是 StoreStore、StoreLoad 、LoadLoad、LoadStore 四条指令
  • 当我们的 java程序的变量被 volatile 修饰之后,会添加一个 ACC_VOLATI LE ,JVM 会把字节码生成为机器码的时候,发现操作是volatile 量的话,就会根据JVM要求,在相应的位置去插入内存屏障指令

四大屏障

1661419450033

volatile变量规则

  • volatile 读操作之前,如果是 普通读写 那么可以重排,如果是 volatile 读写 就不可以重排(重排会导致写操作有可能被排序到 volatile 读之前,读就可能会读错,违反了原则)
  • volatile 读操作之后,什么操作 都不可以重排(同上)
  • volatile 写操作之前,什么操作 都不可以重排(重排会导致 读操作或者写操作排在 volatile 写之后,也会读错或者改错,违反规则)
  • volatile 写操作之后,如果是 普通读写 那么可以重排,如果是 volatile 读写 就不可以重排(同上)

1661419590255

内存屏障插入策略

    • 在每个volatile写操作的前面插⼊⼀个 StoreStore 屏障
    • 在每个volatile写操作的后面插⼊⼀个 StoreLoad 屏障

1661420253237

    • 在每个volatile读操作的前面插⼊⼀个LoadLoad屏障
    • 在每个volatile读操作的后面插⼊⼀个LoadStore屏障

1661420306634

代码演示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package tech.chen.juccode.a12;
import java.util.concurrent.TimeUnit;
/**
* @Date 2022/8/25 17:39
* @Author c-z-k
*/
public class VolatileDemo {
public static boolean flag = true;
public static void main(String[] args) {
new Thread(()->{
System.out.println(Thread.currentThread().getName() + "\t com in");
while (flag){

}
System.out.println(Thread.currentThread().getName() + "\t end in");
},"t1").start();

try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}

flag = false;
}
}

运行结果:

1661420594660

红灯不熄灭,停不下来。

用 volatile 修饰 flag 后:

运行结果:

1661420643033

volatile 特性

保证可见性

保证证不同线程对这个变量进行操作时的可见性,即变量一旦改变所有线程立即可以看到

代码展示

不加 volatile,没有可见性,程序无法停止
加了 volatile,保证可见性,程序可以停止

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package tech.chen.juccode.a12;
import java.util.concurrent.TimeUnit;
/**
* @Date 2022/8/25 17:39
* @Author c-z-k
*/
public class VolatileDemo {
public volatile static boolean flag = true;
public static void main(String[] args) {
new Thread(()->{
System.out.println(Thread.currentThread().getName() + "\t com in");
while (flag){

}
System.out.println(Thread.currentThread().getName() + "\t end in");
},"t1").start();

try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = false;
}
}

代码解释:

  1. 如果 flag 不加 volatile 修饰,当主线程 运行 flag = false; 时,t1 线程 访问的 flag 仍然是线程内部的缓存为 true ,这就是不可见。
  2. 添加 volatile 后,主线程改变 flag 时,所有其他线程持有该 flag值 的缓存都会失效 ,while 循环会从主存中再次读取 flag 发现 已经为 false,这就是可见。

不保证原子性

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package tech.chen.juccode.a12;

/**
* @Date 2022/8/28 14:18
* @Author c-z-k
*/
public class VolatileDemo2 {
private final static int SIZE = 50;
public static void main(String[] args) {
AddClass addClass = new AddClass();
for (int i = 0; i < SIZE; i++) {
new Thread(()->{
for (int j = 0; j < 100; j++) {
addClass.add();
}
},i+"").start();
}
System.out.println(addClass.getNumber());
}
}

class AddClass{
private volatile int number = 0;
public void add(){
this.number++;
}
public int getNumber(){
return number;
}
}

运行结果:

4783

代码解释:

在 AddClass 中 number 属性是被 volatile 修饰的。主线程模拟高并发场景,同时有50个线程 每个线程对 number++ 100次,理想结果应该是 number ==5000,但是在多次测试后,发现number 的值都不一样。可见 volatile 不保证原子性。

其实也很好理解,volatile 只是通过内存屏障在防止对 volatile 修饰的变量操作时 禁止其前后指令混乱排序。而在 number++ 操作中,有三条指令:1. 获取属性值 2.属性值加一 3.写回主存。在多线程环境下,volatile 虽然可以保证这三步的顺序不会混乱,但是不能保证 有两条及以上的线程 先后都只执行了第一步获取属性值 ,这样在后续的 ++操作和赋值操作都会使主存中的属性值不符合期望。

简单说就是 number++ 本身不是原子操作,它的三个步骤是可能被中断的。而,volatile 不负责保证 所修饰属性的 原子性。

禁止指令重排

  1. 重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段,有时候会改变程序语句的先后顺序(不存在数据依赖关系,可以重排序;存在数据依赖关系,禁止重排序)

  2. 重排序的分类和执行流程

    1. 编译器优化的重排序 : 编译器在不改变单线程串行语义的前提下,可以重新调整指令的执行顺序
    2. 指令级并行的重排序 : 处理器使用指令级并行技术来讲多条指令重叠执行,若不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
    3. 内存系统的重排序 : 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是乱序执行

    1661668677785

  3. 数据依赖性 : 若两个操作访问同一变量,且这两个操作中有一个为写操作,此时两操作间就存在数据依赖性(存在数据依赖关系,禁止重排序===> 重排序发生,会导致程序运行结果不同)

volatile应用

  1. 单一赋值可以 , but 含复合运算赋值不可以(i++之类)

    volatile int a = 10
    volatile boolean flag = false

  2. 状态标志,判断业务是否结束

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public class VolatileDemo {
    public volatile static boolean flag = true;
    public static void main(String[] args) {
    new Thread(()->{
    System.out.println(Thread.currentThread().getName() + "\t com in");
    while (flag){
    }
    System.out.println(Thread.currentThread().getName() + "\t end in");
    },"t1").start();

    try {
    TimeUnit.SECONDS.sleep(2);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    flag = false;
    }
    }
  3. 开销较低的读,写锁策略

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    /**
    * 使用:当读远多于写,结合使用内部锁和 volatile 变量来减少同步的开销
    * 理由:利用 volatile 保证读取操作的可见性;利用 synchronized 保证复合操作的原子性
    */
    public class Counter{
    private volatile int value;
    public int getValue(){
    return value; //利用volatile保证读取操作的可见性
    }
    public synchronized int increment(){
    return value++; //利用synchronized保证复合操作的原子性
    }
    }

  4. 懒加载单例模式

  5. AtomicIntegerFieldUpdater 要求使用 public volatile 修饰的属性。