Java虚拟机:垃圾收集机制

版权声明:本文为斑马君学习总结文章,转载请注明出处!

一、垃圾回收

上篇博客介绍了Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈三个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的。),因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。

Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存。
GC需要完成的3件事情:

  • 如何判定对象为垃圾对象
  • 如何回收
  • 何时回收
二、如何判定对象为垃圾对象

在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”。

引用计数算法

在对象中添加一个引用计数器,当有地方引用这个对象的时候,引用计数器的值就+1,当引用失效的时候,计数器的值就-1.该算法缺陷是难解决对象之间相互循环引用的问题。
public class Main {

private Object instance;

public Main() {
    byte[] m = new byte[20 * 1024 *1024];
}

public static void main(String[] args) {
    
    Main m1 = new Main();
    
    Main m2 = new Main();
    
    m1.instance = m2;
    m2.instance = m1;
    
    m1 = null;
    m2 = null;
    
    System.gc();
}

VM arguments参数设置:-verbose:gc -XX:+PrintGCDetails

从运行结果中可以清楚看到,GC日志中包含“707K->476K”,意味着虚拟机并没有因为这两个对象互相引用就不回收它们,这也从侧面说明虚拟机并不是通过引用计数算法来判断对象是否存活的。

可达性分析算法

这个算法的基本思路就是通过一些列的称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

在java语言中,可作为GC Roots的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI引用的对象

再谈引用

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。

在JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong
Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(PhantomReference)4种,这4种引用强度依次逐渐减弱。

  • 强引用就是指在程序代码之中普遍存在的,类似“Object obj=new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
  • 软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2之后,提供了SoftReference类来实现软引用。
  • 弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2之后,提供了WeakReference类来实现弱引用。
三、如何回收

回收策略:标记-清除算法

算法分为”标记“和”清除“两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

该算法主要有两个不足的问题:

效率问题:标记和清除两个过程的效率都不高;
空间问题:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

回收策略:复制算法

该算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

活着的对象复制到另外一块上面
已使用过的内存空间一次清理掉
现在的商业虚拟机都采用这种收集算法来回收新生代,IBM公司的专门研究表明,新生代中的对象98%是“朝生死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。

当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。

回收策略:标记-整理算法和分代收集算法

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

“标记-清除”算法:不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

标记-整理算法
“分代收集算法:根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间。对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。

四、垃圾收集器

新生代收集器-Serial收集器

是最基本、发展历史最悠久的收集器。曾经(在JDK 1.3.1之前)是虚拟机新生代收集的唯一选择。这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。

Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。,收集几十兆甚至一两百兆的新生代,停顿时间完全可以控制在几十毫秒最多一百多毫秒以内,只要不是频繁发生,这点停顿是可以接受的。

Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)

新生代收集器-ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集外,其余与Serial收集器完全一样。ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器。

ParNew收集器在单CPU的环境中绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个CPU的环境中都不能百分之百地保证可以超越Serial收集器。当然,随着可以使用的CPU的数量的增加,它对于GC时系统资源的有效利用还是很有好处的。

使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。使用-XX:+UseConcMarkSweepGC选项后的默认新生代收集器,也可以使用-XX:+UseParNewGC选项来强制指定它。

新生代收集器-Parallel Scavenge收集器

Parallel Scavenge收集器是一个新生代收集器,使用复制算法的并行收集器,Parallel Scavenge 收集器使用两个参数控制吞吐量。
直观上,只要最大的垃圾收集停顿时间越小,吞吐量是越高的,但是GC停顿时间的缩短是以牺牲吞吐量和新生代空间作为代价的。比如原来10秒收集一次,每次停顿100毫秒。但是线程编程每5秒收集一次,每次停顿70毫秒,停顿时间下降的同时,吞吐量也下降了。

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。收集器有一个参数- XX:+UseAdaptiveSizePolicy当这个参数打开之后,就不需要手动指定新生代的大小,Eden和Survivor区的比例,晋升老年代对象等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大吞吐量,这种调节方式成为GC自适应的调节策略。

老年代收集器 - CMS收集器

