Skip to content

语言基础

1 语法基础

1.6 for range 的时候它的地址会发生变化么?

go中for range的时候,地址没有发生变化。 对值的改变并不会影响原有值,因为for range 的时候会创建一个新的变量,对这个新变量的修改不会影响原有值。可以通过访问索引或键的方式修改切片或字典的值。

go
package main

import "fmt"

type User struct {
	Name string
	Age  int
}

func main() {
	var users = []User{
		{Name: "tom", Age: 18},
		{Name: "kitty", Age: 22},
	}

	for _, user := range users {
		user.Age++
	}

	// users: [{tom 18} {kitty 22}]
	fmt.Printf("users: %v\n", users)
}

解决方案:

go
package main

import "fmt"

type User struct {
	Name string
	Age  int
}

func main() {
	var users = []User{
		{Name: "tom", Age: 18},
		{Name: "kitty", Age: 22},
	}

	for index := range users {
		users[index].Age++
	}

	// users: [{tom 19} {kitty 23}]
	fmt.Printf("users: %v\n", users)
}

1.7 for 循环遍历 slice 有什么问题?

在循环时,会创建一个变量,每次都会把地址赋给同一个变量,导致循环结束后,这个变量的值将会变为 slice 的最后一个元素。

  • 不能直接修改这个变量,修改这个临时变量不会影响原始值。
  • 不可以将这个变量直接赋值给其它值,会导致所有被赋值过的变量的值都相同。

1.8 多个 defer 的顺序是什么样的?

defer 是一个栈类型的数据结构。后进先出。

go
package main

import (
	"fmt"
)

func main() {
	// 321
	defer fmt.Print(1)
	defer fmt.Print(2)
	defer fmt.Print(3)
}

1.8 go defer,多个 defer 的顺序,defer 在什么时机会修改返回值?

多个 defer 的执行顺序为后进先出,defer 在 return 之后在函数返回值返回给调用方之前修改返回值。

1.9 defer recover 的问题?

defer 语句推迟函数的执行,直到函数返回。延迟调用的参数会立即求值,但直到函数返回后才会执行函数调用。defer 通常用于简化执行各种清理操作的函数。

recover是一个内置函数,可以重新获得对发生恐慌的 goroutine 的控制。恢复仅在延迟函数内有用。在正常执行期间,调用recover将返回nil并且没有其他效果。如果当前 goroutine 发生恐慌,则调用恢复将捕获为恐慌提供的值并恢复正常执行。

1.17 能说说 uintptr 和 unsafe.Pointer 的区别吗?

  • unsafe.Pointer只是单纯的通用指针类型,用于转换不同类型指针,它不可以参与指针运算;
  • 而uintptr是用于指针运算的,GC 不把 uintptr 当指针,也就是说 uintptr 无法持有对象, uintptr 类型的目标会被回收;
  • unsafe.Pointer 可以和 普通指针 以及 uintptr 进行相互转换;

通过一个例子加深理解,接下来尝试用指针的方式给结构体赋值。

go
package main

import (
	"fmt"
	"unsafe"
)

type Student struct {
	username string
	age      int
}

func main() {
	var student = &Student{}
	// 这时student变量打印出来都是默认值: "", 0
	fmt.Println(student.username, student.age)

	// 现在我们通过指针运算给username字段赋值为kitty
	username := unsafe.Pointer(uintptr(unsafe.Pointer(student)) + unsafe.Offsetof(student.username))
	*((*string)(username)) = "kitty"

	// 这时student变量打印出来就变成了: kitty, 0
	fmt.Println(student.username, student.age)
}
  • uintptr(unsafe.Pointer(student)) 获取了 student 指针的起始值
  • unsafe.Offsetof(student.username) 获取 username 字段的指针偏移量
  • 获取到的变量指针起始值字段指针偏移量,就得到了字段 username 的指针地址。
  • 通用指针 unsafe.Pointer 类型的变量 username,转换成指针类型,(*string)(username),然后通过 * 对指针解引用,并赋值 *((*string)(username)) = “kiity”

1.18 拷贝大切片一定比小切片代价大吗?

