深入理解Java虚拟机|JVM03-垃圾收集器与内存分配策略

深入理解Java虚拟机

  • 第3章 垃圾回收器与内存分配策略
    • 3.2 对象已死?
      • 3.2.1 引用计数法
      • 3.2.2可达性分析算法
      • 3.2.3 再谈引用
      • 3.2.4 生存还是死亡
      • 3.2.5 回收方法区
    • 3.3 垃圾收集算法
      • 3.3.1 分代收集理论
        • GC分类
      • 3.3.2 标记-清除算法
      • 3.3.3 标记-复制算法
      • 3.3.4 标记-整理算法
      • 概念补充:
      • 对象分配的过程:
        • 为对象分配内存:TLAB
    • 3.4 HotSpot的算法实现
      • 3.4.1 枚举根节点
      • 3.4.2 安全点
      • 3.4.3 安全区域
      • 3.4.4 记忆集与卡表
      • 3.4.5 写屏障
      • 3.4.6 并发的可达性分析
    • 3.5 经典垃圾收集器
      • 较简单的收集器
      • CMS收集器
      • Garbage First收集器
      • ZGC收集器
      • 3.7 如何选择垃圾收集器
        • GC日志查看
      • 3.8、内存分配与回收策略
      • 3.9 衡量垃圾收集器的三项最重要的指标
  • 参考

第3章 垃圾回收器与内存分配策略

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的"高墙",墙外面的人想进去,墙里面的人却想出来。

GC需要完成的3件事情:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

前面提到的Java内存运行时区域的各部分,其中程序计数器、虚拟机栈、本地方法在3各区域随线程而生,随线程而灭,栈中的栈帧随着方法进入和退出而出栈入栈,而每个栈帧分配多少内存基本在类结构确定下来时就已知。所以,这三个区域的内存分配和回收都具备确定性,不需要考虑去回收问题,线程结束时自然就回收了。

本章主要关注于Java堆和方法区这两个具有显著不确定性的区域。

3.2 对象已死?

垃圾收集器在做垃圾回收的时候,首先需要判定的就是哪些内存是需要被回收的,哪些对象是「存活」的,是不可以被回收的;哪些对象已经「死掉」了,需要被回收。

3.2.1 引用计数法

Java 堆 中每个具体对象(不是引用)都有一个引用计数器。当一个对象被创建并初始化赋值后,该变量计数设置为1。每当有一个地方引用它时,计数器值就加1。当引用失效时,即一个对象的某个引用超过了生命周期(出作用域后)或者被设置为一个新值时,计数器值就减1。任何引用计数为0的对象可以被当作垃圾收集。当一个对象被垃圾收集时,它引用的任何对象计数减1。

优点:

引用计数收集器执行简单,判定效率高,交织在程序运行中。对程序不被长时间打断的实时环境比较有利。

缺点:

难以检测出对象之间的循环引用。同时,引用计数器增加了程序执行的开销。所以Java语言并没有选择这种算法进行垃圾回收。

3.2.2可达性分析算法

可达性分析算法又叫根搜索算法,该算法的基本思想就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些起始点开始根据引用链往下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 对象之间没有任何引用链的时候(不可达),证明该对象是不可用的,于是就会被判定为可回收对象。

如下图所示: Object5、Object6、Object7 虽然互有关联, 但它们到GC Roots是不可达的, 因此也会被判定为可回收的对象。
深入理解Java虚拟机|JVM03-垃圾收集器与内存分配策略_第1张图片

GC Roots的对象包括固定的几种和一些运行时临时加入的。

固定的GC Roots有:

1)在虚拟机栈中引用的对象,如各个线程被调用的方法堆栈中使用到的参数,局部变量,临时变量
2)在方法区中类静态属性引用的对象,如Java类引用类型静态变量
3)在方法区中常量引用对象,如字符串常量池里的引用
4)在本地方法栈中JNI,如基本数据类型对应的Class对象,还有系统类加载器
5)所有被同步锁(synchronized关键字)持有的对象
6)反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调,本地代码缓存等。

一个判断的小方法:由于Root采用的是栈方法存放变量和指针,所以如果一个指针保存了堆里面的对象,但是自己又不存放在堆内存中, 则它是一个Root。

动态的加入则是考虑了分代收集和局部回收,所以收集某个区域对象时,要将其它区域的对象加入到GC Roots中。

