3、Kotlin对象创建与内存分配:对象头、实例数据、对齐填充、内存对齐规则

好,咱们今天聊点实在的。对象创建和内存分配,这话题听起来有点底层,但说实话,搞Android性能优化,这块是绕不过去的坎。我见过太多人写Kotlin代码,new一个对象出来用得飞起,结果内存飙上去、GC频繁触发,自己还不知道问题出在哪。

你想想看,一个对象在JVM里到底长什么样?它占多少内存?为什么有时候你感觉没多少数据,内存却涨得离谱?嗯,今天我们就把它扒开来看一看。

对象的内存布局:三部分结构

一个Java/Kotlin对象在内存中,其实就三块东西:对象头实例数据对齐填充。我习惯把这比喻成一个人的「头、身体、鞋子」——头是身份信息,身体是实际内容,鞋子是为了走路不绊脚。

组成部分 作用 大小(32位JVM) 大小(64位JVM,指针压缩开启)
对象头(Mark Word + Klass Pointer) 存储GC状态、锁信息、类元数据指针 8字节(Mark)+ 4字节(Klass) 8字节(Mark)+ 4字节(Klass)
实例数据 对象的实际字段值 取决于字段类型和数量 取决于字段类型和数量
对齐填充 保证对象起始地址是8的倍数 0~7字节 0~7字节

核心结论:任何一个对象,哪怕里面一个字段都没有,最少也要占16字节(64位JVM开启指针压缩时)。这就是为什么你new一个空对象,内存就涨了16个字节。

对象头:Mark Word 和 Klass Pointer

对象头是每个对象的「身份证」。Mark Word 占了8个字节,里面存的是啥?GC分代年龄、偏向锁信息、锁状态标志、hashCode……说白了,JVM运行时需要的一切元信息,都塞在这8个字节里。

Klass Pointer 呢?它指向这个对象的类元数据。在64位JVM上,如果不开启指针压缩,它占8个字节;开启后压缩到4个字节。我建议你务必开启指针压缩(默认就是开启的),能省不少内存。

小技巧:在Android的ART虚拟机里,对象头的结构略有不同,但核心思想一致。我曾经在分析一个图片缓存库的内存泄漏时,发现大量Bitmap对象头占了将近30%的内存——嗯,那之后我对对象头就格外敏感了。

实例数据:字段怎么排的?

实例数据就是你的Kotlin类里那些属性。比如:

class User(
    val id: Int,        // 4字节
    val name: String,   // 4字节(引用,指针压缩下)
    val active: Boolean // 1字节
)

这里有个坑:JVM不是按你写的顺序来排字段的。它有自己的重排序规则——把相同大小的字段排在一起,减少填充浪费。比如上面这个类,实际内存布局可能是:

  • id(4字节)
  • name引用(4字节)
  • active(1字节)
  • 填充3字节(对齐到8的倍数)

你看,一个Boolean字段才1个字节,但因为要对齐,硬生生多占了3个字节的填充。我遇到过最夸张的一次,一个类里十几个Boolean字段,每个都跟着3字节填充,白白浪费了40多字节。

避坑指南:我曾经在写一个高并发的消息对象时,把int、long、boolean混着写,结果内存占用比预期多了30%。后来我把同类型的字段聚在一起,内存立刻降下来了。所以,写Kotlin data class时,尽量把相同类型的字段放一起

对齐填充:为什么非要8的倍数?

内存对齐,说白了就是CPU读取内存的效率问题。CPU一次读8个字节(64位架构),如果对象起始地址是8的倍数,那一次就能读完。如果不是,可能要读两次再拼起来——性能就差了。

所以JVM规定:每个对象的起始地址必须是8的倍数。如果对象头+实例数据加起来不是8的倍数,就补上填充字节。

举个例子:

class SmallObject {
    val flag: Boolean = true  // 1字节
}

这个对象的内存布局:

  • 对象头:12字节(Mark 8 + Klass 4)
  • 实例数据:1字节(flag)
  • 对齐填充:3字节(凑到16字节)

总共16字节。你看,一个Boolean字段,实际只用了1个字节,但整个对象占了16个字节。这就是为什么我常说:对象本身有固定开销,别动不动就new对象

Kotlin特有:Lambda和匿名对象的内存开销

Kotlin里有个容易忽略的点——Lambda表达式。你写个:

list.filter { it > 0 }.map { it * 2 }

每个Lambda在底层都会生成一个匿名对象(除非被内联了)。这些对象同样有对象头、实例数据、对齐填充。我见过一个项目,大量使用Lambda做链式调用,结果内存里瞬间多了几千个匿名对象,GC频繁到卡顿。

我的建议:在性能敏感的循环或高频调用路径上,尽量用内联函数(inline)或者手动写循环。别让Lambda成为你内存的隐形杀手。

实战:如何查看对象的内存布局?

如果你想知道一个对象到底占多少内存,可以用 jol(Java Object Layout)工具。在Kotlin项目里加个依赖:

dependencies {
    implementation("org.openjdk.jol:jol-core:0.17")
}

然后跑一下:

println(ClassLayout.parseClass(User::class.java).toPrintable())

输出会清晰告诉你:对象头多少、每个字段偏移多少、填充了多少。我每次做内存优化,第一件事就是拿jol跑一遍,看看有没有浪费。

总结一下

对象创建和内存分配,说白了就是三件事:

  1. 对象头——每个对象固定的12或16字节开销,逃不掉
  2. 实例数据——字段怎么排、排在哪,直接影响内存占用
  3. 对齐填充——为了CPU效率,不得不浪费一点空间

我个人习惯是:写Kotlin类之前,先想想这个对象会被创建多少次、存活多久。如果是高频创建的对象,尽量精简字段、同类型放一起、考虑用基本类型代替包装类型。你想想看,一个Integer对象比int多占多少内存?16字节 vs 4字节,差了4倍。

嗯,今天就聊到这儿。下一节我们讲讲Kotlin里那些「看不见」的对象——比如String、集合类,它们的内存陷阱更多。