编辑
2025-07-26
技术学习
00

目录

基础
1. 相比于其他语言,go有什么优势
2. Go是面向对象的吗
3. golang中make和new 的区别
4. slice和数据的区别
5. for i, v := range nums(切片)的坑
6. 多个defer的执行顺序,defer何时修改返回值?
7. 介绍rune类型
8. go struct 能不能比较
9. 空struct{}占用多少空间,用途是什么
10. Go闭包是什么
11. go多返回值
12.Go 语言中不能比较的类型如何比较是否相等?
13. go中init()函数的特征
14. slice如何扩容
Contenxt相关
1. context结构是怎样的
2. context使用场景和用途
channel 相关
1. channel是否线程安全,锁用在什么地方
2. channel的底层实现原理
3. nil、关闭的channel、有数据的channel,再进行读写关闭会怎么样
4. select的底层原理
Map相关
1. Map是并发安全的吗
2. Map循环是有序还是无序的,为什么
3. Map中删除一个key,内存会释放吗
4. 怎么对Map进行并发访问,不同方案的区别是什么
5. 没有初始化的map和空map有何不同
6. map的数据结构
7. map如何扩容
8. map的key为什么一定要是可比较类型
interface相关
1. interface底层原理
2. 类型转换和断言的区别
3. interface的使用场景
4. 接口之间可以相互比较吗?
GMP模型
1. 什么是GMP模型,过程是什么样的
2. GMP可不可以去掉P

整理一些golang面试常见八股,大多来自互联网结合一些个人理解,尽量简单描述,适合面试回答,只建议作为学习补充,不建议开始学习使用,有问题欢迎指正。

基础

1. 相比于其他语言,go有什么优势

  • golang的语法简单。这在go的各个方面都有体现,比如关键词少、提倡组合而不是继承。
  • 天然支持高并发。原生支持通过 goroutine 实现轻量级线程,结合 channel 进行协程间通信,且底层使用 GMP 并发模型,降低内核态和用户态切换成本。
  • 允许跨平台编译。golang可以编译出各个平台可运行的二进制的可执行文件文件,直接部署即可。

2. Go是面向对象的吗

官方文档中写的是 Yes and No。也就是说,go并不是面向对象的语言,但是可以进行面向对象风格的编程,具体而言,是通过下面的方法实现的面向对象的三大特性。

  • 封装:go是通过方法或变量字段的首字母大小写来决定是否可以被其他包使用的。
  • 继承:go通过组合结构体的方式或接口继承来实现的。
  • 多态:go通过接口来实现多态。具体而言就是不同类型实现同一个接口,这样调用方法时,通过接口存储的类型来决定结果。

3. golang中make和new 的区别

  • make只能用来分配或初始化类型为slice、map、chan的数据。new可以用来分配任意类型的数据。
  • make返回类型,即Type。new返回的是指针,即*Type。
  • make会初始化分配的空间,new分配的空间被清零。

4. slice和数据的区别

  • 数组是值类型,长度固定
  • 切片是引用语义的,长度不固定,可以动态扩容

为什么说是引用语义,而不是引用类型?

因为go中没有引用类型,全部都是值传递。slice只是看起来是引用,因为它的底层结构体中,是一个指向数组的指针,在进行传递时,依然是传递整个结构体,只是指针指向的地址是相同的。

5. for i, v := range nums(切片)的坑

在1.22版本之前,v的地址不会发生变化,但是该地址的值是变化的,每遍历到一个元素,就把该元素值就会写到该地址,这导致如果在循环中使用v创建协程就会很容易有问题。 在1.22版本,v的地址会发生变化,不再共享变量,也就不会有上面的问题。

6. 多个defer的执行顺序,defer何时修改返回值?

defer的执行顺序类似于栈,是先调用后执行。 defer在return的时候有机会修改返回值,return的过程可以被分解为三步:

  • 设置返回值
  • 执行defer语句
  • 将结果返回

7. 介绍rune类型

rune类型是 Go 语言的一种特殊数字类型。它的定义: type rune= int32 ;官方对它的解释是

是类型int32的别名,在所有方面都等价于它,用来区分字符值跟整数值。使用单引号定义,返回采用 UTF-8 编码的 Unicode 码点。Go 语言通过rune处理中文,支持国际化多语言。

