02-JVM自动内存管理 垃圾收集器与内存分配策略

概述

程序计数器、虚拟机栈和本地方法栈随线程而生,随线程而灭,且每一个栈帧分配内存大小基本上在类结构确定下来就已知,因此这几个区域不需要过多考虑内存回收。当方法执行结束时,内存就自动回收了。

堆与方法区存在许多不确定性。一个接口的多个实现类需要的内存会不同,只有在运行运行期间,才知道程序会创建哪些对象,创建多少对象。GC主要关注这部分的内存。

堆的垃圾回收

GC对堆进行垃圾回收前,首先需要判断对象是否死亡。

引用计数法

  • 原理
    对象中添加一个引用计数器。每当有一个地方引用对象,计数器就加一;引用失效时,计数器减一;计数器为0的对象就是不再被使用的对象。
  • 缺点
    单纯的引用计数法无法解决对象之间互相循环引用的问题。

可达性分析算法

  • 原理
    通过一系列成为GC Roots的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程走过的路径称为引用链;若某个对象到GC Roots之间没有引用链相连,则证明此对象不可能再被使用。
  • 可作为GC Roots的对象(两栈两方法+JVM内部引用+同步锁持有的对象)
    1. 虚拟机栈中对象的引用

      public class Test {
          public static  void main(String[] args) {
      	Test a = new Test();
      	a = null;
          }
      }
      

      a是栈帧中的引用变量,充当了GC Root,当a=null时,对象与GC Root断开,会被回收

    2. 方法区中类静态属性的引用

      public class Test {
          public static Test s;
          public static  void main(String[] args) {
      	Test a = new Test();
      	a.s = new Test();
      	a = null;
          }
      }
      

      栈里的引用变量指向一个对象A,静态属性s指向了一个对象B,当a=null时,a指向的对象消亡,而a.s指向的对象不会消亡。a与s都是GC Root。

    3. 方法区中常量对象的引用,如字符串常量池里的引用

      public class Test {
      	public static final Test s = new Test();
          public static void main(String[] args) {
              Test a = new Test();
              a = null;
          }
      }
      

      s是一个方法区常量引用,作为GC Root指向对象A。a是栈中引用变量,作为GC Root指向对象B。a=null会导致B消亡,A不受影响。

    4. 本地方法栈中JNI对象的引用

      本地方法栈中 JNI 的对象引用,因此只会在此本地方法执行完成后才会被释放。

    5. JVM的内部引用,如基本数据类型对应的Class对象、常驻的异常对象还有系统类加载器

    6. 被同步锁持有的对象

对象回收过程

  • 如果对象经过可达性分析发现没有与GC Roots相连的引用链,则对象会被进行第一次标记。
  • 判断被标记的对象是否有必要执行finalize()方法。若对象未覆盖finalize()方法或该方法已经被JVM调用过一次,则JVM视为没有必要执行finalize()方法,并回收。
  • 标记的对象且要执行finalize()方法,放置在F-Queue的队列中,并稍后由Finalizer线程执行他们的finalize()方法。
  • GC对F-Queue中对象进行第二次标记,如果对象在finalize()期间,重新与GC Roots上任何对象建立关联则在第二次标记会被移出队列,否则就会被真正回收。

实际上,使用try-finally比finalize()方法更适合做关闭外部资源的清理工作,因此finalize()实际上在开发中没有必要使用。

对象的四种引用

  • 强引用
    程序代码普遍存在的引用方式,类似"Object obj = new Object()"。
    只要强引用存在,GC就不会回收被引用的对象。
  • 软引用
    描述还有用,但非必须的对象。若内存充足,GC工作也不清除软引用关联的对象。只有在系统将发生内存溢出异常前,才会将软引用列进回收范围中进行二次回收。如果回收了软引用对象还没有足够内存,才会报内存溢出异常。
  • 弱引用
    描述非必须对象。被弱引用关联的对象只能生存到下一次GC工作。当GC工作,无论内存是否充足,都回收。
  • 虚引用
    虚引用不会对对象的生存时间有任何影响;虚引用存在的目的就是对象被GC回收时收到一个系统通知。

