JVM学习(4)——全面总结Java的GC算法和回收机制

  引用实例被添加在引用队列中,可以在任何时候通过查询引用队列回收对象。

  

  现在我对一个对象的生命周期进行描述:

  新建Java对象A首先处于可达的,未执行finalize方法的状态,随着程序的运行,一些引用关系会消失,或者变迁,当对A使用可达性算法判断,对象A变成了 GC Roots 不可达时,A从可达状态变迁到不可达状态,但是JVM不会就就这样把它清理了,而是在第一次GC的时候,对它首先进行一个标记(标记清除算法),之后最少还要再进行一次筛选,而对其筛选的的条件就是看该对象是否覆盖了Object的finalize方法,或者看这个对象是否执行过一次finalize方法。如果没有执行,也没有覆盖,就满足筛选条件,JVM将其放入F-Queue队列,由JVM的一个低优先级的线程执行该队列中对象的finalize方法。此时执行finalize方法优先级是很低的,且不会保证等待finalize方法执行完毕才进行第二次回收(怕发生无限等待的情景,JVM崩溃),之后不久GC对队列里的对象进行二轮回收,去判断该对象是否可达,若不可达,才进行回收,否则,对象“复活”(执行finalize的过程中,应用程序是可以让对象再次被引用,复活的)。而在可达性判断的时候,还要兼顾四种引用类型,根据不同的引用类型特点去判断是否是回收的对象。看例子:
 

 

package wys.demo1;

public class Demo1 {
    public static Demo1 obj;

    @Override
    protected void finalize() throws Throwable {
        super.finalize();

        System.out.println("CanReliveObj finalize called");

        obj = this;// 把obj复活了!!!
    }

    @Override
    public String toString(){
        return "I am CanReliveObj";
    }

    public static void main(String[] args) throws InterruptedException{
        obj = new Demo1();// 强引用
        obj = null;   //不会被立即回收,是可复活的对象

        System.gc();// 主动建议JVM做一次GC,GC之前会调用finalize方法,而我在里面把obj复活了!!!
        Thread.sleep(1000);

        if(obj == null){
            System.out.println("obj 是 null");
        }else{
            System.out.println("obj 可用");
        }

        System.out.println("第二次gc");
        obj = null;    //不可复活
        System.gc();
        Thread.sleep(1000);

        if(obj == null){
            System.out.println("obj 是 null");
        }else{
            System.out.println("obj 可用");
        }
    }
}

  结果:

