Java语言的一个重要特点就是对内存的管理。

对于内存,有两个相依相存的概念,一个是内存溢出,一个是内存泄露。内存溢出即大名鼎鼎的OOM。指内存空间已经满了,无法继续分配空间,导致程序无法进行。而内存泄露本身则不一定导致程序的崩溃,它指的是已经分配的内存,在不再需要使用的时候,没有及时清理。一旦没有及时清理的内容多了,那么就可能造成OOM。

而Java对内存的管理特点,十分明显地体现在其垃圾回收机制中。这里的垃圾回收,就是上面说的已经不再需要但占据内存空间的程序对象。由于Java语言的使用场景的变迁,垃圾回收机制以及实现垃圾回收机制的垃圾回收器也有诸多的不同。

从Stop the world说起

垃圾回收的一个经典概念是Stop The World,简称STW。在垃圾回收的时候,由于要对内存进行清理,如果这个时候程序还是自顾自运行,那么很可能出现问题。因此在进行垃圾回收的时候,正在运行的各个用户线程需要停止,只留下垃圾回收的线程做垃圾回收的工作。由于线程是程序世界的绝对主角,各个线程都停止了,也就相当于整个程序世界都静止了。

不过,初学JVM的时候,我有一个疑问,CMS垃圾回收器也有STW吗?

https://www.zhihu.com/question/29114369

因为对于CMS垃圾回收器,有一个说法:它第一次实现了让垃圾收集线程与用户线程同时工作。不过,只需要搞清楚垃圾回收的本质,就能明白,至少在现在,STW无法避免。但是如果STW不可避免,那么CMS怎么和用户线程同时工作呢。

这里就涉及一个概念,并发和并行。这里CMS垃圾回收器,应该被称为并发垃圾回收器。指的是垃圾回收这个过程,和用户线程是交替进行的。虽然CMS垃圾回收器,依然会在垃圾回收过程中STW,但是CMS垃圾回收的过程可以和用户线程交替进行,这就保证了CMS垃圾回收器的交互性强,响应速度快,而这一特点,正好满足了搭建网站的需求,因此,CMS垃圾回收器可谓生逢其时。

那么,CMS到底是怎么降低垃圾回收STW的时长的呢?

这与CMS的垃圾回收算法有关系。

垃圾回收算法总体思路非常近似。首先,当然是要把“垃圾”给找出来,找到垃圾之后,还要给垃圾做一个标记。这个步骤,叫做垃圾标记。除了垃圾之外,就是以后还要用的对象。因此,也可以考虑找到存活的对象,将存活的对象留下来。

这种找存活的对象,有两个经典的思路。一个是引用计数的方法,另外一个是可达性分析的方法。引用计数的方法,其实有点像搜索引擎的排名方法,利用指向对象的链接数来判断引用是否存活。但是引用计数法难以解决循环引用的问题,即两个对象相互指向。因此,JVM采用可达性分析的方法来进行垃圾标记。可达性分析的方法,是认为在程序运行的过程中,一定有一些对象是存在的。比如:

  • 虚拟机栈中引用的对象
  • 方法栈内引用的对象
  • 方法区中类静态属性引用的对象
  • 字符串常量池里的引用
  • 被同步锁持有的对象
  • java虚拟机内部的引用

可达性分析算法中,找这种一定存在的对象的过程,一定要在一个能保障一致性的快照中进行,这也是GC必须进行STW的原因。

对于CMS垃圾回收器而言,STW仅仅发生在初始标记阶段,在该阶段,垃圾回收器标记出GCRoots能直接关联到的对象。一旦标记完成后会恢复之前被暂停的所有应用线程。

在应用线程继续进行的时候,CMS进行并发标记的工作,即完成其他被GCRoots关联的对象的标记。

但是,在应用进程继续进行的时候,可能由于这段时间的程序运行,导致标记发生一部分变动,因此,需要再次STW来标出这部分发生变动的内容。

将所有垃圾标记完毕后,就应该进入并发清除阶段,即清理删除掉标记阶段判断已经死亡的对象。由于清除的都是垃圾,因此清除阶段应用程序可以继续进行。