并不是,所有切片的大小相同;切片有三个字段(一个 uintptr,两个int)。切片中的第一个字段是指向切片底层数组的指针,这是切片的存储空间,第二个字段是切片的长度,第三个字段是容量。将一个 slice 变量分配给另一个变量只会复制三个机器字(机器字长是指计算机进行一次整数运算所能处理的二进制数据的位数)。所以 拷贝大切片跟小切片的代价应该是一样的

  • SliceHeader 是切片在golang的底层结构。

    go
    type SliceHeader struct {
      Data uintptr
      Len  int
      Cap  int
    }
  • 大切片跟小切片的区别无非就是Len 和 Cap的值比小切片的这两个值大一些,如果发生拷贝,本质上就是拷贝上面的三个字段。

1.19 如何翻转含有中文、数字、英文字母的字符串?

翻转含有中文、数字、英文字母的字符串 “k你好abc啊哈哈"

go
package main

import "fmt"

func reverse(str []rune) []rune {
	for i, j := 0, len(str) - 1; i < j; i, j = i+1, j-1 {
		str[i], str[j] = str[j], str[i]
	}
	return str
}


func main() {
	str := "嗨你好abc啊哈哈"
	rev := reverse([]rune(str))
	// 哈哈啊cba好你嗨
	fmt.Println(string(rev))
}
  • 在golang中 rune 类型是int32的别名,可表示的范围为(-2^31~2^31-1),而byte是uint8的别名,可表示的范围为(0~255),所以rune比byte可表示更多的字符
  • 由于rune可表示的范围更大,所以能处理一切字符,当然也包括中文字符。在平时表示中文字符时可以使用rune。
  • 因此将字符串转换为rune切片,再进行翻转,完美解决。

1.20 字符串转成byte数组,会发生内存拷贝吗?

字符串转成切片,会产生拷贝。严格来说,只要是发生强制类型转换都会发生内存拷贝。

那么问题来了!频繁的内存拷贝操作听起来对性能不太友好。有没有什么办法可以在字符串转成切片的时候不用发生拷贝呢?

我们可以通过底层数据结构直接转换,代码如下:

go
package main

import (
	"fmt"
	"reflect"
	"unsafe"
)

func main() {
	usernameStr := "kitty"
	pointer := *(*reflect.StringHeader)(unsafe.Pointer(&usernameStr))
	usernameSlice := *(*[]byte)(unsafe.Pointer(&pointer))
	// []uint8, kitty
	fmt.Printf("%T, %s\n", usernameSlice, usernameSlice)
}
  • StringHeader 是字符串类型在Golang中的底层数据结构。

    go
    type StringHeader struct {
      Data uintptr
      Len int
    }
  • SliceHeader 是切片类型中Golang中的底层数据结构。

    go
    type SliceHeader struct {
      Data uintptr
      Len int
      Cap int
    }