8. go struct 能不能比较

  • 对于不同类型的struct无法进行比较;而同一个struct的两个实例可比较也不可比较。
  • 在Go中,Slice、map、func无法比较,当一个struct的成员是这三种类型中的任意一个,就无法进行比较。反之,struct是可以进行比较的

9. 空struct{}占用多少空间,用途是什么

不占用任何空间。用途:

  • 集合(Set)类型:用 map[string]struct{} 表示集合,比 map[string]bool 更省内存。
  • 信号通知(channel):chan struct{} 用作无数据载荷的信号通知通道,非常轻量。
  • 接口实现:只包含方法,不包含任何字段。

10. Go闭包是什么

闭包实际上就是匿名函数 +引用环境(捕获的变量)

在《深度探索Go语言》一书中提到,从语义角度来讲,闭包捕获变量并不是要复制一个副本,变量无论被捕获与否都应该是唯一的所谓捕获只是编译器为闭包函数访问外部环境中的变量搭建了一个桥梁。这个桥梁可以复制变量的值,也可以存储变量的地址。只有在变量的值不会再改变的前提下,才可以复制变量的值,否则就会出现不一致错误

有点绕,下面是GPT的解释:

image.png

11. go多返回值

Go函数传参是通过fp+offset来实现的,而多个返回值也是通过fp+offset存储在调用函数的栈帧中 可以看下这个Go语言圣经的详细介绍https://gopl-zh.codeyu.com/ch5/ch5-03.html

12.Go 语言中不能比较的类型如何比较是否相等?

  • string,int,float interface 等可以通过 reflect.DeepEqual 和等于号进行比较
  • slice,struct,map 则一般使用 reflect.DeepEqual 来检测是否相等。

13. go中init()函数的特征

  • 每个包下可以有多个 init 函数,每个文件也可以有多个 init 函数。多个 init 函数按照它们的文件名顺序逐个初始化。
  • 应用初始化时初始化工作的顺序是,从被导入的最深层包开始进行初始化,层层递出最后到 main 包。
    • 不管包被导入多少次,包内的 init 函数只会执行一次。
    • 而且包级别变量的初始化先于包内 init 函数的执行。

执行顺序import -> const -> var ->init()->main()

14. slice如何扩容

1.17及以前

  1. 如果期望容量大于当前容量的两倍就会使用期望容量;
  2. 如果当前切片的容量小于 1024 就会将容量翻倍;
  3. 如果当前切片的容量大于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量;

1.18之后,阈值从1024变为256

Contenxt相关

1. context结构是怎样的

  • context实际上是一个接口,提供了四种方法
golang
Deadline() (deadline time.Time, ok bool) // 第一个返回值代表还有多久超时;第二个返回值表示是否有超时时间控制 Done() <- chan struct{} // 返回一个只读channel,当这个channel被关闭时,说明这个context被取消 Err() error // 返回一个错误,表示该context被关闭的原因,例如被取消、超时关闭 Value(key interface{}) interface{} // 返回指定key对应的value
  • 有emptyCtx、cancelCtx、timerCtx、valueCtx四种实现
    • emptyCtx:emptyCtx虽然实现了context接口,但是不具备任何功能,因为实现很简单,基本都是直接返回空值
      • 我们一般调用context.Background()和context.TODO() 都是返i.回一个 *emptyCtx的动态类型(通过静态类型context.Context传递)
    • cancelCtx:cancelCtx同时实现Context和canceler接口,通过取消函数cancelFunc实现退出通知。注意其退出通知机制不但通知自己,同时也通知其children节点
      • 我们一般调用context.WithCancel()就会返回一个*cancelCtx和cancelFunc
    • timerCtx
      是一个实现了Context接口的具体类型,其内部封装了cancelCtx类型实例,同时也有个deadline变量,用来实现定时退出通知
      • 我们一般调用context.WithTimeout()就会返回一个*timerCtx和i.cancelFunc,不仅可以定时通知,也可以调用cancelFunc进行通知
      • 调用context.WithDeadline()也可以,WithTimeout是多少秒后t.进行通知,WithDeadline是在某个时间点通知,本质上,WithTimeout会转而WithDeadline
    • valueCtx: valueCtx是一个实现了Context接口的具体类型,其内部封装了Context接口类型,同时也封装了一个kv的存储变量,其是一个实现了数据传递
      • 我们一般context.WithValue()来得到一个*valueCtx,valueCtx可以继承它的parent valueCtx中的{key, value}

