Java虚拟机自动内存管理机制——理论篇

钱钟书先生说过:Java的自动内存管理机制是一座围城,外面的人想进来,墙里面的人却想出去…
(不然我也不会在这学习JVM了 qwq)

一.Java内存初探

(1)内存区域的划分

JVM在执行的时候会将自己所管理的内存区域划分为不同的区域,这些内存区域有着各自的用途和生命周期。
注意:下面给出的内存模型只是java虚拟机规范给出的概念模型,具体的如何实现将由具体的虚拟机完成。
在这里插入图片描述

1.程序计数器

程序计数器又叫做PC寄存器,是Java虚拟机规范中定义的唯一一个不会抛出OOM异常的内存区域,该区域所占内存很小,通过改变该计数器的值来获取下一条需要执行的字节码指令

2.Java虚拟机栈

虚拟机栈是描述java方法执行的线程内存模型,每一个java方法的执行都对应着虚拟机栈中的每一个栈帧的创建、入栈和出栈的过程。栈帧用于存放局部变量表操作数栈等信息,其中最为重要的是应该是局部变量表信息。
局部变量表中存放着编译期可知的各种基本数据类型、对象引用(reference),基本的存储单位是局部变量槽

3.本地方法栈

本地方法栈的作用与虚拟机栈类似,所不同的是:本地方法栈是为虚拟机执行非java方法服务的。在java虚拟机规范中并没有对本地方法栈的具体细节有强制规定,甚至在主流虚拟机HotSpot中将本地方法栈与虚拟机栈合二为一了。

4.Java堆

Java堆做为Java内存中占地最大的一部分,存在的唯一目的是存放对象实例,同时Java堆也是属于GC垃圾收集器管理的内存区域。
为了便于对堆内存的分配和回收,共享的堆空间也可以划分出多个线程私有的分配缓冲区。
按照《Java虚拟机规范》的规定,Java堆允许内存上不连续,但逻辑上必须连续。在不同的虚拟机实现中,Java堆可以是固定大小的,也可以是可扩展的,这点由虚拟机自身来决定。

5.方法区

5.1

方法区主要用来存放已经被虚拟机加载了的与类型相关的信息、静态变量、常量、代码缓存等

值得一提的是,在jdk8以前的版本中,主流虚拟机HotSpot的方法区是采用永久代设计的,从jdk8开始采用的便是元空间的设计了。

注意:在JDK8之后静态变量存放在堆当中,此时的静态变量在方法区就只是存在于逻辑上的概念了

元空间与永久代.

5.2 运行时常量池

一段代码,在经历编译期后的生成的各种字面量与符号引用都存放于class文件中的常量池表中,再经过类加载过程之后这部分数据又将存放到运行时常量池中。运行时常量池相对于class文件常量池最大的不同是它的动态性,可允许执行期间动态地将数据放入池中。

补充:直接内存

直接内存并不属于JVM内存区域的一部分,它的大小受限于本机总内存和 CPU寻址能力,适用于需要大内存且频繁访问的场合。
JVM在Java堆当中存放着直接内存的地址引用对象:DirectByteBuffer,使用该对象完成对堆外内存的访问。

(2)对象在内存中的处理过程

下面以主流虚拟机HotSpot和最常用的区域Java堆为例,了解Java对象在Java堆当中的创建、布局和访问的过程。

1.对象的创建

当虚拟机遇到new指令时,首先会进行类加载检查(保证必须先执行了类加载过程),之后为Java对象分配内存,同时将字段信息初始化为零值,接着再进行对象头信息的初始化(HashCode、元数据信息等),最后再执行<init>方法,完成对象的创建。

对象的内存分配策略主要有两种:指针碰撞空闲列表,分别对应内存的集中分布与离散分布。

由于对象的创建是非常频繁的,所以我们需要保证绝对的线程安全。主要提供以下两种解决方案:

a.对分配内存空间的动作进行同步处理。HotSpot虚拟机采用的是CAS锁+失败重试的方式保证更新操作的原子性。
b.将内存分配的动作划分到不同的空间,为每一个线程预先分配一个本地线程分配缓冲(TLAB),TLAB用完后再进行同步锁定。该策略是否使用可通过-XX:+/-UseTLAB参数来确定