CMS垃圾收集器之所以这样著名,就在于其很好地降低了STW时间。但是在另外一些场景中,可能STW时长并非是最重要的指标,这个时候,就要考虑其他的垃圾回收算法了。

标记了,然后呢?

对于垃圾内容的标记,是任何一种垃圾回收器都会做的事情。但是垃圾回收器做完标记之后的行为,却各有不同。像CMS垃圾回收器,其最主要的工作其实就是做标记。做完标记之后,无非是下回再需要内存的时候,将标记的垃圾区域直接当空闲区域使用,但是这样的做法也有问题:由于垃圾本身是与程序有关的,所以垃圾在内存中的分布往往不是连续的,因此仅仅对垃圾做标记,就会内存碎片的问题:可用的空闲区域被存活对象分割成一片片,虽然可用队列的方式标记空闲区域的地址,但是如果后面需要存入较大的对象,可能就无法找到合适的内存空间。

因此,可以考虑在垃圾回收的过程中,对存活的对象进行一定的处理,比如将所有存活对象压缩到内存的一端,按顺序存放。之后存活对象外的空间。这种方法被称作标记压缩。标记压缩的方法解决了内存碎片的问题,不仅避免了无法存放较大对象的问题,而且在分配内存的时候,可以用指针碰撞的方法分配内存,即只需要修改指针的偏移量将新对象分配到一个空闲内存位置上。

不过,要移动存活对象,这个开销看起来就不小。因此,我们又可以考虑另外一种算法,即复制算法。复制算法将内存分为两个区域,将正在使用区域的存活对象复制到另外一个区域,之后清除正在使用的内存的所有对象,再交换两个内存的角色。

这个算法的缺点显而易见,就是消耗空间。但是优点也很明显,就是不会产生内存碎片,也不需要整理(其实是做了整理这个事情,不过是该算法利用冗余的空间,将整理和标记的事情放在一起做了)。

不同的垃圾回收算法,有其各自的特点。由此也产生了对应不同垃圾回收算法的垃圾回收器。

垃圾回收器

垃圾回收器不少,比如CMS垃圾回收器,G1垃圾回收器。还有Serial以及Serial Old垃圾回收器,ParNew,Parallel Scavenge,Parallel old垃圾回收器。

对不同的垃圾回收器,我们可以从不同方面做一些简单的分类。

首先是从垃圾回收这个过程本身的线程数来分类,将垃圾回收器分为串行垃圾回收器和并行垃圾回收器。这里的串行和并行都是针对垃圾回收这个过程来讲的。串行垃圾回收器利用一个线程来进行垃圾回收,而并行垃圾回收器则用多个线程来进行垃圾回收。想到串行垃圾回收器,就可以想到Serial垃圾回收器和Serial Old垃圾回收器。而其他的垃圾回收器都是并行的。

其中,比较有趣的是Paraller 垃圾回收器,它和ParNew垃圾回收器一样,都是并行垃圾回收器,不过它是一款吞吐量优先的垃圾回收器。

这就涉及到垃圾回收器的评价指标问题。以现在一般网站服务的情况来说,我们希望STW的时间尽可能短,对用户线程影响尽可能小。但是,一味追求STW的短,可能导致STW的次数变多,即使得一段时间内,用户线程真正运行的时间减小。因此,又提出了另外一个指标吞吐量来评价垃圾回收器。吞吐量指的是用户线程运行时间/总的时间(用户线程运行时间+垃圾回收器垃圾回收的时间)。而Paraller垃圾回收器就是吞吐量优先的垃圾回收器,因为其吞吐量优先,因此很适合用来做那些弱交互性的程序,比如作为后台应用进行科学计算等。

另外一个垃圾回收器的分类标准是是垃圾回收是否是独占的,即垃圾回收这个线程是否能够被应用程序打断。如果垃圾回收这个线程不会被应用程序打断,那么垃圾回收就是独占的,否则就可以实现垃圾回收的时候,应用程序同时也可以运行的效果(广义范围的,中间是垃圾回收线程和应用程序线程之间的相互切换)。

