据说看完这篇 JVM 要一小时(三)

简介: 大家好,我是干货旋,今天又给大家怼干货了,我不就放纵了几天么,有些难搞的读者还以为我把号卖了,呐,你说我不出干货,我就问你这篇文章敢不敢三连。

对象访问定位的方式有哪些?

我们创建一个对象的目的当然就是为了使用它,但是,一个对象被创建出来之后,在 JVM 中是如何访问这个对象的呢?一般有两种方式:通过句柄访问通过直接指针访问

  • 如果使用句柄访问方式的话,Java 堆中可能会划分出一块内存作为句柄池,引用(reference)中存储的是对象的句柄地址,而句柄中包含了对象的实例数据与类型数据各自具体的地址信息。如下图所示。

微信图片_20220417152343.jpg

  • 如果使用直接指针访问的话,Java 堆中对象的内存布局就会有所区别,栈区引用指示的是堆中的实例数据的地址,如果只是访问对象本身的话,就不会多一次直接访问的开销,而对象类型数据的指针是存在于方法区中,如果定位的话,需要多一次直接定位开销。如下图所示

微信图片_20220417152347.jpg

这两种对象访问方式各有各的优势,使用句柄最大的好处就是引用中存储的是句柄地址,对象移动时只需改变句柄的地址就可以,而无需改变对象本身。

使用直接指针来访问速度更快,它节省了一次指针定位的时间开销,由于对象访问在 Java 中非常频繁,因为这类的开销也是值得优化的地方。

上面聊到了对象的两种数据,一种是对象的实例数据,这没什么好说的,就是对象实例字段的数据,一种是对象的类型数据,这个数据说的是对象的类型、父类、实现的接口和方法等。

如何判断对象已经死亡?

我们大家知道,基本上所有的对象都在堆中分布,当我们不再使用对象的时候,垃圾收集器会对无用对象进行回收♻️,那么 JVM 是如何判断哪些对象已经是"无用对象"的呢?

这里有两种判断方式,首先我们先来说第一种:引用计数法

引用计数法的判断标准是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器的值就会加一;当引用失效时,计数器的值就会减一;只要任何时刻计数器为零的对象就是不会再被使用的对象。虽然这种判断方式非常简单粗暴,但是往往很有用,不过,在 Java 领域,主流的 Hotspot 虚拟机实现并没有采用这种方式,因为引用计数法不能解决对象之间的循环引用问题。

循环引用问题简单来讲就是两个对象之间互相依赖着对方,除此之外,再无其他引用,这样虚拟机无法判断引用是否为零从而进行垃圾回收操作。

还有一种判断对象无用的方法就是可达性分析算法

当前主流的 JVM 都采用了可达性分析算法来进行判断,这个算法的基本思路就是通过一系列被称为GC Roots的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程走过的路径被称为引用链(Reference Chain),如果某个对象到 GC Roots 之间没有任何引用链相连接,或者说从 GC Roots 到这个对象不可达时,则证明此这个对象是无用对象,需要被垃圾回收。

这种引用方式如下

微信图片_20220417152351.jpg

如上图所示,从枚举根节点 GC Roots 开始进行遍历,object 1 、2、3、4 是存在引用关系的对象,而 object 5、6、7 之间虽然有关联,但是它们到 GC Roots 之间是不可达的,所以被认为是可以回收的对象。

在 Java 技术体系中,可以作为 GC Roots 进行检索的对象主要有

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中类静态属性引用的对象,比如 Java 类的引用类型静态变量。
  • 方法区中常量引用的对象,比如字符串常量池中的引用。
  • 在本地方法栈中 JNI 引用的对象。
  • JVM 内部的引用,比如基本数据类型对应的 Class 对象,一些异常对象比如 NullPointerException、OutOfMemoryError 等,还有系统类加载器。
  • 所有被 synchronized 持有的对象。
  • 还有一些 JVM 内部的比如 JMXBean、JVMTI 中注册的回调,本地代码缓存等。
  • 根据用户所选的垃圾收集器以及当前回收的内存区域的不同,还可能会有一些对象临时加入,共同构成 GC Roots 集合。

虽然我们上面提到了两种判断对象回收的方法,但无论是引用计数法还是判断 GC Roots 都离不开引用这一层关系。

这里涉及到到强引用、软引用、弱引用、虚引用的引用关系,你可以阅读作者的这一篇文章

小心点,别被当成垃圾回收了。

如何判断一个不再使用的类?

判断一个类型属于"不再使用的类"需要满足下面这三个条件

  • 这个类所有的实例已经被回收,也就是 Java 堆中不存在该类及其任何这个类字类的实例
  • 加载这个类的类加载器已经被回收,但是类加载器一般很难会被回收,除非这个类加载器是为了这个目的设计的,比如 OSGI、JSP 的重加载等,否则通常很难达成。
  • 这个类对应的 Class 对象没有任何地方被引用,无法在任何时刻通过反射访问这个类的属性和方法。

虚拟机允许对满足上面这三个条件的无用类进行回收操作。

JVM 分代收集理论有哪些?

