Golang面试题二(基础类)

发布于:2024-04-19 ⋅ 阅读:(22) ⋅ 点赞:(0)

目录

1.Go 语言当中 new和make 的作用是什么

2.Printf(), Sprintf(), FprintF() 都是格式化输出,有什么不同?

3.Go 语言当中数组和切片的区别是什么?

数组

切片

区别

4.Go 语言当中值传递和地址传递(引用传递)如何运用?有什么区别? 

值传递

地址传递(引用传递)

总结

5.defer 的执行顺序是什么? defer 的作用和特点是什么?

defer 的执行顺序

作用和特点

6.Golang Slice 的底层实现

1. 结构体定义

2. 底层数组

3. 动态扩容

4. 分享底层数组

5. 切片操作的低成本

6. 安全性

总结

7.Golang Slice 的扩容机制,有什么注意点

扩容机制

注意点

总结

8.扩容前后的 Slice 是否相同

9.深拷贝和浅拷贝

浅拷贝(Shallow Copy)

深拷贝(Deep Copy)

总结

10.Golang Map 底层实现

1. 哈希表结构

2. 哈希函数

3. 开放寻址法与拉链法

4. 扩容机制

5. 键的要求

6. 并发安全

总结

11.Golang Map 如何扩容

1. 触发扩容条件

2. 选择新容量

3. 分配新哈希表

4. 重哈希并迁移元素

5. 替换哈希表

6. 回收旧哈希表

总结

12.Golang Map 如何查找

1. 计算哈希值

2. 计算索引

3. 查找桶内元素

4. 处理哈希冲突

13.介绍一下 Channel

基本概念

操作

应用场景

特性

总结

14.Channel 的 ring buffer 实现

环形缓冲(Ring Buffer)概述

Channel 中的环形缓冲实现

示意图

总结


1.Go 语言当中 new和make 的作用是什么

new 函数用于为任意类型分配内存并初始化为零值,返回指向该内存的指针,适用于创建普通的值类型。而 make 函数专门用于创建并初始化切片、映射和通道这三种内建类型,返回的是这些类型的实例本身,而不是指针。

new

type MyStruct struct {
    Field1 int
    Field2 string
}

// 分配并初始化一个 MyStruct 类型的内存空间
s := new(MyStruct)

// s 是一个 *MyStruct 类型的指针,指向新分配的内存
fmt.Printf("Type of s: %T\n", s) // 输出:*main.MyStruct

// 通过指针 s 访问和修改 MyStruct 的字段
s.Field1 = 42
s.Field2 = "Hello"

fmt.Printf("MyStruct fields: Field1=%d, Field2=%s\n", s.Field1, s.Field2) // 输出:MyStruct fields: Field1=42, Field2=Hello

 make

// 创建一个长度为 5、容量为 5 的整数切片
slice := make([]int, 5)

// 创建一个空的字符串到整数的映射
map := make(map[string]int)

// 创建一个无缓冲的整数通道
channel := make(chan int)

2.Printf(), Sprintf(), FprintF() 都是格式化输出,有什么不同?

  • Printf 是标准输出,一般是屏幕,也可以重定向。
  • Sprintf()是把格式化字符串输出到指定的字符串中。
  • Fprintf()是把格式化字符串输出到文件中。

3.Go 语言当中数组和切片的区别是什么?

数组

数组是具有固定大小的同类型元素的集合。定义数组时需要指定元素类型和数组长度。数组的大小(即元素数量)在创建时就确定,并且不能更改。

切片

切片是一个可变大小的、同类型元素的序列,是对数组的一个抽象视图。切片不直接存储元素,而是指向一个数组(或另一个切片)的部分或全部元素。切片定义时不需要指定长度,但可以指定初始容量(可选)。

区别

  • 数组具有固定大小,切片大小可变。
  • 数组不灵活,切片灵活,可以动态增长和缩小。
  • 数组直接分配所有元素内存,切片仅存储指向底层数组的指针、长度和容量。
  • 数组在函数调用和赋值时复制整个数组,切片只复制切片结构体。
  • 数组访问性能略优,切片操作更灵活,适用于大多数实际场景。

4.Go 语言当中值传递和地址传递(引用传递)如何运用?有什么区别? 

Go 语言中所有的传参都是值传递(传值),都是一个副本,一个拷贝。

在 Go 语言中,值传递和地址传递(通常被称为“引用传递”)是两种不同的参数传递方式,它们决定了函数调用过程中实参与形参之间的关系以及对实参的影响。

值传递

基本类型:在函数调用时,基本类型(如整型、浮点型、布尔型、字符串等)作为参数时,默认采用值传递。函数内部接收到的是实参的副本,对副本的修改不影响实参。

