当前位置: 首页 > news >正文

Go数据结构

目录

一、string字符串

二、Slice切片

2.1.知识点

2.2.slice避坑指南

2.2.1. 母子切片共享

2.2.2. 切片导致内存泄露

2.2.3. 遍历slice时修改slice

三、Map哈希表?

3.1. 底层结构

 3.2. 扩容规则

4. map相关八股文

四、for和range几个“神奇”的问题

1. 循环永动机

2. 神奇的指针

3. Map遍历的值是随机的

五、defer

六、反射?

七、多路Select

八、闭包

九、context 上下文

9.1. 一个接口

9.2. 四种实现 + 六个函数

十、channel

10.1. 底层实现原理(数据结构)

10.2. 案例分析

10.3. 总结

10.4. 使用案例+注意事项


一、string字符串

1. 结构

type StringHeader struct {
	Data uintptr
	Len  int
}

        字符串是由字符组成的数组,C 语言中的字符串使用字符数组 char[] 表示。数组会占用一片连续的内存空间,而内存空间存储的字节共同组成了字符串,Go 语言中的字符串只是一个只读(只读只意味着字符串会分配到只读的内存空间)的字节数组,下图展示了 "hello" 字符串在内存中的存储方式:

        

        编译报错:

                

2. string和[]byte转换

        1. 字符串和 []byte 中的内容虽然一样,但是字符串的内容是只读的,我们不能通过下标或者其他形式改变其中的数据,而 []byte 中的内容是可以读写的

        2. 不过无论从哪种类型转换到另一种都需要拷贝数据,而内存拷贝的性能损耗会随着字符串和 []byte 长度的增长而增长

二、Slice切片

2.1.知识点

type SliceHeader struct {
	Data uintptr  // 指向数组的指针
	Len  int      // 当前切片的长度  
	Cap  int      // 当前切片的容量
}

扩容策略:在分配内存空间之前需要先确定新的切片容量,运行时根据切片的当前容量选择不同的策略进行扩容

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

考题

func f1(){
    s:=[]int{1,2,3}  
    f2(s)  
    f3(s)  
    fmt.Println(s)  
}  
func f2(s []int){  // 相当于C++里面传入了*int,指向arr的指针
    s[0]=100  
}  
func f3(s []int){  // 相当于C++传入了vector<int>,不能修改,若修改,要传入vector<int>&
    s=append(s,200)// 此时的s,不是实参的s  
}  
func main() {
    f1()
}
// 以上代码输出
// 100 2 3

2.2.slice避坑指南

2.2.1. 母子切片共享

parent := make([]int, 3, 5) //len=3, cap=5
child := parent[1:3] //len=2, cap=4	

1. 刚开始,子切片和母切片共享底层的内存空间

2. 修改子切片会反映到母切片上

3. 在子切片上执行append会把新元素放到母切片预留的内存空间上;当子切片不断执行append,耗完了母切片预留的内存空间,子切片跟母切片就会发生内存分离,此后两个切片没有任何关系

2.2.2. 切片导致内存泄露

func returnSubSlice() []int {
  parent := make([]int, TOTAL)
  child := parent[begin:end]
  return child
}

        如上代码,假设母切片占用内存8M,child切片是在parent基础上构造的占用内存1M,子切片和母切片共享同一块内存,当函数返回后,母切片已经不在使用,本该进行释放,但是由于和子切片公用一块内存未释放母切片,造成了7M的内存泄漏

        正确做法应该是给子切片开辟空间,然后for循环将需要的数据从母切片拷贝到子切片上,然后返回子切片

2.2.3. 遍历slice时修改slice

sli := []int{1, 2, 3}
//方法一: 修改失败,v是slice元素的拷贝,并不会影响到元素v本身
for _, v := range sli {
	v = v + 1
}
//方法二: 修改成功
for i, v := range sli {
	sli[i] = v + 1
}

三、Map哈希表?

3.1. 底层结构

map的底层实现就是哈希表,map类型的变量本质上是一个指针(*hmap),指向hmap结构体

