JVM 内存结构


JVM内存区域划分

JVM运行时数据区分为:堆、方法区、栈(虚拟机栈、本地方法栈)、程序计数器。

JVM内存布局

1. 程序计数器 Program Counter

  • 线程私有,是当前线程的字节码行号指示器。
    • 如果线程执行 Java 方法,指示字节码指令的地址(行号指示器);
    • 如果执行的是 Native 方法,计数器值为 Undefined
  • PC 中无异常发生。

程序计数器保证了线程挂起和线程再次获得CPU使用权时,代码的运行顺序。

2. 虚拟机栈(线程栈)Java Virtual Machine Stacks

  • 虚拟机栈为 Java 方法服务。
  • 线程私有,每个线程都维护一个与线程同时创建的虚拟机栈,线程调用的每个方法对应一个栈帧,方法的调用与退出对应了栈帧的入栈与出栈:
    • 每个栈帧中储存了一个方法的状态信息:局部变量表操作数栈动态链接方法出口
  • 可能发生的异常:
    • StackOverflowError:方法调用的深度超过了虚拟机栈深度。
    • OutOfMemoryError:虚拟机栈随着内容的增加会动态扩展内存,扩展到一定程度无法申请到足够的内存时抛出该异常。
  • 使用 -Xss 设置栈大小,通常几百K就够用了。由于栈是线程私有的,线程数越多,占用栈空间越大。

image

2.1 局部变量表

局部变量表用于储存:

  • 基本数据类型(boolean, byte, char, int, long, float, double)
  • 对象引用(包括 方法参数局部变量):可以是直接地址指针,或者句柄
  • returnAddress 类型(指向调用该方法的字节码指令的地址)

2.1.1 变量槽 Variable Slot

局部变量表的最小单位是 变量槽(Variable Slot),每个变量槽 32 位(4字节)。

  • 通过 index 的方式直接索引。
  • 每个变量槽占 32 位,可以存放任意一个 32 位以内数据类型的数据。
    • boolean, byte, char, short, int, float, reference, returnAddress
    • 对于 64 位的 longdouble, 分配两个连续的 Slot,并以高位对齐
  • 普通方法 0 号位存放 this 指针,static 方法没有 this,所以直接存其他数据。
  • 为了节省栈空间,Slot 可以复用,当局部变量作用域失效时,可以被其他数据覆盖。

2.2 操作数栈

存在于虚拟机栈中,用于辅助计算,存放用于计算的操作数。

2.2.1 局部变量表 与 操作数栈 的共享区域

在概念模型中,一个活动线程中两个栈帧是相互独立的。但大多数虚拟机实现都会做一些优化处理:让下一个栈帧的部分操作数栈与上一个栈帧的部分局部变量表重叠在一起,这样的好处是方法调用时可以共享一部分数据,而无须进行额外的参数复制传递。

2.3 动态链接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用(栈指向方法区),持有这个引用是为了支持方法调用过程中的动态连接;

字节码中方法调用指令是以 class 文件常量池中的指向方法的符号引用为参数的:

  • 一部分符号引用会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为 静态解析
  • 另外一部分在每次的运行期间转化为直接引用,这部分称为动态连接

静态解析对应的是非虚方法,这些方法编译期可知,运行期不可变:

  • 静态方法,私有方法,构造器,父类方法等;

虚方法是在运行期间将符号引用转化为直接引用。

2.4 方法返回地址

方法返回地址中存放调用该方法的 PC 寄存器的值。

当一个方法被执行后,有 2 种方式退出方法:

  1. 执行引擎遇到任意一个方法返回的字节码指令,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。
  2. 在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理(即本方法异常处理表中没有匹配的异常处理器),就会导致方法退出,这种退出方式称为异常完成出口(Abrupt Method Invocation Completion)。
    • 异常退出方式不会给上层调用者产生任何返回值。

无论采用何种退出方式,在方法退出后,都需要返回到方法被调用的位置,程序才能继续执行。方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。

  • 方法正常退出时,调用者的程序计数器 PC 的值可以作为返回地址,栈帧中保存这个计数器值。
  • 方法异常退出时,返回地址是通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。

