ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Scala数组深度解析:从Array到ArrayBuffer的性能优化与避坑指南

Scala数组深度解析:从Array到ArrayBuffer的性能优化与避坑指南

1. 为什么Scala的数组值得单独拎出来讲

很多人学Scala的时候,第一反应是"这语言不是有List吗,数组这种老古董还有什么好讲的"。我一开始也这么想,直到在一个数据处理的小项目里,因为用List做频繁的随机访问,性能直接拉胯,被现实狠狠教育了一顿。后来回头认真把Scala的数组体系捋了一遍,才发现这里面的门道比想象中多得多——它牵扯到可变与不可变、JVM原生数组的映射、泛型擦除带来的坑、以及和Java互操作时的各种细节。

这篇内容就是把我自己踩过的坑和总结出来的经验,系统地分享给刚接触Scala的朋友。Scala数组本质上是对JVM原生数组的一层封装,它既有面向对象的外衣,又保留了底层数组的性能特征。你要搞清楚的核心问题是:什么时候该用Array,什么时候该用ArrayBuffer,什么时候干脆用List或者Vector。这几个选择背后,是性能、可变性、类型安全三者的权衡。

适合谁看?如果你已经会写基本的Scala语法,能看懂val和var的区别,知道函数式编程大概是怎么回事,但一到具体选数据结构就犯迷糊,那这篇就是给你准备的。我会从最基础的创建方式讲起,一路讲到多维数组、性能对比、以及那些文档里不会写的实操细节。全程用大白话加代码示例,保证你能直接上手抄。

2. Scala数组的创建方式与底层映射逻辑

2.1 三种创建方式的本质区别

Scala里创建一个数组,至少有三种写法,很多人用的时候随手一写,但不知道它们背后的差异。

第一种,最直观的:

val arr = Array(1, 2, 3, 4, 5)

这是调用了Array伴生对象的apply方法,传入的是可变参数。编译器会把它翻译成Array.apply(1, 2, 3, 4, 5),最终在JVM上就是一个int[]。注意这里有个关键点:元素类型会被自动推断,因为传入的都是Int字面量,所以底层就是原生int[],不是包装类型Integer[]。这个区别在性能敏感的场景下很重要。

第二种,指定长度但不给初始值:

val arr = new Array[Int](5)

这样创建出来的数组,所有元素会被初始化为该类型的默认值。Int是0,String是null,Boolean是false。这个行为跟Java完全一致,因为底层就是JVM数组。

第三种,用Array.ofDim:

val arr = Array.ofDim[Int](5)

这个和第二种效果一样,但它的优势在于创建多维数组时更清晰,后面讲多维的时候会展开。

提示:Array(1,2,3)和new Array[Int](3)的区别在于,前者是带初始值的,后者是带默认值的。别小看这个区别,我在一个统计程序里就因为用错了方式,导致数组里全是0,排查了半天才发现是创建方式的问题。

2.2 泛型擦除带来的类型陷阱

这是Scala数组最容易踩的坑之一。看这段代码:

val arr = Array("a", "b", "c") val anyArr: Array[Any] = arr // 编译能过吗?

答案是:编译能过,但运行时会出问题。为什么?因为JVM的数组是协变的,String[]可以被当作Object[]使用,但Scala的Array[String]在类型系统里并不是Array[Any]的子类型。编译器在某些情况下会允许这种赋值,但如果你往anyArr里塞一个非String的值,运行时就会抛ArrayStoreException。

这个坑的根源是泛型擦除。JVM在运行时不知道Array[String]里的String是什么,它只知道这是一个对象数组。所以类型检查被推迟到了运行时。我的建议是:永远不要对数组做向上转型的赋值,需要多态就用Array[AnyRef]或者干脆用List。

2.3 数组与Seq的隐式转换

Scala有一个很方便的设计:Array可以隐式转换成ArrayOps,从而获得大量序列操作方法。比如:

val arr = Array(3, 1, 4, 1, 5) arr.map(_ * 2) // 返回Array(6, 2, 8, 2, 10) arr.filter(_ > 2) // 返回Array(3, 4, 5) arr.sorted // 返回Array(1, 1, 3, 4, 5)

这些方法看起来像是数组自带的,其实是ArrayOps提供的。每次调用这些方法,都会创建一个新的数组,原数组不变。这一点要特别注意:数组本身是可变的,但这些操作返回的是新数组。如果你想要原地修改,得用Array自己的update方法或者直接下标赋值。

