深入浅出 JVM 之运行时数据区-方法区

深入浅出 JVM 之运行时数据区-方法区

文章目录

  !版权声明:本博客内容均为原创,每篇博文作为知识积累,写博不易,转载请注明出处。

系统环境:

  • JDK 1.8

参考地址:

深入浅出 JVM 系列文章

一、方法区概述

在 Java 中我们编写的代码经过编译后,都会转换为 .class 字节码文件,而每个字节码文件中都包含了魔数、版本号、常量池、类、父类、接口数组、字段、方法等信息。当 Java 应用程序启动时,Java 虚拟机会通过类加载机制,将字节码文件中的类信息加载至运行时数据区中的方法区,形成可执行的类元数据结构,供程序动态引用。

方法区作为 Java 运行时数据区的关键组成部分,与堆区共同支撑多线程环境下的数据共享需求。其内存空间在逻辑上连续,但物理上不一定连续。并且,方法区的实现随 JDK 版本迭代而演变,比如,在 JDK 7 中采用 "永久代" 作为方法区的实现方式,用于存储类的元数据和常量信息;而到了 JDK 8 以后,方法区的实现转为 "元空间",最显著的变化就是其直接使用了本地内存作为数据存储空间,相较于永久代,元空间则具有灵活的内存管理策略和空间扩展性。

而且,在《Java虚拟机规范》中并没有强制规定方法区该如何实现,这给 JVM 设计人员预留了充足的设计空间,从而形成了不同虚拟机中的方法区的实现各不相同。比如,在 HotSpot 虚拟机中存在永久代的概念,而 EA JRockit / IBM J9 虚拟机中则没有这个概念。

二、栈、堆、方法区的交互关系

比如,在 Java 程序执行过程中,使用 new User 创建一个 User 类型的实例对象时,会涉及到运行时数据区中的 、和 方法区,归总到表中如下:

代码位置 运行时数据区位置
对象类型 方法区 (Method Area)
变量名 (引用) 栈 (Stack)
对象 堆 (Heap)

注: 这里不考虑方法逃逸的情况

根据上面内容可知,当我们创建一个对象并赋给一个变量时,如 User user = new User(); 这行代码,对象的类型 数据存储在 方法区 中,而 变量的名称 存储在 中,具体创建的 对象实例 存储在 中,三者之间的协作确保了对象的高效创建与访问,同时也体现了 Java 内存管理模型的基本框架。如下图所示:

三、不同 JDK 版本中的方法区

3.1 不同版本中方法区的区别

在介绍方法区时提到过,在 HotSpot 虚拟机中,不同的 JDK 版本方法区的实现也不相同,按照 JDK 6、JDK 7、JDK 8 来进行区分话,汇总如下:

JDK 版本 方法区的实现 存储的信息
JDK 6 永久代 类信息、方法信息、域信息、JIT代码缓存、运行时常量池、字符串常量池、类变量
JDK 7 永久代 类信息、方法信息、域信息、JIT代码缓存、运行时常量池
JDK 8 元空间 类信息、方法信息、域信息、JIT代码缓存、运行时常量池

3.2 不同版本中的方法区演变

JDK 6

在 JDK 6 版本中,方法区的实现是 永久代,用于存储 类信息方法信息域信息JIT代码缓存运行时常量池字符串常量池类变量 等信息。

JDK 7

在 JDK 7 版本中,方法区的实现也是 永久代,不过对其中的 字符串常量池类变量 的位置进行了调整,将其转移到了 堆空间 中进行存储。这一改动主要是为了缓解永久代 OutOfMemoryError 的问题,因为字符串常量池和类变量在某些应用中可能占用大量内存,而频繁的类加载和卸载也会导致永久代空间紧张。

JDK 8

在 JDK 8 版本中,JVM 移除了 永久代,使用 元空间 作为 方法区 的实现,元空间使用的是本地内存,其大小受制于本地内存大小的限制,可以一定程度上避免发生 OutOfMemoryError 错误。

四、方法区中包含的内容

在 JDK 8 版本的 HotSpot 虚拟机中,方法区包含的数据主要是 类信息域信息方法信息JIT 代码缓存运行时常量池 等。这些组件共同构成了程序运行时的重要数据基础。

4.1 类信息

