0%

JVM学习笔记(5)——方法区

今天把宋红康老师的JVM内存结构的方法区看完了,至此所有的内存结构都已经系统的学习一遍了,我也在博客中整理和回顾一下我的学习笔记。ps:本来打算昨天就写blog的,结果在准备今天项目的时候遇到了一些问题,真心发现硬件实践要比算法难很多,所以也给我一个警惕,不仅要关注上层的算法知识,也要多关注底层的硬件,多考虑如何将算法落地。

一. 方法区概念

upload successful
我们之前也提到了JVM的内存结构,分为堆、方法区、本地方法栈、虚拟机栈、程序计数器。如果按照线程是否共享来说,方法区和堆是线程共享的,而本地方法栈、虚拟机栈、程序计数器都是线程私有的。方法区和堆一样,在JVM启动的时候就被创建了,并且它的实际物理内存空间中和堆一样是不连续的。方法区是用于存储已被虚拟机加载的类信息、常量、静态变量、计时编译器编译后的代码等。方法区的大小决定了系统可以保存多少类,如果系统定义了太多的类,导致方法区的溢出,虚拟机同样会抛出内存异常错误:java.lang.OutOfMemoryError:MetaSpace。

方法区在java1.8版本之前都是叫做永久代,从1.8开始永久代被弃用,转而变成了元空间,其一些属性也发生了一些变化,在永久代的时候我们需要设置-XX:PermSize -XX:MaxPermSize来分别设置永久代初始值和最大值,当变成元空间之后,不再使用JVM的内存,转而使用本地内存来存储方法区的信息。其默认大小就是本地内存的大小,当然我们仍然可以设置元空间的初始值和最大值,当达到初始值的时候会触发full GC,当到达最大值并且full gc也满足不了需求的时候会触发OOM。比如说我们一直用动态代理创建对象或者一直往常量池加入数据,就有可能发生方法区内存溢出。当然关于触发了OOM,到底是内存泄漏导致的还是内存溢出导致的,这个还需要我们去探讨一下。我们可以看一下知乎的回答内存泄漏和内存溢出有啥区别?(ps:内存泄漏可能会导致频繁进行full GC)

1. 方法区的内部结构

upload successful
我们之前提到了,方法区内部存储有类信息和运行时常量池、字符串常量池、静态变量。其中静态变量和字符串常量池在java1.7之后放在堆中进行处理,后面我们会进行详细的分析。

upload successful
一个经典的堆内部结构还会有域信息和方法信息,但是我们通常将域信息和方法信息放在类型信息中。

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

  • 1.这了类型完整的有效名称(全名=包名、类名)
  • 2.这个类型的直接父类的完整有效名(对于interface、或者是java.lang.Object都没有父类)
  • 3.这个类型的修饰符(public abstruct、final的子集)
  • 4.这个类型的直接接口的一个有序序列

方法信息
JVM必须保存所有方法信息,同域信息一样包括声明顺序

  • 1.方法名称
  • 2.方法的返回类型(或void)
  • 3.方法参数的数量和类型(按顺序)
  • 4.方法的修饰符(public、private、protected、static、final、synchroized、native、abstract)
  • 5.方法的字节码(bytecodes)、操作数栈、局部变量表大小(abstract和netive方法除外)
  • 6.异常表(abstract和native除外),每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移位置、被捕获的异常累的常量池检索。

non-final的类变量
静态变量和类变量关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分。类变量被类的所有实例一起分享,即使没有类实例时你也可以访问他。

upload successful

2. 常量池和运行时常量池

常量池在字节码文件中,运行时常量池在方法区中,这两者还是有一定区别的。

常量池
java原文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这个数据会很大以至于不能直接存到字节码里面,换另一种方式,可以存到常量池,这个字节码包含指向常量池的引用,我们在动态链接的时候就会把符号引用变成直接引用。

常量池通常分为字面量和符号引用,字面量比较接近于Java语言层面的常量的概念,比如文本字符串、声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括了下面的三类变量:

  • 1.类和接口的全限名称
  • 2.字段的名称和描述符
  • 3.方法的名称和描述符

我先来贴一段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

import java.io.Serializable;

public class MethodInnerStrucTest implements Comparable<String>, Serializable {
public int num = 10;
public static final int num1 = 23;
public static String str = "测试方法内部结构";

public MethodInnerStrucTest() {
}

public int compareTo(String o) {
return 0;
}

public void test1() {
int count = 20;
System.out.println("count =" + count);
}

public static int test2(int cal) {
int result = 0;

try {
int value = 39;
result = value / cal;
} catch (Exception var3) {
var3.printStackTrace();
}

return result;
}
}


然后在编译之后通过javap进行反编译,我只将反编译结果汇总的常量池贴出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90

