写点什么

Java- 技术专题 -volatile 关键字

发布于: 2020 年 10 月 30 日
Java-技术专题-volatile关键字

1.多线程下变量不可见性

多线程并发执行下,多个线程修改共享的成员变量,会出现一个线程修改了共享变量的值后,另一个线程不能直接看到该线程修改后的变量的最新值。

示例:

public class MyThread extends Thread {

// 定义成员变量

private boolean flag = false ;

public boolean isFlag() { return flag;}

@Override

public void run() {

try {

Thread.sleep(1000);

} catch (InterruptedException e) {

e.printStackTrace();

}

// 将flag的值更改为true

this.flag = true ;

System.out.println("flag=" + flag);

}

}



public class VolatileThreadDemo {

public static void main(String[] args) {

// 创建MyThread线程对象

Thread t = new MyThread() ;

t.start();

// main方法

while(true) {

if(((MyThread) t).isFlag()) {

System.out.println("执行了======");

}

}

}

}

//控制台打印:flag=true



我们看到,子线程中已经将flag设置为true,但main()方法中始终没有读到修改后的最新值,从而循环没有能进入到if语句中执行,所以没有任何打印;

结论:多线程下修改共享变量会出现变量修改值后的不可见性。

2.变量不可见性内存语义

多线程并发修改变量不可见现象的原因之前,我们需要了解回顾一下Java内存模型(和Java并发编程有关的模型):JMM

JMM(Java Memory Model)Java内存模型,是java虚拟机规范中所定义的一种内存模型,Java内存模型是标准化的,屏蔽掉了底层不同计算机的区别;

Java内存模型描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节。

3.JMM有以下规定

  • 共享变量都存储于主内存。这里所说的变量指的是实例变量和类变量。不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题;

  • 每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本;

  • 线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量;

  • 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量值传递需要通过主内存中转来完成;

本地内存和主内存的关系

执行流程



  1. 子线程t从主内存读取到数据放入其对应的工作内存;

  2. 将flag的值更改为true,但是这个时候flag的值还没有写回主内存;

  3. 此时main方法读取到了flag的值为false;

  4. 当子线程t将flag的值写回去后,但是main函数里面的while(true)调用的是系统比较底层的代码,速度

快,快到没有时间再去读取主存中的值;

所以while(true)读取到的值一直是false。(如果有一个时刻main线程从主内存中读取到了主内存中flag的最新值,那么if语句就可以执行,main线程何时从主内存中读取最新的值,我们无法控制)。

可见性问题的原因:所有共享变量存在于主内存中,每个线程由自己的本地内存,而且线程读写共享数据也是通过本地内存交换的,所以才导致了可见性问题!

变量不可见性解决方案

如何实现在多线程下访问共享变量的可见性:也就是实现一个线程修改变量后,对其他线程可见呢?接下来为大家介绍两种方案:

第一种是加锁,第二种是使用volatile关键字;

synchronized加锁:

// main方法

while(true) {

synchronized (t) {

if(((MyThread) t).isFlag()) {

System.out.println("执行了======");

}

}

}

某一个线程进入synchronized代码块前后,执行过程入如下:

  1. 线程获得锁

  2. 清空工作内存

  3. 从主内存拷贝共享变量最新的值到工作内存成为副本

  4. 执行代码

  5. 将修改后的副本的值刷新回主内存中

  6. 线程释放锁

volatile关键字修饰:

private volatile boolean flag ;





  1. 子线程t从主内存读取到数据放入其对应的工作内存

  2. 将flag的值更改为true,但是这个时候flag的值还没有写回主内存

  3. 此时main方法main方法读取到了flag的值为false

  4. 当子线程t将flag的值写回去后,失效其他线程对此变量副本(总线嗅探监控机制-对应的内存块)

  5. 再次对flag进行操作的时候线程会从主内存读取最新的值,放入到工作内存中


总结: volatile保证不同线程对共享变量操作的可见性,也就是说一个线程修改了volatile修饰的变量,当修改写回主内存时,另外一个线程立即看到最新的值!

volatile的其他特性

volatile除了可以保证可见性外,volatile还具备如下一些突出的特性:

  1. volatile不能保证原子性操作

  2. volatile可以防止指令重排序操作

volatile不保证原子性

所谓的原子性是指在一次操作或者多次操作中,要么所有的操作全部都得到了执行并且不会受到任何因素的干扰而中断,要么所有的操作都不执行。volatile不保证原子性。

