Java并发中的内存模型

什么是JavaMemoryModel(JMM)?

JMM经过构建一个统一的内存模型来屏蔽掉不一样硬件平台和不一样操做系统之间的差别,让Java开发者无需关注不一样平台之间的差别,达到一次编译,随处运行的目的,这也正是Java的设计目的之一。java

CPU和内存

在讲JMM以前,我想先和你们聊聊硬件层面的东西。你们应该都知道执行运算操做的CPU自己是不具有存储能力的,它只负责根据指令对传递进来的数据作相应的运算,而数据存储这一任务则交给内存去完成。虽然内存的运行速度虽然比起硬盘快很是多,可是和3GHZ,4GHZ,甚至5GHZ的CPU比起来仍是太慢了,在CPU的眼中,内存运行的速度简直就是弟弟中的弟弟,等内存进行一次读写操做,CPU能思考成百上千次人生了😁。可是CPU的运算能力是紧缺资源啊,可不能这么白白浪费了,因此得想办法解决这一个问题。缓存

没有什么问题是一个缓存不能解决的,若是有,那就再加一个缓存 ——鲁迅:反正我没说这句话多线程

因此人们就想到了给CPU增长一个高速缓存(为何是加高速缓存而不是给内存提升速度就牵扯到硬件成本问题了)来解决这个问题,好比像博主用的Intel的I9 9900k CPU就带了高达16M的三级缓存,因此硬件上的内存模型大概以下图所示。 并发

如图能够很清楚的看到,在CPU内部构建了一到多层的缓存,而且其中的L1 Cache是CPU内核心独有的,不一样的Core之间是不能共享的,而L2 Cache则是全部的核心共享。简单来讲就是CPU在读取一个数据时会先去最近的Cache层级上读取,若是找不到则会去下一个层级寻找,都找不到的话就会从内存中去加载,而若是Cache中能拿到所须要的数据就不会去内存读取。在将数据写回的时候也会先写入Cache中,等待合适的时机再写入到内存中(其中有一个细节就是缓存行的问题,关于这部份内容放在文章结尾)。而因为存在多个cache层级,而且部分cache还不可以被共享,因此会存在内存可见性的问题。dom

举个简单的例子: 假设如今存在两个Core,分别是CoreA和CoreB而且他们都拥有属于本身的L1Chace和共用的L2Cache。同时有一个变量X的值为1,该变量已经被加载在L2Cahce上。此时CoreA执行运算须要用到变量X,先去L1Cache寻找,未命中,继续在L2Cache寻找,命中成功,将X=1载入L1Cahce,再通过一系列运算后将X修改成2并写入L1Cache。于此同时CoreB恰好也须要X来进行运算,此时他去本身的L1Cahce寻找,未命中,继续L2Cache寻找,命中成功,将X=1载入本身的L1Cache。此时就出现了问题,CoreA明明已经将X的值修改成2了,CoreB读取到的依然是X=1,这就是内存可见性问题。性能

看到这里的小伙伴们可能要问了,博主你啥状况啊,你这写的渐渐忘记标题了啊,说好了Java内存模型,你扯这么多硬件上的问题干啥啊?(╯‵□′)╯︵┻━┻this

Java中的主内存和工做内存

小伙伴们别着急,其实JMM和上面的硬件层次上的模型很像,不信看下面的图片 spa

怎么样,是否是看起来很像,能够简单的理解为线程的工做内存就是CPU里Core独占的L1Cahce,而主内存就是共享的L2Cache。因此上述的内存一致性问题也会在JMM中存在,而JMM就须要制定一些列的规则来保证内存一致性,这也是Java多线程并发的一个疑难点,那么JMM制定了哪些规则呢?操作系统

##内存间交互操做 首先是主内存的工做内存之间的交互协议,具体来讲定义了如下几个操做(而且保证这几个操做都是原子性的):线程

  • lock (锁定)做用于主内存的变量,将一个变量标识为一个线程独占状态
  • unlock(解锁)做用于主内存的变量,将一个处于锁定状态的变量释放出来,释放以后才能被其余线程锁定
  • read(读取)做用于主内存的变量,将一个变量的值从主内存传输到线程工做内存中,便于以后的load操做使用
  • load(载入)做用于工做内存的变量,它把read操做从主内存中获得的变量值放入工做内存的变量副本中。
  • use(使用)做用于工做内存的变量,把工做内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个须要使用变量的值的字节码指令时将会执行这个操做。
  • assign(赋值)做用于工做内存的变量,它把一个从执行引擎接收到的值赋值给工做内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操做。
  • store(存储)做用于工做内存的变量,把工做内存中的一个变量的值传送到主内存中,以便随后的write的操做。
  • write(写入)做用于主内存的变量,它把store操做从工做内存中一个变量的值传送到主内存的变量中。

同时还规定了执行上述八个操做时必须遵循如下规则:

  • 若是要把一个变量从主内存中复制到工做内存,就须要按顺寻地执行read和load操做, 若是把变量从工做内存中同步回主内存中,就要按顺序地执行store和write操做。但Java内存模型只要求上述操做必须按顺序执行,而没有保证必须是连续执行。
  • 不容许read和load、store和write操做之一单独出现
  • 不容许一个线程丢弃它的最近assign的操做,即变量在工做内存中改变了以后必须同步到主内存中。
  • 不容许一个线程无缘由地(没有发生过任何assign操做)把数据从工做内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不容许在工做内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操做以前,必须先执行过了assign和load操做。
  • 一个变量在同一时刻只容许一条线程对其进行lock操做,但lock操做能够被同一条线程重复执行屡次,屡次执行lock后,只有执行相同次数的unlock操做,变量才会被解锁。lock和unlock必须成对出现
  • 若是对一个变量执行lock操做,将会清空工做内存中此变量的值,在执行引擎使用这个变量前须要从新执行load或assign操做初始化变量的值
  • 若是一个变量事先没有被lock操做锁定,则不容许对它执行unlock操做;也不容许去unlock一个被其余线程锁定的变量。
  • 对一个变量执行unlock操做以前,必须先把此变量同步到主内存中(执行store和write操做)。

