Java对象的生命周期

456 阅读7分钟

前言

有出生那么肯定就有死亡, 在 java中, 对象的诞生是我们开发人员 new出来的, 对象的使用也是我们开发人员进行操作的, 但是对象的创建你了解过吗? 接下来就让我们一起去揭开对象生命周期的神秘面纱

1. 对象的创建流程

快速入门之简单讲讲, 本节也按照创建流程来展开讲解

  • 首先我们开始 new一个对象
  • 进行常量池检查
    • 看能否在常量池中定位到这个类的符号引用, 定位不到则加载类
    • 看是否加载过这个类, 没加载过则加载类
  • 分配内存空间
    • 指针碰撞: GC不带压缩功能, Serial和ParNew
    • 空闲列表: GC带压缩功能, CMS
  • 内存空间初始化为零值: 保证了对象的实例字段在 java代码中科院不赋初始值就直接使用, 程序能访问到这些字段的数据类型所对应的零值
  • 必要信息设置
    • 对象类的元数据
    • 对象的哈希码
    • GC分代年龄 -> 对象头
  • init()

image.png

类的加载可以看这篇文章: JVM之类加载器 - 掘金 (juejin.cn)

2 对象的内存分配方式

内存的方法有两种

  • 指针碰撞
    • 假设 Java堆中的内存是绝对规整的, 所有用过的内存放一边, 未使用内存放另一边, 中间边界线就可以类比为指针, 内存分配就是把指针向未分配的区域挪一段与对象大小相等的距离, 这就是指针碰撞

image.png

  • 空闲列表
    • 如果 Java堆中的内存不是很规整的, 已使用和未使用的内存就会相互交错, 这个时候就要维护一个列表来记录所有已使用和未使用的内存块, 在分配内存时从列表找到一块足够大的空间划分给对象实例, 并更新内存列表

image.png

分配方法说明收集器
指针碰撞内存地址是连续的(新生代)Serial和 ParNew收集器
空闲列表内存地址不连续(老年代)CMS收集器和 Mark-Sweep收集器

2.1 内存分配的安全问题

通过上一小节我们知道了对象的内存分配方式, 但是我们想这样一个场景: 线程A 去给对象分配内存的过程中, 此时指针未修改, 线程B 也去请求了同一块内存地址, 这个时候就出现了内存抢占, 也就是线程安全问题

对于这种问题, 在JVM中有两种解决办法:

  • CAS: CAS是乐观锁的一种实现方式, 虚拟机采用 CAS配合失败重试的机制来保证操作的原子性
  • TLAB本地线程分配缓冲: 为每一个线程预先分配一块内存, 在给对象分配内训时直接在自己这块私有内幕才能中进行分配, 当新的对象大于剩余内存或者内存耗尽之后, 在分配新的内存

因为内存分配这是一个高并发的操作, CAS就显得效率低下了

可以通过设置-XX:+/-UseTLAB 参数来指定是否开启TLAB分配(默认启动)

2.2 对象进入老年代

  • 新生代对象: 新生代对象大多数默认会进入到Eden
  • 对象进入老年代的四种方式:
    • 存活年龄太大, 超过阈值之后会转入到老年区(默认15, 参数: -XX:MaxTenuringThreshold=15)
    • Hotspot遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了survivor区的一半时,取这个年龄和MaxTenuringThreshold中更小的一个值,作为新的晋升年龄阈值
    • 大对象直接进入老年代 Serial和 ParNew收集器
      • 例如超长的字符串和数组
    • GC后, Survivor区放不下所有的对象

2.3 内存担保机制

当新生代无法分配内存时, 我们想把新生代的老对象转移到老年代, 然后把新对象放到腾空的新生代, 这种机制我们称之为内存担保

  • GC之前, 判断老年代最大可用连续内存是否大于新生代所有对象总大小
    • 大于: GC是安全的
    • 不大于: 查看HandlePromotionFailure设置值是否允许担保失败
      • 允许: 检查老年代最大可用连续空间是否大于历次晋升老年代对象的平均大小
        • 大于: 尝试进行一次 Minor GC(此次GC有风险)
        • 不大于: 进行 Full GC
      • 不允许: 进行 Full GC

image.png

3 对象的内存布局

在堆内存中, 一个对象的的存储布局可以分为三个区域:

  • 对象头: 对象头分为两部分
    • 存储对象自身的运行时数据
      • 哈希吗
      • GC分代年龄
      • 锁状态标准
      • ...
    • 类型指针: 类元素局的指针, 虚拟机通过这个指针来确定这个对象是哪个类的实例
  • 实例数据: 存储对象真正的有效信息, 例如: 非静态变量也会存入堆空间
  • 对齐填充: 不是必然存在的, 也没有特别的含义. JVM内对象都采用8byte对齐, 不够8byte整数倍的就需要通过对齐填充来补全

3.1 对象头

  • 对象头Header:Java对象头占8byte。如果是数组则占12byte。在 JVM中数据需要数组长度size来记录数组长度, 占用4byte
  • 标记字段Mark Word
    • 用于存储对象自身的运行时数据,它是synchronized实现轻量级锁和偏向锁的关键。
    • 默认存储:对象HashCode、GC分代年龄、锁状态等等信息。
    • 为了节省空间,也会随着锁标志位的变化,存储数据发生变化。下面画图解释
  • 类型指针KlassPoint
    • 是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
    • 开启指针压缩存储空间4byte,不开启8byte。
    • JDK1.6+默认开启
  • 数组长度(只有数组对象才有):如果对象是数组,则记录数组长度,占4个byte,如果对象不是数组则不存在。
  • 对齐填充:保证数组的大小永远是8byte的整数倍。

3.2 对象头大小

对象头信息是与对象自身定义的数据无关的额外存储成本。考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构,以便在极小的空间内,尽量多的存储数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化,变化状态如下(JDK1.8)。

image.png

基本数据类型和包装类的内存占用情况:

image.png

3.3 对象头总结图示

image.png 图片来自于5分钟,带你理解Java对象的内存布局

4 如何访问一个对象

对象的访问方式由虚拟机俩决定, 目前主流的访问方式有以下两种

  • 句柄
    • 使用句柄的话, java堆中会专门划分出一块内存来作为句柄池, reference中存储对象句柄的地址, 句柄中包含了对象实例数据与对象类型数据各自的具体地址信息
  • 直接指针: 访问速度快, 节省了一次指针定位的开销
    • 直接访问, reference中存储的就是对象的地址, 节省了一次指针定位的开销



本文内容到此结束了

如有收获欢迎点赞👍收藏💖关注✔️,您的鼓励是我最大的动力。

如有错误❌疑问💬欢迎各位大佬指出。

我是 宁轩 , 我们下次再见