美文网首页
线程安全和锁机制(二)谈谈volatile

线程安全和锁机制(二)谈谈volatile

作者: 勇敢地追 | 来源:发表于2021-02-17 15:12 被阅读0次

在引入volatile之前有必要先谈谈内存模型

一、计算机内存模型

计算机在执行程序的时候,每条指令都是在CPU中执行的,执行完了把数据存放在主存当中,也就是计算机的物理内存。
刚开始没问题,但是随着CPU技术的发展,执行速度越来越快。而由于内存的技术并没有太大的变化,导致从内存中读写数据比CPU慢,浪费CPU时间。
于是在CPU和内存之间增加高速缓存。这样就引入新的问题:缓存一致性。在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致。
除了这种情况,还有一种硬件问题也比较重要。那就是为了使处理器内部的运算单元能够尽量的被充分利用,处理器可能会对输入代码进行乱序执行处理。这就是处理器优化。
除了现在很多流行的处理器会对代码进行优化乱序处理,很多编程语言的编译器也会有类似的优化,比如Java虚拟机的即时编译器(JIT)也会做指令重排。


计算机内存模型.png

这个模型存在三个问题:缓存一致性,处理器优化,指令重排。

二、Java 内存模型(JMM)

Java虚拟机也有自己的内存模型。Java 内存模型(JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了 Java 程序在各种平台下对内存的访问都能保证效果一致的机制及规范。


Java内存模型.png

Java内存模型有三大特征

  • 原子性是指在一个操作中就是cpu不可以在中途暂停然后再调度,既不被中断操作,要不执行完成,要不就不执行。
  • 可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
  • 有序性即程序执行的顺序按照代码的先后顺序执行。

它还有一个很重要的原则是 happens-before 原则。

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  • 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
    volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
有必要解释一下程序次序规则。意思就是书写顺序不等于执行顺序。在单线程中,哪怕优化了因为结果不变还是保证了“顺序”。但在多线程中就不一定了。比如

    private int a = 5;
    private boolean init = false;
    
    public void setData(int num) {
        a = num;
        init = true;
    }
    
    public void readData() {
        if(init) {
            System.out.println(a);
        } else {
            System.out.println("uninit");
        }
    }

比如这个例子。如果一个线程执行setData,一个线程执行readData。可能出现的情况是setData方法内部指令优化以后init先执行,但是a还是旧值。导致readData输出uninit
(理论上会出现,但是本人测试了好多次并没有出现。。。。。。)
所以,JMM是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。目的是保证并发编程场景中的原子性、可见性和有序性。
PS:计算机内存模型和硬件有关。JMM是一种规范,用来处理共享内存的竞争问题的。两者从根本意义上来讲是不同的

三、Java内存模型的实现

了解Java多线程的朋友都知道,在Java中提供了一系列和并发处理相关的关键字,比如volatile、synchronized、final、concurren包等。其实这些就是Java内存模型封装了底层的实现后提供给程序员使用的一些关键字。

1、volatile

volatile 的特性

  • (1)禁止进行指令重排序。(实现有序性)
  • (2)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(实现可见性)

(1)防止重排序最经典的就是 double check

public class Singleton {
    private static volatile Singleton singleton;
    
    private Singleton() {};
    
    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (singleton) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

现在我们分析一下为什么要在变量singleton之间加上volatile关键字。要理解这个问题,先要了解对象的构造过程,实例化一个对象其实可以分为三个步骤:

  • (1)分配内存空间。
  • (2)初始化对象。
  • (3)将内存空间的地址赋值给对应的引用。

但是由于操作系统可以对指令进行重排序,所以上面的过程也可能会变成如下过程:

  • (1)分配内存空间。
  • (2)将内存空间的地址赋值给对应的引用。
  • (3)初始化对象

如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量。
(2)实现可见性
先来看一段代码

public class MainTest {
    int a = 1;
    int b = 2;
    
    public void change(){
        a = 3;
        b = a;
    }
    
    public void print(){
        System.out.println("b="+b+";a="+a);
        if (b == 3 && a == 1) {
            System.out.println("捕获异常");
        }
    }

    public static void main(String[] args) {

        while (true){
            final MainTest test = new MainTest();
            new Thread(new Runnable() {
                @Override
                public void run() {
                    test.change();
                }
            }).start();
            
            new Thread(new Runnable() {
                @Override
                public void run() {
                    test.print();
                }
            }).start();
        }
    }
}

为什么会出现b=3;a=1这种结果呢?正常情况下,如果先执行change方法,再执行print方法,输出结果应该为b=3;a=3。相反,如果先执行的print方法,再执行change方法,结果应该是 b=2;a=1。那b=3;a=1的结果是怎么出来的?原因就是第一个线程将值a=3修改后,但是对第二个线程是不可见的,所以才出现这一结果。如果将a和b都改成volatile类型的变量再执行,则再也不会出现b=3;a=1的结果了。
疑惑:change之后ab都是3,可能出现没有同步完的情况,那么如果a没同步完,b=3;a=1那可以理解。那如果b没有同步完,答案不就是b=2;a=3么?为啥这个没有?

对volatile变量的写操作与普通变量的主要区别有两点:

  • (1)修改volatile变量时会强制将修改后的值刷新的主内存中。
  • (2)修改volatile变量后会导致其他线程工作内存中对应的变量值失效。因此,再读取该变量值的时候就需要重新从读取主内存中的值。

通过这两个操作,就可以解决volatile变量的可见性问题。

2、使用volatile关键字的场景

通常来说,使用volatile必须具备以下2个条件:

  • 1)对变量的写操作不依赖于当前值
  • 2)该变量没有包含在具有其他变量的不变式中

