深入理解Java虚拟机之垃圾收集器与内存分配策略

概述

观察Java内存运行时区域的各个部分,其中程序计数器、Java虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。然而,堆和方法区中的内存清理工作就没那么容易了。 堆和方法区所有线程共享,并且都在JVM启动时创建,一直得运行到JVM停止时。因此它们没办法根据线程的创建而创建、线程的结束而释放。
堆中存放JVM运行期间的所有对象,虽然每个对象的内存大小在加载该对象所属类的时候就确定了,但究竟创建多少个对象只有在程序运行期间才能确定
方法区中存放类信息、静态成员变量、常量。类的加载是在程序运行过程中,当需要创建这个类的对象时才会加载这个类。因此,JVM究竟要加载多少个类也需要在程序运行期间确定。
这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存,本文后续讨论中的“内存”分配与回收也仅指Java堆和方法区的内存

对象已死吗?

在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(即不可能再被任何途径使用的对象)。
下面介绍两种判断对象是否存活的算法:

引用计数算法

给每个对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
但主流的Java虚拟机里面没有选用引用计数算法来管理内存,因为这种算法存在一个缺陷,即它无法解决对象之间相互循环引用的问题:
举个简单的例子,对象objA和objB都有字段
instance,赋值令objectA.instance = objectB 及 objectB.instance = objectA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为0,于是引用计数算法无法通知GC收集器回收它们。

public class ReferenceCountingGC {
    public Object instance = null;

    public static void main(String[] args) {
        ReferenceCountingGC objectA = new ReferenceCountingGC();
        ReferenceCountingGC objectB = new ReferenceCountingGC();
        objectA.instance = objectB;
        objectB.instance = objectA;
        objectA = null;
        objectB = null; 
      /*虽然已经无法访问到objectA和objectB,但它们互相引用,
      引用计数均为1,无法被回收*/
    }
}

可达性分析算法

这个算法的基本思路就是以一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,沿着引用链能够到达到的对象都是存活的,不可达的对象可被回收。
如下图所示,对象object 5、object 6、object 7虽然互相有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。

在Java语言中,可作为GC Roots的对象包括下面几种:
1、Java虚拟机栈(栈帧中的局部变量表,Local Variable Table)中引用的对象。
2、本地方法栈中JNI(即一般说的Native方法)引用的对象。
3、方法区中类静态属性引用的对象。
4、方法区中常量引用的对象。

回收无效对象的过程

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

具体步骤如下:

1)判断该对象是否覆盖了finalize()方法或是否执行过finalize()方法
若已覆盖该方法,并该对象的finalize()方法还没有被执行过,那么就会将对象放入F-Queue队列中;若未覆盖该方法,或已经调用过finalize(),则直接释放对象内存。

2)执行F-Queue队列中的finalize()方法
虚拟机会以较低的优先级执行这些finalize()方法,所谓的“执行”是指虚拟机会触发这个方法,但不会确保所有的finalize()方法都会执行结束。一个对象在finalize()方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃,此时虚拟机就直接停止执行,将该对象清除。

3)对象重生或死亡
finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。

试验覆盖finalize()方法进行自救

public class FinalizeEscapeGC{
    public static FinalizeEscapeGC instance=null;
    public void isAlive(){
        System.out.println("yes,i am still alive");
    }
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize methode execute");
        instance = this;  //把this赋给引用变量进行自救
    }

    public static void main(String [] args) throws InterruptedException {
        instance=new FinalizeEscapeGC();

        instance=null;
        System.gc(); //调用System.gc()后,建议JVM进行一次Full GC
        Thread.sleep(1000);
        instance.isAlive(); 
         //在没有重写finalize方法时,肯定是会报NullpointerException的

        instance=null;
        System.gc();
        Thread.sleep(1000);
        instance.isAlive();
    }
}

结果

finalize method executed
yes,i am still alive
Exception in thread "main" java.lang.NullPointerException
    at com.alioo.gc.FinalizeEscapeGC.main(FinalizeEscapeGC.java:29)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at com.intellij.rt.execution.application.AppMain.main(AppMain.java:134)

分析:先执行 instance = null,后执行instance.isAlive(), 在没有重写finalize方法时,肯定是会报NullPointerException的, 但是实际执行结果是一次逃脱成功,一次失败,这是因为任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法就不会再次执行。