由于垃圾回收时,都需要暂停用户线程,CMS(Concurrent Mark Sweep)收集器是一种以 获取最短停顿时间 为目标的收集器,重视服务的响应速度,希望系统停顿时间最短,能给用户带来良好的体验。
CMS收集器是基于"标记-清除"算法实现的,它的运作过程比较复杂,整个过程分为四个步骤:

  • 初始标记 初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要Stop The World(暂停所有的用户线程)
  • 并发标记 并发标记阶段就是进行GC Roots Tracing的过程 (用户不暂停)—用户不暂停就还可能产生一些对象与GC Roots不可达
  • 重新标记重新标记阶段是为了修正 并发标记期间 因用户程序继续运作而导致标记产生变动 的那一部分对象的标记记录,这个阶段的停顿时间会比初始阶段稍长一些,但是远比并发标记的时间短,仍然需要"Stop The World"
  • 并发清除并发清除阶段会清除对象(用户不暂停)

整个过程中耗时最长的并发表及和并发清除过程收集线程可以与用户线程一起工作,所以整体上来说,CMS收集器的内存回收过程与用户线程一起并发执行。

优点:CMS是一款优秀的收集器,主要优点:并发、低停顿。

缺点:

  • CMS收集器对CPU的资源非常敏感,在并发阶段,它虽然不会导致用户线程停顿,但是会因为占了一部分CPU资源,而导致应用程序变慢,总吞吐量会降低。
  • CMS无法处理浮动垃圾,由于CMS 并发清理阶段用户线程还在运行着,用户线程在运行自然就还会有新的垃圾产生,CMS无法在当次收集中处理掉它们,只好留到下一次GC再清理掉,这一部分垃圾叫做"浮动垃圾"。
  • CMS收集器会产生大量的空间碎片,CMS是一款基于"标记-清除" 算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,就会给大对象的分配带来很多麻烦,往往会出现还有很大的空间剩余,但是无法找到足够大连续的空间来分配当前对象,不得不提前触发一次Full GC。

全区域的垃圾回收器 - G1收集器
G1垃圾回收器是用在heap memory很大的情况下,把heap划分为很多很多的region块,然后并行的对其进行垃圾回收。
G1垃圾回收器在清除实例所占用的内存后,还会做内存压缩。
G1垃圾回收器回收region的时候基本不会Stop The World,从整体来看是基于标记-整理算法,从局部(两个region之间)来看基于复制算法。

一个region有可能属于Eden、Survivor或者Tenured内存,图中的E表示Eden区,S表示Survivor区、T表示Tenured区、空白就是未使用的空间。G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块。这种内存区域主要用于存储大对象-即大小超出一个region大小的50%的对象。
年轻代垃圾收集
在G1垃圾收集器中,年轻代的垃圾回收过程使用复制算法,把Eden区和Survivor区的对象复制到新的Survivor区域。
老年代垃圾收集
对于老年代的垃圾收集,G1(Garbage First)也分为四个阶段,基本与CMS垃圾收集器一样,但是略有不同。

  • 初始标记(Initial Mark) 同CMS垃圾收集器初始标记阶段一样,G1也需要暂停应用程序的执行,它会标记从跟对象出发,在根对象的第一层孩子结点中标记所有可达对象。但是G1的垃圾收集器的初始标记结点是跟Minor gc一起发生的。也就是说,在G1中,不用像CMS那样,单独暂停应用程序的执行来运行初始标记阶段,而是在G1出发Minor gc的时候一并将老年代上的初始标记给做了。
  • 并发标记(Concurrent Mark) 同CMS垃圾收集器并发标记阶段一样,但G1还多做了一件事件,就是如果在并发标记阶段,发现哪些Tenured region中对象的存活率很小或者基本没有对象存活,那么G1就会在这个阶段将其回收掉,而不用等到后面的清除阶段,这也是Garbage First名字的由来,同时在该阶段,G1会计算每个region的存活率,方便后面的清除阶段使用。
  • 最终标记(CMS中的remark阶段) 同CMS垃圾收集器重新标记阶段一样,但是采用的算法不一样,G1采用了一种叫做STAB(snapshot-at-the-begining)的算法能够在Remark阶段更快的标记可达对象。
  • 筛选回收(clean up/Copy) 在G1中,没有CMS对于的Sweep阶段。相反,它有一个Clean up/Copy阶段,在这个阶段中,G1会挑选出那么对象存活率低的region进行回收,这个阶段也是和minor gc一同完成的。

    G1是一款面向服务端应用的垃圾收集器,Hotspot开发团队赋予它的使命是未来可以替换掉JDK1.5中发布的CMS收集器
    你想追求低停顿、想让用户有更好的体验用G1
    如果你的应用追求吞吐量,G1并不能带来很明显的好处。

