文章目录
1 方法区
1.1 堆,虚拟机栈,方法区的交互关系
运行时数据区可大致分为五部分:
方法区,堆,虚拟机栈,程序计数器,本地方法栈

从线程共享与否的角度看:
方法区和堆是线程共享的,虚拟机栈,程序计数器,本地方法栈是线程私有的
元空间(JDK8)/永久代(JDK7及之前)是方法区的落地实现

看这样一个简单的代码:
public static void main(String[] args) {
Person person = new Person();
}
其中Person是类型信息 存放在方法区
person是引用变量 存放在main线程的虚拟机栈的局部变量表中 是一个指针 它指向堆中真实的对象
new Person()是具体的对象 真正的对象存放在堆中

1.2 方法区的理解
JVM规范中明确说到尽管方法区在逻辑上是堆的一部分,但对于HotSpot VM来说,方法区还有一个别名叫Non-Heap(非堆),目的就是和堆分开,为此方法区可以看成一块独立的内存空间
方法区的特点:
1,方法区与堆一样,是线程共享的内存区域
2,方法区在JVM启动时被创建,JVM关闭时销毁,且它实际的物理内存和堆一样都可以是不连续的
3,方法区的大小,可以选择固定大小或使用参数设置
4,方法区的大小决定了系统可以保存多少个类信息,如果系统定义了太多类或加载了大量第三方jar文件,这些都可能导致方法区溢出,JVM同样会抛出OOM
看一个简单的示例,打开Java VisualVM看看执行该方法,方法区中保存了多少类信息:

可以看到一个非常简单的程序,方法区中保存了1600个类的信息,包括当前类的信息,当前类使用的其它类的信息,当前类的父类信息等等
1.3 方法区在HotSpot VM中的演进过程
在JDK7及之前,方法区的实现称为永久代(PermGen Space),在JDK8到现在称为元空间(Meta Space),这二者是对方法区的不同实现

在JDK8中及以后,永久代的概念被废弃,改用了JRockit,J9一样在本地内存中实现的元空间来代替
元空间的本质和永久代相似,都是方法区的落地实现,不过元空间和永久代最大的区别在于
元空间不再像永久代一样使用JVM的内存,而使用本地物理内存,二者不但名字不同,使用的内存不同,内部结构也进行了调整
1.4 设置方法区的大小
方法区的大小不是固定的,可以通过参数调整
在JDK7及以前,永久代大小的设置:
通过-XX:PermSize=x来设置永久代初始分配空间 默认值是20.75M
通过-XX:MaxPermSize=x来设置永久代的最大空间 32位机器最大64M 64位机器最大82M
当JVM加载的类信息超过了永久代的最大空间,会抛出
OutOfMemoryError:PermGen space
在JDK8到现在,元空间大小的设置:
通过-XX:MetaspaceSize=x来设置元空间初始分配空间 默认是21M
通过-XX:MaxMetaspaceSize=x来设置永久代的最大空间 默认为-1 即没有限制 可用物理内存多大 就能用多大
但当本地物理内存也不够方法区使用时,会抛出
OutOfMemoryError:Metaspace
开发中方法区一般只设置初始分配空间,不设置最大空间
对于64位的服务器端JVM来说,其默认的-XX:MetaspaceSize为21M,这是初始的高水位线
一旦方法区的大小触及到这个高水位线,会开启Full GC对堆和方法区进行垃圾回收
将方法区中没有的类信息回收,然后这个高水位线将会重置
新的高水位线的值取决于Full GC后方法区释放了多少空间
如果释放的空间不足,在不超过MaxMetaspace时,适当提高该值
如果释放空间较多,则适当降低该值
如果方法区初始内存设置的较低,高水位线的调整会发生很多次
也会就说Full GC进行了很多次,为此应该将方法区初始内存大小设置稍大一些,避免多余的Full GC
可以看到元空间和永久代最大的差距是最大内存的设置,永久代有最大限制,元空间默认会使用机器的所有可用物理内存,极大的减少了方法区发生OOM的概率,应该将元空间的初始大小设置的较大一些,避免多余的Full GC
1.5 OOM的排查
1,要解决方法区或堆出现的OOM,一般手段是通过内存映像分析工具(如Java VisualVM)对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是说先要分析出是出现了“内存泄漏” 还是 “内存溢出”
内存泄漏: 已申请的内存空间,但该空间无法被释放
一次内存泄漏问题不大,但多次内存泄漏就会导致内存溢出
内存溢出: 申请空间时,可用空间不足
2,如果是出现了内存泄漏,可以通过工具查看泄漏对象到GC Roots的引用链,于是就能找到泄漏对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的,掌握了泄漏对象的信息,以及GC Roots引用链的信息,就可以比较准确地定位出泄漏代码的位置
3,如果不存在内存泄漏,就是说内存中的对象确实还必须存活,那就应该检查虚拟机的堆参数,与机器物理内存,从代码上检查对象生命周期是否过长,持有时间是否过长,尝试减少程序运行期间的内存消耗
1.6 方法区存储的信息
class文件将被类加载子系统加载到方法区,方法区用于存储:
类型信息 成员信息 方法信息 JIT代码缓存 运行时常量池
不同JDK版本方法区的结构稍有差别,但从JDK8到现在元空间没有变动过
对class文件使用javap -v -p xxx.class查看反编译后的完整结构
1,类型信息:
对每个加载的类型(class,interface,enum,annotation),JVM必须在方法区中存储以下类型信息:
1,这个类型的完整名称(包名.类名)
2,这个类型的直接父类的完整名称(interface和Object类没有直接父类)
3,这个类型的修饰符(public,abstract,final)的某个子集
4,这个类型直接接口的一个有序列表
如:
public class com.coisini.methodarea.MethodAreaStruct implements java.lang.Runnable,java.io.Serializable
2,字段(成员)信息:
JVM必须在方法区中保存类型所有成员的信息及声明顺序
成员信息包括: 成员名称,成员类型
成员修饰符(public,protected,static,volatile,transient)的某个子集
如:
private int num;
descriptor: I
flags: ACC_PRIVATE
private static java.lang.String str;
descriptor: Ljava/lang/String;
flags: ACC_PRIVATE, ACC_STATIC
private static final int i;
descriptor: I
flags: ACC_PRIVATE, ACC_STATIC, ACC_FINAL
ConstantValue: int 12
3,方法信息:
JVM必须在方法区保存所有方法的信息及声明顺序
方法信息包括:
1,方法名称
2,方法返回类型
3,方法的参数和类型(按顺序)
4,方法的修饰符(public,private,protected,static,final,synchronized,native,abstract)的某个子集
5,方法的字节码(bytecodes),操作数栈,局部变量表及大小(abstract和native方法除外)
6,方法的异常表(abstract和native方法除外)
每个异常处理的开始位置,结束位置
代码处理在程序计数器中的偏移地址,被捕获的异常类的常量池索引
如:
private void test1();
descriptor: ()V
flags: ACC_PRIVATE
Code:
stack=2, locals=2, args_size=1
0: bipush 40
2: istore_1
3: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
6: iload_1
7: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
10: return
LineNumberTable:
line 17: 0
line 18: 3
line 19: 10
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 this Lcom/coisini/methodarea/MethodAreaStruct;
3 8 1 tmpCount I
1.7 class文件中的常量池 Constant pool
方法区中还有一个重要的结构运行时常量池,而在字节码文件中包含了常量池
字节码文件被类加载子系统加载到方法区后,常量池中的内容就被加载到了方法区对应的运行时常量池,在理解运行时常量池前,先分析字节码文件中的常量池

