1 什么是Java对象

Java是一门面向对象语言,对象是其最基本的一个组件,在Java程序运行过程中无时无刻不在创建对象、销毁对象,有些对象的生命周期非常短暂(例如在方法内部创建的对象,一般在方法调用之后不长的时间内会被回收销毁),有些对象的生命周期非常漫长(例如HashMap等常驻内存的组件)。

那究竟什么是对象?我们先来举个非常简单的例子:自然界中有很多动植物,人类来是动物的一种,人来又可以分做男人、女人。这里人类可以类比做Java里的一个类,男人、女人都其子类。假设张三是女人,李四是男人,我们把张三称作一个对象,更加准确的说法是张三是女人的一个实例对象,李四是男人的一个实例对象,用Java语言描述如下:

public class Person {
// some fields and methods
}
public class Man extends Person {
// some fields and methods
}
public class Woman extends Person {
// some fields and methods;
}
public class Main {
public static void main(String[] args) {
Woman zhangsan = new Woman();
Man lisi = new Man();
}
}

虽然上面这个例子有点粗糙,但我想应该足以说明对象是个什么东西了。

2 Java对象的生命周期

Java程序在运行的过程中会频繁的创建对象、使用对象、销毁对象。所以,我们将Java对象的生命周期简单粗略的分为三个部分:

Java对象的创建

Java对象的使用

Java对象的销毁

2.1 Java对象的创建

我们知道,在编写Java代码的时候,创建对象是一件很简单的事,只需要调用new关键字即可,如下所示:

User user = new User(); //1
Cart cart = new Cart(); //2

但在虚拟机中,并不那么简单。虚拟机执行字节码时遇到new指令,首先会检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析、初始化过。如果没有,那么就必须先执行类加载过程。在类加载检查通过之后,虚拟机将会为新对象分配内存,对象所需的内存大小在类加载完成后就可以确定了,这里分配的内存绝大多数情况下是堆内存,少数情况下可能会分配在栈上(这是一种栈上分配的新技术)。内存分配完成之后,虚拟机将分配的这块内存都初始化为“零值”,接下来就是对对象做一些必要的设置,例如对象的hashcode,GC分代年龄,类指针等,这些信息都存储在对象头中(关于对象头,我们会在后面讲到)。

在上面都完事之后,从虚拟机的角度来看,对象的创建就已经完毕了,但从Java程序的角度来看,一个对象才刚刚开始创建,初始化方法还没有执行,方法包括初始化代码块和构造器。所以,之后一般会执行方法并按照程序员写的想法做初始化,这样就得到一个完整的Java对象了。

上面的描述中有提到两个术语,在此解释一下:

符号引用。符号引用存储是在方法区里的用来描述类的字符串。它只是一个字符串,并不是Java语言里的那种引用,Java里的引用我们称作直接引用。所以,他的作用仅仅在用来描述类,便于类加载系统能找到其代表的类。

零值。零值不是数字0,对于数字类型来说,确实是0,对于布尔类型来说是false,字符串类型就是空字符串。

这里只是简单的描述了一下Java对象的创建过程,其实实际的过程会更加复杂、繁琐。

2.2 对象的使用

对象的使用就非常简单了,我们在创建了一个新的对象后,一般会赋值给一个引用,之后的对对象的操作都是在此引用的基础上操作,例如:

User user = new User(); //赋值给一个User类型的引用

user.setName("yeonon"); //使用该引用进行操作,例如调用方法等

就Java语言层面来看,使用确实就那么简单,但在虚拟机的角度来看,在使用对象的时候还需要定位到对象,否则将无法操作对象。

至于如何定位到对象,Java虚拟机中没有做出规定,所以这个功能是虚拟机自行实现的,主流的访问方式有两种,一种是使用句柄,另一种是直接指针。

使用句柄。句柄可以理解为一种介于对象和引用中间的中间件,引用不直接指向对象,而是指向句柄,句柄又有两个指针,一个指向对象的地址,一个指向对象的类信息。如下图所示:

使用直接指针。这种方式就是引用直接指向对象,即引用指向的就是对象实例,如下图所示:

这两种方式各有优劣,句柄的优势是当对象被移动的时候,不需要修改引用的指向,只需改动句柄对象实例的指针指向即可,劣势是比直接指针的方式多了一次定位的过程,可能效率会受到影响。直接指针的优势是不需要二次,效率较高,劣势是当对象位置发生改变的时候就不得不修改引用的指向。

就HotSpot虚拟机而言,它是直接指针的方式,也许HotSpot团队认为对象地址改变的情况比较少,采用直接指针的效率会比较高。

地址改变的情况主要是因为垃圾回收,如果垃圾回收采用的是标记整理,那么对象在内存中的位置会发生改变,且只有存活的对象才会这样。实际上,很多对象都是朝生夕死的,所以存活的对象不会很多,故发生内存地址改变的对象也就不会很多。

2.3 对象的销毁

Java是有自动内存管理机制的,所以我们不必太担心一个对象是否被销毁,不过理解这个销毁过程有助于在一些特殊情况下出现的内存泄露、内存污染等问题。

当虚拟机的垃圾回收系统认为某个对象是无用的时候,就会尝试去回收该对象所占用的内存,这就使得该对象被销毁掉了,最终使得该对象原先占用的内存再次变得可用的。关于垃圾回收的更多细节,我会在下一篇文章中说到,这里就不多说了。

3 对象的内存布局

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3个区域:

对象头

实例数据

对象填充

3.1 对象头

