
温馨提示:另有配套视频版,附带图解演示,观感不同,欢迎到各大视频平台搜索同名标题或【蛋先生说识】
开场
丹尼尔:蛋兄,上回您说"接管型 Runtime"能包办内存安全,还撂下一句"且听下回分解"。我这"下回"可等了一个星期了啊!(没看过上回的,可在各平台搜索"内存安全翻车现场的罪魁祸首,根源就在这三步里?")
蛋先生:哈哈,行,那今天就唠这个!具体怎么包办,还得看语言最终走的是哪条运行路线。咱先按主要的执行方式分成两类------字节码解释型和机器码编译型(说明:这里仅仅是为了介绍原理设计,实际上不少现代语言是混合的,比如 Java 会先用解释器,热点再 JIT 编译)
先分清两种"包办"的姿势
丹尼尔:怎么区分?
蛋先生:有的语言,程序跑的是字节码,而字节码指令的具体实现都在 Runtime 里,由它来解释执行------既然每条指令都是它亲手执行的,内存管理自然也就由它直接上手管了。这就是"字节码解释型"(以下简称解释型)
丹尼尔:那"机器码编译型"(以下简称编译型)呢?
蛋先生:编译型就稍麻烦一点!代码被直接编译成 CPU 能执行的机器码,CPU 自己就能按顺序执行,执行指令流本身不再需要 Runtime 逐条调度。但内存分配怎么办呢?Runtime 想接管,只能换一招------"替换用户代码"
丹尼尔:哦?一个是"我直接管",一个是"我偷偷改你的代码"?
蛋先生:( ╯▽╰) 总结到位!那咱就先看"直接管"的解释型
解释型:指令都归我解释,内存自然归我管
丹尼尔:那解释型具体是怎么把内存管起来的?
蛋先生:先解释下啥叫解释型。它解释的是"字节码所描述的指令"------注意,字节码不是机器码指令,CPU 不认,得由 Runtime 来解释执行。既然每条指令的实现都攥在 Runtime 手里,那内存管理的活,自然也只有它能干
丹尼尔:明白,但我想知道"怎么管"
蛋先生:那咱就拿一句最经典的"申请内存"的代码开刀,把「源码 → AST → 字节码 → Runtime 真的申请到内存」这条链路从头串到尾,保准你一看就懂
丹尼尔:成!走起!
✧ 源码:一行代码,暗含三步
蛋先生:就这句,写 Java 的没人不认识吧
java
Foo foo = new Foo();
丹尼尔:像我这种不怎么写 Java 的也看得懂啊,各语言的语法都差不多嘛
蛋先生:这句看似简单的代码,其实暗含三步:先在堆上申请一块能装下 Foo 实例的内存,再调用构造方法做初始化,最后把对象引用交给变量 foo
丹尼尔:我平时倒是没看得这么深
蛋先生:有没有发现,"申请内存"这件事,程序员甚至可以完全不关心------因为真正动手的是 Runtime
✧ AST:把意图"解析"成一棵树
丹尼尔:那我倒想看看,这行源码接下来都要经历哪些环节?
蛋先生:第一步,编译器会先按语法规则把它解析成一棵树,语义没变,但结构化了,这就是 AST(抽象语法树)。刚才那句代码,大致长这样
text
VariableDeclarationStatement // 一条"声明变量"的语句
├── type: SimpleType "Foo" // 变量类型
└── fragments
└── VariableDeclarationFragment
├── name: "foo" // 变量名
└── initializer // 初始化器 = 下面的 new Foo()
└── ClassInstanceCreation
├── type: SimpleType "Foo"
└── arguments: []
丹尼尔:哦!结构化之后,机器确实更容易处理了
蛋先生:对!还记得上次咱们说过,为了避免反复遍历这棵树的开销,需要怎么做吗?
✧ 字节码:翻译成 Runtime 能听懂的话
丹尼尔:把它拍平成可执行的字节码指令呗
蛋先生:没错!编译器接着把这棵树"拍平"成 Runtime 认识的语言------字节码。刚才那句源码,大致就对应这几条 JVM 字节码
text
0: new #2 // class Foo ← 申请对象内存,引用入栈
3: dup // ← 复制引用(构造器调用会消耗一份)
4: invokespecial #3 // Method Foo."<init>":()V ← 调用构造方法做初始化
7: astore_1 // ← 把引用存入局部变量 foo
丹尼尔:目前看,还没看到"申请内存"的显式指令
蛋先生:是的,申请内存的逻辑,就藏在 new 指令的实现里
丹尼尔:那,瞧一眼呗
✧ Runtime 对字节码 new 的实现:终于轮到我出手
蛋先生:安排!接管型 Runtime 会预先为每一条字节码指令写好解释逻辑。new 的实现大致长这样(伪代码示意)
cpp
void opcode_new(u2 class_index) {
// 1. 从常量池查出要创建的是哪个类(Klass = 类的运行时结构)
Klass* cls = constant_pool->klass_at(class_index);
// 2. 首次使用时触发类加载:把 class 文件变成运行时结构
if (!cls->is_loaded()) {
load_class(cls);
}
// 3. 检查堆空间,不够就先触发 GC(对象都是它管的,它可以先清理再分配)
while (heap->free_size() < cls->instance_size()) {
gc();
}
// 4. 申请内存:从 Runtime 自己掌管的那块堆上划出一块
oop obj_ref = heap->allocate(cls->instance_size());
// 5. 零值初始化:字段先全部填默认值(数值 0 / 引用 null)
zero_fill(obj_ref, cls->instance_size());
// 6. 把对象引用压入操作数栈,交给下一条指令(dup/invokespecial)继续处理
operand_stack.push(obj_ref);
}
丹尼尔:哇~!看到了!申请内存、触发 GC,全都在这段实现里
蛋先生:(≧∇≦) 对!因为对象全是它管的,堆不够了就先清理再分配,主动权全在它手里
✧ 完整链路:从意图到内存
丹尼尔:明白了,new Foo() 只是往 Runtime 那儿递了张"我要个 Foo"的申请单,后面怎么处理,全由 Runtime 接管
蛋先生:正解!真正的内存申请动作,被完整封装在 Runtime 解释器里------它不把这套逻辑交给用户,内存的分配、释放节奏就完全在它掌控之中,这就是解释型里"接管"的真相。解释型的主流程大致如下:
text
源码 new Foo() → AST(语法树) → 字节码 new(指令编号) → Runtime 的 opcode_new() → 从堆上申请到内存
编译型:代码都成机器码了,Runtime 咋插一脚?
丹尼尔:解释型我算看明白了。可编译型咋整?像 Go 那样,源码直接编译成机器码,CPU 自己就能执行,根本不用 Runtime 来调度------那它又是怎么接管内存的呢?
蛋先生:所以编译型得换思路------它没有"Runtime 统一逐条解释"的字节码,Runtime 想接管,只能在"生成机器码"这一环节做手脚
丹尼尔:咋整?
蛋先生:无论是 AOT(提前编译)还是 JIT(即时编译),编译器在生成机器码时,都知道 Runtime 的内存分配接口,于是会在 new Foo() 这类地方自动植入内存管理的代码------相当于替 Runtime 在用户代码里进行"埋点"。还是用那句 Foo foo = new Foo(); 来演示,方便你跟刚才的解释型对比着看
✧ 内联方式(快路径):把分配直接内联成几条 CPU 指令
丹尼尔:您继续!
蛋先生:最常见的情况,是当前线程私有的 TLAB(线程本地分配缓冲区,Thread-Local Allocation Buffer)里还有空余
丹尼尔:等等,TLAB 又是啥新词?
蛋先生:你是不是经常给你家小孩一点零花钱?
丹尼尔:嗯,跟这有啥关系?
蛋先生:所以你家小孩买点小零食的时候,是不用来找你的,自己兜里的小零钱就够了。把你比作 Runtime,那 TLAB 就是你提前塞给你小孩(线程)的"零钱"。这种小内存分配起来比较简单,编译器就索性把"分配"展开成几行机器码,直接插进你的方法体里(x86-64 伪汇编示意)
asm
; JIT 编译 `Foo foo = new Foo();` 生成的机器码(x86-64 伪汇编示意)
; rdx = 当前线程的 TLAB 结构
mov rax, [rdx + TLAB.top] ; 1. 取 TLAB 当前水位(下一块内存从哪开始)
mov rcx, rax
add rcx, 32 ; 2. 32 = Foo 实例大小,算出划完后的新水位
cmp rcx, [rdx + TLAB.end] ; 3. 会超出 TLAB 上界吗?
jg slow_path ; 超出 → 跳去"函数调用方式"处理
mov [rdx + TLAB.top], rcx ; 4. 水位前移 = 这块内存被划走了
丹尼尔:哇塞!整个"申请内存"的过程,就是几行 CPU 指令?
蛋先生:没错!所以它叫"内联 / 快路径"。但这块内存可不是用户自己拿的------它来自 Runtime 提前塞给线程的"零钱"。Runtime 只是把最频繁的小额分配"预授权"给了线程,让它几纳秒内自己划完,省去每次分配都要进 Runtime 的开销
✧ 函数调用方式(慢路径):把分配替换成一次"call 进 Runtime"
丹尼尔:那要是"额度"不够用呢?
蛋先生:你小孩零花钱不够,不是得找回你吗?所以就需要把这次分配替换成一次真正的函数调用------直接 call 进 Runtime 的 C++ 代码里,这就是"慢路径"
asm
; 慢路径:情况变复杂了,把控制权交回 Runtime
slow_path:
call runtime_allocate_object ; call 进入 Runtime(C++ 实现的)
; 返回后 rax = 新对象地址
丹尼尔:这看起来就是个普通的分配函数嘛
蛋先生:关键在调用方式!这个 call 不是用户主动调的,是编译器在生成机器码时替用户埋好的------你写的明明还是那句 new Foo(),机器码里却悄悄多了一次 call 进 Runtime
两种姿势,一个目的
丹尼尔:这下全明白了,舒服。来,蛋兄,收个尾呗
蛋先生:好!解释型:指令的实现全在 Runtime,内存分配由它亲手操办;编译型:机器码虽由 CPU 直接执行,但靠"内联分配 + 替换成函数调用",内存照样被它管得死死的
丹尼尔:一句话总结:甭管哪种姿势,核心就一条------"申请内存"这个动作,永远轮不到用户亲手碰
蛋先生:说得漂亮!这,就是"接管"的全部真相了
写在最后
亲们,看到这,不妨在评论区聊聊:你平时最常写的编程语言是哪一款?它身上有"接管型 Runtime"吗?有的话,它采用的是本文说的哪种姿势------"解释型直接管","编译型埋点偷偷管",还是两者结合的"混合姿势"?欢迎带上你熟悉的语言来现身说法
都到这了,要不,顺手点赞 / 收藏 / 关注支持下呗? o( ̄▽ ̄)d