[jvm]详谈JAVA内存模型

2019独角兽企业重金招聘Python工程师标准>>> hot3.png

JAVA内存模型

犹记得大学时操作系统课上,我们迷茫的眼神注视着带着厚眼镜教授向我们一遍遍的强调,一个程序最少有一个进程组成,进程是操作系统提供独立资源供应用程序运行的基本单位。另外老师向我们讲到,为了更好的提高计算机的并行计算能力,计算机科学家们又设计了线程。线程是比进程更小的单位,一个进程可以由多个线程组成。同时线程也是在得到cpu时间片时可运行的最小的单位。尤记得在这些理论基础下,我慢慢的学会了使用C在LINUX环境下使用多进程和多线程进行编程。这些并发API都是LINUX提供的标准API,程序在编译链接之后可以直接调用通过系统内核创建进程或线程,在类UNIX操作系统下这样去做可以让程序有更高的性能。但是这种编程方式所编写出来的代码是与操作系统绑定的,我在linux下明明可以完美运行的代码,在windows下连编译甚至都做不到。除非是用 windows API重新把与操作系统进行交互的那些代码给替换掉,否则就不要想着让程序去跨平台运行了。
在进行并发编程时相对于C,我更喜欢java的编程体验。java消除操作系统之间的差异,原生的对多线程应用提供了很好的支持,特别是在jdk1.5之后,jdk还提供了currency包,更好的让程序员们无需去关注并发的难点与细节,更专心的关注应用的业务需求实现。
在多线程编程中最长遇到的问题就是线程安全问题,其中又以内存中的数据安全问题最为常见(这个数据安全指的是发生脏读、幻读等并发编程中会遇到的错误)。为了消除不同硬件和操作系统对内存操作的差异,在硬件设备和操作系统的内存模型之上,java虚拟机规范定义了一种java内存模型(JAVA Memory Model,简称JMM),将内存分为了工作内存和主内存。JMM主要定义了JVM中在内存中操作变量的规则和细节,用来解决在并发竞争对变量操作时可能会发生的各种问题。JMM完全兼容CPU的多级cache机制,并且支持编译器的代码重排序。
注意:JMM内存模型的概念不同于jvm中6大内存区域的概念,两者不可强行混为一谈。

非原创声明    >本文并非我的原创文章,而是我学习jvm时的笔记。文中的材料与数据大部分来自于其它资料,详细请查看本文的引用章节。

主内存和工作内存

JMM规定了所有的全局变量都存于主内存(Main Memory)中,一般情况下这些全局变量都会被保存到堆里。每个线程都有自己的工作内存(Working Memory),工作内存中会保存当前线程所需用到的主内存中全局变量的拷贝和自己的局部变量。一般情况下工作内存指的是栈内存,从物理上来讲,工作内存一般情况下都会工作于cpu的cache里。一个线程对全局变量的任何操作必须在自己的工作内存中进行,不允许直接操作工作内存。不同线程不能访问对方的工作内容,只能通过主内存进行数据交换。 {% asset_img /images/java/jvm/JMM交互关系图.png JMM交互关系图 %}

工作内存在对主内存中变量做拷贝时,如果变量是基本类型的,则会拷贝其值;如果是引用类型的,那么仅仅会拷贝其引用

JMM内存操作

JMM定义了8种主内存与工作内存之间的具体交互操作,虚拟机在实现JMM时必须保证每种内存操作都是原子的。表1详细列出了JMM的8种内存操作。

| 指令 | 操作名 | 作用区域 | 描述 | :------ | :------ | :------ | | lock | 锁定 | 主内存 | 把一个变量标识为一个线程独占的状态 | unlock | 解锁 | 主内存 | 把一个处于锁定状态的变量释放出来 | read | 读取 | 主内存 | 把一个变量的值从主内存读取到工作内存中,以便于随后的load指令使用 | load | 载入 | 工作内存 | 把read指令从主内存中读取的变量值放入到工作内存的变量副本中 | use | 使用 | 工作内存 | 将工作内存中一个变量的值传递给执行引擎 | assign | 赋值 | 工作内存 | 把一个从执行引擎接收到的值赋给工作内存的变量 | store | 存储 | 工作内存 | 把一个工作内存中变量的值传送到主内存中,以便于随后的write指令使用 | write | 写入 | 主内存 | 把store指令从工作内存传出的变量的值写入到主内存的变量中 表1 JMM内存操作指令表

