Android开发Android开发Android技术知识

学习分析 JVM 中的对象与垃圾回收机制(上)

2021-03-30  本文已影响0人  __Y_Q

建议按照顺序阅读

上一章学习了 JVM 中的内存模型, 也就是运行时数据区的一些知识, 今天接着来继续学习 JVM 中对象与垃圾回收机制, 本章内容将围绕以下几点进行学习.

  1. 虚拟机中对象的创建过程.
  2. 对象的内存布局.
  3. 对象的访问定位.
  4. 对象的存活以及各种引用.
  5. 对象的分配策略.
  6. 垃圾回收算法与垃圾收集器.

其中每个大的方向又包含了几个别小的知识点. 那么现在开始从第一点对象的创建过程开始学习.

1. 虚拟机中对象的创建过程

先上一张图.


当我们在Java 程序中 new 一个普通对象时候, HotSpot 虚拟机是在经过了上面图中的五个步骤后才创建出的对象.现在开始对其一一进行分析 (类加载会在别的章节单独学习)
1.1 检查加载

当 JVM 遇到 new 指令时, 会先检查指令参数是否能在常量池中定位到一个类的符号引用.

1.2 分配内存

对象所需内存的大小在类加载完成后就可以确定. (JVM 可以通过普通 Java 对象的元数据信息确定对象大小)

为对象分配内存相当于把一块确定大小的内存从 Java 堆里划分出来

内存的分配方式分为两种: 指针碰撞与空闲列表. 其实很容易区别, 下面来说一下.

1.2.1 内存的分配方式 - 指针碰撞

如果 Java 堆是规整的, 也就是用过的内存放在一边, 空闲的内存放在另一边, 中间放着一个指针作为分界点的指示器, 那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离, 这种分配方式称之为 指针碰撞(Bump-the-Pointer). 类似下图.


规整的 Java 堆
1.2.2 内存的分配方式 - 空闲列表

如果 java 堆不是规整的, 也就是说用过的空间和空闲的内存相互交错, 那就没有办法简单地进行指针碰撞了. 虚拟机就必须维护一个列表, 记录上哪些内存块是可用的, 在分配的时候从列表中找到一块足够大的空间划分给对象实例, 并更新列表上的记录, 这种分配方式称为 空闲列表(Free Lis). 类似下图


空闲列表

选择哪种分配方式是由 Java 堆是否规整决定的, 而 Java 堆的是否规整又是由采用的垃圾收集器是否带有压缩功能决定的.
所以使用 Serial, ParNew 等待有 Compact 过程的收集器时, JVM 采用指针碰撞的方式分配内存既简单又高效. 而使用 CMS 这种基于 标记-清除 (Mark - Sweep) 算法的收集器时, 采用的是空闲列表方式.
关于垃圾收集器会在后面进行学习.

在分配内存时, 还需要考虑到线程安全问题.

1.2.3 内存的分配 - 线程安全问题

除如何划分可用空间之外, 还有另外一个需要考虑的问题就是对象创建在虚拟机中是非常频繁的行为, 即使是仅仅修改一个指针所指向的位置, 在并发情况下也并不是线程安全的, 可能出现正在给对象 A 分配内存, 指针还没来得及修改, 对象 B 又同时使用了原来的指针来分配内存的情况. 那么解决这个问题的方案有两种.

TLAB 只是让每个线程有私有的分配指针, 但底下存对象的内存空间还是给所有线程访问的, 只是其他线程无法在这个区域分配而已.
JVM中默认 开启了 TLAB 方案. 可以使用命令: -XX: +UseTLAB 开启, -XX: -UseTLAB 关闭.

1.2.4 内存的分配 - 栈上分配

在程序中, 其实有很多对象的作用域都不会逃逸出方法外, 也就是说该对象的生命周期会随着方法的调用开始而开始, 方法的调用结束而结束, 对于这种对象, 就可以可以考虑不需要分配在堆中.

因为一旦分配在堆中, 当方法调用结束, 没有了引用指向该对象, 该对象就需要被 GC 回收, 而如果存在大量的这种情况, 其实对 GC 来说也是一种负担. 因此, JVM 提供了一种叫栈上分配的概念, 针对那些作用域不会逃逸出方法的对象, 在分配内存时不再将对象实例分配到堆中, 而是将对象属性打散后分配在栈中, 这样就会随着方法的调用结束而回收掉, 不再给 GC 增加额外的无用负担, 从而提高整体的性能.

代码示例

public static void main (String[] args) {
  User user = new User();
}

方法中 User 的引用, 就是方法的局部变量, 需要的就是将这个实例打散, 比如 User 实例中有两个字段, 就把这个实例认作它内部的两个字段以局部变量的形式分配在栈上.就是所谓的打散, 这个操作成为标量替换.

