对象的创建过程
步骤一:类加载检查
当虚拟机遇到一条new的指令时,首先将检查这个指令的参数是否能在class文件常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载,解析和初始化过。如果没有,那必须先执行相应的类加载过程。(new 指令对应到语言层面上讲是,new关键字,对象克隆,对象序列化)
步骤二分配内存
指针碰撞
假设 Java 堆中内存是绝对规整的,所有使用过的内存都被放在一边,没有使用过的内存放在了另外一边。中间放着一个指针用来表示他们的分界点。那所分配的内存仅仅是把那个指针向空闲的方向挪动一段与Java对象大小相等的距离,这种分配方式叫做**“指针碰撞”(Dump The Pointer)**
空闲列表
但是如果 Java 堆中内存并不是规整的,已经使用的内存块,和空闲的内存块相互交错在一起,那就没有办法简单的进行指针碰撞了,虚拟机必须维护一个可用内存区域列表。记录哪些内存块是可以使用的。在对象内存分配的时候就从列表中去找到一块足够大的内存空间划分给实例对象,并且更新列表上的记录。这种分配方式叫做“空闲列表”(Free List).
内存分配方式选择
什么时候使用指针碰撞,什么时候才用空闲列表?选择哪一种分配方式是由 Java 堆是否规整决定的,而 Java 堆是否规整又是由所采用的垃圾回收器是否有空间整理(Compact)的能力决定。
当使用 Serial 、ParNew 等带指针压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单,又高效。
当采用 CMS 基于清除(Sweep)算法的收集器时,理论上只能采用复杂的空闲列表来分配内存。
并发内存分配方案
对象频繁分配的过程中,即使只修改一个指针所指向的位置,但是在并发的情况下也不是线程安全的,可能出现正在给 A 对象分配内存,指针还没有来得及修改,对象 B 又同时使用原来的指针进行内分配的情况。解决这个问题有两种可选的方案:一种是对内存分配空间的动作进行同步处理-实际上虚拟机是采用CAS + 失败重试的方式来保证更新操作的原子性。另外一种就是把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thred Local Allocation Buffer, TLAB), 那个线程要分配内存,就在那个线程分配内存,就在那个线程的本地缓冲中分配,只有本地缓冲用完了,分配新的缓冲区时才需要同步锁定,虚拟机是否使用 TLAB,可以通过-XX:+/-UseTLAB参数设置。
步骤三初始化零值
内存分配完成后,虚拟机需要将新分配的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段可以在Java代码中可以不赋初始值就直接使用,程序能够访问这些实例字段的数据类型所对应的零值。
例如,如果一个类定义了一个int类型的实例字段,并且没有给它赋初值,那么在创建该类的新实例时,该字段的值就会被初始化为0。这是因为,Java虚拟机在为对象分配内存后,会将分配的内存空间清零,这就保证了实例字段的默认值都为0。在Java中,每种数据类型都有一种默认值,例如int类型的默认值为0,boolean类型的默认值为false,引用类型的默认值为null等。因此,程序能够访问这些实例字段的数据类型所对应的零值,也就是默认值。这些默认值通常是Java程序中常见的特殊值,因此程序可以直接使用它们,而不需要进行赋初值的操作。
步骤四设置对象头
初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头
步骤五执行init方法
执行完上面四个步骤后,从虚拟机的角度来看,一个新对象已经产生了,但是从Java程序的角度来看,对象的创建才刚刚开始,init()方法还没有执行,所有的字段都还是零值,所以,一般来说,执行完new指令后会接着执行init方法,将对象按照程序员的需求来进行初始化,这样一个真正可用的对象才算完全产生出来。
对象的内存布局
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。下图是普通对象实例与数组对象实例的数据结构:
1.对象头
对象头主要分为两类信息:
第一类是用于存储自身的运行时数据。如:哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有锁、偏向线程ID、偏向时间戳等,这部分数据长度在32位和64位的虚拟机中分别为32个比特和64个比特。官方称它为“Mark Word”。
对象需要存储的运行时数据其实很多,但对象头例的信息与对象本身定义的数据无额外的存储成本,所以Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间存储尽量多的数据,根据对象状态的不同,而改变存储结构。
例如对象在未被同步锁锁定的状况下,Mark Word的32个比特位中,25个比特用于存储对象的哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0 。其他状态如下图:
对象头的另一类数据是类型指针,即对象指向它的类型元数据的指针,java虚拟机通过这个指针来确认对象是那个类的实例。
此外,如果对象是一个数组,那么对象头中还需要有一块来记录数组的长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确认Java对象的大小,但是如果数组的长度不确定,就无法推断出数组的大小。
2.实例数据
实例数据部分是对象真正存储的有效信息,即我们在程序代码中定义的各种类型的字段内容,无论是从父类继承下来的还是子类中定义的字段都必须记录起来。至于这一部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。
3.对齐填充
该部分不是必然存在的,也没有特别的含义,仅仅起到占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小必须是8字节的倍数。因此如果对象的实例数据部分没有对齐的话,就需要通过对齐填充来完成。
对象访问定位
Java程序会通过栈上的reference数据来操作堆上的具体对象。由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义。这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种:
如果使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息,其结构如图
句柄访问
直接指针访问
如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销,如图
各自好处
句柄:使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改
指针:使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销
如何判断一个对象是否存活
判断对象是否存活的的算法包括:
引用计数算法
可达性分析算法
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器加1;当引用失效时,计数器值减1;任何时刻计数器为0的对象就是不能再被引用的。
引用计数算法(Reference Counting)
例如Object-C,Python语音使用引用计数算法进行内存管理。Java虚拟机没有选用引用计数器算法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。
对象循环引用代码示例:
public class TestCirculate {
TestCirculate testCirculate;
public TestCirculate() {
}
public static void main(String[] args) {
TestCirculate a = new TestCirculate();
TestCirculate b = new TestCirculate();
a.testCirculate = b;
b.testCirculate = a;
a = null;
b = null;
System.gc();
}
}
对象a和对象b都有字段testCirculate,赋值使得a.testCirculate = b,b.testCirculate = a;这样虽然最后a和b都指向了null但是由于他们互相引用着对方,导致他们的引用计数都不为0,于是引用计算算法无法通知gc收集器回收它们。
可达性分析算法
该算法基本思路就是通过一系列的称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。
可作为GC Roots的对象有哪些?
1、虚拟机栈(栈帧中的本地变量表)中引用的对象
2、本地方法栈中JNI(Native方法)引用的对象
3、方法区中类静态属性引用的对象
4、方法区中常量引用的对象
5、所有被同步锁(synchronized关键字)持有的对象。