Java线程-volatile关键字学习(11)

2019-07-07  本文已影响0人  骑着乌龟去看海

一、概述

volatile关键字被称为“轻量级 synchronized”,是Java中针对并发同步访问提供的一种免锁机制,只能用于修饰变量,不能用于方法及代码块等。volatile 可以保证共享变量的可见性。在介绍volatile关键字之前,我们先来回忆下Java并发编程中的几个概念。

1. 原子性

这个其实在学习数据库的时候就了解到了,所谓的原子性,也就是某个操作或多个操作,在执行的时候要么全部执行,要么全部不执行,这个就是所谓的原子性。而 volatile 并不能保证原子性。

2. 可见性

要说可见性的话,先要简单说下内存模型;等内存模型说过之后,可见性就比较清楚了。

2.1 操作系统内存模型

学过计算机组成原理的应该都知道,计算机中程序的执行都是通过CPU来控制的,在程序在执行过程中,势必会涉及到数据的读写,而这些数据是存储在内存中的。而在计算机中,内存的速度是低于CPU的,因此如果只通过内存与CPU交互的话,势必会影响到指令执行的速度,这时候自然就有了高速缓存存储器,也就是高速缓存。

高速缓存是为了解决CPU和内存之间速度不匹配而采用的一项技术。

当程序运行过程中,操作系统会将相应的数据从主存复制一份到CPU的高速缓存中,然后CPU进行计算时就可以直接从对高速缓存进行读写,当计算结束之后,再将高速缓存中的数据刷新到主存当中。

不过在多线程中,这样的处理方式是有问题的。在多核CPU中,每个线程可能会属于不同的CPU,因此每个线程运行时有自己的高速缓存,如果多个线程同时操作某一个变量,这时候就可能会出现预期之外的结果。假如有2个线程对某一个变量执行加1操作:i = i + 1; 初始时i的值为0,那么有可能会出现下面这种情况:

初始时,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,然后线程1进行加1操作,然后把i的最新值1写入到内存;此时线程2的高速缓存当中i的值还是0,进行加1操作之后,i的值为1,然后线程2把i的值写入内存;

而最终结果i的值是1,而不是2,这就是所谓的缓存一致性问题,通常称这种被多个线程访问的变量为共享变量。也就是说,如果一个变量在多个CPU中都存在缓存,那么就可能存在缓存不一致的问题。

而要解决这个问题,可以是 共享变量在被修改后,想办法通知其他CPU将该该变量的缓存设置为失效状态,这样当其他CPU需要读取这个值时,发现缓存中缓存的变量是无效的,那么它将会从内存中重新读取。

2.2 Java内存模型

Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存,而工作内存中则保存了被该线程使用到的变量的主内存拷贝。

这里只简单介绍下Java内存模型,因为这其实属于JVM的知识,后续学习JVM的时候再仔细了解,这里只需要了解到Java的内存模型类似于操作系统的内存模型即可。

2.3 可见性

了解了内存模型后,我们基本上就理解了可见性的概念了。所谓的可见性,是说当多个线程访问同一个变量时(共享变量),一个线程修改了这个变量的值,其他线程能够立即看到修改后的值。

  • 而Java中的volatile关键字就是用来保证可见性的;当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,并且CPU会让使用此变量的工作内存中的拷贝失效,需要读取时重新从主存读取;
  • 而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

另外,关于可见性而导致问题的例子可以参考:可见性问题实例-并发编程网

3. 有序性

有序性,简单来说就是程序执行的顺序是按照代码的先后顺序来执行。但在代码执行的过程中,编译器也好,CPU也好,在保证程序最终结果的前提下,会对代码的执行进行重新排序。并且最近这些年,计算机性能的提升在很大程序上都要归功于这些重新排序的措施,而这些重新排序的方式又被称为指令重排序。

3.1 指令重排序

指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

当然,处理器在进行重排序时会考虑指令之间的数据依赖性,如果某个指令2的执行依赖于指令1,那么指令2一定会排到指令1之后。虽然指令重排序不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

3.2 Happens-Before关系

Java内存模型为程序中所有的操作定义了一个偏序关系,称之为 Happens-Before。比如两个操作A和操作B,要想保证执行操作B的线程能看到执行操作A的结果(无论A和B是否在同一个线程中执行),那么在A和B之间必须满足Happens-Before关系,如果两个操作之间不符合Happens-Before关系,那么JVM可以对他们任意地重排序。

Java内存模型可以不通过任何手段“天然的”就能保证有序性,只要操作满足Happens-Before原则;如果两个操作之间的关系不在下面几个规则中,并且也无法从下列规则中推导出来,那么JVM就可以对他们随意重排序;

Happens-Before的规则包括(这里参考自《Java并发编程实战》):

  • 程序顺序规则:同一个线程内,如果操作A在操作B之前,那么在线程中A操作将在B操作之前执行;
  • 监视器锁规则:在监视器锁上的解锁操作必须在同一个监视器锁上的加锁操作之前执行,这里指的是同一个锁;
  • volatile变量规则:对volatile变量的写入操作必须在对该变量的读操作之前执行;
  • 线程启动规则:在线程上对Thread.start的调用必须在该线程中执行任何操作之前执行;
  • 线程结束规则:线程中的任何操作都必须在其他线程检测到该线程已经结束之前执行,或者从Thread.join中成功返回,或者在调用Thread.isAlive时返回false;
  • 线程中断规则:当一个线程在另一个线程上调用interrupt时,必须在被中断线程检测到interrupt调用之前执行(通过抛出InterruptedException,或者调用isInterrupted和interrupted);
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)必须在该对象的finalize() 方法之前执行完成;
  • 传递性:如果A before B执行,B before C执行,那么A必须在C之前执行;