注意:强烈不建议使用finalize()函数进行任何操作!如果需要释放资源,请使用try-finally。因为finalize()的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。

Java引用种类

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否可被回收都与引用有关。
在JDK 1.2以前,Java中的引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,但是太过狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态,对于如何描述一些“食之无味,弃之可惜”的对象就显得无能为力。我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。
在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱

强引用

我们平时所使用的引用就是强引用。 A a = new A(); 也就是通过关键字new创建的对象所关联的引用就是强引用。 只要强引用存在,该对象永远也不会被回收。
使用 new 一个新对象的方式来创建强引用。

Object obj = new Object();

软引用

软引用是用来描述一些还有用但并非必需的对象。被软引用关联的对象只有在内存不够即将抛出OutOfMemeryError的情况下才会被回收。如果这次回收还没有足够的内存,才会抛出OutOfMemeryError。软引用的生命周期比强引用短一些。
使用 SoftReference 类来创建软引用。

Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null;  // 使对象只被软引用关联

弱引用

弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,被弱引用关联的对象一定会被回收。
使用 WeakReference 类来实现弱引用。

Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;

虚引用

虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。
为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知
使用 PhantomReference 来实现虚引用。

Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj);
obj = null;

其中,上述四种引用中,软引用、弱引用都可以配合ReferenceQueue使用,虚引用则必须配合ReferenceQueue使用。见 ReferenceQueue的使用

方法区的内存回收

在方法区中进行垃圾收集的“性价比”一般比较低。在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,而方法区的垃圾收集效率远低于此。
方法区的垃圾收集主要回收两部分内容:废弃常量和无用的类

如何判定废弃常量?

清除废弃的常量和清除对象类似,只要常量池中的常量不被任何变量或对象引用,那么这些常量就会被清除掉。

如何判定废弃的类?

在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载功能,以保证不会出现内存溢出。
卸载废弃类的条件较为苛刻,需要满足以下三个条件,并且满足了也不一定会被卸载:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。
  • 加载该类的ClassLoader已经被回收。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

垃圾收集算法

现在我们知道了判定一个对象是无效对象、判定一个类是废弃类、判定一个常量是废弃常量的方法,也就是知道了垃圾收集器会清除哪些数据,那么接下来介绍如何清除这些数据。

标记-清除算法

最基础的收集算法是“标记-清除”(Mark-Sweep)算法,如同它的名字一样,算法分为“标记”和“清除”两个阶段:从GC Roots出发搜索,标记所有存活的对象,然后清理掉未被标记的对象。
之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。

它的主要不足有两个:

  • 标记和清除两个过程的效率都不高
  • 会产生大量不连续的内存碎片,导致无法给大对象分配内存

标记—清除算法的执行过程如下图所示:


复制算法

