GoLand面试题


Goland面试题

基础

什么是Go语言?Go语言的特点是什么?

Go语言是一种开源的编程语言,由Google公司开发。它具有高效、简洁、安全等特点。Go语言支持并发编程,可以更好地利用多核处理器的能力。同时,Go语言还拥有垃圾回收、自动类型推断、内置函数等特性。

Go语言的优缺点是什么?

优点:

  • 编译速度快,执行速度快。
  • 内存管理自动化,支持垃圾回收。
  • 并发模型简单,支持大规模并发。
  • 语言结构简洁,易于学习和使用。
  • 支持跨平台,可以在不同的操作系统上运行。

缺点:

  • 对于一些底层的操作,需要手动实现。
  • 类型系统不够强,类型转换的限制较少。
  • 反射机制的性能较差。

make和new的区别?

1)作用变量类型不同,new给string,int和数组分配内存,make给切片,map,channel分配内存;
2)返回类型不一样,new返回指向变量的指针,make返回变量本身;
3)new 分配的空间被清零。make 分配空间后,会进行初始化;

数组和切片的区别?

1)数组是定长,访问和复制不能超过数组定义的长度,否则就会下标越界,切片长度和容量可以自动扩容;
2)数组是值类型,切片是引用类型,每个切片都引用了一个底层数组,切片本身不能存储任何数据,都是这底层数组存储数据,所以修改切片的时候修改的是底层数组中的数据。切片一旦扩容,指向一个新的底层数组,内存地址也就随之改变

方法与函数的区别?

在Go语言中,函数和方法不太一样,有明确的概念区分。其他语言中,比如Java,一般来说函数就是方法,方法就是函数;但是在Go语言中,函数是指不属于任何结构体、类型的方法,也就是说函数是没有接收者的;而方法是有接收者的。

//方法
func (t *T) add(a, b int) int {
    return a + b 
}
// 其中T是自定义类型或者结构体,不能是基础数据类型int等

//函数
func add(a, b int) int {
    return a + b 
}

方法值接收者和指针接收者的区别?

如果方法的接收者是指针类型,无论调用者是对象还是对象指针,修改的都是对象本身,会影响调用者;

如果方法的接收者是值类型,无论调用者是对象还是对象指针,修改的都是对象的副本,不影响调用者;

type Person struct {
    age int
}

// 如果实现了接收者是指针类型的方法,会隐含地也实现了接收者是值类型的IncrAge1方法。
// 会修改age的值
func (p *Person) IncrAge1() {
    p.age += 1
}

// 如果实现了接收者是值类型的方法,会隐含地也实现了接收者是指针类型的IncrAge2方法。
// 不会修改age的值
func (p Person) IncrAge2() {
    p.age += 1
}

// 如果实现了接收者是值类型的方法,会隐含地也实现了接收者是指针类型的GetAge方法。
func (p Person) GetAge() int {
    return p.age
}

func main() {
    // p1 是值类型
    p := Person{age: 10}

    // 值类型 调用接收者是指针类型的方法
    p.IncrAge1()
    fmt.Println(p.GetAge()) //11
    // 值类型 调用接收者是值类型的方法
    p.IncrAge2()
    fmt.Println(p.GetAge()) //11

    // ----------------------

    // p2 是指针类型
    p2 := &Person{age: 20}

    // 指针类型 调用接收者是指针类型的方法
    p2.IncrAge1()
    fmt.Println(p2.GetAge()) //21
    // 指针类型 调用接收者是值类型的方法
    p2.IncrAge2()
    fmt.Println(p2.GetAge())//21
}

函数返回局部变量的指针是否安全

一般来说,局部变量会在函数返回后被销毁,因此被返回的引用就成为了”无所指”的引用,程序会进入未知状态。

但这在 Go 中是安全的,Go 编译器将会对每个局部变量进行逃逸分析。如果发现局部变量的作用域超出该函数,则不会将内存分配在栈上,而是分配在堆上,因为他们不在栈区,即使释放函数,其内容也不会受影响。