二、volatile 关键字

前面基本上了解了学习volatile关键字的一些前提条件,接下来我们来学习下volatile关键字。

1. volatile特性

被 volatile 修饰的变量,具有两个特性:

a. 保证了该变量对于不同线程之间操作时的可见性;
b. 禁止指令重排序;

2. 典型用法

一般情况下,我们使用volatile的典型用法就是:检查某个标记以判断是否退出循环,比如下面的代码:

private boolean asleep;

while (!asleep) {
    countSomeSheep(); // 数绵羊
}

//other
asleep = false;

首先,我们没有使用volatile来修饰,但是这样的话,就会有可见性问题,也就是当asleep被另一个线程修改时,执行判断的线程却发现不了,详细可以参考上面链接中的可见性问题参考实例。不过我们使用volatile修饰之后就变得不一样了:private volatile boolean asleep;

  • 首先,volatile关键字会将修改后的变量强制写入内存;
  • 并且会导致其他线程的工作内存中该变量的拷贝缓存失效;
  • 其他线程再次读取该变量时将会去主存中读取;
3. 如何禁止指令重排序

首先,我们来看下对于普通变量和volatile变量操作时有什么区别:

volatile变量的内存可见性是基于内存屏障(Memory Barrier)实现的,什么是内存屏障?内存屏障,又称内存栅栏,是一个CPU指令。在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM为了保证在不同的编译器和CPU上有相同的结果,通过插入特定类型的内存屏障来禁止特定类型的编译器重排序和处理器重排序,插入一条内存屏障会告诉编译器和CPU:不管什么指令都不能和这条Memory Barrier指令重排序。

a = 1;  // 1
b = 2;  // 2
instance = new Singleton();  // 3
c = a + b;  // 4
  1. 如果变量instance没有volatile修饰,指令1、2、3、4可以随意的进行重排序执行,即指令执行过程可能是3214或1324。
  2. 如果是volatile修饰的变量instance,那么会在指令3的前后各插入一个内存屏障;指令3不会在指令1和指令2之前执行,也不会在指令4和5之后执行;但指令1和2,指令3和4顺序是不固定的;
  3. 并且能保证,执行到指令3时,指令1和指令2必定是执行完毕了的,且指令1和指令2的执行结果对指令3、指令4、指令5是可见的。
4. 如何保证可见性?

以下内容来源于:深入分析Volatile的实现原理-方腾飞-并发编程网

通过观察volatile变量和普通变量所生成的汇编代码可以发现,操作volatile变量会多出一个lock前缀指令:

//instance是volatile变量
instance = new Singleton();

// 汇编代码
0x01a3de1d: movb $0x0,0x1104800(%esi);
0x01a3de24: lock addl $0x0,(%esp);

有volatile变量修饰的共享变量进行写操作的时候会多第二行汇编代码,通过查IA-32架构软件开发者手册可知,lock前缀的指令在多核处理器下会引发了两件事情。

  • 将当前处理器缓存行的数据会写回到系统内存;
  • 这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效;

处理器为了提高处理速度,不直接和内存进行通讯,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完之后不知道何时会写到内存,如果对声明了Volatile变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。

所以,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。

5. 单例双重检查问题

双重检查锁定(DCL)是单例模式的一种实现方式,实现代码大致如下:

class Singleton {
    private volatile static Singleton instance;
    public static Singleton getInstance() {
        if (instance == null) {
            syschronized(Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    } 
}

很多人会忽略volatile关键字,因为没有该关键字,程序一般情况下也可以很好的运行。不过由于指令重排,可能会导致instance已经指向了内存地址,也就是已经不为null,但构造器代码还没有执行,而使用volatile 则可以解决这个问题。而有关这块更多的介绍,可以参考:双重检查锁定(DCL)失效与volatile

不过根据《Java并发编程实战》的说法,这种双重检查加锁正在广泛的被废弃掉,如果仅对单例而言的话,我们肯定始有更好的实现方式的。

三、总结

简单介绍了volatile关键字的用法,这里来简单总结下:

  1. 使用volatile关键字可以保证共享变量的可见性,并且可以禁止指令重排序,但volatile关键字不能保证原子性;;
  2. 和加锁机制相比,加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性;
  3. volatile变量虽然很方便,但通常用作某个操作完成、发生中断或者状态的标志,官方并不建议过度依赖volatile变量提供的可见性;仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用他们;如果在验证正确性时需要对可见性进行复杂的判断,那么就不要使用volatile变量。

本文参考自:
《Java并发编程实战》《深入理解Java虚拟机》
并发编程网-深入分析Volatile的实现原理
海子-Java并发编程-volatile关键字解析
java内存模型介绍-hollischuang.com

上一篇下一篇

猜你喜欢

热点阅读