跳转至

数据结构

约 4571 个字 197 行代码 1 张图片 预计阅读时间 25 分钟

Slice

1. slice的底层结构是怎样的?

slice 的底层数据其实也是数组,slice 是对数组的封装,它描述一个数组的片段。slice 实际上是一个结构体,包含三个字段:长度、容量、底层数组。

Go
// runtime/slice.go
type slice struct {
        array unsafe.Pointer // 元素指针
        len   int // 长度 
        cap   int // 容量
}

2. Go语言里slice是怎么扩容的?

1.17及以前

  1. 如果期望容量大于当前容量的两倍就会使用期望容量;

  2. 如果当前切片的长度小于 1024 就会将容量翻倍;

  3. 如果当前切片的长度大于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量;

Go1.18及以后,引入了新的扩容规则:

当原slice容量(oldcap)小于256的时候,新slice(newcap)容量为原来的2倍;原slice容量超过256,新slice容量newcap = oldcap+(oldcap+3*256)/4

3. 从一个切片截取出另一个切片,修改新切片的值会影响原来的切片内容吗

在截取完之后,如果新切片没有触发扩容,则修改切片元素会影响原切片,如果触发了扩容则不会。

示例

Go
package main

import "fmt"

func main() {
        slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
        s1 := slice[2:5]
        s2 := s1[2:6:7]

        s2 = append(s2, 100)
        s2 = append(s2, 200)

        s1[2] = 20

        fmt.Println(s1)
        fmt.Println(s2)
        fmt.Println(slice)
}

运行结果:

Bash
[2 3 20]
[4 5 6 7 100 200]
[0 1 2 3 20 5 6 7 100 9]

s1slice 索引2(闭区间)到索引5(开区间,元素真正取到索引4),长度为3,容量默认到数组结尾,为8。 s2s1 的索引2(闭区间)到索引6(开区间,元素真正取到索引5),容量到索引7(开区间,真正到索引6),为5。

接着,向 s2 尾部追加一个元素 100:

Go
s2 = append(s2, 100)

s2 容量刚好够,直接追加。不过,这会修改原始数组对应位置的元素。这一改动,数组和 s1 都可以看得到。

再次向 s2 追加元素200

Go
s2 = append(s2, 200)

这时,s2 的容量不够用,该扩容了。于是,s2 另起炉灶,将原来的元素复制新的位置,扩大自己的容量。并且为了应对未来可能的 append 带来的再一次扩容,s2 会在此次扩容的时候多留一些 buffer,将新的容量将扩大为原始容量的2倍,也就是10了。

最后,修改 s1 索引为2位置的元素:

Go
s1[2] = 20

这次只会影响原始数组相应位置的元素。它影响不到 s2 了,人家已经远走高飞了。

再提一点,打印 s1 的时候,只会打印出 s1 长度以内的元素。所以,只会打印出3个元素,虽然它的底层数组不止3个元素。

4. slice作为函数参数传递,会改变原slice吗?

当 slice 作为函数参数时,因为会拷贝一份新的slice作为实参,所以原来的 slice 结构并不会被函数中的操作改变,也就是说,slice 其实是一个结构体,包含了三个成员:len, cap, array并不会变化。但是需要注意的是,尽管slice结构不会变,但是其底层数组的数据如果有修改的话,则会发生变化。若传的是 slice 的指针,则原 slice 结构会变,底层数组的数据也会变。

示例

Go
package main

func main() {
        s := []int{1, 1, 1}
        f(s)
        fmt.Println(s)
}

func f(s []int) {
        // i只是一个副本,不能改变s中元素的值
        /*for _, i := range s {
                i++
        }
        */

        for i := range s {
                s[i] += 1
        }
}

程序输出:

Go
[2 2 2]

果真改变了原始 slice 的底层数据。这里传递的是一个 slice 的副本,在 f 函数中,s 只是 main 函数中 s 的一个拷贝。在f 函数内部,对 s 的作用并不会改变外层 main 函数的 s的结构。

要想真的改变外层 slice,只有将返回的新的 slice 赋值到原始 slice,或者向函数传递一个指向 slice 的指针。我们再来看一个例子:

Go
package main

import "fmt"

func myAppend(s []int) []int {
        // 这里 s 虽然改变了,但并不会影响外层函数的 s
        s = append(s, 100)
        return s
}

func myAppendPtr(s *[]int) {
        // 会改变外层 s 本身
        *s = append(*s, 100)
        return
}

func main() {
        s := []int{1, 1, 1}
        newS := myAppend(s)

        fmt.Println(s)
        fmt.Println(newS)

        s = newS

        myAppendPtr(&s)
        fmt.Println(s)
}

程序输出

Go
[1 1 1]
[1 1 1 100]
[1 1 1 100 100]

myAppend 函数里,虽然改变了 s,但它只是一个值传递,并不会影响外层的 s,因此第一行打印出来的结果仍然是 [1 1 1]

newS 是一个新的 slice,它是基于 s 得到的。因此它打印的是追加了一个 100 之后的结果: [1 1 1 100]

最后,将 newS 赋值给了 ss 这时才真正变成了一个新的slice。之后,再给 myAppendPtr 函数传入一个 s 指针,这回它真的被改变了:[1 1 1 100 100]

Map

1. Go语言Map的底层实现原理是怎样的?

map的就是一个hmap的结构。Go Map的底层实现是一个**哈希表**。它在运行时表现为一个指向hmap结构体的指针,hmap中记录了**桶数组指针buckets、**溢出桶指针**以及**元素个数**等字段。每个桶是一个bmap结构体,能存储**8个键值对**和**8个tophash,并有指向下一个**溢出桶的指针overflow。为了**内存紧凑bmap中采用的是先存8个键再存8个值的存储方式。

分析

hmap结构定义:

Go
// A header for a Go map.
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数组(大小为新buckets数组的一半),非扩容时,oldbuckets 为空
   nevacuate  uintptr        // 表示扩容进度的一个计数器,小于该值的桶已经完成迁移

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

2. Go语言Map的遍历是有序的还是无序的?

Go语言里Map的遍历是**完全随机**的,并没有固定的顺序。map每次遍历,都会从一个随机值序号的桶,在每个桶中,再从按照之前选定随机槽位开始遍历,所以是无序的。

3. Go语言Map的遍历为什么要设计成无序的?

map 在扩容后,会发生 key 的搬迁,原来落在同一个 bucket 中的 key,搬迁后,有些 key 就要远走高飞了(bucket 序号加上了 2^B)。而遍历的过程,就是按顺序遍历 bucket,同时按顺序遍历 bucket 中的 key。搬迁后,key 的位置发生了重大的变化,有些 key 飞上高枝,有些 key 则原地不动。这样,遍历 map 的结果就不可能按原来的顺序了。

Go团队为了避免开发者写出依赖底层实现细节的脆弱代码,而**有意为之**的一个设计。通过在遍历时引入随机数,Go从根本上杜绝了程序员依赖特定遍历顺序的可能性,强制我们写出更健壮的代码。

4. Map如何实现顺序读取?

如果业务上确实需要有序遍历,最规范的做法就是将Map的键(Key)取出来放入一个切片(Slice)中,用sort包对切片进行排序,然后根据这个有序的切片去遍历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])
   }
}

5. Go语言的Map是否是并发安全的?

map 不是线程安全的。

在查找、赋值、遍历、删除的过程中都会检测写标志,一旦发现写标志置位(等于1),则直接 panic。赋值和删除函数在检测完写标志是复位之后,先将写标志位置位,才会进行之后的操作。

检测写标志:

Go
if h.flags&hashWriting == 0 {
                throw("concurrent map writes")
        }

设置写标志:

Go
h.flags |= hashWriting

6. Map的Key一定要是可比较的吗?为什么?

Map的Key必须要可比较。

首先,Map会对我们提供的Key进行哈希运算,得到一个哈希值。这个哈希值决定了这个键值对大概存储在哪个位置(也就是哪个"桶"里)。然而,不同的Key可能会产生相同的哈希值,这就是"哈希冲突"。当多个Key被定位到同一个"桶"里时,Map就没法只靠哈希值来区分它们了。此时,它必须在桶内进行逐个遍历,用我们传入的Key和桶里已有的每一个Key进行**相等(==)**比较。这样才能确保我们操作的是正确的键值对。