那么如果想要在底层转换二者,只需要把 StringHeader 的地址强转成 SliceHeader 就行。那么go有个很强的包叫 unsafe

  1. unsafe.Pointer(&usernameStr) 方法可以获取到变量 usernameStr 的地址。
  2. (*reflect.StringHeader)(unsafe.Pointer(&usernameStr) 可以把字符串 usernameStr 转换成底层结构 *reflect.StringHeader 的指针形式。再通过指针解引用 *(*reflect.StringHeader)(unsafe.Pointer(&usernameStr)) 获取到 reflect.StringHeader 反射类型。
  3. 再通过 (*[]byte)(unsafe.Pointer(&pointer))reflect.StringHeader 类型的 unsafe.Pointer 指针转换为字节切片指针
  4. 最后再通过指针解引用 *(*[]byte)(unsafe.Pointer(&pointer)) 指向实际内容。

1.21 知道golang的内存逃逸吗?什么情况下会发生内存逃逸?

在golang中变量会携带有一组校验数据,用来证明它的整个生命周期是否在运行时完全可知。如果变量通过了这些校验,它就可以在栈上分配。否则就说它逃逸了,必须在堆上分配。

能引起变量逃逸到堆上的典型情况:

  • 在方法中把局部变量指针返回:局部变量原本应该在栈中分配,在栈中回收。但是由于返回时被外部引用,因此其生命周期大于栈,则溢出。
  • 发送指针或带有指针的值到 channel 中:在编译时,是没有办法知道哪个 goroutine 会在 channel 上接收数据。所以编译器没法知道变量什么时候才会被释放。
  • 在一个切片上存储指针或带指针的值。 一个典型的例子就是 []*string 。这会导致切片的内容逃逸。尽管其底层数组可能是在栈上分配的,但其引用的值一定是在堆上。
  • slice 的底层数组被重新分配了,因为 append 时可能会超出其容量( cap )。 slice 初始化的地方在编译时是可以知道的,它最开始会在栈上分配。如果切片的底层数组要基于运行时的数据进行扩充,就会在堆上分配。
  • 在 interface 类型上调用方法。在 interface 类型上调用方法都是动态调度的 —— 方法的真正实现只能在运行时知道。想像一个 io.Reader 类型的变量 r , 调用 r.Read(b) 会使得 r 的值和切片b 的底层存储都会逃逸掉,所以会在堆上分配。

1.22 怎么避免内存逃逸?

在runtime包中有一个叫noescape的函数,该函数可以在逃逸分析中隐藏一个指针,这个指针在逃逸分析中不会被检测为逃逸。

go
// noescape 隐藏逃逸分析的指针。
// noescape 是恒等函数,但逃逸分析并不认为输出取决于输入。
// noescape 是内联的,目前编译为零指令。
//
//go:nosplit
func noescape(p unsafe.Pointer) unsafe.Pointer {
	x := uintptr(p)
	return unsafe.Pointer(x ^ 0)
}

下面通过一个示例来加深对noescape的理解。我们通过对比示例来加深理解!首先是逃逸的示例:

go
package main

import (
	"fmt"
)

type Student struct {
	username *string
}

func NewStudent(username string) Student {
	return Student{username: &username}
}

func (s *Student) String() string {
	return *s.username
}

func main() {
	// go build -gcflags=-m main.go
	tom := NewStudent("tom")
	fmt.Printf(tom.String())
}

在编译这个程序时指定gc标志:

go
go build -gcflags=-m main.go

输出具体的逃逸分析报告如下:

bash
# command-line-arguments
./main.go:11:6: can inline NewStudent
./main.go:15:6: can inline (*Student).String
./main.go:21:19: inlining call to NewStudent
./main.go:22:23: inlining call to (*Student).String
./main.go:22:12: inlining call to fmt.Printf
./main.go:11:17: moved to heap: username
./main.go:15:7: leaking param: s to result ~r0 level=2

主要通过下面这行就可以分析出逃逸结果:

bash
./main.go:11:17: moved to heap: username

下面再来看一下未发生逃逸的示例:

go
package main

import (
	"fmt"
	"unsafe"
)

type StudentTrick struct {
	username unsafe.Pointer
}

func NewStudentTrick(username string) StudentTrick {
	return StudentTrick{username: noescape(unsafe.Pointer(&username))}
}

func (s *StudentTrick) String() string {
	return *(*string)(s.username)
}

// noescape 避免内存逃逸.
func noescape(p unsafe.Pointer) unsafe.Pointer {
	x := uintptr(p)
	return unsafe.Pointer(x ^ 0)
}

func main() {
	// go build -gcflags=-m main.go
	tom := NewStudentTrick("tom")
	fmt.Printf(tom.String())
}

输出具体的逃逸分析报告如下:

bash
# command-line-arguments
./main.go:21:6: can inline noescape
./main.go:12:6: can inline NewStudentTrick
./main.go:13:40: inlining call to noescape
./main.go:16:6: can inline (*StudentTrick).String
./main.go:28:24: inlining call to NewStudentTrick
./main.go:29:23: inlining call to (*StudentTrick).String
./main.go:29:12: inlining call to fmt.Printf
./main.go:28:24: inlining call to noescape
./main.go:12:22: username does not escape
./main.go:16:7: leaking param: s to result ~r0 level=2
./main.go:21:15: p does not escape

可以通过下面这行发现并未发生逃逸:

bash
./main.go:12:22: username does not escape
  • 通过上面代码是对StudentStudentTrick同样功能的两种同不实现:他们包含一个 string ,然后用 String() 方法返回这个字符串。但是从逃逸分析看StudentTrick版本没有逃逸。
  • noescape() 函数的作用是遮蔽输入和输出的依赖关系。使编译器不认为 p 会通过 x 逃逸, 因为** uintptr()** 产生的引用是编译器无法理解的。
  • 内置的 uintptr 类型是一个真正的指针类型,但是在编译器层面,它只是一个存储一个指针地址的 int 类型。代码的最后一行返回unsafe.Pointer 也是一个int
  • noescape()runtime 包中使用 unsafe.Pointer 的地方被大量使用。如果作者清楚被 unsafe.Pointer 引用的数据肯定不会被逃逸,但编译器却不知道的情况下,这是很有用的。
  • 面试中秀一秀是可以的,如果在实际项目中如果使用这种unsafe包大概率会被同事打死。不建议使用! 毕竟包的名字就叫做 unsafe, 而且源码中的注释也写明了USE CAREFULLY(小心使用)!

1.23 json包在使用时,如果结构体不加tag标签会怎样?

  • 如果字段首字母小写,则为private,将其转换为json数据时,无法导出。
  • 如果字段首字母大写,则为public,如果不加json标签,则导出来字段名称为结构体字段名,如果加json标签,则导出来的字段名称为json标签指定的名称。

例如有一个Student结构体定义如下:

go
type Student struct {
	Username string `json:"username"`
	Age      int    `json:"age"`
}

则在执行json.Marshal时,将会将Student类型的变量转换为如下JSON数据

json
{"username":"tom","age":18}

1.24 如何获取结构体标签值?为什么json包不能将私有字段导出?

tag信息可以通过反射(reflect包)内的方法获取,下面是一个例子:

go
package main

import (
	"fmt"
	"reflect"
)

type Student struct {
	Username string `json:"username"`
	Age      int    `json:"age"`
	isSuper  bool   `json:"is_super"`
}

func main() {
	student := &Student{Username: "tom", Age: 18, isSuper: true}
	elem := reflect.TypeOf(student).Elem()
	for i := 0; i < elem.NumField(); i++ {
		fmt.Printf("json tag: %v\n", elem.Field(i).Tag.Get("json"))
	}
}

运行这个程序会输出如下内容:

bash
json tag: username
json tag: age
json tag: is_super
  • 通过** reflect.TypeOf(student).Elem()**获取结构体指针Type类型。
  • 通过** elem.NumField()**获取有多少个字段。
  • 在一个for循环中迭代输出结构体每一个字段的tag标签名称。
  • 并且私有类型的字段也可以获取到标签的值。

注意了,Golang的设计理念是以简洁而著名,结构体声明时通过字段名称的大小写来区别字段是私有访问还是公开访问字段。在json.Marshal的实现中,会判定一个变量是否是可导出的,私有变量是不可导出的Unexported。

1.25 对已经关闭的chan进行读写,会怎么样?为什么?

对已经关闭的channel读取数据会一直可以读取,只不过读到的内容根通道关闭前是否有元素而不同:

  • 如果channel关闭前,缓冲区有数据还未读,会正确读到 channel 内的值,且返回的第二个bool值(是否读取成功)为true。
  • 如果channel关闭前,缓冲区数据已经被读取完,channel内无值,接下来所有接收到的值都会非阻塞直接成功,返回channel元素的零值,但是第二个bool值一直为false。

对已经关闭的channel写入数据会Panic。

1.26 for select时,如果通道已经关闭会怎么样?如果select中只有一个case呢?

  • for循环select时,如果其中一个case通道已经关闭,则每次都会执行到这个case。
  • 如果select里边只有一个case,而这个case被关闭了,则会出现死循环。

2. 闭包

2.1 Go 闭包是什么?

Go 语言支持匿名函数,可作为闭包。匿名函数是一个"内联"语句或表达式。匿名函数的优越性在于可以直接使用函数内的变量,不必申明。通俗易懂的讲,就是函数返回一个函数,返回的函数引用函数内的变量,就是闭包。 举个例子来说明,比如说我们定义一个统计函数被调用多个次的 count 函数,这个函数返回一个函数如 func () int, 在函数内部定义一个 total 变量,在返回的函数中我们对 total 加 1 后返回 total,那么这个函数就是闭包函数。

go
package main

import "log"

func count() func() int {
	var total int
	return func() int {
		total += 1
		return total
	}
}

func getUser(fn func() int) string {
	// 输出日志,执行闭包函数调用,count函数内的变量total将会被加1。
	log.Printf("Get user information for the %d time\n", fn())
	return "kitty"
}

func main() {
	// 获取闭包函数
	fn := count()
	for i := 0; i < 10; i++ {
		// 传递闭包函数
		getUser(fn)
	}
}

3. Context

context 定义了 Context 类型,它在 API 边界和进程之间携带截止日期、取消信号和其他请求范围的值。 对服务器的传入请求应该创建一个上下文,对服务器的传出调用应该接受一个上下文。它们之间的函数调用链必须传播上下文,可选择将其替换为使用 WithCancel、WithDeadline、WithTimeout 或 WithValue 创建的派生上下文。当一个上下文被取消时,从它派生的所有上下文也被取消。 WithCancelCause 函数返回一个 CancelCauseFunc,它接受错误并将其记录为取消原因。在取消的上下文或其任何子上下文上调用 Cause 来检索原因。如果没有指定原因,Cause(ctx) 返回与 ctx.Err() 相同的值。 使用 Contexts 的程序应该遵循以下几点规则,以保持接口在包之间的一致性,并启用静态分析工具来检查上下文传播。

  • 不要将上下文存储在结构类型中;相反,将 Context 显式传递给需要它的每个函数。 Context 应该是第一个参数,通常命名为 ctx。
    go
    func DoSomething(ctx context.Context, arg Arg) error {
      // ... use ctx ...
    }
  • 不要传递 nil 上下文,即使函数允许。如果您不确定要使用哪个上下文,请传递 context.TODO。
  • 仅将上下文值用于传输进程和 API 的请求范围的数据,而不是将可选参数传递给函数。
  • 相同的 Context 可能会传递给运行在不同 goroutine 中的函数;多个 goroutine 同时使用上下文是安全的。

3.1 context 结构是什么样的?

Context 是一个包含 Deadline()、Done()、Err()、Value() 方法的接口类型。

3.2 context 使用场景和用途?

context 用来在 goroutine 之间传递上下文信息,使用场景包括:取消 goroutine、传递共享的数据、防止 goroutine 泄漏等。

4. Channel

4.1 channel 是否线程安全?

Golang 中的 Channel 本身是线程安全的。在 Go 语言的并发模型中,Channel 是用来在不同的 Goroutine(Go 语言中的线程)之间进行数据通信的。当一个 Goroutine 向 Channel 发送数据时,直到另一个 Goroutine 接收到这个数据之前,该 Goroutine 将会被阻塞。这种机制保证了 Channel 的数据在 Goroutine 之间传递时的安全性。因此,我们可以得出结论:Golang Channel 本身是线程安全的。

4.2 go channel 的底层实现原理 (数据结构)

4.3 channel close 还能不能写入数据?

不能

4.4 channel close 还能不能读取数据?

可以

4.5 向为 nil 的 channel 发送数据会怎么样?

会发生死锁。

4.6 向 channel 发送数据和从 channel 读数据的流程是什么样的?

不管是无缓冲channel还是缓冲channel都需要提前准备好读取channel操作,这时读取操作将会阻塞,等待读取channel数据。如果是缓冲channel就可以写入buffer个数据,直到缓冲被写满,如果无读取channel数据操作,写入将会发生阻塞。

5. Map

5.1 map 使用注意的点,并发安全?

  • 使用前初始化map 未初始化的 map 都是 nil,直接赋值会报 panic。map 作为结构体成员的时候,很容易忘记对它的初始化。
  • 并发读写 并发读写是我们使用 map 时很常见的一个错误。多个协程并发读写同一个 key 的时候,会出现冲突,导致 panic。 Go 内置的 map 类型并没有对并发场景场景进行优化,但是并发场景又很常见,因此可以使用 sync.Mutex 或 sync.RWMutex 加锁解决并发冲突的问题,也可以使用类型安全map类型,sync.Map。

5.2 map 循环是有序的还是无序的?

无序的,因为Map的存储结构是哈希表键值对。

5.3 map 循环为什么是无序的?

Map是无序的,它的存储结构是哈希表键值对,map中插入元素是根据key计算出的哈希值来存储元素的,因此他不是按照元素的添加顺序来存储对象的,所以Map是无序的。

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

map删除元素并不会释放内存,只是修改标记(标记为删除)。如果想要释放内存,可以将map设置为nil后调用gc来实现。

其实出发点,和 mysql 的标记删除类似,防止后续会有相同的 key 插入,省去了扩缩容的操作。

5.5 听说过对Go map 做GC 吗?

是的,对 Go map 做 gc 操作,可以对一个已初始化的 map 赋值 nil,调用 runtime.GC 方法来手动释放内存。

7. 通信模型

7.1 如何理解“不要通过共享内存来通信,要通过通信来共享内存”?