面试八股-golang
golang
部分内容参考《go 程序员面试笔试宝典》
go 有什么特点
特性
- 协程
- 静态编译,编译出的可执行文件体积小
- 有 runtime、垃圾回收、反射
- 指针
和其他语言相比
- go 作为静态编译语言,和 C/C++相比,go 有 runtime,有垃圾回收,有反射
- go 作为有 runtime 的语言,和 java 相比,是 go 静态编译的
- go 支持指针操作
引用类型
- 说明,go 中实际上没有引用这个机制,go 只有值传参,没有所谓的引用传参,我们只是用“引用”来形容一种现象,即传参后,对参数的修改会影响到原变量
- 6 种,slice、map、channel、指针、函数、interface
- 引用类型的零值是 nil
- 数组和结构体不是引用类型,它们的零值是其所有元素和成员的零值
切片(slice)
- 切片和数组
- 切片是对数组的引用,切片的底层数据结构是数组。
- 切片的长度是切片引用部分的长度,切片的容量是从引用起点到底层数组末尾的长度
- 切片能扩容,数组不能
- 切片如何扩容
- 追加元素时发现数组已满会引起扩容
- 扩容是给切片重新分配一个更大的数据,并将数据迁移过去
- 扩容后会预留一定的 buffer,以免每次追加元素都扩容
- 扩容策略:
当原 slice 容量小于 1024 的时候,新 slice 容量变成原来的 2 倍;原 slice 容量超过 1024,新 slice 容量变成原来的 1.25 倍以上(有一个计算公式)。
- 切片实际上上是一个结构体,因此,把切片作为参数传递的时候,如果修改它的底层数组,会影响原切片,但是如果切片发生了扩容,是直接把底层数组给换了,是不会影响到原切片的
- 切片不能比较
map
- map 底层是一个 hash 表,链式 hash,每个 hash 位置指向一个链表,每个链表节点长度为 8(能放 8 个 key-value 对)
- map 实际上是一个指向底层哈希表的指针,因此把 map 作为参数传递后,对参数做的修改都会影响原 map
- map 遍历是无序的,每次遍历的顺序不一样(因为对 hash 位置的遍历是随机的)
- map 扩容
- 扩容不是一个立即完成的操作,而是会持续一段时间,每次最多搬运两个 hash 链
- 两种扩容场景
- 所有的节点都快满了,这时要双倍扩容(扩容后的 hash 空间是原来的两倍),并且要重新计算元素的 hash 值(rehash)
- 元素总数很少,节点数量过多,map 退化为链表,这时要等量扩容,扩容后 hash 空间数量和原来相等,只是把数据存储变紧凑了。
- 不要用 float 做 key(语法上是可以的,因为 float 可比较),浮点数的精度问题会导致很多诡异的情况。
- map 不是并发安全的,sync.map 是并发安全的
- 无法对 map 的 key 或 value 进行取址
- 不能直接比较两个 map,只能通过遍历比较
接口
- 接口的作用
- 用于实现继承和多态
T和*T是两个类型,用T实现了一个接口,并不能认为*T也实现了。- 结论,实现了接收者是值类型的方法,相当于自动实现了接收者是指针类型的方法;而实现了接收者是指针类型的方法,不会自动生成对应接收者是值类型的方法
- 换句话说,值类型实现了接口 A,相当于指针类型也实现了接口 A;指针类型实现了接口 A,不会使值类型也实现了接口 A
- 类型断言的作用
- 提取动态值
- 转换接口类型
- 判断动态值是否能转换为某个类型(利用类型断言返回的 bool)
- 放在switch语句中简化判断结构
go runtime
- go runtime 位于应用程序和操作系统之间
- 负责应用程序的内存分配、垃圾回收、协程调度、channel 通信
- 负责创建 os 线程、调用 system call
go 的协程(goroutine)
- 协程是什么
- 和线程、进程一样是一种并发单位
- 和系统线程相比
- 更细粒度,线程的栈空间是固定的,而且比较大(linux 下为 8MB),而 goroutine 初始栈大小为 2KB,按需扩缩容,无上限
- 上下文切换开销小,线程运行在内核态,而协程运行在内核态
- go 协程没有供程序员访问的标识,而线程都有线程号,go 不鼓励类似 threadlocal 这样的东西
- goroutine 是如何调度的
- goroutine 由 go runtime 的 scheduler 调度
- goroutine 运行在系统线程上,是一种 m:n 模型
- 线程数通过 GOMAXPROCS 变量调整,默认的系统线程数是物理机的 cpu 核数
goroutine 调度
- go 调度器的最终目的是把 g 尽量均匀的分配到 m 上执行,兼顾公平和效率
- gmp 模型,go 使用三种基础的结构体实现 goroutine 的调度。
- g 代表一个 goroutine,包含:goroutine 栈、状态、指令地址(PC 寄存器的值)
- m 代表一个内核线程,包含正在运行的 goroutine
- p 代表一个虚拟的 processer,维护一个 runnable 状态的 g 队列,m 需要获得 p 才能运行 g
- 存放 g 的队列有两类,GRQ(全局可运行队列)和 LRQ(本地可运行队列),每个 p 都要关联一个 LRQ。
- go 设计的早期没有 p 和 LRQ,只有 g、m 和 GRQ,每次调度执行一个 g 都要对 GRQ 上锁,效率低下。p 和 LRQ 的设计是为了提高调度的效率。
- 工作窃取,如果一个 p 的 LRQ 中已经没有 g,GPQ 中也没有 g,p 会从别的 p 中“偷”一些 g 过来执行
- os 的线程调度是抢占式调度,而 goroutine 的调度是协作式调度(无法硬件中断),但调度是由 runtime 负责的,对用户来说,仍可将 goroutine 的调度视为抢占式调度
协作式调度依靠被调度方主动放弃执行;抢占式调度则依靠调度器强制中断被调度方的执行
- g 的三种状态,和线程类似
- waiting,等待状态(有时候被称为阻塞状态),由等待网络数据、硬盘 io、等待获取锁、系统调用、sleep
- runnable,可执行状态(就绪状态)
- running,运行状态
- m 只有两种状态,自旋状态(工作状态)和非自旋状态(休眠状态),m 会因为找不到工作、gc 等原因进入非自旋状态,其他时间都处于自旋状态。
- main goroutine 退出时会调用 exit(0)退出进程
channel
- channel 解决什么问题
- channel 用来在两个 goroutine 之间传递数据,类似一个FIFO队列
- 解决协程之间的并发同步问题
- 原则,不通过共享内存来通信,而是通过通信共享内存
- 是一种比锁层次更高的同步工具
- 从一个关闭且缓冲区数据读完的 channel 中仍能取到数据,只不过是零值
- 操作 channel 的情况总结 |操作| nil channel |closed channel |not nil, not closed channel| |---|---|---|---| |close |panic |panic |正常关闭 |读 <- ch| 阻塞| 缓冲区如果还有数据,会正常读取数据,否则读到对应类型的零值| 阻塞或正常读取数据。|缓冲型 channel 为空或非缓冲型 channel 没有等待发送者时会阻塞| |写 ch <-| 阻塞| panic| 阻塞或正常写入数据。非缓冲型 channel 没有等待接收者或缓冲型 channel buf 满时会被阻塞
- 使用时要注意防止资源泄露,比如有若干 goroutine 处于阻塞状态,而 channel 的状态一直没有得到改变
- channel 的应用
- 解耦生产者和消费者
- 广播停止信号
- 定时
- 控制并发数,把令牌放入缓冲区
- channel的底层实现是一个循环链表,记录了两个指针,读指针和写指针,利用一个mutex保证并发安全,保存两个协程队列,一个记录接收协程队列,一个记录发送协程队列
- 如果有协程需要读写channel时,先加锁,完成数据copy,然后解锁
- 如果buffer满了,有goroutine需要写怎么办,channel会调用调度器,让出goroutine(G1)在p上的执行权,并且将G1的指针和数据挂到发送队列上去,一旦有goroutine读数据,channel就会将等待队列的G1拿出,将其数据copy到buffer中,并通知调度器唤醒G1,然后调度器就把G1放到runnable队列中去
context
- context 是什么
- context 一般翻译成上下文,用来在 goroutine 之间传递上下文信息,包括停止信号、超时时间、截止时间、key-value 数据
- context 几乎是并发控制和超时控制的标准做法,很多接口都加上了 context 参数
- context 是一个接口,标准库实现了一些具体的 context 类型,包括 emptyCtx、valueCtx、cancelCtx、timerCtx,还有两个全局变量 background 和 todo
- 所有 context 会组成一个树状结构(只不过指向和树是相反的,子 context 指向父 context)
- 如何取消 context
- 父 context 持有一个 channel,可以对所有子 context 广播取消信号,一般会让每个协程携带一个 context,这样就可以把协程也组织成树状结构,父协程可以在请求超时、请求失败时及时向子协程发出终止信号,子协程通过调用 Done()方法获取 channel,并监听 channel 的取消信号
- 如何查找 value
- 从自己开始,顺着父 context,层层向上找
reflect
- 什么是反射
- 反射是一种程序在运行过程中观察自己、修改自己的一种机制,而且这种观察和修改和编译期间做不到的
- 什么时候用反射
- 不知道参数类型的时候
- 慎用反射的理由
- 反射会让代码可读性降低
- 反射性能差
指针
- 普通指针(*T),不能参与运算,不同类型不能比较,不能相互转换
- unsafe.Pointer 相当于一个通用指针,能在不同类型之间转换。
- uintptr 可以对指针进行算术运算,但是不会对指向的变量有引用语义,换言之,即使 uintptr 还存在,它指向的变量还是有可能被 gc(他就像 c 里面的指针)
go 堆和栈
go 对程序员隐藏了堆和栈的概念,变量分配在栈上还是堆上不取决于变量定义的位置和方法。编译器会对程序进行逃逸分析,如果一个变量在函数之外被引用了,那这个变量会被创建在堆上,否则在栈上
垃圾回收(GC)
- gc是回收堆内存的,而不是栈内存,栈内存在函数调用结束后就释放掉了不用专门回收
- 垃圾回收机制有两种,追踪式 GC 和引用计数式 GC,go 是追踪式 GC。
- 引用计数式GC,C++的shared_ptr就是一个典型的引用计数式资源回收
- 好处是不需要额外的gc任务,并且程序不用暂停
- 坏处是会出现循环引用,而且对象增加和减少引用时会增加大量计算
- 追踪式GC,是判断一个对象是否可达,如何判断一个对象是否可达,第一步,找出所有全局变量和函数栈中的变量,标记为可达,然后通过回溯的方式把所有可达的对象标记出来,那剩下的就是不可达的
- go 的 GC 算法是三色标记法
- 三色标记法是一种追踪式gc,它主要是为了解决gc会让整个程序停止的问题。其原则是它把堆中的对象根据它们的颜色分到不同集合里面。
- 黑白灰三色对象,初始全部都为白色对象(表示未检查过,是潜在垃圾)
- 每检查到一个白色对象,将其变为灰色,表示已经检查了这个对象,但是没有检查它的子对象
- 如果一个对象的所有直接子对象都被检查到了,就把它变成黑色
- 直到所有的灰色对象被清空,这时剩下的白色对象就可以被回收了
- 三色标记的特点是可以并发执行,依靠的是go的写屏障计数,这像是一个钩子方法,能够在创建对象的同时将其标记为灰色(保证不会让一个黑色对象直接指向一个白色对象)
- 内存泄漏有两种
- 对不再使用的内存一直有引用
- goroutine 泄露(goroutine 一直阻塞在一个 channel 上)
go 工具
- GOROOT 和 GOPATH 的区别
- GOROOT 是 go 安装的位置
- GOPATH 的作用是提供一个可以寻找 go 源码的路径,GOPATH 目录下需要包含 src、pkg、bin 三个目录,src 存放源文件,pkg 存放编译产出(库文件),bin 存放可执行文件
gin框架
- 主要概念
- engine、handelr、router、conetxt、bind
- engine,是一个gin框架的全局实例,RouterGroup(定义了一个路由组,保存了该路由组的中间件和请求路径)、Trees(包含所有路由前缀树,查找路由,根据请求方法对树分类。并保存了每个路由对应的handler)
- context -是handler会传入一个gin context指针,一般命名为c
- gin context实现了go的Context接口的四个函数,Done、Value、Deadline、Err,所以gin context也是一个context
- gin context定义了一个key map,调用Value时首先去这个key map里面找,还带了一个读写锁用来保护这个key map,可以并发读写key map(go原生的valuectx不能并发,只能被一个goroutine操作,除非你自己给它上锁)
- gin context定义了一个copy函数,启动新goroutine的时候可以拷贝
- request body和一个struct绑定以后会被清空,不能再次使用
- 多次绑定可以使用ShouldBindBodyWith
- http server
- 不要直接调用r.Run(),而是把r作为handler传入一个http.Server变量,调用http.Server的ListenAndServe()启动服务
- 如果想自定义端口、超时等配置,给http.Server的对应成员赋值即可
- 利用goroutine、信号、和http.Server的Shutdown()函数实现优雅退出
- gin handler(gin 中间件)
- 一个gin handler就是一个处理函数,gin在请求处理的某个阶段调用这些handler,handler相当于是请求处理的钩子函数
- handler负责打日志、鉴权、异常恢复等工作
- (请求处理函数本身其实也是个handler)
- handler分局部和全局的,局部handler对一个路由生效,全局handler对所有路由生效
- handler可以在请求处理函数之前执行,也可以在请求处理函数之后执行
- 在handler中调用next方法的作用是,调用next之前的代码在请求处理之前执行(前置),调用next之后的代码在请求处理之后执行(后置)。(这个不难理解,next的作用是执行其他handler,如果其中一个handler中没有调用next,那它整个是前置的,如果其中一个handler也调了next,就继续递归处理其他handler,最后一个handler是请求处理函数,请求处理函数执行完毕后,递归调用返回时,触发所有handler的后置部分)