复合类型:复合类型(如数组、切片、结构体等)作为参数时,同样默认采用值传递。函数接收到的是复合类型的副本,但请注意,对于切片和接口,虽然复制了切片头或接口描述符,但它们可能共享相同的底层数组或动态类型值

副本操作:值传递将实参的副本传递给函数,函数内部对参数的修改不会影响实参。

性能:对于大尺寸的复合类型,值传递可能涉及较多的数据复制,可能影响性能。但对于小尺寸或基本类型,影响通常不大。

地址传递(引用传递)

指针类型:通过将指针作为参数传递,可以实现地址传递。函数接收到的是实参地址的副本,通过该地址可以直接修改实参的值。

可寻址的复合类型:对于数组、切片、结构体等复合类型,可以使用 & 运算符获取其地址,然后将地址作为参数传递。函数内部通过解引用指针来修改实参。

共享状态:地址传递使得函数能够直接修改实参的值,实参和形参共享同一块内存区域,修改形参即修改实参。

性能:地址传递只需要传递指针(内存地址),通常比值传递更节省内存和 CPU 时间,特别适合大尺寸数据结构的传递。

总结

值传递和地址传递在 Go 语言中的运用主要体现在函数参数传递时:

  • 值传递:函数接收实参的副本,对副本的修改不影响实参。适用于不需要修改实参的场景,或实参为小尺寸数据类型。
  • 地址传递(引用传递):函数接收实参地址的副本,通过该地址可以直接修改实参。适用于需要在函数内部修改实参的场景,特别是大尺寸数据结构。

选择使用哪种传递方式应根据实际需求权衡数据安全性、性能和代码可读性。在需要保持数据隔离时使用值传递,而在需要共享和修改状态时使用地址传递。

5.defer 的执行顺序是什么? defer 的作用和特点是什么?

defer 的执行顺序

在 Go 语言中,defer 关键字用于延迟执行一个函数调用,直到包含该 defer 语句的函数或方法返回时才执行。defer 语句的执行顺序遵循以下规则:

  1. 后进先出(LIFO,Last In First Out):在同一个函数或方法中,多个 defer 语句的执行顺序与它们在代码中出现的顺序相反。也就是说,最近定义的 defer 语句会最先执行,而最早定义的 defer 语句会最后执行。

  2. 延迟执行:无论 defer 语句出现在函数或方法中的哪个位置,它指定的函数调用都会在函数或方法即将返回时(即所有的return语句执行之前)按上述顺序执行。

  3. 即使出现 panic:即使函数或方法内部发生了 panic,defer 语句仍然会被执行,这使得 defer 常用于资源清理、关闭文件、解锁互斥锁等需要确保在函数结束前完成的任务,即使在异常情况下也能得到妥善处理。

  4. 执行时机:对于嵌套的函数调用,每个函数内部的 defer 语句会在相应函数返回时执行,与外部函数的 defer 语句无关。也就是说,每个函数的 defer 语句独立执行,互不影响。

作用和特点

作用

  • 资源清理defer 最常见的用途是确保在函数结束时执行必要的资源清理操作,如关闭文件、释放锁、取消网络请求、清理临时数据等。无论函数是否正常结束(无论是通过 return 还是发生 panic),defer 语句都能确保这些清理操作得到执行。

  • 异常处理:在可能发生异常(panic)的代码块之前使用 defer,可以确保在异常发生后仍能执行必要的恢复操作,如记录错误信息、回滚事务、关闭资源等。

  • 保持代码整洁:通过 defer,可以将与主逻辑分离的清理代码集中放置,使得函数主体更加专注于核心业务逻辑,提高了代码的可读性和可维护性。

特点

  • 延迟执行defer 语句指定的函数调用不会立即执行,而是推迟到包含它的函数或方法即将返回时执行。

  • 后进先出顺序:在同一函数或方法内部,defer 语句按照定义的逆序执行。

  • panic 安全:即使函数中发生了 panicdefer 语句仍然会被执行,有助于在异常情况下保证资源的正确释放和清理。

  • 函数参数即时求值:虽然 defer 语句的执行被延迟,但其函数参数在 defer 语句定义时就会被求值。因此,对于可变的函数参数(如引用类型),在 defer 定义时的值会被捕获并在延迟函数执行时使用。

总结起来,defer 是 Go 语言中一种强大的工具,用于确保在函数返回前执行必要的清理或恢复操作,其后进先出的执行顺序和对 panic 的处理机制使得它在资源管理、异常处理和代码组织方面发挥着重要作用。

package main

