首页 golang

golang面试题-基础

发布于: 2024-04-23

=:= 的区别?

=是赋值变量,:=是定义变量。

指针的作用

一个指针可以指向任意变量的地址,它所指向的地址在32位或64位机器上分别固定占4或8个字节。指针的作用有:

获取变量的值

1
2
3
4
5
func main(){
a := 1
p := &a//取址&
fmt.Printf("%d\n", *p);//取值*
}

改变变量的值

1
2
3
func swap(a, b *int) {
*a, *b = *b, *a
}

用指针替代值传入函数,比如类的接收器就是这样的。

1
2
type A struct{}
func (a *A) fun(){}

Go 允许多个返回值吗?

可以。通常函数除了一般返回值还会返回一个error。

Go 有异常类型吗?

有。Go用error类型代替try…catch语句,这样可以节省资源。同时增加代码可读性:

1
2
3
4
5
 _, err := funcDemo()
if err != nil {
fmt.Println(err)
return
}

也可以用errors.New()来定义自己的异常。errors.Error()会返回异常的字符串表示。只要实现error接口就可以定义自己的异常,

1
2
3
4
5
6
7
8
9
10
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}
// 多一个函数当作构造函数
func New(text string) error {
return &errorString{text}
}

什么是协程(Goroutine)

协程是用户态轻量级线程,它是线程调度的基本单位。通常在函数前加上go关键字就能实现并发。一个Goroutine会以一个很小的栈启动2KB或4KB,当遇到栈空间不足时,栈会自动伸缩, 因此可以轻易实现成千上万个goroutine同时启动。

如何高效地拼接字符串

拼接字符串的方式有:+ , fmt.Sprintf , strings.Builder, bytes.Buffer, strings.Join

方式解释
+使用+操作符进行拼接时,会对字符串进行遍历,计算并开辟一个新的空间来存储原来的两个字符串。
fmt.Sprintf由于采用了接口参数,必须要用反射获取值,因此有性能损耗。
strings.Builder用WriteString()进行拼接,内部实现是指针+切片,同时String()返回拼接后的字符串,它是直接把[]byte转换为string,从而避免变量拷贝。
bytes.Bufferbytes.Buffer是一个一个缓冲byte类型的缓冲器,这个缓冲器里存放着都是bytebytes.buffer底层也是一个[]byte切片。
strings.joinstrings.join也是基于strings.builder来实现的,并且可以自定义分隔符,在join方法内调用了b.Grow(n)方法,这个是进行初步的容量分配,而前面计算的n的长度就是我们要拼接的slice的长度,因为我们传入切片长度固定,所以提前进行容量分配可以减少内存分配,很高效。

性能比较:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func main(){
a := []string{"a", "b", "c"}
//方式1:+
ret := a[0] + a[1] + a[2]
//方式2:fmt.Sprintf
ret := fmt.Sprintf("%s%s%s", a[0],a[1],a[2])
//方式3:strings.Builder
var sb strings.Builder
sb.WriteString(a[0])
sb.WriteString(a[1])
sb.WriteString(a[2])
ret := sb.String()
//方式4:bytes.Buffer
buf := new(bytes.Buffer)
buf.Write(a[0])
buf.Write(a[1])
buf.Write(a[2])
ret := buf.String()
//方式5:strings.Join
ret := strings.Join(a,"")
}

strings.Join ≈ strings.Builder > bytes.Buffer > “+” > fmt.Sprintf

什么是 rune 类型

ASCII 码只需要 7 bit 就可以完整地表示,但只能表示英文字母在内的128个字符,为了表示世界上大部分的文字系统,发明了 Unicode, 它是ASCII的超集,包含世界上书写系统中存在的所有字符,并为每个代码分配一个标准编号(称为Unicode CodePoint),在 Go 语言中称之为 rune,是 int32 类型的别名。

Go 语言中,字符串的底层表示是 byte (8 bit) 序列,而非 rune (32 bit) 序列。

1
2
3
4
5
sample := "我爱GO"
runeSamp := []rune(sample)
runeSamp[0] = '你'
fmt.Println(string(runeSamp)) // "你爱GO"
fmt.Println(len(runeSamp)) // 4

如何判断 map 中是否包含某个 key ?

1
2
3
4
5
6
var sample map[int]int
if _, ok := sample[10]; ok {
//TODO 存在逻辑
} else {
//TODO 不存在裸机
}