判断可达性分析时,要确保根节点的美剧要在一个一致性的快照中进行,也就是说要避免根节点集合的对象引用关系在枚举期间不断变化,所以垃圾收集过程必须停顿所有用户线程,即“stop the world”,类似的停顿在垃圾收集时的标记过程也会发生,只是后者时间更短。

3.2.3 再谈引用

无论是通过引用计数器还是通过可达性分析来判断对象是否可以被回收都设计到“引用”的概念。JDK1.2之后, Java 中根据引用关系的强弱不一样,将引用类型划为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)。

  • 强引用:Object obj = new Object()这种方式就是强引用,只要这种强引用存在,垃圾收集器就永远不会回收被引用的对象。JDK1.2之前的传统引用。

  • 软引用:用来描述一些有用但非必须的对象。在 OOM 之前垃圾收集器会把这些被软引用的对象列入回收范围进行二次回收。如果本次回收之后还是内存不足才会触发 OOM。在 Java 中使用 SoftReference 类来实现软引用。

  • 弱引用:同软引用一样也是用来描述非必须对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在 Java 中使用 WeakReference 类来实现。

  • 虚引用:是最弱的一种引用关系,一个对象是否有虚引用的存在完全不影响对象的生存时间,也无法通过虚引用来获取一个对象的实例。一个对象使用虚引用的唯一目的是为了在被垃圾收集器回收时收到一个系统通知。在 Java 中使用 PhantomReference 类来实现。

3.2.4 生存还是死亡

一个对象是否应该在垃圾回收器在GC时回收,至少要经历两次标记过程。

第一次标记:如果对象在进行可达性分析后被判定为不可达对象,那么它将被第一次标记并且进行一次筛选。筛选的条件是此对象是否有必要执行 finalize() 方法。对象没有覆盖 finalize() 方法或者该对象的 finalize() 方法曾经被虚拟机调用过,则判定为没必要执行。

finalize()第二次标记:如果被判定为有必要执行 finalize() 方法,那么这个对象会被放置到一个 F-Queue 队列中,并在稍后由虚拟机自动创建的、低优先级的 Finalizer 线程去执行该对象的 finalize() 方法。但是虚拟机并不承诺会等待该方法结束,这样做是因为,如果一个对象的 finalize() 方法比较耗时或者发生了死循环,就可能导致 F-Queue 队列中的其他对象永远处于等待状态,甚至导致整个内存回收系统崩溃。finalize() 方法是对象逃脱死亡命运的最后一次机会,如果对象要在 finalize() 中挽救自己,只要重新与 GC Roots 引用链关联上就可以了。这样在第二次标记时它将被移除「即将回收」的集合,如果对象在这个时候还没有逃脱,那么它基本上就真的被回收了。对象的finalize()方法只会执行一次。

所以根据finalize的存在,是否可回收就可以分为三个判断状态:

  • 可触及的:从根节点出发能到达该点。
  • 可复活的:对象的所有引用被释放,但是对象可能在finalize中复活。
  • 不可触及的:对象的finalize被调用,并且没有复活,则进入不可触及状态。不可触及对象不可能复活,因为对象的finalize()方法只会执行一次。

3.2.5 回收方法区

在 Java 虚拟机规范中没有要求方法区实现垃圾收集,而且方法区垃圾收集的性价比也很低。

方法区(永久代)的垃圾收集主要回收两部分内容:废弃常量和无用的类。

废弃常量的回收和 Java 堆中对象的回收非常类似,这里就不做过多的解释了。

类的回收条件就比较苛刻了。要判定一个类是否可以被回收,要满足以下三个条件:

  • 该类的所有实例已经被回收;
  • 加载该类的 ClassLoader 已经被回收;
  • 该类的 Class 对象没有被引用,无法再任何地方通过反射访问该类的方法

3.3 垃圾收集算法

根据如何判断对象消亡,垃圾收集器算法可分为“引用计数式垃圾收集”和“追踪式垃圾收集”,而本文主要讨论的HotSpot VM是采用的可达性分析算法判断,所以这里讨论的垃圾收集算法都是追踪式垃圾收集”。

这部分可以参考博客图片:(10条消息) 深入理解Java虚拟机-垃圾回收器与内存分配策略_ThinkWon的博客-CSDN博客

3.3.1 分代收集理论

