最近开发中遇到一个bug,报表查询条件多选时会有一个 dept in (0001,0002) 的条件,但是在数据库中dept设计的却不是数值类型,导致如果dept存在中文就会在查询时出现bug-> ORA-01722: 无效数字

阅读全文 »

前言

前段时间购买了一个4k分辨率的便携显示器,连接nuc8黑苹果后发现无法开启hidpi 但是在缩放分辨率中能看到1080p的选项,使用此选项也能实现hidpi的效果,但是作为一个强迫症患者,不能在设置中原始显示还是有点难受的。所以就有了这个教程。

阅读全文 »

什么是RPC 服务

RPC,也就是远程过程调用,是分布式系统中不同节点调用的方式(进程间通信),属于 C/S 模式。RPC 由客户端发起,调用服务端的方法进行通信,然后服务端把结果返回给客户端。

RPC的核心有两个:通信协议序列化。在 HTTP 2 之前,一般采用自定义 TCP 协议的方式进行通信,HTTP 2 出来后,也有采用该协议的,比如流行的gRPC。

序列化和反序列化是一种把传输内容编码和解码的方式,常见的编解码方式有 JSON、Protobuf 等。

阅读全文 »

使用G语言编写 RESTful API 和 RPC 服务。在实际开发项目中,编写的这些服务可以被其他服务使用,这样就组成了微服务的架构;也可以被前端调用,这样就可以前后端分离。

阅读全文 »

Oh My Zsh is a delightful, open source, community-driven framework for managing your Zsh configuration. It comes bundled with thousands of helpful functions, helpers, plugins, themes, and a few things that make you shout…

“Oh My ZSH!”

阅读全文 »

任何业务,都是从简单向复杂演进的。而在业务演进的过程中,技术是从单体向多模块、多服务演进的。技术的这种演进方式的核心目的是复用代码、提高效率。

阅读全文 »

代码规范检查

什么是代码规范检查

代码规范检查,顾名思义,是从 Go 语言层面出发,依据 Go 语言的规范,对你写的代码进行的静态扫描检查,这种检查和你的业务无关。

比如你定义了个常量,从未使用过,虽然对代码运行并没有造成什么影响,但是这个常量是可以删除的,代码如下所示:

1
2
3
const name = "Golang"
func main() {
}

示例中的常量 name 其实并没有使用,所以为了节省内存你可以删除它,这种未使用常量的情况就可以通过代码规范检查检测出来。
再比如,你调用了一个函数,该函数返回了一个 error,但是你并没有对该 error 做判断,这种情况下,程序也可以正常编译运行。但是代码写得不严谨,因为返回的 error 被我们忽略了。代码如下所示:

1
2
3
func main() {
os.Mkdir("tmp",0666)
}

示例代码中,Mkdir 函数是有返回 error 的,但是并没有对返回的 error 做判断,这种情况下,哪怕创建目录失败,你也不知道,因为错误被忽略了。如果使用代码规范检查,这类潜在的问题也会被检测出来。

以上两个例子可以理解什么是代码规范检查、它有什么用。除了这两种情况,还有拼写问题、死代码、代码简化检测、命名中带下划线、冗余代码等,都可以使用代码规范检查检测出来。

golangci-lint

要想对代码进行检查,则需要对代码进行扫描,静态分析写的代码是否存在规范问题。

小提示:静态代码分析是不会运行代码的。

可用于 Go 语言代码分析的工具有很多,比如 golint、gofmt、misspell 等,如果一一引用配置,就会比较烦琐,所以通常我们不会单独地使用它们,而是使用 golangci-lint。

golangci-lint 是一个集成工具,它集成了很多静态代码分析工具,便于我们使用。通过配置这一工具,我们可以很灵活地启用需要的代码规范检查。

如果要使用 golangci-lint,首先需要安装。因为 golangci-lint 本身就是 Go 语言编写的,所以我们可以从源代码安装它,打开终端,输入如下命令即可安装。

1
go install github.com/golangci/golangci-lint/cmd/[email protected]

安装完成后,在终端输入如下命令,检测是否安装成功。

