引言

当年刚刚接触Java编程时遇到一位Java技术实力比较厉害的前辈,在维护他所写的Java代码时无意地发现其在使用完大对象时会显式地对本地变量赋值为null,同时在这种操作旁备注上"断开引用提前释放对象内存",虽然当时并不明白为什么对用完的对象变量赋值为null就可以释放掉占用的内存,从而可以提高对内存的使用效率,但是还是对这种涉及这么底层的操作(操控内存释放)感到由衷地佩服,不知道读者朋友们是否也遇到过或听闻过这种内存使用的底层”技巧“,那么这种操作到底有没有基本的依据?

Java对象内存结构

要搞清楚这个问题,肯定要先搞明白Java虚拟机是怎么存储我们创建的对象实例的,也就是Java虚拟机是怎么使用内存的?Java虚拟机把内存划分成多个区域,每个区域的作用各不相同,我们比较关心的是Java线程栈、heap堆和元数据区这几个区域,因为当我们创建一个新的Java对象时,这个对象就会被分配到Java的堆内存中,同时把对应的堆地址赋值给线程栈中的变量。

具体的内存结构如下示意:


                   函数帧栈
        ┌──────────────────────────┐  
        │          ......          │
        │ ┌──────────────────────┐ │
        │ local var: oop p       │─┼──┐    (栈帧中只保存一个指针地址,oop的英文是ordinary object pointer)
        │ └──────────────────────┘ │  │
        │          ......          │  │
        └──────────────────────────┘  │
                                      │
                                      │ 指向堆中 oopDesc 的首地址,oopDesc 即是用来描述 Java Object实例
                                      ▼
        ┌──────────────────────────────────────────────┐ 堆 Heap
        │  oopDesc                                     │
        │  ┌────────────────────────────────────────┐  │
        │  │ _mark        : markWord                │  │ ← 对象头 Header  (8B / 64位) 
        │  ├────────────────────────────────────────┤  │
        │  │ _metadata._klass : Klass* (8B)         │  │ ← 或 narrowKlass(4B)+gap(4B)
        │  ├────────────────────────────────────────┤  │
        │  │ instance fields:                       │  │ ← Java 对象实例字段
        │  │   int  i;                              │  │   (由具体子类如 instanceOopDesc
        │  │   oop  ref ───────────────┐            │  │    / objArrayOopDesc 决定)
        │  │   ...                     │            │  │
        │  └───────────────────────────┼────────────┘  │
        └──────────────────────────────┼───────────────┘
                                       │
                                       ▼  (字段中的 oop 又指向另一个堆对象)
            ┌──────────────────────────────────────────────┐
            │  oopDesc                               │
            │  [ _mark | _klass | fields... ]              │
            └──────────────┬───────────────────────────────┘
                           │ _klass 指向元数据区
                           ▼
            ┌──────────────────────────────────────────────┐ Metaspace(非堆)
            │  Klass (类元数据,描述对象类型/方法/字段)         │
            │  [ vtable | itable | _java_mirror ... ]      │
            └──────────────────────────────────────────────┘

对应的关系图(栈 / 堆 / 元数据区):

Metaspace(类元数据)

Java Heap(GC 管理)

栈 Stack(线程私有)

引用

引用

字段中的 oop

_klass

_klass

oop p1 (8B 指针)

oop p2 (8B 指针)

oopDesc #1
_mark | _klass | fields

oopDesc #2
_mark | _klass | fields

Klass for ClassA

Klass for ClassB

关键要点小结:

区域 存放内容 oopDesc 的关系
栈 Stack 局部变量 oop(即 oopDesc* 指针)、JNI handle 等 只存指针,不存对象实体;GC 时作为 Root 扫描
堆 Heap oopDesc(含 _mark + _metadata)+ 子类实例字段 / 数组元素 真正的对象实体存放处
Metaspace Klass(类元数据) oopDesc::_metadata._klass 指向,描述对象类型
  • oopoopDesc* 的别名,所以源码中所有 oop 局部变量在栈帧上都只占一个机器字。
  • oopDesc 自身禁止虚函数,从而避免引入 vtable 指针,保证对象头大小完全可控。
  • 通过 _metadata._klass 这一指针,oopDesc 与 Metaspace 中的 Klass 元数据建立联系,实现"对象 → 类型"的反查。