栈上分配是通过逃逸分析技术实现的.

栈上分配需要有一定的前提,

1.3 内存空间初始化

对象内存分配完后, 虚拟机需要将分配到的内存空间都初始化零值.(如 int 值为 0, boolean 值为 false 等.), 但不包括对象头.
这一步操作保证了对象的实例字段在 Java 代码中可以不赋值就直接使用, 程序能访问到这些字段的数据类型所对应的零值.

如果使用了 TLAB, 则这一步操作将提前至分配 TLAB 时.

1.4 设置

接下来, 虚拟机要对对象进行一些必要的设置, 例如这个对象是哪个类的实例, 对象头信息, 包括类元数据引用, 对象的哈希码, 对象的GC分代年龄等.

1.5 对象初始化

在上面的工作都完成之后, 从虚拟机的视角来看, 一个新的对象已经产生了, 但从 Java 程序的视角来看, 对象创建才刚刚开始, 所有的字段都还是零值. 所以一般来说执行 new 指令之后会接着把对象按照程序员的意愿进行初始化(执行构造方法), 这样一个真正可用的对象才算是完全产生出来.


 

2. 对象的内存布局

在 HotSpot 虚拟机中, 对象在内存中存储的布局可分为 3 块区域, 对象头, 实例数据, 对齐填充. 如下图所示


对象的内存布局图

在 HotSpot 虚拟机中, 对象在内存中存储的布局可分为三块区域: 对象头, 实例数据, 和对齐填充.
对象头包括两部分信息:

内存布局中的第三部分也就是对齐填充并不是必然存在的, 也没有什么特别的含义, 仅起到占位符的作用. 由于 HotSpot VM 自动内存管理系统要求对象的大小必须是 8 字节的整数倍. 当对象其他数据部分没有对齐时, 就需要通过对齐填充来补全.

比如 对象头 + 实例数据 是 30 个字节, 那么就会再填充2 个字节.


 

3. 对象的访问定位

建立对象是为了使用对象, Java 程序需要通过找到栈上的引用 (reference) 来操作堆上的具体对象. reference 类型在 Java 虚拟机规范中只规定了一个指向对象的引用, 对于如何通过引用定位到对象没有做出具体的规定. 对象的访问方式取决于虚拟机的实现, 目前主流的访问方式有两种句柄和直接指针, 下来来看这两种方式的区别.

3.1 句柄

如果使用句柄访问的话, 那么 Java 堆中将会划分出一块内存来作为句柄池, reference 中存储的就是对象的句柄地址, 而句柄中包含了对象实例数据与类型数据各自的具体地址信息. 如下图所示

3.2 直接指针

如果使用直接指针访问, 那么 reference 中存储的直接就是对象地址, 如下图

3.3 两者对比

使用句柄来访问对象最大的好处就是 reference 中存储的是稳定的句柄地址. 当对象移动时(垃圾回收时移动对象是非常普遍的行为)也只会改变句柄的实例数据指针, reference 本身不需要修改.

使用指针来访问对象的最大好处就是速度更快, 它相比句柄的方式节省了一次指针定位的时间开销, 由于对象的访问是非常频繁的. HotSpot 虚拟机使用的就是直接指针的方式来进行访问对象.


 

4. 对象的存活/死亡

在堆中几乎存放了所有的对象实例, 而垃圾回收器在对其进行回收前, 要做的事情就是要确定这些对象中那些还存活着, 判断对象的存活也有两种方式.引用计数算法 与 可达性分析算法.

4.1 引用计数算法

在对象中添加一个引用计数器, 每当有一个地方引用它, 计数器就加一, 当引用失效时, 计数器减一 . 当计数器不为 0 时, 判断该对象存活. 否则判断为死亡(计数器 = 0).

相互引用即如下代码所示

class A {
   private B b;
   public void setB(B b) {
       this.b = b;
   }
}

class B {
   private A a = new A();
   public void setA(A a) {
       this.a = a;
   }
}

public void method() {
   A a = new A();
   B b = new B();
  a.setB(b);
   b.setA(a);
}

method 方法中, 执行完两个 set 后, method 方法结束后, A 与 B 两个对象内部相互引用还会依然存在.

4.2 可达性分析算法

很多主流商用语言都采用引用链法判断对象是否存活, 大致思路就是将一系列的 GC Roots 对象作为起点, 从这些起点开始向下搜索, 搜索所走过的路径成为引用链. 当一个对象到 GC Roots 没有任何引用链相连时, 则证明此对象是不可用的. 在 Java 语言中, 可作为 GC Roots 的对象包含以下几种.

判断一个对象到 GC Roots 没有任何引用链相连时, 则判断该对象不可达.

