深入理解JVM虚拟机(第二、三章)

内容参考:《深入理解JVM虚拟机》

JVM

第二章 内存区域

img

程序计数器

是一块比较小的内存空间,可以看作是当前线程锁执行的字节码的行号指示器。

Java虚拟机的多线程是通过线程轮流切换,分配处理器执行时间来实现的,在任意一个时刻,一个处理器只能执行一条线程中的指令,因此为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,从而保证线程之间是互不影响的

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值则为空(Undefined

Java堆

Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,Java堆是垃圾收集器管理的内存区域,因此也叫做GC堆

从内存分配来看:Java堆中还可以划分出多个线程私有的分配缓冲区TLAB,细分的目的也是为了更好的分配和回收内存。

从内存回收来看:Java堆还可以细分为新生代老年代;再细致一点的有Eden空间、From Survivor空间和To Survivor空间等。

实际上,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。

如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

方法区

方法区与Java堆一样是所有线程共享的内存区域,用于存储已被虚拟机加载的类信息常量静态变量即时编译器编译后的代码等数据。

这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载

运行时常量池是方法区的一部分,用于存放编译期生成的各种字面常量和符号引用,运行期间可以将新的常量放入池中,这种特性用的比较多的就是String类的intern方法

intern() : 检查String对象是否在常量池中存在,如果存在直接返回字符串的引用,如果不存在则将字符串的添加到常量池中,并且返回次string对象的引用。也就是会将首次遇到的字符串复制到字符串常量池中存储。

看个例子:

String str1 = new StringBuilder("计算机").append("软件").toString();
sout(str1.intern() == str1)
  
String str2 = new StringBuilder("ja").append("va").toString();
sout(str2.intern() == str2)

在JDK6中,返回均为false,因为此时常量池还在方法区之中,那"计算机软件"肯定在常量池中还没有创建,那么就得新建,新建又在方法区,而右边是一个堆对象,必不可能是一个引用,为false,同理 str2一样

在JDK中,str1返回为ture, str2返回为false,"计算机软件"在常量池中没有,因此是首次遇到的字符串,则复制到字符串常量池(堆)中存储,并且返回引用,因此第一个为ture;但是在第二个能看到是“java”,“java”已经在常量池中先存在了,因此不是首次遇见的字符串,返回的是已有“java”字符串的引用,那么右边又是堆新建了一个,那么为false。

当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

Java虚拟机栈

Java虚拟机栈是线程私有的,它的生命周期与线程相同,描述的是方法的执行。每个方法在执行的时候都会同时创建一个栈帧用于存储局部变量表操作栈动态链接方法返回地址等信息。每一个方法从被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程,如下图所示

img

局部变量表以变量槽Slot)为最小存储单位,每个Slot能够存放一个boolean、byte、char、short、int、float、reference(对象引用)和returnAddress(指向了一条字节码指令的地址)类型的32位数据,对于64位的数据类型long和double,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。

在方法执行时,如果是实例方法,即非static方法,局部变量表中第0位Slot默认存放对象实例的引用,在方法中可以通过关键字 this 进行访问,方法参数按照参数列表顺序,从第1位Slot开始分配,方法内部变量则按照定义顺序进行分配其余的Slot。

如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存时会抛出OutOfMemoryError异常。

本地方法栈

其区别是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务

与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowErrorOutOfMemoryError异常。

1. 一个对象创建流程

new指令:检查这个指令的参数是否能够在常量池中定位到一个类的符号引用,检查是否被加载,解析,初始化过没有。

类加载完成后,就需要分配内存,两种方式

  • 想想内存规整,有个指针指明内存哪边用了,哪边空闲,将内存分配的空间向指针的位置挪动,发生指针碰撞
  • 如果是不连续空间,就必须找到一块足够大的空间给这个对象,那么是根据空闲列表来决定,

但是存在一个问题,创建对象是非常频繁的事,那么这个指针肯定是高频访问,在并发情况下就不安全,解决的方法要么CAS不断retry保证操作的原子性,要么分配的内存就在TLAB上,哪个线程要分配,就在哪个线程的本地缓冲区中分配,因为这片空间是私有的。

2. 对象的内存布局

对象头Header、实例数据Instance Data , 对齐填充Padding

对象头有两部分,一部分是mark word,另一部分是类型指针

  • mark word里就有 25bit存储哈希码、4bit存储分代年龄、2bit存储锁的标识位,1bit固定为0,标识状态(轻量级、重量级、gc锁定、可偏向)

  • 类型指针:对象志向它的类型愿数据的指针,虚拟机通过指针来确定对象是哪个类的实例

