JVM内存模型

  内存是冯诺依曼体系中重要的一环,负责存储计算的数据和计算函数本身,是cpu唯一可以直接读取的硬件。对于c、c++开发人员来说,他们需要手动的申请和释放计算机内存,什么都靠自己,权力越大也就越累。对于Java程序员,在虚拟机的自动内存管理机制的帮助下,不需要再去手动操作内存(当然Java也提供了函数允许你这么做),不容易出现内存泄漏和内存溢出的问题。这一节我们就讲一下虚拟机自动内存管理机制的那些事。 

一. 内存结构

  虚拟机在启动时会去向计算机申请内存,虚拟机会将这一计算机内存分为5个虚拟机内存区域:

  1. 方法区、2. 堆、3. 虚拟机栈、4. 本地方法栈、5. 程序计数器

  当然了,Java还提供了一些方法允许开发人员自己控制内存,如Unsafe类,VarHandle类等。通过这些类创建的对象会保存到 6. 直接内存区域

  虚拟机内存模型如下图。其中方法区和堆属于线程共享,线程私有的区域有虚拟机栈、本地方法栈、程序计数器

 

方法区

  方法区(Method Area)是共享的内存区域,它用于保存JVM加载过的类信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

  在jdk1.8以前的HotSpot中,方法区是通过永久代(Perm)实现的。永久代通过-XX:PermSize和-XX:MaxPermSize指定,默认情况下-XX:MaxPermSize为64MB。一个大的永久区可以保存更多的类信息,如果永久区的内存被占满可能会发生内存溢出。值得注意的是,如果大量使用了动态代理,可能会在运行时产生大量的类而造成OOM。

  可以看到,用永久代实现的方法区不是一个很好的设计,由于运行时的情况很难预估,而永久代又必须指定一个MaxPermSize,由此操作的OOM时有发生。

  在jdk1.6之后,HotSpot就已经有逐步放弃永久代转而通过本地内存来实现的计划了。到jdk1.7时的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出到了堆中。到了jdk1.8已经用元数据区MetaSpace代替了永久代。MetaSpace如果不指定内存大小,理论上可以使用计算机内存的最大值。

  和方法区一样,堆也是共享的内存,堆中共享的是类的实例和数组。

  堆是最重要的一块内存区域,JVM调优一大部分都是在调这个区域的性能。

  堆是JVM中最大的一块内存空间,也是最容易发生OOM的内存空间。我们常说的垃圾回收器就是用来管理堆的,而垃圾回收器的算法又与JVM的响应时间和吞吐量密切相关,可以说堆是JVM中最重要的一块内存区。根据垃圾回收器的算法不同,堆也有不同的结构。关于堆的更多内容我们放在垃圾回收器的时候再探讨。

  

虚拟机栈

  虚拟机栈是线程私有的,它的生命周期和线程相同,描述Java方法执行的内存模型。虚拟机栈由一个个栈帧组成,每执行一个Java方法都会创建一个栈帧。栈帧包括:局部变量表、操作数栈、动态连接、方法出口。

  1. 局部变量表:存放了各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型)和returnAddress(指向了一条字节码指令的地址)。局部变量表的数据结构和数组类似,由一个个槽Slot表示,其中64字节的long和double类型会占据两个槽,其他类型占用一个槽。也就是说,无论时short还是int 在局部变量表中占用的内存大小事一样的,当然如果是分配在堆中还是不一样的。局部变量表的大小在编译器就已经确定,运行时不会改变。注意这里说的大小指的是槽的个数,具体每个槽占用多少字节由各自的虚拟机实现自行决定。

  2. 操作数栈:用于存放Java方法执行的操作数,它就是一个先进后出的栈结构,操作的元素可以是任何Java数据类型。如果你对操作数栈的功用有所疑问,可以了解一下后缀表达式。操作数栈本质是JVM执行引擎的一个工作区,如果没有代码执行,操作数栈是空的。

  3. 动态连接:指向运行时常量池中该栈帧所属方法的引用,该引用的目的是为了支持动态代理。

  4. 方法出口:标记方法的结束,也就是本栈帧结束后要跳到哪一行继续执行。

 

