JVM篇·垃圾收集器与内存分配策略

15 篇文章 1 订阅

Java堆内存的整理方法

本文为《深入理解Java虚拟机_第三版 周志明》学习笔记

引用计数法

概念:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1,当引用失效时,计数值就减1;任何时刻计数器为零的对象就是不可能再被使用的。但当遇到循环引用时就无法正确处理;

  • 强引用:引用赋值,只要强引用关系存在,垃圾收集器就永远不会回收掉引用对象。
  • 软引用:SoftReference有用但非必须的对象。只要存在着关系,在发生内存溢出异常前,会把这些对象列入回收范围之中进行二次回收,如果回收没有足够的内存,才会抛出内存溢出异常。
  • 弱引用:WeakReference非必须对象,强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
  • 虚引用:PhantomReference幽灵引用,最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例,为对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到的一个系统通知。
可达性分析法

思路:通过一系列称为GC Roots的跟对象作为起始节点集,从这些结点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果某个对象到根节点间没有任何引用链相连,则证明此对象可再被使用。

在这里插入图片描述

Java技术体系里面作为GC Roots的对象:
  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、布局变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用对象,譬如字符串常量池里的引用。
  • 在本地方法栈JNI(Native)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class现象,一些常驻的异常对象(比如NullPointException、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反应Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
  • 其他对象临时性的加入,共同构成GC Roots集合。
对象销毁过程

对象的若判定为不可达对象,不会立即销毁,至少要经历两次标记过程。如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那将第一次标记,随后再次根据是否有必要执行finalize()方法进行筛选。若对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,则其为“没有必要执行finalize()。当有必要执行的时候,对象将会放置在F-Queue的队列中,并由虚拟机自动建立的低调度低优先级的Finalizer线程去执行他们的finalize()方法。若在finalize()方法中重新与引用链建立关系,则移除即将回收的集合,否则就被销毁了。

回收方法区

由于JDK8以后,方法区中的字符串常量池和静态变量放入堆中,而方法区置于本地内存元空间中。书上说判定一个类型是否被废弃的条件有:

  • 该类的所有实例都已经被回收,堆中不含有该类的任何派生子类的实例;
  • 类加载器已被回收;
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法;

垃圾收集方法

  • 引用计数式垃圾收集:直接垃圾收集
  • 追踪式垃圾收集:间接垃圾收集
标记-清除算法

标记所有需要回收的对象,标记完成后,统一回收掉所有被标记的对象。

缺点:1. 执行效率低,遇到大量对象需要回收时,需要进行大量的标记清除动作。2.产生碎片化的内存;

在这里插入图片描述

标记-复制算法

将内存空间分为相等两部分,每次使用其中一块,当内存耗尽,就将存活的对象复制到另一块上面,然后把使用过的空间清理掉。

复制算法确实可以细分为标记和复制这两个阶段,但是这两个阶段是同步执行的,先从GC-roots出发,找出存活的对象,然后直接复制到另一块区域中,下一次进行标记和复制的时候,直接移动另外一块对象的内存指针,分配出相应的大小即可,这样可以保证内存空间是连续的,不会产生碎片,所以两步并一步,提高了效率,同时复制完的那部分空间,剩下的都是需要清除的对象,直接一次性清除就可以了,但是这种缺点也很明显了,空间换时间,复制算法需要耗费一倍的空间。

缺点:当大量的对象均存活,则需要大量拷贝对象,还有就是内存空间缩小了一倍。

在这里插入图片描述

标记整理法

针对老年代对象特征提出的回收算法。当内存耗尽时,将活着的对象向内存空间一端移动,然后直接清理掉边界以外的内存。

缺点:老年代回收时都有大量存活的对象。移动对象必须全程暂停用户应用程序才能进行。

在这里插入图片描述

HotSpot虚拟机的回收算法实现

1. 根节点枚举
概念:

构造GC Roots,GC Roots中一般为全局性引用(常量或类静态属性)和执行上下文(栈帧中的本地变量表)中;

关键点:
  1. 必须在一个能保证一致性的快照中才得以进行。(整个枚举期间,执行子系统期间,根节点集合的对象引用关系不发生变化);
  2. 快速定位内存区域是否存放对象的引用;OopMap
OopMap:

一组数据结构,一旦类加载动作完成时,HotSpot会把对象内什么偏移量上是什么类型的数据计算出来。

2. 安全点

通过OopMap,JVM可以准确迅速的完成GC Roots枚举。安全点指的是在某个特定的指令流位置暂停下来开始垃圾收集。

原则

是否具有让程序长时间执行的特征为标准进行选定。

保证线程执行到最近的安全点方案
  1. 抢先式中断:在垃圾收集发生时,系统让所有的用户线程全部中断,如果发现中断位置不在安全点上,就恢复其执行,然后再重新中断,直到跑到安全点上。仅停留在理论上
  2. 主动式中断:各个线程在执行时不断主动轮询标志,一旦发现中断标志为True时就在自己最近的安全点上主动中断挂起。JVM将轮询操作精简至只有一条汇编指令,保证其高效。
3. 安全区域

若程序未获得处理器,处于Sleep或者Blocked状态,程序无法响应JVM的中断请求,就需要引入安全区域解决。在这个区域中任意地方开始垃圾收集都是安全的。

当用户线程执行到安全区域里面的代码时,先标记自己进入安全区域,当JVM需要发起垃圾收集时,就不去关注在安全区域的代码。当线程要离开安全区域时,检查虚拟机是否已完成了根节点枚举。否则,用户线程一直等到JVM允许离开的信号时才继续执行。

4.记忆集与卡表

记忆集就是用于从非收集区域指向收集区域的指针集合的抽象数据结构。

记录精度:
  • 字节精度:每个记录精确到一个机器字长,该字包含跨代指针;
  • 对象精度:每一个记录精确到一个对象,该对象里含有跨代指针字段;
  • 卡精度:每个记录精确到一块内存区域,该区域有对象含有跨代指针;
卡表

记忆集的一种实现,他定义了记忆集的记录精度,与堆内存的映射关系等。最简单的实现形式可以只是一个字节数组。

CARD_TABLE [this.address >>9] = 0;

每一个元素都对应着其标识的内存区域中一块特定大小的内存块(卡页)。一个卡页的内存通常包含不止一个对象,只要卡页内有一个以上的对象的字段存在着跨代指针,就将数组值变为1,称这个元素变脏。垃圾收集时,将为1的区域加入GC Roots中一并扫描。

5.写屏障

写屏障是维护卡表状态的,可看作是虚拟机层面,对卡表写入时的AOP环形通知。在赋值前叫写前屏障,之后叫写后屏障。先检查卡表标记,当卡表元素未被标记时,才将其变脏。

6.并发可达性分析

引入三色标记法

  • 白色:表示对象未被垃圾收集器访问过;若扫描完成仍是白色,则表示对象不可达。

  • 黑色:表示对象已被垃圾收集器访问过,且这个对象的所有引用都已扫描。它是安全存活的,如果有其他对象引用指向了黑色对象,无需重新扫描一遍,黑色对象不可能直接指向某个白色对象。

  • 灰色:表示对象已被垃圾收集器访问过,但至少存在一个引用还没被扫描过。

在这里插入图片描述

  • 并发可能出现的问题:

    1. 赋值器插入了一条或多条从黑色到白色对象的新引用;

      增量更新破坏其条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色为跟对象,重新的扫描一次。也就是,黑色对象一旦插入白色对象引用后,就变成了灰色。

    2. 赋值器删除了全部从灰色对象到该白色对象的直接引用或间接引用;

      原始快照破坏其条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,并发结束后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。也就是,无论引用关系删除与否,都会按照刚刚开始扫描的那一刻的对象图快照来进行搜索。

    相比增量更新算法,原始快照搜索能减少并发标记和重新标记阶段的消耗,避免在最终标记阶段停顿时间过长的缺点,但会带来跟踪引用带来的额外负担。

垃圾收集器

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

经典垃圾收集器

在这里插入图片描述

Serial收集器

单线程的新生代收集器,需要暂停其他线程。推荐在客户端模式下选用,由于没有线程交互的开销,可以获得最高的单线程收集效率。

在这里插入图片描述

ParNew收集器

Serial收集器的多线程并行版本。

在这里插入图片描述

Parallel Scavenge收集器

新生代并行收集器,与ParNew非常相似,目标是达到可控制的吞吐量;
吞 吐 量 = 运 行 用 户 代 码 时 间 运 行 用 户 代 码 时 间 + 运 行 垃 圾 收 集 时 间 吞吐量=\frac{运行用户代码时间}{运行用户代码时间+运行垃圾收集时间} =+

Serial Old收集器

Serial老年代版本,单线程收集器。

在这里插入图片描述

Parallel Old收集器

是Parallel Scavenge老年代版本。支持多线程并行收集。

在这里插入图片描述

CMS收集器

老年代收集器,以获得最短回收停顿时间为目的的收集器。基于标记-清除出算法。

整个过程的四个步骤:

  • 初始标记:标记一下GC Roots能直接关联的对象,速度很快。需要停顿用户线程
  • 并发标记:从根开始遍历整个对象图的过程,过程耗时长,但不需要停顿用户线程。
  • 重新标记:为了修正用户线程继续运作而导致标记变动部分对象的标记记录,这一阶段会比初始阶段稍长,但比并发标记时间短。需要停顿用户线程
  • 并发清除:清理掉已死亡的对象,不需要移动存活对象,可以与用户线程同时并发。

优势:并发收集,低停顿;

不足:

  • 对处理器资源非常敏感。会占用一部分线程降低总吞吐量。默认启动的回收线程数是 (处理器核心数+3)/4;

  • 无法处理浮动垃圾。在并发标记和并发清理过程中,会产生新的垃圾对象,CMS无法在当次收集中处理掉他们,只好在下一次清理。这部分垃圾称为浮动垃圾。

  • CMS使用标记清除算法,会产生大量的碎片化空间。

    在这里插入图片描述

Garbage First(G1)

一款主要面向服务端应用的垃圾收集器,包括新生代和老年代;使用Mixed GC模式,面向堆内存任何部分来组成回收集进行回收。标准就是哪块内存存放的垃圾数量最多,回收收益最大。整体看是标记整理算法实现,局部看,两个Region之间是标记复制算法实现。不会产生内存空间碎片。

G1把连续的JAVA堆内存划分为多个大小相等的独立区域(Region);每一个Region可以充当Eden,Survivor,老年代空间。还有Humongous区域,专门用来存储大对象。只要大小超过了一个Region的一半的对象即为大对象。

建立一个可预测的停顿时间模型,他将Region作为单词最小回收单元,每次回收都是Region的整数倍.G1跟踪每个Region中的垃圾堆积的价值(回收获得的空间大小和回收所需时间的经验值),然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间优先处理回收价值大的Region。保证了G1收集器在有限时间内获得尽可能高的收集效率。

在这里插入图片描述

  • 在针对跨域引用对象的维护上,每个Region都维护自己的记忆集,其数据结构是双向卡表结构,Map<别的Region起始地址,Set<卡表的索引号>>;实现复杂,比其他收集器占据更多的内存负担;
  • 保证并发性,相比于CMS采用的是增量更新而言,G1采用的是原始快照实现的。还有就是Region会开辟部分空间用于并发时新对象的分配,用两个指针(TAMS)去维护,并默认这些新创建的对象是存活的,不纳入回收范围。
  • 建立可靠的停顿预测模型:以衰减均值为了理论基础,记录每个Region平均回收耗时,每个记忆集中的藏卡数量等,并分析统计数据。

收集过程的步骤:

  • 初始标记:标记GC Roots能直接关联的对象,并修改TAMS的指针,需要停顿线程,耗时短,借助Minor GC时同步完成。实际没有额外停顿。
  • 并发标记:从根开始进行可达性分析,耗时长但可并行,完成后重新处理原始快照下有变动的对象。
  • 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下的少量SATB(原始快照)记录。
  • 筛选回收:统计数据,按回收价值和成本排序,将存活对象复制到空Region并清理旧的Region空间,暂停用户线程,并行完成清理和移动对象。

在这里插入图片描述

G1相比CMS的不足

  1. G1在垃圾收集产生的内存占用与程序运行额外执行负载都比CMS高;
  2. G1卡表实现复杂,每个Region均有一个,消耗内存大,CMS只有一个,实现简单,只需要处理老年代到新生代的引用,节省空间。
  3. 负载角度:CMS使用写后屏障更新维护卡表,G1还需要使用写前卡表跟踪并发时指针变化。G1需实现类似小消息队列的结构,将写前,写后屏障放到队列异步处理。
内存分配与回收策略

在这里插入图片描述

流程

把刚实例化出的对象放入Eden区from survior区,经过一次Minor GCC后进入to surivior区域,默认经过15次Minor GC会进入老年代;

原则
  1. 对象优先在Eden分配

  2. 大对象直接进入老年代

    大对象:大量连续内存空间的Java对象。避免在Eden和Survivor间来回复制

  3. 长期存活的对象将进入老年代

    对象通中有一个年龄计数器,当经过第一次MinorGC后仍存活,并且能被Survivor容纳,就移动到Survivor中,将其设置为1岁,每次在Survivor区域中熬过Minor GC 年龄就增加一岁。当15岁时就进入老年代。

  4. 动态对象年龄判断

    如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可直接进入老年代。

  5. 空间担保分配

    在MinorGC之前,JVM检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果不成立,继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小。如果大于进行Minor GC,否则,就进行Full GC。

新生代要采用一块Eden区两块survior区这种方式进行对象存储的原因
  • 若新生代只有一块Eden区域,由于新生代垃圾收集会比较频繁,所以需要使用复制算法,复制算法的特点就是需要两块空间,从一块往另一块复制,所以只有一块Eden区域的新生代是不合适的;

  • 若将老年代使用复制算法的另一块区域,当Minor GC结束后存活下来的对象都会放入老年代,这样老年代空间会迅速减少,当老年代空间不足的时候,就会进Full GC,造成stop the world;

如果要执意采用一块Eden区,那就需要保证

  1. 增大老年代空间,减少FullGC发生频率;
  2. 减少老年代空间,减少单次Full GC消耗时间;

由于1,2两点相互违背,因此新生代需要使用survior区域,减少对象进入老年代多的数量,只有经历了指定次数的Minor GC后才能进入老年代。使用两块的目的是为了减少内存碎片,因此也是采用的复制算法;若只有一块survior在清理survior区域时这时候Survior区域的存储空间就不连续了,我们再把存活下来的Eden区对象复制过去,更加会产生内存的碎片化,所以我们采用两块Survior区域,同时保证一块survior区域始终是没有对象的,当发生MinorGC的时候,就把Eden区域和From Survior区域存活的对象,复制到空白的to Survior区域,从头排列,然后清空Eden和From Survior的垃圾,这样这两块空间就干净了,这时候再把From和To的角色互换,这样就能保证总有一块Survior区域是干净的,是可以从头排列存活对象的,防止了内存的碎片化。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

BugGuys

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值