第三章 垃圾收集器和内存分配策略

考虑垃圾回收的三件事

  • 什么内存需要回收?

  • 什么时候内存回收?

  • 怎么进行内存回收?

如何触发内存回收?

1. System.gc()

显式的调用System.gc():调用此方法建议JVM进行Full GC,虽然只是建议而非一定,但是一般都会触发FGC,从而增加FGC的频率。但是一般都不使用此方法,而是让虚拟机自己去管理内存。

2. JVM垃圾回收机制决定

  • 创建对象时需要分配内存空间,如果空间不足,会触发GC
  • 其他回收机制
    • java.lang.Object中有一个finalize() 方法,当JVM确定不再有指向该对象的引用时,垃圾收集器在对象上调用该方法。finalize() 方法有点类似于对象周期的临终方法,JVM调用该方法,表示该对象即将“死亡”,之后就可以回收该对象了。(注意这里的回收还是在JVM去处理,所以手动调用finalize()方法,不会造成对象“死亡”)
怎么进行垃圾回收?

引用计数算法

在对象中添加一个引用计数器,每当有一个地方引用它的时候,计数器加1;当引用失效的时候,计数器减1;当计数器等于0的对象就是不可能再被使用的。

引用计数算法实现简单, 效率也很高,大多数情况下都很不错,但是有一个缺点:不能解决对象之间相互引用问题

可达性分析,基本思路就是一个GC Roots根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程走过的路径为“引用链”,如果在一个对象和GC Roots间没有任何引用链相连,那么就证明这个对象是不可能再使用的。

而引用reference分为四种

  • 强引用:垃圾回收器就永远不会回收掉被引用的对象

  • 软引用:在系统要发生内存溢出异常前,会把这些对象列为回收范围进行回收

  • 弱引用:只要垃圾回收开始,那么无论当前内存够不够,都会被回收

  • 虚引用:最弱的一种引用关系,目的就是为了能在这个对象被垃圾回收器回收的时候收到一个系统通知

finalize方法:是对象能否逃脱回收的最后一次机会。

因为在检查是否为回收对象时,先经过了可达性分析,如果判定为不可达的对象,也不一定会被回收。过程分为两步

第一步:可达性分析(第一次标记),标记不可达的对象

第二步:筛选这些对象是否有必要执行finalize方法?如果覆盖了finalize方法或者finalize方法已经被调用过,那么就代表没有必要执行。

执行finalize方法的过程:

  • 先放在F-QUEUE队列中,对队列中的对象进行第二次小规模标记
  • 看是否能够重新与引用链上的任何一个对象建立关联
    • 如果可以,移除即将回收的集合
    • 如果不可以,那么就被回收

任何一个对象的finalize方法只会被系统自动调用一次,如果对象面临下一次的回收,它的的finalize方法不会被再次执行

根据分代不同区分回收

  • 部分收集 Partial GC

    • 新生代收集,又叫Minor GC, Young GC:指的是新生代的垃圾收集,因为java对象一般都具有朝生夕灭的特性,所以Minor GC非常频繁,一般回收动作也比较快。
    • 老年代收集,又叫Major GC, Old GC:指的是老生代的垃圾收集,出现了Major GC,一般都会伴随着至少一次的Minor GC。CMS收集器会有单独收集老年代的行为
    • 混合收集,又叫Mixed GC:混合收集,目前只有G1收集器会有这种行为哦
  • 整堆收集 Full GC:收集整个Java堆和方法区的垃圾收集

3.1 垃圾回收算法

标记清除

思路:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象

缺点:

  • 执行效率不稳定,如果有大量对象,就需要大量标记和清除的动作,随着对象的增多,这些执行效率会降低
  • 内存空间的碎片化问题,标记清楚后会产生大量不连续的内存碎片,空间碎片太多可能导致程序运行过程中需要分配较大的对象时,无法找到足够的连续内存而不得不提前触发下一次的垃圾回收。
标记复制

思路:将内存划分为两块,一块先用,等回收的时候把存活的放在另一块上

缺点:虽然产生了大量的复制开销,但是分配内存时不用考虑有空间碎片的复杂情况。但是唯一不好的一点就是将可用内存缩小了。

引申:

  • Appel回收就是将新生代分为一块较大的Eden区 + 两块较小的Survivor区,每次分配只使用Eden和一块S区。一旦发生垃圾收集,将E + S区的对象一次性复制到另一个S区中,然后直接清理掉E+S区的空间
  • 空间大小:Eden : Survivor = 8:1,因此此时可用内存就变为了90%
