什么是volatile

volatile是Java提供的一个关键字,是一种轻量级的同步方式,也可以理解为轻量级的synchronized。

码出高效Java开发手册中是这样说道。
volatile是轻量级的同步方式,这种说法是错误的。它只是轻量级的线程操作可见方式,并非同步方式,如果是多写场景,一定会产生线程安全问题。</br>
这里的错误就不修改了,以便提醒自己。

Java内存结构

Java的内存结构其实说的是JVM的运行时数据区。

绿色代表线程私有的内存区域,紫色的代表所有线程共享的内存区域。

Java内存结构在这里就不展开详细说了,这算是JVM的内容了,这里提前主要是提醒一下,不要与下面学习的Java的内存模型混淆,这两个概念说的并不是同一个东西。

JMM

JMM,全称为Java Memory Model,Java内存模型。

Java内存模型是一组规范或者说是规则,每个线程执行都要遵循这个规范,是用来解决在线程的通信问题的。

JMM是一种规范,是一个抽象的概念,并不真实存在,内存结构才是真实存在的。

在讲解JMM之前先要理解两个概念,主内存和工作内存。

《Java虚拟机规范》试图这样进行定义Java内存模型这个概念。

Java内存模型是用来屏蔽各种硬件和操作系统的内存访问的差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

主内存

主内存是Java运行时在计算机存储数据的地方,是所有线程共享的,同时多个线程对这个主内存进行修改,就会出现很多的问题,这就是并发操作的问题,需要我们去解决。

工作内存

每个线程都有一个存储数据的地方,用来存储线程需要操作的数据,为什么要这样呢?

因为线程是不能直接对主内存中的数据进行修改的,只能修改线程工作内存中的数据,所以线程修改主内存中的数据时就会将主内存中的数据保存在自己的工作内存,然后在进行操作。

这样就会存在一个问题,每个线程都会对自己的工作内存进行操作,所以每一个线程都无法得知其他线程工作内存中的数据是怎么样的,这就是一个可见性的问题。

JMM的抽象结构

以下是JMM对主内存数据操作时会执行的八个操作。(按顺序)

  • lock
    将主内存中的数据变量标识为线程独占状态,即对该变量进行加锁操作,其他线程不能对其操作。

  • read
    读取主内存中需要修改的变量,即上个经过加锁操作的变量。

  • load

    将读取到的数据变量载入到线程的工作内存之中。

  • use

    把工作内存中的变量传输给执行引擎,即对该变量进行操作。

  • assign
    执行引擎对变量进行操作之后,将得到的变量的值放回工作内存。

  • store
    将线程工作内存中的变量存储好。

  • write
    将上述存储的变量写入主内存,实现刷新主内存中的值。

  • unlock
    将该变量的锁释放,使其能让其他线程进行操作。

Java内存模型的结构大概率是参考了CPU基于高速缓存的存储交互的设计。</br>
为了提高CPU的效率,添加了高速缓存的概念,用于解决处理器总是等待缓慢的内存读写的问题。在Java内存模型中,工作内存其实就像是一层高速缓存。

JMM的三个特性

Java内存模型就是为了解决对共享数据中的可见性,原子性和有序性问题的一组规则。

即JMM的存在就是为了 保证这三个特性,现在具体来看看这三个特性。

可见性

可见性刚刚也讲工作内存的时候也是有提到的,这个其实很好理解,每个线程中的工作内存经过修改写回主内存之后,其他线程都可以看见主内存中的值发生变化,从而解决一些缓存不一致的情况。

原子性

原子性表示一个操作在执行中是不可以被中断的,有点类似事务的原子性,要么成功完成,要么直接失败。

有序性

有序性表示JMM会保证操作是有序执行的。或许有人会感到疑惑,难道程序不都是有序执行的吗?

这就要说到处理器的指令重排了,这涉及到了一些汇编的知识,所以不怎么展开了,大概了解一下。

