GC的基本概念 - 垃圾回收系列(一)

66 阅读6分钟

GC的基本概念

我们先介绍一些垃圾回收的基本概念,通过问答的方式,对概念进行基本介绍。

一、对象的“生死判断”

🤔如何判断对象是否已“死”(是否为垃圾)

目前Java虚拟机的主流垃圾回收器采取的是可达性分析算法判断对象是否为垃圾。核心思想很容易理解,就是假如一个对象,无法通过任何手段引用到,那么它就是垃圾。具体实现,是通过 一系列称为“GC Roots”的根对象作为起始节点,通过引用关系一路搜索下去,搜索完成后,如果某个对象没有被“访问”过,则可以判定是一个垃圾。

可达性分析.png

🤔哪些对象可以作为“GC Roots”呢

“GC Roots”也就是根对象,这个根就很传神,也就是我们程序运行时,能够“抓住”的对象,包括但不限于:

  • 栈(虚拟机栈 or 本地方法栈)中引用的对象
  • 方法区中引用的静态对象 & 常量对象

二、垃圾回收算法

2.1 分代回收理论

分代回收理论,确切的说,是一种符合大多数程序运行实际情况的经验:

  1. 绝大多数对象都是朝生夕灭的,创建后很快就不再使用。
  2. 少数非朝生夕灭的对象,通常会存活很长时间,很难回收。

根据以上两条经验,就可以把JVM堆分成两部分,存储以上两种不同特性的对象,也就是我们常说的年轻代(Young Generation)和老年代(Old Generation)。再根据年轻代和老年代的特性,使用不同的算法,进行回收。

但跨带回收有个明显的问题,就是跨代引用,即年轻代可能会引用老年代,老年代也可能会引用年轻代,这就造成,假如我们对某一代回收时,理论上却需要对整个堆进行扫描,才能完成可达性分析。这无疑是非常耗时的,这时就需要引出以上两条经验的推论:

  1. 朝生夕灭的对象与长期存活的对象之间,很少会互相引用。这也符合直觉,存在互相引用关系的两个对象,是应该倾 向于同时生存或者同时消亡的。

有了这个推论,就可以通过某种数据结构(记忆集),记录这些少量的特殊引用,进而避免在可达性分析时,扫描整个堆。

2.2 标记回收算法

所谓标记,也就是可达性分析,标记出谁是垃圾,这是无论哪种回收算法都需要做的事情,毕竟回收前我们需要知道谁是垃圾才行。然后根据具体的回收策略,可以分为三种:

  1. 清除:把“死亡对象”所占据的内存标记为空闲内存。
    • 优点:仅处理“死亡对象”,如果垃圾很少,会比较快。
    • 缺点
      1. 需要额外的数据结构,也就是空闲链表(free list),记录零散的空闲内存;
      2. 内存分配效率低,需要结合空闲列表,寻找到合适的空间,存放新创建的对象;
      3. 存在内存碎片,可能剩余的空间足够,却找不到连续的空间,分配新创建的对象;
  2. 复制:把内存区域分为两等分,平时仅使用其中一个,发生垃圾回收时,把垃圾复制到另一块空闲区域。
    • 优点:无内存碎片;
    • 缺点:需要额外的空间用于复制,理论上会浪费50%的空间;
  3. 整理:把存活的对象,向内存的一个方向移动,也就是所谓的整理。
    • 优点:无内存碎片,也不需要额外的空间;
    • 缺点:耗时高。

2.3 分代 + 回收算法

下面我们结合一下分代回收思想,来看看年轻代、老年代,分别适合使用哪种算法。

  • 标记清除,清除“死亡对象”,适合“死的对象”比较少的场景,也就是老年代。CMS垃圾回收器就是采用这种算法。
  • 标记复制,移动“存活对象”,适合存活对象比较少的场景,也就是年轻代。这样移动对象的成本就非常小,也不需要额外准备一块一样大小的内存。分代回收拉机器,年轻代都是这种算法:
    1. 把年轻代划分为三部分,一个Eden区,以及两个大小相同的Survivor区(称之为S0/S1),默认比例为8:1:1(可通过-XX:Survivor-Ratio=N来调节)。
    2. 程序运行时,仅使用Eden区和一块Survivor区。
    3. 年轻代内存不足,触发垃圾回收后,经过可达性分析算法,扫描出哪些对象存活,复制到剩余的那块Survivor区。
    4. 如果剩余的Survivor空间不足,则由老年代进行“担保”,也就是进入老年代。
  • 标记整理,更适用于老年代(也不是不适用年轻代,只是有更好的复制算法),与标记清除各有优劣,优势不存在内存碎片,但是需要移动大量的老年代存活对象,是比较耗时的。

🤔怎样判断一个对象该在年轻代还是老年代呢

我们无法在对象刚被创建的时候,就确定该对象的生存特性。而是刚创建时,都放在年轻代(大对象除外),如果一个对象躲过了多次年轻代的回收,就“晋升”到老年代。

🤔多次怎么定义

JVM为每个对象定义了一个年龄(Age),存储在对象头中。初始为0,熬过一次垃圾回收年龄就加1,直到达到一定阈值(默认为15,可以通过-XX:MaxTenuringThreshold=N来设定,N<=15),就会进入老年代。 除此之外,为了能更好地适应不同程序的内存状况,除达到-XX:MaxTenuringThreshold设定的阈值外,还有一个规则,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

🤔什么是大对象,又为什么例外

大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串或者很长的数组。可以通过-XX:PretenureSizeThreshold=xxx(单位byte),来指定大对象阈值,默认是0,即都放在年轻代。

注意:-XX:PretenureSizeThreshold参数只对Serial和ParNew两款新生代收集器有效

🤔大对象为什么例外

年轻代采用复制算法,大对象很大,不仅复制起来很耗时,还容易撑爆Survivor区,导致剩余存活对象,全部进入老年代。