上面说的回收的都是对象, 那么类可以进行回收吗?
可以的, 但是 Class 要被回收的话, 条件非常苛刻, 必须同时满足以下的条件.

  • 该类所有的实例都已经被回收, 也就是堆中不存在该类的任何实例
  • 加载该类的 ClassLoader 已经被回收
  • 该类的 java.lang.class 对象没有任何地方被引用. 无法通过反射访问该类的方法.
  • 参数控制.-Xnoclassgs 禁用类的垃圾回收. 也不能开启, 一旦使用这个, 会始终被认为是活动的 .

注意: 可达性分析仅仅只是判断对象是否可达, 但是还不足以判断对象是否死亡.

4.3 Finalize

即使通过可达性分析判断不可达的对象, 也不是 "非死不可", 还会处于一个 "缓刑" 的阶段, 真正要宣告一个对象死亡, 还需要经过两次标记的过程, 一次是没有找到 GC Roots 引用链, 它将被第一次标记. 随后经历一次筛选 (筛选条件: 如果没有实现 Finalize 方法 或者已经调用过 Finalize 方法), 则直接认定为死亡, 等待被回收. 如果有实现这个方法, 就会先将这个对象放在一个队列中, 并由虚拟机建立的一个低优先级的线程去执行它, 随后就会进行第二次标记, 如果对象在这个方法中重新与 GC Roots 建立关系链, 那么二次标记时就会将这个对象移出即将回收的集合, 如果二次标记时没有重新建立关系链, 那么也被认定为死亡, 等待被回收.

Object 类中有个方法 Finalize 方法, 虚拟机只会触发这个方法, 并不承诺等待它执行结束, 这样做的原因就是如果一个对象在执行 Finalize方法时执行缓慢, 或者发生了死循环, 将可能导致 F-Queue 队列中的其他对象永久处于等待状态, 甚至导致整个内存回收系统崩溃. 尽量避免使用这个方法, 因为无法掌控这个时间.

4.4 四大引用类型

 

5. 对象的分配策略

其实在为对象在堆中分配空间的时候, 需要遵从以下原则.

5.1 对象优先在 Eden 区分配

如果经过逃逸分析, 发现对象无法在栈中分配, 那么就还是需要在堆中进行分配, 首先会优先将新生对象分配在堆中的 Eden 区. Eden 区内存不足就会触发 MinorGC 清理 Eden 区. 在这个区域(新生代)的对象都是朝生夕死, 是对象最频繁发生变动的区域

5.2 大对象直接进入老年代

什么是大对象? 需要大量连续空间的对象, 如长字符串, 大数组等, 会直接在老年代分配内存, 这样做的目的是避免在Eden区, from 和 to 区之间发生大量的内存拷贝.

新生代采用复制算法, 新生对象会被优先分配到 Eden 区, 当这些对象经历过一次 Minor GC 后, 如果仍然存活就会移动到 from 区. 此时 to 区是空的.
下一次再发生 Minor GC 后, 会将 Eden 区与 from 区所有垃圾对象清除, 并将存活的对象复制至 to 区. 此时 from 区为空.
每复制一次, 对象头中的分代年龄都加一. 如此反复在 from 与 to 区之间切换的次数超过了默认的最大分代年龄后, 依然存活的对象将会被移动到老年代中.

5.3 长期存活的对象进入到老年代

在 Eden 区,from 和 to 区的对象, 每移动一次, 对象的分代年龄就会加 1, 当达到阈值 15 时, 对象就会从年轻代移动到老年代.



上图是对象头中存储的分代年龄, 存储是按 byte 位存储, 无论是 32 位还是 64 位虚拟机存放的都是4 位, 那么分代年龄能够记录的最大值也就是 1111, 转为 10 进制为 15. 也就是说分代年龄最大值为 15, 在新生代中通过复制移动超过 15 次后, 就会认为是需要长期存活的对象, 被移动到老年代. 移动到老年代后, 对象头中的分代年龄失效.

也可以设置分代年龄的最大阈值: -XX:MaxTenuringThreshold= 阈值 (CMS 垃圾收集器的默认值为 6).

5.4 动态对象分代年龄判断

虚拟机并不是永远的要求分代年龄必须达到阈值后才能晋升老年代, 如果 from 区和 to 区中相同年龄的所有对象大小的总和大于 from 区和 to 区空间的一半, 年龄大于或等于该年龄的对象可直接进入到老年代.

5.5 空间分配担保

当垃圾收集器准备要在新生代发起一次 Minor GC 时, 首先会检查老年代中最大的连续空间区域的大小是否 大于 新生代中所有对象的大小 或者 历次晋升的平均大小. 如果大于就会进行 Minor GC, 否则进行 Major GC (Major GC 会回收老年代)

上一篇 下一篇

猜你喜欢

热点阅读