Constant pool:
#1 = Methodref #18.#55 // java/lang/Object."<init>":()V
#2 = Fieldref #17.#56 // MethodInnerStrucTest.num:I
#3 = Fieldref #57.#58 // java/lang/System.out:Ljava/io/PrintStream;
#4 = Class #59 // java/lang/StringBuilder
#5 = Methodref #4.#55 // java/lang/StringBuilder."<init>":()V
#6 = String #60 // count =
#7 = Methodref #4.#61 // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#8 = Methodref #4.#62 // java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
#9 = Methodref #4.#63 // java/lang/StringBuilder.toString:()Ljava/lang/String;
#10 = Methodref #64.#65 // java/io/PrintStream.println:(Ljava/lang/String;)V
#11 = Class #66 // java/lang/Exception
#12 = Methodref #11.#67 // java/lang/Exception.printStackTrace:()V
#13 = Class #68 // java/lang/String
#14 = Methodref #17.#69 // MethodInnerStrucTest.compareTo:(Ljava/lang/String;)I
#15 = String #70 // 测试方法内部结构
#16 = Fieldref #17.#71 // MethodInnerStrucTest.str:Ljava/lang/String;
#17 = Class #72 // MethodInnerStrucTest
#18 = Class #73 // java/lang/Object
#19 = Class #74 // java/lang/Comparable
#20 = Class #75 // java/io/Serializable
#21 = Utf8 num
#22 = Utf8 I
#23 = Utf8 num1
#24 = Utf8 ConstantValue
#25 = Integer 23
#26 = Utf8 str
#27 = Utf8 Ljava/lang/String;
#28 = Utf8 <init>
#29 = Utf8 ()V
#30 = Utf8 Code
#31 = Utf8 LineNumberTable
#32 = Utf8 LocalVariableTable
#33 = Utf8 this
#34 = Utf8 LMethodInnerStrucTest;
#35 = Utf8 compareTo
#36 = Utf8 (Ljava/lang/String;)I
#37 = Utf8 o
#38 = Utf8 test1
#39 = Utf8 count
#40 = Utf8 test2
#41 = Utf8 (I)I
#42 = Utf8 value
#43 = Utf8 e
#44 = Utf8 Ljava/lang/Exception;
#45 = Utf8 cal
#46 = Utf8 result
#47 = Utf8 StackMapTable
#48 = Class #66 // java/lang/Exception
#49 = Utf8 (Ljava/lang/Object;)I
#50 = Utf8 <clinit>
#51 = Utf8 Signature
#52 = Utf8 Ljava/lang/Object;Ljava/lang/Comparable<Ljava/lang/String;>;Ljava/io/Serializable;
#53 = Utf8 SourceFile
#54 = Utf8 MethodInnerStrucTest.java
#55 = NameAndType #28:#29 // "<init>":()V
#56 = NameAndType #21:#22 // num:I
#57 = Class #76 // java/lang/System
#58 = NameAndType #77:#78 // out:Ljava/io/PrintStream;
#59 = Utf8 java/lang/StringBuilder
#60 = Utf8 count =
#61 = NameAndType #79:#80 // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#62 = NameAndType #79:#81 // append:(I)Ljava/lang/StringBuilder;
#63 = NameAndType #82:#83 // toString:()Ljava/lang/String;
#64 = Class #84 // java/io/PrintStream
#65 = NameAndType #85:#86 // println:(Ljava/lang/String;)V
#66 = Utf8 java/lang/Exception
#67 = NameAndType #87:#29 // printStackTrace:()V
#68 = Utf8 java/lang/String
#69 = NameAndType #35:#36 // compareTo:(Ljava/lang/String;)I
#70 = Utf8 测试方法内部结构
#71 = NameAndType #26:#27 // str:Ljava/lang/String;
#72 = Utf8 MethodInnerStrucTest
#73 = Utf8 java/lang/Object
#74 = Utf8 java/lang/Comparable
#75 = Utf8 java/io/Serializable
#76 = Utf8 java/lang/System
#77 = Utf8 out
#78 = Utf8 Ljava/io/PrintStream;
#79 = Utf8 append
#80 = Utf8 (Ljava/lang/String;)Ljava/lang/StringBuilder;
#81 = Utf8 (I)Ljava/lang/StringBuilder;
#82 = Utf8 toString
#83 = Utf8 ()Ljava/lang/String;
#84 = Utf8 java/io/PrintStream
#85 = Utf8 println
#86 = Utf8 (Ljava/lang/String;)V
#87 = Utf8 printStackTrace

我们在常量池中看到

#75 = Utf8 java/io/Serializable
通常表示符号引用
以及
#25 = Integer 23
这样的自面量

