面试现场。
"来,写一行代码。"面试官把白板笔递给我。
我在白板上写了:
Object obj = new Object();"这行代码背后,JVM做了哪些事?给你三分钟。"
我当时心里一紧。说实话,new一个对象谁不会啊,但要说清楚底层每一步,大部分人只能说出"分配内存、调用构造方法"就卡住了。
今天就掰开揉碎了讲讲,new一个对象,JVM到底在背后忙什么。
一、五个大步骤
从new Object()到你能用obj这个引用,JVM内部走了五个大步骤:
1. 类加载检查
2. 分配内存
3. 初始化零值
4. 设置对象头
5. 执行<init>方法
每一步里面都有坑。
二、第一步:类加载检查
虚拟机遇到new指令,第一件事不是分配内存——是检查这个类加载了没有。
// 字节码层面 0: new #2 // class java/lang/Object 3: dup 4: invokespecial #1 // Method java/lang/Object."<init>":()V 7: astore_1new指令的参数#2指向常量池中java/lang/Object的符号引用。虚拟机先去方法区找这个类有没有加载过。
没加载?先走一遍类加载流程:加载→验证→准备→解析→初始化。
这五个阶段里面最有意思的是"准备"和"初始化"。准备阶段给静态变量分配内存并设零值,初始化阶段才执行你写的赋值语句。比如:
public static int count = 100;准备阶段count=0,初始化阶段count=100。两者之间有时间差。如果别的线程在准备阶段读了count,读到的是0而不是100——这就是为什么静态变量在多线程环境中有可见性问题。
Object类肯定加载过了(JVM启动就加载),但如果是你自己写的类,这一步可能会触发类加载。而类加载的初始化阶段会执行static{}块和静态变量赋值——这就是为什么面试官爱追问"类加载时机"。什么时候会触发?new对象、反射、访问静态变量、初始化子类先初始化父类……每个时机展开都能讲十分钟。
三、第二步:分配内存
类加载完毕,确定对象需要多大内存,下一步就是在堆上划一块地。
这里涉及两个问题:怎么划?划在哪?
3.1 怎么划地:指针碰撞 vs 空闲列表
取决于堆内存是否规整。堆是否规整又取决于垃圾收集器有没有"压缩整理"功能。
指针碰撞:堆内存是规整的,用过的在一边,空闲的在另一边,中间一根指针当分界线。分配内存就是指针往空闲那边挪一段,等于在纸上画一道线。
空闲列表:堆内存不规整,虚拟机维护一个列表记录哪些块空闲。分配时从列表里找一块够大的。
Serial、Parallel用指针碰撞(因为它们有压缩),CMS用空闲列表。G1和ZGC各有各的玩法,G1是Region级别管理,ZGC用彩色指针。
3.2 划在哪:栈上 vs TLAB vs 堆
这是面试的加分点。很多人以为对象一定在堆上,其实不一定。
栈上分配:逃逸分析发现对象不会逃出方法,直接在栈帧里分配,方法结束自动销毁,不用GC。这玩意听起来很美但实际触发条件很苛刻,JDK 8之后默认开启但实际栈上分配的场景不多。
public void test() { // 这个Point对象只在这个方法里用,可能直接分配在栈上 Point p = new Point(1, 2); System.out.println(p.x + p.y); }TLAB(Thread Local Allocation Buffer):这是最常用的优化。堆是共享的,多个线程同时new对象会有锁竞争。TLAB的思路是给每个线程在堆上预先申请一小块专属区域,线程在自己的TLAB里分配不用加锁,只有TLAB用完了才去堆上申请新的。
JDK 8默认开启了-XX:+UseTLAB。你可以加-XX:-UseTLAB关掉试试,高并发场景下吞吐量直接腰斩。这就是为什么JVM团队搞了这个优化——太有用了。
TLAB不够用了,或者对象太大(比如一个大数组超过TLAB剩余空间),才走堆上的慢速分配——这一步要加CAS锁,性能差不少。所以写代码的时候尽量别在循环里new超大对象,会让TLAB频繁换新的,等于把TLAB优化的好处全丢了。
四、第三步:初始化零值
内存分配好了,但在构造方法执行之前,虚拟机会把分配到的这块内存全部清零(除了对象头)。
public class User { private int age; // 这一步后被设为 0 private String name; // 这一步后被设为 null private boolean vip; // 这一步后被设为 false }这就是为什么Java不会有"野指针"问题。C/C++里如果不初始化,局部变量的值是不确定的——那块内存之前存过什么就是什么。Java直接给你清零了。
注意:这一步是虚拟机做的,不是构造方法做的。所以你在构造方法里读到的字段值已经是0/null/false了,不是乱码。
五、第四步:设置对象头
这是最有意思的地方——对象头(Object Header)里藏了对象的所有"身份证信息"。
一个Java对象在内存中的布局:
+------------------+ | Mark Word | 8字节(64位JVM) +------------------+ | Klass Pointer | 4字节(压缩后) +------------------+ | 实例数据 | 变长 +------------------+ | 对齐填充 | 凑到8字节的倍数 +------------------+Mark Word里存了:哈希码、GC分代年龄、锁状态标志、偏向锁信息、线程ID...
这就是为什么能synchronized(obj)——锁信息就写在对象的Mark Word里。
// HotSpot源码:markOop.hpp // Mark Word的位布局(64位): // // 无锁状态: // unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 // // 偏向锁: // thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 // // 轻量级锁: // ptr_to_lock_record:62 | lock:2 // // 重量级锁: // ptr_to_monitor:62 | lock:2你看那lock:2字段,两位二进制表示四种锁状态:01无锁、00轻量锁、10重量锁、11GC标记。
这就是上一篇讲的锁升级——升级的不是别的东西,改的就是Mark Word里的这几个bit。
Klass Pointer指向方法区中类的元数据,告诉虚拟机"我是哪个类的对象"。
六、第五步:执行<init>方法
最后才轮到构造方法。
但要注意,这里执行的<init>不是只执行你写的构造方法。它是编译器自动生成的,执行顺序是:
1. 父类构造方法(递归到Object) 2. 实例变量初始化 3. 实例初始化块 {} 4. 你写的构造方法体这个顺序很重要——很多诡异bug都跟它有关。看个例子:
public class Child extends Parent { private int x = 10; // 第2步 { System.out.println("init"); } // 第3步 public Child() { super(); // 第1步(隐式,编译器插入) System.out.println(x); // 第4步,此时x已经是10了 } }如果父类构造方法里调用了一个被子类重写的方法,那子类重写的方法执行时,子类的实例变量还是默认值(0/null),不是你在声明时写的那个值。因为第2步还没执行。
这是Java面试的经典陷阱题——构造方法里调用可重写方法会出问题,根源就在<init>的执行顺序。
另一个知识点:<init>方法和<clinit>方法的区别。<clinit>是类构造器,执行静态变量赋值和静态代码块,由JVM保证线程安全,只会执行一次。<init>是实例构造器,每次new都执行。
有个细节很多高级开发都不知道——如果类里没有任何构造方法,编译器会自动生成一个无参构造方法。但如果只定义了有参构造方法,编译器就不会生成无参的了。这时候用反射newInstance()就会报NoSuchMethodException。Spring容器创建Bean时默认调无参构造,所以Spring管理的Bean最好保留无参构造。
🎯 面试官视角的标准回答
当面试官问"new一个对象做了什么",他想要听到的完整答案是:
"首先虚拟机遇到new指令,去检查类是否加载过,没加载会触发类加载。
然后根据类的大小分配内存——具体怎么分取决于GC器,Serial用指针碰撞CMS用空闲列表。分配的时候会优先使用TLAB来避免线程竞争,大的或者TLAB放不下的对象走堆上慢速分配。
分配完后虚拟机把内存空间清零,保证字段有默认值。
接着设置对象头,包括Mark Word和Klass Pointer。Mark Word里存hashCode、GC年龄和锁标志——这就是synchronized能用的原因。
最后执行<init>方法,按照父类构造、实例变量、初始化块、构造方法体的顺序执行。到这里对象才算真正创建完成。"
说到这,面试官大概率会追问:"那你讲讲对象头里的锁状态变化过程?"
你看,下一篇的选题都帮你想好了。锁升级+对象头,串起来讲。
下一篇:对象头里的锁状态到底怎么变的?轻量锁膨胀成重量锁的那一刻,JVM做了什么?
唠点键盘之外的 · 第 22 篇