1
2
golangci-lint version
golangci-lint has version v1.49.0 built from (unknown, mod sum: "h1:I8WHOavragDttlLHtSraHn/h39C+R60bEQ5NoGcHQr8=") on (unknown)

小提示:在 MacOS 下也可以使用 brew 来安装 golangci-lint。

好了,安装成功 golangci-lint 后,就可以使用它进行代码检查了,我以上面示例中的常量 name 和 Mkdir 函数为例,演示 golangci-lint 的使用。在终端输入如下命令回车:

1
2
3
4
5
6
7
8
9
10
golangci-lint run .
//结果
main.go:8:10: Error return value of `os.Mkdir` is not checked (errcheck)
os.Mkdir("tmp", 0666)
^
main.go:5:7: const `name` is unused (unused)
const name = "Golang"
^


通过代码检测结果可以看到,两个代码规范问题都被检测出来了。

golangci-lint 配置

golangci-lint 的配置比较灵活,比如你可以自定义要启用哪些 linter。golangci-lint 默认启用的 linter,包括这些:

1
2
3
4
5
6
7
8
9
10
deadcode - 死代码检查
errcheck - 返回错误是否使用检查
gosimple - 检查代码是否可以简化
govet - 代码可疑检查,比如格式化字符串和类型不一致
ineffassign - 检查是否有未使用的代码
staticcheck - 静态分析检查
structcheck - 查找未使用的结构体字段
typecheck - 类型检查
unused - 未使用代码检查
varcheck - 未使用的全局变量和常量检查

小提示:golangci-lint 支持的更多 linter,可以在终端中输入 golangci-lint linters 命令查看,并且可以看到每个 linter 的说明。

如果要修改默认启用的 linter,就需要对 golangci-lint 进行配置。即在项目根目录下新建一个名字为 .golangci.yml 的文件,这就是 golangci-lint 的配置文件。在运行代码规范检查的时候,golangci-lint 会自动使用它。假设我只启用 unused 检查,可以这样配置:

1
2
3
4
5
6
7
.golangci.yml

linters:
disable-all: true
enable:
- unused

在团队多人协作开发中,有一个固定的 golangci-lint 版本是非常重要的,这样大家就可以基于同样的标准检查代码。要配置 golangci-lint 使用的版本也比较简单,在配置文件中添加如下代码即可:

1
2
service:
golangci-lint-version: 1.32.2 # use the fixed version to not introduce new linters unexpectedly

此外,你还可以针对每个启用的 linter 进行配置,比如要设置拼写检测的语言为 US,可以使用如下代码设置:

1
2
3
linters-settings:
misspell:
locale: US

golangci-lint 的配置比较多,你自己可以灵活配置。关于 golangci-lint 的更多配置可以参考官方文档,给出一个常用的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
.golangci.yml

linters-settings:
golint:
min-confidence: 0
misspell:
locale: US
linters:
disable-all: true
enable:
- typecheck
- goimports
- misspell
- govet
- golint
- ineffassign
- gosimple
- deadcode
- structcheck
- unused
- errcheck
service:
golangci-lint-version: 1.32.2 # use the fixed version to not introduce new linters unexpectedly

集成 golangci-lint 到 CI

代码检查一定要集成到 CI 流程中,效果才会更好,这样开发者提交代码的时候,CI 就会自动检查代码,及时发现问题并进行修正。

不管你是使用 Jenkins,还是 Gitlab CI,或者 Github Action,都可以通过Makefile的方式运行 golangci-lint。现在我在项目根目录下创建一个 Makefile 文件,并添加如下代码:

1
2
3
4
5
6
7
8
9
10
Makefile

getdeps:
@mkdir -p ${GOPATH}/bin
@which golangci-lint 1>/dev/null || (echo "Installing golangci-lint" && go get github.com/golangci/golangci-lint/cmd/[email protected])
lint:
@echo "Running [email protected] check"
@GO111MODULE=on ${GOPATH}/bin/golangci-lint cache clean
@GO111MODULE=on ${GOPATH}/bin/golangci-lint run --timeout=5m --config ./.golangci.yml
verifiers: getdeps lint

