JVM(2)--一文读懂垃圾回收

zhll 2019-06-28

与其他语言相比,例如c/c++,我们都知道,java虚拟机对于程序中产生的垃圾,虚拟机是会自动帮我们进行清除管理的,而像c/c++这些语言平台则需要程序员自己手动对内存进行释放。

虽然这种自动帮我们回收垃圾的策略少了一定的灵活性,但却让代码编写者省去了很多工作,同时也提高了很多安全性。(因为像C/C++假如你创建了大量的对象,但却由于自己的疏忽忘了将他们进行释放,可能会造成内存溢出)。

何为垃圾?

刚才说了,虚拟机会自动帮助我们进行垃圾的清除,那什么样的对象我们才可以称为是垃圾对象呢?

假如你创建了一个对象

Man m = new Man();

你用一个变量指向了这个对象,显然对于这个对象,你可以用变量m对这个对象进行利用,但过了一段时间,你执行了

m = null;

并且也并没有新的变量来指向刚才创建的对象。此时对于这个没有任何变量指向的对象,你觉得它还有用处吗?

显然,对于这种没有被变量指向的对象,它是一点卵用也没有的,它只能在随风漂流。

因此,对于这样的对象,我们就可以把它称为垃圾了,它早晚会被垃圾回收器给干掉。

怎么知道它已经是垃圾对象了?

假如代码是你自己编写的,你可能知道这个对象啥时候应该被抛弃,你可以随时让它成为垃圾对象。

但是,你毕竟是你,虚拟机则没那么智能。那虚拟机是如何知道的呢?

上面已经说了,没有变量引用这个对象时,它就是垃圾对象了,基于这个原理,我们可以这样做啊:

我们可以为这个对象设置一个计数器,初始值为0,假如有一个变量指向它,那么计数器就加1,如果这个变量不在指向它了,计数器就减1。那么我们就可以判断,如果这个计数器为0的话,那它就是垃圾对象了,否则就是有用的对象。

对于这种方法,我们称之为引用计数法

好吧,我们先来夸一夸引用计数法这种方法:

  1. 实现简单。
  2. 效率高(一个if语句就能解决的问题想不高效都难)。

不好意思,接下来得说说它那个致命的缺点

实际上,对于这种引用计数的方法,假如它遇到对象互相引用的话,是很难解决的。

先看一段代码:

Man m1 = new Man();
Man m2 = new Man();
//互相引用
m1.instance = m2;//假设Man有instance这个属性
m2.instance = m1;

m1 = null;
m2 = null;
System.gc();//按道理对象应该被回收

这段代码m1和m2都指向null了,按道理两个对象已经是无用对象,应该被回收,但是,两个对象之间彼此有一个instance的属性互相牵引的对方,导致两个对象并没有被回收。

这个缺点够致命吧?

所以,虚拟机并没有采用这种引用计数的方法。

可达性分析

除了这种方法,我们还有其他的方法吗?

答案是有的,必须得有啊。这种方法就是传说中的可达性分析,(我靠,听名字是真的高级啊)。它的工作原理是这样的:

在程序开始时,会建立一个引用根节点(GC Roots),并构建一个引用图。当需要判断谁是垃圾时,我们可以从这个根节点进行遍历,如果没有被遍历到的节点则是垃圾对象,否则就是有用对象。如下图:

JVM(2)--一文读懂垃圾回收

这个方法可以解决循环相互引用的问题,但是这个方法并没有引用计数法高效,毕竟要遍历图啊。

总结下判断是否为垃圾对象的算法:

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

何时进行垃圾回收

可能有人会觉得这个问题很奇怪,觉得看到垃圾就回收不是很好。对于这个我只能说:

  1. 看到房间有一点垃圾你会马上扫?还是等到某个时间点或者当垃圾积累到一定的数量再扫?
  2. 虚拟机可没那么智能可以马上识别这个对象是垃圾对象,它还得遍历所有对象才能知道有哪些是垃圾对象。

所以说,你总不能几秒(我们假设几秒是贼短的时间)就让虚拟机遍历一下所有对象吧?

这里先说明一下,当垃圾回收器在进行垃圾回收的时候,为了保证垃圾回收不受干扰,是会暂停所有线程的,此时程序无法对外部的请求进行响应。(因为你想啊,当你在可达性分析的时候,那些引用关系还在不断着变化,那不很难受)。

而且频繁的垃圾回收,对于有一些程序,是很影响用户体验的,例如你在玩游戏,系统动不动就停顿一下,怕你是要把这游戏给删了。

所以说,垃圾回收是会等到内存被使用了一定的比例的时候,才会触发垃圾回收。至于这个比例是多少,这可能就是人为规定的了。

怎么回收?

当我们标记好了哪些是垃圾,想要进行回收的时候,该怎么回收比较好呢?

可能有一些人就觉得奇怪,这还不简单,看见它是垃圾,直接回收不就得了。

其实这也不无道理,简单粗暴,直接回收。

是的,确实有这样的算法,看哪些是被我们标记的垃圾,看见了就直接回收。这种算法我们称之为标记--清除算法

标记-清除算法工作原理:就是先标记出所有需要回收的对象,然后在统一回收所有被标记过的对象。

不过,那些人你可别得意啊,因为这种方法虽然简单暴力,但它有个致命的缺点就是:

标记清除过后,会产生大量的不连续内存碎片,如果不连续的碎片过多的话,,可能会导致有一些大的对象存不进去。这样,会导致下面两个问题:

  1. 有些内存浪费了。
  2. 对象存不进去,会又一次触发垃圾回收。

复制算法

