后端入门课 01|Go 基础语法

掘金的朋友们,好久不见~

上一篇更新内容是面经(引来了不少朋友们的关注),转眼我已经来字节两年啦。这两年做了不少事情,收获了很多成长(但是一直没来这里冒泡... 🙂)。最近组里缺服务端人力,对我而言是个不错的机会,所以我开始学习写服务端的代码啦。服务端入门要学的东西实在是不少,来这里记录一下学习过程。

因为我们团队用的是 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() {
  // 这里可以写一些初始化逻辑
}

注意点:

  1. 除了使用 _ 关键字之外,引用某个包必须调用其中的内容,否则编译不通过
  2. 使用 . 关键字时,需要保证多个包中没有重名方法
  3. 包不能循环引用
  4. 包内只有首字母为大写的内容(包括方法、变量、常量、结构体)才可以被别的包调用到(相当于首字母的大写的成员属于 public,小写的属于 private)
  5. 同属于一个 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 作为参数传入并运行协程,在协程任务完成后执行 WaitGroupDone() 释放;在主方法中执行 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掉)
}
相关推荐
程序员爱钓鱼1 小时前
Go语言实战案例-创建模型并自动迁移
后端·google·go
javachen__1 小时前
SpringBoot整合P6Spy实现全链路SQL监控
spring boot·后端·sql
uzong7 小时前
技术故障复盘模版
后端
GetcharZp7 小时前
基于 Dify + 通义千问的多模态大模型 搭建发票识别 Agent
后端·llm·agent
桦说编程7 小时前
Java 中如何创建不可变类型
java·后端·函数式编程
IT毕设实战小研7 小时前
基于Spring Boot 4s店车辆管理系统 租车管理系统 停车位管理系统 智慧车辆管理系统
java·开发语言·spring boot·后端·spring·毕业设计·课程设计
wyiyiyi8 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
阿华的代码王国9 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
Jimmy9 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
AntBlack9 小时前
不当韭菜V1.1 :增强能力 ,辅助构建自己的交易规则
后端·python·pyqt