对于 Java 虚拟机加载的每一种类型,不论是 类(class)接口(interface)枚举(enum),还是 注解(annotation),都会在方法区维护一系列详细的信息以确保类型的安全性和完整性。这些核心信息包括但不限于:

  • 访问修饰符: 访问修饰符可以指明类或接口的访问权限,如 public 表示公开可访问,abstract 表示这是一个抽象类。
  • 全限定名称: 全限定名称可以确保每个类型在全局中的唯一性,这个名称结合了类所在的包名和类名。
  • 直接父类名称: 直接父类名称记录了当前类继承的直接父类的全限定名。如果该类型是基类,如 Object,或者是一个接口,则此字段为空,因为 Object 类没有父类,而接口直接继承自 java.lang.Object 但不直接列出。
  • 直接实现的接口列表: 按顺序列出该类或接口直接实现的所有接口名称,体现了 Java 的多重继承机制通过接口实现的特点。列表为空则表示该类型没有直接实现任何接口。

存储的这些类型信息,可以作为 JVM 进行类型检查、方法解析和动态链接的基础,是 Java 平台可靠性和高效性的重要支撑。

4.2 域信息

Java 虚拟机在方法区中详尽地记录了每个类型所包含的域 (即变量,包括实例变量和类变量) 的全部细节,以及它们的声明顺序,这是确保对象状态正确性和反射操作可行性的重要环节。域信息具体涵盖以下几个方面:

  • 域名称: 域的简单名称,用于区分类内部的不同变量。
  • 域类型: 精确到类型的全限定名,无论是基本类型还是引用类型,确保了类型的准确无误。
  • 域修饰符: 一系列标志位,描述了域的访问控制级别和特殊属性,包括但不限于 public、private、protected、static、final、volatile、transient。这些修饰符的组合定义了域的行为特征和生命周期。

通过维护这些域信息,Java 虚拟机能够实施严格的访问控制,支持高级特性的实现,比如反射 API 对类成员的动态访问,以及在运行时对静态变量和实例变量的管理与操作。

4.3 方法信息

Java 虚拟机在方法区中不仅保存了类型的方法列表及其声明顺序,还包含了每条方法的详尽细节,以便于方法在调用和验证时能够精准控制。具体而言,方法信息涵盖以下几个方面:

  • 方法名称: 标识方法的简单名称,是方法调用和反射操作中的关键索引。
  • 返回类型: 精确到类型的全限定名,定义了方法执行后预期的数据输出形式。
  • 参数信息: 包括参数的数量、各自的类型及参数列表的顺序,对方法重载解析至关重要。
  • 方法修饰符: 一系列标志位,如 public、private、protected、static、final 等,决定了方法的访问权限和特性。
  • 字节码: 除 abstract 和 native 方法外,其它方法的 Java 字节码被存储于此,这是方法执行的直接指令集。
  • 操作数栈和局部变量表: 描述了方法执行时的操作数栈深度和局部变量表的结构及其大小,这对方法调用的栈帧布局至关重要。
  • 异常处理表: 同样排除 abstract 和 native 方法,记录了方法内异常处理的范围和目标 catch 块信息,用于动态链接异常处理流程。

存储这些详细的方法信息,可以确保了 Java 虚拟机能够高效地执行方法调用,进行动态绑定,以及在需要时准确抛出并处理异常,是 Java 程序运行时行为控制和优化的基石。

4.4 JIT 代码缓存

在计算机科学领域中,操作系统能够直接识别并执行的是机器码,这是处理器能够理解的二进制指令集。所以,我们所编写的 Java 源代码在经过编译后的字节码其实是一种中间代码格式,因此操作系统并不能直接执行字节码中的指令。

为了解决这一问题,Java 团队引入了 Java 虚拟机 (JVM) 作为执行字节码的运行环境,通过 解释模式编译模式 实现将字节码转换为系统可以执行的机器码,实现了高级语言与底层硬件之间的桥梁。具体如下:

模式 描述
解释模式 在解释模式下,Java 虚拟机直接读取字节码并逐条解释执行,将字节码指令转换为底层硬件可以理解的机器码,这一过程在程序运行时反复进行。这种方法的优势在于启动速度快,因为它无需等待编译过程,但执行效率相对较低,因为每次执行都需要翻译。
编译模式 当使用纯编译模式时,JVM 中的 JIT 编译器会在程序启动时或运行初期就将字节码编译成本地机器码。尽管这会导致更长的启动时间,但一旦完成编译,程序的执行速度会显著提高,因为机器码可以直接由 CPU 执行,省去了逐条解释的开销。
混合模式 混合模式是 Java 虚拟机的默认工作方式,结合了解释执行的快速启动特性和 JIT 编译的高性能优势。程序开始运行时,JVM 首先使用解释器执行字节码,同时监控代码的执行情况。对于那些频繁执行的 "热点代码",JVM 会将其标记,并通过 JIT 编译器将其编译为高度优化的机器码,之后执行这部分代码时就会直接使用编译好的机器码,从而提升整体性能。

