Java的垃圾回收

Java内存模型

Java把内存大概分为:方法区,虚拟机栈,本地方法栈,堆和程序计数器5个区。

程序计数器 是一块比较小的内存空间,可以看成是一个线程的运行的指示器,是一个线程私有的内存。由于JVM的运行时通过线程抢占CPU的时间片实现的(在任何一个时刻,一个CPU只能执行一个指令),如果存在线程的上下文切换,每个线程在被移除时间片的时候都需要记录执行的位置。如果当前正在执行的是Native方法,则不需要计数器来标记。

虚拟机栈也是线程私有的内存,Java代码在运行的时候,会对每一个方法创建一个栈帧,用于存储局部变量,操作数据,动态链接等数据。

本地方法栈 和虚拟机栈作用类似,本地方法栈是为了Java调用Native方法服务。

Java堆 是Java常用的引用对象的真实存储的区域,也是修改和访问最为频繁的区域。

Java的内存模型分类大致如下:

Java内存结构

垃圾回收算法


标记清除方法

标记清除算法是最基础的算法,算法分为“标记”和“清除”两个阶段: 先标记出需要回收的对象,然后再统一回收所有标记的对象。

标记清除算法的缺点主要有两个:一个效率比较慢,一个是会产生内存碎片。碎片太多会导致在分配较大对象的时候,无法找到连续的空间,而不得不提前触发一次垃圾回收。

复制算法

为了解决标记清除法的效率问题,出现了复制算法。把内存分为大小相同的两个部分。使用的时候,只是用其中的一块,每次进行垃圾回收的时候,就把存活的对象复制到另外一块内存,然后把当前的内存区域直接清空。

复制算法比较简单高效,但是带来的后果是内存的浪费,可使用的内存只有原来的半。

标记整理算法

结合上面两种算法的有点和缺点,标记整理的算法就能够很好的解决浪费和内存的碎片问题。

标记整理的算法,标记过程与标记清除算法相同,只是在回收的时候,把存活得对象都向一侧移动,然后直接清理掉边界以外的内存。

标记整理虽然解决了内存的碎片化的问题,但还是没有解决性能问题。

分代收集算法

分代收集算法是一个综合算法,根据不同对象的生存周期而采用上面三种算法。

Java一般把堆内存分为老年代和新生代,新生代存活率低,只需要少量存活得空间就可以了回收,则可以使用复制算法。

老年代存活率高,没有额外的空间担保,就只能是由标记整理或者标记清除方法。

HotSpot垃圾回收算法

在Hotspot的虚拟机中,如何实现垃圾内存的标记,首先我们需要理解一个概念:GC Root节点和可达性分析

GC Root节点包含以下几种类型:

 1. 虚拟机栈(栈帧中的本地变量表)中引用的对象。

 2. 本地方法栈中JNI(即一般说的native方法)引用的对象。

 3. 方法区中的静态变量和常量引用的对象。  

在Java中判断一个对象的是否存活,都是通过判断这个对象是否具有可达性,即变量是否被GC Root节点引用。

在HotSpot的虚拟机中,使用一组ooPMap的数据结构来记录对象的引用关系。

