Java 中对象的生命周期

3,543 阅读10分钟

前言

  在真实的世界里,每个人都是一个对象,从出生到长大再到死亡是一个完整的生命周期。而在计算机的世界里,对象也会有它的生命周期,包括对象的创建、对象的内存布局、对象的访问和对象的销毁。C++ 中对象是这样,Java 中对象也是这样。只是在 C++ 里对象的生命周期完全由程序员掌控,包括创建、使用和回收。而在 Java 中程序员只需要负责创建和使用对象即可,回收全权交给 Java 虚拟机。创建对象易,销毁对象难。作为 Java 程序员,虽然虚拟机给我们做了对象回收,但不代表我们不用去理解一个完整的对象生命周期。只有理解了对象从生到死的过程,才能对对象爱恨情深。

对象的创建

1、创建对象的流程

  在程序员看来创建对象大部分情况下就是一个 new 关键字,而在虚拟机中远远不止这么简单。它至少包括如下几个阶段:

  • 加载该对象所在的类文件(就是编译后的.class 文件)进入内存
  • 在堆上分配一块跟类对象大小一样的内存
  • 将对象所在的内存值初始化为 0
  • 初始化对象头,包括、对象的哈希码、对象的GC分代年龄等信息
  • 调用类的 init 函数进行 Java 层面的对象初始化

  流程图如下:

2、分配对象内存的方式

  都知道 Java 生成对象需要分配内存,虚拟机又是怎样从内存池中分配一块内存给新创建的对象呢?虚拟机的实现中主要有以下两种方法:

  • 指针碰撞法

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

  • 空闲列表法:

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

  以上两种方法只考虑了对象内存的分配,其实还有内存的回收,也是虚拟机需要做的。内存的分配与回收在C/C++ 中讨论的比较多,对于 Java 对象创建来说这里不是重点,只需要知道对象内存分配有这两种方式就行,它们各有利弊。多说一句,对象内存分配和回收的设计,我只服 nginx,nginx 将内存资源管理这块玩的淋漓尽致,有兴趣的可以看看相关源码。

对象的布局

  Java 对象并不只是包含我们在 class 中所定义那部分实例数据,还需要包含虚拟机所需要的一些额外信息以及空填充,即对象头(Header)、实体数据(Instance Data)和对齐填充(Padding)三个部分,如下图所示:

  1. 对象头中的 Mark Word 在 32 和 64 位的虚拟机中大小为 32bit 和 64bit,类型指针同理,在对象头中字段特性如下:
  • Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。具体不同状态不同复用效果见下图
  • 类型指针指向该对象所属的类的 Class 对象,但该字段并不是在所有虚拟机中都是必须的。要看虚拟机如何实现对象的访问方式,这里下一节讨论。
  • 另外如果对象是一个数组,对象头中还需要保存数组长度的信息。这样虚拟机才能从对象元数据中确定对象的大小。
  1. 对象的实例数据才是用户可见的、可控制的,包括从所有父类(直到Object)中继承的,以及子类中定义的。需要注意的是所有的静态变量、局部变量和函数并不包含在对象内存的实例数据中:

静态变量和函数是属于类的,一个类只有一份,它在类加载的时候存进方法区。 函数作为类的元数据也存在方法区(同时会受到即时编译的影响)。 局部变量在方法执行时在栈中进行动态分配的,主要位置是局部变量表。

  1. 对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

  C++ 中有一本广为流传的书《深入理解 C++ 对象模型》专门讲 C++ 对象模型的,整整写成了一本书。可见相对于 C++ 对象,Java 要容易的多。C++ 中要计算一个对象的大小,直接使用 sizeof 即可。而在 Java 中却不能那么直接,如果想更具体的了解 Java 对象在内存中的大小,请看 一个java对象占多大内存 这篇文章,自己实践下。其实,只要知道了对象在内存中的组成(头、实例数据和填充)、实例数据中各个字段类型的大小,就能轻易算出对象的整体大小。

对象的访问

  就像创建自然世界的人主要目的就是活着,创建计算机世界的对象主要是为了使用它。Java 对象保存在堆上的,而它的引用主要保存在栈上,引用中存放的是对象在堆中的地址。这句话也对也不对,不对在后半句,引用中存放的是啥要看虚拟机访问对象方式的具体实现,主流的有以下两种:

  • 句柄:reference 中保存的是对象实例数据指针的指针,要通过两次寻址才能访问到对象实例,具体流程见下图:

  这种方案的好处是引用保存的是稳定的句柄地址,在对象在内存中被移动的时候,只需要修改句柄中的实例数据指针即可,无需修改 reference。

  • 直接指针:reference 中保存的是对象实例数据,与大部分人理解的一致。此时,对象的类型数据指针放到 Mark Word 中去(不理解的可以回看对象头的介绍)。具体的访问流程图如下:

  这种方案的好处是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。

  这下别人再问你 reference 中保存的是什么的时候,别急着回答说是对象的地址哦,也有可能是对象地址的地址。

对象的销毁

  这是对象生命周期中最复杂的部分,也是 Java 的精髓所在。在 C++ 中一个 new 出来的对象如果不再需要使用的时候,需要手动 DELETE 才行。而 Java 中靠虚拟机来完成回收,虚拟机 GC(Gargbage Collection)对象内存要解决如下两个问题:

何时回收?

   当一个对象还在被使用的时候显然是不能被回收的,只有死亡了的(不再使用的)对象才能进行内存回收,那如何判断对象是否不再使用呢?

  1. 引用计数器法
  • 原理:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减 1;任何时刻计数器为 0 的对象就是不可能再被使用的,如上图中的 Object A。
  • 优点:原理简单,使用简单,判定效率高。
  • 缺点:很难解决对象之间相互循环应用的额问题,除非禁止循环引用。
  • 实践:微软公司的 COM 技术,Python 语言等。
  1. 可达性判定法
  • 原理:通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的,如上图中的 Object5、Object6 和 Object7。
  • 优点:可以解决循环引用的问题,满足各种情况下的需求。
  • 缺点:实现复杂,遍历的效率低。
  • 实践:Java 的主流虚拟机。

如何回收

   找到了需要被回收的对象,如何进行回收也是个技术活。一方面它影响到虚拟机的回收性能,另一方面也会影响到对象内存的分配。所以回收算法很重要,主要有以下几种算法:

  • 标记-清除(Mark-Sweep)算法:分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
  • 复制(Copying)算法:它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
  • 标记-整理(Mark-Compact)算法:标记过程与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
  • 分代收集(Generation-Collection)算法:Collection)算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。。

  由于这些算法涉及的内容比较多,这里只抛出概念,后面还会整理一篇文章专门讲虚拟机的垃圾回收,这也是 Java 虚拟机中的重中之重,不想三言两语的带过,也不想在本篇文章大篇幅的介绍。

  说起来读者可能不信,在上家公司的一款上线产品(C++实现)中,我们的项目中几乎不允许使用 new(面向过程编程),就是怕一不小心导致内存泄露。真的是如履薄冰,不过我觉得这样做有点因噎废食了,完全没有体现 C++ 面向对象的优势。

总结

  C++ 下多线程网络库 muduo 的作者陈硕在它的《Linux多线程服务端编程》中提到的“创建对象很简单,销毁太难”,看了 Java 的对象生命周期管理才终于明白此言不虚。感谢伟大的 Java 发明者,把对象生命周期管理中最容易的部分给了使用者,把最难的部分留给了自己。