Easton Notes
八股Go 面试题

Go 语言基础

Go 语言设计、值语义、初始化、defer、字符串与 unsafe 高频面试题。

Go 相比其他语言有哪些特点?

回答重点

Go 的优势不在于某一项能力绝对领先,而在于工程取舍比较统一:

  • 语法和语言特性相对精简,代码格式由 gofmt 统一。
  • 编译速度快,可以直接生成便于部署的本地可执行文件。
  • goroutine、channel 和运行时调度器降低了并发编程的门槛。
  • 标准库覆盖网络、HTTP、测试、性能分析等常见服务端需求。
  • 内置垃圾回收、竞态检测、基准测试和性能分析工具。

需要避免“Go 的 GC 一定比 Java 更快”这类绝对结论。GC 表现取决于堆大小、对象生命周期、延迟目标、运行时版本和具体负载。

goroutine、线程和进程有什么区别?

回答重点

  • 进程拥有独立的虚拟地址空间和操作系统资源,是资源隔离的边界。
  • 线程是操作系统调度执行的基本单位,同一进程内的线程共享地址空间。
  • goroutine是 Go 运行时管理的轻量级执行单元。运行时通过 GMP 调度器把大量 goroutine 复用到较少的操作系统线程上。

goroutine 的栈会按需增长和收缩,创建与切换成本通常低于操作系统线程。但 goroutine 并不会让共享数据自动安全,多个 goroutine 访问同一份可变状态时仍需 channel、锁或原子操作建立同步关系。

make 和 new 有什么区别?

回答重点

  • new(T) 为 T 的零值分配存储空间,返回 *T。
  • make(T, ...) 只适用于 slice、map 和 channel,返回初始化后的 T,而不是 *T。

new 得到的值已经按零值初始化,不能说成“只分配但不初始化”。区别在于 slice、map 和 channel 的零值通常还不能完成预期操作,因此需要 make 初始化其运行时结构。

package main

import "fmt"

func main() {
	p := new(int)
	s := make([]int, 3)
	m := make(map[string]int)
	ch := make(chan int, 1)

	fmt.Println(*p, s, len(m), cap(ch))
}

Go 1.22 前后循环变量的语义有什么变化?

回答重点

对于语言版本为 Go 1.22 及以上的包,for 循环中声明的迭代变量每轮都会创建新实例,闭包捕获和取地址不再共享同一个循环变量。

Go 1.21 及更早语言版本中,循环变量在各轮之间复用,因此把变量地址保存到切片、或让闭包异步读取变量,可能全部观察到同一个变量的最终值。

package main

import "fmt"

func main() {
	values := []string{"a", "b", "c"}
	for _, value := range values {
		fmt.Printf("%p %s\n", &value, value)
	}
}

这个变化由模块的 go 版本决定,不应误写成“Go 1.23 才变化”。参考 Go 1.22 发布说明。

如何高效拼接字符串?

回答重点

  • 少量固定字符串使用 +,可读性最好,编译器也能做优化。
  • 循环追加或增量构造字符串优先使用 strings.Builder。
  • 已有字符串切片并需要分隔符时使用 strings.Join。
  • 同时处理 []byte 或需要读写缓冲区时使用 bytes.Buffer。
  • fmt.Sprintf 适合格式化,不应作为纯拼接的默认方案。

不存在脱离输入规模和 Go 版本的固定性能排名。性能敏感时应使用基准测试,并在能够估算最终大小时调用 Grow。

package main

import (
	"fmt"
	"strings"
)

func main() {
	parts := []string{"Go", " ", "interview"}

	var builder strings.Builder
	builder.Grow(len("Go interview"))
	for _, part := range parts {
		builder.WriteString(part)
	}

	fmt.Println(builder.String())
	fmt.Println(strings.Join(parts, ""))
}

defer 的执行顺序和求值时机是什么?

回答重点

  • defer 按注册顺序的逆序执行,即后进先出。
  • 被延迟函数及其参数在执行到 defer 语句时就会求值。
  • 延迟调用在函数返回前执行,包括正常返回和当前 goroutine 发生 panic 后展开栈的过程。
  • 延迟函数可以读取或修改命名返回值。
  • os.Exit 会直接终止进程,不会执行 defer。
package main

import "fmt"

func result() (n int) {
	defer func() { n++ }()
	return 1
}

func main() {
	fmt.Println(result()) // 2
}

byte、rune 和字符串是什么关系?

回答重点

Go 字符串是只读字节序列,通常承载 UTF-8 文本,但语言并不要求字符串内容必须是合法 UTF-8。

  • byte 是 uint8 的别名,表示一个字节。
  • rune 是 int32 的别名,通常表示一个 Unicode 码点。
  • len(s) 返回字节数。
  • utf8.RuneCountInString(s) 或 len([]rune(s)) 返回码点数量,不等于用户看到的字符数量。组合字符和 emoji 可能由多个码点组成。
