简说Java的垃圾回收

在进行垃圾回收前,我们首先要能够判断一个Java对象是不是还有存在的必要,只有不再使用的对象才应该被回收。判断的方法有两种:

  1. 引用计数法
  2. 可达性分析

引用计数法通过给对象中添加一个引用计数器,每当有一个地方引用它时,计数器的值就加1,当失去引用时,计数器的值就减1,当计数器值变为0就认为不再使用。这种方法的优点是实现起来简单,并且效率也很高,但是缺点也很明显,就是很难解决对象之间相互循环引用的问题。

可达性分析通过一系列称为 GC Roots 的对象作为起始点,从这些起始点开始向下搜索,搜索所走过的路径我们称为引用链,当一个对象到 GC Roots 没有任何引用链相连时,我们判定该对象是不可用的。所以,什么样的对象可以作为GC Roots呢,大概有下面四种:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中JNI引用的对象

在Java中,判定一个对象是否应该被回收采用的是第二种方法,也就是可达性分析

两次标记:对象通过可达性分析确定不可达后,并不会立即被判死刑,还会判断此对象是否有必要执行finalize方法,如果需要执行该方法,那么这个对象会被放到一个叫F-Queue的队列中,而判断的标准有两个,一是对象本身要覆盖了finalize方法,二是finalize方法还未被虚拟机调用过。F-Queue中的对象稍后会由一个虚拟机自动建立的、低优先级的Finalizer线程去触发对象的finalize方法,但不承诺方法运行结束。上面的过程也就是第一次标记。

第一次标记结束后,稍后GC会对F-Queue中的对象进行第二次小规模标记。在第二次标记中,如果一个对象还是不可达的,那它真的是要被回收了。

在确定了哪些对象应当被回收后,接下来要做的就是如何来回收。

分代垃圾回收

在Java中,无法手动的申请和释放一块内存。但是,我们可以调用System.gc()方法来触发一次垃圾回收动作。尽管如此,还是应该极力避免使用该方法,因为它将触发的是一次Full GC,这会严重影响系统的性能,我们接下里也会详细介绍为啥要尽量避免Full GC。

在Java中,一个对象从出生到死亡,大多都满足下面两个规律(或者说是假设):

  • 大部分对象创建后很快会不再使用,并且变得不可达
  • 创建时间较长的对象只有很少的一部分会引用新生对象

这些假设我们称为弱年代假说(weak generation hypothesis),并且,为了强化这一假设,HotSpot虚拟机中物理上划分出了新生代(young generation)和老年代(old generation)两个区域。

新生代:绝大多数新创建的对象都会被分配到这里。由于大部分对象创建后很快会变为不可达,所以许多对象都是直接在年轻代创建,之后就被回收了。在年轻代中,对象的回收我们称之为minor GC

老年代:在新生代中存活下来(熬过数次minor GC)的对象,会被拷贝到这里。另外,对于一些需要大量连续内存空间的对象,即所谓的大对象,也会直接在老年代分配。通常情况下,老年代其所占用的空间要比新生代多。也正因此,老年代上GC的频率要比新生代小得多。当老年代发生垃圾回收动作时,我们称之为major GC(或叫full GC)。

看下面这个图:

GC Area & Data Flow.

上图中的持久代(permanent generation)也被称为方法区(method area),用来保存类信息、字符串常量等数据,这个区域也可能发生GC,并且发生在这个区域上的GC也会被算为full GC。在这个区域中要回收的内容有两块,一个是废弃的常量,另一个是无用的类。判断是否为废弃常量相对简单,也是通过可达性分析,而判断类是否还有用则相对复杂,一般来说,需要满足下面三个条件:

  1. 该类所有的实例已经被回收
  2. 加载该类的ClassLoader已经被回收
  3. 该类对应的Class对象没有在任何地方引用,并且通过反射无法访问该类的方法

Java中将内存物理划分成了新生代和老年代,那不禁有人要问:

如果老年代的对象需要引用一个新生代的对象,会怎么样呢?

