Go语言基础

1. 什么是协程
  • 携程是一种用户态轻量级线程,是线程调度的基本单位
  • 通常在函数前加关键字 go 就可以实现
  • 一个Goroutine会以一个很小的栈启动(2KB或4KB),当遇到栈空间不足时,栈会自动伸缩, 因此可以轻易实现成千上万个goroutine同时启动
2. 高效拼接字符串 💖

拼接字符串的方式有:+fmt.Sprintfstrings.Builderbytes.Bufferstrings.Join

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

性能比较:strings.Joinstrings.Builder > bytes.Buffer > + > fmt.Sprintf

五种拼接方式代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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,"")
}
3. Go 是否支持默认参数/可选参数?

Go 语言不支持默认参数/可选参数,但是可以通过结构体参数,或者传入参数切片数组来实现。

1
2
3
4
5
6
7
8
# 可以传入任意数量的整型参数,会被当作一个整型切片处理
func sum(nums ...int) {
total := 0
for _, num := range nums {
total += num
}
fmt.Println(total)
}
1
**结构体参数是值传递**,如果需要修改结构体参数中的值,则应该传入结构体指针!
4. defer 执行顺序

defer 执行顺序与调用顺序相反,类似于栈的先入后出(FILO)
defer 会在 return 语句后执行,但是在函数退出之前,defer 可以修改返回值

无名返回中,go 会创建临时变量保存返回值,在所有 defer 执行完毕后将临时变量保存的返回值返回
在有名返回中,执行 return 语句时,并不会再创建临时变量保存,因此,defer 语句修改了 i,即对返回值产生了影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 无名返回
func test() int {
i := 0
defer func() {
fmt.Println("defer1")
}()
defer func() {
i += 1
fmt.Println("defer2")
}()
return i
}

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

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

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

// defer2
// return 1
5. Go 语言中的 tag

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
19
20
// 利用反射机制
import reflect

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

func main() {
t := reflect.TypeOf(Author{})
for i := 0; i < t.NumField(); i++ {
// 获取属性
// 调用 t.Filed(i) 返回一个 reflect.StructField 类型
attr := t.Field(i)
fmt.Println(attr.Name, attr.Tag)
}
}

// Name json:Name
// Publications json:Publication,omitempty

reflect.StructField:

6. 如何判断两个字符串切片是否相等

可以使用 reflect.DeepEqual(x, y any) 来判断两个字符串数组([]string) 是否相等
但是由于使用了反射,效率会很底

7. 格式化输出结构体
1
2
3
4
5
6
7
8
au := Author{  
Name: "jack",
Age: 20,
}

fmt.Printf("%v\n", au)
fmt.Printf("%+v\n", au)
fmt.Printf("%#v\n", au)
  • %v:输出结构体各成员的值
    {jack 20}
  • %+v:输出结构体各成员的名称
    {Name:jack Age:20}
  • %#v:输出结构体全称和结构体各成员的名称和值
    test.Author{Name:"jack", Age:20}
8. 空结构体的作用

在 Go 中,我们可以使用 unsafe.Sizeof 计算出一个数据类型实例需要占用的字节数

1
fmt.Println(unsafe.Sizeof(struct{}{})) // 0

我们不难发现一个空结构体实例不占用内存空间
因为空结构体不占据内存空间,因此被广泛作为各种场景下的占位符使用。一是节省资源,二是空结构体本身就具备很强的语义,即这里不需要任何值,仅作为占位符。

  1. ==实现 Set==
    Go 语言中本身没有 Set,一般来说我们可以使用 map[string]bool 来实现一个 Set,但是事实上我们只需要集合的键而不需要集合的值,即使是将值设置为 bool 类型,也会多占据 1 个字节的空间,假设 map 中有一百万条数据,就会浪费 1MB 的空间。
    因此呢,将 map 作为集合(Set)使用时,可以将值类型定义为空结构体,仅作为占位符使用即可。
    type Set map[string]struct{}
  2. ==不发送数据的通道==
    有时候使用 channel 不需要发送任何的数据,只用来通知子协程(goroutine) 执行任务,或只用来控制协程并发度。这种情况下,使用空结构体作为占位符就非常合适了。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    func main() {
    ch := make(chan struct{}, 1)
    go func() {
    <-ch
    // do something
    }()
    ch <- struct{}{}
    // ...
    }
  3. ==仅有方法的结构体==
    在部分场景下,结构体只包含方法,不包含任何的字段。例如上面例子中的 Door,在这种情况下,Door 事实上可以用任何的数据结构替代。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    type Door struct{}  

    func (d Door) Open() {
    fmt.Println("Open the door")
    }

    func (d Door) Close() {
    fmt.Println("Close the door")
    }
9. go里面的int和int32是同一个概念吗?

go 语言中的 int 不能和 int32 混为一谈,int32 的大小固定为 4 字节,而 int 的大小与操作系统位数有关,如果是 32 位操作系统则 int 占 4 字节,如果是64 位操作系统则 int 占 8 字节

类型 大小
int8 1 字节
int16 2 字节
int32 4 字节
int64 8 字节
int 取决于操作系统位数

实现原理

1. init() 是什么时候执行的?

结论:mian() 函数执行之前执行

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

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

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

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

Go 和 C++ 不同,Go 的局部变量会进行逃逸分析。如果变量离开作用域后没有被引用,则优先分配到栈上,否则分配到堆上。

如何判断是否发生了逃逸?

go build -gcflags=m xxx.go

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

3. 两个 interface 可以进行比较吗

在 Go 中,interface 内部实现包含了两个字段:类型 T 和值 V,interface 可以使用 ==!= 进行比较,满足以下情况时两个 interface 相等:

  1. 两个 interface 均为 nil 时(T、V 此时均处于 unset 状态)
  2. 两个 interface T、V 均相同时
1
2
3
4
5
6
7
8
9
10
11
12
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,stu1与stu2值是结构体的地址
fmt.Println(stu3 == stu4) // true,stu3与stu4值是结构体
}
4. 两个 nil 可能不相等吗

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

1
2
3
var p *int = nil
var i interface{} = nil
fmt.Println(p == i) // false

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

5. Go 垃圾回收💖

摘自:
Go常见面试题【由浅入深】2022版
参考:
Golang垃圾回收(GC)介绍 | Random walk to my blog
图解Golang垃圾回收机制! | Go 技术论坛 - LearnKu

垃圾回收机制是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。
6. 函数返回局部变量的指针是否安全?

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

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

一个 T 类型的值可以调用 *T 类型声明的方法,当且仅当 T 是可寻址的
反之,*T 可以调用 T 的方法,因为指针可以解引用

8. Go slice扩容💖
  • Go <= 1.17:
    如果当前容量小于 1024,则判断所需容量是否大于原来容量的两倍,如果大于,则当前容量加上所需容量,如果小于,则当前容量乘二
  • Go >= 1.18:
    引入了新的扩容规则:浅谈 Go 1.18.1的切片扩容机制

    对于容量小的切片,按照 2 倍的速率扩容
    对于容量大的切片,按照 1.25 倍的速率扩容
    公式为以上两个原则提供了平滑的过度!