Java学渣学习 Go 的内存管理| 青训营笔记

120 阅读10分钟

Java学渣学习 Go 的内存管理| 青训营笔记

前言

这是我参加参加字节跳动第六届青训营所做的第三篇笔记,我们来学一下Go 的内存管理机制。

性能优化

自动内存管理

在Go语言中,自动内存管理的概念是指通过垃圾回收来动态分配内存。垃圾回收可以让我们不必手动释放内存,从而把注意力集中在业务逻辑上,同时还能避免内存安全问题,如双重释放或释放后继续使用的问题。

一个垃圾回收周期一般包括三个主要任务:为新对象分配内存空间,找到仍然存活的对象,回收已经不再使用的对象的内存空间。

要深入了解垃圾回收,需要先了解一些相关概念:

  • Mutator:指的是业务线程,负责分配新对象并修改对象指向关系。
  • Collector:指的是垃圾回收线程,负责找到存活的对象并回收不再使用的对象的内存空间。
  • Serial GC(串行垃圾回收):只有一个Collector的垃圾回收算法。
  • Parallel GC(并行垃圾回收):支持多个Collector同时工作的垃圾回收算法。
  • Concurrent GC(并发垃圾回收):Mutator线程和Collector线程可以同时执行的垃圾回收算法。

要评价一个垃圾回收算法,可以考虑以下几个方面:

  • 安全性:指垃圾回收器不应该回收仍然存活的对象。
  • 吞吐率:指垃圾回收占程序执行总时间的比例。
  • 暂停时间:指垃圾回收导致业务线程被挂起的时间(垃圾回收导致的暂停称为停止世界,STW)。
  • 内存开销:指垃圾回收器本身占用的内存空间。

接下来,我将简要介绍一些经典的垃圾回收算法。

追踪垃圾回收

追踪垃圾回收(Tracing Garbage Collection)是一种常见的垃圾回收方法,主要通过追踪对象之间的引用关系确定哪些对象应该被释放(即垃圾回收),并将剩余的对象视为垃圾并进行回收。在Go语言中,也采用了这种追踪垃圾回收算法。

简而言之,追踪垃圾回收的工作原理如下:

首先,标记根对象,这些根对象可以是静态变量、全局变量、常量、线程栈等; 接着,从根对象开始,寻找所有通过引用链与根对象关联的可达对象; 最后,清理所有不可达对象,这个过程包括三个步骤:复制存活对象至另一个内存空间(复制回收算法)、将死亡对象的内存标记为可重用状态(标记清除算法)以及移动和整理存活对象(标记整理算法)。

根据对象的生命周期,垃圾回收器可能会使用不同的标记和清理策略。

分代 GC

和Go语言一样,Java也支持垃圾回收,并且其主流垃圾回收器G1GC(Garbage First Garbage Collector)是一种分代垃圾回收器(Generational GC)。

分代垃圾回收的设计基于分代假设(Generational hypothesis),即大多数对象在短时间内就会死亡,即分配后很快就不再使用。通过为年轻代和年老代对象(年老代对象指的是经历过多次GC的对象)指定不同的回收策略,可以降低整体内存管理的开销。

对于年轻代对象,可以采用复制收集算法(copying collection),以提高垃圾回收的吞吐率。而对于年老代对象,则可以采用标记-清除收集算法(mark-sweep collection)。

引用计数

确定对象是否需要回收的另一种方式是引用计数(Reference Counting),它为每个对象维护一个与之相关的引用计数。只有当引用计数大于0时,对象才被标记为存活,否则,对象将被回收。

引用计数方案的优点是,内存管理操作被平均分布在程序执行过程中(在创建对象或将对象添加到集合时增加引用计数,在销毁对象或从集合中移除对象时减少引用计数),并且内存管理不需要了解底层的实现细节(例如C++的智能指针)。

然而,引用计数方案的缺点是维护引用计数的开销较大(因为引用计数操作必须是原子操作),它无法回收环形数据结构(因为所有对象直接或间接地相互引用),每个对象额外引入内存来存储引用计数,回收内存时可能仍然会导致暂停等问题。

手动内存管理

相较于手动内存管理,自动垃圾回收无疑是一种进步。它成功地解放了开发者的生产力,使他们能够将注意力集中在业务代码上,而不是为内存分配和释放而烦恼。然而,或许你不知道的是,Go语言在1.20版本引入了实验性的arenas系统,允许开发者手动申请一块连续的内存,并在最低程度的垃圾回收情况下使用,并允许手动释放。具体代码大致如下:

import "arena"
 
type T struct{
    Foo string
    Bar [16]byte
}
 
func processRequest(req *http.Request) {
    // 在函数开始时创建一个arena。
    mem := arena.NewArena()
    // 在函数结束时释放arena。
    defer mem.Free()
 
    // 从arena分配一系列对象。
    for i := 0; i < 10; i++ {
        obj := arena.New[T](mem)
    }
 
    // 或者创建长度和容量都指定的切片。
    slice := arena.MakeSlice[T](mem, 100, 200)
}

虽然看起来似乎是绕过了垃圾回收机制,重新回到了手动分配内存的老路上,但是,事实真的是这样吗?

