dcLunatic's blog

volatile关键字

字数统计: 2.6k阅读时长: 9 min
2018/09/21 Share

volatile关键字

内存模型的相关概念

4

如上图所示,在java中,每个线程都拥有自己的本地空间,在需要对共享变量操作时,会将共享变量复制一份到自己的本地空间,操作完成后,在某个时间刷回主内存。线程与线程所拥有的空间是相互独立的,一般情况下是不可见的,这个时候,就会出现一个问题,内存可见性问题

内存可见性问题

先举个例子

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
public class RunThread extends Thread {

private boolean isRunning = true;

public boolean isRunning() {
return isRunning;
}

public void setRunning(boolean isRunning) {
this.isRunning = isRunning;
}

@Override
public void run() {
System.out.println("进入到run方法中了");
while (isRunning == true) {
//System.out.println("Running");
}
System.out.println("线程执行完成了");
}
}

public class Run {
public static void main(String[] args) {
try {
RunThread thread = new RunThread();
thread.start();
Thread.sleep(1000);
thread.setRunning(false);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

本来在thread.setRunning(false);后,线程就应该停止的了。但是在我们运行后,线程一直都在循环当中,无法结束。

这里的话,64位的jdk无须做任何修改(默认就是server方式启动),32位的需要加上-server参数。

仔细的分析一下,有两个线程,一个main线程,一个RunThread线程。他们都会试图操纵isRunning变量。而按照上面所说的,main线程会将isRunning变量读取到本地线程内存空间,修改后,刷会主内存。而RunThread线程则从主内存中获取isRunning变量到本地线程内存空间,通过读取isRunning的值,作为while循环的判断条件。本来到这里是没什么问题的。但是,当JVM是以-server模式与性的时候,线程会一直在私有堆栈中读取变量的值,也就是说,RunThread线程读取的一直都是没有改变过的那个isRunning的值,始终无法读取到main线程改变的isRunning的值。所以这里就出现了死循环了。

如果在while循环中加上一句sout,会发现执行若干次后,程序结束了循环。

这种情况,称为“活性失败”

这里只需要将isRunnig用volatile关键字修饰,使得isRunning在各个线程之间是可见的,会强制线程从主内存中取被volatile修饰的isRunning变量。

原子性问题

所谓原子性,就是某系列的操作要么全部执行,要么全不执行。

比如说i = i + 1;,粗略的可分成三个步骤:

  • 从内存中取出i的值
  • 将i的值加1
  • 将相加后的值放到i中,写回内存

上面用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
public class MyThread extends Thread {
public volatile static int count;

private static void addCount() {
for (int i = 0; i < 100; i++) {
count++;
}
System.out.println("count=" + count);
}

@Override
public void run() {
addCount();
}
}

public class Run {
public static void main(String[] args) {
MyThread[] mythreadArray = new MyThread[100];
for (int i = 0; i < 100; i++) {
mythreadArray[i] = new MyThread();
}

for (int i = 0; i < 100; i++) {
mythreadArray[i].start();
}
}
}

正常的来说,最后count的值应该是10000,但实际上,往往会比这个值要小一些。

比如,假设 i 自增到 5,线程A从主内存中读取i,值为5,将它存储到自己的线程空间中,执行加1操作,值为6。此时,CPU切换到线程B执行,从主从内存中读取变量i的值。由于线程A还没有来得及将加1后的结果写回到主内存,线程B就已经从主内存中读取了i,因此,线程B读到的变量
i 值还是5。有点类似于数据库的脏读了。

这种情况称为“安全性失败”

有序性问题

一般来说,JVM为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

1
2
3
4
int i = 0;
boolean flag = true;
i = 10;
flag = !flag;

在上面,第三句与第四句谁先执行都对程序结果是没有影响的,因为在他们中间也没有其他的语句了,你先我先,都一样的。

JVM在对指令进行重排序的时候,是根据指令之间的数据依赖性来判断是否要重排序的,从而来保证结果没有任何影响。

但是,这仅仅是在单个线程内,如果是在多个线程呢?

1
2
3
4
5
6
7
8
9
10
11
//一些声明
boolean inited = false;
//线程1:
context = loadContext(); //语句1
inited = true; //语句2

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

上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。

那么如果我们在声明inited变量的时候,用volatile关键字修饰,那么就不会存在这个问题,JVM保证volatile修饰的变量不会发生指令重排序,也就是说,volatile修饰的变量会禁止指令重排序。

Java中的有序性:

在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

在Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

下面就来具体介绍下happens-before原则(先行发生原则):

①程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作

②锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作

③volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作

④传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C

⑤线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作

⑥线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生

⑦线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行

⑧对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

这8条规则中,前4条规则是比较重要的,后4条规则都是显而易见的。

下面我们来解释一下前4条规则:

对于程序次序规则来说,就是一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,但是虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。

第二条规则也比较容易理解,也就是说无论在单线程中还是多线程中,同一个锁如果处于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行lock操作。

第三条规则是一条比较重要的规则。直观地解释就是,如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。

第四条规则实际上就是体现happens-before原则具备传递性。

总结

  1. volatile主要用于线程对共享变量的使用的安全问题,使线程每次在获得共享变量的值时,都是从主内存中去获取,而不是从线程的私有内存中读取,从而来保证数据的可见性。

  2. volatile只能修饰变量,不能修饰代码块,方法等(可以考虑使用synchronized关键字)

  3. 仅仅使用volatile的话,是无法保证线程的安全性的,而synchronized可以)

    synchronized不仅保证可见性,而且还保证原子性,因为,只有获得了锁的线程才能进入临界区,从而保证临界区中的所有语句都全部执行。多个线程争抢synchronized锁对象时,会出现阻塞。

  4. 对于多线程中的并发安全问题,volatile关键字是这样子的:

    • 保证内存可见性
    • 不保证原子性
    • 禁止指令的重排序

原文作者:dcLunatic

原文链接:http://dclunatic.github.io/volatile%E5%85%B3%E9%94%AE%E5%AD%97.html

发表日期:September 21st 2018, 1:41:17 pm

更新日期:July 11th 2021, 9:13:50 pm

版权声明:转载的时候,记得注明来处

CATALOG
  1. 1. volatile关键字
    1. 1.1. 内存模型的相关概念
    2. 1.2. 内存可见性问题
    3. 1.3. 原子性问题
    4. 1.4. 有序性问题
    5. 1.5. Java中的有序性:
      1. 1.5.0.0.1. 下面就来具体介绍下happens-before原则(先行发生原则):
  • 1.6. 总结