IT技术栈:Golang面试攻略详细总结,有的坑,原来真的可以躲过去

发布时间 2023-10-11 09:04:58作者: 我家有只江小白

IT技术栈:Golang面试攻略详细总结,有的坑,原来真的可以躲过去

make与new的异同

 

 

相同点:

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

不同点:

  • new一般给值类型的变量,例如:string、int、arr分配内存,make给slice、channel、map等引用类型的变量分配内存
  • 返回值的类型不一样,new返回指向这个变量的指针,make返回的是一个初始化后的引用类型。
package main

import "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 main

import "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 main

import "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 main

import "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 main

import "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 main

import (
    "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: name
Field: Age, Tag: age
Field: 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 main

import (
    "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 main

import (
    "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 main

import (
    "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 main

import (
    "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 main

import (
    "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 main

import (
    "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 main

import (
    "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 main

import "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语言可以方便地返回多个相关的值,例如错误信息和结果,而不需要使用额外的数据结构来传递。