Jvm基础篇

1. Jvm体系结构

首先,我们来了解jvm的位置,jvm是运行在操作系统上的,它与硬件没有直接交互。
jvm - 图1

接下来是jvm的整个体系结构图:
jvm - 图2
jvm - 图3
之后 我们根据jvm体系结构图来阐述相关知识

2. 类装载器ClassLoader

类装载器 ClassLoader 是负责加载class文件的,将class文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构。ClassLoader只负责文件的加载,至于它是否可运行,则由Execution Engine决定。
在这里需要区分一下classClass。小写的class,是指编译 Java 代码后所生成的以.class为后缀名的字节码文件。而大写的Class,是 JDK 提供的java.lang.Class,可以理解为封装类的模板。多用于反射场景,例如 JDBC 中的加载驱动,Class.forName("com.mysql.jdbc.Driver");
接下来我们来观察下图,Car.class字节码文件被ClassLoader类装载器加载并初始化,在方法区中生成了一个Car Class的类模板,而我们平时所用到的实例化,就是在这个类模板的基础上,形成了一个个实例,即car1car2。反过来讲,我们可以对某个具体的实例进行getClass()操作,就可以得到该实例的类模板,即Car Class。再接着,我们对这个类模板进行getClassLoader()操作,就可以得到这个类模板是由哪个类装载器进行加载的。
jvm - 图4
扩展一下,JVM并不仅仅只是通过检查文件后缀名是否是.class来判断是否加载,最主要的是通过class文件中特定的文件标示,即下图test.class文件中的cafe babe

2.1. 有哪些类装载器