标记整理

思路:让存活对象都向内存空间的一端移动,然后直接清理掉边界以外的内存,相当于实现了边回收边整理。

缺点:这种算法是移动的,如果移动存活对象,尤其是老年代这种每次回收都会有大量对象存活的区域,移动又更新空间是一项负重操作,而且移动的过程必须全部暂停用户应用程序才能进行,这种就叫做Stop the World

什么是stop the world

Java中Stop-The-World机制简称STW,是在执行垃圾收集算法时,Java应用程序的其他所有线程都被挂起(除了垃圾收集帮助器之外)。Java中一种全局暂停现象,全局停顿,所有Java代码停止,native代码可以执行,但不能与JVM交互;这些现象多半是由于gc引起。

但是,内存分配和访问相比垃圾回收的频率高的多,那么就应该尽量减少内存的访问,因此hotspot虚拟机关注吞吐量的parallel scavenge 收集器是基于标记整理算法的,还有关注延迟的CMS收集器是基于标记清除算法的。


3.2 垃圾回收细节

安全点Safe Point
  • 程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置称为“安全点(Safepoint) ”
  • Safe Point的选择很重要,如果太少可能导致GC等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。比如:选择些执行时间较长的指令作为Safe Point, 如方法调用、循环跳转和异常跳转等。

如何在GC发生时,检查所有线程都跑到最近的安全点停顿下来呢

  • 抢先式中断: (目前没有虚拟机采用了) 首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。
  • 主动式中断: 设置一个中断标志,各个线程运行到Safe Point的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。
安全区域Safe Region

Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint

但是,程序”不执行“的时候呢?例如线程处于Sleep 状态或Blocked状态,这时候线程无法响应JVM的中断请求,“走” 到安全点去中断挂起,JVM也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。

**安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。**我们也可以把Safe Region 看做是被扩展了的Safepoint。

程序实际执行时

1、 当用户线程运行到Safe Region的代码时,首先标识已经进入了Safe Region,如果这段时间内发生GC,JVM会忽略标识为Safe Region状态的用户线程即用户线程STW,等待JVM执行GC完毕;
2、当用户线程即将离开Safe Region时, 会检查JVM是否已经完成GC,如果完成了,则用户线程继续运行,否则用户线程必须等待直到收到可以安全离开SafeRegion的信号为止;

3.2 垃圾回收器

串行收集器
  • 只会使用一个CPU或一个收集线程去完成垃圾收集工作,它进行垃圾回收的时候,必须暂停其他所有的工作线程(Stop The World,STW),直到它收集完成。它适合Client模式的应用,在单CPU环境下,它简单高效,由于没有线程交互的开销,专心垃圾收集自然可以获得最高的单线程效率。
  • 串行的垃圾收集器有两种,Serial与Serial Old,一般两者搭配使用。新生代采用Serial,是利用复制算法;老年代使用Serial Old采用标记-整理算法。Client应用或者命令行程序可以,通过-XX:+UseSerialGC可以开启上述回收模式。
并行收集器

通过多线程运行垃圾收集的,也会stop-the-world。适合Server模式以及多CPU环境。一般会和jdk1.5之后出现的CMS搭配使用。并行的垃圾回收器有以下几种:

  • ParNew:Serial收集器的多线程版本,用于新生代收集,复制算法。
  • Parallel Scavenge: 关注吞吐量,吞吐量优先,用于新生代收集,复制算法
  • Parllel Old:Parallel Scavenge的老年代版本
CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获得最短回收停顿时间为目标的收集器。基于标记-清除算法的。但是它比一般的标记-清除算法要复杂一些,分为以下4个阶段

初始标记:标记一下GC Roots能直接关联到的对象,会STW

并发标记:GC Roots Tracing,从Root关联的对象遍历,可以和用户线程并发执行。

重新标记:标记期间产生的对象存活的再次判断,修正对这些对象的标记,执行时间相对并发标记短,会STW

并发清除:清除对象,可以和用户线程并发执行。

  • 由于它是基于标记-清除算法的,那么就无法避免空间碎片的产生

  • 而且会产生浮动垃圾,因为有两个阶段用户线程还在运行,程序运行自然就还会伴随着新垃圾的产生,但是这些垃圾是出现在标记过程结束后,CMS无法在这次处理它,只能待下一次GC时再清理掉

Garbage First收集器

