Skip to content
Go back

JVM对象创建与生命周期:从new指令到GC回收

JVM对象创建与生命周期:从new指令到GC回收

一句话结论(30s)

new User() 的本质是一条六步流水线——new 指令触发类加载检查、指针碰撞/TLAB 无锁分配内存、零值初始化、写对象头、执行 <init>() 构造器,因为只有类加载完成、内存归零且对象头写好后,才算产生一个合法可用的对象。关键设计是 TLAB 让每个线程在 Eden 私有小块内无锁分配、Mark Word 里 4 位分代年龄决定对象最多经历 15 次 Minor GC。权衡在于对象从 Eden 复制到 Survivor 再晋升老年代,晋升阈值是新生代复制开销与老年代压力之间的折中。

核心原理(2min)

主流程:new 字节码先查常量池并触发类加载(父类也要先加载),随后在堆上分配内存(规整用指针碰撞、不规整用空闲列表,TLAB 提供线程私有无锁分配),分配后零值初始化、写对象头(Mark Word 存哈希/分代年龄/锁状态,Klass Pointer 指向类元数据),最后执行 <init>() 完成字段和构造器初始化;对象用完由 GC 通过可达性分析回收(不可达 → 两次标记 → finalize 自救机会 → 回收)。关键机制:存活对象从 Eden 经 Survivor 交替复制,年龄到默认 15 晋升老年代,老年代满触发 Full GC。

底层深入(5-10min)

每当你写下一行User user = new User(),JVM内部经历了一个远比表面复杂的流程。理解这个流程,不仅能帮你写出更高效的代码,也能让你在面对内存和性能问题时知道从何下手。

第一阶段:创建——六步完成一个对象

第一步:new指令触发

Java编译器把new User()编译成一条new字节码指令。JVM解释器遇到new指令后,不是直接分配内存,而是先去常量池找类符号引用,检查这个类是否已经被加载。

解释器模板(templateTable_x86.cpp)中 _new 的汇编片段忠实还原了这一顺序——先取常量池下标、校验 tag 是否为 JVM_CONSTANT_Class,做类初始化屏障,最后才调用 tlab_allocate 真正分配内存:

void TemplateTable::_new() {
  transition(vtos, atos);
  __ get_unsigned_2_byte_index_at_bcp(rdx, 1);
  Label slow_case;
  // ...
  // Make sure the class we're about to instantiate has been resolved.
  const int tags_offset = Array<u1>::base_offset_in_bytes();
  __ cmpb(Address(rax, rdx, Address::times_1, tags_offset), JVM_CONSTANT_Class);
  __ jcc(Assembler::notEqual, slow_case_no_pop);

  // get InstanceKlass
  __ load_resolved_klass_at_index(rcx, rcx, rdx);
  __ push(rcx);  // save the contexts of klass for initializing the header

  // make sure klass is initialized
  __ clinit_barrier(rcx, nullptr /*L_fast_path*/, &slow_case);

  // get instance_size in InstanceKlass (scaled to a count of bytes)
  __ movl(rdx, Address(rcx, Klass::layout_helper_offset()));
  __ testl(rdx, Klass::_lh_instance_slow_path_bit);
  __ jcc(Assembler::notZero, slow_case);
  // ...
  if (UseTLAB) {
    __ tlab_allocate(rax, rdx, 0, rcx, rbx, slow_case);
    // ...
  }

get_unsigned_2_byte_index_at_bcp 读的是紧跟 new 操作码的常量池索引,clinit_barrier 保证类已初始化,走不通就落入 slow_case——这正是”先检查类、再分配内存”的机器级体现。

💭 思考:为什么 new 指令不直接分配内存,而要先检查类是否加载/初始化?——分配内存需要知道”这个对象有多大、对象头怎么写”,这些信息都存在类的元数据(InstanceKlass 的 layout_helper)里;类还没加载时,连分配多大都不知道,更谈不上写对象头。所以”先类加载、再分配”不是流程洁癖,而是硬约束——没有类元数据,分配这一步根本无从下手。若反过来先分配再加载,就会拿到一块大小都算不对的内存。

第二步:类加载检查

JVM检查这个类的符号引用能否在常量池中定位到,并检查是否已经完成了加载、解析和初始化。如果没有——触发类加载过程(加载→验证→准备→解析→初始化)。

解释器的慢路径入口 InterpreterRuntime::_new 把这三步写得很直白——按常量池下标取出 Klass、校验能否实例化、确保类已初始化,然后才真正分配实例:

JRT_ENTRY(void, InterpreterRuntime::_new(JavaThread* current, ConstantPool* pool, int index))
  Klass* k = pool->klass_at(index, CHECK);
  InstanceKlass* klass = InstanceKlass::cast(k);

  // Make sure we are not instantiating an abstract klass
  klass->check_valid_for_instantiation(true, CHECK);

  // Make sure klass is initialized
  klass->initialize_preemptable(CHECK_AND_CLEAR_PREEMPTED);

  oop obj = klass->allocate_instance(CHECK);
  current->set_vm_result_oop(obj);
JRT_END

klass_at 对应”定位符号引用”,check_valid_for_instantiation 拦截抽象类等非法实例化,initialize_preemptable 对应”确保初始化”。前面的快路径走不通时,new 指令就会落到这条慢路径上来补做完整检查。

已经加载过的类跳过这一步。但注意:父类也必须被加载,如果父类还没初始化,会先初始化父类。

第三步:分配内存

类确认已加载后,JVM从堆中为新对象划分内存。分配方式取决于堆是否规整:

为了减少多线程并发分配时的竞争,HotSpot引入了TLAB(Thread Local Allocation Buffer)

为什么不干脆在堆上全局加锁分配,而要费劲搞 TLAB?因为对象分配是极高频操作——若每次 new 都抢同一把全局锁,多线程下会成为严重的竞争瓶颈。TLAB 把「高频的无锁分配」放在线程私有小块里,「低频的加锁申请」只在 TLAB 耗尽时发生,从而把锁竞争降到几乎为零。

TLAB 的指针碰撞在 ThreadLocalAllocBuffer::allocate 里就是几行——读 _top,判断 _end - top 是否够,够了就把 _top 往后挪 size 并返回,全程无锁:

inline HeapWord* ThreadLocalAllocBuffer::allocate(size_t size) {
  invariants();
  HeapWord* obj = top();
  if (pointer_delta(end(), obj) >= size) {
    // Successful thread-local allocation.
    set_top(obj + size);
    invariants();
    return obj;
  }
  return nullptr;
}

上层 MemAllocator::mem_allocate 把”快路径 TLAB → 慢路径补 TLAB → 堆上直接分配”串成三级分配策略:

HeapWord* MemAllocator::mem_allocate(Allocation& allocation) const {
  if (UseTLAB) {
    // Try allocating from an existing TLAB.
    HeapWord* mem = mem_allocate_inside_tlab_fast();
    if (mem != nullptr) {
      return mem;
    }
  }
  // ...
  if (UseTLAB) {
    // Try refilling the TLAB and allocating the object in it.
    HeapWord* mem = mem_allocate_inside_tlab_slow(allocation);
    if (mem != nullptr) {
      return mem;
    }
  }
  return mem_allocate_outside_tlab(allocation);
}

set_top(obj + size) 就是文章开头说的”指针碰撞”——只要 TLAB 足够就移动边界指针,而 mem_allocate_inside_tlab_fast 失败后才回到需要申请新 TLAB 的慢路径。这解释了为什么 TLAB 内分配几乎无竞争、只有 TLAB 耗尽才需要同步。

TLAB处理的都是小对象。大对象直接分配到堆的老年代(通过-XX:PretenureSizeThreshold定义阈值)。

💭 思考:为什么大对象要跳过新生代、直接进老年代?——新生代的特点是”频繁复制”:每次 Minor GC 存活对象都要从 Eden 拷到 Survivor、再在 S0/S1 间反复搬。对小对象这很划算(多数活不过一轮),但大对象每搬一次都是实打实的内存拷贝,几轮下来开销巨大。所以让大对象直接落到老年代,避免被反复搬。代价是它可能过早挤占老年代、提前触发 Full GC——这正是”复制开销 vs 老年代压力”的取舍,阈值 PretenureSizeThreshold 就是调节这个平衡的旋钮。

第四步:零值初始化

内存分配好后,JVM将分配到的内存空间全部初始化为零值(不包括对象头)。这确保了Java对象的实例字段在未赋初始值之前就有确定的默认值:int=0、boolean=false、引用=null等。

为什么 JVM 要主动清零,而不是相信构造器会赋值?因为 Java 语言规范要求字段在显式赋值前就有确定的默认值,而且新分配的内存可能残留上一任对象的脏数据,不清零就会读到未定义内容。清零把「默认值」这件事交给了虚拟机保证。

MemAllocator::mem_clear 就是干这件事的——它从对象头之后(mem + hs)开始清零实例字段,跳过了对象头:

void MemAllocator::mem_clear(HeapWord* mem) const {
  assert(mem != nullptr, "cannot initialize null object");
  const size_t hs = oopDesc::header_size();
  assert(_word_size >= hs, "unexpected object size");
  if (oopDesc::has_klass_gap()) {
    oopDesc::set_klass_gap(mem, 0);
  }
  Copy::fill_to_aligned_words(mem + hs, _word_size - hs);
}

而开启 ZeroTLAB 时,整块新 TLAB 在申请时就被一次性清零,后续对象甚至无需再逐字清零:

  // ...and clear or zap just allocated TLAB, if needed.
  if (ZeroTLAB) {
    Copy::zero_to_words(mem, allocation._allocated_tlab_size);
  }

mem + hs 的偏移量恰好印证了”不包括对象头”——对象头随后在第五步单独写入。

💭 思考:为什么零值初始化要”跳过对象头”,只清 mem + hs 之后的部分?——对象头(Mark Word + Klass Pointer)紧接着会在第五步单独写入。如果第四步先把对象头也清零,第五步还得再写一遍,等于白做一次无效写。所以清零点从对象头之后开始,把对象头留给 finish 阶段用正确的值写入。若不清零就写,则实例字段可能残留上一任对象的脏数据,违反”字段必有默认值”的规范——两步各有分工,边界就在 header_size() 这条线上。

这一步由内存分配系统完成,很快——因为操作系统分配内存时通常也会给零页。

第五步:设置对象头

对象头(Object Header)存储了JVM管理对象所需的元数据,包括两部分:

Mark Word(标记字):32位JVM中占32位,64位JVM中占64位。存储:

Klass Pointer(类型指针):指向方法区中类元数据的指针。JVM通过这个指针确定对象是哪个类的实例。32位JVM占32位,64位JVM中如果开启了指针压缩(默认开启,堆<32GB时生效)占32位,否则占64位。

如果是数组对象,对象头中还会有一个记录数组长度的字段。

对象头里为什么专门存「分代年龄」?因为 GC 需要知道每个对象熬过了多少次 Minor GC 来决定何时晋升老年代——而把年龄压缩进 4 位(最多 15)就是「晋升阈值默认 15」的硬约束来源,用最小的空间换来了分代回收必需的元数据。

在 HotSpot 里,对象头对应的就是 oopDesc 的两个字段——_mark(Mark Word)和 _compressed_klass(Klass Pointer 的压缩形态),header_size() 明确给出了对象头的大小:

class oopDesc {
 private:
  volatile markWord _mark;
  narrowKlass _compressed_klass;
  // ...
  // Size of object header, aligned to platform wordSize
  static int header_size() {
    if (UseCompactObjectHeaders) {
      return sizeof(markWord) / HeapWordSize;
    } else {
      return sizeof(oopDesc)  / HeapWordSize;
    }
  }

Mark Word 的位布局直接写在 markWord.hpp 的注释里——64 位布局是 hash:31 + age:4 + lock:2 等字段的拼装:

// Bit-format of an object header (most significant first, big endian layout below):
//
//  32 bits:
//  --------
//             hash:25              age:4  self-fwd:1  lock:2
//
//  64 bits (without compact headers):
//  ----------------------------------
//  unused:22  hash:31  valhalla:4  age:4  self-fwd:1  lock:2
//
//  - lock bits are used to describe lock states: locked/unlocked/monitor-locked
//    and to indicate that an object has been GC marked / forwarded.

真正写入这两个字段的是 MemAllocator::finish——先写 Mark Word,再以 release 语义写 Klass 指针,写完对象才算”可被并发 GC 扫描”:

oop MemAllocator::finish(HeapWord* mem) const {
  // Need a release store to ensure array/class length, mark word, and
  // object zeroing are visible before setting the klass non-null, for
  // concurrent collectors.
  if (UseCompactObjectHeaders) {
    oopDesc::release_set_mark(mem, _klass->prototype_header());
  } else {
    oopDesc::set_mark(mem, markWord::prototype());
    oopDesc::release_set_klass(mem, _klass);
  }
  return cast_to_oop(mem);
}

_mark_compressed_klass 的先后写入、以及 release_set_klass 的内存屏障,正是对象头”Mark Word + Klass Pointer”两部分在 C++ 层面的精确落地。

💭 思考:为什么写 Klass Pointer 要用 release 语义,而不是普通赋值?——并发 GC 可能在对象还没完全初始化好时就”路过”它。release store 建立了一条 happens-before:保证 Mark Word、零值、数组长度都写完并可见之后,Klass 指针才变为非 null,对象才算”可以被 GC 扫描”。若用普通赋值、不加屏障,编译器/CPU 可能重排写入顺序,让 GC 提前看到一个半初始化的对象,进而崩溃。这条 release 就是”对象诞生”的最后一道门。

第六步:执行init方法

从JVM视角,对象已经”诞生”了。但从Java程序员的视角,对象还没初始化。JVM接下来执行实例初始化方法<init>()——就是你在类中写的构造方法(以及实例初始化块、实例字段的显式赋值表达式)。

为什么把「分配内存」和「执行构造器」拆成两步,而不是一步到位?因为分配是 JVM 层面保证对象可用(内存、对象头就绪),构造器是 Java 语义层面初始化业务字段——前者保证任何对象被 GC 扫描时结构合法,后者才轮到你的业务逻辑。

<init>()方法的调用顺序:父类构造器先执行(Object的<init>()一路到当前类),然后按代码中出现的顺序初始化字段和执行初始化块,最后执行构造器的方法体。

至此,new User()才算真正完成。

第二阶段:使用

对象创建完毕后,程序通过栈上的引用变量(局部变量表)指向堆中的对象实例来使用它。方法调用、字段读写都通过这个引用进行。

对象的使用阶段可能很长(从创建到不可达),也可能很短(方法执行完局部变量就出了作用域)。

第三阶段:销毁——被GC回收

Java没有显式的deletefree,销毁由垃圾回收器自动完成。

对象经历以下阶段:

  1. 可达(Reachable):有GC Roots链指向它,正常生命周期。
  2. 不可达(Unreachable):所有GC Roots链都断了,可以被回收。
  3. 两次标记:GC第一次标记后发现不可达,但对象重写了finalize()且还没执行过,会被放入F-Queue等待执行。finalize()是对象最后的自救机会——可以把自己重新赋值给一个GC Root可达的引用来”复活”。但finalize()在JDK 9中已被标记为deprecated,不推荐使用。

为什么 finalize() 明明能「自救」却被官方弃用?因为它让对象回收变得不确定——执行时机由 GC 线程决定、可能永远不执行,而且复活会打乱 GC 的标记结果、拖慢回收。资源清理应交给 try-with-resources 或 Cleaner,而不是依赖不可控的 finalize。 4. 回收:经历两次标记后仍不可达,对象占用的内存被回收。

对象分配的整体流向

大部分对象在Eden区分配 → 第一次Minor GC后存活的对象移到Survivor区(S0和S1交替)→ 每次Minor GC存活的对象年龄+1 → 年龄到达-XX:MaxTenuringThreshold(默认15)时晋升到老年代 → 老年代满时触发Full GC → Full GC清理老年代中的无效对象。

“年龄最多 15”这个数字不是拍脑袋定的,而是 Mark Word 里 age_bits = 4 位的硬约束。markWord.hppmax_age 封顶,incr_age 到达上限后就不再自增,防止回绕:

  // Number of bits
  static const int lock_bits                      = 2;
  static const int self_fwd_bits                  = 1;
  static const int age_bits                       = 4;
  // ...
  static const uint max_age                       = age_mask;

  // age operations
  uint     age()           const { return (uint) mask_bits(value() >> age_shift, age_mask); }
  markWord incr_age()      const { return age() == max_age ? markWord(_value) : set_age(age() + 1); }

而 JVM 参数 -XX:MaxTenuringThreshold 的默认值 15 与合法范围就定义在 gc_globals.hpp,上限直接引用 markWord::max_age + 1

  product(uint, MaxTenuringThreshold,    15,                                \
          "Maximum value for tenuring threshold")                           \
          range(0, markWord::max_age + 1)                                   \

age_bits = 4 意味着年龄只能表示 0~15,range(0, markWord::max_age + 1) 让参数上限与 Mark Word 布局强绑定——所以文档里”默认 15 次晋升”的说法,源头就是这 4 个 bit。

这就是一个Java对象的完整生命——从一条new指令的微观世界,到GC回收的宏大叙事。

章末提问

  1. new User() 从 JVM 视角到底发生了什么? — 结论:一条六步流水线。因为 new 指令要依次完成类加载检查、内存分配(TLAB/指针碰撞)、零值初始化、写对象头、执行 <init>(),任何一步失败都不会产生合法对象。

  2. 指针碰撞和空闲列表怎么选? — 结论:取决于堆是否规整。因为带压缩的收集器(Serial/ParNew)整理出连续空闲内存可用指针碰撞,不规整的 CMS 只能维护空闲列表在碎片中找可用的块。

  3. 对象头里分代年龄为什么是 4 位、最多 15? — 结论:这是 Mark Word 里 age_bits=4 的硬约束。因为 4 位只能表示 0~15,incr_age 到上限就不再自增,这就是 -XX:MaxTenuringThreshold 默认 15 的源头。

  4. TLAB 为什么能做到几乎无锁? — 结论:把高频分配放进线程私有缓冲区。因为每个线程在自己的 TLAB 里用指针碰撞、无需同步,只有 TLAB 耗尽申请新块才做 CAS 同步。

  5. 为什么 finalize() 被弃用? — 结论:回收时机不可控且可能复活对象。因为执行时机由 GC 决定、可能永不执行,复活会破坏标记结果,应改用 try-with-resources/Cleaner。


Share this post on:

Previous Post
JVM指针压缩:32位地址访问32GB堆的魔法
Next Post
JVM ShenandoahGC:Brooks Pointer与并发转移