05运行时数据区
JVM 在运行 Java 程序时,会把它管理的内存划分成若干个区域,称为运行时数据区。《Java虚拟机规范》中规定了每一部分的作用,但允许不同虚拟机在实现上各不相同。从大类上看,JVM 由类加载器、运行时数据区域(JVM 管理的内存)、执行引擎(即时编译器、解释器、垃圾回收器等)和本地接口几部分组成,类加载器把 class 字节码文件中的内容加载到内存中,执行引擎负责运行,运行时数据区则负责管理 JVM 使用到的内存(比如创建对象和销毁对象)。

从内存是否共享的角度,可以将运行时数据区分成两类:线程独占的(程序计数器、Java虚拟机栈、本地方法栈)和线程共享的(堆、方法区)。

下面分别展开讲解。
1.程序计数器
程序计数器(Program Counter Register)也叫 PC 寄存器,每个线程会通过程序计数器记录当前要执行的字节码指令的地址。

在加载阶段,虚拟机把字节码文件中的指令读取到内存之后,会将原文件中的偏移量转换成内存地址,每一条字节码指令都会拥有一个内存地址。代码执行过程中,程序计数器会记录下一行字节码指令的地址,执行完当前指令后,虚拟机的执行引擎根据程序计数器执行下一行指令。除了顺序执行,程序计数器还可以控制程序指令的进行,实现分支、跳转、异常等逻辑。
在多线程执行情况下,Java 虚拟机需要通过程序计数器记录 CPU 切换前解释执行到哪一句指令,等线程再次被调度时继续解释运行。

程序计数器在运行中会出现内存溢出吗?内存溢出指的是程序在使用某一块内存区域时,存放的数据需要占用的内存大小超过了虚拟机能提供的内存上限。因为每个线程只存储一个固定长度的内存地址,程序计数器是不会发生内存溢出的,程序员无需对程序计数器做任何处理。
2.Java虚拟机栈
Java虚拟机栈(Java Virtual Machine Stack)采用栈的数据结构来管理方法调用中的基本数据,先进后出(First In Last Out),每一个方法的调用使用一个栈帧(Stack Frame)来保存。
public class MethodDemo {
public static void main(String[] args) {
study();
}
public static void study(){
eat();
sleep();
}
public static void eat(){
System.out.println("吃饭");
}
public static void sleep(){
System.out.println("睡觉");
}
}执行 main 方法时把 main 的栈帧压入栈中,调用 study 时再把 study 的栈帧压入栈顶;study 中又调用 eat,eat 栈帧入栈;eat 执行完出栈,再调用 sleep 入栈……整个调用过程符合"先进后出",控制台依次打印"吃饭"、"睡觉"。

Java虚拟机栈随着线程的创建而创建,而回收则会在线程的销毁时进行。由于方法可能会在不同线程中执行,每个线程都会包含一个自己的虚拟机栈。
栈帧内部由三部分组成:局部变量表、操作数栈、帧数据。

- 局部变量表:作用是在运行过程中存放所有局部变量。
- 操作数栈:栈帧中虚拟机在执行指令过程中用来存放临时数据的一块区域。
- 帧数据:主要包含动态链接、方法出口、异常表的引用。
这三部分的细节(slot 槽、this、参数顺序、槽的复用、操作数栈的执行过程、动态链接、方法出口、异常表等)会在第14篇中详细展开。本文先看栈内存溢出的边界。
Java虚拟机栈如果栈帧过多,占用内存超过栈内存可以分配的最大大小就会出现内存溢出,Java虚拟机栈内存溢出时会抛出 StackOverflowError 错误。

