
温馨提示:另有配套视频版,附带图解演示,观感不同,欢迎到各大视频平台搜索同名标题或【蛋先生说识】
术语约定:下文提到的无前缀修饰的
Runtime是指 C/C++ 那种「甩手掌柜」——工具给你、逻辑你自己负责;不是 JVM 那种「啥都管」的接管型 Runtime
开场
丹尼尔:蛋兄,上回(请在各平台搜索"明明都是源码到CPU,各语言中间原理咋不是一个套路?")你以"内存安全"为维度列举了一些编程语言的设计思路,今天能跟我系统地讲讲"内存安全"吗?
蛋先生:当然!内存安不安全,其实就是程序怎么用内存的问题。程序用内存,就三步:先申请,再读写,最后用完释放。就这么个过程
┌──────────┐ ┌──────────┐ ┌──────────┐
│① 申请内存 │ ──→ │② 读写内存 │ ──→ │③ 用完释放 │
└──────────┘ └──────────┘ └──────────┘
丹尼尔:听着挺简单的啊,咋就能出事呢?
蛋先生:如果这三步全都由程序员说了算(比如 C/C++ 里的堆内存),那问题就来了。你想想,程序员也是个凡人,他能保证每次都按流程走吗?
丹尼尔:应该……会吧?申请了再读,读完再释放,天经地义啊
蛋先生:( ╯▽╰) 他大概率不是故意的,但架不住写岔了呀!今天咱就按这简单的三步,把这些"翻车现场"挨个捋一遍
内存申请,如果忘记了会怎样?
丹尼尔:那先从第一步——内存申请开始,这步会出现啥问题呢?
蛋先生:如果你直接绕过这一步,你觉得会怎样?
丹尼尔:没申请就用?这也太离谱了吧!
蛋先生:在 C/C++ 里就有这种离谱的现象——野指针的一种来源:未初始化指针。你声明了一个局部的指针变量 int* p;,本意是让它指向一块申请好的堆内存。但你忘了申请,它就装着一个不确定的地址(通常是栈上该位置残留的旧数据)。然后你写 *p = 100;——指向的对象可能不存在,这跟"跳过申请去使用"是同一回事
丹尼尔:那会报错吗?
蛋先生:要看情况!如果那块地址不在当前进程的有效可写内存区域内,就会立刻触发错误崩溃;反之,它会悄悄篡改那块地址上的内存,表面一切正常,过很久才在别处炸开
内存使用,时间空间错了会怎样?
丹尼尔:那要是我申请了,是不是就安全了?
蛋先生:这就得引出两个维度了:空间维度和时间维度
丹尼尔:先说空间维度呗
蛋先生:打个比方,数组就像一排储物格,共 10 个,编号 0 到 9。你在堆上申请了这排格子,指针的起点没错——它就指向第 0 格,稳稳落在已分配的堆区里。但你偏移一算错,代码里写了 scores[10],访问地址直接飞到了格子区外面。偏偏旁边紧挨着的,是别人的格子
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐ ┌─────┬─────┬─────┬─────┬─────┬─────┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │══════▶│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘ └─────┴─────┴─────┴─────┴─────┴─────┘
↑ ↑
│ scores[0] 指针起点,稳稳落在自己区 │ scores[10] 越界!飞进邻居格子区
│ │
你申请的区域:0 ~ 9 邻居的区域:10 ~ …
丹尼尔:哇塞!这不是偷窥吗?
蛋先生:还有更厉害的——"越界写"。还是这排格子,你写上 scores[10] = 100 这句代码,就能往邻居格子里乱涂乱画了
丹尼尔:这么狠的吗?
蛋先生:这些操作,程序表面上可能一切正常运行,也可能直接崩溃,这种问题,排查起来能把人逼疯
丹尼尔:好家伙!那时间维度又是咋回事?
蛋先生:这就是大名鼎鼎的数据竞争(Data Race)了。多个线程并发读写同一块内存,其中至少一个是写操作,又没有加锁、也没用原子操作之类的同步手段
丹尼尔:同时抢一块地儿,会怎样?
蛋先生:谁先谁后,全看造化。结果就是程序时好时坏,极难复现咯
内存释放,如果忘记了会怎样?
丹尼尔:那释放这一步,我掐指一算,应该就是忘了释放导致的问题是吧
蛋先生:果然神机妙算!哈哈!这就是"慢性病"——内存泄漏(Memory Leak)。前台登记本上永远标记着"已住",这块地儿就永远空不出来了
丹尼尔:那最后岂不是要爆了?
蛋先生:对喽!程序正常运行,但内存占用持续飙升。直到某天系统忍无可忍,无需再忍,直接给你一个 Out Of Memory,把进程直接带走
释放了,我再访问会怎样?
丹尼尔:还有没有更狠的?
蛋先生:最玄乎的,压轴登场——释放后再访问,专业术语叫 Use After Free(UAF)
丹尼尔:释放了再访问?不就相当于没申请就访问吗?为何单独拎出来讲?
蛋先生:你认为的释放,是不是内存数据被清理了,空间还回去了?
丹尼尔:不然呢?
蛋先生:但实际上,释放只是"逻辑删除",Runtime 只是在"内存管理账本"上改一下空闲标记位
丹尼尔:啊!我都释放了,为啥数据不清理呢?
蛋先生:都是为了性能。数据为啥不擦呢?因为挨个字节擦一遍太费 CPU了,所以 Runtime 干脆懒得擦;那空间为啥不还呢?因为小块内存归还给操作系统也得来回折腾,不如留在手里复用。等下次有人申请到这块地,新数据一写,把旧的直接盖掉就完事了
丹尼尔:这么说,释放后再访问,是可以访问到数据咯?
蛋先生:没错,但访问到什么数据,全看人品!运气好读到旧数据(看着正常),运气差读到新对象的数据(脏数据),运气更差直接崩溃。这是极难排查的"幽灵问题"
丹尼尔:你刚刚提到"内存管理账本",是在哪里实现的?
蛋先生:像 C++ 这种能让程序员显式摸到内存的语言,分配和释放内存的工具都是 Runtime 提供的。所以 Runtime 手里攥着一本"内存账本",记录哪块被占了、哪块空着
丹尼尔:但好像它只记账啊,没啥实质作用。你看,释放时它又不清理数据
蛋先生:没错,这本账只为了指导"内存复用",也就是下次分配时好知道该把哪块地给你
丹尼尔:那读写的时候,它怎么不翻翻账本防着点?
蛋先生:翻账本是要花时间的!So,为了极致性能,读写时根本没有查账本的逻辑,就信程序员手里的指针,指哪打哪
所以,该怎么办?
丹尼尔:哎,照这么看,要想搞好内存安全问题,对程序员的要求还是挺高的啊
蛋先生:所以聪明的语言们就换了个思路——不让程序员显式碰内存!"接管型 Runtime"因此而生
丹尼尔:接管了会怎样?
蛋先生:分配、访问、释放,全由 Runtime 一手包办,程序员压根没有机会跳过分配、越界乱写、忘了释放。这下好了,那些翻车现场理论上就可以从根上就杜绝了
丹尼尔:妙啊!那具体怎么接管的?
蛋先生:嘿嘿,且听下回分解!
写在最后
亲们,都到这了,要不,点赞 / 收藏 / 关注支持下呗 o( ̄▽ ̄)d