1)弱分代假说:绝大多树对象都是朝生夕灭的。
2)强分代假说:熬过越多次垃圾收集过程的对象就越难被回收。

依据这两个假说,奠定了多款常用垃圾收集器的一直设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(对象熬过的垃圾收集过程次数)分配到不同的区域中存储。

GC分类

针对HotSpot的实现,其将GC按回收区域分为两种类型:一种是部分收集(Partail GC),一种是整堆收集(Full GC)。

  • 部分收集(Partial GC):

    • 新生代收集(“Minor GC/Young GC”,收集新生代中的可回收对象)
    • 老年代收集(“Major GC/Old GC”,收集老年代中的可回收对象,有时候回合Full GC混淆使用。目前只有CMS收集器支持单独收集老年代)
    • 混合收集(“Mixed GC”,收集整个新生代及部分老年代的可回收对象,只有G1收集器支持)
  • 整堆收集(“Full GC”)收集整个Java堆和方法区中的可回收对象。

但是这两个理论尚不够完整,未考虑到对象之间的跨代引用,于是有了第三个假说。

3)跨代引用假说:跨代引用相对于同代引用来说仅占极少数。

依据该假说,只需要在新生代上建立一个记忆集(remembered set)将老年代划分成若干小块,标识出哪一块会存在跨代引用,之后Minor GC时,指把该小块内存里包含了跨代引用的老年代对象加入到GC Roots中扫描,再使用可达性分析算法标记回收对象。

3.3.2 标记-清除算法

标记-清除算法(Mark-Sweep)是一种常见的基础垃圾收集算法,它将垃圾收集分为两个阶段:

标记阶段:标记出可以回收的对象。
清除阶段:回收被标记的对象所占用的空间。
标记-清除算法之所以是基础的,是因为后面讲到的垃圾收集算法都是在此算法的基础上进行改进的。

优点:实现简单,不需要对象进行移动。

缺点:标记、清除过程效率低,产生大量不连续的内存碎片,提高了垃圾回收的频率。

注意:这里的清除是指下次分配空间时,如果要回收的对象空间大小足够,则用该部分空间分配给新的对象。

3.3.3 标记-复制算法

为了解决标记-清除算法的效率不高的问题,产生了复制算法。它把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域中,最后将当前使用的区域的可回收的对象进行回收。

优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。

缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。

商用虚拟机大多优先采用这种收集算法回收新生代。为了缓解内存严重浪费问题, 针对具有“朝生夕灭”特点的对象(新生代具有这样特点)提出更优化的半区复制分代策略,叫做“Appel式回收”。其将新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只是用Eden和其中的一块Survivor。垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和刚用过的那块Survivor空间。HotSpot虚拟机默认的Eden和Survivor比例是8:1,即每次新生代中可用的内存空间为整个新生代空间的90%,因为有2个Survivor空间,则刚好是8:1:1。当然不能保证每次都只有不多于10%的对象存活,所以需要有个逃生门的安全设计,当Survivor空间不足以容纳一次Minor GC之后存货的对象时,就要依赖于其它区域(老年代)做担保分配,即通过分配担保机制直接进入老年代。

3.3.4 标记-整理算法

在新生代中可以使用复制算法,但是在老年代就不能选择复制算法了,因为老年代的对象存活率会较高,这样会有较多的复制操作,导致效率变低。标记-清除算法可以应用在老年代中,但是它效率不高,在内存回收后容易产生大量内存碎片。因此就出现了一种标记-整理算法(Mark-Compact)算法,与标记-整理算法不同的是,在标记可回收的对象后将所有存活的对象压缩到内存的一端,使他们紧凑的排列在一起,然后对端边界以外的内存进行回收。回收后,已用和未用的内存都各自一边。

优点:解决了标记-清理算法存在的内存碎片问题。

缺点:仍需要进行局部对象移动,一定程度上降低了效率。移动存活的对象,那么在移动对象的这个时候程序全部暂停一下,即“stop the world”现象。

总之,是否移动对象都存在弊端,移动则内存回收时更复杂,不移动则内存分配时更复杂。有一些则是采用二者综合,即先采用标记-清除算法,知道额你存空间的碎片化程度大到影响对象分配,则采用一次标记-整理算法收集一次,活动规整的内存空间。

概念补充:

