从 Java 的角度看待 Go 的内存管理| 青训营笔记
这是我参与「第五届青训营」伴学笔记创作活动的第 4 天
前言
本系列文章试图从一名 Java 开发者(有时也会穿插其他语言)的角度窥探 Go 语言,并以注释的方式提及 Go 与 Java 的一些区别,方便 Java 开发者迅速入门 Go 语言。
这是该系列的第四章,将介绍 Go 的内存管理机制。
性能优化
自动内存管理
所谓自动内存管理,其实就是指垃圾回收,在 Go 中,程序在运行时根据需求动态分配的内存(即动态内存)会被纳入自动内存管理的范畴。
通过自动内存管理,我们可以避免手动释放内存,将注意力专注在业务逻辑,同时还可以避免发生内存安全问题(诸如 内存重复释放问题 double-free problem
或是 内存释放后使用问题 use-after-free problem
)。
一个垃圾回收周期大致有三个任务:为新对象分配空间,找到存活对象,回收死亡对象的内存空间。
要想详细了解垃圾回收,就必须先了解其相关概念:
- Mutator:业务线程,分配新对象,修改对象指向关系;
- Collector:GC 线程,找到存活对象,回收死亡对象的内存空间;
- Serial GC(串行 GC):只有一个 collector;
- Parallel GC(并行 GC):支持多个 collectors 同时回收的 GC 算法;
- Concurrent GC(并发 GC):mutator(s) 和 collector(s) 可以同时执行。
要想评价一个 GC 算法,大概可以从以下几个方面进行:
- 安全性(Safety):指垃圾回收器不应回收存活的对象;
- 吞吐率(Throughput):指垃圾回收器花在 GC 上的时间占程序执行总时间的比率;
- 暂停时间(Pause time):指垃圾回收导致业务线程挂起(暂停)的时间(GC 导致的暂停被称为 stop the world, STW)
- 内存开销(Space overhead):指垃圾回收器元数据占用的内存开销;
接下来,将从几个经典的垃圾回收器算法简述垃圾回收。
追踪垃圾回收
追踪垃圾回收(Tracing Garge Collection)是一种最常见的垃圾回收方式,它通过跟踪哪些对象可以通过来自某些“根”对象的引用链访问来确定哪些对象应该被释放(“垃圾回收”),并将其余对象视为“垃圾”并收集它们。
追踪垃圾回收也是 Go 目前正在使用的垃圾回收算法。
简单来说,追踪垃圾回收以如下方式工作:
- 首先,标记根对象,这些根对象可能包括静态变量,全局变量,常量,线程栈等;
- 然后,从根对象触发,找到所有引用根对象的可达对象;
- 最后,清理所有不可达对象,这分为三个步骤:将存活对象复制到另外的内存空间(Copying GC),将死亡对象的内存标记为"可分配"(Mark-sweep GC),移动并整理存活对象(Mark-compact GC)。
根据对象的生命周期,垃圾回收器可能会使用不同的标记和清理策略。
分代 GC
与 Go 相同,Java 也支持垃圾回收,而 Java 目前主流的垃圾回收器 G1GC
(Garbage First Garbage Collector),就是一种分代垃圾回收器(Generational GC)。
分代 GC 的设计来源于分代假说(Generational hypothesis) —— most objects die young,即大多数对象在很短的生命周期内就会死亡,分配出来后很快就不再使用了。通过为年轻和年老(经历过 GC 的次数越多则越老,反之越年轻)的对象指定不同的 GC 策略,降低整体内存管理的开销。
对于年轻代(Young generation)的对象,可以采用 copying collection,且提高 GC 的吞吐率;对于老年代(Old generation)的对象则可以采用 mark-sweep collection。
引用计数
确定一个对象需要被回收的另一种方式是引用计数(Referenct counting),其为每一个对象维护一个与之关联的引用数目,当且仅当引用数大于 0 时,该对象才会被标记为存活,否则,对象会被回收。
引用计数方案的优点是,内存管理的操作被平摊到程序执行的过程中(当新建对象,或是将对象添加到一个集合中时增加引用计数,反之,销毁对象或是从集合中移除时减少引用计数),并且内存管理不需要了解 runtime 的实现细节(例如 C++ 的智能指针);
相反,其缺点就是维护引用计数的开销较大(因为引用计数操作必须是原子的),无法回收环形数据结构(因为所有对象都直接或间接的互相引用对方),每个对象引入额外的内存空间以存储引用数目,回收内存时依然可能引发暂停等。
手动内存管理:妥协还是进步?
自动垃圾回收对于手动内存管理无疑是一种进步,因为它成功的解放了开发者的生产力,让开发者可以将注意力专注于业务代码而不是为内存分配和释放焦头烂额。但是,或许你不知道的是,Go 在 1.20 版本引入了实验性的 arenas
系统,允许你手动申请一处连续的内存,可在最低程度 GC 的情况下使用,并允许手动释放。它看起来大概是这样的:
import "arena"
type T struct{
Foo string
Bar [16]byte
}
func processRequest(req *http.Request) {
// Create an arena in the beginning of the function.
mem := arena.NewArena()
// Free the arena in the end.
defer mem.Free()
// Allocate a bunch of objects from the arena.
for i := 0; i < 10; i++ {
obj := arena.New[T](mem)
}
// Or a slice with length and capacity.
slice := arena.MakeSlice[T](mem, 100, 200)
}
听起来,这似乎是绕开了 GC,又回到了手动分配内存的老路,但是,试试真的如此吗?
其实,比起“妥协”,我认为这其实是一种“进步”。因为这给予了开发者更多的选择权:对于普通 Go 开发者来说,他们完全可以不使用 arenas
,享受 Go 垃圾回收器带给他们的欢愉;但是,对于对内存占用和性能有高要求,但是又不希望使用 C/C++ 进行开发的开发者来说,Go 无疑可以成为他们新的避风港。
最后,只剩下悲催的 Java 开发者,只能忍受垃圾回收器带来的高开销和 STW(bushi。
展望未来:还有更好的内存管理方式吗
现在,你或许已经了解了两种内存管理方式:一种是开发者手动管理,另一种是通过垃圾回收器自动管理,两者各有优劣。那么,有没有更好的垃圾管理方式呢?答案是有,那就是 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 移出作用域。不会有特殊操作
当然,这只是 Rust 所有权和借用系统的冰山一角,此处只是抛砖引玉,如果你对 Rust 感兴趣,可以日后深入了解。
Go 内存管理及优化
Go 内存分配
- 分块:可以通过系统调用(
mmap()
)提前向操作系统申请一大块内存,然后再不断将内存分配成特定大小的小块,用于对象分配;将内存分配为包含指针的大块(scanmspan
)和不包含指针的大块(noscanmspan
)来有针对性地进行 GC。 - 缓存:通过维护
mcache
管理一组mspan
加快内存分配效率,避免重复向操作系统申请内存。 - Balanced GC
编译器和静态分析
编译器(Compiler)可以将源代码转换为计算机可执行二进制文件(当然,Java 编译器则是将源代码转换为 JVM 可读的字节码文件),这包含了很多步骤,例如词法分析,语法分析,语义分析,中间代码生成等。通过这些分析,编译器可以知道开发者的实际意图并为其优化代码并生成编译结果。
Go 编译器优化
- 函数内联(Inlining)(小提一嘴,在 Kotlin 中,虽然其支持使用
inline
关键字主动内联函数/变量,但这是不被建议的,因为 JVM 会为需要内联的函数/变量自动内联,手动内联其实并不能提升多大的效率,因此inline
关键字只建议配合refied T
泛型使用) - Beast Mode
- 逃逸分析
引用
该文章部分内容来自于以下课程或网页:
- 字节内部课:高性能 Go 语言发行版优化与落地实践
- Garbage First Garbage Collector Tuning (oracle.com)
- Tracing garbage collection - Wikipedia
- A Guide to the Go Garbage Collector - The Go Programming Language (golang.org)
- Golang memory arenas 101 guide (uptrace.dev)
- 所有权和借用 - Rust语言圣经(Rust Course)
分发
This work is licensed under CC BY-SA 4.0