数据类型的本质是内存空间,每种语言都有大量丰富的预定义类型,以方便开发。每种数据类型究竟占用多少内存空间呢?我相信很多学编程的人并不十分清楚这个问题,然而要想写出高质量的代码、设计高效的数据结构、开发优良的底层工具类项目;对内存空间的了解就很有必要了,这样才能尽可能节省内存空间,提高CPU和缓存之间的命中率,进而提高核心代码的性能。

基础类型内存占用

我们直接用Go语句中的非安全指针,过一遍常见的数据类型,看变量占用多少内存空间(现在几乎都是x64-86处理器,后面的试验都以64位CPU来测试,严格来说有些数据类型占用内存空间我们应该说占多少个字,因为处理器规格不一样占用字节数不一样;但是现在几乎都是64位处理器,为了更直观的说明占用内存空间大小,以后我们都直接说字节数):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func TheSize() {
	fmt.Println(unsafe.Sizeof(int(0)))			// 8
	fmt.Println(unsafe.Sizeof(int8(0)))			// 1
	fmt.Println(unsafe.Sizeof(int16(0)))		// 2
	fmt.Println(unsafe.Sizeof(int32(0)))		// 4
	fmt.Println(unsafe.Sizeof(int64(0)))		// 8

	fmt.Println(unsafe.Sizeof(uint(0)))			// 8
	fmt.Println(unsafe.Sizeof(uint8(0)))		// 1
	fmt.Println(unsafe.Sizeof(uint16(0)))		// 2
	fmt.Println(unsafe.Sizeof(uint32(0)))		// 4
	fmt.Println(unsafe.Sizeof(uint64(0)))		// 8

	fmt.Println(unsafe.Sizeof(byte(0)))			// 1
	fmt.Println(unsafe.Sizeof(rune(0)))			// 4
	fmt.Println(unsafe.Sizeof(uintptr(0)))		// 8
	fmt.Println(unsafe.Sizeof(float32(0)))		// 4
	fmt.Println(unsafe.Sizeof(float64(0)))		// 8
	fmt.Println(unsafe.Sizeof(complex64(0)))	// 8
	fmt.Println(unsafe.Sizeof(complex128(0)))	// 16

	fmt.Println(unsafe.Sizeof(false))			// 1
	fmt.Println(unsafe.Sizeof("string"))		// 16
}

上面对基础数据类型占用内存字节数的计算,总体应该是好理解的。有两点说明下:

  1. 指针的内存占用在64位机上是8字节,指针好像不占用空间,其实不然。如果有个指针数组,存10240个指针,光存储指针就要占用8*10240=80K的空间。

  2. 字符串底层是引用类型,标头含有值地址指针(8字节)和字符个数(int型8字节)两个字段;所以总共16字节。

组合类型内存占用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
type Student struct {
	height uint8
	age uint8
}

func TheSize2()  {
	arr := [5]uint8{}						// array
	fmt.Println(unsafe.Sizeof(arr))			// 5

	stu := Student{height: 175, age: 36}	// struct
	fmt.Println(unsafe.Sizeof(stu))			// 2

	stuA := new(Student)					// pointer
	fmt.Println(unsafe.Sizeof(stuA))		// 8

	stuB := []Student{stu}							// slice
	fmt.Println(unsafe.Sizeof(stuB))				// 24
	stuB2 := []Student{stu, stu, stu, stu, stu}		// slice
	fmt.Println(unsafe.Sizeof(stuB2))				// 24

	stuC := make(map[string]Student)				// map
	fmt.Println(unsafe.Sizeof(stuC))				// 8
	stuC2 := make(map[string]Student, 64)			// map
	fmt.Println(unsafe.Sizeof(stuC2))				// 8

	stuD := make(chan Student)						// channel
	fmt.Println(unsafe.Sizeof(stuD))				// 8
	stuD2 := make(chan Student, 32)					// channel
	fmt.Println(unsafe.Sizeof(stuD2))				// 8

	stuE := make([]Student, 8)						// slice
	fmt.Println(unsafe.Sizeof(stuE))				// 24
	stuE2 := make([]Student, 16, 32)				// slice
	fmt.Println(unsafe.Sizeof(stuE2))				// 24

	stuF0 := new([]Student)							// pointer of a empty slice
	fmt.Println(unsafe.Sizeof(stuF0))				// 8
    
	var emptyFunc = func() {}						// empty func
	fmt.Println(unsafe.Sizeof(emptyFunc))			// 8
    
   	var stuF1 []Student								// empty slice
	fmt.Println(unsafe.Sizeof(stuF1))				// 24
	var stuF2 struct{}								// empty struct
	fmt.Println(unsafe.Sizeof(stuF2))				// 0
	var stuF3 [0]Student							// empty array
	fmt.Println(unsafe.Sizeof(stuF3))				// 0
	var stuF4 interface{}							// empty interface
	fmt.Println(unsafe.Sizeof(stuF4))				// 16
}