新生代(Young generation)
绝大多数最新被创建的对象会被分配到这里,由于大部分对象在创建后会很快变得不可达,所以很多对象被创建在新生代,然后消失。对象从这个区域消失的过程我们称之为 minor GC。

新生代 中存在一个Eden区和两个Survivor区。新对象会首先分配在Eden中(如果新对象过大,会直接分配在老年代中)。在GC中,Eden中的对象会被移动到Survivor中,直至对象满足一定的年纪(定义为熬过GC的次数),会被移动到老年代。

可以设置新生代和老年代的相对大小。这种方式的优点是新生代大小会随着整个堆大小动态扩展。参数 -XX:NewRatio 设置老年代与新生代的比例。例如 -XX:NewRatio=8 指定 老年代/新生代 为8/1. 老年代 占堆大小的 7/8 ,新生代 占堆大小的 1/8(默认即是 1/8)。

例如:

-XX:NewSize=64m -XX:MaxNewSize=1024m -XX:NewRatio=8

老年代(Old generation)
对象没有变得不可达,并且从新生代中存活下来,会被拷贝到这里。其所占用的空间要比新生代多。也正由于其相对较大的空间,发生在老年代上的GC要比新生代要少得多。对象从老年代中消失的过程,可以称之为major GC(或者full GC)。

永久代(permanent generation)
像一些类的层级信息,方法数据 和方法信息(如字节码,栈 和 变量大小),运行时常量池(JDK7之后移出永久代),已确定的符号引用和虚方法表等等。它们几乎都是静态的并且很少被卸载和回收,在JDK8之前的HotSpot虚拟机中,类的这些**“永久的”** 数据存放在一个叫做永久代的区域。

永久代一段连续的内存空间,我们在JVM启动之前可以通过设置-XX:MaxPermSize的值来控制永久代的大小。但是JDK8之后取消了永久代,这些元数据被移到了一个与堆不相连的称为元空间 (Metaspace) 的本地内存区域。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-B2Yn1H6R-1627355944674)(D:\cs-book\笔记\jvm\img\image-20210722203206754.png)]

对象分配的过程:

为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑Gc执行完内存回收后是否会在内存空间中障生内存碎片。

  1. new的对象先放伊甸园区。此区有大小限制。
  2. 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
  3. 然后将伊甸园中的剩余对象移动到幸存者0区。
  4. 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会I放到幸存者1区。
  5. 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。
  6. 啥时候能去养老区呢?可以设置次数。默认是15次。·可以设置参数: -XX:MaxTenuringThreshold=进行设置。
  7. 在养老区,相对悠闲。当养老区内存不足时,再次触发cC:Major Gc,进行养老区的内存清理。
  8. 若养老区执行了Major GC之后发现依然无法进行对象的保存,就会产生ooM异常

深入理解Java虚拟机|JVM03-垃圾收集器与内存分配策略_第2张图片

为对象分配内存:TLAB

TLAB(Thread Local Allocation Buffer)本地线程分配缓冲。

·堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据,·由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的。

(具体划分内存空间有指针碰撞和空闲列表两种方式,看2.3.1节对象的创建,这里只介绍解决空间分配的线程安全问题)

解决方案有两种:

  • 一种是同步加锁,为避免多个线程操作同一地址,需要使用加锁等机制,但是这种方式很影响分配速度。
  • 一种是本地线程分配缓存(TLAB)。从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。哪个线程要分配内存就在哪个线程的本地缓冲区种分配。只有本地缓存区用完了才需要同步锁定来分配新的缓存区。

多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。

·据我所知所有openJDK衍生出来的JVM都提供了TLAB的设计。

小结

深入理解Java虚拟机|JVM03-垃圾收集器与内存分配策略_第3张图片

深入理解Java虚拟机|JVM03-垃圾收集器与内存分配策略_第4张图片

3.4 HotSpot的算法实现

3.4.1 枚举根节点

可达性分析枚举GC Roots时 ,必须stop the world
目前JVM使用准确式GC,停顿时并不需要一个个检查,而是从预先存放的地方直接取。(HotSpot保存在OopMap数据结构中,OopMap可以邦族HotSpot快速准确地完成GC Roots枚举)

3.4.2 安全点

基于效率考虑,生成OopMap只会在特定的地方,称为安全点,目的在于解决停顿用户线程。
如何保证垃圾收集时所有线程到达安全点并停顿:

  • 抢先式中断:现代JVM不采用
  • 主动式中断:线程轮询安全点标识,然后挂起

