一个对象在JVM中经历了什么?

4,832 阅读13分钟

这个标题让我想起来一个哲学问题:从哪里来,到哪里去。

那我们就通过这个哲学问题谈一谈:一个对象在JVM中经历了什么?

从哪里来?

我:对象从哪里来?

同事甲:呃,国家发的?

同事乙:充话费送的?

咳咳,我说的是JVM的对象

对于我们程序员来说,没有对象?不存在的!我直接创建一个!

所以这个问题很简单:对象都是创建出来的。

但是这个问题也很难:对象是怎么创建出来的?

这就像咱都知道咱都是咱妈生的,但咱是怎么……?

image-20230128162148994

那我们就先来探讨一下咱是怎么……? 对象是怎么创建出来的?

对象创建流程

想要创建对象,首先得找到它的类元信息,所以创建对象的第一步,就是类加载检查。

类加载检查

虚拟机遇到一条加载类指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

有没有一种字都认识,连在一起就不晓得啥意思的感觉?

没关系,我会出手

什么是加载类指令

常见的加载类指令有:

  • new关键字
  • Class.forName()
  • 初始化子类,父类未初始化时,先初始化父类
  • 虚拟器启动,初始化main()方法的类

加载类指令的参数就是指new User()User

什么是符号引用?

简单来说,符号引用就是字面量,比如User就是一个符号引用。

详细来说,符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。

现在再来看类加载的解释,就是当虚拟机遇到new User()时,首先会检查能否在常量池中定位到User, 并且检查User这个类是否被类加载过。

如果没有,那必须先执行相应的类加载过程,那么类加载过程是怎么样的?

我们先假设类已经加载过了

类加载检查通过之后,下一步就得给对象买房分配内存了。

如果没有通过自然会发生ClassNotFound异常

分配内存

分配内存第一个问题:我怎么知道对象要的房子内存有多大?三室一厅?

对象的结构

要回答这个问题,就必须先要知道对象的内存结构是怎样的?

对象的内存结构由三部分组成

  • 对象头
  • 实例数据
  • 对齐填充
对象头

对象头分为两部分:

  • Mark Word标记字段:标记对象的hashcode,分代年龄等,见下图。

    该部分在32位机器上占4字节,64位占8字节

  • Klass Pointer类型指针:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例

    该部分开启指针压缩占4字节,不开启占8字节

image-20230128170632708

实例数据

实例数据就是对象中的一些变量,基础类型该多大就多大,引用类型占4个字节(关闭指针压缩占8字节)

如int类型占4个字节,String, User,数组等就是引用类型。

对齐填充

当一个对象的对象头+实例数据所占的内存非8字节的倍数时,就会使用对齐填充的方式补上一些字节,让该对象所需内存达到8字节的倍数。

如该对象:

public static class B {
  //8B mark word 64位机器战8字节
  //4B Klass Pointer   如果关闭压缩-XX:-UseCompressedClassPointers或-XX:-UseCompressedOops,则占用8B
  int id;        //4B
  String name;   //4B  如果关闭压缩-XX:-UseCompressedOops,则占用8B
  // 8+4+4+4=20, 所以还需对齐填充4字节。
}

代码:github.com/lzj960515/P…

综上,其实对象所需的内存在类加载之后就已经确定了。

既然已经知道对象所需的内存了,那么又要怎么给对象划分内存呢?

划分内存的方式

划分内存有两种方式:指针碰撞和空闲列表

指针碰撞(默认)

如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。

由对象结构可知,对象的大小都是8字节的倍数,假设JVM的内存都是一格一格的,每格8字节,现在要为一个16字节的对象分配内存,则指针碰撞的方式可以用下图表示

image-20230129112151003

空闲列表

如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。

仍然是分配一个16字节的对象,用下图表示

image-20230129104042595

划分内存的方式有了,但是还有一个问题,业务系统都是多线程运行的,也就是对象内存的分配存在并发问题。

