JVM 垃圾收集器与内存分配策略

JVM 垃圾收集器与内存分配策略

Java是自动内存管理的,但了解相关的内存分配与回收机制仍然很重要。

在Java运行时数据区域中,程序计数器、虚拟机栈和本地方法栈都随着线程创建而创建,随着线程消亡而消亡,栈中的栈帧也随着方法的进入和退出而入栈和出栈,这部分的内存回收在编译后就是确定的。而堆和方法区中则有明显的不确定性,只有在运行时才能确定,内存的分配和回收都是动态的。通常意义上的垃圾回收都是指的对堆和方法区的回收。

判断对象可回收

垃圾回收前首先要确定哪些对象是可回收的。主要有两种方法:

引用计数法

即在对象中添加一个引用计数器,每有一个对象引用它,引用计数器就加一;当引用失效时,计数器就减一。引用计数器为0的对象就是可回收的。

引用计数法虽然使用了一些额外的内存空间,但原理简单、效率也高,但Java并没有使用这种方法。因为这种方法要配合大量的额外处理才能正确工作,如当初的计数引用就无法解决相互循环引用的问题。

可达性分析算法

通过将一系列称为GC Roots的根对象作为起始节点集,从这些节点通过引用关系向下搜索,搜索路径称为引用链,如果从GC Root到一个对象没有一条引用链相连,即从GC Root到这个对象不可达,则这个对象是可回收的。

可作为GC Roots的对象有:

  1. 在虚拟机栈中引用的对象
  2. 在方法区中类静态属性对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中引用的对象
  5. Java虚拟机内部应用
  6. 所有被synchronized持有的对象

引用分类

Java中将引用分为四种:强引用、软引用、弱引用、虚引用。

  • 强引用

    强引用指的是程序代码中最常见的引用赋值,即Object obj = new Object(),无论何时,只要有强引用关系存在,垃圾收集器永远不会回收被引用的对象

  • 软引用

    软引用用来描述一些还有用但非必需的对象。只被软引用关联的对象,在将要发生内存溢出前会被回收。JDK提供了SoftReference类来实现软引用。

    场景:实现内存敏感的缓存,当有空闲内存时会暂时保留,当内存不足时被清理

  • 弱引用

    弱引用也用来描述非必须的对象,但强度比软引用跟弱一些,被弱引用关联的对象只能生存到下次垃圾回收时。当垃圾回收开始时,无论内存是否足够,都会回收只被弱引用关联的对象。JDK提供了WeakReference类来实现弱引用

    场景:内存敏感的缓存

  • 虚引用

    虚引用是最弱的一种引用关系,一个对象是否有虚引用不会对其生命周期造成影响,也无法通过虚引用获得对象实例。设置虚引用只是为了对象被回收时收到一个通知。JDK提供了PhantomReference类来实现虚引用。

    场景:可用来跟踪对象被垃圾回收的活动,当一个被虚引用关联的对象被回收时会收到一条系统通知

回收类

回收一个实例相对简单,但回收一个类型条件比较苛刻,需要同时满足下面三个条件:

  • 该类的所有实例都已经被回收
  • 该类的类加载器已经被回收
  • 该类的类对象,即Class对象没有在其他地方被引用

垃圾收集算法

分代收集理论

即:收集器将Java堆分为不同的区域,根据对象的年龄分配到不同的区域中存储。一般分为新生代老年代两个区域。

Java堆被划分为不同区域后,垃圾收集器可以每次只回收其中一个或几个区域,才有了Minor GcMajor GCFull GC,才可以根据不同的区域采用不同的垃圾回收算法:标记-清除、标记-复制、标记-整理。

1
2
3
4
5
部分收集(Partial GC): 指不是完整收集整个Java堆的垃圾收集,又分为
新生代收集(Minor GC)
老年代收集(Major GC): 只有CMS收集器会有单独收集老年代的行为

整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集

标记-清除算法

标记-清除算法分为标记和清除两个部分,首先标记出所有要回收的对象,再统一回收被标记的对象。

其主要有两个缺点:

  1. 执行效率不稳定:如果堆中包含大量要被回收的对象,就必须要进行大量的标记和清除的动作,导致标记和清除的效率随对象数量的增长而降低;
  2. 产生大量内存碎片:标记、清除后会产生大量的内存碎片,可能会导致没有足够的连续内存分配,从而触发另一次垃圾回收。

标记-复制算法

标记-复制算法将内存按容量分为大小相等的两块,每次只使用其中一块,当一块内存用完了,就将还存活的对象复制到另一块内存中,然后把已使用的一块内存清除。

如果内存中大部分对象都是存活的,复制算法会产生大量的复制对象的开销;如果存活对象很少,复制算法开销就较小。而且因为每次都是回收整个半区,所以不会产生内存碎片。但是,可用内存缩小为原来的一半,空间浪费严重!

因为新生代存活对象很少的特点,还有一种优化的复制分区策略:

将新生代分为一块较大的Eden区和两块较小的Survivor空间。每次分配内存只使用Eden和一块Survivor空间。当发生垃圾回收时,将存活的对象复制到另一块Survivor上,直接清理掉Eden和已使用的那块Survivor空间。

当然,上面这种策略需要使用老年代的内存空间作为内存分配担保,当另一块Survivor空间不足时,直接将存活对象复制到老年代。

标记-整理算法

标记-复制算法在对象存活率较高时需要复制大量对象,效率较低,并且如果不想浪费一半空间,需要使用额外空间进行担保,所以老年代一般不会使用这种方法。