func DeferFunc1(i int) (t int) {
	// t = 1
	t = i
	defer func() {
		t += 3
	}()
	return t
}
func DeferFunc2(i int) int {
	t := i
	defer func() {
		t += 3
	}()
	return t
}
func DeferFunc3(i int) (t int) {
	defer func() {
		t += i
	}()
	return 2
}

// DeferFunc1中,有名返回(指定返回值命名func test() (t int)),执行 return 语句时,并不会再创建临时变量保存,defer 语句修改了t,即对返回值产生了影响,所以返回4。
// DeferFunc2中,无名返回(返回值没有指定命名),执行Return语句后,Go会创建一个临时变量保存返回值,defer 语句修改的是 t,而不是临时变量,所以返回1。
// DeferFunc3中,有名返回,执行 return 语句时,把2赋值给t,defer 语句再执行t+1,所以返回3。
func main() {
	println(DeferFunc1(1))
	println(DeferFunc2(1))
	println(DeferFunc3(1))
}

6.Golang Slice 的底层实现

Go 语言中的 Slice(切片)是一种灵活的、动态大小的、基于数组的序列类型。其底层实现主要涉及以下几个关键部分:

1. 结构体定义

Go 语言中的 Slice 在底层其实是一个结构体,通常表示为:

这个结构体包含了三个重要字段:

  • array:一个指向底层数据(数组)的指针,存储切片实际数据的地址。
  • len:当前切片的长度,表示切片中有效元素的数量。
  • cap:当前切片的容量,表示底层数组可以容纳的最大元素数量。切片的长度不能超过其容量。
type slice struct {
    array unsafe.Pointer // 指向底层数组的指针
    len   int           // 当前切片的长度(已使用的元素数量)
    cap   int           // 当前切片的容量(底层数组可容纳的最大元素数量)
}

2. 底层数组

切片并不是直接存储数据,而是通过 array 字段指向一个底层数组。这个数组可以是静态声明的固定大小数组,也可以是通过 make 函数动态创建的。切片通过操作底层数组的一部分来提供动态大小的序列。

3. 动态扩容

当切片的长度达到其容量,且需要添加更多元素时,Go 运行时会自动进行扩容。扩容的具体策略如下:

  • 如果当前容量小于 1024 个元素,新容量通常是当前容量的 2 倍。
  • 如果当前容量大于等于 1024 个元素,新容量通常是当前容量的 1.25 倍(即增加 25%)。
  • 扩容后的容量至少为所需最小容量(即原容量加上追加元素的数量)。

扩容时,Go 运行时会创建一个新的底层数组,将原切片的元素复制到新数组中,然后更新切片结构体的 arraylen 和 cap 字段,使其指向新的底层数组和更新后的容量。

4. 分享底层数组

多个切片可以共享同一个底层数组。当通过切片操作(如 s[i:j])创建新的切片时,新切片与原切片将共享相同的底层数组。对任何一个切片的元素进行修改,都会影响到其他共享同一底层数组的切片。这种特性有利于减少数据复制,提高内存效率。

5. 切片操作的低成本

由于切片结构体仅包含一个指向底层数组的指针以及长度和容量信息,对切片本身的创建、复制和传递操作成本很低。复制一个切片只是复制了结构体本身,而不是复制底层数据。这使得切片在函数调用、返回值以及作为参数传递时非常高效。

6. 安全性

虽然切片提供了对底层数组的动态访问,但其长度和容量的约束以及 Go 运行时的检查机制,可以防止越界访问,保证了切片操作的安全性。

总结

Go 语言中的 Slice 底层实现主要包括一个结构体,该结构体包含指向底层数组的指针以及长度和容量信息。切片通过操作底层数组的一部分实现了动态大小的序列,并在需要时自动进行扩容。切片可以与其他切片共享底层数组,降低了数据复制的成本。Go 运行时对切片操作的检查和约束确保了其安全性。这些特性使得 Slice 成为 Go 语言中处理序列数据的核心工具。

7.Golang Slice 的扩容机制,有什么注意点

Go 语言中的 Slice(切片)在需要增加其容量以容纳更多元素时,会触发自动扩容机制

扩容机制

  1. 触发扩容:当通过 append 函数向 Slice 追加元素,且当前 Slice 容量不足以容纳新增元素时,会触发扩容操作。

  2. 扩容策略

    • 首次扩容:新创建的 Slice 如果没有指定容量,首次扩容通常将容量设置为至少 10,以减少频繁的小容量扩容。
    • 常规扩容
      • 若当前 Slice 容量小于 1024 个元素,扩容后的容量将是现有容量的 2 倍。
      • 若当前 Slice 容量大于等于 1024 个元素,扩容后的容量将是现有容量的 1.25 倍(即增加 25%)。
      • 扩容后的容量始终至少为所需的最小容量(即原容量加上追加元素的数量)。
      • 如果按照正常的扩容策略(翻倍或增加 25%)计算出的新容量仍小于所需最小容量,Go 运行时会直接分配一个足以容纳所有元素的新底层数组。
  3. 内存分配:根据上述扩容策略计算出新容量后,Go 运行时会为新容量分配一块内存,创建一个新的底层数组。

  4. 元素复制:将原 Slice 中的所有元素复制到新分配的底层数组中。如果原 Slice 已有大量元素,这一步可能会成为性能瓶颈。

  5. Slice 更新:更新原 Slice 结构,使其指向新的底层数组,同时更新长度以反映追加元素后的实际元素数量,容量则为新分配的容量。