func add(x, y int) *int {
res := 0
res = x + y
return &res
}

func main() {
fmt.Println(add(1, 2))
}

// 编译时可以借助选项 -gcflags=-m,查看变量逃逸的情况
./main.go:6:2:          res escapes to heap:
./main.go:6:2:        flow: ~r2 = &res:
./main.go:6:2:     from &res (address-of) at ./main.go:8:9
./main.go:6:2:     from return &res (return) at ./main.go:8:2
./main.go:6:2:         moved to heap: res
./main.go:12:13: ... argument does not escape 0xc0000ae008

res escapes to heap 即表示 res 逃逸到堆上了。

uintptr与unsafe.Pointer区别?

unsafe.Pointer 是通用指针类型,它不能参与计算,任何类型的指针都可以转化成 unsafe.Pointer,unsafe.Pointer 可以转化成任何类型的指针,uintptr 可以转换为 unsafe.Pointer,unsafe.Pointer 可以转换为 uintptr。uintptr 是指针运算的工具,但是它不能持有指针对象(意思就是它跟指针对象不能互相转换),unsafe.Pointer 是指针对象进行运算(也就是 uintptr)的桥梁。

Slice

array和slice的区别?

数组长度不能改变,初始化后长度就是固定的;切片的长度是不固定的,可以追加元素,在追加时可能使切片的容量增大。

结构不同,数组是一串固定数据,切片描述的是截取数组的一部分数据,从概念上说是一个结构体。

初始化方式不同,如上。另外在声明时的时候:声明数组时,方括号内写明了数组的长度或使用...自动计算长度,而声明slice时,方括号内没有任何字符。

函数调用时的传递方式不同,数组按值传递,slice按引用传递。

func main() {

    s := []int{1, 2, 3}
    s1 := s[0:1]
    PrintSlice(s1) // [1,4]
  
    fmt.Println(s)  // [1,4,3]
    fmt.Println(s1) // [1]
}
func PrintSlice(s []int) {
    s = append(s, 4)

    fmt.Println(s)
}

slice深拷贝和浅拷贝

深拷贝:拷贝的是数据本身,创造一个新对象,新创建的对象与原对象不共享内存,新创建的对象在内存中开辟一个新的内存地址,新对象值修改时不会影响原对象值、

实现深拷贝的方式:

  1. copy(slice2, slice1)
  2. 遍历append赋值
func main() {
    slice1 := []int{1, 2, 3, 4, 5}
    slice2 := make([]int, 5, 5)
    fmt.Printf("slice1: %v, %p", slice1, slice1)
    
    copy(slice2, slice1)
    fmt.Printf("slice2: %v, %p", slice2, slice2)
    slice3 := make([]int, 0, 5)
    for _, v := range slice1 {
        slice3 = append(slice3, v)
    }
    fmt.Printf("slice3: %v, %p", slice3, slice3)
}
slice1: [1 2 3 4 5], 0xc0000b0030
slice2: [1 2 3 4 5], 0xc0000b0060
slice3: [1 2 3 4 5], 0xc0000b0090

浅拷贝:拷贝的是数据地址,只复制指向的对象的指针,此时新对象和老对象指向的内存地址是一样的,新对象值修改时老对象也会变化

func main() {
    slice1 := []int{1, 2, 3, 4, 5}
    fmt.Printf("slice1: %v, %p", slice1, slice1)
    slice2 := slice1
    fmt.Printf("slice2: %v, %p", slice2, slice2)
}
slice1: [1 2 3 4 5], 0xc00001a120
slice2: [1 2 3 4 5], 0xc00001a120

slice扩容机制?

扩容会发生在slice append的时候,当slice的cap不足以容纳新元素,就会进行扩容,扩容规则如下

  • 如果新申请容量比两倍原有容量大,那么扩容后容量大小 为 新申请容量
  • 如果原有 slice 长度小于 1024, 那么每次就扩容为原来的 2 倍
  • 如果原 slice 长度大于等于 1024, 那么每次扩容就扩为原来的 1.25 倍