也就是说,在方法区中 JIT 编译器会持续监控程序运行过程中的代码执行频率,对于那些频繁调用的代码段 (即热点代码),它会进行深度优化并将其转换为与目标平台直接兼容的机器码。这些优化后的代码随后被存储在方法区的代码缓存中,供后续执行时直接调用,避免了多次解释同一段代码所带来的性能损耗。这种动态编译与缓存的结合,是 Java 能够兼顾跨平台性和高效运行的关键所在。

4.5 运行时常量池

常量池

我们日常编写的 Java 源代码在经过编译后,会转换为字节码文件,而在字节码文件中,存储的主要数据则是 魔数版本号常量池访问标志字段方法接口属性 等信息。其中的 常量池 扮演着关键角色,它负责存储编译期生成的各种 字面量 (Literal) 和 符号引用 (Symbolic References)。

运行时常量池

然而,字节码文件中的 常量池 仅承载静态信息,真正的动态链接发生在类加载阶段。当类加载器将字节码文件加载至 Java 虚拟机时,其中的 符号引用 将被解析为具体的内存地址信息,这一转化过程使得常量池得以升级为 运行时常量池。此时,符号引用被替换为指向加载至内存区域代码的直接引用,实现了动态链接的功能。

运行时常量池不仅继承了字节码文件中的 字面量符号引用,还展现了动态特性,允许在运行时向其添加新数据。一个典型的示例便是 String类intern() 方法,该方法能在运行时常量池中查找或添加字符串实例,从而实现字符串共享,优化内存使用。

简而言之,运行时常量池是字节码文件中常量池在 JVM 中的活化形式,它在类加载过程中完成从符号引用到直接引用的转换,并提供了运行时数据动态扩展的能力。

五、方法区中的垃圾回收

5.1 占用方法区的主要数据

在 HotSpot 虚拟机的运行环境中,占用方法区大量内存空间的数据,主要是常量池中的 字面量符号引用:

  • 字面量: 字面量与常量概念相近,指的是直接在源代码中表示的固定值。比如一个数值、一个字符串、一个布尔类型等,都可以是一个字面量。字面量充当着程序运行过程中的不变量,其在常量池中的存在确保了代码的稳定性和可读性。
  • 符号引用: 在 Java 程序编译成字节码文件的过程中,编译器并不知道程序运行时的真实物理地址,因此无法直接生成对应的直接引用 (即指向具体内存地址的引用)。相反,它会生成符号引用,这是一种基于符号名的引用,包含了对类、字段、方法或者接口的描述信息,而这些描述信息足以让虚拟机在运行时确定引用的目标。符号引用主要包括三种类型:
    • ① 类和接口的全限定名: 标识了类和接口在类路径中的确切位置。
    • ② 字段的名称和描述符: 描述了类成员变量的细节,包括名称和类型信息。
    • ③ 方法的名称和描述符: 记录了方法名称、参数列表和返回类型。

5.2 方法区是否需要垃圾回收

在《Java虚拟机规范》中,对方法区的约束是非常宽松,其并不强制要求虚拟机在方法区中实现垃圾回收。事实上,在方法区的区域中进行垃圾回收,回收的效果确实不太理想,尤其是类型的卸载,回收条件是相当苛刻的。但是,这不并能说这个区域完全不能进行垃圾回收。

比如,在以前 Sun 公司的 Bug 列表中,曾出现过的若干个严重的 Bug 就是由于低版本的 HotSpot 虚拟机对此区域未完全回收而导致内存泄漏,因为方法区的区域大小也是有一定大小限制的,如果加载的类数据或者存储的常量池数据过多,而有些无用的数据又不能被及时被回收掉,这样就会容易导致方法区中的内存不足。所以,对方法区进行回收是非常有必要的。

5.3 方法区垃圾回收的主要数据

方法区的垃圾回收主要集中于 废弃的常量未被引用的类型 这两类数据:

  • 废弃的常量: 某个常量不再被系统中的任何部分所引用时,那么该常量就会被视为废弃的常量,将成为垃圾回收的目标。
  • 未被引用的类型: 当一个类及其方法不再被被任何变量所引用时,那么该类将成为垃圾回收的目标。

5.4 判断常量是否可以被回收的方法

与 Java 堆中对象的回收相比,判断一个类型是否成为不再被使用的类,条件显得更为严苛与复杂。要确认一个类型可被回收,需同时满足以下三个条件:

  • 实例无残留: 该类型的全部实例均已从 Java 堆中清除,即不存在任何该类或其派生子类的实例。
  • 类加载器已回收: 加载该类型的类加载器必须已被回收。这一条件在常规应用中难以触及,除非在特定设计的场景中,如 OSGi 框架或 JSP 热重载环境下,类加载器的替换机制被刻意启用。
  • Class对象无引用: 该类型对应的 java.lang.Class 对象不再被任何地方引用,意味着没有任何途径可通过反射机制访问到该类的任何方法。

