深入理解JVM运行时内存结构

得益于 JVM 的自动内存管理机制,开发者在写代码时,很少再去关注内存分配与释放。多数情况下,应用不会出现内存泄漏和溢出问题。不过,由于开发者把内存的控制权交给了 JVM,一旦出现内存泄露和溢出问题,如果不了解 JVM 是怎样使用内存的,将很难排查和修正错误。本文从概念上介绍 JVM 运行时内存的各个区域及其作用。

JVM 在执行程序时会把其所管理的内存划分成多个不同的数据区域,每个区域的创建时间、销毁时间以及用途都各不相同。比如有的内存区域是所有线程共享的,而有的内存区域是线程隔离的。线程隔离的区域就会随着线程的启动和结束而创建和销毁。JVM 所管理的内存将会包含以下几个运行时数据区域,如下图所示。
JVM运行时内存数据区示意图

Method Area (方法区)

在 JVM 中,方法区是所有线程共享的运行时内存区域。它用于存储已被虚拟机加载的类型信息 ( 类的结构信息、字段和方法数据等 )、常量、静态变量、即时编译器编译后的代码缓存等数据。在 Java 虚拟机规范中,方法区属于堆的一个逻辑部分。但很多情况下,都把方法区与堆区分开来说。平时开发中通过反射获取到的类名、方法名、字段名称、访问修饰符等信息都是从这块区域获取的。

在 JDK8 以前,使用 HotSpot 虚拟机的人通常将方法区称为永久代 ( Permanent Generation ) 。然而,这两者并非同一概念,只是因为 HotSpot 虚拟机的设计选择了使用永久代来实现方法区。这种设计让垃圾收集器能够像管理堆内存一样管理方法区,避免了需要为方法区单独编写内存管理代码的麻烦。但是,对于其他虚拟机 ( 比如 JRockit 和 J9 ) ,它们并没有引入永久代这个概念。

现在看来,使用永久代来实现方法区并非一个好主意。方法区存储着诸如类名、访问修饰符、运行时常量池、字段描述和方法描述等与类相关的信息。在某些情况下,特别是当使用像 CGLib 这样的字节码技术来增强类 ( 比如 Spring 和 Hibernate 框架 ) ,会导致方法区内存溢出问题。随着动态生成的类增多,需要更大的方法区来确保这些类能够顺利加载到内存中。

举个简单的例子来说明:下面的代码使用 CGLIB 的 Enhancer 来指定和增强代理的对象。通过调用 create() 方法得到被代理的对象。代理对象的所有非 final 方法的调用都会经过 MethodInterceptor.intercept() 方法处理,因此可以在这里加入各种增强逻辑,比如打印日志和安全检查等。在 intercept() 方法的结尾,调用 proxy.invokeSuper() 将调用转发给目标对象的对应方法。

// 可以使用CGLIB库,如果是Spring项目,可以不用依赖任何库
public class CGlibProxyDemo {
    public static void main(String[] args) {
        while (true) {
            Enhancer enhancer = new Enhancer();
            // 设置需要代理的目标对象
            enhancer.setSuperclass(ProxyObject.class);
            // 不要缓存
            enhancer.setUseCache(false);
            // 这里指定需要在原代理对象上增强的逻辑
            enhancer.setCallback(new MethodInterceptor() {
                @Override
                public Object intercept(Object o, 
                                        Method method, 
                                        Object[] os, 
                                        MethodProxy proxy) throws Throwable {
                    System.out.println("I am proxy");
                    return proxy.invokeSuper(o,os);
                }
            });
            ProxyObject proxy = (ProxyObject) enhancer.create();
            proxy.greet();
        }
    }

    /**
     * 被代理对象
     */
    static class ProxyObject {
        public String greet() {
            return "Thanks for you";
        }
    }
}

运行时设置 VM 参数 -XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M 来控制 Metaspace 大小 ( JDK1.8 以上 ),运行过程中,可以使用 VisualVM 来监控 Metaspace 的空间大小变化情况,大致如下图所示。

监控时,先启动 VisualVM,然后 debug 运行程序,接着在 VisualVM 找到对应的进程,切换到监控选项卡后,最后放开断点,观察监控情况。

VisualVM 监控 Metaspace变化

运行上述代码,会抛出内存溢出错误:

Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
    at java.base/java.lang.Class.forName0(Native Method)
    at java.base/java.lang.Class.forName(Class.java:416)
    at org.springframework.cglib.core.ReflectUtils.defineClass(ReflectUtils.java:563)
    ......

Metaspace 不使用堆内存,而是 Native Memory,其大小受本机内存大小限制,默认情况下,一般不会出现内存溢出错误,但在使用 Hibernate 或者 Spring AOP 时,可以关注一下 Metaspace 的使用情况。

Heap Space (堆空间)