(上述部分参考并引用《深刻理解Java虚拟机》中的内容)

volatile(可以保证内存可见性和禁止指令重排序)

对于volatile修饰的变量,JMM对其有一些特殊的规定。

内存可见性

往简单来讲volatile关键字能够理解为,有一个volatile修饰的变量x,当一个线程须要使用该变量的时候,直接从主内存中读取,而当一个线程修改该变量的值时,直接写入到主内存中。根据以前的分析咱们能得出具有这些特性的volatile可以保证一个变量的内存可见性和内存一致性。

指令重排序

指令重排序是一个大部分CPU都有的操做,同时JVM在运行时也会存在指令重排序的操做。 简单举个🌰

private void test(){
        int a,b,c;//1
        a=1;//2
        b=3;//3
        c=a+b;//4
    }
复制代码

假设有上面这么一个方法,内部有这4行代码。那么JVM可能会对其进行指令重排序,而指令重排序的规定则是as-if-serial 无论怎么重排序(编译器和处理器为了提升并行度),(单线程)程序的执行结果不能被改变。根据这一规定,编译器和处理器不会对有依赖关系的指令重排序,可是对没有依赖的指令则可能会进行重排序。放在上面的例子里面就是,第1行代码和2,3,4行代码是有依赖关系的,因此第一行代码的指令必须排在2,3,4以前,由于不可能对一个未定义的变量进行赋值操做。而第2,3行代码之间并无相互依赖关系,因此此处可能会发生指令重排序,先执行3,再执行2。而最后的第4行代码和以前的3行代码都有依赖关系,因此他必定会放在最后执行。

既然JVM特别指出指令重排序只在单线程下和未排序的效果一致,那是否表示在多线程下会存在一些问题呢? 答案是确定的,多线程下指令重排序会带来一些意想不到的结果。

int a=0;
    //flag做为一个标识符,标识是否写入完成
    boolean flag = false;
    public void writer(){
        a=10;//1
        flag=true;//2
    }
    public void reader(){
        if (flag)
            System.out.println("a:"+a);
    }
复制代码

假设存在一个类,他有上述部分的field和method,该类在设计上以flag做为写入是否完成的标志,在单线程下这并不会存在问题。而此时有两个线程分别执行writer和reader方法,暂时不考虑内存可见性的问题,假设对a和flag的写入,是当即被其余线程所知晓的,这个时候你们以为输出a的值为多少?10?

即便不考虑内存可见性,此时a的值仍是有可能会输出0,这就是指令重排序带来的问题。在上述代码中注释1和2处的代码是没有依赖关系的,在单线程下先执行1仍是2都没有任何问题,根据as-if-serial 原则此时就可能会发生指令重排序。

而volatile关键字能够禁止指令重排序。

long,double的问题

咱们都知道JMM定义的8个主内存和工做内存之间的操做都是具有原子性的,可是对long和double这两个64位的数据类型有一些例外。

容许虚拟机将没有被volatile修饰的long和double的64数据的读写操做划分为两次32位的读写操做,即不要求虚拟机保证对他们的load ,store,read,write四个操做的原子性。 可是大部分的虚拟机实现都保证了这四个操做的原子性的,因此大部分时候咱们都不须要刻意的对long,double对象使用volatile修饰。

性能问题

volatile是Java提供的保证内存可见性的最轻量级操做,比起重量级的synchronized能快上很多,可是具体能快多少这部分没办法量化。而咱们能够知道的是volatile修饰的变量读操做的性能消耗几乎和普通变量相差无几,而写操做则会慢上一些。因此当volatile能解决咱们的问题的时候(内存可见性和禁止指令重排序),咱们应该优先选择使用volatile而不是锁。

synchronized的内存语义

简单归纳就是

当程序进入synchronized块时,把在synchronized块中用到的变量从工做内存中清楚,这样在须要访问这些变量的时候会从新从主内存中获取。当程序退出synchronized块时,把对块中恭喜变量的修改刷新到主内存。 如此依赖synchronized也能保证了内存的可见性。

final的内存语义

final也能保证内存的可见性

被final修饰的字段在构造器中一旦初始化完成,而且构造器没有把this引用传递出去,那么其余线程中就能看见final字段的值。

后记之CPU缓存行和伪共享

什么是伪共享

根据前面的文章,咱们知道CPU和Memory之间是有Cache的,而Cache内部是按行存储的,行拥有固定的大小,这些行被称为缓存行。 当CPU访问的某个变量不在Cache中时,就会去内存里获取,并将该变量所在内存的一个缓存行大小的数据读入Cache中。因为一次读取并非单个对象而是一整个缓存行,因此可能会存在多个变量被读入一个缓存行中。而一个缓存行只能同时被一个线程操做,因此当多个线程同时修改一个缓存行里的多个变量时会形成其余线程等待从而带来性能损耗(可是在单线程状况下,伪共享反而会提高性能,由于一次性可能会缓存多个变量,节省后续变量的读取时间)。

如何避免伪共享

在Java8以后可使用JDK提供的@sun.misc.Contended注解来解决伪共享,像Thread中的threadLocalRandom 字段就使用了这个注解。

相关文章
相关标签/搜索