func main() {
    slice1 := []int{1, 2, 3}
    for i := 0; i < 16; i++ {
        slice1 = append(slice1, 1)
        fmt.Printf("addr: %p, len: %v, cap: %v", slice1, len(slice1), cap(slice1))
    }
}
addr: 0xc00001a120, len: 4, cap: 6
addr: 0xc00001a120, len: 5, cap: 6
addr: 0xc00001a120, len: 6, cap: 6
addr: 0xc000060060, len: 7, cap: 12
addr: 0xc000060060, len: 8, cap: 12
addr: 0xc000060060, len: 9, cap: 12
addr: 0xc000060060, len: 10, cap: 12
addr: 0xc000060060, len: 11, cap: 12
addr: 0xc000060060, len: 12, cap: 12
addr: 0xc00007c000, len: 13, cap: 24
addr: 0xc00007c000, len: 14, cap: 24
addr: 0xc00007c000, len: 15, cap: 24
addr: 0xc00007c000, len: 16, cap: 24
addr: 0xc00007c000, len: 17, cap: 24
addr: 0xc00007c000, len: 18, cap: 24
addr: 0xc00007c000, len: 19, cap: 24

slice为什么不是线程安全的?

slice底层结构并没有使用加锁等方式,不支持并发读写,所以并不是线程安全的,使用多个 goroutine 对类型为 slice 的变量进行操作,每次输出的值大概率都不会一样,与预期值不一致; slice在并发执行中不会报错,但是数据会丢失

func TestSliceConcurrencySafe(t *testing.T) {
    a := make([]int, 0)
    var wg sync.WaitGroup
    for i := 0; i < 10000; i++ {
        wg.Add(1)
        go func(i int) {
            a = append(a, i)
            wg.Done()
        }(i)
    }
    wg.Wait()
    t.Log(len(a))
    // not equal 10000
}

扩容前后的 Slice 是否相同?

情况一:

原数组还有容量可以扩容(实际容量没有填充完),这种情况下,扩容以后的数组还是指向原来的数组,对一个切片的操作可能影响多个指针指向相同地址 的 Slice。

情况二:

原来数组的容量已经达到了最大值,再想扩容, Go 默认会先开一片内存区域,把原来的值拷贝过来,然后再执行 append() 操作。这种情况丝毫不影响 原数组。

要复制一个 Slice,最好使用 Copy 函数。

slice分配在堆上还是栈上

有可能分配到栈上,也有可能分配到栈上。当开辟切片空间较大时,会逃逸到堆上。

Channel

Go channel有什么特点?

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

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

写操作模式 读操作模式 读写操作模式
创建 make(chan<- int) make(<-chan int) make(<-chan int)

channel有3种状态:未初始化、正常、关闭

未初始化 关闭 正常
关闭 panic panic 正常关闭
发送 永远阻塞导致死锁 panic 阻塞或者成功发送
接收 永远阻塞导致死锁 缓冲区为空则为零值,否则可以继续 阻塞或者成功接收

注意点:

  1. 一个 channel不能多次关闭,会导致painc
  2. 如果多个 goroutine 都监听同一个 channel,那么 channel 上的数据都可能随机被某一个 goroutine 取走进行消费
  3. 如果多个 goroutine 监听同一个 channel,如果这个 channel 被关闭,则所有 goroutine 都能收到退出信号

Go channel的底层实现原理?

底层结构需要描述出来,这个简单,buf,发送队列,接收队列,lock。

hchan

发送

向 channel 中发送数据时大概分为两大块:检查和数据发送,数据发送流程如下:

  • 如果 channel 的读等待队列存在接收者goroutine
  • 将数据直接发送给第一个等待的 goroutine, 唤醒接收的 goroutine
  • 如果 channel 的读等待队列不存在接收者goroutine
  • 如果循环数组buf未满,那么将会把数据发送到循环数组buf的队尾
  • 如果循环数组buf已满,这个时候就会走阻塞发送的流程,将当前 goroutine 加入写等待队列,并挂起等待唤醒