用指针碰撞的方式举例,A对象和B对象同时需要分配内存,很可能指针在分配A对象内存后已经到了新的位置,但是分配B对象时的指针还在原来的位置上,此时再移动指针,就出现了并发问题。

可以类比成多线程执行count++count会被重复累加的问题。

并发问题的解决方式

解决方式有两种:

  • CAS(compare and swap):虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。

    即:分配对象的内存时,先进行比较指针是否发生变化,未发生变化则进行更改指针的位置,比较和更改这两步操作是原子性的。如果发生变化,则使用新的指针位置,并重试该步骤。

  • TLAB(thread local allocation buffer):这是一种非常值得学习的思想,他的思想是:既然不同的线程分配对象内存是存在冲突,那我是否可以在创建线程时就事先划分好一大块区域,每个线程分配对象内存时只在自己区域里做操作,这样就避免了并发问题。

    这同样可以借鉴到业务开发中,在Java中,很多Util不是线程安全的, 比如SimpleDateFormat,一个笨方法是每次使用时都新new一个,如果借鉴了这个思想,那我们可以做一个Map出来,key为线程id,value为SimpleDateFormat对象实例,这样每个线程都有自己专属的SimpleDateFormat对象,就避免了并发问题。

初始化

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值,如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。

这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

比如你只写了int a但没赋值,JVM就给他赋个0,当然,你赋值了JVM也会先给他赋个0, 赋你写的值是在后面执行<init>方法。

设置对象头

关于对象头的信息在对象的结构部分已经详细说明

该步骤就是给对象头设置一些必要的信息:对象的哈希码、对象的GC分代年龄等,还有类型指针,这样在使用时才能知道这个对象是哪个类的实例。

执行<init>方法

给对象的属性赋值,即程序员赋的值。

以及执行构造方法。

到此,一个对象就算是创建出来了。

到哪里去?

对象从哪里来我们是知道了,那么对象到哪里去呢?

变为一抔黄土?

没错,对象最终也会嗝屁,对象是怎么嗝屁的?

运行时数据区

关于对象是怎么嗝屁的,那先得知道对象在哪里?

没错,通过对象创建的过程我们知道了对象需要分配在内存里。

那到底是分配到内存哪里呢?

JVM的组成主要有三部分:类加载子系统、字节码执行引擎、运行时数据区。

对象就在运行时数据区中。

而运行时数据区又分为五个部分,堆、方法区、虚拟机栈、本地方法栈、程序计数器

image-20230129181759286

存放了几乎所有的对象实例。

元空间

存储每个类的结构,例如运行时常量池,字段和方法数据,以及方法和构造函数的代码,包括用于类和实例初始化和接口初始化的特殊方法。

虚拟机栈

随着线程创建而同步创建的一块内存区域,在每个方法被执行时,都会创建一个栈帧。

栈帧包含:

  • 局部变量表
  • 操作数栈
  • 动态连接
  • 方法出口

每一个方法调用完毕,就对应着一个栈帧在虚拟机栈从入栈到出栈的过程

程序计数器

存储当前正在执行的字节码指令。

对象在内存中的分配方式

首先,讨论这个问题前,我们必须有一个共识:对象和对象是不一样的。

嗯,我说的是Java里的对象。

有一些对象存活时间长,甚至是应用运行了多长时间,它就存活了多长时间。比如类Class对象,Spring的Bean对象。

有一些对象存活时间短,刚创建就被销毁,比如业务对象。

针对不同的对象,当然要有不同的分配方式。

对象在栈上分配

是的,对象除了会在堆里,同样也会栈上分配。

先看以下代码:

private void alloc() {
  User user = new User();
  user.name = "a";
  user.age = 10;
}
class User{
  String name;
  int age;
}

请问,user对象什么时候会变成垃圾对象?

显然, 当alloc方法结束后,user对象就已经变成垃圾对象了。

