tkernel 2019-12-29
前段时间带着好奇去看了一下 Rust
语言的教程,然后就看到了 Rust
中所有权的概念,看的时候就是一句卧槽脱口而出,居然还有这种操作?
感慨完了以后就联系了一下以前学过的一些知识,感觉可以思考总结一下内存管理的方式,于是,这篇博客便诞生了。
PS:这里十分推荐大家去看一下 Rust
官方的所有权 教程,讲得真的很好!
我们的程序运行时往往离不开 栈 和 堆 这两个内存空间,很多问题往往就是对这两个空间的不合理使用导致的,常见的有:
而怎样安全合理的利用 栈 和 堆 中的空间便是 内存管理 需要考虑的问题,其中,常见的两种管理方式为:
C/C++
这类底层语言中的手动管理方式,不仅对 堆内存 操作给与了极大的自由,对于 栈内存 的访问也是极其放肆Java/Python/JavaScript
这类高级语言中的垃圾回收机制,通过垃圾回收完成内存的自动管理然后,便是 Rust
中的所有权方式了,通过巧妙的方式在编译时便解决了内存管理中的很多问题。
这些方式各有各的优势,但又存在各自的问题,不能说那个绝对比另一个好,只能说,各自存在适合自己的领域。
本来想先写 手动管理内存 的,然后才发现,手动管理内存中的很多问题和优势都是通过和 垃圾回收机制 对比出来的,于是,只能先讲垃圾回收机制了。
就我目前的经验来说,使用 垃圾回收机制 的编程语言可以分为两种类型:
但是不管是 强类型 的还是 弱类型 的语言,它们都可以保证在 栈内存 中保存的是大小确定的值,这样一来,便尽可能的避免了 栈内存 空间的越界访问,而堆中的对象, 又可以通过一系列的措施检查避免越界访问。
因此,对于拥有 垃圾回收机制 的语言来说,它们关注的主要问题便是 堆内存 空间的使用和管理。由于内存溢出的特殊性,于是乎,主要的问题就变成了如何避免内存泄漏。
而垃圾回收,便是指通过回收在 堆中 已经没有用的对象来回收堆中内存,达到避免内存泄漏的目的,因此,这里需要解决的问题便变成了如何判断一个对象已经是没有用的!
我们都知道,当我们通过 obj.attr
的方式访问对象时,其实就是通过 obj
这个 引用 来访问对象,那么,当一个对象的引用不存在了以后,这个对象不就无法访问 - 没有用了吗?
因此,一个很直接的想法便出现了,那就是通过 引用计数 的方式来判断一个对象是否有效,当一个对象的 引用计数 变为 0
时,垃圾回收器便可以回收该对象。
然而,这样的操作方式虽然很简单,但还是存在一些问题,其中,最为著名的大概就是 循环引用 问题了。
当引用位于 栈 上时,可以随着栈的退出而失效,使得引用计数减一,但是,假如引用本身就是对象的属性呢?由于对象是保存在堆上的,因此,当对象的属性是引用时, 要让该引用失效就只有等垃圾回收器回收该对象。
那么,问题来了,如果出现 obj.attr = obj
的情况咋办?
为了避免循环引用的问题,Java 采用了 根可达性算法 来进行垃圾回收,该算法将所有无法通过 根对象 达到的对象视为无效对象,这些对象包括栈中的局部变量和全局的静态变量和常量。
通过这种方式,无法从局部变量或全局变量达到的对象,那么也就没有存在的必要了,垃圾回收器也就可以干掉它们。这时循环引用也就不存在问题了:
但是,这也不意味着 Java 就不存在内存泄漏的问题了,比如说:
虽然说垃圾回收机制能够让程序员从复杂的内存管理中解脱出来,但也还是导致了一些问题,最直接的便是性能问题,使用垃圾回收机制的语言往往都需要运行在虚拟机/解释器上, 由于中间多了一层东西的原因,使得这些语言的运行速度多少还是受到了影响。
我学习的第一个编程语言是 C
语言,虽然现在很多人都不推荐使用 C
语言作为入门语言,但是不得不说,C 语言本身的语法大概是我学过的所有语言中最简单的一个了。
而 C 语言中的内存管理方式便是手动管理,程序员手中直接就掌握了整个内存空间的生杀大权,只要你想,你就可以在内存空间中反复横跳。
首先是栈内存的使用,和拥有垃圾回收机制的编程语言不同,C 语言中各种值默认都是是存在 栈内存 中的,类型的作用往往就只是:
这时,对于普通的数值还好,但要是涉及到 数组 和 指针 操作,稍不注意就是一个越界访问,而且还是栈上的越界访问,很容易让有心之人有机可乘:
int* ptr = &var + 1; // 只需要在取址后偏移一点,就可以访问存储该值以外的栈内存空间了
然后是堆内存空间,越界访问就不说了,由于堆内存空间的申请和释放完全由程序员自己来完成,很容易就会造成内存泄漏。
简单来说,就是在 C 语言这样的底层语言中,内存管理中的常见问题都是很容易出现的,而且极其依赖于程序员本身的素质,程序员自身能力不过关, 写出来的程序很有可能就存在各种各样的问题。
但是,在明白了 C 语言其实是 “弱类型” 的语言后,你才会发现,C 语言中这自由的内存操作是很爽的,比如说,直接申请一大段的堆内存,然后用你想用的方式去操作它:
void* ptr = (int*) malloc(sizeof(int) * 1000); ((int*) ptr + 1); // int 宽度访问 ((struct node*) ptr); // struct node 宽度访问
虽然没什么用,但是,很爽啊 ( ̄▽ ̄),而且,这样的自由度,在大佬手里,完全是可以玩出花来的。
而且,C 语言这样的底层语言的运行速度往往是要快一点的,这在对性能要求比较高的时候就很有用了。
虽然说 C 语言的速度很快,但是其内存管理完全依赖于程序员自身,安全隐患太大,而垃圾回收机制又会降低运行速度,于是乎,Rust 中的所有权概念便出现了。
Rust 中的所有权是围绕作用域打造的一种内存管理方式,在大多数语言中,局部变量和引用在离开其作用域后便失效了,其所占据的内存便被回收,但由于对象可以存在 多个引用 的原因, 因此,往往需要在对象所有引用失效后才可以被回收。
但是 Rust 换了一种思路,它让每个值只拥有 一个 所有者,当所有者离开作用域后,该值便失效:
{ // s 在这里无效, 它尚未声明 let s = "hello"; // 从此处起,s 是有效的 // 使用 s } // 此作用域已结束,s 不再有效
为了保证一个值只拥有一个所有者这一点,Rust 通过编译器对代码的编写增加了诸多限制,其中一个便是所有权的转移,当发生以下情况之一时所有权便会转移,原有变量不在拥有所有权:
// 1. 赋值时所有权转移到 s2 上,s1 不在有效 let s1 = String::from("hello"); let s2 = s1; // 2. 作为函数参数传递时,s 的所有权转移到函数内部,s 失效 let s = String::from("hello"); takes_ownership(s); // 3. 函数的返回值将所有权转移给它的接受者 fn gives_ownership() -> String { let some_string = String::from("hello"); return some_string; }
使用已经失去所有权的变量的时候 编译器 会给出错误,这样,便在编译时解决了内存管理的问题:
let s1 = String::from("hello"); let s2 = s1; println!("{}, world!", s1); // error[E0382]: use of moved value: `s1` // --> src/main.rs:5:28 // | // 3 | let s2 = s1; // | -- value moved here // 4 | // 5 | println!("{}, world!", s1); // | ^^ value used here after move // | // = note: move occurs because `s1` has type `std::string::String`, which does // not implement the `Copy` trait
这真的是一种很清奇的思路,这样做的最大的好处就是即保留了底层语言的运行速度(不需要虚拟机/解释器),又在一定程度上解决了内存管理的问题。
但问题就是,这样的编写代码的方式让人很是不习惯,为了方便一点,就需要使用其他的东西,比如引用,但随之又会带来其他的问题。
总的来说,三种内存管理方式各有各的优势与缺点,其中 Rust 中的所有权更是让人耳目一新,虽然说现在的主流还是垃圾回收 ?╮( ̄▽ ̄)╭