2. context使用场景和用途

  • context 主要用来在 goroutine 之间传递上下文信息,比如传递请求的trace id,以便于追踪全局唯一请求
  • 另一个用处是可以用来做取消控制,通过取消信号和超时时间来控制子goroutine的退出,防止goroutine泄漏

channel 相关

1. channel是否线程安全,锁用在什么地方

channel配合goroutine可以用来实现并发编程,并且是go语言推荐的并发编程模式,那么肯定是可以保证线程安全的。 对channel就只有读,写,关闭三种操作,这三种操作,channel底层数据结构都用同一把runtime.Mutex来进行保扩

2. channel的底层实现原理

  • 对于包含缓冲的channel,go语言的channel底层是一个hchan的结构,里面包含一个指向循环数组的指针,这个循环数组就是用于存储数据的。当然还包含下次读取和下次发送的数据索引位置recvx和sendx
  • 还包含两个goroutine等待队列,在一个goroutine对这个channel读写阻塞的时候会分情况放到这两个队列里,发送数据阻塞就放到sendq这个等待队列,接收数据阻塞就放到recvq这个等待队列
  • 为了保证channel的线程安全,hchan结构还有一个互斥锁,用作数据读写时候加锁,当前close channel也会用到这个互斥锁

详细可以看下这个博客

3. nil、关闭的channel、有数据的channel,再进行读写关闭会怎么样

操作 \ Channel状态未初始化 (nil)关闭 (closed)正常
关闭panicpanic正常关闭
阻塞当前 goroutine,可能永久挂起(死锁)panic正常写入或阻塞
阻塞当前 goroutine,可能永久挂起(死锁)读缓冲区数据,读完返回零值正常读取或阻塞

4. select的底层原理

  • select的核心原理是,按照随机的顺序执行case,直到某个case完成操作,如果所有case的都没有完成操作,则看有没有default分支,如果有default分支,则直接走default,防止阻塞
  • 如果没有的话,需要将当前goroutine加入到所有case对应channel的等待队列中,并挂起当前goroutine,等待唤醒
  • 如果当前goroutine被某一个case 上的channel操作唤醒后,还需要将当前goroutine从所有case对应channel的等待队列中剔除

Map相关

1. Map是并发安全的吗

map 不是线程安全的。

如果某个任务正在对map进行写操作,那么其他任务就不能对该 字典执行并发操作(读、写、删除),否则会导致进程崩溃。

在查找、赋值、遍历、删除的过程中都会检测写标志,一旦发现写标志等于1,则直接 fatal 退出程序。赋值和删除函数在检测完写标志是0之后,先将写标志改成1,才会进行之后的操作。

2. Map循环是有序还是无序的,为什么

无序的。先随机选择一个桶,再随机一个槽位开始便利。

因为map 在扩容后,会发生 key 的搬迁,原来落在同一个 bucket 中的key,搬迁后,有些 key 的位置就会发生改变。而遍历的过程,就是按顺序遍历 bucket,同时按顺序遍历 bucket 中的 key。搬迁后,key 的位置发生了重大的变化,这样,遍历 map 的结果就不可能按原来的顺序了。所以,go语言,强制每次遍历都随机开始。

3. Map中删除一个key,内存会释放吗

不会释放,删除一个key,可以认为是标记删除,只是修改key对应内存位置的值为空,并不会释放内存,只有在置空这个map的时候,整个map的空间才会被垃圾回后释放

4. 怎么对Map进行并发访问,不同方案的区别是什么

对map进行加读写锁或者是使用sync.map 和原始map+RWLock的实现并发的方式相比,减少了加锁对性能的影响它做了一些优化:可以无锁访问read map,而且会优先操作read map,倘若只操作read map就可以满足要求,那就不用去加锁操作writemap(dirty),所以在某些特定场景中它发生锁竞争的频率会远远小于map+RWLock的实现方式

