likes
comments
collection
share

【JVM系列】- 寻觅·方法区的内容

作者站长头像
站长
· 阅读数 7

寻觅·方法区的内容

【JVM系列】- 寻觅·方法区的内容

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

在学习方法区之前已经是学习了堆和栈,现在我们就来回顾一下,来看看三者的交互关系是怎样的。栈内存是用于存储方法的局部变量、形参和方法调用的状态信息。当一个方法被调用时,Java虚拟机会在栈内存中为该方法创建一个栈帧,用于存储方法的局部变量、形参等信息。当方法执行完毕后,该栈帧会被销毁。堆内存是用于存储Java对象和数组。当一个对象被创建的时候,JVM会在堆内存中为该对象分配一块内存空间,用于存储对象的实例变量。对象的引用则会存储在栈内存中。当没有任何引用指向该对象时,该对象所占用的堆内存空间会被垃圾回收器回收。方法区(Method Area)用于存储被虚拟机加载的类信息、常量池、静态变量等。方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样可以是不连续的。方法区的大小,跟堆空间一样,可以选择固定大小或者可拓展。【JVM系列】- 寻觅·方法区的内容在Java栈中的本地变量表中存放的是对象的引用地址,指向的是Java堆中的对象实例数据,在对象实例数据中有个到对象类型数据的指针,这个指针会指向这个类的类型数据,而这个类的类型结构就是被加载到方法区中。

方法区的概念

方法区在逻辑上是属于堆的一部分,但是在一些简单的实现可能不会选择去进行垃圾收集或进行压缩。但是对于HotspotJVM而言,方法区还有一个别名:Non-Heap(非堆),目的就是要和堆分开。方法区看作是一块独立于Java堆的内存空间。

了解方法区

  • 方法区(Method Area)与 Java 堆一样,是所有线程共享的内存区域。
  • 方法区(Method Area)是一块用于存储类信息、常量、静态变量、即时编译器优化后的代码等数据的内存区域。在不同的JVM实现中,方法区也被称为永久代(Permanent Generation,在Java 7及之前的版本中),或者是在Java 8及以后的版本中,被元数据区(Metaspace)取代。
  • 方法区在JVM启动的时候就会被创建,实际物理内存空间和Java堆区是一样都可以不连续。并且方法区的空间大小也是可以选择固定或者可扩展。
  • 方法区也是有内存溢出错误,当定义的类过多,导致方法区溢出,虚拟机会抛出OutOfMemoryError错误。
  • 在关闭JVM就会释放这个方法区的内存。

我们通过Java VisualVM就能看到方法的占用情况【JVM系列】- 寻觅·方法区的内容

设置方法区大小

方法区的大小可以通过参数来设置固定大小,不设置就是根据应用的需要动态调整。

jdk1.7及以前

  • 通过-XX:PermSize来设置永久代初始分配空间。默认是20.75M
  • 通过-XX:MaxPermSize来设置永久代最大可分配空间(32位默认64M,64位默认82M)。
  • 当JVM加载的类信息超过了这个值,就会报OutOfMemoryError:PermGen space

jdk1.8及以后

  • 元数据区大小可以使用参数-XX:MetaspaceSize-XX:MaxMetaspaceSize指定,替代上述原有的两个参数。
  • 默认值是与平台有关系,在windows下,-XX:MetaspaceSize是21M,-XX:MaxMetaspaceSize的值是-1,代表没有限制。
  • 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据发生溢出,虚拟机一样会抛出异常OutOfMemoryError:Metaspace
  • -XX:MetaspaceSize :设置初始的元空间大小。对于一个 64 位的服务器端 JVM 来说,其默认的 -XX:MetaspaceSize 的值为20.75MB,这就是初始的高水位线,一旦触及这个水位线,Full GC 将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置,新的高水位线的值取决于 GC 后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。
  • 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次,通过垃圾回收的日志可观察到 Full GC 多次调用。为了避免频繁 GC,建议将 -XX:MetaspaceSize 设置为一个相对较高的值。

OOM问题

从Java 8开始,随着永久代的去除,方法区OOM的问题在很大程度上得到缓解。在Java 8及以后的版本中,采用元数据区,可以根据应用程序的需要动态调整内存大小,从而更好地适应不同的应用场景。