Java 堆是 JVM 管理的最大内存区域,所有线程共享它。几乎所有的对象实例都在这里分配内存,因此,Java 堆也是垃圾收集器主要管理的区域。

Java 虚拟机规范并没有明确要求要如何管理这块内存,由不同的垃圾收集器根据实际的场景自行实现。

在 G1 收集器出现之前,HotSpot 虚拟机采用了经典分代设计。它将堆内存划分为新生代、老年代和永久代 ( 现在是元空间 ) ,其中各代的存储地址在逻辑上是连续的。新创建的对象放在新生代,存活时间较长的对象存放在老年代。不过,大对象通常直接分配到老年代。新生代和老年代的垃圾收集是分开进行的,各自有专门的收集器 ( 如Parallel GC、CMS等 ) 负责。

随着垃圾收集器技术的发展,出现了不再采用传统分代设计的新垃圾收集器,例如 G1 和 ZGC。G1 不再强调严格的分代设计,而是将堆内存划分为多个大小相等的 Region。每个 Region 可以根据需要划分为新生代或老年代,实现更灵活的内存管理。G1 还引入了 Humongous 区域,专门用于存放大对象,这些对象可以在年轻代垃圾收集过程中被处理,而无需等待老年代的收集器介入。这种新设计提升了内存利用率,控制了垃圾收集的暂停时间,并且提高了整体性能。

我注意到一些观点,逃逸分析可以帮助确定某些新创建的对象是否会逃出当前方法的作用域,如果逃逸分析证明这些对象不会逃逸,Java虚拟机可以选择将它们分配到栈上。这样,当方法退出时,内存可以自动回收,无需垃圾收集器处理。尽管理论上可行,但在 Oracle HotSpot JVM 中,由于需要修改大量假设了“对象只能在堆分配”的代码,目前并未实现这种优化。因此,在 HotSpot 中,所有的对象实例仍然会被创建在堆上。

如果对这个话题感兴趣,可以参考:
逃逸分析-深入拆解Java虚拟机
深入JVM即时编译器JIT,优化Java编译

Java 堆用来存储对象实例,只要我们不断创建对象,并且确保这些对象之间有可达路径连接到 GC Roots,就能避免垃圾回收器清理这些对象,最终会导致内存溢出异常。Java 堆内存溢出异常是实际应用中很常见的内存溢出异常,一旦发生,异常堆栈信息通常会显示 java.lang.OutOfMemoryError,并进一步提示 Java heap space

要解决这个异常,通常的做法是通过内存映像分析工具分析 Dump 出来的堆转储快照,确认导致 OOM 的对象是否必要:

  • 如果导致 OOM 的对象是不必要的,也就是它们本来应该被回收,但由于错误的代码导致 GC Roots 一直引用它们,使垃圾回收器无法清理它们。这就是常说的内存泄漏(Memory Leak),意味着代码有问题,需要调整。
  • 如果导致 OOM 的对象确实是必须存活的,这就是常说的内存溢出(Memory Overflow)。在这种情况下,大多数时候只能调整堆空间大小。当然,也可以从代码层面优化,比如确认这些对象的生命周期是否过长、对象的存储结构是否合理等,这些措施也可以减少程序运行期间的内存消耗。

VM Stack (虚拟机栈)

虚拟机栈属于线程私有,它的生命周期与线程相同。虚拟机栈用于描述 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

  • 局部变量表用于存储方法参数和方法内部定义的局部变量,它只在当前方法调用中有效,当方法调用结束,栈帧随之销毁,局部变量表也随之消失。
  • 操作数栈是一个后入先出栈,用于存放方法运行过程中的各种中间变量和字节码指令。
  • 动态链接其实是指一个过程,即在程序运行过程中将符号引用解析为直接引用的过程。

如何理解动态链接?我们知道 Class 文件的常量池中存有大量的符号引用,在加载过程中会被原样拷贝到内存里先放着,到真正使用的时候就会被解析为直接引用 (直接引用包含:直接指向目标的指针、相对偏移量、能间接定位到目标的句柄等)。有些符号引用会在类的加载阶段或者第一次使用的时候转化为直接引用,这种转化称为静态解析,而有的将在运行期间转化为直接引用,这部分称为动态链接。

全部静态解析不是更好,为何会存在动态链接?Java 多态的实现会导致一个引用变量到底指向哪个类的实例对象,或者说该引用变量发出的方法调用到底是调用哪个类中实现方法都需要在运行期间才能确定。因此有些符号引用在类加载阶段是不知道它对应的直接引用是谁的。

  • 方法正常完成是指方法执行过程中没有抛出异常,这种情况下,它可能会返回一个值给它的调用方。在这种场景下,当前栈帧承担着恢复调用者状态的责任,包括恢复调用者的局部变量表和操作数栈,以及正确递增程序计数器,以跳过刚才执行的方法调用指令等。 调用者的代码在被调用方法的返回值压入调用者栈帧的操作数栈后,会继续正常执行。
  • 方法异常完成是指方法执行过程中,某些指令导致虚拟机抛出了异常或在执行过程中代码主动抛出了异常。如果方法异常调用完成,一定没有返回值给它的调用方。