接收

向 channel 中接收数据时大概分为两大块,检查和数据发送,而数据接收流程如下:

  • 如果 channel 的写等待队列存在发送者goroutine
  • 如果是无缓冲 channel,直接从第一个发送者goroutine那里把数据拷贝给接收变量,唤醒发送的 goroutine
  • 如果是有缓冲 channel(已满),将循环数组buf的队首元素拷贝给接收变量,将第一个发送者goroutine的数据拷贝到 buf循环数组队尾,唤醒发送的 goroutine
  • 如果 channel 的写等待队列不存在发送者goroutine
  • 如果循环数组buf非空,将循环数组buf的队首元素拷贝给接收变量
  • 如果循环数组buf为空,这个时候就会走阻塞接收的流程,将当前 goroutine 加入读等待队列,并挂起等待唤醒

总结hchan结构体的主要组成部分有四个:

  • 用来保存goroutine之间传递数据的循环数组:buf
  • 用来记录此循环数组当前发送或接收数据的下标值:sendx和recvx
  • 用于保存向该chan发送和从该chan接收数据被阻塞的goroutine队列: sendq 和 recvq
  • 保证channel写入和读取数据时线程安全的锁:lock

使用场景: 消息传递、消息过滤,信号广播,事件订阅与广播,请求、响应转发,任务分发,结果汇总,并发控制,限流,同步与异步

请解释一下Go语言的协程和并发模型

Go channel有无缓冲的区别?

无缓冲 有缓冲
创建方式 make(chan TYPE) make(chan TYPE,SIZE)
发送阻塞 数据接收前发送阻塞 缓冲满时发送阻塞
接收阻塞 数据发送前接收阻塞 缓冲空时接收阻塞

Go channel为什么是线程安全的?

为什么设计成线程安全?

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

如何实现线程安全的?

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

Map

map的底层实现原理

图片

Go中的map是一个指针,占用8个字节,指向hmap结构体

源码包中src/runtime/map.go定义了hmap的数据结构:

hmap包含若干个结构为bmap的数组,每个bmap底层都采用链表结构,bmap通常叫其bucket

hmap结构体

// A header for a Go map.
type hmap struct {
  count     int 
  // 代表哈希表中的元素个数,调用len(map)时,返回的就是该字段值。
  flags     uint8 
  // 状态标志(是否处于正在写入的状态等)
  B         uint8  
  // buckets(桶)的对数
  // 如果B=5,则buckets数组的长度 = 2^B=32,意味着有32个桶
  noverflow uint16 
  // 溢出桶的数量
  hash0     uint32 
  // 生成hash的随机数种子
  buckets    unsafe.Pointer 
  // 指向buckets数组的指针,数组大小为2^B,如果元素个数为0,它为nil。
  oldbuckets unsafe.Pointer 
  // 如果发生扩容,oldbuckets是指向老的buckets数组的指针,老的buckets数组大小是新的buckets的1/2;非扩容状态下,它为nil。
  nevacuate  uintptr        
  // 表示扩容进度,小于此地址的buckets代表已搬迁完成。
  extra *mapextra 
  // 存储溢出桶,这个字段是为了优化GC扫描而设计的,下面详细介绍
}

bmap结构体

bmap 就是我们常说的“桶”,一个桶里面会最多装 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果的低B位是相同的,关于key的定位我们在map的查询中详细说明。在桶内,又会 根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有8个位置)。

// A bucket for a Go map.
type bmap struct {
  tophash [bucketCnt]uint8        
  // len为8的数组
  // 用来快速定位key是否在这个bmap中
  // 一个桶最多8个槽位,如果key所在的tophash值在tophash中,则代表该key在这个桶中
}

上面bmap结构是静态结构,在编译过程中runtime.bmap会拓展成以下结构体:

type bmap struct{
tophash [8]uint8
keys [8]keytype 
// keytype 由编译器编译时候确定
values [8]elemtype 
// elemtype 由编译器编译时候确定
overflow uintptr 
// overflow指向下一个bmap,overflow是uintptr而不是*bmap类型,保证bmap完全不含指针,是为了减少gc,溢出桶存储到extra字段中
}

tophash就是用于实现快速定位key的位置,在实现过程中会使用key的hash值的高8位作为tophash值,存放在bmap的tophash字段中

tophash字段不仅存储key哈希值的高8位,还会存储一些状态值,用来表明当前桶单元状态,这些状态值都是小于minTopHash的

为了避免key哈希值的高8位值和这些状态值相等,产生混淆情况,所以当key哈希值高8位若小于minTopHash时候,自动将其值加上minTopHash作为该key的tophash。桶单元的状态值如下:

emptyRest      = 0 // 表明此桶单元为空,且更高索引的单元也是空
emptyOne       = 1 // 表明此桶单元为空
evacuatedX     = 2 // 用于表示扩容迁移到新桶前半段区间
evacuatedY     = 3 // 用于表示扩容迁移到新桶后半段区间
evacuatedEmpty = 4 // 用于表示此单元已迁移
minTopHash     = 5 // key的tophash值与桶状态值分割线值,小于此值的一定代表着桶单元的状态,大于此值的一定是key对应的tophash值

func tophash(hash uintptr) uint8 {
top := uint8(hash >> (goarch.PtrSize*8 - 8))
if top < minTopHash {
top += minTopHash
}
return top
}

mapextra结构体

当map的key和value都不是指针类型时候,bmap将完全不包含指针,那么gc时候就不用扫描bmap。bmap指向溢出桶的字段overflow是uintptr类型,为了防止这些overflow桶被gc掉,所以需要mapextra.overflow将它保存起来。如果bmap的overflow是*bmap类型,那么gc扫描的是一个个拉链表,效率明显不如直接扫描一段内存(hmap.mapextra.overflow)

type mapextra struct {
overflow    *[]*bmap
// overflow 包含的是 hmap.buckets 的 overflow 的 buckets
oldoverflow *[]*bma
// oldoverflow 包含扩容时 hmap.oldbuckets 的 overflow 的 bucket
nextOverflow *bmap 
// 指向空闲的 overflow bucket 的指针
}

总结

bmap(bucket)内存数据结构可视化如下:

注意到 key 和 value 是各自放在一起的,并不是 key/value/key/value/... 这样的形式,当key和value类型不一样的时候,key和value占用字节大小不一样,使用key/value这种形式可能会因为内存对齐导致内存空间浪费,所以Go采用key和value分开存储的设计,更节省内存空间

Go map如何查找?

Go 语言中读取 map 有两种语法:带 comma 和 不带 comma。当要查询的 key 不在 map 里,带 comma 的用法会返回一个 bool 型变量提示 key 是否在 map 中;而不带 comma 的语句则会返回一个 value 类型的零值。如果 value 是 int 型就会返回 0,如果 value 是 string 类型,就会返回空字符串。

// 不带 comma 用法
value := m["name"]
fmt.Printf("value:%s", value)

// 带 comma 用法
value, ok := m["name"]
if ok {
fmt.Printf("value:%s", value)
}

map的查找通过生成汇编码可以知道,根据 key 的不同类型/返回参数,编译器会将查找函数用更具体的函数替换,以优化效率:

key 类型 查找
uint32 mapaccess1_fast32(t maptype, h hmap, key uint32) unsafe.Pointer
uint32 mapaccess2_fast32(t maptype, h hmap, key uint32) (unsafe.Pointer, bool)
uint64 mapaccess1_fast64(t maptype, h hmap, key uint64) unsafe.Pointer
uint64 mapaccess2_fast64(t maptype, h hmap, key uint64) (unsafe.Pointer, bool)
string mapaccess1_faststr(t maptype, h hmap, ky string) unsafe.Pointer
string mapaccess2_faststr(t maptype, h hmap, ky string) (unsafe.Pointer, bool)