3.4.3 安全区域

对于没有分配cpu的线程(sleep),安全点无法处理,由安全区域解决
安全区域指一段代码中引用关系不会发生变化
线程进入安全区域时,JVM发起GC就不用管这些线程,离开时需要检查GC是否完成,未完成就需要等待。

3.4.4 记忆集与卡表

前面分代收集理论提到为解决跨代引用问题,垃圾收集器在新生代建立了一个叫记忆集的数据结构来避免把整个老年代加进 GC Roots扫描范围,这里的记忆集是一种记录从非收集区域指向收集区域的指针的集合的抽象数据结构,根据记忆集的记录粒度的粗狂程度(定位精度),可以有几种实现,常用的是卡精度——指每个记录精确到一块内存区域,该内存区域内有对象含有跨代指针。

这种“卡精度”使用一种“卡表方式实现,相当于一个map,key是非收集区域的一个内存区域——“卡页”,value是0或1,分别代表没有跨代指针和有跨代指针,但是由于内存区域大小相同,所以直接采用一个字节数组就可实现,每个索引都对应一个内存区域的起始地址,值则是0或1。有跨代指针称为这个元素变脏(dirty),实际垃圾收集时,赛选出表中变dirty的元素,就可以知道那些卡页内存块存在跨代指针,然后把它们加入GC Roots中一起扫描。

3.4.5 写屏障

解决卡表如何维护。

3.4.6 并发的可达性分析

解决或降低用户线程停顿。

对象消失问题的产生条件:

复制器插入了一条或多条从黑色对象到白色对象的新引用。

复制器删除全部从灰色到该白色对象的直接或间接引用。

解决对象消失问题即破坏这两个条件中任意一个,分别两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning ,SATB)。

3.5 经典垃圾收集器

这里的经典是指 JDK7 Update 4之后到JDK11正式发布前,Oracle JDK的HotSpot虚拟机中包含的全部垃圾收集器。

深入理解Java虚拟机|JVM03-垃圾收集器与内存分配策略_第5张图片

较简单的收集器

Serial收集器(复制算法): 新生代单线程收集器,标记和清理都是单线程,优点是简单高效;
ParNew收集器 (复制算法): 新生代收并行集器,实际上是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现;
Parallel Scavenge收集器 (复制算法): 新生代并行收集器,追求高吞吐量,高效利用 CPU。吞吐量 = 用户线程时间/(用户线程时间+GC线程时间),高吞吐量可以高效率的利用CPU时间,尽快完成程序的运算任务,适合后台应用等对交互相应要求不高的场景;
Serial Old收集器 (标记-整理算法): 老年代单线程收集器,Serial收集器的老年代版本;
Parallel Old收集器 (标记-整理算法): 老年代并行收集器,吞吐量优先,Parallel Scavenge收集器的老年代版本;

注意:JDK8默认的是Parallel Scavenge收集器 (复制算法): 新生代并行收集器+Serial Old收集器 (标记-整理算法): 老年代单线程收集器

CMS收集器

CMS(Concurrent Mark Sweep)收集器(标记-清除算法): 老年代并行收集器,以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短GC回收停顿时间。特点的是并发收集,低停顿。

1)初始标记:只是标记一下GC Roots能够直接关联到的对象,速度很快。
2)并发标记:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
3)重新标记:为了修正并发标记期间,因程序继续运行而导致标记产生变动的那一部分对象的标记记录。因为并发标记是与垃圾收集线程一起并发运行的,所以会导致一部分对象有变动
4)并发清除:清理删除掉标记阶段已经标记为可回收的对象,由于不需要移动存活对象,所以这个阶段也是可用和用户线程同时并发的
在初始标记和重新标记这两个步骤仍然是需要stop the world 的。

缺点:
1)在并发阶段,虽然不会导致用户线程停顿,但是他会占用一部份线程而导致程序变慢,降低了吞吐量。
2)在并发标记和并发清理步骤中,用户线程还在继续运行,程序就会产生新的可回收的垃圾对象,这一部分垃圾对象是出现在标记重新标记后的,那么在并发清除步骤是就不会将这新产生的垃圾对象进行回收,只能留在下次垃圾收集的时候再清理。这段时间产生的垃圾对象也称为浮动垃圾。
3)因为CMS是基于标记-清除算法实现的收集器,就意味着收集结束后会出现大量的空间碎片,空间碎片过多时,以后对大对象的分配就带来很大困难,当连续空间不够来分配这个大对象时,则会触发一次Full GC。JDK9之前可以通过-XX:CMSFullGCsBeforeCompaction来整理内存空间。