为了提高CPU的使用率,在程序编译执行的时候,处理器会将指令进行重排优化,一般分为以下三个。

  1. 编译器优化的重排
  2. 指令并行的重排
  3. 内存系统的重排

指令重排使得语句不一定是按从上到下执行的,可能会是乱序执行的,有些语句是存在数据依赖性的才会保持前后顺序。

为什么单线程的时候没有感觉呢?这是因为指令重排不会干扰到单线程执行的结果的,但是在多线程中乱序执行就会出现一些问题,导致得到的结果不一样。

volatile的特点

根据上述JMM的三个特性来说说volatile的特点。

  1. 保证可见性
  2. 不保证原子性
  3. 禁止指令重排

下面就仔细说说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
27
28
29
30
31
32
33
34
package com.yw;

import java.util.concurrent.TimeUnit;

public class Test{
public static void main(String[] args) {
Data data = new Data();
System.out.println(Thread.currentThread().getName() + "线程开启");
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "线程开启!");
// 保证main线程已经执行到循环的部分
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
data.modifydata();
System.out.println(Thread.currentThread().getName() + "修改了资源值,当前值为" + data.datanum);
},"第二个线程").start();

while(data.datanum == 0) {
// datanum等于0就一直循环
}
System.out.println(Thread.currentThread().getName() + "线程结束");
}
}

class Data {
int datanum = 0;

public void modifydata() {
this.datanum = 100;
}
}
1
2
3
main线程开启
第二个线程线程开启!
第二个线程修改了资源值,当前值为100

这个程序的运行结果可以看到,main线程一直没有结束,一直在while循环里出不来。

因为main线程工作内存中datanum是一开始从主内存中拿到的0,在第二个线程将值改为100并刷新之后,由于main线程没收到通知主内存中的值发生了变化,故一直使用工作内存中的值,故一直会进行循环。

那接下来为这个资源变量增加一个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
27
28
29
30
31
32
33
34
package com.yw;

import java.util.concurrent.TimeUnit;

public class Test{
public static void main(String[] args) {
Data data = new Data();
System.out.println(Thread.currentThread().getName() + "线程开启");
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "线程开启!");
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
data.modifydata();
System.out.println(Thread.currentThread().getName() + "修改了资源值,当前值为" + data.datanum);
},"第二个线程").start();

while(data.datanum == 0) {
// datanum等于0就一直循环
}
System.out.println(Thread.currentThread().getName() + "线程结束");

}
}

class Data {
volatile int datanum = 0;

public void modifydata() {
this.datanum = 100;
}
}
1
2
3
4
main线程开启
第二个线程线程开启!
第二个线程修改了资源值,当前值为100
main线程结束

发现main线程是正常结束了,故可以知道main线程知道主内存中的值发生了改变,故该资源类对所有线程具有了可见性,故可以知道volatile是可以保证可见性的。

那么问题来了,volatile是怎么保证可见性的。

  1. 用volatile修饰的变量在工作内存中被修改之后,就会将该值强制刷新到主内存当中去
  2. 每个线程在修改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
27
28
29
package com.yw;

import java.util.concurrent.CountDownLatch;

public class Test{
public static void main(String[] args) throws InterruptedException {
Data data = new Data();
// 用来辅助确保二十个线程全部执行完毕
CountDownLatch countDownLatch = new CountDownLatch(20);
for (int i = 0; i < 20; i++) {
new Thread(() -> {
// 设置次数要多,太少结果不明显
for (int j = 0; j < 1000; j++) {
data.addData();
}
countDownLatch.countDown();
}).start();
}
countDownLatch.await();
System.out.println("当前资源类的值为----->" + data.datanum);
}
}

class Data {
volatile int datanum = 0;
public void addData() {
datanum++;
}
}

得到的结果按理说应该是20000,但是执行了很多次出来的结果都不是20000,都是比20000少的。这显而易见,每个线程并不能全部完成自己的任务,从而可知volatile不保证原子性。为什么不保证原子性呢?

