目录
- 进程、线程、协程
- Go 的垃圾回收机制
- GC 的触发条件
- GC 的调优
- GMP 调度和 CSP 模型
- Goroutine 的调度原理
- Goroutine 的切换时机
- Context 结构原理
- Context 工作原理
- Context 使用场景
- Golang 的内存分配机制
- 竞态问题
- 内存逃逸
- golang 内存对齐机制
- golang 中 new 和 make 的区别?
- golang 的 slice 的实现原理
- golang 中 array 和 slice 的区别
- golang 的 map 实现原理
- golang 的 map 为什么是无序的?
- golang 的 map 的查找原理
- 为什么 golang 的 map 的负载因子是 6.5?
- golang 的 map 如何扩容
- golang 的 sync.Map
- 对比 golang 的 sync.Map 和 原始 map + 锁 实现并发
- golang 对 nil 的 slice 和 空 slice 的处理是一致的吗?
- golang 的内存模型是怎样的?
- golang 的内存模型中为什么小对象多了会造成 GC 压力?
- golang 中 Channel 的实现原理
- Channel 是同步的还是异步的?
- Channel 死锁场景
- golang 的原子操作有哪些?
- 理解什么是乐观锁、悲观锁
- 原子操作和锁的区别
- goroutine 的实现原理
- goroutine 的泄露
- 怎么查看 goroutine 的数量?怎么限制 goroutine 的数量?
- goroutine 和线程的区别?
- golang 的 struct 能不能比较?
- golang 的 slice 如何扩容
- 在 Go 函数为什么会发生内存泄露?发生了泄露如何检测?
- golang 中两个 nil 可能不相等吗?
- Go 语言中的内存对齐
- 两个 Interface 可以比较吗?
- golang 打印时 %v、%+v、%#v 的区别?
- 什么是 rune 类型?
- 空 struct{} 占用空间吗?用途是什么?
- golang 值接收者和指针接收者的区别
- defer 关键字的实现原理
- select 的底层原理
- gRPC
- 反射
- golang 的字符串拼接方式对比
- 常见字符集
- string 和 []byte 的区别
- HTTP 和 RPC 对比
- gRPC 和 RPC 对比
- sync.Pool 的使用
- JWT (JSON Web Token)
进程、线程、协程
- 进程(Process):
- 进程是操作系统分配资源的最小单位,拥有独立的内存空间和系统资源。
- 进程之间通常相互独立,不能直接访问彼此的内存。
- 进程启动、终止和切换开销较大。
- 举例:在操作系统中,每个正在运行的应用程序通常都是一个独立的进程。比如浏览器、音乐播放器、文本编辑器都可以作为独立的进程运行。
- 线程(Thread):
- 线程是进程内的执行单元,共享进程的内存空间和系统资源。
- 线程之间可以相对容易的进行通信,因为它们共享相同的内存。
- 线程启动、终止和切换开销较小。
- 举例:在一个多线程的程序中,可以有一个线程用于图形界面处理,另一个用户文件下载,他们可以共享数据而不需要复杂的通信机制。
- 协程(Coroutine):
- 协程是一种轻量级的线程,它在代码级别进程协作式多任务处理。
- 协程通常是用户级别的,而不是操作系统级别的,他们由开发者控制,更适合处理 I/O 密集型任务和高并发。
- 协程之间可以随时暂停和恢复,而不需要线程的上下文切换。
- 举例:比如一个网络爬虫,可以使用协程来处理多个页面的下载,以便在等待网络响应时切换到其他任务,而不会浪费时间等待。
- 区别:
- 进程是操作系统级别的,拥有独立的内存空间,启动和切换开销大。线程是进程内的执行单元,共享进程的内存,启动和切换开销小。协程是更轻量级的线程,由开发者控制,适合处理高并发和 I/O 密集型任务。
- 进程之间通常不能直接共享数据,线程之间可以,而协程可以随时暂停和恢复,不需要显式的数据共享和同步。
- 进程通常用于多核 CPU 上的并行计算,线程用于多任务处理,协程用于异步编程和高并发应用。
Go 的垃圾回收机制
GC 的触发条件
GC 的调优
GMP 调度和 CSP 模型
Goroutine 的调度原理
goroutine 调度的本质:就是将 Goroutine 按照一定算法放到 CPU 上去执行。
在 Go 中,协程是轻量级的用户级线程,它们不直接运行在操作系统的线程上。Go 使用了一种特殊的调度器(Schedule),负责管理和分配协程到操作系统线程,以便并发执行。
- 协程是 Go 语言的并发执行单位。是用户级别的线程,非常轻量。
- Go 调度器(Schedule)负责管理协程的执行。调度器会将多个协程分配到少数的操作系统线程(通常是 GOMAXPROCS 的数量),这些操作系统线程是实际运行在 CPU 上的执行单元。
- 操作系统线程(OS Thread)是底层的操作系统级线程,负责在 CPU 上运行协程。Go 调度器会将协程调度到操作系统线程上,然后将操作系统线程运行在 CPU 上,这样协程就能够执行。
补充:Go 的调度器具有自动的伸缩性,可以根据需要创建和销毁操作系统线程,以适应应用程序的并发需求。这使得 Go 能够高效的处理大量的协程,而不会浪费过多的系统资源。
总结:Go 的协程和调度器使得并发编程更容器,同时也更高效,因为它们隐藏了许多操作系统级线程管理的复杂性,使开发者能够专注于应用程序逻辑。
Goroutine 的切换时机
- select 操作阻塞时
- io 阻塞
- 阻塞在 Channel
- 程序员显示编码操作
- 等待锁
- 程序调用
Context 结构原理
Context(上下文)是Golang应用开发常用的并发控制技术 ,它可以控制一组呈树状结构的goroutine,每个goroutine拥有相同的上下文。Context 是并发安全的,主要是用于控制多个协程之间的协作、取消操作。
type Context interface {Deadline() (deadline time.Time, ok bool)Done() <-chan struct{}Err() errorValue(key interface{}) interface{}
}
- 「Deadline」 方法:可以获取设置的截止时间,返回值 deadline 是截止时间,到了这个时间,Context 会自动发起取消请求,返回值 ok 表示是否设置了截止时间。
- 「Done」 方法:返回一个只读的 channel ,类型为 struct{}。如果这个 chan 可以读取,说明已经发出了取消信号,可以做清理操作,然后退出协程,释放资源。
- 「Err」 方法:返回Context 被取消的原因。
- 「Value」 方法:获取 Context 上绑定的值,是一个键值对,通过 key 来获取对应的值。
几个实现context接口的对象:
- context.Background()和context.TODO()相似,返回的context一般作为根对象存在,其不可以退出,也不能携带值。要具体地使用context的功能,需要派生出新的context。
- context.WithCancel()函数返回一个子context并且有cancel退出方法。子context在两种情况下会退出,一种情况是调用cancel,另一种情况是当参数中的父context退出时,该context及其关联的context都退出。
- context.WithTimeout函数指定超时时间,当超时发生后,子context将退出。因此子context的退出有3种时机,一种是父context退出;一种是超时退出;一种是主动调用cancel函数退出。
- context.WithDeadline()与context.WithTimeout()返回的函数类似,不过其参数指定的是最后到期的时间。
- context.WithValue()函数返回待key-value的子context
Context 工作原理
context 机制允许在协程之间传递控制信号和请求范围值,以及安全地取消操作。这对于管理并发操作和资源的生命周期非常有用。context 的工作原理是基于 Go 语言的并发特性和协程的能力,以及调度器的支持来实现的。
- 创建 Context: 可以使用 context.WithCancel、context.WithTimeout、context.WithDeadline 等函数来创建一个新的 context.Context 对象。这个新的 Context 包含了一个取消函数、截止日期、以及一些其他信息,这些信息用于控制协程的行为。
- 传递 Context: 可以将这个 Context 对象传递给你的函数或协程,以使它们能够在需要的时候访问到这个 Context。通常,你会在函数的参数中接受一个 Context 对象。
- 取消 Context: 如果在某些情况下需要取消某个操作,可以调用 Context 中的取消函数。这将发送一个取消信号,通知所有使用该 Context 的协程停止它们的工作。这是通过一个内部的 chan 实现的。
- 监听 Context: 协程通常会在一个循环中监听 Context 的取消信号,一旦接收到取消信号,它们会停止正在执行的工作并退出。
- 链式 Context: 可以使用 context.WithValue 函数来添加额外的键-值对到 Context 中,以便在协程之间传递一些请求范围的值。
- 截止日期和超时: 如果使用 context.WithTimeout 或 context.WithDeadline 创建 Context,它将在设定的截止日期或超时到期时自动取消。这对于执行超时操作非常有用。
Context 使用场景
- RPC 调用
- Pipeline
- 超时请求
- HTTP 服务器的 Request 互相传递数据
- 取消操作:可以使用 Context 来取消长时间运行的操作,例如在 HTTP 请求处理中,如果客户端中断连接或者超时,你可以取消相关的操作。
- 截止日期和超时:Context 可以设置截止日期或超时时间,以限制操作的执行时间。这对于执行具有时间限制的任务很有用,例如等待某个操作在一定时间内完成。
- 并发控制:Context 允许控制并发,可以在多个协程之间共享一个 Context,以便在某些条件下协调它们的行为。例如,可以使用 Context 来限制同时进行的数据库连接数或HTTP请求数。
- 请求范围值传递:可以在 Context 中存储请求范围的值,例如用户身份验证信息、语言首选项等。这些值可以在整个请求处理过程中传递给不同的函数和服务。
- 传播跟踪信息:在微服务架构中,可以使用 Context 传递跟踪信息、日志标识和其他上下文信息,以便跟踪请求在不同服务之间的传递。
- 防止泄漏:Context 具有继承性,子协程可以派生自父协程的 Context。这对于防止资源泄漏很有用,因为当父协程取消时,所有子协程也会被取消。
- 测试和模拟:在编写单元测试或模拟时,可以使用 Context 来模拟取消、超时或其他条件,以测试代码对这些情况的处理是否正确。
- HTTP 请求和处理:在处理 HTTP 请求时,通常会将 Context 与每个请求相关联,以处理请求取消、超时和传递请求范围信息。
Golang 的内存分配机制
- 堆和栈: 在 Go 中,内存分为两个主要部分:堆(heap)和栈(stack)
* 堆:堆是用于存储动态分配的内存的地方。在堆中分配的内存通常需要手动释放,否则会导致内存泄漏。在 Go 中,new 或 make 函数用于分配堆上的内存。
* 栈:栈用于存储函数的局部变量和调用信息。栈上的内存由编译器自动管理,不需要手动释放。 - 自动内存管理: Go 语言的一个特点是自动内存管理,也就是说,你不需要手动释放大多数内存。当你不再使用一个变量或数据结构时,Go 的垃圾收集器会自动回收它们,防止内存泄漏。
竞态问题
竞态条件是多个协程访问共享数据时可能导致的问题。
使用 go build、go run、go test 命令时,添加 -race
标识可以检查代码中是否存在资源竞争。
竞态(Race Condition):竞态是一种并发编程中的问题,它发生在多个协程(goroutine)试图同时访问和修改共享数据时。这可能导致不可预测的行为和数据不一致。竞态条件的存在通常是因为缺乏适当的同步机制。
例子: 假设有两个协程同时尝试增加一个变量的值:在这个例子中,两个协程同时修改 count 变量,由于没有适当的同步,最终的结果可能是不确定的,可能不是预期的 2000。这就是竞态条件。
package mainimport ("fmt""sync"
)var count int
var wg sync.WaitGroupfunc increment() {for i := 0; i < 1000; i++ {count++}wg.Done()
}func main() {wg.Add(2)go increment()go increment()wg.Wait()fmt.Println("Count:", count)
}
解决竞态问题:
-
使用互斥锁(Mutex): 互斥锁是最常用的解决竞态问题的方法。它允许只有一个协程同时访问临界区(共享数据),从而避免竞态条件。在 Go 中,你可以使用 sync.Mutex 来创建互斥锁。
var mutex sync.Mutexfunc increment() {mutex.Lock()count++mutex.Unlock() }
-
使用通道(Channel): 通道可以用来安全地在协程之间传递数据,避免竞态问题。通过发送和接收数据,你可以确保只有一个协程能够修改共享数据。
var ch = make(chan int)func increment() {ch <- 1count++<-ch }
-
使用原子操作: Go 提供了原子操作的支持,例如 sync/atomic 包中的函数,这些函数可以在不需要互斥锁的情况下执行原子操作。
import "sync/atomic"var count int32func increment() {atomic.AddInt32(&count, 1) }
内存逃逸
内存逃逸是编译器将变量分配到堆上而不是栈上的情况。
编译时可以借助选项 -gcflags=-m
,查看变量逃逸的情况
内存逃逸(Memory Escape):内存逃逸发生在编译器无法确定一个变量的生命周期时,它可能会分配到堆上而不是栈上。这可能会导致性能问题,因为堆上的内存分配和释放开销较大。
例子: 在下面的示例中,x 变量的生命周期超出了函数的范围,因此编译器将它分配到了堆上:这里的 x 变量的内存分配发生在堆上,因为它的生命周期无法在编译时确定。这可能会导致不必要的堆分配和额外的垃圾收集负担,对性能造成影响。
package mainfunc create() *int {x := 10return &x // 返回局部变量的指针
}func main() {y := create()// 在这里,编译器无法确定 x 变量何时结束生命周期// 所以 y 指向的数据分配在堆上
}
解决内存逃逸问题:
- 避免使用指针: 如果可能的话,避免使用指针。在 Go 中,局部变量通常会分配在栈上,而不是堆上,因此尽量不要返回局部变量的指针,以避免内存逃逸。
- 使用值语义: 使用值语义而不是引用语义。在 Go 中,值类型通常分配在栈上,而引用类型(如切片、映射)可能会分配在堆上。尽量使用值语义。
- 避免不必要的指针: 如果一个对象不需要在函数外部被访问,不要将其封装在指针中。在 Go 中,这将减少内存逃逸的机会。
- 使用合适的数据结构: 使用合适的数据结构和算法来避免内存逃逸。选择数据结构和算法以最小化堆分配。
golang 内存对齐机制
为了能让CPU可以更快的存取到各个字段,Go编译器会帮你把struct结构体做数据的对齐。所谓的数据对齐,是指内存地址是所存储数据大小(按字节为单位)的整数倍,以便CPU可以一次将该数据从内存中读取出来。编译器通过在结构体的各个字段之间填充一些空白已达到对齐的目的。
不同硬件平台占用的大小和对齐值都可能是不一样的,每个特定平台上的编译器都有自己的默认“对齐系数”,32位系统对齐系数是4,64位系统对齐系数是8
不同类型的对齐系数也可能不一样,使用Go 语言中的unsafe.Alignof函数可以返回相应类型的对齐系数,对齐系数都符合2^n这个规律,最大也不会超过8
对齐原则:
- 结构体成员的偏移量必须是成员大小和对齐系数两者最小值的整数倍。
- 整个结构体的地址必须是最大字段大小和对齐系数两者最小值的整数倍。
- struct{}(空结构体)不会增加对齐要求,它在结构体中不引起字段对齐的变化。放在结构体最后一个字段则要根据最大字节和编译器默认对齐系数两者最小值来进行字段对齐。
type T2 struct{i8 int8i64 int64i32 int32
}type T3 struct{i8 int8i32 int32i64 int64
}
type C struct {a struct{}b int64c int64
}type D struct {a int64b struct{}c int64
}type E struct {a int64b int64c struct{}
}type F struct {a int32b int32c struct{}
}func main() {// 使用 Go 语言的 unsafe.Sizeof 函数来获取特定类型的大小fmt.Println(unsafe.Sizeof(C{})) // 16fmt.Println(unsafe.Sizeof(D{})) // 16fmt.Println(unsafe.Sizeof(E{})) // 24fmt.Println(unsafe.Sizeof(F{})) // 12
}
golang 中 new 和 make 的区别?
在 Go 语言中,new 和 make 是两个用于分配内存的内建函数,它们用于创建不同类型的对象。它们的主要区别在于用途和返回值类型。
- new :使用 new 创建指向零值的指针,用于值类型。
- 用于创建值类型(如整数、浮点数、结构体等)的指针。它分配了零值并返回指向新分配内存的指针。
- new 的返回值是指向零值的指针,它不会初始化内存,所以你得到的是一个零值对象。
示例:var i *int i = new(int) // 创建一个 int 类型的指针,i 指向的值为 0
- make :使用 make 创建初始化后的引用类型对象,比如切片、映射、通道。
- 用于创建引用类型(如切片、映射、通道等)的实例。它分配并初始化内存,返回的是初始化后的实例。
- make 的返回值是已初始化的引用类型对象。
示例:slice := make([]int, 5) // 创建一个包含 5 个整数的切片,每个元素为 0
golang 的 slice 的实现原理
Go 中的切片(slice)是一种灵活的数据结构,其实现原理基于数组。切片本质上是一个包装了数组的结构,它引用数组的一部分,可以动态调整大小。下面是切片的主要实现原理:
- 底层数组: 切片包含一个指向底层数组的指针,以及切片的长度和容量。底层数数组是切片的数据存储源,切片的操作实际上是对底层数组的操作。
- 长度和容量: 切片的长度是它包含的元素数量,而容量是底层数组中可以包含的元素数量。在创建切片时,它的容量通常与长度相同,但随着切片的操作,容量可能会变化。
- 动态调整: 切片可以动态调整大小。当你追加元素到切片时,如果容量不足,Go 会为切片创建一个新的底层数组,将旧数据复制到新数组中,然后添加新的元素。这使切片能够自动增长。
- 引用子数组: 切片可以引用底层数数组的任意部分,通过设置切片的起始索引和长度。这允许你创建切片视图,而不复制数据。
- 零值切片: 一个未初始化的切片的值是 nil,它不引用任何底层数数组。
总的来说,切片是一种非常方便的数据结构,它允许你有效地处理变长的数据,同时避免了手动管理内存。在需要动态调整大小的情况下,切片是一种非常强大的工具,因为它自动处理了底层数数组的复制和管理。
type slice struct{array unsafe.Pointerlen intcap int
}
- slice:占24个字节
- array:指向底层数组的指针,占用8个字节
- len:切片的长度,占用8个字节
- cap:切片的容量,cap总是大于等于len,占用8个字节
- 初始化slice调用的是 runtime.makeslice,makeslice 函数的工作主要就是计算slice所需内存大小,然后调用mallocgc进行内存的分配。所需内存的大小=切片中元素大小*切片的容量
golang 中 array 和 slice 的区别
- 长度不同
- 数组初始化必须指定长度,并且长度是固定的。
- 切片的长度是不固定的,可以追加元素,在追加时可能使切片的容量增大。
- 函数传参不同
- 数组是值类型,将一个数组赋值给另一个数组时,传递的是一份深拷贝,函数传参操作会复制整个数组数据,会占用额外的内存,函数内对数组值的修改,不会修改原数组内容。
- 切片是引用类型,将一个切片赋值给另一个切片时,传递的是一份浅拷贝,函数传参操作不会拷贝整个切片,只会复制 len 和 cap,底层共用同一个数组,不会占用额外的内存,函数内对数组元素值的修改,会修改原数组内容。
- 计算数组长度方式不同
- 数组需要遍历计算数组长度,时间复杂度是 O ( n ) O(n) O(n)。
- 切片底层包含 len 字段,可以通过 len() 计算切片长度,时间复杂度是 O ( 1 ) O(1) O(1)。
golang 的 map 实现原理
golang 中的 map 是一个指针,占用 8 个字节,指向 hmap 结构体,map 底层是基于哈希表 + 链表地址法 存储的。
map 的特点:
- 键不能重复
- 键必须可哈希(可哈希的有:int、bool、float、string、array)
- 无序
实现细节:
- 底层哈希表:Go 中的 map 使用哈希表来存储键值对。哈希表是一个数组,每个元素称为桶(bucket)。每个桶可以存储多个键值对。
- 哈希函数:map 使用哈希函数将键映射到哈希表的索引。哈希函数的目标是将键均匀地分散在哈希表中,以便在查找时能够快速定位对应的桶。
- 冲突处理:由于哈希函数的限制,可能会发生冲突,就是多个键映射到同一个桶。为了处理冲突,每个桶实际上是一个链表或者二叉搜索树,用于存储具有相同哈希值的键值对。
- 自动扩容:Go 的 map 具有自动扩容功能。当 map 中的键值对数量接近哈希表容量的上限时,map 会自动扩大哈希表的大小,以保持性能。
- 哈希表大小:哈希表的大小通常会保持为2的幂(2^n),以便进行位运算来计算桶的索引。这提供了哈希表的性能。
- 无序性:map 不保证键值对的顺序。遍历 map 中的元素可能不会按照插入的顺序,而是按键的哈希值顺序。
- 值的类型:map 中值可以是任何数据类型,包括内置类型、自定义类型、切片、结构体等。键必须是可以进行相等比较的类型,比如整数、字符串、指针等。
- 零值:未初始化的 map 的零值是 nil,表示一个空的 map。
总结:Go 中的 map 使用哈希表来实现键值存储,它具有自动扩容功能,能够快速查找和插入键值对。但是 map 不保证元素的顺序。在多线程环境下,map 不是线程安全的,需要进行适当的同步操作来避免竞态条件。如果需要线程安全的映射,可以使用 sycn.Map
类型。
golang 的 map 为什么是无序的?
- 哈希表存储方式: map内部使用哈希表来存储键-值对。哈希表通常是一个由桶(buckets)组成的数组,而每个桶可能包含多个键-值对。哈希表的存储方式决定了它的元素存储位置不是按照插入的顺序来确定的。
- 哈希冲突: 在哈希表中,多个键可能会映射到相同的桶,这被称为哈希冲突。当发生哈希冲突时,map会使用链表或其他数据结构来存储这些具有相同哈希值的键-值对。由于哈希冲突的存在,元素在哈希表中的顺序不再是可预测的。
- 性能优化: 为了保持高性能,map的内部实现会对桶的数量进行动态扩展和收缩。这意味着桶的顺序可能在内部重排,从而影响了元素的顺序。
- 无序的规范: Go语言规范明确规定了map是无序的,这意味着程序员不应该依赖于map中元素的顺序。这也给编译器和实现者提供了更大的灵活性来优化map的性能,因为不需要保持元素的有序性。
尽管map是无序的,但如果需要按照某种特定顺序访问map中的元素,可以通过将键存储在切片中,并对切片进行排序来实现。
golang 的 map 的查找原理
- 哈希函数:当尝试查找 map 中的值时,Go 使用哈希函数将你提供的键映射到 map 内部的哈希表。哈希函数将键转换为一个整数,这个整数称为哈希码(hash code)。
- 桶的选择: 哈希表内部由一系列桶(buckets)组成,每个桶可以容纳多个键-值对。哈希表会根据哈希码来选择一个特定的桶。
- 查找操作: 一旦哈希表确定了要查找的桶,它会在这个桶中查找具有相同哈希码的键-值对。这通常涉及遍历桶中的元素,以查找匹配的键。
- 键的比较: 在桶内,哈希表将查找的键与桶中存储的键进行比较。这是通过键的相等性判断来实现的。如果找到匹配的键,哈希表返回与该键关联的值。
- 返回结果:如果找到了匹配的键,map 的查找操作会返回与该键关联的值。如果没有找到匹配的键,查找操作会返回值类型的零值。
为什么 golang 的 map 的负载因子是 6.5?
什么是负载因子?
- 负载因子(load factor),用于衡量当前哈希表中空间占用率的核心指标,也就是每个bucket桶存储的平均
元素个数。 - 负载因子=哈希表存储的元素个数/桶个数
负载因子的选择是一种权衡,它涉及到性能和内存使用之间的取舍。以下是一些背后的原因:
- 减少碰撞: 较小的负载因子可以降低哈希碰撞的发生率。哈希碰撞是指两个不同的键映射到相同的桶的情况。通过保持负载因子相对较小,可以降低碰撞的发生,提高查找操作的效率。
- 减少内存使用: 较大的负载因子可以减少内存的使用,因为哈希表中的桶数相对较少。这对于内存受限的环境或需要大量 map 的应用程序来说很重要。
- 性能平衡: 负载因子的选择旨在在性能和内存之间取得平衡。较小的负载因子可能提供更好的性能,但可能需要更多的内存,而较大的负载因子可以减少内存使用,但可能会导致较多的哈希碰撞,从而降低性能。
根据这份测试结果和讨论,Go官方取了一个相对适中的值,把Go中的 map的负载因子硬编码为6.5,这就是6.5的选择缘由。
这意味着在Go语言中,当map存储的元素个数大于或等于6.5*桶个数时,就会触发扩容行为。
golang 的 map 如何扩容
扩容时机:在向 map 插入新 Key 的时候,会进行条件检测,符合以下 2 个条件,就会触发扩容。
扩容条件:
- 超过负载 map 元素个数 > 6.5 * 桶的个数
- 溢出桶太多
- 当桶总数 < 2^15 时,如果溢出桶总数 >= 桶总数,则认为溢出桶过多
- 当桶总数 >= 2^15 时,直接与 2^15 比较,当溢出桶总数 >= 2^15 时,则认为溢出桶太多了
对于条件2,其实算是对条件1的补充。因为在负载因子比较小的情况下,有可能 map 的查找和插入效率也很低,而第1点识别不出来这种情况。
表面现在就是负载因子比较小,即 map 中元素总数少,但是桶数量多(真实分配的桶数量多,包括大量的溢出桶)。比如不断的增删,这样会造成 overflow 的 bucket 数量增多,但是负载因子又不高,达不到第1点的临界值,就不能触发扩容来缓解这种情况。这样会造成桶的使用率不高,值存储得比较稀疏,查找插入效率会变得非常低,因此有了第2个扩容条件。
扩容机制:
- 双倍扩容:针对条件1,新建一个buckets数组,新的buckets大小是原来的2倍,然后旧的buckets数据
搬迁到新的buckets - 等量扩容:针对条件2,并不扩大容量,buckets数量维持不变,重新做一遍类似双倍扩容的搬迁操作,
把松散的键值对重新排列一次,使得同一个bucket中的key排列地更紧密,节省空间,提高buckets利用
率,进而保证更快的存取。该方法我们称之为等量扩容。
golang 的 sync.Map
sync.Map 是 Go 语言标准库中提供的一种线程安全的键值存储数据结构,它用于在并发环境中安全地存储和检索键值对。sync.Map 是在 Go 1.9 版本中引入的。
sync.Map 的主要特点和原理如下:
- 线程安全: sync.Map 是线程安全的,可以在多个 Goroutine 中同时读取和修改其中的键值对,而不需要额外的锁操作。
- 无需锁: sync.Map 的实现采用了一种精巧的机制,它不会在每次访问时使用锁,而是使用了一种细粒度的锁策略,将数据分散到多个小桶中,每个桶使用独立的锁。这降低了竞争和锁争用,提高了性能。
- 原子操作: sync.Map 使用原子操作来保证并发安全性,这意味着在并发环境中,多个 Goroutine 可以安全地访问和修改 sync.Map 中的数据,而不需要显式加锁。
- 无须初始化: 与普通的 map 不同,无需初始化 sync.Map。可以直接创建一个新的 sync.Map 并开始使用它。
- Load 和 Store 操作: sync.Map 提供了 Load 和 Store 方法用于加载和存储键值对,以及其他用于访问和修改操作的方法。这些操作是线程安全的。
- 无需复制: 在普通的 map 中,如果需要在多个 Goroutine 之间共享一个 map,通常需要使用锁来保护它,或者为每个 Goroutine 创建一个独立的副本。而 sync.Map 不需要这些复杂的同步操作。
注意:sync.Map 并不适用于所有场景,它的性能通常不如单纯的 map,特别是在只有单一 Goroutine 访问的情况下。因此,只有在需要在多个 Goroutine 之间共享数据时,或者需要高度并发性能的情况下,才应该考虑使用 sync.Map。
golang 的 sync.Map 支持并发读写,采取“空间换时间”的机制,冗余了两个数据结构,分别是:read 和 dirty
type Map struct {mu Mutex //互斥锁read atomic.Value //无锁化读,包含两个字段:m map[interface}l*entry数据和amended bool标识只读map是否缺失数据dirty map[interface}*entry //存在锁的读写misses int //无锁化读的缺失次数
}type entry struct{p unsafe.Pointer
}
kv 中的 value,统一采用 unsafe.Pointer 的形式存储,通过 entry.p 指针进行链接。
entry.p 指向分为三种情况:
1. 存活态:正常指向元素。即 key-entry 对 仍未删除。
2. 软删除态:指向 nil。read map 和 dirty map 底层的 map 结构仍存在 key-entry 对,但是逻辑少那个该 key-entry 对已经被删除,因此无法被用户查询到。
3. 硬删除态:指向固定的全局变量 expunged。dirty map 中已不存在该 key-entry 对。
无锁化读的 m 是 dirty 的子集,amended 标识为 true 代表缺失数据,此时再去读 dirty,并把 misses + 1,当 misses 到一定阈值之后,同步 dirty 到 read 的 m 中。
对比 golang 的 sync.Map 和 原始 map + 锁 实现并发
和原始map+RWLock的实现并发的方式相比,减少了加锁对性能的影响。它做了一些优化:
- 可以无锁访问 read map,而且会优先操作 read map,倘若只操作 read map 就可以满足要求,那就不用去操作 write map(dirty),所以在某些特定场景中它发生锁竞争的频率会远远小于 map+RWLock 的实现方式。
- 优点:适合读多写少的场景
- 缺点:写多的场景,会导致 read map 缓存失效,需要加锁,冲突变多,性能急剧下降。
golang 对 nil 的 slice 和 空 slice 的处理是一致的吗?
slice1 := make([]int, 0)
slice2 := []int{}
var slice3 []int
slice4 := new([]int)if slice1 == nil {fmt.Println("slice1 is nil")
}
if slice2 == nil {fmt.Println("slice2 is nil")
}
if slice3 == nil {fmt.Println("slice3 is nil")
}
if *slice4 == nil {fmt.Println("*slice4 is nil")
}
输出
slice3 is nil
*slice4 is nil
解释:
- slice1 是一个通过 make 函数创建的空切片,它有底层的数组分配了空间,但长度为0。因此,slice1 不是 nil,它是一个非 nil 切片。
- slice2 是一个使用切片字面值创建的空切片,它是一个空切片,但不是 nil 切片。与 slice1 一样,它不是 nil。
- slice3 是一个声明但没有分配底层数组的切片。这是一个 nil 切片,因为它没有底层数组分配。因此,条件 if slice3 == nil 成立。
- slice4 是一个指向 nil 切片的指针。虽然 slice4 的值是 nil,但它实际上是一个指向 nil 切片的指针,而不是 nil 切片本身。因此,条件 if *slice4 == nil 成立,因为 *slice4 表示的是一个 nil 切片。
总结:
- nil 切片表示切片本身为 nil,即底层数组没有分配。
- 空切片表示切片不为 nil,但长度为0,它具有底层的数组分配。
golang 的内存模型是怎样的?
Go 语言的内存模型是一种并发编程模型,它提供了一些规则和保证,以便多个 Goroutine 可以安全地访问和修改共享的内存。Go 的内存模型具有以下主要特点:
- 顺序一致性:Go 语言的内存模型提供了顺序一致性的保证。这意味着在一个 Goroutine 中的操作不会被重排序,而在其他 Goroutine 中看到这些操作的顺序也是一致的。这有助于开发者理解程序的行为,因为操作的顺序在不同 Goroutine 中是可预测的。
- 同步原语: Go 提供了一些同步原语,如互斥锁(sync.Mutex)、读写锁(sync.RWMutex)、通道(chan)等,用于在不同 Goroutine 之间同步数据访问。这些同步原语允许开发者明确地定义临界区,以避免竞态条件和数据竞争。
- 原子操作:Go 提供了原子操作函数,如 atomic.AddInt32、atomic.LoadInt64 等,用于执行在多个 Goroutine 之间的原子操作。这些原子操作允许在不使用锁的情况下安全地更新共享的变量。
- 通道通信:Go 的通道是一种用于 Goroutine 之间通信的内置机制。通道提供了同步的方式,确保发送和接收操作按照一定的顺序进行。通道通信是 Go 并发编程的重要工具,有助于协调不同 Goroutine 之间的操作。
- 内存栅栏:Go 语言的编译器和运行时系统会自动插入内存栅栏(memory barrier),以确保内存操作按照预期的顺序进行。这有助于避免编译器和处理器的优化导致的意外行为。
golang 的内存模型中为什么小对象多了会造成 GC 压力?
在 Go 语言的内存管理中,小对象会增加垃圾收集(GC)的压力,主要是因为以下几个原因:
- 内存分配和回收成本: 分配和回收小对象的成本较高。垃圾收集器需要频繁扫描和回收小对象,这导致了更多的 GC 暂停时间。对于小对象,内存分配和回收的额外开销相对较大。
- 碎片化: 频繁的小对象分配和回收可能导致堆内存的碎片化。当堆内存中有大量小的碎片时,分配大对象可能会变得更加复杂,需要进行更多的内存拷贝和合并操作。
- GC 周期: 垃圾收集器会根据堆内存的使用情况来触发 GC 周期。如果堆内存中包含大量的小对象,垃圾收集器可能需要更频繁地运行,因为小对象的分配和回收增加了垃圾收集的频率。
为了减轻小对象对 GC 的压力,可以考虑以下几种策略:
- 对象池(Object Pool): 通过维护一个对象池,可以重用已分配的对象,而不是频繁地创建和销毁对象。
- 使用合适的数据结构,避免不必要的小对象: 考虑是否可以使用更大的数据结构或合并多个小对象为一个大对象,从而减少小对象的数量。
- 减少不必要的复制: 避免不必要的数据复制操作,特别是在大规模数据处理中。
- 生命周期管理: 仔细管理对象的生命周期,确保对象不再需要时能够及时释放。
- 使用指针: 在需要传递大对象时,可以使用指针而不是值传递,以减少对象的复制。
- 性能分析和优化: 使用性能分析工具来识别应用程序中频繁分配和回收的小对象,然后针对性地进行优化。
- 调整 GC 参数: 根据应用程序的实际情况,可以调整 GC 的参数,例如 GC 的阈值和频率,以平衡性能和内存开销。
golang 中 Channel 的实现原理
我认为Channel本质上就是一个线程安全的队列,用于协程间通讯的。
内部通过锁确保队列操作的原子性。
Channel 是同步的还是异步的?
Channel 是异步进行的, channel 存在3种状态:
- nil,未初始化的状态,只进行了声明,或者手动赋值为nil
- active,正常的channel,可读或者可写
- closed,已关闭,千万不要误认为关闭channel后,channel的值是nil
Channel 死锁场景
- 非缓存 Channel 只写不读
func deadlock1() {ch := make(chan int)ch <- 3 //这里会发生一直阻塞的情况,执行不到下一句 }
- 非缓存 Channel 读在写后面
func deadlock2() {ch := make(chan int)ch <- 3 //这里会发生一直阻塞的情况,执行不到下一句num := <- chfmt.Println("num=", num) }
- 缓存 Channel 写入超过缓冲区数量
func deadlock3() {ch := make(chan int, 3)ch <- 3ch <- 4ch <- 5ch <- 6 //这里会发生一直阻塞的情况 }
- 空读
func deadlock4() {ch := make(chan int)fmt.Println(<- ch) }
- 多个协程相互等待
func deadlock5() {ch1 := make(chan int)ch2 := make(chan int) //互相等对方造成死锁go func() {for {select {case num := <- ch1:fmt.Println("num=", num)ch2 <- 100}}}() for {select {case num := <- ch2:fmt.Println("num=", num)ch1 <- 300}} }
golang 的原子操作有哪些?
在 Go 语言中,原子操作用于确保多个 Goroutines 安全地对共享变量进行读写操作,以避免数据竞争和并发问题。Go 语言提供了一些原子操作函数,其中最常用的是 sync/atomic 包中的函数。
使用场景:
- 当我们想要对某个变量并发安全的修改,除了使用官方提供的
mutex
,还可以使用sync/atomic
包的原子操作,它能够保证对变量的读取或修改期间不被其他的协程所影响。 atomic
包提供的原子操作能够确保任一时刻只有一个 goroutine 对变量进行操作,善用 atomic 能够避免程序中出现大量的锁操作。
常见操作:
- 增减 Add
- 载入 Load
- 比较并交换 CompareAndSwap
- 交换 Swap
- 存储 Store
atomic 操作的对象是一个地址,你需要把可寻址的变量的地址作为参数传递给方法,而不是把变量的值传递给方法。下面分别介绍这些操作:
-
增减操作:此类操作的前缀为 Add。原子地将指定的整数值与另一个整数值相加。
func AddInt32(addr *int32,delta int32)(new int32) func AddInt64(addr *int64, delta int64)(new int64) func AddUint32(addr *uint32, delta uint32)(new uint32) func AddUint64(addr *uint64,delta uint64)(new uint64) func AddUintptr(addr *uintptr,delta uintptr)(new uintptr)func add(addr *int64, delta int64) {atomic.AddInt64(addr, delta)//加操作fmt.Println("add opts: ",*addr) }
-
载入操作:此类操作的前缀为 Load。原子地加载指定的 32 位或 64 位整数值。
func LoadInt32(addr *int32)(val int32) func LoadInt64(addr *int64)(val int64) func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer) func LoadUint32(addr *uint32)(val uint32) func LoadUint64(addr *uint64) (val uint64) func LoadUintptr(addr *uintptr) (val uintptr) //特殊类型:Value类型,常用于配置变更 func (v *Value) Load() (x interface{}){}
-
比较并交换:此类操作的前缀为 CompareAndSwap,该操作简称 CAS,可以用来实现乐观锁。原子地比较指定的整数值和期望值,并在它们相等时进行交换。
func CompareAndSwapInt32(addr *int32, old, new int32)(swapped bool) func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool) func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool) func CompareAndSwapUint32(addr *uint32, old, new uint32)(swapped bool) func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool) func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool)
该操作在进行交换前首先确保变量的值未被更改,即仍然保持参数 old 所记录的值,满足此前提下才进行交换操作。
CAS的做法类似操作数据库时常见的乐观锁机制。
注意,当有大量的goroutine对变量进行读写操作时,可能导致CAS操作无法成功,这时可以利用for循环多次尝试。 -
其他
atomic.StoreInt32 / atomic.StoreInt64: 用于原子地存储指定的 32 位或 64 位整数值。 atomic.SwapInt32 / atomic.SwapInt64: 用于原子地交换指定的整数值。 atomic.LoadPointer / atomic.StorePointer: 用于原子地加载和存储指针。 atomic.CompareAndSwapPointer: 用于原子地比较指定的指针和期望值,并在它们相等时进行交换。 atomic.AddUint32 / atomic.AddUint64: 用于原子地将指定的无符号整数值与另一个无符号整数值相加。 atomic.AddInt / atomic.AddUint: 用于原子地将指定的整数值与另一个整数值相加,其中整数的大小是平台相关的。
理解什么是乐观锁、悲观锁
- 悲观锁:
- 悲观锁的基本思想是,在访问共享资源(如数据库记录、内存数据)之前,先获取锁,以防止其他线程同时修改该资源。
- 当一个线程获取了悲观锁,其他线程必须等待,直到锁被释放。这会导致并发性较低,因为只有一个线程能够访问共享资源。
- 悲观锁通常使用互斥锁(Mutex)或数据库的行级锁来实现。这些锁能够确保资源的独占性,但可能导致性能瓶颈和死锁问题。
- 乐观锁:
- 乐观锁的基本思想是,在访问共享资源之前,不阻塞其他线程,而是先尝试执行操作,然后在操作完成时检查是否有其他线程同时修改了资源。
- 乐观锁的实现通常依赖于版本号或时间戳。每个资源都会关联一个版本号或时间戳,当一个线程尝试修改资源时,它会检查资源的版本号或时间戳是否仍然是它在开始操作时所读取的值。
- 如果版本号或时间戳匹配,操作继续执行;否则,操作失败,则重新尝试。
- 乐观锁通常用于处理读多写少的场景,以提高并发性,减少锁竞争。
乐观锁适用于读多写少的情况,可以提高并发性,但需要处理冲突;
悲观锁适用于写多读少或需要强制资源独占的情况,但可能导致性能瓶颈。
原子操作和锁的区别
- 原子操作由底层硬件支持,而锁是基于原子操作+信号量完成的。若实现相同的功能,前者通常会更有效率
- 原子操作是单个指令的互斥操作;互斥锁/读写锁是一种数据结构,可以完成临界区(多个指令)的互斥操作,扩大原子操作的范围
- 原子操作是无锁操作,属于乐观锁;说起锁的时候,一般属于悲观锁
- 原子操作存在于各个指令/语言层级,比如*机器指令层级的原子操作",““汇编指令层级的原子操作”,“Go语言层级的原子操作”等。
- 锁也存在于各个指令/语言层级中,比如“机器指令层级的锁”,“汇编指令层级的锁“Go语言层级的锁“等
goroutine 的实现原理
Goroutine可以理解为一种Go语言的协程(轻量级线程),是Go支持高并发的基础,属于用户态的线程,由Goruntime管理而不是操作系统。
底层数据结构:
type g struct {goid int64 //唯一的goroutine的IDsched gobuf //goroutine切换时,用于保存g的上下文stack stack //栈gopc //pc of go statement that created this goroutinestartpc uintptr //pc of goroutine function// ...
}
type gobuf struct {sp uintptr //栈指针位置pc uintptr //运行到的程序位置g guintptr //指向goroutineret uintptr //保存系统调用的返回值// ...
}
type stack struct {lo uintptr //栈的下界内存地址hi uintptr //栈的上界内存地址
}
goroutine的状态流转:
-
创建:go 关键字会调用底层函数
runtime.newproc()
创建一个goroutine
,调用该函数之后,goroutine会被设置成runnable
状态- 创建好的这个 goroutine 会新建一个自己的栈空间,同时在 G 的 sched 中维护栈地址与程序计数器这些信
息。 - 每个G在被创建之后,都会被优先放入到本地队列中,如果本地队列已经满了,就会被放入到全局队列
中。
- 创建好的这个 goroutine 会新建一个自己的栈空间,同时在 G 的 sched 中维护栈地址与程序计数器这些信
-
运行:goroutine 本身只是一个数据结构,真正让 goroutine 运行起来的是调度器。Go实现了一个用户态的调度器(GMP模型),这个调度器充分利用现代计算机的多核特性,同时让多个 goroutine 运行,同时 goroutine 设计的很轻量级,调度和上下文切换的代价都比较小。
调度时机:- 新起一个协程和协程执行完毕
- 会阻塞的系统调用,比如文件io、网络io
- channel、mutex等阻塞操作
- time.sleep
- 垃圾回收之后
- 主动调用runtime.Gosched()
- 运行过久或系统调用过久等等
每个M开始执行P的本地队列中的G时,goroutine会被设置成 running 状态。
如果某个M把本地队列中的G都执行完成之后,然后就会去全局队列中拿G,这里需要注意,每次去全局队列拿G的时候,都需要上锁,避免同样的任务被多次拿。从全局队列取的G数量: N= min(len(GRQ)/GOMAXPROCS 1,len(GRQ/2)) (根据GOMAXPROCS负载均衡)
如果全局队列都被拿完了,而当前M也没有更多的G可以执行的时候,它就会去其他Р的本地队列中拿任务,这个机制被称之为work stealing机制,每次会拿走一半的任务,向下取整,比如另一个P中有3个任务,那一半就是一个任务。从其它P本地队列窃取的G数量: N=len(LRQ)/2 (平分)
当全局队列为空,M也没办法从其他的Р中拿任务的时候,就会让自身进入自旋状态,等待有新的G进来。最多只会有GOMAXPROCS个M在自旋状态,过多M的自旋会浪费CPU 资源。
-
阻塞:channel的读写操作、等待锁、等待网络数据、系统调用等都有可能发生阻塞,会调用底层函数runtime. gopark(),会让出CPU时间片,让调度器安排其它等待的任务运行,并在下次某个时候从该位置恢复执行。当调用该函数之后,goroutine会被设置成
waiting
状态。 -
唤醒:处于waiting状态的goroutine,在调用runtime.goready()函数之后会被唤醒,唤醒的goroutine会被重新放到M对应的上下文P对应的runqueue中,等待被调度。当调用该函数之后,goroutine会被设置成 runnable 状态。
-
退出:当goroutine执行完成后,会调用底层函数 runtime.Goexit() ,当调用该函数之后,goroutine会被设置成
dead
状态。
goroutine 的泄露
泄露原因:
- goroutine 内进行 Channel/mutex 等读写操作一直被阻塞。
- goroutine 内的业务逻辑进入死循环,资源一直无法释放。
- goroutine 内的业务逻辑进入长时间等待,有不断新增的 goroutine 进入等待。
怎么查看 goroutine 的数量?怎么限制 goroutine 的数量?
在开发过程中,如果不对 goroutine 加以控制而进行滥用的化,可能会导致服务整体崩溃。比如耗尽系统资源导致程序崩溃,或者 CPU 使用率过高导致系统忙不过来。
在 golang 中,GOMAXPROCS 中控制的是未被阻塞的所有 goroutine,可以被 Multiplex 到多少个线程上运行,通过 GOMAXPROCS 可以查看 goroutine 的数量。
在 Go 中,可以使用 runtime 包来查看当前正在运行的 goroutine 的数量和限制 goroutine 的数量。以下是一些相关函数和方法:
-
查看当前正在运行的 goroutine 的数量:使用 runtime.NumGoroutine() 函数可以查看当前正在运行的 goroutine 的数量。该函数返回一个整数,表示当前活动的 goroutine 数量。例如:
numGoroutines := runtime.NumGoroutine() fmt.Printf("当前活动的 goroutine 数量: %d\n", numGoroutines)
-
限制 goroutine 的数量: Go 语言本身没有提供内置的机制来限制 goroutine 的数量。但是,可以使用通道和协程来自行实现 goroutine 的数量限制。以下是一个示例:
package main import ("fmt""sync" ) func worker(id int, jobs <-chan int, results chan<- int) {for job := range jobs {// 模拟工作fmt.Printf("Worker %d 开始处理 Job %d\n", id, job)// 实际工作处理results <- job * 2fmt.Printf("Worker %d 完成处理 Job %d\n", id, job)} } func main() {numJobs := 10numWorkers := 3jobs := make(chan int, numJobs)results := make(chan int, numJobs)var wg sync.WaitGroup// 启动 goroutinefor i := 1; i <= numWorkers; i++ {wg.Add(1)go func(workerID int) {defer wg.Done()worker(workerID, jobs, results)}(i)}// 提交任务for i := 1; i <= numJobs; i++ {jobs <- i}close(jobs)// 收集结果go func() {wg.Wait()close(results)}()// 处理结果for result := range results {fmt.Printf("收到结果: %d\n", result)} }
通过使用两个通过 jobs 和 results 以及一个等待组 wg 来实现的。
-
jobs 通道:这个通道用于传递需要处理的任务(jobs)给 goroutine。在示例中,我们创建了10个任务(1 到 10),并将它们发送到 jobs 通道中。
-
results 通道:这个通道用于传递处理任务后的结果。每个 goroutine 将任务处理后的结果发送到 results 通道中。
-
wg 等待组:sync.WaitGroup 用于等待所有 goroutine 完成工作。我们在 main 函数中创建了一个 WaitGroup 变量 wg。每个启动的 goroutine 都会在完成工作后调用 wg.Done() 来通知 wg 已完成。最后,我们在一个单独的协程中调用 wg.Wait() 来等待所有 goroutine 完成。这确保了在程序退出之前等待所有 goroutine 完成。
这种设计模式允许我们限制并发执行的 goroutine 数量。在示例中,我们启动了3个工作 goroutine,它们从 jobs 通道中接收任务,处理任务后将结果发送到 results 通道。只有当一个 goroutine完成任务后,它才能从 jobs 通道中接收下一个任务。这样,限制了同时活动的 goroutine 数量。
-
goroutine 和线程的区别?
- 一个线程可以有多个协程
- 线程、进程都是同步机制,而协程是异步
- 协程可以保留上一次调用时的状态,当过程重入时,相当于进入了上一次的调用状态
- 协程是需要线程来承载运行的,所以协程并不能取代线程,「线程是被分割的CPU资源,协程是组织好的代码流程」。
golang 的 struct 能不能比较?
在 Go 中,结构体(struct)可以进行比较操作,但有一些限制。结构体比较是按字段逐个比较的,但有一些规则和限制:
- 结构体比较必须是相同类型的结构体才能进行,即它们具有相同的字段类型和字段顺序。
- 结构体可以用 == 运算符进行比较。如果两个结构体的所有字段相等,则它们被认为是相等的,否则它们被认为是不相等的。
- 如果结构体的字段中包含不可比较的类型(例如切片、映射、函数等),则结构体不可比较,并且编译时会出现错误。
- 如果结构体中包含指针字段,比较的结果将取决于指针指向的内容。
- 如果结构体的字段是比较的,但它们包含不可比较的类型,比较将引发编译时错误。
package main
import "fmt"type Person struct {Name stringAge int
}func main() {p1 := Person{"Alice", 30}p2 := Person{"Bob", 25}p3 := Person{"Alice", 30}// 结构体比较fmt.Println("p1 == p2:", p1 == p2) // falsefmt.Println("p1 == p3:", p1 == p3) // true
}
golang 的 slice 如何扩容
在 Go 1.18 之前:
- 如果新容量 cap 大于旧容量 old.cap 的两倍,最终容量 newcap 就是新容量 cap。
- 如果新容量 cap 不到旧容量 old.cap 的两倍,而旧切片的长度小于 1024 字节,最终容量 newcap 就是旧容量 old.cap 的两倍。
- 如果新容量 cap 不到旧容量 old.cap 的两倍,但旧切片长度大于等于 1024 字节,最终容量 newcap 从旧容量 old.cap 开始循环增加原来的 1.25 倍,直到新最终容量大于等于新容量 cap。
- 如果计算得到的最终容量 newcap 溢出,最终容量 newcap 就是新容量 cap。
在 Go 1.18 之后:
Go 1.18 引入了更智能的扩容策略,以减少内存分配和复制的次数。具体策略如下:
- 如果切片的容量小于 256 字节,每次扩容会使容量翻倍。
- 如果切片的容量大于等于 256 字节,每次扩容会增加 (old.cap + 3*256) / 4,这相当于每次增加旧容量的 1.25 倍再加上 192。
这个更新的策略可以更好地平衡内存和性能。所以,扩容策略在 Go 1.18 之后有了一些改变,不再是固定的 2 倍或 1.25 倍,而是根据容量的大小来调整。
在 Go 函数为什么会发生内存泄露?发生了泄露如何检测?
内存泄漏在 Go 中通常发生在以下情况下:
- 创建了大量的 goroutines,但没有及时结束它们,导致它们占用的内存得不到释放。
- 数据结构中的引用循环,导致垃圾回收器无法识别和回收不再使用的内存。
- 持续分配内存而不释放,例如,通过不断向切片或映射添加元素,而不会删除或清理不再使用的元素。
对于内存泄露的检测,Go 提供了一些工具来帮助检测盒分析内存泄露:
pprof
:Go 的标准库提供了 net/http/pprof 包,可以用于生成内存和 CPU 分析报告,以帮助诊断和定位内存泄漏问题。Gops
:Gops 是一个用于检查和操作正在运行的 Go 进程的工具,它提供了一些命令,如查看内存分配情况和垃圾回收的状态,以帮助识别内存泄漏。
这些工具可以帮助你发现内存泄漏,并分析哪些部分的代码导致了泄漏,以便及时修复问题。
golang 中两个 nil 可能不相等吗?
可能不相等。在Go中,接口值包括两个部分:类型(Type)和值(Value)。当接口值的类型部分为 nil 且值部分也未设置时,接口值等于 nil。这是因为接口类型的零值就是 nil。
两个接口值比较时,会先比较 T,再比较 V。
接口值与非接口值比较时,会先将非接口值尝试转换为接口值,再比较。
func main() {var p *intvar i interface{} = pfmt.Println(i == p) // true 接口 i 的值部分包含了指针 p,而接口值的比较是按照 i 的类型和值进行的,所以 i 等于 p。fmt.Println(p == nil) // true 指针 p 确实是 nilfmt.Println(i == nil) // false 虽然 i 的值部分包含 nil 指针,但由于 i 的类型部分不是 nil,因此整个接口值 i 不等于 nil。这是因为接口值的比较是按照类型和值一起进行的。
}
Go 语言中的内存对齐
CPU 并不会以一个一个字节去读取和写入内存。相反 CPU 读取内存是一块一块读取的,块的大小可以为 2、4、6、8、16 字节等大小。块大小我们称其为内存访问粒度,内存访问粒度跟机器字长有关。
对齐规则:
- 结构体的成员变量的对齐要求是根据每个成员变量的类型来确定的。每个成员变量的对齐值必须是编译器默认对齐长度和该成员变量类型的大小中的较小者。
- 例如,如果默认对齐长度为4字节,而某个成员变量的类型大小为2字节,那么该成员变量的对齐值将是2字节。这确保了成员变量按照其类型的自然对齐方式进行排列。
- 结构体本身的对齐值是根据结构体的所有成员变量的类型中的最大大小来确定的。
- 如果结构体的成员变量类型中的最大大小小于编译器的默认对齐长度,那么结构体的对齐值将是最大成员变量的大小。
- 如果最大大小大于编译器默认对齐长度,那么结构体的对齐值将是编译器的默认对齐长度。
- 默认对齐长度通常是与目标平台相关的,例如,在32位系统上通常为4字节,而在64位系统上通常为8字节。
对齐规则确保了结构体和其成员变量按照合适的方式在内存中排列,以便于 CPU 高效地访问数据。对于不同的编译器和目标平台,对齐规则可能会有所不同。
两个 Interface 可以比较吗?
- 判断类型是否一样:
reflect.TypeOf(a).Kind() == reflect.TypeOf(b).Kind()
- 判断两个 Interface{} 是否相等:
reflect.DeepEqual(a, b)
- 将一个 Interface{} 赋值给另一个 Interface{}:
reflect.ValueOf(&a).Elem().Set(reflect.ValueOf(b))
golang 打印时 %v、%+v、%#v 的区别?
%v:只输出所有的值
%+v:先输出字段名字,再输出该字段的值
%#v:先输出结构体名字值,再输出结构体(字段名字+字段的值)
package main
import "fmt"type student struct {id int32name string
}
func main() {a := &student{id: 1, name: "微客鸟窝"}fmt.Printf("a=%v \n", a) // a=&{1 微客鸟窝}fmt.Printf("a=%+v \n", a) // a=&{id:1 name:微客鸟窝}fmt.Printf("a=%#v \n", a) // a=&main.student{id:1, name:"微客鸟窝"}
}
什么是 rune 类型?
在 Go 中,字符有两种类型:
- uint8 类型,或者叫byte 型,代表了 ASCII 码的一个字符。
- rune 类型,代码一个 UTF-8 字符,当需要处理中文或者其他复合字符时,则需要使用 rune 类型。它能够表示任何 Unicode 字符。rune 类型等价于 int32 类型。
空 struct{} 占用空间吗?用途是什么?
空结构体 struct{} 实例不占据任何的内存空间。
用途:
- 将 map 作为集合(Set)使用时,可以将值类型定义为空结构体,仅作为占位符使用即可。
- 不发送数据的通道(channel),使用 channel 不需要发送任何的数据,只用来通知子协程(goroutine)执行任务,或只用来控制协程并发度。
- 结构体只包含方法,不包含任何的字段。
golang 值接收者和指针接收者的区别
golang 函数与方法的区别是:方法有一个接收者。
如果方法的接收者是指针类型,无论调用者是对象还是对象指针,修改的都是对象本身,会影响调用者。
如果方法的接收者是值类型,无论调用者是对象还是对象指针,修改的都是对象的副本,不影响调用者。
package main
import "fmt"type Person struct {age int
}func (p *Person) IncrAge1() {p.age += 1
}func (p Person) IncrAge2() {p.age += 1
}func (p Person) GetAge() int {return p.age
}func main() {p := Person{22,}p.IncrAge1()fmt.Println(p.GetAge()) //23p.IncrAge2()fmt.Println(p.GetAge()) //23p2 := &Person{age: 22,}p2.IncrAge1()fmt.Println(p2.GetAge()) //23p2.IncrAge2()fmt.Println(p2.GetAge()) //23
}
通常使用指针类型作为方法的接收者的理由:
- 使用指针类型能够修改调用者的值。
- 使用指针类型可以避免在每次调用方法时复制改值,在值的类型为大型结构体时,这样做更加高效。
defer 关键字的实现原理
在 Go 中,每个函数(或方法)都有一个数据结构,其中包含一堆 defer 函数的指针。当你使用 defer
关键字来推迟函数的执行时,Go 编译器会将要推迟的函数包装成一个闭包(closure)并存储到函数的 defer 链表中。
这个 defer 链表就像一个栈,后添加的 defer 函数会放在链表的前面,这意味着最后添加的 defer 函数会最先执行。当函数正常返回时,Go 会按照 后进先出(LIFO) 的顺序执行这些 defer 函数,以确保资源的释放和清理。
如果在函数中遇到了 panic,Go 会立即停止正常执行路径,但会保留 defer 链表。之后,defer 链表中的函数将按照后进先出的顺序执行,这可以用来处理 panic、恢复程序状态或执行资源清理等操作。
所以,defer 的实现基本上是通过将要推迟执行的函数封装成闭包,并按照后进先出的顺序执行他们,以确保在函数返回或 panic 时执行特定的操作。
- panic 没有被 recover 时,抛出的 panic 到当前 goroutine 最上层函数时,最上层程序直接异常终止。
package main import "fmt"func F() {defer func() {fmt.Println("b")}()panic("a") }func main() {defer func() {fmt.Println("c")}()//子函数抛出的panic没有recover时,上层函数时,程序直接异常终止F()fmt.Println("继续执行") }
- panic 有被 recover 时,当前 goroutine 最上层函数正常执行。
package main import "fmt"func F() {defer func() {if err := recover(); err != nil {fmt.Println("捕获异常", err)}fmt.Println("b")}()panic("a") }func main() {defer func() {fmt.Println("c")}()//子函数抛出的panic没有recover时,上层函数时,程序直接异常终止F()fmt.Println("继续执行") }
select 的底层原理
select 是 Go 语言中用于处理并发操作的一种控制结构。它允许你在多个通道操作之间进行选择,以便在其中一个操作准备好数据时执行相应的操作。select的底层原理:
- select中包含多个case,每个case对应一个通道操作(发送或接收)或默认操作(当没有其他case满足条件时执行)。
- 当select开始执行时,它会检查每个case,看哪个操作可以立即执行(即通道中有数据可以接收,或者有空间可以发送数据)。
- 如果有多个case都可以立即执行,Go语言的运行时系统会随机选择一个来执行,以保证公平性。
- 如果没有任何case可以立即执行,select将会等待,直到至少有一个case满足条件。在等待期间,其他协程可以继续执行。
- 一旦有满足条件的case,select会执行该case对应的操作,然后继续向下执行。
- 如果有多个case同时满足条件,select仍然只会执行一个,具体哪个case被执行是随机的。
- 如果有default操作,它会在没有其他case满足条件时执行,这是一种通用的备用操作。
select的底层原理是通过检查多个通道操作,选择一个可以立即执行的操作,或等待至少一个操作准备好。
gRPC
gRPC是基于go的远程过程调用。RPC 框架的目标就是让远程服务调用更加简单、透明,RPC 框架负责屏蔽底层的传输方式(TCP 或者 UDP)、序列化方式(XML/Json/ 二进制)和通信细节。服务调用者可以像调用本地接口一样调用远程的服务提供者,而不需要关心底层通信细节和调用过程。
反射
反射是指计算机程序可以访问、检测和修改它本身状态或行为的一种能力。程序在编译时,变量被转换为内存地址,变量名不会被编译器写入到可执行部分。在运行程序时,程序无法获取自身的信息。
支持反射的语言可以在程序编译期将变量的反射信息,比如字段名称、类型信息、结构体信息等整合到可执行文件中,并给程序提供接口访问反射信息,这样就可以在程序运行期间获得类型的反射信息,并且有能力修改它们。
反射的两个弊端:
- 代码不易阅读,不易维护,容器发生线上panic
- 性能很差,比正常代码慢1到2个数量级
golang 当中反射最重要的两个概念是 Type 和 Value。
- Type 用于获取类型相关的信息,就像你可以看到一个盒子的标签,知道它是装着什么物品的。(比如 slice 的长度,struct 的成员,函数的参数个数)
- Value 用于获取和修改原始数据的值,就像你可以打开盒子,查看和修改里面的东西。(比如修改 slice 和 map 中的元素,修改 struct 的成员变量)。
golang 的字符串拼接方式对比
对于字符串拼接,推荐使用 strings.Builder
或 bytes.Buffer
,它们的性能更高,因为它们是基于 []byte
实现的缓冲区,可以避免不必要的内存分配和拷贝。同时,这两个类型也提供了更多的字符串操作方法。
-
使用
+
拼接字符串:因为golang的字符串是静态的,所以每次+
都会重新分配一个内存空间存相加的两个字符串。- 字符串在 golang 中本质上是不可变的,所以每次使用
+
操作符拼接字符串时,会创建一个新的字符串,并将原始字符串的内容和要拼接的字符串复制到新的内存位置。 +
操作符相当于使用append
函数将字节片段追加到切片中,然后将该切片转换为字符串。
- 字符串在 golang 中本质上是不可变的,所以每次使用
-
使用
fmt.Sprintf
拼接字符串:主要是使用到了反射。fmt.Sprintf
是通过格式化字符串来拼接各种数据类型,并返回一个新的字符串。fmt.Sprintf
在内部使用反射来动态的将各种数据类型转化为字符串,然后将他们拼接在一起。- 由于反射的使用,
fmt.Sprintf
可以处理各种不同的数据类型,但是这也会引入一些性能开销,因为反射在运行时会检查和转换数据类型。
-
使用
strings.Builder
:(golang 1.10及以后版本可用)-
strings.Builder 是一个字符串缓冲区,用于构建字符串。
-
它提供了方法来追加字符串,比如
WriteString
、Write
等,以及String
方法用于获取最终的字符串。 -
使用 strings.Builder 时,可以连续追加字符串,而不会导致大量的内存分配。它内部维护一个
[]byte
,动态调整大小,以容纳追加的内容。 -
示例:
var builder strings.Builder builder.WriteString("hello, ") builder.WriteString("world!") result := builder.String() //获取最终字符串
-
addr 字段主要是做 copycheck,buf 字段是一个 byte 类型的切片,这个就是用来存放字符串内容的,提供的 writeString() 方法就是像切片buf中追加数据。
-
[]byte的申请是成倍的,例如,初始大小为 0,当第一次写入大小为 10 byte 的字符串时,则会申请大小为 16 byte 的内存(恰好大于 10 byte 的 2 的指数),第二次写入 10 byte 时,内存不够,则申请 32 byte 的内存,第三次写入内存足够,则不申请新的,以此类推。
提供的 String 方法就是将 []byte 转换为 string 类型,这里为了避免内存拷贝的问题,使用了强制转换来避免内存拷贝。type Builder struct {addr *Builder // of receiver, to detect copies by valuebuf []byte // 1 }func (b *Builder) WriteString(s string) (int, error) {b.copyCheck()b.buf = append(b.buf, s...)return len(s), nil }func (b *Builder) String() string {return *(*string)(unsafe.Pointer(&b.buf)) }
-
-
使用
bytes.Buffer
:- bytes.Buffer 与 strings.Builder 类似,也是一个缓冲区,用于构建字符串。
- 它提供了类似的方法来追加数据,比如
WritrString
、Write
等。 - 与 strings.Builder 不同,bytes.Buffer 可以用于处理任何二进制数据,而不仅仅是字符串。
- 示例:
var buffer bytes.Buffer buffer.WriteString("hello, ") buffer.WriteString("world!") result := buffer.String() //获取最终字符串
- strings.Builder 和 bytes.Buffer 底层都是 []byte 数组,但 strings.Builder 性能比 bytes.Buffer 略快约 10% 。一个比较重要的区别在于,bytes.Buffer 转化为字符串时重新申请了一块空间,存放生成的字符串变量,而 strings.Builder 直接将底层的 []byte 转换成了字符串类型返回了回来。
常见字符集
- ACSII:使用一个字节(8位)来表示一个字符,包括英文字母、数字和一些特殊符号。首位是0,总共可以表示 128 个字符,使用 7 位二进制数来编码。
- UTF-8:是 Unicode 字符集的一种编码方式。采用可变长度编码,总共分为四个长度区:1个字节、2个字节、3个字节、4个字节。英文字符、数字等占用1个字节(8位)(兼容标准ACSII编码),汉字字符占用3个字节。
- UTF-16:使用 16 位(2个字节)位基本编码单元。可以表示 Unicode 的所有字符。
- UTF-32:使用固定的 32 位(4个字节)编码单元。每个字符都用32 位表示。可以表示 Unicode 的所有字符。
string 和 []byte 的区别
- string 类型的不可变性:string 类型在 Go 中被设计为不可变,这意味着一旦一个 string 被创建,它的内容就不可更改。这种不可变性对于并发操作来说是非常有用的,因为可以在不担心其他 goroutine 修改字符串内容的情况下共享 string。
- string 底层表示:string 类型本质也是一个结构体,定义如下:
string 底层表示是一个指向 byte 数组的指针,以及一个表示字符串长度的整数。这个底层 byte 数组包含了字符串的实际字节数据。stringStruct 和 slice 还是很相似的,str 指针指向的是 byte数组的首地址,len 代表数组长度。type stringStruct struct {str unsafe.Pointerlen int }
- string 和 [] byte 的区别:主要区别在于不可变性。如果需要在字符串上执行修改操作,应该先将 string 转换为 []byte,做出所需的更改,然后再将其转换为 string。
- string 是不可变的
- []byte 可以被修改
在这个过程中,由于字符串的不可变性,实际上是创建了一个新的字符串。str := "hello" // string 转为 []byte bytes := []byte(str) // 修改 []byte bytes[0] = 'H' // 可以修改成功 // []byte 转为 string str = string(bytes)
- string 类型为什么还要在数组的基础上再进行一次封装呢?
因为在 Go 语言中,string 类型被设计为不可变的,不仅是在 Go 语言,在其他语言中 string 类型也是被设计为不可变的,这样设计的好处是:在并发场景下,可以在不加锁的控制下,多次使用同一字符串,在保证高效共享的情况下而不用担心安全问题。
HTTP 和 RPC 对比
HTTP:是一种应用层协议,通常用于在客户端和服务器之间传输超文本文档(如网页)的协议。基于文本,可读性强的协议。
RPC:是一种远程过程调用协议,用于实现不同计算机或进程之间的函数调用和数据交换。通常以编程语言特定的方式定义服务和数据类型。
相同点:
- 网络通信:都是用于实现网络通信的协议,允许不同计算机或进程之间进行数据交换。
- 远程调用:都支持远程调用,允许客户端请求服务器上的操作。
- 可扩展性:都可以用于构建分布式系统和微服务架构,实现应用程序的可扩展性。
不同点:
- 协议类型:HTTP 是基于文本的应用层协议,而 RPC 通常使用二进制协议。
- 通信方式:HTTP 通常采用请求-响应模式,而 RPC 允许远程过程调用,更类似于本地函数调用。
- 数据格式:HTTP 数据通常是基于文本的,而 RPC 使用更紧凑的二进制数据格式。
- 跨语言:HTTP 可以在不同编程语言之间通信,而 RPC 通常需要使用专门的 IDL 文件来定义服务和数据结构,生成服务端和客户端的代码,支持多种编程语言。
gRPC 和 RPC 对比
RPC:是一种远程过程调用机制,允许客户端程序调用远程服务器上的函数或方法,就像本地函数调用一样。
gRPC:是一个高性能、跨语言的远程过程调用框架,使用 Protocol Buffers 作为接口描述语言和二进制数据序列化格式。
相同点:
- 远程调用:都支持远程调用,允许客户端调用远程服务或函数。
- 通信范式:都属于远程过程调用的通信范式,允许不同计算机或进程之间进行函数调用。
- 跨语言:都支持多种编程语言,使得不同编程语言的应用程序可以互相通信。
不同点:
- 通信协议:gRPC 使用 HTTP/2 作为通信协议,而传统的 RPC 框架可以使用不同的传输层协议,包括 TCP和UDP,取决于具体的实现。
- 序列化协议:gRPC 使用 Protocol Buffers 作为默认的数据序列化格式,这是一种高效的二进制格式,而传统的RPC 可以使用不同的数据格式,包括 XML-PRC、JSON-RPC 等。
- IDL:gRPC 使用 Protocol Buffers 定义接口和数据结构,提供了强类型和自动代码生成的特性。传统 RPC 通常需要程序员自行定义接口和数据结构。
- 性能和效率:gRPC 针对性能进行了优化,提供了双向流、流式传输、头部压缩和多路复用等功能,使其在性能和效率上优于传统的 RPC。
- 自动生成代码:gRPC 可以根据服务定义文件自动生成客户端和服务端的代码,大大简化了开发过程。
- 安全性:gRPC 提供了 TLS 加密和认证等安全特性,确保通信的安全性。
sync.Pool 的使用
sync.Pool 本质用途是增加临时对象的重用率,减少 GC 负担;
sync.Pool 中保存的元素有如下特征:
- Pool 池里的元素随时可能释放掉,释放策略完全由 runtime 内部管理。
- Get 获取到的元素对象可能是刚刚创建的,也可能是之前创建好 cache 的,使用者无法区分。
- Pool 池里面的元素个数你无法知道。
sync.Pool 的数据结构:
type Pool struct {noCopy noCopylocal unsafe.Pointer // local fixed-size per-P pool, actual type is [P]poolLocallocalSize uintptr // size of the local arrayvictim unsafe.Pointer // local from previous cyclevictimSize uintptr // size of victims array// New optionally specifies a function to generate// a value when Get would otherwise return nil.// It may not be changed concurrently with calls to Get.New func() any
}
申请对象 Get
释放对象 Put
初始化 Pool 示例
package main
import ("fmt""sync""sync/atomic""time"
)var createNum int32func createBuffer() interface{} {atomic.AddInt32(&createNum, 1)buffer := make([]byte, 1024)return buffer
}func main() {bufferPool := &sync.Pool{New: createBuffer}workerPool := 1024 * 1024var wg sync.WaitGroupwg.Add(workerPool)for i := 0; i < workerPool; i++ {go func() {defer wg.Done()buffer := bufferPool.Get()_ = buffer.([]byte)// buffer := createBuffer()// _ = buffer.([]byte)defer bufferPool.Put(buffer)}()}wg.Wait()fmt.Printf(" %d buffer objects were create.\n", createNum)time.Sleep(3 * time.Second)
}
JWT (JSON Web Token)
jwt 结构:JWT 由三部分组成,它们以点号分隔:
- Header:包含了令牌的类型(即 JWT)和所使用的签名算法(例如 HMAC SHA256 或 RSA)。
- Payload:包含了声明(Claim),通常包括用户身份信息、过期时间和其他信息。
- Signature:用于验证令牌是否完整和未被篡改。签名是通过使用 Header 中指定的签名算法对 Header 和 Payload 进行签名得到的。
jwt 认证授权过程:
- 生成 JWT:
- 在服务端,当用户成功登录后,服务器会生成一个包含用户信息的 JWT。
- 服务器使用秘钥对 Header 和 Payload 进行签名,生成 Signature。
- 最终生成的 JWT 包含 Header、Payload、Signature,以点号分隔。
- 传输 JWT:将 JWT 传输给客户端,通常放在 HTTP 头的 Authrization 字段中,或者作为 URL 的查询参数,或者存储在浏览器的 Cookie 中。
- 验证 JWT:
- 客户端接收到 JWT 后,可以将其存储在本地。
- 每次客户端发送请求到服务器时,可以将 JWT 放在请求头中,以便服务器验证用户身份。
- 服务器使用密钥验证 JWT 的签名,如果签名有效,服务器会解析 JWT 中的 Payload,获取用户信息。