好了,现在你就可以把如下命令添加到你的 CI 中了,它可以帮你自动安装 golangci-lint,并检查你的代码。

1
make verifiers

性能优化

性能优化的目的是让程序更好、更快地运行,但是它不是必要的,这一点一定要记住。所以在程序开始的时候,不必刻意追求性能优化,先大胆地写代码就好了,写正确的代码是性能优化的前提

堆分配还是栈

在比较古老的 C 语言中,内存分配是手动申请的,内存释放也需要手动完成

  • 手动控制有一个很大的好处就是你需要多少就申请多少,可以最大化地利用内存

  • 但是这种方式也有一个明显的缺点,就是如果忘记释放内存,就会导致内存泄漏

所以,为了让程序员更好地专注于业务代码的实现,Go 语言增加了垃圾回收机制,自动地回收不再使用的内存。

Go 语言有两部分内存空间:栈内存堆内存

栈内存由编译器自动分配和释放,开发者无法控制。栈内存一般存储函数中的局部变量、参数等,函数创建的时候,这些内存会被自动创建;函数返回的时候,这些内存会被自动释放

堆内存的生命周期比栈内存要长,如果函数返回的值还会在其他地方使用,那么这个值就会被编译器自动分配到堆上。堆内存相比栈内存来说,不能自动被编译器释放,只能通过垃圾回收器才能释放,所以栈内存效率会很高。

逃逸分析

既然栈内存的效率更高,肯定是优先使用栈内存。那么 Go 语言是如何判断一个变量应该分配到堆上还是栈上的呢?这就需要逃逸分析了。下面通过一个示例来学习逃逸分析,代码如下:

1
2
3
4
5
func newString() *string{
s:=new(string)
*s = "Golang"
return s
}

在这个示例中:

  • 通过 new 函数申请了一块内存;

  • 然后把它赋值给了指针变量 s;

  • 最后通过 return 关键字返回。

小提示:以上 newString 函数是没有意义的,这里只是为了方便演示。
现在通过逃逸分析来看下是否发生了逃逸,命令如下:

1
2
3
4
5
go build -gcflags="-m -l" .
结果
# awesomeProject
./main.go:4:10: new(string) escapes to heap

在这一命令中,-m 表示打印出逃逸分析信息,-l 表示禁止内联,可以更好地观察逃逸。从以上输出结果可以看到,发生了逃逸,也就是说指针作为函数返回值的时候,一定会发生逃逸

逃逸到堆内存的变量不能马上被回收,只能通过垃圾回收标记清除,增加了垃圾回收的压力,所以要尽可能地避免逃逸,让变量分配在栈内存上,这样函数返回时就可以回收资源,提升效率。

下面我对 newString 函数进行了避免逃逸的优化,优化后的函数代码如下:

1
2
3
4
5
func newString() string{
s:=new(string)
*s = "Golang"
return *s
}

再次通过命令查看以上代码的逃逸分析,命令如下:

1
2
3
4
5
go build -gcflags="-m -l" .
运行结果
# awesomeProject
./main.go:4:10: new(string) does not escape

通过分析结果可以看到,虽然还是声明了指针变量 s,但是函数返回的并不是指针,所以没有发生逃逸。

这就是关于指针作为函数返回逃逸的例子,那么是不是不使用指针就不会发生逃逸了呢?下面看个例子,代码如下:

1
fmt.Println("Golang")
1
2
3
4
5
go build -gcflags="-m -l" .
运行结果
./main.go:7:13: ... argument does not escape
./main.go:7:14: "Golang" escapes to heap

观察这一结果,你会发现「Golang」这个字符串逃逸到了堆上,这是因为「Golang」这个字符串被已经逃逸的指针变量引用,所以它也跟着逃逸了,引用代码如下:

1
2
3
4
func (p *pp) printArg(arg interface{}, verb rune) {
p.arg = arg
//省略其他无关代码
}

所以被已经逃逸的指针引用的变量也会发生逃逸

Go 语言中有 3 个比较特殊的类型,它们是 slice、map 和 chan,被这三种类型引用的指针也会发生逃逸,看个这样的例子:

1
2
3
4
5
func main() {
m:=map[int]*string{}
s:="Golang"
m[0] = &s
}
1
2
3
4
5
6
go build -gcflags="-m -l" .
运行结果
# awesomeProject
./main.go:5:2: moved to heap: s
./main.go:4:22: map[int]*string{} does not escape

从这一结果可以看到,变量 m 没有逃逸,反而被变量 m 引用的变量 s 逃逸到了堆上。所以被map、slice 和 chan 这三种类型引用的指针一定会发生逃逸的。

逃逸分析是判断变量是分配在堆上还是栈上的一种方法,在实际的项目中要尽可能避免逃逸,这样就不会被 GC 拖慢速度,从而提升效率。

小技巧:从逃逸分析来看,指针虽然可以减少内存的拷贝,但它同样会引起逃逸,所以要根据实际情况选择是否使用指针。

优化技巧

通过前面小节的介绍,相信你已经了解了栈内存和堆内存,以及变量什么时候会逃逸,那么在优化的时候思路就比较清晰了,因为都是基于以上原理进行的。下面我总结几个优化的小技巧:

第 1 个需要介绍的技巧是尽可能避免逃逸,因为栈内存效率更高,还不用 GC。比如小对象的传参,array 要比 slice 效果好。

如果避免不了逃逸,还是在堆上分配了内存,那么对于频繁的内存申请操作,我们要学会重用内存,比如使用 sync.Pool,这是第 2 个技巧。

第 3 个技巧就是选用合适的算法,达到高性能的目的,比如空间换时间。

小提示:性能优化的时候,要结合基准测试,来验证自己的优化是否有提升。

以上是基于 GO 语言的内存管理机制总结出的 3 个方向的技巧,基于这 3 个大方向基本上可以优化出你想要的效果。除此之外,还有一些小技巧,比如要尽可能避免使用锁、并发加锁的范围要尽可能小、使用 StringBuilder 做 string 和 [ ] byte 之间的转换、defer 嵌套不要太多等等。

最后推荐一个 Go 语言自带的性能剖析的工具 pprof,通过它你可以查看 CPU 分析、内存分析、阻塞分析、互斥锁分析

总结
主要学习了代码规范检查和性能优化两部分内容,其中代码规范检查是从工具使用的角度学习,而性能优化可能涉及的点太多,所以是从原理的角度讲解,明白了原理,就能更好地优化代码。

是否进行性能优化取决于两点:业务需求和自我驱动。所以不要刻意地去做性能优化,尤其是不要提前做,先保证代码正确并上线,然后再根据业务需要,决定是否进行优化以及花多少时间优化。自我驱动其实是一种编码能力的体现,比如有经验的开发者在编码的时候,潜意识地就避免了逃逸,减少了内存拷贝,在高并发的场景中设计了低延迟的架构。

单元测试

什么是单元测试

顾名思义,单元测试强调的是对单元进行测试。在开发中,一个单元可以是一个函数一个模块等。一般情况下,你要测试的单元应该是一个完整的最小单元,比如 Go 语言的函数。这样的话,当每个最小单元都被验证通过,那么整个模块、甚至整个程序就都可以被验证通过。
单元测试由开发者自己编写,也就是谁改动了代码,谁就要编写相应的单元测试代码以验证本次改动的正确性。

阅读全文 »

数组

几乎所有的编程语言里都存在数组,Go 也不例外。那么为什么 Go 语言除了数组之外又设计了 slice 呢?要想解答这个问题,先来了解数组的局限性。

在下面的示例中,a1、a2 是两个定义好的数组,但是它们的类型不一样。变量 a1 的类型是 [1]string,变量 a2 的类型是 [2]string,也就是说数组的大小属于数组类型的一部分,只有数组内部元素类型和大小一致时,这两个数组才是同一类型。

1
2
a1:=[1]string{"golang"}
a2:=[2]string{"golang"}

可以总结为,一个数组由两部分构成:数组的大小和数组内的元素类型。

1
2
3
4
5
6
<!--more-->
//数组结构伪代码表示
array{
len
item type
}

