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

  • 跟我一起分析学习 JVM 内存模型
  • 学习分析 JVM 中的对象与垃圾回收机制(上)
  • 学习分析 JVM 中的对象与垃圾回收机制(下)
  • JVM 方面总结

建议按照顺序阅读

上一章学习了 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 又同时使用了原来的指针来分配内存的情况. 那么解决这个问题的方案有两种.

  • 对分配内存空间的动作进行同步处理, JVM 采用 CAS 机制加上失败重试的方式, 保证更新操作的原子性. CAS 前面有分析学习过. java 基础回顾 - 基于 CAS 实现原子操作的基本理解
  • 另一种是把内存分配的动作按照线程划分在不同的空间之中进行. 即每个线程在 Java 堆中预先分配一小块私有内存, 这小块私有内存被称为 本地线程分配缓冲 (Thread Local Allocation Buffer), 简称 TLAB.
    JVM 在线程初始化时, 同时也会申请一块指定大小的内存, 只给当前线程使用, 这样每个线程都单独拥有一个 Buffer, 如果需要分配内存, 就在自己的 Buffer 上分配, 这样就不存在竞争的情况, 可以大大提升分配效率. 当 Buffer 容量不够的时候, 再重新从 Eded 区申请一块继续使用.
    TLAB 的目的是在为新生对象分配内存空间时, 让每个 Java 应用线程能在使用自己专属的分配指针来分配空间, 减少同步开销.

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 实例中有两个字段, 就把这个实例认作它内部的两个字段以局部变量的形式分配在栈上.就是所谓的打散, 这个操作成为标量替换.

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

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

  • 开启逃逸分析 -XX:+DoEscapeAnalysis, JDK1.8 中默认开启
  • 开启标量替换 -XX:+EliminateAllocations.
    标量替换的作用是允许将对象根据属性打散后分配在栈中, 默认也为开启.
1.3 内存空间初始化

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

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

1.4 设置

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

1.5 对象初始化

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


 

2. 对象的内存布局

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


对象的内存布局图

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

  • 第一部分用于存储对象自身运行时的数据, 如哈希码,分代年龄, 锁状态标志, 线程持有的锁, 偏向线程 ID, 偏向时间戳等.
  • 另一部分是类型指针, 即对象指向它的类元数据的指针, 虚拟机通过这个指针来确定这个对象是哪个类的实例.

内存布局中的第三部分也就是对齐填充并不是必然存在的, 也没有什么特别的含义, 仅起到占位符的作用. 由于 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 的对象包含以下几种.

  • 虚拟机栈中引用的对象
    在程序中正常创建一个对象, 对象会在堆上开辟一块空间, 同时将这块空间的地址作为引用保存到虚拟机栈中, 如果对象声明周期结束了, 那么引用就会从虚拟机栈中出栈, 因此如果在虚拟机栈中有引用, 就说明这个对象还是有用的, 这是一种最常见的情况.

  • 在类中定义了全局的静态对象
    也就是使用了 static 关键字, 由于虚拟机栈是线程私有的, 所以这种对象的引用会保存在共有的方法区中, 显然将方法区中的静态引用作为 GC Roots 是必须的.

  • 常量引用
    使用了 static final 关键字, 由于这种引用初始化后不会修改, 所以方法区常量池里的引用的对象也作为了GC Roots

  • 本地方法栈中 JNI 引用的对象
    在使用 JNI 技术时, 有时候单纯的 Java 代码无法满足需求, 可能需要在 Java 中调用 C/C++ 代码, 因此会需要用到 Native 方法. JVM 内存中有专门有一块本地方法栈, 来保存这些对象的引用, 所以本地方法栈中引用的对象也会被作为 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 四大引用类型
  • StrongReference 强引用
    是使用最普遍的引用. 垃圾回收器绝对不会回收它, 内存不足时宁愿抛出 OOM 导致程序异常. 平常的new 对象就是强引用. 例 Object obj = new Object()

  • SoftReference 软引用
    垃圾回收器在内存充足时不会回收软引用对象, 但是在将要发生 OOM 时, 这些对象就会被回收. 适合用于创建缓存.

  • WeakReference 弱引用
    用弱引用关联的对象只能生存到下一次垃圾回收之前, 当 GC 发生时, 不管内存够不够, 都会被回收.

  • PhantomReference 虚引用
    也称为幽灵引用, 最弱, 随时都有可能被回收. 唯一的作用就是监控垃圾回收器是否正常工作..


 

5. 对象的分配策略

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

  • 对象优先在 Eden 区分配
  • 大对象直接进入老年代
  • 长期存活的对象进入到年代
  • 动态对象分代年龄判断
  • 空间分配担保
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 会回收老年代)

你可能感兴趣的:(学习分析 JVM 中的对象与垃圾回收机制(上))