注意点

  1. 性能影响:虽然 Go 语言的 Slice 扩容机制设计得相对高效,但频繁的扩容操作仍可能影响程序性能。特别是当需要追加大量元素时,每次扩容都会导致元素复制和内存分配。为避免频繁扩容,可以预先通过 make 函数或 append 的第二个参数指定较大的初始容量或目标容量。

  2. 内存泄漏:当一个大容量的 Slice 被扩容后,原底层数组可能不再被任何 Slice 引用,但仍占用内存,直到被垃圾收集器回收。如果程序中存在大量这样的短暂大容量 Slice,可能会导致内存使用率升高。对此,可以考虑适时使用 copy 函数将不再需要的大容量 Slice 复制到一个小容量的新 Slice,释放原大容量底层数组。

  3. 共享底层数组:多个 Slice 可以共享同一底层数组。当一个 Slice 扩容后,原底层数组可能被弃用,但其他共享同一底层数组的 Slice 仍指向旧数组。因此,如果在多线程环境中并发地修改共享底层数组的多个 Slice,可能会遇到意料之外的行为。在这种情况下,应确保同步访问或避免共享底层数组。

  4. 并发安全:Slice 自身并不提供并发安全保证。在并发环境下对 Slice 进行追加、修改等操作时,需要使用互斥锁(如 sync.Mutex)或其他同步原语来确保操作的原子性。

  5. append 返回值append 函数会返回一个新的 Slice,它可能指向新分配的底层数组。在多级 append 或链式 append 操作中,应使用返回值而非原 Slice,以确保操作的正确性:

slice = append(slice, elem1) // 正确:使用 append 的返回值
slice = append(slice, elem2, elem3) // 正确:使用 append 的返回值

总结

Go 语言的 Slice 扩容机制旨在自动适应数据规模的增长,但频繁或不当的扩容可能带来性能问题和内存管理挑战。在使用 Slice 时,应注意预估和管理容量需求,避免不必要的扩容,尤其是在处理大量数据或在并发环境下。同时,理解并正确使用 append 函数的返回值对于确保 Slice 操作的正确性和性能至关重要。

8.扩容前后的 Slice 是否相同

在 Go 语言中,当切片(Slice)进行扩容时,扩容前后的 Slice 会有所不同,具体表现在以下几个方面:

  1. 底层数组:扩容会导致分配一个新的底层数组来存储更多的元素。扩容前的 Slice 指向原来的底层数组,而扩容后的 Slice 指向新分配的底层数组。这意味着扩容前后的 Slice 实际上操作的是不同的内存区域。

  2. 容量(cap):扩容后的 Slice 具有更大的容量(cap),以容纳更多的元素。扩容前后的 Slice 容量值不同。

  3. 元素内容:扩容过程中,原 Slice 中的所有元素会被复制到新分配的底层数组中。因此,扩容前后 Slice 中已存在的元素内容是一样的。但如果扩容后进行了元素追加或修改,那么扩容前后的 Slice 在这些追加或修改的位置上的元素值将会不同。

  4. 长度(len):如果扩容是由于 append 函数追加元素导致的,那么扩容后的 Slice 长度(len)会增加,反映出追加元素后的实际元素数量。如果没有追加元素,仅因手动调整容量(如使用 copy 和 make 函数)导致的扩容,长度可能保持不变。

  5. 地址:由于扩容后 Slice 指向新的底层数组,所以扩容前后的 Slice 结构体(包含 arraylen 和 cap 字段的结构体)在内存中的地址也会不同。

总结来说,扩容前后 Slice 的底层数组、容量、可能的元素内容、长度以及 Slice 结构体的地址都会有所不同。虽然它们在某些属性(如已存在元素的内容)上可能存在相似性,但从整体上看,它们是两个不同的 Slice 实例。在编程实践中,应意识到扩容操作可能导致 Slice 的这些变化,并根据需要合理管理 Slice 的容量,以优化内存使用和程序性能。

9.深拷贝和浅拷贝