一般商业的虚拟机,大多数都遵循了分代收集的设计思想,分代收集理论主要有两条假说。

第一个是强分代假说,强分代假说指的是 JVM 认为绝大多数对象的生存周期都是朝生夕灭的;

第二个是弱分代假说,弱分代假说指的是只要熬过越多次垃圾收集过程的对象就越难以回收(看来对象也会长心眼)。

就是基于这两个假说理论,JVM 将区划分为不同的区域,再将需要回收的对象根据其熬过垃圾回收的次数分配到不同的区域中存储。

JVM 根据这两条分代收集理论,把堆区划分为新生代(Young Generation)和老年代(Old Generation)这两个区域。在新生代中,每次垃圾收集时都发现有大批对象死去,剩下没有死去的对象会直接晋升到老年代中。

上面这两个假说没有考虑对象的引用关系,而事实情况是,对象之间会存在引用关系,基于此又诞生了第三个假说,即跨代引用假说(Intergeneration Reference Hypothesis),跨代引用相比较同代引用来说仅占少数。

正常来说存在相互引用的两个对象应该是同生共死的,不过也会存在特例,如果一个新生代对象跨代引用了一个老年代的对象,那么垃圾回收的时候就不会回收这个新生代对象,更不会回收老年代对象,然后这个新生代对象熬过一次垃圾回收进入到老年代中,这时候跨代引用才会消除。

根据跨代引用假说,我们不需要因为老年代中存在少量跨代引用就去直接扫描整个老年代,也不用在老年代中维护一个列表记录有哪些跨代引用,实际上,可以直接在新生代中维护一个记忆集(Remembered Set),由这个记忆集把老年代划分称为若干小块,标识出老年代的哪一块会存在跨代引用。

记忆集的图示如下

微信图片_20220417152357.jpg

从图中我们可以看到,记忆集中的每个元素分别对应内存中的一块连续区域是否有跨代引用对象,如果有,该区域会被标记为“脏的”(dirty),否则就是“干净的”(clean)。这样在垃圾回收时,只需要扫描记忆集就可以简单地确定跨代引用的位置,是个典型的空间换时间的思路。

聊一聊 JVM 中的垃圾回收算法?

在聊具体的垃圾回收算法之前,需要明确一点,哪些对象需要被垃圾收集器进行回收?也就是说需要先判断哪些对象是"垃圾"?

判断的标准我在上面如何判断对象已经死亡的问题中描述了,有两种方式,一种是引用计数法,这种判断标准就是给对象添加一个引用计数器,引用这个对象会使计数器的值 + 1,引用失效后,计数器的值就会 -1。但是这种技术无法解决对象之间的循环引用问题。

还有一种方式是 GC Roots,GC Roots 这种方式是以 Root 根节点为核心,逐步向下搜索每个对象的引用,搜索走过的路径被称为引用链,如果搜索过后这个对象不存在引用链,那么这个对象就是无用对象,可以被回收。GC Roots 可以解决循环引用问题,所以一般 JVM 都采用的是这种方式。

解决循环引用代码描述:

public class test{
    public static void main(String[]args){
        A a = new A();
        B b = new B();
        a=null;
        b=null;
    }
}
class A {
    public B b;
}
class B {
    public A a;
}

基于 GC Roots 的这种思想,发展出了很多垃圾回收算法,下面我们就来聊一聊这些算法。

标记-清除算法

标记-清除(Mark-Sweep)这个算法可以说是最早最基础的算法了,标记-清除顾名思义分为两个阶段,即标记和清除阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。当然也可以标记存活的对象,回收未被标记的对象。这个标记的过程就是垃圾判定的过程。

后续大部分垃圾回收算法都是基于标记-算法思想衍生的,只不过后续的算法弥补了标记-清除算法的缺点,那么它有什么缺点呢?主要有两个

  • 执行效率不稳定,因为假如说堆中存在大量无用对象,而且大部分需要回收的情况下,这时必须进行大量的标记和清除,导致标记和清除这两个过程的执行效率随对象的数量增长而降低。
  • 内存碎片化,标记-清除算法会在堆区产生大量不连续的内存碎片。碎片太多会导致在分配大对象时没有足够的空间,不得不进行一次垃圾回收操作。

标记算法的示意图如下

微信图片_20220417152402.jpg

标记-复制算法

由于标记-清除算法极易产生内存碎片,研究人员提出了标记-复制算法,标记-复制算法也可以简称为复制算法,复制算法是一种半区复制,它会将内存大小划分为相等的两块,每次只使用其中的一块,用完一块再用另外一块,然后再把用过的一块进行清除。虽然解决了部分内存碎片的问题,但是复制算法也带来了新的问题,即复制开销,不过这种开销是可以降低的,如果内存中大多数对象是无用对象,那么就可以把少数的存活对象进行复制,再回收无用的对象。

不过复制算法的缺陷也是显而易见的,那就是内存空间缩小为原来的一半,空间浪费太明显。标记-复制算法示意图如下

微信图片_20220417152405.jpg

