4、垃圾收集算法

当JVM空闲时,自动回收每块可能被回收的内存,GC是完全自动的,不能被强制执行。程序员最多只能用System.gc()来建议执行垃圾回收器回收内存,但是具体的回收时间,是不可知的。

1. 标记-清除算法

这是 垃圾收集算法中 最最基础的算法。

1.1 算法思想

算法分为两个阶段:

标记阶段:标记出所有需要回收的对象;
清除阶段:统一清除(回收)所有被标记的对象。
下面主要讲解标记阶段。标记阶段主要分为:(先进行可达性分析)

第一次标记 & 筛选 执行finalize
第二次标记 & 筛选 放入F队列,F队列中的对象是否重新连接

1.2 优点

算法简单、实现简单

1.3 缺点

效率问题:即标记和清除两个过程效率不高
空间问题:标记 - 清除后,会产生大量不连续的内存碎片。

这导致 以后程序 需要分配较大空间对象时无法找到足够大的连续内存而被迫触发另外一次垃圾收集行为,这导致非常浪费资源。

1.4 应用场景

对象存活率较低 & 垃圾回收行为频率低 的场景

如老年代区域,因为老年代区域回收频次少、回收数量少,所以对于效率问题 & 空间问题不会很明显。

2、复制算法

该算法的出现是为了解决 标记-清除算法中 效率 & 空间问题的。

2.1 算法思想

  • 将内存分为大小相等的两块,每次使用其中一块;
  • 当使用的这块内存用完,就将 这块内存上还存活的对象 复制到另一块还没试用过的内存上
  • 最终将使用的那块内存一次清理掉

2.2 优点

  1. 解决了标记-清除算法中 清除效率低的问题:每次仅回收内存的一半区域
  2. 解决了标记-清除算法中 空间产生不连续内存碎片的问题:将已使用内存上的存活对象 移动到栈顶的指针,按顺序分配内存即可。

2.3 缺点

  • 每次使用的内存缩小为原来的一半。
  • 当对象存活率较高的情况下需要做很多复制操作,即效率会变低

2.4 应用场景

对象存活率较低 & 需要频繁进行垃圾回收 的区域

如新生代区域

2.5 特别注意

a. 背景

新生代区域在进行垃圾回收时,98%对象都必须得回收

b. 问题

复制算法中 每次使用的内存缩小为原来的一半 利用率低 & 代价太高

c. 解决方案

不 按 1:1的比例 划分内存,而是 按8:1:1比例 将内存划分为一块较大的 Eden 和两块较小的 Survivor 区域(From Survivor、To Survivor)

  • 每次使用Eden、From Survivor区域;
  • 用完后就 将上述两块区域存活的对象 复制到To Survivor区域上
  • 最终一次清理掉Eden、From Survivor区域使用逻辑 同 改进前

很多同学会问,假如 Eden、From Survivor区域上存活对象所需内存大小 > To Survivor区域怎么办?

解决方案:依赖老年代内存区域 做 内存分配担

即To Survivor区域 存不下来的对象 会通过 内存分配担保机制 暂时保存在老年代

3、标记-整理算法

3.1 算法思路

算法分为三个阶段:

  1. 标记阶段:标记出所有需要回收的对象;
  2. 整理阶段:让所有存活的对象都向一端移动
  3. 清除阶段:统一清除(回收)端以外的对象。

3.2 优点

  • 解决了标记-清除算法中 清除效率低的问题:一次清楚端外区域
  • 解决了标记-清除算法中 空间产生不连续内存碎片的问题:将已使用内存上的存活对象 移动到栈顶的指针,按顺序分配内存即可。

3.3 应用场景

对象存活率较低 & 垃圾回收行为频率低的场景

如老年代区域,因为老年代区域回收频次少、回收数量少,所以对于效率问题 & 空间问题不会很明显。

4. 分代收集算法

主流的虚拟机基本都采用该算法,下面会着重讲解。

4.1 算法思路

逐一标记和压缩 Java 虚拟机里的所有对象非常低效:分配的对象越多,垃圾回收需时就越久。不过,根据统计,大部分的对象,其实用没多久就不用了。根据之前的规律,就可以用来提升 JVM 的效率了。方法是,把堆分成几个部分(就是所谓的分代),分别是新生代、老年代,以及永生代.