JDK 9后,G1成为了默认的垃圾回收器,设计者希望做出一款能够建立起“停顿时间模型”的收集器,停顿时间模型就是在一个长度为M毫秒的时间片段内,小号在垃圾手机上的时间大概率不超过N毫秒的目标,就差不多是实时垃圾收集器。

思路的转变:之前按照分代来回收垃圾,而G1可以面向堆内存任何部分来组成一个回收集合,进行回收,决定它的是哪块内存中存放的垃圾数量最多,回收收益最大,这也就是G1开启的MixedGC模式。

稍微具体来说,G1虽然仍然保留新生代和老年代的概念,但是新生代和老年代不再固定,他们都是region的动态集合。G1收集器去跟踪各个region里面的垃圾堆积的“价值”大小,价值=回收获得的空间大小+回收所需要时间,然后在后台维护有一个优先级列表,每次根据用户设定的收集停顿时间(使用参数 -XX:MaxGCPauseMills指定,默认200毫秒),优先处理回收价值收益最大的region,这也就是G1名称的来源。

TAMS(Top at Mark Start):Region中的一部分空间划分出来用于并发回收新对象分配,因此每一个Region设计了两个TAMS,并发回收时候新分配的对象地址都必须要在这两个指针位置以上。

回收步骤:

  • 初始标记:初始标记阶段仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一个阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这个阶段需要STW,但耗时很短

  • 并发标记:从GC Roots开始对堆中对象进行可达性分析,找到存活的对象,这阶段耗时较长,可以和用户线程并发运行(只有这个阶段不会STW)

  • 最终标记:为了修正在并发标记期间,因用户程序继续运行而导致标记产生变化的那一部分标记记录,这阶段需要对用户线程做一个短暂的暂停

  • 筛选回收:首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来确定回收计划,是必须暂停用户线程,由多条收集器线程并行完成的

从步骤能看出,G1设计的时候,整体是使用“标记-整理”,但是Region之间基于“复制”算法,GC后会将存活对象复制到可用分区(未分配的分区),所以不会产生空间碎片。

G1分区的概念

G1的堆区在分代的基础上,引入分区的概念。G1将堆分成了若干Region,以下和”分区”代表同一概念。这些分区不要求是连续的内存空间)Region的大小可以通过G1HeapRegionSize参数进行设置,其必须是2的幂,范围允许为1Mb到32Mb。 JVM的会基于堆内存的初始值和最大值的平均数计算分区的尺寸,平均的堆尺寸会分出约2000个Region。分区大小一旦设置,则启动之后不会再变化。

例子:

img

Eden regions(年轻代-Eden区)

Survivor regions(年轻代-Survivor区)

Old regions(老年代)

Humongous regions(巨型对象区域)

Free regions(未分配区域,也会叫做可用分区)-上图中空白的区域

G1中的****巨型对象****是指,占用了Region容量的50%以上的一个对象。Humongous区,就专门用来存储巨型对象。如果一个H区装不下一个巨型对象,则会通过连续的若干H分区来存储。因为巨型对象的转移会影响GC效率,所以并发标记阶段发现巨型对象不再存活时,会将其直接回收。ygc也会在某些情况下对巨型对象进行回收。

CMS&&G1对比

堆空间上分配的不同

CMS收集器:将堆空间分成Eden、Servivor、old,并且他们是固定大小,JVM启动的时候设定且不能改变。

G1收集器:将堆空间分成多个大小相同的Region区域,逻辑上分Eden/Servivor、old,且大小是可变的,每次会根据GC的信息做出调整。

垃圾回收的方式不同

CMS收集器:是一个全量过程,垃圾回收时间不能把控

G1收集器:在垃圾回收的时候,可以根据回收时间的经验,垃圾最多的regions进行回收

由于G1内存占用和执行的额外负载都大于了CMS,目前在小内存应用CMS优于G1,而在大内存上应用G1大多能发挥优势。


3.3 低延迟垃圾收集器

衡量垃圾收集器的三个重要指标:吞吐量,内存占用,延迟

ZGC (Z Garbage Collector)

这部分内容来自https://yescode.blog.csdn.net/,写的很清楚。

ZGC的关键词:并发基于Region整理内存、支持NUMA、用了染色指针、用了读屏障,对了 ZGC 用的是 STAB。

1. 并发

ZGC 一共分了 10 个阶段,只有 3 个很短暂的阶段是 STW 的。

ea6ed8408b775a8dd0c6d6c1a4cbe57b

只有初始标记再标记初始转移阶段是 STW

初始标记就扫描 GC Roots 直接可达的,耗时很短,重新标记一般而言也很短

