浅谈JVM运行时内存结构(1)
JVM 在执行 Java 程序的过程中会将内存划分为若干不同功能的数据区。分析 JVM 的内存结构,主要就是分析 JVM 运行时的数据存储区,其中包括:栈、堆、方法区、程序计数器等,JVM 的优化主要集中在线程共享的数据区(堆、方法区)中。这些区域有的随着 JVM 的启动而创建,有的随着用户线程的启动和结束而建立和销毁。JVM运行时的内存结构如下图所示:
一、程序计数器(Program Counter Register)
1. 什么是程序计数器
程序计数器是一个记录当前线程所执行字节码的行号指示器。
Java程序编译后的字节码在未经过 JIT(Just in Time,即时编译器)编译前,起执行方式是通过 “字节码解释器” 执行。基本工作原理为:解释器读取载入内存的字节码,按照顺序读取字节码指令。读取一个指令后,将该指令“翻译“成固定操作,并根据这些操作执行分支、循环、跳转等流程。
从上面的描述中不难发现:沿着指令顺序执行下去,即使是分支或跳转流程,跳转到指定的指令出按顺序继续执行是完全可以保证程序的执行顺序。假设程序永远只有一个线程,程序计数器并没有体现出存在的价值。实际上,程序计数器的作用主要体现在多线程场景中。
这里需要概述一下JVM的多线程实现方式:多线程任务调度是通过CPU时间片轮转算法(即线程轮流切换并分配处理器执行时间)实现。多线程字节码程序执行过程中,某支线程在执行过程中可能因为时间片耗尽而被挂起,而另一支线程获取到时间片开始执行。当被挂起的线程重新获取到时间片并将继续执行时,就必须先知道上一次挂起前程序执行到了哪个位置(字节码指令地址),程序计数器的作用就是记录不同线程字节码程序在被切换前的执行位置。为了确保线程切换后(当前线程)能够恢复到正确的执行位置,每一支线程都会配置专属且独立的程序计数器,因此,程序计数器具有线程隔离特性,它的内存配分是线程私有的。
2. 程序计数器的特点
- 线程隔离性,每个线程工作时都有属于自己的独立计数器
- 执行Java方法时,程序计数器是有值的,且记录的是正在执行的字节码指令地址
- 执行 native 本地方法时,程序计数器的值为空(Undefined)。这是因为native 方式时 Java 通过 JNI(Java Native Interface,Java本地接口)直接调用本地 C/C++ 库,可以近似认为 native 方法相当于 C/C++ 暴露给 Java 的接口,Java 通过这个接口来调用 C/C++ 方法。由于该方法是通过 C/C++ 实现而不是通过 Java 实现,那么自然无法产生相应的字节码,所以程序计数器无法捕获 native 本地方法的执行位置。
- 程序计数器占用内存很小,在进行JVM内存计算时可以忽略不计
- 程序计数器不会发生内存溢出问题。程序计数器也是唯一一个在JVM规范中没有规定任何OutOfMemoryError(OOM)的区域。
二、虚拟机栈(VM Stack)
1. 什么是虚拟机栈
虚拟机栈适用于描述Java方法执行的内存模型。
每个Java方法在执行时,会创建一个栈帧(stack frame),栈帧的结构分为局部变量表、操作数栈、动态链接、方法出口。平常所说的 “栈内存” 指的就是虚拟机栈,确切地说,指的是虚拟机栈的栈帧中的局部变量表,因为这里存放了一个方法的所有局部变量。
方法调用时,创建栈帧,并压入虚拟机栈;方法执行完毕,栈帧出栈并被销毁。如下图所示:
2. 虚拟机栈的特点
虚拟机栈是线程隔离的,每个线程都有自己独立的虚拟机栈。
3. 虚拟机栈的 StackOverflowError
若单个线程请求的栈深度大于虚拟机允许的深度,则会抛出StackOverflowError(栈溢出错误)。
JVM会为每个线程的虚拟机栈分配一定的内存空间(-Xss参数),因此虚拟机站能容纳的栈帧数量是有限的,若栈帧不断进栈而不出栈,最终会导致当前线程虚拟机栈的内存耗尽,典型案例如一个没有结束条件的递归调用,代码如下:
/**
* java栈溢出StackOverFlowError
* JVM参数:-Xss128k
*/
public class JavaVMStackSOF {
private int stackLength = -1;
//通过递归调用造成StackOverFlowError
public void stackLeak() {
stackLength++;
stackLeak();
}
public static void main(String[] args) {
JavaVMStackSOF oom = new JavaVMStackSOF();
try {
oom.stackLeak();
} catch (Throwable e) {
System.out.println("Stack length:" + oom.stackLength);
e.printStackTrace();
}
}
}
运行结果
Stack length:983
java.lang.StackOverflowError // 栈溢出
at com.manayi.study.jvm.chapter2._02_JavaVMStackSOF.stackLeak(_02_JavaVMStackSOF.java:14)
at com.manayi.study.jvm.chapter2._02_JavaVMStackSOF.stackLeak(_02_JavaVMStackSOF.java:15)
at com.manayi.study.jvm.chapter2._02_JavaVMStackSOF.stackLeak(_02_JavaVMStackSOF.java:15)
······
4. 虚拟机栈的 OutOfMemoryError
不同于StackOverflowError,OutOfMemoryError指当整个虚拟机栈内存耗尽,并且无法再申请到新的内存是抛出的异常。
JVM未提供设置整个虚拟机栈占用内存的配置参数。虚拟机栈的最大内存 ≈ JVM进程能占用的最大内存 - 最大堆内存 - 最大方法区内存 - 程序计数器内存 - JVM进程本身消耗的内存。具体的空间大小依赖于不同的操作系统。当虚拟机栈能够使用的最大内存被耗尽后,便会抛出 OutOfMemoryError(内存溢出错误),可以通过不断开启新的线程来模拟这种异常,代码如下:
/**
* java栈溢出OutOfMemoryError
* JVM参数:-Xss2m
* 系统崩溃警告
*/
public class JavaVMStackOOM {
private void dontStop() {
while (true) {
}
}
//通过不断的创建新的线程使Stack内存耗尽
public void stackLeakByThread() {
while (true) {
Thread thread = new Thread(() -> dontStop());
thread.start();
}
}
public static void main(String[] args) {
JavaVMStackOOM oom = new _03_JavaVMStackOOM();
oom.stackLeakByThread();
}
}
运行结果
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread // 无法生成新的线程
三、本地方法栈(Native Method Stack)
本地方法栈的功能和特点类似于虚拟机栈,均具有线程隔离的特点以及都能抛出 StackOverflowError 和 OutOfMemoryError 异常。
不同的是,本地方法栈服务的对象是JVM执行的native方法,而虚拟机栈服务的是JVM执行的Java方法。如何去服务native方法?native方法使用什么语言实现?怎么组织像栈帧这种为了服务方法的数据结构?虚拟机规范并未给出强制规定,因此不同的虚拟机可以进行自由实现。常用的HotSpot虚拟机选择合并了虚拟机栈和本地方法栈。