为了解决这个问题,老年代中使用了一个称为卡表(card table)的数据结构,卡表是一个比特位的集合,每一个比特位用来表示老年代某一区域中的对象是否持有新生代对象的引用。当新生代执行GC的时候,可以先扫描卡表,只有卡表的标记位为1时,才需要扫描给定区域的老年代对象,而不用查询整个老年代。卡表由写屏障(write barrier)来管理,虽然写屏障使得应用线程增加了一些性能开销,但Minor GC变快了许多,整体的垃圾收集效率也提高了。

48cdc6b502cde95307996cb9a68caf79f5b45bf7

新生代GC

由于新生代中对象98%都是朝生夕死的,因此,在新生代中进行垃圾回收会采用复制算法。

复制算法:将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活的对象复制到另一块上面,然后把已使用过的内存空间一次清理掉。该算法的优点是简单、高效,缺点是内存使用率不高。

具体来说,是将新生代内存划分为三个空间:

  • 一个伊甸园空间(Eden )
  • 两个幸存者空间(Survivor )

默认情况下,这三个空间的比例大小是8:1:1,在实际使用时,它们会按照下面的顺序交互执行:

  1. 绝大多数刚刚被创建的对象会存放在Eden空间。
  2. 在Eden空间执行了一次GC后,存活的对象被移动到其中一个Survivor空间,我们暂称为S1空间。
  3. 此后,在Eden空间每次执行GC,存活的对象还会被堆积到S1空间。
  4. 当S1空间饱和后,还存活的对象会被移动到另一个Survivor空间,暂称为S2空间。
  5. 清空已经饱和的S1空间。
  6. 在以上的步骤中重复几次依然存活的对象,就会被移动到老年代。

在整个过程中,始终有一个Survivor空间是空的。可以用下图来描述通过频繁的Minor GC将数据移动到老年代的过程:

Before & After a GC.

为了加快内存分配,在HotSpot虚拟机中使用了两种技术,一个是指针碰撞(Bump-the-pointer),一个是本地线程分配缓冲(TLAB:Thread-Local Allocation Buffers)

Bump-the-pointer是内存分配的一种算法,这个算法可以简单的描述为:假设Java堆中的内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。这种算法可以极大地加快内存分配速度。但是,在多线程的环境下,就可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题的一种方案是加锁,对分配内存空间的动作进行同步处理,但这比较影响性能。另外一种方案就是使用本地线程分配缓冲。

TLAB为每一个线程在Eden空间分配一块独享的空间,这样每个线程只访问他们自己的TLAB空间,再与Bump-the-pointer技术结合,从而可以做到在不加锁的情况下高效分配内存。

在新生代中,我们可选的垃圾收集器有下面三种:

  1. Serial
  2. ParNew
  3. Parallel Scavenge
Serial GC (-XX:+UseSerialGC)

这个收集器是一个单线程的收集器,当它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。用行话,我们又叫做stop the world,需要说明的一点是,无论选择何种垃圾回收算法,stop the world 都是无法避免的,我们能做的就是尽量减少stop the world的时间。

最后还要特别注意的是,Serial GC不应该被用在服务器上。这种GC类型在单核CPU的桌面电脑时代就存在了。使用Serial GC会显著的降低应用的性能。

ParNew GC(-XX:+UseParNewGC)

ParNew 收集器与 Serial最大的区别就是提供了多线程的支持。它可以用于生产环境,并且可以跟CMS收集器搭配使用。ParNew收集器在默认情况下开启的收集线程数与CPU的数量相同,如果是在单CPU环境下,由于存在线程交互的开销,因此,它的收集效果未必比Serial好。

Parallel Scavenge(-XX:+UseParallelGC)

Parallel Scavenge收集器的关注点是达到一个可控制的吞吐量(Throughput)。高吞吐量可以高效的利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

如果使用这个收集器,有三个参数是需要了解的:

  • -XX:MaxGCPauseMillis 用来控制最大垃圾收集停顿时间
  • -XX:GCTimeRatio 直接设置吞吐量大小
  • -XX:+UseAdaptiveSizePolicy 开关参数,打开后虚拟机会根据上面两个目标,自动调整运行参数

其中,前面两个参数用于精确控制吞吐量。

老年代GC