20170702 01:48 编辑到JMM内存操作指令表

JMM规定了在使用以上8种内存操作时必须遵守以下规则:

  • read,load或store,write必须成对出现。如:执行read操作从主内存读取一个变量后,必须在工作内存使用load载入这个变量。两者之间的顺序不可错,但两者之间可以穿插其它指令。同理,在工作内存中对一个变量使用了store指令,必须在主内存中使用write指令进行写入。
  • 不允许一个线程丢弃它最近做的assign操作。变量在工作内存中改变了之后必须将这个变化同步到主内存中。
  • 不允许一个线程没有发生过assign操作就将数据从工作内存同步到主内存。
  • 只能在主内存中新建全局变量,不能在工作内存中直接使用一个未被初始化的变量。可以使用assign和load指令在工作内存对一个变量进行初始化操作。
  • 一个变量在同一时刻只能被一个线程执行lock操作。
  • 如果对一个变量执行lock,那将清空工作内存中此变量的值。在执行引擎使用这个变量之前,需要重新load或assign重新初始化变量的值。
  • unlock只能解锁被本线程锁定的变量。
  • 对一个变量执行unlock之前,必须将此变量同步回主内存。

volatile 关键字

volatile是java语言所提供的关键字,使java最轻量级的内存同步的措施。与 synchronized 块相比,volatile 变量所需的编码较少,并且运行时开销也较少,但是它所能实现的功能也仅是 synchronized 的一部分。

java中锁提供了两种主要特性:互斥(mutual exclusion) 和可见性(visibility)。互斥即一次只允许一个线程持有某个特定的锁,因此可使用该特性实现对共享数据的协调访问协议,这样,一次就只有一个线程能够使用该共享数据。可见性要更加复杂一些,它必须确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的 。
volatile 变量具有 synchronized 的可见性特性,但是不具备锁的原子特性。所以即便volatile没有不一致的问题,但volatile变量在并发的运算下并不是原子操作,所以依然可能会有安全问题。

  package com.github.weiwei02.jvm.jmm.volatile_test;

  /**
  * volatile 线程安全性测试
  * [@author](https://my.oschina.net/arthor) Wang Weiwei [email protected] / [email protected]
  * [@version](https://my.oschina.net/u/931210) 1.0
  * [@sine](https://my.oschina.net/mysine) 2017/7/2
  */
  public class VolatileTest {
  public static volatile int race = 0;
  public static final int THREAD_COUNT=20;

  public static void main(String[] args) {
      Thread threads[] = new Thread[THREAD_COUNT];
      System.out.println(race);
      for (int i =0; i < THREAD_COUNT; i++){
          threads[i] = new Thread(() -> {
              for (int j = 0; j < 1000 ; j++) {
                  increase();
              }
          });
          threads[i].start();
      }


      //等待所有累加线程都结束
      while (Thread.activeCount() > 1){
          Thread.yield();
      }

      //等待所有线程执行完毕之后,打印race的最终值
      System.out.println(race);
  }

  private static void increase() {
      race++;
  }
  }

代码1 volatile 线程安全测试

程序的执行结果如图2所示。 图2 volatile 线程安全测试

这个程序执行的结果应该是20000,可实际执行所得到的结果只有18439.并且多次执行本程序都会得到不一样的结果。所以会出现这种情况的原因是 race++ 这个操作并不是原子操作,volatile关键字只能保证在使用race变量时,工作线程能够从主内存中拿取最新的race的值,并不能保证进行过++操作之后写回到主内存期间其它线程没有对race变量进行修改。 volatile关键字只能保证可见性,只有在以下两种条件下,我们可以认为volatile是线程安全的;

  1. 运算结果并不依赖变量的当前值,或者能够保证只有一个线程修改变量的值。
  2. 变量不需要与其它的变脸共同参与不变约束。

当不符合这两种条件时,想要保证volatile变量的原子性,只能通过 机制来实现。

volatile变量的第二个重要作用是禁止编译器进行指令重排序。普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。从硬件层面上来讲,是指CPU允许多条指令不按程序规定的顺序分开发送到个相应的电路单元来处理。CPU必须要正确的处理指令的依赖情况,不能将指令任意的排序。比如指令1把地址A中的值加10,指令2把地址A中的值乘以2,指令3将地址B中的值减去3。在这一系列指令中,指令1,2之间是有依赖关系的,不能进行重排序。但指令3的执行顺序可以改到指令1之前也可以放到指令1之后,只要能够保证在执行完毕这三条指令程序中A,B的值是正确的即可。volatile在某些情况下性能高于锁,由于虚拟机对锁实行有多种消除和优化,我们很难衡量volatile会比锁快多少。

