Go面试题(一)
1、空切片 和 nil 切片 区别
空切片:
空切片是指长度和容量都为0的切片。它不包含任何元素,但仍然具有切片的容量属性。在Go语言中,可以使用内置的make函数创建一个空切片,例如:
emptySlice := make([]int)
这个语句创建了一个长度为0、容量为0的空切片。需要注意的是,空切片与nil切片不同,它具有容量属性,即分配了内存空间
用途:可以使用空切片作为初始值或者作为函数的返回值,注意扩容开销
nil切片:
nil切片是指长度和容量都为0的切片,并且没有指向任何底层数组。在Go语言中,可以使用内置的make函数创建一个长度和容量都为0的切片,并将其值赋给一个nil切片的变量,例如:
nilSlice := make([]int)
nilSlice = nil
在这个例子中,nilSlice最初被赋值为一个长度和容量都为0的切片。然后,我们将其值设置为nil,使其成为nil切片。需要注意的是,nil切片不具有容量属性,即没有分配内存空间。
用途:表示一个没有值的切片,将变量赋值为nil可以清除其原有的值和容量信息
总结:使用空切片时需要注意容器的扩容开销;使用nil切片时需要注意长度和容量的初始化问题。通过正确地理解和使用这两种切片类型。
2、字符串转成 byte 数组,会发生内存拷贝吗
在 Go 语言中,字符串是不可变的字节序列,而字节数组是可变的字节序列。当将字符串转换为字节数组时,会发生内存拷贝。
需要注意的是,由于发生了内存拷贝,所以在将字符串转换为字节数组时会产生额外的内存开销。在处理大型字符串时,这可能会对性能和内存利用率产生一定的影响,特别是在频繁转换的情况下。因此,在性能敏感的场景中,需要谨慎使用字符串到字节数组的转换,并根据实际需求进行优化。
3、拷贝大切片一定比小切片代价大吗
并不是,所有切片的大小相同;三个字段(一个 uintptr,两个int)。切片中的第一个字是指向切片底层数组的指针,这是切片的存储空间,第二个字段是切片的长度,第三个字段是容量。将一个 slice 变量分配给另一个变量只会复制三个机器字。所以 拷贝大切片跟小切片的代价应该是一样的。
解释
SliceHeader是切片在go的底层结构。
type SliceHeader struct {Data uintptrLen intCap int
}
大切片跟小切片的区别无非就是 Len和 Cap的值比小切片的这两个值大一些,如果发生拷贝,本质上就是拷贝上面的三个字段。
4、空map和未初始化map区别
可以对未初始化的map进行取值,但取出来的东西是空:
var m1 map[string]string
fmt.Println(m1["1"])
不能对未初始化的map进行赋值,这样将会抛出一个异常:panic: assignment to entry in nil map
var m1 map[string]string
m1["1"] = "1"
通过fmt打印map时,空map和nil map结果是一样的,都为map[]。所以,这个时候别断定map是空还是nil,而应该通过map == nil来判断。
5、map最大容量
在Go语言的标准库中,map是一种常用的数据结构。Map的特点是无序的键值对,其中键是唯一的。在Golang中,map的最大容量是由无符号整数uint32类型表示的,在Go语言中为2^31-1,这使得map的容量达到了非常惊人的程度。
6、map 的 iterator 是否安全 能不能一边 delete 一边遍历
map 并不是一个线程安全的数据结构。同时读写一个 map 是未定义的行为,如果被检测到,会直接 panic。
上面说的是发生在多个协程同时读写同一个 map 的情况下。 如果在同一个协程内边遍历边删除,并不会检测到同时读写,理论上是可以这样做的。但是,遍历的结果就可能不会是相同的了,有可能结果遍历结果集中包含了删除的 key,也有可能不包含,这取决于删除 key 的时间:是在遍历到 key 所在的 bucket 时刻前或者后。
一般而言,这可以通过读写锁来解决:sync.RWMutex
。
读之前调用 RLock()
函数,读完之后调用 RUnlock()
函数解锁;写之前调用 Lock()
函数,写完之后,调用 Unlock()
解锁。
另外,sync.Map
是线程安全的 map,也可以使用。
7、检查一个给定的数组是否被排序
冒泡排序
package main
import "fmt"
func checkSortedArray(arr []int){sortedArray := truefor i:=0; i<=len(arr)-1; i++{for j:=0; j<len(arr)-1-i; j++{if arr[j]> arr[j+1]{sortedArray = falsebreak}}}if sortedArray{fmt.Println("Given array is already sorted.")} else {fmt.Println("Given array is not sorted.")}
}func main(){checkSortedArray([]int{1, 3, 5, 6, 7, 8})checkSortedArray([]int{1, 3, 5, 9, 4, 2})checkSortedArray([]int{9, 7, 4, 2, 1, -1})
}
8、深拷贝和浅拷贝比较
深拷贝和浅拷贝是编程中处理对象或数据结构复制时的两种主要策略。理解它们之间的基本概念和差异对于避免潜在的数据共享和修改冲突至关重要。
(1)、定义:
浅拷贝 是对对象的表面层次的复制。它创建一个新的对象,并复制原始对象的所有非引用类型字段的值。然而,对于引用类型的字段(如切片、映射、通道、接口和指向结构体或数组的指针),浅拷贝仅仅复制了引用的地址,而非引用的实际内容。这意味着新对象和原始对象共享相同的引用类型字段的数据。
深拷贝 深拷贝则是对对象的完全复制,包括对象引用的其他对象。它递归地遍历原始对象的所有字段,并创建新的内存空间来存储这些字段的值,包括引用类型字段所指向的实际数据。这样,深拷贝后的对象与原始对象在内存中是完全独立的,对其中一个对象的修改不会影响另一个对象。
(2)、区别
主要区别在于它们处理引用类型字段的方式
浅拷贝仅仅复制了引用的地址,因此新对象和原始对象共享相同的数据
深拷贝则创建了新的内存空间来存储引用类型字段的数据,确保新对象与原始对象完全独立
深拷贝需要递归地复制对象的所有字段,包括引用的其他对象,因此它通常比浅拷贝更加耗时和消耗内存,而浅拷贝则更加高效;
(3)、比较
为什么需要浅拷贝:性能更好 内存使用更少 共享状态
为什么需要深拷贝:独立性 生命周期管理 避免内存泄漏 数据安全性
9、slice 深拷贝和浅拷贝
slice 有[]T{}
、new
、make
三种声明方式;
slice 会在变量赋值时发生浅复制;
copy() 可以让 slice 进行深复制;
append 再操作切片时,切片空闲容量不足时会发生扩容。
10、几种深度拷贝(deepcopy)方法的性能对比
Go语言中所有赋值操作都是值传递,如果结构中不含指针,则直接赋值就是深度拷贝;
如果结构中含有指针(包括自定义指针,以及切片,map等使用了指针的内置类型),则数据源和拷贝之间对应指针会共同指向同一块内存,这时深度拷贝需要特别处理。
目前,有三种方法(在性能要求较高的情况下应该尽量避免使用前两者)
一是用gob序列化成字节序列再反序列化生成克隆对象;
二是先转换成json字节序列,再解析字节序列生成克隆对象;
三是针对具体情况,定制化拷贝。
11、map 扩容机制
(1)、概述
Map在编程过程中往往需要存储大量的键值对数据。当存储的键值对数量超过了map的初始容量时,map就会自动进行扩容。扩容是指当map达到一定的负载因子时,系统自动重新分配更大的内存,并将原有的键值对重新映射到新的内存空间上。这样可以避免因为数据过多而导致的性能下降。
(2)、Map扩容的触发条件
在Golang中,map的扩容是基于两个主要的触发条件:
- 当map存储的键值对数量超过了当前map的容量(cap),即loadFactor * cap。
- 当插入新的键值对到map中,而当前map的创建时间距离上一次扩容的时间小于2个tick。
loadFactor是指map目前已存储键值对数量与当前容量之间的比例。当键值对数量超过这个比例时,就会触发map的扩容。这个比例在Golang中默认为6.5,即map的键值对数量超过容量的6.5倍时,就会触发扩容。
(3)、Map扩容的过程
当触发了map的扩容条件后,Golang会进行以下操作:
- 计算新的容量,并分配新的内存空间。
- 将原有的键值对重新映射到新的内存空间上。
- 释放原有的内存空间。
具体来说,当map需要进行扩容时,会先根据当前map容量(cap)和键值对数量计算出新的容量(newCap)。然后,根据新的容量(newCap)分配新的内存空间,将原有的键值对重新映射到新的内存空间上。最后,释放原有的内存空间。
(4)、Map扩容对性能的影响
map的扩容机制虽然可以在存储大量键值对时保证性能的稳定,但是扩容过程本身是需要耗费时间和内存的。因此,在编写程序时,我们应尽量避免频繁地对map进行扩容操作,以提高程序的性能。
(5)、Map扩容的发生时机
在Golang中,map的扩容是非确定性的,即我们无法精确控制map的扩容时机。Golang会根据map的使用情况和当前存储的键值对数量来决定是否扩容。
在某些特殊情况下,我们可以通过手动触发map的扩容来控制扩容时机。可以通过向map插入一个空结构体或nil值来触发扩容。当然,这种做法需要谨慎使用,必要时才进行手动扩容,以避免不必要的性能开销。
(6)、扩容策略
map扩容时使用渐进式扩容:翻倍扩容 等量扩容
翻倍扩容
count/(2^B) > 6.5:当负载因子超过6.5时就会触发翻倍扩容。
等量扩容
虽然没有超过负载因子限制,但是使用溢出桶过多,就会触发等量扩容,创建和旧桶数目一样多的新桶,然后把原来的键值对迁移到新桶中。
12、array 和 slice 的区别
(1)、数组(Array)
数组是Go语言中的基础数据结构,用于存储固定数量的同一类型的元素。数组的长度是数组类型的一部分,因此 [5]int 和 [10]int 是不同的类型。一旦定义,数组的长度就不能改变。
示例代码
var arr [5]int // 声明一个长度为5的整数数组
arr[0] = 1 // 给第一个元素赋值
arr[1] = 2 // 给第二个元素赋值
// ...
数组的缺点
固定长度:一旦声明,数组的长度就不能改变,这限制了其灵活性。
不便的传递:当你需要将数组作为参数传递给函数时,你会传递数组的副本,这可能会导致性能问题,特别是当数组很大时。
(2)、切片(Slice)
切片是对数组的抽象,提供了动态大小的、灵活的、可变的序列。切片本身并不存储数据,而是描述了一个底层数组的一部分(或全部)。切片有一个长度和一个容量,长度是切片当前包含的元素数量,容量是底层数组从切片起始位置到数组末尾的元素数量。
示例代码
arr := [5]int{1, 2, 3, 4, 5} // 声明一个长度为5的整数数组
slice := arr[1:4] // 从数组arr中创建一个切片,包含元素2, 3, 4
切片的优点
动态大小:切片的长度可以在运行时改变,使其比数组更加灵活。
引用传递:当切片作为参数传递给函数时,传递的是对底层数组的引用,而不是数组的副本,这可以提高性能。
更方便的操作:Go语言标准库提供了许多内置函数和操作符来操作切片,使得对切片进行排序、搜索等操作变得更加容易。
(3)、场景
数组适用于那些确实需要固定大小序列的场景,比如算法竞赛中的静态数组。
切片则更适用于那些需要动态大小序列的场景,比如处理用户输入的数据或构建复杂的数据结构。由于切片本身只是一个小的数据结构,包含指向底层数组的指针、长度和容量,因此传递切片实际上是非常高效的。
13、make 和 new 什么区别
(1)
make
是一个用于创建切片、映射(map)和通道(channel)的引用类型的内置函数。make
的主要作用是为这些引用类型分配内存并进行初始化。
创建切片(slice)
创建映射(map)
创建通道(channel)
注意事项:
make
只能用于引用类型的数据结构,不能用于值类型(例如结构体)的创建。
make
返回被初始化的引用类型实例,而不是指针。
对于切片和映射,make
除了分配内存,还会初始化内部的数据结构,确保它们可以被直接使用。
对于通道,make
会创建并返回一个未被缓冲的通道。
(2)new
是一个用于为值类型分配内存并返回指向新分配的零值实例的指针的内置函数。new
主要用于创建值类型的实例,例如结构体创建值类型实例创建结构体实例
注意事项:
new 返回一个指向新分配内存的零值实例的指针。
对于值类型,new 分配的内存会被初始化为零值。
new 接受一个参数,即要分配内存的类型,并返回一个指向该类型的零值的指针。
new 不适用于引用类型(如切片、映射和通道),只能用于值类型的创建。
new 分配的内存不会被清理,需要程序员负责释放。
14、for 循环select时,通道已经关闭会怎样 如果select中的case只有一个,会怎样
for循环select时,如果其中一个case通道已经关闭,则每次都会执行到这个case。
如果select里边只有一个case,而这个case被关闭了,则会出现死循环
如果没有default字句,select将有可能阻塞,直到某个通道有值可以运行,所以select里最好有一个default,否则将有一直阻塞的风险。
15、如何避免内存逃逸
内存逃逸是指原本应该在栈上分配的内存被分配到了堆上。这意味着即使函数返回后,这部分内存也不会被自动释放,需要等待垃圾回收器来回收。
package mainimport "fmt"type User struct {Name string
}func main() {var user *Useruser = getUser()fmt.Println(user.Name)
}func getUser() *User {u := User{Name: "Alice"}return &u
}
getUser 函数创建了一个 User 类型的局部变量 u,并返回了它的地址。由于 u 的引用在函数外部被使用(即在 `main` 函数中),所以会发生逃逸
如何避免内存逃逸
- 严格限制变量的作用域。如果一个变量只在函数内部使用,就不要将其返回或赋值给外部变量。
- 使用值而不是指针,当不必要的时候,尽量使用值传递而不是指针传递。
- 池化对象,对于频繁创建和销毁的对象,考虑使用对象池技术进行复用,减少在堆上分配和回收对象的次数。
- 尽量避免在循环或频繁调用的函数中创建闭包,以减少外部变量的引用和堆分配,避免使用不必要的闭包,闭包可能会导致内存逃逸。
- 优化数据结构,使用固定大小的数据结构,避免使用动态大小的切片和 map。比如使用数组而不是切片,因为数组的大小在编译时就已确定。
- 预分配切片和 map 的容量,如果知道切片或 map 的大小,预先分配足够的容量可以避免在运行时重新分配内存。
16、内存泄漏的原因和处理方法
即使有垃圾回收机制,但在编写Go程序时仍然可能发生内存泄漏。内存泄漏是指程序中不再使用的内存没有被正确释放,最终导致内存占用过高。下面是一些常见的导致内存泄漏的原因以及相应的处理方法:
循环引用
循环引用指的是两个或多个对象之间相互引用,导致它们无法被垃圾回收器正确地回收。为了解决循环引用导致的内存泄漏,可以使用弱引用(Weak Reference)来替代强引用(Strong Reference),或者手动将其中一个对象的引用置为空。
忘记关闭文件或网络连接
在使用文件或网络资源时,如果忘记关闭这些资源,会导致文件描述符或网络连接句柄没有被释放,最终导致内存泄漏。为了避免这种情况发生,可以使用defer
语句或者io.Closer
接口来确保资源的正确关闭。
大量创建临时对象
在循环中大量创建临时对象,并未及时释放,会导致内存占用过高。为了避免这种情况,可以通过复用对象或者使用对象池来减少对象的创建和销毁次数。
Goroutine泄漏
如果Goroutine在执行完毕后没有正确退出,会导致Goroutine所占用的资源无法释放,从而引起内存泄漏。为了避免这种情况发生,可以使用sync.WaitGroup
来等待所有Goroutine执行完毕,或者使用context.Context
来控制Goroutine的生命周期。
最佳实践
以下是一些使用Go语言进行内存管理的最佳实践:
- 避免不必要的内存分配,尽量复用对象或者使用对象池。
- 及时释放不再使用的资源,如文件、网络连接等。
- 避免循环引用导致的内存泄漏,及时将无用对象置为空。
- 使用
defer
语句或者io.Closer
接口来确保资源的正确关闭。 - 使用
sync.WaitGroup
等待所有Goroutine执行完毕,避免Goroutine泄漏。
17、简单介绍sync.Pool使用场景
sync.Pool 是 Golang 内置的对象池技术,可用于缓存临时对象,以缓解因频繁建立临时对象带来的性能损耗以及对 GC 带来的压力。
但sync.Pool 缓存的对象随时可能被无通知的清除,因此不能将 sync.Pool 用于存储持久对象的场景。
所有sync.Pool的缓存对象数量是没有限制的(只受限于内存),因此使用sync.pool是没办法做到控制缓存对象数量的个数的。
sync.Pool 本质用途是增加临时对象的重用率,减少 GC 负担。划重点:临时对象。所以说,像 socket 这种带状态的,长期有效的资源是不适合 Pool 的。
总结:
-
sync.Pool 本质用途是增加临时对象的重用率,减少 GC 负担;
-
不能对 Pool.Get 出来的对象做预判,有可能是新的(新分配的),有可能是旧的(之前人用过,然后 Put 进去的);
-
不能对 Pool 池里的元素个数做假定,你不能够;
-
sync.Pool 本身的 Get, Put 调用是并发安全的,
sync.New
指向的初始化函数会并发调用,里面安不安全只有自己知道; -
当用完一个从 Pool 取出的实例时候,一定要记得调用 Put,否则 Pool 无法复用这个实例,通常这个用 defer 完成;
18、sync.map 的优缺点和使用场景
-
优点:Go 官方所出;通过读写分离,降低锁时间来提高效率;线程安全的map;
-
缺点:不适用于大量写的场景,这样会导致 read map 读不到数据而进一步加锁读取,同时 dirty map 也会一直晋升为 read map,整体性能较差,甚至没有单纯的 map+metux 高。
适用场景:读多写少的场景。
通过这种读写分离的设计,解决了并发场景下的写入安全,又使读取速度在大部分情况可以接近内建 map,非常适合读多写少的情况。
19、uintptr和unsafe.Pointer的区别
- unsafe.Pointer只是单纯的通用指针类型,用于转换不同类型指针,它不可以参与指针运算;
- 而uintptr是用于指针运算的,GC 不把 uintptr 当指针,也就是说 uintptr 无法持有对象, uintptr 类型的目标会被回收;
- unsafe.Pointer 可以和 普通指针 进行相互转换;
- unsafe.Pointer 可以和 uintptr 进行相互转换。
案例
package mainimport ("fmt""unsafe"
)type W struct {b int32c int64
}func main() {var w *W = new(W)//这时w的变量打印出来都是默认值0,0fmt.Println(w.b,w.c)//现在我们通过指针运算给b变量赋值为10b := unsafe.Pointer(uintptr(unsafe.Pointer(w)) + unsafe.Offsetof(w.b))*((*int)(b)) = 10//此时结果就变成了10,0fmt.Println(w.b,w.c)
}
uintptr(unsafe.Pointer(w))
获取了w
的指针起始值
unsafe.Offsetof(w.b)
获取b
变量的偏移量
- 两个
相加
就得到了b
的地址值
,将通用指针Pointer
转换成具体指针((*int)(b))
,通过*
符号取值,然后赋值。*((*int)(b))
相当于把(*int)(b)
转换成int
了,最后对变量重新赋值成10
,这样指针运算就完成了。
20 、协程和线程的差别
进程:进程是操作系统对一个正在运行的程序的一种抽象,进程是资源分配的最小单位;进程就是应用程序的启动实例。比如我们运行一个游戏,打开一个软件,就是开启了一个进程。
线程:线程从属于进程,是程序的实际执行者。一个进程至少包含一个主线程,也可以有更多的子线程。多线程比多进程之间更容易共享数据,在上下文切换中线程一般比进程更高效。
协程:是用户态的线程。通常创建协程时,会从进程的堆中分配一段内存作为协程的栈, 是一种比线程更加轻量级的存在。正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。最重要的是,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态中执行)。
线程的栈有 8 MB,而协程栈的大小通常只有 KB,而 Go 语言的协程更夸张,只有 2-4KB,非常的轻巧。
协程的优势如下:
节省 CPU:避免系统内核级的线程频繁切换,造成的 CPU 资源浪费。好钢用在刀刃上。而协程是用户态的线程,用户可以自行控制协程的创建于销毁,极大程度避免了系统级线程上下文切换造成的资源浪费。
节约内存:在 64 位的Linux中,一个线程需要分配 8MB 栈内存和 64MB 堆内存,系统内存的制约导致我们无法开启更多线程实现高并发。而在协程编程模式下,可以轻松有十几万协程,这是线程无法比拟的。
稳定性:前面提到线程之间通过内存来共享数据,这也导致了一个问题,任何一个线程出错时,进程中的所有线程都会跟着一起崩溃。
开发效率:使用协程在开发程序之中,可以很方便的将一些耗时的IO操作异步化,例如写文件、耗时 IO 请求等。
协程本质上就是用户态下的线程,所以也有人说协程是 “轻线程”,但我们一定要区分用户态和内核态的区别,很关键。
21、开源库里会有一些类似下面这种奇怪的用法:var _ io.Writer = (*myWriter)(nil)
,是为什么?
上述赋值语句会发生隐式地类型转换,在转换的过程中,编译器会检测等号右边的类型是否实现了等号左边接口所规定的函数。
总结一下,可通过在代码中添加类似如下的代码,用来检测类型是否实现了接口:
package mainimport "io"type myWriter struct {}/*func (w myWriter) Write(p []byte) (n int, err error) {return
}*/func main() {// 检查 *myWriter 类型是否实现了 io.Writer 接口var _ io.Writer = (*myWriter)(nil)// 检查 myWriter 类型是否实现了 io.Writer 接口var _ io.Writer = myWriter{}
}
注释掉为 myWriter 定义的 Write 函数后,运行程序:
|
报错信息:*myWriter/myWriter 未实现 io.Writer 接口,也就是未实现 Write 方法。
解除注释后,运行程序不报错。
22、协程之间是怎么调度的
要点:GMP模型
Gorutine从入队到执行
(1)当我们创建一个G对象,就是 gorutine,它会加入到本地队列或者全局队列
(2)如果还有空闲的P,则创建一个M 绑定该 P ,注意!这里,P 此前必须还没绑定过M 的,否则不满足空闲的条件。细节点:
先找到一个空闲的P,如果没有则直接返回
P 个数不会占用超过自己设定的cpu个数
P 在被 M 绑定后,就会初始化自己的 G 队列,此时是一个空队列
注意这里的一个点!
无论在哪个 M 中创建了一个 G,只要 P 有空闲的,就会引起新 M 的创建
不需考虑当前所在 M 中所绑的 P 的 G 队列是否已满
新创建的 M 所绑的 P 的初始化队列会从其他 G 队列中取任务过来
这里留下第一个问题: --协程的切换时间片是10ms,也就是说 goroutine 最多执行10ms就会被 M 切换到下一个 G。这个过程,又被称为 |
(3)M 会启动一个底层线程,循环执行能找到的 G 任务。这里的寻找的 G 从下面几方面找:
当前 M 所绑的 P 队列中找
去别的 P 的队列中找
去全局 G 队列中找
(4)G任务的执行顺序是,先从本地队列找,本地没有则从全局队列找
(5)程序启动的时候,首先跑的是主线程,然后这个主线程会绑定第一个 P
(6)入口 main 函数,其实是作为一个 goroutine 来执行。
23、gc 的 stw 是怎么回事
停止-世界(Stop-The-World, STW): 停止-世界是垃圾回收过程中的一种情况,此时程序的所有正常执行都会被暂停,以便垃圾回收器能够执行,比如标记(marking)和清除(sweeping)内存中的不再使用的对象。STW事件的时间越短,对用户体验的影响就越小,尤其是在交互式应用和实时系统中。
Go语言中的垃圾回收: Go语言设计了一个并发的、低延迟的垃圾回收器,其特点是在尽可能不影响程序运行的情况下,执行内存的垃圾回收。Go的GC设计选择了牺牲一部分吞吐量来换取更短的STW时间。这意味着Go的垃圾回收器可能比那些优化了吞吐量的回收器更频繁地运行,但每次停止程序的时间非常短,因此用户几乎感觉不到延迟。
Golang Gc回收算法:三色标记算法+混合写屏障机制
24、两个interface能否比较
在Go语言中,接口是一种引用类型。如果我们需要比较两个接口对象是否相等,实际上是在比较它们指向内存区域的地址。如果两个接口指向的对象在内存中的地址相同,那么它们就是相等的。否则,它们是不相等的。
因此,在Go语言中,两个接口可以进行比较,但实际上比较的是它们指向的内存地址。例如:
var a interface{} = "hello"
var b interface{} = "hello"fmt.Println(a == b) // 输出 false
在上述代码中,我们声明了两个interface{}类型的变量a和b,它们都指向同一个字符串对象"hello"。尽管它们指向的对象相同,但由于它们指向的内存地址不同,所以在比较时会返回false。
注意事项:
在比较两个接口对象时,我们需要注意以下几点:
(1).接口比较实际上是比较它们指向的内存地址,而不是比较它们的值。
(2).如果我们需要比较接口对象的值,可以在接口类型中定义一个Equal方法,根据具体的情况进行比较。
(3).在实现Equal方法时,需要将other参数转换为具体的类型,并逐个比较属性值。如果other不能转换为当前类型,应该返回false。
25、必须要手动对齐内存的情况
Go 语言内存对齐机制是为了优化内存访问和提高性能而设计的。为了能让CPU可以更快的存取到各个字段,Go编译器会帮你把struct结构体做数据的对齐。所谓的数据对齐,是指内存地址是所存储数据大小(按字节为单位)的整数倍,以便CPU可以一次将该数据从内存中读取出来。编译器通过在结构体的各个字段之间填充一些空白已达到对齐的目的。
绝大部分情况下,go编译器会帮我们自动内存对齐,我们不需要关心内存是否对齐,但是在有一种情况下,需要手动对齐。
在 x86 平台上原子操作 64bit 指针。之所以要强制对齐,是因为在 32bit 平台下进行 64bit 原子操作要求必须 8 字节对齐,否则程序会 panic。
type T3 struct {b int64c int32d int64
}func main() {a := T3{}atomic.AddInt64(&a.d, 1)
}
原因就是 T3 在 32bit 平台上是 4 字节对齐,而在 64bit 平台上是 8 字节对齐。在 64bit 平台上其内存布局为:
Figure 4: T3在 amd64 的内存布局
但是在I386 的布局为:
Figure 5: T3在 i386的内存布局
为了解决这种情况,我们必须手动 padding T3,让其 “看起来” 像是 8 字节对齐的:
type T3 struct {b int64c int32_ int32d int64
}