由于Java虚拟机是由C++语言实现的,所以其底层的内存结构是建立在C内存结构上的,其线程帧栈复用的是C的函数栈,而heap堆和元数据区(直接内存)复用的是C的堆区。

运行时内存结构

当实际运行Java代码时,我们需要考虑如何引用对象,一般有两种方式进行对象引用:

  • 地址引用:直接使用内存地址作为引用,一次地址解析(寻址)就可以得到对象数据,若对象被移动(如垃圾回收、内存整理),地址可能失效,导致悬空指针
  • 句柄引用:由可以映射到具体的内存地址的抽象标识符作为引用,需要查询对应的映射关系才能得到内存地址,也就是经过两次解析才能得到对象数据,好处是把真实的内存地址隐藏起来了,在地址失效时对其进行更新避免出现悬空指针

Java虚拟机对常规Java对象使用的是第一种对象引用方式,避免访问对象数据时需要经过两次解析才能完成(性能影响),但是作为代价,在内存回收时需要暂停Java线程进行地址引用更新。而对native环境下的对象使用JNI handle句柄引用的方式,因为native环境没有Java帧栈或其他辅助工具来进行对象引用的记录,只能使用统一的"句柄映射表"来管理对象引用。

我们使用简单的例子进行说明:

    public static class Data {
        int val;
        byte[] data = new byte[1024*1024];//1mb内存
    }

    public static void main(String[] args) {
        Data p1 = new Data();
        Data p2 = p1;
        int i = 42;
        {
            Data p3 = new Data();
            i += p3.val;
        }
    }

以上代码编译后生成的字节码如下:

 0 new #2 <jvm/CompilerTest$Data>
 3 dup
 4 invokespecial #3 <jvm/CompilerTest$Data.<init> : ()V>
 7 astore_1
 8 aload_1
 9 astore_2
10 bipush 42
12 istore_3
13 new #2 <jvm/CompilerTest$Data>
16 dup
17 invokespecial #3 <jvm/CompilerTest$Data.<init> : ()V>
20 astore 4
22 iload_3
23 aload 4
25 getfield #4 <jvm/CompilerTest$Data.val : I>
28 iadd
29 istore_3
30 return

其运行时内存结构如下示意:


                     栈帧 (Stack Frame, 由编译器/解释器布局)
                                 栈底(高地址)
                %rbp ┌──────────────────────────────┐
                     │ return addr / saved fp / ... │
                     ├──────────────────────────────┤
      0x7ffff79fddc8 │ oop  p1   = 0x7f00_1234_0000 │──┐  仅 8 字节指针
      0x7ffff79fddc0 │ oop  p2   = 0x7f00_1234_0000 │──┤  多个引用可指向同一对象
                     │ jint i    = 42               │  │
                     │ oop  p3   = 0x7f00_5678_abcd │──┼─────────────────────┐
                %rsp └──────────────────────────────┘  │                     │
                                                       │                     │
                                                       │                     │
                                              0x7f00_1234_0000       0x7f00_5678_abcd   
                                                       │                     │
                                                       ▼                     ▼
                                                       ┌─────────────────┐   ┌─────────────────┐
                                                       │ oopDesc A (heap)│   │ oopDesc B (heap)│
                                                       │ _mark           │   │ _mark           │
                                                       │ _metadata._klass│   │ _metadata._klass│
                                                       │ fields...       │   │ fields...       │
                                                       └─────────────────┘   └─────────────────┘

