关于JVM的GC的问答

引言

在Java的世界中,很多同学似乎对垃圾回收不会有过多关注,很多入门的同学对GC也不熟悉,但是也能写出不赖的程序或系统。但这并不代表Java的GC就不重要。相反,当系统或程序出现问题的时候,我们可能更依赖GC日志来对问题进行定位。

虽然Java语言JVM实现对于GC的自我管理,但在Java面试中,GC的问题出现的频率非常高,有时候,偷懒的面试官只是无脑地抛出“你能说说你对GC的理解吗?”这种笼统但让你一时间不知道从哪说起的干巴巴的问题,这个时候需要静下心来思考和梳理一下该如何回答关于GC诸多方面,本文简单地说说关于面试中GC中可能出现的问答。

其实对于这种单一名词性的问题,可以尝试从以下三个步骤去描述:

1、什么是XX

2、XX的作用是什么

3、XX工作原理是什么

GC(垃圾回收)

(1)什么是GC?


在Java程序中,存在着一些已经死亡的或者长时间没有使用的对象,而GC的意思也就是释放垃圾占用的内存空间,防止出现内存泄漏。这样可以有效地使用可以使用的内存,对内存堆中已经死亡或者长时间没有使用的对象进行清除和回收。其实这种定义又臭又长,没必要去强记,只需要理解到垃圾回收的意思就行,即使是临时组织语言,也能表达出来。

其实在Java出现之前,同学们都在写C或者C++时,比如说在C++中,开发人员需要不断地申请内存,当这块内存写完后,又要不断地释放内存,既要写构造函数,又要有析构函数,这种操作对于很多人来说确实是不太好的体验。这个时候就会有人思考,能不能写一段程序来实现内存的申请和释放,当我每次申请和释放都用这一份代码,而不需要重复地写申请和释放的代码。垃圾回收的概念就由此诞生了,当然,GC并不是Java中才有的机制,事实上GC的出现要远早于Java。


(2)Java程序中垃圾是怎么定义出来的?

既然是垃圾回收,那么我们首先得弄清楚JVM中,垃圾是怎么定义出来的。


1、引用计数算法


引用计数的算法的方式是通过在每一个对象的对象头中存储该对象的引用次数,如果这个对象被引用了,则它的引用计数加1,如果删除对该对象的引用,计数就减1,如果该对象的引用计数会被回收,那么这个对象就会被JVM回收。比如说,String str = new String("abc");则abc有一个引用,如果再将str = null;那么此时abc的引用就减1,计数为0,那么abc的内存就将会被回收。

需要注意的是,引用计数算法是将垃圾分摊到整个应用程序的运行中了,而不会在垃圾回收时将整个应用程序挂起,一直等到对堆内存中所有的对象处理完,它并不是属于“Stop-The-World”的垃圾收集机制。

引用计数这种算法看起来很完美,但是我们要知道实际上现在JVM的垃圾回收就是STW的,那么引用计数存在什么缺陷,导致JVM放弃使用这种方法呢?其实我们可以按照以下几个步骤写段代码:

  • 定义两个对象
  • 两个对象相互引用
  • 然后把这两个对象都置空
public class ReferenceCountingGC {

  public Object instance;

  public ReferenceCountingGC(String name) {
  }

  public static void testGC(){

    ReferenceCountingGC a = new ReferenceCountingGC("objA");
    ReferenceCountingGC b = new ReferenceCountingGC("objB");

    a.instance = b;
    b.instance = a;

    a = null;
    b = null;
  }
}

这种情况下的a b两个对象即使不可能再被访问,但是由于相互引用,他们的引用计数永远不会为0,也永远无法通知GC回收器回收他们,这样积累下来就很有可能导致OOM问题。

2、可达性分析算法


可达性分析算法的基本思路就是通过一些被称为GC Roots的对象作为起点,从这些起点往下搜索,当一个对象到GC Roots没有任何引用链相连时,也被称为不可达,则判定这个对象是不可用的,需要通知GC回收器进行回收。