为了解决效率问题,一种称为“复制”(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

这种算法避免了碎片空间,但主要不足是只使用了内存的一半。 而且每次都需要将有用的数据全部复制到另一片内存上去,效率不高。

复制算法的执行过程如下图所示:

注意:在复制的时候会将存活对象复制到一片连续的空间上,因为复制算法的内存分配是通过“指针碰撞”方式实现的。

解决空间利用率问题

现在的商业虚拟机都采用复制算法来回收新生代但是并不是将新生代划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survivor 空间(From Survivor 和 To Survivor,From Survivor的内存被分配,To Survivor为空),比例为8 : 1 : 1,每次使用 Eden 空间和From Survivor。在回收时,会对 Eden 和 From Survivor 中的对象进行回收,Eden中的存活对象被复制到 To Survivor,From Survivor中的存活对象如果达到了晋升年龄,则进入老年代,否则也被复制到 To Survivor,接下来清空 Eden 和 From Survivor ,将 From Survivor 和To Survivor 的身份互换,结束。
因为HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90%,只需要浪费10%的内存空间即可实现带有压缩功能的垃圾收集方法,避免了内存碎片的问题。
如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间存储放不下的对象。

什么是分配担保?

当JVM准备为一个对象分配内存空间时,发现此时Eden中空闲的区域无法装下该对象,那么就会触发Minor GC,对Eden + From Survivor的废弃对象进行回收,并把存活对象复制到To Survivor区。但如果Minor GC过后只有少量对象被回收,To Survivor无法装下存活对象,那么此时需要将Eden + From Survior中的所有对象都转移到老年代中,然后再将新对象存入Eden区。这个过程就是“分配担保”。

标记-整理算法

标记-整理算法是一种老年代的垃圾收集算法。老年代中的对象一般寿命比较长,因此每次垃圾回收会有大量对象存活,因此如果选用“复制”算法,每次需要复制大量存活的对象,会导致效率很低。而且,在新生代中使用“复制”算法,当Eden+Survior中都装不下某个对象时,可以使用老年代的内存进行“分配担保”,而如果在老年代使用该算法,那么在老年代中如果出现Eden+Survior装不下某个对象时,没有其他区域给他作分配担保。因此,老年代中一般使用“标记-整理”算法。
根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,“标记-整理”算法的示意图如下图所示:

分代收集算法

当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,这种算法并不是一种具体的方法,而是一种思想,即根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

  • 新生代:每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
  • 老年代:因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。

Minor GC 和 Full GC

Minor GC:发生在新生代上,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。

  • 具体过程:Minor GC时,会对 Eden 和 From Survivor 中的对象进行回收,Eden中的存活对象被复制到 To Survivor,From Survivor中的存活对象如果达到了晋升年龄,则进入老年代,否则也被复制到 To Survivor,接下来清空 Eden 和 From Survivor ,将 From Survivor 和To Survivor 的身份互换,结束。
    特别要注意!永远不会直接在Survivor上分配内存!对象优先被分配到Eden,当Eden空间不足时会触发Minor GC,然后将存活对象复制到Survivor区,即Survivor区的空间只能作为复制的目的地,而不是直接分配的目的地。

  • 第一次Minor GC:第一次Minor GC时,From Survivor和To Survivor都是空的,所以只有Eden中的存活对象被复制到To Survivor,接下来To Survivor变成了From Survivor。之后进入正常的Minor GC过程。

  • 使用算法:Minor GC采用的是复制算法

  • 触发条件:当Eden空间不够,触发Minor GC

Full GC:发生在老年代上,由于老年代的对象几乎都是在 Survivor 区熬过来的,存活时间长。因此 Full GC 发生的次数不会有 Minor GC 那么频繁,并且 Time(Full GC)>Time(Minor GC)。

  • 使用算法:Full GC 采用的是 标记-清除 / 标记-整理 算法。
  • 触发条件:
    1)调用System.gc()
    系统建议执行Full GC,但是不必然执行。不建议使用这种方式,而是让虚拟机管理内存。
    2)老年代空间不足
    老年代空间不足的常见场景为大对象直接进入老年代、长期存活的对象进入老年代等。
    为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
    3)空间分配担保失败
    在分配担保时,需要将Eden和From Survivor的存活对象转存到老年代。如果老年代的可用内存小于这些对象所需的内存,且HandlePromotionFailure 设置了不允许空间分配担保失败,就会执行一次 Full GC。
    4)JDK 1.7 及以前的永久代空间不足
    在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。
    当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。
    为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。
    5)Concurrent Mode Failure
    执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。

内存分配策略

对象优先在Eden分配

大多数情况下,对象在新生代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC。

大对象直接进入老年代

所谓的大对象是指:需要大量连续内存空间的Java对象,最典型的大对象就是那种很长字符串以及数组。
经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。
通过设置-XX:PretenureSizeThreshold参数,使得大于此值的对象直接在老年代分配,避免在 Eden 区和 Survivor 区之间的大量内存复制。
注意:该参数只对Serial和ParNew收集器有效。

生命周期较长的对象进入老年代

老年代用于存储生命周期较长的对象,那么我们如何判断一个对象的年龄呢?
新生代中的每个对象都有一个年龄计数器,当新生代发生一次Minor GC后,存活下来的对象被复制到To Survivor,且年龄加一,当年龄超过一定值时,就将超过该值的所有对象转移到老年代中去。
使用-XXMaxTenuringThreshold设置新生代的最大年龄
设置该参数后,只要超过该参数的新生代对象都会被转移到老年代中去。

动态对象年龄判定