虽然 Java 虚拟机允许回收满足上述条件的类型,但这并不意味着满足条件的类型必然被回收,其回收时机和频率受到虚拟机实现策略的影响。HotSpot 虚拟机为此提供了 -Xnoclassgc 参数,允许开发者禁用类型回收,同时,通过 -verbose:class 以及 -XX:+TraceClassLoading-XX:+TraceClassUnLoading 选项,可以开启详细的类加载和卸载日志,帮助监控类型生命周期,为优化内存管理提供依据。

参数名称 描述
-Xnoclassgc 不对方法区进行垃圾回收
-verbose:class 打印类加载过程
-XX:+TraceClassLoading 监控类的加载
-XX:+TraceClassUnLoading 监控类的卸载

在大量使用反射、动态代理、CGLib 等字节码框架,动态生成 JSP 以及 OSGi 这类频繁自定义类加载器的场景中,通常都需要 Java 虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

六、方法区中的疑问

6.1 类变量和字符串常量池存储的位置?

在 JDK 7 以后的版本中,类变量字符串常量池 已经被移到 堆空间 中进行存储。

6.2 为什么调整类变量和字符串常量池的位置?

类变量字符串常量池 转移到了堆空间中进行存储,其主要的原因主要有两个:

  • 缓解内存压力: 在方法区的实现中,无论是在永久代还是元空间,其内存容量相对有限。而类变量与字符串常量池往往占据较大内存,这易导致方法区过早耗尽资源,进而引发 OutOfMemoryError 异常。
  • 优化垃圾回收: 只有当永久代或者元空间达到一定阈值,或者堆空间中老年代满了后,才会触发 Full GC 对方法区进行回收。因此这会导致方法区中的数据难以被清理,所以将这些对象转移至堆空间中进行存储,这将优化这些数据的回收过程,从而提高整体系统性能。

6.3 方法区是否会触发 OutOfMemoryError 错误?

方法区是可能会发生 OutOfMemoryError 内存溢出错误的。比如,加载大量的第三方的 JAR 包,或者使用反射生成大量的反射类等,就有可能会引发方法区发生内存溢出 OutOfMemoryError 错误。

  • 在 JDK 7 版本中方法区的实现是 永久代,永久代占用的是 JVM 堆内存空间,所以当方法区达到一定阈值后后会触发 Full GC,如果方法区满了无法再存数据时,就会触发 java.lang.OutofMemoryError:PermGen 错误。
  • 在 JDK 8 版本中方法区的实现是 元空间,元空间占用的是操作系统的内存空间,所以当方法区达到一定阈值后后会触发 Full GC,如果方法区满了无法再存数据时,也会触发 java.lang.OutOfMemoryError:Metaspace 错误。

七、方法区配置参数

7.1 堆 TLAB 参数配置

参数名称 默认值 配置示例 描述
-XX:MetaspaceSize -XX:MetaspaceSize=128m 设置元空间初始内存空间大小。
-XX:MaxMetaspaceSize 不限制 -XX:MaxMetaspaceSize=256m 设置元空间可用最大内存空间,默认是没有限制的。
-XX:MinMetaspaceFreeRatio 40 -XX:MinMetaspaceFreeRatio=40 设置在经过 Metaspace GC 之后,如果空闲内存比小于设置的这个参数,那么虚拟机会扩大元空间中的大小。
-XX:MaxMetaspaceFreeRatio 70 -XX:MaxMetaspaceFreeRatio=70 设置在经过 Metaspace GC 之后,如果空闲内存比大于设置的这个参数,那么虚拟机会释放元空间中的部分空间。
-XX:MinMetaspaceExpansion 5452592B
(大约为5MB)
-XX:MinMetaspaceExpansion=5m 元空间扩大时的最小幅度。
-XX:MaxMetaspaceExpansion 5452592B
(大约为5MB)
-XX:MaxMetaspaceExpansion=5m 元空间扩大时的最大幅度。

7.2 其他相关参数

参数名称 默认值 配置示例 描述
-XX:+UseCompressedOops true -XX:+UseCompressedOops 是否开启压缩对象指针,如果开启,Java 堆中对象指针会被压缩成32位
-XX:+UseCompressedClassPointers true -XX:+UseCompressedClassPointers 是否开启压缩类指针,如果开启,Java 对象中指向类元数据的指针会被压缩成32位
-XX:CompressedClassSpaceSize 1G -XX:CompressedClassSpaceSize=1G 类指针压缩空间大小

---END---


  !版权声明:本博客内容均为原创,每篇博文作为知识积累,写博不易,转载请注明出处。