可达性分析算法解决了引用计数算法中无法解决的循环依赖的问题,只要对象无法与GC Roots的节点建立直接或者间接的链接,那么这个对象就将会被回收。这里需要说明的一点是,理解对象到底可不可达,和该对象是否被引用,被引用次数没有关系,判断可不可达并不是根据你有没有被别人引用到,而是判断从哪些GC Roots的对象为根节点向下搜索可不可达,如果一个对象确实是不可达的状态,即使它被其他无数个对象依赖,那么这个对象还是不可达,还是被会GC回收器回收。

在可达性分析算法中有一个名词叫GC Root,什么是GC Root呢?JVM指定在某些内存中所引用的对象即为GC Root,那么问题来了,那些内存中引用的对象可以作为GC Root呢?

  • 虚拟机栈中引用的对象(局部变量)
  • 方法区中静态属性所引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈JNI(一般说的Native方法)中引用对象

下面放几张图来分别说明一下:

1、虚拟机栈中引用的对象(局部变量)

2、方法区中类的静态属性引用的对象

3、 方法区中常量引用的对象

 4、本地方法栈JNI(一般说的Native方法)中引用对象

本地方法栈中引用的对象,一般来说就是Native方法引用的对象,这个就比较简单了,因为本地方法的接口使用的基本都是C语言实现的,那么它的本地方法栈其实就是C栈,当程序调用Java方法时,虚拟机会新建一个新的栈帧进行压栈,压入的是Java栈,而当程序调用的是本地方法时,虚拟机创建的Java栈并不会压栈,而是保持不变。

(3)GC的工作原理

定义了那些垃圾可以被回收后,垃圾收集器要做的事情就是开始垃圾回收,但是这里就涉及到一个问题,就是如何高效地进行垃圾回收。由于Java虚拟机规范并没有对如何实现垃圾回收器做出明确的规定,因此每个编程人员都可以指定自己偏爱的方式来实现垃圾收集器。我们就来讲讲几种常见的垃圾收集算法的核心算法。

1、标记清除算法

标记清除算法是一种最基础的垃圾回收算法,把内存分为2个部分,先把内存中属于可回收的内存标记出来,然后把这些内存拎出来清理掉,清理掉的内存变为可用内存,等待再次被使用。

这种算法的思想简单明了,但是这个算法存在一个问题就是内存碎片。在某些时候,由于标记后回收的一些内存很小,而这些小的内存又是相互分散的,如果我要申请一块连续的内存,这些小的内存碎片是白白浪费的。

2、复制算法

复制算法是在标记清除算法基础上演化而来,解决标记清除算法的内存碎片问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。保证了内存的连续可用,内存分配时也就不用考虑内存碎片等复杂情况。复制算法暴露了另一个问题,可用内存为实际可用内存的一半,利用率太低了。

3、标记-整理算法

标记-整理算法标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,再清理掉端边界以外的内存区域。

标记整理算法解决了内存碎片的问题,也规避了复制算法只能利用一半内存区域的弊端。标记整理算法对内存变动更频繁,需要整理所有存活对象的引用地址,在效率上比复制算法要差很多。

4、分代收集算法

分代收集算法分代收集算法严格来说并不是一种思想或理论,而是融合上述3种基础的算法思想,而产生的针对不同情况所采用不同算法的一套组合拳,根据对象存活周期的不同将内存划分为几块。

  • 在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。

  • 在老年代中,因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记-清理算法或者标记-整理算法来进行回收。

(4)堆区的内存模型和回收原理

GC回收器主要管理的就是堆区和方法区的内存,而Java 堆是JVM所管理的内存中最大的一块,这里我们务必要了解 Java 堆的内存模型还有GC对于堆区的回收原理。Java 堆将内存分为2个区域:即年轻代与老年代,其中年轻代又分 Eden 区和 Survivor 区,其中 Survivor 区又分 From 和 To 2个区。年轻带和老年代的内存比为1:3,Eden区与From还有To区的内存占比为8:1:1。

1、年轻代

Eden 区

