zoukankan      html  css  js  c++  java
  • java内存模型

    概述

    Java内存模型是屏蔽掉硬件和操作系统内存访问差异,实现在各个平台内存访问的一致性。本文就介绍一下Java内存模型原理,之后介绍一下并发编程中常见的原子性、可见性、有序性问题。

    主内存和工作内存

    由于内存和CPU性能的差异,所以现代计算机都使用多级缓存的方式来加快运算速度,也就是说CPU不能直接操作内存,而是需要先把内存中的数据拷贝到高速缓存或者寄存器才可以修改。Java中的公共变量是保存在主内存中,而局部变量和方法的参数不是保存在主内存中,而是保存在各个线程自己的工作栈中,下面就介绍一下Java的工作内存和主内存是如何交互的。

    工作内存和主内存交互

                   图片来源:一篇文章搞懂Java内存模型(详解)

    • lock(锁定):作用于主内存的变量,一个变量在同一时间只能一个线程锁定,该操作表示这条线成独占这个变量
    • unlock(解锁):作用于主内存的变量,表示这个变量的状态由处于锁定状态被释放,这样其他线程才能对该变量进行锁定
    • read(读取):作用于主内存变量,表示把一个主内存变量的值传输到线程的工作内存,以便随后的load操作使用
    • load(载入):作用于线程的工作内存的变量,表示把read操作从主内存中读取的变量的值放到工作内存的变量副本中(副本是相对于主内存的变量而言的)
    • use(使用):作用于线程的工作内存中的变量,表示把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行该操作
    • assign(赋值):作用于线程的工作内存的变量,表示把执行引擎返回的结果赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就会执行该操作
    • store(存储):作用于线程的工作内存中的变量,把工作内存中的一个变量的值传递给主内存,以便随后的write操作使用
    • write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中

    问题

    硬件结构决定了Java内存模型的形式,而这套结构会导致很多问题,主要就是原子性、有序性、可见性等问题。

    原子性

    定义:一个操作执行过程中不能被打断,要么全部执行成功,要么全部失败。java可以保证简单的赋值操作是原子性的,比如int i = 1;在32虚拟机上无法保证long和double的赋值操作是原子性,对于32位操作系统来说,单次次操作能处理的最长长度为32bit,而long类型8字节64bit,所以对long的读写都要两条指令才能完成(即每次读写64bit中的32bit)。

    i++
    

    上面的操作同样无法保证原子性,因为上面的操作其实是分了3步

    • 获取i的值
    • 将i的值加1
    • 将结果赋值给i

    以上三步操作不是一个原子操作,中间可以被打断。

    可见性

    所谓可见性,就是一个处理器修改了某个变量的值,别的处理器立即可以获得这个修改信息。

    图中有两个处理器,假设处理器0修改了主内存中的某个变量的副本,然后将结果放到写缓冲器,寄存器,高速缓存等地方,对于处理器1来说都是不可见的,那如果处理器1要想要可见,处理器0在修改完之后一定要通知通知处理器1,让处理器1的变量副本失效,同时要将自己的结果同步到主内存中。

    具体的解决办法就是使用内存屏障,比如处理器0修改了变量的副本,然后执行store屏障,这个时候处理器0需要执行flush操作,把数据写回到主内存,同时通知处理器1,让其中的变量副本失效,使用内存屏障确实可以解决可见性的问题,但是相应的程序的执行效率会降低。

    有序性

    所谓有序性,就是在本线程内观察,所有操作都是有序的。在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序

    上图中给出了会发生重排序的地方,下面就逐一介绍一下。

    • 编译的时候,编译器会重排指令
    • 处理器也会重排指令,处理器执行的顺序可能并不是编译之后的顺序
    • 硬件层间也会导致有序性问题,比如前一个指令执行的结果放入到写缓冲器中还没有同步到高速缓存,后一个指令的结果却已经放入到高速缓存中

    解决有序性的问题还是依靠内存屏障,具体参考这篇文章

    指令重排,并不是乱排的,而是要遵循一定的规则,这个规则就是java提前已经定义好 happens-before 原则,下面简单介绍一下。

    happens-before 原则

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

    这8条规则中,前4条规则是比较重要的,后4条规则都是显而易见的。

      下面我们来解释一下前4条规则:

      对于程序次序规则来说,我的理解就是一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。

      第二条规则也比较容易理解,也就是说无论在单线程中还是多线程中,同一个锁如果出于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行lock操作。

      第三条规则是一条比较重要的规则,也是后文将要重点讲述的内容。直观地解释就是,如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。

      第四条规则实际上就是体现happens-before原则具备传递性。

    例子

    有序性问题有一个很著名的例子就是单例模式使用double check在高并发的时候依然可能会出问题,具体如下。

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

      对象的创建要经过如下三步

    memory = allocate();  // 1:分配对象的内存空间
    ctorInstance(memory); // 2:初始化对象
    instance = memory;  // 3:设置instance指向刚分配的内存地址
    

      如果第二步和第三步发生了指令重排,先执行了第三步,执行完之后对象就不为null了,如果另一个线程以为单例模式已经初始化完成了,就开始使用这个对象,这时由于对象还没有初始化完成,就会出问题。所以解决的办法就是对单例对象加上volatile,加了volatile之后,相当于如下:

    public class Singleton {
        private Singleton() { }
        private volatile static Singleton instance;
        public Singleton getInstance(){
            if(instance==null){
                synchronized (Singleton.class){
                    if(instance==null){
                      LoadStore();//伪代码
                        StoreStore();//伪代码
                        instance = new Singleton();
                      Store();//伪代码
                    }
                }
            }
            return instance;
        }
    } 

    在instance前面加入了两个内存屏障,第一个内存屏障LoadStore在别的线程执行如下代码的时候:

       if(instance==null){
    

      由于有LoadStore屏障的存在,这个Load操作要等到Store操作完成,也就是这个屏障前面的所有Store操作都完成,但是加这个屏障并不能保证实例化对象的时候,那三步的指令重排,也就是说,单例的创建依然有可能先执行第三步分配地址空间,后执行第二部初始化对象,但这个指令重排没有影响,有了屏障之后会等待全部执行完成。

    上面的Store屏障(这个说的有些混乱,因为屏障这个东西很多不同的硬件厂家实现都不一样,理解原理就可以了),这个屏障的作用是当初始化完成会把instance强制刷新回主内存。

    总结

      本文主要介绍Java内存模型结构,主内存和工作内存的交互方式,之后介绍在并发编程中几个重要的性质,分别是原子性、有序性、可见性,介绍了在什么场景下会出现问题,之后介绍了一个单例模式实例化的例子,用于说明有序性可能会出现的问题以及解决的办法。

    参考:

    单例模式+volatile禁止指令重排序

    并发之原子性、可见性、有序性

    一篇文章搞懂Java内存模型(详解)

    深入理解Java内存模型

  • 相关阅读:
    UIPickerView UIDatePicker的常见属性
    IOS笔记4
    判断代理是否实现了协议方法
    TableViewCell中自定义XIB的使用
    TableView中表格的添加与删除
    TableViewCell的循环使用
    NSTimer与运行循环
    IOS笔记3
    win7系统中文件夹按字母快速定位
    Intent启动常用的系统组件
  • 原文地址:https://www.cnblogs.com/gunduzi/p/13594554.html
Copyright © 2011-2022 走看看