对象头包括两部分信息,第一部分用于保存对象运行时数据,例如hashcode、GC分代年龄、锁状态标志、偏向锁ID、偏向锁时间戳等,这部分信息占用的内存是32位(32位虚拟机)或者64位(64位虚拟机),官方称作“Mark Word”。但无论是32位还是64位都很难完全存储这些信息,所以在HostSpot虚拟机中,采用来复用的技术来实现仅仅使用少部分内存就能存储必要的数据信息,详细的可以看下图:

另一部分是类型指针,该指针指向该对象所属的类的元数据,虚拟机通过这个指针可以快速的确定该对象是哪个类的实例,如果对象是数组类型,那么在对象头中还会有一个用于表示数组长度的数据。

3.2 实例数据

实例数据部分就是存储对象的有效信息的,即在代码中定义的各种字段的内容,无论是从父类继承下来的,还是在子类中定义的,都需要存储。不过其顺序并不一定就是代码中定义的顺序,HostSpot虚拟机的策略是根据类型宽度来存储,例如double和long类型存储在一块儿,short和char存储在一块儿,在满足这个条件的前提下,父类中定义的变量会出现在子类定义的变量之前,即假设父类定义了一个double a = 3.0,子类定义了一个long b = 2,那么a和b会被存储在一起且a在b之前。

3.3 对象填充

对象填充不是必须的,仅仅是起到占位符的作用,因为HotSpot虚拟机的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,但我们对象并不总是规整的8字节的整数倍,这会导致下一个对象的起始地址不是8字节的整数倍,所以需要一些占位符来占据后面的内存,使得整个对象变得规整。

4 oop-klass模型

我们已经知道什么是Java对象,以及Java对象的生命周期和对象的内存布局,但还没有说到虚拟机内部是如何表示对象的,接下来我们就来一起讨论讨论。

HotSpot虚拟机是基于C++实现的,C++也是一门面向对象的语言,本身也拥有面向对象的特性,所以表示Java对象的最简单的方式就是:一个Java类对应一个C++类。但HotSpot没有这么做,而是设计了oop-klass模型来描述对象,这么做的原因是HotSpot不想让每个对象都包含一张虚函数表。

oop-klass包含两个部分,即oop和klass。

oop。不是面向对象编程的意思,而是普通对象指针(ordinary objects pointer),它用来描述对象的实例信息。

klass。读音同class,据说之所以叫这个名字是仅仅是为了和C++的class关键字做区分。它用来描述类的元信息。

4.1 oop体系

上图是oop体系的类图。可以看到,oopDesc是基类,其有多个子类,例如instanceOopDesc,arrayOopDesc,methodOopDesc等,从名字不难看出,instanceOopDesc就是用来描述实例的,arrayOopDesc是用来描述数组的,其他的也同理。

所以,当我们使用new创建一个Java对象的时候,就会创建一个instanceOopDesc对象,创建一个数组对象的时候就会创建一个arrayOopDesc对象。

oopDesc类的代码如下所示:

class oopDesc {
friend class VMStructs;
private:
volatile markOop _mark;
union _metadata {
wideKlassOop _klass;
narrowOop _compressed_klass;
} _metadata;
private:
// field addresses in oop
void* field_base(int offset) const;
jbyte* byte_field_addr(int offset) const;
jchar* char_field_addr(int offset) const;
jboolean* bool_field_addr(int offset) const;
jint* int_field_addr(int offset) const;
jshort* short_field_addr(int offset) const;
jlong* long_field_addr(int offset) const;
jfloat* float_field_addr(int offset) const;
jdouble* double_field_addr(int offset) const;
address* address_field_addr(int offset) const;
}
class instanceOopDesc : public oopDesc {
}
class arrayOopDesc : public oopDesc {
}

可以看到,该类有_mark和_metadata和其他表示java中各种数据类型的成员变量。比较重要的是_mark和_metadata这两个成员变量。

_mark。保存GC分代年龄、锁标记等信息,即Java对象头中的第一部分。

_metadata。保存着该对象所属类的指针,即Java对象头中的第二部分,它是一个结构体,里面包含一个普通指针和一个压缩指针(从名字就能判断)。

4.2 klass体系

上图就是klass的继承体系。

klass的作用就是用来表示Java类,即一个Klass对象和一个Java类对应,当Java类加载系统加载类的时候,虚拟机内部会为该类创建一个instanceKlass对象用来描述这个类。一个instanceKlass对象中包含了一个Java类的所有信息,包括字段信息,方法信息,静态成员的信息等。

oop-klass模型其实就是把对象实例数据和对象所属类的元信息分离了。oop仅仅用于表示实例数据,所以oopDesc不包含任何虚方法,编译完成后也就不会有虚函数表了,klass为了实现多态,所以就有虚函数表。如果不做这样的分类,统一使用一个类的话,那么会导致每个对象都包含虚函数表,这就解释了本节开头的描述。

4.3 存储结构

从上面的讨论,我们知道了oop用来表示对象的实例数据,故可以理解为一个OopDesc对象对应着一个Java对象,klass用来描述类的元信息,所以一个klass对象对应着一个Java类。

下图清晰的表达了它们之间的关系以及在内存中的位置:

5 小结

本文从什么是对象开始,到介绍对象的生命周期,对象在内存中的布局以及更加深层次的oop-klass模型。其中oop-klass模型会有点难以理解,因为这涉及到C++的一些知识,如果不熟悉C++的话(比如我),可能对这一块比较懵。

6 参考资料