前言
对于前面几篇文章, 主要就是说明了一个.java文件是如何一步步编译, 解析最后加载到JVM中运行的, 那么本篇文章将说明对象是如何创建的, 包括创建过程、对象头与指针压缩、jvm对象内存分配详解、逃逸分析,线上分配,标量替换等等内容。
内容有点多,所以准备分为三篇文章来写:
- JVM对象创建及对象大小与指针压缩
- 对象内存分配
- 对象内存回收
如果感觉文章中有的图片字太小不清楚的可以通过公众号加我,然后说明是哪篇文章的图片,然后我发给你。
对象的创建
对象创建的主要流程:
图片
1.类加载检查
虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。new指令对应到语言层面上讲是,new关键词、对象克隆、对象序列化等。
对于我们来说,我们写的java代码是new 一个对象,实际上对于底层jvm实际上是执行了一个new 指令。
这里用的插件是:jclasslib Bytecode Viewer
图片
首先会判断这个类有没有被加载过,如果没有加载过,那么它首先会执行加载类的过程(前几篇文章有讲),如果加载过了,那么就要开始new对象了,这个对象一般来说可能放在堆中也有可能放在栈里边,但是不管放在哪,前提都是需要分配一块内存空间的。
2.分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类 加载完成后便可完全确定,为对象分配空间的任务等同于把 一块确定大小的内存从Java堆中划分出来。
这个步骤有两个问题:
- 如何划分内存。
- 在并发情况下, 可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
划分内存的方法:
“指针碰撞”(Bump the Pointer)(默认用指针碰撞)
如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,比如下图中蓝色实线表示当前指针位置,虚线表示挪动后的位置,那所谓分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。
图片
“空闲列表”(Free List)
如果Java堆中的内存并不是规整的,已使用的内存和空 闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例, 并更新列表上的记录。
图片
但是具体使用的是指针碰撞的方式还是使用的是空闲列表的分配方式,取决于使用的什么垃圾回收算法,如果使用的是标记整理的话,那么最终剩余的内存肯定是第一种,那么使用的也就是指针碰撞的方式,如果使用的是标记清除的话,那么最终剩余的内存肯定是第二种,所以就使用空闲列表的方式来分配内存。
解决并发问题的方法:
不管使用哪种方式分配,都会出现并发问题,也就是两个线程同时创建了一个对象,然后争抢同一块内存
图片
多个线程创建了多个对象,但是内存空间只有一块,那么jvm为了解决这种并发问题,采取了以下两种措施
CAS(compare and swap)
虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。
CAS配上失败重试也就是线程A和线程B同时争抢这一块内存,如果线程A先争抢到了这块内存,那么线程B重新进行分配,发现这块内存分配给了线程A,然后就会在这块内存后面进行内存分配操作。这样线程A、B对象的内存空间就在并发的情况下被分配了。
本地线程分配缓冲(TLAB: Thread Local Allocation Buffer)
把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中(比如Eden区)预先分配一小块内存。通过-XX:+/-UseTLAB参数来设定虚拟机是否使用TLAB(JVM会默认开启-XX:+UseTLAB)
那么这个内存也不可能特别大,好像默认是Eden区的1% , 通过-XX:TLABSize 可以指定TLAB大小。如果这个时候放不下了,那么就会恢复CAS配上失败重试的方式进行分配。当然,一般不推荐你去改JVM默认的参数设置。
图片
线程A和线程B在Eden区预先分配一块属于自己的内存空间,然后把各自的对象放到各自的空间种。JDK8默认使用的就是这种方式。
对象的分配过程会在下一篇文章详细说明。
3.初始化零值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头), 如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
也就是对于对象的成员变量,比如int initData = 666;那么在这个过程,会先给initData 赋一个0值,就和前面有一篇文章中提到过静态变量的初始化赋值是一样的。最终可能有一步会把真正的值666赋给initData。
4.设置对象头
初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头Object Header之中。
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、 实例数据(Instance Data)和对齐填充(Padding)。HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时 间戳等。对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
32位对象头
图片
对象头中有一个Mark Word标记字段,第一列是对象的一个状态,可能有一些对象被加锁了或者是被GC标记了,不同的对象,它对象头的结构是不一样的,比如说一个对象是正常的对象,也就是没有任何的锁,对象头中前面25bit存储是对象的hashCode,中间4bit存储的是对象的分代年龄,分代年龄在上一篇文章中有讲过,它是4bit,也就以为着它的分代年龄是<=15的,因为4位(bit)大小可以表示从0到15的数值,因此无法存储大于15的数值,当然还有一些偏向锁、锁标志位等锁的标记。关于锁的相关内容也会在后面写并发相关的文章的时候进行详解。
还有一块就是Klass Pointer类型指针,一个对象new出来之后是放到堆中的,但是在这个对象的头部区域,有一个指针,指向方法区的对象所属的类的元数据信息。如下图中画红线的地方的示例。
图片
比如说就是下面这一段代码,要想在元数据区找到compute方法对应的代码,就是通过这个类型指针Klass Pointer去找。
图片
那么还有一个对象叫做类对象,比如Math类所属的对象mathClass,这个对象是放在堆中的。
图片
堆中的这个mathClass对象和元数据区的Math.class是什么关系呢?
Math.class是类的元数据信息,也就是我们编写的代码,那么mathClass是类装载完之后,是jvm给我们开发人员在我们想访问类的元数据信息是提供的一个对象,我们可以通过这个对象mathClass去访问类的元数据信息,简单一点就是反射,通过反射是可以获取到很多信息的,类的名称。方法的名称等等。但是mathClass对象中是不会存储这些代码的,代码只是存储在方法区。
这个是jvm提供给我们开发人员去使用的,但是jvm内部不会这么干,而是通过刚刚讲的类型指针。而元数据信息的存储介质是C++对象,这个类型指针也是C++实现的。
还有一块就是数组的长度,如果是一个数组对象的话,对象头中还有一块会存储数组的长度。
64位对象头
图片
对象头在hotspot的C++源码markOop.hpp文件里的注释如下:
// Bit-format of an object header (most significant first, big endian layout below):
//
// 32 bits:
// --------
// hash:25 ------------>| age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object)
// size:32 ------------------------------------------>| (CMS free block)
// PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
// 64 bits:
// --------
// unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
// PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
// size:64 ----------------------------------------------------->| (CMS free block)
//
// unused:25 hash:31 -->| cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && normal object)
// JavaThread*:54 epoch:2 cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && biased object)
// narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
// unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)
5.执行方法
执行方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。
这一步的话比如就会把initData赋值为666, 因为在初始化零值这个步骤中initData被赋值为0,这一步可以说是真正的进行赋值。也就是下图中框起来的部分,这个过程是C++调用的。
图片
对象大小与指针压缩
对象大小可以用jol-core包查看,引入依赖
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
以下这几行代码的话主要就是想查看new Object() 以及new int[]{}还有new A()对象的大小。
package com.liuxs.fusionx;
import org.openjdk.jol.info.ClassLayout;
/**
* @author: Liu Yuehui
* @ClassName: JOLSample
* @date: 2023/11/27 0:25
* @description: 查看对象大小
* @version:v1:2023/11/27 0:25:
**/
public class JOLSample {
public static void main(String[] args) {
ClassLayout layout = ClassLayout.parseInstance(new Object());
System.out.println(layout.toPrintable());
System.out.println();
ClassLayout layout1 = ClassLayout.parseInstance(new int[]{});
System.out.println(layout1.toPrintable());
System.out.println();
ClassLayout layout2 = ClassLayout.parseInstance(new A());
System.out.println(layout2.toPrintable());
}
// -XX:+UseCompressedOops 默认开启的压缩所有指针
// -XX:+UseCompressedClassPointers 默认开启的压缩对象头里的类型指针Klass Pointer
// Oops : Ordinary Object Pointers
public static class A {
//8B mark word
//4B Klass Pointer 如果关闭压缩-XX:-UseCompressedClassPointers或-XX:-UseCompressedOops,则占用8B
int id; //4B
String name; //4B 如果关闭压缩-XX:-UseCompressedOops,则占用8B
byte b; //1B
Object o; //4B 如果关闭压缩-XX:-UseCompressedOops,则占用8B
}
}
运行结果:
图片
Object对象大概是可以分为以下几块
图片
这里的类型指针只占了4个字节是因为64位系统默认是8字节,但是会涉及到指针压缩,压缩之后就是4字节。
这里有一个叫对象对齐,也就是上面说到对象头的第三块对齐填充(Padding),这块部分有的时候有,有的时候没有,也就是jvm内部会把内存的读取信息按照8个字节对齐,这个是整个jvm底层包括计算机组成原理经过大量实践证明的,也就是通过8个字节的对象的对齐,会让整个计算机的存取效率非常之高。
比如我这个操作系统是64位的,它的内存大概是一格一格的,比如下面这张图,一共就是64位,现在有个对象只占一点空间,你在查这个对象的时候,还要评估这个对象的大小,然后从这个大小的起始位置去偏移,这就比较麻烦了,那么8个字节的存取说白了就是对象寻址最优的一种方式。
图片
比如Object对象中,Mark Word标记字段和Klass pointer类型指针占了12字节,也就是这个Object对象真正的大小是12字节,但是为了满足对象对齐是8的整数倍,所以有搞了4个字节的对齐,这样就成了16字节,也就是8的2倍。让我们对象总共的大小是16字节。
图片
数组对象会多一个数组长度。
图片
A对象
其它内容一样,这里就不过多赘述了,这里的bate类型的b只占用了1字节,但是会有内部对齐,对齐成为了4字节,然后Object对象只占用了4字节,就是因为Object对象存储的是指针,只占了4个字节是因为64位系统默认是8字节,但是会涉及到指针压缩,压缩之后就是4字节。
图片
什么是Java对象的指针压缩?
现象
图片
对于上面查看对象大小的代码先在IDEA中设置一些jvm的参数。
XX:-UseCompressedOops禁止指针压缩
运行结果
图片
可以发现对象对齐没有了,但是多了一个对象头,也就是说会有两个4字节大小的位置来存储类型指针。包括A类中name和Object对象也都是8字节,这些对象都是放在堆中的,如果不开启指针压缩,会无形的增大很多空间,会导致整个堆的压力非常大,很容易就放满,然后GC...
1.jdk1.6 update14开始,在64bit操作系统中,JVM支持指针压缩。
2.jvm配置参数:UseCompressedOops,compressed--压缩、oop(ordinary object pointer)--对象指针。
3.启用指针压缩:-XX:+UseCompressedOops(默认开启),禁止指针压缩:-XX:-UseCompressedOops。
为什么要进行指针压缩?
1.在64位平台的HotSpot中使用32位指针(实际存储用64位),内存使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据,占用较大宽带,同时GC也会承受较大压力。
2.为了减少64位平台下内存的消耗,启用指针压缩功能。
3.在jvm中,32位地址最大支持4G内存(2的32次方),但是现在的机器基本都是64位的,也就是2的64次方,这绝对是一个非常大的数字,也就是64位能表述的内存非常大,可以通过对对象指针的存入堆内存时压缩编码、取出到cpu寄存器后解码方式进行优化(对象指针在堆中是32位,在寄存器中是35位,2的35次方=32G),使得jvm只用32位地址就可以支持更大的内存配置(小于等于32G)。
4.堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,即使用低虚拟地址空间。
5.堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,这就会出现1的问题,所以堆内存不要大于32G为好。
说的简单一点就是如果压缩了,只占用4个字节,如果没有压缩占用8个字节,是为了节约内存空间。