arr(0) = 100 // 原地修改,arr变成Array(100, 1, 4, 1, 5)

这个隐式转换的机制是:Predef里定义了一个Array到ArrayOps的隐式转换,当你调用map、filter这些方法时,编译器发现Array本身没有这些方法,就会自动套一层ArrayOps。理解这一点,你就能明白为什么数组既能用下标访问,又能用函数式方法了。

3. 可变数组ArrayBuffer:什么时候该用它而不是Array

3.1 Array的不可变长度是硬伤

Array一旦创建,长度就固定了。你没法往里面追加元素,也没法删除元素。这在很多场景下很不方便,比如你要从一个文件里逐行读取数据,事先不知道有多少行,用Array就很尴尬。

这时候ArrayBuffer就派上用场了。它是可变长度的数组,底层还是一个数组,但会自动扩容。

import scala.collection.mutable.ArrayBuffer val buf = ArrayBuffer[Int]() buf += 1 // 追加一个元素 buf += (2, 3, 4) // 追加多个元素 buf ++= Array(5, 6) // 追加一个集合

+=和++=是ArrayBuffer最常用的两个操作符。+=追加单个或多个元素,++=追加一个集合。注意++=可以接受任何TraversableOnce,包括Array、List、Set等。

3.2 扩容机制与性能考量

ArrayBuffer的扩容策略是:当容量不够时,创建一个更大的数组(通常是原来的1.5倍或2倍,取决于具体实现),把旧元素复制过去。这个复制操作是O(n)的,所以如果你知道大概需要多大容量,最好提前指定:

val buf = new ArrayBuffer[Int](1000)

这样初始容量就是1000,避免了频繁扩容。我在处理一个百万级数据的时候,一开始没指定容量,程序跑了十几秒,后来加上初始容量,直接降到两秒多。这个优化在数据量大的时候非常明显。

3.3 Array和ArrayBuffer的转换

两者之间可以互相转换:

val arr = Array(1, 2, 3) val buf = arr.toBuffer // Array转ArrayBuffer val arr2 = buf.toArray // ArrayBuffer转Array

toBuffer和toArray都会创建新的对象,原对象不受影响。这个转换在需要"先动态构建,再固定下来"的场景下很有用。比如你先用ArrayBuffer收集数据,最后转成Array传给一个只接受数组的API。

注意:toArray需要有一个ClassTag,因为要创建具体类型的数组。对于基本类型,Scala会自动处理;对于自定义类型,编译器通常也能推断出来。如果推断不出来,你需要显式提供,比如buf.toArray[String]。

3.4 插入和删除操作

ArrayBuffer支持在任意位置插入和删除:

val buf = ArrayBuffer(1, 2, 3, 4, 5) buf.insert(2, 99) // 在下标2处插入99,变成(1, 2, 99, 3, 4, 5) buf.remove(0) // 删除下标0的元素,变成(2, 99, 3, 4, 5) buf.remove(1, 2) // 从下标1开始删除2个元素,变成(2, 4, 5)

这些操作都是O(n)的,因为需要移动后续元素。如果你频繁在中间插入删除,ArrayBuffer并不是好选择,应该考虑List或者ListBuffer。ArrayBuffer的优势在于尾部追加和随机访问,这两点都是O(1)。

4. 多维数组:从矩阵运算到实际应用

4.1 创建二维数组的几种姿势

Scala创建二维数组,最标准的方式是Array.ofDim:

val matrix = Array.ofDim[Int](3, 4) // 3行4列的二维数组

这会创建一个3x4的数组,所有元素初始化为0。访问方式是matrix(i)(j),其中i是行下标,j是列下标。

另一种方式是数组的数组:

val matrix = Array( Array(1, 2, 3), Array(4, 5, 6), Array(7, 8, 9) )

这种方式更直观,但要注意每一行的长度可以不同,也就是所谓的"锯齿数组"。如果你需要规则的矩阵,用ofDim更合适。

4.2 遍历二维数组的正确方式

遍历二维数组,最自然的是嵌套循环:

for (i <- matrix.indices) { for (j <- matrix(i).indices) { print(matrix(i)(j) + " ") } println() }

或者用更函数式的写法:

for (row <- matrix; elem <- row) { print(elem + " ") }