7. Go语言Map的扩容时机是怎样的?

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

  1. 装载因子超过阈值,源码里定义的阈值是 6.5,这个时候会触发双倍扩容

  2. overflow 的 bucket 数量过多:

  3. 当 B 小于 15,也就是 bucket 总数 2^B 小于 2^15 时,如果 overflow 的 bucket 数量超过 2^B;

  4. 当 B >= 15,也就是 bucket 总数 2^B 大于等于 2^15,如果 overflow 的 bucket 数量超过 2^15

这两种情况下会触发双倍扩容

8. Go语言Map的扩容过程是怎样的?

Go的扩容是**渐进式(gradual)**的。它不会在触发扩容时"stop the world"来一次性把所有数据搬迁到新空间,而是只分配新空间,然后在后续的每一次插入、修改或删除操作时,才会顺便搬迁一两个旧桶的数据。这种设计将庞大的扩容成本分摊到了多次操作中,极大地减少了服务的瞬间延迟(STW),保证了性能的平滑性。

如果是触发双倍扩容,会新建一个buckets数组,新的buckets数量大小是原来的2倍,然后旧buckets数据搬迁到新的buckets。如果是等量扩容,buckets数量维持不变,重新做一遍类似双倍扩容的搬迁动作,把松散的键值对重新排列一次,使得同一个 bucket 中的 key 排列地更紧密,这样节省空间,存取效率更高

9. 可以对Map的元素取地址吗?

无法对 map 的 key 或 value 进行取址。会发生编译报错,这样设计主要是因为map一旦发生扩容,key 和 value 的位置就会改变,之前保存的地址也就失效了。

示例

Go
package main

import "fmt"

func main() {
        m := make(map[string]int)

        fmt.Println(&m["qcrao"])
}

会出现编译报错:

Go
./main.go:8:14: cannot take the address of m["qcrao"]

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

不会,delete一个key,并不会立刻释放或收缩Map占用的内存。具体来说,delete(m, key) 这个操作,只是把key和value对应的内存块标记为"空闲",让它们的内容可以被后续的垃圾回收(GC)处理掉。但是,Map底层为了存储这些键值对而分配的"桶"(buckets)数组,它的规模是不会缩小的。只有在置空这个map的时候,整个map的空间才会被垃圾回后释放

11. Map可以边遍历边删除吗

map 并不是一个线程安全的数据结构。如果多个线程边遍历,边删除,同时读写一个 map 是未定义的行为,如果被检测到,会直接 panic。

如果是发生在多个协程同时读写同一个 map 的情况下。 如果在同一个协程内边遍历边删除,并不会检测到同时读写,理论上是可以这样做的。但是,遍历的结果就可能不会是相同的了,有可能结果遍历结果集中包含了删除的 key,也有可能不包含,这取决于删除 key 的时间:是在遍历到 key 所在的 bucket 时刻前或者后。这种情况下,可以通过加读写锁sync.RWMutex来保证

Interface

1. Go语言中,interface的底层原理是怎样的?

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

eface是空interface{}的实现

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

iface是带方法的interface实现

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

分析

eface定义:

Go
type eface struct {
   _type *_type
   data  unsafe.Pointer
}

iface定义:

Go
type iface struct {
   tab  *itab
   data unsafe.Pointer
}

其中itab的结构定义如下:

Go
type itab struct {
   inter *interfacetype
   _type *_type
   hash  uint32 // copy of _type.hash. Used for type switches.
   _     [4]byte
   fun   [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
}

2. iface和eface的区别是什么?

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

eface是空接口interface{}的底层实现,结构非常简单,只有两个字段:_type指向类型信息,data指向实际数据。因为空接口没有方法约束,所以不需要存储方法相关信息。

iface是非空接口的底层实现,结构相对复杂,包含itabdata。关键是这个itab,它不仅包含类型信息,还包含了一个方法表,存储着该类型实现的所有接口方法的函数指针。

3. 类型转换和断言的区别是什么?

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

安全性差别很大

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

使用场景不同

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

底层实现也不同

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

4. Go语言interface有哪些应用场景

Go语言的interface主要有几个核心应用场景:

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

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

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

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

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

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

  1. 接口值之间可以使用 ==!=来进行比较。两个接口值相等仅当它们都是nil值,或者它们的动态类型相同并且动态值也根据这个动态类型的==操作相等。如果两个接口值的动态类型相同,但是这个动态类型是不可比较的(比如切片),将它们进行比较就会失败并且panic。

  2. 接口值在与非接口值比较时,Go会先将非接口值尝试转换为接口值,再比较。

  3. 接口值很特别,其它类型要么是可比较类型(如基本类型和指针)要么是不可比较类型(如切片,映射类型,和函数),但是接口值视具体的类型和值,可能会报出潜在的panic。

分析

接口类型和 nil 作比较

接口值的零值是指动态类型动态值都为 nil。当仅且当这两部分的值都为 nil 的情况下,这个接口值就才会被认为 接口值 == nil

Go
package main

import "fmt"

type Coder interface {
        code()
}

type Gopher struct {
        name string
}

func (g Gopher) code() {
        fmt.Printf("%s is coding\n", g.name)
}

func main() {
        var c Coder
        fmt.Println(c == nil)
        fmt.Printf("c: %T, %v\n", c, c)

        var g *Gopher
        fmt.Println(g == nil)

        c = g
        fmt.Println(c == nil)
        fmt.Printf("c: %T, %v\n", c, c)
}

程序输出:

Go
true
c: <nil>, <nil>
true
false
c: *main.Gopher, <nil>

一开始,c 的 动态类型和动态值都为 nilg 也为 nil,当把 g 赋值给 c 后,c 的动态类型变成了 *main.Gopher,仅管 c 的动态值仍为 nil,但是当 cnil 作比较的时候,结果就是 false 了。

6. 什么类型可以作为map的key

在Go语言中,map的key可以是任何可以**比较**的类型。这包括所有的基本类型,如**整数、浮点数、字符串和布尔值,以及结构体和数组**,只要它们没有被定义为包含不可比较的类型(如切片、映射或函数)。

以下是一些可以作为map key的类型的例子:

  1. 整数类型
Go
m := make(map[int]string)
m[1] = "one"
  1. 字符串类型
Go
m := make(map[string]int)
m["one"] = 1
  1. 布尔类型
Go
m := make(map[bool]string)
m[true] = "yes"
m[false] = "no"
  1. 结构体类型(只要结构体的所有字段都是可比较的)
Go
type Point struct {
    X, Y int
}

m := make(map[Point]string)
m[Point{1, 2}] = "1,2"
  1. 数组类型(数组的元素类型必须是可比较的)

go m := make(map[[2]int]string) m[[2]int{1, 2}] = "1,2"

注意,切片、映射和函数类型是不可比较的,因此不能作为map的key。如果你需要一个包含这些类型的key,你可以考虑使用一个指向这些类型的指针,或者将它们封装在一个可比较的结构体中,并确保结构体不包含任何不可比较的类型。

底层实现补充(节选)

slice

运行时 slice{ array, len, cap }截取**子切片仍可能共享底层数组,修改重叠区间会彼此影响;**仅当 append 触发扩容时,新数组与旧数组分离。cap 从截取起点算到**原底层数组末尾**(在合法 cap 约束下),与“长度”不同,截取后可用容量常大于 len

map

运行时核心为 hmap + bmap 桶数组;每个桶通常容纳 8 个槽位,含 tophash 等。**拉链**通过 overflow 桶链接。扩容与**渐进式迁移**时会出现 oldbuckets / nevacuate 等字段;遍历顺序**不保证**稳定。并发读写请用**互斥**或 sync.Map,不要依赖 map 自带线程安全。

string

字符串是**只读字节序列**(builtin 注释:8-bit bytes,惯例上**多为 UTF-8 文本)。运行时可用 stringStruct{ str, len } 理解:len 为**字节长度,非“字符数”。string[]byte 转换通常会**分配新内存**(或共享只读后备,依场景优化),修改内容请通过 []byte 再转回。

评论