深入理解JVM --- 垃圾收集算法终章

2024-02-03 08:30

本文主要是介绍深入理解JVM --- 垃圾收集算法终章,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!


大部分的Java对象只存活一小段时间,而存活下来的小部分Java对象则会存活很长一段时间

  • pmd中Java对象生命周期的直方图,红色的表示被逃逸分析优化掉的对象

    之所以要提到这个假设,是因为它造就了Java虚拟机的分代回收思想
    就是将堆空间划分为两代,分别叫做新生代和老年代
  • 新生代用来存储新建的对象
  • 当对象存活时间够长时,则将其移动到老年代

Java虚拟机可以给不同代使用不同的回收算法
对于新生代,大部分的Java对象只存活一小段时间,那么便可以频繁地采用耗时较短的垃圾回收算法,让大部分的垃圾都能够在新生代被回收掉。

对于老年代,大部分的垃圾已经在新生代中被回收了,而在老年代中的对象有大概率会继续存活。当真正触发针对老年代的回收时,则代表这个假设出错了,或者堆的空间已经耗尽了。

这时候,Java虚拟机往往需要做一次全堆扫描,耗时也将不计成本。(当然,现代的垃圾回收器都在并发收集的道路上发展,来避免这种全堆扫描的情况。)

今天来关注一下针对新生代的Minor GC

首先,我们来看看

Java虚拟机的堆划分

Java虚拟机将堆划分为新生代和老年代
其中,新生代又被划分为Eden区,以及两个大小相同的Survivor区。

默认情况下,Java虚拟机采取的是一种动态分配的策略(对应Java虚拟机参数-XX:+UsePSAdaptiveSurvivorSizePolicy),根据生成对象的速率,以及Survivor区的使用情况动态调整Eden区和Survivor区的比例。

当然,你也可以通过参数-XX:SurvivorRatio来固定这个比例
但是需要注意的是,其中一个Survivor区会一直为空,因此比例越低浪费的堆空间将越高。

当调用new指令时,它会在Eden区中划出一块作为存储对象的内存
由于堆空间是线程共享的,因此直接在这里边划空间是需要进行同步的

否则,将有可能出现两个对象共用一段内存的事故
就相当于两个司机(线程)同时将车停入同一个停车位,因而发生剐蹭事故。

Java虚拟机的解决方法是为每个司机预先申请多个停车位,并且只允许该司机停在自己的停车位上。那么当司机的停车位用完了该怎么办呢(假设这个司机代客泊车)?
答案是:再申请多个停车位便可以了
这项技术被称之为TLAB(Thread Local Allocation Buffer,对应虚拟机参数-XX:+UseTLAB,默认开启)。

具体来说,每个线程可以向Java虚拟机申请一段连续的内存,比如2048字节,作为线程私有的TLAB
这个操作需要加锁,线程需要维护两个指针(实际上可能更多,但重要也就两个),一个指向TLAB中空余内存的起始位置,一个则指向TLAB末尾

接下来的new指令,便可以直接通过指针加法(bump the pointer)来实现,即把指向空余内存位置的指针加上所请求的字节数。

为什么不把bump the pointer翻译成指针碰撞

在英语中通常省略了bump up the pointer中的up
在这个上下文中bump的含义应为“提高”
另外一个例子是当我们发布软件的新版本时,也会说bump the version number

如果加法后空余内存指针的值仍小于或等于指向末尾的指针,则代表分配成功。否则,TLAB已经没有足够的空间来满足本次新建操作。这个时候,便需要当前线程重新申请新的TLAB。

当Eden区的空间耗尽了怎么办?

这个时候Java虚拟机便会触发一次Minor GC,来收集新生代的垃圾存活下来的对象,则会被送到Survivor区。

新生代共有两个Survivor区,我们分别用from和to来指代
其中to指向的Survivior区是空的。
当发生Minor GC时,Eden区和from指向的Survivor区中的存活对象会被复制到to指向的Survivor区中,然后交换from和to指针,以保证下一次Minor GC时,to指向的Survivor区还是空的。

