内存安全翻车现场的罪魁祸首,根源就在这三步里?

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

术语约定:下文提到的无前缀修饰的 Runtime 是指 C/C++ 那种「甩手掌柜」------工具给你、逻辑你自己负责;不是 JVM 那种「啥都管」的接管型 Runtime

开场

丹尼尔:蛋兄,上回(请在各平台搜索"明明都是源码到CPU,各语言中间原理咋不是一个套路?")你以"内存安全"为维度列举了一些编程语言的设计思路,今天能跟我系统地讲讲"内存安全"吗?

蛋先生:当然!内存安不安全,其实就是程序怎么用内存的问题。程序用内存,就三步:先申请,再读写,最后用完释放。就这么个过程

复制代码
┌──────────┐     ┌──────────┐     ┌──────────┐
│① 申请内存 │ ──→ │② 读写内存 │ ──→ │③ 用完释放 │
└──────────┘     └──────────┘     └──────────┘

丹尼尔:听着挺简单的啊,咋就能出事呢?

蛋先生:如果这三步全都由程序员说了算(比如 C/C++ 里的堆内存),那问题就来了。你想想,程序员也是个凡人,他能保证每次都按流程走吗?

丹尼尔:应该......会吧?申请了再读,读完再释放,天经地义啊

蛋先生:( ╯▽╰) 他大概率不是故意的,但架不住写岔了呀!今天咱就按这简单的三步,把这些"翻车现场"挨个捋一遍

内存申请,如果忘记了会怎样?

丹尼尔:那先从第一步------内存申请开始,这步会出现啥问题呢?

蛋先生:如果你直接绕过这一步,你觉得会怎样?

丹尼尔:没申请就用?这也太离谱了吧!

蛋先生:在 C/C++ 里就有这种离谱的现象------野指针的一种来源:未初始化指针。你声明了一个局部的指针变量 int* p;,本意是让它指向一块申请好的堆内存。但你忘了申请,它就装着一个不确定的地址(通常是栈上该位置残留的旧数据)。然后你写 *p = 100;------指向的对象可能不存在,这跟"跳过申请去使用"是同一回事

丹尼尔:那会报错吗?

蛋先生:要看情况!如果那块地址不在当前进程的有效可写内存区域内,就会立刻触发错误崩溃;反之,它会悄悄篡改那块地址上的内存,表面一切正常,过很久才在别处炸开

内存使用,时间空间错了会怎样?

丹尼尔:那要是我申请了,是不是就安全了?

蛋先生:这就得引出两个维度了:空间维度和时间维度

丹尼尔:先说空间维度呗

蛋先生:打个比方,数组就像一排储物格,共 10 个,编号 0 到 9。你在堆上申请了这排格子,指针的起点没错------它就指向第 0 格,稳稳落在已分配的堆区里。但你偏移一算错,代码里写了 scores[10],访问地址直接飞到了格子区外面。偏偏旁边紧挨着的,是别人的格子

css 复制代码
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐        ┌─────┬─────┬─────┬─────┬─────┬─────┐
│ 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

相关推荐
Rain的Java大神之路1 小时前
手写一个Spring容器
java·后端·mysql·spring·面试
小七在进步1 小时前
C++入门(1)
java·jvm·c++
IT_陈寒1 小时前
明明设了默认值,为什么我的JavaScript函数参数还是undefined?
前端·人工智能·后端
晴天的雨.9921 小时前
[C++算法]快乐数
数据结构·c++·算法
影视飓风TIM1 小时前
C++11 核心新特性完整梳理
数据结构·c++·算法
艾莉丝努力练剑1 小时前
【AI大模型接入SDK】Ollama本地大语言模型部署
c++·人工智能·语言模型·自然语言处理·面试
晴天的雨.9921 小时前
[C++]算法双指针 复写0
数据结构·c++·算法
智购科技自动贩卖机2 小时前
自动售货机嵌入式系统安全加固实践:从Go语言国密算法到硬件防拆的工程化落地
大数据·人工智能·后端·安全·golang·系统安全
烂蜻蜓2 小时前
Flask入门教程(附录):模板渲染API——Jinja2集成全解析
后端·python·flask