比如变量 a1 的大小是 1,内部元素的类型是 string,也就是说 a1 最多只能存储 1 个类型为 string 的元素。而 a2 的大小是 2,内部元素的类型也是 string,所以 a2 最多可以存储 2 个类型为 string 的元素。一旦一个数组被声明,它的大小和内部元素的类型就不能改变,你不能随意地向数组添加任意多个元素。这是数组的第一个限制。

既然数组的大小是固定的,如果需要使用数组存储大量的数据,就需要提前指定一个合适的大小,比如 10 万,代码如下所示:

1
a10:=[100000]string{"golang"}

这样虽然可以解决问题,但又带来了另外的问题,那就是内存占用。因为在 Go 语言中,函数间的传参是值传递的,数组作为参数在各个函数之间被传递的时候,同样的内容就会被一遍遍地复制,这就会造成大量的内存浪费,这是数组的第二个限制。

虽然数组有限制,但是它是 Go 非常重要的底层数据结构,比如 slice 切片的底层数据就存储在数组中。

slice 切片

数组虽然也不错,但是在操作上有不少限制,为了解决这些限制,Go 语言创造了 slice,也就是切片。切片是对数组的抽象和封装,它的底层是一个数组存储所有的元素,但是它可以动态地添加元素,容量不足时还可以自动扩容,你完全可以把切片理解为动态数组。在 Go 语言中,除了明确需要指定长度大小的类型需要数组来完成,大多数情况下都是使用切片的。

动态扩容

通过内置的 append 方法,你可以向一个切片中追加任意多个元素,所以这就可以解决数组的第一个限制。

1
2
3
4
5
6
7
8
9
func main() {
ss:=[]string{"飞雪无情","张三"}
ss=append(ss,"李四","王五")
fmt.Println(ss)
}

运行结果
[golang 张三 李四 王五]

当通过 append 追加元素时,如果切片的容量不够,append 函数会自动扩容。比如上面的例子,打印出使用 append 前后的切片长度和容量,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
ss := []string{"golang", "张三"}
fmt.Println("切片ss长度为", len(ss), ",容量为", cap(ss))
ss = append(ss, "李四", "王五")
fmt.Println("切片ss长度为", len(ss), ",容量为", cap(ss))
fmt.Println(ss)
}
运行结果
切片ss长度为 2 ,容量为 2
切片ss长度为 4 ,容量为 4
[golang 张三 李四 王五]

在调用 append 之前,容量是 2,调用之后容量是 4,说明自动扩容了。

小提示:append 自动扩容的原理是新创建一个底层数组,把原来切片内的元素拷贝到新数组中,然后再返回一个指向新数组的切片。

数据结构

在 Go 语言中,切片其实是一个结构体,它的定义如下所示:

1
2
3
4
5
type SliceHeader struct {
Data uintptr
Len int
Cap int
}

SliceHeader 是切片在运行时的表现形式,它有三个字段 Data、Len 和 Cap。

  • Data 用来指向存储切片元素的数组。

  • Len 代表切片的长度。

  • Cap 代表切片的容量。

通过这三个字段,就可以把一个数组抽象成一个切片,便于更好的操作,所以不同切片对应的底层 Data 指向的可能是同一个数组。现在通过一个示例来证明,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
a1 := [2]string{"golang", "张三"}
s1 := a1[0:1]
s2 := a1[:]
fmt.Println(s1, s2)
//打印出s1和s2的Data值 ( Data是指向底层数组的 ),是一样的
fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&s1)).Data)
fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&s2)).Data)
}
运行结果
[golang] [golang 张三]
824634089504
824634089504

这两个切片共用一个数组,所以我们在切片赋值、重新进行切片操作时,使用的还是同一个数组,没有复制原来的元素。这样可以减少内存的占用,提高效率。

注意:多个切片共用一个底层数组虽然可以减少内存占用,但是如果有一个切片修改内部的元素,其他切片也会受影响。所以在切片作为参数在函数间传递的时候要小心,尽可能不要修改原切片内的元素。