初始转移阶段也是扫描 GC Roots 也很短,所以可以认为 ZGC 几乎是并发的。这其实就是 ZGC 超过 G1 很关键的一个地方, G1 的对象转移需要 STW 所以堆大需要转移对象多,停顿的时间就长了,而 ZGC 有并发转移。那并发转移必然会导致一边给新对象分配内存,一边回收,因此需要预留一些空间给并发生成的新对象。

那如果空间不够呢?CMS会出现Full gc,而ZGC是阻塞应用线程,因此需要注意ZGC触发的时间。

2. 基于Region

为了能更细粒度的控制内存的分配,和 G1 一样 ZGC 也将堆划分成很多分区。

分了三种:2MB、32MB 和 X*MB(受操作系统控制)。

对于回收的策略是优先收集小区,中、大区尽量不回收。

3. Compacting整理内存

和 G1 一样都分区了所以肯定从整体来看像是标记-复制算法,所以也是会整理的。

因此 ZGC 也不会产生内存碎片。

4. 支持NUMA

首先介绍SMP(Symmetric Multi-Processor),因为每一个 CPU 对内存的访问速度是一致的,不用考虑不同内存地址之间的差异,所以也称一致内存访问(Uniform Memory Access, UMA )。

c048fef3fcabe568f1d034ca8b363f2c

随着CPU增多,那访问速度还是一致的,因此把 CPU 和内存集成到一个单元上,这个就是非一致内存访问 (Non-Uniform Memory Access,NUMA)

5d36ba795ecd83dab8b833c973fa29ff

同样还可以多个CPU共享一块内存462a25cbf4d893279b769ab6d73abcbd

因此在这,内存就被切分为本地内存和远程内存,当某个模块比较“热”的时候,就可能产生本地内存爆满,而远程内存都很空闲的情况。

比如 64G 内存一分为二,模块一的内存用了31G,而另一个模块的内存用了5G,且模块一只能用本地内存,这就产生了内存不平衡问题。

如果有些策略规定不能访问远程内存的时候,就会出现明明还有很多内存,却产生 SWAP(将部分内存置换到硬盘中) 的情况。

因此ZGC 对 NUMA 的支持是小分区分配时会优先从本地内存分配,如果本地内存不足则从远程内存分配,对于中、大分区的话就交由操作系统决定

5. 染色指针

有的回收标记放在对象头mark word,比如串行收集器。

有的把标记记录在与对象独立的数据结构中,比如G1,用bitmap记录标记位置。

然而在ZGC就直接把标记信息放在了,引用对象的指针上,因此进行可达性分析的时候,是遍历对象图,实则是遍历引用图!

在64位操作系统中,从 64 位拿几位来标识对象此时的情况,分别表示 Marked0、Marked1、Remapped、Finalizable。

fbae4a2c9aedc29572179dd5efe958eb

源码中的注释

5c8653105ff0f44c32f49edb8013aa6e

所以说 ZGC 最大支持 4TB ,不过这里先提个问题,为什么就支持 4TB,不是还有很多位没用吗

首先 X86_64 的地址总线只有 48 条 ,所以最多其实只能用 48 位,指令集是 64 位没错,但是硬件层面就支持 48 位。因为基本上没有多少系统支持这么大的内存,那支持 64 位就没必要了,所以就支持到 48 位。

那现在对象地址就用了 42 位,染色指针用了 4 位,不是还有 2 位可以用吗?是的,理论上可以支持 16 TB,不过暂时认为 4TB 够了,所以暂做保留,仅此而已没啥特别的含义。

染色指针的优势在哪里呢?

  • 使得一旦某个region的存活对象被一走后,这个region就能够被立即的释放和重用
  • 大幅减少在垃圾收集过程中内存屏障的使用数量,ZGC中只使用了读屏障。
  • 染色指针可以作为一种可扩展的存储结构来记录更多的对象标记,重定位等相关信息。

6. 读屏障

在 CMS 和 G1 中都用到了写屏障,而 ZGC 用到了读屏障。

写屏障是在对象引用赋值时候的 AOP,而读屏障是在读取引用时的 AOP。

比如 Object a = obj.foo;,这个过程就会触发读屏障。

也正是用了读屏障,ZGC 可以并发转移对象,而 G1 用的是写屏障,所以转移对象时候只能 STW。

简单的说就是 GC 线程转移对象之后,应用线程读取对象时,可以利用读屏障通过指针上的标志来判断对象是否被转移。

  • 如果被转移了的话,就修正对象的引用,不仅 a 能得到最新的引用地址,obj.foo 也会被更新,这样下次访问的时候一切都是正常的,就没有消耗了。