type hmap struct{
    count int     // 键值对的个数
    flags uint8
    B     uint8   // 记录bmap桶的数目是2的多少次幂,因为选择桶时用的是与运算的方法
    hash0 uint32

    noverflow uint32 // 记录已经使用的溢出桶的数量

    bucket     unsafe.Pointer // 桶
    oldbucket  unsafe.Pointer // 旧桶(扩容阶段保存旧桶在哪里)
    nevacuate  uintptr        // 记录渐进式扩容阶段下一个要迁移的旧桶编号

    extra      *mapextra
}

先来看下map使用的桶长什么样子,也就是bmap结构

        1. 一个bmap桶里可以放8个键值对

        2. 但是为了让内存排列更加紧凑,8个key放在一起,8个val放在一起

        3. 在8个key的前面,则是8个tophash(每个tophash都是对应哈希值的高8位)

        4. 在最下面是bmap类型的指针,指向一个溢出桶(溢出桶的结构和bmap相同,是为了减少扩容次数而引入的)

                4.1. 当一个bmap桶存满了,该bmap桶还有可用的溢出桶时,就会在bmap桶后面链接一个溢出桶,将kv存入溢出桶

        

                4.2. 实际上,如果哈希表分配的bmap桶的数目大于2^4=16个(即B>4),就认为使用溢出桶的几率很大,就会预分配2^(B-4)个溢出桶备用(这些溢出桶和常规桶在内存中是连续的;只是前2^B个用作常规桶,后面的用作溢出桶)

        举例:假设B=5,那么bmap桶个数=2^B=2^5=32个,剩下的作为溢出桶2^(B-4)=2^(5-4)=2个

                

                4.3. hmap结构体最后还有一个extra字段,指向一个mapextra结构体,里面记录的都是溢出桶相关的信息,其中,

                        4.3.1. nextoverflow指向下一个空闲溢出桶

                        4.3.2. overflow指向已经使用的溢出桶

                

 3.2. 扩容规则

        默认负载因子 = 6.5 = count / 2^B

case1:负载因子>6.5(有效元素很多),就会发生“翻倍扩容”,分配新桶的数目是旧桶的2倍

        buckets指向新桶,oldbuckets指向旧桶,nevacuate=0(表示接下来要迁移编号为0的旧桶),每个旧桶的键值对都会分流到两个新桶中

        

 case2:负载因子<=6.5(有效元素很少),使用的溢出桶较多(bmap常规桶的数量>2^15 && 使用溢出桶数量>常规桶数量),会发生“等量扩容”,即创建和旧桶数量一样多的新桶,将旧桶的数据迁移到新桶。

        思考:那么既然是等量扩容,将数据迁移来迁移去有什么用?

        答:很多键值对被删除,导致使用了很多溢出桶。迁移后,能够使得数据排列的更加紧密。

4. map相关八股文

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

答:不会,会标记删除,当负载因子<6.5且使用了很多溢出桶时,会发生等量扩容迁移,释放内存

2. golang 哪些类型可以作为map key

        先说结论:可以用于比较的字段可以作为key

        可以用于比较的类型:bool、数字、string、point指针、channel、interface接口、struct、array

        不能用于比较的类型:slice、map、func

3. 哈希函数:怎么判断一个元素是否在哈希表中

        1. 先通过低B位找到是哪个编号的桶bmap

        2. 在桶bmap里面通过高八位进行循环判断,如果高八位相同了,在进行全部hash值的判断(判断key是否相同)

四、for和range几个“神奇”的问题

1. 循环永动机

func main() {
	arr := []int{1, 2, 3}
	for _, v := range arr { 
		arr = append(arr, v) // 遍历arr的时候,向arr增加元素
	}
	fmt.Println(arr)
}

$ go run main.go
1 2 3 1 2 3

        上述代码的输出意味着循环只遍历了原始切片中的3个元素,我们在遍历切片时追加的元素不会增加循环的执行次数,所以循环最终还是停了下来

        遍历的底层原理:对于所有的 range 循环,Go 语言都会在编译期将原切片或者数组赋值给一个新变量 ha,在赋值的过程中就发生了拷贝,而我们又通过 len 关键字预先获取了切片的长度,所以在循环中追加新的元素也不会改变循环执行的次数,这也就解释了循环永动机一节提到的现象

