运行时数据区
JVM内存布局规定了java在运行过程中内存申请、分配、管理的策略,保证了JVM的高效稳定的运行.不同的JVM对内存的划分方式和管理机制存在着部分差异
Java虚拟机中定义了若干程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机的启动而创建,随着虚拟机的退出而销毁
如堆空间和方法区
另一些则是与线程一一对应,这些数据区会随着线程开始和结束,创建和销毁
如程序计数器、本地方法栈、虚拟机栈
每个线程,独立包含程序计时器、栈、本地栈
线程间共享:堆、堆外内存(永久代、元空间、代码缓存)
线程
线程是一个程序里的运行单元,每个JVM允许一个应用有多个线程并行执行
在Hotspot JVM里,每个线程都与操作系统的本地线程直接映射
当一个java线程执行好以后,此时一个操作系统的本地线程也同时创建
java线程执行终止以后,本地线程也会被回收
操作系统负责所有线程的安排和调度到任何一个可用的CPU上,一旦本地线程初始化完成,他就会被调用执行线程的run方法
如果使用jconsole或其他调试工具,都能看到所有线程(不包括main方法和main方法创建的线程)
Hotspot的后台系统线程主要有以下几个
虚拟机线程
需要在所有线程都到达安全点(这样堆才不会发生变化),执行"stop the world"的垃圾收集、线程栈收集,线程挂起以及偏向锁撤销
程序执行时并非所有地方都能停下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置称为安全点
周期任务线程
周期性操作的调度执行
GC线程
对JVM里不同种类的垃圾收集行为提供了支持
编译线程
运行时会将字节码编译为本地代码
信号调度线程
接受信号并发送给JVM,在内部通过调用适当方法进行处理
程序计数器
JVM的程序计数器Program Counter Register中Register命名源自CPU寄存器,寄存器存储指令相关信息.CPU只有把数据装载到寄存器才能够运行,JVM中的pc寄存器时对物理寄存器的一种抽象模拟.这里并非广义上所指的物理寄存器,所以翻译为程序计数器会更加贴切不容易产生误会
程序计数器是用来存储指向下一条指令的地址的,也将要执行的指令代码,由执行引擎读取下一条指令
程序控制流的指示器,如分支、循环、跳转、异常处理、线程恢复等功能都需要依赖程序计数器
任何时间一个线程都只会有一个方法执行,也就是当前方法
,程序计数器会存储当前线程正在执行的方法的JVM指令地址,如果执行的是native则是未制定值
实例
可以通过javap命令反编译代码,部分截取
其中左边的部分就是指令地址,右边的部分是操作指令
执行引擎通过程序计数器找到对应的操作指令,并且将对应的指令翻译为机器指令交给CPU执行,同时操作局部变量表、操作数栈
0: bipush 10
2: istore_1
3: bipush 20
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: ldc #2
12: astore 4
作用
CPU需要在多个时间片中不停的切换各个线程,切换回原线程后就需要知道要从哪里开始继续执行,JVM的字节码解释器需要通过改变程序计时器的值来明确下一条需要执行什么样的字节码指令
看似并行实则并发
程序计数器是线程私有
的,CPU会在不同的线程中切换,为了能够准确的记录各个线程正在执行的字节码指令地址,就需要为每个线程都配备一个程序计数器.这样各个线程都会独立计算,而不会出现相互干扰的情况
虚拟机栈
每个线程创建时都会创建一个虚拟机栈,在这个线程上执行的每个方法都对应一个栈帧,并且不同线程中所包含的栈帧是无法相互引用的
栈帧是一个内存区域,维系着方法执行过程中的各种数据信息
栈是一种快速有效的内存分配方式,每个方法的执行都会伴随着压栈和出栈
在一个活动线程中,一个时间点上,只会有一个活动的栈帧,即只有当前正在执行的方法的栈帧是有效的,与当前栈帧
对应的方法就是当前方法
,定义这个方法的类就是当前类
如果在该方法中调用了其他方法,对应新的栈帧就会被创建出来,放在栈的顶部,成为新的栈帧
如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前栈帧,使得下一个栈帧成为当前栈帧
java有两种方法返回形式,一种是正常的方法返回,即return指令.另一种是出现未被捕获的异常,并以抛出异常的方式处理.不管哪种方式,都会导致栈帧被弹出
内存异常
java虚拟机规范允许java栈的大小是动态的或着固定的
如果采用固定大小的虚拟机栈,如果线程请求分配的栈内存超过java虚拟机栈允许的最大容量,则会抛出StackOverflowError
如果虚拟机栈可以动态扩展,当尝试扩展时无法申请到足够的内存时,或者新创建的线程没有足够的内存去创建对应的虚拟机栈时则会抛出OutOfMemoryError
可以通过Xss修改栈内存大小
-Xss 2m
内部结构
每个栈帧的结构
局部变量表
操作数栈
动态链接(指向运行时常量池的方法引用)
方法地址
附加信息(栈帧中还运行携带java虚拟机实现的相关的附加信息,例如对程序调试提供支持的信息,不一定存在)
局部变量表
局部变量表是一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用、以及返回地址(returnAddress).对于一个方法而言,它的参数和局部变量越多,局部变量表就越膨胀,栈帧也就越大,进而导致栈能容纳的栈帧就会越少
局部变量表中的变量只会在当前方法中有效,当方法调用完成后,随着栈帧的销毁,局部变量表也随之销毁
局部变量表所需的大小是在编译期就确定下来,可以通过jclaaslib中方法的Code属性的maximum local variables数据项中,在方法运行期间不会被改变
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1 // locals就是局部变量表的最大长度
局部变量表中的变量是重要的垃圾回收的根结点,只要被局部变量表中直接或间接引用的对象都不会被回收
slot
局部变量表中最基本的存储单元是slot(变量槽),局部变量表中存放编译期可知的基本数据类型、引用数据类型和returnAddress类型的变量
局部变量表中,32位以内的类型占用一个slot(包括returnAddress,byte、short、char、boolean都会被转换为int).64位的类型占用两个slot(long和double)
JVM会为局部变量表中的每一个slot都分配一个访问索引,通过这个索引即访问到局部变量表中指定的局部变量值.当访问局部变量表中一个64位的局部变量时,只需前一个索引即可
一个实例方法被调用时,它的方法参数和方法体内定义的局部变量将会按照顺序被复制到局部变量表中的每一个slot上
当前栈帧是构造方法或实例方法创建的,那么该对象的引用将会存放在下标为0的slot处
非静态方法则没有该对象的引用
private int count = 0;
// this变量不存在于静态方法的局部变量表中
public static void test() {
// this.count;
}
如果一个方法有返回值,但是没有使用变量接收,那么也不会被存放入局部变量表中
public void test1() {
// 该方法只有hello存在于局部变量表中
String name1 = "hello";
test2(name1);
}
public String test2(String hello) {
return hello + "world";
}
重复利用
栈帧的局部变量中的slot是可以复用的,如果一个局部变量超过了其作用域,那么在其作用域之后申明的新的局部变量可能就会复用过期的局部变量的slot,从而达到节省资源的目的
public void test4() {
int a = 0;
{
int b = 0;
b = a + 1;
}
// 变量b的生命周期已结束,c直接占用了b的位置
int c = a + 1;
}
类变量在类的加载过程
中有两次初始化的机会,和类变量初始化不同的是,局部变量表不存在系统初始化过程
也就是说局部变量必须赋值,否则无法使用
public void test() {
int i;
// 变量i还没有初始化
// System.out.println(i);
}
操作数栈
操作数栈主要用于保存计算过程的中间结果,同时作为计算过程中的变量临时存储空间
操作数栈是JVM执行引擎的工作区,当一个方法刚开始执行的时候同时也会创建一个新的操作数栈.每一个操作数栈都会拥有一个明确的栈深度用于存储数值,它所需的最大深度在编译期就确定好了
通过jclaaslib查看,则是保存在code中的Maximum stack size
栈中的任意一个元素和局部变量表相似,32位占用一个栈深度,64位占用两个栈深度
操作数栈虽然是采用数组实现的,但是并没有被设计为可以通过索引访问,而是只能通过入栈和出栈来完成数据的访问
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1 // stack就是操作数栈的最大长度
如果被调用的方法有返回值的话,会调用对应的方法并存入局部变量表中,此处是非静态代码所以是通过aload_0获取了当前类的实例对象后再通过invokevirtual调用方法
aload和iload都是从局部变量表中获取并压入操作数栈中
aload获取引用类型,iload获取int类型
public int getSum() {
int m = 10;
int n = 20;
int k = m + n;
return k;
}
public void testGetSum() {
int i = getSum();
int j = 10;
}
字节码指令
0: aload_0
1: invokevirtual #2 // Method getSum:()I
4: istore_1
5: bipush 10
7: istore_2
8: return
栈顶缓存技术
基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作时必须要使用更多的入栈和出栈指令,这同时也就意味着需要更多的指令分派次数和内存读写次数
由于操作数是存储在内存中的,因此频繁的执行内存读写操作必然会影响执行速度.为了解决这个问题,HotSport JVM设计者提出栈顶缓技术,将栈顶元素全部缓存在CPU寄存器中,以此来降低对内存的读写次数,提升执行引擎的执行效率
动态链接
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用.包含这个引用的目的是为了支持当前方法能够实现的动态链接
在java源文件被编译到字节码文件时,所有变量和方法引用都作为符号引用保存在class文件的常量池里
描述一个方法调用了另外的其他方法,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
通过javap 指令可以看到字节码文件中包含常量池.左边的部分是符号引用,右边的是真实结构
Constant pool:
#1 = Methodref #9.#23 // java/lang/Object."<init>":()V
#2 = Fieldref #8.#24 // test3/DynamicLinkingTest.num:I
#3 = Fieldref #25.#26 // java/lang/System.out:Ljava/io/PrintStream;
#4 = String #27 // methodA()....
#5 = Methodref #28.#29 // java/io/PrintStream.println:(Ljava/lang/String;)V
#6 = String #30 // methodB()....
#7 = Methodref #8.#31 // test3/DynamicLinkingTest.methodA:()V
#8 = Class #32 // test3/DynamicLinkingTest
#9 = Class #33 // java/lang/Object
#10 = Utf8 num
#11 = Utf8 I
#12 = Utf8 <init>
#13 = Utf8 ()V
#14 = Utf8 Code
常量池的作用就是为了提供一些符号和常量,便于指令识别
方法的调用
在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关
静态链接 当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译器可知,且运行时保持不变.这种情况下将调用的方法引用直接转换为直接引用的过程称之为静态链接
动态链接 被调用的方法在编译器无法确定下来,只能在程序运行期将调用方法的引用转换为直接引用
对应的方法绑定机制是早期绑定、晚期绑定.绑定是一个字段、方法或者类在 符号引用被替换为直接引用 的过程,并且仅发生一次
早期绑定 被调用的目标如果在编译期可知,且运行保持不变时,即可将这个方法与所属的类型进行绑定,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态连接的方式将符号引用转换为直接引用
晚期绑定 被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定的相关方法
实例
接口与父类
class Animal {
public void eat() {
System.out.println("eat");
}
}
interface Huntable {
void hunt();
}
实现类Dog
class Dog extends Animal implements Huntable {
public void eat() {
System.out.println("eat of dog");
}
public void hunt() {
System.out.println("hunt of dog");
}
}
实现类Cat
早期绑定,编译期就可以确定.对应字节码指令invokespecial和invokestatic
调用构造方法、静态方法、显式的调用父类方法都属于非虚方法
class Cat extends Animal implements Huntable {
public Cat() {
super(); // 早期绑定,非虚方法
}
public Cat(String name) {
this(); // 早期绑定,非虚方法
}
public void eat() {
super.eat(); // 早期绑定,非虚方法
System.out.println("eat of cat");
}
public void hunt() {
System.out.println("hunt of cat");
}
}
调用类
晚期绑定,编译期间无法确定,对应的字节码指令invokevirtual和invokeinterface
public class AnimalTest {
public void showAnimal(Animal animal) {
animal.eat(); // 晚期绑定,虚方法
}
public void showHunt(Huntable huntable) {
huntable.hunt(); // 晚期绑定,虚方法
}
}
java中任意的方法(不包括构造方法)都具备虚函数的特征,相当于c++的virtual关键字修饰的方法
无法被重写,编译期就已经确定的都是非虚方法
静态方法、私有方法、final修饰的方法、实例构造器、显式的调用父类方法都是非虚方法
除此以外都是虚方法
注意 final修饰的是非虚方法,但在编译期无法确定方法的具体调用目标,因此编译器依然会使用invokevirtual指令
class Father {
public final void showFinal() {
System.out.println("father show final");
}
}
public class Son extends Father {
public void show() {
showFinal(); // invokevirtual
super.showFinal(); // invokesepcial
info(); // invokevirtual
}
public void info() {
}
public static void main(String[] args) {
Son son = new Son();
son.show();
}
}
上面invokevirtual是子类独有的方法,不过依然是虚方法,因为子类的方法也会被重写,编译器无法确定
动态类型特性
java7中并没有直接生成invokedynamic指令的方法,需要借助ASM这种底层字节码文件生成该指令.直到java8的lambda出现invokedynamic才有直接生成的方式
该指令是为了实现动态类型语言特性的支持而做的改进
动态类型和静态类型的区别就在于对类型的检查是在编译器还是在运行期,前者是静态语言,而后者是动态语言
静态语言是判断变量自身的类型信息,动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特性
@FunctionalInterface
interface Func {
boolean func(String str);
}
public class Lambda {
public void lambda(Func func) {
}
public static void main(String[] args) {
Lambda lambda = new Lambda();
Func func = s -> true;
lambda.lambda(func);
}
}
lambda表达式只有在运行期间才能确定,实现动态语言类似的特性
重写的本质
找到操作数栈顶的第一个元素所知晓的对象的实际类型
如果在该类型中找到与常量中描述符名称相等的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,如果不通过则返回java.lang.IllegalAccessError
试图访问或修改属性或方法,如果没有权限就抛出IllegalAccessError
按照继承关系从下往上依次对该类的各个父类进行第二步的搜索和验证过程
如果最终没有找到合适的方法,则抛出java.lang.AbstractMethodError
没有重写过方法,调用的是抽象方法,抛出AbstractMethodError
虚方法表
在面向对象的编程中会很频繁的使用动态分配,如果每次动态分配的过程的都要在重新在类的方法元数据中搜索合适的目标方法就会大大影响执行效率,因此JVM在类的方法区建立一个虚方法表来实现
每个类都有一个虚方法表,表中存在着各个方法的实际入口
虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始化准备完成后,JVM会把该类的方法也初始化完毕
方法返回地址
方法的结束有两种方式无论是正常执行完成还是出现未处理的异常导致退出,都会返回到该方法调用的位置
方法正常退出时,调用者的程序技术器的值作为下一条的指令地址
在字节码指令中,返回指令包括ireturn(boolean、byte、char、short、int),lreturn(long),freturn(float),
dreturn(double),areturn(引用类型),return(void)
异常退出时,返回地址是要通过异常表来确定(栈帧中不会保存这部分信息),通过异常退出不会给上层调用者产生任何的返回值
执行过程中出现异常,并且这个异常没有在方法中进行处理(本方法的异常表中没有搜索的匹配的处理器),就会导致方法退出,也就是
异常完成出口
public class Main {
public static void main(String[] args) {
try {
int i = 1 / 0;
} catch (Exception e) {
e.printStackTrace();
}
}
}
异常处理表
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: iconst_1
1: iconst_0
2: idiv
3: istore_1
4: goto 12
7: astore_1
8: aload_1
9: invokevirtual #3 // Method java/lang/Exception.printStackTrace:()V
12: return
Exception table:
from to target type
0 4 7 Class java/lang/Exception
本质上方法的退出就是当前栈帧出栈的过程.此时需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置程序计数器值,让调用者的方法继续执行下去
本地方法栈
虚拟机栈用于管理java方法的调用而本地方法栈用于管理本地方法的调用
和虚拟栈一样也是线程私有的,内存异常
也可以参照虚拟机栈
当一个线程调用本地方法时,它会进入本地方法栈中执行本地方法代码,此时该线程的执行不再受虚拟机的限制并且和虚拟机拥有同样的权限
本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区
可以直接访问处理器中的寄存器
直接从本地内存的堆中分配任意数量的内存
堆
一个jvm只有一个堆内存,堆也是java内存管理的核心区域,堆在jvm启动时候就被创建,并且大小也被确定了.堆可以处于物理上不连续的内存空间,不过在逻辑上它应该被视为连续的
数组和对象一般情况不会存储在栈上,因为局部变量表中存储的是引用,这个引用指向对象在堆中的位置
方法执行完后,堆中的对象不会立刻移出,仅仅在垃圾收集的时候才会被移出
设置堆空间(年轻代+老年代)的内存大小
-Xms 初始堆内存空间,等同于-XX:InitialHeapSize
-Xmx 最大堆内存空间,等同于-XX:MaxHeapSize
-X指jvm运行参数,ms指memory start,mx指memory max
默认堆空间大小为物理内存大小的1/64,最大堆空间大小为物理内存大小的1/4
-Xms600m -Xmx600m
获取JVM参数
long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
System.out.println("-Xms : " + initialMemory + "M"); // 575M
System.out.println("-Xmx : " + maxMemory + "M"); // 575M
Thread.sleep(Integer.MAX_VALUE);
jps用于显示当前系统中所有正在运行的Java进程的相关信息
jstat -gc 显示指定pid的JVM的垃圾回收状况
为了方便浏览,此处将多余返回内容去除了
$ jps
27376 HeapSpaceInitial
$ jstat -gc 27376
S0C S1C S0U S1U EC EU OC OU
25600.0 25600.0 0.0 0.0 153600.0 12288.5 409600.0 0.0
jstat命令返回的信息解析
OC 老年代容量, OU老年代使用量
EC 伊甸园区容量,EU 伊甸园区使用量
S0C 幸存者0区容量, S0U 幸存者0区总量
S1C 幸存者1区容量, S1U 幸存者1区总量
通过计算将OC、EC、S0C和S1C加起来结果为614400除以1024结果为600m,和-Xmx中配置的结果一致
但是实际上内存中S0C和S1C只有一个会参与存储,所以只有575m
jvisualvm
可以通过这个可视化工具看到Eden Space是150M,Survivor 0 是25M,Old Gen是400M总共575m
分代理论
现代垃圾收集器大部分都基于分代收集理论,堆空间分为
新生代
老年代
元空间(jdk7之前为永久区)
元空间不存储在堆中
分代的唯一理由就是优化GC性能,如果没有分代而是将所有的对象放在一起,这样每次都会将整个堆扫描一遍.而很多对象都是朝生夕死,如果分代把新创建的对象放在一起,当GC时优先对这部分区域进行回收,则会腾出很大的空间
以下命令可以打印GC执行细节
可以查看每次的GC日志,JVM执行结束时还会显示最终的堆内存使用情况
-XX:+PrintGCDetails
执行代码后查看控制台,在jdk1.7以及之前显示的是PSPermGen,而1.8以后是Metaspace
和Runtime.getRuntime().totalMemory()
一样,此处PSYoungGen的总量为伊甸园区加上幸存者区0区(或1区)
Heap
PSYoungGen total 179200K, used 12288K [0x00000007b3800000, 0x00000007c0000000, 0x00000007c0000000)
eden space 153600K, 8% used [0x00000007b3800000,0x00000007b4400058,0x00000007bce00000)
from space 25600K, 0% used [0x00000007be700000,0x00000007be700000,0x00000007c0000000)
to space 25600K, 0% used [0x00000007bce00000,0x00000007bce00000,0x00000007be700000)
ParOldGen total 409600K, used 0K [0x000000079a800000, 0x00000007b3800000, 0x00000007b3800000)
object space 409600K, 0% used [0x000000079a800000,0x000000079a800000,0x00000007b3800000)
Metaspace used 3076K, capacity 4620K, committed 4864K, reserved 1056768K
class space used 324K, capacity 392K, committed 512K, reserved 1048576K
新生代和老年代
存储在JVM中的对象分为两类
生命周期较短的瞬时对象,这类对象创建和消亡都较为迅速
生命周期更长,在某些情况下甚至和JVM保持一致
大部分对象都是在Eden中被创建的,除非这个对象被创建时Eden就存储不下了
默认新生代占1,老年代占2,新生代占整个堆的1/3
一般开发中不会修改这个参数,除非项目中有明显大量的生命周期较短或较长的对象需要存储在堆中
可以通过以下参数修改,年轻代占1,老年代占4,年轻代占整个堆的1/5
-XX:NewRatio=4
查看JVM的参数设置
$ jps
42937 EdenSurvivorTest
$ jinfo -flag NewRatio 42937
-XX:NewRatio=2
在HotSpot中Eden和另外两个Survivor所占的默认比例为6:1:1
设置Eden和另外两个Survivor所占的比例为8:1:1
-XX:SurvivorRatio=8
也可以直接设置年轻代的空间,如果和NewRatio冲突时,以-Xmn为准
-Xmn100m
垃圾收集
对象分配详解
对象的创建一般放入伊甸园区
当伊甸园区满后如果JVM还需要创建对象,垃圾收集器将会对伊甸园区进行Minor GC(或称为Young GC),将伊甸园区中不再被引用指向的对象销毁,再将新对象放入伊甸园区
只有Eden区满了才会触发Minor GC,当触发Young GC时,会将Eden区和Servivor区一起回收
Minor GC会引发STW,暂停其它用户线程,等待垃圾回收结束,用户线程才会恢复运行
将这次GC后伊甸园区中的剩余对象转移到幸存者0区
如果再次触发GC,将上次幸存下来的对象全部放入幸存者0区(如果幸存者0区有对象则放入幸存者1区)
两个Servivor区一定有一个是空的,那个空的就是to区,另一个就是from区
当一个对象每次GC都不被回收,累计到一定次数将放入老年区
最大晋升到老年代的阈值默认15次,可以通过以下参数设置
-XX:MaxTenuringThreshold=10
有几种情况直接分配到老年代
创建的对象过大在GC后依然无法移入Eden区或Servivor区,则该对象会被直接移入老年代
对象动态年龄判断 Servivor区中相同年龄的对象数量已经达到Survivor空间一半,年龄大于或等于该年龄的直接进入老年代
幸存者区中使用的是复制算法,0区和1区对象互相转移太过于消耗性能,并且已经积累了一定数量,直接将他们晋升到老年代中
内存分配担保 老年代最大的连续空闲空间和年轻代所有对象的内存大小(或历次晋升到老年代的平均对象大小做比较).如果这个条件成立,那么虚拟机可以保证Minor GC 可能是安全的,因为存活的对象可以放入老年代中,而老年代的可用空间足够容纳它们
幸存者区采用复制收集算法,假如大量对象在Minor GC后仍然存活(最极端情况为内存回收后新生代中所有对象均存活),而Survivor空间是比较小的,这时就需要老年代进行分配担保,把Survivor无法容纳的对象放到老年代
老年代得有足够空间来容纳这些对象(老年代要进行空间分配担保),但一共有多少对象在内存回收后存活下来是不可预知的,因此只好取之前每次垃圾回收后晋升到老年代的对象大小的平均值作为参考
使用这个平均值与老年代剩余空间进行比较,来决定是否进行Full GC来让老年代腾出更多空间
当老年代内存不足时会触发Full GC,如果老年代执行了Full GC后内存依然不足,则会产生OOM
java.lang.OutOfMemoryError: Java heap space
通过VisualVM也可以看出
Eden区每次GC可以看到明显的波峰
Survivor区使用的是复制算法,两个区域内存互相交替
每次GC年轻代的对象都会被移入老年代,所以老年代的占用是逐步上升的,达到顶峰后OOM
元空间存储的是类数据,对象不会被移入,所以比较平稳
GC分类
JVM在进行GC时,并非每次都是对新生代、老年代、元空间(方法区)一起回收
针对HotSpot VM的实现,GC分为部分收集和整堆收集Full GC
部分收集,不是完整收集整个java堆,其中分为
Minor GC (或Young GC)新生代收集
Mixed GC 混合收集 收集整个新生代以及部分老年代的垃圾收集 (目前只有G1 GC 有混合收集的行为)
Major GC 老年代收集 (目前只有CMS GC 有单独收集老年代的行为)
整堆收集Full GC 收集整个java堆和元空间(方法区)的垃圾收集
垃圾收集日志
GC主要参数
-XX:+PrintGC # 和-verbose:gc类似,输出GC简易日志
-XX:+PrintGCDetails # 输出GC的详细日志
-XX:+PrintGCTimestamps # GC时打印进程启动到现在经历的时间
-XX:+PrintGCDatestamps # GC时打印当前时间
-XX:+PrintGCApplicationStoppedTime # 输出GC时应用停顿时间
-XX:+PrintGCApplicationConcurrentTime # 输出GC时打印应用执行时间
-XX:+PrintHeapAtGC # 在进行GC的前后打印出堆的信息
-Xloggc:../logs/gc.log # 日志文件的输出路径(开启该指令可能会导致控制台输出的gc失效)
通过-XX:+PrintGCDetails查看GC日志
[GC (GC原因) [GC区域: 年轻代回收前->年轻代回收后(年轻代总大小)] 年轻代和老年代前->年轻代和老年代后(年轻代和老年代总大小), GC耗时] [Times: user=用户态耗时 sys=系统态耗时, real=实际耗时]
其中年轻代的大小是实际大小的9/10 (复制算法中减去空余的内存1/10)
user 此次gc用户执行的代码执行的cpu时间
sys 此次gc内核执行系统调用或等待系统事件的时间
real gc事件完整的耗时,因为并发的缘故所以user+sys会小于real,如果user+sys大于real则可能io负载过重或cpu不够调度
[GC (Allocation Failure) [PSYoungGen: 3702K->496K(4608K)] 3702K->1830K(15872K), 0.0016111 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 4408K->128K(4608K)] 5742K->3766K(15872K), 0.0031306 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Ergonomics) [PSYoungGen: 3275K->0K(4608K)] [ParOldGen: 9782K->6807K(11264K)] 13058K->6807K(15872K), [Metaspace: 3360K->3360K(1056768K)], 0.0052063 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) [PSYoungGen: 3149K->3149K(4608K)] 9957K->13029K(15872K), 0.0003558 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
[Full GC (Ergonomics) [PSYoungGen: 3149K->0K(4608K)] [ParOldGen: 9879K->9879K(11264K)] 13029K->9879K(15872K), [Metaspace: 3360K->3360K(1056768K)], 0.0017378 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 0K->0K(4608K)] 9879K->9879K(15872K), 0.0002345 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(4608K)] [ParOldGen: 9879K->9820K(11264K)] 9879K->9820K(15872K), [Metaspace: 3360K->3360K(1056768K)], 0.0038914 secs] [Times: user=0.02 sys=0.01, real=0.00 secs]
Heap
PSYoungGen total 4608K, used 160K [0x00000007bfb00000, 0x00000007c0000000, 0x00000007c0000000)
eden space 4096K, 3% used [0x00000007bfb00000,0x00000007bfb28398,0x00000007bff00000)
from space 512K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007bff80000)
to space 512K, 0% used [0x00000007bff80000,0x00000007bff80000,0x00000007c0000000)
ParOldGen total 11264K, used 9820K [0x00000007bf000000, 0x00000007bfb00000, 0x00000007bfb00000)
object space 11264K, 87% used [0x00000007bf000000,0x00000007bf997288,0x00000007bfb00000)
Metaspace used 3391K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 365K, capacity 386K, committed 512K, reserved 1048576K
java.lang.OutOfMemoryError: Java heap space
详解
[GC (Allocation Failure) [PSYoungGen: 3702K->496K(4608K)] 3702K->1830K(15872K), 0.0016111 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
GC (Allocation Failure) 指的是伊甸园区内存分配失败,准备Minor GC
[PSYoungGen: 3702K->496K(4608K)] 新生代的内存占用变化和新生代内存总量
3702K->1830K(15872K) 堆内存的内存占用变化和堆内存的总量
0.0016111 secs GC花费的时间
[Full GC (Ergonomics) [PSYoungGen: 3275K->0K(4608K)] [ParOldGen: 9782K->6807K(11264K)] 13058K->6807K(15872K), [Metaspace: 3360K->3360K(1056768K)], 0.0052063 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
Full GC (Ergonomics) 执行一次Full GC
Ergonomics 堆内存满了导致Full GC
Metadata GC Threshold 方法区满了导致Full GC
[ParOldGen: 9782K->6807K(11264K)] 老年代的内存变化情况,老年代的总量
[Metaspace: 3360K->3360K(1056768K)] 元空间的内存变化情况,元空间的总量
[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(4608K)] [ParOldGen: 9879K->9820K(11264K)] 9879K->9820K(15872K), [Metaspace: 3360K->3360K(1056768K)], 0.0038914 secs] [Times: user=0.02 sys=0.01, real=0.00 secs]
Full GC (Allocation Failure) 执行一次Full GC内存分配失败,可以看到老年代大小几乎没有变化,OOM
日志分析工具
使用-Xloggc:./logs/gc.log
记录日志
可以使用gceasy,通过官网导入gc.log
其他工具gcviewer、GCHisto、HPjmeter
TLAB
堆是线程共享的区域,任何线程都可以访问到堆中的数据,由于对象实例的创建在JVM中十分频繁,因此在并发环境下从堆中划分内存空间是线程不安全的
为避免多个线程同时操作同一地址,需要使用锁等机制,进而影响分配速度的
从内存模型而不是垃圾收集的角度,对Eden区域进行了划分,JVM为每个线程分配了一个私有的缓存区域TLAB(thread lock allocation buffer)
多线程同时分配内存时,使用TLAB可以避免一系列非线程安全问题,同时还能提升内存分配的吞吐量,因此可以将这种内存分配方式称为快速分配策略.尽管不是所有对象都能在TLAB中成功分配内存,但是JVM确实将TLAB作为内存分配的首选
每个线程都会从Eden分配一块空间作为自己的TLAB标识出 eden 里被这个 TLAB 所管理的区域,让eden里的一块空间不让其它线程来这里分配
可以通过以下命令关闭TLAB,TLAB默认开启
-XX:-UseTLAB
TLAB默认情况下占用非常小,仅占整个Eden空间的1%,可以通过以下命令设置Eden空间占用的百分比
-XX:TLABWasteTargetPercent=2
一旦对象在TLAB空间分配内存失败,JVM就会在Eden区中分配内存,并且通过使用指针碰撞
来分配内存
指针碰撞 堆内存被一个指针一分为二.指针的左边都被塞满了对象.指针的右变是未使用的区域,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,只需要通过修改指针偏移量将新对象分配在内存空闲位置上
逃逸分析
通过逃逸分析,编译器会在运行时或编译期分析出一个新的对象的引用使用的作用域从而决定是否要执行优化
逃逸分析就是分析对象动态作用于
一个对象在方法中被定义,但却被该方法以外的其他方法使用
一个对象由某个线程在方法中被定义,但却被其他线程访问
jdk7以后HotSpot默认开启逃逸分析,如果使用更早版本则需要以下命令开启
-XX:+DoEscapeAnalysis
可以使用以下命令查看逃逸分析的筛选结果(可能需要dev版本的jvm才可以使用)
VM option 'PrintIdealGraphLevel' is develop and is available only in debug version of VM.
-XX:+PrintEscapeAnalysis
只有服务器端才可以使用逃逸分析
如果使用的是32位jvm需要开启服务器端模式,可以通过java -version
查看
-server
缺点
逃逸分析这项技术十分不成熟,其根本原因无法保证逃逸分析的性能消耗一定高于优化前的性能,逃逸分析自身也需要进行一系列的复杂分析,这也是一个相对耗时的过程.例如经过逃逸分析后,发现没有对象是不逃逸的,那么逃逸分析的过程就被浪费了
有一些观点认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,但这取决于JVM设计者的选择,Hotspot虚拟机并未这么做, 也就是说至少Hotspot虚拟机的对象全部是在堆里分配的
栈上分配
注意,Hotspot虚拟机并未实现栈上分配,性能的提高是因为标量替换的实现
在JVM中,对象是在堆中分配内存的是一个普遍常识.但是也有特殊情况,如果经过逃逸分析
后发现,一个对象并没有逃逸出方法的话那就可能被优化为栈上分配,这样就无需在堆上分配内存,也无需进行垃圾回收了
开发中能使用局部变量的情况就尽量不要在传递到方法外
// sb逃逸
public StringBuffer create(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.sppend(s2);
return sb;
}
// sb未逃逸,作用域完全在方法内
public String create2(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.sppend(s2);
return sb.toString();
}
测试
以下代码如果使用了-XX:+DoEscapeAnalysis,则会执行的更快并且在堆内存中只会分配少量User对象,并且不会GC
而-XX:-DoEscapeAnalysis关闭了栈上分配,则运行时间更长,堆内存中分配了大量User对象,并且还会触发GC
public static void main(String[] args) throws InterruptedException {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println("花费时间为: " + (end - start) + " ms ");
TimeUnit.SECONDS.sleep(Integer.MAX_VALUE);
}
private static void alloc() {
User user = new User();
}
static class User {}
同步省略
一个对象被发现只能从一个线程被访问,这个对象的操作即可不考虑同步.因为线程同步的代价是相当高,会降低并发性和性能
在动态编译同步块时,JIT编译器可以借助逃逸分析
来判断同步块所使用的锁对象是否只能被一个线程访问而没有被发布到其它线程.如果没有JIT编译器在编译这个同步块时就会取消这部分代码的同步,这样就能提高性能,这个操作就叫同步省略或锁消除
public void f() {
Object hollis = new Object();
synchronized(hollis) {
System.out.println(hollis);
}
}
以上代码会被优化为
public void f() {
Object hollis = new Object();
System.out.println(hollis);
}
并且同步省略是在运行时进行的,所以分析字节码文件依然存在monitorenter和monitorexit关键字
0 new #6 <java/lang/Object>
3 dup
4 invokespecial #1 <java/lang/Object.<init> : ()V>
7 astore_1
8 aload_1
9 dup
10 astore_2
11 monitorenter
12 getstatic #7 <java/lang/System.out : Ljava/io/PrintStream;>
15 aload_1
16 invokevirtual #8 <java/io/PrintStream.println : (Ljava/lang/Object;)V>
19 aload_2
20 monitorexit
21 goto 29 (+8)
24 astore_3
25 aload_2
26 monitorexit
27 aload_3
28 athrow
29 return
标量替换
一个对象可以不需要作为一聚合量可以被访问到,那么该对象的会被拆解成标量并存储在栈中
无法被分解成更小的数据被称为标量,java中基本数据类型就是标量
还可以被分解的被称为聚合量,java对象就是聚合量
可以通过以下命令关闭标量替换,默认开启
-XX:-EliminateAllocations
通过逃逸分析
发现一个对象不会被外界访问,那么经过JIT优化,就会把该对象拆解成若干个成员变量来替代,这就是标量替换
public static void alloc() {
new Point(1, 2);
}
static class Point {
private int x;
public int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
以上代码会被优化为
可以看到Point这个聚合量通过
逃逸分析
分析没有逃逸后,就替换成两个标量了标量替换为栈上分配提供了很好的基础
public static void alloc() {
int x = 1;
int y = 2;
}
方法区
方法区在逻辑上属于堆的一部分,但是可能不会选择进行垃圾回收或压缩,对于Hotspot而言,方法区还有一个别名叫做非堆(Non-Heap)以便区分
方法区在JVM启动时创建,和堆一样是各个线程共享的内存区域,并且和堆一样实际的物理内存可以是不连续的.方法区的大小决定了JVM可以加载多少个类,如果加载了大量的类会导致方法区溢出
永久代
jdk7及之前,习惯将方法区称为永久代
java.lang.OutofMemoryError: PermGen space
# 或者控制台会显示以下信息,虚拟机立刻停止,异常无法被捕捉
A fatal error has been detected by the Java Runtime Environment:
#
# SIGSEGV (0xb) at pc=0x00007ff8120a9ce3, pid=78839, tid=7171
# 以下省略
可以通过以下参数设置永久代的可分配内存空间和最大可分配空间
-XX:PermSize=21.75m
-XX:MaxPermSize=82m
元空间
jdk8之后元空间取代了永久代
java.lang.OutofMemoryError: Metaspace
java.lang.OutOfMemoryError: Compressed class space
可以通过以下参数设置元空间的可分配内存空间和最大可分配空间
MetaspaceSize是初始的高水平线,一旦触及这个水平线,Full GC就会被触发并卸载没用的类以及类对应的类加载器,然后高水平线就会被重置,新的高水平线的值取决于GC后释放了多少元空间,如果释放的空间不足应该提高MetaspaceSize,如果释放的过多则应该适当降低该值
-XX:MetaspaceSize=21m
-XX:MaxMetaspaceSize=18446744073709535232
整个永久代有JVM本身设置的固定大小上限无法进行调整,导致JVM更容易OOM.而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小
存储内容
方法区存储已被虚拟机加载的类型信息、常量、静态变量、JIT即时编译器编译后的代码缓存等
有以下代码
public class MethodInnerStructTest extends Object implements Comparable<String>, Serializable {
public int num = 10;
private static String str = "测试内部结构";
public void test1() {
int count = 20;
System.out.println("count = " + count);
}
public static int test2(int cal) {
int result = 0;
try {
int value = 30;
result = value / cal;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
@Override
public int compareTo(String o) {
return 0;
}
}
可以通过javap -v -p
指令查看字节码文件反编译,并整理出以下信息,整理的代码只是javap命令的片段式例
类型信息
对于加载的类型(class、interface、enum、annotation),JVM必须在方法区存储以下类型信息
该类型的完整有效名称
该类型直接父类的完整有效名
该类型的修饰符(public、abstrat、final等)
该类型的直接接口的有序列表(接口可以有多个)
public class test7.MethodInnerStructTest extends java.lang.Object implements java.lang.Comparable<java.lang.String>, java.io.Serializable
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
域(Field)信息
JVM在方法区中保存类型的相关信息以及域的声明顺序
域信息包括: 域名称、域类型、域修饰符(public、final、static、volatitle、transient等)
public int num;
descriptor: I
flags: ACC_PUBLIC
private static java.lang.String str;
descriptor: Ljava/lang/String;
flags: ACC_PRIVATE, ACC_STATIC
方法信息
JVM在方法区中必须保存方法的以下信息,同域信息一样包含声明顺序
方法名称
方法返回类型(包括void)
方法修饰符(public、final、static、native、abstrat等)
方法的字节码、操作数栈、局部变量表及大小(abstrat、native方法除外)
异常表(abstrat、native方法除外) 每个异常处理的开始位置、结束位置、代码处理在程序计数器的偏移地址、被捕获异常的常量池索引
public static int test2(int);
descriptor: (I)I
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: iconst_0
1: istore_1
2: bipush 30
4: istore_2
5: iload_2
6: iload_0
7: idiv
8: istore_1
9: goto 17
12: astore_2
13: aload_2
14: invokevirtual #12 // Method java/lang/Exception.printStackTrace:()V
17: iload_1
18: ireturn
Exception table:
from to target type
2 9 12 Class java/lang/Exception
运行时常量池
常量池
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息以外,还包含一项常量池,包括各种字面量和对类型、域、方法的符号引用
已以下代码为例
public void test1() {
int count = 20;
System.out.println("count = " + count);
}
对应的字节码常量池
常量池可以看作一张表,虚拟机指令根据这张常量池表找到要执行的类名、方法名、参数类型、字面量等类型
Constant pool:
#1 = Methodref #18.#52 // java/lang/Object."<init>":()V
#2 = Fieldref #17.#53 // test7/MethodInnerStructTest.num:I
#3 = Fieldref #54.#55 // java/lang/System.out:Ljava/io/PrintStream;
#4 = Class #56 // java/lang/StringBuilder
#5 = Methodref #4.#52 // java/lang/StringBuilder."<init>":()V
#6 = String #57 // count =
#7 = Methodref #4.#58 // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#8 = Methodref #4.#59 // java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
#9 = Methodref #4.#60 // java/lang/StringBuilder.toString:()Ljava/lang/String;
#10 = Methodref #61.#62 // java/io/PrintStream.println:(Ljava/lang/String;)V
#11 = Class #63 // java/lang/Exception
#12 = Methodref #11.#64 // java/lang/Exception.printStackTrace:()V
#13 = Class #65 // java/lang/String
#14 = Methodref #17.#66 // test7/MethodInnerStructTest.compareTo:(Ljava/lang/String;)I
#15 = String #67 // 测试内部结构
#16 = Fieldref #17.#68 // test7/MethodInnerStructTest.str:Ljava/lang/String;
#17 = Class #69 // test7/MethodInnerStructTest
#18 = Class #70 // java/lang/Object
#19 = Class #71 // java/lang/Comparable
#20 = Class #72 // java/io/Serializable
对应的字节码指令为
0: bipush 20
2: istore_1
3: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
6: new #4 // class java/lang/StringBuilder
9: dup
10: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
13: ldc #6 // String count =
15: invokevirtual #7 // Method java/lang/StringBuilder.append: (Ljava/lang/String;)Ljava/lang/StringBuilder
18: iload_1
19: invokevirtual #8 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
22: invokevirtual #9 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
25: invokevirtual #10 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
28: return
具体可以参照动态链接
此图只对偏移地址为3的字节码指令做了图解
有#的,使用的都是在常量池中存放
运行时常量池
运行时常量池是方法区的一部分,而上述常量池表(字节码指令中的Constant Pool Table)是字节码文件的一部分,用于存放编译期生成的各种字面量和符号引用,这些内容将在类加载后存放到方法区中的运行时常量池
运行时常量池在加载类和接口到虚拟机后就会创建和维护对应的常量池,池中的数据通过索引访问
运行时常量池中包含多种不同常量,包括编译期就已经明确的字面量和运行期解析后才能够获得的方法或者字段引用,此时就不再是常量池中的符号地址(索引),而是真实的内存地址
运行时常量池具备动态性
String str = "intern";
String intern = str.intern();
方法区演进
HotSpot才有永久代,对于JRocket和J9等虚拟机是不存在永久代的概念的.原则上来说如何实现方法区属于虚拟机的实现细节,不受java虚拟机规范约束
Hotspot方法的的变化
只要是new创建的对象全部都是存放在堆里的,但是对于的变量存储的位置不同
非静态成员变量存储在堆中
静态成员变量在1.6及之前存储在永久代中,之后存储在堆中
方法中的变量存储在局部变量表中
public class StaticObjTest {
static class Test {
static ObjectHolder objectHolder = new ObjectHolder();
ObjectHolder instanceObj = new ObjectHolder();
void foo() {
ObjectHolder localObj = new ObjectHolder();
System.out.println("done");
}
}
private static class ObjectHolder {}
}
替换原因
jdk1.8取消永久代,并且将其中的数据移动到了与堆不相连的内存区域,也就是元空间
为永久代设置空间大小是很难确定的
使用永久代的默认值往往很容易导致OOM,如果设置了过大很容易导致资源的浪费
由于类的元数据直接分配在内存中,所以元空间的最大可分配空间就是系统最大可分配的内存空间
然而元空间的大小仅受本地内存限制
降低GC复杂度
永久代会为GC带来不必要的复杂性,并且回收效率偏低,在永久代中元数据可能会随着每一次GC发生而进行移动,而hotspot虚拟机每种类型的垃圾回收器都要特殊处理永久代中的元数据,分离出来以后可以简化GC
字符串常量池
jdk7中将StringTable放在了堆空间里(在此之前是放在永久代中),这是因为永久代的回收效率较低,只有在full GC的时候才会触发,这就导致了StringTable的回收效率不高,然而开发中会创建大量的字符串,最终导致OOM
所以放在堆内存中就能及时回收
方法区的垃圾收集
java虚拟机规范对方法区的约束是非常宽松的,可以不要求在方法区中实现垃圾收集,事实上也确实有未实现或者未完整实现方法区类型卸载的收集器存在(如jdk11的zgc就不支持类的卸载)
方法区的回收效果非常难以令人满意,尤其是类型的卸载,条件相当苛刻.但是这个区域的回收又是有必要的,方法区的垃圾收集主要分为两部分
废弃的常量
Hotspot回收方法区中的常量和java堆中的对象非常相似,只要常量池中的常量没有在任何地方被引用就可以被回收
不再使用的类型
如何判断一个类型是否属于不在被使用的类,条件比较苛刻,需要同时满足以下条件
该类和该类的子类所有实例对象都已经被回收
加载该类的类加载器全部被回收,这个条件除非是经过精心设计的可替换类加载器的场景(如OSGI、JSP的重加载),否则通常很难达成
该类的类对象没有被任何地方被引用,不能在任何地方通过反射访问该类的方法
关于是否要对类型进行回收hotspot以下参数
-Xnoclassgc # 关闭堆类的垃圾回收行为
-verboseLclass # 查看类型加载和卸载信息
-XX:+TraeClass-Loading
-XX:+TraceClassUnLoading
在大量通过反射、动态代理,通常都需要jvm有卸载类的能力,以保证不会堆方法区操作过大的内存压力