%rsp是用来记录栈顶指针的寄存器,当Java字节码被执行时,Java帧栈中的”操作数栈“(其实就是Java帧栈)会因为操作数入栈而向下扩大,也就是说%rsp的值会变小。
当执行完22 ~ 23的字节码(也就是Java代码i += p3.val;对应的字节码)后,其帧栈如下示意:

                                 栈底(高地址)
                %rbp ┌──────────────────────────────┐
                     │ return addr / saved fp / ... │
                     ├──────────────────────────────┤
      0x7ffff79fddc8 │ oop  p1   = 0x7f00_1234_0000 │
      0x7ffff79fddc0 │ oop  p2   = 0x7f00_1234_0000 │
                     │ jint i    = 42               │
                     │ oop  p3   = 0x7f00_5678_abcd │
                     │ jint i    = 42               │<── 操作数1
                     │ oop  p3   = 0x7f00_5678_abcd │<── 操作数2
                %rsp └──────────────────────────────┘
                                栈顶(低地址)

我们可以显而易见地发现,在Java代码里面对Java对象的各种操作其实就是对heap堆Java对象的地址进行的操作,无论是对象变量的赋值还是访问对象的字段,都是在对heap堆的地址在做操作。这里也可以看出Java对象的深浅拷贝的差异,如果把p1变量中的地址值赋给p2变量,这就是对象的浅拷贝,如果把p1变量中的地址值指向的heap堆内存数据完完整整地拷贝一份到p3变量中地址值指向的heap堆内存中(也就是oopDesc A的内容拷贝到oopDesc B),那么就变成了对象的深拷贝。

注意,以上分析是基于Java虚拟机解释执行字节码的场景,当字节码被JIT编译成本地代码进行编译执行时情况会更复杂一点。

垃圾回收

当内存分配失败时Java虚拟机会触发垃圾回收,垃圾回收时会进行GC根扫描,让我们把注意力集中在SerialGC的YGC过程中的GC根扫描而避免引入过多的复杂性:

分配失败 mem_allocate_work
        │
        ▼
VM_GenCollectForAllocation (VM operation, STW 进入安全点)
        │
        ▼
GenCollectedHeap::do_collection()        ← 判断 young/full
        │
        ▼
GenCollectedHeap::collect_generation(_young_gen, ...)
        │
        ▼
DefNewGeneration::collect()              ← YGC 核心算法(复制 + 晋升)
        │
        ├── young_process_roots()        ← 根扫描
        ├── evacuate_followers.do_void() ← 广度优先复制传递
        ├── ReferenceProcessor           ← 软/弱/虚引用处理
        ├── WeakProcessor::weak_oops_do  ← 弱根处理
        └── swap_spaces()                ← 生存区互换 from↔to

GC Root 根扫描

YGC 只回收年轻代,但老年代 → 年轻代的引用也必须当作 Root,否则会误回收存活对象。SerialGC 通过 “GC根 + 卡表(Card Table / RSet)” 两套机制完成根扫描:

1. 触发根扫描

{
    StrongRootsScope srs(0);   // n_threads=0 → 串行模式
    heap->young_process_roots(&srs,
                              &fsc_with_no_gc_barrier,    // root_closure
                              &fsc_with_gc_barrier,       // old_gen_closure
                              &cld_scan_closure);         // cld_closure
}

2. young_process_roots 分两步进行根扫描

void GenCollectedHeap::young_process_roots(StrongRootsScope* scope,
                                           OopsInGenClosure* root_closure,
                                           OopsInGenClosure* old_gen_closure,
                                           CLDClosure* cld_closure) {
    MarkingCodeBlobClosure mark_code_closure(root_closure,
                                             CodeBlobToOopClosure::FixRelocations);

    // ① 扫描"GC根"(线程栈、JNI、Universe、同步、CodeCache 中的可清扫部分等)
    process_roots(scope, SO_ScavengeCodeCache, root_closure,
                  cld_closure, cld_closure, &mark_code_closure);

    if (_process_strong_tasks->try_claim_task(GCH_PS_younger_gens)) {
        root_closure->reset_generation();
    }

    // ② 通过卡表扫描老年代 → 年轻代的跨代引用
    old_gen_closure->set_generation(_old_gen);
    rem_set()->younger_refs_iterate(_old_gen, old_gen_closure, scope->n_threads());
    old_gen_closure->reset_generation();

    _process_strong_tasks->all_tasks_completed(scope->n_threads());
}