本地方法栈

  本地方法栈和虚拟机栈的功能和结构是相近的,其区别是本地方法栈是为虚拟机使用到的本地方法(Native)方法服务。

程序计数器

  虚拟机的程序计数器和操作系统的程序计数器很类似,可以看作是当前线程所执行的字节码的行号指示器,当前线程切换到其他线程时,需要保存程序计数器到内存或磁盘上保护现场,当切换回来时再根据程序计数器恢复现场继续从计数器所指向的行号执行。

  程序计数器是JVM内存区域中唯一不会发生OOM的区域。其他的区域中 堆的OOM最为常见。在1.7之前方法区由于大小受参数限制,也可能发生OOM。虚拟机栈和本地方法栈,除了OOM还有一个常见的错误:StackOverflow。

 直接内存

  详见此篇:JVM的直接内存

 

  除了上诉6个内存区域,还有几个比较重要的常量池:

Class常量池(静态常量池)

  我们都知道,class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)。 

  字面量:

    给基本类型变量赋值的方式就叫做字面量或者字面值。

    比如:String a=“b” ,这里“b”就是字符串字面量,同样类推还有整数字面值、浮点类型字面量、字符字面量。

  符号引用:

    符号引用以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量,JAVA 在编译的时候一个每个 java 类都会被编译成一个 class 文件,但在编译的时候虚拟机并不知道所引用类的地址(实际地址),就用符号引用来代替,而在类的解析阶段(后续 JVM 类加载会具体讲到)就是为了把 这个符号引用转化成为真正的地址的阶段。

    一个 java 类(假设为 People 类)被编译成一个 class 文件时,如果 People 类引用了 Tool 类,但是在编译时 People 类并不知道引用类的实际内存地址,因 此只能使用符号引用(org.simple.Tool)来代替。而在类装载器装载 People 类时,此时可以通过虚拟机获取 Tool 类的实际内存地址,因此便可以既将符号 org.simple.Tool 替换为 Tool 类的实际内存地址。

运行时常量池

  运行时常量池是每一个类的常量池的运行时表达形式,它包括了若干种不同的常量: 从编译期可知的数值字面量到必须运行期解析后才能获得的方法或字段引用。(这个是虚拟机规范中的描述,很生涩)。
  运行时常量池是在类加载完成之后,将 Class 常量池中的符号引用值转存到运行时常量池中,类在解析之后,将符号引用替换成直接引用。 运行时常量池在 JDK1.7 版本之后,就移到堆内存中了,这里指的是物理空间,而逻辑上还是属于方法区(方法区是逻辑分区)。
   在 JDK1.8 中,使用元空间代替永久代来实现方法区,但是方法区并没有改变,所谓"Your father will always be your father"。变动的只是方法 区中内容的物理存放位置,但是运行时常量池和字符串常量池被移动到了堆中。但是不论它们物理上如何存放,逻辑上还是属于方法区的。
  运行时常量池相对于 Class 常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入CLass文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用比较多的就是String类的intern()方法。

字符串常量池

  字符串常量池是比较有争议的一个,它不是虚拟机规范规定的概念。所以与运行时常量池的关系不 去抬杠,我们从它的作用和 JVM 设计它用于解决什么问题的点来分析它。

  以 JDK1.8 为例,字符串常量池是存放在堆中,并且与 java.lang.String 类有很大关系。设计这块内存区域的原因在于:String 对象作为 Java 语言中重要的数据类型,是内存中占据空间最大的一个对象。高效地使用字符串,可以提升系统的整体性能。 所以要彻底弄懂,我们的重心其实在于深入理解 String。