5094ef5f83dea7afc191d91412d3bd5f

这种也称之为**“自愈”**,不仅赋值的引用时最新的,自身引用也修正了。

染色指针和读屏障是 ZGC 能实现并发转移的关键所在

ZGC 回收流程解析

流程:

并发标记:遍历对象图进行可达性分析,在初始标记、再标记的时候都会短暂的STW,ZGC的标记不是在对象上进行的,标记阶段是更新染色指针的中Maked0 、Maked1标识位

并发预备重分配:查询本次收集过程中要清理哪些region,并将这些region组成重分配集(Relocation Set)

并发重分配:重分配集中的存活对象复制到新的region中,并且为重分配集的每个region维护一个转发表,记录就对象到新对象的转向关系,由于染色指针,就可以知道一个对象是否处于重分配集之中。并且,如果应用线程并发访问了这个位于重分配集的对象,会被读屏障解惑,然后根据这个region的转发表的记录,将访问转发到新复制的对象,不仅访问的更新了,自身引用的也更新了。

并发重映射:修正重分配集中的旧对象的所有引用,ZGC巧妙的将并发重映射阶段要做的工作,合并到了下一次GC的并发标记阶段,因为他们始终都会遍历对象图,那么在遍历的时候就顺便修改旧引用,原来记录新旧对象关系的转发表也就可以释放掉了。

ZGC 的步骤大致可分为三大阶段分别是标记、转移、重定位。

  • 标记:从根开始标记所有存活对象
  • 转移:选择部分活跃对象转移到新的内存空间上
  • 重定位:因为对象地址变了,所以之前指向老对象的指针都要换到新对象地址上。

并且这三个阶段都是并发的。在标记的时候如果发现引用的还是老的地址则会修正成新的地址,然后再进行标记。

这是意识上的阶段,具体的实现上重定位其实是糅合在标记阶段的。如何理解?

简单的说就是从第一个 GC 开始经历了标记,然后转移了对象,这个时候不会重定位,只会记录对象都转移到哪里了。

在第二个 GC 开始标记的时候发现这个对象是被转移了,然后发现引用还是老的,则进行重定位,即修改成新的引用。

96dfab019337ed1b4f657603464922df

3.4 内存分配与回收策略

对象优先在Eden分配

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

private static final int _1MB = 1024 * 1024;
/**
* VM参数: -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8

	解释 E 8m + S1 1m + S2 1m = -xmn 10m 也就是young区是10m survivorRation = 8 就是 e:s = 8:1  
*/
public static void testAllocation() {
	byte[] allocation1, allocation2, allocation3, allocation4;
	allocation1 = new byte[2 * _1MB];
	allocation2 = new byte[2 * _1MB];
	allocation3 = new byte[2 * _1MB];
	allocation4 = new byte[4 * _1MB]; // 出现一次Minor GC
}

产生这次垃圾收集的原因是为allocation4分配内存时,发现Eden已经被占用了6MB,剩余空间已不足以分配allocation4所需的4MB内存,所以发生Minor GC,垃圾收集期间虚拟机又发现已有的三个2MB大小的对象全部无法放入Survivor空间(Survivor空间只有1MB大小),所以只好通过分配担保机制提前转移到老年代去。

