内存管理和 Rust 所有权

前言

前段时间带着好奇去看了一下 Rust 语言的教程,然后就看到了 Rust 中所有权的概念,看的时候就是一句卧槽脱口而出,居然还有这种操作?

感慨完了以后就联系了一下以前学过的一些知识,感觉可以思考总结一下内存管理的方式,于是,这篇博客便诞生了。

PS:这里十分推荐大家去看一下 Rust 官方的所有权 教程,讲得真的很好!

内存管理及常见问题

我们的程序运行时往往离不开 这两个内存空间,很多问题往往就是对这两个空间的不合理使用导致的,常见的有:

  • 内存溢出 - 指程序运行过程中申请的内存大于系统能够提供的内存,导致无法申请到足够的内存
  • 内存泄漏 - 指程序运行过程中申请的内存空间没有被正确释放,导致后续程序里这块内存被永远占用
  • 越界访问 - 指程序运行过程中非法访问了分配的内存区域以外的内存区域,导致很严重的安全问题

而怎样安全合理的利用 中的空间便是 内存管理 需要考虑的问题,其中,常见的两种管理方式为:

  • C/C++ 这类底层语言中的手动管理方式,不仅对 堆内存 操作给与了极大的自由,对于 栈内存 的访问也是极其放肆
  • Java/Python/JavaScript 这类高级语言中的垃圾回收机制,通过垃圾回收完成内存的自动管理

然后,便是 Rust 中的所有权方式了,通过巧妙的方式在编译时便解决了内存管理中的很多问题。

这些方式各有各的优势,但又存在各自的问题,不能说那个绝对比另一个好,只能说,各自存在适合自己的领域。

垃圾回收机制

本来想先写 手动管理内存 的,然后才发现,手动管理内存中的很多问题和优势都是通过和 垃圾回收机制 对比出来的,于是,只能先讲垃圾回收机制了。

就我目前的经验来说,使用 垃圾回收机制 的编程语言可以分为两种类型:

  • 强类型 的编程语言,由于可以通过类型系统确定基本数据类型(数值、布尔、字符)的大小,因此为了加快访问速度,往往会将基本数据类型的值放在 栈内存
  • 弱类型 的编程语言,由于缺少类型系统的原因,因此这些语言往往无法直接确定对象的大小,只能将所有对象都保存在 堆内存 中,而在 栈内存 中保存大小固定的 引用

修@2020-01-17:这里存在错误,并不是所有弱类型编程语言都会将所有对象保存在堆内存中,或者说,这和语言的类型没有关系,但通常来说,对象一般都会放到堆内存中,而将引用放在栈里面

但是不管是 强类型 的还是 弱类型 的语言,它们都可以保证在 栈内存 中保存的是大小确定的值,这样一来,便尽可能的避免了 栈内存 空间的越界访问,而堆中的对象, 又可以通过一系列的措施检查避免越界访问。

因此,对于拥有 垃圾回收机制 的语言来说,它们关注的主要问题便是 堆内存 空间的使用和管理。由于内存溢出的特殊性,于是乎,主要的问题就变成了如何避免内存泄漏。

而垃圾回收,便是指通过回收在 堆中 已经没有用的对象来回收堆中内存,达到避免内存泄漏的目的,因此,这里需要解决的问题便变成了如何判断一个对象已经是没有用的!

引用计数和循环引用

我们都知道,当我们通过 obj.attr 的方式访问对象时,其实就是通过 obj 这个 引用 来访问对象,那么,当一个对象的引用不存在了以后,这个对象不就无法访问 - 没有用了吗?

因此,一个很直接的想法便出现了,那就是通过 引用计数 的方式来判断一个对象是否有效,当一个对象的 引用计数 变为 0 时,垃圾回收器便可以回收该对象。

然而,这样的操作方式虽然很简单,但还是存在一些问题,其中,最为著名的大概就是 循环引用 问题了。

当引用位于 上时,可以随着栈的退出而失效,使得引用计数减一,但是,假如引用本身就是对象的属性呢?由于对象是保存在堆上的,因此,当对象的属性是引用时, 要让该引用失效就只有等垃圾回收器回收该对象。

那么,问题来了,如果出现 obj.attr = obj 的情况咋办?

根可达性算法

为了避免循环引用的问题,Java 采用了 根可达性算法 来进行垃圾回收,该算法将所有无法通过 根对象 达到的对象视为无效对象,这些对象包括栈中的局部变量和全局的静态变量和常量。

通过这种方式,无法从局部变量或全局变量达到的对象,那么也就没有存在的必要了,垃圾回收器也就可以干掉它们。这时循环引用也就不存在问题了:

但是,这也不意味着 Java 就不存在内存泄漏的问题了,比如说:

问题

虽然说垃圾回收机制能够让程序员从复杂的内存管理中解脱出来,但也还是导致了一些问题,最直接的便是性能问题,使用垃圾回收机制的语言往往都需要运行在虚拟机/解释器上, 由于中间多了一层东西的原因,使得这些语言的运行速度多少还是受到了影响。

手动管理内存

我学习的第一个编程语言是 C 语言,虽然现在很多人都不推荐使用 C 语言作为入门语言,但是不得不说,C 语言本身的语法大概是我学过的所有语言中最简单的一个了。

而 C 语言中的内存管理方式便是手动管理,程序员手中直接就掌握了整个内存空间的生杀大权,只要你想,你就可以在内存空间中反复横跳。

首先是栈内存的使用,和拥有垃圾回收机制的编程语言不同,C 语言中各种值默认都是是存在 栈内存 中的,类型的作用往往就只是:

  1. 确定你要访问的内存大小
  2. 确定解释该内存空间中的值的方式

这时,对于普通的数值还好,但要是涉及到 数组指针 操作,稍不注意就是一个越界访问,而且还是栈上的越界访问,很容易让有心之人有机可乘:

int* ptr = &var + 1;  // 只需要在取址后偏移一点,就可以访问存储该值以外的栈内存空间了

然后是堆内存空间,越界访问就不说了,由于堆内存空间的申请和释放完全由程序员自己来完成,很容易就会造成内存泄漏。

简单来说,就是在 C 语言这样的底层语言中,内存管理中的常见问题都是很容易出现的,而且极其依赖于程序员本身的素质,程序员自身能力不过关, 写出来的程序很有可能就存在各种各样的问题。

但是,在明白了 C 语言其实是 “弱类型” 的语言后,你才会发现,C 语言中这自由的内存操作是很爽的,比如说,直接申请一大段的堆内存,然后用你想用的方式去操作它:

void* ptr = (int*) malloc(sizeof(int) * 1000);

((int*) ptr + 1);       // int 宽度访问
((struct node*) ptr);   // struct node 宽度访问

虽然没什么用,但是,很爽啊 ( ̄▽ ̄),而且,这样的自由度,在大佬手里,完全是可以玩出花来的。

而且,C 语言这样的底层语言的运行速度往往是要快一点的,这在对性能要求比较高的时候就很有用了。

Rust 所有权

虽然说 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 中的所有权更是让人耳目一新,虽然说现在的主流还是垃圾回收 ‍╮( ̄▽ ̄)╭

参考链接

posted @ 2019-12-29 15:43  rgb-24bit  阅读(637)  评论(0编辑  收藏  举报
隐藏