掘金的朋友们,好久不见~
上一篇更新内容是面经(引来了不少朋友们的关注),转眼我已经来字节两年啦。这两年做了不少事情,收获了很多成长(但是一直没来这里冒泡... 🙂)。最近组里缺服务端人力,对我而言是个不错的机会,所以我开始学习写服务端的代码啦。服务端入门要学的东西实在是不少,来这里记录一下学习过程。
因为我们团队用的是 Golang,所以就从 Golang 开始入门!
环境准备
for Mac
安装 Homebrew
bash
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
安装 Go
perl
# 推荐1.21版本
brew install go@1.21
# 指定版本
brew link go@1.21 --force
# 验证是否安装成功
go version
安装 IDE Goland
www.jetbrains.com/go/ (需要激活 💡)
配置 GoRoot:AllSettings -- GO -- GOROOT
OK,完成以上步骤就可以开始愉快地写代码啦!
语法基础
包(package)
Golang 项目的模块化是基于包(package) 实现的,每个 package 中是一段可编译执行的代码。
程序入口文件 必须以 package main
定义,并且内部定义一个 func main()
入口方法,程序执行时会从 main
开始执行,main
执行结束则程序进程也随即结束。(遥远的关于 C 语言的记忆开始攻击我...)
运行代码:
go
// 1. 直接命令 run
go run main.go
// 2. 编译为二进制可执行文件
go build
// 3. 执行
./${output}
package 是模块的拆分,通常以目录为单位定义包,包名与目录名保持一致(当然了也可以不一样)。
package 命名规范:
-
只包含小写字母
-
尽量简短且包含一定的上下文信息
-
拆分子包时使用 a/b 而不是 aB 或 a_b
-
使用单数而不是复数
-
谨慎使用缩写
使用
go mod tidy
命令管理依赖包,移除没有用到的模块,补齐缺失的模块。
go
// 定义一个包
package a
// 引入其他包
import "b" // 单个
import (
"b"
"c"
) // 多个
import (pkgA "a") // 包重命名为 pkgA
import (. "a")// 匿名引用,可直接调用包a中的方法
import (_ "a") // 只执行 a 中的 init 方法
// 对于包进行引用时,默认会执行引用包中的 init() 方法
func init() {
// 这里可以写一些初始化逻辑
}
注意点:
- 除了使用 _ 关键字之外,引用某个包必须调用其中的内容,否则编译不通过
- 使用 . 关键字时,需要保证多个包中没有重名方法
- 包不能循环引用
- 包内只有首字母为大写的内容(包括方法、变量、常量、结构体)才可以被别的包调用到(相当于首字母的大写的成员属于 public,小写的属于 private)
- 同属于一个 package(文件夹)的内容可以互相引用,没有限制
import 分组规范:
- 两个及以上的 import 应该聚合为一个组
- import 的标准库和其他库应该使用一个空行隔开,先引入标准库,然后是其他库
Hello World!
go
package main
// 引入内置包 fmt, 用于进行字符串格式化输出
import "fmt"
// 程序入口方法
func main(){
fmt.Println("Hello,world!")
}
变量和常量声明
变量命名规范:
- 可导出的变量首字母大写,内部使用的变量首字母小写
- 变量中包含缩略词时,如果位于开头且不需要导出时全小写,否则全部大写,如 ServerHTTP
- 简洁胜于冗长,如在循环中用 i 而不是 sthIndex
变量声明:
go
// 关键字 var,类型一旦声明不可更改
var {变量名称} {变量类型}
// int
var num int
var num1, num2 int
var num int = 1
var num1, num2 int = 1 , 2
// float32
var num float32
var num int = 3.1415926
// 当然了也支持类型推断
var a = 1
var b = true
// 混合声明,按顺序对应
var a,b,c,d = 1, "2", true, 3.444
// 快捷声明 := 代替 var
a := 1
// 指针
var v1 int
// 定义一个指向整型的指针
var p1 *int
// 使用&获取整型变量地址并赋值给指针
p1 = &v1
// 定义一个指向浮点型的指针
var p2 *float32 = new(float32)
和 JS 很不一样的一点在于:变量类型后置
变量未指定初始值时,默认会有一个零值 ,汇总在下表中 :
基础类型 | 初始值 | 说明 |
---|---|---|
int, int8, int16, int32, int64 | 0 | 有符号整型;不同类型的整型无法互相赋值,需要做类型转换。其中位数长的类型转换为位数短的类型,或者无符号的类型转换为有符号的类型时,会丢失准确性 |
uint, uint8, uint16, uint32, uint64, uintptr | 0 | 无符号整型,uintptr 用于指针运算 |
float32, float64 | 0 | 浮点型, |
complex64, complex128 | 复数,a+bi |
|
string | "" | 默认采用UTF-8编码 |
bool | false | |
byte | 单个字节数据,同 uint8,字面量为单引号c := 'a' |
|
rune | 单个字符数据,同 uint32,字面量为单引号d := '啊' |
|
其他类型 | 初始值 | 说明 |
array | s1 := [32]int |
|
slice | 数组的一段数组元素区间,定义方式与数组类似,只不过不需要指定数组大小 | |
func | ||
map | 声明形式: map[KeyType]ValueType``KeyType 必须是可比较的类型,不可以是map/slice/array... |
|
struct | 每一个字段都会有相应的零值 | 结构体, OOP 基础,自定义类型; |
指针 | nil | 对于变量的间接引用,存放变量的内存地址关键字 * 用于定义指针和获取指针的值 关键字 & 用于获取元素的指针地址 |
interface | nil |
三种常见的 int 转 string 方法:
- fmt.Sprintf("%d", n)
- strconv.Itoa(n) [推荐]
- strconv.FormatInt(n2, 10) [推荐]
string to int
字符串转 int 主要两种方法:
-
strconv.Atoi [推荐]
-
strconv.ParseInt
常量声明:
go
const Pi = 3.1415926
const Pi float32 = 3.1415926
// 自定义类型常量
type Status int
const StatusSuccess Status = 0
const StatusFail Status = 1
// 有一个 iota 可以用来生成序列化整数,从0开始自增
// 不建议在业务代码中使用
type Status int
const (
StatusSuccess Status = iota // 值为0
StatusFail // 值为1,如果 -iota 的话就是向负递增,-1
StatusPending // 值为2,如果 -iota 的话就是 -2
)
注释规范:
- 如果注释位于单独一行,则需要以大写开头(缩略词或变量名除外)并以 . 结尾
- 如果位于语句后方,则不要以大写开头,且结尾不需要加入标点符号
- 建议写英文注释
结构体 struct
结构体是一种自定义类型,是 OOP 的基础,可以理解为一个 class。
结构体定义:
go
type {结构体名称} struct{
{字段名} {字段类型}
}
// eg:
type Person struct {
Name string
Age int8
}
结构体创建:
go
// 一个名为 Person 的结构体
type Person struct {
Name string
Age int8
unavailable string // 私有字段
}
// 定义一个 Person 类型的结构体变量并赋值
var hah Person = Person{
Name: "hah",
Age: 99
}
// 修改其中的值
hah.Age = 100
字段可访问性:小写字母开头相当于私有字段;大写字母开头则公开可访问。
结构体方法:
go
// 一个名为 Person 的结构体
type Person struct {
Name string
Age int8
unavailable bool // 私有字段
}
// (p Person) 标识它是一个结构体方法
func (p Person) SayHi() {
fmt.printf("Hi, my name is %s", p.Name)
}
func main() {
// 实例化一个 Person 类型变量
var hah Person = Person{
Name: "hah",
Age: 99
}
hah.SayHi()
}
结构体方法在执行时,会在内存中创建一个新的结构体 并执行其中的方法,即我们无法修改原结构体中的指针。那如果我们想修改原结构体的内容 呢?借助指针的引用传递,寻找到源结构体。
go
func (p *Person) updateAge(age int8) {
p.Age= age
}
// 结构体指针方法只能通过结构体指针调用
func main() {
var h * Person = & Person{
Name: "huihui",
Age: 100
}
h.updateAge = 101
}
结构体继承:
go
type Person struct {
Name string
Age int8
}
func (p Person) SayHi() {
fmt.printf("Hi, my name is %s", p.Name)
}
// 非常简单的语法糖写法
type Student struct {
Person // 继承 Person
}
func main(){
var s Student = Student {
Name: "xiaoming",
Age: 12
}
s.SayHi()
}
接口 interface
interface 命名规范:
- 对于只有一个方法的 interface,通常将其命名为方法名加上 er,例如 Reader 和 Writer
go
// 空接口
var person interface {}
// 类型接口,定义各种方法的规范
type 类型名称 interface {
方法名(传入参数)返回参数
...
}
type Runner interface {
Run()
}
// 类型推断,方法 1
{{ 推断后的变量 }} := {{ 接口变量 }}.({{ 推断类型 }})
// 使用该方法需要保证类型推断是正确的,否则会引发 Panic 错误
var i interface{}
i = 100
var j int
j = i.(int)
// 类型推断,方法 2
{{ 推断后的变量 }}, {{ 是否推断成功 }} := {{ 接口变量 }}.({{ 推断类型 }})
var i interface{}
i = 100
var j string
j, ok := i.(int)
if !ok {
panic("error")
}
// 类型推断,方法 3,参数类型不确定时,推荐使用方法 3
switch {{ 推断后的变量 }}:={{ 接口变量 }}.(type){
case {{ 推断类型1 }}:
...
case {{ 推断类型2 }}:
...
default:
...
}
var i interface{}
i = 100
var j float32
switch v := i.(type) {
case float32:
j = i
default:
panic("error")
}
接口只关心类型能否去执行某个方法,而不会去关心类型中有什么值。
条件判断和循环
go
// if 判断
func handler(num int) {
if num == 1 {
log.Println("1")
} else if num == 2 {
log.Println("2")
} else {
log.Println("else")
}
}
// switch 判断
func CheckScore(score int) {
switch score {
case score < 60:
log.Println("bad")
if true {
break
}
case score < 80:
log.Println("normal")
if true {
break
}
default:
log.Println("good")
}
}
// for 判断
func myLoop() {
i := 1
for i < 3 {
log.Println(i)
i++
}
}
// 结合 range
func myLoop() {
arr := []string{"1", "2"}
for i := range arr {
log.Println(arr[i])
i++
}
}
错误处理
使用内置包 errors 的 NewError()
可以创建错误,不过业务中基于业务特点会自己封装 bizError
。
一些比较严重的错误,可以用内置的 panic
方法引发运行时错误 ,这个错误会在方法中逐层向上传递直到 main 中,导致程序的错误退出。
如果我们希望接管 panic 错误自定义处理,而不是让程序中断退出,可以通过内置的 recover
方法结合延迟执行 defer
实现。
go
func main(){
// ... 一些逻辑
// 在末尾执行
defer func(){
e := recover() // 拦截 panic 错误并将错误赋值给 e
if err, ok := e.(error); ok {
fmt.Println(err)
} else {
fmt.Println(e)
}
}
panic("uh oh!")
}
协程
协程可以理解为轻量级的线程 ,golang 中,可以轻松创建出成千上万个协程。
得益于 golang 的优化~TODO: 有空看看是怎么优化的。
协程可以通过 go
关键字创建:
go
func main(){
// ....
go func(){
fmt.Println("我在协程中运行 :P")
}
// 协程的执行是非阻塞的,协程创建后主方法会继续往下走,直接退出进程
// 这时协程很有可能还没执行到,需要有一个方法去等待协程执行完毕
time.Sleep(time.Second)
// 这个方法不太可靠
}
锁
如果多方对同一个数据做读写操作,会导致数据不准确,所以需要引入锁机制 来保证数据并发读写时操作的原子性 。锁具有互斥性 ,同一个锁在一个协程中加锁之后在另一个协程中是无法加锁的,会一直阻塞到锁被解开,从而保证一段时间内只有一个事务在进行。
只读没必要加锁,读写同时发生或者并发写入就需要加锁。
锁的种类很多,比如分布式锁、线程锁、协程锁...
在 golang 中,内置库 sync 实现了互斥锁 sync.Mutex
:
scss
var waitGroup sync.WaitGroup
var myMutex sync.Mutex
waitGroup.Add(2)
go someReadAction(&waitGroup)
go someWriteAction(&waitGroup)
waitGroup.wait() // 等 Group 操作都执行完毕
// 加锁
func someWriteAction(wg *sync.WaitGroup){
myMutex.Lock()
// ....操作
myMutex.Unlock()
wg.Done()
}
以及读写锁 sync.RWMutex
:
读写锁对锁的作用做了进一步延伸,包括两种锁:读锁和写锁。
写锁和互斥锁一样,有锁时无法再加锁;
读锁不太一样,只要不存在写锁,多个读锁可以同时锁上。
只要不是协程安全的类型,在跨协程使用过程中会发生数据变化的,都应当加锁。 加锁时应当以事务为单位加,且不能出现嵌套加锁,否则会出现死锁。
只要事务中出现了写操作,就应当加写锁;只有在事务中不存在写操作时,才加读锁。
等待组 WaitGroup
我们怎么获取所有协程都执行完毕的准确时机呢?
在内置包 sync
中有一个 WaitGroup
可以对应这种场景,它基于锁机制 实现,原理是在 WaitGroup
内部有一个带锁的计数器,通过它的 Add()
方法可以加值,Done()
可以释放一个计数器(值减1),还有一个 Wait()
方法会一直阻塞到计算器归零。
比如我们要设计一个并发去下载数据的功能,可以在协程执行前为 WaitGroup
计数器加1,然后把 WaitGroup
作为参数传入并运行协程,在协程任务完成后执行 WaitGroup
的 Done()
释放;在主方法中执行 Wait()
方法阻塞知道所有协程任务完成。
go
func export() {
arr := []string{"https://xxxxximage1", "https://xxxxximage2", "https://xxxxximage3", "https://xxxxximage4"}
var wg sync.WaitGroup
for i := range arr {
// 等待组计数器+1
wg.Add(1)
go downloadImages(arr[i], &wg)
i++
}
wg.Wait()
fmt.Println("全部下载完成!!!")
}
func downloadImages(url string, waitgroup *sync.WaitGroup){
// 执行下载逻辑
// Sleep 模拟一下
time.Sleep(time.Second)
fmt.Println("下载完成:", url)
// 释放计数器
waitgroup.Done()
}
运行结果:
通道 channel
通道,可以理解为支持跨协程使用的空间大小固定的队列 ,由关键字 chan
定义:
go
var {变量名} chan {通道类型}
通过 make()
方法创建通道:
go
var msgChannel chan string
// 给通道分配内存空间,不指定时默认为0
msgChannel = make(chan string, 10)
哦我说项目中的 make 是啥意思呢!明白了 💡
通道可以作为参数协程中传递,通过操作符 <-
和 ->
将数据读出或写入通道:
go
func test(testChan chan string){
fmt.Println("test begins!")
time.Sleep(time.Second)
// 将数据写入通道,这里会阻塞直到另一端读取通道
testChan <- "start"
}
func main(){
fmt.Println("main begins!")
// 创建通道
var testChan chan string = make(chan string)
// 执行协程,传入通道作为参数
go test(testChan)
// 从通道中读取数据,这里会阻塞直到另一端写入通道
<-testChan
fmt.Println("main ends!")
}
// 运行结果
main begins!
test begins!
main ends!
关闭通道可以基于内置的 close
方法实现,通道关闭之后不支持写入数据(此时写入会 panic
),但仍然可以读取数据直到通道中内容为空。
go
var msgChannel chan string
msgChannel = make(chan string, 10)
// 写入一条数据
msgChannel <- "hello"
// 关闭
close(msgChannel)
// 读取数据,队列嘛,取一条少一条
msg, ok := <- msgChannel // ok 为 false 时,表示通道已经关闭且没有数据
上面的示例是双向通道,可以读也可以写;此外还可以指定单向通道(只读或者只写);双向通道可以转换为单向通道,反之不可以。
go
var <-chan {通道类型} // 只读
var chan-> {通道类型} // 只写
在高并发场景中,通道可以用来缓存无法即时消费的写请求,从而释放协程资源。
TODO:这块后面可以再进一步了解下。
上下文 Context
scss
type Context interface {
// 返回一个只读通道,当此上下文被取消或传递的 deadline 时间到达时,该通道将关闭
Done() <-chan struct{}
// 返回上下文已取消的原因(如果有)
Err() error
// 如果上下文有设置 deadline,则返回 deadline 时间;否则,返回 false
Deadline() (deadline time.Time, ok bool)
// 返回与某个键相关联的值,没有则返回 nil
Value(key interface{}) interface{}
}
协程运行是独立的,如果处理不当有协程一直驻留在内存区,会形成僵尸协程 ,影响系统稳定性。在内置包 context
中,我们可以基于 context.Context
应对这种场景。
Context
是一个协程安全的类型,可以传递给不同的协程方法使用。
scss
func main(){
// 通过 context.Background() 创建一个上下文,做为根级上下文,只用于派生子上下文,不做其他用途
// 通过根级上下文进行派生,获得一个子上下文以及子上下文的终止方法
ctx, cancel := context.WithCancel(context.Background())
go doSth(ctx)
// 3s 后关闭上下文
time.Sleep(3 * time.Second)
cancel()
}
doSth(ctx context.Context){
// ....
}
测试与分析
单元测试
Golang 中内置了测试包 testing
,可以对某个方法或者模块做功能测试和验证。
比如我们有一个 a.go
文件,测试时在同级目录下创建一个同名且带 _test.go
后缀的文件【必须】;在 a_test.go
文件中,引入包 testing
,给需要测试的方法加 Test
前缀【必须】,接收参数为 *testing.T
,*testing.T
可以在测试过程中输出相关信息。
go
// a.go
package unittest
func Add(x int, y int) int {
return x + y
}
// a_test.go
package unittest
import "testing"
func TestAdd(t *testing.T) {
if Add(1, 2) !== 3 {
t.Error("add error!")
} else {
t.Log("add correctly!")
}
}
运行 go test -v
执行单元测试,-v 表示打印日志。go test -v -cover
可以开启覆盖率统计。
性能测试
性能测试文件也是以 _test.go
结尾,同时引入包 testing
。性能测试方法的定义要求以 Benchmark
作为前缀。
运行 go test a_test.go -test.bench=".*"
执行性能测试。测试结果会包含执行次数、执行时间等信息。
go test b_test.go -test.bench=".*" -count=5
执行5轮。
go test b_test.go -test.bench=".*" -benchmem
查看内存分配情况。
性能分析
Golang 中提供了工具 pprof
用来做性能分析,有两个库:runtime/pprof
(用于为非守护形式运行的程序做性能分析)和net/http/pprof
(用于为守护态的 http 服务程序做性能分析)。
Q:守护态是啥?
A(by ChatGPT):守护进程(daemon process)是指在后台运行的进程,该进程通常以超级用户权限运行,并且不与控制台或用户交互。守护进程通常用于作为后台服务运行,例如 Web 服务器、数据库服务或其他长时间运行的服务,但不需要与用户交互。
编译
后面再看
HTTP
Handler
Golang 中内置包 net/http
提供了一些 http 相关的方法,通过 http.ListenAndServe()
方法监听端口并启动服务,接收到请求后需要根据不同路由指定对应的处理方法,http.HandleFunc()
实现了请求路径和 handler
的绑定。
go
import (
"fmt"
"net/http"
)
func SayHi(w http.ResponseWriter, req *http.Request){
w.Write([]byte("hello,world!\n"))
}
func main(){
// 将所有匹配到 / 的请求交由 SayHi 方法处理
http.HandleFunc("/", SayHi)
// 开启 http 服务并侦听到 8080 端口
// 第二个参数表示服务处理程序,一般设置为空,会用默认的路由分发器 http.DefaultServeMux
// http.Handle() 或 http.HandleFunc() 会默认将路由注入 http.DefaultServeMux 中
if err := http.ListenAndServe("localhost:8080", nil); err != nil {
fmt.Println(err)
}
}
// http.ListenAndServe 会使用默认配置开启一个 Server
// 也支持自定义 Server
server := http.Server{
Addr: "localhost:8080",
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
Handler: mux,
}
ServeMux
http.NewServeMux()
方法支持创建自定义的路由分发器:
scss
// 创建自定义的路由分发器
mux := http.NewServeMux()
// mux 里面也封装了 Handle() 和 HandleFunc() 方法,和上述使用一致
Middleware
中间层,在路由分发和 HandlerFunc
中间生效,附加一些通用的功能。
scss
func LoggerMiddleware(next http.Handler){
return http.HandlerFunc(func (w http.ResponseWriter, r *http.Request){
start := time.Now()
log.Printf("here comes a request %s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
log.Printf("response cost %s", time.Since(start))
})
}
进阶
数组与切片
slice 是对数组的封装,它的底层数据是数组。
数组定长,长度指定之后不能再更改,在 Go 中数组用的比较少,因为数组长度是类型的一部分,限制了它的表达能力 ,比如 [3]int
和 [4]int
就是不同的类型。而切片则非常灵活,它可以动态地扩容。切片的类型和长度无关。
go
// 创建切片
var slice []T // 声明一个 T 类型的空切片
slice := make([]T, length) // 使用 make 函数创建指定长度的 T 类型切片
slice := make([]T, length, capacity) // 创建指定长度和容量的 T 类型切片
// append 扩容, 返回一个新的 slice
// append 函数执行完后,返回的是一个全新的 slice,并且对传入的 slice 并不影响
slice = append(slize, ele1, ele2)
slize = append(slize, anotherSlice)
数组就是一片连续的内存, slice 实际上是一个结构体,包含三个字段:长度、容量、底层数组。
go
// runtime/slice.go
type slice struct {
array unsafe.Pointer // 元素指针,指向底层数据的地址
len int // 长度
cap int // 容量
}
使用 append
可以向 slice 追加元素,实际上是往底层数组添加元素。但是底层数组的长度是固定的,如果索引 len-1
所指向的元素已经是底层数组的最后一个元素,就没法再添加了。
这时,slice 会迁移到新的内存位置,新底层数组的长度也会增加,这样就可以放置新增的元素。同时,为了应对未来可能再次发生的 append 操作,新的底层数组的长度,也就是新 slice
的容量是留了一定的 buffer
的。否则,每次添加元素的时候,都会发生迁移,成本太高。
新 slice 预留的 buffer
大小是有一定规律的。在 golang1.18 版本更新之前网上大多数的文章都是这样描述 slice 的扩容策略的:
当原 slice 容量小于
1024
的时候,新 slice 容量变成原来的2
倍;原 slice 容量超过1024
,新 slice 容量变成原来的1.25
倍。
在 1.18 版本更新之后,slice 的扩容策略变为了:
当原 slice 容量(oldcap)小于 256 的时候,新 slice(newcap) 容量为原来的 2 倍;原 slice 容量超过256,新 slice 容量
newcap = oldcap+(oldcap+3*256)/4
。
- 使用slice尽量初始化一定的大小,有接近10倍的性能提升。
Map
map 是由 key-value
对组成的;key
只会出现一次。和 map 相关的操作就是最基本的 增删查改
。
map 的设计也被称为 "The dictionary problem",它的任务是设计一种数据结构用来维护一个集合的数据,并且可以同时对集合进行增删查改的操作。最主要的数据结构有两种:哈希查找表(Hash table)
、搜索树(Search tree)
。
哈希查找表用一个哈希函数将 key 分配到不同的桶(bucket,也就是数组的不同 index)。这样,开销主要在哈希函数的计算以及数组的常数访问时间。在很多场景下,哈希查找表的性能很高。
哈希查找表一般会存在"冲突"的问题,就是说不同的 key 被哈希到了同一个 bucket。一般有两种应对方法:链表法
和开放地址法
。链表法
将一个 bucket 实现成一个链表,落在同一个 bucket 中的 key 都会插入这个链表。开放地址法
则是冲突发生后,通过一定的规律,在数组的后面挑选"空位",用来放置新的 key。
搜索树法一般采用自平衡搜索树,包括:AVL 树,红黑树。自平衡搜索树法的最差搜索效率是 O(logN),而哈希查找表最差是 O(N)。当然,哈希查找表的平均查找效率是 O(1),如果哈希函数设计的很好,最坏的情况基本不会出现。还有一点,遍历自平衡搜索树,返回的 key 序列,一般会按照从小到大的顺序;而哈希查找表则是乱序的。
Go 语言采用的是哈希查找表,并且使用链表法解决哈希冲突。
go
// runtime/map.go
// A header for a Go map.
type hmap struct {
count int // 元素个数,len返回
flags uint8 // 当前map所处的状态标志。目前定义了四个状态值: iterator、oldlterator、hashWriting、sameSizeGrow
B uint8 // buckets 的log2 对数
noverflow uint16 // 溢出桶的数量
hash0 uint32 // hash seed
buckets unsafe.Pointer // 2^B长度的Buckets数组
oldbuckets unsafe.Pointer // 扩容时指向老buckets,每次扩容2倍
nevacuate uintptr // 扩容进度
extra *mapextra // 可选字段。如果有overflowbucket存在,且key、value都因不包含指针而被内联(inline)的情况下,
// 这个字段将存储所有指向overflow bucket的指针, 保证overflow|bucket是始终可用的(不被GC掉)
}