ha := a
hv1 := 0
hn := len(ha)
v1 := hv1
for ; hv1 < hn; hv1++ {
    v1 = hv1
    ...
}

2. 神奇的指针

func main() {
	arr := []int{1, 2, 3}
	newArr := []*int{}
	for _, v := range arr {
		newArr = append(newArr, &v)
	}
	for _, v := range newArr {
		fmt.Println(*v)
	}
}

$ go run main.go
3 3 3

说明:一些有经验的开发者不经意也会犯这种错误,正确的做法应该是使用 &arr[i] 替代 &v

ha := a
hv1 := 0
hn := len(ha)
v1 := hv1
v2 := nil
for ; hv1 < hn; hv1++ {
    tmp := ha[hv1]
    v1, v2 = hv1, tmp
    ...
}

        而遇到这种同时遍历索引和元素的 range 循环时,Go 语言会额外创建一个新的 v2 变量存储切片中的元素,循环中使用的这个变量 v2 会在每一次迭代被重新赋值而覆盖,赋值时也会触发拷贝。

        因为在循环中获取返回变量的地址都完全相同,所以会发生神奇的指针一节中的现象。因此当我们想要访问数组中元素所在的地址时,不应该直接获取 range 返回的变量地址 &v2,而应该使用 &a[index] 这种形式

3. Map遍历的值是随机的

func main() {
	hash := map[string]int{
		"1": 1,
		"2": 2,
		"3": 3,
	}
	for k, v := range hash {
		println(k, v)
	}
}
$ go run main.go
2 2
3 3
1 1

$ go run main.go
1 1
2 2
3 3

         两次运行上述代码可能会得到不同的结果,第一次会打印 2 3 1,第二次会打印 1 2 3,如果我们运行的次数足够多,最后会得到几种不同的遍历顺序。

原理

1. 首先生成一个随机数帮助我们随机选择一个遍历桶的起始位置。Go 团队在设计哈希表的遍历时就不想让使用者依赖固定的遍历顺序,所以引入了随机数保证遍历的随机性。

2. 选择桶

        首先会选出一个绿色的正常桶开始遍历,随后遍历所有黄色的溢出桶,最后依次按照索引顺序遍历哈希表中其他的桶,直到所有的桶都被遍历完成

map 循环是有序的还是无序的?答:无序的,map 因扩张⽽重新哈希时,各键值项存储位置都可能会发生改变,顺序自然也没法保证了,所以官方避免大家依赖顺序,直接打乱处理。就是 for range map 在开始处理循环逻辑的时候,就做了随机播种

五、defer

作用:延迟函数,释放资源、收尾工作

1. 调用顺序:后进先出

2. 执行顺序:defer、return,return value(函数返回值)

func b() (i int) {
	defer func() {
		i++
		fmt.Println("defer2:", i)
	}()
	defer func() {
		i++
		fmt.Println("defer1:", i)
	}()
	return i //或者直接写成return
}
func main() {
	fmt.Println("return:", b())
}

defer1: 1
defer2: 2
return: 2

3. defer底层的数据结构

        1. 每个defer都对应一个_defer实例,多个实例通过指针串联成一个单链表,保存在gotoutine数据结构中

        2. 每次插入_defer实例,均头插;函数结束也是从链表头部去除开始

        

六、反射?

反射的作用,就是把类型元数据暴露给用户使用

        

Go 中解析的 tag 是通过反射实现的,反射是指计算机程序在运行时(Run time)可以访问、检测和修改它本身状态或行为的一种能力或动态知道给定数据对象的类型和结构,并有机会修改它。反射将接口变量转换成反射对象 Type 和 Value;反射可以通过反射对象 Value 还原成原先的接口变量;反射可以用来修改一个变量的值,前提是这个值可以被修改;

TypeOf 获取一个变量的类型信息 func TypeOf(i interface{}) Type

ValueOf 获取变量的值