首先要知道的一点是,自增或者自减其实并不是一个原子性的操作,我们总觉得它是一个语句所以就把它当成了一个原子性操作,这是不对的,在底层i++是有几个指令组成的,现在来探究一下。

首先写一个简单的自增语句。

1
2
3
4
5
6
7
8
9
package com.yw;

public class Test{
public static void main(String[] args) throws InterruptedException {
int i = 0;
i++;
System.out.println(i);
}
}

对该程序进行javap -c的反汇编得到以下的指令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Compiled from "Test.java"
public class com.yw.Test {
public com.yw.Test();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return

public static void main(java.lang.String[]) throws java.lang.InterruptedException;
Code:
0: iconst_0
1: istore_1
2: iinc 1, 1
5: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
8: iload_1
9: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
12: return
}

于是就可以得到i++的指令。

1
2
3
1: istore_1				//	将栈顶int型数值存入第一个本地变量
2: iinc 1, 1 // 将指定int型变量增加指定值
5: getstatic #2 // 获取指定类中的静态域,并将其压入栈顶

所以可以看到自增的指令并不是一条,所以就会有可能出现一些情况导致出现错误。

比如存在两个线程A和B。这两个线程都在各自线程的工作内存获取了相同并正确的值,并执行完iinc的自增指令,并将其压入栈中,但是由于存在线程竞争的情况,将其写回主内存时就会出现这种情况,A抢到时间片将自增写回主内存,时间片结束轮到B线程进行写入,可能会出现执行速度过快,导致没有进行嗅探将线程错误的初始值确认为无效,然后也将工作内存中的值写回主内存,于是就出现了丢失修改的错误。

所以说valatile并不能代替锁进行线程的同步,只要不是原子性的操作,就不能保证线程安全,如果需要保证原子性那就需要用到锁了。

禁止指令重排

指令重排涉及到汇编,结果也会有随机性,只能进行云分析了,先看以下样例。

假设有a,b,x,y四个变量的值为0,然后两个线程对该变量进行修改。

线程A 线程B
x = a y = b
b = 10 a = 10

正常的结果是得到x=0,y=0

可是由于指令重排的存在,所以在小概率的情况下会出现先执行b=1或者是a=2,然后就会导致x与y的值不为0这种错误的结果。

这时候就可以使用volatile来禁止指令重排,使其顺序进行,从而使结果正确。

那么volatile是怎样禁止指令重排的呢?这就涉及到一个新的概念了,内存屏障。

内存屏障

内存屏障分为四类,如表。

屏障类型 指令示例 说明
LoadLoad Barriers Load1;LoadLoad;Load2 确保Load1数据装载先于Load2及所有后续装载指令的装载
StoreStore Barriers Store1;StoreStore;Store2 确保Store1数据对其他处理器可见(刷新到内存)先于Store2及所有后续存储指令的存储
LoadStore Barriers Load1;LoadStore;Store2 确保Load1数据装载先于Store2及所有后续存储指令的刷新到内存
SroreLoad Barriers Store1;StoreLoad;Load2 确保Store1数据对其他处理器可见(刷新到内存)先于Load2及所有后续装载指令的装载
该指令会使屏障之前所有内存访问指令完成之后才执行该屏障之后的内存访问指令

接下来的就是JMM针对编译器制定的volatile重排序规则表。

是否能够重排序 普通读/写 volatile读 volatile写
普通读/写 NO
volatile读 NO NO NO
volatile写 NO NO

左边为第一个操作,右边为第二个操作。比如第三行第二列的NO就表示,如果第一个操作为volatile读,第二个操作为普通读/写,则编译器不能重排序这两个操作。

volatile写

  • 在每个volatile写操作前面插入一个StoreStore Barriers
  • 在每个volatile写操作后面插入一个StoreLoad Barriers

volatile读

  • 在每个volatile读操作后面插入一个LoadLoad Barriers
  • 在每个volatile读操作后面插入一个LoadStore Barriers

参考资料