但要注意,第二种写法不会换行,因为它是把所有元素拉平了遍历。如果你需要按行处理,还是得用第一种。

4.3 矩阵转置的实操案例

转置是一个经典的二维数组操作,把行和列互换。假设有一个3x4的矩阵,转置后变成4x3:

def transpose(matrix: Array[Array[Int]]): Array[Array[Int]] = { val rows = matrix.length val cols = matrix(0).length val result = Array.ofDim[Int](cols, rows) for (i <- 0 until rows; j <- 0 until cols) { result(j)(i) = matrix(i)(j) } result }

这个函数里有个细节:matrix(0).length假设了所有行长度相同。如果是锯齿数组,这个假设不成立,需要先取最大列数。我在一个图像处理的小工具里就遇到过这个问题,输入的数据不是规则矩阵,导致下标越界。后来改成先扫描一遍求最大列数,再创建结果数组,才稳定下来。

4.4 三维及更高维数组

Array.ofDim支持任意维度:

val cube = Array.ofDim[Double](10, 10, 10)

三维数组在科学计算、图像处理(比如RGB通道)、游戏开发(比如体素地形)里很常见。但维度越高,内存占用越大,访问也越慢。我的经验是:超过三维就考虑用一维数组加下标计算来模拟,这样内存是连续的,缓存友好,性能反而更好。

val flat = new Array[Double](1000) def index(i: Int, j: Int, k: Int) = i * 100 + j * 10 + k

这种方式在数值计算库里很常见,值得掌握。

5. 数组的常用操作与性能对比

5.1 遍历、映射、过滤的底层行为

数组的遍历有几种方式,性能差异不大,但代码风格不同:

val arr = Array(1, 2, 3, 4, 5) // 方式一:while循环,最快 var i = 0 while (i < arr.length) { println(arr(i)) i += 1 } // 方式二:for循环,编译器会优化成while for (elem <- arr) println(elem) // 方式三:foreach,函数式风格 arr.foreach(println)

实测下来,while循环确实最快,因为它没有额外的函数调用开销。但在Scala里,for循环经过编译器优化后,性能和while几乎一样。foreach会稍微慢一点,因为涉及函数对象的创建和调用。不过在大多数业务场景下,这点差异可以忽略。

map和filter会创建新数组,原数组不变。如果你只是想要副作用(比如打印),用foreach;如果你需要转换结果,用map。

5.2 排序:sorted、sortBy、sortWith

数组排序有三个方法:

val arr = Array(3, 1, 4, 1, 5, 9, 2, 6) arr.sorted // 自然排序 arr.sortBy(x => -x) // 按自定义规则排序 arr.sortWith((a, b) => a > b) // 自定义比较器

sorted要求元素类型有隐式的Ordering。对于基本类型和String,Scala都提供了默认的Ordering。sortBy接受一个函数,把元素映射成可比较的值。sortWith接受一个返回Boolean的比较函数。

这三个方法都返回新数组,原数组不变。底层用的是TimSort(Java的Arrays.sort),时间复杂度O(n log n)。如果你需要原地排序,可以用scala.util.Sorting:

scala.util.Sorting.quickSort(arr) // 原地排序,arr被修改

注意:quickSort只适用于Array[Int]、Array[Double]等基本类型数组,对于对象数组,需要用stableSort。

5.3 数组与集合的互转

数组和List、Vector、Set之间的转换很频繁:

val arr = Array(1, 2, 3) val list = arr.toList // Array转List val vector = arr.toVector // Array转Vector val set = arr.toSet // Array转Set val arr2 = list.toArray // List转Array val arr3 = vector.toArray // Vector转Array

这些转换都会创建新对象。toList和toVector是O(n)的,toSet也是O(n)的。如果你只是需要遍历,不需要转换,直接用数组就好,避免不必要的开销。

5.4 性能对比表格

操作ArrayArrayBufferListVector
随机访问O(1)O(1)O(n)O(log n)
尾部追加不支持O(1)均摊O(1)O(1)均摊
头部插入不支持O(n)O(1)O(log n)
中间插入不支持O(n)O(n)O(log n)
内存占用最低较低较高较高

这张表是我自己在选数据结构时的参考。简单说:需要随机访问和性能,用Array;需要动态增长,用ArrayBuffer;需要频繁头部操作,用List;需要平衡各种操作,用Vector。

6. 那些文档里不会写的踩坑经验

6.1 数组的equals是个陷阱