根据 对象存活周期的不同 将 Java堆内存 分为:新生代 & 老年代 。分配比例如下:

老年代存活率高使用标记整理或者标记清除,年轻代少量存活使用复制算法

特别注意

有时候survivor被称为From Survivor和To Survivor,他们之间会经常互换角色:每次发生GC时,把Eden区和 From Survivor区中 存活且没超过年龄阈值的对象 复制到To Survivor区中(此时To Survivor变成了From Survivor),然后From Survivor清空(此时From Survivor变成了To Survivor)

两块区域特点 选择 对应的垃圾收集算法(即上面介绍的算法),具体细节请看下图

4.2 具体存储过程

新对象会被分配在新生代内存。一旦新生代内存满了,就会开始对死掉的对象,进行所谓的小型垃圾回收过程。一片新生代内存里,死掉的越多,回收过程就越快;至于那些还活着的对象,此时就会老化,并最终老到进入老年代内存。

Stop the World 事件 —— 小型垃圾回收属于一种叫 “Stop the World” 的事件。在这种事件发生时,所有的程序线程都要暂停,直到事件完成(比如这里就是完成了所有回收工作)为止(停止也不是随意停止,当到达安全点的时候暂停,OopMap才会记录信息)。停顿的时候不是全部遍历,这样太麻烦了。而是枚举根节点时,递归遍历每个栈帧的 OopMap(数据结构) ,通过栈中记录的被引用对象的内存地址,即可找到这些对象( GC Roots )。这是执行的线程,如果线程不执行,那么在安全区域内开始GC是安全的。

老年代用来保存长时间存活的对象。通常,设置一个阈值,当达到该年龄时,年轻代对象会被移动到老年代。最终老年代也会被回收。这个事件成为 Major GC。

Major GC 也会触发STW(Stop the World)。通常,Major GC会慢很多,因为它涉及到所有存活对象。所以,对于响应性的应用程序,应该尽量避免Major GC。还要注意,Major GC的STW的时长受年老代垃圾回收器类型的影响。

永久代包含JVM用于描述应用程序中类和方法的元数据。永久代是由JVM在运行时根据应用程序使用的类来填充的。此外,Java SE类库和方法也存储在这里。

如果JVM发现某些类不再需要,并且其他类可能需要空间,则这些类可能会被回收。

介绍

  1. 新建的对象 一般会被优先分配到新生代的Eden区、From Survivor区
  2. 大对象(如很长的字符串以及数组)会直接分配到老年代,这是为了避免在 Eden 区 和 Survivor区之间发生大量的内存复制(因为新生代会采用复制算法进行垃圾收集)
  3. 这些对象经过第一次 Minor GC后,若仍然存活,将会被移到To Survivor区
  4. 一次清理掉Eden、From Survivor区域
  5. 在 To Survivor 区每经过一轮 Minor GC ,该对象的年龄就+1
  6. 当对象年龄达到一定时(阈值默认=15),就会被移动到老年代。
  • 即新生代的对象在存活一定时间后,会被移动存储到老年代区域。
  • 还有一种 新生代对象被移懂到老年代区域 的情况是:动态对象年龄判定。即如果在Survivor区中 所有相同年龄对象的大小总和 大于Survivor区内存大小一半时,所有大于或等于该年龄的对象都会直接进入老年代。

世代垃圾收集过程

首先,将任何新对象分配给 eden 空间。 两个 survivor 空间都是空的。

当 eden 空间填满时,会触发轻微的垃圾收集。

引用的对象被移动到第一个 survivor 空间。 清除 eden 空间时,将删除未引用的对象。

在下一次Minor GC中,Eden区也会做同样的操作。删除未被引用的对象,并将被引用的对象移动到Survivor区。然而,这里,他们被移动到了第二个Survivor区(S1)。

此外,第一个Survivor区(S0)中,在上一次Minor GC幸存的对象,会增加年龄,并被移动到S1中。待所有幸存对象都被移动到S1后,S0和Eden区都会被清空。注意,Survivor区中有了不同年龄的对象。

在下一次Minor GC中,会重复同样的操作。不过,这一次Survivor区会交换。被引用的对象移动到S0,。幸存的对象增加年龄。Eden区和S1被清空。

