JVM知识点整理
JVM
JVM整理
断断续续也看了不少关于JVM的东西,下面就来整理一下自己的收获
概要
* JVM的概念及作用
* JVM的内存模型
* JVM的垃圾回收
* JVM调优
JVM的概念及作用
一起来看下JVM到底是个什么鬼,有什么用。
JVM是java virtual merchine的简写,即java虚拟机。什么是虚拟机?虚拟机就是借助于操作系统对物理机器的一种模拟。这种模拟实现了输入满足某种指令集架构的指令序列,转换为目标ISA(指令集架构)的指令序列并加以执行,最后输出程序的执行结果。
基于寄存器的指令集与基于栈的指令集的区别
基于寄存器的指令集:
1、效率高,直接由cpu执行运算
2、平台依赖,可移植性差
基于栈的指令集:
1、效率相对较低,cpu与操作数栈进行交互,相比寄存器多了入栈和出栈的操作
2、可移植性好
3、实现相对简单
4、更适合存储器小的硬件,指令更紧凑
而无论是哪种指令集,所完成的任务都是:
* 取指令,指令来源于内存
* 译码,决定指令类型,从内存中读取操作数
* 执行
* 存储计算结果
很显然,JVM为了移植性和适用范围,选择了基于栈的指令集。
JVM的内存模型
JDK1.8之前和之后的JVM内存模型有些变化,先来看看JDK1.8之前的内存模型。JDK1.8之前,JVM内存模型主要是五部分,分别是:
1. 虚拟机栈
2. 本地方法栈
3. 程序计数器
4. 本地内存
5. 堆(方法区)
而JDK1.8之后,JVM内存模型变成了:
1. 虚拟机栈
2. 本地方法栈
3. 程序计数器
4. 本地内存(元空间)
5. 堆
其实整体的大布局并没有发生变化,变化的是从1.8以后在本地内存中增加了一块元空间,并将之前堆中的方法区移入了元空间中。具体为什么这么做,我们后面再具体分析。
下面来分别看看这五个区域:
- 虚拟机栈
我们前面也说了,JVM选用的基于栈的指令集,那么对应的就需要有栈空间来接受这些指令。再来看,指令对应的是操作,而java中的每行代码都是通过方法来执行的。而每个方法都对应一个执行线程,因此,虚拟机栈是线程私有的,生命周期与线程相同。
栈结构有一个深度,当线程请求的栈深度大于JVM允许的最大深度时,就会引发StackOverFlowError;如果虚拟机可以动态扩展,当扩展时无法申请到足够的内存,会引发OutOfMemeryError。
方法被执行时会创建栈桢,用于保存方法的局部变量表,操作数栈、动态连接和方法的返回地址等信息。方法执行时入栈,方法执行完出栈,出栈即可认为是清空了信息,因此,::这块区域不需要执行GC::。
局部变量表是一组变量值存储空间,用于存放方法的参数和方法内部定义的局部变量。局部变量表是以slot为单位的,32位操作系统中,每个slot最多存储32位数据,因此如果是一个64位的变量,需要两个slot来存储,64位操作系统虽然slot可以存储64位数据,但是long和double依然会用两个slot来存储。
LocalVariableTable:
Start Length Slot Name Signature
0 150 0 this Lcom/jqjr/dccj/service/ChTempPageService;
0 150 1 chtemppage Lcom/jqjr/dccj/model/ChTempPage;
4 146 2 doubleSlot J
54 96 4 example Ltk/mybatis/mapper/entity/Example;
61 89 5 criteria Ltk/mybatis/mapper/entity/Example$Criteria;
如上,方法中定义了一个long类型的变量,对应的slot是2,因为是64位,所以占用了两个slot,下面的example只能存储在slot4中。从上面的class文件内容看,感觉局部变量表是一个通过索引来快速定位地址的数据结构,根据astore_x和aload_x中的x来决定将哪个变量压入或弹出。
操作数栈,是一个后进先出的队列,随着方法和字节码指令的执行,会从局部变量表和对象实例的字段中复制常量或者变量写入到操作数栈,再将栈中元素出栈到局部变量表或返回给方法调 用者。
动态连接,java的编译过程不包含连接,原因在于java在常量池中存有被调用方法的符号引用,而不是方法实际运行时的入口地址,在类的加载解析过程中,会将这一类符号引用转化为直接引用。JVM提供了五种方法调用的指令:
invokestatic 调用静态方法
invokespecial 调用实例构造器方法、私有方法和父类方法
invokevirtual 调用虚方法,final修饰的方法
invokeinterface 调用接口方法,会在运行时再确定一个实现此接口的对象
invokedynamic 先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法
方法返回地址就不多说了,这个应该比较好理解。
- 本地方法栈
本地方法栈与虚拟机栈一样,也是线程私有的,相对于java虚拟机栈,本地方法栈即是为了native method的调用而准备的。::同样不需要执行GC::。这部分与虚拟机栈类似,就不单独说了。
-
程序计数器
程序计数器,也称为PC寄存器。它记录了下一条cpu将要执行的指令的地址,也是线程私有的。PC寄存器是JVM规范中唯一没有规定OOM的区域。所以,也::不需要执行GC::。 -
本地内存
本地内存在jdk1.8之前和之后有着比较大的差别,那我们就来分开说。首先,还是来认识一下本地内存这块区域。
本地内存,也叫直接内存,或者堆外内存,直接受操作系统管理。把对象分配在这个区域,可以减少GC对应用程序的影响,并且在复制的时候速度会比堆内的对象快。
如NIO中的DirectByteBuffer,可以直接分配堆外内存。我们可以关注一下这块区域的内存回收。既然不受JVM管理,那么怎么回收?什么时候回收呢?
我们先来看看DirectByteBuffer分配的内存是怎么回收的。看代码:
package directBuffer;
import sun.nio.ch.DirectBuffer;
import java.nio.ByteBuffer;
import java.util.concurrent.TimeUnit;
public class DirectBufferGC {
public static void main(String[] args) throws InterruptedException {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024*1024*1024);
TimeUnit.SECONDS.sleep(20);
((DirectBuffer) byteBuffer).cleaner().clean();
TimeUnit.SECONDS.sleep(10);
}
}
ByteBuffer有一个cleaner方法,可以返回一个cleaner对象,再调用这个cleaner对象的clean方法,就可以清除堆外内存了。
回头看一下这个Cleaner对象,先看看源码:
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package sun.misc;
import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;
import java.security.AccessController;
import java.security.PrivilegedAction;
public class Cleaner extends PhantomReference<Object> {
private static final ReferenceQueue<Object> dummyQueue = new ReferenceQueue();
private static Cleaner first = null;
private Cleaner next = null;
private Cleaner prev = null;
private final Runnable thunk;
private static synchronized Cleaner add(Cleaner var0) {
if (first != null) {
var0.next = first;
first.prev = var0;
}
first = var0;
return var0;
}
private static synchronized boolean remove(Cleaner var0) {
if (var0.next == var0) {
return false;
} else {
if (first == var0) {
if (var0.next != null) {
first = var0.next;
} else {
first = var0.prev;
}
}
if (var0.next != null) {
var0.next.prev = var0.prev;
}
if (var0.prev != null) {
var0.prev.next = var0.next;
}
var0.next = var0;
var0.prev = var0;
return true;
}
}
private Cleaner(Object var1, Runnable var2) {
super(var1, dummyQueue);
this.thunk = var2;
}
public static Cleaner create(Object var0, Runnable var1) {
return var1 == null ? null : add(new Cleaner(var0, var1));
}
public void clean() {
if (remove(this)) {
try {
this.thunk.run();
} catch (final Throwable var2) {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
if (System.err != null) {
(new Error("Cleaner terminated abnormally", var2)).printStackTrace();
}
System.exit(1);
return null;
}
});
}
}
}
}
从代码上可以看到,Cleaner是一个虚引用,回顾一下虚引用的特征,若只有虚引用关联着对象,则这个对象就会被回收。接着看,在调用clean方法的时候,会先调用remove,从双向队列中将这个引用删除,然后调用thunk.run()方法,这个thunk是一个 Runnable的实现类,在私有构造器和静态的create方法中作为参数传入。我们来看一下ByteBuffer.allocateDirect方法:
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
创建了一个DirectByteBuffer,来看看它的逻辑:
DirectByteBuffer(int cap) { // package-private
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);
long base = 0;
try {
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}
倒数第二行,调用了Cleaner.create方法,传入一个Deallocator:
private static class Deallocator
implements Runnable
{
private static Unsafe unsafe = Unsafe.getUnsafe();
private long address;
private long size;
private int capacity;
private Deallocator(long address, long size, int capacity) {
assert (address != 0);
this.address = address;
this.size = size;
this.capacity = capacity;
}
public void run() {
if (address == 0) {
// Paranoia
return;
}
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}
}
现在就比较清晰了,通过unsafe.freeMemory来释放内存。那么关于堆外内存的释放,就暂时先介绍到这里。继续我们前面的话题。JDK1.8之前,本地内存是一块单独的区域,而在JDK1.8之后,这块区域内增加了一个叫元空间(metaspace),我们来认识一下这个元空间。
::元空间取代了jdk1.8之前的方法区/永久代(Hotspot)的部分功能,方法区整个被移入了元空间,但是字符串常量则被存放在堆中。来想想为什么这么设计?jdk1.8之前,永久代的大小是固定死的,即通过-XX:MaxPermSize来设定,但是很难确切的说出来这个值到底应该设置成多少,调优困难。同时,由于jdk1.8之前的永久代是位于堆中,对于永久代的回收是通过Full GC来进行的,所以这块区域是否能够有效回收直接影响了JVM的性能,而且对于永久代的回收效率很低。永久代的垃圾回收主要回收两部分:废弃的常量和无用的类。废弃的常量的判断方法与堆中的对象类似,都是判断有没有引用。而判断无用的类,需要满足三个条件:::
::1. 该类所有的实例都已被回收,堆中不存在该类的任何实例::
::2. 加载该类的ClassLoader已经被回收::
::3. 该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法::
::而且,即使满足上述三个条件,也并不一定会被回收。那么,在移入本地内存后,由于本地内存不受JVM GC影响,所以可以省掉一部分GC时间。当元空间的空间不足时(达到-XX:MetaspaceSize设置的值),会首先触发一次Full GC,然后自动扩容,而当扩容达到-XX:MaxMetaspaceSize时,就会抛出java.lang.OutOfMemoryError:Metadata space异常。而因为元空间位于直接内存,因此它的最大容量与机器内存有关,理论上来讲,会减少Full GC的频率。::
说了这么多,那么元空间到底存储了啥呢?上面说是把方法区除字符串常量以外的内容移入元空间,那么就应该包括:类的元数据,类的层级关系、字段、名字,方法的编译信息及字节码,变量,常量池和符号解析。简单来说,但凡可以通过反射来获取的数据,都存在元空间内。
- 堆
关于堆,这块区域是GC的重点关注对象。对象的实例和数组都存放在堆中。jdk1.8之前,堆内存包括永久代,而当永久代容量不足时,会抛出java.lang.OutOfMemoryError:PermGen异常。而jdk1.8之后,由于将永久代从堆中移除了,所以我们一般都不会看到这个错误。但是当元空间的空间不足时,会抛出java.lang.OutOfMemoryError:Metadata space异常。
JVM GC
现在来看一下GC。我们上面说到,GC主要发生在堆中。我们先看一下堆中的内存分布,JVM将堆分为新生代和老年代两种,而新生代又分为一个Eden和两个Survivor(S0,S1)。针对新生代的GC我们称为minor GC,而针对老年代和永久代(jdk1.8之前)的GC称为major GC或者Full GC。在了解GC之前,我们先要知道JVM总是会尝试在Eden区分配空间用于创建对象,但是如果开启了TLAB(本地线程缓冲),则将按照线程优先在TLAB上分配。并且JVM对每个对象都定义了一个对象年龄计数器(Age)。
一起来看一下对象如何从新生代进入老年代:
- JVM尝试在Eden区分配空间,若Eden区没有足够的空间进行分配时,就会触发一次minorGC。
- Minor GC根据一定算法进行回收以后,对象在Eden区内仍然存活,并且能够被survivor区容纳,就会被移入Survivor区,并且将对象的Age做加1操作。
- 对象每经历一次Minor GC,age都会加1,当age达到设定的值(-XX:MaxTenuringThreshold)时,就会进入老年代。
- 若JVM判断对象需要大量连续的内存空间时,会直接将对象放入老年代。
5。 若在Survivor空间中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于等于该年龄的对象就直接进入老年代。
再来看一下Minor GC的详细过程:- 在对新生代进行第一次垃圾回收的时候,首先将Eden中GC ROOT不可达的对象释放,然后将存活的对象移到Survivor区(S0)
- 再次触发Minor GC的时候,会对Eden区和S0区的对象进行回收,然后将存活的对象移动到S1
- 第三次Minor GC会把Eden和S1的存活对象移动到S0
- 以此类推
而当对象的存活年龄Age达到了设定的阈值,就会进入老年代,对于老年代的回收,Major GC通常都会伴随至少一次的Minor GC(并非绝对,在parallel Scavenge收集器的手机策略里就有直接进行Major GC的策略)。Major GC的速度一般会比Minor GC慢10倍以上。
以上是简单的介绍了一下GC,那么什么样的对象需要被回收呢?当前比较常用的算法有两种:
- 引用计数算法
引用计数是垃圾收集器中的早期策略。堆中的每一个对象都有一个引用计数器,每当有一个地方引用它时,计数器的值就加1,而当引用失效时,计数器的值就减1。任何时刻,计数器的值为0的对象就是不可能再被使用的对象,即可以被回收。
这个算法的优点是实现简单,判定效率高。
缺点是无法解决对象循环引用的问题。 - 可达性分析算法
可达性分析是把对象作为一个一个的结点,以GC ROOT为开始结点,寻找对应的引用结点,找到以后,继续寻找引用结点的引用结点,以此类推,当所有的引用结点都被寻找完毕后,剩余的结点即被认为是没有被引用的结点,从而被判定为可回收对象。
可以作为GC ROOT的对象有以下几种:(摘自IBM Knowledge Center)
a. System class
被bootstrap loader或者system class loader加载的类。比如rt.jar的java.util.*包中的文件。
b. JNI local
在本地接口代码中的局部变量
c. JNI global
在本地接口代码中的全局变量
d. Thread block
在活动的线程块中的对象的引用
e. Thread
运行中的线程
f. Busy monitor
所有调起wait()或者notify()方法,或者被Synchronized修饰的方法中,如果方法是静态的,则root是方法所在的class,如果不是,则root就是对应的对象引用
g. JAVA local
JAVA中的局部变量,如入参,方法创建的仍然在Java虚拟机栈中的对象等
h. 本地方法栈
本地方法栈中的对象引用,如入参和出参
i. Finalizer
队列中等待Finalizer运行的对象引用
j. Unfinalized
含有Finalize方法,但是并没有被结束,并且没有进入finzlize队列的对象引用
k. Unreachable
若一个对象对任何GC Root都不可达,则该对象的引用会被内存分析器标记为GC Root,以便于在内存分析过程中被包含进去。不可达对象通常是垃圾回收算法优化的结果,比如,一个对象是垃圾回收的候选对象,但是这个对象太小,以至于对它进行回收的代价就显得非常大,所以这个对象可能不会被垃圾回收器回收,而是作为一个不可达对象继续存活。
l. JAVA stack frame
JAVA虚拟机栈中的栈帧,栈帧中存放着局部变量等。当我们在参数中设置将栈帧看做一个对象的时候,栈帧也可以作为GC Root。
m. Unknown
一些未知的root类型。
以上就是可以作为GC Root的引用,注意,GC Root一定是一组必须活跃的引用,而不是对象。GC的根本思路是,将一组活跃的引用作为根,通过引用关系遍历对象图,能被遍历到的即为存活,其余的则被判定为死亡。那么,GC就是要找出所有存活的对象,并把剩余的空间标记为无用;而不是找出所有死亡的对象。当垃圾收集器被调用时,它开始遍历堆中的所有元素,并将引用类型对象存储在一个特殊的队列中。当检查完所有的对象后,就开始从队列中删除存活的对象,以确定哪些需要被清理。
需要注意,即使在可达性分析中被确定为不可达对象,也不是一定会被回收的。要想真正使对象被回收,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并进行一次筛选,筛选的条件是此对象是否需要执行finalize()方法,如果对象没有覆盖finalize()方法或者已经被执行过,jvm将会标记为不需要执行。但是如果这个对象被标记为需要执行finalize()方法,那么这个对象将会被放置在一个叫F-Queue的队列之中,并在稍后由一个由jvm建立的低优先级的Finalizer线程去执行它,但并不承诺会等待它执行结束。而finalize()方法就是对象逃过回收的最后一次机会,因为GC会对F-Queue队列中的对象进行第二次标记,如果此时与引用链中的任意一个对象建立关联,则会在第二次标记时被移除待回收的集合。(参考《深入理解Java虚拟机-JVM高级特性与最佳实践》—-周志明,3.2.4节)
上面我们说到GC Root必须是一组活跃的引用,那么在JAVA中,引用都有哪些类型呢?JAVA中,引用分为强引用、软引用、弱引用和虚引用四种。分别来看一下:
* 强引用
强引用是默认的引用类型,比如:
StringBuilder builder = new StringBuilder();
此时,builder即为强引用,而强引用是不会被GC回收的。即使当内存不足时,jvm也不会回收强引用,那么就会抛出OOM。
我们之前说过,对象的创建一般都是在堆内存中分配空间,而堆又是GC的主要场所,那么当方法返回时,栈帧会被弹出并销毁,此时,对new出来的对象的引用也就断了,也就是强引用不存在了,jvm就可以选择回收掉这部分空间。
* 软引用
软引用的对象,或者叫软可达的对象,没有强引用指向它,可以被垃圾收集器清除。尽管不同的JVM实现会有区别,但是JVM文档指出,要保证JVM抛出OOM之前清除软引用。也就是软引用的对象至少会经历两次GC,第一次GC,不清理软引用,经过第一次GC,若还是内存不足,则会将软引用也列入回收范围进行第二次GC。JVM会选择最近创建或者最近使用的算法来进行清理软引用的对象。
在上次软引用被使用后,软引用会存活一段时间。默认值是每个可以用的MB大小的空间的生命周期为一秒。可以通过-XX:SoftRefLRUPolicyMSPerMB来调节。
-XX:SoftRefLRUPolicyMSPerMB=2500
通常,软引用可以用来实现内存敏感的缓存。
我们可以通过两种方式来创建软引用:
StringBuilder builder = new StringBuilder();
SoftReference<StringBuilder> reference = new SoftReference<StringBuilder>(builder);
ReferenceQueue<StringBuilder> refQueue = new RefereneceQueue<StringBuilder>();
SoftReference<StringBuilder> reference2 = new SoftReference<StringBuilder>(builder, refQueue);
第二种方法传入了一个ReferenceQueue,这个队列中会存储被GC的对象的引用,即reference2。如果我们将引用作为key存进map,那么当软引用被回收的时候,我们就可以通过refQueue来获取到reference2,以reference2作为key来获取到业务值,进行接下来的操作。
* 弱引用
首先,jvm总会回收弱引用指向的对象,即只要发生GC,就会清理弱引用。对于弱引用的这个特性,我们可以用在一些对信息要求不是很严格的场景。比如消息发布,若发布者持有订阅者的强引用,当订阅者出现问题时,由于发布者持有强引用,导致订阅者不能被回收,就可能会发生内存泄漏。此时,可以根据业务场景采用弱引用。当然,被大家所熟知的还是WeakHashMap,详细可以参照Java8中的WeakHashMap | 并发编程网 – ifeve.com
创建弱引用的方法与软引用类似,通过WeakReference的两个构造器来完成。不再赘述。
* 虚引用
虚引用也称为幽灵引用或幻影引用,是所有引用类型中最弱的一个。一个对象是否有虚引用,完全不会对其生命周期构成影响,也无法通过虚引用获取对象实例。如果一个对象与GC Root之间仅存在虚引用,则称这个对象为虚可达对象。
虚引用的get方法总是返回null,并且虚引用必须与引用队列一起使用。垃圾回收器在执行Referent的Finalize方法后,将虚引用添加到引用队列,此时对象实例仍然在内存中。那么虚引用的意义就在于跟踪垃圾回收的过程。当一个对象被垃圾收集器回收时,如果发现对象拥有虚引用,则会在回收对象内存之前,将虚引用加入引用队列,并且在虚引用出队列之前,不会彻底销毁该对象。所以我们可以通过判断队列中是否有虚引用来判断对象是否被回收。
关于虚引用的用途,我们在前面其实已经见过了,就是用于回收DirectByteBuffer的Cleaner。
虚引用里面的对象没有强引用的时候,开始回收,加入到列队去,不会设置referent为null;而软引用和弱引用会设置referent为null。
此时,我们应该就可以大致判断哪些是可以被回收的。
我们了解了如何判断对象是否是可回收的,那么接下来一起看一下垃圾收集的算法。
-
标记 - 清除算法
标记 - 清除算法是最基础的算法,该算法分为两步:标记和清除。首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。标记的过程可以参考上面介绍的可达性分析算法。
该算法最大的优点就是实现简单。缺点主要有两点:一是效率问题,标记和清除的效率都比较低;二是空间问题,标记清除后会产生大量的内存碎片,碎片过多会导致给大对象分配空间时,没有足够的连续空间可分配,从而触发GC。 -
复制算法
复制算法解决了标记清除算法的两个问题,我们看一下复制算法的收集过程。首先,该算法将内存分为大小相等的两块,每次只使用其中一块。当被使用的那块内存用完了,就将存活的对象复制到另一块内存中,然后将使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行回收,内存分配时也不用考虑内存碎片,只要移动堆顶指针,按顺序分配内存即可。
该算法的优点是实现简单,运行高效;缺点是将可用内存缩小了一半。
说到这里,是不是觉着在哪见过?我们前面介绍新生代的时候,提到过Eden和survivor区,就是根据这个算法设立的。IBM公司的专门研究表明,新生代中的对象98%都是朝生夕死的,所以不需要按照1:1的比例来分配内存,所以采用了一块较大的Eden和两块较小的Survivor。HotSpot虚拟机默认Eden和Survivor的比例8:1,即Eden占80%,两块Survivor分别占10%。(参考《深入理解Java虚拟机-JVM高级特性与最佳实践》—-周志明,3.3.2节)
复制算法对于存活率低的对象回收效率较高,每次只要进行少部分对象的复制;但是如果对象存活率高,复制算法的效率就会变低。而且在所有对象都100%存活的极端情况下,还需要有额外的空间进行分配担保。因此,在老年代,不适合使用复制算法。 -
标记 - 整理算法
标记 - 整理算法就是根据老年代的特点设计的。标记过程与标记清除算法一致,但是标记完以后不是对可回收对象直接清理,而是将所有存活对象都往一端移动,然后直接清理掉端边界以外的内存。 -
分代收集算法
分代收集就是将内存分为新生代和老年代,针对他们不同的特点选用不同的垃圾收集算法。新生代就采用复制算法,老年代采用标记清除或者标记整理算法。垃圾收集器是垃圾收集算法的具体实现。 先来看看都有哪些垃圾收集器:
-
Serial
Serial收集器是最基本,历史最悠久的收集器,在jdk1.3.1之前,是新生代的唯一选择。它单线程的,这里的单线程不仅仅说明它只会使用一个CPU或者一条线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有工作线程。它采用就是我们上面介绍的复制算法。由于Serial的工作机制会导致Stop The World,所以它并不适用于Server环境,而是适用于单CPU的Client模式。由于STW,Serial在垃圾收集的过程中不存在线程交互,单线程效率高,响应时间短。 -
Serial Old
Serial Old是Serial收集器的老年代版本,同样是一个单线程收集器,使用标记-整理算法,也是适用于单CPU的Client模式。如果是在Server模式下,它有两大用途:一是在JDK1.5及之前版本中与Parallel Scavenge收集器搭配使用;二是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。优点同样是单线程效率高,响应时间短。 -
ParNew
ParNew是Serial的并行版本,可以使用多条线程进行垃圾收集工作。注意这里的并行,仅指多线程收集,收集过程中的用户线程仍然处于等待状态。除了多线程收集,其他方面与Serial一致,同样是采用了复制算法。
ParNew是许多Server模式下的虚拟机的首选新生代收集器,一个重要的原因是,目前只有它可以与CMS收集器配合工作。在单CPU环境下,它与Serial的收集效率几乎一致,但是在多CPU环境下,它的优势就比较明显了。ParNew默认开启的收集线程数与CPU的数量相同。 -
Parallel Scavenge
Parallel Scavenge是一个新生代收集器,同样采用复制算法,是并行的多线程收集器。看上去与ParNew差不多,它俩的区别在于,Parallel Scavenge的关注点在于达到一个可控的吞吐量,而ParNew关于点在于尽可能地缩短垃圾收集时的用户线程的停顿时间。
吞吐量是指CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效利用CPU时间,适合在后台运算而不需要太多交互的任务。 -
Parallel Old
Parallel Old收集器是Parallel Scavenge的老年代版本,采用的是标记-整理算法。同样是注重吞吐量,在吞吐量以及CPU资源敏感的场合,可以优先考虑Parallel Scavenge加Parallel Old的组合。 -
CMS
CMS,即Concurrent Mark Sweep,是一款以获取最短回收停顿时间为目标的垃圾收集器。从全称可以看出,CMS是基于标记-清除算法的,主要用于老年代的垃圾收集。它的运行过程相对复杂,分为四个步骤,一起看一下:- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 重新标记(CMS remark)
- 并发清除(CMS concurrent sweep)
初始标记,仍然需要STW,这个阶段仅仅是标记一下GC Root能直接关联到的对象,速度很快;并发标记就是进行GC Roots Tracing的过程;而重新标记阶段是为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,同样需要STW,这个阶段的停顿时间一般会比初始标记阶段稍长,但是远比并发标记短;并发清除,就是清理需要回收的内存空间。
由于整个过程中耗时最长的并发标记和并发清除过程,收集器线程可以与用户线程一起工作,所以,从总体上来讲,CMS的内存回收过程是与用户线程一起并发执行的。
CMS的优点也很明显,并发收集,低延迟。我们来关注一下缺点,主要有3个:- CMS收集器对CPU资源非常敏感。在并发阶段,虽然不会导致用户线程停顿,但是会因为占用了一部分线程而导致应用程序变慢。CMS默认会启动(CPU数量+3)/4个线程进行垃圾收集,因此当CPU在4个以上时,并发回收时的垃圾收集线程不少于25%的CPU资源,并随着CPU增加而下降。而当CPU数量不足4个时,对用户程序影响就会比较大,会分出去一半的运算能力进去垃圾收集。
- CMS收集器无法处理浮动垃圾,可能会出现”Concurrent Mode Failure”失败而导致另一次Full GC被触发。由于CMS在并发清理阶段用户线程还在执行,所以一直会有新的垃圾不断产生,这一部分垃圾在标记过程之后,CMS无法在当次收集中处理它们,只有留在下一次GC进行回收,这部分垃圾就称为“浮动垃圾”。正因为有浮动垃圾,虚拟机就需要预留足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎被填满了再收集,需要预留一部分空间提供并发收集时的程序运作使用。JDK1.5默认老年代使用68%的空间后会被激活,而JDK1.6提升至92%,可以通过-XX:CMSInitiatingOccupancyFraction来修改触发的百分比。当CMS运行期间预留的内存无法满足程序需要,就会出现“Concurrent Mode Failure”失败,此时虚拟机将启动预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集。
- 会产生空间碎片。我们在介绍垃圾回收算法的时候提到过,标记-清除算法会导致内存碎片的产生,大量的内存碎片会导致触发Full GC,降低性能。为此,CMS提供了一个-XX:+UseCMSCompactAtFullCollection开关参数,此开关默认开启,用于在进行Full GC时开启内存碎片的合并整理,此过程无法并发,会导致停顿时间变长。虚拟机还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction,来设置执行多少次无压缩的Full GC后,进行一次带压缩的Full GC,默认值是0,即每次都进行合并整理。
-
G1
G1,Garbage-First,是一款面向服务端应用的垃圾收集器,与其他垃圾收集器相比,它具有以下特点:- 并行、并发
G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短STW停顿的时间,并且可以通过并发的方式让用户程序继续执行。 - 分代收集
前面介绍的垃圾收集器大都只专注于新生代或者老年代的其中一个,而G1可以独立管理整个GC堆。 - 空间整合
G1从整体来看是用了标记-整理算法,而从局部来看是用了复制算法,因此,G1不会产生内存碎片。 - 可预测的停顿
G1与CMS一样,都把降低停顿时间作为关注点,但G1还可以建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不超过N毫秒。
G1将整个Java堆划分为多个大小相等的区域(Region),虽然扔保留新生代和老年代的概念,但是新生代和老年代不再是物理隔离的,都是一部分Region的集合。而G1之所以可以建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小,在后台维护一张优先列表,每次根据允许的收集时间,优先回收价值最大的Region。因此,G1可以在有限的时间内获取尽可能高的收集效率。
G1通过维护一个Remembered Set来避免全堆扫描,这个Remembered Set记录了对象的引用信息。G1中每个Region都有一个与之相应的Remembered Set,当虚拟机发现程序对Reference类型的数据进行写操作时,会产生一个Write Barrier,暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中,如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set之中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。
G1的工作流程与CMS类似,也分为四步:- 初始标记(Initial Marking)
- 并发标记(Concurrent Masking)
- 最终标记(Final Masking)
- 筛选回收(Live Data Counting and Evacuation)
初始标记仅仅只标记一个GC Roots能直接关联到的对象,并且修正TAMS(Top at Mark Start)的值(Next Top at Mark Start),让下一阶段用户程序并发运行时,能在正确可用的Region中创建对象,这个过程需要停顿线程,但耗时很短。 并发标记是从GC Roots开始对堆中对象进行可达性分析,找出存活对象,这段时间耗时较长,但可以与用户程序并发执行。最终标记时为了修正在并发标记期间因用户程序继续运作而导致标记产证变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这个阶段同样可以并发执行。最后的筛选回收阶段会对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。
关于G1的详细介绍可以参考详解 JVM Garbage First(G1) 垃圾收集器_java_coderlius的博客-CSDN博客介绍完了垃圾收集器,接下来看一下关于如何选择垃圾收集器的一些简单建议,摘自Available Collectors:
- 如果应用只有很小的数据集,大约100M,则可以选择Serial收集器
- 如果应用在单处理器环境,并且对停顿时间没有要求,可以让虚拟机自己选择收集器,或者指定Serial收集器
- 如果程序对性能要求较高,并且对停顿时间的要求较为宽松,可以让虚拟机选择收集器,或者指定Parallel收集器
- 如果程序的响应时间要求较高,并且停顿时间要求在1秒左右,可以选择CMS或者G1收集器
最后,来看一下垃圾收集器的相关设置参数:
- 并行、并发
-
UseSerialGC:使用Serial + Serial Old的收集器组合
-
UseParNewGC: 使用ParNew + Serial Old的收集器组合
-
UseConcMarkSweepGC:使用ParNew + CMS + Serial Old的收集器组合,Serial Old作为concurrent mode failure出现后的后备收集器使用
-
UseParallelGC:使用Parallel Scavenge + Serial Old的收集器组合
-
UseParallelOldGC:使用Parallel Scavenge + Parallel Old的收集器组合
-
SurvivorRatio:新生代中Eden和Survivor的比,默认是8,即Eden:Survivor=8:1
-
PretenureSizeThreshold:直接晋升到老年代的对象大小,超过这个值就直接在老年代分配
-
MaxTenuringThreshold:晋升到老年代的对象年龄,每次MinorGC后年龄就增加1,超过这个数值,对象就进入老年代
-
UseAdaptiveSizePolicy:动态调整Java堆中各个区域的大小以及进入老年代的年龄
-
HandlePromotionFailure:是否允许分配担保失败
-
ParallelGCThreads:设置并行GC时进行内存回收的线程数
-
GCTimeRatio:GC时间占总时间的比例,默认99,即允许1%的GC时间,仅在Parallel Scavenge收集器时生效
-
MaxGCPauseMillis:设置GC的最大停顿时间,仅在Parallel Scavenge收集器时生效
-
CMSInitiatingOccupancyFraction:设置CMS收集器在老年代空间被使用多少后触发垃圾收集,默认值是68%,仅在CMS时生效
-
UseCMSCompactAtFullCollection:设置CMS收集器在完成垃圾收集后是否要进行一次内存碎片整理
-
CMSFullGCsBeforeCompaction:设置CMS收集器在进行若干次垃圾收集后再启动一次内存碎片整理
这里需要在补充说明两个概念:
-
动态年龄判定:即虚拟机并非一定要等到对象的年龄达到MaxTenuringThreshold设定的值才会进入老年代,如果在Survivor空间中的相同年龄的对象大小的总和大于Survivor空间的一半,年龄大于等于该年龄的对象就会直接进入老年代。
-
空间分配担保:在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC就是安全的,否则,虚拟机会查看HandlePromotionFailure参数,是否允许担保失败,如果允许,就会检查老年代的最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,就会尝试进行一次minor GC,如果小于,或者HandlePromotionFailure设置为不允许,就会进行一次Full GC。这里的担保,就是指老年代的最大可用连续空间,那么这个担保对应了一个什么风险呢?我们知道新生代使用复制算法进行垃圾收集的,并且每次minor GC都只使用一个survivor空间来轮换备份,因此当出现大量对象在Minor GC之后仍然存活的情况,survivor空间可能不够,需要老年代进行担保这种风险,把survivor空间无法容纳的对象直接放入老年代。
JVM调优
说来惭愧,工作中并没有这方面的经验,那就根据前面介绍的来猜测一下吧,先来看几个配置参数:
- -Xmx和-Xms:分别对应JVM最大可用内存和JVM最小可用内存
- -Xmn:设置新生代大小
- -Xss:每个线程的堆栈大小
- -XX:NewRatio:新生代和老年代的比值,默认为4,即新生代:老年代=1:4
- -XX:MaxPermSize:设置永久代大小(jdk1.8之前)
上面五个配置,加上JVM GC部分介绍的参数,就是我们调优的切入点。
我们通常会使用log来跟踪问题,对于JVM也是一样,我们可以通过下面几个参数来开启log: - -XX:+PrintGC:打印GC日志
- -XX:+PrintGCDetail:打印GC详细日志
- -XX:+PrintGCTimeStamps:输出GC的时间戳(以基准时间的形式)
- -XX:+PrintGCDateStamps:输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
- -XX:+PrintGCApplicationConcurrentTime:打印每次垃圾回收前,程序未中断的执行时间
- -XX:+PrintGCApplicationStoppedTime:打印垃圾回收期间程序暂停的时间
- -XX:PrintHeapAtGC:打印GC前后的详细堆栈信息
- -Xloggc:filename:日志文件的输出路径
那么GC的log要怎么看呢?
611.633: [GC [DefNew: 843458K->2K(948864K), 0.0059180 secs] 2186589K->1343132K(3057292K), 0.0059490 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
100.677: [Full GC [Tenured: 0K->210K(10240K), 0.0149142 secs] 4603K->210K(19456K), [Perm : 2999K->2999K(21248K)], 0.0150007 secs] [Times: user=0.01 sys=0.00 real=0.02 secs]
上面两条log分别对应了新生代和老年代的GC,一起看一下。
首先,第一个冒号前面的是GC发生的时间,紧接着中括号里面的GC和Full GC,说明了这次垃圾收集的停顿类型,而不是用来区分新生代和老年代。DefNew和Tenured则是表明了GC发生的区域,这个名称与使用的垃圾收集器密切相关,比如Serial收集器显示DefNew,Parallel Scavenge收集器则显示PSYoung,而ParNew收集器显示ParNew;Tenured和Perm分别代表老年代和永久代,具体的显示名称同样受选择的垃圾收集器影响。
然后,方括号内的843458K->2K(948864K)表示“GC前该内存区域已使用容量->GC后该内存区域已使用的容量(该内存区域的总容量)”,而方括号外的2186589K->1343132K(3057292K)表示“GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)”,后面的“0.0059180 secs”表示该区域GC所用的时间。
最后,Times后面表示的是GC所用的具体时间,user表示用户态消耗的CPU时间,sys表示内核态消耗的CPU时间,real表示操作从开始到结束所经过的墙钟时间。
以上,若有错误或者侵权,烦请告知。

浙公网安备 33010602011771号