Java 多线程之Volatile

作者: Ludwigvan | 来源:发表于2018-04-13 10:45 被阅读27次

    Java 多线程之Volatile

    今天来讲讲Java中经常使用到的另一个比较难懂的关键字Volatile,这个关键字之所以难懂,是因为他在Java字节码层面做了些动作。

    在讲Volatile之前,这里先让大家了解下Java 中容易搞混淆的两个概念,之前笔者也没有弄清楚也是后面查资料,这里也一并提出来。

    1. Java内存模型(JMM)
    2. Jvm内存模型
    3. Volatile语义
    4. 使用场景
    5. 原理浅析
    6. 总结

    1 Java内存模型(JMM)

    1.1 内存模型分类

    在并发编程中,我们需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。
    在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。
    同步是指程序用于控制不同线程之间操作发生相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。
    Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的Java程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。

    1.2 内存模型抽象

    在java中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享(本文使用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量(Local variables),方法定义参数(java语言规范称之为formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
    Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java内存模型的抽象示意图如下:

    image

    从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:

    1. 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
    2. 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。

    示意图如下:

    image

    如上图所示,本地内存A和B有主内存中共享变量x的副本。假设初始时,这三个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。
    从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。

    1.3 重排序

    在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

    1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
    2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
    3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

    1.4 happens-before

    从JDK5开始,java使用新的JSR -133内存模型(本文除非特别说明,针对的都是JSR- 133内存模型)。JSR-133使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。
    与程序员密切相关的happens-before规则如下:

    程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
    监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
    volatile变量规则:对一个volatile域的写,happens- before 于任意后续对这个volatile域的读。
    传递性:如果A happens- before B,且B happens- before C,那么A happens- before C。
    注意,两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。

    2 JVM内存模型

    <深入理解Java虚拟机>中JVM的内存模型如下:

    image

    我们常说的Java内存模型应该就是这里的内存空间那部分,是JVM 内存模型的一个子集。

    3 Volatile 语义

    Java中,Volatile具有两个特性:

    可见性: 当一个共享变量被volatile修饰时,它会保证修改的值立即被更新到主存,所以对其他线程是可见的。当其他线程需要读取该值时,其他线程会去主存中读取新值。相反普通的共享变量不能保证可见性,因为普通共享变量被修改后并不会立即被写入主存,何时被写入主存也不确定。当其他线程去读取该值时,此时主存可能还是原来的旧值,这样就无法保证可见性。

    禁止重排序: java内存模型中允许编译器和处理器对指令进行重排序,虽然重排序过程不会影响到单线程的执行的正确性,但是会影响到多线程并发执行的正确性。这时可以通过volatile来保证有序性,除了volatile,也可以通过synchronized和Lock来保证有序性。synchronized和Lock保证每个时刻只有一个线程执行同步代码,这相当于让线程顺序执行同步代码,从而保证了有序性。如果不考虑原子性操作的话volatile比synchronized和Lock更轻量级,成本更低。

    注意:这里明确说明Volatile不保证原子性。

    不保障原子性volatile关键字只能保证共享变量的可见性和有序性。如果volatile修饰并发线程中共享变量, 而该共享变量是非原子操作的话,并发中就会出现问题。比如下面代码:

    public class HelloVolatile{
        public volatile int mNumber =0;
        
        public static void main(String []args){
            final HelloVolatile hello =new HelloVolatile();
            for(inti =0; i<10; i++){
                newThread(){
                    public void run(){
                        for(intj =0; j<1000; j++){
                        hello.mNumber ++;
                    }
                }
            }.start();
        }
        while(Thread.activeCount()>2){
            Thread.yield();
        }
        System.out.println("number:"+hello.mNumber); 
        }
    }
    

    这段代码预期结果是10000,可是每次执行结果都有可能不一样。这是因为自增或自减都是非原子操作。

    (1) 假如mNumber此时等于100,线程1进行自增操作。

    (2)线程1先读取了mNumber的值100,然后它被堵塞了。

    (3)这时候线程2读取mNumber的值100,然后进行了自增操作,并写入到主存中, 这时候主存中的值为101。

    (4)这时候线程1继续执行,因为此前线程1已经读取到值100,然后进行自增操作101,然后将101写入到主存中。

    可以看到两个线程分别对100进行了+1操作,预期主存中的nNumber = 102,实际mNumebr = 101; 这就是因为非原子操作造成的。

    4 使用场景

    (1)并发编程中不依赖于程序中任意其状态的状态标识。可以通过关键字volatile代替synchronized, 提高程序执行效率,并简化代码。

    (2)单例模式的双重检查模式DCL

    public class DclSingleton{
    
     private volatile static DclSingleton mInstance=null;
     
     public static DclSingletongetInstance(){
        if(mInstance==null){
            synchronized(DclSingleton.class){
                if(mInstance==null){ 
                    mInstance =newDclSingleton(); 
                } 
            }
        }
        return mInstance; 
     }
    }
    

    虽然这么说,但其实我们更推荐用内部类(具体的单例模式可以看LZ的这篇博客:设计模式之单例模式)的方式:

    public class InnerClassSingleton {
        
        public static Singleton getInstance(){
            return Singleton.singleton;
        }
    
        private static class Singleton{
            
            protected static Singleton singleton = new Singleton();
            
        }
    }
    

    5 原理浅析

    将volatile修饰的变量转变成汇编代码,如下:

    ... lock addl $0x0,(%rsp)
    

    通过查IA-32架构安全手册可知,Lock前缀指令在多核处理器会引发两件事。

    1)将当前处理器缓存行的数据写回到系统内存。

    2)这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。

    解读 :

    为了提高,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存后再进行操作,但操作完不知道何时再写回内存。如果对声明了volatile的变量进行写操作,JVM会向处理机发送一条Lock前缀指令,将这个变量所在的缓存行的数据写回到系统内存。

    但是写会内存后,如果其他处理器缓存的值还是旧的,再执行计算操作就会出现问题。所以在多处理器下,为了保证各个处理器缓存是一致的,就会实现缓存一致性协议,如下图:

    image

    每个处理器通过嗅探在总线上传播的数据来检查自己缓存的数据是否过期了,当处理器发现自己的缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态。当处理器对这个数据进行操作的时候,就会重新从系统内存中把数据读到处理器缓存中。

    相关文章

      网友评论

        本文标题:Java 多线程之Volatile

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