简介Go面试经典问题常被称为“八股文”这份PDF合集正是围绕此类高频考题整理的面试宝典适合准备Go语言岗位面试的开发者、初入Go后台方向的求职者以及希望通过系统梳理语言特性查漏补缺的学习者。内容覆盖flag命令行解析的常见陷阱、nil在不同类型下的行为边界、context的取消传播与值传递实现原理等高频考点并对string与[]byte转换、并发控制等易混淆知识点给出系统说明能帮助读者快速形成答题思路与知识框架。资源为单个PDF文件包体约10.38MB内容集中便于通读目前已吸引1852人学习下载。对希望在面试中展现理解深度的Go工程师而言这份资料能将零散概念整理成清晰可讲的回答适合考前集中复习与反复查阅。1. Go 八股文面试清单每一道题都连着一段源码Go 八股文面试清单在很多人眼里就是背题册但我把这套真题过了一遍之后判断完全不同——它相当于一份按底层机制索引的小源码地图。文档覆盖了 flag 库的参数解析边界、nil 的无类型语义、context 取消链、singleflight 合并请求、channel 读写、切片扩容、内存对齐这些高频考点每一题都给了结论也给了结论背后的运行机制不满足于「面试官问什么我答什么」。适合准备 Go 后端岗位面试、想花一周把底层重新过一遍的开发者也适合写了两三年业务代码、想借面试题查漏补缺的人。我自己拆这份文档最大的感受是能把这些题讲成源码级追问比背下答案重要得多。2. flag、nil 与字符串先从零值和解析边界把基础打牢2.1 flag 的解析边界参数顺序为什么能让后续 flag 全部失效flag 是 Go 官方标准库做命令行工具基本绕不开它。面试问 flag 通常不是问怎么用而是问「有什么坑」文档里第一个坑就是解析顺序。flag 包处理参数时有一个硬规则遇到第一个非 flag 参数不以-开头就停止解析后面的内容全部当成剩余参数。这意味着参数顺序会直接影响解析结果。package main import ( flag fmt ) func main() { // 注册三个命令行参数 name : flag.String(name, asong, 名字) verbose : flag.Bool(verbose, false, 是否输出详细信息) flag.Parse() // 手动打印解析结果 fmt.Println(name , *name) fmt.Println(verbose , *verbose) fmt.Println(剩余参数 , flag.Args()) }用go run main.go --name golang now.txt --verbose运行输出是name golang、verbose false、剩余参数 [now.txt --verbose]。原因就是now.txt这个非 flag 参数让解析直接停在原地后面的--verbose根本没机会进入解析流程。文档里点出的就是这个边界写 CLI 工具时如果允许自由参数最好把 flag 全部放在前面或者用--显式分隔让 flag 解析器知道后面不再是选项。第二个坑是 bool 型 flag 的写法限制。bool flag 只接受-verbose或-verbosetrue两种形式。写-verbose true时flag 库会把verbose置为true而true本身反而成了第一个非 flag 参数后面再接什么都会被当成剩余参数。这是很典型的「命令看起来没问题行为却不对」的情况。我在实际项目里见过同事把-debug false当成关闭调试开关用结果 debug 被置为了 true问题极难排查。提示如果业务上需要 bool 开关优先用-debugfalse这种等号写法至少语义不会被参数顺序影响。2.2 nil 不是关键字无类型标识符怎么判断与使用文档里对 nil 的定性很关键nil 不是关键字而是一个预先声明的标识符没有默认类型。编译器必须从上下文推断出它的类型。所以var p *int的零值是 nilvar ch chan int的零值也是 nil但nil nil这种写法编译不过因为两边都没有可以被推断的类型。package main import fmt func main() { // nil map 可以安全读返回零值 var m map[string]int fmt.Println(m[key]) // 输出 0 // 写 nil map 会 panic // m[key] 1 // nil 切片不能按下标访问 var s []int // _ s[0] // 关闭 nil channel 会 panic var ch chan int // close(ch) }这段代码把 nil 的五种高危行为浓缩在一起。声明的 map 是 nil map读操作因为底层没有元素返回类型零值不报错但写入需要分配 bucketnil map 没有可分配的内存结构直接 panic。nil 切片的结构体里 Data、Len、Cap 全是零值下标访问等于操作一个非法地址同样 panic。nil channel 关闭会触发 runtime 的 closechan 检查直接抛 panic向 nil channel 发送或接收数据则会让当前 goroutine 永久挂起本质是死锁。方法接收者为 nil 时方法内部一旦访问接收者字段就会 panic空指针解引用同理。这几个点在面试里经常被组合起来问文档给的判断思路是nil 只是零值标识符真正要关注的是「这个类型在 nil 状态下做了什么实现」。闭包 nil 函数可以直接判断后调用map 要先初始化再写channel 要区分 nil 和 closed 两种状态这些细节比背结论更有用。2.3 string、rune 与 len从字节视角看三个高频题string 和 []byte 的转换是 Go 面试的常客文档里给出了底层答案。string 本质是一个结构体包含一个指向字节数组的指针和一个长度字段byte 是 uint8 的别名rune 是 int32 的别名。rune 代表一个 Unicode 字符因为 UTF-8 编码下一个字符需要 1 到 4 字节int32 足够覆盖。所以 len 函数返回的是字节数不是字符数。package main import ( fmt unicode/utf8 ) func main() { s : hello世界 // 5 个 ASCII 字符 2 个中文字符各 3 字节 11 字节 fmt.Println(len(s)) // 11 fmt.Println(utf8.RuneCountInString(s)) // 7 fmt.Println(len([]rune(s))) // 7 }标准转换[]byte(s)和string(b)都会发生内存拷贝。string 转 []byte 走的是 runtime 的slicestringcopy先定义一个 32 字节的临时数组字符串长度不超过 32 就直接用栈上空间超过才重新分配。[]byte 转 string 则是根据切片长度决定是否分配内存再拷贝底层数组。因为 string 不可变每次重新赋值都意味着一次新分配旧空间交给 GC 回收。如果不走标准转换可以使用 unsafe 强转因为 string 和切片的内存布局高度相似只差一个 cap 字段直接替换指针就能完成转换。文档特别提醒这种写法不安全string 转 []byte 后如果直接修改字节数组会导致严重错误而且defer recover也捕获不到。这一点放到后面避坑章节再展开这里先记住一个结论标准转换慢但安全unsafe 强转快但只适合只读场景。3. context 与并发协同把取消链、超时和单飞讲成一套3.1 context 到底解决了什么context 是 Go 1.7 引入的标准库解决的问题很具体多个 goroutine 协同工作时如何传递取消信号、截止时间以及少量请求级别的数据。文档里有一句很关键的话——context 可以在不同的 goroutine 之间同步「请求特定的数据、取消信号以及处理请求的截止日期」。它不是用来替代锁和 channel 的而是在链路维度上做一种结构化控制。文档总结的实现原理是 context 树一个父 context 可以衍生任意多个子节点每个子节点依赖父节点整棵树的取消信号是从上往下传播的。context.Background()和context.TODO()用来创建根节点前者是正式入口后者是暂时不知道传什么时占位。衍生节点使用WithCancel、WithDeadline、WithTimeout、WithValue四类方法。这里有个细节值得注意创建衍生节点本质是创建链表节点查找和取消的时间复杂度不是常数级节点多了效率会下降。所以 context 不是用来无限塞数据的它是控制流的一部分。3.2 四个衍生函数WithCancel / WithDeadline / WithTimeout / WithValue四个衍生函数对应四种场景。WithCancel 返回 ctx 和 cancel 函数调用 cancel 后整棵子树收到取消信号WithTimeout 以持续时间为参数WithTimeout(ctx, 2*time.Second)表示两秒后自动取消WithDeadline 以时间点为参数WithDeadline(ctx, time.Now().Add(2*time.Second))表示到那个时间点取消。文档特意点明两者本质相同WithTimeout 内部调用的就是 WithDeadline。package main import ( context fmt time ) func main() { // 父 context 衍生一个可取消的子 context ctx, cancel : context.WithCancel(context.Background()) defer cancel() // 防止漏掉 cancel导致 goroutine 泄漏 // 启动两个 goroutine共享同一个 ctx go worker(ctx, worker-1) go worker(ctx, worker-2) // 模拟业务处理 2 秒后主动取消 time.Sleep(2 * time.Second) cancel() time.Sleep(500 * time.Millisecond) } func worker(ctx context.Context, name string) { for { select { case -ctx.Done(): fmt.Println(name, 退出:, ctx.Err()) return default: // 模拟业务逻辑 time.Sleep(200 * time.Millisecond) } } }这段代码展示的是并发控制的标准姿势。worker 里用select监听ctx.Done()只有 cancel 被调用时才会触发退出分支否则一直执行 default 里的业务逻辑。defer cancel()是文档里强烈建议的写法因为实际代码中 cancel 可能在多个分支提前 return遗漏它就会造成 goroutine 泄漏。ctx.Err()返回取消原因context.Canceled表示主动取消context.DeadlineExceeded表示超时。用 WithTimeout 时要注意超时触发后子 context 会自动取消但父 context 不会。所以资源清理逻辑要放在子 context 的 Done 分支里而不是依赖父链路。3.3 WithValue 的边界trace_id 能放关键参数不能放WithValue 用于在 context 中携带键值对但文档给出的结论非常明确不建议用 context 传关键参数关键参数应该显式声明。context 适合放的是 trace_id、请求 ID 这类横切关注点的数据。原因是 context 携带 value 没有类型和大小限制很容易形成隐式依赖代码可读性下降而且 interface 类型的 key/value 在编译期无法做类型检查取值时类型断言失败就会 panic。package main import ( context fmt ) // 自定义 key 类型避免与别的包使用 context 时冲突 type ctxKey string const traceIDKey ctxKey trace_id func main() { // 把 trace_id 写入 context ctx : context.WithValue(context.Background(), traceIDKey, abc-123) // 伪造一个传入的 context 用于获取 handle(ctx) } func handle(ctx context.Context) { // 从当前 ctx 获取找不到会向父 context 回溯 id, ok : ctx.Value(traceIDKey).(string) if !ok { fmt.Println(trace_id 不存在或类型不对) return } fmt.Println(trace_id , id) }key 建议使用自定义类型而不是 string 原生类型因为多个包同时往 context 里塞值时内置 string 类型很容易撞 key。取值逻辑在文档里也讲清楚了先从当前 context 查找不到就往父 context 递归查找直到某个父节点返回 nil 或找到对应的值。我一般把类型断言写成两步v, ok : ...一旦断言失败就走兜底逻辑不会让 panic 穿透业务代码。值传递还有一个隐藏的坑context 的 value 对子 context 可见对兄弟节点不可见所以不要在一条业务链路的中间节点往 context 塞数据然后交给无关分支去读那样会形成隐式耦合。3.4 singleflight把 N 个相同请求合并成一个singleflight 包解决的是缓存击穿场景。大量请求同时打到同一个热点 key缓存没命中所有请求都会穿到数据库。singleflight 的做法是按 key 合并请求同一个 key 只有一个 goroutine 真正执行查询其他 goroutine 阻塞等待拿到同一个结果返回。package main import ( fmt sync time golang.org/x/sync/singleflight ) func main() { var g singleflight.Group // 并发发起 5 个完全相同的请求 var wg sync.WaitGroup for i : 0; i 5; i { wg.Add(1) go func(i int) { defer wg.Done() // key 相同只有一个请求真正进入 loadData v, err, shared : g.Do(user_1001, func() (interface{}, error) { return loadDataFromDB(user_1001) }) fmt.Printf(请求 %d 返回 %v, err%v, shared%v\n, i, v, err, shared) }(i) } wg.Wait() } func loadDataFromDB(key string) (interface{}, error) { // 模拟数据库查询 time.Sleep(200 * time.Millisecond) return fmt.Sprintf(data:%s, key), nil }代码块里的shared返回值很关键它标识当前 goroutine 拿到的结果是不是和别人共享的。文档里把 singleflight 的结构讲得很细内部是 map 互斥锁map 存的是同一请求的 call 对象call 里有 waitgroup 控制等待、val/err 存结果、dups 统计重复请求次数。Do 方法是同步等待DoChan 方法返回 channel 异步等待Forget 方法可以主动删除某个 key让下一次请求重新执行而不是继续复用旧结果。用的时候要注意singleflight 只能保证并发瞬间的合并如果第一个请求已经执行完并返回后面的新请求仍然会再查一次所以它要和缓存配合使用不能单独扛流量。3.5 WaitGroupADD 前置与计数器语义WaitGroup 是并发协同的等待工具和 Java 的 CountDownLatch 类似。文档写的源码结构很有意思WaitGroup 里只有一个[3]uint32的 state1 字段总共 12 字节。当数组首地址 8 字节对齐时前 8 字节作为 64 位状态高 32 位是计数器低 32 位是等待者数量后 4 字节作为信号量如果首地址没有对齐就反过来用。这种设计是为了让 64 位原子操作在 32 位编译器下也能正常工作。package main import ( fmt sync ) func main() { var wg sync.WaitGroup // 先 ADD再启动 goroutine wg.Add(3) for i : 0; i 3; i { go func(i int) { defer wg.Done() fmt.Println(完成任务, i) }(i) } // 等待全部完成 wg.Wait() fmt.Println(所有任务结束) }代码块的顺序是硬约束ADD 必须在 Wait 之前调用且 ADD 的参数必须和实际 goroutine 数一致。WaitGroup 的计数器不能为负数否则会 panicDone()本质就是Add(-1)所以想快速唤醒所有等待者可以直接传负数但前提是计数器不能减到负。Wait 方法内部在计数器减到 0 时会唤醒所有等待的 goroutine。另外两个约束要格外注意第一WaitGroup 不能被拷贝特别是使用中的 WaitGroup一旦拷贝就会出现计数器错乱的 bug第二ADD 和 Wait 不能并发调用文档明确说这会 panic。关于 noCopy 的检查机制放到下一章展开。4. 结构体、内存对齐与 unsafe把「为什么」答出来4.1 内存对齐的两条规则与一个反直觉例子内存对齐这个概念面试官喜欢问「为什么需要对齐」。文档给了两层解释第一是硬件平台差异有些 CPU 只能访问特定地址上的数据不对齐代码就没有移植性第二是性能现代 CPU 访问内存不是逐个字节访问而是以字长为单位对齐的内存只需要一次访问未对齐需要两次。所以编译期会在结构体成员之间插入填充字节。对齐规则有两条每个成员相对于结构体首地址的偏移必须是「该成员大小与有效对齐值中较小者」的整数倍结构体整体长度必须是「最大成员对齐值」的整数倍。常用平台的对齐系数是 32 位系统 4、64 位系统 8。package main import ( fmt unsafe ) // T1 的字段顺序不好 type T1 struct { a bool // 1 字节 b int64 // 8 字节 c byte // 1 字节 } // T2 调整字段顺序 type T2 struct { a bool c byte b int64 } func main() { // 同一组字段不同顺序内存占用不同 fmt.Println(T1 size:, unsafe.Sizeof(T1{})) // 24 fmt.Println(T2 size:, unsafe.Sizeof(T2{})) // 16 }T1 布局是offset 0 放 aoffset 1 到 7 填充offset 8 到 15 放 boffset 16 放 c整个结构体长度要按 8 对齐所以补到 24。T2 布局是offset 0 放 aoffset 1 放 coffset 2 到 7 填充offset 8 到 15 放 b总长度正好 16。这是面试里最反直觉的例子同样的字段、同样的类型换个顺序就能省 8 字节。文档的建议是把宽成员int64、string、指针尽量往前放窄成员往后放同时也要兼顾业务字段的可读性不要为了省内存把结构体改成天书。4.2 空结构体zeroBase 与最后一个成员的填充空结构体struct{}有它自己的行为语义。文档指出所有空结构体内存分配都在同一个地址即 zeroBase 地址因为它不占用任何空间。但空结构体作为结构体字段时放置顺序会影响布局。package main import ( fmt unsafe ) type Good struct { mark int _ struct{} // 空结构体放最后 } func main() { fmt.Println(size:, unsafe.Sizeof(Good{})) }空结构体放在中间占用是 0放在最后一个字段时编译器会有特殊处理文档原话是「会被填充到对齐前一个字段的大小地址偏移对齐规则不变」。Go runtime 的源码里对 zero-size 字段有一个特殊判定目的是避免最后一个零尺寸字段的内存地址越界或与其他对象的地址重叠。所以实际编码时如果结构体里要用空结构体做占位或标记把它放在字段列表的最前面或中间不要依赖它做末尾的边界保护除非你清楚这个填充规则。4.3 Go 指针的安全限制与 unsafe 的边界Go 的指针类型比 C 语言限制严格得多文档里列了四条指针不能做数学运算不同类型的指针不能相互转换不同类型的指针不能比较不同类型的指针不能相互赋值。这些限制都是安全考虑但代价是灵活性下降。unsafe 包的存在就是为了在必要时候绕过类型系统。unsafe 包提供三个函数Sizeof 返回变量占用字节数但不包含指向内容Offsetof 返回结构体成员相对于起始地址的偏移参数必须是结构体成员Alignof 返回类型需要对齐的倍数。package main import ( fmt unsafe ) type Sample struct { A int32 B int64 C byte } func main() { s : Sample{A: 1, B: 2, C: 3} // unsafe.Pointer 可以和任意类型指针互转 p : unsafe.Pointer(s) // 转成 uintptr 才能做算术运算取 B 的地址 bAddr : uintptr(p) unsafe.Offsetof(s.B) bPtr : (*int64)(unsafe.Pointer(bAddr)) fmt.Println(*bPtr) // 2 // 万一 s 被 GC 回收bPtr 使用前必须保证 s 存活 _ s }unsafe.Pointer 是通用指针类型可以和普通指针互转但它不能做算术运算。uintptr 是整数类型可以做算术但 uintptr 没有指针语义GC 不把它当指针看所以 uintptr 指向的对象可能被回收。这个区别是面试追问的关键用了 uintptr 之后原来的对象引用链可能断裂正确用法是在同一个表达式中完成「转 uintptr、算术、转回 unsafe.Pointer」并且在函数内用 runtime.KeepAlive 或直接引用原对象保证存活。4.4 noCopygo vet 是怎么抓 WaitGroup 拷贝的Go 里结构体拷贝带来的隐患根源是成员里的指针字段在拷贝后会让两个对象共享同一份数据。为了防范这种问题Go 提供了两层保护机制运行期检查和静态编译检查。文档说的是官方目前只给 strings.Builder 和 sync.Cond 做了运行期拷贝检查其他需要防拷贝的类型靠 go vet 的 copylocks 检查器兜底。package main import sync // noCopy 嵌入需要防拷贝的结构体 type noCopy struct{} func (*noCopy) Lock() {} func (*noCopy) Unlock() {} type SafeBox struct { noCopy data []string } func main() { box : SafeBox{data: []string{a}} // go vet 会报告assignment copies lock value to box2 _ box }关键是 noCopy 必须实现sync.Locker接口也就是有 Lock 和 Unlock 方法go vet 才能识别出它包含锁语义从而检查拷贝行为。嵌入 noCopy 的 struct 一旦被赋值给新变量go vet 就会报错。运行期检查则依赖对象地址变化判断是否发生了移动拷贝代价是性能损耗所以没有压到所有类型上。工程上要防拷贝的类型自己嵌一个 noCopy 再在 CI 里跑 go vet比运行期检查更便宜。4.5 struct tag反射读取与 json 的私有字段陷阱结构体标签是编译期就关联到字段上的字符串运行期通过反射读取。格式有严格约束键和值用冒号分隔值用双引号包裹多个键值对用一个空格分隔不能用逗号。这是个经典的「面试默写」题错在逗号上的人不在少数。package main import ( encoding/json fmt reflect ) type User struct { Name string json:name binding:required age int json:age // 私有字段 } func main() { u : User{Name: asong, age: 18} // 反射读取 tag t : reflect.TypeOf(u) for i : 0; i t.NumField(); i { f : t.Field(i) fmt.Println(f.Name, f.Tag.Get(json)) } // json 序列化私有字段会被忽略 out, _ : json.Marshal(u) fmt.Println(string(out)) // {name:asong} }文档特意强调了一句话json 包不能导出私有变量的 tag原因是 json 包内部遇到私有字段直接 continue不是反射读不到而是它主动跳过。所以面试里如果问「为什么私有字段的 json tag 不生效」答案要从 json 包的实现策略讲而不是反射机制。多个 tag 键值对用空格分隔json:name binding:required这种写法合法json:name,binding:required这种写法不合法会解析失败。5. 避坑现场这些题最容易答错原因与对策5.1 channel 关闭之后的读写结果完全不同现象向已关闭的 channel 写数据程序直接 panic错误信息是 send on closed channel从已关闭的 channel 读数据却能一直读读到的内容是通道关闭前剩余的元素读完后就一直是零值。原因channel 被关闭后发送方不允许再发送数据runtime 会在发送时检查 closed 标志位发现关闭状态就抛 panic。接收方不受影响因为 close 的语义是「发送方不再发送」不是「接收方不能接收」缓冲区里的元素仍然可以被消费。解决代码里用v, ok : -ch判断 okfalse 表示 channel 已关闭且无更多数据。不要把关闭 channel 当成数据发送给接收方需要广播关闭信号时用close(ch)接收方用 for range 或 ok 模式收尾。5.2 切片 append 之后函数内外的引用关系断开了现象切片作为参数传入函数函数里对切片元素赋值调用方能看到修改但函数里append之后调用方的切片长度和数据都没变。原因切片本质是三个字段的结构体Data 指针、Len、Cap。作为参数传递时Go 只拷贝结构体本身Data 指针指向同一块底层数组所以改元素能共享但 append 一旦触发扩容函数内的切片会指向一块新分配的数组Len 和 Cap 也变了函数外的切片还指着旧数组。解决需要在函数内改变切片长度或底层数组时用指针传递切片或者让函数返回新的切片。面试时把这句话说出来基本就到位直接传切片只能共享底层数组不能共享长度和容量的变更。5.3 WaitGroup 被拷贝导致死锁或计数错乱现象定义了var wg sync.WaitGroup后把它作为函数参数传进去go vet 会报错代码照跑时可能出现 Wait 永远不返回。原因WaitGroup 内部 state1 保存计数器和信号量拷贝后副本和原对象各持一份状态真正的 goroutine 可能调用的还是旧副本的 Done导致新副本的 Wait 永远等不到计数器归零。这是典型的「用起来才炸」的场景。解决需要传递时用指针*sync.WaitGroup。如果只是静态检查在结构体里嵌入 noCopy 字段让 go vet 在 CI 阶段直接拦截。5.4 结构体字段顺序不对内存多占 8 字节现象同类型字段只是换了个顺序unsafe.Sizeof的结果从 16 变成 24。原因上一章讲的字节对齐规则。宽字段如果放在窄字段后面中间要补齐对齐字节窄字段也随之被推到更高偏移。解决把 int64、指针型这类宽度大的字段往前排小字段尽量靠后批量创建这类结构体时可以明显省内存。线上排查内存占用不降时我一般会先拿unsafe.Sizeof扫一遍大对象看字段顺序有没有浪费。5.5 unsafe 强转 string 后改字节recover 都救不了现象用 unsafe 把 string 强转成 []byte然后修改字节数组的第 0 个元素程序直接崩溃包上defer recover也捕获不到。原因string 是不可变的底层字节可能分配在只读内存段。强转只是把指针和长度复制过来没有真正复制底层数组写入操作触发的错误属于内存访问错误Go 的 recover 只能拦截 panic拦不住这类更底层的崩溃。解决需要修改字符串内容用[]byte(s)标准转换产生一份拷贝再改或者先[]rune(s)做字符级处理。unsafe 强转只用在严格的只读场景比如高性能序列化里的临时 view绝对不要对它写数据。6. 落地用这份文档搭一套两日自检清单拿到文档不是看完就完我习惯把它拆成两份清单一份按「结论→源码→场景」的格式重新过一遍另一份是随机抽题自讲。具体做法是把每章标题改写成问句例如「flag 在什么情况下会提前停止解析」「WaitGroup 为什么不能拷贝」「context 的 value 为什么不适合放关键参数」然后用表格安排两天的复习节奏。时间段复习范围自测方式第一天上午flag、nil、string、rune、len 相关真题不看文档手写运行结果第一天下午context 四类衍生、singleflight 合并请求写出带取消的 goroutine 示例第二天上午内存对齐、空结构体、unsafe、nosource画出结构体布局图第二天下午channel 读写、切片扩容、WaitGroup、避坑案例逐条复述现象、原因、解决自讲的时候强制套一个模板先说结论再讲成因然后给一个最小代码场景最后说边界条件。这四步缺一步说明这个知识点还有盲区回头翻对应章节找答案。我拆这类面试文档的习惯是只留「能展开成三句追问」的题目。比如 flag 的题目就能追问三句为什么解析会停bool flag 为什么不能写成空格形式如果我要让自由参数出现在 flag 前面怎么办答得上才算过。从那以后我每次做面试突击都会强制走一遍随机抽题自讲的流程讲不出来就回去翻对应章节用这道题的三个追问验证是不是真懂。希望帮到你。本文还有配套的精品资源点击获取