【Java进阶笔记】Java内存模型(内存一致性、volatile原理)

1. JVM 内存模型

.java文件会被编译器编译为.class文件,然后由JVM中的类加载器加载各个类的字节码文件,加载完毕后,交由JVM执行。JVM会用一段空间来存储程序执行期间需要的数据和相关信息,这段空间一般称为Runtime Data Area运行时数据区,也就是JVM内存。

image

1.1. 程序计数器

程序计数器是一个记录着当前线程所执行的字节码的行号指示器。

JVM 采用 CPU 时间片轮转算法来调度多线程。当被挂起的线程重新获取到时间片时,它必须知道上次执行到哪里才能继续执行,因此程序计数器就是记录某个线程的字节码执行位置。

  • 占用的内存空间较小。
  • 线程私有,每个线程都有独立程序计数器。
  • JVM 规范中唯一没有规定 OutOfMemoryError 情况的区域。
  • 执行 java 方法时,程序计数器是有值的,且记录的是正在执行的字节码指令的地址。
  • 执行 native 本地方法时,程序计数器的值为空(Undefined)。因为 native 方法是 java 通过 JNI 直接调用本地 C/C++ 库,由于该方法是通过 C/C++ 实现,无法产生相应的字节码,并且 C/C++ 执行时的内存分配是由自己语言决定的,而不是由 JVM 决定的。

1.2. 虚拟机栈

https://www.jianshu.com/p/ecfcc9fb1de7

描述 Java 方法执行的内存模型,用于存储栈帧。

  • 线程隔离性,每个线程都有独立虚拟机栈。
  • 使用的内存不需要保证是连续的。
  • JVM 规范既允许虚拟机栈被实现成固定大小(栈容量在线程创建时确定),也允许通过动态扩容和收缩来调整大小。

1.2.1. 栈帧

每个线程中调用一个相同或不同的方法,都会创建一个新的栈帧。调用的方法链越多,创建的栈帧越多(递归)。每个方法从调用到执行完成的过程,就对应入栈到出栈的过程。在 Running 线程中,所有的指令都只能针对当前帧(位于栈顶的帧)进行操作。

存储局部变量表、操作数栈、动态连接、方法返回地址、附加信息等信息。

  • 局部变量表:用于存放方法参数和方法内部定义的局部变量。
  • 操作数栈:方法的执行操作都在此完成,每一个字节码指令往操作数栈进行写入和提取的过程,就是入栈和出栈的过程。JVM 的 JIT 引擎称为 “基于栈的执行引擎”,这里的 “栈” 就是操作数栈。
  • 动态连接:每个栈帧都包含一个指向运行时常量池中该栈帧所属性方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
  • 方法返回地址:方法退出后,需要返回到被调用的位置程序才能继续执行。一般地,方法正常退出时,返回地址可以是调用者的程序计数器的值,栈帧中很可能会保存这个计数器值。方法异常退出时,返回地址要通过异常处理器表来确定,栈帧中一般不会保存这部分信息。
  • 附加信息:JVM 规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。

1.2.2. 栈内存溢出

导致栈内存溢出的情况:

  • 压入的栈帧过多(递归调用)。
  • 压入的栈帧过大(不容易出现)。

1.3. 本地方法栈

与虚拟机栈几乎相同,对象是Native方法。为虚拟机使用到的 Native 方法服务。JVM 规范中对本地方法栈没有强制规定,不同虚拟机可以自由实现。

image
  • 本地方法栈是一个后入先出栈。
  • 由于是线程私有的,生命周期随着线程,线程启动而产生,线程结束而消亡。
  • 本地方法栈会抛出 StackOverflowErrorOutOfMemoryError 错误。

1.4. 堆

最大的内存空间,被所有线程共享,用来存储对象实例及数组内容。几乎所有的对象实例都会存储在堆中分配。

  • 从内存分配的角度看,线程共享的 Java 堆中可能划分出多个线程私有的线程本地分配缓存区(Thread Local Allocation Buffer,TLAB)。
  • JVM 规范规定,Java 堆可以物理不连续,只要逻辑连续即可。既可以是固定大小,也可以是可扩展大小,主流虚拟机都是按照可扩展实现。
  • 如果是可扩展大小,如果尝试扩展时无法申请到足够的内存,那 JVM 将抛出 OutOfMemoryError 异常。

1.5. 方法区

JVM 规范把方法区描述为堆的一个逻辑部分,但它有一个别名 Non-Heap(非堆),目的是与 Java 堆区分开来。

  • 方法区与 Java 堆一样,是所有线程共享的内存区域。
  • JDK7 之前(永久代)用于存放已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
  • 运行时常量池是方法区的一部分。Class 文件中除了有类的版本 / 字段 / 方法 / 接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将类在加载后进入方法区的运行时常量池中存放。运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的是 String.intern() 方法。受方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
  • Java 中包装类 ByteShortIntegerLongCharacterBoolean 都实现了常量池技术, FloatDouble 则没有实现。 ByteShortIntegerLongCharacter 这 5 种整型的包装类也只是在对应值在 -128~127 之间时才可使用对象池。