再次说明下make和new的区别:

  • make只能用在(map|channel|slice),返回类型标头,标头至少包含一个值地址指针,有的还包含其它描述字段。
  • new返回的是类型指针,指针指向类型值的起始地址
1
2
3
// builtin.go
func make(t Type, size ...IntegerType) Type
func new(Type) *Type

上面的几种组合类型的变量占用内存空间也一目了然了,说明:

  1. 数组是值类型,其大小取决于类型字节数和数组长度的乘积。
  2. 结构体是值类型,变量的大小取决于成员变量占用大小的和,但是这不完全正确,因为有内存对齐,后面具体分析。
  3. 指针(pointer)就是8字节,指向存放具体值的内存地址。
  4. slice变量其实是标头(地址+长度+容量),每一项都是占用8字节,所以标头的总大小是24字节。
  5. map是比较复杂的数据结构,他的变量其实就是一个指针,占用8字节。
  6. channel也是一个复杂的数据结构,他对应的变量也是一个指针,占用8字节。
  7. make返回的就是类型变量。要么是指针(map|channel)占用8字节,要么是(slice)占用24字节。
  8. new始终返回一个类型的指针,占用8字节。指针指向的类型变量可能一个值都没有。
  9. 函数func对应的变量实际就是一个指针,指向函数的起始位置。

struct结构类型字段顺序的影响

看下面的例子

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func MemoryAlignment() {
	type MemSize1 struct {
		CardID  int64 // 8
		HasCard bool  // 4
		GroupID int32 // 4
	}

	type MemSize2 struct {
		HasCard bool  // 8
		CardID  int64 // 8
		GroupID int32 // 8
	}

	user1 := MemSize1{}
	user2 := MemSize2{}
	fmt.Println(unsafe.Sizeof(user1))
	fmt.Println(unsafe.Sizeof(user2))
}

打印结果如下:

16
24

再看看下面的例子,这个更有意思:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func MemoryAlignment() {
	type MemSize1 struct {
		GroupID  int16 // 2
		HasCard  bool  // 1
		HasCard2 bool  // 1
	}

	type MemSize2 struct {
		GroupID  int32 // 4
		HasCard  bool  // 1
		HasCard2 bool  // 1
		// hide 2
	}

	type MemSize3 struct {
		HasCard  bool  // 4
		GroupID  int32 // 4
		HasCard2 bool  // 4
	}

	user1 := MemSize1{}
	user2 := MemSize2{}
	user3 := MemSize3{}
	fmt.Println(unsafe.Sizeof(user1))
	fmt.Println(unsafe.Sizeof(user2))
	fmt.Println(unsafe.Sizeof(user3))
}

结果如下:

4
8 
12

为什么会出现变量占用内存大小不一样的情况?因为内存对齐,方便CPU高效寻址,大致结论如下:

  1. 结构体内存分配必须是其中最大基础类型字段长度Max的整数倍
  2. 当相近基础数据类型字段长度和大于Max时,需要将不足Max的补足,后面字段再按此规则继续分配。

特别注意

struct{} 大小为 0,作为其他 struct 的字段时,一般不需要内存对齐。但有一种特许情况:即当 struct{} 作为结构体最后一个字段时,需要内存对齐。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 	fmt.Println(unsafe.Sizeof(user1)) 结果是:4
type MemSize1 struct {
	GroupID  int16    // 2
	HasCard  bool     // 1
	Empty    struct{} // 0
	HasCard2 bool     // 1
}

// 	fmt.Println(unsafe.Sizeof(user1)) 结果是:6
type MemSize1 struct {
	GroupID  int16    // 2
	HasCard  bool     // 1
	HasCard2 bool     // 1
	Empty    struct{} // 0
	// hide 2
}

因为如果有指针指向该字段,返回的地址将在结构体之外,如果此指针一直存活不释放对应的内存,就会有内存泄露的问题(该内存不因结构体释放而释放)。

结论

1)内存对齐是为了cpu更高效访问内存数据;

2)结构体对齐依赖成员变量的大小保证和对齐保证;

3)地址对齐保证是:如果类型t的对齐保证是n,那么类型t的每个值的地址在运行时必须是n的倍数;

4)struct内字段如果填充过度,可以尝试重排,使字段排序更紧密,减少内存浪费;

5)零大小字段要避免作为struct最后一个字段。