如果当前新生代的Survior中,年龄相同的对象的内存空间总和超过了Survior内存空间的一半,那么所有年龄相同的对象和超过该年龄的对象都被转移到老年代中去。无需等到对象的年龄超过MaxTenuringThreshold才被转移到老年代中去。

空间分配担保

在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。
如果不成立的话虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那么就要进行一次 Full GC。

垃圾收集器

以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。

  • 单线程与多线程:单线程指的是垃圾收集器只使用一个线程进行收集,而多线程使用多个线程
  • 串行与并行:串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并行指的是垃圾收集器和用户程序同时执行。除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。

1. Serial


Serial 翻译为串行,也就是说它以串行的方式执行。

它是单线程的收集器,只会使用一个线程进行垃圾收集工作。

它的优点是简单高效,对于单个 CPU 环境来说,由于没有线程交互的开销,因此拥有最高的单线程收集效率

它是 Client 模式下的默认新生代收集器,因为在用户的桌面应用场景下,分配给虚拟机管理的内存一般来说不会很大。Serial 收集器收集几十兆甚至一两百兆的新生代停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿是可以接受的。

2. ParNew


它是 Serial 收集器的多线程版本

Server 模式下的虚拟机首选新生代收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作

在JDK1.5 时期,HotSpot 推出了 CMS 收集器(Concurrent Mark Sweep),它是 HotSpot 虚拟机中第一款真正意义上的并发收集器。不幸的是,CMS 作为老年代的收集器,却无法与 JDK1.4.0 中已经存在的新生代收集器 Parallel Scavenge 配合工作,所以在 JDK1.5中使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。

默认开启的线程数量与 CPU 数量相同,可以使用 -XX:ParallelGCThreads 参数来设置线程数。

3. Parallel Scavenge


Parallel Scavenge 收集器是一个新生代收集器,它也是使用复制算法的收集器,又是串行的多线程收集器。

Parallel Scavenge 收集器以及后面提到的 G1 收集器都没有使用传统的 GC 收集器代码框架,而另外独立实现,其余集中收集器则共用了部分的框架代码。

与 ParNew 的不同之处:

其它收集器关注点是尽可能缩短垃圾收集时用户线程的停顿时间(响应时间),而它的目标是达到一个可控制的吞吐量,它被称为 吞吐量优先收集器

吞吐量指 CPU 用于运行用户代码的时间占总时间的比值
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)

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

缩短停顿时间是以牺牲吞吐量和新生代空间来换取的:新生代空间变小,垃圾回收变得频繁,导致吞吐量下降。

Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量:

  • 最大垃圾收集停顿时间: -XX:MaxGCPauseMills
  • 吞吐量大小:-XX:GCTimeRatio

MaxGCPauseMills 参数允许的值是一个大于0的毫秒数,收集器将尽可能地保证内存回收所花费的时间不超过设定值。但 GC 的停顿时间缩短是以牺牲吞吐量和新生代空间来换取的。停顿时间下降,但吞吐量也降下来了。

GCTimeRatio 参数的值是一个大于0且小于100的整数,也就是垃圾收集时间占总时间的比例,相当于吞吐量的倒数。区间 1 / (1+99) ~ 1 / (1+1),即 1% ~ 50%。

可以通过一个开关参数-XX:+UserAdaptiveSizePolicy打开 GC 自适应的调节策略(GC Ergonomics),就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了。虚拟机会根据当前系统运行情况收集性能监控信息,动态调整这些参数,以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomiscs)

4. Serial Old


Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用 ”标记-整理“ 算法。

这个收集器的主要意义也是在于给 Client 模式下的虚拟机使用。如果在 Server 模式下,那么它主要还有两大用途:

  • 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。

  • 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

5. Parallel Old


Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和 ”标记-整理“ 算法。

在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

6. CMS


CMS(Concurrent Mark Sweep),Mark Sweep 指的是 标记 - 清除 算法。CMS 是一款优秀的收集器,主要优点:并发收集、低停顿,Sun公司也称之为并发低停顿收集器(Concurrent Low Pause Collection)。

特点:并发收集、低停顿。

分为以下四个流程:

  • 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿(STW:Stop the World)。
  • 并发标记:进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
  • 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
  • 并发清除:不需要停顿。
    在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。