Garbage First收集器

G1(Garbage First)收集器 (宏观上标记-整理算法,微观上标记-清楚算法)与之前介绍的收集器有很大不同。是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征.
是基于Region的堆内存布局是实现这个收集器的关键。G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都要根据需要,扮演新生代的Eden、Survivor空间或者老年代空间。
Region中还有一类特殊的Humongous区域,专门用来存储大对象。只要是对象大小超过Region容量的一半就认为是大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB-32MB,且为2的N次幂。
G1收集器也是可以根据-XX:MaxGCPauseMillis 参数来设定垃圾收集而stop the world 的时间。
整个过程分为4个步骤:
1)初始标记:暂停所有的其他线程,只是标记一下GC Roots能够直接关联到的对象,速度很快
2)并发标记:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
3)最终标记:为了修正并发标记期间,因程序继续运行而导致标记产生变动的那一部分对象的标记记录。因为并发标记是与垃圾收集线程一起并发运行的,所以会导致一部分对象有变动
4)筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来指定回收计划。这里的操作涉及存活对象的移动,必须暂停用户线程,由多条收集器线程并行完成。回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个region中,这种不会像CMS那样回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。

G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字Garbage-First的由来),比如一个Region花200ms能回收10M垃圾,另外一个Region花50ms能回收20M垃圾,在回收时间有限情况下,G1当然会优先选择后面这个Region回收。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以尽可能高的收集效率。

G1垃圾收集分类

YoungGC
YoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发Young GC

MixedGC 不是FullGC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发,回收所有的Young和部分Old(根据期望的GC停顿时间确定old区垃圾收集的优先顺序)以及大对象区,正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC

Full GC 停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批Region来供下一次MixedGC使用,这个过程是非常耗时的。

ZGC收集器

启用参数:-XX:+UseZGC
是在JDK11中新加入的低延迟的垃圾收集器。
内存布局与G1一样,采用Region的堆内存布局,分为大中小三类容量:
1)小型Region:容量固定为2MB,用于存放小于256KB的小对象
2)中型Region:固定容量是32MB,用于放至大于等于256KB但小于4MB的对象
3)大型Region:容量不固定,可以动态变化,但必须是2MB的整数倍,用于放至4MB及以上的大对象。
运作过程分为4个大阶段:
1)并发标记:与G1相同
2)并发预备重分配:根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集。
3)并发重分配:是ZGC的核心阶段,要把重分配集中的存活对象复制到新的Region上,并重分配集中的每个Region维护一个转发表,记录从旧对象到新对象的转向关系。
4)并发重映射:重映射所做的就是修正整个堆中指向重新分配集中旧对象的所以引用。

3.7 如何选择垃圾收集器

  1. 优先调整堆的大小让服务器自己来选择
  2. 如果内存小于100M,使用串行收集器
  3. 如果是单核,并且没有停顿时间的要求,串行或JVM自己选择
  4. 如果允许停顿时间超过1秒,选择并行或者JVM自己选
  5. 如果响应时间最重要,并且不能超过1秒,使用并发收集器
  6. 4G以下可以用parallel,4-8G可以用ParNew+CMS,8G以上可以用G1,几百G以上用ZGC

GC日志查看

1)GC基本信息:jdk9之前-XX:+PrintGC jdk9之后-Xlog:gc:
2)GC详情信息:jdk9之前-XX:+PrintGCDetails jdk9之后-Xlog:gc*
3)查看GC前后堆、方法区可用容量变化:jdk9之前-XX:+PrintHeapAtGC jdk9之后-Xlog:gc+heap=debug:
4)查看GC过程中用户线程并发时间及停顿时间:jdk9之前-XX:+PrintGCApplicationConcurrentTime 以及-XX:+PrintGCApplicationStopTime jdk9之后-Xlog:safepoint:
5)查看收集器个分代区域大小,自动调节的相关信息:jdk9之前-XX:+PrintAdaptiveSizePolicy jdk9之后-Xlog:gc+ergo*=trace:
6)查看熬过收集后剩余对象的年龄分布信息:jdk9之前-XX:+PrintTenuringDistribution jdk9之后-Xlog:gc+age=trace:

3.8、内存分配与回收策略

1、对象优先在Eden分配
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够的空间进行分配对象时,虚拟机将发起一次Minor GC

2、大对象直接进入老年代
大对象就是指需要大量连续的内存空间的Java对象,最典型的大对象就是很长的那种字符串(图片转base64后),或者元素数量很庞大的数组。
-XX:PretenureSizeThreshold参数(只对Serial和ParNew收集器有效),指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Surivor区之间来回复制,产生大量内存复制操作。

3、长期存活的对象将进入老年代
虚拟机中多数收集器都是采用分代(新生代,老年代)收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活的对象放在老年代中。
对象通常在Eden区中诞生,如果经过第一次Monir GC后仍然存活,并且能被Survivor容纳的话,该对象会被移到另一个Survivor区中,并且起对象年龄设置为1.对象在Survivor区中每熬过一次Monir GC,年龄就会加1,当他的年龄到达一定的程度(默认15),就会将该对象放至老年代中。这个升级值老年代的阈值,可以通过-XX:MaxTenuringThreshold参数设置。

4、动态对象年龄判断
如果在Survivor空间中低于或等于某年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可用直接进入老年代。

5、空间分配担保
在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么这一次的Minor GC可用确保时安全的。防止所有对象都进入老年代。

3.9 衡量垃圾收集器的三项最重要的指标

衡量垃圾收集器的三项最重要的指标内存占用。吞吐量,延迟。其中前者由于硬件进步,更能容忍内存占用多一些。硬件进步给吞吐量带来提升,但是对延迟带来了负面效果,因为需要回收完整的1TB堆内存肯定比回收1GB堆内存耗时。

于是延迟成了越来越重视的指标。

阿里的GC收集器就是基于G1修改的,所以要仔细看这个。

高吞吐量最好因为这会让应用程序的最终用户感觉只有应用程序线程在做“生产性”工作。 直觉上,吞吐量越高程序运行越快。 低暂停时间最好因为从最终用户的角度来看不管是GC还是其他原因导致一个应用被挂起始终是不好的。 这取决于应用程序的类型,有时候甚至短暂的200毫秒暂停都可能打断终端用户体验。 因此,具有低的最大暂停时间是非常重要的,特别是对于一个交互式应用程序。

不幸的是**”高吞吐量”和”低暂停时间”是一对相互竞争的目标(矛盾)**。这样想想看,为了清晰起见简化一下:GC需要一定的前提条件以便安全地运行。 例如,必须保证应用程序线程在GC线程试图确定哪些对象仍然被引用和哪些没有被引用的时候不修改对象的状态。 为此,应用程序在GC期间必须停止(或者仅在GC的特定阶段,这取决于所使用的算法)。 然而这会增加额外的线程调度开销:直接开销是上下文切换,间接开销是因为缓存的影响。 加上JVM内部安全措施的开销,这意味着GC及随之而来的不可忽略的开销,将增加GC线程执行实际工作的时间。 因此我们可以通过尽可能少运行GC来最大化吞吐量,例如,只有在不可避免的时候进行GC,来节省所有与它相关的开销。

然而,仅仅偶尔运行GC意味着每当GC运行时将有许多工作要做,因为在此期间积累在堆中的对象数量很高。 单个GC需要花更多时间来完成, 从而导致更高的平均和最大暂停时间。 因此,考虑到低暂停时间,最好频繁地运行GC以便更快速地完成。 这反过来又增加了开销并导致吞吐量下降,我们又回到了起点。
综上所述,在设计(或使用)GC算法时​​,我们必须确定我们的目标:一个GC算法​​只可能针对两个目标之一(即只专注于最大吞吐量或最小暂停时间),或尝试找到一个二者的折衷。

本章学习结束,细节需要重要关注运行时数据区分别存储的是什么,以及几种垃圾收集算法,重要的垃圾收集器CMS和G1.有时间再补充学习地停顿的两个先进收集器。我认为最重要的收集器应该是G1.

看的时候配合宋红康的JVM视频或者PPT看。

参考

笔记主要内容来自《深入理解Java虚拟机 第3版》,里面的一些插图来自尚硅谷的宋红康老师的ppt。

你可能感兴趣的:(jvm,java,jvm,jvm.gc)