原子性、可见性和有序性

20170803 23:11:51 原子性、可见性和有序性 JAVA内存模型的核心问题时为了解决多线程应用中的原子性、可见性与有序性的问题,保证了这三点才能使多线程应用有数据安全性可言。

  • 原子性 (Atomicity) 由java内存模型来直接保证原子性的操作包括read、load、assign、use、store和write。对于一个变量的基本读写操作都是具有原子性的。如果需要对多种这几种基本读写操作的操作集合保证原子性,可以使用lock和unlock。
  • 可见性(Visibility) 可见性是指当变量的值被一个线程修改了之后,其它线程能够立即知道这个修改。JAVA内存模型时通过变量内容修改后立刻同步到主内存,读取变量的值之前先从内存刷新这种方式来保证可见性。JAVA中除了volatile之外还有synchronized、final两个关键字能够实现内存可见性。synchronized的可见性是由"对一个变量执行unlock操作之前,必须先把此变量同步会主内存中(执行store,write操作)"这条规则获得的。final的可见性是由于被final修饰的变量在构造器中一旦被初始化完成,并且构造器没有吧this的引用传递出去(this引用是一件非常危险的事,其它线程可能通过这个引用访问到初始化一半的对象),,那在其它线程中就能看见final变量的值。
  • 有序性 如果在本线程内观察,所有的操作都是有序的,如果在一个线程中观察另外一个线程,所有的操作都是无序的。前半句指的是“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句指的是“指令重排序”现象和“工作内存与主内存同步延迟”现象。

先行发生原则

20170804 02:02:23 先行发生原则

先行发生原则(Happens-before)是JAVA内存模型中定义的两项操作之间的偏序关系,如果操作A先行发生于操作B,操作A造成的影响就能被操作B观察到。JAVA只能怪的先行发生原则无需任何同步协助,可以直接编码使用。以下列出JAVA中的先行发生原则,如果两个操作之间的关系不在下面的规则中,且无法通过下面的规则推导出来,虚拟机就能随意的对他们进行指令重排序。

  • 程序次序规则(Program Order Rule) :在一个线程内,按照程序的代码顺序,书写在前的操作先于书写在后面的操作发生。准确的说,应该是控制流顺序而不是程序代码顺序,因为需要考虑分支、循环等结构。

  • 管程锁定规则(Monitor Lock Rule) : 一个unlocak操作先行发生于后面对同一个锁的lock操作。

  • volatile变量规则(Volatile Variable Rule)
    :对一个volatile的写操作,先行发生于后面对这个变量的读操作。

  • 线程启动规则(Thread Start Rule)
    :线程的start方法先行发生于此线程的每一个动作。

  • 线程终止规则(Thread Termination Rule)
    : 线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.jion()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。

  • 线程中断原则(Thread Interruption Rule) :对线程的interrupt()方法的调用先行发生于被中断的线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。

  • 对象终结规则(Finalizer Rule) :一个对象的初始化完成(够构造函数执行结束)先行发生于它的fnalize()方法开始。

  • 传递性(Transitivity) :如果A操作先行发生于B操作,B操作先行发生于C操作,就可以得出A先行发生于C的结论。

时间先后顺序与先行发生原则之间基本没有关系,所以当我们需要衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。

20170804 23:59:46 先行发生原则

引用

  本文是对class文件的学习笔记,笔记的内容并非是原创,而是大量参考其它资料。在写作本文的过程中引用了以下资料,为为在此深深谢过以下资料的作者。   1. 《The Java Virtual Machine Specification》   2. 《深入理解Java虚拟机:JVM高级特性与最佳实践/周志明著.——2版.——北京:机械工业出版社,2013.6》

  ## 关于 原文链接 https://weiwei02.github.io/2017/07/02/jvm/001-java%E5%86%85%E5%AD%98%E6%A8%A1%E5%9E%8B/

转载于:https://my.oschina.net/weiwei02/blog/1503153

你可能感兴趣的:([jvm]详谈JAVA内存模型)