具有以下缺点:

吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。

无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。

标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。

CMS 提供了一个开关参数 -XX:+UseCMSCompactAtFullCollection(默认开启),用于在 CMS 收集器顶不住要进行 Full GC 时开启内存碎片的合并整理过程,内存整理的过程是无法并发的。
参数 -XX:CMSFullGCsBeforeCompaction 用于设置执行多少次不压缩的 Full GC后,跟着来一次带压缩的,(默认值为0)

7.G1

G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。

堆被分为新生代和老年代,其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。

G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。


通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。

每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。


如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:

  • 初始标记
  • 并发标记
  • 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
  • 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

具备如下特点:

  • 空间整合:整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
  • 可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。

比较

什么是内存泄露?

在 Java 中,内存泄漏就是存在一些被分配的对象,这些对象有下面两个特点:

  • 这些对象是可达的,即对象存在通路与“GC Roots”相连;
  • 这些对象是无用的,即程序以后不会再使用这些对象。

如果对象满足这两个条件,这些对象就可以判定为 Java 中的内存泄漏,这些对象不会被 GC 所回收,然而它却占用内存

在 C++ 中,内存泄漏的范围更大一些。有些对象被分配了内存空间,然后却不可达,由于 C++ 中没有 GC,这些内存将永远收不回来。在 Java 中,这些不可达的对象都由 GC 负责回收,因此程序员不需要考虑这部分的内存泄露。

通过分析,我们得知,对于C++,程序员需要自己管理边和顶点,而对于 Java 程序员只需要管理边就可以了(不需要管理顶点的释放)。通过这种方式,Java 提高了编程的效率。

Java中内存泄漏的根本原因:长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收。

Java中内存泄露的发生场景主要有以下几类:

① 静态集合类引起内存泄露

static Vector v = new Vector(10);
for (int i = 1; i<100; i++){
    Object o = new Object();
    v.add(o);
    o = null;
}

在这个例子中,我们循环申请Object对象,并将所申请的对象放入一个静态 Vector 对象中,静态变量的生命周期和应用程序一致,如果我们仅仅释放引用本身,那么 Vector 仍然引用该对象,所以这个对象对 GC 来说是不可回收的。因此,如果对象加入到Vector 后,还必须从 Vector 中删除,最简单的方法就是将 Vector 对象设置为 null。

② 当集合里面的对象属性被修改后,再调用 remove() 方法时不起作用

public static void main(String[] args){
    Set<Person> set = new HashSet<Person>();
    Person p1 = new Person("唐僧","pwd1",25);
    Person p2 = new Person("孙悟空","pwd2",26);
    Person p3 = new Person("猪八戒","pwd3",27);
    set.add(p1);
    set.add(p2);
    set.add(p3);
    System.out.println("总共有:"+set.size()+" 个元素!"); //结果:总共有:3 个元素!
    p3.setAge(2); //修改p3的年龄,此时p3元素对应的hashcode值发生改变

    set.remove(p3); //此时remove不掉,造成内存泄漏

    set.add(p3); //重新添加,居然添加成功
    System.out.println("总共有:"+set.size()+" 个元素!"); //结果:总共有:4 个元素!
    for (Person person : set){
        System.out.println(person);
    }
}

③ 监听器

在 Java 编程中,我们都需要和监听器打交道,通常一个应用当中会用到很多监听器,我们会调用一个控件的诸如 addXXXListener() 等方法来增加监听器,但往往在释放对象的时候却没有记住去删除这些监听器,从而增加了内存泄漏的机会。

④ 各种连接

比如数据库连接(dataSourse.getConnection()),网络连接(socket)和 IO 连接,除非其显式的调用了其 close() 方法将其连接关闭,否则是不会自动被 GC 回收的。

⑤ 单例模式

不正确使用单例模式是引起内存泄漏的一个常见问题,单例对象在初始化后将在JVM的整个生命周期中存在(以静态变量的方式),如果单例对象持有外部的引用,那么这个对象将不能被JVM正常回收,导致内存泄漏。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,378评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,356评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,702评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,259评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,263评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,036评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,349评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,979评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,469评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,938评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,059评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,703评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,257评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,262评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,501评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,792评论 2 345

推荐阅读更多精彩内容