事实上,使用volatile关键字需要保证操作是原子性操作,这样才能保证并发时能够正确执行。

synchronized关键字(下一篇会讲到)是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。(下一篇讲synchronized)

参考:
Java并发编程:volatile关键字解析
Java 并发编程:volatile的使用及其原理
【死磕Java并发】-----Java内存模型之happens-before
再有人问你Java内存模型是什么,就把这篇文章发给他。

相关文章

  • 线程安全和锁机制(二)谈谈volatile

    在引入volatile之前有必要先谈谈内存模型 一、计算机内存模型 计算机在执行程序的时候,每条指令都是在CPU中...

  • Java 多线程之线程安全的处理方式

    一、深入线程安全产生的原因 二、解决线程安全的办法 三、volatile关键字和锁的概念及其作用 一、深入线程安全...

  • Java多线程编程-线程同步机制

    线程同步机制 是一套用于协调线程之间的数据访问和活动的机制 java提供的线程同步有:锁,volatile关键字,...

  • 网络之美

    线程安全问题----互斥锁和递归锁 互斥锁线程同步能够保证多个线程安全访问竞争资源,最简单的同步机制就是引入互斥锁...

  • java多线程学习(三)

    线程的同步机制 java平台提供的线程同步机制包括锁,volatile关键字、final关键字、static关键字...

  • 2019 Java 底层面试题上半场(第一篇)

    JUC多线程及高并发 请谈谈你对volatile的理解 volatile是Java虚拟机提供的轻量级的同步机制 ...

  • 多线程之volatile

    volatile Synchronized 同步锁给多个线程访问的代码块加锁以保证线程安全性。多线程之Synchr...

  • volatile关键字总结

    volatile保证了线程安全的可见性,是由jvm提供的机制。 java内存模型对volatile关键字定义的特殊...

  • JUC面试问题

    请谈谈你对 volatile 的理解 JMM 你谈谈 CAS 你知道吗? CAS缺点 ABA 问题 集合线程不安全...

  • Lock和synchronized和volatile的区别和使用

    在锁层次上具体说明 二.volatile 深入剖析volatile关键字 volatile是一个轻量级的同步机制。...

网友评论

      本文标题:线程安全和锁机制(二)谈谈volatile

      本文链接:https://www.haomeiwen.com/subject/qgmixltx.html