CanReliveObj finalize called
obj 可用
第二次gc
obj 是 null

  说明JVM不管程序员手动调用finalize,JVM它就是执行一次finalize方法。执行finalize方法完毕后,GC会再次进行二轮回收,去判断该对象是否可达,若不可达,才进行回收。

  

  建议:避免使用finalize方法!

  太复杂了,还是让系统照管比较好。可以定义其它的方法来释放非内存资源。建议使用try-catch-finally来替代它执行清理操作。

  如果手动调用了finalize,很容易出错。且它执行的优先级低,何时被调用,不确定——也就是何时发生GC不确定,因为只有当内存告急时,GC才工作,即使GC工作,finalize方法也不一定得到执行,这是由于程序中的其他线程的优先级远远高于执行finalize()的线程优先级。 因此当finalize还没有被执行时,系统的其他资源,比如文件句柄、数据库连接池等已经消耗殆尽,造成系统崩溃。且垃圾回收和finalize方法的执行本身就是对系统资源的消耗,有可能造成程序的暂时停止,因此在程序中尽量避免使用finalize方法。

  上面提到了GC或者执行finalize可能造成程序暂停,这引出一个概念:Stop-The-World现象。

  这是Java中一种全局暂停的现象,全局停顿,所有Java代码停止,类似JVM挂起的状态……但是native代码可以执行,但不能和JVM交互。这多半由于GC引起,其他的引起原因比如:

  • Dump线程
  • JVM的死锁检查
  • 堆的Dump。

  这三者出现概率很低,多半是程序员手动引起的,而GC是JVM自动引起的。

  

  GC时为什么会有全局停顿?

  类比在聚会时打扫房间,聚会时很乱,又有新的垃圾产生,房间永远打扫不干净,只有让大家停止活动了,才能将房间在某一个状态下打扫干净。回程序中就是只有程序暂停了,才能全面,完整,正确的清理一次垃圾对象,否则前脚清理了,后脚还有新的,永远清理不完,对判断垃圾对象也是一个判断上干扰的问题,也永远干净不了。

 

  Stop-The-World现象危害

  长时间服务停止,没有响应,一般新生代的GC停顿时间很短,零点几秒。而老年代比较时间长,几秒甚至几十分钟……一般堆内存越大,GC时间越长,也就是Stop-The-World越久。所以,JVM的内存不是越大越好,要根据实际情况设置。

  遇到HA系统,可能引起主备切换,严重危害生产环境。比如一个系统,一个主机服务器,一个备机服务器,不会同时启动,我们会只使用一个,比如主机暂时因为GC没有响应,如果时间太长,我们会使用备机,一旦主机恢复了,主机也启动了,此时备机主机都启动了,很可能导致服务器数据不一致……

  

  前面罗嗦了一堆,那么这些算法是如何在JVM中配合使用的呢?那么就引出新的问题需要解决:JVM的垃圾回收器。

  回忆下堆的结构:还是以Java 7为例子:

  Java堆整体分两代,新生代和老年代,顾名思义,前者存放新生对象,大部分都是朝生夕死!进行GC的次数不多,后者存放的是时间比较久的对象,也就是多次GC还没死的对象。对象创建的时候,大部分都是放入新生代的eden区,除非是很大的对象,可能会直接存放到老年代,还有之前说的栈上分配(逃逸分析)。

  如果eden对象在GC时幸存,就会进入幸存区,也就是s0,s1,或者叫from和to,或者叫survivor(两个),大小一样。完全对称,功能也一样。前面说了GC有复制算法,那么就是使用在这里,GC在新生代时,eden区的存活对象被复制到未使用的幸存区,假设是to,而正在使用的是from区的年轻的对象也会一起被复制到了to区,如果to区满了,这些对象也和大对象,老年对象一样直接进入了老年代保存(担保空间)。此时,eden区剩余的对象和from区剩余的对象就是垃圾对象,能直接GC,to区存放的是新生代的此次GC活下来的对象。避免了产生内存碎片。

  先不说了,先看看JVM的垃圾回收器吧,先看一种最古老的收集器——串行收集器

  最古老,最稳定,效率高,但是串行的最大问题就是停顿时间很长!因为串行收集器只使用一个线程去回收,可能会产生较长的停顿现象。我们可以使用参数-XX:+UseSerialGC,设置新生代、老年代使用串行回收,此时新生代使用复制算法,老年代使用标记-压缩算法(标记-压缩算法首先需要从根节点开始,对所有可达对象做一次标记。但之后,它并不简单的清理未标记的对象,而是将所有的存活对象压缩到内存的一端。之后,清理边界外所有的空间。有效解决内存碎片问题)。

  因为串行收集器只使用一个线程去回收,可能会产生较长的停顿现象。

 

  还有一种收集器叫并行收集器(两种并行收集器)

  • 一种是ParNew并行收集器。使用JVM参数设置XX:+UseParNewGC,设置之后,那么新生代就是并行回收,而老年代依然是串行回收,也就是并行回收器不会影响老年代,它是Serial收集器在新生代的并行版本,新生代并行依然使用复制算法,但是是多线程,需要多核支持,我们可以使用JVM参数: XX:ParallelGCThreads 去限制线程的数量。如图:

  注意:新生代的多线程回收不一定快!看在多核还是单核,和具体环境。、

  • 还有一种是Parallel收集器,它类似ParNew,但是更加关注JVM的吞吐量!同样是在新生代复制算法,老年代使用标记压缩算法,可以使用JVM参数XX:+UseParallelGC设置使用Parallel并行收集器+ 老年代串行,或者使用XX:+UseParallelOldGC,使用Parallel并行收集器+ 并行老年代。也就是说,Parallel收集器可以同时让新生代和老年代都并行收集。如图:

  关于并行收集器还有两个参数设置:

  -XX:MaxGCPauseMills,代表最大的GC线程占用的停顿时间,单位是毫秒,GC尽力保证回收时间不超过设定值,不是100%的保证。

  -XX:GCTimeRatio,GC使用的cpu时间占总时间的百分比,理解为吞吐量,0-100的取值范围,垃圾收集时间占总时间的比,默认99,即最大允许1%时间做GC。我们肯定希望停顿时间短,且占用总时间比例少,但是这两个参数是矛盾的。因为停顿时间和吞吐量不可能同时调优。

  如果GC很频繁,那么GC的最大停顿时间变短,但吞吐量变小,如果GC次数很少,最大的停顿时间就会变长,但吞吐量增大。

  

  最后看一个很重要的收集器-CMS(并发标记清除收集器Concurrent Mark Sweep)收集器

  顾名思义,它在老年代使用的是标记清除算法,而不是标记压缩算法,也就是说CMS是老年代收集器(新生代使用ParNew),所谓并发标记清除就是CMS与用户线程一起执行。标记-清除算法与标记-压缩相比,并发阶段会降低吞吐量,使用参数-XX:+UseConcMarkSweepGC打开。

   CMS运行过程比较复杂,着重实现了标记的过程,可分为:

  • 初始标记,标记GC ROOT 根可以直接关联到的对象(会产生全局停顿),但是初始标记速度快。
  • 并发标记(和用户线程一起),主要的标记过程,标记了系统的全部的对象(不论垃圾不垃圾)。
  • 重新标记,由于并发标记时,用户线程依然运行(可能产生新的对象),因此在正式清理前,再做一次修正,会产生全局停顿
  • 并发清除(和用户线程一起),基于标记结果,直接清理对象。这也是为什么使用标记清除算法的原因,因为清理对象的时候用户线程还能执行!标记压缩算法的压缩过程涉及到内存块移动,这样会有冲突。
  • 并发重置,为下一次GC做准备工作。

 

  CMS的特点

  尽可能降低了JVM的停顿时间,但是会影响系统整体吞吐量和性能,比如:

  1. 在用户线程运行过程中,分一半CPU去做GC,系统性能在GC阶段,反应速度就下降一半。
  2. 清理不彻底。因为在清理阶段,用户线程还在运行,会产生新的垃圾,无法清理。
  3. 因为和用户线程基本上是一起运行的,故不能在空间快满时再清理。

