Go实战面试备忘录
原文地址:https://blog.likeli.top/posts/面试/go面试备忘录/
一个小厂的面试,记录一下,答案不对的,请帮忙更正下
go部分
map底层实现
map底层通过哈希表实现
slice和array的区别
array是固定长度的数组,使用前必须确定数组长度
array特点:
- go的数组是值类型,也就是说一个数组赋值给另一个数组,那么实际上就是真个数组拷贝了一份,需要申请额外的内存空间
- 如果go中的数组做为函数的参数,那么实际传递的参数是一份数组的拷贝,而不是数组的指针
- array的长度也是Type的一部分,这样就说明[10]int和[20]int是不一样的
slice特点:
- slice是一个引用类型,是一个动态的指向数组切片的指针
- slice是一个不定长的,总是指向底层的数组array的数据结构
区别:
- 声明时:array需要声明长度或者…
- 做为函数参数时:array传递的是数组的副本,slice传递的是指针
struct和OOP使用中有什么区别
首先OOP的特点:继承、封装、多态
继承
概念:一个对象获得另一个对象的属性的过程
- java只有单继承,接口多实现
- go可以实现多继承
- 一个struct嵌套了另一个匿名struct,那么这个struct可以直接访问匿名机构提的方法,从而实现集成
- 一个struct嵌套了另一个命名的struct,那么这个模式叫做组合
- 一个struct嵌套了多个匿名struct,那么这个结构可以直接访问多个匿名struct的方法,从而实现多重继承
封装
概念:自包含的黑盒子,有私有和公有的部分,公有可以被访问,私有的外部不能访问
- java中访问权限控制通过public、protected、private、default关键字控制
- go通过约定来实现权限控制。变量名首字母大写,相当于public,首字母小写,相当于private。在同一个包中访问,相当于default。由于在go中没有继承,所以就没有protected
多态
概念:允许用一个接口在访问同一类动作的特性
- java中的多态是通过
extends class
或implements interface
实现 - go中的interface通过
合约
方式实现,只要某个struct实现了某个interface中的所有方法,那么它就隐式的实现了该接口
聊聊你对channel的理解
channel是一个通信机制,它可以让一个goroutine通过它给另一个goroutine发送值信息。每个channel都有一个特殊的类型,也就是channel可发送数据的类型。
channel有哪些状态
channel有三种状态:
- nil,未初始化的状态,只进行了声明,或者手动赋值为nil
- active,正常的channel,可读可写
- closed,已关闭
channel可进行三种操作:
- 读
- 写
- 关闭
这三种操作和状态可以组合出九种情况:
操作 | nil的channel | 正常channel | 已关闭channel |
---|---|---|---|
<-ch (读) | 阻塞 | 成功或阻塞 | 读到零值 |
ch<- (写) | 阻塞 | 成功或阻塞 | panic |
close(ch) (关闭) | panic | 成功 | panic |
在并发状态下map如何保证线程安全
go的map并发访问是不安全的,会出现未定义行为,导致程序退出。
go1.6之前,内置的map类型是部分goroutine安全的,并发的读没有问题,并发的写可能有问题。go1.6之后,并发的读写map会报错。
对比一下Java的
ConcurrentHashMap
的实现,在map的数据非常大的情况下,一把锁会导致大并发的客户端争抢一把锁,Java的解决方案是shard
,内部使用多个锁,每个区间共享一把锁,这样减少了数据共享一把锁的性能影响
go1.9之前,一般情况下通过sync.RWMutex
实现对map的并发访问控制,或者单独使用锁都可以。
go1.9之后,实现了sync.Map
,类似于Java的ConcurrentHashMap
。
sync.Map
的实现有几个优化点:
- 空间换时间。通过冗余的两个数据结构(read,dirty),实现加锁对性能的影响
- 使用只读数据(read),避免读写冲突
- 动态调整,miss次数多了之后,将dirty数据提升为read
- double-checking
- 延迟删除。删除一个键值只是打标记,只有在提升dirty的时候才清理删除的数据
- 优先从read读取、更新、删除,因为对read的读取不需要锁
聊聊你对gc的理解
内存管理
go实现的内存管理简单的说就是维护一块大的全局内存,每个线程(go中为P)维护一块小的私有内存,私有内存不足再从全局申请。
- go程序启动时申请一块大内存,并划分成spans、bitmap、arena区域
- arean区域按页划分成一个个小块
- span管理一个或多个页
- mcentral管理多个span供线程申请使用
- mcache作为线程私有资源,资源来源于mcentral
更多说明参阅引用说明[1]
垃圾回收
常见的垃圾回收算法:
- 引用计数:对每个对象维护一个引用计数,当引用该对象的对象被销毁时,引用计数减一,当引用计数为0时回收该对象。
- 优点:对象可以很快地被回收,不会出现内存耗尽或达到某个阈值时才回收。
- 缺点:不能很好的处理循环引用,而且实时的维护引用计数,也有一定的代价。
- 代表语言:Python、PHP、Swift
- 标记-清除:从根变量遍历所有引用的对象,引用对象标记为”被引用“,没有被标记的进行回收。
- 优点:解决了引用计数的缺点
- 缺点:需要STW(Stop The World),就是停掉所有的goroutine,专心做垃圾回收,待垃圾回收结束后再恢复goroutine,这回导致程序短时间的暂停。
- 代表语言:Go(三色标记法)
- 分代收集:按照对象生命周期的长短划分不同的代空间,生命周期长的放入老年代,而短的放入新生代,不同代有不同的回收算法和回收频率。
- 优点:回收性能好
- 缺点:回收算法复杂
- 代表语言:Java
Go垃圾回收的三色标记法
三色标记法只是为了描述方便抽象出来的一种说法,实际上对象并没有颜色之分。这里的三色对应了垃圾回收过程中对象的三种状态:
- 灰色:对象还在标记队列中等待
- 黑色:对象已被标记,gcmarkBits对应的位为1(对象不会在本次GC中被清理)
- 白色:对象未被标记,gcmarkBits对应的位为0(对象将会在本次GC中被清理)
垃圾回收优化[2]
写屏障(Write Barrier)
前面说过STW目的是防止GC扫描时内存变化而停掉goroutine,而写屏障就是让goroutine与GC同时运行的手段。虽然写屏障不能完全消除STW,但是可以大大减少STW的时间。
写屏障类似一种开关,在GC的特定时机开启,开启后指针传递时会把指针标记,即本轮不回收,下次GC时再确定。
GC过程中新分配的内存会被立即标记,用的并不是写屏障技术,也即GC过程中分配的内存不会在本轮GC中回收。
辅助GC(Mutator Assist)
为了防止内存分配过快,在GC执行过程中,如果goroutine需要分配内存,那么这个goroutine会参与一部分GC的工作,即帮助GC做一部分的工作,这个机制叫做Mutator Assist。
垃圾回收触发时机[3]
内存分配量达到阈值出发GC
每次内存分配时都会检查当前内存分配量是否已达到阈值,如果达到阈值则立即启动GC。
阈值 = 上次GC内存分配量 * 内存增长率
内存增长率由环境变量GOGC
控制,默认为100,即每当内存扩大一倍时启动GC。
定期触发GC
默认情况下,最长2分钟触发一次GC,这个间隔在src/runtime/proc.go:forcegcperiod
变量中被声明:
// forcegcperiod is the maximum time in nanoseconds between garbage
// collections. If we go this long without a garbage collection, one
// is forced to run.
//
// This is a variable for testing purposes. It normally doesn't change.
var forcegcperiod int64 = 2 * 60 * 1e9
手动触发
程序代码中也可以使用runtime.GC()
来手动触发GC,这主要用于GC性能测试和统计。
GC性能优化
GC性能与对象数量负相关,对象越多GC性能越差,对程序影响越大。
所以GC性能优化的思路之一就是减少对象分配个数,比如对象复用或使用大对象组合多个小对象等等。
另外,由于内存逃逸现象,有些隐式的内存分配也会产生,也有可能成为GC的负担。
内存逃逸现象[4]:变量分配在栈上需要能在编译器确定它的作用域,否则就会被分配到堆上。而堆上动态分配内存比栈上静态分配内存,开销大很多。
go通过go build -gcflags=m
命令来观察变量逃逸情况[5]
更多逃逸场景:逃逸场景
逃逸分析的作用:
- 逃逸分析的好处是为了减少GC的压力,不逃逸的对象分配在栈上,当函数返回时就回收了资源,不需要GC标记清除。
- 逃逸分析完后可以确定哪些变量可以分配在栈上,栈的分配比堆快,性能好(逃逸的局部变量会分配在堆上,而没有发生逃逸的则由编译器分配到栈上)
- 同步消除,如果你定义的对象在方法上有同步锁,但在运行时,却只有一个线程在访问,此时逃逸分析后的机器码,会去掉同步锁运行
逃逸总结
- 栈上分配内存比在堆中分配内存有更高的效率
- 栈上分配的内存不需要GC处理
- 堆上分配的内存使用完毕会交给GC处理
- 逃逸分析的目的是决定内存分配到堆还是栈
- 逃逸分析在编译阶段完成
go方法传参比起python、java有什么区别
参考文档:go的参数传递细节
go中的函数的参数传递采用的是值传递
gin
聊聊你对gin的理解
gin是一个go的微框架,封装优雅,API友好。快速灵活。容错方便等特点。
其实对于go而言,对web框架的依赖远比Python、Java之类的小。本身的net/http
足够简单,而且性能也非常不错,大部分的框架都是对net/http
的高阶封装。所以gin框架更像是一些常用函数或者工具的集合。使用gin框架发开发,可以提升效率,并同意团队的编码风格。
gin的路由组件为什么高性能
路由树
gin使用高性能路由库httprouter
[6]
在Gin框架中,路由规则被分成了9课前缀树,每一个HTTP Method对应一颗前缀树,树的节点按照URL中的 / 符号进行层级划分
gin.RouterGroup
RouterGroup是对路由树的包装,所有的路由规则最终都是由它来进行管理。Engine结构体继承了RouterGroup,所以Engine直接具备了RouterGroup所有的路由管理功能。
gin数据绑定
gin提供了很方便的数据绑定功能,可以将用户传过来的参数自动跟我们定义的结构体绑定在一起。
这是也我选择用gin的重要原因。
gin数据验证
在上面数据绑定的基础上,gin还提供了数据校验的方法。gin的数据验证是和数据绑定结合在一起的。只需要在数据绑定的结构体成员变量的标签添加binding
规则即可。这又省了大量的验证工作,对用惯AspCoreMVC、Spring MVC的程序员来说是完美的替代框架。
gin的中间件
gin中间件利用函数调用栈后进先出
的特点,完成中间件在自定义处理函数完成后的处理操作。
redis
为什么redis高性能
- 纯内存操作,内存的读写速度非常快。
- 单线程[7],省避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗CPU,不用去考虑各种锁的问题,不存在加锁释放锁的操作,因为没有出现死锁。
- 高效的数据结构,Redis的数据结构是专门进行设计的
- 使用多路I/O复用模型,非阻塞IO
- 使用底层模型不同,它们之间底层实现方式以及与客户端之间通信的应用协议不一样,Redis直接自己构建了VM机制,因为一般的系统调用系统函数的话,会需要一定的时间去移动和请求;
为什么redis要采用单线程
官方答复:因为Redis是基于内存的操作,CPU不是Redis的瓶颈,Redis的瓶颈最有可能是机器的内存大小或者网络带宽。既然单线程容易实现,而且CPU不会成为瓶颈,那就顺理成章的采用单线程方案了
多路I/O复用模型,非阻塞IO
Linux下的select、poll和epoll就是干这个的。目前最先进的就是epoll。将用户socket对应的fd注册进epoll,然后epoll帮你监听那些socket上有消息到达,这样就避免了大量的无用操作。此时的socket采用非阻塞的模式。这样,这个过程只在调用epoll的时候才会阻塞,收发客户端消息是不会阻塞的,整个进程或线程就被充分利用起来,这也就是事件驱动。
常用的5数据结构
- String:缓存、计数器、分布式锁等
- List:链表、队列、微博关注人时间轴列表等
- Hash:用户信息、Hash表等
- Set:去重、赞、共同好友等
- Zset:访问量排行、点击量排行榜等
redis作为消息队列的可靠性如何保证
参照RabbitMQ的ACK机制,消费端提供消费回馈。
RabbitMQ
RabbitMQ如何保证消息的可靠性
生产端
有两种方案:事务消息、消息确认
事务消息会严重损耗RabbitMQ的性能,所以基本不会使用。所以一般使用异步的消息确认方式保证发送的消息一定到达RabbitMQ
消费端
消息确认(ACK),当Customer使用autoAck=true
的方式订阅RabbitMQ节点消息的时候,可能由于网络原因也可能由于Customer处理消息的时候出现异常,亦或是服务器宕机,都有可能丢失消息。
而当autoAck=true
的时候,RabbitMQ会自动把发出去的消息设置为确认,然后从内存(或者磁盘)中删除,而不管消费者是否真正的消费到了这些消息。
为了避免这种情况下丢失消息,RabbitMQ提供了消费端确认的方式处理消息,所以需要设置autoAck=false
MQ本身
以上都是应用级别保证消息的可靠性,虽然已经极大的提高了应用的安全性,但是当RabbitMQ节点重启、宕机等情况依旧会导致消息丢失,所以还需要设置队列的持久性。消息的持久性,保证节点宕机或者重启后能恢复消息。
如果出现单点问题,消息还是会丢失。所以可以对于关键的消息设置镜像队列和集群保证消息服务的高可用。
MongoDB
MongoDB是一个通用的、面向文档的分布式数据库
MongoDB索引的数据结构
MongoDB的默认引擎WiredTiger
使用B树
做为索引底层的数据结构,但是除了B树外,还支持LSM树做为可选的底层数据存储结构。
MongoDB索引是特殊的数据结构,索引存储在一个易于遍历读取的数据集合中,索引是对数据库表中一列或多列的值进行排序的一种结构。
MongoDB为什么默认选择B树而不是MySQL默认的B+树
首先是应用场景:
- 做为非关系型数据库,MongoDB对于遍历数据的需求没有关系型数据库那么强,它追求的是读写单个记录的性能
- 大多数的数据库面对的都是读多写少的场景,B树与LSM树在该场景下有更大的优势
MySQL中使用的B+树是因为B+树只有叶结点会存储数据,将树种的每一个叶结点通过指针连接起来就能实现顺序遍历,而遍历数据库在关系型数据库中非常常见
MongoDB和MySQL在多个不同数据结构之间选择的最终目的就是减少查询需要的随机IO次数,MySQL认为遍历数据的查询是非常常见的,所以它选择B+树作为底层数据结构。而MongoDB认为查询单个数据记录远比遍历数据更加常见,由于B树的非叶结点也可以存储数据,所以查询一条数据所需要的平均随机IO次数会比B+树少,使用B树的MongoDB在类似的场景中的查询速度就会比MySQL快。这里并不是说MongoDB并不能对数据进行遍历,我们在MongoDB中也可以使用范围查询来查询一批满足对应条件的记录,只是需要的时间会比MySQL长一些。
MongoDB作为非关系型的数据库,它从集合的设计上就使用了完全不同的方法,如果我们仍然使用传统的关系型数据库的表设计思路来思考MongoDB中集合的设计,写出的查询可能会带来相对比较差的性能。
MongoDB中推荐的设计方法,是使用嵌入文档[8]。
MongoDB的索引有哪些,区别是什么
MongoDB支持多种类型的索引,包括单字段索引、复合索引、多key索引、文本索引等,每种类型的索引有不同的使用场景。
- 单字段索引:能加速对指定字段的各种查询请求,是最常见的索引形式,MongoDB默认创建的id索引也是这种类型。
- 复合索引:是单字段索引的升级版,它针对多个字段联合创建索引,先按第一个字段排序,第一个字段相同的文档第二个字段排序,以此类推。
- 多key索引:当索引的字段为数组时,创建出的索引称为多key索引,多key索引会为数组的每个元素建立一条索引。
- 哈希索引:是指按照某个字段的hash值来建立索引,目前主要用于MongoDB Sharded Cluster的Hash分片,hash索引只能满足字段完全匹配的查询,不能满足范围查询等。
- 地理位置索引:能很好的解决O2O的应用场景,比如『查找附近的美食』、『查找某个区域内的车站』等。
- 文本索引:能解决快速文本查找的需求,比如有一个博客文章集合,需要根据博客的内容来快速查找,则可以针对博客内容建立文本索引。
Nginx
文档:http://www.aosabook.org/en/nginx.html
引用文章[9]
为什么Nginx高性能
Nginx运行过程
- 多进程:一个 Master 进程、多个 Worker 进程
- Master 进程:管理 Worker 进程
- 对外接口:接收外部的操作(信号)
- 对内转发:根据外部的操作的不同,通过信号管理 Worker
- 监控:监控 worker 进程的运行状态,worker 进程异常终止后,自动重启 worker 进程
- Worker 进程:所有 Worker 进程都是平等的
- 实际处理:网络请求,由 Worker 进程处理;
- Worker 进程数量:在 nginx.conf 中配置,一般设置为核心数,充分利用 CPU 资源,同时,避免进程数量过多,避免进程竞争 CPU 资源,增加上下文切换的损耗。
HTTP连接建立和请求处理过程
- Nginx启动时,Master进程,加载配置文件
- Master进程,初始化监听的socket
- Master进程,fork出多个Worker进程
- Worker进程,竞争新的连接,获胜方通过三次握手,建立Socket连接,并处理请求
TCP/UDP
TCP
Tcp三次握手
图片来自于《图解HTTP》
- 客户端-发送带有SYN标志的数据包 – 一次握手-服务端
- 第一次握手:Client什么都不能确认;Server确认了对方发送正常,自己接收正常
- 服务端-发送带有SYN/ACK标志的数据包 – 二次握手-客户端
- 第二次握手:Client确认了:自己发送、接收正常、对方发送、接收正常;Server确认了;对方发送、自己接收正常
- 客户端-发送带有ACK标志的数据包 – 三次握手-服务端
- 第三次握手:Client确认了:自己发送、接收正常,对方发送、接收正常;Server确认了:自己发送、接收正常,对方发送、接收正常
所以需要三次握手才能确认双方收发功能都正常。
Tcp四次挥手
断开一个TCP连接则需要“四次挥手”:
- 客户端-发送一个FIN,用来关闭客户端到服务器的数据传送
- 服务器-收到这个FIN,它发回一个ACK,确认序号为收到的序号加1。和SYN一样,一个FIN将占用一个序号
- 服务器-关闭与客户端的连接,发送一个FIN给客户端
- 客户端-发回ACK报文确认,并将确认序号设置为收到序号加1
任何一方都可以在数据传送结束后发出连接释放的通知,待对方确认或进入半关闭状态。
当另一方也没有数据再发送的时候,则发出连接释放通知,对方确认后就完全关闭了TCP连接。
UDP
UDP在传送数据之前不需要先建立连接,远程主机在收到UDP报文后,不需要给出任何确认。虽然UDP不提供可靠交付,但在某些情况下UDP是一种最有效的工作方式(一般用于即时通信,比如:QQ语言、QQ视频、直播等等)
长连接/短连接[10]
TCP本身没有长短连接的区别,长短与否,取决于我们怎么用它。
-
短连接:每次通信时,创建Socket;一次通信结束,调用
socket.close()
,这就是一般意义上的短连接。- 短连接的好处是管理起来比较简单,存在的连接都是可用的连接,不需要额外的控制手段。
-
长连接:每次通信完毕后,不会关闭连接,这样可以做到连接的复用。
- 长连接的好处是省去了创建连接的耗时,性能好。
-
https://rainbowmango.gitbook.io/go/chapter04/4.1-memory_alloc#4-zong-jie ↩︎
-
https://rainbowmango.gitbook.io/go/chapter04/4.2-garbage_collection#4-la-ji-hui-shou-you-hua ↩︎
-
https://rainbowmango.gitbook.io/go/chapter04/4.2-garbage_collection#5-la-ji-hui-shou-chu-fa-shi-ji ↩︎
-
redis的单线程指的是网络请求模块使用了一个线程,即一个线程处理所有的网络请求,其他模块仍用了多个线程 ↩︎