问题列表:
1.请解释一下对象的创建过程
2.Class对象在堆还是方法区中
3.对象在内存中的存储布局
4.对象头具体包括什么
5.对象怎么定位
6.对象怎么分配
7.Object object = new Object(); 在内存中占用多少字节
本文下述内容若无特殊说明,指的都是JDK8 HotSpot实现的64位虚拟机。
基于分代收集的堆内存布局
上图是基于分代收集的堆才会出现的,举个栗子,G1(整堆收集)中内存布局就不是那样。这些区域划分仅仅是一部分垃圾收集器的共同特性或者说设计风格而已,而非某个Java虚拟机具体实现的固有内存布局,更不是《Java虚拟机规范》里对Java堆的进一步细致划分。

实例数据就是这个对象内部的成员变量。
问题3总结:在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
问题4总结:对象头又可以分为mark word和类型指针。HotSpot虚拟机Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特。对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的最大限度,但对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效 率,Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。


引入依赖
<dependency>
<groupId>org.openjdk.jolgroupId>
<artifactId>jol-coreartifactId>
<version>0.16version>
dependency>
此处引入的版本号太低了,改为0.8,后续实验在0.16的基础上进行,执行完了才发现版本号过低。
下载插件

public class SynchronizedTest2 {
public static void main(String[] args) {
Object object = new Object(); // 类型指针指向Object.class
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
}
可以看到一共占用了16个字节,对象头占8个字节,类型指针占4个字节,填充了4个字节,所以Object object = new Object();占用内存16字节。
public class SynchronizedTest2 {
public static void main(String[] args) {
Object[] object = new Object[]{};
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
}

可以看到一共占用了16个字节,对象头占8个字节,类型指针占4个字节,数组长度4个字节,总共16字节。
java基础回顾
java语言中有8种基本数据类型,分类四大类型:
逻辑类型:boolean 1字节
整数类型:byte、short、int、long(1、2、4、8字节)
浮点类型 :float、double(4、8字节)
字符类型:char 2字节
对象类型占4个字节,不开压缩占8个字节。
public class SynchronizedTest2 {
public static void main(String[] args) {
Cat object = new Cat();
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
private static class Cat{
int i;
long j;
char c;
boolean flag;
String str ="123";
}
}
可以看到String类型占4个字节
类型指针占4个字节,不开压缩占8个字节。
-XX:-UseCompressedClassPointers -XX:-UseCompressedOops
对象指针压缩
public class SynchronizedTest2 {
public static void main(String[] args) {
Cat object = new Cat();
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
private static class Cat{
int i;
long j;
char c;
boolean flag;
String str ="123";
ArrayList<String> list = new ArrayList<>();
}
}
对象类型占4个字节,不开压缩占8个字节。
关闭对象指针压缩
关闭后对象指针占8个字节
问题7总结:Object obj = new Object(); 在内存中占用16字节,开启与不开启类型指针压缩都是16字节。
CPU位数主要指的是寄存器的位宽,32位CPU能放入最大2的32次方的数值。64位就是最大2的64次方的值。这里的32位位宽的CPU就是我们常说的32位CPU。CPU跟内存之间,是用总线来进行信号传输的。总线,也有个宽度,比如宽度是32位,那么一次可以传32个0或1的信号,那么这个宽度能表达的数值范围就是0到2^32这么多。
32位CPU的总线宽度一般是32位,CPU可以利用地址总线在内存中进行寻址操作,那么现在这根地址总线,最大能寻址的范围,也就到
2的32次方,其实就是4G。64位CPU,总线宽度实际上是48位,所以寻址范围能到2的48次方,也就是256T。当然,寻址能力,除了受到cpu的限制外,还受到操作系统的限制,如果操作系统就是按着32位的指令和寻址范围(4G)来编译的话,那么它就会缺少64位系统该有的指令,它在运行软件的时候就不能做到超过这个限制,因此寻址能力还会是4G。
操作系统和软件(操作系统也是一种软件),也应该按CPU所能支持的范围去构建自己的寻址范围。
一般情况下,CPU架构在设计上都是完全向前兼容的,后出的位数的CPU肯定是兼容先出的,因此64位的CPU是能装上32位操作系统的。也是兼容32位的软件的。但是,32位操作系统只支持4g的内存,而64位的软件在编译的时候就设定自己的内存边界不止4个G,并且64位的CPU指令集内容比32位的要多,所以32位操作系统是肯定不能运行64位软件的。
32位CPU和操作系统插8g内存条,系统能正常工作,但一般用不到8G,因为32位系统的总线寻址能力为4G,哪怕装了8G的内存,真正能被用到的其实只有4G。
java -XX:+PrintCommandLineFlags -version
查看JVM启动参数
JVM(HotSpot)参数选项提供了三大类
第一类:“-”开头,标准参数,JVM(HotSpot)都支持。最稳定,在后续版本中也不太会发生变化。
第二类:“-X”开头,非标准参数,各版本的JVM(HotSpot)支持各自命令。相对稳定,可能会在后续版本中发生变化。
第三类:“-XX”开头,不稳定参数,主要是给JVM开发者用于开发和调试JVM用的,在后续版本中发生变化可能最大。
+号代表开启,-号代表关闭。
在idea控制台输入上述命令,查看JVM启动参数
-XX:InitialHeapSize #JVM起始堆内存大小
-XX:MaxHeapSize #JVM最大堆内存大小
-XX:+UseCompressedClassPointers #默认打开Class指针压缩
-XX:+UseCompressedOops #默认打开普通对象指针压缩,Oops(ordinary object pointers)
-XX:+UseParallelGC #1.8版本JVM默认使用的垃圾收集器(新生代:Parallel Scavenge,老年代:Serial Old)参考周志明老师jvm p128 3-4表。
Parallel Scavenge收集器默认的分代年龄是15次。
CMS收集器默认的分代年龄是6次。
分代年龄最大是15次,原因是只有4位,最大表数1111就是15。
mark word里面默认存储对象的hashcode,分代年龄和锁标记位。
实验验证:-XX:MaxTenuringThreshold=15

验证hashcode,调用对象的hashcode方法
public class SynchronizedTest2 {
public static void main(String[] args) {
Cat object = new Cat();
System.out.println(ClassLayout.parseInstance(object).toPrintable());
System.out.println(Integer.toHexString(object.hashCode()));
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
private static class Cat{
}
}
验证锁标记
public class SynchronizedTest2 {
public static void main(String[] args) {
Cat object = new Cat();
System.out.println(ClassLayout.parseInstance(object).toPrintable());
synchronized (object){
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
private static class Cat{
}
}

Cat object = new Cat();如何通过object指针定位到Cat对象?
找到对象的首地址,然后根据对象的大小往后数,就可以定位到对象。
Java程序会通过栈上的reference数据来操作堆上的具体对象。由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置所以对象访问方式也是由虚拟机实现而定的。
问题5总结:主流的访问方式主要有使用句柄和直接指针两种:
如果使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。
如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关 信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。

句柄访问与直接指针访问的优劣:
这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference本身不需要被修改。
使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访 问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本,HotSpot主要使用第二种方式进行对象访问。
问题6总结:
如果对象不可逃逸,且属于热点数据,会在栈上分配内存。栈上分配的对象可以不用经历垃圾回收,直接弹出
如果占空间很大,直接在老年代分配
多线程下同时创建对象,要保证线程安全:在Eden中为每个线程开辟很小的空间TLAB,只占1%;每个线程独立创建,互不干扰,TLAB用完后,再使用CAS+失败重试来保证更新操作的原子性
在Eden分配
分析需要安装jclasslib插件


public class Test2 {
public static void main(String[] args) {
Object object = new Object();
}
}
先执行代码,生成字节码文件,然后选中Test2 类,在view菜单栏下调出如下窗口。

new:申请空间,为变量赋默认值
dup:堆栈操作
invokespecial:调用构造方法,初始化对象
astore_1:建立关联,将对象指向引用变量
return:返回
对象的创建过程:
jvm 遇到new指令,检查能否在常量池中定位到一个类的符号引用,检查这个符号引用代表的类是否已经经历类加载的过程,没有就执行类加载。
类加载完成 可以确定对象所需内存大小,分配方式有碰撞指针(带压缩整理的收集器使用)和空闲列表(理论上清除算法的收集器使用)两种方式
内存分配完成后,jvm将除对象头之外的内存空间都初始化为零值,保证对象的字段可以不初始化直接使用。(对使用TLAB也一样)
设置对象头中的信息
执行() : 调用构造方法进行初始化
将对象指向引用变量(如果是new Object()就没有这一步)
问题1总结:对象的创建过程:
new:申请一块空间,将变量赋为默认值(半初始化)
调用构造方法,对变量进行赋值(初始化)
建立关联关系,将对象地址赋给引用变量obj
问题2总结:Class对象是存放在堆区的,不是方法区。类的元数据(元数据并不是类的Class对象。Class对象是加载的最终产品,类的方法代码,变量名,方法名,访问权限,返回值等等都是在方法区的)才是存在方法区的。
方法区
在一个JVM实例的内部,类型信息被存储在一个称为方法区的内存逻辑区中。类型信息是由类加载器在类加载时从类文件中提取出来的。类(静态)变量也存储在方法区中。
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把 方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。
原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。在JDK1.8之前,HotSpot虚拟机实现方法区采用的是永久代,而到了1.8废除了永久代,改用Metaspace(元空间)来实现。所以不管是永久代还是元空间都是实现方法区的方式。
引入依赖
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.16</version>
</dependency>
代码验证
import jdk.nashorn.internal.ir.debug.ObjectSizeCalculator;
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.info.GraphLayout;
import org.springframework.beans.BeanUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class TestObject {
public static void main(String[] args) {
List<TestObject> list = new ArrayList<>();
System.out.println("list占据大小:" + ObjectSizeCalculator.getObjectSize(list));
System.out.println("EMPTY_LIST占据大小:" + ObjectSizeCalculator.getObjectSize(Collections.EMPTY_LIST));
TestObject object = new TestObject("lisi-1", 18, 25.36);
System.out.println("object占据大小:" + ObjectSizeCalculator.getObjectSize(object));
System.out.println("object占据大小:" + ClassLayout.parseInstance(object).instanceSize());
list.add(object);
System.out.println("list占据大小:" + ObjectSizeCalculator.getObjectSize(list));
for (int i = 0; i <= 10; i++) {
TestObject object1 = new TestObject();
// BeanUtils.copyProperties(object,object1);
object1.setAge(i);
object1.setName(object.getName() + i);
object1.setSalary(object.getSalary() + i);
list.add(object1);
System.out.println("第" + i + "次:" + GraphLayout.parseInstance(list).totalSize());
}
System.out.println(GraphLayout.parseInstance(list).totalSize());
System.out.println(ClassLayout.parseInstance(list).instanceSize());
}
private String name;
private int age;
private double salary;
public TestObject() {
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public double getSalary() {
return salary;
}
public void setSalary(double salary) {
this.salary = salary;
}
public TestObject(String name, int age, double salary) {
this.name = name;
this.age = age;
this.salary = salary;
}
}
执行结果
list占据大小:40
EMPTY_LIST占据大小:16
object占据大小:88
object占据大小:32
list占据大小:168
第0次:256
第1次:344
第2次:432
第3次:520
第4次:608
第5次:696
第6次:784
第7次:872
第8次:960
第9次:1072
第10次:1160
1160
24
经验总结:
GraphLayout.parseInstance(list).totalSize()和ObjectSizeCalculator.getObjectSize(list)都是计算对象总的内存占用大小,而ClassLayout.parseInstance(list).instanceSize()计算的是对象自身占用的内存大小,不包含其引用对象所占的内存大小。工作中想要分析某个对象的内存占用大小,以此来分析是否会导致OOM,应该采用总大小。模拟的时候不能使用BeanUtils.copyProperties(object,object1);来模拟对象,因为其引用的对象都是同样的,不能真实反映现网的实际情况。
此处计算出来的内存大小的单位为Byte,而不是bit。1Byte=8bit。1KB=1024bit。1kb=128Byte。
所以换算成KB需要除以128,换算成MB需要再除以1024,换算成GB需要再除以1024。
bit(位)、B(字节)、KB(千字节)、MB(兆字节)、GB(千兆字节)
当需要返回空集合的时候,采用Collections.EMPTY_LIST,而不采用new ArrayList<>()。因为Collections.EMPTY_LIST更省内存。
注:本文是学习B站周阳老师《尚硅谷2022版JUC并发编程》
和马士兵老师《Java面试:关于Object o = new Object()九道大厂面试真题!》课程所做学习笔记。