我们来简单描述一下方法在调用过程中,栈帧的变化情况:

在某条线程执行过程中的某个时间点上,只有目前正在执行的那个方法的栈帧是活动 的。这个栈帧称为当前栈帧,这个栈帧对应的方法称为当前方法,定义这个方法的类称作当前类。对局部变量表和操作数栈的各种操作吗,通常都指的是对当前栈帧的局部变量表和操作数栈所进行的操作。

如果当前方法调用了其他方法,或者当前方法执行结束,那这个方法的栈帧就不再是当前栈帧了。调用新的方法时,新的栈帧也会随之而创建,并且会随着程序控制权移交到新方法而成为新的当前栈帧。方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,然后,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。

– 来自《Java虚拟机规范》第 2 章 6 小节

简而言之,一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

最后,在栈空间中,如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverFlowError 异常;同堆内存一样,如果应用运行时无法申请到足够的栈空间,仍然会抛出 OutOfMemoryError 异常。

Native Method Stack (本地方法栈)

本地方法栈与虚拟机栈一样,都属于线程私有,它的生命周期与线程相同。本地方法栈与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行 Java 方法 (也就是字节码) 服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

Java 虚拟机规范对本地方法栈中方法使用的语言、使用方式和数据结构并没有任何规定,全靠虚拟机自己发挥,甚至有的虚拟机(HotSpot VM)直接把本地方法栈和虚拟机栈合二为一。

与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出 StackOverflowError 和 OutOfMemoryError 异常。

Program Counter Register (程序计数器)

程序计数器(Program Counter Register),很多地方也被称为 PC 寄存器,但寄存器是 CPU 的一个部件,用于存储 CPU 内部重要的数据资源,比如在汇编语言中,它保存的是程序当前执行的指令的地址(也可以说保存下一条指令的所在存储单元的地址),当 CPU 需要执行指令时,需要从程序计数器中得到当前需要执行的指令所在存储单元的地址,然后根据得到的地址获取到指令,在得到指令之后,程序计数器便自动加 1 或者根据转移指针得到下一条指令的地址,如此循环,直至执行完所有的指令。

类似的,JVM 规范中规定,如果线程执行的是非 native 方法,则程序计数器中保存的是当前需要执行的指令的地址;如果线程执行的是 native 方法,则程序计数器中的值是 undefined。

Java 虚拟机可以支持多条线程同时执行,多线程是通过线程轮流切换来获得 CPU 执行时间的,因此,在任一具体时刻,一个 CPU 的内核只会执行一条线程中的指令,因此,为了能够使得每个线程都在线程切换后能够恢复在切换之前的程序执行位置,每个线程都需要有自己独立的程序计数器,并且不能互相被干扰,否则就会影响到程序的正常执行次序。因此,JVM 中的程序计数器是每个线程私有的。

堆外内存

堆外内存又被称为直接内存(Direct Memory),它并不是虚拟机运行时数据区的一部分,Java 虚拟机规范中也没有定义这部分内存区域,使用时由 Java 程序直接向系统申请,访问直接内存的速度要优于 Java 堆,因此,读写频繁的场景下使用直接内存,性能会有提升,比如 Java NIO 库,就是使用 Native 函数直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectBytedBuffer 对象作为这块内存的引用进行操作。

由于直接内存在 Java 堆外,其大小不会直接受限于 Xmx 指定的堆大小,但它肯定会受到本机总内存大小以及处理器寻址空间的限制,因此我们在配置 JVM 参数时,特别是有大量网络通讯场景下,要特别注意,防止各个内存区域的总内存大于物理内存限制 (包括物理的和 OS 的限制)。

最后

在Java虚拟机的规定中,除了程序计数器外,其他几个运行时区域可能会发生内存溢出(OOM)异常。本文旨在帮助你了解各个运行时区域存储的内容,以便在实际工作中遇到内存溢出问题时,能根据异常提示快速定位是哪个区域出了问题,知道怎样的代码可能会导致这些区域内存溢出,以及出现这些异常后该如何处理。

参考资料

周志明著;深入理解Java虚拟机(第三版)
Java虚拟机规范(JavaSE8版)
逃逸分析-深入拆解Java虚拟机
深入JVM即时编译器JIT,优化Java编译

深入理解 JVM 系列的第 5 篇,完整目录请移步:深入理解 JVM 系列
本站暂时未接入任何评论系统,如对本文有任何疑问,可移步到微信公众号评论
在微信客户端内打开右侧链接即可评论:深入理解JVM运行时内存结构
不遗漏重要文章,欢迎关注本站公众号,手机扫描下方二维码或微信搜索 橙子成绩好
微信公众号二维码

回到首页