一、虚拟机自带的加载器:
1. 启动类加载器(Bootstrap),也叫根加载器,加载%JAVAHOME%/jre/lib/rt.jar
2. 扩展类加载器(Extension),加载%JAVAHOME%/jre/lib/ext/*.jar,例如javax.swing包。
3. 应用程序类加载器(AppClassLoader),也叫系统类加载器,加载%CLASSPATH%的所有类

二、用户自定义的加载器:
用户可以自定义类的加载方式,但必须是Java.lang.ClassLoader的子类
jvm - 图5

2.2. 双亲委派和沙箱保护

接下来,我们通过下面代码来观察这几个类加载器。首先,我们先看自定义的MyObject,首先通过getClassLoader()获取到的是AppClassLoader,然后getParent()得到ExtClassLoader,再getParent()竟然是null?可能大家会有疑惑,不应该是Bootstrap加载器么?这是因为,**BootstrapClassLoader**是使用C++语言编写的,Java在加载的时候就成了null。
我们再来看Java自带的Object,通过getClassLoader()获取到的加载器直接就是BootstrapClassLoader,如果要想getParent()的话,因为是null值,所以就会报java.lang.NullPointerException空指针异常。
jvm - 图6
Tip:输出中,sun.misc.Launcher是JVM相关调用的入口程序。
那为什么会出现这个情况呢?这就需要我们来了解类加载器的加载顺序和机制了,即双亲委派沙箱安全
(1)双亲委派,当一个类收到了类加载请求,它首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,因此所有的加载请求都应该传送到启动类加载器中,只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的Class),子类加载器才会尝试自己去加载。
采用双亲委派的一个好处是,比如加载位于rt.jar包中的类java.lang.Object,不管是哪个加载器加载这个类,最终都是委派给顶层的启动类加载器进行加载,确保哪怕使用了不同的类加载器,最终得到的都是同样一个Object对象。
(2)沙箱安全机制,是基于双亲委派机制上采取的一种JVM的自我保护机制,假设你要写一个java.lang.String的类,由于双亲委派机制的原理,此请求会先交给BootStrapClassLoader试图进行加载,但是BootStrapClassLoader在加载类时首先通过包和类名查找rt.jar中有没有该类,有则优先加载rt.jar包中的类,因此就保证了java的运行机制不会被破坏,确保你的代码不会污染到Java的源码
所以,类加载器的加载顺序如下:
1. 当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
2. 当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
3. 如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载。
4. 若ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException

Tip:rt.jar是什么?做了哪些事?这些暂且不提,那你有没有想过,为什么可以在idea这些开发工具中可以直接去使用String、ArrayList、甚至一些JDK提供的类和方法?是因为这些都在rt.jar中定义好了,且直接被启动类加载器进行加载了。

3. 本地方法栈

本地方法接口(Native Interface),其作用是融合不同的编程语言为 Java 所用,它的初衷是用来融合 C/C++ 程序的,Java 诞生的时候是 C/C++ 流行时期,要想立足,就得调用 C/C++ 程序,于是 Java
就在内存中专门开辟了一块区域处理标记为 native 的代码。
本地方法栈(Native Method Stack),就是在一个 Stack 中登记这些 native 方法,然后在执行引擎Execution Engine执行时加载本地方法库native libraies
接下来,我们通过下图的多线程部分源码来理解什么是native方法。首先我们观察start()的源码,发现它其实并没有做什么复杂的操作,只是单纯的调用了start0()这个方法,然后我们去观察start0()的源码,发现它只是一个使用了native关键字修饰的一个方法(private native void start0();),但只有声明却没有具体的实现!
jvm - 图7

为什么?我们都知道ThreadClass关键字修饰的类(class Thread implements Runnable),而不是接口。一般来说,类中的方法都要有定义和实现,接口里面才有方法的定义声明。这就是native方法的独特之处,说白了,被native关键字修饰的方法,基本上和我们,甚至和 Java 都没啥关系了,因为它要去调用底层操作系统或者第三方语言的库函数,所以我们不需要去考虑它具体是如何实现的。

4. 程序计数器Program Counter Register

程序计数器(Program Counter Register),也叫PC寄存器。每个线程启动的时候,都会创建一个PC寄存器。PC寄存器里保存当前正在执行的JVM指令的地址。 每一个线程都有它自己的PC寄存器,也是该线程启动时创建的。
简单来说,PC寄存器就是保存下一条将要执行的指令地址的寄存器,其内容总是指向下一条将被执行指令的地址,这里的地址可以是一个本地指针,也可以是在方法区中相对应于该方法起始指令的偏移量。
每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,也即将要执行的指令代码),由执行引擎Execution Engine读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记。
这块内存区域很小,它是当前线程所执行的字节码的行号指示器,字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令。
PC寄存器一般用以完成分支、循环、跳转、异常处理、线程恢复等基础功能。不会发生内存溢出(OutOfMemory,OOM)错误。
如果执行的是一个native方法,那这个计数器是空的。

5. 方法区 Method Area

方法区(Method Area),是供各线程共享的运行时内存区域,它存储了每一个类的结构信息。例如运行时常量池(Runtime Constant Pool)、字段和方法数据、构造函数和普通方法的字节码内容。
上面说的是规范(定义的一种抽象概念),实际在不同虚拟机里实现是不一样的,最典型的就是永久代(PermGen space)和元空间(Meta space)。
实例变量存在堆内存中,和方法区无关。

6. 栈 Stack

栈管运行,堆管存储! 请熟读并默写全文。 ( ´•ω•)ノ(´ノД;`)
栈(Stack),也叫栈内存,主管Java程序的运行,在线程创建时创建。其生命期是跟随线程的生命期,是线程私有的,线程结束栈内存也就是释放。
对于栈来说,不存在垃圾回收的问题,只要线程一结束该栈就Over。

6.1. 栈存储什么数据?

栈主要存储8种基本类型的变量、对象的引用变量、以及实例方法。
这里引出一个名词,栈帧,什么是栈帧?
每个方法执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息,每个方法从调用直至执行完毕的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程。


jvm - 图8
简单来说,栈帧对应一个方法的执行和结束,是方法执行过程的内存模型。
其中,栈帧主要保持了3类数据:
1. 本地变量(Local Variables):输入参数和输出参数,以及方法内的变量。
2. 栈操作(Operand Stack):记录出栈、入栈的操作。
3. 栈帧数据(Frame Data):包括类文件、方法等。
栈的大小是根据JVM有关,一般在256K~756K之间,约等于1Mb左右。

6.2. 栈运行的原理

  1. 观察下图,在java中,`test()``main()`都是方法,而在栈中,称为栈帧。在栈中,`main()`都是第一个入栈的。<br />栈的顺序为:`main()`入栈 --> `test()`入栈 --> `test()`出栈 --> `main()`出栈。<br />![](https://cdn.nlark.com/yuque/0/2020/png/2527002/1601106660379-6bc7ef73-24c6-4bde-8117-fba8deceb734.png#height=262&width=415)
  2. 根据代码和运行结果可以知道,`main()`想要出栈,则必须`test()`先出栈。那么怎么证明呢?观察下面代码,我们在`test()`方法中添加了一条语句`Thread.sleep(Integer.MAX_VALUE);`,来让`test()`无法进行出栈操作,进而导致`main()`也无法出栈。运行代码发现,运行结果如我们所料,程序一直停留在`test()`入栈,无法进行其他操作<br />![](https://cdn.nlark.com/yuque/0/2020/png/2527002/1601106660506-b331a7f8-539a-462d-b5b9-0c865a47b412.png#height=212&width=297)
  3. 我们接着观察下图,在图中一个栈中有两个栈帧,分别是`Stack Frame1``Stack Frame2`,对应方法1和方法2。其中`Stack Frame2`是最先被调用的方法2,所以它先入栈。然后方法2又调用了方法1,所以`Stack Frame1`处于栈顶位置。执行完毕后,依次弹出`Stack Frame1``Stack Frame2`,然后线程结束,栈释放。<br />所以,每执行一个方法都会产生一个栈帧,并保存到栈的顶部,顶部的栈帧就是当前所执行的方法,该方法执行完毕后会自动出栈。<br />![](https://cdn.nlark.com/yuque/0/2020/png/2527002/1601106660648-d5d05971-0bf2-440d-970a-30e32878881b.png#height=296&width=270)<br />总结如下:栈中的数据都是以栈帧(Stack Frame)的格式存在,栈帧是一个内存区块,是一个数据集,是一个有关方法(Method)和运行期数据的数据集,当一个方法A被调用时就产生了一个栈帧F1,并被压入到栈中,方法A中又调用了方法B,于是产生栈帧F2也被压入栈中,方法B又调用方法C,于是产生栈帧F3也被压入栈中······执行完毕后,**遵循“先进后出,后进先出”的原则**,先弹出F3栈帧,再弹出F2栈帧,再弹出F1栈帧。

6.3. 栈溢出 StackOverflowError

大家肯定对栈溢出耳熟,那栈溢出是怎么产生的呢?
请看下面代码,test()方法里面又调用了test()方法,即自己调用自己,也叫递归。
同时,栈是一个内存块,它是有大小长度的,而我们观察代码发现,只要代码一运行,test()方法就会一直进行入栈操作,而没有出栈操作,结果肯定会超出栈的大小,进而造成栈溢出错误,即java.lang.StackOverflowError
jvm - 图9
java.lang.StackOverflowError是错误,不是异常!证明如下 :
jvm - 图10

7. 栈、堆和方法区的关系

栈、堆、方法区三者的关系如下图,其中reference是引用类型。
jvm - 图11

举个栗子,比如MyObject myObject = new MyObject();,等号左边MyObject myObjectmyObject就是引用,在Java栈里面。等号右边的new MyObject()new出来的MyObject实例对象在堆里面。简单来说,就是Java栈中的引用myObject指向了堆中的MyObject实例对象。
jvm - 图12

8. 堆

8.1. 堆的体系结构

一个JVM实例只存在一个堆内存,堆内存的大小是可以调节的。类加载器读取了类文件之后,需要把类、方法、常量变量放到堆内存中,保持所以引用类型的真实信息,方便执行器执行。
其中,堆内存分为3个部分:
1. Young Generation Space,新生区、新生代
2. Tenure Generation Space,老年区、老年代
3. Permanent Space,永久区、元空间
Java7之前,堆结构图如下,而Java8则只将永久区变成了元空间。
jvm - 图13
总结一下,堆内存在逻辑上分为新生+养老+元空间,而堆内存在物理上分为新生+养老。

8.2. 对象在堆中的生命周期

那么如何直观的了解对象在堆中的生命周期呢?
(1)首先,新生区是类的诞生、成长、消亡的区域。一个类在这里被创建并使用,最后被垃圾回收器收集,结束生命。
(2)其次,所有的类都是在Eden Spacenew出来的。而当Eden Space的空间用完时,程序又需要创建对象,JVM的垃圾回收器则会将Eden Space中不再被其他对象所引用的对象进行销毁,也就是垃圾回收(Minor GC)。此时的GC可以认为是轻量级GC
(3)然后将Eden Space中剩余的未被回收的对象,移动到Survivor 0 Space,以此往复,直到Survivor 0 Space也满了的时候,再对Survivor 0 Space进行垃圾回收,剩余的未被回收的对象,则再移动到Survivor 1 SpaceSurvivor 1 Space也满了的话,再移动至Tenure Generation Space
(4)最后,如果Tenure Generation Space也满了的话,那么这个时候就会被垃圾回收(Major GC or Full GC)并将该区的内存清理。此时的GC可以认为是重量级GC。如果Tenure Generation Space被GC垃圾回收之后,依旧处于占满状态的话,就会产生我们场景的OOM异常,即OutOfMemoryError

8.3. Minor GC 的过程

Survivor 0 Space,幸存者0区,也叫from区;Survivor 1 Space,幸存者1区,也叫to区。
其中,from区和to区的区分不是固定的,是互相交换的,意思是说,在每次GC之后,两者会进行交换,谁空谁就是to区。
不明白?没关系,接着往下看。
jvm - 图14
(1)**Eden Space****from**复制到**to**,年龄+1。
首先,当Eden Space满时,会触发第一次GC,把还活着的对象拷贝到from区。而当Eden Space再次触发GC时,会扫描Eden Spacefrom,对这两个区进行垃圾回收,经过此次回收后依旧存活的对象,则直接复制到to区(如果对象的年龄已经达到老年的标准,则移动至老年代区),同时把这些对象的年龄+1。
(2)清空**Eden Space****from**
然后,清空Eden Spacefrom中的对象,此时的from是空的。
(3)**from****to**互换
最后,fromto进行互换,原from成为下一次GC时的to,原to成为下一次GC时的from。部分对象会在fromto中来回进行交换复制,如果交换15次(由JVM参数MaxTenuringThreshold决定,默认15),最终依旧存活的对象就会移动至老年代。
总结一句话,GC之后有交换,谁空谁是**to**
这样也是为了保证内存中没有碎片,所以Survivor 0 SpaceSurvivor 1 Space有一个要是空的。

8.4. HotSpot虚拟机的内存管理

jvm - 图15
不同对象的生命周期不同,其中98%的对象都是临时对象,即这些对象的生命周期大多只存在于Eden区。
实际而言,方法区(Method Area)和堆一样,是各个线程共享的内存区域,它用于存储虚拟机加载的:类信息+普通常量+静态常量+编译器编译后的代码等等。虽然JVM规范将方法区描述为堆的一个逻辑部分,但它却还有一个别名叫做**Non-Heap**(非堆内存),目的就是要和堆区分开。
对于HotSpot虚拟机而言,很多开发者习惯将方法区称为 “永久代(Permanent Gen)” 。但严格来说两者是不同的,或者说只是使用永久代来实现方法区而已,永久代是方法区(可以理解为一个接口interface)的一个实现,JDK1.7的版本中,已经将原本放在永久代的字符串常量池移走。(字符串常量池,JDK1.6在方法区,JDK1.7在堆,JDK1.8在元空间。)
jvm - 图16

8.5. 永久区

永久区是一个常驻内存区域,用于存放JDK自身所携带的ClassInterface的元数据(也就是上面文章提到的rt.jar等),也就是说它存储的是运行环境必须的类信息,被装载进此区域的数据是不会被垃圾回收器回收掉的,关闭JVM才会释放此区域所占用的内存。
jvm - 图17
jvm - 图18
在JDK1.8中,永久代已经被移除,被一个称为元空间的区域所取代。元空间的本质和永久代类似。
元空间与永久代之间最大的区别在于: 永久带使用的JVM的堆内存,但是java8以后的元空间并不在虚拟机中而是使用本机物理内存。
因此,默认情况下,元空间的大小仅受本地内存限制。
类的元数据放入native memory,字符串池和类的静态变量放入Java堆中,这样可以加载多少类的元数据就不再由MaxPermSize控制, 而由系统的实际可用空间来控制。

8.6. 堆参数调优

在进行堆参数调优前,我们可以通过下面的代码来获取虚拟机的相关内存信息。
jvm - 图19
运行结果:jvm - 图20
有人就有疑问了,这个3607.5MB243.5MB是怎么算出来的?看下图就明白了,虚拟机最大内存为物理内存的1/4,而初始分配的内存为物理内存的1/64。
jvm - 图21
IDEA中如何配置JVM内存参数?在【Run】->【Edit Configuration…】->【VM options】中,输入参数-Xms1024m -Xmx1024m -XX:+PrintGCDetails,然后保存退出。
jvm - 图22
运行结果:jvm - 图23
JVM的初始内存和最大内存一般怎么配?
答:初始内存和最大内存一定是一样大,理由是避免GC和应用程序争抢内存,进而导致内存忽高忽低产生停顿。

8.7. 堆溢出OutOfMemoryError

现在我们来演示一下OOM,首先把堆内存调成10M后,再一直new对象,导致Full GC也无法处理,直至撑爆堆内存,进而导致OOM堆溢出错误,程序及结果如下:
jvm - 图24
jvm - 图25
如果出现java.lang.OutOfMemoryError: Java heap space异常,说明Java虚拟机的堆内存不够,造成堆内存溢出。原因有两点:①Java虚拟机的堆内存设置太小,可以通过参数-Xms-Xmx来调整。②代码中创建了大量对象,并且长时间不能被GC回收(存在被引用)。

9. GC(Java Garbage Collection)

9.1. GC垃圾收集机制

对于GC垃圾收集机制,我们需要记住以下几点:
1. 次数上频繁收集Young区。
2. 次数上较少收集Old区。
3. 基本不动元空间。
jvm - 图26
jvm - 图27
JVM在进行GC时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代。
因此GC按照回收的区域又分了两种类型,一种是普通GC(minor GC),一种是全局GC(major GC or Full GC)
Minor GC和Full GC的区别:
(1)普通GC(minor GC):只针对新生代区域的GC,指发生在新生代的垃圾收集动作,因为大多数Java对象存活率都不高,所以Minor GC非常频繁,一般回收速度也比较快。
(2)全局GC(major GC or Full GC):指发生在老年代的垃圾收集动作,出现了Major GC,经常会伴随至少一次的Minor GC(但并不是绝对的)。Major GC的速度一般要比Minor GC慢上10倍以上

9.2. GC日志信息详解

通过上面OOM案例,是不是觉得那一大片的日志信息看不懂?懵逼?没事,通过下图你就知道如何阅读GC日志信息。
(1)YGC相关参数:jvm - 图28
(2)FGC相关参数:jvm - 图29

9.3. GC算法上天篇

9.3.1. 如何判断java中对象是否存活?

1) 引用计数法
引用计数算法是给每个对象设置一个计数器,当有地方引用这个对象的时候,计数器+1,当引用失效的时候,计数器-1,当计数器为0的时候,JVM就认为该对象不再被使用,是“垃圾”了。
引用计数实现简单,效率高;但是不能解决循环引用问问题(A对象引用B对象,B对象又引用A对象,但是A,B对象已不被任何其他对象引用),同时每次计数器的增加和减少都带来了很多额外的开销,所以在JDK1.1之后,这个算法已经不再使用了。
jvm - 图30
2) 根搜索方法
根搜索方法是通过一些GCRoots对象作为起点,从这些节点开始往下搜索,搜索通过的路径成为引用链(ReferenceChain),当一个对象没有被GCRoots的引用链连接的时候,说明这个对象是不可用的。
GCRoots对象包括:
1. 虚拟机栈(栈帧中的本地变量表)中的引用的对象。
2. 方法区域中的类静态属性引用的对象。
3. 方法区域中常量引用的对象。
4. 方法栈中JNI(Native方法)的引用的对象。
jvm - 图31

9.3.2. 复制算法(Copying):适用于新生代

虚拟机把新生代分为了三部分:1个Eden区和2个Survivor区(分别叫fromto),默认比例为8:1:1。
一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄 +1,当它的年龄增加到一定程度时(默认是 15 ,通过-XX:MaxTenuringThreshold来设定参数),就会被移动到年老代中。
因为新生代中的对象基本都是朝生夕死(被GC回收率90%以上),所以在新生代的垃圾回收算法使用的是复制算法
复制算法的基本思想就是将内存分为两块,每次只用其中一块(from),当这一块内存用完,就将还活着的对象复制到另外一块上面。
我们来举个栗子,在GC开始的时候,对象只会存在于Eden区和名为fromSurvivor区,Survivorto是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到to,而在from区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(默认15)的对象会被移动到老年代中,没有达到阈值的对象会被复制到to区域。经过这次GC后,Eden区和from区已经被清空。这个时候,fromto会交换他们的角色,也就是新的to就是上次GC前的from,新的from就是上次GC前的to。不管怎样,都会保证名为toSurvivor区域是空的。Minor GC会一直重复这样的过程,直到to区被填满,to区被填满之后,会将所有对象移动到老年代中。
jvm - 图32
-XX:MaxTenuringThreshold,设置对象在新生代中存活的次数。
因为Eden区对象一般存活率较低,一般的,使用两块10%的内存作为空闲和活动区间,而另外80%的内存,则是用来给新建对象分配内存的。一旦发生GC,将10%的from活动区间与另外80%中存活的Eden区对象转移到10%的to空闲区间,接下来,将之前90%的内存全部释放,以此类推。
优缺点
优点 :不会产生内存碎片,效率高。
缺点 :耗费内存空间。
如果对象的存活率很高,我们可以极端一点,假设是100%存活,那么我们需要将所有对象都复制一遍,并将所有引用地址重置一遍。复制这一工作所花费的时间,在对象存活率达到一定程度时,将会变的不可忽视。
所以从以上描述不难看出,复制算法要想使用,最起码对象的存活率要非常低才行,而且最重要的是,我们必须要克服50%内存的浪费。

9.3.3. 标记清楚(Mark-Sweep):适用于老年代

标记清除算法,主要分成标记和清除两个阶段,先标记出要回收的对象,然后统一回收这些对象,如下图:
jvm - 图33
简单来说,标记清除算法就是当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将要回收的对象标记一遍,最终统一回收这些对象,完成标记清理工作接下来便让应用程序恢复运行。
主要进行两项工作,第一项则是标记,第二项则是清除。
· 标记:从引用根节点开始标记遍历所有的GC Roots, 先标记出要回收的对象。
· 清除:遍历整个堆,把标记的对象清除。
jvm - 图34
优缺点
优点 :不需要额外的内存空间。
缺点 :需要暂停整个应用,会产生内存碎片;两次扫描,耗时严重。
简单来说,它的缺点就是效率比较低(递归与全堆对象遍历),而且在进行GC的时候,需要停止应用程序,这会导致用户体验非常差劲。
而且这种方式清理出来的空闲内存是不连续的,这点不难理解,我们的死亡对象都是随机分布在内存当中,现在把它们清除之后,内存的布局自然会零碎不连续。而为了应付这一点,JVM就不得不维持一个内存的空闲列表,这又是一种开销。并且在分配数组对象的时候,需要去内存寻找连续的内存空间,但此时的内存空间太过零碎分散,因此资源耗费加大。

9.3.4. 标记压缩(Mark-Compact):适用老年代

简单来说,就是先标记,后整理,如下图所示:
jvm - 图35
优缺点
优点 :没有内存碎片。
缺点 :需要移动对象的成本,效率也不高(不仅要标记所有存活对象,还要整理所有存活对象的引用地址)

9.3.5. 分代收集算法

当前商业虚拟机都是采用分代收集算法,它根据对象存活周期的不同将内存划分为几块,一般是把Java堆分为新生代和老年代,然后根据各个年代的特点采用最适当的垃圾收集算法。
在新生代中,每次垃圾收集都发现有大批对象死去,只有少量存活,就选用复制算法,而老年代因为对象存活率高,没有额外空间对它进行分配担保,就必须使用标记清除或者标记压缩算法来进行回收。
jvm - 图36

9.3.6. 总结

新生代(Young Gen)
年轻代特点是内存空间相对老年代较小,对象存活率低。
复制算法的效率只和当前存活对象大小有关,因而很适用于年轻代的回收。而复制算法的内存利用率不高的问题,可以通过虚拟机中的两个Survivor区设计得到缓解。
老年代(Tenure Gen)
老年代的特点是内存空间较大,对象存活率高。
这种情况,存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记清除或者是标记清除与标记整理的混合实现。
(1)标记阶段(Mark) 的开销与存活对象的数量成正比。这点上说来,对于老年代,标记清除或者标记整理有一些不符,但可以通过多核/线程利用,对并发、并行的形式提标记效率。
(2)清除阶段(Sweep) 的开销与所管理内存空间大小形正相关。但Sweep“就地处决”的特点,回收的过程没有对象的移动。使其相对其他有对象移动步骤的回收算法,仍然是效率最好的。但是需要解决内存碎片问题。
(3)整理阶段(Compact) 的开销与存活对象的数据成开比。如上一条所描述,对于大量对象的移动是很大开销的,做为老年代的第一选择并不合适。
基于上面的考虑,老年代一般是由标记清除或者是标记清除与标记整理的混合实现。以虚拟机中的CMS回收器为例,CMS是基于Mark-Sweep实现的,对于对象的回收效率很高。而对于碎片问题,CMS采用基于Mark-Compact算法的Serial Old回收器做为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial Old执行Full GC以达到对老年代内存的整理。
1)内存效率: 复制算法 > 标记清除算法 > 标记整理算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。
(2)内存整齐度: 复制算法 = 标记整理算法 > 标记清除算法。
(3)内存利用率: 标记整理算法 = 标记清除算法 > 复制算法。
可以看出,效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存,而为了尽量兼顾上面所提到的三个指标,标记整理算法相对来说更平滑一些,但效率上依然不尽如人意,它比复制算法多了一个标记的阶段,又比标记清除多了一个整理内存的过程。

9.4. GC算法落地篇

收集算法是内存回收的方法论(上天),垃圾收集器就是内存回收的实现(入地)。
当前jvm的垃圾收集器:
jvm - 图37
注:连线代表可以结合使用。

相关概念:

并行和并发

并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行。而垃圾收集程序运行在另一个CPU上。

吞吐量(Throughput)

吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即
吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)。
假设虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

Minor GC 和 Full GC


新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。具体原理见上一篇文章。
老年代GC(Major GC / Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。

9.4.1. 新生代收集器

Serial收集器

Serial(串行)收集器是最基本、发展历史最悠久的收集器,它是采用复制算法的新生代收集器,曾经(JDK 1.3.1之前)是虚拟机新生代收集的唯一选择。
它是一个单线程收集器,只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停其他所有的工作线程,直至Serial收集器收集结束为止(“Stop The World”)。
这项工作是由虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户正常工作的线程全部停掉,这对很多应用来说是难以接收的。下图展示了Serial 收集器(老年代采用Serial Old收集器)的运行过程:
jvm - 图38
为了消除或减少工作线程因内存回收而导致的停顿,HotSpot虚拟机开发团队在JDK 1.3之后的Java发展历程中研发出了各种其他的优秀收集器,这些将在稍后介绍。但是这些收集器的诞生并不意味着Serial收集器已经“老而无用”,实际上到现在为止,它依然是HotSpot虚拟机运行在Client模式下的默认的新生代收集器。
它也有着优于其他收集器的地方:简单而高效(与其他收集器的单线程相比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得更高的单线程收集效率。
在用户的桌面应用场景中,分配给虚拟机管理的内存一般不会很大,收集几十兆甚至一两百兆的新生代(仅仅是新生代使用的内存,桌面应用基本不会再大了),停顿时间完全可以控制在几十毫秒最多一百毫秒以内,只要不频繁发生,这点停顿时间可以接收。
所以,Serial收集器对于运行在Client模式下的虚拟机来说是一个很好的选择。

ParNew 收集器

ParNew收集器就是Serial收集器的多线程版本,它也是一个新生代收集器。除了使用多线程进行垃圾收集外,其余行为包括Serial收集器可用的所有控制参数、收集算法(复制算法)、Stop The World、对象分配规则、回收策略等与Serial收集器完全相同,两者共用了相当多的代码。
ParNew收集器的工作过程如下图(老年代采用Serial Old收集器):
jvm - 图39
ParNew收集器除了使用多线程收集外,其他与Serial收集器相比并无太多创新之处,但它却是许多运行在Server模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关的重要原因是,除了Serial收集器外,目前只有它能和CMS收集器(Concurrent Mark Sweep)配合工作,CMS收集器是JDK 1.5推出的一个具有划时代意义的收集器,具体内容将在稍后进行介绍。
ParNew 收集器在单CPU的环境中绝对不会有比Serial收集器有更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个CPU的环境中都不能百分之百地保证可以超越。
在多CPU环境下,随着CPU的数量增加,它对于GC时系统资源的有效利用是很有好处的。它默认开启的收集线程数与CPU的数量相同,在CPU非常多的情况下可使用-XX:ParallerGCThreads参数设置。

Parallel Scavenge 收集器

Parallel Scavenge收集器也是一个并行的多线程新生代收集器,它也使用复制算法。Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标是达到一个可控制的吞吐量(Throughput)。
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
Parallel Scavenge收集器除了会显而易见地提供可以精确控制吞吐量的参数,还提供了一个参数-XX:+UseAdaptiveSizePolicy,这是一个开关参数,打开参数后,就不需要手工指定新生代的大小(-Xmn)、Eden和Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了。
虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为GC自适应的调节策略(GC Ergonomics)。自适应调节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别。
另外值得注意的一点是,Parallel Scavenge收集器无法与CMS收集器配合使用,所以在JDK 1.6推出Parallel Old之前,如果新生代选择Parallel Scavenge收集器,老年代只有Serial Old收集器能与之配合使用。

9.4.2. 老年代收集器

Serial Old收集器

Serial Old 是 Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”(Mark-Compact)算法。
此收集器的主要意义也是在于给Client模式下的虚拟机使用。如果在Server模式下,它还有两大用途:
l 在JDK1.5 以及之前版本(Parallel Old诞生以前)中与Parallel Scavenge收集器搭配使用。
l 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。
它的工作流程与Serial收集器相同,这里再次给出Serial/Serial Old配合使用的工作流程图:
jvm - 图40

Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。前面已经提到过,这个收集器是在JDK 1.6中才开始提供的,在此之前,如果新生代选择了Parallel Scavenge收集器。
老年代除了Serial Old以外别无选择,所以在Parallel Old诞生以后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。
Parallel Old收集器的工作流程与Parallel Scavenge相同,这里给出Parallel Scavenge/Parallel Old收集器配合使用的流程图:
jvm - 图41

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,它非常符合那些集中在互联网站或者B/S系统的服务端上的Java应用,这些应用都非常重视服务的响应速度。从名字上(“Mark Sweep”)就可以看出它是基于“标记-清除”算法实现的。
CMS收集器工作的整个流程分为以下4个步骤:
l 初始标记(CMS initial mark):仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要“Stop The World”。
l 并发标记(CMS concurrent mark):进行GC Roots Tracing的过程,在整个过程中耗时最长。
l 重新标记(CMS remark):为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。此阶段也需要“Stop The World”。
l 并发清除(CMS concurrent sweep)
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作。
所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。通过下图可以比较清楚地看到CMS收集器的运作步骤中并发和需要停顿的时间:
jvm - 图42
优点
CMS是一款优秀的收集器,它的主要优点在名字上已经体现出来了:并发收集、低停顿,因此CMS收集器也被称为并发低停顿收集器(Concurrent Low Pause Collector)。
缺点
l 对CPU资源非常敏感 其实,面向并发设计的程序都对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。
l CMS默认启动的回收线程数是(CPU数量+3)/4,也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。但是当CPU不足4个时(比如2个),CMS对用户程序的影响就可能变得很大,如果本来CPU负载就比较大,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了50%,其实也让人无法接受。
l 无法处理浮动垃圾(Floating Garbage) 可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。
l 由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生。这一部分垃圾出现在标记过程之后,CMS无法再当次收集中处理掉它们,只好留待下一次GC时再清理掉。
l 这一部分垃圾就被称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。
l 标记-清除算法导致的空间碎片 CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。
l 空间碎片过多时,将会给大对象分配带来很大麻烦,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象。

9.4.3. G1收集器

G1(Garbage-First)收集器是当今收集器技术发展最前沿的成果之一,它是一款面向服务端应用的垃圾收集器,HotSpot开发团队赋予它的使命是(在比较长期的)未来可以替换掉JDK 1.5中发布的CMS收集器。与其他GC收集器相比,G1具备如下特点:
l 并行与并发 G1 能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短“Stop The World”停顿时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。

l 分代收集 与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同方式去处理新创建的对象和已存活一段时间、熬过多次GC的旧对象来获取更好的收集效果。

l 空间整合 G1从整体来看是基于“标记-整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的。这意味着G1运行期间不会产生内存空间碎片,收集后能提供规整的可用内存。此特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。

l 可预测的停顿 这是G1相对CMS的一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了降低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在GC上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。
横跨整个堆内存
在G1之前的其他收集器进行收集的范围都是整个新生代或者老生代,而G1不再是这样。
G1在使用时,Java堆的内存布局与其他收集器有很大区别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,而都是一部分Region(不需要连续)的集合。
建立可预测的时间模型
G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage-First名称的来由)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。
避免全堆扫描——Remembered Set
G1把Java堆分为多个Region,就是“化整为零”。但是Region不可能是孤立的,一个对象分配在某个Region中,可以与整个Java堆任意的对象发生引用关系。在做可达性分析确定对象是否存活的时候,需要扫描整个Java堆才能保证准确性,这显然是对GC效率的极大伤害。
为了避免全堆扫描的发生,虚拟机为G1中每个Region维护了一个与之对应的Remembered Set。虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作。
检查Reference引用的对象是否处于不同的Region之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set之中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。
如果不计算维护Remembered Set的操作,G1收集器的运作大致可划分为以下几个步骤:
l 初始标记(Initial Marking) 仅仅只是标记一下GC Roots 能直接关联到的对象,并且修改TAMS(Nest Top Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可以的Region中创建对象,此阶段需要停顿线程,但耗时很短。

l 并发标记(Concurrent Marking) 从GC Root 开始对堆中对象进行可达性分析,找到存活对象,此阶段耗时较长,但可与用户程序并发执行。

l 最终标记(Final Marking) 为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。

l 筛选回收(Live Data Counting and Evacuation) 首先对各个Region中的回收价值和成本进行排序,根据用户所期望的GC 停顿是时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
通过下图可以比较清楚地看到G1收集器的运作步骤中并发和需要停顿的阶段(Safepoint处):
jvm - 图43

9.4.4. 总结

jvm - 图44