现在我们已经了解了变量和函数,我们将快速了解指针语法。然后我们将通过将指针与其他语言中类的行为进行比较来阐明 Go 中指针的行为。我们还将学习如何以及何时使用指针,如何在 Go 中分配内存,以及如何正确使用指针和值使 Go 程序更快、更高效。
一种指针只是一个变量,它保存内存中存储值的位置。如果您参加过计算机科学课程,您可能已经看到了一个表示变量如何存储在内存中的图形。以下两个变量的表示类似于图 6-1:
var x int32 = 10 var y bool = true
每一个变量存储在一个或多个连续的内存位置,称为地址。不同类型的变量可以占用不同的内存量。在这个例子中,我们有两个变量,x
一个 32 位整数,y
一个布尔值。存储一个 32 位的 int 需要四个字节,因此 for 的值x
存储在四个字节中,从地址 1 开始,到地址 4 结束。布尔值只需要一个字节(您只需要一个位来表示真或假,但可以独立寻址的最小内存量是一个字节),因此值y
存储在地址 5 的一个字节中,用true
值 1 表示。
指针只是一个变量,其内容是存储另一个变量的地址。图 6-2演示了指针是如何存储在内存中的:
var x int32 = 10 var y bool = true pointerX := &x pointerY := &y var pointerZ *string
虽然不同类型的变量可以占用不同数量的内存位置,但每个指针,无论它指向什么类型,始终是相同的大小:一个数字,用于保存存储数据的内存位置。我们指向x
, pointerX
的指针存储在位置 6 并具有值 1,即 的地址x
。类似地,指向 , 的指针y
存储pointerY
在位置 10 并具有值 5,即 的地址y
。最后一个指针pointerZ
存储在位置 14 并具有值 0,因为它不指向任何东西。
这指针的零值是nil
。我们之前已经见过nil
几次,作为切片、映射和函数的零值。所有这些类型都是用指针实现的。(另外两种类型,通道和接口,也使用指针实现。我们将在“接口快速课程”和“通道”中详细了解它们。)正如我们在第 3 章中介绍的那样,nil
它是一个无类型标识符,表示某些类型缺少值。与NULL
C 不同,nil
不是 0 的另一个名称;你不能用数字来回转换它。
正如第 4 章中提到的,在Universenil
块中定义。因为nil
是在 Universe 块中定义的值,所以它可以被遮蔽。永远不要命名变量或函数nil
,除非你试图欺骗你的同事并且不关心你的年度审查。
去的指针语法部分借鉴自 C 和 C++。由于 Go 有一个垃圾收集器,因此消除了大部分内存管理的痛苦。此外,您可以在 C 和 C++ 中使用指针执行的一些技巧,包括指针算术,在 Go 中是不允许的。
这Go 标准库确实有一个unsafe
包,可以让您对数据结构进行一些低级操作。虽然指针操作在 C 中用于常见操作,但 Go 开发人员极少使用unsafe
. 我们将在第 14 章快速了解它。
&
是_地址运算符。它位于值类型之前并返回存储值的内存位置的地址:
x := "hello" pointerToX := &x
*
是_间接运算符。它位于指针类型变量之前并返回指向的值。这称为取消引用:
x := 10 pointerToX := &x fmt.Println(pointerToX) // prints a memory address fmt.Println(*pointerToX) // prints 10 z := 5 + *pointerToX fmt.Println(z) // prints 15
在取消引用指针之前,您必须确保指针非零。如果您尝试取消引用nil
指针,您的程序将出现恐慌:
var x *int fmt.Println(x == nil) // prints true fmt.Println(*x) // panics
指针类型是表示指针的类型。它写*
在类型名称之前。指针类型可以基于任何类型:
x := 10 var pointerToX *int pointerToX = &x
这内置函数new
创建一个指针变量。它返回一个指向所提供类型的零值实例的指针:
var x = new(int) fmt.Println(x == nil) // prints false fmt.Println(*x) // prints 0
该new
功能很少使用。对于结构,&
在结构文字之前使用 an 来创建指针实例。您不能&
在原始文字(数字、布尔值和字符串)或常量之前使用 an,因为它们没有内存地址;它们仅在编译时存在。当你需要一个指向原始类型的指针时,声明一个变量并指向它:
x := &Foo{} var y string z := &y
无法获取常量的地址有时很不方便。如果您有一个带有指向原始类型的指针字段的结构,则不能将文字直接分配给该字段:
type person struct { FirstName string MiddleName *string LastName string } p := person{ FirstName: "Pat", MiddleName: "Perry", // This line won't compile LastName: "Peterson", }
编译此代码会返回错误:
不能在字段值中使用“Perry”(类型字符串)作为类型 *string
如果您尝试放置&
before "Perry"
,您将收到错误消息:
不能取“佩里”的地址
有两种方法可以解决这个问题。首先是做我们之前展示的,引入一个变量来保存常量值。第二种方法是编写一个辅助函数,它接受布尔、数字或字符串类型并返回指向该类型的指针:
func stringp(s string) *string { return &s }
使用该函数,您现在可以编写:
p := person{ FirstName: "Pat", MiddleName: stringp("Perry"), // This works LastName: "Peterson", }
为什么这行得通?当我们将一个常量传递给一个函数时,该常量被复制到一个参数中,该参数是一个变量。由于它是一个变量,它在内存中有一个地址。然后该函数返回变量的内存地址。
使用辅助函数将常量值转换为指针。
这指针的第一条规则是不要害怕它们。如果您习惯于 Java、JavaScript、Python 或 Ruby,您可能会发现指针令人生畏。然而,指针实际上是类的熟悉行为。Go 中的非指针结构是不寻常的。
在 Java 和 JavaScript 中,原始类型和类之间的行为存在差异(Python 和 Ruby 没有原始值,但使用不可变实例来模拟它们)。当原始值分配给另一个变量或传递给函数或方法时,对其他变量所做的任何更改都不会反映在原始值中,如示例 6-1所示。
int x = 10; int y = x; y = 20; System.out.println(x); // prints 10
但是,让我们看看当一个类的实例被分配给另一个变量或传递给一个函数或方法时会发生什么(示例 6-2中的代码是用 Python 编写的,但 Java、JavaScript 和 Ruby 的代码类似可以在 GitHub上找到)。
class Foo: def __init__(self, x): self.x = x def outer(): f = Foo(10) inner1(f) print(f.x) inner2(f) print(f.x) g = None inner2(g) print(g is None) def inner1(f): f.x = 20 def inner2(f): f = Foo(30) outer()
运行此代码会打印出:
20 20 真的
那是因为以下事情在 Java、Python、JavaScript 和 Ruby 中都是正确的:
如果将类的实例传递给函数并更改字段的值,则更改会反映在传入的变量中。
如果重新分配参数,则更改不会反映在传入的变量中。
如果传递nil/null/None
参数值,将参数本身设置为新值不会修改调用函数中的变量。
有些人通过说类实例在这些语言中通过引用传递来解释这种行为。这是不真实的。如果它们是通过引用传递的,情况二和三将改变调用函数中的变量。这些语言总是按值传递,就像 Go 一样。
我们看到的是,这些语言中类的每个实例都是作为指针实现的。当一个类实例被传递给一个函数或方法时,被复制的值是指向该实例的指针。由于outer
和inner1
指的是相同的内存,因此对 in 中的字段所做的更改f
会inner1
反映在变量 in 中outer
。当inner2
重新分配f
给一个新的类实例时,这会创建一个单独的实例并且不会影响outer
.
当您在 Go 中使用指针变量或参数时,您会看到完全相同的行为。Go 和这些语言之间的区别在于,Go 让您可以选择对原语和结构使用指针或值。大多数情况下,您应该使用一个值。它们使您更容易理解修改数据的方式和时间。第二个好处是使用值减少了垃圾收集器必须做的工作量。我们将在“减少垃圾收集器的工作量”中讨论这一点。
作为我们已经看到,Go 常量为可以在编译时计算的文字表达式提供名称。语言中没有机制来声明其他类型的值是不可变的。现代软件工程包含不变性。麻省理工学院的软件构建课程总结了原因:“[I] 可变类型更安全,不会出现错误,更容易理解,并且更易于更改。可变性让你更难理解你的程序在做什么,也更难执行合约。”
Go 中缺少不可变声明似乎有问题,但是在值和指针参数类型之间进行选择的能力解决了这个问题。正如软件构建课程材料继续解释的那样:“如果您在方法中完全在本地使用可变对象,并且只对对象进行一次引用,那么使用可变对象就可以了。” Go 开发人员没有声明某些变量和参数是不可变的,而是使用指针来指示参数是可变的。
自从Go 是按值调用的语言,传递给函数的值是副本。对于像基元、结构和数组这样的非指针类型,这意味着被调用的函数不能修改原始函数。自从被调用的函数有一份原始数据的副本,保证了原始数据的不变性。
我们将在“映射和切片之间的区别”中讨论将映射和切片传递给函数。
但是,如果将指针传递给函数,则该函数将获得指针的副本。这仍然指向原始数据,这意味着原始数据可以被调用的函数修改。
这有几个相关的含义。
第一个含义是,当您将nil
指针传递给函数时,您不能将值设为非 nil。如果已经为指针分配了值,则只能重新分配该值。虽然起初令人困惑,但这是有道理的。由于内存位置是通过按值调用传递给函数的,因此我们无法更改内存地址,就像我们无法更改int
参数的值一样。我们可以用下面的程序来证明这一点:
func failedUpdate(g *int) { x := 10 g = &x } func main() { var f *int // f is nil failedUpdate(f) fmt.Println(f) // prints nil }
此代码的流程如图 6-3所示。
我们从 中的一个nil
变量f
开始main
。当我们调用时,failedUpdate
我们将 的值复制到名为 的参数中。这意味着也设置为。然后我们在其中声明一个值为 10的新变量。接下来,我们将in更改为指向。这并没有改变in ,当我们退出并返回时,仍然是。f
nil
g
g
nil
x
failedUpdate
g
failedUpdate
x
f
main
failedUpdate
main
f
nil
复制指针的第二个含义是,如果您希望分配给指针参数的值在退出函数时仍然存在,则必须取消引用指针并设置值。如果您更改指针,您更改的是副本,而不是原件。取消引用将新值放在原始和副本都指向的内存位置中。这是一个简短的程序,展示了它是如何工作的:
func failedUpdate(px *int) { x2 := 20 px = &x2 } func update(px *int) { *px = 20 } func main() { x := 10 failedUpdate(&x) fmt.Println(x) // prints 10 update(&x) fmt.Println(x) // prints 20 }
此代码的流程如图 6-4所示。
在这个例子中,我们从x
inmain
设置为 10 开始。当我们调用 时failedUpdate
,我们将 的地址复制x
到参数px
中。接下来,我们在 中声明x2
,failedUpdate
设置为 20。然后我们指向px
的failedUpdate
地址x2
。当我们返回 时main
, 的值x
不变。当我们调用 时,我们再次update
复制x
into的地址。px
但是,这次我们改变了px
inupdate
指向的变量x
in的值main
。当我们返回时main
,x
有被改变了。
那说,在 Go 中使用指针时要小心。如前所述,它们使理解数据流变得更加困难,并且可以为垃圾收集器创建额外的工作。与其通过将指向结构的指针传递给函数来填充结构,不如让函数实例化并返回结构(参见示例 6-3和示例 6-4)。
func MakeFoo(f *Foo) error { f.Field1 = "val" f.Field2 = 20 return nil }
func MakeFoo() (Foo, error) { f := Foo{ Field1: "val", Field2: 20, } return f, nil }
唯一应该使用指针参数来修改变量的时候是函数需要接口时。您在使用 JSON 时会看到这种模式(我们将在“encoding/json”中更多地讨论 Go 标准库中的 JSON 支持):
f := struct { Name string `json:"name"` Age int `json:"age"` } err := json.Unmarshal([]byte(`{"name": "Bob", "age": 30}`), &f)
这 Unmarshal
函数从包含 JSON 的字节切片中填充变量。它被声明为采用一个字节切片和一个interface{}
参数。为参数传入的值interface{}
必须是指针。如果不是,则返回错误。使用这种模式是因为 Go 没有泛型。这意味着没有一种方便的方法可以将类型传递给函数来指定要解组的内容,也没有一种方法可以为不同的类型指定不同的返回类型。
因为 JSON 集成如此普遍,所以这个 API 有时被新的 Go 开发人员视为常见情况,而不是应有的例外情况。
那里是一种通过使用包中的Type
类型在变量中表示 Go 中的类型的方法reflect
。该reflect
包是为没有其他方法来完成任务的情况保留的。我们将在第 14 章中讨论反射。
从函数返回值时,您应该优先考虑值类型。如果数据类型中存在需要修改的状态,则仅使用指针类型作为返回类型。当我们在“io and Friends”中查看 I/O 时,我们会看到用于读取或写入数据的缓冲区。此外,还有一些与并发一起使用的数据类型必须始终作为指针传递。我们将在第 10 章中看到这些内容。
如果结构足够大,使用指向结构的指针作为输入参数或返回值可以提高性能。将指针传递给函数的时间对于所有数据大小都是恒定的,大约为 1 纳秒。这是有道理的,因为指针的大小对于所有数据类型都是相同的。随着数据变大,将值传递给函数需要更长的时间。一旦该值达到大约 10 兆字节的数据,大约需要一毫秒。
返回指针与返回值的行为更有趣。对于小于 1 兆字节的数据结构,返回指针类型实际上比返回值类型要慢。例如,一个 100 字节的数据结构需要大约 10 纳秒才能返回,但指向该数据结构的指针大约需要 30 纳秒。一旦你的数据结构大于一兆字节,性能优势就会翻转。返回 10 兆字节的数据需要将近 2 毫秒,但返回指向它的指针则需要半毫秒多一点。
您应该知道,这些时间非常短暂。在绝大多数情况下,使用指针和值之间的差异不会影响程序的性能。但是,如果您在函数之间传递兆字节的数据,即使数据是不可变的,也要考虑使用指针。
所有这些数字都来自具有 32GB RAM 的 i7-8700 计算机。您可以使用GitHub 上的代码运行自己的性能测试。
这Go 中指针的其他常见用法是指示已分配零值的变量或字段与根本未分配值的变量或字段之间的差异。如果这种区别在您的程序中很重要,请使用nil
指针来表示未分配的变量或结构字段。
因为指针也表示可变性,使用此模式时要小心。与其从函数返回一个指向的指针,不如nil
使用我们在映射中看到的逗号 ok 习语并返回一个值类型和一个布尔值。
请记住,如果nil
通过参数或参数上的字段将指针传递给函数,则无法在函数内设置值,因为无处存储该值。如果为指针传递了一个非 nil 值,请不要修改它,除非您记录了该行为。
同样,JSON 转换是证明规则的例外。当从 JSON 来回转换数据时(是的,我们将在“encoding/json”中更多地讨论 Go 标准库中的 JSON 支持),您通常需要一种方法来区分零值和未分配值全部。对结构中可为空的字段使用指针值。
当不使用 JSON(或其他外部协议)时,抵制使用指针字段来指示没有值的诱惑。虽然指针确实提供了一种方便的方式来指示没有值,但如果您不打算修改值,则应该使用值类型,并与布尔值配对。
作为我们在上一章中看到,对传递给函数的映射所做的任何修改都会反映在传入的原始变量中。现在我们知道了指针,我们可以理解为什么:在 Go 运行时中,映射被实现作为指向结构的指针。将映射传递给函数意味着您正在复制指针。
因此,您应该避免将映射用于输入参数或返回值,尤其是在公共 API 上。在 API 设计级别上,映射是一个糟糕的选择,因为它们没有说明其中包含什么值;没有明确定义映射中的键的内容,因此了解它们的唯一方法是跟踪代码。从不变性的角度来看,映射是不好的,因为知道映射中最终结果的唯一方法是跟踪与之交互的所有函数。这可以防止您的 API 进行自我记录。如果您习惯于动态语言,请不要使用映射来替代另一种语言缺乏结构。Go 是一种强类型语言;与其传递地图,不如使用结构。(当我们在“减少垃圾收集器的工作量”。)
同时,将切片传递给函数具有更复杂的行为:对切片内容的任何修改都会反映在原始变量中,但使用append
更改长度不会反映在原始变量中,即使切片具有容量大于它的长度。这是因为切片被实现为具有三个字段的结构:int
长度int
字段、容量字段和指向内存块的指针。图 6-5展示了这种关系。
当切片被复制到不同的变量或传递给函数时,会生成长度、容量和指针的副本。图 6-6显示了两个切片变量如何指向同一个内存。
更改切片中的值会更改指针指向的内存,因此在副本和原始文件中都可以看到更改。我们在图 6-7中看到它在内存中的样子。
对长度和容量的更改不会反映在原始文件中,因为它们仅在副本中。改变容量意味着指针现在指向一个新的、更大的内存块。在图 6-8中,我们展示了每个切片变量现在如何指向不同的内存块。
如果切片副本被附加到并且有足够的容量不分配新切片,则副本中的长度会发生变化,并且新值将存储在副本和原始副本共享的内存块中。但是,原始切片中的长度保持不变。这意味着 Go 运行时会阻止原始切片看到这些值,因为它们超出了原始切片的长度。图 6-9突出显示了在一个切片变量中可见但在另一个切片变量中不可见的值。
结果是传递给函数的切片可以修改其内容,但不能调整切片的大小。作为唯一可用的线性数据结构,切片在 Go 程序中经常被传递。默认情况下,您应该假设切片没有被函数修改。您的函数文档应指定它是否修改了切片的内容。
您可以将任意大小的切片传递给函数的原因是传递给函数的数据对于任何大小的切片都是相同的:两个int
值和一个指针。你不能编写一个接受任何大小数组的函数的原因是因为整个数组被传递给函数,而不仅仅是指向数据的指针。
切片作为输入参数还有另一种用途:它们非常适合可重用缓冲区。
什么时候从外部资源(如文件或网络连接)读取数据,许多语言使用如下代码:
r = open_resource() while r.has_data() { data_chunk = r.next_chunk() process(data_chunk) } close(r)
这种模式的问题在于,每次我们遍历该while
循环时,我们都会分配另一个循环,data_chunk
即使每个循环只使用一次。这会产生大量不必要的内存分配。垃圾收集的语言会自动为您处理这些分配,但在您完成处理后仍需要完成清理它们的工作。
甚至尽管 Go 是一种垃圾收集语言,但编写惯用的 Go 意味着避免不必要的分配。我们不是每次从数据源读取时都返回一个新的分配,而是创建一次字节切片并将其用作缓冲区以从数据源读取数据:
file, err := os.Open(fileName) if err != nil { return err } defer file.Close() data := make([]byte, 100) for { count, err := file.Read(data) if err != nil { return err } if count == 0 { return nil } process(data[:count]) }
记住当我们将切片传递给函数时,我们无法更改切片的长度或容量,但我们可以将内容更改为当前长度。在这段代码中,我们创建了一个 100 字节的缓冲区,每次循环时,我们将下一个字节块(最多 100 个)复制到切片中。然后我们将缓冲区的填充部分传递给process
. 我们将在“io and Friends”中了解有关 I/O 的更多细节。
使用缓冲区只是我们如何减少垃圾收集器完成的工作的一个例子。当程序员谈论“垃圾”时,他们的意思是“没有更多指针指向它的数据”。一旦不再有指向某个数据的指针,这些数据占用的内存就可以被重用。如果内存没有恢复,程序的内存使用量将继续增长,直到计算机用完 RAM。垃圾收集器的工作是自动检测未使用的内存并恢复它以便可以重用。Go 有一个垃圾收集器真是太棒了,因为几十年的经验表明,人们很难手动正确地管理内存。但仅仅因为我们有一个垃圾收集器并不意味着我们应该制造大量垃圾。
如果你已经花时间学习编程语言是如何实现的,你可能已经了解了堆和堆栈。如果您不熟悉,以下是堆栈的工作原理。堆栈是一个连续的内存块,执行线程中的每个函数调用都共享同一个堆栈。分配堆栈上的内存既快速又简单。堆栈指针跟踪分配内存的最后位置;通过移动堆栈指针来分配额外的内存。什么时候调用一个函数,为函数的数据创建一个新的堆栈帧。局部变量与传递给函数的参数一起存储在堆栈中。每个新变量将堆栈指针移动值的大小。当一个函数退出时,它的返回值通过堆栈复制回调用函数,堆栈指针移回退出函数的堆栈帧的开头,释放该函数的本地使用的所有堆栈内存变量和参数。
Go 的不寻常之处在于它实际上可以在程序运行时增加堆栈的大小。这是可能的,因为每个 goroutine 都有自己的堆栈,并且 goroutine 由 Go 运行时管理,而不是由底层操作系统管理(我们在第 10 章讨论并发时讨论 goroutine )。这有优点(Go 堆栈开始时很小,使用更少的内存)和缺点(当堆栈需要增长时,需要复制堆栈上的所有数据,这很慢)。也可以编写导致堆栈一次又一次地增长和收缩的最坏情况代码。
要将某些内容存储在堆栈中,您必须在编译时确切知道它有多大。当您查看 Go 中的值类型(原始值、数组和结构)时,它们都有一个共同点:我们确切地知道它们在编译时占用了多少内存。这就是为什么将大小视为数组类型的一部分的原因。因为它们的大小是已知的,所以它们可以分配在堆栈上而不是堆上。指针类型的大小也是已知的,它也存储在堆栈中。
当涉及到指针指向的数据时,规则更加复杂。为了让 Go 在堆栈上分配指针指向的数据,必须满足几个条件。它必须是一个局部变量,其数据大小在编译时是已知的。无法从函数返回指针。如果将指针传递给函数,编译器必须能够确保这些条件仍然成立。如果大小未知,则无法通过简单地移动堆栈指针来为其腾出空间。如果返回指针变量,则函数退出时指针指向的内存将不再有效。当编译器确定数据不能入栈时,我们说指针指向的数据 转义堆栈,编译器将数据存储在堆上。
堆是由垃圾收集器(或在 C 和 C++ 等语言中手动管理)管理的内存。我们不打算讨论垃圾收集器算法的实现细节,但它们比简单地移动堆栈指针要复杂得多。存储在堆上的任何数据都是有效的,只要它可以追溯到堆栈上的指针类型变量。一旦不再有指向该数据(或指向该数据的数据)的指针,该数据变成垃圾,垃圾收集器的工作就是清除它。
C 程序中常见的错误来源是返回指向局部变量的指针。在 C 中,这会导致指向无效内存的指针。Go 编译器更智能。当它看到一个指向局部变量的指针被返回时,局部变量的值被存储在堆上。
这 Go 编译器完成的逃逸分析并不完美。在某些情况下,可以存储在堆栈上的数据会逃逸到堆中。但是,编译器必须保守;当它可能需要在堆上时,它不能冒险将值留在堆栈上,因为留下对无效数据的引用会导致内存损坏。较新的 Go 版本改进了逃逸分析。
您可能想知道:在堆上存储东西有什么不好?有两个与性能有关的问题。首先是垃圾收集器需要时间来完成它的工作。跟踪堆上所有可用的空闲内存块或跟踪哪些已使用的内存块仍然具有有效指针并非易事。这是编写程序所要执行的处理所花费的时间。已经编写了许多垃圾收集算法,它们可以大致分为两类:那些设计用于更高吞吐量(在一次扫描中找到尽可能多的垃圾)或更低延迟(尽快完成垃圾扫描)的算法。Jeff Dean是谷歌众多工程成功背后的天才,他在 2013 年与人合写了一篇论文,名为规模的尾巴。它认为系统应该针对延迟进行优化,以保持较低的响应时间。Go 运行时使用的垃圾收集器倾向于低延迟。每个垃圾回收周期的设计时间少于 500 微秒。但是,如果您的 Go 程序创建了大量垃圾,那么垃圾收集器将无法在一个周期内找到所有垃圾,从而减慢收集器的速度并增加内存使用量。
如果你对实现细节感兴趣,不妨听听 Rick Hudson 在 2018 年内存管理国际研讨会上的演讲,讲述Go 垃圾 收集器的历史和实现。
第二个问题涉及计算机硬件的性质。RAM 可能意味着“随机存取内存”,但从内存中读取的最快方法是顺序读取。Go 中的结构切片将所有数据按顺序排列在内存中。这使得它可以快速加载和快速处理。指向结构的指针切片(或字段为指针的结构)的数据分散在 RAM 中,因此读取和处理速度要慢得多。Forrest Smith 撰写了一篇深入的博客文章,探讨了这对性能的影响程度。他的数字表明,通过随机存储在 RAM 中的指针访问数据大约慢了两个数量级。
这编写了解其运行的硬件的软件的方法称为机械同情。该术语来自赛车世界,其理念是了解汽车在做什么的驾驶员可以最好地从中挤出最后一点性能。2011 年,Martin Thompson 开始将该术语应用于软件开发。遵循 Go 中的最佳实践会自动为您提供。
将 Go 的方法与 Java 的方法进行比较。在 Java 中,局部变量和参数存储在堆栈中,就像 Go 一样。然而,正如我们前面所讨论的,Java 中的对象是作为指针实现的。这意味着对于每个对象变量实例,只有指向它的指针被分配到堆栈上;对象内的数据在堆上分配。只有原始值(数字、布尔值和字符)完全存储在堆栈中。这意味着Java中的垃圾收集器必须做大量的工作。这也意味着Java中的Lists之类的东西实际上是指向指针数组的指针。虽然看起来像线性数据结构一样,读取它实际上涉及在内存中弹跳,这是非常低效的。Python、Ruby 和 JavaScript 中也有类似的行为。为了解决所有这些低效率问题,Java 虚拟机包含一些非常聪明的垃圾收集器,它们可以完成大量工作,一些针对吞吐量进行了优化,一些针对延迟进行了优化,并且所有这些都具有配置设置以调整它们以获得最佳性能。Python、Ruby 和 JavaScript 的虚拟机优化程度较低,因此它们的性能也会受到影响。
现在你可以明白为什么 Go 鼓励你少用指针了。我们通过确保尽可能多的存储在堆栈上来减少垃圾收集器的工作量。结构切片或原始类型的数据在内存中按顺序排列,以便快速访问。当垃圾收集器确实工作时,它被优化为快速返回而不是收集最多的垃圾。使这种方法发挥作用的关键是首先简单地产生更少的垃圾。虽然专注于优化内存分配感觉像是过早的优化,但 Go 中的惯用方法也是最有效的。
如果如果您想了解更多关于 Go 中的堆与堆栈分配和逃逸分析的信息,有涵盖该主题的优秀博客文章,包括Arden Labs 的 Bill Kennedy 和Segment 的 Achille Roussel 和 Rick Branson 的文章。
本章稍微深入了解一下,以帮助我们理解指针、它们是什么、如何使用它们,以及最重要的是,何时使用它们。在下一章中,我们将看看 Go 的方法、接口和类型的实现,它们与其他语言的区别,以及它们拥有的强大功能。