标记-整理算法在标记后,不是直接将可回收对象清除,而是让所有存活对象向一端移动,然后直接清理边界以外的内存。

移动存活对象并更新所有引用这些对象的引用,尤其是老年代这种存活率较高的区域,需要暂停所有的用户程序,开销较大。但如果像标记-清除算法那样不移动对象,又会产生内存碎片,要依赖更复杂的内存分配机制和内存访问机制。

移动则内存回收更复杂,不移动则内存分配更复杂。这就需要根据不同的场景进行权衡。

另一种综合的方法是让多数时间内采用标记-清除算法,直到内存的碎片化程度影响对象分配时,再采用标记-整理算法收集。这也是CMS垃圾收集器采用的策略。

常见垃圾收集器

Serial收集器

Serial收集器是单线程工作的收集器,在它进行垃圾收集时,必须暂停其他所有工作线程,直到收集完成。

它是Client场景下的默认新生代收集器,在该场景下内存不会太大,延时可以可以接受。

ParNew收集器

ParNew收集器可以看成是Serial收集器的多线程版本。

它是Server场景下默认的新生代收集器,并且只有它能与CMS收集器配合使用。

Parallel Scavenge收集器

ParNew一样是多线程收集器,其他收集器是为了缩短收集时用户线程的停顿时间,而它是为了提高吞吐量。这里吞吐量是指CPU用于用户线程的时间与总时间之比。

Parallel Scavenge收集器可以通过一个参数打开GC自适应的调节策略,不需要手动设置新生代的大小、Eden与Survivor的比例、晋升老年代的年龄等参数,虚拟机会自动根据系统的运行情况收集监控信息,动态调整以获得最大的吞吐量。

Serial Old收集器

![](https://f1bu920.github.io/images/Serial Old收集器.JPG)

是Serial的老年代版本,主要供客户端模式下的虚拟机使用。

如果在服务端模式下,主要有两种用途:

  1. 在JDK5之前版本中与Parallel Scavenge收集器配合使用;
  2. 作为CMS收集器发生Concurrent Mode Failure时的后备预案。

Parallel Old收集器

![](https://f1bu920.github.io/images/Parallel Old收集器.JPG)

是Parallel Scavenge收集器的老年代版本。

在注重吞吐量的场景下,优先考虑Parallel Scavenge加上Parallel Old收集器的组合。

CMS收集器

CMS:Concurrent Mark Sweep,使用的是标记-清除算法。分为四个流程:

  1. 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要停顿
  2. 并发标记:从GC Roots直接关联的对象开始遍历整个对象图的过程,耗时较长但不需要停顿;
  3. 重新标记:修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿;
  4. 并发清除:清理标记阶段被判定已经死亡的对象,不需要停顿。

CMS收集器追求的是低停顿,举有以下缺点:

  • 吞吐量低:低停顿是以牺牲吞吐量为代价的,导致CPU利用率不高
  • 无法处理浮动垃圾:在并发标记和并发清除期间,用户程序继续运行,就会产生垃圾对象,而这部分垃圾只能到下一次垃圾收集时才能清理,这就叫做浮动垃圾。因为浮动垃圾的存在,需要预留一部分内存,不能像其他垃圾收集器一样等到老年代快满时再回收,如果预留的内存空间不足,就会导致Concurrent Mode Failure,冻结用户程序,临时采用Serial Old收集器来重新进行老年代的收集。
  • 标记-清除算法导致的内存碎片:当找不到足够的内存来分配大对象时,就会提前触发一次Full GC

G1收集器

G1收集器的主要关注点在于达到可控的停顿时间并在这基础上尽可能提高吞吐量。G1收集器开创了收集器面向局部收集的思路和基于Region的内存布局形式,是一款面向服务端的垃圾收集器,可用于整堆收集。

Hotspot堆布局

其他收集器都是收集整个新生代或者老年代,而G1可以对整个新生代和老年代一起回收。

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

G1通过Region把内存分为了一块块的小空间,每块小空间都可以进行单独内存回收。G1收集器跟踪每个Region里面垃圾堆积的价值大小,即回收所获得空间大小与回收时间的比值,然后维护一个优先级列表,每次根据用户设定允许的收集停顿时间(-XX:MaxGCPasueMills)优先处理收益最高的Region

G1会比CMS消耗更多的内存,因为每个Region都有一个RememberedSet,用来记录该Region对象的引用对象所在的Region,在可达性分析时就可以避免全表扫描。

G1收集器的运行可分为4个步骤:

  1. 初始标记:标记GC Roots能直接关联到的对象,需要停顿但耗时短
  2. 并发标记:从GC Roots开始进行可达性分析,递归扫描整个堆中的对象图,耗时较长但不需要停顿
  3. 最终标记:修正在并发标记期间因程序继续运行而标记产生变动的那部分记录,需要停顿但可并行执行
  4. 筛选回收:对各个Region的回收价值和成本进行排序,根据用户期望停顿时间来制定回收计划。把回收那一部分的Region中的存活对象复制到空的Region中,再清除旧的Region的全部空间,需要暂停用户线程但可并行执行。

特点:

  • 空间整合:整体上是基于标记-整理算法实现的,但局部(两个Region之间)上看是基于标记-复制算法的,这代表着不会有内存碎片。
  • 可预测的停顿:用户可设置不同的期望停顿时间来取得吞吐量和延迟的最佳平衡。
-------------本文结束感谢您的阅读-------------