1.5.1. 组成结构

【JDK 6】

永久代物理上是堆的一部分,和新生代,老年代地址是连续的。

Coroutine.drawio

【JDK 8】

元空间属于本地内存。

image

1.5.2. 方法区内存溢出

  • JDK 8之前会导致永久代内存溢出 java.lang.OutOfMemoryError: PermGen space
  • JDK 8之后会导致永久代内存溢出 java.lang.OutOfMemoryError: Metaspace


2. 逃逸分析

逃逸分析是 Java 虚拟机中的一种优化技术,但它并不是直接优化代码,而是为其他优化手段提供优化依据的分析技术。JDK8 默认开启。

逃逸分析的基本行为就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,称为方法逃逸;也可能被外部线程访问到,称为线程逃逸

对象的三种逃逸状态:

  • 全局逃逸 GlobalEscape: 一个对象的引用逃出了方法或者线程:
    • 对象的引用赋值给一个类变量(成员变量或静态成员变量)。
    • 对象的引用存储在已逃逸的对象中。
    • 对象的引用作为方法的返回值返回。
  • 参数逃逸 ArgEscape: 在方法调用过程中传递对象的引用给调用方法。
  • 没有逃逸 NoEscape: 一个可以进行标量替换的对象,可以不将这种对象分配在堆上。
private Object o;
private HashMap map = new HashMap<>();

// 给成员变量赋值,发生全局逃逸
public void test1() {
    o = new Object();
}

// 存储在已逃逸对象中,发生全局逃逸
public void test2() {
    Object o = new Object();
    map.put(0, o);
}

// 作为方法返回值,发生全局逃逸
public Object test3() {
    return new Object();
}

// 实例引用传递,发生参数逃逸
public void test4() {
    Object o = methodPointerEscape();
}

// 纯粹的局部作用域,没有逃逸
public void test5() {
    Object o = new Object();
}

2.1. 标量替换

把一个 Java 对象拆散,根据程序访问的情况,将其使用到的成员变量恢复到基本数据类型来访问,就叫标量替换。

【标量】

一个数据无法再分解为更小的数据来表示了,Java 虚拟机中的基本数据类型 byteshortintlongbooleancharfloatdouble 以及 reference 类型等,都不能再进一步分解了,这些就可以称为标量。

【聚合量】

一个数据可以继续分解,就称为聚合量。对象就是最典型的聚合量。

【替换过程】

如果一个对象没有逃逸,则运行时可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来替代。

将对象拆分后,除了可以让对象的成员变量在栈上分配和读写外(栈上存储的数据,有很大概率会被虚拟机分配至物理机器的高速寄存器中存储),还可以为后续的进一步优化手段创造条件。

class User {
    int age;
    int id;
}

public void test() {
    // 由于User对象没有逃逸,且User对象可以被拆分为两个标量
    // 因此这个User对象可以被分配在栈中
    User user = new User();
    // user.id = 1;
}

2.2. 栈上分配

基于逃逸分析和标量替换。JDK8 默认开启。

【原理】

方法内局部变量对象未发生逃逸,则使用标量替换将该对象分解,并在栈上分配内存,不在堆中分配,分配完成后,继续在调用栈内执行。

方法执行完后自动销毁,线程结束后栈空间被回收,局部变量对象也被回收,不需要 GC ,提高系统性能。

public static void alloc() {
    byte[] b = new byte[2];
    b[0] = 1;
}

public static void main(String[] args) {
    // 短时间内在堆内存中大量创建和销毁对象,会频繁GC,引发内存抖动,最终的执行时间约900ms左右
    // 使用栈上分配可以完全避免堆内存的内存抖动,最终的执行时间约6ms左右
    for (int i = 0; i < 100000000; i++) {
         alloc();
    }
}

【使用场景】

对于大量的零散小对象,栈上分配的速度快,可以避免 GC 带来的 Stop The World。但栈空间比较小,因此大对象不适合进行栈上分配。

2.3. 同步消除

如果一个对象没有逃逸,对这个变量的同步措施就可以消除掉。单线程中是没有锁竞争。(即锁和锁块内的对象不会逃逸出线程,就可以把这个同步块取消)

public static void alloc() {
    byte[] b = new byte[2];
    // 不会线程逃逸,所以该同步锁可以去掉
    // 开启使用同步消除执行时间 10 ms左右
    // 关闭使用同步消除执行时间 3870 ms左右
    synchronized (b) {
         b[0] = 1;
    }
}

public static void main(String[] args) {
    for (int i = 0; i < 100000000; i++) {
         alloc();
    }
}

你可能感兴趣的:(【Java进阶笔记】Java内存模型(内存一致性、volatile原理))