Java中常用的收集器


  1. Serial收集器

    Serial是最基本的,时间最长的垃圾收集器,采用复制算法回收。Serial在JDK1.3以前都已经在使用了,从名字可以看出Serial收集器是单线程工作的。单线程带来的问题是在程序在垃圾回收的时候,会出现停顿。 Serial收集器在有的场景中的有点点也很多,由于没有cpu上下文的切换,是的Serial收集器相对比较简单高效,短暂的暂停只要不是过于频繁,还是能够被接受的。

  1. ParNew收集器

    ParNew是Serial收集器的多线程版本,可以通过XX:ParallelGCThread参数来控制会受到的线程数,在单个CPU的环境下,由于存在线程的上下文的切换,所以性能不一定能保证优于Serial收集器。

  2. Parallel Scavenge 收集器

    Parallel Scavenge 收集器是新生代的收集器,也是采用复制算法。和其他收集器不同的是,Parallel Scavenge 收集器只关心吞吐量,而不关心GC的暂停时间。
    举一个简单的场景,如果一个垃圾回收过程,一次GC需要1s,如果分成4次,每次需要0.5s,两次GC的时间分别是1s和2s,对于程序的体验来后,后者的GC时间的停顿间隔低于前者,大多数GC回收期都会采用后面的回收机制,而对于Parallel Scavenge 收集器会选择前者,而不会选多次回收来降低GC的停顿时间。

    Parallel Scavenge 收集器 提供了两个参数来控制吞吐量,XX:MaxGCPausmillis控制停顿时间,-XX:GCTimeRatio设置吞吐量的大小。

  1. Serial Old 收集器

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

  1. Parallel Old收集器

    Parallel Old收集器是 Parallel Scavenge 收集器的一个老年代版本,采用标记整理算法。在JDK1.6版本中才开始提供,在此之前,如果新生代采用了Parallel Scavenge收集器,老年代回收除了Serial收集器之外别无选择。由于Serial收集器的效率拖累,所以Parallel Old收集器应运而生。

  2. CMS收集器

    CMS收集器是一种以获取最短回收停顿时间为目标的收集器,大部分运行在BS的服务端。CMS收集器主要有4个步骤:

    1. 初始标记
    2. 并发标记
    3. 重新标记
    4. 并发清除

      初始标记是仅仅标记一下GC Roots直接关联到的对象,速度很快。并发标记就是进行GC Roots Tracing的过程,重新标记是为了修正有变动的的对象。在初始标记和重新标记的时候,需要“Stop the world”。

  3. G1 收集器

    G1收集器是最新的收集器,也是面向服务端的垃圾收集器,G1具有一下几个特点:

    1. 并发和并行
    2. 分代手机
    3. 空间整合
    4. 可预测的停顿

      G1 收集器大致分为下面几个步骤:

    5. 初始标记

    6. 并发标记
    7. 最终标记
    8. 筛选回收

      与CMS对比,最终标记和CMS重新标记相同,不同的在于筛选回收,筛选回收是先对各个回收的Region的回收价值和成本进行综合排序,根据用户的期望来进行回收。

内存分配和垃圾回收的策略


在Java的堆内存中可以简单把内存分为如下结构:

Java内存结构

  1. 对象优先在Eden分配

    在大多数情况下,对象的分配优先在新生代Eden中分配,当Eden没有足够的空间进行分配的时候虚拟机触发一次Minor GC

  2. 大对象直接进入老年代

    大对象通常是指很长字符串和数组,新生代的内存无法安置,就直接进入老年代空间尽心存放,大对象内存的阈值可以用-XX:PretenureSizeThreshold参数来定义。

  3. 长期存活的对象进入老年代

    对象在Eden中出生,并经历了一次MinorGC后仍然存活,并且能够被Survivor存放的话,将会把对象从Eden区域移动到S1区域,年代就记为1岁。当年龄增加到一定(默认是15岁)就会被晋升到老年代,这个阈值可以通过-XX:MaxTenuringThreshold设置。

    为了更好的适应不同程序的内存情况,虚拟机并不是简单的要求对象的年龄必须达到某个阈值,如果在Survivor空间中相同年龄所有对象的大小综合大于Survivor空间的一半,则年龄大于或等于这个年龄的对象可以直接进入老年代。

  4. 空间分配担保

    为了保证Minor GC能够顺利执行,虚拟机会在MinorGC 回收前检查老年代最大可用的连续内存空间是否大于新生代所有对象总和,如果条件成立,该次MinorGC可以安全执行。

    如果不成立,虚拟机会查看是否允许担保失败,如果允许,则虚拟机会继续检查可用空间是否大于历次晋升到老年代的平均水平,如果大于,则尝试进行一次MinorGC,显然这次回收是有风险的,如果分配失败则会重新触发一次FULL GC。

    如果虚拟机设置不允许担保失败,则会进行一次FULL GC。