String

  String 对象是对 char 数组进行了封装实现的对象,主要有 2 个成员变量:char 数组,hash 值。

  String 类的不可变性,String 类中的 char 数组被声明为 final ,所以 String 是不可变类型。这么做的好处是什么呢?:

    1. 保证 String 对象的安全性,不被恶意篡改。

    2. 保证 hash 属性值不会频繁变更,确保了唯一性,使得类似 HashMap 容器才能实现相应的 key-value 缓存功能。

    3. 可以实现字符串常量池。在 Java 中,通常有两种创建字符串对象的方式,一种是通过字符串常量的方式创建,如 String str=“abc”;另一种是 字符串变量通过 new 形式的创建,如 String str = new String(“abc”)。

String 的两种创建方式

  1. String str = "abc";

    这种方式创建字符串时,JVM首先会检查对象是否在字符串常量池中,如果在就返回该对象,否则在字符串常量池中创建一个字符串。字符串常量池就像一个缓存,来给各个线程栈帧使用以达到节约内存的目的(据统计分析会节约很多内存)。

  2. Sring str = new String("abc");

    首先在编译类文件时,"abc" 常量字符串会放入到常量结构中,在类加载时,"abc" 将会在常量池中创建;其次,在调用 new 时,JVM 命令会调用 String 的构造函数,同时引用常量池中的 “abc” 字符串,在堆中创建一个String对象,最后 str 引用这个对象。

  简言之:第一种方式,堆中没有 String 对象。第二种方式,堆中有 String 对象。

  3. String str = "ab" + "cd" + "ef";

    从代码分析来看,上诉语句应该会创建 "ab"、"abcd"、"abcdef" 三个字符串到字符串常量池中,不过实际上,JVM会将其优化成 String str= "abcdef"; 只有一个 "abcdef" 字符串。

  4. 大循环使用 +

    在复杂逻辑中调用大量的字符串+时,JVM会优化其为 StringBuilder 。

  5. String str = new String("abc").intern();

    在调用 new String() 会在堆上创建一个 str 的对象,"abc" 字符串会在字符串常量池中创建。在调用 inern() 后,会去常量池中查找是否有等于该字符串的引用,有就返回引用。

 

二. 内存模型

as-if-serial

  先来看一个反常识的例子:

int a=0, b=0;

public void method1() {
  int r2 = a;
  b = 1;
}

public void method2() {
  int r1 = b;
  a = 2;
}

  我们猜一下,r1 r2 会存在哪些结果?

  在单线程下调用,可能先执行 method1,那么结果 (1, 0)。也可能先执行 method2 ,那么结果 (0, 2)。

  在多线程环境下,假设这两个方法分别跑在两个不同的线程之上,如果 Java 虚拟机在执行了任一方法的第一条赋值语句之后便切换线程,那么最终结果将可能出现(0,0)的情况。

  除上述三种情况之外,Java 语言规范第 17.4 小节还介绍了一种看似不可能的情况(1,2)。造成这一情况的原因有三个,分别为即时编译器的重排序,处理器的乱序执行,以及内存系统的重排序。

  首先需要说明一点,即时编译器(和处理器)需要保证程序能够遵守 as-if-serial 属性。通俗地说,就是在单线程情况下,要给程序一个顺序执行的假象。即经过重排序的执行结果要与顺序执行的结果保持一致。

  然而,在多线程情况下,这种数据竞争(data race)的情况是有可能发生的。而且,Java 语言规范将其归咎于应用程序没有作出恰当的同步操作。