3. process_roots:枚举所有"GC根"

按出现顺序,YGC 期间扫描的根包括:

序号 根类型 调用 说明
1 ClassLoaderDataGraph ClassLoaderDataGraph::roots_cld_do 扫描所有 CLD 中的强/弱引用(Class 镜像、静态字段等)
2 Java 线程栈 + 寄存器 Threads::possibly_parallel_oops_do 栈帧中的局部变量、操作数栈、监视器、JNI handle 块、线程 vm_result 等
3 Universe Universe::oops_do 系统级根:_main_thread_group、各种 mirror、内置异常对象
4 Global JNI handles JNIHandles::oops_do 全局 JNI 引用(NewGlobalRef
5 ObjectSynchronizer ObjectSynchronizer::oops_do 重量级锁中持有的对象监视器引用
6 Management Management::oops_do JMX/Management API 引用
7 JVMTI JvmtiExport::oops_do JVMTI Tag Map、Agent 持有的引用
8 AOT loader AOTLoader::oops_do 启用 AOT 时的根
9 VM OopStorage(global) OopStorageSet::vm_global()->oops_do string table、resolved method table 等
10 CodeCache 可清扫部分 ScavengableNMethods::nmethods_do 编译方法中嵌入的、引用到年轻代的 oop

4. Java 线程栈枚举"GC根"

GenCollectedHeap::process_roots
  └─ Threads::possibly_parallel_oops_do                 
       └─ JavaThread::oops_do (每个 Java 线程)   
            ├─ for (StackFrameStream fst; ...) {
            │      fst.current()->oops_do(f, cf, regmap)  // ← 逐帧扫描
            └─ }

frame::oops_do_internal                                    
  ├─ is_interpreted_frame() → oops_interpreted_do          // 解释帧
  ├─ is_entry_frame()       → oops_entry_do                // 入口帧(C调用Java方法的边界)
  └─ CodeCache::contains(pc)→ oops_code_blob_do            // 编译帧(JIT)
                                  └─ OopMapSet::oops_do
void JavaThread::oops_do(OopClosure* f, CodeBlobClosure* cf) {
  Thread::oops_do(f, cf);                       // ① handle area 等线程通用根

  if (has_last_Java_frame()) {
    // ② JNI 局部句柄、线程内的 GrowableArray<oop>
    if (_array_for_gc != NULL) { ... }

    // ③ 重量级监视器
    for (MonitorChunk* chunk = monitor_chunks(); ...) chunk->oops_do(f);

    // ④ 逐帧扫描整个 Java 调用栈
    for (StackFrameStream fst(this); !fst.is_done(); fst.next()) {
      fst.current()->oops_do(f, cf, fst.register_map());
    }
  }

  ...
}

StackFrameStream 自栈顶向栈底逐帧迭代访问Java帧栈上的GC根:

void frame::oops_do_internal(OopClosure* f, CodeBlobClosure* cf,
                             RegisterMap* map, bool use_interpreter_oop_map_cache) {
  if (is_interpreted_frame()) {
    oops_interpreted_do(f, map, use_interpreter_oop_map_cache);
  } else if (is_entry_frame()) {
    oops_entry_do(f, map);
  } else if (CodeCache::contains(pc())) {
    oops_code_blob_do(f, cf, map);
  } else {
    ShouldNotReachHere();
  }
}

Java帧栈的迭代主要分为三种情况:

  • 1、解释栈,Java虚拟机执行字节码
  • 2、C调用Java方法的入口栈,也就是Java虚拟机开始调用Java方法的入口
  • 3、编译栈,Java虚拟机执行Java代码编译后的本地代码
4.1 Java解释栈

我们先分析Java解释栈,Java解释栈会对局部变量和操作数进行枚举:

void frame::oops_interpreted_do(OopClosure* f, const RegisterMap* map,
                                bool query_oop_map_cache) {
  methodHandle m (thread, interpreter_frame_method());
  jint bci = interpreter_frame_bci();

  ...

  // 局部变量 + 操作数栈  由 InterpreterOopMap 精确驱动扫描
  InterpreterFrameClosure blk(this, max_locals, m->max_stack(), f);
  InterpreterOopMap mask;
  if (query_oop_map_cache) m->mask_for(bci, &mask);
  else                     OopMapCache::compute_one_oop_map(m, bci, &mask);
  mask.iterate_oop(&blk);
}

InterpreterOopMap主要是用来记录Java解释栈上存活的oop对象,在扫描GC根时可以提高扫描效率:

void InterpreterOopMap::iterate_oop(OffsetClosure* oop_closure) const {
  int n = number_of_entries();
  for (int i = 0; i < n; i++) {
    if (is_oop(i)) oop_closure->offset_do(i); 
  }
}

void InterpreterFrameClosure::offset_do(int offset) {
  oop* addr;
  if (offset < _max_locals) {
    // 局部变量区
    addr = (oop*) _fr->interpreter_frame_local_at(offset);
    _f->do_oop(addr);
  } else {
    // 操作数栈
    addr = (oop*) _fr->interpreter_frame_expression_stack_at(offset - _max_locals);

    // 异常时 esp 会被重置;只在 addr 仍处于活跃栈范围时调用
    bool in_stack = (frame::interpreter_frame_expression_stack_direction() > 0)
                    ? (intptr_t*)addr <= _fr->interpreter_frame_tos_address()
                    : (intptr_t*)addr >= _fr->interpreter_frame_tos_address();
    if (in_stack) _f->do_oop(addr);
  }
}

interpreter_frame_local_at会返回Java解释栈的上的本地变量的地址,然后把本地变量的地址传入do_oop函数进行Java对象的复制或晋升等操作,结合我们前面给出的示例也就是把地址值0x7ffff79fddc80x7ffff79fddc0传入do_oop函数,等复制操作完成,内存的大概示意如下:


                     栈帧 (Stack Frame, 由编译器/解释器布局)
                                 栈底(高地址)
                %rbp ┌──────────────────────────────┐
                     │ return addr / saved fp / ... │
                     ├──────────────────────────────┤
      0x7ffff79fddc8 │ oop  p1   = 0x7f00_5566_00a0 │──┐  仅 8 字节指针
      0x7ffff79fddc0 │ oop  p2   = 0x7f00_5566_00a0 │──┤  多个引用可指向同一对象
                     │ jint i    = 42               │  │
                     │ oop  p3   = 0x7f00_5566_00c0 │──┼─────────────────────┐
                %rsp └──────────────────────────────┘  │                     │
                                                       │                     │
                                                       │                     │
                                              0x7f00_5566_00a0       0x7f00_5566_00c0   
                                                       │                     │
                                                       ▼                     ▼
                                                       ┌─────────────────┐   ┌─────────────────┐
                                                       │ oopDesc A (heap)│   │ oopDesc B (heap)│
                                                       │ _mark           │   │ _mark           │
                                                       │ _metadata._klass│   │ _metadata._klass│
                                                       │ fields...       │   │ fields...       │
                                                       └─────────────────┘   └─────────────────┘

4.2 Java编译栈

Java代码被JIT 编译后,局部变量和操作数栈被分配到 寄存器栈 slot,且经过寄存器分配、SSA 优化后,解释栈的"局部变量表/操作数栈"概念已不存在。所以编译器在每个安全点处生成对应的 OopMap,记录此 PC 处所有寄存器和栈 slot 上 Java对象的状态。

oops_code_blob_do 入口:

void frame::oops_code_blob_do(OopClosure* f, CodeBlobClosure* cf,
                              const RegisterMap* reg_map) {
  if (_cb->oop_maps() != NULL) {
    OopMapSet::oops_do(this, reg_map, f);                    // OopMap核心
    if (reg_map->include_argument_oops()) {
      _cb->preserve_callee_argument_oops(*this, reg_map, f); // c2i 边界保护参数
    }
  }
  if (cf != NULL) cf->do_code_blob(_cb);                     // 标记 nmethod 自身
}

每个 nmethod 内部有 OopMapSet,按安全点 PC 索引到 ImmutableOopMap

const ImmutableOopMap* map = cb->oop_map_for_return_address(fr->pc());

返回的 OopMap 是一组 (VMReg, oop_type) 二元组,oop_type 取值:

类型 含义
oop_value 普通对象指针
narrowoop_value 压缩指针(CompressedOops)
derived_oop_value 派生指针:指向对象内部某偏移(base + offset)
callee_saved_value 被本帧保存的 caller 寄存器

我们把简单示例中的Java代码进行JIT编译后得到的汇编代码段如下所示:

  0x00007f2e0d1fc0b4: mov    %rax,%rsi          ;*invokespecial <init>
                                                ; - jvm.CompilerTest::main@17 (line 20)

  0x00007f2e0d1fc0b7: mov    %rax,0x28(%rsp)
  0x00007f2e0d1fc0bc: nop
  0x00007f2e0d1fc0bd: nop
  0x00007f2e0d1fc0be: nop
  0x00007f2e0d1fc0bf: callq  0x00007f2e0d105de0  ; OopMap{[40]=Oop off=164}
                                                ;*invokespecial <init>
                                                ; - jvm.CompilerTest::main@17 (line 20)
                                                ;   {optimized virtual_call}

这里生成的汇编代码跟字节码17 invokespecial #3 <jvm/CompilerTest$Data.<init> : ()V>是相对应的,可以看到其中的OopMap{[40]=Oop off=164}只含有0x28(%rsp)这个栈地址,也就是说只有一个存活的Java对象,其内存堆地址值存储在栈地址0x28(%rsp)指向的内存中,其他的Java对象被JIT编译器优化后对GC不可见了。

我们对垃圾回收的分析就到GC根的扫描不再深入展开,让我们继续回到我们的问题上来。

问题分析

当要回答我们的问题“断开引用提前释放对象内存”时,我们可以从安全点和GC根两方面进行分析:

  • 安全点

    • 安全点的相关内容可以参考另一篇博文,当Java虚拟机需要进行GC时必须通过安全点让Java线程暂停下来,而且安全点不会随意出现在任何Java代码中,它只会在某些比较固定的位置出现,所以在我们执行变量赋值为null的代码位置可能离安全点比较远,那么Java虚拟机在Java线程还未到达安全点前是无法提前触发GC的
  • GC根

    • 解释栈 我们从前面的分析可以得知Java的解释栈可以保存GC根,只要栈上存储的是存活对象的堆内存地址,那么它就会被GC根扫描时被扫描出来,所以把变量赋值为null确实可以帮助GC减少被扫描的GC根
    • 编译栈 由于JIT编译通过生成数据流图对Java对象的存活做分析后进行优化,它可以非常智能地知道那些Java对象已经不再使用,哪些Java对象后续还将被引用,所以变量赋值为null的操作看起来就是多余的

对这个问题的回答总结起来就是除非Java代码长期执行在解释栈上,不然没有必要显式地对已使用完的Java对象变量赋值为null,那么会有场景让Java代码长期执行在解释栈上吗?是的,有各种场景会阻碍Java代码被JIT编译,但是出现这些场景后我们都应该第一时间去优化掉这些阻碍,让解释执行不会变成常态,不然对性能的影响是非常巨大的。

更多推荐