可以使用-XX:CMSInitiatingOccupancyFraction设置触发CMS GC的阈值,设置空间内存占用到多少时,去触发GC,如果不幸内存预留空间不够,就会引起concurrent mode failure。

可以使用-XX:+ UseCMSCompactAtFullCollection, Full GC后,进行一次整理,而整理过程是独占的,会引起停顿时间变长。

可以使用-XX:+CMSFullGCsBeforeCompaction,设置进行几次Full GC后,进行一次碎片整理。

还可以使用-XX:ParallelCMSThreads,设定CMS的线程数量,一般设置为cpu数量,不用太大。

 

  为减轻GC压力,我们需要注意些什么?

   从三个方面考虑:

  • 软件如何设计架构
  • 代码如何写
  • 堆空间如何分配

 

辛苦的劳动,转载请注明出处,谢谢……http://www.cnblogs.com/kubixuesheng/p/5208647.html

时间: 2024-09-18 13:10:27

JVM学习(4)——全面总结Java的GC算法和回收机制的相关文章

JVM学习笔记(三)------内存管理和垃圾回收【转】

转自:http://blog.csdn.net/cutesource/article/details/5906705 版权声明:本文为博主原创文章,未经博主允许不得转载. JVM内存组成结构 JVM栈由堆.栈.本地方法栈.方法区等部分组成,结构图如下所示:   1)堆 所有通过new创建的对象的内存都在堆中分配,其大小可以通过-Xmx和-Xms来控制.堆被划分为新生代和旧生代,新生代又被进一步划分为Eden和Survivor区,最后Survivor由From Space和To Space组成,结

我的Java开发学习之旅------>Java语言中方法的参数传递机制

       实参:如果声明方法时包含来了形参声明,则调用方法时必须给这些形参指定参数值,调用方法时传给形参的参数值也被称为实参.      Java的实参值是如何传入方法?这是由Java方法的参数传递机制来控制的,Java里方法的参数传递方式只有一种:值传递.所谓值传递,就是将实际参数的副本(复制品)传入方法内,而参数本身不会收到任何影响. 一.参数类型是原始类型的值传递 下面通过一个程序来演练 参数类型是原始类型的值传递的效果: public class ParamTransferTest

