前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Go语言中常见100问题-#94 Not being aware of data alignment

Go语言中常见100问题-#94 Not being aware of data alignment

作者头像
数据小冰
发布2024-01-10 14:19:56
1030
发布2024-01-10 14:19:56
举报
文章被收录于专栏:数据小冰数据小冰
数据没有对齐

数据对齐是指数据在内存中的分配方式。规则的内存分配可以加速CPU访问内存速度。如果不了解数据对齐,会导致编写的程序消耗额外的内存,并且程序性能低下。

为了理解数据对齐是如何工作的,先看看如果没有对齐,会产生什么效果。现分配两个变量,一个类型为int32(32bits),另一个类型为int64(64bits)。

代码语言:javascript
复制
var i int32
var j int64

在没有数据对齐的情况下,在64位系统架构上,上述变量在内存中的分配方式如下图。变量j分配空间跨越两个字。如果CPU读取j中的内容,需要访问两次内存,而不是一次。

为了阻止上述情况发生,变量的内存地址应该是其自身大小的整数倍,这种约束就是数据对齐。在Go语言中,下面各类型对齐规则如下:

  • byte,uint8,int8: 1字节
  • uint16,int16: 2字节
  • uint32,int32,float32: 4字节
  • uint64,int64,float64,complex64: 8字节
  • complex128: 16字节

上述所有的数据类型都保证是对齐的:即它们的地址是其大小的倍数。例如,任何int32类型变量的地址都是4的整数倍。

回到开头的例子,下图显示了i和j在内存中分配的两种不同情况。情况1对应的情况是在变量i前面分配有1个32位的变量。情况2是通过填充方法,变量i在字长的开头分配,为了保持对齐(j的地址必须是64的倍数),变量j不能直接紧挨i, 只能在下一个64的倍数地址分配,图中的灰色格子即填充的32字节。

下面来看因为填充导致的问题,结构Foo内容如下。b1占1个字节,i占8个字节,b2占1个字节。

代码语言:javascript
复制
type Foo struct{
    b1 byte
    i int64
    b2 byte
}

在64位系统架构中,结构Foo在内存中的结构如下图。由于i是int64类型,所以它的地址必须是8的整数倍。因此,它不可能挨着b1在0x01位置分配,最近适合它的位置在0x08。b2分配的地址需要是1的倍数,所以紧挨着i在0x10位置分配。

又由于结构体的大小必须是字长(8字节)的整数倍,所以它的大小不是17字节,而是24字节。在编译的时候,Go编译器会添加填充确保数据对齐。填充后结构如下。

代码语言:javascript
复制
type Foo struct{
    b1 byte
    _ [7]byte
    i int64
    b2 byte
    _ [7]byte
}

每创建一个Foo对象,需要占用24字节内存空间,尽管只含有10字节数据(剩下的14字节为填充信息)。因为结构是一个原子单元,所以它永远不会被重新组织,即使在垃圾回收(GC)之后;它将总是占用24个字节的内存。注意,编译器不会重新排列字段,它只添加填充以保证数据对齐。

如何减少Foo占用内存空间呢?经验方法是重新排列字段顺序,按字段类型大小降序排列,本例中,先排int64,然后是两个byte类型。

代码语言:javascript
复制
type Foo struct{
    i int64
    b1 byte
    b2 byte
}

调整字段顺序后Foo在内存的结构如下图,首先是字段i,占一整个字长(8字节),然后是b1和b2紧挨着,并且在同一个字长内。由于结构体总大小必须字长整数倍,所以调整后占用内存为16字节。通过这个小小顺序调整,减少了33%内存占用空间。

版本1相比版本2关键的影响是下面这个场景。有一个内存缓存,需要缓存所有的Foo对象,这种情况下节省的内存非常明显。即使没有缓存的场景,也会有其他影响。例如,如果频繁的创建Foo对象,并分配在了堆上,导致的结果是频繁的GC,影响整体应用性能。

此外,空间局部性对程序也有性能影响。例如,考虑下面的sum函数,接收一个Foo 切片,对里面的数据i进行求和。

代码语言:javascript
复制
func sum(foos []Foo) int64 {
 var s int64
 for i := 0; i < len(foos); i++ {
  s += foos[i].i               
 }
 return s
}

针对两个不同字段顺序的Foo对象,在两个缓存行大小(128字节)的空间内布局如下。图中每个灰色条代表8个字节的数据,更暗的条状是变量i所在的位置。

可以看到都是相同的2个缓存行大小,第一个版本只能容纳5个i变量,第二个版本能容纳8个i变量。所以迭代切片foos,第二个版需要的缓存行要少很多。

下面通过性能测试代码验证下程序性能是否符合我们预期:第二个版本要比第一个快。在我电脑上测得第二个版本比第一个版本快约25%。

性能测试代码如下:

代码语言:javascript
复制
const n = 1_000_000

var global int64

func BenchmarkSum1(b *testing.B) {
 var local int64
 s := make([]Foo1, n)
 b.ResetTimer()
 for i := 0; i < b.N; i++ {
  local = sum1(s)
 }
 global = local
}

func BenchmarkSum2(b *testing.B) {
 var local int64
 s := make([]Foo2, n)
 b.ResetTimer()
 for i := 0; i < b.N; i++ {
  local = sum2(s)
 }
 global = local
}

测试运行结果如下:

代码语言:javascript
复制
goos: darwin
goarch: amd64
pkg: alignment
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkSum1-4              872           1274668 ns/op
BenchmarkSum2-4             1183            952591 ns/op
本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2024-01-08,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 数据小冰 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 数据没有对齐
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档