
大家知道 Kotlin 可能要引入一个"集合字面量"的新特性吗?
也就是说,以后 Kotlin 的集合可以这么写:
kotlin
val arr = [3, 4, 5]
Kotlin 2.4.0-RC 已经实验性引入了 collection literals,也就是可以用
[1, 2, 3]这样的语法创建集合。不过它目前还需要 -Xcollection-literals 开启,尚未稳定。
不过,我今天暂时不想说这个特性。
我刚看到这个特性的时候,以为是 Kotlin 允许在栈上申请数组。
我当时没有反应过来这个错误想法,过了一会儿才猛然惊醒:Java 创建数组需要 new,Kotlin 在 JVM 上也绕不开数组对象这件事。
欸,为什么 Java 的数组需要 new 出来?
这才是今天这篇文章的来源!
很多有 C 经验的 Java 开发者第一次学习数组的时候,看到这样的代码:
java
int[] arr = new int[10];
都会产生一种疑惑(俺也一样):等等,为什么我不能这么写:
java
int[] arr = [0, 1, 2, 3];
这个数组为什么一定要 new 出来?更深一点说,为什么 Java 的数组通常要在堆上申请?
OK,听我娓娓道来!
C 的"裸内存"
先看 C:
c
int arr[10];
很多时候,它真的就是一块连续的内存:
text
[ int ][ int ][ int ][ int ] ...
它没有:
- 对象头
- 运行时类型
- GC 信息
length字段- 元数据
只是"10 个连续的 int",因此,天然适合放在栈上。
你想想 C 是怎么获取数组长度的:
c
int len = sizeof(arr) / sizeof(arr[0]);
它可没有 arr.length 这种写法。
Java 不是"裸数组"
而 Java 完全不同,来看:
java
int[] arr = new int[10];
其实在 JVM 眼里,arr 指向的是一个真正的对象。
这个对象内部通常包含:
text
+ 对象头
+ GC 信息
+ 类型信息
+ length
+ 元素区
也就是说:Java 的数组,本质上是一个"数组对象(Array Object)"。
甚至 arr instanceof Object 是成立的。因为数组本身就是对象,这和 C 世界完全不同。
Java 对象统一放堆
Java 当年最核心的目标之一是自动内存管理。Java 想避免:
- 野指针
- 悬空引用
- double free
- 生命周期错误
当然,更重要的一点是,Java 在设计时就不希望让程序员主动管理内存:你不用管,我来给你释放!
要做到这一点,也不是那么容易。Java 做了一个非常重要的设计:
text
局部变量 -> 栈
对象 -> 堆
你先记住:对象在堆上,对象的引用在栈上的局部变量里。我们接着往下看。
生命周期
那么 C 在栈上的内存,是怎么处理的呢?
我们来看个经典的 C 代码:
c
int* test() {
int arr[10];
return arr;
}
这段代码是错误的(实际上编译可能没有问题,运行的时候也可能暂时看不出问题),因为 arr 的生命周期在函数返回后就结束了。这个过程伴随着栈帧销毁,最后会返回一个失效地址。
这就是悬空指针(dangling pointer)。
如果你继续访问,就会触发未定义行为(Undefined Behavior),可能好使,也可能不好使。
而 Java:
java
int[] test() {
int[] arr = new int[10];
return arr;
}
却完全合法。
为什么?
因为 Java 必须保证对象在函数返回后依然可以存在,所以 JVM 需要让对象脱离函数栈帧的生命周期,因此数组对象通常进入堆。
这其实是 Java 数组使用堆内存的最核心原因。
好,此刻我们思考一个问题:如果 Java 支持把数组直接分配在栈上,那么如何避免函数结束后栈帧退出的问题呢?
一种思路是:拷贝!
也就是说,把函数栈帧里分配的数组拷贝到另一个仍然有效的位置。
但是大家想想,这个做法有很大的问题:如果这个数组很大,那么拷贝花费的时间就会非常长,这种性能消耗是不可预期的。
所以,Java 为了安全性和性能考虑,选择了更统一的模型:数组对象在堆上分配,然后通过数组引用,在栈上管理。
GC 也要求对象统一在堆
垃圾回收器(GC)的核心任务是:追踪哪些对象还活着。
如果对象有的在栈,有的在堆,GC 会变得更复杂。
因此 JVM 采用了一个清晰的模型:
- 堆:对象区
- 栈:局部变量和引用区
GC 可以从栈上的引用等 GC Roots 出发,扫描堆对象。
整个模型会非常清晰。
很多带 GC 的语言,都会采用类似的思路:从根集合出发,追踪仍然可达的对象。
Java 数组的运行时类型
Java 数组的内存模型并不像看起来的那么简单,它还携带运行时类型信息(RTTI)。
例如:
java
Object obj = new int[10];
System.out.println(obj.getClass());
这是 C 数组做不到的,因为 Java 数组是类型系统的一部分,而不是纯内存块。
JVM 的栈上分配
虽然从语言模型和 JVM 规范的角度看,数组是对象,对象通常在堆上创建,但现代 JVM 还有优化手段:
- Escape Analysis(逃逸分析)
- Scalar Replacement(标量替换)
例如:
java
void test() {
int[] arr = new int[4];
arr[0] = 1;
}
如果 JVM 发现 arr 没有逃逸当前函数,它可能会消除这次对象分配,甚至连数组对象都不创建,直接优化成局部变量。
所以,"Java 对象在堆上"这个说法,更准确地说是语言和运行时的抽象模型,而不一定是最终机器码的现实。
语言的世界观
现在回过头来,你会发现,这个问题本质上不是"数组为什么在堆上",而是:"Java 如何看待对象生命周期"。
C 的世界观是:
- 性能优先
- 相信程序员
悬空指针就是一个很好的证明:我相信你知道这么写的代价。
Java 的世界观是:
- 安全优先
- 统一内存模型
所以对于程序员来讲,很轻松,不需要主动管理内存。
而 Rust 的世界观是:
- 编译期证明安全
这有点像:我不仅不完全相信程序员,也不依赖 GC,所以我既不把内存安全完全交给程序员,也不把对象生命周期交给运行时 GC。你必须写出看上去就安全的代码!
不同语言,其实是在不同方向上解决同一个问题:"对象什么时候活着?"
一点想法
Java 数组放在堆上,并不是因为 Java 不会栈分配,而是因为 Java 把数组视为真正的对象,并且希望用统一的对象生命周期模型来换取安全性和可管理性。
Reference
Collection literals
github.com/Kotlin/KEEP...
Java Virtual Machine Specification
docs.oracle.com/javase/spec...
Java Language Specification
docs.oracle.com/javase/spec...