OOM的排查与解决:

  • 可以使用JVM监控工具,例如VisualVM、JConsole或jstat,来监控方法区的使用情况。通过监控工具,你可以观察方法区的内存使用情况,及时发现潜在的OOM问题。在应用程序中,尤其是大型或长时间运行的应用程序中,可能会发生内存泄漏,导致方法区不断积累无用的类信息。通过内存分析工具,如Eclipse Memory Analyzer(MAT)或YourKit等,可以检查并解决潜在的内存泄漏问题。
  • 优化应用程序代码和配置,尽可能减少类的加载和卸载次数,合理使用缓存,以降低对方法区的压力。

*方法区的内部结构

方法区的内部结构包括常量池(Constant Pool)、类信息(包括接口、注解、方法等等信息)、静态变量、即时编译器生成的代码、运行时常量池等。方法区的信息存储:

《深入理解Java虚拟机》书中对方法区存储内容的表述是:用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。随着jdk版本的不一样,对于这些存储信息也会有稍微的改变。

类型信息

方法区存储了加载的每个类的结构信息,包括类的字段、方法、父类、接口等。每个加载的类都有一个对应的Class对象,该对象包含了类的元信息。

对每个加载的类型(类 class、接口 interface、枚举 enum、注解 annotation),JVM 必须在方法区中存储以下类型信息:

  1. 这个类型需要是完整的类名(全限定名:包名.类名)
  2. 对于此类型的直接父类是需要完整有效名(对于interface或者java.lang.Object,都是没有父类)
  3. 这个类型的修饰符(public,abstract,final的某个子集)
  4. 这个类型直接接口的一个有序列表。(比如说一个接口实现了多个接口,这些被实现的接口是有序的)

域(Field)信息

  • JVM 必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
  • 域的相关信息包括:域名称、域类型、域修饰符(public、private、protected、static、final、volatile、transient 的某个子集)

对于静态变量(类变量),它们的信息除了存储在方法区中,还会在类加载时被分配在堆内存中,而不是对象实例的内存中。这是因为静态变量是属于类的,而不是对象的。

方法(Method)信息

方法信息包括类中的各种方法的相关数据,这些数据描述了方法的签名、访问修饰符、返回类型、方法体的字节码等。以下是方法区中存储的方法信息的一些关键点:

  • 方法名称
  • 方法的返回类型
  • 方法参数的数量和类型(需要按顺序)
  • 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract 的一个子集)
  • 方法的字符码(bytecodes)、操作数栈、局部变量表及大小(abstract 和 native 方法除外)
  • 异常表(abstract 和 native 方法除外)
    • 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引。

常量池与运行时常量池

常量池和运行时常量池都是用于存储字面量常量和符号引用的地方,但是二者是存在一些区别的。在方法区中,包含了运行时常量池(Runtime Constant Pool),要了解运行时常量池,就得先来了解一下常量池。

常量池(Constant Pool)

常量池是方法区的一部分,用于存储编译期生成的字面量和符号引用。常量池包括类和接口的全限定名、字段和方法的名称和描述符、字符串常量等。常量池是Class文件结构的一部分,但在加载到方法区时,它被存储为JVM的内部数据结构。

我们来了解一下常量池,它是在编译阶段由编译器生成并存储在 .class 文件中的一部分。也就是说,常量池虽然也是存储字面量和符号引用的地方,但是它主要是在编译期间确定的。所谓常量池,简单来说可以理解成一张表,虚拟机指令可以根据此表找到类名、方法名、参数类型、字面量等信息。通过jclasslib这个idea插件可以直接查看字节码文件,可以从图中看到所谓的常量池,里面存储的类名等信息,开头[01]就是对应的常量池位置,例如#11也就对应常量池[11]【JVM系列】- 寻觅·方法区的内容为什么需要常量池?

一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table),包含各种字面量和对类型、域和方法的符号引用。

一个 Java 源文件中的类、接口,编译后产生一个字节码文件。而 Java 中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候用到的就是运行时常量池。【JVM系列】- 寻觅·方法区的内容如上图所示,字节码中链接#2对应的位置就是常量池中的[02]数据。

运行时常量池(Runtime Constant Pool)

