首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >线程安全和锁机制(二)谈谈volatile

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

作者头像
提莫队长
发布2021-03-03 14:31:19
3300
发布2021-03-03 14:31:19
举报
文章被收录于专栏:刘晓杰刘晓杰刘晓杰

在引入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内存模型是什么,就把这篇文章发给他。

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、计算机内存模型
  • 二、Java 内存模型(JMM)
  • 三、Java内存模型的实现
    • 1、volatile
      • 2、使用volatile关键字的场景
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档