一个有效的字节码文件中除了包含类的版本信息,成员,方法以及接口等描述信息外,还有一项信息就是常量池,
常量池保存各种字面量(文本字符串,final修饰的常量,基本数据类型的值,变量名,方法名…),以及类型,成员和方法的符号引用
常量池中保存的字面量 (CONSTANT_Utf8_info都是字面量) :

常量池中保存的符号引用(类型,成员,方法):

1.7.1 为什么需要常量池
一个Java源文件中的类,接口等信息编译后产生一个字节码文件
字节码文件才是JVM需要的原材料,字节码文件也需要大量的数据支持
通常这些数据很大以至于不能直接存放到字节码文件中
如果将这些数据全直接放到字节码文件中,字节码文件会很庞大
因此采用常量池来“节省空间地”保存这些数据,以压缩整个字节码文件
比如下面的代码:

这是一个非常简单的类,但是里面却用到了String,System,PrintStream等结构,这已经是一个很简单的代码了,如果这个类方法很多,很复杂,那么引用到的结构会更多,如果将这些引用信息直接全部存放在class文件中,那么class文件会很庞大,为此
使用常量池,将类中出现的字面量保存起来,并把类型信息,成员信息,方法信息转换成对应的符号,存储在常量池中,这些符号就是符号引用,当执行时将符号引用转换成直接引用,即指向数据真实地址的指针

如上述类中的test2()方法,观察其对应的JVM指令,可以看到有 #5 #6 #7 这样的数据出现,这样的数据就对应常量池中索引为5,6,7的常量
#5在常量池中是一个符号引用 代表类型信息<java/lang/RuntimeException>

#6在常量池中是一个字面量 代表字符串 "除0错"


#7 是一个符号引用 用到了#5和#51 代表<java/lang/RuntimeException>类中的init方法

常量池可以看成一张表,虚拟机指令根据这张常量表找到要使用的字符串,常量,基本数据类型值,类型信息,方法信息,成员信息等来执行JVM指令
1.8 方法区中的运行时常量池
常量池是字节码文件中的一部分,用于存放编译期间生成地各种字面量和符号引用
当字节码文件经过类加载子系统加载后,常量池中的内容被加载到方法区对应的运行时常量池
运行时常量池的特点:
1,当某个类型(类,接口,枚举,注解)被加载到JVM后,就会在方法区为它创建对应的运行时常量池,方法区中存储若干个运行时常量池,运行时常量池通过索引访问数据
2,当在方法区创建类型的运行时常量池时,如果创建运行时常量池所需的空间超过了方法区剩余的空间,就会抛出OOM
3,运行时常量池中包括编译期已经明确的字面量,和运行期将符号引用解析后获得的类,方法,成员的直接引用(真实地址指针)
4,运行时常量池,相对于class文件常量池的最大特征是 “具备动态性”,也就是运行期间也能将常量放入运行时常量池,而不是class文件常量池编译后内容就不可更改
1.9 执行一个方法的大致过程
跟踪下面main方法的指令来看看执行指令的过程