在 Go 语言中,深拷贝(Deep Copy)和浅拷贝(Shallow Copy)指的是复制数据结构时的不同方式,它们的区别主要体现在如何对待嵌套的引用类型(如指针、切片、映射和接口):

浅拷贝(Shallow Copy)

定义:浅拷贝仅复制数据结构的原始值和指针,而不复制指针所指向的数据。对于非引用类型(如整数、浮点数、字符串等),浅拷贝会创建这些值的副本。而对于引用类型,浅拷贝仅复制指针本身,使得新旧结构共享同一块内存区域。

package main

import (
	"fmt"
)

type User struct {
	Name    string
	Friends []*User
}

func main() {
	// 原始数据
	original := &User{
		Name: "Alice",
		Friends: []*User{
			{Name: "Bob"},
			{Name: "Charlie"},
		},
	}

	// 浅拷贝
	copied := *original // 或者使用 reflect.Copy()、copy() 等方法实现浅拷贝

	// 修改浅拷贝后的数据
	copied.Name = "Eve"
	copied.Friends[0].Name = "David"

	// 输出结果
	fmt.Println(original.Name) // 输出:Alice
	fmt.Println(copied.Name)   // 输出:Eve

	fmt.Println(original.Friends[0].Name) // 输出:David
	fmt.Println(copied.Friends[0].Name)   // 输出:David
}

深拷贝(Deep Copy)

定义:深拷贝不仅复制数据结构的原始值和指针,还递归地复制所有引用类型所指向的数据,确保新旧结构拥有完全独立的内存区域。对于非引用类型,深拷贝行为与浅拷贝相同。对于引用类型,深拷贝会创建新内存区域存储这些数据,并复制其中的内容。

package main

import "fmt"

type User struct {
	Name    string
	Friends []*User
}

func main() {
	// 原始数据
	original := &User{
		Name: "Alice",
		Friends: []*User{
			{Name: "Bob"},
			{Name: "Charlie"},
		},
	}
	// 深拷贝
	copied := &User{
		Name:    original.Name,
		Friends: make([]*User, len(original.Friends)),
	}
	for i, friend := range original.Friends {
		copied.Friends[i] = &User{Name: friend.Name}
	}

	// 修改深拷贝后的数据
	copied.Name = "Eve"
	copied.Friends[0].Name = "David"

	// 输出结果
	fmt.Println(original.Name) // 输出:Alice
	fmt.Println(copied.Name)   // 输出:Eve

	fmt.Println(original.Friends[0].Name) // 输出:Bob
	fmt.Println(copied.Friends[0].Name)   // 输出:David
}

在深拷贝示例中,original 和 copied 的 Friends 切片分别指向不同的内存区域。因此,修改 copied.Friends[0].Name 不会影响 original.Friends[0].Name

总结

  • 浅拷贝:仅复制原始值和指针,不复制指针所指向的数据。新旧结构共享同一块内存区域,修改一方可能影响另一方。
  • 深拷贝:不仅复制原始值和指针,还递归复制所有引用类型所指向的数据。新旧结构拥有完全独立的内存区域,修改一方不会影响另一方。

在实际编程中,应根据数据复制的目的和后续操作需求选择合适的拷贝方式,以避免意外的数据共享或不必要的性能开销。对于复杂的结构或不确定深度的嵌套数据,可能需要借助库函数(如 encoding/jsonencoding/gob 等)或自定义递归函数来实现深拷贝。

10.Golang Map 底层实现

Go 语言中的 Map(映射)是一种关联数据类型,它将唯一的键(key)与对应的值(value)关联起来。Map 的底层实现基于哈希表(Hash Table),这是一种能够提供接近常数时间复杂度查找、插入和删除操作的数据结构。以下是对 Go 语言 Map 底层实现的详细解析

1. 哈希表结构

Go 语言的 Map 以哈希表为核心实现。哈希表由一个数组(通常称为 buckets 或 bins)和一系列链表组成。数组的每个元素(称为桶)指向一个链表(也可能为空)。链表中的每个节点(称为 entry 或元素)包含一对键值对。

2. 哈希函数

取模法或与运算,golang使用的与运算

当向 Map 插入键值对时,首先使用哈希函数对键进行计算,得到一个哈希值。哈希函数的目标是将键均匀分布到哈希表的桶中,减少碰撞(即不同键计算出相同哈希值的概率)。Go 语言的 Map 实现使用了高效的哈希函数,确保哈希分布的均匀性和查找性能。

3. 开放寻址法与拉链法

Go 语言 Map 的哈希表实现采用了拉链法(Separate Chaining with Linked Lists)来处理键值对的碰撞。即当两个键计算出相同的哈希值时,它们会被插入到同一个桶对应的链表中。链表中的每个节点除了存储键值对外,还保存了下一个节点的指针,形成链式结构。查询、插入和删除操作时,先计算键的哈希值找到对应的桶,然后在链表中依次比较键以找到匹配的节点。