如果不指定栈的大小,JVM 将创建一个具有默认大小的栈。大小取决于操作系统和计算机的体系结构(Linux x86 64 位默认 1MB,ppc 2MB;BSD、Solaris 64 位均为 1MB;Windows 基于操作系统默认值)。
要修改 Java 虚拟机栈的大小,可以使用虚拟机参数 -Xss:
- 语法:
-Xss栈大小 - 单位:字节(默认,必须是 1024 的倍数)、k 或者 K(KB)、m 或者 M(MB)、g 或者 G(GB)
- 例如:
-Xss1048576、-Xss1024K、-Xss1m、-Xss1g
一般情况下,工作中即便使用了递归进行操作,栈的深度最多也只能到几百,不会出现栈的溢出。所以此参数可以手动指定为 -Xss256k 节省内存。
3.本地方法栈
Java虚拟机栈存储了 Java 方法调用时的栈帧,而本地方法栈存储的是 native 本地方法的栈帧。在 Hotspot 虚拟机中,Java虚拟机栈和本地方法栈实现上使用了同一个栈空间。本地方法栈会在栈内存上生成一个栈帧,临时保存方法的参数,同时方便出现异常时也把本地方法的栈信息打印出来。

4.堆
一般 Java 程序中堆内存是空间最大的一块内存区域。创建出来的对象都存在于堆上。栈上的局部变量表中可以存放堆上对象的引用。静态变量也可以存放堆对象的引用,通过静态变量就可以实现对象在线程之间共享。

堆空间有三个需要关注的值:used、total、max。
- used:当前已使用的堆内存
- total:java虚拟机已经分配的可用堆内存
- max:java虚拟机可以分配的最大堆内存

堆内存 used、total、max 三个值可以在 arthas 中通过 dashboard 命令看到。手动指定刷新频率(不指定默认 5 秒一次):dashboard -i 刷新频率(毫秒)。
随着堆中对象增多,当 total 即将不足时,虚拟机会继续分配内存给堆,total 值会变大,但 total 最多只能与 max 相等。如果不设置任何的虚拟机参数,max 默认是系统内存的 1/4,total 默认是系统内存的 1/64。在实际应用中一般都需要设置 total 和 max 的值(Oracle 官方文档:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html )。
是不是当 used = max = total 的时候,堆内存就溢出了呢?不是,堆内存溢出的判断条件比较复杂,在下一章《垃圾回收器》中会详细介绍。
要修改堆的大小,可以使用虚拟机参数 -Xmx(max 最大值)和 -Xms(初始的 total):
- 语法:
-Xmx值 -Xms值 - 单位:字节(默认,必须是 1024 的倍数)、k 或者 K(KB)、m 或者 M(MB)、g 或者 G(GB)
- 限制:Xmx 必须大于 2 MB,Xms 必须大于 1MB
- 例如:
-Xms6m -Xmx80m
为什么 arthas 中显示的 heap 堆大小与设置的值不一样呢?arthas 中的 heap 堆内存使用了 JMX 技术中内存获取方式,这种方式与垃圾回收器有关,计算的是可以分配对象的内存,而不是整个内存。
Java 服务端程序开发时,建议将 -Xmx 和 -Xms 设置为相同的值,这样在程序启动之后可使用的总内存就是最大内存,而无需向虚拟机再次申请,减少了申请并分配内存时间上的开销,同时也不会出现内存过剩之后堆收缩的情况。
5.方法区
方法区是存放基础信息的位置,线程共享,主要包含三部分内容:
- 类的元信息:保存了所有类的基本信息
- 运行时常量池:保存了字节码文件中的常量池内容
- 字符串常量池:保存了字符串常量
方法区是用来存储每个类的基本信息(元信息),一般称之为 InstanceKlass 对象,在类的加载阶段完成。