运行时常量池与常量池相对应,运行时常量池是方法区的一部分,用于存储一些动态生成的常量,例如String对象的intern()方法生成的字符串常量。

  • 运行时常量池(Runtime Constant Pool)是在类加载阶段,就会创建对应的运行时常量池,将常量池的信息加载到内存中的一部分,属于方法区的一部分。
  • 常量池表(Constant Pool Table)是 字节码文件的一部分,用于存储编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
  • JVM 为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
  • 运行时常量池中包含各种不同的常量,包括编译器就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或字段引用。此时不再是常量池中的符号地址了,这里将是转换为真实地址。
  • 运行时常量池的内容可能会因为类的动态加载,这也就是运行时常量池相对于字节码文件常量池的另一个重要特征是:动态性
  • 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则 JVM 会抛出 OutOfMemoryError 异常。

常量池与运行时常量池的区别

在 JVM 的方法区内部结构中,常量池和运行时常量池都是重要的组成部分。常量池在编译期确定,它包含了类的基本信息;而运行时常量池则在运行时加载,用于支持类的动态特性。运行时常量池通常也被认为是方法区的一部分。二者的主要区别是:

  • ①常量池是 Java 类文件(.class 文件)的一部分,在编译期由编译器生成;常量池的数据在编译时已经确定,是类文件的一部分,不随着程序的运行而改变。
  • ②运行时常量池是在类加载阶段,将常量池的信息加载到内存中的一部分,属于方法区的一部分;运行时常量池的内容可能会因为类的动态加载、反射等操作而发生变化。

通过字节码看结构

接下来,我们使用一个demo,通过jclasslib来查看字节码信息,从而了解方法区的结构。可以通过jclasslib插件也可以通过命令获取到字节码信息,这里我先将两种的方式做个比较。首先使用命令来进行反编译,我们要先找到.class文件,并且将数据输出到txt文件中,命令如下:

# 切换到相应的地址
cd .\target\classes\com\lyd\testboot\jvm\
# 反编译
javap -v -p .\MethodAreaStructure.class > methodStructure.txt

我们来看一下两者数据的体现,都是用来查看反编译后的字节码信息。【JVM系列】- 寻觅·方法区的内容首先,我们可以从jclasslib中看到,记录了父类以及实现的接口的类型信息,而这些数据都指向着常量池对应的索引。这些数据都会在classload加载到方法区中。 :::info *注:加载到方法区中的类里面是记录了是哪个类加载器加载的,类加载器也会记录加载过哪些类。 ::: 【JVM系列】- 寻觅·方法区的内容接下来,我们看一下域信息,这些信息也是有classload加载到方法区中。其记录了域名称、域类型、域修饰符,并且这些都是有先后顺序的。【JVM系列】- 寻觅·方法区的内容接着我们继续看方法,这里需要注意的是,在代码中并没有显式声明,但在编译的时候会自动加上无参构造方法,也就是对应着<init>,从jclasslib可以看到还有一个方法<clinit>,这是类的静态初始化块(static initializer),用于静态成员的初始化。通过jclasslib来介绍一下方法信息还存储了什么,具体是怎么使用的。【JVM系列】- 寻觅·方法区的内容为了方便查看这些都是什么信息,我这里将行号表、局部变量表的信息都截图粘在旁边,方便对应。首先,方法区还记会记录了栈的最大深度和局部变量表最大槽数。具体这些是对应了什么,笔者在之前的文章《探索·数据区的私有结构》都做了详细介绍,这里就不再做过多的赘述。与javap输出的数据是一样的,只是用了不用的展示方式。

public void count();
  descriptor: ()V
  flags: ACC_PUBLIC
  Code:
    stack=2, locals=2, args_size=1
       0: iconst_0
       1: istore_1
       2: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       5: iload_1
       6: invokevirtual #3                  // Method java/io/PrintStream.println:(I)V
       9: return
    LineNumberTable:
      line 20: 0
      line 21: 2
      line 22: 9
    LocalVariableTable:
      Start  Length  Slot  Name   Signature
          0      10     0  this   Lcom/lyd/testboot/jvm/MethodAreaStructure;
          2       8     1 count   I

