Golang八股文
一、Go语言编程
1.1 make 和 new 的区别
- 接收参数个数不一样:new() 只接收一个参数,而 make() 可以接收多个参数
- 返回类型不一样:new() 返回一个指针,而 make() 返回类型和它接收的第一个参数类型一样
- 应用场景不一样:make() 专门用来为 slice、map、chan 这样的引用类型分配内存并作初始化,而 new() 用来为其他类型分配内存。
1.2 切片和数组的比较
切片的数据结构
type slice struct {
array unsafe.Pointer
len int
cap int
}
切片的结构体由3部分构成,Pointer 是指向一个数组的指针
,len 代表当前切片的长度
,cap 是当前切片的容量
。cap 总是大于等于 len 的。
- 长度和容量:
- 数组的长度是固定的,在声明时需要指定长度,并且不能动态改变。数组的声明方式为 [长度]类型,例如 [5]int 表示一个包含 5 个整数的数组。
- 切片的长度可以动态改变,它是对底层数组的一个视图,可以根据需要动态调整其长度。切片的声明方式为 []类型,例如 []int 表示一个整数切片。
- 传递和拷贝:
- 数组在传递给函数或赋值给其他变量时会 进行值拷贝,即创建一个完全相同的新数组。
- 切片在传递给函数或赋值给其他变量时是引用传递,即传递的是切片的引用(底层数组的引用),修改切片会影响到原始切片和底层数组。
- 底层数组:
- 数组是一个固定长度的数据结构,它在内存中是连续存储的。
- 切片是一个动态长度的数据结构,它本质上是对底层数组的一个视图,通过指向底层数组的指针、长度和容量来表示切片。
1.3 for range 陷阱
我之前写边缘网关项目的时候也遇到过:
在 Go 中,使用 for range 循环遍历切片、数组、映射等数据结构时,循环体中的变量可能会因为引用同一个内存地址而产生意想不到的行为。
切片/数组:for range 会创建循环变量的拷贝,每次循环更新拷贝。因此,如果你取循环变量的地址,地址保持不变,因为你取的是循环变量的地址,而不是数据本身的地址。要避免这个问题,可以在循环体内创建变量的拷贝。
示例:避免地址变化导致的问题。
names := []string{"Alice", "Bob", "Charlie"}
// 这里会引发问题,因为 `name` 变量的地址固定
pointers := []*string{}
for _, name := range names {
pointers = append(pointers, &name)
}
// 解决办法:创建变量的独立拷贝
for _, name := range names {
n := name
pointers = append(pointers, &n)
}
解决办法:
- 创建变量的独立拷贝(用for i:=1 names[i]去遍历也行)
- 使用指针作为成员(不推荐,指针的使用可能增加内存管理的复杂性,可能导致内存泄漏。)
1.4 三种引号区别
- 单引号用于表示单个字符,类型是 rune。
- 双引号用于表示字符串,支持转义字符,类型是 string。
- 反引号用于表示原始字符串,不支持转义字符,保留原样,类型也是 string。
rune
是 Go 语言中的一种数据类型,代表一个 Unicode 码点。它是一种特殊类型的整数,实际是 Go 的内置类型int32
的别名。
1.5 函数与方法
方法就是成员函数
1.6 子切片导致的内存泄漏问题
子切片可能导致内存泄露,因为它们可能引用了较大的底层数组。为了避免这个问题,确保在不需要引用底层数组时显式复制数据,并在不需要时释放切片。合理使用切片和定期监控内存是防止内存泄露的有效方法。
1.7 Golang如何高效地拼接字符串?
在 Go 语言中,字符串是不可变的,所以直接使用 + 运算符拼接字符串可能导致性能问题,尤其是在大量拼接操作时。高效拼接字符串的方法包括使用strings.Builder
、bytes.Buffer
和 join
等方式。这些方法在拼接大量字符串时比 + 运算符更有效。
- strings.Builder 是 Go 语言专为高效拼接字符串设计的工具。它可以动态分配内存,并在内部使用可变缓冲区来避免不必要的字符串复制。
- 特点:
- 适合在循环中拼接字符串。
- 内部使用动态缓冲区,减少内存分配。
import (
"strings"
)
func efficientConcat(parts []string) string {
var builder strings.Builder
for _, part := range parts {
builder.WriteString(part) // 拼接字符串
}
return builder.String() // 最后返回拼接结果
}
- bytes.Buffer 类似于 strings.Builder,但主要用于操作字节。它适合在需要处理字节流或跨字符串和字节之间转换时使用。
- 特点:
- 可以用于拼接字符串和字节。
- 在处理混合类型数据时表现良好。
import (
"bytes"
)
func bufferConcat(parts []string) string {
var buffer bytes.Buffer
for _, part := range parts {
buffer.WriteString(part) // 拼接字符串
}
return buffer.String() // 最后返回拼接结果
}
strings.Join 是一个方便的函数,用于将字符串切片拼接成一个字符串,使用指定的分隔符。它适合在需要用特定字符分隔字符串时使用。
特点:
- 可以用指定的分隔符拼接字符串。
- 对于拼接字符串切片,效率高。
import (
"strings"
)
func joinConcat(parts []string) string {
return strings.Join(parts, ", ") // 用逗号和空格拼接
}
直接使用 + 运算符拼接字符串可能导致性能问题,因为每次拼接都会创建新的字符串,并复制内容。
1.8 Golang中2 个 interface 可以比较吗?
在 Go 中,两个 interface 是否可比较取决于它们指向的底层类型。可以比较两个空接口,或者两个指向同一对象的接口。不可比较的类型,如切片、映射和函数,不能用于接口比较。如果尝试比较不可比较的接口,会引发运行时panic。在比较接口之前,确保它们的底层类型是可比较的,避免潜在的错误和panic。
1.9 Golang 中 init() 函数
- 自动执行:init() 不需要手动调用,会在包加载时自动执行。
- 没有参数和返回值:init() 不能接受参数,也没有返回值。
- 每个包可以有多个:一个包可以包含多个 init() 函数,按照源文件中的顺序执行。
- 执行顺序:init() 的执行顺序取决于包的导入顺序和依赖关系。
1.10 Golang的Map可以边遍历边删除元素吗?
在 Go 中,边遍历 Map 边删除元素可能导致未定义行为或运行时恐慌。为了确保安全,建议先收集要删除的键,然后在第二个循环中删除这些键,或使用新的 Map 进行替换。这样可以避免边遍历边删除导致的问题,确保 Map 的稳定性和正确性。
1.11 Golang的float类型可以作为Map的key吗?
虽然 Go 允许 float 类型作为 Map 的键,但由于浮点数的精度和比较不稳定,不建议将其作为 Map 的键。推荐使用整数或字符串作为 Map 的键,以确保稳定性和可预测性。如果必须使用 float 类型作为键,请注意可能的精度问题,并确保足够的测试来验证行为的一致性。
1.12 Golang中Map的数据结构是什么?
在Go语言中,map是一种键值对数据结构。它是一种哈希表实现,所以它是无序的。允许您通过键来快速检索、插入和删除值。map的键和值可以是各种类型,但键的类型必须是可以比较的类型,以支持哈希计算和键比较操作。常见的可比较类型包括整数、字符串、指针、接口、数组和结构体(条件是结构体的所有字段也必须是可比较的)。
值得注意的是,map在Go中是无序的,每次迭代的顺序可能不同。此外,map在并发情况下并不是线程安全的。如果需要并发访问map,可以使用同步机制,如锁或其他并发安全的数据结构,比如sync.Map
。
1.13 Golang中函数返回局部变量的指针是否安全?
在 Go 语言中,函数返回局部变量的指针是安全的。这是因为 Go 具有智能的内存管理和逃逸分析机制,它确保在返回指针或引用时,指向的变量不会在返回后被释放或无效。
局部变量指针的逃逸分析
当函数返回局部变量的指针时,Go 的编译器会执行逃逸分析。逃逸分析的目的是确定变量的生命周期。如果一个局部变量的地址被返回给外部,它的内存将不会分配在栈上,而是分配在堆上,这样即使函数结束,该变量也不会被销毁。
逃逸分析(Escape Analysis)是一种在
编译阶段
进行的静态分析技术,用于确定程序中变量的作用域和生命周期,特别是判断变量是保留在栈上还是堆上。通过逃逸分析,编译器可以决定变量在程序执行过程中是否需要逃逸到堆上分配,从而影响内存分配和垃圾收集的效率。
如果变量在函数或代码块内的生命周期结束后仍可能被引用,则这个变量需要在堆上分配,确保它在函数返回后仍然有效。这种情况称为逃逸到堆
。如果变量只在当前函数或代码块内使用,不会被外部引用,则可以在栈上分配。这样做更高效,因为栈分配和释放的成本较低。
可以使用 go build -gcflags “-m” 来查看 Go 编译器的逃逸分析结果。这个命令会显示哪些变量逃逸到堆上,以及编译器对变量内存分配的决策过程。这对于理解编译器的内存管理策略以及优化代码性能非常有帮助。
小心内存泄漏
虽然返回局部变量的指针是安全的,但需要注意的是,如果不适当地管理指针,可能导致内存泄漏。例如,当指针引用了一个很大的数据块,并且这个数据块不再需要时,没有将指针设置为 nil 或让它超出作用域,可能导致内存没有被释放。这可能会造成程序消耗大量的内存资源。
1.14 Golang中两个 nil 可能不相等吗?
在 Go 语言中,通常情况下,两个 nil 是相等的。但在接口比较时,如果接口的静态类型不同,即使动态值都是 nil,它们也可能不相等。理解这个特性对于接口编程和处理多态性非常重要。
1.15 Golang的切片作为函数参数是值传递还是引用传递?
在 Go 中,切片作为函数参数是值传递的,但由于切片内部结构的特性,这种传递行为类似于引用传递。切片的长度和容量是值传递的部分,而指向底层数组的指针是共享的。因此,切片作为参数传递时,改变其内容可能影响原始切片。
1.16 Golang中哪些不能作为map类型的key?
在 Go 中,map 的键必须是可比较的,因此切片
、映射
、函数
等不可比较的类型不能用作 map 键。可作为 map 键的类型包括基础类型、数组、结构体(所有字段可比较)、指针等。理解这一点可以避免在使用 map 时遇到编译错误。
1.17 Golang中nil map 和空 map 有何不同?
nil map 和 空 map 的主要区别在于,nil map 未初始化,不能添加或修改元素,而空 map 已初始化,可以进行添加、修改和删除操作。在实际应用中,应根据需求来选择适当的 map 类型。
1.18 Golang的Map中删除一个 key,它的内存会释放么?
在 Go 语言中,当您从 map 中删除一个键时,map 的内存管理是自动处理的,但删除键并不一定会立即释放相应的内存。
垃圾回收的时机:垃圾回收不是即时的。它在后台异步运行,可能会有一定的延迟。因此,删除键后,相关内存可能需要等待一段时间才会被回收。
Map 的容量:即使删除了某个键,Map 的容量可能不会立即缩小。这是因为 Map 的内部结构有其自身的管理方式,通常会保留一些空闲容量以便于未来的增长。这个特点可以避免频繁的内存重新分配,但也意味着即使删除了键,Map 的内存占用可能不会立刻减少。
1.19 Golang 调用函数传入结构体时,应该传值还是指针?
传递结构体到函数时,传值和传指针都有各自的用例。了解具体需求,综合考虑性能和数据一致性,是选择传值还是传指针的关键。
1.20 哪些数据结构默认引用传递?
切片、Map、通道、函数等数据类型是默认以引用传递的。
1.21 Golang 中解析 tag 是怎么实现的?
type Person struct {
Name string `json:"name"` // 使用标签指定 JSON 字段名称
Age int `json:"age"`
}
在 Go 语言中,标签提供了一种在结构体字段上存储元数据的方法,可以通过反射在运行时解析这些标签。使用反射可以获取结构体的字段信息,并根据需要提取和解析标签内容。这种机制在 Go 中非常有用,特别是在需要自定义序列化/反序列化、验证或其他基于元数据的操作时。
1.22 Golang sync.Map 的用法?
无需显式初始化,可以直接声明并使用
Store Load Delete
1.23 Go的Struct能不能⽐较?
在 Go 语言中,结构体可以比较,但前提是所有字段都必须是可比较的类型。
- 对于可比较的结构体,可以使用 == 和 != 操作符直接比较。
- 对于不可比较的结构体,可能需要自定义比较逻辑或确保结构体字段都是可比较的类型。
1.24 解释Go语言什么是负载因子?
负载因子是哈希表中存储的元素数量与哈希表中可用存储位置数量之比。通常用公式来表示:
负载因子=哈希表中的元素数量/哈希表的容量
在 Go 中,哈希表的主要应用之一是 map。当创建 map 时,通常指定初始容量。随着元素的添加,哈希表会根据负载因子进行自动扩容或调整。
负载因子的作用
负载因子用于衡量哈希表的效率和性能。负载因子影响以下几个方面:
- 查找性能:
- 负载因子越低,哈希表的冲突越少,查找性能越高。
- 负载因子越高,可能导致哈希表中的冲突增加,查找性能下降。
- 空间效率:
- 负载因子较低时,哈希表可能浪费大量空间,因为许多存储位置未被使用。
- 负载因子较高时,哈希表利用率更高,但可能增加冲突和查找时间。
- 自动扩容:
- 当负载因子超过特定阈值时,哈希表可能自动扩容。扩容时,哈希表会增加容量,并将现有元素重新分配到新的存储位置。这可以减少冲突并提高查找性能。
1.25 unsafe.Pointer
- 通用指针:unsafe.Pointer 是一种特殊的非类型化指针,用于与 Go 的类型安全系统交互。它可以转换为其他类型的指针,也可以从其他指针类型转换过来。
- 内存操作:unsafe.Pointer 通常用于与底层内存操作相关的场景,允许绕过 Go 的类型系统。
- 强制转换:unsafe.Pointer 允许指针之间的强制转换,但这样的操作需要谨慎,因为它绕过了类型安全。
1.26 简述Golang空结构体 struct{} 的使用
在 Go 语言中,空结构体 struct{} 是一种非常轻量级的结构体,没有字段和方法。虽然看似没有功能,但它有几个有用的用途,通常与节省内存、信号传递、标签等相关。以下是空结构体在 Go 中的常见使用场景和用途:
内存优化
空结构体不占用额外的内存空间。在 Go 中,空结构体实例的大小通常为零字节,因为它没有字段。这使得空结构体在需要存储标志或标记的场景中非常有用。用作集合中的值
在 Go 中,映射(map)通常用于实现集合(Set)。使用空结构体作为映射的值可以有效地创建一个不重复的键集合,而不会浪费额外的内存。
package main
import "fmt"
func main() {
set := make(map[string]struct{}) // 使用空结构体作为值,创建一个集合
set["apple"] = struct{}{} // 添加元素
set["banana"] = struct{}{}
if _, exists := set["apple"]; exists {
fmt.Println("Apple is in the set") // 检查集合中是否存在元素
}
}
在这个例子中,map[string]struct{} 表示一个字符串集合,使用空结构体作为值。这样做比使用其他非空值更节省内存。
- 通道通信
空结构体常用于通道传递信号。这通常用于 Goroutine 之间的同步,不需要额外的数据信息。
package main
import "fmt"
func main() {
done := make(chan struct{}) // 使用空结构体通道
go func() {
// 做一些工作
fmt.Println("Goroutine started")
done <- struct{}{} // 发送完成信号
}()
<-done // 接收完成信号
fmt.Println("Main goroutine resumed")
}
在这个例子中,chan struct{} 用于 Goroutine 之间的同步。空结构体作为信号传递,不需要携带额外的信息。
1.3 用作占位符
空结构体也可以用于占位符或标记。在某些设计中,这可能用于标记特定的状态或属性,而无需存储额外数据。
package main
import "fmt"
// 用作状态标记
type State struct {
isCompleted struct{} // 标记完成状态
}
func main() {
s := State{}
s.isCompleted = struct{}{} // 标记为已完成
fmt.Println("State is completed:", s.isCompleted != struct{}{})
}
在这个例子中,空结构体作为标记,表明某个状态已完成。
1.27 string 类型的值可以修改吗?
在 Go 语言中,string 类型是不可变的,一旦创建,不能直接修改内容。如果需要更改字符串的内容,通常需要创建一个新的字符串。可以通过字符串拼接、子串操作、转换为字节切片等方式来创建新的字符串。理解字符串的不可变性有助于避免在 Go 中使用字符串时出现意外行为。
1.28 Switch 中如何强制执行下一个 case 代码块?
Go 语言中的fallthrough
关键字用于强制执行 switch 语句中的下一个 case。它可以在某些情况下实现类似 C 或 Java 中的 “fallthrough” 行为,但只支持无条件跳转。这意味着一旦使用 fallthrough,会立即跳转到下一个 case。
1.29 解析 JSON 数据时,默认将数值当做哪种类型?
在 Go 语言中,解析 JSON 数据时,默认情况下,数值被视为float64
类型。这是因为 JSON 格式的数值可以包含整数和浮点数,而 float64 可以兼容这些情况。
1.30 如何从 panic 中恢复?
recover 是 Go 的内建函数,用于从 panic 中恢复。recover 只能在 defer 函数中使用,它允许您处理 panic 并继续执行其他代码。
关键点
只能在 defer 中使用:recover 只有在 defer 函数中调用时才有效。如果在非 defer 的上下文中调用,它返回 nil 并不起作用。
用于从 panic 中恢复:当 panic 发生时,Go 会沿堆栈向上寻找 defer 语句。如果找到一个包含 recover 的 defer,它会调用 recover 并停止堆栈展开。
继续执行:在调用 recover 并成功捕获 panic 后,程序会继续执行 defer 后的代码,而不是继续展开堆栈。
package main
import (
"fmt"
"errors"
)
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered from panic:", r) // 恢复并打印 panic 信息
}
}()
fmt.Println("Start")
panic(errors.New("Something went wrong")) // 触发 panic
fmt.Println("This line won't execute") // 因为发生了 panic
}
在这个例子中,panic 发生时,defer 中的 recover 成功捕获 panic,然后继续执行其他代码。输出将是:
Start
Recovered from panic: Something went wrong
1.31 阐述 Printf()、Sprintf()、Fprintf()函数的区别用法是什么?
- Printf() 用于将格式化的字符串输出到标准输出,通常是控制台。
- Sprintf() 返回格式化后的字符串,而不是直接输出,适合需要保存或进一步处理的场景。
- Fprintf() 将格式化的字符串输出到指定的 Writer,如文件、网络连接、缓冲区等,适用于需要定制输出目的地的场景。
1.32 类型断言
在 Go 语言中,类型断言是一种将接口值转换为特定类型的机制。它可以用于安全地检查接口的具体类型,并在需要时将其转换为具体类型。
func main() {
var x interface{} = 42 // 接口值
s, ok := x.(string) // 断言为 string 类型
if ok {
fmt.Println("String:", s)
} else {
fmt.Println("Not a string") // 输出: Not a string
}
}
1.33 静态类型声明的好处
var x int = 42 // 显式声明类型为 int
- 类型安全:编译时检查类型,减少运行时错误。
- 性能优化:因为类型在编译时确定,编译器可以进行更多优化。
- 代码可读性:明确的类型声明提高了代码的可读性和可维护性。
1.34 可变参数
// 定义一个可变参数的函数
func sum(nums ...int) int {
total := 0
for _, num := range nums {
total += num // 累加所有参数
}
return total
}
- 可变参数的位置:在参数列表中,可变参数必须是最后一个参数,因为它会接收所有剩余的参数。
- 使用切片展开:当使用切片作为可变参数时,需要使用 … 将切片展开。
- 可变参数的类型:在定义可变参数时,需要指定其类型。例如,…int 表示可变数量的整数。
- 避免混淆:在函数接受多种类型的参数时,确保可变参数的位置和传递方式不会引起混淆。
1.35 Golang导入包时,为什么可能使用_
或 .
导入? 举例说明
使用 ‘_’ 导入时,表示只导入包以执行其初始化代码,而不使用该包中的任何标识符。这样做的主要目的是触发包的 init() 函数或其他初始化行为,而不需要使用包的具体内容。
使用 ‘.’ 导入时,表示将包中的标识符直接引入当前命名空间。这意味着可以使用包中的标识符,而不需要通过包名进行引用。这种导入方式被认为是不好的实践,因为它可能导致命名冲突或代码可读性下降。
1.36 Golang中的接口类型是否支持像Java那样的多继承?
在 Go 语言中,接口类型不支持像 Java 或 C++ 那样的多继承概念。Java 中的多继承通常指的是类可以继承多个其他类,或者接口可以继承多个其他接口。在 Go 中,虽然没有类的概念,但接口可以嵌入其他接口,这是一种类似多继承的机制,但并不完全相同。
类似C++的组合
接口嵌入示例
package main
import "fmt"
// 定义两个简单的接口
type Speaker interface {
Speak() string
}
type Walker interface {
Walk() string
}
// 定义一个嵌入其他接口的接口
type Person interface {
Speaker
Walker
}
// 定义一个结构体,实现嵌入接口
type Human struct{}
func (h Human) Speak() string {
return "I am speaking"
}
func (h Human) Walk() string {
return "I am walking"
}
func main() {
var p Person = Human{}
fmt.Println(p.Speak()) // 输出: "I am speaking"
fmt.Println(p.Walk()) // 输出: "I am walking"
}
1.37 Golang中的sync包是什么?如何使用?
sync 包提供了 Go 语言中常用的同步原语,用于确保并发环境中的线程安全和数据一致性。
常用的有:
- 互斥锁(Mutex)
- 读写锁(RWMutex)
- 等待组(WaitGroup)
- Map
1.38 Go 调度器 MPG模型
多对一 动态变化
Go 调度器由以下核心组件组成:
- M:代表操作系统线程(OS Thread),用于运行 Goroutine。
- P:代表处理器(Processor),控制 Goroutine 的执行。每个 P 绑定一个操作系统线程 M,负责调度 Goroutine 的执行。
- G:代表 Goroutine,是 Go 的并发单元。
1.39 CSP模型
管道通信 实现并发同步Communicating Sequential Process
严格来说,CSP 是一门形式语言(类似于 ℷ calculus),用于描述并发系统中的互动模式,也因此成为一众面向并发的编程语言的理论源头,并衍生出了 Occam/Limbo/Golang…
而具体到编程语言,如Golang,其实只用到了 CSP 的很小一部分,即理论中的 Process/Channel(对应到语言中的 goroutine/channel):这两个并发原语之间没有从属关系, Process 可以订阅任意个 Channel,Channel 也并不关心是哪个 Process 在利用它进行通信;Process 围绕 Channel 进行读写,形成一套有序阻塞和可预测的并发模型。
1.40 简述Go 语言中 cap 函数可以作用于那些内容?
在 Go 语言中,cap 函数可以作用于切片
、数组
和通道
,返回它们的容量。容量是数据结构在扩展前可以容纳的最大元素数量。
- 对于切片,容量可以大于其长度;
- 对于数组,容量与长度相同;
- 对于通道,容量是通道的缓冲区大小。
1.41 简述go convey 是什么?一般用来做什么?
GoConvey 是 Go 语言中的一个用于编写和执行单元测试的测试框架。与标准的 Go 测试库相比,GoConvey 提供了一种更直观和简洁的方式来编写测试,并支持行为驱动开发(BDD)的风格。它提供了用于组织和描述测试的工具,以及内置的测试服务器来展示测试结果。
1.42 闭包
在 Go 中,闭包主要体现在匿名函数中。匿名函数可以捕获其外部作用域中的变量,并在不同的上下文中保留这些变量的状态。
感觉像强化版的静态变量
作用域:局部静态变量通常是函数级的,并且在函数外部无法访问。而闭包可以在其定义的外部环境中保存状态,并可以返回给其他代码部分。
灵活性:闭包比局部静态变量更灵活,可以在多种环境中使用,并可以作为返回值传递到不同的作用域。
共享状态:闭包可以在多个实例之间共享状态,而局部静态变量通常在单个函数内使用。
示例:简单闭包
func main() {
// 定义一个变量
x := 10
// 定义一个闭包,捕获 x
increment := func() int {
x++ // 增加 x 的值
return x
}
// 每次调用闭包,x 都会增加
fmt.Println(increment()) // 输出: 11
fmt.Println(increment()) // 输出: 12
fmt.Println(increment()) // 输出: 13
}
闭包的特点
捕获环境变量:闭包可以捕获其创建时的外部作用域中的变量,并在不同的上下文中使用这些变量。
状态保持:闭包可以保持变量的状态,这在某些情况下非常有用,如计数器、累加器等。
匿名函数:闭包通常通过匿名函数实现,但不一定是匿名函数。关键是捕获变量的作用域。
闭包的应用场景
闭包在 Go 中有多种应用场景,包括但不限于:计数器:使用闭包实现计数器,每次调用闭包时增加计数。
延迟计算:闭包可以用于延迟计算,在需要时再执行。
函数作为参数:闭包可以作为参数传递给其他函数,提供更灵活的行为。
闭包的注意事项
变量作用域:闭包捕获的变量是外部作用域中的变量,因此在多 Goroutine 场景中要小心变量的共享状态。
内存管理:闭包会保留捕获的变量,如果变量过多或生命周期过长,可能会增加内存使用。
闭包的副作用:由于闭包可以改变外部变量的状态,因此在使用闭包时要注意可能的副作用。
1.43 Go字符串可以修改吗,底层原理。
Go字符串不可修改。
源码包在src/runtime/string.go:stringStruct中定义了string的数据结构:
type stringStruct struct {
str unsafe.Pointer //字符串首地址,指向底层字节数组的指针
len int //字符串长度
}
因为底层是一个[]byte类型的切片,当我们使用下标的方式去修改值,这时候将一个字符内容赋值给byte类型,肯定是不允许的。但是我们可以通过下标的方式去访问对应的byte值。
string与[]byte切片的转换都需要一次内存拷贝。
二、Go机制原理
2.1 Golang uint 类型溢出问题?
在 Go 语言中,uint 类型溢出是无符号整数的固有问题。当无符号整数的值超过其最大值时,它会绕回最小值。理解溢出问题的原因和解决方法有助于在 Go 中编写健壮且安全的代码。防止溢出需要显式检查、使用大容量类型和错误处理等方法。
func safeAdd(a, b uint8) (uint8, error) {
if a > 255-b {
return 0, errors.New("Overflow occurred")
}
return a + b, nil
}
func main() {
result, err := safeAdd(250, 10)
if err != nil {
fmt.Println("Error:", err) // 输出: "Overflow occurred"
} else {
fmt.Println("Result:", result)
}
}
2.2 Golang中什么是协程泄露(Goroutine Leak)?
Goroutine 泄露是指 Goroutine 未按预期结束而持续运行,占用系统资源。防止 Goroutine 泄露需要:
- 确保 Goroutine 正常结束
- 使用 context 包实现超时取消
- 正确处理通道操作(close(ch))等
2.3 Go 语言的局部变量分配在栈上还是堆上?
Go 语言中的局部变量分配在栈上还是堆上取决于逃逸分析。如果变量在函数返回后没有外部引用,编译器通常会将其分配到栈上。如果变量可能在函数返回后仍然被引用,编译器会将其分配到堆上。
2.4 阐述 Go 的 select 的特性?
- 多通道选择:select 可以监控多个通道,并根据通道的状态进行选择。Go 运行时会监控这些通道的状态,确保选择操作的正确性。
- 等待队列:每个通道都有一个等待队列,当 Goroutine 执行 select 时,它会被添加到等待队列中。运行时会确保当通道变得可用时,正确唤醒等待队列中的 Goroutine。
- 随机选择:如果 select 中有多个通道可用,Go 运行时会随机选择一个进行操作。这种随机选择确保 select 操作的公平性,避免通道的偏向和不平衡。
2.5 Golang字符串转成byte数组,会发生内存拷贝吗?
在 Go 语言中,将字符串转换为字节数组([]byte)会发生内存拷贝。这意味着一个新的字节数组会被创建,其中包含与原始字符串相同的数据。这种内存拷贝在转换过程中是必然的,因为字符串在 Go 中是不可变的,而字节数组是可变的。
2.6 对已经关闭的的chan进行读写,会怎么样?为什么?
读取一个已经关闭的通道:
- 当你从一个关闭的通道中读取数据时,如果通道里有剩余的数据,你会读取到这些数据。
- 如果通道已经没有数据了,读取操作会立即返回零值,并且通道的第二个返回值将是 false,表示通道已关闭。例如,读取一个 chan int 类型的通道,如果通道为空且已关闭,你会得到 0 和 false。
写入一个已经关闭的通道:
- 当你尝试写入一个已经关闭的通道时,会引发 panic。这是因为在关闭后,通道不再接受任何新的数据。
- 因此,在写入通道之前,务必确认通道没有被关闭。这可以通过同步机制来确保,或者通过捕获 panic 进行错误处理。
读取一个关闭的通道是安全的,也不会引发 panic。
2.7 Golang的内存模型中为什么⼩对象多了会造成GC压⼒?如何解决?
在 Golang 中,小对象的数量增多会导致垃圾回收(GC)压力增加。主要原因包括以下几点:
对象的创建与销毁
每个对象的创建和销毁都会产生管理成本。当大量小对象被创建和销毁时,垃圾回收器需要处理更多的对象,这意味着更频繁的垃圾回收过程。垃圾回收的工作
垃圾回收器的主要任务是清理不再需要的内存。Go 的垃圾回收器使用一种标记-清除的算法,在每次垃圾回收过程中,垃圾回收器需要:- 标记:遍历整个堆,标记所有存活的对象。
- 清除:释放未标记的内存。
当有大量小对象时,标记和清除的过程会更频繁且成本更高,因为垃圾回收器需要处理更多的对象并跟踪更多的引用。
增加的堆碎片
大量小对象的创建和销毁可能导致堆内存的碎片化。碎片化增加了内存的非连续性,导致垃圾回收器在清除内存时更难以找到可以合并的大块空闲内存。碎片化通常需要更多的 GC 迭代来保持堆的效率。并发与暂停时间
Go 的垃圾回收器设计为并发运行,但在某些阶段需要短暂暂停整个程序。例如,在标记阶段,垃圾回收器需要确保堆上的对象不再改变,导致某些暂停。当小对象很多时,标记阶段可能需要更长时间,增加了程序的暂停时间。高速对象分配
当程序频繁分配和释放小对象时,这些操作会在堆上产生较多的压力。较频繁的分配操作可能导致堆的快速增长,而这反过来会触发更多的垃圾回收周期。
缓解 GC 压力的策略
- 对象重用:尽可能重用对象,避免频繁的创建和销毁。
- 减少对象分配:避免过多的小对象分配,考虑使用缓冲池或临时缓存。
- 优化垃圾回收:根据需要调整 GC 的参数,如 GOGC,以减少垃圾回收的频率。
2.8 Golang 的 GC的触发条件?
Go 的垃圾回收器是自适应的,会根据程序的运行情况
、堆的大小和存活率
等多种因素自动调整触发条件。这种机制确保了 GC 在保持高效垃圾回收的同时,尽量减少对程序性能的影响。
2.9 Go中的锁有哪些 ?
- 互斥锁
- 读写锁
- 一次性锁(Once)
sync.Once
Once 确保某段代码只会执行一次,通常用于初始化操作或单例模式。
使用 Do() 方法,传入要执行的函数,保证该函数只会被执行一次。var once sync.Once var config map[string]string func initializeConfig() { once.Do(func() { config = make(map[string]string) // 初始化配置 }) }
- 条件变量(Cond)
sync.Cond
条件变量用于实现基于条件的同步,允许 Goroutine 在某个条件满足时继续执行。
条件变量通常与 Mutex 结合使用,用于实现复杂的同步逻辑。
具有 Wait(), Signal(), 和 Broadcast() 等方法,用于控制等待和唤醒 Goroutine。var mu sync.Mutex var cond = sync.NewCond(&mu) var ready = false func worker() { mu.Lock() defer mu.Unlock() for !ready { cond.Wait() // 等待条件满足 } // 执行工作 } func setReady() { mu.Lock() ready = true cond.Signal() // 唤醒一个等待的 Goroutine mu.Unlock() }
- 等待组(WaitGroup)
sync.WaitGroup
等待组用于等待一组 Goroutine 完成,可以用于同步 Goroutine 的结束。
使用 Add() 指定等待的 Goroutine 数量,Done() 表示一个 Goroutine 完成,Wait() 等待所有 Goroutine 完成。var wg sync.WaitGroup func worker() { defer wg.Done() // 标记 Goroutine 完成 // 执行工作 } func main() { wg.Add(2) // 期望等待两个 Goroutine go worker() go worker() wg.Wait() // 等待所有 Goroutine 完成 }
2.10 Channel是同步的还是异步的?
Go 中的通道(Channel)可以是同步的,也可以是异步的,取决于通道的缓冲机制和容量设置。
2.11 简述Goroutine和线程的区别?
- 轻量级 vs. 重量级
- Goroutine 是轻量级的
- Goroutine 是 Go 语言中的一种协程,它在启动和运行时的开销非常小。每个 Goroutine 只需几 KB 的初始内存,运行时能根据需要自动扩展和收缩。
- 线程是重量级的
- 操作系统线程需要较大的内存和资源,通常启动一个线程需要几 MB 的内存,并且会消耗更多的操作系统资源。
- Goroutine 是轻量级的
- 调度模型
- Goroutine 使用用户态调度
- Go 运行时包含一个用户态的调度器,负责在多个操作系统线程上调度和运行 Goroutine。这个调度器可以更灵活地管理 Goroutine,并且不依赖操作系统的调度机制。
- 线程使用内核态调度
- 操作系统线程由内核调度,调度机制取决于操作系统。由于内核调度器的复杂性,线程的调度成本通常更高。
- Goroutine 使用用户态调度
- 并发数量
- Goroutine 可以大量创建
- 由于 Goroutine 的轻量级特性,Go 应用可以同时运行数以千计甚至数百万个 Goroutine,适合高度并发的应用场景。
- 线程的数量受限
- 线程由于其高开销和资源限制,无法像 Goroutine 那样大量创建。通常一个应用创建数百个线程就可能遇到性能和资源问题。
- Goroutine 可以大量创建
- 数据共享和通信
- Goroutine 倾向于使用通道进行通信
- Go 倡导 “不要通过共享内存来通信,而是通过通信来共享内存”。Goroutine 之间使用通道进行通信,这种机制有助于避免数据竞争。
- 线程通常使用锁和共享内存
- 线程之间经常通过共享内存和锁进行同步,这种方式更容易引发数据竞争和死锁等问题。
- Goroutine 倾向于使用通道进行通信
- 并行性与并发性
- Goroutine 适合并发
- Goroutine 的轻量级和用户态调度使其适合并发编程,特别是在 I/O 密集型和高延迟操作中。
- 线程适合并行
- 线程的重量级特性和内核态调度使其适合 CPU 密集型任务,可以充分利用多核 CPU 的并行能力。
- Goroutine 适合并发
- 错误处理
- Goroutine 的错误隔离
- Goroutine 的错误不会影响其他 Goroutine。当一个 Goroutine 出现 panic 时,其他 Goroutine 可以继续运行。
- 线程的错误影响
- 当一个线程出错时,可能影响整个进程,尤其是在未捕获异常的情况下。
- Goroutine 的错误隔离
2.12 介绍下golang的atomic包
Golang 的 sync/atomic 包提供了一组用于实现原子操作的工具,这些操作可以确保在并发环境中安全地更新和读取简单变量类型,而不需要使用互斥锁。原子操作可以在并发环境中避免数据竞争,是构建无锁数据结构和实现线程安全操作的关键工具。
原子操作的作用
- 原子操作确保某个操作在执行过程中不会被其他操作中断。这意味着在并发环境中,原子操作能够保证操作的完整性和一致性。sync/atomic 包中的原子操作通常用于对简单数据类型的原子性更新、读取和比较。
sync/atomic 支持的原子操作
- 原子性读取和写入
- 读取操作
- LoadInt32, LoadInt64, LoadUint32, LoadUint64, LoadPointer, LoadUintptr: 以原子方式读取变量的当前值。
- 写入操作
- StoreInt32, StoreInt64, StoreUint32, StoreUint64, StorePointer, StoreUintptr: 以原子方式将值写入变量。
- 原子性加法和减法
- 加法操作
- AddInt32, AddInt64, AddUint32, AddUint64: 以原子方式将变量增加特定值。
- 减法操作
- AddInt32 等同于减负数,但没有显式减法操作。
- 原子性比较和交换(CAS)
- CompareAndSwapInt32, CompareAndSwapInt64, CompareAndSwapUint32, CompareAndSwapUint64, CompareAndSwapPointer: 以原子方式比较变量的当前值,如果等于期望值,则替换为新的值。
- 原子性交换
- SwapInt32, SwapInt64, SwapUint32, SwapUint64, SwapPointer: 以原子方式将变量的值替换为新值,并返回旧值。
使用 sync/atomic 的场景
- 无锁计数器: 使用 Add 或 CompareAndSwap 实现线程安全的计数器。
- 条件性更新: 使用 CAS 在特定条件下更新变量。
- 无锁数据结构: 构建无锁队列、堆栈等复杂数据结构。
- 原子性标志: 使用原子操作实现简单的标志变量,以避免数据竞争。
sync/atomic 的限制
- 简单数据类型: sync/atomic 仅支持简单的标量类型(如整数、指针、布尔等),不适用于复杂数据结构。
- ABA 问题: CAS 操作可能导致 ABA 问题,需要额外处理。
- 高竞争环境: 在高竞争环境中,使用原子操作可能导致自旋和性能下降。
2.13 简述Go中CAS算法?
CAS(Compare and Swap)算法是一种常用的原子操作算法,通常用于在多线程或并发环境中实现无锁操作。CAS 算法提供了一种确保原子性的方法,可以用于避免竞争条件和数据不一致的问题。Go 语言通过 sync/atomic 包提供了对 CAS 操作的支持。
原子操作用于简单的增减、读取/写入等,而 CAS 可用于更复杂的比较和更新操作。
CAS 算法的概念
CAS 是一种原子性操作,其核心思想是“比较并交换”。在 CAS 操作中,会对某个共享变量执行以下步骤:- 比较(Compare):检查共享变量的当前值是否等于期望值。
- 交换(Swap):如果当前值等于期望值,则将其更改为新的值。
- 如果当前值不等于期望值,操作失败,通常会返回当前的实际值。
CAS 允许在并发环境中进行原子性的变量更新,而不使用传统的锁机制。
Go 中的 CAS 操作
Go 语言中的 sync/atomic 包提供了多种 CAS 操作,用于对简单数据类型(如整数、布尔值、指针等)进行原子操作。主要操作包括:- CompareAndSwapInt32, CompareAndSwapInt64:对 32 位和 64 位整数进行 CAS 操作。
- CompareAndSwapUint32, CompareAndSwapUint64:对 32 位和 64 位无符号整数进行 CAS 操作。
- CompareAndSwapPointer:对指针类型进行 CAS 操作。
- CompareAndSwapUintptr:对 uintptr 类型进行 CAS 操作。
CAS 的使用场景
- 无锁计数器:通过 CAS 实现线程安全的计数器,而不使用锁。
- 锁的实现:一些锁的实现可以通过 CAS 操作来避免竞争条件。
- 原子变量更新:在多线程环境中,CAS 允许安全地更新共享变量。
CAS 的优缺点
- 优点
- 无锁:CAS 不需要使用锁,避免了锁的开销和潜在的死锁问题。
- 高效:CAS 操作通常比锁更高效,因为它是原子性的,避免了上下文切换。
- 缺点
- ABA 问题:CAS 中可能发生 ABA 问题,即变量经过多次变化后又恢复原值,但状态已改变。这个问题在某些场景下需要额外处理。
- 自旋:CAS 失败时,可能需要不断重试(自旋),在高竞争环境中可能导致性能问题。
- 优点
CAS 的示例
import (
"sync/atomic"
)
var counter int32
func increment() {
for {
current := atomic.LoadInt32(&counter) // 读取当前值
newValue := current + 1 // 计算新的值
if atomic.CompareAndSwapInt32(&counter, current, newValue) { // 尝试更新
break // 更新成功,退出循环
}
}
}
在这个示例中,increment 函数尝试增加 counter,并通过 CAS 确保在多线程环境中是原子性的。
2.14 Go函数中为什么会发⽣内存泄露?
在 Go 中,内存泄露可能由于以下原因导致:
- 无意保留引用:对象的引用在程序中意外保留,垃圾回收器无法回收它们。
- 循环引用:多个对象相互引用,导致垃圾回收器无法释放它们。
- 未关闭的通道或 Goroutine:通道未关闭或 Goroutine 无法正常退出,导致资源被占用。
- 未关闭的文件或资源:文件、数据库连接等资源未关闭,导致泄露。
- 全局变量或缓存:全局变量或缓存中保留了不再使用的对象。
2.15 Golang协程为什么⽐线程轻量?
Golang 的协程(Goroutines)比传统的线程轻量,因为 Go 语言设计了独特的用户态调度器和灵活的栈管理机制,这使得 Goroutines 能在更低的资源消耗下高效运行。以下是 Goroutines 比线程轻量的主要原因:
初始栈大小较小
- Goroutines 的初始栈大小通常只有几 KB,这与传统线程的栈大小相差甚远。传统线程的栈通常是固定大小,可能是 1 MB 或更大。较小的初始栈大小使得创建 Goroutines 的开销大大降低。
动态栈扩展
- Go 的 Goroutines 支持栈的动态扩展和收缩。这意味着栈可以根据需要增长,而不会固定占用大量内存。动态栈扩展可以减少内存的浪费,并允许更多 Goroutines 在同一时刻运行。
用户态调度
- Go 语言的调度器是用户态调度器,与操作系统的线程调度器不同。用户态调度器可以更高效地调度 Goroutines,而不需要进行系统调用或内核态的线程切换,这减少了上下文切换的成本。
较少的系统资源消耗
- Goroutines 不需要为每个实例创建操作系统级别的线程,因此它们消耗的系统资源较少。传统线程需要操作系统资源,如文件描述符、内核栈、线程上下文等,这些资源开销较高。
高效的 Goroutines 调度
- Go 运行时的调度器可以在多个操作系统线程上调度大量的 Goroutines。它采用 M:N 模型,意味着多个 Goroutines 可以共享少量的操作系统线程。这种调度方式更加灵活,减少了系统资源的消耗。
Goroutines 的创建成本较低
- 由于 Goroutines 的轻量级特性,它们的创建和销毁成本较低。传统线程的创建通常涉及操作系统的系统调用,而 Goroutines 可以在用户态完成,大大减少了创建成本。
并发模型的简化
- Go 的并发模型通过 Goroutines 和通道提供了简化的通信机制。这种机制减少了传统线程模型中的复杂同步操作,如锁定、条件变量等,从而降低了 Goroutines 的管理成本。
2.16 线程模型有哪些?为什么 Go Scheduler 需要实现 M:N 的⽅案?Go Scheduler 由哪些元 素构成呢?
线程模型描述了如何在操作系统或语言运行时中管理线程或协程。不同的线程模型在调度、资源利用和性能方面具有不同的特点。常见的线程模型包括:
- 1:1 线程模型
- 定义:每个用户线程映射到一个操作系统线程。
- 优点:操作系统调度器直接管理线程,调度可靠,集成了操作系统的多线程特性。
- 缺点:创建、上下文切换和管理成本较高,受限于操作系统线程的限制。
- N:1 线程模型
- 定义:多个用户线程映射到一个操作系统线程。
- 优点:用户态调度更轻量,创建和管理成本低,节约资源。
- 缺点:如果用户态线程阻塞,整个线程就会阻塞;无法充分利用多核。
- M:N 线程模型
- 定义:多个用户线程可以映射到多个操作系统线程,通过用户态调度器调度。
- 优点:灵活性强,可以在用户态进行高效调度,充分利用多核优势,同时减少线程创建和上下文切换的成本。
- 缺点:实现复杂,用户态调度器需要与操作系统进行交互,确保高效和一致性。
为什么 Go Scheduler 选择 M:N 模型?
Go Scheduler 采用 M:N 线程模型,主要是为了结合 1:1 和 N:1 模型的优势,解决两者的缺点:
- 高效并发:通过用户态调度器,实现轻量级的 Goroutine 并发,降低创建和管理成本。
- 多核支持:通过多个操作系统线程,确保可以充分利用多核 CPU,支持高并发。
- 灵活的调度:用户态调度器可以实现灵活的调度策略,减少线程阻塞导致的性能问题。
Go Scheduler 由以下几个关键部分构成:
- M(Machine):表示操作系统线程。一个 M 可以运行多个 Goroutine。
- P(Processor):逻辑处理器,负责管理 Goroutine 的调度和运行。每个 P 与一个 M 关联,M 可以在不同 P 之间切换。
- G(Goroutine):代表一个 Goroutine 实例,可以在 P 上运行。
Go 的 Scheduler 使用 GMP 模型管理 Goroutine 的调度。P 负责调度 Goroutine,M 负责操作系统线程,G 代表具体的 Goroutine 实例。
2.17 互斥锁正常模式和饥饿模式的区别 ?
在 Go 语言中,互斥锁(sync.Mutex)有两种不同的工作模式:正常模式和饥饿模式。两者的区别主要在于锁的公平性以及锁竞争时的调度策略。
正常模式
- 在正常模式下,sync.Mutex 按照 “先到先得” 的原则工作,但并不保证绝对的公平性。锁在释放后,会尽量满足已经在等待的 Goroutine,但不是严格按照队列顺序。以下是正常模式的主要特点:
饥饿模式
- 饥饿模式用于处理锁竞争中可能出现的饥饿问题。在饥饿模式下,锁按照严格的队列顺序分配,确保所有等待的 Goroutine 按照请求顺序获得锁。以下是饥饿模式的主要特点:
什么时候使用饥饿模式?
- 在 Go 中,sync.Mutex 默认使用正常模式,但在某些情况下可能会切换到饥饿模式。当锁竞争激烈时,可能会出现某些 Goroutine 长时间无法获得锁的情况,此时锁会自动切换到饥饿模式,确保公平性。
2.18 解释Go work stealing 机制?
Work Stealing 机制的基本原理是:
当一个 P 处理完其队列中的 Goroutines,但其他 P 仍然有未处理的 Goroutines 时,它可以从其他 P 的队列中窃取工作。这种机制的核心目的是平衡负载,确保所有的 P 都有足够的工作,以最大限度地利用多核 CPU 的性能。
2.19 解释 Go hand off 机制?
M 阻塞,会将 M 上 P 的运行队列交给其他 M 执行,交接效率要高,才能提高 Go 程序整体的并发度。
2.20 如何在Golang中实现协程池?
package main
import (
"fmt"
"sync"
"time"
)
// Worker 是一个执行任务的协程
func Worker(id int, tasks <-chan func(), wg *sync.WaitGroup) {
defer wg.Done()
for task := range tasks {
fmt.Printf("Worker %d starting task\n", id)
task() // 执行任务
fmt.Printf("Worker %d completed task\n", id)
}
}
func main() {
const numWorkers = 3 // 协程池大小
const numTasks = 10 // 总任务数量
tasks := make(chan func(), numTasks) // 用于分发任务的通道
var wg sync.WaitGroup // 等待所有任务完成的 WaitGroup
// 创建工作者
for i := 1; i <= numWorkers; i++ {
wg.Add(1)
go Worker(i, tasks, &wg)
}
// 分发任务
for i := 1; i <= numTasks; i++ {
task := func(taskID int) func() {
return func() {
fmt.Printf("Executing task %d\n", taskID)
time.Sleep(1 * time.Second) // 模拟任务处理时间
}
}(i)
tasks <- task
}
// 关闭任务通道,表示不再有新任务
close(tasks)
// 等待所有工作者完成任务
wg.Wait()
fmt.Println("All tasks completed")
}
通过这样的方式,你可以控制同时运行的协程数量,避免创建过多的协程引起的性能和资源问题。
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 1430797759@qq.com