Java内存区域详解

JVM内存结构主要包括以下几部分:

  • 程序计数器
  • Java虚拟机栈
  • 本地方法栈
  • Java堆
  • 方法区(元空间)
  • 运行时常量池
  • 直接内存

其中:

  • 线程私有的:
    • 程序计数器
    • Java虚拟机栈
    • 本地方法栈
  • 线程共享的:
    • Java堆
    • 方法区(元空间)
    • 直接内存

JDK1.7及之前,方法区被称为永久代(PermGen),从JDK1.8开始,方法区被移除,取而代之的是元空间(Metaspace)。

JDK1.7 JDK1.8

一、程序计数器

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。

每个线程都有一个独立的程序计数器,线程切换时,程序计数器可以保存当前线程的执行位置,以便恢复执行。

所以程序计数器主要有两个作用:

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制
  • 在多线程情况下,程序计数器用于记录当前线程执行位置,从而实现线程切换和恢复

程序计数器的生命周期与线程完全同步:

  • 创建:随着线程的创建而创建
  • 销毁:随着线程的结束而销毁

注意:程序计数器是JVM规范中唯一没有规定任何OutOfMemoryError的内存区域,因为它的内存需求非常小,且由线程私有,不会出现内存溢出的问题。

二、Java虚拟机栈

Java虚拟机栈也是线程私有的,生命周期与线程相同。

方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈,每一个方法调用结束后,都会有一个栈帧被弹出。

每个栈帧中都拥有:局部变量表、操作数栈、动态链接和方法返回地址等信息。

  • 局部变量表:主要存放编译期可知的各种类型(boolean、byte、char、short、int、long、float、double)、对象引用(reference类型)。
  • 操作数栈:主要作为方法调用的中转站使用,用于存放方法执行过程中产生的各种中间结果。
  • 动态链接:主要用于在运行时解析虚方法的调用点,将其链接到正确的方法版本上。
  • 方法返回地址:主要用于存储方法调用结束后,程序计数器应该返回的位置。

程序运行中栈可能出现的两种异常:

  • StackOverflowError:当线程请求的栈深度超过虚拟机所允许的最大深度时,抛出该异常。
  • OutOfMemoryError:当虚拟机无法为线程分配足够的栈内存时,抛出该异常。

三、本地方法栈

虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现StackOverFlowErrorOutOfMemoryError两种错误。

四、Java堆

Java堆是JVM中最大的一块内存区域,是所有线程共享的内存区域,主要用于存放对象实例数组

Java堆是垃圾收集器管理的主要区域,因此也被称为GC堆。

JDK1.7及之前,堆一般分为下面三部分:

  • 新生代(Young Generation):主要存放新创建的对象,垃圾收集器会频繁地对新生代进行垃圾回收。新生代一般占堆内存的1/3。
  • 老年代(Old Generation):主要存放经过多次垃圾回收仍然存活的对象,垃圾收集器对老年代的垃圾回收频率较低。老年代一般占堆内存的2/3。
  • 永久代(PermGen):主要存放类的元数据,如类的结构信息、常量池等。

从JDK1.8开始,永久代被移除,取而代之的是元空间(Metaspace),元空间不再使用堆内存,而是使用本地内存。

Java堆内存结构

新生代分为Eden区和两个Survivor区(S0和S1),新创建的对象首先被分配到Eden区,当Eden区满时,垃圾收集器会进行Minor GC,将存活的对象从Eden区复制到Survivor区。
当Survivor中的对象年龄增加到一定程度时(默认是15岁,这是因为记录年龄使用的区域是4为,这四位可以表示的最大二进制数字是1111,即15),就会被晋升到老年区。
老年代主要存放经过多次垃圾回收仍然存活的对象,垃圾收集器对老年代的垃圾回收频率较低,通常在老年代满时才会进行Full GC。

在某些JVM实现中,为大对象分配了专门的区域,称为大对象区。这类对象直接分配在老年代,以避免在新生代进行频繁的垃圾回收。

补充:Survivor为什么要分为S0和S1两个区?

  • 主要是为了实现对象的复制算法。在垃圾回收过程中,垃圾收集器会将存活的对象从Eden区复制到一个Survivor区(比如S0),而另一个Survivor区(比如S1)则作为空闲区。当下一次垃圾回收发生时,垃圾收集器会将存活的对象从Eden区复制到另一个Survivor区(S1),同时将之前存活的对象从S0区复制到S1区。这样通过交替使用两个Survivor区,可以有效地管理对象的生命周期和内存使用。

五、方法区(元空间)

方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码等数据。

方法区主要存储以下核心数据:

  • 类的元数据
  • 方法的字节码
  • 运行时常量池

需要注意的是,以下几类数据虽然在逻辑上与类相关,但在HotSpot虚拟机中,他们并不储存在方法区内:

  • 静态变量:自JDK7开始,静态变量被移到了Java堆中。
  • 字符串常量池:常量池中的字符串常量和基本类型常量也被移到了Java堆中。
  • 即时编译器编译后的代码:JIT编译器生成的本地代码被存储在一个独立的名为Code Cache的内存区域中。

方法区中的方法执行过程:

  • 解析方法调用:JVM会根据方法的符号引用找到实际的方法地址。
  • 栈帧创建:在调用方法前,JVM会在当前线程的Java虚拟机栈中为该方法分配一个新的栈帧。
  • 执行方法:执行方法内部的字节码指令。
  • 返回处理:方法执行完毕后,JVM会根据方法的返回类型处理返回值,并清理当前栈帧,回复调用者的执行环境。

JDK1.8及之后,方法区被移除,取而代之的是元空间(Metaspace)。
为什么要将永久代替换为元空间?

  • 永久代有一个JVM本身设置的固定大小上限,无法进行调整,而元空间使用的是本地内存,受本机可用内存的限制,具有更大的灵活性。
  • 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由MaxPermSize控制了,而由系统的实际可用空间来控制,这样能加载的类就更多了。
  • 永久代会为GC带来不必要的复杂度,而且回收效率偏低。

六、运行时常量池

运行时常量池是方法区的一部分,用于存储编译期生成的各种字面量和符号引用。它是类文件中的常量池在运行时的表现形式。

七、直接内存

直接内存是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的。

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致OutOfMemoryError错误出现。

八、总结

总体结构图

1
2
3
4
5
6
7
8
9
10
线程私有:
├── 程序计数器
├── JVM栈
└── 本地方法栈

线程共享:
├── 堆(重点)
├── 方法区
│ └── 运行时常量池
└── 直接内存(非规范)

参考资料: