生命不息,写作不止
继续踏上学习之路,学之分享笔记
总有一天我也能像各位大佬一样
分享学习心得,欢迎指正,大家一起学习成长!
在学习方法区之前已经是学习了堆和栈,现在我们就来回顾一下,来看看三者的交互关系是怎样的。
栈内存是用于存储方法的局部变量、形参和方法调用的状态信息。当一个方法被调用时,Java虚拟机会在栈内存中为该方法创建一个栈帧,用于存储方法的局部变量、形参等信息。当方法执行完毕后,该栈帧会被销毁。
堆内存是用于存储Java对象和数组。当一个对象被创建的时候,JVM会在堆内存中为该对象分配一块内存空间,用于存储对象的实例变量。对象的引用则会存储在栈内存中。当没有任何引用指向该对象时,该对象所占用的堆内存空间会被垃圾回收器回收。
方法区(Method Area)用于存储被虚拟机加载的类信息、常量池、静态变量等。方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样可以是不连续的。方法区的大小,跟堆空间一样,可以选择固定大小或者可拓展。
在Java栈中的本地变量表中存放的是对象的引用地址,指向的是Java堆中的对象实例数据,在对象实例数据中有个到对象类型数据的指针,这个指针会指向这个类的类型数据,而这个类的类型结构就是被加载到方法区中。
方法区在逻辑上是属于堆的一部分,但是在一些简单的实现可能不会选择去进行垃圾收集或进行压缩。但是对于HotspotJVM而言,方法区还有一个别名:Non-Heap(非堆),目的就是要和堆分开。方法区看作是一块独立于Java堆的内存空间。
OutOfMemoryError
错误。方法区的大小可以通过参数来设置固定大小,不设置就是根据应用的需要动态调整。
OutOfMemoryError:PermGen space
。-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
指定,替代上述原有的两个参数。OutOfMemoryError:Metaspace
。MaxMetaspaceSize
时,适当提高该值。如果释放空间过多,则适当降低该值。从Java 8开始,随着永久代的去除,方法区OOM的问题在很大程度上得到缓解。在Java 8及以后的版本中,采用元数据区,可以根据应用程序的需要动态调整内存大小,从而更好地适应不同的应用场景。
OOM的排查与解决:
方法区的内部结构包括常量池(Constant Pool)、类信息(包括接口、注解、方法等等信息)、静态变量、即时编译器生成的代码、运行时常量池等。
方法区的信息存储:
《深入理解Java虚拟机》书中对方法区存储内容的表述是:用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。随着jdk版本的不一样,对于这些存储信息也会有稍微的改变。
方法区存储了加载的每个类的结构信息,包括类的字段、方法、父类、接口等。每个加载的类都有一个对应的Class对象,该对象包含了类的元信息。
对每个加载的类型(类 class、接口 interface、枚举 enum、注解 annotation),JVM 必须在方法区中存储以下类型信息:
- 这个类型需要是完整的类名(全限定名:包名.类名)
- 对于此类型的直接父类是需要完整有效名(对于interface或者java.lang.Object,都是没有父类)
- 这个类型的修饰符(public,abstract,final的某个子集)
- 这个类型直接接口的一个有序列表。(比如说一个接口实现了多个接口,这些被实现的接口是有序的)
对于静态变量(类变量),它们的信息除了存储在方法区中,还会在类加载时被分配在堆内存中,而不是对象实例的内存中。这是因为静态变量是属于类的,而不是对象的。
方法信息包括类中的各种方法的相关数据,这些数据描述了方法的签名、访问修饰符、返回类型、方法体的字节码等。以下是方法区中存储的方法信息的一些关键点:
常量池和运行时常量池都是用于存储字面量常量和符号引用的地方,但是二者是存在一些区别的。在方法区中,包含了运行时常量池(Runtime Constant Pool),要了解运行时常量池,就得先来了解一下常量池。
常量池是方法区的一部分,用于存储编译期生成的字面量和符号引用。常量池包括类和接口的全限定名、字段和方法的名称和描述符、字符串常量等。常量池是Class文件结构的一部分,但在加载到方法区时,它被存储为JVM的内部数据结构。
我们来了解一下常量池,它是在编译阶段由编译器生成并存储在 .class 文件中的一部分。也就是说,常量池虽然也是存储字面量和符号引用的地方,但是它主要是在编译期间确定的。所谓常量池,简单来说可以理解成一张表,虚拟机指令可以根据此表找到类名、方法名、参数类型、字面量等信息。
通过jclasslib
这个idea插件可以直接查看字节码文件,可以从图中看到所谓的常量池,里面存储的类名等信息,开头[01]
就是对应的常量池位置,例如#11
也就对应常量池[11]
。
为什么需要常量池?
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table),包含各种字面量和对类型、域和方法的符号引用。
一个 Java 源文件中的类、接口,编译后产生一个字节码文件。而 Java 中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候用到的就是运行时常量池。
如上图所示,字节码中链接#2
对应的位置就是常量池中的[02]
数据。
运行时常量池与常量池相对应,运行时常量池是方法区的一部分,用于存储一些动态生成的常量,例如String对象的intern()方法生成的字符串常量。
在 JVM 的方法区内部结构中,常量池和运行时常量池都是重要的组成部分。常量池在编译期确定,它包含了类的基本信息;而运行时常量池则在运行时加载,用于支持类的动态特性。运行时常量池通常也被认为是方法区的一部分。
二者的主要区别是:
接下来,我们使用一个demo,通过jclasslib来查看字节码信息,从而了解方法区的结构。
可以通过jclasslib插件也可以通过命令获取到字节码信息,这里我先将两种的方式做个比较。
首先使用命令来进行反编译,我们要先找到.class文件,并且将数据输出到txt文件中,命令如下:
# 切换到相应的地址
cd .\target\classes\com\lyd\testboot\jvm\
# 反编译
javap -v -p .\MethodAreaStructure.class > methodStructure.txt
我们来看一下两者数据的体现,都是用来查看反编译后的字节码信息。
首先,我们可以从jclasslib中看到,记录了父类以及实现的接口的类型信息,而这些数据都指向着常量池对应的索引。这些数据都会在classload加载到方法区中。
*注:加载到方法区中的类里面是记录了是哪个类加载器加载的,类加载器也会记录加载过哪些类。
接下来,我们看一下域信息,这些信息也是有classload加载到方法区中。其记录了域名称、域类型、域修饰符,并且这些都是有先后顺序的。
接着我们继续看方法,这里需要注意的是,在代码中并没有显式声明,但在编译的时候会自动加上无参构造方法,也就是对应着
,从jclasslib可以看到还有一个方法
,这是类的静态初始化块(static initializer),用于静态成员的初始化。
通过jclasslib来介绍一下方法信息还存储了什么,具体是怎么使用的。
为了方便查看这些都是什么信息,我这里将行号表、局部变量表的信息都截图粘在旁边,方便对应。首先,方法区还记会记录了栈的最大深度和局部变量表最大槽数。具体这些是对应了什么,笔者在之前的文章《探索·数据区的私有结构》都做了详细介绍,这里就不再做过多的赘述。
与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指向的地址对应到行号。
这里还需要注意的就是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之前,方法区的落地是基于永久代(PermGen实现),其中包含了类型信息、域信息、方法信息、JIT代码缓存、静态变量和字符串常量池。
在JDK1.7的时候,方法区的落地还是基于永久代(PermGen实现),内容的信息耶大多都不变,只不过就是把静态变量和字符串常量池提取到堆空间中了。所使用的内存还是虚拟机的内存,跟物理内存是存在着映射关系。
在JDK1.8之后啊,就开始将永久代替换成了元数据区(Metaspace)来实现。使用的就不再是虚拟机内存,而是直接使用了物理内存,直接影响上限的是本地内存了。
替换永久代的动机可以通过官网找到答案,JEP 122: Remove the Permanent Generation, 找到Motivation标题的那段话,因为JRockit和Hotspot进行了融合,而JRockit客户不需要配置永久生成(因为JRockit没有永久生成),并且习惯于不配置永久生成,也就这样,所以永久代最后就被替换成了元数据区。
改动也是有必要的:
在JDK7中,StringTable就被放在了堆里了。这是因为永久代的回收效率很低,在full gc的时候才会触发。而full gc是因老年代空间不足、永久代不足时才会触发。这就会导致StringTable的效率不高。而正常开发来说,字符串会大量的被创建,回收效率低,导致永久代空间不足,但是放到堆里,就能够及时的回收。
方法区的垃圾回收主要是两部分:常量池中废弃的常量和不再使用的类型。
先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。字面量比较接近 Java 语言层次的常量概念,如文本字符串、被声明为 final 的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:
方法区的回收内容:
本次学完,算是把运行时数据区的内容算是弄完了,本章介绍了方法区的概念和其内存结构,详细的说明了里面各记录了什么信息,也通过了jclasslib插件反编译字节码来了解整体的逻辑关系。
创作不易,可能有些语言不是很通畅,如有错误请指正,感谢观看!记得点赞哦!