为什么 Java 的数组需要 new 出来

0 阅读6分钟

ig_0a8e017bdffc01d0016a0ec078de008191ac99a7329d2a4a5d.png

大家知道 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 开发者第一次学习数组的时候,看到这样的代码:

int[] arr = new int[10];

都会产生一种疑惑(俺也一样):等等,为什么我不能这么写:

int[] arr = [0, 1, 2, 3];

这个数组为什么一定要 new 出来?更深一点说,为什么 Java 的数组通常要在堆上申请?

OK,听我娓娓道来!

C 的“裸内存”

先看 C:

int arr[10];

很多时候,它真的就是一块连续的内存:

[ int ][ int ][ int ][ int ] ...

它没有:

  • 对象头
  • 运行时类型
  • GC 信息
  • length 字段
  • 元数据

只是“10 个连续的 int”,因此,天然适合放在栈上。

你想想 C 是怎么获取数组长度的:

int len = sizeof(arr) / sizeof(arr[0]);

它可没有 arr.length 这种写法。

Java 不是“裸数组”

而 Java 完全不同,来看:

int[] arr = new int[10];

其实在 JVM 眼里,arr 指向的是一个真正的对象。

这个对象内部通常包含:

+ 对象头
+ GC 信息
+ 类型信息
+ length
+ 元素区

也就是说:Java 的数组,本质上是一个“数组对象(Array Object)”。

甚至 arr instanceof Object 是成立的。因为数组本身就是对象,这和 C 世界完全不同。

Java 对象统一放堆

Java 当年最核心的目标之一是自动内存管理。Java 想避免:

  • 野指针
  • 悬空引用
  • double free
  • 生命周期错误

当然,更重要的一点是,Java 在设计时就不希望让程序员主动管理内存:你不用管,我来给你释放!

要做到这一点,也不是那么容易。Java 做了一个非常重要的设计:

局部变量 -> 栈
对象 -> 堆

你先记住:对象在堆上,对象的引用在栈上的局部变量里。我们接着往下看。

生命周期

那么 C 在栈上的内存,是怎么处理的呢?

我们来看个经典的 C 代码:

int* test() {
    int arr[10];
    return arr;
}

这段代码是错误的(实际上编译可能没有问题,运行的时候也可能暂时看不出问题),因为 arr 的生命周期在函数返回后就结束了。这个过程伴随着栈帧销毁,最后会返回一个失效地址。

这就是悬空指针(dangling pointer)。

如果你继续访问,就会触发未定义行为(Undefined Behavior),可能好使,也可能不好使。

而 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)。

例如:

Object obj = new int[10];

System.out.println(obj.getClass());

这是 C 数组做不到的,因为 Java 数组是类型系统的一部分,而不是纯内存块。

JVM 的栈上分配

虽然从语言模型和 JVM 规范的角度看,数组是对象,对象通常在堆上创建,但现代 JVM 还有优化手段:

  • Escape Analysis(逃逸分析)
  • Scalar Replacement(标量替换)

例如:

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…