Java对象赋值为null提高内存使用
引言
当年刚刚接触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 ... ] │
└──────────────────────────────────────────────┘
对应的关系图(栈 / 堆 / 元数据区):
关键要点小结:
| 区域 | 存放内容 | 与 oopDesc 的关系 |
|---|---|---|
| 栈 Stack | 局部变量 oop(即 oopDesc* 指针)、JNI handle 等 |
只存指针,不存对象实体;GC 时作为 Root 扫描 |
| 堆 Heap | oopDesc(含 _mark + _metadata)+ 子类实例字段 / 数组元素 |
真正的对象实体存放处 |
| Metaspace | Klass(类元数据) |
由 oopDesc::_metadata._klass 指向,描述对象类型 |
oop是oopDesc*的别名,所以源码中所有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对象的复制或晋升等操作,结合我们前面给出的示例也就是把地址值0x7ffff79fddc8和0x7ffff79fddc0传入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编译,但是出现这些场景后我们都应该第一时间去优化掉这些阻碍,让解释执行不会变成常态,不然对性能的影响是非常巨大的。
更多推荐

所有评论(0)