golang
基础部分
uint8 类型溢出会发生什么:
package main import "fmt" func main() { var a uint8 = 255 var b uint8 = 1 fmt.Println(a + b) // 输出:0 var c uint8 = 1 var d uint8 = 2 fmt.Println(c - d) // 输出:255 }
go 中的 byte 和 rune 类型分别是用来做什么的:
byte 等同于 int8 ,常用来处理 ascii 字符;rune 等同于 int32 ,常用来处理 unicode 或 utf-8 字符
package main import "fmt" func main() { a := "abc" fmt.Println(len([]byte(a))) // 输出:3 b := "你好吗" fmt.Println(len([]rune(b))) // 输出:3 }
单引号、双引号、反引号的区别:
单引号:表示 byte 或 rune 类型,默认是 rune 双引号:表示字符串 反引号:表示字符串字面量
go 里面的 _ 用法:
忽略返回值:
a, _ := myFunc()
interface 断言:
package main type I interface { say() } type A struct { } type B struct { } func (a A) say() { } func main() { var _ I = A{} //var _ I = B{} // 此处编译器会报红 }
用来导入包:
import _ "some/package"
引入包时,仅让导入的包做初始化,而不使用包中的其他功能
go 出现 panic 的场景有哪些:
数组、slice 越界 访问一个 nil 结构体指针的成员 除以 0 向已经关闭的 chan 发送消息 重复关闭 chan 关闭未初始化的 chan 向未初始化的 map 赋值 interface{} 断言类型不匹配
make 和 new 的异同:
- 相同点:
都是用来给变量分配内存的
- 不同点:
- 相同点:
关键字 | 返回值 | 作用类型 | 空间 | 内存分配位置 |
---|---|---|---|---|
make | 返回引用类型 | slice、map、chan | 初始化空间 | 栈 |
new | 返回指针类型 | 任意类型 | 空间被清零 | 堆 |
go 语言中的引用类型包含哪些:
slice map chan interface{} func()
空 struct 的用途:
- 说明:
空 struct{} 不占空间,用作占位符
- 应用:
- 只使用 map 中的 key 做唯一值校验(例:uniqueKey := make(map[string]struct{}))
- 实现不发送数据的 chan(例:<-ch // ch := make(chan struct{}))
- 实现仅包含方法,不包含属性的类(例:type Animal struct{})
- 说明:
go 如何处理服务器异常退出:
原理:
监听异常信号,当程序被杀死之前做一些清理工作
代码实现:
package main import ( "fmt" "os" "os/signal" "syscall" "time" ) func main() { quit := make(chan os.Signal, 1) // 退出信号 signal.Notify(quit, syscall.SIGKILL, syscall.SIGQUIT, syscall.SIGINT, syscall.SIGTERM) go func() { <-quit // todo: 做一些程序清理方面的工作,如:关闭数据库连接、关闭服务器 fmt.Println("程序被杀死!") os.Exit(0) }() for { fmt.Println("程序运行中。。。") time.Sleep(time.Second) } }
go 如何在同一个进程启动多个服务:
原理:
使用 errgroup 包,监听多个服务
代码实现:
package main import ( "fmt" "golang.org/x/sync/errgroup" "time" ) func main() { var g errgroup.Group g.Go(func() error { for { time.Sleep(3 * time.Second) fmt.Println("server 1 is running...") } }) g.Go(func() error { for { time.Sleep(5 * time.Second) fmt.Println("server 2 is running...") } }) if err := g.Wait(); err != nil { panic(err) } }
go 如何实现位图:
数组和位图的存值方式对比:
- 数组:arr[0] = 1, arr[1] = 1, …
- 位图:bm.Set(0) -> arr[index:0] = position:00000001, bm.Set(1) -> arr[index:0] = position:00000010, … bm.Set(7) -> arr[index:0] = position:10000000, bm.Set(8) -> arr[index:1] = position:00000000 00000001, …
代码实现:
package main import ( "fmt" "strconv" ) type BitMap struct { bits []uint8 // 底层数组:uint8 最大存储 255,换成二进制表示为: 0000 0000(8位),即每个索引可以存储 8 个数值 capacity int // 存储容量 } func NewBitMap(capacity int) *BitMap { bits := make([]uint8, capacity/8+1) return &BitMap{ bits: bits, capacity: capacity, } } // 求出索引和位置,并将指定位置为 1 func (b *BitMap) Set(num uint) { index := num / 8 position := num % 8 b.bits[index] = b.bits[index] | (1 << position) // 左移、按位或运算 } // 求出索引和位置,并将指定位置为 0 func (b *BitMap) Remove(num uint) { index := num / 8 position := num % 8 b.bits[index] = b.bits[index] &^ (1 << position) // 左移、按位清除运算 } // 求出索引和位置,并判断指定位是否为 1(即:不为 0) func (b *BitMap) IsSet(num uint) bool { index := num / 8 position := num % 8 decimalNum := b.bits[index] & (1 << position) // 左移、按位与运算 return decimalNum != 0 } func (b *BitMap) Cap() int { return b.capacity } // 格式化输出 func (b *BitMap) Fmt() []string { arr := make([]string, len(b.bits)) for key, val := range b.bits { binary := strconv.FormatInt(int64(val), 2) arr[key] = fmt.Sprintf("%08v", binary) } return arr } func main() { bm := NewBitMap(15) bm.Set(0) bm.Set(1) bm.Set(7) bm.Set(8) /// index:0 index:1 ... /// 00000000 00000000 ... /// |||||||| |||||||| /// 76543210 ...98 -> position fmt.Println(bm.Fmt()) // 输出:[10000011 00000001] bm.Remove(1) fmt.Println(bm.Fmt()) // 输出:[10000001 00000001] fmt.Println(bm.IsSet(7)) // 输出:true fmt.Println(bm.IsSet(1)) // 输出:false }
slice
slice 的底层数据结构是什么样的:
- 数据结构:
type slice struct { array unsafe.Pointer // 底层数组 len int // 长度 cap int // 容量 }
- 使用 make 创建 slice:
- 通过截取数组、slice 创建 slice:
- 数据结构:
slice 扩容机制:
数组和 slice 的区别:
- 相同点:
- 只能存储一组相同类型的数据结构
- 都是通过下标来访问,并且有长度和容量
- 在函数传参中,数组、slice 都是值传递
- 不同点:
- 相同点:
数据类型 | 长度 | 类型 | 声明方式 |
---|---|---|---|
数组 | 固定长度 | 值类型 | [n]int |
slice | 可变长度 | 引用类型 | []int |
go 中数组和 slice 作为函数参数的异同:
相同点:
在函数传参中,数组、slice 都是值传递
不同点:
使用数组作为函数参数的时候,在函数内部对数组进行修改并不会影响原数据
使用 slice 作为函数参数的时候,在函数内部通过对 slice 的下标进行赋值会影响到原数据(append 和截取操作不会影响),因为 slice 和其副本共享底层数组
package main import "fmt" func test1(arr []int) { arr[0] = 5 } func test2(arr []int) { arr = append(arr, 1) } func test3(arr []int) { arr = arr[:2] } func main() { arr := []int{1, 2, 3} fmt.Println("old:", arr) test1(arr) fmt.Println("test1:", arr) test2(arr) fmt.Println("test2:", arr) test3(arr) fmt.Println("test3:", arr) }
使用 slice 作为函数参数的时候,如果我们不希望原数据被修改,可以使用 copy 函数复制 slice 后再传入;如果希望原数据被修改,应该使用指针传递的方式
map
map 的底层数据结构是什么样的:
数据结构:
type hmap struct { count int // 当前保存的元素个数 B uint8 // 确定 buckets 数组的大小(数组长度 = 2^B) buckets unsafe.Pointer // bucket 数组指针,数组长度 = 2^B } // bucket 数据结构 type bmap struct { tophash [8]uint8 // 存储哈希值的高 8 位 data byte[1] // key-value 数据: key1/key2/key3/.../value1/value2/value3... overflow *bmap // 溢出 bucket 的地址 }
查找过程:
假设当前 B=4 即桶数量为 2^B=16 个,要从 map 中获取 k4 对应的 value
插入过程:
- 对 key 进行哈希运算,得出高 8 位和低 4 位的值
- 通过低 4 位确定桶的位置
- 在 keys 中找到第一个可以插入的位置存储数据
- 在 tophash 中记录插入位置的高 8 位
- 如果当前桶元素已满,就去溢出桶链接创建出一个新的桶来存储数据
解决哈希冲突的方法有哪些:
- 链地址法:将所有哈希地址相同的记录都链接在同一链表中
- 开放地址法:在遇到哈希冲突时,去寻找一个新的空闲的位置去存储数据
- 再哈希法:构造多个不同的哈希函数,等发生哈希冲突时就使用第二个、第三个…等其他的哈希函数计算地址,直到哈希地址对应的位置空闲时为止
map 是如何解决哈希冲突的:
go 使用链地址法来解决键冲突
map 扩容条件:
负载因子 = count(当前保存的元素个数) / buckets数组长度
- 负载因子 > 6.5 时,即平均每个桶存储的键值对大于 6.5 个,触发增量扩容
- 溢出桶数量 > 2^15 时,触发等量扩容
map 是如何实现扩容的:
- 增量扩容(负载因子过高):
- 等量扩容(溢出桶数量过多):
- 增量扩容(负载因子过高):
map 循环是有序的吗:
不是有序的。原因是:
- map 通过哈希算法保存 key 底层的 bucket 位置是随机的
- map 在扩容时,key 的存储位置有可能会发生变化
map 中删除一个 key ,它的内存会释放吗:
- 删除的元素是值类型(如:int、float、bool、string、数组、struct)时,内存不会释放
- 删除的元素是引用类型(如:slice、map、chan)时,内存会释放
- 将 map 置为 nil 后,内存会释放
map 是并发安全的吗?如何实现一个并发安全的 map:
不是并发安全的
- 使用读写锁
- 使用 sync.Map
nil map 和 空 map 有什么异同:
- 相同点:
- 都可以取值,取出来的值是类型对应的0值
- 打印出来的结果都是 map[]
- 不同点:
nil map 不能存值,否则会报 panic;空 map 可以正常存值
- 相同点:
map 的 key 支持哪些类型:
- 可比较类型(支持):
bool、int、float、string、interface、数组、chan、struct
- 不可比较类型(不支持):
slice、map、func
- 可比较类型(支持):
interface
iface 和 eface 的区别:
区别:
iface 描述的 interface 包含方法,eface 是不包含任何方法的空 interface
iface 数据结构:
type iface struct { tab *itab data unsafe.Pointer // 指向 interface 具体值的指针 } type itab struct { inter *interfacetype // 描述 interface 的类型 _type *_type // interface 所承载的实体类型,包含内存对齐方式、大小... fun [1]uintptr // 放置 interface 方法对应的具体数据类型的方法地址 } type interfacetype struct { typ _type // interface 所承载的实体类型,包含内存对齐方式、大小... pkgpath name // 定义了 interface 的包名 mhdr []imethod // interface 所定义的函数列表 }
eface 数据结构:
type eface struct { _type *_type // interface 所承载的实体类型,包含内存对齐方式、大小... data unsafe.Pointer // 指向 interface 具体值的指针 }
interface 的动态类型和动态值是什么:
iface 和 eface 源码中都包含 _type 和 data 字段。_type 表示 interface 的动态类型;data 表示 interface 的动态值。仅当动态类型和动态值都为 nil 的情况下,才会被认为 interface值 == nil
以下代码输出结果是什么?为什么:
package main import "fmt" func main() { var i interface{} = (*int)(nil) fmt.Println(i == nil) }
输出结果是 false,因为 interface 的动态类型为 *int 。只有当 interface 的动态类型和动态值都为 nil 的情况下,才会被认为 interface值 == nil
实现了 interface 方法的 struct,值接收和指针接收的区别:
区别:
- 值接收方法,会生成一个指针接收方法
- 指针接收方法,不会生成值接收方法
代码实现:
package main import "fmt" type Animal interface { walk() run() } type Cat struct { } func (c Cat) walk() { // 值接收 fmt.Println("cat is walking...") } //// 会自动生成如下方法 //func (c *Cat) walk() { // fmt.Println("cat is walking...") //} func (c *Cat) run() { // 指针接收 fmt.Println("cat is running...") } func main() { var obj Animal = &Cat{} // 正常 //var obj Animal = Cat{} // 报错 obj.walk() obj.run() }
interface 如何实现多态:
package main import "fmt" type Animal interface { walk() } func AnimalWalk(obj Animal) { // 声明 interface 作为参数 obj.walk() } type Cat struct { } func (c Cat) walk() { fmt.Println("cat is walking...") } type Dog struct { } func (d Dog) walk() { fmt.Println("dog is walking...") } func main() { cat := Cat{} dog := Dog{} AnimalWalk(cat) // 传入 Cat 类 AnimalWalk(dog) // 传入 Dog 类 }
context
context 的底层数据结构是什么样的:
数据结构:
type Context interface { Deadline() (deadline time.Time, ok bool) // 返回 deadline 和是否已设置 deadline 的 bool 值。若没有设置 deadline,则 ok == false Done() <-chan struct{} // 当 context 关闭后,返回一个被关闭的 chan;当 context 未关闭时,返回 nil Err() error // 当 context 关闭后,返回 context 的关闭原因;当 context 未关闭时,返回 nil Value(key interface{}) interface{} // 根据 key 值查询 map 中的 value }
各方法的依赖关系:
context 的使用场景有哪些:
- 控制嵌套的 goroutine 在满足条件时结束(context.WithCancel())
- 在嵌套的 goroutine 之间传递数据(context.WithValue())
- 嵌套的 goroutine 超时控制(context.WithTimeout() 或 context.WithDeadline())
chan
CSP 并发编程模型是什么:
不同进程间通过共享内存进行通信
chan 的底层数据结构是什么样的:
- 数据结构:
type hchan struct { qcount uint // 循环队列中剩余元素个数 dataqsiz uint // 循环队列长度 buf unsafe.Pointer // 指向底层循环队列的指针 sendx uint // 发送数据的下标位置 recvx uint // 读取数据的下标位置 sendq waitq // 向该 chan 写消息的 goroutine 队列 recvq waitq // 向该 chan 读消息的 goroutine 队列 lock mutex // 互斥锁,保证读写 chan 时不存在并发问题 ... }
- 图示:
- 数据结构:
chan 的读写特性有哪些:
- 给一个 nil chan 发送数据,造成永远阻塞
- 从一个 nil chan 接收数据,造成永远阻塞
- 给一个已经关闭的 chan 发送数据,引起 panic
- 从一个已经关闭的 chan 接收数据,如果缓冲区中为空,则返回一个零值
chan 的使用场景有哪些:
- 消息传递
- 事件订阅与发布
- 任务分发
- 结果汇总
- 并发量控制
- 限流
- 异步处理
chan 是线程安全的吗:
是线程安全的。因为 chan 底层在从循环队列读写值的时候加了互斥锁
有缓冲 chan 和无缓冲 chan 的区别:
无缓冲的 chan 是同步(阻塞)的,有缓冲的 chan 是非同步(非阻塞)的
chan 是分配在栈上还是堆上:
由于 chan 被设计用来在 goroutine 之间做通信,其作用域和生命周期不可能仅限于某个函数内部,所以 go 直接将其分配在堆上
GMP
什么是 IO 多路复用:
单个线程可以对一组文件描述符(fd)进行相关事件的注册,然后阻塞等待某些事件的发生或等待超时
select、poll、epoll 的区别:
关键字 | 处理方式 | 是否有最大连接数限制 | 时间复杂度 |
---|---|---|---|
select | 轮循 | 是 | O(n) |
poll | 轮循 | 否 | O(n) |
epoll | 基于事件 | 否 | O(1) |
GMP 调度原理:
G 代表着 goroutine,P 代表着上下文处理器,M 代表 thread 线程
GMP 为什么要引入 P:
- 每个 P 有自己的本地队列,减轻了对全局队列的直接依赖,减少了锁的竞争
- 通过 Work Stealing(工作量窃取机制)算法,如果 P 的本地队列为空,会从全局队列或其他 P 的本地队列中窃取 G 来运行,减少空转,提高了资源利用率
GMP 调度器的设计策略:
- 复用线程:避免频繁的创建、销毁线程 M,而是对线程 M 的复用
- work stealing(工作量窃取)机制:当本线程 M1 无可运行的 G 时,尝试从其他线程 M2 绑定的 P 偷取 G,而不是销毁线程 M1
- hand off(移交)机制:当一个线程 M1 因为 G 阻塞时,会释放绑定的 P,把 P 转移给其他空闲的线程 M2 执行
- 并行处理:设置 GOMAXPROCS 个 P,使同一时间有 GOMAXPROCS 个线程 M 分布在多个 CPU 上同时运行
- 主动让出机制:一个 goroutine 最多占用 CPU 10ms,防止其他 goroutine 被饿死
- 全局 G 队列:当 M 执行 work stealing 从其他 P 偷不到 G 时,会从全局队列中获取 G
- 复用线程:避免频繁的创建、销毁线程 M,而是对线程 M 的复用
GMP 调度器的生命周期:
锁
原子操作与互斥锁的区别:
- 原子操作是无锁的,是通过 CPU 指令直接实现的,比互斥锁轻量、快捷
- 互斥锁是在多个 goroutine 竞争的时候只允许一个 goroutine 获得执行权限,其它 goroutine 会被阻塞。性能方面不如原子操作
什么是乐观锁,什么是悲观锁:
- 乐观锁:
乐观锁在操作时不会上锁,只在更新的时候判断一下在此更新期间数据是否有变化,如果有变化则放弃操作,否则执行操作
- 悲观锁:
操作时把数据锁住,操作完成后才释放锁。上锁期间别人不能修改数据
- 乐观锁:
Mutex 的底层数据结构是什么样的:
- 数据结构:
type Mutex struct { state int32 // 互斥锁的状态,包含:Waiter、Starving、Woken、Locked sema uint32 // 信号量,用于唤醒被阻塞的 goroutine }
- 图示:
Waiter:表示被阻塞的 goroutine 个数,goroutine 解锁时用来判断是否需要释放信号量 sema Starving:是否处于饥饿状态,0.没有饥饿 1.饥饿状态,说明有 goroutine 阻塞超过了 1ms Woken:是否有 goroutine 已被唤醒,0.没有 goroutine 被唤醒 1.已有 goroutine 被唤醒,正在加锁过程中 Locked:是否已被锁定,0.没有被锁定 1.已被锁定
- 加锁、解锁过程:
- goroutine A 和 goroutine B 尝试加锁(B 被阻塞)
- goroutine A 解锁并通知 goroutine B 进行加锁
- 数据结构:
RWMutex 的底层数据结构是什么样的:
- 数据结构:
type RWMutex struct { w Mutex // 互斥锁 readerCount int32 // 正在进行读操作的 goroutine 个数 }
- Lock()过程:
- Unlock()过程:
- RLock()过程:
- RUnlock()过程:
- 数据结构:
RWMutex 哪些场景会阻塞,哪些场景不会阻塞:
- 阻塞场景:
- 写锁阻塞写锁
- 写锁阻塞读锁
- 读锁阻塞写锁
- 不阻塞场景:
- 读锁不阻塞读锁
- 阻塞场景:
Mutex 有几种模式:
有两种模式:正常模式和饥饿模式
- 正常模式:
正常模式下如果一个 goroutine 加锁不成功会启动自旋过程
- 饥饿模式:
饥饿模式下 goroutine 加锁不成功不会启动自旋过程,会阻塞等待其它 goroutine 释放信号量后被唤醒
- 正常模式:
Mutex 自旋的条件:
- 自旋的总次数不能超过 4 次
- CPU 核心数要大于 1
- GOMAXPROCS 值要大于 1
- GMP 中的 goroutine 队列必须为空
并发
go 如何保证一组 goroutine 按顺序执行:
实现原理:
利用无缓冲的 chan 是同步(阻塞)这一特性,来实现顺序控制
代码实现:
package main import ( "fmt" "time" ) func main() { a := make(chan struct{}) b := make(chan struct{}) c := make(chan struct{}) go func() { <-b fmt.Println("B") close(c) time.Sleep(time.Second) }() go func() { <-a fmt.Println("A") close(b) time.Sleep(time.Second) }() go func() { <-c fmt.Println("C") }() close(a) time.Sleep(3 * time.Second) }
如何通过 chan 控制并发数:
实现原理:
利用有缓冲的 chan 在缓冲区满时写阻塞和缓冲区空时读阻塞的特性,来实现并发数控制
代码实现:
package main import ( "fmt" "time" ) func main() { count := 2 // 并发数 sum := 10 // 任务总数 ch := make(chan struct{}, count) // 控制并发的 chan defer close(ch) for i := 0; i < sum; i++ { ch <- struct{}{} // chan 缓存区满时,写阻塞。此时只有 goroutine 可以运行 go func(num int) { fmt.Println(num) time.Sleep(time.Second) <-ch // chan 缓存区空时,读阻塞。此时只有 chan 从外层循环接收数据 }(i) } time.Sleep(time.Second) }
go 中多个 goroutine 之间如何进行通信:
- 通过全局变量 + 锁来实现
- 通过 chan 来实现
GC
go 是如何实现 GC 的:
- 标记清除算法(go:v1.3):
- 回收过程:
- 实施流程:
- 回收过程:
- 三色标记法(go:v1.5):
- 屏障的作用:
避免程序运行过程中,变量被误回收;减少 STW 的时间
- 强三色不变式:
- 弱三色不变式:
- 插入屏障:
满足强三色不变式,仅作用于堆中,会对栈进行 STW
- 删除屏障:
满足弱三色不变式
- 执行流程:
- 屏障的作用:
- 混合写屏障(go:v1.8):
- 满足变形的弱三色不变式
- GC 开始时将栈上的对象全部扫描并标记为黑色,无需 STW
- 把在栈上创建的对象标记为黑色
- 把被删除的对象标记为灰色
- 把被添加的对象标记为灰色
- 标记清除算法(go:v1.3):
GC 的触发时机:
- 手动触发:调用 runtime.GC
- 被动触发:
- 使用步调(Pacing)算法:当分配内存时,如果检测到内存较之前扩大了一倍,则启用 GC
- 使用系统监控:当超过两分钟没有产生任何 GC 时,强制触发 GC
defer 特性:
defer 的执行顺序:
结论:
先进后出
代码演示:
package main import "fmt" func main() { defer func() { fmt.Println("A") }() defer func() { fmt.Println("B") }() defer func() { fmt.Println("C") }() }
输出:
C B A
defer 与 return 执行顺序:
结论:
return 先执行,defer 后执行
代码演示:
package main import "fmt" func test() int { defer func() { fmt.Println("defer called") }() return func() int { fmt.Println("return called") return 0 }() } func main() { test() }
输出:
return called defer called
通过 defer 修改有名函数返回值:
结论:
有名函数返回值作用域在整个函数中,defer 要晚于 return 执行。所以 defer 可以修改有名返回值
代码演示:
package main import "fmt" func test() (num int) { defer func() { num = num + 1 // 在 return 后执行,将全局作用域变量 num + 1 }() return num // return 先执行,此时 num = 0 } func main() { num := test() fmt.Println(num) }
输出:
1
defer 遇见 panic:
结论:
defer 会在 panic 之后执行
代码演示:
package main import "fmt" func main() { defer func() { if err := recover(); err != nil { fmt.Println(err) } fmt.Println("捕获异常-1") }() defer func() { fmt.Println("A") }() defer func() { if err := recover(); err != nil { fmt.Println(err) } fmt.Println("捕获异常-2") }() defer func() { fmt.Println("B") }() panic("panic called") // 调用 panic 之后的代码永远不会执行 defer func() { fmt.Println("C") }() }
输出:
B panic called 捕获异常-2 A 捕获异常-1
在 defer 中调用 panic:
结论:
recover 只能捕获最后一个 panic
代码演示:
package main import ( "fmt" ) func main() { defer func() { if err := recover(); err != nil { fmt.Println(err) } fmt.Println("捕获异常") }() defer func() { panic("defer panic 1") }() defer func() { panic("defer panic 2") }() panic("main panic") // 在函数中抛出异常 }
输出:
defer panic 1 捕获异常
函数作为 defer 的参数:
结论:
函数作为 defer 的参数会先执行该函数,以获取该函数的返回值,然后再按 defer 的先进后出的顺序执行
代码演示:
package main import "fmt" func test(a int, b int) int { fmt.Println(a) return a } func main() { defer test(1, test(3, 0)) // 函数作为 defer 的参数会先执行该函数,以获取该函数的返回值 defer test(2, test(4, 0)) }
输出:
3 4 2 1
内存
- 堆和栈的区别:
内存区 | 管理方式 | 空间大小 | 生长方向 | 内存地址是否连续 | 分配效率 |
---|---|---|---|---|---|
栈 | 由系统分配和释放 | 小 | 向下,由高地址指向低地址 | 是 | 高 |
堆 | 需要在程序中申请和释放 | 大 | 向上,由低地址指向高地址 | 否,容易产生内存碎片 | 低 |
程序在什么时候会把内存分配到堆中?什么时候会把内存分配到栈中:
- 如果在函数外存在引用,则必定放到堆中
- 如果在函数外没有引用,则优先放到栈中
go 中的内存对齐:
原理:
示例:
代码:
package main import ( "fmt" "unsafe" ) type T1 struct { a bool // 1 b int32 // 4 c string // 16 } type T2 struct { a int32 // 4 b string // 16 c bool // 1 } func main() { var t1 T1 var t2 T2 fmt.Println("t1:", unsafe.Sizeof(t1)) // 输出:t1: 24 fmt.Println("t2:", unsafe.Sizeof(t2)) // 输出:t2: 32 }
T1 内存布局:
T2 内存布局:
go 中 uintptr 和 unsafe.Pointer 的区别:
- uintptr: 指针运算类型,它跟指针对象不能互相转换
- unsafe.Pointer: 通用指针类型,可以跟指针对象互相转换,但是不能参与运算
深拷贝和浅拷贝的区别:
- 深拷贝:拷贝的是数据本身,即创造一个新对象,并开辟一个新的内存地址。与原对象完全独立,修改新对象时不会影响原对象的值。释放内存时,新旧对象的内存没有任何关联
- 浅拷贝:拷贝的是数据地址,只复制对象的指针,新旧对象的内存地址是一样的,修改一个另一个也会变。释放内存时,新旧对象的内存同时释放
go 中哪些数据类型是浅拷贝:
- slice
- map
- chan
如何对引用类型进行深拷贝:
使用 gob 对类型进行编解码
package main import ( "bytes" "encoding/gob" "fmt" ) type Test struct { Position []int } func DeepCopyByGob(dst, src interface{}) error { var buffer bytes.Buffer if err := gob.NewEncoder(&buffer).Encode(src); err != nil { return err } return gob.NewDecoder(&buffer).Decode(dst) } func main() { t1 := Test{[]int{1, 2, 3}} fmt.Println("修改前-t1:", t1) t2 := t1 // 浅拷贝 var t3 Test err := DeepCopyByGob(&t3, t1) // 深拷贝 if err != nil { panic(err) } t1.Position[0] = 4 fmt.Println("修改后-t1:", t1) fmt.Println("浅拷贝-t2:", t2) fmt.Println("深拷贝-t3:", t3) m1 := map[string]interface{}{"name": "a"} fmt.Println("修改前-m1:", m1) m2 := m1 // 浅拷贝 var m3 map[string]interface{} err = DeepCopyByGob(&m3, m1) // 深拷贝 if err != nil { panic(err) } m1["name"] = "b" fmt.Println("修改后-m1:", m1) fmt.Println("浅拷贝-m2:", m2) fmt.Println("深拷贝-m3:", m3) }
go 中的内存分配模型:
- 模型:基于 TCMalloc
- 图示:
go 什么情况下会发生内存泄露:
- goroutine 被阻塞无法退出
- Mutex 未释放或发生死锁
- time.Ticker 未调用 Stop 方法
怎么定位排查内存泄露问题:
使用 pprof 工具进行采样分析
什么是内存逃逸:
本该分配到栈上的变量,跑到了堆上,就导致了内存逃逸
什么情况下会发生内存逃逸:
- 方法内返回局部变量指针
- 向 chan 发送指针数据
- 在闭包中引用包外的值
- 在 slice 或 map 中存储指针
- slice(扩容后)长度太大
如何进行逃逸分析:
在编译或运行时使用 -gcflags=-m 参数
for-range 循环的时候内存地址会发生变化吗:
结论:
for-range 循环的时候,key、value 的内存地址不会发生变化
我们在 for-range 循环里面使用 goroutine 的时候,不要直接把 key 或 value 传给 goroutine,而是应该把 key 或 value 赋值给一个临时变量后再传给 goroutine
我们在 for-range 循环里面进行引用赋值的时候,不要直接使用 value,而是应该把 value 赋值给一个临时变量后再进行引用赋值操作
示例代码1:
package main import ( "fmt" "time" ) func main() { arr := []int{1, 2, 3} for key, value := range arr { // 错误做法:直接将 key、value 放入 goroutine 中运行 go func() { fmt.Println("key:", key, "value:", value) }() // 正确做法:将 key、value 赋值给临时变量 tmpKey、tmpValue,然后将 tmpKey、tmpValue 放入 goroutine 中运行 tmpKey := key tmpValue := value go func() { fmt.Println("tmpKey:", tmpKey, "tmpValue:", tmpValue) }() } time.Sleep(time.Second) }
示例代码2:
package main import "fmt" type Student struct { Name string Age int } func main() { m := make(map[string]*Student) students := []Student{ {"A", 1}, {"B", 2}, {"C", 3}, } for _, value := range students { // 错误做法:直接将 value 的引用赋值给 m m[value.Name] = &value // 正确做法:将 value 赋值给临时变量 tmpValue,然后将 tmpValue 的引用赋值给 m tmpValue := value m[tmpValue.Name] = &tmpValue } for k, v := range m { fmt.Println(k, "=>", v.Name) } }
使用 for-range 迭代 slice 时,可以直接修改值吗?正确的修改方式是什么:
结论:
不可以,因为for-range是值拷贝。正确的修改方式是使用索引赋值
代码:
package main import "fmt" func main() { arr := []int{1, 2, 3} for k, v := range arr { arr[k] = 2 * v } fmt.Println(arr) }
编译
- go 的编译过程是什么样的:
框架
gin 路由是怎么实现的:
- 实现原理:
基于基数树(压缩前缀树)的路由匹配
- 图示:
- 实现原理:
gin 中间件是怎么实现的:
实现原理:
基于洋葱模型实现的调用链函数数组
代码实现:
package main import ( "fmt" ) const maxIndex = 63 type HandlerFunc func(ctx *Context) // 调用链函数 type Context struct { HandlersChain []HandlerFunc // 调用链函数数组 index int8 } func (ctx *Context) Next() { if ctx.index < maxIndex { ctx.index++ ctx.HandlersChain[ctx.index](ctx) } } func (ctx *Context) Abort() { ctx.index = maxIndex fmt.Println("abort...") } func (ctx *Context) Use(f HandlerFunc) { ctx.HandlersChain = append(ctx.HandlersChain, f) } func (ctx *Context) Get(path string, f HandlerFunc) { ctx.HandlersChain = append(ctx.HandlersChain, f) } func (ctx *Context) Run() { ctx.HandlersChain[0](ctx) // 执行第一个函数 } func main() { ctx := &Context{} ctx.Use(middleware1) ctx.Use(middleware2) ctx.Get("/login", loginFunc) ctx.Run() } func middleware1(ctx *Context) { fmt.Println("middleware1 begin") // loginFunc 之前调用 ctx.Next() //ctx.Abort() fmt.Println("middleware1 end") // loginFunc 之后调用 } func middleware2(ctx *Context) { fmt.Println("middleware2 begin") // loginFunc 之前调用 ctx.Next() fmt.Println("middleware2 end") // loginFunc 之后调用 } func loginFunc(ctx *Context) { fmt.Println("loginFunc called") }
mysql
底层数据结构
B+树
:myisam 和 innodb 的区别有哪些?
存储引擎 | 是否支持事务 | 是否支持外键 | 索引类型 | 最小锁粒度 | 是否保存表的行数 |
---|---|---|---|---|---|
innodb | 支持 | 支持 | 聚簇索引 | 行锁 | 不保存 |
myisam | 不支持 | 不支持 | 非聚簇索引 | 表锁 | 保存 |
事务的4大特性(ACID)是什么:
- 原子性(Atomicity):
一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。通过 undo log 来实现;
- 一致性(Consistency):
在一个事务执行之前和执行之后数据库都必须处于一致性状态。通过 undo log、redo log 和隔离性共同实现;
- 隔离性(Isolation):
不同的事务同时操纵相同的数据时,每个事务都有各自的完整数据空间。通过加锁(当前读)和 MVCC(快照读)来实现;
- 持久性(Durability):
只要事务成功结束,它对数据库所做的更新就必须永久保存下来。通过 redo log 来实现;
- 原子性(Atomicity):
什么是
undo log
,什么是redo log
:undo log
:撤销日志。作用于原子性和一致性层,将旧的数据备份下来,用于失败回滚到旧的版本。
redo log
:重做日志。作用于持久性层,将新的数据备份下来,防止系统突然崩溃造成的数据丢失。
什么是分布式事务:
当采用微服务架构时,保证不同模块相互调用时事务的一致性。
分布式事务
CAP
理论是什么:- C (一致性):
如果在某个节点更新了数据,那么在其他节点都能读取到这个最新的数据。
- A (可用性):
要有合理的时间,即:请求应该在合理的时间给出返回;要有合理的响应,即:系统应该返回正确的结果。
- P (分区容错性):
集群中有一个节点出现了问题,这个集群仍然可以正常工作。
- C (一致性):
分布式事务
BASE
理论是什么:- Basically Available(基本可用):
允许损失部分功能,保证核心功能可用。
- Soft state(软状态):
允许存在中间状态,这个状态不影响系统可用性。
- Eventually consistent (最终一致性):
经过一段时间后,所有节点的数据都将达成一致。
- Basically Available(基本可用):
分布式事务方案对比:
对比维度 | XA | SAGA | TCC |
---|---|---|---|
实现原理 | 预提交-回滚机制 | 补偿机制 | Try-Confirm-Cancel机制 |
优点 | 强一致性,实现成本低 | 支持高并发 | 隔离性好,支持高并发 |
缺点 | 存在单点问题,不支持高并发 | 不能保证隔离性 | 实现复杂 |
适用场景 | 短事务,低并发场景 | 长事务,高并发场景 | 对隔离性有要求的短事务,高并发场景 |
数据库隔离级别:
读未提交;读已提交;可重复读;串行化
MVCC多版本并发控制:
MVCC是为了在事务中读取数据时不加锁来提高读取效率和并发性的一种手段。MVCC的本质是乐观锁
b+树和哈希的区别:
索引类型 | 数据结构 | 优点 | 缺点 |
---|---|---|---|
哈希 | 散列表 | 对等值查询效率高 | 不支持范围查询;无法利用索引完成排序;不支持最左匹配原则; |
b+树 | 多叉排序树 | 范围查询的效率高 | 占用存储空间 |
建立索引要注意什么:
- 需要加索引的字段,要在where条件中
- 数据量少的字段不需要加索引
区分度
低的字段不需要建索引,如:性别、状态- 如果where条件中是OR关系,加索引不起作用
- 符合最左原则
唯一索引和主键索引的区别:
索引类型 | 能否作为外键 | 是否允许空值 | 允许数量 |
---|---|---|---|
主键索引 | 能 | 不允许 | 一个 |
唯一索引 | 不能 | 允许 | 多个 |
mysql优化方案:
- 表设计优化:
- 创建合理的字段类型及字段长度
- 默认值避免使用
NULL
值 - 适当添加索引
- SQL语句优化:
- 避免使用
SELECT *
- 尽量用
JOIN
代替子查询(避免创建临时表) - 尽量用
UNION ALL
代替UNION
(省去了去重操作) - 使用模糊匹配时,满足最左匹配原则,如
LIKE '关键词%'
- 不要在
WHERE
条件中带有函数计算及类型转换
- 避免使用
- 架构方面优化:
- 分表
- 将读多写少的语句加入到查询缓存
- 采用分布式部署
- 表设计优化:
mysql锁表处理方式:
- 查看相应的进程:
SHOW PROCESSLIST;
- 杀进程:
KILL 进程ID
- 查看相应的进程:
如何解决
LIMIT
查询慢的问题:- 利用索引进行范围搜索,如:
SELECT * FROM product WHERE id > 20000 LIMIT 10
- 利用索引进行范围搜索,如:
EXPLAIN
关键字查看哪些字段:type
:访问类型,至少保证达到 range 级别,最好达到 refkey
:显示实际用到的索引rows
:估算出所优化的行数,越少越好
tinyint(1)
和int(1)
有什么异同:- 占用内存空间不同:
tinyint
占 1 个字节,int
占 4 个字节; - 显示宽度相同:
tinyint(1)
和int(1)
显示的宽度相同;
- 占用内存空间不同:
什么是联合索引的最左匹配原则:
(k1,k2,k3) == (k1) + (k1,k2) + (k1,k2,k3)
Mysql中的共享锁、排他锁:
- 共享锁:
SELECT ... LOCK IN SHARE MODE
- 排他锁:
INSERT ...
、DELETE ...
、UPDATE ...
、SELECT ... FOR UPDATE
- 共享锁:
什么是聚簇索引:
按照指定顺序进行检索的索引叫作聚簇索引,如InnoDB中的主键索引
什么是覆盖索引:
从非主键索引中就能查到记录,而不需要查询主键索引中的记录,避免了“回表”操作,减少了树的搜索次数,提升了检索的性能
redis
raft 一致性算法是如何实现的:
- 实现过程:
- 节点角色说明:
- 领导者(Leader):接受客户端请求,并向 Follower 同步日志,当日志同步到大多数节点后告诉 Follower 提交日志
- 追随者(Follower):接受并持久化 Leader 同步的日志,在 Leader 告之日志可以提交之后,提交日志
- 候选人(Candidate):选举过程中的临时角色
- 领导选举:
- 日志同步:
- 安全性:
成为 Leader 条件:拥有比大多数节点都新的 term-任期和 index-日志索引
- 日志压缩:
对已经提交的日志进行压缩,生成 snapshot
- 成员变更:
- 实现过程:
底层数据结构
跳表
:redis 基本数据结构及使用场景:
String
: 分布式锁;限制请求频率;查询缓存Hash
: 用户表缓存;购物车List
: 栈;队列Set
: 点赞;共同好友Zset
: 排行榜;热搜Bitmap
: 签到;活跃用户统计
redis 持久化方案 rdb 和 aof 的区别:
持久化方案 | 备份方式 | 存储方式 | 缺点 |
---|---|---|---|
rdb | 全量备份 | 二进制 | 存在丢失数据的情况 |
aof | 增量备份 | 文本文件 | 性能开销大 |
- redis 缓存穿透、缓存击穿、缓存雪崩的原理及预防措施:
问题 | 原理 | 预防措施 |
---|---|---|
缓存穿透 | 用户请求不存在的key | 将null值存入key中;使用布隆过滤器 |
缓存击穿 | 单个key失效 | 加锁控制读的频率;设缓存永不过期 |
缓存雪崩 | 多个key同时失效 | 加锁控制读的频率;设置不同的过期时间 |
单线程的 redis 为什么这么快:
- 纯内存操作,避免了io开销
- 单线程操作,避免了上下文切换和访问共享资源加锁的性能损耗
- 采用io多路复用机制,使同一线程可以同时操作多个句柄
redis 的删除策略的区别:
策略 | 原理 | 优点 | 缺点 |
---|---|---|---|
定时删除 | 设置过期时间的同时,创建定时器去删除key | 对内存友好 | 对CPU不友好 |
惰性删除 | 读取key时再去检查过期时间,执行删除操作 | 对CPU友好 | 对内存不友好 |
定期删除 | 每隔一段时间去检查过期的key | 对CPU和内存都相对友好 | 频率不好控制;有可能获取到过期的key |
redis 的淘汰策略:
noeviction
:所有key通用,内存满了不删除,直接返回错误(不删除策略
)allkeys-lru
:所有key通用,优先删除最近最少使用(LRU)的key(最近最少使用策略
)allkeys-random
:所有key通用,随机删除一部分key(随机删除策略
)volatile-lru
:只限于设置了过期时间的key,优先删除最近最少使用(LRU)的keyvolatile-random
:只限于设置了过期时间的key,随机删除一部分keyvolatile-ttl
:只限于设置了过期时间的key,优先删除剩余时间(TTL)短的key(剩余时间短策略
)
redis 慢查询配置:
配置名 | 含义 | 默认值 |
---|---|---|
slowlog-log-slower-than | 慢查询被记录的阈值(单位:微秒) | 10000 |
slowlog-max-len | 最多记录慢查询的条数 | 128 |
- redis pipeline、事务、lua 的区别:
项目 | 优势 | 是否具备原子性 | 是否可以得到上个命令的执行结果 | 缺点 |
---|---|---|---|---|
pipeline | 减少传输带宽 | 否 | 否 | 不具备原子性 |
事务 | 具备原子性 | 是 | 否 | 遇到运行时错误时(如类型转换失败)会继续执行,不能捕获该类型错误而回滚 |
lua 脚本 | 具备原子性;可以得到上个命令的执行结果 | 是 | 是 | 实现复杂 |
- redis 其它命令:
scan
命令:- 字符串:
SCAN 0 MATCH *search* COUNT 100
- 哈希表:
HSCAN hashKey 0 MATCH *search* COUNT 100
- 集合:
SSCAN setKey 0 MATCH *search* COUNT 100
- 有序集合:
ZSCAN zsetKey 0 MATCH *search* COUNT 100
- 字符串:
info
命令:- 查看服务器信息:
INFO SERVER
- 查看客户端信息:
INFO CLIENTS
- 查看内存信息:
INFO MEMORY
- 查看持久化信息:
INFO PERSISTENCE
- 查看统计信息:
INFO STATS
- 查看集群信息:
INFO CLUSTER
- 查看服务器信息:
monitor
命令:实时打印出 redis 服务器接收到的命令slowlog
命令:- 查看慢查询涉及的命令:
SLOWLOG GET
- 查看所有慢查询命令的条数:
SLOWLOG LEN
- 清空慢查询日志:
SLOWLOG RESET
- 查看慢查询涉及的命令:
mongodb
底层数据结构
B树
:什么是mongodb中”命名空间”:
数据库名称.集合名称 = 命名空间
mongodb中的分片是什么:
在多台计算机上存储数据记录的过程称为分片。
如何查看mongos正在使用的连接:
db.adminCommand("connPoolStats")
ObjectId 由什么组成:
ObjectId由:【4个字节的时间戳】+【3个字节的机器标识码】+【2个字节的进程ID】+【3字节递增数字】组成
查看是否在主服务器上的命令语法是什么:
db.isMaster()
mongodb允许多少个主服务器:
一个
elasticsearch
底层数据结构
倒排索引
:什么是elasticsearch:
elasticsearch 是基于 lucene 的 restful 的分布式实时全文搜索引擎,每个字段都被索引并可被搜索。
什么是倒排索引:
倒排索引是【关键词】到【文档ID】的映射
keyword
和text
类型的区别是什么:keyword
: 在保存时不会分词,直接根据字符串内容建立倒排索引,所以keyword
类型的字段只能通过精确值搜索到;text
: 在保存时会先进行分词操作,然后根据分词后的内容建立倒排索引,所以text
类型的字段可以进行模糊搜索。query
和filter
的区别是什么:query
: 在查询时会计算分值,用于确定相关度;filter
: 在查询时仅判断是否满足查询条件,不会计算分值,也不会关心返回的排序问题。但是filter
查询的结果可以被缓存,提高性能。elasticsearch的搜索阶段有哪些:
【Query阶段】和【Fetch阶段】
如何避免脑裂现象:
- 当集群中有2个以上节点时,设置最少投票通过数量为超过所有候选节点一半以上,即:(N / 2) + 1;
- 当集群中的节点小于等于2个时,设置只允许一个节点为主节点。
什么是”深度分页”:
"深度分页"是指搜索的深浅度,例如:第1页叫浅搜索,第99999页叫深搜索。我们应该通过限制分页页数来避免"深度分页"操作,如:限制搜索内容最多只能提供100页的展示。
什么是”滚动搜索”:
"滚动搜索"是指分页搜索,避免一次性取出大量数据带来的性能问题。
mysql、mongodb、elasticsearch 关键概念对比
mysql | mongodb | elasticsearch |
---|---|---|
数据库 | 数据库(db) | 索引(index) |
数据表 | 集合(collection) | 自定义类型(type) |
行 | 文档(document) | 文档(document) |
列(字段) | 键值对 | 键值对 |
查询语句(SQL) | mongodb命令 | DSL(Restful API) |
索引 | index | - |
- | 分片(shard) | 分片(shard) |
- | 复制集(replica set) | 副本(replica) |
rabbitmq
AMQP 协议分为哪3层:
模型层
:最高层,定义了一些客户端调用的命令;会话层
:中间层,负责客户端命令发送给服务器,再将服务端应答返回给客户端;传输层
:最底层,负责传输二进制数据流。AMQP 模型有哪3大组件:
交换器(Exchange)
:用于把消息路由到队列;队列(Queue)
:用来存储消息的数据结构;绑定(Binding)
:告知交换器将消息投递给哪个队列。什么是生产者:
表示投递消息的一方,包含消息体(payload)和标签(Label)
什么是消费者:
表示接收消息的一方,消费消息时只消费消息体,丢弃标签
交换器(Exchange)有哪 4 种类型:
fanout
:把所有发送到该交换器(Exchange)的消息路由(Route)到所有与该交换器绑定的队列中;direct
:把消息路由到【BindingKey】和【RoutingKey】完全匹配的队列中;topic
:根据规则进行匹配,【BindingKey】可使用 “*” 和 “#” 做模糊匹配;headers
:根据发送消息内容中的【headers】属性进行匹配,很少使用。交换器(Exchange)无法找到符合条件的队列时有哪些处理方式:
mandatory
设为 true 时,返回消息给生产者;mandatory
设为 false 时,直接丢弃消息什么是死信队列:
当消息在队列中变成死信(Dead Message)后,会被发送到死信交换器,跟死信交换器绑定的队列被称为死信队列
死信产生的原因是什么:
- 消息被拒;
- 消息过期;
- 队列已满。
RabbitMq 的事务机制是什么:
- 通过
channel.txSelect
将通道设置成事务模式; - 通过
channel.txCommit
提交事务; - 通过
channel.txRollback
回滚事务。
- 通过
RabbitMq 的发送确认(confirm)机制是什么:
生产者将通道设置成【confirm】模式后,发送的消息会指定一个唯一的ID,当消息发送成功后,RabbitMq 会发送一个确认(Basic.Ack)给生产者(包含消息的唯一ID)
消费者如何拒绝接收消息:
channel.basicNack
和channel.basicReject
消息传输的保证层级有哪 3 种:
最多一次
:消息可能会丢失,但不会重复传输;最少一次
:消息不会丢失,但可能会重复传输;恰好一次
:每条消息肯定仅传输一次。RabbitMq 中消息的存储方式有哪 4 种:
alpha
:消息内容和消息索引都保存在内存中;beta
:消息内容保存在磁盘中,消息索引保存在内存中;gamma
:消息内容保存在磁盘中,消息索引在内存和磁盘中都有;delta
:消息内容和消息索引都保存在磁盘中。
其它
大文件排序:
拆分排序+归并
什么tcp粘包/拆包,解决方法是什么:
- 产生原因:
粘包
:客户端将多个包一次性发到服务端;服务端没有及时接收到客户端的包,造成多个包同时接收
拆包
:客户端将一个包分多次发送 - 解决方案:
- 客户端添加包长度字段
- 客户端发送固定长度,服务端接收固定长度
- 在包之间设置特殊符号,标示边界
- 产生原因:
常见垃圾回收算法:
引用计数
标记清除
分代收集
算法名 | 优点 | 缺点 |
---|---|---|
引用计数 | 实现简单、判定效率高 | 不能处理循环引用 |
标记清除 | 可以处理循环引用 | 会暂停程序的执行(STW) |
分代收集 | 回收性能好 | 算法复杂 |
- 有状态服务和无状态服务的区别:
服务类别 | 代表资源 | pod 名称 |
存储方式 |
---|---|---|---|
无状态服务 | Deployment | 随机分配 | 共享存储 |
有状态服务 | StatefulSet | 按名称递增 | 每个pod有唯一的pv/pvc |