这些符号引用通常是需要进行动态链接,在类创建的时候对这些符号引用进行解析,翻译到本地内存中,从而找到真正内存的入口地址。

运行时常量池

运行时常量池是方法区的一部分。常量池表是Class文件的一部分,用于存放编译期生成的种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,都是通过索引访问的。

运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换成了真实地址。

运行时常量池,相对于class文件常量池的另一个重要特征是:具备动态性。
String:intern()
当创建类或者接口窦娥运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛出OutOfMemoryError异常。

二. 方法区的演进细节

在jdk7及以前,习惯上将方法区称之为永久代,但是在1.7之后随着去永久代的落实,永久代的部分内容转移到堆中存储,在java1.8中,正式使用元空间代替了永久代。

本质上方法区和永久代也不是等价的,仅是堆hotspot而言,对如何实现方法区,不做统一要求,例如:BEA JRpckit/IBM J9中就不存在永久代。
现在来看,当年使用永久代。不是好的idea,导致Java程序更容易OOM(超过-XX:MaxPermSize上限)

upload successful

JDK8摒弃了永久代,使用元空间,这两者最大的区别在于元空间不在虚拟机设置的内存中,而是使用本地内存。

hotspot 方法区中的变化

upload successful

这里面有一些问题需要注意,1. 永久代为什么要被元空间替换? 2. 字符串常量池为什么会被转移到堆中 3. 静态变量为什么会被转移到堆中

永久代被元空间替代

随着Java8的到来,Hotspot vm 中再也见不到永久代了,但是这并不意味着类的元数据信息也消失了,这些数据被移到一个与堆不相连的本地内存区域,这个区域叫做元空间。

由于类的元数据分配到本地内存中,元空间的最大可分配空间就是系统可用内存空间。在java虚拟机规范中说的是,为了使得hotspot和jrocket更好的融合,所以去除了方法区。这里解释其实有点含糊,我从我上课中记的笔记和我自己理解的进行一个解释:

  1. 永久代设置空间大小是很难确定的
    元空间默认的最大值为-1也就是整个本地内存,然而永久代的默认最大值是82m,这个是对于一个大工程尤其是使用多个动态代理框架来说是远远不够的,很容易就OOM,而设置永久代最大值需要结合多方面的元素比如JVM加载的class总数,常量池的大小,方法的大小等,如果过小就容易OOM,过大容易导致虚拟机内存紧张。而元空间并不在虚拟机中,而是使用的本地内存,因此,默认情况下,元空间的大小仅受本地内存的限制。

  2. 对永久代调优是困难的
    永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。

字符串常量池和静态变量为什么调整

字符串常量池在java1.7的时候被放入了堆中,因为永久代回收效率很低,在full gc的时候才会触发,而full GC是老年代空间不足、永久代空间不足才会触发的,这就导致了字符串常量池回收的效率不高,而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足,放到堆中能够及时回收。

首先放入堆中一个重要的好处就是当我们大量加载类或者使用String.intern的时候由于永久代或者元空间不存放字符串常量池,这使得在方法区中触发full GC就不会很频繁,并且应为字符串通常寿命较短在放在堆中垃圾回收的效率也高。

二. 方法区中的垃圾回收

《Java虚拟机规范》对方法区的垃圾回收是十分宽泛的。一般来说这个区域的回收效果比较难以令人满意,尤其是类型的加载,条件相当苛刻。但是这部分区域的回收有时又确实是有必要的。以前Sun公司Bug列表中,曾出现若干个严重的bug都是由于低版本的HotSpot虚拟机对此区域未完全回收而导致的内存泄漏。
方法区中的垃圾回收主要分为两个部分:常量池中废弃的常量和不再使用的类型。

之前说过方法区中常量池主要有字面量和符号引用。HotSpot对常量池的回收策略是十分明确的,只要常量池中的常量没有任何地方引用,就可以被回收。

判定一个常量是否被“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

  • 1.该类所有的实例都已经被回收了,也就是Java堆中不存在该类及其任何派生子类的实例。
  • 2.加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGI、JSP的重加载等,否则通常很难达成。
  • 3.该类对应的Java.lang.class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java虚拟机被允许对满足上述的三个条件的无用类进行回收,这里仅仅说的是被允许,而不是和对象一样,没有引用就一定被回收。关于是够要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制。还可以使用-verbose:class以及-XX:+TraceClass-Loading、-XX:+TraceClassUnLoading查看类加载和卸载信息。

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

总结

最后贴一张JVM的内存模型图,大家自行回忆一下我们学过的知识。

upload successful

参考文献

尚硅谷2020最新版宋红康JVM教程持续更新中(java虚拟机详解,jvm从入门到精通)