七、多路Select

        select结构组成主要是由case语句和执行的函数组成的,select的case和switch不同,只能处理channel。

        1. select仅支持channel

        2. select出现读写nil的channel,该分支会被忽略

        3. 每个case只能处理一个channel,要么读要么写

        4. select 在遇到多个 Channel 同时响应时,会随机执行一种情况

        5. 存在default语句,select将不会阻塞

八、闭包

        有人形象的概括闭包就是:函数 + 引用环境 = 闭包,要搞清楚闭包的关键就是分析出返回的函数它引用到哪些变量

说到Go语言的闭包,不得不说说全局变量和局部变量

        全局变量的特点:1.常驻内存  2. 污染全局
        局部变量的特点:1.不常驻内存  2.不污染全局

而Go语言的闭包可以做到:1.可以让变量常驻内存  2.可以让变量不污染全局 ===> 所以闭包主要是为了避免全局变量的滥用

闭包:
        1.闭包是指有权访问另一个函数作用域中的变量的函数==>包括自由变量(在函数外部定义但在函数内被引用)
        2.创建闭包的常见方式就是在一个函数内部创建另一个函数, 内函数可以访问外函数的变量 ==> 即使脱离了捕捉时的上下文,它也能照样执行

注意:
      闭包里作用域返回的局部变量不会被立刻销毁回收,但过度使用闭包可能会占用更多内存,导致性能下降

例子1

// 函数create()的返回值是一个函数func() int
func create() func() int {
	c := 2              // 通常称这个变量为"捕获变量"
	return func() int { // 该函数使用了外部定义的变量c
		return c
	}
}
func main() {
	// 即使create()结束,通过f1和f2依然能够正常调用这个闭包函数,并使用在create()函数内部定义的局部变量c
	f1 := create()
	f2 := create()
	fmt.Println(f1(), f2()) // 2 2
}

例子2

1. addTool是一个函数,返回的数据类型是func(int)int
2. 这个匿名函数就和变量n形成一个整体,构成闭包。大家可以这样理解:
闭包是类,函数是操作,n是字段,函数和它使用的变量构成闭包
3. 当我们反复调用f函数时,因为n是初始化一次,因此每调用一次就进行累计

func addTool() func(int) int {
	var n = 10
	return func(x int) int { // 闭包
		n = n + x
		return n
	}
}
func main() {
	f := addTool()
	fmt.Println(f(1)) // 11
	fmt.Println(f(2)) // 13
	fmt.Println(f(3)) // 16
}

九、context 上下文

先来几个问答

Q1:context设计成线程安全?

A1:不同协程通过channel进行通信,本身的使用场景就是多线程,为了保证数据的一致性,必须实现线程安全

Q2:如何实现线程安全的?

A2:channel的底层实现中,hchan结构体中采用mutex锁来保证数据读写安全。在对循环数组buf中的数据进行入队和出队操作时,必须先获取mutex,才能操作channel数据

应用场景:在多个协程之间,传递截止时间、取消信号、请求相关的数据

        

context概括为:1个接口,4种实现,6个函数  

        

9.1. 一个接口

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key interface{}) interface{}
}

Deadline():返回值(deadline time.Time, ok bool)

        1. ok:表示有结束时间,否则表示无结束时间

        2. time.Time:表示context的截止时间,通过此时间,函数就可以决定是否进行接下来的操作:①如果时间太短,就可以不往下面做了,否则浪费系统资源 ②也可以使用这个deadline来设置一个IO操作的超时时间

Done()

        1. 返回一个只读的chan,类型为struct{}

        2. 在goroutine中,如果该方法返回的chan可以读取,则意味着parent context已经发起了取消请求,通过Done方法收到这个信号后,就应该做清理操作,然后退出goroutine,释放资源

Err():返回一个错误,表示channel被关闭的原因,例如:被取消、超时等

Value():Value方法获取该Context上绑定的值,是一个键值对,所以要通过一个Key才可以获取对应的值,这个值一般是线程安全的

9.2. 四种实现 + 六个函数

9.2.1. emptyCtx

        emptyCtx对context的实现,只是简单的返回nil、false,实际上什么也没做

        