方法区回收

方法区主要回收废弃的常量和不再使用的类型。

  • 废弃的常量判断
    与回收堆中对象相似,废弃的常量已经没有被任何字符串对象引用,且JVM没有任何其他地方引用该字面量,则发生GC时,垃圾收集器判断有必要的话会将该常量清除出常量池。
  • 运行回收的不再使用的类判断
    1. 该类所有实例已被回收,堆中不存在任何该类及其派生子类的实例。
    2. 加载该类的类加载器以及被回收。
    3. 该类对应的java.lang.Class对象没有在任何地方被引用,即无法在任何地方通过反射访问该类的方法。

在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP等频繁自定义类加载器的场景中,需要JVM具备类型卸载的能力。

垃圾收集算法

从如何判断对象消亡的角度出发,垃圾收集算法主要分为引用计数式垃圾收集和追踪式垃圾收集。追踪式垃圾收集是主流的垃圾收集算法。

分代收集理论

两个分代假说

  • 弱分代假说
    大多数的对象都是朝生夕死的。
  • 强分代假说
    熬过越多次GC过程的对象就越难以消亡。

将对象依据年龄分配到不同的存储区域。一般至少将java堆划分为新生代与老年代。

对于新生代,只关注哪些对象会存活,不用标记那些大量将要回收的对象,就可以以较低代价回收到大量的空间。对于老年代,JVM就可以以较低的频率来回收这个区域。

跨代引用假说

  • 跨代引用相对于同代引用来说仅占少数。
    对于新生代区域中存活对象,可能被老年代所引用,不得不再额外遍历整个老年代中所有对象来确保可达性分析的正确性,反过来也一样。
    依据跨代引用假说,只需要新生代上建立一个全局的数据结构(记忆集),将老年代 划分为若干小块,标识出哪一块内存存在跨代引用。Minor GC时只扫描包含跨代引用的小块内存里的对象。

Minor GC/Young GC:目标是新生代的垃圾收集

Major GC/Old GC:目标是老年代的垃圾收集,目前只有CMS收集器会有专门单独收集老年代的行为

Mixed GC:目标是整个新生代和部分老年代,目前只有G1收集器会有这样的行为。

Full GC:整堆收集,收集整个Java堆和方法区。

三种垃圾收集算法

标记-清除算法

  • 标记过程
    标记所有需要回收或标记所有存活的对象。标记过程就是判定对象是否属于垃圾的过程。
  • 清除过程
    将标记为垃圾的对象清除

标记-清除算法主要有两个缺点

  1. 执行效率不稳定。如果堆中有大量对象,则需要进行大量的标记和清除,执行效率随着对象增多而降低。
  2. 内存空间碎片化。标记清理后产生大量的内存碎片,导致分配内存较大的对象时容易触发下一次GC。

标记-复制算法

针对标记-清理面对大量可回收对象时执行效率低下的问题。

  • 将内存分成两块,每次只使用其中一半。
  • 当一块内存用完,则将存活的对象复制到另一块内存,并将已使用过的内存块一次性清理掉。

好处:

  1. 分配内存无需考虑有空间碎片的复杂性
  2. 对于大多数对象需要回收的情况,只需要复制少量存活对象。

缺点:

  1. 可用内存空间缩小为原来的一半。
    Serial和ParNew等新生代收集器采用了更优化的半区复制策略来设计内存布局——Appel式回收。
    将新生代分为较大的Eden空间和两块较小的Survivor空间。每次分配内存只使用Eden和其中一块survivor。
    GC时,将Eden与使用的survivor复制到另一块survivor上。默认Eden与Survivor比例为8:1。
    当Survivor空间不足以容纳一次Minor GC之后存活的对象时,需要依赖其他内存区域(大多数是老年代)进行分配担保。
  2. 如果大多数对象存活,复制开销大。

标记-整理算法