happens-before

  happens-before 关系是用来描述两个操作的内存可见性的,如果操作 X happens-before 操作 Y,那么 X 的结果对于 Y 可见。

  Java内存模型定义了如下几种 happends-before 关系

    1. 线程内的字节码先后顺序,不过字节码的顺序并不是代码的书写顺序,如果后者没有观测前者的运行结果,即后者没有数据依赖于前者,那么它们可能会被重排序。

    2. 解锁操作 happens-before 之后(这里指时钟顺序先后)对同一把锁的加锁操作。

    3. volatile 字段的写操作 happens-before 之后(这里指时钟顺序先后)对同一字段的读操作。

    4. 线程的启动操作(即 Thread.starts()) happens-before 该线程的第一个操作。

    5. 线程的最后一个操作 happens-before 它的终止事件(即其他线程通过 Thread.isAlive() 或 Thread.join() 判断该线程是否中止)。

    6. 线程对其他线程的中断操作 happens-before 被中断线程所收到的中断事件(即被中断线程的 InterruptedException 异常,或者第三个线程针对被中断线程的 Thread.interrupted 或者 Thread.isInterrupted 调用)。

    7. 构造器中的最后一个操作 happens-before 析构器的第一个操作。

  happens-before 关系还具备传递性。如果操作 X happens-before 操作 Y,而操作 Y happens-before 操作 Z,那么操作 X happens-before 操作 Z。

  上诉 as-if-serial 的例子中,如果要消除指令重排序造成的 (1,2)结果,可将 a, b 变量修饰为volatile。

内存模型的底层实现

  内存模型是通过内存屏障(memory barrier)来禁止重排序的。

  对于即时编译器来说,它会针对前面提到的每一个 happens-before 关系,向正在编译的目标方法中插入相应的读读、读写、写读以及写写内存屏障。

    1. 读读内存屏障,我读完你才能读。

    2. 读写内存屏障,我读完你才能写。

    3. 写读内存屏障,我写完你才能读。

    4. 写写内存屏障,我写完你才能写。

  内存屏障对应着计算机的 cpu 指令,不同操作系统对应的指令不同。换句话说内存屏障真正的实现是操作系统,虚拟机只是对 cpu 指令进行了抽象和封装。以我们日常接触的 X86_64 架构来说,读读、读写以及写写内存屏障是空操作(no-op),只有写读内存屏障会被替换成具体指令。

 

  前面提到,锁操作同样具备 happens-before 关系。具体来说,解锁操作 happens-before 之后对同一把锁的加锁操作。实际上,在解锁时,Java 虚拟机同样需要强制刷新缓存,使得当前线程所修改的内存对其他线程可见。

  需要注意的是,锁操作的 happens-before 规则的关键字是同一把锁。也就意味着,如果编译器能够(通过逃逸分析)证明某把锁仅被同一线程持有,那么它可以移除相应的加锁解锁操作。

  因此也就不再强制刷新缓存。举个例子,即时编译后的 synchronized (new Object()) {},可能等同于空操作,而不会强制刷新缓存。

  volatile 字段可以看成一种轻量级的、不保证原子性的同步,其性能往往优于(至少不亚于)锁操作。然而,频繁地访问 volatile 字段也会因为不断地强制刷新缓存而严重影响程序的性能。

  在 X86_64 平台上,只有 volatile 字段的写操作会强制刷新缓存。因此,理想情况下对 volatile 字段的使用应当多读少写,并且应当只有一个线程进行写操作。

  volatile 字段的另一个特性是即时编译器无法将其分配到寄存器里。换句话说,volatile 字段的每次访问均需要直接从内存中读写。

 

  final 实例字段则涉及新建对象的发布问题。当一个对象包含 final 实例字段时,我们希望其他线程只能看到已初始化的 final 实例字段。

  因此,即时编译器会在 final 字段的写操作后插入一个写写屏障,以防某些优化将新建对象的发布(即将实例对象写入一个共享引用中)重排序至 final 字段的写操作之前。在 X86_64 平台上,写写屏障是空操作。

  新建对象的安全发布(safe publication)问题不仅仅包括 final 实例字段的可见性,还包括其他实例字段的可见性。

   当发布一个已初始化的对象时,我们希望所有已初始化的实例字段对其他线程可见。否则,其他线程可能见到一个仅部分初始化的新建对象,从而造成程序错误。这里我就不展开了。

posted @ 2020-09-26 22:49  Super-Yan  阅读(138)  评论(0编辑  收藏  举报