所以user随着alloc方法的退出就已经可以被销毁了,没有必要等到gc。

另外,我们也知道,栈帧内的局部变量会随着栈帧的关闭而销毁。

所以,能不能把上面的代码改成这样:

private void alloc() {
  String name = "a";
  int age = 10;
}

这样不就可以让nameage随着栈帧关闭而销毁了。

没错,以上过程就是对象在栈上分配,该方式依赖于两个方面:

  • 逃逸分析:分析一个对象的作用域,是否不被外部方法所引用,只在本方法中使用。

    就是上面讨论的user是否可以随着alloc方法退出而销毁。

  • 标量替换: 通过逃逸分析确定一个对象不会被外部访问,JVM就不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替。

    就是上面将代码改造的过程。

    标量:不可被进一步分解的量, 如基础数据类型

    聚合量:可以被进一步分解的量, 如对象

对象在Eden区分配

大多数情况下,对象都是在年轻代中Eden区分配。

Eden区内存不够时,则触发YoungGc,将存活的对象放入Survivor区。

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象,关于如何定义大对象可以通过参数-XX:PretenureSizeThreshold=1m指定(这里设置的是1m),当对象的大小超过1m后,会直接进入老年代。

这样设计的原因

因为在业务中,通常大对象都是长期存活的对象,如大数组,静态变量。对于长期存活的对象,如果还是像普通对象一样在Eden区和Survivor区反复gc后才进入老年代,这是没有意义且耗费资源的事情,所以应当让这样的对象尽早进入老年代,提升gc效率。

注意,业务对象千万不要做成了大对象,因为它会直接进入老年代,导致频繁full gc

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

如果对象每并经过一次Young GC后仍然能够存活,则对象年龄+1,当年龄达到15(默认值)后就会进入老年代。

对于这一点,我们可以设置合理的阈值。

比如我明确知道业务中对象绝对不会超过3次gc就会被回收,那么反过来说,超过3次gc还没有被回收的对象,就是些可以长期存活的对象。

长期存活的对象应该让它尽早进入老年代,所以我保险一点,可以设置阈值为5。

流程图

对象内存分配

对象是如何销毁的?

随着程序的运行,当一些对象变成了垃圾对象,就会随着gc被回收内存。

那JVM要如何判断哪些对象是垃圾对象呢?

引用计数法

引用计数法是一种非常简单的实现:给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效,计数器就减1。

任何时候计数器为0的对象就是不可能再被使用的。

当它有一个致命的问题:循环引用。

如下代码:

public class ReferenceCountingGc {
	Object instance = nulL;
  public static void main(String[〕 args) {
    ReferenceCountingGc objA = new ReferenceCountingGc();
    ReferenceCountingGc obiB = new ReferenceCountingGc();
    obiA.instance = objB;
    obiB.instance = objA;
    objA=null;
    objB=null;
  }
}

除了对象objAobjB相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为0,于是引用计数算 法无法通知GC回收器回收他们。

可达性分析算法

GC Roots对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未标记的对象都是垃圾对象

GC Roots:线程栈的本地变量、静态变量、本地方法栈的变量、存活的线程等等

image-20230130174510364

当触发gc后,便会有垃圾收集器对这些可以回收的对象进行回收,对象也就被销毁了。

小结

本文从一个对象在JVM的经历出发,串讲了JVM中的一些知识点,如对象的结构是怎样的?JVM是如何给对象分配内存的,分配的方式又有哪些?

其中有部分内容由于篇幅所限,阿紫会另开章节单独介绍,如类加载机制是怎样的?垃圾收集器又有哪些?


如果我的文章对你有所帮助,还请帮忙点赞、收藏、转发一下,你的支持就是我更新的动力,非常感谢!

追更,想要了解更多精彩内容,欢迎关注公众号:程序员阿紫

个人博客网站:zijiancode.cn