sync.Map的优缺点:

  • 优点:适合读多写少的场景
  • 缺点:写多的场景,会导致 read map 缓存失效,需要加锁,冲突变多,性能急剧下降

Sync.Map可以看下这个博客

5. 没有初始化的map和空map有何不同

未初始化的map:

  • 往值为nil的map添加值,会触发panic
  • 读取值为nil的map,不会报错
  • 删除值为nil的map,不会报错

已经初始化,没有任何元素的map为空map,对空map增删改查不会报错

6. map的数据结构

golang
type hmap struct { count int // 元素的个数 B uint8 // buckets 数组的长度就是 2^B 个 overflow uint16 // 溢出桶的数量 buckets unsafe.Pointer // 2^B个桶对应的数组指针 oldbuckets unsafe.Pointer // 发生扩容时,记录扩容前的buckets数组指针 extra *mapextra //用于保存溢出桶的地址 } type mapextra struct { overflow *[]*bmap oldoverflow *[]*bmap nextOverflow *bmap } type bmap struct { tophash [bucketCnt]uint8 } //在编译期间会产生新的结构体 type bmap struct { tophash [8]uint8 //存储哈希值的高8位 data byte[1] //key value数据:key/key/key/.../value/value/value... overflow *bmap //溢出bucket的地址 }

Map的底层实现数据结构实际上是一个哈希表。在运行时表现为个指向hmap结构的指针,hmap中有记录了桶数组指针buckets,溢出桶指针以及元素个数等字段。每个桶是一个bmap的数据结构,可以存储8个键值对和8个tophash以及指向下一个溢出桶的指针overflow。为了内存紧凑,采用的是先存8个key过后再存value.

7. map如何扩容

扩容时机:

向 map 插入新 key 的时候,会进行条件检测,符合下面这2个条件就会触发扩容。

  • 超过负载 map元素个数>6.5(负载因子)*桶个数,触发双倍i.扩容
  • 溢出桶太多,触发等量扩容
    • 当桶总数<2^15时,如果溢出桶总数>=桶总数,则认为溢出桶过多
    • 当桶总数>2^15时,如果溢出桶总数>=2^15,则认为溢出桶过多

扩容机制

  • 双倍扩容:新建一个buckets数组,新的buckets数量大小是原来的2倍然后|日buckets数据搬迁到新的buckets.
  • 等量扩容:并不扩大容量,buckets数量维持不变,重新做一遍类似双倍扩容的搬迁动作,把松散的键值对重新排列一次,使得同一个 bucket 中的 key 排列地更紧密,节省空间,提高 bucket 利用率,进而保证更快的存取。

扩容方式:

扩容过程并不是一次性进行的,而是采用的渐进式扩容,在插入修改删除key的时候,都会尝试进行搬迁桶的工作,每次都会检查oldbucket是否nil,如果不是nil则每次搬迁2个桶,蚂蚁搬家一样渐进式扩容

8. map的key为什么一定要是可比较类型

  • 首先map 的 key、value 是存在 buckets 数组里的,而每个 bucket 又可以容纳 8个key和8个 value。
  • 当要插入一个新的 key-value 时,会对 key 进行 hash 运算得到一个hash 值,然后根据 hash 值 的低B位(取几位取决于桶的数量,比如一开始桶的数量是4,则取低2位)来决定命中哪个 bucket。
    • bucket数量=2^B
  • 在命中某个 bucket 后,又会根据 hash 值的高8位来决定是 8个 key里的哪个位置。如果不巧,发生了 hash冲突,即该位置上已经有其他key 存在了,则会去其他空位置寻找插入。如果全都满了,则使用overflow 指针指向一个新的 bucket,重复刚刚的寻找步骤。

从上面的流程可以看出,在判断 hash 冲突,即该位置是否已有其他 key时,肯定是要进行比较的,所以 key 必须得是可比较类型的。像 slice、map、function 就不能作为 key。

interface相关

1. interface底层原理

Go的interface底层有两种数据结构:eface和iface。

eface是空interface{}的实现,只包含两个指针:-type 指向类型信息data 指向实际数据。这就是为什么空接口能存储任意类型值的原因,通过类型指针来标识具体类型,通过数据指针来访问实际值。

iface是带方法的interface实现,包含 itab 和 data 两部分。 itab 是核心,它存储了接口类型、具体类型,以及方法表。方法表是个函数指针数组,保存了该类型实现的所有接口方法的地址。

iface和eface的核心区别在于是否包含方法信息

2. 类型转换和断言的区别

类型转换类型断言 本质都是把一个类型转换成另外一个类型。不同之处在于,类型断言是对接口变量进行的操作。对于类型转换而言,类型转换是在编译期确定的强制转换,转换前后的两个类型要相互兼容才行,语法是 T(value)。而类型断言是运行期的动态检查,专门用于从接口类型中提取具体类型,语法是 value.(T)

安全性差别很大:类型转换在编译期保证安全性,而类型断言可能在运行时失败。所以实际开发中更常用安全版本的类型断言 value, ok := x.(string),通过 ok 判断是否成功。

使用场景不同:类型转换主要解决数值类型、字符串、切片等之间的转换问题;类型断言主要用于接口编程,当你拿到一个 interface{} 需要还原成具体类型时使用。

底层实现也不同:类型转换通常是简单的内存重新解释或者数据格式调整;类型断言需要检查接口的底层类型信息,涉及到 runtime 的类型系统。

3. interface的使用场景

依赖注入和解耦。通过定义接口抽象,让高层模块不依赖具体实现,比如定义一个 UserRepo 接口,具体实现可以是 MySQLRedis 或者 Mock 实现。这样代码更容易测试和维护,也符合 SOLID 原则

多态实现。比如定义一个 Shape 接口包含 Area() 方法,不同的图形结构体实现这个接口,就能用统一的方式处理各种图形。这让代码更加灵活和可扩展

标准库中大量使用 interface 来提供统一 API。像 io.Readerio.Writer 让文件、网络连接、字符串等都能用统一的方式操作;sort.Interface 让任意类型都能使用标准库的排序算法。

还有类型断言和反射的配合使用,比如 JSON 解析、ORM 映射等场景,先用 interface{} 接收任意类型,再通过类型断言反射处理具体逻辑。

插件化架构也 heavily 依赖 interface。比如 Web 框架的中间件数据库驱动日志组件等,都通过接口定义规范,让第三方能够轻松扩展功能。

4. 接口之间可以相互比较吗?

  • 接口值之间可以使用 ==和 != 来进行比较。两个接口值相等仅当它们都是nil值,或者它们的动态类型相同并且动态值也根据这个动态类型的=操作相等。如果两个接口值的动态类型相同,但是这个动态类型是不可比较的(比如切片),将它们进行比较就会失败并且panic.
  • 接口值在与非接口值比较时,Go会先将非接口值尝试转换为接口值再比较
  • 接口值很特别,其它类型要么是可比较类型(如基本类型和指针)要么是不可比较类型(如切片,映射类型,和函数),但是接口值视具体的类型和值,可能会报出潜在的panic。

总之,一般是不推荐比较两个接口值的。

GMP模型

1. 什么是GMP模型,过程是什么样的

GMP模型是go语言中协程的调度模型

协程在刚创建的时候,会优先加到当前p的本地队列中,等待被调度,当这个p队列满了的时候,本地队列满了时,会将本地队列的一半G和新创建的 G一起放入全局队列。每个m都有一个特殊的协程g0负责调度工作每一轮调度过程是这样的,M 优先执行其所绑定的P的本地运行队列中的 G,如果本地队列没有 G,则会从全局队列获取,为了提高效率和负载均衡,会从全局队列获取多个 G,而不是只取一个,同样,当全局队列没有时,会从其他 M 的P上偷取 G 来运行,偷取的个数通常是其他P运行队列的一半;如果还没有获取到g,则m就处于自旋状态。

2. GMP可不可以去掉P

  • 每个P有自己的本地队列,大幅度的减轻了对全局队列的直接依赖所带来的效果就是锁竞争的减少。而 GM 模型的性能开销大头就是锁竞争。
  • 每个P相对的平衡上,在 GMP 模型中也实现了 Work Stealing 算法!如果P的本地队列为空,则会从全局队列或其他P的本地队列中窃取可运行的 G来运行,减少空转,提高了资源利用率、

本文作者:AstralDex

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!