这是我最想强调的一点。在Scala里,Array的equals方法比较的是引用,不是内容:

val a = Array(1, 2, 3) val b = Array(1, 2, 3) println(a == b) // false!

这跟List的行为完全不同。List(1,2,3) == List(1,2,3)是true,但数组是false。为什么?因为数组直接继承自Java的数组,equals就是Object的equals,比较的是引用。

要比较数组内容,得用sameElements或者deep:

println(a.sameElements(b)) // true println(a.deep == b.deep) // true,适用于多维数组

这个坑我在写单元测试的时候踩过,断言两个数组相等,结果一直失败,查了半天才发现是equals的问题。后来统一用sameElements,问题解决。

6.2 数组作为参数传递时的可变性

数组是可变的,所以把它传给一个方法,方法内部可以修改它的内容:

def modify(arr: Array[Int]): Unit = { arr(0) = 999 } val a = Array(1, 2, 3) modify(a) println(a(0)) // 999

这个行为跟Java一样。如果你不希望方法修改原数组,要么传副本(arr.clone()),要么用不可变集合。我在一个多人协作的项目里,就因为一个方法偷偷修改了传入的数组,导致调用方的数据被污染,排查了很久。后来团队约定:凡是修改数组的方法,方法名必须带!或者明确注释,比如sortInPlace!。

6.3 数组的初始化顺序问题

用new Array[Int](n)创建数组时,所有元素都是默认值。但如果你用Array.fill,可以指定初始值:

val arr = Array.fill(5)(0) // Array(0, 0, 0, 0, 0) val arr2 = Array.fill(5)(math.random()) // 5个随机数

Array.fill的第二个参数是传名参数,每次都会重新求值。所以Array.fill(5)(math.random())会生成5个不同的随机数,而不是5个相同的。这个细节很多人不知道,我第一次用的时候以为会生成相同的值,结果发现每次都不一样,还以为是bug。

6.4 数组与模式匹配

数组可以用在模式匹配里:

val arr = Array(1, 2, 3) arr match { case Array(1, _, _) => println("以1开头") case Array(_, 2, _) => println("第二个是2") case _ => println("其他") }

但要注意,模式匹配数组时,长度必须完全匹配。Array(1, 2, 3)不会匹配Array(1, 2)。如果你需要匹配任意长度,可以用Array(1, _*):

arr match { case Array(1, _*) => println("以1开头,后面任意") case _ => println("其他") }

这个_*语法在提取器和模式匹配里很常用,值得记住。

6.5 数组的序列化问题

数组是可序列化的,但如果你把数组放在一个case class里,序列化时会有一些细节:

case class Data(values: Array[Int])

这个case class的equals方法会比较数组的引用,而不是内容。所以两个Data对象即使数组内容相同,也不相等。要解决这个问题,要么重写equals,要么用List代替数组。我在一个需要缓存的场景里就遇到过这个问题,缓存key用了包含数组的case class,结果缓存永远命中不了。后来改成List,问题解决。

7. 从数组出发的进阶学习路径

数组是Scala集合体系的起点,理解了数组,再学其他集合就轻松很多。我的建议是:先把Array和ArrayBuffer用熟,然后去了解Seq、IndexedSeq这些trait的层次结构,最后再深入Vector、List、Set、Map。

如果你对性能有要求,可以进一步研究scala.collection.parallel(虽然在新版本里已经独立出去了)或者scala.collection.immutable.ArraySeq。ArraySeq是Scala 2.13引入的,它是对数组的不可变封装,既有数组的性能,又有不可变集合的安全性。我在新项目里已经全面用ArraySeq替代裸数组了,既避免了可变性带来的问题,又保留了性能优势。

另外,数组和Java互操作是Scala的一大优势。你可以直接把Scala数组传给Java方法,反之亦然。这在调用Java库的时候非常方便。但要注意基本类型数组和包装类型数组的区别,Java方法如果接受Integer[],你传Array[Int]是不行的,需要先转成Array[Integer]。

最后分享一个小技巧:如果你需要频繁地创建和销毁小数组,考虑用对象池或者复用数组。JVM的垃圾回收对短生命周期的小对象很友好,但在高并发场景下,减少分配总是好的。我在一个高频交易模拟系统里,就用了一个简单的数组池,性能提升了大概15%。当然,大多数业务场景不需要这么抠,但知道这个思路没坏处。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进