Java虚拟机会记录Survivor区中的对象一共被来回复制了几次
如果一个对象被复制的次数为15(对应虚拟机参数-XX:+MaxTenuringThreshold),那么该对象将被晋升(promote)至老年代
另外,如果单个Survivor区已经被占用了50%(对应虚拟机参数-XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。

总而言之,当发生Minor GC时,我们应用了复制算法,将Survivor区中的老存活对象晋升到老年代,然后将剩下的存活对象和Eden区的存活对象复制到另一个Survivor区中
理想情况下,Eden区中的对象基本都死亡了,那么需要复制的数据将非常少,因此采用这种复制算法的效果极好。

Minor GC的另外一个好处是不用对整个堆进行垃圾回收
但是,它却有一个问题,那就是老年代的对象可能引用新生代的对象
也就是说,在标记存活对象的时候,我们需要扫描老年代中的对象。如果该对象拥有对新生代对象的引用,那么这个引用也会被作为GC Roots。
这样一来,岂不是又做了一次全堆扫描呢?

卡表(Card Table)

HotSpot给出的解决方案.
将整个堆划分为一个个大小为512字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位
这个标识位代表对应的卡是否可能存有指向新生代对象的引用
如果可能存在,那么我们就认为这张卡是脏的。

在进行Minor GC的时候,便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到Minor GC的GC Roots里。当完成所有脏卡的扫描之后,Java虚拟机便会将所有脏卡的标识位清零。

由于Minor GC伴随着存活对象的复制,而复制需要更新指向该对象的引用。
因此,在更新引用的同时,又会设置引用所在的卡的标识位
这个时候,可以确保脏卡中必定包含指向新生代对象的引用。

在Minor GC之前,我们并不能确保脏卡中包含指向新生代对象的引用。其原因和如何设置卡的标识位有关。

首先,如果想要保证每个可能有指向新生代对象引用的卡都被标记为脏卡,那么Java虚拟机需要截获每个引用型实例变量的写操作,并作出对应的写标识位操作。

这个操作在解释执行器中比较容易实现
但是在即时编译器生成的机器码中,则需要插入额外的逻辑。这也就是所谓的写屏障(write barrier,注意不要和volatile字段的写屏障混淆)。

写屏障需要尽可能地保持简洁。这是因为我们并不希望在每条引用型实例变量的写指令后跟着一大串注入的指令。

因此,写屏障并不会判断更新后的引用是否指向新生代中的对象,而是宁可错杀,不可放过,一律当成可能指向新生代对象的引用。

这么一来,写屏障便可精简为下面的伪代码
这里右移9位相当于除以512,Java虚拟机便是通过这种方式来从地址映射到卡表中的索引的。
最终,这段代码会被编译成一条移位指令和一条存储指令。

CARD_TABLE [this address >> 9] = DIRTY;

虽然写屏障不可避免地带来一些开销,但是它能够加大Minor GC的吞吐率( 应用运行时间/(应用运行时间+垃圾回收时间)
总的来说还是值得的。不过,在高并发环境下,写屏障又带来了虚共享(false sharing)问题

在介绍对象内存布局中我曾提到虚共享问题,讲的是几个volatile字段出现在同一缓存行里造成的虚共享。这里的虚共享则是卡表中不同卡的标识位之间的虚共享问题。

在HotSpot中,卡表是通过byte数组来实现的。对于一个64字节的缓存行来说,如果用它来加载部分卡表,那么它将对应64张卡,也就是32KB的内存。

如果同时有两个Java线程,在这32KB内存中进行引用更新操作,那么也将造成存储卡表的同一部分的缓存行的写回、无效化或者同步操作,因而间接影响程序性能。

为此,HotSpot引入了一个新的参数-XX:+UseCondCardMark,来尽量减少写卡表的操作。其伪代码如下所示:

if (CARD_TABLE [this address >> 9] != DIRTY) CARD_TABLE [this address >> 9] = DIRTY;

总结

Java虚拟机将堆分为新生代和老年代,并且对不同代采用不同的垃圾回收算法。
其中,新生代分为Eden区和两个大小一致的Survivor区,并且其中一个Survivor区是空的。

在只针对新生代的Minor GC中,Eden区和非空Survivor区的存活对象会被复制到空的Survivor区中,当Survivor区中的存活对象复制次数超过一定数值时,它将被晋升至老年代。

因为Minor GC只针对新生代进行垃圾回收,所以在枚举GC Roots的时候,它需要考虑从老年代到新生代的引用。为了避免扫描整个老年代,Java虚拟机引入了名为卡表的技术,大致地标出可能存在老年代到新生代引用的内存区域。

Java虚拟机的分代垃圾回收是基于大部分对象只存活一小段时间,小部分对象却存活一大段时间的假设的。

然而,现实情况中并非每个程序都符合前面提到的假设。如果一个程序拥有中等生命周期的对象,并且刚移动到老年代便不再使用,那么将给默认的垃圾回收策略造成极大的麻烦。

下面这段程序将生成64G的Java对象。并且,我通过ALIVE_OBJECT_SIZE这一变量来定义同时存活的Java对象的大小。这也是一种对于垃圾回收器来说比较直观的生命周期。

当我们使用Java 8的默认GC,并且将新生代的空间限制在100M时,试着估算当ALIVE_OBJECT_SIZE为多少时,这段程序不会触发Full GC(提示一下,如果Survivor区没法存储所有存活对象,将发生什么。)。实际运行情况又是怎么样的?

// Run with java -XX:+PrintGC -Xmn100M -XX:PretenureSizeThreshold=10000 LifetimeTest
// You may also try with -XX:+PrintHeapAtGC,-XX:-UsePSAdaptiveSurvivorSizePolicy or -XX:SurvivorRatio=N
public class LifetimeTest {private static final int K = 1024;private static final int M = K * K;private static final int G = K * M;private static final int ALIVE_OBJECT_SIZE = 32 * M;public static void main(String[] args) {int length = ALIVE_OBJECT_SIZE / 64;ObjectOf64Bytes[] array = new ObjectOf64Bytes[length];for (long i = 0; i < G; i++) {array[(int) (i % length)] = new ObjectOf64Bytes();}}
}class ObjectOf64Bytes {long placeholder0;long placeholder1;long placeholder2;long placeholder3;long placeholder4;long placeholder5;
}

参考

  • http://psy-lob-saw.blogspot.com/2014/10/the-jvm-write-barrier-card-marking.html
  • https://blogs.oracle.com/dave/false-sharing-induced-by-card-table-marking
  • http://openjdk.java.net/jeps/291
  • https://www.zhihu.com/question/287945354/answer/458761494
  • 深入拆解Java虚拟机
  • 深入理解Java虚拟机

这篇关于深入理解JVM --- 垃圾收集算法终章的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/673537

相关文章

Java实现检查多个时间段是否有重合

《Java实现检查多个时间段是否有重合》这篇文章主要为大家详细介绍了如何使用Java实现检查多个时间段是否有重合,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 目录流程概述步骤详解China编程步骤1:定义时间段类步骤2:添加时间段步骤3:检查时间段是否有重合步骤4:输出结果示例代码结语作

Java中String字符串使用避坑指南

《Java中String字符串使用避坑指南》Java中的String字符串是我们日常编程中用得最多的类之一,看似简单的String使用,却隐藏着不少“坑”,如果不注意,可能会导致性能问题、意外的错误容... 目录8个避坑点如下:1. 字符串的不可变性:每次修改都创建新对象2. 使用 == 比较字符串,陷阱满

Java判断多个时间段是否重合的方法小结

《Java判断多个时间段是否重合的方法小结》这篇文章主要为大家详细介绍了Java中判断多个时间段是否重合的方法,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 目录判断多个时间段是否有间隔判断时间段集合是否与某时间段重合判断多个时间段是否有间隔实体类内容public class D

IDEA编译报错“java: 常量字符串过长”的原因及解决方法

《IDEA编译报错“java:常量字符串过长”的原因及解决方法》今天在开发过程中,由于尝试将一个文件的Base64字符串设置为常量,结果导致IDEA编译的时候出现了如下报错java:常量字符串过长,... 目录一、问题描述二、问题原因2.1 理论角度2.2 源码角度三、解决方案解决方案①:StringBui

Java覆盖第三方jar包中的某一个类的实现方法

《Java覆盖第三方jar包中的某一个类的实现方法》在我们日常的开发中,经常需要使用第三方的jar包,有时候我们会发现第三方的jar包中的某一个类有问题,或者我们需要定制化修改其中的逻辑,那么应该如何... 目录一、需求描述二、示例描述三、操作步骤四、验证结果五、实现原理一、需求描述需求描述如下:需要在

Java中ArrayList和LinkedList有什么区别举例详解

《Java中ArrayList和LinkedList有什么区别举例详解》:本文主要介绍Java中ArrayList和LinkedList区别的相关资料,包括数据结构特性、核心操作性能、内存与GC影... 目录一、底层数据结构二、核心操作性能对比三、内存与 GC 影响四、扩容机制五、线程安全与并发方案六、工程

JavaScript中的reduce方法执行过程、使用场景及进阶用法

《JavaScript中的reduce方法执行过程、使用场景及进阶用法》:本文主要介绍JavaScript中的reduce方法执行过程、使用场景及进阶用法的相关资料,reduce是JavaScri... 目录1. 什么是reduce2. reduce语法2.1 语法2.2 参数说明3. reduce执行过程

如何使用Java实现请求deepseek

《如何使用Java实现请求deepseek》这篇文章主要为大家详细介绍了如何使用Java实现请求deepseek功能,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 目录1.deepseek的api创建2.Java实现请求deepseek2.1 pom文件2.2 json转化文件2.2

Java调用DeepSeek API的最佳实践及详细代码示例

《Java调用DeepSeekAPI的最佳实践及详细代码示例》:本文主要介绍如何使用Java调用DeepSeekAPI,包括获取API密钥、添加HTTP客户端依赖、创建HTTP请求、处理响应、... 目录1. 获取API密钥2. 添加HTTP客户端依赖3. 创建HTTP请求4. 处理响应5. 错误处理6.

Spring AI集成DeepSeek的详细步骤

《SpringAI集成DeepSeek的详细步骤》DeepSeek作为一款卓越的国产AI模型,越来越多的公司考虑在自己的应用中集成,对于Java应用来说,我们可以借助SpringAI集成DeepSe... 目录DeepSeek 介绍Spring AI 是什么?1、环境准备2、构建项目2.1、pom依赖2.2