• Java并发专题(三)深入理解volatile关键字


    前言

      上一章节简单介绍了线程安全以及最基础的保证线程安全的方法,建议大家手敲代码去体会。这一章会提到volatile关键字,虽然看起来很简单,但是想彻底搞清楚需要具备JMM、CPU缓存模型的知识。不要小看这个关键字,它在整个并发包(concurrent包)使用的非常广泛,掌握volatile关键字是非常重要的。

       如果你是一个急性子,请看下面3点就行:

    • 保证了多线程读取变量的可见性,一个线程修改volatile修饰的变量,另外一个线程会立即读取到新的值
    • 禁止指令重排序
    • volatile关键字不会像synchronized关键字一样造成线程阻塞,也就是说无锁

    1.1 初识volatile关键字

      我先写一个例子,在主线程启动2个线程,一个线程负责写,一个线程负责读,读写的该变量就是共享变量,那么结果是你想的那样吗?

    /**
     * volatile第一个演示Demo类。
     *
     * @author GrimMjx
     */
    public class VolatileDemo1 {
    
        //i的初始值为0
        public static int i;
        //i的最大值为3
        public static int MAX = 3;
    
        public static void main(String[] args) {
            //读线程
            new Thread(() -> {
                int index = i;
                while (index < MAX) {
                    if (i != index) {
                        System.out.println("i = " + i);
                        index = i;
                    }
                }
            }).start();
    
            //写线程
            new Thread(() -> {
                int index = i;
                while (index < MAX) {
                    System.out.println("new i = " + ++i);
                    index = i;
                    try {
                        TimeUnit.SECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }
    }

      我贴上一份我执行的结果:

    new i = 1
    i = 1
    new i = 2
    new i = 3

      程序不会停,需要手动停。那么问题来了,为什么明明写入了i了,读线程还是无法读到新的i的值呢?读线程压根没有感知到i的变化!只要我们把变量i的定义改变一下,那么就可以解决这个问题。

    public volatile static int i;

      改好之后再试一下,确实如我们预料的执行了且读线程也不会死循环。只是一个关键字的差别,会发生很大的不同。那接下来请带着疑问去学习。

    new i = 1
    i = 1
    new i = 2
    i = 2
    new i = 3
    i = 3

    1.2 机器CPU

      所有的指令都是CPU寄存器完成的,CPU指令的过程中涉及到数据的写入和读取。CPU能访问的所有数据只能是RAM(计算机内存)。虽然CPU频率不断提升,但是RAM访问速度没有很大突破,因此CPU处理速度和内存的访问速度差距巨大,一次主内存的访问通常在几十到几百个甚至上千个时钟周期,一次L1高速缓存的读写需2个左右时钟周期,一次L2高速缓存的读写需要几十个时钟周期。

    1.2.1 CPU Cache模型

      可以直观看到两边的速度严重不对等,于是有了在CPU和主内存之间增加缓存,最靠近CPU的缓存成为L1高速缓存,其次是L2,L3和主内存。我们先看一张各级缓存之间响应时间差距,以及内存到底有多慢。

      接下来我们看一下CPU Cache模型:

    1.2.2 CPU缓存一致性

      缓存大大提高了访问速度,但是同时也引入了缓存不一致的问题,比如i++;这个操作。具体的过程如下:

    1. 读取主内存的i到CPU Cache中
    2. 将i+1
    3. 将结果写回CPU Cache
    4. 将数据刷新回主内存

      i++在单线程完全不会有问题,但是多线程的时候就会有问题,每个线程都有自己的工作内存(本地内存),如果在2个线程都执行i++;操作,A线程和B线程此时的工作内存中的i都是0,加1之后都变成1。最后经过计算再写入主内存可能结果还是1。这就是缓存不一致问题。如果想要解决这个问题,主流方法是通过缓存一致性协议(MESI协议)。这个协议的大致思想就是如果当CPU在操作Cache中的数据时,其他Cache也存在一份副本,那么会进行如下操作:

    1. 读取操作,不做任何处理,只是将Cache中的数据读取到寄存器
    2. 写入操作,发出信号通知其他CPU将其变量中的Cache line置为无效状态,其他CPU在进行该变量的读取时候不得不到主内存中再次获取

    1.3 Java内存模型

      JMM指定了JVM和计算机RAM如何进行工作的,同时也决定了一个线程对共享变量的写入何时对其他线程可见,有以下几个要点:

    • 每个线程都有自己的工作内存,也成为本地内存
    • 工作线程只存储线程对共享变量的副本
    • 线程不能直接操作主内存,只能操作工作内存
    • 工作内存和JMM都是一个抽象的概念,实际并不存在,覆盖了寄存器,编译器优化等等

      主内存和工作内存的关系和CPU与CPUCache之间的关系是非常类似的,所以通过图示和讲解,我们发现理解volatile关键字会比synchronized关键字困难很多,需要了解机器CPU还有JMM。volatile在JDK5以后的concurrent包运用非常广泛,所以掌握volatile关键字很重要。

    1.4 深入理解volatile关键字

      说到并发,有三大特性,原子性,有序性和可见性,那我们从三个方面来介绍

    1.4.1 原子性

      volatile不具备原子性

      原子性的意思就是在一次操作中,所有的操作全部执行或者都不执行,就像名字一样是不可分割的。Java中,对变量的读取和赋值操作都是源自的,但是多个原子性的操作在一起,不一定是个原子操作。JMM只保证了基本的读取和赋值的原子性,其他的均不保证。说回volatile,如果在上一章节的UnsafeAdd的例子,用volatile修饰变量i,是否可以解决多线程并发问题呢,结果是不可以的,可以自己去试试。

      就像是i++操作,他其实包含了3步骤

      1.从主内存获取i,缓存到工作内存

      2.在工作内存中进行+1

      3.刷回主内存。

      这也就是刚刚说的多个原子性的操作在一起,不一定是个原子操作

      Java中想要保证原子性,需要使用synchronized关键字,concurrent包的lock,原子封装类和循环CAS的方式(原子变量是一种更好的volatile,后面会讲到)

    1.4.2 可见性

      volatile具备可见性

      读取:当一个变量被volatile关键字修饰时,当其他线程对此变量进行了修改,则会迫使其他线程的工作内存中的该变量失效,所以必须从主内存重新获取。(使用的是机器指令lock)

      写入:当然是先修改工作内存,修改后立即将其刷新到主内存中。

      Java中volatile,synchronized关键字和显式锁lock都保证可见性

    1.4.3 有序性

      volatile具备有序性

      首先volatile遵循happens-before原则:对一个变量的写操作要早于这个变量之后的读操作。也就是说,如果一个变量使用volatile关键字修饰,一个线程对这个变量进行写操作,另外一个线程对这个变量进行读操作。那么写操作肯定要先发生于读操作。

      volatile对顺序性非常霸道,直接禁止JVM和处理器进行指令重排序,但是对于volatile前后无依赖关系的执行可以随便排序。

      Java中volatile,synchronized关键字和显式锁lock都保证有序性

    1.5 volatile的正确打开姿势

    • 确保它们所引用状态的可见性
    • 标识一些重要的程序生命周期事件发生(init,destroy)
    • 确保只有一个线程更新变量的值
    • 不会用就不要用:)
  • 相关阅读:
    Unicode编码
    第2章 词法结构
    ubuntu thrift
    Django admin 忘记密码
    STL中的优先级队列priority_queue
    哈希表面试题-转载
    C++中基于成员函数是否是const重载成员函数
    printf格式输出知识整理
    Linux dns和网络连接配置方法
    Linux下高效并发服务器的常用方法和案例(转)
  • 原文地址:https://www.cnblogs.com/GrimMjx/p/10059958.html
Copyright © 2020-2023  润新知