为了解决这种问题,另外一种算法出现了---复制算法。就是说,它会将可用的内存按容量划分成两块。然后每次只使用其中的一块,当这一块快用完的时候,就会触发垃圾回收,它会把还存活的对象全部复制到另外一块内存中去,然后把这块内存全部清理了。

这样,就不会出现碎片问题了。

居然帮我们解决了我们必须夸一下它:不仅帮我们解决了问题,而且实现上也简单、运行也高效。

但是(凡事都有个但是的),它也是有缺点的,缺点很明显,发现了没有。假如每次存活的对象都很少很少,那另外一块内存不是几乎没有用到?所以说,这种方法有可能导致另外一半内存几乎没用了。内存那么宝贵,这可是很严重的问题。

优化策略:可以告诉你,有研究显示,其实有98%的对象都是朝生夕死的,也就是说,每次存活的对象确实很少很少。既然我们都知道存活的对象很少很少了,那我们干嘛还1:1的比例来分配?所以说,HotShot虚拟机是默认按8:1的比例来分配的。这样,就不会出现很多内存没用到的问题了。

可能有人会说,万一占比为1/9的内存不够用了怎么办?不就没地方存那些活的对象?实际上,当内存不够用时,可以向其他地方借些内存来使用,例如老年代里的内存。

这里说明一下新生代和老年代:说白了,新生代就是刚刚创建不久的对象,而老年代是已经活了挺久的对象。也就是说,有一些对象是确实活的比较久的,对于这种对象,我们另外给它分配内存来养老,而且垃圾回收时,我们不用每次都来这里查找有没垃圾对象,因为这些对象是垃圾的几率会比较小。

下面在简单介绍另外两种算法:

  1. 标记-整理算法:这种算法和标记-清除算法类似,不过它把垃圾清除了之后,会让存活的对象往一个方向靠拢,以此来整理碎片。
  2. 分代收集算法:所谓分代就是把对象分成类似上面说的老年代和新生代,在新手代一般每次垃圾回收时死的对象一般都会比较多,而老年代会比较少,基于这种关系,我们就可以采取不同的算法来针对了。

总结下垃圾回收的几种算法:

  1. 标记-清除算法。
  2. 复制算法。
  3. 标记-整理算法。
  4. 分代收集算法。

最后给大家几种垃圾回收器

对于垃圾的回收,你是想一边运行程序其他代码一边进行垃圾回收?还是想把垃圾全收好再来执行程序的其他代码?虽然说最终使用cpu的时间是一样,但两种方式还是有区别的。

下面简单介绍几种垃圾回收器,看看他们都使用哪种方。

(1).Serial收集器

serial(串行),看这个英文单词就知道这是一个单线程收集器。也就是说,它在进行垃圾回收时,必须暂停其他所有线程。显然,有时垃圾回收停顿的比较久的话,这对于用户来说是很难受的。

JVM(2)--一文读懂垃圾回收

(2).ParNew

这个收集器和Serial很类似,进行垃圾回收的时候,也是得暂停其他所有线程,不过,它可以多条线程工作进行垃圾回收。

JVM(2)--一文读懂垃圾回收

(3).Parallel Scavenge收集器

parallel,并行的意思。也是可以多线程进行垃圾回收处理,但是它与ParNew不同。它会严格控制垃圾回收的时间与执行其他代码的时间之间的比例。我们来看一个名词:吞吐量

吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。

也就是说,Parallet Scavenge收集器会严格控制吞吐量,至于这个吞吐量是多少,这个可以人为设置。

JVM(2)--一文读懂垃圾回收

下面两个收集器重点介绍下

(4).CMS(Concurrent Mark Sweep)收集器

CMS收集器是基于“标记-清除”算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为4个步骤,包括:

  1. 初始标记(CMS initial mark)
  2. 并发标记(CMS concurrent mark)
  3. 重新标记(CMS remark)
  4. 并发清除(CMS concurrent sweep)

其中初始标记、重新标记这两个步骤仍然需要暂停其他线程。但另外两个步骤可以和其他线程并发执行。初始标记仅仅只是标记一下GCRoots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程 (说白了就是把整个图都遍历了,找出没有的对象)

而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。

由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程几乎是与与用户线程一起并发地执行。

JVM(2)--一文读懂垃圾回收

(5).G1收集器

这个估计是最牛的收集器了。该收集器具有如下特点:

  1. 并行与并发:G1能充分利用现代计算器多CPU,多核的硬件优势,可以使用并发或并行的方式来缩短让其他线程暂停的优势。
  2. 分代收集:就是类似像分出新生代和老年代那样处理。
  3. 空间整合:采用了复制算法+标记-整合算法的特点来回收垃圾。就是整体采用标记-整理算法,局部采用复制算法
  4. 可预测停顿:这个就牛了,就是说,它能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不超过N毫秒。

它的执行过程大体如下:

  1. 初始标记。
  2. 并发标记。
  3. 最终标记。
  4. 筛选回收。

这个流程和CMS很相似,它也是在初始标记最终标记需要暂停其他线程,但其他两个过程就可以和其他线程并发执行。

刚才我们说了G1收集器哪些优点,例如可预测停顿,这也使得筛选回收,是可以预测停顿垃圾回收的时间的,也就是说,停顿的时间是用户自己可以控制的,这也使得一般情况下,在筛选回收的时候,我们会暂停其他线程的执行,把所有时间都用到筛选回收上。

JVM(2)--一文读懂垃圾回收

本次讲解到这里。

关注公我的众号:苦逼的码农,获取更多原创文章,后台回复"礼包"送你一份特别的资源大礼包。

相关推荐