4. 扩容机制

随着 Map 中元素数量的增加,为了保持良好的查找性能,Map 需要适时调整其内部哈希表的大小(即增加桶的数量)。Go 语言 Map 的扩容策略如下:

  • 初始容量:新创建的 Map 通常会有一个较小的初始容量(如 1),随着元素的增加,Map 会自动扩容。

  • 扩容阈值:当 Map 中元素数量达到当前容量(桶数量)的一定比例(如 6.5/8,约为 80%)时,Map 会触发扩容操作。

  • 扩容策略:扩容时,Go 语言会选择一个更大的容量(通常是原容量的两倍),创建一个新的哈希表,并将原哈希表中的所有键值对重新哈希并插入到新表中。扩容过程中,原有的键值对关系保持不变。

  • 负载均衡:扩容后,由于桶数量翻倍,原先的键可能被分配到不同的桶中,有助于分散哈希冲突,降低单个桶中链表的长度,从而提高查找效率。

5. 键的要求

Go 语言 Map 的键必须满足以下要求:

  • 可哈希性:键的类型必须支持 == 操作符进行相等性比较,并且必须实现 hash.Hash 接口(通过实现 hash 包中的 Hash 方法)。几乎所有内置类型(如整数、浮点数、字符串等)和实现了这些要求的自定义类型都可以作为 Map 的键。

  • 不可变性:在 Map 中,键的值在其生命周期内不应改变。这是因为键的哈希值决定了其在哈希表中的位置,键值改变可能导致哈希值变化,进而影响 Map 的正确性。如果需要更新键对应的值,应先删除原键值对,再插入新的键值对。

  • 不能作为 map 键的类型
    以下类型不能作为 map 的键:

  • 切片类型,因为切片是引用类型,其内容可能会变化,使得比较操作不确定。
  • 函数类型,因为 Go 语言中没有为函数定义相等性比较操作。
  • map 类型,map 类型不能作为 map 的键,因为也是引用类型,且没有定义相等性比较操作。
  • 包含上述不可比较类型的复合类型,任何包含上述不可比较类型(如切片、函数、映射)的复合类型,如结构体,也不能作为 map 的键。

6. 并发安全

Go 语言标准库提供的 map 类型在并发访问时不保证线程安全。如果需要在多个 Goroutine 中同时读写 Map,必须使用互斥锁(如 sync.Mutex)或其他同步原语进行保护。另外,Go 语言提供了并发安全的 sync.Map 类型,它在内部实现了锁的精细控制,提供了更高的并发性能。

总结

Go 语言 Map 的底层实现基于哈希表,使用拉链法处理键值对的碰撞,通过动态扩容和负载均衡来保持高效的查找性能。Map 对键有严格的要求,包括可哈希性和不可变性。标准库中的 map 类型不保证并发安全,若需在多线程环境中使用,应使用互斥锁或选择 sync.Map

11.Golang Map 如何扩容

1. 触发扩容条件

扩容通常发生在以下两种情况之一:

  • 装载因子(Load Factor)过高:当 map 中的元素数量(即键值对数量)达到当前容量(即哈希表的桶数量)的一定比例(通常是 6.5/8,约为 80%)时,会触发扩容操作。这是为了避免哈希冲突过多导致的查找效率下降。

  • 显式扩容:在极少数情况下,用户可能需要通过 runtime.mapassign 等内部函数显式触发扩容,但这通常仅在实现特殊功能或进行性能调优时使用。

2. 选择新容量

扩容时,map 会选择一个更大的容量,通常为当前容量的两倍。选择两倍的原因是为了尽量维持哈希表的负载均衡,同时减少后续扩容的频率。新容量必须是 2 的幂,以便于高效地计算哈希索引。

3. 分配新哈希表

根据选定的新容量,map 会分配一个新的哈希表。新哈希表的桶数量等于新容量,每个桶初始化为空。

4. 重哈希并迁移元素

遍历原哈希表中的所有元素,对每个键重新计算其在新哈希表中的索引。如果新索引对应的桶为空,则直接将键值对放入该桶;如果桶非空(即存在哈希冲突),则将键值对添加到桶对应的链表末尾。这个过程确保了原 map 中的所有键值对都被正确地迁移到了新哈希表中,且键值对的相对顺序保持不变。

5. 替换哈希表

迁移完成后,原哈希表会被丢弃,新哈希表取代原哈希表成为 map 的实际数据存储结构。后续的查找、插入和删除操作都将在这个新哈希表上进行。

6. 回收旧哈希表