0 sipush 500
// 指令0 将500压入操作数栈
3 istore_1
// 指令3 将栈顶数据500出栈 并存储到局部变量表的1号Slot
4 bipush 100
// 指令4 将100压入操作数栈
6 istore_2
// 指令6 将栈顶数据100出栈 并存储到局部变量表的2号Slot
7 iload_1
// 读取局部变量表1号Slot的数据500 并压入操作数栈
8 iload_2
// 读取局部变量表2号Slot的数据100 并压入操作数栈
9 idiv
// 将栈底数据除以栈顶数据的结果5 压入操作数栈
10 istore_3
// 将操作数栈栈顶的数据5 存储到局部变量表的3号Slot
11 bipush 50
// 将50压入操作数栈
13 istore 4
// 将操作数栈顶的数据50 存储到局部变量表的4号Slot
15 getstatic #2 <java/lang/System.out>
// 获取System类的out成员
// 运行时 运行时常量池中不再存储#2
// 而是<java/lang/System.out>在方法区对应的地址
18 iload_3
// 将局部变量表中3号Slot的数据5取出 压入操作数栈
19 iload 4
// 将局部变量表中4号Slot的数据50取出 压入操作数栈
21 iadd
// 将操作数栈中栈底和栈顶的数据相加55 压入操作数栈
22 invokevirtual #3 <java/io/PrintStream.println>
// 执行out对象的println方法 打印栈顶的数55
// 运行时 运行时常量池中不再存储#3
// 而是<java/io/PrintStream.println>在方法区对应的地址
25 return
// 方法结束 栈帧消耗
1.10 方法区在HotSpot VM中的演进细节
1.10.1 不同版本JDK中方法区的构成
HotSpot VM中不同版本方法区的变化:
jdk 1.6及之前: 有永久代
静态变量和字符串常量池(String Table)存放在永久代中(只有HotSpot才有永久代)

jdk 1.7: 有永久代,但已经逐步“去永久代”
将永久代中的字符串常量池,静态变量移除,保存在堆里

jdk 1.8及之后: 无永久代
类型信息,成员信息,方法信息,运行时常量池保存在本地内存的元空间
将字符串常量池和静态变量保存在堆里

为什么要用元空间替换永久代?
永久代的思路就是所有的内存都由JVM分配管理,让JVM控制程序运行时的一切,而为了融合JRockit(JRockit没有永久代,方法区应该存储的数据都保存在物理内存),并且减少方法区出现OOM的问题,就使用元空间替换永久代,将方法区应该存储的数据放在物理内存
1,永久代的空间大小很难确定,在加载的类过多的情况下易出现OOM
如一个庞大的Web工程,运行时要不断动态地加载很多类,就容易出现OOM
而元空间使用物理内存,仅元空间大小仅限于本地机器可用物理内存大小
2,对永久代的调优很困难,很难避免多次的Full GC
1.10.2 字符串常量池为什么要放在堆里
JDK7中将字符串常量池(StringTable)放到了堆空间中,因为永久代的回收效率很低,在Full GC时才会触发,而Full GC是因为老年代的空间不足和永久代空间不足才触发的,这就导致StringTable回收效率不是很高
因为在开发中有大量的字符串被创建,如果将字符串常量池放在永久代,那么就会导致字符串回收效率低,导致永久代空间不足,而将StringTable放到堆里,能及时回收字符串
1.11 方法区的垃圾回收
JVM规范对方法区的回收是没有明确规定的,因为方法区的回收效果比较难令人满意,尤其是类的卸载,条件相当苛刻,但是对方法区的回收又是必要的,不对方法区进行回收,就会导致方法区的可用容量越来越少,在老版本的HotSpot VM中常常因为对方法区未完全回收导致内存泄漏,进而内存溢出
方法区的GC主要回收两部分内容:废弃常量+不再使用的类型信息
废弃常量
即方法区中运行时常量池内不被任何地方引用的字面量和直接引用
不再使用的类型信息
即方法区中不再使用的类型信息,成员信息,方法信息
类型信息的回收条件很苛刻,需要同时满足:
1,该类所有的对象已经被回收,即堆中不存在该类和该类的子类的对象
2,加载该类的类加载器已经被回收,通常很难达成
3,该类的Class对象没有在任何地方被引用
即使同时满足了上述三个条件,该类型信息也只是允许被回收,而不是和对象一样没有引用就直接被回收
但是在大量的使用反射,动态代理,CGLib等字节码框架,动态生成JSP以及OGSi这类
频繁自定义类加载器的场景中,通常需要JVM具备类型卸载的功能,以保证不会对方法区造成太大压力

被折叠的 条评论
为什么被折叠?