标记-复制在对象存活率高的情况下,需要进行较多的复制操作,效率降低。在老年代一般不使用标记-复制方法。

因为老年代很少回收,提出了标记整理算法。

  • 标记对象是否存活
  • 让所有存活的对象向内存空间的一端移动,然后直接清理掉边界外的内存。

移动对象的标记-整理在内存回收时更复杂,对象移动是极为负重的操作,而且对象移动时必须暂停用户程序。好处是没有内存碎片,下一次分配对象内存时效率高。

分区空闲分配列表的标记-清除标记哪些内存空闲,在回收时效率高,但是分配时效率低。

从长远看,标记-整理标记-整理划算,因为标记-清除的内存会越来越碎片,会越来越容易触发GC。

从GC的短期停顿看,标记-清除更快。

老年代的一种回收方法是使用标记-清除与标记-整理相结合的方式。当内存碎片少时使用标记-清除;当内存碎片程度影响到对象分配时,再采用标记-整理算法收集一次。CMS就是采取该方法。

HotSpot的算法实现细节

根节点枚举

可以作为GC Roots的节点主要是全局性的引用(如常量或静态变量)与执行上下文(如栈帧中的本地变量表)。

  • 所有收集器在根节点枚举这一步都必须暂停用户的线程。
    为了保证根节点枚举的准确性,根节点枚举必须在一个能保证一致性的快照中进行,不会出现在分析过程中根节点集合的对象引用关系变化的情况。即使是CMS、G1、ZGC等收集器,枚举根节点时也需要停顿。

  • 主流的JVM使用的都是准确式垃圾收集器,因此当用户线程停顿时,JVM应该可以直接得到哪些地方存放对象的引用。

    HotSpot中,使用OopMap的数据结构来存放引用。类加载动作完成时,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,即时编译时也会在特定位置记录下栈里和寄存器里哪些位置是引用。这样收集器扫描时不需要真正一个不漏地从方法区等GC Roots查找,直接从OopMap获取信息。

    遍历方法区和栈区查找(保守式 GC)。

安全点

对应 OopMap 的位置即可作为一个安全点(Safe Point)。可能导致OopMap变化的指令非常多,如果为每一条指令都生成对应的OopMap,则需要大量额外的存储空间,这样GC的空间成本过高。
安全点太多,GC 过于频繁,增大运行时负荷;安全点太少,GC 等待时间太长。

  • 一般设置安全点的地方以是否具有让程序长期执行为标准选定的,一般如下:

    1. 循环的末尾(超大的循环导致执行 GC 等待时间过长)
    2. 方法临返回前
    3. 调用方法之后
    4. 抛异常的位置
  • 如何在 GC 发生时,所有线程都跑到最近的 Safe Point 上停下来

    1. 抢占式中断
      在 GC 发生时,首先中断所有线程,如果发现线程未执行到 Safe Point,就恢复线程让其运行到 Safe Point 上。
    2. 主动式中断
      在 GC 发生时,不直接操作线程中断,而是简单地设置一个标志,让各个线程执行时主动轮询这个标志,发现中断标志为真时就自己中断挂起。

安全区域

  • 如果一个线程处于 Sleep 或中断状态,它就不能响应 JVM 的中断请求,再运行到安全点上。因此 JVM 引入了 安全区。

  • 安全区域是指在一段代码片段中,引用关系不会发生变化。在这个区域内的任意地方开始 GC 都是安全的。

  • 线程在进入安全区域的时候先标记自己已进入了安全区域,等到被唤醒时准备离开安全区域时,先检查能否离开。如果 根节点枚举 完成了,那么线程可以离开,否则它必须等待直到收到安全离开的信号为止。

记忆集与卡表