原哈希表及其包含的链表节点等内存会被标记为可回收,等待垃圾收集器在适当时候进行清理。

总结

Go 语言 map 的扩容过程包括触发扩容条件、选择新容量、分配新哈希表、重哈希并迁移元素、替换哈希表以及回收旧哈希表。扩容旨在维持合理的装载因子,减少哈希冲突,保持查找性能。扩容操作对用户透明,由 Go 运行时自动处理。尽管扩容过程中涉及元素迁移,但由于 Go 语言的 map 实现高效,通常对程序性能影响较小。

12.Golang Map 如何查找

1. 计算哈希值

首先,对给定的查找键(key)调用其类型的哈希函数,计算出一个哈希值。哈希函数的目标是将键均匀地映射到一个固定范围的整数上,减少哈希冲突。

2. 计算索引

将计算得到的哈希值与 map 当前的容量(桶数量)进行模运算(取余),得到一个索引值。这个索引值就是查找键在哈希表中的目标桶编号。

3. 查找桶内元素

在目标桶对应的链表中,从头节点开始,沿着链表依次比较每个节点的键与查找键是否相等。如果找到相等的键,返回对应的值;如果没有找到相等的键,说明 map 中不存在该键,返回零值或指定的默认值(如果使用 value, ok := map[key] 形式查找)。

4. 处理哈希冲突

如果目标桶内有多个节点(即存在哈希冲突),则需要逐个比较节点的键。如果键不相等,继续检查下一个节点;如果找到相等的键,返回对应的值。如果遍历完整个链表都没有找到相等的键,则说明 map 中不存在该键。

13.介绍一下 Channel

Go 语言中的 Channel(通道)是一种内建的类型,它提供了在 Goroutines(协程)之间安全、同步地传递数据的机制,是 Go 语言并发编程模型的核心组成部分之一。Channel 用于实现 Goroutines 间的通信和同步,遵循“通信顺序进程”(Communicating Sequential Processes, CSP)理论。

基本概念

  • 类型定义:Channel 是带类型的,声明时需要指定它所传输的数据类型。例如,ch := make(chan int) 创建了一个可以传输整数的 Channel。

  • 方向性:Channel 可以是双向(发送和接收均可)或单向(仅发送或仅接收)。单向 Channel 通过在类型后面添加 <- 符号指定方向,如 chSend := make(chan<- int)(只发送)、chRecv := make(<-chan int)(只接收)。

  • 缓冲与非缓冲

    • 非缓冲(无缓冲)Channel:不预先分配存储空间,发送操作只有在有对应的接收操作准备好时才能完成,反之亦然。这种 Channel 强制了发送和接收操作的同步,常用于同步 Goroutines 的执行。
    • 缓冲 Channel:具有一定的存储容量,可以暂时存储一定数量的数据。当缓冲未满时,发送操作不受阻塞;当缓冲非空时,接收操作也不受阻塞。缓冲 Channel 提供了一定程度的异步性,允许 Goroutines 在不必严格同步的情况下进行通信。

操作

  • 创建:使用 make 函数创建 Channel,如 ch := make(chan int, 10) 创建了一个容量为 10 的整数缓冲 Channel。

  • 发送:通过 ch <- value 语句向 Channel 发送数据。对于非缓冲 Channel,发送操作会阻塞,直到有 Goroutine 准备好接收;对于缓冲 Channel,只要 Channel 未满,发送操作就可以立即完成。

  • 接收:通过 value := <-ch 或 value, ok := <-ch 语句从 Channel 接收数据。对于非缓冲 Channel,接收操作会阻塞,直到有 Goroutine 发送数据;对于缓冲 Channel,只要 Channel 非空,接收操作就可以立即完成。使用 value, ok := <-ch 形式接收时,ok 标志位表示是否成功接收到了数据(Channel 关闭且无数据时,ok 为 false)。

  • 关闭:通过 close(ch) 函数关闭 Channel。关闭后的 Channel 不能再进行发送操作,接收操作可以继续,直至 Channel 中的所有数据被接收完毕。接收一个已关闭且无数据的 Channel 会立即返回零值和 ok=false,表示 Channel 已关闭且无数据可接收。

应用场景

  • 同步 Goroutines:通过 Channel 实现生产者-消费者模式,一个 Goroutine 生产数据发送至 Channel,另一个 Goroutine 从 Channel 接收并消费数据。Channel 保证了数据的有序传递和 Goroutines 的协调运行。

  • 信号传递:Channel 可以用来传递简单的布尔标志或空值,用于通知 Goroutines 完成任务、停止循环等。例如,一个 Goroutine 通过接收 Channel 的数据得知可以安全退出。

  • 工作池:通过 Channel 组织多个 Goroutines 形成工作池,主 Goroutine 将任务放入 Channel,工作 Goroutines 从 Channel 中取出任务执行。

  • 扇出/扇入:一个 Goroutine 通过 Channel 向多个 Goroutine 广播数据(扇出),或者多个 Goroutine 将结果汇集到一个 Goroutine(扇入)。

