1. slice
1.1 底层实现
- 利用数组创建切片时,比如array[:] arrary [1:3],切片的数组指针都是指向arrary的,当然从1开始切的地方地址有偏移(64位系统int偏移8字节)。
- 切片追加元素,修改元素都是在修改底层数组,对于共享底层数组的多个切片来说,会相互产生影响。与Map一样,Slice也不是并发安全的。用copy函数。
- 切片时如果长度为1的切片可以切[1:],返回空切片,因为要求的切片低位<=高位<=len.
1.2 扩容
- 避免重建slice的底层数组,在扩展切片时,并非按需分配,而是2的幂次方,当二的幂次方变得很大时<1024,是按照1/4。所以大部分时候cap大于len。实际上还要考虑内存对齐,扩容是大于或者等于1.25 倍。
- 扩容的影响导致原切片和传递后的切片不再有关联。
1.3 slice 与数组
切片与数组:可以覆盖数组全部功能吗?那么数组存在的意义是什么?
- 数组是值类型,赋值或传递时会复制整个数据。这种特性在某些场景下是有用的,比如:
- 确保数据不会被意外修改(因为数组每次传递副本)。
- 对数据隔离性要求高的场景(如并发安全)。
- 切片是引用类型,赋值或传递时共享底层数据。如果多个切片引用同一个底层数组,修改一个切片可能影响其他切片。
- 数组更有可能在栈上分配,而切片由于额外的元数据和动态性,更容易逃逸到堆上。
- 强制要求数组的某些库或框架的接口(如加密库中固定长度的哈希值、固定大小的缓冲区)。
1.4 for range 注意事项
在for a,b := range c遍历中,a和b在内存中只会存在一份,即之后每次循环时遍历到的数据都是以值覆盖的方式赋给a和b,ab的内存地址始终不变。由于有这个特性,for循环里面如果开协程,不要直接把a或者b的地址传给协程。解决办法:在每次循环时,创建一个临时变量。
2. rune和go字符串
(1) 底层
- byte 等同于int8,常用来处理ascii字符;rune 等同于int32,常用来处理unicode或utf-8字符;
- Go 的字符串本质上是一个只读的字节切片(
[]byte) ,与编码无关:字符串只是字节序列,不关心内容是否是 UTF-8。虽然字符串底层是字节数组,但Go约定字符串字面量()range 遍历等场景默认按 UTF-8 (占3字节)处理。结构体实现:
type string struct {
data *byte // 指向底层字节数组的指针
len int // 字符串的字节长度(非字符数!)
}
(2)单引号,双引号,反引号的区别
- 单引号,表示byte类型或rune类型,对应 uint8和int32类型,默认是 rune 类型。
- 双引号,才是字符串,实际上是字符数组。可以用索引号访问某字节。
- 反引号,表示字符串字面量,但不支持任何转义序列。字面量 raw literal string 的意思是,你定义时写的啥样,它就啥样,你有换行,它就换行。你写转义字符,它也就展示转义字符。
3. make和new
(1)区别
- 作用变量类型不同;new给string,array分配内存;make给slice,map,channel分配内存。
- 返回类型不一样,new返回指向变量的指针,make返回变量本身。
- new分配的空间被清零;make分配空间后,会进行初始化。
(2)相同
都是给变量分配内存, make与new对堆栈分配处理是相同的,编译器优先进行逃逸分析,逃逸的才分配到堆上。
4. map
- 切片、函数、map不能作为key,但通道、接口、结构体可以。在golang规范中,可比较的类型都可以作为map key。(切片底层数组指针会不一样,map hash会重排,两个函数地址一样,但闭包不同,可能行为完全不同(每个闭包都带着自己的“上下文”)。)
- nil map未初始化,取出来的东西返回零值,一定要先初始化,否则赋值会panic(无哈希桶指针为nil)。初始化后空map是长度为空,底层已分配哈希桶结构,可安全读写。
- map中删除一个key,它的内存会释放么? 其内存是否释放取决于值的类型。基本类型的内存由map复用,不会立即释放;由map的桶管理。引用类型的内存由GC回收,但map的桶内存不会立即缩容。
4.1 底层
// map数据结构:
type hmap struct{
count int // 元素总个数
flags uint8
B uint8 // 桶个数的对数,2^B个桶
noverflow uint16 // 溢出桶的个数
hash0 uint32 // hash种子
buckets unsafe.Pointer // map的底层是一个桶数组,这是那个数组的指针
oldbuckets unsafe.Pointer // 发生桶迁移时,指向旧桶的指针
nevacuate uintptr // 迁移进度
extra *mapextra // 存map溢出桶
}
// 桶的结构:
type bmap struct { // bucketCnt=8,每个桶存8个
tophash [bucketCnt]uint8 // key的哈希值前8位,存在时再进一步匹配key,当key hash在这里不存在时会去溢出桶访问
// 后面的字段是在编译时期动态追加的
key [bucketCnt]T // 分开key、val放置主要是为了字节对齐,压缩空间
value [bucketCnt]T
pad uintptr
overflow uintptr // 指向下一个桶的指针
}
- map访问无序的原因:map 因扩张而重新哈希时,各键值项存储位置都可能会发生改变,顺序自然也没法保证了,所以官方避免大家依赖顺序,直接打乱处理。就是for range map在开始处理循环逻辑的时候,就做了随机播种。
- 如果发生了哈希冲突两个key的hash值落到同一个bucket,但这个bucket还没满、负载因子也没超标未超过6.5,Go会怎么处理?将这个key插入同一个bucket的空位中,使用开放地址法解决冲突。如果当前bucket已满,则创建 overflow bucket(链表串接)。采用拉链法防止碰撞,但是链表①地址不连续没法高效利用CPU缓存;②整个map会多出存指针的开销。
4.2 map并发怎么锁
(1) 锁之间区别
map和sync.map的区别是什么?加了锁的map和sync.map的区别? sync.map详解
map非线程安全,map不支持并发读写但是可以并发读。sync.RWMutex+map实现简单,但是适合读多写少场景。写操作阻塞所有读写,高并发下性能差。当写操作增加会导致性能接近完全串行化。除了锁,原子操作也能达到类似并发安全的目的。sync.map在查找指定的key的时候,总会先去只读字典中寻找,并不需要锁定互斥锁。只有当read中没有,但dirty中可能会有这个key的时候,才会在锁的保护下去访问dirty。正因如此sync.Map的read/dirty机制会导致约30%的内存冗余。
不同读写并发下怎么使用锁。(其中在竞争加剧时,相较于互斥锁[使得数据访问完全串行化了],RWMutex需要维护更复杂的状态[读者计数、写者等待等],导致更多原子操作和内存屏障,所以性能会下降。更进一步,分片锁是一种将互斥锁与数据分片(Sharding)结合的高性能并发控制策略。)
graph TD
A[需要并发安全?] -->|Yes| B{数据规模}
B -->|小数据量| C[全量替换map指针]
B -->|大数据量| D{读写比例}
D -->|读多写少| E[sync.Map]
D -->|读写均衡| F[RWMutex+Map]
D -->|写密集型| G[分片锁+Map]
// Map指针原子替换策略,指通过原子操作(如atomic.Value)来原子性地替换整个map的指针,从而实现无锁(lock-free)的并发访问。
var config atomic.Value // 存储map的指针
config.Store(make(map[string]string))
| 方案 | 核心优势 | 劣势 | 典型适用场景 |
|---|---|---|---|
| Mutex+Map | 强一致性 | 完全串行化 | 写密集型+低延迟要求 |
| RWMutex+Map | 读性能优化 | 写操作仍完全互斥, 高竞争时可能不如Mutex | 读写均衡的业务逻辑 |
| sync.Map | 读旧数据无锁(read map) | 内存占用高,写多的场景read map缓存失效,冲突变多性能急剧下降 | 配置/元数据等读多写少 |
| 分片锁 | 减少锁竞争 | 分片导致的热点放大,热点分片退化 | 超高并发写入场景,KV和缓存 |
| 原子替换(Map指针) | 零竞争 | 内存占用翻倍 | 分钟级更新,数据量极小 |
(2) 异常捕获
多个goroutine对同一个map写会panic,异常是否可以用defer捕获?可以捕获异常,但是只能捕获一次。
5. channel
- 是协程安全的消息队列,多协程间通信手段。如果多个goroutine监听同一个channel,如果这个channel被关闭,则所有goroutine都能收到退出信号。
- 使用场景: 消息传递、消息过滤,信号广播,事件订阅与广播,请求、响应转发,任务分发,结果汇总,并发控制,限流,同步与异步。
| 状态\行为 | 关闭(close) |
发送(ch <- v) |
接收(<-ch) |
|---|---|---|---|
| 未初始化 | panic |
永久阻塞(所以说要make啊) | 永久阻塞 |
| 正常 | 成功关闭 | 阻塞或写入(视缓冲区情况) | 阻塞或读取(视数据/关闭状态) |
| 已关闭 | panic |
panic |
读完数据后返回零值(ok=false) |
5.1 意义
go为什么要有channel?没有channel会怎样?
总:channel是Go并发的第一公民,如果没有它goroutine的优势就难以发挥出来。
分:①goroutine之间需要协作、同步、传递数据,如果没有channel,那就得用共享内存、锁、信号量等原语去实现,复杂又容易出错。② Go的并发模型不是用锁管理共享资源,而是通过channel把数据交给需要的人,让资源拥有者处理自己的数据。
总:是CSP(Communicating Sequential Processes)模型的体现。“进程之间不共享内存,而是通过通信(发送消息)来协作”。
5.2 底层
type hchan struct {
elemsize uint16 // 通道类型的大小
closed uint32 // 关闭状态
elemtype *_type // 通道的数据类型
recvq waitq // 等待从channel中读取数据,正在被阻塞的协程队列
sendq waitq // 等待从channel中写入数据,正在被阻塞的协程队列
lock mutex // 并发保护,先获取互斥锁才能操作channel
// -------如果有缓冲区的话-------
qcount uint // channel里的数据个数
dataqsize uint // 缓冲区大小
buf unsafe.Pointer // 缓冲区指针,指向循环队列 (sendx和recvx是其中的下标)
sendx uint // 缓冲区中,已经被读取的数据的位置
recvx uint // 缓冲区中,当前已经写入的数据的位置
}
5.3 goroutine 接收消息细节
当协程A写入数据时:if recvq 有,则代表无缓冲区或者缓冲区中无数据,A要写的数据交给 recvq的第一个;if没有,那就尝试写入缓冲区;当缓冲区已满或者没有缓冲区,阻塞A这个协程,并将A加入sendq。
graph TD
A[协程A尝试写入数据] --> B{recvq队列是否为空?}
B -->|No| C[将数据直接交给recvq第一个等待者]
C --> D[唤醒对应接收协程]
B -->|Yes| F{缓冲区是否可用且未满?数据写入哪里等另一个协程告诉你或者放缓冲区}
F -->|Yes| G[数据写入缓冲区]
F -->|No| H[阻塞协程A并加入sendq队列]
- 协程B读取数据时:检查sendq,为空尝试从缓冲区中获取,缓冲区无则把自己也加入进去;sendq不为空,且无缓冲区,那么从第一个sendq读数据;sendq不为空,且有缓冲区,从缓冲区读数据。(但是也有看到一些书说FIFO,应该先读缓冲区)
graph TD
A[协程B尝试读取数据] --> B{sendq队列是否为空?}
B -->|No| H{是否有缓冲区?}
H -->|No| C[直接从sendq第一个发送者读取数据]
H -->|Yes| I[说明缓冲区满了,从缓冲中拿出第一个\n并唤醒一个发送goroutin写入缓冲区]
I --> E
C --> D[唤醒该发送者]
D --> E[流程结束]
B -->|Yes| F{缓冲区是否有数据?}
F -->|Yes| G[从缓冲区读取数据]
G --> E
F -->|No| K[阻塞协程B并加入recvq队列]
K --> E
6. select
6.1 执行
select仅支持管道,而且是单协程操作。
graph TD
C[开始执行select] --> E[按随机顺序遍历所有case]
E --> F{当前case是否就绪?}
F -->|Yes| G[执行该case操作]
G --> H[返回选择的case索引]
F -->|No| I{是否遍历完所有case?}
I -->|No| E
I -->|Yes| J{存在default case?}
J -->|Yes| K[执行default case]
K --> H
J -->|No| L[将Goroutine加入所有channel的等待队列]
L --> M[挂起Goroutine, 等待唤醒]
M --> N[被唤醒后重新检查所有case]
N --> F
6.2 超时控制
select可以结合time.After实现超时控制。但是在for循环里不要使用select + time.After的组合会导致内存泄漏。来看一个发布订阅的简化代码:
for i := 0; i < concurrency; i++ { // 启动多个协程
go pub(i) // 每个协程执行 pub(i)
}
func pub(start int) { // start 就是传入的 i
for j := start; j < count; j += concurrency {
// 向 subscribers[j] 发送消息,分片任务分配的经典模式,每个协程间隔处理多个订阅者
select {
case subscribers[j] <- msg: // 尝试发送消息
case <-time.After(time.Millisecond * 5): // 底层行为:每次调用time.After()方法会创建一个新的time.Timer,并返回其通道Timer.C,每次循环执行到select时,都会调用并产生一个新的Timer(即使前一个定时器还未触发)。
case <-b.exit: // 监听退出信号
return
}
}
}
①每次执行select,无论去不去那个case都会先创建好Timer,因为当运行到 select 语句时,先初始化所有 case 的表达式,生成所有可能的 scase 对象,(time.After 对应的 scase 的 kind 是 caseRecv(接收操作))。②当一个for循环中select已经执行了,当前select实列已经没有了,没有执行到Timer这个case,由于select 的机制只是监听多个通道的就绪状态,它不负责管理通道的生命周期。已创建的Timer不会消失,它会留在Go运行时的时间堆(timer heap)中,直到5ms后触发并向通道发送数据。③这些Timer会在5ms后陆续触发,但触发前会堆积在内存中。最终导致内存泄漏(大量未释放的Timer对象)和不必要的CPU开销(处理无用的定时事件)。
// 解决方案:使用NewTimer来做定时器,不需要每次都创建定时器对象,添加上time.Reset每次重新激活定时器
pub := func(start int) {
idleDuration := 5 * time.Millisecond
idleTimeout := time.NewTimer(idleDuration) // 这个协程在处理分给他的订阅者时都共用一个计时器。
defer idleTimeout.Stop() // 确保函数退出时释放定时器,可以被回收
for j := start; j < count; j += concurrency {
if !idleTimeout.Stop() {
<-idleTimeout.C // drain排空channel一定要做!reset的是时间,但是timer通道里可能有上一次的残留信号
}
idleTimeout.Reset(idleDuration) // 重置定时器
select {
case subscribers[j] <- msg:
case <-idleTimeout.C:
case <-b.exit:
return
}
}
}
6.3 底层
select中的每个case在运行时都是scase结构体,在编译阶段,select 语句会被转换为以下结构:
type scase struct{
c *hchan // 操作的 channel
elem unsafe.Pointer // 数据指针(发送值或接收地址)
kind uint16 // 4种类型,caseNil,caseRecv,caseSend,caseDefault,行为不同
……
}
caseNil 运行时直接跳过它(既不执行也不阻塞),(如果直接对nil channel操作,会触发panic;但select的 caseNil会安全跳过)用途:
// 可以通过 nil channel 临时禁用某个 case(类似 disable 功能)
var ch chan int
if enable {
ch = make(chan int) // 启用
} // 不过你disable的时候,原channel的缓冲区(如果有)会被GC回收,会丢失数据
select {
case x := <-ch: // 如果ch是nil,此case被忽略
handle(x)
default:
fmt.Println("channel disabled")
}
6.4 无case阻塞
空的 select{} 会永久阻塞(常用于main函数阻止退出)。**select{}和for{}阻塞有什么区别?
:select{}会释放线程M,让出CPU;for{} 是忙等待,占满CPU核心。for会不断check循环条件,所以会占用CPU资源。select的话,goroutine会因为阻塞自己挂起,然后休眠,不会让cpu利用率升高。存在default语句,select将不会阻塞,但是存在default会影响性能。
6.5 为什么select要随机
初始化阶段随机数生成器(用于后续 case 的随机选择顺序,避免饥饿)
6.6 性能开销
select比单独的channel操作更耗时(因需遍历和调度)
7. defer
- defer函数紧跟在资源打开后面,否则defer可能得不到执行,导致内存泄露。
- 场景:释放锁,关闭文件,关闭链接;捕获panic。
7.1 执行流程
- defer,return,return value(函数返回值) 执行顺序:首先return,其次return value,最后defer。
- defer可以修改函数最终返回值,修改时机:有名返回值或者函数返回指针。
- 多个defer调用顺序是LIFO(后入先出)可以理解为压入栈中。每个defer语句都对应一个
_defer实例,多个实例使用指针连接起来形成一个单连表,保存在gotoutine 数据结构中,每次插入_defer实例,均插入到链表的头部,函数结束再一次从头部取出,从而形成后进先出的效果。
7.2 原理
| Go 1.13(栈分配) | Go 1.14+(开放编码) |
|---|---|
尝试将 _defer 结构体分配在当前函数的栈帧上,如果 defer 出现在循环或无法静态分析的情况下(如条件判断),编译器会退回到使用旧的堆分配方式。 |
编译时直接插入调用代码 |
| 大部分静态 defer | 简单、少量的 defer |
| 后备机制 | 首选机制 |
7.3 如何用C语言模拟这种机制
step1:首先需要用链表管理defer,参考go需要哪些参数在:
type _defer struct {
siz int32 // 参数和结果的内存大小
started bool // 是否已执行
sp uintptr // 调用者栈指针(用于区分调用层级)
pc uintptr // 程序计数器
fn *funcval // 要执行的函数
_panic *_panic // 关联的 panic
link *_defer // 指向下一个 defer
}
- 函数指针:指向需要被延迟执行的函数。
- 参数地址:存储被延迟函数的参数。在
defer语句执行时,参数会立刻被计算并拷贝到_defer结构体的特定位置。这就是为什么defer对参数的求值是“立即”的,但函数执行是“延迟”的。 - 链表指针:指向下一个
_defer结构体,形成一个链表。 - PC/SP 寄存器值:存储程序计数器(PC)和栈指针(SP)。这至关重要,它记录了
defer语句所在的作用域。当函数返回时,运行时通过比较当前的 SP 与_defer中记录的 SP,来判断哪些defer需要在该函数返回时执行(即,只执行当前函数栈帧对应的defer)。
step2:需要一个全局的或线程局部的(Thread-Local)栈(可以用链表实现)来存储上述结构体。
step3:手动在作用域出口执行延迟调用。
但是编译时直接把代码段插入好像没法在C实现,c的编译器没有那种功能。