方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整程序计数器 PC 的值,以指向方法调用指令后面的一条指令等。

3. 本地方法栈

本地方法栈为 native 方法服务,其他与虚拟机栈类似。

native 方法比如:String.intern() 方法。

异常:

  • StackOverflow
  • OutOfMemory

本地方法

为什么使用本地方法?

  • 因为有些底层操作采用java不好实现,本地方法可以实现一些java语言本身不能实现的特性。

为什么jvm里可以运行本地方法?

  • jvm本身就是用c实现的。
  • 本地方法被封装在类中,类在加载后会把一些符号引用替换为直接引用。本地方法描述符会指向方法的实现,在方法调用的时候,本地方法对应的dll文件会被加载,然后在本地方法栈中执行。

hot spot 虚拟机中的 虚拟机栈 和 本地方法栈 是合在一起的。

4. Java 堆 Heap

4.1 堆的用途

  • 堆在 JVM 启动时被创建,用于存放所有的对象实例、数组、String 等,在其他地方使用的是对堆中实例对象的引用。
  • Java 堆是 JVM 内存管理的最大区域,是垃圾回收的主要场所。
  • Java 堆是线程共享的。

4.1.1 什么时候会在栈上分配对象?

这与逃逸分析这一概念有关。有关逃逸分析,看[编译与优化]。

栈上分配是针对逃逸分析的当编译器能证明一个对象不会发生逃逸时,所做的优化。从 JDK 1.7 开始,逃逸分析默认开启,但栈上分配会被标量替换所替代,通过把对象分解为一系列标量,能够直接在栈上分配内存。

4.2 堆的结构

Java 堆按垃圾回收区域分为 年轻代老年代 ,具体:
java堆a

image

  • 年轻代存储新创建的对象。当年轻内存占满后,会触发 Minor GC,清理年轻代内存空间。
  • 老年代存储长期存活的对象和大对象。年轻代中存储的对象,经过多次GC后仍然存活的会移动到老年代中进行存储。老年代空间占满后,会触发Full GC,清理整个堆空间,包括年轻代和老年代。如果Full GC之后,堆中仍然无法存储对象,就会抛出OutOfMemoryError异常。

4.3 Java 堆设置常用参数

  • -Xms:设置Java应用程序启动时的初始堆大小
  • -Xmx:设置Java应用程序能获得的最大堆大小
  • -Xss:设置线程栈的大小
  • -XX:MinHeapFreeRatio:设置堆空间最小空闲比例。当对空间的空闲内存小于这个数值时,JVM便会扩展堆空间
  • -XX:MaxHeapFreeRatio:设置堆空间的最大空闲比例。当堆空间的空闲内存大于这个数值时,便会压缩堆空间,得到一个较小的堆
  • -XX:NewSize:设置新生代的大小
  • -XX:NewRatio:设置老年代与新生代的比例,它等于老年代大小除以新生代大小
  • -XX:SurviorRatio:新生代中eden区与survivior区的比例
  • -XX:MaxPermSize:设置最大的持久区的大小
  • -XX:PermSize:设置永久区的初始值
  • -XX:TargetSurvivorRatio:设置survivior区的可使用率。当survivior区的空间使用率达到这个数值时,会将对象送入老年代

5. 方法区 Method Area (元空间 Meta Space)

  • 存放已被虚拟机加载的 类信息类常量static变量即时编译后的代码 等。(方法区中存放类元信息,而static变量随着Class类放在堆中)
    • 存放每个类的结构信息
    • 类属性,这里的常量应该是 static final 类常量
    • 类的方法
    • 方法和类的构造函数的代码(包括类实例在初始化、接口初始化中使用的特殊方法)
  • 线程共享
  • 在 JDK 1.7 及以前,方法区是堆的一个逻辑部分;为了与堆区分,被称为“非堆”或“永久代”。JDK 1.8 时,把方法区改名为元空间,直接占用本地内存。

5.1 运行时常量池

