线程安全之volatile还能这么学?

并发中的变量可见性
变量可见性、线程安全问题根因
保证变量可见性的方式
synchronized关键字解密
volatile关键字解密
总结

并发中的变量可见性问题

什么是并发中的变量可见性问题呢? 一个线程对共享变量值的修改,能够及时地被其他线程看到。下面通过一个小例子加以说明,代码逻辑就是,通过共享变量,在一个线程中控制另一个线程的执行流程。请问:线程会停止循环,打印出i的值吗?


public class VisibilityDemo {

    // 状态标识
    private static boolean flag = true;

    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                int i = 0;
                while (VisibilityDemo.flag) {
                    i++ ;
                }
                System.out.println(i);
            }
        }).start();

        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 设置flag为false, 使上面的线程结束while循环
        VisibilityDemo.flag = false;
        System.out.println("flag被置为false了");
    }
}

打印的结果:flag被置为false了

按照代码逻辑,不是2秒后,终止循环,要打印出i的值吗?为什么没有按剧本走呢?说明while循环没有终止,在主线程更改了变量flag的值,但是在new的子线程中,变量法flag的值根本没有更新,也就是说共享变量flag的值在子线程不可见,并发中线程能不能看到变量的最新值,这就是并发中的变量可见性的问题。

那么问题来了,为什么会不可见?怎样才能可见呢?

  • 方式一:synchronize关键字
public class VisibilityDemo {

    // 状态标识
    private static boolean flag = true;

    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                int i = 0;
                while (VisibilityDemo.flag) {
                	// i++;
                	// 方式一
                	synchronized(this) {
                    	i++ ;
                	}
                }
                System.out.println(i);
            }
        }).start();

        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 设置flag为false, 使上面的线程结束while循环
        VisibilityDemo.flag = false;
        System.out.println("flag被置为false了");
    }
}

打印的结果:flag被置为false了
100317209

  • 方式二:volatile关键字
public class VisibilityDemo {
    // 状态标识
//    private static boolean flag = true;

    // 方式二
    private static volatile boolean flag = true;

    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                int i = 0;
                while (VisibilityDemo.flag) {
                    i++;
                }
                System.out.println(i);
            }
        }).start();

        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 设置flag为false, 使上面的线程结束while循环
        VisibilityDemo.flag = false;
        System.out.println("flag被置为false了");
    }
}

打印的结果:

flag被置为false了
-1760723396

这两种方式,都使new的子线程看见了变量flag值的改变,但是一个打印的是一个正的整数,一个打印的是一个负的整数,这又是为什么呢?负数是因为i++已经溢出了,i++执行的次数已经大于int类型的最大值了,说明volatile效率比synchronize效率高。那么问题又来了,为什么使用synchronize和volatile变量就可见了呢?

变量可见性、线程安全问题根因

为了回答这个问题,我们需要了解Java内存模型(Java memory mode 简称JMM)的操作规范

  • 共享变量必须存放在主内存中;
  • 线程有自己的工作内存,线程只可操作自己的工作内存
  • 线程操作共享变量,需要从主内存读取(拷贝)到工作内存,改变值后需要从工作内存同步(写入)到主内存中。

在这里插入图片描述

相信很容易让人联想到java虚拟机(Java virtual machine 简称JVM ),这与JVM的内存中的堆区,栈区等是否有联系呢?首先JMM内存模型是java语言规定的,JVM是JVM规范定的,前者是一种逻辑概念,后者一种物理划分,在学习JVM的相关知识的时候,线程私有的栈区,本地方法栈,程序计数器,对应的就是图中工作内存,而线程共享的堆区,方法区,对应的就是主内存。

想象一下,如何让线程2使用A时看到的是最新值?

  • 线程1修改A后必须立马同步回主内存
  • 线程2使用A前需要重新从主内存读取到工作内存

疑问1:使用前不会重新从主内存读取到工作内存吗?

疑问2:修改后不会立马同步到主内存吗?

的的确确,确确实实,实实在在是不会。相信前面的变量可见性的例子,已经很好的说明了这个问题。这是因为java内存模型中同步交互协议,规定了8种原子操作:

原子操作:是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束。

  • lock(锁定):将主内存中的变量锁定,为一个线程所独占
  • unlock(解锁):将lock加的锁定解除,此时其它的线程可以有机会访问此变量
  • read(读取):作用于内存变量,将主内存中的变量值读取到工作内存当中
  • load(载入):作用于工作内存变量,将read读取的值保存到工作内存中的变量副本中
  • use(使用):作用于工作内存变量,将值传递给线程的代码执行引擎
  • assign(赋值):作用于工作内存变量,将执行引擎处理返回的值重新赋值给变量副本
  • store(存储):作用于工作内存变量,将变量副本的值传送到主内存中
  • write(写入):作用于主内存变量,将store传送过来的值写入到主内存的共享变量中

java内存模型-同步交互协议,操作规范还有另外两条:

  • 将一个变量从主内存复制到工作内存要顺序执行read、load操作;要将变量从工作内存同步到主内存要顺序执行store、write操作。只要求顺序执行,不一定是连续执行
  • 做了assign操作,必须同步回主内存,不能没做assign,同步回主内存

具体流程如下图:

在这里插入图片描述
lock和unlock很好理解,如果多个线程同时操作,有可能一个线程在read,一个线程在write,数据会出现严重错误。从主内存读取到工作内存中实际上是分两步的,首先是从主内存read到寄存器中,然后从寄存器中load到工作内存中,use和assign就是使用变量,进行一些列的运算赋值,然后通过store先返回到寄存器,然后write到工作内存中。这每一步都是原子操作,但是从主内存到读到工作内存需要两个步骤read和load,从工作内存写入主内存也是分两步,由两个操作合在一起,它还是原子操作吗?不是,CPU执行read后,它就让出时间片,没有执行load,在这段时间,别人可能改了它的值。正是由于java的内存模型,它固有的这个问题,导致了线程安全问题和变量的可见性问题。

保证变量可见性的方式

1.final变量

// final不可变变量

private final int var = 1;

2.synchronized

while (xxx) {

​ synchronized(this) {

​ i++;

​ }

}

3.用volatile修饰

// 状态标识

private static volatile boolean is = true;

synchronized关键字解密

可见性

synchronized语义规范

  • 1.进入同步块前,先清空工作内存中的共享变量,从主内存中重新加载

  • 2.解锁前必须把修改的共享变量同步回主内存

synchronized是如何做到线程安全的?

  • 1.锁机制保护资源共享,只有获得锁的线程才可以操作共享资源

  • 2.synchronized语义规范保证了修改共享资源后,会同步回主内存,就做到了线程安全。

synchronized一定能保证可见性和线程安全吗?
不一定,只有多个线程抢的是同一把锁,才能保证可见和性线程安全。如果不是同一把锁,两个线程的同步代码块都可以操作共享资源,线程1已经修改了共享变量的值,但是线程1还没有解锁,但是线程2此时进入同步代码块,共享变量还是初始值,并不是线程1修改后的值,这显然不能保证可见性和线程安全。

原子性

原子性:通常指多个操作不存在只执行一部分的情况,如果全部执行完成那没毛病,如果只执行了一部分,那对不起,你得撤销(即事务中的回滚)已经执行的部分。比如 a=0;(a非long和double类型) 这个操作是不可分割的,那么我们说这个操作时原子操作。再比如:a++; 这个操作实际是a = a + 1;是可分割的,所以他不是一个原子操作。非原子操作都会存在线程安全问题,需要我们使用同步技术(sychronized)来让它变成一个原子操作。

一个变量在同一时刻只允许一条线程对其进行lock操作,获取对象锁,互斥排他性达到两个同步块串行执行。通过这种控制线程串行执行间接的实现了原子性。

synchronize无禁止指令重排。

volatile关键字解密

volatile语义规范:
  • 使用volatile变量时,必须重新从主内存加载,并且read、load是连续的
  • 修改volatile变量后,必须马上同步回主内存,并且store、write是连续的
可见性