1
2
3
4
5
6
7
8
9
10
11
func main() {
a1 := [2]string{"golang", "张三"}
s1 := a1[0:1]
s2 := a1[:]
fmt.Println(s1, s2)
s1[0] = "java"
fmt.Println(s1, s2)
}
运行结果
[golang] [golang 张三]
[java] [java 张三]

通过运行结果我们发现 修改s1下标为0的元素时 s2下标为0的元素也被修改了。

切片的本质是 SliceHeader,又因为函数的参数是值传递,所以传递的是 SliceHeader 的副本,而不是底层数组的副本。这时候切片的优势就体现出来了,因为 SliceHeader 的副本内存占用非常少,即使是一个非常大的切片(底层数组有很多元素),也顶多占用 24 个字节的内存,这就解决了大数组在传参时内存浪费的问题。

小提示:SliceHeader 三个字段的类型分别是 uintptr、int 和 int,在 64 位的机器上,这三个字段最多也就是 int64 类型,一个 int64 占 8 个字节,三个 int64 占 24 个字节内存。

高效的原因

如果从集合类型的角度考虑,数组、切片和 map 都是集合类型,因为它们都可以存放元素,但是数组和切片的取值和赋值操作要更高效,因为它们是连续的内存操作,通过索引就可以快速地找到元素存储的地址。

小提示:当然 map 的价值也非常大,因为它的 Key 可以是很多类型,比如 int、int64、string 等,但是数组和切片的索引只能是整数。

进一步对比,在数组和切片中,切片又是高效的,因为它在赋值、函数传参的时候,并不会把所有的元素都复制一遍,而只是复制 SliceHeader 的三个字段就可以了,共用的还是同一个底层数组。

在下面的示例中,我定义了两个函数 arrayF 和 sliceF,分别打印传入的数组和切片底层对应的数组指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func main() {
a1:=[2]string{"golang","张三"}
fmt.Printf("函数main数组指针:%p\n",&a1)
arrayF(a1)
s1:=a1[0:1]
fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&s1)).Data)
sliceF(s1)
}
func arrayF(a [2]string){
fmt.Printf("函数arrayF数组指针:%p\n",&a)
}
func sliceF(s []string){
fmt.Printf("函数sliceF Data:%d\n",(*reflect.SliceHeader)(unsafe.Pointer(&s)).Data)
}
运行结果
函数main数组指针:0xc00005a020
函数arrayF数组指针:0xc00005a040
824634089504
函数sliceF Data:824634089504

同一个数组在 main 函数中的指针和在 arrayF 函数中的指针是不一样的,这说明数组在传参的时候被复制了,又产生了一个新数组。而 slice 切片的底层 Data 是一样的,这说明不管是在 main 函数还是 sliceF 函数中,这两个切片共用的还是同一个底层数组,底层数组并没有被复制

小提示:切片的高效还体现在 for range 循环中,因为循环得到的临时变量也是个值拷贝,所以在遍历大的数组时,切片的效率更高。

切片基于指针的封装是它效率高的根本原因,因为可以减少内存的占用,以及减少内存复制时的时间消耗。

string 和 []byte 互转

把一个 []byte 转为一个 string 字符串,然后再转换回来,示例代码如下:

1
2
3
4
5
6
7
s:="golang"
b:=[]byte(s)
s3:=string(b)
fmt.Println(s,string(b),s3)

运行结果
golang golang golang

Go 语言通过先分配一个内存再复制内容的方式,实现 string 和 []byte 之间的强制转换。现在我通过 string 和 []byte 指向的真实内容的内存地址,来验证强制转换是采用重新分配内存的方式。如下面的代码所示:

1
2
3
4
5
6
7
8
9
10
11
s:="golang"
fmt.Printf("s的内存地址:%d\n", (*reflect.StringHeader)(unsafe.Pointer(&s)).Data)
b:=[]byte(s)
fmt.Printf("b的内存地址:%d\n",(*reflect.SliceHeader)(unsafe.Pointer(&b)).Data)
s3:=string(b)
fmt.Printf("s3的内存地址:%d\n", (*reflect.StringHeader)(unsafe.Pointer(&s3)).Data)
运行结果
s的内存地址:17444283
b的内存地址:824634330856
s3的内存地址:824634330824