9.2.2. cancelCtx — 可取消的context

type cancelCtx struct {
	Context
	mu       sync.Mutex            
	done     chan struct{}         // 用于获取该context的取消通知
	children map[canceler]struct{} // 用于存储以当前节点为根节点的所有可取消的context
	err      error                 // 存储取消时指定的错误信息
}

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) 

作用:将context包装成cancelCtx,并提供一个取消函数cancel,调用它可以cancel对应的context

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	go func(ctx context.Context) {
		for {
			select {
			case <-ctx.Done():
				fmt.Println("监控退出,停止了...")
				return
			default:
				fmt.Println("goroutine监控中...")
				time.Sleep(2 * time.Second)
			}
		}
	}(ctx)

	time.Sleep(10 * time.Second)
	fmt.Println("可以了,通知监控停止")
	cancel()
	//为了检测监控过是否停止,如果没有监控输出,就表示停止了
	time.Sleep(5 * time.Second)
}

9.2.3. timerCtx — 超时取消的context

        timerCtx是在cancelCtx基础上,增加了定时器/截止时间功能,这样,①既可以根据需要主动取消,②也可以到达deadline时,通过timer来调用cancelCtx的取消函数

type timerCtx struct {
	*cancelCtx
	timer *time.Timer  // 定时器
	deadline time.Time // 截止时间
}

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

作用:创建timerCtx对象,指定时间段

        
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc) 

作用:创建timerCtx对象,指定时间点

9.2.3. valueCtx — 支持键值对打包        

        

  给context附加一个键值对信息,支持context传递数据

        

十、channel

10.1. 底层实现原理(数据结构)

        

        环形数组/读写下标:buf、sendx、recvx

        goroutine队列:sendq等待写消息的 goroutine 队列、recvq等待读消息的 goroutine 队列

        互斥锁:lock

10.2. 案例分析

1. 创建一个具有5个缓冲区的channel

2. 协程G1向channel写入1,2,3,4,5,6,写入1~5时,不会发生阻塞,缓冲区被写满

3. 当写入6时,因为缓冲区满了,所以6无处可放,此时协程G1就会加入到sendq中。sudog是协程队列中的元素结构体,g保存了协程、elem保存了等待发送的数据6、c保存了阻塞在哪个chan

4. 当开了协程G2去channel读取数据,recvx下标向前移动

        

5. 此时,因为缓冲区buf中存在空闲位置了,所以会去唤醒sendq中的发送协程G1,G1会将数据发送给缓冲区buf

        

6. 缓冲区buf再次满了,G1继续挂起,保存在recvq中,循环执行上面步骤... ...

10.3. 总结

向channel发送数据

是否阻塞取决于2个条件:① recvq中是否有消费者 ②buf是否为空

        如果channel的recvq存在接收者goroutine:将数据直接发送给第一个等待的goroutine,唤醒接收的goroutine

        如果channel的recvq不存在接收者goroutine:

                1. 如果循环数组buf未满,那么将会把数据发送到buf的队尾

                2. 如果循环数组buf已满,此时就会走阻塞发送的流程,将当前发送数据的goroutine加入sendq(不让其发送数据),并挂起等待唤醒

从channel接收数据

是否阻塞取决于2个条件:① sendq中是否有生产者 ②buf是否为空

        如果channel的sendq存在发送者goroutine

                1. 如果是无缓冲channel,直接从第一个发送者goroutine哪里把数据拷贝给接收变量,唤醒发送的goroutine

                2. 如果是有缓冲channel(已满),将循环数组buf的队首元素拷贝给接收变量,将第一个发送者goroutine的数据拷贝到buf队尾,唤醒发送的goroutine

        如果channel的sendq不存在发送者goroutine

                1. 如果buf非空,将循环数组buf的队首元素拷贝给接收变量

                2. 如果buf为空,这个时候就会走阻塞接收的流程,将当前goroutine加入readq,并挂起等待唤醒

10.4. 使用案例+注意事项

channel有2种类型:无缓冲、有缓冲

channel有3种模式:写操作模式(单向通道)、读操作模式(单向通道)、读写模式(双向通道)