我的Java开发学习之旅------>Java经典排序算法之冒泡排序

冒泡排序(Bubble Sort)是一种简单的排序算法.它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来.走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成.这个算法的名字由来是因为越小的元素会经由交换慢慢"浮"到数列的顶端. 一.算法原理 冒泡排序算法的运作如下: 1.比较相邻的元素.如果第一个比第二个大,就交换他们两个. 2.对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对.在这一点,最后的元素应该会是最大的数. 3.

我的Java开发学习之旅------>Java经典排序算法之希尔排序

一.希尔排序(Shell Sort) 希尔排序(Shell Sort)是一种插入排序算法,因D.L.Shell于1959年提出而得名. Shell排序又称作缩小增量排序. 二.希尔排序的基本思想 希尔排序的中心思想就是:将数据进行分组,然后对每一组数据进行排序,在每一组数据都有序之后 ,就可以对所有的分组利用插入排序进行最后一次排序.这样可以显著减少交换的次数,以达到加快排序速度的目的.       希尔排序的中心思想:先取一个小于n的整数d1作为第一个增量,把文件的全部记录分成d1个组.所有距

我的Java开发学习之旅------>Java经典排序算法之插入排序

一.算法原理 插入排序法:所谓插入排序法乃是将一个数目插入该占据的位置. 假设我们输入的是 "53,27,36,15,69,  42" 我们从第二个数字开始,这个数字是27,我们的任务只要看看27有没有正确的位置,我们的做法是和这个数字左边的数字来比,因此我们比较27和53,27比53小,所以我们就交换27和53,原来的排列就变成了"27, 53, 36, 15, 69, 42 " 接下来,我们看第3个数字有没有在正确的位置.这个数字是36,它的左边数字是53,36

我的Java开发学习之旅------>Java经典排序算法之选择排序

一.算法原理 对比数组中前一个元素跟后一个元素的大小,如果后面的元素比前面的元素小则用一个变量k来记住他的位置, 接着第二次比较,前面"后一个元素"现变成了"前一个元素",继续跟他的"后一个元素"进行比较如果后面的元素比 他要小则用变量k记住它在数组中的位置(下标),等到循环结束的时候,我们应该找到了最小的那个数的下标了, 然后进行判断,如果这个元素的下标不是第一个元素的下标,就让第一个元素跟他交换一下值,这样就找到整 个数组中最小的数了.然后找

我的Java开发学习之旅------>Java经典排序算法之归并排序

一.归并排序 归并排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用.将已有序的子序列合并,得到完全有序的序列:即先使每个子序列有序,再使子序列段间有序.若将两个有序表合并成一个有序表,称为二路归并. 归并过程为:比较a[i]和a[j]的大小,若a[i]≤a[j],则将第一个有序表中的元素a[i]复制到r[k]中,并令i和k分别加上1:否则将第二个有序表中的元素a[j]复制到r[k]中,并令j和k分别加上1,如此循环下去,直

我的Java开发学习之旅------>Java经典排序算法之快速排序

一.算法思想     快速排序是C.R.A.Hoare于1962年提出的一种划分交换排序.它采用了一种分治的策略,通常称其为分治法(Divide-and-ConquerMethod). (1) 分治法的基本思想     分治法的基本思想是:将原问题分解为若干个规模更小但结构与原问题相似的子问题.递归地解这些子问题,然后将这些子问题的解组合为原问题的解. (2)快速排序的基本思想     设当前待排序的无序区为R[low..high],利用分治法可将快速排序的基本思想描述为:①分解:      在

我的Java开发学习之旅------>Java经典排序算法之二分插入排序

一.折半插入排序(二分插入排序) 将直接插入排序中寻找A[i]的插入位置的方法改为采用折半比较,即可得到折半插入排序算法.在处理A[i]时,A[0]--A[i-1]已经按关键码值排好序.所谓折半比较,就是在插入A[i]时,取A[i-1/2]的关键码值与A[i]的关键码值进行比较,如果A[i]的关键码值小于A[i-1/2]的关键码值,则说明A[i]只能插入A[0]到A[i-1/2]之间,故可以在A[0]到A[i-1/2-1]之间继续使用折半比较:否则只能插入A[i-1/2]到A[i-1]之间,故可