package main

import (
	"fmt"
	"unicode/utf8"
)

func main() {
	s := "hello 你好"
	fmt.Println(len(s))                    // 12
	fmt.Println(utf8.RuneCountInString(s)) // 8
}

结构体标签有什么作用?

回答重点

结构体标签是附着在字段声明上的字符串元数据。标准库或框架可以通过反射读取它们,例如 encoding/json 使用 json 标签控制字段名和序列化行为。

标签本身不会自动产生效果,必须由相应代码解析。标签只对可导出字段有实际价值,因为反射和编码包通常无法设置未导出字段。

type User struct {
	ID       int    `json:"id"`
	Password string `json:"-"`
}

%v、%+v 和 %#v 有什么区别?

回答重点

  • %v 使用默认格式输出值。
  • %+v 输出结构体时包含字段名。
  • %#v 输出值的 Go 语法表示,通常还包含类型信息。

这些格式的具体输出取决于值是否实现 fmt.Formatter、fmt.Stringer 或 error。

空结构体 struct{} 是否占空间?有什么用途?

回答重点

unsafe.Sizeof(struct{}{}) 为 0。常见用途包括:

  • 作为 map[T]struct{} 的值来表达集合。
  • 通过 chan struct{} 只传递事件,不携带业务数据。
  • 作为只承载方法的类型。

零尺寸值在数组、切片或结构体中的实际地址布局由实现决定,不能据此假设不同零尺寸变量一定有不同地址。

package main

import "fmt"

func main() {
	set := map[string]struct{}{"Go": {}}
	_, exists := set["Go"]
	fmt.Println(exists)
}

init 函数什么时候执行?

回答重点

程序启动时,运行时先按依赖顺序初始化包。每个包先初始化包级变量,再按源文件顺序执行该包中的 init 函数;依赖包完成后才会初始化当前包,最后调用 main.main。

init 不能显式调用,不能有参数或返回值,一个文件可以声明多个 init。同一包多个文件的提交顺序由构建系统决定,因此不应让不同文件中的 init 隐式依赖彼此顺序。

Go 函数参数是值传递还是引用传递?

回答重点

Go 只有值传递。传入数组、结构体、指针、slice、map、channel 或 interface 时,都会复制实参的值。

容易混淆的地方是:slice、map、channel、指针等值内部包含对共享数据的引用。复制这些描述符后,调用方与被调用方仍可能访问同一底层数据,但被调用方重新给自己的参数赋值不会改变调用方变量本身。

如何判断变量分配在栈还是堆?

回答重点

栈或堆是编译器实现决策,不由 new、make 或源码写法直接决定。编译器根据值的生命周期、大小和使用方式执行逃逸分析,而且结果可能随 Go 版本变化。

可以用编译器诊断查看当前构建下的决定:

go build -gcflags='-m=2' ./...

“返回局部变量地址就一定性能差”也是误区。Go 会保证地址有效,是否值得优化要结合分配次数、对象大小和性能分析结果判断。

Go 的多返回值如何实现?

回答重点

语言规范只保证函数可以返回多个值,不规定必须通过调用方栈帧上的连续空间实现。具体传递方式属于编译器 ABI,可能使用寄存器,也可能使用栈,且会随架构和工具链变化。

面试中应先说明语言语义,再把寄存器或栈上的传递称为“当前编译器实现”,不要当作 Go 语言保证。

空白标识符 _ 有什么作用?

回答重点

空白标识符用于显式忽略值,常见场景包括:

  • 忽略多返回值中的某一项。
  • 忽略 range 的索引或元素。
  • 使用空白导入触发包的初始化副作用。
  • 在编译期声明接口实现关系。
package main

import "io"

type reader struct{}

func (reader) Read([]byte) (int, error) { return 0, io.EOF }

var _ io.Reader = (*reader)(nil)

func main() {}

普通指针、unsafe.Pointer 和 uintptr 有什么区别?

回答重点

  • *T 带有静态类型,编译器会检查可执行的操作。
  • unsafe.Pointer 可以在不同指针类型之间转换,并仍被垃圾回收器视为指针。
  • uintptr 只是足以容纳地址的整数。它不会让对象保持存活,也不能长期保存后再安全转回指针。

使用 uintptr 做地址运算时,转换通常必须出现在同一个表达式中,并遵守 unsafe 包文档列出的合法模式。涉及对象生命周期时可配合 runtime.KeepAlive,但它不能把任意不安全转换变成安全代码。

扩展知识

评论

On this page