创建
make(chan <-int) // 写操作模式
make(<-chan int) // 读操作模式
make(chan int)   // 读写操作模式
未初始化关闭正常
关闭panicpanic正常关闭
发送永远阻塞导致死锁panic阻塞或者成功发送
接收永远阻塞导致死锁缓冲区为空则为零值,否则可以继续读阻塞或者成功接收

注意点:

1. 一个channel不能多次关闭,会导致panic

2. 若多个goroutine都监听同一个channel,那么channel上的数据都可能随机被某一个goroutine取走进行消费

3. 若多个goroutine监听同一个channel,如果这个channel被关闭,则所有goroutine都能收到退出信号

相关文章:

  • Vue学习---插槽篇
  • .NET教程 - 字符串 编码 正则表达式(String Encoding Regular Express)
  • Yii - [新]项目开发流程指南
  • 优秀的你在哪里?《阿里云SLS团队2023校园招聘》
  • 【图像分类】基于matlab多种特征结合支持向量机脑MRI肿瘤分类【含Matlab源码 2149期】
  • 06-使用pytorch实现手写数字识别
  • 高级特效开发阶段学习总结
  • WPF 简单的ComboBox自定义样式。
  • Servlet 规范和 Servlet 容器
  • 切面的优先级、基于XML的AOP实现
  • 【Java面试宝典】常用类中的方法重写|equals方法与逻辑运算符==的区别
  • 重构的原则
  • Restyle起来!
  • 【Unity3D日常BUG】Unity3D中出现“unsafe code 不安全的代码”的错误时的解决方法
  • Node中实现一个简易的图片验证码流程
  • 9月CHINA-PUB-OPENDAY技术沙龙——IPHONE
  • 收藏网友的 源程序下载网
  • 【css3】浏览器内核及其兼容性
  • android高仿小视频、应用锁、3种存储库、QQ小红点动画、仿支付宝图表等源码...
  • Angular2开发踩坑系列-生产环境编译
  • Codepen 每日精选(2018-3-25)
  • ES6, React, Redux, Webpack写的一个爬 GitHub 的网页
  • Kibana配置logstash,报表一体化
  • laravel with 查询列表限制条数
  • Node.js 新计划:使用 V8 snapshot 将启动速度提升 8 倍
  • PHP 程序员也能做的 Java 开发 30分钟使用 netty 轻松打造一个高性能 websocket 服务...
  • react 代码优化(一) ——事件处理
  • redis学习笔记(三):列表、集合、有序集合
  • vue 个人积累(使用工具,组件)
  • vue脚手架vue-cli
  • 番外篇1:在Windows环境下安装JDK
  • 给初学者:JavaScript 中数组操作注意点
  • 如何进阶一名有竞争力的程序员?
  • 使用parted解决大于2T的磁盘分区
  • 手机app有了短信验证码还有没必要有图片验证码?
  • 思维导图—你不知道的JavaScript中卷
  • 正则与JS中的正则
  • elasticsearch-head插件安装
  • ​批处理文件中的errorlevel用法
  • # 计算机视觉入门
  • %@ page import=%的用法
  • (5)STL算法之复制
  • (八)c52学习之旅-中断实验
  • (二) Windows 下 Sublime Text 3 安装离线插件 Anaconda
  • (附源码)spring boot智能服药提醒app 毕业设计 102151
  • (附源码)springboot教学评价 毕业设计 641310
  • (蓝桥杯每日一题)平方末尾及补充(常用的字符串函数功能)
  • (力扣)1314.矩阵区域和
  • (转)mysql使用Navicat 导出和导入数据库
  • (转)创业的注意事项
  • ***微信公众号支付+微信H5支付+微信扫码支付+小程序支付+APP微信支付解决方案总结...
  • . ./ bash dash source 这五种执行shell脚本方式 区别
  • .bat批处理(四):路径相关%cd%和%~dp0的区别
  • .NET 实现 NTFS 文件系统的硬链接 mklink /J(Junction)
  • .net开源工作流引擎ccflow表单数据返回值Pop分组模式和表格模式对比