Java和Go都拍胸脯说"内存我包了",可它们到底是怎么下手的?

温馨提示:另有配套视频版,附带图解演示,观感不同,欢迎到各大视频平台搜索同名标题或【蛋先生说识】

开场

丹尼尔:蛋兄,上回您说"接管型 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

相关推荐
SimonKing1 小时前
阅后即焚的加密便签:Cryptgeon,你口袋里的秘密信使
java·后端·程序员
ss2731 小时前
Java全栈实战 | 1.3-03 索引原理:联合索引 (a,b,c) 只查 b 走不了索引?B+ 树一图讲透所有索引玄学
java·数据库
前端初见2 小时前
Java+AI零基础入门到大牛
java·spring boot·spring·java-ee·intellij-idea
猫猫不是喵喵.2 小时前
两个不同应用Session能互相访问解决
java
带多刺的玫瑰2 小时前
Leecode#35刷题之搜索插入位置
java·python·算法
一条泥憨鱼2 小时前
苍穹外卖【day10| 来电提醒和客户催单】
java·后端·websocket·苍穹外卖
SunnyDays10112 小时前
Java 设置 Excel 数据验证:整数、日期、文本长度、下拉列表和时间
java·excel·数据验证·下拉列表
编程_大白2 小时前
@SpringBootTest
java
刘天远2 小时前
Agent最小权限实现:策略表、临时令牌与撤权回归
java·jvm·人工智能·sqlite