掌握Golang面试技巧:详细总结IT技术栈中的坑点

发表时间: 2023-10-10 17:38

make与new的异同


相同点:

  • 都是用来给变量分配内存的

不同点:

  • new一般给值类型的变量,例如:string、int、arr分配内存,make给slice、channel、map等引用类型的变量分配内存
  • 返回值的类型不一样,new返回指向这个变量的指针,make返回的是一个初始化后的引用类型。
package mainimport "fmt"func main() {    // 使用 new 创建一个整数的指针    var numPtr *int    numPtr = new(int)    *numPtr = 42    fmt.Println("Value of numPtr:", *numPtr) // 输出: Value of numPtr: 42    // 使用 make 创建一个切片    slice := make([]int, 3, 5) // 创建一个长度为3,容量为5的切片    slice[0] = 1    slice[1] = 2    slice[2] = 3    fmt.Println("Slice:", slice) // 输出: Slice: [1 2 3]    // 使用 make 创建一个映射    m := make(map[string]int)    m["apple"] = 5    m["banana"] = 3    fmt.Println("Map:", m) // 输出: Map: map[apple:5 banana:3]    // 使用 make 创建一个通道    ch := make(chan int)    go func() {        ch <- 42    }()    value := <-ch    fmt.Println("Channel value:", value) // 输出: Channel value: 42}

数组与切片的异同


相同点:

  • 只能存放相同类型的变量
  • 都是通过下标来访问,并且有容量长度,长度通过 len 获取,容量通过 cap 获取

不同点:

  • 数组的大小是固定的,定义时需要指定数组的长度,且无法更改。
  • 切片的大小是可变的,可以根据需要动态增加或减少元素,切片是基于数组实现的。
  • 数组是值类型,当将一个数组赋值给另一个变量或作为函数参数传递时,会复制整个数组的值,对一个副本的修改不会影响原始数组
  • 切片是引用类型,赋值或传递切片时,实际上传递的是底层数组的引用,多个切片可以共享同一个底层数组,对一个切片的修改会影响到其他共享底层数组的切片
  • 数组的长度是固定的,不能更改。
  • 切片有长度和容量两个属性,长度表示当前切片中的元素数量容量表示底层数组中可以容纳的元素数量。切片的容量可以在创建时指定,或者使用 append 函数来动态增加。
  • 数组的零值是一个具有所有元素为零值的数组,例如,var arr [3]int 的零值是 [0 0 0]。
  • 切片的零值是 nil,表示一个未分配底层数组的空切片。
package mainimport "fmt"func main() {    // 声明一个数组    var arr [3]int    arr[0] = 1    arr[1] = 2    arr[2] = 3    // 打印数组    fmt.Println("Array:", arr) // 输出: Array: [1 2 3]    // 尝试修改数组的值    modifiedArr := arr    modifiedArr[0] = 100    fmt.Println("Modified Array:", modifiedArr) // 输出: Modified Array: [100 2 3]    fmt.Println("Original Array:", arr)         // 输出: Original Array: [1 2 3](原始数组未受影响)    // 声明一个切片    slice := []int{1, 2, 3}    // 打印切片    fmt.Println("Slice:", slice) // 输出: Slice: [1 2 3]    // 尝试修改切片的值    modifiedSlice := slice    modifiedSlice[0] = 100    fmt.Println("Modified Slice:", modifiedSlice) // 输出: Modified Slice: [100 2 3]    fmt.Println("Original Slice:", slice)         // 输出: Original Slice: [100 2 3](原始切片受到影响)    // 使用切片的 append 函数动态增加元素    slice = append(slice, 4, 5)    fmt.Println("Updated Slice:", slice) // 输出: Updated Slice: [100 2 3 4 5]}

对切片或数组进行for range 的时候它的地址会发生变化么?

迭代变量的地址不会发生变化。每次迭代都会创建一个新的迭代变量,该变量的值是切片或数组中的元素,但地址不同。这是因为Go在每次迭代中会重新分配内存来存储迭代变量的副本。

package mainimport "fmt"func main() {    slice := []int{1, 2, 3}    for index, value := range slice {        fmt.Printf("Index: %d, Value: %d, Address: %p\n", index, value, &value)    }}

for range 循环迭代了切片 slice,并打印了每个元素的索引、值以及值的地址。会发现,每次迭代中 value 的地址都不同,这表明每次迭代都创建了一个新的变量。

因此,在 for range 循环中,不要依赖迭代变量的地址来保持状态,因为它们会在每次迭代中重新分配。如果需要保持某个迭代变量的状态,可以将其复制到一个新的变量中。

Defer的原理


用于延迟执行函数调用。当使用 defer 时,它会将函数调用推迟到包含 defer 语句的函数即将返回之前执行。defer 常用于清理操作,如关闭文件、释放资源等,以确保在函数执行完毕时执行这些操作。

defer 的原理是通过一个栈(defer stack)来实现的,使用链表实现,将新的defer插入头节点,结束时,依次从头部取出。每次遇到 defer 语句,它会将要执行的函数及其参数入栈,但不会立即执行。当函数即将返回时,会按照后进先出(LIFO)的顺序执行栈中的 defer 函数调用。

package mainimport "fmt"func main() {    fmt.Println("Start")    defer fmt.Println("Deferred 1")    defer fmt.Println("Deferred 2")    defer fmt.Println("Deferred 3")    fmt.Println("End")}#Start#End#Deferred 3#Deferred 2#Deferred 1

rune类型

在Go语言中,rune是一种数据类型,用于表示Unicode字符。Unicode是一种字符编码标准,它包含了世界上几乎所有的字符,包括常见字符(如字母、数字、标点符号)以及各种特殊字符(如表情符号、非拉丁字母等)。

rune类型实际上是int32类型的别名,用于表示Unicode字符的整数值。每个rune代表一个字符,无论字符的编码有多大。这使得Go语言能够处理各种不同字符集的文本数据。

golang中的字符串底层实现是通过byte数组的,中文字符在unicode下占2个字节,在utf-8编码下占3个字节,而golang默认编码正好是utf-8。

package mainimport "fmt"func main() {    // 创建一个包含Unicode字符的字符串    str := "Hello, 世界!"    // 使用 for range 迭代字符串中的每个字符    for i, r := range str {        fmt.Printf("Character %d: %c (Unicode: %U)\n", i, r, r)    }}
Character 0: H (Unicode: U+0048)Character 1: e (Unicode: U+0065)Character 2: l (Unicode: U+006C)Character 3: l (Unicode: U+006C)Character 4: o (Unicode: U+006F)Character 5: , (Unicode: U+002C)Character 6:   (Unicode: U+0020)Character 7: 世 (Unicode: U+4E16)Character 10: 界 (Unicode: U+754C)Character 13: ! (Unicode: U+FF01)

tag的实现原理

可以使用反射来解析结构体字段的标记(tag)。反射是Go语言的一种特性,它允许程序在运行时检查和操作变量、方法、结构体等程序结构信息。通过反射,可以获取结构体字段的标记信息,以及动态访问、修改这些字段的值。

要解析结构体字段的标记,需要使用reflect包,该包提供了一些函数和类型,用于处理反射操作。

package mainimport (    "fmt"    "reflect")// 定义一个结构体并添加标记type Person struct {    Name    string `json:"name"`    Age     int    `json:"age"`    Address string `json:"address"`}func main() {    // 创建一个示例结构体    p := Person{        Name:    "Alice",        Age:     30,        Address: "123 Main St",    }    // 获取结构体类型    t := reflect.TypeOf(p)    // 遍历结构体的字段    for i := 0; i < t.NumField(); i++ {        field := t.Field(i)        // 获取字段名和标记值        fieldName := field.Name        tagValue := field.Tag.Get("json")        fmt.Printf("Field: %s, Tag: %s\n", fieldName, tagValue)    }}
Field: Name, Tag: nameField: Age, Tag: ageField: Address, Tag: address

切片扩容

  1. 初始容量(Capacity): 当创建一个切片时,可以选择指定初始容量,例如:make([]T, length, capacity)。初始容量表示底层数组的大小,即切片可以容纳的元素数量,但长度(Length)为0。容量通常用于优化性能,以减少频繁扩容的开销。
  2. 添加元素: 当向切片添加元素时,Go语言会检查切片的长度(len(slice))和容量(cap(slice))。
  3. 检查容量是否足够: 如果切片的长度小于容量,说明底层数组还有足够的空间来容纳新元素,这时不需要扩容。
  4. 容量不足时扩容: 如果切片的长度等于容量,表示底层数组已满。这时,Go语言会执行以下操作:
  5. 创建一个新的底层数组,通常容量会增加一倍(但最小会增加到原始容量的两倍,以避免小容量的切片频繁扩容)。
  6. 将原始数据复制到新的底层数组中。
  7. 更新切片的引用,使其指向新的底层数组。
  8. 释放旧的底层数组(垃圾回收)。
  9. 继续添加元素: 现在,切片有了更大的容量,可以继续添加元素,重复上述步骤,直到容量再次不足。

这个扩容机制的好处是,开发者无需关心切片的容量,可以专注于操作切片的长度。这简化了代码,并且避免了手动管理内存分配和复制数据的繁琐工作。

关于select

select 是用于处理多个通道操作的控制结构,实现 I/O 多路复用机制,它允许等待多个通道中的任何一个可以操作(发送或接收),并执行相应的操作。select 通常用于解决并发编程中的问题,例如等待多个任务中的一个完成,或者处理多个输入源的数据。

  1. 等待多个通道的数据到达: 通过将多个通道操作放入 select 语句中,程序可以同时等待多个通道的数据到达,无需一个一个等待。
  2. 处理超时操作: select 可以与 time.After 结合使用,以在特定时间内等待某个通道操作完成或处理超时操作。
  3. 实现非阻塞操作: select 可以在多个通道都没有数据可用时,执行默认操作,从而实现非阻塞的操作。
  4. 监听多个网络连接: 通过将多个 net.Conn 对象的读取操作放入 select,可以同时监听多个客户端连接,响应它们的请求。
package mainimport (    "fmt"    "time")func main() {    // 创建两个通道    ch1 := make(chan string)    ch2 := make(chan string)    // 启动两个并发的 goroutine,分别向通道发送数据    go func() {        time.Sleep(2 * time.Second)        ch1 <- "Hello from goroutine 1"    }()    go func() {        time.Sleep(1 * time.Second)        ch2 <- "Hello from goroutine 2"    }()    // 使用 select 来等待多个通道操作    select {    case msg1 := <-ch1:        fmt.Println("Received:", msg1)    case msg2 := <-ch2:        fmt.Println("Received:", msg2)    case <-time.After(3 * time.Second):        fmt.Println("Timeout: No data received")    }    // 关闭通道    close(ch1)    close(ch2)}

怎么处理对 map 进行并发访问

处理并发访问map时需要注意,因为map不是线程安全的,多个goroutine同时对同一个map进行读写操作可能会导致数据竞态问题。为了安全地并发访问map,可以采用以下几种方式:

  • 使用互斥锁(Mutex): 使用sync包中的Mutex来保护map,确保在任何时刻只有一个goroutine可以对map进行读写操作。
package mainimport (    "fmt"    "sync")func main() {    var mu sync.Mutex    m := make(map[int]int)    // 启动多个goroutine并发写入map    for i := 0; i < 5; i++ {        go func(i int) {            mu.Lock()            defer mu.Unlock()            m[i] = i * 2        }(i)    }    // 等待所有goroutine完成    for i := 0; i < 5; i++ {        go func(i int) {            mu.Lock()            defer mu.Unlock()            fmt.Println(m[i])        }(i)    }}
  • 使用读写锁(RWMutex): 如果大多数操作是读取操作,而写入操作较少,可以使用sync包中的RWMutex,它允许多个goroutine同时读取map,但写入操作仍然需要互斥。
package mainimport (    "fmt"    "sync")func main() {    var mu sync.RWMutex    m := make(map[int]int)    // 启动多个goroutine并发写入map    for i := 0; i < 5; i++ {        go func(i int) {            mu.Lock()            defer mu.Unlock()            m[i] = i * 2        }(i)    }    // 启动多个goroutine并发读取map    for i := 0; i < 5; i++ {        go func(i int) {            mu.RLock()            defer mu.RUnlock()            fmt.Println(m[i])        }(i)    }}
  • 使用并发安全的sync.Map: Go 1.9及以上版本引入了sync.Map,它是一种并发安全的映射,可以直接在多个goroutine中进行并发读写操作。
package mainimport (    "fmt"    "sync")func main() {    var m sync.Map    // 启动多个goroutine并发写入map    for i := 0; i < 5; i++ {        go func(i int) {            m.Store(i, i*2)        }(i)    }    // 启动多个goroutine并发读取map    for i := 0; i < 5; i++ {        go func(i int) {            if value, ok := m.Load(i); ok {                fmt.Println(value)            }        }(i)    }}

context的使用

context 是Go语言标准库中的一个包,用于在多个goroutine之间传递上下文信息和取消信号。它的设计旨在解决在并发环境中管理请求范围的值、控制goroutine的生命周期以及处理取消请求的问题。context 在处理HTTP请求、数据库查询、RPC等场景中非常有用。

原理

context 的核心概念是创建一个上下文(context)对象,它包含了一个取消通道(Done)、截止时间(Deadline)、上下文值(Value)等信息。当需要在多个goroutine之间传递上下文信息或取消请求时,可以将这个上下文对象传递给相关的goroutine,从而实现跨goroutine的信息传递和控制。

使用场景

控制goroutine的生命周期: context 可以用于在父goroutine中控制子goroutine的生命周期。当父goroutine取消上下文时,所有从该上下文派生的子goroutine都会收到取消信号并退出。

package mainimport (    "context"    "fmt"    "time")func worker(ctx context.Context) {    for {        select {        case <-ctx.Done():            fmt.Println("Worker: Context canceled")            return        default:            fmt.Println("Worker: Working...")            time.Sleep(1 * time.Second)        }    }}func main() {    ctx, cancel := context.WithCancel(context.Background())    go worker(ctx)    time.Sleep(3 * time.Second)    cancel() // 取消上下文,停止worker    time.Sleep(1 * time.Second)}

传递请求范围的值: context 可以用于在多个goroutine之间传递请求范围的值,如请求ID、用户信息等。这些值可以在整个请求范围内传递,而不需要在每个函数参数中传递。

package mainimport (    "context"    "fmt")func logRequestID(ctx context.Context) {    if reqID, ok := ctx.Value("requestID").(string); ok {        fmt.Println("Request ID:", reqID)    } else {        fmt.Println("Request ID not found")    }}func main() {    ctx := context.WithValue(context.Background(), "requestID", "12345")    logRequestID(ctx)}

处理超时和取消: context 可以用于设置超时和处理取消请求。通过设置截止时间,可以确保某个操作在指定的时间内完成,否则会自动取消。

package mainimport (    "context"    "fmt"    "time")func main() {    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)    defer cancel()    select {    case <-time.After(3 * time.Second):        fmt.Println("Operation completed")    case <-ctx.Done():        fmt.Println("Operation canceled or timed out")    }}

context 是Go语言中处理并发操作的强大工具,可以用于控制goroutine的生命周期、传递请求范围的值以及处理超时和取消请求。根据具体的应用场景,可以使用不同的context类型,如context.Background()、context.WithCancel()、context.WithTimeout()等。这样可以确保Go程序在并发操作中更加健壮和可控。

channel底层原理

IT技术栈:程序员面试宝典之Golang的Channel(管道)使用与原理

GMP相关

IT技术栈:程序员面试宝典之Golang的GMP模型

GC相关

IT技术栈:程序员面试宝典之Golang的GC机制

多返回值是如何实现的

  1. 栈帧: 在函数调用时,Go语言会为每个函数创建一个栈帧。栈帧是一个用于存储函数的局部变量、参数、返回值等信息的内存区域。每次函数调用都会创建一个新的栈帧,并将其压入调用栈。
  2. 返回值传递: 当一个函数需要返回多个值时,Go语言会将这些返回值按顺序依次存储在当前函数的栈帧中,通常是在栈帧的顶部。
  3. 调用方读取返回值: 调用方函数可以读取被调用函数的返回值,这是通过访问被调用函数的栈帧来完成的。根据返回值的数量和类型,调用方函数从栈帧中读取返回值,并将其用于后续操作。
package mainimport "fmt"func multiReturn() (int, string) {    return 42, "Hello, World!"}func main() {    // 调用 multiReturn 函数并获取返回值    result1, result2 := multiReturn()    // 处理返回值    fmt.Println("Result 1:", result1)    fmt.Println("Result 2:", result2)}

在这个示例中,multiReturn 函数返回两个值:一个整数和一个字符串。当 multiReturn 被调用时,这两个返回值按顺序存储在栈帧中。然后,调用方函数 main 通过多重赋值操作从栈帧中读取这两个返回值,并进行后续的处理。

总的来说,Go语言的多返回值原理是基于栈帧的机制,它允许函数返回多个值,并由调用方函数负责读取和处理这些返回值。这种机制使得Go语言可以方便地返回多个相关的值,例如错误信息和结果,而不需要使用额外的数据结构来传递。

【申明:文章部分图片来源于网路,侵权,删除】