实际上,我认为这不是一种"妥协",而是一种"进步"。因为它赋予了开发者更多的选择权:对于普通的Go开发者来说,他们完全可以不使用arenas,享受Go语言垃圾回收器带来的便利;但是,对于那些对内存占用和性能有高要求,同时又不想使用C/C++进行开发的开发者来说,Go语言无疑成为了他们的新宠。

Rust 的所有权和生命周期系统

如你已了解,有两种常见的内存管理方式:手动管理和通过垃圾回收器自动管理。然而,是否有更好的内存管理方式呢?答案是有的,就是Rust语言的所有权和生命周期系统。

Rust是一门引人注目的语言,该语言于2010年诞生,并在近年来引起了广泛关注。一些大型公司已开始推广Rust以取代C语言,并且Linux社区也宣布将Rust引入内核开发中。而这门语言最神奇(同时也最难理解)的特点就是其所有权和生命周期系统。

Rust实现了内存安全的强制执行,无需依赖垃圾回收器或其他内存安全的语言中的引用计数。听起来很神奇,但实际上其原理很简单:Rust引入了“所有权”规则,要求一个对象只能被一个变量拥有,其他变量若需要访问该对象的内容,要么获得所有权(这样其他变量就无法再通过之前的变量来访问该对象),要么申请“借用”。每个变量都有其确定的生命周期,当一个变量的生命周期结束(例如一个局部变量在函数运行结束时),它就会被自动移除。所有这些操作都无需垃圾回收器的支持——由于严格的生命周期规定,对象何时不再需要使用便是确定的,因此释放内存的代码可以直接由编译器插入到逻辑代码中。

fn main() {
    let s = String::from("hello");  // s 进入作用域
 
    takes_ownership(s);             // s 的值移动到函数里 ...
                                    // ... 所以到这里不再有效
 
    let x = 5;                      // x 进入作用域
 
    makes_copy(x);                  // x 应该移动函数里,
                                    // 但 i32 是 Copy 的,所以在后面可继续使用 x
 
} // 这里, x 先移出了作用域,然后是 s。但因为 s 的值已被移走,
  // 所以不会有特殊操作
 
fn takes_ownership(some_string: String) { // some_string 进入作用域
    println!("{}", some_string);
} // 这里,some_string 移出作用域并调用 `drop` 方法。占用的内存被释放
 
fn makes_copy(some_integer: i32) { // some_integer 进入作用域
    println!("{}", some_integer);
} // 这里,some_integer 移出作用域。不会有特殊操作

以上的的内容仅仅是冰山一角,有兴趣的同学可以自己深入了解。

Go 内存管理及优化

Go语言的内存分配机制可以通过以下方式进行优化和管理:

  1. 分块:Go可以通过系统调用(mmap())提前向操作系统申请一大块内存,然后将其分为特定大小的块,用于对象的分配。这种分块技术有两种类型:包含指针的大块(scan mspan)和不包含指针的大块(noscan mspan)。这样可以更加精确地进行垃圾回收(GC)操作。
  2. 缓存:Go通过维护mcache来管理一组mspan,以加快内存分配的效率。这样可以避免频繁地向操作系统申请内存,提高了性能。
  3. 平衡的GC:Go的垃圾回收机制被称为平衡GC,它简化了内存分配的过程,同时专注于提高性能。平衡GC会根据应用程序的需求自动进行垃圾回收,以尽可能减少内存泄漏和垃圾对象的存在。

总的来说,Go语言的内存分配机制经过了专业化和简化的处理,通过分块和缓存优化了内存分配的效率,同时平衡的垃圾回收机制确保了内存的有效管理。

编译器和静态分析

在编程中,编译器扮演了一个重要的角色。它能够将我们编写的源代码转换成计算机可以理解的二进制可执行文件。这个转换过程经过了多个步骤,比如词法分析(将源代码分解成词汇单元),语法分析(根据语法规则构建语法树),语义分析(检查代码的意义和逻辑),以及中间代码生成等。通过这些分析过程,编译器能够理解开发者所表达的意图,并对代码进行优化并生成可执行的二进制文件。

Go 编译器优化

函数内联(Inlining)是一种编译器优化技术,它可以在编译过程中将函数调用处的代码直接替换为函数体,以减少函数调用的开销。这种优化可以提高程序的执行效率,尤其是在频繁调用的小型函数中。需要注意的是,在一些编程语言如Kotlin中,虽然可以使用inline关键字来主动要求进行函数内联,但并不建议在所有情况下都使用这个关键字。实际上,JVM会自动对需要进行内联的函数进行优化,因此手动使用inline关键字并不能带来太大的性能提升。因此,使用inline关键字时应慎重考虑,最好只在需要使用refied T泛型时才使用。

Beast Mode (野兽模式)是一个术语,通常用来形容某物进入高性能工作状态或表现出特别出色的能力。在编程领域,可以将其用来形容代码或算法在某些情况下表现出非常高效或优秀的特性。

逃逸分析是编译器对代码进行分析的过程,用于确定在函数调用中使用的局部变量是否逃逸到函数作用域之外。逃逸分析的目标是了解局部变量在函数调用期间的作用域和生命周期,以便进行相关的优化。如果变量没有逃逸,编译器可以在栈上分配内存,避免使用堆内存,从而提高程序的性能。

引用

该笔记内容资料主要来源于: 后端 - 字节内部课 (juejin.cn)