1.jvm简介
java号称是一种与平台无关的语言,可以做的“一次编写,到处运行”,依靠的就是java虚拟机(java virtual machine,简记jvm)。jvm是java运行环境的一部分,它屏蔽了底层硬件平台的细节,任何平台只要装有针对于该平台的Java虚拟机,字节码文件(.class)就可以在该平台上运行。这就是“一次编译,多次运行”。
java虚拟机,是一个可以执行java字节码的虚拟机进程,所以Java虚拟机本质上就是一段程序。当我们编写的java源代码编译完成后,会生成对应的字节码文件,当执行这些字节码文件时,会先启动java虚拟机,然后由它来执行这些保存在字节码文件中的指令。
2.jvm运行时数据区
线程独占:每个线程独立拥有的空间,不能互相访问,随线程生命周期而创建和销毁。
线程共享:所有线程共享的空间,都能访问这块内存数据,随虚拟机或GC而创建和销毁。
2.1 线程共享的内存
2.1.1 方法区
方法区是用于存储加载的字节码文件中的类的相关信息的,如类名、访问修饰符、常量池、字节描述、方法描述等。它在虚拟机规范中只是一个逻辑分区,具体的实现根据不同虚拟机有些差异。这里以Oracle等的HotSpot虚拟机为例(后面内容若未明确说明,均以HotSpot虚拟机为例)。
Oracle的HotSpot虚拟机在Java8之前的版本的堆内存中有一个永久代,这个永久代实现了JVM规范定义的方法区功能,主要存储类的信息,常量,静态变量,即时编译器编译后的代码等。当然,由于是在堆内存中实现的,永久代受GC的管理,但是由于永久代有 -XX:MaxPermSize 的上限,所以如果加载过多的类(将类信息存放到永久代)或有大量的String.intern() 方法执行的操作(将字符串放入永久代的常量池中,运行时常量池也属于方法区的一部分),很容易造成OOM。所以在java8以后就把方法区的实现移到了堆外内存的元空间metaspace中,这样方法区就不受JVM的控制了(那肯定了,堆外内存本就不属于JVM管理嘛),也就不会进行GC,也因此提升了性能(发生GC时会STOP THE WORLD,对性能有一定影响),也就不存在由于永久代的大小限制而导致的OOM,也方便在元空间中统一管理。假如一台服务器总共有1G内存,给jvm分配100M,理论上metaspace可以分配1G - 100M = 900M。
注意:String.intern()是一个Native本地方法,它的作用是 - 如果字符串常量池中已经存在一个等于该String对象的字符串,则返回这个字符串对象的引用;否则,将此String对象包含的字符串添加到常量池中,并返回此String对象的引用。当然,我这里的理解也不严谨,因为不同的java版本的该方法的实现方式也是不一样的。比如在java1.6中,intern()方法会把首次遇见的String对象的实例复制到永久代中,返回该实例的引用;而在java1.7中,对于首次遇见的String对象的实例,会在常量池中记录该对象的引用,并返回该引用。而且,java1.6版本的运行时常量池在方法区中,java1.7时放到了堆内存中,java1.8以后放到了元数据空间中。
演示:
下面分别在java1.6, java1.7, java.8三个版本下分别运行下面同一段测试代码,要来验证常量池在三个版本中的位置
代码:
package com.yjx.study;
import java.util.ArrayList;
import java.util.List;public class Test {
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
int i=0;
while(true){
list.add(String.valueOf(i++).intern());
}
}
}
-1.java1.6
配置jvm参数:-XX:PermSize=2M -XX:MaxPermSize=2M
结果可以看到,永久代发生了OOM,证明java1.6版本时运行时常量池在永久代中。
-2.java1.7
设置jvm参数:-Xmx20m -Xms20m -XX:-UseGCOverheadLimit(-XX:-UseGCOverheadLimit是关闭GC占用时间过长时会报的异常,然后限制堆的大小。)
结果可以看到,运行测试代码后,java堆空间发生了OOM,证明java1.7版本时运行时常量池在堆内存中。
-3.java1.8
网上看好多资料说是java1.8的时候,常量池被挪到了元空间中,但是我测试的结果是堆内存发生了OOM。对这个问题,欢迎大家一起讨论学习。
2.1.2 堆内存
存放根据class类生成的对象的内存,即存放new的方式生成的对象实例。垃圾收集器主要就是管理的堆内存。
堆内存还可以细分为老年代和新生代,新生代又可以细分为Eden区,From Survivor区,To Survivor区。
2.2 线程独占的内存
2.2.1 虚拟机栈
执行java方法的内存模型,线程私有的,生命周期与线程相同,每个方法被执行的同时会创建一个对应的栈帧。栈帧中主要保存执行方法时的局部变量表、操作数栈、动态连接、方法返回地址和附加信息等。方法执行时入栈,方法执行完出栈,出栈相当于清空栈内的数据。栈内存默认最大是1M,超出则会抛出栈溢出错误 - StackOverFlowError。入栈出栈的实际很明确,所以这块内存区域不需要进行GC。
2.2.2 本地方法栈
也是线程独占的私有内存空间,功能和虚拟机栈类似。虚拟机栈是为虚拟机执行java方法而准备的,本地方法栈是为虚拟机执行Native本地方法而准备的。这块区域也不需要进行GC。
2.2.3 程序计数器
记录当前线程执行的字节码位置,存储的是字节码指令地址。如果是执行Native本地方法,则计数器值为空。每个线程都在这个内存区域中有一份私有的空间。占用的内存空间很小。Java虚拟机的多线程是通过时间片轮法来轮流切换线程并分配CPU的执行时间实现的,在任意时刻,一个CPU只会执行一个线程。,如果这个线程分配的时间片执行完了,该线程就会被挂起,CPU会切换到另一个线程执行,当下次轮到执行被挂起的线程时,唤醒线程,然后通过程序计数器中记录的代码位置继续向下执行。所以程序计数器的主要作用是记录线程运行时的状态,方便线程被唤醒时能从上一被挂起时的状态继续执行。而且,程序计数器是唯一一个在java虚拟机规范中没有规定任何OOM情况的区域,所以这块内存也不需要进行GC。
总结一下,java1.8以后,GC只管理堆内存。
3.类加载机制
3.1 类加载器
一个java类从加载到初始化需要进行如下几个步骤:
其中最关键的就是类的加载,而这项工作的就是由类加载器完成的。
类加载器负责装入类,搜索网络、jar、zip、文件夹、二进制数据、内存等位置的类资源。一个Java程序的运行,最少需要3个类加载器实例协同完成。
3.1.1 Bootstrap loader - 核心类库加载器
C/C++实现,无对应的Java类。它用于加载JRE_HOME/jre/lib目录,或用户配置的目录下的类,比如JDK核心类库rt.jar等。由于它没有对应的Java类,所以由它加载的类通过getClassLoader() 方法获取的结果为 null。
3.1.2 Extension Class Loader - 拓展类库加载器
用于加载JRE_HOME/jre/lib/ext目录,JDK拓展包,或者用户配置的目录。对应的Java类是sun.misc.Launcher.ExtClassLoader。
3.1.3 Application Class Loader - 用户应用程序类加载器
加载参数java.class.path指定的目录,用户应用程序的 classpath 或者java命令运行时参数 -cp...
可以看到,我们编辑的Test类,是通过sun.misc.Launcher.AppClassLoader加载的。它通过读取java.class.path配置,指定去哪些地方加载类资源。
3.2 双亲委派机制
为了避免对类的重复加载,当需要加载一个类时,首先不会自己去加载,而是把这个请求委派给父级的类加载器,每一层的类加载器都是如此,先会让自己的父级去加载,因此所有的类加载请求都会传给上层的启动类加载器。只有当父加载器反馈自己无法完成该加载要求(该加载器的搜索范围中没有找到对应的类)时,子加载器才会尝试自己去加载。加载请求被由下到上逐级委托,由上到下逐级查找,这就叫双亲委派机制。这里的是双亲是逻辑上的上下级关系,类加载器之间不存在父类子类的关系。
虚拟机规定,同一个类加载器加载的,类命一样,代表的是同一个类,即通过 ClassLoader-instance-id + PackageName + ClassName 标识一个加载的类。所以对于同一个Java类,使用同一个类加载器多次加载时,只有第一次有效。所以,我们可以通过每次加载类时新建一个类加载器来实现热加载,这样就可以多次加载同一个类。
import java.net.URL;
import java.net.URLClassLoader;/**
* 热加载,指定class 进行加载
*/
public class LoaderTest1 {
public static void main(String[] args) throws Exception {
URL classUrl = new URL("file:D:\\");
// 测试双亲委派机制
// 如果使用此加载器作为父加载器,则下面的热更新会失效,因为双亲委派机制,HelloService实际上是被这个类加载器加载的;
URLClassLoader parentLoader = new URLClassLoader(new URL[]{classUrl}); while (true) {
/**
* 这里每次都创建一个新的类加载器,它的父加载器为上面的parentLoader。
* 如果按照下面这样创建类加载器loader,因为指向的要加载的类的位置都是同一个classUrl,
* 用loader加载HelloService时,它就会先委派parentLoader去加载,然后parentLoader
* 又去委派它的上一级类加载器,如此一层一层往上找,而由于这个类的位置是我们指定的一个
* 特殊位置,parentLoader的父级类加载器是找不到的,,所以又会一层一层的往下查找,
* 最后又回到parentLoader这里,然后被parentLoader加载进来,由于这里parentLoader
* 从未变过,所以它在在第一次加载过HelloService类后,就不会再进行加载,所以,按照
* 下面这种写法是不会进行热加载的。
*/
//URLClassLoader loader = new URLClassLoader(new URL[]{classUrl},parentLoader);
/**
* 而如果按照下面这种写法,没有指定父类加载器,循环每次执行的时候,都是一个新的类加载器,
* 所以每次都会去加载HelloService,所以可以实现热加载。
*/
URLClassLoader loader = new URLClassLoader(new URL[]{classUrl});
Class clazz = loader.loadClass("HelloService");
System.out.println("HelloService所使用的类加载器:" + clazz.getClassLoader());
Object newInstance = clazz.newInstance();
Object value = clazz.getMethod("test").invoke(newInstance);
System.out.println("调用getValue获得的返回值为:" + value); // help gc
newInstance = null;
value = null;
System.gc();
loader.close(); Thread.sleep(3000L); // 每3秒执行一次
System.out.println();
}
}
}
3.3 类的卸载
类什么时候卸载需要满足两个条件:
-1. 该Class 所有的实例都已被GC;
-2. 加载该类的ClassLoader实例已经被GC。
我们可以通过以下代码进行演示。
package com.yjx.study;
import java.net.URL;
import java.net.URLClassLoader;public class Test {
public String doTest(){
return "do a good test";
}
public static void main(String[] args) throws Exception {
URL classUrl = new URL("file:G:\\");//告诉jvm 我们的类放在什么位置 //URLClassLoader parentLoader = new URLClassLoader(new URL[]{classUrl});
// 创建一个新的类加载器
while (true) {
URLClassLoader loader = new URLClassLoader(new URL[]{classUrl});
// 问题:静态块触发
Class clazz = loader.loadClass("Test");
System.out.println("HelloService所使用的类加载器:" + clazz.getClassLoader()); Object newInstance = clazz.newInstance();
Object value = clazz.getMethod("doTest").invoke(newInstance);
System.out.println("调用getValue获得的返回值为:" + value); Thread.sleep(3000L); // 1秒执行一次
System.out.println(); // help gc -verbose:class
newInstance = null;
loader = null;
System.gc();
}
}
}
控制台输出结果如下,我们可以看到Test类的load和unload过程。
4.Java程序的执行
上图是Oracle官网上关于虚拟机执行Java程序的过程的文档,地址:https://docs.oracle.com/javase/specs/jls/se8/html/jls-12.html#jls-12.1。
流程看起来非常的多、负载。我总结了一个Java程序从执行到退出大致经过这么几个步骤。
图中是以执行一个Test类为例说明的。
1.首先是JVM启动,加载Class类,然后进行校验、准备数据空间、解析等操作,然后执行初始化操作,这些操作都完成之后,jvm就会去执行Test类的main方法。
2.然后加载main方法中引用的类、接口,校验、解析,初始化,完成后,执行类实例创建等操作。
3.GC回收内存空间前执行类实例的finalize方法等操作。
4.最后执行完成后,卸载类,程序退出。
这里我也只是一些个人粗浅的理解,欢迎大家补充斧正。
5.GC - 垃圾回收机制
JVM的自动垃圾收集是指GC管理堆内存,识别正在使用哪些对象以及哪些对象未被删除以及未使用对象的,回收不再被使用或未引用的对象的内存的过程。前面分析运行时数据区的时候,已经知道GC主要发生在堆。那么GC如何判断堆中的对象实例或数据是否可以被回收呢?
5.1 引用计数法
早期的JVM中使用的就是这种方式。简单说,就是对象被引用一次,在它的对象头上加一次引用次数,若没有被引用,引用次数为0,则此对象可以被回收。这种方式简单,但是无法回收循环引用的对象
public class TestRC {
TestRC instance;
public TestRC(String name) {
}
public static void main(String[] args) {
// 第一步
A a = new TestRC("a");
B b = new TestRC("b");
// 第二步
a.instance = b;
b.instance = a;
// 第三步
a = null;
b = null;
}}
上面程序中,执行到第三步时,a、b都被置为null了,但是由于之前它们指向的对象互相指向了对方(引用计数都为1),所以无法回收。正是由于无法解决循环引用的问题,现代的JVM都不用引用计数来判断对象是否应该被回收了。
5.2 可达性算法
现代的JVM基本都采用的这种算法来判断对象是否可回收。简单来说,可达性算法的原理是以一系列叫做 GC Root 的对象为起点出发,引出它们指向的下一个节点,再以下个节点为起点,引出此节点指向的下一个节点。这样通过 GC Root 串联成的一条线就叫引用链,直到所有的节点都遍历完毕,如果相关对象不在任意一个以 GC Root 为起点的引用链中,则这些对象会被判断为“垃圾”, 会被 GC 回收。
所以,如果有两个对象之间存在循环引用,但是从 GC Root 出发没有可以到达它们任意一个的引用,则还是会被GC回收掉,这样就完美解决了循环引用的问题。
当一个对象可以被回收时,并不一定就会被回收。对象的finalize方法给了对象一次自救的机会,当对象不可达(可回收)时,GC时会先判断对象是否执行了finalize方法,如果未执行,则会先执行finalize方法,我们可以在此方法中将当前对象与 GC Root关联,这样执行finalize后,GC会再次判断对象是否可达,如果不可达,则会被回收,如果可达,则不回收。
注意:finalize方法只会被执行一次,如果第一次执行此方法将对象变成了可达确实不会回收,但如果对象再次被GC,则会忽略finalize方法,对象会被回收!
5.2.1 哪些对象可以作为 GC Root
可达性分析是从 GC Root开始的,所以哪些对象可以作为GC Root呢?
-1. 虚拟机栈(栈帧中的局部变量表)中正在引用的对象;
public class Test {
public static void main(String[] args) {
Test a = new Test();
a = null;
}
}
上面代码中,a对象是main方法中的变量,存储在虚拟机栈帧中的局部变量表中,当a=null时,由于此时a充当了GC Root,a与原来指向的实例 new Test() 的引用断了,所以对象就会被回收。
-2. 本地方法栈中JNI(即Native方法)正在引用的对象;
当JVM调用java方法时,JVM会创建一个栈帧并压入java栈,而当它调用的是Native本地方法时,JVM会保持java栈不变,不会在java栈帧中压入新的帧,虚拟机只是简单的动态连接并直接调用指定的本地方法。
如上图所示,当java调用以上本地方法时,对象jc会被本地方法栈压入栈中,这个jc对象就是本地方法栈中JNI的对象引用,因此只会在此本地方法执行完成后释放。
-3. 方法区静态属性引用的对象;
public class Test {
public static Test s;
public static void main(String[] args) {
Test a = new Test();
a.s = new Test();
a = null;
}
}
上面代码中,a对象存储在虚拟机栈帧的局部变量表中,当a=null时,由于此时a充当了GC Root,a与原来指向道德实例 new Test() 的引用断了,所以a原来指向的对象会被回收,但是由于我们给s变量赋值了,它指向了一个new Test()实例的引用,s在此时是类静态属性引用,充当了 GC Root的作用,它指向的对象依然存活。
-4. 方法区常量引用的对象;
public class Test {
public static final Test s = new Test();
public static void main(String[] args) {
Test a = new Test();
a = null;
}
}
类常量s指向的对象并不会因为a指向的Test类的实例对象被回收而回收。
5.2.2 引用类型和可达性级别
-1. 引用类型
强引用(StrongReference):最常见的普通对象引用,只要还有强引用指向一个对象,就不会回收。
软引用(SoftReference):JVM认为内存不足时,才会去视图回收软引用指向的对象。(缓存场景)
弱引用(WeakReference):虽然是引用,但随时可能被回收掉。
虚引用(PhantomReference):不能通过它访问对象。供对象被finalize之后,执行指定逻辑的机制(cleaner)
-2. 可达性级别
强可达(Strongly Reachable): 一个对象可以有一个或多个线程可以不通过各种引用访问到的情况。
软可达(Softly Reachable): 就是当我们只能通过软引用才能访问到对象的状态。
弱可达(Weakly Reachable): 只能通过弱引用访问时的状态。当弱引用被清除的时候,就符合销毁条件。
幻象可达(Phantom Reachable): 不存在其他引用,并且finalize过了,只有幻象引用指向这个对象。
不可达(unreachable): 意味着对象可以被清除了。
总结:可达性算法就是找到一些能够作为入口的对象,通过这些对象我们可以顺藤摸瓜找到所有正在被使用的对象,标记出来(这些对象一般都在常量池中、虚拟机栈中),剩下的没有被标记的对象就会被GC回收掉。
5.3 垃圾收集算法
5.3.1 标记 - 清除(Mark-Sweep)算法
首先标记出所有可回收的对象,然后进行清除。
操作简单,但是容易造成内存碎片话,不适合特别大的堆。
5.3.2 复制(Copying)算法
把堆等分成两块区域A和B,区域A负责分配对象,区域B不分配,对区域A进行GC时,使用标记法先把存活的对象标记出来,然后将区域A中存活的对象都复制到区域B。拷贝过程中将对象顺序依次紧邻排列,避免内存碎片化,最后把区域A中的对象全部清理掉释放空间,这样就解决了内存碎片话的问题。
不过,由于要预留一半的内存供复制使用,内存空间利用率较低,而且每次回收时要把存活的对象都移动到另一半内存里,效率也比较低。
5.3.3 标记 - 整理(Mark-Compact)算法
前两步跟标记 - 清除算法一样,但是为了避免内存碎片化,它增加了一个整理的过程。它是先标记出所有存活的对象,然后清理掉可回收的对象,最后将所有存活的对象都往内存一端移动,紧密排列,以确保移动后的对象占用连续的内存空间,这样就解决了内存碎片话的问题。
它的缺点是每一次垃圾清除都要频繁的移动存活的对象,效率十分低下。
5.3.4 分代收集算法
上图是专家们分析了大量内存使用量随程序运行时长变化而总结出的关系曲线图。纵轴代表已分配的字节,横轴代表程序运行时间。
由图可知,大部分的对象生命周期都很短,会很快被回收掉。所以,根据对象的存活周期,人们将内存划分为了几个区域,不同区域采用合适的垃圾收集算法,这就是分代收集算法。
分代收集算法根据对象存活周期的不同将堆分成了新生代和老年代,默认比例为 1:2。老年代又叫 Tenured区 ,新生代可细分为Eden区,from Survivor区(简记S0),to Survivor区(简记S1),三者的比例为 8:1:1。我们把新生代发生的GC成为 Young GC(也叫 Minor GC),老年代发生的GC成为 Old GC (也叫 Full GC)。
-1. 分代收集工作原理
1). 新生代
由于大部分对象会在很短的时间内被回收,新对象一般分配在Eden区,当Eden区将满时,会触发 Minor GC 。经过 Minor GC 后只有少量对象存活,它们会被移动到S0区或S1区,同时对象的年龄加1(对象的年龄即发生 Minor GC的次数),最后把Eden区对象剩余的对象全部清除,释放空间。
因为程序运行时,会在Eden区生成大量的对象,其中大部分对象的生命周期都很短暂,在Eden区进行 Minor GC时会把接近98%的对象都回收了,留下的存活对象很少,因此把它们移动到S0或S1绰绰有余。这也是为什么内存分区大小的比例是 Eden:S0:S1 = 8:1:1,Eden区远大于S0,S1的原因。
当触发下一次 Minor GC时,会把Eden区的存活对象和 S0(或S1)区中存活对象(S0区中的存活对象经过每一次 Minor GC时都可能被回收)一起移动到 S1区,同时对Eden区和S0区中存活对象的年龄加1,然后清空Eden区和S0区。
若再一次触发 Minor GC,则重复上一步操作,只不过此时变成将Eden、S1区中存活的对象复制到 S0区,每次GC进行垃圾回收时,S0,S1角色互换,都是从Eden,S0 (或S1)将存活对象移动到S1(或S0)。由此我们可以看出,新生代的垃圾回收采用的是复制算法,因为在Eden区中分配的对象大部分都会被GC掉,只剩下极少部分存活对象,S0、S1区也比较小,所以最大限度的降低了复制算法在频繁拷贝对象时带来的开销。
2). 老年代
-1>. 哪些对象会进入老年代呢?
- 当对象的年龄达到了我们设定的阈值,则会从S0(或S1)晋升到老年代。如下图所示:年龄阈值设置为15,当发生下一次 Minor GC时,S0中有个对象年龄达到了15,达到了我们设定的阈值,所以会被提拔到老年代。
- 大对象。当要创建的对象大小超过 -XX:PretenureSizeThreshold: 的设置时,这个大对象会直接接入老年代。因为大对象需要大量连续内存,如果把它分配在Eden区,Minor GC后再移动到S0或S1时,会有很大的开销(因为对象越大,复制速度越慢,而且会占用大量连续内存),也会很快占满S0,S1区,所以干脆直接分配到老年代。
- 还有一种情况会让对象晋升到老年代,即在 S0(或S1)区中相同年龄的对象大小之和大于 S0(或S1)空间一半以上时,则年龄大于等于该年龄的对象也会晋升到老年代。
-2>. 空间分配担保
在发生 Minor GC 之前,JVM会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果大于,那么 Minor GC 可以确保是安全的,如果不大于,那么JVM会查看 HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于则进行 Minor GC ,否则可能进行一次 Full GC。
-3>. STOP THE WORLD
如果老年代满了,会触发 Full GC,同时回收新生代和老年代,即对整个堆进行GC操作,它会导致Stop The World(STW),期间只由垃圾回收线程在工作,其他的工作线程会被全部挂起,因此会造成一定的性能开销。
Full GC会清理整个堆中的不可用对象,一般需要花较长的时间,期间会挂起所有的工作线程,如果在此时Server 收到了很多请求,则会被拒绝服务。所以需要尽量减少 Full GC的次数。当然,Minor GC 也会造成 STW,但只会触发轻微的STW,因为Eden区的对象大部分被回收了,只有极少数存活的独享会通过复制算法转移到 S0或S1区,所以相对还好。
-4>. Safe Point
由于Full GC会影响性能,所以需要在一个合适的时间点发起GC,这个时间点叫做 Safe Point,这个时间点的选定既不能太少以让GC时间太长,也不能导致过于频繁的GC操作,而且线程在这个时间点上的状态是可以确定的,如确定 GC Root 的信息等,以使JVM可以开始安全的GC。
Safe Point主要指的是以下特定的位置:
- 循环的末尾
- 方法返回前
- 调用方法的call之前
- 抛异常的位置
-5>. 老年代的垃圾回收算法
前面说了 Minor GC 用的是复制算法,而在老年代中由于对象比较多,占用的空间较大(新生代于老年代空间大小的比例是1:2),使用复制算法会有较大的开销,因为复制算法在对象存活率较高时要进行多次复制操作,同时还要浪费一半的空间。所以,根据老年代的特点,在老年代进行GC时一般采用的是标记-整理算法进行垃圾回收。
5.4 垃圾收集器
上面都是一些垃圾回收机制的算法原理等,在实际应用中需要根据其原理进行具体的实现垃圾收集器。
根据工作的内存区域的不同可分为以下几种:
- 新生代垃圾回收器 - Serial,ParNew,ParallelScavenge。
- 老年代垃圾回收器 - CMS,Serial Old,Parallel Old。
- 同时在新生代、老年代工作的垃圾回收器 - G1
下面图片中的垃圾收集器互相之间存在连线,表示可以互相配合使用。
目前,常用的垃圾收集器组合有CMS + ParNew、Parallel Scavenge + Parallel Old、G1。jvm中默认的垃圾收集器组合是: Parallel Scavenge + Parallel Old 。
5.4.1 新生代垃圾收集器
-1. Serial 收集器
单线程的串行垃圾收集器,只会使用一个CPU或线程来完成垃圾回收,而且GC工作期间会STW,挂起工作线程。Client模式下JVM在新生代的默认选项。
-2. ParNew 收集器
它是Serial 收集器的多线程版本,除了使用多线程,其他的比如收集算法、对象分配规则、回收策略等与Serial 收集器完全一样。可通过配置参数 -XX:ParallelGCThreads 控制线程数量。
-3. Parallel Scavenge 收集器
它是一个多线程的垃圾收集器,采用的是复制算法,功能与 ParNew收集器一样。但是它的目标是达到一个可控制的吞吐量,即吞吐量= 运行用户代码时间/(运行用户代码时间+GC时间),适合做后台运算等不需要太多用户交互的任务。可以通过控制最大垃圾收集时间 -XX:MaxGCPauseMillis 参数及直接设置吞吐量大小的 -XX:GCTimeRatio(默认99%) 来精确控制吞吐量。它还提供了一个参数 -XX:UseAdaptiveSizePolicy,开启这个参数后,就不需要手工指定新生代大小,Eden 与 Survivor 比例(SurvivorRatio)等细节,只需要设置好堆的大小(-Xmx 设置最大堆),以及最大垃圾收集时间与吞吐量大小,虚拟机就会根据当前系统运行情况收集监控信息,动态调整这些参数以尽可能地达到我们设定的最大垃圾收集时间或吞吐量大小这两个指标。自适应策略也是 Parallel Scavenge 与 ParNew 的重要区别。
5.4.2 老年代垃圾收集器
-1. Serial Old 收集器
Serial 收集器是工作在新生代的单线程收集器,与之相对应的,Serial Old 是工作于老年代的单线程收集器,它的主要意义是在于给Client模式下的JVM使用,如果在Server模式下,它还有两个用途:一种是在JDK1.5及以前的版本中与 Parallel Scavenge配合使用,另一种是作为 CMS 收集器的后备预案,在并发收集器发生 Concurrent Model Failure时使用,它与Serial 收集器配合使用的示意图如下:
-2. Parallel Old 收集器
它是相对于 Parallel Scavenge 收集器的老年代版本,也是多线程的,采用的标记清除算法。它们两个由于都是多线程收集器,组合使用可以真正实现吞吐量优先的目标。
-3. CMS 收集器
CMS收集器是以实现最短 STW 时间为目标的收集器,如果应用很重视服务的响应速度,希望给用户最好的交互体验,它是个很不错的选择。
前面说过老年代通常采用的标记整理算法,但是CMS它虽然工作于老年代,但却采用的是标记清除算法。
从上图可以看到,CMS收集器在工作时,因此执行初始标记、并发标记、重新标记和并发清理4个步骤,而且在初始标记和重新标记阶段会发生STW,挂起用户线程,不过初始标记仅标记 GC Root 能关联的对象,速度很快,并发标记是进行 GC Root Tracing 的过程,重新标记是为了修正并发标记期间因为用户线程继续运行而导致标记产生变动的那一部分对象的标记记录,这一阶段停顿时间一般比初始标记节点稍长,但远比并发标记时间短。整个过程中耗时最长的是并发标记和标记清除,不过这两节点是与用户线程并行执行的,不影响应用的正常使用,所以总体上可以认为 CMS 收集器的内存回收过程是与用户线程并发执行的。
但是CMS也有这样3个缺点:
- 对CPU资源非常敏感。CMS默认会启动(CPU数量+3)/4 个回收线程,而需要CPU分出部分资源去执行这些垃圾回收线程,则会导致吞吐量的下降。
- 无法处理浮动垃圾。CMS在进行垃圾收同时垃圾也会不断产生,而这部分新产生的浮动垃圾只能在下一次GC的时候回收。
- 由于采用的标记清除算法,会导致大量的内存碎片,当没有连续等的内存可以用于分配对象时,会导致Full GC,影响性能。当然我们可以开启 -XX:+UseCMSCompactAtFullCollection(默认是开启的),用于在 CMS 收集器顶不住要进行 Full GC 时开启内存碎片的合并整理过程,内存整理会导致 STW,停顿时间会变长。还可以用另一个参数 -XX:CMSFullGCsBeforeCompation 用来设置执行多少次不压缩的 Full GC 后跟着带来一次带压缩的。
-4. G1 收集器
G1 收集器是针对大的堆内存设计的,兼顾吞吐量和停顿时间,JDK9以后的默认选型,目标是替代 CMS 收集器。
G1 将堆分成固定大小的区域,区域之间是复制算法,但整体上可以看做是标记-整理算法,这两种算法都不会产生内存碎片,收集后可提供规整连续的可用内存,可以有效的避免内存碎片。它主要有这样几个特点:能与用户程序并发执行、容易预测需要GC的停顿时间、吞吐量高等。
上图中,红色代表新生代,浅蓝色代表老年代 。Region还多了一个H,它代表Humongous,这表示这些Region存储的是巨大对象(humongous object,H-obj),即大小大于等于region一半的对象,这样超大对象就直接分配到了老年代,防止了反复拷贝移动。那么 G1 分配成这样有啥好处呢?
传统的收集器进行 Full GC 时,会对整个堆进行全区域的垃圾收集,而在G1收集器中,堆被分成了固定大小的Region区域,这就方便了 G1 跟踪各个 Region 里垃圾堆积的价值大小(回收所获得的空间大小及回收所需经验值),这样根据价值大小维护一个优先列表,根据允许的收集时间,优先收集回收价值最大的 Region,也就避免了整个老年代的回收,也就减少了 STW 造成的停顿时间。同时由于只收集部分 Region,可就做到了 STW 时间的可控。
G1 号称是 “驾驭一切” 的垃圾收集器,但是在实际使用的时候我们还是需要根据吞吐量等要求适当的调整响应的 JVM 参数。