【JVM】详细分析JVM内存区域
- (一)准备好Math.java、Math.class和JVM结构图
- 【1】认清*.java、*.class和JVM之间的关系
- 【2】编写一个简单的Math.java文件
- 【3】对Math.java文件进行反编译,得到Math.class文件
- 【4】准备一个JVM运行时数据区域图
- (二)从Math.class开始分析流程
- 【1】Math.class进入JVM
- 【2】认识方法区
- 【3】认识栈内存
- 【4】认识程序计数器
- 【5】认识堆
- 【6】方法区,栈、堆之间的过程
- (三)详细分析堆内存里对象的一生
- 【1】堆分为新生代和老年代
- 【2】堆内存分配策略明确以下三点
- 【3】垃圾回收机制GC说明以下三点
- 【4】触发主GC的条件
- 【4】触发Full GC的原因
- 【5】减少GC开销的措施
- (四)运行时数据区域总结
- 【1】基本介绍
- 【2】程序计数器
- 【3】Java虚拟机栈(Java方法生成栈帧入栈执行)
- 【4】本地方法栈(Native方法生成栈帧入栈执行)
- 【5】Java堆(对象的一生)
- 【6】方法区(存放类信息、常量、静态变量、即时编译器编译后的代码)
- 【7】运行时常量池(存放编译期生成的各种字面量和符号引用)
- 【8】直接内存(堆外内存)
- (五)异常
- Java堆溢出
- 虚拟机栈和本地方法栈溢出
- 方法区和运行时常量池溢出
- 本机直接内存溢出
前面已经分析过JVM内存区域的各个功能,但是过于抽象和散乱,接下来就根据Math.java、Math.class、JVM结构图和Java VisualVM工具来完整详细的理解一个代码在JVM中是如何执行的,以及JVM是如何对内存区域进行垃圾收集的。
(一)准备好Math.java、Math.class和JVM结构图
【1】认清*.java、*.class和JVM之间的关系
首先,Math.java文件是我们用编译器编写的源代码文件,但是,这种文件人看得懂机器看不懂,所以Math.java文件要转换成字节码文件Math.class,也就是二进制文件;然后把字节码文件Math.class传进JVM让机器进行编译处理,最后把代码编译成机器码,实现对机器的控制。
如图所示,不同的文件对应不同的编译器,最终都会转换成字节码文件,然后才能传入JVM:
如下图所示:字节码文件翻译成机器码还有三个步骤
【2】编写一个简单的Math.java文件
public class Math {
public static final int initData = 666;
public static User user = new User();
public int compute(){
int a=1;
int b=2;
int c=(a+b)*10;
return c;
}
public static void main(String[] args) {
Math math=new Math();
math.compute();
System.out.println("test");
}
}
【3】对Math.java文件进行反编译,得到Math.class文件
(1)先执行main方法,然后找到target目录下已经编译好的Math.class文件,在控制器中打开
(2)此时虽然已经得到class文件,但是文件里都是字节码,不方便阅读,于是使用命令进行反编译,得到一个我们容易阅读的代码内容
(3)在控制器中得到我们想要的字节码文件内容
public class com.itheima.Math {
public static final int initData;
public static com.itheima.User user;
public com.itheima.Math();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public int compute();
Code:
0: iconst_1 //把int类的常量1压入操作数栈---1;
1: istore_1 //把int类的值存入局部变量1---int a=1;
2: iconst_2 //把int类的常量2压入操作数栈---2;
3: istore_2 //把int类的值存入局部变量2---int b=2;
4: iload_1 //
5: iload_2
6: iadd
7: bipush 10
9: imul
10: istore_3
11: iload_3
12: ireturn
public static void main(java.lang.String[]);
Code:
0: new #2 // class com/itheima/Math
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method compute:()I
12: pop
13: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
16: ldc #6 // String test
18: invokevirtual #7 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
21: return
static {};
Code:
0: new #8 // class com/itheima/User
3: dup
4: invokespecial #9 // Method com/itheima/User."<init>":()V
7: putstatic #10 // Field user:Lcom/itheima/User;
10: return
}
【4】准备一个JVM运行时数据区域图
完成上面的材料准备工作,就可以跟着代码来分析执行的流程了!!!
(二)从Math.class开始分析流程
【1】Math.class进入JVM
Math.class文件最开始放在磁盘中,然后经过【类装载子系统】装入【方法区】,字节码执行引擎读取方法区的字节码自适应解析,边解析边运行,然后PC寄存器指向了main函数所在的位置,虚拟机开始在栈中为main函数预留一个栈帧,然后开始运行main函数,main函数里的代码被字节码执行引擎映射成本地操作系统里相应的实现,然后调用本地方法接口,本地方法运行的时候,操纵系统会为本地方法分配本地方法栈,用来储存一些临时变量,然后运行本地方法,调用操作系统APIi等等。
上图表明:jvm 虚拟机位于操作系统的堆中,并且,程序员写好的类加载到虚拟机执行的过程是:当一个 classLoder 启动的时候,classLoader 的生存地点在 jvm 中的堆,然后它会去主机硬盘上将A.class装载到jvm的方法区,方法区中的这个字节文件会被虚拟机拿来new A字节码(),然后在堆内存生成了一个A字节码的对象,然后A字节码这个内存文件有两个引用一个指向A的class对象,一个指向加载自己的classLoader。
【2】认识方法区
类加载器把加载到的所有信息放入方法区,也就是说方法区里存放了类的信息,有类的static静态变量、final类型变量、field自动信息、方法信息,处理逻辑的指令集,我们仔细想想一个类里面也就这些东西。
而堆中存放是对象和数组,
1-这里的对应关系就是 “方法区–类” “堆–对象”,以“人”为例就是,堆里面放的是你这个“实实在在的人,有血有肉的”,而方法区中存放的是描述你的文字信息,如“你的名字,身高,体重,还有你的行为,如吃饭,走路等”。
2-再者我们从另一个角度理解,就是从前我们得知方法区中的类是唯一的,同步的。但是我们在代码中往往同一个类会new几次,也就是有多个实例,既然有多个实例,那么在堆中就会分配多个实例空间内存。
方法区的内容是边加载边执行,例如我们使用tomcat启动一个spring工程,通常启动过程中会加载数据库信息,配置文件中的拦截器信息,service的注解信息,一些验证信息等,其中的类信息就会率先加载到方法区。但如果我们想让程序启动的快一点就会设置懒加载,把一些验证去掉,如一些类信息的加载等真正使用的时候再去加载,这样说明了方法区的内容可以先加载进去,也可以在使用到的时候加载。
方法区是被字节码执行引擎直接执行的,所以静态信息都是在类加载的时候就创建了的,非静态的信息是在堆内存实例化对象的时候才创建,所以静态方法中不能调用非静态的方法和变量
【3】认识栈内存
(1)在之前学习Static关键字的时候了解到,Math math=new Math();,在这个代码执行时分为三块:
- Math math:负责在栈内存开辟内存空间,用来存放类对象的名称,其实就是对象的地址,此时对象还没有完成初始化,还不能使用
- new Math():负责在堆内存开辟内存空间,用来存放类对象,此时完成对象的初始化,对象可以使用了
- Math math=new Math():负责将栈内存里的地址指向堆内存里的对象,此时类对象的名字和类对象的实例合为一体,我们就可以通过类对象名称来调用对象中的信息了
(2)我们把栈放大,可以看到在栈里面,每一个方法都有一个自己的栈帧,main方法有一个栈帧,compute方法有一个栈帧,在代码中main方法还调用了compute方法,那么它们是怎么存放信息、处理信息并相互传递信息的呢?
因为先运行main方法,所以main方法先入栈,再调用compute方法,再入栈,因为栈的特点是先入后出,所以compute先出栈,接下分析compute的结构和信息处理过程。
方法栈帧的内存又可以分为4个部分:局部变量表、操作数栈、动态链接、方法出口。其中局部变量表用来存放变量的名称a、b、c等,操作数栈存放要进行赋值的操作数1、2。操作流程如下:
把操作数栈里的值赋值给局部变量表中的变量,还有其他一系列操作,操作结果就得到了 c=30,并且使用return语句把结果返回了,但是这个值是怎么传递给main方法的呢?那就是通过方法出口,把结果传递给栈中的下一个方法(方法栈帧是按照调用顺序入栈的,所以结果都是顺着往下传递),此时compute方法栈帧完成出栈。
再来分析main方法栈帧,它同样也包含局部变量表
从上面的分析可以看出,通过栈和命令使得方法完成了数据的赋值和处理,还有方法的调用和处理等操作,最后把方法的处理的结果通过方法出口传递给调用者。
【4】认识程序计数器
在Math.class文件中可以看到,每一行指令都有一个行数,如果线程在执行指令的时候突然被挂起了,等线程被唤醒后要接着上次被挂起的位置执行怎么办?
那就要给每一个方法线程配备一个程序计数器,用来标记线程执行的位置,每执行一行指令,字节码执行引擎都会命令程序计数器更新到最新的行数。
所以程序计数器的作用为:
- 读取指令:字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 标记位置:在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
【5】认识堆
在main方法中对Math进行实例化,栈内存中的math地址指向堆内存中的math对象,但是math对象对应的那些属性和方法不会也都放在堆内存中,这就要讲到下一个区域方法区(元空间)了
【6】方法区,栈、堆之间的过程
类加载器加载的类信息放到方法区---->执行程序后,方法区的方法压入栈的栈顶---->栈执行压入栈顶的方法---->遇到new对象的情况就在堆中开辟这个类的实例空间。(这里栈是有此对象在堆中的地址的)
(三)详细分析堆内存里对象的一生
【1】堆分为新生代和老年代
新生代分为:Eden区、From Survivor区、To Survivor区
(1)新生代
几乎所有新生成的对象首先都是放在年轻代的。新生代内存按照8:1:1的比例分为一个Eden区和两个Survivor(Survivor0,Survivor1)区。大部分对象在Eden区中生成。
当新对象生成,Eden Space申请失败(因为空间不足等),则会发起一次GC(Scavenge GC)。回收时先将Eden区存活对象复制到一个Survivor0区,然后清空Eden区,当这个Survivor0区也存放满了时,则将Eden区和Survivor0区存活对象复制到另一个Survivor1区,然后清空Eden和这个Survivor0区,此时Survivor0区是空的,然后将Survivor0区和Survivor1区交换,即保持Survivor1区为空, 如此往复。
当Survivor1区不足以存放 Eden和Survivor0的存活对象时,就将存活对象直接存放到老年代。当对象在Survivor区躲过一次GC的话,其对象年龄便会加1,默认情况下,如果对象年龄达到15岁,就会移动到老年代中。若是老年代也满了就会触发一次Full GC,也就是新生代、老年代都进行回收。
新生代大小可以由-Xmn来控制,也可以用-XX:SurvivorRatio来控制Eden和Survivor的比例。
(2)老年代
在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。一般来说,大对象会被直接分配到老年代。
所谓的大对象是指需要大量连续存储空间的对象,最常见的一种大对象就是大数组。比如:byte[] data = new byte[410241024]。这种一般会直接在老年代分配存储空间。
当然分配的规则并不是百分之百固定的,这要取决于当前使用的是哪种垃圾收集器组合和JVM的相关参数。
堆分为:新生代、老年代
(3)对象GC过程描述
所有新创建的对象都会放在堆内存的Eden区里,这个区最大,当Eden的空间用完了,程序又需要创建对象,就会使用minor GC算法对新生代进行垃圾收集,Eden区中死亡的对象被垃圾收集器处理掉,存活的对象进入From Survivor区;如果From Survivor区也满了,再进行minor GC算法,还活着的对象从From Survivor区进入To Survivor区,并且对象年龄+1,接下来每次有存活的对象就在From Survivor区、To Survivor区两个区里来回跑。
当对象的年龄达到15时,说明这个对象真的很难死亡,那么就可以把这个对象传入老年代,在老年代中如果内存也被放满了就会使用full GC进行垃圾收集
【2】堆内存分配策略明确以下三点
(1)对象优先在Eden分配。
(2)大对象直接进入老年代。
(3)长期存活的对象将进入老年代。
【3】垃圾回收机制GC说明以下三点
(1)新生代GC(Minor GC/Scavenge GC):发生在新生代的垃圾收集动作。因为Java对象大多都具有朝生夕灭的特性,因此Minor GC非常频繁(不一定等Eden区满了才触发),一般回收速度也比较快。在新生代中,每次垃圾收集时都会发现有大量对象死去,只有少量存活,因此可选用复制算法来完成收集。
(2)老年代GC(Major GC/Full GC):发生在老年代的垃圾回收动作。Major GC,经常会伴随至少一次Minor GC。由于老年代中的对象生命周期比较长,因此Major GC并不频繁,一般都是等待老年代满了后才进行Full GC,而且其速度一般会比Minor GC慢10倍以上。另外,如果分配了Direct Memory,在老年代中进行Full GC时,会顺便清理掉Direct Memory中的废弃对象。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记—清除算法或标记—整理算法来进行回收。
(3)新生代采用空闲指针的方式来控制GC触发,指针保持最后一个分配的对象在新生代区间的位置,当有新的对象要分配内存时,用于检查空间是否足够,不够就触发GC。当连续分配对象时,对象会逐渐从Eden到Survivor,最后到老年代。
【4】触发主GC的条件
是否进行主GC由JVM根据系统环境决定,而系统环境在不断的变化当中,所以主GC的运行具有不确定性,无法预计它何时必然出现,但可以确定的是对一个长期运行的应用来说,其主GC是反复进行的。
(1)当应用程序空闲时,即没有应用线程在运行时,GC会被调用。因为GC在优先级最低的线程中进行,所以当应用忙时,GC线程就不会被调用,但以下条件除外。
(2)Java堆内存不足时,GC会被调用。当应用线程在运行,并在运行过程中创建新对象,若这时内存空间不足,JVM就会强制地调用GC线程,以便回收内存用于新的分配。若GC一次之后仍不能满足内存分配的要求,JVM会再进行两次GC作进一步的尝试,若仍无法满足要求,则 JVM将报“out of memory”的错误,Java应用将停止。
(3)在编译过程中作为一种优化技术,Java 编译器能选择给实例赋 null 值,从而标记实例为可回收。
【4】触发Full GC的原因
(1)年老代(Tenured)被写满;
(2)持久代(Perm)被写满;
(3)System.gc()被显示调用;
(4)上一次GC之后Heap的各域分配策略动态变化.
【5】减少GC开销的措施
(1)不要显式调用System.gc()
此函数建议JVM进行主GC,虽然只是建议而非一定,但很多情况下它会触发主GC,从而增加主GC的频率,也即增加了间歇性停顿的次数。
(2)尽量减少临时对象的使用
临时对象在跳出函数调用后,会成为垃圾,少用临时变量就相当于减少了垃圾的产生,从而延长了出现上述第二个触发条件出现的时间,减少了主GC的机会。
(3)对象不用时最好显式置为Null
一般而言,为Null的对象都会被作为垃圾处理,所以将不用的对象显式地设为Null,有利于GC收集器判定垃圾,从而提高了GC的效率。
(4)尽量使用StringBuffer,而不用String来累加字符串
由于String是固定长的字符串对象,累加String对象时,并非在一个String对象中扩增,而是重新创建新的String对象,如Str5=Str1+Str2+Str3+Str4,这条语句执行过程中会产生多个垃圾对象,因为对次作“+”操作时都必须创建新的String对象,但这些过渡对象对系统来说是没有实际意义的,只会增加更多的垃圾。避免这种情况可以改用StringBuffer来累加字符串,因StringBuffer是可变长的,它在原有基础上进行扩增,不会产生中间对象。
(5)能用基本类型如Int,Long,就不用Integer,Long对象
基本类型变量占用的内存资源比相应对象占用的少得多,如果没有必要,最好使用基本变量。
(6)尽量少用静态对象变量
静态变量属于全局变量,不会被GC回收,它们会一直占用内存。
(7)分散对象创建或删除的时间
集中在短时间内大量创建新对象,特别是大对象,会导致突然需要大量内存,JVM在面临这种情况时,只能进行主GC,以回收内存或整合内存碎片,从而增加主GC的频率。集中删除对象,道理也是一样的。它使得突然出现了大量的垃圾对象,空闲空间必然减少,从而大大增加了下一次创建新对象时强制主GC的机会。
(四)运行时数据区域总结
当我们使用new关键字给对象开辟内存空间的时候,内存都会被占用一部分,Java把内存的管理交给JVM来操作,不再需要人为的为对象释放内存空间,不容易出现内存泄漏和内存溢出问题。但是,正是因为JVM掌控内存的管理,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务,所以我们要了解JVM是怎样使用内存的。
【1】基本介绍
如果没有特殊说明,都是针对的是 HotSpot 虚拟机。Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。不同的区域有不同的功能和不同的创建销毁时间,有的区域是随着虚拟机进程的启动而存在,有的区域是依赖用户线程的启动和结束而建立和销毁。JDK. 1.8 和之前的版本略有不同。
(1)首先认识运行时数据区域的各部分组成
Java 内存区域和内存模型是不一样的东西:
- 内存区域是指 Jvm 运行时将数据分区域存储,强调对内存空间的划分。
- 而内存模型(Java Memory Model,简称 JMM )是定义了线程和主内存之间的抽象关系,即 JMM 定义了 JVM 在计算机内存(RAM)中的工作方式,如果我们要想深入了解Java并发编程,就要先理解好Java内存模型。
JDK 1.8 之前:
JDK 1.8 :
(2)区分线程私有和线程共享
线程私有的:
- 程序计数器
- 虚拟机栈
- 本地方法栈
线程共享的:
- 堆
- 方法区
- 直接内存 (非运行时数据区的一部分)
(3)Java 8 中 PermGen 为什么被移出 HotSpot JVM?
1-由于 PermGen 内存经常会溢出,引发恼人的 java.lang.OutOfMemoryError: PermGen,因此 JVM 的开发者希望这一块内存可以更灵活地被管理,不要再经常出现这样的 OOM
2-移除 PermGen 可以促进 HotSpot JVM 与 JRockit VM 的融合,因为 JRockit 没有永久代。
根据上面的各种原因,PermGen 最终被移除,方法区移至 Metaspace,字符串常量移至 Java Heap。
【2】程序计数器
由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器内核都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
(1)选取下一条字节码
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。
(2)线程私有
另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
(3)作用有二
程序计数器主要有两个作用:
- 读取指令:字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 标记位置:在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
(4)注意有三
- 注意一:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
- 注意二:如果线程正在执行的是一个java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址
- 注意三:如果正在执行的是Native方法,这个计数器则为空Undefined
【3】Java虚拟机栈(Java方法生成栈帧入栈执行)
(1)线程私有,存放方法
线程私有,每个线程都有各自的Java虚拟机栈;虚拟机栈的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。虚拟机描述的是Java方法(method)执行的内存模型,每次方法调用,的时候都会创建一个栈帧,用来存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用到执行完成,对应一个栈帧在虚拟机栈中入栈到出栈。
(2)栈帧内容
Java 内存可以粗糙的区分为堆内存(Heap)和栈内存 (Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。 (实际上,Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。)
1-局部变量表主要存放了编译器的内容:方法参数,局部变量
- 可知的各种数据类型(boolean、byte、char、short、int、float、long、double)
- 对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
2-操作栈
操作栈是个初始状态为空的桶式结构栈。在方法执行过程中, 会有各种指令往栈中写入和提取信息。JVM 的执行引擎是基于栈的执行引擎, 其中的栈指的就是操作栈。字节码指令集的定义都是基于栈类型的,栈的深度在方法元信息的 stack 属性中。
i++ 和 ++i 的区别:
i++:从局部变量表取出 i 并压入操作栈,然后对局部变量表中的 i 自增 1,将操作栈栈顶值取出使用,最后,使用栈顶值更新局部变量表,如此线程从操作栈读到的是自增之前的值。
++i:先对局部变量表的 i 自增 1,然后取出并压入操作栈,再将操作栈栈顶值取出使用,最后,使用栈顶值更新局部变量表,线程从操作栈读到的是自增之后的值。
之前之所以说 i++ 不是原子操作,即使使用 volatile 修饰也不是线程安全,就是因为,可能 i 被从局部变量表(内存)取出,压入操作栈(寄存器),操作栈中自增,使用栈顶值更新局部变量表(寄存器更新写入内存),其中分为 3 步,volatile 保证可见性,保证每次从局部变量表读取的都是最新的值,但可能这 3 步可能被另一个线程的 3 步打断,产生数据互相覆盖问题,从而导致 i 的值比预期的小。
3-动态链接
每个栈帧中包含一个在常量池中对当前方法的引用, 目的是支持方法调用过程的动态连接。
4-方法返回地址
方法执行时有两种退出情况:正常退出,即正常执行到任何方法的返回字节码指令,如 RETURN、IRETURN、ARETURN 等;异常退出。
无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧,退出可能有三种方式:返回值压入上层调用栈帧/异常信息抛给能够处理的栈帧/PC计数器指向方法调用后的下一条指令。
(3)两种异常
Java 虚拟机栈会出现两种错误:StackOverFlowError 和 OutOfMemoryError。
- StackOverFlowError: 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
- OutOfMemoryError: 若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出 OutOfMemoryError 错误。
(4)一个扩展
扩展:那么方法/函数如何调用?
Java 栈可用类比数据结构中栈,Java 栈中保存的主要内容是栈帧,每一次函数调用都会有一个对应的栈帧被压入 Java 栈,每一个函数调用结束后,都会有一个栈帧被弹出。
(5)两种返回
Java 方法有两种返回方式:(不管哪种返回方式都会导致栈帧被弹出)
1-return 语句。
2-抛出异常。
(6)个人总结
方法调用时,创建栈帧,并压入虚拟机栈;方法执行完毕,栈帧出栈并被销毁,如下图所示:
【4】本地方法栈(Native方法生成栈帧入栈执行)
(1)Native 方法
和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
(2)栈帧内容
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
(3)两种异常
方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。
【5】Java堆(对象的一生)
(1)线程共享,块头最大,存放对象实例和数组
Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
(2)堆内存继续划分
Java 堆是垃圾收集器管理的主要区域,因此也被称作GC 堆(Garbage Collected Heap).从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代:再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。
Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,当前主流的虚拟机都是按照可扩展来实现的(通过 -Xmx 和 -Xms 控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。
(3)元空间(直接内存)取代永久代(方法区)
方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。垃圾收集行为在这个区域是比较少出现的,其内存回收目标主要是针对常量池的回收和对类型的卸载。当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。
版本区别:
- 在 JDK 7 版本及JDK 7 版本之前,堆内存被通常被分为下面三部分:
- 新生代内存(Young Generation)
- 老生代(Old Generation)
- 永生代(Permanent Generation)
- JDK 8 版本之后方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。
上图所示的 Eden 区、两个 Survivor 区都属于新生代(为了区分,这两个 Survivor 区域按照顺序被命名为 from 和 to),中间一层属于老年代。
(4)为什么移除永久代(使用元空间取代永久代的实现)?
- 字符串存在永久代中,容易出现性能问题和内存溢出。
- 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
- 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
- 将 HotSpot 与 JRockit 合二为一。为融合HotSpot JVM与JRockit VM(新JVM技术)而做出的改变,因为JRockit没有永久代。
有了元空间就不再会出现永久代OOM问题了!
(5)对象年龄增长过程
大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
新生成的对象首先放到年轻代Eden区,当Eden空间满了,触发Minor GC,存活下来的对象移动到Survivor0区,Survivor0区满后触发执行Minor GC,Survivor0区存活对象移动到Suvivor1区,这样保证了一段时间内总有一个survivor区为空。经过多次Minor GC仍然存活的对象移动到老年代。
老年代存储长期存活的对象,占满时会触发Major GC=Full GC,GC期间会停止所有线程等待GC完成,所以对响应要求高的应用尽量减少发生Major GC,避免响应超时。
- Minor GC : 清理年轻代
- Major GC : 清理老年代
- Full GC : 清理整个堆空间,包括年轻代和永久代
所有GC都会停止应用所有线程。
修正(issue552):“Hotspot遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了survivor区的一半时,取这个年龄和MaxTenuringThreshold中更小的一个值,作为新的晋升年龄阈值”。
动态年龄计算的代码如下:
uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
//survivor_capacity是survivor空间的大小
size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);
size_t total = 0;
uint age = 1;
while (age < table_size) {
total += sizes[age];//sizes数组是每个年龄段对象大小
if (total > desired_survivor_size) break;
age++;
}
uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
...
}
(6)异常
堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:
- OutOfMemoryError: GC Overhead Limit Exceeded : 当JVM花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
- java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发java.lang.OutOfMemoryError: Java heap space 错误。(和本机物理内存无关,和你配置的内存大小有关!)
- …
【6】方法区(存放类信息、常量、静态变量、即时编译器编译后的代码)
(1)存放类信息、常量、静态变量、即时编译器编译后的代码
方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
(2)方法区也被称为永久代。方法区和永久代的区分:
《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。 方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久代这一说法。
(3)常用参数
JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。
-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小
与永久代很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。
(4)为什么要将永久代 (PermGen) 换成元空间 (MetaSpace) 呢?
- 整个永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。
- 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
- 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。
【7】运行时常量池(存放编译期生成的各种字面量和符号引用)
(1)属于方法区
运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各种字面量和符号引用)
(2)异常
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。
【8】直接内存(堆外内存)
(1)不是运行时数据区
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。
(2)使用 Native 函数库直接分配堆外内存
JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。
本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。
(五)异常
Java堆溢出
虚拟机栈和本地方法栈溢出
方法区和运行时常量池溢出
本机直接内存溢出