吞吐量

吞吐量就是CPU 运行用户代码的时间 与 CPU总消耗时间 的比值。

吞吐量 = 运行用户代码的时间 / (运行用户代码的时间+垃圾收集的时间)

假设虚拟机总共运行了100分钟,其中垃圾收集花了一分钟 吞吐量就是99%,虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务。

五、理解GC日志

阅读GC日志是处理Java虚拟机内存问题的基础技能,它只是一些人为确定的规则,没有太多技术含量。

最前面的数字“33.125:”和“100.667:”代表了GC发生的时间,这个数字的含义是从Java
虚拟机启动以来经过的秒数。

GC日志开头的“[GC”和“[Full GC”说明了这次垃圾收集的停顿类型,而不是用来区分新生代GC还是老年代GC的。如果有“Full”,说明这次GC是发生了Stop-The-World的,例如下面这段新生代收集器ParNew的日志也会出现“[Full GC”(这一般是因为出现了分配担保失败之类的问题,所以才导致STW)。如果是调用System.gc()方法所触发的收集,那么在这里将显示“[Full GC(System)”。

接下来的“[DefNew”、“[Tenured”、“[Perm”表示GC发生的区域,这里显示的区域名称与使用的GC收集器是密切相关的,例如上面样例所使用的Serial收集器中的新生代名为“DefaultNew Generation”,所以显示的是“[DefNew”。如果是ParNew收集器,新生代名称就会变为“[ParNew”,意为“Parallel New Generation”。如果采用Parallel Scavenge收集器,那它配套的新生代称为“PSYoungGen”,老年代和永久代同理,名称也是由收集器决定的。

后面方括号内部的“3324K->152K(3712K)”含义是“GC前该内存区域已使用容量->GC后该内存区域已使用容量(该内存区域总容量)”。而在方括号之外的“3324K->152K(11904K)”表示“GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)”。

再往后,“0.0025925 secs”表示该内存区域GC所占用的时间,单位是秒。有的收集器会给出更具体的时间数据,如“[Times:user=0.01 sys=0.00,real=0.02 secs]”,这里面的user、sys和real与Linux的time命令所输出的时间含义一致,分别代表用户态消耗的CPU时间、内核态消耗的CPU事件和操作从开始到结束所经过的墙钟时间(Wall Clock Time)。CPU时间与墙钟时间的区别是,墙钟时间包括各种非运算的等待耗时,例如等待磁盘I/O、等待线程阻塞,而CPU时间不包括这些耗时,但当系统有多CPU或者多核的话,多线程操作会叠加这些
CPU时间,所以读者看到user或sys时间超过real时间是完全正常的。

[GC (Allocation Failure) [DefNew: 707K->476K(4928K), 0.0013631 secs]
[Tenured: 0K->476K(10944K), 0.0024463 secs] 707K->476K(15872K), 
[Metaspace: 1640K->1640K(4480K)], 0.0238666 secs] [Times: user=0.00 
sys=0.00, real=0.02 secs] 
[GC (Allocation Failure) [DefNew: 0K->0K(4992K), 0.0003036 secs]
[Tenured: 20956K->475K(31428K), 0.0017487 secs] 20956K-
 >475K(36420K), [Metaspace: 1640K->1640K(4480K)], 0.0021762 secs] 
[Times: user=0.02 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [Tenured: 20955K->475K(31428K), 0.0019586 secs] 21208K->475K(45636K), [Metaspace: 1640K->1640K(4480K)], 0.0185442 secs] [Times: user=0.00 sys=0.00, real=0.02 secs] 
Heap
  def new generation   total 13248K, used 235K [0x03e00000, 0x04c60000, 0x09350000)
  eden space 11776K,   2% used [0x03e00000, 0x03e3af90, 0x04980000)
  from space 1472K,   0% used [0x04980000, 0x04980000, 0x04af0000)
  to   space 1472K,   0% used [0x04af0000, 0x04af0000, 0x04c60000)
  tenured generation   total 29380K, used 475K [0x09350000, 0x0b001000, 0x13e00000)
  the space 29380K,   1% used [0x09350000, 0x093c6e10, 0x093c7000, 0x0b001000)
  Metaspace       used 1644K, capacity 2242K, committed 2368K, reserved 4480K

你可能感兴趣的:(Java虚拟机:垃圾收集机制)