老年代中的对象一般都经过了数次Minor/Major GC,具有很强的生命力,并且,在老年代中发生GC的频率要远远低于新生代。所以,对于老年代中的垃圾回收,一般不会采用复制算法,而是标记-清除或标记-整理算法。

标记-清除(Mark-Sweep):算法分为两步,第一步还是标记出所有需要回收的对象,第二步是在标记完成后统一回收所有被标记的对象。主要缺点:1、标记和清除两个过程的效率都不高。2、标记清除之后会产生大量不连续的内存碎片。

标记-整理(Mark-Compact):仍然是分为两步,第一步还是标记出所有要回收的对象,但后续不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。优点就是避免了内存碎片的产生。

老年代中可选的垃圾收集器有下面三种,除此以外,还有一个G1,这个比较特殊,我们放在最后再说。

  1. Serial Old
  2. Parallel Old
  3. Concurrent Mark & Sweep (or “CMS”)
Serial Old GC(-XX:+UseSerialGC)

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”算法。这个收集器目前主要使用在Client模式下的虚拟机上。当然也能够用在Server模式下,这个时候就是作为CMS收集器的后备预案来用,如果CMS收集器出现Concurrent Mode Failure,则Serial Old收集器将作为后备收集器。

Parallel Old GC(-XX:+UseParallelOldGC)

Parallel Old GC在JDK 1.6 中才开始提供,与Parallel GC相比,唯一的区别在于针对老年代的GC算法。Parallel Old GC采用了标记-整理算法,在注重吞吐量及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。

CMS GC (-XX:+UseConcMarkSweepGC)

Serial GC & CMS GC.

CMS应该是目前介绍的收集器中最为复杂的一个,它以获取最短回收停顿时间为目标,并且被大量应用于服务器端。CMS的运作过程大致可以分为4个步骤:

  1. 初始标记(initial mark),需要 Stop the world
  2. 并发标记(concurrent mark),不需要 Stop the world
  3. 重新标记(remark),需要 Stop the world
  4. 并发清除(concurrent sweep),不需要Stop the world

在第一步初始标记中,只是查找那些 GC Roots 能直接关联到的对象,这个过程很快,因此,停顿的时间也非常短暂。之后的并发标记中,从 GC Roots 开始对堆中对象进行可达性分析,找出存活的对象。这一步的重点在于:并发标记的过程中,其他的线程依然在执行。最后会执行重新标记,这一步的目的是修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。这一步同样会产生停顿,但是停顿时间比并发标记的时间要短的多。最后,在并发清除阶段,垃圾回收工作会与用户其他线程一起并发执行。

整体上来看,由GC导致的暂停时间会极其短暂,因此,CMS GC也被称为低延迟GC。它经常被用在那些对于响应时间要求十分苛刻的应用之上。

当然,CMS在拥有stop-the-world时间很短的优点的同时,也有如下几个缺点:

  • 它会比其他GC类型占用更多的内存和CPU
  • 无法处理浮动垃圾
  • 默认情况下不支持压缩步骤,会产生内存碎片

G1 GC (-XX:+UseG1GC)

最后,我们来看看G1收集器,全名为Garbage-First,是一款面向服务端的收集器。

Layout of G1 GC.

G1的原理与我们之前所学过的几个收集器都不一样。正如上图所看到的,它会将整个Java堆划分为多个大小相等的独立区域(Region,图中每个格子代表一个Region),每个对象被分配到不同的Region。当一个区域装满之后,对象被分配到另一个区域。在G1中仍然保留了新生代和老年代的概念,但它们不再是物理隔离的了,而都是一部分Region的集合,并且这些Region允许是不连续的。

G1的运作大致可以分为如下几个步骤:

  1. 初始标记(Initial Marking)
  2. 并发标记(Concurrent Marking)
  3. 最终标记(Final Marking)
  4. 筛选回收(Live Data Counting and Evacuation)

前面几个步骤跟CMS有很多相似之处,主要的区别是最后一步,在G1中会对各个Region的回收价值和成本进行排序,然后根据用户所期望的GC停顿时间来制定回收计划。

G1最大的好处是性能,他比我们在上面讨论过的任何一种GC都要快。

参考:Understanding Java Garbage Collection

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值