Java 的垃圾回收主要是针对 Heap 堆区对象实例的回收,而对于其引用,是随线程消亡而清除空间的。垃圾回收涉及到对象的是否死亡,回收策略算法。下面我们来看一看一个对象是如何在 Heap 上划分内存,且如何判断其死亡和回收的。
Java 对象的创建
- 当使用 new 关键字的时候,首先去常量池检查指令的参数能否定位到类的符号引用,并检查是否被加载,解析,初始化过?,如果否,则执行类加载过程。
- 在堆中分配内存,为这个对象分配一部分内存。通过
指针碰撞
或者空闲列表
。 - 进行对象的初始化设置,包括对象是哪个的类实例,如何找到类的元数据,对象的 HashCode,GC 分代信息,这些信息存在
对象头
。
内存的划分存在这线程的并发安全性问题,必须使用哦同步措施来保证,一种采用加锁 CAS/Sychronired 保证安全,另一种是预先为每个线程分配一块内存称为 本地线程分配缓冲TLAB
,当这个线程需要创建对象的时候就在这块内存中划分,是否使用 TALAB 可以使用参数 -XX:+/UseTLAB
来指定。
对象内存分配两种方式:
- 指针碰撞:如果每次划分都是按顺序从一侧划分一部分,在分配和未分配之间使用一个指针作为分界点指示器。那么分配内存就是将指针移动与对象大小相等的距离,这种分配方式叫做
"指针碰撞"
。 - 空闲列表:如果分配的空间凌乱,相互交错,那么只能维护一个 VM 内存的
空闲列表Free List
,哪一块内存可用必须详细记录,这样在分配的时候才能找到足够独享大小的内存去分配。这种饭是钢 hi 就叫做"空闲列表"
。
对象的内存布局
hotSpot 中对象布局分为三部分:对象头,实例数据,对齐填充。
⬜️ 对象头
分为 Mark word
和 类元数据指针
[[如果对象是数组,这里还要存储数组的长度,因为VM从Java对象元数据可以了解到对象的大小,但是无法从数组元数据获取到长度
]]** ,类的元数据指针
不用多说,指向一个对象所属类的信息。Mark Work
存储了分代年龄,hash 码,锁状态。。如下:
存储内容 | 标志位 | 状态 |
---|---|---|
对象HashCode,GC分代年龄 | 01 | 未锁定 |
指向锁记录的指针 | 00 | 轻量级锁定 |
指向重量级锁的指针 | 10 | 重量级锁定 |
GC标记 | 11 | GC标记 |
偏向线程ID,偏向时间戳 | 01 | 未偏向 |
⬜️ 实例数据
部分存储的是对象真正的有效信息,即代码中定义的各种变量。无论是父类继承,还是自己定义都要被记录起来。他们的存储顺序受到源码中定义的顺序和 VM 的分配策略的影响。相同宽度字段在一起,父类变量在子类之前。子类的较窄变量也有可能会插入到父类变量间,前提是开启 CompactFileds=true。
⬜️ 对齐填充
没有什么具体的作用,仅仅是用来保证对象具有相同的字节长度。
对象如何定位
我们寻找一个对象的时候,通过栈上的指向对象的指针即对象引用来获取对象的位置。但是如何去定位获取它的位置,JVM 实现的方式也不同,主流的访问方式包括两种:句柄指针
,直接指针
。
直接指针
直接指针相对简单,就是引用直接指向 堆中实例对象数据的地址
,而在这部分数据中还包含了指向其类元数据的指针。
⬜️ 优势:reference 中存储的是稳定的句柄地址,一旦实例对象位置发生改变,这个 reference 都不需要改变,只需要修改句柄中的对象实例指针。
⬜️ 劣势:两次指针定位才能够定位到对象,开销较大,频繁的对象访问较为耗时。
句柄指针
使用句柄访问的话,会在 Java 堆中划分你出一块内存用于存储 指向对象数据的指针
和 执行对象元数据的指针
,这块区域叫做 句柄池
。
⬜️ 优势:reference 中存储的是对象实例指针。访问速度快,可以直接定位到对象数据。
⬜️ 劣势:对象位置发生改变就需要重新修改 reference。
对象已死?
对象是否死亡是垃圾回收的关键,这里的对象死亡有不同的定义方式,也有不同的评价标准。确定一个对象是否已死是垃圾回收的第一步。如果回收了仍在使用的对象就会导致程序出现问题,如果一个对象很久不使用不对其回收就会导致内存中的垃圾越来越多最终占满空间,程序运行所欲的内存空间得不到分配,就会出现 OOM,同样导致程序不能运行。那么如何判定一个对象已死呢?我们通过判定对象是否存在引用来确定
,实现的方式有两种:引用计数
,可达性分析
。
一、引用计数法[Reference Counting]
引用计数法
:是一种思路策略非常简单的方法,它通过给对向象添加一个引用计数器,每当被引用的时候就给计数器 +1,引用失效的时候就-1,任何时刻计数器为 0 的对象就是不可能再被使用的。那么就可以被回收。致命弱点
:无法解决循环引用问题。两个对象相互引用,但是对象并没有进行其他的操作,因此他们是需要被回收的,引用计数就无法告诉 GC 此对象需要被回收。
二、可达性分析
可达性分析
:是通过一系列称之为 "GC Roots"
的对象作为起始点,向下搜索,搜索的路径称为 引用链
,如果某一个对象与所有的 GC Root 之间没有引用链相连【如同图论所说的两点之间不可达】,那么此对象就是待回收对象。
可作为 GC Roots 的对象
⬜️ 虚拟机栈,栈帧中的本地变量表所引用的对象
⬜️ 元空间(方法区)类静态属性所引用的对象
⬜️ 元空间(方法区)中常量引用的对象
⬜️ 本地方法栈 JNI,即 Native 方法引用的对象。
枚举根结点 GC Roots
一旦对象回收就需要通过可达性分析需要回收的对象,此时我们需要一个安全的不变的环境(保持引用关系不变)来进行分析,以保证结果的准确性。这是导致 GC 进行时必须停顿所有线程("stop the world"
)的原因.
三、回收流程,死亡判决
垃圾回收算法
一、标记清除法
标记清除法
:"Mark-Sweep",分为标记,清除两个阶段,先标记出需要回收的对象,标记完成后统一回收。
⬜️ 缺点:标记和清除两步效率低下,耗时。同时会产生大量碎片空间,当分配不到一个连续的足够大的内存就会触发一次 GC。
二、复制清除法
复制清除法
:是对内存区域进行划分,一部分用来进行分配,触发 GC 的时候将所有存活的对象复制到另一区域,将之前的区域清空。通过不断重复这个过程来实现垃圾回收。相比标记清除效率更高,不会产生内存碎片。
⬜️ 缺点:浪费了一半的内存空间,且当对象存活率较高时会不断进行复制。如果不等分的情况下就需要分配担保。
三、标记整理法
标记整理法
:“Mark-Compact”是专门针对老年代而产生的,老年代对象生命周期长,死亡率低,一般来说回收率底。采用复制法不想浪费 50% 空间就要分配担保,以保证复制的对象都有内存可以存储。他比“标记-清除”多了一个移动的过程。在标记之后,将所有的存活对象都想一侧移动,最终使得内存分为两部分,然后将回收对象全部清除。
以上三种方法是基础方法,不同的 JVM 会有不同的实现方式,同时根据情况进行部分优化。如下的分代收集中的新生代收集策略,就是在复制基础上进行优化。
四、分代收集
分代收集并不是一种垃圾回收算法,他只是提供一种回收策略用于提高垃圾回收的效率,他的主要思想将堆区分为不同的区域,每个区域存储不同属性的对象,针对不同的区域特点使用不同的回收算法来进行回收。一般来说,会将堆区划分新生代,老年代,而方法区作为持久代,现已移除持久代,改为元空间。也就是说分代回收中有新生代,老年代,持久代三个概念。新生代采用复制-清除法
,老年代作为新生代的分配担保
,老年代
对象生命周期长且无分配担保,则使用 “标记-清除或者标记-整理”
。使用复制-清除的新生代回收方法如下:只浪费了 1/10
的内存空间。
总结
垃圾收集算法思想比较简单,具体的使用取决于 JVM 和垃圾收集器。Java9 已经放弃古老的组合收集器,包括并行,串行和 CMS,G1 收集器是主流收集器。
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于