保证了read、load和use的操作连续性,assign、store和write的操作连续性,从而达到工作内存读取前必须刷新主存最新值;工作内存写入后必须同步到主存中。读取的连续性和写入的连续性,看上去像线程直接操作了主内存。

非原子性

volatile本身并不对数据运算处理维持原子性,强调的是读写及时影响主内存。

volatile禁止指令重排序

指令重排:为了提高性能,编译器和和处理器通常会对指令进行指令重排序。
在这里插入图片描述
图中的三个重排位置可以调换的,根据系统优化需要进行重排。遵循的原则是单线程重排后的执行结果要与顺序执行结果相同。

内存屏障指令:volatile在指令之间插入内存屏障,保证按照特定顺序执行和某些变量的可见性。

volatile就是通过内存屏障通知cpu和编译器不做指令重排优化来维持有序性。

线程A和线程B的部分代码:
在这里插入图片描述
jvm优化指令重排序后,代码的执行顺序可能如下:
在这里插入图片描述

当两个线程并发执行时,就可能出现线程B抛空指针异常

当我们在变量上加volatile修饰时,则用到该变量的代码块中就不会进行指令重排序

volatile能保证线程安全吗?

答案是不能,从volatile语义来看,可以肯定的是volatile可以保证可见性,但是它没有锁机制,线程可以并发操作共享资源。如果有3个线程同时访问变量int A = 0,线程1执行 A+1,线程执行A+2,线程执行A+3,如果线程1readA=0,此时还没有将结果同步回主内存,此时线程2,3又访问A = 0,想想看,主内存中A的最终结果,取决于哪个线程最后同步回主内存,这显然无法保证线程安全。

既然如此synchronized可以保证可见性,为什么要用volatile呢?

在有些不需要考虑线程安全的前提下有如下两点:

  • 使用volatile比synchronized简单
  • volatile性能比synchronized稍好(前面可见性打印i的值就说明了这一点)
volatile的使用场景

volatile的使用范围

  • volatile只可修饰成员变量(静态的、非静态)
  • 多线程并发下,才需要使用它

典型的使用场景:volatile与synchronize配合使用

public 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来修饰?

按照上边的写法已经对new Singleton();这个操作进行了synchronize操作,已经保证了多线程只能串行执行这个实例化代码。事实上,synchronize保证了线程执行实例化这段代码是串行的,但是Synchronize并不具备禁止指令重排的特性。

而instance = new Singleton(); 主要做了3件事情:

(1) java虚拟机为对象分配一块内存x。

(2) 在内存x上为对象进行初始化 。

(3) 将内存x的地址赋值给instance 变量。

如果编译器进行重排为:

(1) java虚拟机为对象分配一块内存x。

(2) 将内存x的地址赋值给instance 变量。

(3) 在内存x上为对象进行初始化 。

第一种情况,无volatile修饰:此时,有两个线程执行getInstance()方法,加入线程A进入代码的注释中的第②处,synchronized代码块的非空判断,并执行到了重排指令的(2),与其同时线程B刚好代码注释中的第①处,synchronized代码块外面的if判断。此时,instance有线程A把内存地址x地址赋值给了instance,那么instance已经不为空只是没有初始化完成,线程B就返回了一个没有完成初始化的instance,最终使用时候会出现空指针的错误。

第二种情况,有volatile修饰:instance因为被volatile的禁止指令重排的特性,那只会安装先初始化对象再赋值给instance这样顺序执行,这样就能保证返回正常的实例化的对象。

总结

  • volatile具有可见性和有序性(禁止指令重排序),不能保证原子性。

  • volatile在特定情况下线程安全,比如自身不做非原子性运算。

  • synchronize通过获取对象锁,保证代码块串行执行,间接保证原子性

  • synchronize无禁止指令重排能力。

  • synchronize只有多个线程抢的是同一把锁,才能保证可见和性线程安全

  • DCL双重锁校验单例操作需要volatile和synchronize保证线程安全。

原创文章 23 获赞 30 访问量 9573

猜你喜欢

转载自blog.csdn.net/my_csdnboke/article/details/103947795