在 Go 语言面试中,面试官通常会评估你对 Go 语言核心特性的理解,包括并发模型、内存管理、数据结构、接口等方面的知识。本文将涵盖一些常见的 Go 面试问题,并提供详细的解析和解答,帮助你为 Go 语言面试做准备。
一、Go 基础知识
0. Go 的特点是什么?
Go 是一种开源的编程语言,由 Google 开发,具有以下特点:
- 简洁易学:Go 设计简洁,避免了许多冗余的语法结构(例如,C++ 中的类、模板等)。
- 并发支持:Go 的并发模型是基于 goroutine 和通道(channel)实现的,轻量级的 goroutine 可以并发执行。
- 垃圾回收:Go 内建垃圾回收机制,自动管理内存。
- 静态类型语言:Go 是静态类型语言,在编译期就能捕获错误。
- 跨平台支持:Go 可以编译为不同操作系统上的可执行文件,支持跨平台部署。
1. Java和Go的区别?怎么实现面向对象的思想?
设计哲学
- Java:设计上重量级,有大量的内置库和框架。设计初衷是“一次编写,到处运行”。
- Go:设计上轻量级,强调简单性和高效性。目标是简化开发过程并提供高性能。
并发模型
- Java:基于线程的并发模型,提供 synchronized 关键字进行同步。
- Go:使用 goroutine 和 channel 进行并发,更加灵活和高效。
性能
- Java:运行在 JVM(Java 虚拟机)上,可能有一定的性能开销。
- Go:编译成机器代码,通常运行得更快。
生态系统
- Java:拥有庞大的生态系统,特别是在企业级应用和 Android 开发方面。
- Go:生态系统相对较小,但在云计算、微服务和网络编程方面日益受欢迎。
Java VS Go 还在纠结怎么选吗,(资深后端带你深度对比) - 腾讯云开发者社区-腾讯云
面向对象:
- 封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。
Java和Go实现方式的区别:
- 封装:Java中主要通过类和访问权限来实现封装:类可以将数据以及封装数据的方法结合在一起,更符合人类对事物的认知,而访问权限用来控制方法或者字段能否直接在类外使用。Java中提供了四种访问限定符:private、default、protected、public。而Go通过结构体和结构体对应的方法实现封装,可见性只有两种,大写字母开头表示对包外可见,小写字母开头表示仅包内可见。
- 继承:Java中通过Extends关键字显式声明需要继承的父类,获得父类的属性和方法;而Go里其实是没有继承的,但是可以通过嵌入匿名结构体的方式实现继承的效果,通过匿名组合来获得嵌入结构体的属性和方法。
- 多态:Java和Go都是通过接口实现多态,但是Java需要显示声明接口的实现,而Go不需要显式声明,使用鸭子类型的思想,一个结构体只需要包含了接口中的方法,就认为它实现了这个接口。
1. Go方法和函数的区别?
具体来说,方法是一种特殊类型的函数,它与某个类型相关联,需要使用该类型实例调用它的方法。
函数是一种独立的代码块,它没有与任何类型相关联,可以直接进行调用。
2. Go方法指针接受者和值接受者的区别?
在 Go 语言中,一个方法可以有值接受者或指针接受者。这两种接受者的区别主要体现在修改接受者的行为上。
当一个方法拥有值接受者时,它只能访问接受者的副本,并不能修改接受者的内容。
当一个方法拥有指针接受者时,它可以访问并修改接受者的原始值。这是因为指针接受者接收的是接受者的地址,在方法内部对接受者所做的任何更改都会影响到接受者的原始值。
总之,当需要在方法内部修改接受者的内容,并且这些更改对方法外的代码也应该生效时,应该使用指针接受者;当不需要修改接受者的内容时,或者不需要对方法外的代码产生影响时,应该使用值接受者。值接受者比指针接受者更为安全,因为它们不会对原始值产生副作用,并且能够避免由于指针使用不当而导致的错误。
什么时候应该使用指针接受者?
- 需要修改接收者中的值
- 接收者是拷贝代价比较大的大对象
- 保证一致性,如果结构体有某个方法使用了指针接收者,那么其他的方法也应该使用指针接收者
3. Go函数是值传递还是引用传递?
在 Go 语言中,函数参数传递是值传递,而不是引用传递。但是当我们将一个slice、map或channel传递给函数时,实际上传递的是该类型的一个引用。这是因为这些类型的底层实现都使用了指针,因此将它们传递给函数时,对slice、map或channel的更改会影响到原始值。
4. Go函数可以返回局部变量指针吗?是否安全?
在 Go 语言中,可以从函数中返回局部变量的指针,而且是安全的。一般来说,局部变量会在函数返回后被销毁,造成某些不可预知的影响。但这在 Go 中是安全的,Go 编译器将会对每个局部变量进行逃逸分析。如果发现局部变量的作用域超出该函数,则不会将内存分配在栈上,而是分配在堆上,因为他们不在栈区,即使释放函数,其内容也不会受影响。
5. make和new的区别?
在 Golang 中,make 和 new 都用于内存分配,但它们用于不同的类型和有不同的用途。
- 作用不同:new用于给任意类型分配内存空间,返回指向该类型的指针,而不会对其进行初始化,因此使用new创建的对象需要手动初始化对象的值;make主要作用是初始化内置的数据结构,make只用于初始化slice、map、channel等引用类型,创建的实例已经被初始化可以直接使用。
- 函数返回值不同:new返回对应类型的指针;make返回类型对应的实例,并不是指针。
func new(Type) *Type
func make(Type, size IntegerType) Type
Go : new 和 make 是什么,差异在哪? - 掘金
6. defer关键字的原理?执行顺序?
defer关键字用于函数的延迟调用,在函数调用结束后执行某些操作,例如关闭文件、解锁互斥锁等。defer关键字的原理是在函数调用时将需要执行的语句(可以是函数调用或方法调用)压入一个栈中,然后在函数返回时逆序执行栈中的语句。这意味着,最后入栈的语句会最先执行,而最先入栈的语句会最后执行。defer 后面的函数的参数会在 defer 语句处立即被计算和存储起来,而不是在实际调用时。
此外,如果在defer语句中调用了函数或方法,并且该函数或方法返回了一个值,那么这个值会被保存起来,在最终执行时被使用。如果有多个defer语句返回了值,则使用最后一个 defer 语句的返回值。
7. Go指针和C语言指针的区别?
Go 语言的指针比 C 语言更安全,因为Go指针不能进行指针运算和指针比较(除了与nil比较),只能用于直接访问内存中的值或者将其传递给函数。这样可以避免一些常见的指针问题,如空指针引用、越界访问等。
8. Go如何高效地拼接字符串?
- 当进行少量字符串拼接时,直接使用
+
操作符进行拼接字符串。 strings.Builder
无论是少量字符串的拼接还是大量的字符串拼接,性能都能很不错,综合易用性和性能,Go语言官方推荐使用strings.Builder
来拼接字符串。
package main
import (
"fmt"
"strings"
)
func main() {
var builder strings.Builder
builder.WriteString("Hello, ")
builder.Write([]byte("world!"))
fmt.Println(builder.String()) // 输出 "Hello, world!"
builder.Reset() // 清空所有缓存的数据
}
9. rune和byte类型的区别?
byte类型,实际上是uint8的别名,代表了 ASCII 码的一个字符。 rune 类型,实际上是int32的别名,代表一个 UTF-8 字符,当需要处理中文、日文或者其他复合字符时,则需要用到 rune 类型。
// 使用 byte 遍历
for i := 0; i < len(str); i++ {
fmt.Println(str[i]) // 输出的是每个字节的ASCII值或者二进制表示
}
// 使用 rune 遍历
for _, r := range str {
fmt.Println(r) // 输出的是每个字符的Unicode码点
}
10. Go的引用类型有哪些?
引用类型的值是对底层数据结构的引用,而不是实际的数据值。修改引用类型变量,通常会影响到原始值,它们底层共享相同的数据。
切片、map、channel,除此之外,Go 语言还有一些其他的引用类型,比如接口、指针等。由于它们在某些行为(如传递行为、动态类型等)上与引用类型相似,所以通常会被认为是引用类型。因为它们在赋值和传递参数时,都是传递的指针或者引用。
这些类型都有一个共同点,即它们的变量存储的不是数据本身,而是数据在内存中的位置(或引用)。因此,当传递这些类型的变量时,实际上是在传递数据的引用,而不是数据的副本。这与基础数据类型(如 int、float64、bool 等)形成对比,基础数据类型的变量存储的是数据本身。
11. Go面向对象是怎么实现的?
Go 语言中没有类的概念,而是通过结构体和方法的组合来实现面向对象的特性。
封装特性:Go通过大小写来实现可见性,大写的变量和方法表示对包外可见,小写的表示对包外不可见。
继承特性:Go中继承不需要显示声明,通常通过嵌入匿名结构体获得该结构体的属性和方法。
多态特性:Go通过接口实现多态,但是不需要显示声明接口的实现,一个结构体如果实现了某个接口的全部方法,则认为这个类型实现了该接口,这种实现接口的方式有个名词叫做鸭子类型。
12. Go有异常类型吗?
Go语言使用panic和recover机制来处理异常。
当程序遇到一个无法处理的错误时,可以使用panic函数来抛出一个异常。panic会中断当前的控制流程,将异常传递给上层调用者,直到被recover函数捕获或者程序终止。
Go怎么进行错误处理?
- 错误:用于可预见的、应该由程序显式处理的问题。
- 异常:用于不可预见的、通常由程序错误导致的严重问题。
这两者的主要区别在于它们的用途和处理方式:错误是你应该预见并处理的情况,而异常是你几乎无法预料的严重错误。在 Go 中,推荐尽量使用错误处理,而避免使用 Panic 和 Recover。
err和panic的区别?
Error
- 显式处理:错误在 Go 中是显式返回的值,通常是函数的最后一个返回值。这强制程序员显式地检查和处理错误。
goCopy code
file, err := os.Open("file.txt")
if err != nil {
// 处理错误
}
- 常规问题:错误用于处理预期内的问题,如文件不存在、无法连接到数据库等。
- 不打断控制流:当一个错误发生时,函数不会立即停止执行。相反,它会返回一个错误对象,供调用者决定如何处理。
- 可预见性:因为错误是显式返回的,所以它们的处理逻辑通常更容易预见和理解。
- 自定义错误:你可以通过实现 error 接口来创建自定义错误。该接口只包含一个方法:Error() string。
Panic
- 隐式处理:异常是隐式触发的,并导致程序控制流立即停止。
panic("Something went wrong")
- 严重问题:Panic 通常用于处理预期外的、严重的或无法恢复的错误,如数组越界、不可恢复的状态等。
- 打断控制流:触发 Panic 后,程序的控制流会被打断,所有的 defer 语句会被执行,然后程序崩溃。
- 可恢复性:尽管一般情况下不建议这么做,但你可以使用 recover 函数来捕获和处理 Panic。
- 不常用:在 Go 中,Panic 很少用于普通的错误处理。
13. int和int32是同一个概念吗?
不是一个概念。go语言中的int的大小是和操作系统位数相关的,如果是32位操作系统,int类型的大小就是4字节。如果是64位操作系统,int类型的大小就是8个字节。除此之外uint也与操作系统有关。而int32在任何系统下只占用4个字节。
14. 闭包是什么?作用?
在 Go 语言中,闭包(Closure)是一种特殊类型的匿名函数,它可以捕获并使用其外部作用域中的变量。这意味着,除了函数的参数列表所定义的变量之外,闭包还可以访问并操作它被创建时所在作用域内的变量。
func outer() func() int {
x := 0
return func() int {
x++
return x
}
}
func main() {
counter := outer()
fmt.Println(counter()) // 输出:1
fmt.Println(counter()) // 输出:2
}
闭包的作用可以归纳为以下几个方面:
- 保存函数状态:闭包可以用于保存函数的状态,因为它可以访问函数体外部的变量。这样,即使函数执行结束,它的状态仍然可以被保留下来,并在下一次调用时继续使用。
- 实现函数式编程:闭包可以用于实现函数式编程中的高阶函数,比如 map、reduce、filter 等。通过将函数作为参数传递给其他函数,并在函数内部使用闭包实现某些功能,可以实现函数式编程的特性。
- 隐藏实现细节:闭包可以用于隐藏函数的实现细节,比如内部状态变量。通过返回一个只包含公共接口的函数类型,可以隐藏函数的实现细节,从而提高代码的可维护性和可复用性。
15. init()函数什么时候执行?有什么作用?能保证执行顺序吗?
init() 函数是一种特殊的函数,它可以用于在程序运行之前(main函数之前)完成一些初始化工作。init() 函数不能被显式调用,而是在包被导入时自动执行,执行顺序和包的导入顺序有关,不同包内的 init() 函数执行顺序是不确定的。
init() 函数主要有以下几个作用:
- 初始化程序状态:在 init() 函数中可以初始化包级别的全局变量、执行复杂的计算操作、连接数据库等。这些初始化操作可以帮助程序在运行时更快地响应用户请求,避免一些潜在的问题。
- 注册服务:在 init() 函数中可以注册一些服务,比如 HTTP 服务、RPC 服务等。这些服务可以在程序运行时对外提供服务,方便用户使用。
- 加载插件:在 init() 函数中可以加载一些插件,这些插件可以扩展程序的功能。这些插件可以根据用户的需要动态加载,提高程序的灵活性。
需要注意的是,init() 函数的执行顺序可能会受到包的导入顺序的影响,因此不能保证执行顺序。在多个包中同时定义了 init() 函数时,它们的执行顺序也是不确定的。因此,在编写 init() 函数时,应该避免依赖其他包的状态,保证程序的可靠性。同时,也应该尽量避免使用 init() 函数执行过多的操作,以免影响程序的性能。
17. Go的编译过程是怎么样的?
- 词法分析(Lexical analysis):将源代码分解成词素或标记。词法分析器会根据语言规范识别关键字、标识符、操作符、分隔符、字面量等语法元素。
- 语法分析(Syntax analysis):将词法分析的结果转化成抽象语法树(AST)。语法分析器会根据语言规范检查词法分析器生成的 token 是否符合语法规则,构建语法树。
- 语义分析(Semantic analysis):对语法树进行类型检查、作用域检查、类型转换等操作。语义分析器会根据语言规范检查代码的语义是否正确。
- 代码优化(Code optimization):根据语言规范对代码进行优化,包括常量表达式折叠、循环展开、函数内联等操作。
- 生成目标平台的机器代码(Code generation):将优化后的代码生成目标平台的机器代码。代码生成器会根据目标平台的特点生成机器码,并且根据需要添加调试信息。
19. 如何在运行时检查变量的类型?
在 Go 语言中,可以使用类型断言或者反射来在运行时检查变量的类型。
类型断言是一种常见的检查类型的方法,主要用于接口类型。类型断言的基本语法如下:
value, ok := variable.(Type)
另一种更为通用但也更复杂的方法是使用 reflect 包。reflect.TypeOf()
函数返回一个reflect.Type
类型的对象,该对象表示x的动态类型。
var x int = 10
fmt.Println("Type:", reflect.TypeOf(x)) // 输出:Type: int
为什么需要判断类型?
在编程中,类型检查有多种用途,大部分类型错误在编译期就能被捕获。然而,在某些情况下,运行时类型检查仍然是必要的或有用的。以下是一些常见的原因:
- 泛型编程
使用接口类型可以实现某种程度上的泛型编程。在这种情况下,可能需要在运行时检查变量的实际类型。
- 接口和多态
在面向对象的设计中,接口提供了一种机制来实现多态。然而,有时你可能需要了解一个接口变量的动态类型,可能是为了调用某个特定于该类型的方法,或者进行特定的操作。
- 动态编程
有时,程序的某些方面可能是动态的,并且你可能需要根据运行时状态来处理不同类型的对象。在这些情况下,类型检查和类型断言可能会非常有用。
- 反射和元编程
在更高级的用例中,如框架或库的开发,可能需要进行反射操作,这通常涉及到运行时类型检查。例如,在一个序列化库中,你可能需要知道变量的具体类型,以便正确地将它们转换为字节流。
18. 类型断言用过吗?实现原理?如何判断断言成功?
类型断言可以判断一个接口类型的值能否转换为具体类型的值。它的基本语法如下:value, ok := x.(T)
其中,x是一个接口类型的值,T是一个具体的类型。如果x的动态类型是T,则类型断言会返回x的底层值,并且将ok设置为true;否则,类型断言会返回T类型的零值,并且将 ok 设置为 false。因此,我们可以通过检查 ok 的值来判断类型断言是否成功。
19. 两个nil可能不相等吗?
在 Go 语言中,两个nil值有可能不相等。这主要发生在涉及接口的情况下。
在 Go 语言中,一个接口变量包含两个组成部分:一个是接口的类型,另一个是接口包含的值。当我们说一个接口变量为 nil,实际上是说这个接口变量的类型和值都是 nil。
type MyInterface interface {
DoSomething()
}
var a MyInterface = nil
var b *int = nil
// 这里,a 是 nil 接口变量,b 是 nil 指针变量。
fmt.Println(a == nil) // 输出:true
// 将 nil 指针赋值给接口
a = b
// 现在,a 的值是 nil,但它的类型是 *int,所以它不再是 nil 接口。
fmt.Println(a == nil) // 输出:false
20. Go中哪些类型不可比较?
- 可比较:int、float、string、bool、complex、pointer、channel、interface、array
- 不可比较:slice、map、function,只可以与nil进行比较。
- 复合类型(结构体、数组等)中如果带有不可比较的类型,那么该类型也是不可比较的。可以理解不可比较类型具有传递性。
对于不可比较类型,如果需要比较,通常需要遍历元素逐一进行比较。
浅析go中的类型比较 - Go语言中文网 - Golang中文社区
21. Go深拷贝和浅拷贝?
浅拷贝是对内存地址的复制,而不会复制对象本身,新对象和源对象共享同一块内存,它们之间会相互影响。深拷贝是拷贝对象的具体内容,新对象和原对象不共享内存,两个对象之间互不影响。
在Go语言中所有赋值操作都是值传递,如果是基本数据类型,则直接赋值就是深度拷贝;如果是引用数据类型,则赋值就是浅拷贝。
1. Struct
1. 结构体可以比较吗?如何比较两个结构体是否相等?
Go 面试系列:如何比较GO中的结构体?_Hi丶ImViper的博客-CSDN博客_go 结构体比较
在 Go 语言中,以下类型是不可比较的:
- 切片(slice)
- 映射(map)
- 函数(function)
这些类型是不可比较的,因为它们都包含了一个或多个非可比较的元素。对于切片和映射来说,它们的底层数据结构是动态变化的,而且可以包含任意类型的元素,因此它们的比较操作不是确定性的。对于函数来说,由于它们可能包含指向不同代码块的指针,因此也不支持比较操作。
另外,结构体和数组类型也可能是不可比较的,具体取决于它们的字段或元素是否可比较。如果结构体或数组的所有字段或元素都是可比较的,那么它们就是可比较的类型。否则,它们就是不可比较的类型。
在 Go 语言中,结构体可以进行比较操作,但是需要注意以下几点:
- 结构体只能与相同类型的结构体进行比较。
- 结构体中的所有字段都可以进行相等性比较。
如果两个结构体变量的类型相同,而且它们的所有字段都是可比较的类型,那么可以使用 == 或 != 操作符来比较它们是否相等。当且仅当它们的对应字段都相等时,这两个结构体变量才相等。
2. 结构体中的tag有什么作用?如何实现?
tag可以实现一些特定的功能,例如序列化、反序列化、数据校验等。结构体tag是以字符串形式存储在结构体类型的元数据中的,可以包含一个或多个键值对,键和值之间用冒号分隔,多个键值对之间用空格分隔。
在实现上,利用了Go语言的反射特性可以动态地给结构体成员赋值。Go语言中提供reflect包来支持对结构体tag的解析和读取,可以在赋值前使用tag来决定赋值的动作。
tag还可以包含一些特殊的值,例如omitempty
和-
,它们的作用分别是:
omitempty
:当字段的值为空(零值或空指针)时,将该字段忽略。例如在上面的示例中,如果 Password 字段的值为空,转换为 JSON 字符串时该字段会被省略。-
:将该字段忽略,不参与序列化或反序列化。例如在上面的示例中,如果将Password字段的json键的值设为 -,则在将结构体实例转换为 JSON 字符串时,该字段会被忽略。
type User struct {
Name string `json:"name" xml:"name"`
Age int `json:"age" xml:"age"`
Password string `json:"password,omitempty" xml:"-"`
}
结构体变量不加tag,使用json序列化时会怎么样?
4. 结构体中值为nil、零值的变量JSON序列化后对应的值是多少?(不使用ominiempty)
当struct中的字段没有值时,json.Marshal()
序列化的时候不会忽略这些字段,而是默认输出字段的类型零值,例如数字类型零值是0
,string类型零值是""
,对象类型零值是null
。
nil变量序列化后为null,而零值变量序列化后为对应的Json对象的零值,比如数字类型就是0,空切片序列化为json空数组,nil切片序列化为null。
5. json包可以导出结构体私有变量的tag吗?
go面试题:reflect(反射包)如何获取字段tag?为什么json包不能导出私有变量的tag?
6. 结构体JSON编码时如何忽略某个字段?
go语言结构体json编码时候忽略某个字段_go json忽略字段_秋山刀名鱼丶的博客-CSDN博客
7. 为什么要在序列化时忽略零值?
在 Go 中,序列化数据通常使用 encoding/json 或 encoding/gob 等库进行。这些库在序列化数据时,可以通过指定选项来控制是否包含零值字段。通常情况下,这些库默认会忽略零值字段,这是为了减小序列化后的数据大小,提高传输效率。
在 Go 中,零值通常是可以被隐式地赋值的。例如,如果一个结构体中的字段没有显式初始化,则它们将被自动初始化为其类型的零值。因此,序列化时忽略零值字段不会导致数据的丢失,因为这些字段的值可以在反序列化时被正确地恢复。
此外,忽略零值字段还可以避免序列化中不必要的冗余数据。例如,在一个大的 JSON 对象中,如果有许多字段都是零值,则包含这些字段可能会导致序列化后的 JSON 数据变得很大。通过忽略这些零值字段,可以显著减小序列化后的数据大小,提高传输效率。
当然,在某些情况下,需要在序列化时包含零值字段。例如,如果需要在反序列化后保留这些字段的值,则需要在序列化时包含这些字段。在这种情况下,可以通过在序列化时指定选项来包含零值字段。例如,在 encoding/json 中,可以使用 omitempty 标记来控制是否忽略零值字段。
8. nil切片和空切片在json序列化时有什么区别?
nil切片将被序列化为null值,而空切片将被序列化为空数组。
func main() {
var nilSlice []string
emptySlice := []string{}
m := map[string][]string{
"nilSlice": nilSlice,
"emptySlice": emptySlice,
}
marshal, _ := json.Marshal(m)
fmt.Println(string(marshal))
}
// {"emptySlice":[],"nilSlice":null}
9. 如何判断一个json对象中某个字段的如下几种情况:字段不存在、值为0、值为nil ?
10. 匿名结构体和非匿名结构体有什么区别?
2. Slice
1. 原理和实现
在Go中,切片实际上是一个结构体类型,包含指向底层数组的指针、切片长度、切片容量三个字段,本质上对切片的操作,其实都是对底层数组的操作。
2. 扩容机制以及注意点
切片扩容是为切片分配新的内存空间,并复制原切片的过程。扩容方式根据当前切片容量和期望切片容量决定,有三种情况:
- 如果新切片需要的容量大于原来容量的两倍,则直接根据新切片需要的容量的大小来分配空间;
- 如果原来切片的容量小于1024,则每次扩容会将切片容量进行翻倍;
- 如果原来切片的容量大于等于1024,则每次扩容只会增加25%的容量;
在上述规则的基础上,还会考虑元素类型与内存分配规则,对实际分配的容量进行一些微调。
注意点:
- 在添加元素时,如果切片容量不足,Go 语言会重新分配一个底层数组,并将原有元素复制到新的底层数组中,这个过程是比较耗时的,因此应该尽量避免频繁扩容。可以通过初始化时指定一个足够大的容量来避免频繁扩容;
- 切片赋值时,多个变量共享同一个底层数组,会相互影响。
示例1:
package main
import "fmt"
func main() {
s := []int{1, 2}
fmt.Printf("初始容量:%d\n", cap(s)) // 2
s = append(s, []int{3, 4, 5}...)
fmt.Printf("添加 3 个元素后的容量:%d\n", cap(s)) //6
}
示例2:
package main
import "fmt"
func main() {
s := []int{1, 2} // 创建一个容量为 2 的空切片
fmt.Printf("初始容量:%d\n", cap(s))
s = append(s, 1)
s = append(s, 2)
s = append(s, 3)
fmt.Printf("添加 3 个元素后的容量:%d\n", cap(s)) // 8
}
Go切片扩容机制_go 切片扩容_Jacob_云飞的博客-CSDN博客
3. 空切片和nil切片的区别
nil切片
- 声明方式:当你声明一个切片变量但不进行初始化时,它的默认值是 nil。
- 长度和容量:nil 切片的长度和容量都是 0。
- 底层数组:没有底层数组。
- 与 nil 比较:nil 切片与 nil 相等。
var s []int // s 是一个 nil 切片
fmt.Println(s == nil) // 输出:true
空切片
- 声明方式:可以通过 make 函数或切片字面量来创建一个空切片。
- 长度和容量:长度和容量都是 0,但它有一个非 nil 的底层数组(尽管这个数组没有元素)。
- 底层数组:有底层数组,但没有元素。
- 与 nil 比较:空切片与 nil 不相等。
s := make([]int, 0) // s 是一个空切片
fmt.Println(s == nil) // 输出:false
s := []int{} // s 也是一个空切片
fmt.Println(s == nil) // 输出:false
行为差异
- 在使用 append 函数或进行切片操作时,nil 切片和空切片的行为基本相同。
- 在序列化为 JSON 时,nil 切片会被序列化为 null,而空切片会被序列化为 []。
nil切片和空切片最大的区别在于底层数组指针指向的地址是不一样的,nil切片的指向底层数组的指针为nil,表示不指向任何底层数组;而空切片底层数组的指针指向一个固定值(一个预先分配的、全局共享的、不包含任何元素的数组),但是它没有分配任何内存空间,所以nil切片和空切片不相等,nil切片和nil相等。
4. 数组和切片的区别?
- 长度:数组的长度是固定的,定义后不能改变;切片的长度可以动态改变。
- 传递方式:数组在函数间传递时是值传递,数组元素会被复制;而切片在函数间传递时是引用传递。
- 使用场景:数组一般用于存储固定长度的数据,而切片则更适合用于动态地增加或删除元素的情况。
5. 切片的深拷贝和浅拷贝?
深拷贝:拷贝的是数据本身,创造一个新对象,新创建的对象与原对象不共享内存,新对象值修改时不会影响原对象值。实现深拷贝的方式:
copy(destSlice, srcSlice)
- 遍历赋值
浅拷贝:拷贝的是数据地址,只复制指向的对象的指针,此时新对象和老对象指向的内存地址是一样的,新对象值修改时老对象也会变化。实现浅拷贝的方式:引用类型的变量,默认赋值操作就是浅拷贝。
在 Go 语言中,切片的拷贝有两种方式:浅拷贝和深拷贝。
- 浅拷贝:浅拷贝是指将一个切片的指针、长度和容量都复制给另一个切片,这两个切片会共享相同的底层数组。当修改其中一个切片的元素时,底层数组中的对应元素也会被修改,另一个切片看到的值也会发生改变。
a := []int{1, 2, 3, 4, 5}
b := a
b[0] = 100
fmt.Println(a) // [100 2 3 4 5]
fmt.Println(b) // [100 2 3 4 5]
- 深拷贝:深拷贝是指将一个切片中的元素全部复制到另一个切片中,新的切片会有一个独立的底层数组,对新的切片进行修改不会影响原始的切片。在Go中可以使用
copy(目标切片, 原切片) int
来进行深拷贝。
a := []int{1, 2, 3, 4, 5}
b := make([]int, len(a))
copy(b, a)
b[0] = 100
fmt.Println(a) // [1 2 3 4 5]
fmt.Println(b) // [100 2 3 4 5]
在实际开发中,需要根据具体情况选择合适的拷贝方式。如果需要对切片进行修改并且不希望影响到原始的切片,应该使用深拷贝;如果只需要访问切片中的元素,可以使用浅拷贝来提高效率。
6. Go Slice为什么不是线程安全的?
slice底层结构并没有使用加锁等方式,不支持并发读写,所以并不是线程安全的,使用多个goroutine对类型为 slice的变量进行操作,每次输出的值大概率都不会一样,与预期值不一致; slice在并发执行中不会报错,但是数据会丢失。因此,在多个goroutine中使用Slice时,需要使用互斥锁或其他同步机制来保证数据的一致性和线程安全。
map并发读写时会panic报错。
7. 如何比较两个slice是否相等?
在 Go 中,Slice 是引用类型,不能直接使用==
进行比较,因==
只能用于比较两个Slice是否指向同一个底层数组。
- 如果要比较两个 Slice 的元素是否相等,可以使用循环遍历每个元素进行比较。
- 还可以使用
reflect.DeepEqual
函数进行深度比较,DeepEqual函数可以比较任意的类型是否相等。 - 引入第三方包进行比较,比如使用google的
cmp
包。
8. Slice分配在栈上还是堆上?
在 Go 中,切片通过指针引用底层数组,切片的底层数组可以在栈上或堆上分配,具体取决于底层数组的大小和分配时的情况。需要经过编译器逃逸分析,由编译决定在哪里分配,如果切片数组比较小,Go会将底层数组分配在栈上;如果切片底层数组太大,就会分配在堆上。
3. Map
1. Map原理和实现?
Go语言的Map使用哈希表作为底层实现,一个哈希表中有多个桶,首先会根据键的哈希值选择一个桶,每个桶中存储了8组键值对。当我们向map中插入 一个键值对时,然后将该键值对存储到桶中。当我们需要查找某个键对应的值时,map会根据键的哈希值查找对应的桶,然后再在桶中查找对应的键值对。
2. 扩容机制
为了保证哈希表的效率,当负载因子超过6.5或者哈希表使用了太多的溢出桶时,会触发扩容操作,重新计算每个键的哈希值,使其均匀的分布。
负载因子 = 键值对数量 / 哈希桶数量
扩容方式有两种:
- 增量扩容:当负载因子过大时(预分配的空间太大或者大部分元素被删除),会新建一个容量更大的哈希表(桶数组),容量是原来的2倍,为了防止一次性搬迁大量键值对造成比较大的延时,Go采用渐进式策略搬迁键值对,每次访问map时会触发一次搬迁,每次搬迁2个键值对。
- 等量扩容:在出现较多溢出桶时(溢出桶数量大于2的15次方),哈希表大小不变,而是把松散的键值对重新排列一次,这样会减少溢出桶的数量,减少空间占用提高访问效率。
溢出桶:溢出桶是为了减少扩容频率,当哈希表的单个桶已经装满时,就会使用溢出桶存储数据,只有在溢出桶使用过多才真正进行扩容。
哈希冲突解决办法:
- 开放寻址法
- 链地址法
逐步搬迁策略:
- 新插入的键值对:新插入的键值对通常会直接插入到新的哈希表中。这样做的好处是,新的哈希表通常具有更大的容量和更低的负载因子,因此插入操作的性能通常会更好。此外,这也避免了将新插入的键值对在稍后再次搬迁到新表的额外成本。
- 查找操作:查找一个键值对时,通常会在两个哈希表中都进行查找。首先在新的哈希表中查找;如果在新哈希表中没有找到,再在旧的哈希表中查找。这是因为,在逐步搬迁过程中,旧的哈希表和新的哈希表都可能包含一部分键值对。
- 删除操作:删除操作也需要在两个哈希表中都进行,以确保键值对被完全删除。
3. map是否并发安全?为什么map是非线程安全的?
map数据结构的底层哈希表没有使用锁来保证并发读写时的一致性,所以map的相关操作不是原子的,并发读写时可能导致读写冲突,读写冲突会触发Panic导致程序异常退出。
4. 解决方法(如何实现并发安全的map)?
- 使用互斥锁或者读写锁实现并发安全的map
- 使用sync标准库中的
sync.Map
5. map读取是否有序?如何实现有序读?
Go的map是无序的,因为map底层是使用哈希表实现的,map扩容时会重新映射键值对,各键值项存储位置都可能会发生改变,顺序自然也没法保证了,所以Go官方避免大家依赖键值对的顺序,直接将键值对打乱处理。
如果需要有序读取,可以将map中的key放入一个切片中,并对切片进行排序,然后按顺序读取map中的值。
6. sync.Map和Map区别?它们谁的性能高?为什么?
sync.Map和普通的map最大的区别在于,sync.Map是并发安全的,而普通的map是非并发安全的。另外,sync.Map中的键和值都是空接口类型,意味着我们可以往里面存放任何类型的键值对,而不需要像普通的map一样,事先指定好键和值的具体类型。
性能方面,对于读多写少的场景,sync.Map的性能会比普通的map好一些,因为锁的竞争更小。而对于写多,读少的场景,会导致read map缓存失效,需要加锁,冲突变多,性能急剧下降,这也是sync.Map最大的缺点。
7. sync.Map底层实现原理
sync.Map采取了 “空间换时间” 的理念,冗余了两个map,分别是read map和dirty map,read map中的key是只读的,而dirty map中的key是可变的。读取时先从read map中查找,找不到则加锁去dirty map中查找。
sync.Map性能更好的原因:尽量减少了加锁的次数,很多地方使用原子操作来保证并发安全。
type Map struct {
mu Mutex
read atomic.Value // readOnly
dirty map[interface{}]*entry
misses int
}
7. 如何比较两个map是否相等?
map是不可比较类型,不能直接进行比较,map只能与nil比较。如果需要比较两个map是否相等,有两种办法:
- 使用一个循环遍历比较所有的键值对,判读是否相等。
- 使用
reflect.DeepEqual
函数进行比较。
8. Slice可以作为map的key吗?
slice是不可比较的,所以不能作为map的key,map的key只能是可比较类型。不能作为map key 的类型包括:
- slices
- maps
- functions
9. 当key值不存在返回的是什么?如何可靠的读取map中的key?
当key不能存在时,则返回value类型对应的默认值。为了判断key是否存在,在从map中读取数据时,会返回两个值,value, ok := m["key"]
,当ok为true,则表示key存在,否则表示key不存在。
4. String
1. 底层结构
字符串其实也是结构体类型,包含指向底层字符串的指针和字符串长度两个字段。和切片类似,但是字符串不可修改,且字符串可以为空(长度为0),但是不会是nil。
type stringStruct struct {
str unsafe.Pointer
len int
}
Go字符串只包含一个指向底层字符串的指针,这样做的好处是可以很方便的进行传递而不用担心内存拷贝。
2. Go字符串为什么不允许修改?
因为String通常指向字符串字面量,而字符串字面量存储的位置是只读段,而不是堆或栈上,所以String不允许修改。
另外,Go 语言的字符串采用了 UTF-8 编码,一个字符可能由多个字节组成,如果允许修改字符串,那么可能会破坏 UTF-8 编码的规则,导致字符串无法正确解析。
因此,Go 语言中的字符串被设计为不可变的,这样可以提高程序的并发安全性。
3. 字符串拼接方式有哪些?哪种方式效率最高?
在Go语言中,字符串拼接有6种方式:
- 使用加号拼接字符串:使用+操作符进行拼接时,会对字符串进行遍历,计算并开辟一个新的空间来存储原来的两个字符串。
- 使用
fmt.Sprintf()
拼接字符串:fmt.Sprintf()
实现原理主要是使用到了反射,反射会产生性能损耗。
str1 := "Hello"
str2 := "World"
result := fmt.Sprintf("%s %s", str1, str2)
fmt.Println(result) // 输出:Hello World
strings.Join()
函数来连接一个字符串切片:
strs := []string{"Hello", "World"}
result := strings.Join(strs, " ")
fmt.Println(result) // 输出:Hello World
- 使用
strings.Builder
类型
var builder strings.Builder
builder.WriteString("Hello World")
builder.String()
- 使用
bytes.Buffer
类型
var buf bytes.Buffer
buf.WriteString("Hello World")
buf.String()
[]byte
切片
s := make([]byte, 0)
str = "Hello World"
s = append(s, str...)
string(s)
上面我们总共提供了6种方法,原理我们基本知道了,那么我们就使用Go语言中的Benchmark来分析一下到底哪种字符串拼接方式更高效。我们主要分两种情况进行分析:
- 当进行少量字符串拼接时,直接使用+操作符进行拼接字符串。
strings.Builder
无论是少量字符串的拼接还是大量的字符串拼接,性能都能很不错,综合易用性和性能,Go语言官方推荐使用strings.Builder
来拼接字符串。
Go 字符串拼接6种,最快的方式 -- strings.builder - 技术颜良 - 博客园
5. Goroutine
1. 进程、线程和Goroutine区别?
- 进程是资源分配的基本单位,每个进程都有独立的空间,不同进程间通过进程通信方式来通信。
- 线程从属于进程,是CPU调度的基本单位,多个线程间共享父进程的资源,每个线程只有一点点必不可少的资源,比如栈、寄存器、程序计算器等。
- 进程创建销毁以及切换开销大,线程创建销毁以及切换开销小。
- 协程可以理解为是一种轻量级用户级线程,一个线程可以对应多个协程,与线程相比,协程不受操作系统调度,需要在用户程序中进行调度。
2. 线程和协程区别?
- 线程由操作系统调度,需要在内核态进行上下文切换,开销较大;而协程由用户进程调度,在用户态进行上下文切换,开销较小;
- 多个协程从属于一个线程,共享该线程的时间片,在线程内每时刻只能执行一个协程;
- 线程需要分配的栈内存较大,默认需要8MB,而协程只需要分配2KB的栈内存,且栈内存可动态分配;
3. 如何关闭一个Goroutine?
Goroutine设计的退出机制是由goroutine自己控制自己的生命周期,不能在外部强制结束一个正在执行的goroutine(只有一种情况正在运行的goroutine会因为其他goroutine的结束被终止,就是main函数退出或程序停止执行)。
记住,在 Go 语言中每一个 goroutine 都需要自己承担自己的任何责任,这是基本原则。
停止 Goroutine 有几种方法? - 侃豺小哥 - 博客园
goroutine退出方式的总结_goroutine 退出_xingwangc2014的博客-CSDN博客
golang面试官:for select时,如果通道已经关闭会怎么样?如果只有一个case呢?
扩展:
- 线程怎么退出?一个线程能控制其他线程退出吗?
- 怎么控制一个Goroutine执行5秒钟后关闭?
在 Go 中,无法直接关闭一个 Goroutine。Goroutine 是由 Go 运行时调度的,所以关闭它需要一些额外的工作。
一种常见的方法是使用一个通道来控制 Goroutine 的生命周期。通道可以用来发送信号,告诉 Goroutine 停止运行。例如,可以定义一个布尔型的通道,然后在 Goroutine 中轮询该通道,如果通道被关闭,Goroutine 就会退出。
func myGoroutine(done chan bool) {
for {
select {
case <-done:
return
default:
// do some work
}
}
}
func main() {
done := make(chan bool)
go myGoroutine(done)
// wait for some time
time.Sleep(time.Second * 5)
// close the channel to stop the Goroutine
close(done)
}
需要注意的是,如果 Goroutine 中使用了阻塞操作,如 I/O 操作或者等待通道,那么关闭通道可能无法停止 Goroutine。在这种情况下,可以使用 context.Context 来停止 Goroutine。
在 Go 中,可以使用 context.Context 来停止 Goroutine。context.Context 提供了一个机制来跟踪请求的生命周期,可以用于取消操作或停止 Goroutine 的执行。
使用 context.Context 取消 Goroutine 的步骤如下:
- 在 Goroutine 中轮询 context.Context.Done(),如果收到了取消信号,就退出 Goroutine 的执行。
- 在调用 Goroutine 的代码中,可以使用 context.WithCancel() 创建一个新的 context.Context 对象,并通过 context.Context 的 cancel() 方法发送取消信号。
func myGoroutine(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
// do some work
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go myGoroutine(ctx)
// wait for some time
time.Sleep(time.Second * 5)
// cancel the context to stop the Goroutine
cancel()
}
- 开启10个并发的Goroutine,当一个执行完后,怎么把剩余9个关闭?
4. 协程共享哪些资源?
协程拥有自己的栈空间,多个协程共享父线程的堆空间,代码段等。不同协程之间切换时会保存当前协程的状态,然后恢复之前保存的协程状态并继续执行。多个协程可以在同一线程中运行,共享线程的资源,例如栈、堆和全局变量等。
5. Go可以限制运行时操作系统的线程数量吗?常见的goroutine操作函数有哪些?
Go可以限制运行时操作系统的线程数量。可以使用GOMAXPROCS
环境变量来设置使用的最大CPU数目,以及runtime.GOMAXPROCS()
函数在代码中动态设置。这将影响Go程序使用的线程数,从而影响其并发性能。在某些情况下,限制线程数量可以提高Go程序的性能。
常见的goroutine操作函数包括:
- go:启动一个新的goroutine执行函数。
- runtime.Gosched():手动触发调度器进行协程调度。
- runtime.NumGoroutine():获取当前存在的goroutine数量。
- runtime.LockOSThread() 和 runtime.UnlockOSThread():将当前goroutine绑定到一个操作系统线程上。
- sync.WaitGroup:等待多个goroutine执行完毕后再继续执行。
- context.Context 和 context.WithCancel():用于在goroutine之间传递取消信号或共享数据。
6. 如何控制并发的协程数目?
- 使用带有缓冲的chan来控制协程的数量。例如,可以创建一个缓冲区大小为n的chan,然后在启动协程之前,往chan中发送n个元素,每个协程在启动时先从chan中取出一个元素,当协程执行完毕后再往chan中发送一个元素。这样可以保证同时运行的协程数目不超过n。
- 除此之外,还可以使用协程池来控制协程的数量。协程池是一个维护一定数量的协程的容器,当有任务到来时,协程池从容器中取出一个协程来执行任务。
7. goroutine默认栈空间多少?
Go中每个goroutine默认的栈大小是2KB,但是可以通过在启动goroutine时设置runtime.Stack的大小来增加或减少栈的大小。
8. 最多可以创建多少个协程?
在Go语言中,可以创建的协程数量并没有明确的上限。协程的数量主要受到计算机的资源的限制,比如可用内存和文件描述符等限制。一般来说,如果协程数过多,会导致内存占用过高和上下文切换频繁等问题,进而影响程序的性能。因此,在实际使用中,需要根据具体的应用场景和硬件环境,合理地调整协程数量,以获得最佳的性能和资源利用率。
9. goroutine泄露的场景?
10. 如何查看正在执行的Goroutine数量?
可以使用runtime.NumGoroutine()
函数可以返回当前程序中正在执行的Goroutine的数量。
6. Chan
读写nil管道均会被阻塞,而且是永久阻塞;关闭的chan仍然可以读数据,向关闭的管道写数据会触发Panic。
管道读取表达式最多可以给两个变量赋值:v, ok := <-ch
,第一个变量表示读出的数据,第二个变量表示是否通道是否关闭。一个已关闭的管道有两种状态:
- 管道内已没有数据;
- 管道内还有数据;
当管道内没有数据时,返回的一个值为对应类型的零值,第二个值为false。
1. Chan原理和实现
Chan是Go在语言层面提供的协程间通信方式,Chan的对应的结构体是hchan,结构体内部其实就是通过实现一个环形队列来作为缓冲,结构体内还包含指向环形队列的指针、等待从管道读消息的Goroutine队列、等待向管道写消息的Goroutine队列、写指针、读指针,以及一些其他字段,比如元素类型、队列中元素个数、管道是否关闭标志、互斥锁等。
2. Chan的关闭过程?
- 关闭Chan时,会把所有等待从Chan读消息的Goroutine唤醒,这些Goroutine读取到的数据都为nil;
- 关闭Chan时,会把所有等待向Chan写消息的Goroutine唤醒,且这些协程会触发Panic。
3. 对Chan进行操作时,哪些会触发Panic?
- 关闭值为nil的Chan;
- 关闭已经被关闭的Chan;
- 向已经关闭的管道写入数据;
4. for-range遍历管道
通过for-range可以持续地从管道中读出数据,当管道中没有数据时会阻塞当前协程,即使管道被关闭,for-range也可以优雅的结束。尽可能把看见你,
5. Channel的RING BUFFER实现?
在 Go 中,Channel 的实现是基于环形缓冲区(ring buffer)的,它是由一个固定长度的数组和一个写指针、一个读指针组成的。
6. chan用在什么地方?
一个是使用chan来控制同时并发的goroutine数量;另一个是父协程控制关闭子goroutine,还有就是用于多个协程间进行通信,传递一些数据。
7. chan的特点?
在Go中,channel是一种并发安全的、带缓冲的、阻塞的通信机制。channel的主要特点包括:
- 并发安全:多个Goroutine可以同时读写同一个channel而不会发生竞争状态,因为channel内部维护了一个锁。
- 带缓冲:channel可以带有缓冲区,缓冲区可以预先存储一定数量的元素,当channel中的元素达到缓冲区容量时,写操作会被阻塞,直到channel中有元素被读取。如果channel没有缓冲区,那么写操作会一直被阻塞,直到有其他Goroutine来读取元素。
- 阻塞:在channel上进行读写操作时,如果channel中没有元素或缓冲区已满,读或写操作会被阻塞,直到有元素被读取或缓冲区有空间。
8. chan有缓冲和无缓冲的区别?
在 Go 中,chan 分为两种类型:有缓冲的和无缓冲的。
无缓冲的chan,又称为同步通道,可以实现协程之间的同步,即在发送端发送数据时,如果没有接收端接收数据,那么发送端将一直阻塞,直到有接收端接收数据为止。同样的,在接收端接收数据时,如果没有发送端发送数据,接收端也将一直阻塞,直到有发送端发送数据为止。
有缓冲的chan,也就是异步通道,允许在没有接收端的情况下发送数据,或者在没有发送端的情况下接收数据,只要缓冲区还没有满或者没有空。如果缓冲区已满,则发送端会阻塞,直到有接收端接收数据为止;如果缓冲区已空,则接收端会阻塞,直到有发送端发送数据为止。
9. chan是线程安全的吗?为什么是线程安全的?
chan是线程安全的,chan内部通过互斥锁来实现对环形队列的互斥访问,保证了发送和接收操作的原子性和同步性。
10. chan如何控制Goroutine的并发执行顺序?
可以使用channel来控制Goroutine的并发执行顺序,可以通过channel的阻塞和非阻塞操作来实现。
一种常见的做法是使用带缓冲的channel,将所有要执行的Goroutine都添加到channel中,然后按照需要的执行顺序将它们取出来执行。在这个过程中,可以通过控制channel的大小来限制同时执行的Goroutine数量。
另一种做法是使用无缓冲的channel,通过发送和接收操作的阻塞和非阻塞特性来控制Goroutine的执行顺序。例如,可以使用两个channel来实现交替执行两个Goroutine的操作:
func main() {
ch1 := make(chan bool)
ch2 := make(chan bool)
go func() {
for {
<-ch1 // 阻塞等待ch1接收到一个bool值
fmt.Println("Goroutine 1")
ch2 <- true // 发送一个bool值给ch2,唤醒Goroutine 2
}
}()
go func() {
for {
<-ch2 // 阻塞等待ch2接收到一个bool值
fmt.Println("Goroutine 2")
ch1 <- true // 发送一个bool值给ch1,唤醒Goroutine 1
}
}()
ch1 <- true // 启动第一个Goroutine
time.Sleep(10 * time.Second)
}
11. chan什么情况下会死锁?
- 读写nil管道均会被阻塞,而且是永久阻塞;
- 一个管道在一个协程里同时进行读写会死锁;
- chan缓存满了,没有协程从chan里面读数据,则继续写入会死锁;
- chan缓存空了,没有协程向里面写数据,继续读取会死锁;
12. chan和共享内存方式各有什么优劣势?
chan 和共享内存是实现并发编程的两种基本方式,它们各有优劣势,具体如下:
chan 的优势:
- 更安全:chan 可以保证数据传递的安全,因为其在实现中使用了互斥锁,可以避免数据竞争的问题。
- 易于使用:chan 的使用非常简单,不需要像共享内存一样需要自己手动进行同步和互斥的操作。
- 更灵活:chan 的长度可以动态增长或缩小,可以适应不同的并发场景,比如可以用来进行任务调度、事件驱动等。
共享内存的优势:
- 更快速:共享内存的通信方式比 chan 更快,因为 chan 通信需要通过内核的调度器进行,而共享内存可以直接访问内存,减少了调度的开销。
- 更高效:共享内存可以避免数据复制的开销,对于大量的数据传输场景,可以提高效率。
- 更直观:共享内存的实现比较直观,可以直接使用指针进行访问,不需要像 chan 一样使用特殊的语法。
总的来说,chan和共享内存各有优劣,具体使用哪种方式需要根据具体的场景来选择。在大多数情况下,chan 更安全、易用、灵活,因此建议优先选择chan进行并发编程。而共享内存适用于需要高效传输大量数据的场景。
13. for select时,如果管道关闭会怎么样?
golang面试官:for select时,如果通道已经关闭会怎么样?如果只有一个case呢?
14. 管道怎么优雅关闭?(怎么确保管道不会被重复关闭,有什么技巧)
管道应该在发送者端关闭,而不是在接收端,当确定不再需要往chan写入数据时,发送者可以关闭chan。或者说不对它进行关闭,关闭close一般是用来通知事件。
golang如何优雅地关闭通道 - 孙龙-程序员 - 博客园
如何优雅地关闭 channel? | Go优质外文翻译 | Go 技术论坛
7. Interface
接口可以分为两类:
- 不带任何方法的空接口:
var i interface{}
- 带有方法的非空接口:
type i interface { func() }
这两种接口在底层对应两种结构体,空接口的底层结构是eface
结构体,而非空接口的底层结构是iface
结构体。
空接口eface
空接口eface结构,由两个属性构成,一个是动态类型信息_type
,一个是动态值。当声明一个空接口时,动态类型和动态值都为nil,当被赋值后,动态类型指向实例的类型元数据,动态值就是该实例:
type eface struct { //空接口
_type *_type //动态类型描述符
data unsafe.Pointer //动态值
}
type _type struct {
size uintptr //类型大小
ptrdata uintptr //前缀持有所有指针的内存大小
hash uint32 //数据hash值
tflag tflag
align uint8 //对齐
fieldalign uint8 //嵌入结构体时的对齐
kind uint8 //kind 有些枚举值kind等于0是无效的
alg *typeAlg //函数指针数组,类型实现的所有方法
gcdata *byte
str nameOff
ptrToThis typeOff
}
非空接口iface
非空接口的结构体由两个属性构成,一个是动态类型描述符itab,一个是动态值,非空接口初始化的过程就是初始化一个iface结构体:
type iface struct { //非空接口
tab *itab //动态类型描述符
data unsafe.Pointer //动态值
}
type itab struct {
inter *interfacetype // 接口自身的元信息
_type *_type // 具体类型的元信息
link *itab
bad int32
hash int32 // _type里也有一个同样的hash,此处多放一个是为了方便运行接口断言
fun [1]uintptr // 函数指针数组,指向具体类型所实现的方法
}
其中itab结构体可以看做接口类型和具体类型的组合,在itab结构体中记录了接口的类型和赋值给该接口的动态类型信息,还包括了存储接口方法的动态数组等。
注意,接口值可以用==和!=来做比较,如果两个接口值都是nil或者二者的动态类型完全一致且二者动态值相等,那么两个接口值相等。因为接口是可比较的,所以可以作为map的键,也可以作为switch语句的操作数。
参考:
1. 接口是否可以比较?如何比较两个接口是否相等?
接口类型是可以比较的,如果两个接口值的动态类型和动态值都相等,或者两个接口值都为nil,那么它们是相等的。
2. 接口可以和nil比较吗?
接口可以和nil比较。当接口类型的变量值为nil时,表示该接口变量并未持有任何实际对象,它的动态类型和动态值均为nil,此时与nil值比较结果为true。
8. Select
Select是Go语言层面提供的多路IO复用机制,用于检测多个Chan是否就绪(即可读或可写)。Select有如下几个特点:
- 每个case语句一定要处理一个管道,要么读要么写,且不能为空;
- 如果多个case语句就绪,则随机选择一个case语句执行;
- 如果case语句都没就绪,且没有default语句,则Select会一直阻塞;
- 如果Select的所有case语句都阻塞时,default语句会执行;
- 如果case语句中操作nil通道,则该case语句在运行时会被忽略,即向nil通道写消息不会Panic。
1. 实现原理和底层数据结构
研究Select的实现原理可以回答如下问题:
- 为什么每个case只能处理一个管道?
- 为什么case语句的执行顺序是随机的?
- 为什么case语句中向值为nil的管道写数据不会触发Panic?
2. Go select一般用在什么场景?
通常情况下,select 用于处理以下场景:
- 处理多个通道的输入或输出操作。
- 用于超时处理,可以设置超时时间,如果在指定时间内没有任何操作准备就绪,就可以执行超时操作。
- 防止因为一个通道阻塞而导致整个程序被阻塞,可以使用select的default关键字来处理这种情况。
Go语言select超时-golang select处理管道超时-golang select channel-嗨客网
3. for循环select时,如果通道已经关闭会怎么样?如果select中的case只有一个,又会怎么样?
golang面试官:for select时,如果通道已经关闭会怎么样?如果只有一个case呢?
4. select作用?怎么实现的?
通常情况下,select 用于处理以下场景:
- 处理多个通道的输入或输出操作。
- 用于超时处理,可以设置超时时间,如果在指定时间内没有任何操作准备就绪,就可以执行超时操作。
- 防止因为一个通道阻塞而导致整个程序被阻塞,可以使用default关键字来处理这种情况。
9. defer
defer语句用于延迟函数调用,常用于关闭文件描述符、释放锁等资源释放场景。
defer语句采用先进后出的设计,底层实现是函数栈,每遇到一个defer就会把函数压入栈中,函数返回前再将函数从栈中取出执行,最早被压入栈的函数最晚被执行。
不仅函数的正常返回会执行defer延迟函数,函数中任意一个return语句、panic语句均会触发defer执行。
注意:defer定义的延迟函数参数在函数压入栈时就已经确定了,在和闭包结合使用时要注意。
10. panic和recover
在panic执行过程中有几个点要明确:
- panic会递归执行协程中所有的defer,与函数正常退出时的执行顺序一致;
- panic不会处理其他协程中defer;
- 当前协程中的defer处理完后,触发程序退出;
recover()用于捕获当前协程中的panic并使程序恢复正常,关于recover()函数,有几个点需要明确:
- recover()一定要直接位于defer()函数中执行,不能嵌套在defer函数中;
- recover()只能捕获同一协程下的panic;
- recover()函数的返回值是panic()函数的错误,且recover()在成功处理异常后,无法回到本次函数发生panic的位置继续执行;
- recover()可以捕获本函数产生或子函数的panic,上游函数感知不到panic的发生;
- 当函数中发生panic并用recover()恢复后,当前函数仍然会继续返回,对于匿名返回值,函数就返回相应类型的零值,对于具名返回值,函数将返回当前已经存在的值。
1. go语言的panic如何恢复
Go语言中的panic可以通过recover函数进行恢复。recover函数用于捕获panic抛出的异常,并返回异常值。如果recover函数在defer语句中调用,那么它可以捕获到当前协程的panic异常,从而使程序能够正常继续执行。
在使用recover函数时,需要注意以下几点:
- recover函数必须直接位于defer语句中调用,recover嵌套在defer函数中无效,否则无法捕获异常。
- recover函数的返回值类型是interface{},需要通过类型断言将其转换为实际的异常类型。
- 如果当前协程没有发生panic异常,或者panic异常不是由当前协程抛出的,那么recover函数会返回nil。
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered from panic:", r)
}
}()
panic("Something went wrong.")
}
在Go语言中,当程序发生panic时,会先执行当前函数中defer的语句(如果有),然后再返回到上一层函数,并依次执行上一层函数中的defer语句,直到所有的defer都执行完毕,最终程序会在最顶层的函数处崩溃并打印panic信息。因此,虽然panic不会像普通的错误一样返回上一层函数调用,但是会先执行当前函数中的defer语句,包括可能存在的recover()语句。如果当前函数中的recover()函数成功捕获了panic,那么程序会恢复正常执行,并在上一层函数处继续执行,否则仍然会崩溃并打印panic信息。
2. defer recover panic执行顺序?作用?
在 Go 中,defer、recover、panic都是用于处理程序中出现的错误或异常的。它们的执行顺序是 panic -> defer -> recover。
panic 用于抛出异常,通常在程序遇到不可恢复的错误时使用。一旦执行了 panic,程序会立即停止执行,逐层回退执行defer调用,直到遇到可以处理该异常的recover函数。
defer 于在函数执行完毕后延迟执行一些操作,比如关闭文件、释放资源等,可以有效地避免资源泄漏。在发生异常时,defer 延迟的函数仍然会被执行。
recover 用于恢复程序在执行过程中发生的异常,只有在 defer 函数中调用 recover 才有用。如果在当前函数中调用 recover,则不会起作用。recover 可以获取到 panic 抛出的异常信息,并在需要的情况下进行处理。
综上所述,defer、recover、panic 三者一般是一起使用的,可以使代码更加健壮、稳定。
3. go如何避免panic
在 Go 中,我们可以通过以下几种方式来避免panic的发生:
- 错误处理:对可能出现错误的地方进行错误处理,比如对于输入不符合要求的数据进行校验或者对于返回错误的函数进行错误处理。
- 恰当使用recover函数:在代码中使用 recover 函数来捕获 panic,以便能够在程序崩溃前进行一些必要的操作或者恢复程序状态,但是需要注意的是,recover 只能捕获同一协程内的 panic。
- 谨慎使用一些可能会导致panic的语法和函数,比如使用nil指针、数组越界、重复关闭通道、向已关闭通道发送数据等等。
11. 并发控制
1. Mutex
1. 锁的底层实现原理
《Go专家编程》——Mutex,133页
2. mutex的几种状态?
- Locked:表示该锁是否已被锁定,0表示没有锁定,1表示锁定;
- Woken:表示是否有协程已被唤醒,0表示没有协程被唤醒,1表示已有协程唤醒,正在加锁过程中;
- Starving:表示该锁是否处于饥饿状态,0表示没有饥饿,1表示饥饿状态;
- Waiter:表示阻塞等待锁的协程个数,协程解锁时根据此值来判断是否需要释放信号量;
3. mutex正常模式和饥饿模式的区别?
4. mutex允许自旋的条件?
- 自旋次数小于最大自旋次数,通常是4次;
- CPU核数要大于1,否则自旋没有意义,因为只有一个CPU时不可能有其他协程释放锁;
- P的数量要大于1;
- P的本地队列必须为空,否则会延迟协程调度;
可见自旋的条件是很苛刻的,总而言之就是不忙的时候才会启动自旋。
5. go的锁是可重入的吗?如何实现可重入锁?
Go提供的锁是不可重入的,即同一个 Goroutine 在持有锁的情况下再次尝试获取锁会导致死锁。因为 Mutex 是基于操作系统的互斥量实现的,每次调用 Lock 方法都会获取互斥量,如果同一个 Goroutine 重复获取,那么会一直持有互斥量导致其他 Goroutine 无法获取锁。
如果需要可重入的锁,可以使用sync包提供的RWMutex,它支持同时有多个 Goroutine 获取读锁,但只能有一个 Goroutine 获取写锁。RWMutex 内部通过维护一个计数器实现了可重入性,同一个 Goroutine 在获取读锁时计数器加一,在释放读锁时计数器减一。只有计数器值为 0 时才可以获取写锁。这样可以避免死锁同时保证线程安全。
可重入锁是一种特殊的互斥锁,也叫递归锁。它允许同一个线程多次获取锁而不会被阻塞,线程在获取锁后,可以继续重复获取锁,而不会造成死锁。
在实现可重入锁的过程中,锁会保存一个计数器,记录当前线程获取锁的次数。每次获取锁时,计数器加1,释放锁时计数器减1。只有当计数器的值为0时,其他线程才可以获取锁。这样就保证了同一个线程可以多次获取锁,而其他线程必须等待当前线程释放完所有锁后才能获取锁。
可重入锁通常用于线程内部的递归调用中,避免了同一线程在执行递归调用时重复加锁而造成死锁,提高了程序的可靠性和性能。
6. Go 原子操作有哪些?原子操作和锁的区别?
- golang支持哪些并发机制
- golang如何知道或者检测死锁
7. 锁变量可以复制吗?
Go的锁变量是一个结构体类型,它包含了一个互斥锁(sync.Mutex),因此可以进行复制操作,但是不建议复制锁变量,因为这样可能会导致不可预期的行为。
在Go语言中,锁的目的是为了同步多个goroutine之间的访问,防止它们同时访问同一个共享资源而导致的数据竞争问题。因此,每个共享资源通常都需要有一个对应的锁,而这个锁的作用域应该是整个应用程序,而不是只在某个函数内部。
如果你复制一个锁变量,那么它实际上是复制了互斥锁的副本,这将导致多个goroutine同时访问同一个共享资源,因为它们都持有一个相同的锁。这种情况下,就失去了锁的作用,而可能导致数据竞争的问题。
因此,一般情况下,我们应该避免复制锁变量,而应该尽可能地使用引用类型,以确保所有goroutine都在访问同一个锁变量。
2. RWMutex
1. 读写锁的实现原理?
type RWMutex struct {
w Mutex
writeSem uint32
readerSem uint32
readerCount int32
readerWait int32
}
RWMutex是基于Mutex实现的,内部实现包含一个互斥锁(w)和两个条件变量(readerSem、writerSem)。
当一个goroutine请求写锁时,会首先请求互斥锁w,然后获取条件变量writerSem的所有权。此时,如果已有其他goroutine持有了读锁或者写锁,那么请求写锁的goroutine就会被阻塞在条件变量writerSem上,等待其他goroutine释放锁。
当一个goroutine请求读锁时,会首先请求互斥锁w,然后将读锁计数器加1。如果此时有其他goroutine持有写锁或正在等待获取写锁,那么请求读锁的goroutine就会被阻塞在条件变量readerSem上,等待其他goroutine释放锁。
当一个goroutine释放写锁时,会释放条件变量writerSem的所有权,并释放互斥锁w,以允许其他goroutine获取读写锁。
当一个goroutine释放读锁时,会将读锁计数器减1,如果此时计数器已经减到0,那么会释放条件变量readerSem的所有权,并释放互斥锁w。
总之,RWMutex内部使用了互斥锁和条件变量实现了读写锁的功能。当一个goroutine请求读锁或写锁时,会首先请求互斥锁来保证同步,然后根据请求的类型在条件变量上等待或者阻塞,等待其他goroutine释放锁。
2. 读写锁是公平的吗(为什么写操作不会被饿死)?怎么实现?
读写锁是公平的,写操作到来时,会把readerCount的值复制到readerWait中,用于标记排在写操作前面的读者个数。前面的读操作结束后,除了会把readerCount的数量减一,还会把readWait的数量减一,当readerWait变为0时唤醒写操作。
所以,写操作就相当于把读操作划分为两部分,前面的读操作结束后唤醒写操作,写操作结束后唤醒后面的读操作。
3. WaitGroup
WaitGroup主要维护了两个计数器和一个信号量,其中counter表示当前还未执行结束的goroutine,waiter表示等待goroutine-group结束的协程数量,即有多少个协程在等待wait。
Add()就是把counter的值加一,可以接受负值,当counter为0的时候,释放信号量,唤醒正在等待的goroutine;
Done()就是把counter减一,实际上是调用Add(-1),当counter为0的时候,释放信号量,唤醒正在等待的goroutine;
Wait()方法就是累加waiter,然后阻塞等待信号量释放;
4. Context
1. golang 如何做超时控制?
在 Go 中,可以使用time.After()
和time.Tick()
以及context.WithTimeOut
函数结合select
语句实现超时控制。
func doSomething() error {
// ...
}
func main() {
ch := make(chan error, 1)
go func() {
err := doSomething()
ch <- err
}()
select {
case err := <-ch:
if err != nil {
fmt.Println("doSomething failed:", err)
} else {
fmt.Println("doSomething succeeded")
}
case <-time.After(5 * time.Second):
fmt.Println("doSomething timed out")
}
}
在上面的示例中,使用 make() 函数创建了一个带有缓冲区大小为 1 的通道 ch。然后,使用 go 关键字启动了一个并发协程,在协程中执行 doSomething() 函数,并将其返回值发送到通道 ch 中。接下来,使用 select 语句等待通道 ch 中的数据,并使用 time.After() 函数实现 5 秒钟的超时控制。如果 doSomething() 函数在 5 秒钟内返回了结果,则从通道 ch 中读取数据;否则,超时控制生效,执行超时处理代码。
同样地,也可以使用 time.Tick() 函数实现周期性超时控制,示例如下:
func doSomething() error {
// ...
}
func main() {
ch := make(chan error, 1)
timeout := 5 * time.Second
ticker := time.Tick(timeout)
go func() {
err := doSomething()
ch <- err
}()
for {
select {
case err := <-ch:
if err != nil {
fmt.Println("doSomething failed:", err)
} else {
fmt.Println("doSomething succeeded")
}
return
case <-ticker:
fmt.Println("doSomething timed out")
return
}
}
}
2. 说说context包的作用?你用过哪些,原理知道吗?
context 包的主要作用是为了在跨多个Goroutine的函数之间传递上下文信息,比如超时时间、截止时间等。
context 的核心是 Context 接口,它定义了一个基本的上下文类型,它包含了 Deadline(截止时间)、Cancel(取消)等方法。同时,context 包还提供了一些方便的函数,如 context.WithCancel、context.WithDeadline、context.WithTimeout 等,用于创建特定类型的上下文。
在实现上,context 包使用了一个基于树形结构的设计,通过创建新的上下文,并使用 WithCancel、WithDeadline、WithTimeout 等方法将新上下文和当前上下文链接起来,从而形成一个树形结构。每个节点上的上下文会继承父节点的超时时间和取消信号等属性。同时,通过调用 WithCancel 或 WithDeadline 等方法创建新的上下文时,也会返回一个cancelFunc函数,用于取消当前上下文,从而通知所有子节点上的 Goroutine 停止工作。
5. Once
1. Go有哪些同步原语?
- Mutex(互斥锁):用于在代码中创建一个临界区,同一时间只能有一个 goroutine 进入该临界区,保护共享资源的访问。
- RWMutex(读写锁):相比于 Mutex,RWMutex 适用于读多写少的场景,可以在多个 goroutine 读取共享资源时同时进行,而在写入共享资源时会独占锁,保证同一时间只有一个 goroutine 可以写入。
- Cond(条件变量):用于等待或通知 goroutine,通常和 Mutex 或 RWMutex 一起使用。
- WaitGroup(等待组):用于等待一组 goroutine 执行完毕,只有等待组中所有 goroutine 都执行完毕后,主 goroutine 才会继续执行。
- Once(一次性执行):用于只执行一次某个函数,多次调用只有第一次会执行。
如何安全的共享全局变量?
在 Go 语言中,由于协程的存在,多个协程可能会同时访问全局变量,因此需要采取一些措施来确保全局变量的安全共享。以下是一些常见的方法:
- 互斥锁:使用互斥锁可以保证同一时刻只有一个协程能够访问全局变量,其他协程需要等待互斥锁的释放。这样可以避免多个协程同时修改全局变量导致的数据竞争问题。
- 原子操作:使用原子操作可以保证对全局变量的读写操作是原子的,即不会被打断。这样可以避免在读写全局变量时出现的并发问题。
- 通道:使用通道可以在协程之间传递数据,避免了对全局变量的直接访问,从而避免了并发问题。通道提供了安全的、同步的、可靠的数据传输方式。
- sync 包提供的其他同步原语,如读写锁、条件变量等,也可以用来保证全局变量的安全共享。
6. Cond
Go语言中的Cond是一种条件变量,它可以在多个Goroutine之间同步和传递信息。Cond通常与Mutex一起使用,Mutex用于保护共享资源的访问,而Cond用于在多个Goroutine之间等待特定的条件发生。Cond的主要作用是在一个条件变量上等待某个特定条件的发生,并在满足条件时通知等待的Goroutine。
Cond的常用方法有:
- Wait:等待条件变量的特定条件满足。在调用Wait方法之前,必须先获得锁,否则会导致panic。在Wait方法被唤醒之前,它会自动释放锁,并等待条件的发生。当被唤醒时,Wait方法会重新获取锁,并返回。
- Signal:向某个等待的Goroutine发送信号,表示条件发生。需要注意的是,Signal并不保证唤醒哪一个等待的Goroutine,也不能保证唤醒所有等待的Goroutine。
- Broadcast:向所有等待的Goroutine发送信号,表示条件发生。
下面是一个使用sync.Cond的示例代码,该代码演示了如何使用Cond实现一个简单的生产者-消费者模型:
package main
import (
"fmt"
"sync"
)
var (
buffer []int
bufferSize = 3
lock sync.Mutex
cond = sync.NewCond(&lock)
)
func main() {
go producer()
go consumer()
for {
}
}
func producer() {
for i := 1; i <= 10; i++ {
lock.Lock()
for len(buffer) == bufferSize {
cond.Wait()
}
buffer = append(buffer, i)
fmt.Printf("Produced: %d, buffer size: %d\n", i, len(buffer))
cond.Signal()
lock.Unlock()
}
}
func consumer() {
for {
lock.Lock()
for len(buffer) == 0 {
cond.Wait()
}
item := buffer[0]
buffer = buffer[1:]
fmt.Printf("Consumed: %d, buffer size: %d\n", item, len(buffer))
cond.Signal()
lock.Unlock()
}
}
在该示例中,生产者向缓冲区中放置数据,而消费者从缓冲区中取出数据。当缓冲区为空时,消费者会等待,直到有数据可用;当缓冲区满时,生产者会等待,直到有空间可用。sync.Cond中的Wait()方法可以阻塞当前的goroutine,并等待Signal()或Broadcast()信号,然后重新唤醒。Signal()会唤醒至少一个等待的goroutine,而Broadcast()会唤醒所有等待的goroutine。
12. GMP调度模型
[Golang三关-典藏版] Golang 调度器 GMP 原理与调度全分析 | Go 技术论坛
0. GMP模型是什么?
GMP调度器的功能是把可运行的goroutine分配到工作线程上,采用了多对多线程模型进行实现,即多个协程映射到多个内核线程上。其中有几个概念:
- G:G就是指goroutine;
- M:M就是内核线程;
- P:P是指虚拟处理器,包含M执行G所需要的资源和上下文。每个P有一个本地队列用来存放等待运行的G,除此之外,还有一个全局队列用来存放等待运行的G。
- 调度器维护了两个Goroutine队列:本地运行队列和全局运行队列。本地运行队列存储当前P上正在等待执行的G,全局运行队列存储所有等待执行的G。
M想要运行G,就必须先绑定一个P,然后从P的本地队列获取G,如果P的本地队列为空,P会尝试从全局队列拿一批G放到P的本地队列,当全局队列为空,P会尝试偷取一半其他P的本地队列中的G放到自己P的本地队列。
M运行G执行完成之后,M会从P的本地队列获取下一个等待运行的G,不断重复下去。
1. GMP中,M的数量怎么控制,P呢?
G:G的数量无限制,理论上只会受内存大小的限制。
M:Go运行时系统会根据需要动态地创建或销毁M,M的数量有限制,Go运行时默认数量限制为10000,可以通过debug.SetMaxThreads()
方法进行设置。
P:GOMAXPROCS环境变量可以对使用的P的个数进行限制,一般设置为CPU核心的数量。
2. Go协程实现模型?
多对多模型,多个协程映射到多个内核线程上。
3. work stealing机制?
在Go中,每个P(处理器)都维护着一个本地任务队列(local run queue),当一个goroutine执行完毕时,它会从P的本地任务队列中取下一个任务。如果P的本地任务队列中没有任务可执行,那么P会从全局任务队列中获取任务。如果全局任务队列也为空,那么P就会从其他P的本地任务队列中窃取任务。
4. hand off机制?
当线程M因为G进行的系统调用阻塞时,线程会释放绑定的P,把P转移给其他空闲的M执行。
5. P和M何时会被创建?
- P何时创建:在确定了P的最大数量n后,运行时系统会根据这个数量创建n个P。
- M何时创建:没有足够的M来关联P并运行其中的可运行的G。比如所有的M此时都阻塞住了,而P中还有很多就绪任务,就会去寻找空闲的M,而没有空闲的,就会去创建新的M。
5. Go是抢占式调度吗?何时会抢占?
是的,Go是抢占式调度的。
Go语言中的抢占式调度指的是当一个goroutine运行时间片用完(10ms)或者发生了阻塞的时候,调度器会主动中断它的执行,切换到其他可运行的goroutine上去执行,以此来实现并发的执行。
6. 如何查看运行时调度信息?
有2种方式可以查看一个程序的调度GMP信息,分别是go tool trace
和GODEBUG
7. 协程的几种状态?
- 新建状态(Gidle):当一个goroutine被创建时,它处于新建状态,还未初始化。
- 可运行状态(Grunnable):表示当前G正在可运行队列中等待执行。
- 运行状态(Grunning):当一个goroutine被调度器分配线程并开始执行时,它处于运行状态。
- 执行系统调用状态(Gsyscall):表示当前G正在执行某个系统调用。
- 阻塞状态(Gwaiting):表示当前G正在阻塞。
- 死亡状态(Gdead):当一个goroutine执行完毕或者发生了异常,它就进入了死亡状态。处于死亡状态的goroutine的资源会被回收。
- Gcopystack:表示当前G的栈正在被移动,移动的原因可能是栈的扩展或收缩。
- Gscan:Gscan表示当前G的栈在被扫描,扫描的原因一般是GC的执行。
8. GMP模型,为什么要有P?
在GMP模型之前的调度器是GM调度器,每个M从全局队列获取G执行,有几个问题:
- 每个G的创建、销毁、调用,都需要M获取锁访问全局队列,会有激烈的锁竞争;
- M交接G时会造成延迟和额外的系统负载,同时造成数据局部性很差;
- 当线程执行系统调用时,线程经常被阻塞和唤醒增加了系统开销;
为了解决上面这些问题,引进了P,P它包含了运行 goroutine 的资源,如果线程想运行 goroutine,必须先获取 P,P 中还包含了可运行的 G 队列。
9. 执行一个系统调用,在GMP里面是什么流程?
GMP模型中有个hand off机制,当执行系统调用,线程阻塞时,M会和绑定的P分离,P唤醒一个新的M继续工作,原来的M和G一起阻塞,直到系统调用完成。
13. 内存分配原理
Go语言的内存分配器会根据申请分配的内存大小选择不同的分配逻辑,将对象分为3种:
- 微对象:0~16B
- 小对象:16B~32KB
- 大对象:>32KB
内存分配器不仅会区别对待大小不同的对象,还会将内存分成不同的级别进行管理,引入线程缓存、中心缓存、页堆3个组件分级管理内存。
线程缓存属于每一个独立的线程,主要用来缓存用户程序申请的微小对象,它能够满足线程上绝大多数的内存分配需求。因为不涉及多线程,所以不需要使用互斥锁来保护内存,这能够减少锁竞争造成的性能损耗。
当线程缓存不能满足需求时,运行时会使用中心缓存来解决小对象的分配,中心缓存由全部线程共享,访问中心缓存中的内存需要加锁。
当遇到32KB以上的大对象时,内存分配器会选择在页堆直接分配大内存。
总的来说,就是Go使用了内存分级管理,降低锁的力度,提高分配效率。
1. 栈扩容收容机制
所有函数调用之前都会进行检查当前Goroutine的栈内存是否充足,如果不足,开辟新的栈空间进行扩容;当栈内存使用不足1/4时,会触发缩容,缩容会将栈空间缩小为原来的一半。
2. Go内存泄露
go中的内存泄露一般都是goroutine泄露,就是goroutine没有被关闭,或者没有添加超时控制,让goroutine一只处于阻塞状态,不能被GC。常见的协程泄露情况有以下几种:
- channel发送阻塞,channel接受阻塞;
- 死锁;
- 无限循环;
Go 切片导致内存泄露,被坑两次了! - 技术颜良 - 博客园
3. Go内存对齐机制?
14. 垃圾回收机制
请简单描述 Go 语言 GC(垃圾回收)的工作原理 | Go面试题笔试题 | Go 技术论坛
[Golang三关-典藏版] Golang三色标记混合写屏障GC模式全分析 | Go 技术论坛
1. 垃圾回收过程
Go的垃圾回收机制采用的是三色标记清除算法+屏障机制,三色就是指白、灰、黑色,其中
- 白色:未标记的对象;
- 灰色:存活的对象,但其包含的子对象还未被标记;
- 黑色:存活的对象,且它的子对象也被标记;
所谓的三色标记法主要就是通过三个阶段的标记来确定需要清除的对象有哪些,有以下几个的过程:
- 第一步:所有新创建的对象,都是白色;
- 第二步:每次GC回收开始,都从根对象遍历所有可访问的对象,把遍历到的对象从白色变为灰色;
- 第三步:分析灰色对象,将灰色对象的子对象从白色变为灰色,灰色对象变为黑色;
重复上面的分析过程,直到不存在灰色对象,最后只剩下白色对象和黑色对象,其中白色对象表示不可达对象,将被GC回收。
触发三色标记法不安全的必要条件
为了防止程序的改变对象引用关系,影响标记的正确性。可以看出,有两种情况,在三色标记法中,是不希望被发生的。
条件 1: 一个白色对象被黑色对象引用 (白色被挂在黑色下)
条件 2: 灰色对象与它之间的可达关系的白色对象遭到破坏 (灰色同时丢了该白色)
如果当以上两个条件同时满足时,就会出现对象丢失现象!
为了防止这种现象的发生,最简单的方式就是 STW,直接禁止掉其他用户程序对对象引用关系的干扰,但是 STW 的过程有明显的资源浪费,对所有的用户程序都有很大影响。那么是否可以在保证对象不丢失的情况下合理的尽可能的提高 GC 效率,减少 STW 时间呢?答案是可以的,我们只要使用一种机制,尝试去破坏上面的两个必要条件就可以了。
屏障机制
让 GC 回收器,满足下面两种情况之一时,即可保对象不丢失。 这两种方式就是 “强三色不变式” 和 “弱三色不变式”。
- 强三色不变式:强三色不变色实际上是强制性的不允许黑色对象引用白色对象,这样就不会出现有白色对象被误删的情况。
- 弱三色不变式:弱三色不变式强调,黑色对象可以引用白色对象,但是这个白色对象必须存在其他灰色对象对它的引用,或者可达它的链路上游存在灰色对象。
GC 算法使用两种屏障方式遵循上述两种方式,分别是 “插入屏障(插入写屏障)”和“删除屏障(删除写屏障)”。
- 插入屏障:插入屏障的具体操作是,在A对象引用B对象的时候,B对象被标记为灰色,插入屏障实际上是满足强三色不变式(不存在黑色对象引用白色对象的情况了, 因为白色会强制变成灰色)。所以“插入屏障”机制在栈空间的对象操作中不使用,而仅仅使用在堆空间对象的操作中。当全部三色标记扫描之后,栈上有可能依然存在白色对象被引用的情况。 所以要对栈重新进行三色标记扫描,但这次为了对象不丢失。但这次扫描要启动STW暂停,直到栈空间的三色标记结束。
- 删除屏障:删除屏障是当一个对象的引用被摘掉的时候,或者当一个对象引用被上游替换的时候,该对象被标记为灰色。删除屏障实际上是满足弱三色不变式,目的是保护灰色对象到白色对象的路径不会断。
混合写屏障
插入写屏障和删除写屏障虽然都可以在一定程度上解决STW带来的无法并行处理的问题,但是也都有各自的短板。
(1)插入写屏障:结束时需要STW来重新扫描栈,标记栈上引用的白色对象的存活。
(2)删除写屏障:回收精度低,GC开始时STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象。因为一个对象即使被删除了,但是最后一个指向它的指针也依旧可以多“活”过这一轮,只有等到下一轮GC中才会被清理掉。
Go V1.8版本引入了混合写屏障机制,避免了对栈Re-scan(重新扫描)的过程,这也极大的减少了STW的时间,同时结合了插入写屏障和删除写屏障两者的优点。
混合写屏障的具体操作一般需要遵循以下几个条件限制:
(1)GC开始将栈上的可达对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW)。
(2)GC期间,任何在栈上创建的新对象,均为黑色。
(3)被删除的对象标记为灰色。
(4)被添加的对象标记为灰色。
混合写屏障实际上满足的是一种变形的弱三色不变式。
注意,屏障技术是不在栈上应用的,因为要保证栈的运行效率。混合写屏障是GC的一种屏障机制,所以只是当程序执行GC的时候,才会触发这种机制。
一次完整的 GC 分为四个阶段:
- 1)标记准备(Mark Setup,需 STW),打开写屏障(Write Barrier)
- 2)使用三色标记法标记(Marking, 并发)
- 3)标记结束(Mark Termination,需 STW),关闭写屏障。
- 4)清理(Sweeping, 并发)
2. GC触发的原因
- 内存分配达到阈值触发GC
每次内存分配时都会检查当前内存量是否达到阈值,如果达到阈值则立即启动GC。
阈值 = 上次GC内存分配量 * 内存增长率
内存增长率由环境变量GOGC控制,默认为100,即每当内存扩大一倍时启动GC。
- 定期触发GC
默认情况下,最长2分钟触发一次GC。
- 手动触发
程序代码中可以使用runtime.GC()来手动触发GC,主要用于GC的性能测试和统计。
3. GC性能优化
GC性能和对象数量负相关,对象越多,GC性能越差。
- 所以GC优化的思路之一就是减少对象分配的个数,比如对象复用或使用大对象组合多个小对象、减少字符串拼接等等。
- 另外,由于内存逃逸会产生一些隐式内存分配,也有可能成为GC的负担,所以尽量避免内存逃逸也可以优化GC。
4. 如何查看GC信息?
在Go语言中,可以使用runtime包提供的一些函数来查看GC信息,具体如下:
- runtime.NumGC():返回当前程序运行过的GC次数。
- runtime.ReadMemStats():返回程序当前内存使用情况的统计信息,其中包括当前分配的堆内存大小、当前堆内存中已经使用的内存大小、当前程序中活动的goroutine数量、垃圾回收器的状态等信息。
5. Go GC如何调优?
6. GC发生在哪里?
GC回收的是堆内存,GC 不会回收栈内存,因为栈内存是在函数调用时自动分配和回收的。
15. 内存逃逸分析
内存逃逸:指局部变量、函数参数等被分配在函数栈上的内存对象被引用到函数外部,导致这些对象在函数执行完成后仍然存在于内存中,从而使得这些对象的生命周期延长到函数的结束,这就称为内存逃逸。
逃逸分析是指由编译器决定内存分配的位置,不需要程序员指定。在函数中申请一个新对象时:
- 如果分配在栈中,则函数执行结束后可自动将内存回收;
- 如果分配在堆中,则函数执行结束后交给GC处理;
有了逃逸分析,返回函数局部变量变得可能。除此之外,逃逸分析还跟闭包息息相关。
1. 逃逸策略
简单来说,在函数中申请新对象时,编译器会根据该对象是否被函数外部引用来决定是否逃逸:
- 如果函数外部没有引用,则优先放入栈中;
- 如果函数外部存在引用,则必定放到堆中;
注意,对于仅在函数内部使用的变量,也有可能放入到堆中,比如占用内存过大超过栈的存储能力。
2. 逃逸场景
- 指针逃逸
Go可以返回局部变量指针,可能会发生内存逃逸,这些对象会被分配到堆中而不是栈中。
- 栈空间不足逃逸/chan空间不足逃逸
实际上当栈空间不足以存放当前对象或无法判断当前切片的长度时会将对象分配到堆中。
func main() {
s := make([]int, 10000, 10000)
for i, _ := range s {
s[i] = i
}
}
➜ demo go build -gcflags=-m
# demo
./main.go:3:6: can inline main
./main.go:4:11: make([]int, 10000, 10000) escapes to heap
同理,
- 动态类型参数逃逸
很多函数的参数为interface{}
类型,比如fmt.Println(a ...interface{}),由于空接口类型参数在编译期间很难确定且参数的具体类型,所以会统一把它们分配到堆上,产生逃逸。
- 闭包引用对象逃逸
函数中的局部变量由于闭包的引用,会将局部变量分配到堆中,以致产生逃逸。
3. 小结
- 栈上分配的内存比堆中分配的内存效率更高,栈上分配到内存不需要GC处理。
- 逃逸分析的目的是决定分配地址是栈内存还是堆内存,逃逸分析在编译阶段完成。
- 通过编译参数
-gcflag=-m
可以查看编译过程中的逃逸分析过程。] - Golang中一个函数内局部变量,不管是不是动态new出来的,它会被分配在堆还是栈,是由编译器做逃逸分析之后做出的决定。
4. 函数传递指针真的比传值效率高吗?
比如调用函数传入结构体时,应该传值还是传引用?
我们知道传递指针可以减少底层值的复制,可以提高效率,但是如果复制的数据量小,由于指针传递会产生逃逸,则可能会使用堆内存分配,也可能增加GC负担,所以传递指针不一定是更高效的。
总结下,在一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。对于只读的占用内存较小的结构体,直接传值能够获得更好的性能。
5. 为什么需要逃逸分析?有好处?
Go通过逃逸分析,对变量进行更合理的分配,把那些不需要分配到堆上的变量直接分配到栈上,堆上的变量少了不但同时减少 GC 的压力,还减轻了内存分配的开销,提高程序的运行速度。
6. 堆和栈的区别
7. Go的对象是分配在堆上吗?如何知道一个对象是分配在栈上还是堆上?
在Go语言中,一般情况下,栈和堆的分配是由编译器和运行时共同管理的,对于大多数开发者来说,不需要手动干预栈和堆的分配。但是,可以通过以下几种方式判断一个对象是分配在栈上还是堆上:
- 使用Go语言提供的pprof工具查看内存分配情况:可以通过在代码中导入pprof包并使用该包提供的API进行内存分配监控,然后使用pprof工具进行分析。
- 查看对象的地址:使用Go语言中的“&”操作符可以获取一个对象的地址。如果该地址以“0x”开头,表示该对象是分配在堆上的;如果该地址以其他值开头,表示该对象是分配在栈上或静态区域的。
8. 怎么避免内存逃逸?
- 尽量使用值传递:尽量使用函数参数的值传递方式,而不是引用传递或指针传递,避免在堆上分配内存。
- 避免在函数内部分配大量的内存:在函数内部分配的大量内存容易导致内存逃逸。
16. 反射
1. 反射是什么?
Go语言的反射机制是一种在运行时检查变量类型、获取变量值和调用变量方法等能力的机制。
2. 热身测验
- 如果判断两个Foo结构体是否相等?能否使用"=="操作符进行比较?
type Foo struct {
A int
B string
C interface{}
}
使用"=="操作符可以比较两个结构体变量,但仅限于结构体成员类型都为简单类型,不能包含slice、map、interface等不可比较类型。实际项目中一般使用reflect.DeepEqual()
函数来比较两个结构体变量,它支持任意两个结构体变量的比较。
- 如何判断空接口类型中的两个变量是否相等?是否可以使用"=="操作符比较?
func IsEqual(a, b interface{}) bool {
// TODO
}
使用"=="操作符可以比较两个空接口类型变量,但仅限于接口底层类型一致且不能包含slice、map等不可比较类型。实际项目中一般使用reflect.DeepEqual()
函数来比较两个结构体变量,它支持任意两个结构体变量的比较。
3. reflect包
每个interface类型都有类型和值两部分,Go的反射就是在运行时操作interface中的类型和值。
reflect
包中提供了reflect.Type
和reflect.Value
两个接口,分别代表interface
中的type
和value
。同时提供两个方法来获取接口的类型和值:
func ValueOf(i interface) Value
,用来获取输入参数中的数据的值,如果接口为空则返回0。func TypeOf(i interface) Type
,用来动态获取输入参数中的值的类型,如果接口为空则返回nil。
Type表示变量的类型信息,包括类型名、包名、方法列表等。它是一个接口类型,提供了许多方法检查返回该接口的类型信息。
type Type interface {
...
Method(int) Method
MethodByName(string) (Method, bool)
NumMethod() int
Name() string
PkgPath() string
...
String() string
// Kind returns the specific kind of this type.
Kind() Kind
...
// Elem returns a type's element type.
// It panics if the type's Kind is not Array, Chan, Map, Pointer, or Slice.
Elem() Type
// Field returns a struct type's i'th field.
// It panics if the type's Kind is not Struct.
// It panics if i is not in the range [0, NumField()).
Field(i int) StructField
// FieldByIndex returns the nested field corresponding
// to the index sequence. It is equivalent to calling Field
// successively for each index i.
// It panics if the type's Kind is not Struct.
FieldByIndex(index []int) StructField
// FieldByName returns the struct field with the given name
// and a boolean indicating if the field was found.
FieldByName(name string) (StructField, bool)
FieldByNameFunc(match func(string) bool) (StructField, bool)
...
// Key returns a map type's key type.
// It panics if the type's Kind is not Map.
Key() Type
Len() int
// NumField returns a struct type's field count.
// It panics if the type's Kind is not Struct.
NumField() int
}
Value表示变量的值,可以是任何类型的值,包括基本类型、结构体、数组、切片、Map等。Value是一个结构体,实现许多方法用来操作该值。
具体关系如下图所示:
一般将reflect.Type
和reflect.Value
称为interface
的反射对象。
4. 反射定律
- 第一定律:反射可以将
interface{}
类型变量转换成反射对象 - 第二定律:反射可以将反射对象还原成
interface{}
对象 - 第三定律:反射对象可以修改,value值必须是可设置的
5. 反射实现原理
17. Go调试和性能调优
Pporf
1、最常用的调试 golang 的 bug 以及性能问题的实践方法 · 语雀
18. 单元测试
搞定Go单元测试(二)—— mock框架(gomock) - 掘金
Go语言支持的测试类型有单元测试、性能测试、示例测试,此外还支持子测试、Main测试等。
编写可测试的Go代码的核心原则是将代码分解为易于测试的小块,限制每个块的依赖性,避免全局状态以及使用标准接口实现清晰的输入输出。
- 模块化设计:将代码按照功能划分为小模块,每个模块都有清晰的输入输出和接口定义,可以方便地进行单元测试和集成测试。
- 依赖注入:将依赖关系通过参数传递或者接口定义的方式,避免在代码中直接引用外部依赖
- 避免全局变量:尽量避免使用全局变量,因为全局变量会增加代码的复杂度和不确定性,难以测试和维护。
Mock:在测试包中创建一个结构体,满足某个外部依赖的接口
19. 泛型
https://zhuanlan.zhihu.com/p/471490292
牛客网 - 找工作神器|笔试题库|面试经验|实习招聘内推,求职就业一站解决_牛客网
20. 性能调优
- golang http库设计原理,为什么不池化?
- Orm是什么了解过吗
- 比较 gin 框架和其它框架
- 写过哪些gin中间件?
- 2个协程交替打印字母和数字
- 深拷贝和浅拷贝
在go语言中值类型赋值都是深拷贝,引用类型一般都是浅拷贝。
深拷贝拷贝的是数据本身,创造一个样的新对象,新创建的对象与原对象不共享内存,新创建的对象在内存中开辟一个新的内存地址,新对象值修改时不会影响原对象值。
浅拷贝的是数据地址,只复制指向的对象的指针,此时新对象和老对象指向的内存地址是一样的,新对象值修改时老对象也会变化。
- go深拷贝发生在什么情况下?切片的深拷贝是怎么做的?
- go什么场景使用接口
- 一个线程打印奇数一个线程打印偶数 交替打印
- Go string底层实现?
- Go管理依赖go mod命令,go mod最后的版本号如果没有tag,是怎么生成的
- map如何顺序读取?
- 看过哪些底层包源码?
- go的包管理?
- go性能调优怎么做的?
4. 进阶问题
2.1 语言基础进阶
- 什么是CAS
- 如果项目里api耗时过久,你会怎么去排查
- pprof使用
- go性能分析工具
2.2 内存
- golang内存分配
- Go内存泄漏
2.3 并发
- 无缓冲channel和有缓冲channel区别?
- channel底层实现?是否线程安全?
- 同一个协程里面,对无缓冲channel同时发送和接收数据有什么问题?
同一个协程里,不能对无缓冲channel同时发送和接收数据,如果这么做会直接报错死锁。
对于一个无缓冲的channel而言,只有不同的协程之间一方发送数据一方接受数据才不会阻塞。channel无缓冲时,发送阻塞直到数据被接收,接收阻塞直到读到数据。
- 对已经关闭的channel进行读写操作会发生什么?
1 . 读已关闭的channel
读已经关闭的channel无影响。如果在关闭前,通道内部有元素,会正确读到元素的值;如果关闭前通道无元素,则会读取到通道内元素类型对应的零值。若遍历通道,如果通道未关闭,读完元素后,会报死锁的错误。
2 . 写已关闭的通道
会引发panic: send on closed channel
3 . 关闭已关闭的通道
会引发panic: close of closed channel
「总结:」对于一个已初始化,但并未关闭的通道来说,收发操作一定不会引发 panic。但是通道一旦关闭,再对它进行发送操作,就会引发 panic。如果我们试图关闭一个已经关闭了的通道,也会引发 panic。
//1. 读一个已经关闭的通道
func main() {
channel := make(chan int, 10)
channel <- 2
close(channel)
x := <-channel
fmt.Println(x)
}
/*[Output]: 不会报错,输出2*/
// 遍历读关闭通道
func main() {
channel := make(chan int, 10)
channel <- 2
channel <- 3
close(channel) //若不关闭通道,则会报死锁错误
for num := range channel {
fmt.Println(num)
}
}
/*[Output]: 不会报错,输出2 3*/
//2. 写一个已经关闭的通道
func main() {
channel := make(chan int, 10)
close(channel)
channel <- 1
}
/*[Output]: panic: send on closed channel*/
//3. 关闭一个已经关闭的管道
func main() {
channel := make(chan int, 10)
close(channel)
close(channel)
}
/*[Output]: panic: close of closed channel */
- context类型有哪些?Context的作用是什么?context如何实现cancel的?
- 如何判断channel是否关闭?
- 如何实现一个线程安全的 map?
- ?
- go map并发安全问题,如何解决?
- waitGroup 用法
2.4 GC
- 简述 Go 语言GC(垃圾回收)的工作原理
- 什么时候会触发GC 呢
- 三色标记法的灰色、黑色有什么区别
- 为什么区分灰色和黑色,灰色存在的意义?
- 写屏障是什么?
- GC如何调优?
五、总结
Go 语言以其简洁性、并发性和高效性赢得了广泛的应用。准备 Go 面试时,你不仅需要掌握基础语法和常用库的使用,还需要深入理解 Go 的并发模型、内存管理以及性能优化技巧。通过对上述问题的练习和深入理解,你将能够在面试中脱颖而出。