2.对象数据的布局(组成)

Java对象数据在内存中除了由实例数据构成之外,还包括对齐填充(充当占位符的作用:在HotSpot虚拟机当中要求对象的起始地址必须是8的整数倍)和对象头数据

对象头数据中存放着运行时数据(Mark Word)类型指针

运行时数据里面存放着许许多多与对象本身定义的数据无关的数据信息,为了节省空间,采取动态定义的数据结构(即通过设置标志位来代表不同的状态,不同状态下对应存放不同的内容)。

虚拟机通过类型指针确定对象的对应类,但是并不是所有的对象都需要保留类型指针(原因是:通过句柄访问对象时,其中既存放着对象实例地址也存放着类型数据的地址)。此外,如果该对象是一个数组的话,还需要保留数组的长度信息。

3.对象的访问

Java程序通过栈上的reference引用来定位Java对象,主流的访问方式有以下两种:
在这里插入图片描述
在这里插入图片描述
两种访问方式各有优势:

采取句柄访问时,当对象地址发生改变时只需要改变句柄的取值,不需要改变reference的值。
采用直接指针访问方式时,最大的好处就是速度的提升。

二.垃圾收集器

Java与C、C++语言之间最大的不同之一是它的自动垃圾回收机制。但在面临一些特定环境下的内存溢出、内存泄露等问题时,又需要我们自己来排查故障和性能调优。我们已经知道了Java内存区域的划分,本地方法栈、虚拟机栈、程序计数器是属于线程私有的部分,与线程同生共死,相濡以沫。这一部分区域不需要GC,需要GC机制的地方是Java堆和方法区。

首先在思想上要确认的是,和人一样,从来没有放之四海皆perfect的垃圾收集器存在。只有考虑具体的应用场景,选择最佳的垃圾收集器和参数组合才能获得最好的性能。(学习Java虚拟机的目的也正是在此)

(1)对象存活判定算法(堆回收)

最直接的判定算法是引用计数算法:存在一次引用就将计数器+1,引用失效时就-1,直到计数器为0时就判定对象已死。该算法过于简单,没有考虑特殊性的一些情况。例如,对象循环引用时,即便对象永远不可能再被访问,可计数器依然不为0,垃圾回收器也就在那望着它也不回收。
在这里插入图片描述
当前主流的判定算法是可达性分析算法以一些称为"GC Roots“的根对象做为起始节点集,根据引用关系向下搜索,走过的路径称为”引用链"。当一个对象再没有了到GC Roots的引用链,则表明该对象已死。(不过这是处于缓刑期,在真正回收该对象之前还会尝试对象的finalize()方法,这是对象的最后一次自我救赎机会!(因为种种原因,该方法不推荐使用))
可做为 GC Roots的包括:虚拟机内部引用、常量引用、静态引用、常驻异常对象、对象引用等

补:方法区的垃圾回收

方法区在《Java虚拟机规范》中不要求一定实现垃圾回收,因为相对于堆回收来说,对方法区回收的性价比太低了(囿于苛刻的判定条件)。但是在某些特定场合之下又需要对方法区实现垃圾回收:大量使用反射、动态代理等频繁生成自定义类加载器的地方。
然后是,方法区存放的是常量和与类相关的信息,自然回收的对象也是常量与类相关信息。

(2)垃圾收集算法(思想)

1.前言:分代收集理论

三个基本假说:
1.绝大多数对象都是朝生夕灭的(老是死在黎明前的黑暗里)
2.经历过越多次垃圾回收但未被回收的对象,愈发难以回收(维京勇者北风造)
3.跨代引用仅占极少数(相同频率,相同节奏的两个对象一般都存在于同一个地方)

显而易见,如果按照对象的年龄进行分区存放,每一个分区按照其不同的特点采取不同的垃圾收集机制,这将获得效率上的极大提升。
另外,根据第三条假说,对于跨代引用的问题,我们并不需要在垃圾收集过程中扫描整个内存,只需要建立一个记忆集记录下存在跨代引用的内存单元,GC时扫描这一小部分的记录单元即可。