[GC [DefNew: 6651K->148K(9216K), 0.0070106 secs] 6651K->6292K(19456K), 0.0070426 secs] [Times: user=0.00 sys=0.0
Heap
	def new generation total 9216K, used 4326K [0x029d0000, 0x033d0000, 0x033d0000)  // allocation4在这里
		eden space 8192K, 51% used [0x029d0000, 0x02de4828, 0x031d0000)
		from space 1024K, 14% used [0x032d0000, 0x032f5370, 0x033d0000)
		to space 1024K, 0% used [0x031d0000, 0x031d0000, 0x032d0000)
	tenured generation total 10240K, used 6144K [0x033d0000, 0x03dd0000, 0x03dd0000)  // 放了6m
			the space 10240K, 60% used [0x033d0000, 0x039d0030, 0x039d0200, 0x03dd0000)
	compacting perm gen total 12288K, used 2114K [0x03dd0000, 0x049d0000, 0x07dd0000)
			the space 12288K, 17% used [0x03dd0000, 0x03fe0998, 0x03fe0a00, 0x049d0000)
No shared spaces configured.
大对象直接进入老年代

大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串。

虚拟机要避免大对象,为啥呢?因为大对象会发生gc,也会产生复制开销。

  • 在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们。
  • 而当复制对象时,大对象就意味着高额的内存复制开销

因此提供了一个-XX: PretenureSizeThreshold参数,指定大于该设置值的对象直接在老年代分配,就不要在新生代中来回复制了,从而产生大量的内存复制操作。

private static final int _1MB = 1024 * 1024;
/**
* VM参数: -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
* -XX:PretenureSizeThreshold=3145728=3m
*/
public static void testPretenureSizeThreshold() {
	byte[] allocation;
	allocation = new byte[4 * _1MB]; //直接分配在老年代中 4m>3m
}
Heap
	def new generation total 9216K, used 671K [0x029d0000, 0x033d0000, 0x033d0000)
		eden space 8192K, 8% used [0x029d0000, 0x02a77e98, 0x031d0000)
		from space 1024K, 0% used [0x031d0000, 0x031d0000, 0x032d0000)
		to space 1024K, 0% used [0x032d0000, 0x032d0000, 0x033d0000)
	tenured generation total 10240K, used 4096K [0x033d0000, 0x03dd0000, 0x03dd0000) // 直接分配在老年代
		the space 10240K, 40% used [0x033d0000, 0x037d0010, 0x037d0200, 0x03dd0000)
	compacting perm gen total 12288K, used 2107K [0x03dd0000, 0x049d0000, 0x07dd0000)
		the space 12288K, 17% used [0x03dd0000, 0x03fdefd0, 0x03fdf000, 0x049d0000)
No shared spaces configured.
长期存活的对象将进入老年代

虚拟机定了一个对象年龄,对象通常在Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。

在之后,对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。

private static final int _1MB = 1024 * 1024;
/**
* VM参数: -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1
* -XX:+PrintTenuringDistribution
*/
@SuppressWarnings("unused")
public static void testTenuringThreshold() {
	byte[] allocation1, allocation2, allocation3;
	allocation1 = new byte[_1MB / 4]; // 什么时候进入老年代决定于XX:MaxTenuringThreshold设置
	allocation2 = new byte[4 * _1MB];
	allocation3 = new byte[4 * _1MB];
	allocation3 = null;
	allocation3 = new byte[4 * _1MB];
}

以-XX: MaxTenuringThreshold=1参数来运行的结果:

[GC [DefNew
Desired Survivor size 524288 bytes, new threshold 1 (max 1)
- age 1: 414664 bytes, 414664 total
: 4859K->404K(9216K), 0.0065012 secs] 4859K->4500K(19456K), 0.0065283 secs] [Times: user=0.02 sys=0.00, real=0.0
[GC [DefNew
Desired Survivor size 524288 bytes, new threshold 1 (max 1)
: 4500K->0K(9216K), 0.0009253 secs] 8596K->4500K(19456K), 0.0009458 secs] [Times: user=0.00 sys=0.00, real=0.00
                                                                           
Heap
	def new generation total 9216K, used 4178K [0x029d0000, 0x033d0000, 0x033d0000)
		eden space 8192K, 51% used [0x029d0000, 0x02de4828, 0x031d0000)
		from space 1024K, 0% used [0x031d0000, 0x031d0000, 0x032d0000)
		to space 1024K, 0% used [0x032d0000, 0x032d0000, 0x033d0000)
	tenured generation total 10240K, used 4500K [0x033d0000, 0x03dd0000, 0x03dd0000)
		the space 10240K, 43% used [0x033d0000, 0x03835348, 0x03835400, 0x03dd0000)
	com\pacting perm gen total 12288K, used 2114K [0x03dd0000, 0x049d0000, 0x07dd0000)
		the space 12288K, 17% used [0x03dd0000, 0x03fe0998, 0x03fe0a00, 0x049d0000)
No shared spaces configured.

动态对象年龄判定

为了能更好地适应不同程序的内存状况, HotSpot虚拟机并不是永远要求对象的年龄必须达到-XX: MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代

private static final int _1MB = 1024 * 1024;
/**
* VM参数: -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
-XX:MaxTenuringThreshold=15
* -XX:+PrintTenuringDistribution
*/
@SuppressWarnings("unused")
public static void testTenuringThreshold2() {
	byte[] allocation1, allocation2, allocation3, allocation4;
	allocation1 = new byte[_1MB / 4]; // allocation1+allocation2大于survivor空间一半
	allocation2 = new byte[_1MB / 4];
	allocation3 = new byte[4 * _1MB];
	allocation4 = new byte[4 * _1MB];
	allocation4 = null;
	allocation4 = new byte[4 * _1MB];
}

发现运行结果中Survivor占用仍然为0%,而老年代比预期增加了6%,也就是说allocation1、 allocation2对象都直接进入了老年代,并没有等到15岁的临界年龄。因为这两个对象加起来已经到达了512KB,并且它们是同年龄的,满足同年对象达到Survivor空间一半的规则。我们只要注释掉其中一个对象的new操作,就会发现另外一个就不会晋升到老年代了。

[GC [DefNew
Desired Survivor size 524288 bytes, new threshold 1 (max 15)
- age 1: 676824 bytes, 676824 total
: 5115K->660K(9216K), 0.0050136 secs] 5115K->4756K(19456K), 0.0050443 secs] [Times: user=0.00 sys=0.01, real=0.0
[GC [DefNew
Desired Survivor size 524288 bytes, new threshold 15 (max 15)
: 4756K->0K(9216K), 0.0010571 secs] 8852K->4756K(19456K), 0.0011009 secs] [Times: user=0.00 sys=0.00, real=0.00
Heap
	def new generation total 9216K, used 4178K [0x029d0000, 0x033d0000, 0x033d0000)
		eden space 8192K, 51% used [0x029d0000, 0x02de4828, 0x031d0000)
		from space 1024K, 0% used [0x031d0000, 0x031d0000, 0x032d0000)
		to space 1024K, 0% used [0x032d0000, 0x032d0000, 0x033d0000)
	tenured generation total 10240K, used 4756K [0x033d0000, 0x03dd0000, 0x03dd0000)
		the space 10240K, 46% used [0x033d0000, 0x038753e8, 0x03875400, 0x03dd0000)
	compacting perm gen total 12288K, used 2114K [0x03dd0000, 0x049d0000, 0x07dd0000)
		the space 12288K, 17% used [0x03dd0000, 0x03fe09a0, 0x03fe0a00, 0x049d0000)
No shared spaces configured.

空间分配担保

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

考虑一个最坏情况,只使用其中一个Survivor空间来作为轮换备份,所以当出现大量对象在Minor GC后仍然存活的情况,此时age=15了要转移到老年代了,那么需要老年代进行分配担保,把Survivor无法容纳的对象直接送入老年代。然而老年代也要保证自己有容纳的能力,因此就出现一个-XX:HandlePromotionFailure设置允许冒险的参数,决定是Minor GC 晋升到老年代,还是进行Full GC来让老年代腾出更多空间。但通常情况下都还是会将-XX: HandlePromotionFailure开关打开,避免Full GC过于频繁。

小总结

触发Minor GC的原因Eden区空间不足

触发Full GC的原因:

  1. 调用System.gc()时,系统建议执行Full GC
  2. 老年代空间不足
  3. 方法区空间不足
  4. 通过Minor GC后进入老年代的平均大小大度老年代的可用空间大小
  5. 由Eden区和from区,向to区复制时,对象大于了to区的可用空间大小,则把对象转移到老年代中,老年代也放不下, 那么就会Full GC

理解回收过程

首先对象创建都创建在Eden区

  • 一旦eden区内存满了,那么就出发YGC = Minor GC

  • 第一次Minor GC

    • 将还需要使用的对象转移到s0区【to区】,to区代表一个空区
    • 对象并标记它的年龄 = 1
    • 此时Eden区的垃圾被回收
  • 此时Eden区又满了

  • 第二次Minor GC

    • 此时s1区为空区,因此s1是to区
    • 检查出eden区继续使用的对象,以及s0区【此时为from区】还需要使用的对象放入到s1区
    • 标记在s1中的年龄
      • 如果从from区来的 +1
      • 如果从eden区来的 =1
    • 此时Eden区和s0区的垃圾都会被回收
  • 此时Eden区又满了【注意s0,s1区满的时候并不会触发ygc

  • 第n次Minor GC

    • 此时在from区出现了年龄 =15的对象
      • 如果该对象还要被使用就移到老年区
      • 如果对象不被使用了就垃圾回收
    • 同样与第二次gc一样的操作

(很多时候Major GC 和 Full GC会混淆使用,需要具体分辨是老年代回收还是整堆回收)

如果在eden区生成一个超大对象,那么直接放在老年区

  • 如果老年代放不下,先试图Full GC

    • FGC也就是对于老年区进行垃圾回收
  • FGC后能放下,就放在老年代

  • FGC后不能发下,报OOM

  • 5
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值