其实在很多情况下,很多对象的生命周期很短,在创建不就后就会死亡,所以我们将这些对象会在新生代 Eden 区中进行分配,当 Eden 区没有足够空间进行分配时,虚拟机会发起一次 Minor GC,Minor GC 相比 Major GC 更频繁,回收速度也更快。通过 Minor GC 之后,Eden 会被清空,Eden 区中绝大部分对象会被回收,而那些无需回收的存活对象,将会进到 Survivor 的 From 区(若 From 区不够,则直接进入Old 区)。

Survivor区

Survivor 区相当于是 Eden 区和 Old 区的一个缓冲,Survivor 又分为2个区,一个是 From 区,一个是 To 区。每次执行 Minor GC,会将 Eden 区和 From 存活的对象放到 Survivor 的To 区(如果To区内存不够,则直接进入Old 区)。

思考1:Survivor区存在的意义是什么?

也许有人会说,为什么不设置年轻代的内存通过Minor GC,存活下来的对象直接进入老年代,为啥要多此一举地设置一个Survivor区。其实如果没有Survivor区,Eden区每进行一次Minor GC,存活的对象就会被送到老年代,老年代的内存根本就不够装,很快就会被填满。而有很多对象虽然一次 Minor GC 没有死亡,但或许第二次,第三次就需要被清除。这时候移入老年区,很明显不是一个明智的决定。

所以,Survivor 的存在意义就是减少被送到老年代的对象,进而减少Major GC 的发生。Survivor相当于进行了一次预筛选,并且JVM对顶,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代。

思考2:Survivor区又存在的意义,为什么又把它分为From和To区两个部分?

其实,设置两个 Survivor区最大的好处就是解决内存碎片化。

我们还是以逆向思维来考虑一下,Survivor区如果只有一个区域会怎样。Minor GC执行后,Eden区被清空了,存活的对象放到了 Survivor 区,而之前 Survivor 区中的对象,可能也有一些是需要被清除的。问题来了,这时候我们怎么清除它们?在这种场景下,我们只能通过标记清除的方式,而标记清除最大的问题就是内存碎片。在新生代这种经常会消亡的区域,采用标记清除必然会让内存产生严重的碎片化。而设置2个区域的话,每次Minor GC,会将之前 Eden区和From区中的存活对象复制到To区域。第二次 Minor GC时From与To职责对换,这时候会将Eden区和To区中的存活对象再复制到 From 区域,以此反复。

这种机制最大的好处就是,整个过程中,永远有一个Survivor space是空的,另一个非空的Survivor space是无碎片的。那么,Survivor 为什么不分更多块呢?比方说分成三个、四个、五个?显然,如果 Survivor 区再细分下去,每一块的空间就会比较小,容易导致 Survivor 区满,两块 Survivor 区可能是经过权衡之后的最佳方案。

2、老年代

老年代占据着2/3的堆内存空间,只有在 Major GC 的时候才会进行清理,每次GC都会触发“Stop-The-World”。内存越大,STW 的时间也越长,所以内存也不仅仅是越大就越好。由于复制算法在对象存活率较高的老年代会进行很多次的复制操作,效率很低,所以老年代这里采用的是标记-整理算法。

除了上述所说,在内存担保机制下,无法安置的对象会直接进到老年代,以下几种情况也会进入老年代。我就直接按照深入理解Java虚拟机书上的来。

大对象

大对象指需要大量连续内存空间的对象,这部分对象不管是不是“朝生夕死”,都会直接进到老年代。这样做主要是为了避免在 Eden 区及2个 Survivor 区之间发生大量的内存复制。当你的系统有非常多“朝生夕死”的大对象时,得注意了。

长期存活对象

虚拟机给每个对象定义了一个对象年龄(Age)计数器。正常情况下对象会不断的在 Survivor 的 From 区与 To 区之间移动,对象在 Survivor 区中每经历一次Minor GC,年龄就增加1岁。当年龄增加到15岁时,这时候就会被转移到老年代。当然,这里的15,JVM 也支持进行特殊设置。

动态对象年龄

虚拟机并不重视要求对象年龄必须到15岁,才会放入老年区,如果 Survivor 空间中相同年龄所有对象大小的总合大于 Survivor 空间的一半,年龄大于等于该年龄的对象就可以直接进去老年区,无需等你“成年”。

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值