查找流程

Go语言采用了基于CSP(Communicating Sequential Processes)的并发模型,通过goroutine(协程)来实现。goroutine是一种轻量级的线程,可以在Go语言的运行时(runtime)中自由地调度。通过goroutine,可以轻松地实现并发和并行。
  • Go语言的垃圾回收机制是怎样的?

  • Go语言的垃圾回收机制采用了标记-清除算法。在Go语言中,当一个对象不再被引用时,垃圾回收器会自动回收这个对象所占用的内存空间。垃圾回收器会周期性地遍历程序中的所有对象,标记那些还在使用中的对象,然后清除那些已经不再使用的对象。
    
  • 如何在Go语言中实现线程安全?

  • Go语言提供了一些机制来实现线程安全,如互斥锁、读写锁、原子操作等。互斥锁可以在多个goroutine之间保证数据的互斥访问,读写锁则可以在读多写少的情况下提高程序的性能。原子操作可以保证对变量的操作是原子性的,不会出现竞争条件。
    
  • 请解释一下Go语言的标准库

  • Go语言的标准库提供了一系列常用的功能,如网络编程、文件操作、加密解密、时间日期处理等。标准库的设计注重于可读性和可维护性,大部分功能都有详细的文档和示例代码
    
  • 请解释一下Go语言的接口实现原理

  • 在Go语言中,接口是一种类型,通过定义方法集来描述类型的行为。一个类型只需要实现接口定义的方法,就可以成为这个接口的实现。Go语言中的接口是隐式实现的,即一个类型只要实现了接口定义的方法,就自动成为这个接口的实现。
    
  • 请解释一下Go语言的反射机制

  • Go语言的反射机制可以在运行时动态地获取一个变量的类型信息和值。通过反射,可以实现一些高级的功能,如动态创建类型、调用任意函数、动态修改变量等。在Go语言中,反射主要通过reflect包来实现
    
  • 如何在Go语言中进行错误处理?

  • 在Go语言中,错误是一种类型,通过实现error接口来表示。Go语言的标准库中提供了一些函数和方法来处理错误,如返回error类型的函数、panic和recover函数等。通过这些函数和方法,可以实现错误的捕获、处理和传递。
    
  • 请解释一下Go语言中的闭包和defer关键字

  • 闭包是一种函数,它可以访问在函数定义体外部的变量。在Go语言中,闭包可以用于实现一些高级的功能,如延迟执行、并发控制、事件驱动等。
    
    defer语句用于在函数返回前执行一些清理操作,如关闭文件、解锁资源等。在Go语言中,defer语句会在函数返回前被执行,即使函数发生了panic也会被执行。
    
  • Golang 中的 goroutine 是什么?它与线程的区别是什么?

  • goroutine 是 Golang 中的轻量级线程,它可以在一个 CPU 核心上并发执行多个任务。
    与传统的线程相比,goroutine 的创建和销毁代价较小,且可以利用 Golang 内置的调度器进行高效的调度。此外,Golang 中的 goroutine 采用协作式调度,即 goroutine 需要主动让出 CPU 时间片,而不是像线程那样由操作系统进行抢占式调度。这使得 goroutine 可以更好地利用 CPU 时间,并且更容易避免竞态条件和死锁等并发问题。
    
  • Golang 中的 interface 是什么?它有什么作用?如何使用它?

  • nterface 是 Golang 中的一种类型,用于定义对象的行为。interface 定义了对象应该具有的方法集合,但不提供方法的实现。一个对象只要实现了 interface 中定义的所有方法,就可以被认为是实现了该 interface。interface 可以用于实现多态性,使得代码更加灵活和可扩展。要使用 interface,需要先定义 interface,然后定义实现该 interface 的对象。
    

文章作者: 谦
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 谦 !
赏
  目录