通过以上的示例代码,你已经知道了 SliceHeader 是什么。其实 StringHeader 和 SliceHeader 一样,代表的是字符串在程序运行时的真实结构,StringHeader 的定义如下所示:

1
2
3
4
5
// StringHeader is the runtime representation of a string.
type StringHeader struct {
Data uintptr
Len int
}

也就是说,在程序运行的时候,字符串和切片本质上就是 StringHeader 和 SliceHeader。这两个结构体都有一个 Data 字段,用于存放指向真实内容的指针。所以我们打印出 Data 这个字段的值,就可以判断 string 和 []byte 强制转换后是不是重新分配了内存。

现在你已经知道了 []byte(s) 和 string(b) 这种强制转换会重新拷贝一份字符串,如果字符串非常大,由于内存开销大,对于有高性能要求的程序来说,这种方式就无法满足了,需要进行性能优化。

如何优化呢?既然是因为内存分配导致内存开销大,那么优化的思路应该是在不重新申请内存的情况下实现类型转换。

仔细观察 StringHeader 和 SliceHeader 这两个结构体,会发现它们的前两个字段一模一样,那么 []byte 转 string,就等于通过 unsafe.Pointer 把 *SliceHeader 转为 *StringHeader,也就是 *[]byte 转 *string,原理和我上面讲的把切片转换成一个自定义的 slice 结构体类似。

在下面的示例中,s4 和 s3 的内容是一样的。不一样的是 s4 没有申请新内存(零拷贝),它和变量 b 使用的是同一块内存,因为它们的底层 Data 字段值相同,这样就节约了内存,也达到了 []byte 转 string 的目的。

1
2
3
s:="golang"
b:=[]byte(s)
s4:=*(*string)(unsafe.Pointer(&b))

SliceHeader 有 Data、Len、Cap 三个字段,StringHeader 有 Data、Len 两个字段,所以 *SliceHeader 通过 unsafe.Pointer 转为 *StringHeader 的时候没有问题,因为 *SliceHeader 可以提供 *StringHeader 所需的 Data 和 Len 字段的值。但是反过来却不行了,因为 *StringHeader 缺少 *SliceHeader 所需的 Cap 字段,需要我们自己补上一个默认值。

在下面的示例中,b1 和 b 的内容是一样的,不一样的是 b1 没有申请新内存,而是和变量 s 使用同一块内存,因为它们底层的 Data 字段相同,所以也节约了内存。

1
2
3
4
s:="golang"
sh:=(*reflect.SliceHeader)(unsafe.Pointer(&s))
sh.Cap = sh.Len
b1:=*(*[]byte)(unsafe.Pointer(sh))

注意:通过 unsafe.Pointer 把 string 转为 []byte 后,不能对 []byte 修改,比如不可以进行 b1[0]=12 这种操作,会报异常,导致程序崩溃。这是因为在 Go 语言中 string 内存是只读的。

通过 unsafe.Pointer 进行类型转换,避免内存拷贝提升性能的方法在 Go 语言标准库中也有使用,比如 strings.Builder 这个结构体,它内部有 buf 字段存储内容,在通过 String 方法把 []byte 类型的 buf 转为 string 的时候,就使用 unsafe.Pointer 提高了效率,代码如下:

1
2
3
4
// String returns the accumulated string.
func (b *Builder) String() string {
return *(*string)(unsafe.Pointer(&b.buf))
}

string 和 []byte 的互转就是一个很好的利用 SliceHeader 结构体的示例,通过它可以实现零拷贝的类型转换,提升了效率,避免了内存浪费。

总结

通过 slice 切片的分析,可以更深地感受 Go 的魅力,它把底层的指针、数组等进行封装,提供一个切片的概念给开发者,这样既可以方便使用、提高开发效率,又可以提高程序的性能。

Go 语言设计切片的思路非常有借鉴意义,也可以使用 uintptr 或者 slice 类型的字段来提升性能,就像 Go 语言 SliceHeader 里的 Data uintptr 字段一样。