Golang面试题库(Map)

文章目录

  • [Map 相关:](#Map 相关:)
  • [1、map 使用注意点,是否并发安全的?](#1、map 使用注意点,是否并发安全的?)
  • [2、map 循环是有序的还是无序的?](#2、map 循环是有序的还是无序的?)
    • 回答
    • [补充问题: 为什么go语言的map要这样设计,要随机选定桶号和槽位进行随机遍历?](#补充问题: 为什么go语言的map要这样设计,要随机选定桶号和槽位进行随机遍历?)
  • [3. map 如何顺序读取?](#3. map 如何顺序读取?)
  • [4. map 中删除一个 key,它的内存会释放么?](#4. map 中删除一个 key,它的内存会释放么?)
  • [5. 怎么处理对 map 进行并发访问?有没有其他方案?区别是什么?](#5. 怎么处理对 map 进行并发访问?有没有其他方案?区别是什么?)
  • [6. nil map 和空 map 有何不同?](#6. nil map 和空 map 有何不同?)
  • [7. map 的数据结构是什么?是怎么实现扩容?](#7. map 的数据结构是什么?是怎么实现扩容?)
  • [8. map 的 key 为什么得是可比较类型的?](#8. map 的 key 为什么得是可比较类型的?)

Map 相关:

1、map 使用注意点,是否并发安全的?

分析

考察map的线程安全,map在使用过程中主要是要注意并发读写不加锁会造成fatal error,让程序崩溃。并且这种错误是不能被recover捕获的

回答

map 不是线程安全的。

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

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

map 的读操作在"没有并发写"的前提下是安全的;一旦存在并发写,读写并发会导致运行时 fatal。map 支持并发读,但不支持读写并发或写写并发;有写时必须用 sync.Mutex/RWMutex 或改用 sync.Map。

2、map 循环是有序的还是无序的?

分析

考察对map遍历的底层实现是否了解,map在每次遍历的时候都会选定一个随机桶序号还有槽位,遍历从这个随机桶开始往后依次遍历完所有的桶,在每个桶内,则是按照之前选定随机槽位开始遍历,回答的时候要突出随机桶号和槽位。【golang中的map,桶位是[0, buckers - 1],槽位是 [0, 7],buckets = 2^B】

回答

map的遍历是无序的,map每次遍历,都会从一个随机值序号的桶,在每个桶中,再从按照之前选定随机槽位开始遍历,所以是无序的。

补充问题: 为什么go语言的map要这样设计,要随机选定桶号和槽位进行随机遍历?

分析

因为map是可以动态扩容的,map 在扩容后,会发生 key 的搬迁,这样 key 的位置就会发生改变,那么如果顺序遍历key,在扩容后顺序肯定会不一样,这道题回答一定要突出扩容会带来key的位置发生变化

回顾一下双倍扩容, key的变化过程,双倍扩容,目标桶扩容后的位置可能在原位置也可能在原位置+偏移量处。

"原桶 or 原桶+旧桶数"这个规律就是 Go map 双倍扩容时最核心的搬迁逻辑


https://go.dev/doc/go1

https://go.dev/ref/spec#For_range

回答

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

Go 的 map 遍历顺序不属于语言语义,Go 通过随机选择起始 bucket 和桶内 slot 来刻意让遍历顺序不稳定:一是防止开发者错误依赖遍历顺序 (尽早暴露 bug),二是提升不可预测性(安全性),三是 避免为稳定顺序维护额外结构导致性能/内存/实现复杂度上升 ;扩容会加剧顺序变化,但不是主要动机。

扩容时:槽位不保证保持不变,通常会变

扩容(grow)时会把旧 bucket 里的元素"搬迁"到新表中对应的新 bucket(要么去同索引 bucket,要么去 index+oldBuckets 的那一半)。搬过去时会重新把 key/value 填进新 bucket 的 8 个槽里,填槽的过程并不承诺保留原来的槽位号。

3. map 如何顺序读取?

分析

这个题目实际上是上一个题目的补充,因为map本身的遍历是不能顺序执行的,所以我们要达到一个顺序遍历的目的就不能用原map的遍历方式,要想顺序遍历,显然需要对map的key进行排序,然后,我们按照这个排序完之后的key从map里面取出对应的数据即可

代码示例:

go 复制代码
package main

import (
	"fmt"
	"sort"
)

func main() {
	keyList := make([]int, 0)
	m := map[int]int{
		3: 200,
		4: 200,
		1: 100,
		8: 800,
		5: 500,
		2: 200,
	}
	for key := range m {
		keyList = append(keyList, key)
	}
	sort.Ints(keyList)
	for _, key := range keyList {
		fmt.Println(key, m[key])
	}
}
go 复制代码
root@GoLang:~/proj/goforjob# go run main.go 
1 100
2 200
3 200
4 200
5 500
8 800

回答

如果想顺序遍历map,先把key放到切片排序,再按照key的顺序遍历map

4. map 中删除一个 key,它的内存会释放么?

分析

考察map中key的删除原理,map删除key的时候是根据hash值找到对应的槽位,找对应的key删除,将key置为空,并且将对应的tophash置为emptyOne,如果后面没有任何数据了,则再将emptyOne状态置为emptyRest ,所以删除一个key,只是修改对应内存位置的值,并不会释放内存

回顾:

假设当前map的状态如下图所示,溢出桶2后面没有在接溢出桶,或者是溢出桶2后面接的溢出桶中没有数据,溢出桶2中有三个空槽,即第2,3,6处为emptyOne,

在删除了溢出桶1的key2和key4,以及溢出桶2的key7之后,对应map状态如下:

回答

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

真想"释放 map 占用的大块内存",用 重建 map(copy 存活元素)或让旧 map 整体不可达。想让 map 的 buckets / table / directory 那些底层结构也能被 GC 回收,需要让 整个 map 不可达,最直接就是:m = nil

5. 怎么处理对 map 进行并发访问?有没有其他方案?区别是什么?

分析

主要考察对加锁运用熟悉程度以及对go语言中内置的sync.map的了解,要使用线程安全的map,一般有这两种方式

  • 加锁

  • sync.map

同时,要明确这两种方式的性能比较,sync.map在性能上要优于map加锁,因为sync.map在底层使用了两个map,read和dirty来提升性能,对read的操作时原子操作不用加锁,只有在对read操作不能满足要求时才会加锁操作dirty,这样就减少了加锁的场景,锁竞争频率会减少,所以性能会高于单纯的map加锁,在回答的时候要突出sync.map的read和dirty,以及锁竞争的频率。

回答

对map进行加读写锁或者是使用sync.map

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

优点:
适合读多写少的场景

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

6. nil map 和空 map 有何不同?

分析

主要考察细节对各种情况下的map的读写情况,结合代码示例理解

回答

  1. 未初始化的map为nil map,
    a. 往值为nil的map添加值,会触发panic
    b. 读取值为nil的map,不会报错
    c. 删除值为nil的map,不会报错

7. map 的数据结构是什么?是怎么实现扩容?

分析

map的底层实现其实是一个hmap的结构,其中包括一个buckets指针,指向一个bmap的数组,bmap数组每个元素是一个bmap结构,称之为桶,每个桶内存储着8个tophash和8个key-value的键值对,以及指向下一个溢出桶的指针。回答要突出hmap,bmap,tophash,溢出指针overflow

hmap结构定义:

go 复制代码
// A header for a Go map. Richard

type hmap struct {
    count     int    // map中元素个数
    flags     uint8  // 状态标志位,标记map的一些状态
    B         uint8  // 桶数以2为底的对数,即B=log_2(len(buckets)), 比如B=3, 那么桶数为2^3=8
    noverflow uint16 // 溢出桶数量近似值
    hash0     uint32 // 哈希种子

    buckets    unsafe.Pointer // 指向buckets数组的指针
    oldbuckets unsafe.Pointer // 是一个指向buckets数组的指针,在扩容时,oldbuckets 指向老的buckets数组,非扩容时,oldbuckets 为空
    nevacuate  uintptr        // 表示扩容进度的一个计数器,小于该值的桶已经完成迁移

    extra *mapextra // 指向mapextra 结构的指针,mapextra 存储map中的溢出桶
}



回答

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

map怎么实现扩容

分析

这个问题作为上一个问题的补充,其实在回答的时候也要参考map的底层结构,回答扩容一定要涵盖扩容策略,扩容时机,扩容方式(渐进式扩容)

回答

扩容时机:

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

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

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

扩容方式:

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

8. map 的 key 为什么得是可比较类型的?

分析

本题主要考察go语言map中如何通过一个key计算得到它在桶中的位置

  • 第一步:根据key来计算出一个hash值(64位的,当然与机器位数挂钩)
  • 第二步:然后根据hash值的低B位锁定桶号(找到对应的bucket)
  • 第三步:接着在桶中找到对应的槽位(找到对应的一个cell)

但是这里会存在一个hash冲突的问题,并不是找到了这个槽位就是当前key的位置,因为可能有其他的key和这个key计算出的hash值相同,那么显然槽位也就一样

所以还有第四步:进一步比较key本身,来获取当前key的位置,所以key一定要是可比较的

所以在回答时,一定要重点突出会存在hash冲突,然后会比较key本身

回答

  • 首先map 的 key、value 是存在 buckets 数组里的,而每个 bucket 又可以容纳 8 个 key 和 8 个 value。

  • 当要插入一个新的 key - value 时,会对 key 进行 hash 运算得到一个 hash 值,然后根据 hash 值 的低B位(取几位取决于桶的数量,比如一开始桶的数量是4,则取低2位)来决定命中哪个 bucket。

    • bucket数量 = 2B
  • 在命中某个 bucket 后,又会根据 hash 值的高 8 位来决定是 8 个 key 里的哪个位置。如果不巧,发生了 hash 冲突,即该位置上已经有其他 key 存在了,则会去其他空位置寻找插入。如果全都满了,则使用 overflow 指针指向一个新的 bucket,重复刚刚的寻找步骤。

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

之后我会持续更新,如果喜欢我的文章,请记得一键三连哦,点赞关注收藏,你的每一个赞每一份关注每一次收藏都将是我前进路上的无限动力 !!!↖(▔▽▔)↗感谢支持!

相关推荐
To Be Clean Coder4 小时前
【Spring源码】createBean如何寻找构造器(二)——单参数构造器的场景
java·后端·spring
你才是臭弟弟4 小时前
SpringBoot 集成MinIo(根据上传文件.后缀自动归类)
java·spring boot·后端
码农水水4 小时前
得物Java面试被问:消息队列的死信队列和重试机制
java·开发语言·jvm·数据结构·机器学习·面试·职场和发展
C澒4 小时前
面单打印服务的监控检查事项
前端·后端·安全·运维开发·交通物流
鸣潮强于原神5 小时前
TSMC chip_boundary宽度规则解析
后端
Code blocks5 小时前
kingbase数据库集成Postgis扩展
数据库·后端
金庆5 小时前
Commit Hash from debug.ReadBuildInfo()
golang
无限码力5 小时前
华为OD技术面真题 - JAVA开发 - 5
java·华为od·面试·华为od技术面真题·华为od技术面八股·华为od技术面java八股文
Elieal5 小时前
JWT 登录校验机制:5 大核心类打造 Spring Boot 接口安全屏障
spring boot·后端·安全
czlczl200209255 小时前
Spring Boot Filter :doFilter 与 doFilterInternal 的差异
java·spring boot·后端