java的垃圾回收算法,java垃圾自动回收机制

  java的垃圾回收算法,java垃圾自动回收机制

  一.概述二。对象死了吗?1.引用计数算法2。可达性分析算法3。四参考文献4。生存还是毁灭?5.回收方法区域3。垃圾收集算法1。代集论2。名词解释3。标记清除算法4。标记复制算法5。标记-排序算法。

  00-1010说起垃圾收集(以下简称GC),很多人都把这项技术当做Java语言的副产品。事实上,垃圾收集的历史远远早于Java。Lisp于1960年诞生于麻省理工学院,是第一种使用动态内存分配和垃圾收集技术的语言。当Lisp还处于萌芽状态时,它的作者约翰麦卡锡考虑了垃圾收集需要完成的三件事:

  需要回收哪些内存?什么时候回收?怎么回收?经过半个世纪的发展,如今的内存动态分配和内存回收技术已经相当成熟,一切似乎都进入了“自动化”时代。那么,为什么我们必须了解垃圾收集和内存分配呢?

  答案很简单:当我们需要排查各种内存溢出和内存泄漏问题时,当垃圾收集成为系统实现更高并发的瓶颈时,我们必须对这些“自动”技术实施必要的监控和调整。

  Java内存运行时区,包括程序计数器、虚拟机堆栈和本地方法堆栈,随着线程的产生和退出而产生。堆栈中的堆栈帧随着方法的进入和退出依次执行推出和推入操作。每个栈帧分配多少内存,在类结构确定的时候就基本知道了,所以这些区域的内存分配和回收是确定性的,不需要过多考虑这些区域如何回收。当方法或线程结束时,内存自然会被回收。

  但是Java堆和方法区有很大的不确定性:一个接口的多个实现类需要的内存可能不同,一个方法执行的不同条件分支需要的内存也可能不同。只有在运行时才能知道程序会创建哪些对象,创建多少对象,这部分内存的分配和回收是动态的。垃圾收集器关心的是如何管理这部分内存,本文后续讨论的“内存”的分配和回收仅指这部分内存。

  00-1010 Java世界中几乎所有的对象实例都存储在堆中。在垃圾收集器回收堆之前,首先要确定这些对象中哪些仍然是“活的”,哪些是“死的”(“死的”是不能再以任何方式使用的对象)。

  在这种面试中,你经常会被要求依靠两种算法来判断物体是否有生命:

  1.引用计数算法

  2.可达性分析算法。

  00-1010判断对象是否有生命的算法如下:给对象加一个引用计数器,每有一个对它的引用,计数器值就加1;当引用无效时,计数器值减1;任何时间计数器为零的对象都不能再使用。

  客观来说,引用计数算法虽然计数占用了一些额外的内存空间,但其原理简单,判断效率高,在很多领域都有应用。但是在Java领域,至少主流的Java虚拟机没有使用引用计数算法来管理内存。原因:单纯通过引用计数很难解决对象之间的循环引用问题。

  代码示例:

  在下面的testGC()方法中:对象objA和objB有字段instance,赋值顺序为objA.instance=objB和objB.instance=objA。此外,这两个对象没有引用。实际上,这两个对象已经不能被访问了,但是因为它们相互引用,所以它们的引用计数不为零,引用计数算法无法恢复它们。

  对于objA=null,objB=null,应该会有一些人不理解为什么要这么做。我们要测试的是属性是否被交叉引用,是否会被gc丢弃。然后我必须确保他不会在其他地方被引用。我们用main方法来测试,所以需要设置为null。在普通的java应用程序中不需要它。线程执行后,局部变量将被销毁。那就没有所谓的引用了。

  尽管手动删除了引用堆中对象的局部变量,但这两个对象的属性仍然在堆内存中相互引用。根据计数算法,他应该不会被gc掉。

  public class reference counting GC { public Object instance=null;私有静态final int _ 1MB=1024 * 1024/* * *这个成员属性唯一的意义就是占用一些内存,这样就可以在GC日志中清楚的看到是否已经回收*/private byte[]big size=new byte[2 * _ 1mb];public static void testGC(){ ReferenceCountingGC objA=new ReferenceCountingGC();ReferenceCountingGC objB=new R

  eferenceCountingGC(); objA.instance = objB; objB.instance = objA; objA = null; objB = null; // 假设在这行发生GC,objA和objB是否能被回收? System.gc(); } public static void main(String[] args) { testGC(); }}设置启动参数:输出GC的详细日志

  

-XX:+PrintGCDetails

 

  

运行结果:

 

  

 

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

  

 

  

2.可达性分析算法

当前主流的商用程序语言(Java、C#,上溯至前面提到的古老的Lisp)的内存管理子系统,都是 通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过 一系列称为GC Roots的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过 程所走过的路径称为引用链(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连, 或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

 

  如图所示,对象object 5、object 6、object 7虽然互有关联,但是它们到GC Roots是不可达的, 因此它们将会被判定为可回收的对象。

  

 

  在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的 参数、局部变量、临时变量等。在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。在本地方法栈中JNI(即通常所说的Native方法)引用的对象。Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。所有被同步锁(synchronized关键字)持有的对象。反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不 同,还可以有其他对象临时性地加入,共同构成完整GC Roots集合。譬如后文将会提到的分代收集 和局部回收(Partial GC),如果只针对Java堆中某一块区域发起垃圾收集时(如最典型的只针对新生 代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不 可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引 用,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去,才能保证可达性分析的正确 性。目前最新的几款垃圾收集器无一例外都具备了局部回收的特征。如OpenJDK中的G1、Shenandoah、ZGC以及Azul的PGC、C4这些收集器。

  

 

  

3.四种引用

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可 达,判定对象是否存活都和引用离不开关系。在JDK 1.2版之前,Java里面的引用是很传统的定义: 如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表 某块内存、某个对象的引用。一个对象在 这种定义下只有被引用或者未被引用两种状态,对于描述一些食之无味,弃之可惜的对象就显 得无能为力。譬如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空 间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象——很多系统的缓存功能都符合这样的应 用场景。

 

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

  强引用是最传统的引用的定义,是指在程序代码之中普遍存在的引用赋值,即类似Object obj=new Object()这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回 收掉被引用的对象。软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内 存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存, 才会抛出内存溢出异常。在JDK 1.2版之后提供了SoftReference类来实现软引用。弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只 能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只 被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的 存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚 引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供 了PhantomReference类来实现虚引用。

 

  

4.生存还是死亡?

即使在可达性分析算法中判定为不可达的对象,也不是非死不可的,这时候它们暂时还处于缓 刑阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没 有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是 否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用 过,那么虚拟机将这两种情况都视为没有必要执行。

 

  finalize()方法是Object的一个方法,所有的对象都是Object的子类。只不过语法上默认没有继承Object。但是实际上是继承的Object类。

  如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的 队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize() 方法。这里所说的执行是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。

  这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导 致F-Queue队列中的其他对象永久处于等待。

  如果对 象要在finalize()中成功拯救自己 只要重新与引用链上的任何一个对象建立关联即可,譬如把自己 (this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出即将回收的集 合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。从下面代码我们可以看到一个 对象的finalize()被执行,但是它仍然可以存活。

  一次对象自我拯救的演示:

  此代码演示了两点:

  1.对象可以在被GC时自我拯救。

  2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次

  

public class FinalizeEscapeGC { public static FinalizeEscapeGC SAVE_HOOK = null; public void isAlive() { System.out.println("yes, i am still alive :)"); } @Override protected void finalize() throws Throwable { super.finalize(); System.out.println("finalize method executed!"); FinalizeEscapeGC.SAVE_HOOK = this; } public static void main(String[] args) throws Throwable { SAVE_HOOK = new FinalizeEscapeGC(); //对象第一次成功拯救自己 SAVE_HOOK = null; System.gc(); // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它 Thread.sleep(500); if (SAVE_HOOK != null) { SAVE_HOOK.isAlive(); } else { System.out.println("no, i am dead :("); } // 下面这段代码与上面的完全相同,但是这次自救却失败了 SAVE_HOOK = null; System.gc(); // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它 Thread.sleep(500); if (SAVE_HOOK != null) { SAVE_HOOK.isAlive(); } else { System.out.println("no, i am dead :("); } }}

运行结果:

 

  

 

  finalize()方法大家尽量避免使用它,它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,如今已被官方明确声明为 不推荐使用的语法。finalize()能做的所有工作,使用try-finally或者其他方式都可以做得更好、 更及时。

  

 

  

5.回收方法区

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃常量与回收 Java堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串java曾经进入常量池 中,但是当前系统又没有任何一个字符串对象的值是java,换句话说,已经没有任何字符串对象引用 常量池中的java常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,这个java常量就将会被系统清理出常量池。常量池中其他类(接 口)、方法、字段的符号引用也与此类似。

 

  判定一个常量是否废弃还是相对简单,而要判定一个类型是否属于不再被使用的类的条件就 比较苛刻了。需要同时满足下面三个条件:

  该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP的重加载等,否则通常是很难达成的。该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方 法。Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是被允许,而并不是 和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了命令参数进行控制,

-Xnoclassgc :关闭虚拟机对class的垃圾回收功能。-verbose:class XXX :(XXX为程序名)你会在控制台看到加载的类的情况。-XX:+TraceClassLoading :监控类的加载-XX:+TraceClassUnLoading : 监控类的卸载

 

  

其中-verbose:class和-XX:+TraceClassLoading可以在 Product版的虚拟机中使用,-XX:+TraceClassUnLoading参数需要FastDebug版的虚拟机支持。

 

  在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载 器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压 力。

  

 

  

三、垃圾收集算法

Java默认的虚拟机HotSpot VM,采用的追踪式垃圾收集,也就是刚刚所说的可达分析,所以本节介绍的所有算法均属于追踪式垃圾收集的范畴。

 

  

 

  

1.分代收集理论

当前商业虚拟机的垃圾收集器,大多数都遵循了分代收集的理论进 行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

 

  1)弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。

  2)强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消 亡

  这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分 出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区 域之中存储。

  在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域 ——因而才有了Minor GCMajor GCFull GC这样的回收类型的划分;发展出了标记-复制算法标记-清除算 法标记-整理算法等针对性的垃圾收集算法,这一切的出现都始于分代收集理论。

  把分代收集理论具体放到现在的商用Java虚拟机里,设计者一般至少会把Java堆划分为新生代 (Young Generation)和老年代(Old Generation)两个区域。顾名思义,在新生代中,每次垃圾收集 时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不 是孤立的,对象之间会存在跨代引用。

  假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可 能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整 个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象 的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分 代收集理论添加第三条经验法则:

  3)跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极 少数。

  依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录 每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称 为记忆集,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会 存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数 据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

  

 

  

2.名词解释

部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:

 

  新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单 独收集老年代的行为。另外请注意Major GC这个说法现在有点混淆,在不同资料上常有不同所指,有的是指老年代的收集、有的是指整堆收集。混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收 集器会有这种行为。整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。通常能单独发生收集行为的只是新生代,所以这里反过来的情况只是理论上允许,实际上除了 CMS收集器,其他都不存在只针对老年代的收集。

  

 

  

3.标记-清除算法

算法分为标记和清除两个阶段:首先标记出需要回 收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回 收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程,怎么判断是否垃圾,就是刚刚所提到的,引用计数算法和可达分析算法。

 

  它的主要缺点有两个:第一个是执行效率不稳定,如果Java堆中包含大量对 象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过 程的执行效率都随对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大 量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找 到足够的连续内存而不得不提前触发另一次垃圾收集动作。标记-清除算法的执行过程如图所示。

  

 

  

 

  

4.标记-复制算法

标记-复制算法常被简称为复制算法。为了 解决标记-清除算法面对大量可回收对象时执行效率低 的问题,1969年Fenichel提出了一种称为半区复制(Semispace Copying)的垃圾收集算法,它将可用 内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着 的对象复制到另外一块上面,复制的时候不用考虑有 空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可,然后再把已使用过的内存空间一次清理掉。

 

  缺点:

  如果内存中多数对象都是存 活的,这种算法将会产生大量的内存间复制的开销。

  回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一 点。

  

 

  在1989年,Andrew Appel针对具备朝生夕灭特点的对象,提出了一种更优化的半区复制分代策 略,现在称为Appel式回收。HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设 计新生代的内存布局。

  Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的 Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍 然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新 生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会 被浪费的。当然,98%的对象可被回收仅仅是普通场景下测得的数据,任何人都没有办法百分百 保证每次回收都只有不多于10%的对象存活,因此Appel式回收还有一个充当罕见情况的逃生门的安 全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实 际上大多就是老年代)进行分配担保。

  内存的分配担保好比我们去银行借款,如果我们信誉很好,在98%的情况下都能按时偿还,于是 银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证如果我不能还款 时,可以从他的账户扣钱,那银行就认为没有什么风险了。内存的分配担保也一样,如果另外一块 Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直 接进入老年代,这对虚拟机来说就是安全的。

  

 

  

5.标记-整理算法

针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的标记-整 理(Mark-Compact)算法,其中的标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可 回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内 存,标记-整理算法的示意图如图所示。

 

  标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动 式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:

  

 

  移动则内存回收时会更复杂,不移动则内存分配时会 更复杂。移动虽然复杂点,但是不影响服务器的内存吞吐量。HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的。

  还有一种和稀泥式解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚 拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经 大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标 记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法(CMS是老年代垃圾收集器)。

  以上就是Java垃圾回收机制的示例详解的详细内容,更多关于Java垃圾回收机制的资料请关注盛行IT其它相关文章!

郑重声明:本文由网友发布,不代表盛行IT的观点,版权归原作者所有,仅为传播更多信息之目的,如有侵权请联系,我们将第一时间修改或删除,多谢。

留言与评论(共有 条评论)
   
验证码: