✍个人博客:Pandaconda-CSDN博客
📣专栏地址:http://t.csdnimg.cn/UWz06📚专栏简介:在这个专栏中,我将会分享 Golang 面试中常见的面试题给大家~
❤️如果有收获的话,欢迎点赞👍收藏📁,您的支持就是我创作的最大动力💪
26. Go slice 深拷贝和浅拷贝
深拷贝:拷贝的是数据本身,创造一个新对象,新创建的对象与原对象不共享内存,新创建的对象在内存中开辟一个新的内存地址,新对象值修改时不会影响原对象值。
实现深拷贝的方式:
-
copy(slice2, slice1)
-
遍历 append 赋值
func main() {slice1 := []int{1, 2, 3, 4, 5}fmt.Printf("slice1: %v, %p", slice1, slice1)slice2 := make([]int, 5, 5)copy(slice2, slice1)fmt.Printf("slice2: %v, %p", slice2, slice2)slice3 := make([]int, 0, 5)for _, v := range slice1 {slice3 = append(slice3, v)}fmt.Printf("slice3: %v, %p", slice3, slice3)
}slice1: [1 2 3 4 5], 0xc0000b0030
slice2: [1 2 3 4 5], 0xc0000b0060
slice3: [1 2 3 4 5], 0xc0000b0090
浅拷贝:拷贝的是数据地址,只复制指向的对象的指针,此时新对象和老对象指向的内存地址是一样的,新对象值修改时老对象也会变化。
实现浅拷贝的方式:
引用类型的变量,默认赋值操作就是浅拷贝。
-
slice2 := slice1
func main() {slice1 := []int{1, 2, 3, 4, 5}fmt.Printf("slice1: %v, %p", slice1, slice1)slice2 := slice1fmt.Printf("slice2: %v, %p", slice2, slice2)
}slice1: [1 2 3 4 5], 0xc00001a120
slice2: [1 2 3 4 5], 0xc00001a120
27. G o slice 扩容机制?
扩容会发生在 slice append 的时候,当 slice 的 cap 不足以容纳新元素,就会进行扩容,扩容规则如下:
-
如果新申请容量比两倍原有容量大,那么扩容后容量大小为新申请容量。
-
如果原有 slice 长度小于 1024, 那么每次就扩容为原来的 2 倍。
-
如果原 slice 长度大于等于 1024, 那么每次扩容就扩为原来的 1.25 倍。
func main() {slice1 := []int{1, 2, 3}for i := 0; i < 16; i++ {slice1 = append(slice1, 1)fmt.Printf("addr: %p, len: %v, cap: %v
", slice1, len(slice1), cap(slice1))}
}
addr: 0xc00001a120, len: 4, cap: 6
addr: 0xc00001a120, len: 5, cap: 6
addr: 0xc00001a120, len: 6, cap: 6
addr: 0xc000060060, len: 7, cap: 12
addr: 0xc000060060, len: 8, cap: 12
addr: 0xc000060060, len: 9, cap: 12
addr: 0xc000060060, len: 10, cap: 12
addr: 0xc000060060, len: 11, cap: 12
addr: 0xc000060060, len: 12, cap: 12
addr: 0xc00007c000, len: 13, cap: 24
addr: 0xc00007c000, len: 14, cap: 24
addr: 0xc00007c000, len: 15, cap: 24
addr: 0xc00007c000, len: 16, cap: 24
addr: 0xc00007c000, len: 17, cap: 24
addr: 0xc00007c000, len: 18, cap: 24
addr: 0xc00007c000, len: 19, cap: 24
28. Go slice 为什么不是线程安全的?
先看下线程安全的定义:
多个线程访问同一个对象时,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的。
若有多个线程同时执行写操作,一般都需要考虑线程同步,否则的话就可能影响线程安全。
再看 Go 语言实现线程安全常用的几种方式:
-
互斥锁
-
读写锁
-
原子操作
-
sync.once
-
sync.atomic
-
channel
slice 底层结构并没有使用加锁等方式,不支持并发读写,所以并不是线程安全的,使用多个 goroutine 对类型为 slice 的变量进行操作,每次输出的值大概率都不会一样,与预期值不一致;slice 在并发执行中不会报错,但是数据会丢失。
/**
* 切片非并发安全
* 多次执行,每次得到的结果都不一样
* 可以考虑使用 channel 本身的特性 (阻塞) 来实现安全的并发读写*/
func TestSliceConcurrencySafe(t *testing.T) {a := make([]int, 0)var wg sync.WaitGroupfor i := 0; i < 10000; i++ {wg.Add(1)go func(i int) {a = append(a, i)wg.Done()}(i)}wg.Wait()t.Log(len(a)) // not equal 10000
}
29. Golang Map 底层实现
Go 语言中的 map 是一种无序的键值对的集合,底层实现使用了哈希表(hash table)。
具体来说,Go 语言中的 map 实际上是一个指向哈希表的指针。哈希表本身是由若干个桶(bucket)组成的,每个桶包含了若干个键值对,每个键值对由一个 key 和一个 value 组成。在对 map 进行读写操作时,Go 语言会根据 key 计算出它在哈希表中的位置,然后直接访问对应的桶,从而实现高效的访问。
具体而言,当我们往 map 中添加键值对时,Go 语言会首先计算出 key 的哈希值,然后根据哈希值计算出 key 在哈希表中的位置。如果该位置还没有被占用,Go 语言会在该位置上创建一个新的桶,并把键值对放入该桶中;如果该位置已经被占用,Go 语言会在该桶中查找是否已经有一个键值对的 key 与待添加的 key 相同。如果找到了相同的 key,就替换该键值对的 value;如果没有找到相同的 key,就将新的键值对添加到该桶的末尾。
需要注意的是,Go 语言中的 map 不是线程安全的,因此在多线程并发访问时需要使用锁等机制来保证安全。
另外,由于哈希表的大小是固定的,因此当 map 中的元素数量达到一定程度时,需要对哈希表进行扩容。
30. G o Map 如何扩容?
扩容时机:
在向 map 插入新 key 的时候,会进行条件检测,符合下面这 2 个条件,就会触发扩容
if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {hashGrow(t, h)goto again // Growing the table invalidates everything, so try again
}
// 判断是否在扩容
func (h *hmap) growing() bool {return h.oldbuckets != nil
}
扩容条件:
1. 条件 1:超过负载
-
map 元素个数 > 6.5 * 桶个数
func overLoadFactor(count int, B uint8) bool {return count > bucketCnt && uintptr(count) > loadFactor*bucketShift(B)
}
其中
bucketCnt = 8,一个桶可以装的最大元素个数
loadFactor = 6.5,负载因子,平均每个桶的元素个数
bucketShift(B): 桶的个数
2. 条件 2:溢出桶太多
-
当桶总数 < 2 ^ 15 时,如果溢出桶总数 >= 桶总数,则认为溢出桶过多。
-
当桶总数 >= 2 ^ 15 时,直接与 2 ^ 15 比较,当溢出桶总数 >= 2 ^ 15 时,即认为溢出桶太多了。
func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {// If the threshold is too low, we do extraneous work.// If the threshold is too high, maps that grow and shrink can hold on to lots of unused memory.// "too many" means (approximately) as many overflow buckets as regular buckets.// See incrnoverflow for more details.if B > 15 {B = 15}// The compiler does not see here that B < 16; mask B to generate shorter shift code.return noverflow >= uint16(1)<<(B&15)
}
对于条件 2,其实算是对条件 1 的补充。因为在负载因子比较小的情况下,有可能 map 的查找和插入效率也很低,而第 1 点识别不出来这种情况。 表面现象就是负载因子比较小比较小,即 map 里元素总数少,但是桶数量多(真实分配的桶数量多,包括大量的溢出桶)。比如不断的增删,这样会造成 overflow 的 bucket 数量增多,但负载因子又不高,达不到第 1 点的临界值,就不能触发扩容来缓解这种情况。这样会造成桶的使用率不高,值存储得比较稀疏,查找插入效率会变得非常低,因此有了第 2 扩容条件。
扩容机制:
-
双倍扩容:针对条件 1,新建一个 buckets 数组,新的 buckets 大小是原来的 2 倍,然后旧 buckets 数据搬迁到新的 buckets。该方法我们称之为双倍扩容.
-
等量扩容:针对条件 2,并不扩大容量,buckets 数量维持不变,重新做一遍类似双倍扩容的搬迁动作,把松散的键值对重新排列一次,使得同一个 bucket 中的 key 排列地更紧密,节省空间,提高 bucket 利用率,进而保证更快的存取。该方法我们称之为等量扩容。
扩容函数:
上面说的 hashGrow()
函数实际上并没有真正地 “搬迁”,它只是分配好了新的 buckets,并将老的 buckets 挂到了 oldbuckets 字段上。真正搬迁 buckets 的动作在 growWork()
函数中,而调用 growWork()
函数的动作是在 mapassign 和 mapdelete 函数中。也就是插入或修改、删除 key 的时候,都会尝试进行搬迁 buckets 的工作。先检查 oldbuckets 是否搬迁完毕,具体来说就是检查 oldbuckets 是否为 nil。
func hashGrow(t *maptype, h *hmap) {// 如果达到条件 1,那么将B值加1,相当于是原来的2倍// 否则对应条件 2,进行等量扩容,所以 B 不变bigger := uint8(1)if !overLoadFactor(h.count+1, h.B) {bigger = 0h.flags |= sameSizeGrow}// 记录老的bucketsoldbuckets := h.buckets// 申请新的buckets空间newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)// 注意&^ 运算符,这块代码的逻辑是转移标志位flags := h.flags &^ (iterator | oldIterator)if h.flags&iterator != 0 {flags |= oldIterator}// 提交grow (atomic wrt gc)h.B += biggerh.flags = flagsh.oldbuckets = oldbucketsh.buckets = newbuckets// 搬迁进度为0h.nevacuate = 0// overflow buckets 数为0h.noverflow = 0// 如果发现hmap是通过extra字段 来存储 overflow buckets时if h.extra != nil && h.extra.overflow != nil {if h.extra.oldoverflow != nil {throw("oldoverflow is not nil")}h.extra.oldoverflow = h.extra.overflowh.extra.overflow = nil}if nextOverflow != nil {if h.extra == nil {h.extra = new(mapextra)}h.extra.nextOverflow = nextOverflow}
}
由于 map 扩容需要将原有的 key/value 重新搬迁到新的内存地址,如果 map 存储了数以亿计的 key-value,一次性搬迁将会造成比较大的延时,因此 Go map 的扩容采取了一种称为 “渐进式” 的方式,原有的 key 并不会一次性搬迁完毕,每次最多只会搬迁 2 个 bucket。
func growWork(t *maptype, h *hmap, bucket uintptr) {// 为了确认搬迁的 bucket 是我们正在使用的 bucket// 即如果当前key映射到老的bucket1,那么就搬迁该bucket1。evacuate(t, h, bucket&h.oldbucketmask())// 如果还未完成扩容工作,则再搬迁一个bucket。if h.growing() {evacuate(t, h, h.nevacuate)}
}
需要注意的是,在 Map 进行扩容时,可能会导致哈希冲突的数量增加,因此扩容后的 Map 的性能可能会有所下降。为了避免这种情况,可以考虑在创建 Map 时指定初始容量,以减少扩容的次数。