Java基础之刨根问底第2集——垃圾回收器

原文转自我自己的个人公众号:Java基础之刨根问底第2集——垃圾回收器

  • 本系列不适合初学者,读者应具备一定的Java基础。

  • 考虑到目前行业中使用最广的版本,本系列依据Java8编写。

垃圾回收器

        垃圾回收器是JVM的重要技术,也是Java的立身之本。垃圾回收器可以让程序员不需要关注内存的回收,但对程序的执行效率来说却带来了不小的影响。

图片

        上面这幅图显示了垃圾回收对性能的影响。纵轴是吞吐率,指的是整个程序运行时间中没有花费在垃圾回收上的百分比。横轴是处理器数量。可见,当处理器数量是32时,如果花费1%的时间进行垃圾回收,那么吞吐率只有单核时的0.8左右。如果花费10%的时间进行垃圾回收,32核的吞吐率会下降超过75%。这一方面说明了垃圾回收会影响程序的性能,另一方面也说明了核数越多,越会放大垃圾回收时间对性能的影响程度。

        为了理解JVM的垃圾回收机制并正确的使用和调优,再加上上一集刚刚介绍了内存结构,因此这一集介绍垃圾回收器。

        第一集介绍的java的内存结构,不知道有没有人发现,貌似少了一个部分,在java8有个Metaspace区,这在第一集的图中是没有的。实际上,图中是存在的,我们再来看一下那张图:

图片

        注意一下右侧上方的Method Area下面括号中的描述:逻辑上属于Heap,但JVM的实现可以选择不对其应用GC,也可以选择不放在Heap中,JVM启动时创建。

        上面的这幅图的依据是JVM的规范,在这个规范下是有许多具体的实现的,其中HotSpot是最常见的JVM实现,是由Sun公司开发,现在属于Oracle的实现。在HotSpot的早期版本中,使用永久代(permanent generation)来存放方法区,而从Java8开始使用Metaspace。

        原来的永久代是由JVM自己管理的,而现在的Metaspace则直接使用系统的本地内存(native memory),方法区中的内存使用率和加载的类的数量成正比,并且程序通常不会卸载已经加载的类,因此在垃圾回收时,方法区回收的意义并不是太大(不过在HotSpot的实现中,如果Metaspace满了,是会触发Full GC的)。所以说,垃圾回收主要的战场在堆,上一集说过,堆中主要存放的是对象和数组,这些也成为了回收的目标。

        是否有人有疑问,难道栈区的内存不需要回收吗?其实这个问题的答案还是在上面引用第一集中的图中,左侧中间部分,Frame下方的文字:方法调用时创建,调用结束被销毁。因此,JVM的垃圾回收并不关注栈区。

        在详细介绍垃圾回收器之前,先介绍一个在JVM中被称为“Ergonomics”的技术。这个单词的中文意思是“功效学”,从名词含意上是比较难理解的。实际上,就是一种JVM根据运行环境自动对内存进行管理的一种技术。
        JVM在运行时分为client模式和server模式,它会根据主机的情况自动选择一个模式(当然也可以手动指定)。当主机满足以下条件时,默认会选择server模式:

  • 2个或以上物理处理器

  • 2GB或以上物理内存

        在server模式下,JVM会进行以下默认设置:

  • 初始堆内存(-Xms)会被设置为物理内存的1/64,最大为1GB(由于server模式至少有2GB的物理内存,因此-Xms最小为32MB)。

  • 最大堆内存(-Xmx)会被设置为物理内存的1/4,最大为1GB。

  • 默认的垃圾回收器为Parallel。

        如果主机资源不满足server,就会选择client模式,JVM会进行以下设置:

  • 初始堆内存(-Xms)被设置为4MB。

  • 最大堆内存(-Xmx)被设置为64MB。

  • 默认的垃圾回收器为Serial。

        除了设置默认的堆内存和选择垃圾回收器外,当使用Parallel垃圾收集器时,Ergonomics还会进行基于行为的自动优化。优化会基于三个目标:

  • 最大暂停时间目标:并行垃圾回收器在回收的时候会暂停所有的应用线程,这时应用程序是被挂起的,这种情况被称为“Stop-the-world”(Serial收集器同样如此)。最大暂停时间目标可以通过“-XX:MaxGCPauseMillis”来设置,这会告知收集器,暂停时间不要超过这个目标。为了满足这个目标,垃圾回收器的做法是缩小堆内存,因此,Xms参数可能会阻碍这个目标的达成,因此尽量不要自行指定。默认是没有设置该目标的。

  • 吞吐率目标:可以通过“-XX:GCTimeRatio”,默认值是99,就是说,总的暂停时间不能超过总的运行时间的1%。

  • 空间目标:如果最大暂停时间目标和吞吐率目标都满足了,JVM就会尽可能地减少堆内存的大小,来节省空间。直到吞吐率目标不满足时,就会再增大内存来满足吞吐率目标。

        空间目标的优先级最低,并且没有参数能够手动影响它,最大暂停时间目标的优先级最高。调整堆内存时,每次扩容是按20%比例增加的,缩容按5%,这些都有参数可以指定。扩缩容会分代(后文会介绍分代)进行,假设吞吐量目标未满足,此时要扩容,JVM发现年轻代的回收时间占总回收时间的25%,就会在20%的基础上乘以25%,因此年轻代会扩容5%。

        Ergonomics在多数情况下都足以应付实际的情况,但人为的设置-Xms和-Xmx可能会使得目标无法达成,甚至,现实中有很多人喜欢讲-Xms和-Xmx设置为一样的,避免Ergonomics的动态扩缩带来的开销。但这样做的前提是必须对程序的内存使用量有着清楚的认识,设置的值在吞吐率和最大暂停时间上有着很好的平衡,否则程序的运行效率不但不会提升反而会带来很多性能问题,如果要设置的内存比较大,还需要考虑更换适当的垃圾回收器。

        接下来再介绍一下JVM的分代垃圾回收原理。JVM的垃圾回收机制基于“弱时代假设(weak generational hypothesis)”:大对数对象的生存期都很短。于是垃圾回收分为Minor GC和Major GC(也叫Full GC),结合JVM的内存结构,Minor GC仅回收年轻代的内存,而Full GC则回收整个堆内存。

        年轻代中的对象生存时间普遍很短,大部分都能够回收,并且年轻代通常要比老年代要小(默认是1:2,有参数可以指定),所以Minor GC执行的非常频繁,每次的时间非常短。而老年代由于增长的比较缓慢(年轻代的对象提升到老年代需要条件,比如生存的时间够长或者生存区放不下),再加上空间大、可回收的对象比较少,因此回收的时间要比年轻代长很多,也就是说Full GC的时间要比Minor GC长很多。也是因为这个原因,年轻代的回收方式和老年代的回收方式可能是不一样的。

        Java8共支持三类垃圾回收器:串行回收器、并行回收器和大多数并发回收器(The Mostly Concurrent Collectors),而大多数并发回收器有两个,CMS和G1,也就是说Java8种共有4个垃圾回收器,下面就对它们进行一一介绍。

        首先是串行回收器(Serial Collector)

        串行收集器执行的时候是Stop-the-world的,只能用一个cpu,无论是年轻代的回收还是老年代的回收,都是串行的。下面从年轻代和老年代两个方面分别介绍串行收集器的工作原理。

  • 年轻代的回收

        

图片

        如上图所示。当年轻代种的Eden区满的时候,首先会把Eden种活的对象拷贝到一个空的生存区To中,如果To中放不下,会直接放入老年代。生存区From中年纪不够的(从From到to算是长大一岁),会拷贝到To中(长一岁),同样,如果To放不下,会直接放入老年代。红叉叉的就是垃圾,这些空间就会被清空,如下图所示:

图片

        回收后,Eden和原本的生存区From就都是空的了,只有原本的生存区To中有存活的对象,然后原本的From和To交换角色,变为现在的To和From。

  • 老年代的回收

        老年代的回收基于“标记-清理-压缩”算法,如下图所示:

图片

        在标记阶段,垃圾回收器识别所有存活的对象,清理阶段对识别到的垃圾进行回收,然后执行滑动压缩(sliding compaction),有点类似windows系统中的磁盘碎片整理。把存活的对象向左边移动,这样右边的正片区域就空下来了,这样做的好处是可以加快新对象的创建,Java使用碰触指针技术(bump-the-pointer)来跟踪空闲区域的最左侧,每次创建新对象就从这个位置开始,然后把指针再移动到这个对象占据空间的末尾,方便下一次的对象空间的开辟。否则空闲区域随机散落,大小不一,为新对象开辟空间时就要去寻找一个大小合适的区域,就会慢一些。

        然后是并行回收器(Parallel Collector)

        并行回收器执行时的也是Stop-the-world的,但是因为可以用多个处理器并行执行,所以要比串行回收器快很多。

  • 年轻代的回收

        年轻代的回收算法和串行回收器的年轻代算法是一样的,只是用了并行的方式执行。

        

图片

        如上图所示,左侧是串行回收器,右侧是并行回收器,可以看到在Stop-the-world阶段,串行回收器只有一个线程工作,并行回收器则有多个线程,这也使得并行回收器的Stop-the-world时间要比串行回收器短很多。

  • 老年代的回收

        分为三个阶段,首先,老年代会被逻辑的分割为若干个固定大小的区域。然后进入第一阶段——标记阶段,会并行的在这些区域中识别存活的对象。然后进入第二阶段——摘要阶段,由于上一次回收时的压缩,因此越向左边,对象越密集,并且包含更多存活的对象,所以摘要阶段先从左侧检测对象密集的区域,找到一个适合大小的可压缩区域后,左侧的密集区域被指针引用为密集前缀( dense prefix),标识对象不能移动到这个区域中,从右侧开始进行压缩。最后是压缩阶段,用摘要阶段的信息,并行的向可压缩的区域中移动对象。

        接下来是CMS回收器(Concurrent Mark Sweep Collector)

        CMS收集器尽可能地使用并发,让暂停时间尽可能地短,但这也需要额外地开销,对吞吐率有负面影响。CMS也被称为“低延迟回收器(low-latency collector.)”

  • 年轻代的回收

        和并行回收器(Parallel)算法相同。

  • 老年代的回收

        首先是一次很短的暂停,被称为初始标记( initial mark),目的是从程序代码中发现直接可达的存活对象,然后进入并发标记阶段,并发的基于上一个阶段发现的存活对象标记传递引用的存活对象。由于是并发执行,此时应用程序还在产生新的对象和引用,因此还需要再暂停一次,被称为重标记(remark),将并发标记阶段到现在这段时间内有更新的对象再进行一次识别,第二次暂停要比第一次长一些,重标记阶段是多线程并行执行的。最后的并发清理阶段回收所有被识别到的垃圾,过程如下图所示:

图片

        这里需要强调的是CMS不会对内存进行压缩,因此会产生很多碎片化(fragmentation)的区域,对于年轻代的对象提升到老年代时的新对象空间开辟是有负面影响的。

        回收后的情况如下图所示:

图片

        CMS与之前两个回收器不同的是,前两个回收器只有在空间满的时候才会触发,但CMS由于是并发执行的,收集过程中应用依然会创建新的对象和引用,为了避免CMS还未回收完空间,应用就面临无空间可用的情况,CMS需要额外的堆内存。并且,CMS会基于最近的历史,预判内存耗尽的时间,在这个事件前就会执行垃圾回收,而且,当老年代的内存使用量大到一定的比例时,也会提前执行垃圾回收,这个比例有参数可以设置,默认是大约92%。

        还有一种情况,因为回收是分代独立执行的,有时年轻代刚执行完收集CMS就需要暂停,这样会增加一次暂停的时间,为了避免这种情况的发生,CMS会将remark阶段尽量安排在两次年轻代暂停的中间时刻执行。初始标记阶段因为时间太短,所以没有这种安排。

        CMS还会有一个问题,是浮动垃圾(floating garbage),因为并发的原因,有些对象会在标记阶段变为垃圾,这些只能等到下一次老年代回收的时候才能清理,这些垃圾就被称为浮动垃圾。

        最后是G1回收器(Garbage-First Garbage Collector)

        CMS虽然减少了暂停时间,但吞吐量降低了,G1则可以最大限度的同时满足两者。G1将堆内存看作一个整体,然后将整个堆划分成固定大小的若个区域,如下图所示:

图片

        在G1的内存管理中,年轻代和老年代都是逻辑上的,上图中浅蓝色是年轻代,深蓝色是老年代,红色是要回收的区域,S代表了年轻代中的生存区,大于半个区大小的对象放在H中。

        执行的时候,首先是一个并发的全局标记阶段,用来确定整个堆中各个区域的活跃度,这样,G1就会指导哪些区域经常是空的,回收的时候会优先从这些区域开始,这也是为什么叫做Garbage-First的原因。G1使用暂停预测模型来满足用户定义的暂停时间目标,并根据指定的暂停时间目标选择要收集的区域数量。

        G1会通过拷贝的方式,将多个区域的存活对象拷贝到一个区域中,这样既回收了内存又压缩了空间。这个过程是多线程并行的,因此缩短了暂停时间。对于压缩空间来说,G1不像并行回收器那样进行整体压缩,而是每次只压缩回收数量的区域,是一个持续减少碎片的过程,这也使得暂停时间更短。

        在实现上,因为不是整体回收,G1需要知道从堆的未收集部分到正在收集的堆部分的指针在哪里。G1引入了新的概念——卡表(Card Tables),大家有兴趣可用看看,这里就不介绍了。

        不过G1不是一个实时回收器,它是根据历史来判断需要回收多少区域以满足暂停时间目标,这是一种尽力而为而不是绝对的。

        G1默认在heap的使用率达到45%的时候开始进行标记,这个值可以通过参数设置。无论是对年轻代的收集还是对老年代的回收,都可能会触发混合回收(上图展示的就是一次混合回收,年轻代和老年代都有要回收的区域)。

        重点强调

        在介绍除了G1回收器外,其余3个回收器都是按照年轻代回收算法和老年代回收算法描述的,因为对应了不同代的回收方式,这3个回收器在进行Full GC的时候会先执行年轻代的回收算法来回收年轻代内存,再执行老年代的算法回收老年代,但有例外。如果老年代已经没有空间接收年轻代回收时要提升到老年代的对象时,除了CMS外,其余两个都会不执行年轻代回收算法,而是用老年代的回收算法回收整个堆内存(CMS的老年代算法无法用于回收年轻代)。

        CMS存在并发模式失败(Concurrent Mode Failure)的情况,前面也提到,CMS因为回收内存的时候应用程序还在运行,所以如果还没回收完老年代就已经面临无内存可用的情况时,就会出现并发模式失败,此时,所有应用程序的线程都会被挂起,回收会在Stop-the-world的模式下完成。

        G1存在分配失败(Allocation (Evacuation) Failure)的情况。与CMS的并发模式失败的情况类似,当G1需要将一个区域的对象复制到另一个区域时(压缩),如果找不到空闲的区域,就会发生分配失败,同样,此时应用程序的所有线程会被挂起,回收会在Stop-the-world的模式下完成。

        好了,第二节垃圾回收器的内容就介绍到这,计划下一集介绍一下垃圾回收器的各个参数、调优的方法和分析工具,敬请期待。

更多内容请关注我的公众号:

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值