还有一个重要的是,异常的情况。如下图,通过jclasslib中,在exceptionTest#Code下会多出一个StackMapTable(堆栈映射表)。 主要记录了在字节码的特定位置处的栈帧映射帧信息。每个栈帧映射帧描述了该位置处的局部变量表、操作数栈的类型状态,以及控制流的信息。在Code中,能够看到异常表信息。里面会记录了异常开始的PC地址和结束的PC地址,以及触发异常之后需要跳转的PC地址,在通过行号表可以根据PC指向的地址对应到行号。【JVM系列】- 寻觅·方法区的内容这里还需要注意的就是non-final的类变量

  • 静态变量和类关联在一起,随着类的加载而加载,成为类数据在逻辑上的一部分。
  • 类变量被类的所有实例共享,即使没有共享也能访问到。

例如一个简单的例子。我们定义一个静态变量,我们没有实例MethodAreaStructure对象,而是通过MethodAreaStructure.string的方法获得到这个字符串数据。即便我们创建了个null的对象,也能获得到。

public static void main(String[] args) {
    MethodAreaStructure methodAreaStructure = null;
    System.out.println(MethodAreaStructure.string);
    System.out.println(methodAreaStructure.string);
}

还有一种就是全局常量,使用static final修饰的。被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候,就已经会被分配了。

对于方法区的使用,字节码的PC包括本地变量表的变化是如何的,笔者在《探索·数据区的私有结构》一文中做了简单的介绍,后续再来补充细致化的描述。

*方法区的演进

Hotspot方法区的演变过程:(只有Hotspot才有永久代)

jdk1.6及之前有永久代,静态变量存放在永久代上
jdk1.7有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中
jdk1.8及之后取消永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆中

JDK1.6之前

在jdk1.6之前,方法区的落地是基于永久代(PermGen实现),其中包含了类型信息、域信息、方法信息、JIT代码缓存、静态变量和字符串常量池。【JVM系列】- 寻觅·方法区的内容

JDK1.7

在JDK1.7的时候,方法区的落地还是基于永久代(PermGen实现),内容的信息耶大多都不变,只不过就是把静态变量和字符串常量池提取到堆空间中了。所使用的内存还是虚拟机的内存,跟物理内存是存在着映射关系。【JVM系列】- 寻觅·方法区的内容

JDK1.8之后

在JDK1.8之后啊,就开始将永久代替换成了元数据区(Metaspace)来实现。使用的就不再是虚拟机内存,而是直接使用了物理内存,直接影响上限的是本地内存了。【JVM系列】- 寻觅·方法区的内容

永久代为何被替换呢?

替换永久代的动机可以通过官网找到答案,JEP 122: Remove the Permanent Generation, 找到Motivation标题的那段话,因为JRockit和Hotspot进行了融合,而JRockit客户不需要配置永久生成(因为JRockit没有永久生成),并且习惯于不配置永久生成,也就这样,所以永久代最后就被替换成了元数据区。改动也是有必要的:

  • 永久代设置空间大小是很难确定的。如果动态加载很多类,就会很容易出现Perm区的OOM。而元空间并不在虚拟内存中,它依靠的是本地内存,所以,元空间在默认情况下只受本地内存的影响。
  • 对永久代进行的调优是很困难的。

字符串常量池(StringTable)为什么要做调整呢?

在JDK7中,StringTable就被放在了堆里了。这是因为永久代的回收效率很低,在full gc的时候才会触发。而full gc是因老年代空间不足、永久代不足时才会触发。这就会导致StringTable的效率不高。而正常开发来说,字符串会大量的被创建,回收效率低,导致永久代空间不足,但是放到堆里,就能够及时的回收。

方法区的垃圾回收机制

方法区的垃圾回收主要是两部分:常量池中废弃的常量不再使用的类型。先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。字面量比较接近 Java 语言层次的常量概念,如文本字符串、被声明为 final 的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

方法区的回收内容:

  • HotSpot 虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。
  • 判定常量是否废弃是简单,但是判断一个类型是否属于“不再被使用的类”那就比较难的了,需要同时满足三个条件:
    • 该类的所有实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例。
    • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常很难达成。
    • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
  • Java 虚拟机被允许堆满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot 虚拟机提供了 -Xnoclassgc 参数进行控制,还可以使用 -verbose:class 以及 -XX:+TraceClassLoading 、-XX:+TraceClassUnLoading 查看类加载和卸载信息。
  • 在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

总结

本次学完,算是把运行时数据区的内容算是弄完了,本章介绍了方法区的概念和其内存结构,详细的说明了里面各记录了什么信息,也通过了jclasslib插件反编译字节码来了解整体的逻辑关系。