在进行学习之前,我们首先来看看几道常见面试题。
- 请谈谈你对JVM的理解,Java8的虚拟机有怎样的更新?
- 什么是OOM,什么是StackOverflowError?
- JVM常用参数调优你知道哪些?
- GC的三种收集方法:标记清除、标记整理、复制算法的原理与特点,分别用在什么地方?
带着这些问题,我们可以开始接下来的学习。
一、JVM体系结构概述
JVM的位置:
JVM是运行在操作系统之上的,它与硬件没有直接交互。
JVM结构概览
可以看到,JVM逻辑上可以分为三个部分,作为入口的类加载器子系统、运行时数据区以及作为出口的执行引擎。
类加载器ClassLoader
由于Java类加载的过程比较复杂,这里不做过多的介绍,仅仅只对类加载器的功能和作用进行简单介绍。
ClassLoader类加载器负责加载class文件(class文件在文件的开头有特定的文件标示),将class文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构。
JVM有三种类加载器
- 根类加载器(Bootstrap Class Loader):用来加载Java核心类,是用原生代码实现的。
- 扩展类加载器(Extension Class Loader):负责加载JRE的扩展目录。
- 应用类加载器(App Class Loader):应用类加载器又称为系统类加载器,主要负责加载程序开发者自己编写的Java类。
public class ClassLoderDemo {
public static void main(String[] args) {
ClassLoderDemo demo = new ClassLoderDemo();
System.out.println(demo.getClass().getClassLoader().getParent().getParent());
System.out.println(demo.getClass().getClassLoader().getParent());
System.out.println(demo.getClass().getClassLoader());
}
}
结果:
可以很明确的看到三种类加载器的继承关系。
执行引擎Execution Engine
执行引擎是JVM的出口,负责解释命令,提交给操作系统执行。
接下来着重介绍运行时数据区域
运行时数据区域
jdk1.8之前:
运行时数据区域可以分为两个部分,线程共享区域和线程独占区域。
线程独占区域
-
程序计数器
程序计数器是一块较小的内存区域,用来存储指向下一条指令的地址,从而实现代码的流程控制。在多线程的情况下,程序计数器也用来记录当前线程执行的位置,从而当线程被切换回来时能知道该线程上次运行到哪了。 -
虚拟机栈
虚拟机栈就是我们常说的Java内存中的栈。实际上,Java虚拟机栈有一个个栈帧组成,每一次方法调用都会有一个对应的栈帧压入Java栈中。栈帧中拥有局部变量表、操作数栈、动态链接、方法出口等信息。
局部变量表中主要存放了8中基本的数据类型(Boolean、Byte、char、short、int、long、float、double)以及对象的引用。
Java虚拟机栈的生命周期与线程相同。若Java虚拟机栈的动态内存不允许动态扩展,那么当线程请求栈的深度超过当前Java虚拟机栈的最大深度时,就抛出StackOverFlowError异常
-
本地方法栈
本地方法栈与虚拟机栈的作用相似,但本地方法栈使用到的是native方法。
线程共享区域
- 方法区
方法区存储了每一个类的结构信息,包括常量、静态变量、构造函数、成员方法以及运行时常量池等数据。
方法区是Java虚拟机中定义的一种规范,在JDK1.8之前,永久代是它的一种实现方式。JDK1.8时,永久代被彻底移除了,取而代之的是元空间,元空间使用的是直接内存。 - 堆
堆是Java虚拟机中管理的最大的一块内存,是所有线程共享的,在虚拟机启动时创建。堆的作用即是存放对象实例,几乎所有的对象实例和数组都在这分配内存。
因此,Java堆是垃圾回收器管理的主要区域。
在介绍垃圾收集算法之前,需要先了解堆的体系结构。
二、堆的体系结构概述
堆内存可以分为新生区(年轻代)和养老区(老年代)。
新生区又可以细分为伊甸区(Eden)、幸存0区(Survivor 0)、幸存1区(Survivor 1)。幸存0区和幸存1区也叫Survivor From 和 Survivor to。
新生区是类的诞生、成长、消亡的区域。一个类在这里产生,应用,最后被垃圾回收器收集,结束生命。所有的类都是在伊甸区诞生的,当伊甸区用完时,JVM的垃圾回收器将对伊甸区进行垃圾回收(Minor GC),将不再被其他对象引用的对象进行销毁。然后将幸存的对象移入Survivor From区。
当伊甸区再次触发GC时,会扫描Eden区和Survivor From区,经过这次回收还存活的对象则直接复制到Survivor to区,同时把这些对象年龄加1。最后,Survivor From区和Survivor to区互换,原Survivor to区成为下一次Survivor from区。部分对象会在From和to区中反复移动,当它们年龄增加到一定程度后(默认15岁,可通过-XX:MaxTenuringThreshold来设定参数)即移动15次后,就会进入到老年代。
如此累计,当养老区满时,会触发FullGC。若执行FullGC后仍旧无法进行对象存储,就会产生OOM异常。
OOM
java.lang.OutOfMemoryError:Java heap space异常。表示堆内存溢出。
Java虚拟机内存不够的原因有二:
(1)Java虚拟机堆内存不够,可以通过参数-Xms(初始内存大小)、-Xmx(最大内存大小)来设置。
(2)代码创建了大量的大对象,并且长时间不能被垃圾回收区回收。
三、堆参数调优
-Xms:设置初始分配大小,默认为物理内存的“1/64”。
-Xmx:最大分配内存,默认为物理内存的“1/4”。
-XX:+PrintGCDetails:输出详细的GC处理日志。
结果为(本机物理内存为16G):
设置参数:-Xms1024m -Xmx1024m -XX:+PrintGCDetails
结果为:
年轻代与老年代空间加起来正好等于初始分配内存总量。
即可证明,元空间(Metaspace)物理上不属于堆,属于Java直接内存。
设置参数:-Xms8m -Xmx8m -XX:+PrintGCDetails
将内存空间缩小后构造死循环
结果为:
可以看到GC的过程,当最后老年区进行FullGC也无法存储对象后,产生OOM异常。所以一般情况下,出现异常后调节分配内存的大小或者优化代码即可。
四、GC
GC是什么
GC是Java虚拟机的垃圾回收机制,分代收集算法。GC的目的是回收堆内存中不在使用的对象,释放资源。
GC按照回收的区域又分了两种类型,一种是普通GC(minor GC),一种是全局GC(majorGC / Full GC)。
minor GC:只针对新生代区域的GC。
major GC / Full GC:针对老年代的GC,偶尔也会伴随对新生代的GC。
GC算法
- 引用计数法
每个对象拥有一个引用计数器,只要被引用一次,计数器加一,取消引用后减一,当引用计数器为零后可以被回收。
缺点:
每次对对象赋值时,均要维护引用计数器,计数器本身也有一定消耗。
较难处理循环引用。
JVM的实现一般不采用这种方式 - 复制算法
年轻代中使用的时Minor GC,这种GC算法采用的是复制算法。(将幸存的对象从Form区复制到To区)
优点:
- 没有标记和清除的过程,只需扫描一次,效率高。
- 没有内存碎片。
缺点:
- 需要双倍的内存空间。 - 标记清除(Mark-Sweep)
在老年代中使用。
优点:不需要额外空间。
缺点:
- 两次扫描,耗时严重。
- 会产生内存碎片。 - 标记压缩(Mark-Compact)
同标记清除一样,应用在老年代Full GC中。
优点:没有内存碎片
缺点:需要移动对象的成本 - 标记清除压缩(Mark-Sweep-Compact)
在进行多次标记清除后,产生内存碎片多了后,此时再进行一次标记压缩。
原理:Mark-Sweep与Mark-Compact的结合。当进行多次Mark-Sweep后才进行Mark-Compact。可以减少移动对象的成本。
总结
没有最好的算法,只有最合适的算法———分代收集。
年轻代的特点是区域相对于老年代较小,对象存活率低且GC的频率高。这种情况使用复制算法,速度是最快的。即使需要两倍的内存空间,由于年轻代中两个survivor区域的设计以及存活对象很少,所以影响不大。
老年代的特点是区域较大,对象存活率高且GC频率较低。
这种存在大量存活率高的对象的情况,明显不适合使用复制算法。一般使用标记清除与标记压缩的混合实现。
参考文献:
- JavaGuide面试指南:https://gitee.com/SnailClimb/JavaGuide
- 尚硅谷JVM:BV1iJ41197RC