运行时常量池的概念是相对于静态常量池(class文件常量池)的,因为在运行时常量也会增加。运行时常量池是方法区的一部分,类加载时 class 文件常量池会被加载到 运行时常量池中。

  • 运行时常量池是线程共享的。
  • 存储 Java class文件常量池中的符号信息。在类加载时,第一步是“加载 Loading”,包括三步,其中,第(2)步中就包含了.class文件中的 class文件常量池 进入 运行时常量池 的过程。:
    1. 通过类全限定名来获取二进制字节流;
    2. 把字节流所代表的静态存储结构转化为方法区的运行时数据结构
    3. 方法区中生成类对应的 Class 对象,作为该类的各种数据访问入口。
  • 存放直接引用
    • 类加载的“解析”阶段会将符号引用所翻译出来的直接引用存储在 运行时常量池 中。
  • 常量不一定编译时才能产生,运行时产生的常量会放入运行时常量池。
    • 例如String.intern()方法会在运行时产生字符串常量,并放入字符串常量池。

5.1.1 字符串常量池

字符串常量池用于保存字符串常量,是为了减少相同的字符串被重复创建,以减少内存占用。

  • JDK 1.7 之前,字符串常量池存在于方法区(永久代)中的运行时常量池内;
  • JDK 1.7 时,字符串常量池被单独移到了堆中,运行时常量池剩余的内容仍在方法区(永久代)中;
  • JDK 1.8 移除了永久代,使用了元空间 Meta Space。此时,
    • 元空间直接占用本地内存
    • 运行时常量池在元空间中
    • 字符串常量池仍留在堆中

String类为例,可以通过反编译验证:

String str1 = "abcd";               // 放在 常量池 中
String b = "a" + "true";            // 编译期就计算完,然后放在 常量池

// 通过 new 的方式创建String,其直接引用对象是在堆中,但仍涉及字符串池。
// 具体:先判断字符串常量池内有无该字符串:
// 如果有,在堆中复制一个副本,把副本地址给引用;
// 如果没有,先在字符串池中创建一个,然后复制副本到堆,然后把副本地址给引用。
String str1 = new String("abcd");

对于字符串池的详细信息和String.intern()方法查看String总结中的内容。

5.2 方法区随 JDK 版本的变化

方法区的变化

JDK 1.7

JDK 1.7时的Hotspot VM,把方法区中的静态变量字符串常量池等移到了堆内存。运行时常量池剩余内容仍在方法区(永久代)中。

JDK 1.8

JDK 1.8中,方法区被替换为元空间 Meta Space,它没有占用堆内存,而是直接占用本地内存。

  • 运行时常量池在元空间中,
  • 但字符串常量池仍留在堆中。

方法区的参数取代为:

  • -XX:MetaspaceSize
  • -XX:MaxMetaspaceSize

6. 栈、堆、方法区的指向关系

6.1 栈指向堆

当方法中 new 变量时,会在堆中新建对象,然后返回对象引用给栈帧中的局部变量表

public void test() {
    Object A = new Object();
}

6.2 方法区指向堆

方法区中存放类变量(static)、常量等。

public static Object A = new Object();

6.3 堆指向方法区

堆中的对象想要拿到类信息,需要从对象头指向方法区中的类信息。

Reference

一文搞懂JVM内存结构

《深入理解Java虚拟机》

JVM常量池浅析

JVM中的常量池详解


文章作者: Yu Yang
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Yu Yang !
 上一篇
堆中的对象内存分布 堆中的对象内存分布
Java 堆中的对象内存1. java中的对象指向问题public class HeapMemory { private Object obj1 = new Object(); public static void ma
2020-09-26
下一篇 
垃圾回收机制 垃圾回收机制
Java 垃圾回收 GC 1. 方法区的垃圾回收方法区的垃圾回收主要针对:废弃的常量和不再使用的类型信息。 废弃的常量,比如常量池中的字面量,如字符串池中的某个字符串的值已经不与任何字符串对象相同。 回收类型信息又被称为类型卸载。回收条件
2020-09-26
  目录