cathoy
7. 坑!还有你不知道的 golang 闭包知识
前言本文将深度解析 golang 中的闭包,将从什么是闭包、闭包的实现、闭包的使用场景以及闭包常见的坑等四个方面进行讲解,希望可以帮助大家彻底理解闭包这个概念。1.什么是闭包什么是闭包?相信很多同学都有所了解,但没有深入地探究过,本小节就详细的讨论下闭包是什么?我们先看看维基百科对闭包的定义;在计算机科学中,闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),是在支持头等函数的编程语言中实现词法绑定的一种技术。闭包在实现上是一个结构体,它存储了一个函数(通常是其入口地址)和一个关联的环境(相当于一个符号查找表)。环境里是若干对符号和值的对应关系,它既要包括约束变量(该函数内部绑定的符号),也要包括自由变量(在函数外部定义但在函数内被引用),有些函数也可能没有自由变量。闭包跟函数最大的不同在于,当捕捉闭包的时候,它的自由变量会在捕捉时被确定,这样即便脱离了捕捉时的上下文,它也能照常运行。捕捉时对于值的处理可以是值拷贝,也可以是名称引用,这通常由语言设计者决定,也可能由用户自行指定(如C++)。闭包是如何产生的?在支持头等函数的语言中,如果函数 f 内定义了函数 g,那么如果 g 存在自由变量,且这些自由变量没有在编译过程中被优化掉,那么将产生闭包。闭包和匿名函数是一个东西吗?闭包和匿名函数经常被用作同义词。但严格来说,匿名函数就是字面意义上没有被赋予名称的函数,而闭包则实际上是一个函数的实例,也就是说它是存在于内存里的某个结构体。如果从实现上来看的话,匿名函数如果没有捕捉自由变量,那么它其实可以被实现为一个函数指针,或者直接内联到调用点,如果它捕捉了自由变量那么它将是一个闭包;而闭包则意味着同时包括函数指针和环境两个关键元素。在编译优化当中,没有捕捉自由变量的闭包可以被优化成普通函数,这样就无需分配闭包结构体,这种编译技巧被称为函数跃升。通过对维基百科中对闭包一词的定义和解释,我们可以了解到,闭包是由函数和与其相关的引用环境组合而成的实体。(闭包 = 函数指针 + 引用环境)举个简单的例子,用闭包实现一个累加器:package main
import "fmt"
func Add(value int) func() int {
return func() int {
value++
return value
}
}
func main() {
func main() {
value := 1
// 第一个累加器
accumulator := Add(value)
fmt.Println(accumulator()) // 2
fmt.Println(accumulator()) // 3
fmt.Println(accumulator()) // 4
// 创建另一个累加器
accumulator2 := Add(value)
fmt.Println(accumulator2()) // 2
fmt.Println(accumulator2()) // 3
fmt.Println(accumulator2()) // 4
}定义一个累加函数,返回类型为 func() int,入参为整数类型,每次调用函数对该值进行累加。用同一个 value 创建两个累加器,分别执行三次,两个累加器独立执行。此时闭包 = 函数指针(func() int)+ 引用环境(入参 value),由于两个累加器引用环境不同,所以两个累加器互不影响。闭包和匿名函数严格意义上讲不是一个东西,但闭包的产生条件之一是函数的嵌套,而匿名函数刚好适用于函数的嵌套。因此,匿名函数常用于闭包的使用,当匿名函数引用了外部作用域中的变量时就形成了闭包。2闭包的实现在 Go 语言中,函数是一等公民(支持头等函数),这意味着函数可以作为别的函数的参数、函数的返回值,赋值给变量或存储在数据结构中。因此,Go 也支持闭包,接下来我们探究一下 Go 中闭包的实现。在 Go 语言中,函数被当做一种变量,本质上是一个指针,指向 runtime.funcval 结构体,这个结构体保存了函数的入口地址 fn uintptr。代码位置:src/runtime/runtime2.gotype funcval struct {
fn uintptr
// variable-size, fn-specific data here
}一个函数变量会经过两层才能找到函数的入口地址:函数变量 -> runtime.funcval -> fn uintptr;Go 增 runtime.funcval 这一层的原因是为了实现闭包,Go 在编译期间会将引用环境变量加入到 funcval 结构体中实现闭包,这样不同的函数变量就拥有了不同的闭包实体。这里我们通过汇编语句来验证一下:2.1 闭包结构的汇编验证举个闭包的例子:package main
func add() func() int {
x := 1
return func() int {
x++
return x
}
}
func main() {
f1 := add()
f2 := add()
f1() // 2
f2() // 2
}执行 go tool compile -S -N main.go 生成汇编语句,主要看 add 函数的汇编代码:main.add STEXT size=157 args=0x0 locals=0x30 funcid=0x0 align=0x0
0x0000 00000 (main.go:3) TEXT main.add(SB), ABIInternal, $48-0
0x0000 00000 (main.go:3) CMPQ SP, 16(R14)
0x0004 00004 (main.go:3) PCDATA $0, $-2
0x0004 00004 (main.go:3) JLS 147
0x000a 00010 (main.go:3) PCDATA $0, $-1
0x000a 00010 (main.go:3) SUBQ $48, SP
0x000e 00014 (main.go:3) MOVQ BP, 40(SP)
0x0013 00019 (main.go:3) LEAQ 40(SP), BP
0x0018 00024 (main.go:3) FUNCDATA $0, gclocals·J5F+7Qw7O7ve2QcWC7DpeQ==(SB)
0x0018 00024 (main.go:3) FUNCDATA $1, gclocals·wdmTuppZUxZYftR7OCq88Q==(SB)
0x0018 00024 (main.go:3) MOVQ $0, main.~r0+16(SP)
0x0021 00033 (main.go:4) LEAQ type:int(SB), AX
0x0028 00040 (main.go:4) PCDATA $1, $0
0x0028 00040 (main.go:4) CALL runtime.newobject(SB)
0x002d 00045 (main.go:4) MOVQ AX, main.&x+32(SP)
0x0032 00050 (main.go:4) MOVQ $1, (AX)
0x0039 00057 (main.go:5) LEAQ type:noalg.struct { F uintptr; main.x *int }(SB), AX
0x0040 00064 (main.go:5) PCDATA $1, $1
0x0040 00064 (main.go:5) CALL runtime.newobject(SB)
0x0045 00069 (main.go:5) MOVQ AX, main..autotmp_2+24(SP)
0x004a 00074 (main.go:5) LEAQ main.add.func1(SB), CX
0x0051 00081 (main.go:5) MOVQ CX, (AX)
0x0054 00084 (main.go:5) MOVQ main..autotmp_2+24(SP), DI
0x0059 00089 (main.go:5) TESTB AL, (DI)
0x005b 00091 (main.go:5) MOVQ main.&x+32(SP), CX
0x0060 00096 (main.go:5) LEAQ 8(DI), DX
0x0064 00100 (main.go:5) PCDATA $0, $-2
0x0064 00100 (main.go:5) CMPL runtime.writeBarrier(SB), $0
0x006b 00107 (main.go:5) JEQ 111
0x006d 00109 (main.go:5) JMP 117
0x006f 00111 (main.go:5) MOVQ CX, 8(DI)
0x0073 00115 (main.go:5) JMP 127
0x0075 00117 (main.go:5) MOVQ DX, DI
0x0078 00120 (main.go:5) CALL runtime.gcWriteBarrierCX(SB)
0x007d 00125 (main.go:5) JMP 127
0x007f 00127 (main.go:5) PCDATA $0, $-1
0x007f 00127 (main.go:5) MOVQ main..autotmp_2+24(SP), AX
0x0084 00132 (main.go:5) MOVQ AX, main.~r0+16(SP)
0x0089 00137 (main.go:5) MOVQ 40(SP), BP
0x008e 00142 (main.go:5) ADDQ $48, SP
0x0092 00146 (main.go:5) RET
0x0093 00147 (main.go:5) NOP
0x0093 00147 (main.go:3) PCDATA $1, $-1
0x0093 00147 (main.go:3) PCDATA $0, $-2
0x0093 00147 (main.go:3) CALL runtime.morestack_noctxt(SB)
0x0098 00152 (main.go:3) PCDATA $0, $-1
0x0098 00152 (main.go:3) JMP 0
0x0000 49 3b 66 10 0f 86 89 00 00 00 48 83 ec 30 48 89 I;f.......H..0H.
0x0010 6c 24 28 48 8d 6c 24 28 48 c7 44 24 10 00 00 00 l$(H.l$(H.D$....
0x0020 00 48 8d 05 00 00 00 00 e8 00 00 00 00 48 89 44 .H...........H.D
0x0030 24 20 48 c7 00 01 00 00 00 48 8d 05 00 00 00 00 $ H......H......
0x0040 e8 00 00 00 00 48 89 44 24 18 48 8d 0d 00 00 00 .....H.D$.H.....
0x0050 00 48 89 08 48 8b 7c 24 18 84 07 48 8b 4c 24 20 .H..H.|$...H.L$
0x0060 48 8d 57 08 83 3d 00 00 00 00 00 74 02 eb 06 48 H.W..=.....t...H
0x0070 89 4f 08 eb 0a 48 89 d7 e8 00 00 00 00 eb 00 48 .O...H.........H
0x0080 8b 44 24 18 48 89 44 24 10 48 8b 6c 24 28 48 83 .D$.H.D$.H.l$(H.
0x0090 c4 30 c3 e8 00 00 00 00 e9 63 ff ff ff .0.......c...
rel 36+4 t=14 type:int+0
rel 41+4 t=7 runtime.newobject+0
rel 60+4 t=14 type:noalg.struct { F uintptr; main.x *int }+0
rel 65+4 t=7 runtime.newobject+0
rel 77+4 t=14 main.add.func1+0
rel 102+4 t=14 runtime.writeBarrier+-1
rel 121+4 t=7 runtime.gcWriteBarrierCX+0
rel 148+4 t=7 runtime.morestack_noctxt+0汇编代码中有这样一行:LEAQ type:noalg.struct { F uintptr; main.x *int }(SB), AX 这里明确定义了闭包的结构体指针:struct {
F uintptr // 函数指针
main.x *int // 引用环境 x 变量
}main 函数中给 f1、f2 变量进行赋值,分别执行了 CALL main.add(SB)语句,两次调用了 add 函数;在 add 函数汇编语句中, CALL runtime.newobject(SB) 语句的执行将引用环境和函数指针都分配到了堆上,为 f1 和 f2 都分配了闭包结构体指针,f1 变量指向 main 函数栈地址 main.f1+8(SP;f2 指向 main 函数栈地址 main.f2(SP)。当 main 函数中两次调用 add 函数时,会产生两个闭包的结构体指针,拥有共同的函数入口,但引用环境互相隔离,因此 f1 和 f2 执行互不影响。0x0014 00020 (main.go:12) PCDATA $1, $0
0x0014 00020 (main.go:12) CALL main.add(SB)
0x0019 00025 (main.go:12) MOVQ AX, main.f1+8(SP)
0x001e 00030 (main.go:13) PCDATA $1, $1
0x001e 00030 (main.go:13) NOP
0x0020 00032 (main.go:13) CALL main.add(SB)
0x0025 00037 (main.go:13) MOVQ AX, main.f2(SP)
0x0029 00041 (main.go:14) MOVQ main.f1+8(SP), DX
0x002e 00046 (main.go:14) MOVQ (DX), AX
0x0031 00049 (main.go:14) PCDATA $1, $2
0x0031 00049 (main.go:14) CALL AX
0x0033 00051 (main.go:15) MOVQ main.f2(SP), DX
0x0037 00055 (main.go:15) MOVQ (DX), AX
0x003a 00058 (main.go:15) PCDATA $1, $0
0x003a 00058 (main.go:15) CALL AX自由变量捕捉时对于值的处理可以是值拷贝,也可以是名称引用,这个也是由 Go 编译器进行优化的,还是刚刚的例子,去掉 x++ 这一行代码,我们再执行一下汇编对比一下:func add() func() int {
x := 1
return func() int {
return x
}
}
通过对x := 1这一行汇编语句的前后对比,发现 x 变量没有被分配到堆上,而是分配到了栈上。// 之前
LEAQ type:int(SB), AX
PCDATA $1, $0
CALL runtime.newobject(SB)
MOVQ AX, main.&x+32(SP)
MOVQ $1, (AX)
// 之后
MOVQ $1, main.x+16(SP)同时闭包结构体中 x 自由变量前后也发生了变化:// 之前
struct {
F uintptr // 函数指针
main.x *int // 引用环境 x 变量
}
// 之后
struct {
F uintptr
main.x int
}2.2 自由变量的捕获通过对比闭包结构体 x 自由变量的变化,可以发现当闭包中改变自由变量时(x++),自由变量捕获方式为名称引用,而相对的则为值拷贝。识别出变量需要在堆上分配,是由编译器的一种叫 escape analyze 的技术实现的,中文也称之为“逃逸分析”,我们用逃逸分析再验证一下自由变量的捕获方式。再次利用上边的两种代码分别执行:go build -gcflags '-m -l' main.go,得到两种结果,接下来对这两种结果进行一下分析和讲解。2.2.1 自由变量名称引用通过逃逸分析,我们不难发现:当函数中改变了自由变量 x(例如 x++),x 发生了逃逸,变量不在栈上分配,而是逃逸到了堆上 moved to heap: x。此时,自由变量的捕获变为了名称引用:main.x *int,闭包结构体中自由变量引用了逃逸到堆上的 x 变量,以指针的形式存在。与此同时,我们发现函数字面量(闭包结构体)也发生了逃逸,被分配到了堆上,这是因为 add 函数返回的 func() int函数字面量是一个指针,指向了闭包结构体,add 函数内部无法判断其是否被外部引用,所以内存不能随 add 函数栈一起消亡。当修改代码如下,x 变量并不是在匿名函数中改变,而是在引用环境中发生改变,x 依然会发生逃逸,和在匿名函数中发生改变是一样的结果:func add() func() int {
x := 1
f := func() int {
return x
}
x++
return f
}2.2.2 自由变量值拷贝相反,当 x 在函数中不发生改变时,x 变量没有发生逃逸,只是简单的被分配到了 add 的函数栈里,随着函数栈的消亡而消亡,那 x 是如何被闭包引用的呢?此时,闭包结构体依然以指针返回的形式逃逸到了堆上,其中自由变量 x 变为值拷贝,而不是指向 x 的引用,x 随闭包结构体一起被分配到堆上存储,最终也能被闭包结构使用。自由变量的捕获方式是由 Go 编译器经过分析后决定的,不仅仅是因为函数中有没有对自由变量做更改而决定捕获方式,还存在一部分例外情况,当自由变量占用存储空间过大时,也会优化为名称引用,我们可以使用逃逸分析其捕获情况。3闭包的使用场景3.1 隔离数据使用闭包最主要的意义就是:缩小变量作用域,减少对全局变量的污染。换句话说就是隔离数据。举个例子,闭包用于计算函数调用次数:package main
import (
"fmt"
"time"
)
// 函数计数器
func counter(x int, f func()) func() int {
return func() int {
f()
x += 1
return x
}
}
func fn() {
time.Sleep(time.Second)
fmt.Println("exec fn")
}
func main() {
fc := counter(0, fn)
fc()
fc()
fc()
fmt.Println(fc())
}
// 执行结果
exec fn
exec fn
exec fn
exec fn
4通过这个例子可以看出,一旦使用了 counter 函数包装了要执行的 fn 函数,就为每一个 fn 函数建立了独立的计数变量,互不影响,且很难在外部对该变量进行改变,防止了变量污染和全局变量的维护工作。3.2 搭配 defer 使用闭包可以很好保存程序运行的状态,搭配 defer 关键字能让程序的逻辑更加清晰。下边是统计代码耗时的例子:func main() {
start := time.Now()
defer func() {
fmt.Println(time.Since(start)) // 1.005156635s
}()
// 省略 100 行代码
...
time.Sleep(time.Second)
}3.3 用作装饰器高阶函数一般以其他函数作为参数传入或把其他函数作为结果返回,多用于逻辑的封装,而装饰器模式的实现离不开高阶函数。 举个例子,封装一个用于加减乘除运算的函数,两个整数和具体的操作都由该函数的调用方给出,并添加装饰器对参数进行校验。package main
import (
"errors"
"fmt"
)
type operate func(x, y int) int
type calculateFunc func(x int, y int) (int, error)
func checkCalculator(op string, fn operate) calculateFunc {
return func(x int, y int) (int, error) {
switch op {
case "add":
case "sub":
case "multi":
case "divide":
if y == 0 {
return 0, errors.New("invalid y")
}
default:
return 0, errors.New("invalid operation")
}
if fn == nil {
return 0, errors.New("invalid operation")
}
return fn(x, y), nil
}
}
func main() {
addop := func(x, y int) int {
return x + y
}
add := checkCalculator("add", addop) // 加法
result, err := add(1, 2) // 3
fmt.Println(result, err)
subop := func(x, y int) int {
return x - y
}
sub := checkCalculator("sub", subop) // 减法
result, err = sub(6, 3) // 3
fmt.Println(result, err)
multiop := func(x, y int) int {
return x * y
}
multi := checkCalculator("multi", multiop) // 乘法
result, err = multi(1, 3) // 3
fmt.Println(result, err)
divideop := func(x, y int) int {
return x / y
}
divide := checkCalculator("divide", divideop) // 乘法
result, err = divide(6, 2) // 3
result, err = divide(6, 0) // 0 invalid y
fmt.Println(result, err)
}4.闭包常见的坑4.1 常见坑案例一:func main() {
a := []int{1, 2, 3, 4}
fns := make([]func(), 0)
for _, v := range a {
fns = append(fns, func() {
fmt.Println(v)
})
}
for _, f := range fns {
f()
}
}
// 输出结果
0xc0000240a0
0xc0000240a0
0xc0000240a0
0xc0000240a0
4
4
4
4讲解:首先 for range 中 v 是循环可复用变量,变量地址都是不变的 0xc0000240a0;其次闭包中的自由变量 v 在函数中会被修改,最终修改为 4,该自由变量是会发生逃逸的,自由变量捕捉为名称引用,所以闭包中变量会随 v 一起变化,最终全部为 4。这里可以用逃逸分析验证一下:案例二:package main
import "fmt"
func main() {
a := []int{1, 2, 3, 4}
for _, v := range a {
go func() {
fmt.Println(v)
}()
}
select {}
}
// 输出结果
4
4
4
4案例二和案例一同样是自由变量 v 发生了逃逸导致的名称引用,协程中 v 随 for range 变化而改变;这个案例也是常见的,和案例一略有不同;案例一明确知道 v 变为了 4 之后执行的,所以结果必然是 4;而案例二是协程执行的,只是因为 for range 太快了,导致 v 变化太快,而协程调度执行需要时间,闭包执行的时候 v = 4 的概率最大,所以结果全为 4。我们加入一行,做一个小实验来验证一下:func main() {
a := []int{1, 2, 3, 4}
for _, v := range a {
go func() {
fmt.Println(v)
}()
time.Sleep(time.Second)
}
select {}
}
// 输出结果
1
2
3
44.2 解决方案一般解决方案有两种一种是给 for range 中添加新的变量指向 v一种是通过给匿名函数传递参数的方式方案一:给 for range 中添加新的变量指向 vfunc main() {
a := []int{1, 2, 3, 4}
fns := make([]func(), 0)
for _, v := range a {
vt := v
fmt.Println(&vt)
fns = append(fns, func() {
fmt.Println(vt)
})
}
for _, f := range fns {
f()
}
}
// 输出结果
0xc00018e008
0xc00018e020
0xc00018e028
0xc00018e030
1
2
3
4由于每次 vt 都是新局部变量,且并未发生改变,因此匿名函数中引用的环境没有发生变化。方案二:通过给匿名函数传递参数的方式func main() {
a := []int{1, 2, 3, 4}
for _, v := range a {
go func(vt int) {
fmt.Println(vt)
}(v)
}
select {}
}
// 输出结果
2
4
1
3通过值拷贝的方式传递到匿名函数,成为匿名函数的新变量,不受外部环境 v 的影响。小结本篇文章最关键的点在于理解:闭包 = 函数指针 + 引用环境,而不同闭包间引用环境是互相隔离的;闭包的引用环境中存在名称引用的自由变量,使用起来得格外小心,虽然 Go 源码中闭包很常见,也可以利用闭包完成很多高阶函数编程,但闭包还是要慎用,误用有可能导致内存泄漏。
cathoy
17. Go调度器系列解读(四):GMP 调度策略
前言继续分享 Go 调度器系列文章第四篇:GMP 模型调度策略。沿着思路,我们已经聊过:什么是 GMP 、 GMP 如何启动调度、GMP 的调度时机,本篇文章将是 GMP 系列的最后一篇文章,我们来聊一聊 GMP 的调度策略,了解一下是什么样的调度策略,能够为 Go 程序提供如此快的并发性能!在本篇文章中,你可以了解到以下内容:GMP 的整体调度策略流程G、M 锁定机制是什么?调度器如何尽全力寻找一个可执行的 G:GC工作、可执行队列、网络轮询、stealWork 窃取 G 的策略P 本地队列的获取和窃取并发操作,如何实现无锁化?没有可执行的 G 时,是直接退出 M,还是直接休眠 M,还是有其他操作呢?本文专业术语解释:G(Goroutine):Goroutine 是 Go 语言中的轻量级线程,由 Go 运行时管理。G 中存放并发执行的代码入口地址、上下文、运行环境(关联的 P 和 M)、运行栈等执行相关的信息。G 的新建、休眠、恢复、停止都受到 Go 运行时的管理。M(Machine):M 代表操作系统层面的线程,是真正执行计算资源的实体。M 仅负责执行,M 启动时进入运行时的管理代码,这段管理代码必须拿到 P 后,才能执行调度。P(Processor):P 代表处理器资源,是一种抽象的管理数据结构,主要作用是降低 M 对 G 的复杂性,增加一个间接的控制层数据结构。P 控制 Go 代码的并行度,它不是实体。P 持有 G 的队列,P 可以隔离调度,解除 P 和 M 的绑定,就解除了 M 对一串 G 的调用。GMP 模型的设计思想在于将 G(goroutine)与 M(machine)和 P(processor)结合使用,以实现高效的并发执行和资源管理。g.lockedm 锁定机制:一个 G 可以锁定在某个 M 上执行,M 在该 G 执行完成之前,不会允许其他 G 在该 M 执行;gp.lockedm 的设计意义在于提供了一种机制,使得调度器能够跟踪哪些 G 被锁定在哪些 M上,以便在调度时做出相应的决策。调度器可以根据这个信息来决定是否将一个 Goroutine 调度到已经被锁定的机器上,或者将其调度到其他可用的机器上。(当一个 G 需要执行某个特定的系统调用或需要独占某个资源时,它可以被锁定到一个机器上,以确保在该 G 完成之前,其他 G 不会在该机器上运行。这样可以避免竞争条件和保证资源的正确使用。)m.lockedg 锁定机制:在 G 锁 M 的同时, M 也锁定了 G,通过使用 m.lockedg 字段,调度器可以更好地管理并发执行和资源分配,确保资源的正确使用和 G 的正确执行。当一个机器被锁定在某个 G 上时,调度器可以将其视为不可用状态,以便其他 G 可以获得别的 M 的执行机会。Go 调度器系列文章(阅读前面的文章,有助于理解本文细节内容):《13. 入门 go 语言汇编,看懂 GMP 源码》《14. Go调度器系列解读(一):什么是 GMP?》《15. Go调度器系列解读(二):Go 程序启动都干了些什么?》《16. Go调度器系列解读(三):GMP 模型调度时机》源码解读环境:Go 版本 1.20.7、linux 系统1.调度启动函数 schedule通过对 GMP 系列文章的学习,我们知道调度循环是从 schedule 函数开始的,今天我们就从这个函数入手,详细分析一下 GMP 的调度策略。我们先阅读一下源码,随后画个流程图详细分析!源码:runtime/proc.go 3349// One round of scheduler: find a runnable goroutine and execute it.
// Never returns.
func schedule() {
mp := getg().m
...
// 如果 M 锁定了执行的 G
if mp.lockedg != 0 {
// 停止执行锁定到 g 的当前 m,直到 g 再次可运行。m 被阻塞在 m.park
stoplockedm()
// m 被唤醒,运行锁定的 g
execute(mp.lockedg.ptr(), false) // Never returns.
}
...
top:
pp := mp.p.ptr()
pp.preempt = false
...
// 获取一个可运行的 G,可能会阻塞直到有可运行的任务
gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available
// 这个线程将运行一个 goroutine 并且不再旋转,
// 因此,如果它被标记为旋转,我们现在需要重置它,并可能启动一个新的旋转 M。
if mp.spinning {
resetspinning()
}
// 处理被禁止调度的 G
...
// 如果要调度一个非正常的 goroutine(GCworker 或 Tracereader),则唤醒 P(如果有)。
if tryWakeP {
wakep()
}
// 如果 G 锁定了执行的 M'
if gp.lockedm != 0 {
// 解除 G 所在的 P 和当前 M 的关系
// 由 M' 接管 P,唤醒 M‘
// 阻塞 M 进入睡眠
startlockedm(gp)
// M 被重新唤醒,回到 top 开启新的一次调度循环
goto top
}
// 执行 gp
execute(gp, inheritTime)
}schedule 函数逻辑也比较简单,我们总结一下:获取 M,检查 M 是否锁定 G 执行,如果 M 锁定了 G(mp.lockedg != 0),M 必须阻塞等待,直到该 G 可执行时被其他 M 唤醒, stoplockedm 函数细节后续分析。设置 top 标签,后续可以跳转到 top 循环执行;获取 P,可抢占标志初始化为 false。findRunnable 获取一个可运行的 G,如果没有可运行的 G,会阻塞 M 直到有可运行的任务,短时间内阻塞的 M 不会被运行时所销毁,提升 M 的可重复使用率,减少线程创建和销毁的开销。M 找到一个可运行的 G 后,如果在 findRunnable 中被设置为自旋状态,则重置为非自旋,此时可能会通过 wakep 函数尝试唤醒一个 P,这些我们在之前的文章都详细聊到过,这里不再赘述!如果 findRunnable 函数找的 G 不是一个普通的 G,比如 GC 的标记任务,此时会通过 wakep 函数尝试唤醒一个 P,充分利用系统并行的能力。当 findRunnable 函数选出的 G 锁定了具体的 M 才能执行,那就尝试通过 startlockedm 函数唤醒被 G 锁定的 M;自己则进入阻塞状态,等待被其他 M 唤醒,被重新唤醒后,执行 goto top,回到调度的开始,继续调度。正常通过以上条件后,findRunnable 函数选出的 G 会进入执行状态,execute 负责执行 G,设置 G 的状态为 _Grunning,execute 函数具体细节看后文分享。前面文章我们也提到过调度循环的执行流程,大家可以先去复习一下 《14. Go调度器系列解读(一):什么是 GMP?》。stoplockedm 和 startlockedm 函数比较简单,这里带着大家过一下:stoplockedm 源码:runtime/proc.go 2564// 停止执行锁定到g的当前m,直到g可以再次运行为止
// 返回获取的P
func stoplockedm() {
gp := getg()
...
if gp.m.p != 0 {
// Schedule another M to run this p.
pp := releasep() // 释放 P 到空闲状态,解除 P 和 M 之间的关系
handoffp(pp) // 寻找一个 M 接管 P
}
// 增加被锁定的空闲 M 数量
incidlelocked(1)
// Wait until another thread schedules lockedg again.
// M 通过 notesleep 阻塞在 m.park 字段
mPark()
...
acquirep(gp.m.nextp.ptr())
gp.m.nextp = 0
}stoplockedm 函数主要逻辑总结:能进入 schedule 函数,说明此时 P 一定是存在的,会执行 releasep 解除 M、P 的绑定关系,通过 handoffp 函数寻找一个 M 接管 P 的执行;handoffp 源码分享可参考文章 《16. Go调度器系列解读(三):GMP 模型调度时机》。调用 mPark -> notesleep 函数,阻塞 M 到 m.park 字段,等待 G 可执行时,被其他 M 唤醒(M 的阻塞和唤醒在 linux 系统下使用的是 futex 系统调用实现的,实现源码可参考文章 《14. Go调度器系列解读(一):什么是 GMP?》)。M 被唤醒前,P 会被设置到 m.nextp 字段,所以 M 被唤醒后,直接使用 acquirep(gp.m.nextp.ptr()) 即可完成 M、P 的绑定。startlockedm 源码:runtime/proc.go 2594// 调度锁定的 M 执行锁定的 G
func startlockedm(gp *g) {
mp := gp.lockedm.ptr() // 获取G 锁定的 M'
...
// directly handoff current P to the locked m
incidlelocked(-1) // 锁定的空闲 M 数量 -1
pp := releasep() // 解除 G 所在的 P 和当前 M 的关系
// 由 M' 接管 P,需要提前绑定到 m.nextp,后续 M' 被唤醒
// 可以直接使用 m.nextp 绑定 P,然后执行 G
mp.nextp.set(pp)
notewakeup(&mp.park) // 唤醒 M‘
stopm() // 阻塞 M 进入睡眠
}startlockedm 函数主要逻辑总结:获取 G 锁定的 M';解除 G 所在的 P 和当前 M 的关系; 将 P 设置到 M' 的 m.nextp 字段中;从 M' 的 m.park 字段中,唤醒 M’;M 放入空闲列表,阻塞 M,睡眠到 m.park 上。阻塞和唤醒 M 的代码逻辑稍微有点割裂,看起来有点费劲,这里举例一个具体的场景,并画了流程图进行分析:这幅图看着有点复杂,但是逻辑是很简单的,为了照顾新来的朋友,我们这里简单解释一下:这个流程图涉及到三个 M 的启动:M0、M1、M2;假设这些都是普通的 M,首先 M0 和 M1 随着我们业务代码不断创建 G 而被启动起来,并开始运行调度;假设 M0 锁定了 G1,意味着 G1 只能在 M0 上执行,M0 在 G1 执行完成之前,不会再执行别的 G。此时 M0 启动,调用 schedule 函数启动调度循环,因为 M0 锁定了 G1,所以需要调用 stoplockedm -> mPark -> notesleep 函数,阻塞 M0 到 m.park 字段,等待 G1 可执行时,被其他 M 唤醒(M 的阻塞和唤醒在 linux 系统下使用的是 futex 系统调用实现的,实现源码可参考文章 《14. Go调度器系列解读(一):什么是 GMP?》)。阻塞 M0 前,先通过 releasep 函数解除了 M、P 的绑定关系,然后通过 handoffp 让其他 M 接管了 P,handoffp -> startm 会有两个选择,优先唤醒空闲的 M,否则新创建一个 M 接管 P,保障其他 G 可以由其他 M 继续调度执行(具体过程可参考 《14. Go调度器系列解读(一):什么是 GMP?》M 的启动,handoffp 源码分享可参考文章 《16. Go调度器系列解读(三):GMP 模型调度时机》 )。M1 启动,也调用 schedule 函数,假设顺利走到了 findRunnable 函数(根据调度策略获取到一个可执行的 G),假设恰好是 G1,由于 G1 锁定了 M0,则需要通过 startlockedm -> notewakeup 函数,唤醒被阻塞的 M0,而自己 M1 通过 stopm -> mPark -> notesleep 进入阻塞状态。在 M1 唤醒 M0 之前,首先通过 releasep 函数解除了 M1 与 P 的绑定关系,并设置 P 到 m.nextp 字段,等 M0 唤醒后,可以直接使用 m.nextp 绑定 P,进而直接执行 G1。当 M0 被唤醒后,从步骤 2 中 mPark 函数的下一行开始继续执行代码,通过 acquirep(gp.m.nextp.ptr()) 绑定了 M1 唤醒 M0 前所解绑的 P,此时要执行的 G 已经确定是 G1 了,所以 M0 继续调用 execute(mp.lockedg.ptr(), false),直接执行了 G1,如上图所示。当我们聊清楚被锁定的 M、G 的调度策略以后,后续就属于正常的调度了,从图中可以看出,红色步骤为调度执行的主流程图 schedule -> findRunnable -> execute -> gogo -> mcall -> schedule,其中最重要的就是 findRunnable 函数代表的调度策略了,接下来我们就移步进入 findRunnable 函数。2.寻找可用的 GfindRunnable 总共 278 行代码,是调度器中的一个核心函数,它的主要任务是从各种队列中找到一个可以执行的 Goroutine,主要逻辑分为三部分:GC 内存回收的处理;尽全力寻找一个可执行的 G;没有 G 可执行时,选择性处理 M,或给 GC 帮忙、或阻塞在网络轮询,或彻底放弃 CPU 执行权,睡眠在 m.park 上。由于这块代码过于复杂,只能分块去讲,这里给出整体的流程图,帮助大家从整体上了解一下调度策略:2.1 GC-STW 事件的处理本文 GC 不是重点,但 GMP 调度器组件和 GC 垃圾回收组件经常会交叉执行,因此简单了解一下即可!源码:runtime/proc.go 2686// 寻找一个可运行的 G 去执行 execute
// inheritTime 是否需要继承上一个 G 的调度周期
// tryWakeP 表示如果返回的不是一个普通的 G,需要尝试去唤醒 P(比如 GC 的工作 G)
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
mp := getg().m
top:
pp := mp.p.ptr()
// 要执行 GC STW
if sched.gcwaiting.Load() {
// 暂停现在的 M 为了 stopTheWorld = STW
// 当 world 重新启动,恢复 M 的运行
gcstopm()
goto top
}
...
}在 Go 语言的运行时(runtime)中,sched.gcwaiting 是一个标志,用于表示当前是否有垃圾回收(GC)的“stop-the-world”(STW)事件正在等待发生或正在进行中。当这个标志被设置时,意味着运行时需要暂停所有的用户 Goroutines 以执行 GC 的某个阶段。具体来说,GC 的某些阶段(如标记或清理)需要确保没有用户 Goroutines 同时访问堆上的对象,因为这可能会导致不一致的状态。为了实现这一点,Go 运行时会在这些关键阶段暂停所有用户 Goroutines,这个过程被称为“stop-the-world”。在这个标志被设置期间,调度器会尝试确保所有的 M 都响应 GC 的暂停请求。一旦所有的 M 都已经暂停,GC 就可以安全地执行其需要的工作。当 GC 完成该阶段后,它会允许 M 重新开始执行用户 Goroutines,并清除 sched.gcwaiting 标志。简要分析这段代码的逻辑:mp := getg().m:获取当前线程(M)的信息。top::这是一个标签,用于后面的 goto 语句跳转回这个点。pp := mp.p.ptr():获取当前 M 关联的处理器(P)的指针。if sched.gcwaiting.Load() { ... }:这个条件判断检查全局调度器状态 sched 中的 gcwaiting 标志。如果这个标志被设置,意味着 GC 需要执行一个 STW 暂停。gcstopm():这个函数调用会暂停当前的 M,直到 STW 阶段结束。在 STW 期间,所有的 Goroutine 都会被暂停,以便 GC 可以安全地执行其工作。goto top:一旦 gcstopm() 返回,这个 goto 语句会使执行跳回到 top 标签,重新检查调度状态。这是因为在 STW 结束后,调度器的状态可能已经发生了变化,需要重新评估。gcstopm 源码:runtime/proc.go 2610// 暂停现在的 M 为了 stopTheWorld = STW
// 当 world 重新启动,恢复 M 的运行
func gcstopm() {
gp := getg()
...
if gp.m.spinning {
// 如果 M 在自旋状态,设置为非自旋
gp.m.spinning = false
// OK to just drop nmspinning here,
// startTheWorld will unpark threads as necessary.
if sched.nmspinning.Add(-1) < 0 {
throw("gcstopm: negative nmspinning")
}
}
pp := releasep() // 解除 m 和 p 的关系,p 重置为 _Pidle 状态
lock(&sched.lock)
pp.status = _Pgcstop // 设置 P 状态为 _Pgcstop
sched.stopwait--
// sched.stopwait 初始值为 gomaxprocs
// 当 sched.stopwait == 0 表示 P 都被设置为 _Pgcstop
if sched.stopwait == 0 {
// 唤醒待执行的任务(比如垃圾回收)
notewakeup(&sched.stopnote)
}
unlock(&sched.lock)
stopm() // 停止当前 m 的执行,直到有新的工作可用
}gcstopm 函数主要负责停止所有 M 的执行,从而可以唤醒 GC 释放内存,该段代码逻辑清晰,看注释应该可以看懂,这里偷个懒不解释了!2.2 调度策略:寻找一个可执行的 GfindRunnable 函数的主要任务是从各种队列中找到一个可以执行的 Goroutine,包括 GC worker、G 的可运行队列、网络轮询、通过 stealWork 窃取其他 P 的 G。2.2.1 寻找 GC 工作func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
...
// Try to schedule a GC worker.
if gcBlackenEnabled != 0 {
gp, tnow := gcController.findRunnableGCWorker(pp, now)
if gp != nil {
return gp, false, true
}
now = tnow
}
...
}调度器在寻找可运行的 Goroutine 时会优先考虑 GC 的工作:if gcBlackenEnabled != 0 { ... }:这个条件判断表示,如果当前允许进行 GC 的“标记(blacken)”阶段,那么就尝试找一个 GC 工作 G 来运行。gcController.findRunnableGCWorker(pp, now):这个函数调用尝试在 gcBgMarkWorkerPool 标记工作的工人池子里 pop 一个 worker 出来,然后由 M 调度执行,该 G 是可以和其他 G 并发执行的,如果没有 worker,说明 GC 已经有足够多的 M 去执行了。这样做的目的是确保 GC 工作能够及时得到执行,从而保持内存的使用在一个可控的范围内。在 GC 期间,尤其是在标记阶段,运行时需要确保有足够的线程来处理 GC 任务,以避免 GC 延迟过长,从而影响程序的性能。2.2.2 从可运行队列寻找 GG 可运行队列分为两种:全局可运行队列、P 本地可运行队列;接下来的源码将介绍如何从可运行队列获取 G:func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
...
// 每隔一段时间检查一次全局可运行队列以确保公平性。
// 否则,两个 Goroutine 可以通过不断地互相重生来完全占用本地运行队列。
// 每隔 61 个调度时钟周期,尝试从全局运行队列中获取一个 G
if pp.schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp := globrunqget(pp, 1)
unlock(&sched.lock)
if gp != nil {
return gp, false, false
}
}
...
// 尝试从本地运行队列中获取一个可运行的 G
if gp, inheritTime := runqget(pp); gp != nil {
return gp, inheritTime, false
}
// 如果全局运行队列非空,则尝试从全局运行队列中获取 G
if sched.runqsize != 0 {
lock(&sched.lock)
gp := globrunqget(pp, 0)
unlock(&sched.lock)
if gp != nil {
return gp, false, false
}
}
...
}主要逻辑梳理:每隔 61 个调度时钟周期检查全局运行队列:这段代码是为了确保公平性而存在的。pp.schedtick 是当前处理器(P)的调度时钟周期计数器。每隔 61 个周期,如果全局运行队列(sched.runq)非空,调度器会尝试从全局队列中获取一个 Goroutine 来执行。这样做是为了防止本地运行队列被少数几个 Goroutine 长期占用,从而导致其他 Goroutine 得不到执行机会。从本地运行队列中获取 Goroutine:在尝试从全局运行队列获取 Goroutine 之前,调度器会先检查当前处理器(P)的本地运行队列(pp.runq)。如果本地队列中有可运行的 Goroutine,则优先执行它们。再次检查全局运行队列:如果在本地运行队列中没有找到可运行的 Goroutine,并且全局运行队列非空,调度器会再次尝试从全局队列中获取 Goroutine。注意这里和第一步的区别在于,这一步不是周期性执行的,而是在本地队列为空时才会执行。这些步骤共同构成了 Go 调度器在查找可运行 Goroutine 时的基本策略,即优先考虑本地运行队列,同时确保全局运行队列中的 Goroutine 也能得到公平的执行机会。通过这种方式,Go 运行时能够在多核处理器上高效地调度和执行大量的并发 Goroutines。globrunqget 源码// Try get a batch of G's from the global runnable queue.
// sched.lock must be held.
func globrunqget(pp *p, max int32) *g {
assertLockHeld(&sched.lock) // 断言锁已持有
// 如果全局运行队列的大小为 0,则直接返回 nil
if sched.runqsize == 0 {
return nil
}
// 计算要获取的 Goroutine 数量
n := sched.runqsize/gomaxprocs + 1
if n > sched.runqsize {
n = sched.runqsize
}
if max > 0 && n > max {
// 不会超过传入的 max 参数
n = max
}
if n > int32(len(pp.runq))/2 {
// 不会超过当前处理器(P)的本地运行队列长度的一半
n = int32(len(pp.runq)) / 2
}
sched.runqsize -= n // 更新全局运行队列的大小
gp := sched.runq.pop() //从全局运行队列中获取一个 Goroutine
n--
for ; n > 0; n-- {
// 从全局运行队列中获取剩余的 Goroutines
gp1 := sched.runq.pop()
// 放入当前处理器(P)的本地运行队列中
runqput(pp, gp1, false)
}
return gp
}globrunqget 函数用于从全局运行队列(sched.runq)中获取一批 Goroutines(G)以供执行:检查全局锁 sched.lock;如果全局运行队列的大小为 0,则直接返回 nil,表示没有可获取的 Goroutine。计算要获取的 Goroutine 数量:这里首先计算一个理想的获取数量 n,它是全局运行队列大小除以 gomaxprocs(即最大处理器数)再加 1(负载均衡)。然后,通过一系列的条件判断来调整 n 的值,确保它不会超出全局运行队列的大小、不会超过传入的 max 参数(如果提供了的话),并且不会超过当前处理器(P)的本地运行队列长度的一半。在从全局运行队列中移除 Goroutines 之前,先更新全局运行队列的大小。随后获取 G,首先通过调用 pop 方法从全局运行队列中获取一个 Goroutine,并将其赋值给 gp。然后,通过循环继续从全局运行队列中获取剩余的 Goroutines,每次获取一个,并通过 runqput 方法将它们放入当前处理器(P)的本地运行队列中。最后,函数返回第一个从全局运行队列中获取的 Goroutine(gp)。其他获取的 Goroutines 已经被放入了本地运行队列中,供后续调度使用。runqget 源码func runqget(pp *p) (gp *g, inheritTime bool) {
// 检查 runnext
next := pp.runnext
if next != 0 && pp.runnext.cas(next, 0) {
return next.ptr(), true
}
for {
h := atomic.LoadAcq(&pp.runqhead) // load-acquire, synchronize with other consumers
t := pp.runqtail
if t == h {
return nil, false
}
gp := pp.runq[h%uint32(len(pp.runq))].ptr()
if atomic.CasRel(&pp.runqhead, h, h+1) { // cas-release, commits consume
return gp, false
}
}
}runqget 函数用于从当前处理器(P)的本地运行队列(pp.runq)中获取一个 Goroutine(G)以供执行:检查 runnext:pp.runnext 是一个特殊的字段,用于指示下一个要运行的 Goroutine。如果 runnext 非零,并且能够通过 CAS(Compare-And-Swap)操作将其成功设置为 0,那么说明这个 Goroutine 还没有被其他处理器偷走,可以安全地返回它并执行。此时,inheritTime 为 true,意味着这个 Goroutine 应该继承当前时间片剩余的时间。循环获取本地运行队列中的 Goroutine:如果 runnext 为空或者已经被其他处理器偷走,那么就会进入这个循环来从本地运行队列中获取 Goroutine。循环中的逻辑如下:使用 atomic.LoadAcq 原子地加载 pp.runqhead 的值,这是队列的头部索引,用于指示下一个要执行的 Goroutine 的位置。这个加载操作带有获取内存屏障(acquire barrier),用于同步其他消费者对该队列的访问(如果有窃取者,可能会有并发问题,而 pp.runqtail 更改由一个线程执行,不会存在并发问题)。检查队列的头部索引 h 是否等于尾部索引 t。如果相等,说明队列为空,函数返回 nil 和 false,表示没有获取到 Goroutine。如果队列不为空,计算要获取的 Goroutine 在队列中的位置,并通过 pp.runq[h%uint32(len(pp.runq))].ptr() 获取到对应的 Goroutine 指针 gp(P 本地队列底层是一个由数组实现的循环列表)。使用 atomic.CasRel 尝试原子地将 pp.runqhead 的值从 h 增加到 h+1,表示已经消费了一个 Goroutine。这个 CAS 操作带有释放内存屏障(release barrier),用于确保在此之前的所有读/写操作都对其他处理器可见。如果 CAS 操作成功,返回获取到的 Goroutine 指针 gp 和 false,表示这个 Goroutine 不需要继承当前时间片剩余的时间,而是应该开始一个新的时间片。如果 CAS 操作失败,说明有其他处理器已经抢先更新了队列头部索引,需要重试循环。runqget 函数通过优先检查 runnext 字段,然后从本地运行队列中获取 Goroutine 的方式,实现了高效的 Goroutine 调度。这种方式可以减少不必要的竞争和锁开销,提高调度器的性能。随后使用了自旋获取操作,实现了无锁化,进而提升并发性能,具体无锁化的实现方式后续在窃取 G 小节进行分析。2.2.3 网络轮询Go 语言的网络轮询使用的是 epoll 多路复用网络 IO,可以参考文章 《4. IO 多路复用之 epoll 核心原理解析》。网络轮询是 Go 运行时用来检查是否有就绪的网络事件(如新的网络连接、可读/可写的网络套接字等)并执行相应的处理函数的机制。这对于实现高效的 I/O 并发尤为重要,因为它允许 Go 程序在等待网络事件时继续执行其他任务。func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
...
// 如果网络轮询已初始化,并且有等待的网络事件,并且上次轮询的时间不为零
if netpollinited() && netpollWaiters.Load() > 0 && sched.lastpoll.Load() != 0 {
// 尝试非阻塞获取准备就绪的网络事件列表
if list := netpoll(0); !list.empty() { // non-blocking
// 从列表中弹出一个 G,准备调度这个 G
gp := list.pop()
// 剩余的 G 加入可运行队列,等待调度
injectglist(&list)
casgstatus(gp, _Gwaiting, _Grunnable) // 修改 gp 状态为可运行
...
return gp, false, false
}
}
...
}代码逻辑如下:1.条件检查:首先检查是否满足以下三个条件:netpollinited():网络轮询是否已经初始化。netpollWaiters.Load() > 0:是否有 Goroutines 在等待网络事件。sched.lastpoll.Load() != 0:上次网络轮询的时间是否不为零,即是否发生过网络轮询。2.非阻塞网络轮询:如果满足上述条件,则调用 netpoll(0) 进行非阻塞的网络轮询。这里的参数 0 表示不阻塞等待网络事件,立即返回。3.处理就绪事件:如果 netpoll 返回的列表不为空,说明有就绪的网络事件。执行以下操作:从列表中弹出一个 Goroutine(gp := list.pop())。这个 Goroutine 之前因为等待网络事件而被阻塞。使用 injectglist(&list) 将列表中剩余的 Goroutines(如果有的话)加入全局或本地可运行队列,等待调度。通过 casgstatus(gp, _Gwaiting, _Grunnable) 将弹出的 Goroutine 的状态从等待(_Gwaiting)更改为可运行(_Grunnable)。最后,返回这个 Goroutine,并指示它不应该继承当前时间片剩余的时间(inheritTime = false),也不需要尝试唤醒其他处理器(tryWakeP = false)。如果网络轮询没有找到就绪的 Goroutine,或者网络轮询的条件不满足,findRunnable 函数会继续执行其他逻辑来尝试找到可运行的 Goroutine,下一个就是从其他处理器 P 窃取等。2.2.4 stealWork 窃取 GM 自旋是指在没有可运行的 Goroutine 时,M 会继续尝试从其他 P 窃取任务,而不是立即进入睡眠状态。这有助于减少线程唤醒和调度的开销,提高系统的响应性。接下来我们就一起来看看,如何从其他 P 窃取 G:func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
...
// 如果 M 处于自旋状态 || 将旋转的 M 数量限制为繁忙的 P 数量的一半
if mp.spinning || 2*sched.nmspinning.Load() < gomaxprocs-sched.npidle.Load() {
// 如果 M 不在自旋状态,则将其切换为自旋状态
if !mp.spinning {
mp.becomeSpinning()
}
// 尝试从其他 P 中窃取任务
gp, inheritTime, tnow, w, newWork := stealWork(now)
if gp != nil {
return gp, inheritTime, false
}
if newWork {
// 可能有定时器到期触发的 G 可执行或有 GC 工作;重启 find 即可发现。
goto top
}
now = tnow
if w != 0 && (pollUntil == 0 || w < pollUntil) {
// Earlier timer to wait for.
// 等待定时器触发,设置最早的定时器触发时间
pollUntil = w
}
}
...
}代码逻辑如下:自旋条件检查:首先检查是否满足自旋的条件。自旋的条件是 M 当前已经在自旋,或者正在自旋的 M 的数量少于繁忙的 P 数量的一半(这里的繁忙 P 是指那些既不是空闲也不是系统调用的 P)。进入自旋状态:如果 M 当前不在自旋状态,通过 mp.becomeSpinning() 将其切换为自旋状态。这通常涉及增加调度器中自旋 M 的计数(sched.nmspinning)。窃取工作:调用 stealWork 函数尝试从其他 P 中窃取一个可运行的 Goroutine。stealWork 函数的参数 now 通常是当前的时间,返回值包括可能窃取到的 Goroutine、是否应该继承时间片、当前时间(可能在窃取过程中被更新)、下一个定时器的等待时间以及是否有新工作产生的标志。处理窃取结果:这段代码通过自旋和窃取工作来减少 M 的空闲时间,提高处理器的利用率。当没有可运行的 Goroutine 时,M 会继续自旋一段时间,尝试从其他 P 窃取任务,而不是立即阻塞。这有助于减少线程调度的开销,提高系统的整体性能。stealWork 窃取 GstealWork 用于尝试从其他处理器(P)窃取可运行的 Goroutine(G),接下来我们详细聊一下代码细节:stealWork 源码:runtime/proc.go 3056func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool) {
pp := getg().m.p.ptr()
ranTimer := false // 标记是否有定时器被运行
const stealTries = 4 // 定义窃取尝试的次数
for i := 0; i < stealTries; i++ {
// 在最后一次循环时检查定时器或运行下一个 G。
stealTimersOrRunNextG := i == stealTries-1
// 遍历所有 P(使用 stealOrder 枚举)
for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
if sched.gcwaiting.Load() {
// 如果 GC 等待中,则可能有 GC 工作可做,返回以重启 findRunnable。
return nil, false, now, pollUntil, true
}
p2 := allp[enum.position()]
if pp == p2 {
continue // 跳过当前 P
}
// 最后一次窃取循环 && P 拥有计时器
if stealTimersOrRunNextG && timerpMask.read(enum.position()) {
// 检查定时器并运行到期的定时器
tnow, w, ran := checkTimers(p2, now)
now = tnow
if w != 0 && (pollUntil == 0 || w < pollUntil) {
pollUntil = w
}
// 有定时器运行,
if ran {
// P 本地可能有新 G(p2 的定时器到期执行,触发放入当前 P 队列)
if gp, inheritTime := runqget(pp); gp != nil {
return gp, inheritTime, now, pollUntil, ranTimer
}
ranTimer = true // ranTimer 会被设置为 true
}
}
// 如果 P 不空闲,尝试从其 runq 窃取 G
if !idlepMask.read(enum.position()) {
if gp := runqsteal(pp, p2, stealTimersOrRunNextG); gp != nil {
return gp, false, now, pollUntil, ranTimer
}
}
}
}
// No goroutines found to steal. Regardless, running a timer may have
// made some goroutine ready that we missed. Indicate the next timer to
// wait for.
return nil, false, now, pollUntil, ranTimer
}stealWork 函数是 Go 调度器中的一个重要部分,用于在多个 Processor(P)之间“窃取”工作,即寻找并尝试执行其他 Processor 上的可运行 Goroutines。这是 Go 调度器实现工作窃取算法的核心,有助于提高多核 CPU 的利用率和程序的总体性能。 参数介绍:now:当前时间,用于检查定时器是否到期。gp:窃取到的可运行的 Goroutine。inheritTime:是否应该继承时间片。rnow:更新后的当前时间。pollUntil:下一个要等待的定时器时间。newWork:是否有新工作产生。代码主要逻辑:pp := getg().m.p.ptr():获取当前 M 绑定的 P。ranTimer := false:标记是否有定时器被运行。const stealTries = 4:定义窃取尝试的次数。循环 stealTries 次尝试窃取工作:stealTimersOrRunNextG := i == stealTries-1:在最后一次循环时检查定时器或运行下一个 G。遍历所有 P(使用 stealOrder 枚举):如果 GC 等待中,则可能有 GC 工作可做,返回以重启 findRunnable。跳过当前 P。如果是最后一次循环且 P 有定时器,则检查定时器并运行到期的定时器。如果 P 不空闲,尝试从其 runq 窃取 G。P 有定时器运行,ranTimer 会被设置为 true,会尝试从 P 的本地队列获取可执行的 G。函数最后返回窃取结果。即使没有窃取到 G,也会更新 now 和 pollUntil,并指示是否有新工作产生(newWork)或定时器被运行(ranTimer)。在窃取过程中,函数会考虑 GC 工作和定时器到期的可能性。如果有 GC 工作需要处理或有定时器到期触发了新的 G,函数会提前返回以便调度器能够及时处理这些情况。窃取算法使用了一个枚举器 stealOrder 来决定遍历 P 的顺序,这有助于减少争用和提供更好的负载均衡。同时,通过检查 idlepMask 可以避免不必要的窃取尝试,提高效率。stealWork 函数通过窃取机制来分发工作,从而提高了系统的整体吞吐量和响应性。runqsteal 源码:runtime/proc.go 6214func runqsteal(pp, p2 *p, stealRunNextG bool) *g {
t := pp.runqtail // 尾部索引
// 窃取 Goroutines
n := runqgrab(p2, &pp.runq, t, stealRunNextG)
if n == 0 {
return nil
}
n--
// 计算窃取到的最后一个 Goroutine 在 pp 的可运行队列中的位置
// 获取 gp
gp := pp.runq[(t+n)%uint32(len(pp.runq))].ptr()
if n == 0 {
return gp
}
h := atomic.LoadAcq(&pp.runqhead) // load-acquire, synchronize with consumers
if t-h+n >= uint32(len(pp.runq)) {
throw("runqsteal: runq overflow")
}
// 更新 pp 的可运行队列的尾部索引
atomic.StoreRel(&pp.runqtail, t+n) // store-release, makes the item available for consumption
return gp
}runqsteal 函数用于从一个 Processor(p2)的本地可运行队列中窃取一半的 Goroutines,并将它们放到另一个 Processor(pp)的本地可运行队列中。这种窃取机制有助于在多个 Processor 之间平衡工作负载,从而提高多核 CPU 的利用率。参数和返回值:pp:目标 Processor,即窃取到的 Goroutines 将被放置的 Processor。p2:源 Processor,即 Goroutines 将被窃取的 Processor。stealRunNextG:一个布尔值,指示是否应该窃取 p2 的 runnext Goroutine(如果有的话)。返回一个窃取到的 Goroutine 的指针,如果没有窃取到任何 Goroutine,则返回 nil。主要逻辑:调用 runqgrab 函数来从源 Processor p2 的可运行队列中窃取 Goroutines,并将它们放入 pp 的可运行队列中。这个过程中,会考虑到尾部索引 t 和 stealRunNextG 参数。如果 runqgrab 返回的窃取到的 Goroutines 数量 n 为 0,表示没有窃取到任何 Goroutine,直接返回 nil。否则,计算窃取到的最后一个 Goroutine 在 pp 的可运行队列中的位置,并获取其指针 gp。如果只窃取到一个 Goroutine(即 n == 0),则直接返回该 Goroutine 的指针 gp。否则,加载 pp 的可运行队列的头部索引 h;检查队列是否溢出,即检查新的尾部索引是否超过了队列的容量。如果发生溢出,则抛出异常。更新 pp 的可运行队列的尾部索引,使其指向新的尾部位置,并使窃取到的 Goroutines 对消费者可用。返回窃取到的第一个 Goroutine 的指针 gp。这里有个点需要强调一下:runqsteal 函数中的操作涉及到处理器之间的数据竞争和同步问题,因此使用了原子操作来确保数据的一致性和顺序性。例如,atomic.LoadAcq 和 atomic.StoreRel 分别用于执行带获取语义的加载操作和带释放语义的存储操作,以确保在窃取 Goroutines 的过程中,PP 数据的一致性。runqgrab 窃取过程runqgrab 函数的作用是从运行队列中“抓取”一些 Goroutine,并放入一个批量处理队列中。这个函数主要用于负载均衡和并发控制。func runqgrab(pp *p, batch *[256]guintptr, batchHead uint32, stealRunNextG bool) uint32 {
for {
h := atomic.LoadAcq(&pp.runqhead) // load-acquire, synchronize with other consumers
t := atomic.LoadAcq(&pp.runqtail) // load-acquire, synchronize with the producer
n := t - h // 计算运行队列中的 Goroutine 数量
n = n - n/2 // 取一半 G,这是偷取策略
if n == 0 {
// 本地队列没有可偷取的 G
if stealRunNextG {
// Try to steal from pp.runnext.尝试偷取 pp.runnext
if next := pp.runnext; next != 0 {
...
if !pp.runnext.cas(next, 0) {
continue
}
// 获取到 next G,插入队列头部
batch[batchHead%uint32(len(batch))] = next
return 1
}
}
return 0
}
if n > uint32(len(pp.runq)/2) {
continue
}
for i := uint32(0); i < n; i++ {
// 窃取的 G 循环插入 batch 队列
g := pp.runq[(h+i)%uint32(len(pp.runq))]
batch[(batchHead+i)%uint32(len(batch))] = g
}
// 更新 pp 本地队列的头部指针,表示被窃取了 n 个
if atomic.CasRel(&pp.runqhead, h, h+n) { // cas-release, commits consume
return n
}
}
}参数:pp:源 Processor,即 Goroutines 将被窃取的 Processor。batch:目标 P 的本地运行队列 (目标 P 的 runq)。batchHead:窃取到的 G 开始插入的头部索引,目前指向目标 P 的尾部指针,表示从目标 P 的 尾部插入窃取到的 G。stealRunNextG:一个布尔值,表示是否应该窃取源 P.runnext 中的 G。主要逻辑:for 无限循环,不断尝试从 P 中抓取 Goroutine,直到抓取到 G,或 P 中没有 G 时停止循环。使用原子操作从运行队列的头部获取索引 h,从运行队列的尾部获取索引 t,并确保在此操作期间没有其他处理器可以修改这个值。计算运行队列中的 Goroutine 数量,从 P 中偷取一半 G;如果 n = 0,表示没有 G 可以偷,此时根据 stealRunNextG 字段尝试从 p.runnext 中偷取,偷到则返回 1;否则返回 0,表示没有偷到。当 n > 0 时,循环将 G 窃取到目标 P 的本地队列,使用原子操作 atomic.CasRel 更新 pp 本地队列的头部指针到 h + n,表示被窃取 n 个 G,当源 P 头指针更新成功时,才表示 G 被窃取成功,否则窃取失败(源 P 自己也会更改自己的头指针,所以并发存在失败的情况),继续进入 for 循环,尝试下一次窃取。这里有个很重要的点:并发状况的处理。通过对调度策略的分析,我们可以发现 P 从本地队列获取 G 以及被窃取,是存在并发情况的,面对并发 Go 是怎么处理的呢?避免并发:从本地获取 runqget 函数通过优先检查 runnext 字段,然后从本地运行队列中获取 Goroutine 的方式,实现了高效的 Goroutine 调度。这种方式可以减少不必要的竞争和锁开销,提高调度器的性能。无锁处理: 我们会发现不管是 runqget 函数还是 runqgrab 函数,在不得不应对 P 本地队列的并发情况时,并没有采用加锁处理,而是使用了 for + atomic.LoadAcq + atomic.CasRel 这样的代码组合,实现了无锁化,通过原子操作保证数据读写的一致性;通过无限 for 循环,解决原子操作失败的问题,这样就实现了无锁化操作。在 Go 语言的运行时系统中,为了提高并发性能,调度器通常会避免使用显式的锁机制,而是利用原子操作和内存屏障来实现无锁化操作。通过使用原子操作,可以在不进行显式锁定的前提下,确保数据的一致性和正确性。原子操作是不可中断的操作,可以在多处理器环境中安全地执行,而不会出现数据竞争或不一致的情况。无限 for 循环的使用是为了解决原子操作失败的情况。当一个处理器尝试通过原子操作获取或修改队列头部时,如果该操作失败(例如,由于其他处理器的并发修改),则该处理器会在循环中重新尝试该操作,直到成功为止。这种自旋重复获取的机制可以确保在并发环境下获得正确的队列头部,而不需要依赖显式的锁机制。通过结合原子操作和内存屏障,以及自旋重复获取的机制,Go 调度器能够在不使用显式锁的情况下实现无锁化操作,提高并发性能并确保数据的一致性和正确性。2.3 没有 G 可执行时当没有 G 可执行时,Go 调度器并没有直接让 M 放弃 CPU 执行权,进入睡眠状态,而是尽自己所能找活干,接下来我们就一起看看 M 是如何找活的吧!2.3.1 查看 GC 的标记工作能否再加一个 worker如果处理器处于 GC 的标记阶段,并且有可安全扫描和标记为黑色的对象(即那些已经确定为活跃状态的对象),那么处理器应该继续执行这些标记任务,而不是立即放弃控制权。这样做的好处是,它可以在等待新工作到来的同时,继续推进 GC 的进度,从而有助于减少 GC 停顿的时间,提高整体的程序性能。func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
...
// 到这里,表示没有任何事情可以做
//
// 当处理器(P)在 GC 的标记阶段,且当前没有其他紧急任务需要处理时
// 如果处理器处于 GC 的标记阶段,并且有可安全扫描和标记为黑色的对象,
// 那么处理器应该继续执行这些标记任务,而不是立即放弃控制权。
if gcBlackenEnabled != 0 && gcMarkWorkAvailable(pp) && gcController.addIdleMarkWorker() {
node := (*gcBgMarkWorkerNode)(gcBgMarkWorkerPool.pop())
if node != nil {
pp.gcMarkWorkerMode = gcMarkWorkerIdleMode
gp := node.gp.ptr()
casgstatus(gp, _Gwaiting, _Grunnable)
if trace.enabled {
traceGoUnpark(gp, 0)
}
return gp, false, false
}
gcController.removeIdleMarkWorker()
}
...
}我们可以看到代码中 gcController.addIdleMarkWorker,GC 会尝试增加一个 worker,因为 worker 池子里没有空闲的 worker,如果能增加成功,就可以安排 M 去执行 GC 标记工作。2.3.2 释放 P 之前的检查当 GC 都不缺人的时候,就得考虑释放 P 了,但在释放之前,又进行了一系列的检查,为了最大限度的找活干,我们继续看看都干啥了:func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
...
// 放弃 P 之前要做一些检查工作
allpSnapshot := allp
idlepMaskSnapshot := idlepMask
timerpMaskSnapshot := timerpMask
// 有 GC STW || runSafePointFn 可执行,则返回 top
lock(&sched.lock)
if sched.gcwaiting.Load() || pp.runSafePointFn != 0 {
unlock(&sched.lock)
goto top
}
// 全局可执行队列不为空,直接获取一批 G,放入 P 本地
// 返回第一个可执行的 G
if sched.runqsize != 0 {
gp := globrunqget(pp, 0)
unlock(&sched.lock)
return gp, false, false
}
if !mp.spinning && sched.needspinning.Load() == 1 {
// 如果 M 不在自旋状态,并且需要自旋,则切换为自旋状态
mp.becomeSpinning()
unlock(&sched.lock)
goto top
}
// releasep 解除 M 和 P 的关系,并设置 P 状态 _Pidle
if releasep() != pp {
throw("findrunnable: wrong p")
}
now = pidleput(pp, now) // P 重新加入空闲队列
unlock(&sched.lock)
...
}代码的主要逻辑如下:代码首先创建了三个快照,分别保存了所有 P(处理器)的列表、空闲 P 的掩码和定时器 P 的掩码。接下来,它尝试获取调度器的锁,以检查一些条件。如果调度器正在等待 GC(垃圾回收)或运行安全点函数,则它会释放锁并跳转到 top 标签,这意味着它会重新开始寻找可执行的 Goroutine。如果全局可执行队列不为空,它就从队列中获取一批 Goroutine,并将这些 Goroutine 放入当前 P 的本地队列中,然后返回第一个可执行的 Goroutine。如果当前 M(机器)不在自旋状态,并且需要自旋,那么它将切换到自旋状态并释放锁,然后跳转到 top 标签,它会重新开始寻找可执行的 Goroutine。接下来,解除当前 M 和 P 的关系,并重新将 P 加入空闲队列。2.3.3 处理 M 自旋状态func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
...
wasSpinning := mp.spinning
// 如果 M 还处于自旋状态,目前已解除 P
if mp.spinning {
// 重置为非自旋
mp.spinning = false
if sched.nmspinning.Add(-1) < 0 {
throw("findrunnable: negative nmspinning")
}
// Check all runqueues once again.
// 再次尝试看现在有没有能偷的工作
// 有的话返回一个空闲 P,绑定 M,并重新寻找 G
// 以便窃取工作继续执行
pp := checkRunqsNoP(allpSnapshot, idlepMaskSnapshot)
if pp != nil {
acquirep(pp)
mp.becomeSpinning()
goto top
}
// Check for idle-priority GC work again.
// 再次查看 GC 是否有工作可以执行
// 函数 checkIdleGCNoP 尝试在没有当前处理器(P)的情况下,
// 找到一个可用的处理器 P 和一个 G 处理垃圾回收工作
pp, gp := checkIdleGCNoP()
if pp != nil {
acquirep(pp)
mp.becomeSpinning()
// Run the idle worker.
pp.gcMarkWorkerMode = gcMarkWorkerIdleMode
casgstatus(gp, _Gwaiting, _Grunnable)
if trace.enabled {
traceGoUnpark(gp, 0)
}
return gp, false, false
}
// 检查定时器的创建或过期时间,更新 pollUntil
pollUntil = checkTimersNoP(allpSnapshot, timerpMaskSnapshot, pollUntil)
}
...
}M 自旋状态指的是线程在没有工作时不断检查是否有新工作可做的状态,而非自旋状态则是线程在没有工作时进入休眠或等待状态。线程(M)从自旋状态到非自旋状态转换期间,会并发的产生新工作提交,而这段代码就是为了解决在并发环境中安全地进行这种转换,同时确保不会丢失任何新提交的工作。 工作源涉及到多个方面,包括:每个处理器(P)的运行队列中添加的 G。GC 工作。每个处理器的定时器触发,导致新工作提交。2.3.4 阻塞在网络轮询中当调度器发现没有可运行的 goroutine 时,它可能会选择让网络轮询器阻塞,而不是立即让出 CPU。这样做可以提高系统的响应性,因为一旦有新的网络连接、数据到达或者其他网络事件发生,网络轮询器可以迅速唤醒,并调度相关的 goroutine 进行处理。func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
...
// 轮询网络直到下一个计时器
// 网络轮询是否已初始化 && (是否有等待的网络事件 || 是否有一个指定的轮询超时时间)&& 上次轮询的时间戳是否为非零
if netpollinited() && (netpollWaiters.Load() > 0 || pollUntil != 0) && sched.lastpoll.Swap(0) != 0 {
sched.pollUntil.Store(pollUntil)
if mp.p != 0 {
throw("findrunnable: netpoll with p")
}
if mp.spinning {
throw("findrunnable: netpoll with spinning")
}
// Refresh now.
now = nanotime()
// 计算轮询延迟时间
delay := int64(-1)
if pollUntil != 0 {
delay = pollUntil - now
if delay < 0 {
delay = 0
}
}
if faketime != 0 {
// 如果使用了 faketime,轮询将不会阻塞,直接进行轮询。
// When using fake time, just poll.
delay = 0
}
// delay 表示阻塞等待的时长,delay = 0 表示非阻塞调用网络轮询
list := netpoll(delay) // block until new work is available
sched.pollUntil.Store(0)
sched.lastpoll.Store(now) // 设置上一次网络轮询时间
if faketime != 0 && list.empty() {
// 使用了 fake time && 没有网络事件准备好
// 阻塞 M,等待被唤醒
stopm()
goto top // M 唤醒后,回到 top
}
lock(&sched.lock)
pp, _ := pidleget(now) // 尝试获取一个空闲的处理器(P)
unlock(&sched.lock)
if pp == nil {
// 如果没有获取到处理器,把剩余的事件列表注入到全局队列中,以供其他线程处理
injectglist(&list)
} else {
// 成功获取到一个处理器,绑定 M、P
acquirep(pp)
// 检查网络轮询返回的事件列表是否为空
if !list.empty() {
// 不为空,则处理网络事件
gp := list.pop()
injectglist(&list)
casgstatus(gp, _Gwaiting, _Grunnable)
if trace.enabled {
traceGoUnpark(gp, 0)
}
return gp, false, false
}
if wasSpinning {
// 之前线程是在自旋状态,它将恢复自旋状态并跳回到调度循环的顶部
mp.becomeSpinning()
}
goto top
}
} else if pollUntil != 0 && netpollinited() {
// 当轮询超时时间不为0 && 网络轮询已经初始化
// 获取调度器中 sched.pollUntil 字段
// 调度器应该阻塞网络轮询直到这个时间点
pollerPollUntil := sched.pollUntil.Load()
// 如果 sched.pollUntil 的值为 0,这通常意味着网络轮询器不应该阻塞,或者应该立即被打断
// 如果 sched.pollUntil 表示的时间点晚于 pollUntil 表示的时间点,
// 那么网络轮询器应该被打断,因为有一个更早的时间点需要被考虑。
if pollerPollUntil == 0 || pollerPollUntil > pollUntil {
// 打断任何正在进行的网络轮询
netpollBreak()
}
}
...
}这段代码用于处理网络轮询(netpoll)以及相关的调度操作:检查网络轮询的条件:首先检查是否满足进行网络轮询的条件。这包括检查网络轮询是否已初始化(netpollinited()),是否有等待的网络事件(netpollWaiters.Load() > 0),或者是否有一个指定的轮询超时时间(pollUntil != 0),以及上次轮询的时间戳是否为非零(sched.lastpoll.Swap(0) != 0)。设置轮询超时时间:如果满足条件,将设置调度器的 pollUntil 字段,并检查当前线程(M)是否持有一个处理器(P)或者是否在自旋状态。如果满足这些条件,将抛出异常,因为网络轮询应该在没有处理器和不在自旋状态的情况下进行。计算轮询延迟:计算轮询的延迟时间。如果 pollUntil 是非零的,它表示一个未来的时间戳,轮询应该在这个时间点之前阻塞。如果当前时间已经超过这个时间戳,轮询将立即返回。另外,如果使用了假时间(faketime),轮询将不会阻塞。执行网络轮询:调用 netpoll(delay) 执行网络轮询,阻塞直到有新的网络事件可用或者达到指定的延迟时间。处理轮询结果:轮询完成后,会检查是否使用了假时间并且没有新的工作可用。如果是这种情况,它将停止当前线程(M),直到被唤醒后跳回到调度循环的顶部(goto top)。否则,它将尝试获取一个空闲的处理器(P)。处理没有获取到处理器的情况:如果没有获取到处理器 P,把网络轮询返回的事件列表注入到全局队列中,以供其他线程处理。处理获取到处理器的情况:如果成功获取到一个处理器,将检查网络轮询返回的事件列表是否为空。如果不为空,它将取出一个事件,将其状态从等待(_Gwaiting)更改为可运行(_Grunnable),并返回这个事件以供执行,列表中的其他 G 被放入可运行队列等待调度。如果事件列表为空,并且之前线程是在自旋状态,它将恢复自旋状态并跳回到调度循环的顶部,重新开始寻找 G。处理不需要轮询的情况:如果一开始的条件不满足,但是指定了一个轮询超时时间,并且网络轮询已经初始化,将检查调度器的 pollUntil 字段。如果这个字段的值为零或者大于调度器记录的 pollUntil,说明调度器的定时事件先发生,它将调用 netpollBreak() 来打断任何正在进行的网络轮询。这段代码用于在没有处理器可用时进行网络轮询,以处理异步网络事件。2.3.5 阻塞 M,等待被唤醒当前面一系列检查都无法找到可执行的 G 的时候,就只能选择休眠 M,让出 CPU 了。func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
...
stopm() // 停止当前 m 的执行,直到有新的工作可用
goto top
}stopm 源码:runtime/proc.go 2317// 停止当前 m 的执行,直到有新的工作可用。
// 返回获取到的 P。
func stopm() {
gp := getg()
...
lock(&sched.lock)
mput(gp.m) // m 放入空闲列表 sched.midle
unlock(&sched.lock)
mPark() // 阻塞,等待唤醒
// m 被唤醒后,绑定一个 P,唤醒 m 前会提前绑定 P 到 gp.m.nextp 字段
acquirep(gp.m.nextp.ptr())
gp.m.nextp = 0 // 使用完,重置为 0
}3.调度执行 execute回到 schedule 函数的主流程,最后一步代码:execute(gp, inheritTime) 用于执行调度策略选出的 G。源码:runtime/proc.go 2646// Schedules gp to run on the current M.
// 如果 inheritTime 为 true,继承当前时间片,
// 否则新开启一个时间片
func execute(gp *g, inheritTime bool) {
mp := getg().m
...
mp.curg = gp // 设置 M 当前执行的 G
gp.m = mp // 绑定 G、M 关系
casgstatus(gp, _Grunnable, _Grunning) // G 设置为执行中
gp.waitsince = 0
gp.preempt = false // 初始化抢占标志
gp.stackguard0 = gp.stack.lo + _StackGuard // 初始化栈检查保护字段
if !inheritTime {
// 如果 inheritTime = false,使用新的时间片,执行 G
// 否则继承上一次调度的时间片,和 sysmon 监控线程逻辑有关
mp.p.ptr().schedtick++
}
...
gogo(&gp.sched) // 切换到 G 栈执行用户代码
}execute 代码逻辑比较简单,不总结了,这里贴上 《14. Go调度器系列解读(一):什么是 GMP?》 文章中提到的调度流程图,希望可以帮助各位从整体理解 GMP 的核心调度逻辑。总结本文是 Go 调度器系列最后一篇文章,主要是讲述 Go 调度器的调度策略,下面我们总结一下 Go 调度器策略的要点和优势:支持锁定机制:当一个 G 需要执行某个特定的系统调用或需要独占某个资源时,它可以被锁定到一个机器上,以确保在该 G 完成之前,其他 G 不会在该机器上运行。这样可以避免竞争条件和保证资源的正确使用。支持 GC STW事件的执行:sched.gcwaiting 是一个标志,用于表示当前是否有垃圾回收(GC)的“stop-the-world”(STW)事件正在等待发生或正在进行中。当这个标志被设置时,意味着运行时需要暂停所有的用户 Goroutines 以执行 GC 的某个阶段。尽最大可能寻找可执行 G(负载均衡)1.优先考虑 GC 的标记工作:确保 GC 工作能够及时得到执行,从而保持内存的使用在一个可控的范围内;在标记阶段,运行时需要确保有足够的线程来处理 GC 任务,以避免 GC 延迟过长,从而影响程序的性能。2.每隔 61 个调度时钟周期检查全局运行队列:每隔 61 个周期,如果全局运行队列(sched.runq)非空,调度器会尝试从全局队列中获取一个 Goroutine 来执行。这样做是为了防止本地运行队列被少数几个 Goroutine 长期占用,从而导致其他 Goroutine 得不到执行机会。3.从本地运行队列中获取 G:在尝试从全局运行队列获取 Goroutine 之前,调度器会先检查当前处理器(P)的本地运行队列(pp.runq)。如果本地队列中有可运行的 Goroutine,则优先执行它们。这样做的目的是减少并发,并发挥利用程序局部性的优势。4.再次检查全局运行队列:如果在本地运行队列中没有找到可运行的 Goroutine,并且全局运行队列非空,调度器会再次尝试从全局队列中获取 Goroutine。注意这里和第一步的区别在于,这一步不是周期性执行的,而是在本地队列为空时才会执行。此时并不是单纯的获取一个 G,而是通过负载均衡获取多个 G 到 P 的本地队列。5.从网络轮询中获取 G:网络轮询是 Go 运行时用来检查是否有就绪的网络事件(如新的网络连接、可读/可写的网络套接字等)并执行相应的处理函数的机制。这对于实现高效的 I/O 并发尤为重要,因为它允许 Go 程序在等待网络事件时继续执行其他任务。6.从其他 P 中窃取 G:当没有可运行的 Goroutine 时,M 会继续自旋一段时间,尝试从其他 P 窃取任务,而不是立即阻塞,这有助于减少线程调度的开销,提高系统的整体性能。窃取算法也是经过巧妙设计的,为了更好的支持 G 的调度,实现负载均衡。4. 没有 G 可执行时,会尝试以下工作,尽力为 M 找一些事情,而不是立即让出执行权:尝试增加一个 GC worker,尽快推进 GC 标记工作;会尝试释放 P,释放之前还努力再次检查了一下 GC、全局运行队列的工作;处理 M 自旋状态转换,在并发环境中安全地进行转换,同时确保不会丢失任何新提交的工作;当调度器发现没有可运行的 G 时,它可能会选择让网络轮询器阻塞,而不是立即让出 CPU。这样做可以提高系统的响应性,因为一旦有新的网络连接、数据到达或者其他网络事件发生,网络轮询器可以迅速唤醒,并调度相关的 goroutine 进行处理。最后阻塞 M,等待被唤醒继续复用,这里也不会消亡 M 哦,M 的消亡是 Go 运行时根据系统负载情况,做出的决定。用一句话总结 Go 的调度策略就是:尽最大努力从各种队列中找到一个可以执行的 G,支持锁定、窃取机制,支持 GC、网络轮询、定时器等组件的并发调度,可以做到负载均衡,能够减少线程调度的开销,并提升网路 IO 系统的响应性能。还有一点值得提一下:在 Go 语言的运行时系统中,为了提高并发性能,调度器通常会避免使用显式的锁机制,而是利用原子操作和内存屏障来实现无锁化操作。通过使用原子操作,可以在不进行显式锁定的前提下,确保数据的一致性和正确性。原子操作是不可中断的操作,可以在多处理器环境中安全地执行,而不会出现数据竞争或不一致的情况。无限 for 循环的使用是为了解决原子操作失败的情况。当一个处理器尝试通过原子操作获取或修改队列头部时,如果该操作失败(例如,由于其他处理器的并发修改),则该处理器会在循环中重新尝试该操作,直到成功为止。这种自旋重复获取的机制可以确保在并发环境下获得正确的队列头部,而不需要依赖显式的锁机制。通过结合原子操作和内存屏障,以及自旋重复获取的机制,Go 调度器能够在不使用显式锁的情况下实现无锁化操作,提高并发性能并确保数据的一致性和正确性。
cathoy
14. Go调度器系列解读(一):什么是 GMP?
前言紧接 13. 入门 go 语言汇编,看懂 GMP 源码 文章,我们继续开启 Go 调度器解读之旅。通过对 GMP 源码的阅读,理解了很多 Go 调度器的运行和调度规则,源码有六千行之多(还不包括汇编在内),这是一个庞大的工程,坚持下去,定会收获颇丰。Go 调度器探索共分为三篇文章进行讲述,今天就先分享第一篇相关文章:《什么是 GMP》,在这篇文章中,你可以学习到以下内容:Go 调度模型的发展史;G、M、P 相关概念和作用;G、M、P 实体以及其他对象之间的关系;G、M、P 源码结构体中重要的字段含义;G、M、P 对象的启动和初始化;GMP 关于一个线程的基本调度流程;G、M、P 的状态流转。Go 版本 1.20.7、linux 系统源码地址如下:src/runtime/runtime2.gosrc/runtime/proc.gosrc/runtime/asm_amd64.s1.Go 调度模型的发展史Go 语言有强大的并发能力,能够简单的通过 go 关键字创建大量的轻量级协程 Goroutine,帮助程序快速执行各种任务,这都源自于强大而复杂的 Go 调度器。本文基于 Go 1.20.7 版本进行调度器源码解读 ;Go 调度模型不可能一开始就是这么复杂,肯定经历了很长时间的优化和完善,才获得了今天的成果。只有了解了 Go 调度模型的发展历史,才能够更好的帮助我们理解现在的调度原理。1.1 Go 协程的产生我们都知道在多进程/多线程时代,CPU 内核有着自己的调度规则,给系统提供了并发处理的能力,但也存在着很多的缺点:进程、线程都拥有着很多的资源,它们的创建、切换和销毁都会占用大量的 CPU 资源;在如今的高并发业务场景下,为每一个任务创建一个线程是不现实的,每个线程大约需要 4MB 内存,大量的线程会导致高内存占用问题。然而这些缺点是作为应用层的我们不能插手和改变的,应用层唯一能做的就是减少线程的创建和切换,于是就产生了“协程”的概念:协程指的是用户级别的线程。这个概念怎么理解呢,我们看一张图:在这张图中,我们可以了解到 CPU 管辖的地段可以被称之为内核空间,这里我们的用户程序是进不去的,只能通过系统调用完成交互;而应用程序所在的地方,就是用户空间,这里我们是拥有绝对的调度权的。用户线程(协程)通过绑定内核线程就可以实现程序的运行,所以可以通过合理的调度用户线程进而提升 CPU 的利用率。于是便产生了协程,在 Go 中被称为 goroutine,是 Go 语言实现的用户态线程,主要用来解决内核线程太“重”的问题,所谓的太重,主要表现在以下两个方面:(为了区分表示,后边文章内容中,线程仅代表内核线程,goroutine 代表 Go 中的协程,也就是用户态线程)创建和切换太重:线程的创建和切换都需要进入内核,而进入内核所消耗的性能代价比较高,开销较大;内存使用太重:而相对的,用户态的 goroutine 则轻量得多:goroutine 是用户态线程(协程),其创建和切换都在用户代码中完成而无需进入操作系统内核,所以其开销要远远小于线程的创建和切换;goroutine 启动时默认栈大小只有 2k,这在多数情况下已经够用了,即使不够用,goroutine 的栈也会自动扩大,如果栈太大了过于浪费,它还能自动收缩,这样既没有栈溢出的风险,也不会造成栈内存空间的大量浪费。正是因为 Go 语言中实现了如此轻量级的 goroutine,才使得在 Go 程序中,可以轻易的创建成千上万甚至上百万的 goroutine 出来并发的执行任务,而不用太担心性能和内存等问题。1.2 GM 模型当然,产生 goroutine 后,下一个要考虑的问题就是 goroutine(G) 和线程(M)的映射关系,有三种映射方式:N:1 关系:N 个 goroutine 使用一个线程,goroutine 之间可以在用户态完成切换,不用进入内核,切换十分轻量;缺点是每个 Go 程序都用不了硬件的多核加速能力,并且 G 阻塞会导致跟 G 绑定的 M 阻塞,其他 G 也用不了 M 去执行自己的程序了。1:1 关系:1 个 goroutine 使用一个线程,直接丧失 goroutine 存在的意义。N:M 关系:N 个 goroutine 共同使用 M 个线程,这必然得产生一个中间层,我们称之为调度器,负责 goroutine 和 线程 M 之间的调度,这种方式是最复杂,也是最有效的映射方式,不仅可以使 goroutine 之间在用户态的完成切换,还可以最大限度利用硬件的多核加速能力。Go 调度器最原始的模型便是 GM 模型,采用 N:M 的映射方式,我们了解一下这种模型的实现方式:GM 模型使用全局唯一的 goroutine 运行队列,对所有 goroutine 进行管理和调度,线程(M)通过对全局运行队列加锁的方式,对 G 进行获取和执行,该模型存在以下缺点:G 的创建、调度都需要对全局运行队列加互斥锁,会造成激烈的锁竞争,全局锁带来的锁竞争导致的性能下降(最主要的缺陷);M 会频繁交接 G,导致额外开销、性能下降;每个 M 都得能执行任意的 runnable 状态的 G;新产生的 G 也需要被加入到全局队列中,无法保证在产生它的 M 中执行,每个 M 都需要处理内存缓存,导致大量的内存占用并影响数据局部性;1.3 GMP 模型为了解决 GM 模型的缺点,在已有 G、M 的基础上,引入了 P 处理器,由此产生了当前 Go 中经典的 GMP 调度模型:G、M、P 概念理解:G:goroutine,对用户态线程的抽象,可以在 M 上运行,存储在全局队列和 P 上的本地队列(大小 256)中。M:对操作系统的线程的抽象,一个 M 代表一个线程,Go 程序启动时会设置 M 最大数量(默认 10000),一个 M 最多可以绑定一个 P,M 发生阻塞会释放 P,P 可以与其他 M 建立绑定,如果不存在空闲的 M,可以进行创建新的 M。P:逻辑处理器,是对 cpu 核的抽象,可以简单理解为一个 p 就是一核。可以通过设置 GOMAXPROCS 来设置 P 的数量。其实 P 的数量,控制了并行的能力,一个 M 绑定一个 P, 如果 M 数量大于 P,多出来的 M 就只有阻塞排队。所以最好就是有几核就设置几个 P。GMP 模型增加了 P 这一层,解决了 GM 的缺陷:P 中保存了 goroutine 的本地队列,可以通过 CAS 的方式实现无锁访问,工作线程 M 优先使用自己的局部运行队列中的 G,只有必要时才会去访问全局运行队列,这大大减少了锁冲突,提高了大量 G 的并发性。M 创建新的 G 后,会优先将其存储到与自己绑定的 P 的本地队列中,从而解决了局部性问题,M 只需要处理和自己相关的缓存信息,减少资源的浪费。当 M 阻塞时,可以通过 **Hand Off 交接机制 **会将 M 上 P 的运行队列交给其他 M 执行,交接效率高,进一步提高了 Go 程序整体的并发度;当然 GMP 模型不止有这些优化,接下来本篇文章将从 G、M、P 对象启动的角度,探究一下 GMP 模型的核心调度过程,有兴趣的同学可以继续跟进后续的源码解读。2.调度器中重要的数据结构2.1 g 结构体为了实现对 goroutine 的调度,需要引入一个数据结构来保存 CPU 寄存器的值,以及 goroutine 的一些状态信息。在调度器源代码中,这个数据结构是一个名叫 g 的结构体,它保存了 goroutine 的所有信息,该结构体的每一个实例对象都代表了一个 goroutine,调度器代码可以通过 g 对象来对 goroutine 进行调度,当 goroutine 被调离 CPU 时,调度器代码负责把 CPU 寄存器的值保存在 g 对象的成员变量之中,当 goroutine 被调度起来运行时,调度器代码又负责把 g 对象的成员变量所保存的寄存器的值恢复到 CPU 的寄存器,完成 goroutine 的切换。接下来看一下 g 结构体(字段太多,只列出关键字段,其他结构体也是如此)。源码位置:src/runtime/runtime2.go 407type g struct {
stack stack // goroutine 使用的栈
// 下面两个成员用于栈溢出检查,实现栈的自动伸缩,抢占调度也会用到 stackguard0
stackguard0 uintptr
stackguard1 uintptr
...
m *m // 当前与 g 绑定的 m
sched gobuf // goroutine 的运行现场, CPU 几个寄存器相关信息
...
param unsafe.Pointer // wakeup 时传入的参数,可以参考 chan 源码
atomicstatus atomic.Uint32 // 表示 goroutine所处状态
goid uint64
// schedlink字段指向全局运行队列中的下一个g,
//所有位于全局运行队列中的g形成一个链表
schedlink guintptr
...
// 抢占调度标志。这个为 true 时,stackguard0 等于 stackpreempt
preempt bool // preemption signal, duplicates stackguard0 = stackpreempt
...
gopc uintptr // pc of go statement that created this goroutine
startpc uintptr // pc of goroutine function
...
}g 结构体重要字段详细介绍:stackguard0 主要用于检查栈空间是否足够的值, 低于这个值会扩张栈;如果 stackguard0 字段被设置成 StackPreempt(值很大) 意味着当前 goroutine 发出了抢占请求,后续会具体讲解使用方式。atomicstatus 表示 goroutine 所处状态,状态解释如下:g 状态(atomicstatus)状态下 g 的情况_Gidle = 0g 刚刚被分配,并且还没有申请内存,还不能被使用_Grunnable = 1g 可运行状态,g 在运行队列中,等待被调度运行_Grunning = 2g 正在执行状态,被分配给了 M,正在执行用户代码,g 不在运行队列中_Gsyscall = 3g 正在执行系统调用,被分配给了 M,M 阻塞在系统调用中,g 不在运行队列中_Gwaiting = 4g 在运行时被程序阻塞(例如 channel 的 go_park),没有执行用户代码,处于睡眠状态,g 不在运行队列中,被在记录在其他等待队列中(比如在 channel 的等待队列中),需要时执行 ready 才能被唤醒_Gmoribund_unused = 5当前此状态未使用_Gdead = 6表示 goroutine 实例有内存可以使用。有两种情况会成为 _Gdead,第一种是 g 刚刚申请完内存,表示初始状态,此时 g 可用;第二种是 g 刚刚退出,也就意味着该 g 生命周期结束,可以被放入 freelist,等待重复利用,不用重新申请内存_Genqueue_unused = 7当前此状态未使用_Gcopystack = 8需要扩容或者缩小 g 的栈空间,将协程的栈转移到新栈时的状态,没有执行用户代码,不在运行队列上,已经被分配给了 M,目前扩缩栈中,扩缩完成就可以继续执行用户代码Gpreempted = 9g 由于被抢占 M,被重新塞回运行队列中g 结构体关联了两个比较简单的结构体,stack 表示 goroutine 运行时的栈:// 用于记录 goroutine 使用的栈的起始和结束位置,栈的范围:[lo, hi)
type stack struct{
// 栈顶,低地址
lo uintptr
// 栈低,高地址
hi uintptr
}gobuf 用于保存 goroutine 的调度信息,主要包括 CPU 的几个寄存器相关的值,利用 gobuf 可以完成 goroutine 在 CPU 的切换:type gobuf struct {
sp uintptr // 保存 CPU 的 rsp 寄存器的值(函数栈顶)
pc uintptr // 保存 CPU 的 rip 寄存器的值(下一条运行指令地址)
g guintptr // 记录当前这个 gobuf 对象属于哪个 goroutine
ctxt unsafe.Pointer
// 保存系统调用的返回值,因为从系统调用返回之后如果 p 被其它工作线程抢占,
// 则这个 goroutine 会被放入全局运行队列被其它工作线程调度,其它线程需要知道系统调用的返回值。
ret sys.Uintreg
lr uintptr
// 保存 CPU 的 rbp 寄存器的值
bp uintptr // 对于支持帧指针的系统架构,才有用
}2.2 m 结构体Go 调度器源代码中还有一个用来代表工作线程的 m 结构体,每个工作线程都有唯一的一个 m 结构体的实例对象与之对应,m 结构体对象除了记录着工作线程的诸如栈的起止位置、当前正在执行的 goroutine 以及是否空闲等等状态信息之外,还通过指针维持着与 p 结构体的实例对象之间的绑定关系。于是,通过 m 既可以找到与之对应的工作线程正在运行的 goroutine,又可以找到工作线程的局部运行队列等资源 p。源码位置:src/runtime/runtime2.go 526type m struct {
// g0主要用来记录工作线程使用的栈信息,在执行调度代码时需要使用这个栈
// 执行用户 goroutine 代码时,使用用户 goroutine 自己的栈,因此调度时会发生栈的切换
g0 *g // goroutine with scheduling stack
...
// 通过 tls 结构体实现 m 与工作线程的绑定
// 这里是线程本地存储
tls [tlsSlots]uintptr // thread-local storage (for x86 extern register)
mstartfn func() // m 初始化后,运行的函数,比如监控线程 runtime.sysmon 或主线程的 runtime.main
// 指向正在运行的 gorutine 对象
curg *g // current running goroutine
// 当前工作线程绑定的 p
p puintptr // attached p for executing go code (nil if not executing go code)
nextp puintptr // 下一个可以绑定的 p
// 发生系统调用前绑定的 P,为了等系统调用返回时,快速绑定 P
oldp puintptr // the p that was attached before executing a syscall
...
// spinning状态:表示当前工作线程正在试图从其它工作线程的本地运行队列偷取 goroutine
spinning bool // m is out of work and is actively looking for work
blocked bool // m is blocked on a note
...
// 没有goroutine需要运行时,工作线程睡眠在这个park成员上,
// 其它线程通过这个park唤醒该工作线程
park note
// 记录所有工作线程的一个链表
alllink *m // on allm
schedlink muintptr
...
freelink *m // on sched.freem
...
}前面我们说每个工作线程都有一个 m 结构体对象与之对应,但并未详细说明它们之间是如何对应起来的,工作线程执行的代码是如何找到属于自己的那个 m 结构体实例对象的呢?答案是:利用线程本地存储 TLS,为每一个工作线程绑定一个 m,这样每个工作线程拥有了各自私有的 m 结构体全局变量,我们就能在不同的工作线程中使用相同的全局变量名来访问不同的 m 结构体对象,这完美的解决我们的问题。而线程的本地存储在 m 结构体中就是 tls 字段,后续文章会详细讲述,线程和 m 实例是如何绑定的。2.3 p 结构体上文提到过为了解决全局可运行队列激烈的锁竞争问题,Go 调度器为每一个工作线程引入了局部 goroutine 运行队列,工作线程优先使用自己的局部运行队列,只有必要时才会去访问全局运行队列,这大大减少了锁冲突,提高了工作线程的并发性。在 Go 调度器源代码中,局部运行队列被包含在 p 结构体的实例对象之中,每一个运行着 go 代码的工作线程都会与一个 p 结构体的实例对象关联在一起。p 结构体用于保存工作线程执行 go 代码时所必需的资源,比如 goroutine 的运行队列,内存分配用到的缓存等等。源码位置:src/runtime/runtime2.go 609type p struct {
// 在 allp 中的索引
id int32
status uint32 // P 状态 one of pidle/prunning/...
link puintptr // pidle 链表的指针
schedtick uint32 // 每次调用 schedule 时会加一
syscalltick uint32 // 每次系统调用时加一
sysmontick sysmontick // 用于 sysmon 线程记录被监控 p 的系统调用时间和运行时间(抢占的时候用)
m muintptr // 绑定的 m back-link to associated m (nil if idle)
...
// 本地可运行的队列,不用通过锁即可访问
runqhead uint32 // 队列头
runqtail uint32 // 队列尾
runq [256]guintptr // 使用数组实现的循环队列,大小 256
// runnext 非空时,代表的是一个 runnable 状态的 G,
// 这个 G 被当前 G 修改为 ready 状态,相比 runq 中的 G 有更高的优先级。
// 如果当前 G 还有剩余的可用时间,那么就应该运行这个 G
// 运行之后,该 G 会继承当前 G 的剩余时间
runnext guintptr
// Available G's (status == Gdead)
gFree struct {
gList
n int32
}
...
}
type sysmontick struct {
schedtick uint32
schedwhen int64
syscalltick uint32
syscallwhen int64
}
// A gList is a list of Gs linked through g.schedlink. A G can only be
// on one gQueue or gList at a time.
type gList struct {
head guintptr
}这里介绍一下 p 中最为重要的几个字段:p 结构体中,runq 再配合 runqhead 和 runqtail 模拟了一个循环队列,大小为 256,用于存储本地可运行的 G。runnext 不为 nil 的话,该 P 绑定的 M 的下一个调度的 g 优先是 runnext 指向的 g。gFree 是一个 goroutine 缓存池,里面的 g 的状态都是 Gdead,goroutine 内存可以被重复利用,gList 是一个链表,n 是数量。创建 goroutine 时, 会先从 gFreee list 中查找空闲的 goroutine,如果不存在空闲的 goroutine,会重新创建 goroutine。status 表示 p 的状态,包括以下几种:p 的状态状态解释_Pidle = 0当前 P 没有被使用,通常是在 schedt.pidle 中等着被调度,同时它的本地运行列队为空_Prunning = 1当前 P 被线程 M 持有,在此状态下,只有拥有当前 P 的 M 才可能修改状态_Psyscall = 2当前 P 与一个正在进行系统调用的 M 关联着,M 进入系统调用阻塞前改变 P 为此状态,但此时 P 并不属于这个 M,M 和 P 已经解绑, P 可能会被其他 M 偷走,或者该 M 结束系统调用,重新绑定 P_Pgcstop = 3当前 P 所属 M 正在进行 GC_Pdead = 4当前 P 已经不被使用 (如动态调小 GOMAXPROCS)2.4 schedt 结构体为了实现对 goroutine 的调度,需要一个存放可运行 G 的容器,便于 M 寻找,因此引入了 schedt 结构体:用来保存调度器自身的状态信息;拥有一个用来保存 goroutine 的运行队列,由于每个 Go 程序只有一个调度器,只有一个 schedt 实例对象被 M 共享,因此该运行队列被称为:全局运行队列。源码位置:src/runtime/runtime2.go 766type schedt struct {
...
lastpoll atomic.Int64 // 上次网络轮询的时间,如果当前轮询则为 0
pollUntil atomic.Int64 // 当前轮询休眠的时间
lock mutex
midle muintptr // 由空闲的 m 组成的链表
nmidle int32 // 空闲的 m 数量
nmidlelocked int32 // 空闲的且被 lock 的 m 计数
mnext int64 // 已创建的 m 的数量和下一个 M ID
maxmcount int32 // 表示最多所能创建的 m 数量
nmsys int32 // 不计入死锁的系统 m 数量
nmfreed int64 // 释放的 m 的累积数量
ngsys atomic.Int32 // 系统 goroutine 数量
pidle puintptr // 由空闲的 p 结构体对象组成的链表,pidle 表示头指针
npidle atomic.Int32 // 空闲的 p 结构体对象的数量
// 关于自旋 m 的数量,唤醒 P 的关键条件
nmspinning atomic.Int32 // See "Worker thread parking/unparking" comment in proc.go.
needspinning atomic.Uint32 // See "Delicate dance" comment in proc.go. Boolean. Must hold sched.lock to set to 1.
runq gQueue // 全局可运行的 G 队列
runqsize int32 // 全局可运行的 G 队列元素数量
...
// Global cache of dead G's.
// gFree 是所有已经退出的 goroutine 对应的 g 结构体对象组成的链表
// 用于缓存 g 结构体对象,避免每次创建 goroutine 时都重新分配内存
gFree struct {
lock mutex
stack gList // Gs with stacks
noStack gList // Gs without stacks
n int32
}
...
}
// gQueue 是通过 g.schedlink 链接的 G 的出队。
// 一个 G 一次只能位于一个 gQueue 或 gList 上。
type gQueue struct {
head guintptr
tail guintptr
}2.5 全局变量在程序初始化时,这些全变量都会被初始化为 0 值,指针会被初始化为 nil 指针,切片初始化为 nil 切片,int 被初始化为数字 0,结构体的所有成员变量按其本类型初始化为其类型的 0 值。所以程序刚启动时 allgs,allm 和 allp 都不包含任何 g、m 和 p。allglen uintptr // 所有 g 的长度
allgs []*g // 保存所有的 g
allm *m // 所有的 m 构成的一个链表,包括下面的 m0
allp []*p // 保存所有的 p,len(allp) == gomaxprocs
ncpu int32 // 系统中 cpu 核的数量,程序启动时由 runtime 代码初始化
gomaxprocs int32 // p 的最大值,默认等于 ncpu,但可以通过 GOMAXPROCS 修改
sched schedt // 调度器结构体对象,记录了调度器的工作状态
m0 m // m0 代表进程的主线程
g0 g // m0的g0,也就是m0.g0 = &g0这里有个特殊的变量 g0,g0 的主要作用是提供一个栈供 runtime 代码执行; g0 是每次启动一个 M 都会第一个创建的 gourtine,g0 仅用于负责调度 g,g0 不指向任何可执行的函数, 每个 M 都会有一个自己的 g0。在调度或系统调用时会使用 g0 的栈空间, 全局变量的 g0 是 m0 的 g0,该 g0 的栈大约有64K,在系统的栈上进行分配,内存分配情况我们下一篇文章详细分析。3.G、M、P 的启动本小节主要讲述普通 G、M、P 启动的时刻和准备工作,为后续理解调度做准备(注:main 函数的启动过程,因为比较特殊,也比较重要,需要详细介绍,所以有关主线程 M0 和第一个 G 的启动内容在下一篇文章详细讲解)3.1 G 的创建先聊一下普通的 g 是怎么创建的?其实我们都很熟悉 go 这个关键字,该关键字就是创建 g 的关键!首先使用 go build -gcflags="-S -l -N" main.go 2> main.s 工具链将下面的代码生成汇编。package main
import (
"fmt"
"time"
)
func main() {
go func() {
fmt.Println("Hello GMP!")
}()
time.Sleep(time.Second)
}我们会看到 go 关键字被编译成了 CALL runtime.newproc(SB)汇编语句,当我们使用 go 关键字时,编译器会翻译成汇编代码调用 runtime·newproc 创建一个协程,该函数源代码在 src/runtime/proc.go。// Create a new g running fn.
// Put it on the queue of g's waiting to run.
// The compiler turns a go statement into a call to this.
func newproc(fn *funcval) {
gp := getg()
pc := getcallerpc() // 获取 newproc 函数调用者指令的地址
systemstack(func() {
newg := newproc1(fn, gp, pc)
pp := getg().m.p.ptr()
runqput(pp, newg, true)
if mainStarted {
wakep()
}
})
}newproc 函数用于创建新的 goroutine,它有一个参数 fn 表示 g 创建出来要执行的函数,接下来我们分析一下 newproc 函数的内容。本章节只分析普通 goroutine 的创建,创建步骤如下:使用 systemstack 函数切换到系统栈(一般是 g0 栈)中执行,执行完毕后切回普通 g 的栈;newproc1 用于创建一个新可运行的 goroutine,主要是内存分配和 g 参数的初始化;获取当前 g 的 p,优先将 newg 放入该 p 的本地队列,本地队列放不下,放入全局运行队列中;如果 main 函数已经启动,则使用 wakep 函数唤醒一个 p(wakep 函数我们后续讲解,这里我们先分析前三个步骤)。第一步:systemstack 括起来的地方表示切换到 g0 栈去执行,执行完切换回来,systemstack 函数是使用汇编实现的(有兴趣的可以看看),源码 src/runtime/asm_amd64.s 466;// func systemstack(fn func())
TEXT runtime·systemstack(SB), NOSPLIT, $0-8
MOVQ fn+0(FP), DI // DI = fn
get_tls(CX) // 从 tls 获取 g 到 cx 中
MOVQ g(CX), AX // AX = g
MOVQ g_m(AX), BX // BX = m
CMPQ AX, m_gsignal(BX) // g 是否是信号 g
JEQ noswitch // 是的话不用切换栈,直接执行 DI 就行
MOVQ m_g0(BX), DX // DX = g0
CMPQ AX, DX // g 是否是 g0 (是否已经在使用 g0 栈了)
JEQ noswitch // 是的话不用切换栈,直接执行 DI 就行
CMPQ AX, m_curg(BX) // g 是否是当前 m 正在运行的 g
JNE bad // g 不是 m 当前运行的 g,代码出现问题
// switch stacks 切换栈
// save our state in g->sched. Pretend to
// be systemstack_switch if the G stack is scanned.
CALL gosave_systemstack_switch<>(SB)
// switch to g0
MOVQ DX, g(CX)
MOVQ DX, R14 // set the g register
MOVQ (g_sched+gobuf_sp)(DX), BX // g0 的 sp
MOVQ BX, SP // 切换到 g0 栈
// call target function
MOVQ DI, DX
MOVQ 0(DI), DI
CALL DI
// switch back to g
get_tls(CX) // g0
MOVQ g(CX), AX // AX = g0
MOVQ g_m(AX), BX // BX = m
MOVQ m_curg(BX), AX // AX = g
MOVQ AX, g(CX) // tls 存入 g
MOVQ (g_sched+gobuf_sp)(AX), SP // 切换 g 的 sp
MOVQ $0, (g_sched+gobuf_sp)(AX) // 清除不用的数据
RET
noswitch:
// already on m stack; tail call the function
// Using a tail call here cleans up tracebacks since we won't stop
// at an intermediate systemstack.
MOVQ DI, DX
MOVQ 0(DI), DI
JMP DI
bad:
// Bad: g is not gsignal, not g0, not curg. What is it?
MOVQ $runtime·badsystemstack(SB), AX
CALL AX
INT $3第二步:newproc 函数中调用 newproc1 创建一个可运行的 newg。// Create a new g in state _Grunnable, starting at fn. callerpc is the
// address of the go statement that created this. The caller is responsible
// for adding the new g to the scheduler.
func newproc1(fn *funcval, callergp *g, callerpc uintptr) *g {
if fn == nil {
fatal("go of nil func value")
}
mp := acquirem() // 禁止抢占 m
pp := mp.p.ptr()
newg := gfget(pp) // 从 p 的 gFree 获取一个可用的 g
if newg == nil {
newg = malg(_StackMin) // 只能创建一个 g 了; _StackMin = 2k
casgstatus(newg, _Gidle, _Gdead) // 改变 g 状态为 _Gdead
allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
}
...
// 额外准备一些栈空间
totalSize := uintptr(4*goarch.PtrSize + sys.MinFrameSize) // extra space in case of reads slightly beyond frame
totalSize = alignUp(totalSize, sys.StackAlign)
sp := newg.stack.hi - totalSize
// 初始化 g 参数
// 清除内存数据,因为 g 可能是复用已经 dead 的 g
// 把 newg.sched 结构体成员的所有成员设置为 0
memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))
// 设置 newg 的 sched 成员,调度器需要依靠这些字段才能把 goroutine 调度到 CPU 上运行。
newg.sched.sp = sp // newg 的栈顶
newg.stktopsp = sp
// newg.sched.pc 表示当 newg 被调度起来运行时从这个地址开始执行指令
// 把 pc 设置成了 goexit 这个函数偏移1(sys.PCQuantum = 1)的位置,
// 至于为什么要这么做需要等到分析完 gostartcallfn 函数才知道
newg.sched.pc = abi.FuncPCABI0(goexit) + sys.PCQuantum // +PCQuantum so that previous instruction is in same function
newg.sched.g = guintptr(unsafe.Pointer(newg))
// 调整 sched 成员和 newg 的栈,一会具体分析一下这个关键函数!!!
gostartcallfn(&newg.sched, fn)
newg.gopc = callerpc
newg.ancestors = saveAncestors(callergp) // 记录一下创建 g 的祖先 g,debug 用的
newg.startpc = fn.fn // 指向 fn 真正开始执行的第一条指令,和函数的底层实现有关
...
casgstatus(newg, _Gdead, _Grunnable) // g 设置为可运行状态
// 生成 g_id
if pp.goidcache == pp.goidcacheend {
// Sched.goidgen is the last allocated id,
// this batch must be [sched.goidgen+1, sched.goidgen+GoidCacheBatch].
// At startup sched.goidgen=0, so main goroutine receives goid=1.
pp.goidcache = sched.goidgen.Add(_GoidCacheBatch)
pp.goidcache -= _GoidCacheBatch - 1
pp.goidcacheend = pp.goidcache + _GoidCacheBatch
}
newg.goid = pp.goidcache // 初始化 gid
pp.goidcache++ // 下一个 gid
...
releasem(mp) // 释放 m,允许抢占
return newg
}这段代码首先对 newg 的 sched 成员进行了初始化,该成员包含了调度器代码在调度 goroutine 到 CPU 运行时所必须的一些信息:sched 的 sp 成员表示 newg 被调度起来运行时应该使用的栈的栈顶;sched 的 pc 成员表示当 newg 被调度起来运行时从这个地址开始执行指令。然而从上面的代码可以看到,newg.sched.pc 被设置成了 goexit 函数的第二条指令的地址而不是 fn.fn,这是为什么呢?要回答这个问题,必须深入到 gostartcallfn 函数中做进一步分析。// adjust Gobuf as if it executed a call to fn
// and then stopped before the first instruction in fn.
func gostartcallfn(gobuf *gobuf, fv *funcval) {
var fn unsafe.Pointer
if fv != nil {
fn = unsafe.Pointer(fv.fn)
} else {
fn = unsafe.Pointer(abi.FuncPCABIInternal(nilfunc))
}
gostartcall(gobuf, fn, unsafe.Pointer(fv))
}
// adjust Gobuf as if it executed a call to fn with context ctxt
// and then stopped before the first instruction in fn.
func gostartcall(buf *gobuf, fn, ctxt unsafe.Pointer) {
sp := buf.sp // newg 的栈顶
sp -= goarch.PtrSize // 栈顶向下移动 8 字节,用来存 return address
*(*uintptr)(unsafe.Pointer(sp)) = buf.pc // return address = goexit 函数的第二条指令的地址
buf.sp = sp // 设置 buf.sp 指向新的栈顶
buf.pc = uintptr(fn) // buf.pc 执行函数地址 fn,后边 g 被调度起来,会从这里开始执行
buf.ctxt = ctxt
}gostartcallfn 函数首先从参数 fv 中提取出函数地址 fv.fn,然后继续调用 gostartcall 函数。gostartcall 函数的主要作用有两个:调整 newg 的栈空间,把 goexit 函数的第二条指令的地址入栈,伪造成 goexit 函数调用了 fn 的假象,从而使 fn 执行完成后,执行 ret 指令时,返回到 goexit+1 处继续执行,完成最后的清理工作;重新设置 newg.buf.sp 指向新栈顶,设置 newg.buf.pc 为需要执行的函数的地址,即 fn,也就是 go 关键字后边的函数的地址。至此,一个可用的 goroutine 就创建好了!这里把 goexit 函数也贴出来,你就知道 goexit+1 指向哪里了,也就知道 g 正常执行结束后,会 ret 到哪里了;从源码里我们可以看到 goexit+1 地址指向 CALL runtime·goexit1(SB) 指令,其实 g 正常 ret 后,继续调用了 runtime·goexit1 函数:源码地址:src/runtime/asm_amd64.s : 1595// The top-most function running on a goroutine
// returns to goexit+PCQuantum.
TEXT runtime·goexit(SB),NOSPLIT|TOPFRAME,$0-0
BYTE $0x90 // NOP
CALL runtime·goexit1(SB) // does not return
// traceback from goexit1 must hit code range of goexit
BYTE $0x90 // NOP我们继续把目光回到主线任务:第三步:调用 runqput 函数将 g 放入可运行队列,优先放入 p 的本地队列,本地队列满了,再放入全局可运行队列;如果 next = true,将 g 替换当前的 pp.runnext,然后将 pp.runnext 中原本的内容重新放入可运行队列。// runqput tries to put g on the local runnable queue.
// If next is false, runqput adds g to the tail of the runnable queue.
// If next is true, runqput puts g in the pp.runnext slot.
// If the run queue is full, runnext puts g on the global queue.
// Executed only by the owner P.
func runqput(pp *p, gp *g, next bool) {
// 引入调度的随机性
if randomizeScheduler && next && fastrandn(2) == 0 {
next = false
}
if next {
retryNext:
// 为了最大限度的保持局部优先性,gp 优先放入 pp.runnext 槽中
oldnext := pp.runnext
if !pp.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {
// cas 保证操作的原子性
goto retryNext
}
// 原本 runnext 值为 nil,所以没任何事情可做了,直接返回
if oldnext == 0 {
return
}
// Kick the old runnext out to the regular run queue.
// //原本存放在 runnext 的 gp 需要放入 runq 的尾部
gp = oldnext.ptr()
}
retry:
// 可能有其它线程正在并发修改 runqhead 成员,所以需要跟其它线程同步
h := atomic.LoadAcq(&pp.runqhead) // load-acquire, synchronize with consumers
t := pp.runqtail
// 判断 p 的本地运行队列是否满了
if t-h < uint32(len(pp.runq)) {
// 队列还没有满,可以放入,尾部放入 gp
pp.runq[t%uint32(len(pp.runq))].set(gp)
// 虽然没有其它线程并发修改这个 runqtail,但其它线程会并发读取该值以及 p 的 runq 成员
// 这里使用 StoreRel (汇编实现的)是为了:
// 1.原子写入 runqtail
// 2.防止编译器和 CPU 乱序,保证上一行代码对 runq 的修改发生在修改 runqtail 之前
// 3.可见行屏障,保证当前线程对运行队列的修改对其它线程立马可见
atomic.StoreRel(&pp.runqtail, t+1) // store-release, makes the item available for consumption
return
}
// p 的本地运行队列已满,需要放入全局运行队列
if runqputslow(pp, gp, h, t) {
return
}
// the queue is not full, now the put above must succeed
goto retry // 队列未满时,必须得执行成功
}我们再来简单的过一下 runqputslow 函数:// Put g and a batch of work from local runnable queue on global queue.
// Executed only by the owner P.
func runqputslow(pp *p, gp *g, h, t uint32) bool {
var batch [len(pp.runq)/2 + 1]*g // gp 加上 p 本地队列的一半
// First, grab a batch from local queue.
n := t - h
n = n / 2
if n != uint32(len(pp.runq)/2) {
throw("runqputslow: queue is not full")
}
// 取出 p 本地队列的一半
for i := uint32(0); i < n; i++ {
batch[i] = pp.runq[(h+i)%uint32(len(pp.runq))].ptr()
}
if !atomic.CasRel(&pp.runqhead, h, h+n) { // cas-release, commits consume
// 这里存在并发,会有其他 p 过来偷 g
// 如果 cas 操作失败,说明已经有其它工作线程
// 从 p 的本地运行队列偷走了一些 goroutine
// 所以直接返回,让 p 继续 retry 就行
return false
}
batch[n] = gp
// 增加调度的随机性,随机打乱一下顺序
if randomizeScheduler {
for i := uint32(1); i <= n; i++ {
j := fastrandn(i + 1)
batch[i], batch[j] = batch[j], batch[i]
}
}
// Link the goroutines.
// 全局运行队列是一个链表,这里首先把所有需要放入全局运行队列的 g 链接起来,
// 减少后面对全局链表的锁住时间,从而降低锁冲突
for i := uint32(0); i < n; i++ {
batch[i].schedlink.set(batch[i+1])
}
var q gQueue
q.head.set(batch[0])
q.tail.set(batch[n])
// Now put the batch on global queue.
lock(&sched.lock)
globrunqputbatch(&q, int32(n+1))
unlock(&sched.lock)
return true
}runqputslow 函数首先把从 p 的本地队列中取出的一半,连同 gp 一起通过链表串联起来;然后在加锁成功之后,通过 globrunqputbatch 函数把该链表链入全局运行队列(全局运行队列是使用链表实现的)。值的一提的是 runqputslow 函数并没有一开始就把全局运行队列锁住,而是等所有的准备工作做完之后才锁住全局运行队列,这是并发编程加锁的基本原则,需要尽量减小锁的粒度,降低锁冲突的概率。到这里,一个新的可运行的 goroutine 就被塞入可运行队列中了,接下来我们聊一下 goroutine 如何被运行起来!3.2 P 的唤醒P 在整个程序启动的时候就被初始化了,具体是在 schedinit 函数中初始化的,但这里我并不想展开,我会把这一块放入 main 函数的启动一节,这样会更加清晰!那 P 什么时候会被唤醒呢?通过创建 G 源码第四步骤,就已经发现是 wakep 函数负责唤醒 P,该方法的上游还是有不少的,来源的代码我就不一一分析了,情况有如下几个:唤醒阻塞 G (ready )的时候,会尝试唤醒 P,比如 channel 阻塞和唤醒;STW 之后,唤醒 P;唤醒一个空闲的 P 来为定时器和网络轮询器提供服务(如果还没有的话);在 schedule 函数循环调度过程中,当自旋 M 找到 G,而结束自旋时,尝试唤醒一个 P;在 schedule 过程中,如果正在调度一个非正常的 G (比如 GC)时,尝试唤醒一个 P;新 G 创建的时候,会尝试唤醒 P(也就是我们刚刚分析的那个)。好了,分析了一下 P 被唤醒的来源,我们来解读一下 wakep 函数的源码吧!// Tries to add one more P to execute G's.
// Called when a G is made runnable (newproc, ready).
// Must be called with a P.
func wakep() {
// Be conservative about spinning threads, only start one if none exist
// already.
// 不是很随意就能唤醒一个 P,需要满足一定的条件
// 当没有自旋 m 的时候才能唤醒 P
if sched.nmspinning.Load() != 0 || !sched.nmspinning.CompareAndSwap(0, 1) {
return
}
// Disable preemption until ownership of pp transfers to the next M in
// startm. Otherwise preemption here would leave pp stuck waiting to
// enter _Pgcstop.
//
// See preemption comment on acquirem in startm for more details.
mp := acquirem() // 禁止抢占
var pp *p
// sched 全局对象加锁,因为要从 sched.pidle 空闲 P 链表中获取 P
lock(&sched.lock)
// 从 sched.pidle 获取空闲的 p(函数比较简单,就是链表的使用,这里不分析了)
pp, _ = pidlegetSpinning(0)
if pp == nil {
if sched.nmspinning.Add(-1) < 0 {
throw("wakep: negative nmspinning")
}
unlock(&sched.lock)
releasem(mp)
return
}
// Since we always have a P, the race in the "No M is available"
// comment in startm doesn't apply during the small window between the
// unlock here and lock in startm. A checkdead in between will always
// see at least one running M (ours).
unlock(&sched.lock)
// 开始一个 M
startm(pp, true, false)
releasem(mp)
}wakep 函数逻辑比较简单:首先判断处于自旋状态的 M 数量是否等于 0,然后通过 cas 操作再次确认是否有其它 M 正处于 spinning 状态,判断到需要启动 M 之后,到真正启动 M 之前的这一段时间之内,如果已经有 M 进入了 spinning 状态,而在四处寻找需要运行的 goroutine,这样的话我们就没有必要再启动一个多余的工作线程出来了。通过这一层校验后,说明没有处于 spinning 状态的 M,我们可以启动一个 M,所以从 sched.pidle 全局空闲 P 链表中,获取一个空闲状态的 P,如果获取成功则启动一个 M。该场景下启动 M 使用的是 startm 函数,我们继续沿着这个方向,继续分析一下 M 是如何被启动起来的!3.3 M 的启动m 启动使用 startm 函数,我们一起来看一下源码:func startm(pp *p, spinning, lockheld bool) {
mp := acquirem()
if !lockheld {
lock(&sched.lock)
}
// 如果 p 不存在,从空闲链表获取
if pp == nil {
if spinning {
// TODO(prattmic): All remaining calls to this function
// with _p_ == nil could be cleaned up to find a P
// before calling startm.
throw("startm: P required for spinning=true")
}
pp, _ = pidleget(0)
if pp == nil {
if !lockheld {
unlock(&sched.lock)
}
releasem(mp)
return
}
}
nmp := mget() // 尝试从空闲的 M 链表获取一个 M
if nmp == nil {
// 利用 sched.mnext 创建新 M 的 ID
id := mReserveID() // 这里有加锁
unlock(&sched.lock)// 这里解锁
// 设置新 M 的执行函数 fn,直接设置自己为 spinning
var fn func()
if spinning {
// The caller incremented nmspinning, so set m.spinning in the new M.
fn = mspinning
}
newm(fn, pp, id) // 新建一个 M,这个函数非常重要!!!一会详细分析
if lockheld {
lock(&sched.lock)
}
// Ownership transfer of pp committed by start in newm.
// Preemption is now safe.
releasem(mp)
return
}
...
// The caller incremented nmspinning, so set m.spinning in the new M.
nmp.spinning = spinning
nmp.nextp.set(pp) // 为后续绑定 P 做准备,m 后续只需绑定 nextp
notewakeup(&nmp.park) // 唤醒处于休眠状态的工作线程
// Ownership transfer of pp committed by wakeup. Preemption is now
// safe.
releasem(mp)
}startm 函数首先判断是否有空闲的 p 结构体对象,如果没有则直接返回,如果有则需要创建或唤醒一个工作线程出来与之绑定。在确保有可以绑定的 p 对象之后,startm 函数分两条路走:首先尝试从 m 的空闲队列中查找正处于休眠状态的工作线程,如果找到则通过 notewakeup 函数唤醒它,开始调度。否则调用 newm 函数创建一个新的工作线程出来,开始新的调度。3.3.1 唤醒休眠的 M聊 notewakeup(&nmp.park) 源码之前,先了解一个背景知识,什么是 nmp.park,在 schedule 调度过程中,当 M 找不到可运行的 G 时,工作线程会通过 notesleep(&gp.m.park) 函数睡眠在 m.park 成员上,所以这里使用 m.park 成员作为参数,调用 notewakeup 把睡眠在该成员之上的工作线程唤醒。这里找源码需要注意一下,想看 linux 源码的,就得跳转到 src/runtime/lock_futex.go 139 了,跳转会根据你现在的系统跳转,比如我现在用的 mac,idea 就给我跳到这里了:runtime/lock_sema.go 142,这里分析的是 linux 下的代码。func notewakeup(n *note) {
old := atomic.Xchg(key32(&n.key), 1)
if old != 0 {
print("notewakeup - double wakeup (", old, ")\n")
throw("notewakeup - double wakeup")
}
futexwakeup(key32(&n.key), 1)
}notewakeup 函数首先使用 atomic.Xchg 设置 note.key = 1,这是为了使被唤醒的线程,可以通过查看该值是否等于1,来确定是被其它线程唤醒,还是意外从睡眠中苏醒了过来,如果该值为 1 则表示是被唤醒的,可以继续工作了,但如果该值为 0,则表示是意外苏醒,需要抛出异常。notewakeup 函数拿到 M 唤醒的权限后,开始执行唤醒 M 的函数 futexwakeup。源码:runtime/os_linux.go : 81//go:nosplit
func futexwakeup(addr *uint32, cnt uint32) {
// 调用 futex 函数唤醒工作线程
ret := futex(unsafe.Pointer(addr), _FUTEX_WAKE_PRIVATE, cnt, nil, nil, 0)
if ret >= 0 {
return
}
// I don't know that futex wakeup can return
// EAGAIN or EINTR, but if it does, it would be
// safe to loop and call futex again.
systemstack(func() {
print("futexwakeup addr=", addr, " returned ", ret, "\n")
})
*(*int32)(unsafe.Pointer(uintptr(0x1006))) = 0x1006
}对于 Linux 平台来说,工作线程通过 note 睡眠,其实是通过 futex 系统调用睡眠在内核之中,所以唤醒处于睡眠状态的线程,也需要通过 futex 系统调用进入内核来唤醒,所以这里的 futexwakeup 又继续调用futex 函数(该函数包装了 futex 系统调用)来实现唤醒睡眠在内核中的工作线程。源码地址 runtime/sys_linux_amd64.s// int64 futex(int32 *uaddr, int32 op, int32 val,
// struct timespec *timeout, int32 *uaddr2, int32 val2);
TEXT runtime·futex(SB),NOSPLIT,$0
MOVQ addr+0(FP), DI // SYS_futex 参数准备
MOVL op+8(FP), SI
MOVL val+12(FP), DX
MOVQ ts+16(FP), R10
MOVQ addr2+24(FP), R8
MOVL val3+32(FP), R9
MOVL $SYS_futex, AX // futex 系统调用编号放入 AX 寄存器
SYSCALL // 系统调用,进入内核
MOVL AX, ret+40(FP) // 系统调用通过 AX 寄存器返回返回值
RETfutex 函数由汇编代码写成,前面的几条指令都在为 futex 系统调用(define SYS_futex 202)准备参数,参数准备完成之后则通过 SYSCALL 指令进入操作系统内核,完成线程的唤醒功能。内核在完成唤醒工作之后,当前工作线程 M 则从内核返回到 futex 函数,继续执行 SYSCALL 指令之后的代码,并按函数调用链原路返回,继续执行其它代码;而被唤醒的工作线程 M',则由内核负责在适当的时候调度到 CPU 上运行。看到这里不知道你们有没有这样一个疑问:为什么 M' 还没绑定 P,就能直接被调度到 CPU 运行了?其实是不实庐山真面,原因是我们还没有聊到 M' 被唤醒以后从哪里执行呢!当 M' 被 CPU 调度执行时,M' 会从开始睡眠的地方继续执行。我们前边聊过一个背景知识:在 schedule 调度过程中,当 M' 找不到可运行的 G 时,工作线程会通过 notesleep(&gp.m.park) 函数睡眠在 m.park 成员上,当 M' 被唤醒时,当然要从睡眠这里继续执行啦!看代码 src/runtime/proc.go 3349:// 调度
func schedule() {
...
gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available
...
execute(gp, inheritTime)
}
// 寻找可运行的 g
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
mp := getg().m
top:
pp := mp.p.ptr()
// 省略调度策略:寻找 g 的过程
...
stopm()
goto top
}
// Stops execution of the current m until new work is available.
// Returns with acquired P.
func stopm() {
gp := getg()
if gp.m.locks != 0 {
throw("stopm holding locks")
}
if gp.m.p != 0 {
throw("stopm holding p")
}
if gp.m.spinning {
throw("stopm spinning")
}
lock(&sched.lock)
mput(gp.m) // 把 m 结构体对象放入 sched.midle 空闲队列
unlock(&sched.lock)
mPark() // 睡眠和被唤醒
acquirep(gp.m.nextp.ptr()) // 绑定 m 和 p
gp.m.nextp = 0 // 重置 nextp
}
// mPark causes a thread to park itself, returning once woken.
//
//go:nosplit
func mPark() {
gp := getg()
notesleep(&gp.m.park) // 进入睡眠状态
noteclear(&gp.m.park) // 被其它工作线程唤醒
}
当 M' 被唤醒&被 CPU 调度执行时,代码从 mPark 函数中 noteclear(&gp.m.park) 开始执行:首先清除 park 信息,结束睡眠;把 M' 和唤醒之前获取的 P(在 nextp 存着) 绑定,设置 p 的状态从 _Pidle 变为 _Prunning ,重置 nextp;通过 goto top 跳转,继续寻找可执行的 G,开始下一次调度循环 schedule。3.3.2 创建新 M我们把思路拉回到 startm 函数,如果没有正处于休眠状态的工作线程,则需要调用 newm 函数新建一个工作线程。源码:runtime/proc.go//go:nowritebarrierrec
func newm(fn func(), pp *p, id int64) {
acquirem()
mp := allocm(pp, fn, id) // 创建 m,并分配内存,初始化 g0 与 m 绑定
mp.nextp.set(pp) // nextp 设置要绑定 p,后边直接用就行
...
newm1(mp)
releasem(getg().m)
}
func newm1(mp *m) {
// 省略 cgo 相关代码.......
execLock.rlock() // Prevent process clone.
newosproc(mp)
execLock.runlock()
}newm 首先调用 allocm 函数从堆上分配一个 m 结构体对象,设置 mp.mstartfn = fn,此时 fn = mspinning,创建 g0 对象,并为 g0 申请 8KB 栈内存,绑定 m 和 g0,m.nextp 设置要绑定 p,以便后续可以直接绑定 p;然后调用 newm1 函数。newm1 继续调用 newosproc 函数,newosproc 的主要任务是调用 clone 函数创建一个系统线程,而新建的这个系统线程将从 mstart 函数(clone 函数的第五个参数,会在汇编中被调用)开始运行。源码:runtime/os_linux.go 163// May run with m.p==nil, so write barriers are not allowed.
//
//go:nowritebarrier
func newosproc(mp *m) {
stk := unsafe.Pointer(mp.g0.stack.hi)
...
ret := retryOnEAGAIN(func() int32 {
r := clone(cloneFlags, stk, unsafe.Pointer(mp), unsafe.Pointer(mp.g0), unsafe.Pointer(abi.FuncPCABI0(mstart)))
// clone returns positive TID, negative errno.
// We don't care about the TID.
if r >= 0 {
return 0
}
return -r
})
...
}
// clone系统调用的 Flags 选项
cloneFlags = _CLONE_VM | /* share memory */ //指定父子线程共享进程地址空间
_CLONE_FS | /* share cwd, etc */
_CLONE_FILES | /* share fd table */
_CLONE_SIGHAND | /* share sig handler table */
_CLONE_SYSVSEM | /* share SysV semaphore undo lists (see issue #20763) */
_CLONE_THREAD /* revisit - okay for now */ //创建子线程而不是子进程clone 函数是由汇编语言实现的,该函数使用 clone 系统调用完成创建系统线程的核心功能。这个地方很重要,我们详细分析一下,源码:runtime/sys_linux_amd64.s 558// int32 clone(int32 flags, void *stk, M *mp, G *gp, void (*fn)(void));
TEXT runtime·clone(SB),NOSPLIT,$0
MOVL flags+0(FP), DI // 参数一:cloneFlags
MOVQ stk+8(FP), SI // 参数二:stk
MOVQ $0, DX
MOVQ $0, R10
MOVQ $0, R8
// Copy mp, gp, fn off parent stack for use by child.
// Careful: Linux system call clobbers CX and R11.
MOVQ mp+16(FP), R13 // 参数三:m
MOVQ gp+24(FP), R9 // 参数四:g0
MOVQ fn+32(FP), R12 // 参数五:mstart 函数
CMPQ R13, $0 // m,该场景下 m != 0
JEQ nog1
CMPQ R9, $0 // g,该场景下 g != 0
JEQ nog1
LEAQ m_tls(R13), R8
#ifdef GOOS_android
// Android stores the TLS offset in runtime·tls_g.
SUBQ runtime·tls_g(SB), R8
#else
ADDQ $8, R8 // ELF wants to use -8(FS)
#endif
ORQ $0x00080000, DI //add flag CLONE_SETTLS(0x00080000) to call clone
nog1:
MOVL $SYS_clone, AX // AX 存入系统调用 SYS_clone
SYSCALL // 执行系统调用,进入内核
// 虽然这里只有一次 clone 调用,但它却返回了2次,
// 一次返回到父线程,一次返回到子线程,然后 2 个线程各自执行自己的代码流程。
// In parent, return.
CMPQ AX, $0 // 返回值如果是 0 则表示这是子线程
JEQ 3(PC) // 跳转到子线程部分,往下跳 3
MOVL AX, ret+40(FP) // 给父线程准备返回值到 AX
RET // return 到父线程
// In child, on new stack.
MOVQ SI, SP // 设置 CPU 栈顶寄存器指向子线程的栈顶 stk
// If g or m are nil, skip Go-related setup.
// m,新创建的m结构体对象的地址,由父线程保存在R8寄存器中的值被复制到了子线程
CMPQ R13, $0 // m
JEQ nog2
// g,m.g0的地址,由父线程保存在R9寄存器中的值被复制到了子线程
CMPQ R9, $0 // g0
JEQ nog2
// Initialize m->procid to Linux tid
// 通过gettid系统调用获取线程ID(tid)
MOVL $SYS_gettid, AX
SYSCALL // 执行系统调用,进入内核
MOVQ AX, m_procid(R13) // 设置 m.procid = tid
// In child, set up new stack
get_tls(CX) // 获取当前线程的 TLS 地址
MOVQ R13, g_m(R9)ux_amd // g0.m = m
MOVQ R9, g(CX) // tls.g = g0
MOVQ R9, R14 // set g register R14 = g0
CALL runtime·stackcheck(SB) // 栈检查
nog2:
// Call fn. This is the PC of an ABI0 function.
CALL R12 // call mstart 函数,非错误情况不返回,会进入调度循环
// It shouldn't return. If it does, exit that thread.
// 发生错误而返回,需要退出线程
MOVL $111, DI
MOVL $SYS_exit, AX
SYSCALL
JMP -3(PC) // keep exitingclone 函数的执行步骤解析:首先用了几条指令为 clone 系统调用准备参数,存储到父线程寄存器中;第一个参数和第二个参数分别用来指定内核创建线程时需要的选项和新线程应该使用的栈。新线程使用的栈为 m.g0.stack.lo~m.g0.stack.hi 这段内存。参数三和参数四分别是与新线程绑定的 m对象和 g0,参数五表示 mstart 函数,存储在 R12 寄存器中,这个后边会用到。使用 SYSCALL 指令进入系统内核,通过 SYS_clone 系统调用创建子线程;父子线程共享进程地址空间,父线程的寄存器会被复制一份给子线程,这样参数就会随着寄存器被传递。SYS_clone 系统调用创建完子线程后,会返回两次结果,一次返回到父线程,一次返回到子线程,然后 2 个线程各自执行自己的代码流程;当返回值 AX = 0 时,表示为子线程,否则为父线程;父线程结束 clone 任务后,将返回值放入 AX 中,最终执行了 RET,至此父线程回到 newosproc 函数继续执行其他逻辑。子线程则跳转到后面的代码继续执行,进行后续的初始化工作,首先进行了栈内存的切换工作;该场景下寄存器中的 m 和 g0 不为 0,所以略过检查函数;随后通过系统调用获取子线程 ID,绑定到 m.procid;绑定 m 和 g0,使用 get_tls(CX) 获取当前线程的 TLS 地址,使用 MOVQ R9, g(CX) 绑定线程 tls 和 g0 的关系,R14 寄存器指向了 g0。最后通过 CALL R12 调用 mstart,此后整个调度循环就可以运行起来了,至于 mstart 又是如何启动调度循环的,请移步到下一小节内容。拓展知识这里细心的同学会发现一个问题:系统线程的本地存储 TLS 地址指向了 g0,那 TLS 地址哪里的来的?换句话说:系统线程的本地存储存到什么地方去了(系统线程的 FS 段的段基址指向哪里了呢)?按理来说 TLS 需要和 m.tls[1] 地址绑定,这样系统线程的 FS 段的段基址(TLS)就有地方存了,就能使用了;比如在启动第一个系统线程时,明明使用 runtime·settls(SB)对系统线程 TLS 和 m.tls[1] 进行了绑定,这里为什么没有绑定呢?其实是因为这里有几条指令,我没有给出解释: LEAQ m_tls(R13), R8
#ifdef GOOS_android
// Android stores the TLS offset in runtime·tls_g.
SUBQ runtime·tls_g(SB), R8
#else
ADDQ $8, R8 // ELF wants to use -8(FS)
#endif
ORQ $0x00080000, DI //add flag CLONE_SETTLS(0x00080000) to call clone
LEAQ m_tls(R13), R8 R8 寄存器指向 m.tls 地址;对于 GOOS_android 系统,存储 tls 地方在 R8 - runtime·tls_g(SB);对于其他系统都是使用 ELF(二进制文件格式)格式执行文件,需要偏移 ADDQ $8, R8,也就是 m.tls[1] 的地址;最后使用 ORQ $0x00080000, DI 指令,ORQ 表示“按位或逻辑”,DI 指向 clone-flag 参数,0x00080000 = 100000000;这个指令表示在 clone 系统调用执行过程中加入 flag 参数 CLONE_SETTLS。CLONE_SETTLS 是一个标志,用于 Linux 中的 clone() 系统调用,它允许一个新创建的进程(通常是一个子进程)设置其自己的线程本地存储(TLS)。而 R8 寄存器估计就是在 clone 系统调用执行时传递的参数了,这样也就实现了与 runtime·settls(SB)指令相同的功能。4.一个线程的基本调度流程前面讲述了 G、M、P 三个重要对象的启动或唤醒过程,接下来本小节将从整体角度带大家串一下最核心的循环调度流程是如何启动的,每个工作线程的执行流程和调度循环都一样,如下图所示:上一小节讲到在一个新线程被创建、初始化完毕后,调用了 mstart 函数,mstart 直接调用了 mstart0 函数,mstart0 函数在初始化 g0 的 stackguard0、stackguard1 属性后,继续调用了 mstart1 函数:// mstart is the entry-point for new Ms.
// It is written in assembly, uses ABI0, is marked TOPFRAME, and calls mstart0.
func mstart()
func mstart0() {
gp := getg()
...
// Initialize stack guard so that we can start calling regular
// Go code.
gp.stackguard0 = gp.stack.lo + _StackGuard
// This is the g0, so we can also call go:systemstack
// functions, which check stackguard1.
gp.stackguard1 = gp.stackguard0
mstart1()
// Exit this thread.
if mStackIsSystemAllocated() {
// Windows, Solaris, illumos, Darwin, AIX and Plan 9 always system-allocate
// the stack, but put it in gp.stack before mstart,
// so the logic above hasn't set osStack yet.
osStack = true
}
mexit(osStack)
}我们继续看一下 mstart1 函数的代码:func mstart1() {
gp := getg()
if gp != gp.m.g0 {
throw("bad runtime·mstart")
}
// 初始化 gp.sched
gp.sched.g = guintptr(unsafe.Pointer(gp))
gp.sched.pc = getcallerpc() // 获取 mstart1 执行完的返回地址
gp.sched.sp = getcallersp() // 获取调用 mstart1 时的栈顶地址
asminit() // 在AMD64 Linux平台中,这个函数什么也没做,是个空函数
minit() // 与信号相关的初始化,目前不需要关心
// Install signal handlers; after minit so that minit can
// prepare the thread to be able to handle the signals.
if gp.m == &m0 {
// main 启动时_g_.m 是 m0,目前不是
mstartm0()
}
if fn := gp.m.mstartfn; fn != nil {
// 这个场景下:fn = mspinning,m 设置为自旋状态
fn()
}
if gp.m != &m0 {
acquirep(gp.m.nextp.ptr()) // 绑定 m 和 p
gp.m.nextp = 0
}
// 启动调度循环
schedule()
}mstart1 函数执行主要流程:在 mstart1 函数中设置 g0.sched.sp 和 g0.sched.pc 等调度信息,其中 g0.sched.sp 指向 mstart1 函数栈帧的栈顶,g0.sched.pc 指向 mstart1 函数执行完的返回地址,也就是 mstart0 函数中调用 mstart1 函数返回后的下一行指令的地址,对应着退出线程。执行 gp.m.mstartfn 函数,此时对应 mspinning 函数,设置 m 为自旋状态。非 m0 工作线程需要重新绑定空闲的 p,这里的 p 在创建线程的时候,事先放到了 m.nextp 中。接着调用 schedule 函数,开始调度。4.1 schedule 流程我们继续看一下 schedule 的核心代码:// One round of scheduler: find a runnable goroutine and execute it.
// Never returns.
func schedule() {
mp := getg().m
...
top:
pp := mp.p.ptr()
pp.preempt = false
// Safety check: if we are spinning, the run queue should be empty.
// Check this before calling checkTimers, as that might call
// goready to put a ready goroutine on the local run queue.
if mp.spinning && (pp.runnext != 0 || pp.runqhead != pp.runqtail) {
throw("schedule: spinning with local work")
}
// 调度策略 findRunnable :获取一个可运行的 G
// 获取不到 G ,会通过 stopm() 进入休眠状态,和前面 M 的唤醒连接起来了
// 具体如何获取,下一篇再讲
gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available
// 如果 m 为自旋状态,设置为非自旋(因为找到 G 了),然后尝试唤醒一个 p
if mp.spinning {
resetspinning()
}
...
// 非普通 G 如 GCworker,可以尝试唤醒一个 P
if tryWakeP {
wakep()
}
...
execute(gp, inheritTime)
}在 schedule 函数中根据调度策略(findRunnable 函数,这里就不展开了)选择一个可运行的 g;随后调用 execute 函数,执行调度。func execute(gp *g, inheritTime bool) {
mp := getg().m
...
// 绑定 m 和 g
mp.curg = gp
gp.m = mp
casgstatus(gp, _Grunnable, _Grunning) // g 设置为正在运行状态
...
// 切换 g0 到 g,执行用户代码
gogo(&gp.sched)
}在 execute 函数中:先将 g 和 m 绑定起来;然后将 g 由 _Grunnable 改变为 _Grunning;调用 gogo 函数,切换 g 的执行权。4.2 gogo 函数到这里调度器又要开大招了,gogo 函数是一个非常关键的函数,与其对应的还有一个 mcall 函数,我们先来看一张图:在整个调度流程中,存在很关键的一点,就是关于 g0 和 g 执行权和栈的相互切换,如上图所示,g0 切换到 g 使用 gogo()函数,而 g 切换回 g0 使用 mcall(fn func(*g))函数,这俩函数实现原理类似,只不过过程刚好相反,这里我们先分析 gogo 源码,后续再看 mcall:(不知道你有没有看吐,我已经快写吐了,哈哈哈,在坚持一下!)源码:runtime/asm_amd64.s 401// func gogo(buf *gobuf)
// restore state from Gobuf; longjmp
TEXT runtime·gogo(SB), NOSPLIT, $0-8
MOVQ buf+0(FP), BX // gobuf 对应 gp.sched
MOVQ gobuf_g(BX), DX // DX = gp.sched.g
MOVQ 0(DX), CX // make sure g != nil
JMP gogo<>(SB) // 跳转
TEXT gogo<>(SB), NOSPLIT, $0
get_tls(CX) // 获取线程本地存储地址,里边存着 g0
MOVQ DX, g(CX) // 把当前 g 写入 tls,替代 g0
MOVQ DX, R14 // set the g register R14 寄存器一直存m 当前使用的 g
// restore SP 切换 CPU 的 SP 栈顶到 g 的栈顶 gobuf_sp,完成栈的切换
MOVQ gobuf_sp(BX), SP
MOVQ gobuf_ret(BX), AX // 系统调用的返回值放入AX寄存器
MOVQ gobuf_ctxt(BX), DX
MOVQ gobuf_bp(BX), BP // 恢复了 CPU 的栈基地址寄存器 BP
// 相关寄存器都放入 CPU 寄存器了,不需要的成员设置为0,这样可以减少gc的工作量
MOVQ $0, gobuf_sp(BX) // clear to help garbage collector
MOVQ $0, gobuf_ret(BX)
MOVQ $0, gobuf_ctxt(BX)
MOVQ $0, gobuf_bp(BX)
// 把 gp.sched.pc 的值读取到BX寄存器
MOVQ gobuf_pc(BX), BX
JMP BX // 执行 gp.sched.pc我们来看看 gogo 都干了些什么:g0 调用 gogo() 函数时,首先将线程 tls 的 g0 替换为了 g;然后通过设置 CPU 的栈顶寄存器 SP 为 g.sched.sp,实现了从 g0 栈到 g 栈的切换;保存了其他 gobuf 内的寄存器到 CPU 对应的寄存器,为后续调用 g 做准备;最后从 g 中取出 g.sched.pc 的值,并通过 JMP 指令从 runtime 代码直接跳转到用户代码执行,完成了 CPU 执行权的转让。还记得 g.sched.pc 指向的了啥不?不记得可以回头再看看 G 的创建,g.sched.pc 指向了 go 关键字后边的函数的 fn.fn 指针,也就是执行的第一条指令,cpu 从这里开始运行起来了用户程序代码!!!(皆大欢喜)4.3 G 运行用户程序代码讲述了 CPU 如何运行了 G 所持有的用户代码,当用户代码正常运行结束,又会发生什么呢?(这里暂时不考虑抢占、主动调度和被动调度的情况)我们都知道 go 关键字后边是一个函数 func,自然会有其对应的函数栈调用,当其运行结束,自然会调用 RET 指令,回到 return address 处继续执行(不理解的可以看 13. 入门 go 语言汇编,看懂 GMP 源码 这篇文章),那 return address 又指向的了哪里呢?请看 G 的创建小节的内容,我们会发现 return address 指向了 CALL runtime·goexit1(SB) 。因此正常结束的 G 会从这里继续开始执行 goexit1 函数:源码地址:src/runtime/proc.go : 3634// Finishes execution of the current goroutine.
func goexit1() {
if raceenabled {
racegoend()
}
if trace.enabled {
traceGoEnd()
}
mcall(goexit0)
}我们会发现 goexit1 函数继续调用了 mcall(goexit0),这个函数在讲 gogo 的时候已经提到过了,我们来分析一下吧!4.4 mcall(goexit0) 函数mcall 主要作用是切换 g0 的执行权和栈内存,然后执行 goexit0 函数。源码:runtime/asm_amd64.s 424// func mcall(fn func(*g))
// Switch to m->g0's stack, call fn(g).
// Fn must never return. It should gogo(&g->sched)
// to keep running g.
TEXT runtime·mcall<ABIInternal>(SB), NOSPLIT, $0-8
MOVQ AX, DX // DX = fn 它是funcval对象的指针,此场景中fn.fn是 goexit0 的地址
// save state in g->sched
// 保存 g 的 sched 状态,有可能还切换回来
MOVQ 0(SP), BX // caller's PC 存储到 BX
// R14 里存储着 g
MOVQ BX, (g_sched+gobuf_pc)(R14) // g.sched.pc = caller's PC
LEAQ fn+0(FP), BX // caller's SP 存储到 BX
MOVQ BX, (g_sched+gobuf_sp)(R14) // g.sched.sp = caller's SP
MOVQ BP, (g_sched+gobuf_bp)(R14) // g.sched.bp = caller's BP
// switch to m->g0 & its stack, call fn
MOVQ g_m(R14), BX // BX = m
MOVQ m_g0(BX), SI // SI = g.m.g0
CMPQ SI, R14 // if g == m->g0 call badmcall(g 不能是 g0)
JNE goodm
JMP runtime·badmcall(SB)
goodm:
MOVQ R14, AX // AX (and arg 0) = g
MOVQ SI, R14 // g = g.m.g0; R14 存入 g0
get_tls(CX) // Set G in TLS
MOVQ R14, g(CX) // tls 存入 g0
MOVQ (g_sched+gobuf_sp)(R14), SP // sp = g0.sched.sp 切换 CPU 栈顶
PUSHQ AX // open up space for fn's arg spill slot
MOVQ 0(DX), R12 // R12 = goexit0
CALL R12 // fn(g) 执行 goexit0,这里不会返回
POPQ AX
JMP runtime·badmcall2(SB)
RET我们来看看 mcall 函数都干了什么:保存当前 g 的现场环境 g.sched 中寄存器们的值,有些场景还需要唤醒 g (比如 channel);校验 g !=g0;切换 tls 的 g 为 g0;切换 g0 的栈(由于每次调用 mcall 函数切换到 g0 栈时,都是切换到 g0.sched.sp 所指的固定位置,因此 g0 栈内存是覆盖重复使用的,不会因为函数不返回问题导致爆栈);调用 goexit0 函数。4.5 goexit0 函数快结束了,我们继续看看 goexit0 又干了什么?// goexit continuation on g0.
func goexit0(gp *g) {
mp := getg().m
pp := mp.p.ptr()
casgstatus(gp, _Grunning, _Gdead) // g马上退出,所以设置其状态为_Gdead
gcController.addScannableStack(pp, -int64(gp.stack.hi-gp.stack.lo))
if isSystemGoroutine(gp, false) {
sched.ngsys.Add(-1)
}
// 清空 g 保存的一些信息
gp.m = nil
locked := gp.lockedm != 0
gp.lockedm = 0
mp.lockedg = 0
gp.preemptStop = false
gp.paniconfault = false
gp._defer = nil // should be true already but just in case.
gp._panic = nil // non-nil for Goexit during panic. points at stack-allocated data.
gp.writebuf = nil
gp.waitreason = waitReasonZero
gp.param = nil
gp.labels = nil
gp.timer = nil
...
dropg() // g->m = nil, m->currg = nil 解绑 g 和 m 之间的关系
...
// g 放入 p 的 freeg 队列,方便下次重用,免得再去申请内存,提高效率
gfput(pp, gp)
...
// 开启调度循环
schedule()
}goexit0 函数主要逻辑:设置 G 状态为_Gdead;重置 G 保存的信息,以便下一次复用;解绑 G 和 M 的关系;G 被放入 P 的 freeg,等待下一次复用;调用 schedule 开启调度循环。至此一条函数循环调用链形成:gogo -> go(用户程序) ->goexit -> goexit1 -> mcall(goexit0) -> goexit0(gp *g) -> schedule();5.GMP 状态变更我们已经初步的了解了一部分 GMP 源码,这里对 G、M、P 涉及的状态变化做一个总结,以便你更好地理解 GMP 调度过程!5.1 G 的状态变更本文通过对 G 的创建、调度的分析,我们能得出如下一张 G 状态变更图:通过这张图,我们发现本篇文章只涉及 G 的四个状态变更,还有两个重要的状态还没有聊到,后续我们慢慢补全!我们先总结一下本篇文章涉及到的内容:go 关键字会触发 newproc 函数创建 G,如果 p.freeg 中包含 G 则不需要重新申请内存,直接复用 G,此时 G 为 _Gdead 状态;否则使用 malg 函数创建一个 G,此时 G 为 _Gidle, malg 为 G 申请完内存后,更改其状态为 _Gdead。随后通过 newproc1 函数为 G 初始化 g.sched 的相关运行环境参数,初始化完成后 G 变为可执行 _Grunnable,随后通过 runqput 函数将其放入可执行队列中,等待被调度执行。M 通过 schedule 选择一个可以执行的 G,放入 execute 函数中执行,此时 G 状态被变更为 _Grunning,通过 gogo 切换执行权限后,G 开始顺利执行。G 顺利执行完毕,回调 goexit1 -> mcall(goexit0) -> goexit0(gp *g) 函数,最终重置 G 的参数,将 G 加入 p.freeg 队列中,等待被复用,此时 G 状态为 _Gdead。5.2 M 的状态变更M 的状态可以简化为只有两种:自旋和非自旋;自旋状态,表示 M 绑定了 P,却从 P 和全局运行队列都没有获取到可运行的 G,处于寻找 G 的状态;非自旋状态,表示正在执行 Go 代码中,或正在进入系统调用,或空闲。M 的自旋数量是判断是否唤醒空闲 P 的关键参数!本文讲到,通过 startm -> newm 新建一个 M 的时候,初始状态为非自旋状态,调用 mstart1 -> mspinning 设置为自旋状态。当 schedule 函数中找到可运行的 G 时,则切换为非自旋状态。5.3 P 的状态变更本文涉及的 P 状态变更只有一个,使用 acquirep 函数,设置 p 的状态从 _Pidle� 变为 _Prunning。其他状态我们暂未涉及,后续文章再补齐状态变更!总结Go 语言有强大的并发能力,能够简单的通过 go 关键字创建大量的轻量级协程 Goroutine,帮助程序快速执行各种任务,今天带大家把 GMP 调度器的部分底层源码学习一遍,收获颇深,这里来总结一下本篇文章的重要内容!为了解决 Go 早期多线程 M 对应多协程 G 调度器的全局锁带来的锁竞争导致的性能下降等问题,Go 开发者引入了处理器 P 结构,形成了当前经典的 GMP 调度模型,该模型引入了本地运行队列(可以通过 CAS 做到无锁访问)和局部优先原则。Go 调度器指的是由 G、M、P 以及 schedt 对象和函数等组成的一种机制,目的是高效地调度 G 到 M上去执行。P 数量一般和 CPU 数量保持一致,用于控制最大并行数量,M 必须得绑定 P 才能执行 G。Go 调度器的核心思想是:尽可能复用线程 M,避免频繁的线程创建和销毁;利用多核并行能力,限制同时运行(不包含阻塞)的 M 线程数 等于 CPU 的核心数目,也就是 P 的数量。调度策略本篇文章没有涉及到,会在后续文章专门分享,这里提前透露一下:M 优先执行其所绑定的 P 的本地运行队列中的 G,如果本地队列没有 G,则会从全局队列获取,为了提高效率和负载均衡,会从全局队列获取多个 G,而不是只取一个,个数是自己应该从全局队列中承担的;Work Stealing 任务窃取机制,M 可以从其他 M 绑定的 P 的运行队列偷取 G 执行;Hand Off 交接机制,为了提高效率,M 阻塞时,会将 M 上 P 的运行队列交给其他 M 执行;基于协作的抢占机制,为了保证公平性和防止 Goroutine 饥饿问题,Go 程序会保证每个 G 运行 10ms 就让出 M,交给其他 G 去执行。调度的局部性优化:新创建的 G 放入 runnext 槽中,被替换的 G 优先放入本地队列,在本地队列满了时,会将本地队列的一半 G 和新创建的 G 打乱顺序,一起放入全局队列。G、M、P 启动、运转的相关函数和状态变化,上边已经分析了,且画成了图,这里就不展开了。正常结束 G 的调度循环:gogo -> go(用户程序) ->goexit -> goexit1 -> mcall(goexit0) -> goexit0(gp *g) -> schedule();非正常结束的 G,我们后续再聊!下一篇文章我们将聊一聊在 Go 调度器中,main 函数主流程是如何加载启动的,启动过程中又发生了什么有趣的事情!
cathoy
16. Go调度器系列解读(三):GMP 模型调度时机
前言本文继续分享 Go 调度器系列文章第三篇:GMP 模型调度时机。前面已经分享了什么是 GMP,以及 GMP 如何启动的知识,接下来我们聊一聊 GMP 在哪些时机会触发 goroutine 调度。在本篇文章中,你可以了解到以下内容:GMP 的调度时机:正常调度、主动调度、被动调度和抢占调度GMP 不同调度时机的触发流程协助式抢占和异步信号抢占的实现过程handoff 的工作条件和触发时机系统调用执行前的准备工作和执行后的收尾工作调度触发过程中 G、P 状态的转换本文专业术语解释:G(Goroutine):Goroutine 是 Go 语言中的轻量级线程,由 Go 运行时管理。G 中存放并发执行的代码入口地址、上下文、运行环境(关联的 P 和 M)、运行栈等执行相关的信息。G 的新建、休眠、恢复、停止都受到 Go 运行时的管理。M(Machine):M 代表操作系统层面的线程,是真正执行计算资源的实体。M 仅负责执行,M 启动时进入运行时的管理代码,这段管理代码必须拿到 P 后,才能执行调度。P(Processor):P 代表处理器资源,是一种抽象的管理数据结构,主要作用是降低 M 对 G 的复杂性,增加一个间接的控制层数据结构。P 控制 Go 代码的并行度,它不是实体。P 持有 G 的队列,P 可以隔离调度,解除 P 和 M 的绑定,就解除了 M 对一串 G 的调用。GMP 模型的设计思想在于将 G(goroutine)与 M(machine)和 P(processor)结合使用,以实现高效的并发执行和资源管理。Go 调度器系列文章(阅读前面的文章,有助于理解本文细节内容):《13. 入门 go 语言汇编,看懂 GMP 源码》《14. Go调度器系列解读(一):什么是 GMP?》《15. Go调度器系列解读(二):Go 程序启动都干了些什么?》源码解读环境:Go 版本 1.20.7、linux 系统1.调度时机本小节将从整体角度介绍 GMP 的调度时机,主要分为正常调度、主动调度、被动调度和抢占调度四种情况,如下图所示:正常调度:g 顺利执行完成进入下一次调度,这应该是最常见的一种调度方式,由 g 正常运行结束后,切换 g0,处理 g 收尾工作,然后继续调用 schedule 函数开启下一次调度。主动调度:业务程序主动调用 runtime.Gosched 函数让出 CPU 而产生的调度。业务代码上主动触发 runtime.Gosched 函数,主动让出 CPU 产生调度,由 g 切换到 g0 ,继续调用 schedule 函数开启下一次调度。被动调度:g 执行业务代码时,因条件不满足需要等待阻塞,而发生的调度。例如:等待接收 channel 数据,但 channel 又没有数据的时候,就会发生 g 阻塞(channel 源码解读),此时 channel 源码中会调用 gopark 函数让出 CPU,而产生调度,由 g 切换到 g0 ,继续调用 schedule 函数开启下一次调度。抢占调度:由于 g 运行时间太长或长时间处于系统调用之中,被调度器剥夺运行权,从而发生的调度。sysmon 系统监控线程会定期通过 retake 函数对 goroutine 发起抢占:1.协助式抢占:针对 g 运行时间太长(一般是 10ms)的情况,retake 会设置抢占标志,随后由 g 进行扩栈检查时,根据抢占标志触发抢占调度,最终也是通过调用类似于 gosche_m 函数的方式主动放弃执行权,形成的调度;这种抢占方式有一个很明显的缺点:一个没有主动放弃执行权、且不参与任何函数调用的函数,直到执行完毕之前, 是不会被抢占的。2.信号异步抢占:针对 g 运行时间太长(一般是 10ms)的情况,retake 会在支持异步抢占的系统内,直接发送信号给 M,M 收到信号后实施异步抢占,最终也是通过调用类似于 gosche_m 函数的方式主动放弃执行权,形成的调度;这种抢占时为了解决由密集循环导致的无法抢占的问题。3.针对 g 长时间处于系统调用之中的情况,g 在进入系统调用时,会通过 runtime·entersyscall 函数解除 m 与 p 的绑定关系;retake 会定期检查所有 p,当满足一定条件时,会调用 handoffp 寻找新的工作线程来接管这个 p,通过抢占 p,实现抢占调度。接下来,我们就具体聊一聊每一个调度时机的细节!2.正常调度正常调度;g 顺利执行完成,并进入下一次调度循环,调度流程图如下:文章《14. Go调度器系列解读(一):什么是 GMP?》详细讲述了 GMP 对象的创建和一个线程的正常调度流程,这里我们简单复习一下:创建一个 G 的时候,运行时会在堆上为 G 分配自己的栈内存(g.stack.lo ~ g.stack.hi),如上图所示,其中 g.sched.sp 指向函数的返回地址(return address),也是 G 的栈顶地址,该地址指向 goexit + 1 的地址代表的指令:CALL runtime·goexit1(SB);其中 g.sched.pc 会指向 G 的任务函数地址,当调度到 G 时,就能执行用户代码。当 M 被启动运行后,会调用 mstart 函数,然后沿着 mstart -> mstart0 -> mstart1 -> schedule 函数调用链,一步步执行到 schedule 函数,schedule 函数会根据调度策略选择一个可运行的 G。随后沿着调用链 schedule -> execute -> gogo 执行到 gogo 函数,在 gogo 函数中将 CPU 执行栈从 g0 栈切换为 g 栈,通过 JMP 跳转到 g 的任务函数 g.sched.pc(go 关键字后边的函数的第一条指令地址)处,至此开始执行用户程序!用户程序执行结束时,会使用 RET 汇编指令将 PC 指向 return address 地址,该地址存储的是 CALL runtime·goexit1(SB),CPU 从此处开始继续执行。随后沿着调用链 goexit1 -> mcall(goexit0) 执行到了 mcall 函数,在 mcall 函数中将 CPU 执行栈从 g 栈切换为 g0 栈,并执行 goexit0 函数。在 goexit0 函数中设置 G 状态为_Gdead,并重置了 G 保存的信息,并将 G 放入 P 的 freeg,以便复用 G;解绑了 G 和 M 的关系,调用 schedule 开启下一次调度循环,重复 3 ~ 6 步骤。以上便是 G 正常调度循环过程,源码请参考文章《14. Go调度器系列解读(一):什么是 GMP?》,该文章包括以下内容:G、M、P 对象的创建和初始化gogo 源码详解mcall 源码详解goexit0 源码详解3.主动调度主动调度:业务程序主动调用 runtime.Gosched 函数让出 CPU 而产生的调度。源码:src/runtime/proc.go 317// Gosched yields the processor, allowing other goroutines to run. It does not
// suspend the current goroutine, so execution resumes automatically.
func Gosched() {
checkTimeouts()
mcall(gosched_m) // 切换到当前 m 的 g0 栈执行 gosched_m 函数
}
// Gosched continuation on g0.
// gp 为被调度的 g,而不是 g0
func gosched_m(gp *g) {
if trace.enabled {
traceGoSched()
}
goschedImpl(gp)
}
func goschedImpl(gp *g) {
status := readgstatus(gp)
if status&^_Gscan != _Grunning {
dumpgstatus(gp)
throw("bad g status")
}
// 放弃当前 g 的运行状态
casgstatus(gp, _Grunning, _Grunnable)
// 使当前 m 放弃 g
dropg() // 设置当前 m.curg = nil, gp.m = nil
lock(&sched.lock)
globrunqput(gp) // 把 gp 放入 sched 的全局运行队列 runq
unlock(&sched.lock)
schedule() // 进入新一轮调度
}Gosched 函数源码比较简单,当业务代码主动调用 runtime.Gosched() 函数时,会沿着函数调用链( runtime.Gosched -> mcall(gosched_m) -> gosched_m(gp *g) -> goschedImpl(gp *g) -> schedule )执行,直到开启下一次的调度循环,主要逻辑如下:将 G 状态切换为 _Grunnable;dropg 释放 M 当前运行的 G;globrunqput 将 G 放入全局运行队列 sched.runq,G 可以等待下一次调度;调用 schedule 函数获取 G 并执行,M 继续开启下一次调度循环,调度流程图如下(由于主动调度和正常调度函数链流程类似,这里就不重新总结一遍了):4.被动调度被动调度:g 执行业务代码时,因条件不满足需要等待,而发生的调度。这里我们举个简单的例子演示一下:package main
import (
"fmt"
"time"
)
func main() {
c := make(chan int)
go func() {
time.Sleep(2 * time.Second)
c <- 1000
}()
x := <-c
fmt.Println(x)
}该程序启动时,main goroutine 首先会创建一个无缓存的 channel,然后启动一个新的 goroutine,2秒后向 channel 发送数据;main goroutine 等待去读取这个 channel,此时 main goroutine 会因为 channel 没有数据,而等待 2 秒,2 秒后唤醒 main goroutine,读取数据继续执行,打印读取到的数据,最后结束程序。4.1 G 阻塞等待关于 channel 阻塞 goroutine 的源码(channel 源码解读),之前的文章详细分析过,无关代码就直接省略了,这里我们重点关注一下 channel 阻塞的逻辑:源码:src/runtime/chan.go 457func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
...
lock(&c.lock) // 锁住 chan,控制并发
...
gp := getg() // 获取 g
mysg := acquireSudog() // 初始化一个 sudog 对象
...
mysg.elem = ep // mysg.elem 用于接收数据
mysg.g = gp // mysg 绑定 g
...
c.recvq.enqueue(mysg) // mysg 入队 chan 接收等待队列
...
// 切换调度协程
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2)
...
}
// gopark 函数的等待解锁参数
func chanparkcommit(gp *g, chanLock unsafe.Pointer) bool {
...
unlock((*mutex)(chanLock)) // 解锁
return true
}
// runtime/proc.go 364
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
...
mp := acquirem() // 获取当前 m
...
mp.waitlock = lock // lock = unsafe.Pointer(&c.lock)
mp.waitunlockf = unlockf // 设置等待解锁的函数
// can't do anything that might move the G between Ms here.
mcall(park_m)
}channel 接收源码关于阻塞接收的主要逻辑:chanrecv 函数用于在 channel 上接收数据,<-c 会触发该函数调用;sudog 是对 goroutine 和 channel 对应关系的一层封装抽象,以便于 goroutine 可以同时阻塞在不同的 channel 上;在 chanrecv 函数中,g 被绑定到 sudog 对象中,并存入了 chan.recvq 接收等待队列;ep (数据接收对象)也被绑定到 sudog.elem 属性中,用于 g 被唤醒时,接收数据;chanparkcommit 函数用于后续的解锁,因为我们对 channel 上了锁 lock(&c.lock) ,只有解锁后才能继续后面的调度。随后调用 gopark 设置待解锁函数到 m 上,随后调用 mcall(park_m) 切换 g0,并执行 park_m 函数完成调度切换,接下来我们看一下 park_m 函数。// park continuation on g0.
func park_m(gp *g) {
mp := getg().m
...
casgstatus(gp, _Grunning, _Gwaiting)
dropg()
if fn := mp.waitunlockf; fn != nil {
ok := fn(gp, mp.waitlock) // 该场景下,完成 chan 的解锁 ok = true
mp.waitunlockf = nil
mp.waitlock = nil
if !ok {
...
casgstatus(gp, _Gwaiting, _Grunnable)
execute(gp, true) // Schedule it back, never returns.
}
}
schedule() // 开启下一次调度
}park_m 函数的主要作用是将指定的 g 结构体从运行状态(_Grunning)切换到等待状态(_Gwaiting),并释放其关联的 M 以供其他协程使用。以下是代码的详细解释:mp := getg().m:获取当前协程关联的 M 结构体。casgstatus(gp, _Grunning, _Gwaiting):使用比较并交换(CAS)操作将 gp 的状态从 _Grunning(运行中)切换到 _Gwaiting(等待中)。CAS 是一种原子操作,可以确保在多线程环境下数据的一致性。dropg():释放当前协程的 M 结构体,使其可以被其他协程使用。if fn := mp.waitunlockf; fn != nil { ... }:如果存在一个等待解锁的函数(通常用于在协程之间传递锁),则调用该函数。函数的返回值表示是否成功解锁。这里是对 chan 对象的解锁,这样别的协程才能用。if !ok { ... }:如果解锁失败,则将协程状态切换回 _Grunnable(可运行),并尝试重新调度该协程。schedule():进行调度,让其他协程开始运行。根据以上源码,总结被动调度流程图如下(依旧和前面两种调度时机的函数链流程类似,可以看出 Go 设计者对普通的调度时机做了统一的封装和流程规划,我们也应该学习这样写代码):4.2 G 被唤醒虽然被动调度讲到 G 阻塞等待,内容就已经讲完了,不过我写东西,喜欢有始有终,既然有了 G 阻塞,那必然就会有 G 唤醒的过程,因此想把知识点补全,接下来我们聊一聊 G 如何被唤醒。本章提供的 go 程序中,启动了另外一个协程,其中 c <- 1000 代码开启了 G 唤醒的过程。源码:src/runtime/chan.go 160func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
...
lock(&c.lock) // 加锁
...
if sg := c.recvq.dequeue(); sg != nil {
// 出队一个等待接收的 goroutine
// 将数据发送给等待接收的 sudog
send(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true
}
...
}上一小节中讲到 sudog 被 chanrecv 函数塞入了 c.recvq 队列;而 chansend 函数负责往 channel 发送数据,该段代码会尝试从 c.recvq 队列出队一个等待接收的 sudog,然后利用 send 函数发送数据,并将 sudog 中的 g 唤醒,接下来我们看一看 send 函数。// sg 表示接收方 goroutine
// ep 表示要发送的数据
func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
...
// 接收数据的地址不为空,则拷贝数据 (sg.elem 用来接收数据)
if sg.elem != nil {
sendDirect(c.elemtype, sg, ep)
sg.elem = nil
}
gp := sg.g // 从 sudog 获取 goroutine
unlockf() // 解锁 hchan (结合 chansend 函数加锁)
...
// 调用 goready 函数将接收方 goroutine 唤醒并标记为可运行状态
goready(gp, skip+1)
}这里解释一下 send 函数的主要逻辑:sg.elem 是接收数据的地址,用于保存接收到的数据(在 chanrecv 函数中已将 ep 绑定到 sg.elem);如果接收地址不为 nil,将数据发送到接收地址,就完成了数据的发送与接收。从 sg 中获取 g(g 也是在 chanrecv 函数中被绑定的);然后调用 goready 唤醒 g。源码:src/runtime/proc.go 390func goready(gp *g, traceskip int) {
// 切换系统栈,一般是 g0
systemstack(func() {
ready(gp, traceskip, true)
})
}
// Mark gp ready to run.
func ready(gp *g, traceskip int, next bool) {
...
status := readgstatus(gp)
// 获取 m,并加锁,禁止被抢占
mp := acquirem() // disable preemption because it can be holding p in a local var
...
// status is Gwaiting or Gscanwaiting, make Grunnable and put on runq
casgstatus(gp, _Gwaiting, _Grunnable) // g 状态改变
runqput(mp.p.ptr(), gp, next) // 优先放入 p.runnext
wakep() // 尝试唤醒一个 p
releasem(mp) // 释放 m 锁
}goready 函数调用了 ready 函数;在 ready 函数中,将 g 的状态从 _Gwaiting 转换为 _Grunnable,并通过 runqput 放入可运行队列,优先放入 p 的本地队列,这样 g 就能重新有机会被调度起来了;然后尝试通过 wakep 唤醒一个空闲的 p,这样可以增加并行,提升效率;当 g 得到调度时,会从 gopark 阻塞的地方接着执行,ep 带着数据就返回了,chanrecv 函数执行结束,这样 main goroutine 数据也就读取成功了。以上用到的函数我们都一行行分析过源码,想要深入了解的同学,可以参考这两篇文章:《channel 源码解读》 和 《14. Go调度器系列解读(一):什么是 GMP?》。5.抢占调度抢占调度:由于 g 运行时间太长或长时间处于系统调用之中,被调度器剥夺运行权,从而发生的调度。5.1 sysmon 监控线程启动函数为了看明白抢占的调度时机,让我们先深入了解 sysmon 函数以及 Golang 监控线程的工作内容。sysmon 是一个核心函数,它决定了 Go 的抢占时机。这个函数不依赖于 P(处理器),可以直接绑定在 M(机器)上执行。这意味着它可以独立于特定的处理器或核来运行,这有助于实现更细粒度的任务调度。在 runtime.main 函数中会启动一个 sysmon 监控线程,该线程启动会执行 sysmon 函数(线程启动可以参考文章 《14. Go调度器系列解读(一):什么是 GMP?》 ),且永远不会返回,接下来我们看一下源码。源码:src/runtime/proc.go 5297func sysmon() {
lock(&sched.lock)
sched.nmsys++
checkdead() // 检查死锁
unlock(&sched.lock)
lasttrace := int64(0) // 记录上一次调度器跟踪的时间
idle := 0 // 连续多少个周期没有抢占 g
delay := uint32(0) // 要暂停的微妙数
for {
if idle == 0 { // start with 20us sleep...
delay = 20
} else if idle > 50 { // start doubling the sleep after 1ms...
delay *= 2
}
if delay > 10*1000 { // up to 10ms 最多暂停 10ms
delay = 10 * 1000
}
usleep(delay) // 暂停当前执行的线程一段时间,单位微秒
now := nanotime()
// 调试变量未开启 && (至少有一个 g 在等待被 GC || P 都是空闲的,也就是没有 g 需要执行)
if debug.schedtrace <= 0 && (sched.gcwaiting.Load() || sched.npidle.Load() == gomaxprocs) {
lock(&sched.lock)
// 二次检查,确保数据一致性
if sched.gcwaiting.Load() || sched.npidle.Load() == gomaxprocs {
syscallWake := false
next := timeSleepUntil() // 所有 P 中最早的定时器到期时间
if next > now {
...
// 休眠一段时间(sleep 表示时间),时间到了自己苏醒
syscallWake = notetsleep(&sched.sysmonnote, sleep)
...
// 清理休眠的数据
noteclear(&sched.sysmonnote)
}
if syscallWake {
idle = 0
delay = 20
}
}
unlock(&sched.lock)
}
lock(&sched.sysmonlock)
...
// 如果网络没有被轮询超过10毫秒,那么就会进行网络轮询。
lastpoll := sched.lastpoll.Load() // 获取最后一次轮询的时间
// 网络已经初始化 && 上次的轮询时间不是零 && 自上次轮询以来已经过去了足够的时间 10ms
if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now {
sched.lastpoll.CompareAndSwap(lastpoll, now) // 更新最后一次轮询的时间
// 执行网络轮询,并返回一个包含可执行的 goroutine 的列表。
// 这是一个非阻塞操作,意味着它不会等待网络I/O完成。
list := netpoll(0) // non-blocking - returns list of goroutines
if !list.empty() {
// 如果返回的列表不是空的(即有goroutine在等待网络I/O完成)
// 减少空闲锁定的M的数量(为了模拟一个正在运行的M,防止死锁)
incidlelocked(-1)
injectglist(&list) // 注入等待的 goroutine 列表到调度器中
incidlelocked(1) // 增加空闲锁定的M的数量,恢复系统正常状态
}
}
...
// 唤醒 scavenge 垃圾回收器
if scavenger.sysmonWake.Load() != 0 {
// Kick the scavenger awake if someone requested it.
scavenger.wake()
}
// retake P's blocked in syscalls 重新获取因系统调用而被阻塞的 P
// and preempt long running G's 抢占长时间运行的 G
// 这里就是监控线程抢占调度的时机
if retake(now) != 0 {
idle = 0 // 触发 retake,则 idle 从 0 开始继续计数
} else {
idle++ // 否则 ++
}
// check if we need to force a GC 检查是否需要强制进行垃圾回收(基于时间触发的垃圾回收)
if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && forcegc.idle.Load() {
lock(&forcegc.lock)
forcegc.idle.Store(false) // 将forcegc.idle设置为非空闲状态
var list gList
list.push(forcegc.g) // 将 forcegc.g 添加到 list 中, forcegc.g 在 init 中启动
injectglist(&list) // 将 Goroutine 列表注入到调度器中,以便它们能够被执行
unlock(&forcegc.lock)
}
// 跟踪和调试 Go 语言的运行时调度器
if debug.schedtrace > 0 && lasttrace+int64(debug.schedtrace)*1000000 <= now {
lasttrace = now
schedtrace(debug.scheddetail > 0)
}
unlock(&sched.sysmonlock)
}
}sysmon 是一个无限循环,始终在后台运行,执行各种监控任务。下面总结一下该线程的主要工作:无限循环的执行有一个特点:一开始每次循环休眠 20us,但在 1ms 后,每次休眠时间会倍增,最终每一轮都会休眠 10ms。这种策略使得 sysmon 可以根据需要动态地调整其工作频率。在至少有一个 g 在等待被 GC 或 P 都是空闲的条件下,会休眠一段时间,减少资源消耗。检查上次网络轮询时间,如果超过10毫秒,那么就会进行网络轮询,通过 netpoll 函数获取 fd 事件,将可执行的 goroutine 加入到调度器中,让其可以得到调度和执行,这样就保证了网络 IO 的处理。sysmon 还会尝试唤醒 scavenger 对象(GC 机制中扮演重要角色),Scavenger 对象会收集和存储所有未被释放的内存块,并在垃圾回收器完成整个过程后,将这些内存块释放给操作系统。retake(抢占)是 sysmon 中的一个重要环节,它的主要任务是抢占当前运行的 G(Goroutine),以便将执行权切换给其他等待的 G。这样可以确保系统的资源能够更有效地分配给各个任务,从而提高整体性能。这也是本小节将要详细阐述的重点内容!!!sysmon 还负责监控垃圾回收器(GC)的活动,检查是否需要强制进行垃圾回收。当 GC 启动时,sysmon 会与之协同工作,确保 GC 在适当的时候运行,以减少对程序性能的影响。这种协调对于优化垃圾回收过程和避免不必要的停顿至关重要。sysmon 函数和 Golang 监控线程的工作涉及到系统监控、资源管理、垃圾回收协调和线程调度等多个方面。它们共同维护着系统的健康和性能,确保 Go 程序能够高效、稳定地运行。5.2 retake 触发抢占通过对 sysmon 函数的分析,我们可以知道,系统线程会定期通过 retake 函数对 goroutine 发起抢占,那么接下来,我们就一起来看看 retake 如何抢占 goroutine。源码:src/runtime/proc.go 5454func retake(now int64) uint32 {
n := 0
lock(&allpLock)
for i := 0; i < len(allp); i++ {
pp := allp[i] // 遍历所有的 p
if pp == nil {
continue
}
// 用于 sysmon 线程记录被监控 p 的系统调用时间和运行时间
pd := &pp.sysmontick
s := pp.status
sysretake := false // 是否需要进行系统抢占
if s == _Prunning || s == _Psyscall {
// 如果 P 处于运行或系统调用状态
// Preempt G if it's running for too long.
t := int64(pp.schedtick) // 获取 P 的调度时钟计数,调度一次则 +1
// 如果系统监控信息中的调度时钟与当前 P 的不一致,则更新系统监控信息
if int64(pd.schedtick) != t {
// 已经不是同一次调度时钟计数,更新监控线程信息
pd.schedtick = uint32(t)
pd.schedwhen = now
} else if pd.schedwhen+forcePreemptNS <= now {
// 还处于同一次调度 && 如果距离上次调度的时间已经超过一定阈值,则设置抢占标志
preemptone(pp)
// In case of syscall, preemptone() doesn't
// work, because there is no M wired to P.
// 系统调用前会解除 m 和 p 的关系,因此无法顺利执行 preemptone
sysretake = true
}
}
// 如果 P 处于系统调用状态
if s == _Psyscall {
// Retake P from syscall if it's there for more than 1 sysmon tick (at least 20us).
t := int64(pp.syscalltick) // 获取 P 的系统调用时钟计数
// 未进行系统抢占 && 系统监控信息中的系统调用时钟与当前 P 的不一致
// 则更新系统监控信息
if !sysretake && int64(pd.syscalltick) != t {
// 不是同一次系统调用了,需要更新信息,等待下一轮抢占
pd.syscalltick = uint32(t)
pd.syscallwhen = now
continue
}
// 运行队列为空 && 有自旋状态的 m 或 有空闲的 p && 距离监控线程记录的系统调用的时间大于一定阈值 10ms
if runqempty(pp) && sched.nmspinning.Load()+sched.npidle.Load() > 0 && pd.syscallwhen+10*1000*1000 > now {
continue
}
// Drop allpLock so we can take sched.lock.
unlock(&allpLock)
incidlelocked(-1)
// 尝试将 P 状态从 _Psyscall 改为 _Pidle 空闲
if atomic.Cas(&pp.status, s, _Pidle) {
...
n++ // 增加系统监控 retake 的空闲 P 数量
pp.syscalltick++ // 增加 P 的系统调用时钟计数
handoffp(pp) // 寻找一新的 m 接管 p
}
incidlelocked(1)
lock(&allpLock)
}
}
unlock(&allpLock)
return uint32(n) // 返回触发抢占的 P 数量
}我们分析一下 retake 函数的主要逻辑:遍历所有 P,检查是否满足抢占规则;针对 s == _Prunning 情况,如果同一 goroutine 的运行时间超过了10毫秒,则对需要抢占,使用 preemptone(pp) 设置抢占标志,处于系统调用状态是无法执行 preemptone 函数的,后续具体展开分析。针对 s == _Psyscall 情况,当前 goroutine 正在执行系统调用,满足三个条件就会使用 handoffp(pp) 寻找一新的 m 接管 p,三个条件如下:根据 retake 函数的逻辑,抢占调度分为两种情况:由于 g 运行时间太长,而发生的抢占,主要是为了防止出现饿死的协程;由于 g 长时间处于系统调用之中,而发生的抢占,主要是为了提高并发性能。通过对 retake 函数的分析,我们可以知道抢占触发的时机,但依然无法了解抢占设计的整体过程,那我们就从这两种场景出发,聊一聊 Go 中的抢占逻辑的全貌!5.3 preemptone 抢占逻辑通过上文分析,我们知道当 g 连续运行时间超过 10 ms 时,retake 会调用 preemptone 函数向该 g 发出抢占请求,其实这里并不是真正触发抢占调度的地方,而只是打上可抢占的标志,我们具体来看一下 preemptone 源码。源码:src/runtime/proc.go 5551func preemptone(pp *p) bool {
// 获取 p 绑定的 m,获取不到就返回 false
mp := pp.m.ptr()
if mp == nil || mp == getg().m {
return false
}
gp := mp.curg // 当前 g 不能是 g0
if gp == nil || gp == mp.g0 {
return false
}
gp.preempt = true // 设置可抢占标志
// Goroutine 中的每次调用都会通过将当前堆栈指针与 gp->stackguard0 进行比较
// 来检查堆栈溢出。 将 gp->stackguard0 设置为 StackPreempt
// 将抢占合并到正常的堆栈溢出检查中。
gp.stackguard0 = stackPreempt
// 如果支持异步抢占并且没有禁用异步抢占(只有 windouw 支持异步)
if preemptMSupported && debug.asyncpreemptoff == 0 {
pp.preempt = true
// 信号的发送,直接向需要进行抢占的 m 发送 SIGURG 信号
// m 会根据系统信号回调异步处理抢占
preemptM(mp)
}
return true
}preemptone 函数逻辑比较简单,涉及到两种不同的抢占方式:获取 p 绑定的 m,获取不到就返回 false,这种情况针对的是进入系统调用的 m,在进入系统调用之前会主动解除与 p 的关联,自然 p 就获取不到 m 了。设置抢占标记 gp.preempt = true ,并给栈扩张标记赋值 gp.stackguard0 = stackPreempt。没有主动触发抢占,而是等待当前 g 进行栈扩张检查时,由当前 g 主动放弃执行权。当操作系统支持异步抢占时,使用 preemptM 主动触发信号抢占,用于解决由密集循环导致的无法抢占的问题。这种抢占方式在 GC 执行 stw 时,也会用来暂停所有 g 的执行。5.3.1 由栈扩张触发抢占这种抢占调度是通过抢占标记的方式实现的,基本逻辑是在每个函数调用的序言 (汇编:函数调用的最前方)插入抢占检测指令,当检测到当前 Goroutine 被标记为被应该被抢占时, 则主动中断执行,让出执行权利。Go 采用的是动态扩缩栈的机制,扩缩机制也是经过演进的:在早些年间Go 运行时使用分段栈的机制:当一个 Goroutine 的执行栈溢出时,栈的扩张操作是在另一个栈上进行的,这两个栈地址没有连续,这种设计的缺陷很容易破坏缓存的局部性原理,从而降低程序的运行时性能。现在 Go 运行时开始使用连续栈机制,当一个执行栈发生溢出时, 新建一个两倍于原栈大小的新栈,再将原栈整个拷贝到新栈上,从而整个栈总是连续的。因此,为了实现动态扩缩栈,运行时需要为栈溢出做检查,而栈分段检查的代码是由编译器在预处理阶段插入的,在预处理阶段编译器会为没有被 go:nosplit 标记的函数的序言部分会插入分段检查的代码,从而在发生栈溢出的情况下, 触发 runtime.morestack_noctxt 调用。举个 main 函数的例子:package main
func main() {
sum(1, 2)
}
func sum(a, b int) int {
return a + b
}使用 go build -gcflags="-S -l -N" main.go 2> main.s 编译为汇编代码:main.main STEXT size=54 args=0x0 locals=0x18 funcid=0x0 align=0x0
0x0000 00000 (main.go:3) TEXT main.main(SB), ABIInternal, $24-0
0x0000 00000 (main.go:3) CMPQ SP, 16(R14)
0x0004 00004 (main.go:3) PCDATA $0, $-2
0x0004 00004 (main.go:3) JLS 47
0x0006 00006 (main.go:3) PCDATA $0, $-1
0x0006 00006 (main.go:3) SUBQ $24, SP
0x000a 00010 (main.go:3) MOVQ BP, 16(SP)
0x000f 00015 (main.go:3) LEAQ 16(SP), BP
0x0014 00020 (main.go:3) FUNCDATA $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
0x0014 00020 (main.go:3) FUNCDATA $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
0x0014 00020 (main.go:4) MOVL $1, AX
0x0019 00025 (main.go:4) MOVL $2, BX
0x001e 00030 (main.go:4) PCDATA $1, $0
0x001e 00030 (main.go:4) NOP
0x0020 00032 (main.go:4) CALL main.sum(SB)
0x0025 00037 (main.go:5) MOVQ 16(SP), BP
0x002a 00042 (main.go:5) ADDQ $24, SP
0x002e 00046 (main.go:5) RET
0x002f 00047 (main.go:5) NOP
0x002f 00047 (main.go:3) PCDATA $1, $-1
0x002f 00047 (main.go:3) PCDATA $0, $-2
0x002f 00047 (main.go:3) CALL runtime.morestack_noctxt(SB)
0x0034 00052 (main.go:3) PCDATA $0, $-1
0x0034 00052 (main.go:3) JMP 0从上边 main 函数的汇编源码可以看到,JLS 47指令可以跳转到 CALL runtime.morestack_noctxt(SB)处触发栈扩张检查,那我们来分析一下跳转条件:CMPQ SP, 16(R14)用于比较 SP 和 16(R14) 大小,当 SP 小于 16(R14) 时,会发生栈扩张检查,那 16(R14) 是什么呢?在文章《15. Go调度器系列解读(二):Go 程序启动都干了些什么?》中我们可以了解到 main 函数是在 gogo 函数从 g0 切换到 g 后,进行执行的;在文章《14. Go调度器系列解读(一):什么是 GMP?》中我们对 gogo 函数源码进行了详细分析,可以知道 R14 寄存器存储的是当前的 g,通过 g 的结构体,我们可以知道 16(R14) 为 g.stackguard0;所以函数调用的序言部分会检查 SP 寄存器与 stackguard0 之间的大小,如果 SP 小于 stackguard0 则会 触发 morestack_noctxt,触发栈扩张检查操作,所以如果把 stackguard0 设置的比任何可能得 SP 都要大时,就必然会触发 morestack_noctxt;而在 preemptone 函数中,我们知道 g.stackguard0 被设置为 stackPreempt(一个非常大的数 十六进制为:0xfffffade) ,因此一旦被标记为可抢占后,当前运行的 g 会通过函数序言检查栈扩张,进而触发抢占行为。从抢占调度的角度来看,这种发生在函数序言部分的抢占有一个重要目的,就是能够简单且安全的记录执行现场,我们一起来看一下 morestack_noctxt 函数:源码:src/runtime/asm_amd64.s 578// morestack but not preserving ctxt.
TEXT runtime·morestack_noctxt(SB),NOSPLIT,$0
MOVL $0, DX
JMP runtime·morestack(SB)
TEXT runtime·morestack(SB),NOSPLIT,$0-0
...
get_tls(CX) // 获取 tls
MOVQ g(CX), SI // SI = g
...
// Set g->sched to context in f.
// SP 栈顶寄存器现在指向的是 morestack_noctxt 函数的返回地址
// 保存 g.sched.gobuf 的执行现场,寄存器的值
MOVQ 0(SP), AX // f's PC
MOVQ AX, (g_sched+gobuf_pc)(SI)
LEAQ 8(SP), AX // f's SP
MOVQ AX, (g_sched+gobuf_sp)(SI)
MOVQ BP, (g_sched+gobuf_bp)(SI)
MOVQ DX, (g_sched+gobuf_ctxt)(SI)
// Call newstack on m->g0's stack.
// 切换到 g0 栈,并设置 tls 的 g 为 g0
MOVQ m_g0(BX), BX
MOVQ BX, g(CX)
MOVQ (g_sched+gobuf_sp)(BX), SP
// 执行之后 CPU 就开始使用 g0 的栈了,然后 call newstack
CALL runtime·newstack(SB)
CALL runtime·abort(SB) // crash if newstack returns
RETmorestack_noctxt 直接调用 morestack 函数,在 morestack 函数中,保存了 g 一系列执行现场,并将 SP 切换为 g0 栈,然后 call newstack 开始执行 newstack 函数。在这里可以看到,记录 g 的执行现场还是很简单的,我们继续看一下 newstack 函数。源码:src/runtime/stack.go 964func newstack() {
thisg := getg()
...
gp := thisg.m.curg
...
stackguard0 := atomic.Loaduintptr(&gp.stackguard0)
preempt := stackguard0 == stackPreempt
// 如果是发起的抢占请求,而非真正的栈扩张检查
if preempt {
// 如果正持有锁、分配内存或抢占被禁用,则不发生抢占
if !canPreemptM(thisg.m) {
// 不发生抢占,继续调度
gp.stackguard0 = gp.stack.lo + _StackGuard
gogo(&gp.sched) // 重新进入调度循环
}
}
...
if preempt {
...
// 如果需要对栈进行调整
if gp.preemptShrink {
// 我们正在一个同步安全点,因此等待栈收缩
gp.preemptShrink = false
shrinkstack(gp)
}
// 抢占时,过渡到 _Gpreempted 状态
if gp.preemptStop {
preemptPark(gp) // never returns
}
// 表现得像是调用了 runtime.Gosched,主动让权
gopreempt_m(gp) // never return
}
...
}
func canPreemptM(mp *m) bool {
return mp.locks == 0 && mp.mallocing == 0 && mp.preemptoff == "" && mp.p.ptr().status == _Prunning
}
func gopreempt_m(gp *g) {
...
goschedImpl(gp)
}分析一下上边代码的主要逻辑:判断抢占标志 stackguard0通过 canPreemptM 验证了可以被抢占的条件(标志 mp 是否处于可抢占的安全状态):可以进行抢占,则转入调用 gopreempt_m, 放弃当前 G 的执行权,将其加入全局队列,重新进入调度循环。gopreempt_m 类似于主动调度的 Gosched,和其执行逻辑一致。通过对这种协作式抢占的分析也可以看出,这种抢占是保守式的抢占,优先级低于运行时,还需要函数调用协作执行,所以这种抢占方式有一个很明显的缺点:一个没有主动放弃执行权、且不参与任何函数调用的函数,直到执行完毕之前, 是不会被抢占的。因此,为了解决这个问题,Go 后续推出了基于信号的抢占方式。5.3.2 信号抢占现代操作系统的调度器多为抢占式调度,其实现方式是通过硬件中断来支持线程的切换,进而能安全的保存运行上下文。Go 运行时实现的抢占调度也是类似于这样原理:首先向线程 M 发送信号进入内核;M 收到信号后中断代码的执行,检查信号是否有指定的信号处理函数;如果有则切换到用户态执行对应的信号处理函数,信号处理函数中修改执行的上下文环境(例如:更改 PC、SP 寄存器等),修改完后继续切换到内核;M 处理完中断,恢复到中断处继续执行,CPU 根据新的 PC 跳转到 asyncPreempt 继续执行,实现信号抢占调度。信号初始化和注册函数初始化Go 调度器系列文章中讨论过两种 m 的创建方式,一种是 m0 的创建(函数调用顺序:schedinit -> mcommoninit -> mpreinit -> sigsave -> initSigmask -> mstart);一种是普通的 m 的创建(函数调用顺序:newm -> allocm -> mcommoninit -> mpreinit -> newm1 -> newosproc -> mstart)。在 mcommoninit 函数中会调用 mpreinit 函数,最终为 M 创建一个 gsignal 协程,用于在 M 上处理信号。源码:runtime/proc.go 811func mcommoninit(mp *m) {
...
// 初始化 gsignal,用于处理 m 上的信号。
mpreinit(mp)
// gsignal 的运行栈边界处理
if mp.gsignal != nil {
mp.gsignal.stackguard1 = mp.gsignal.stack.lo + _StackGuard
}
...
}
// 从一个父线程上进行调用(引导时为主线程),可以分配内存
func mpreinit(mp *m) {
mp.gsignal = malg(32 * 1024) // OS X 需要 >= 8K,此处创建处理 singnal 的 g
mp.gsignal.m = mp // 指定 gsignal 拥有的 m
}在调度器的初始化的阶段 sigsave 会通过系统调用将主线程的屏蔽字保存到 m.sigmask,sigsave 执行完毕后,将 sigmask 保存到 initSigmask 这一全局变量中,用于初始化新创建的 M 的信号屏蔽字,在新创建 M 时,会调用 newm 将 M 的 sigmask 进行设置。源码:runtime/proc.go 677func schedinit() {
...
mcommoninit(gp.m, -1)
...
sigsave(&gp.m.sigmask)
initSigmask = gp.m.sigmask
...
}
// 源码 2184
func newm(fn func(), pp *p, id int64) {
...
mp.sigmask = initSigmask
...
}至此,m0 和 m1 函数调用都进入了 mstart,后续的函数调用顺序则相同:mstart -> mstart0 -> mstart1 -> minit -> mstartm0(只有 m0 才调用)-> schedule。这里我们只讨论信号的注册过程,minit 会调用 minitSignalMask 函数为 M 设置信号的屏蔽字,通过 sigmask 来获得当前 M 的屏蔽字,而后通过遍历所有运行时信号表来对屏蔽字进行初始化:源码:runtime/signal_unix.go 1240func minitSignalMask() {
nmask := getg().m.sigmask
for i := range sigtable {
// 判断某个信号是否为不可阻止的信号,
if !blockableSig(uint32(i)) {
// 如果是不可阻止的信号,则删除对应的屏蔽字所在位
// 不可阻止,意味着无法由信号处理函数处理,需要去除
sigdelset(&nmask, i)
}
}
// 重新设置屏蔽字
sigprocmask(_SIG_SETMASK, &nmask, nil)
}随后在 M0 上会调用 mstartm0,进而调用 initsig 初始化信号,对于一个需要设置 sighandler 的信号,会通过 setsig 来设置信号对应的处理函数 sigtramp。源码:runtime/signal_unix.go 114func initsig(preinit bool) {
...
for i := uint32(0); i < _NSIG; i++ {
fwdSig[i] = getsig(i) // 初始化信号处理函数 为 nil
...
// 设置信号处理函数
setsig(i, abi.FuncPCABIInternal(sighandler))
}
}
func getsig(i uint32) uintptr {
var sa usigactiont
sigaction(i, nil, &sa) // 通过系统调用设置信号处理函数
return *(*uintptr)(unsafe.Pointer(&sa.__sigaction_u))
}
func setsig(i uint32, fn uintptr) {
var sa usigactiont
sa.sa_flags = _SA_SIGINFO | _SA_ONSTACK | _SA_RESTART
sa.sa_mask = ^uint32(0)
if fn == abi.FuncPCABIInternal(sighandler) {
if iscgo {
fn = abi.FuncPCABI0(cgoSigtramp)
} else {
fn = abi.FuncPCABI0(sigtramp)
}
}
*(*uintptr)(unsafe.Pointer(&sa.__sigaction_u)) = fn
sigaction(i, &sa, nil) // 通过系统调用设置信号处理函数
}至此信号初始化完毕!信号发送在 preemptone 中我们聊到使用 preemptM 主动触发信号抢占,其实原理很简单,直接向需要进行抢占的 M 发送 SIGURG 信号即可:源码:runtime/signal_unix.go 368const sigPreempt = _SIGURG
func preemptM(mp *m) {
...
if mp.signalPending.CompareAndSwap(0, 1) {
if GOOS == "darwin" || GOOS == "ios" {
pendingPreemptSignals.Add(1)
}
signalM(mp, sigPreempt)
}
...
}
func signalM(mp *m, sig int) {
pthread_kill(pthread(mp.procid), uint32(sig))
}当监控线程 sysmon 向 M 线程发送 _SIGURG 信号后,M 捕获到信号,开始调用信号处理函数 sigtramp -> sigtrampgo -> sighandler:源码:runtime/sys_linux_386.s 431// Called using C ABI.
TEXT runtime·sigtramp(SB),NOSPLIT|TOPFRAME,$28
...
CALL runtime·sigtrampgo(SB)
...
RET源码:runtime/signal_unix.go 608func sigtrampgo(sig uint32, info *siginfo, ctx unsafe.Pointer) {
...
sighandler(sig, info, ctx, gp)
...
}
func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) {
gsignal := getg()
mp := gsignal.m
c := &sigctxt{info, ctxt}
...
// 处理抢占信号
if sig == sigPreempt && debug.asyncpreemptoff == 0 && !delayedSignal {
// Might be a preemption signal.
doSigPreempt(gp, c)
}
...
}
func doSigPreempt(gp *g, ctxt *sigctxt) {
// 检查 G 是否需要被抢占、抢占是否安全
if wantAsyncPreempt(gp) {
// isAsyncSafePoint 报告指令 PC 处的 gp 是否为异步安全点
if ok, newpc := isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()); ok {
// Adjust the PC and inject a call to asyncPreempt.
// 插入抢占调用,调整 PC 寄存器,让 go 运行时恢复时,
// 从 asyncPreempt 函数开始执行
ctxt.pushCall(abi.FuncPCABI0(asyncPreempt), newpc)
}
}
...
}
func (c *sigctxt) pushCall(targetPC, resumePC uintptr) {
// Make it look like we called target at resumePC.
sp := uintptr(c.rsp())
sp -= goarch.PtrSize
*(*uintptr)(unsafe.Pointer(sp)) = resumePC
c.set_rsp(uint64(sp)) // 设置 SP 寄存器
c.set_rip(uint64(targetPC)) // 设置 PC 寄存器
}在 sighandler 信号处理函数中,判断信号是否是 sigPreempt 抢占信号,然后调用 doSigPreempt 处理异步抢占,抢占之前需要判断抢占是否安全(过程是比较复杂的),然后通过 ctxt.pushCall 更改 SP、PC 等寄存器,改变 Go 代码的执行顺序,当中断处理结束时,从 asyncPreempt 函数开始正常执行!源码:runtime/preempt_amd64.sTEXT ·asyncPreempt(SB),NOSPLIT|NOFRAME,$0-0
...
CALL ·asyncPreempt2(SB)
...
RETasyncPreempt 调用了 asyncPreempt2 函数,asyncPreempt2 函数中的逻辑我们就相当熟悉了,mcall 用于切换 g 到 g0 栈,然后调用 preemptPark 或 gopreempt_m 函数继续开启下一次循环调度(源码自己看哈)。源码:runtime/preempt.go 301func asyncPreempt2() {
gp := getg()
gp.asyncSafePoint = true
if gp.preemptStop {
mcall(preemptPark)
} else {
mcall(gopreempt_m)
}
gp.asyncSafePoint = false
}再次总结一下异步抢占的整体过程:M 创建时,会注册信号处理回调函数 sigtramp;监控线程 sysmon 向线程 M 发送信号进入内核态;M 收到信号后中断代码的执行,保存上下文环境;处理中断,检查信号是否有指定的信号处理函数,如果有则切换到用户态,执行对应的信号处理函数 sigtramp;信号处理函数中会修改执行的上下文环境(更改 PC、SP 寄存器等),修改完后继续切换到内核,处理中断;M 处理完中断,返回用户代码 Go 程序继续执行,CPU 根据新的 PC 跳转到 asyncPreempt 继续执行;asyncPreempt 调用 asyncPreempt2 函数,会根据 preemptPark 或 gopreempt_m 函数进行抢占调度。5.4 handoffp 抢占逻辑当 P 状态为 _Psyscall 时,g 已经阻塞在系统调用上,此时 sysmon 会通过 retake 函数对 P 实施抢占,这种抢占方式被称之为 handoff,本质是抢占 P,为 P 重新寻找一个 M 继续执行,原来的 M 会阻塞在系统调用中。这里就涉及三个重要逻辑步骤:Go 进入系统调用的过程Go 系统调用完毕,退出系统调用的过程handoffp 抢占过程5.4.1 系统调用因为用户代码特权级较低,无权访问需要最高特权级才能访问的内核地址空间的代码和数据,因此用户代码想要访问内核数据,必须使用系统调用。Linux 系统调用为用户态进程提供了硬件的抽象接口,每个系统调用被赋予一个独一无二的系统调用号,当用户空间的进程执行一个系统调用时,会使用调用号指明系统调用;在 Go 中使用Syscall 函数进行系统调用。源码:syscall/syscall_linux.go 68func Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno) {
runtime_entersyscall()
r1, r2, err = RawSyscall6(trap, a1, a2, a3, 0, 0, 0)
runtime_exitsyscall()
return
}
通过源码可以发现,系统调用执行时,在系统调用执行的前后分别调用了 runtime_entersyscall 和 runtime_exitsyscall 两个函数,这两个函数刚好负责进入系统调用前的准备工作和系统调用结束后的收尾工作,我们一起来看一下。进入系统调用前的准备工作entersyscall 源码:runtime/proc.go 3843func entersyscall() {
reentersyscall(getcallerpc(), getcallersp())
}
func reentersyscall(pc, sp uintptr) {
gp := getg()
gp.m.locks++
/// 设置栈警戒值为 stackPreempt,表示在 syscall 期间可以发生抢占
gp.stackguard0 = stackPreempt
gp.throwsplit = true
// Leave SP around for GC and traceback.
save(pc, sp) // 保存 pc 和 sp 到当前 G 的栈中
gp.syscallsp = sp
gp.syscallpc = pc
casgstatus(gp, _Grunning, _Gsyscall) // 将当前 G 的状态切换为 _Gsyscall
...
pp := gp.m.p.ptr() // 获取当前 G 所在的 P
pp.m = 0 // 解除当前 P 和 M 之间的关联
gp.m.oldp.set(pp) // 把 P 记录在 oldp 中,等从系统调用返回时,优先绑定这个 P
gp.m.p = 0 // 解除当前 M 和 P 之间的关联
// 修改当前 P 的状态,sysmon 线程依赖状态实施抢占
atomic.Store(&pp.status, _Psyscall)
...
gp.m.locks--
}entersyscall 函数直接调用了 reentersyscall 函数,reentersyscall 首先把现场信息保存在当前 G 的 sched 成员中;然后解除 M 和 P 的绑定关系,这样 sysmon 线程就不需要加锁解除 M 和 P 的关系了,可以直接执行 handoffp 操作;并设置 P 的状态为_Psyscall,前面我们已经看到 sysmon监控线程需要依赖该状态实施抢占。系统调用结束后的收尾工作exitsyscall 源码:runtime/proc.go 3938func exitsyscall() {
gp := getg()
...
oldp := gp.m.oldp.ptr() // 进入系统调用之前所绑定的 p
gp.m.oldp = 0
// 尝试获取 P
if exitsyscallfast(oldp) {
...
// 系统调用完成,增加 syscalltick 计数
gp.m.p.ptr().syscalltick++
// 重新把 g 设置成 _Grunning 状态
casgstatus(gp, _Gsyscall, _Grunning)
...
return
}
...
// 没有拿到 P,执行不了了
// 调用 exitsyscall0 处理 syscall 的退出过程
mcall(exitsyscall0)
...
}由于在进入系统调用前,解除了 M 和 P 的关系,因此从系统调用返回,需要调用 exitsyscallfast 重新获取 P,才能继续调度执行;如果获取不到 P,则调用 mcall(exitsyscall0) 解除 M 和 G 的关系,将 G 重新放入可执行队列中,等待调度器的下一次调度。exitsyscallfast 源码:runtime/proc.go 4022func exitsyscallfast(oldp *p) bool {
gp := getg()
...
// 如果存在旧的 P 且旧 P 的状态为 _Psyscall,将其状态切换为 _Pidle
// 优先使用原来的 P
if oldp != nil && oldp.status == _Psyscall && atomic.Cas(&oldp.status, _Psyscall, _Pidle) {
wirep(oldp) // 绑定 m 和 p
exitsyscallfast_reacquired() // 更新系统调度计数等状态
return true
}
// Try to get any other idle P.
if sched.pidle != 0 {
var ok bool
systemstack(func() {
// exitsyscallfast_pidle 获取一个空闲的 P,并绑定到 M 上
ok = exitsyscallfast_pidle()
...
})
if ok {
return true
}
}
return false
}快速路径 runtime.exitsyscallfast 处理流程如下:如果 G 原来的 P(即 oldp) 处于 _Psyscall 状态,会直接调用 wirep 将 M 与 P 重新进行关联;如果调度器中存在闲置的处理器 P,会调用 exitsyscallfast_pidle -> runtime.acquirep 使用闲置 P 关联当前 M;exitsyscall0 源码:runtime/proc.go 4105有关 mcall 源码请参考 《14. Go调度器系列解读(一):什么是 GMP?》func exitsyscall0(gp *g) {
// 将 G 的状态从 _Gsyscall 切换为 _Grunnable
casgstatus(gp, _Gsyscall, _Grunnable)
dropg() // 释放当前 G,解除和 M 的关系
lock(&sched.lock)
var pp *p
// 如果调度器启用,尝试从空闲 P 队列中获取 P
if schedEnabled(gp) {
// 之前 M 获取不到 P,这里再尝试获取一下,万一能获取到呢
pp, _ = pidleget(0)
}
var locked bool
if pp == nil {
// 如果未获取到 P,将 G 放入全局运行队列
globrunqput(gp)
locked = gp.lockedm != 0 // 查看 g 是不是绑定了 m
}
...
unlock(&sched.lock)
if pp != nil {
// 如果获取到 P
acquirep(pp) // 绑定 M 和 P
execute(gp, false) // Never returns. 执行调度,已经有 G,直接执行
}
if locked {
// 如果 g 绑定了 m,必须在该 m 上执行
// 停止执行锁定到 g 的当前 m,直到 g 再次可运行。
// 使用 mPark() 睡眠 m,直到被唤醒
stoplockedm()
// 唤醒以后说明,有 p 绑定 m,直接运行 g 即可
execute(gp, false) // Never returns.
}
stopm() // 当前工作线程进入睡眠,等待被其它线程唤醒 mPark()
// 从睡眠中被其它线程唤醒,执行 schedule 调度循环重新开始工作
schedule() // Never returns.
}exitsyscall0 执行逻辑如下:更新 G 的状态是_Grunnable;调用 dropg 解除当前 G 与 M 的绑定关系;再次尝试获取 P,获取到 P,则调用 acquirep 绑定 P 和 M,然后调用 execute 进入调度循环;未获取到 P,则调用 globrunqput 将 G 放入 sched.runq 全局运行队列;如果 G 绑定了 M,只能在该 M 上执行,目前没有可用的 P,因此只能调用 stoplockedm -> mPark ,让 M 睡眠在 m.park 上,等待其他线程唤醒,成功唤醒 M 后,则表示有 P 可用,立即执行 G;调用 stopm 将 M 加入全局的空闲 M 列表,然后将 M 睡眠在 m.park 上,等待被唤醒(唤醒过程请参考 《14. Go调度器系列解读(一):什么是 GMP?》);M 被唤醒后,代表获取到了可用的 P, 随后会调用 schedule 函数,执行一次新的调度,重新开始工作。5.4.2 handoffp 抢占过程sysmon 会通过 retake 函数对正处于系统调用状态的 P 实施抢占,最终调用 handoffp 函数,为 P 再寻找一个 M,重新开始执行!handoffp 源码:runtime/proc.go 2458func handoffp(pp *p) {
// 检查 P 的本地队列是否非空,或者全局运行队列的大小是否不为零
if !runqempty(pp) || sched.runqsize != 0 {
startm(pp, false) // 启动一个 M 来执行任务
return
}
// 如果追踪已启用或正在关闭,并且追踪读取器可用
if (trace.enabled || trace.shutdown) && traceReaderAvailable() != nil {
startm(pp, false)
return
}
// 如果垃圾回收的 blacken 模式已启用,并且存在需要标记的工作
if gcBlackenEnabled != 0 && gcMarkWorkAvailable(pp) {
startm(pp, false)
return
}
// 检查是否有其他 M 正在自旋状态,如果没有且没有空闲的 M,则尝试将一个 M 设置为自旋状态并启动它
if sched.nmspinning.Load()+sched.npidle.Load() == 0 && sched.nmspinning.CompareAndSwap(0, 1) {
sched.needspinning.Store(0)
startm(pp, true)
return
}
lock(&sched.lock)
// 检查 GC 是否正在等待
if sched.gcwaiting.Load() {
pp.status = _Pgcstop // 将 P 的状态设置为 _Pgcstop
sched.stopwait-- // 减少等待计数
// 如果等待计数为 0,说明 P 都被置为 _Pgcstop
// 可以唤醒 GC 执行了
if sched.stopwait == 0 {
notewakeup(&sched.stopnote)
}
unlock(&sched.lock)
return
}
// 检查 P 上是否存在需要运行的 SafePoint 函数
if pp.runSafePointFn != 0 && atomic.Cas(&pp.runSafePointFn, 1, 0) {
sched.safePointFn(pp) // 执行 SafePoint 函数
sched.safePointWait-- // 减少 SafePoint 等待计数
// 如果等待计数为 0
if sched.safePointWait == 0 {
notewakeup(&sched.safePointNote)
}
}
// 如果全局可执行队列不为空
if sched.runqsize != 0 {
unlock(&sched.lock)
startm(pp, false) // 启动一个 M 来执行任务
return
}
// 如果当前空闲的 P 数量为 gomaxprocs-1,并且上次轮询的时间不为零
if sched.npidle.Load() == gomaxprocs-1 && sched.lastpoll.Load() != 0 {
unlock(&sched.lock)
startm(pp, false)
return
}
when := nobarrierWakeTime(pp) // 计算无障碍唤醒时间
pidleput(pp, 0) // 将 P 放入空闲队列
unlock(&sched.lock)
if when != 0 {
// wakeNetPoller 唤醒在网络轮询器中休眠的线程,如果它在 when 参数之前不被唤醒;
// 或者它会唤醒一个空闲的 P 来为定时器和网络轮询器提供服务(如果还没有的话)。
wakeNetPoller(when)
}
}handoff 会对当前的条件进行检查,如果满足下面的条件,则会调用 startm 函数,启动新的工作线程 M 来与当前的 P 进行关联,实现对 P 的接管,从而继续执行可运行的 G。P 的本地运行队列或全局运行队列里面有待运行的 G;需要帮助 GC 完成标记工作;系统比较忙,所有其它 P 都在运行 G,需要它帮忙;其它 P 都已经处于空闲状态,如果需要监控网络连接读写事件,则需要启动新的 M 来接管 P,用于监控网络连接。6.G、P 状态的变更还记得文章 《14. Go调度器系列解读(一):什么是 GMP?》 中总结了 GMP 的状态变更,今天随着新知识的拓展,我们再来把状态变更图补充一下!G 的状态变更G 基本上状态变更就差不多了,这里稍微总结一下:从 newproc 创建 G 开始,malg 初始化了一个 G,此时状态为 _Gidle;newproc1 函数中为 G 分配了内存,意味着 G 可用,此时状态为 _Gdead;newproc1 函数继续初始化 G 的执行环境变量 gobuf 中的寄存器参数等,此时 G 可以被运行 _Grunnable;可运行的 G 被调度执行 execute,此时状态更改为 _Grunning 运行中;当 G 顺利运行完毕,通过 goexit0 回收 G 对象,并退出调度,此时状态为 _Gdead,表示可以被复用;接下来就是本节内容调度时机相关的状态变化了:运行中的 G 通过主动调度、被抢占,会触发 goschedImpl 函数,让出 CPU,G 进入 _Grunnable 状态,加入可运行队列,等待下一次被调度执行;运行中的 G 通过被动调度,使用 park_m 函数,阻塞在等待队列中(比如 channel 的等待队列),等待被唤醒,此时 G 状态为 _Gwaiting;当等待的内容到达,G 通过 ready 函数被唤醒,重新进入可执行队列,此时状态为 _Grunnable;运行中的 G 主动触发系统调用(比如打开文件),在进入系统调用前,通过 entersyscall 函数进行预处理,此时 G 状态改为 _Gsyscall,表示系统调用中;当 G 顺利从系统调用中返回时,由 exitsyscall 函数处理收尾工作,如果此时能顺利获取到 P,则恢复为可执行状态 _Grunning,直接进行调度;如果获取不到 P(因为可能会被 handoffp),则通过 exitsyscall0 函数处理,加入可与行队列,状态更改为 _Grunnable,等待调度器的下一次调度执行。 _GsyscalP 的状态变更当 P 被初始化时,会被指定为 _Pgcstop 状态,通过调用 procresize 函数设置为 _Pidle 状态,并加入调度器空闲 P 列表中;M 通过 (acquirep) wirep 函数与 P 建立绑定关系,此时 P 状态更新为 _Prunning;运行中的 P 通过 releasep 函数,将 P 重置为 _Pidle 状态;运行中的 P 在发生系统调用时,通过 entersyscall 解绑 M 和 P,并将 P 状态改为 _Psyscall;sysmon 监控线程定时通过 retake 函数将 _Psyscall 状态的 P 重新置为 _Pidle,让其可以得到抢占使用;如果 handoffp 期间 GC 正在等待,则将 P 改为 _Pgcstop 状态;当 M 执行完成系统调用,由 exitsyscall 函数处理收尾工作,会优先选择之前绑定的 oldP,将其从 _Psyscall 状态,重置为 _Pidle,并通过 wirep 函数重新进行绑定,此时 P 为 _Prunning;如果 oldP 已经被抢占,则从空闲 P 中获取一个,并绑定,最终 P 状态为 _Prunning;如果没有空闲 P,M 则阻塞等待,加入空闲 M 列表,等待被唤醒。总结本篇文章我们讲述了有关 GMP 模型调度时机的知识内容,首先我们从整体角度讲述了正常调度、主动调度、被动调度和抢占调度等四种调度时机。正常调度、主动调度和被动调度可以简化为如下流程:这三种调度时机都以 g0 -> gogo -> g -> mcall -> g0 为一轮循环;g0 执行 schedule 函数,寻找到用于执行的 g;g0 执行 execute 方法,更新当前 g 的状态信息,并调用 gogo() 方法,将执行权交给 g;g 在运行过程中,因主动让渡( gosche_m() )、被动调度( park_m() )、正常结束( goexit0() )等原因,调用 mcall 函数,执行权重新回到 g0 手中;g0 执行 schedule() 函数,开启新一轮循环。抢占调度逻辑复杂一些,由监控线程 sysmon 定时触发,分为三种情况:协助式抢占:针对 g 运行时间太长(一般是 10ms)的情况,retake 会设置抢占标志,随后由 g 进行扩栈检查时,根据抢占标志触发抢占调度,最终也是通过调用类似于 gosche_m 函数的方式主动放弃执行权,形成的调度;这种抢占方式有一个很明显的缺点:一个没有主动放弃执行权、且不参与任何函数调用的函数,直到执行完毕之前, 是不会被抢占的。信号异步抢占:针对 g 运行时间太长(一般是 10ms)的情况,retake 会在支持异步抢占的系统内,直接发送信号给 M,M 收到信号后实施异步抢占,最终也是通过调用类似于 gosche_m 函数的方式主动放弃执行权,形成的调度;这种抢占时为了解决由密集循环导致的无法抢占的问题。针对 g 长时间处于系统调用之中的情况,g 在进入系统调用时,会通过 runtime·entersyscall 函数解除 m 与 p 的绑定关系;retake 会定期检查所有 p,当满足一定条件时,会调用 handoffp 寻找新的工作线程来接管这个 p,通过抢占 p,实现抢占调度。
cathoy
深入理解 Go Modules:高效管理你的 Golang 项目依赖
前言Go Modules 已经成为 Golang 项目依赖管理的标准,它不仅带来了便利,还为开发者们带来了更多的控制和灵活性。在本文中,我们将深入探讨 Go Modules 的精髓,从基础概念到高级技巧,带你领略如何高效管理你的 Golang 项目依赖。无论你是新手还是有经验的开发者,本文都将为你揭开 Go Modules 的神秘面纱,让你在依赖管理的道路上越走越得心应手。本文依据官方文档解读而来:golang.google.cn/ref/mod1.Go 依赖管理的历史Go 语言的依赖管理经历了三个主要阶段:GOPATH、Go Vendor 和 Go Module。GOPATH 阶段:这是 Go语 言早期的一个依赖管理方式,它是一个环境变量,也是 Go 项目的工作区。在GOPATH下,项目源码、编译生成的库文件和项目编译的二进制文件都有特定的存放路径。然而,如果多个项目依赖同一个库,则每个项目只能使用该库的同一份代码,容易触发依赖冲突,无法实现库的多版本控制,GOPATH 管理模式就显得力不从心。Go Vendor 阶段:在 Go 1.5 版本中推出了 vendor 机制,每个项目的根目录下有一个 vendor 目录,里面存放了该项目的依赖包。go build 命令在查找依赖包时会先查找 vendor 目录,再查找 GOPATH 目录。这解决了多个项目需要使用同一个包的依赖冲突问题。然而,如果不同工程想重用相同的依赖包,每个工程都需要复制一份在自己的vendor目录下,导致冗余度上升,无法实现库的多版本控制。Go Module 阶段:从 Go 1.11 版本开始,官方推出 Go Module 作为包管理工具,并在 Go 1.16 版本中默认开启。在项目目录下有一个 go.mod 文件,且工程项目可以放在 GOPATH 路径之外。通过 go.mod 文件来描述模块的依赖关系,并使用 go get/go mod 指令工具来管理依赖包的下载和更新。Go Module 解决了之前存在的问题,实现了库的多版本控制,并且可以自动化管理依赖包的下载、编译和安装过程。2.模块、包、版本Go 程序被组织到 Go 包中,Go 包是同一目录中一起编译的 Go 源文件的集合。在一个源文件中定义的函数、类型、变量和常量,对于同一包中的所有其他源文件可见。模块是存储在文件树中的 Go 包的集合,并且文件树根目录有 go.mod 文件。go.mod 文件定义了模块的名称及其依赖包,通过导入路径和版本描述一个依赖。1.1 模块和包模块: 在Go语言中,模块是指包含 go.mod 文件的目录。模块是一起发布、版本控制和分发的包的集合。模块可以直接从版本控制仓库或模块代理服务器下载。开发者可以将项目拆分成多个模块,每个模块都有自己的依赖关系和版本控制。包: 模块下的每个包都是一系列同目录下、将被编译到一起的文件集合。每个模块可以包含一个或多个 Go 包,这些包可以是相关的代码库或应用程序。模块路径:就是模块的规范名称,被 go.mod 文件中的 module 指令所声明;通常描述模块做什么以及在哪里找到它;模块路径由存储库根路径、存储库中的目录和主要版本后缀(仅适用于主要版本为 v2 或更高)组成。存储库根路径是模块路径的一部分,对应开发模块的版本控制仓库的根目录。大多数模块都被定义在存储库根目录,因此这通常就是整个模块路径了。例如:golang.org/x/net 模块。如果模块并没有定义于仓库根目录,则存储库中的目录是命名该目录模块路径的一部分,且不包括主版本后缀。例如:golang.org/x/tools/gopls 表示的模块在存储库根路径 golang.org/x/tools 的子目录 gopls 下,因此它的模块路径具有子目录 gopls。(模块下还有子模块)假设模块发布在版本 v2 或更高,模块路径必须有像 /v2 这样的主版本后缀。例如:路径为 github.com/go-playground/assert/v2 的模块。注:国内访问 github.com/golang/tool…包路径: 是模块路径和包含包的子目录拼起来的结果。比如,模块 "golang.org/x/net" 包含了目录 html 下的包。则这个包路径就是 "golang.org/x/net/html"。1.2 版本一个版本标示着模块的不可变快照,每个版本都以字母 v 开头,跟着语义版本,一个语义版本由三个非负整数(主要、次要和补丁版本,从左到右)用点分隔组成,例如:v1.2.3。主要版本号: 在发布不兼容的公共接口更改后,例如模块里的某个包被删除,必须递增主要版本,必须将次要和补丁版本设置为零。次要版本号: 在发布向后兼容的更改后,例如添加新函数后,必须递增次要版本且补丁版本设置为零。补丁版本号: 在不修改到公共接口的情况下,例如 Bug 修复或者做了一些优化,必须递增补丁版本。补丁版本后面可以跟一个以连字符开头的标识符,例如 -pre 或 -beta 表示是一个测试版或者预发布版,它可能包含了一些新的特性和功能,但也可能存在一些已知的问题和限制,通常用于在正式发布之前提供给开发者进行测试和评估。如果一个版本的主要版本是 0 或者它有一个预发布后缀,那么它就被认为是不稳定的。 不稳定的版本不受兼容性要求的限制。 例如,v0.2.0 可能与 v0.1.0 不兼容,v1.5.0-beta 可能与 v1.5.0 不兼容。1.2.1 伪版本伪版本是一种特殊格式的 “预发布” 版本,对版本控制仓库中特定修订的信息进行编码。 例如:v0.0.0-20191109021931-daa7c04131f5。每个伪版本有三个部分:基础版本前缀 (vX.0.0 or vX.Y.Z-0), 它由 tag 派生,如果没有 tag,则派生为 vX.0.0。时间戳 (yyyymmddhhmmss),即提交的 UTC 时间,在 Git 中指的是 commit 提交时间。修订标识符 (abcdefabcdef), 它是 commit 哈希的前 12 个字符。每个伪版本可以是以下三种形式中的一种,具体取决于基础版本。这些形式确保伪版本比其基础版本高,但低于下一个标记的版本.vX.0.0-yyyymmddhhmmss-abcdefabcdef 当没有已知的基础版本时使用。与所有版本一样,主要版本 X 必须匹配模块的主要版本后缀。vX.Y.Z-pre.0.yyyymmddhhmmss-abcdefabcdef 当基础版本是 vX.Y.Z-pre 等预发行版本时使用。vX.Y.(Z+1)-0.yyyymmddhhmmss-abcdefabcdef 当基础版本是一个像 vX.Y.Z 这样的发布版本时使用。例如,如果基本版本是 v1.2.3, 则伪版本可能是 v1.2.4-0.20191109021931-daa7c04131f5。具有已知基本版本的伪版本排序高于那些版本,但低于后续版本的其他预发布版本. 具有相同基本版本前缀的伪版本按时间顺序排序。1.2.2 主版本后缀主版本从第 2 个版本开始,模块路径必须有个类似 /v2 的后缀,以此来匹配主版本。例如:如果一个模块在 1.0.0 版本的路径为 example.com/mod,那么这个模块需要在 v2.0.0 版本时的路径为 example.com/mod/v2。如果旧包和新包具有相同的模块路径,那么这个新包需要对旧包向后兼容。Go Modules 官方认为 v0 版本完全没有兼容性保证,因此不需要区分模块路径,v1 版本默认省略主版本后缀,方便开发者。根据定义,模块在新主版本的包与旧主版本中的包不向后兼容。因此,从 v2 版本开始,包必须使用新的导入路径。这是通过模块路径添加一个主版本后缀实现的(如:/v2)。由于模块路径是模块内每个包的导入路径的前缀,因此向模块路径添加主版本后缀可为每个不兼容版本,提供不同的导入路径。当一个项目依赖多个模块时,可能会出现版本冲突的情况。例如,模块 A 和模块 B 都依赖于模块 C 的不同版本,这时就会出现版本冲突。Go Modules 提供了一些解决冲突的机制:通常情况下,如果一个模块需要两个不同版本的可传递依赖项,那么应当使用模块的更高版本。但是,如果这两个版本不兼容,那么这两个版本都不能满足所有依赖的需求。Go Modules 通过在模块路径添加一个主版本后缀,将具有不同后缀的模块视为一个独立的模块,解决了不兼容版本依赖冲突的问题,由于包路径=模块路径+包的子目录,因此包的引入路径也是不同的。但是,如果有一些库不遵守 Go Modules 语义化版本控制的规范(毕竟不是强制的),在一个主版本号内开发了不向后兼容的次要版本,就有可能导致多个依赖项之间的冲突无法解决,比如 v1.2.3 版本定义了 Tools 变量,v1.4.3 版本把这个变量删除或者改名了,就会导致依赖的不兼容冲突。1.2.3 后缀 +incompatiblego.mod 文件中有些库增加了 +incompatible 后缀例如:github.com/dgrijalva/jwt-go v3.2.0+incompatible查看该项目的版本,它们只是发布了 v3.2.0 的 tag,并没有包含 +incompatible 后缀。这个后缀是 Go Modules 自己加上去的,用于做一种不兼容的标识:这些库的主要版本号已经大于等于 2,也就是 tag 已经定义到 v2 及以上,正常使用 Go Modules 的库,此时模块路径必须有像 /v2 这样的主版本后缀,用于区分不同主要版本的模块,但它们的模块路径中没有添加类似的后缀,亦或者是压根没有用 Go Modules 进行模块管理,因此无法区分主要版本号不同的模块,而主要版本号不同会被 Go Modules 认为是前后不兼容的,所以 Go Modules 会对这类库打上 +incompatible 后缀。3.Go Modules 的环境配置3.1 go env 环境变量使用 go env 可以查看 Go 环境变量,这里列一个表格梳理一下重要的环境变量:GO111MODULEGo modules 的启用开关;auto:只有项目中包含go.mod 文件,才启用 Go modules,否则关闭 Go modules;on:启用 Go modules,推荐设置;off:禁用 Go modules,不推荐设置。打开方式:go env -w GO111MODULE=onGOMODCACHE存储下载的模块和相关文件的目录。如果没有设置 GOMODCACHE,它默认为 $GOPATH/pkg/mod。GONOPROXY以逗号分隔的模块路径前缀列表(采用 Go 的 path.Match的语法),在这个列表里的模块直接从版本控制仓库下载,不用走代理(如果是私人仓库,代理是访问不到的,就需要配置到这里)。如果不配置,默认值为 GOPRIVATE 环境变量。GONOSUMDB以逗号分隔的模块路径前缀列表(采用 Go 的 path.Match的语法),用于绕过 GOSUMDB 指定 Go checksum database 的验证,如果不配置,默认值为 GOPRIVATE 环境变量。GOPRIVATE以逗号分隔的模块路径前缀列表(采用 Go 的 path.Match的语法),用来控制 go 命令把哪些仓库看做是私有的仓库,公司内部的私有仓库代码一般不会上传到公共仓库中,因此镜像一般下载不了,此时会从 GOPRIVATE 进行拉取依赖。可以通过go env -w GOPRIVATE="*.example.com"指令设置私有模块。GOPATH在 GOPATH 模式下,GOPATH 变量是一个可能包含 Go 代码的目录列表。Go Modules 模式下 $GOPATH/pkg/mod 存储依赖。GOPROXY用于设置 Go 模块代理,用于使 Go 在后续拉取模块版本时能够脱离传统的版本控制系统方式(比如存储在 git 的代码),直接通过镜像站点来快速拉取。GOPROXY 的默认值是:https://proxy.golang.org,direct,然而国内无法访问,所以必须国内代理地址:[https://goproxy.cn,direct](https://goproxy.cn,direct),设置方式:go env -w GOPROXY=https://goproxy.cn,direct 其中 direct 用于指示 Go 回源到模块版本的源地址去抓取,比如依赖地址在 github ,就可以在镜像中抓不到的时候,去 github 拉取。GOSUMDB拉取模块依赖时用于安全校验的数据库,默认为 sum.golang.org 在国内也是无法访问的,也是通过模块代理 GOPROXY 进行解决的;它可以保证拉取到的模块版本数据未经过篡改(使用 go.sum 文件实现);绕过特定模块的校验数据库的更好方法是使用 "GOPRIVATE" 或 "GONOSUMDB" 环境变量。3.2 依赖管理文件Go Modules 模式下有两个重要文件:go.mod: 该文件是 Go 语言中用于管理模块依赖关系的文件。它记录了项目所依赖的模块信息,包括模块的版本号、路径和依赖项。go.sum: 该文件在 Go 语言中用于记录各个依赖项的版本和哈希值。它主要用于验证项目所下载依赖的安全性和一致性。3.2.1 go.mod 文件go.mod 文件是面向行的,每行包含一个指令,由关键字和参数组成。例如:module example.com/my/thing
go 1.20
toolchain go1.21.0
require golang.org/x/net v1.2.3
require example.com/new/thing/v2 v2.3.4
exclude golang.org/x/net v1.2.3
replace golang.org/x/net => github.com/golang/net latest
retract [v1.9.0, v1.9.5]指令一致的相邻行,可以用括号括起来,形成一个块,类似 Go 编码中的 import 语法:require (
golang.org/x/crypto v1.4.5 // indirect
golang.org/x/text v1.6.7
)接下来详细介绍一下每个关键字的作用:module模块指令定义主模块的路径,一个 go.mod 文件必须只包含一个模块指令。(前文已经介绍过模块路径的起名规范)go一个 go.mod 文件最多可以包含一个 go 指令,go 指令表示一个模块是按照给定的 Go 版本的语义来编写的;该 go 指令设置使用该模块所需的最低 Go 版本;在 Go 1.21 之前,该指令仅是建议性的,现在这是一个强制性要求。toolchain指令 toolchain 声明了与模块一起使用的建议 Go 工具链。不能低于 go 版本。require一个 require 指令声明了一个特定模块依赖的最低版本要求。go 命令使用最小版本选择(MVS)解决依赖版本冲突问题;go 命令为某些需求自动添加 //indirect 注释,//indirect 注释表明,该模块为间接依赖,主模块并没有直接引用(import)。(前文已经介绍过版本信息)exclude一个 exclude 指令可以阻止一个模块的版本被 go 命令加载(后续介绍 MVS 时逻辑会更加清晰)。replace替换指令用其他地方的内容替换一个模块的特定版本,或一个模块的所有版本。可以使用另一个模块路径和版本或特定于平台的文件路径来指定替换。retractretract 指令表示由 go.mod 定义的模块的某个版本或一系列版本不应该被依赖。(retract 指令是在 Go 1.16 中添加的)replace替换指令如果一个版本出现在箭头的左边(=>),只有该模块的特定版本被替换,其他版本将被正常访问。如果左边的版本被省略,则模块的所有版本都被替换。如果箭头右侧的路径是一个绝对或相对路径(以 ./ 或 ../ 开头),则将其解释为替换模块根目录的本地文件路径,该目录必须包含文件 go.mod。在这种情况下,必须省略替换版本。(本地测试可以使用这种方式 **replace github.com/example/xxx => ../github.com/example/xxx**)如果右侧的路径不是本地路径,则它必须是有效的模块路径。 在这种情况下,需要一个版本。 相同的模块版本不得同时出现在构建列表中。无论替换是使用本地路径还是模块路径,如果替换模块有文件 go.mod,则其 module 指令必须与其替换的模块路径匹配(两者的 go.mod 文件中 module指令定义的模块路径要一致)。例如:replace golang.org/x/net => github.com/golang/net latest单独的 replace 指令不会将模块添加到模块图中,还需要 require 指令的配合使用。retract 指令表示由 go.mod 定义的模块的某个版本或一系列版本不应该被依赖。当一个版本过早发布或在发布后发现严重问题时,retract 指令很有用;被撤回的版本应该在版本控制库和模块代理中保持可用,因为还有项目依赖于这个版本。当一个模块的版本被撤回时,go get, go mod tidy 或其他命令将不会使用自动升级到该版本。依赖于撤回版本的项目可以继续工作,在使用 go list -m -u 或 go get 命令检查更新相关模块时,将被告知模块版本撤回的情况。举个例子:开发者发布版本 v1.0.0 后,发现 v1.0.0 存在严重 bug,为了防止用户升级到 v1.0.0,可以 retract 向 go.mod 中添加两个指令如下,发布一个 v1.0.1 新版本用于撤回。retract (
v1.0.0 // Published accidentally. 意外发布(本来为最新,为了防止更新,新发布一个 v1.0.1)
v1.0.1 // Contains retractions only. 此版本为最新,包含 retract,帮助 v1.0.0 撤销
)当其他项目更新该模块依赖时,比如 go get example.com/m@latest,该 go 命令会从模块的 go.mod 中读取撤回内容 v1.0.1(这是现在的最高版本)。标志着 v1.0.0 都 v1.0.1 被撤回,因此该 go 命令降级到下一个最高版本,也许是 v0.9.5。retract 指令可以使用单个版本(如 v1.0.0)或具有上限和下限的封闭版本区间编写(如 [v1.0.0, v1.9.9]):retract v1.0.0 // 撤销单个版本
retract [v1.0.0, v1.9.9] // 撤销 v1.0.0 和 v1.9.9 之间的所有版本常用的撤销版本指令形式,目的是撤销 v1.0.0:retract [v0.0.0, v1.0.1] // assuming v1.0.1 contains this retraction.撤销包含所有伪版本和标记版本的模块:retract [v0.0.0-0, v0.15.2] // assuming v0.15.2 contains this retraction.3.2.2 go.sum 文件go.sum 文件中,每行记录由模块名、版本、哈希算法和哈希值组成;文件中记录了所有依赖的 module 的校验信息,以防下载的依赖被恶意篡改,主要用于安全校验。// 格式
<module> <version> <hash>
<module> <version>/go.mod <hash>
// 例子
github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw=
github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=正常情况下,每个依赖包会包含两条记录,内容详解:module:模块路径version:版本信息hash:h1 表示算法 SHA-256/go.mod:表示哈希值是模块的 go.mod 文件;没有 /go.mod 表示哈希值是模块的 .zip 文件。当使用 go get 命令引入依赖包时,包的哈希值会经过校验和数据库(checksum database)进行校验,校验通过才会被加入到 go.sum 文件中。当执行项目构建时,会从本地缓存中查找依赖包,通过计算和校验依赖包的哈希值的方式进行对比,来确保依赖包没有被篡改。go.sum 文件可以包含模块的多个版本的哈希,go 命令可能需要从多个版本的依赖关系加载 go.mod 文件以执行最小版本选择。go.sum 也可以包含不再需要的模块版本的哈希 (例如,升级后)。 go mod tidy 将添加缺少哈希物,并将从 go.sum 中删除不必要的哈希。3.2.3 自动更新机制当我们使用大多数模块感知类(会操作 go.mod 文件)指令(如:go get)时 ,会触发模块的自动更新机制,自动的修复go.mod 和 go.sum 中的问题。在 Go 1.15 及更低版本中,go 指令默认启用了 -mod=mod参数,因此会触发自动更新; 自 Go 1.16 以来,默认设置为 -mod=readonly,表示如果对go.mod 有修改的需要,会报告错误并建议修复。4.最小版本选择 (MVS)Go 使用一种称为最小版本选择 (MVS) 的算法来选择构建包时要使用的一组模块版本。模块版本有向图:图中的每个顶点代表一个模块版本。每条边代表依赖项的最低要求版本,使用 require 指令指定。在主模块的 go.mod 文件中,使用 replace 和 exclude 指令修改图形。MVS 从主模块开始(图中没有版本的特殊顶点),遍历图跟踪每个模块所需的最高版本。在遍历结束时,所需的最高版本构成构建列表:它们是满足所有要求的最低版本。可以使用命令 go list -m all 检查构建列表。考虑下图中的示例。主模块需要模块 A(最低 1.2 版本) 和 模块 B (最低 1.2 版本),A 1.2 和 B 1.2 分别依赖 C 1.3 和 C 1.4,C 1.3 和 C 1.4 都依赖 D 1.2。MVS 访问并加载所有标注蓝色版本模块的 go.mod 文件(go.mod loaded)。在图上遍历结束时,MVS 返回一个包含粗体版本的构建列表:A 1.2、B 1.2、C 1.4 和 D 1.2(Selected version)。请注意,可以使用更高版本的 B(1.3) 和 D(1.3),但 MVS 不会选择它们,因为不需要它们,选择可用版本内最小的。4.1 替换 replace在主模块的 go.mod 文件中,可以使用 replace 指令来替换模块内容。因为替换的模块可能依赖不同的版本,替换会更改模块图。考虑下面的示例,其中 C 1.4 已被 R 替换。R 取决于 D 1.3 而不是 D 1.2,因此 MVS 返回包含 A 1.2、B 1.2、C 1.4(替换为 R)和 D 1.3 的构建列表。4.2 排除 Exclusion还可以使用主模块 go.mod 文件中的 exclude 指令在特定版本中排除模块。排除也会改变模块图,当某个版本被排除时,它将从模块图中删除,并且对其的要求将被重定向到下一个更高的版本。考虑下面的例子。C 1.3 已被排除。MVS 将表现为 A 1.2 需要 C 1.4(下一个更高版本)而不是 C 1.3。4.3 升级go get 命令可以用来升级一组模块。为了执行升级,go 命令在运行 MVS 之前改变了模块图,增加了从访问的版本到升级后的版本。看下面的例子。模块 B 可以从 1.2 升级到 1.3,C 可以从 1.3 升级到 1.4 ,D 可以从 1.2 升级到 1.3。升级(和降级)可以增加或删除间接的依赖关系。在这种情况下,E 1.1 和 F 1.1 在升级后出现在构建列表中,因为 E 1.1 是 B 1.3 所需要的。为了保持升级,go 命令会更新 go.mod 中的依赖版本,它将改变 B 的版本为 1.3 版本;它还将增加对 C 1.4 和 D 1.3 的依赖,并加上 //indirect 注释,表示是因为升级导致的依赖,否则不会选择这些版本。4.4 降级go get 命令也可以用来降低一组模块的等级。为了执行降级,go 命令通过移除降级后的版本来改变模块图。考虑下面的例子。 假设发现 C 1.4 存在问题,因此我们降级到 C 1.3,把 C 1.4 从模块图中删除。 因为 B 需要 C 1.4 或更高版本,B 1.2 也被删除, 主模块对 B 的要求改为 1.1。go get 也可以完全删除依赖项,在参数后使用 @none 后缀。 这与降级类似。指定模块的所有版本都将从模块图中删除。5.Go Modules 的常用操作5.1 项目初始化命令:go mod init project_name初始化项目使用 Go Modules 管理依赖,可以在 $GOPATH 以外的目录创建一个任意目录:myGoMod,然后初始化项目,步骤如下:mkdir myGoMod
cd myGoMod
go mod init myGoMod执行完毕以上指令,发现 myGoMod 目录下创建了一个 go.mod 文件,内容如下:module myGoMod
go 1.205.2 添加依赖命令:go get github.com/gin-gonic/gin 拉取依赖,并构建模块go mod tidy删除未使用的依赖项新建 main.go 文件,写入如下代码,执行 go get 指令获取并构建 gin 模块,不指定版本将拉取最新的版本。package main
import "github.com/gin-gonic/gin"
func main() {
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "pong",
})
})
r.Run() // 监听并在 0.0.0.0:8080 上启动服务
}执行 go mod tidy 确保 go.mod 文件与模块中的源代码匹配。执行完发现 go.mod 中写入了一堆依赖,只有 require github.com/gin-gonic/gin v1.9.1是我们项目直接 import 的依赖。5.3 版本降级和升级命令:go list -m -versions github.com/gin-gonic/gin 查看依赖的历史版本。go list -m github.com/gin-gonic/gin 查看依赖的模块信息,或者使用 all 查看所有模块,还可以加入 -json 查看结构化信息(包括依赖的存储缓存地址),-u 查看可以升级的信息。go get github.com/gin-gonic/gin@v1.9.1参数后面显式地指定版本,用于版本降级和升级。我们具体操作一下:查看依赖的历史版本:go list -m -versions github.com/gin-gonic/gin2. 版本降级:go get github.com/gin-gonic/gin@v1.6.1,此时发现 go.mod 关于 gin 模块的依赖已经改变,但有部分依赖我们已经不需要了,可以执行 go mod tidy清理一下。3. 查看一下具体模块信息:go list -m -u -json github.com/gin-gonic/gin4. 版本升级就是反向过程:go get github.com/gin-gonic/gin@latest总结当涉及到 Golang 项目的包管理时,Go Modules 提供了一种全新的方式来高效管理项目的依赖。作为 Go 语言的官方包管理解决方案,Go Modules 极大地简化了项目的依赖管理流程,使得开发人员能够更加轻松地管理项目所需的第三方包。Go Modules 的核心原理在于模块化,它通过引入模块的概念,使得每个包都可以独立于其他包进行版本控制和管理。每个模块都拥有自己的版本信息,可以明确地指明其依赖关系,这使得依赖管理变得更加清晰和可控。在 Go Modules 中,模块的版本信息是通过语义化版本控制(Semantic Versioning)来管理的,这意味着每个模块版本的变化都具有清晰的语义意义,开发者可以根据实际需求进行版本的升级和降级。除此之外,Go Modules 还引入了 go.sum 文件来记录模块的哈希值,用于确保模块的下载和使用过程中不会被篡改,从而提高了模块的安全性。总的来说,Go Modules 的原理基于模块化和语义化版本控制,它让依赖管理变得更加清晰、可控和安全。通过深入理解 Go Modules 的原理,开发者可以更好地利用这一工具来管理项目的依赖,提高开发效率。以下是 Go Modules 的一些主要优点:版本控制:Go Modules 可以自动管理依赖包的版本,避免版本冲突和重复下载。每个模块都有单独的依赖关系,可以根据需要选择特定版本的依赖包,也可以自动更新到最新版本。避免版本冲突:Go Modules 允许多个依赖项使用不同的版本,避免了版本冲突的问题。清晰的项目结构:使用 Go Modules,可以将项目的源代码和依赖包分别存储在不同的目录下,使得项目结构更加清晰简洁。这有助于提高代码的可读性和可维护性。支持嵌套引用:Go Modules 支持嵌套引用其他模块,方便大型项目的开发和管理。自动构建和测试:Go Modules 支持使用 Go 命令进行编译和测试,可以自动处理依赖包的下载、构建和测试。这使得开发过程更加便捷,减少了手动管理的繁琐过程。支持私有仓库:Go Modules 支持从私有仓库中获取依赖包,这对于公司内部的私有库管理非常有用。这样可以避免公开私有库的访问权限,并确保安全性。安全性高:Go Modules 可以有效地避免恶意代码的攻击。由于每个模块都有自己的版本号和依赖关系,因此可以确保使用的依赖包是安全和可靠的。
cathoy
4. golang map 基本使用
什么是 map?map 是由一组 <key, value> 对组成的抽象数据结构,并且同一个 key 只会出现一次。<key,value> 组成每个 key 只会出现一次map 基本使用初始化 map// 初始化 - 不指定长度
aMap := make(map[string]string)
// 初始化 - 指定长度
bMap := make(map[string]string,16)
// 声明一个 map 为 nil,不能向其添加元素,会直接panic; 可以读取,源码会返回零值
cMap := map[string]string
// 支持在声明的时候填充元素, 此时 map 不为 nil
dMap := map[string]string{}通过 key 获取 valuefunc main() {
scoreMap := make(map[string]int)
scoreMap["小红"] = 90
scoreMap["小明"] = 80
// 如果 key 存在 ok 为 true, v 为对应的值;不存在 ok 为 false,v 为值类型的零值
v, ok := scoreMap["张三"]
if ok {
fmt.Println(v)
}
// 也可以不用 ok 模式,直接取 key
v2 := scoreMap["小明"]
// 一个不存在的 key 会返回对应类型的零值
v3 := scoreMap["xx"]
fmt.Println(v2,v3)
}map 遍历func main() {
scoreMap := make(map[string]int)
scoreMap["小红"] = 90
scoreMap["小明"] = 80
scoreMap["小号"] = 60
for k, v := range scoreMap {
fmt.Println(k, v)
}
}只遍历 keyfunc main() {
scoreMap := make(map[string]int)
scoreMap["小红"] = 90
scoreMap["小明"] = 80
scoreMap["小号"] = 60
for k := range scoreMap {
fmt.Println(k)
}
}遍历map时的元素顺序与添加键值对的顺序无关,更准确的说法应该是:迭代 map 的结果是无序的。关于此有两点原因:map 在扩容后,会发生 key 的搬迁,遍历 map 的结果就不可能按原来的顺序了。(那如果我不做删除和插入操作,按理说每次遍历这样的 map 都会返回一个固定顺序的 key/value 序列吧,然而并非如此)从 go 1.0 以后,map 就变得完全无序了,当我们在遍历 map 时,并不是固定地从 0 号 bucket 开始遍历,每次都是从一个随机值序号的 bucket 开始遍历,并且是从这个 bucket 的一个随机序号的 cell 开始遍历。这样,即使你是一个写死的 map,仅仅只是遍历它,也不太可能会返回一个固定序列的 key/value 对了。删除键值对func main(){
scoreMap := make(map[string]int)
scoreMap["小红"] = 90
scoreMap["小明"] = 80
scoreMap["小号"] = 60
delete(scoreMap, "小明")//将小明:100从map中删除
for k,v := range scoreMap{
fmt.Println(k, v)
}
}删除 key 并不会释放内存,只是底层对应 value 位置赋值为空,key 位置都不一定清理内存。按顺序遍历 map先取出 key,按某种顺序排序后,对 key 进行遍历即可。func main() {
rand.Seed(time.Now().UnixNano()) //初始化随机数种子
var scoreMap = make(map[string]int, 200)
for i := 0; i < 100; i++ {
key := fmt.Sprintf("stu%02d", i) //生成stu开头的字符串
value := rand.Intn(100) //生成0~99的随机整数
scoreMap[key] = value
}
//取出map中的所有key存入切片keys
var keys = make([]string, 0, 200)
for key := range scoreMap {
keys = append(keys, key)
}
//对切片进行排序
sort.Strings(keys)
//按照排序后的key遍历map
for _, key := range keys {
fmt.Println(key, scoreMap[key])
}
}map 特性map 有很多特性,都与其底层实现息息相关,比如:map 是引用类型、无序键值对集合、range 迭代随机次序返回map 中的 key 必须是可比较的(key 值类型必须支持 ==、!=)map 可判断是否等于 nil,但不支持比较操作访问不存在的 key 返回对应零值(map 为 nil 时,也适用)map[key] 不支持寻址操作(& map[key] )(map 元素是无法取址的)- 因为扩容的存在,取地址没有意义删除不存在的 key,不会引发错误并发不安全,同时读写 map 会 panic
cathoy
5. golang map 源码的逐句解读
前言map 又被称之为字典或哈希表,是除数组外最常见的数据结构,用于表示键值对之间映射关系。和切片相比,map 类型的内部实现要更加复杂,Go 运行时使用一张哈希表来实现抽象的 map 类型,哈希表有着 O(1) 的平均查找效率,因此 map 数据结构在编程中使用频率极高。上篇文章:map 基本使用 讲述了 map 数据结构相关的操作,本文将从底层角度展开,结合源码探索 map 的底层实现原理。文章有点长,做好心里准备,让我们开启 golang 学习第三课:map 底层实现源码解析。注:本文依据 go 版本 1.20.7 源码进行讲解,源码位置:src/runtime/map.go、 代码中保留了部分官方英文注释,并对每一行代码进行了详细注释;特别的地方会重点进行强调和讲解。1.map 底层数据结构map 类型的内部实现相对比较复杂,Go 运行时使用一张哈希表来实现抽象的 map 类型。其中最核心的结构体为 runtime.hmap,hamp 中 buckets unsafe.Pointer指针最终指向了 bmap 结构体,该结构体用于存储真正的key,value ,本小节将重点介绍这两个重要结构体内的参数作用,是理解后续源码的关键。我们先通过一张图,从整体上了解一下结构体之间的指向关系,然后对其进行详细的分析与讲解:1.1 hmap 数据结构从图中我们可以看到,hmap 结构体用于表示 map(它是 hashmap 的缩写),hmap 类型是 map 类型的头部结构(header),它存储了后续 map 类型操作所需的所有信息。下边展示了源码中 hmap 的详细结构。// A header for a Go map.
type hmap struct {
// Note: the format of the hmap is also encoded in cmd/compile/internal/reflectdata/reflect.go.
// Make sure this stays in sync with the compiler's definition.
count int // map 的元素数量。调用 len(map) 的时候返回此值
flags uint8 // map 当前状态的标志位,目前定义了 4 中状态:iterator/oldIterator/hashWriting/sameSizeGrow
B uint8 // 当前哈希表持有的 buckets(存储桶) 的数量(2^B 是 bucket 的数量,bucket 数组的长度)
noverflow uint16 // 溢出桶的大致数量,溢出桶较少时为精确值
hash0 uint32 // 哈希种子,计算 key 的哈希的时候会传入哈希函数
buckets unsafe.Pointer // 指针指向 2^B Buckets 数组。当 count==0 时,可能为 nil
// 仅仅在扩容期间不为 nil,非等量扩容期间为 buckets 的一半大小,等量扩容与 buckets 等大
oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
nevacuate uintptr // 迁移进度计数器,小于此下标的 buckets 都已迁移完成 // progress counter for evacuation (buckets less than this have been evacuated)
extra *mapextra // optional fields 可选字段,用于防止 k-v 不是指针情况下,溢出桶被 GC
}
const(
// flags
// map 迭代器允许并行,在迭代过程中,有可能使用 buckets 或 oldbuckets
iterator = 1 // 有迭代器可能正在使用 buckets; there may be an iterator using buckets
oldIterator = 2 // 有迭代器可能正在使用 oldbuckets; there may be an iterator using oldbuckets
hashWriting = 4 // 有协程在写 map,用于限制并发读写;a goroutine is writing to the map
sameSizeGrow = 8 // 等量扩容;the current map growth is to a new map of the same size
)
// mapextra holds fields that are not present on all maps.
type mapextra struct {
// 源码注释
// If both key and elem do not contain pointers and are inline, then we mark bucket
// type as containing no pointers. This avoids scanning such maps.
// However, bmap.overflow is a pointer. In order to keep overflow buckets
// alive, we store pointers to all overflow buckets in hmap.extra.overflow and hmap.extra.oldoverflow.
// overflow and oldoverflow are only used if key and elem do not contain pointers.
// overflow contains overflow buckets for hmap.buckets.
// oldoverflow contains overflow buckets for hmap.oldbuckets.
// The indirection allows to store a pointer to the slice in hiter.
overflow *[]*bmap // 包含 hmap.buckets 的溢出桶
oldoverflow *[]*bmap // 包含 hmap.oldbuckets 的溢出桶
// nextOverflow holds a pointer to a free overflow bucket.
// nextOverflow 持有指向空闲溢出桶的指针(map 初始化的时候,预分配出来的,用于提升 map 后续创建溢出桶的速度)。
nextOverflow *bmap
}flags 为 hmap 的标志位,共有四位 1111 表示了 map 的四种状态iterator:有迭代器可能正在使用 bucketsoldIterator:有迭代器可能正在使用 oldbuckets;hashWriting:有协程在写 map,用于限制并发读写sameSizeGrow:map 正在进行等量扩容mapextra 字段mapextra 字段设计思路很有意思,这里值得详细介绍一下,后续源码解析中也有讲解。 首先你得提前了解一下 golang 的 GC 机制,方便理解该字段的内容。垃圾回收机制会回收那些没有被引用到可回收的内存。当 GC 扫描到 hmap 结构体的变量时,不仅需要扫描 hmap 内部的字段,还需要扫描整个 buckets 数组,当 map 存储大量 k-v 时,将会耗费大量的性能用于 GC 扫描,影响性能。这个时候设计 map 的工程师就在想了,如果 map 存储的 key、value 都不包含指针,能不能避免 GC 扫描 buckets 数组。来看一下官方对 mapextra 字段的注释内容:If both key and elem do not contain pointers and are inline, then we mark bucket type as containing no pointers. This avoids scanning such maps. However, bmap.overflow is a pointer. In order to keep overflow buckets alive, we store pointers to all overflow buckets in hmap.extra.overflow and hmap.extra.oldoverflow. overflow and oldoverflow are only used if key and elem do not contain pointers. overflow contains overflow buckets for hmap.buckets. oldoverflow contains overflow buckets for hmap.oldbuckets. The indirection allows to store a pointer to the slice in hiter.这里先对官方注释做一个翻译:如果 key 和 eiem 都不包含指针,会把 bucket 的存储类型标记为不含指针,这将避免扫描这样的 maps。 然而 bmap.overflow 是一个指针。 为了保持溢出桶存活,我们在 hmap.extra.overflow 和 hmap.extra.oldoverflow 变量中存储了指针指向所有的溢出桶。 overflow 和 oldoverflow 字段仅仅只用在 k-v 不包含指针的情况下。overflow 字段包含了 hmap.buckets 的溢出桶。oldoverflow 字段包含了 hmap.oldbuckets 的溢出桶。 间接允许在 hiter 中存储指向切片的指针。如果没有读过 map 源码,一定不知道官方注释在说什么,这里对官方注释内容做一个讲解:第一句:如果 key 和 value 都不包含指针,会把 bucket 的存储类型标记为不含指针,这将避免扫描这样的 maps。讲解:如果 map 的 key 和 value 都不包含指针,会把 map 的存储类型 maptype 标记为不含指针(使用 maptype.bucket.ptrdata == 0 进行判断,后续会有源码解读 ptrdata 字段)。这将避免扫描整个 map(触发扫描的过程就是 GC)。第二句:然而 bmap.overflow 是一个指针。讲解:然而 bmap.overflow 字段是指针,这将破坏 bmap 中没有指针这一设想(虽然 k-v 不是指针,但 overflow 还是指针,无法避免 GC 扫描 bmap)。第三句:为了保持溢出桶存活,我们在 hmap.extra.overflow 和 hmap.extra.oldoverflow 变量中存储了指针指向所有的溢出桶。讲解:为了保持溢出桶的活跃状态(也就是不被 GC 认为可回收),我们在 hmap.extra.overflow 和 hmap.extra.oldoverflow 变量中存储了指针指向所有的溢出桶。从这句注释,我们大概知道了 extra 字段的作用是为了保活溢出桶而存在的,到这里 extra 中字段的意思解释清楚了,官方注释也戛然而止了,我相信你们和我产生了同样的疑问:你第二句没告诉我咋解决 bmap.overflow 是指针的问题啊!!!很明显第二句和第三句注释之间是断层的,隐藏了一些东西,官方没有直接说出来,这里把缺失的部分补充回来:bmap.overflow 指针字段该如何解决?首先得理解一下,为什么 bmap.overflow 字段要用指针类型?我们都知道,map 溢出桶是通过链表结构维护的,需要指针指向下一个溢出桶,所以用指针很正常,且指针可以用来对溢出桶保活。那有办法不用指针类型吗? 当然可以,golang 在编译期间会把 k-v 不是指针的 map 中的 bmap.overflow 字段优化为 unitptr 类型。uintptr 是数值类型,非指针类型,用这个存储指针是无法保护对象的(扫描的时候 uintptr 指向的对象不会被扫描,意味着会被 GC)。这样就会带来一个新问题,在使用 map 的过程中,溢出桶链表会被 GC 回收掉,这是不能接受的,此时 hmap.extra 的作用就彰显出来了。第四句:overflow 和 oldoverflow 字段仅仅只用在 k-v 不包含指针的情况下。overflow 字段包含了 hmap.buckets 的溢出桶。oldoverflow 字段包含了 hmap.oldbuckets 的溢出桶。讲解:从第三句+第四句注释,我们可以知道溢出桶的保活任务交给了 hmap.extra.overflow 和 hmap.extra.oldoverflow 两个 slice 变量;overflow 包含了 hmap.buckets 的所有溢出桶,oldoverflow 包含了 hmap.oldbuckets 的所有溢出桶,这样扩容的时候,也可以进行保活。最后一句:间接允许在 hiter 中存储指向切片的指针。讲解:hiter 是 map 迭代器的数据结构,在遍历 map 的过程中,会使用 hiter 迭代器;此时迭代器保存了 map 中的快照数据,如果 hiter 指向了oldbuckets ,当 hmap 把 oldbuckets 迁移完毕时,会把字段 h.oldbuckets = nil,有迭代器存在时,虽然不清理 oldbuckets 的内存,但溢出桶却失去了保活的变量;而在 hiter 遍历 oldbuckets 过程中也是不允许溢出桶被 GC 回收的,所以迭代器需要对溢出桶进行保活,也就是官方的这句注释的解释了,保活的字段为 hiter.overflow 和 hiter.oldoverflow。在 Go 中,这种避免 GC 回收的保活手段在源码里是经常被使用的,对理解源码还是十分必要的。1.2 bmap 数据结构buckets 数组中存储是一个指针,最终它指向的是一个结构体:bmap。// A bucket for a Go map.
type bmap struct {
// tophash generally contains the top byte of the hash value
// for each key in this bucket. If tophash[0] < minTopHash,
// tophash[0] is a bucket evacuation state instead.
tophash [bucketCnt]uint8
// Followed by bucketCnt keys and then bucketCnt elems.
// NOTE: packing all the keys together and then all the elems together makes the
// code a bit more complicated than alternating key/elem/key/elem/... but it allows
// us to eliminate padding which would be needed for, e.g., map[int64]int8.
// Followed by an overflow pointer.
}通过官方注释也可以知道,bmap 不止包含 tophash 这一个字段,其实在编译期间会给它加料,动态的创建一个新的结构,如下:type bmap struct {
// tophash 通常包含此 bucket 中每个 key 的哈希值的最高的 8 位(1 个字节)
// 如果 tophash[0] < minTopHash,则 tophash[0] 为桶已迁移状态。
tophash [bucketCnt]uint8
keys [bucketCnt]keytype // 8个key
values [bucketCnt]valuetype // 8个value
padding uintptr // 填充字段,用于内存对齐,经常会用到,刚好一个字节大小
overflow uintptr // 指向溢出桶链表 overflow 类型由编译器根据情况而定
}
// tophash 的标志位
const (
emptyRest = 0 // 这个 cell 是空的,并且在更高的索引或溢出处没有更多的非空 cell。
emptyOne = 1 // 这个 cell 是空的
evacuatedX = 2 // key/elem 有效。 entry 已被迁移到较大的哈希表的前半部分(扩容了)。
evacuatedY = 3 // 同上,但迁移到大的哈希表的后半部分(扩容了)。
evacuatedEmpty = 4 // cell 是空的,bucket 已经被迁移了
minTopHash = 5 // 正常填充 cell 的最小 tophash。
)
// bucketCnt = 8,一个 bucket 可以存储 8 个 k-v
const(
// Maximum number of key/elem pairs a bucket can hold.
bucketCntBits = 3
bucketCnt = 1 << bucketCntBits
)bmap 是存放 k-v 的地方,也被成为 bucket(桶),我们具体看下 bucket 的内部组成。上图就是 bucket 的内存模型,从上到下分别为:tophashs 区域、keys 区域、values 区域 和 overflow,我们先简单分析一下它们存储的内容:tophash 区域因为一个 bucket 只能存储 8 个元素,所以 tophashs 区域是一个长度为 8 的数组。tophash 通常包含此 bucket 中每个 key 的哈希值的最高的 8 位(刚好是 1 个字节),如果 tophash[0] < minTopHash,则 tophash[0] 含义转变为桶已迁移状态。因为 tophash 可能有不同的含义冲突,所以需要在 key 的哈希值的最高的 8 位 < minTopHash 时,为其 tophash + minTopHash 避免冲突(后续有详细代码分析)。key、value 区域因为 key 和 value 总是成对出现,这里就一起分析了,通过命名想必大家也能看出来,key - value 就是 map 中存储的键值对。仔细观察图我们可以注意到 key 和 value 是各自放在一起的,并不是以 key/value/key/value/... 这样的形式存储的。源码里说明这样的好处是在某些情况下可以省略掉 padding 字段,节省内存空间。这里稍微解释一下如何节省内存空间的,如果按 key/value/key/value/... 形式存储,由于 key 和 value 类型可能不一样,例如:map[int64]int8,则需要在每个 value 后边补足额外的 7 个 padding 字节,对齐 64 位(64位计算机);而如果按 key/key/... /value/value/... 形式存储,则只需要在 value 最后增加 padding 对齐内存即可,可以节省很多内存空间。overflow每个 bucket 中存储的是 Hash 值低 hmap.B 位数值相同的元素(和取余类似,这里用的是二进制相与操作),也就是说发生哈希碰撞的元素会被分配到一个 bucket 进行存储,当某个 bucket 的 8 个空槽 slot/cell 都填满了,且 map 尚未达到扩容条件的情况下,运行时会建立 overflow bucket(溢出桶),并将这个 overflow bucket 挂在上面 bucket 末尾的 overflow 指针上,这样两个 bucket 形成了一个链表结构,用拉链的方式,解决哈希碰撞的问题。2.map 的函数调用和类型字段2.1 map 操作的函数调用运行时实现了 map 类型操作的所有功能,包括创建、查找、插入、删除、遍历等。在编译阶段,Go 编译器会将 Go 语法层面的 map 操作,重写成运行时对应的函数调用。大致的对应关系是这样的:// 创建map类型变量实例
m := make(map[keyType]valType, hint) → m := runtime.makemap(maptype, hint, h)
// 获取某键的值
v := m["key"] → v := runtime.mapaccess1(maptype, m, "key")
v, ok := m["key"] → v, ok := runtime.mapaccess2(maptype, m, "key")
// 删除某键
delete(m, "key") → runtime.mapdelete(maptype, m, “key”)
// 插入新键值对或给键重新赋值,v是用于后续存储value的空间的地址
m["key"] = "value" → v := runtime.mapassign(maptype, m, "key")
// 遍历 map
for k,v := range m{}
// 初始化 map 迭代器,后续操作以迭代器 hiter 为准
mapiterinit(t *maptype, h *hmap, it *hiter)
// 每次迭代会调用 mapiternext(it *hiter) 函数,返回下一个 key 和 value
mapiternext(it *hiter)
// for range map 编译器源码注释
// The loop we generate:
// var hiter map_iteration_struct
// for mapiterinit(type, range, &hiter); hiter.key != nil; mapiternext(&hiter) {
// index_temp = *hiter.key
// value_temp = *hiter.val
// index = index_temp
// value = value_temp
// original body
// }2.2 map 类型 maptype通过观察我们可以发现,这些运行时函数都有一个共同的特点,那就是第一个参数都是 maptype 指针类型的参数。当我们声明一个 map 类型变量,比如 var m map[string]int 时,Go 编译期间就会为这个变量生成对应的特定 map 类型,生成一个 runtime.maptype 实例。 maptype 实例的结构如下:type maptype struct {
typ _type
key *_type // key 类型
elem *_type // elem(value) 类型
bucket *_type // 表示哈希桶的内部类型
// function for hashing keys (ptr to key, seed) -> hash
// 哈希函数,用于计算 key 的哈希值
hasher func(unsafe.Pointer, uintptr) uintptr
keysize uint8 // key 大小
elemsize uint8 // value(elem) 大小
bucketsize uint16 // bucket 大小
flags uint32 // map k-v 标志位,比如 key 是否存储的是指针
}
type _type struct {
size uintptr
ptrdata uintptr // 保存所有指针的内存前缀的大小 size of memory prefix holding all pointers
hash uint32
tflag tflag
align uint8
fieldAlign uint8
kind uint8
// function for comparing objects of this type
// (ptr to object A, ptr to object B) -> ==?
equal func(unsafe.Pointer, unsafe.Pointer) bool
// gcdata stores the GC type data for the garbage collector.
// If the KindGCProg bit is set in kind, gcdata is a GC program.
// Otherwise it is a ptrmask bitmap. See mbitmap.go for details.
gcdata *byte
str nameOff
ptrToThis typeOff
}
这个实例包含了我们需要的 map 类型中的所有"元信息"。Go 运行时就是利用 maptype 参数中的信息确定 key、value 的类型和大小的。maptype 的存在让 Go 中所有 map 类型都共享一套运行时 map 操作函数,而不是像 C++ 那样为每种 map 类型创建一套 map 操作函数,这样就节省了对最终二进制文件空间的占用。这里简单解释一下 flags 的作用。由于 map 对 key、value 的数据长度有长度限制,如果 key 或 value 的数据长度大于一定数值,那么运行时不会在 bucket 中直接存储数据,而是会存储 key 或 value 数据的指针。目前 Go 运行时定义的最大 key 和 value 的长度是这样的:const (
maxKeySize = 128
maxElemSize = 128
)因此,需要字段标记 key,value 存储的是不是指针,就是 flags 字段,flags 字段还标记了其他值,后续代码里用到会具体进行讲解。func (mt *maptype) indirectkey() bool { // store ptr to key instead of key itself
return mt.flags&1 != 0
}
func (mt *maptype) indirectelem() bool { // store ptr to elem instead of elem itself
return mt.flags&2 != 0
}3.map 创建创建 map 对应的函数调用为 makemap,下边对其源码进行分析:// 创建map类型变量实例
m := make(map[keyType]valType, hint) → m := runtime.makemap(maptype, hint, h)3.1 makemap 函数创建 map// makemap implements Go map creation for make(map[k]v, hint).
// If the compiler has determined that the map or the first bucket
// can be created on the stack, h and/or bucket may be non-nil.
// If h != nil, the map can be created directly in h.
// If h.buckets != nil, bucket pointed to can be used as the first bucket.
// makemap 是 make(map[k]v, hint) 的实现,创建一个 map。
func makemap(t *maptype, hint int, h *hmap) *hmap {
// 计算 bucket 所需内存大小,是否溢出,如果溢出或超过最大内存,分配 hint = 0
mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
if overflow || mem > maxAlloc {
hint = 0
}
// initialize Hmap
// 分配 hamp 结构体所占内存
if h == nil {
h = new(hmap)
}
// 生成随机哈希种子
h.hash0 = fastrand()
// Find the size parameter B which will hold the requested # of elements.
// For hint < 0 overLoadFactor returns false since hint < bucketCnt.
// 根据传入的 hint 和 负载因子,计算出需要的最小桶数量。
B := uint8(0)
for overLoadFactor(hint, B) {
B++
}
h.B = B
// allocate initial hash table 分配最初的哈希表内存
// if B == 0, the buckets field is allocated lazily later (in mapassign) B == 0 时,延迟分配
// If hint is large zeroing this memory could take a while. hint 比较大时,耗时比较长
if h.B != 0 {
var nextOverflow *bmap
// 初始化 buckets(分配 buckets 所需要的内存),可能会提前分配一些空闲的溢出桶,以备后续使用,提升速度
h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
// 如果预分配了空闲的溢出桶数组,则初始化 mapextra 字段,用 h.extra.nextOverflow 指向第一个溢出桶
if nextOverflow != nil {
h.extra = new(mapextra)
h.extra.nextOverflow = nextOverflow
}
}
return h
}
// overLoadFactor 放置在 2^B 个桶中的键值对数量是否超过负载因子。
func overLoadFactor(count int, B uint8) bool {
// 第一步:判断一个桶能否装下,可以装下 B 就不用算了
// 第二步:count 是否大于 桶数量(2^B) * 负载因子(6.5),如果比这都大,说明现在的 B 负载不了,还需要 ++
// bucketShift(B) = 2^B
// loadFactorNum / loadFactorDen = 6.5
return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}为什么说 map 是引用类型,看 makemap 函数返回值就明白了,返回了 *hmap 一个指针;这一点需要和 slice 区分开,创建切片源码返回的是一个结构体,只不过是结构体里有底层数组指针,有兴趣可以看这一篇文章:切片原理分析。3.2 makeBucketArray 初始化 map buckets 底层数组makemap 函数中当 h.B != 0 时,makeBucketArray 函数会初始化 map buckets 的底层数组,还可能会预分配空闲溢出桶的内存。// makeBucketArray initializes a backing array for map buckets.
// 1<<b is the minimum number of buckets to allocate.
// dirtyalloc should either be nil or a bucket array previously
// allocated by makeBucketArray with the same t and b parameters.
// If dirtyalloc is nil a new backing array will be alloced and
// otherwise dirtyalloc will be cleared and reused as backing array.
// makeBucketArray 初始化 map buckets 的底层数组
// 最小存储桶数为 1<<b,也就是 2^b
// dirtyalloc 应该是 nil 或之前由 makeBucketArray 使用相同的 t 和 b 参数分配的存储桶数组。
// 如果 dirtyalloc 为 nil,将分配一段新的内存;否则将清除 dirtyalloc 指向的内存,将其作为新分配的内存。
func makeBucketArray(t *maptype, b uint8, dirtyalloc unsafe.Pointer) (buckets unsafe.Pointer, nextOverflow *bmap) {
// nbuckets = base = 2^b
base := bucketShift(b)
nbuckets := base
// For small b, overflow buckets are unlikely.
// Avoid the overhead of the calculation.
// 对于 b 很小的情况,溢出桶的可能性不大(由于数据较少,哈希冲突少),避免计算开销(减少溢出桶的预分配)
// 当 b >= 4 认为溢出桶的使用概率变大,可以预先分配一下溢出桶的内存,提升后续使用 map 的性能
if b >= 4 {
// Add on the estimated number of overflow buckets
// required to insert the median number of elements
// used with this value of b.
// 总存储桶数量 = 普通存储桶 + 溢出桶数量 (2^(b-4))
nbuckets += bucketShift(b - 4)
sz := t.bucket.size * nbuckets // 存储桶所需内存
up := roundupsize(sz) // 内存对齐后所占内存
if up != sz {
// 根据新内存重新计算存储桶数量
nbuckets = up / t.bucket.size
}
}
if dirtyalloc == nil {
// 创建 t.bucket 类型新数组
buckets = newarray(t.bucket, int(nbuckets))
} else {
// dirtyalloc was previously generated by
// the above newarray(t.bucket, int(nbuckets))
// but may not be empty.
// 清空原有数组内存,作为该 map 的数组使用
buckets = dirtyalloc
size := t.bucket.size * nbuckets
if t.bucket.ptrdata != 0 {
// bucket 中有指针,用有指针的清理函数
memclrHasPointers(buckets, size)
} else {
// bucket 中没有指针,用没有指针的清理函数
memclrNoHeapPointers(buckets, size)
}
}
// 有预分配的溢出桶情况
if base != nbuckets {
// We preallocated some overflow buckets.
// To keep the overhead of tracking these overflow buckets to a minimum,
// we use the convention that if a preallocated overflow bucket's overflow
// pointer is nil, then there are more available by bumping the pointer.
// We need a safe non-nil pointer for the last overflow bucket; just use buckets.
// nextOverflow 指向溢出桶的开始地址(溢出桶地址在普通存储桶后边)
nextOverflow = (*bmap)(add(buckets, base*uintptr(t.bucketsize)))
// 获取最后的一个溢出桶
last := (*bmap)(add(buckets, (nbuckets-1)*uintptr(t.bucketsize)))
// 最后一个溢出桶的 overflow 指针链接到第一个普通桶
last.setoverflow(t, (*bmap)(buckets))
}
return buckets, nextOverflow
}通过阅读 makeBucketArray 函数源码,我们可以发现预分配的溢出桶数组的尾部的溢出桶有一点点特殊,它的 overflow 字段并不是 nil(其余溢出桶都是 nil),这样一来,我们通过判断溢出桶的 overflow 是否为 nil 就可以知道是否是最后一个空闲溢出桶。如果是最后一个空闲溢出桶,那么将 map 里面的 extra.nextOverflow 字段设置为 nil,表示预分配的空闲溢出桶用完了,后面如果再需要溢出桶的时候,就只能直接 new 一个了。4.map 读取读取 map 一般有两种形式,分别对应两个调用函数:// 获取某键的值
v := m["key"] → v := runtime.mapaccess1(maptype, m, "key")
v, ok := m["key"] → v, ok := runtime.mapaccess2(maptype, m, "key")为了更好地理解源码的内容,我们先来学习一下在 map 中如何定位一个 key。4.1 定位 key 的方式当向 map 插入一条数据,或者是从 map 按 key 查询数据的时候,运行时都会使用哈希函数对 key 做哈希运算,并获得一个哈希值(hashcode,在 64 位机器上为 64 个 bit 位)。这个 hashcode 是定位 key 的关键,运行时会从 hashcode 中取得高 8 位和 低 B(hmap.B) 位 ,其中低位区的值用于选定 bucket,高位区的值用于在某个 bucket 中确定 key 的位置(也就是 tophash 的值)。我把这一过程整理成了下面这张示意图,你理解起来可以更直观:每个 bucket 的 tophash 区域其实是用来快速定位 key 位置的,这样就避免了逐个 key 进行比较这种代价较大的操作。尤其是当 key 是 size 较大的字符串类型时,好处就更突出了。这是一种以空间换时间的思路,避免的是 key 本身的内容过大导致的慢,而不是把遍历 tophash 这个过程砍掉。换句话说:比较 8 个 bit 的 tophash 肯定是比比较字符串更快的。当然,后续我们分析源码也会看到,比较 key 是否相等这一步骤是绕不过去的,因为哈希冲突的存在,低 B 位使得两个 key 落在了一个桶里,还会有更小的概率,这俩 key 哈希值的 tophash 也相等,此时只能通过 key 是否相等来进行区分(map 中 key 是唯一的);同理,找到了哈希值低 B 位和高8位相等的槽,并不能确定的说这就是我们要找的槽,还是需要比较 key 是否相等,做最终的判断。因此,tophash 只是对比较 key 的次数进行了优化,绕不过比较 key 是否相等这一层。4.2 mapaccess1 源码解析mapaccess1 和 mapaccess2 函数实现基本一致,只是返回值不同,这里我们以 mapaccess1 为例子进行分析,查找的关键有两点:key 的定位问题,上一小节已经详细讲述。在扩容过程中查找,需要注意 bucket 是否已经迁移,未进行迁移的桶,需要在旧桶中查找。// mapaccess1 返回一个指向 h[key] 的指针。 永远不会返回 nil,
// 如果键不在映射中,它将返回对 elem 类型的零对象的引用。
// NOTE: The returned pointer may keep the whole map live, so don't
// hold onto it for very long.(因为返回的是指针,会导致整个 map 不能被GC)
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
// ...
// 如果 h 什么都没有,返回零值
if h == nil || h.count == 0 {
if t.hashMightPanic() {
t.hasher(key, 0) // see issue 23734
}
return unsafe.Pointer(&zeroVal[0])
}
// 并发读和写冲突(hashWriting 写 map 时标志位为 1)
// 所以 map 并发读是可以的,并发读写会 panic
if h.flags&hashWriting != 0 {
fatal("concurrent map read and map write")
}
// 不同类型 key 使用的 hash 算法在编译期确定,t *maptype 参数确定哈希函数
// 计算哈希值,并且加入 hash0 引入随机性
hash := t.hasher(key, uintptr(h.hash0))
// 比如 B=5,那 m 就是31,二进制是全 1
// 定位 bucket num 时,将 hash 与 m 相与,
// 最终由 hash 的低 8 位决定 key 在哪个 bucket 中
// 和子网掩码作用类似
m := bucketMask(h.B)
// b 就是具体 bucket 的首地址(h.buckets 首地址 + 偏移量)
b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
// 如果 map 正经历扩容,需要重新定位,需要从旧桶中读取
if c := h.oldbuckets; c != nil {
// 不是等量扩容,需要重新计算 m
if !h.sameSizeGrow() {
// 不是等量扩容,则将 m 除以 2,因为是 2 倍扩容,
// 所以 buckets 的大小为 oldbuckets 长度的 2 倍,
// 除以 2 才是旧的桶数量
m >>= 1
}
// key 在 oldbuckets 中所在 bucket 的首地址
oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))
// 如果该 bucket 尚未迁移到新的 buckets 中
if !evacuated(oldb) {
// b 尚未迁移到新的 buckets 中,还在 oldbuckets 中
// 则需要从旧桶中查找
b = oldb
}
}
// 计算高 8 位,用于定位 key 具体位置(下边有具体实现)
top := tophash(hash)
bucketloop:
// 遍历溢出桶所有的 bucket(这相当于是一个 bucket 链表)。
// 第一次遍历的是普通桶
for ; b != nil; b = b.overflow(t) {
// 循环遍历 bucketCnt = 8 个元素(一个 bucket 存储 8 个 key)
for i := uintptr(0); i < bucketCnt; i++ {
// tophash 不匹配不是这个槽,continue,继续遍历下一个 cell
if b.tophash[i] != top {
// emptyRest 为 tophash 标志位:
// 这个 cell 是空的,并且在更高的索引或溢出处没有更多的非空 cell(bucket 后边没 key了)。
// 如果等于这个标志位,后续就不需要再进行遍历了
if b.tophash[i] == emptyRest {
break bucketloop
}
continue
}
// tophash 找到了,但有极小可能冲突,需要继续判断 key 是否相等
// k 定位到 key 的首地址
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
// 如果 key 是指针
if t.indirectkey() {
// 解引用,找到真正的 key 值
k = *((*unsafe.Pointer)(k))
}
// 比较 key 是否相等
if t.key.equal(key, k) {
// e:value 首地址
e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
// value 为指针
if t.indirectelem() {
// 解引用
e = *((*unsafe.Pointer)(e))
}
return e
}
// tophash 相等,但 key 不等,继续遍历下一次 cell
}
}
// 如果键不在映射中,它将返回对 elem 类型的零对象的引用。
return unsafe.Pointer(&zeroVal[0])
}
// 判断该 bucket 是否迁移完毕
func evacuated(b *bmap) bool {
h := b.tophash[0]
return h > emptyOne && h < minTopHash
}
// tophash 计算 hash 的 tophash 值。
// 这是一个字节的大小的。(hash 最高的 8 位)
func tophash(hash uintptr) uint8 {
// top 本质上就是 hash 的前面 8 个字节
// goarch.PtrSize*8 - 8,左移位数:指针的字节大小 - 8 字节
top := uint8(hash >> (goarch.PtrSize*8 - 8))
// 为了跟正常的 tophash 区分开来,如果计算出来的 tophash 小于 minTopHash,
// 会将计算出来的 tophash 加上 minTopHash:
// 这样一来,通过 tophash 这一个字节就可以记录桶里面槽的状态了,非常节省空间。
if top < minTopHash {
top += minTopHash
}
return top
}
// 计算 overflow 指向的 bmap
func (b *bmap) overflow(t *maptype) *bmap {
return *(**bmap)(add(unsafe.Pointer(b), uintptr(t.bucketsize)-goarch.PtrSize))
}这里再强调一下 overflow 的计算方式,uintptr(t.bucketsize)-goarch.PtrSize 得到了溢出桶字段在 bmap 的偏移量(goarch.PtrSize 为机器上一个字节的大小,也是一个指针的大小),通过寻址的方式 add(unsafe.Pointer(b), uintptr(t.bucketsize)-goarch.PtrSize) 找到了 bmap.overflow 字段的地址,进而获取 bmap 指针。5.map 删除map 删除对应的函数为:mapdelete。如果删除发生在扩容过程中,需要先把定位到的 bucket 迁移完毕后才进行删除,顺便还要为渐进式迁移做出额外贡献:顺序迁移一个 bucket。func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
if raceenabled && h != nil {
callerpc := getcallerpc()
pc := abi.FuncPCABIInternal(mapdelete)
racewritepc(unsafe.Pointer(h), callerpc, pc)
raceReadObjectPC(t.key, key, callerpc, pc)
}
if msanenabled && h != nil {
msanread(key, t.key.size)
}
if asanenabled && h != nil {
asanread(key, t.key.size)
}
// map 为 空,直接返回
if h == nil || h.count == 0 {
if t.hashMightPanic() {
t.hasher(key, 0) // see issue 23734
}
return
}
// 并发写检查
if h.flags&hashWriting != 0 {
fatal("concurrent map writes")
}
// 计算 key 的 hash 值
hash := t.hasher(key, uintptr(h.hash0))
// Set hashWriting after calling t.hasher, since t.hasher may panic,
// in which case we have not actually done a write (delete).
// 设置写 map 标志
h.flags ^= hashWriting
// 定位 key 所在 bucket
bucket := hash & bucketMask(h.B)
// 正在扩容
if h.growing() {
// 迁移该 bucket 到新地址
growWork(t, h, bucket)
}
// b 指向 key 所在 bucket
b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize)))
// 记录原始的 b
bOrig := b
// 计算 tophash
top := tophash(hash)
search:
// 循环整个 bucket 链表
for ; b != nil; b = b.overflow(t) {
// 循环 8 个槽
for i := uintptr(0); i < bucketCnt; i++ {
// tophash 不匹配
if b.tophash[i] != top {
// emptyRest:bucket 后边数据都为空,不用继续循环了
if b.tophash[i] == emptyRest {
break search
}
// 直到找到匹配的 tophash
continue
}
// 获取 key(处理 key 为指针的情况)
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
k2 := k
if t.indirectkey() {
k2 = *((*unsafe.Pointer)(k2))
}
// key 不相等,继续循环 bucket
if !t.key.equal(key, k2) {
continue
}
// 找到相等的 key
// Only clear key if there are pointers in it.
// 仅当其中有指针时才清除键
if t.indirectkey() {
// 当 key 存储为指针
// 指针指向 nil,并不主动清理内存(由 GC 处理)
*(*unsafe.Pointer)(k) = nil
} else if t.key.ptrdata != 0 {
// key 本身为指针
// 清除指针内存
memclrHasPointers(k, t.key.size)
}
// 获取 elem(value);同理 key 的清空过程
e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
if t.indirectelem() {
*(*unsafe.Pointer)(e) = nil
} else if t.elem.ptrdata != 0 {
memclrHasPointers(e, t.elem.size)
} else {
// 清除不包含指针的 elem 的内存
memclrNoHeapPointers(e, t.elem.size)
}
// tophash 置为标志位 emptyOne,此槽为空
b.tophash[i] = emptyOne
// If the bucket now ends in a bunch of emptyOne states,
// change those to emptyRest states.
// 如果这个 bucket 后边以一堆 emptyOne 为结尾,则可以设置为 emptyRest
// It would be nice to make this a separate function, but
// for loops are not currently inlineable.
if i == bucketCnt-1 {
// key 为 bucket 中最后一个槽
// 溢出桶不为空 && 溢出桶的第一个标志位不为 emptyRest
if b.overflow(t) != nil && b.overflow(t).tophash[0] != emptyRest {
// 不是最后一个元素,后边还有数据,直接跳过 for 循环
goto notLast
}
} else {
// 当 key 不为 bucket 最后一个槽时,直接检查后一个槽是否为 emptyRest
// 即可判断后边是否还有数据
if b.tophash[i+1] != emptyRest {
goto notLast
}
}
// 执行到这里的时候,表明刚刚删除的 key 是 bucket 或其溢出桶中的最后一个有数据的元素,后续都没数据了
// 倒着往前数,把 emptyOne 标志位都置为 emptyRest,直到有数据或链表到头
// emptyRest 标志可以优化遍历 map 的性能
for {
// 将当前 b.tophash[i] 设置为 emptyRest,标志后边没有元素了
// 循环往前设置 emptyRest,直到槽内有数据或链表到头
b.tophash[i] = emptyRest
// 从 i 开始往前遍历,直到 i == 0
// 当遍历到 i == 0 时,分两种情况
// 1. 链表到头了
// 2. 需要找到链表的前一个,继续循环遍历,然后赋值 emptyRest
if i == 0 {
// 如果 b 是链表最原始的头(也就是普通桶)
if b == bOrig {
// 找到初始桶,就意味着链表到头了,可以结束循环了
break // beginning of initial bucket, we're done.
}
// Find previous bucket, continue at its last entry.
// 找到当前 bucket 的前一个 bmap
c := b
for b = bOrig; b.overflow(t) != c; b = b.overflow(t) {
}
// 继续遍历前一个桶的槽
i = bucketCnt - 1
} else {
i--
}
// 如果当前 i 标志位不为 emptyOne,说明槽内有数据,则终止循环
if b.tophash[i] != emptyOne {
break
}
}
notLast:
// 由于删除了元素,因此 count--
h.count--
// Reset the hash seed to make it more difficult for attackers to
// repeatedly trigger hash collisions. See issue 25237.
// 如果 map 内元素清零
if h.count == 0 {
// 重置哈希种子,使攻击者更难重复触发哈希冲突。 请参阅问题 25237。
h.hash0 = fastrand()
}
// 已经删除了对应的 value,退出循环
break search
}
}
// 写冲突判断
if h.flags&hashWriting == 0 {
fatal("concurrent map writes")
}
// 消除写标志
h.flags &^= hashWriting
}map 删除逻辑有几个非常优秀的设计值得一提:当 key、value 被编译器优化为以指针形式存储的时候(indirectkey、indirectelem),源码只是将对应槽中的区域指针指向了 nil,并没有主动触发清理内存,节省了时间,最终由 GC 回收。当 key 不是指针时,源码中并没有对 key 的内存进行清理,但 elem 却被清理了,是不是有一点懵啊!原因这里解释一下:首先没有指针,不清理内存不妨碍 GC;被删除的 key 的位置被插入新 key 源码会使用 typedmemmove(t.key, insertk, key) 对其内存进行覆盖,所以不清理内存也是 ok 的。但为啥又把 elem 的内存给清了呢?这里我们留一个疑问,等看完下一小节,我们再来揭晓。emptyRest 状态的设置也是对性能的一种优化,通过两种标志位 emptyOne、emptyRest 和倒循环写法,在删除元素时,完成了对桶中元素的标记,最终提升了 map 遍历性能,emptyRest 在读取、修改、插入、删除等等,都有实际的应用;虽然迭代器部分没有使用,但后续官方也有优化的意向,写了 todo 事项,真的为官方这种优化精神而感动。6.map 修改和写入map 修改和写入共用一个函数:mapassign。同样,修改和写入如果发生在扩容期间,也会触发 bucket 迁移,最多两个 bucket 进行迁移,也是等 bucket 迁移完毕,对新 bucket 进行修改和写入;另外写入 key 还有可能会额外触发扩容操作,一旦发生扩容,key 需要重新进行计算和写入。6.1 mapassign 修改和写入 map// 功能:插入 key 或者修改 map 中的 key
// Like mapaccess, but allocates a slot for the key if it is not present in the map.
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
// map 为 nil,触发 panic
if h == nil {
panic(plainError("assignment to entry in nil map"))
}
if raceenabled {
callerpc := getcallerpc()
pc := abi.FuncPCABIInternal(mapassign)
racewritepc(unsafe.Pointer(h), callerpc, pc)
raceReadObjectPC(t.key, key, callerpc, pc)
}
if msanenabled {
msanread(key, t.key.size)
}
if asanenabled {
asanread(key, t.key.size)
}
// 并发写,panic
if h.flags&hashWriting != 0 {
fatal("concurrent map writes")
}
// 计算 key 的哈希值
hash := t.hasher(key, uintptr(h.hash0))
// Set hashWriting after calling t.hasher, since t.hasher may panic,
// in which case we have not actually done a write.
// 标记正在写 map(map 并发读写、并发写不安全)
h.flags ^= hashWriting
// 如果 buckets 为 nil,则新建一个
if h.buckets == nil {
h.buckets = newobject(t.bucket) // newarray(t.bucket, 1)
}
again:
// 获取 bucket 下标(定位 key 在哪个 bucket)
bucket := hash & bucketMask(h.B)
// 正在扩容
if h.growing() {
// 迁移该 bucket
growWork(t, h, bucket)
}
// b 为 key 所属的 bucket
b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize)))
// 计算 tophash 高 8 位
top := tophash(hash)
var inserti *uint8 // 记录要插入的 tophash 地址
var insertk unsafe.Pointer // 记录要插入的 key 地址
var elem unsafe.Pointer // 记录要插入的 value(elem) 地址
bucketloop:
for {
// 循环 bucket 中的 8 个槽
for i := uintptr(0); i < bucketCnt; i++ {
if b.tophash[i] != top {
// tophash 不匹配
// 槽为空 && 还没记录要插入的位置
if isEmpty(b.tophash[i]) && inserti == nil {
// 记录要插入的位置 tophash、key、value
inserti = &b.tophash[i]
insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
}
// 如果 tophash 为标志位 emptyRest,意味着 bucket 没有找到对应的 key,同时 bucket 中剩余的槽都是空的。
// 后边就没必要循环了
if b.tophash[i] == emptyRest {
// 终止对 bucket 的循环
break bucketloop
}
// 没找到对应的 tophash,就继续找
continue
}
// 走到这里,意味着找到对应的 tophash 了
// 获取 key
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
// key 是否包含指针
if t.indirectkey() {
// 解引用
k = *((*unsafe.Pointer)(k))
}
// key 是否相等(寻找 key 的最终判断条件)
if !t.key.equal(key, k) {
continue
}
// already have a mapping for key. Update it.
// 已存在该 key,则更新值
// 看看 key 是否需要被覆盖
if t.needkeyupdate() {
typedmemmove(t.key, k, key)
}
// 获取 value 的地址,最终返回 elem 的地址,在函数外按地址对值进行更新,这样就不用把新值传进来了
elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
// 找到 key 和 value,后续就可以结束了
goto done
}
// 获取下一个溢出桶地址
ovf := b.overflow(t)
// 溢出桶到头了
if ovf == nil {
// 跳出 bucket 循环
break
}
// 去下一个溢出桶继续找 key
b = ovf
}
// Did not find mapping for key. Allocate new cell & add entry.
// 没找到 key,说明要插入新 key,需要给 key 分配新槽
// If we hit the max load factor or we have too many overflow buckets,
// and we're not already in the middle of growing, start growing.
// 非扩容过程中 && (超过负载因子 || 有太多溢出桶) -> 触发扩容
if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
// 哈希表扩容
hashGrow(t, h)
// 扩容后上述所做工作全部无效,需要重新再来一遍
goto again // Growing the table invalidates everything, so try again
}
// 没找到可插入的地方
if inserti == nil {
// The current bucket and all the overflow buckets connected to it are full, allocate a new one.
// 新建一个溢出桶
newb := h.newoverflow(t, b)
// 记录要插入的位置
inserti = &newb.tophash[0]
insertk = add(unsafe.Pointer(newb), dataOffset)
elem = add(insertk, bucketCnt*uintptr(t.keysize))
}
// store new key/elem at insert position
// key 是否需要存储指针(key 太大时,需要存储指针类型)
if t.indirectkey() {
kmem := newobject(t.key) // 给新 key 分配内存(只是分配了地址,还没有写入 key)
*(*unsafe.Pointer)(insertk) = kmem // 槽中的 key 值区域保存了新 key 应在的内存指针
insertk = kmem // insertk 指向新分配的地址,不再指向槽中的 key 值区
}
// value 同理 key 操作
if t.indirectelem() {
vmem := newobject(t.elem)
*(*unsafe.Pointer)(elem) = vmem
}
// 移动 key 到 insertK 指向的地址
// 最终槽中的 key 值区域保存了新 key 应在的内存指针,该指针指向了新 key 值
// 如果不需要 indirectkey,则槽中的 key 值区域直接保存 key 值
typedmemmove(t.key, insertk, key)
*inserti = top // 保存 tophash 值
h.count++ // map 元素个数 +1
done:
// 如果写冲突,panic
if h.flags&hashWriting == 0 {
fatal("concurrent map writes")
}
// 清除写标志
h.flags &^= hashWriting
// 如果 elem 保存的是指向 elem 的指针
if t.indirectelem() {
// 获取指向 elem 实际存储地址的指针(由于返回 elem 用于修改 value,所以需要操作 value 的地址)
elem = *((*unsafe.Pointer)(elem))
}
// 返回存储值的指针
return elem
}还记得上一小节留下了一个小小的疑问吗?当 key、elem 都不是指针时,删除 key 时只清理了 elem 的内存,却保留了 key 的内存,虽然这么写代码是合理的,但优点是啥呢?我们看了插入源码可以发现,插入 key 时使用了 typedmemmove(t.key, insertk, key) 函数对 key 的内存进行了覆盖,这样删除 key 时不清理内存逻辑是合理的,其实也是节省了清理内存的性能的,但插入的时候性能不就有损了吗?我们继续看一下 typedmemmove 函数的代码,相信你就理解了。func typedmemmove(typ *_type, dst, src unsafe.Pointer) {
// 当内存覆盖的源和目的一样,直接就 return 了,否则才进行内存覆盖
if dst == src {
return
}
if writeBarrier.needed && typ.ptrdata != 0 {
bulkBarrierPreWrite(uintptr(dst), uintptr(src), typ.ptrdata)
}
// There's a race here: if some other goroutine can write to
// src, it may change some pointer in src after we've
// performed the write barrier but before we perform the
// memory copy. This safe because the write performed by that
// other goroutine must also be accompanied by a write
// barrier, so at worst we've unnecessarily greyed the old
// pointer that was in src.
memmove(dst, src, typ.size)
if writeBarrier.cgo {
cgoCheckMemmove(typ, dst, src, 0, typ.size)
}
}到这里,相信你已经明白为啥唯独不处理 key 了,原因就是被删除的 key 重新被写回 map 的概率很高,同时这个 key 重新再被插到同一个槽的概率也很高,当重新被写回来时,就不用处理内存了,这样可以极大地提升性能;而一个被删除的 key ,再次被插进来时,value 还是同一个 value 的概率可太小了(相信各位写业务代码的时候都能感受到),因此还不如直接把 value 清理了,这样代码的可读性会更好,更符合我们编码的习惯,通过阅读源码可以发现,golang 源码的工程师是把性能优化考虑到了极致,希望你阅读本篇文章可以感受到这一点。6.2 newoverflow 新建溢出桶// 新建一个溢出桶
func (h *hmap) newoverflow(t *maptype, b *bmap) *bmap {
var ovf *bmap
// 优先使用创建 map 时预分配的溢出桶 h.extra.nextOverflow
if h.extra != nil && h.extra.nextOverflow != nil {
// We have preallocated overflow buckets available.
// See makeBucketArray for more details.
ovf = h.extra.nextOverflow
if ovf.overflow(t) == nil {
// 预分配的溢出桶中指向下一个溢出桶的字段为 nil,意味着还没有用完预分配的溢出桶(不懂的看前面分析)
// We're not at the end of the preallocated overflow buckets. Bump the pointer.
// h.extra.nextOverflow 指向下一个可用的溢出桶
h.extra.nextOverflow = (*bmap)(add(unsafe.Pointer(ovf), uintptr(t.bucketsize)))
} else {
// This is the last preallocated overflow bucket.
// Reset the overflow pointer on this bucket,
// which was set to a non-nil sentinel value.
// 已经用到最后一个预分配的溢出桶,设置其内部指向下一个溢出桶的字段为 nil(因为要用,需要恢复初始值)
ovf.setoverflow(t, nil)
// 这里表示预分配的溢出桶用完了
h.extra.nextOverflow = nil
}
} else {
// 没有预分配的溢出桶,只能重新建一个
ovf = (*bmap)(newobject(t.bucket))
}
// h.noverflow 溢出桶数量增加,记录溢出桶数量
// 溢出桶数量小时,是精确值,否则为大概计数,源码很简单,有兴趣可以看一下
h.incrnoverflow()
// map 中 key,value 不含指针
if t.bucket.ptrdata == 0 {
// 创建 overflow 切片
h.createOverflow()
// 切片指向所有溢出桶(保活使用,防止被 GC)
*h.extra.overflow = append(*h.extra.overflow, ovf)
}
// 给当前 bucket 的 overflow 赋值指向溢出桶
b.setoverflow(t, ovf)
return ovf
}当 bucket 8个槽都存满的情况下,map 会新建溢出桶,此时会优先使用 makeBucketArray 函数创建的空闲溢出桶,还记得存在哪里吗?不记得的话,可以再去看一下第 3 小节。空闲溢出桶存储在 h.extra.nextOverflow 字段中,如果空闲溢出桶不为 nil,说明达到最后一个空闲溢出桶,将 h.extra.nextOverflow 置为 nil 即可标志用完了。同时,在创建好新的溢出桶的时候,h.extra.overflow的保活机制就开始干活了,不了解的可以回头看一下第 1 小节的 extra 字段的作用讲解部分。7.map 扩容使用哈希表的目的就是要快速查找到目标 key,然而,随着向 map 中添加的 key 越来越多,key 发生碰撞的概率也越来越大;bucket 中的 8 个槽会被逐渐塞满,通过前面的源码分析,我们也知道,查找、插入、删除 key 的效率也会越来越低。最理想的情况是一个 bucket 只装一个 key,这样就能达到 O(1) 的效率,但这样空间消耗太大,用空间换时间的代价太高。而 Go 对 map 的设计中,一个 bucket 能装载 8 个 key,定位到 bucket 后,还需要再定位到具体的 key,其实是一种时间换空间的操作。超过 8 个的 key 冲突,以链表的形式解决,当然这也要有度,不然一个 bucket 存储太多的 key,性能会直接退化为链表,操作效率变为 O(n)。因此,需要一个指标来衡量 bucket 整体的负载情况,也就是“装载因子”。装载因子计算公式:loadFactor := count / (2^B);count:map 中元素个数;2^B 表示 bucket 数量。7.1 扩容触发条件扩容触发的时机:map 插入新 key 时,会检测触发条件,满足条件则会触发扩容。 在 mapassign 函数中触发条件是这样写的:// If we hit the max load factor or we have too many overflow buckets,
// and we're not already in the middle of growing, start growing.
// 非扩容过程中 && (超过负载因子 || 有太多溢出桶) -> 触发扩容
if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
// 哈希表扩容
hashGrow(t, h)
// 扩容后上述所做工作全部无效,需要重新再来一遍
goto again // Growing the table invalidates everything, so try again
}
// overLoadFactor reports whether count items placed in 1<<B buckets is over loadFactor.
func overLoadFactor(count int, B uint8) bool {
// loadFactorNum = 13;loadFactorDen = 2
// bucketShift(B) = 2^B
return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}
// tooManyOverflowBuckets reports whether noverflow buckets is too many for a map with 1<<B buckets.
// Note that most of these overflow buckets must be in sparse use;
// if use was dense, then we'd have already triggered regular map growth.
// 判断是否有太多的溢出桶了
// 多的标准是:
// B <= 15 的时候,溢出桶数量大于 2^B 的时候
// B > 15 的时候,溢出桶的数量大于 2^15 的时候
func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
// If the threshold is too low, we do extraneous work.
// If the threshold is too high, maps that grow and shrink can hold on to lots of unused memory.
// "too many" means (approximately) as many overflow buckets as regular buckets.
// See incrnoverflow for more details.
if B > 15 {
B = 15
}
// The compiler doesn't see here that B < 16; mask B to generate shorter shift code.
return noverflow >= uint16(1)<<(B&15)
}扩容触发条件总结为两点:装载因子超过阈值,源码里定义的阈值是 6.5。如果每个 bucket 都没有溢出,并且 8 个槽都装满的情况下,装载因子 = 8,6.5 也就意味着很多 bucket 都快装满了,查找和插入效率都会变低,这个时候扩容是很有必要的。至于装载因子为啥是 6.5,官方注释给的解释是应该实验得出的,实验数据在源码也能找到,这里就不展示了。针对元素过多的这种情况,Go 给的扩容方式为 2 倍扩容,即 B + 1,bucket 由原来的 2 ^ B 变为 2 ^ B * 2 = 2^(B+1)。溢出桶的数量过多。当装载因子较小的时候,有时候会发现 map 的查找和插入效率也很低,原因是溢出桶太多了,但由于 map 中元素少,触发不了第一种条件,但查询效率变低;这种是由于大量的插入和删除元素导致的:比如先插入了大量的元素,创建了很多的 overflow,然后删除后,继续插入,触发不了第一种扩容机制,但 key 会因为大量 overflow 的存在变得很分散,导致查询效率变低。针对这种元素不多,overflow 过多的情况,bucket 并没有装满,只是太分散了,解决办法就是开辟一个新 bucket 空间(与原空间等量为 2 ^ B),将老 bucket 中的元素移动到新 bucket,使得同一个 bucket 中的 key 排列地更紧密。这样,原来,在 overflow bucket 中的 key 可以移动到 bucket 中来。结果是节省空间,提高 bucket 利用率,map 的查找和插入效率自然就会提升。7.2 扩容源码解析由于 map 扩容需要将原有的 key/value 重新搬迁到新的内存地址,如果有大量的 key/value 需要搬迁,会非常影响性能。因此 Go map 的扩容采取了一种称为“渐进式”的方式,原有的 key 并不会一次性搬迁完毕,每次最多只会搬迁 2 个 bucket。 因此,map 扩容分为两个阶段:hashGrow函数:分配新的内存空间,用于存储新的 buckets,并将老的 buckets 挂到 oldbuckets 字段上。growWork函数:搬迁旧 bucket 到新 bucket 中。触发搬迁的地方在 mapassign 和 mapdelete 函数中,也就是在插入、修改或删除 key 的操作里。7.2.1 hashGrow 函数func hashGrow(t *maptype, h *hmap) {
// If we've hit the load factor, get bigger.
// Otherwise, there are too many overflow buckets,
// so keep the same number of buckets and "grow" laterally.
// 扩容分为等量和2倍量
bigger := uint8(1)
if !overLoadFactor(h.count+1, h.B) {
// 未达到装载因子,等量扩容
bigger = 0
// 标志位置为等量扩容
h.flags |= sameSizeGrow
}
// h.buckets 挂载到 oldbuckets 字段
oldbuckets := h.buckets
// 分配新的 bucket 数组,和预分配新的溢出桶
newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)
// flags 先把迭代器标志置为 0,用于后续记录迭代器在使用新的还是旧的 bucket
flags := h.flags &^ (iterator | oldIterator)
if h.flags&iterator != 0 {
// 当前 hmap 在迭代器中
// flags 迭代器标志位置为在使用旧 bucket,因为在扩容前发生的
flags |= oldIterator
}
// commit the grow (atomic wrt gc)
// 提交扩容操作
h.B += bigger // 等量或2倍量扩容
h.flags = flags // 迭代器使用新旧 bucket 标志位确认(迭代器初始化会把新旧标志位都置为 1)
h.oldbuckets = oldbuckets // 挂载旧 buckets
h.buckets = newbuckets // 挂载新 buckets
h.nevacuate = 0 // 初始化迁移进度
h.noverflow = 0 // 新 buckets 溢出桶数量为 0
// h.extra.overflow != nil 意味着 k-v 都不是指针,溢出桶存储在 overflow 字段保活
if h.extra != nil && h.extra.overflow != nil {
// Promote current overflow buckets to the old generation.
if h.extra.oldoverflow != nil {
throw("oldoverflow is not nil")
}
// 旧溢出桶存储到 oldoverflow
h.extra.oldoverflow = h.extra.overflow
// 新溢出桶个数为 0 ,置空即可
h.extra.overflow = nil
}
if nextOverflow != nil {
if h.extra == nil {
h.extra = new(mapextra)
}
// 指向预分配的新空白溢出桶
h.extra.nextOverflow = nextOverflow
}
// the actual copying of the hash table data is done incrementally
// by growWork() and evacuate().
// 哈希表数据的实际迁移过程是通过 growWork() 和 evacuate() 增量完成的。
}hashGrow 函数逻辑比较简单,其中值得一提是对 h.flags 的处理,这里其实是和下一小节迭代器有关系,我这里把代码放到一起看,你会更明白:func hashGrow(t *maptype, h *hmap) {
// flags 先把迭代器标志置为 0,用于后续记录迭代器在使用新的还是旧的 bucket
flags := h.flags &^ (iterator | oldIterator)
if h.flags&iterator != 0 {
// 当前 hmap 在迭代中
// flags 迭代器标志位置为在使用旧 bucket,因为在扩容前发生的
flags |= oldIterator
}
h.flags = flags // 迭代器使用新旧 bucket 标志位确认(迭代器初始化会把新旧标志位都置为 1)
}
// 迭代器初始化函数
func mapiterinit(t *maptype, h *hmap, it *hiter) {
// Remember we have an iterator.
// Can run concurrently with another mapiterinit().
// 记住我们有一个迭代器。
// 可以与另一个 mapiteinit() 同时运行。
// 设置 hmap 迭代器标志位为 iterator|oldIterator
// 1. 迭代前未进行扩容,迭代器初始化为 11,迭代过程中发送扩容,标志位被更改为 10
// 表示迭代器正在使用旧 hmap.oldbuckets,迁移完毕不能其清理内存,标志新 bucket 没有被迭代器使用
// 2. 迭代器初始化时正在经历扩容,标志为 11,表示 hmap.buckets 正在被迭代器使用
// 此时,迭代器也会访问 hmap.oldbuckets 数据,因此也不能进行清理
if old := h.flags; old&(iterator|oldIterator) != iterator|oldIterator {
atomic.Or8(&h.flags, iterator|oldIterator)
}
}迭代器初始化函数 mapiterinit 初始化迭代器时会把 h.flags 字段中 iterator 和 oldIterator 对应位都置为 1,因为迭代器使用旧 buckets 和 新 buckets 的可能性都是存在的,有可能在迭代器使用过程中发生扩容,此时可以确定的认为该迭代器的标志位为 oldIterator,在 hashGrow 函数中,将 h.flags 字段置为 oldIterator。7.2.2 growWork 函数以 mapassign 函数中触发为例,会执行两个函数:growing 和 growWork,代码如下:func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
// 正在扩容
if h.growing() {
// 迁移该 bucket
growWork(t, h, bucket)
}
}
// growing reports whether h is growing. The growth may be to the same size or bigger.
func (h *hmap) growing() bool {
// 当 h.oldbuckets != nil 时,说明正在扩容中
return h.oldbuckets != nil
}
func growWork(t *maptype, h *hmap, bucket uintptr) {
// make sure we evacuate the oldbucket corresponding
// to the bucket we're about to use
// 把即将使用的旧存储桶迁移到新桶
evacuate(t, h, bucket&h.oldbucketmask())
// evacuate one more oldbucket to make progress on growing
// 仍然处于扩容中
if h.growing() {
// 按顺序触发迁移
evacuate(t, h, h.nevacuate)
}
}bucket&h.oldbucketmask() 这行代码,如源码注释里说的,是为了确认搬迁的 bucket 是我们正在使用的 bucket。oldbucketmask() 函数返回扩容前的 map 的 bucketmask。bucket&h.oldbucketmask() = oldbucket 旧存储桶的下标。我们发现 growWork 函数会最多进行两次 bucket 迁移,每次迁移一个 bucket,如果定位到的 bucket 已经迁移完毕,或者刚好定位到的 bucket 与顺序迁移的 bucket 一致,则此过程只迁移一个 bucket,否则迁移两个 bucket。顺序迁移,由标志位 nevacuate 决定旧 buckets 数组的下标。写操作导致的迁移, bucket&h.oldbucketmask() 用于计算旧 buckets 数组的下标。接下来继续分析源码 evacuate 函数,逻辑有点复杂,这里理解的关键点在于 2 倍扩容期间的迁移操作。由于扩容是 2 倍的,也就意味着一个 bucket 会裂变为两个 bucket,那 oldbucket 里的 key 该如何分配呢?其实也很简单,buckets 数组由原来的 2^B 裂变为 2^(B+1) 个;那定位 key 时,就该计算低 B+1 位,来定位新 buckets 数组的下标了。其实就是多了一个二进制位,我们都知道一个二进制位代表了两种情况 0 或 1。假设 B = 5,原低 B 位为 00110,也就是定位到 buckets[6];如今 2 倍扩容后,该 key 可能的情况有 000110 = 6 或 100110 = 38,裂变为了两个 bucket 下标,其中一个和迁移前一致;一致的部分被称之为低位部分:x 部分;不一致的部分被称之为高位部分:y 部分;后续迁移以及迭代器部分都要使用 xy,这里需要记一下。 接下来,我们逐句分析一下源码:// 迁移目的地
type evacDst struct {
b *bmap // 迁移目的地 bucket
i int // 槽数组的下标
k unsafe.Pointer // 当前槽 key 的地址指针
e unsafe.Pointer // 当前槽 elem 的地址指针
}
func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
// 定位老的 bucket 地址
b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
// newbit = 2^B,该变量的意思是新 bit 位,如果是二倍扩容,旧 bucket 会发生裂变,产生新的 bit 位
newbit := h.noldbuckets()
// 如果 b 尚未进行迁移
if !evacuated(b) {
// TODO: reuse overflow buckets instead of using new ones, if there
// is no iterator using the old buckets. (If !oldIterator.)
// xy contains the x and y (low and high) evacuation destinations.
// xy 变量包含 x 和 y(低和高)迁移目的地。(2倍迁移,bucket 下标的前一位多了 0 或 1,分为 x 和 y)
var xy [2]evacDst
x := &xy[0]
// x 表示裂变的前半部分,在新桶数组的下标与旧桶数组下标一致
// 存储新桶要迁移的地址
x.b = (*bmap)(add(h.buckets, oldbucket*uintptr(t.bucketsize)))
x.k = add(unsafe.Pointer(x.b), dataOffset)
x.e = add(x.k, bucketCnt*uintptr(t.keysize))
// 非等量扩容,则为2倍扩容
if !h.sameSizeGrow() {
// Only calculate y pointers if we're growing bigger.
// Otherwise GC can see bad pointers.
// 2倍扩容存在 y 区域,也就是新 bit 位 = 1
// y 中存储新桶裂变的后半部分迁移地址
y := &xy[1]
// 2 倍裂变的 y 部分下标 = oldbucket+newbit
// 例如:oldbucket = 111 三位,newbit = 1000 四位;oldbucket+newbit = 1111 新下标
y.b = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(t.bucketsize)))
y.k = add(unsafe.Pointer(y.b), dataOffset)
y.e = add(y.k, bucketCnt*uintptr(t.keysize))
}
// 循环当前迁移旧 bucket 的整个链表结构
for ; b != nil; b = b.overflow(t) {
k := add(unsafe.Pointer(b), dataOffset) // 取出第一个 key
e := add(k, bucketCnt*uintptr(t.keysize)) // 取出第一个 value
// 循环 8 个槽
for i := 0; i < bucketCnt; i, k, e = i+1, add(k, uintptr(t.keysize)), add(e, uintptr(t.elemsize)) {
// 获取 tophash
top := b.tophash[i]
// 槽上数据为空
if isEmpty(top) {
// 标记已迁移,且 cell 为空
b.tophash[i] = evacuatedEmpty
// 处理下一个槽
continue
}
// top 不能为已迁移标志,否则有问题,相当于已经迁移的又迁移一遍
if top < minTopHash {
throw("bad map state")
}
// 获取 key 真正的值
k2 := k
if t.indirectkey() {
k2 = *((*unsafe.Pointer)(k2))
}
var useY uint8 // 数据迁移的裂变去向 0:表示x;1:表示 y
// 非等量扩容
if !h.sameSizeGrow() {
// Compute hash to make our evacuation decision (whether we need
// to send this key/elem to bucket x or bucket y).
// 计算 key 的哈希值,判断 key 的去向,是 x 还是 y 部分(等量扩容,直接迁移去 x 部分)
hash := t.hasher(k2, uintptr(h.hash0))
// 存在迭代器 && key != key && key !equal key
if h.flags&iterator != 0 && !t.reflexivekey() && !t.key.equal(k2, k2) {
// If key != key (NaNs), then the hash could be (and probably
// will be) entirely different from the old hash. Moreover,
// it isn't reproducible. Reproducibility is required in the
// presence of iterators, as our evacuation decision must
// match whatever decision the iterator made.
// Fortunately, we have the freedom to send these keys either
// way. Also, tophash is meaningless for these kinds of keys.
// We let the low bit of tophash drive the evacuation decision.
// We recompute a new random tophash for the next level so
// these keys will get evenly distributed across all buckets
// after multiple grows.
// 如果 key != key(不是一个数字),key 每次的哈希值都不一样,它是不可再现的。
// 在迭代器存在的情况下,可重复性是必需的,因为我们的迁移决策必须与迭代器所做的任何决策相匹配,
// 迭代器还要利用迁移后的数据进行遍历 map。
// 幸运的是,我们可以任意发送这些 key,因为这些值,靠 key 计算哈希是访问不到的,只有迭代器可以访问。
// 此外,tophash 对于这些类型的 key 没有意义。我们让 tophash 的最低位的决定如何迁移。
// 我们为下一个级别重新计算一个新的随机 tophash,这样在多次扩容后,这些 key 将均匀分布在所有桶中。
useY = top & 1
top = tophash(hash)
} else {
// key 可计算 hash,且幂等的情况下,使用 hash 的新 bit 位,决定迁移数据的去向
if hash&newbit != 0 {
// hash新 bit 位为 1,应该去往 y 部分,否则去往 x 部分
useY = 1
}
}
}
if evacuatedX+1 != evacuatedY || evacuatedX^1 != evacuatedY {
throw("bad evacuatedN")
}
// tophash 标志位设置为 evacuatedX + useY (迁移去了 x 还是 y 部分)
b.tophash[i] = evacuatedX + useY // evacuatedX + 1 == evacuatedY
// 使用新 bucket 的目的地
dst := &xy[useY] // evacuation destination
// 目标 bucket 装不下了,使用溢出桶
if dst.i == bucketCnt {
// 创建一个溢出桶,更新 dst 迁移目的地
dst.b = h.newoverflow(t, dst.b)
dst.i = 0
dst.k = add(unsafe.Pointer(dst.b), dataOffset)
dst.e = add(dst.k, bucketCnt*uintptr(t.keysize))
}
// 记录 tophash (dst.i&(bucketCnt-1) 避免边界检查)
dst.b.tophash[dst.i&(bucketCnt-1)] = top // mask dst.i as an optimization, to avoid a bounds check
// key 优化为指针存储
if t.indirectkey() {
// 修改 bucket 槽中指向 key 值的指针
*(*unsafe.Pointer)(dst.k) = k2 // copy pointer
} else {
// 修改 bucket 槽中保存 key 值的内存
typedmemmove(t.key, dst.k, k) // copy elem
}
// value 优化为指针存储
if t.indirectelem() {
// 修改 bucket 槽中指向 value 值的指针
*(*unsafe.Pointer)(dst.e) = *(*unsafe.Pointer)(e)
} else {
// 修改 bucket 槽中保存 value 值的内存
typedmemmove(t.elem, dst.e, e)
}
// 通过改变 dst 内部指针的方式,改变了 &xy[useY] 指向下一个槽,继续进行迁移
dst.i++
// These updates might push these pointers past the end of the
// key or elem arrays. That's ok, as we have the overflow pointer
// at the end of the bucket to protect against pointing past the
// end of the bucket.
// 这些更新可能会将这些指针推过 key 或 elem 数组的末尾。
// 没关系,因为我们在桶的末端有溢出指针,以防止指针超出桶的末端。
// 到达溢出桶,会重新改变 dst 内部指针的指向
dst.k = add(dst.k, uintptr(t.keysize))
dst.e = add(dst.e, uintptr(t.elemsize))
}
}
// Unlink the overflow buckets & clear key/elem to help GC.
// 该 bucket 迁移完成,看是否需要清理内存
// 取消链接溢出桶并清除 key/elem 以帮助 GC。(清除旧桶的指针内存)
// hmap 不在迭代器中(迭代器没有使用旧 buckets) && key、value 包含指针 -> 此时应该清理内存
if h.flags&oldIterator == 0 && t.bucket.ptrdata != 0 {
// 定位旧 bucket 地址
b := add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))
// Preserve b.tophash because the evacuation
// state is maintained there.
// 不能清除 tophash 的内存,因为有标志位
// dataOffset 表示 tophash 的内存大小
ptr := add(b, dataOffset)
n := uintptr(t.bucketsize) - dataOffset
// memclrHasPointers 清除从 ptr 开始的 n 个字节的类型化内存。
memclrHasPointers(ptr, n)
}
}
// 迁移的桶为顺序迁移的位置,需要更新 nevacuate 字段,表示已经迁移多少桶
// 表示该下标前的桶都迁移完毕了
if oldbucket == h.nevacuate {
advanceEvacuationMark(h, t, newbit)
}
}advanceEvacuationMark 函数用于记录顺序迁移的进度,小于 nevacuate 下标的 bucket 表示已经完成迁移。当全部迁移完成时,会把 old 相关字段置空,但旧桶内存并没有被清理,只是表示停止指向 oldbuckets 的相关内存,如果此时存在迭代器,需要迭代器自己保活 oldbuckets 相关内存,否则会被 GC 回收。// 记录迁移进度
func advanceEvacuationMark(h *hmap, t *maptype, newbit uintptr) {
h.nevacuate++
// Experiments suggest that 1024 is overkill by at least an order of magnitude.
// Put it in there as a safeguard anyway, to ensure O(1) behavior.
// 实验表明 1024 至少大了一个数量级。
// 把它放在那里作为保障,以确保 O(1) 行为。
// 往后找一个未迁移的桶
// 桶分两种情况进行迁移
// 1. 顺序迁移,2. 当前正在插入、修改、删除的 bucket 进行迁移
// 所以 h.nevacuate + 1 可能已经被迁移,所以需要一个搜索范围,控制最坏情况,也就是 1024
// 从 h.nevacuate + 1 最多遍历 1024
stop := h.nevacuate + 1024
// 如果 stop > 桶最大数量下标 :说明桶都遍历到最后了,还没到 stop 是不合理的,因此最大值为 newbit
if stop > newbit {
stop = newbit
}
// 还未遍历到未迁移的桶
for h.nevacuate != stop && bucketEvacuated(t, h, h.nevacuate) {
// 已迁移标志 ++
h.nevacuate++
}
// 遍历到最后,也没有找到未迁移的桶,表示迁移完毕了
if h.nevacuate == newbit { // newbit == # of oldbuckets
// Growing is all done. Free old main bucket array.
// 释放 oldbuckets 字段(释放旧桶数组)
h.oldbuckets = nil
// Can discard old overflow buckets as well.
// If they are still referenced by an iterator,
// then the iterator holds a pointers to the slice.
// 也可以丢弃旧的溢出桶。
// 如果它们仍然被迭代器引用,那么迭代器将保存指向切片的指针,用于保活,防止被 GC。
//(但是 h 不再需要保存指向切片的指针)
if h.extra != nil {
h.extra.oldoverflow = nil
}
// 新 map 清除等量扩容标志
h.flags &^= sameSizeGrow
}
}8.map 迭代器从编译器源码 gofrontend/go/statements.cc/For_range_statement::do_lower() 中,可以看到 for...range 对 map 有以下注释:// The loop we generate:
// var hiter map_iteration_struct
// for mapiterinit(type, range, &hiter); hiter.key != nil; mapiternext(&hiter) {
// index_temp = *hiter.key
// value_temp = *hiter.val
// index = index_temp
// value = value_temp
// original body
// }注释源码分析:初始化迭代器使用 mapiterinit(type, range, &hiter);hiter 为迭代器结构体。hiter.key != nil 为 for range 的终止条件,由 mapiternext 函数内部设置。遍历 map 时没有指定循环次数,每一次迭代需要调用 mapiternext 函数获取新值,每调用一次 mapiternext,hiter.key 和 hiter.val 就会变化为下一个 map 元素,给外部 for range 使用。8.1 mapiterinit 迭代器初始化看源码之前,需要先理解一下迭代器 hiter 和 hmap 之间的关系,hiter 在初始化的时候不仅关联了 hmap,例如 hiter.h 字段,可以获取 hmap 的最新值;hiter 还记录 hmap 的快照数据(快照即为定格),迭代器是按照快照指向的 buckets 数组来进行遍历的。// 哈希迭代结构。
type hiter struct {
key unsafe.Pointer // 必须排在第一位。 写入 nil 表示迭代结束
elem unsafe.Pointer // 必须在第二个位置(参见 cmd/compile/internal/walk/range.go)。
t *maptype // map 的类型信息,包括 key、elem 的类型等信息
h *hmap // 需要迭代的 hmap
// hiter 初始化时的 bucket 指针(迭代器初始化时,设置的 map 当前最新的 buckets = hmap.buckets)
buckets unsafe.Pointer
bptr *bmap // 当前正在遍历的 bucket
overflow *[]*bmap // 保持 hmap.buckets 的溢出桶存活
oldoverflow *[]*bmap // 保持 hmap.oldbuckets 的溢出桶存活
startBucket uintptr // bucket 迭代开始位置(随机选择的 bucket)
offset uint8 // 在迭代期间开始的桶内偏移量(应该足够大以容纳 bucketCnt-1)
// 已经从桶数组的末尾环绕到开始(比如从中间 bucket 开始遍历,
// 经过最大 bucket 时,从 0 下标 bucket 重新开始,此时该参数为 true)
wrapped bool
B uint8 // 就是当前遍历的 hmap 的那个 B
i uint8 // 当前遍历的 bucket 内 key 的索引
bucket uintptr // 当前遍历的 bucket
checkBucket uintptr // 是否需要检查 bucket 迁移裂变的位置
}
func mapiterinit(t *maptype, h *hmap, it *hiter) {
if raceenabled && h != nil {
callerpc := getcallerpc()
racereadpc(unsafe.Pointer(h), callerpc, abi.FuncPCABIInternal(mapiterinit))
}
// 记录 map 的类型信息
it.t = t
if h == nil || h.count == 0 {
return
}
if unsafe.Sizeof(hiter{})/goarch.PtrSize != 12 {
throw("hash_iter size incorrect") // see cmd/compile/internal/reflectdata/reflect.go
}
// 关联上 map
it.h = h
// grab snapshot of bucket state
// 抓取桶状态快照,记录迭代器初始化的时候桶状态 (记住 it 是 hmap 的快照)
it.B = h.B
it.buckets = h.buckets
// bucket 中存储的内容不包含指针
if t.bucket.ptrdata == 0 {
// Allocate the current slice and remember pointers to both current and old.
// This preserves all relevant overflow buckets alive even if
// the table grows and/or overflow buckets are added to the table
// while we are iterating.
// 分配当前切片并记住指向当前切片和旧切片的指针。
// 即使在迭代时表增长或溢出桶添加到表中,这也会保留所有相关的溢出桶。
// 迭代器给溢出桶保活,迭代器迭代期间,溢出桶不能被 GC
h.createOverflow()
it.overflow = h.extra.overflow
it.oldoverflow = h.extra.oldoverflow
}
// decide where to start
// 决定从哪里开始遍历(这也是 map 迭代器每次顺序都随机的原理所在)
var r uintptr
if h.B > 31-bucketCntBits {
r = uintptr(fastrand64())
} else {
r = uintptr(fastrand())
}
// 随机迭代开始的 bucket 下标
it.startBucket = r & bucketMask(h.B)
// 随机迭代开始的 cell 下标(bucket 的槽也是随机的,每个 bucket 都从这个槽开始遍历,转一圈)
it.offset = uint8(r >> h.B & (bucketCnt - 1))
// iterator state
// 当前遍历到的 bucket 下标(初始值)
it.bucket = it.startBucket
// Remember we have an iterator.
// Can run concurrently with another mapiterinit().
// 记住我们有一个迭代器。
// 可以与另一个 mapiteinit() 同时运行。
// 设置 hmap 迭代器标志位为 iterator|oldIterator
// 1. 迭代前未进行扩容,迭代器初始化为 11,迭代过程中发送扩容,标志位被更改为 10
// 表示迭代器正在使用旧 hmap.oldbuckets,迁移完毕不能其清理内存,标志新 bucket 没有被迭代器使用
// 2. 迭代器初始化时正在经历扩容,标志为 11,表示 hmap.buckets 正在被迭代器使用
// 此时,迭代器也会访问 hmap.oldbuckets 数据,因此也不能进行清理
if old := h.flags; old&(iterator|oldIterator) != iterator|oldIterator {
atomic.Or8(&h.flags, iterator|oldIterator)
}
// 开始一次遍历
mapiternext(it)
}这里着重讲解一下遍历 map 两个随机起点的计算方式:bucket 数组下标的起始值 it.startBucket = r & bucketMask(h.B),利用随机数 r 的后 B 位确定起始的 bucket 下标。每一个 bucket 起始槽的偏移量 it.offset = uint8(r >> h.B & (bucketCnt - 1)) ,bucketCnt - 1 = 7,二进制也就是 111,r 右移 B 位后与 7 相与,得到一个 0~7 的槽下标。map 底层是使用哈希表实现的,插入数据位置是随机的,所以遍历过程中新插入的数据不能保证遍历到,有可能在遍历过程中插入到已遍历过的 bucket 中,则没有机会进行遍历。8.2 mapiternext 遍历下一个元素map 遍历的核心在于理解 2 倍扩容时,老 bucket 会分裂到 2 个新 bucket 中去。而遍历操作,可能会按照新 bucket 的序号顺序进行,碰到老 bucket 未搬迁的情况时,要在老 bucket 中找到将来要搬迁到新 bucket 来的 key。func mapiternext(it *hiter) {
// 每一次元素遍历都需要调用一次 mapiternext 函数,
// 例如:如果 map 中有10个值,则需要调用 10 次 mapiternext
// 调用由 for ... range 完成,通过 it.key = nil,it.elem = nil 终止调用 mapiternext
// h 为正在迭代的底层 hmap 实例
h := it.h
if raceenabled {
callerpc := getcallerpc()
racereadpc(unsafe.Pointer(h), callerpc, abi.FuncPCABIInternal(mapiternext))
}
// 正在插入、修改、删除 key 但时候,不能获取下一个 key,
// 注意:这不能保证插入、修改、删除之后,进行迭代。
if h.flags&hashWriting != 0 {
fatal("concurrent map iteration and map write")
}
t := it.t // map 中存储的 key、val 类型 *maptype
bucket := it.bucket // 当前遍历到的 bucket 下标(int 类型),从随机的 startBucket 开始,每次调用 mapiternext 重新赋值
b := it.bptr // 当前正在遍历的 bucket 指针,这个值存在的时候,不用重新的再去找 bucket 了
i := it.i // 迭代器当前遍历到的位置(bucket 内 key 的位置)
checkBucket := it.checkBucket // 当前 bucket 是否需要检查迁移裂变位置 x,y
next:
// 首次遍历 b == nil,为其赋初始值 startBucket
// 遍历溢出桶 b == nil,表示该 bucket 链表遍历完毕,可以为其赋值下一个要遍历的 bucket 链表了
if b == nil {
// 遍历完成,把迭代器中 key,elem 置为 nil,退出循环
// for...range 查看到 key 是 nil 会中止迭代
if bucket == it.startBucket && it.wrapped {
// end of iteration
it.key = nil
it.elem = nil
return
}
// 首先得知道,迭代器是根据快照内容进行遍历的
// 1.如果 it 快照指向了 oldbuckets,直接遍历 oldbuckets 就行了,不用管迁不迁移
// 因为 oldbuckets 内存不会被清除,但 h.oldbuckets 字段在迁移完毕时,会等于 nil
// 2.如果 it 快照指向了新 buckets && 2倍扩容过程中,需要按2倍下标遍历,
// 才需要考虑当前 bucket 所属旧 bucket 是否被迁移,
// 如果没有被迁移,则需要去旧桶读取数据,等量扩容直接读取旧数据就可以了,2倍扩容需要按 key 裂变位置读取。
// hmap 正在扩容 && 迭代器快照 B == hmap.B
// 1. 迭代器初始化的时候,hmap 已经在扩容过程中,此时迭代器指向新 buckets
// 2. 迭代器初始化后,hmap 才发生扩容,但为等量扩容,此时迭代器指向旧 buckets
if h.growing() && it.B == h.B {
// 如果迭代器是在扩容过程中启动的,扩容尚未完成。
// 如果我们正在查看的 bucket 尚未迁移,说明数据在 oldbucket 中,
// 那么我们需要遍历旧存储桶,同时只返回将迁移到此存储桶的数据。
// 那些需要迁移到另一个下标的桶则跳过。
// 通过 &mask 的方式获取旧存储桶的下标
oldbucket := bucket & it.h.oldbucketmask()
// 获取旧存储桶中当前遍历 bucket 地址
b = (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
// 是否迁移完成
if !evacuated(b) {
// 旧 bucket 未进行迁移
// 需要检查 bucket 的裂变情况 checkBucket 为要检查的 bucket 下标
checkBucket = bucket
} else {
// bucket 已迁移(在新的也有数据,在旧的也有数据)
// 直接用迭代器指向的 buckets 遍历即可
b = (*bmap)(add(it.buckets, bucket*uintptr(t.bucketsize)))
checkBucket = noCheck
}
} else {
// 1.不在扩容中(未进行扩容 || 扩容结束),直接去迭代器指向的 it.buckets 读取数据即可
// 2.在扩容中 && it.B != h.B -> 表示 it.buckets 指向了 oldbuckets,
// 里边有数据,直接遍历就行,不用在意迁移过程
// 第2种情况下,这里如果有数据插入到新桶,会导致遍历不到,所以不建议并发执行遍历和插入操作
// 而且 key 插入具有随机性,也有可能遍历不到
b = (*bmap)(add(it.buckets, bucket*uintptr(t.bucketsize)))
checkBucket = noCheck
}
// bucket 下标加1,指向下一个 bucket
bucket++
// 判断下标是否是最后一个 bucket (bucketShift(it.B) 计算最大桶的下标)
if bucket == bucketShift(it.B) {
// 已经从第一个随机定位的 bucket 遍历到最后一个 bucket 了,下一个应该是第一个 bucket 了
// 下标从 0 开始,继续循环(达到环形数组的效果,前面的 bucket 可能还未遍历)
bucket = 0
// 标志位:已经从桶数组的末尾环绕到开始
it.wrapped = true
}
// 开始遍历新的 bucket 的时候,重置 i
i = 0
}
// 遍历当前 bucket 的 8 个 cell
for ; i < bucketCnt; i++ {
// 计算桶内 cell 下标:从随机的 it.offset 下标开始遍历
offi := (i + it.offset) & (bucketCnt - 1)
// 如果槽是空的,或者 key 已经迁移,则跳过。(利用 tophash 的标志位判断)
if isEmpty(b.tophash[offi]) || b.tophash[offi] == evacuatedEmpty {
// TODO: emptyRest is hard to use here, as we start iterating
// in the middle of a bucket. It's feasible, just tricky.
continue
}
// 定位第 i 个槽的 key
k := add(unsafe.Pointer(b), dataOffset+uintptr(offi)*uintptr(t.keysize))
if t.indirectkey() {
k = *((*unsafe.Pointer)(k))
}
// 定位第 i 个槽的 value
e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+uintptr(offi)*uintptr(t.elemsize))
// 需要检查迁移后裂变的位置 && 增量扩容 -> 需要判断迁移之后的 key 落入的是 h.buckets 的前半部分还是后半部分(x 还是 y)
// 前半部分 x 则需要在旧存储桶中获取属于该裂变部分的值
// 后半部分 y 则跳过,后边还会遍历到 y 所属的新的 bucket,那个时候再进行处理即可
if checkBucket != noCheck && !h.sameSizeGrow() {
// Special case: iterator was started during a grow to a larger size
// and the grow is not done yet. We're working on a bucket whose
// oldbucket has not been evacuated yet. Or at least, it wasn't
// evacuated when we started the bucket. So we're iterating
// through the oldbucket, skipping any keys that will go
// to the other new bucket (each oldbucket expands to two
// buckets during a grow).
// 如果 k 是可比较的
if t.reflexivekey() || t.key.equal(k, k) {
// If the item in the oldbucket is not destined for
// the current new bucket in the iteration, skip it.
hash := t.hasher(k, uintptr(h.hash0))
// k 的 hash 后最终落到地 bucket 不是 checkBucket(意味着是后半部分),则跳过
// 寻找被迁移到 checkBucket 的 key
if hash&bucketMask(it.B) != checkBucket {
continue
}
} else {
// Hash isn't repeatable if k != k (NaNs). We need a
// repeatable and randomish choice of which direction
// to send NaNs during evacuation. We'll use the low
// bit of tophash to decide which way NaNs go.
// NOTE: this case is why we need two evacuate tophash
// values, evacuatedX and evacuatedY, that differ in
// their low bit.
// 对于不可比较的 key,
// 是由 tophash 的最低位来决定迁移到前半部分还是后半部分的。
if checkBucket>>(it.B-1) != uintptr(b.tophash[offi]&1) {
continue
}
}
}
if (b.tophash[offi] != evacuatedX && b.tophash[offi] != evacuatedY) ||
!(t.reflexivekey() || t.key.equal(k, k)) {
// This is the golden data, we can return it.
// OR
// key!=key, so the entry can't be deleted or updated, so we can just return it.
// That's lucky for us because when key!=key we can't look it up successfully.
// 没有进行迁移 || key 不可比较
// key!=key,所以该条目不能被删除或更新,所以我们只能返回它。
// 这对我们来说很幸运,因为当 key!=key 时我们无法成功查找它。
it.key = k
if t.indirectelem() {
e = *((*unsafe.Pointer)(e))
}
it.elem = e
} else {
// The hash table has grown since the iterator was started.
// The golden data for this key is now somewhere else.
// Check the current hash table for the data.
// This code handles the case where the key
// has been deleted, updated, or deleted and reinserted.
// NOTE: we need to regrab the key as it has potentially been
// updated to an equal() but not identical key (e.g. +0.0 vs -0.0).
// 自迭代器启动以来,哈希表已扩容。
// key 已经被迁移到其他地方,所以需要检查当前哈希表中的数据。
// 此代码处理已删除、更新或删除并重新插入 key 的情况。
// 注意:我们需要重新标记 key,因为它可能已更新为 equal() 但不是相同的 key(例如 +0.0 vs -0.0)。
// 获取当前遍历到的 key/elem。
rk, re := mapaccessK(t, h, k)
if rk == nil {
continue // key has been deleted
}
it.key = rk
it.elem = re
}
// 每遍历一个元素,调用一次 mapiternext 函数,找到 key/elem 就返回
// 记录迭代器当前迭代内容,然后进行下一次迭代
// 记录下一个要遍历的 bucket 下标
it.bucket = bucket
// 记录当前正在遍历的 bucket
if it.bptr != b { // avoid unnecessary write barrier; see issue 14921
it.bptr = b
}
// 记录遍历到哪个槽了
it.i = i + 1
// 记录是否需要检查 key 迁移裂变位置
it.checkBucket = checkBucket
return
}
// for 循环 8 个 cell 都没有 key 需要返回,则继续遍历溢出的桶
b = b.overflow(t)
i = 0
goto next
}通过源码分析,我们知道迭代器会给 hmap 初始化快照,当迭代器初始化后, map 发生扩容,此时迭代器快照是指向 oldbuckets 数组;如果此时插入、修改或删除值,按源码来看,会先对 bucket 进行迁移,然后插入新的 bucket,然而迭代器会根据 oldbuckets 进行遍历,则无法遍历到改变的值,因此官方是不建议遍历 map 和写操作并发执行的。小结通过对 map 底层源码的分析,解决了很多我对 map 特性的困惑,这里总结一下:map 是引用类型:因为创建 map 的函数返回了一个指针。map 遍历是无序的:桶起点和槽偏移点都是随机的。map 是非线程安全的:并发读可以,并发读写、写写会 panic,写操作发生时,会标记 hmap.flags, 如果同时有其他协程读写,会抛出错误,导致 panic。map 使用链表法解决哈希冲突:一个 bucket 有 8个槽 + overflow 指针指向 bmap 链表。map 扩容分为2倍扩容和等量扩容两种,扩容触发操作发生在插入 key 期间,触发条件为装载因子和过多的溢出桶。map 没有缩容机制,容量不会变小,即使删除 key,也不会减少 map 内存;由于没有缩容机制 map 内存存在一直变大的风险,所以大规模的存储尽量别用 map,要不会把你的内存干爆;后续希望官方增加缩容代码,看起来实现比较复杂。map 的迁移是渐进式的,迁移动作发生在写操作期间,写操作发生时,每次最多迁移两个桶,最少迁移一个桶。删除不存在的 key 不会发生错误;查询不存在的 key,会返回对应类型的零值。
cathoy
9.最具研读价值的 Go 源码之一:context 包
前言你了解 Context 中的回溯链和树结构吗?想知道 Context 如何触发级联取消吗?本文将换个角度聊一聊 golang 中的 context,让你真正理解什么是 Context。context 包中的代码虽然只有 600 多行,但已经成为了并发控制、超时控制的标准做法,可以说是真正的短小而精悍,是十分值得研读的 Go 源码之一。本文首先从整体的视角解析了 context 的主要接口和函数,分析了其中重要结构的实现关系,以及存储所用的数据结构;随后针对 context 接口不同的实现源码进行了详细的解析,可以帮助大家更有效理解不同 Context 的实现原理。(关于具体使用,我们下期再聊)注:本文依据 go 版本 1.20.7 源码进行讲解 源码地址:src/context/context.go1.context 简介1.1 context 是什么?context 在 Go 1.7 版本被引入标准库,包 context 定义了 Context 类型,它携带跨 API 边界和进程之间的截止日期、取消信号和其他请求范围的值。context 在 Golang 中的作用是为了提供一种在函数之间传递请求作用域数据、控制请求执行时间、处理并发任务等功能的机制,以便更加有效地管理和控制请求的执行。特别是在处理并发请求和微服务架构中,context 的作用更加凸显。1.2 为什么需要 context举一个常见的例子:大量 http 请求不断地访问服务器,每一个请求都会开多个协程去处理这个请求的业务逻辑,例如:获取用户信息(基本信息、权限信息、其他额外的信息等),如果下游业务处理逻辑发生异常,长时间未返回处理结果,上游长时间的等待将会导致严重的超时,业务服务可能会因为协程没有释放导致协程泄漏,极端情况还会引发服务器雪崩。因此,协程之间能够进行事件通知并且能控制协程的生命周期非常重要,这时候就应该使用 context 包了,context 主要就是用来在多个协程中设置截止日期、同步信号,传递请求相关值。每一次 context 都会从顶层一层一层的传递到下面一层的协程中,当上面的 context 取消的时候,下面所有的 context 也会随之取消。因此,在 Golang 中引入 context 的主要原因是为了更好地管理并发请求和控制请求的执行。在处理并发请求的情况下,可能会涉及到多个并发任务、超时控制、任务取消等需求,而 context 提供了一种标准化的方式来处理这些问题。具体来说,引入 context 主要有以下几个原因:控制请求执行时间:在处理 HTTP 请求或者其他并发任务时,可以使用 context 来设置截止时间或者超时时间,确保请求不会无限期地执行。这对于避免资源的过度占用和及时释放资源非常重要。取消操作:context 提供了一种统一的机制来取消请求的执行。在某些情况下,可能需要取消已经启动的任务,或者在某些条件下终止任务的执行,这时可以使用 context 来实现。传递请求作用域的数据:context 可以用于在多个函数之间传递请求的元数据,例如请求 ID、用户身份信息、语言环境等。这在处理微服务架构或者处理 HTTP 请求时非常有用。跨 API 边界传递请求作用域数据:当请求需要经过多个 API 边界时,可以使用 context 来传递请求作用域的数据,确保在整个请求处理链路中能够获取到必要的信息和控制。并发任务管理:通过 context 可以控制多个并发任务的执行,比如通过一个 context 对象去取消多个并发任务的执行,或者等待多个任务中的一个完成。总的来说,context 的使用场景涵盖了处理并发请求、控制请求执行时间、取消操作、请求作用域数据传递等多个方面,适用于处理并发请求、微服务架构中的请求处理等多种场景。2.context 源码整体概览研读源码,最好先从整体视角分析一下包中函数、接口、类(Go 中多为结构体)之间的结构关系。下图画出了 context 包中的主要函数、接口和类的关系,context 包主要由两个接口(Context 、canceler) 以及四个结构体 (emptyCtx、valueCtx、cancelCtx、timerCtx) 组成,其中类的实现关系如图所示。emptyCtx 一般作为 root 节点使用;valueCtx、cancelCtx、timerCtx 通过不同的函数依据祖先 Context 派生出来,结构体中通过嵌入的方式拥有对祖先 Context 的回溯机制(被称为回溯链),后续会进行详细介绍。下边这张表列举了 context 包中所有重要的变量、函数、接口以及结构体,起到总览全局和查找复习的作用,等阅读完全文,可以再来回顾一下。类型名称作用Cancelederror 变量errors.New("context canceled")DeadlineExceedederror 变量deadlineExceededError{};deadlineExceededError 结构体实现了 error 接口;return "context deadline exceeded"backgroundContext 实例变量new(emptyCtx)todoContext 实例变量new(emptyCtx)cancelCtxKeyint 变量&cancelCtxKey 被用来寻找第一个父级 cancelCtxclosedchanchannel 变量已关闭的channel,在init 中直接调用了 close(closedchan)CancelCauseFunc函数类型func(cause error) 带原因的取消函数类型定义CancelFunc函数类型type CancelFunc func() 取消函数类型定义Context接口类型定义了 Context 接口(四个方法:Deadline、Done、Err、Value)canceler接口类型定义了取消接口(两个方法:cancel、Done)stringer接口类型该接口只有一个方法:String() stringdeadlineExceededError结构体类型实现了 error 接口emptyCtxint 类型实现了 Context,是个空 ContextcancelCtx结构体类型组合了 Context 接口,用于存储 parent ctx;并实现了 canceler 接口,可以被取消;存储了 map[canceler]struct{} 用于和 children 关联timerCtx结构体类型组合了 cancelCtx 结构体,拥有了 cancelCtx 的功能;携带了 timer 和 deadline 参数,能够定时取消valueCtx结构体类型组合了 Context 接口,用于存储 parent ctx;key, val interface{} 用于存储 key:value(可能为空)Background函数返回一个空 context background,常作为根 contextTODO函数返回一个空 context todoCause函数返回一个 error 表示被取消原因WithCancel函数基于 parent context 创建可取消的 context 和 取消函数WithCancelCause函数基于 parent context 创建可取消的 context 和 带取消原因的取消函数withCancel函数根据祖先 ctx 创建一个可取消的 cancelCtx,当祖先 ctx 取消时安排孩子取消newCancelCtx函数根据祖先 ctx 创建一个可取消的 cancelCtxpropagateCancel函数当祖先 ctx 取消时安排孩子取消;parent context 不可取消 done = nil 不用建立关联;parent context 已取消 -- child.cancel 取消 child;parent context 为 *cancelCtx 时,p.children[child] 利用 cancelCtx 中的 map 结构建立父子关联,方便在祖先取消时,一并取消孩子 ctx;parent context 不为 *cancelCtx 时,单独起携程监测祖先 done 时,取消孩子 ctxparentCancelCtx函数返回 parent 的第一个祖先 cancelCtx 节点removeChild函数用于解除父子关联 delete(p.children, child) map中删除WithDeadline函数创建一个 *timerCtx 包含 deadline ;内部存在一个定时器 timer *time.Timer;时间到的时候执行 cancel 方法WithTimeout函数创建一个 *timerCtx 包含 deadline;WithDeadline(parent, time.Now().Add(timeout))WithValue函数&valueCtx{parent, key, val};创建一个存储 k-v 的 contextinit函数close(closedchan) 初始化关闭 closechanvalue函数聚合实现了不同结构体的 value 方法2.1 接口2.1.1 Context 接口type Context interface {
// 返回一个 channel,用于判断 context 是否结束
// 多次调用同一个 context done 方法会返回相同的 channel
// 当 context 被取消或者到了 deadline,返回一个被关闭的 channel
Done() <-chan struct{}
// 当 context 结束时才会返回错误,有两种情况
// context 被主动调用 cancel 方法取消:Canceled
// context 超时取消: DeadlineExceeded
Err() error
// 返回 context 是否会被取消以及自动取消时间(即 deadline)
Deadline() (deadline time.Time, ok bool)
// 获取 key 对应的 value
Value(key interface{}) interface{}
}简单介绍一下方法的作用:Done()方法返回一个只读的 channel,源码中不会往里塞值,我们都知道读一个关闭的 channel 会读出相应类型的零值,因此只有 channel 被关闭,才能从该 channel 中读出值,子协程也可以根据该 channel 来判断自己是否应该结束。当 Context 被主动取消或者超时自动取消时,该 Context 所有派生 Context 的 done channel 都被 close 。所有子协程通过该字段收到 close 信号后,应该立即中断执行、释放资源然后返回。Err() 返回一个错误,表示 channel 被关闭的原因。例如是被取消(Canceled error),还是超时(DeadlineExceeded error)。Deadline() 如果本 Context 被设置了时限,则该函数返回 ok=true 和对应的到期时间点。否则,返回 ok=false 和 nil。Value() 返回绑定在该 Context 链上的给定的 Key 的值,如果没有,则返回 nil。2.1.2 canceler 接口type canceler interface {
cancel(removeFromParent bool, err, cause error)
Done() <-chan struct{}
}canceler 接口定义了两个方法,实现了这两个方法的 Context 为可取消的 Context,比如: *cancelCtx 和 *timerCtx。cancel()方法为取消操作,负责关闭 Done() 返回的 channel,以及调用所有 children 的 cancel 方法;removeFromParent == true 表示和祖先断绝联系,err, cause 表明取消的错误和理由。Done() 方法返回一个只读的 channel,用于判断 context 是否结束。2.2 实现 Context 接口的结构体在阅读源码后会发现,Context 各种创建方法其实主要只使用到了 4 种类型的 Context 实现,也就是前文经常提到的四种实现,本小节就来简单介绍一下这 4 种类型的 Context。2.2.1 emptyCtxemptyCtx 本质是一个 int 类型,正如其名 emptyCtx 实现的 Context 全部返回了 nil。它通常用于创建 root Context,标准库中 context.Background() 和 context.TODO() 返回的就是这个 emptyCtx。type emptyCtx int
func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
return
}
func (*emptyCtx) Done() <-chan struct{} {
return nil
}
func (*emptyCtx) Err() error {
return nil
}
func (*emptyCtx) Value(key any) any {
return nil
}2.2.2 cancelCtxcancelCtx 是 context 包中十分重要的数据结构,*cancelCtx 是一个可被取消的 Context,*cancelCtx 实现了 canceler 接口,同时内嵌了 Context 作为匿名字段,这样就会被派生为一个 Context。type cancelCtx struct {
Context
mu sync.Mutex // protects following fields
done atomic.Value // of chan struct{}, created lazily, closed by first cancel call
children map[canceler]struct{} // set to nil by the first cancel call
err error // set to non-nil by the first cancel call
cause error // set to non-nil by the first cancel call
}cancelCtx 结构中有一个字段 children map[canceler]struct{} 用于维护 parent canceler 与 children canceler 之间的关系,后面 WithCancel 篇章会详细讲到树结构的建立过程。接下来看一下 *cancelCtx 的方法,首先是 Value() 方法:用于寻找第一个祖先(最近的祖先) *cancelCtx 实例。func (c *cancelCtx) Value(key any) any {
if key == &cancelCtxKey {
return c
}
return value(c.Context, key)
}
func value(c Context, key any) any {
for {
switch ctx := c.(type) {
case *valueCtx:
if key == ctx.key {
return ctx.val
}
c = ctx.Context
case *cancelCtx:
if key == &cancelCtxKey {
return c
}
c = ctx.Context
case *timerCtx:
if key == &cancelCtxKey {
return ctx.cancelCtx
}
c = ctx.Context
case *emptyCtx:
return nil
default:
return c.Value(key)
}
}
}cancelCtxKey 是 context 包中定义的私有变量,如果是 *cancelCtx 遇到 &cancelCtxKey 就会返回自己,否则沿着回溯链往上寻找,看看有没有 parent 是 cancelCtx,如果有就返回寻找到的第一个祖先cancelCtx。这个其实是复用了 Value 函数的回溯逻辑,从而在 Context 树回溯链中遍历时,可以找到给定 Context 的第一个祖先 *cancelCtx 实例。使用方式:p, ok := parent.Value(&cancelCtxKey).(*cancelCtx)。接下来是 Done() 方法:返回一个只读 channel 用于判断是否取消。c.done 是“懒汉式”创建,只有调用了 Done() 方法的时候才会被创建,而且只有 *cancelCtx 实现了非空 Done 函数。func (c *cancelCtx) Done() <-chan struct{} {
// 原子变量获取存储的通道信息
d := c.done.Load()
if d != nil {
// 不为 nil 则直接返回
return d.(chan struct{})
}
// 并发锁 - 要执行并发写操作
c.mu.Lock()
// defer 解锁
defer c.mu.Unlock()
// 二次判断原子信息是否已经被其他协程写入
d = c.done.Load()
if d == nil {
// 没有被写入,则初始化 chan
d = make(chan struct{})
// 存入原子信息
c.done.Store(d)
}
// 返回通道 chan
return d.(chan struct{})
}接下来 Err() 方法:func (c *cancelCtx) Err() error {
c.mu.Lock()
err := c.err
c.mu.Unlock()
return err
}String() 方法func contextName(c Context) string {
if s, ok := c.(stringer); ok {
return s.String()
}
return reflectlite.TypeOf(c).String()
}
func (c *cancelCtx) String() string {
return contextName(c.Context) + ".WithCancel"
}cancel() 方法:该方法用于关闭 c.done 并且取消其 children,该方法不对外暴露,最终以函数返回值形式暴露出去:cancel CancelFunc。// cancel closes c.done, cancels each of c's children, and, if
// removeFromParent is true, removes c from its parent's children.
// cancel sets c.cause to cause if this is the first time c is canceled.
func (c *cancelCtx) cancel(removeFromParent bool, err, cause error) {
if err == nil {
panic("context: internal error: missing cancel error")
}
if cause == nil {
cause = err
}
// 并发写锁
c.mu.Lock()
// err 不为 nil,表示已经被取消过,那则不用继续取消了
if c.err != nil {
// 解锁
c.mu.Unlock()
return // already canceled
}
// 设置取消原因和错误
c.err = err
c.cause = cause
// 取出 chan
d, _ := c.done.Load().(chan struct{})
// chan 未初始化
if d == nil {
// 直接存入已关闭的 chan,通知取消操作
c.done.Store(closedchan)
} else {
// 关闭 chan,通知取消
close(d)
}
// 祖先取消,孩子也得跟着取消(级联取消)
for child := range c.children {
// NOTE: acquiring the child's lock while holding parent's lock.
// 取消 child
child.cancel(false, err, cause)
}
// 断绝与所有后代的关系
c.children = nil
// 解锁
c.mu.Unlock()
// 如果想断绝与祖先的关系
if removeFromParent {
// 断绝与祖先的关系
removeChild(c.Context, c)
}
}
// removeChild removes a context from its parent.
func removeChild(parent Context, child canceler) {
// 回溯找到第一个祖先 *cancelCtx
// 只有 *cancelCtx 中存在 children map[canceler]struct{},才有父子关系
p, ok := parentCancelCtx(parent)
// 没找到,意味着没有父子关系,不用断绝
if !ok {
return
}
p.mu.Lock()
if p.children != nil {
// 祖先的 children map 中移除对该孩子的关系
delete(p.children, child)
}
p.mu.Unlock()
}2.2.3 timerCtx一个 timerCtx 携带一个定时器和一个截止时间,有了这两个配置以后就可以在特定时间进行自动取消;它嵌入了一个 *cancelCtx 来实现 Done 和 Err,它通过停止计时器然后委托给 *cancelCtx.cancel 来实现取消。// A timerCtx carries a timer and a deadline. It embeds a cancelCtx to
// implement Done and Err. It implements cancel by stopping its timer then
// delegating to cancelCtx.cancel.
type timerCtx struct {
*cancelCtx
timer *time.Timer // Under cancelCtx.mu.
deadline time.Time
}Deadline() 方法:返回截止时间func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
return c.deadline, true
}String() 方法func (c *timerCtx) String() string {
return contextName(c.cancelCtx.Context) + ".WithDeadline(" +
c.deadline.String() + " [" +
time.Until(c.deadline).String() + "])"
}cancel() 方法func (c *timerCtx) cancel(removeFromParent bool, err, cause error) {
// 委托给 cancelCtx 执行 cancel
c.cancelCtx.cancel(false, err, cause)
if removeFromParent {
// Remove this timerCtx from its parent cancelCtx's children.
removeChild(c.cancelCtx.Context, c)
}
c.mu.Lock()
if c.timer != nil {
// 停止计时
c.timer.Stop()
c.timer = nil
}
c.mu.Unlock()
}2.2.4 valueCtxvalueCtx 内部同样包含了一个 Context 接口实例,由原 Context 实例派生,valueCtx 结构中包含一对 key-value 用于存储键值对,在调用 valueCtx.Value(key interface{}) 会进行递归向上查找 key 对应的 val,但是这个查找只负责查找 “直系” Context,也就是说可以无限递归查找 parent Context 是否包含这个 key,但是无法查找兄弟 Context 是否包含。// A valueCtx carries a key-value pair. It implements Value for that key and
// delegates all other calls to the embedded Context.
type valueCtx struct {
Context
key, val any
}其所包含方法较为简单,如下:func (c *valueCtx) String() string {
return contextName(c.Context) + ".WithValue(type " +
reflectlite.TypeOf(c.key).String() +
", val " + stringify(c.val) + ")"
}
func (c *valueCtx) Value(key any) any {
if c.key == key {
return c.val
}
return value(c.Context, key)
}3.回溯链与树构建对 context 包有过了解的都知道,Context 是一种回溯链 + 树形结构构成,为了方便大家理解,这里画一个图。图中虚线代表回溯链,实线代表树形结构。回溯链: context 包中可以通过 WithCancel、WithDeadline、 WithTimeout 或 WithValue 等函数根据祖先 Context 派生出孩子 Context,新派生的 Context 嵌入了祖先 Context 实例,形成了回溯链结构(C0~C4),图中由虚线表示,value 函数就是通过该回溯链向上一层层寻找。树形结构: cancelCtx 结构体中包含字段 children map[canceler]struct{},可以关联祖先和孩子节点,Context 树实质上是一颗 canceler(*cancelCtx 和 *timerCtx)树(C1、C2、C4 形成一棵树),map 中只存储了可取消节点间的父子关系,因为在级联取消的时候只需要找到子树中所有的 canceler 节点(循环 map 中的 key,cancelCtx 小节代码有使用到),对其进行取消,就可以完成对子树所有节点生命周期的掌控。有同学会有疑问那 valueCtx 这一层如何被取消呢? 通过对 valueCtx 源码分析可以知道,valueCtx 并没有实现非空 Done 方法,其实四种 ctx 实现中只有 cancelCtx 实现了非空 Done 方法,那也就意味着调用 ctx.Done() 会直接转发到第一个祖先 cancelCtx 上,返回它的 done channel。因此当图中的 C1 节点取消时,关闭了自身的 done channel,而 C3 节点(valueCtx)中的 done channel 其实就是 C1 节点已关闭的 done channel,因此对其生命周期也进行了管控,其实就是自己的生命周期。3.1 回溯链回溯链是通过嵌入父级 Context 来进行构造的,主要作用有以下两点:value() 函数可以沿着回溯链向上查找匹配的键值对。利用 value() 函数逻辑沿着回溯链查找最近的 cancelCtx 祖先,用于构造 Context 树。3.1.1 回溯链的构建回溯链构建与四个主要函数息息相关:WithCancel、WithDeadline、WithTimeout、WithValue, 这里详细分析一下回溯链的构建过程,其他细节(树构建)后边讨论。WithCancel:通过 &cancelCtx{Context: parent} 构建回溯链。func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := withCancel(parent)
return c, func() { c.cancel(true, Canceled, nil) }
}
func withCancel(parent Context) *cancelCtx {
if parent == nil {
panic("cannot create context from nil parent")
}
// 回溯链构建
c := newCancelCtx(parent)
// 树构建(后边详细介绍)
propagateCancel(parent, c)
return c
}
// 回溯链构建
func newCancelCtx(parent Context) *cancelCtx {
return &cancelCtx{Context: parent}
}WithDeadline:通过内嵌 cancelCtx 构建回溯链。func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
if parent == nil {
panic("cannot create context from nil parent")
}
if cur, ok := parent.Deadline(); ok && cur.Before(d) {
// The current deadline is already sooner than the new one.
// 祖先节点的截止时间更早,直接按祖先建立取消就行,反正祖先没了,他也跟着没
// 构造回溯链
return WithCancel(parent)
}
// 构造回溯链
c := &timerCtx{
cancelCtx: newCancelCtx(parent),
deadline: d,
}
// 树构建(后边详细介绍)
propagateCancel(parent, c)
dur := time.Until(d)
if dur <= 0 {
// 设置的时候就已经改取消了,直接调用取消,返回取消 err,意味着已取消
c.cancel(true, DeadlineExceeded, nil) // deadline has already passed
// c.cancel 已经解除与祖先关系,这里返回的 func 就不需要再解除了
return c, func() { c.cancel(false, Canceled, nil) }
}
// 上锁
c.mu.Lock()
// 解锁
defer c.mu.Unlock()
if c.err == nil {
// 还未被取消,因为取消后 err != nil
// 设置定时器,进行取消
c.timer = time.AfterFunc(dur, func() {
c.cancel(true, DeadlineExceeded, nil)
})
}
// 这里返回的 func 需要解除与祖先的关系
return c, func() { c.cancel(true, Canceled, nil) }
}WithTimeout:复用 WithDeadline 构建回溯链。func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}WithValue:通过 &valueCtx{parent, key, val} 构建回溯链。func WithValue(parent Context, key, val any) Context {
if parent == nil {
panic("cannot create context from nil parent")
}
if key == nil {
panic("nil key")
}
if !reflectlite.TypeOf(key).Comparable() {
panic("key is not comparable")
}
return &valueCtx{parent, key, val}
}3.1.2 回溯链的作用value() 函数可以沿着回溯链向上查找匹配的键值对。利用 value() 函数逻辑沿着回溯链查找最近的 cancelCtx 祖先,用于构造 Context 树(这一点需要详细分析一下 parentCancelCtx 函数的代码,后续讲到树构建就会一目了然)。func value(c Context, key any) any {
for {
switch ctx := c.(type) {
case *valueCtx:
if key == ctx.key {
return ctx.val
}
c = ctx.Context
case *cancelCtx:
if key == &cancelCtxKey {
return c
}
c = ctx.Context
case *timerCtx:
if key == &cancelCtxKey {
return ctx.cancelCtx
}
c = ctx.Context
case *emptyCtx:
return nil
default:
return c.Value(key)
}
}
}parentCancelCtx:返回 parent 的第一个祖先 cancelCtx 节点。func parentCancelCtx(parent Context) (*cancelCtx, bool) {
// 返回第一个实现了 Done() 的实例
done := parent.Done()
if done == closedchan || done == nil {
return nil, false
}
// 回溯链中寻找第一个 *cancelCtx
p, ok := parent.Value(&cancelCtxKey).(*cancelCtx)
if !ok {
return nil, false
}
pdone, _ := p.done.Load().(chan struct{})
// 判断回溯链中第一个实现 Done() 的实例是不是 cancelCtx 的实例
if pdone != done {
return nil, false
}
return p, true
}理解该函数代码,主要是这一行会有疑惑:if pdone != done,接下来详细解释一下: 因为只有 *cancelCtx 实现了非空 Done 方法,因此 done := parent.Done() 会返回第一个祖先 cancelCtx 中的 done channel,除非该 Context 回溯链中存在第三方实现的 Context 接口的实例,parent.Done() 才有可能返回其他 channel,如下图所示:假设 C3 是由我们自己实现 Context,parent.Done() 才会返回自己实现的 Done channel。理解到这里,你也就看得懂 parentCancelCtx 函数的代码了,其实就是返回了 parent 的第一个祖先 cancelCtx 节点。为啥一定要找到第一个祖先 cancelCtx 节点呢?其实是为了进行 Context 树的构建,请看下一小节讲解。3.2 Context 树前文提到树形结构和 children map[canceler]struct{} 字段息息相关,主要作用是关联祖先节点和孩子节点之间的关系,最终用于在祖先节点取消时,级联取消所有孩子节点,接下来看一下树结果是如何构建和进行级联取消的。3.2.1 树构建Context 树的构建是在调用 context.WithCancel() 调用时通过 propagateCancel 进行的。调用过程为:WithCancel -> withCancel -> propagateCancel,具体看一下源码:func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := withCancel(parent)
return c, func() { c.cancel(true, Canceled, nil) }
}
func withCancel(parent Context) *cancelCtx {
if parent == nil {
panic("cannot create context from nil parent")
}
// 回溯链
c := newCancelCtx(parent)
// 树构建
propagateCancel(parent, c)
return c
}
func propagateCancel(parent Context, child canceler) {
done := parent.Done()
if done == nil {
// 祖先节点不会被取消,因此不用建立树结果
return // parent is never canceled
}
// 看看祖先是不是已经取消
select {
case <-done:
// parent is already canceled
child.cancel(false, parent.Err(), Cause(parent))
return
default:
}
// 祖先是否是 *cancelCtx 节点
if p, ok := parentCancelCtx(parent); ok {
p.mu.Lock()
if p.err != nil {
// parent has already been canceled
child.cancel(false, p.err, p.cause)
} else {
// 懒汉式创建
if p.children == nil {
p.children = make(map[canceler]struct{})
}
// 建立树结构
p.children[child] = struct{}{}
}
p.mu.Unlock()
} else {
// 祖先是第三方实现的 Context 节点
// 启动守护线程,在祖先取消时,取消该孩子节点
goroutines.Add(1)
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err(), Cause(parent))
case <-child.Done():
}
}()
}
}代码注释写的很详细,应该可以看懂,这里把重点讲解一下: done := parent.Done() 沿着回溯链找到了第一个实现 Done() 方法的实例,存在已下四种情况:done channel 为 nil,表示无法取消,那自然不用级联取消孩子;done channel 已经关闭,表示祖先已经取消,自然孩子之间触发取消就行;既然祖先能取消&还未被取消,需要判断祖先是不是 *cancelCtx 节点,利用上一节提到的 parentCancelCtx 函数寻找,如果是 *cancelCtx 则必然实现了 canceler 接口,直接放入 map 中即可;祖先不是 *cancelCtx 节点 & 还实现了非空 Done() 函数,那只能是三方自己实现的类了,因为不知道其内部实现,所以无法利用 map(有没有这个字段都不知道),只能开启一个守护协程,当祖先节点取消时,直接取消孩子节点。propagateCancel 函数通过处理这四种情况,做到了只要祖先在能取消前提下,发生了取消,必然会触发孩子节点的取消。又有人会问了,第三种只是塞进去了,没看到触发取消呀,这个其实前面已经讲过一次了,我们来回顾一下 *cancelCtx 节点的 cancel 方法,也就是触发级联取消的地方。3.2.2 级联取消因为代码不长,就不做删减了。代码中通过 for range map 的方式对其所有的 child 节点做了 cancel,并通过 c.children = nil 的方式断绝了与所有孩子的联系;还通过 parentCancelCtx 函数找到了祖先节点,通过 delete(p.children, child)进行了解绑,断绝了与祖先的联系。func (c *cancelCtx) cancel(removeFromParent bool, err, cause error) {
if err == nil {
panic("context: internal error: missing cancel error")
}
if cause == nil {
cause = err
}
// 并发写锁
c.mu.Lock()
// err 不为 nil,表示已经被取消过,那则不用继续取消了
if c.err != nil {
// 解锁
c.mu.Unlock()
return // already canceled
}
// 设置取消原因和错误
c.err = err
c.cause = cause
// 取出 chan
d, _ := c.done.Load().(chan struct{})
// chan 未初始化
if d == nil {
// 直接存入已关闭的 chan,通知取消操作
c.done.Store(closedchan)
} else {
// 关闭 chan,通知取消
close(d)
}
// 祖先取消,孩子也得跟着取消(级联取消)
for child := range c.children {
// NOTE: acquiring the child's lock while holding parent's lock.
// 取消 child
child.cancel(false, err, cause)
}
// 断绝与所有后代的关系
c.children = nil
// 解锁
c.mu.Unlock()
// 如果想断绝与祖先的关系
if removeFromParent {
// 断绝与祖先的关系
removeChild(c.Context, c)
}
}
// removeChild removes a context from its parent.
func removeChild(parent Context, child canceler) {
// 回溯找到第一个祖先 *cancelCtx
// 只有 *cancelCtx 中存在 children map[canceler]struct{},才有父子关系
p, ok := parentCancelCtx(parent)
// 没找到,意味着没有父子关系,不用断绝
if !ok {
return
}
p.mu.Lock()
if p.children != nil {
// 祖先的 children map 中移除对该孩子的关系
delete(p.children, child)
}
p.mu.Unlock()
}总结阅读到这里,context 包的主要内容就串完了,这篇文章只讲述了 context 包的源码,但没有讲解具体如何使用(下一期继续),迫不及待的同学可以通过 pkg.go.dev 查询到 context 的官方说明文档:pkg.go.dev/context,里面记录了每个对外函数的使用案例。这里再啰嗦一下 Context 的使用规则:不要将 Context 存储在结构类型中;相反,将 Context 显式传递给每个需要它的函数。Context 应该是第一个参数,通常命名为 ctx。即使函数允许,也不要传递 nil Context 。 如果您不确定要使用哪个 Context, 请传递context.TODO 。不要将函数的可选参数放在 context 当中,context 中一般只放一些全局通用的数据,例如 tracing id。相同的 Context 可以传递给在不同 goroutine 中运行的函数,Context 对于多个 goroutine 同时使用是并发安全的。本文详细的讲解了 context 包的源码,context 包主要用于处理并发请求、控制请求执行时间、取消操作、传递值等场景。它在处理微服务架构中的请求处理、HTTP 请求处理等方面非常实用。理解 context 包源码最重要的是要理解其中的回溯链以及树结构,文章中已经使用详细的图进行了讲解;回溯链中四种实现类 emptyCtx、valueCtx、cancelCtx、timerCtx 完成了不同的功能:emptyCtx:用于根节点的创建,不可取消,返回 nil;valueCtx:用于传值,以 key-val 存储对应的键值对,注意只能回溯直系祖先,不能寻找兄弟节点,而且相同的 key 采取就近原则;cancelCtx:该类是核心类,实现了 canceler 接口,是一个可取消的 Context,也是唯一一个实现了非空 Done() 方法的结构体,结构体中字段 children 维护了 canceler 树的结构,用于级联取消孩子节点,完成整个分支的取消操作;而 Context 的取消操作是通过关闭一个只读 channel 进行广播通知的。timerCtx:该结构体中内嵌了 cancelCtx,并定义了定时器,用于定时取消。
cathoy
1. Go 学习目录
Go 语言起源 2007 年,并于 2009 年正式对外发布。 Go 起源于谷歌公司,该项目的三位领导者均是著名的 IT 工程师:Robert Griesemer,参与开发 Java HotSpot 虚拟机;Rob Pike,Go 语言项目总负责人,贝尔实验室 Unix 团队成员,参与的项目包括 Plan 9,Inferno 操作系统和 Limbo 编程语言;Ken Thompson,贝尔实验室 Unix 团队成员,C 语言、Unix 和 Plan 9 的创始人之一,与 Rob Pike 共同开发了 UTF-8 字符集规范。Go 语言秉承着 “Less can be more 大道至简,小而蕴真” 的核心思想发展至今,成为了主流的编程语言之一。Go 语言优势性能优越,一般比 python 快 30 倍自带运行环境,无需处理 gc 问题静态编译,编译好后可以直接运行,部署简单简单的交叉编译,仅需改变环境变量 golang 的交叉编译丰富的库和详细的开发文档 Go 标准库并发开发简单,支持协程 Goroutine,拥有同步并发的 channel 类型Go 语言简单,容易上手,没有继承,多态,类等概念Go 自带完善的工具链,比如:gofmt 自动排版,解决代码格式不统一问题Go 学习路线持续更新中,构建自身的知识网络。 Go 系列文章1. 初识 Go2. golang 的数组3. golang 切片详解(面试看这一篇就够了)4. golang map 基本使用5. golang map 源码的逐句解读7. 坑!还有你不知道的 golang 闭包知识8. 看 Go 源码,你需要了解 unsafe.Pointer9.最具研读价值的 Go 源码之一:context 包10.深入理解 Go Modules:高效管理你的 Golang 项目依赖12. 揭秘 Golang Channel 高性能背后的故事:图解源码13. 入门 go 语言汇编,看懂 GMP 源码14. Go调度器系列解读(一):什么是 GMP?15. Go调度器系列解读(二):Go 程序启动都干了些什么?16. Go调度器系列解读(三):GMP 模型调度时机17. Go调度器系列解读(四):GMP 调度策略本文将持续更新有关 Go 的相关学习网站以及自己的知识网络。
cathoy
12. 揭秘 Golang Channel 高性能背后的故事:图解源码
前言在现代并发编程中,Channel 作为传递数据的管道,扮演着至关重要的角色。它允许生产者和消费者在不同的 goroutine 中安全地传递数据,从而实现解耦和并发控制。本文将深入探讨 Channel 的源码实现,以帮助读者更好地理解其内部机制和工作原理。通过了解 Channel 的源码,我们可以更好地理解并发编程中的关键概念,并掌握如何优化和改进我们的代码。本文源码版本:1.20.7地址:src/runtime/chan.go1.Channel 是什么很多人都看到过这样一段话:Do not communicate by sharing memory; instead, share memory by communicating.不要通过共享内存来通信,而要通过通信来实现内存共享。Go 是第一个将 CSP(Communicating Sequential Processes) 的这些思想引入,并且发扬光大的语言,所以并发编程成为 Go 的一个独特的优势。而 Go 的并发哲学主要靠使用 Goroutine 和 Channel 实现。那 Channel 到底是什么呢?Channel 可以理解为一种基于通信的并发编程模型,它主要用于在多个 goroutine 之间传递数据。Channel 的设计理念旨在通过提供一个安全、高效、灵活的通信机制,来简化并发编程中的数据传递和同步问题。Channel 的设计理念强调了通信的重要性。在并发编程中,不同的 goroutine 之间需要安全地传递数据,以实现协作和同步。Channel 作为一种通信管道,提供了发送和接收数据的功能,使得不同的 goroutine 可以通过 Channel 进行通信,从而实现了数据的传递和共享。Channel 的设计理念注重简洁性和易用性。在 Go 语言中,使用 Channel 非常简单,只需要通过声明一个类型为 chan T 的变量即可。发送和接收操作也非常直观,可以使用 <- 运算符进行发送和接收数据。这种简洁的设计使得使用 Channel 进行并发编程变得非常容易,降低了学习和使用的门槛。Channel 的设计理念还强调了并发安全性和性能优化。在并发编程中,数据的安全传递和同步是非常重要的。Channel 内部实现了同步机制,确保在并发环境中安全地传递数据。此外,Channel 还支持缓冲和非阻塞操作,以适应不同的并发场景和需求。这些优化措施使得 Channel 在性能方面表现优异,能够满足各种并发编程的需求。了解了 Channel 是什么,也明白其在并发编程中扮演着重要的角色,这里简单列举一下 Channel 在并发编程中的一些主要作用:实现同步:通过使用 Channel,可以在多个 goroutine 之间实现同步。当一个 goroutine 需要等待另一个 goroutine 完成某个任务后才能继续执行时,可以使用 Channel 来实现这种同步。传递数据:Channel 可以用于在 goroutine 之间传递数据。一个 goroutine 可以将数据发送到Channel,而另一个 goroutine 可以从 Channel 接收数据。这使得在不同 goroutine 之间共享和传递数据变得非常容易。解耦生产者和消费者:Channel 可以作为生产者和消费者之间的桥梁。生产者将数据发送到 Channel,而消费者从 Channel 接收数据。这样生产者和消费者可以独立地运行,无需直接相互调用,从而实现了解耦。支持异步操作:通过使用 Channel,可以实现异步操作。一个 goroutine 可以启动一个异步任务,将结果发送到 Channel,而另一个 goroutine 可以在稍后的时间从 Channel 接收结果。这使得并发编程更加灵活和高效。确保并发安全:Channel 内部实现了同步机制,确保在并发环境中安全地传递数据。这避免了常见的并发问题,如数据竞争和死锁。聊了这么多,是不是对 Channel 很感兴趣了呢?接下来就让我们进入 Channel 的源码世界一探究竟吧!2.Channel 源码概述本节对 Channel 中的重要结构和函数、方法进行简单的介绍,为后续理解代码打好基础。2.1 Channel 重要数据结构2.1.1 hchan 结构hchan 对象表示运行时的 channel。hchan.buf 循环数组,用于有缓冲区的 channel 存储。hchan.recvq 接收 goroutine 等待队列 (数据结构是双向链表)。hchan.sendq 发送 goroutine 等待队列 (数据结构是双向链表)。hchan源码:type hchan struct {
qcount uint // channel 元素数量
dataqsiz uint // channel 缓冲区环形队列长度
buf unsafe.Pointer // 指向缓冲区的底层数组 (针对有缓冲的 channel)
elemsize uint16 // channel 元素大小
closed uint32 // channel 是否关闭
elemtype *_type // channel 元素类型
sendx uint // 当前已发送元素在队列中的索引
recvx uint // 当前已接收元素在队列中的索引
recvq waitq // 接收 goroutine 等待队列 (数据结构是链表)
sendq waitq // 发送 goroutine 等待队列 (数据结构是链表)
// lock 保护结构体中的所有字段,以及 sudogs 对象中被当前 channel 阻塞的几个字段
lock mutex
}2.1.2 waitqwaitq 为 goroutine 等待队列,底层是由 sudog 数据结构的双向链表实现的。first 为队列头部,last 为队列尾部,图中也画出了相应的数据结构形式,很好理解。type waitq struct {
first *sudog
last *sudog
}sudog 是对 goroutine 和 channel 对应关系的一层封装抽象,以便于 goroutine 可以同时阻塞在不同的 channel 上(比如:select 没有 default,且所有 channel 阻塞),其中 elem 用于读取/写入 channel 的数据的容器。sudog 中所有字段都受 hchan.lock 保护。type sudog struct {
g *g // 绑定 goroutine
next *sudog // 下一个节点
prev *sudog // 上一个节点
elem unsafe.Pointer // data element (may point to stack)
// isSelect = true 表示 g(协程) 正在参与 select,阻塞在多个 chan 中
// select 只能通过一个 case,所以需要使用 g.selectDone 标志已经有 case 通过了
// 其余 case 可以从等待列表中删除了,g.selectDone 必须经过 CAS 才能执行成功。
isSelect bool
success bool // 表示通道c通信是否成功。
c *hchan // 绑定 channel
}2.1.3 常量const (
// 内存对齐的最大值,这个等于 64 位 CPU 下的 cacheline 的大小
maxAlign = 8
// 计算 unsafe.Sizeof(hchan{}) 最接近的 8 的倍数(对齐内存使用)
hchanSize = unsafe.Sizeof(hchan{}) + uintptr(-int(unsafe.Sizeof(hchan{}))&(maxAlign-1))
// 是否开启 debug 模式
debugChan = false
)2.2 Channel 重要函数和方法本文将主要介绍以下重要函数和方法的具体实现代码,这里整体预览一下。3.创建 Channel编译器会将应用层代码中的 make(chan type, N) 语句转换为 makechan 函数调用。源码中主要对不同类型的 channel 申请内存的方式做了区分:hchan.buf 大小为 0 的 channel无缓冲区型 chan(没有 buf,因此不用分配内存)元素类型为 struct{} 的 chan(只使用游标 sendx 或 recvx,不会拷贝元素到缓冲区,因此 buf 也不用分配内存)hchan.buf 大小不为 0元素类型不含指针的 channel,此时为 hchan 结构体和 buf 字段分配一段连续的内存,GC 不会扫描 buf 内的元素。元素类型包含指针的 channel,分别为 hchan 结构体和 buf 字段单独分配内存空间。
func makechan(t *chantype, size int) *hchan {
elem := t.elem
// 由编译器检查保证元素大小不能大于等于 64K
if elem.size >= 1<<16 {
throw("makechan: invalid channel element type")
}
// 检测 hchan 结构体大小是否是 maxAlign = 8 的整数倍
// 并且元素的对齐单位不能超过最大对齐单位
if hchanSize%maxAlign != 0 || elem.align > maxAlign {
throw("makechan: bad alignment")
}
// 判断申请内存空间大小是否越界
// mem = elem.size * size(内存大小 = 元素大小 * 元素个数)
mem, overflow := math.MulUintptr(elem.size, uintptr(size))
if overflow || mem > maxAlloc-hchanSize || size < 0 {
panic(plainError("makechan: size out of range"))
}
// 当存储在 buf 中的元素不包含指针时,可以消除 GC 扫描
var c *hchan
switch {
case mem == 0:
// 1. 无缓冲型 chan
// 2. 元素类型:struct{} 的 chan(只使用游标,不会拷贝元素)
// 只分配 hchan 结构体大小内存
// 因为以上两种情况都用不到 c.buf
c = (*hchan)(mallocgc(hchanSize, nil, true))
c.buf = c.raceaddr()
case elem.ptrdata == 0:
// 元素类型不含指针 - GC 就不会扫描 chan 中的元素
// 只进行一次内存分配,分配大小 hchanSize+mem
// chan 结构体和循环数组内存地址连续
c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
// c.buf 指向循环数组起始位置
c.buf = add(unsafe.Pointer(c), hchanSize)
default:
// 元素类型含指针
// 分别申请 chan 和 buf 的空间(两者内存地址不连续)
c = new(hchan)
c.buf = mallocgc(mem, elem, true)
}
// 初始化其余字段
c.elemsize = uint16(elem.size)
c.elemtype = elem
c.dataqsiz = uint(size)
lockInit(&c.lock, lockRankHchan)
return c
}4.Channel 发送数据编译器会根据 channel 发送数据的使用方式转换为 chansend 函数调用:c <- x 语句会被转换为 chansend1函数。 block = true (阻塞发送)select + case c <- v + default 语句会被转换为 selectnbsend 函数。block = false(非阻塞发送)// entry point for c <- x from compiled code.
//
//go:nosplit
func chansend1(c *hchan, elem unsafe.Pointer) {
chansend(c, elem, true, getcallerpc())
}
// compiler implements
//
// select {
// case c <- v:
// ... foo
// default:
// ... bar
// }
//
// as
//
// if selectnbsend(c, v) {
// ... foo
// } else {
// ... bar
// }
func selectnbsend(c *hchan, elem unsafe.Pointer) (selected bool) {
return chansend(c, elem, false, getcallerpc())
}4.1 chansend 函数chansend函数向 channel 发送数据,并返回是否发送成功。func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
// chan 为 nil
if c == nil {
// 非阻塞
if !block {
return false
}
// 挂起当前 goroutine,永久阻塞
// 调用 gopark 时传入的 unlockf 为 nil,会被一直休眠
gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
throw("unreachable")
}
// 非阻塞 && 未关闭 && 未做好发送准备 -> 直接返回
if !block && c.closed == 0 && full(c) {
return false
}
// 加锁
lock(&c.lock)
// channel 关闭
if c.closed != 0 {
// 解锁
unlock(&c.lock)
// 抛出 panic - 向一个已经关闭的 channel 发送数据会导致 panic
panic(plainError("send on closed channel"))
}
// 出队一个等待接收的 goroutine
if sg := c.recvq.dequeue(); sg != nil {
// 将数据发送给等待接收的 sudog,绕过 buf 缓冲区
// 唤醒 goroutine
send(c, sg, ep, func() { unlock(&c.lock) }, 3)
// 返回成功
return true
}
// qcount 是队列当前元素数量
// dataqsiz 是队列总长度
// 当前元素数量小于队列总长度时,说明 buf 还有空闲空间可供使用
if c.qcount < c.dataqsiz {
// 获取下一个可放置缓冲数据的 buf 地址
qp := chanbuf(c, c.sendx)
// 将发送的数据拷贝到缓冲区
typedmemmove(c.elemtype, qp, ep)
// 发送索引 +1
c.sendx++
// 循环队列,当 sendx = 队列长度时,重置为 0
if c.sendx == c.dataqsiz {
c.sendx = 0
}
// 缓冲区元素数量 +1
c.qcount++
// 解锁
unlock(&c.lock)
// 返回成功
return true
}
if !block {
// 非阻塞 + 缓冲区没有空闲空间可以使用 - 解锁、返回失败
unlock(&c.lock)
return false
}
// 以下为阻塞发送情况代码:阻塞发送 && buf 没有空闲空间
// 获取当前发送数据的 goroutine
gp := getg()
// 从 sudogcache 中获取 sudog
mysg := acquireSudog()
// 构造封装当前 goroutine 的 sudog 对象
// 建立 sudog、goroutine、channel 之间的指向关系
mysg.elem = ep
mysg.waitlink = nil
mysg.g = gp
mysg.isSelect = false
mysg.c = c
gp.waiting = mysg
gp.param = nil
// 把 sudog 添加到当前 channel 的发送队列
c.sendq.enqueue(mysg)
// 挂起协程
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)
// 其他协程通过 c 接收数据,唤醒该协程 gp
// 确保正在发送的值保持活动状态,直到接收者将其复制出来。
KeepAlive(ep)
// 此时被唤醒 gp.waiting 不是当前的 mysg 直接 panic(说明遭受破坏)
if mysg != gp.waiting {
throw("G waiting list is corrupted")
}
gp.waiting = nil // 等待任务结束,重置为 nil
gp.activeStackChans = false
// success 表示通道c通信是否成功。
// 如果 goroutine 因通过通道 c 传递值而被唤醒,则为 true;
// 如果因为 c 被关闭而被唤醒,则为 false。
// closed 为其相反的值
closed := !mysg.success
gp.param = nil // 重置唤醒时传递的参数(传递为 sudog)
// 取消和 channel 的绑定关系
mysg.c = nil
// 释放 Sudog
releaseSudog(mysg)
// 通道 c 通信失败,抛出失败原因
if closed {
// channel 发送失败 -- 抛出 panic
if c.closed == 0 {
throw("chansend: spurious wakeup")
}
panic(plainError("send on closed channel"))
}
// 返回发送成功
return true
}4.2 send 函数send函数用于处理 channel数据的发送操作,发送到阻塞接收的 receiver 中,分为两步:调用 sendDirect 直接将发送方的数据(ep)拷贝到接收方持有的目标内存地址上(sg.elem 所指的地方) ,这里看代码或许会存在割裂感,需要配合 chanrecv 函数内容进行理解,等阅读完全部代码,我们再整体回顾一下(在接收函数 chanrecv 中,ep 参数指向接收目的地址,被赋值到 mysg.elem = ep )。将等待接收的 goroutine 唤醒。sendDirect 函数用于 channel 具体的发送数据操作,将发送方的数据直接写入到接收方的目的地址中。// sg 表示接收方 goroutine
// ep 表示要发送的数据
func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
// 接收数据的地址不为空,则拷贝数据 (sg.elem 用来接收数据)
if sg.elem != nil {
// 直接拷贝数据到接收方 sudog.elem 所指向的地址
// 接收方会从这个地址获取 chan 传递的内容
sendDirect(c.elemtype, sg, ep)
// sudog 中 elem 置为 nil(elem 的任务结束了,可以重置为 nil)
// 这里置为 nil,不会影响上一步数据的拷贝,只是指针换了指向
sg.elem = nil
}
// 从 sudog 获取 goroutine
gp := sg.g
// 解锁 hchan (结合 chansend 函数加锁)
unlockf()
gp.param = unsafe.Pointer(sg)
// 设置接收成功
sg.success = true
if sg.releasetime != 0 {
sg.releasetime = cputicks()
}
// 调用 goready 函数将接收方 goroutine 唤醒并标记为可运行状态
goready(gp, skip+1)
}
func sendDirect(t *_type, sg *sudog, src unsafe.Pointer) {
dst := sg.elem
// ...
// 拷贝数据
memmove(dst, src, t.size)
}4.3 小结读完代码,简单的画个图加深一下印象吧!chanrecv 和 closechan 函数暂时还没有讲到,等看完相关源码,再返回来看这个图,应该会一目了然。5.Channel 接收数据编译器会根据 channel 接收数据的使用方式转换为 chanrecv 函数调用:<- ch 语句转换为 chanrecv1 函数调用。(阻塞接收)x, ok <- ch 语句转换为 chanrecv2 函数调用。(阻塞接收)select + v, ok = <-c + default 语句会被转换为 selectnbrecv 函数。block = false(非阻塞接收)// entry points for <- c from compiled code.
//
//go:nosplit
func chanrecv1(c *hchan, elem unsafe.Pointer) {
chanrecv(c, elem, true)
}
//go:nosplit
func chanrecv2(c *hchan, elem unsafe.Pointer) (received bool) {
_, received = chanrecv(c, elem, true)
return
}
// compiler implements
//
// select {
// case v, ok = <-c:
// ... foo
// default:
// ... bar
// }
//
// as
//
// if selected, ok = selectnbrecv(&v, c); selected {
// ... foo
// } else {
// ... bar
// }
func selectnbrecv(elem unsafe.Pointer, c *hchan) (selected, received bool) {
return chanrecv(c, elem, false)
}5.1 chanrecv 函数chanrecv 函数用于在 channel 上接收数据,并将接收到的数据写入参数 ep (ep 可以设置为 nil,这种情况下接收到的数据将会被忽略,本函数和 4.2 小节中 send 函数都有使用),并有两个返回值(selected, received bool)selected:select 中是否会选择该分支received:是否接收到了数据chanrecv函数中需要注意,即使 channel 已经关闭,仍然可以继续接收数据,直到 buf 为空为止。而且读取已经关闭的 channel 中的数据,也不会 panic。func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
if c == nil {
// 非阻塞直接返回 false,false
if !block {
return
}
// 阻塞情况:在 nil channel 接收数据,永久阻塞
gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)
throw("unreachable")
}
// 非阻塞 && c 数据为空
if !block && empty(c) {
// 判断 channel 是否已经关闭
if atomic.Load(&c.closed) == 0 {
// chan 未关闭,还没有数据,返回 false,false
return
}
// chan 已关闭,二次判断是否有待接收的数据数据
// 后边就不可能再有数据进来
if empty(c) {
// chan 为空 && 已关闭
if ep != nil {
// 清理 ep 的内存
typedmemclr(c.elemtype, ep)
}
// 读一个已关闭&&数据为空的 chan,返回 true, false
return true, false
}
}
// 加锁
lock(&c.lock)
if c.closed != 0 {
// chan 已关闭 && 缓冲区也没有数据了,返回 true, false
// 并发环境下,随时可能关闭 chan
if c.qcount == 0 {
unlock(&c.lock)
if ep != nil {
typedmemclr(c.elemtype, ep)
}
return true, false
}
} else {
// 判断有阻塞在等待发送的 goroutine
if sg := c.sendq.dequeue(); sg != nil {
// 拿到第一个等待的 sender
// 如果缓冲区大小为 0,那第一个 sender 数据可以直接拷贝到 ep
// 如果缓冲区大小不为 0,则表明缓冲区中有更早的数据,ep 应该取缓冲区头部数据
// 而该 sender 数据应该写入缓冲区尾部
recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true, true
}
}
// 缓冲区有数据
if c.qcount > 0 {
// 获取数据地址
qp := chanbuf(c, c.recvx)
if ep != nil {
// 拷贝数据,ep 为 nil 不用动
typedmemmove(c.elemtype, ep, qp)
}
// 清理缓冲区数据
typedmemclr(c.elemtype, qp)
// 循环数组更新下一个 recvx 索引
c.recvx++
if c.recvx == c.dataqsiz {
c.recvx = 0
}
c.qcount-- // 缓冲区元素计数 -1
unlock(&c.lock) // 解锁
return true, true
}
// 缓冲区没有数据 && 非阻塞
if !block {
// 解锁,直接返回 false, false
unlock(&c.lock)
return false, false
}
// 代码执行到这里表示:没有可用的 sender && 缓冲区没有数据 && 阻塞
gp := getg()
mysg := acquireSudog()
// 目标地址保存到 mysg.elem 中
mysg.elem = ep
mysg.waitlink = nil
gp.waiting = mysg
mysg.g = gp
mysg.isSelect = false
mysg.c = c
gp.param = nil
// 塞入等待接收队列
c.recvq.enqueue(mysg)
// 阻塞协程
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2)
// someone woke us up
if mysg != gp.waiting {
throw("G waiting list is corrupted")
}
gp.waiting = nil
success := mysg.success
gp.param = nil
mysg.c = nil
releaseSudog(mysg) // 释放 mysg
return true, success
}5.2 recv 函数recv 函数用于处理 channel 的数据接收操作,接收一个阻塞发送的 sender 的数据到 buf 或者 receiver 中。当缓冲区大小为 0:直接拷贝阻塞发送的数据到 ep 中;当缓冲区大小不为 0(缓冲区已塞满,否则不会阻塞发送协程):本着队列先进先出的原则,接收方获取 buf 的头部数据,而发送方发送数据到 buf 的尾部,同时更新 c.sendx = c.recvx;唤醒阻塞的 sender 协程。recvDirect 函数用于 channel 具体的接收数据操作,将发送方的数据直接写入到接收方的目的地址中。func recv(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
if c.dataqsiz == 0 {
if ep != nil {
// copy data from sender
recvDirect(c.elemtype, sg, ep)
}
} else {
// 获取队列首元素
qp := chanbuf(c, c.recvx)
// copy data from queue to receiver
if ep != nil {
typedmemmove(c.elemtype, ep, qp)
}
// copy data from sender to queue
typedmemmove(c.elemtype, qp, sg.elem)
// 更新 recvx 索引
c.recvx++
if c.recvx == c.dataqsiz {
c.recvx = 0
}
// 队列是满的,所以 c.sendx = c.recvx
// 或者使用 c.sendx = (c.sendx+1) % c.dataqsiz 更新 sendx 也行
c.sendx = c.recvx
}
sg.elem = nil
gp := sg.g
unlockf() // 解锁
gp.param = unsafe.Pointer(sg)
sg.success = true
// 唤醒阻塞的 sender 协程
goready(gp, skip+1)
}
func recvDirect(t *_type, sg *sudog, dst unsafe.Pointer) {
src := sg.elem
memmove(dst, src, t.size)
}5.3 小结看到这里,发送和接收数据已经看完了,这个图应该可以看懂 90% 了。6.关闭 Channelclsoe(chan)语句对应转换为 closechan 函数调用。关闭 channel 时需要注意,如果 sendq 中还存在 sender 协程阻塞等待,当唤醒 sender 协程后,会发生 panic。func closechan(c *hchan) {
// 关闭一个 nil channel, 抛出 panic
if c == nil {
panic(plainError("close of nil channel"))
}
lock(&c.lock) // 加锁
if c.closed != 0 {
unlock(&c.lock)
// 关闭一个已经关闭的 channel, 抛出 panic
panic(plainError("close of closed channel"))
}
// chan 关闭标志
c.closed = 1
// goroutine 列表
// 用于存放发送+接收队列中的所有 goroutine
// gList 为栈结构
var glist gList
// 释放所有 readers
for {
// readers 出队
sg := c.recvq.dequeue()
// 说明接收队列为空,直接跳出循环
if sg == nil {
break
}
// 清理接收数据的地址内存
if sg.elem != nil {
typedmemclr(c.elemtype, sg.elem)
sg.elem = nil
}
gp := sg.g
gp.param = unsafe.Pointer(sg)
sg.success = false
// 将绑定的 goroutine 存入 glist
glist.push(gp)
}
// 释放所有 writers (they will panic)
// 发送 goroutine 被唤醒后会导致 panic
for {
sg := c.sendq.dequeue()
if sg == nil {
break
}
sg.elem = nil
gp := sg.g
gp.param = unsafe.Pointer(sg)
sg.success = false
glist.push(gp)
}
// 解锁
unlock(&c.lock)
// 将所有 goroutine 唤醒
for !glist.empty() {
gp := glist.pop()
gp.schedlink = 0
goready(gp, 3)
}
}closechan 函数的流程就相对简单很多了:7.其他函数和方法7.1 full 和 empty 函数full 函数检测 sender 向 channel 发送数据是否应该阻塞,或者说是否做好了发送数据的准备:如果 channel 没有缓冲区,查看等待接收队列是否存在接收者等待,不存在则 sender 应该阻塞;如果 channel 有缓冲区,比较元素数量和缓冲区长度是否一致,一致表明缓冲区已满,sender 应该阻塞。func full(c *hchan) bool {
if c.dataqsiz == 0 {
return c.recvq.first == nil
}
return c.qcount == c.dataqsiz
}empty 函数检测 receiver 从 channel 读取数据是否应该阻塞,或者说是否做好了读取数据的准备:如果 channel 没有缓冲区,查看等待发送队列是否存在发送者等待,不存在则 receiver 应该阻塞。如果 channel 有缓冲区,检查缓冲区元素数量是否等于 0,等于 0 表示没有数据等待接收,receiver 应该阻塞。func empty(c *hchan) bool {
if c.dataqsiz == 0 {
return atomic.Loadp(unsafe.Pointer(&c.sendq.first)) == nil
}
return atomic.Loaduint(&c.qcount) == 0
}chanrecv 函数进行快速检查时,检查中的状态不能发生变化,否则容易导致返回结果错误。因此,为了防止错误的检查结果,c.closed 和 empty() 都必须使用原子检查。// 非阻塞 && c 数据为空
if !block && empty(c) {
// 判断 channel 是否已经关闭
if atomic.Load(&c.closed) == 0 {
// chan 未关闭,还没有数据,返回 false,false
return
}
// chan 已关闭,二次判断是否有待接收的数据数据
// 后边就不可能再有数据进来
if empty(c) {
// chan 为空 && 已关闭
if ep != nil {
// 清理 ep 的内存
typedmemclr(c.elemtype, ep)
}
// 读一个已关闭&&数据为空的 chan,返回 true, false
return true, false
}
}这里总共检查了 2 次 empty()。因为第一次检查时,channel 可能还没有关闭,但是第二次检查的时候关闭了,在 2 次检查之间可能有待接收的数据到达了,所以需要 2 次 empty() 检查。7.2 enqueue 和 dequeue 方法enqueue 入队 enqueue 方法用于将 sudog 放入 channel 的发送/接收队列,内部实现就是双向链表操作。func (q *waitq) enqueue(sgp *sudog) {
sgp.next = nil
x := q.last // 链表尾部
if x == nil {
// 尾部为 nil,表示只有一个 sudog 节点
sgp.prev = nil
q.first = sgp
q.last = sgp
return
}
// 在队列尾部插入一个新的 sudog
sgp.prev = x
x.next = sgp
// 更新尾部指向
q.last = sgp
}dequeue 出队 dequeue 方法用于出队 channel 的发送/接收队列的一个元素 sudog,内部实现就是双向链表操作。func (q *waitq) dequeue() *sudog {
for {
// 取队列头部元素
sgp := q.first
if sgp == nil {
return nil
}
// 头部元素不为 nil
// 设置队列中的 first\last 指向
y := sgp.next
if y == nil {
// 队列只有头部一个元素
q.first = nil
q.last = nil
} else {
// 头部后边最少还有一个元素
// 设置 y 为新的头部
y.prev = nil
q.first = y
sgp.next = nil // 标记为已删除(头部被取走)
}
// 如果一个 goroutine 因为在 select 阻塞在多个 chan 等待队列中,
// select 中只有一个 case 可以顺利执行,所以需要使用 g.selectDone 标志是否已经有
// case 赢得了比赛;其他 case 只执行删除操作,不返回 sudog;
// 只有 g.selectDone 经过 CAS 才能获取到执行权,从 0 写到 1
// 否则都 continue 掉,即可从等待列表中删除
// isSelect = true 代码在 selectgo 中
if sgp.isSelect && !sgp.g.selectDone.CompareAndSwap(0, 1) {
continue
}
return sgp
}
}为什么要用自旋的方式获取 waitq 中的 sudog?因为 sudog 进入 waitq 队列分为两种情况:在 chansend 和 chanrecv 函数中因为 读取/写入 阻塞进入 waitq 队列中;在 select 的 case 中阻塞进入 waitq,因此 goroutine 会同时阻塞在不同的 channel 中。源码在:src/runtime/select.go 中,select 的实现函数是 selectgo,这里不详细进行展开,我们看一个代码片段,在代码中陷入阻塞的 select 会把阻塞的 goroutine 同时塞入每一个 case 下的 channel 中,从而进入等待队列中。针对因为 select 陷入阻塞的情况,sudog 中使用字段IsSelect = true进行表示,由于 select 使用中只有一个 case 能够通过阻塞并唤醒 goroutine 最终执行,因此使用 g.selectDone 对此进行标识,标志已经有 case 通过竞争获取了执行权,其余等待的 sudog 可以自行消亡了,于是就出现了 dequeue 方法的自旋销毁方式,没有更改 g.selectDone 值成功的 sudog 都进行链表删除操作。总结通过阅读源码,我们可以总结下面这一张 Channel 的操作规则:操作nil已关闭 channel未关闭有缓冲区的 channel未关闭无缓冲区的 channel关闭panicpanic成功关闭,然后可以读取缓冲区的值,读取完之后,继续读取到的是 channel 类型的默认值成功关闭,之后读取到的是 channel 类型的默认值接收阻塞不阻塞,读取到的是 channel 类型的默认值不阻塞,正常读取值阻塞发送阻塞panic不阻塞,正常写入值阻塞Channel 是 Go 语言中用于实现并发安全通道的一种数据结构,具有以下优点:线程安全:Channel 提供了一种并发安全的通信机制,可以在多个 goroutine 之间进行安全的消息传递。支持多种类型的消息传递:Channel 可以传递任意类型的数据,包括基本类型、结构体、接口等。阻塞和非阻塞操作:Channel 支持阻塞和非阻塞操作,可以根据需要选择合适的操作方式。缓冲和非缓冲:Channel 可以配置为缓冲或非缓冲的,以满足不同的需求。解耦生产者和消费者:使用 Channel 可以解耦生产者和消费者,使得生产者和消费者之间的交互更加灵活和可配置。支持select语句:在 Go 语言中,可以使用 select 语句来同时监听多个 Channel 的消息,从而实现多路复用。Channel 和锁在并发编程中都可以用来解决并发问题,但它们各自有不同的侧重点和适用场景。关注点:Channel 关注的是并发问题的数据流动,适用于数据在多个协程中流动的场景。而锁关注的是某一场景下只给一个协程访问数据的权限,适用于数据位置固定的场景。解决问题的方式:Channel 通过数据流动来解决并发问题,可以在协程之间传递数据,实现协程之间的通信。而锁则是通过互斥访问来解决并发问题,同一时间只允许一个协程访问共享数据,避免出现数据竞争的情况。Channel 和锁都是解决并发问题的有效工具,但应根据具体的场景和需求来选择使用哪种方式,下边具体列举一下:锁的使用场景访问共享数据结构中的缓存信息保存应用程序上下文和状态信息保护某个结构内部状态和完整性高性能要求的临界区代码channel 的使用场景线程 (goroutine) 通信并发通信异步操作任务分发传递数据所有权数据逻辑组合 (如 Pipeline, FanIn FanOut 等并发模式)Channel 在 Go 语言中有许多应用场景,以下是其中一些常见的应用场景:生产者-消费者模式:Channel 可以用于实现生产者-消费者模式。生产者将数据发送到 Channel,消费者从 Channel 接收数据。这种模式可以有效地解耦生产者和消费者,使得它们可以独立地运行,无需直接相互调用。管道模式:Channel 可以作为管道,用于在不同 goroutine 之间传递数据。例如,可以使用 Channel 来将数据从一个 goroutine 传递到另一个 goroutine,从而实现数据的流动和处理。超时处理:通过使用带有超时的 Channel,可以实现超时处理。例如,可以使用 select 语句和time.After() 或 time.Tick()函数,来设置超时时间,并在超时后执行相应的操作。控制并发数:在需要控制并发数的场景中,可以使用 Channel 来控制并发规模。例如,可以使用一个带有缓冲的 Channel 来限制同时进行的任务数量。当 Channel 中有足够的空间时,可以启动新的任务;当 Channel 已满时,可以等待已有的任务完成后再启动新的任务。同步和协调:Channel 可以用于实现 goroutine 之间的同步和协调。例如,可以使用带有缓冲的 Channel 来确保多个 goroutine 在某个点上达到同步。当所有的 goroutine 都向 Channel 发送了数据后,可以从 Channel 接收数据,从而确保所有 goroutine 都完成了相应的操作。Channel 在 Go 语言中具有广泛的应用场景,可以用于实现各种并发模式和同步机制,通过合理地使用Channel,可以提高并发编程的效率和可靠性。
cathoy
3. golang 切片详解(面试看这一篇就够了)
前言上一篇文章(golang 的数组)介绍了 Go 语言的数组结构,我们都知道数组是定长的,长度定义好之后,不能再更改。因此,数组是不常见的,因为其长度是类型的一部分,限制了它的表达能力。 更为常用的数据结构是切片,也被称作动态数组,其长度不固定,可以向切片中追加元素,还支持动态扩容的能力。本篇文章就带大家一起学习下 golang 语言学习第二课:切片(slice)。1.数据结构slice 实际上是一个结构体,包含三个字段:长度、容量、底层数组。array 是指向底层数组的指针;len: 是切片的长度,即切片中当前元素的个数;cap: 是底层数组的长度,也是切片的最大容量,cap 值永远大于等于 len 值。// runtime/slice.go
type slice struct {
array unsafe.Pointer // 元素指针
len int // 长度
cap int // 容量
}
// 例子
var nums = make([]int, 3, 6)
nums[0], nums[1], nums[2] = 1, 2, 3slice 的数据结构如下:由于底层数组是一片连续的内存空间,所以我们可以将切片理解成一片连续的内存空间加上长度与容量的标识。切片是对数组的封装,提供了对数组中部分连续片段的引用,在运行期间可以修改它的长度和范围,当底层数组长度不能满足当前切片容量时,切片就会更换指向的底层数组,而对于上层来说无感知。 注意:底层数组是可以被多个 slice 同时指向的,因此对一个 slice 的元素进行操作是有可能影响到其他 slice 的。2.初始化Go 编译器会自动为每个新创建的切片,建立一个底层数组,默认底层数组的长度与切片初始元素个数相同。我们可以用以下几种方法创建切片,并指定它底层数组的长度。2.1 使用下标创建 slice采用 array[low : high : max] 语法基于一个已存在的数组或切片创建新切片。// 声明一个数组 [10]int 并赋值
arr := [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
// 使用数组创建切片
sl := arr[3:7:9]
// 使用切片创建切片
sl2 := sl[0:2:3]
fmt.Println(sl) // [4 5 6 7]
fmt.Println(sl2) // [4 5]基于数组创建的切片,它的起始元素从 low 所标识的下标值开始,切片的长度(len)是 high - low = 7 -3 = 4,它的容量是 max - low = 9 - 3 = 6。同理基于切片创建的切片,起始元素从 sl 的 0 开始,长度 = 2,容量 = 3,由于 sl 的容量为 6,因此 max 下标最大为 6,超过 6 则会运行报错:panic: runtime error: slice bounds out of range [::7] with capacity 6。使用下标初始化切片不会拷贝原数组或者原切片中的数据,它只会创建一个指向原数组的切片结构体,所以修改新切片的数据也会修改原切片。由于切片 sl2 的底层数组也是数组 arr,对切片 sl2 中元素的修改将直接影响数组 arr 和 切片 s1 变量。// 修改切片 sl2 的值
sl2[0] = 100
fmt.Println(sl2) // [100 5]
fmt.Println(sl) // [100 5 6 7]
fmt.Println(arr) // [1 2 3 100 5 6 7 8 9 10]当然,我们还可以省略 array[low : high : max] 中的 max,则 max 值默认为数组的长度或切片的容量。// 声明一个数组 [10]int 并赋值
arr := [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
// 使用数组创建切片
sl := arr[3:7]
fmt.Println(len(sl), cap(sl)) // 4 7
// 使用切片创建切片
sl2 := sl[0:2]
fmt.Println(len(sl2), cap(sl2)) // 2 7省略 max 后,还可以继续省略 low 或 high,或者全部省略都是可以的。[:] 表示截取全部的数组或切片作为新切片 [0:max)[:high] 截取 [0,high) 的数组或切片作为新切片[low:] 截取 [low,max) 的数组或切片作为新切片2.2 使用字面量直接创建切片Go 支持使用字面量直接创建新的切片,此时 Go 在编译期间会根据切片中元素数量创建一个底层数组,将元素存储到数组中,然后使用 [:] 创建切片,这就又回到了 2.1 小节的创建方式。sl3 := []int{1, 2, 3, 4, 5, 6, 7}2.3 使用关键字 make 创建 slice// 其中 14 为 cap 值,即底层数组长度,7 为切片的初始长度
sl4 := make([]byte, 7, 14)
fmt.Println(sl4) // [0 0 0 0 0 0 0]
// 不指定 cap 值时,cap = len = 7
sl5 := make([]byte, 7)
fmt.Println(len(sl5), cap(sl5)) // 7 7使用 make 创建 slice make([]Type, len, cap)可以省略 cap,默认等于 len;cap >= len >= 0 的条件必须成立;len > 0 时,slice 填充类型的零值。3.常用方法len:返回 slice 中元素的数量。arr := [5]int{1,2,3,4,5}
// 省略 max
sl := arr[2:4]
fmt.Println(len(sl)) // 2cap:返回 slice 的容量。fmt.Println(cap(sl)) // 3append:向 slice 中追加一个或多个元素,然后返回新的 slice。append 函数的参数长度可变,因此可以追加多个值到 slice 中,还可以用 ... 传入 slice,直接追加一个切片。append 函数返回值是一个新的 slice,并且对传入的 slice 不影响。Go编译器不允许调用了 append 函数后不使用返回值,会触发编译错误。使用 append 可以向 slice 追加元素,实际上是往底层数组添加元素,因此会影响底层数组。当切片达到容量上限,再次 append 元素时,会触发切片的动态扩容机制,返回的 slice 会指向新的底层数组,与原底层数组解绑,此时不再影响原底层数组,这里需要额外注意。// append 函数
func append(slice []Type, elems ...Type) []Type
slice = append(slice, elem1, elem2)
slice = append(slice, anotherSlice...)
append(slice, elem1, elem2) // 会引发编译错误
// 感兴趣的,可以自己分析一下输出结果
s := []int{5}
s = append(s, 7)
s = append(s, 9)
x := append(s, 11)
y := append(s, 12)
fmt.Println(s, x, y)
// 输出结果:[5 7 9] [5 7 9 12] [5 7 9 12]copy:从源 slice(src) 中复制元素到目标 slice(dist),并且返回复制的元素的个数。拷贝为深拷贝 src 改变不会影响 dist。arr := [4]int{1, 2, 3, 4}
sl := arr[:]
sl2 := make([]int, len(sl))
copy(sl2, sl)
fmt.Println(sl2) // [1 2 3 4]
sl[0] = 100
fmt.Println(sl) // [100 2 3 4]
fmt.Println(sl2) // [1 2 3 4]sort:支持切片排序sl := make([]int, 0, 10)
for i := 0; i < 10; i++ {
n := rand.Intn(100)
sl = append(sl, n)
}
fmt.Println("排序前:", sl)
// 内置的排序算法
sort.Ints(sl)
fmt.Println("排序后:", sl)
// 降序,第二个参数传排序规则
sort.Slice(sl, func(i, j int) bool {
return sl[i] > sl[j]
})
fmt.Println("反排序:", sl)
// 结果
排序前: [81 87 47 59 81 18 25 40 56 0]
排序后: [0 18 25 40 47 56 59 81 81 87]
反排序: [87 81 81 59 56 47 40 25 18 0]4.动态扩容4.1 append 触发扩容在切片达到容量上限时,继续 append 操作会导致切片扩容。如代码所示,当切片 sl 的 len = cap = 8 时,继续 append 102 元素导致 sl 扩容,sl 与原底层数组 arr 解绑,因此 append 102 元素没有影响到原底层数组 arr,后续对 sl 元素更改也不会影响 arr。arr := [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
sl := arr[0:6:8]
fmt.Println(len(sl), cap(sl)) // 6 8
fmt.Println(sl) // [1 2 3 4 5 6]
// 切片 append 会影响原底层数组内容
sl = append(sl, 100)
fmt.Println(len(sl), cap(sl)) // 7 8
fmt.Println(sl) // [1 2 3 4 5 6 100]
fmt.Println(arr) // [1 2 3 4 5 6 100 8 9 10]
// 切片 append 会影响原底层数组内容
sl = append(sl, 101)
fmt.Println(len(sl), cap(sl)) // 8 8
fmt.Println(sl) // [1 2 3 4 5 6 100 101]
fmt.Println(arr) // [1 2 3 4 5 6 100 101 9 10]
// 触发扩容,与原数组解绑
sl = append(sl, 102)
fmt.Println(len(sl), cap(sl)) // 9 16
fmt.Println(sl) // [1 2 3 4 5 6 100 101 102]
fmt.Println(arr) // [1 2 3 4 5 6 100 101 9 10]
// 后续更改也不会影响原底层数组内容
sl[0] = -1
fmt.Println(len(sl), cap(sl)) // 9 16
fmt.Println(sl) // [-1 2 3 4 5 6 100 101 102]
fmt.Println(arr) // [1 2 3 4 5 6 100 101 9 10]slice 扩容后会迁移到新的内存位置,新底层数组的长度也会增加,这样就可以放置新增的元素。 同时,为了应对未来可能再次发生的 append 操作,新的底层数组的长度,也就是新 slice 的容量是留了一定的 buffer 的。否则,每次添加元素的时候,都会发生迁移,成本太高。4.2 扩容规律golang 版本 1.18// go 1.18 src/runtime/slice.go:178
func growslice(et *_type, old slice, cap int) slice {
// ……
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
const threshold = 256
if old.cap < threshold {
newcap = doublecap
} else {
for 0 < newcap && newcap < cap {
// Transition from growing 2x for small slices
// to growing 1.25x for large slices. This formula
// gives a smooth-ish transition between the two.
newcap += (newcap + 3*threshold) / 4
}
if newcap <= 0 {
newcap = cap
}
}
}
// ……
capmem = roundupsize(uintptr(newcap) * ptrSize)
newcap = int(capmem / ptrSize)
// ……
}slice 扩容代码详解:计算新 slice 容量 newcap当原 slice 容量 (oldcap) 小于 256 的时候,新 slice(newcap) 容量为原来的2倍;原 slice 容量超过 256,新 slice 容量 newcap = oldcap+(oldcap+3*256)/4之后对 newcap 做了一个内存对齐,这个和内存分配策略相关(可以提高内存的分配效率并减少碎片,拓展内容有分析,有兴趣可以阅读)。进行内存对齐之后,新 slice 的容量是要 >= 按照前半部分生成的 newcap。之后,向 Go 内存管理器申请内存,将老 slice 中的数据复制过去,并且将 append 的元素添加到新的底层数组中。最后,向 growslice 函数调用者返回一个新的 slice,这个 slice 的长度并没有变化,而容量却增大了。5.切片作为参数Go 语言的函数参数传递,只有值传递,没有引用传递,切片作为参数当然也是如此。5.1 形参通过改变底层数组影响实参前面我们说到,slice 其实是一个结构体,包含了三个成员:len, cap, array。分别表示切片长度,容量,底层数据的地址。当 slice 作为函数参数时,就是一个普通的结构体。 其实很好理解:若直接传 slice,在调用者看来,实参 slice 并不会被函数中的操作改变;若传的是 slice 的指针,在调用者看来,是会被改变原 slice 的。值得注意的是,不管传的是 slice 还是 slice 指针,如果改变了 slice 底层数组的数据,会反应到实参 slice 的底层数据。package main
func main() {
sl := []int{6, 6, 6}
f(sl)
fmt.Println(sl)
}
func f(sl []int) {
for i := range sl {
sl[i] += 1
}
}
// 输出 [7 7 7]为什么能改变底层数组的数据?很好理解:底层数据在 slice 结构体里是一个指针,尽管 slice 结构体自身不会被改变,也就是说底层数据地址不会被改变。 但是通过指向底层数据的指针,可以改变切片的底层数据,这也是为什么网上常把 slice 称作引用类型的原因。5.2 通过指针传递影响实参形参是一个 slice 的副本,在函数中,形参只是实参的一个拷贝。因此,在函数内部,对形参的作用并不会改变外层的实参的,举个简单的例子。package main
func main() {
sl := []int{6, 6, 6}
f(sl)
fmt.Println(sl) // [6 6 6]
}
func f(sl []int) {
for i := 0; i < 3; i++ {
sl = append(sl, i)
}
fmt.Println(sl) // [6 6 6 0 1 2]
}要想真的改变外层 slice,只有将返回的新的 slice 赋值到原始 slice,或者向函数传递一个指向 slice 的指针。我们再来看一个例子:package main
import "fmt"
func myAppend(s []int) []int {
// 这里 sl 虽然改变了,但并不会影响外层函数的 sl
sl = append(sl, 100)
return sl
}
func myAppendPtr(sl *[]int) {
// 会改变外层 s 本身
*sl = append(*sl, 100)
return
}
func main() {
sl := []int{1, 1, 1}
sl2 := myAppend(sl)
fmt.Println(sl) // [1 1 1]
fmt.Println(sl2) // [1 1 1 100]
sl = sl2
myAppendPtr(&sl)
fmt.Println(sl) // [1 1 1 100 100]
}6.拓展内容6.1 拓展 1先看一个特殊的切片扩容案例:package main
import "fmt"
func main() {
sl := []int{1,2}
sl = append(s,3,4,5)
fmt.Printf("len=%d, cap=%d",len(sl),cap(sl))
}
// 运行结果 len=5, cap=6当 cap 很小时,按双倍扩容理论来说,结果应该是 len=5, cap=8;但事实却是 len=5, cap=6。原因简略分析:内存对齐导致的。我们来逐行捋一下源代码,当我们执行上述代码时,会触发 runtime.growslice 函数扩容 sl 切片,并传入期望的新容量 newcap = 5,这时期望分配的内存大小为 40(5 * 8) 字节,因此 roundupsize 的参数 size = 40。// ptrSize = 8 ptrSize 是指一个指针的大小,在 64 位机上是 8
// newcap = 5
capmem = roundupsize(uintptr(newcap) * ptrSize)
newcap = int(capmem / ptrSize)
// src/runtime/msize.go:13
func roundupsize(size uintptr) uintptr {
if size < _MaxSmallSize {
if size <= smallSizeMax-8 {
return uintptr(class_to_size[size_to_class8[divRoundUp(size, smallSizeDiv)]])
} else {
return uintptr(class_to_size[size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)]])
}
}
if size+_PageSize < size {
return size
}
return alignUp(size, _PageSize)
}
const _MaxSmallSize = 32768
const smallSizeMax = 1024
const smallSizeDiv = 8执行 roundupsize 最终走的分支如下:// 把常量替换后
return uintptr(class_to_size[size_to_class8[divRoundUp(40, 8)]])
// divRoundUp returns ceil(n / a).
func divRoundUp(n, a uintptr) uintptr {
// a is generally a power of two. This will get inlined and
// the compiler will optimize the division.
return (n + a - 1) / a
}
// 调用 divRoundUp 计算得到 47/8 = 5
// 取 size_to_class8 数组的 5 索引的值为 5
var size_to_class8 = [smallSizeMax/smallSizeDiv + 1]uint8{0, 1, 2, 3, 4, 5, 5, 6, 6, ...}
// 取 class_to_size 数组的 5 索引的值为 48
var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 24, 32, 48, 64, 80, 96, ...}
调用 runtime.roundupsize 向上取整内存的大小到 48 字节,所以新切片的容量为 newcap = int(capmem / ptrSize) = 48 / 8 = 6。6.2 拓展 2如果向一个 nil 的 slice 添加元素会发生什么?为什么?其实 nil slice 或者 empty slice 都是可以通过调用 append 函数来获得底层数组的扩容。最终都是调用 mallocgc 来向 Go 的内存管理器申请到一块内存,然后再赋给原来的 nil slice 或 empty slice,然后摇身一变,成为“真正”的 slice 了。初值为零值 nil 的切片类型变量,可以借助内置的 append 的函数进行操作,这种在 Go 语言中被称为“零值可用”。
cathoy
13. 入门 go 语言汇编,看懂 GMP 源码
前言近期在看 GMP 源码,涉及到了很多 Golang 汇编的代码,为了看懂 GMP,就得学习一下 Go 语言的汇编了。这几天通过对汇编的学习,了解到了寄存器、内存、函数调用栈以及函数调用过程等相关知识,不仅对操作系统底层有了更深一步的了解,还对上层的高级语言有了更深刻的认识,更为后续看懂 GMP 源码奠定了基础。这里把我的学习心得分享给大家,通过本文你可以了解到以下内容:什么是寄存器什么是内存什么是函数调用栈函数的调用过程如何把自己的 Golang 代码编译为汇编用汇编写自己的代码&并执行利用汇编知识进入 GMP 世界Go 版本 1.20.7主要参考文档:https://Go.dev/doc/asmhttps://9p.io/sys/doc/compiler.html想一起学习 Go 语言进阶知识的同学可以点赞+关注+收藏哦!后续将继续更新 GMP 相关源码分享(第一次有粉丝主动评论想学习这块内容,那必须得给安排上,我也不是啥大佬,凑合看哈哈哈)。通过读本文内容,希望各位读者最后都能够看懂 GMP 的源码。例如:runtime/asm_amd64.s为了让大家看的更顺畅一点,我们先了解一下寄存器、内存的概念,然后进入正题:Go 汇编入门,结合简单案例的实战,让自己拥有看懂简单汇编代码的能力。最后,我们开启 GMP 之旅!1.什么是寄存器寄存器是 CPU 内部的存储单元,用于存放从内存读取而来的数据(包括指令)和 CPU 运算的中间结果,之所以要使用寄存器来临时存放数据而不是直接操作内存,主要有以下两点原因:CPU 的工作原理决定了有些操作运算只能在 CPU 内部进行;CPU 读写寄存器的速度比读写内存的速度快得多。为了方便大家使用汇编语言进行编程,CPU 厂商为每个寄存器都取了一个名字,这样程序员就可以很方便的在汇编代码中使用寄存器的名字来进行编程。不同体系结构的CPU,其内部寄存器的数量、种类以及名称可能大不相同,应用最广泛的是 AMD64 这种体系结构的 CPU,这种 CPU 共有 20 多个可以直接在汇编代码中使用的寄存器,应用层一般只会用到 19 个(这些寄存器需要我们额外关心,其他寄存器一般由操作系统内部使用,我们不用关心),这 19 个寄存器它们大致分为三类:分类位数解释举例说明通用寄存器64位(8字节)通用寄存器共 16 个,一般没有被 CPU 规定特殊用途,由业务方自己定义和约定使用。通用寄存器:rax, rbx, rcx, rdx, rsi, rdi, rbp, rsp, r8, r9, r10, r11, r12, r13, r14, r15,其中 rbp 和 rsp 寄存器都跟函数调用栈有关,rbp 为**栈基址寄存器,rsp 为栈顶寄存器。**通用寄存器还可以作为 32/16/8 位寄存器使用,使用时需要换一个名字,例如:rax(64位) -> eax(32位) -> ax(16 位) -> al(低8位)/ah(高8位)。程序计数寄存器64位(8字节)程序计数寄存器也被称为 PC 寄存器或 IP 寄存器,用 rip 表示,它用来存放 CPU 下一条即将执行的指令的地址,这个寄存器决定了程序的执行流程。修改 rip 寄存器的值是 CPU 自动控制的,不需要业务程序操纵。段寄存器16位(2字节)段寄存器一般用来实现线程本地存储(TLS),由 fs 和 gs 表示。2.什么是内存内存是计算机系统的存储设备,其主要作用是协助 CPU 在执行程序时存储数据和指令。内存由大量内存单元组成,内存单元大小为 1 个字节,每一个内存单元都有一个地址,在汇编代码中想要读写内存,就必须在指令中指定内存地址,这样 CPU 才知道它要存取哪个或哪些内存单元。举一个简单的例子:在 Go 中 int64 类型的变量占用 8 个字节,任何大于一个字节的变量在内存中都存储在相邻连续的的几个内存单元之中,也就是占用 8 个连续的内存单元,要读写该变量,只需在汇编指令中指定这些内存单元的起始地址以及读写的字节数即可。操作系统把磁盘上的可执行文件加载到内存运行之前,会做很多工作,其中很重要的一件事情就是把可执行文件中的代码,数据放在内存中合适的位置,并分配和初始化程序运行过程中所必须的堆栈,所有准备工作完成后操作系统才会调度程序起来运行。来看一下程序运行时在内存中的布局图:进程运行时在内存中的布局主要分为四部分:代码区: 包括能被CPU执行的机器代码(指令)和只读数据比如字符串常量,程序一旦加载完成代码区的大小就不会再变化了。数据区: 包括程序的全局变量和静态变量,与代码区一样,程序加载完毕后数据区的大小也不会发生改变。堆: 程序运行时动态分配的内存都位于堆中,这部分内存由内存分配器负责管理。栈: 随函数调用、返回而增减的一片内存,用于为局部变量和函数调用链接信息分配存储空间。3.Go 汇编入门关于 Go 的汇编器,最重要的一点是它不是底层机器的直接表示。有些细节与机器精确对应,但有些则不然。在 Go 中编译出来的伪汇编代码中,使用的是 GAS 汇编语法(Gnu ASsembler),采用 AT&T 汇编格式,使用的是 Plan9 汇编语法,在机器汇编层面,总是有一些自己特立独行的惯例规约,接下来我们就一起走入 Go 汇编的世界。3.1 Go 的寄存器Go 汇编中大部分寄存器与机器中的寄存器存在一一对应的关系,下面是寄存器的名字在 AMD64 和 Plan 9 中的对应关系:AMD64raxrbxrcxrdxrsirdirbprspr8 ~ r15ripPlan9AXBXCXDXSIDIBPSPR8 ~ R15PC伪寄存器是 Plan9 伪汇编中的一个助记符, 也是 Plan9 比较有个性的语法之一。Go 汇编提供了 SB、PC、FP、SP 等常见的伪寄存器,接下来我们一一介绍一下。SB(Static base pointer: global symbols.) 指向全局符号,保存静态基地址(static-base) 指针,即我们程序地址空间的开始地址,一般用来声明函数或全局变量。符号 foo(SB) 表明我们的符号位于某个固定的相对地址空间起始处的偏移位置 (最终是由链接器计算得到的)。换句话来讲,它有一个直接的绝对地址: 是一个全局的函数符号。具体使用方法:exprmeaningfoo(SB)foo 这个符号的起始地址foo<>(SB)foo 符号只在当前文件可见foo+4(SB)从 foo 开始偏移4字节PC(Program counter: jumps and branches.)程序计数器,指向下一条要执行的指令的地址,在 AMD64 对应 rip 寄存器。修改 rip 寄存器的值是 CPU 自动控制的,不需要业务程序操纵,因此我们一般也用不到。FP(Frame pointer: arguments and locals.) 伪寄存器是一个虚拟帧指针,编译器维护一个虚拟帧指针,并将栈上的参数引用为该伪寄存器的偏移量。使用形如 symbol+offset(FP) 的方式,例如 arg0+0(FP),arg1+8(FP)。使用 FP 不加 symbol 时,无法通过编译,在汇编层面来讲,symbol 并没有什么用,加 symbol 主要是为了提升代码可读性。此处的 offset(FP),指的是与帧指针 (FP) 的偏移量(而 SB 中的偏移量是与 symbol 的偏移量)。因此,0(FP) 是函数的第一个参数,8(FP) 是第二个参数(在 64 位机器上)。举个具体点的例子:返回两个数之和的程序,代码执行完毕,结构存储在 R0 寄存器中。TEXT sum(SB), $0 // 声明 sum 函数(语法后边会学到),内部栈帧占用 0 字节
MOVL arg1+0(FP), R0 // arg1+0(FP) 表示第一个参数(int32),R0 = arg1+0(FP)
ADDL arg2+4(FP), R0 // arg2+4(FP) 从第一个参数偏移 4 字节,表示第二个参数, R0 = R0 + arg2+4(FP)
RTS // 函数返回SP(Stack pointer: top of stack.)伪寄存器是一个虚拟栈指针,指向本地栈帧内的最高地址,用于寻找栈本地变量和为函数调用准备的参数。SP 寄存器名字有点特殊,和物理寄存器 SP 同名,因此 Go 汇编使用两种语法进行区分:伪寄存器使用语法是 symbol+offset(SP),此场景下 SP 指向局部变量的起始位置(最高地址处);x-8(SP) 表示函数的第一个本地变量;物理 SP(硬件SP) 的使用语法则是 +offset(SP),此场景下 SP 指向真实栈顶地址(栈帧最低地址处)。注意:有 symbol 的 SP 和没 symbol 的 SP 不是一个东西,手写的时候有 symbol 的 SP,即symbol+offset(SP) 为伪寄存器 SP;而直接使用的 SP,即 MOVQ offset(SP) 为物理寄存器 SP。后面说到栈结构时会对 SP 和 FP 有图示讲解,但是这是在手写汇编时的说法,使用 Go tool compile 得到的汇编代码中,没有 伪 SP、FP 寄存器,生成真正可执行代码时,伪 SP、FP 会由物理 SP 寄存器加上偏移量替换。因此,SP、FP 伪寄存器只适用于我们看 Go 源码中的一些手写汇编代码。另外还有 1 个比较特殊的伪寄存器:TLS:存储当前 Goroutine 的 g 结构体的指针。实际上,X86 和 AMD64 下的 TLS 是通过段寄存器 FS 或 GS 实现的线程本地存储基地址,而当前 g 的指针是线程本地存储的第一个变量。3.2 Go 函数调用栈结构下图描述了栈桢与SP、FP寄存器的内存关系模型,能够帮助你快速理解 SP 伪寄存器和 FP 伪寄存器指向的位置和使用方式。 每个函数在栈中对应的片段叫做栈帧,也就是 stack frame。栈帧记录了函数调用需要的上下文信息。栈帧有以下几部分组成:caller BP:保存调用函数的栈基地址(BP),用于函数返回后获得调用函数的栈帧基地址。local var:保存函数内部本地变量。callee ret:保存被调用函数的返回值。callee arg:保存被调用函数的入参参数。return address:保存被调用函数返回后的程序地址,即本函数调用被调用函数的下一条指令地址,给 PC 赋值使用。栈帧中有一点需要注意,return addr 也是在 caller 的栈上的,不过往栈上插 return addr 的过程是由 CALL 指令完成的(在分析汇编时,是看不到关于 addr 相关空间信息的,在分配栈空间时,addr 所占用空间大小不包含在栈帧大小内)。 在 AMD64 环境,伪 PC 寄存器其实是 IP 指令计数器寄存器的别名。伪 FP 寄存器对应的是 caller 函数的帧指针,一般用来访问 callee 函数的入参参数和返回值。伪 SP 栈指针对应的是当前 callee 函数栈帧的底部,一般用于定位局部变量。伪 SP 是一个比较特殊的寄存器,因为还存在一个同名的 SP 物理寄存器,SP 物理寄存器对应的是栈的顶部,如图所示。3.3 Go 汇编语法3.3.1 文件命名使用到汇编时,即表明了所写的代码不能够跨平台使用,因此需要针对不同的平台使用不同的汇编代码。Go 编译器采用文件名中加入平台名后缀进行区分,例子: src/runtime/asan_amd64.s,详情可以参考 go/build。3.3.2 函数声明关于函数声明,我看网上有人这样画,感觉还挺清晰的(拿来用一下)。TEXT 指令用于定义函数
^ 静态基地址指针(告诉汇编器这是基于静态地址的数据)
| ^
| | 标签 函数入参占用空间大小(+ 部分返回值大小)
| | ^ ^
| | | |
TEXT pkgname·funcname(SB),ABIInternal,flag,$168-16
^ ^ ^ ^
| | | |
函数所属包名 函数名 表示ABI类型 函数栈帧大小(本地变量占用空间大小)
格式:TEXT pkgname·funcname(SB), ABI, flag, $framesize-argsize表示意义:TEXT: 定义函数标识;pkgname: 表示包名(可以省略,最好省略,不然修改包名还要级联修改);funcname: 声明的函数名;SB: 伪寄存器,前边已经介绍了。ABI(application binary interface): 应用程序二进制接口,规定了程序在机器层面的操作规范和调用规约,调用规约: calling convention, 所谓“调用规约”是调用方和被调用方对于函数调用的一个明确的约定,包括:函数参数与返回值的传递方式、传递顺序。只有双方都遵守同样的约定,函数才能被正确地调用和执行。如果不遵守这个约定,函数将无法正确执行。Go 从1.17.1版本开始支持多 ABI:1. 为了兼容性各平台保持通用性,保留历史版本 ABI,并更名为 ABI0。2. 为了更好的性能,增加新版本 ABI 取名 ABIInternal,该调用公约增加了用寄存器传递参数的约定,据官方测试,寄存器传参可以带来 5% 的性能提升。参考文档 Go internal ABI specification。flag: 标志位,如 NOSPLIT,因为 Go Runtime 会追踪每个 stack(栈)的使用情况,然后实现动态自增。而NOSPLIT 标志位禁止检查栈是否需要被分割,节省开销,但是写程序的人要保证这个函数是内存安全的,也就是你手写汇编的时候需要指定足够的栈内存 framesize。所有的 flag 定义需要通过 #include "textflag.h" 引入,参数如下:flag含义NOPROF = 1用于 TEXT 指令 不优化NOPROF标记的函数。这个标志已废弃。DUPOK = 2允许同二进制中有多个相同的符号,链接器会选择其中之一NOSPLIT = 4用于 TEXT 指令,标记不需要插入栈溢出检查RODATA = 8用于 DATA 和 GLOBAL 指令,将数据放入只读段NOPTR = 16用于 DATA 和 GLOBAL 指令,标记数据不包含指针,不需要 GC 扫描WRAPPER = 32用于 TEXT 指令,标记该函数只是一个 wrap 不要禁用 recoverNEEDCTXT = 64用于 TEXT 指令,标记该函数为闭包需使用传入的上下文寄存器LOCAL = 128此符号是动态共享对象的本地符号。TLSBSS = 256用于 DATA 和 GLOBAL 指令,标记分配 TLS 存储单元并将其偏移量存储在变量中NOFRAME = 512用于 TEXT 指令,标记不在函数中插入分配栈帧空间的指令,适用于零栈帧函数。TOPFRAME = 2048用于 TEXT 指令, 函数在调用栈顶部,调用追踪在此处停止。framesize: 本函数栈帧大小 = 局部变量(包括返回值,也属于局部变量,写代码的时候有时是会显示声明,有时候隐式声明;但不包含参数变量,该部分由函数调用方提供) + 调用其它函数参数空间的总大小。argsize:调用方为调用该函数提供的参数空间(包括返回值空间),这个大小在不同 Go 版本有不同的约定GO 1.7.1 之前 参数+返回值都存在栈帧中,此时 argsize = 调用参数大小 + 函数返回值大小GO 1.7.1 更新后, 优先使用 9 个 通用寄存器传递参数与返回值,超出部分再存在栈中,并且寄存器中返回值会覆盖参数中的值。3.3.3 变量声明全局数据通过一组 DATA 指令加上一个 GLOBAL 指令来定义。DATA 指令的格式是 DATA symbol+offset(SB)/width, value 表示在符号 symbol 的指定偏移量 offset 处初始化一个大小为 width 初始值为 value 的内存段,多个 DATA 指令的 offset/width 必须是连续的。这个 offset 需要稍微注意,其含义是该值相对于符号 symbol 的偏移,而不是相对于 SB 的偏移。GLOBAL 指令格式是 GLOBL symbol(SB), flag, $64用于声明全局符号,需要指定符号名,参数和大小。如果 DATA 指令没有初始化值,则 GLOBAL 会将其初始化为 0。在 GLOBL 中加入 <>, 如 GLOBL foo<>(SB), RODATA, $16 则表示这个全局变量只在本文件中生效,此处 RODATA 参数表示只读数据(Read Only)。实际例子:// src/runtime/asm_amd64.s, 这里声明的 argc,argv 是 Go 程序的入参
// NOPTR 这个表示不是指针,不需要垃圾回收扫描
DATA _rt0_amd64_lib_argc<>(SB)/8, $0
GLOBL _rt0_amd64_lib_argc<>(SB),NOPTR, $8
DATA _rt0_amd64_lib_argv<>(SB)/8, $0
GLOBL _rt0_amd64_lib_argv<>(SB),NOPTR, $8局部变量:其在栈帧中,不需要声明,直接依靠 offset 取出使用。例如 0(FP) 代表函数第一个参数,arg0-8(SP) 代表函数中第一个局部变量。3.3.4 常用指令栈调整intel 或 AT&T 汇编提供了 push 和 pop 指令族,plan9 中虽然有 push 和 pop 指令,但一般生成的代码中是没有的,我们看到的栈的调整大多是通过对物理 SP 寄存器进行运算来实现的,例如:SUBQ $0x18, SP // 对 SP 做减法,为函数分配函数栈帧 16+8=24
ADDQ $0x18, SP // 对 SP 做加法,清除函数栈帧数据搬运MOV 指令表示数据搬运操作,关于该操作有以下常用参数:Go 汇编指令一般格式是:操作码 + 源操作数 + 目标操作数的形式。例如:MOVQ $10, AX;表示 AX = 10。Go 汇编会在指令后加上 B , W , D 或 Q , 分别表示操作数的大小为 1 个,2 个,4 个或 8 个字节,例如:MOVQ。立即操作数需要加上 $ 符号做前缀,比如:MOVB $1, DI 这条指令中第一个操作数不是寄存器,也不是内存地址,而是直接写在指令中的一个常数,这种操作数叫做立即操作数。这条指令表示把数值 1 放入 DI 寄存器中。常数可表示为 $num, 可以为负数,默认为十进制,可以使用 $0x18 表示 16 进制的 24;寄存器间接寻址的格式为 offset(register) ,如果 offset 为 0,则可以略去偏移不写直接写成(register)。何为间接寻址呢?其实就是指令中的寄存器并不是真正的源操作数或目的操作数,寄存器的值是一个内存地址,这个地址对应的内存才是真正的源或目的操作数,比如:MOVQ (R1), R2 这条指令,第一个操作数 (R1) 用括号括起来,则表示间接寻址,R1 的值是一个内存地址,这条指令真实意思是:(R2 = *R1) 把 R1 寄存器的值(内存地址)对应的内存赋值给寄存器 R2 中的值;相对比,MOVQ R1, R2这条指令表示 R2 = R1。MOVB $1, DI // 1 byte
MOVW $0x10, BX // 2 bytes
MOVD $1, DX // 4 bytes
MOVQ $-10, AX // 8 bytes
MOVQ (R1), R2 // R2 = *R1
MOVQ R1, R2 // R2 = R1
MOVQ 8(R3), R4 // R4 = *(8 + R3)
MOVQ 16(R5)(R6*1), R7 // R7 = *(16 + R5 + R6*1)
MOVQ ·myvar(SB), R8 // R8 = *myvar
MOVQ $·myvar(SB), R8 // R8 = &myvar地址运算LEA:将有效地址加载到指定的地址寄存器中。amd64 平台地址都是 8 个字节,所以直接就用 LEAQ 就好了。LEAQ (BX)(AX*8), CX // CX = BX + (AX * 8)
// 上面代码中的 8 代表 scale
// scale 只能是 0、2、4、8
// 如果写成其它值:
// LEAQ (BX)(AX*3), CX
// ./a.s:6: bad scale: 3
// 用 LEAQ 的话,即使是两个寄存器值直接相加,也必须提供 scale
// 下面这样是不行的
// LEAQ (BX)(AX), CX
// asm: asmidx: bad address 0/2064/2067
// 正确的写法是
LEAQ (BX)(AX*1), CX
// 在寄存器运算的基础上,可以加上额外的 offset
LEAQ 16(BX)(AX*1), CX
// ret+24(FP) 这代表了第三个函数参数,是个地址
LEAQ ret+24(FP), AX // 把 ret+24(FP) 地址加载到 AX 寄存器中数据计算数据计算就是我们常见的”加减乘除“,注释写的很明白!ADDQ AX, BX // BX += AX
SUBQ AX, BX // BX -= AX
IMULQ AX, BX // BX *= AX
SUB R3, R4, R5 // R5 = R4 - R3
MUL $7, R6 // R6 *= 7跳转跳转包括条件跳转和无条件跳转两种模式:// 无条件跳转
JMP addr // 跳转到地址,地址可为代码中的地址,不过实际上手写不会出现这种东西
JMP label // 跳转到标签,可以跳转到同一函数内的标签位置
JMP 2(PC) // 以当前指令为基础,向前/后跳转 x 行
JMP -2(PC) // 同上
// 有条件跳转
JZ target // 如果 zero flag 被 set 过,则跳转
JLS num // 如果上一行的比较结果,左边小于右边则执行跳到 num 地址处控制流对于函数控制流的跳转,是用label来实现的,label 只在函数内可见。next: // next 标签定义
MOVW $0, R1
JMP next // 跳转到标签,可以跳转到同一函数内的标签位置DECQ/INCQ 自增和自减用于减少或者增加寄存器内的数值,DECQ(Decrease), INCQ(Increase)。INCQ CX // CX++
DECQ CX // CX--CALL 与 RET 指令CALL指令是用来调用函数的,在调用函数时除了要准备参数和返回值还需要保存 CALL 指令的下一条指令的地址(return addr),以便函数返回时能继续运行 caller 里的代码,这个保存动作就是 CALL 指令隐式去做的,它会把 PC 寄存器(储存的 CPU 下一条要运行的指令)的值(return addr)保存到栈里,因此栈结构那一小节会有这一块的内存。// CALL 指令的使用方式
CALL main.sub(SB)CALL相当于有三步操作:SUBQ $8, SP // 栈向下增长 8 个字节(申请 8 个字节的内存)MOVQ PC, (SP) // 在这刚刚申请的 8 个字节上赋值 PC 当前值,其实就是 return addressMOVQ 函数的第一条指令, PC // PC 指向 CALL 调用的函数的地址,这样 CPU 就可以执行被调用函数了。RET指令是用来返回一个函数的,相当于 return 语句。RET 与 CALL 是相反的操作,RET 相当于两步操作:MOVQ (SP), PC // PC 赋值为 SP 指向的地址的值,也就是刚刚 CALL 设置进来的 return addressADDQ $8, SP // 回收栈内存,CALL 申请的3.4 手写 Go 汇编Go 语言手写的汇编使用的 ABI 格式是 ABI0。对于手写汇编来说,所有参数通过栈来传递,通过伪寄存器 FP 偏移进行访问,函数的返回值跟随在输入参数后面,各个输入参数和返回值将以倒序的方式从高地址位分布于栈空间上,在下一小节会详细进行分析。3.4.1 计算 argsize 和 framesizeGo 汇编使用的是 caller-save 模式,因此被调用函数的参数、返回值的栈内存都需要由调用者维护和准备,在 amd64 平台(指针大小为 8 字节)上不满足 8 字节倍数的内存需要进行对齐。argsize = 参数大小求和 + 返回值大小求和 (最后再做内存对齐,8的倍数)函数参数往往混合了多种类型,还需要考虑内存对齐问题,所以如果不确定自己的函数签名需要多大的 argsize,可以通过简单实现一个相同签名的空函数,然后 go tool objdump(该命令如何使用后边有讲) 来逆向查找应该分配多少空间。当然,我这里使用的是 Go 1.20.7 版本,argsize 只包含部分返回值的大小,其余 9 个返回值用寄存器传递,所以可以把返回值当做参数定义到函数里,自然就可以得到 ABI0 格式下的 argsize。函数的 framesize(函数栈大小) 就稍微复杂一些了,手写代码的 framesize 不需要考虑由编译器插入的 caller BP(调用方的栈底,后边实例分析会讲到),需要考虑以下内容:全部局部变量大小之和。在函数中是否有对其它函数调,若有需要为其准备 argsize (虽然 return address(rip) 的值也是存储在 caller 的 stack frame 上的,但是这个过程是由 CALL 指令和 RET 指令完成 PC 寄存器的保存和恢复的,在手写汇编时,同样也是不需要考虑这个 PC 寄存器在栈上所需占用的 8 个字节的)。原则上调用函数时只要不把局部变量覆盖掉就可以了,因此可以多分配一点 framesize,如果少分配了就会导致局部变量被覆盖,按理说,只要保证进入和退出汇编函数时的 caller 和 callee 能正确拿到返回值就可以。3.4.2 案例一:求和main.gopackage main
var a = 999
func add(b int) int
func main() {
println(add(1))
}sum.s#include "textflag.h"
TEXT ·add(SB), NOSPLIT, $0-16
MOVQ ·a(SB), AX // 共用全局变量 a
ADDQ b+0(FP), AX // 使用参数 b
MOVQ AX, ret+8(FP) // 返回
RET
// 最后一行的空行是必须的,否则可能报 unexpected EOF
案例一中使用 main,go 和 sum.s 文件共同构成求和函数,使用 go build 即可编译执行;在 .go 文件中声明 add 函数,在 .s 文件中进行函数实现,.s 文件中求和函数使用了 a 这个全局变量(.go 文件中定义的),是为了说明 .s 和 .go 文件的全局变量是可以互相使用的, ·a(SB) 表示该符号需要链接器来帮我们进行重定向(relocation)。3.4.3 案例二:循环求和求切片每个元素之和:使用了自减、跳转加控制流指令实现了循环求和。 main.gopackage main
func sum([]int64) int64
func main() {
println(sum([]int64{1, 2, 3, 4, 5}))
}sum.s#include "textflag.h"
// func sum(sl []int64) int64
TEXT ·sum(SB), NOSPLIT, $0-32
MOVQ $0, SI // 初始化 SI
MOVQ sl+0(FP), BX // slice 的底层数组的指针
MOVQ sl+8(FP), CX // slice 的长度 len
start:
ADDQ (BX), SI // SI += *BX
ADDQ $8, BX // 指针移动
DECQ CX // CX--
JZ done // CX = 0 时跳转
JMP start
done:
// 返回地址是 24 是怎么得来的呢?
// 可以通过 go tool compile -S main.go 得知
// MOVQ AX, main..autotmp_3+24(SP)
// 在调用 sum 函数时,会传入三个值,分别为:
// slice 的首地址、slice 的 len, slice 的 cap
// 不过我们这里的求和只需要 len,但 cap 依然会占用参数的空间
// 就是 16(FP)
MOVQ SI, ret+24(FP)
RET
4.剖析 Go 函数调用栈分配过程本小节将使用一个简单的汇编代码,解答以下几个问题:CPU 是如何从调用者跳转到被调用函数执行的?参数是如何从调用者传递给被调用函数的?函数局部变量所占内存是怎么在栈上分配的?返回值是如何从被调用函数返回给调用者的?函数执行完成之后又需要做哪些清理工作?4.1 Go 工具链可以使用 Go 工具链的命令将 Go 代码编译为汇编代码。-N 禁用编译优化-l 禁止内联-m 打印编译优化策略(包括逃逸情况和函数是否内联,以及变量分配在堆或栈)-S 打印汇编// 方式一
go build -gcflags="-S -l -N" main.go 2> main.s
// 方式二
GOOS=linux GOARCH=amd64 go tool compile -S -L -N main.go
// 方式三
go build -gcflags="-S -l -N" main.go // 先编译为二进制
go tool objdump -s main.main main // 反汇编具体函数
go tool objdump -s "main\." main // 反编译获取汇编注意,这里顺带说一下,Go build -gcflags="-S -l -N" main.go 这个命令的输出是直接输出到标准错误的(FD = 2),所以如果需要重定向到文件需要将错误重定向到文件,也就是 go build -gcflags="-S -l -N" main.go 2> main.s。4.2 示例代码示例很简单,主要目的是梳理一下函数调用过程。使用 go build -gcflags="-S -l -N" main.go 2> main.s 编译代码并输出汇编。func main() {
_ = run(10, 20)
}
func run(a, b int) (x int) {
c := a + b
d := sub(100, c)
return d
}
func sub(x, y int) int {
return x - y
}4.3 汇编代码分析在我的 Mac 机器上,amd64 的架构,汇编代码生成如下(输出结果中的 FUNCDATA 和 PCDATA 指令由编译器引入,包含 GC 用到的信息,我们额外不需要关注)。main 函数汇编代码main.main STEXT size=54 args=0x0 locals=0x18 funcid=0x0 align=0x0
# 声明 main 函数,函数栈帧 24 字节,格式 ABIInternal
# 24 = caller BP(8)+ 调用 run(16)
0x0000 00000 (main.go:3) TEXT main.main(SB), ABIInternal, $24-0
# R14 寄存器就是当前协程 g,这里比较栈顶 SP 寄存器与 16(R14) 地址大小(也就是字段 stackguard0);如果小于说明栈空间不足
0x0000 00000 (main.go:3) CMPQ SP, 16(R14)
0x0004 00004 (main.go:3) PCDATA $0, $-2
0x0004 00004 (main.go:3) JLS 47 # 栈空间不足,则跳转到地址 00047 CALL runtime.morestack_noctxt(SB)
0x0006 00006 (main.go:3) PCDATA $0, $-1
# 生成 24 字节大小的栈空间
0x0006 00006 (main.go:3) SUBQ $24, SP # 即把 SP 向低地址移动 24 字节作为 main 栈的栈顶
# BP 是栈帧的栈底指针,当进行函数调用时需要把 caller 的 BP 中的值保存在 callee 的栈帧中
0x000a 00010 (main.go:3) MOVQ BP, 16(SP)
0x000f 00015 (main.go:3) LEAQ 16(SP), BP # 把当前(callee)栈帧的栈底地址赋值给 BP 寄存器,指向 callee 的栈底
0x0014 00020 (main.go:3) FUNCDATA $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
0x0014 00020 (main.go:3) FUNCDATA $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
0x0014 00020 (main.go:4) MOVL $10, AX # AX 存储 10
0x0019 00025 (main.go:4) MOVL $20, BX # BX 存储 20
0x001e 00030 (main.go:4) PCDATA $1, $0
# NOP(No Operation)指令是汇编语言中的一种指令,它表示不执行任何操作,仅作为一个占位符使用
0x001e 00030 (main.go:4) NOP
0x0020 00032 (main.go:4) CALL main.run(SB) # 调用 main.run 函数
0x0025 00037 (main.go:5) MOVQ 16(SP), BP # BP 重新指向 caller 的 BP
0x002a 00042 (main.go:5) ADDQ $24, SP # 回收栈空间
0x002e 00046 (main.go:5) RET # 返回
0x002f 00047 (main.go:5) NOP
0x002f 00047 (main.go:3) PCDATA $1, $-1
0x002f 00047 (main.go:3) PCDATA $0, $-2
0x002f 00047 (main.go:3) CALL runtime.morestack_noctxt(SB) // 执行扩容
0x0034 00052 (main.go:3) PCDATA $0, $-1
0x0034 00052 (main.go:3) JMP 0 // 扩容后跳回 0 地址继续执行 main 第一条指令
一旦检查到栈溢出,就会 call runtime.morestack_noctxt(SB) 执行栈拷贝功能,这包含新栈分配+旧栈拷贝两个部分。run 函数汇编代码main.run STEXT size=104 args=0x10 locals=0x30 funcid=0x0 align=0x0
# 声明 main.run 函数,函数栈帧 48 字节
# 48 = caller BP(8)+ 调用 sub(16)+ 局部变量(16) + 返回值局部变量(8)
0x0000 00000 (main.go:7) TEXT main.run(SB), ABIInternal, $48-16
0x0000 00000 (main.go:7) CMPQ SP, 16(R14)
0x0004 00004 (main.go:7) PCDATA $0, $-2
0x0004 00004 (main.go:7) JLS 77
0x0006 00006 (main.go:7) PCDATA $0, $-1
0x0006 00006 (main.go:7) SUBQ $48, SP # 分配栈空间
0x000a 00010 (main.go:7) MOVQ BP, 40(SP) # 存储 caller 的 BP
0x000f 00015 (main.go:7) LEAQ 40(SP), BP # 指向 callee 的BP
0x0014 00020 (main.go:7) FUNCDATA $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
0x0014 00020 (main.go:7) FUNCDATA $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
0x0014 00020 (main.go:7) FUNCDATA $5, main.run.arginfo1(SB)
# AX 为第一个参数 10,存储到 56(SP) 地址处,属于 caller 函数栈,看图更清晰
0x0014 00020 (main.go:7) MOVQ AX, main.a+56(SP)
0x0019 00025 (main.go:7) MOVQ BX, main.b+64(SP) # 同 AX,BX 存储 20
0x001e 00030 (main.go:7) MOVQ $0, main.x+16(SP) # x 返回值,在 callee 栈内
0x0027 00039 (main.go:8) ADDQ AX, BX # BX 存储 10 + 20
0x002a 00042 (main.go:8) MOVQ BX, main.c+32(SP) # c 局部变量存储结果 30
0x002f 00047 (main.go:9) MOVL $100, AX # AX 用于下一个函数的传参 100
0x0034 00052 (main.go:9) PCDATA $1, $0
0x0034 00052 (main.go:9) CALL main.sub(SB) # 调用 main.sub
# main.sub 函数的返回值用 AX 传递,存储在 caller 栈内存中
0x0039 00057 (main.go:9) MOVQ AX, main.d+24(SP)
0x003e 00062 (main.go:10) MOVQ AX, main.x+16(SP) # 自己的返回值属于局部变量
0x0043 00067 (main.go:10) MOVQ 40(SP), BP # BP 重新指向 main 函数 BP
0x0048 00072 (main.go:10) ADDQ $48, SP # 回收栈空间
0x004c 00076 (main.go:10) RET
0x004d 00077 (main.go:10) NOP
0x004d 00077 (main.go:7) PCDATA $1, $-1
0x004d 00077 (main.go:7) PCDATA $0, $-2
0x004d 00077 (main.go:7) MOVQ AX, 8(SP) # 栈扩容前先将参数存储到 caller 栈内存中
0x0052 00082 (main.go:7) MOVQ BX, 16(SP)
0x0057 00087 (main.go:7) CALL runtime.morestack_noctxt(SB)
0x005c 00092 (main.go:7) MOVQ 8(SP), AX # 栈扩容完毕,再赋值到寄存器中
0x0061 00097 (main.go:7) MOVQ 16(SP), BX
0x0066 00102 (main.go:7) PCDATA $0, $-1
0x0066 00102 (main.go:7) JMP 0
sub 函数汇编代码main.sub STEXT nosplit size=49 args=0x10 locals=0x10 funcid=0x0 align=0x0
# 声明 main.sub 函数,函数栈帧 16 字节
# 16 = caller BP(8) + 返回值局部变量(8)
# NOSPLIT 标志不用做栈溢出检查
0x0000 00000 (main.go:13) TEXT main.sub(SB), NOSPLIT|ABIInternal, $16-16
0x0000 00000 (main.go:13) SUBQ $16, SP # 分配栈空间
0x0004 00004 (main.go:13) MOVQ BP, 8(SP) # 存储 caller BP
0x0009 00009 (main.go:13) LEAQ 8(SP), BP # 指向 caller BP
0x000e 00014 (main.go:13) FUNCDATA $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
0x000e 00014 (main.go:13) FUNCDATA $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
0x000e 00014 (main.go:13) FUNCDATA $5, main.sub.arginfo1(SB)
0x000e 00014 (main.go:13) MOVQ AX, main.x+24(SP) # 参数传递 100
0x0013 00019 (main.go:13) MOVQ BX, main.y+32(SP) # 参数传递 30
0x0018 00024 (main.go:13) MOVQ $0, main.~r0(SP) # 返回值局部变量,未命名 r0
0x0020 00032 (main.go:14) SUBQ BX, AX # 相减后存储在 AX
0x0023 00035 (main.go:14) MOVQ AX, main.~r0(SP) # 局部返回值变量赋值 70
0x0027 00039 (main.go:14) MOVQ 8(SP), BP # BP 重新指向 run 函数 BP
0x002c 00044 (main.go:14) ADDQ $16, SP # 回收栈内存
0x0030 00048 (main.go:14) RET
整体函数栈内存图:(对照着上边的汇编看,应该很容易看明白) 4.4 问题解答CPU 是如何从调用者跳转到被调用函数执行的?还记得 CALL 指令相当于以下三步操作吗?SUBQ $8, SP // 栈向下增长 8 个字节(申请 8 个字节的内存)MOVQ PC, (SP) // 在这刚刚申请的 8 个字节上赋值 PC 当前值,其实就是 return addressMOVQ 函数的第一条指令, PC // PC 指向 CALL 调用的函数的地址,这样 CPU 就可以执行被调用函数了。经过这三步操作后,PC 指向了函数的第一条指令,CPU 执行下一条指令时,就会从调用者跳转到被调用函数执行。参数是如何从调用者传递给被调用函数的?经过对汇编源码和函数栈内存的分析,我们可以得知调用者(caller)栈帧中存储了传递给被调用函数的参数和部分返回值,而参数传递时通过 AX 或 BX 寄存器实现的,传递方式和 Go 版本息息相关。函数局部变量所占内存是怎么在栈上分配的?函数内部的局部变量被分配到自己的栈帧中,随着函数调用结束而自动消亡,最早声明的局部变量被分配到低地址,向高地址扩散,可以看一下上一小节的栈内存分配图。返回值是如何从被调用函数返回给调用者的?在 Go 1.20.7 版本,前 9 个返回值是通过寄存器传递给调用者的,多于 9 个的部分通过存储在调用者函数栈帧中进行传递,我们可以看到 sub 函数返回到 run 函数的参数的传递方式正是使用了寄存器传递。函数执行完成之后又需要做哪些清理工作?首先执行 ADDQ $16, SP回收栈内存,随后使用 RET 指令返回调用者。 还记得 RET 指令相当于两步操作吗?MOVQ (SP), PC // PC 赋值为 SP 指向的地址的值,也就是刚刚 CALL 设置进来的 return addressADDQ $8, SP // 回收栈内存,CALL 申请的将 PC 指向调用前的下一条指令地址,等 CPU 执行时,就顺利的从调用函数返回到了调用者。回收存储 return address 时的栈内存。5.开始整 GMP 源码5.1 安装 gdb 调试工具主要步骤:下载安装 brew install gdb创建证书(mac 下版本 10.15.6 Catalina)证书授权 gdb5.2 寻找程序入口main.go 文件源码:package main
import "fmt"
func main() {
fmt.Println("Hello World!")
}编译源码// 关闭函数内联和编译器的代码优化,使得编译可以不受其干扰更好地可以查看编译源码的展示
go build -gcflags=all="-N -l" -ldflags=-compressdwarf=false -o main main.go执行 gdb main 进入程序调试,执行 info files 查看程序入口:在程序入口设置断点进行调试 break *0x1068260执行 run 运行程序,就找到对应的汇编文件了,接着就可以顺着汇编文件进行分析了。这里我们先简单看一下这个文件:JMP 直接跳转到了 _rt0_amd64(SB) 函数,使用全局搜索,找到定义的地方,就可以继续愉快的看源码了! 入口源码如下:// _rt0_amd64 is common startup code for most amd64 systems when using
// internal linking. This is the entry point for the program from the
// kernel for an ordinary -buildmode=exe program. The stack holds the
// number of arguments and the C-style argv.
TEXT _rt0_amd64(SB),NOSPLIT,$-8
MOVQ 0(SP), DI // argc
LEAQ 8(SP), SI // argv
JMP runtime·rt0_go(SB)前两行指令把操作系统内核传递过来的参数 argc 和 argv 数组的地址分别放在 DI 和 SI 寄存器中,第三行指令跳转到 rt0_go 去执行。rt0_go 函数完成了 go 程序启动时的所有初始化工作,因此这个函数比较长,也比较繁杂,这里我们能看到与调度器相关的一些初始化;GMP 就从这里开始了,详细内容下一节再分享吧!总结本文讲解了寄存器、内存、Go 汇编语法 以及函数调用栈结构等相关内容,能够快速入门 Go 汇编;随后以求和为例,实现了自己的手写汇编代码,然后深度剖析了 Go 函数调用栈分配过程;最后,进入我们的终极目标,开始研读 GMP 源码,讲述了如何开始阅读 GMP,后续会继续分享 GMP 源码的阅读过程,分析、总结 Go 调度器的相关知识,敬请期待!
cathoy
golang 的交叉编译
前言交叉编译: 在一个平台上编译,然后放到另外一个平台去执行。Go 官方工具链支持超级简单的交叉编译功能,只需要改变环境变量,而且编译的工具是 Go 内置的,因此十分的方便,这里总结一下,以便后续使用时查找。交叉编译指令Linux/Mac 支持一次性更改其环境变量,因此其交叉编译更为简单。编译环境执行环境指令LinuxMacCGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build main.goLinuxWindowsCGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build main.goMacLinuxCGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build main.goMacWindowsCGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build main.go在 Windows 下编译与 Linux/Mac 略有不同,只能手动设置环境变量,而不能只一次性更改其环境变量,因此相对复杂一点。# Linux 下执行
SET CGO_ENABLED=0
SET GOOS=linux
SET GOARCH=amd64
go build main.go
# Mac 下执行
SET CGO_ENABLED=0
SET GOOS=darwin
SET GOARCH=amd64
go build main.go
补充: 当我们使用的 Go 语言版本 >= 1.13 时候,直接使用 set 已经不管用了,可以使用 go env -w 命令进行设置。例如:设置 GOOS=linux 的命令 go env -w GOOS=linux参数详解CGO_ENABLED : CGO 用于在 GO 代码中使用 C 语言编程,由于交叉编译中不支持,因此要禁用,CGO_ENABLED = 0 表示禁用 CGO。(go 默认是开启 CGO 工具,可以使用 go env 命令查看)GOOS : 目标平台的操作系统。GOARCH:目标平台的计算机架构。系统GOOSGOARCHWindows 32位windows386Windows 64位windowsamd64OS X 32位darwin386OS X 64位darwinamd64Linux 32位linux386Linux 64位linuxamd64
cathoy
8. 看 Go 源码,你需要了解 unsafe.Pointer
前言如果你经常看 Go 的源码,一定会遇到 unsafe.Pointer 类型,例如上篇文章 map 源码解析中有这样一段代码:// 获取下一个溢出桶
func (b *bmap) overflow(t *maptype) *bmap {
return *(**bmap)(add(unsafe.Pointer(b), uintptr(t.bucketsize)-sys.PtrSize))
}上边这段代码利用 unsafe.Pointer 和 uintptr 类型的特性,通过直接对内存地址进行操作的方式,实现了链表下一节点的查找,使得代码更加高效。当然有利自然有弊,它实际上绕开了 Go 语言的类型安全机制,通过它可以直接操作内存,这种行为无疑是危险的,就如 unsafe 包的命名一样,官方都认为这个类型是很不安全的,所以轻易不要使用,但我们要解它,才能更好的阅读源码。1.Go 中的指针因为 unsafe.Pointer 与指针息息相关,因此我们先简单来了解一下 Go 语言中的指针。1.1 指针的基本使用变量的本质是对一块内存空间的命名,我们可以通过引用变量名来使用这块内存空间存储的值,而指针则是用来指向这些变量值所在内存地址的值。Go 语言支持指针,如果一个变量是指针类型的,那么就可以用这个变量来存储指针类型的值。简单举个例子:package main
import "fmt"
func main() {
var x *int
a := 1
x = &a
fmt.Println("a:", a, ",", "x:", x)
a++
fmt.Println("&a:", &a, ",", "*x:", *x)
*x++
fmt.Println("a:", a, ",", "*x:", *x)
x = nil
fmt.Println("a:", a, ",", "x:", x)
}
// 输出结果
a: 1 , x: 0xc000120000
&a: 0xc000120000 , *x: 2
a: 3 , *x: 3
a: 3 , x: <nil>我们解释一下上边的代码,了解一下指针的基本使用:&a 表示取变量 a 的地址,也就是 0xc000120000;x变量是指针变量,指向了变量 a 所在的内存地址 0xc000120000;*x表示解引用得到内存地址存储的变量 a,此时 *x 等价于 a,因此变量 a 的修改会影响 *x,相反 *x 的修改也会反应到变量 a 上;但 x = nil 改变了指针变量 x 的指向,只是断开了与变量 a 的引用关系,因此并不能影响变量 a。1.2 指针作为函数参数指针变量常被用作参数传递,不仅可以节省内存空间,还可以在调用函数中实现对变量值的修改。 举个例子对比一下 Go 语言中的参数传递:package main
import "fmt"
func swap1(a, b int) {
a, b = b, a
}
func swap2(a, b *int) {
a, b = b, a
}
func swap3(a, b *int) {
*a, *b = *b, *a
}
func main() {
a := 1
b := 2
swap1(a, b)
fmt.Println(a, b)
swap2(&a, &b)
fmt.Println(a, b)
swap3(&a, &b)
fmt.Println(a, b)
}我们都知道 Go 中函数参数为“值传递”,也就意味着传递到函数 swap 的都是 a,b 变量的副本:由于 swap1 传递的是变量值的副本,因此在函数内修改不会影响原参数;swap2 传递的是指针变量的副本,但并没有对指针变量所指向的内存地址做更改,只是更改了副本指针变量的指向地址,因此也不会影响原参数;swap3 传递的也是指针变量的副本,但通过解引用的方式,修改了指针变量指向的内存地址所代表的值,因此影响了原参数,达到了函数最终的目的,指针的作用也由此体现。因此,只有通过指针变量修改了所指向的内存地址中存储的值时,才会对原变量产生影响;如果只是对变量的副本做改变,是不会影响原变量的。1.3 指针的限制Go 既然有指针了,为什么还需要 unsafe.Pointer 类型呢?这就得聊一聊 Go 语言中对指针的一些限制了:Go 中指针不能进行算术运算。例如:&a++Go 中不同类型的指针不能相互转换。例如:var a int = 1;f := (*float64)(&a)Go 中不同类型的指针不能比较和相互赋值,例如:var a int = 1;var f float64;f = &a;&a == &f以上指针错误的使用方式在 Go 中都会编译报错。Go 语言是一门强类型的静态语言,意味着类型一旦定义就不能改变,为了安全考虑,对指针做了以上限制。然而,为了性能的高效,官方开放了一个指针类型 unsafe.Pointer,它可以包含任意类型变量的地址,它绕开了 Go 语言的类型系统,通过它可以直接操作内存,因此使用起来并不安全,接下来我们就一起看看 unsafe.Pointer 是什么,以及用它都玩些什么花活。2.unsafe.Pointer 是什么unsafe.Pointer 是特别定义的一种指针类型,它可以包含任意类型变量的地址。代码地址:src/unsafe/unsafe.go,源码中只有 5 行代码,其余都是注释。type ArbitraryType int
type Pointer *ArbitraryType
func Sizeof(x ArbitraryType) uintptr
func Offsetof(x ArbitraryType) uintptr
func Alignof(x ArbitraryType) uintptrArbitraryType 是 int 的一个别名,意思为任意的类型; Pointer 是 *ArbitraryType 的一个别名,表示指向任意类型的指针。 Go 官方文档对这个类型有如下四个描述:任何类型的指针都可以被转化为 unsafe.Pointer;unsafe.Pointer 可以被转化为任何类型的指针;uintptr 可以被转化为 unsafe.Pointer;unsafe.Pointer 可以被转化为 uintptr。什么是 uintptr ?源码地址:src/builtin/builtin.go// uintptr is an integer type that is large enough to hold the bit pattern of
// any pointer.
type uintptr uintptruintptr 是一个整数类型,足够大能保存任何一种指针类型。uintptr 指的是具体的内存地址,不是个指针,因此 uintptr 地址关联的对象可以被垃圾回收。而 unsafe.Pointer 有指针语义,可以保护它不会被垃圾回收。指针类型、unsafe.Pointer、uintptr 三者关系如下:指针类型 *T <-> unsafe.Pointer <-> uintptruintptr 是可用于存储指针的整型,而整型是可以进行数学运算的。因此,将 unsafe.Pointer 转化为 uintptr 类型后,就可以让本不具备运算能力的指针具备了指针运算能力。unsafe 包中还有三个函数,这里简单解释一下:Sizeof:返回类型 x 所占据的字节数,但不包含 x 所指向的内容的大小。例如,如果 x 是切片,则 Sizeof 返回切片描述符的大小,而不是切片引用的内存的大小。Offsetof:返回 x 表示的字段在结构体中的偏移量,该字段必须采用 structValue.field 形式。 换句话说,它返回结构体开头和字段开头之间的字节数。Alignof:返回变量 x 所需的对齐方式,例如;如果变量 s 是结构体类型,并且 f 是该结构体中的字段,则 Alignof(s.f) 将返回结构体中该类型字段所需的对齐方式。总结:unsafe.Pointer可以和任意的指针类型进行转换,意味着可以借助 unsafe.Pointer 完成不同指针类型之间的转换。unsafe.Pointer 可以转换为 uintptr,而 uintptr 拥有计算能力,因此指针可以借助 unsafe.Pointer 和 uintptr 完成算术运算,进而直接操作内存。3.unsafe.Pointer 实战了解了 unsafe.Pointer 是什么后,我们通过实际的例子对其加深一下印象。3.1 指针类型转换unsafe.Pointer 可以在不同的指针类型之间做转化,从而可以表示任意可寻址的指针类型,利用 unsafe.Pointer 为中介,即可完成指针类型的转换。 先举一个简单例子,看一下不同指针类型之间的转换过程:package main
import (
"fmt"
"unsafe"
)
func main() {
i := 100
intI := &i
var floatI *float64
floatI = (*float64)(unsafe.Pointer(intI))
*floatI = *floatI * 3
fmt.Printf("%T\n", i)
fmt.Println(i)
fmt.Printf("%T\n", intI)
fmt.Printf("%T\n", floatI)
}
// 输出
int
300
*int
*float64该例子中定义了两个指针变量分别是 *int 类型的 intI 和 *float64 类型的 floatI,然后先对 intI 做了类型 unsafe.Pointer 的转换,随后进行 *float64 类型的转换;然后对 *float64 进行乘法操作,最终影响到了 i 变量,也从侧面证明了 *float64 的指针变量是指向 i 变量的内存地址的。然后我们看一个类型转换经典的例子:实现字符串和 bytes 切片之间的转换,要求是零拷贝。 如果按照以往的方式,循环遍历,然后挨个拷贝赋值是无法完成目标的,这个时候只能考虑共享底层 []byte 数组才可以实现零拷贝转换。 string 和 []byte 在运行时的类型表示为 reflect.StringHeader 和 reflect.SliceHeader源码:src/reflect/value.gotype StringHeader struct {
Data uintptr
Len int
}
type SliceHeader struct {
Data uintptr
Len int
Cap int
}使用 unsafe.Pointer 将 string 或 []byte 转换为 *reflect.StringHeader 或 *reflect.SliceHeader,然后通过构造方式,完成底层 []byte 数组的共享,最后通过指针类型转换方式再次转换回来,代码如下:func string2bytes(s string) []byte {
stringHeader := (*reflect.StringHeader)(unsafe.Pointer(&s))
bh := reflect.SliceHeader{
Data: stringHeader.Data,
Len: stringHeader.Len,
Cap: stringHeader.Len,
}
return *(*[]byte)(unsafe.Pointer(&bh))
}
func bytes2string(b []byte) string{
sliceHeader := (*reflect.SliceHeader)(unsafe.Pointer(&b))
sh := reflect.StringHeader{
Data: sliceHeader.Data,
Len: sliceHeader.Len,
}
return *(*string)(unsafe.Pointer(&sh))
}3.2 指针运算通过上边的分析,我们知道指针可以借助 unsafe.Pointer 和 uintptr 完成指针运算,接下来我们看两个例子。案例一:通过指针运算,修改数组内部的值。package main
import (
"fmt"
"unsafe"
)
func main() {
arr := [3]int{1, 2, 3}
ap := &arr
arr0p := (*int)(unsafe.Pointer(ap))
arr1p := (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(ap)) + unsafe.Sizeof(arr[0])))
*arr0p += 10
*arr1p += 20
fmt.Println(arr) // [11 22 3]
}解释一下关键代码: arr0p := (*int)(unsafe.Pointer(ap)) 通过类型转换的方式,使得 arr0p 变量指向了 arr 数组的起始地址,也就是 arr[0] 所在的起始地址;uintptr(unsafe.Pointer(ap))将数组的起始地址转换为 uintptr 类型,然后加上unsafe.Sizeof(arr[0])数组第一个元素的偏移量,得到 arr[1] 的起始地址,通过类型转换为 *int 指针,赋值给 arr1p。最终做到通过指针修改数组内部元素的值。案例二:通过指针运算,修改结构体内的值。package main
import (
"fmt"
"unsafe"
)
type user struct {
name string
age int
}
func main() {
u := new(user)
fmt.Println(*u) // { 0}
pName := (*string)(unsafe.Pointer(u))
*pName = "张三"
pAge := (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(u)) + unsafe.Offsetof(u.age)))
*pAge = 20
fmt.Println(*u) // {张三 20}
}通过 unsafe.Offsetof(u.age) 内存偏移的方式,定位到我们需要操作的字段(比如: age),然后改变它们的值。具体的类型转换过程和案例一类似,这里不再赘述。4.总结unsafe.Pointer 有两个最重要的作用:作为不同类型指针互相转换的中介;利用 uintptr 突破指针不能进行算术运算的限制,从而达到直接操作内存的目的。unsafe.Pointer 类型可以绕开 Go 语言的类型系统,还可以直接操作内存,方便了很多代码的编写,且提升了代码性能,比如:底层类型相同的指针之间的转换,访问结构体私有字段等。但同时因为其特性,使其变得很不安全,因此请各位慎用。
cathoy
2. golang 的数组
前言数组是 Go 语言中重要的数据结构,了解它的实现能够帮助我们更好地理解这门语言,而且数组是很多复杂结构的底层数据结构,学习数组对后续理解复杂数据结构有着至关重要的作用,今天开启 golang 第一课:数组。1 数组的类型Go 语言提供了数组类型的数据结构,数组类型包含两个重要属性:元素的类型和数组长度。var arr [N]T这里声明了一个数组变量 arr,它的类型为 [N]T,其中元素的类型为 T,数组的长度为 N。注意:数组元素的类型可以为任意的 Go 原生类型或自定义类型,而且数组的长度必须在声明数组变量时提供,Go 编译器需要在编译阶段就知道数组类型的长度,数组长度一旦确定不能更改,所以,我们只能用整型数字面值或常量表达式作为 N 值。如果两个数组类型的元素类型 T 与数组长度 N 都是一样的,那么这两个数组类型是等价的,如果有一个属性不同,它们就是两个不同的数组类型,例如:[5]int 和 [6]int 为不同数组类型。2 数组的内存分配数组不仅是逻辑上的连续序列,而且在实际内存分配时也占据着一整块内存。 Go 编译器在为数组类型的变量实际分配内存时,会为 Go 数组分配一整块、可以容纳它所有元素的连续内存。这块内存全部空间都被用来表示数组元素,所以说这块内存的大小,就等于各个数组元素的大小之和。如果两个数组所分配的内存大小不同,那么它们肯定是不同的数组类型。Go 提供了预定义函数 len 可以用于获取一个数组类型变量的长度,通过 unsafe 包提供的 Sizeof 函数,我们可以获得一个数组变量的总大小,如下面代码:var arr = [6]int{1, 2, 3, 4, 5, 6}
fmt.Println("数组长度:", len(arr)) // 6
fmt.Println("数组大小:", unsafe.Sizeof(arr)) // 48数组大小就是所有元素的大小之和,这里数组元素的类型为 int。在 64 位平台上,int 类型的大小为 8,数组 arr 一共有 6 个元素,因此它的总大小为 6x8=48 个字节。3 数组的声明和初始化不进行显式初始化,那么数组中的元素值就是它类型的零值。var arr [6]int // [0 0 0 0 0 0]显式地对数组初始化,需要在右值中显式放置数组类型,并通过大括号的方式给各个元素赋值。var arr = [6]int {
11, 12, 13, 14, 15, 16,
} // [11 12 13 14 15 16]当然,我们也可以忽略掉右值初始化表达式中数组类型的长度,用“…”替代,Go 编译器会根据数组元素的个数,自动计算出数组长度:var arr = [...]int {
21, 22, 23,
} // [21 22 23]
fmt.Printf("%T\n", arr) // [3]int对一个长度较大的稀疏数组进行显式初始化,可以通过使用下标赋值的方式对它进行初始化,Go 编译器会根据最大的索引赋值来确定数组大小。var arr = [...]int{
99: 1099, // 将第100个元素(下标值为99)的值赋值为1099,其余元素值均为0
}
fmt.Printf("%T\n", arr) // [100]int4 数组元素的访问和遍历通过数组变量以及下标值,可以很容易地访问到数组中的元素值,并且这种访问是十分高效的,不存在 Go 运行时带来的额外开销。数组的下标值是从 0 开始的。如果下标值超出数组长度范畴,或者是负数,那么 Go 编译器会给出错误提示:防止访问溢出。var arr = [6]int{1, 2, 3, 4, 5, 6}
fmt.Println(arr[0], arr[5]) // 1 6
fmt.Println(arr[-1]) // 错误:下标值不能为负数
fmt.Println(arr[8]) // 错误:小标值超出了 arr 的长度范围for 循环遍历数组:for(i) 形式遍历for range 形式遍历// for(i)
arr := [5]int{1,2,3,4,5}
for i := 0; i < len(arr); i++{
fmt.Println(arr[i])
}
// for range
arr := [5]int{1,2,3,4,5}
for i, v := range arr {
fmt.Printf("index=%d, value=%\n", i, v)
}
// 不需要索引 i,可以用 _进行省略
for _, v := range arr {
fmt.Printf("value=%\n", v)
}
// 不需要值 v,可以用 _进行省略
for i, _ := range arr {
fmt.Printf("index=%d\n", i)
}
cathoy
15. Go调度器系列解读(二):Go 程序启动都干了些什么?
前言本篇文章分享 Go 调度器系列文章第二篇:Go 程序启动的整体过程。在这篇文章中,我们主要会聊到 Go 程序的启动、初始化以及第一个 G 的调度过程,并画了丰富的流程图和内存图帮助理解,你可以从中学习到以下内容:如何寻找一个 Go 程序的执行入口?Go 程序启动过程都干了什么?m0 、g0、p 等对象的初始化过程系统线程与 m0 是如何完成绑定的?何时创建的第一个 goroutine,其任务函数是什么?m0 线程的调度启动过程main 函数从调用到结束的过程Go 调度器系列文章:13. 入门 go 语言汇编,看懂 GMP 源码14. Go调度器系列解读(一):什么是 GMP?解读的源码环境:Go 版本 1.20.7、linux 系统源码地址如下:src/runtime/runtime2.gosrc/runtime/proc.gosrc/runtime/asm_amd64.s1.程序入口任何一个由编译型语言所编写的程序在被操作系统加载起来运行时,都会顺序经过如下几个阶段:从磁盘上把可执行程序读入内存;创建进程和主线程;为主线程分配栈空间;把由用户在命令行输入的参数拷贝到主线程的栈;把主线程放入操作系统的运行队列等待被调度执起来运行。在主线程第一次被调度起来执行第一条指令之前,主线程的函数栈如下图所示:在 Go 程序执行到启动程序入口点之前,Go 运行时系统会进行一些初始化的准备工作,包括加载和链接依赖的包等,在这个过程中,runtime·m0 和 runtime·g0 等全局变量也会被初始化。随后 Go 程序开始从程序入口开始执行。Go 程序的执行入口并不是 runtime.main 函数,关于程序入口这个问题,之前在 13. 入门 go 语言汇编,看懂 GMP 源码 文章中专门讨论过,我们来复习一下(这里使用的 macOS 系统)。第一步:首先准备 main.go 文件,安装 gdb 调试程序(安装过程:mac gdb 安装避坑指南):package main
import "fmt"
func main() {
fmt.Println("Hello GMP!")
}第二步:编译源码 go build -gcflags=all="-N -l" -ldflags=-compressdwarf=false -o main main.go ;第三步:执行 gdb main 进入程序调试,执行 info files 查看程序入口,程序入口在 0x1068260 地址处;第四步:在程序入口设置断点进行调试 break *0x1068260 ,设置断点的时候,就已经知道程序入口在 src/runtime/rt0_darwin_amd64.s文件的第 8 行。第五步:执行 run 运行程序,程序会停到断点的地方,就能顺着文件名字找到对应的汇编文件了:macOS 系统对应的 go 程序入口文件为 src/runtime/rt0_darwin_amd64.s,linux 系统对应的 go 程序入口文件为 src/runtime/rt0_linux_amd64.s好的,通过以上步骤我们已经找到了程序真正的入口:源码:runtime/asm_amd64.s 15TEXT _rt0_amd64(SB),NOSPLIT,$-8
MOVQ 0(SP), DI // argc
LEAQ 8(SP), SI // argv
JMP runtime·rt0_go(SB)_rt0_amd64 函数逻辑简单:转存参数 argc 值和 argv 数组的地址到 DI、SI 寄存器,然后跳转到 runtime·rt0_go(SB) 函数中执行。rt0_go 就是我们的核心启动函数了,从 159 ~ 377 共 218 行汇编代码,接着就让我们看看 rt0_go 都干了些什么吧!2.rt0_go 函数初始化 go 程序先整体看一下 rt0_go 函数的主要执行逻辑(省去很多和调度无关的逻辑),下面将对这些主要逻辑一一讲解:2.1 第一步:接收命令行参数源码:runtime/asm_amd64.s 159调整栈顶寄存器的值并使其按16字节对齐,把 argc 和 argv 搬到新的位置TEXT runtime·rt0_go(SB),NOSPLIT|TOPFRAME,$0
// copy arguments forward on an even stack
MOVQ DI, AX // argc
MOVQ SI, BX // argv
SUBQ $(5*8), SP // 3args 2auto
ANDQ $~15, SP // 调整栈顶寄存器使其按16字节对齐
MOVQ AX, 24(SP) // argc 放在 SP+24 字节处
MOVQ BX, 32(SP) // argv 放在 SP+32 字节处2.2 第二步:初始化 g0 栈内存源码:runtime/asm_amd64.s 168初始化全局变量 g0 的栈内存,从主线程的栈分出一部分当作 g0 的栈。g0 的主要作用是提供一个栈供 runtime 代码执行,这里主要对 g0 的几个与栈有关的成员进行了初始化,g0 的栈大约有 64K,地址范围为 SP + (-64*1024 + 104) ~ SP。 // create istack out of the given (operating system) stack.
// _cgo_init may update stackguard.
MOVQ $runtime·g0(SB), DI // DI = g0
LEAQ (-64*1024+104)(SP), BX
MOVQ BX, g_stackguard0(DI) // g0.g_stackguard0 = BX
MOVQ BX, g_stackguard1(DI) // g0.g_stackguard1 = BX
MOVQ BX, (g_stack+stack_lo)(DI) // g0.stack.lo = BX
MOVQ SP, (g_stack+stack_hi)(DI) // g0.stack.hi = SP初始 g0 栈内存后,内存图如下:这里省去很多判断 CPU 和 不同操作系统相关的代码逻辑分析,直接进入 Go 调度的正题部分!2.3 第三步:m0 与主线程绑定这里想讲一个重要知识点:线程本地存储。线程本地存储是一种机制,允许每个线程存储其自己的私有数据副本,而不会与其他线程共享数据。在 x86 架构中,fs 寄存器是一个特殊的寄存器,通常用于访问线程局部存储。通过将 TLS 数据存储在 fs 寄存器指向的地址空间中,每个线程都可以独立地访问和修改自己的数据副本,而不会与其他线程的数据发生冲突。2.3.1 初始化线程本地存储在 Go 调度器中 fs 寄存器指向的地址空间为 m.tls[1],用于存储 TLS 数据,m.tls[0] 一般存储的是当前使用的 g(无论是 g0 或其他 g),通过 g.m 就能绑定系统线程和 m 的关系。接下来我们就来看一看系统线程是如何绑定 m.tls[1]。源码:runtime/asm_amd64.s 258 LEAQ runtime·m0+m_tls(SB), DI // DI = &m0.tls
// 调用 settls 设置线程本地存储,settls 函数的参数在 DI 寄存器中
CALL runtime·settls(SB) // 设置 tls = m.tls[1] 的地址
// store through it, to make sure it works
// 验证 TLS 功能是否正常,如果不正常则直接 abort 退出程序
// 获取 fs 段基地址并放入 BX 寄存器,
// 如果功能正常,则 BX 应该和 m0.tls[1] 指向同一个地址,
// get_tls 的代码由编译器生成
get_tls(BX)
// 因为 m 在堆上分配,g(BX) BX ~ BX-8 存储 0x123
// 把整型常量 0x123 拷贝到 fs 段基地址偏移 -8 的内存位置,也就是m0.tls[0] = 0x123
MOVQ $0x123, g(BX)
MOVQ runtime·m0+m_tls(SB), AX // AX = m0.tls[0]
// 检查 m0.tls[0] 的值是否是通过线程本地存储存入的 0x123 来验证 tls 功能是否正常
CMPQ AX, $0x123
JEQ 2(PC)
CALL runtime·abort(SB) // 如果线程本地存储不能正常工作,退出程序这段代码主要逻辑如下:首先调用 settls 函数初始化主线程的线程本地存储( fs 段寄存器基地址 = m0.tls[1]),目的是把m0 与主线程关联在一起;设置了线程本地存储之后,接下来的几条指令在于验证 TLS 功能是否正常,如果不正常则直接 abort 退出程序。拓展知识简单解释一下 get_tls(BX) 指令:用于获取当前线程的 TLS 数据,并将其地址返回给调用者。在源码中我们是看不到 get_tls 函数的实现的,编译器会处理源代码中的 get_tls(BX) 函数调用,将其转换为相应的汇编指令。编译器通常会将函数调用转换为库函数的调用,这些库函数是为特定平台优化的,以提供高效且可靠的线程局部存储访问。由于平台不同会转换为为不同的库函数,所以需要编译器动态生成。比如在 Linux 系统中,常用的库函数是 __get_tls(),这是一个由 GCC 编译器提供的内建函数。__get_tls() 函数用于获取当前线程的 TLS 数据,并将其地址返回给调用者。settls 函数属于拓展知识内容,不感兴趣可以跳过!源码:runtime/sys_linux_amd64.s 633// set tls base to DI = &m0.tls
TEXT runtime·settls(SB),NOSPLIT,$32
#ifdef GOOS_android
// Android stores the TLS offset in runtime·tls_g.
SUBQ runtime·tls_g(SB), DI
#else
// 除安卓以外系统,ELF (可执行文件格式)中的 TLS 实现的机制需要 +8
// 对堆内存来说 +8 相当于由 tls[0] -> tls[1]
ADDQ $8, DI // ELF wants to use -8(FS)
#endif
MOVQ DI, SI // SI 存放 arch_prctl 系统调用的第二个参数
// DI 存入 ARCH_SET_FS 参数,用于标志设置 TLS,arch_prctl 的第一个参数
MOVQ $0x1002, DI
MOVQ $SYS_arch_prctl, AX // AX 存入系统调用
SYSCALL
CMPQ AX, $0xfffffffffffff001 // 是否系统调用成功
JLS 2(PC)
MOVL $0xf1, 0xf1 // crash 系统调用失败
RET2.3.2 系统线程绑定 m0视角继续转换回 rt0_go 函数:源码:runtime/asm_amd64.s 268ok:
// set the per-goroutine and per-mach "registers"
get_tls(BX) // 获取存储 TLS 数据的地址
LEAQ runtime·g0(SB), CX // CX = &g0
MOVQ CX, g(BX) // m0.tls[0] = &g0; TLS 数据存储 &g0
LEAQ runtime·m0(SB), AX // AX = &m0
// save m->g0 = g0
MOVQ CX, m_g0(AX)
// save m0 to g0->m
MOVQ AX, g_m(CX)这段代码主要逻辑如下:首先将主线程的 TLS 存储为 &g0,保存在主线程本地存储中的值是 g0 的地址,也就是说工作线程的私有全局变量其实是一个指向 g 的指针,而不是指向 m 的指针,目前这个指针指向 g0,表示代码正运行在 g0 栈,当切换为普通 g 栈执行用户代码时,自然需要设置 TLS = &g。接着绑定了 m0 和 g0 之间的关系,这样系统线程就可以通过 TLS(g0).m0 找到 m0 了,对于普通线程而言,也可以使用 TLS(g).m 找到对应的 m,这样 m 就和系统线程完成了绑定。此时,主线程,m0、g0 以及 g0 的栈之间的关系如下图所示:2.4 第四步:处理命令行参数跳过 282~341 的代码,暂时不需要关注。接下来是处理命令行参数的函数 args。源码:runtime/asm_amd64.s 343 MOVL 24(SP), AX // copy argc
MOVL AX, 0(SP) // argc 放在栈顶
MOVQ 32(SP), AX // copy argv
MOVQ AX, 8(SP) // argv 放在 SP + 8 的位置
CALL runtime·args(SB) // 处理操作系统传递过来的参数args 具体作用是给 runtime 包下的 argc、argv 等全局变量赋值。源码具体解析在第3小节:处理命令行参数部分。(这部分源码与调度无关,但我没忍住阅读了一下,我承认我跑偏了,不感兴趣的可以跳过第四步!)2.5 第五步:初始化操作系统对于 Linux 来说,osinit 函数功能就是获取操作系统的参数设置,例如:获取 CPU 的核数并放在 global 变量 ncpu 中,后边初始化 P 的数量的时候会用到。源码:runtime/asm_amd64.s 348CALL runtime·osinit(SB) 在 runtime·osinit 中主要是获取CPU数量、页大小和操作系统初始化工作。源码:runtime/os_linux.go 341func osinit() {
ncpu = getproccount()
physHugePageSize = getHugePageSize()
...
osArchInit()
}2.6 第六步:调度器初始化(M0、P)接下来就是重点内容了,初始化调度器参数,包括 m0、allp 等!详细源码解析在第 4 小节:调度器初始化 schedinit 函数。源码:runtime/asm_amd64.s 349CALL runtime·schedinit(SB)2.7 第七步:创建第一个 goroutine(G)该块汇编代码调用了 newproc 函数,创建了一个新的 goroutine,也是 go 程序第一个 goroutine。第一个 goroutine 也被叫做 main goroutine,用于运行我们的 main 函数。newproc 详细讲解参照第5小节:创建第一个 goroutine。源码:src/runtime/asm_amd64.s 351 // create a new goroutine to start program
MOVQ $runtime·mainPC(SB), AX // entry mainPC 变量 = runtime·main 函数
PUSHQ AX // newproc 的参数入栈
CALL runtime·newproc(SB) // 调用 newproc 创建 goroutine
POPQ AX // 参数出栈这里解释一下 mainPC 变量的定义:mainPC 变量 = runtime·main 函数源码:src/runtime/asm_amd64.s 379// mainPC is a function value for runtime.main, to be passed to newproc.
// The reference to runtime.main is made via ABIInternal, since the
// actual function (not the ABI0 wrapper) is needed by newproc.
// mainPC 变量 = runtime·main 函数
DATA runtime·mainPC+0(SB)/8,$runtime·main<ABIInternal>(SB)
GLOBL runtime·mainPC(SB),RODATA,$8 // 声明全局变量2.8 第八步:启动 M0 开始 GMP 调度rt0_go 函数最后一步,启动 m0,开启 GMP 的调度循环,mstart 函数详解参考第六小节:启动 M0 开始 GMP 调度。GMP 模型启动后,会进入调度循环,mstart 函数一般是不会返回的。源码:src/runtime/asm_amd64.s 357 // start this M
// 主线程进入调度循环,运行刚刚创建的 goroutine
CALL runtime·mstart(SB)
// 上面的 mstart 函数永远不应该返回的,如果返回了,一定是代码逻辑有问题,直接 abort
CALL runtime·abort(SB) // mstart should never return
RET3.处理命令行参数关键代码:CALL runtime·args(SB)对应下面这个函数,该函数主要对 argc、argv 等全局变量赋值。源码:src/runtime/runtime1.go 66func args(c int32, v **byte) {
argc = c
argv = v
sysargs(c, v)
}那 argc、argv 变量如何使用呢?随后在接下来我们要讲到的 schedinit 函数中,调用了 goargs 函数,初始化了 argslice 变量。func goargs() {
if GOOS == "windows" {
return
}
argslice = make([]string, argc)
for i := int32(0); i < argc; i++ {
argslice[i] = gostringnocopy(argv_index(argv, i))
}
}那包级别私有的全局变量 argslice 又该如何使用呢?我们顺着 argslice 找到了 runtime 包下的 os_runtime_args 函数,这里把 argslice 返回了,使用了 go:linkname 连接到了 os.runtime_args 函数的实现上,也就是说 os.runtime_args 函数的实现等于 runtime.os_runtime_args,这样就绕开了私有函数和私有变量的限制。接着看 os.Args 初始化方式在 init 函数中,被赋值到了 Args 全局变量,后边就直接能用了。// 供业务使用 runtime/runtime.go 60
//go:linkname os_runtime_args os.runtime_args
func os_runtime_args() []string { return append([]string{}, argslice...) }
// 源码地址:os/proc.go 16
// Args hold the command-line arguments, starting with the program name.
var Args []string
func init() {
if runtime.GOOS == "windows" {
// Initialized in exec_windows.go.
return
}
Args = runtime_args()
}
func runtime_args() []string // in package runtime接下来我们讲述一下业务该如何使用吧!业务通过: go run main.go arg1 arg2 运行如下代码,就能获取到命令行参数,这样业务就能使用了!package main
import (
"fmt"
"os"
)
func main() {
args := os.Args
// 遍历命令行参数并打印它们的值
for _, arg := range args {
fmt.Println("参数:", arg)
}
}4.调度器初始化 schedinit 函数对照 2.6 小节内容,这里分析 schedinit 函数的主要逻辑:源码:src/runtime/proc.go 669注释中解释:golang 的 bootstrap(启动)流程步骤分别是 call osinit、call schedinit、make & queue new G 和 call runtime·mstart 四个步骤。当前我们正处于 call schedinit 步骤。// The bootstrap sequence is:
//
// call osinit
// call schedinit
// make & queue new G
// call runtime·mstart
//
// The new G calls runtime·main.
func schedinit() {
// 一些列 lock(锁) 初始化
...
// getg 函数在源代码中没有对应的定义,由编译器插入代码
// get_tls(CX)
// MOVQ g(CX), BX
gp := getg() // 获取当前 tls 中的 g,目前是 g0
...
sched.maxmcount = 10000 // 设置最多启动 10000 个操作系统线程,也是最多 10000 个M
...
// 栈、内存分配器相关初始化
stackinit() // 初始化栈
mallocinit() // 初始化内存分配器
...
// 初始化当前系统线程 M0
mcommoninit(gp.m, -1)
...
goargs() // 这个第四步讲过,存储命令行参数到全局变量
goenvs() // 初始化 go 环境变量
...
gcinit() // 初始化 GC
...
lock(&sched.lock)
sched.lastpoll.Store(nanotime()) // 初始化上次网络轮询的时间
procs := ncpu //系统中有多少核,就创建和初始化多少个 P 结构体对象
if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
procs = n // 设置 P 的个数为 GOMAXPROCS
}
// procresize 创建和初始化全局变量 allp
if procresize(procs) != nil {
throw("unknown runnable goroutine during bootstrap")
}
unlock(&sched.lock)
...
}schedinit 函数主要逻辑:初始化各种锁会设置 M 最大数量为 10000,一般实际中不会达到堆栈内存分配器相关初始化(Go 内存管理相关)调用 mcommoninit 函数初始化当前系统线程 M0(重点内容)设置命令行参数、go 环境参数初始化 GC将 P 个数设置为 GOMAXPROCS 的值,即程序能够同时运行的最大处理器数调用 procresize 函数创建和初始化全局变量 allp(重点内容)从上述源码中我们可以看到,P 的数量取决于当前 cpu 的数量,或者是 GOMAXPROCS 的配置。不少 golang 的同学都有一种错误的认知,认为 GOMAXPROCS 限制的是 golang 中的线程数,这个认知是错误的。GOMAXPROCS 真正制约的是 GMP 中 P 的数量,而不是 M。其实 P 的数量,控制了并行的能力,一个 M 绑定一个 P, 如果 M 数量大于 P,多出来的 M 就只有阻塞排队。所以最好就是有几核就设置几个 P。schedinit 函数中最重要的两个逻辑就是 mcommoninit(gp.m, -1) 函数和 procresize(procs) 函数,接下来我们详细分析一下源码。4.1 初始化 M0先来看一下 mcommoninit(gp.m, -1) 函数源码,主要逻辑是初始化 m0 的一些属性,并将 m0 放入全局链表 allm 之中,过程比较简单。源码:src/runtime/proc.go 810// Pre-allocated ID may be passed as 'id', or omitted by passing -1.
func mcommoninit(mp *m, id int64) {
...
lock(&sched.lock)
// 初始化 m 的 id 属性
if id >= 0 {
mp.id = id
} else {
// 检查已创建系统线程是否超过了数量限制(10000)
// id 在 sched.mnext 存着
mp.id = mReserveID()
}
...
mpreinit(mp) // 创建用于信号处理的 gsignal,从堆上分配一个 g 结构体对象,并设置栈内存
if mp.gsignal != nil {
mp.gsignal.stackguard1 = mp.gsignal.stack.lo + _StackGuard
}
// Add to allm so garbage collector doesn't free g->m
// when it is just in a register or thread-local storage.
mp.alllink = allm // 把 m 挂入全局链表 allm 之中
...
unlock(&sched.lock)
...
}4.2 创建和初始化全局变量 allp再来简单看下 procresize,这个函数会创建和初始化 p 结构体对象:创建指定个数的 p 结构体对象,放在全变量 allp 里, 并把 m0 和 allp[0] 绑定在一起,这里记一下,m0 已经绑定 p 了,后续就不用绑定了。这个源码看起来很复杂,是因为初始化完成之后用户代码还可以通过 GOMAXPROCS() 函数调用它,重新创建和初始化 p 结构体对象,运行过程中需要处理的情况比单纯初始化复杂的多,这里我们简单理解一下初始化代码就可以了。源码:src/runtime/proc.go 4956func procresize(nprocs int32) *p {
...
old := gomaxprocs // 系统初始化时 old = gomaxprocs = 0
...
// Grow allp if necessary.
// 初始化时 len(allp) == 0
if nprocs > int32(len(allp)) {
// Synchronize with retake, which could be running
// concurrently since it doesn't run on a P.
lock(&allpLock)
if nprocs <= int32(cap(allp)) {
// 用户代码对 P 数量进行缩减
allp = allp[:nprocs]
} else {
// 这里是初始化
nallp := make([]*p, nprocs)
// 将所有内容复制到 allp 的上限,这样我们就不会丢失旧分配的 P。
copy(nallp, allp[:cap(allp)])
allp = nallp
}
...
unlock(&allpLock)
}
// initialize new P's
// 循环创建新 P,直到 nprocs 个
for i := old; i < nprocs; i++ {
pp := allp[i]
if pp == nil {
pp = new(p)
}
pp.init(i) // 初始化 p 属性,设置 pp.status = _Pgcstop
atomicstorep(unsafe.Pointer(&allp[i]), unsafe.Pointer(pp))
}
gp := getg() // g0
if gp.m.p != 0 && gp.m.p.ptr().id < nprocs {
// continue to use the current P
gp.m.p.ptr().status = _Prunning
gp.m.p.ptr().mcache.prepareForSweep()
} else {
// 初始化会走这个分支
...
gp.m.p = 0
pp := allp[0]
pp.m = 0
pp.status = _Pidle // 把 allp[0] 设置为 _Pidle
acquirep(pp) // 把 allp[0] 和 m0 关联起来,设置为 _Prunning
...
}
...
var runnablePs *p
// 下面这个for 循环把所有空闲的 p 放入空闲链表
for i := nprocs - 1; i >= 0; i-- {
pp := allp[i]
if gp.m.p.ptr() == pp { // allp[0] 保持 _Prunning
continue
}
pp.status = _Pidle // 初始化其他 p 都为 _Pidle
if runqempty(pp) {
pidleput(pp, now) // 放入 sched.pidle P 空闲链表,都是链表操作
} else {
...
}
}
...
return runnablePs
}总结一下这个函数初始化的主要流程:使用 make([]*p, nprocs) 初始化全局变量 allp,即 allp = make([]*p, nprocs);循环创建、初始化 nprocs 个 p 结构体对象,此时 p.status = _Pgcstop,依次保存在 allp 切片之中;先把 allp[0] 状态设置为 _Pidle,然后把 m0 和 allp[0] 关联在一起,即 m0.p = allp[0] , allp[0].m = m0,此时设置 allp[0] 的状态 _Prunning;循环 allp[0] 之外的所有 p 对象,设置 _Pidle 状态,并放入到全局变量 sched 的 pidle 空闲队列之中,链表使用 p.link 进行连接。(后续使用可以参考《 14. Go调度器系列解读(一):什么是 GMP?》 3.2 小节:P 的唤醒)至此,调度器初始化完成,这时整个调度器相关的各组成部分之间的联系如下图所示:5.创建第一个 goroutine对照 2.7 小节内容,这里分析一下 newproc 函数源码,这部分源码其实已经在文章《 14. Go调度器系列解读(一):什么是 GMP?》 3.1 小节(G 的创建)中详细分析过一次,这里简单回顾一下:源码:src/runtime/proc.go 4259// Create a new g running fn.
// Put it on the queue of g's waiting to run.
// The compiler turns a go statement into a call to this.
func newproc(fn *funcval) {
gp := getg()
pc := getcallerpc() // 获取 newproc 函数调用者指令的地址
systemstack(func() {
newg := newproc1(fn, gp, pc) // 创建 G
pp := getg().m.p.ptr() // 获取当前绑定的 p
runqput(pp, newg, true) // 将 G 放入运行队列
if mainStarted { // 如果 main 函数已经启动
wakep()
}
})
}newproc 函数用于创建新的 goroutine,它有一个参数 fn 表示 g 创建出来要执行的函数,这里 fn = runtime·main,接下来我们分析一下 newproc 函数的内容。第一步:切换运行栈使用 systemstack 函数切换到系统栈(一般是 g0 栈)中执行,执行完毕后切回普通 g 的栈,由于我们本身就在 g0 栈中,不需要进行切换,直接执行代码即可。第二步:创建 goroutinenewproc1 用于创建一个新可运行的 goroutine,主要是内存分配和 g 参数的初始化,这里和普通的 goroutine 初始化基本一致。使用 malg 函数创建一个 newg,此时 g 为 __Gidle,malg 为 newg 申请完内存后,更改其状态为 _Gdead。goroutine 的栈(g.stack)是在堆上分配的,而不是在栈内存中。由于这个栈是在堆上动态分配的,意味着它的大小可以在运行时改变。与栈内存不同,堆内存的分配和释放更加灵活,可以在运行时动态调整。这种设计使得 goroutine 的栈可以随着程序的需要而增长和缩小,从而实现更好的性能和资源利用率。使用 gostartcallfn 函数对 newg 的 sched 成员进行初始化:newg.sched.sp 指向 newg 的栈顶,栈内存入了 return address = goexit+1;newg.sched.pc 指向 runtime·main 函数的第一条指令。更改 newg 状态为 _Grunnable,生成 goid,返回 newg。第三步:放入可运行队列获取当前 m0 的 p,本场景下这里的 p = allp[0],且 p 的本地运行队列为空,优先将 newg 放入该 p 的 p.runnext,后边都不用执行了。第四步:唤醒一个 P本场景下,main 函数还没有启动,因此也不用执行!经过这四个步骤后,一个新的 G 被创建出来,状态为 _Grunnable,并且被放入了 allp[0] 的 runnext 字段中,后边就等着被调度执行了,到此程序中第一个真正意义上的 goroutine 已经创建完成,该 goroutine 的执行函数为 runtime·main,此时 goroutine 还没有和任何 m 进行绑定。对应的内存关系图如下:6.启动 M0 开始 GMP 调度对照 2.8 小节内容,这里分析一下 runtime·mstart 函数源码,这部分源码也已经在文章《 14. Go调度器系列解读(一):什么是 GMP?》 4 章节(一个线程的基本调度流程)中详细分析过一次,这里就不再赘述了。源码:src/runtime/proc.go 1419这里用一张图简单回顾一下 M0 线程的调度流程:CALL runtime·mstart(SB) 具体执行流程总结:mstart 直接调用了 mstart0 函数,mstart0 函数在初始化 g0 的 stackguard0、stackguard1 属性后,继续调用了 mstart1 函数。mstart1 函数中设置 g0.sched.sp 和 g0.sched.pc 等调度信息,其中 g0.sched.sp 指向 mstart1 函数栈帧的栈顶(如上图所述),g0.sched.pc 指向 mstart1 函数执行完的返回地址。由于 m0 已经绑定了 p,所以可以直接调用 schedule 函数,开始调度流程。在 schedule 函数中根据调度策略(findRunnable 函数,下一篇文章会分享源码解读)选择一个可运行的 g;随后调用 execute 函数,执行调度。目前唯一能被调度的就是刚刚创建的 main G,因此该 goroutine 被调度执行,状态变为 _Grunning。由于 goroutine 执行用户程序需要在自己的栈内,因此使用 gogo 函数将当前 g0 栈切换为 g 栈,gogo 主要逻辑如下:在 2.7 小节创建 g 的时候,将 g.sched.pc 指向 runtime·main 函数的第一条指令,gogo 函数中使用 JMP 指令跳转到 g.sched.pc 开始执行,此时 g 真正开始执行 runtime.main 函数。在 runtime.main 函数源码中会调用 main_main 函数,在编译期间会动态的链接到我们写的 main 函数,因此我们业务的 main 函数开始执行。与普通 goroutine 不同的是:main g 执行完用户程序中的 main 函数,还会继续返回到 runtime.main 函数继续往下执行,接着就执行 exit(0) 退出进程了,并没有像普通 g 一样返回到 return address = goexit+1 处,也就不会继续执行调度循环。进程退出,程序自然也就结束了。接下来,我们聊一下 runtime.main 函数都干了些什么!7.runtime·main 函数此时,CPU 开始运行 runtime.main 函数,创建 g 的时候,塞入的任务函数,当 g 被调度运行时,CPU 执行的下一条指令,就是 g 的任务函数。当前 main g 的任务函数为 runtime.main 函数。源码:src/runtime/proc.go 145// The main goroutine.
func main() {
mp := getg().m // m0
...
if goarch.PtrSize == 8 {
// 64 位系统上每个 goroutine 的栈最大可达 1G
maxstacksize = 1000000000
} else {
// 250 MB on 32-bit.
maxstacksize = 250000000
}
...
// Allow newproc to start new Ms.
// newproc 新建 G 的时候,决定要不要唤醒一个 P
mainStarted = true
if GOARCH != "wasm" { // no threads on wasm yet, so no sysmon
// 在系统栈上运行 sysmon
systemstack(func() {
// 创建监控线程,该线程独立于调度器,不需要跟 p 关联即可运行
// mstart 时会通过调用 g.m.mstartfn 执行 sysmon
// 然后监控线程就运行起来了,而且不会停止
newm(sysmon, nil, -1)
})
}
...
// runtime 内部 init 函数的执行,由编译器实现
doInit(&runtime_inittask) // Must be before defer.
...
gcenable() // 开启垃圾回收器
...
// main 包的初始化函数 init,也是由编译器实现,会递归的调用我们import进来的包的初始化函数
doInit(&main_inittask)
// 调用 main.main 函数,也就是业务的 main 函数
fn := main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
fn()
...
// 进入系统调用,退出进程,可以看出 main goroutine 并未返回
// 而是直接进入系统调用退出进程了
exit(0)
// 保护性代码
for {
var x *int32
*x = 0
}
}通过对主要代码的梳理,可以把 runtime.main 函数的主要工作流程总结如下:根据系统定义每个 goroutine 的栈空间最大值;设置 main 函数已经启动的标志,mainStarted = true;启动一个 sysmon 系统监控线程,该线程负责整个程序的 gc、抢占调度以及 netpoll 等功能的监控;执行 runtime 包的初始化函数;开启垃圾回收器的 goroutine;执行 main 包以及 main 包 import 的所有包的初始化(通过递归,初始化所以相关包);执行 main_main 函数,也就是业务程序的 main 函数;从 main_main 函数返回后调用 exit 系统调用退出进程,至此程序结束。总结洋洋洒洒又写了一篇关于 GMP 的知识点,越写越感觉根本写不完,无奈只能拆成不同的角度进行讲述,本篇文章侧重点是 Go 程序的启动过程,总结一下重点知识:利用 gdb 调试工具寻找 Go 程序入口,我们找到了 runtime·rt0_go(SB) 汇编函数。分析了 runtime·rt0_go(SB) 函数的主要逻辑流程,rt0_go 函数主要是进行系统的初始化工作和启动调度线程:随后聊到了 M0 的 mstart 函数启动过程,参照第 6 小节流程图,通过一系列的函数调用 mstart -> mstart0 -> mstart1 -> schedule -> execute -> gogo 将执行权交给了新的 goroutine,随后启动任务函数 runtime.main。runtime.main 函数是 main 函数真正的入口,主要逻辑如下: 1.在该函数中启动了一个 sysmon 系统监控线程,该线程负责整个程序的 gc、抢占调度以及 netpoll 等功能的监控; 2.执行 runtime init; 3.启动 GC 的 goroutine 负责垃圾内存回收; 4.执行 main 函数的 init,执行 main 包以及 main 包 import 的所有包的初始化; 5.执行 main 函数,执行完毕后,调用系统调用 exit 进行退出。
cathoy
Go语言深入剖析与实践
本专栏将深入剖析Go语言核心概念与常用特性,并结合实践案例进行详细讲解。我们将从交叉编译、数组、切片和映射等基础知识入手,为读者提供全面而深入的理解。通过面试题目的解析,读者将掌握面试中常见的问题和解决方法。此外,我们还将对Go语言的闭包、unsafe.Pointer、context包等关键内容进行深入剖析,让读者了解底层实现和使用注意事项。同时,我们将解读Go Modules的高效管理方法,以及图解源码揭秘Golang Channel高性能背后的故事。通过阅读本专栏,读者将全面掌握Go语言的核心概念和常用特性,能够更好地应用于实际项目中,提升开发效率和代码质量。
cathoy
Spring Boot 异常报告器解析
创建自定义异常报告器FailureAnalysis 是Spring Boot 启动时将异常转化为可读消息的一种方法,系统自定义了很多异常报告器,通过接口也可以自定义异常报告器。创建一个异常类:public class MyException extends RuntimeException{
}创建一个FailureAnalyzer:public class MyFailureAnalyzer extends AbstractFailureAnalyzer<MyException> {
@Override
protected FailureAnalysis analyze(Throwable rootFailure, MyException cause) {
String des = "发生自定义异常";
String action = "由于自定义了一个异常";
return new FailureAnalysis(des, action, rootFailure);
}
}需要在Spring Boot 启动的时候抛出异常,为了测试,我们在上下文准备的时候抛出自定义异常,添加到demo中的MyApplicationRunListener中。public void contextPrepared(ConfigurableApplicationContext context) {
System.out.println("在创建和准备ApplicationContext之后,但在加载源之前调用");
throw new MyException();
}启动后就会打印出我们的自定义异常报告器内容:***************************
APPLICATION FAILED TO START
***************************
Description:
发生自定义异常
Action:
由于自定义了一个异常原理分析在之前的文章《Spring Boot 框架整体启动流程详解》,有讲到过Spring Boot 对异常的处理,如下是Spring Boot 启动时的代码:public ConfigurableApplicationContext run(String... args) {
long startTime = System.nanoTime();
DefaultBootstrapContext bootstrapContext = createBootstrapContext();
ConfigurableApplicationContext context = null;
configureHeadlessProperty();
SpringApplicationRunListeners listeners = getRunListeners(args);
listeners.starting(bootstrapContext, this.mainApplicationClass);
try {
ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);
ConfigurableEnvironment environment = prepareEnvironment(listeners, bootstrapContext, applicationArguments);
Banner printedBanner = printBanner(environment);
context = createApplicationContext();
context.setApplicationStartup(this.applicationStartup);
prepareContext(bootstrapContext, context, environment, listeners, applicationArguments, printedBanner);
refreshContext(context);
afterRefresh(context, applicationArguments);
Duration timeTakenToStartup = Duration.ofNanos(System.nanoTime() - startTime);
if (this.logStartupInfo) {
new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), timeTakenToStartup);
}
listeners.started(context, timeTakenToStartup);
callRunners(context, applicationArguments);
}
catch (Throwable ex) {
if (ex instanceof AbandonedRunException) {
throw ex;
}
handleRunFailure(context, ex, listeners);
throw new IllegalStateException(ex);
}
try {
if (context.isRunning()) {
Duration timeTakenToReady = Duration.ofNanos(System.nanoTime() - startTime);
listeners.ready(context, timeTakenToReady);
}
}
catch (Throwable ex) {
if (ex instanceof AbandonedRunException) {
throw ex;
}
handleRunFailure(context, ex, null);
throw new IllegalStateException(ex);
}
return context;
}通过两个try…catch…包裹,在catch 中判断异常是否是AbandonedRunException类型,是直接抛出异常,否则的话进入handleRunFailure中。AbandonedRunException 异常 在 Spring Boot 处理AOT相关优化的时候会抛出private void handleRunFailure(ConfigurableApplicationContext context, Throwable exception,
SpringApplicationRunListeners listeners) {
try {
try {
//处理exitCode
handleExitCode(context, exception);
if (listeners != null) {
//发送启动失败事件
listeners.failed(context, exception);
}
}
finally {
//获取报告处理器,并处理错误
reportFailure(getExceptionReporters(context), exception);
if (context != null) {
//关闭上下文
context.close();
//移除关闭钩子
shutdownHook.deregisterFailedApplicationContext(context);
}
}
}
catch (Exception ex) {
logger.warn("Unable to close ApplicationContext", ex);
}
//重新抛出异常
ReflectionUtils.rethrowRuntimeException(exception);
}exitCode是一个整数值,默认返回0,Spring Boot会将该exitCode传递给System.exit()以作为状态码返回,如下是IDEA中停止Spring Boot 返回的退出码:进程已结束,退出代码130handleExitCode进入handleExitCode,看下是如何处理的:private void handleExitCode(ConfigurableApplicationContext context, Throwable exception) {
int exitCode = getExitCodeFromException(context, exception);
//exitCode非0
if (exitCode != 0) {
if (context != null) {
//发送ExitCodeEvent事件
context.publishEvent(new ExitCodeEvent(context, exitCode));
}
//获取当前线程的SpringBootExceptionHandler,SpringBootExceptionHandler用来处理未捕获的异常,实现了UncaughtExceptionHandler接口
handler = getSpringBootExceptionHandler();
if (handler != null) {
//添加exitCode到SpringBootExceptionHandler 中
handler.registerExitCode(exitCode);
}
}
}
private int getExitCodeFromException(ConfigurableApplicationContext context, Throwable exception) {
//从ExitCodeExceptionMapper实现中获取exitCode
int exitCode = getExitCodeFromMappedException(context, exception);
if (exitCode == 0) {
//尝试从ExitCodeGenerator实现获取exitCode
exitCode = getExitCodeFromExitCodeGeneratorException(exception);
}
return exitCode;
}
private int getExitCodeFromMappedException(ConfigurableApplicationContext context, Throwable exception) {
//判断上下文是否是活动状态,上下文至少刷新过一次,不是就返回0
if (context == null || !context.isActive()) {
return 0;
}
//用于维护ExitCodeGenerator有序集合的组合器,ExitCodeGenerator 是一个接口,用于获取exitCode
ExitCodeGenerators generators = new ExitCodeGenerators();
//获取ExitCodeExceptionMapper类型的Bean
Collection<ExitCodeExceptionMapper> beans = context.getBeansOfType(ExitCodeExceptionMapper.class).values();
//将异常和bean包装成MappedExitCodeGenerator,排序后保存,MappedExitCodeGenerator是ExitCodeGenerator 的一个实现
generators.addAll(exception, beans);
//会循环ExitCodeGenerators 中的ExitCodeGenerator,ExitCodeGenerator会去获取ExitCodeExceptionMapper的实现,如果有一个exitCode非0则马上返回,否则返回0
return generators.getExitCode();
}
private int getExitCodeFromExitCodeGeneratorException(Throwable exception) {
//没有异常
if (exception == null) {
return 0;
}
//异常类有实现了ExitCodeGenerator 接口
if (exception instanceof ExitCodeGenerator generator) {
return generator.getExitCode();
}
//继续寻找
return getExitCodeFromExitCodeGeneratorException(exception.getCause());
}
SpringBootExceptionHandler getSpringBootExceptionHandler() {
//当前线程是主线程
if (isMainThread(Thread.currentThread())) {
//获取当前线程的SpringBootExceptionHandler
return SpringBootExceptionHandler.forCurrentThread();
}
return null;
}listeners.failed在处理完exitCode后,继续执行listeners.failed(context, exception),这里就跟以前一样,循环SpringApplicationRunListener实现reportFailureSpring Boot 首先从spring.factories获取所有的SpringBootExceptionReporter实现,FailureAnalyzers是其唯一实现,其用于加载和执行FailureAnalyzerreportFailure 循环执行获取的SpringBootExceptionReporter,如果发送异常成功,则会向之前的SpringBootExceptionHandler中记录,表示该异常已经捕获处理private void reportFailure(Collection<SpringBootExceptionReporter> exceptionReporters, Throwable failure) {
try {
for (SpringBootExceptionReporter reporter : exceptionReporters) {
//如果异常发送成功
if (reporter.reportException(failure)) {
//记录异常
registerLoggedException(failure);
return;
}
}
}
catch (Throwable ex) {
// 如果上述操作发生异常,还是会继续执行
}
//记录error级别日志
if (logger.isErrorEnabled()) {
logger.error("Application run failed", failure);
registerLoggedException(failure);
}
}reporter.reportException在reportFailure中,通过reporter.reportException(failure)判断异常是否发送成功,进入代码,由于该Demo 只有一个FailureAnalyzers实现,所以进入到FailureAnalyzers的reportException中:public boolean reportException(Throwable failure) {
//循环调用加载的FailureAnalyzer实现的analyze方法
FailureAnalysis analysis = analyze(failure, this.analyzers);
//加载FailureAnalysisReporter实现,组装具体错误信息,并打印日志
return report(analysis);
}this.analyzers在FailureAnalyzers创建的时候已经将FailureAnalyzer实现从spring.factories中加载下面的代码将循环调用加载的FailureAnalyzer实现的analyze方法,返回一个包装了异常描述、发生异常的动作、原始异常 信息的对象private FailureAnalysis analyze(Throwable failure, List<FailureAnalyzer> analyzers) {
for (FailureAnalyzer analyzer : analyzers) {
try {
FailureAnalysis analysis = analyzer.analyze(failure);
if (analysis != null) {
return analysis;
}
}
catch (Throwable ex) {
logger.trace(LogMessage.format("FailureAnalyzer %s failed", analyzer), ex);
}
}
return null;
}此处Spring Boot 建议自定义的FailureAnalyzer 通过继承AbstractFailureAnalyzer来实现,Spring Boot 自带的FailureAnalyzer确实也是这样的,但是你也可以直接实现FailureAnalyzer 接口。AbstractFailureAnalyzer中会筛选出需要关注的异常,而直接实现FailureAnalyzer 接口,需要自行在方法中处理。随后将返回的FailureAnalysis实现通过FailureAnalysisReporter组装打印到客户端private boolean report(FailureAnalysis analysis) {
//FailureAnalysisReporter也是从spring.factories中加载,可见也可以自定义
List<FailureAnalysisReporter> reporters = this.springFactoriesLoader.load(FailureAnalysisReporter.class);
if (analysis == null || reporters.isEmpty()) {
return false;
}
for (FailureAnalysisReporter reporter : reporters) {
reporter.report(analysis);
}
return true;
}在该Demo中,只有一个FailureAnalysisReporter实例LoggingFailureAnalysisReporterpublic void report(FailureAnalysis failureAnalysis) {
//如果是debug级别,则会打印堆栈信息
if (logger.isDebugEnabled()) {
logger.debug("Application failed to start due to an exception", failureAnalysis.getCause());
}
//如果是error级别,还会打印组装好的错误信息
if (logger.isErrorEnabled()) {
logger.error(buildMessage(failureAnalysis));
}
}
private String buildMessage(FailureAnalysis failureAnalysis) {
StringBuilder builder = new StringBuilder();
builder.append(String.format("%n%n"));
builder.append(String.format("***************************%n"));
builder.append(String.format("APPLICATION FAILED TO START%n"));
builder.append(String.format("***************************%n%n"));
builder.append(String.format("Description:%n%n"));
builder.append(String.format("%s%n", failureAnalysis.getDescription()));
if (StringUtils.hasText(failureAnalysis.getAction())) {
builder.append(String.format("%nAction:%n%n"));
builder.append(String.format("%s%n", failureAnalysis.getAction()));
}
return builder.toString();
}关闭上下文、移除钩子context.close() 如果上下文不为空,则关闭上下文,并且移除关闭钩子。shutdownHook.deregisterFailedApplicationContext(context) 用来将之前在SpringApplicationShutdownHook 钩子中注册的上下文移除。SpringApplicationShutdownHook 是Spring Boot 定义的关闭钩子,用来优雅关机。总结
cathoy
Spring Boot 3.x微服务升级经历
前言Spring Boot 3.0.0 GA版已经发布,好多人也开始尝试升级,有人测试升级后,启动速度确实快了不少,如下为网络截图,于是我也按捺不住的想尝试下。历程首先就是要把Spring Boot、Spring Cloud 相关的依赖升一下Spring Boot:3.0.0Spring Cloud:2022.0.0-RC2统一依赖版本管理:<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2022.0.0-RC2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>现在还不能下载Spring 相关依赖包,需要加入Spring 仓库。在你的maven仓库中加入如下配置,我是加在了pom.xml中<repository>
<id>netflix-candidates</id>
<name>Netflix Candidates</name>
<url>https://artifactory-oss.prod.netflix.net/artifactory/maven-oss-candidates</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>另外Spring Boot 3.X 开始使用了Java 17,将java版本调整到>17,为了不必要的麻烦,就选17IDEA选择17,并在pom.xml文件中指定版本:<java.version>17</java.version>到这里我们的common 包是能正常编译了。接下来是服务的配置同样调整Spring Boot、Spring Cloud、Java的版本,同common的配置。碰到如下的几个问题:找不到hystrix的依赖问题:升级后找不到hystrix的版本,官网也找不到,这里我显式指定了版本<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
<version>2.2.9.RELEASE</version>
</dependency>rabbitmq问题:相关的配置丢失,比如如下图,这边进行适当调整或者直接注释解决。TypeVariableImpl丢失问题:原来服务中引入了sun.reflect.generics.reflectiveObjects.TypeVariableImpl,现在17中已经被隐藏无法直接使用,这边为了能够先启动,暂时注释,后面再想办法。Log 异常问题:由于之前我们项目中历史原因,既有用log4j,也有用logback,升级后已经不行,提示冲突,报错如下Exception in thread "main" java.lang.IllegalArgumentException: LoggerFactory is not a Logback LoggerContext but Logback is on the classpath. Either remove Logback or the competing implementation (class org.slf4j.helpers.NOPLoggerFactory loaded from file:/Users/chenjujun/.m2/repository/org/slf4j/slf4j-api/1.7.0/slf4j-api-1.7.0.jar). If you are using WebLogic you will need to add 'org.slf4j' to prefer-application-packages in WEB-INF/weblogic.xml: org.slf4j.helpers.NOPLoggerFactory
at org.springframework.util.Assert.instanceCheckFailed(Assert.java:713)
at org.springframework.util.Assert.isInstanceOf(Assert.java:632)意思是,要么移除Logback,要么解决slf4j-api的冲突依赖,这里两种方式都尝试了,slf4j-api依赖的地方太多,后面移除了Logback。要排除依赖一个好办法:使用Maven Helper插件logback依赖:<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.8</version>
</dependency>Apollo问题:使用Apollo会提示该错误,需要在启动中加入--add-opens java.base/java.lang=ALL-UNNAMEDCaused by: com.ctrip.framework.apollo.exceptions.ApolloConfigException: Unable to load instance for com.ctrip.framework.apollo.spring.config.ConfigPropertySourceFactory!
at com.ctrip.framework.apollo.spring.util.SpringInjector.getInstance(SpringInjector.java:40)
at com.ctrip.framework.apollo.spring.boot.ApolloApplicationContextInitializer.<init>(ApolloApplicationContextInitializer.java:66)
... 16 more
Caused by: com.ctrip.framework.apollo.exceptions.ApolloConfigException: Unable to initialize Apollo Spring Injector!
at com.ctrip.framework.apollo.spring.util.SpringInjector.getInjector(SpringInjector.java:24)
at com.ctrip.framework.apollo.spring.util.SpringInjector.getInstance(SpringInjector.java:37)
... 17 more
Caused by: java.lang.reflect.InaccessibleObjectException: Unable to make protected final java.lang.Class java.lang.ClassLoader.defineClass(java.lang.String,byte[],int,int,java.security.ProtectionDomain) throws java.lang.ClassFormatError accessible: module java.base does not "opens java.lang" to unnamed module @16612a51
at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:354)
at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:297)
at java.base/java.lang.reflect.Method.checkCanSetAccessible(Method.java:199)
at java.base/java.lang.reflect.Method.setAccessible(Method.java:193)
at com.google.inject.internal.cglib.core.$ReflectUtils$1.run(ReflectUtils.java:52)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:318)
at com.google.inject.internal.cglib.core.$ReflectUtils.<clinit>(ReflectUtils.java:42)通过上述配置调整后,能编译成功,但是无法启动,控制没有任何日志,初步怀疑还是log依赖问题,由于时间关系,没有再继续,问题留到以后再弄,后面有新进展,会持续更新该文。javax 的依赖都变成jakarta:比如原来基于javax.validation包中的验证,javax.validation.constraints.NotNull此类的都需要调整Spring Boot 3.0后,很多starter不能用:Spring Boot 3.0后,以前的spring.factories 不能用了,只能使用META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports ,对于一些还没改造的starter都无法使用,目前mybatisplus 已经有支持3.0 的 SNAPSHOT版本,其他的druid、nacos 等还适配3.0,要等等了。但我怎么会坐以待毙,我尝试自己改造中间件的starter,可还是报错Failed to instantiate [org.springframework.boot.env.EnvironmentPostProcessor]: Specified class is an interface
cathoy
Spring Boot 属性配置解析
属性配置介绍Spring Boot 3.1.0 支持的属性配置方式与2.x版本没有什么变动,按照以下的顺序处理,后面的配置将覆盖前面的配置:1、SpringApplication.setDefaultProperties 指定的默认属性2、@PropertySource注解配置3、Jar包内部的application.properties 和 YAML 变量4、Jar包内部的application-{profile}.properties 和 YAML 变量5、Jar包外部的application.properties 和 YAML 变量6、Jar包外部的application-{profile}.properties 和 YAML 变量7、RandomValuePropertySource的随机值属性8、操作系统环境变量9、Java System属性 (System.getProperties())10、JNDI属性11、ServletContext 初始化参数12、ServletConfig 初始化参数13、嵌入在环境变量或系统属性中的SPRING_APPLICATION_JSON 的属性14、命令行参数15、测试环境properties 属性16、测试环境的@TestPropertySource 注解17、Devtools 全局配置属性配置实验使用前面的MyApplicationRunListener来读取Spring Boot 启动完成后的自定义配置,如下: public void started(ConfigurableApplicationContext context, Duration timeTaken) {
System.out.println("上下文已刷新,应用程序已启动,但尚未调用CommandLineRunners和ApplicationRunners");
System.out.println(context.getEnvironment().getProperty("me"));
}默认属性Properties properties = new Properties();
properties.setProperty("me", "123456");
springApplication.setDefaultProperties(properties);
springApplication.run(args);@PropertySource注解配置创建一个app.yml文件,放置于resource目录下:me: 333333在SpringBootDemoApplication中标注,@PropertySource("classpath:app.yml")运行后,此配置覆盖了“SpringApplication.setDefaultProperties 指定的默认属性”。基于 @PropertySource注解的配置,需要刷新上下文后才能读取,因此需要在刷新之前就加载的配置如 logging.* and spring.main.* ,不适用。Jar包内部的application.properties 和 YAML 变量在resources内部的application.yml中定义me: 4444运行后覆盖之前的配置值Jar包内部的application-{profile}.properties 和 YAML 变量在resources内部的application-test.yml中定义me: 55555并在application.yml中定义spring:
profiles:
active:
- test运行后覆盖之前的配置值Jar包外部的application.properties 和 YAML 变量在jar包所在目录,创建一个application.yml文件:me: 666666运行后覆盖之前的配置值Jar包外部的application-{profile}.properties 和 YAML 变量在jar 所在目录,创建一个application-test.yml文件:me: 777777运行后覆盖之前的配置值RandomValuePropertySource的随机值属性RandomValuePropertySource 会解析random.*开头的属性,返回一个随机值,如${random.int}返回一个随机整数同样在前面的application-test.yml文件中配置:me: ${random.int}启动后,打印一个随机整数操作系统环境变量在操作系统中配置一个me变量,值为888888,启动后,即可读取到me的环境变量:注意:操作系统环境变量要全局生效,否则会读取不到Java System属性 (System.getProperties())在这里,我们不再往JVM中设置新的属性,而是读取其原有的属性,如java.version在MyApplicationRunListener中,输出java.version@Override
public void started(ConfigurableApplicationContext context, Duration timeTaken) {
System.out.println("上下文已刷新,应用程序已启动,但尚未调用CommandLineRunners和ApplicationRunners");
System.out.println(context.getEnvironment().getProperty("me"));
System.out.println(context.getEnvironment().getProperty("java.version"));
}为了能够体现后面的配置覆盖前面的配置,在application-test.yml中手动配置java.versionjava:
version: 1.8运行后,打印的结果:JNDI属性这块用的很少,就忽略了,如果是同样的配置,该配置会覆盖前面的配置。ServletContext 初始化参数ServletConfig 初始化参数如上两个都是servlet的配置,如server.port嵌入在环境变量或系统属性中的SPRING_APPLICATION_JSON 的属性在IDEA中配置启动时候的环境变量,SPRING_APPLICATION_JSON是一个JSON格式,如:启动后,将打印:命令行参数同样的在IDEA中配置命令行参数,--me=10000启动后打印结果如下,覆盖以前配置的值:测试环境properties 属性该配置是在单元测试中使用,如:@SpringBootTest(properties = {"me=2000"})
class GatewayApplicationTests {
@Autowired
private Environment environment;
@Test
void contextLoads() {
System.out.println(environment.getProperty("me"));
}
}启动后,将打印2000测试环境的@TestPropertySource 注解该配置是在单元测试中使用,如:@TestPropertySource(properties = {"me=3000"})
@SpringBootTest(properties = {"me=2000"})
class SpringBootDemoTests {
@Autowired
private Environment environment;
@Test
void contextLoads() {
System.out.println(environment.getProperty("me"));
}
}启动后打印3000Devtools 全局配置Devtools 是Spring Boot 提供的一套开发工具,启用需要依赖如下依赖:<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>默认读取$HOME/.config/spring-boot目录下的spring-boot-devtools.properties、spring-boot-devtools.yaml、spring-boot-devtools.yml文件,如果不存在,会从 $HOME 目录的根目录中搜索是否存在 .spring-bootdevtools.properties如在.spring-bootdevtools.properties中配置:启动后打印的结果如下,已为最新值:
cathoy
使用GraalVM 构建 Spring Boot 3.0 原生可执行文件
GraalVM 介绍既然是VM,那肯定也是一个虚拟机,那它跟JVM有关系吗?有一定关系,GraalVM 可以完全取代上面提到的那几种虚拟机,比如 HotSpot。把你之前运行在 HotSpot 上的代码直接平移到 GraalVM 上,不用做任何的改变,甚至都感知不到,项目可以完美的运行。但是 GraalVM 还有更广泛的用途,不仅支持 Java 语言,还支持其他语言。这些其他语言不仅包括嫡系的 JVM 系语言,例如 Kotlin、Scala,还包括例如 JavaScript、Nodejs、Ruby、Python 等,如图。GraalVM Native Image 介绍GraalVM Native Image 是GraalVM 提供的一种能够将Spring Boot 程序打包成云原生可执行文件的技术,并且比JVM 占用更少的内存和更快的启动速度,非常适合使用容器部署和在Faas平台使用。与在JVM运行的应用程序不同,GraalVM Native Image需要提前对代码进行编译处理才能创建可执行文件,GraalVM Native Image 的运行不需要提供JVM虚拟机。GraalVM 文档地址:https://www.graalvm.org/latest/docs/getting-started/GraalVM Native Image 文档地址:https://www.graalvm.org/latest/reference-manual/native-image/创建第一个GraalVM云原生应用程序有两种办法创建原生应用程序:使用Cloud Native Buildpacks 来生成一个包含可执行应用程序的轻量级容器使用GraalVM Native 构建工具生成一个可执行文件下面示例使用GraalVM Native来构建。环境准备安装GraalVM SDK压缩包安装下载对应版本软件:https://github.com/graalvm/graalvm-ce-builds/releasesWindows解压ZIP包到安装目录配置path路径到GraalVM 的bin目录setx /M PATH “C:\Progra~1\Java<graalvm>\bin;%PATH%”配置JAVA_HOME到GraalVM 的安装目录setx /M JAVA_HOME “C:\Progra~1\Java<graalvm>”重启,测试Linux解压ZIP包到指定目录tar -xzf graalvm-ce-java-linux--.tar.gz2.配置PATH路径export PATH=/path/to//bin:$PATH3.配置JAVA_HOME路径export JAVA_HOME=/path/to/4.测试MAC解压ZIP包tar -xzf graalvm-ce-java-darwin-amd64-.tar.gz如果使用的是macOS Catalina更高版本,可能需要执行如下命令:sudo xattr -r -d com.apple.quarantine /path/to/graalvm2.移动解压的包到/Library/Java/JavaVirtualMachinessudo mv graalvm-ce-java- /Library/Java/JavaVirtualMachines验证是否成功:/usr/libexec/java_home -V 将会得到一个安装的JDK目录3.配置PATH路径export PATH=/Library/Java/JavaVirtualMachines//Contents/Home/bin:$PATH4.配置JAVA_HOME路径export JAVA_HOME=/Library/Java/JavaVirtualMachines//Contents/HomeIDEA 安装使用IDEA内置功能即可,下载有点慢,这边IDEA只有基于Java 19 的版本使用IDEA 下载后,只能在IDEA内部运行应用程序,如果要使用maven 打包,还需要配置PATH和JAVA_HOME路径,同压缩包安装方式安装Native Image 工具如果没有安装该工具,maven 在打包的时候会自动下载,但建议提前安装打包工具gu install native-image安装Native Image依赖的本地环境因为要编译成指定本地可执行文件,比如exe,需要Windows安装了Microsoft Visual C++ (MSVC),MAC 需要安装xcode,通过xcode-select --install,Linux sudo yum install gcc glibc-devel zlib-develUbuntu sudo apt-get install build-essential libz-dev zlib1g-dev其他Linux sudo dnf install gcc glibc-devel zlib-devel libstdc++-static这里以Windows为例,安装 Visual Studio 2017 或更高版本的 构建工具和 Windows 10 SDK使用start.spring.io创建一个Spring Boot 3.0应用1、选择Java 17 版本2、选择GraalVM Native Support、Spring Web创建后的pom.xml<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.0.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>graalvm-native-application</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>graalvm-native-application</name>
<description>graalvm-native-application</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
3、写一个简单的接口package com.example.graalvmnativeapplication;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@SpringBootApplication
public class GraalvmNativeApplication {
public static void main(String[] args) {
SpringApplication.run(GraalvmNativeApplication.class, args);
}
@RequestMapping("/")
String home() {
return "Hello World!";
}
}4、打包可执行文件在 安装VS 中找到 x64 Native Tools Command Prompt 执行如下命令mvn -Pnative native:compile一共7个步骤,花费了差不多2分钟打包完,生成的可执行文件在target目录5、运行可执行文件双击exe文件,Spring Boot 应用程序几乎瞬间启动完毕,文件大小有68M,对于一个没什么业务代码的demo来说,确实太大了。访问地址http://localhost:8080/,能正常访问。6、与Java 17 比较VM包大小启动时间GraalVM Native Image68M0.15sJava 1718M2.15sJava 816.5M3.5s从这个DEMO看出,使用GraalVM Spring Boot 的启动时间确实快乐很多,但同时包也大了很多 ,有点空间换时间的意思。如果要打包原生可执行文件的话,环境配置也比较繁琐。不过使用GraalVM 来替代JVM 跑Java 程序还是很值得尝试的。参考资料:https://www.graalvm.org/latest/docs/getting-started/windows/ (GraalVM在 Windows的使用)https://blog.csdn.net/q412086027/article/details/113878426 (给了我启发)https://medium.com/graalvm/using-graalvm-and-native-image-on-windows-10-9954dc071311 (比较清楚的Windows 配置步骤)
cathoy
Spring Boot 中文参考指南
1. 文档预览2. 开始本节对Spring Boot进行介绍 以及如何安装,我们将引导您构建第一个Spring Boot 应用,同时讨论一些核心准则。2.1 Spring Boot 介绍Spring Boot 帮助您创建可以独立运行的,生产级的Spring 应用程序。您创建的Spring Boot 应用程序,可以通过java -jar 或者 传统的war包方式启动,另外还提供了一个运行spring scripts的命令行工具。2.2 系统要求Spring Boot 2.7.8 需要Java8 ,兼容Java19,Spring 版本5.3.25或更高。构建工具版本Maven3.5+Gradle6.8.x, 6.9.x, and 7.x2.2.1 Servlet 容器Spring Boot 支持如下嵌入式servlet容器:名称Servlet 版本Tomcat 9.04.0Jetty 9.43.1Jetty 10.04.0Undertow 2.04.0您也可以将Spring Boot部署到任何兼容servlet 3.1+的容器中。2.3 Spring Boot 安装安装之前使用java -version检查 Java 版本,Spring Boot 2.7.8 需要Java8 或更高的版本。2.3.1 面向Java开发人员Maven 安装Spring Boot 依赖项使用org.springframework.boot groupId。通常,您的Maven POM文件继承自spring-boot-starter-parent ,并声明一个或多个“Starters”的依赖关系。另外Spring Boot 还提供了可选的Maven 插件来创建可执行的jar,更多信息参考 Spring Boot Maven 插件文档。Gradle 安装同Maven,Spring Boot 也提供了一个 Gradle插件,用于创建可执行的jar,更多信息参考 Spring Boot Gradle 插件文档。2.3.2 安装Spring Boot CLISpring Boot CLI是一个命令行工具,可用于快速创建Spring Boot 初始化应用程序,这在没有IDE的情况下非常有用。手动安装您可以从如下地址下载Spring CLI发行版本:spring-boot-cli-2.7.8-bin.zipspring-boot-cli-2.7.8-bin.tar.gz另外提供了 快照列表下载后,按照解压缩存档中的INSTALL.txt说明进行操作。.zip文件的bin/目录中有一个spring脚本(适用于Windows的spring.bat),或者可以使用jar -jar 运行 jar包。使用SDKMAN安装SDKMAN(软件开发工具包管理器)可用于管理各种二进制SDK版本,包括Groovy和Spring Boot CLI。从sdkman.io获取并使用以下命令安装 Spring Boot:$ sdk install springboot
$ spring --version
Spring CLI v2.7.8如果您为 CLI 开发功能并希望访问您构建的版本,请使用以下命令:$ sdk install springboot dev /path/to/spring-boot/spring-boot-cli/target/spring-boot-cli-2.7.8-bin/spring-2.7.8/
$ sdk default springboot dev
$ spring --version
Spring CLI v2.7.8前面的说明安装了一个spring名为instance 的本地dev实例。它指向您的目标构建位置,因此每次您重建 Spring Boot 时,spring它都是最新的。您可以通过运行以下命令来查看它:$ sdk ls springboot
================================================================================
Available Springboot Versions
================================================================================
> + dev
* 2.7.8
================================================================================
+ - local version
* - installed
> - currently in use
================================================================================使用OSX Homebrew安装在Mac上可以使用Homebrew安装。$ brew tap spring-io/tap
$ brew install spring-bootHomebrew 安装 spring 到 /usr/local/bin.如果找不到这个命令,尝试使用brew update更新后重试使用MacPorts安装在Mac上使用MacPorts 安装。$ sudo port install spring-boot-cli使用 Windows Scoop安装在Window使用Scoop安装。> scoop bucket add extras
> scoop install springbootScoop 安装 spring 到 ~/scoop/apps/springboot/current/bin如果提示命令不存,请使用scoop update更新后再重试Spring CLI 快速启动示例首先创建一个名为app.groovy的文件。@RestController
class ThisWillActuallyRun {
@RequestMapping("/")
String home() {
"Hello World!"
}
}然后使用如下命令运行:$ spring run app.groovy第一次运行需要下载依赖,会比较慢,后面运行会快很多。最后,使用浏览器打开localhost:8080,输出Hello World!2.4 开发第一个Spring Boot 应用程序建议使用start.spring.io 创建Spring Boot 应用程序。3. 升级Spring Boot3.1 从1.x升级从1.x升级,可以查看GitHub wiki上的升级指南3.2 升级到最新的功能版本Spring Boot提供了一种方法来分析应用程序的环境并在启动时打印诊断信息,还可以在运行时临时迁移属性,要启动该功能,在项目中添加以下依赖:<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-properties-migrator</artifactId>
<scope>runtime</scope>
</dependency>完成升级后,删除该依赖。4. Spring Boot开发4.1 构建系统可以使用Maven、Gradle、Ant 构建系统4.1.1 Starters所有官方启动器都遵循类似的命名模式:spring-boot-starter-*,其中*是特定类型的应用程序,如果是自己创建的启动器一般以项目名称开头,如thirdpartyproject-spring-boot-starter。Spring Boot 提供了以下应用启动器org.springframework.boot:名称描述spring-boot-starterCore starter,包括自动配置支持、日志记录和 YAMLspring-boot-starter-activemq使用 Apache ActiveMQ 的 JMS 消息传递启动器spring-boot-starter-amqp使用 Spring AMQP 和 Rabbit MQ 的启动器spring-boot-starter-aop使用 Spring AOP 和 AspectJ 进行面向方面编程的入门spring-boot-starter-artemis使用 Apache Artemis 的 JMS 消息传递启动器spring-boot-starter-batch使用 Spring Batch 的启动器spring-boot-starter-cache使用 Spring Framework 的缓存支持的 Starterspring-boot-starter-data-cassandra使用 Cassandra 分布式数据库和 Spring Data Cassandra 的 Starterspring-boot-starter-data-cassandra-reactive使用 Cassandra 分布式数据库和 Spring Data Cassandra Reactive 的 Starterspring-boot-starter-data-couchbase使用 Couchbase 面向文档的数据库和 Spring Data Couchbase 的启动器spring-boot-starter-data-couchbase-reactive使用 Couchbase 面向文档的数据库和 Spring Data Couchbase Reactive 的 Starterspring-boot-starter-data-elasticsearch使用 Elasticsearch 搜索和分析引擎以及 Spring Data Elasticsearch 的 Starterspring-boot-starter-data-jdbc使用 Spring Data JDBC 的启动器spring-boot-starter-data-jpa将 Spring Data JPA 与 Hibernate 一起使用的启动器spring-boot-starter-data-ldap使用 Spring Data LDAP 的启动器spring-boot-starter-data-mongodb使用 MongoDB 面向文档的数据库和 Spring Data MongoDB 的启动器spring-boot-starter-data-mongodb-reactive使用 MongoDB 文档型数据库和 Spring Data MongoDB Reactive 的 Starterspring-boot-starter-data-neo4j使用 Neo4j 图形数据库和 Spring Data Neo4j 的启动器spring-boot-starter-data-r2dbc使用 Spring Data R2DBC 的启动器spring-boot-starter-data-redis用于将 Redis 键值数据存储与 Spring Data Redis 和 Lettuce 客户端一起使用的 Starterspring-boot-starter-data-redis-reactive将 Redis 键值数据存储与 Spring Data Redis 反应式和 Lettuce 客户端一起使用的启动器spring-boot-starter-data-rest使用 Spring Data REST 通过 REST 公开 Spring Data 存储库的 Starterspring-boot-starter-freemarker使用 FreeMarker 视图构建 MVC Web 应用程序的启动器spring-boot-starter-graphql使用 Spring GraphQL 构建 GraphQL 应用程序的 Starterspring-boot-starter-groovy-templates使用 Groovy 模板视图构建 MVC web 应用程序的启动器spring-boot-starter-hateoas使用 Spring MVC 和 Spring HATEOAS 构建基于超媒体的 RESTful Web 应用程序的启动器spring-boot-starter-integration使用 Spring Integration 的启动器spring-boot-starter-jdbc将 JDBC 与 HikariCP 连接池一起使用的启动器spring-boot-starter-jersey使用 JAX-RS 和 Jersey 构建 RESTful Web 应用程序的启动器。的替代品spring-boot-starter-webspring-boot-starter-jooq使用 jOOQ 通过 JDBC 访问 SQL 数据库的启动器。替代spring-boot-starter-data-jpa或spring-boot-starter-jdbcspring-boot-starter-json读写json的starterspring-boot-starter-jta-atomikos使用 Atomikos 的 JTA 事务启动器spring-boot-starter-mail使用 Java Mail 和 Spring Framework 的电子邮件发送支持的 Starterspring-boot-starter-mustache使用 Mustache 视图构建 Web 应用程序的启动器spring-boot-starter-oauth2-client使用 Spring Security 的 OAuth2/OpenID Connect 客户端功能的 Starterspring-boot-starter-oauth2-resource-server使用 Spring Security 的 OAuth2 资源服务器功能的启动器spring-boot-starter-quartz使用 Quartz 调度器的启动器spring-boot-starter-rsocket用于构建 RSocket 客户端和服务器的启动器spring-boot-starter-security使用 Spring Security 的启动器spring-boot-starter-test用于使用 JUnit Jupiter、Hamcrest 和 Mockito 等库测试 Spring Boot 应用程序的 Starterspring-boot-starter-thymeleaf使用 Thymeleaf 视图构建 MVC Web 应用程序的启动器spring-boot-starter-validation将 Java Bean Validation 与 Hibernate Validator 结合使用的 Starterspring-boot-starter-web用于使用 Spring MVC 构建 Web(包括 RESTful)应用程序的 Starter。使用 Tomcat 作为默认的嵌入式容器spring-boot-starter-web-services使用 Spring Web 服务的启动器spring-boot-starter-webflux用于使用 Spring Framework 的 Reactive Web 支持构建 WebFlux 应用程序的 Starterspring-boot-starter-websocket使用 Spring Framework 的 MVC WebSocket 支持构建 WebSocket 应用程序的 Starter除了应用程序启动器之外,还可以使用以下启动器来添加*生产就绪*功能:名称描述spring-boot-starter-actuator使用 Spring Boot Actuator 的 Starter,它提供生产就绪功能来帮助您监控和管理您的应用程序最后,Spring Boot 还包括以下启动器:名称描述spring-boot-starter-jetty使用 Jetty 作为嵌入式 servlet 容器的启动器的替代品spring-boot-starter-tomcatspring-boot-starter-log4j2使用 Log4j2 进行日志记录的启动器的替代品spring-boot-starter-loggingspring-boot-starter-logging使用 Logback 进行日志记录的启动器。默认日志记录启动器spring-boot-starter-reactor-netty使用 Reactor Netty 作为嵌入式响应式 HTTP 服务器的启动器。spring-boot-starter-tomcat将 Tomcat 用作嵌入式 servlet 容器的启动器。使用的默认 servlet 容器启动器spring-boot-starter-webspring-boot-starter-undertow使用 Undertow 作为嵌入式 servlet 容器的启动器的替代品spring-boot-starter-tomcat其他社区贡献的starter列表,请参阅GitHub 上模块 中的自述文件。spring-boot-starters4.2 构建代码Spring Boot 没有固定的代码布局,但是有些实践提供参考。4.2.1 "default"包当一个类不包含package时,它被认为在“default package”中。通常不建议使用“default package”,它可能会导致@ComponentScan、@ConfigurationPropertiesScan、@EntityScan或@SpringBootApplication 注解出现问题,我们应该遵循推荐的包命名方式,比如com.example.project4.2.2 主程序类通常建议将主程序类放在其他类之上的根包中,@SpringBootApplication通常放在主类中,其隐式的定义了基本的包搜索功能,其内部引入了@EnableAutoConfiguration和@ComponentScan。下面是一个典型的布局:com
+- example
+- myapplication
+- MyApplication.java
|
+- customer
| +- Customer.java
| +- CustomerController.java
| +- CustomerService.java
| +- CustomerRepository.java
|
+- order
+- Order.java
+- OrderController.java
+- OrderService.java
+- OrderRepository.javaMyApplication.java 定义了一个main方法以及@SpringBootApplication,如下所示:import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}4.3 配置类Spring Boot 支持使用Java进行配置,虽然SpringApplication 跟XML可以一起使用,但还是建议@Configuration是独立的类。4.3.1 导入其他的配置类你不需要把所有的@Configuration 放到一个类中,该@Import注解可用于导入其他的配置类,或者可以使用@ComponentScan自动获取所有的Spring 组件,包括@Configuration类。4.3.2 导入XML配置如果你还是要使用XML配置,依然建议使用@Configuration类,然后使用@ImportResource来加载XML配置。4.4 自动配置Spring Boot会尝试将starter自动配置到应用程序,比如引入了HSQLDB的starter,但是没有手动配置任何数据库连接bean,那么Spring Boot 会自动配置一个内存数据库。开启自动配置,需要添加@EnableAutoConfiguration或者@SpringBootApplication。4.4.1 替换自动配置自动配置是非侵入式的,任何时候都可以使用自定义配置替换自动配置的指定部分,比如,添加了DataSource bean,默认的嵌入式数据库就会被替换。使用--debug启动应用程序,可以打印出当前应用了哪些自动配置。4.4.2 禁用指定的自动配置类如果想要禁用指定的自动配置类,可以使用@SpringBootApplication的exclude属性,如:import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
public class MyApplication {
}如果排除的类不在类路径中,可以使用excludeName指定类的完全限定名,另外如果不用@SpringBootApplication,@EnableAutoConfiguration的exclude和excludeName也是可用的。最后也能用spring.autoconfigure.exclude的配置来排除自动配置类。4.5 Spring Beans和依赖注入通常建议使用构造函数注入依赖项,和@ComponentScan查找bean。如果是按照4.2的方式构建的代码,则可以使用@ComponentScan不带任何参数或者使用@SpringBootApplication其已经包含了@ComponentScan注解,这样所有的组件(@Component、@Service、@Repository、@Controller和其他)都会自动注册为Spring Beans。如下示例表示一个@Service使用构造函数来注入RiskAssessor Bean。import org.springframework.stereotype.Service;
@Service
public class MyAccountService implements AccountService {
private final RiskAssessor riskAssessor;
public MyAccountService(RiskAssessor riskAssessor) {
this.riskAssessor = riskAssessor;
}
// ...
}如果一个Bean有多个构造函数,需要使用@Autowired标记哪个需要Spring 注入:import java.io.PrintStream;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class MyAccountService implements AccountService {
private final RiskAssessor riskAssessor;
private final PrintStream out;
@Autowired
public MyAccountService(RiskAssessor riskAssessor) {
this.riskAssessor = riskAssessor;
this.out = System.out;
}
public MyAccountService(RiskAssessor riskAssessor, PrintStream out) {
this.riskAssessor = riskAssessor;
this.out = out;
}
// ...
}使用构造函数注入应该使用 final标记,表示后面不能再被修改。4.6 使用@SpringBootApplication 注解使用@SpringBootApplication注解可以启用如下三个功能:@EnableAutoConfiguration: 启用Spring Boot 的自动配置机制@ComponentScan @Component:在应用程序所在的包上启用扫描@SpringBootConfiguration: 允许在上下文中注册额外的 beans 或导入额外的配置类。Spring 标准的替代方案@Configuration,有助于在集成测试中进行配置检测。import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
// Same as @SpringBootConfiguration @EnableAutoConfiguration @ComponentScan
@SpringBootApplication
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}如果不想使用@SpringBootApplication,也可以单独使用注解,如下示例并未使用@ComponentScan 自动扫描功能,而使用显示导入(@Import):import org.springframework.boot.SpringApplication;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.Import;
@SpringBootConfiguration(proxyBeanMethods = false)
@EnableAutoConfiguration
@Import({ SomeConfiguration.class, AnotherConfiguration.class })
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}4.7 运行应用程序4.7.1 从IDE运行4.7.2 作为打包应用程序运行使用java -jar运行:$ java -jar target/myapplication-0.0.1-SNAPSHOT.jar也可以附加远程调式器:$ java -Xdebug -Xrunjdwp:server=y,transport=dt_socket,address=8000,suspend=n \
-jar target/myapplication-0.0.1-SNAPSHOT.jar4.7.3 使用Maven插件Spring Boot Maven 插件包含一个run命令:$ mvn spring-boot:run另外还可以使用MAVEN_OPTS 设置环境变量:$ export MAVEN_OPTS=-Xmx1024m4.7.4 使用Gradle插件Gradle插件包含一个bootRun命令:$ gradle bootRun使用JAVA_OPTS设置环境变量:$ export JAVA_OPTS=-Xmx1024m4.7.5 热插拨Spring Boot 的热插拨基于JVM,JVM在某种程序上受限于可以替换的字节码,对于完整方案可以使用JRebel 。spring-boot-devtools模块还包括对应用程序快速重启的支持,详细信息查看后面的热插拔“操作方法”。4.8 开发者工具Spring Boot 提供spring-boot-devtools 模块提供开发时的额外功能,要支持该功能,需要将依赖添加到项目中:Maven<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
</dependencies>Gradledependencies {
developmentOnly("org.springframework.boot:spring-boot-devtools")
}默认情况下,打包的应用程序不包含devtools,如果想要使用某个远程devtool特性,在Maven插件中配置,excludeDevtools为false,Gradle插件中配置task任务以包含developmentOnly,如tasks.named("bootWar") {
classpath configurations.developmentOnly
}
当打包生产应用程序时,开发者工具将被自动禁用。如果您的应用程序是从一个特殊的类加载器启动的或者 使用java -jar,那么会被认为是一个"生产的应用程序"。可以使用spring.devtools.restart.enabled来控制,要开启devtools,使用-Dspring.devtools.restart.enabled=true启动,要禁用devtools,排除依赖项或者使用-Dspring.devtools.restart.enabled=false启动。Maven 中使用optional,Gradle 使用 developmentOnly,表示可以防止devtools被传递到项目的其他模块。4.8.1 诊断类加载问题开发者工具的重启功能是通过使用两个类加载器实现的,对于大不多应用程序效果很好,但是有时候会导致类加载问题,特别是在多模块项目中。要判断是不是由于这个问题,可以尝试禁用重启,使用spring.devtools.restart.enabled=false属性禁用它。另外可以 自定义重启类加载器,自定义由哪个类加载加载,详见[4.8.3自动重启](#4.8.3 自动重启)。4.8.2 属性默认值Spring Boot 的一些库使用缓存来提高性能,比如,模版引擎会缓存编译后的模版,以此避免重复解析,但这样在开发过程中我们就不能即时看到模版的变更。spring-boot-devtools 默认禁用了缓存。下表列出了所有应用的属性:名称默认值server.error.include-binding-errorsalwaysserver.error.include-messagealwaysserver.error.include-stacktracealwaysserver.servlet.jsp.init-parameters.developmenttrueserver.servlet.session.persistenttruespring.freemarker.cachefalsespring.graphql.graphiql.enabledtruespring.groovy.template.cachefalsespring.h2.console.enabledtruespring.mustache.servlet.cachefalsespring.mvc.log-resolved-exceptiontruespring.template.provider.cachefalsespring.thymeleaf.cachefalsespring.web.resources.cache.period0spring.web.resources.chain.cachefalse如果不想应用属性默认值,可以在应用程序配置文件中配置spring.devtools.add-properties=false在开发WEB应用的时候,可以开启DEBUG日志,这样会显示请求、正在处理的程序,响应结果和其他详细信息,如果希望显示所有的详细信息(比如潜在的敏感信息),可以打开spring.mvc.log-request-details或spring.codec.log-request-details。笔者注:开启spring.mvc.log-request-details 后的日志关闭spring.mvc.log-request-details后的日志:4.8.3 自动重启只要类路径上的文件发生变更,使用了spring-boot-devtools的应用程序就会自动重启,但是某些资源(如静态资源和视图模版)不需要重启应用程序。触发重启的方法:由于DevTools 通过监听类路径上的资源来触发重启,所以不管使用哪个IDE都需要重新编译后才能触发重启:Eclipse 中,保存修改后会更新类文件并触发重启IDEA中,通过Build 触发或者编辑项目的Edit Configurations -> On Update action:Update classes and resources也可以触发重启使用构建工具,mvn compile或者gradle build可以触发重启笔者注:官方文档提示:使用Maven或者Gradle时,需要将forking设置为enabled,才能触发重启。实测,新版本的spring-boot-maven-plugin在项目引入spring-boot-devtools后会自动开启fork,如图:并且插件的注释也标记为过期,将在3.0.0中彻底删除:在重启期间 DevTools 依赖应用上下文的 shutdown hook 来关闭,如果设置为SpringApplication.setRegisterShutdownHook(false),就会导致其无法正常工作。笔者注:在笔者按照这样设置后,发现自动重启并无失效public static void main(String[] args) {
SpringApplication application = new SpringApplication(SpringBootDemoApplication.class);
application.setRegisterShutdownHook(false);
application.run(args);
}
AspectJ 切面不支持自动重启重新启动与重新加载Spring Boot 的重启技术通过使用两个类加载器来工作的,不会更改的类(如:第三方jar的类)被加载到基类加载器中,频繁修改的类被加载到一个重启类加载器中。当应用程序重启时,旧的重启类加载器被丢弃并创建一个新的类加载器,这种方法会被“冷启动”快得多,因为基类加载器已经可用。如果自动重启还是比较慢的,或者遇到类加载问题,可用尝试使用重新加载技术,如JRebel,他们通过加载类时重写类来获得更快的速度。记录条件评估中的变化默认每次自动重启应用程序的时候,都会显示一份对自动配置的变更报告(比如添加或删除bean或者设置配置属性)禁用报告设置:spring.devtools.restart.log-condition-evaluation-delta=false笔者注:开启时候的报告示例:排除资源某些资源在更改时并不会触发自动重启,默认情况下更改 /META-INF/maven, /META-INF/resources, /resources, /static, /public, /templates目录下的资源不会触发重启但是会触发[实时加载](#4.8.4 实时加载),如果要自定义这些排除项,可以使用spring.devtools.restart.exclude属性,比如仅排除/static和/public目录:spring.devtools.restart.exclude=static/**,public/**如果要保留默认的配置,并且添加新的排除项,使用spring.devtools.restart.additional-exclude。监听其他路径文件如果要监听不在类路径中的文件时,使用spring.devtools.restart.additional-paths属性。另外可以配合spring.devtools.restart.exclude来设置其他路径下的文件变更是触发重启还是实时加载。禁用重启使用spring.devtools.restart.enabled禁用重启,如果在application.properties配置,重启类加载器还是会初始化,只是不会监听文件的变更,要完全禁用需要设置系统变量spring.devtools.restart.enabled为false,如下:import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MyApplication {
public static void main(String[] args) {
System.setProperty("spring.devtools.restart.enabled", "false");
SpringApplication.run(MyApplication.class, args);
}
}使用触发式文件使用某个指定的文件变更来触发自动重启,使用spring.devtools.restart.trigger-file配置指定文件(不包括路径),该文件必须在类路径下。比如:有这样一个结构的项目:src
+- main
+- resources
+- .reloadtrigger那么trigger-file的配置是spring.devtools.restart.trigger-file=.reloadtrigger自定义重启类加载器默认情况下,IDE中打开的项目都使用重启类加载器,其他.jar文件使用基类加载器。使用mvn spring-boot:run或者gradle bootRun也是这样。可以通过META-INF/spring-devtools.properties文件来自定义,spring-devtools.properties文件包含前缀为restart.exclude和restart.include的属性,include属性被重启类加载器加载,exclude属性被基类加载器排除,该属性适用类路径的正则表达式,如:restart.exclude.companycommonlibs=/mycorp-common-[\\w\\d-\\.]+\\.jar
restart.include.projectcommon=/mycorp-myproj-[\\w\\d-\\.]+\\.jar键必须是唯一的,只要是restart.exclude和restart.include开头的属性都会被考虑。META-INF/spring-devtools.properties的内容可以打包中项目中,也可以打包到库中。已知限制对于使用标准ObjectInputStream反序列化的对象,重新启动功能不起作用。如果您需要反序列化数据,则可能需要将Spring的ConfigurableObjectInputStream与Thread.currentThread().getContextClassLoader()结合使用。笔者注:这个点我觉得略过即可4.8.4 实时加载spring-boot-devtools包含一个嵌入式的LiveReload服务器,可用于资源变更时实时触发浏览器刷新。LiveReload 浏览器扩展可从livereload.com免费获得 Chrome、Firefox 和 Safari 。如果您不想在应用程序运行时启动 LiveReload 服务器,您可以将该spring.devtools.livereload.enabled属性设置为false.您一次只能运行一个 LiveReload 服务器。在启动您的应用程序之前,请确保没有其他 LiveReload 服务器正在运行。如果您从 IDE 启动多个应用程序,则只有第一个应用程序支持 LiveReload。笔者注:这个点我觉得略过即可,浏览器手动刷新一下也不费事4.8.5 全局设置可以通过将以下任何文件添加到$HOME/.config/spring-boot目录来配置全局 devtools 设置:spring-boot-devtools.propertiesspring-boot-devtools.yamlspring-boot-devtools.yml添加到该文件的任何配置都适用于该机器上的所有Spring Boot 应用程序,例如,要将自动重启配置为使用触发式文件,可以这样配置:spring.devtools.restart.trigger-file=.reloadtrigger默认情况下,$HOME是用户的主目录。要自定义此位置,请设置SPRING_DEVTOOLS_HOME环境变量或spring.devtools.home系统属性。如果在$HOME/.config/spring-boot中找不到 devtools 配置文件,则会在根$HOME目录中搜索是否存在.spring-boot-devtools.properties文件。这允许您与不支持该$HOME/.config/spring-boot位置的旧版本 Spring Boot 上共享 devtools 全局配置。在.spring-boot-devtools.properties中的配置都不会影响其他的应用配置文件(如application-{profile}之类的文件),并且不支持spring-boot-devtools-.properties和spring.config.activate.on-profile 之类的配置。配置文件监听器FileSystemWatcher通过一定的时间间隔轮询类文件的变更来工作,然后等待预定义的静默期以确保没有更多变更。如果您发现有时候某些更改并没有及时变化,可以尝试修改spring.devtools.restart.poll-interval和spring.devtools.restart.quiet-period参数。spring.devtools.restart.poll-interval=2s
spring.devtools.restart.quiet-period=1s受监视的类路径目录现在每 2 秒轮询一次更改,并保持 1 秒的静默期以确保没有其他类更改。4.8.6 远程应用Spring Boot 支持部分远程功能,但有一定安全风险,只能在受信任的网络或SSL保护下运行,并且不能在生产环境上开启该功能。启用该功能,确保如下配置:<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludeDevtools>false</excludeDevtools>
</configuration>
</plugin>
</plugins>
</build>然后使用spring.devtools.remote.secret设置一个复杂的密码。Spring WebFlux 不支持该功能运行远程客户端应用程序远程客户端应用程序旨在从IDE中运行。您需要使用与连接到的远程项目相同的类路径运行org.springframework.boot.devtools.RemoteSpringApplication。应用程序的单个必需参数是它连接的远程URL。例如,如果您使用的是Eclipse或STS,并且已经部署到Cloud Foundry的项目名为my-app,则可以执行以下操作:从Run菜单中选择Run Configurations…。创建一个新的Java Application“启动配置”。浏览my-app项目。使用org.springframework.boot.devtools.RemoteSpringApplication作为主类。将https://myapp.cfapps.io添加到Program arguments(或任何远程URL)。正在运行的远程客户端可能类似于以下列表: . ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ ___ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | | _ \___ _ __ ___| |_ ___ \ \ \ \
\\/ ___)| |_)| | | | | || (_| []::::::[] / -_) ' \/ _ \ _/ -_) ) ) ) )
' |____| .__|_| |_|_| |_\__, | |_|_\___|_|_|_\___/\__\___|/ / / /
=========|_|==============|___/===================================/_/_/_/
:: Spring Boot Remote :: (v2.7.8)
2023-01-19 14:18:32.205 INFO 16947 --- [ main] o.s.b.devtools.RemoteSpringApplication : Starting RemoteSpringApplication v2.7.8 using Java 1.8.0_362 on myhost with PID 16947 (/Users/myuser/.m2/repository/org/springframework/boot/spring-boot-devtools/2.7.8/spring-boot-devtools-2.7.8.jar started by myuser in /opt/apps/)
2023-01-19 14:18:32.211 INFO 16947 --- [ main] o.s.b.devtools.RemoteSpringApplication : No active profile set, falling back to 1 default profile: "default"
2023-01-19 14:18:32.566 INFO 16947 --- [ main] o.s.b.d.a.OptionalLiveReloadServer : LiveReload server is running on port 35729
2023-01-19 14:18:32.584 INFO 16947 --- [ main] o.s.b.devtools.RemoteSpringApplication : Started RemoteSpringApplication in 0.804 seconds (JVM running for 1.204)
因为远程客户端使用与真实应用程序相同的类路径,所以它可以直接读取应用程序属性。这是spring.devtools.remote.secret属性的读取方式并传递给服务器进行身份验证。始终建议使用https://作为连接协议,以便加密连接并且不会截获密码。如果需要使用代理来访问远程应用程序,请配置spring.devtools.remote.proxy.host和spring.devtools.remote.proxy.port属性。远程更新远程客户端以与[本地重新启动](#4.8.3 自动重启)相同的方式监视应用程序类路径以进行更改 。任何更新的资源都会被推送到远程应用程序,并且(如果需要)会触发重新启动。如果您迭代使用本地没有的云服务的功能,这将非常有用。通常,远程更新和重新启动比完全重建和部署周期快得多。仅在远程客户端运行时监视文件。如果在启动远程客户端之前更改文件,则不会将其推送到远程服务器笔者注:对于目前的大型微服务集群来说,并不实用,而且操作繁琐,使用这种更新方式部分类还有可能不生效,如果只是在测试环境使用,还不如Jenkins重新打包部署4.9 打包应用程序使用Maven 或者Gradle 打包应用程序,生成jar包文件。5. 核心功能5.1 SpringApplicationSpringApplication提供了一个main()方法方便引导Spring 应用程序启动,并委托给静态方法SpringApplication.run。import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}启动后,会看到如下信息: . ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.7.8)
2023-01-19 14:18:33.375 INFO 17059 --- [ main] o.s.b.d.f.s.MyApplication : Starting MyApplication using Java 1.8.0_362 on myhost with PID 17059 (/opt/apps/myapp.jar started by myuser in /opt/apps/)
2023-01-19 14:18:33.379 INFO 17059 --- [ main] o.s.b.d.f.s.MyApplication : No active profile set, falling back to 1 default profile: "default"
2023-01-19 14:18:34.288 INFO 17059 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8080 (http)
2023-01-19 14:18:34.301 INFO 17059 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2023-01-19 14:18:34.301 INFO 17059 --- [ main] org.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/9.0.71]
2023-01-19 14:18:34.371 INFO 17059 --- [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
2023-01-19 14:18:34.371 INFO 17059 --- [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 943 ms
2023-01-19 14:18:34.754 INFO 17059 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path ''
2023-01-19 14:18:34.769 INFO 17059 --- [ main] o.s.b.d.f.s.MyApplication : Started MyApplication in 1.789 seconds (JVM running for 2.169)默认情况下日志级别是INFO,如果需要额外的日志级别设置,查看[5.4.5 日志级别](#5.4.5 日志级别)。通过spring.main.log-startup-info设置为false,可以关闭应用程序的日志记录。5.1.1 启动失败如果应用启动失败,能够通过已注册的FailureAnalyzers获取错误信息以便修复问题。比如应用程序启动的8080端口被占用。***************************
APPLICATION FAILED TO START
***************************
Description:
Embedded servlet container failed to start. Port 8080 was already in use.
Action:
Identify and stop the process that is listening on port 8080 or configure this application to listen on another port.Spring Boot 支持自定义FailureAnalyzer实现如果没有故障分析器能够处理异常,您需要给org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportLoggingListener[启用debug属性](#5.2 外部配置),或者[开启DEBUG日志](#5.4.5 日志级别)。使用java -jar启动应用程序,使用debug开启日志:$ java -jar myproject-0.0.1-SNAPSHOT.jar --debug5.1.2 惰性初始化SpringApplication 允许延迟初始化应用程序,当启用惰性初始化时,bean 在需要时创建,而不是在启动期间创建。惰性初始化的一个缺点是会延迟发现应用程序的问题,如果配置错误的bean被惰性初始化,则在启动期间不会发生故障,只有在bean 被初始化时才发现问题。另外还要注意确保JVM有足够的内存来容纳所有的bean。因此建议在启用惰性初始化前微调JVM堆大小。使用SpringApplicationBuilder的lazyInitialization或者SpringApplication的setLazyInitialization方法开启惰性初始化,也可以使用spring.main.lazy-initialization开启。spring.main.lazy-initialization=true指定某些bean延迟初始化,使用@Lazy(false)5.1.3 自定义横幅通过将banner.txt添加到类路径中,或者设置spring.banner.location为该类文件的位置,来更改应用启动时打印的横幅。如果文件编码不是UTF-8,可以设置spring.banner.charset。除了使用文本文件外,还可以使用图片,将图片添加到类路径中,或者设置spring.banner.image.location,图形将被转换为ASCII格式。在banner.txt文件中,您可以使用Environment中可用的任何键和以下占位符。占位符描述${application.version}您的应用程序的版本号,如在MANIFEST.MF声明的那样。例如,Implementation-Version: 1.0打印为1.0.${application.formatted-version}您的应用程序的版本号,在MANIFEST.MF中声明并格式化显示(用括号括起来并以 为前缀v)。例如(v1.0)。${spring-boot.version}您正在使用的 Spring Boot 版本。例如2.7.8。${spring-boot.formatted-version}您正在使用的 Spring Boot 版本,经过格式化以供显示(用方括号括起来并以 为前缀v)。例如(v2.7.8)。${Ansi.NAME}(或${AnsiColor.NAME},,${AnsiBackground.NAME})${AnsiStyle.NAME}NAMEANSI 转义代码的名称在哪里。详情请见AnsiPropertySource。${application.title}您的应用程序的标题,如MANIFEST.MF中声明的那样。例如Implementation-Title: MyApp打印为MyApp.使用SpringApplication.setBanner(…)以编程方式设置横幅,使用org.springframework.boot.Banner接口并实现printBanner()方法自定义打印横幅。可以使用spring.main.banner-mode 设置是否在System.out( console)、或者日志文件中打印横幅、或者不打印横幅${application.version}和${application.formatted-version}配置仅仅在使用Spring Boot启动器的时候可用。如果你使用未打包的jar并使用java -cp <classpath> <mainclass>启动,则不会生效。这就是为什么我们建议您始终使用java org.springframework.boot.loader.JarLauncher启动未打包的jar。这将在构建类路径和启动应用程序之前初始化application.*banner变量。5.1.4 自定义SpringApplication如果默认的SpringApplication 不适合您,您可以自己创建一个实例,并进行自定义。例如,要关闭横幅:import org.springframework.boot.Banner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MyApplication {
public static void main(String[] args) {
SpringApplication application = new SpringApplication(MyApplication.class);
application.setBannerMode(Banner.Mode.OFF);
application.run(args);
}
}也可以使用application.properties配置SpringApplication,详细查看[外部配置](#5.2 外部配置)5.1.5 流式API生成器如果您需要构建ApplicationContext层次结构(具有父子关系的多个上下文)或者更喜欢使用流式API构建器,可以使用SpringApplicationBuilder。SpringApplicationBuilder让你将多个方法链式调用,包括parent和child方法创建层级结构,比如:new SpringApplicationBuilder()
.sources(Parent.class)
.child(Application.class)
.bannerMode(Banner.Mode.OFF)
.run(args);ApplicationContext创建层次结构 时有一些限制。例如,Web 组件必须包含在子上下文中,并且同样Environment用于父上下文和子上下文。有关详细信息,请参阅SpringApplicationBuilderJavadoc。5.1.6 应用可用性当部署在平台上时,应用程序可以使用Kubernetes Probe等基础设施向平台提供有关其可用性的信息。Spring Boot 对常用的“liveness” 和 “readiness”状态提供开箱即用的支持。如果您使用了Spring Boot 的actuator那么状态将以监控端点的形式提供。另外,您还可以通过ApplicationAvailability接口将可用性状态注入到自己的bean中。Liveness 状态应用程序的"Liveness"状态表示其是否能正常工作,或者当前是失败状态,则自行修复。中断的“Liveness”状态意味着应用程序处于无法恢复的状态,那么基础架构应重启应用程序。“Liveness”状态不应该基于外部检查,比如健康检查。如果这样,一个失败的外部信息(如数据库、外部缓存等)将导致大规模重启和整个平台的连锁故障。Spring Boot 应用程序的内部状态主要根据Spring 的 ApplicationContext。如果应用程序上下文成功启动,则Spring Boot 会认为应用程序处于有效状态,上下文刷新的话,应用程序被认为处于活跃,更多参考[5.1.7 应用程序事件和监听器](#5.1.7 应用程序事件和监听器)Readiness 状态“Readiness”状态表示应用程序是否已经准备好处理请求。失败的“Readiness”状态表示现在不应该接收流量。这通常发生在启动期间,同时处理CommandLineRunner和ApplicationRunner组件,或者在应用程序认为太忙的时候发生。一旦应用程序和使用命令行调用应用程序被调用,就被认为是“Readiness 状态”。预期在启动期间运行的任务应该由组件CommandLineRunner和ApplicationRunner执行,不是使用 Spring 组件生命周期回调,例如@PostConstruct管理应用程序可用性状态应用程序可用随时通过注入ApplicationAvailability 接口并调用其上的方法来获取其可用性状态。还有的情况是,应用程序希望监听状态更新或者更新应用程序的状态。例如,我们可以将应用程序的“Readiness”状态导出到一个文件中,以便Kubernetes“exec Probe”可以查看该文件:import org.springframework.boot.availability.AvailabilityChangeEvent;
import org.springframework.boot.availability.ReadinessState;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class MyReadinessStateExporter {
@EventListener
public void onStateChange(AvailabilityChangeEvent<ReadinessState> event) {
switch (event.getState()) {
case ACCEPTING_TRAFFIC:
// create file /tmp/healthy
break;
case REFUSING_TRAFFIC:
// remove file /tmp/healthy
break;
}
}
}当应用程序中断并且不能恢复的时候,还可以更新这个应用程序的状态。import org.springframework.boot.availability.AvailabilityChangeEvent;
import org.springframework.boot.availability.LivenessState;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
@Component
public class MyLocalCacheVerifier {
private final ApplicationEventPublisher eventPublisher;
public MyLocalCacheVerifier(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
public void checkLocalCache() {
try {
// ...
}
catch (CacheCompletelyBrokenException ex) {
AvailabilityChangeEvent.publish(this.eventPublisher, ex, LivenessState.BROKEN);
}
}
}Spring Boot 提供[Kubernetes HTTP接口](#11.2.9 Kubernetes Probes),用于Actuator 健康端点的"Liveness" and "Readiness"状态,你能够获取更多指导关于[在Kubernetes上部署应用程序](#12.1.2 Kubernetes)。5.1.7 应用程序事件和监听器除了Spring Framework事件之外,比如ContextRefreshedEvent,SpringApplication 还会发送一些额外的事件。有些事件实际上是在创建ApplicationContext创建之前,因此你不能作为@Bean注册监听器。你能够通过SpringApplication.addListeners(…)方法或者SpringApplicationBuilder.listeners(…)注册。如果希望自动注册这些监听器,可以将监听器添加到META-INF/spring.factories中,使用org.springframework.context.ApplicationListener做为key。运行应用程序的时候,按以下顺序发送事件:ApplicationStartingEvent 在应用程序开始运行时发送(任何处理之前),除了监听器和初始化程序的注册ApplicationEnvironmentPreparedEvent发送,当上下文中要使用的已知Environment时但在创建上下文之前。ApplicationContextInitializedEvent发送,在准备了ApplicationContext并且调用了ApplicationContextInitializers后,但在加载任何bean之前ApplicationPreparedEvent在刷新开始之前,但在加载Bean定义后发送ApplicationStartedEvent在刷新上下文之后,但在任何应用程序和命令行程序被调用之前发送AvailabilityChangeEvent在表示应用程序状态为LivenessState.CORRECT时发送ApplicationReadyEvent在任何应用程序和命令行程序被调用之后发送AvailabilityChangeEvent 在表示应用程序已经做好接收请求准备时发送,状态为ReadinessState.ACCEPTING_TRAFFICApplicationFailedEvent在启动异常时发送上面的列表只包括与SpringApplication相关的SpringApplicationEvent事件。以下事件也在ApplicationPreparedEvent之后和ApplicationStartedEvent之前发送。WebServerInitializedEvent在WebServer准备好后发送,ServletWebServerInitializedEvent和ReactiveWebServerInitializedEvent分别是servlet 和 reactive的变体ContextRefreshedEvent在ApplicationContext刷新后发送事件监听器不应该运行冗长的任务,因为他们默认在同一线程中运行应用程序事件使用Spring Framework的事件发布机制发送。此机制的一部分确保在子上下文中发布给监听器的事件也会在任何祖先上下文中发布给监听器。因此,如果您的应用程序使用SpringApplication实例的层次结构,则监听器可能会收到相同类型的应用程序事件的多个实例。为了允许监听器区分其上下文的事件和后代上下文的事件,它应该请求注入其应用程序上下文,然后将注入的上下文与事件的上下文进行比较。可以通过实现ApplicationContextAware或者如果监听器是bean,使用@Autowired来注入上下文。5.1.8 Web环境SpringApplication会试图创建正确类型的ApplicationContext。用于确定WebApplicationType的算法如下:如果存在 Spring MVC,使用AnnotationConfigServletWebServerApplicationContext如果 Spring MVC 不存在而 Spring WebFlux 存在,使用AnnotationConfigReactiveWebServerApplicationContext否则,使用AnnotationConfigApplicationContext这意味着如果您WebClient在同一应用程序中使用 Spring MVC 和 Spring WebFlux ,则默认情况下将使用 Spring MVC。您可以通过调用setWebApplicationType(WebApplicationType)来覆盖。也可以完全控制ApplicationContext调用所使用的类型setApplicationContextClass(…)。在JUnit测试中使用SpringApplication时,通常需要调用setWebApplicationType(WebApplicationType.NONE)5.1.9 访问应用程序参数如果您需要访问传递给SpringApplication.run(…)的应用程序参数,则可以注入org.springframework.boot.ApplicationArguments bean。ApplicationArguments接口提供对原始String[]参数以及解析的option和non-option参数的访问,如以下示例所示:import java.util.List;
import org.springframework.boot.ApplicationArguments;
import org.springframework.stereotype.Component;
@Component
public class MyBean {
public MyBean(ApplicationArguments args) {
boolean debug = args.containsOption("debug");
List<String> files = args.getNonOptionArgs();
if (debug) {
System.out.println(files);
}
// if run with "--debug logfile.txt" prints ["logfile.txt"]
}
}Spring Boot还注册CommandLinePropertySource和Spring Environment。这使您还可以使用@Value注释注入单个应用程序参数。5.1.10 使用ApplicationRunner 或 CommandLineRunner如果您需要在启动后运行一些特定的代码SpringApplication,您可以实现ApplicationRunner或CommandLineRunner接口。这两个接口以相同的方式工作,并提供一个run方法,该方法在SpringApplication.run(…)完成之前被调用。非常适合在应用程序启动后但在接受请求之前运行的任务这些CommandLineRunner接口提供字符串数组用于访问对应用程序参数,而ApplicationRunner使用ApplicationArguments。以下示例显示了CommandLineRunner一个run方法:import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class MyCommandLineRunner implements CommandLineRunner {
@Override
public void run(String... args) {
// Do something...
}
}如果CommandLineRunner或ApplicationRunner bean必须按顺序调用,可以实现org.springframework.core.Ordered接口或者使用org.springframework.core.annotation.Order注解,5.1.11 应用程序退出每个都SpringApplication向 JVM 注册一个关闭钩子,以确保ApplicationContext在退出时正常关闭。可以使用所有标准的 Spring 生命周期回调(例如DisposableBean接口或@PreDestroy注释)。此外,如果希望在SpringApplication.exit()被调用时返回特定的退出代码,则可以实现该接口org.springframework.boot.ExitCodeGenerator,然后可以将此退出代码传递给System.exit()其将作为状态码返回,如以下示例所示:import org.springframework.boot.ExitCodeGenerator;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class MyApplication {
@Bean
public ExitCodeGenerator exitCodeGenerator() {
return () -> 42;
}
public static void main(String[] args) {
System.exit(SpringApplication.exit(SpringApplication.run(MyApplication.class, args)));
}
}此外,ExitCodeGenerator接口可以通过异常来实现。遇到此类异常时,Spring Boot 会返回已实现getExitCode()方法提供的退出代码。如果存在多个ExitCodeGenerator,则使用生成的第一个非零退出代码。要控制调用生成器的顺序,请另外实现org.springframework.core.Ordered接口或使用org.springfframework.core.annotation.order注解。5.1.12 管理员功能可以通过指定spring.application.admin.enabled属性为应用程序启用与管理相关的功能。这暴露了SpringApplicationAdminMXBean平台上的MBeanServer。您可以使用此功能远程管理您的 Spring Boot 应用程序。此功能也可用于任何服务包装器实现。如果您想知道应用程序在哪个 HTTP 端口上运行,获取local.server.port属性的值。5.1.13 应用程序启动跟踪在应用程序启动期间,SpringApplication执行ApplicationContext许多与应用程序生命周期、bean 生命周期甚至处理应用程序事件相关的任务。有了ApplicationStartupSpring Framework ,您就可以使用StartupStep对象跟踪应用程序启动顺序。可以收集这些数据用于分析目的,或者只是为了更好地了解应用程序启动过程。可以使用setApplicationStartup设置一个实现ApplicationStartup的实例,比如,使用BufferingApplicationStartup的示例:import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.metrics.buffering.BufferingApplicationStartup;
@SpringBootApplication
public class MyApplication {
public static void main(String[] args) {
SpringApplication application = new SpringApplication(MyApplication.class);
application.setApplicationStartup(new BufferingApplicationStartup(2048));
application.run(args);
}
}第一个可用的实现FlightRecorderApplicationStartup由 Spring Framework 提供。它将特定于 Spring 的启动事件添加到 Java Flight Recorder 会话,旨在分析应用程序并将其 Spring 上下文生命周期与 JVM 事件(例如分配、GC、类加载……)相关联。配置完成后,您可以在启用飞行记录器的情况下运行应用程序来记录数据:$ java -XX:StartFlightRecording:filename=recording.jfr,duration=10s -jar demo.jarSpring Boot 附带BufferingApplicationStartup变体;此实现旨在缓冲启动步骤并将它们排入外部指标系统。BufferingApplicationStartup应用程序可以在任何组件中请求类型的 bean 。Spring Boot 还可以配置为公开一个以 JSON 文档形式提供此信息的startup端点。5.2 外部化配置Spring Boot 允许您外部化您的配置,以便您可以在不同的环境中使用相同的应用程序代码。您可以使用各种外部配置源,包括 Java 属性文件、YAML 文件、环境变量和命令行参数。属性值可以通过注解直接注入 bean @Value,通过 Spring 的抽象Environment访问,或者通过@ConfigurationProperties绑定到对象。Spring Boot 使用一种非常特殊的PropertySource顺序,旨在允许合理地覆盖值。后面的属性源可以覆盖前面定义的值。来源按以下顺序考虑:默认properties,由SpringApplication.setDefaultProperties指定在@Configuration上的@PropertySource注解,请注意,在刷新应用程序上下文之前,这些属性源不会添加到Environment中。而logging.* 和 spring.main.* 是在应用程序上下文刷新之前读取。Config 数据,比如application.propertiesRandomValuePropertySource中仅为random.*的配置操作系统环境变量Java系统配置,System.getProperties()来自java:comp/env的JNDI属性ServletContext初始化参数ServletConfig初始化参数来自SPRING_APPLICATION_JSON的属性,嵌入在环境变量(environment variable )或系统属性(system property)中的内联 JSON命令行参数在单元测试中的properties,在@SpringBootTest 和用于测试应用程序特定部分的测试注解上有效。单元测试中的@TestPropertySource在devtools激活下,$HOME/.config/spring-boot目录中的Devtools全局设置配置Config 数据的加载按照以下顺序:打包在jar包中的Application配置,application.properties 和 YAML变体打包在jar包中的application-{profile}.properties和 YAML 变体jar包外的application.properties和 YAML 变体jar包外的application-{profile}.properties和 YAML 变体建议使用一种配置文件格式,如果同时有properties和yaml,properties优先。假设您开发了一个@Component使用name属性的应用程序,如以下示例所示:import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class MyBean {
@Value("${name}")
private String name;
// ...
}在您的应用程序类路径中(例如,在您的 jar 中),您可以有一个application.properties文件为name. 在新环境中运行时,application.properties可以在 jar 之外提供一个文件来覆盖name. 对于一次性测试,您可以使用特定的命令行开关(例如,java -jar app.jar --name="Spring")启动。env和configprops端点可用于确定属性具有特定值的原因。可以使用这两个端点来诊断预期外的属性值。有关详细信息,请参阅"Production ready features"部分。5.2.1 访问命令行属性默认情况下,SpringApplication将任何命令行选项参数(即以 --开头的参数,例如--server.port=9000)转换为property并将它们添加到 SpringEnvironment中,命令行属性始终优先于基于文件的属性源。如果您不想将命令行属性添加到 中Environment,您可以使用SpringApplication.setAddCommandLineProperties(false)禁用它们。5.2.2 JSON 应用程序属性环境变量和系统属性通常有限制,这意味着某些属性名称不能使用。为了解决这个问题,Spring Boot 允许您将属性块编码为单个 JSON 结构。当您的应用程序启动时,任何spring.application.json或SPRING_APPLICATION_JSON属性将被解析并添加到Environment.例如,SPRING_APPLICATION_JSON可以在 UN*X shell 的命令行中将属性作为环境变量提供:$ SPRING_APPLICATION_JSON='{"my":{"name":"test"}}' java -jar myapp.jar在前面的示例中,您最终在 Spring 的Environment中获取my.name=test。同样的 JSON 也可以作为系统属性提供:$ java -Dspring.application.json='{"my":{"name":"test"}}' -jar myapp.jar或者您可以使用命令行参数提供 JSON:$ java -jar myapp.jar --spring.application.json='{"my":{"name":"test"}}'如果要部署到经典应用程序服务器,您还可以使用名为java:comp/env/spring.application.json的JNDI 变量。虽然JSON中的null将添加到结果属性源中,但PropertySourcesPropertyResolver会将null属性视为缺少的值。这意味着JSON不能用null值覆盖低优先级属性源中的属性。5.2.3 外部应用程序属性当您的应用程序启动时,Spring Boot 将自动从以下位置查找并加载application.properties和application.yaml从classpathclasspath根目录classpath 的 /config 包从当前目录当前目录当前目录的config/子目录config/的直接子目录这个列表按顺序排列(较低的项会覆盖较早的项)。加载的文件会做为PropertySources添加到Spring Environment中。如果您不想用application作为配置文件名,您可以通过指定一个spring.config.name环境属性来切换到另一个文件名。例如,要查找myproject.properties和myproject.yaml文件,您可以按如下方式运行您的应用程序:$ java -jar myproject.jar --spring.config.name=myproject您还可以使用spring.config.location环境属性来引用显式位置。此属性接受一个或多个要检查的位置的逗号分隔列表。以下示例显示如何指定两个不同的文件:$ java -jar myproject.jar --spring.config.location=\
optional:classpath:/default.properties,\
optional:classpath:/override.propertiesoptional前缀表示,位置是可选的,允许不存在spring.config.name, spring.config.location, 和spring.config.additional-location很早就用于确定必须加载哪些文件。它们必须定义为环境属性(通常是操作系统环境变量、系统属性或命令行参数)。如果spring.config.location包含目录(而不是文件),应该以/结尾。在运行时,它们将在加载之前附加从spring.config.name生成的名称目录和文件定位也用于 检查 profile指定文件。例如,如果spring.config.location配置为classpath:myconfig.properties,classpath:myconfig-<profile>.properties的文件也会被加载在大多数情况下,spring.config.location您添加的每个项目都将引用一个文件或目录。位置按照它们被定义的顺序处理,后面的可以覆盖前面的值。如果您有一个复杂的位置要设置,并且您使用profile指定的配置文件,那么您可能需要提供进一步的提示,以便Spring Boot知道它们应该如何分组。位置组是所有被认为处于同一级别的位置的集合。例如,您可能希望对所有类路径位置进行分组,然后对所有外部位置进行分组。位置组中的项目用;分隔,详细查看Profile Specific Files使用spring.config.location替换默认的位置配置。例如,如果spring.config.location设置为optional:classpath:/custom-config/,optional:file:./custom-config/,则完整的位置集是:optional:classpath:custom-config/optional:file:./custom-config/如果您更喜欢添加其他位置,而不是替换它们,您可以使用spring.config.additional-location. 从其他位置加载的属性可以覆盖默认位置中的属性。例如,如果spring.config.additional-location配置了值optional:classpath:/custom-config/,optional:file:./custom-config/,则考虑的完整位置集是:optional:classpath:/;optional:classpath:/config/optional:file:./;optional:file:./config/;optional:file:./config/*/optional:classpath:custom-config/optional:file:./custom-config/此搜索顺序使您可以在一个配置文件中指定默认值,然后在另一个配置文件中有选择地覆盖这些值。您可以在默认位置之一的application.properties(或您选择的任何其他基本名称spring.config.name)中为您的应用程序提供默认值。然后可以在运行时使用位于自定义位置之一的不同文件覆盖这些默认值。如果您使用环境变量而不是系统属性,大多数操作系统不允许使用句点分隔的键名,但您可以使用下划线代替(例如,SPRING_CONFIG_NAME代替spring.config.name)。有关详细信息,请参阅从环境变量绑定。如果您的应用程序在 servlet 容器或应用程序服务器中运行,则可以使用 JNDI 属性(在java:comp/env中)或 servlet 上下文初始化参数来代替或同时使用环境变量或系统属性。可选位置默认情况下,当指定的配置数据位置不存在时,Spring Boot 将抛出ConfigDataLocationNotFoundException,并且应用程序将停止。如果需要指定一个位置,但不是必须存在,使用optional:前缀。可以在spring.config.location和spring.config.additional-location以及spring.config.import中声明。比如,spring.config.import属性,值为optional:file:./myconfig.properties,在文件不存在的情况下,应用程序也能够启动。如果你想要忽略所有的ConfigDataLocationNotFoundExceptions异常,并且始终允许应用程序继续启动,可以使用spring.config.on-not-found配置。或者通过SpringApplication.setDefaultProperties(…)或者使用系统/环境变量设置忽略的值。通配符位置定位如果一个配置文件位置路径最后包含*,则表示其为通配符位置。这在多个配置文件的情况下,非常有用。比如,有一些Redis配置和Mysql配置,可以想要把这两个配置文件分开,但又在application.properties文件中,这样可能会有两个不同的路径,/config/redis/application.properties 和/config/mysql/application.properties,通过config/*/可以将两个配置文件都进行加载。默认情况下,Spring Boot在默认搜索位置包含config/*/,这意味着将搜索jar之外的/config目录的所有子目录。您可以将通配符与spring.config.location和spring.config.additional-location一起使用。通配符位置定位只能包含一个*,对于搜索目录必须以*/结尾,对于搜索文件,则必须以*/<filename>结尾。带有通配符的位置根据文件名的绝对路径按字母顺序排序。通配符位置仅适用于外部目录。不能在classpath:location中使用通配符。Profile特定文件除了application属性文件之外,Spring Boot还将尝试使用命名约定application-{profile}加载profile特定文件。例如,如果应用程序激活名为prod的配置文件并使用YAML文件,那么将同时加载application.yml和application-prod.yml。Profile特定文件的属性加载与标准应用程序属性加载的位置相同,profile特定文件总是覆盖非特定的文件(application.yml)。如果指定了多个配置文件,则采用最后获胜策略。例如,如果配置文件 prod 、live 是由spring.profiles.active属性指定的,那么application-prod.properties中的值可以被application-live.properties中的值覆盖。最后获胜策略适用于位置组级别。spring.config.location的classpath:/cfg/,classpath:/ext/配置和classpath:/cfg/;classpath:/ext/配置的覆盖规则不同。例如,继续上面的prod、live示例,我们可能有以下文件:/cfg
application-live.properties
/ext
application-live.properties
application-prod.properties
spring.config.location的值为classpath:/cfg/,classpath:/ext/,程序会先处理/cfg下的所有文件,再处理/ext/cfg/application-live.properties/ext/application-prod.properties/ext/application-live.properties如果值为classpath:/cfg/;classpath:/ext/,程序视为同一级别/ext/application-prod.properties/cfg/application-live.properties/ext/application-live.propertiesEnvironment有一组默认配置文件(默认情况下为[default]),如果未设置活动配置文件,则使用这些配置文件。换句话说,如果没有显式激活配置文件,那么将考虑application-default。配置文件只加载一次。如果您已经直接导入了特定配置文件的属性文件,则不会再次导入该文件。导入附加数据应用程序配置可以使用spring.config.import 属性从其他位置导入更多配置数据。例如,classpath application.properties文件中可能包含以下内容:spring.application.name=myapp
spring.config.import=optional:file:./dev.properties这将触发当前目录中dev.properties文件的导入(如果存在这样的文件)。导入的dev.properties中的值将优先于触发导入的文件。在上面的示例中,dev.properties可以将spring.application.name重新定义为不同的值。无论声明多少次,都只能导入一次。在导入properties/yaml的文件中定义的单个文档顺序是无关紧要的,比如,下面的两个例子产生相同的结果。spring.config.import=my.properties
my.property=valuemy.property=value
spring.config.import=my.properties在上述两个示例中,my.properties文件中的值将优先于触发其导入的文件。可以在一个spring.config.import下指定多个位置,位置将按照定义的顺序进行处理,以后导入的配置优先。适当时,特定配置文件的变体还会导入,上面的示例将导入my.properties以及任何my-<profile>.properties变体。Spring Boot包括可插拔API,允许支持各种不同的位置地址。默认情况下,您可以导入Java配置、YAML和“配置树”。第三方jar可以提供对其他技术的支持(不要求文件是本地的)。例如,您可以想象配置数据来自Consul、Apache ZooKeeper或Netflix Archaius等外部存储。如果要支持自定义位置,请参阅org.springframework.boot.context.config包中的ConfigDataLocationResolver和ConfigDataLoader类。导入无扩展名文件某些云平台无法向卷装载的文件添加文件扩展名。要导入这些无扩展名文件,您需要给Spring Boot一个提示,以便它知道如何加载它们。您可以通过在方括号中放置扩展提示来完成此操作。例如,假设您有一个/etc/config/myconfig文件,希望将其作为yaml导入。您可以使用以下命令从application.properties导入它:spring.config.import=file:/etc/config/myconfig[.yaml]使用配置树在云平台(如Kubernetes)上运行应用程序时,通常需要读取平台提供的配置值。出于这种目的使用环境变量并不罕见,但这可能会有缺点,特别是如果值应该保密的话。作为环境变量的替代方案,许多云平台现在允许您将配置映射到装载的数据卷中。例如,Kubernetes可以卷装载ConfigMaps和Secrets。可以使用两种常见的卷装载模式:单个文件包含一组完整的属性(通常写为 YAML)多个文件被写入目录树,文件名成为“key”,内容成为“value”对于第一种情况,可以上述配置使用spring.config.import导入YAML或Properties文件。对于第二种情况,您需要使用configtree:前缀,以便Spring Boot知道它需要将所有文件公开为Properties。例如,让我们假设Kubernetes安装了以下卷:etc/
config/
myapp/
username
passwordusername 是一个配置的值,password是一个加密字符串要导入这些配置,你可以将如下内容导入application.properties或者application.yamlspring.config.import=optional:configtree:/etc/config/然后,您可以用通常的方式从Environment中访问或注入myapp.username和myapp.password属性。 配置树下的文件夹构成属性名称。在上面的示例中,要以username和password的形式访问属性,可以将spring.config.import设置为optional:configtree:/etc/config/myapp。带有点符号的文件名也正确映射。例如,在上面的示例中,/etc/config中名为myapp.username的文件将在Environment中生成myapp.username属性。配置树值可以绑定到字符串String和byte[]类型,具体取决于预期的内容。如果要从同一父文件夹导入多个配置树,则可以使用通配符快捷方式。任何以/*/结尾的configtree:location都会将所有直接子级作为配置树导入。etc/
config/
dbconfig/
db/
username
password
mqconfig/
mq/
username
password您可以使用configtree:/etc/config/*/作为导入位置:spring.config.import=optional:configtree:/etc/config/*/如上配置将导入 db.username, db.password, mq.username 和 mq.password 属性。使用通配符加载的目录按字母顺序排序。如果您需要不同的排序,则应将每个位置列为单独的导入配置树也可以用于Docker 保密数据。当Docker群服务被授权访问一个保密数据时,该保密数据被装入容器中。例如,如果名为db.password的保密数据安装在位置/run/secrets/,则可以使用以下变量db.passwords在Spring环境中:spring.config.import=optional:configtree:/run/secrets/属性占位符application.properties和application.yml中的值在使用时会通过现有的Environment进行过滤,因此您可以引用以前定义的值(例如,从系统属性或环境变量)。标准的${name}属性占位符语法可以在值的任何位置使用,属性占位符还可以使用:指定默认值,将默认值与属性名称分开,例如${name:default}。以下示例显示了带默认值和不带默认值的占位符的使用:app.name=MyApp
app.description=${app.name} is a Spring Boot application written by ${username:Unknown}您应该始终使用占位符中的规范形式(kebab-case仅使用小写字母)引用占位符中的属性名称。这将允许Spring Boot使用与@ConfigurationProperties相同的宽松绑定逻辑。例如,${demo.item-price}将从application.properties文件中获取demo.iterm-price和demo.itemPrice,并从系统环境中获取DEMO_ITEMPRICE。如果改用${demo.itemPrice},则不会考虑demo.item-price和DEMO_ITEMPRICE。您还可以使用此技术创建现有SpringBoot属性的“短”变体。有关详细信息,请参阅使用“短”命令行参数的方法。使用多文档文件Spring Boot允许您将单个物理文件拆分为多个逻辑文档,每个逻辑文档都是独立添加的。文档按照从上到下的顺序进行处理。后续文档可以覆盖早期文档中定义的配置。对于application.yml文件,使用标准的YAML多文档语法。三个连续的连字符表示一个文档的结尾和下一个文档开始。例如,以下包含两个逻辑文档:spring:
application:
name: "MyApp"
---
spring:
application:
name: "MyCloudApp"
config:
activate:
on-cloud-platform: "kubernetes"application.properties文件使用#---或者!--- 来分割文档spring.application.name=MyApp
#---
spring.application.name=MyCloudApp
spring.config.activate.on-cloud-platform=kubernetes 配置文件分隔符不能有任何前导空格,必须正好有三个连字符。多文档属性文件通常与激活配置(如spring.config.activate.on-profile)结合使用。有关详细信息,请参阅下一节。无法使用@PropertySource或@TestPropertySource注解加载多文档属性文件。激活属性您可能具有仅在特定配置文件处于激活状态时才关联配置。您可以使用spring.config.activate.*有条件地激活配置属性。下面的激活配置可用:PropertyNoteon-profile必须匹配才能激活文档的配置文件表达式on-cloud-platform要使文档处于活动状态,必须检测到“CloudPlatform”例如,下面指定第二个文档仅在Kubernetes上运行时有效,并且仅在“prod”或“staging”配置文件处于激活状态时有效:myprop=always-set
#---
spring.config.activate.on-cloud-platform=kubernetes
spring.config.activate.on-profile=prod | staging
myotherprop=sometimes-set5.2.4 加密属性Spring Boot不提供任何加密属性的内置支持,但它提供了修改Spring环境中包含值所需的钩子点。EnvironmentPostProcessor允许你在应用程序启动的时候控制Environment,详细查看[启动时自定义环境变量](#15. “How-to” 指南)。如果您需要一种安全的方式来存储凭据和密码,Spring Cloud Vault项目将支持在HashiCorp Vault中存储外部化配置。5.2.5 使用YAML文件YAML是JSON的超集,是指定分层配置数据的便捷格式。只要类路径上有SnakeYAML库,SpringApplication类就会自动支持YAML作为properties的替代。YAML 映射到PropertiesYAML文档需要从其分层格式转换为可用于Spring Environment的平面结构。例如,如下的YAML文档:environments:
dev:
url: "https://dev.example.com"
name: "Developer Setup"
prod:
url: "https://another.example.com"
name: "My Cool App"为了从Environment中访问这些属性,它们将按以下方式展平:environments.dev.url=https://dev.example.com
environments.dev.name=Developer Setup
environments.prod.url=https://another.example.com
environments.prod.name=My Cool App同样,YAML列表也需要扁平化,使用[index]做为键,比如下面的YAML。my:
servers:
- "dev.example.com"
- "another.example.com"上述示例转为properties后:my.servers[0]=dev.example.com
my.servers[1]=another.example.com使用[index]表示的properties能够绑定到Java的List或Set对象。有关更多详细信息,请参阅下面的“[类型安全配置属性](#5.2.8 类型安全配置属性)”部分。无法使用@PropertySource或@TestPropertySource注解加载YAML文件。因此,如果需要以这种方式加载值,则需要使用properties文件。直接加载YAMLSpring Framework提供了两个方便类,可用于加载YAML文档。YamlPropertiesFactoryBean将YAML作为Properties加载,YamlMapFactoryBean将YAML作为Map加载。如果要将YAML作为Spring PropertySource加载,也可以使用YamlPropertySourceLoader类。5.2.6 配置随机值RandomValuePropertySource用于注入随机值(例如,注入加密字符或测试用例)。它可以生成integer、long、uuid或string,如下例所示:my.secret=${random.value}
my.number=${random.int}
my.bignumber=${random.long}
my.uuid=${random.uuid}
my.number-less-than-ten=${random.int(10)}
my.number-in-range=${random.int[1024,65536]}random.int*语法是OPEN value (,max) CLOSE,其中OPEN,CLOSE是任何字符,value、max是整数,如果提供了max,则value是最小值,max是最大值(不包括)。5.2.7 配置系统环境属性Spring Boot支持为环境属性设置前缀。如果系统环境由具有不同配置要求的多个Spring Boot应用程序共享,这将非常有用。系统环境属性的前缀可以直接在SpringApplication上设置。例如,如果将前缀设置为input,则诸如remote.timeout之类的属性也将在系统环境中解析为input.remote.timeout。5.2.8 类型安全的配置属性使用@Value("${property}")注入配置属性有时会很麻烦,特别是当您有多个属性或数据本质上是分层的时候。SpringBoot提供了另一种使用properties的方法,该方法允许强类型bean管理和验证应用程序的配置。也可以查看@Value 和 type-safe configuration properties的不同点JavaBean 属性绑定可以绑定到一个标准的JavaBean,如下例所示:import java.net.InetAddress;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("my.service")
public class MyProperties {
private boolean enabled;
private InetAddress remoteAddress;
private final Security security = new Security();
public boolean isEnabled() {
return this.enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public InetAddress getRemoteAddress() {
return this.remoteAddress;
}
public void setRemoteAddress(InetAddress remoteAddress) {
this.remoteAddress = remoteAddress;
}
public Security getSecurity() {
return this.security;
}
public static class Security {
private String username;
private String password;
private List<String> roles = new ArrayList<>(Collections.singleton("USER"));
public String getUsername() {
return this.username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return this.password;
}
public void setPassword(String password) {
this.password = password;
}
public List<String> getRoles() {
return this.roles;
}
public void setRoles(List<String> roles) {
this.roles = roles;
}
}
}前面的POJO定义了以下属性:my.service.enabled,默认为falsemy.service.remote-address,能够强制转成Stringmy.service.security.usernamemy.service.security.passwordmy.service.security.roles String类型的列表,默认是USER映射到Spring Boot中可用的@ConfigurationProperties类的properties是公共API,这些类是通过properties文件、YAML文件、环境变量和其他机制配置的,但类本身的访问器(getters/setters)并不打算直接使用。这种安排依赖于默认的空构造函数,getter和setter通常是强制性的,因为绑定是通过标准的JavaBeans属性描述符进行的,就像在SpringMVC中一样。在下列情况下,可以省略setter:Maps,只要它们被初始化,就需要getter,但不一定需要setter,因为它们可以被绑定器改变。可以通过索引(通常使用 YAML)或使用单个逗号分隔值(属性)来访问集合和数组。在后一种情况下,setter 是强制性的。我们建议始终为此类类型添加一个 setter。如果您初始化一个集合,请确保它不是不可变的(如前例所示)。如果嵌套的 POJO 属性被初始化(如Security前面示例中的字段),则不需要 setter。如果希望绑定器使用其默认构造函数动态创建实例,则需要setter。有些人使用 Project Lombok 来自动添加 getter 和 setter。确保 Lombok 不会为此类类型生成任何特殊的构造函数,因为容器会自动使用它来实例化对象。最后,只考虑标准 Java Bean 属性,不支持绑定静态属性。构造函数绑定上一节中的示例可以以不可变的方式重写,如以下示例所示:import java.net.InetAddress;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.ConstructorBinding;
import org.springframework.boot.context.properties.bind.DefaultValue;
@ConstructorBinding
@ConfigurationProperties("my.service")
public class MyProperties {
private final boolean enabled;
private final InetAddress remoteAddress;
private final Security security;
public MyProperties(boolean enabled, InetAddress remoteAddress, Security security) {
this.enabled = enabled;
this.remoteAddress = remoteAddress;
this.security = security;
}
public boolean isEnabled() {
return this.enabled;
}
public InetAddress getRemoteAddress() {
return this.remoteAddress;
}
public Security getSecurity() {
return this.security;
}
public static class Security {
private final String username;
private final String password;
private final List<String> roles;
public Security(String username, String password, @DefaultValue("USER") List<String> roles) {
this.username = username;
this.password = password;
this.roles = roles;
}
public String getUsername() {
return this.username;
}
public String getPassword() {
return this.password;
}
public List<String> getRoles() {
return this.roles;
}
}
}在此设置中,@ConstructorBinding注解用于指示应使用构造函数绑定。这意味着绑定器将期望找到一个带有您希望绑定的参数的构造函数。如果您使用的是 Java 16 或更高版本,构造函数绑定可以与记录一起使用。在这种情况下,除非您的记录有多个构造函数,否则没有必要使用@ConstructorBinding.类的嵌套成员@ConstructorBinding(如上Security例)也将通过其构造函数进行绑定。可以使用@DefaultValue构造函数参数指定默认值,或者在使用 Java 16 或更高版本时使用记录组件指定默认值。转换服务将用于将String值强制转换为缺失属性的目标类型。参考前面的示例,如果没有属性绑定到Security,则该MyProperties实例将包含 一个null值的security。要使它包含一个非空的实例,Security即使没有属性绑定到它(使用 Kotlin 时,这将需要将 的username和password参数Security声明为可空的,因为它们没有默认值),使用空@DefaultValue注解:public MyProperties(boolean enabled, InetAddress remoteAddress, @DefaultValue Security security) {
this.enabled = enabled;
this.remoteAddress = remoteAddress;
this.security = security;
}要使用构造函数绑定,必须使用@EnableConfigurationProperties或@ConfigurationProperties来启用类。您不能对由常规 Spring 机制创建的 bean 使用构造函数绑定(例如@Componentbean、使用@Bean方法创建的 bean 或使用 @Import加载的 bean)如果您的类有多个构造函数,您也可以在应该绑定的构造函数上直接使用@ConstructorBinding不建议将java.util.Optional与@ConfigurationProperties一起使用,因为它主要用作返回类型。因此,它不太适合配置属性注入。为了与其他类型的属性保持一致,如果您确实声明了一个Optional属性并且它没有值,那么将绑定 null一个空值。启用@ConfigurationProperties注解类型Spring Boot 提供基础设施来绑定@ConfigurationProperties类型并将它们注册为 beans。您可以逐个类地启用配置属性,也可以启用以类似于组件扫描的方式工作的配置属性扫描。有时,带有注解的类@ConfigurationProperties可能不适合扫描,例如,如果您正在开发自己的自动配置或您希望有条件地启用它们。在这些情况下,使用@EnableConfigurationProperties注解指定要处理的类型列表。这可以在任何@Configuration类上完成,如以下示例所示:import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(SomeProperties.class)
public class MyConfiguration {
}要使用配置属性扫描,请将@ConfigurationPropertiesScan注解添加到您的应用程序。通常,它被添加到带有@SpringBootApplication的类中,但它可以添加到任何@Configuration类中。默认情况下,将从声明注解的类的包中进行扫描。如果要定义要扫描的指定包,可以按照以下示例所示进行操作:import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan({ "com.example.app", "com.example.another" })
public class MyApplication {
}当@ConfigurationProperties使用配置属性扫描或通过 @EnableConfigurationProperties注册 bean时,bean 有一个约定名称:<prefix>-<fqn>,其中<prefix>是@ConfigurationProperties中指定的环境键前缀,<fqn>是 bean 的完全限定名称。如果不提供任何前缀,则仅使用 bean 的完全限定名称。上面示例中的 bean 名称是com.example.app-com.example.app.SomeProperties.我们建议@ConfigurationProperties只处理环境,特别是不要从上下文中注入其他 beans。对于极端情况,可以使用 setter 注入或*Aware框架提供的任何接口(例如,EnvironmentAware如果您需要访问Environment)。如果您仍想使用构造函数注入其他 bean,则配置属性 bean 必须注释@Component并使用基于 JavaBean 的属性绑定。使用@ConfigurationProperties 注解类型这种类型的配置在SpringApplication外部YAML配置中尤其适用,如下例所示:my:
service:
remote-address: 192.168.1.1
security:
username: "admin"
roles:
- "USER"
- "ADMIN"要使用@ConfigurationProperties bean,可以与任何其他bean相同的方式注入,如下例所示:import org.springframework.stereotype.Service;
@Service
public class MyService {
private final MyProperties properties;
public MyService(MyProperties properties) {
this.properties = properties;
}
public void openConnection() {
Server server = new Server(this.properties.getRemoteAddress());
server.start();
// ...
}
// ...
}使用@ConfigurationProperties还可以生成元数据文件,IDE可以使用这些文件自动完成自己的密钥。三方配置除了使用@ConfigurationProperties注解类之外,还可以在公共@Bean方法上使用它。当您想将属性绑定到不在您控制范围内的第三方组件时,这样做特别有用。要从Environment中配置bean,请将@ConfigurationProperties添加到其bean注册中,如以下示例所示:import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class ThirdPartyConfiguration {
@Bean
@ConfigurationProperties(prefix = "another")
public AnotherComponent anotherComponent() {
return new AnotherComponent();
}
}使用another前缀定义的属性都以类似于前面的SomeProperties示例的方式映射到该AnotherComponent bean上。宽松绑定Spring Boot使用一些宽松的规则将Environment属性绑定到@ConfigurationProperties bean,因此,Environment属性名称和bean属性名称之间不需要完全匹配。这很有用的常见示例包括以破折号分隔的环境属性(例如,context-path绑定到contextPath),和大写的环境属性(例如,PORT绑定到port)。例如,以下@ConfigurationProperties类:import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "my.main-project.person")
public class MyPersonProperties {
private String firstName;
public String getFirstName() {
return this.firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
}使用上述代码,可以使用以下属性名称:PropertyNotemy.main-project.person.first-name建议在.properties和.yml文件中使用my.main-project.person.firstName标准的驼峰写法my.main-project.person.first_name下划线表示法,建议在.properties和.yml文件中使用MY_MAINPROJECT_PERSON_FIRSTNAME大写格式,建议在系统环境变量中使用注解的前缀值必须是kebab大小写(小写并用-分隔,例如my.main-project.person)。Property SourceSimpleListProperties Files驼峰大小写、kebab小写或下划线符号标准的使用[ ]或者逗号分割值YAML Files驼峰大小写、kebab小写或下划线符号标准的YAML列表或者逗号分割值Environment Variables以下划线作为分隔符的大写格式( Binding From Environment Variables).用下划线包围的数值 (see Binding From Environment Variables)System properties驼峰大小写、kebab小写或下划线符号标准的使用[ ]或者逗号分割值我们建议在可能的情况下,将属性存储为小写的kebab格式,例如my.person.first-name=Rod。绑定 Maps绑定到Map配置时,可能需要使用特殊的括号表示法,以便保留原始键值。如果键未被[]包围,则为非字母数字、-或.任何字符将被移除。例如,以下示例绑定到Map<String,String>:my.map.[/key1]=value1
my.map.[/key2]=value2
my.map./key3=value3
对于YAML文件,括号需要用引号括起来,以便正确解析键。上面的配置将以/key1、/key2和key3作为映射中的键绑定到Map。斜线已从key3中删除,因为它没有被方括号包围。当绑定到标量值时,使用键.其中不需要被[]包围。标量值包括枚举和java.lang包中除Object之外的所有类型。将a.b=c绑定到Map<String, String>将会保留.,并返回包含{"a.b"="c"}项的map。对于任何其他类型,如果键包含.,则需要使用括号表示法。比如,将a.b=c绑定到Map<String, Object>,将返回{"a"={"b"="c"}}项的map,而[a.b]=c将返回{"a.b"="c"}项的map。绑定环境变量大多数操作系统对可用于环境变量的名称施加严格的规则。例如,Linux shell变量只能包含字母(a到z或a到z)、数字(0到9)或下划线字符(_)。按照惯例,Unix shell变量的名称也将以大写字母表示。Spring Boot的宽松绑定规则尽可能与这些命名限制兼容。要将规范形式的属性名称转换为环境变量名称,可以遵循以下规则:将.替换为_移除-转换为大写例如,一个spring.main.log-startup-info属性转换为环境变量后为SPRING_MAIN_LOGSTARTUPINFO。绑定到对象列表时也可以使用环境变量。要绑定到List,元素编号应在变量名称中用下划线包围。例如,一个my.service[0].other转换为环境变量后是MY_SERVICE_0_OTHER。合并复杂类型当在多个位置配置列表时,覆盖通过替换整个列表来工作。例如,假设MyPojo对象的名称和描述属性默认为null。以下示例显示MyProperties中的MyPojo对象列表:import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("my")
public class MyProperties {
private final List<MyPojo> list = new ArrayList<>();
public List<MyPojo> getList() {
return this.list;
}
}可以如下配置:my.list[0].name=my name
my.list[0].description=my description
#---
spring.config.activate.on-profile=dev
my.list[0].name=my another name如果dev未激活,MyProperties.list包含一个MyPojo项,如果dev激活,然而,列表仍然只包含一个条目(名称为my another name,description为null)。此配置不会向列表中添加第二个MyPojo实例,也不会合并项目。当在多个配置文件中指定列表时,将使用优先级最高的配置文件(并且仅使用该配置文件)。my.list[0].name=my name
my.list[0].description=my description
my.list[1].name=another name
my.list[1].description=another description
#---
spring.config.activate.on-profile=dev
my.list[0].name=my another name在前面的示例中,dev激活,MyProperties.list包含一个MyPojo项,name为my another name和description为null。对于YAML,逗号分隔列表和YAML列表都可以用于完全覆盖列表的内容。对于Map属性,可以使用从多个源绘制的属性值进行绑定。但是,对于多个源中的相同属性,将使用具有最高优先级的属性。以下示例,MyProperties公开了一个Map<String, MyPojo>属性。import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("my")
public class MyProperties {
private final Map<String, MyPojo> map = new LinkedHashMap<>();
public Map<String, MyPojo> getMap() {
return this.map;
}
}以下示例配置:my.map.key1.name=my name 1
my.map.key1.description=my description 1
#---
spring.config.activate.on-profile=dev
my.map.key1.name=dev name 1
my.map.key2.name=dev name 2
my.map.key2.description=dev description 2如果dev未激活,MyProperties.map仅包含一个key为key1的项(name是my name 1,description是my description 1)。如果dev激活,将包含两个项目键为key1(name是dev name 1,description是my description 1),键为key2(name是dev name 2,description是my description 2)上述合并规则适用于所有属性源的配置,而不仅仅是文件。属性转换当绑定到@ConfigurationProperties bean时,SpringBoot会尝试将外部应用程序属性强制为正确的类型。如果需要自定义类型转换,可以提供ConversionService bean(带有名为conversionService的bean)或自定义属性编辑器(通过CustomEditorConfigurer bean)或定制Converter(带有@ConfigurationPropertiesBinding注解的bean定义)。由于此bean在应用程序生命周期的早期被请求,请确保限制ConversionService正在使用的依赖关系。通常,您需要的任何依赖项在创建时都可能无法完全初始化。如果配置键不强制需要,并且仅依赖于用@ConfigurationPropertiesBinding限定的自定义转换器,则可能需要重命名自定义ConversionService。转换 DurationsSpring Boot 支持Durations,如果你公开java.time.Duration,应用程序中可以用以下格式:通常使用long描述,如果没有指定@DurationUnit,默认是毫秒java.time.Duration使用的标准ISO-8601格式一种更可读的格式,其中值和单位是耦合的(10s表示10秒)import java.time.Duration;
import java.time.temporal.ChronoUnit;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.convert.DurationUnit;
@ConfigurationProperties("my")
public class MyProperties {
@DurationUnit(ChronoUnit.SECONDS)
private Duration sessionTimeout = Duration.ofSeconds(30);
private Duration readTimeout = Duration.ofMillis(1000);
public Duration getSessionTimeout() {
return this.sessionTimeout;
}
public void setSessionTimeout(Duration sessionTimeout) {
this.sessionTimeout = sessionTimeout;
}
public Duration getReadTimeout() {
return this.readTimeout;
}
public void setReadTimeout(Duration readTimeout) {
this.readTimeout = readTimeout;
}
}
指定会话超时时间30s,PT30S和30s等效,读取超时500ms可以以下列任意形式指定:500、PT0.5S和500ms。您也可以使用任何支持的单位:ns 纳秒us 微秒ms 毫秒s 秒m 分h 小时d 天默认单位为毫秒,可以使用@DurationUnit重写,如上面的示例所示。如果您喜欢使用构造函数绑定,可以公开相同的属性,如以下示例所示:import java.time.Duration;
import java.time.temporal.ChronoUnit;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.ConstructorBinding;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.boot.convert.DurationUnit;
@ConfigurationProperties("my")
@ConstructorBinding
public class MyProperties {
private final Duration sessionTimeout;
private final Duration readTimeout;
public MyProperties(@DurationUnit(ChronoUnit.SECONDS) @DefaultValue("30s") Duration sessionTimeout,
@DefaultValue("1000ms") Duration readTimeout) {
this.sessionTimeout = sessionTimeout;
this.readTimeout = readTimeout;
}
public Duration getSessionTimeout() {
return this.sessionTimeout;
}
public Duration getReadTimeout() {
return this.readTimeout;
}
}如果要升级Long属性,请确保定义单位(使用@DurationUnit)(如果不是毫秒)。这样做可以提供透明的升级路径,同时支持更丰富的格式。转换 Periods除了持续时间,Spring Boot还可以使用java.time.Period类型。应用程序配置中可以使用以下格式:通常使用int描述,默认使用天,除非指定了@PeriodUnitjava.time.Period使用标准的ISO-8601一种更简单的格式,其中值和单位对是耦合的(1y3d表示1年3天)简单格式支持以下单位:y 年m 月w 周d 天java.time.Period类型实际上从未存储周数,它是一个表示“7天”的快捷方式。转换 Data SizesSpring Framework具有以字节表示大小的DataSize值类型,如果你要公开DataSize,以下格式可以使用:通常是long格式,默认使用bytes,除非指定了@DataSizeUnit一种更可读的格式,其中值和单位是耦合的(10MB表示10兆字节)如下示例:import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.convert.DataSizeUnit;
import org.springframework.util.unit.DataSize;
import org.springframework.util.unit.DataUnit;
@ConfigurationProperties("my")
public class MyProperties {
@DataSizeUnit(DataUnit.MEGABYTES)
private DataSize bufferSize = DataSize.ofMegabytes(2);
private DataSize sizeThreshold = DataSize.ofBytes(512);
public DataSize getBufferSize() {
return this.bufferSize;
}
public void setBufferSize(DataSize bufferSize) {
this.bufferSize = bufferSize;
}
public DataSize getSizeThreshold() {
return this.sizeThreshold;
}
public void setSizeThreshold(DataSize sizeThreshold) {
this.sizeThreshold = sizeThreshold;
}
}
要指定10兆字节的缓冲区大小,10和10MB同等。256字节的大小阈值可以指定为256或256B。您也可以使用任何支持的单位。这些是:B bytesKB kilobytesMB megabytesGB gigabytesTB terabytes默认单位是字节,可以使用@DataSizeUnit重写,如上面的示例所示。如果您喜欢使用构造函数绑定,可以公开相同的属性,如以下示例所示:import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.ConstructorBinding;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.boot.convert.DataSizeUnit;
import org.springframework.util.unit.DataSize;
import org.springframework.util.unit.DataUnit;
@ConfigurationProperties("my")
@ConstructorBinding
public class MyProperties {
private final DataSize bufferSize;
private final DataSize sizeThreshold;
public MyProperties(@DataSizeUnit(DataUnit.MEGABYTES) @DefaultValue("2MB") DataSize bufferSize,
@DefaultValue("512B") DataSize sizeThreshold) {
this.bufferSize = bufferSize;
this.sizeThreshold = sizeThreshold;
}
public DataSize getBufferSize() {
return this.bufferSize;
}
public DataSize getSizeThreshold() {
return this.sizeThreshold;
}
}如果要升级Long属性,请确保定义单位(使用@DataSizeUnit)(如果不是字节)。这样做可以提供透明的升级路径,同时支持更丰富的格式。@ConfigurationProperties 验证当@ConfigurationProperties类被Spring的@Validated注解注释时,Spring Boot会尝试验证它们。您可以直接在配置类上使用JSR-303 javax.validation约束注释。要做到这一点,请确保类路径上有一个兼容的JSR-303实现,然后向字段添加约束注解,如下例所示:import java.net.InetAddress;
import javax.validation.constraints.NotNull;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
@ConfigurationProperties("my.service")
@Validated
public class MyProperties {
@NotNull
private InetAddress remoteAddress;
public InetAddress getRemoteAddress() {
return this.remoteAddress;
}
public void setRemoteAddress(InetAddress remoteAddress) {
this.remoteAddress = remoteAddress;
}
}
您还可以通过注释@Bean方法来触发验证,该方法使用@Validated创建配置属性。为了确保始终为嵌套属性触发验证,即使找不到,也必须用@Valid注解相关字段。以下示例基于前面的MyProperties示例:import java.net.InetAddress;
import javax.validation.Valid;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
@ConfigurationProperties("my.service")
@Validated
public class MyProperties {
@NotNull
private InetAddress remoteAddress;
@Valid
private final Security security = new Security();
public InetAddress getRemoteAddress() {
return this.remoteAddress;
}
public void setRemoteAddress(InetAddress remoteAddress) {
this.remoteAddress = remoteAddress;
}
public Security getSecurity() {
return this.security;
}
public static class Security {
@NotEmpty
private String username;
public String getUsername() {
return this.username;
}
public void setUsername(String username) {
this.username = username;
}
}
}您还可以通过创建名为configurationPropertiesValidator的bean定义来添加自定义Spring Validator。@Bean方法应声明为静态。配置属性验证器是在应用程序生命周期的早期创建的,将@Bean方法声明为static创建Bean,而无需实例化@configuration类。这样做可以避免早期实例化可能导致的任何问题。spring-boot-actuator包括一个端点,它公开所有@ConfigurationProperties bean。将web浏览器指向/actuator/configprops或使用等效的JMX端点。有关详细信息,请参阅“[生产就绪功能](#11. 生产就绪功能)”部分。@ConfigurationProperties vs. @Value@Value注解是一个核心容器功能,它提供的功能与类型安全配置属性不同。下表总结了@ConfigurationProperties和@Value支持的功能:Feature@ConfigurationProperties@Value宽松绑定YesLimited (see note below)元数据支持YesNoSpEL 表达式NoYes如果您确实想使用@Value,我们建议您使用规范形式引用属性名称(kebab-case仅使用小写字母)。这将允许Spring Boot与使用宽松绑定的@ConfigurationProperties相同的逻辑。例如,@Value(“${demo.item-price}”)将从application.properties文件中获取demo.iitem-price和demo.itermPrice表单,并从系统环境中获取DEMO_ITEMPRICE。如果改用@Value(“${demo.itemPrice}”),则不会考虑demo.item-price和DEMO_ITEMPRICE。如果您为自己的组件定义了一组配置键,我们建议您将它们分组到带有@ConfigurationProperties注释的POJO中。这样做将为您提供结构化的类型安全对象,您可以将其注入到自己的bean中。在解析这些文件并填充环境时,不会处理应用程序属性文件中的SpEL表达式。但是,可以在@Value中编写SpEL表达式。如果应用程序属性文件中的属性值是SpEL表达式,则在通过@value使用时将对其求值。5.3 ProfilesSpring profiels 提供了一种隔离应用程序配置部分的方法,使其仅在特定环境中可用。任何@Component、@Configuration或@ConfigurationProperties都可以用@Profile标记加以限制,如下例所示:import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@Configuration(proxyBeanMethods = false)
@Profile("production")
public class ProductionConfiguration {
// ...
}如果@ConfigurationProperties bean是通过@EnableConfigurationProperties而不是自动扫描注册的,则需要在具有@EnableConfigurationProperty注解的@Configuration类上指定@Profile注解。在扫描@ConfigurationProperties的情况下,可以在@ConfigurationProperties类本身上指定@Profile。可以使用spring.profiles.active Environment属性来指定哪些配置文件处于活动状态。您可以用本章前面描述的任何方式指定属性,例如,您可以将其包含在application.properties中,如下例所示:spring.profiles.active=dev,hsqldb也可以使用以下开关在命令行上指定它:--spring.profiles.active=dev,hsqldb。如果没有激活配置文件,则启用默认配置文件。默认配置文件的名称是默认的,可以使用spring.profile.default Environment属性对其进行调整,如下例所示:spring.profiles.default=nonespring.profiles.active和spring.profiles.default只能在非配置文件特定的文档中使用。这意味着它们不能包含在spring.config.activate.on-profile激活的特定配置文件的文件或激活属性中。例如,如下配置无效:# this document is valid
spring.profiles.active=prod
#---
# this document is invalid
spring.config.activate.on-profile=prod
spring.profiles.active=metrics5.3.1 添加活动配置文件spring.profiles.active属性遵循与其他属性相同的排序规则。PropertySource优先级最高,这意味着您可以在application.properties中指定活动配置文件,然后使用命令行开关替换它们。有时,将配置添加到活动配置文件而不是替换它们是很有用的。spring.profiles.include属性可用于在spring.profiles.active属性激活的配置文件之上添加活动配置文件。SpringApplication入口点还具有用于设置其他配置文件的Java API,请参阅SpringApplication中的setAdditionalProfiles()方法。例如,当运行以下配置的应用程序时,即使使用-spring.profiles.active 开关运行,也会激活common和local配置文件:spring.profiles.include[0]=common
spring.profiles.include[1]=local与spring.profile.active类似,spring.profile.include只能用于非配置文件特定的文档。如果给定的配置文件处于活动状态,则也可以使用配置文件组(在下一节中介绍)添加活动的配置文件。5.3.2 配置文件组有时,您在应用程序中定义和使用的配置文件过于细粒度,使用起来很麻烦。例如,您可以使用proddb和prodmq配置文件来独立启用数据库和消息传递功能。为了帮助实现这一点,Spring Boot允许您定义配置文件组。配置文件组允许您定义相关配置文件组的逻辑名称。例如,我们可以创建一个由prodb和prodmq配置文件组成的生产组。spring.profiles.group.production[0]=proddb
spring.profiles.group.production[1]=prodmq我们的应用程序现在可以使用--spring.profiles.active=production启动,一次激活production、proddb和prodmq配置文件。5.3.3 以编程方式设置配置文件您可以在应用程序运行之前通过调用SpringApplication.setAdditionalProfiles(...),还可以使用Spring的ConfigurationEnvironment接口激活配置文件。5.3.4 指定配置文件application.properties(或application.yml)和通过@ConfigurationProperties引用的文件的特定配置文件的变体都被视为文件并被加载。有关详细信息,请参阅“Profile特定文件”。5.4 日志Spring Boot使用Commons Logging进行所有内部日志记录,但底层日志实现保持打开状态。默认配置提供了Java Util Logging, Log4J2, 和 Logback。在每种情况下,记录器都预先配置为使用控制台输出,也可以使用可选的文件输出。默认情况下,如果使用“Starters”,则使用Logback进行日志记录。还包括适当的Logback路由,以确保使用Java Util Logging、Commons Logging、Log4J或SLF4J的依赖库都能正常工作。Java有很多可用的日志框架。如果上面的列表看起来令人困惑,请不要担心。通常,您不需要更改日志依赖关系,Spring Boot默认值也可以正常工作。当您将应用程序部署到servlet容器或应用程序服务器时,使用JavaUtil Logging API执行的日志记录不会路由到应用程序的日志中。这将防止容器或已部署到容器的其他应用程序执行的日志记录出现在应用程序的日志中。5.4.1 日志格式化Spring Boot的默认日志输出类似于以下示例:2023-01-19 14:18:28.678 INFO 16676 --- [ main] o.s.b.d.f.s.MyApplication : Starting MyApplication using Java 1.8.0_362 on myhost with PID 16676 (/opt/apps/myapp.jar started by myuser in /opt/apps/)
2023-01-19 14:18:28.686 INFO 16676 --- [ main] o.s.b.d.f.s.MyApplication : No active profile set, falling back to 1 default profile: "default"
2023-01-19 14:18:30.656 INFO 16676 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8080 (http)
2023-01-19 14:18:30.672 INFO 16676 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2023-01-19 14:18:30.672 INFO 16676 --- [ main] org.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/9.0.71]
2023-01-19 14:18:30.756 INFO 16676 --- [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
2023-01-19 14:18:30.757 INFO 16676 --- [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 1977 ms
2023-01-19 14:18:31.328 INFO 16676 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path ''
2023-01-19 14:18:31.339 INFO 16676 --- [ main] o.s.b.d.f.s.MyApplication : Started MyApplication in 3.552 seconds (JVM running for 4.101)输出以下项目:日期和时间:毫秒级精度和易于排序的Log级别:ERROR, WARN, INFO, DEBUG, 或者 TRACE进程ID— 分隔符,用于区分实际的日志消息开头线程名称:用方括号括起来(可能会被控制台输出截断)Logger 名称:通常是源类名(通常是缩写)日志消息Logback没有FATAL级别。它被映射到ERROR。5.4.2 Console 输出默认日志配置在写入消息时将消息回显到控制台。默认情况下,记录ERROR级别、WARN级别和INFO级别消息。您还可以通过使用--debug标志启动应用程序来启用“调试”模式。$ java -jar myapp.jar --debug您还可以在application.properties中指定debug=true。启用调试模式后,将配置一组核心记录器(嵌入式容器、Hibernate和Spring Boot)以输出更多信息。启用调试模式使用debug级别不会将应用程序配置为记录所有消息。或者,您可以通过使用--trace标志(或应用程序配置中的trace=true)来启动应用程序“trace”模式,这样做可以为一些核心记录器(嵌入式容器、Hibernate模式生成和整个Spring组合)启用跟踪日志记录。彩色输出如果您的终端支持ANSI,则使用颜色输出来提高可读性。您可以将spring.output.ansi.enabled设置为支持的值,以覆盖自动检测。通过使用%clr转换字配置颜色编码。在最简单的形式中,转换器根据日志级别为输出着色,如下例所示:%clr(%5p)下表描述了日志级别到颜色的映射:LevelColorFATALRedERRORRedWARNYellowINFOGreenDEBUGGreenTRACEGreen或者,您可以通过将其作为转换选项来指定应使用的颜色或样式。例如,要使文本变为黄色,请使用以下设置:%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){yellow}支持以下颜色和样式:bluecyanfaintgreenmagentaredyellow5.4.3 文件输出默认情况下,Spring Boot只记录到控制台,不写入日志文件。如果要在控制台输出之外写入日志文件,你需要设置logging.file.name或者logging.file.path。下表显示了logging.*如何一起使用:logging.file.namelogging.file.pathExampleDescription(none)(none)仅仅在控制台输出Specific file(none)my.log写入指定的日志文件。名称可以是确切的位置或相对于当前目录。(none)Specific directory/var/log将spring.log写入指定目录。名称可以是确切的位置或相对于当前目录。日志文件在达到10MB时会重新开始写入,与控制台输出一样,默认情况下会记录ERROR级别、WARN级别和INFO级别的消息。日志记录配置独立于实际的日志记录基础结构。因此,特定的配置键(如logback.configurationFile for logback)不会由springBoot管理。5.4.4 文件周期如果使用Logback,则可以使用application.properties或application.yaml文件微调日志周期设置。对于所有其他日志记录系统,您需要自己直接配置周期设置(例如,如果使用Log4j2,则可以添加Log4j2.xml或Log4j2-pring.xml文件)。支持以下周期性配置:NameDescriptionlogging.logback.rollingpolicy.file-name-pattern用于创建日志存档的文件名模式。logging.logback.rollingpolicy.clean-history-on-start应用程序启动时应进行日志存档清理。logging.logback.rollingpolicy.max-file-size存档前日志文件的最大大小。logging.logback.rollingpolicy.total-size-cap删除日志存档文件之前可以使用的最大大小。logging.logback.rollingpolicy.max-history要保留的存档日志文件的最大数量(默认为7)。5.4.5 日志级别所有支持的日志记录系统都可以在Spring环境中设置日志记录程序级别(例如,在application.properties中),通过使用logging.level.<logger-name>=<level>,其中级别是TRACE、DEBUG、INFO、WARN、ERROR、FATAL或OFF之一。root日志记录程序可以通过使用logging.level.root进行配置。如下是application.properties中的日志配置:logging.level.root=warn
logging.level.org.springframework.web=debug
logging.level.org.hibernate=error还可以使用环境变量设置日志记录级别。例如,LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_WEB=DEBUG将设置org.springframework.web为DEBUG。上述方法仅适用于包级日志记录。由于宽松绑定总是将环境变量转换为小写,因此不可能以这种方式为单个类配置日志记录。如果需要给类配置日志,你可以使用SPRING_APPLICATION_JSON 。5.4.6 日志组能够将相关的记录器分组在一起,以便可以同时配置它们,这通常很有用。例如,您可能通常会更改所有Tomcat相关记录器的日志记录级别,但您不容易记住顶级包。为了帮助实现这一点,Spring Boot允许您在Spring环境中定义日志组。例如,以下是如何通过将“tomcat”组添加到application.properties:logging.group.tomcat=org.apache.catalina,org.apache.coyote,org.apache.tomcat定义后,您可以使用单行更改组中所有记录器的级别:logging.level.tomcat=traceSpring Boot包括以下预定义的日志记录组,可以立即使用:NameLoggersweborg.springframework.core.codec, org.springframework.http, org.springframework.web, org.springframework.boot.actuate.endpoint.web, org.springframework.boot.web.servlet.ServletContextInitializerBeanssqlorg.springframework.jdbc.core, org.hibernate.SQL, org.jooq.tools.LoggerListener5.4.7 使用日志 ShutDown 钩子为了在应用程序终止时释放日志记录资源,提供了在JVM退出时触发日志系统清理的关闭挂钩。除非将应用程序部署为war文件,否则会自动注册此关闭挂钩。如果应用程序具有复杂的上下文层次结构,则关闭挂钩可能无法满足您的需要。如果没有,请禁用关闭挂钩并调查底层日志系统直接提供的选项。例如,Logback提供了上下文选择器,允许在自己的上下文中创建每个Logger。你可以使用logging.register-shutdown-hook关闭钩子,设置false关闭注册。logging.register-shutdown-hook=false5.4.8 可以通过在类路径中包含适当的库来激活各种日志记录系统,并且可以通过在路径的根目录中或在以下Spring Environment属性指定的位置提供适当的配置文件来进一步定制:logging.config。您可以强制Spring Boot使用特定的日志记录系统,使用org.springframework.boot.logging.LoggingSystem。该值应该是LoggingSystem实现的完全限定类名。您还可以使用值none完全禁用Spring Boot的日志记录配置。由于日志记录是在创建ApplicationContext之前初始化的,因此无法从Spring@Configuration文件中的@PropertySources控制日志记录。更改日志记录系统或完全禁用日志记录系统的唯一方法是通过系统配置。根据您的日志记录系统,将加载以下文件:Logging SystemCustomizationLogbacklogback-spring.xml, logback-spring.groovy, logback.xml, or logback.groovyLog4j2log4j2-spring.xml or log4j2.xmlJDK (Java Util Logging)logging.properties如果可能,我们建议您在日志配置中使用-spring变量(例如,logback-spring.xml而不是logback.xml)。如果您使用标准配置位置,spring无法完全控制日志初始化。Java Util Logging存在已知的类加载问题,在从“可执行jar”运行时会导致问题。我们建议您在从“可执行jar”运行时尽可能避免使用它。为了帮助定制,提供了一些其他配置从Spring环境传输到系统配置,如下表所述:Spring EnvironmentSystem PropertyCommentslogging.exception-conversion-wordLOG_EXCEPTION_CONVERSION_WORD记录异常时使用的转换字。logging.file.nameLOG_FILE如果已定义,则在默认日志配置中使用。logging.file.pathLOG_PATH如果已定义,则在默认日志配置中使用。logging.pattern.consoleCONSOLE_LOG_PATTERN要在控制台上使用的日志模式(stdout)。logging.pattern.dateformatLOG_DATEFORMAT_PATTERN日志日期格式的追加模式。logging.charset.consoleCONSOLE_LOG_CHARSET用于控制台日志记录的字符集。logging.pattern.fileFILE_LOG_PATTERN要在文件中使用的日志模式(如果启用了“LOG_FILE”)。logging.charset.fileFILE_LOG_CHARSET用于文件日志记录的字符集(如果启用了“LOG_FILE”)。logging.pattern.levelLOG_LEVEL_PATTERN呈现日志级别时使用的格式(默认为“%5p”)。PIDPID当前进程ID(如果可能,并且尚未定义为操作系统环境变量时发现)。如果使用Logback,还将传输以下配置:Spring EnvironmentSystem PropertyCommentslogging.logback.rollingpolicy.file-name-patternLOGBACK_ROLLINGPOLICY_FILE_NAME_PATTERN滚动的日志文件名模式 (默认${LOG_FILE}.%d{yyyy-MM-dd}.%i.gz).logging.logback.rollingpolicy.clean-history-on-startLOGBACK_ROLLINGPOLICY_CLEAN_HISTORY_ON_START是否在启动时清除存档日志文件。logging.logback.rollingpolicy.max-file-sizeLOGBACK_ROLLINGPOLICY_MAX_FILE_SIZE最大日志文件大小。logging.logback.rollingpolicy.total-size-capLOGBACK_ROLLINGPOLICY_TOTAL_SIZE_CAP要保留的日志备份的总大小。logging.logback.rollingpolicy.max-historyLOGBACK_ROLLINGPOLICY_MAX_HISTORY要保留的存档日志文件的最大数量。所有受支持的日志记录系统在解析其配置文件时都可以参考系统配置。有关示例,请参见spring-bot.jar中的默认配置:LogbackLog4j 2Java Util logging如果要在日志属性中使用占位符,则应使用Spring Boot的语法,而不是底层框架的语法。值得注意的是,如果使用Logback,则应使用:作为属性名称与其默认值之间的分隔符,而不要使用:-。通过仅覆盖LOG_LEVEL_PATTERN(或Logback 的 logging.pattern.level),可以将MDC和其他特殊内容添加到日志行。如你使用logging.pattern.level=user:%X{user} %5p,那么默认日志格式包含“user”的MDC条目(如果存在),如下例所示。2019-08-30 12:30:04.031 user:someone INFO 22174 --- [ nio-8080-exec-0] demo.Controller
Handling authenticated request
5.4.9 Logback扩展Spring Boot包括许多Logback扩展,可以帮助进行高级配置。您可以在logback-spring.xml配置文件中使用这些扩展名。因为标准logback.xml配置文件很早就加载,您需要使用logback-spring.xml或定义logging.config属性扩展不能用于Logback的配置扫描。如果尝试这样做,则对配置文件进行更改会导致类似以下错误会被记录:ERROR in ch.qos.logback.core.joran.spi.Interpreter@4:71 - no applicable action for [springProperty], current ElementPath is [[configuration][springProperty]]
ERROR in ch.qos.logback.core.joran.spi.Interpreter@4:71 - no applicable action for [springProfile], current ElementPath is [[configuration][springProfile]]Profile指定配置<springProfile>标记允许您根据激活的Spring配置文件选择性地包括或排除配置部分。<configuration>元素中的任何位置都支持配置文件部分。使用name属性指定哪个配置文件接受配置。<springProfile>标记可以包含profile文件名称(例如staging)或profile文件表达式。profile表达式允许表达更复杂的逻辑,例如,production & (eu-central | eu-west),查看 Spring Framework指南查看详细信息。以下列表显示了三个示例配置文件:<springProfile name="staging">
<!-- configuration to be enabled when the "staging" profile is active -->
</springProfile>
<springProfile name="dev | staging">
<!-- configuration to be enabled when the "dev" or "staging" profiles are active -->
</springProfile>
<springProfile name="!production">
<!-- configuration to be enabled when the "production" profile is not active -->
</springProfile>环境属性配置<springProperty>标记允许您从Spring环境中公开配置,以便在Logback中使用。如果您想从Logback配置中的访问application.properties文件的值,那么这样做很有用。该标记的工作方式与Logback的标准<property>标记类似。可以指定属性的source(从环境中),而不是指定直接值。如果您需要将属性存储在local范围以外的其他位置,你可以使用scope属性。如果需要回退值(为在Environment环境中设置),你可以使用defaultValue属性,以下示例显示如何公开配置以在Logback中使用:<springProperty scope="context" name="fluentHost" source="myapp.fluentd.host"
defaultValue="localhost"/>
<appender name="FLUENT" class="ch.qos.logback.more.appenders.DataFluentAppender">
<remoteHost>${fluentHost}</remoteHost>
...
</appender>source必须使用kebab格式指定(例如my.property-name)。然而,可以使用宽松的规则将属性添加到Environment环境中。5.4.10 Log4j2 扩展Spring Boot包括对Log4j2的许多扩展,可以帮助进行高级配置,您可以在任何log4j2-spring.xml配置文件中使用这些扩展。由于标准log4j2.xml配置文件加载得太早,因此不能在其中使用扩展。您需要使用log4j2-spring.xml或定义logging.config属性。这些扩展取代了Log4J提供的Spring Boot支持。您应该确保在构建中不包含org.apache.logging.log4j:log4j-spring-boot模块。Profile 指定配置<springProfile>标记允许您根据激活的Spring配置文件选择性地包括或排除配置部分。<configuration>元素中的任何位置都支持配置文件部分。使用name属性指定哪个配置文件接受配置。<springProfile>标记可以包含profile文件名称(例如staging)或profile文件表达式。profile表达式允许表达更复杂的逻辑,例如,production & (eu-central | eu-west),查看 Spring Framework指南查看详细信息。以下列表显示了三个示例配置文件:<SpringProfile name="staging">
<!-- configuration to be enabled when the "staging" profile is active -->
</SpringProfile>
<SpringProfile name="dev | staging">
<!-- configuration to be enabled when the "dev" or "staging" profiles are active -->
</SpringProfile>
<SpringProfile name="!production">
<!-- configuration to be enabled when the "production" profile is not active -->
</SpringProfile>环境属性查找如果您想在Log4j2配置中引用Spring环境中的属性,可以使用spring:前缀查找。如果您想访问Log4j2配置中application.properties文件中的值,那么这样做很有用。以下示例显示如何设置名为applicationName的Log4j2属性,该属性从spring环境中读取spring.application.name:<Properties>
<Property name="applicationName">${spring:spring.application.name}</Property>
</Properties>查找关键字应该以kebab 格式(例如 my.property-name)。Log4j2 系统配置Log4j2支持许多可用于配置各种项目的系统配置。log4j2.skipJansi系统属性可用于配置ConsoleAppender是否将尝试在Windows上使用Jansi输出流。Log4j2初始化后加载的所有系统配置都可以从Spring环境中获得。例如,可以将log4j2.skipJansi=false添加到application.properties文件中,以便ConsoleAppender在Windows上使用Jansi。只有当系统配置和操作系统环境变量不包含加载的值时,才考虑Spring Environment环境。5.5 国际化Spring Boot支持本地化消息,以便您的应用程序能够迎合不同语言需求的用户。默认情况下,Spring Boot会在类路径的根位置查找messages资源包。当配置的资源束的默认配置文件可用时(默认情况下为messages.properties),将应用自动配置。如果资源包只包含特定于语言的配置文件,则需要添加默认值。如果没有找到与任何配置的基本名称匹配的配置文件,则不会有自动配置的MessageSource。可以使用spring.messages命名空间配置资源包的基本名称以及其他几个属性,如下例所示:spring.messages.basename=messages,config.i18n.messages
spring.messages.fallback-to-system-locale=falsespring.messages.basename支持逗号分隔的位置列表,可以是包限定符,也可以是从类路径根解析的资源。有关更多支持的选项,请参阅MessageSourceProperties。5.6 JSONSpring Boot提供了与三个JSON映射库的集成:GsonJacksonJSON-BJackson是首选和默认库。5.6.1 Jackson提供了Jackson的自动配置,Jackson是spring-boot-starter-json的一部分。当Jackson在类路径上时,会自动配置ObjectMapper bean。提供了几个配置,用于自定义ObjectMapper的配置。自定义序列化和反序列化如果使用Jackson序列化和反序列化JSON数据,您可能需要编写自己的JsonSerializer和JsonDeserializer类。自定义序列化,通常使用模块化注册。Spring Boot提供了另一种@JsonComponent注解,使直接注册Spring Beans变得更容易。你能使用@JsonComponent注解在JsonSerializer, JsonDeserializer 或KeyDeserializer 实现上。您还可以在包含序列化程序/反序列化程序作为内部类的类上使用它,如下例所示:import java.io.IOException;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.ObjectCodec;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import org.springframework.boot.jackson.JsonComponent;
@JsonComponent
public class MyJsonComponent {
public static class Serializer extends JsonSerializer<MyObject> {
@Override
public void serialize(MyObject value, JsonGenerator jgen, SerializerProvider serializers) throws IOException {
jgen.writeStartObject();
jgen.writeStringField("name", value.getName());
jgen.writeNumberField("age", value.getAge());
jgen.writeEndObject();
}
}
public static class Deserializer extends JsonDeserializer<MyObject> {
@Override
public MyObject deserialize(JsonParser jsonParser, DeserializationContext ctxt) throws IOException {
ObjectCodec codec = jsonParser.getCodec();
JsonNode tree = codec.readTree(jsonParser);
String name = tree.get("name").textValue();
int age = tree.get("age").intValue();
return new MyObject(name, age);
}
}
}ApplicationContext中的所有@JsonComponent bean都会自动向Jackson注册。因为@JsonComponent是用@Component元注释的,所以通常的组件扫描规则适用。Spring Boot还提供了JsonObjectSerializer和JsonObjectDeserializer基类,这些基类在序列化对象时为标准Jackson版本提供了有用的替代方案。有关详细信息,请参阅Javadoc中的JsonObjectSerializer和JsonObjectDeserializer。上面的示例可以重写为使用JsonObjectSerializer/JsonObjectDeserializer,如下所示:import java.io.IOException;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.ObjectCodec;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.SerializerProvider;
import org.springframework.boot.jackson.JsonComponent;
import org.springframework.boot.jackson.JsonObjectDeserializer;
import org.springframework.boot.jackson.JsonObjectSerializer;
@JsonComponent
public class MyJsonComponent {
public static class Serializer extends JsonObjectSerializer<MyObject> {
@Override
protected void serializeObject(MyObject value, JsonGenerator jgen, SerializerProvider provider)
throws IOException {
jgen.writeStringField("name", value.getName());
jgen.writeNumberField("age", value.getAge());
}
}
public static class Deserializer extends JsonObjectDeserializer<MyObject> {
@Override
protected MyObject deserializeObject(JsonParser jsonParser, DeserializationContext context, ObjectCodec codec,
JsonNode tree) throws IOException {
String name = nullSafeValue(tree.get("name"), String.class);
int age = nullSafeValue(tree.get("age"), Integer.class);
return new MyObject(name, age);
}
}
}混合Jackson支持混合可以用于将附加注释混合到目标类上已经声明的注释中。Spring Boot的Jackson自动配置将扫描应用程序的包,查找用@JsonMixin注释的类,并将它们注册到自动配置的ObjectMapper中。注册由Spring Boot的JsonMixinModule执行。5.6.2 Gson提供了Gson的自动配置。当Gson在类路径上时,会自动配置Gson bean。提供了几个spring.gson.*配置来定制配置。要获得更多控制,可以使用一个或多个GsonBuilderCustomizer bean。5.6.3 JSON-B提供JSON-B的自动配置。当JSON-B API和实现位于类路径上时,将自动配置Jsonb bean。首选的JSON-B实现是Eclipse Yasson,它提供了依赖性管理。5.7 任务执行和调度在上下文中缺少Executor bean的情况下,Spring Boot 自动配置ThreadPoolTaskExecutor,并使用可自动关联到异步任务执行(@EnableAsync)和Spring MVC异步请求处理的合理默认值。常规任务执行(即@EnableAsync)将透明地使用它,但不会配置Spring MVC支持,因为它需要AsyncTaskExecutor实现(名为applicationTaskExecutor)。根据您的目标安排,您可以将Executor更改为ThreadPoolTaskExecutor,或者同时定义ThreadPoolTaskExecutor和AsyncConfigurer来包装自定义Executor。自动配置的TaskExecutorBuilder允许您轻松创建复制默认情况下自动配置的实例。线程池使用8个核心线程,可以根据负载增长和收缩。可以使用spring.task.execution命名空间对这些默认设置进行微调,如下例所示:spring.task.execution.pool.max-size=16
spring.task.execution.pool.queue-capacity=100
spring.task.execution.pool.keep-alive=10s这将更改线程池以使用有界队列,这样当队列已满(100个任务)时,线程池将增加到最多16个线程。当线程空闲10秒(而不是默认情况下的60秒)时,会回收线程,因此池的收缩更为积极。如果需要将ThreadPoolTaskScheduler与计划的任务执行相关联(例如使用@EnableScheduling),也可以自动配置ThreadPoolTaskScheduler。线程池默认使用一个线程,其设置可以使用spring.task.scheduling进行微调,如下例所示:spring.task.scheduling.thread-name-prefix=scheduling-
spring.task.scheduling.pool.size=2如果需要创建自定义执行器或调度程序,则在上下文中可以使用TaskExecutorBuilder bean和TaskSchedulerBuilder bean。笔者注:TaskExecutorBuilder 和 TaskSchedulerBuilder 能通过Bean 注入,最终创建的Bean 为 ThreadPoolTaskExecutor5.8 测试Spring Boot提供了许多实用程序和注解,以帮助测试应用程序。测试支持由两个模块提供:spring-boot-test包含核心项目,spring-boot-test-autoconfigure支持自动配置。大多数开发者使用spring-boot-starter-test开始,它导入了两个Spring Boot测试模块以及JUnit Jupiter、AssertJ、Hamcrest和许多其他有用的库。如果您有使用JUnit4的测试,可以使用JUnit5引擎来运行它们,按以下示例添加junit-vintage-engine依赖<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.hamcrest</groupId>
<artifactId>hamcrest-core</artifactId>
</exclusion>
</exclusions>
</dependency>5.8.1 测试范围依赖spring-boot-starter-test 的Starter(test scope)包含如下库:JUnit 5: Java单元测试的实际标准Spring Test & Spring Boot Test: Spring Boot应用程序的实用程序和集成测试支持AssertJ: 断言库Hamcrest: 匹配器对象库(也称为约束或谓词Mockito: Java模拟框架.JSONassert: JSON断言库JsonPath: JSON适用的XPath我们通常发现这些公共库在编写测试时很有用。如果这些库不符合您的需要,您可以添加自己的附加测试依赖项。5.8.2 测试Spring 应用程序依赖注入的一个主要优点是它应该使代码更容易进行单元测试。您可以使用new操作符实例化对象,而不需要使用Spring,也可是使用mock对象。通常需要集成测试而不是单元测试(在Spring ApplicationContext中)。Spring 框架包括这样的集成测试模块,你可以直接依赖org.springframework:spring-test或者使用spring-boot-starter-test。如果您以前没有使用过spring测试模块,那么应该首先阅读spring Framework参考文档的相关部分。5.8.3 测试Spring Boot 应用程序Spring Boot应用程序是一个Spring ApplicationContext,因此除了使用普通的Spring上下文进行测试外,无需进行任何特殊的测试。Spring Boot 提供 @SpringBootTest注解,当您需要Spring Boot特性时,它可以作为标准spring test @ContextConfiguration注释的替代。注释的工作原理是通过SpringApplication创建测试中使用的ApplicationContext。除了@SpringBootTest之外,还提供了许多其他注解来测试应用程序的更具体的切片。检测Web应用程序类型默认的,@SpringBootTest不会启动一个服务,您可以使用@SpringBootTest的webEnvironment属性来进一步优化测试的运行方式:MOCK(Default) : 加载一个web ApplicationContext并提供一个模拟的web环境。使用该注解时不会启动一个容器。如果在类路径上web 环境不可用,将创建一个非web环境的ApplicationContext。它可以与 @AutoConfigureMockMvc 或者 @AutoConfigureWebTestClient 一起使用,用来对web应用程序进行模拟测试。RANDOM_PORT:加载WebServerApplicationContext并提供真实的web环境。嵌入服务器将启动和监听基于随机端口。DEFINED_PORT: 加载WebServerApplicationContext并提供真实的web环境。嵌入式服务器启动并在定义的端口(基于application.properties)或默认端口8080上侦听。NONE:使用SpringApplication加载ApplicationContext,但不提供任何web环境(模拟或其他)。如果您的测试是@Transactional,默认情况下,它会在每个测试方法结束时回滚事务。然而,当RANDOM_PORT或DEFINED_PORT一起使用时,实际会提供了一个真实的servlet环境,HTTP客户端和服务器在单独的线程中运行,在这种情况下,在服务器上启动的任何事务都不会回滚。检测测试配置如果使用Spring 框架,你可以使用@ContextConfiguration(classes=…)指定要加载的@Configuration,或者在测试用使用的嵌套@Configuration。使用Spring Boot测试,这些不是必须的,Spring Boot 的@*Test注解会自动搜索primary 配置,只要没有显示指定配置。从当前的测试包开始搜索,直到搜索到@SpringBootApplication或@SpringBootConfiguration为止,只要以合理的方式构造代码,总是可以找到。如果需要自定义主配置,可以使用嵌套的@TestConfiguration类,不同于嵌套的@Configuration类,嵌套的@TestConfiguration使用在应用程序的主配置之外。Spring 测试框架会缓存上下文,因此只要你的测试共享配置,这些耗时操作都只会加载一次。笔者注:@TestConfiguration 用来对@Configuration做补充,用来指定专门用来测试的bean排除测试配置如果你的应用使用了组件扫描(比如,使用了@SpringBootApplication 或 @ComponentScan),你可能会发现对于一些特定测试创建的配置类也被加载。@TestConfiguration能够使用在测试的内部类中,以自定义配置。如果你定义在顶级类,则表示src/test/java中的类不通过扫描获取,这个时候可以通过Import显式导入。import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
@SpringBootTest
@Import(MyTestsConfiguration.class)
class MyTests {
@Test
void exampleTest() {
// ...
}
}如果不使用@SpringBootApplication,而是使用的@ComponentScan,则应该注册TypeExcludeFilter,用来排除配置使用应用程序参数如果你的应用需要参数,那么可以使用@SpringBootTest的args属性。import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(args = "--app.test=one")
class MyApplicationArgumentTests {
@Test
void applicationArgumentsPopulated(@Autowired ApplicationArguments args) {
assertThat(args.getOptionNames()).containsOnly("app.test");
assertThat(args.getOptionValues("app.test")).containsOnly("one");
}
}使用Mock环境测试使用Spring MVC,我们可以使用MockMvc或WebTestClient查询web端点,如下例所示:import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class MyMockMvcTests {
@Test
void testWithMockMvc(@Autowired MockMvc mvc) throws Exception {
mvc.perform(get("/")).andExpect(status().isOk()).andExpect(content().string("Hello World"));
}
// 如果WebFlux存在,也可以使用WebTestClient测试
@Test
void testWithWebTestClient(@Autowired WebTestClient webClient) {
webClient
.get().uri("/")
.exchange()
.expectStatus().isOk()
.expectBody(String.class).isEqualTo("Hello World");
}
}如果希望只关注web层而不启动完整的ApplicationContext,请考虑使用@WebMvcTest。对于Spring WebFlux,可以使用WebTestClient,如下示例:import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.reactive.server.WebTestClient;
@SpringBootTest
@AutoConfigureWebTestClient
class MyMockWebTestClientTests {
@Test
void exampleTest(@Autowired WebTestClient webClient) {
webClient
.get().uri("/")
.exchange()
.expectStatus().isOk()
.expectBody(String.class).isEqualTo("Hello World");
}
}在模拟环境中进行测试通常比使用完整的servlet容器运行更快。然而,由于模拟发生在SpringMVC层,依赖于较低级别servlet容器行为的代码不能直接使用MockMvc进行测试。例如,Spring Boot的错误处理基于servlet容器提供的“错误页”支持。这意味着,虽然可以按预期测试MVC层抛出和处理异常,但不能直接测试是否呈现了特定的自定义错误页面。如果需要测试这些较低级别的问题,可以启动一个完全运行的服务器,如下一节所述。使用运行服务器测试如果需要启动一个完整的运行服务器,建议使用随机端口。使用@SpringBootTest(webEnvironment=WebEnvironment.RANDOM_PORT),将在每次运行测试的时候获取一个随机的可用端口。@LocalServerPort注解能在测试中注入实际使用的端口。为了方便,在参数中使用@Autowired注入WebTestClient。import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.test.web.reactive.server.WebTestClient;
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class MyRandomPortWebTestClientTests {
@Test
void exampleTest(@Autowired WebTestClient webClient) {
webClient
.get().uri("/")
.exchange()
.expectStatus().isOk()
.expectBody(String.class).isEqualTo("Hello World");
}
}WebTestClient 能使用在实时服务和模拟环境中这种方式要求类路径中存在spring-webflux,如果你不添加webflux,Spring Boot提供了TestRestTemplate。import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.client.TestRestTemplate;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class MyRandomPortTestRestTemplateTests {
@Test
void exampleTest(@Autowired TestRestTemplate restTemplate) {
String body = restTemplate.getForObject("/", String.class);
assertThat(body).isEqualTo("Hello World");
}
}自定义WebTestClient需要自定义WebTestClient,配置一个WebTestClientBuilderCustomizer bean。使用WebTestClient.Builder会调用此类创建的bean。笔者注:源码如下: @Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ WebClient.class, WebTestClient.class })
static class WebTestClientMockMvcConfiguration {
@Bean
@ConditionalOnMissingBean
WebTestClient webTestClient(MockMvc mockMvc, List<WebTestClientBuilderCustomizer> customizers) {
WebTestClient.Builder builder = MockMvcWebTestClient.bindTo(mockMvc);
for (WebTestClientBuilderCustomizer customizer : customizers) {
customizer.customize(builder);
}
return builder.build();
}
}
使用JMX因为测试上下文框架缓存上下文的缘故,默认情况下禁用JMX以防止相同的组件注册在同一域上。如果此类需要访问MBeanServer,请标记dirty。import javax.management.MBeanServer;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.DirtiesContext;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(properties = "spring.jmx.enabled=true")
@DirtiesContext
class MyJmxTests {
@Autowired
private MBeanServer mBeanServer;
@Test
void exampleTest() {
assertThat(this.mBeanServer.getDomains()).contains("java.lang");
// ...
}
}查看更多介绍,@DirtiesContext使用Metrics不论类路径是怎么样的,使用@SpringBootTest时都不会自动配置,除了内存中支持的注册表。如果需要将度量导出到其他后端作为集成测试的一部分,请使用@AutoConfigureMetrics对其进行注释。Mocking and Spying Beans运行测试时,有时需要模拟应用程序上下文中的某些组件。例如,您可能有一个在开发期间不可用的远程服务的facade。当您想要模拟在真实环境中很难触发的故障时,模拟也很有用。Spring Boot包含一个@MockBean注解,可用于为ApplicationContext中的bean定义Mockito mock。您可以使用注解添加新bean或替换单个现有bean定义。注解可以直接用于测试类、测试中的字段或@Configuration类和字段。当在字段上使用时,创建的mock的实例也会被注入。模拟bean在每个测试方法之后都会自动重置。如果您的测试使用SpringBoot的一个测试注释(例如@SpringBootTest),则会自动启用此功能。要将此功能用于不同的排列,必须显式添加监听器,如下例所示:import org.springframework.boot.test.mock.mockito.MockitoTestExecutionListener;
import org.springframework.boot.test.mock.mockito.ResetMocksTestExecutionListener;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestExecutionListeners;
@ContextConfiguration(classes = MyConfig.class)
@TestExecutionListeners({ MockitoTestExecutionListener.class, ResetMocksTestExecutionListener.class })
class MyTests {
// ...
}
以下示例使用模拟实现替换现有的RemoteService bean:import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
@SpringBootTest
class MyTests {
@Autowired
private Reverser reverser;
@MockBean
private RemoteService remoteService;
@Test
void exampleTest() {
given(this.remoteService.getValue()).willReturn("spring");
String reverse = this.reverser.getReverseValue(); // Calls injected RemoteService
assertThat(reverse).isEqualTo("gnirps");
}
}@MockBean不能用于模拟在应用程序上下文刷新期间执行的bean的行为。执行测试时,应用程序上下文刷新已完成,此时配置模拟行为已经晚了。建议在这种情况下使用@Bean方法来创建和配置模拟。此外,您可以使用@SpyBean用Mockito spy包装任何现有的bean。有关详细信息,请参阅Javadoc。笔者注:@SpyBean 可以用在类上,或者@Configuration类型、测试类和@RunWith类中的字段上。@SpyBean 示例:@RunWith(SpringRunner.class)
public class ExampleTests {
@SpyBean
private ExampleService service;
@Autowired
private UserOfService userOfService;
@Test
public void testUserOfService() {
String actual = this.userOfService.makeUse();
assertEquals("Was: Hello", actual);
verify(this.service).greet();
}
@Configuration
@Import(UserOfService.class) // A @Component injected with ExampleService
static class Config {
}
}
CGLib代理,例如为作用域bean创建的代理,将代理的方法声明为final。这会阻止Mockito正常运行,因为它无法在默认配置中模拟或监视最终方法。如果您想模拟或监视这样的bean,请通过将org.Mockito:Mockito-inline添加到应用程序的测试依赖项中,将Mockito配置为使用其内联模拟生成器。这允许Mockito模拟和监视最终方法。虽然Spring的测试框架在测试之间缓存应用程序上下文,并为共享相同配置的测试重用上下文,但使用@MockBean或@SpyBean会影响缓存键,这很可能会增加上下文的数量。如果您使用@SpyBean监视带有@Cacheable方法的bean,这些方法按名称引用参数,则必须使用-parameters编译应用程序。这确保一旦监视到bean,缓存基础结构就可以使用参数名称。当您使用@SpyBean监视由Spring代理的bean时,在某些情况下,您可能需要删除Spring的代理,例如,在使用given或when设置期望值时。使用AopTestUtils.getTargetObject(yourProxiedSpy)执行此操作。Auto-configured 测试Spring Boot 自动配置系统对于应用程序能够运行的很好,但有时对测试来讲还是太多了。在测试的时候只加载测试“片段”是非常有帮助的。比如,您可能希望测试Spring MVC控制器是否正确映射URL,并且不希望在这些测试中涉及数据库调用,或者您可能希望对JPA实体进行测试,并且当这些测试运行时,您不关心web层。spring-boot-test-autoconfigure模块有许多注解,能够用来配置这些"片段"。他们中的每个都以相似的方式工作,提供了@…Test注解用来加载ApplicationContext,一个或多个@AutoConfigure…用来自定义自动化配置。每个片段将组件扫描限制到适当的组件,并加载一组非常有限的自动配置类。如果您需要排除其中一个,大多数@…Test批注提供excludeAutoConfiguration属性。或者,您可以使用@ImportAutoConfiguration#exclude。不支持一个测试中使用多个@...Test来包含多个“片段”。如果您需要多个“片段”,请选择一个@…Test注解并包括其他片段的@AutoConfiguration… 注解。如果你对应用的测试片段不关心,但需要一些自动配置的测试bean,可以使用@AutoConfigure…和@SpringBootTest注解的组合。Auto-configured JSON 测试要测试对象JSON序列化和反序列化是否按预期工作,可以使用@JsonTest注解。@JsonTest自动配置可用的受支持的JSON映射器,它可以是以下库之一:Jackson ObjectMapper, 任何 @JsonComponent bean 和任何 Jackson ModuleGsonJsonb@JsonTest 启用的自动配置列表可在附录中找到。如果需要自动配置的元素,你可以使用@AutoConfigureJsonTesters注解。Spring Boot 包括基于AssertJ的辅助程序,他们与JSONAssert 和 JsonPath库一起工作,以检查JSON是否按预期工作。JacksonTester、GsonTester、JsonbTester和BasicJsonTester类能够分别用于Jackson, Gson, Jsonb和字符串。使用@JsonTest时,测试类上的任何辅助字段都可以使用@Autowired。以下示例显示了Jackson的测试类。import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.JsonTest;
import org.springframework.boot.test.json.JacksonTester;
import static org.assertj.core.api.Assertions.assertThat;
@JsonTest
class MyJsonTests {
@Autowired
private JacksonTester<VehicleDetails> json;
@Test
void serialize() throws Exception {
VehicleDetails details = new VehicleDetails("Honda", "Civic");
// Assert against a `.json` file in the same package as the test
assertThat(this.json.write(details)).isEqualToJson("expected.json");
// Or use JSON path based assertions
assertThat(this.json.write(details)).hasJsonPathStringValue("@.make");
assertThat(this.json.write(details)).extractingJsonPathStringValue("@.make").isEqualTo("Honda");
}
@Test
void deserialize() throws Exception {
String content = "{\"make\":\"Ford\",\"model\":\"Focus\"}";
assertThat(this.json.parse(content)).isEqualTo(new VehicleDetails("Ford", "Focus"));
assertThat(this.json.parseObject(content).getMake()).isEqualTo("Ford");
}
}JSON 辅助类也可以直接用于单元测试。如果不使用@JsonTest,请在@Before中调用helper的initFields方法如果是用Spring Boot 基于AssertJ的辅助程序来断言给定JSON路径上的数值,则可能没法根据类型使用isEqualTo。相反,可使用AssertJ的satisfies来断言该值与给定条件是否匹配。例如,下面示例断言实际数字是一个接近0.15的浮点值,偏移量为0.01@Test
void someTest() throws Exception {
SomeObject value = new SomeObject(0.152f);
assertThat(this.json.write(value)).extractingJsonPathNumberValue("@.test.numberValue")
.satisfies((number) -> assertThat(number.floatValue()).isCloseTo(0.15f, within(0.01f)));
}Auto-configured Spring MVC 测试要测试Spring MVC 控制器是否按预期工作,使用@WebMvcTest注解。@WebMvcTest自动配置Spring MVC 基础结构,并将扫描到的bean限制为@Controller, @ControllerAdvice, @JsonComponent, Converter, GenericConverter, Filter, HandlerInterceptor, WebMvcConfigurer, WebMvcRegistrations, 和 HandlerMethodArgumentResolver。使用@WebMvcTest注解时,不会扫描常规@Component和@ConfigurationProperties bean。@EnableConfigurationProperties可用于包含@ConfigurationProperties bean。可以在附录中查看@WebMvcTest启用的自动配置列表如果需要注册额外的组件,比如Jackson Module,你能使用@Import导入额外的配置类。一般的@WebMvcTest只能用于一个控制器,并与@MockBean结合使用,提供模拟实现。@WebMvcTest也自动配置MockMvc,Mock MVC 提供一个强大快速测试Mvc控制器的方法,而不需要启动整个HTTP 服务器。还能够在非@WebMvcTest(如@SpringBootTest)中使用@AutoConfigureMockMvc对MockMVC 进行自动配置。如下是MockMvc的示例:import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(UserVehicleController.class)
class MyControllerTests {
@Autowired
private MockMvc mvc;
@MockBean
private UserVehicleService userVehicleService;
@Test
void testExample() throws Exception {
given(this.userVehicleService.getVehicleDetails("sboot"))
.willReturn(new VehicleDetails("Honda", "Civic"));
this.mvc.perform(get("/sboot/vehicle").accept(MediaType.TEXT_PLAIN))
.andExpect(status().isOk())
.andExpect(content().string("Honda Civic"));
}
}如果需要配置自动配置的元素(例如,当应用servlet过滤器时),可以使用@AutoConfigureMockMvc注解中的属性。如果使用HtmlUnit和Selenium,自动配置还提供HtmlUnit WebClient bean或Selenium WebDriver bean。以下示例使用HtmlUnit:import com.gargoylesoftware.htmlunit.WebClient;
import com.gargoylesoftware.htmlunit.html.HtmlPage;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
@WebMvcTest(UserVehicleController.class)
class MyHtmlUnitTests {
@Autowired
private WebClient webClient;
@MockBean
private UserVehicleService userVehicleService;
@Test
void testExample() throws Exception {
given(this.userVehicleService.getVehicleDetails("sboot")).willReturn(new VehicleDetails("Honda", "Civic"));
HtmlPage page = this.webClient.getPage("/sboot/vehicle.html");
assertThat(page.getBody().getTextContent()).isEqualTo("Honda Civic");
}
}默认情况下,Spring Boot 将WebDriver bean 放置在一个特殊的 scope中,以确保每次测试以后驱动程序退出,并注入新的实例。Spring Boot 创建的 webDriver 作用域将替换任何用户定义的同名作用域。若是定义了本身的webDriver作用域,则在使用@WebMvcTest时可能会发现他停止工作。如果类路径上有Spring Security,@WebMvcTest还将扫描WebSecurityConfigurer bean。您可以使用SpringSecurity的测试支持,而不是完全禁用此类测试的安全性。有关如何使用Spring Security的MockMvc支持的更多详细信息,请参阅本节的Testing With Spring Security操作指南。有时编写SpringMVC测试是不够的;Spring Boot可以帮助您在实际服务器上运行完整的端到端测试。Auto-configured Spring WebFlux 测试要测试Spring WebFlux 控制器是否按预期工作,你能够使用@WebFluxTest注解。@WebFluxTest自动配置Spring WebFlux 基础设施,并将扫描的bean限制为@Controller、@ControllerAdvice、@JsonComponent、Converter、GenericConverter、WebFilter和WebFluxConfigurer。使用@WebFluxTest 注解时,不会扫描常规的@Component和@ConfigurationProperties。@WebFluxTest启用的自动配置列表可在附录中找到如果您需要注册额外的组件,例如Jackson Module,您可以在测试中使用@import导入其他配置类。通常,@WebFluxTest仅限于一个控制器,并与@MockBean注解结合使用,为所需的合作者提供模拟实现。@WebFluxTest还自动配置WebTestClient,它提供了一种快速测试WebFlux控制器的强大方法,无需启动完整的HTTP服务器。您还可以在非@WebFluxTest(例如@SpringBootTest)使用@AutoConfigureWebTestClient注解,以自动配置web测试客户端。以下示例显示了一个同时使用@WebFluxTest和WebTestClient的类:import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import static org.mockito.BDDMockito.given;
@WebFluxTest(UserVehicleController.class)
class MyControllerTests {
@Autowired
private WebTestClient webClient;
@MockBean
private UserVehicleService userVehicleService;
@Test
void testExample() {
given(this.userVehicleService.getVehicleDetails("sboot"))
.willReturn(new VehicleDetails("Honda", "Civic"));
this.webClient.get().uri("/sboot/vehicle").accept(MediaType.TEXT_PLAIN).exchange()
.expectStatus().isOk()
.expectBody(String.class).isEqualTo("Honda Civic");
}
}此设置仅受WebFlux应用程序支持,因为在模拟的web应用程序中使用WebTestClient目前仅适用于WebFlux。@WebFluxTest无法检测通过功能web框架注册的路由。要在上下文中测试RouterFunction bean,请考虑使用@Import或@SpringBootTest自己导入RouterFunction。@WebFluxTest无法检测注册为SecurityWebFilterChain类型的@Bean的自定义安全配置。要将其包含在测试中,您需要使用@import或@SpringBootTest导入注册bean的配置。有时编写SpringWebFlux测试是不够的;Spring Boot可以帮助您在实际服务器上运行完整的端到端测试。Auto-configured Spring GraphQL 测试略Auto-configured Data Cassandra 测试略Auto-configured Data Couchbase 测试略Auto-configured Data Elasticsearch 测试您可以使用@DataElasticsearchTest测试Elasticsearch应用程序。默认情况下,它配置ElasticsearchRestTemplate,扫描@Document类,并配置SpringDataElasticSearch存储库。使用@DataElasticsearchTest注解时,不会扫描常规@Component和@ConfigurationProperties bean。@EnableConfigurationProperties可用于包含@ConfigurationProperties bean。(有关在Spring Boot中使用Elasticsearch的更多信息,请参阅本章前面的“Elasticsearch”。)更多@DataElasticsearchTest列表在这里查看以下示例显示了在Spring Boot中使用Elasticsearch测试的典型示例:import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.data.elasticsearch.DataElasticsearchTest;
@DataElasticsearchTest
class MyDataElasticsearchTests {
@Autowired
private SomeRepository repository;
// ...
}Auto-configured Data JPA 测试略Auto-configured JDBC 测试略Auto-configured Data JDBC 测试略Auto-configured jOOQ 测试略Auto-configured Data MongoDB 测试您可以使用@DataMongoTest测试MongoDB应用程序。默认情况下,它配置内存中嵌入的MongoDB(如果可用),配置MongoTemplate,扫描@Document类,并配置Spring Data MongoDB存储库。使用@DataMongoTest注解时,不会扫描常规@Component和@ConfigurationProperties bean。@EnableConfigurationProperties可用于包含@ConfigurationProperties bean。(有关在Spring Boot中使用MongoDB的更多信息,请参阅“MongoDB”。)@DataMongoTest 自动配置列表查看。下面是典型的使用@DataMongoTest的示例:import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.data.mongo.DataMongoTest;
import org.springframework.data.mongodb.core.MongoTemplate;
@DataMongoTest
class MyDataMongoDbTests {
@Autowired
private MongoTemplate mongoTemplate;
// ...
}内存嵌入式MongoDB通常很适合测试,因为它速度快,不需要任何开发人员安装。但是,如果您希望对真实的MongoDB服务器运行测试,则应排除嵌入式MongoDB自动配置,如下例所示:import org.springframework.boot.autoconfigure.mongo.embedded.EmbeddedMongoAutoConfiguration;
import org.springframework.boot.test.autoconfigure.data.mongo.DataMongoTest;
@DataMongoTest(excludeAutoConfiguration = EmbeddedMongoAutoConfiguration.class)
class MyDataMongoDbTests {
// ...
}Auto-configured Data Neo4j 测试略Auto-configured Data Redis 测试您可以使用@DataRedisTest测试Redis应用程序。默认情况下,它扫描@RedisHash类并配置Spring Data Redis存储库。使用@DataRedisTest注解时,不会扫描常规@Component和@ConfigurationProperties bean。@EnableConfigurationProperties可用于包含@ConfigurationProperties bean。(有关在Spring Boot中使用Redis的更多信息,请参阅“Redis”。)@DataRedisTest注解使用示例:import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest;
@DataRedisTest
class MyDataRedisTests {
@Autowired
private SomeRepository repository;
// ...
}Auto-configured Data LDAP 测试Auto-configured REST Clients您可以使用@RestClientTest注解来测试REST客户端。默认情况下,它自动配置Jackson、GSON和Jsonb支持,配置RestTemplateBuilder,并添加对MockRestServiceServer的支持。使用@RestClientTest注解时,不会扫描常规@Component和@ConfigurationProperties bean。@EnableConfigurationProperties可用于包含@ConfigurationProperties bean。应使用@RestClientTest的value或components属性指定要测试的特定bean,如下例所示:import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
@RestClientTest(RemoteVehicleDetailsService.class)
class MyRestClientTests {
@Autowired
private RemoteVehicleDetailsService service;
@Autowired
private MockRestServiceServer server;
@Test
void getVehicleDetailsWhenResultIsSuccessShouldReturnDetails() {
this.server.expect(requestTo("/greet/details")).andRespond(withSuccess("hello", MediaType.TEXT_PLAIN));
String greeting = this.service.callRestService();
assertThat(greeting).isEqualTo("hello");
}
}Auto-configured Spring REST Docs Tests你可以使用@AutoConfigureRestDocs注解在Mock MVC,REST Assured,或者 WebTestClient的测试中来使用Spring REST Docs。@AutoConfigureRestDocs可以覆盖默认的输出目录(如果使用Maven,则为target/generated-snippets,如果是Gradle,则为build/generated-snippets)。使用Mock MVC 测试 Auto-configured Spring REST Docs基于servlet的Web应用程序@AutoConfigureRestDocs支持自定义MockMvc bean,以便在测试中使用Spring REST Docs,在单元测试中使用@Autowired注入。import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(UserController.class)
@AutoConfigureRestDocs
class MyUserDocumentationTests {
@Autowired
private MockMvc mvc;
@Test
void listUsers() throws Exception {
this.mvc.perform(get("/users").accept(MediaType.TEXT_PLAIN))
.andExpect(status().isOk())
.andDo(document("list-users"));
}
}如果要比@AutoConfigureRestDocs更多的控制Spring REST Docs的配置,可以使用RestDocsMockMvcConfigurationCustomizer。import org.springframework.boot.test.autoconfigure.restdocs.RestDocsMockMvcConfigurationCustomizer;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentationConfigurer;
import org.springframework.restdocs.templates.TemplateFormats;
@TestConfiguration(proxyBeanMethods = false)
public class MyRestDocsConfiguration implements RestDocsMockMvcConfigurationCustomizer {
@Override
public void customize(MockMvcRestDocumentationConfigurer configurer) {
configurer.snippets().withTemplateFormat(TemplateFormats.markdown());
}
}如果要让Spring REST Docs支持参数化输出目录,可以创建一个RestDocumentationResultHandler bean。自动配置使用此结果处理程序调用alwaysDo,从而使每个MockMvc调用自动生成默认代码段。以下示例显示了正在定义的RestDocumentationResultHandler:import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation;
import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler;
@TestConfiguration(proxyBeanMethods = false)
public class MyResultHandlerConfiguration {
@Bean
public RestDocumentationResultHandler restDocumentation() {
return MockMvcRestDocumentation.document("{method-name}");
}
}使用WebTestClient测试Auto-configured Spring REST Docs在reactive环境的Web应用程序,@AutoConfigureRestDocs可以使用WebTestClient进行测试。可以使用@Autowired注入,并在测试中使用@WebFluxTest和Spring REST Docs。import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.test.web.reactive.server.WebTestClient;
import static org.springframework.restdocs.webtestclient.WebTestClientRestDocumentation.document;
@WebFluxTest
@AutoConfigureRestDocs
class MyUsersDocumentationTests {
@Autowired
private WebTestClient webTestClient;
@Test
void listUsers() {
this.webTestClient
.get().uri("/")
.exchange()
.expectStatus()
.isOk()
.expectBody()
.consumeWith(document("list-users"));
}
}同样,可以自定义RestDocsWebTestClientConfigurationCustomizer bean,提供更多的Spring REST Docs配置控制。import org.springframework.boot.test.autoconfigure.restdocs.RestDocsWebTestClientConfigurationCustomizer;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.restdocs.webtestclient.WebTestClientRestDocumentationConfigurer;
@TestConfiguration(proxyBeanMethods = false)
public class MyRestDocsConfiguration implements RestDocsWebTestClientConfigurationCustomizer {
@Override
public void customize(WebTestClientRestDocumentationConfigurer configurer) {
configurer.snippets().withEncoding("UTF-8");
}
}使用WebTestClientBuilderCustomizer配置让Spring REST Docs提供对参数化输出目录的支持。import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.web.reactive.server.WebTestClientBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import static org.springframework.restdocs.webtestclient.WebTestClientRestDocumentation.document;
@TestConfiguration(proxyBeanMethods = false)
public class MyWebTestClientBuilderCustomizerConfiguration {
@Bean
public WebTestClientBuilderCustomizer restDocumentation() {
return (builder) -> builder.entityExchangeResultConsumer(document("{method-name}"));
}
}使用REST Assured 测试Auto-configured Spring REST Docs@AutoConfigureRestDocs使用一个RequestSpecification bean(预配置为使用Spring REST Docs)在单元测试中,使用@Autowired注入。import io.restassured.specification.RequestSpecification;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.server.LocalServerPort;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.is;
import static org.springframework.restdocs.restassured3.RestAssuredRestDocumentation.document;
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureRestDocs
class MyUserDocumentationTests {
@Test
void listUsers(@Autowired RequestSpecification documentationSpec, @LocalServerPort int port) {
given(documentationSpec)
.filter(document("list-users"))
.when()
.port(port)
.get("/")
.then().assertThat()
.statusCode(is(200));
}
}使用RestDocsRestAssuredConfigurationCustomizer自定义配置提供更多的配置控制。import org.springframework.boot.test.autoconfigure.restdocs.RestDocsRestAssuredConfigurationCustomizer;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.restdocs.restassured3.RestAssuredRestDocumentationConfigurer;
import org.springframework.restdocs.templates.TemplateFormats;
@TestConfiguration(proxyBeanMethods = false)
public class MyRestDocsConfiguration implements RestDocsRestAssuredConfigurationCustomizer {
@Override
public void customize(RestAssuredRestDocumentationConfigurer configurer) {
configurer.snippets().withTemplateFormat(TemplateFormats.markdown());
}
}Auto-configured Spring Web Services测试使用@WebServiceClientTest测试使用了Spring Web Services的项目。默认情况下,它配置一个模拟WebServiceServerbean 并自动定义WebServiceTemplateBuilder。(更多Spring Boot使用 Web Service 查看 “Web Services”)import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.webservices.client.WebServiceClientTest;
import org.springframework.ws.test.client.MockWebServiceServer;
import org.springframework.xml.transform.StringSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.ws.test.client.RequestMatchers.payload;
import static org.springframework.ws.test.client.ResponseCreators.withPayload;
@WebServiceClientTest(SomeWebService.class)
class MyWebServiceClientTests {
@Autowired
private MockWebServiceServer server;
@Autowired
private SomeWebService someWebService;
@Test
void mockServerCall() {
this.server
.expect(payload(new StringSource("<request/>")))
.andRespond(withPayload(new StringSource("<response><status>200</status></response>")));
assertThat(this.someWebService.test())
.extracting(Response::getStatus)
.isEqualTo(200);
}
}Auto-configured Spring Web Services Client 测试使用@WebServiceClientTest测试使用了Spring Web Services 项目的应用程序。默认的,它会配置一个模拟的WebServiceServer bean 和自动自定义WebServiceTemplateBuilder。import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.webservices.client.WebServiceClientTest;
import org.springframework.ws.test.client.MockWebServiceServer;
import org.springframework.xml.transform.StringSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.ws.test.client.RequestMatchers.payload;
import static org.springframework.ws.test.client.ResponseCreators.withPayload;
@WebServiceClientTest(SomeWebService.class)
class MyWebServiceClientTests {
@Autowired
private MockWebServiceServer server;
@Autowired
private SomeWebService someWebService;
@Test
void mockServerCall() {
this.server
.expect(payload(new StringSource("<request/>")))
.andRespond(withPayload(new StringSource("<response><status>200</status></response>")));
assertThat(this.someWebService.test())
.extracting(Response::getStatus)
.isEqualTo(200);
}
}Auto-configured Spring Web Services Server 测试使用@WebServiceServerTest测试Spring Web Services 的项目。默认它配置一个MockWebServiceClient bean ,用于调用 web Service 端点。import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.webservices.server.WebServiceServerTest;
import org.springframework.ws.test.server.MockWebServiceClient;
import org.springframework.ws.test.server.RequestCreators;
import org.springframework.ws.test.server.ResponseMatchers;
import org.springframework.xml.transform.StringSource;
@WebServiceServerTest(ExampleEndpoint.class)
class MyWebServiceServerTests {
@Autowired
private MockWebServiceClient client;
@Test
void mockServerCall() {
this.client
.sendRequest(RequestCreators.withPayload(new StringSource("<ExampleRequest/>")))
.andExpect(ResponseMatchers.payload(new StringSource("<ExampleResponse>42</ExampleResponse>")));
}
}其他 自动配置 和片段每个片段提供一个或多个@AutoConfigure…注解,即定义一部分包含自动配置。可以通过创建自定义@AutoConfigure…逐个添加自动化配置或者使用@ImportAutoConfiguration添加到测试中。import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.autoconfigure.integration.IntegrationAutoConfiguration;
import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest;
@JdbcTest
@ImportAutoConfiguration(IntegrationAutoConfiguration.class)
class MyJdbcTests {
}不要使用@Import注解来导入自动配置,由于他们由Spring Boot以特定方式处理。笔者注:@Import和@ImportAutoConfiguration的区别:https://www.cnblogs.com/imyjy/p/16092825.html另外,可以通过META-INF/spring中添加自动配置文件META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.JdbcTest.importscom.example.IntegrationAutoConfiguration在这个例子中,com.example.IntegrationAutoConfiguration会在每个@JdbcTest注解中开启。可以在文件中使用#注释使用 Configuration 和 片段略使用Spock 测试 Spring Boot 应用程序Spock 2.x 能用于测试Spring Boot 应用程序,只要添加 spock-spring模块依赖,详细查看Spock 的文档。5.8.4 测试实用程序ConfigDataApplicationContextInitializerConfigDataApplicationContextInitializer是一个ApplicationContextInitializer,可以用于测试加载Spring Boot application.properties文件。当不需要@SpringBootTest提供的全部功能时,可以使用。import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer;
import org.springframework.test.context.ContextConfiguration;
@ContextConfiguration(classes = Config.class, initializers = ConfigDataApplicationContextInitializer.class)
class MyConfigFileTests {
// ...
}ConfigDataApplicationContextInitializer不提供@Value("${…}")注入支持,它仅用于将application.properties文件加载到Spring 的Environment中。要支持@Value,你需要另外配置PropertySourcesPlaceholderConfigurer或者使用@SpringBootTest。TestPropertyValuesTestPropertyValues允许你可以快速添加配置到ConfigurableEnvironment或者ConfigurableApplicationContext中,使用key=value形式。import org.junit.jupiter.api.Test;
import org.springframework.boot.test.util.TestPropertyValues;
import org.springframework.mock.env.MockEnvironment;
import static org.assertj.core.api.Assertions.assertThat;
class MyEnvironmentTests {
@Test
void testPropertySources() {
MockEnvironment environment = new MockEnvironment();
TestPropertyValues.of("org=Spring", "name=Boot").applyTo(environment);
assertThat(environment.getProperty("name")).isEqualTo("Boot");
}
}OutputCaptureOutputCapture是一个Junit 扩展,用于捕获System.out 和 System.err输出。添加@ExtendWith(OutputCaptureExtension.class),并将CapturedOutput作为参数注入测试类或构造函数中。import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(OutputCaptureExtension.class)
class MyOutputCaptureTests {
@Test
void testName(CapturedOutput output) {
System.out.println("Hello World!");
assertThat(output).contains("World");
}
}TestRestTemplateTestRestTemplate是 Spring RestTemplate的有用替代方式。它以测试友好的方式运行,可以通过返回的ResponseEntity检测出错误。Spring Framework 5.0提供了一个新的WebTestClient,用于WebFlux集成测试。建议使用高于4.3.2 的Apache HTTP Client 版本,但不是强制的。如果你的类路径中存在该类,TestRestTemplate将配置合适的客户端来响应,如果没有,将使用其他的友好方式:不遵循重定向规则(因此可以断言响应的位置)Cookies被忽略(因此模板是无状态的)TestRestTemplate可以在集成测试中实例化,如下示例:import org.junit.jupiter.api.Test;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
class MyTests {
private final TestRestTemplate template = new TestRestTemplate();
@Test
void testRequest() {
ResponseEntity<String> headers = this.template.getForEntity("https://myhost.example.com/example", String.class);
assertThat(headers.getHeaders().getLocation()).hasHost("other.example.com");
}
}或者,如果将 WebEnvironment.RANDOM_PORT 或者 WebEnvironment.DEFINED_PORT与@SpringBootTest注解一起使用,你能注入一个TestRestTemplate并可以开始使用。如果有需要可以使用RestTemplateBuilder bean 自定义配置。host 和端口 将自动配置连接到容器。import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpHeaders;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class MySpringBootTests {
@Autowired
private TestRestTemplate template;
@Test
void testRequest() {
HttpHeaders headers = this.template.getForEntity("/example", String.class).getHeaders();
assertThat(headers.getLocation()).hasHost("other.example.com");
}
@TestConfiguration(proxyBeanMethods = false)
static class RestTemplateBuilderConfiguration {
@Bean
RestTemplateBuilder restTemplateBuilder() {
return new RestTemplateBuilder().setConnectTimeout(Duration.ofSeconds(1))
.setReadTimeout(Duration.ofSeconds(1));
}
}
}5.9 创建自己的自动化配置自动化配置可以跟"starter"相关联,该启动器提供自动化配置代码以及使用的库。5.9.1 了解自动配置的Bean实现自动配置的类使用@AutoConfiguration注解,这个注解使用@Configuration标注,使的自动配置称为标注的@Configuration类。@Conditional注解用于约束何时应用自动配置。通常自动配置类使用@ConditionalOnClass和@ConditionalOnMissingBean注解。这确保自动配置仅在找到相关类且尚未声明你自己的@Configuration时适用。可以查看Spring Boot源码浏览提供的自动配置类,或者查看文件META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports5.9.2 查找候选的自动配置Spring Boot检查发布的jar中是否存在META-INF/Spring/org.springframework.Boot.autoconfig.AutoConfiguration.imports文件,该文件每行列出你的配置类。com.mycorp.libx.autoconfigure.LibXAutoConfiguration
com.mycorp.libx.autoconfigure.LibXWebAutoConfiguration可以在文件中使用#字符注释如果需要按指定顺序应用配置,则可以使用 @AutoConfiguration 注解上的 before, beforeName, after 和 afterName 属性,或者使用专用的 @AutoConfigureBefore 和 @AutoConfigureAfter 注解。比如,如果你提供特定于web的配置,你的类可能需要在WebMvcAutoConfiguration后应用。如果你想要对一些不互相了解的类进行排序,也可以使用@AutoConfigureOrder。该注解跟@Order有相同的语义,但专门用于自动配置类。与标准@Configuration类一样,自动配置类的应用顺序只影响其bean的定义顺序。随后创建的这些bean的顺序不受影响,并由每个bean的依赖关系和任何@DependsOn关系决定。5.9.3 条件注解如果想要在自动配置类上,始终配置一个或多个@Conditional,那么最常用的是@ConditionalOnMissingBean注解。Spring Boot 包含许多的@Conditional注解,包括如下注解:Class ConditionsBean ConditionsProperty ConditionsResource ConditionsWeb Application ConditionsSpEL Expression ConditionsClass Conditions@ConditionalOnClass 和 @ConditionOnMissingClass 注解允许根据指定类是否存在来加载@Configuration类。该机制不适用于@Bean方法,其中返回类型通常是condition 的target:在方法的condition应用之前,JVM将加载类和可能处理的方法引用,如果类不存在,这些方法引用将失败。为了处理这种情况,可以使用单独的@Configuration类来隔离condition,如下所示:import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@AutoConfiguration
// Some conditions ...
public class MyAutoConfiguration {
// Auto-configured beans ...
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(SomeService.class)
public static class SomeServiceConfiguration {
@Bean
@ConditionalOnMissingBean
public SomeService someService() {
return new SomeService();
}
}
}如果使用@ConditionalOnClass或者@ConditionalOnMissingClass作为元注解的一部分来编写自己的组合注解,则必须使用name引用类。Bean Conditions@ConditionalOnBean 和 @ConditionalOnMissingBean 注解会根据是否存在指定bean来判断是否加载 bean,使用value指定bean 的类型或者name指定bean 的名称,search属性允许你限制搜索bean时要考虑的ApplicationContext层次结构。当放置于@Bean上事,目标类型默认为方法的返回类型,如下所示:import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
@AutoConfiguration
public class MyAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public SomeService someService() {
return new SomeService();
}
}这个例子中,someService将在SomeService 类型的Bean 不在ApplicationContext中时创建。建议在自动配置类上只使用@ConditionalOnBean 和 @ConditionalOnMissingBean注解,因为这些注解能够保证是在 任何用户自定义bean 后加载的。在声明@Bean方法时,在方法的返回类型中提供尽可能多的类型信息,例如,如果您的bean的具体类实现了接口,那么bean 方法返回的类型应该是具体类而不是接口。Property Conditions@ConditionalOnProperty 注解根据Spring Environment 是否包含指定配置进行加载。使用prefix和name指定要检查的属性,默认情况下匹配任何存在且不等于false的属性。另外可以使用havingValue和matchIfMissing属性创建高级检查。笔者注:havingValue 配置预期值(字符串形式),如果未指定,则属性不能等于falsematchIfMissing 表示如果未设置属性,条件是否匹配,默认为falseResource Conditions@ConditionalOnResource注解仅在指定资源存在时才加载配置。可以使用常用的Spring 约定指定资源,比如file:/home/user/test.dat。Web Application Conditions@ConditionalOnWebApplication 和 @ConditionalOnNotWebApplication注解根据应用是否是一个"web应用"来判断是否加载配置。基于servlet 的 web 应用使用了Spring WebApplicationContext,定义了一个session生命周期或者有一个ConfigurableWebEnvironment,反应式的 web 应用程序使用ReactiveWebApplicationContext或者存在ConfigurableReactiveWebEnvironment。@ConditionalOnWarDeployment注解根据应用是否是传统的WAR应用来判断是否加载配置。对于使用嵌入式应用程序,该条件不会匹配。SpEL Expression Conditions@ConditionalOnWarDeployment注解根据SpEL表达式结果来判断是否加载配置。在表达式中引用bean将导致bean在上下文刷新处理中过早初始化,这将导致bean无法进行post-processing处理(比如配置绑定)并且状态可能是不完整的。5.9.4 测试自动配置自动配置可能会受到许多因素的影响:用户配置(@Bean定义和自定义的Environment)、评估条件和其他的。每个测试都应该创建一个良好的ApplicationContext,表示这些自定义的组合,ApplicationContextRunner提供了实现这一点的好方法。ApplicationContextRunner通常定义为测试类的一个字段,用来收集基础的、公共配置。以下示例确保了MyServiceAutoConfiguration始终被调用。private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(MyServiceAutoConfiguration.class));如果定义了多个自动配置,则不需要对其声明排序,因为它们的调用顺序与应用程序的顺序完全相同。每个测试都可以使用runner来表示特定的用例。例如,下面的示例调用用户配置(UserConfiguration)并检查自动配置是否正确退出。run提供的回调上下文可以在AssertJ中使用。@Test
void defaultServiceBacksOff() {
this.contextRunner.withUserConfiguration(UserConfiguration.class).run((context) -> {
assertThat(context).hasSingleBean(MyService.class);
assertThat(context).getBean("myCustomService").isSameAs(context.getBean(MyService.class));
});
}
@Configuration(proxyBeanMethods = false)
static class UserConfiguration {
@Bean
MyService myCustomService() {
return new MyService("mine");
}
}还可以轻松自定义Environment,如下所示:@Test
void serviceNameCanBeConfigured() {
this.contextRunner.withPropertyValues("user.name=test123").run((context) -> {
assertThat(context).hasSingleBean(MyService.class);
assertThat(context.getBean(MyService.class).getName()).isEqualTo("test123");
});
}runner 也能够用于显示ConditionEvaluationReport,这个报告可以在INFO 或 DEBUG级别打印。如下示例展示如何使用ConditionEvaluationReportLoggingListener在自动化测试中打印报告。import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportLoggingListener;
import org.springframework.boot.logging.LogLevel;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
class MyConditionEvaluationReportingTests {
@Test
void autoConfigTest() {
new ApplicationContextRunner()
.withInitializer(new ConditionEvaluationReportLoggingListener(LogLevel.INFO))
.run((context) -> {
// Test something...
});
}
}模拟Web上下文如果你仅需要在servlet 或者 reactive 上下文测试自动化配置,你可以使用WebApplicationContextRunner或者ReactiveWebApplicationContextRunner。覆盖类路径还可以在运行时测试特定的包或类是否存在,Spring Boot 附带了一个FilteredClassLoader,以下示例断言MyService不存在时,自动配置将禁用。@Test
void serviceIsIgnoredIfLibraryIsNotPresent() {
this.contextRunner.withClassLoader(new FilteredClassLoader(MyService.class))
.run((context) -> assertThat(context).doesNotHaveBean("myService"));
}5.9.5 创建自己的Starter命名给starter提供一个合适的命名空间,不要以spring-boot开头,即使使用不同的maven groupId。配置键如果你的启动器提供配置键,请使用唯一的命名空间。不要使用Spring Boot 使用的键(如server、management、spring等),根据经验,应该在所有键上加上独有的命名空间,比如acme。import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("acme")
public class AcmeProperties {
/**
* Whether to check the location of acme resources.
*/
private boolean checkLocation = true;
/**
* Timeout for establishing a connection to the acme server.
*/
private Duration loginTimeout = Duration.ofSeconds(3);
public boolean isCheckLocation() {
return this.checkLocation;
}
public void setCheckLocation(boolean checkLocation) {
this.checkLocation = checkLocation;
}
public Duration getLoginTimeout() {
return this.loginTimeout;
}
public void setLoginTimeout(Duration loginTimeout) {
this.loginTimeout = loginTimeout;
}
}这里是一些遵循的规则:不要以The或A开始boolean类型,以Whether或者Enable开始列表类型,以“逗号分隔列表”开始(原文 Comma-separated list)使用java.time.Duration而不是long除非在运行时确认,否则不要提供默认值确保你生成了Annotation Processor以便IDE的提示可用,可以在META-INF/spring-configuration-metadata.json查看生成的metadata,确保键已经被正确生成,然后在IDE中进行验证。自动配置模块autoconfigure模块包含任何必要的启动库,可能也包含配置键定义(比如ConfigurationProperties)。你应该将对库的依赖标记为可选,这样就可以更容易地将自动配置模块包含在你的项目中。Spring Boot 使用注释处理器从META-INF/spring-autoconfigure-metadata.properties中收集自动配置条件。如果存在该文件,则可以在早期过滤不匹配的自动配置,这有助于提高启动时间。当用maven构建的时候,推荐添加如下的依赖到模块中:<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure-processor</artifactId>
<optional>true</optional>
</dependency>
如果已经在应用程序中直接定义了自动配置,确保spring-boot-maven-plugin已经配置,防止repackage 将依赖重新打包到fat jar中。<project>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure-processor</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>使用Gradle,应该添加annotationProcessor配置,如下所示:dependencies {
annotationProcessor "org.springframework.boot:spring-boot-autoconfigure-processor"
}Starter模块starter确实是一个空jar,它只提供一些必要的依赖项。
cathoy
Spring Boot 源码阅读初始化环境搭建
在开始源码阅读之前,我们要有一个统一的Spring Boot 版本,不同的版本源码会略有差别,先搭建一个简易的SSM环境用于测试,这边简单的记录一下,阅读我专栏的读者可以下载我使用的Demo环境:demo环境地址:https://github.com/jujunchen/Spring-Boot-Demo.git版本统一:工具:IDEA
JDK:jdk11
Spring Boot 版本:3.1.0
Mybatis Plus 版本:3.5.2使用 Spring Initializr 创建一个项目项目pom.xml文件如下<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.springboot</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>Spring-Boot-Demo</name>
<description>Spring-Boot-Demo</description>
<properties>
<java.version>11</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.2</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
<repository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<releases>
<enabled>false</enabled>
</releases>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</pluginRepository>
<pluginRepository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<releases>
<enabled>false</enabled>
</releases>
</pluginRepository>
</pluginRepositories>
</project>
application.yml文件配置如下# Mysql数据库
spring:
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:./spring-boot-demo
username: root
password: 123456启动项目,打印出如下日志2023-06-16T13:29:22.124+08:00 INFO 23093 --- [ restartedMain] o.s.boot.SpringApplication :
/$$$$$$ /$$$$$$ /$$$$$$$ /$$ /$$ /$$$$$$ /$$$$$$$$ /$$$$$$ /$$$$$$ /$$ /$$ /$$$$$$ /$$$$$$ /$$ /$$
/$$__ $$ /$$__ $$| $$__ $$| $$$ | $$ |_ $$_/|__ $$__//$$__ $$ /$$__ $$| $$ /$$//$$__ $$ /$$__ $$| $$ /$$/
| $$ \__/| $$ \__/| $$ \ $$| $$$$| $$ | $$ | $$ | $$ \__/| $$ \ $$ \ $$ /$$/| $$ \__/| $$ \ $$ \ $$ /$$/
| $$ | $$$$$$ | $$ | $$| $$ $$ $$ /$$$$$$| $$ | $$ | $$$$$$ | $$$$$$$$ \ $$$$/ | $$$$$$ | $$$$$$$$ \ $$$$/
| $$ \____ $$| $$ | $$| $$ $$$$|______/| $$ | $$ \____ $$| $$__ $$ \ $$/ \____ $$| $$__ $$ \ $$/
| $$ $$ /$$ \ $$| $$ | $$| $$\ $$$ | $$ | $$ /$$ \ $$| $$ | $$ | $$ /$$ \ $$| $$ | $$ | $$
| $$$$$$/| $$$$$$/| $$$$$$$/| $$ \ $$ /$$$$$$ | $$ | $$$$$$/| $$ | $$ | $$ | $$$$$$/| $$ | $$ | $$
\______/ \______/ |_______/ |__/ \__/ |______/ |__/ \______/ |__/ |__/ |__/ \______/ |__/ |__/ |__/
::Spring Boot Version: (v3.1.0)
环境能正常启动后,就可以开始阅读源码了。有时候只是看最新的源码我们只能知道现在是怎么样的,但不知道为什么会演变成这样,比如有的类Spring Boot 2.X版本中有,3.X没有了,项目作者是怎么考虑的,这些就需要下载Spring Boot 的项目看Git提交记录。Spring Boot 项目地址:https://github.com/spring-projects/spring-boot
cathoy
Spring Boot 监听器详解
监听器的介绍通过前面的几篇文章,我们都能看到SpringApplicationRunListener,SpringApplicationRunListener 是SpringApplication 的运行监听器,提供Spring Boot启动时各个运行状态的监听,可以在应用程序启动的时候执行一些自定义操作或记录一些信息。SpringApplicationRunListener 在run中加载SpringApplicationRunListeners listeners = getRunListeners(args);。ApplicationListener是Spring 提供的上下文监听器,可用于监听指定感兴趣的事件。监听器的使用SpringApplicationRunListenerSpringApplicationRunListener 的使用比较简单,实现该接口,并在META-INF/spring.factories中定义该实现MyApplicationRunListener.javapublic class MyApplicationRunListener implements SpringApplicationRunListener {
@Override
public void starting(ConfigurableBootstrapContext bootstrapContext) {
System.out.println("Application 启动");
}
@Override
public void environmentPrepared(ConfigurableBootstrapContext bootstrapContext, ConfigurableEnvironment environment) {
System.out.println("环境已准备完毕");
}
@Override
public void contextPrepared(ConfigurableApplicationContext context) {
System.out.println("在创建和准备ApplicationContext之后,但在加载源之前调用");
}
@Override
public void contextLoaded(ConfigurableApplicationContext context) {
System.out.println("上下文准备完毕,未刷新");
}
@Override
public void started(ConfigurableApplicationContext context, Duration timeTaken) {
System.out.println("上下文已刷新,应用程序已启动,但尚未调用CommandLineRunners和ApplicationRunners");
}
@Override
public void ready(ConfigurableApplicationContext context, Duration timeTaken) {
System.out.println("在刷新应用程序上下文并且调用了所有CommandLineRunner和ApplicationRunner之后,在运行方法完成之前立即调用");
}
@Override
public void failed(ConfigurableApplicationContext context, Throwable exception) {
System.out.println("当运行应用程序时发生故障时调用");
}
}META-INF/spring.factoriesorg.springframework.boot.SpringApplicationRunListener=com.springboot.demo.listeners.MyApplicationRunListener运行情况:ApplicationListener1、实现ApplicationListener接口MyApplicationListener.java@Slf4j
public class MyApplicationListener implements ApplicationListener<ApplicationStartedEvent> {
@Override
public void onApplicationEvent(ApplicationStartedEvent event) {
log.info("应用启动完成");
}
}META-INF/spring.factoriesorg.springframework.context.ApplicationListener=com.springboot.demo.listeners.MyApplicationListener2、addListener在springApplication 中添加,同样达到效果SpringApplication springApplication = new SpringApplication(SpringBootDemoApplication.class);
springApplication.addListeners(new MyApplicationListener());3、context.istener.classes在配置文件中添加该配置,value为MyApplicationListener的全路径限定名context:
listener:
classes: com.springboot.demo.listeners.MyApplicationListener4、@EventListener该注解是spring 提供的方式,支持同时监听多种事件,支持SpEL表达式@Component
@Slf4j
public class MyApplicationListener2 {
//监听单个事件
@EventListener
public void listenerApplicationStarted(ApplicationStartedEvent event) {
log.info("应用启动完成");
}
@EventListener({ApplicationEnvironmentPreparedEvent.class})
public void listenerApplicationEnv() {
//实际测试,没有监听到,后面说明原理
log.info("监听到了环境准备完成事件");
}
//监听多个事件
@EventListener({ApplicationReadyEvent.class, ApplicationStartedEvent.class})
public void listenerApplication() {
log.info("监听到了多个事件");
}
//自己发布了一个Person事件,Person并没有继承ApplicationEvent
@EventListener
public void myCustomListener(Person person) {
log.info("监听到自己发布的事件,{}", person);
}
//只有Person事件中name属性值为csdn时才接收到
@EventListener(condition = "#person.name == 'csdn'")
public void myCustomListener2(Person person) {
log.info("SpEL表达式监听到自己发布的事件,{}", person);
}
}原理解析SpringApplicationRunListener 的原理在之前的文章都有体现,可以查看《Spring Boot 框架整体启动流程详解》,我们只需要关注ApplicationListener。Spring Boot 中不同的使用方式有不同的加载,我们一个个来分析。1、从spring.factories中加载首先Spring Boot 会在SpringApplication初始化的时候从META-INF/spring.factories中加载ApplicationListener的实现,并保存在private List<ApplicationListener<?>> listeners;中,待后续使用。第二个关键是EventPublishingRunListener,在run方法中通过SpringApplicationRunListeners listeners = getRunListeners(args);加载,getRunListeners 从 spring.factories加载SpringApplicationRunListener的实现保存在SpringApplicationRunListeners内部,其相当于是代理器,Spring Boot 内部只定义了一个EventPublishingRunListener实现。在Spring Boot 中在不同的阶段调用不同的SpringApplicationRunListeners方法,如图只是部分以starting为例,会在SpringApplicationRunListeners内部通过循环前期加载的SpringApplicationRunListener实现,此处只需要关注EventPublishingRunListener进入EventPublishingRunListener的starting方法中,starting调用同类的multicastInitialEvent,事件定义为ApplicationStartingEventprivate void multicastInitialEvent(ApplicationEvent event) {
refreshApplicationListeners();
this.initialMulticaster.multicastEvent(event);
}refreshApplicationListeners 会从SpringApplication保存的listeners中读取初始化时加载的ApplicationListener实现,并添加到SimpleApplicationEventMulticaster的内部类DefaultListenerRetriever中,待后续使用。private void refreshApplicationListeners() {
this.application.getListeners().forEach(this.initialMulticaster::addApplicationListener);
}第三个关键是SimpleApplicationEventMulticaster,this.initialMulticaster.multicastEvent(event) 调用到了SimpleApplicationEventMulticaster中,multicastEvent又调用了一个同名方法。public void multicastEvent(ApplicationEvent event) {
multicastEvent(event, null);
}
@Override
public void multicastEvent(ApplicationEvent event, @Nullable ResolvableType eventType) {
//获取事件类的类型信息
ResolvableType type = (eventType != null ? eventType : ResolvableType.forInstance(event));
// 获取执行事件的线程池,如果设置了,可以异步执行
Executor executor = getTaskExecutor();
//获取指定事件类型的监听器集合
for (ApplicationListener<?> listener : getApplicationListeners(event, type)) {
//如果定义了执行线程池,则用线程池调用
if (executor != null) {
executor.execute(() -> invokeListener(listener, event));
}
else {
//同步调用监听器
invokeListener(listener, event);
}
}
}
protected void invokeListener(ApplicationListener<?> listener, ApplicationEvent event) {
//获取失败处理器
ErrorHandler errorHandler = getErrorHandler();
if (errorHandler != null) {
try {
doInvokeListener(listener, event);
}
catch (Throwable err) {
errorHandler.handleError(err);
}
}
else {
doInvokeListener(listener, event);
}
}
@SuppressWarnings({"rawtypes", "unchecked"})
private void doInvokeListener(ApplicationListener listener, ApplicationEvent event) {
try {
//此处执行事件监听器的onApplicationEvent方法
listener.onApplicationEvent(event);
}
catch (ClassCastException ex) {
String msg = ex.getMessage();
if (msg == null || matchesClassCastMessage(msg, event.getClass()) ||
(event instanceof PayloadApplicationEvent payloadEvent &&
matchesClassCastMessage(msg, payloadEvent.getPayload().getClass()))) {
// Possibly a lambda-defined listener which we could not resolve the generic event type for
// -> let's suppress the exception.
Log loggerToUse = this.lazyLogger;
if (loggerToUse == null) {
loggerToUse = LogFactory.getLog(getClass());
this.lazyLogger = loggerToUse;
}
if (loggerToUse.isTraceEnabled()) {
loggerToUse.trace("Non-matching event type for listener: " + listener, ex);
}
}
else {
throw ex;
}
}
}实际上到这里流程已经走完了,最后listener.onApplicationEvent(event);调用到自定义的MyApplicationListener中。对于如何获取指定事件类型的监听器集合,getApplicationListeners(event, type),代码比较复杂,可看也可不看。getApplicationListeners 方法在SimpleApplicationEventMulticaster 的父类AbstractApplicationEventMulticaster中,传入传播的事件类bean和事件的类型信息。protected Collection<ApplicationListener<?>> getApplicationListeners(
ApplicationEvent event, ResolvableType eventType) {
//获取事件发生的对象
Object source = event.getSource();
Class<?> sourceType = (source != null ? source.getClass() : null);
//根据事件的类型信息和源对象组成一个监听器的缓存key
ListenerCacheKey cacheKey = new ListenerCacheKey(eventType, sourceType);
// 创建一个新的监听器检索缓存
CachedListenerRetriever newRetriever = null;
// 根据key从检索缓存中获取缓存的监听器封装类
CachedListenerRetriever existingRetriever = this.retrieverCache.get(cacheKey);
//如果不存在
if (existingRetriever == null) {
//判断事件类型和源对象能否用指定的classLoader加载
// 创建并缓存一个新的ListenerRetriever
if (this.beanClassLoader == null ||
(ClassUtils.isCacheSafe(event.getClass(), this.beanClassLoader) &&
(sourceType == null || ClassUtils.isCacheSafe(sourceType, this.beanClassLoader)))) {
newRetriever = new CachedListenerRetriever();
//如果指定键没有关联值,则存入新值,返回null,有关联值返回关联值
existingRetriever = this.retrieverCache.putIfAbsent(cacheKey, newRetriever);
//有关联值,就不填充新值,将创建的对象取消关联
if (existingRetriever != null) {
newRetriever = null;
}
}
}
//缓存检索器中有值,就返回缓存的事件监听器列表
if (existingRetriever != null) {
Collection<ApplicationListener<?>> result = existingRetriever.getApplicationListeners();
if (result != null) {
return result;
}
}
//缓存检索器中没有值的话,继续检索
return retrieveApplicationListeners(eventType, sourceType, newRetriever);
}private Collection<ApplicationListener<?>> retrieveApplicationListeners(
ResolvableType eventType, @Nullable Class<?> sourceType, @Nullable CachedListenerRetriever retriever) {
List<ApplicationListener<?>> allListeners = new ArrayList<>();
Set<ApplicationListener<?>> filteredListeners = (retriever != null ? new LinkedHashSet<>() : null);
Set<String> filteredListenerBeans = (retriever != null ? new LinkedHashSet<>() : null);
Set<ApplicationListener<?>> listeners;
Set<String> listenerBeans;
//从默认检索器中读取监听器列表和监听器bean名称
synchronized (this.defaultRetriever) {
listeners = new LinkedHashSet<>(this.defaultRetriever.applicationListeners);
listenerBeans = new LinkedHashSet<>(this.defaultRetriever.applicationListenerBeans);
}
// 循环添加已经注册的监听器,包括ApplicationListenerDetector加载的监听器
for (ApplicationListener<?> listener : listeners) {
//检查指定的监听器是否是需要关注的事件
if (supportsEvent(listener, eventType, sourceType)) {
if (retriever != null) {
filteredListeners.add(listener);
}
allListeners.add(listener);
}
}
// 通过bean名称来添加监听器,可能与上面的方式重叠,但这里会有一些新的元数据
if (!listenerBeans.isEmpty()) {
//获取bean工厂
ConfigurableBeanFactory beanFactory = getBeanFactory();
for (String listenerBeanName : listenerBeans) {
try {
//判断指定的监听器bean是否是需要关注的事件
if (supportsEvent(beanFactory, listenerBeanName, eventType)) {
//获取监听器bean
ApplicationListener<?> listener =
beanFactory.getBean(listenerBeanName, ApplicationListener.class);
//最终判断
if (!allListeners.contains(listener) && supportsEvent(listener, eventType, sourceType)) {
if (retriever != null) {
if (beanFactory.isSingleton(listenerBeanName)) {
filteredListeners.add(listener);
}
else {
filteredListenerBeans.add(listenerBeanName);
}
}
allListeners.add(listener);
}
}
else {
// 移除不支持的监听器
Object listener = beanFactory.getSingleton(listenerBeanName);
if (retriever != null) {
filteredListeners.remove(listener);
}
allListeners.remove(listener);
}
}
catch (NoSuchBeanDefinitionException ex) {
}
}
}
//排序
AnnotationAwareOrderComparator.sort(allListeners);
if (retriever != null) {
if (filteredListenerBeans.isEmpty()) {
retriever.applicationListeners = new LinkedHashSet<>(allListeners);
retriever.applicationListenerBeans = filteredListenerBeans;
}
else {
retriever.applicationListeners = filteredListeners;
retriever.applicationListenerBeans = filteredListenerBeans;
}
}
return allListeners;
}看下最终判断的部分:supportsEvent(listener, eventType, sourceType)protected boolean supportsEvent(
ApplicationListener<?> listener, ResolvableType eventType, @Nullable Class<?> sourceType) {
GenericApplicationListener smartListener = (listener instanceof GenericApplicationListener gal ? gal :
new GenericApplicationListenerAdapter(listener));
//通过判断给定的事件类型是否与要关注的事件类型一致,并且支持给定的源类型
return (smartListener.supportsEventType(eventType) && smartListener.supportsSourceType(sourceType));
}这里会将监听器包装成GenericApplicationListenerAdapter,在构造器中解析出监听器关注的事件类型信息。public GenericApplicationListenerAdapter(ApplicationListener<?> delegate) {
Assert.notNull(delegate, "Delegate listener must not be null");
this.delegate = (ApplicationListener<ApplicationEvent>) delegate;
//解析出事件类型信息
this.declaredEventType = resolveDeclaredEventType(this.delegate);
}public boolean supportsEventType(ResolvableType eventType) {
//如果是GenericApplicationListener 的实现,它扩展了SmartApplicationListener
if (this.delegate instanceof GenericApplicationListener gal) {
return gal.supportsEventType(eventType);
}
//如果是SmartApplicationListener的实现
else if (this.delegate instanceof SmartApplicationListener sal) {
Class<? extends ApplicationEvent> eventClass = (Class<? extends ApplicationEvent>) eventType.resolve();
return (eventClass != null && sal.supportsEventType(eventClass));
}
else {
//其他类型判断
return (this.declaredEventType == null || this.declaredEventType.isAssignableFrom(eventType));
}
}
@Override
public boolean supportsSourceType(@Nullable Class<?> sourceType) {
return (!(this.delegate instanceof SmartApplicationListener sal) || sal.supportsSourceType(sourceType));
}2、addListener由于addListener是在run方法执行之前就添加到了SpringApplication中,所以加载原理同第一种方式相同3、context.listener.classes该配置的监听器,由Spring Boot 内置的DelegatingApplicationListener处理,该监听器定义在Spring Boot Jar包的META-INF/spring.factories中。public void onApplicationEvent(ApplicationEvent event) {
//环境准备完毕
if (event instanceof ApplicationEnvironmentPreparedEvent preparedEvent) {
//从context.listener.classes加载配置的事件监听器
List<ApplicationListener<ApplicationEvent>> delegates = getListeners(preparedEvent.getEnvironment());
if (delegates.isEmpty()) {
return;
}
//新创建一个SimpleApplicationEventMulticaster,跟以前用的不是同一个
this.multicaster = new SimpleApplicationEventMulticaster();
for (ApplicationListener<ApplicationEvent> listener : delegates) {
this.multicaster.addApplicationListener(listener);
}
}
if (this.multicaster != null) {
//监听到其他事件的时候向所有注册在该广播器上的监听器广播事件
this.multicaster.multicastEvent(event);
}
}this.multicaster.multicastEvent(event);后面的逻辑与前面的相同4、@EventListener在之前的实例中,我们监听了一个ApplicationEnvironmentPreparedEvent事件,但实际测试却没有监听到,因为@EventListener要在SpringApplication.run的refreshContext中才会被加载,而ApplicationEnvironmentPreparedEvent事件发生在refreshContext之前。@EventListener 是Spring 提供的注解,在EventListenerMethodProcessor中被加载,并包装成ApplicationListener实例。Spring Boot 的refreshContext 最终会调用到Spring 的AbstractApplicationContext refresh() 。EventListenerMethodProcessor是一个BeanFactoryPostProcessor,会在refresh 的invokeBeanFactoryPostProcessors(beanFactory) 中进行调用protected void invokeBeanFactoryPostProcessors(ConfigurableListableBeanFactory beanFactory) {
//使用注册委托类处理BeanFactoryPostProcessor的实现
PostProcessorRegistrationDelegate.invokeBeanFactoryPostProcessors(beanFactory, getBeanFactoryPostProcessors());
// Detect a LoadTimeWeaver and prepare for weaving, if found in the meantime
// (e.g. through an @Bean method registered by ConfigurationClassPostProcessor)
if (!NativeDetector.inNativeImage() && beanFactory.getTempClassLoader() == null && beanFactory.containsBean(LOAD_TIME_WEAVER_BEAN_NAME)) {
beanFactory.addBeanPostProcessor(new LoadTimeWeaverAwareProcessor(beanFactory));
beanFactory.setTempClassLoader(new ContextTypeMatchClassLoader(beanFactory.getBeanClassLoader()));
}
}getBeanFactoryPostProcessors()会获取已经加载的BeanFactoryPostProcessor实现,比如准备上下文中的PropertySourceOrderingBeanFactoryPostProcessor。PostProcessorRegistrationDelegate.invokeBeanFactoryPostProcessors内部的方法很长public static void invokeBeanFactoryPostProcessors(
ConfigurableListableBeanFactory beanFactory, List<BeanFactoryPostProcessor> beanFactoryPostProcessors) {
Set<String> processedBeans = new HashSet<>();
//首先处理是BeanDefinitionRegistry的实例
if (beanFactory instanceof BeanDefinitionRegistry registry) {
List<BeanFactoryPostProcessor> regularPostProcessors = new ArrayList<>();
List<BeanDefinitionRegistryPostProcessor> registryProcessors = new ArrayList<>();
for (BeanFactoryPostProcessor postProcessor : beanFactoryPostProcessors) {
if (postProcessor instanceof BeanDefinitionRegistryPostProcessor registryProcessor) {
registryProcessor.postProcessBeanDefinitionRegistry(registry);
registryProcessors.add(registryProcessor);
}
else {
regularPostProcessors.add(postProcessor);
}
}
// 然后其中分别处理实现了 PriorityOrdered、Ordered 和其余的处理器
List<BeanDefinitionRegistryPostProcessor> currentRegistryProcessors = new ArrayList<>();
//处理实现了PriorityOrdered的处理器
String[] postProcessorNames =
beanFactory.getBeanNamesForType(BeanDefinitionRegistryPostProcessor.class, true, false);
for (String ppName : postProcessorNames) {
if (beanFactory.isTypeMatch(ppName, PriorityOrdered.class)) {
currentRegistryProcessors.add(beanFactory.getBean(ppName, BeanDefinitionRegistryPostProcessor.class));
processedBeans.add(ppName);
}
}
sortPostProcessors(currentRegistryProcessors, beanFactory);
registryProcessors.addAll(currentRegistryProcessors);
invokeBeanDefinitionRegistryPostProcessors(currentRegistryProcessors, registry, beanFactory.getApplicationStartup());
currentRegistryProcessors.clear();
// 处理实现了Ordered的处理器
postProcessorNames = beanFactory.getBeanNamesForType(BeanDefinitionRegistryPostProcessor.class, true, false);
for (String ppName : postProcessorNames) {
if (!processedBeans.contains(ppName) && beanFactory.isTypeMatch(ppName, Ordered.class)) {
currentRegistryProcessors.add(beanFactory.getBean(ppName, BeanDefinitionRegistryPostProcessor.class));
processedBeans.add(ppName);
}
}
sortPostProcessors(currentRegistryProcessors, beanFactory);
registryProcessors.addAll(currentRegistryProcessors);
invokeBeanDefinitionRegistryPostProcessors(currentRegistryProcessors, registry, beanFactory.getApplicationStartup());
currentRegistryProcessors.clear();
// 最后是剩下的处理器
boolean reiterate = true;
while (reiterate) {
reiterate = false;
postProcessorNames = beanFactory.getBeanNamesForType(BeanDefinitionRegistryPostProcessor.class, true, false);
for (String ppName : postProcessorNames) {
if (!processedBeans.contains(ppName)) {
currentRegistryProcessors.add(beanFactory.getBean(ppName, BeanDefinitionRegistryPostProcessor.class));
processedBeans.add(ppName);
reiterate = true;
}
}
sortPostProcessors(currentRegistryProcessors, beanFactory);
registryProcessors.addAll(currentRegistryProcessors);
invokeBeanDefinitionRegistryPostProcessors(currentRegistryProcessors, registry, beanFactory.getApplicationStartup());
currentRegistryProcessors.clear();
}
// 调用迄今为止处理的所有处理器的postProcessBeanFactory回调
invokeBeanFactoryPostProcessors(registryProcessors, beanFactory);
invokeBeanFactoryPostProcessors(regularPostProcessors, beanFactory);
}
else {
// 其他情况调用在上下文实例中注册的工厂处理程序
invokeBeanFactoryPostProcessors(beanFactoryPostProcessors, beanFactory);
}
// 处理 BeanFactoryPostProcessor 实现的实例
String[] postProcessorNames =
beanFactory.getBeanNamesForType(BeanFactoryPostProcessor.class, true, false);
//在实现PriorityOrdered、Ordered和其他的BeanFactoryPostProcessors之间分离
List<BeanFactoryPostProcessor> priorityOrderedPostProcessors = new ArrayList<>();
List<String> orderedPostProcessorNames = new ArrayList<>();
List<String> nonOrderedPostProcessorNames = new ArrayList<>();
for (String ppName : postProcessorNames) {
if (processedBeans.contains(ppName)) {
// 跳过-已在上面的第一阶段中处理
}
else if (beanFactory.isTypeMatch(ppName, PriorityOrdered.class)) {
priorityOrderedPostProcessors.add(beanFactory.getBean(ppName, BeanFactoryPostProcessor.class));
}
else if (beanFactory.isTypeMatch(ppName, Ordered.class)) {
orderedPostProcessorNames.add(ppName);
}
else {
nonOrderedPostProcessorNames.add(ppName);
}
}
// 首先,调用实现PriorityOrdered的BeanFactoryPostProcessors
sortPostProcessors(priorityOrderedPostProcessors, beanFactory);
invokeBeanFactoryPostProcessors(priorityOrderedPostProcessors, beanFactory);
//调用实现Ordered的BeanFactoryPostProcessors
List<BeanFactoryPostProcessor> orderedPostProcessors = new ArrayList<>(orderedPostProcessorNames.size());
for (String postProcessorName : orderedPostProcessorNames) {
orderedPostProcessors.add(beanFactory.getBean(postProcessorName, BeanFactoryPostProcessor.class));
}
sortPostProcessors(orderedPostProcessors, beanFactory);
invokeBeanFactoryPostProcessors(orderedPostProcessors, beanFactory);
// 最后调用其他的BeanFactoryPostProcessors
List<BeanFactoryPostProcessor> nonOrderedPostProcessors = new ArrayList<>(nonOrderedPostProcessorNames.size());
for (String postProcessorName : nonOrderedPostProcessorNames) {
nonOrderedPostProcessors.add(beanFactory.getBean(postProcessorName, BeanFactoryPostProcessor.class));
}
//EventListenerMethodProcessor会在此处被调用
invokeBeanFactoryPostProcessors(nonOrderedPostProcessors, beanFactory);
//清除缓存的合并bean定义,因为后处理程序可能已经修改了原始元数据,例如替换值中的占位符。。。
beanFactory.clearMetadataCache();
}private static void invokeBeanFactoryPostProcessors(
Collection<? extends BeanFactoryPostProcessor> postProcessors, ConfigurableListableBeanFactory beanFactory) {
for (BeanFactoryPostProcessor postProcessor : postProcessors) {
//步骤记录器
StartupStep postProcessBeanFactory = beanFactory.getApplicationStartup().start("spring.context.bean-factory.post-process")
.tag("postProcessor", postProcessor::toString);
//循环调用postProcessBeanFactory
postProcessor.postProcessBeanFactory(beanFactory);
postProcessBeanFactory.end();
}
}EventListenerMethodProcessor类中:public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {
this.beanFactory = beanFactory;
//获取EventListenerFactory实现类,其用于处理EventListener注解,
//将其封装成ApplicationListener
Map<String, EventListenerFactory> beans = beanFactory.getBeansOfType(EventListenerFactory.class, false, false);
List<EventListenerFactory> factories = new ArrayList<>(beans.values());
AnnotationAwareOrderComparator.sort(factories);
this.eventListenerFactories = factories;
}EventListenerMethodProcessor 实现了SmartInitializingSingleton接口,会在refresh中的finishBeanFactoryInitialization(beanFactory)处调用,finishBeanFactoryInitialization 的作用是实例化所有剩余的非惰性单例。DefaultListableBeanFactory类中://预实例化所有非懒加载的单例 bean,并触发所有适用 bean 的初始化后回调。
public void preInstantiateSingletons() throws BeansException {
if (logger.isTraceEnabled()) {
logger.trace("Pre-instantiating singletons in " + this);
}
// 访问 beanDefinitionNames,以允许初始化方法注册新的 bean 定义的列表的副本
List<String> beanNames = new ArrayList<>(this.beanDefinitionNames);
// 触发所有非延迟加载的单例 bean 的实例化
for (String beanName : beanNames) {
RootBeanDefinition bd = getMergedLocalBeanDefinition(beanName);
if (!bd.isAbstract() && bd.isSingleton() && !bd.isLazyInit()) {
//如果是工厂 bean,检查是否需要实例化
if (isFactoryBean(beanName)) {
Object bean = getBean(FACTORY_BEAN_PREFIX + beanName);
if (bean instanceof SmartFactoryBean<?> smartFactoryBean && smartFactoryBean.isEagerInit()) {
getBean(beanName);
}
}
//如果不是工厂bean,则实例化 bean
else {
getBean(beanName);
}
}
}
// 触发所有适用bean的初始化后回调
for (String beanName : beanNames) {
Object singletonInstance = getSingleton(beanName);
if (singletonInstance instanceof SmartInitializingSingleton smartSingleton) {
// 启动一个 smart-initialize 的 StartupStep 作为性能分析;
// 在执行完 smartSingleton.afterSingletonsInstantiated() 之后结束这个 StartupStep。
StartupStep smartInitialize = this.getApplicationStartup().start("spring.beans.smart-initialize")
.tag("beanName", beanName);
smartSingleton.afterSingletonsInstantiated();
smartInitialize.end();
}
}
}通过smartSingleton.afterSingletonsInstantiated()又执行到了EventListenerMethodProcessor的afterSingletonsInstantiated,后面又是一长串,我们直接看最后的重点吧。首先根据@EventListener创建成ApplicationListener,然后通过addApplicationListener将监听器存入上下文中,后面的逻辑跟前面是相同的。内置的监听器Spring Boot 内置了不少监听器,每个监听器都有自己的作用ClearCachesApplicationListener应用上下文加载完成后对缓存做清除工作ParentContextCloserApplicationListener父应用程序上下文关闭时,会将关闭事件向下传播以关闭该应用程序上下文FileEncodingApplicationListener用于监听应用程序环境准备完毕时,如果系统文件编码(spring.mandatory-file-encoding)与环境中配置的值(file.encoding)不匹配时(忽略大小写),会抛出异常,并停止应用程序AnsiOutputApplicationListener根据spring.output.ansi.enabled参数配置AnsiOutputDelegatingApplicationListener用于委托管理context.listener.classes中配置的监听器LoggingApplicationListener配置和初始化Spring Boot 的日志系统EnvironmentPostProcessorApplicationListener管理spring.factories文件中注册的EnvironmentPostProcessors内置的事件Spring Boot 包中部分事件:BootstrapContextClosedEvent、ExitCodeEvent、AvailabilityChangeEvent、ParentContextAvailableEvent、ApplicationContextInitializedEvent、ApplicationEnvironmentPreparedEvent、ApplicationFailedEvent、ApplicationPreparedEvent、ApplicationReadyEvent、ApplicationStartedEvent、ApplicationStartingEvent、WebServerInitializedEvent、ReactiveWebServerInitializedEvent、ServletWebServerInitializedEventSpring 包中部分事件:ContextClosedEvent、ContextRefreshedEvent、ContextStartedEvent、ContextStoppedEvent、ServletRequestHandledEvent总结最后还是用一张图来总结整个流程
cathoy
Spring Boot banner详解
自定义bannerSpring Boot 默认打印的banner是这样的,Java工程师看都看腻了。一般的公司如果有自己脚手架,都会选择自定义banner,放一个公司Logo或者框架别名。简易版banner首先生成一个自己的banner,比如我生成的生成的网站很多,可以用"banner 生成器"自行搜索把生成的内容copy到txt中,命名为"banner.txt"(UTF-8),然后放到resources下。启动Spring Boot 即可看到效果。自定义banner路径上述的banner.txt 只能放在resources根目录下,不能在resources子目录或其他的目录,使用spring.banner.location指定该文件的路径,如果该文件不是UTF-8编码,使用spring.banner.charset指定文件编码,比如我将文件放到resources的子目录static中。自定义banner 样式光一个Logo也还是太单调,如果能再打印个Spring Boot 版本、应用程序版本就更好了,Spring Boot 都给我们提供了相关变量,可以在banner.txt中使用。${application.version} 应用程序版本${application.formatted-version} 格式化的应用程序版本,前缀是v${spring-boot.version} Spring Boot框架版本${spring-boot.formatted-version} 格式化的Spring Boot框架版本,前缀是v${Ansi.NAME} (or ${AnsiColor.NAME}, ${AnsiBackground.NAME}, ${AnsiStyle.NAME})给文字加颜色,加背景,加样式,NAME的值可以在这里获取ansi${application.title} 应用程序的标题如图我给banner 加上了颜色,加上了版本配置如下:${AnsiColor.GREEN}
/$$$$$$ /$$$$$$ /$$$$$$$ /$$ /$$ /$$$$$$ /$$$$$$$$ /$$$$$$ /$$$$$$ /$$ /$$ /$$$$$$ /$$$$$$ /$$ /$$
/$$__ $$ /$$__ $$| $$__ $$| $$$ | $$ |_ $$_/|__ $$__//$$__ $$ /$$__ $$| $$ /$$//$$__ $$ /$$__ $$| $$ /$$/
| $$ \__/| $$ \__/| $$ \ $$| $$$$| $$ | $$ | $$ | $$ \__/| $$ \ $$ \ $$ /$$/| $$ \__/| $$ \ $$ \ $$ /$$/
| $$ | $$$$$$ | $$ | $$| $$ $$ $$ /$$$$$$| $$ | $$ | $$$$$$ | $$$$$$$$ \ $$$$/ | $$$$$$ | $$$$$$$$ \ $$$$/
| $$ \____ $$| $$ | $$| $$ $$$$|______/| $$ | $$ \____ $$| $$__ $$ \ $$/ \____ $$| $$__ $$ \ $$/
| $$ $$ /$$ \ $$| $$ | $$| $$\ $$$ | $$ | $$ /$$ \ $$| $$ | $$ | $$ /$$ \ $$| $$ | $$ | $$
| $$$$$$/| $$$$$$/| $$$$$$$/| $$ \ $$ /$$$$$$ | $$ | $$$$$$/| $$ | $$ | $$ | $$$$$$/| $$ | $$ | $$
\______/ \______/ |_______/ |__/ \__/ |______/ |__/ \______/ |__/ |__/ |__/ \______/ |__/ |__/ |__/
${AnsiColor.DEFAULT}
::Spring Boot Version: ${AnsiColor.RED}${spring-boot.formatted-version}${AnsiColor.DEFAULT}
::Application Version: ${AnsiColor.RED}${application.formatted-version}${AnsiColor.DEFAULT}
如果颜色不起作用,那就是需要开启一下:spring.output.ansi.enabled=always${AnsiColor.DEFAULT} 是将颜色重置,防止前面的颜色影响下面的${application.formatted-version} 、 ${application.version}、 ${application.title} 要使用jar包启动才会打印banner.txt 中可以配置环境变量environment中的任何键值使用图片做banner在Spring Boot 3.x版本中已经不被支持编码方式定义banner自定义一个CustomBanner类,实现Banner接口,如:import org.springframework.boot.Banner;
import org.springframework.core.env.Environment;
import java.io.PrintStream;
public class CustomBanner implements Banner {
public static final String BANNER = " /$$$$$$ /$$$$$$ /$$$$$$$ /$$ /$$ /$$$$$$ /$$$$$$$$ /$$$$$$ /$$$$$$ /$$ /$$ /$$$$$$ /$$$$$$ /$$ /$$\n" +
" /$$__ $$ /$$__ $$| $$__ $$| $$$ | $$ |_ $$_/|__ $$__//$$__ $$ /$$__ $$| $$ /$$//$$__ $$ /$$__ $$| $$ /$$/\n" +
"| $$ \\__/| $$ \\__/| $$ \\ $$| $$$$| $$ | $$ | $$ | $$ \\__/| $$ \\ $$ \\ $$ /$$/| $$ \\__/| $$ \\ $$ \\ $$ /$$/ \n" +
"| $$ | $$$$$$ | $$ | $$| $$ $$ $$ /$$$$$$| $$ | $$ | $$$$$$ | $$$$$$$$ \\ $$$$/ | $$$$$$ | $$$$$$$$ \\ $$$$/ \n" +
"| $$ \\____ $$| $$ | $$| $$ $$$$|______/| $$ | $$ \\____ $$| $$__ $$ \\ $$/ \\____ $$| $$__ $$ \\ $$/ \n" +
"| $$ $$ /$$ \\ $$| $$ | $$| $$\\ $$$ | $$ | $$ /$$ \\ $$| $$ | $$ | $$ /$$ \\ $$| $$ | $$ | $$ \n" +
"| $$$$$$/| $$$$$$/| $$$$$$$/| $$ \\ $$ /$$$$$$ | $$ | $$$$$$/| $$ | $$ | $$ | $$$$$$/| $$ | $$ | $$ \n" +
" \\______/ \\______/ |_______/ |__/ \\__/ |______/ |__/ \\______/ |__/ |__/ |__/ \\______/ |__/ |__/ |__/ \n";
@Override
public void printBanner(Environment environment, Class<?> sourceClass, PrintStream printStream) {
printStream.println(BANNER);
}
}SpringApplication 中配置bannerSpringApplication springApplication = new SpringApplication(SpringBootDemoApplication.class);
springApplication.setBanner(new CustomBanner());如果在类路径中存在banner.txt 在会优先使用banner.txt禁用bannerSpring Boot 提供了spring.main.banner-mode 配置,OFF-关闭banner打印,CONSOLE-使用System.out打印banner,log文件不会记录,LOG-打印到log文件另外同样可以用springApplication.setBannerMode(Banner.Mode.OFF);方式设置banner加载打印原理在之前的《Spring Boot 框架整体启动流程详解》中,我们看到有一步是//打印banner
Banner printedBanner = printBanner(environment);这一步就是加载打印banner的核心。private Banner printBanner(ConfigurableEnvironment environment) {
//如果bannerMode是关闭的,就会不打印banner
if (this.bannerMode == Banner.Mode.OFF) {
return null;
}
//获取资源加载器,如果当前没有就使用默认的资源加载器
ResourceLoader resourceLoader = (this.resourceLoader != null) ? this.resourceLoader
: new DefaultResourceLoader(null);
//创建一个SpringBoot应用程序的banner打印类,如果通过setBanner设置,this.banner会有值
SpringApplicationBannerPrinter bannerPrinter = new SpringApplicationBannerPrinter(resourceLoader, this.banner);
//如果bannerMode 是打印到log文件
if (this.bannerMode == Mode.LOG) {
return bannerPrinter.print(environment, this.mainApplicationClass, logger);
}
//默认打印到控制台
return bannerPrinter.print(environment, this.mainApplicationClass, System.out);
}先看下如何打印到log文件:Banner print(Environment environment, Class<?> sourceClass, Log logger) {
//获取Banner对象
Banner banner = getBanner(environment);
try {
//使用log 的info 级别打印
logger.info(createStringFromBanner(banner, environment, sourceClass));
}
catch (UnsupportedEncodingException ex) {
logger.warn("Failed to create String for banner", ex);
}
//创建一个打印banner的装饰器bean,允许后期再次使用
return new PrintedBanner(banner, sourceClass);
}如何获取的Banner对象:private Banner getBanner(Environment environment) {
//获取txt文本banner
Banner textBanner = getTextBanner(environment);
if (textBanner != null) {
return textBanner;
}
//fallbackBanner 为前期通过setBanner设置的自定义banner
//可见如果两者同时设置,优先使用的txt文本banner
if (this.fallbackBanner != null) {
return this.fallbackBanner;
}
//都没有,返回一个默认的SpringBootBanner
return DEFAULT_BANNER;
}如果获取txt文本banner:private Banner getTextBanner(Environment environment) {
//从环境变量中获取spring.banner.location指定的banner地址,如果没有,使用banner.txt
String location = environment.getProperty(BANNER_LOCATION_PROPERTY, DEFAULT_BANNER_LOCATION);
//获取指定的资源对象
Resource resource = this.resourceLoader.getResource(location);
try {
//资源存在,并且资源路径中不包含liquibase-core
if (resource.exists() && !resource.getURL().toExternalForm().contains("liquibase-core")) {
//创建一个从源打印的banner对象,实现了Banner接口
return new ResourceBanner(resource);
}
}
catch (IOException ex) {
// Ignore
}
return null;
}获取到文本banner的ResourceBanner资源对象后,回到print(Environment environment, Class<?> sourceClass, Log logger) 这个方法中,接下来就是要把获取到banner对象打印出来,createStringFromBanner将获取到banner对象,调用其中的printBanner方法,把输出流转为UTF-8的字符串输出到log文件中。private String createStringFromBanner(Banner banner, Environment environment, Class<?> mainApplicationClass)
throws UnsupportedEncodingException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
banner.printBanner(environment, mainApplicationClass, new PrintStream(baos));
String charset = environment.getProperty("spring.banner.charset", "UTF-8");
return baos.toString(charset);
}ResourceBanner的printBanner方法:public void printBanner(Environment environment, Class<?> sourceClass, PrintStream out) {
try {
//从流读取banner.txt 字符串,使用spring.banner.charset编码,或者UTF-8
String banner = StreamUtils.copyToString(this.resource.getInputStream(),
environment.getProperty("spring.banner.charset", Charset.class, StandardCharsets.UTF_8));
//获取用于解析占位符的所有属性源
for (PropertyResolver resolver : getPropertyResolvers(environment, sourceClass)) {
//解析banner,使用PropertyPlaceholderHelper工具类解析
banner = resolver.resolvePlaceholders(banner);
}
//输出到流中
out.println(banner);
}
catch (Exception ex) {
logger.warn(LogMessage.format("Banner not printable: %s (%s: '%s')", this.resource, ex.getClass(),
ex.getMessage()), ex);
}
}bannerPrinter.print(environment, this.mainApplicationClass, System.out);打印到控制台的逻辑也是一样的,只是直接输出到控制台Banner print(Environment environment, Class<?> sourceClass, PrintStream out) {
Banner banner = getBanner(environment);
banner.printBanner(environment, sourceClass, out);
return new PrintedBanner(banner, sourceClass);
}总结通过图来总结一下整个流程
cathoy
Spring Boot 3.x 自动配置详解
Spring Boot :3.1Java: 17前言Spring Boot 3.x 中的自动配置使用META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports ,而不是META-INF/spring.factories,这个变动其实在2.7的时候已经改变2.6.9版本文档介绍2.7.0版本介绍文档中有创建自己的Starter的详细介绍,《Spring Boot 中文参考指南-创建自己的自动配置》加载原理Spring Boot 3.x的自动配置加载入口是META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports ,Spring Boot会读取该文件中的自动配置类,并实例化,我们以该文件为入口。如果你是新手,并且没有资料可查,可以使用IDE的全局文件搜索功能,搜索关键词,如我搜索org.springframework.boot.autoconfigure.AutoConfiguration.imports 的结果如下,再通过打断点的方式就能判断加载该文件的入口。可知,该文件的加载是由AutoConfigurationImportSelector类进行处理,但AutoConfigurationImportSelector类又是如何加载的。通过断点的堆栈可知加载使用到了Spring 框架refresh()中的invokeBeanFactoryPostProcessors,其作用是在实例化Bean之前加载额外定义的Bean到上下文中,我们从头开始梳理,能力强的可以掌握方法自行阅读。堆栈信息AbstractApplicationContext-invokeBeanFactoryPostProcessors protected void invokeBeanFactoryPostProcessors(ConfigurableListableBeanFactory beanFactory) {
PostProcessorRegistrationDelegate.invokeBeanFactoryPostProcessors(beanFactory, getBeanFactoryPostProcessors());
// 如果beanFactory中是否包含LoadTimeWeaver,如果包含则使用临时ClassLoader进行处理,LoadTimeWeaver是一种类加载器的动态织入技术
if (!NativeDetector.inNativeImage() && beanFactory.getTempClassLoader() == null && beanFactory.containsBean(LOAD_TIME_WEAVER_BEAN_NAME)) {
beanFactory.addBeanPostProcessor(new LoadTimeWeaverAwareProcessor(beanFactory));
beanFactory.setTempClassLoader(new ContextTypeMatchClassLoader(beanFactory.getBeanClassLoader()));
}
}该方法的作用是:实例化并调用注册的BeanFactoryPostProcessorgetBeanFactoryPostProcessors() 用来获取所有的BeanFactoryPostProcessor实例,BeanFactoryPostProcessor实例通过ApplicationContextInitializer来加载到上下文中,ApplicationContextInitializer的加载原理可以通过前面的文章了解《Spring Boot 系统初始化器详解》。PostProcessorRegistrationDelegate.invokeBeanFactoryPostProcessors(beanFactory, getBeanFactoryPostProcessors()); 委托处理BeanFactoryPostProcessor。PostProcessorRegistrationDelegate-invokeBeanFactoryPostProcessorspublic static void invokeBeanFactoryPostProcessors(
ConfigurableListableBeanFactory beanFactory, List<BeanFactoryPostProcessor> beanFactoryPostProcessors) {
Set<String> processedBeans = new HashSet<>();
//如果beanFactory 是BeanDefinitionRegistry的实现,先处理BeanDefinitionRegistryPostProcessors
if (beanFactory instanceof BeanDefinitionRegistry registry) {
List<BeanFactoryPostProcessor> regularPostProcessors = new ArrayList<>();
List<BeanDefinitionRegistryPostProcessor> registryProcessors = new ArrayList<>();
for (BeanFactoryPostProcessor postProcessor : beanFactoryPostProcessors) {
if (postProcessor instanceof BeanDefinitionRegistryPostProcessor registryProcessor) {
registryProcessor.postProcessBeanDefinitionRegistry(registry);
registryProcessors.add(registryProcessor);
}
else {
regularPostProcessors.add(postProcessor);
}
}
List<BeanDefinitionRegistryPostProcessor> currentRegistryProcessors = new ArrayList<>();
// 先处理 PriorityOrdered 的 BeanDefinitionRegistryPostProcessor
String[] postProcessorNames =
beanFactory.getBeanNamesForType(BeanDefinitionRegistryPostProcessor.class, true, false);
for (String ppName : postProcessorNames) {
if (beanFactory.isTypeMatch(ppName, PriorityOrdered.class)) {
currentRegistryProcessors.add(beanFactory.getBean(ppName, BeanDefinitionRegistryPostProcessor.class));
processedBeans.add(ppName);
}
}
sortPostProcessors(currentRegistryProcessors, beanFactory);
registryProcessors.addAll(currentRegistryProcessors);
//处理BeanDefinitionRegistryPostProcessors
invokeBeanDefinitionRegistryPostProcessors(currentRegistryProcessors, registry, beanFactory.getApplicationStartup());
currentRegistryProcessors.clear();
// 再处理 Ordered 的 BeanDefinitionRegistryPostProcessor
postProcessorNames = beanFactory.getBeanNamesForType(BeanDefinitionRegistryPostProcessor.class, true, false);
for (String ppName : postProcessorNames) {
if (!processedBeans.contains(ppName) && beanFactory.isTypeMatch(ppName, Ordered.class)) {
currentRegistryProcessors.add(beanFactory.getBean(ppName, BeanDefinitionRegistryPostProcessor.class));
processedBeans.add(ppName);
}
}
sortPostProcessors(currentRegistryProcessors, beanFactory);
registryProcessors.addAll(currentRegistryProcessors);
invokeBeanDefinitionRegistryPostProcessors(currentRegistryProcessors, registry, beanFactory.getApplicationStartup());
currentRegistryProcessors.clear();
// 最后处理剩余的 BeanDefinitionRegistryPostProcessor,直到没有新的 BeanDefinitionRegistryPostProcessor 添加为止
boolean reiterate = true;
while (reiterate) {
reiterate = false;
postProcessorNames = beanFactory.getBeanNamesForType(BeanDefinitionRegistryPostProcessor.class, true, false);
for (String ppName : postProcessorNames) {
if (!processedBeans.contains(ppName)) {
currentRegistryProcessors.add(beanFactory.getBean(ppName, BeanDefinitionRegistryPostProcessor.class));
processedBeans.add(ppName);
reiterate = true;
}
}
sortPostProcessors(currentRegistryProcessors, beanFactory);
registryProcessors.addAll(currentRegistryProcessors);
invokeBeanDefinitionRegistryPostProcessors(currentRegistryProcessors, registry, beanFactory.getApplicationStartup());
currentRegistryProcessors.clear();
}
// 处理所有 BeanFactoryPostProcessor
invokeBeanFactoryPostProcessors(registryProcessors, beanFactory);
invokeBeanFactoryPostProcessors(regularPostProcessors, beanFactory);
}
else {
// 处理普通的上下文中的BeanFactoryPostProcessor
invokeBeanFactoryPostProcessors(beanFactoryPostProcessors, beanFactory);
}
//获取所有常规化Bean,但不初始化,保留让后期的post-processors处理
String[] postProcessorNames =
beanFactory.getBeanNamesForType(BeanFactoryPostProcessor.class, true, false);
//分离实现PriorityOrdered、Ordered和其他的BeanFactoryPostProcessors
List<BeanFactoryPostProcessor> priorityOrderedPostProcessors = new ArrayList<>();
List<String> orderedPostProcessorNames = new ArrayList<>();
List<String> nonOrderedPostProcessorNames = new ArrayList<>();
for (String ppName : postProcessorNames) {
if (processedBeans.contains(ppName)) {
// 跳过已经处理的
}
else if (beanFactory.isTypeMatch(ppName, PriorityOrdered.class)) {
priorityOrderedPostProcessors.add(beanFactory.getBean(ppName, BeanFactoryPostProcessor.class));
}
else if (beanFactory.isTypeMatch(ppName, Ordered.class)) {
orderedPostProcessorNames.add(ppName);
}
else {
nonOrderedPostProcessorNames.add(ppName);
}
}
//首先处理实现了PriorityOrdered的BeanFactoryPostProcessors
sortPostProcessors(priorityOrderedPostProcessors, beanFactory);
invokeBeanFactoryPostProcessors(priorityOrderedPostProcessors, beanFactory);
//处理实现了Ordered的BeanFactoryPostProcessors
List<BeanFactoryPostProcessor> orderedPostProcessors = new ArrayList<>(orderedPostProcessorNames.size());
for (String postProcessorName : orderedPostProcessorNames) {
orderedPostProcessors.add(beanFactory.getBean(postProcessorName, BeanFactoryPostProcessor.class));
}
sortPostProcessors(orderedPostProcessors, beanFactory);
invokeBeanFactoryPostProcessors(orderedPostProcessors, beanFactory);
// 最后处理其他的BeanFactoryPostProcessors
List<BeanFactoryPostProcessor> nonOrderedPostProcessors = new ArrayList<>(nonOrderedPostProcessorNames.size());
for (String postProcessorName : nonOrderedPostProcessorNames) {
nonOrderedPostProcessors.add(beanFactory.getBean(postProcessorName, BeanFactoryPostProcessor.class));
}
invokeBeanFactoryPostProcessors(nonOrderedPostProcessors, beanFactory);
// 清除缓存
beanFactory.clearMetadataCache();
}invokeBeanDefinitionRegistryPostProcessors(currentRegistryProcessors, registry, beanFactory.getApplicationStartup()); 是处理自动配置的关键点PostProcessorRegistrationDelegate-invokeBeanDefinitionRegistryPostProcessorsprivate static void invokeBeanDefinitionRegistryPostProcessors(
Collection<? extends BeanDefinitionRegistryPostProcessor> postProcessors, BeanDefinitionRegistry registry, ApplicationStartup applicationStartup) {
//循环处理BeanDefinitionRegistryPostProcessor实现
for (BeanDefinitionRegistryPostProcessor postProcessor : postProcessors) {
//步骤日志记录
StartupStep postProcessBeanDefRegistry = applicationStartup.start("spring.context.beandef-registry.post-process")
.tag("postProcessor", postProcessor::toString);
postProcessor.postProcessBeanDefinitionRegistry(registry);
postProcessBeanDefRegistry.end();
}
}BeanDefinitionRegistryPostProcessor接口继承了BeanFactoryPostProcessor接口,允许在初始化Bean实例之前修改、添加、删除容器中注册的Bean定义信息在该示例的SpringBoot-Demo中,只有一个BeanDefinitionRegistryPostProcessor实现,即ConfigurationClassPostProcessorConfigurationClassPostProcessor-postProcessBeanDefinitionRegistrypublic void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) {
//计算hashcode
int registryId = System.identityHashCode(registry);
//判断是否处理过
if (this.registriesPostProcessed.contains(registryId)) {
throw new IllegalStateException(
"postProcessBeanDefinitionRegistry already called on this post-processor against " + registry);
}
if (this.factoriesPostProcessed.contains(registryId)) {
throw new IllegalStateException(
"postProcessBeanFactory already called on this post-processor against " + registry);
}
this.registriesPostProcessed.add(registryId);
//处理配置Bean定义信息
processConfigBeanDefinitions(registry);
}ConfigurationClassPostProcessor-processConfigBeanDefinitionspublic void processConfigBeanDefinitions(BeanDefinitionRegistry registry) {
List<BeanDefinitionHolder> configCandidates = new ArrayList<>();
//获取容器中所有的bean定义名称
String[] candidateNames = registry.getBeanDefinitionNames();
for (String beanName : candidateNames) {
// 获取 bean 的定义
BeanDefinition beanDef = registry.getBeanDefinition(beanName);
// 检查该 bean 是否已经被处理成配置类
if (beanDef.getAttribute(ConfigurationClassUtils.CONFIGURATION_CLASS_ATTRIBUTE) != null) {
if (logger.isDebugEnabled()) {
logger.debug("Bean definition has already been processed as a configuration class: " + beanDef);
}
}
// 检查该 bean 是否是配置类候选者
else if (ConfigurationClassUtils.checkConfigurationClassCandidate(beanDef, this.metadataReaderFactory)) {
configCandidates.add(new BeanDefinitionHolder(beanDef, beanName));
}
}
// 如果没有需要处理的配置类,则直接返回
if (configCandidates.isEmpty()) {
return;
}
// 对配置类候选者进行排序,按照先前确定的 @Order 值排序
configCandidates.sort((bd1, bd2) -> {
int i1 = ConfigurationClassUtils.getOrder(bd1.getBeanDefinition());
int i2 = ConfigurationClassUtils.getOrder(bd2.getBeanDefinition());
return Integer.compare(i1, i2);
});
// 检查是否有自定义的 bean 名称生成策略
SingletonBeanRegistry sbr = null;
if (registry instanceof SingletonBeanRegistry _sbr) {
sbr = _sbr;
if (!this.localBeanNameGeneratorSet) {
BeanNameGenerator generator = (BeanNameGenerator) sbr.getSingleton(
AnnotationConfigUtils.CONFIGURATION_BEAN_NAME_GENERATOR);
if (generator != null) {
this.componentScanBeanNameGenerator = generator;
this.importBeanNameGenerator = generator;
}
}
}
if (this.environment == null) {
this.environment = new StandardEnvironment();
}
// 解析每个 @Configuration 类
ConfigurationClassParser parser = new ConfigurationClassParser(
this.metadataReaderFactory, this.problemReporter, this.environment,
this.resourceLoader, this.componentScanBeanNameGenerator, registry);
Set<BeanDefinitionHolder> candidates = new LinkedHashSet<>(configCandidates);
Set<ConfigurationClass> alreadyParsed = new HashSet<>(configCandidates.size());
do {
StartupStep processConfig = this.applicationStartup.start("spring.context.config-classes.parse");
//解析
parser.parse(candidates);
//验证
parser.validate();
Set<ConfigurationClass> configClasses = new LinkedHashSet<>(parser.getConfigurationClasses());
//去除已经解析的类
configClasses.removeAll(alreadyParsed);
// Read the model and create bean definitions based on its content
if (this.reader == null) {
this.reader = new ConfigurationClassBeanDefinitionReader(
registry, this.sourceExtractor, this.resourceLoader, this.environment,
this.importBeanNameGenerator, parser.getImportRegistry());
}
this.reader.loadBeanDefinitions(configClasses);
alreadyParsed.addAll(configClasses);
processConfig.tag("classCount", () -> String.valueOf(configClasses.size())).end();
// 检查新的 BeanDefinition 是否包含新的配置类候选者
candidates.clear();
if (registry.getBeanDefinitionCount() > candidateNames.length) {
String[] newCandidateNames = registry.getBeanDefinitionNames();
Set<String> oldCandidateNames = Set.of(candidateNames);
Set<String> alreadyParsedClasses = new HashSet<>();
for (ConfigurationClass configurationClass : alreadyParsed) {
alreadyParsedClasses.add(configurationClass.getMetadata().getClassName());
}
for (String candidateName : newCandidateNames) {
if (!oldCandidateNames.contains(candidateName)) {
BeanDefinition bd = registry.getBeanDefinition(candidateName);
if (ConfigurationClassUtils.checkConfigurationClassCandidate(bd, this.metadataReaderFactory) &&
!alreadyParsedClasses.contains(bd.getBeanClassName())) {
candidates.add(new BeanDefinitionHolder(bd, candidateName));
}
}
}
candidateNames = newCandidateNames;
}
}
while (!candidates.isEmpty());
// 注册 ImportRegistry 为一个 bean,以支持 @ImportAware ConfigurationClass
if (sbr != null && !sbr.containsSingleton(IMPORT_REGISTRY_BEAN_NAME)) {
sbr.registerSingleton(IMPORT_REGISTRY_BEAN_NAME, parser.getImportRegistry());
}
// 存储 PropertySourceDescriptors,便于在AOT中使用
this.propertySourceDescriptors = parser.getPropertySourceDescriptors();
if (this.metadataReaderFactory instanceof CachingMetadataReaderFactory cachingMetadataReaderFactory) {
// 清除缓存,防止由于缓存无法更新导致的出错问题
cachingMetadataReaderFactory.clearCache();
}
}此处代码很长,对于配置类的解析,在parser.parse(candidates)中完成。parser变量的实例为:ConfigurationClassParserConfigurationClassParser-parsepublic void parse(Set<BeanDefinitionHolder> configCandidates) {
for (BeanDefinitionHolder holder : configCandidates) {
BeanDefinition bd = holder.getBeanDefinition();
try {
// 如果 BeanDefinition 是一个 AnnotatedBeanDefinition,则需要解析该 BeanDefinition 中的注解信息
if (bd instanceof AnnotatedBeanDefinition annotatedBeanDef) {
parse(annotatedBeanDef.getMetadata(), holder.getBeanName());
}
// 如果 BeanDefinition 不是 AnnotatedBeanDefinition,并且具有 beanClass 属性,则解析该 beanClass 中的注解信息
else if (bd instanceof AbstractBeanDefinition abstractBeanDef && abstractBeanDef.hasBeanClass()) {
parse(abstractBeanDef.getBeanClass(), holder.getBeanName());
}
else {
// 如果 BeanDefinition 没有 beanClass 属性,则解析该 BeanDefinition 中的 beanClassName 所指定的类中的注解信息
parse(bd.getBeanClassName(), holder.getBeanName());
}
}
catch (BeanDefinitionStoreException ex) {
throw ex;
}
catch (Throwable ex) {
throw new BeanDefinitionStoreException(
"Failed to parse configuration class [" + bd.getBeanClassName() + "]", ex);
}
}
//处理所有的DeferredImportSelector实例
this.deferredImportSelectorHandler.process();
}在开始的时候,我们已经知道实际解析自动配置类是AutoConfigurationImportSelector,AutoConfigurationImportSelector 实现了DeferredImportSelector接口,而这里正好有deferredImportSelectorHandler来处理所有的deferredImportSelectorHandler实例。看下DeferredImportSelectorHandler中的process。DeferredImportSelectorHandler-processpublic void process() {
List<DeferredImportSelectorHolder> deferredImports = this.deferredImportSelectors;
this.deferredImportSelectors = null;
try {
if (deferredImports != null) {
DeferredImportSelectorGroupingHandler handler = new DeferredImportSelectorGroupingHandler();
deferredImports.sort(DEFERRED_IMPORT_COMPARATOR);
deferredImports.forEach(handler::register);
handler.processGroupImports();
}
}
finally {
this.deferredImportSelectors = new ArrayList<>();
}
}DeferredImportSelectorGroupingHandler 类是在 Spring Boot 中处理 DeferredImportSelector 接口的辅助类,主要用于按照分组将DeferredImportSelector分组进行处理。此处,我们要关注handler.processGroupImports(),调用到DeferredImportSelectorGroupingHandler类的processGroupImports方法。DeferredImportSelectorGroupingHandler-processGroupImportspublic void processGroupImports() {
//遍历所有分组
for (DeferredImportSelectorGrouping grouping : this.groupings.values()) {
//获取候选过滤器
Predicate<String> exclusionFilter = grouping.getCandidateFilter();
//遍历分组中的每个元素
grouping.getImports().forEach(entry -> {
ConfigurationClass configurationClass = this.configurationClasses.get(entry.getMetadata());
try {
//处理当前元素
processImports(configurationClass, asSourceClass(configurationClass, exclusionFilter),
Collections.singleton(asSourceClass(entry.getImportClassName(), exclusionFilter)),
exclusionFilter, false);
}
catch (BeanDefinitionStoreException ex) {
throw ex;
}
catch (Throwable ex) {
throw new BeanDefinitionStoreException(
"Failed to process import candidates for configuration class [" +
configurationClass.getMetadata().getClassName() + "]", ex);
}
});
}
}这里首先要关注grouping.getImports(),在这里对自动配置类进行了加载DeferredImportSelectorGrouping-getImportspublic Iterable<Group.Entry> getImports() {
for (DeferredImportSelectorHolder deferredImport : this.deferredImports) {
//处理每个分组的DeferredImportSelectorHolder
this.group.process(deferredImport.getConfigurationClass().getMetadata(),
deferredImport.getImportSelector());
}
//返回需要导入的类
return this.group.selectImports();
}this.group.process中进入到AutoConfigurationImportSelector的内部类AutoConfigurationGroup中AutoConfigurationImportSelector-AutoConfigurationGroup-processpublic void process(AnnotationMetadata annotationMetadata, DeferredImportSelector deferredImportSelector) {
Assert.state(deferredImportSelector instanceof AutoConfigurationImportSelector,
() -> String.format("Only %s implementations are supported, got %s",
AutoConfigurationImportSelector.class.getSimpleName(),
deferredImportSelector.getClass().getName()));
//转为为AutoConfigurationImportSelector,获取AutoConfigurationEntry
AutoConfigurationEntry autoConfigurationEntry = ((AutoConfigurationImportSelector) deferredImportSelector)
.getAutoConfigurationEntry(annotationMetadata);
//添加到autoConfigurationEntries变量中,供后期使用
this.autoConfigurationEntries.add(autoConfigurationEntry);
//设置entries变量,导入的类名和元注解映射关系
for (String importClassName : autoConfigurationEntry.getConfigurations()) {
this.entries.putIfAbsent(importClassName, annotationMetadata);
}
}此处deferredImportSelector强转为AutoConfigurationImportSelector后,再次调用了getAutoConfigurationEntry方法。AutoConfigurationImportSelector-getAutoConfigurationEntryprotected AutoConfigurationEntry getAutoConfigurationEntry(AnnotationMetadata annotationMetadata) {
// 如果自动配置不可用,则返回 EMPTY_ENTRY
if (!isEnabled(annotationMetadata)) {
return EMPTY_ENTRY;
}
// 获取注解的属性
AnnotationAttributes attributes = getAttributes(annotationMetadata);
// 获取候选自动配置类,此文关键
List<String> configurations = getCandidateConfigurations(annotationMetadata, attributes);
// 去除重复的自动配置类
configurations = removeDuplicates(configurations);
// 获取需要排除的自动配置类,spring.autoconfigure.exclude配置
Set<String> exclusions = getExclusions(annotationMetadata, attributes);
// 检查是否有被排除的自动配置类
checkExcludedClasses(configurations, exclusions);
// 去除被排除的自动配置类
configurations.removeAll(exclusions);
// 使用 ConfigurationClassFilter 过滤自动配置类
configurations = getConfigurationClassFilter().filter(configurations);
// 发送自动配置事件
fireAutoConfigurationImportEvents(configurations, exclusions);
return new AutoConfigurationEntry(configurations, exclusions);
}该方法的作用是根据给定的注解元数据获取自动配置项,对于自动配置的加载,关键在getCandidateConfigurations(annotationMetadata, attributes)。AutoConfigurationImportSelector-getCandidateConfigurationsprotected List<String> getCandidateConfigurations(AnnotationMetadata metadata, AnnotationAttributes attributes) {
//从类路径的META-INF/spring中载入名为AutoConfiguration全限定名的类
List<String> configurations = ImportCandidates.load(AutoConfiguration.class, getBeanClassLoader())
.getCandidates();
Assert.notEmpty(configurations,
"No auto configuration classes found in "
+ "META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports. If you "
+ "are using a custom packaging, make sure that file is correct.");
return configurations;
}该方法的ImportCandidates.load(AutoConfiguration.class, getBeanClassLoader())会拼凑出META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports路径,用于加载定义的AutoConfiguration类。ImportCandidates-ImportCandidatespublic static ImportCandidates load(Class<?> annotation, ClassLoader classLoader) {
Assert.notNull(annotation, "'annotation' must not be null");
ClassLoader classLoaderToUse = decideClassloader(classLoader);
//将META-INF/spring/%s.imports字符串格式化为指定的路径
String location = String.format(LOCATION, annotation.getName());
Enumeration<URL> urls = findUrlsInClasspath(classLoaderToUse, location);
List<String> importCandidates = new ArrayList<>();
while (urls.hasMoreElements()) {
URL url = urls.nextElement();
importCandidates.addAll(readCandidateConfigurations(url));
}
return new ImportCandidates(importCandidates);
}在grouping.getImports()获取到所有要导入的AutoConfigurationEntry后,通过processImports进行处理ConfigurationClassParser-processImportsprivate void processImports(ConfigurationClass configClass, SourceClass currentSourceClass,
Collection<SourceClass> importCandidates, Predicate<String> exclusionFilter,
boolean checkForCircularImports) {
// 如果没有可导入的类,则直接返回
if (importCandidates.isEmpty()) {
return;
}
// 检查循环导入
if (checkForCircularImports && isChainedImportOnStack(configClass)) {
this.problemReporter.error(new CircularImportProblem(configClass, this.importStack));
}
else {
// 将当前配置类推入导入栈中
this.importStack.push(configClass);
try {
// 处理每个导入的类
for (SourceClass candidate : importCandidates) {
if (candidate.isAssignable(ImportSelector.class)) {
// Candidate class is an ImportSelector -> delegate to it to determine imports
// 如果候选类是 ImportSelector 的子类,则交由它来决定导入哪些类
Class<?> candidateClass = candidate.loadClass();
ImportSelector selector = ParserStrategyUtils.instantiateClass(candidateClass, ImportSelector.class,
this.environment, this.resourceLoader, this.registry);
Predicate<String> selectorFilter = selector.getExclusionFilter();
if (selectorFilter != null) {
exclusionFilter = exclusionFilter.or(selectorFilter);
}
if (selector instanceof DeferredImportSelector deferredImportSelector) {
this.deferredImportSelectorHandler.handle(configClass, deferredImportSelector);
}
else {
String[] importClassNames = selector.selectImports(currentSourceClass.getMetadata());
Collection<SourceClass> importSourceClasses = asSourceClasses(importClassNames, exclusionFilter);
processImports(configClass, currentSourceClass, importSourceClasses, exclusionFilter, false);
}
}
else if (candidate.isAssignable(ImportBeanDefinitionRegistrar.class)) {
// Candidate class is an ImportBeanDefinitionRegistrar ->
// delegate to it to register additional bean definitions
// 如果候选类是 ImportBeanDefinitionRegistrar 的子类,则交由它来注册更多的 Bean 定义
Class<?> candidateClass = candidate.loadClass();
ImportBeanDefinitionRegistrar registrar =
ParserStrategyUtils.instantiateClass(candidateClass, ImportBeanDefinitionRegistrar.class,
this.environment, this.resourceLoader, this.registry);
configClass.addImportBeanDefinitionRegistrar(registrar, currentSourceClass.getMetadata());
}
else {
// Candidate class not an ImportSelector or ImportBeanDefinitionRegistrar ->
// process it as an @Configuration class
// 如果候选类不是 ImportSelector 或 ImportBeanDefinitionRegistrar 的子类,则将其作为 @Configuration 类来处理
this.importStack.registerImport(
currentSourceClass.getMetadata(), candidate.getMetadata().getClassName());
processConfigurationClass(candidate.asConfigClass(configClass), exclusionFilter);
}
}
}
catch (BeanDefinitionStoreException ex) {
throw ex;
}
catch (Throwable ex) {
throw new BeanDefinitionStoreException(
"Failed to process import candidates for configuration class [" +
configClass.getMetadata().getClassName() + "]: " + ex.getMessage(), ex);
}
finally {
this.importStack.pop();
}
}
}以MybatisPlusLanguageDriverAutoConfiguration自动配置为例,会通过processConfigurationClass(candidate.asConfigClass(configClass), exclusionFilter);进行处理。ConfigurationClassParser-processConfigurationClassprotected void processConfigurationClass(ConfigurationClass configClass, Predicate<String> filter) throws IOException {
// 如果该配置类被标记为跳过,则直接返回,不需要再处理
if (this.conditionEvaluator.shouldSkip(configClass.getMetadata(), ConfigurationPhase.PARSE_CONFIGURATION)) {
return;
}
// 如果已经存在同名的配置类,则判断两者的导入情况并合并
ConfigurationClass existingClass = this.configurationClasses.get(configClass);
if (existingClass != null) {
if (configClass.isImported()) {
if (existingClass.isImported()) {
existingClass.mergeImportedBy(configClass);
}
// Otherwise ignore new imported config class; existing non-imported class overrides it.
return;
}
else {
// Explicit bean definition found, probably replacing an import.
// Let's remove the old one and go with the new one.
this.configurationClasses.remove(configClass);
this.knownSuperclasses.values().removeIf(configClass::equals);
}
}
// 递归地处理该配置类及其父类的继承关系,解析 Bean 定义
SourceClass sourceClass = asSourceClass(configClass, filter);
do {
sourceClass = doProcessConfigurationClass(configClass, sourceClass, filter);
}
while (sourceClass != null);
// 将该配置类加入到 configurationClasses 中
this.configurationClasses.put(configClass, configClass);
}
ConfigurationClassParser-doProcessConfigurationClassprotected final SourceClass doProcessConfigurationClass(
ConfigurationClass configClass, SourceClass sourceClass, Predicate<String> filter)
throws IOException {
if (configClass.getMetadata().isAnnotated(Component.class.getName())) {
// 如果该配置类被@Component注解标记,则递归处理任何嵌套类。
processMemberClasses(configClass, sourceClass, filter);
}
// 处理 @PropertySource
for (AnnotationAttributes propertySource : AnnotationConfigUtils.attributesForRepeatable(
sourceClass.getMetadata(), PropertySources.class,
org.springframework.context.annotation.PropertySource.class)) {
if (this.propertySourceRegistry != null) {
this.propertySourceRegistry.processPropertySource(propertySource);
}
else {
logger.info("Ignoring @PropertySource annotation on [" + sourceClass.getMetadata().getClassName() +
"]. Reason: Environment must implement ConfigurableEnvironment");
}
}
// 处理@ComponentScan注解
Set<AnnotationAttributes> componentScans = AnnotationConfigUtils.attributesForRepeatable(
sourceClass.getMetadata(), ComponentScans.class, ComponentScan.class);
if (!componentScans.isEmpty() &&
!this.conditionEvaluator.shouldSkip(sourceClass.getMetadata(), ConfigurationPhase.REGISTER_BEAN)) {
for (AnnotationAttributes componentScan : componentScans) {
// The config class is annotated with @ComponentScan -> perform the scan immediately
Set<BeanDefinitionHolder> scannedBeanDefinitions =
this.componentScanParser.parse(componentScan, sourceClass.getMetadata().getClassName());
// 检查扫描的定义集是否有任何进一步的配置类,并在需要时递归解析
for (BeanDefinitionHolder holder : scannedBeanDefinitions) {
BeanDefinition bdCand = holder.getBeanDefinition().getOriginatingBeanDefinition();
if (bdCand == null) {
bdCand = holder.getBeanDefinition();
}
if (ConfigurationClassUtils.checkConfigurationClassCandidate(bdCand, this.metadataReaderFactory)) {
parse(bdCand.getBeanClassName(), holder.getBeanName());
}
}
}
}
// 处理@Import
processImports(configClass, sourceClass, getImports(sourceClass), filter, true);
// 处理@ImportResource
AnnotationAttributes importResource =
AnnotationConfigUtils.attributesFor(sourceClass.getMetadata(), ImportResource.class);
if (importResource != null) {
String[] resources = importResource.getStringArray("locations");
Class<? extends BeanDefinitionReader> readerClass = importResource.getClass("reader");
for (String resource : resources) {
String resolvedResource = this.environment.resolveRequiredPlaceholders(resource);
configClass.addImportedResource(resolvedResource, readerClass);
}
}
// 处理单个的@Bean方法
Set<MethodMetadata> beanMethods = retrieveBeanMethodMetadata(sourceClass);
for (MethodMetadata methodMetadata : beanMethods) {
configClass.addBeanMethod(new BeanMethod(methodMetadata, configClass));
}
// 处理接口上的默认方法
processInterfaces(configClass, sourceClass);
// 处理超类
if (sourceClass.getMetadata().hasSuperClass()) {
String superclass = sourceClass.getMetadata().getSuperClassName();
if (superclass != null && !superclass.startsWith("java") &&
!this.knownSuperclasses.containsKey(superclass)) {
this.knownSuperclasses.put(superclass, configClass);
// 找到超类,返回其注释元数据并递归
return sourceClass.getSuperClass();
}
}
// 没有超类,完成
return null;
}总结一张图说明整个自动配置加载解析流程
cathoy
Spring Boot 框架整体启动流程详解
基于Spring Boot 版本:3.1Java: 17Spring Boot 的入口即为xxApplication类的main方法:@SpringBootApplication
public class SpringBootDemoApplication {
public static void main(String[] args) {
SpringApplication.run(SpringBootDemoApplication.class);
}
}main方法内部再调用SpringApplication.run(SpringBootDemoApplication.class);SpringApplication.java ---- run 有两个重载方法: /**
* 静态方法,使用默认设置从指定的源启动SpringApplication
* @param primarySource 载入的指定源
* @param args 应用程序参数 (通过从Main方法传递)
* @return 正在运行的ApplicationContext
*/
public static ConfigurableApplicationContext run(Class<?> primarySource, String... args) {
return run(new Class<?>[] { primarySource }, args);
}
/**
* 静态方法,使用默认设置从指定的源启动SpringApplication
* @param primarySources 载入的制定源,数组形式
* @param args 应用程序参数 (通过从Main方法传递),数组形式
* @return 正在运行的ApplicationContext
*/
public static ConfigurableApplicationContext run(Class<?>[] primarySources, String[] args) {
return new SpringApplication(primarySources).run(args);
}应用程序初始化经过两次调用run静态方法后,调用new SpringApplication(primarySources).run(args)首先new SpringApplication,调用链路如图该处理的作用有:1、创建一个SpringApplication实例,根据指定的源primarySources加载实例bean2、将资源加载器类赋值给实例变量(此处为null)3、将primarySources转为list并去重后赋值给实例变量4、推断当前的Web应用程序环境(Reactive还是Servlet)5、从META-INF/spring.factories加载BootstrapRegistryInitializer类实例6、从META-INF/spring.factories加载ApplicationContextInitializer类实例7、从META-INF/spring.factories加载ApplicationListener类实例8、从堆栈中推断出主应用程序类BootstrapRegistryInitializer:该接口的作用是将一些默认的组件注册到BootstrapRegistry中,这些组件可以帮助Spring Boot实现自动配置和依赖注入等功能。通过实现BootstrapRegistryInitializer接口,开发人员可以向Spring Boot添加自定义组件,并在应用程序启动阶段进行初始化和注册,从而实现更具有个性化的应用程序配置和功能。ApplicationContextInitializer:该接口提供了一种灵活的机制,允许您在应用程序上下文创建之前自定义应用程序上下文的行为。该接口的实现类可以在应用程序上下文创建之前注册到SpringApplication实例中,并在应用程序上下文创建之前执行一些初始化操作,例如覆盖应用程序上下文中的默认bean定义、添加自定义属性源、激活特定的Spring配置文件等。通过实现该接口,可以实现一些在应用程序启动之前需要做的预处理操作,例如加载一些外部配置、初始化日志等。这样可以提高应用的灵活性和可配置性,使应用程序更加适应不同的环境和需求。建议实现Ordered接口,或者使用@Order注解ApplicationListener:该接口的实现类可以在Spring Boot应用程序中注册到ApplicationContext中,以便在应用程序生命周期内接收和处理特定的应用程序事件,例如启动、关闭、失败等事件。通过实现该接口,可以在应用程序启动、关闭、失败等关键时刻进行一些自定义操作,例如初始化某些资源、注册特定的Bean、记录日志等。常见的Spring Boot应用程序事件包括ApplicationStartingEvent、ApplicationStartedEvent、ApplicationReadyEvent、ApplicationFailedEvent等。应用程序启动在new SpringApplication后,调用run方法public ConfigurableApplicationContext run(String... args) {
//记录应用程序启动时间
long startTime = System.nanoTime();
//创建默认的引导上下文,循环调用BootstrapRegistryInitializer 中的 initialize
DefaultBootstrapContext bootstrapContext = createBootstrapContext();
ConfigurableApplicationContext context = null;
//配置headless,默认为true,不使用图形界面
configureHeadlessProperty();
//获取SpringApplicationRunListeners实例,从META-INF/spring.factories 和 SpringApplicationHook 中获取
SpringApplicationRunListeners listeners = getRunListeners(args);
//启动SpringApplicationRunListeners实例,循环调用SpringApplicationRunListener实例的starting方法
listeners.starting(bootstrapContext, this.mainApplicationClass);
try {
//创建默认的ApplicationArguments实例,用于保存应用程序接收到的命令行参数
ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);
//准备环境,准备完毕后调用SpringApplicationRunListener实例的environmentPrepared方法
ConfigurableEnvironment environment = prepareEnvironment(listeners, bootstrapContext, applicationArguments);
//打印banner
Banner printedBanner = printBanner(environment);
//创建ApplicationContext,根据WebApplicationType类型
context = createApplicationContext();
//设置启动期间的度量记录类
context.setApplicationStartup(this.applicationStartup);
//准备应用程序上下文,这里会调用SpringApplicationRunListener实例的contextPrepared和contextLoaded方法
prepareContext(bootstrapContext, context, environment, listeners, applicationArguments, printedBanner);
//刷新应用程序上下文
refreshContext(context);
//刷新上下文后的操作,可以在子类实现
afterRefresh(context, applicationArguments);
//计算启动需要的时间
Duration timeTakenToStartup = Duration.ofNanos(System.nanoTime() - startTime);
//记录应用程序启动信息,默认是true
if (this.logStartupInfo) {
new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), timeTakenToStartup);
}
//调用SpringApplicationRunListener实例的started方法
listeners.started(context, timeTakenToStartup);
//执行ApplicationRunner和CommandLineRunner
callRunners(context, applicationArguments);
}
catch (Throwable ex) {
if (ex instanceof AbandonedRunException) {
throw ex;
}
//处理应用程序启动失败的情况,处理退出码,发送ExitCodeEvent事件,调用SpringApplicationRunListener的failed方法,向用户发送失败报告(可以实现FailureAnalysisReporter自定义),优雅关闭应用程序上下文
handleRunFailure(context, ex, listeners);
throw new IllegalStateException(ex);
}
try {
if (context.isRunning()) {
//准备完成时间
Duration timeTakenToReady = Duration.ofNanos(System.nanoTime() - startTime);
//最后调用SpringApplicationRunListener的ready方法
listeners.ready(context, timeTakenToReady);
}
}
catch (Throwable ex) {
if (ex instanceof AbandonedRunException) {
throw ex;
}
handleRunFailure(context, ex, null);
throw new IllegalStateException(ex);
}
//返回应用程序上下文
return context;
}这里完成处理有:1、记录应用程序启动时间2、创建默认的引导上下文,循环调用BootstrapRegistryInitializer 中的 initialize3、配置headless,默认为true,不使用图形界面4、获取SpringApplicationRunListeners实例,从META-INF/spring.factories 和 SpringApplicationHook 中获取,并启动SpringApplicationRunListeners实例,循环调用SpringApplicationRunListener实例的starting方法5、创建默认的ApplicationArguments实例,用于保存应用程序接收到的命令行参数6、准备环境,准备完毕后调用SpringApplicationRunListener实例的environmentPrepared方法7、打印banner8、创建ApplicationContext9、设置启动期间的度量记录类10、准备应用程序上下文11、刷新应用程序上下文12、计算启动需要的时间13、如果需要,记录应用程序启动信息14、调用SpringApplicationRunListener实例的started方法15、执行ApplicationRunner和CommandLineRunner16、最后调用SpringApplicationRunListener的ready方法17、返回上下文这样Spring Boot 整体的启动流程就完成了,后面详细看每一步都具体做了什么。createBootstrapContext(),创建默认的引导上下文private DefaultBootstrapContext createBootstrapContext() {
//创建默认的引导上下文
DefaultBootstrapContext bootstrapContext = new DefaultBootstrapContext();
//循环调用initialize,可以在应用程序启动阶段进行初始化和注册,从而实现更具有个性化的应用程序配置和功能
this.bootstrapRegistryInitializers.forEach((initializer) -> initializer.initialize(bootstrapContext));
return bootstrapContext;
}configureHeadlessProperty(),配置headlessprivate void configureHeadlessProperty() {
//获取系统配置java.awt.headless的值,未配置使用默认值true
System.setProperty(SYSTEM_PROPERTY_JAVA_AWT_HEADLESS,
System.getProperty(SYSTEM_PROPERTY_JAVA_AWT_HEADLESS, Boolean.toString(this.headless)));
}this.headless默认为true,表示不需要图形化界面,这样有利于提供性能getRunListeners(args),获取SpringApplicationRunListeners实例该方法会从META-INF/spring.factories 和 SpringApplicationHook 中获取,并启动SpringApplicationRunListeners实例,然后循环调用SpringApplicationRunListener实例的starting方法SpringApplicationRunListeners listeners = getRunListeners(args);
listeners.starting(bootstrapContext, this.mainApplicationClass);private SpringApplicationRunListeners getRunListeners(String[] args) {
//将应用程序接收到的命令行参数组合成一个参数解决器
ArgumentResolver argumentResolver = ArgumentResolver.of(SpringApplication.class, this);
argumentResolver = argumentResolver.and(String[].class, args);
//从META-INF/spring.factories 和 SpringApplicationHook 中获取,并启动SpringApplicationRunListeners实例
List<SpringApplicationRunListener> listeners = getSpringFactoriesInstances(SpringApplicationRunListener.class,
argumentResolver);
//获取当前线程中的SpringApplicationHook,此处暂时有个疑问,没发现怎么设置这个SpringApplicationHook❓
SpringApplicationHook hook = applicationHook.get();
//如果hook 存在则将获取的SpringApplicationRunListener放入列表
SpringApplicationRunListener hookListener = (hook != null) ? hook.getRunListener(this) : null;
if (hookListener != null) {
listeners = new ArrayList<>(listeners);
listeners.add(hookListener);
}
return new SpringApplicationRunListeners(logger, listeners, this.applicationStartup);
}prepareEnvironment(listeners, bootstrapContext, applicationArguments),准备环境private ConfigurableEnvironment prepareEnvironment(SpringApplicationRunListeners listeners,
DefaultBootstrapContext bootstrapContext, ApplicationArguments applicationArguments) {
//创建并配置环境
ConfigurableEnvironment environment = getOrCreateEnvironment();
//配置环境,如果需要转换服务,添加ApplicationConversionService,另外委托给了configurePropertySources(属性源)和configureProfiles(配置文件),子类可以覆盖该方法或分别覆盖两者进行细粒度控制
configureEnvironment(environment, applicationArguments.getSourceArgs());
//将ConfigurationPropertySource支持附加到指定的环境
ConfigurationPropertySources.attach(environment);
//调用environmentPrepared方法
listeners.environmentPrepared(bootstrapContext, environment);
//将defaultProperties属性源移动到指定配置环境的最后
DefaultPropertiesPropertySource.moveToEnd(environment);
Assert.state(!environment.containsProperty("spring.main.environment-prefix"),
"Environment prefix cannot be set via properties.");
//绑定环境到SpringApplication
bindToSpringApplication(environment);
//非自定义环境配置,就将其转换为标准类型
if (!this.isCustomEnvironment) {
EnvironmentConverter environmentConverter = new EnvironmentConverter(getClassLoader());
environment = environmentConverter.convertEnvironmentIfNecessary(environment, deduceEnvironmentClass());
}
//重新将ConfigurationPropertySource支持附加到指定的环境
ConfigurationPropertySources.attach(environment);
return environment;
}printBanner(environment) 打印bannerprivate Banner printBanner(ConfigurableEnvironment environment) {
//banner关闭,不打印
if (this.bannerMode == Banner.Mode.OFF) {
return null;
}
//获取资源加载器
ResourceLoader resourceLoader = (this.resourceLoader != null) ? this.resourceLoader
: new DefaultResourceLoader(null);
//banner打印器
SpringApplicationBannerPrinter bannerPrinter = new SpringApplicationBannerPrinter(resourceLoader, this.banner);
if (this.bannerMode == Mode.LOG) {
return bannerPrinter.print(environment, this.mainApplicationClass, logger);
}
return bannerPrinter.print(environment, this.mainApplicationClass, System.out);
}createApplicationContext(),创建应用上下文protected ConfigurableApplicationContext createApplicationContext() {
return this.applicationContextFactory.create(this.webApplicationType);
}使用策略模式的创建应用程序上下文方法,支持显示设置applicationContextFactory,默认使用DefaultApplicationContextFactorycontext.setApplicationStartup(this.applicationStartup),设置启动期间的记录类默认设置为DefaultApplicationStartup,是一个空操作的记录类,支持显示覆盖prepareContext(bootstrapContext, context, environment, listeners, applicationArguments, printedBanner),准备应用程序上下文private void prepareContext(DefaultBootstrapContext bootstrapContext, ConfigurableApplicationContext context,
ConfigurableEnvironment environment, SpringApplicationRunListeners listeners,
ApplicationArguments applicationArguments, Banner printedBanner) {
//将指定环境设置到应用程序上下文中
context.setEnvironment(environment);
//应用后置处理器
postProcessApplicationContext(context);
//如果需要,添加AOT生成的初始化器
addAotGeneratedInitializerIfNecessary(this.initializers);
//应用ApplicationContextInitializer
applyInitializers(context);
//通知侦听器应用程序上下文已经准备好
listeners.contextPrepared(context);
// 关闭引导上下文
bootstrapContext.close(context);
if (this.logStartupInfo) {
logStartupInfo(context.getParent() == null);
logStartupProfileInfo(context);
}
// 添加引导所必需的单例
ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
beanFactory.registerSingleton("springApplicationArguments", applicationArguments);
if (printedBanner != null) {
beanFactory.registerSingleton("springBootBanner", printedBanner);
}
if (beanFactory instanceof AbstractAutowireCapableBeanFactory autowireCapableBeanFactory) {
//设置循环引用
autowireCapableBeanFactory.setAllowCircularReferences(this.allowCircularReferences);
if (beanFactory instanceof DefaultListableBeanFactory listableBeanFactory) {
//设置是否允许覆盖
listableBeanFactory.setAllowBeanDefinitionOverriding(this.allowBeanDefinitionOverriding);
}
}
if (this.lazyInitialization) {
//设置延迟初始化
context.addBeanFactoryPostProcessor(new LazyInitializationBeanFactoryPostProcessor());
}
//设置一个PropertySourceOrderingBeanFactoryPostProcessor处理器
context.addBeanFactoryPostProcessor(new PropertySourceOrderingBeanFactoryPostProcessor(context));
if (!AotDetector.useGeneratedArtifacts()) {
// Load the sources
Set<Object> sources = getAllSources();
Assert.notEmpty(sources, "Sources must not be empty");
//将所有源bean加载到上下文中
load(context, sources.toArray(new Object[0]));
}
//通知侦听器应用程序上下文已经加载完成
listeners.contextLoaded(context);
}refreshContext(context) 刷新上下文private void refreshContext(ConfigurableApplicationContext context) {
if (this.registerShutdownHook) {
//如果需要,注册ShutdownHook以确保优雅关闭应用程序
shutdownHook.registerApplicationContext(context);
}
//调用Spring的刷新应用程序上下文
refresh(context);
}afterRefresh(context, applicationArguments) 刷新上下文后protected void afterRefresh(ConfigurableApplicationContext context, ApplicationArguments args) {
}这是一个模版方法,子类实现started方法listeners.started(context, timeTakenToStartup);告知所有监听器应用程序启动完成callRunners(context, applicationArguments) 执行ApplicationRunner和CommandLineRunnerprivate void callRunners(ApplicationContext context, ApplicationArguments args) {
List<Object> runners = new ArrayList<>();
runners.addAll(context.getBeansOfType(ApplicationRunner.class).values());
runners.addAll(context.getBeansOfType(CommandLineRunner.class).values());
AnnotationAwareOrderComparator.sort(runners);
for (Object runner : new LinkedHashSet<>(runners)) {
if (runner instanceof ApplicationRunner applicationRunner) {
callRunner(applicationRunner, args);
}
if (runner instanceof CommandLineRunner commandLineRunner) {
callRunner(commandLineRunner, args);
}
}
}用户自定义实现,会循环调用两个类的run,CommandLineRunner参数是数组,ApplicationRunner参数是ApplicationArguments类调用readyif (context.isRunning()) {
Duration timeTakenToReady = Duration.ofNanos(System.nanoTime() - startTime);
listeners.ready(context, timeTakenToReady);
}最终调用监听器的ready方法,告知上下文刷新完成,并且调用了所有CommandLineRunner和ApplicationRunner总结最后使用一张图来总结整个启动流程。
cathoy
Spring Boot 系统初始化器详解
自定义系统初始化器Spring Boot 有多种加载自定义初始化器的方法:1、创建一个实现ApplicationContextInitializer接口的类,在spring.factories中添加,如MyInitializer2、创建一个实现ApplicationContextInitializer接口的类,在SpringApplication 中使用addInitializers添加,如MyInitializer23、创建一个实现ApplicationContextInitializer接口的类,在application.yml或application.properties中使用context.initializer.classes添加,如MyInitializer34、创建一个实现EnvironmentPostProcessor接口的类,在spring.factories中添加,如MyEnvironmentPostProcessor代码如下所示:MyInitializer.java
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Order(2)
public class MyInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
@Override
public void initialize(ConfigurableApplicationContext applicationContext) {
ConfigurableEnvironment configurableEnvironment = applicationContext.getEnvironment();
Map<String, Object> map = new HashMap<>();
map.put("key", "value");
MapPropertySource mapPropertySource = new MapPropertySource("mySource", map);
configurableEnvironment.getPropertySources().addLast(mapPropertySource);
log.info("My Initializer run");
}
}MyInitializer2.java
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Order(1)
public class MyInitializer2 implements ApplicationContextInitializer<ConfigurableApplicationContext> {
@Override
public void initialize(ConfigurableApplicationContext applicationContext) {
ConfigurableEnvironment configurableEnvironment = applicationContext.getEnvironment();
Map<String, Object> map = new HashMap<>();
map.put("key2", "value2");
MapPropertySource mapPropertySource = new MapPropertySource("mySource", map);
configurableEnvironment.getPropertySources().addLast(mapPropertySource);
log.info("My Initializer2 run");
}
}MyInitializer3.java
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Order(10)
public class MyInitializer3 implements ApplicationContextInitializer<ConfigurableApplicationContext> {
@Override
public void initialize(ConfigurableApplicationContext applicationContext) {
ConfigurableEnvironment configurableEnvironment = applicationContext.getEnvironment();
Map<String, Object> map = new HashMap<>();
map.put("key3", "value3");
MapPropertySource mapPropertySource = new MapPropertySource("mySource", map);
configurableEnvironment.getPropertySources().addLast(mapPropertySource);
log.info("My Initializer3 run");
}
}MyEnvironmentPostProcessor.javaimport lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.env.EnvironmentPostProcessor;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Order(5)
public class MyEnvironmentPostProcessor implements EnvironmentPostProcessor {
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
Map<String, Object> map = new HashMap<>();
map.put("key", "value");
MapPropertySource mapPropertySource = new MapPropertySource("mySource", map);
environment.getPropertySources().addLast(mapPropertySource);
//为什么不打印日志
// log.info("My EnvironmentPostProcessor run");
System.out.println("My EnvironmentPostProcessor run");
}
}启动后截图:疑问❓在MyEnvironmentPostProcessor的示例中,用log.info("My EnvironmentPostProcessor run"); 不会打印日志。MyInitializer3的输出怎么会在MyInitializer2之前。加载原理实例1加载原理在之前的文章中《Spring Boot 框架整体启动流程详解》有介绍到Spring Boot 应用程序初始化的时候会从META-INF/spring.factories加载ApplicationContextInitializer类实例SpringFactoriesLoader 是Spring 框架中的类,用于从多个Jar文件的META-INF/spring.factories中加载并实例化给定的类型,spring.factories文件必须采用Properties格式,其中key是接口或抽象类的完全限定名称,value是以逗号分隔的实现类名列表。例如:example.MyService=example.MyServicesImpl1,example.MyService Impl2其中example.MyService是接口的名称,MyServiceImpl1和MyServiceImpl2是两个实现。获取实例分成了两部分,首先从多个Jar文件的META-INF/spring.factories中加载key和value,返回一个SpringFactoriesLoader实例,然后调用SpringFactoriesLoader的load方法初始化指定key(key为接口或者抽象类的全限定名)对应的所有value(接口实现类),返回实例列表。spring.factories的加载FACTORIES_RESOURCE_LOCATION指定了加载的路径public static final String FACTORIES_RESOURCE_LOCATION = "META-INF/spring.factories";public static SpringFactoriesLoader forResourceLocation(String resourceLocation, @Nullable ClassLoader classLoader) {
// 判断资源路径是否为空,若为空则抛出异常
Assert.hasText(resourceLocation, "'resourceLocation' must not be empty");
// 获取资源对应的类加载器,若传入的类加载器为空,则使用SpringFactoriesLoader类的类加载器
ClassLoader resourceClassLoader = (classLoader != null ? classLoader :
SpringFactoriesLoader.class.getClassLoader());
// 从缓存中获取SpringFactoriesLoader,若不存在,则创建一个并缓存 Map<String, SpringFactoriesLoader>,key为ClassLoader,资源对应的类加载器
Map<String, SpringFactoriesLoader> loaders = cache.computeIfAbsent(
resourceClassLoader, key -> new ConcurrentReferenceHashMap<>());
// 返回resourceLocation对应的SpringFactoriesLoader对象,若不存在,则创建一个并缓存,key为resourceLocation,资源路径
return loaders.computeIfAbsent(resourceLocation, key ->
new SpringFactoriesLoader(classLoader, loadFactoriesResource(resourceClassLoader, resourceLocation)));
}computeIfAbsent 返回的是key关联的value值最后一步value创建了一个SpringFactoriesLoader实例,loadFactoriesResource 使用给定的资源类加载器从"META-INF/spring.factories"中加载protected static Map<String, List<String>> loadFactoriesResource(ClassLoader classLoader, String resourceLocation) {
//实现列表,key=接口或抽象类全限定名 value=实现类全限定名
Map<String, List<String>> result = new LinkedHashMap<>();
try {
//获取指定路径下所有的资源URL
Enumeration<URL> urls = classLoader.getResources(resourceLocation);
while (urls.hasMoreElements()) {
UrlResource resource = new UrlResource(urls.nextElement());
//从URL资源中读取配置
Properties properties = PropertiesLoaderUtils.loadProperties(resource);
properties.forEach((name, value) -> {
//实现类逗号分割,转换为数组
String[] factoryImplementationNames = StringUtils.commaDelimitedListToStringArray((String) value);
//接口的实现类列表
List<String> implementations = result.computeIfAbsent(((String) name).trim(),
key -> new ArrayList<>(factoryImplementationNames.length));
//去掉实现类两边空格,并插入实现类列表
Arrays.stream(factoryImplementationNames).map(String::trim).forEach(implementations::add);
});
}
//去重
result.replaceAll(SpringFactoriesLoader::toDistinctUnmodifiableList);
}
catch (IOException ex) {
throw new IllegalArgumentException("Unable to load factories from location [" + resourceLocation + "]", ex);
}
//返回不可修改的map
return Collections.unmodifiableMap(result);
}加载部分有很多的key,value 要分清楚。spring.factories接口实现类的实例化实例化通过调用SpringFactoriesLoader的load方法 public <T> List<T> load(Class<T> factoryType, @Nullable ArgumentResolver argumentResolver) {
return load(factoryType, argumentResolver, null);
}factoryType指定要实例化的类型,这里为 org.springframework.context.ApplicationContextInitializerargumentResolver 实例化需要的参数,这里为nullpublic <T> List<T> load(Class<T> factoryType, @Nullable ArgumentResolver argumentResolver,
@Nullable FailureHandler failureHandler) {
Assert.notNull(factoryType, "'factoryType' must not be null");
//从factories 中获取指定接口类型的所有实现
//factories就是加载步骤中返回的result
List<String> implementationNames = loadFactoryNames(factoryType);
logger.trace(LogMessage.format("Loaded [%s] names: %s", factoryType.getName(), implementationNames));
List<T> result = new ArrayList<>(implementationNames.size());
//定义失败处理器
FailureHandler failureHandlerToUse = (failureHandler != null) ? failureHandler : THROWING_FAILURE_HANDLER;
//循环,实例化
for (String implementationName : implementationNames) {
//通过构造函数实例化
T factory = instantiateFactory(implementationName, factoryType, argumentResolver, failureHandlerToUse);
if (factory != null) {
result.add(factory);
}
}
//根据order 排序
AnnotationAwareOrderComparator.sort(result);
return result;
}最终返回排序后的ApplicationContextInitializer 实例,赋值SpringApplication 的 initializers 变量。执行执行会在SpringApplication类的prepareContext(准备上下文)中进行调用,如图所示: //返回一个只读的有序,LinkedHashSet 类型
public Set<ApplicationContextInitializer<?>> getInitializers() {
return asUnmodifiableOrderedSet(this.initializers);
} protected void applyInitializers(ConfigurableApplicationContext context) {
//获取所有的ApplicationContextInitializer实例
for (ApplicationContextInitializer initializer : getInitializers()) {
Class<?> requiredType = GenericTypeResolver.resolveTypeArgument(initializer.getClass(),
ApplicationContextInitializer.class);
// 判断ApplicationContextInitializer实例泛型是否与context对象类型一致
Assert.isInstanceOf(requiredType, context, "Unable to call initializer.");
// 调用ApplicationContextInitializer实例的initialize方法进行初始化操作
initializer.initialize(context);
}
}实例2加载原理创建一个实现ApplicationContextInitializer接口的类,在SpringApplication 中使用addInitializers添加,如MyInitializer2。我们使用addInitializers 将ApplicationContextInitializer接口的实现加入到SpringApplication中。 public void addInitializers(ApplicationContextInitializer<?>... initializers) {
this.initializers.addAll(Arrays.asList(initializers));
}initializers 就是 SpringApplication中的initializers变量,执行点同实例1,在准备上下文的时候执行,由于执行前会进行一次排序,所以他们两的顺序是正确的。实例3加载原理创建一个实现ApplicationContextInitializer接口的类,在application.yml或application.properties中使用context.initializer.classes添加,如MyInitializer3该处通过配置文件添加ApplicationContextInitializer实现类,并且通过DelegatingApplicationContextInitializer 初始化器进行加载和执行。DelegatingApplicationContextInitializer 被定义在了spring-boot.jar 的 META-INF/spring.factories中,并且由于他的order是0,所以会在我们自定义MyInitializer和MyInitializer2 前执行,它是另外一种独立的初始化器,专门用于将配置文件中的ApplicationContextInitializer实现类加载到Spring容器中。执行在DelegatingApplicationContextInitializer类的applyInitializers方法中private void applyInitializers(ConfigurableApplicationContext context,
List<ApplicationContextInitializer<?>> initializers) {
//排序
initializers.sort(new AnnotationAwareOrderComparator());
for (ApplicationContextInitializer initializer : initializers) {
//调用initialize方法
initializer.initialize(context);
}
}实例4加载原理创建一个实现EnvironmentPostProcessor接口的类,在spring.factories中添加,如MyEnvironmentPostProcessor。实例4是在所有的测试中最先打印日志的,是因为它是在prepareEnvironment(准备环境)中执行,而前面3个实例都是在prepareContext(准备上下文)中执行。该实例中EventPublishingRunListener会调用prepareEnvironment方法,EventPublishingRunListener被定义在Spring Boot Jar包的META-INF/spring.factories中,用于发布各种SpringApplicationEvent事件。EventPublishingRunListener类中public void environmentPrepared(ConfigurableBootstrapContext bootstrapContext,
ConfigurableEnvironment environment) {
//广播环境准备完成事件
multicastInitialEvent(
new ApplicationEnvironmentPreparedEvent(bootstrapContext, this.application, this.args, environment));
}
private void multicastInitialEvent(ApplicationEvent event) {
//刷新SimpleApplicationEventMulticaster中的事件列表
refreshApplicationListeners();
//广播事件
this.initialMulticaster.multicastEvent(event);
}
private void refreshApplicationListeners() {
this.application.getListeners().forEach(this.initialMulticaster::addApplicationListener);
}SimpleApplicationEventMulticaster类中public void multicastEvent(ApplicationEvent event) {
multicastEvent(event, null);
}
@Override
public void multicastEvent(ApplicationEvent event, @Nullable ResolvableType eventType) {
ResolvableType type = (eventType != null ? eventType : ResolvableType.forInstance(event));
// 获取执行事件的线程池
Executor executor = getTaskExecutor();
//获取指定事件类型的事件集合
for (ApplicationListener<?> listener : getApplicationListeners(event, type)) {
//如果定义了执行线程池,则用线程池调用
if (executor != null) {
executor.execute(() -> invokeListener(listener, event));
}
else {
//同步调用监听器
invokeListener(listener, event);
}
}
}
protected void invokeListener(ApplicationListener<?> listener, ApplicationEvent event) {
//获取失败处理器
ErrorHandler errorHandler = getErrorHandler();
if (errorHandler != null) {
try {
doInvokeListener(listener, event);
}
catch (Throwable err) {
errorHandler.handleError(err);
}
}
else {
doInvokeListener(listener, event);
}
}
@SuppressWarnings({"rawtypes", "unchecked"})
private void doInvokeListener(ApplicationListener listener, ApplicationEvent event) {
try {
//此处执行事件监听器的onApplicationEvent方法
listener.onApplicationEvent(event);
}
catch (ClassCastException ex) {
String msg = ex.getMessage();
if (msg == null || matchesClassCastMessage(msg, event.getClass()) ||
(event instanceof PayloadApplicationEvent payloadEvent &&
matchesClassCastMessage(msg, payloadEvent.getPayload().getClass()))) {
// Possibly a lambda-defined listener which we could not resolve the generic event type for
// -> let's suppress the exception.
Log loggerToUse = this.lazyLogger;
if (loggerToUse == null) {
loggerToUse = LogFactory.getLog(getClass());
this.lazyLogger = loggerToUse;
}
if (loggerToUse.isTraceEnabled()) {
loggerToUse.trace("Non-matching event type for listener: " + listener, ex);
}
}
else {
throw ex;
}
}
}在listener.onApplicationEvent(event);处,在本例中为EnvironmentPostProcessorApplicationListenerEnvironmentPostProcessorApplicationListener类中:public void onApplicationEvent(ApplicationEvent event) {
//根据各个事件类型分别去处理
if (event instanceof ApplicationEnvironmentPreparedEvent environmentPreparedEvent) {
onApplicationEnvironmentPreparedEvent(environmentPreparedEvent);
}
if (event instanceof ApplicationPreparedEvent) {
onApplicationPreparedEvent();
}
if (event instanceof ApplicationFailedEvent) {
onApplicationFailedEvent();
}
}
private void onApplicationEnvironmentPreparedEvent(ApplicationEnvironmentPreparedEvent event) {
ConfigurableEnvironment environment = event.getEnvironment();
SpringApplication application = event.getSpringApplication();
// 获取所有的 EnvironmentPostProcessor,然后执行其 postProcessEnvironment 方法
for (EnvironmentPostProcessor postProcessor : getEnvironmentPostProcessors(application.getResourceLoader(),
event.getBootstrapContext())) {
postProcessor.postProcessEnvironment(environment, application);
}
}
// 获取所有的 EnvironmentPostProcessor
List<EnvironmentPostProcessor> getEnvironmentPostProcessors(ResourceLoader resourceLoader,
ConfigurableBootstrapContext bootstrapContext) {
ClassLoader classLoader = (resourceLoader != null) ? resourceLoader.getClassLoader() : null;
//postProcessorsFactory 是一个函数表达式
EnvironmentPostProcessorsFactory postProcessorsFactory = this.postProcessorsFactory.apply(classLoader);
return postProcessorsFactory.getEnvironmentPostProcessors(this.deferredLogs, bootstrapContext);
}EnvironmentPostProcessorsFactory postProcessorsFactory = this.postProcessorsFactory.apply(classLoader);中的postProcessorsFactory是在EnvironmentPostProcessorApplicationListener实例化的时候初始化,根据前面的文章我们知道EnvironmentPostProcessorApplicationListener是一个监听器,会在SpringBoot初始化的时候初始化。public EnvironmentPostProcessorApplicationListener() {
this(EnvironmentPostProcessorsFactory::fromSpringFactories);
}
private EnvironmentPostProcessorApplicationListener(
Function<ClassLoader, EnvironmentPostProcessorsFactory> postProcessorsFactory) {
this.postProcessorsFactory = postProcessorsFactory;
this.deferredLogs = new DeferredLogs();
}
static EnvironmentPostProcessorsFactory fromSpringFactories(ClassLoader classLoader) {
return new SpringFactoriesEnvironmentPostProcessorsFactory(
SpringFactoriesLoader.forDefaultResourceLocation(classLoader));
}EnvironmentPostProcessorsFactory fromSpringFactories(ClassLoader classLoader) 会在EnvironmentPostProcessorsFactory postProcessorsFactory = this.postProcessorsFactory.apply(classLoader); apply的时候调用,如果没有加载META-INF/spring.factories会再这里再次加载。EnvironmentPostProcessorsFactory 的主要作用是实例化EnvironmentPostProcessor,SpringFactoriesEnvironmentPostProcessorsFactory是其子类。SpringFactoriesEnvironmentPostProcessorsFactory类中:public List<EnvironmentPostProcessor> getEnvironmentPostProcessors(DeferredLogFactory logFactory,
ConfigurableBootstrapContext bootstrapContext) {
ArgumentResolver argumentResolver = ArgumentResolver.of(DeferredLogFactory.class, logFactory);
//向argumentResolver对象中添加ConfigurableBootstrapContext.class和bootstrapContext,获取更新后的argumentResolver对象
argumentResolver = argumentResolver.and(ConfigurableBootstrapContext.class, bootstrapContext);
// // 向argumentResolver对象中添加BootstrapRegistry.class和bootstrapContext,获取更新后的argumentResolver对象
argumentResolver = argumentResolver.and(BootstrapContext.class, bootstrapContext);
通过this.loader.load方法加载EnvironmentPostProcessor类型的对象,参数为argumentResolver
argumentResolver = argumentResolver.and(BootstrapRegistry.class, bootstrapContext);
//加载EnvironmentPostProcessor类型的对象
return this.loader.load(EnvironmentPostProcessor.class, argumentResolver);
}最后循环调用postProcessor.postProcessEnvironment(environment, application);完成执行。总结同样的,用一张图来总结本文整个流程:
cathoy
SpringCloud 微服务集群升级记录(1.5.x-2.7.18)
前言前段时间,因项目被扫出大量漏洞,全是因为依赖版本过低,存在高中危漏洞需要升级。正好本来也有规划集群升级,因为工作量大迟迟落实不了,正好有这次修漏洞的机会,升级微服务集群。这篇文章主要记录了本人的升级记录,遇到的问题解决方法,仅供参考。项目背景项目微服务技术栈:Spring Boot 1.5.x 、Spring Cloud、Kafka、RabbitMq、Mysql、Eureka、Apollo、Nacos。Spring Boot 是1.5.x 版本非常老旧,Spring Cloud 版本也早就停更。根据Nacos的兼容情况,Spring Boot 的版本为2.6.13,但目前最新版是2.7.18,由于3.x跟2.x区别较大,因此决定使用2.7.18试试,Spring Cloud 版本为2021.0.5.0。升级记录在xml中加入依赖,过期的配置会提示:<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-properties-migrator</artifactId>
<scope>runtime</scope>
</dependency>1、Loading class com.mysql.jdbc.Driver'. This is deprecated. The new driver class is com.mysql.cj.jdbc.Driver’需要更新Mysql驱动2、Caused by: java.lang.IllegalStateException: Could not resolve element type of Iterable type @。。。。。web.bind.annotation.RequestParam java.util.List. Not declared?@RequestParam(value = "Long[]") List projectIds 此类的代码不能再使用3、spring.rabbitmq.publisher-confirms 过期4、Canonical names should be kebab-case (‘-’ separated)不能使用驼峰形式,用- 隔开5、import org.springframework.cloud.netflix.feign 修改为 import org.springframework.cloud.openfeign6、2.6以后不允许循环依赖spring:
main:
# Spring Boot 2.6以后 默认不允许循环依赖
allow-circular-references: true
#允许bean覆盖
allow-bean-definition-overriding: true7、spring.cloud.client.ipAddress 都修改为 spring.cloud.client.ip-address8、跨域头修改由原来的修改为corsConfiguration.setAllowCredentials(true);
corsConfiguration.addAllowedOriginPattern("*");9、gateway 升级要注意去掉重复跨域头spring.cloud.gateway.default-filters[0] = DedupeResponseHeader=Access-Control-Allow-Origin Access-Control-Allow-Credentials, RETAIN_FIRST10、database配置过期The use of configuration keys that have been renamed was found in the environment:
Property source 'ApolloBootstrapPropertySources':
Key: spring.datasource.data
Replacement: spring.sql.init.data-locations
Key: spring.datasource.platform
Replacement: spring.sql.init.platform
Key: spring.datasource.schema
Replacement: spring.sql.init.schema-locations修改: sql:
init:
platform: mysql
#执行的sql语句
data-locations: classpath:data.sql
#执行的建表语句
schema-locations: classpath:schema.sql11、Eureka 配置的修改instance-id: ${spring.cloud.client.ip-address}:${server.port}
metadata-map:
user-name: ${spring.security.user.name}
user-password: ${spring.security.user.password}12、上传配置的修改spring:
servlet:
#最大上传大小,MB
multipart:
max-file-size: 1000MB
max-request-size: 1000MB13、原有zuul适配org.springframework.cloud.netflix.ribbon.RibbonLoadBalancerClient.choose(Ljava/lang/String;Lorg/springframework/cloud/client/loadbalancer/Request;)Lorg/springframework/cloud/client/ServiceInstance增加如下Bean@Bean
public LoadBalancerClient blockingLoadBalancerClient(LoadBalancerClientFactory loadBalancerClientFactory) {
return new BlockingLoadBalancerClient(loadBalancerClientFactory);
}14、调用接口报NoSuchMethodError: org.springframework.boot.web.servlet.error.ErrorController.getErrorPath增加如下类
@Configuration
public class ZuulConfiguration {
/**
* The path returned by ErrorController.getErrorPath() with Spring Boot < 2.5
* (and no longer available on Spring Boot >= 2.5).
*/
private static final String ERROR_PATH = "/error";
private static final String METHOD = "lookupHandler";
/**
* Constructs a new bean post-processor for Zuul.
*
* @param routeLocator the route locator.
* @param zuulController the Zuul controller.
* @param errorController the error controller.
* @return the new bean post-processor.
*/
@Bean
public ZuulPostProcessor zuulPostProcessor(@Autowired RouteLocator routeLocator,
@Autowired ZuulController zuulController,
@Autowired(required = false) ErrorController errorController) {
return new ZuulPostProcessor(routeLocator, zuulController, errorController);
}
private enum LookupHandlerCallbackFilter implements CallbackFilter {
INSTANCE;
@Override
public int accept(Method method) {
if (METHOD.equals(method.getName())) {
return 0;
}
return 1;
}
}
private enum LookupHandlerMethodInterceptor implements MethodInterceptor {
INSTANCE;
@Override
public Object intercept(Object target, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
if (ERROR_PATH.equals(args[0])) {
// by entering this branch we avoid the ZuulHandlerMapping.lookupHandler method to trigger the
// NoSuchMethodError
return null;
}
return methodProxy.invokeSuper(target, args);
}
}
private static final class ZuulPostProcessor implements BeanPostProcessor {
private final RouteLocator routeLocator;
private final ZuulController zuulController;
private final boolean hasErrorController;
ZuulPostProcessor(RouteLocator routeLocator, ZuulController zuulController, ErrorController errorController) {
this.routeLocator = routeLocator;
this.zuulController = zuulController;
this.hasErrorController = (errorController != null);
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if (hasErrorController && (bean instanceof ZuulHandlerMapping)) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(ZuulHandlerMapping.class);
enhancer.setCallbackFilter(LookupHandlerCallbackFilter.INSTANCE); // only for lookupHandler
enhancer.setCallbacks(new Callback[] {LookupHandlerMethodInterceptor.INSTANCE, NoOp.INSTANCE});
Constructor<?> ctor = ZuulHandlerMapping.class.getConstructors()[0];
return enhancer.create(ctor.getParameterTypes(), new Object[] {routeLocator, zuulController});
}
return bean;
}
}
}
15、负责均衡找不到下游服务的问题增加如下类:
public class RibbonEurekaClientConfig {
@Autowired
private DiscoveryClient discoveryClient;
@Bean
@Lazy
public IPing ribbonPing() {
return new DummyPing();
}
@Bean
@Lazy
public IRule ribbonRule(IClientConfig clientConfig) {
AvailabilityFilteringRule rule = new AvailabilityFilteringRule();
rule.initWithNiwsConfig(clientConfig);
return rule;
}
@Bean
@Lazy
public ServerList<?> ribbonServerList(IClientConfig clientConfig) {
return new ServerList<Server>() {
@Override
public List<Server> getInitialListOfServers() {
return new ArrayList<>();
}
@Override
public List<Server> getUpdatedListOfServers() {
List<Server> serverList = new ArrayList<>();
List<ServiceInstance> instances = discoveryClient.getInstances(clientConfig.getClientName());
if (instances != null && instances.size() == 0) {
return serverList;
}
for (ServiceInstance instance : instances) {
if (instance.isSecure()) {
serverList.add(new Server("https", instance.getHost(), instance.getPort()));
} else {
serverList.add(new Server("http", instance.getHost(), instance.getPort()));
}
}
return serverList;
}
};
}
}在Spring Boot 启动类上配置@RibbonClients(defaultConfiguration = RibbonEurekaClientConfig.class)16、java.lang.NoSuchMethodError: com.netflix.servo.monitor.Monitors.isObjectRegistered(Ljava/lang/String;Ljava/lang/Object;)Zzuul里的版本太久,需要排除 <dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-zuul</artifactId>
<version>2.2.10.RELEASE</version>
<!--zuul里的版本太久,要排除,否则报
java.lang.NoSuchMethodError: com.netflix.servo.monitor.Monitors.isObjectRegistered(Ljava/lang/String;Ljava/lang/Object;)Z
-->
<exclusions>
<exclusion>
<groupId>com.netflix.servo</groupId>
<artifactId>servo-core</artifactId>
</exclusion>
</exclusions>
</dependency>
cathoy
Spring Boot 属性加载原理解析
在《Spring Boot 框架整体启动流程详解》中,我们了解到有一步是准备环境prepareEnvironment,属性加载就是在这一步开始的。private ConfigurableEnvironment prepareEnvironment(SpringApplicationRunListeners listeners,
DefaultBootstrapContext bootstrapContext, ApplicationArguments applicationArguments) {
//创建并配置环境
ConfigurableEnvironment environment = getOrCreateEnvironment();
//配置环境,如果需要转换服务,添加ApplicationConversionService,另外委托给了configurePropertySources(属性源)和configureProfiles(配置文件),子类可以覆盖该方法或分别覆盖两者进行细粒度控制
configureEnvironment(environment, applicationArguments.getSourceArgs());
//将ConfigurationPropertySource支持附加到指定的环境
ConfigurationPropertySources.attach(environment);
//调用environmentPrepared方法
listeners.environmentPrepared(bootstrapContext, environment);
//将defaultProperties属性源移动到指定配置环境的最后
DefaultPropertiesPropertySource.moveToEnd(environment);
Assert.state(!environment.containsProperty("spring.main.environment-prefix"),
"Environment prefix cannot be set via properties.");
//绑定环境到SpringApplication
bindToSpringApplication(environment);
//非自定义环境配置,就将其转换为标准类型
if (!this.isCustomEnvironment) {
EnvironmentConverter environmentConverter = new EnvironmentConverter(getClassLoader());
environment = environmentConverter.convertEnvironmentIfNecessary(environment, deduceEnvironmentClass());
}
//重新将ConfigurationPropertySource支持附加到指定的环境
ConfigurationPropertySources.attach(environment);
return environment;
}
进入getOrCreateEnvironment()private ConfigurableEnvironment getOrCreateEnvironment() {
//判断environment 是否为null,不为null使用environment
if (this.environment != null) {
return this.environment;
}
//根据web应用程序类型,通过applicationContextFactory创建environment
ConfigurableEnvironment environment = this.applicationContextFactory.createEnvironment(this.webApplicationType);
//如果environment为null,并且applicationContextFactory不是用的默认ApplicationContextFactory
if (environment == null && this.applicationContextFactory != ApplicationContextFactory.DEFAULT) {
//使用默认的ApplicationContextFactory创建environment
environment = ApplicationContextFactory.DEFAULT.createEnvironment(this.webApplicationType);
}
//如果不为null返回environment,否则只显示创建一个ApplicationEnvironment
return (environment != null) ? environment : new ApplicationEnvironment();
}this.applicationContextFactory 由于没有显示设置,使用的是默认的ApplicationContextFactoryprivate ApplicationContextFactory applicationContextFactory = ApplicationContextFactory.DEFAULT;ApplicationContextFactory DEFAULT = new DefaultApplicationContextFactory();进入createEnvironment(this.webApplicationType)中:public ConfigurableEnvironment createEnvironment(WebApplicationType webApplicationType) {
return getFromSpringFactories(webApplicationType, ApplicationContextFactory::createEnvironment, null);
}进入getFromSpringFactories中:private <T> T getFromSpringFactories(WebApplicationType webApplicationType,
BiFunction<ApplicationContextFactory, WebApplicationType, T> action, Supplier<T> defaultResult) {
//循环获取ApplicationContextFactory类型的实例
for (ApplicationContextFactory candidate : SpringFactoriesLoader.loadFactories(ApplicationContextFactory.class,
getClass().getClassLoader())) {
//调用实例的createEnvironment方法
T result = action.apply(candidate, webApplicationType);
if (result != null) {
return result;
}
}
return (defaultResult != null) ? defaultResult.get() : null;
}SpringFactoriesLoader.loadFactories(ApplicationContextFactory.class, getClass().getClassLoader()) 从META-INF/spring.factories中获取并实例化ApplicationContextFactory实例,Spring Boot定义了ReactiveWebServerApplicationContextFactory 和 ServletWebServerApplicationContextFactory,所以在这里会分别去调用其中的createEnvironment方法,由于这边是web环境,进入ServletWebServerApplicationContextFactory的createEnvironment中。public ConfigurableEnvironment createEnvironment(WebApplicationType webApplicationType) {
//不是Web Servlet环境的话返回null,是的话创建一个ApplicationServletEnvironment
return (webApplicationType != WebApplicationType.SERVLET) ? null : new ApplicationServletEnvironment();
}进入ApplicationServletEnvironment类中,其继承了StandardServletEnvironment,StandardServletEnvironment类继承了StandardEnvironment并实现了ConfigurableWebEnvironment接口,StandardEnvironment继承了AbstractEnvironment在创建ApplicationServletEnvironment的时候,会先创建父类的构造器,所以会先执行AbstractEnvironment的构造器,AbstractEnvironment是Environment的抽象基类public AbstractEnvironment() {
this(new MutablePropertySources());
}MutablePropertySources 是PropertySources接口的默认实现,PropertySources是属性配置源接口,描述了如何获取属性值。这里再调用了当前类的有参构造器。protected AbstractEnvironment(MutablePropertySources propertySources) {
this.propertySources = propertySources;
//创建配置解析器
this.propertyResolver = createPropertyResolver(propertySources);
//调用自定义配置源,具体由子类实现
customizePropertySources(propertySources);
}
protected void customizePropertySources(MutablePropertySources propertySources) {
}这里就调用到了StandardServletEnvironment的customizePropertySources中:protected void customizePropertySources(MutablePropertySources propertySources) {
propertySources.addLast(new StubPropertySource(SERVLET_CONFIG_PROPERTY_SOURCE_NAME));
propertySources.addLast(new StubPropertySource(SERVLET_CONTEXT_PROPERTY_SOURCE_NAME));
if (jndiPresent && JndiLocatorDelegate.isDefaultJndiEnvironmentAvailable()) {
propertySources.addLast(new JndiPropertySource(JNDI_PROPERTY_SOURCE_NAME));
}
super.customizePropertySources(propertySources);
}在这里添加了关于ServletConfig、ServletContext、JNDI的配置源在该方法的最后,又调用到了父类StandardEnvironment的customizePropertySources中:protected void customizePropertySources(MutablePropertySources propertySources) {
propertySources.addLast(
new PropertiesPropertySource(SYSTEM_PROPERTIES_PROPERTY_SOURCE_NAME, getSystemProperties()));
propertySources.addLast(
new SystemEnvironmentPropertySource(SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, getSystemEnvironment()));
}在这里添加了Java System属性、操作系统环境变量两个配置源到此为止已经添加了4个配置源,由于这里不是JNDI环境,没有添加JNDI的配置源,这里执行结束后返回到SpringApplication的getOrCreateEnvironment()处接着进入configureEnvironment(environment, applicationArguments.getSourceArgs())中protected void configureEnvironment(ConfigurableEnvironment environment, String[] args) {
//这里用于添加转换服务
if (this.addConversionService) {
environment.setConversionService(new ApplicationConversionService());
}
//这里也是设置配置源,后面详解
configurePropertySources(environment, args);
//设置激活的配置文件
configureProfiles(environment, args);
}进入configurePropertySources(environment, args)中protected void configurePropertySources(ConfigurableEnvironment environment, String[] args) {
//获取环境中已有的配置源
MutablePropertySources sources = environment.getPropertySources();
//默认配置不为空,则添加到配置源中,defaultProperties通过springApplication.setDefaultProperties(properties) 配置
if (!CollectionUtils.isEmpty(this.defaultProperties)) {
//addOrMerge会判断已有的配置源中是否已经存在了defaultProperties,来判断是合并还是直接添加
DefaultPropertiesPropertySource.addOrMerge(this.defaultProperties, sources);
}
//判断是否有命令行参数,addCommandLineProperties表示是否允许添加命令行配置,默认为true,可通过setAddCommandLineProperties配置
if (this.addCommandLineProperties && args.length > 0) {
//命令行配置源名称
String name = CommandLinePropertySource.COMMAND_LINE_PROPERTY_SOURCE_NAME;
//已有配置源中是否包含命令行配置源名称
if (sources.contains(name)) {
PropertySource<?> source = sources.get(name);
CompositePropertySource composite = new CompositePropertySource(name);
//创建一个具有新名称的组合配置源
composite
.addPropertySource(new SimpleCommandLinePropertySource("springApplicationCommandLineArgs", args));
composite.addPropertySource(source);
//使用新的替换原来的配置源
sources.replace(name, composite);
}
else {
//不包含就添加到已有源的最前面
sources.addFirst(new SimpleCommandLinePropertySource(args));
}
}
}SimpleCommandLinePropertySource 用于解析命令行参数并填充到CommandLineArgs中,解析规则为:–optName[=optValue]必须以“–”为前缀,并且可以指定值,也可以不指定值。如果指定了值,则名称和值必须用等号(“=”)分隔,不带空格。该值可以是空字符串(可选)。有效示例有:–foo–foo=–foo=“”–foo=bar–foo=“bar then baz”–foo=bar,baz,biz无效示例:-foo–foo bar–foo = bar–foo=bar --foo=baz --foo=biz添加完命令行配置源有,进入configureProfiles(environment, args)中,开始设置激活的配置文件:protected void configureProfiles(ConfigurableEnvironment environment, String[] args) {
}这是一个空的protected方法,可见需要子类去实现,这边没有SpringApplication的子类,也就不会在这里处理。configureEnvironment处理完后,进入ConfigurationPropertySources.attach(environment):public static void attach(Environment environment) {
Assert.isInstanceOf(ConfigurableEnvironment.class, environment);
MutablePropertySources sources = ((ConfigurableEnvironment) environment).getPropertySources();
PropertySource<?> attached = getAttached(sources);
if (attached == null || !isUsingSources(attached, sources)) {
attached = new ConfigurationPropertySourcesPropertySource(ATTACHED_PROPERTY_SOURCE_NAME,
new SpringConfigurationPropertySources(sources));
}
sources.remove(ATTACHED_PROPERTY_SOURCE_NAME);
sources.addFirst(attached);
}该处代码用于将ConfigurationPropertySourcesPropertySource类型的源添加到已有的配置源中,名称为configurationProperties这里处理完后,会调用listeners.environmentPrepared(bootstrapContext, environment),通过EventPublishingRunListener发送ApplicationEnvironmentPreparedEvent事件,这块前面我们已经多次讲到过,这里不再复述,我们进入EnvironmentPostProcessorApplicationListener,其中的onApplicationEvent在收到ApplicationEnvironmentPreparedEvent事件后,执行onApplicationEnvironmentPreparedEvent(ApplicationEnvironmentPreparedEvent event)private void onApplicationEnvironmentPreparedEvent(ApplicationEnvironmentPreparedEvent event) {
ConfigurableEnvironment environment = event.getEnvironment();
SpringApplication application = event.getSpringApplication();
for (EnvironmentPostProcessor postProcessor : getEnvironmentPostProcessors(application.getResourceLoader(),
event.getBootstrapContext())) {
postProcessor.postProcessEnvironment(environment, application);
}
}getEnvironmentPostProcessors(application.getResourceLoader(), event.getBootstrapContext()) 会获取所有的EnvironmentPostProcessor实例,如根据本系列文章的Demo获取到的实例有:我们主要关注如下几个,其他的忽略:RandomValuePropertySourceEnvironmentPostProcessor: 添加RandomValuePropertySource 配置源,用来解析RandomValuePropertySource的随机值属性SystemEnvironmentPropertySourceEnvironmentPostProcessor:将原来的SystemEnvironmentPropertySource替换为OriginAwareSystemEnvironmentPropertySource,以便能够跟踪每个属性的SystemEnvironmentOriginSpringApplicationJsonEnvironmentPostProcessor:添加嵌入在环境变量或系统属性中的SPRING_APPLICATION_JSON 的属性CloudFoundryVcapEnvironmentPostProcessor:如果是Cloud Foundry平台,添加Cloud Foundry相关的配置源ConfigDataEnvironmentPostProcessor:添加application.yml等配置源DevToolsHomePropertiesPostProcessor:添加Devtools 全局配置的配置源另外@PropertySource注解配置的加载是在刷新上下文中的ConfigurationClassPostProcessor类中处理,具体代码可见ConfigurationClassParser:17种属性配置的加载基本都在这里了,最后总结一下总结
cathoy
Spring Boot 中文参考指南(二)-Web
6. WebSpring Boot 非常适合开发Web应用程序,可以使用Tomcat、Jetty、Undertow 或 Netty 作为HTTP服务器,基于servlet的应用程序使用spring-boot-starter-web模块,响应式的Web应用程序使用spring-boot-starter-webflux。6.1 Servlet Web 应用如果你想要构建基于servlet的web应用,可以利用Spring Boot 给Spring MVC 或者 Jersey提供的自动配置。6.1.1 Spring Web MVC FrameworkSpring MVC 允许你创建特定的@Controller 或 @RestController Bean来处理传入的HTTP请求。控制器中的方法通过使用``@RequestMapping`注解映射到HTTP。如下示例显示了一个典型的提供JSON 数据的@RestController例子:import java.util.List;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/users")
public class MyRestController {
private final UserRepository userRepository;
private final CustomerRepository customerRepository;
public MyRestController(UserRepository userRepository, CustomerRepository customerRepository) {
this.userRepository = userRepository;
this.customerRepository = customerRepository;
}
@GetMapping("/{userId}")
public User getUser(@PathVariable Long userId) {
return this.userRepository.findById(userId).get();
}
@GetMapping("/{userId}/customers")
public List<Customer> getUserCustomers(@PathVariable Long userId) {
return this.userRepository.findById(userId).map(this.customerRepository::findByUser).get();
}
@DeleteMapping("/{userId}")
public void deleteUser(@PathVariable Long userId) {
this.userRepository.deleteById(userId);
}
}Spring MVC 自动配置自动配置在Spring的默认配置之上添加了以下功能:包含ContentNegotiatingViewResolver Bean 和 BeanNameViewResolverBean支持服务静态资源,包括支持WebJars(后续介绍)自动注册Converter、GenericConverter、Formatter Bean支持HttpMessageConverters(后续介绍)自动注册MessageCodesResolver(后续介绍)静态index.html支持自动使用ConfigurableWebBindingInitializer bean (后续介绍)如果你想保留这些Spring Boot MVC 的自定义功能,并进行更多的MVC自定义(拦截器、格式化、视图控制器等),你可以添加自己的WebMvcConfigurer类型的@Configuration类,但不需要添加@EnableWebMvc。如果想提供RequestMappingHandlerMapping、RequestMappingHandlerAdapter或ExceptionHandlerExceptionResolver的自定义实例,并保留Spring Boot MVC自定义,您可以声明WebMvcRegistrations类型的bean,并使用它来提供这些组件的自定义实例。如果想完全控制Spring MVC,您可以添加您自己的@Configuration用@EnableWebMvc注解,或者添加您自己的@Configuration-annotated DelegatingWebMvcConfiguration,如@EnableWebMvc的Javadoc中所述。Spring MVC使用的ConversionService与用于从application.properties或application.yaml文件中转换值的服务不同。这意味着Period、Duration和DataSize转换器不可用,@DurationUnit和@DataSizeUnit注释将被忽略。如果您想定制Spring MVC使用的ConversionService,可以提供带有addFormatters方法的WebMvcConfigurer bean。通过此方法,您可以注册任何您喜欢的转换器,也可以委托给ApplicationConversionService上可用的静态方法。笔者注:Spring MVC自动配置由spring-boot-autoconfigure依赖中的WebMvcAutoConfiguration类加载ContentNegotiatingViewResolver的配置@Bean
@ConditionalOnBean(ViewResolver.class)
@ConditionalOnMissingBean(name = "viewResolver", value = ContentNegotiatingViewResolver.class)
public ContentNegotiatingViewResolver viewResolver(BeanFactory beanFactory) {
ContentNegotiatingViewResolver resolver = new ContentNegotiatingViewResolver();
resolver.setContentNegotiationManager(beanFactory.getBean(ContentNegotiationManager.class));
// ContentNegotiatingViewResolver 使用其他视图解析器来定位视图,所以应该具有较高的优先级
reso lver.setOrder(Ordered.HIGHEST_PRECEDENCE);
return resolver;
}
ContentNegotiatingViewResolver 本身不解析视图,而是委托给其他的viewResolverBeanNameViewResolver的配置@Bean
@ConditionalOnBean(View.class)
@ConditionalOnMissingBean
public BeanNameViewResolver beanNameViewResolver() {
BeanNameViewResolver resolver = new BeanNameViewResolver();
resolver.setOrder(Ordered.LOWEST_PRECEDENCE - 10);
return resolver;
}
BeanNameViewResolver 用于将视图名称解析为上下文中的beanWebMvcRegistrations 是一个接口,可以注册WebMvcConfigurationSupport的关键注解,以此来覆盖Spring MVC提供的默认组件ConversionService 类型转换的服务接口HttpMessageConvertersSpring MVC 使用HttpMessageConverter接口来转换HTTP请求和响应,开箱即用。例如,对象可以自动转换为JSON或XML(使用Jackson XML 扩展,如果不可用使用JAXB),默认情况下,字符串使用UTF-8编码。如果需要自定义转换器,可以使用Spring Boot 的 HttpMessageConverters类,如下所示:import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
@Configuration(proxyBeanMethods = false)
public class MyHttpMessageConvertersConfiguration {
@Bean
public HttpMessageConverters customConverters() {
HttpMessageConverter<?> additional = new AdditionalHttpMessageConverter();
HttpMessageConverter<?> another = new AnotherHttpMessageConverter();
return new HttpMessageConverters(additional, another);
}
}在上下文中存在的任何HttpMessageConverter都会添加到转换器列表中,可以用同样的方式覆盖默认转换器。MessageCodesResolverSpring MVC 有一个策略来生成错误代码,用于从绑定的错误中渲染错误消息:MessageCodesResolver。如果你设置了spring.mvc.message-codes-resolver-format属性PREFIX_ERROR_CODE或者POSTFIX_ERROR_CODE,Spring Boot 会自动创建一个。静态内容默认的,Spring Boot 提供静态内容的路径是类路径的/static或/public或/resources或/META-INF/resources或者ServletContext的根目录。它使用Spring MVC的ResourceHttpRequestHandler处理, 也可以通过添加自己的WebMvcConfigurer并覆盖addResourceHandlers方法来修改。在独立的web应用程序中,容器的默认servlet未启用,可以使用server.servlet.register-default-servlet属性启用。默认servlet充当回退,如果Spring决定不处理它,则从ServletContext的根目录中提供内容。大多数时候,这种情况不会发生(除非您修改默认的MVC配置),因为Spring始终可以通过DispatcherServlet处理请求。默认情况下,资源映射在/**上,但您可以使用spring.mvc.static-path-pattern属性进行调整。例如,将所有资源迁移到/resources/**可以以下操作:spring.mvc.static-path-pattern=/resources/**您还可以使用spring.web.resources.static-locations属性自定义静态资源位置(将默认值替换为目录位置列表)。根servlet上下文路径"/"也会自动添加为位置。除了前面提到的“标准”静态资源位置外,还为Webjars 内容做了兼容,如果打包,任何/webjars/**的路径资源将从jar文件中获取。如果你的应用程序被打包为jar,请勿使用/src/main/webapp目录,因为会被忽略,虽然此目录是一个常见的标准,但它仅用于war 打包。Spring Boot 还支持Spring MVC 提供的高级资源处理功能,比如缓存破坏或为Webjars提供与版本无关的URL。要使用Webjars的版本无关URL,添加webjars-locator-core依赖项,然后声明Webjar。以jQuery为例,添加"/webjars/jquery/jquery.min.js"结合会变成"/webjars/jquery/x.y.z/jquery.min.js",其中x.y.z是Webjar版本。如果使用的是JBoss,你需要声明webjars-locator-jboss-vfs依赖项,而不是webjars-locator-core,否则所有的Webjars 会解析为404。通过在URL中添加散列值,使静态资源缓存破坏,以下配置为所有静态资源都不被缓存,比如<link href="/css/spring-2a2d595e6ed9a0b24f027f2b63b134d6.css"/>。spring.web.resources.chain.strategy.content.enabled=true
spring.web.resources.chain.strategy.content.paths=/**由于为Thymelaf和FreeMarker自动配置了ResourceUrlEncodingFilter,资源链接在运行时会在模板中重写。使用JSP时,您应该手动声明此过滤器。目前不自动支持其他模板引擎,但可以使用自定义模板macros/helpers和使用ResourceUrlProvider。"fixed"策略可以在不更改文件名的情况下载URL中添加静态版本字符串,如下所示:spring.web.resources.chain.strategy.content.enabled=true
spring.web.resources.chain.strategy.content.paths=/**
spring.web.resources.chain.strategy.fixed.enabled=true
spring.web.resources.chain.strategy.fixed.paths=/js/lib/
spring.web.resources.chain.strategy.fixed.version=v12通过这样的配置,JavaScript模块定位/js/lib/下的资源使用fixed策略(/v12/js/lib/mymodule.js),而其他资源依然使用内容策略(<link href="/css/spring-2a2d595e6ed9a0b24f027f2b63b134d6.css"/>)。查看WebProperties.Resources,获取更多支持的选项。欢迎页Spring Boot 支持静态和模板欢迎页面,它首先在配置的静态内容位置中查找index.html文件,如果找不到,会查找index模板,如果找到,它会自动用作应用程序的欢迎页。自定义Favicon跟其他的静态资源一样,Spring Boot 会在配置的静态内容位置检查favicon.ico,如果存在这样的文件,它会自动用作应用程序的图标。路径匹配和内容协商Spring MVC 可以通过请求路径并将其与应用程序中定义的映射(如,控制器上的@GetMapping注解)来将传入的HTTP请求映射到处理程序。Spring Boot 默认是禁用后缀匹配模式的,像"GET /projects/spring-boot.json"这样的地址不会跟@GetMapping("/projects/spring-boot")匹配。该功能主要用于不会发送正确的"Accept"头的HTTP客户端。对于始终不会发送正确的 "Accept"头的客户端,可以不使用后缀匹配,而是使用查询参数,比如GET /projects/spring-boot?format=json 将映射到@GetMapping("/projects/spring-boot")。spring.mvc.contentnegotiation.favor-parameter=true或者使用不同的参数名称:spring.mvc.contentnegotiation.favor-parameter=true
spring.mvc.contentnegotiation.parameter-name=myparam大多数媒体类型都支持开箱即用,但也可以定义新的媒体类型。spring.mvc.contentnegotiation.media-types.markdown=text/markdown后缀匹配模式已被弃用,并将在未来版本中删除,如果仍然希望使用后缀匹配模式,则需要以下配置:spring.mvc.contentnegotiation.favor-path-extension=true
spring.mvc.pathmatch.use-suffix-pattern=true或者,与打开所有后缀模式相比,只支持注册的后缀模式更安全:spring.mvc.contentnegotiation.favor-path-extension=true
spring.mvc.pathmatch.use-registered-suffix-pattern=true从Spring Framework 5.3开始,Spring MVC支持几种将请求路径与控制器处理程序匹配的实现策略。它以前只支持AntPathMatcher策略,但现在还提供PathPatternParser。Spring Boot现在提供了一个配置属性来选择新策略:spring.mvc.pathmatch.matching-strategy=path-pattern-parserPathPatternParser是一个优化的实现,但限制了某些路径模式变体的使用,并且与后缀模式匹配(spring.mvc.pathmatch.use-suffix-pattern,spring.mvc.pathmatch.use-registered-suffix-pattern)或将DispatcherServlet映射为servlet前缀(spring.mvc.servlet.path)。ConfigurableWebBindingInitializerSpring MVC 使用WebBindingInitializer为特定的请求初始化WebDataBinder。如果你创建自己的ConfigurableWebBindingInitializer Bean,Spring Boot 会自动配置Spring MVC 使用它。模板引擎Spring MVC 支持多种模板技术,包括Thymeleaf、FreeMarker和JSP。FreeMarkerGroovyThymeleafMustache避免使用JSP,在跟嵌入式servelt容器使用的时候存在一些已知问题。使用其中一个模板引擎的默认配置,模板自动从src/main/resources/templates获取。错误处理默认情况下,Spring Boot 提供一个/error映射,以合理的方式处理所有错误,在servlet容器中它注册为一个"global"错误页。它会在机器客户端产生一个JSON响应包括error、Http状态和异常信息。对于浏览器客户端,会产生一个"whitelabel"错误视图,以HTML格式展现相同的数据(自定义的话,添加一个Vuew来解决error)。可以通过多个server.error属性来自定义默认错误处理行为。更多配置查看附录。要完全替换默认的行为,可以实现ErrorController并注册为Bean或者添加ErrorAttributes类型的bean替换内容。你也可以用@ControllerAdvice来定制JSON文本或异常类型,如下所示:import javax.servlet.RequestDispatcher;
import javax.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@ControllerAdvice(basePackageClasses = SomeController.class)
public class MyControllerAdvice extends ResponseEntityExceptionHandler {
@ResponseBody
@ExceptionHandler(MyException.class)
public ResponseEntity<?> handleControllerException(HttpServletRequest request, Throwable ex) {
HttpStatus status = getStatus(request);
return new ResponseEntity<>(new MyErrorBody(status.value(), ex.getMessage()), status);
}
private HttpStatus getStatus(HttpServletRequest request) {
Integer code = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
HttpStatus status = HttpStatus.resolve(code);
return (status != null) ? status : HttpStatus.INTERNAL_SERVER_ERROR;
}
}该示例中,如果MyException是由SomeController所在的包抛出的异常,使用MyErrorBody POJO的JSON代替ErrorAttributes的表示。在一些情况下,控制器级别处理的错误不会被度量指标记录,通过将处理的异常设置为请求属性,应用程序可以确保此类异常与请求度量一起记录。import javax.servlet.http.HttpServletRequest;
import org.springframework.boot.web.servlet.error.ErrorAttributes;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ExceptionHandler;
@Controller
public class MyController {
@ExceptionHandler(CustomException.class)
String handleCustomException(HttpServletRequest request, CustomException ex) {
request.setAttribute(ErrorAttributes.ERROR_ATTRIBUTE, ex);
return "errorView";
}
}自定义错误页如果要显示一个给定状态码的自定义HTML错误页,可以将文件添加到/error目录。错误页面可以是静态HTML(即,添加到任何静态资源目录下)或者使用模版构建,文件名应该是确切状态代码或序列掩码。例如,要将404映射到静态HTML文件,结构如下:src/
+- main/
+- java/
| + <source code>
+- resources/
+- public/
+- error/
| +- 404.html
+- <other public assets>使用FreeMark模板映射所有5xx错误,结构如下:src/
+- main/
+- java/
| + <source code>
+- resources/
+- templates/
+- error/
| +- 5xx.ftlh
+- <other templates>对于更复杂的映射,可以添加实现ErrorViewResolver接口的bean,如下:import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.springframework.boot.autoconfigure.web.servlet.error.ErrorViewResolver;
import org.springframework.http.HttpStatus;
import org.springframework.web.servlet.ModelAndView;
public class MyErrorViewResolver implements ErrorViewResolver {
@Override
public ModelAndView resolveErrorView(HttpServletRequest request, HttpStatus status, Map<String, Object> model) {
// Use the request or status to optionally return a ModelAndView
if (status == HttpStatus.INSUFFICIENT_STORAGE) {
// We could add custom model values here
new ModelAndView("myview");
}
return null;
}
}还可以是用常规的 @ExceptionHandler 和 @ControllerAdvice 特性,然后ErrorController会处理Spring MVC 之外映射错误页对于不使用Spring MVC的应用程序,可以使用ErrorPageRegistrar接口直接注册ErrorPages。此抽象直接与底层的嵌入式servlet容器一起使用,即使没有Spring MVC DispatcherServlet 也是有效的。import org.springframework.boot.web.server.ErrorPage;
import org.springframework.boot.web.server.ErrorPageRegistrar;
import org.springframework.boot.web.server.ErrorPageRegistry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
@Configuration(proxyBeanMethods = false)
public class MyErrorPagesConfiguration {
@Bean
public ErrorPageRegistrar errorPageRegistrar() {
return this::registerErrorPages;
}
private void registerErrorPages(ErrorPageRegistry registry) {
registry.addErrorPages(new ErrorPage(HttpStatus.BAD_REQUEST, "/400"));
}
}如果注册了一个ErrorPage,其路径最终由Filter处理(这在一些非Spring Web框架中很常见,如Jersey和Wicket),那么Filter必须明确注册为ERROR调度器,如以下示例所示:import java.util.EnumSet;
import javax.servlet.DispatcherType;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class MyFilterConfiguration {
@Bean
public FilterRegistrationBean<MyFilter> myFilter() {
FilterRegistrationBean<MyFilter> registration = new FilterRegistrationBean<>(new MyFilter());
// ...
registration.setDispatcherTypes(EnumSet.allOf(DispatcherType.class));
return registration;
}
}请注意,默认的FilterRegistrationBean不包括ERROR调度器类型。WAR部署中的错误处理当部署到servlet容器时,Spring Boot使用其错误页面过滤器将具有错误状态的请求转发到适当的错误页面。这是必要的,因为servlet规范没有提供用于注册错误页面的API。根据您部署WAR文件的容器以及应用程序使用的技术,可能需要一些额外的配置。只有在响应尚未提交的情况下,错误页面过滤器才能将请求转发到正确的错误页面。默认情况下,WebSphere Application Server 8.0及更高版本在成功完成servlet的服务方法后提交响应。您应该通过将com.ibm.ws.webcontainer.invokeFlushAfterService设置为false来禁用此行为。如果您正在使用Spring Security,并希望在错误页面中访问主体,则必须配置Spring Security的过滤器,以便在错误调度中调用。为此,请将spring.security.filter.dispatcher-types属性设置为async, error, forward, request。CORS支持跨域资源共享(CORS)是由大多数浏览器实现的W3C规范,允许您以灵活的方式指定哪种跨域请求被授权,而不是使用一些安全性较低且功能较弱的方法,如IFRAME或JSONP。从4.2版开始,Spring MVC支持CORS。在Spring Boot应用程序中使用带有@CrossOrigin注解的控制器方法,CORS不需要任何特定的配置。可以通过使用自定义的addCorsMappings(CorsRegistry)方法注册WebMvcConfigurer bean来定义全局CORS配置,如下例所示:import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration(proxyBeanMethods = false)
public class MyCorsConfiguration {
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**");
}
};
}
}6.1.2 JAX-RS 和 Jersey如果您更喜欢REST端点的JAX-RS编程模型,您可以使用其中一个可用的实现,而不是Spring MVC。Jersey和Apache CXF开箱即用。CXF要求您在应用程序上下文中将其Servlet或Filter注册为@Bean。Jersey有一些原生的Spring支持,因此我们还在Spring Boot中为其提供自动配置支持,以及启动器。要开始使用Jersey,请将spring-boot-starter-jersey作为依赖项,然后您需要一个类型ResourceConfig的@Bean,在其中注册所有端点,如以下示例所示:import org.glassfish.jersey.server.ResourceConfig;
import org.springframework.stereotype.Component;
@Component
public class MyJerseyConfig extends ResourceConfig {
public MyJerseyConfig() {
register(MyEndpoint.class);
}
}Jersey对扫描可执行档案的支持相当有限。例如,当运行可执行的war文件时,它无法扫描完全可执行的jar文件或WEB-INF/classes中找到的包中的端点。为了避免这种限制,不应使用packages方法,并且应使用register方法单独注册端点,如前例所示。对于更高级的自定义,您还可以注册任意数量的实现ResourceConfigCustomizer的bean。所有注册的端点都应该是带有HTTP资源注解的@Components(@GET等),如以下示例所示:import javax.ws.rs.GET;
import javax.ws.rs.Path;
import org.springframework.stereotype.Component;
@Component
@Path("/hello")
public class MyEndpoint {
@GET
public String message() {
return "Hello";
}
}由于Endpoint是Spring @Component,其生命周期由Spring管理,您可以使用@Autowired注释注入依赖项,并使用@Value注释注入外部配置。默认情况下,Jersey servlet被注册并映射到/*。您可以通过将@ApplicationPath添加到ResourceConfigResourceConfig更改映射。默认情况下,Jersey在名为jerseyServletRegistrationBean类型的@Bean中设置为servlet,名为jerseyServletRegistration。默认情况下,servlet被懒惰地初始化,但您可以通过设置spring.jersey.servlet.load-on-startup来自定义该行为。您可以通过创建您自己的同名bean来禁用或覆盖该bean。您还可以通过设置spring.jersey.type=filter(在这种情况下,替换或覆盖isjerseyFilterRegistration的@Bean)来使用过滤器而不是servlet。过滤器有一个@Order,你可以用spring.jersey.filter.order进行设置。当使用Jersey作为过滤器时,必须存在一个servlet来处理任何没有被Jersey拦截的请求。如果您的应用程序不包含此类servlet,您可能希望通过将server.servlet.register-default-servlet设置为true来启用默认servlet。servlet和过滤器注册都可以通过使用spring.jersey.init.*指定属性映射来提供init参数。6.1.3 嵌入式Servlet容器支持对于servlet应用程序,Spring Boot包括对嵌入式Tomcat、Jetty和Undertow服务器的支持。大多数开发人员使用适当的“Starter”来获取完全配置的实例。默认情况下,嵌入式服务器在port8080上监听HTTP请求。Servlet、过滤器和监听器使用嵌入式servlet容器时,您可以通过使用Springbean或扫描servlet组件,从servlet规范中注册servlet、过滤器和所有侦听器(如HttpSessionListener)。将Servlet、过滤器和监听器注册为Spring Beans任何作为Spring bean的Servlet、Filter或servlet*Listener实例都注册在嵌入式容器中。如果您想在配置期间引用application.properties中的值,这可能会特别方便。默认情况下,如果上下文仅包含单个Servlet,则将其映射到/。在多个servlet bean的情况下,bean名称用作路径前缀。过滤器映射到/*。如果基于约定的映射不够灵活,您可以使用ServletRegistrationBean、FilterRegistrationBean和ServletListenerRegistrationBean类进行完全控制。过滤bean不有序通常是安全的。如果需要指定顺序,您应该用@Order注解Filter或使其实现Ordered。您无法通过用@Order注解其bean方法来配置Filter的顺序。如果您无法将Filter类更改为添加@Order或实现Ordered,则必须为Filter定义FilterRegistrationBean,并使用setOrder(int)方法设置注册bean的顺序。避免配置在Ordered.HIGHEST_PRECEDENCE读取请求主体的过滤器,因为它可能与应用程序的字符编码配置相拢。如果servlet过滤器包装了请求,则应配置小于或等于OrderedFilter.REQUEST_WRAPPER_FILTER_MAX_ORDER的顺序。要查看应用程序中每个Filter的顺序,请为web日志组启用调试级别日志记录(logging.level.web=debug)。然后,将在启动时记录已注册过滤器的详细信息,包括其订单和URL模式。注册Filterbean时要小心,因为它们在应用程序生命周期的早期就被初始化了。如果您需要注册与其他bean交互的Filter,请考虑使用DelegatingFilterProxyRegistrationBean。Servlet 上下文初始化嵌入式servlet容器不直接执行servlet 3.0+ javax.servlet.ServletContainerInitializer接口或Spring的org.springframework.web.WebApplicationInitializer接口。这是一个有意的设计决定,旨在降低在war中运行的第三方库可能破坏Spring Boot应用程序的风险。如果您需要在Spring Boot应用程序中执行servlet上下文初始化,您应该注册一个实现org.springframework.boot.web.servlet.ServletContextInitializer接口的bean。单一的onStartup方法提供对ServletContext的访问,如有必要,可以轻松用作现有WebApplicationInitializer的适配器。扫描 Servlets、Filters和 listeners在嵌入式容器中,可以使用@ServletComponentScan开启@WebServlet, @WebFilter, 和 @WebListener注解的自动注册。在独立容器中,@ServletComponentScan没有效果,而是是使用的容器的内置发现机制ServletWebServerApplicationContextSpring Boot 底层使用不同类型的ApplicationContext来支持嵌入式servelt容器。ServletWebServerApplicationContext是一种特殊的WebApplicationContext,它通过搜索单个ServletWebServerFactory bean来自我引导。通常会自动配置TomcatServletWebServerFactory、JettyServletWebServerFactory或UndertowServletWebServerFactory。通常不需要了解这些实现类。大多数应用程序都是自动配置的,而且将根据你的要求创建适当的ApplicationContext和ServletWebServerFactory。在嵌入式容器设置中,ServletContext 在应用程序上下文初始化期间的服务器启动过程中设置。因为,ApplicationContext中的bean无法使用ServletContext可靠地初始化。解决这个问题的一种方法是将ApplicationContext作为bean的依赖项注入,并仅在需要时访问ServletContext。另一种方法是在服务器启动后使用回调。这可以使用ApplicationListener完成,它监听ApplicationStartedEvent,如下所示:import javax.servlet.ServletContext;
import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationListener;
import org.springframework.web.context.WebApplicationContext;
public class MyDemoBean implements ApplicationListener<ApplicationStartedEvent> {
private ServletContext servletContext;
@Override
public void onApplicationEvent(ApplicationStartedEvent event) {
ApplicationContext applicationContext = event.getApplicationContext();
this.servletContext = ((WebApplicationContext) applicationContext).getServletContext();
}
}自定义嵌入式Servelt容器可以使用Spring Environment属性配置常用的servlet容器设置。通常,您将在application.properties或application.yaml文件中定义属性。常见服务设置包括:网络设置:侦听来自HTTP请求的端口(server.port),绑定服务器的接口地址(server.address)等。会话设置:会话是否持久(server.servlet.session.persistent),会话超时(server.servlet.session.timeout),会话数据的位置(server.servlet.session.store-dir),会话cookie配置(server.servlet.session.cookie.*)。错误管理:错误页面位置(server.error.path)等等。SSLHTTP compressionSpring Boot尽可能地暴露常见设置,但这并不总是可能的。 对于这些情况,专用名称空间提供特定服务器的定制(请参见server.tomcat和server.undertow)。 例如,可以使用嵌入式servlet容器的特定功能配置访问日志。有关完整列表,请参阅ServerProperties类。SameSite Cookies该SameSite cookie属性可由Web浏览器用于控制cookie在跨站点请求中是否提交,以及如何提交。当属性缺失时,该属性对于现代Web浏览器尤为重要,因为它们开始改变默认值。如果你想更改会话cookie的SameSite属性,你可以使用server.servlet.session.cookie.same-site属性。这个属性被自动配置的Tomcat、Jetty和Undertow服务器所支持。例如,如果您希望会话cookie具有None的SameSite属性,您可以将以下内容添加到您的application.properties或application.yaml文件中:server.servlet.session.cookie.same-site=none如果您想更改添加到HttpServletResponse的其他cookie上的SameSite属性,您可以使用CookieSameSiteSupplier。CookieSameSiteSupplier传递一个Cookie,并可能返回SameSite值或null。有许多便利的工厂和过滤器方法,可以快速匹配特定的 cookie。例如,添加以下 bean 将自动为名称与正则表达式 myapp.* 匹配的所有 cookie 应用 Lax 的 SameSite。import org.springframework.boot.web.servlet.server.CookieSameSiteSupplier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class MySameSiteConfiguration {
@Bean
public CookieSameSiteSupplier applicationCookieSameSiteSupplier() {
return CookieSameSiteSupplier.ofLax().whenHasNameMatching("myapp.*");
}
}程序化定制如果您需要以编程方式配置嵌入式servlet容器,您可以注册实现WebServerFactoryCustomizer接口的Spring Bean。WebServerFactoryCustomizer提供对ConfigurableServletWebServerFactory的访问,其中包括许多自定义设置方法。以下示例显示了以编程方式设置端口:import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory;
import org.springframework.stereotype.Component;
@Component
public class MyWebServerFactoryCustomizer implements WebServerFactoryCustomizer<ConfigurableServletWebServerFactory> {
@Override
public void customize(ConfigurableServletWebServerFactory server) {
server.setPort(9000);
}
}TomcatServletWebServerFactory、JettyServletWebSServerFactory和UndertowServletWebSrverFactory是ConfigurableServletWebServerFactory的专用变体,它们分别为Tomcat、Jetty和Undertow提供了额外的自定义setter方法。以下示例显示了如何自定义TomcatServletWebServerFactory,以提供对Tomcat特定配置选项的访问:import java.time.Duration;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.stereotype.Component;
@Component
public class MyTomcatWebServerFactoryCustomizer implements WebServerFactoryCustomizer<TomcatServletWebServerFactory> {
@Override
public void customize(TomcatServletWebServerFactory server) {
server.addConnectorCustomizers((connector) -> connector.setAsyncTimeout(Duration.ofSeconds(20).toMillis()));
}
}
直接自定义ConfigurableServletWebServerFactory对于需要您从ServletWebServerFactory扩展的更高级用例,您可以自己公开此类类型的bean。为许多配置选项提供了设置器。如果您需要做一些更差异化的事情,还提供了几种受保护的方法“钩子”。有关详细信息,请参阅源代码文档。JSP限制当运行使用嵌入式servlet容器(并打包为可执行存档)的Spring Boot应用程序时,JSP支持有一些限制。有了Jetty和Tomcat,如果你使用war打包,它应该可以工作。当使用java -jar启动时,可执行war将起作用,也可以部署到任何标准容器中。使用可执行jar时不支持JSP。Undertow不支持JSP。创建自定义error.jsp页面不会覆盖错误处理的默认视图。应使用自定义错误页面。6.2 响应式Web应用Spring Boot通过为Spring Webflux提供自动配置,简化了反应式Web应用程序的开发。6.2.1 Spring WebFlux FrameworkSpring WebFlux是Spring Framework 5.0中引入的新反应式Web框架。与Spring MVC不同,它不需要servlet API,是完全异步和非阻塞的,并通过Reactor项目实现Reactive Streams规范。Spring WebFlux 有两种形式:功能性的和基于注解的。基于注解的形式非常接近Spring MVC模型,如下所示:import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/users")
public class MyRestController {
private final UserRepository userRepository;
private final CustomerRepository customerRepository;
public MyRestController(UserRepository userRepository, CustomerRepository customerRepository) {
this.userRepository = userRepository;
this.customerRepository = customerRepository;
}
@GetMapping("/{userId}")
public Mono<User> getUser(@PathVariable Long userId) {
return this.userRepository.findById(userId);
}
@GetMapping("/{userId}/customers")
public Flux<Customer> getUserCustomers(@PathVariable Long userId) {
return this.userRepository.findById(userId).flatMapMany(this.customerRepository::findByUser);
}
@DeleteMapping("/{userId}")
public Mono<Void> deleteUser(@PathVariable Long userId) {
return this.userRepository.deleteById(userId);
}
}“WebFlux.fn”是功能变体,将路由配置与请求的实际处理分开,如以下示例所示:import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.server.RequestPredicate;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
@Configuration(proxyBeanMethods = false)
public class MyRoutingConfiguration {
private static final RequestPredicate ACCEPT_JSON = accept(MediaType.APPLICATION_JSON);
@Bean
public RouterFunction<ServerResponse> monoRouterFunction(MyUserHandler userHandler) {
return route()
.GET("/{user}", ACCEPT_JSON, userHandler::getUser)
.GET("/{user}/customers", ACCEPT_JSON, userHandler::getUserCustomers)
.DELETE("/{user}", ACCEPT_JSON, userHandler::deleteUser)
.build();
}
}import reactor.core.publisher.Mono;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
@Component
public class MyUserHandler {
public Mono<ServerResponse> getUser(ServerRequest request) {
...
}
public Mono<ServerResponse> getUserCustomers(ServerRequest request) {
...
}
public Mono<ServerResponse> deleteUser(ServerRequest request) {
...
}
}WebFlux是Spring框架的一部分,详细信息可在其参考文档中找到。您可以定义任意数量的RouterFunctionbean,以模块化路由器的定义。如果您需要优先应用,可以对bean定义顺序。将spring-boot-starter-webflux模块添加到应用中以开始webflux。在应用程序中添加spring-boot-starter-web和spring-boot-starter-webflux模块会导致Spring Boot自动配置Spring MVC,而不是WebFlux。选择此行为是因为许多Spring开发人员将spring-boot-starter-webflux添加到他们的Spring MVC应用程序中以使用反应式WebClient。您仍然可以通过将所选应用程序类型设置为SpringApplication.setWebApplicationType(WebApplicationType.REACTIVE)强制执行您的选择。Spring WebFlux 自动配置Spring Boot为Spring WebFlux提供了自动配置,适用于大多数应用程序。自动配置在Spring的默认值之上添加了以下功能:为HttpMessageReader和HttpMessageWriter实例配置编解码器(在本文档后面描述)。支持提供静态资源,包括支持WebJars(在本文档后面描述)。如果您想保留Spring Boot WebFlux功能,并想添加额外的WebFlux配置,您可以添加自己的WebFluxConfigurer类型的@Configuration类,但不要添加@EnableWebFlux。如果想要完全控制Spring WebFlux,可以添加自己的@Configuration,并用@EnableWebFlux标注。带有HttpMessageReaders和HttpMessageWriters的HTTP编解码器Spring WebFlux 使用HttpMessageReader和HttpMessageWriter接口来转换HTTP请求和响应。 他们使用 CodecConfigurer 配置了合理的默认值,这样就可以通过查看您的类路径中可用的库来实现。Spring Boot提供专用的编解码器配置属性spring.codec.*,它还通过使用CodecCustomizer实例来进一步自定义。例如,spring.jackson.*配置密钥应用于Jackson编解码器。如果您需要添加或自定义编解码器,您可以创建自定义CodecCustomizer组件,如以下示例所示:import org.springframework.boot.web.codec.CodecCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.codec.ServerSentEventHttpMessageReader;
@Configuration(proxyBeanMethods = false)
public class MyCodecsConfiguration {
@Bean
public CodecCustomizer myCodecCustomizer() {
return (configurer) -> {
configurer.registerDefaults(false);
configurer.customCodecs().register(new ServerSentEventHttpMessageReader());
// ...
};
}
}您还可以利用Boot的自定义JSON序列化器和反序列化器。静态内容默认情况下,Spring Boot从类路径中名为/static(或/public或/resources或/META-INF/resources)的目录提供静态内容。它使用Spring WebFlux中的ResourceWebHandler,以便您可以通过添加自己的WebFluxConfigurer并覆盖addResourceHandlers方法来修改该行为。默认情况下,资源映射在/**上,但您可以通过设置spring.webflux.static-path-pattern属性进行调整。例如,将所有资源迁移到/resources/**可以实现以下操作:spring.webflux.static-path-pattern=/resources/**您还可以使用spring.web.resources.static-locations自定义静态资源位置。这样做会将默认值替换为一个目录位置列表。如果您这样做,默认的欢迎页面检测将切换到您的自定义位置。因此,如果启动时您的任何位置都有一个index.html,那就是应用程序的主页。除了前面列出的“标准”静态资源位置外,Webjars内容也有一个特殊情况。任何在/webjars/**具有路径的资源,如果以Webjars格式打包,则从jar文件提供。Spring WebFlux应用程序并不严格依赖于servlet API,因此它们不能作为war文件部署,并且不使用src/main/webapp目录。欢迎页Spring Boot支持静态和模板欢迎页面。它首先在配置的静态内容位置中查找index.html文件。如果找不到,它会查找index模板。如果找到任何一个,它会自动用作应用程序的欢迎页面。模板引擎除了REST Web服务外,还可以使用Spring WebFlux提供动态HTML内容。Spring WebFlux支持各种模板技术,包括Thymeleaf、FreeMarker和Mustache。Spring Boot包括对以下模板引擎的自动配置支持:FreeMarkerThymeleafMustache当您使用这些模板引擎之一进行默认配置时,您的模板会自动从src/main/resources/templates挑选出来。错误处理Spring Boot提供了一个WebExceptionHandler,以合理的方式处理所有错误。它在处理顺序中的位置紧接在WebFlux提供的处理程序之前,这些处理程序被认为是最后的。对于机器客户端,它会产生一个JSON响应,其中包含错误、HTTP状态和异常消息的详细信息。对于浏览器客户端,有一个“白页”错误处理程序,以HTML格式呈现相同的数据。您还可以提供自己的HTML模板来显示错误(请参阅下一节)。自定义此功能的第一步通常涉及使用现有机制,但替换或增强错误内容。为此,您可以添加ErrorAttributes类型的bean。要更改错误处理行为,您可以实现ErrorWebExceptionHandler并注册该类型的bean定义。由于ErrorWebExceptionHandler级别很低,Spring Boot还提供了一个方便的AbstractErrorWebExceptionHandler,让您以WebFlux功能方式处理错误,如以下示例所示:import reactor.core.publisher.Mono;
import org.springframework.boot.autoconfigure.web.WebProperties.Resources;
import org.springframework.boot.autoconfigure.web.reactive.error.AbstractErrorWebExceptionHandler;
import org.springframework.boot.web.reactive.error.ErrorAttributes;
import org.springframework.context.ApplicationContext;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.reactive.function.server.ServerResponse.BodyBuilder;
@Component
public class MyErrorWebExceptionHandler extends AbstractErrorWebExceptionHandler {
public MyErrorWebExceptionHandler(ErrorAttributes errorAttributes, Resources resources,
ApplicationContext applicationContext) {
super(errorAttributes, resources, applicationContext);
}
@Override
protected RouterFunction<ServerResponse> getRoutingFunction(ErrorAttributes errorAttributes) {
return RouterFunctions.route(this::acceptsXml, this::handleErrorAsXml);
}
private boolean acceptsXml(ServerRequest request) {
return request.headers().accept().contains(MediaType.APPLICATION_XML);
}
public Mono<ServerResponse> handleErrorAsXml(ServerRequest request) {
BodyBuilder builder = ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR);
// ... additional builder calls
return builder.build();
}
}6.2.2 嵌入式响应服务支持Spring Boot包括对以下嵌入式反应式网络服务器的支持:Reactor Netty、Tomcat、Jetty和Undertow。大多数开发人员使用适当的“Starter”来获取完整配置的实例。默认情况下,嵌入式服务器监听端口8080上的HTTP请求。6.2.3 响应式服务资源配置当自动配置Reactor Netty或Jetty服务器时,Spring Boot将创建特定的bean,向服务器实例提供HTTP资源:ReactorResourceFactory或JettyResourceFactory。默认情况下,这些资源也将与Reactor Netty和Jetty客户端共享,以获得最佳性能,给定:相同的技术用于服务器和客户端客户端实例是使用Spring Boot自动配置的WebClient.Builder bean构建的开发人员可以通过提供自定义ReactorResourceFactory或JettyResourceFactory bean来覆盖Jetty和ReactorNetty的资源配置,应用于客户端和服务器。您可以在WebClient Runtime部分了解有关客户端资源配置的更多信息。6.3 优雅关机所有四个嵌入式Web服务器(Jetty、Reactor Netty、Tomcat和Undertow)以及反应式和基于servlet的Web应用程序都支持优雅关机。它作为关闭应用程序上下文的一部分发生,并在停止SmartLifecycle的最早阶段执行。此停止处理使用超时,该超时提供了一个宽限期,在此期间,现有请求将被允许完成,但不允许新的请求。不允许新请求的确切方式因正在使用的网络服务器而异。Jetty、Reactor Netty和Tomcat将停止在网络层接受请求。Undertow将接受请求,但立即响应服务不可用(503)响应。Tomcat的优雅关机需要Tomcat 9.0.33或更高版本。要启用优雅关机,请配置server.shutdown属性,如以下示例所示:server.shutdown=graceful要配置超时期,请配置spring.lifecycle.timeout-per-shutdown-phase属性,如以下示例所示:spring.lifecycle.timeout-per-shutdown-phase=20s如果IDE没有发送正确的SIGTERM信号,那么在IDE中使用优雅的关机可能无法正常工作。有关更多详细信息,请参阅您的IDE文档。6.4 Spring Security如果Spring Security在类路径上,那么Web应用程序默认情况下是安全的。Spring Boot依靠Spring Security的内容协商策略来决定是使用httpBasic还是formLogin。要向Web应用程序添加方法级安全性,您还可以使用所需的设置添加@EnableGlobalMethodSecurity。更多信息可以在Spring Security参考指南中找到。默认的UserDetailsService只有一个用户。用户名是user,密码是随机的,在应用程序启动时以WARN级别打印,如以下示例所示:Using generated security password: 78fa095d-3f4c-48b1-ad50-e24c31d5cf35
This generated password is for development use only. Your security configuration must be updated before running your application in production.如果您微调日志配置,请确保org.springframework.boot.autoconfigure.security类别设置为日志WARN级别的消息。否则,默认密码不会打印。可以使用spring.security.user.name和spring.security.user.password修改用户名和密码。默认情况下,您在Web应用程序中获得的基本功能是:具有内存存储的UserDetailsService(或ReactiveUserDetailsService,如果是WebFlux应用程序)bean和自动生成密码的单个用户(有关用户的属性,请参阅SecurityProperties.User)。整个应用程序(如果actuator在类路径上,则包括actuator端点)的基于表单的登录或HTTP基本安全性(取决于请求中的Accept标头)。用于发布身份验证事件的DefaultAuthenticationEventPublisher。您可以通过为其添加bean来提供不同的AuthenticationEventPublisher。MVC 安全默认安全配置在SecurityAutoConfiguration和UserDetailsServiceAutoConfiguration中实现。SecurityAutoConfiguration会导入用于web安全的SpringBootWebSecurityConfiguration和UserDetailsServiceAutoConfiguration用于配置身份验证,这也适用于非web应用程序。要完全关闭默认的Web应用程序安全配置或合并多个Spring Security组件,如OAuth2客户端和资源服务器,请添加SecurityFilterChain类型的bean(这样做不会禁用UserDetailsService配置或执行器的安全性)。要关闭UserDetailsService配置,您可以添加UserDetailsService、AuthenticationProvider或AuthenticationManager类型的bean。可以通过添加自定义SecurityFilterChain或WebSecurityConfigurerAdapter来覆盖访问规则。Spring Boot提供了方便的方法,可用于覆盖actuator端点和静态资源的访问规则。EndpointRequest可用于创建基于management.endpoints.web.base-path属性的RequestMatcher。PathRequest可用于为常用位置的资源创建RequestMatcher。WebFlux 安全与Spring MVC应用程序类似,您可以通过添加spring-boot-starter-security依赖项来保护WebFlux应用程序。默认安全配置在ReactiveSecurityAutoConfiguration和UserDetailsServiceAutoConfiguration中实现。ReactiveSecurityAutoConfiguration导入WebFluxSecurityConfiguration用于Web安全,UserDetailsServiceAutoConfiguration配置身份验证,这也与非Web应用程序相关。要完全关闭默认的Web应用程序安全配置,您可以添加WebFilterChainProxy类型的bean(这样做不会禁用UserDetailsService配置或执行器的安全性)。要关闭UserDetailsService配置,您可以添加ReactiveUserDetailsService或ReactiveAuthenticationManager类型的bean。可以通过添加自定义SecurityFilterChain或WebSecurityConfigurerAdapter bean来覆盖访问规则。Spring Boot提供了方便的方法,可用于覆盖执行器端点和静态资源的访问规则。EndpointRequest可用于创建基于management.endpoints.web.base-path属性的RequestMatcher。PathRequest可用于为常用位置的资源创建RequestMatcher。例如,您可以通过添加以下内容来自定义安全配置:import org.springframework.boot.autoconfigure.security.reactive.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration(proxyBeanMethods = false)
public class MyWebFluxSecurityConfiguration {
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http.authorizeExchange((exchange) -> {
exchange.matchers(PathRequest.toStaticResources().atCommonLocations()).permitAll();
exchange.pathMatchers("/foo", "/bar").authenticated();
});
http.formLogin(withDefaults());
return http.build();
}
}OAuth2OAuth2是一个广泛使用的授权框架Client如果您的类路径上有spring-security-oauth2-client,您可以利用一些自动配置来设置OAuth2/Open ID Connect客户端。此配置使用OAuth2ClientProperties下的属性。相同的属性适用于servlet和reactive应用程序。您可以在spring.security.oauth2.client前缀下注册多个OAuth2客户端和提供商,如以下示例所示:spring.security.oauth2.client.registration.my-client-1.client-id=abcd
spring.security.oauth2.client.registration.my-client-1.client-secret=password
spring.security.oauth2.client.registration.my-client-1.client-name=Client for user scope
spring.security.oauth2.client.registration.my-client-1.provider=my-oauth-provider
spring.security.oauth2.client.registration.my-client-1.scope=user
spring.security.oauth2.client.registration.my-client-1.redirect-uri=https://my-redirect-uri.com
spring.security.oauth2.client.registration.my-client-1.client-authentication-method=basic
spring.security.oauth2.client.registration.my-client-1.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.my-client-2.client-id=abcd
spring.security.oauth2.client.registration.my-client-2.client-secret=password
spring.security.oauth2.client.registration.my-client-2.client-name=Client for email scope
spring.security.oauth2.client.registration.my-client-2.provider=my-oauth-provider
spring.security.oauth2.client.registration.my-client-2.scope=email
spring.security.oauth2.client.registration.my-client-2.redirect-uri=https://my-redirect-uri.com
spring.security.oauth2.client.registration.my-client-2.client-authentication-method=basic
spring.security.oauth2.client.registration.my-client-2.authorization-grant-type=authorization_code
spring.security.oauth2.client.provider.my-oauth-provider.authorization-uri=https://my-auth-server/oauth/authorize
spring.security.oauth2.client.provider.my-oauth-provider.token-uri=https://my-auth-server/oauth/token
spring.security.oauth2.client.provider.my-oauth-provider.user-info-uri=https://my-auth-server/userinfo
spring.security.oauth2.client.provider.my-oauth-provider.user-info-authentication-method=header
spring.security.oauth2.client.provider.my-oauth-provider.jwk-set-uri=https://my-auth-server/token_keys
spring.security.oauth2.client.provider.my-oauth-provider.user-name-attribute=name
对于支持OpenID Connect discovery的OpenID Connect提供商,可以进一步简化配置。提供商需要配置issuer-uri,这是它声称作为其发行人标识符的URI。例如,如果提供的issuer-uri是“https://example.com”,则将向“https://example.com/.well-known/openid-configuration”发出OpenID Provider Configuration Request结果预计将是OpenID Provider Configuration Response。以下示例展示了如何使用issuer-uri配置OpenID Connect提供程序:spring.security.oauth2.client.provider.oidc-provider.issuer-uri=https://dev-123456.oktapreview.com/oauth2/default/默认情况下,Spring Security的OAuth2LoginAuthenticationFilter仅处理与/login/oauth2/code/*匹配的URL。如果您想自定义redirect-uri以使用不同的模式,则需要提供配置来处理该自定义模式。例如,对于servlet应用程序,您可以添加类似于以下内容的SecurityFilterChain:import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration(proxyBeanMethods = false)
public class MyOAuthClientConfiguration {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeRequests((requests) -> requests.anyRequest().authenticated());
http.oauth2Login((login) -> login.redirectionEndpoint().baseUri("custom-callback"));
return http.build();
}
}Spring Boot自动配置InMemoryOAuth2AuthorizedClientService,Spring Security用于管理客户端注册。InMemoryOAuth2AuthorizedClientService的功能有限,我们建议仅将其用于开发环境。对于生产环境,请考虑使用``JdbcOAuth2AuthorizedClientService或创建您自己的OAuth2AuthorizedClientService`实现。通用 OAuth2 Client Registration对于常见的OAuth2和OpenID提供商,包括Google、Github、Facebook和Okta,我们提供一组提供商默认值(分别为google、github、facebook和okta)。如果您不需要自定义这些提供程序,您可以将provider属性设置为需要推断默认值的提供程序。此外,如果客户端注册的密钥与默认支持的提供程序匹配,Spring Boot也会推断这一点。以下示例中的两种配置都使用谷歌提供商:spring.security.oauth2.client.registration.my-client.client-id=abcd
spring.security.oauth2.client.registration.my-client.client-secret=password
spring.security.oauth2.client.registration.my-client.provider=google
spring.security.oauth2.client.registration.google.client-id=abcd
spring.security.oauth2.client.registration.google.client-secret=passwordResource Server如果您的类路径上有spring-security-oauth2-resource-server,Spring Boot可以设置OAuth2资源服务器。对于JWT配置,需要指定JWK Set URI或OIDC Issuer URI,如以下示例所示:spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://example.com/oauth2/default/v1/keysspring.security.oauth2.resourceserver.jwt.issuer-uri=https://dev-123456.oktapreview.com/oauth2/default/如果授权服务器不支持JWK Set URI,您可以使用用于验证JWT签名的公钥配置资源服务器。这可以使用spring.security.oauth2.resourceserver.jwt.public-key-location属性完成,其中值需要指向包含PEM编码的x509格式公钥的文件。属性同样适用于servlet和反应式应用程序。或者,您可以为servlet应用程序定义自己的JwtDecoder bean,或者为反应式应用程序定义ReactiveJwtDecode。在使用不透明令牌而不是JWT的情况下,您可以配置以下属性通过introspection来验证令牌:spring.security.oauth2.resourceserver.opaquetoken.introspection-uri=https://example.com/check-token
spring.security.oauth2.resourceserver.opaquetoken.client-id=my-client-id
spring.security.oauth2.resourceserver.opaquetoken.client-secret=my-client-secret同样,属性适用于servlet和反应式应用程序。或者,您可以为servlet应用程序定义自己的OpaqueTokenIntrospector bean,或为反应式应用程序定义ReactiveOpaquetokenIntrosector。Authorization Server目前,Spring Security不实现OAuth 2.0授权服务器。然而,此功能可从Spring Security OAuth项目获得,该项目最终将被Spring Security完全接纳。在此之前,您可以使用spring-security-oauth2-autoconfigure模块设置OAuth 2.0授权服务器;有关说明,请参阅其文档。SAML 2.0依赖方如果您的类路径上有spring-security-saml2-service-provider,您可以利用一些自动配置来设置SAML 2.0依赖方。此配置使用Saml2RelyingPartyProperties下的属性。依赖方注册代表身份提供商IDP和服务提供商SP之间的配对配置。您可以在spring.security.saml2.relyingparty前缀下注册多个依赖方,如以下示例所示:spring.security.saml2.relyingparty.registration.my-relying-party1.signing.credentials[0].private-key-location=path-to-private-key
spring.security.saml2.relyingparty.registration.my-relying-party1.signing.credentials[0].certificate-location=path-to-certificate
spring.security.saml2.relyingparty.registration.my-relying-party1.decryption.credentials[0].private-key-location=path-to-private-key
spring.security.saml2.relyingparty.registration.my-relying-party1.decryption.credentials[0].certificate-location=path-to-certificate
spring.security.saml2.relyingparty.registration.my-relying-party1.singlelogout.url=https://myapp/logout/saml2/slo
spring.security.saml2.relyingparty.registration.my-relying-party1.singlelogout.response-url=https://remoteidp2.slo.url
spring.security.saml2.relyingparty.registration.my-relying-party1.singlelogout.binding=POST
spring.security.saml2.relyingparty.registration.my-relying-party1.assertingparty.verification.credentials[0].certificate-location=path-to-verification-cert
spring.security.saml2.relyingparty.registration.my-relying-party1.assertingparty.entity-id=remote-idp-entity-id1
spring.security.saml2.relyingparty.registration.my-relying-party1.assertingparty.sso-url=https://remoteidp1.sso.url
spring.security.saml2.relyingparty.registration.my-relying-party2.signing.credentials[0].private-key-location=path-to-private-key
spring.security.saml2.relyingparty.registration.my-relying-party2.signing.credentials[0].certificate-location=path-to-certificate
spring.security.saml2.relyingparty.registration.my-relying-party2.decryption.credentials[0].private-key-location=path-to-private-key
spring.security.saml2.relyingparty.registration.my-relying-party2.decryption.credentials[0].certificate-location=path-to-certificate
spring.security.saml2.relyingparty.registration.my-relying-party2.assertingparty.verification.credentials[0].certificate-location=path-to-other-verification-cert
spring.security.saml2.relyingparty.registration.my-relying-party2.assertingparty.entity-id=remote-idp-entity-id2
spring.security.saml2.relyingparty.registration.my-relying-party2.assertingparty.sso-url=https://remoteidp2.sso.url
spring.security.saml2.relyingparty.registration.my-relying-party2.assertingparty.singlelogout.url=https://remoteidp2.slo.url
spring.security.saml2.relyingparty.registration.my-relying-party2.assertingparty.singlelogout.reponse-url=https://myapp/logout/saml2/slo
spring.security.saml2.relyingparty.registration.my-relying-party2.assertingparty.singlelogout.binding=POST对于SAML2注销,默认情况下,Spring Security的Saml2LogoutRequestFilter和Saml2LogoutResponseFilter仅处理与/logout/saml2/slo匹配的URL。如果您想自定义AP发起的注销请求发送到的url或AP发送注销响应的response-url,要使用不同的模式,您需要提供配置来处理该自定义模式。例如,对于servlet应用程序,您可以添加类似于以下内容的SecurityFilterChain:import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration(proxyBeanMethods = false)
public class MySamlRelyingPartyConfiguration {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated();
http.saml2Login();
http.saml2Logout((saml2) -> saml2.logoutRequest((request) -> request.logoutUrl("/SLOService.saml2"))
.logoutResponse((response) -> response.logoutUrl("/SLOService.saml2")));
return http.build();
}
}6.5 Spring SessionSpring Boot为各种数据存储提供Spring Session自动配置。在构建servlet Web应用程序时,可以自动配置以下存储:JDBCRedisHazelcastMongoDB此外,Spring Boot Apache Geode 为Apache Geode 作为会话存储提供了自动配置。servlet自动配置取代了使用@Enable*HttpSession的需求。在构建反应式Web应用程序时,可以自动配置以下存储:RedisMongoDB反应式自动配置取代了使用@Enable*WebSession的需求。如果类路径上存在单个Spring Session模块,Spring Boot会自动使用该存储实现。如果您有多个实现,则必须选择要用于存储会话的StoreType。例如,要使用JDBC作为后端存储,您可以按以下方式配置应用程序:spring.session.store-type=jdbc可以将store-type设置为none来禁用Spring Session每个存储都有特定的附加设置。例如,可以自定义JDBC存储的表名,如以下示例所示:spring.session.jdbc.table-name=SESSIONS要设置会话的超时,您可以使用spring.session.timeout属性。如果该属性没有在servlet Web应用程序中设置,则自动配置回退到server.servlet.session.timeout的值。可以使用@Enable*HttpSession(servlet)或@Enable*WebSession(反应式)来控制Spring Session的配置,这将导致自动配置后退,然后可以使用注解的属性而不是之前描述的配置属性来配置Spring Session。6.6 Spring for GraphQL略6.7 Spring HATEOAS略
cathoy
Spring Boot详解
springboot是spring家族中的一个全新框架,本文将从框架整体的启动、系统初始化器等知识点,进行深入解析。
cathoy
JVM | 垃圾回收器(GC)- Java内存管理的守护者
引言在编程世界中,有效的内存管理是至关重要的。这不仅确保了应用程序的稳定运行,还可以大大提高性能和响应速度。作为世界上最受欢迎的编程语言之一,通过Java虚拟机内部的垃圾回收器组件来自动管理内存,是成为之一的其中一项必不可少的技术点。为何需要垃圾回收在许多传统的编程语言中,如C和C++,开发者需要手动管理内存。这意味着他们负责分配内存给新的对象,并在这些对象不再需要时释放这些内存。这种手动管理内存的过程非常容易出错,往往会导致内存泄漏或访问无效的内存地址,进而导致应用程序崩溃。 与此相反,Java选择了一种不同的方法。在Java中,内存管理是自动的,通过垃圾回收器实现。当对象不再被使用时,GC将自动识别并释放它们占用的内存,这样程序员就不必担心内存泄漏或无效内存访问。垃圾回收器在执行引擎中的角色在上一篇文章中,我们介绍了垃圾回收器,它是Java虚拟机执行引擎的核心组件之一。执行引擎负责执行Java字节码,并涵盖了包括字节码解释器、JIT编译器等在内的多个组件。垃圾回收器则专注于自动管理内存,确保及时回收不再使用的对象,防止内存泄漏,并提高内存使用效率。这种内存管理对于保障Java程序的稳定和高效运行至关重要。内存管理的基本原理内存管理是任何计算机程序运行的基础。无论是一个简单的脚本还是一个复杂的分布式系统,内存管理都是核心组件。在Java中,内存管理变得相对简单,主要得益于其自动化的垃圾回收系统。但是,要完全利用它的优势并避免常见的陷阱,我们首先需要理解一些基本的原理。手动 vs. 自动内存管理手动内存管理:在一些语言中,如C和C++,程序员需要显式地分配和释放内存。虽然这为专家提供了更大的灵活性,但也容易引发错误,如内存泄漏或双重释放。自动内存管理:Java选择了自动管理内存的路径,这意味着JVM会自动为新的对象分配内存,并在它们不再被引用时释放内存。这大大降低了内存泄漏和其他相关错误的风险。垃圾回收的角色与重要性垃圾回收是Java内存管理的核心机制。其主要任务是识别不再被引用的对象,并安全地回收它们的内存。此外,它还可以帮助压缩内存,将活动对象移动到连续的内存块中,从而提高内存访问速度。 简而言之,垃圾回收的目的是确保Java应用程序能够在有限的内存中有效、稳定地运行,而不用担心内存溢出或泄漏。 在接下来的章节中,我们将深入探讨垃圾回收器是如何确定哪些对象可以被安全地回收的,以及它是如何利用不同的策略来最大化性能的。垃圾回收的基本工作流程了解Java内存管理的基本原理后,我们接下来将详细探讨垃圾回收的工作流程。对象的生命周期Java中的每个对象都经历了创建、使用和最终被回收的过程。从对象实例化开始,它可能被程序的多个部分引用,直到最后一个引用消失,对象成为垃圾,等待回收。分代回收思想分代回收思想是现代Java垃圾回收器中的核心理念,它基于这样一个观察:大多数对象很快就变得不可访问,而少数对象则可能存活很长时间。因此,将堆内存分为几个不同的区域(或“代”)可以使垃圾回收更为高效。1. 代的分类年轻代(Young Generation):新创建的对象首先被分配到这里。年轻代被进一步划分为:老年代(Old Generation):长时间存活的对象最终会被移动到这里。永久代(Permanent Generation)或Metaspace:用于存储JVM的元数据、类静态变量、方法区等。从Java 8开始,永久代被Metaspace替代,并不存在于堆空间中。2. 为什么使用分代回收?通过将对象基于其生命周期的预期长短分类,可以针对每个代使用最适合的GC策略:在年轻代,对象的存活率相对较低,因此采用如标记-复制算法会更为高效。老年代中的对象已经证明了自己的存活能力,所以此处的GC会比年轻代更加稀少,可以使用如标记-清除-整理算法进行处理。3. 分代回收的优势效率:由于每次不需要整堆收集,而只是针对某一代,所以可以大大提高GC的速度。减少碎片化:特定的垃圾回收策略,如在老年代使用的标记-整理,可以确保内存使用得更为紧凑。适应性:可以根据应用的运行时行为动态地调整GC策略,例如,如果年轻代中的对象存活率增加,可以调整其大小或更改回收策略。 总之,分代回收思想是现代JVM优化垃圾回收性能的关键。这种方法结合了多种垃圾回收策略,以实现在不同场景下的最优性能。分代回收机制演示我们基于上面年轻代的划分,画一张图: 我们知道,Eden存放的都是朝生夕死的对象,假如这个时候只有对象6存活,在一次GC后,它会被移动到From区中。你看: 紧接着对象不断的创建,被存放于Eden区:接着触发了一次GC,存活对象被存放于To区: 存活对象每存活被挪动的过程中,引用计数的值都会+1,当值到达15时,将会被晋升到老年代中。如何确定对象已“死亡”主要的判断依据是对象的可达性,也就是我们常说的GC Root。JVM从根对象(静态变量、线程栈中的本地变量等)开始,通过引用链判断哪些对象是可达的。不可达的对象被视为“死亡”并成为垃圾回收的候选对象。JVM中用了以下两种算法来判断对象是否存活:0. 引用计数法引用计数法就是在对象被引用时,计数加1;引用断开时,计数减1。那么一个对象的引用计数为0时,说明这个对象可以被清除。这个算法的问题在于,如果A对象引用B的同时,B对象也引用A,即循环引用,那么虽然双方的引用计数都不为0,但如果仅仅被对方引用实际上没有存在的价值,应该被GC掉。如图所示: 1. 可达性分析算法它的核心思想是通过一系列的“根对象”作为起始点,来确定哪些对象是“可达”的,即应用仍可能使用的对象,与此相反,那些不可达的对象则可以被视为垃圾,可以被回收。 可达性算法通过引用计数法的缺陷可以看出,从被引用一方去判定其是否应该被清理过于片面,所以可以通过相反的方向去定位对象的存活价值:一个存活对象引用的所有对象都是不应该被清除的(Java中软引用或弱引用在GC时有不同判定表现,不在此深究)。这些查找起点被称为GC Root。2. 三色标记法顾名思义,它是用三种颜色来记录对象的标记状态;黑色:已标记灰色:标记中白色:暂未标记 为什么有这三种颜色呢?我们来看一张图: 触发GC后,从根对象出发,沿途找到引用。最终引用路径下全部被染色即为完成标记。 白色部分即为被回收部分。哪些对象可以作为查找起点GC Root呢?JAVA虚拟机栈中的本地变量引用对象方法区中静态变量引用的对象方法区中常量引用的对象本地方法栈中JNI引用的对象垃圾回收的基本算法为了有效地回收不再使用的对象,垃圾回收器需要一套系统性的方法来确定哪些对象是“活的”以及哪些是“死的”。下面我们将探讨三种主要的垃圾回收算法:标记-清除、标记-整理和标记-复制。1. 标记-清除 (Mark-Sweep)这是最早期的垃圾回收算法。如图所示: 标记:在此阶段,从GC Root开始,所有可访问的对象都被标记为“活的”。清除:一旦所有活动对象都被标记,那么未标记的对象就被视为“死的”并被清除。优点:简单、直观。 缺点:可能会导致大量的内存碎片。2. 标记-整理 (Mark-Compact)此算法是对标记-清除的改进。如图所示: 标记:与标记-清除相同,所有可访问的对象都被标记为“活的”。整理:不是简单地清除死亡的对象,而是将所有活动的对象移向堆的一端。这样,堆的另一端就完全由连续的空闲内存组成,从而消除了碎片化的问题。优点:避免了内存碎片化。 缺点:移动对象可能会增加额外的开销。3. 标记-复制 (Mark-Copy)这是针对年轻代(Young Generation)的垃圾回收非常有效的算法。如图所示: 标记:与之前的算法相似,所有可访问的对象都被标记为“活的”。复制:不是清除死亡的对象,活动对象会被复制到堆的另一部分。这通常在年轻代的两个半区之间完成,一个用于当前分配,另一个用于垃圾回收。优点:简单且高效,尤其适合于对象存活率低的场景。 缺点:需要双倍的内存空间,可能会浪费一半的空间。主流垃圾回收器介绍为了满足不同应用场景的需求,JVM提供了多种垃圾回收器。每种回收器都有其特点和使用场景。接下来,我们将深入了解几种主流的垃圾回收器。Serial GC概述:Serial GC是单线程的垃圾回收器(垃圾回收线程工作时,停止用户线程),适用于单线程应用程序和小型应用。工作原理:它使用标记-复制算法(年轻代)和标记-清除算法(老年代)。特点:由于它是单线程的,所以回收过程会暂停所有用户线程,这种现象通常被称为"Stop-The-World"(STW)。Parallel GC概述:它是多线程的垃圾回收器(相比Serial GC只是垃圾回收线程变多而已),适用于吞吐量比较高的场景,一些计算场景并不在意停顿时间的长短。工作原理:与Serial GC类似,但是Parallel GC在年轻代和老年代都使用多线程。特点:虽然还存在STW现象,但由于多线程的使用,垃圾回收的时间通常更短。CMS (Concurrent Mark-Sweep) GC概述:适用于希望减少暂停时间的应用。(用户和垃圾回收线程可以同时工作,当然还需要少量的STW用于清除浮动垃圾)工作原理:顾名思义,并发标记清除,主要使用标记-清除算法。它的标记和清除阶段的大部分工作都是与应用线程并发执行的。特点:虽然并发执行可以减少暂停时间,但由于并没有整理过程,会导致内存碎片化。G1 GC概述:适用于大型的堆和能更可预测的暂停时间的应用。从JDK9开始,它作为默认的垃圾回收器。工作原理:它将堆分为多个区域(Reigon)并并发地标记、复制和清除这些区域。特点:G1旨在限制垃圾回收的暂停时间,并提供高吞吐量。ZGC (Z Garbage Collector)概述:是一个可扩展的低延迟垃圾回收器。工作原理:ZGC使用了读屏障(Read Barrier)和并发压缩技术。特点:ZGC的目标是在任何堆大小下都能实现不到10毫秒的暂停时间,同时还能提供与其他垃圾回收器相似的吞吐量。垃圾回收器的选择与配置选择合适的垃圾回收器是Java应用性能调优的关键环节之一。不同的垃圾回收器适合不同的场景,因此,了解每种垃圾回收器的特性和适用场景是非常重要的。此外,合适的JVM参数配置也是关键,它可以显著地影响应用的性能和稳定性。如何选择垃圾回收器响应时间要求:如果应用对延迟非常敏感,那么选择如ZGC或CMS这样的暂停时间短的垃圾回收器会更合适。吞吐量要求:高吞吐量的应用,如批处理作业或某些后端任务,可能更适合使用Parallel GC或G1 GC。内存资源:如果内存资源有限,Serial GC可能是一个好选择。停顿时间与响应时间大多数垃圾回收器在执行垃圾收集时需要暂停应用线程。这些停顿可能会影响应用的响应时间,特别是在对延迟敏感的应用中。例如,实时交易系统、高频交易平台等。内存碎片化随着时间的推移,对象的创建和销毁可能导致内存碎片化。碎片化可能会影响性能,因为垃圾回收器需要更多的时间来找到连续的内存块。某些垃圾回收算法,如复制或整理,被设计出来用于减少碎片化。常见的JVM参数与配置指定垃圾回收器:使用-XX:+UseSerialGC、-XX:+UseParallelGC、-XX:+UseConcMarkSweepGC、-XX:+UseG1GC或-XX:+UseZGC来选择特定的垃圾回收器。2.堆大小:使用-Xms和-Xmx来设置堆的初始大小和最大大小。3.新生代大小:使用-Xmn来设置新生代的大小。4.详细的GC日志:-Xlog:gc*可以启用详细的GC日志,这对于性能分析和问题诊断非常有用。5.一些其他的优化参数:如-XX:SurvivorRatio、-XX:PermSize和-XX:MaxPermSize等。正确配置垃圾回收器和相关参数需要一定的经验和多次的试验。应始终在生产环境上运行之前,在模拟的环境中进行充分的测试和调优。我列举的参数也仅仅是冰山一角,更多参数建议大家查阅相关文档。限于篇幅,我会在后续文章中详细为你解析。实际应用与案例分析垃圾回收的理论和实际应用之间有时存在差距。为了提供更深入的理解,我们将讨论一些实际的应用案例,并分享从中得到的经验。如何监控垃圾回收行为有效地监控垃圾回收行为对于确保应用的性能和稳定性至关重要。Java提供了几种机制来实现这一点:GC日志: JVM可以配置为输出GC日志,这些日志详细记录了垃圾回收的过程和结果。通过分析这些日志,开发者可以获取关于内存使用情况、垃圾收集的频率和持续时间等重要信息。监控工具: 工具如JVisualVM和JConsole不仅可以实时显示JVM的性能指标,还提供了丰富的图形界面,帮助开发者直观地了解垃圾回收的行为。诊断与解决常见的内存管理问题尽管JVM提供了自动垃圾回收,但应用仍然可能遭受内存泄漏、过度分配或其他内存管理问题。诊断这些问题通常涉及以下步骤:分析堆转储: 当应用使用过多的内存或出现内存泄漏时,开发者可以生成并分析堆转储。工具如MAT (Memory Analyzer Tool) 可以帮助识别内存中的大对象、对象引用链以及其他相关信息。分析代码: 使用分析器,如YourKit或JProfiler,可以帮助开发者定位可能导致内存问题的代码部分。实际的应用案例收集中....文中重要部分解析并发漏标问题更新中...总结Java的垃圾回收器在确保应用性能和稳定性方面发挥了至关重要的作用。从手动管理到自动化管理,内存处理在计算机科学的发展过程中已经走过了漫长的道路。今天,通过JVM的自动垃圾回收机制,开发者可以集中精力编写更高效的代码,而不是手动管理内存。 通过我们的讨论,我们了解到了垃圾回收的工作原理、常见的垃圾回收算法、以及如何选择和配置合适的垃圾回收器。我们还探讨了监控、诊断和解决内存管理问题的方法。 但是,仅仅理解理论是不够的。为了确保应用的最佳性能,开发者必须积极监控其行为,定期分析性能数据,并在需要时进行调优。 总的来说,垃圾回收是Java性能优化中的一个重要领域。借助于现代的工具和技术,开发者可以有效地管理应用的内存使用,从而提供更好的用户体验。
cathoy
并发编程 | 锁 - 并发世界的兜底方案
引言在我们的并发编程旅程中,我们必将遇到各种挑战和困难。线程间的同步、数据的一致性以及并发中的竞态条件等问题,都是我们必须要面对并解决的。而解决这些问题的关键,往往就是使用锁。锁是我们在并发世界中的守护者,它能帮助我们在并发世界混乱时,找到一丝秩序,保证代码的正确性和一致性。然而,锁并不是万能的,错误的使用锁可能会引发死锁、饥饿等问题。因此,如何正确地使用锁,避免这些并发问题,就显得尤为重要。在这篇博客中,我们将一起探讨锁在并发编程中的角色,学习如何正确地使用锁,以及如何避免常见的并发问题。接下来,让我们共同探讨锁。入门 | 基本概念1. 锁的定义在并发编程中,锁是一种同步机制,用于在多个线程间实现对共享资源的独占访问。当一个线程需要访问一个被其他线程占用的资源时,这个线程就会被阻塞,直到锁被释放。通过这种方式,锁确保了同一时间只有一个线程可以修改共享资源。2. 锁的作用锁的主要作用是为了保证数据一致性和防止数据竞争。在没有锁的情况下,如果两个线程同时修改同一份数据,可能会导致数据不一致的情况,这被称为数据竞争。通过使用锁,我们可以保证任何时刻只有一个线程修改数据,从而避免了数据竞争。3. Java中的内置锁和显式锁在Java中,我们可以使用synchronized关键字来创建内置锁,也可以使用java.util.concurrent.locks包中的Lock接口和ReentrantLock类来创建显式锁。内置锁:synchronizedJava语言提供了内置的锁机制,这种锁也被称为监视器锁。我们可以通过synchronized关键字来创建和使用内置锁。synchronized可以修饰方法或者代码块。显式锁:Lock和ReentrantLockJava并发库还提供了更强大的锁机制,即显式锁。显式锁是通过代码显式地获取和释放锁。相比于synchronized,显式锁提供了更多的灵活性,比如可以尝试获取锁,如果无法立即获取锁,线程可以决定等待还是放弃。3. 锁的分类锁可以根据多个标准进行分类,如下所示:按所有权分独占锁 / 排他锁 独占锁是指该锁一次只能被一个线程所持有。在Java中,ReentrantLock和synchronized都是独占锁。它保证了每次只有一个线程执行同步代码,它的优点是避免了并发和线程安全问题,缺点是可能会引起线程阻塞。共享锁 共享锁是指该锁可被多个线程所持有。对于Java中的ReentrantReadWriteLock,它的读锁是共享锁,写锁是独占锁。读锁的共享锁可以保证并发读是非常高效的,读写,写读,写写的过程是互斥的。按锁的状态分可重入锁 可重入锁,也叫做递归锁,指的是一个线程已经拥有某个锁,可以无阻塞的再次请求这个锁。也就是说,线程可以进入任何一个它已经拥有的锁所同步着的代码块。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可以减少死锁的发生。非重入锁 非重入锁,指的是锁不可以被一个已经拥有它的线程多次获取。在Java中,synchronized和ReentrantLock都不属于非重入锁。按照操作分公平锁 公平锁是指多个线程按照申请锁的顺序来获取锁,类似排队打饭,先来后到。Java中的ReentrantLock可以通过构造函数指定是否为公平锁,默认是非公平锁。非公平锁 非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁,在高并发的情况下,有可能会造成优先级反转或者饥饿现象。入门 | 如何使用锁使用synchronized关键字synchronized是Java内置的一种原始锁机制。我们可以通过在方法或者代码块上添加synchronized关键字来使用它。synchronized方法public class SynchronizedDemo {
public synchronized void method() {
// 业务逻辑代码
}
}
在这个例子中,我们在方法method上添加了synchronized关键字。这意味着当一个线程进入这个方法时,它将会获取到这个对象的锁,其他任何线程都无法进入这个方法,直到这个线程退出这个方法,释放这个对象的锁。synchronized代码块public class SynchronizedDemo {
private Object lock = new Object();
public void method() {
synchronized (lock) {
// 业务逻辑代码
}
}
}
在这个例子中,我们在代码块上添加了synchronized关键字。这意味着当一个线程进入这个代码块时,它将会获取到lock对象的锁,其他任何线程都无法进入这个代码块,直到这个线程退出这个代码块,释放lock对象的锁。使用ReentrantLock类ReentrantLock是java.util.concurrent包提供的一种锁机制。它提供了与synchronized相同的互斥性和内存可见性,但是添加了类似锁投票、定时锁等候和锁中断等更多功能。import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockDemo {
private final ReentrantLock lock = new ReentrantLock();
public void method() {
lock.lock();
try {
// 业务逻辑代码
} finally {
lock.unlock();
}
}
}
在这个例子中,我们创建了一个ReentrantLock对象lock。当一个线程进入method方法时,它将会调用lock.lock()获取锁,其他任何线程都无法通过lock.lock(),直到这个线程调用lock.unlock()释放锁。进阶 | 锁的工作原理对于Java并发锁的工作原理,我们将以synchronized和ReentrantLock为例进行说明。synchronized的工作原理synchronized是依赖于JVM底层来实现的,其主要的执行原理可以分为三部分:获取锁: 当一个线程请求获取synchronized锁时,JVM首先检查这个锁的状态,即是否被其他线程持有。如果当前没有其他线程持有这个锁,那么请求的线程就会成功获取到这个锁。如果锁已经被其他线程持有,那么请求的线程就会进入阻塞状态,直到锁被释放。锁定: 当一个线程获取到synchronized锁后,它将进入到锁定状态。在锁定状态下,这个线程可以自由地访问同步代码区域,其他任何线程都无法访问。释放锁: 当一个线程完成同步代码区域的执行后,它将释放持有的synchronized锁。此时,如果有其他线程正在等待这个锁,JVM会选择其中一个线程,将锁分配给它,使其从阻塞状态变为运行状态。ReentrantLock的工作原理ReentrantLock的工作原理在很大程度上与synchronized相似,但是它更加灵活,提供了更多的功能。获取锁: 当一个线程调用lock()方法请求获取锁时,ReentrantLock会首先检查锁的状态。如果锁当前未被其他线程持有,那么请求的线程将成功获取到锁。如果锁已经被其他线程持有,那么请求的线程将会被阻塞,直到锁被释放。锁定: 当一个线程获取到锁后,它将进入到锁定状态。在锁定状态下,这个线程可以自由地访问被锁保护的代码,其他任何线程都无法访问。释放锁: 当一个线程完成被锁保护的代码执行后,它需要手动调用unlock()方法来释放锁。此时,如果有其他线程正在等待这个锁,ReentrantLock会选择其中一个线程,将锁分配给它,使其从阻塞状态变为运行状态。重入:无论是synchronized还是ReentrantLock,它们都支持重入,即在持有锁的线程内,可以多次无阻塞地获取同一把锁。入门 | 锁如何解决原子性问题在我们讨论了并发编程的基本概念和锁的基本工作原理后,让我们来深入探讨一下,锁是如何解决并发编程中的关键问题之一 - 原子性问题的。在并发编程中,原子性是指一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。如果一个操作符合这个定义,我们就可以说它是原子操作。但是在实际编程中,很多操作并不能满足原子性,例如count++就不是一个原子操作,因为它实际上包含了三个步骤:读取count的值,对count加一,把新的值写回count。为了解决这个问题,Java提供了锁机制,包括synchronized关键字和Lock接口。锁的基本工作原理是,当一个线程要执行一个锁住的代码块时,它必须先获得锁,如果锁已经被其他线程持有,那么它就会进入等待状态,直到其他线程释放锁。这就保证了在同一时刻,只有一个线程能够执行锁住的代码块,也就实现了原子性。那么,让我们来看一个使用锁实现原子性的例子:public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}
}
在这个例子中,我们使用synchronized关键字来锁住increment方法。这样,就能保证在同一时刻,只有一个线程可以执行increment方法,实现了count++的原子性。对于更复杂的场景,我们也可以使用Lock接口。例如,ReentrantLock就是一个支持重入的锁,它可以在同一个线程内多次获取,这对于复杂的并发操作非常有用。通过以上的解释,我们可以看出锁是如何通过保证同一时间只有一个线程访问特定代码块来解决原子性问题的。但请记住,虽然锁可以解决原子性问题,但并不能保证线程安全,因为它并不能解决可见性和有序性这两个问题。在介绍了锁的基本概念和如何解决原子性问题之后,我们可以开始介绍更高级的并发控制机制,比如管程。以下是可能的内容。入门 | 管程和管程模型我们刚刚讨论了如何使用锁解决原子性问题,现在我们来探讨一种更高级的并发控制机制 - 管程。管程(Monitor)是一种同步机制,比锁提供了更高级的抽象。管程包含了一组预定义的程序和数据结构(比如共享变量和锁)的集合,这些程序只能被一个线程一次执行。这种一次性的特性,就保证了在一个时刻只有一个线程可以访问管程的资源,从而避免了并发冲突。管程模型有两个关键部分,一个是互斥性(Mutual exclusion),这意味着任意时刻只允许一个线程执行管程中的一段代码。另一个是条件同步(Conditional synchronization),这意味着允许一个线程等待某个条件,直到这个条件满足时,线程才被唤醒继续执行。以下是一个简单的管程的Java实现:public class MonitorExample {
private int a = 0;
private boolean condition = false;
public synchronized void method1() {
while (!condition) {
try {
wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
// 管程中的操作
a++;
}
public synchronized void method2() {
// 改变条件并唤醒等待的线程
condition = true;
notifyAll();
}
}
在上述代码中,MonitorExample类实现了一个管程。这个管程包含一个共享变量a和一个条件condition。method1方法是一个同步方法,它等待condition为true。当condition为true时,它会增加a的值。method2方法也是一个同步方法,它改变condition的值并唤醒所有等待的线程。我们可以看到,管程提供了一种有效的方式来处理并发程序中的复杂问题,使得编程变得更简单。然而,管程并不是银弹,我们仍然需要注意其他并发问题,例如死锁。在接下来的部分,我们将介绍死锁及其解决方案。入门 | 了解死锁死锁是指两个或者多个线程在执行过程中,由于竞争资源而造成的一种相互等待的现象,如果没有外力干涉那它们都将无法推进下去。 在Java中,死锁经常出现在多线程中,特别是在多个synchronized块中。一个典型的死锁例子如下:public class DeadlockDemo {
private static Object lock1 = new Object();
private static Object lock2 = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (lock1) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock2) {
System.out.println("Thread 1 over");
}
}
}).start();
new Thread(() -> {
synchronized (lock2) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock1) {
System.out.println("Thread 2 over");
}
}
}).start();
}
}
在这个例子中,两个线程分别持有lock1和lock2,然后各自尝试获取对方持有的锁,由于双方都不放弃自己持有的锁,所以形成了死锁。入门 | 了解锁优化锁优化是指通过一些手段,尽可能地减少锁的使用,提高并发性能。以下是Java中常见的锁优化技术:锁消除: 编译器在运行时,对于那些不可能存在共享数据竞争的锁请求进行消除。如StringBuffer的append操作,虽然是synchronized方法,但在某些情况下,JVM会判断出无需加锁。锁粗化: 编译器在运行时,将多个连续的锁合并为一个更大范围的锁,减少锁请求的次数,如在一个循环内对同一对象加锁。轻量级锁: 在没有竞争的前提下,消耗更少的系统资源。当一个线程尝试获取一个已经被另一个线程获取的轻量级锁时,会进行锁升级,升级为重量级锁。偏向锁: 偏向于第一个获取锁的线程,如果在接下来的运行过程中,该锁没有其他线程竞争,那么持有偏向锁的线程在接下来的同步块中,无需再进行同步。入门 | 乐观锁和悲观锁乐观锁和悲观锁不是具体的锁,而是指并发控制的两种策略。 乐观锁认为自己在使用数据时不会有其他线程修改数据,所以不会添加锁,只在更新数据时进行检查。如果发现数据已经被修改,那么操作会重新进行,直到成功为止。 悲观锁则相反,认为自己在使用数据时总会有其他线程来修改数据,因此在每次读写数据时都会加锁,这样可以确保数据的安全,但是付出的代价是并发性能。实例分析假设我们有一个银行账户类,它有一个余额字段balance,我们需要在多线程环境下保护这个字段的安全性。于是我们使用了synchronized关键字:public class Account {
private double balance;
public synchronized void deposit(double money) {
double newBalance = balance + money;
try {
Thread.sleep(10); // 模拟此业务需要一段处理时间
} catch (InterruptedException e) {
e.printStackTrace();
}
balance = newBalance;
}
public double getBalance() {
return balance;
}
}
在这个例子中,我们在deposit方法上添加了synchronized关键字,保证了balance字段在多线程环境下的安全性。常见面试题描述一下你理解的synchronized关键字。你有使用过ReentrantLock吗?它和synchronized有什么区别?你了解什么是死锁吗?如何避免?你了解锁的优化吗?如偏向锁、轻量级锁等。你能解释一下什么是乐观锁和悲观锁吗?总结锁是并发编程中的一个重要概念,了解和掌握锁的使用和原理,能够帮助我们写出高效并且安全的并发代码。在编程时,要尽可能地减少锁的使用,避免死锁,选择适当的并发控制策略,这样才能保证程序的并发性能。 在掌握了基本的锁知识后,我们还需要了解一些锁的优化技术,如锁消除、锁粗化、轻量级锁和偏向锁等。这些优化技术能够在不降低程序安全性的前提下,提高程序的并发性能。 最后,希望这篇文章能够帮助你对并发编程中的锁有更深入的理解和应用。在日常的编程和面试中,锁都是一个重要的话题,希望你能够通过学习,熟练掌握并使用它。
cathoy
并发编程 | Fork/Join 并行计算框架 - 利用‘分而治之’提升多核CPU效率
引言在并发编程中,我们不仅需要考虑如何合理分配任务以提高程序的执行效率,而且还需要关心如何将分配的任务结果合理汇总起来,以便得到我们最终想要的结果。这就需要我们使用一种特殊的并发设计模式——分而治之。在Java中,这种模式被抽象化为了Fork/Join框架。通过Fork/Join框架,我们能够将大任务分解成小任务并行处理,然后再将小任务的结果合并得到最终结果。这大大提高了任务处理的效率,使得并发编程在处理大量数据时变得更加简单有效。在本文中,我们将深入探讨Fork/Join框架,理解其工作原理,并通过实例学习如何在实际项目中使用它。Fork/Join框架的作用?在CPU密集型任务中,利用现代多核处理器的性能,通过并行的方式来执行任务Fork/Join框架在并发编程中处于什么位置?一个专门用于解决可以被分解并且可以并行执行的任务的工具,它在利用多核处理器,提高程序性能方面起到了关键作用。搞懂这两个问题, 我们接着往下看入门 | 理解Fork/Join框架Fork/Join框架的工作原理Fork/Join框架是为了充分利用多核CPU,通过分治策略将大任务分解为小任务并行执行。它使用"ForkJoinPool",一个专门为Fork/Join任务设计的线程池,里面的每个工作线程都有一个"双端队列"维护任务。当线程执行自身任务时,从队头获取;当窃取其他线程任务时,从队尾获取,以避免任务冲突。这个基于"工作窃取算法"的设计使得CPU资源可以高效利用。所有的任务都是"ForkJoinTask"的子类,任务完成后,结果通过"join"步骤进行递归合并。这样,Fork/Join框架实现了任务的并行处理,提高了执行效率。为了方便你理解,我画了一张图: 分治策略在Fork/Join框架中的体现从名字你也可以看出来:任务分解(Fork)对于一个大的任务,Fork/Join框架通过fork操作将其分解为一系列更小的子任务,这些子任务可以更容易地并行处理。这是分治策略的“分”的部分。分解任务通常是递归进行的,也就是说,一个任务可能被分解为一些子任务,然后这些子任务又可以被进一步分解为更小的子任务,直到任务足够小可以直接处理为止。结果合并(Join)当所有的子任务都被处理完毕后,Fork/Join框架通过join操作将这些子任务的结果合并,得到原任务的结果。这是分治策略的“治”的部分。这个过程通常是递归进行的,也就是说,每个任务在完成自己的工作后,还要等待其所有的子任务完成,并将子任务的结果合并到自己的结果中。Fork/Join框架的核心组件:ForkJoinPool和ForkJoinTask至此,理论部分已经铺垫完了,我们来看下源码中这两个重要的组件:ForkJoinPool ForkJoinPool是Fork/Join框架的核心,它是一个专门为Fork/Join任务设计的线程池。它管理着一组工作线程,每个工作线程都有一个双端队列(Deque)来存储待执行的任务。这些工作线程会尽可能地执行提交到线程池的任务。 在ForkJoinPool的源码中,execute()方法用于提交任务到线程池:public void execute(ForkJoinTask<?> task) {
if (task == null)
throw new NullPointerException();
if (threadLocalRandom == null) {
// 线程外部提交的任务
externalPush(task);
}
}
这段代码表示,如果任务是由线程外部提交的,那么调用externalPush()方法将任务添加到队列;ForkJoinTask ForkJoinTask是所有Fork/Join任务的父类。它有两个主要的子类:RecursiveAction和RecursiveTask,分别表示没有返回值和有返回值的任务。在ForkJoinTask的源码中,fork()方法用于将任务提交到线程池:public final ForkJoinTask<V> fork() {
Thread t;
if ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread)
((ForkJoinWorkerThread)t).workQueue.push(this);
else
ForkJoinPool.common.externalPush(this);
return this;
}
这段代码表示,如果当前线程是一个ForkJoinWorkerThread(即Fork/Join线程池的工作线程),那么直接将任务添加到工作队列;否则,调用ForkJoinPool.common.externalPush()方法将任务添加到公共线程池。进阶 | 深入Fork/Join框架ForkJoinPool详解:工作窃取算法和并行级别工作窃取算法上面已经讲解了工作窃取算法的工作原理以及作用,我在这里就不赘述了,现在让我们从源码的视角来进行分析。工作窃取算法的源码主要体现在Java类库的ForkJoinPool中。我们可以分析一下ForkJoinPool类的runWorker(WorkQueue w)方法,这个方法在每个ForkJoinWorkerThread线程中被调用,用于处理任务和执行窃取: final void runWorker(WorkQueue w) {
w.growArray(); // 为工作队列初始化或扩容
int seed = w.hint; // 随机种子
int r = (seed == 0) ? 1 : seed; // avoid 0 for xorShift - 为了防止在随后的异或移位运算中产生全零的结果
for (ForkJoinTask<?> t;;) {
// 调用scan()方法扫描工作队列和其他线程的工作队列,尝试获取一个任务。如果获取到了任务,执行该任务
if ((t = scan(w, r)) != null)
w.runTask(t);
// 如果没有获取到任务,调用awaitWork()方法使线程进入等待状态,等待新的任务的到来。如果线程应该终止,awaitWork()方法会返回false,从而跳出循环。
else if (!awaitWork(w, r))
break;
// 生成新的随机值
r ^= r << 13; r ^= r >>> 17; r ^= r << 5; // xorshift - 异或移位运算
}
}
在scan()方法中,工作线程尝试窃取其他线程的任务:private ForkJoinTask<?> scan(WorkQueue w, int r) {
WorkQueue[] ws; int m;
if ((ws = workQueues) != null && (m = ws.length - 1) > 0 && w != null) {
int ss = w.scanState; // initially non-negative
for (int origin = r & m, k = origin, oldSum = 0, checkSum = 0;;) {
WorkQueue q; ForkJoinTask<?>[] a; ForkJoinTask<?> t;
int b, n; long c;
if ((q = ws[k]) != null) {
if ((n = (b = q.base) - q.top) < 0 && // 当前工作队列q不为空并且其包含任务,就尝试获取任务
(a = q.array) != null) { // non-empty
long i = (((a.length - 1) & b) << ASHIFT) + ABASE;
// 如果成功获取到了任务
if ((t = ((ForkJoinTask<?>)
// 原子性地获取并移除任务
U.getObjectVolatile(a, i))) != null &&
q.base == b) {
if (ss >= 0) {
if (U.compareAndSwapObject(a, i, t, null)) {
q.base = b + 1;
if (n < -1) // signal others
signalWork(ws, q);
return t;
}
}
else if (oldSum == 0 && // try to activate
w.scanState < 0)
tryRelease(c = ctl, ws[m & (int)c], AC_UNIT);
}
if (ss < 0) // refresh
ss = w.scanState;
r ^= r << 1; r ^= r >>> 3; r ^= r << 10;
origin = k = r & m; // move and rescan
oldSum = checkSum = 0;
continue;
}
checkSum += b;
}
if ((k = (k + 1) & m) == origin) { // continue until stable
if ((ss >= 0 || (ss == (ss = w.scanState))) &&
oldSum == (oldSum = checkSum)) {
if (ss < 0 || w.qlock < 0) // already inactive
break;
int ns = ss | INACTIVE; // try to inactivate
long nc = ((SP_MASK & ns) |
(UC_MASK & ((c = ctl) - AC_UNIT)));
w.stackPred = (int)c; // hold prev stack top
U.putInt(w, QSCANSTATE, ns);
if (U.compareAndSwapLong(this, CTL, c, nc))
ss = ns;
else
w.scanState = ss; // back out
}
checkSum = 0;
}
}
}
return null;
}
工作线程会遍历其他所有工作线程的队列,并尝试从队列尾部窃取任务。如果窃取成功,那么跳出循环并执行窃取到的任务;如果窃取失败(即队列为空),那么进入下一个工作线程的队列并尝试窃取。并行级别 Fork/Join框架的并行级别通常与处理器的核心数相关。在创建ForkJoinPool时,可以指定并行级别。这个并行级别就是线程池的线程数量,它决定了同时可以执行的任务数量。如果不指定并行级别,那么默认的并行级别将等于处理器的核心数。在ForkJoinPool的构造函数中,有一个参数parallelism用于指定并行级别:public ForkJoinPool(int parallelism) {
//...
}
在实际使用中,应根据具体的硬件环境和任务特性来选择合适的并行级别。如果并行级别过高,可能会导致线程之间的竞争过于激烈,反而降低性能;如果并行级别过低,可能无法充分利用多核处理器的性能。一般来说,对于计算密集型的任务,最佳的并行级别应接近于处理器的核心数。ForkJoinTask详解:RecursiveAction和RecursiveTask它们的区别主要在于是否有返回值了,我们接着往下看:RecursiveActionRecursiveAction 表示没有返回值的任务。这种类型的任务通常会执行一些改变状态的操作,以下是一个简单的例子:class MyRecursiveAction extends RecursiveAction {
private final int[] array;
private final int start;
private final int end;
public MyRecursiveAction(int[] array, int start, int end) {
this.array = array;
this.start = start;
this.end = end;
}
@Override
protected void compute() {
if (end - start < THRESHOLD) {
// 直接处理任务
Arrays.sort(array, start, end);
} else {
// 将任务分解,非常舒服的写法
int mid = (start + end) >>> 1;
invokeAll(new MyRecursiveAction(array, start, mid),
new MyRecursiveAction(array, mid, end));
}
}
}
在这个例子中,我们定义了一个排序数组的任务。当数组的长度小于一定阈值时,我们直接对数组进行排序;否则,我们将数组分成两部分,然后创建两个新的任务来分别排序这两部分。RecursiveTaskRecursiveTask 表示有返回值的任务。这种类型的任务通常会执行一些计算操作,以下是一个简单的例子:class MyRecursiveTask extends RecursiveTask<Integer> {
private final int[] array;
private final int start;
private final int end;
public MyRecursiveTask(int[] array, int start, int end) {
this.array = array;
this.start = start;
this.end = end;
}
@Override
protected Integer compute() {
if (end - start < THRESHOLD) {
// 直接处理任务
int sum = 0;
for (int i = start; i < end; i++) {
sum += array[i];
}
return sum;
} else {
// 将任务分解
int mid = (start + end) >>> 1;
MyRecursiveTask leftTask = new MyRecursiveTask(array, start, mid);
MyRecursiveTask rightTask = new MyRecursiveTask(array, mid, end);
leftTask.fork(); // 异步执行左边的任务
Integer rightResult = rightTask.compute(); // 同步执行右边的任务
Integer leftResult = leftTask.join(); // 获取左边任务的结果
return leftResult + rightResult;
}
}
}
在这个例子中,我们定义了一个计算数组总和的任务。当数组的长度小于一定阈值时,我们直接计算数组的总和;否则,我们将数组分成两部分,然后创建两个新的任务来分别计算这两部分的总和。入门 | 如何完整使用Fork/Join框架我们来做一个累加运算,步骤如下如下:创建ForkJoinPoolForkJoinPool pool = new ForkJoinPool();
创建ForkJoinTask 这里我们需要返回计算结果,所以继承RecursiveTask对象public class SumTask extends RecursiveTask<Long> {
private final long[] array;
private final int start;
private final int end;
private static final int THRESHOLD = 10000; //任务分解的阈值
public SumTask(long[] array, int start, int end) {
this.array = array;
this.start = start;
this.end = end;
}
@Override
protected Long compute() {
// 计算核心
// 将大任务分解成小任务
if (end - start <= THRESHOLD) {
// 当前计算任务足够小,则直接计算
long sum = 0;
for (int i = start; i < end; i++) {
sum += array[i];
}
return sum;
} else {
// 当前计算任务较大,则分解计算
int mid = (start + end) / 2;
SumTask task1 = new SumTask(array, start, mid);
SumTask task2 = new SumTask(array, mid, end);
task1.fork();
task2.fork();
return task1.join() + task2.join();
}
}
}
我们来测试一下: public static void main(String[] args) {
long start = System.currentTimeMillis();
long[] array = new long[100000000];
// 模拟从 0+1+2...+49的结果
for (int i = 0; i < array.length; i++) {
array[i] = i;
}
ForkJoinPool pool = new ForkJoinPool();
SumTask task = new SumTask(array, 0, array.length);
Long result = pool.invoke(task);
System.out.println("总数是: " + result);
long end = System.currentTimeMillis();
System.out.println("花费时间为:" + (end - start) + "ms");
}
运行结果如下:Connected to the target VM, address: '127.0.0.1:6144', transport: 'socket'
总数是: 1225
花费时间为:4ms
Disconnected from the target VM, address: '127.0.0.1:6144', transport: 'socket'
Process finished with exit code 0
非常快,只需要4ms就计算完成,当然你可以把数组大小调整到10000依然只需要4msConnected to the target VM, address: '127.0.0.1:6551', transport: 'socket'
总数是: 49995000
花费时间为:4ms
Disconnected from the target VM, address: '127.0.0.1:6551', transport: 'socket'
Process finished with exit code 0
这就是并行计算的魅力!Fork/Join框架的优点和局限性优点充分利用多核处理器:Fork/Join框架通过将任务划分为更小的子任务,允许并行处理,从而最大程度地利用了多核处理器。工作窃取:Fork/Join框架采用工作窃取算法,可以有效地利用线程。当一个线程的任务队列为空时,它会从其他线程的队列中窃取任务来执行。易于使用:Fork/Join框架相对容易使用。你只需要继承RecursiveTask或RecursiveAction,然后实现其compute方法,就可以将任务划分为子任务。局限性不适用于IO密集型任务:由于Fork/Join框架主要设计用于CPU密集型任务,因此在IO密集型任务中使用可能无法获得理想的性能。任务划分开销:大任务被划分为小任务会产生一定的开销。如果任务划分的粒度过细,可能会导致任务划分的开销大于任务执行的开销。调试困难:由于Fork/Join框架的并行性,调试Fork/Join任务可能会比较困难。异常处理:Fork/Join任务中的异常必须在任务内部捕获处理,因为由于任务的并行性,不能在任务外部有效捕获任务内部的异常。其它并发模型如果任务是CPU密集型的,可以并行处理,并且任务划分的开销相对较小,那么Fork/Join框架可能是一个好的选择。除了Fork/Join还有哪些模型?事件循环模型事件循环模型基于事件驱动编程。在这个模型中,有一个循环(即事件循环)不断地监听事件,并将它们派发给相应的处理函数。这种模型适合于I/O密集型应用,因为它可以在等待I/O操作完成时处理其他事件,从而使CPU得到充分利用。 这种模型的优点是可以处理大量并发连接,而且编程模型相对简单。然而,对于CPU密集型的任务,事件循环模型可能不太适用,因为一个耗时的任务可能会阻塞整个事件循环。 在Java世界中,Netty也实现了类似的模型。Actor模型Actor模型是一种并发模型,它把并发单元看作是互不共享状态的实体(称为Actor)。Actor之间通过发送和接收消息进行通信。这种模型可以避免传统多线程编程中的许多并发问题,例如竞态条件、死锁等。 Actor模型的优点是它可以简化并发编程的复杂性,并且能够很好地进行横向扩展。然而,对于一些需要共享状态的场景,使用Actor模型可能会有些麻烦。 Java的Akka框架就实现了Actor模型。基于线程的模型基于线程的模型是最传统的并发模型。在这个模型中,我们创建多个线程来执行不同的任务。线程之间可能会共享内存,因此我们需要使用某种机制(如锁)来协调线程对共享资源的访问。 基于线程的模型的优点是可以直接利用多核处理器。然而,管理线程和协调共享资源的访问可能会非常复杂,容易引发并发问题。 Java的内置并发API(如java.util.concurrent包)提供了许多基于线程的并发工具,如Executor框架、并发集合类等。使用Fork/Join框架的最佳实践和常见问题解答如何选择合适的任务分割策略?利用Fork/Join框架,最关键的部分就是如何将大任务分割成足够小的子任务。这个“足够小”通常需要根据具体的应用场景来决定。一般来说,子任务的大小应该能够在一个很小的时间内完成。如果子任务仍然很大,那么你应该继续将其分割。否则,如果任务太小,任务分割和任务调度的开销可能就会超过任务执行的时间,导致效率降低。 一个常用的策略是设置一个阈值,当任务的大小小于这个阈值时,直接进行计算,否则继续分割。这个阈值的设定需要根据实际的应用场景来调整。如何处理并发编程中的异常?并发编程中的异常处理是一个比较复杂的问题。在Fork/Join框架中,如果一个子任务抛出了异常,那么这个异常会被ForkJoinPool捕获,并保存在对应的ForkJoinTask对象中。你可以通过ForkJoinTask的getException()方法获取到这个异常。 一种常见的做法是在主任务中,对所有的子任务调用join()方法。如果某个子任务抛出了异常,那么join()方法会重新抛出这个异常。这样,你就可以在主任务中统一处理所有的异常。如何避免常见的性能问题?Fork/Join框架的性能问题通常出现在以下几个方面:任务分割的粒度不合适如果任务分割得太细,那么任务分割和任务调度的开销可能会超过任务执行的时间,导致效率降低。如果任务分割得太粗,那么可能无法充分利用多核处理器。你需要找到一个合适的阈值,以实现任务大小和任务数量的平衡。没有充分利用Fork/Join框架的并行性在Fork/Join框架中,如果一个任务分割成了多个子任务,那么这些子任务可以并行执行。你应该尽量将大任务分割成独立的子任务,以充分利用并行性。过多的对象创建和垃圾回收在分割任务和合并结果时,可能会创建大量的临时对象。这可能会导致频繁的垃圾回收,影响性能。你应该尽量避免不必要的对象创建。数据竞争和内存一致性问题如果多个任务需要访问共享数据,那么可能会出现数据竞争和内存一致性问题。你应该尽量避免共享数据,或者使用合适的同步机制来保护共享数据。总结我们来回顾一下,我们首先深入探讨了Fork/Join框架的本质,然后详细阐述了其核心概念并进行了源码分析。接着,我们通过实际操作深化了对Fork/Join框架的理解。最后,我们对该框架的优点与局限进行了全面评估,并探索了其他可选的并发模型。在这个过程中,我们还解答了一些常见的关于Fork/Join框架使用中的问题,希望对你有所帮助。
cathoy
JVM | 基于类加载的一次完全实践
引言我在上篇文章:[JVM | 类加载是怎么工作的]JVM | 类加载是怎么工作的,为你介绍了Java的类加载器及其工作原理。我们简单回顾下:我用一个易于理解的类比带你逐步理解了类加载的流程和主要角色:引导类加载器,扩展类加载器和应用类加载器。并带你深入了解了这些“建筑工人”如何从底层工作,搬运原材料(类)并将其完整地构建在Java虚拟机(JVM)的“建筑工地”上。然后,我们跟随一个具体的Building类,亲眼目睹了其在JVM中的生命周期。我在文章末尾留了几个问题,你还记得吗?本篇文章,我将带你了解自定义类加载器的创建和使用。我们还将探索Java的SPI机制,了解它如何利用类加载器实现服务的动态发现和加载。接着,我们再来看下Tomcat的类加载机制,尤其是它的热部署和多版本共存的实现,了解类加载机制在现实世界中的高级应用。自定义类加载器的创建和使用当我们的类涉及到一些安全的操作,或者我们想从网络或者其它地方加载类。这种情况,我们就会创建自定义的类加载器,重写findClass方法来完成这个特殊的加载逻辑。沿用上篇文章的例子:假如工地来活了,要求建造一个复杂的建筑物,这个建筑物不仅包括了普通的房间(普通的类),还包括了一些特殊设计的房间(特殊的类)。 在这个情况下,你可能会需要一位专门的工人来处理这些特殊的房间。这位工人需要有特殊的技能和工具,才能按照设计图纸(类的字节码)正确地建造出房间。 接下来,我们来看下类加载器怎么创建与使用的。创建类加载器我们来实现一个类加载器,代码如下:
public class CustomClassLoader extends ClassLoader {
private String classPath;
public CustomClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
// 先检查类是否已经被加载
Class<?> cls = findLoadedClass(name);
if (cls != null) {
return cls;
}
try {
// 如果类还未被加载,尝试使用父类加载器加载(不破坏双亲委派机制)
cls = getParent().loadClass(name);
} catch (ClassNotFoundException e) {
// 父类加载器无法加载该类,那么就调用 findClass 尝试自己加载
cls = findClass(name);
}
return cls;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 你的类加载逻辑...
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
return defineClass(name, classData, 0, classData.length);
}
}
private byte[] loadClassData(String className) {
String fileName = getFileName(className);
try {
InputStream is = new FileInputStream(fileName);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 4096;
byte[] buffer = new byte[bufferSize];
int bytesNumRead;
while ((bytesNumRead = is.read(buffer)) != -1) {
baos.write(buffer, 0, bytesNumRead);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
private String getFileName(String name) {
int index = name.lastIndexOf('.');
// 如果没有找到'.'则直接在classPath中查找
if (index == -1) {
return classPath + name + ".class";
} else {
return classPath + name.substring(index + 1) + ".class";
}
}
}上面的类加载器CustomClassLoader 通过构造的方式传入文件路径。当我们要加载类时,它会调用loadClass方法从我们定义的类路径下读取字节流。好, 接下来,我们来使用我们自己定义的类加载器。使用类加载器代码如下:// 把上篇文章的Building类放在桌面
CustomClassLoader customClassLoader = new CustomClassLoader("C:\\Users\\xxx\\Desktop\\");
try {
// 使用自定义类加载器加载 Building 类,若你的包名不叫这个,请更换。
Class<?> cls = Class.forName("org.kfaino.jvm.Building", true, customClassLoader);
// 创建类的实例
cls.newInstance();
System.out.println("实例名:" + cls.getName() + " 被加载器:" + cls.getClassLoader() + "创建");
} catch (Exception e) {
e.printStackTrace();
}执行!Connected to the target VM, address: '127.0.0.1:4706', transport: 'socket'
建筑蓝图已被创建!
实例名:org.kfaino.jvm.Building 被加载器:sun.misc.Launcher$AppClassLoader@3f3fdda9创建
Disconnected from the target VM, address: '127.0.0.1:4706', transport: 'socket'
Process finished with exit code 0类被AppClassLoader加载了,为啥? 原因是我的项目中有Building类, 这个类可以被应用类加载器加载,因此就轮不到·CustomClassLoader 加载了。我们把项目内的Building先改名一下。然后执行!Connected to the target VM, address: '127.0.0.1:5032', transport: 'socket'
建筑蓝图已被创建!
实例名:org.kfaino.jvm.Building 被加载器:org.kfaino.jvm.CustomClassLoader@6379b5ed创建
Disconnected from the target VM, address: '127.0.0.1:5032', transport: 'socket'
Process finished with exit code 0这次,我们的类成功被CustomClassLoader加载,并且加载的是我们桌面上的字节码文件。使用Java自带的类加载器工具类当然,如果你想要从外部加载字节码文件,可以不必这么繁琐。JDK提供了一个功能更强大的URLClassLoader。我们一起来看下它怎么用: // 把Building放在桌面
URL[] urls = new URL[] {new URL("file:C:\\Users\\xxx\\Desktop\\")};
URLClassLoader customClassLoader = new URLClassLoader(urls);
try {
// 使用自定义类加载器加载 Building 类
Class<?> cls = Class.forName("org.kfaino.jvm.Building", true, customClassLoader);
// 创建类的实例
cls.newInstance();
System.out.println("实例名:" + cls.getName() + " 被加载器:" + cls.getClassLoader() + "创建");
} catch (Exception e) {
e.printStackTrace();
}我们执行看下:Connected to the target VM, address: '127.0.0.1:9734', transport: 'socket'
建筑蓝图已被创建!
实例名:org.kfaino.jvm.Building 被加载器:java.net.URLClassLoader@28634811创建
Disconnected from the target VM, address: '127.0.0.1:9734', transport: 'socket'
Process finished with exit code 0没有问题,本地的字节码文件Building 被成功读取。因此,当你有从外部读取字节码文件的需求,可以试试用JDK自带的·URLClassLoader类加载器。同时,它还提供了其它更强大的功能。从网络URL加载类和资源若你想从网络加载字节码文件,你可以这么做:URL url = new URL("http://www.github.com/xxx/");
URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{url});
Class<?> clazz = urlClassLoader.loadClass("包名.类名");更多的URL加载类和资源细心的你肯定发现URLClassLoader的构造入参是数组类型,也就意味着可以传入多个URL,具体用法如下:URL url1 = new URL("http://www.github.com/xxx1/");
URL url2 = new URL("http://www.github.com/xxx2/");
URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{url1, url2});
Class<?> clazz = urlClassLoader.loadClass("包名.类名");从JAR文件加载类和资源它可以从完整的jar包中读取字节码文件,代码如下:File file = new File("/xxx/jarfile.jar");
URL url = file.toURI().toURL();
URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{url});
Class<?> clazz = urlClassLoader.loadClass("包名.类名");加载外部配置文件它可以从外部读取配置文件,代码如下:File file = new File("/xxx/resources/");
URL url = file.toURI().toURL();
URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{url});
URL resourceUrl = urlClassLoader.getResource("xxx.properties");
InputStream stream = resourceUrl.openStream();
Properties properties = new Properties();
properties.load(stream);自定义类加载器的注意事项类加载器在类的加载过程中起着至关重要的作用。因此,在使用时,我们必须倍加警惕。接下来,让我们来看下哪些需要注意的问题:内存泄漏长期存活的类加载器持有类的引用就会导致内存泄露。为了避免这个问题,我建议你在关键代码处适当使用如下代码,让旧的类加载器和类实例进行解绑:// 释放对 ClassLoader 的引用,使其有可能被垃圾回收
classLoader = null;
System.gc();当然,每个问题都需要我们针对性地分析。我这里只是提供可能导致内存泄漏的一个说法。实际上,引发内存泄漏的原因有很多,如果你在工作中遇到了这个问题,可以使用一些可视化分析工具来综合性的分析。不要轻易破坏双亲委派机制双亲委派模型是为了保证Java核心类库的安全性。当然,我们也可以选择破坏双亲委派模型,前提是,你已考虑好这些风险并规避。在上述代码中,我们没有违背双亲委派模型的原则。回顾一下我们在之前文章中提到的双亲委派模型的概念:在类加载的过程中,我们首先会让父类加载器进行加载,只有在父类加载器无法加载的情况下,我们才会使用自定义的类加载器进行加载。顺便我把上篇缺少的自定义类加载器也补充进去,你可以看下: 线程安全问题如果我们在多线程中使用类加载器,可能会导致类被重复加载多次。除了会浪费资源外,还会导致我们一些静态初始化代码被执行多次,造成一些诡异的问题。我在上篇专栏中说到,解决线程安全的方式有多种。为了保险起见,你可以采用同步方案来解决它。自定义类加载器使用场景在上面的例子中,我为你展示如何从外部加载字节码文件。接下来,我们来看下还有哪些使用场景:安全检查安全,是软件工程中永恒的话题。为了防止第三方的潜在干扰,我们通常在获取外部文件的同时,做一些过滤的机制。你看代码:public class SecurityCheckingClassLoader extends ClassLoader {
private static final String CLASS_NAME_PREFIX = "Safe";
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
// 加上你想要的安全校验逻辑
if (!name.startsWith(CLASS_NAME_PREFIX)) {
throw new ClassNotFoundException("不安全的类: " + name);
}
return super.loadClass(name);
}
}上面,我为你举了一个简单的例子。我在加载类方法loadClass前校验类名的前缀,如果你不是Safe开头的类,我们就不予放行。解密加密的类文件网络环境充满不确定性,如果你选择从网络获取字节码文件,我建议你首先做好加密工作。既然是从外部获取文件,我们可以通过继承URLClassLoader来实现。代码如下:import java.net.URL;
import java.net.URLClassLoader;
public class DecryptingURLClassLoader extends URLClassLoader {
public DecryptingURLClassLoader(URL[] urls) {
super(urls);
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 获取字节码文件
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
}
byte[] decryptedClassData = decrypt(classData);
return defineClass(name, decryptedClassData, 0, decryptedClassData.length);
}
private byte[] loadClassData(String className) {
// 从网络中获取字节码文件
}
private byte[] decrypt(byte[] classData) {
// 解密字节码文件
}
}上面两个是工作中相对比较容易用到的两种场景,还有没有其它类加载器的优秀案例呢?基于自定义类加载器的其它实现我们从官网文档中得知,Tomcat会为每个web应用创建类加载器。图片如下: 现在,我们来看下Tomcat用自定义加载器做了哪些事情。Tomcat中的热部署先来解释下什么是热部署? 热部署是指我们的应用在运行过程中,可以在不关闭应用的前提下更新应用。 假如你想开启热部署,你可以在context.xml里面设置reloadable="true”。限于篇幅有限,我在这里只是为你说明Tomcat热部署到底是怎么实现的,如果你感兴趣,建议您亲自动手实操。热部署实现原理Tomcat通过一个BackgroundProcessor 后台线程周期性的检查web 应用的 WEB-INF/classes 和 WEB-INF/lib 目录下的 class 文件或 jar 文件是否有变化。具体做法是比较文件的最后修改时间和上次记录的最后修改时间是否一致。如果有变化,就触发 web 应用的重载。 Tomcat重新加载一个web应用时,会创建一个新的WebappClassLoader实例,并使用这个新的类加载器来加载web应用的类。这样,新的类加载器就会加载最新版本的类,而旧的类加载器加载的旧版本的类会在它们不再被引用时被垃圾回收。这就是Tomcat的热部署。Tomcat中的多版本共存那什么是多版本共存? 我们在上面说到,每一个web应用都有自己独立的类加载器,这就意味着每一个web应用都有自己的类和库的命名空间。即使同一Tomcat实例中运行的多个web应用使用了同名的类和库,它们也不会相互干扰。 也就是说Tomcat的多版本共存关键也在于每个应用都有不同的类加载器。 限于篇幅有限,更多细节,建议你移步到官方文档,我在文末参考文献中为你贴出官方地址。ServiceLoader和SPI我们经常会听到许多Java框架包括Dubbo、Spring等都使用了SPI这个机制,SPI究竟是什么东西?ServiceLoader和我们今天讲的类加载器又有什么关系?SPI(Service Provider Interface)是什么?我从服务提供方和服务调用方两个视角来为你讲解:服务提供方对于服务提供方而言,它只需要根据接口实现对应方法。并且把配置文件放在META-INF/services/ 告知服务调用方。服务调用方服务调用方根据约定,使用ServiceLoader来遍历实现该约定的实现类。加载进内存中供服务提供方调用。为了加深理解,我为你画了一张图: ServiceLoader和类加载器的关系它和类加载器又有什么关系?我通过代码为你分析,你看:public static <S> ServiceLoader<S> load(Class<S> service) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}如果你不指定类加载器,load方法默认获取当前线程的类加载器去加载该类。它通过扫描META-INF/services/目录下的配置文件,找到服务接口的实现类的全限定名。有了全限定名就等于掌握了花名册,当我们遍历ServiceLoader时,服务加载器会通过类加载器将这些服务提供者实例化,这样我们就可以使用这些服务了。文中重要部分解析命名空间我在上面Tomcat多版本共存中提到命名空间,什么是命名空间?每个类加载器实例其实就是一个命名空间。也就是说,在一个应用程序中允许一个类被多个类加载器实例加载,并且共存于应用程序中。暂停30秒,思考一下,这样会出现什么问题。 好,在回答这个问题之前,我为你展示一个代码:try {
URL[] urls = new URL[] {new URL("file:C:\\Users\\xxx\\Desktop\\")};
URLClassLoader customClassLoader1 = new URLClassLoader(urls);
URLClassLoader customClassLoader2 = new URLClassLoader(urls);
Class cls1 = customClassLoader1.loadClass("org.kfaino.jvm.Building");
Class cls2 = customClassLoader2.loadClass("org.kfaino.jvm.Building");
Object obj1 = cls1.newInstance();
Object obj2 = cls2.newInstance();
System.out.println("obj1 class: " + obj1.getClass());
System.out.println("obj2 class: " + obj2.getClass());
System.out.println("obj1 class loader: " + obj1.getClass().getClassLoader());
System.out.println("obj2 class loader: " + obj2.getClass().getClassLoader());
// Building对象已经重写hashCode和equals方法
System.out.println("obj1 equals obj2: " + obj1.equals(obj2));
} catch (Exception e) {
e.printStackTrace();
}在Building已经重写hashCode和equals方法的前提下,obj1 equals obj2: 会是true吗?我们看下结果:建筑蓝图已被创建!
建筑蓝图已被创建!
obj1 class: class org.kfaino.webTemplate.jvm.Building
obj2 class: class org.kfaino.webTemplate.jvm.Building
obj1 class loader: java.net.URLClassLoader@da236ecf
obj2 class loader: java.net.URLClassLoader@8e02f9da
obj1 equals obj2: false
Process finished with exit code 0结果是否定的,和我之前说的吻合。也就是说使用不同的类加载器,不同类加载器的对象(命名空间不同),在JVM中就是类型不一致的。生产环境中的热部署BackgroundProcessor 后台线程,需要周期性地检查(checkResources())文件的状态。处于对性能方面的考虑,在生产环境中,通常会关闭 Tomcat 的热部署功能。SPI配置文件存放位置META-INF/services/可以更改吗?查阅官方文档,我们可以知道SPI是JDK内置的一种服务提供发现机制。在SPI机制中,服务提供者的配置文件默认放在META-INF/services/目录下。这是Java SPI规范的一部分,无法更改。总结至此,本篇完结。我们来回顾下:首先,我带你创建并使用了类加载器完成从本地文件夹下加载自己的类。这些工作我们可以通过Java自带的类加载器来简化,我也为你演示其用法。当然,我们在使用自定义类加载器要格外注意,因为涉及到类初始化往往你会碰到一些不可预见的诡异BUG。然后,我为你介绍自定义类加载器场景的使用场景。顺便看一下Tomcat和Java是怎么用自定义类加载器的特性实现高级功能的。常见面试题如何自定义类加载器?在什么情况下会需要自定义类加载器?Tomcat的类加载器有什么特点?如何实现热部署和多版本共存?#### 什么是ServiceLoader和SPI,它们如何利用类加载器?类加载器可能存在的问题有哪些?
cathoy
并发编程 | CompletionService - 如何优雅地处理批量异步任务
引言上一篇文章中,我们详细地介绍了 CompletableFuture,它是一种强大的并发工具,能帮助我们以声明式的方式处理异步任务。虽然 CompletableFuture 很强大,但它并不总是最适合所有场景的解决方案。 在这篇文章中,我们将介绍 Java 的 CompletionService,这是一种能处理批量异步任务并在完成时获取结果的并发工具。 CompletionService 与 CompletableFuture 在很多方面都相似。它们都用于处理异步任务,并且都提供了获取任务完成结果的机制。然而,CompletionService 采用了更传统并发模型,它将生产者和消费者的角色更明确地分离开来。回顾我们在上一篇文章:并发编程 | 从Future到CompletableFuture - 简化 Java 中的异步编程 - 掘金 (juejin.cn) 中讨论的需求,我们需要查找并计算一系列旅行套餐的价格。我们使用 CompletableFuture 实现了这个需求,并且代码看起来很简洁明了。然而,事情都有两面性。有些人并不习惯这种写法,觉得CompletableFuture 的实现中存在大量的嵌套,会让代码难以阅读和理解。另外,我们的代码中有大量的函数式编程,这在一定程度上增加了对代码阅读的门槛,如果你不熟悉这种编程范式,代码可能会看起来很混乱。有没有一种方法,既简洁的同时,又不回到Future的回调地狱陷阱中去?有,CompletionService 。来看下CompletionService 是怎么解决问题。使用CompletionService 解决问题如果我们用 CompletionService 来实现这个需求,会是什么样呢?我们来看下代码:public List<TravelPackage> searchTravelPackages(SearchCondition searchCondition) throws InterruptedException, ExecutionException {
ExecutorService executorService = Executors.newFixedThreadPool(10);
CompletionService<List<TravelPackage>> completionService = new ExecutorCompletionService<>(executorService);
List<Flight> flights = searchFlights(searchCondition);
for (Flight flight : flights) {
// 提交所有的任务
completionService.submit(() -> {
List<TravelPackage> travelPackagesForFlight = new ArrayList<>();
List<Hotel> hotels = searchHotels(flight);
for (Hotel hotel : hotels) {
TravelPackage travelPackage = calculatePrice(flight, hotel);
travelPackagesForFlight.add(travelPackage);
}
return travelPackagesForFlight;
});
}
List<TravelPackage> allTravelPackages = new ArrayList<>();
for (int i = 0; i < flights.size(); i++) {
// 等待它们的完成
Future<List<TravelPackage>> future = completionService.take();
// 如果没完成,这里会阻塞
List<TravelPackage> travelPackagesForFlight = future.get();
allTravelPackages.addAll(travelPackagesForFlight);
}
executorService.shutdown();
allTravelPackages.sort(Comparator.comparing(TravelPackage::getPrice));
return allTravelPackages;
}
通过上面的代码,我们可以看到 CompletionService 提供了一个更传统的并发模型来处理异步任务。相比CompletableFuture 而言,我们的代码中没有复杂的嵌套,代码更加直观。对初学者来说,这个模型会更容易理解,特别是对于那些不熟悉函数式编程的读者来说。 当然,作为老手的你(假如你弄懂了上篇文章,并实践完),如果你在使用CompletableFuture 过程中发现它嵌套太深太复杂,CompletionService 可能也是个不错的选择。基于上述代码抽取CompletionService我们把关键代码抽取出来并简化,就可以得到下面这段代码:ExecutorService executor = Executors.newFixedThreadPool(4);
CompletionService<String> completionService = new ExecutorCompletionService<>(executor);
long start = System.currentTimeMillis();
// 提交3个任务
completionService.submit(() -> {
// 业务返回的实践可能不一样,模拟不一样的任务执行时间
Thread.sleep(5000);
return "任务1完成";
});
completionService.submit(() -> {
// 业务返回的实践可能不一样,模拟不一样的任务执行时间
Thread.sleep(3000);
return "任务2完成";
});
completionService.submit(() -> {
// 业务返回的实践可能不一样,模拟不一样的任务执行时间
Thread.sleep(500);
return "任务3完成";
});
completionService.submit(() -> {
// 业务返回的实践可能不一样,模拟不一样的任务执行时间
Thread.sleep(500);
return "任务4完成";
});
// 获取结果
for (int i = 0; i < 4; i++) {
try {
Future<String> future = completionService.take();
// 如果没完成,这里会阻塞
System.out.println(future.get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
executor.shutdown();
long end = System.currentTimeMillis();
System.out.println("任务花费时间: " + (end - start) + " ms");
结合文中代码注释,我把它总结为一句口诀:批量提交,快速获取。批量我知道啊,就是遍历呗,但是提交到那里去?快速获取是什么意思?别急,我们接着往下看。使用ExecutorService 实现需求在回答这个问题之前,我们先来看一下代码。我们先sumbit()一下....然后get()拿到数据.... 嗯?这不是和之前ExecutorService 差不多吗?好像可以用它实现啊,你看代码:public List<TravelPackage> searchTravelPackages(SearchCondition searchCondition) throws InterruptedException, ExecutionException {
ExecutorService executorService = Executors.newFixedThreadPool(10);
List<Flight> flights = searchFlights(searchCondition);
List<Future<List<TravelPackage>>> futureList = new ArrayList<>();
for (Flight flight : flights) {
Future<List<TravelPackage>> future = executorService.submit(() -> {
List<TravelPackage> travelPackagesForFlight = new ArrayList<>();
List<Hotel> hotels = searchHotels(flight);
for (Hotel hotel : hotels) {
TravelPackage travelPackage = calculatePrice(flight, hotel);
travelPackagesForFlight.add(travelPackage);
}
return travelPackagesForFlight;
});
futureList.add(future);
}
List<TravelPackage> allTravelPackages = new ArrayList<>();
for (Future<List<TravelPackage>> future : futureList) {
List<TravelPackage> travelPackagesForFlight = future.get();
allTravelPackages.addAll(travelPackagesForFlight);
}
executorService.shutdown();
allTravelPackages.sort(Comparator.comparing(TravelPackage::getPrice));
return allTravelPackages;
}
看,是不是可以实现了。那CompletionService这玩意存在的意义是啥?我们继续往下看。提交先后顺序 VS 任务完成快慢顺序我们先把上面抽取出来的代码执行,结果如下:任务3完成
任务4完成
任务2完成
任务1完成
任务花费时间: 5012 ms
Disconnected from the target VM, address: '127.0.0.1:10373', transport: 'socket'
Process finished with exit code 0
然后,我们换成ExecutorService 执行,抽取的ExecutorService 代码如下:ExecutorService executor = Executors.newFixedThreadPool(3);
ArrayList<Future<String>> futures = new ArrayList<>();
long start = System.currentTimeMillis();
CountDownLatch latch = new CountDownLatch(4);
futures.add(executor.submit(() -> {
// 业务返回的实践可能不一样,模拟不一样的任务执行时间
Thread.sleep(5000);
latch.countDown();
return "任务1完成";
}));
futures.add(executor.submit(() -> {
// 业务返回的实践可能不一样,模拟不一样的任务执行时间
Thread.sleep(3000);
latch.countDown();
return "任务2完成";
}));
futures.add(executor.submit(() -> {
// 业务返回的实践可能不一样,模拟不一样的任务执行时间
Thread.sleep(500);
latch.countDown();
return "任务3完成";
}));
futures.add(executor.submit(() -> {
// 业务返回的实践可能不一样,模拟不一样的任务执行时间
Thread.sleep(500);
latch.countDown();
return "任务4完成";
}));
for (Future<String> future : futures) {
try {
// 如果没完成,这里会阻塞
System.out.println(future.get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
latch.await();
executor.shutdown();
long end = System.currentTimeMillis();
System.out.println("任务花费时间: " + (end - start) + " ms");
执行结果如下:任务1完成
任务2完成
任务3完成
任务4完成
任务花费时间: 5007 ms
Disconnected from the target VM, address: '127.0.0.1:14882', transport: 'socket'
Process finished with exit code 0
细心的你肯定可以看到它们执行结果上的差异。CompletionService 是按照任务时间的顺序消费的。好,搞懂了这个,我们就可以回答上面其中一个问题:快速获取是什么?CompletionService是按照任务的快慢,谁先执行完谁就先返回。可以看到上面示例代码的结果,任务3只需要500ms,所以任务3先返回。CompletionService 的适用场景既然CompletionService 可以按照任务快慢顺序来返回,我们来看下它适合哪些场景:执行一组任务并处理结果上面就是很好的例子,我们可以在任何任务完成后立即获取并处理其结果,以实现快速响应。提高程序的吞吐量(先执行完任务,就有多的线程空闲,可以响应更多任务)。生产者-消费者模式我们在最早的开篇说过,CompletionService可以天然地实现生产者-消费者模式。这个模式中,生产者线程负责批量提交任务,消费者线程负责获取并处理任务的结果,而且它也可以安全地在多个线程之间共享。新的问题又出现了,为什么又可以在多个线程之间共享?提交到那里去?快速获取是怎么做到的?以问题为导向,我们来分析下源码。CompletionService源码分析提交到那里去?为什么可以在多线程之间共享?我们先看下构造函数中做了什么:public ExecutorCompletionService(Executor executor) {
if (executor == null)
throw new NullPointerException();
this.executor = executor;
this.aes = (executor instanceof AbstractExecutorService) ?
(AbstractExecutorService) executor : null;
this.completionQueue = new LinkedBlockingQueue<Future<V>>();
}
ExecutorCompletionService使用了一个BlockingQueue来存储已完成的任务。因为,任务的提交Executor和BlockingQueue都是线程安全的。所以多线程共享的数据竞争问题已经在内部解决了。快速获取是怎么做到的?我们可以看下submit()方法是怎么实现的。当你提交一个任务时,这个任务被封装在一个QueueingFuture对象中:public Future<V> submit(Callable<V> task) {
if (task == null) throw new NullPointerException();
RunnableFuture<V> f = newTaskFor(task);
executor.execute(new QueueingFuture(f));
return f;
}
QueueingFuture重写了done()方法。当任务完成时,done()方法会被调用,QueueingFuture会将自己添加到completionQueue中:private class QueueingFuture extends FutureTask<Void> {
QueueingFuture(RunnableFuture<V> task) {
super(task, null);
this.task = task;
}
protected void done() { completionQueue.add(task); } //当任务完成时,将任务添加到队列中
private final Future<V> task;
}
这样似乎就可以解释,快速获取的机制。完成的任务优先被放入BlockingQueue中按照完成顺序排队。 现在,我换一种表述,你看下是否正确:快的任务在消费的时候就会被排在队列前面先被消费,这样就形成一个任务完成快慢的顺序,第一个被消费到的任务一定是最快的。第一个被消费到的任务一定是最快的吗?从上面的代码测试示例结果来看, 确实如此。但是,我很遗憾的告诉你,这句话是错误的。 这句话的正确性是建立在任务数等于线程数的前提下。这就显得很鸡肋了,在在生产中很难达到这个效果,因为资源是稀缺的。当然,我们还是拿代码说话:ExecutorService executor = Executors.newFixedThreadPool(3);
CompletionService<String> completionService = new ExecutorCompletionService<>(executor);
long start = System.currentTimeMillis();
completionService.submit(() -> {
// 业务返回的实践可能不一样,模拟不一样的任务执行时间
Thread.sleep(5000);
return "任务1完成";
});
completionService.submit(() -> {
// 业务返回的实践可能不一样,模拟不一样的任务执行时间
Thread.sleep(3000);
return "任务2完成";
});
completionService.submit(() -> {
// 业务返回的实践可能不一样,模拟不一样的任务执行时间
Thread.sleep(6000);
return "任务3完成";
});
completionService.submit(() -> {
// 业务返回的实践可能不一样,模拟不一样的任务执行时间
Thread.sleep(500);
return "任务4完成";
});
for (int i = 0; i < 4; i++) {
try {
System.out.println(completionService.take().get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
executor.shutdown();
long end = System.currentTimeMillis();
System.out.println("任务花费时间: " + (end - start) + " ms");
假如遵循执行快慢顺序,理想的状态应该是:4 -> 2 -> 1 -> 3;而结果却是:Connected to the target VM, address: '127.0.0.1:5068', transport: 'socket'
任务2完成
任务4完成
任务1完成
任务3完成
任务花费时间: 6020 ms
Disconnected from the target VM, address: '127.0.0.1:5068', transport: 'socket'
这个结果也是意料之外,但在情理之中。因为线程总共只有3个,在1,2,3之间排序,任务顺序应该是2,1,3;然后当2执行完之后,1和3依然未执行完;这个时候4正好执行完。于是就插队到任务中。最终得到2,4,1,3的结果。 因此,我们可以说:在生产环境中,这个顺序是不可控的,除非你把线程设置为1;CompletionService相关面试题如何使用CompletionService处理一组任务并获取结果?比较ExecutorService和CompletionService,它们有什么相同之处和不同之处?在何种情况下,你会选择使用CompletionService而不是ExecutorService?解释CompletionService是如何保证按任务完成顺序获取结果的当一个任务被提交到CompletionService后,它的生命周期是怎样的?在任务执行过程中,CompletionService内部都发生了什么?在使用CompletionService处理任务时,如果某个任务执行异常,应该如何处理?如果我想取消CompletionService中的所有任务,应该如何做?谈谈你对Java中的Executor,ExecutorService,CompletionService和Future之间关系的理解看完上面的文章,你可以试着来回答了吗?参考文献Java并发编程小册总结让我们一起回顾今天所学。首先,我引导你使用了CompletionService和ExecutorService来实现了先前复杂的需求。相较于CompletableFuture,它们可能显得更为传统,但也更易理解。然后,我们一起探索了CompletionService的存在意义。我们试图解答,既然ExecutorService已经足够应对需求,为什么还要有CompletionService这样的设计。为了揭示这个疑惑,我们深入到源码中,同时也纠正了一个错误观点,以帮助你对CompletionService有更深刻的理解。最后,我们通过面试题形式,来巩固和复习我们所学的知识。
cathoy
JVM | 内存调优实战 - MAT工具问题排查与分析
一、引言在软件开发领域中,稳定性和性能始终是开发者的首要关注点。特别是在Java世界中,内存管理无疑是一个重要的话题,尤其当涉及到内存溢出的问题时。这种情况不仅可能导致应用的崩溃,还可能导致系统的整体性能大幅下滑。对于Java开发者来说,仅仅理解JVM的内存结构是不够的。更为重要的是,我们需要有实战经验和应对策略来避免这些问题。这些往往也是面试官喜欢切入的地方。本文的目的正是为了深入挖掘JVM常出现的问题,解析其背后的原因,并提供实际的解决方案和技巧,话不多说,我们开始吧~性能监控经常成为预防生产事故的得力助手,在打出一套检验你故障排查能力连招之前,往往会先试探性的探查你对性能监控的熟练掌握程度。接下来,我们看看应该怎么回答这类问题。二、性能监控:man:面试官:你们公司怎么做性能监控?生产出现问题怎么办 :boy:候选者:看下Tomcat日志,把日志拿出来.... :man:面试官:?停停停....我们公司可能不太适合你分布式,微服务大环境大行其道,只用肉眼24小时盯着机器的时代过去了。为了保证服务的持续可用性和高性能,现代公司已经转向了自动化的、全面的监控方案。它是内需,也是一个产品,用于鉴别大型公司和小型公司的指标之一。选择一套合适的监控工具和策略不仅可以及时发现和解决问题,还能为未来的系统优化提供有价值的数据。排除一些在"自研"方向越走越远的公司。常见的性能监控解决方案如下: 1.数据收集: 使用 Spring Cloud Sleuth 为微服务中的每一个请求生成追踪数据,确保我们能够清晰地看到请求在整个系统中的流动路径。 2.数据存储与可视化: Zipkin 在这里起到了关键作用,它不仅接收和存储由Sleuth生成的追踪数据,还提供了一个直观的界面来可视化这些数据,帮助我们快速定位性能瓶颈或错误。 3.指标监控: 我们使用 Prometheus 来收集各种系统和应用指标,包括但不限于CPU、内存、磁盘、网络,以及业务相关的指标。 4.数据展示: Grafana 与 Prometheus 无缝集成,为我们提供了一个强大的、定制化的仪表板,显示关键指标的实时数据。当某些指标超出正常范围时,Grafana也可以触发警报。 5.警报: 通过 Prometheus的 Alertmanager,我们可以定义自己的警报规则,并决定如何通知相关团队——是通过邮件、微信机器人还是其他方式。限于篇幅,我在这里就不过多展示,将在后续文章中为你介绍。现在会造火箭了吗?咳咳...会说了吗?:boy: 候选者:我们采用了一套开源的微服务监控方案,涉及追踪数据收集、数据存储与可视化、系统与应用指标监控、数据展示,以及警报管理。具体地,我们使用Spring Cloud Sleuth为每个微服务请求生成追踪数据;再用Zipkin来接收、存储并可视化这些数据。对于系统和应用指标,我们采用Prometheus进行收集,并通过Grafana进行展示和仪表板创建。当指标达到某些阈值时,我们的Prometheus Alertmanager会发送警报,通知相关团队采取行动。 :man: 面试官:很好,这样的方案确保了性能问题能够被及时发现和定位,对于生产环境的稳定性至关重要。三、MAT使用指南:man:面试官:生产上出现OOM你怎么解决的? :boy:候选者:远程DEBUG一下,看下哪里报错呗 :man:面试官:(上个用远程DEBUG的公司坟头都长草了吧)?回去等通知工欲善其事,必先利其器。生产的绝大部分问题,都没办法像本地一样方便,例如内存快照,DEBUG断点,随意压测等等去复现问题。往往需要通过你同事辅助保留现场,遇到一些没办法通过日志和监控问题的时候把一些信息DUMP出来,通过工具进行分析。Memory Analyzer下载把大象关进冰箱要几步?少侠,我这里也需要三步,不来试试吗?1.访问路径:eclipse.dev/mat/downloa…2.下载1.12.0版本,可以通过Previous Releases找到历史版本,如下图所示: 根据不同操作系统进行下载 3.打开MAT,会看到Welcome界面,假如你是初学者,不要急着关闭,建议你把Tutorials玩一遍 例如,你可以点开按照它的步骤玩一会,相信你很快就熟悉这个工具了。 满满的Eclipse风,不知道00后程序员习不习惯,哈哈其它网上资料五花八门,如果你想要熟悉这个工具,你可以按照上面的Tutorials玩一遍。 或者通过官方文档进行学习:eclipse官方Tutorial 、wiki.eclipse.org/MemoryAnaly…在更进一步使用MAT之前,先带你熟悉下内存参数四、JVM内存参数详解我们通常会看到这样的参数:-Xmx1024m,-XX:SurvivorRatio 等等;可能查阅相关文档后知道它的用法,过一段时间又忘记了,总是反反复复。曾经我也有这样的困惑,直到我掌握了画图工具。参数的作用直接在我的脑海中呈现。话不多说,我们看图说话。年轻代和老年代按比例①Eden(伊甸园)区和Survivor中的From区的比例; ②Old(老年)区和new(新生代)的比例;按大小③最大新生代容量大小,单位为m;例如:-XX:MaxNewSize=1024M;它是一个可伸缩的配置。 ④去除保留部分的新生代容量大小,同上。 ⑤最大新生代容量大小,不可伸缩。即最小和最大等同。单位为m;例如:-Xmn2048m; ⑥新生代+老年代+保留最大容量大小。单位为m;例如:-Xmx4096m; ⑦去除保留部分的容量大小,同上。元空间JIT编译缓存-XX:ReservedCodeCacheSize 用于设置为编译后的代码保留的内存大小。Code Cache是JVM中用于存放即时编译器生成的本地机器代码的区域。当设置小于240m时候,non-nmethods,profiled-nmethods,non-profiled-nmethods被存放在一起。我们IDEA默认设置的值就是240m。我们来看下这三个到底是什么。关于non-nmethods, profiled-nmethods, 和 non-profiled-nmethods,它们都是Code Cache的区段。JVM(尤其是HotSpot VM)在Java 8和Java 9中进行了一些更改,为Code Cache引入了不同的分段以改进性能和可维护性。non-nmethods用于存放不是由即时编译器生成的代码,例如解释器代码和存根。profiled-nmethods存放已经进行了简单分析(基于采样)的方法的编译代码。这部分代码是第一次编译的,它包含了插桩代码(profilers)来收集更多关于方法行为的信息。non-profiled-nmethods存放未进行分析的方法的编译代码。这些方法通常是第二次(或更多次)编译的,使用了更多的优化技术,但没有插桩代码。它们基于profiled-nmethods中收集的信息进行编译。 为了提高性能,即时编译器可能会多次编译某些热点方法,每次采用更多的优化。这就是为什么有区分“已分析”和“未分析”的编译代码的需要。你可以使用JVM诊断工具(如jcmd)查看Code Cache的使用情况,例如:jcmd <pid> Compiler.codecache其中<pid>是Java进程的ID。五、内存溢出与其常见原因内存溢出(out of menory):程序需求的内存,超出了系统所能分配的范围。 内存泄漏(memory leak):不再用到的内存,没有及时释放。内存溢出是指程序在申请内存时,没有足够的内存供其使用,出现此类错误一般是因为内存中已无空间可供分配。这在JVM中是一个常见的问题。在本章中,我们将详细探讨JVM中可能导致内存溢出的常见原因。堆内存不足•对象过多:如果应用程序创建了大量的长生命周期的对象,堆中可用的内存可能会迅速耗尽。•内存泄漏:某些对象无法被GC回收,但它们不再被使用。这些对象占据了堆空间,导致其他新对象无法在堆上分配。栈溢出•递归过深:如果一个方法递归调用自己,并且没有有效的终止条件,那么这个方法的调用栈会不断增长,直到耗尽所有的栈内存。•局部变量过多:大量的局部变量和大型数据结构可能会导致栈空间迅速耗尽。方法区内存不足•加载的类过多:如果应用程序或其库加载了大量的类,方法区内存可能会被耗尽。•大量的常量:大量的常量,尤其是字符串常量,可能会消耗方法区的内存。内存溢出的问题可以通过多种工具和技巧进行诊断和解决。在后续章节中,我们将详细讨论这些方法。六、诊断内存溢出当你面临一个可能的内存溢出问题时,第一步是确定它确实是内存溢出。接下来,我们将讨论一系列工具和步骤来帮助你诊断和解决这些问题。JVM日志启动JVM时,可以使用以下参数来收集有关内存使用情况的信息: •-XX:+PrintGCDetails:此选项将为每次GC打印详细日志。 •-XX:+PrintGCTimeStamps:此选项将为GC日志添加时间戳。 从GC日志中,你可以看到每次垃圾回收的时间、回收了多少内存以及堆的总大小。频繁的全GC通常是内存压力的一个信号。堆转储分析当你怀疑有堆内存溢出时,可以生成堆转储来分析: •使用jmap工具:jmap -dump:live,format=b,file=<filename> <pid> 堆转储可以使用如Eclipse MAT或VisualVM等工具进行分析,以确定哪些对象占用了最多的内存。堆转储工具类似的工具有Java VisualVM和Eclipse MAT,用于监视、分析和调试Java应用程序。它可以显示内存使用、线程活动和方法调用。硬件和操作系统工具在某些情况下,问题可能不仅仅在JVM级别。使用像top、vmstat或其他OS级别的工具可以帮助确定整个系统的内存使用情况。 一旦诊断出问题,下一步是采取措施修复它。在接下来的章节中,我们将详细讨论如何解决内存溢出问题。七、解决内存溢出问题在确诊了内存溢出后,我们需要立刻行动以解决它。本章将带你走进解决问题的具体步骤。调整JVM参数并不是每个人都会分析如何设置JVM 堆内存大小,为了避免内存太小导致的溢出,基本上都设得挺大的,毕竟内存大总比内存溢出好,因此就造成了不少的内存浪费。对于许多应用来说,简单地微调JVM的内存参数可能就是解决问题的关键。一些常见的参数包括: •-Xms 和 -Xmx:它们分别设置了JVM堆的初始大小和最大大小。 •-XX:MaxMetaspaceSize:对于Java 8及以上版本,这个参数限制了元数据空间的大小,这是旧的持久代的替代品。 请注意,盲目地增加内存不是一个长久之计,你应该根据应用的实际需要进行适当的调整。如何选择合适大小的内存?我会在后面的文章为你分析。代码优化代码优化对每个程序员都息息相关,我们来看下有哪些注意事项: •移除不必要的对象引用:确保你的代码中不再使用的对象被正确地设置为null,这样垃圾回收器可以回收它们。 •使用弱引用或软引用:Java提供了WeakReference和SoftReference类,当你想要持有一个对象的引用,但不想阻止它被垃圾收集时,它们是很有用的。 •池化资源:对于重的对象,如数据库连接或线程,使用池可以有效地复用它们,而不是每次都重新创建。 归结为一句话:避免重复创建,避免让无用的对象存活太长。使用缓存策略缓存可是性能优化的一大利器,但是它是一把双刃剑。虽然它可以显著提高应用的性能,但如果不正确地管理,它也可能是内存溢出的原因。考虑使用像LruCache这样的策略,它会根据使用情况自动删除老的缓存项。外部存储内存是计算机中相对不那么便宜一块位置。如果你的应用需要处理大量数据,可以考虑将一部分数据移到外部存储,如数据库、硬盘或分布式缓存系统。第三方库审查我们引用的大部分包,都可以在升级后得到解决。确保你使用的所有第三方库都是最新的,没有已知的内存泄漏问题。旧的或未维护的库可能会成为应用中内存问题的隐藏来源。解决内存溢出问题可能需要时间和耐心,但通过系统的方法和正确的工具,你可以有效地定位并解决它们。接下来的章节,我们将分享一些实际的案例和经验。八、基于MAT分析的实战应用案例HPROF文件可能包含敏感或私有信息,因此直接在公共平台上共享可能不是一个好主意。这也是为什么大多数公司会选择在内部工具或私有云环境中进行HPROF文件的分析。网上大部分分享出来的案例大多都是层层加码,没办法拿到完整的HPROF文件。实际场景常常是最佳的学习平台。接下来,我将为您分享一个案例,展示如何在实际环境中诊断并解决JVM内存溢出问题。一个完整的生产级事故,一般都会经历下面这几个阶段:快速恢复业务当线上出现故障时,首先需要迅速消除其对业务的影响,随后再收集数据、定位问题,并给出解决方案。为确保线上业务不受干扰,而不仅仅是简单地重启服务器,我们需要尽可能地保留出现问题的场景,为后续的问题分析提供基础。 那么,如何既能迅速消除对业务的影响,又能保留故障的现场信息呢?首选策略是隔离出问题的服务器。当服务器出现问题时,良好的分布式负载方案可以自动将出问题的机器从集群中移除,以确保系统的高可用性。如果故障的服务器没有被自动移除,需要运维人员手动隔离,并保留故障信息供后续分析。 内存泄露问题,大多数情况下是代码错误导致的。这类问题可能会引发连锁反应,导致多台服务器接连崩溃。为了避免这种连锁反应,我们需要迅速定位并处理内存泄露问题,同时尽可能减少其对其他服务的影响。简单的解决办法是根据转发策略(例如使用Nginx或F5),将流量引导到一个单独的集群,与其他业务流量隔离,确保整体业务稳定。问题定位首先,通过日志确定是哪种类型的内存溢出,例如Java heap space(堆空间)或perm space(持久代)。团队发现频繁的全堆垃圾收集是导致应用崩溃的主因。每次GC都会使应用响应时间大幅度增加,直至系统崩溃。进一步的分析显示,存在一个被频繁使用但从未释放的大型缓存对象,导致GC花费大量时间。解决方案团队决定优化缓存的实现。首先,他们移除了所有不必要的对象引用,确保这些对象在不再使用后能够被垃圾回收。接下来,他们采用了新的缓存策略,使用WeakReference来引用可能很快被垃圾回收的对象。此外,他们还确保所有使用的第三方库都是最新版本,并没有已知的内存泄漏问题。收集内存溢出Dump文件有两种方法可以收集Dump文件: •设置JVM启动参数-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/jvmdump。这样,每次发生内存溢出时,JVM会自动进行堆转储,其dump文件将保存在指定路径下。 •使用jmap命令进行收集:jmap -dump:live,format=b,file=/opt/jvm/dump.hprof pid。分析Dump文件我也不制造问题了,直接把教程中的HPROF拿来分析,我们使用MAT打开Dump文件后,您将看到如下的界面: 通过上述方法,我们可以更高效地定位并解决JVM内存溢出问题,确保业务的稳定运行。九、其它工具在处理JVM内存溢出问题时,使用正确的工具至关重要。下面我会介绍一些常用的工具和技巧,这些工具和技巧可以帮助您更快地定位和解决问题。JVM内置工具1. jstatjstat 是一个命令行工具,可以提供JVM的类加载、垃圾收集、JIT编译等的统计信息。例如,要查看一个正在运行的Java进程的垃圾收集统计信息,可以使用:jstat -gc <pid>2. jmapjmap 是一个Java内存映射工具,可以为Java进程生成堆转储。堆转储可以使用其他工具进行分析,以找出内存泄漏或其他问题。例如,生成一个堆转储:jmap -dump:format=b,file=<filename> <pid>3. VisualVM我在上面已经提到,但是为了把它归类完整,还是着重介绍一下它。 VisualVM 是一个强大的工具,集成了多个JDK命令行工具。它提供了一个可视化的界面,可以查看和分析Java应用程序的性能数据。你可以在这个工具中解决80%以上的问题。技巧和建议 •在启动Java应用程序时,使用-verbose:gc 和 -Xloggc:<filename> 参数,可以输出GC日志。这些日志在分析垃圾收集活动时非常有用。 •定期审查代码,避免常见的内存泄漏情况,例如:长时间持有对象引用,或不适当地使用静态集合。 •使用自动测试和负载测试来模拟高流量的情境,确保系统在高压力下仍能正常运行。解决策略与优化建议解决JVM内存溢出问题不仅仅是定位和修复问题,更重要的是对系统进行长期的优化和调整,以预防此类问题再次发生。下面是一些常见的解决策略和优化建议:1. 调整JVM参数调参工程师名不虚传,调参可以解决90%以上的性能慢的问题。以下是一些常见的套路: •堆内存分配:适当增加JVM的-Xms和-Xmx值可以延长OOM的发生,但不是根本解决方法。这只有在确保没有内存泄漏的情况下才有效。 •调整年轻代与老年代大小:使用-XX:NewRatio来调整年轻代和老年代的比例,以适应应用的实际需求。 •调整线程栈大小:如果出现因为线程过多导致的OOM,可以通过-Xss调整每个线程的栈大小。2. 代码优化建议•使用弱引用:对于不必长时间持有的对象,可以考虑使用弱引用,使其能够在适当的时机被垃圾收集器回收。•缓存策略:确保所有缓存(如商品缓存)都有清晰的过期策略和大小限制。•减少对象的全局持有:例如,避免使用静态集合类持有大量对象。3. 垃圾收集策略调整•选择合适的GC算法:例如,对于需要低延迟的应用,可以考虑使用G1或ZGC。•调整GC参数:使用-XX:GCTimeRatio和-XX:AdaptiveSizePolicyWeight等参数,对GC进行微调。十、总结内存管理在JVM性能优化中占有举足轻重的地位。不合理的内存使用不仅会导致应用的不稳定,还会严重影响用户体验。因此,对内存溢出问题的及时发现和解决尤为关键。 本文通过详细的案例分析,让我们了解到了如何定位和解决JVM内存溢出的问题。通过对JVM参数的调整、代码的优化和合理的垃圾收集策略,我们可以确保应用的稳定运行并最大化性能。在今后的开发中,希望大家能够将这些经验和策略运用得当,持续优化和提高应用的性能。
cathoy
并发编程 | ThreadLocal - 线程的私有存储
引言在处理复杂的并发问题时,我们经常需要面对多线程环境中数据一致性和线程安全的挑战。其中一种常见的解决方式就是使用锁或者其他同步机制,但是这往往会导致性能的下降。那么有没有一种方法,可以在保证线程安全的同时,又不会降低程序的性能呢?答案就是Java中的ThreadLocal。ThreadLocal为每个线程都提供了一份独立的变量副本,使得每个线程在使用该变量时,实际上都在操作自己的局部变量。这样既解决了多线程环境下的线程安全问题,又避免了同步带来的性能开销。在本篇博客中,我们将一起探索ThreadLocal的内部工作原理,以及如何在实际应用中正确地使用ThreadLocal来提高我们程序的性能和稳定性。正如我们在 并发编程 | 线程安全-编写零错误代码 - 掘金 (juejin.cn) 所探讨的,为了在编程实践中更为安全、高效地利用并发技术,通常需要在设计和编码过程中纳入以下三种策略来进行考虑:互斥同步、非阻塞同步以及无同步。我们来重新回顾下这三个方案:互斥同步的方法主要通过使用锁或者其他同步机制,来保证任何时刻只有一个线程可以访问共享数据,从而避免了数据竞态和不一致性的问题。非阻塞同步方案则主要利用了原子操作,使得我们能在无需使用互斥锁的情况下实现线程间的同步,进而提高了系统的整体性能。无同步方案是最极端的一种,它适用于那些可以容忍一定程度的数据不一致性的场景,通过完全避免同步操作来最大化性能。带着这个问题往下思考,ThreadLocal属于哪个方案?基础 | 理解ThreadLocal什么是ThreadLocalThreadLocal是Java中的一个类,用于创建线程本地变量。每个线程都会创建一个独立的变量副本,每个副本对其他线程都是隔离的。这样,数据在每个线程间都不会互相影响,从而实现了线程安全的数据共享。ThreadLocal的基本使用在我开始详细解释ThreadLocal的基本使用方法之前,我先为你展示一段相关的代码,具体如下: public static ExecutorService threadPool = Executors.newFixedThreadPool(10);
static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static void main(String[] args) throws InterruptedException {
long start = System.currentTimeMillis();
for (int i = 0; i < 1000; i++) {
int finalI = i;
threadPool.execute(() -> {
String date = date(finalI);
System.out.println(date);
});
}
threadPool.shutdown();
try {
if (!threadPool.awaitTermination(60, TimeUnit.SECONDS)) {
threadPool.shutdownNow();
}
} catch (InterruptedException e) {
threadPool.shutdownNow();
Thread.currentThread().interrupt();
}
long end = System.currentTimeMillis();
System.out.println("花费:" + (end - start) + "ms");
}
public static String date(int seconds) {
//参数的单位是毫秒,从1970.1.1 00:00:00 GMT计时
Date date = new Date(1000 * seconds);
return dateFormat.format(date);
}
我创建了一个线程池,并提交了1000个任务,让其对时间+I。现在暂停思考一下,你觉得会出现什么问题?好,我们来公布答案:Connected to the target VM, address: '127.0.0.1:7250', transport: 'socket'
1970-01-01 08:00:03
1970-01-01 08:00:03
1970-01-01 08:00:03
1970-01-01 08:00:04
1970-01-01 08:00:05
....
花费:30ms
数据重复了,为什么?这个问题的原因是,SimpleDateFormat 内部有一个共享的 Calendar 对象,当调用 format() 或 parse() 方法时,它会更改这个对象的状态。如果同时有多个线程修改这个状态,就可能会得到错误的结果。现在,我们来解决这个问题。首先想到的解决办法就是,既然SimpleDateFormat线程不安全 ,那我们把它弄成局部变量不就好了。我们改下代码,把全局变量dateFormat放到date()方法里面,代码如下: public static String date(int seconds) {
//参数的单位是毫秒,从1970.1.1 00:00:00 GMT计时
Date date = new Date(1000 * seconds);
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
return dateFormat.format(date);
}
好,运行,结果如下:Connected to the target VM, address: '127.0.0.1:7950', transport: 'socket'
1970-01-01 08:00:01
1970-01-01 08:00:05
1970-01-01 08:00:04
1970-01-01 08:00:09
...
花费:57ms
果然没问题。但是慢了一倍,原因是啥? 因为我们把dateFormat放到date()里面,等于说1000个线程创建了1000次对象,自然就慢了。还有没有别的办法?有,JDK8引入了DateTimeFormatter,是线程安全的,而且功能更强大,易用性更好。我们来看下代码: static DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// ...略
public static String date(int seconds) {
return Instant.ofEpochSecond(seconds).atZone(ZoneId.systemDefault()).format(dateTimeFormatter);
}
我们来执行一下:Connected to the target VM, address: '127.0.0.1:9637', transport: 'socket'
1970-01-01 08:00:04
1970-01-01 08:00:01
1970-01-01 08:00:07
1970-01-01 08:00:02
...
花费:65ms
问题倒是没问题....就是更慢了。还有没有别的方案?这时候就请出我们今天的主角ThreadLocal。代码如下: static ThreadLocal<SimpleDateFormat> dateFormatThreadLocal = ThreadLocal.withInitial(() ->
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
// ...略
public static String date(int seconds) {
//参数的单位是毫秒,从1970.1.1 00:00:00 GMT计时
Date date = new Date(1000 * seconds);
SimpleDateFormat dateFormat = dateFormatThreadLocal.get();
return dateFormat.format(date);
}
执行!Connected to the target VM, address: '127.0.0.1:9070', transport: 'socket'
1970-01-01 08:00:02
1970-01-01 08:00:05
1970-01-01 08:00:08
1970-01-01 08:00:04
...
花费:31ms
时间又回到30ms左右了。至此圆满结束。好了,回到上面的问题,你是不是已经有答案了?我们来公布答案。ThreadLocal是一种避免共享的设计模式,它是一种无同步方案。其实把dateFormat写在date()里面也是无同步方案,只不过,它叫另一种名字——栈封闭工作场景总不会用的这么简单吧?那我宁愿使用Java8的新特性。别急,我们接着往下看ThreadLocal的常见使用场景在复杂的链式调用中传递参数:工作中可能会遇到很长的调用链,例如:在A->B->C->D的调用链中,D需要使用A的参数,而为了避免一层层传递参数,可以使用ThreadLocal将参数存储在A中,然后在D中取出使用。为每个线程生成独立的随机数或者其他类似的资源:在多线程编程中,可能需要为每个线程生成独享的对象,避免多线程下的竞争条件。在上面的例子中,就是给每个线程携带dateFormat。你现在是不是有掌握工具的喜悦感?保持兴奋感,我们接着往下看进阶 | 熟练ThreadLocalThreadLocal的内部实现机制掌握一个工具的秘密,就要深入其内部。让我们一起揭开ThreadLocal的工作原理,让你编程更得心应手。首先,我们先来回答两个关键的问题:为什么ThreadLocal要用ThreadLocalMap来存储ThreadLocal对象?:因为一个Thread可能持有多个ThreadLocal。为什么ThreadLocalMap由Thread持有?:在 Java 的实现方案里面,ThreadLocal 仅仅是一个代理工具类,内部并不持有任何与线程相关的数据,所有和线程相关的数据都存储在 Thread 里面,这样的设计容易理解。而从数据的亲缘性上来讲,ThreadLocalMap 属于 Thread 也更加合理。当然,主要目的是实现线程间数据的隔离,保证线程安全。每个线程拥有自己的ThreadLocalMap,互不干扰。这样的设计也便于线程结束后,垃圾回收器对ThreadLocalMap的回收,防止内存泄漏。带着这两个问题,我们继续往下看ThreadLocal的一些重要方法首先是initialValue()方法: protected T initialValue() {
return null;
}
这个方法会返回当前线程的初始值,它是一个延迟加载的方法,只有调用get()才会触发。当线程第一次使用get()方法访问变量时,将调用此方法,除非线程先前调用了set方法,在这种情况下,不会为线程调用本initialValue方法。通常,每个线程最多调用一次此方法,但如果已经调用了remove()后,再调用get(),则可以再次调用此方法。如果不重写本方法,这个方法会返回null。一般使用匿名内部类的方法来重写initialValue0方法,以便在后续使用中可以初始化副本对象。接下来我们来看set()方法。这个方法相对直接,其主要功能就是向内部赋予value值。然后,我们回过头来重新审视get()方法。我们前面已经提到,get()方法在执行之前会先调用initialValue()方法。最后,我们讨论remove()方法,这个方法的主要职责是清除ThreadLocal对应的value值。接下来我们来看下源码高级 | 掌握ThreadLocalThreadLocal源码分析首先是get()方法:public T get() {
// 获取当前执行的线程
Thread t = Thread.currentThread();
// 从当前线程中获取其持有的ThreadLocalMap
ThreadLocalMap map = getMap(t);
// 检查当前线程是否已有ThreadLocalMap
if (map != null) {
// 从ThreadLocalMap中获取与当前ThreadLocal关联的Entry
ThreadLocalMap.Entry e = map.getEntry(this);
// 检查Entry是否存在
if (e != null) {
// 存在则返回Entry中的值
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
// 如果当前线程的ThreadLocalMap不存在或者没有当前ThreadLocal的值,初始化一个值并返回
return setInitialValue();
}
代码已经附有详细注释,如果你对此感兴趣,可以进行阅读。同时,remove()和set()函数的实现也已展示,具体代码如下:public void set(T value) {
// 获取当前执行的线程
Thread t = Thread.currentThread();
// 从当前线程中获取其持有的ThreadLocalMap
ThreadLocalMap map = getMap(t);
// 检查当前线程是否已有ThreadLocalMap
if (map != null) {
// 如果存在,将传入的值存入与当前ThreadLocal关联的Entry中
map.set(this, value);
} else {
// 如果不存在,创建一个新的ThreadLocalMap,并存入当前线程与传入值的映射关系
createMap(t, value);
}
}
public void remove() {
// 获取当前执行的线程
Thread t = Thread.currentThread();
// 从当前线程中获取其持有的ThreadLocalMap
ThreadLocalMap m = getMap(t);
// 检查当前线程是否已有ThreadLocalMap
if (m != null) {
// 如果存在,从ThreadLocalMap中移除与当前ThreadLocal关联的Entry
m.remove(this);
}
}
代码分析也告一段落了,是不是很简单。接下来我们根据所掌握的代码分析下面两个问题。使用ThreadLocal的陷阱和注意事项内存泄露问题ThreadLocal是一个容器,它存储了线程局部变量,但是这个存储的线程局部变量对于线程来说是长期存在的,除非这个线程被销毁,否则这个变量将一直存在。在长时间运行的线程中,如果ThreadLocal被大量使用,而又没有被正确清理,就可能导致内存泄露问题。 在Java中,ThreadLocal的实现用的是线程的弱引用,而不是强引用,所以理论上不会出现内存泄漏。然而,如果我们在ThreadLocal中放入的对象是强引用对象,并且我们没有手动调用ThreadLocal的remove方法,那么ThreadLocal中的这个对象就有可能一直存在,即使ThreadLocal本身已经不存在了。这就可能导致内存泄露。空指针异常ThreadLocal为每个线程都保存了一个独立的变量副本,如果我们试图获取一个没有初始化的ThreadLocal变量,就可能引发空指针异常。在使用ThreadLocal时,我们通常会在ThreadLocal变量首次使用前调用ThreadLocal的initialValue()方法进行初始化,但如果我们忘记了这个步骤,或者在初始化后误删了变量,再试图访问它,就可能抛出NullPointerException。注意事项总结尽量在不使用ThreadLocal变量时调用其remove()方法,将其清除,避免可能的内存泄露。在使用ThreadLocal变量前,确保已经进行了初始化,以避免空指针异常。适当的设计和使用ThreadLocal变量,避免在不必要的情况下长期存储大对象,以降低内存压力。结论好,我们来做个总结。本章,我带你了解了究竟什么是ThreadLocal,并通过一个案例动手实践优化了线程不安全的代码,最终性能优于syncronized关键字。紧接着我为你讲解了其工作机制。并通过源码带你深入了解其背后的运行机制。最后基于源码回答了ThreadLocal最经常出现的两个问题。
cathoy
JVM | 基于openJDK源码深度拆解Java虚拟机
引言在上一篇文章中,我通过探讨类的生命周期,为你详细解析了类在加载进JVM时的全过程。当然,这仅仅只是JVM虚拟机的冰山一角,像执行引擎的动态编译、垃圾回收系统的内存管理、本地方法接口的与本地库的交互,以及本地方法库的结构和功能等诸多核心内容还未涉及。 本篇文章将为你展开JVM的完整画卷,不仅深入探索上述的组成部分,还将整个系统之间的关系和交互机制进行完整梳理,让我们开始吧!堆中的对象在进一步讲解JVM虚拟机之前,我想继续探讨一下上篇的主角——对象,并将分析延展得更深入一些。 我们来回顾下:上篇文章中我们讨论了,在类完成初始化并开始实例化的时候,JVM会为我们分配一个Building对象。你看: 在这个过程中,除了初始化数据,还会创建对象头。对象头是什么?它包含了哪些信息?除了对象头,对象内存结构中还隐藏了哪些内容?这些内容又如何影响对象的访问和操作呢?我们来深入分析下。对象内存结构对象的内存结构由对象头、实例数据、对齐填充组成;我把上面的Building实例对象放大,你看: 接下来我们一个一个分析。对象头Mark Word:存储对象的锁信息、哈希码、垃圾收集状态等。Klass Pointer:指向对象所属类的元数据的指针,可以访问类的方法、字段信息等。数组长度(如果是数组对象):如果对象是数组,则此字段存储数组的长度。实例数据字段:对象的所有字段值都存储在这里,包括原始类型字段和引用类型字段。对齐填充填充字节:添加一些额外的字节,使对象进行对齐,64位的操作系统对象大小应为8的倍数。看到对象我们可以用jol工具(JVM对象布局的工具)来看到它们的内存占用情况。我们来看下如何使用: 首先在pom.xml引入依赖: <dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.14</version>
</dependency>执行如下代码:Object obj = new Object();
System.out.println(ClassLayout.parseInstance(obj).toPrintable());以JDK8,默认开启压缩指针的情况下,我们可以看到这个结果:java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
Disconnected from the target VM, address: '127.0.0.1:9689', transport: 'socket'我们在上面简单的创建了一个Object对象;其中8字节为MarkWord,另外4个字节为KlassPointer,为了使其对齐为8的倍数,最后4字节为对齐填充数据。对象与JVM的关系对象的内存结构是JVM中的一个核心概念。它连接了许多JVM的组件,例如类加载器、执行引擎、垃圾收集器等,并影响了对象创建、访问和管理的性能。了解对象的内存结构有助于深入理解Java程序的行为。结合前面几篇文章,我们把对象的生命周期串起来: 类加载:当首次访问一个类时(例如通过new关键字创建实例),JVM会将该类的字节码加载到内存中。这一过程由类加载子系统完成,并包括了加载、链接(验证、准备和解析)和初始化三个主要阶段。 对象实例化:使用new关键字创建对象时,会先在堆中为该对象分配内存空间,并进行零值初始化。然后会设置对象头信息(包括类的元数据指针、哈希码等)。之后,JVM会调用对象的构造函数<init>进行字段等的初始化。 方法调用:对象的方法调用涉及执行引擎。执行引擎会解释或通过JIT编译器将字节码转换为本地代码执行。是JVM的核心部分,也是实现Java的跨平台特性的关键。 垃圾回收:当对象不再被引用时,垃圾收集器会回收这些对象的内存空间。这是JVM自动管理内存的方式,可以自动回收不再使用的内存。 本地方法调用:如果Java代码需要调用本地(例如C或C++编写的)方法,可以通过**Java Native Interface(JNI)**实现。这是Java与本地代码进行交互的标准机制。JVM虚拟机全览基于上面的完整流程,我画了一张图: 我在图中为你标注序号,接下来,我们来分析下:① 类加载器子系统与元空间的连接箭头含义:类加载器负责将类文件加载到JVM中,类的结构信息被存储在元空间中。具体作用:元空间存储了类的元数据,如类名、访问修饰符、字段、方法等。当类加载器加载类时,它将这些信息存入元空间。② 执行引擎与运行时数据区的连接箭头含义:执行引擎负责执行字节码,其操作涉及到运行时数据区的多个部分。具体作用:执行引擎从程序计数器中获取要执行的字节码指令地址,操作虚拟机栈来执行Java方法,与堆进行交互以操作对象实例等。③ 执行引擎与本地方法库的连接箭头含义:执行引擎可以调用本地方法库中的本地方法。具体作用:对于使用native关键字标记的方法,执行引擎会调用本地方法库中的相应实现。④ Java Native Interface(JNI)与本地方法库的连接箭头含义:JNI允许Java代码与本地代码进行交互。具体作用:通过JNI,Java代码可以调用本地方法库中的方法,并且本地代码也可以调用Java代码中的方法。⑤ 垃圾回收系统与堆的连接箭头含义:垃圾回收系统负责管理和回收堆内存。具体作用:垃圾回收系统定期检查堆中的对象,确定哪些对象不再被引用并可以安全回收。完整的画卷已经平铺其上并勾勒出路线图,我们再深入源码再进一步探索其中奥妙基于源码分析JVM虚拟机我所查看的openJDK源码是 jdk8-b120 分支的源码,如果想进一步探索其中结构,可以将其下载到本地。好,我们开始吧!类加载器类只有加载进内存中,才能工作。而揽起加载类的重任就由类加载器(ClassLoader)来完成。我用三篇文章来向你介绍,足见其中重要。理所应当的,分析JVM虚拟机源码就不能脱离类加载器。 当一个新的类加载器被创建并开始加载类时,系统会为其分配一个新的ClassLoaderData实例。来,我们源码说话:文件位置:src/hotspot/share/classfile/classLoader.cpp代码位置:InstanceKlass* ClassLoader::load_class(Symbol* name, bool search_append_only, TRAPS) {
//...省略
// 检查类是否需要进行字节码验证。
stream->set_verify(ClassLoaderExt::should_verify(classpath_index));
// 创建一个空的ClassLoaderData
ClassLoaderData* loader_data = ClassLoaderData::the_null_class_loader_data();
// 代码安全相关
Handle protection_domain;
// 准备类加载信息
ClassLoadInfo cl_info(protection_domain);
// 从流中创建对象,返回InstanceKlass示例类引用
InstanceKlass* result = KlassFactory::create_from_stream(stream,
name,
loader_data,
cl_info,
CHECK_NULL);
result->set_classpath_index(classpath_index);
// 返回类示例
return result;
}我列举了一些关键代码,你可以看到,类在加载的时候确实创建了一个空的ClassLoaderData。这个结构非常重要,我们来分析下。ClassLoaderData这个类是在C的堆上分配的class ClassLoaderData : public CHeapObj<mtClass>,我们简单过一下头文件,发现一些有意思的结构: // 类加载器关联的元空间
ClassLoaderMetaspace * volatile _metaspace;
// 类加载的对象句柄,持有管理Java对象
OopHandle _class_loader;
Klass* _class_loader_klass;
Symbol* _name;
// 提供一个可以用于遍历所有类加载器的结构,看来底层是使用链表来组织
void set_next(ClassLoaderData* next);
ClassLoaderData* next() const;看完上面的代码以及注释,我们继续。 你可以看到元空间引用,当然,这也是情理之中。我们需要有个空间来存储类元数据。你还记得有哪些数据被存放于元空间吗?我们接着往下看元空间对象创建除了和堆产生直接的联系,和元空间之间的若有若无的关系总是让人难以捉摸。我们简单的通过类加载源码发现它的踪迹。接下来,我将从源码的角度深入为你分析元空间结构,以加深对其的印象。我们回忆一下,我在前几篇文章中提到,类加载到对象创建的过程中有一些内容要被放入元空间中, 网上的说法五花八门,我们来看看源码中是怎么定义的,既然是元空间的内容自然少不了要继承自MetaspaceObj,我们按图索骥,有如下几个结构://类的元数据
metaData
// 常量方法,进一步解读就是不可变的方法,里面包含一些字节码等等结构。
constMethod
// 常量池缓存,可以说是常量池的进阶版了,或者说是运行时常量池。
cpCache
// 记录类型的组件
recordComponent
// 符号,一种特殊的字符串类型,用来记录一些名称,后面会讲到
symbol
// 和CDS有关,这里就不讨论了。
filemap
// 注解相关的东西
annotations
// 数组类
array我们一一对应下:类的元数据:类的名称、父类、实现的接口、方法信息、字段信息等,也包括 静态变量,常量池。字节码常量池:类文件中的字面量和符号引用等内容,它也属于类的元数据。运行时常量池:这是一个在类加载到内存后Java虚拟机为它们分配的一个动态结构,。总结一下,其实元空间包括这三类:类的元数据,字节码,运行时常量池;好,趁热打铁,我们来分析下类的元数据类的元数据Klass文件位置:src/hotspot/share/oops/klass.hpp 代码结构:class Klass : public Metadata {
protected:
// 超类指针,非常关键;用于确认继承,具体调用哪个版本的类,类型检查(instanceof)方法等。
Klass* _super;
// 类加载器数据,每个类加载器都有其自己的命名空间,这意味着不同的类加载器可以加载名字相同但内容不同的类。这个指针让JVM可以追踪哪个类加载器加载特定的Klass。
ClassLoaderData* _class_loader_data;
const KlassKind _kind;
// 符号引用名
Symbol* _name;
OopHandle _java_mirror;
int _vtable_len;
AccessFlags _access_flags;
// ... (其他成员)
};看到_class_loader_data是不是有一种恍然大悟的感觉?我在 基于类加载器的完全实践 中提到命名空间的概念,并通过一个例子告诉你,两个类加载器加载的同名类对象obj1不等于obj2。其底层是两个类加载器拥有不同的类加载数据,或者说是不同的元空间。InstanceKlassKlass只是一个基类,以Building类为例。它在元空间中是InstanceKlass,我们来分析下这个结构: // 注解信息
Annotations* _annotations;
// 包信息
PackageEntry* _package_entry;
// 生成的数组类型
ObjArrayKlass* volatile _array_klasses;
// 内部类
Array<jushort>* _inner_classes;
// 常量池
ConstantPool* _constants;
// 类的状态,例如这个类初始化完成状态,或者未被初始化;
volatile ClassState _init_state; // state of class
// 引用类型,软引用,弱引用等。
u1 _reference_type; // reference type
// 各种标志位
InstanceKlassFlags _misc_flags;
// 监视器
Monitor* _init_monitor; // mutual exclusion to _init_state and _init_thread.
// 当前线程
JavaThread* volatile _init_thread; // Pointer to current thread doing initialization (to handle recursive initialization)我把一些重要的结构列举出来了, 你会发现当你知道类的底层结构后,一些概念会变得非常清晰。接下来,我会把一些重要的结构详细为你讲解:_reference_type:这个成员变量实际上是用来跟踪类实例的引用类型。为垃圾回收提供依据。如果你不想要让这个对象存活时间太长,可以使用弱引用, 在下次GC时把垃圾进行回收。_init_state: 要判断这个类是否初始化完成,可以根据这个成员变量进行判断。_init_monitor:用来保证在一个类加载器下多线程不会执行多次<clint>静态初始化方法。4._init_thread: 用来辅助保证静态初始化方法只能有一个线程执行一次。在注释中doing initialization (to handle recursive initialization) 也明确说明,它是为了处理递归初始化。我们考虑这样一个场景,一个类的静态初始化器调用了另一个方法,而这个方法又触发了该类的主动使用。这会再次尝试初始化同一个类。_init_thread字段可以帮助检测这种递归初始化,并确保不会尝试重新初始化同一个类。常量池 VS 运行时常量池有些人可能会混淆这两个概念,我在这里解释一下:我们在表述某一特定的常量池时,往往会省略定语。我认为表述成某某类的常量池,更加洽当一些。例如:Building类的常量池。常量池和运行时常量池在底层指的是同一种数据结构。它的区别在于省略的定语是否处于使用或者运行的状态。我们在前面已经说过。当我们的字节码文件被加载,链接时会产生符号引用。而在类被使用的时候则会产生直接引用。这就是简单区分两者异同所在。虽然我在这里把它们放在一起讨论,但是在底层结构中,常量池属于元数据。而运行时常量池则属于元空间。这两个类心虽相同,但奈何职责不同。接下来,我们通过源码来深入分析常量池。文件位置:src/share/vm/oops/constantPool.hpp代码结构:class ConstantPool : public Metadata {
private:
// 常量池条目的数量
int _length;
// 指向持有这个常量池的类的指针(属于这个实例类的常量池)
InstanceKlass* _pool_holder;
// 常量池缓存
ConstantPoolCache* _cache; // the cache holding interpreter runtime information
// ... (其他成员)
};常量池条目放在哪里呢?在JVM中常量池条目用cp_info 表示,全局搜索代码发现它只看到Java的实现。当然并不妨碍理解,部分代码如下:for(ci = 1; ci < len; ci++) {
int cpConstType = tags.at(ci);
// write cp_info
// write constant type
switch(cpConstType) {
case JVM_CONSTANT_Utf8: {
// ...
break;
}
case JVM_CONSTANT_Unicode:
throw new IllegalArgumentException("Unicode constant!");
case JVM_CONSTANT_Integer:
// ...
break;
case JVM_CONSTANT_Float:
// ...
break;
case JVM_CONSTANT_Long: {
// ...
break;
}
case JVM_CONSTANT_Double:
// ...
break;
case JVM_CONSTANT_Class: {
// ...
break;
}
// case JVM_CONSTANT_ClassIndex:
case JVM_CONSTANT_UnresolvedClassInError:
case JVM_CONSTANT_UnresolvedClass: {
// ...
break;
}
case JVM_CONSTANT_String: {
// ...
break;
}
// all external, internal method/field references
case JVM_CONSTANT_Fieldref:
case JVM_CONSTANT_Methodref:
case JVM_CONSTANT_InterfaceMethodref: {
// ...
break;
}
case JVM_CONSTANT_NameAndType: {
// ...
break;
}
case JVM_CONSTANT_MethodHandle: {
// ...
break;
}
case JVM_CONSTANT_MethodType: {
// ...
break;
}
case JVM_CONSTANT_InvokeDynamic: {
// ...
break;
}
default:
throw new InternalError("Unknown tag: " + cpConstType);
} // switch
}这些条目也可以借助插件,例如:jclassLib来看到其中条目。我在文章后面也有介绍。接下来我们看下运行时常量池的结构。文件位置:src/hotspot/share/oops/cpCache.hpp代码结构: // 条目长度
int _length;
// 常量池引用
ConstantPool* _constant_pool;
// 解析过的符号引用句柄
OopHandle _resolved_references;
// 映射结构,用于跟踪被解析的引用
Array<u2>* _reference_map;
// 对于动态类型语言的支持,显然不是为Java准备的,像Groovy和Ruby支持动态类型语言
Array<ResolvedIndyEntry>* _resolved_indy_entries;
// 已经解析的字段引用条目
Array<ResolvedFieldEntry>* _resolved_field_entries;这次,我们的直接引用是存储在源码同文件中的ConstantPoolCacheEntry类结构中。设计常量池符号引用延迟解析策略符号引用解析往往比较耗时,我们可以采用懒加载机制。当类被加载,但是还未被使用的时候,可以延迟加载。符号引用在第一次使用时被解析,并缓存解析结果。使用缓存思想:分离的符号引用和直接引用看过源码才知道其实直接引用并不在常量池中,而是在常量池缓存cpCache中。通过结构_resolved_references 来关联其解析的引用。它是一个运行时的数据结构,可以说它是ConstantPool的“缓存”版本。但是缓存并不能让它变得更快,它只是在代码层面做的“缓存”,我们可以通过代码了解它的思想。为了加深你理解,我画了一张图: 这是对象创建中获取方法引用的图,你可以结合源码进行体会。看到常量池我们可以使用javap指令和插件jclassLib看到静态的常量池。后者只需要在IDE中安装插件即可查看。效果如下: 如果你想要安装该插件可以查看网上的相关教程,这里就不赘述了。假如我想看Building类的详细信息,可以在console端,输入如下命令:// 在当前目录下的Building.class
javap -verbose .\Building.class输出内容如下: // ...省略
Constant pool:
#1 = Methodref #17.#54 // java/lang/Object."<init>":()V
#2 = Fieldref #55.#56 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #57 // 建筑蓝图已被创建!
#4 = Methodref #58.#59 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Fieldref #7.#60 // org/kfaino/webTemplate/jvm/Building.floorCount:I
#6 = Fieldref #7.#61 // org/kfaino/webTemplate/jvm/Building.constructionYear:I
#7 = Class #62 // org/kfaino/webTemplate/jvm/Building
#8 = Methodref #7.#54 // org/kfaino/webTemplate/jvm/Building."<init>":()V
#9 = Class #63 // java/lang/StringBuilder
#10 = Methodref #9.#54 // java/lang/StringBuilder."<init>":()V
#11 = String #64 // Building2{floorCount=
#12 = Methodref #9.#65 // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#13 = Methodref #9.#66 // java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
#14 = Methodref #9.#67 // java/lang/StringBuilder.append:(C)Ljava/lang/StringBuilder;
#15 = Methodref #9.#68 // java/lang/StringBuilder.toString:()Ljava/lang/String;
#16 = Methodref #17.#69 // java/lang/Object.getClass:()Ljava/lang/Class;
#17 = Class #70 // java/lang/Object
// ...省略文中重要部分解析元数据和元空间"元"(Meta)在许多上下文中是一个前缀,通常意味着“超越”或“更高级别”。当我们在计算机和信息科技领域讨论“元”时,我们通常是在讨论关于数据的数据或关于结构的结构。 接下来,我为你解释这两个关键名词:元数据(Metadata):元数据是关于数据的数据。它描述了数据的结构、含义、来源和其他与数据相关的信息。例如,一张照片的元数据可能包括拍摄日期、相机型号、曝光设置等。类的元数据描述了类的结构,包括它的方法、字段、父类等。元空间(Metaspace):在Java中,元空间是OpenJDK 8引入的,用于替代之前版本中的永久代(PermGen)。元空间的目标是存储JVM加载的类定义的元数据。元空间的名字意味着这是一个“关于空间的空间”。在这个情境下,它存储的是类定义,而类定义本身定义了对象在Java堆中的布局和行为。对象头中的klass指针你会发现我在介绍对象结构的时候有提到** Klass Pointer ** ,其中有何玄机?很简单,告诉JVM这个对象是哪个类加载器加载,元数据从哪里取,用于快速关联的埋点。 站在设计者的角度,我们思考它的优点:效率:JVM可以迅速知道这个对象是哪个类的实例,对方法调用、类型检查、反射等操作非常之关键。节省空间: 相同类的实例共享同一个Klass结构。而不是挤在堆内存中。弱引用的应用弱引用的目的是在内存紧张的情况下。不希望一些对象的存活时间过长,而在下一次垃圾回收时被回收。我们看下如何使用:Map<WeakReference<Key>, Value> cache = new HashMap<>();上面只是一个简单的示例,我们想象一下这样的场景: 当有一个资源被释放后,需要在释放动作之后做一些清理工作。你可能会想到用finalize 。但是通常并不建议你这么做。因为可能会导致不可预测的延迟。我们可以借助ReferenceQueue 来实现,代码如下:class Resource {
private String id;
public Resource(String id) {
this.id = id;
}
}
public class WeakReferenceWithQueueDemo {
public static void main(String[] args) throws InterruptedException {
// WeakHashMap<Object, Object> objectObjectWeakHashMap = new WeakHashMap<>();
ReferenceQueue<Resource> referenceQueue = new ReferenceQueue<>();
Map<WeakReference<Resource>, String> weakReferences = new HashMap<>();
Resource resource = new Resource("RESOURCE_1");
WeakReference<Resource> weakRef = new WeakReference<>(resource, referenceQueue);
weakReferences.put(weakRef, "RESOURCE_1");
// 清空强引用,只保留弱引用(试试把这里注释,你就看不到后面的打印语句了)
resource = null;
System.gc();
Thread.sleep(1000);
Reference<? extends Resource> removed;
// 检查ReferenceQueue
while ((removed = referenceQueue.poll()) != null) {
String id = weakReferences.remove(removed);
if (id != null) {
System.out.println("Resource with ID: " + id + " 被垃圾回收了,我们来做一些额外的清理工作....");
}
}
}
}执行结果如下:Resource with ID: RESOURCE_1 被垃圾回收了,我们来做一些额外的清理工作....
Process finished with exit code 0这个引用队列确实捕获到资源被释放的事件。常见面试题详细描述Java对象在堆中的内存结构,包括对象头和实例数据的内容你了解JVM虚拟机吗,它包含哪些部分?描述Java的常量池。它存储了哪些信息?什么是弱引用,以及它的用途是什么?总结本篇完毕,我们来回顾下:在Java中,一切皆为对象。所以我们从对象出发,探索对象的内存结构。通过其设计的结构关联到JVM虚拟机的其它组件。一步步的解构这个JVM系统,最终掌握完整的JVM虚拟机。希望以上文章对你有所启发,感谢阅读。
cathoy
JVM | Java执行引擎结构及工作原理
引言1.1Java虚拟机(JVM)和其复杂性在我们先前探讨的文章中,我们已经深入到了Java虚拟机(JVM)的内部,透视了其如何通过元空间存储类的元数据和字节码。JVM的设计初衷是为了实现跨平台兼容性,但随着时间的推移,为了去满足性能和优化的需求,它的结构变得越来越复杂。1.2执行引擎的角色:为什么保留字节码JVM中的元空间确实包含了大量的元数据,这些元数据为运行时提供了关于类、方法和字段的重要信息。但为什么在有了这么丰富的元数据之后,JVM还需要保留字节码呢?答案就在执行引擎。执行引擎是JVM的核心部分,它负责将字节码翻译为可以在特定硬件上运行的机器代码。但是,这并不是一次性的过程。为了提高性能,JVM会使用Just-In-Time (JIT) 编译技术,将“热点”代码段编译成机器代码,从而大大加速程序的执行速度。 因此,尽管元数据为JVM提供了关于类和方法的大量信息,但字节码的存在是为了允许执行引擎进行即时编译优化。这个细节不仅揭示了JVM的优雅设计,也为我们展现了Java为什么能在保持跨平台特性的同时,还能提供出色的性能。在本篇文章中,我们将更深入地探讨执行引擎,了解其如何与其他JVM组件协同工作,以及如何利用现代硬件的特性来提高Java应用的性能。基本结构与组件在深入探索Java虚拟机(JVM)的执行引擎之前,我们首先需要了解其主要组成部分和功能。执行引擎是JVM中的一个核心组件,负责管理和执行Java字节码。它主要由以下部分组成: 2.1 字节码解释器在Java程序初次运行时,大多数的字节码是由解释器执行的。解释器逐行读取字节码并翻译成本地机器指令执行。这种方法虽然跨平台,但效率相对较低,因为每次执行相同的字节码都需要重新解释。 字节码解释器的核心是一个巨大的switch-case结构,用于处理每一个Java字节码指令。源码位于:bytecodeInterpreter.cpp。部分源码如下,是不是很熟悉?//...
CASE(_bipush):
SET_STACK_INT((jbyte)(pc[1]), 0);
UPDATE_PC_AND_TOS_AND_CONTINUE(2, 1);
/* Push a 2-byte signed integer constant onto the stack. */
CASE(_sipush):
SET_STACK_INT((int16_t)Bytes::get_Java_u2(pc + 1), 0);
UPDATE_PC_AND_TOS_AND_CONTINUE(3, 1);
/* load from local variable */
CASE(_aload):
VERIFY_OOP(LOCALS_OBJECT(pc[1]));
SET_STACK_OBJECT(LOCALS_OBJECT(pc[1]), 0);
UPDATE_PC_AND_TOS_AND_CONTINUE(2, 1);
//...上述代码展示了解释器如何处理不同的字节码。对于每一种字节码,解释器都有相应的操作来翻译并执行。逐行解析字节码也太慢了,有办法优化吗?2.2 JIT编译器(Just-In-Time Compiler)为了提高执行效率,JVM引入了JIT编译器。当某部分代码被频繁执行(称为“热点”代码)时,JIT编译器会将这部分字节码编译成本地机器代码。这样,下次执行时,JVM可以直接运行这段已编译的机器代码,从而大大提高执行速度。 JIT编译器的工作是将热点代码编译为本地机器代码。这就是为什么我上篇的元空间中既有类元信息又要有字节码。对于一个经常被调用的方法,JIT可能会这样处理,我写了一个伪代码,你可以看下:if (method->is_hot()) {
// 通过C2编译成机器码
compile_method_with_c2_compiler(method);
} else {
// 解释
interpret_method(method);
}C2是啥?我们接着往下看JVM采用分层编译策略,其中C1和C2是两个主要的编译器。C1,也称为客户端编译器,提供快速编译,但优化程度较低。而C2,也称为服务器编译器,提供高级优化,但编译速度较慢。 在OpenJDK中,有两个主要的JIT编译器:C1和C2。C1编译器快速,但优化较少,而C2编译器则进行深度优化。if (UseC1Compiler) {
// 使用C1编译器
} else if (UseC2Compiler) {
// 使用C2编译器
}2.3 垃圾收集器尽管垃圾收集主要与内存管理相关,但它与执行引擎紧密相连。执行引擎需要与垃圾收集器协同工作,以确保在执行Java代码时,不会因为内存问题而中断或崩溃。字节码解释器3.1 和解释器相关的其它类当Java源代码被编译后,它转化为一个或多个字节码文件(.class文件)。这些文件包含了JVM可解释和执行的指令集。Java字节码是源代码和机器代码之间的中间表示形式。 前面我简单介绍了一下字节码执行核心BytecodeInterpreter,我们来介绍一下其它角色:3.1.1 templateInterpreterGeneratortemplateInterpreterGenerator是templateTable的生成器,它为每种字节码指令生成对应的机器代码模板。这些模板在JVM启动时只生成一次,并存储在templateTable中,以供BytecodeInterpreter在后续执行时使用。3.1.2 templateTabletemplateTable为字节码指令提供了相应的机器代码模板。这意味着,对于每种字节码,templateTable都有一个预定义的、优化过的机器代码序列,该序列可以直接在物理硬件上执行。非常重要!总而言之,BytecodeInterpreter 是处理 JVM 字节码逻辑的核心,而 templateTable 提供了对应的机器代码模板,这些模板是由 templateInterpreterGenerator 生成的。3.2 解释器的工作原理3.2.1 初始化过程:1. 启动阶段:当JVM启动并初始化其核心组件时,templateInterpreterGenerator也被触发。2. 模板生成:templateInterpreterGenerator的主要任务是为每个Java字节码生成一个对应的机器代码模板。这些模板定义了如何在特定的硬件和操作系统上执行这些字节码。3. 存储模板:生成的模板随后被存储在templateTable中,为解释器在运行时使用。3.2.2 运行时:1. 读取字节码:当Java应用程序运行时,BytecodeInterpreter开始逐条读取并解释字节码。2. 查找模板:对于每条字节码,BytecodeInterpreter会查询templateTable以找到对应的机器代码模板。3. 执行机器代码:使用找到的机器代码模板,BytecodeInterpreter将执行对应的操作,从而实现字节码的功能。这样,通过在启动阶段预先生成模板,JVM避免了在运行时为每个字节码重新生成机器代码,从而提高了效率。我画了一张图,你可以看下: 3.2.3 字节码解释器会在哪些情况下会解释字节码?字节码解释器在Java虚拟机(JVM)中主要是用来执行Java字节码的。当JVM启动时,它默认使用解释器来逐条解释并执行字节码。但在实际的JVM实现中,尤其是像HotSpot这样的高性能JVM,除了解释执行,还会使用JIT编译器来将某些字节码编译成本地机器代码以提高性能。因此,字节码解释器主要在以下几种情况下解释执行字节码:1. 启动初期:当Java程序刚启动时,大多数字节码都会被解释器解释执行。运行一段时间之后,JVM尚未收集到足够的运行时信息来决定哪些代码应当被JIT编译。2. 启动之后还是没成为"热点"的代码:JVM使用分析器来监控程序的执行并识别所谓的“热点”代码,即经常被执行的代码片段。那些不被视为“热点”的代码通常会继续由解释器解释执行。3. "热点"代码的回退:在某些情况下,已经被JIT编译过的代码可能需要回退到解释执行。我给你举一个例子:有一个方法m在类A中,当前它在JIT编译后被直接内联到其他方法中。随后,一个新的类B被加载,它继承自A并覆盖了方法m。现在,每次调用A的子类的m方法可能需要调用B中的版本,而不是A中的版本。由于之前的内联优化是基于错误的假设,JVM需要撤销这一优化,使得方法调用可以正确地进行。3.2.4 如何将字节码转换为本地机器代码解释器并不会将字节码持久性地转换为机器代码。相反,它在运行时逐条读取字节码,并根据每条指令执行相应的操作。因此,每次程序运行时,字节码都需要被重新解释。 这与JIT编译器形成了对比,JIT编译器会在运行时将热点代码编译为机器代码,从而提高性能。 解释器知道如何将其转换为一系列的机器指令,我们在上文介绍的 templateTable 里面记录了机器代码模板,这非常重要。换而言之,执行引擎只有解释器它也可以工作,只是编译器提供了一种优化手段,仅此而已。我们来看下这两种编译上的区别吧~3.2.5 解释执行与编译执行的区别解释执行:字节码在每次执行时都被解释器逐条解释并执行。这种方式的主要优点是移植性,但代价是性能较低。编译执行:JIT编译器将频繁执行的字节码片段(热点代码)编译为机器代码,并存储起来。当这些代码片段再次被调用时,JVM可以直接执行已编译的机器代码,而无需再次解释字节码,从而大大提高了执行效率。总结来说,虽然字节码解释器为Java提供了出色的移植性,但从性能的角度看,JIT编译器是更优的选择。然而,在这种情况下:JVM首次启动或当应用只运行一小段时间时,解释执行就派上用场了。JIT编译器4.1 JIT编译的基本概念JIT,即“Just-In-Time”编译器,是Java虚拟机的一部分,其主要任务是将热点代码(即频繁执行的代码)从字节码转化为本地机器代码。这种转化过程称为“即时编译”。与传统的“Ahead-Of-Time”编译相反,JIT编译只在运行时进行。4.2 为何需要JIT?性能提升:机器代码通常执行速度比字节码快。当JVM检测到某段字节码被频繁执行(例如,循环中的代码或被频繁调用的方法),JIT编译器将这些字节码编译为机器代码,从而提高执行速度。平台独立性:Java字节码是跨平台的,可以在任何支持Java的平台上执行。JIT编译器允许这些字节码在特定的硬件和操作系统上转化为高效的机器代码。动态优化:由于JIT编译在运行时发生,编译器可以利用运行时的信息进行更有效的优化,如内联缓存、逃逸分析等。4.3 如何确定代码的“热点”OpenJDK中的JIT编译器使用内部的分析器(Profiler)来跟踪代码的执行频率。“热点”代码的晋升主要通过两个指标:方法调用次数和循环的回边次数。接下来,我们通过源码角度来分析以下。4.3.1 源码分析要找到方法调用次数和循环的回边次数,我们定位到methodData.hpp头文件,代码如下: // How many invocations has this MDO seen?
// These counters are used to determine the exact age of MDO.
// We need those because in tiered a method can be concurrently executed at different levels.
// MDO就是MethodDataObject
InvocationCounter _invocation_counter;
// Same for backedges.
InvocationCounter _backedge_counter;在templateInterpreterGenerator.hpp可以看到计数器增加的方法:void generate_counter_incr(Label* overflow);在这两个值溢出的情况下,设置状态位:// 处理回边计数器的溢出情况
void CompilationPolicy::handle_counter_overflow(const methodHandle& method) {
MethodCounters *mcs = method->method_counters();
if (mcs != nullptr) {
mcs->invocation_counter()->set_carry_on_overflow();
mcs->backedge_counter()->set_carry_on_overflow();
}
MethodData* mdo = method->method_data();
if (mdo != nullptr) {
mdo->invocation_counter()->set_carry_on_overflow();
mdo->backedge_counter()->set_carry_on_overflow();
}
}根据状态位决定是否使用JIT编译器。4.4 基于性能的优化策略JIT编译器使用多种优化策略来提高生成的机器代码的性能:1. 方法内联:这是将一个方法的内容直接插入到另一个方法中,从而避免方法调用的开销。例如,小方法和getter/setter通常会被内联。2. 循环展开:为了减少循环控制的开销,编译器可能会选择展开循环。3. 死代码消除:删除不会被执行的代码,从而减少代码量和提高执行效率。4. 常量传播:编译器试图将程序中的常量值传播到尽可能多的位置,从而使其它优化如死代码消除成为可能。4.5 实战:识别与优化热点代码实例背景:我们将实现一个简单的数字排序程序,使用插入排序算法。首先,我们将提供一个未优化的版本,然后观察其性能。接着,我们会对其进行优化,以体现如何使代码变成热点并提高效率。1. 初始版本:public class InsertionSort {
public static void sort(int[] arr) {
for (int i = 1; i < arr.length; i++) {
int key = arr[i];
int j = i - 1;
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}
public static void main(String[] args) {
int[] data = new int[10000];
for (int i = 0; i < data.length; i++) {
data[i] = (int)(Math.random() * 10000);
}
long startTime = System.currentTimeMillis();
for (int i = 0; i < 1000; i++) {
sort(data.clone());
}
long endTime = System.currentTimeMillis();
System.out.println("Duration: " + (endTime - startTime) + "ms");
}
}当我们多次运行上述代码,sort方法会被频繁调用,并且内部的while循环也会被大量执行,这使得它们很可能被JIT识别为热点代码。2. 优化: 为了提高效率,我们可以考虑以下几点:· 使用JVM参数查看JIT编译的方法:-XX:+PrintCompilation。这样,我们可以知道哪些方法被JIT编译。· 使用其他排序算法,如快速排序或归并排序,来优化我们的排序方法。· 预热JVM:在实际排序前,我们可以先进行几次预排序来使JIT编译器尽快识别并优化热点代码。结论: JVM会根据代码的运行情况动态地决定何时进行JIT编译。频繁执行的代码块更有可能被识别为热点代码,并被JIT编译成本地机器代码,从而提高程序的运行速度。通过观察和调整代码的执行方式,我们可以有效地促使JVM进行更多的即时编译,进一步提高代码的执行效率。4.6 实战:对比被JIT编译的热点代码与非热点代码的性能实例背景:我们将实现一个简单的程序,该程序计算从1到一个大数字(如10,000,000)的累加和。首先,我们将编写两个版本的累加函数:一个是循环版本,另一个是递归版本。循环版本很可能被JIT识别为热点代码并进行优化,而递归版本则可能不会。1. 循环版本:public static long sumUsingLoop(long n) {
long sum = 0;
for (long i = 1; i <= n; i++) {
sum += i;
}
return sum;
}2. 递归版本:public static long sumUsingRecursion(long n) {
if (n <= 1) {
return n;
}
return n + sumUsingRecursion(n - 1);
}测试性能:public static void main(String[] args) {
long n = 10000; // 小点的数字用于递归,以免栈溢出
long startTime = System.currentTimeMillis();
sumUsingLoop(10000000); // 更大的数字用于循环版本
long endTime = System.currentTimeMillis();
System.out.println("Loop Duration: " + (endTime - startTime) + "ms");
startTime = System.currentTimeMillis();
sumUsingRecursion(n); // 小数字用于递归
endTime = System.currentTimeMillis();
System.out.println("Recursion Duration: " + (endTime - startTime) + "ms");
}结论: 循环版本由于被频繁执行,JIT编译器将其识别为热点代码并进行即时编译。因此,即使处理的数字比递归版本大得多,它仍然运行得更快。而递归版本由于函数调用栈的开销,以及它不太可能被JIT编译优化(尤其是深度很大的递归),其性能会较差。分层编译当我们谈论JVM的JIT编译时,经常听到"C1"和"C2"这两个术语。这两者实际上是Java HotSpot VM的两种不同的JIT编译器。在JVM启动时,它们两者都会被启动。那么,为什么需要两种JIT编译器呢?5.1 为何需要分层编译1.快速启动与最大化性能: o C1(客户端编译器):这是一个更快的编译器,它可以很快地为应用生成最初的代码,帮助应用更快地启动。但是,它执行的优化是有限的。 o C2(服务端编译器):它执行更深入的优化,生成的代码执行速度更快,但编译时间较长。2.渐进式的优化: 当一个方法首次被执行时,它被C1编译器编译,并进行一些基本的优化。如果这个方法被频繁执行(即成为热点),C2编译器会重新编译它,执行更深入的优化。5.2 不同的编译层次1. 层次0:解释执行。这是当方法首次被调用时的默认层次。在这个阶段,没有任何JIT编译发生,只是简单的解释执行字节码。2. 层次1:简单的C1 JIT编译。当方法被执行了足够多的次数后,它会被C1编译器编译,带有轻量级的优化。3. 层次2和3:这两个层次都是C1编译,但是采用了更多的优化技术。4. 层次4:这是C2编译器的层次,带有所有的高级优化。5.3 如何选择编译级别JVM会根据方法的执行频率和方法的热度来决定何时以及如何进行编译。例如,当方法被执行了一定次数后,它可能首先被C1编译器编译(层次1)。随着它的执行次数继续增加,它可能会被升级到层次2或3。最后,如果它是一个真正的热点方法,它会被C2编译器编译(层次4)。5.4 总结分层编译是Java HotSpot VM为了在启动性能和长时间运行性能之间取得平衡而采用的策略。通过使用两个不同的JIT编译器,它确保了应用程序在启动时能够快速响应,并且在长时间运行时能够获得最大的性能。5.5 垃圾收集与执行引擎Java的自动内存管理,也就是垃圾收集(Garbage Collection, GC),是Java编程语言的核心特性之一。垃圾收集器负责自动检测并回收那些不再被程序引用的对象,从而防止内存泄漏。尽管垃圾收集与执行引擎在Java虚拟机内部的组织结构中属于不同的部分,但它们之间存在紧密的关联。5.5.1 垃圾收集的基本原理1. 引用计数:这是最简单的垃圾收集方法。每当一个对象的引用数增加或减少时,计数器都会相应地增加或减少。当引用数达到零时,对象被认为是垃圾,并被立即回收。但是,此方法存在循环引用的问题。2. 可达性分析:这是Java使用的主要方法。它从一组基础对象(如栈中的对象)开始,跟踪它们的引用链。未被跟踪到的对象被认为是垃圾。5.5.2 如何与执行引擎交互· 安全点:为了进行垃圾收集,JVM需要确保没有线程正在修改堆中的内容。为此,它会使所有的线程在所谓的"安全点"上暂停。安全点通常位于字节码指令的边界。· 卡表与写屏障:为了跟踪哪些对象已被修改,JVM使用了写屏障和卡表。当一个应用线程修改一个对象引用时,写屏障会被触发,并将相关的信息记录在卡表中。这些信息后来被用于增量垃圾收集。5.5.3 垃圾收集器的选择对性能的影响Java提供了多种垃圾收集器,每个都有其特定的用途和优点:· 串行收集器:简单、高效,但会暂停所有的应用线程。· 并行收集器:使用多线程来加速垃圾收集,适用于多核处理器。· CMS(并发标记-清除)收集器:减少暂停时间,但可能导致更多的CPU使用。· G1收集器:面向延迟,旨在提供高性能和可预测的暂停时间。 选择合适的垃圾收集器对于性能至关重要。例如,低延迟应用可能更喜欢使用G1或CMS,而需要最大吞吐量的应用可能选择并行收集器。5.5.4 总结垃圾收集是Java虚拟机的核心组成部分,它确保了内存的有效利用并防止了内存泄漏。尽管它与执行引擎是独立的,但两者之间的交互确保了Java程序的平稳运行。正确地理解和配置垃圾收集器可以大大提高应用的性能和响应时间。执行引擎的优化技术在了解了执行引擎的基本组成和工作方式后,本章将深入探讨执行引擎的一些关键优化技术。这些优化技术旨在确保Java代码运行得更快、更稳定。我们将探索以下几个主题:6.1 逃逸分析定义: 逃逸分析是一种确定对象是否可以被线程之外的代码访问的技术。优化的机会: o 栈上分配: 如果对象在方法内部创建并且不逃逸到方法之外,那么这个对象可能会被分配在栈上而不是堆上。 o 锁消除: 如果JVM确定某个对象只能被单个线程访问,那么关于这个对象的同步操作可以被消除。6.2 内联缓存定义: 内联缓存是一种优化动态分派调用的技术。如何工作: 当方法被调用时,JVM会记住最后一次调用该方法的对象的类型。如果下一次调用该方法的对象是相同的类型,那么JVM可以直接使用缓存的信息,从而避免查找方法的开销。单态内联缓存 vs. 多态内联缓存: 当缓存只存储一个类型时,称为单态内联缓存;当缓存支持多个类型时,称为多态内联缓存。想象你有一个习惯,那就是每天上班前去同一家咖啡店买咖啡。店员很快就认识了你,也知道你每天都点同样的咖啡。所以,当你走进咖啡店时,店员已经开始为你准备你喜欢的咖啡。这样,你几乎不用等待,就能迅速拿到咖啡并离开。这就是一个现实生活中的**“内联缓存”。店员(JVM)“缓存”**了你的选择(对象的类型和它的方法),以便在下次你访问时更快地为你服务。但是,如果某一天你改变了主意,想要点别的咖啡,店员就需要重新调整,准备新的咖啡给你。同样的,如果JVM遇到一个新的对象类型调用同一个方法,它也需要“清除”缓存并重新“学习”这个新的类型。如果你继续改变你的咖啡选择,店员可能会决定不再提前为你准备咖啡,而是等你告诉他们你的选择后再开始。这类似于JVM的“多态内联缓存”,当多个对象类型都调用同一个方法时,JVM会跟踪这些类型,并在运行时决定应该调用哪个版本的方法。6.3 动态链接与优化动态链接: 是指在运行时解析方法、字段和类引用的过程。动态优化: JVM可以根据程序的实际运行情况进行优化,例如,对于经常执行的代码路径,JVM可以应用更高级的优化。实际应用在理解了执行引擎的内部工作原理和结构之后,我们可以更好地知道如何在实际应用中利用这些知识。这一章节将探讨如何监控、诊断和优化JVM的执行性能。7.1 如何监控和诊断JVM的执行性能1. 使用JVM内置工具: o jstat: 用于监视JVM的统计信息。 o jmap: 提供堆转储、类统计信息等。 o jstack: 用于打印线程堆栈跟踪。 o jconsole: 图形化的监控工具,提供了关于内存使用、线程活动和JIT编译的信息。2. 开启JIT诊断:可以使用-XX:+PrintCompilation标志开启JIT编译日志,这会显示哪些方法被JIT编译。3. 使用专业的监控工具:如VisualVM、Arthas等,这些工具提供了更高级的监控、分析和故障排查功能。7.2 JIT编译器的优势及其使用建议1. 热点代码优化:JIT专门优化执行频率高的“热点”代码。了解其工作机制可以指导我们更有针对性地编写高效代码。2. 控制方法大小:过大的方法可能不会被JIT优化。为了获得最佳性能,应该考虑将长方法拆分成更小的、功能明确的子方法。3. JIT编译开关:在特定场景或系统中(如实时应用),可能需要关闭JIT优化。使用-Djava.compiler=NONE参数可以禁止JIT编译。4. 深入了解内联:JIT通过方法内联来提高执行效率。理解这一机制能帮助我们更好地组织和编写代码,从而充分利用JIT的优势。文中重要部分解析8.1 安全点 (Safepoint)在Java虚拟机执行过程中,为了某些操作(如垃圾收集)能够安全地进行,有时需要确保不会发生某些系统级的更改。例如,为了进行垃圾收集,需要确保没有线程正在或可能访问或修改堆内存中的对象。为了实现这个目的,JVM定义了所谓的“安全点”概念。8.1.1 什么是安全点?简而言之,安全点是那些线程暂停执行原有字节码的地方,转而执行一些特殊操作,例如垃圾收集,然后再恢复到原来的执行点。为什么选择字节码指令的边界作为安全点?字节码指令的边界是JVM中预定义的点,JVM知道在这些点上,线程不会执行任何会更改堆内存内容的操作。因此,这些点被视为“安全的”,可以进行垃圾收集或其他需要整个JVM暂停的操作。不是每一条字节码指令后面都是一个安全点。事实上,安全点的位置是由JVM的实现和其设置决定的。但为什么大部分安全点位于字节码指令的边界,而不是在指令的中间或其他地方?答案在于效率和简单性。如果JVM试图在字节码指令的中间插入一个安全点,这将使得JVM的实现更为复杂,并可能导致性能问题。相反,在指令的边界上设置安全点更为简单,易于管理,且效率更高。8.1.2 实际影响当JVM达到一个安全点时,所有正在执行的线程都会暂停。这种暂停可能会导致微小的性能开销,但它对于系统的整体健康和稳定性是必要的。 在实际应用中,开发者通常不需要关心安全点的位置或其存在。然而,对于那些关心JVM性能和内部工作机制的开发者来说,了解这些机制可以帮助他们更好地理解JVM的行为,以及为什么某些操作(如垃圾收集)需要所有线程暂停。8.1.3 源码分析1. Safepoint类: 这是OpenJDK中与安全点相关的核心类。你可以在src/hotspot/share/runtime/safepoint.hpp和.cpp中找到它。该类包含了许多与安全点同步相关的函数。2. SafepointMechanism类: 在src/hotspot/share/runtime/safepointMechanism.hpp和.cpp中,这个类处理安全点的具体机制,例如检查线程是否应该在安全点上暂停。3.安全点检查:在OpenJDK中,为了执行某些系统范围的操作(例如全局的垃圾收集),可能需要暂停所有Java线程。这些暂停发生在称为"safepoints"的特定代码位置。在CompileBroker.cpp中,有一个set_should_block()方法:void CompileBroker::set_should_block() {
assert(Threads_lock->owner() == Thread::current(), "must have threads lock");
assert(SafepointSynchronize::is_at_safepoint(), "must be at a safepoint already");
#ifndef PRODUCT
if (PrintCompilation && (Verbose || WizardMode))
tty->print_cr("notifying compiler thread pool to block");
#endif
_should_block = true;
}此方法会设置一个标志_should_block = true;,指示编译线程池或者线程应当被阻塞,不允许继续它的活动。一般是因为系统已经处于或正要进入一个安全点,并且希望确保在此期间不会有新的编译活动。4. 编译代码: 当使用JIT编译时,生成的机器代码也会在某些位置插入检查点,以便在必要时暂停执行并达到安全点。5. 线程状态: 在OpenJDK中,每个线程都有一个状态,该状态决定了线程是否可以立即到达安全点。例如,处于BLOCKED或WAITING状态的线程已经被视为在安全点上,而正在执行Java代码的线程需要到达下一个字节码边界才能达到安全点。总结经过前面的深入探讨,我们已经对Java执行引擎有了全面的了解。从其基本结构到其工作原理,再到其在实际应用中的表现,Java执行引擎是Java虚拟机中的核心组件之一。 回到我们一开始的讨论,Java虚拟机的元空间确实既存放了元数据也保存了字节码,这些都是为了更好地服务于执行引擎和JIT编译优化。知道这一点,我们更能理解JVM设计背后的深层次原因。
cathoy
并发编程 | 线程安全-编写零错误代码
一、引言在编程的世界里,线程安全问题是一个永恒的主题。当我们的代码在多线程环境下运行时,如何保证数据的一致性和正确性,避免各种奇怪的并发问题,是每一个开发者都需要面对的挑战。然而,对于这个问题,并没有一个固定的模板答案,因为正确的解决方案取决于具体的应用场景和需求。在这个并发的世界里,我们如何才能编写出零错误的代码呢? 在这篇博客中,我们将一起探索如何在并发环境下编写线程安全的代码。我们将通过深入理解并发中的基本概念,以及学习各种实用的并发工具和方法,帮助我们更好地理解和解决线程安全问题,从而提高我们代码的质量和健壮性,编写出真正的零错误代码。让我们开始这段并发编程的旅程吧。二、基础 | 理解线程不安全问题所在:可见性,原子性,有序性这三个问题(可见性,原子性,有序性)是由计算机系统的硬件架构、编译优化策略和操作系统的调度机制共同导致的,并不是Java语言本身的问题。不过,由于Java语言需要在这样的环境下运行,所以必须提供相应的机制来处理这些问题。我们来逐一讨论这三个问题的来源:可见性:现代计算机系统中,为了提高系统的性能,往往会将主内存中的数据复制到CPU的缓存中。多个CPU核心各自有自己的缓存,可能会同时复制主内存的同一个数据。当某个CPU对它缓存中的数据进行了修改,其他CPU由于无法立即看到这个修改,就产生了数据的可见性问题。(多级缓存设计带来的问题)原子性:对于复合操作(由多个步骤组成的操作),如果在执行完一部分步骤后,线程被操作系统调度出去,此时另一个线程执行了相同的操作,可能会导致数据的不一致。这就是原子性问题。(高级语言:CPU指令 = 1:N, 这时线程切换就会带来问题)有序性:为了提高程序的运行效率,编译器和处理器可能会对指令进行重新排序。虽然这种重排序不会改变单线程程序的执行结果,但在多线程环境下,由于各个线程可能看到不同的指令执行顺序,可能会导致数据的不一致。这就是有序性问题。(编译优化带来的指令重排序)三、基础 | Java在这方面的努力内存模型(Java Memory Model,简称JMM): 规定了Java程序在多线程环境中如何协调访问共享变量。 在Java中,为了解决并发编程中的可见性,原子性,和有序性问题,Java内存模型(Java Memory Model,JMM)和相关的并发库提供了很多机制和工具。可见性:在Java中,关键字volatile能保证变量的修改对其他线程立即可见,这是通过禁止指令重排序和强制从主内存(而不是CPU缓存)读取变量来实现的。原子性:Java提供了Atomic类(如AtomicInteger,AtomicLong等)来保证对变量操作的原子性。另外,synchronized关键字和Lock接口的实现类(如ReentrantLock)也能保证在同一时刻只有一个线程访问临界区,从而实现复合操作的原子性。有序性:Java的synchronized和volatile关键字能防止指令重排序。特别地,volatile还有一个双重作用:保证可见性和防止指令重排序。四、进阶 | 解读JMM内存模型JMM内存模型究竟是什么东西? 它为什么可以解决上面的问题?Java内存模型(JMM)是一个抽象的概念,它描述了Java程序中各种共享变量(堆内存中的对象实例、静态字段和数组)在多线程环境下如何交互,以及在并发操作时如何处理内存一致性问题。JMM的主要目的是定义程序中各个变量的访问规则,包括读取赋值、加载存储、锁定解锁等,以保证在多线程环境下,线程对共享变量操作的可见性、原子性和有序性。通过这些规定,JMM帮助开发者编写出正确、高效的并发程序。如果想要继续深入了解JMM内存模型,强烈建议阅读: Java内存模型关于happen-before原则的官方定义文档如下: 五、进阶 | JMM内存模型代码示例程序顺序规则:// 线程内按照程序顺序,先执行A后执行B
public class ProgramOrderExample {
public void execute() {
int A = 5; // 动作A
int B = A * 6; // 动作B,happens after A
}
}
监视器锁规则:// 监视器锁规则,解锁happens-before后续的加锁
public class MonitorLockExample {
private final Object lock = new Object();
public void method1() {
synchronized(lock) { // 加锁
// do something...
} // 解锁,happens-before下一次加锁
}
public void method2() {
synchronized(lock) { // 加锁,happens after上一次解锁
// do something...
}
}
}
volatile变量规则:// volatile变量规则,写操作happens-before后续的读操作
public class VolatileExample {
private volatile boolean ready = false;
public void writer() {
ready = true; // 写操作
}
public void reader() {
if (ready) { // 读操作,happens after写操作
// do something...
}
}
}
传递性规则:// 传递性规则
public class TransitivityExample {
private int A = 0;
private volatile boolean flag = false;
public void method1() {
A = 1; // 动作A
flag = true; // 动作B,happens after A
}
public void method2() {
if (flag) { // 动作C,happens after B
int temp = A * 5; // 动作D,happens after C,从而也happens after A
}
}
}
六、基础 | 掌握线程安全的方案现在设想一下,假如你是一位出租车司机,正在城市中快速穿梭。突然,一名情绪不稳定的乘客试图抢夺你的方向盘。时间暂停!你会如何应对这种情况呢?接下来,我将向你介绍三种应对策略:上策:尽力用言语安抚乘客,让他们放弃试图抢夺方向盘(共享变量)的想法。或者,在拒载乘客的情况下,预防这种事情的发生。中策:你可以始终保持警惕。在正常驾驶时,你的注意力分散在各处,但是当你发现乘客试图伸手去夺取方向盘的时候,你会立即用一只手(你是个经验丰富的司机,一只手就足以控制住车辆)抵挡住他们。下策:安装一个可开关的安全屏障,平时为了和乘客交流,可以将其打开。但在紧急情况下,你可以迅速启动这个安全屏障,使乘客无法触及到你的方向盘。基于以上三种策略,我们来看看Java是怎么做的。无同步方案没有共享就没有伤害, 尽量避免共享变量的使用。栈封闭 "栈封闭"是一种避免线程安全问题的编程技巧,它意味着只有一个线程可以访问特定的数据。当我们将对象的全部生命周期都限制在单个线程内,即该对象自始至终都不会逃逸出当前线程,那么我们就说这个对象被"栈封闭"了。这样一来,这个对象就完全由这个线程独占,不可能被其他线程访问,从而不存在线程安全问题。使用ThreadLocal 如果对ThreadLocal相关内容感兴趣,建议您阅读这篇文章:并发编程 | ThreadLocal - 线程的私有存储当然,在多数情况下上策是很难达成的,我们来看下中策非阻塞同步方案这种方式主要依赖硬件级别的原子操作实现,比如Java的Atomic类系列和java.util.concurrent包中的LockFree数据结构等。相比阻塞同步,非阻塞同步提供了更好的性能,因为它减少了线程间的等待。使用CAS理念实现的代码逻辑原子类例如:AtomicInteger (本质也是CAS, 但是这个是java提供的类)为了加深你对它的印象,我在文中展示了代码,你可以看一下:import java.util.concurrent.atomic.AtomicInteger;
class Counter {
private AtomicInteger count = new AtomicInteger();
public void increment() {
count.incrementAndGet();
}
public int getCount() {
return count.get();
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
// 创建1000个线程,每个线程对count进行1000次自增操作
Thread[] threads = new Thread[1000];
for (int i = 0; i < 1000; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
counter.increment();
}
});
threads[i].start();
}
// 等待所有线程执行完成
for (int i = 0; i < 1000; i++) {
threads[i].join();
}
// 输出最终的count值
System.out.println(counter.getCount()); // 输出1000000
}
}
需要注意的是CAS可能会造成ABA问题, 虽然多数情况下在我们工作中并不影响, 但是假如线上出现问题, 可以考虑这方面的排查思路互斥(阻塞)同步方案这种方式通常使用锁或同步块来实现,它能保证同一时间只有一个线程能访问临界区(共享资源)。当一个线程在执行临界区的代码时,其他线程必须等待。常用的有以下两种:syncronized关键字ReentrantLock为了加深你对它的印象,我在文中同样为其展示了代码,你可以看一下: synchronized示例: // 第一种:锁的是当前的是实例对象
// ...略
private int count = 0;
// 同步实例方法
public synchronized void increment() {
count++;
}
// 第二种:锁的是当前类Class对象
// ...略
private static int count = 0;
// 同步静态方法
public static synchronized void increment() {
count++;
}
// 第三种:锁的是lock对象,作用于synchronized修饰的代码块
private int count = 0;
private Object lock = new Object();
public void increment() {
// 同步代码块
synchronized (lock) {
count++;
}
}
ReentrantLock 是JDK1.5之后的又一重大更新。与synchronized关键字相比,ReentrantLock提供了更高级的锁定机制,包括更灵活的锁获取和释放、可中断的锁获取、公平和非公平锁策略以及条件变量等。我将在并发工具类之后为你讲解。使用锁可以有效地保证数据的安全性和一致性,防止出现数据竞争的情况。然而,使用锁也有一些潜在的性能问题。锁定会导致线程阻塞,即当一个线程获取不到锁时,它会被挂起,直到锁可用为止。这会导致线程调度开销和上下文切换开销,从而影响系统的整体性能。此外,如果不正确地使用锁,还可能导致死锁、活锁和资源饥饿等问题。我将在并发编程 | 锁 - 并发世界的兜底方案 - 掘金 (juejin.cn)这篇文章重点展开为你讲解。七、总结感谢你看到这里。现在,让我们回顾一下重要知识点。首先,我为你揭示了线程安全问题的根源:可见性、原子性以及有序性这三大原则。然后,我从语言层面向你展示了Java如何应对这些问题,提出了有效的解决策略。其中,Java内存模型(JMM)无疑是其中最重要的一环,它为我们处理多线程带来的问题提供了基础的理论架构。针对JMM内存模型,我对其进行了详尽的解读,并通过实际的代码示例,帮助你更深入地理解其运行原理和使用方法。在理解和掌握了这些知识后,我们就能够编写出线程安全的代码,有效地避免并发中的常见问题。当然,我们在解决并发问题时,有多种不同的方案可以选择。我为你列举了其中的三种主要策略:互斥同步方案、非阻塞同步方案以及无同步方案。每种方案都有其独特的优点和适用场景,选择哪一种,需要根据我们的具体需求和实际情况来决定。希望你能从中找到最适合你的方案,更好地掌握并发编程。
cathoy
JVM | 类加载是怎么工作的
引言在程序世界的大海洋中,类就像是构建一切的基石。它们是构建Java应用的原材料,类加载器则是这个世界的建筑工人。他们负责将构建城市所需的材料搬运到工地(JVM)。了解类加载器的工作原理,就像了解城市建设的过程,能够让我们更好地理解和控制程序的运行。现在,让我们深入探索JVM的类加载器,解析它的奥秘,开启这趟神奇的旅程吧!如果说并发编程是指挥交通的艺术,那么了解JVM就是为城市添砖加瓦的艺术。我们来看下究竟是什么样的。城市建设过程 | 类加载器工作原理首先,我们编写了一个类:public class Building {
public Building() {
System.out.println("建筑蓝图已被创建! 我们可以添砖Java了");
}
private static int constructionYear = 2023;
private int floorCount;
public Building(int floors) {
this.floorCount = floors;
}
public void setFloorCount(int floors) {
this.floorCount = floors;
}
public int getFloorCount() {
return this.floorCount;
}
public static int getConstructionYear() {
return constructionYear;
}
public static void main(String[] args) {
new Building();
}
}
类中有一个main()方法的程序入口点。我们运行一下:Connected to the target VM, address: '127.0.0.1:9888', transport: 'socket'
建筑蓝图已被创建! 我们可以添砖Java了
Disconnected from the target VM, address: '127.0.0.1:9888', transport: 'socket'
Process finished with exit code 0好,没什么问题。你是否好奇当我们在IDE绿色的小箭头点击run ‘Building.main()’之后,底层到底发什么什么?嗯...有问必答,我们接着看。类加载器加载类前过程我还是结合上面的例子为你讲解,请你仔细思考它们的对应关系。我们开始吧~建筑工程立项 | JVM进程启动当我们在IDE绿色的小箭头点击run ‘Building.main()’ 其实IDE会进行两个步骤: 编译:IDE会先使用javac编译器,将你的.java源文件编译成.class字节码文件。这一步骤通常是在后台进行的,你通常不会看到任何有关编译的消息,除非出现编译错误。(往往这个时候程序员就要挠掉不少头发) 运行:编译完成后,IDE会使用java命令启动JVM进程,然后载入并执行相应的.class文件中的main方法。搞懂这两个步骤,我们接着往下说。首先,当你在运行java Building这个指令时,就好比发出了开工命令。JVM就像一位总承包商,控制着一位特殊的工人,也就是Bootstrap类加载器。这位工人的工作是从核心材料库($JAVA_HOME/jre/lib)中取出构建这座大楼所需的基本原材料,这些基本材料包括了Java的核心类库。高级工程师的两位得力助手 | Bootstrap类加载器创建扩展和应用类加载器Bootstrap类加载器,像一位高级工程师,接下来派遣了另外两位工人,他们是扩展(ext)类加载器和应用(app)类加载器。扩展类加载器的任务是从扩展材料库$JAVA_HOME/jre/lib/ext获取扩展材料。应用类加载器的任务是从建筑工地周围(系统类路径CLASSPATH)收集所需的特定材料。至此,类加载器加载类前过程已经完成了,我们接着往后看。类加载器加载类后过程应用类加载器加载Building当你(雇主)告诉高级工程师(Bootstrap类加载器)你需要一个名为Building 的设计蓝图,这个时候高级工程师就可以派出它的得力助手扩展类加载器了,但是扩展类加载器发现Building不是它的职责范围,于是把活交给应用类加载器,他刚好知道在哪里可以找到Building.class这个特定的建筑蓝图。他会沿着系统类路径,寻找到这个类文件,并将其内容(类的字节码)搬运到JVM中。这个过程就好像是将建筑蓝图放到了JVM的工地上。链接过程(验证,准备,解析)当Building类的字节码被搬运到JVM后,总承包商会委托工人们对这些原材料进行处理。他们会检查材料(验证),然后对constructionYear 材料进行预处理先把它设置为0(准备),你看:private static int constructionYear = 0;最后将它们组合在一起(解析),把JVM将常量池中的符号引用替换为直接引用。这个过程就好比按照蓝图的要求,将砖块、水泥等材料准备好并组装起来。初始化过程紧接着就开始真正的施工了。工人们按照Building类的main方法(也就是建筑的蓝图)开始构建大楼。在这个例子中,它会创建一个新的Building对象。并且静态变量constructionYear在初始化阶段会被初始化为2023,你看:private static int constructionYear = 2023;这就好比工人们按照蓝图上的指示,开始把砖块、水泥等材料搭建起来。使用过程一旦建筑物(也就是Building对象)被创建出来,就可以开始使用了。在这个例子中,当Building对象被创建时,它的构造函数会被调用,打印出”建筑蓝图已被创建! 我们可以添砖Java了“。卸载过程当大楼(也就是Building对象)不再被使用,或者建筑工地(也就是JVM)需要关闭时,这座大楼就会被拆除。这个过程由JVM的垃圾回收器负责,它会清理掉不再需要的Building对象。当没有任何类加载器引用这个Building类时,这个类也将被卸载。这就好比当大楼不再需要,或者工地需要关闭时,大楼会被拆除,蓝图(也就是Building.class文件)也会被收回。解决建筑过程中出现的问题不知道你有没有发现,我在描述初始化的过程中并没有提到floorCount变量,那这个材料就不初始化了吗?还有,为什么一开始高级工程师不直接把活派给应用类加载器而是先给扩展类加载器?还有,为什么写了main()方法,程序就可以运行了?好好好,我一个一个来为你解答1. floorCount为什么没被初始化?它们会在创建对象的时候(也就是新建Building对象时)被初始化。实例变量floorCount是属于对象的,每个对象都有一份独立的副本,它们的生命周期随着对象的创建和销毁而开始和结束。2. 高级工程师为什么不直接把活派给应用类加载器而是先给扩展类加载器?因为高级工程师很聪明,他知道有一种双亲委派机制可以提高效率,怎么提高效率?高级工程师的决定他人不能改变 | 保证Java核心API不被篡改例如: 自己写的java.lang.String.class类不会被加载,这样便可以防止核心 API库被随意篡改避免出现重复的工作量 | 防止类的重复加载当父类加载器已经加载了某个类时,子加载器就不会再加载,避免了重复加载。当然还有不少优点:防止Java类库的冲突,节省内存空间... 这里就不赘述了。3. 为什么你写了main()方法,程序就可以运行了?这是由类加载器内部运行机制决定的,你可以看下流程: 在初始化完成后,JVM会查找类中的 main 方法。 main 方法的标准声明应为: public static void main(String[] args)。这个方法是静态的(即与类关联,而不是与对象关联),因此JVM可以在不创建类的实例的情况下调用它。一旦找到 main 方法,JVM就会执行它。程序的执行流程就从 main 方法开始。有哪些建筑工人 | 类加载器上面已经提到,除了引导类加载器(BootStrap)之外,还有扩展类加载器(Ext) 和应用类加载器(App),我们在这里着重再介绍下吧~引导类加载器(Bootstrap ClassLoader)引导类加载器是最顶级的类加载器,它主要负责加载Java的核心类库,例如java.lang.*,java.util.*等。这些类库的位置通常在JDK的jre/lib/rt.jar中。引导类加载器是由C++编写的,我们在Java中是无法获取它的引用的。引导类加载器是其他类加载器的父加载器。扩展类加载器(Extension ClassLoader)扩展类加载器是引导类加载器的子类,它负责加载JDK的扩展类库,这些类库通常位于JDK的jre/lib/ext/目录下或者由系统变量java.ext.dirs指定的目录下。扩展类加载器是由Java编写的,其具体实现类是sun.misc.Launcher$ExtClassLoader。应用类加载器(Application ClassLoader)应用类加载器是扩展类加载器的子类,也是我们通常接触到的默认的类加载器。它负责加载用户路径(ClassPath)上所指定的类库。这个路径可以通过环境变量CLASSPATH设置,也可以通过java命令的-classpath或者-cp参数设置。应用类加载器的实现类也是sun.misc.Launcher$AppClassLoader。每当子类加载器需要加载类时,首先会委托父类加载器进行加载,直到最顶层的引导类加载器。如果父类加载器无法加载该类,才会由子类加载器自己进行加载。为了你加深对它们的印象,我了一张关于这三个的类加载器树图,你可以暂停看一下: 常见面试题 1.什么是类加载器(ClassLoader),类加载器有哪些? 2.能简单描述一下类的生命周期吗?它们在JVM中的状态有哪些? 3.什么是双亲委派模型?这种模型有什么优点? 4.请解释一下引导类加载器、扩展类加载器和应用类加载器的区别?参考文献 1.深入拆解 Java 虚拟机-极客时间 2.类加载器官方文档总结好,我们来做个总结。作为JVM的开篇,还是老样子,我为你构建一个建筑工地的世界。基于这个世界,我为你讲解了类加载器的工作原理。并且为你解答了一些类加载器过程中遇到的问题,带你重新回顾了一下,本篇文章的三位主人公,它们分别是:引导类加载器,扩展类加载器,应用类加载器。最后我留了几道面试题,不知道你是否都能答上来呢。后续既然高级工程师和两位建筑工人已经把事情都划分完了,那么其它工人怎么办?类加载器可以自己定义吗?如何实现? 什么情况下需要使用自定义类加载器?你是否了解ServiceLoader和SPI机制?后面一篇我会回答这些问题,敬请期待。
cathoy
并发编程 | 从Future到CompletableFuture - 简化 Java 中的异步编程
引言在并发编程中,我们经常需要处理多线程的任务,这些任务往往具有依赖性,异步性,且需要在所有任务完成后获取结果。Java 8 引入了 CompletableFuture 类,它带来了一种新的编程模式,让我们能够以函数式编程的方式处理并发任务,显著提升了代码的可读性和简洁性。 在这篇博客中,我们将深入探讨 CompletableFuture 的设计原理,详细介绍其 API 的使用方式,并通过具体的示例来展示其在并发任务处理中的应用。我们也将探讨其与 Future,CompletableFuture 以及 Java 并发包中其他工具的对比,理解何时以及为什么需要使用 CompletableFuture。让我们一起踏上这个富有挑战性的学习之旅吧!在开始之前,我们先来回顾一下Java语言发展历史Java 并发编程的演进自从诞生以来,Java 就一直致力于提供强大的并发和异步编程工具。在最初的 JDK 1.4 时期,Java 开发者需要使用低级的并发控制工具,如 synchronized 和 wait/notify,这些工具虽然功能强大,但使用起来非常复杂。 为了简化并发编程,Java 在 JDK 1.5 中引入了JUC包,提供了一系列高级的并发控制工具,如 ExecutorService、Semaphore 和 Future。我们先来看下,Future到底是怎么进行异步编程的Future的异步编程之旅在开始我们的旅程之前,我们先看看一下这个需求。一个复杂的需求假设你正在为一家在线旅行社工作,用户可以在网站上搜索并预订飞机票和酒店。以下是你需要处理的一系列操作:根据用户的搜索条件,查询所有可用的飞机票对每一个飞机票,查询与之匹配的可用酒店对每一个飞机票和酒店的组合,计算总价格将所有的飞机票和酒店的组合按照价格排序将结果返回给用户实现为了实现这个需求,首先,我们需要创建一个 ExecutorService,:ExecutorService executor = Executors.newFixedThreadPool(10);
// 1. 查询飞机票
Future<List<Flight>> futureFlights = executor.submit(() -> searchFlights(searchCondition));
List<Flight> flights;
try {
flights = futureFlights.get();
} catch (InterruptedException | ExecutionException e) {
// 处理异常
}
// 2. 对每个飞机票查询酒店
List<Future<List<Hotel>>> futureHotelsList = new ArrayList<>();
for (Flight flight : flights) {
Future<List<Hotel>> futureHotels = executor.submit(() -> searchHotels(flight));
futureHotelsList.add(futureHotels);
}
List<Future<List<TravelPackage>>> futureTravelPackagesList = new ArrayList<>();
for (Future<List<Hotel>> futureHotels : futureHotelsList) {
List<Hotel> hotels;
try {
hotels = futureHotels.get();
} catch (InterruptedException | ExecutionException e) {
// 处理异常
}
// 3. 对每个飞机票和酒店的组合计算总价格
for (Hotel hotel : hotels) {
Future<List<TravelPackage>> futureTravelPackages = executor.submit(() -> calculatePrices(flight, hotel));
futureTravelPackagesList.add(futureTravelPackages);
}
}
List<TravelPackage> travelPackages = new ArrayList<>();
for (Future<List<TravelPackage>> futureTravelPackages : futureTravelPackagesList) {
try {
travelPackages.addAll(futureTravelPackages.get());
} catch (InterruptedException | ExecutionException e) {
// 处理异常
}
}
// 4. 将所有的旅行套餐按照价格排序
travelPackages.sort(Comparator.comparing(TravelPackage::getPrice));
// 5. 返回结果
return travelPackages;
需求终于做完了(叹气声)。此时此刻,生在JDK8+的你,会不会感同身受呢。这还是在没有处理异常,没有很多业务代码的前提下。好,现在缓一下我们继续。我们可以从上面代码最直观的看到什么?再完美的表达,也敌不过一个让你直观感受的例子。接下来,我们来分析一下Future的缺点。分析这趟Future异步编程之旅从上面的 Future 的例子中,我们可以明显看到以下几点缺点:回调地狱Future 的实现使得我们必须在每一个 Future 完成后启动另一个 Future,这使得代码看起来像是在不断嵌套回调。这种方式会使得代码难以阅读和理解,特别是在涉及复杂的异步任务链时。阻塞操作虽然 Future.get() 可以得到任务的结果,但这是一个阻塞操作,它会阻止当前线程的执行,直到异步操作完成。这种设计对于要实现非阻塞的异步编程来说,是非常不理想的。复杂的错误处理在使用 Future 链式处理异步任务时,如果中间某个环节出现错误,错误处理的复杂性就会大大增加。你需要在每个 Future 的处理过程中都增加异常处理代码,这使得代码变得更加复杂和难以维护。无法表示任务间复杂关系使用 Future 很难直观地表示出任务之间的依赖关系。例如,你无法使用 Future 来表示某个任务需要在另外两个任务都完成后才能开始,或者表示多个任务可以并行执行但是必须在一个共同的任务之前完成。这种限制使得 Future 在处理复杂的异步任务链时变得非常困难。因此,为了解决这些问题,CompletableFuture 被引入了 Java 8,提供了更强大和灵活的异步编程工具。CompletableFuture的异步编程之旅同样还是上面的例子,我们来看下它的实现代码:CompletableFuture.supplyAsync(() -> searchFlights()) // 1. 查询飞机票
.thenCompose(flights -> { // 2. 对每个飞机票查询酒店
List<CompletableFuture<List<TravelPackage>>> travelPackageFutures = flights.stream()
.map(flight -> CompletableFuture.supplyAsync(() -> searchHotels(flight)) // 查询酒店
.thenCompose(hotels -> { // 3. 对每个飞机票和酒店的组合计算总价格
List<CompletableFuture<TravelPackage>> packageFutures = hotels.stream()
.map(hotel -> CompletableFuture.supplyAsync(() -> new TravelPackage(flight, hotel)))
.collect(Collectors.toList());
return CompletableFuture.allOf(packageFutures.toArray(new CompletableFuture[0]))
.thenApply(v -> packageFutures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList()));
}))
.collect(Collectors.toList());
return CompletableFuture.allOf(travelPackageFutures.toArray(new CompletableFuture[0]))
.thenApply(v -> travelPackageFutures.stream()
.flatMap(future -> future.join().stream())
.collect(Collectors.toList()));
})
.thenApply(travelPackages -> { // 4. 将所有的旅行套餐按照价格排序
return travelPackages.stream()
.sorted(Comparator.comparing(TravelPackage::getPrice))
.collect(Collectors.toList());
})
.exceptionally(e -> { // 处理所有的异常
// 处理异常
return null;
});
你可能乍一看,感觉怎么比Future还要复杂。但是实际在业务中,它反而更加容易读懂。每一步,每一个操作都可以顺着thenCompose下去。分析这趟CompletableFuture异步编程之旅CompletableFuture 是 Java 8 中引入的,用于解决在使用 Future 时遇到的一些问题。它实现了 Future 和 CompletionStage 接口,并且提供了大量的方法来帮助你更好地控制和管理异步操作。我们来结合上面的例子来分析它的优点:链式编程我们使用 CompletableFuture 中的 supplyAsync 方法来异步地开始查询航班的操作: CompletableFuture<List<Flight>> flightsFuture = CompletableFuture.supplyAsync(() ->
searchFlights(source, destination));
然后,我们使用 thenCompose 方法将查询航班和查询酒店的操作连在一起: CompletableFuture<List<TravelPackage>> travelPackagesFuture = flightsFuture.thenCompose(flights ->
CompletableFuture.supplyAsync(() -> flights.stream()
.map(flight -> searchHotels(flight))
.collect(Collectors.toList())
));
非阻塞操作上述的 thenCompose 方法是非阻塞的,即查询酒店的操作会立即开始,而不需要等待查询航班的操作完成。异常处理我们使用 exceptionally 方法处理查询航班和查询酒店过程中可能出现的异常: CompletableFuture<List<TravelPackage>> travelPackagesFuture = flightsFuture.thenCompose(flights ->
CompletableFuture.supplyAsync(() -> flights.stream()
.map(flight -> searchHotels(flight))
.collect(Collectors.toList())
)).exceptionally(ex -> {
System.out.println("失败了: " + ex);
return new ArrayList<>();
});
表示任务间复杂关系我们使用 CompletableFuture.allOf 方法来表示所有的旅行套餐计算任务都必须在开始排序之前完成: CompletableFuture<List<TravelPackage>> sortedTravelPackagesFuture = travelPackagesFuture.thenApply(travelPackages ->
travelPackages.stream()
.flatMap(List::stream)
.sorted(Comparator.comparing(TravelPackage::getPrice))
.collect(Collectors.toList())
);
暂停一分钟,再细细体会上面的例子。我们接着来集中比较这两者CompletableFuture与Future的比较异步执行与结果获取Future 提供了一种在未来某个时间点获取结果的方式,但它的主要问题是在获取结果时,如果结果尚未准备好,会导致阻塞。另外,使用 isDone() 方法进行轮询也不是一个好的选择,因为它将消耗CPU资源。CompletableFuture 提供了非阻塞的结果获取方法,thenApply, thenAccept, thenRun 等方法可以在结果准备好后被自动执行,这样我们不需要手动检查和等待结果。链式操作Future 不支持链式操作,我们无法在 Future 完成后自动触发另一个任务。CompletableFuture 提供了 thenApply, thenAccept, thenRun, thenCompose, thenCombine 等一系列方法,用于在当前任务完成后自动执行另一个任务,形成任务链。异常处理在 Future 中,只能通过 get() 方法获取异常,但是这种方式会阻塞线程,直到任务执行完毕。CompletableFuture 提供了 exceptionally, handle 等方法,我们可以用这些方法在发生异常时提供备用的结果,或者对异常进行处理。任务组合Future 并未提供任何任务组合的方式。CompletableFuture 提供了 allOf, anyOf, thenCombine 等方法,我们可以通过这些方法来表示任务间的并行关系,或者汇聚关系。灵活的任务执行控制Future 在任务执行上相对较为死板,我们无法中途取消任务,也无法在任务结束后执行特定操作。CompletableFuture 提供了 cancel, complete 等方法,用于中途取消任务,或者提前完成任务。此外,whenComplete 和 whenCompleteAsync 方法允许我们在任务结束时,无论成功或失败,都可以执行特定的操作。假如有一个面试官现在问题它们两者的区别,你会回答了吗? 接下来,我们来解析一下进阶 | 理解CompletableFuture原理为了让你理解的不那么晦涩,我为你讲生活中的例子:我们可以把 CompletableFuture 想象成一家装配线生产车间。每一件零件(任务)的加工完成(Future 完成)都可能会触发下一步工作(下一步的操作),而每一步工作的完成都会通知车间(Future),以便开始下一个阶段的生产。这个过程就像一条流水线,每完成一个步骤就自动进行下一个。带着这个场景,我们接着往下看。任务链CompletableFuture 的源码中,有一个内部类 Completion,代表了任务链中的一项任务。每当一个任务完成时,它都会尝试去完成依赖于它的任务,就像流水线上的工人完成了一部分工作后,就会把半成品传递给下一个工人。 abstract static class Completion extends ForkJoinTask<Void> implements Runnable, AsynchronousCompletionTask {
// ...
}
结果容器CompletableFuture 本身就是一个结果容器,它持有了执行的结果,包括正常的计算结果或者执行过程中出现的异常。 volatile Object result; // The outcome of the computation
工作线程所有的异步任务都会提交到 ForkJoinPool.commonPool() 中进行执行,当然也可以指定自定义的 Executor 来执行任务。 static final ForkJoinPool ASYNC_POOL = ForkJoinPool.commonPool();
任务触发当一个任务完成后,CompletableFuture 会通过 tryFire 方法触发与之关联的下一个任务。这就好比工人完成了一部分工作后,通知流水线的下一位工人继续完成接下来的工作。 final CompletableFuture<T> postFire(CompletableFuture<?> a, int mode) {
// ...
if (a != null && a.stack != null) {
if (mode < 0)
a.cleanStack();
else
a.postComplete();
}
if (b != null && b.stack != null) {
if (mode < 0)
b.cleanStack();
else
b.postComplete();
}
return null;
}
是不是有点理解了呢?我可以肯定的说,你已经超过80%的人了!CompletableFuture的主要方法细心的你肯定发现了,CompletableFuture大多数方法都实现于一个CompletionStage接口。当然,我在这里可以为你把所有方法都试过一遍,但是你肯定会看的特别累。这样!我把上面需求中所用到的方法都为你讲解,剩下的请你结合网上的案例学习。supplyAsync()方法这个方法用于异步执行一个供应函数,并返回一个CompletableFuture对象。在我们的示例中,这个方法用于启动一个异步任务来查找航班。 CompletableFuture<List<Flight>> flightsFuture = CompletableFuture.supplyAsync(() -> searchFlights(destination));
thenCompose()方法这个方法用于链接多个CompletableFuture对象,形成一个操作链。当一个操作完成后,thenCompose()方法会将操作的结果传递给下一个操作。在我们的示例中,这个方法用于在找到航班之后查找酒店。 CompletableFuture<List<Hotel>> hotelsFuture = flightsFuture.thenCompose(flights -> CompletableFuture.supplyAsync(() -> searchHotels(destination)));
thenCombine()方法这个方法用于将两个独立的CompletableFuture对象的结果合并为一个结果。在我们的示例中,这个方法用于将查找航班和酒店的结果合并为一个旅行套餐。 CompletableFuture<List<TravelPackage>> travelPackagesFuture = flightsFuture.thenCombine(hotelsFuture, (flights, hotels) -> createTravelPackages(flights, hotels));
thenAccept()方法这个方法在CompletableFuture对象完成计算后执行一个消费函数,接收计算结果作为参数,不返回新的计算值。在我们的示例中,这个方法用于打印出所有的旅行套餐。 travelPackagesFuture.thenAccept(travelPackages -> printTravelPackages(travelPackages));
allOf()方法这个方法用于将一个CompletableFuture对象的数组组合成一个新的CompletableFuture对象,这个新的CompletableFuture对象在数组中所有的CompletableFuture对象都完成时完成。在我们的示例中,这个方法用于将每个航班与每个酒店的组合结果(也就是旅行套餐)组合在一起。 CompletableFuture.allOf(packageFutures.toArray(new CompletableFuture[0]))
.thenApply(v -> packageFutures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList()));
thenApply()方法这个方法用于对CompletableFuture的结果进行变换,并返回一个新的CompletableFuture对象。在我们的示例中,这个方法用于将查询到的旅行套餐按照价格进行排序。 .thenApply(travelPackages -> { // 4. 将所有的旅行套餐按照价格排序
return travelPackages.stream()
.sorted(Comparator.comparing(TravelPackage::getPrice))
.collect(Collectors.toList());
})
exceptionally()方法这个方法用于处理CompletableFuture的异常情况。如果CompletableFuture的计算过程中抛出异常,那么这个方法会被调用。在我们的示例中,这个方法用于处理查询旅行套餐过程中可能出现的任何异常。 .exceptionally(e -> { // 处理所有的异常
// 处理异常
return null;
});
当然,这些方法已经够你用了。除非这个需求比我想得还复杂,那算你厉害。哦,不对,算需求变态。现在,你可以挥起历史的毛笔续写了吗?Java 并发编程的续章JDK 1.5 的 Future 解决了许多并发编程的复杂性,但是它仍有一些局限性。Future 只能描述一个异步操作,并不能描述一个由多个步骤组成的异步操作。例如,当需要处理一个由多个异步操作序列组成的业务流程时,你可能会发现你的代码被复杂的回调逻辑淹没,这就是人们常说的回调地狱。此外,Future 没有提供一种有效的方式来处理异步操作的结果,你只能通过阻塞调用 get() 方法来获取结果。 为了解决这些问题,Java 在 JDK 1.8 中引入了 CompletableFuture。CompletableFuture 是 Future 的增强版,它不仅能表示一个异步操作,还可以通过 thenCompose(), thenCombine(), allOf() 等方法来描述一个由多个步骤组成的异步操作。通过这些方法,CompletableFuture 能以流畅的链式调用的方式来描述复杂的异步业务流程,这大大简化了异步编程的复杂性。常见面试题请解释一下 Future 接口在 Java 中的用途?解释一下 Future 的局限性是什么?请解释一下 CompletableFuture 的用途以及它如何克服 Future 的局限性?如何用 CompletableFuture 来表示一组并行的异步操作?请解释一下 CompletableFuture 的 thenApply(),thenCompose(),和 thenCombine() 方法的作用及区别?如果你有一个耗时的异步操作需要执行,但是你又不希望调用 get() 方法时阻塞,你可以使用 CompletableFuture 的哪个方法来达到这个目的?如何处理 CompletableFuture 的异常?请解释一下 CompletableFuture 的工作原理?阅读完文章的你,是否可以回答这些问题呢?我在留言等你。总结好了,到这里就结束了,我们来回顾一下。首先,我带你回顾了一下Java并发世界的编年史。紧接着,我带你体验了一下古人经常使用的Future。感到它的不妙之后,我带你回到CompletableFuture 。紧接着有深入了解了它的全貌以及使用方法。最后,希望阅读到这里的你,不要忘记回答问题哦。
cathoy
JVM | 从类加载到JVM内存结构
引言我在上篇文章:[JVM | 基于类加载的一次完全实践]JVM | 基于类加载的一次完全实践 中为你讲解如何请“建筑工人”来做一些定制化的工作。但是,大型的Java应用程序时,材料(类)何止数万,我们直接堆放在工地上(JVM)上吗?相反,JVM有着一套精密的管理机制,来确保类的加载、验证、解析和初始化等任务能够有序且高效地完成。 在Java的世界中,虚拟机(JVM)是我们每一个程序的运行环境,而它的内存结构更是决定我们程序运行性能的关键因素。理解JVM的内存结构,不仅可以帮助我们编写出更高效的代码,而且可以在程序出现问题时,更快地定位并解决问题。然而,JVM内存结构的复杂性,很多人仍然存在许多误解和疑惑。 在本篇文章中,我们将详细地探讨这些“建筑工人”是如何处理“建筑材料”的,从而帮助你更深入地理解JVM类加载和初始化的内部工作机制。希望通过这篇文章,可以带你更深入地理解Java程序的运行机制。让我们开始吧!类的加载我在之前为你讲解了类的生命周期,你还记得吗?我们来回顾下:加载、验证、准备、解析、初始化、使用和卸载。 接下来,我们再深入分析完整的过程。加载类进JVM内存还是以Building为例。假设你在编译器中编写了Building类,并生成了相应的字节码文件Building.class。当你启动你的Java程序时,首先JVM启动并初始化。在这个过程中,JVM的类装载子系统起着关键的作用。类装载子系统的主要职责就是加载类到JVM中。当类被加载时,Java虚拟机首先将类的元信息放入运行时数据区的元空间中,然后在堆中生成java.lang.Class类的实例。这个Class对象会包含指向元空间中类元信息的引用。文字还是过于抽象,我画了一张图,你看: 这里有几个让人混淆的地方,我来为你解释一下:两个Class图中有两处Building.class。但是,此Class非彼Class。第一步的Class代表着Building的字节码文件。而第二步的Class则为指向Building类元信息的Class对象。两处元空间这里我从不同的JDK内存结构讲起,你可以比较这两者差异: 在JDK7里,类元数据信息被存储在堆的一部分,叫做方法区,它需要参与垃圾回收,但时常被GC忽略。所以方法区的存在让内存管理成本变高,而且在空间分配不当的情况下,容易出现内存溢出的情况。 所以在JDK8时,将方法区改为元空间,并把其移到本地内存中,这样可以更好地管理内存,避免出现内存溢出的情况。JVM内存和直接内存在图中你可以看到,JVM内存和本地内存都属于(物理)内存的一部分,为什么要把它们分开讨论呢?因为目标不同,JVM是由JVM进程管理的一块内存空间,它可以对其中的内存进行自动垃圾收集。而本地内存是不受JVM管理,而且不受JVM内存设置的限制。直接内存和(操作系统)内存虽然直接内存不受垃圾回收管理。但是它依然是Java虚拟机从操作系统申请的。它可以用于高效的I/O操作,如果你想使用直接内存空间可以使用这个方法:ByteBuffer.allocateDirect() 。类的链接过程接下来我们看下链接的过程,链接分为三步:验证阶段,准备阶段,解析阶段。这个过程由类加载子系统来完成,我们来看下:验证阶段JVM 读取类文件后,需要对其进行验证,确保这个类文件满足 JVM规范要求,不会有安全问题。准备阶段JVM 为类的静态变量分配内存,并且为它们设置默认值。在我们的 Building 类中,constructionYear 就是一个静态变量,所以它会在这个阶段被初始化为 0(对于 int 类型,初始化默认值为 0)。静态变量是属于类的,我们会把它放在元空间中,你看: 解析阶段JVM 将类的二进制数据中的符号引用替换为直接引用。这个过程是在元空间完成的。符号引用就是一组符号来描述所引用的目标,直接引用就是直接指向目标的指针、相对偏移量或者是一个能直接定位到目标的句柄。 直接引用好理解,符号应用是啥?以Building为例,符号引用就是:org.kfaino.jvm.Building.construct:()Lorg/kfaino/jvm/Building; 这两个东西都在元空间的运行时常量池中,你看:类的初始化阶段在讲类初始化之前,我们应该要知道类什么时候开始初始化,什么时候又不初始化?这里也是面试的常考题,我们来重点分析下。类什么时候不初始化?我直接以代码举例,你可以看下:static String CONSTANT = "我是静态常量,我要被放到堆的常量池里面了";
static int i = 128;这里展示了两种情况,引用类型的String会被放到堆的字符串常量池中,而int类型则会被放在上面的元空间的静态变量中,你可以结合上面的图理解。接下来,我们看下初始化的情况。类什么时候开始初始化?还是以代码举例,你可以看下:Building building = new Building();
Building.静态方法();
// 如果initializeBoolean为false也不会初始化
Class<?> clazz = Class.forName("org.kfaino.jvm.Building");
// 作为父类的情况
class SubBuilding extends Building {}看完这些初始化的情况之后,我们来看下具体是怎么初始化的。类的初始化初始化阶段首先会为对象分配内存,内存分配完成后,需要将分配给对象的内存空间都初始化为零值(分配零值)。然后设置对象头。分配内存好理解,因为当Class被加载进元空间中就已经可以算出每个类型的内存大小了。至于对象头,我打算在垃圾回收时为你讲解,限于篇幅,这里按下不表。 这里的分配零值也有可考的内容,你看:public class ZeroTest {
int i;
public void testMethod() {
int j;
System.out.println(i);
// Variable 'j' might not have been initialized
System.out.println(j);
}
}因为i在初始化时有分配0,所有可以正常输出。但是j是局部变量,没有初始化就会报错。做完这三件事之后,JVM 会执行类的初始化代码。对于 Building 类来说,constructionYear 在这个阶段会被初始化为 2023,这个值是在类的静态初始化器(<clinit>)中设置的。 我在上篇文章中说到:如果我们在多线程中使用类加载器,可能会导致类被重复加载多次。除了会浪费资源外,还会导致我们一些静态初始化代码被执行多次。 指的就是<clinit> 。有关也有一个常见的面试题,我为你展示代码,你暂停思考下,结果如何:public class Building {
static int constructionYear = 2023;
static {
constructionYear = 2024;
}
public static void main(String[] args) {
System.out.println(constructionYear);
}
}想好了吗?最终答案是2024。因为静态变量和静态代码块会放在静态初始化器中按顺序执行的。使用在完成初始化后,类就可以被应用程序正常使用了。当你调用一个方法时,JVM会为这个方法创建一个新的栈帧,并压入到当前线程的Java栈中。Java栈是线程私有的内存区域,用于存储每个方法调用的状态,包括局部变量、操作数栈、动态链接等信息。方法调用方法调用具体过程是什么样的呢? 依然以 Building 为例, 我i先改造下它,加上一个计算建筑年龄的方法,你看:public class Building {
private static final int CONSTRUCTION_YEAR = 1998;
public int calculateAge(int currentYear) {
return currentYear - CONSTRUCTION_YEAR;
}
}接下来,假设有一段代码调用了 calculateAge 方法:public static void main(String[] args) {
Building building = new Building();
int age = building.calculateAge(2023);
}当 calculateAge 方法被调用时,我们来看下在JVM虚拟机内存发生了什么?为了方便你理解, 我事先画了一张图,你看: 我在图中完整标注出执行顺序,你可以暂停看下。接下来我详细的为你解释:1.方法调用:当Java代码执行到building.calculateAge(2023)时,首先JVM会通过对象引用(即building)查找到类Building,然后在类中查找calculateAge方法的符号引用。2.动态链接:JVM会根据Building类中的符号引用找到calculateAge方法在运行时常量池中的直接引用,获取改方法的内存地址。3.创建新的栈帧:JVM为调用的方法创建一个新的栈帧,并推入当前线程的Java栈顶。这个栈帧包含局部变量表、操作数栈、动态链接和方法出口。4.初始化局部变量表:JVM将方法调用的参数(即currentYear和this)存储到新栈帧的局部变量表中。5.更新程序计数器:JVM的程序计数器更新为calculateAge方法的第一条字节码指令。6.执行方法体: JVM开始执行calculateAge方法的字节码。当执行到currentYear - CONSTRUCTION_YEAR时,它会将currentYear和CONSTRUCTION_YEAR推入操作数栈,然后执行减法操作,并将结果推入操作数栈顶。7.方法返回:执行完calculateAge方法后,JVM将操作数栈顶的结果(即年龄)作为方法返回值,并将calculateAge方法的栈帧从Java栈中弹出。8.接收返回值:calculateAge方法的返回值被推入调用者(即main方法)的操作数栈中,并赋值给局部变量age。9.更新程序计数器:JVM的程序计数器更新为main方法的下一条指令。至此,我们就完成了从类的加载,到类的实例化,再到类的使用完整的过程。在这个过程中,你可以看到JVM运行时数据区的各个部分是如何协同工作的。细心体会之后,你会发现类的加载和初始化阶段主要与元空间有关,而类的实例化阶段主要与堆有关。顺便我画了一张图,你可以看一下: 接下来我们来看下类不用之后如何被卸载。卸载垃圾回收当Building对象不再被任何引用变量引用时(对象不可达),它就成为了垃圾。在某个时间点,垃圾收集器会回收这个对象占用的堆内存,这块我将在后续的垃圾回收为你详细讲解。类的完全卸载如果Building类的ClassLoader实例被回收,且没有任何线程在Building类的方法内执行,且没有任何Java栈帧持有Building类的方法的引用,那么JVM会判断Building类可以被卸载,并可能在未来的某个时间点,由垃圾收集器回收其在元空间内占用的内存。对,你没听错。方法区也可以进行垃圾回收。但是,类的完全卸载是一件苛刻的事情,你还记得我在第一篇文章中说的AppClassLoader吗?它是由BootstrapClassLoader创建,它的生命周期与JVM一样长,不会被垃圾回收。所以由AppClassLoader创建的类不会被卸载。当然,如果你想要卸载类,可以用第二篇文章中的自定义类加载器。文中重要部分解析初始化和未初始化我在前面强调:什么时候会进行类的初始化阶段,什么会只进行加载和链接。知道这两个差异有什么用呢?我们在编写代码的时候可以减少内存开销,我们现在知道类的初始化阶段需要分配内存,如果我们写一个懒加载,在使用时才初始化,那么我们的内存就会减少很多。相信你已经明白它的价值了。当然,空有概念没有代码可不行,我为你举一个例子,你可以看下:public class ConfigManager {
private Map<String, Supplier<Config>> allConfigs = new HashMap<>();
public ConfigManager() {
// 在初始化阶段,只是将配置类的构造函数注册到map中
allConfigs.put("config1", Config1::new);
allConfigs.put("config2", Config2::new);
// ...
allConfigs.put("configN", ConfigN::new);
}
public Config getConfig(String name) {
return allConfigs.get(name).get();
}
}相比原来new的操作,我使用了Config1::new。它不会在一开始就被初始化,而是在我们getConfig()的时候,才进行初始化。这就是专家级和普通级别程序员的差距。直接内存VSJVM内存我在之前为你提到:ByteBuffer.allocateDirect() 方法,它可以使用直接内存。用直接内存有什么好处?答案是可以减少内存复制的开销,直接缓冲区可以直接在内存中进行数据操作,无需将数据复制到Java堆内存中。还是老规矩,我用代码为你演示一个读取文件IO的场景,你看: // 一个5G的视频
private static final String FILE_PATH = "C:\\Users\\xxx\\Desktop\\1.mp4";
// 1MB
private static final int BUFFER_SIZE = 1024 * 1024;
public static void main(String[] args) throws Exception {
// 我用了懒加载
testBufferAllocator(ByteBuffer::allocate, "Heap Buffer");
testBufferAllocator(ByteBuffer::allocateDirect, "Direct Buffer");
}
private static void testBufferAllocator(BufferAllocator allocator, String testName) throws Exception {
try (FileChannel channel = FileChannel.open(Paths.get(FILE_PATH), StandardOpenOption.READ)) {
ByteBuffer buffer = allocator.allocate(BUFFER_SIZE);
Instant start = Instant.now();
while (channel.read(buffer) > 0) {
buffer.clear();
}
Instant end = Instant.now();
System.out.printf("%s: %s ms%n", testName, Duration.between(start, end).getNano() / 1000000);
}
}
private interface BufferAllocator {
ByteBuffer allocate(int capacity);
}我分别用堆缓存和直接缓存来测试它们两个的吞吐量。我们来看下结果:Connected to the target VM, address: '127.0.0.1:5061', transport: 'socket'
Heap Buffer: 934 ms
Direct Buffer: 765 ms
Disconnected from the target VM, address: '127.0.0.1:5061', transport: 'socket'
Process finished with exit code 0
直接内存比堆内存快了将近200ms。这两种内存的差距就在于堆内存多出了数据从内核缓冲区复制到Java堆内存中的缓冲区步骤。关于intern()方法我在上面说到,String类型的静态变量会被放到堆的字符串常量池中。它的目的就是为了减少相同字符串初始化带来的开销。当然,这样的设计就会带来一个问题。你来看下这段代码:String s1 = "Building";
String s2 = new String("Building");
System.out.println(s1 == s2);
System.out.println(s1 == s2.intern()); 输出结果是多少呢?暂停思考下,有答案了你再接着往下看我来公布答案:第一个为false ,因为 s2 是一个新的字符串实例:第二个为true,因为 s2.intern() 返回的是字符串常量池中的 "Hello";如果你感兴趣还可以阅读官方文档,我对相关部分进行了截图,你可以看下,链接已放在参考文献中,如果你感兴趣,也可以阅读。 总结至此,本篇完结。我们来回顾一下:本篇文章是类加载过渡到JVM内存结构的衔接文章。为了让你把之前的知识串起来,我结合了内存结构重新为你讲解类的生命周期。希望看完这篇文章,你会有不一样的收获。后续本篇文章从类的完整生命周期的角度为你深入解析了JVM内存结构,但仍有一些细节未涉及,例如:本地方法栈的具体工作方式,以及本地方法是C++代码,它是如何运作的?在接下来的文章中,我将进一步展开,为你勾勒出JVM内存结构的全貌,让你对其有更深入、全面的理解。
cathoy
并发编程 | 并发编程框架 - Disruptor - 深入理解高性能异步处理框架
总览本章节的思维导图如下所示: 前言在并发编程的世界中,对效率的追求从未停止过。我们尝试用各种方式来提高程序的执行效率,包括使用更高级的并发控制结构,如锁和线程池,以及采用更先进的并发设计模式。然而,有一种工具在许多高性能系统中得到了广泛的应用,那就是Disruptor。Disruptor是一个高性能的异步处理框架,它利用了Ring Buffer、CAS等高效的并发策略,使得在处理高并发、低延迟的需求时,表现出了惊人的性能。在这篇博客中,我们将一起深入探索Disruptor的内部工作原理,分析其如何提供出类似于常见队列但是性能卓越的数据结构,以及为何能在高并发环境下实现高效的数据交换。我们也将通过实例展示如何在实际项目中使用Disruptor,以帮助我们更好地理解其使用方法和性能优势。让我们一起开启这个高性能异步处理框架的探索之旅吧!我们来看一张图: 这是一张展示了Disruptor和ArrayBlockingQueue性能对比的直方图,你可以明显地看到Disruptor的优越性能。如果你对它感兴趣,可以跳转到这个链接查看what_is_the_disruptor2010年的3月,在伦敦QCon上,LMAX提到Disruptor框架。次年,Martin Fowler在它的博客 The LMAX Architecture 中探讨有关LMAX架构。主要讲述的是LMAX,这个新的零售金融交易平台,它每天需要处理大量的交易。为了应对这个大麻烦,LMAX团队研发了一款Disruptor框架。这个堪称神器的框架居然是2010年甚至之前的产物,我们赶紧了解下....入门 | Disruptor高性能的秘密Disruptor,我对它的定义为“并发破局者”,是 LMAX 公司开发的一种高性能,低延迟的并发框架。它采用了一些独特的设计理念,比如零阻塞,预分配数据,确保数据的局部性,使得在一些高吞吐量,低延迟的场景中,能够表现出优秀的性能。上面有几个关键的设计理念,我们来分析一下:零阻塞在并发编程中,线程阻塞通常是由等待资源(例如锁或数据)引起的。想象一下你在餐馆用餐,如果服务员在为其他顾客服务,你可能需要等待,这种等待就是阻塞。在 Disruptor 中,设计者采用了无锁的设计,这就好比餐馆有足够的服务员为每个顾客服务,所以你不需要等待,可以直接享用你的美食,这就是"零阻塞"。预分配数据在并发编程中,动态数据分配可能会成为一个性能瓶颈,因为为对象分配内存和初始化可能需要消耗一定的时间。而 Disruptor 通过将数据预先分配在 RingBuffer 中,使得每个处理线程在处理数据时,数据在多个 CPU 之间的传递被降到了最低,从而提高了性能。预分配数据就像在一场音乐会开始前,你已经预先为所有的观众分配了座位。这样,当他们到来时,他们可以直接坐下,无需等待工作人员找位置。这就提高了系统的处理速度。确保数据的局部性在现代的计算机硬件中,为了提高处理速度,CPU 会将常用的数据存储在一个叫做缓存的地方。如果数据在多个 CPU 之间频繁地传递,那么 CPU 就需要不断地更新缓存,这就造成了所谓的“缓存行击穿”,会消耗很多时间。在 Disruptor 中,数据尽可能地在同一个 CPU 中处理,就像我们在工作时,我们希望所有需要的文件和资料都在我们的办公桌上,这样我们就不需要来回跑去取文件,从而提高了工作效率。入门 | 使用Disruptor框架接下来,我们来看下如何使用Disruptor,我在下文为你贴出代码,你可以看一下。首先,我们定义了一个事件,以及事件对应的工厂:public class LongEvent {
private long value;
public long getValue() {
return value;
}
public void setValue(long value) {
this.value = value;
}
}
public class LongEventFactory implements EventFactory<LongEvent> {
public LongEvent newInstance() {
return new LongEvent();
}
}
紧接着,生产者和消费者出现了。public class LongEventProducer {
private final RingBuffer<LongEvent> ringBuffer;
public LongEventProducer(RingBuffer<LongEvent> ringBuffer) {
this.ringBuffer = ringBuffer;
}
public void onData(long value) {
long sequence = ringBuffer.next();
try {
LongEvent event = ringBuffer.get(sequence);
event.setValue(value);
} finally {
ringBuffer.publish(sequence);
}
}
}
public class LongEventConsumerHandler implements EventHandler<LongEvent> {
public void onEvent(LongEvent event, long sequence, boolean endOfBatch) {
System.out.println("事件值: " + event.getValue());
}
}
我们来测试看看:public class DisruptorTest {
public static void main(String[] args) throws Exception {
Executor executor = Executors.newCachedThreadPool();
LongEventFactory factory = new LongEventFactory();
int bufferSize = 1024;
Disruptor<LongEvent> disruptor = new Disruptor<>(factory, bufferSize, executor);
disruptor.handleEventsWith(new LongEventHandler());
disruptor.start();
RingBuffer<LongEvent> ringBuffer = disruptor.getRingBuffer();
LongEventProducer producer = new LongEventProducer(ringBuffer);
ByteBuffer bb = ByteBuffer.allocate(8);
for (long l = 0; l<100; l++) {
bb.putLong(0, l);
producer.onData(bb.getLong(0));
// 延迟一秒
Thread.sleep(1000);
}
}
}
结果如下:Connected to the target VM, address: '127.0.0.1:2144', transport: 'socket'
事件值: 0
事件值: 1
事件值: 2
事件值: 3
...
Disconnected from the target VM, address: '127.0.0.1:2144', transport: 'socket'
事件值有条不紊的输出。当然,为了方便你理解,我把生产者和消费者都为你注明。若你在工作中要想使用它,建议你把这两个角色从需求中抽象出来,好的设计往往事半功倍。虽然你已经熟悉了Disruptor框架的使用,但仍有一层微妙的薄雾笼罩在你对它的全面理解上。现在,让我们一起揭开这层薄雾,深入探索其底层原理。进阶 | Disruptor 的工作流程在此之前,我建议你对上面的代码有一定的印象。当然,我也会为你把代码贴出来,你可以结合着理解。现在,我们开始。为了更好的理解,我画了一张图,你可以看一下: 初始化首先,我们需要初始化一个 Ring Buffer 和一组 EventProcessor(事件处理器)。代码如下:Disruptor<LongEvent> disruptor = new Disruptor<>(factory, bufferSize, executor);
Ring Buffer 是一个环形的数据结构,可以存储并传输数据(事件)。EventProcessor 是处理事件的消费者。生产事件生产者通过 Ring Buffer 的 next() 方法获取一个事件槽(slot),假如是图中的slot-2。这个方法返回的是一个序列号,表示分配给这个事件的唯一标识。代码如下:long sequence = ringBuffer.next();
填充事件生产者使用这个序列号获取对应的事件槽,并在其中填充数据。这个步骤可以是生产者自行完成,也可以是通过 Ring Buffer 的 publishEvent() 方法进行。我们上面的示例是通过onData()方法完成。我们结合另一张图,继续往下看: 发布事件当事件数据填充完成后,生产者需要调用 Ring Buffer 的 publish() 方法来发布这个事件。这个方法会更新 Ring Buffer 中的序列号,表示新的事件已经准备好并可供消费。代码如下:finally {
ringBuffer.publish(sequence);
}
消费事件每个 EventProcessor 都有一个 SequenceBarrier(序列屏障)。这个序列屏障会持续监控 Ring Buffer 的序列号。消费者会在序列屏障的 waitFor() 方法处阻塞,直到有新的事件发布。处理事件当 Ring Buffer 的序列号更新(即有新的事件发布)后,waitFor() 方法会返回最新的可用序列号,消费者就可以开始处理对应的事件。更新消费者序列消费者处理完事件后,需要更新其自身的序列,表示它已经完成了对应的事件处理。这通常是通过 EventProcessor 的 onEvent() 方法来完成的。代码如下: public void onEvent(LongEvent event, long sequence, boolean endOfBatch) {
System.out.println("事件值: " + event.getValue());
}
重复过程上述的过程会不断重复,生产者和消费者会持续在 Ring Buffer 中进行事件的生产和消费。多生产者和多消费者单生产者单消费者只是为了方便你理解而举得特例,实际我们在使用的时候,它可以支持多生产者和多消费者的模型。生产者和消费者各自有一个序列号来跟踪它们在 RingBuffer 中的位置。生产者间通过 CAS(Compare And Swap)操作安全地发布事件,消费者通过序列屏障来确定何时可以安全地读取事件。高级 | Disruptor 源码分析还是老规矩,以问题出发来看源码。上面文章讲到Disruptor高性能的秘密在于它独特的设计理念。我们来回顾一下,有哪些设计理念:零阻塞,预分配数据,数据的局部性接下来,我们一个一个进行分析。零阻塞上面的代码中,我们是通过next()方法来获取sequence。理所应当的,我们也来分析这个方法。 public long next()
{
return sequencer.next();
}
很简单的代码,通过Sequencer来调用next()方法。因为是单生产者,所以分支走到SingleProducerSequencer我们接着往下看:public long next(int n)
{
// 检查参数n是否大于等于1,n表示我们想要生产的事件的数量
if (n < 1)
{
throw new IllegalArgumentException("n must be > 0");
}
// 获取下一个序列号
long nextValue = this.nextValue;
// 计算下一个事件的序列号和超过缓冲区大小的位置
long nextSequence = nextValue + n;
long wrapPoint = nextSequence - bufferSize;
// 获取缓存的gatingSequence
long cachedGatingSequence = this.cachedValue;
// 如果wrapPoint大于缓存的gatingSequence,或者缓存的gatingSequence大于当前的序列号,那么就会发生自旋等待
if (wrapPoint > cachedGatingSequence || cachedGatingSequence > nextValue)
{
cursor.setVolatile(nextValue); // StoreLoad fence
long minSequence;
// 自旋等待,直到wrapPoint不再大于minSequence
while (wrapPoint > (minSequence = Util.getMinimumSequence(gatingSequences, nextValue)))
{
LockSupport.parkNanos(1L); // 使用LockSupport.parkNanos(1L)实现自旋等待
}
// 更新缓存的gatingSequence
this.cachedValue = minSequence;
}
// 更新下一个序列号
this.nextValue = nextSequence;
// 返回下一个事件的序列号
return nextSequence;
}
这段代码的主要设计目标是确保生产者不会生产出消费者还未消费的事件,以此来保证零阻塞。它通过维护一个cachedValue,并使用自旋等待来实现这一目标。如果消费者已经消费了足够多的事件,那么生产者就可以生产更多的事件。如果消费者还没有消费足够的事件,那么生产者就会进入自旋等待,直到消费者消费了足够的事件。接着,我们来看下消费端的waitFor()方法:public long waitFor(final long sequence)
throws AlertException, InterruptedException, TimeoutException
{
// 检查是否有警报,如果有,抛出AlertException
checkAlert();
// 通过waitStrategy.waitFor方法实现等待策略,返回当前可用的最大序列号
long availableSequence = waitStrategy.waitFor(sequence, cursorSequence, dependentSequence, this);
// 如果当前可用的最大序列号小于消费者希望消费的序列号,那么消费者只能消费到当前可用的最大序列号
if (availableSequence < sequence)
{
return availableSequence;
}
// 如果当前可用的最大序列号大于等于消费者希望消费的序列号,消费者可以尝试消费更多的事件,方法是通过获取当前已经发布的最大序列号
return sequencer.getHighestPublishedSequence(sequence, availableSequence);
}
这段代码的主要设计目标是确保消费者只消费生产者已经生产出的事件。如果生产者还没有生产出消费者希望消费的事件,那么消费者需要等待。等待的具体策略由waitStrategy.waitFor实现,这也是实现"零阻塞"设计的关键。如果你对它感兴趣,可以阅读源码,查看每个策略的实现。篇幅有限,我这里就不展开讨论。依然是篇幅有限原因,后面的预分配数据,数据的局部性 我就不讨论了,就当给各位的作业,如果您对它感兴趣,欢迎你去阅读源码。至此,源码已经为你分析完了。现在你是不是已经掌握了Disruptor 框架了呢?进阶 | Disruptor Vs ArrayBlockingQueue 性能分析还记得最开始的那张图吗?我为你展示Disruptor 和·ArrayBlockingQueue 两者的性能差异。但是,光图可不够,我们得自己验证一下。老规矩,上代码。 首先是Disruptor 测试,我们直接来模拟亿级的“set”操作。测试代码如下: Executor executor = Executors.newCachedThreadPool();
LongEventFactory factory = new LongEventFactory();
int bufferSize = 1024;
Disruptor<LongEvent> disruptor = new Disruptor<>(factory, bufferSize, executor);
disruptor.handleEventsWith(new LongEventConsumerHandler());
disruptor.start();
RingBuffer<LongEvent> ringBuffer = disruptor.getRingBuffer();
LongEventProducer producer = new LongEventProducer(ringBuffer);
long start = System.currentTimeMillis();
for (long l = 0; l < 100000000; l++) {
producer.onData(l);
}
long end = System.currentTimeMillis();
System.out.println("Disruptor 花费时间: " + (end - start) + " ms");
执行结果:Connected to the target VM, address: '127.0.0.1:2874', transport: 'socket'
Disruptor 花费时间: 31507 ms
再看看下ArrayBlockingQueue:ArrayBlockingQueue<Long> queue = new ArrayBlockingQueue<>(1024);
// 消费者线程
new Thread(() -> {
while (true) {
try {
Long l = queue.take();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
long start = System.currentTimeMillis();
for (long l = 0; l < 10000000; l++) {
queue.put(l);
}
long end = System.currentTimeMillis();
System.out.println("ArrayBlockingQueue 花费时间: " + (end - start) + " ms");
结果如下:Connected to the target VM, address: '127.0.0.1:2650', transport: 'socket'
ArrayBlockingQueue 花费时间: 38728 ms
这....好像差不不太大啊,那我还不如用我熟悉的ArrayBlockingQueue 降低学习门槛。别急,我们接着往下看。查阅官网后,和大量博客文献进行分析,我们得知:Disruptor 的优势在于能够处理大量的并发事件和对高吞吐率的支持。接下来,我们从这两个关键的点来讨论:任务性质如果任务本身需要消耗大量的 CPU 时间,那么并发框架的选择可能不会对总体性能产生太大影响。相反,如果任务主要是 I/O 绑定,或者包含大量的等待,那么 Disruptor 由于其设计的非阻塞性,可能会显著提高性能。并发水平当并发的线程数量增加时,一些传统的并发容器可能会因为线程上下文切换的成本和锁竞争而性能下降。在这种情况下,Disruptor 的无锁设计和对线程调度的优化可能会带来显著的性能提升。原来是这样啊,看来不能单纯的看时间的多少,还得看场景。以并发水平为例,我们来进行测试。代码如下:ExecutorService executorService = Executors.newCachedThreadPool();
LongEventFactory factory = new LongEventFactory();
int bufferSize = 1024;
Disruptor<LongEvent> disruptor = new Disruptor<>(factory, bufferSize, executorService);
disruptor.handleEventsWith(new LongEventConsumerHandler(), new LongEventConsumerHandler());
disruptor.start();
RingBuffer<LongEvent> ringBuffer = disruptor.getRingBuffer();
List<LongEventProducer> longEventProducers = new ArrayList<>();
for (int i = 0; i < 10; i++) {
longEventProducers.add(new LongEventProducer(ringBuffer));
}
long start = System.currentTimeMillis();
CountDownLatch latch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
int finalI = i;
executorService.execute(() -> {
for (long l = 0; l < 10000000; l++) {
longEventProducers.get(finalI).onData(l);
}
latch.countDown();
});
}
latch.await();
long end = System.currentTimeMillis();
System.out.println("Disruptor 花费时间: " + (end - start) + " ms");
我们开辟了10个生产者来进行“set”值。老规矩,看下结果。Connected to the target VM, address: '127.0.0.1:8515', transport: 'socket'
Disruptor 花费时间: 33714 ms
和上面单线程的差别不大,如果你在ArrayBlockingQueue中使用多线程,你会看到一个惊人的数字:Connected to the target VM, address: '127.0.0.1:9340', transport: 'socket'
ArrayBlockingQueue 花费时间: 256174 ms
性能分析到这里就结束了。读到这里,你应该越发觉得框架在使用场景上的选型到底有多重要了吧。接下来,我们来看下使用场景和注意事项。进阶 | Disruptor 的适用场景和使用注意事项看完之后,你是不是想把Disruptor 放到生产去实践?别急,看完这些你再考虑也不迟。Disruptor的适用场景高并发、低延迟Disruptor最初是为高性能、低延迟的场景设计的。它在一些金融交易、游戏、实时计算等需要高并发、低延迟的场景中有广泛的应用。多生产者、多消费者Disruptor支持多生产者、多消费者模型,适合需要大量并发读写的场景。无锁设计在需要大量并发操作,但又希望避免锁带来的性能损耗的场景中,可以考虑使用Disruptor。事件驱动的架构Disruptor是基于事件驱动的编程模型设计的,如果你的应用架构是事件驱动的,Disruptor是一个不错的选择。它能有效地处理和分发大量的事件,确保事件的快速和准确处理。使用Disruptor的注意事项谨慎配置Disruptor的性能和正确性很大程度上依赖于配置。例如,buffer的大小、等待策略等都需要根据实际场景进行合理配置。正确处理异常在Disruptor中,任何一个消费者抛出的异常都可能会影响到其它消费者。因此,需要在消费者中正确处理异常,避免出现一处异常导致整个系统出问题的情况。注意内存管理Disruptor的高性能部分来自于它对内存的有效管理。但这也要求开发者在使用Disruptor时要更加注意内存管理,避免出现内存泄漏等问题。明确使用场景虽然Disruptor在某些场景下表现优异,但并不是所有场景都适合使用Disruptor。在某些情况下,使用Java自带的并发工具可能更加简单有效。对缓存行填充有一定了解因为Disruptor的设计利用了缓存行填充(false sharing)来提高性能,所以最好对此有一定的理解。如果读者对伪共享感兴趣,后续我会专门出一篇文章进行讲解。总结好,我们来回顾一下。首先,我向你揭示了Disruptor框架高性能的秘密。然后,我们一起深入探讨了Disruptor框架本身。在此基础上,我们深入剖析了Disruptor的工作原理,这让我们对源码的理解更加深入。最后,我们分析了Disruptor和ArrayBlockingQueue在性能上的差异,并对在这个过程中需要注意的问题进行了详细的说明。如果你计划在生产环境中使用Disruptor,我希望你能充分考虑这些问题。至此,本篇结束。附录:相关资源和进一步阅读Dissecting the Disruptor: Why it's so fast (part two) - Magic cache line padding The LMAX Architecture官方github官方github page
cathoy
Java并发编程原理与实战
深入学习并发编程核心概念,包括ThreadLocal的线程私有存储、零错误代码的线程安全编写、锁的兜底方案。掌握Fork/Join框架提升多核CPU效率,实现异步编程简化从Future到CompletableFuture。深入理解高性能异步处理框架Disruptor,处理批量异步任务的CompletionService
cathoy
Java虚拟机深度解析系列
深入探讨JVM内存调优实战,以MAT工具进行问题排查与分析。详解Java类加载的工作原理及一次完全实践,从类加载到VM内存结构全面解析。深度拆解openJDK源码,理解Java虚拟机的内部机制。探讨Java执行引擎结构与工作原理,以及垃圾回收器(GC)在Java内存管理中的关键角色。
cathoy
聊聊 Java 21 中的结构化并发(预览版)
hello,大家好,今天和大家一起聊聊 Java 21 中另一个有意思的预览特性 - 结构化并发。结构化编程在开始聊结构化并发之前,我们先简单聊聊一下结构化编程:Goto Statement Considered Harmful在计算机发展的早期,程序员使用汇编语言进行编程,在之后的一段时期,诞生了比汇编略微高级的编程语言,如 FORTRAN、FLOW-MATIC 等。这些语言虽然在一定程度上提高了可读性,但是仍然存在很大的局限性。如下所示就是一段 FLOW-MATIC 代码:由于当时块语法还没有发明,因此 FLOW-MATIC 不支持 if 块、循环块、函数调用、块修饰符等现代语言必备的基础特性。整段代码就是一系列按顺序排列并打平的命令。关于控制流,程序支持两种方式,分别是:顺序执行、跳转执行,即 GOTO 语句。顺序执行的逻辑非常简单,它总是能够找到执行入口与出口。与之相反,跳转执行则充满了不确定性。如果程序中存在 GOTO 语句,那么它可以在 任何时候跳转至任何指令位置。一旦程序大量使用了 GOTO 语句,那么最终将变成 面条式代码(Spaghetti code)。如下图所示:结构化编程在发表 《Goto Statement Considered Harmful》 之后,Dijkstra 又发表了 《Notes on Structured Programming》 表达了其理想的编程范式,提出了 结构化编程 的概念。结构化编程在现在看来是理所当然的,但是在当时并不是。结构化编程的核心是 基于块语句,实现代码逻辑的抽象与封装,从而保证控制流具有单一入口和单一出口。现代编程语言中的条件语句、循环语句、函数定义与调用都是结构化编程的体现。相比 GOTO 语句,基于块的控制流有一个显著的特征:控制流从程序入口进入,中途可能会经历条件、循环、函数调用等控制流转换,但是最终控制流都会从程序出口退出。这种编程范式使得代码结构变得更加结构化,思维模型变得更加简单,也为编译器在低层级提供了优化的可能。因此,完全禁用 GOTO 语句已经成为了大部分现代编程语言的选择。虽然,少部分编程语言仍然支持 GOTO,但是它们大都支持高德纳(Donald Ervin Knuth)所提出的前进分支和后退分支不得交叉的理论。类似 break、continue 等控制流命令,依然遵循结构化的基本原则:控制流拥有单一的入口与出口。非结构化并发在开始了解结构化并发前,我们先回顾一下 Java 中非结构化并发的写法。 ExecutorService executorService= Executors.newFixedThreadPool(3);
Future<String> user = executorService.submit(() -> getUser());
Future<Integer> order = executorService.submit(() -> getOrder());
String theUser = user.get(); // 加入 getUser
int theOrder = order.get(); // 加入 getOrder
非结构化并发存在的一些问题线程泄漏当 getUser 或者 getOrder 抛出异常时,另外一个任务并不会停止执行,一方面会导致线程资源的浪费,另一方面可能干扰其它任务。又或者其中一个线程已经执行失败,继续执行的线程执行时间很长,这时候需要阻塞等待线程的完成,同样造成资源的浪费。代码本身不会体现任务间的关系上面的各种情况其实都是在开发人员的脑海中,程序逻辑本身并不会体现出来,这样不仅会产生更多的错误空间,而且会使错误排查更加困难。排查错误困难多线程编程中一个比较大的难点就是对错误的追踪,任务运行在不同的线程上,当然我们现在有跨线程追踪的方案,但是远远没有我们使用非并发编程时的简单和方便。结构化并发在单线程编程模型中,编程语言 通过代码块避免控制流随意跳转,从而实现程序的结构化。但在多线程编程(并发编程)模型中,线程之间控制和归属关系仍然存在很多问题,其面临的问题与 GOTO 的问题非常相似,这也是结构化并发所要解决的问题。什么是结构化并发呢?结构化并发的核心是 在并发模型下,也要保证控制流的单一入口和单一出口。程序可以产生多个控制流来实现并发,但是所有并发控制流在出口时都应该处于完成或取消状态,控制流最终在出口处完成合并。下面是非结构化并发(图一)和结构化并发(图二)的运行示例图:Java 结构化并发示例public class Test {
public static void main(String[] args) {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> userFuture = scope.fork(() -> getUser());
Future<Integer> orderFuture = scope.fork(() -> getOrder());
scope.join() // Join both subtasks
.throwIfFailed(); // ... and propagate errors
System.out.println("User: " + userFuture.get());
System.out.println("Order: " + orderFuture.get());
} catch (ExecutionException | InterruptedException e) {
throw new RuntimeException(e);
}
}
private static int getOrder() throws Exception {
// throw new Exception("test");
return 1;
}
private static String getUser() {
return "user";
}
}
结构化并发带来的好处下面我们看看结构化并发如何解决非结构化并发中可能存在的一些问题:短路处理如果一个getOrder()或getUser()一个子任务失败,则另一个尚未完成的任务将被取消。(这是由实施的关闭策略管理的ShutdownOnFailure;其他策略也是可能的,同时支持自定义策略)。避免了线程资源浪费以及可能的无意义阻塞。取消传播如果线程在调用期间被中断join(),则当线程退出作用域时,两个子任务都会自动取消。避免了线程资源浪费。清晰性上面的代码有一个清晰的结构:设置子任务,等待它们完成或被取消,然后决定是成功(并处理已经完成的子任务的结果)还是失败(没有什么需要清理的)。可观察性线程转储 - 线程堆栈信息可以清楚的显示任务层次结构:Exception in thread "main" java.lang.RuntimeException: java.util.concurrent.ExecutionException: java.lang.Exception: test
at Test.main(Test.java:21)
Caused by: java.util.concurrent.ExecutionException: java.lang.Exception: test
at jdk.incubator.concurrent/jdk.incubator.concurrent.StructuredTaskScope$ShutdownOnFailure.throwIfFailed(StructuredTaskScope.java:1188)
at Test.main(Test.java:17)
Caused by: java.lang.Exception: test
at Test.getOrder(Test.java:26)
at Test.lambda$main$1(Test.java:15)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
at java.base/java.lang.VirtualThread.run(VirtualThread.java:305)
at java.base/java.lang.VirtualThread$VThreadContinuation.lambda$new$0(VirtualThread.java:177)
at java.base/jdk.internal.vm.Continuation.enter0(Continuation.java:327)
at java.base/jdk.internal.vm.Continuation.enter(Continuation.java:320)
总结目前结构化并发的目标推广一种并发编程风格,可以消除因取消和关闭而产生的常见风险,例如线程泄漏和取消延迟。提高并发代码的可观察性。以下不是目前非结构化并发的目标不会替换现有的任务并发结构。(java.util.concurrent package, such as ExecutorService and Future)为Java平台定义明确的结构化并发API并不是我们的目标。其他结构化并发结构可以由第三方库或在未来的JDK版本中定义。参考JEP 453: Structured Concurrency (Preview)
cathoy
【多线程系列】JUC 中的另一重要大杀器 AQS 抽象队列同步器
回顾前面我们讲解 JUC 中一个重要的基础工具 CAS, 今天我们来分享 JUC 中的另一重要工具 AQS【多线程系列】高效的 CAS (Compare and Swap)【多线程系列】CAS 常见的两个升级版本 CLH、MCS导读AQS 是什么、底层原理(独占模式、共享模式实现)AQS 变种 CLH 相比于原始 CLH 的改变版本及说明AQSAQS 全称是 AbstractQueuedSynchronizer,是 Java 并发包中的一个抽象类,用于构建各种同步器和锁,如 ReentrantLock、CountDownLatch、Semaphore 等。核心思想基于 CAS 和 变种 CLH 实现对互斥资源的访问;访问互斥资源时,当互斥资源空闲时,通过 CAS 操作将互斥资源置为锁定状态,并将访问线程置为当前线程,当互斥资源被其他线程锁定时,通过变种 CLH 实现的逻辑 FIFO 队列实现对线程的阻塞以及资源释放时的唤醒机制。结构AQS 使用 int 成员变量 state 表示同步状态,通过内置的 FIFO 等待队列 来完成获取资源线程的排队工作。public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer
implements java.io.Serializable {
private transient volatile Node head;
private transient volatile Node tail;
// 使用 volatile 保证变量的可见性
private volatile int state;
protected final int getState() {
return state;
}
protected final void setState(int newState) {
state = newState;
}
// 提供 CAS 操作更新 state 的值
protected final boolean compareAndSetState(int expect, int update) {
// See below for intrinsics setup to support this
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
}
state 状态状态名描述SIGNAL(-1)表示该节点正常等待PROPAGATE(-3)应将 releaseShared 传播到其他节点CONDITION(-2)该节点位于条件队列,不能用于同步队列节点CANCELLED(1)由于超时、中断或其他原因,该节点被取消(0)节点初始状态Node 节点static final class Node {
/**
* Marker to indicate a node is waiting in shared mode
*/
static final Node SHARED = new Node();
/**
* Marker to indicate a node is waiting in exclusive mode
*/
static final Node EXCLUSIVE = null;
/**
* Status field, taking on only the values:
*/
volatile int waitStatus;
// 前置节点
volatile Node prev;
// 后置线程
volatile Node next;
/**
* The thread that enqueued this node. Initialized on
* construction and nulled out after use.
*/
volatile Thread thread;
/**
* Link to next node waiting on condition, or the special
* value SHARED.
*/
Node nextWaiter;
}
独占模式和共享模式AQS 支持两种资源共享方式:Exclusive(独占,只有一个线程能执行,如基于独占模式实现的 ReentrantLock)和 Share(共享,多个线程可同时执行,如基于共享模式实现的 Semaphore/CountDownLatch)。独占模式获取锁 /**
* 获取独占锁主流程:
* 1、阻塞获取锁,获取锁逻辑由具体同步器重写 tryAcquire() 实现
* 2、获取锁成功直接返回,获取锁失败进入 FIFO 线程进行线程的阻塞和唤醒
* 2.1、调用 addWaiter() 将当前线程封装为 Node 节点并入队
* 2.2、入队成功后在 acquireQueued() 方法尝试自旋获取锁或阻塞当前线程
*
* @param arg
*/
public final void acquire(int arg) {
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
/**
* 将当前线程封装为 Node 节点并入队
*
* @param mode
* @return
*/
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;
// 当队尾节点不为 null 时使用 CAS 快速入队
// 这种写法可以借鉴,可以提高性能(减少小概率的临界值判断)
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 快速入队失败 重新入队直到入队成功
enq(node);
return node;
}
private Node enq(final Node node) {
for (; ; ) {
Node t = tail;
// 队尾节点为空 初始化一个哨兵节点
// 作用:统一处理逻辑,首节点是哨兵节点或持有锁线程(正在持有或已释放唤起后续线程)
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
// CAS 入队
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
/**
* Node 节点入 FIFO 等待队列后 进行 CAS 操作获取锁或线程阻塞
* @param node
* @param arg
* @return
*/
final boolean acquireQueued(final Node node, int arg) {
// 获取锁结果
boolean failed = true;
try {
// 是否被中断
boolean interrupted = false;
for (; ; ) {
final Node p = node.predecessor();
// 当前节点为等待队列中的第二个节点 尝试 CAS 获取锁
// 前置可能节点为 哨兵节点 或 已经释放锁节点 尝试 CAS 获取锁
if (p == head && tryAcquire(arg)) {
// 获取成功设置当前线程为 头节点
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
// 非第二节点/获取失败判断是否阻塞当前线程 & 阻塞线程并判断线程是否被中断
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
// 超时、中断导致线程获取锁失败时 标记节点状态为 Cancel
if (failed)
cancelAcquire(node);
}
}
private void setHead(Node node) {
head = node;
node.thread = null;
node.prev = null;
}
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
// 前置节点 处于等待状态 当前节点线程阻塞挂起
if (ws == Node.SIGNAL)
return true;
// 前置节点已取消 去除队列中已取消节点
if (ws > 0) {
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
/*
* waitStatus must be 0 or PROPAGATE. Indicate that we
* need a signal, but don't park yet. Caller will need to
* retry to make sure it cannot acquire before parking.
*/
// 前置节点状态只能是 0 或 PROPAGATE 可能需要等待
// 将前置节点 状态置为 SIGNAL 并 重新尝试是否可以获取锁
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
private void cancelAcquire(Node node) {
// Ignore if node doesn't exist
if (node == null)
return;
node.thread = null;
// Skip cancelled predecessors
Node pred = node.prev;
while (pred.waitStatus > 0)
node.prev = pred = pred.prev;
// predNext is the apparent node to unsplice. CASes below will
// fail if not, in which case, we lost race vs another cancel
// or signal, so no further action is necessary.
Node predNext = pred.next;
// Can use unconditional write instead of CAS here.
// After this atomic step, other Nodes can skip past us.
// Before, we are free of interference from other threads.
node.waitStatus = Node.CANCELLED;
// If we are the tail, remove ourselves.
if (node == tail && compareAndSetTail(node, pred)) {
compareAndSetNext(pred, predNext, null);
} else {
// If successor needs signal, try to set pred's next-link
// so it will get one. Otherwise wake it up to propagate.
int ws;
if (pred != head &&
((ws = pred.waitStatus) == Node.SIGNAL ||
(ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
pred.thread != null) {
Node next = node.next;
if (next != null && next.waitStatus <= 0)
compareAndSetNext(pred, predNext, next);
} else {
// 唤醒节点条件:pred == head或者pred.thread == null 第一个节点;
// ((ws = pred.waitStatus) != Node.SIGNAL 并且 (ws >0 || compareAndSetWaitStatus(pred, ws, Node.SIGNAL) == false)):前置节点突然释放锁
unparkSuccessor(node);
}
node.next = node; // help GC
}
}
释放锁 public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
// 等待队列不为空 同时状态不为 初始状态(节点初始化已完成)
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
// 将头节点状态置为 初始状态 0
compareAndSetWaitStatus(node, ws, 0);
// 从尾到头查找到最早的入队可以唤醒的节点(不包括头节点)
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
// 唤醒找到的节点
if (s != null)
LockSupport.unpark(s.thread);
}
共享模式获取锁 /**
* 获取共享锁主流程:
* 1、尝试获取贡献锁 获取锁逻辑由具体同步器重写 tryAcquire() 实现
* 2、获取锁成功直接返回,获取锁失败进入 FIFO 线程进行线程的阻塞和唤醒
* 2.1、调用 addWaiter() 将当前线程封装为 Node 节点并入队
*2.2、入队成功后尝试自旋获取锁(获取成功后走共享锁唤醒逻辑 setHeadAndPropagate)或阻塞当前线程
* @param arg
*/
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
private void doAcquireShared(int arg) {
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head) {
int r = tryAcquireShared(arg);
if (r >= 0) {
// 设置头节点 & 共享锁传播唤醒逻辑
setHeadAndPropagate(node, r);
p.next = null; // help GC
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
// 和独占锁一致
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
// 和独占锁一致
if (failed)
cancelAcquire(node);
}
}
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // Record old head for check below
// 设置为头结点
setHead(node);
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
// 如果后继节点为空或者后继节点为共享类型,则进行唤醒后继节点
if (s == null || s.isShared())
// 读锁唤醒往后传播(A 被唤醒获取锁唤醒 B ,B 被唤醒被获取锁唤醒 C...)
// 见释放锁
doReleaseShared();
}
}
释放锁 public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
// 唤醒后一个等待节点
doReleaseShared();
return true;
}
return false;
}
private void doReleaseShared() {
*/
for (;;) {
// 执行唤醒逻辑(如果从setHeadAndPropagate方法调用该方法,那么这里的head是新的头节点)
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
// CAS原子操作,因为setHeadAndPropagate和releaseShared这两个方法都会调用doReleaseShared,避免多次unpark唤醒操作
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
// 唤醒节点
unparkSuccessor(h);
}
// 如果后继节点暂时不需要唤醒,那么当前头节点状态更新为PROPAGATE,确保后续可以传递给后继节点
else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
// 防止其它线程设置了头节点,其它线程已经获取锁,交给其它线程处理
if (h == head) // loop if head changed
break;
}
}
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
// 将头节点状态置为 初始状态 0
compareAndSetWaitStatus(node, ws, 0);
// 从尾到头查找到最早的入队可以唤醒的节点(不包括头节点)
// 从尾到头的原因:避免已经入队但通过 next 节点查找不到(https://blog.csdn.net/foxException/article/details/108917338)
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
// 唤醒找到的节点
if (s != null)
LockSupport.unpark(s.thread);
}
模版方法的使用AQS 使用了模板方法模式,当实现自定义同步器时需要重写下面几个 AQS 提供的钩子方法://独占方式。尝试获取资源,成功则返回true,失败则返回false。
protected boolean tryAcquire(int)
//独占方式。尝试释放资源,成功则返回true,失败则返回false。
protected boolean tryRelease(int)
//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
protected int tryAcquireShared(int)
//共享方式。尝试释放资源,成功则返回true,失败则返回false。
protected boolean tryReleaseShared(int)
//该线程是否正在独占资源。只有用到condition才需要去实现它。
protected boolean isHeldExclusively()
AQS 条件队列 Condition在 AQS 的基础上,Java 提供了一个更高级的同步工具 Condition,它允许线程在特定条件下等待和唤醒,以实现更复杂的线程间通信。实现 synchronized 对象锁中的wait、notify、notifyAll, Condition 支持多条件,可以实现更细粒度的控制。仅支持独占锁。使用示例public class MainTest {
public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();
new Thread(() -> {
lock.lock();
System.out.println("线程1获取锁");
// 条件等待 释放锁
System.out.println("线程1条件等待、释放锁");
condition.awaitUninterruptibly();
System.out.println("线程1重新获取锁");
lock.unlock();
System.out.println("线程1释放锁");
}).start();
new Thread(() -> {
lock.lock();
System.out.println("线程2获取锁");
// 条件等待 释放锁
System.out.println("线程唤醒条件队列的的一个锁");
condition.signal();
lock.unlock();
System.out.println("线程2释放锁");
}).start();
}
}
// 运行结果:
线程1获取锁
线程1条件等待、释放锁
线程2获取锁
线程唤醒条件队列的的一个锁
线程2释放锁
线程1重新获取锁
线程1释放锁
类图ConditionObject 类结构Condition 接口提供了常见的标准方法,ConditionObject 类是具体实现。 public class ConditionObject implements Condition, java.io.Serializable {
private static final long serialVersionUID = 1173984872572414699L;
/** First node of condition queue. */
private transient Node firstWaiter;
/** Last node of condition queue. */
private transient Node lastWaiter;
}
Condition 接口提供的方法 //响应线程中断的条件等待
void await() throws InterruptedException;
//不响应线程中断的条件等待
void awaitUninterruptibly();
//设置相对时间的条件等待(不进行自旋)
long awaitNanos(long nanosTimeout) throws InterruptedException;
//设置相对时间的条件等待(进行自旋)
boolean await(long time, TimeUnit unit) throws InterruptedException;
//设置绝对时间的条件等待
boolean awaitUntil(Date deadline) throws InterruptedException;
//唤醒条件队列中的头结点
void signal();
//唤醒条件队列的所有结点
void signalAll();
核心方法下面以 await、signal 两个核心方法介绍 Condition 的底层实现。await() public final void await() throws InterruptedException {
// 如果线程被中断 抛出中断异常
if (Thread.interrupted()) throw new InterruptedException();
// 将节点加入到条件队列
Node node = addConditionWaiter();
// 释放之前获取的锁资源
int savedState = fullyRelease(node);
int interruptMode = 0;
// 当不再同步队列时才挂起线程(因为唤醒时会重新加入同步队列竞争锁)
while (!isOnSyncQueue(node)) {
LockSupport.park(this);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
}
// AQS acquireQueued 方法逻辑 加入同步队列后等待获取锁逻辑
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null) // clean up if cancelled
unlinkCancelledWaiters();
// 中断逻辑处理
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
}
private Node addConditionWaiter() {
Node t = lastWaiter;
// If lastWaiter is cancelled, clean out.
if (t != null && t.waitStatus != Node.CONDITION) {
// 从后往前清理已经取消的线程
unlinkCancelledWaiters();
t = lastWaiter;
}
// 将当前线程加入到条件队列中(获取互斥锁时执行 所有不用加锁)
Node node = new Node(Thread.currentThread(), Node.CONDITION);
if (t == null)
firstWaiter = node;
else
t.nextWaiter = node;
lastWaiter = node;
return node;
}
// AQS 释放锁逻辑
final int fullyRelease(Node node) {
boolean failed = true;
try {
// 获取当前节点的 state
int savedState = getState();
// 释放锁
if (release(savedState)) {
failed = false;
return savedState;
} else {
throw new IllegalMonitorStateException();
}
} finally {
if (failed)
node.waitStatus = Node.CANCELLED;
}
}
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
final boolean isOnSyncQueue(Node node) {
// 说明在条件队列中,不再同步队列
if (node.waitStatus == Node.CONDITION || node.prev == null)
return false;
if (node.next != null) // If has successor, it must be on queue
return true;
return findNodeFromTail(node);
}
// 从同步队列尾部开始遍历线程是否在同步队列中
private boolean findNodeFromTail(Node node) {
Node t = tail;
for (; ; ) {
if (t == node)
return true;
if (t == null)
return false;
t = t.prev;
}
}
signal() public final void signal() {
// 当前持有锁线程才可唤醒
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
Node first = firstWaiter;
// 存在需要唤醒的线程
if (first != null)
doSignal(first);
}
/**
* 遍历条件队列 从前往后尝试获取一个有效的线程(非取消)
*
* @param first
*/
private void doSignal(Node first) {
do {
// firstWaiter 头节点指向条件队列头的下一个节点
if ((firstWaiter = first.nextWaiter) == null)
lastWaiter = null;
// 原来的头节点和同步队列断开
first.nextWaiter = null;
} while (!transferForSignal(first) &&
(first = firstWaiter) != null);
}
// 是否唤醒一个有效线程(从前往后依次尝试)
final boolean transferForSignal(Node node) {
// 判断节点是否已经在之前被取消
if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
return false;
// 调用 enq 添加到 同步队列的尾部
Node p = enq(node);
int ws = p.waitStatus;
// 同步节点前置节点 修改为 SIGNAL 等待后续唤醒
if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL))
LockSupport.unpark(node.thread);
return true;
}
// AQS 入同步队列逻辑
private Node enq(final Node node) {
for (; ; ) {
Node t = tail;
// 尾节点为空 需要初始化头节点,此时头尾节点是一个
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
// 不为空 循环赋值
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
// signalAll() 区别在于会唤醒条件队列中的所有等待线程
private void doSignalAll(AbstractQueuedSynchronizer.Node first) {
lastWaiter = firstWaiter = null;
do {
AbstractQueuedSynchronizer.Node next = first.nextWaiter;
first.nextWaiter = null;
transferForSignal(first);
first = next;
} while (first != null);
}
补充AQS 变种 CLH 改进点将 CLH 自旋操作改为线程阻塞操作扩展每个节点的状态、显式的维护前驱节点和后继节点以及出队节点显式设为 null 等辅助 GC 的优化来支持更多功能参考【多线程系列】CAS 常见的两个升级版本 CLH、MCS预告下一篇文章将介绍基于 AQS 实现的同步器,也就是我们常常使用的 ReentrantLock 、ReentrantReadWriteLock 等等。