方法区除了存储类的元信息之外,还存放了运行时常量池。常量池中存放的是字节码中的常量池内容。字节码文件中通过编号查表的方式找到常量,这种常量池称为静态常量池;当常量池加载到内存中之后,可以通过内存地址快速定位到常量池中的内容,这种常量池称为运行时常量池。
方法区是《Java虚拟机规范》中设计的虚拟概念,每款 Java 虚拟机在实现上各不相同。Hotspot 设计如下:
- JDK7 及之前的版本将方法区存放在堆区域中的永久代空间,堆的大小由虚拟机参数
-XX:MaxPermSize=值来控制。 - JDK8 及之后的版本将方法区存放在元空间中,元空间位于操作系统维护的直接内存中,默认情况下只要不超过操作系统承受的上限,可以一直分配。可以使用
-XX:MaxMetaspaceSize=值将元空间最大大小进行限制。

arthas 可使用 memory 打印出内存情况,JDK7 及之前的版本查看 ps_perm_gen 属性,JDK8 及之后的版本查看 metaspace 属性。
5.1字符串常量池
方法区中除了类的元信息、运行时常量池之外,还有一块区域叫字符串常量池(StringTable)。字符串常量池存储在代码中定义的常量字符串内容,比如 "123" 中的 123 就会被放入字符串常量池。
public class Test {
public static void main(String[] args) {
String s1 = new String("abc");
String s2 = "abc";
System.out.println(s1 == s2);
}
}
字符串常量池和运行时常量池有什么关系?早期设计时,字符串常量池属于运行时常量池的一部分,存储位置也是一致的。后续做出了调整,将字符串常量池和运行时常量池做了拆分:
- JDK7 之前:运行时常量池逻辑包含字符串常量池,Hotspot 虚拟机对方法区的实现为永久代。
- JDK7:字符串常量池被从方法区拿到了堆中,运行时常量池剩下的东西还在永久代。
- JDK8 之后:Hotspot 移除了永久代,用元空间(Metaspace)取而代之,字符串常量池还在堆。
String.intern() 方法可以手动将字符串放入字符串常量池中。分别在 JDK6、JDK8 下执行同一段代码,JDK6 中结果是 false false,JDK8 中是 true false:
- JDK6 版本中,
intern()方法会把第一次遇到的字符串实例复制到永久代的字符串常量池中,返回的也是永久代里面这个字符串实例的引用。JVM 启动时就会把java加入到常量池中。

- JDK7 及之后版本中,由于字符串常量池在堆上,所以
intern()方法会把第一次遇到的字符串的引用放入字符串常量池。

5.2静态变量的存储
运行时数据区都学完了,静态变量存储在哪里呢?
JDK6 及之前的版本中,静态变量是存放在方法区中的,也就是永久代。

JDK7 及之后的版本中,静态变量是存放在堆中的 Class 对象中,脱离了永久代。具体源码可参考虚拟机源码:BytecodeInterpreter 针对 putstatic 指令的处理。

6.直接内存
直接内存(Direct Memory)并不在《Java虚拟机规范》中存在,所以并不属于 Java 运行时的内存区域。在 JDK 1.4 中引入了 NIO 机制,使用了直接内存,主要为了解决以下两个问题:
- Java 堆中的对象如果不再使用要回收,回收时会影响对象的创建和使用。
- IO 操作比如读文件,需要先把文件读入直接内存(缓冲区)再把数据复制到 Java 堆中。
现在直接放入直接内存即可,同时 Java 堆上维护直接内存的引用,减少了数据复制的开销。写文件也是类似的思路。

要创建直接内存上的数据,可以使用 ByteBuffer:
- 语法:
ByteBuffer directBuffer = ByteBuffer.allocateDirect(size); - 注意事项:arthas 的
memory命令可以查看直接内存大小,属性名direct。
如果需要手动调整直接内存的大小,可以使用 -XX:MaxDirectMemorySize=大小。单位 k 或 K 表示千字节,m 或 M 表示兆字节,g 或 G 表示千兆字节。默认不设置该参数情况下,JVM 自动选择最大分配的大小。以下示例以不同的单位说明如何将直接内存大小设置为 1024 KB:
-XX:MaxDirectMemorySize=1m
-XX:MaxDirectMemorySize=1024k
-XX:MaxDirectMemorySize=1048576