特性

  • 并发安全:Channel 提供了内置的并发安全机制,无需显式锁或其他同步原语就能在多个 Goroutines 间安全地交换数据。

  • 选择(select)语句:Go 语言提供了 select 语句用于监听多个 Channel 的操作,实现多路复用和超时控制。select 语句会阻塞,直到某个分支的 Channel 操作准备就绪。

总结

Go 语言的 Channel 是一种强大且安全的并发原语,用于实现 Goroutines 之间的同步通信。通过 Channel,开发人员可以轻松构建复杂的并发系统,确保数据在多个并发执行单元之间的有序流动,同时避免常见的并发编程问题。Channel 支持非缓冲和缓冲两种模式,以及单向和双向通信,提供了高度灵活且易于理解的并发编程模型。

14.Channel 的 ring buffer 实现

Go 语言标准库中的 Channel(通道)并没有直接公开其内部实现细节,但从源码分析和官方文档中可以了解到,Channel 在实现上确实使用了环形缓冲(Ring Buffer)这一数据结构来优化其性能,特别是在处理缓冲 Channel(即具有非零容量的 Channel)时。

环形缓冲(Ring Buffer)概述

环形缓冲是一种固定大小的循环队列,其特点是两端连接,形成一个环状结构。它通过两个指针(通常称为“读指针”和“写指针”)来跟踪缓冲区内的数据状态:

  • 读指针:指向下一个可供读取的数据位置。当从环形缓冲中读取数据时,读指针向前移动。
  • 写指针:指向下一个可供写入数据的位置。当向环形缓冲中写入数据时,写指针向前移动。

环形缓冲的优势在于能够在不进行数据移动的情况下实现数据的循环存储和读取,非常适合处理持续输入输出的流式数据场景。当写指针追赶并超过读指针时,环形缓冲被视为“满”;当读指针追赶并超过写指针时,环形缓冲被视为“空”。

Channel 中的环形缓冲实现

在 Go 语言的 Channel 实现中,缓冲 Channel 使用环形缓冲来暂存待发送或待接收的数据。具体实现细节如下:

  • 缓冲区:Channel 内部维护一个固定大小的元素数组作为缓冲区,数组的大小即为 Channel 容量。

  • 读写指针:Channel 内部有两个整数变量分别表示读指针和写指针,用于跟踪缓冲区内数据的状态。读指针指向下一个可读取的数据元素位置,写指针指向下一个可写入数据的位置。

  • 发送操作:当向 Channel 发送数据时,首先检查缓冲区是否已满(即写指针是否追赶上了读指针)。如果没有满,将数据写入写指针所指位置,并将写指针向前移动一位。如果缓冲区已满,发送操作将阻塞,直到有 Goroutine 从 Channel 接收数据腾出空间。

  • 接收操作:当从 Channel 接收数据时,首先检查缓冲区是否为空(即读指针是否追赶上了写指针)。如果没有空,从读指针所指位置读取数据,并将读指针向前移动一位。如果缓冲区为空,接收操作将阻塞,直到有 Goroutine 向 Channel 发送数据。

  • 环状特性:当读写指针到达缓冲区边界时,会通过取模运算(如 % cap(buffer))使其回绕到缓冲区的起始位置,从而实现环状结构。

示意图

1+---+---+---+---+---+---+---+---+
2|   |   |   |   |   |   |   |   |
3+---+---+---+---+---+---+---+---+
4  ^               ^               ^
5  |               |               |
6  |               |               |
7  R (read ptr)    W (write ptr)   end of buffer

在这个示意图中,R 表示读指针,W 表示写指针。当 Channel 中有数据时,R 和 W 之间(包括 R 和 W 所指位置)的元素都是有效的数据。当 Channel 为空时,R 和 W 指向同一个位置;当 Channel 满时,W 指向的位置紧邻 R。

总结

Go 语言中的 Channel 在实现缓冲 Channel 时,确实使用了环形缓冲这一数据结构。环形缓冲通过固定大小的数组和读写指针,高效地实现了数据的循环存储和读取,使得 Channel 能够在缓冲区满之前无阻塞地发送数据,或者在缓冲区非空时无阻塞地接收数据,极大地提高了 Channel 的性能和并发能力。在处理高并发、数据流等场景时,使用缓冲 Channel 可以有效地减少 Goroutines 间的同步开销,提高程序整体性能。