Go 支持默认参数或可选参数吗?

不支持。但是可以利用结构体参数,或者…传入参数切片数组。

1
2
3
4
5
6
7
func sum(nums ...int) {
total := 0
for _, num := range nums {
total += num
}
fmt.Println(total)
}

defer 的执行顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func test() int {
i := 0
defer func() {
fmt.Println("defer2")
}()
defer func() {
i += 1
fmt.Println("defer3")
}()
return i
}

func main() {
defer func() {
fmt.Println("defter 1")
}()
fmt.Println("return", test())
}

//defer3
//defer2
//return 0
//defter 1

上面这个例子中,test返回值并没有修改,这是由于Go的返回机制决定的,执行Return语句后,Go会创建一个临时变量保存返回值。如果是有名返回(也就是指明返回值func test() (i int)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func test() (i int) {
i = 0
defer func() {
i += 1
fmt.Println("defer2")
}()
return i
}

func main() {
fmt.Println("return", test())
}
// defer2
// return 1

这个例子中,返回值被修改了。对于有名返回值的函数,执行 return 语句时,并不会再创建临时变量保存,因此,defer 语句修改了 i,即对返回值产生了影响。

如何交换 2 个变量的值?

对于变量而言a,b = b,a; 对于指针而言*a,*b = *b, *a

Go 语言 tag 的用处?

tag可以为结构体成员提供属性。常见的:

  1. json序列化或反序列化时字段的名称
  2. db: sqlx模块中对应的数据库字段名
  3. form: gin框架中对应的前端的数据字段名
  4. binding: 搭配 form 使用, 默认如果没查找到结构体中的某个字段则不报错值为空, binding为 required 代表没找到返回错误给前端

如何获取一个结构体的所有tag?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import (
"fmt"
"reflect"
)

type Author struct {
Name int `json:"name"`
Publications []string `json:"publications,omitempty"`
}

func main() {
t := reflect.TypeOf(Author{})
for i := 0; i < t.NumField(); i++ {
name := t.Field(i).Name
s, _ := t.FieldByName(name)
fmt.Println(name, s.Tag)
}
}

上述例子中,reflect.TypeOf方法获取对象的类型,之后NumField()获取结构体成员的数量。 通过Field(i)获取第i个成员的名字。 再通过其Tag 方法获得标签。

如何判断 2 个字符串切片(slice) 是相等的?

reflect.DeepEqual() , 但反射非常影响性能。

结构体打印时,%v%+v 的区别

%v输出结构体各成员的值;

%+v输出结构体各成员的名称和值;

%#v输出结构体名称和结构体各成员的名称和值

Go 语言中如何表示枚举值(enums)?

在常量中用iota可以表示枚举。iota从0开始。

1
2
3
4
5
6
7
8
9
const (
B = 1 << (10 * iota)
KiB
MiB
GiB
TiB
PiB
EiB
)

空 struct{} 的用途

用map模拟一个set,那么就要把值置为struct{},struct{}本身不占任何空间,可以避免任何多余的内存分配。

1
2
3
4
5
6
7
8
9
10
11
12
type Set map[string]struct{}

func main() {
set := make(Set)
for _, item := range []string{"A", "A", "B", "C"} {
set[item] = struct{}{}
}
fmt.Println(len(set)) // 3
if _, ok := set["A"]; ok {
fmt.Println("A exists") // A exists
}
}

有时候给通道发送一个空结构体,channel<-struct{}{},也是节省了空间。

1
2
3
4
5
6
7
8
9
func main() {
ch := make(chan struct{}, 1)
go func() {
<-ch
// do something
}()
ch <- struct{}{}
// ...
}

仅有方法的结构体

1
type Lamp struct{}

go里面的int和int32是同一个概念吗?

不是一个概念!千万不能混淆。go语言中的int的大小是和操作系统位数相关的,如果是32位操作系统,int类型的大小就是4字节。如果是64位操作系统,int类型的大小就是8个字节。除此之外uint也与操作系统有关。

int8占1个字节,int16占2个字节,int32占4个字节,int64占8个字节。

init() 函数是什么时候执行的?

init()函数是go初始化的一部分,由runtime初始化每个导入的包,初始化不是按照从上到下的导入顺序,而是按照解析的依赖关系,没有依赖的包最先初始化。

每个包首先初始化包作用域的常量和变量(常量优先于变量),然后执行包的init()函数。同一个包,甚至是同一个源文件可以有多个init()函数。init()函数没有入参和返回值,不能被其他函数调用,同一个包内多个init()函数的执行顺序不作保证。

执行顺序:import –> const –> var –>init()–>main()

一个文件可以有多个init()函数!

如何知道一个对象是分配在栈上还是堆上?

Go和C++不同,Go局部变量会进行逃逸分析。如果变量离开作用域后没有被引用,则优先分配到栈上,否则分配到堆上。那么如何判断是否发生了逃逸呢?

1
go build -gcflags '-m -m -l' xxx.go

关于逃逸的可能情况:变量大小不确定,变量类型不确定,变量分配的内存超过用户栈最大值,暴露给了外部指针。

2 个 interface 可以比较吗 ?

Go 语言中,interface 的内部实现包含了 2 个字段,类型 T 和 值 V,interface 可以使用 ==!= 比较。2 个 interface 相等有以下 2 种情况

  1. 两个 interface 均等于 nil(此时 V 和 T 都处于 unset 状态)
  2. 类型 T 相同,且对应的值 V 相等。
1
2
3
4
5
6
7
8
9
10
type Stu struct {
Name string
}
type StuInt interface{}
func main() {
var stu1, stu2 StuInt = &Stu{"Tom"}, &Stu{"Tom"}
var stu3, stu4 StuInt = Stu{"Tom"}, Stu{"Tom"}
fmt.Println(stu1 == stu2) // false
fmt.Println(stu3 == stu4) // true
}

stu1stu2 对应的类型是 *Stu,值是 Stu 结构体的地址,两个地址不同,因此结果为 false。
stu3stu4 对应的类型是 Stu,值是 Stu 结构体,且各字段相等,因此结果为 true。

2 个 nil 可能不相等吗?

可能不等。interface在运行时绑定值,只有值为nil接口值才为nil,但是与指针的nil不相等。举个例子:

1
2
3
4
5
var p *int = nil
var i interface{} = nil
if(p == i){
fmt.Println("Equal")
}

两者并不相同。总结:两个nil只有在类型相同时才相等。

函数返回局部变量的指针是否安全?

这一点和C++不同,在Go里面返回局部变量的指针是安全的。因为Go会进行逃逸分析,如果发现局部变量的作用域超过该函数则会把指针分配到堆区,避免内存泄漏。

非接口的任意类型 T() 都能够调用 *T 的方法吗?反过来呢?

一个T类型的值可以调用*T类型声明的方法,当且仅当T是可寻址的。

反之:*T 可以调用T()的方法,因为指针可以解引用。

slice是怎么扩容的?

Go <= 1.17: 如果当前容量小于1024,则判断所需容量是否大于原来容量2倍,如果大于,当前容量加上所需容量;否则当前容量乘2。如果当前容量大于1024,则每次按照1.25倍速度递增容量,也就是每次加上cap/4。

Go>=1.18:

1
2
nums := []int{1, 2}
nums = append(nums, 2, 3, 4)

此时old.cap = 2,容量至少为cap=5,那么就简单的扩容让cap=5了吗?

src/runtime/slice.go的166行处定义了扩容slice的函数。

1
2
3
func growslice(et *_type, old slice, cap int) slice {
//
}

计算预估容量newcap

变量含义说明
old.cap扩容前切片容量
newcap预估容量默认为扩容前切片容量(old.cap)
cap扩容后至少需要的最小容量old.cap + 本次新增的元素数量
doublecap扩容前切片的2倍容量old.cap * 2

其中,当扩容前容量 >= 256时,会按照公式进行扩容,

1
newcap += (newcap + 3*threshold) / 4

回到1.18案例代码进行解释:old.cap = 2, cap = 2 + 3 = 5,那么由于 cap > old.cap *2 ,所以预估容量 newcap = cap = 5

为什么有协程泄露(Goroutine Leak)?

协程泄漏是指协程创建之后没有得到释放。主要原因有:

  1. 缺少接收器,导致发送阻塞
  2. 缺少发送器,导致接收阻塞
  3. 死锁。多个协程由于竞争资源导致死锁。
  4. 创建协程的没有回收。

Go 可以限制运行时操作系统线程的数量吗? 常见的goroutine操作函数有哪些?

可以,使用runtime.GOMAXPROCS(num int)可以设置线程数目。该值默认为CPU逻辑核数,如果设的太大,会引起频繁的线程切换,降低性能。

runtime.Gosched(),用于让出CPU时间片,让出当前goroutine的执行权限,调度器安排其它等待的任务运行,并在下次某个时候从该位置恢复执行。
runtime.Goexit(),调用此函数会立即使当前的goroutine的运行终止(终止协程),而其它的goroutine并不会受此影响。runtime.Goexit在终止当前goroutine前会先执行此goroutine的还未执行的defer语句。请注意千万别在主函数调用runtime.Goexit,因为会引发panic。

如何控制协程数目。

The GOMAXPROCS variable limits the number of operating system threads that can execute user-level Go code simultaneously. There is no limit to the number of threads that can be blocked in system calls on behalf of Go code; those do not count against the GOMAXPROCS limit.

从官方文档的解释可以看到,GOMAXPROCS 限制的是同时执行用户态 Go 代码的操作系统线程的数量,但是对于被系统调用阻塞的线程数量是没有限制的。GOMAXPROCS 的默认值等于 CPU 的逻辑核数,同一时间,一个核只能绑定一个线程,然后运行被调度的协程。因此对于 CPU 密集型的任务,若该值过大,例如设置为 CPU 逻辑核数的 2 倍,会增加线程切换的开销,降低性能。对于 I/O 密集型应用,适当地调大该值,可以提高 I/O 吞吐率。

可以用带缓冲区的channel来控制,下面的例子是协程数为1024的例子

1
2
3
4
5
6
7
8
9
10
11
var wg sync.WaitGroup
ch := make(chan struct{}, 1024)
for i:=0; i<20000; i++{
wg.Add(1)
ch<-struct{}{}
go func(){
defer wg.Done()
<-ch
}
}
wg.Wait()

new和make的区别?

new只用于分配内存,返回一个指向地址的指针。它为每个新类型分配一片内存,初始化为0且返回类型*T的内存地址,它相当于&T{}

make只可用于slice,map,channel的初始化,返回的是引用。

请你讲一下Go面向对象是如何实现的?

Go实现面向对象的两个关键是struct和interface。

封装:对于同一个包,对象对包内的文件可见;对不同的包,需要将对象以大写开头才是可见的。

继承:继承是编译时特征,在struct内加入所需要继承的类即可:

1
2
3
4
type A struct{}
type B struct{
A
}

多态:多态是运行时特征,Go多态通过interface来实现。类型和接口是松耦合的,某个类型的实例可以赋给它所实现的任意接口类型的变量。

Go支持多重继承,就是在类型中嵌入所有必要的父类型。

uint型变量值分别为 1,2,它们相减的结果是多少?

1
2
3
4
5
6
func main() {
var a uint = 1
var b uint = 2
fmt.Println(a - b)
}
//18446744073709551615

答案,结果会溢出,如果是32位系统,结果是2^32-1,如果是64位系统,结果2^64-1.

讲一下go有没有函数在main之前执行?怎么用?

go的init函数在main函数之前执行,它有如下特点:

1
2
3
func init() {
//TODO
}
  • 初始化不能采用初始化表达式初始化的变量;
  • 程序运行前执行注册
  • 实现sync.Once功能
  • 不能被其它函数调用
  • init函数没有入口参数和返回值:
  • 每个包可以有多个init函数,每个源文件也可以有多个init函数。
  • 同一个包的init执行顺序,golang没有明确定义,编程时要注意程序不要依赖这个执行顺序。
  • 不同包的init函数按照包导入的依赖关系决定执行顺序。

下面这句代码是什么作用,为什么要定义一个空值?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type GobCodec struct{
conn io.ReadWriteCloser
buf *bufio.Writer
dec *gob.Decoder
enc *gob.Encoder
}

type Codec interface {
io.Closer
ReadHeader(*Header) error
ReadBody(interface{}) error
Write(*Header, interface{}) error
}

var _ Codec = (*GobCodec)(nil)

答:将nil转换为GobCodec类型,然后再转换为Codec接口,如果转换失败,说明GobCodec没有实现Codec接口的所有方法。

mutex有几种模式?

mutex有两种模式:normal 和 starvation

正常模式:所有goroutine按照FIFO的顺序进行锁获取,被唤醒的goroutine和新请求锁的goroutine同时进行锁获取,通常新请求锁的goroutine更容易获取锁(持续占有cpu),被唤醒的goroutine则不容易获取到锁。公平性:否。

饥饿模式:所有尝试获取锁的goroutine进行等待排队,新请求锁的goroutine不会进行锁获取(禁用自旋),而是加入队列尾部等待获取锁。公平性:是。

Go什么时候发生阻塞?阻塞时,调度器会怎么做。

  • 用于原子、互斥量或通道操作导致goroutine阻塞,调度器将把当前阻塞的goroutine从本地运行队列LRQ换出,并重新调度其它goroutine;
  • 由于网络请求和IO导致的阻塞,Go提供了网络轮询器(Netpoller)来处理,后台用epoll等技术实现IO多路复用。

其它回答:

  • channel阻塞:当goroutine读写channel发生阻塞时,会调用gopark函数,该G脱离当前的M和P,调度器将新的G放入当前M。
  • 系统调用:当某个G由于系统调用陷入内核态,该P就会脱离当前M,此时P会更新自己的状态为Psyscall,M与G相互绑定,进行系统调用。结束以后,若该P状态还是Psyscall,则直接关联该M和G,否则使用闲置的处理器处理该G。
  • 系统监控:当某个G在P上运行的时间超过10ms时候,或者P处于Psyscall状态过长等情况就会调用retake函数,触发新的调度。
  • 主动让出:由于是协作式调度,该G会主动让出当前的P(通过GoSched),更新状态为Grunnable,该P会调度队列中的G运行。

如果有一个G一直占用资源怎么办?什么是work stealing算法?

如果有个goroutine一直占用资源,那么GMP模型会从正常模式转变为饥饿模式(类似于mutex),允许其它goroutine使用work stealing抢占(禁用自旋锁)。

work stealing算法指,一个线程如果处于空闲状态,则帮其它正在忙的线程分担压力,从全局队列取一个G任务来执行,可以极大提高执行效率。

goroutine什么情况会发生内存泄漏?如何避免。

在Go中内存泄露分为暂时性内存泄露和永久性内存泄露。

暂时性内存泄露

  • 获取长字符串中的一段导致长字符串未释放
  • 获取长slice中的一段导致长slice未释放
  • 在长slice新建slice导致泄漏

string相比切片少了一个容量的cap字段,可以把string当成一个只读的切片类型。获取长string或者切片中的一段内容,由于新生成的对象和老的string或者切片共用一个内存空间,会导致老的string和切片资源暂时得不到释放,造成短暂的内存泄漏

永久性内存泄露

  • goroutine永久阻塞而导致泄漏
  • time.Ticker未关闭导致泄漏
  • 不正确使用Finalizer(Go版本的析构函数)导致泄漏

go竞态条件了解吗?

所谓竞态竞争,就是当两个或以上的goroutine访问相同资源时候,对资源进行读/写。

比如var a int = 0,有两个协程分别对a+=1,我们发现最后a不一定为2.这就是竞态竞争。

通常我们可以用go run -race xx.go来进行检测。

解决方法是,对临界区资源上锁,或者使用原子操作(atomics),原子操作的开销小于上锁。

如果若干个goroutine,有一个panic会怎么做?

有一个panic,那么剩余goroutine也会退出,程序退出。如果不想程序退出,那么必须通过调用 recover() 方法来捕获 panic 并恢复将要崩掉的程序。

defer可以捕获goroutine的子goroutine吗?

不可以。它们处于不同的调度器P中。对于子goroutine,必须通过 recover() 机制来进行恢复,然后结合日志进行打印(或者通过channel传递error),下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 心跳函数
func Ping(ctx context.Context) error {
... code ...

go func() {
defer func() {
if r := recover(); r != nil {
log.Errorc(ctx, "ping panic: %v, stack: %v", r, string(debug.Stack()))
}
}()

... code ...
}()

... code ...

return nil
}

channel 死锁的场景

  • 当一个channel中没有数据,而直接读取时,会发生死锁:
1
2
q := make(chan int,2)
<-q

解决方案是采用select语句,再default放默认处理方式:

1
2
3
4
5
6
7
q := make(chan int,2)
select{
case val:=<-q:
default:
...

}
  • 当channel数据满了,再尝试写数据会造成死锁:
1
2
3
4
q := make(chan int,2)
q<-1
q<-2
q<-3

解决方法,采用select

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
q := make(chan int, 2)
q <- 1
q <- 2
select {
case q <- 3:
fmt.Println("ok")
default:
fmt.Println("wrong")
}

}
  • 向一个关闭的channel写数据。