在较小的GC之后,当老化的物体达到一定的年龄阈值(在该示例中为8)时,它们从年轻一代晋升到老一代。

随着较小的GC持续发生,物体将继续被推广到老一代空间。

所以这几乎涵盖了年轻一代的整个过程。最终,将主要对老一代进行GC,清理并最终压缩该空间。

4.3 优点

效率高、空间利用率高

根据不同区域特点选择不同的垃圾收集算法

4.4 应用场景

现在主流的虚拟机基本都采用 分代收集算法 ,即根据不同区域特点选择不同垃圾收集算法。

新生代 区域:采用 复制算法

老年代 区域:采用 标记-清除 算法、标记 - 整理 算法

4.5 GC 触发条件

Minor GC触发条件:

当Eden区满时,触发Minor GC。

Full GC触发条件:

  1. 调用System.gc时,系统建议执行Full GC,但是不必然执行
  2. 老年代空间不足
  3. 方法去空间不足
  4. 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
  5. 由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

5、HotSpot算法实现

根据垃圾回收算法和判定对象存活的原理来实现HotSpot。

1、枚举根节点 我们知道,对象可达性分析中GC Roots根节点主要包括栈和方法区所引用的对象。那么实际设计中如何逐个检查这些引用呢?

为了保证准确性,显然我们在枚举根节点的时候,应该停止所有的Java用户线程。
(Stop-The-World,使整个分析过程,系统好像冻结到某个时间点),为了让这个时间尽量短(否则用户线程阻塞太久),主流的虚拟机都是采用准确式GC,并不需要挨个扫描方法栈,就可以得知哪些位置上存放着对象引用。这个又是如何实现的呢?

在HotSpot的实现中,是使用一组称为OopMap的数据结构来达到这个目的的,在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC在扫描时就可以直接得知这些信息了。

OopMap 记录了栈上本地变量到堆上对象的引用关系。

其作用是:垃圾收集时,收集线程会对栈上的内存进行扫描,看看哪些位置存储了 Reference 类型。如果发现某个位置确实存的是 Reference 类型,就意味着它所引用的对象这一次不能被回收。但问题是,栈上的本地变量表里面只有一部分数据是 Reference 类型的(它们是我们所需要的),那些非 Reference 类型的数据对我们而言毫无用处,但我们还是不得不对整个栈全部扫描一遍,这是对时间和资源的一种浪费。 它的另外一个更根本的作用是,可以帮助 HotSpot 实现准确式 GC。

2、安全点

一个线程意味着一个栈,一个栈由多个栈帧组成,一个栈帧对应着一个方法,一个方法里面可能有多个安全点。

gc 发生时,程序首先运行到最近的一个安全点停下来,然后更新自己的 OopMap ,记下栈上哪些位置代表着引用。

枚举根节点时,递归遍历每个栈帧的OopMap,通过栈中记录的被引用对象的内存地址,即可找到这些对象( GC Roots )。

前面我们知道了OopMap的概念,然而为每一条指令的位置都生成对应的OopMap显然不显示。前面提到的“特定位置”即安全点:程序执行并非所有地方都可以停下来GC,只有到达安全点才可以。

关于安全点的选择? 既不能让GC等待太久,也不能太过频繁增加负荷。 普通指令执行很快,一般遇到“长时间执行”的指令才会产生安全点,包括:方法调用、循环跳转等。

GC时如何让所有线程跑到安全点再停下来?抢先式中断和主动式中断。

抢先式中断:一般不采用。GC时暂停所有线程,如果发现有线程没在安全点,则让它跑到安全点。

主动式中断:GC中断线程不直接对线程操作,而是设置一个中断标志位。线程在每一个安全点检查这个标志位即可。

3、安全区域

考虑一下,程序不执行的时候(没有分配到cpu时间片)如何跑到安全点呢? 于是,提出了扩展的安全点——安全区域的概念。

安全区域:一段代码中,对象引用没有发生变化,任何地方开始GC都是安全的。
当线程执行到安全区域时,首先标识自己已经进入安全区域,这中间如果发生GC,就不用管标识为安全区域的线程了。 线程离开安全区域之前,需要确定自己已经完成了根节点枚举的过程,否则必须等待完成。