参考:https://geektutu.com/post/hpg-struct-alignment.html

特别分析struct{}、[0]T、interface{}

空结构struct{}

先看一段测试代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func isArrayHaveSameNumber(nums []int) bool {
	defer func() {
		fmt.Printf("+++++ end.\n\n")
	}()
	fmt.Printf("+++++ start: %v\n", nums)

	hash := make(map[int]struct{}, 8)
	for _, v := range nums{
		_, ok := hash[v]
		if ok {
			fmt.Printf("%d exist.\n", v)
			return true
		}
		hash[v] = struct{}{}	// 不占新内存
	}
	return false
}

func TheSize3()  {
	var k1 struct{}
	var k2 struct{}
	fmt.Println(unsafe.Sizeof(k1))
	fmt.Println(unsafe.Sizeof(k2))
	fmt.Printf("k1 = %p, k2 = %p, %v\n", &k1, &k2, &k1 == &k2)

	isArrayHaveSameNumber([]int{1, 2, 3, 4})
	isArrayHaveSameNumber([]int{2, 2, 3, 4})
}

运行结果:

0
0
k1 = 0xb986d8, k2 = 0xb986d8, true
+++++ start: [1 2 3 4]
+++++ end.

+++++ start: [2 2 3 4]
2 exist.
+++++ end.

特点:

  1. 不占用内存
  2. 地址都一样(所有的空结构变量都指向全局的同一个地址)

作用:

  1. map[string]struct{} 表示key是否存在,其值不占用任何内存。Go语言没有集合类型,用这种结构就可以模拟了。
  2. chan struct{} 用于通道模拟信号

可以看看struct{}用于通道信号的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
func TheSize4() {
	chanStr 	:= make(chan string, 3)		// 能放3个字符串的有缓冲通道
	signChan1 	:= make(chan struct{})  	// struct{}类型的无缓冲通道
	signChan2 	:= make(chan struct{}, 2) 	// 含2个struct{}的有缓冲通道

	// 用来接收信息
	go func() {
		<- signChan1  // 阻塞协程,直到signChan1接收到值
		for value := range chanStr {
			fmt.Println("接收值为:", value)
		}
		signChan2 <- struct{}{}
	}()

	// 模拟发送数据
	go func() {
		for index, value := range []string{"1", "2", "3"} {
			fmt.Println("发送数据:", value)
			chanStr <- value
			if index == 2 {
				signChan1 <- struct{}{}
			}
		}
		close(chanStr)
		signChan2 <- struct{}{}
	}()

	fmt.Println("等待上面两个协程运行结束")
	<- signChan2
	<- signChan2
	fmt.Println("返回main函数")
	// <- signChan2
}

运行结果如下,有意思的是如果上面代码最后一句取消注释,结果就会出现最后一行的错误(go判断出现了死锁):

等待上面两个协程运行结束
发送数据: 1
发送数据: 2
发送数据: 3
接收值为: 1
接收值为: 2
接收值为: 3
返回main函数
#fatal error: all goroutines are asleep - deadlock!

零长数组[0]T

可以定义零长数组,实际意义和上面的struct{}很像

1
2
3
4
5
6
7
8
func TheSizeArr()  {
	var zeroArr1 [0]int
	var zeroArr2 [0]int

	println(unsafe.Sizeof(zeroArr1))
	println(unsafe.Sizeof(zeroArr2))
	fmt.Printf("Addr1: %p, Addr2: %p.\n", &zeroArr1, &zeroArr2)
}

运行结果如下:

0
0
Addr1: 0x56ba70, Addr2: 0x56ba70.

长度为0的数组特点(和struct{}一样):

  1. 不占用内存
  2. 地址都一样(所有的零长数组变量都指向全局的同一个地址)

空接口interface{}

没有任何方法声明的接口就是空接口。接口是一个复合数据类型,其变量包含两个指针。

第一个指针指向内部表iTable,其中包含了所存储的值的类型信息以及与这个类型相关联的一组方法集。

第二个指针指向接口变量所存储的具体值。

下面再来看一个例子:

1
2
3
4
5
6
7
8
func TheSizeInterface()  {
	var inter1 interface{}
	var inter2 interface{}

	fmt.Println(unsafe.Sizeof(inter1))
	fmt.Println(unsafe.Sizeof(inter2))
	fmt.Printf("IF1: %p, IF2: %p.\n", &inter1, &inter2)
}

运行结果:

16
16
IF1: 0xc00002e1f0, IF2: 0xc00002e200.

由此看出,接口的变量的确是两个指针组成的标头。那么接口的变量(标头)都表示啥具体意思呢?请看后面章节的分析。

(完)