2.常见收集算法思想

2.1 标记-清除算法

标记存活对象或死亡对象,再清除掉未被标记对象或标记对象。

存在的主要问题是会产生大量的内存碎片

《深入理解Java虚拟机》中说到还存在另一个问题:当堆中大部分对象都需要回收时必须进行大量标记和清除的工作,这个时候效率会降低。
我个人不太理解作者的意思。既然堆中回收的对象占比较大,我们就可以反过来只标记不被回收的对象啊,为什么一定会进行大量标记的工作呢?我觉得作者想表达的应该只是当待清除对象增多时效率会显得低下的问题。(因为清除大量对象的工作无可避免)

2.2 标记-复制算法

将内存进行区域划分,每次只使用一部分的内存空间,当该区域满了之后再将其中的存活对象复制到另一区域,最后一次性批量释放原内存区域。适用于新生代这种存活对象较少的内存区域。

解决了内存碎片的问题但是却产生了内存浪费,同时也需要有其他区域提供内存担保(因为在实际的应用场景中,区域划分比远大于1:1,但是谁也无法保证在任何情况下剩下的内存区域都能够容纳下当前存活的所有对象)

2.3 标记-整理算法

将所有存活对象向一个边界内移动,再清除掉边界外的对象。

该算法在面对老年代这种存活率高的区域时虽然也是一项极为负重的操作了,但是相比于标记-复制来说,不再需要额外的内存担保,更加适合于老年代这种对象高存活率区域的垃圾收集。一种可想象的方案是:在大部分时间采用标记-清除算法,只有当内存碎片多得忍无可忍时再执行一次标记-整理算法。

(3)Garbage First 收集器

1.划时代的解题思路

G1收集器与之前的收集器之间最大的不同是它采用的是化整为零的思想。G1收集器保留了分代的概念,但分代区域不是固定的了。G1将内存回收的最小单元确定为Region,每一个Region都可以去扮演Eden、Survivor和老年代空间的角色,并同时采用不同的回收策略去进行垃圾处理。

G1收集器很好地实现了停顿时间模型在用户给定的停顿时间内,达到最好的回收效果。用户通过-XX:MaxGCPauseMills参数指定停顿时间,G1收集器再按照优先级列表(按照回收的最大收益对Region排序)进行垃圾回收。

值得一提的是,G1收集器并不追求每一次垃圾收集后的绝对干净,只要保证当前内存足够用户使用,这,就够了。(很符合人的佛性思维哈哈)

不过这需要注意一点,不能够把停顿时间设置得过小,因为这样到最后可能就是内存清理的速度跟不上内存分配的速度了。

2.运作过程与细节实现

2.1 对象跨代引用问题的解决思路

之前说过,对于对象引用跨代问题我们不必去扫描整个堆区域,只需要通过记忆集记录下存在跨代引用对象的内存单元,扫描该部分记录下的内存单元即可。

这里的记忆集给出的是一种抽象的数据结构,在HotSpot虚拟机中以卡表的形式实现记忆集,维护一个一定大小的字节数组做为value值(HotSpot中默认大小为512字节)。将内存视作一个个更小的区域,如果该区域内存在有对象的跨代引用,则将其标识下来,称对应的key值变脏,最后再将脏值对应的value区域加入GC Roots中进行扫描。G1收集器更在此基础上采用双向卡表的形式,不仅记录“我指向谁”,还记录“谁指向我”。

在HotSpot中,用写屏障来实现对卡表元素的维护。写屏障简单理解即是在对象的引用赋值语句前后进行标记等操作的额外逻辑代码。另外,为了避免伪共享的问题(数据处理在同一个缓存行时会影响效率),可在执行变脏标记之前先加上一个判定语句:如果没有标记再进行标记(细节,真的细节,每一行代码都是深思熟虑)。该判定是否开启可由-XX:+UseCondCardMark参数决定

2.2 并发过程中用户线程与收集线程的分离

G1收集器在扫描标记阶段是并发进行的,也就是说,不会“Stop the World”。该效果的实现需要解决两个问题:

1.保证在用户线程的执行过程中不会改变原对象图的结构。
2,在并发回收过程中,回收线程进行垃圾收集的同时也不断有新对象被创建,那么对新对象的内存分配也是一个要考虑的问题。

针对第一个问题,首先搞懂三色标记 :
在这里插入图片描述
简单理解为:白色代表消亡的对象;黑色代表一定存活的对象,对象只有被黑色对象引用才能够存活,黑色对象不会返回去重新扫描!!!灰色介于白色和黑色之间(相当于扫描过程中的缓冲区一样)。

那么问题来了,当用户线程与收集线程并发进行时,同时满足以下条件时就会产生对象消失(原本应该存活的对象让她死了!!!)的问题:
在这里插入图片描述
说白了根本原因都是因为黑色对象不会倒回去重新扫描。针对这两个必要条件分别提供两种解决方案:增量更新和原始快照(SATB)

增量更新:就是在插入引用时记录下来,当扫描结束后再以这些黑色对象为根重新扫描。《深入理解Java虚拟机》中的精辟概括:简单理解为黑色对象一旦新插入了指向白色对象的引用之后就变回灰色对象了

原始快照:同样记录删除的引用对象,扫描结束之后再以其中的灰色对象为根重新扫描。同样的精辟概括:无论引用关系删除与否,都将按照原始的对象图快照进行记录

针对第二个问题:
虚拟机在gc过程中肯定要先把新创建对象视为存活的,HotSpot为每一个Region设计了两个TAMS指针(top at mark start):prevTAMS和nextTAMS指针

《深入理解Java虚拟机》中这部分没有讲清楚,下面这张图片更为清晰:
在这里插入图片描述
注:图片原文链接.

2.3 运作过程

大致分为四个步骤:

1.初始标记。该阶段只简单标记到与GC Roots能直接关联到的部分,该阶段需停顿线程但耗时较短。
2.并发标记。递归扫描整个对象图进行可达性分析,耗时较长但可与用户线程并发执行。
3.最终标记。倒回去重新扫描SATB(原始快照)中的遗留GC Roots,该过程也需停顿线程。
4.筛选回收。按照Region的回收最大收益进行排序组成回收集,在两个Region之间采用标记-复制算法进行垃圾回收。
在这里插入图片描述

补:根节点枚举、安全点与安全区域

我们已经知道可通过增量扫描和原始快照(SATB)的方法实现对查找引用链过程的并发,但是对于根节点的扫描,是必须要停顿用户线程的。(如果根节点的对象引用也是处于不断的修改之中,那之后所有结果的分析可靠性就得不到保证)

为了提高根节点枚举过程的效率,HotSpot在类加载过程后就会计算并保留住对象的引用信息(即时编译过程中也会记录下来这些引用关系)。

安全点,通俗说来就是用户线程停下来进行垃圾收集的位置。对应的安全区域,可视作拉长了的安全点,该区域内的对象引用关系不会发生改变,故可在这个区域内的任何时刻进行垃圾收集。安全区域的存在可用来解决用户线程因各种原因被迫停下来的情况(eg:Sleep状态或Blocked状态),如果这个时候虚拟机发起垃圾收集也不会去管这些声明已在安全区域的用户线程。

3.G1收集器与CMS收集器的比较

首先G1收集器的实现思想本来就是划时代的里程碑地位。G1按Region进行内存布局,实现停顿模型 ,用户可自己指定停顿时间,按照回收收益对Region进行排序组成回收集。其次,G1从整体上看采用的是标记-整理算法,局部上是标记-复制算法,但无论是哪种,都保证了内存空间的连续性
但是,G1在为了进行垃圾收集而产生的内存占用和程序运行时的执行负载都比CMS高。

祸兮福所倚,福兮祸所伏

G1收集器不再严格区分新生代与老年代,统一采用Region的内存结构,就记忆集来说,每一个Region都对应着一个卡表(反观CMS,只记录了老年代对象到新生代对象的引用。因为新生代对象引用变化过于频繁,这种处理方式更为划算),并且相比CMS的卡表还更加复杂,最终导致G1的额外内存占用比CMS高得多。

猜你喜欢

转载自blog.csdn.net/m0_46550452/article/details/110386219