现在 Java 虚拟机大多数都是用了这种算法来回收新生代,因为经过研究表明,新生代对象 98% 都熬不过第一轮收集,因此不需要按照 1 :1 的比例来划分新生代的内存空间。

基于此,研究人员提出了一种 Appel 式回收,Appel 式回收的具体做法是把新生代分为一块较大的 Eden 空间和两块 Survivor 空间,每次分配内存都只使用 Eden 和其中的一块 Survivor 空间,发生垃圾收集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已使用过的 Survivor 空间。

在主流的 HotSpot 虚拟机中,默认的 Eden 和 Survivor 大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%,只有一个 Survivor 空间,所以会浪费掉 10% 的空间。这个 8:1 只是一个理论值,也就是说,不能保证每次都有不超过 10% 的对象存活,所以,当进行垃圾回收后如果 Survivor 容纳不了可存活的对象后,就需要其他内存空间来进行帮助,这种方式就叫做内存担保(Handle Promotion) ,通常情况下,作为担保的是老年代。

标记-整理算法

标记-复制算法虽然解决了内存碎片问题,但是没有解决复制对象存在大量开销的问题。为了解决复制算法的缺陷,充分利用内存空间,提出了标记-整理算法。该算法标记阶段和标记-清除一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。具体过程如下图所示:

微信图片_20220417152410.jpg

相关文章
|
存储 设计模式 缓存
据说看完这篇 JVM 要一小时(四)
大家好,我是干货旋,今天又给大家怼干货了,我不就放纵了几天么,有些难搞的读者还以为我把号卖了,呐,你说我不出干货,我就问你这篇文章敢不敢三连。
据说看完这篇 JVM 要一小时(四)
|
存储 算法 Java
据说看完这篇 JVM 要一小时(二)
大家好,我是干货旋,今天又给大家怼干货了,我不就放纵了几天么,有些难搞的读者还以为我把号卖了,呐,你说我不出干货,我就问你这篇文章敢不敢三连。
据说看完这篇 JVM 要一小时(二)
|
存储 安全 Java
据说看完这篇 JVM 要一小时(一)
大家好,我是干货旋,今天又给大家怼干货了,我不就放纵了几天么,有些难搞的读者还以为我把号卖了,呐,你说我不出干货,我就问你这篇文章敢不敢三连。
据说看完这篇 JVM 要一小时(一)
|
26天前
|
缓存 Prometheus 监控
Elasticsearch集群JVM调优设置合适的堆内存大小
Elasticsearch集群JVM调优设置合适的堆内存大小
208 1
|
2月前
|
存储 安全 Java
jvm 锁的 膨胀过程?锁内存怎么变化的
【10月更文挑战第3天】在Java虚拟机(JVM)中,`synchronized`关键字用于实现同步,确保多个线程在访问共享资源时的一致性和线程安全。JVM对`synchronized`进行了优化,以适应不同的竞争场景,这种优化主要体现在锁的膨胀过程,即从偏向锁到轻量级锁,再到重量级锁的转变。下面我们将详细介绍这一过程以及锁在内存中的变化。
40 4
|
15天前
|
存储 监控 算法
深入探索Java虚拟机(JVM)的内存管理机制
本文旨在为读者提供对Java虚拟机(JVM)内存管理机制的深入理解。通过详细解析JVM的内存结构、垃圾回收算法以及性能优化策略,本文不仅揭示了Java程序高效运行背后的原理,还为开发者提供了优化应用程序性能的实用技巧。不同于常规摘要仅概述文章大意,本文摘要将简要介绍JVM内存管理的关键点,为读者提供一个清晰的学习路线图。
|
24天前
|
Java
JVM内存参数
-Xmx[]:堆空间最大内存 -Xms[]:堆空间最小内存,一般设置成跟堆空间最大内存一样的 -Xmn[]:新生代的最大内存 -xx[use 垃圾回收器名称]:指定垃圾回收器 -xss:设置单个线程栈大小 一般设堆空间为最大可用物理地址的百分之80
|
25天前
|
Java
JVM运行时数据区(内存结构)
1)虚拟机栈:每次调用方法都会在虚拟机栈中产生一个栈帧,每个栈帧中都有方法的参数、局部变量、方法出口等信息,方法执行完毕后释放栈帧 (2)本地方法栈:为native修饰的本地方法提供的空间,在HotSpot中与虚拟机合二为一 (3)程序计数器:保存指令执行的地址,方便线程切回后能继续执行代码
19 3
|
26天前
|
存储 缓存 监控
Elasticsearch集群JVM调优堆外内存
Elasticsearch集群JVM调优堆外内存
45 1
|
1月前
|
Arthas 监控 Java
JVM进阶调优系列(9)大厂面试官:内存溢出几种?能否现场演示一下?| 面试就那点事
本文介绍了JVM内存溢出(OOM)的四种类型:堆内存、栈内存、元数据区和直接内存溢出。每种类型通过示例代码演示了如何触发OOM,并分析了其原因。文章还提供了如何使用JVM命令工具(如jmap、jhat、GCeasy、Arthas等)分析和定位内存溢出问题的方法。最后,强调了合理设置JVM参数和及时回收内存的重要性。