新生代与老年代之间存在的跨代引用以及部分区域收集的垃圾收集器都需要使用记忆集,避免全扫描。

  • 垃圾收集场景中,收集器只要通过记忆集判断某块非收集区域是否有引用指向收集区域即可,不需要了解这些引用指针的全部细节。

  • 记忆集的存储和维护成本

    1. 字长精度
      每个记录精确到一个机器字长,即处理器的寻址位数,该字含有跨代指针。
    2. 对象精度
      每个记录精确到一个对象,该对象含有跨代指针。
    3. 卡精度
      每个记录精确到一个区域,该区域存在跨代指针。
  • 卡表与卡页
    卡表是记忆集的实现,类似于HashMap与Map。

    HotSpot中,卡表是一个字节数组。卡表每一个元素对应一块内存块——卡页。
    只要卡页中有一个对象的字段存在跨代指针,就将对应卡表的数组元素标识为1,称这个元素变脏。GC时,根据变脏的卡表元素,扫描哪些卡页内存区块。

    CARD_TABLE [this address >> 9] = 0;
    

    该卡表定义卡页大小为2^9,根据起始地址就可以得到所有卡页内存地址范围。

写屏障

  • 有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻,把维护卡表的动作放到每一个赋值操作之中。

  • 在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值 后的则叫作写后屏障(Post-Write Barrier)。

  • 使用写屏障后,JVM为所有赋值操作生成相应的指令。如果GC在写屏障中增加了更新卡表的操作,那么不论是否更新的是老年代对新生代的引用,都会产生额外的开销。不过该开销相当于Minor GC扫描整个老年代要小得多。

    void oop_field_store(oop* field, oop new_value) {
     // 引用字段赋值操作 
     *field = new_value; 
     // 写后屏障,在这里完成卡表状态更新 
     post_write_barrier(field, new_value); 
     }
    
  • 伪共享问题
    现代CPU缓存系统是以缓存行为单位存储的。当多线程修改各自独立的变量时,若这些变量正好共享同一个缓存行,则会发生写回、无效或同步,导致性能降低。
    一个虚拟机的多个卡表元素保存在一个缓存行。如果不同线程更新的对象的卡页对应的卡表元素在同一个缓存行里,会导致更新卡表时正好写入同一个缓存行,从而影响性能。

    解决伪共享有两个方案

    1. 采用有条件的写屏障。
      在JDK7后的JVM增加了参数-XX:UseCondCardMark来决定是否开启卡表更新的条件判断。先检查卡表是否已脏,只有不脏时才执行写入操作,即增加读取判断的次数换取减少并发写入的操作,从而减少伪共享发生。
    2. 字节填充避免伪共享
      @sun.misc.Contended 是 JDK8 新增的一个注解,对某字段加上该注解则表示该字段会单独占用一个缓存行(JDK12没有)
      JVM 添加 -XX:-RestrictContended 参数后 @sun.misc.Contended 注解才有效

并发的可达性分析

根节点枚举带来的停顿是非常短暂和固定的,而继续往下遍历对象图停顿的时间是理论上与Java堆存储的对象数量成正比的。如果可以减少这部分停顿时间,则收益很大。

  • 使用三色标记来进行可达性分析对象消失的问题
    1. 白色:未扫描的对象
    2. 灰色:被收集器扫描,但是其至少还有一个引用未扫描
    3. 黑色:本身以及其所有引用均扫描过,如果有其他对象指向黑色,则不重新扫描,黑色不可能不经过灰色直接指向白色。

在这里插入图片描述

  • 解决并发扫描时对象消失的问题
    对象消失的条件:

    1. 赋值器插入了一条或多条从黑色对象到白色对象的新引用
    2. 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

    解决方法:

    1. 增量更新
      当黑色对象插入了新的指向白色对象的引用关系时,就将这个新插入的引用记录,并发扫描后再以记录过的引用关系中黑色对象为跟再次扫描。即黑色对象退化为灰色对象。
    2. 原始快照
      当灰色对象要删除指向白色对象的引用时,就将该要删除的引用记录下来。并发扫描结束后,将记录过的引用关系中灰色对象为根重新扫描一遍。无论引用关系删除与否,都按照刚刚开始扫描那一刻的对象图快照来进行搜索。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值