public class VolatileAtomicThread implements Runnable{
// 定义一个int类型的遍历
private int count = 0 ;
@Override
public void run() {
// 对该变量进行++操作,100次
for(int x = 0 ; x < 100 ; x++) {
count++ ;
System.out.println("count =========>>>> " + count);
}
}
}

public class VolatileAtomicThreadDemo {
public static void main(String[] args) {
// 创建VolatileAtomicThread对象
VolatileAtomicThread volatileAtomicThread = new VolatileAtomicThread() ;
// 开启100个线程对count进行++操作
for(int x = 0 ; x < 100 ; x++) {
new Thread(volatileAtomicThread).start();
}
}
}

执行结果:不保证一定是10000

以上问题主要是发生在count++操作上:

count++操作包含3个步骤:

  1. 从主内存中读取数据到工作内存

  2. 对工作内存中的数据进行++操作

  3. 将工作内存中的数据写回到主内存

count++操作不是一个原子性操作,也就是说在某一个时刻对某一个操作的执行,有可能被其他的线程打断

流程
  1. 假设此时x的值是100,线程A需要对改变量进行自增1的操作,首先它需要从主内存中读取变量x的值。由于CPU的切换关系,此时CPU的执行权被切换到了B线程。A线程就处于就绪状态,B线程处于运行状态

  2. 线程B也需要从主内存中读取x变量的值,由于线程A没有对x值做任何修改因此此时B读取到的数据还是100

  3. 线程B工作内存中x执行了+1操作,但是未刷新之主内存中

  4. 此时CPU的执行权切换到了A线程上,由于此时线程B没有将工作内存中的数据刷新到主内存,因此A线程工作内存中的变量值还是100,没有失效,A线程对工作内存中的数据进行了+1操作

  5. 线程B将101写入到主内存

  6. 线程A将101写入到主内存

虽然计算了2次,但是只对A进行了1次修改;

总结:在多线程环境下,volatile关键字可以保证共享数据的可见性,但是并不能保证对数据操作的原子性(在多线程环境下volatile修饰的变量也是线程不安全的)。在多线程环境下,要保证数据的安全性,我们还需要使用锁机制。

使用锁机制

我们可以给count++操作添加锁,那么count++操作就是临界区的代码,临界区只能有一个线程去执行,所以count++就变成了原子操作。

public class VolatileAtomicThread implements Runnable{

// 定义一个int类型的变量

private volatile int count = 0 ;

private static final Object obj = new Object();
@Override

public void run() {

// 对该变量进行++操作,100次

for(int x = 0 ; x < 100 ; x++) {

synchronized (obj) {

count++ ;

System.out.println("count =========>>>> " + count);

}

}

}

}

观察控制台会发现结论始终会是10000!

禁止指令重排序

什么是重排序:为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序。

原因:一个好的内存模型实际上会放松对处理器和编译器规则的束缚,也就是说软件技术和硬件技术都为同一个目标而进行奋斗:在不改变程序执行结果的前提下,尽可能提高执行效率。JMM对底层尽量减少约束,使其能够发挥自身优势。因此,在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。一般重排序可以分为如下三种:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序

  2. 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序

  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的



重排序的好处:重排序可以提高处理的速度!





重排序虽然可以提高执行的效率,但是在并发执行下,JVM虚拟机底层并不能保证重排序下带来的安全性等问题!

volatile修饰变量后可以实现禁止指令重排序!

4.volatile与synchronized的区别



  • volatile只能修饰实例变量和类变量,而synchronized可以修饰方法,以及代码块

  • volatile保证数据的可见性,但是不保证原子性(多线程进行写操作,不保证线程安全);而synchronized是一种排他(互斥)的机制

  • volatile用于禁止指令重排序:可以解决单例双重检查对象初始化代码执行乱序问题

  • volatile可以看做是轻量版的synchronized,volatile不保证原子性,但是如果是对一个共享变量进行多个线程的赋值,而没有其他的操作,那么就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了



用户头像

我们始于迷惘,终于更高的迷惘. 2020.03.25 加入

一个酷爱计算机技术、健身运动、悬疑推理的极客狂人,大力推荐安利Java官方文档:https://docs.oracle.com/javase/specs/index.html

评论

发布
暂无评论
Java-技术专题-volatile关键字