不同的垃圾回收器作用的处理年代也有所不同。而不同的处理年代,也决定了垃圾回收算法。对于新生代垃圾回收器来说,常常采用复制算法,因为新生代的存活对象相对较少,需要回收的垃圾较多,所以要着重考虑内存碎片的问题,因此新生代垃圾回收常用复制算法。老年代的垃圾回收算法则用标记-压缩,这也与老年代存活对象较多的特征有关。

G1垃圾回收器又和以往的垃圾回收器不同。G1回收器出现在堆空间变得更加大的现在,所以G1回收器采取的垃圾回收算法也利用了堆空间较大的特点。

下面是各种垃圾回收器的一个对比:

垃圾回收器 处理年代 并行/串行 并发/独占 垃圾回收算法
Serial Young 串行 独占 复制算法
Serial Old Old 串行 独占 标记-压缩
ParNew Young 并行 独占 复制算法
Parallel Young 并行 独占 复制算法(吞吐量优先)
Parallel Old Old 并行 独占 标记压缩算法
G1 Young and Old 并行 并发 垃圾优先回收,分区域复制算法,整体标记压缩
CMS Young 并行 并发 标记-清除算法

G1垃圾回收器

G1垃圾回收器可谓非常现代的垃圾回收器,充分适应了当前大内存,多处理器的环境。G1垃圾回收器的命名意义为Garbage First即垃圾优先,其含义指的是它有计划地避免在整个Java堆中进行全区域的垃圾收集,而是将Java堆划分为多个Region,跟踪每个Region里面垃圾堆积的价值大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。不过,从分代的角度来看,G1仍然会区分年轻代和老年代,只不过其不要求Eden区年轻代老年代都是连续 且固定大小的,其处理的范围,也兼顾了年轻代和老年代。

G1垃圾回收器会将整个堆划分成约2048个大小相同的独立Region,每个Region都有可能属于Eden,Survivor或者Old/Tenured内存区域。但是一个region只可能属于一个角色。相对于CMS垃圾回收器,G1垃圾回收器有一个概念上的改变,就是其 设计导向变为追求能够应付应用的内存分配速率,而不追求一次把整个java堆清理干净(深入理解java虚拟机第三版所总结)。

G1垃圾回收器同样进行了需要暂停用户进程的初始标记,以及与用户进程同步进行的并发标记,还有最后对在并发过程中改变的对象的最终标记。不过,与CMS的并发清除不同的是,G1垃圾回收器最后一步做的是筛选回收。

筛选回收做了如下事情:

1.对region的统计数据进行更新,同时对region垃圾回收价值进行排序,制定回收计划。

2.自由选择多个region构成回收集。

3.把决定回收的region的存活对象复制到空的region,清理掉旧region空间。

筛选回收不同于CMS的并发清除,是会暂停用户线程的,这也是后续的垃圾回收器进一步优化的点;此外,由于G1 最后利用复制的方法,移动了存活对象,所以避免了CMS的内存碎片问题。

G1垃圾回收器,的总体策略,在复制算法和整理算法中间做了个权衡。即全局来看,是整理算法,而局部来看,则是复制算法。这样规避了复制算法带来的过大的空间浪费,同时也避免了整理算法所消耗的时间。不过,这不意味着G1 垃圾回收器就比CMS更先进更高级了。因为G1的一些设计,比如分为多个region,其中跨region的引用,需要用记忆集来进行处理。但是记忆集实现复杂,比此前的垃圾回收器需要更多的内存占用。

回顾

不同的垃圾回收器,有其不同的特性。虽然垃圾回收技术都是逐渐发展螺旋上升,后面还出现了一些低延迟垃圾回收器,其垃圾回收过程几乎可以全部和用户同步(对应G1垃圾回收器的筛选回收过程都实现了同步),但我们仍然要根据具体的情况选择垃圾回收器。

其中,垃圾回收器的思想,也值得我们学习。

从开始简单的标记-清理,标记-复制,标记-整理,再到后面试图将标记过程分为初始标记和并行标记,再到回收价值最高的region;从开始的单线程回收,到多线程回收,从垃圾回收线程要完整的走完一遍STW,到现在越来越短的延迟时间,垃圾回收器技术的发展过程,也是一种不断进行权衡的过程。如何利用资源,如何改进技术,如何设定指标和完成指标,都是我们应该学习的,应该思考的。