注意:一个已经关闭的channel,只能读数据,不能写数据。

对已经关闭的chan进行读写会怎么样?

  • 读已经关闭的chan能一直读到东西,但是读到的内容根据通道内关闭前是否有元素而不同。
  • 如果chan关闭前,buffer内有元素还未读,会正确读到chan内的值,且返回的第二个bool值(是否读成功)为true。
  • 如果chan关闭前,buffer内有元素已经被读完,chan内无值,接下来所有接收的值都会非阻塞直接成功,返回 channel 元素的零值,但是第二个bool值一直为false。

写已经关闭的chan会panic。

进程被kill,如何保证所有goroutine顺利退出

goroutine监听SIGKILL信号,一旦接收到SIGKILL,则立刻退出。可采用select方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var wg = &sync.WaitGroup{}

func main() {
wg.Add(1)

go func() {
c1 := make(chan os.Signal, 1)
signal.Notify(c1, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
fmt.Printf("goroutine 1 receive a signal : %v\n\n", <-c1)
wg.Done()
}()

wg.Wait()
fmt.Printf("all groutine done!\n")
}

说说context包的作用?你用过哪些,原理知道吗?

context可以用来在goroutine之间传递上下文信息,相同的context可以传递给运行在不同goroutine中的函数,上下文对于多个goroutine同时使用是安全的,context包定义了上下文类型,可以使用backgroundTODO创建一个上下文,在函数调用链之间传播context,也可以使用WithDeadlineWithTimeoutWithCancelWithValue 创建的修改副本替换它,听起来有点绕,其实总结起就是一句话:context的作用就是在不同的goroutine之间同步请求特定的数据、取消信号以及处理请求的截止日期。

说说 atomic底层怎么实现的.

atomic源码位于sync\atomic。通过阅读源码可知,atomic采用CAS(CompareAndSwap)的方式实现的。所谓CAS就是使用了CPU中的原子性操作。在操作共享变量的时候,CAS不需要对其进行加锁,而是通过类似于乐观锁的方式进行检测,总是假设被操作的值未曾改变(即与旧值相等),并一旦确认这个假设的真实性就立即进行值替换。本质上是不断占用CPU资源来避免加锁的开销。

channel底层实现?是否线程安全。

channel底层实现在src/runtime/chan.go

channel内部是一个循环链表。内部包含buf, sendx, recvx, lock ,recvq, sendq几个部分;

  • buf是有缓冲的channel所特有的结构,用来存储缓存数据。是个循环链表;
  • sendx和recvx用于记录buf这个循环链表中的发送或者接收的index;
  • lock是个互斥锁;
  • recvq和sendq分别是接收(<-channel)或者发送(channel <- xxx)的goroutine抽象出来的结构体(sudog)的队列。是个双向链表。

map的底层实现。

源码位于src\runtime\map.go 中。

go的map和C++map不一样,底层实现是哈希表,包括两个部分:hmap和bucket。

里面最重要的是buckets(桶),buckets是一个指针,最终它指向的是一个结构体:

1
2
3
4
// A bucket for a Go map.
type bmap struct {
tophash [bucketCnt]uint8
}

每个bucket固定包含8个key和value(可以查看源码bucketCnt=8).实现上面是一个固定的大小连续内存块,分成四部分:每个条目的状态,8个key值,8个value值,指向下个bucket的指针。

创建哈希表使用的是makemap函数.map 的一个关键点在于,哈希函数的选择。在程序启动时,会检测 cpu 是否支持 aes,如果支持,则使用 aes hash,否则使用 memhash。这是在函数 alginit() 中完成,位于路径:src/runtime/alg.go 下。

map查找就是将key哈希后得到64位(64位机)用最后B个比特位计算在哪个桶。在 bucket 中,从前往后找到第一个空位。这样,在查找某个 key 时,先找到对应的桶,再去遍历 bucket 中的 key。

select的实现原理?

select源码位于src\runtime\select.go,最重要的scase 数据结构为:

1
2
3
4
type scase struct {
c *hchan // chan
elem unsafe.Pointer // data element
}

scase.c为当前case语句所操作的channel指针,这也说明了一个case语句只能操作一个channel。

scase.elem表示缓冲区地址:

  • caseRecv : scase.elem表示读出channel的数据存放地址;
  • caseSend : scase.elem表示将要写入channel的数据存放地址;

select的主要实现位于:select.go函数:其主要功能如下:

  1. 锁定scase语句中所有的channel
  2. 按照随机顺序检测scase中的channel是否ready

2.1 如果case可读,则读取channel中数据,解锁所有的channel,然后返回(case index, true)

2.2 如果case可写,则将数据写入channel,解锁所有的channel,然后返回(case index, false)

2.3 所有case都未ready,则解锁所有的channel,然后返回(default index, false)

  1. 所有case都未ready,且没有default语句

3.1 将当前协程加入到所有channel的等待队列

3.2 当将协程转入阻塞,等待被唤醒

  1. 唤醒后返回channel对应的case index

4.1 如果是读操作,解锁所有的channel,然后返回(case index, true)

4.2 如果是写操作,解锁所有的channel,然后返回(case index, false)

go的interface怎么实现的?

go interface源码在runtime\iface.go中。

go的接口由两种类型实现ifaceeface。iface是包含方法的接口,而eface不包含方法。

  • iface

对应的数据结构是(位于src\runtime\runtime2.go):

1
2
3
4
type iface struct {
tab *itab
data unsafe.Pointer
}

可以简单理解为,tab表示接口的具体结构类型,而data是接口的值。

  • itab:
1
2
3
4
5
6
7
type itab struct {
inter *interfacetype //此属性用于定位到具体interface
_type *_type //此属性用于定位到具体interface
hash uint32 // copy of _type.hash. Used for type switches.
_ [4]byte
fun [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
}

属性interfacetype类似于_type,其作用就是interface的公共描述,类似的还有maptypearraytypechantype…其都是各个结构的公共描述,可以理解为一种外在的表现信息。interfaetype和type唯一确定了接口类型,而hash用于查询和类型判断。fun表示方法集。

  • eface

与iface基本一致,但是用_type直接表示类型,这样的话就无法使用方法。

1
2
3
4
type eface struct {
_type *_type
data unsafe.Pointer
}

go的reflect 底层实现

go reflect源码位于src\reflect\下面,作为一个库独立存在。反射是基于接口实现的。

Go反射有三大法则:

  • 反射从接口映射到反射对象;
  • 反射从反射对象映射到接口值;
  • 只有值可以修改(settable),才可以修改反射对象。

Go反射基于上述三点实现。我们先从最核心的两个源文件入手type.govalue.go.

type用于获取当前值的类型。value用于获取当前的值。

简述 Go 语言GC(垃圾回收)的工作原理

垃圾回收机制是Go一大特(nan)色(dian)。Go1.3采用标记清除法, Go1.5采用三色标记法,Go1.8采用三色标记法+混合写屏障。

*标记清除法*

分为两个阶段:标记和清除

标记阶段:从根对象出发寻找并标记所有存活的对象。

清除阶段:遍历堆中的对象,回收未标记的对象,并加入空闲链表。

缺点是需要暂停程序STW。

*三色标记法*:

将对象标记为白色,灰色或黑色。

白色:不确定对象(默认色);黑色:存活对象。灰色:存活对象,子对象待处理。

标记开始时,先将所有对象加入白色集合(需要STW)。首先将根对象标记为灰色,然后将一个对象从灰色集合取出,遍历其子对象,放入灰色集合。同时将取出的对象放入黑色集合,直到灰色集合为空。最后的白色集合对象就是需要清理的对象。

这种方法有一个缺陷,如果对象的引用被用户修改了,那么之前的标记就无效了。因此Go采用了写屏障技术,当对象新增或者更新会将其着色为灰色。

一次完整的GC分为四个阶段:

  1. 准备标记(需要STW),开启写屏障。
  2. 开始标记
  3. 标记结束(STW),关闭写屏障
  4. 清理(并发)

基于插入写屏障和删除写屏障在结束时需要STW来重新扫描栈,带来性能瓶颈。混合写屏障分为以下四步:

  1. GC开始时,将栈上的全部对象标记为黑色(不需要二次扫描,无需STW);
  2. GC期间,任何栈上创建的新对象均为黑色
  3. 被删除引用的对象标记为灰色
  4. 被添加引用的对象标记为灰色

总而言之就是确保黑色对象不能引用白色对象,这个改进直接使得GC时间从 2s降低到2us。