数组

大多数时候只需要数组的简单用法,但有时需要对数组做一些复杂的操作,可能还需要在数组和更复杂的 Collection 类型之间做评估选型。随着 Java Collection 和 Stream 类中高级功能的不断增加,日常编程中使用数组的需求越来越少。

数组特性

数组和 Collection 中的类型主要有三方面的区别:效率、类型以及保存基本类型数据的能力。数组是 Java 中保存和随机访问对象引用序列最高效的方法。数组是简单的线性序列,这使得对元素的访问变得非常快。然而这种高速的代价就是数组对象的大小是固定的,且在该数组的生存期内不能更改。

速度通常不是问题,即使是问题,存储对象的方式通常不是首要原因。通常你应该首先考虑 Collection 中的 ArrayList 类型,该类型实际是在内部维护了一个数组。在必要时,它会自动分配更多的数组空间,创建新数组,并将旧数组中的引用移动到新数组。这种灵活性需要开销,所以一个 ArrayList 的效率不如数组。在极少的情况下效率会成为问题,所以这种时候你可以直接使用数组。

不论是数组还是 Collection,如果越界访问,就会抛出 RuntimeException 异常,指明程序存在错误。

在泛型之前,Collection 类在处理对象时无需关注对象的具体类型,因为它们把对象统一视为 Object 类型,即 Java 中所有类共同的基类。相比于泛型之前的 Collection,数组的优点是它创建后只能用于保存特定类型的对象,这意味着得到了编译时类型检查的机制保障。

泛型之前的 Collection 不能保存基本类型,依靠泛型,Collection 类型可以指定并检查它们所保存对象的类型,依靠自动装箱,Collection 也可以变相地保存基本类型。下面是数组和泛型 Collection 的比较:

class BerylliumSphere {
    private static long counter;
    private final long id = counter++;
    @Override public String toString() {
        return "Sphere: " + id;
    }
}
public class CollectionComparison {
    public static void main(String[] args) {
        BerylliumSphere[] spheres = new BerylliumSphere[10]; //对象数组初始化为 null
        for(int i = 0; i < 5; i++) {
            spheres[i] = new BerylliumSphere();
        }
        show(spheres);
        System.out.println(spheres[4]);

        List<BerylliumSphere> sphereList = Suppliers.create(ArrayList::new, BerylliumSphere::new, 5);
        System.out.println(sphereList);
        System.out.println(sphereList.get(4));

        int[] ints = {0, 1, 2, 3, 4, 5};
        show(ints);
        System.out.println(ints[4]);

        List<Integer> intList = new ArrayList<>(Arrays.asList(0, 1, 2, 3, 4));
        intList.add(97);
        System.out.println(intList);
        System.out.println(intList.get(4));
    }
}
/* output
[Sphere: 0, Sphere: 1, Sphere: 2, Sphere: 3, Sphere: 4, null, null, null, null, null]
Sphere: 4
[Sphere: 5, Sphere: 6, Sphere: 7, Sphere: 8, Sphere: 9]
Sphere: 9
[0, 1, 2, 3, 4, 5]
4
[0, 1, 2, 3, 4, 97]
4 */

两种保存对象的方式都是有类型检查的,唯一明显区别是,数组使用 [] 来访问元素,而 List 使用如 add()get() 这样的方法。数组和 ArrayList 之间的相似是设计者有意为之,所以在概念上,两者很容易切换。如前所见,集合支持的功能要比数组多很多。

随着 Java 自动装箱技术的出现,通过集合使用基本数据类型几乎和通过数组一样简单。数组唯一剩下的优势就是效率。然而,当你解决一个更加普遍的问题时,数组可能限制太多,这种情形下可以使用集合类。

用于显示数组的实用程序:Java 提供了 Arrays.toString() 来将数组转换为可读字符串,然后可以在控制台上显示。然而这种方式不够简洁,所以我们创建一个小的库来完成这项工作:

第一个单参数的 show() 方法用于打印 Object 类型的数组,包括各种基本类型的包装类。其余重载方法则用于适配各种基本类型。

两组参数的 show() 方法是为了打印时在数组前增加一个说明性的字符串。

一等对象

不论使用哪种类型的数组,数组的标识符 [] 实际上都是对某个在堆上创建的真实对象的引用,因此数组就是一种保存对其他对象的引用的对象,它可以作为数组初始化语句的一部分来隐式地创建,也可以使用 new 关键字显示地创建。数组对象中的一部分(实际上也是唯一可读的字段或方法)是只读的 length 成员属性,代表数组中可保存的元素数量,此外 [] 语句就是访问数组对象的唯一途径了。

下面的例子总结了初始化数组的多种方式,并且展示了如何给不同的数组对象分配数组引用。从该例可以看到,对象数组和基本类型数组的使用方式是一样的,唯一区别在于,对象数组保存的是引用,而基本类型数组保存的是基本类型的值。

数组 a 是一个未初始化的本地变量,如果没有正确初始化,编译器就会阻止对该引用做任何操作。数组 b 被初始化为一个对象数组的引用,但实际上没有任何对象被保存在 b 里,由于 b 指向了合法的对象,因此仍然可以查询数组的大小。这带来了一个问题:无法找出到底有多少元素存储在数组中,因为 length 只能告诉你数组可以存储多少元素,即数组对象的大小并不是真正存储在数组中对象的个数。然而,当你创建一个数组对象,其引用将自动初始化为 null,因此你可以通过检查特定数组元素中的引用是否为 null 来判断其中是否有对象。基本类型数组也有类似的机制,比如自动将数值类型初始化为 0,char 型初始化为 (char)0,布尔类型初始化为 false。

数组 c 演示了如何创建数组,并给c中所有的位置都分配了 BerylliumSphere 对象。数组 d 展示了创建数组对象的聚合初始化语法(和数组 c 一样隐式地使用 new 在堆中创建对象)并且初始化成 BeryliumSphere 对象。

然后是一种“动态聚合初始化”, d 使用的聚合初始化必须在 d 定义处使用,但是使用第二种语法,你可以在任何地方创建和初始化数组对象。假设 hide() 是一个个将 BerylliumSphere 对象数组作为参数的方法,可以 hide(d) 调用,也可以在传入参数的地方,直接动态创建一个数组:

很多情况下这种语法写代码更加方便。

返回数组

假设需要写一个返回多个元素的方法,对 C++/C 这样的语言来说这是很困难的,因为你无法返回一个数组,只能是返回一个指向数组的指针。这会带来一些问题,因为对数组生存期的控制变得很混乱,这会导致内存泄露。

而在 Java 中,可以直接返回一个数组。永远不需要担心数组的内存管理,只要数组还在使用就一直存在,而垃圾收集器会在使用完之后自动将它回收。

下面演示如何返回一个 String 类型数组:

返回数组和返回所有其他类型的对象一样,都只是返回引用。数组是在 flavorSet() 中或者是在其他什么地方创建的并不重要。垃圾收集器会清理你用完的数组,你需要的数组则会保留。

如果需要返回一组不同类型的元素,可以选择使用泛型章节介绍的 tuple。

注意,当 flavorSet() 随机选择 flavors,它应该确保某个特定的选项没被选中。这在一个 do 循环中执行,它将一直做出随机选择直到它发现一个元素不在 picked 数组中(也可以使用字符串比较的方式检查随机选出的口味是否已在 result 中)。

一直到现在,随机数都是通过 java.util.Random 类生成的,这个类从 Java 1.0 就有,甚至更新过以提供 Java 8 中的 Stream。现在我们可以介绍 Java 8 中的 SplittableRandom ,它不仅能在并行操作使用,而且提供了一个高质量的随机数。剩余章节都使用 SplittableRandom 。

多维数组

创建多维的基本类型数组:

每个嵌套的大括号都代表了数组的一个维度。

该例使用 Arrays.deepToString() 方法,将多维数组转换成 String 类型,在结果这种格式化显示。

也可以用 new 创建数组:

对于基本类型数组的元素值,如果没有将其显式地初始化,则它们会被自动初始化。对象数组会被初始化为 null。

数组中构成矩阵的每组向量的大小没有限制(称为不规则数组(ragged array)):

Java 8 新增了 Arrays.setAll() 方法,其使用一个生成器来生成数值并填充到数组中。该生成器和函数式接口 IntUnaryOperator 一致,只有一个非默认方法 applyAsInt(int operand)Arrays.setAll() 则将当前数组索引作为 operand(操作数) 传入,所以也可以使用 n->n 的 lambda 表达式来显示数组中的索引,此处忽略了索引,只是简单地插入了一个自增计数器的值。

第一个 new 创建了一个数组,首元素长度随机,其余的则不确定。第二个 new 在 for 循环中给数组填充了第二个元素,第三个 new 为数组的最后一个索引填充元素。

对象数组同样可以被定义为不规则数组:

下面实例是如何逐步构建一个非基本类型的对象数组:

Arrays.deepToString() 同时适用于基本类型数组和对象数组:

同样的,在 Integer 和 Double 数组中,自动装箱可为你创建包装类型对象。

数组和泛型

通常数组和泛型不能很好结合,例如无法实例化一个参数化类型的数组:

类型擦除没了参数的类型信息,而数组必须知道它们所保存的确切类型,以强制保证类型安全。

但是,可以参数化数组本身的类型:

比起使用参数化类,使用参数化方法很方便。无需每应用到一种类型都实例化一个带参数的类,并且该类还可以是静态的。但又不能总是选择参数化方法。

其实,编译器不允许实例化一个泛型数组,但它允许创建对此类数组的引用,如 List<String>[] ls;

这样可以通过编译,并且尽管无法创建一个能实际持有泛型的数组对象,但还是可以创建非泛型数组,然后强制类型转换:

一旦持有对 List<String>[] 的引用,就会得到编译时检查。问题是数组是协变的,所以一个 List<String>[] 同时也是一个 Object[],可以利用这一点,将 ArrayList<Integer> 分配到数组里,而且无论编译时还是运行时都不会报错。

如果你知道你不会进行向上类型转换,你的需求相对简单,那么可以创建一个泛型数组,它将提供基本的编译时类型检查。然而,一个泛型 Collection 实际上是一个比泛型数组更好的选择。

一般泛型在类和方法的边界上是有效的,但在内部,擦除常常会使泛型不可使用。所以,就像下面的例子,不能创建泛型类型的数组:

试图创建已被擦除类型(因此是未知类型)的数组,可以创建 Object 类型数组,然后进行强制类型转换。但若无注解,由于数组没有真正持有或动态检查类型 T,会得到运行时报警"unchecked"。即如果我创建了一个 String[] , Java将在编译时和运行时强制执行,只能在数组中放置字符串对象。然而,如果我创建一个 Object[] ,我可以把除了基本类型外的任何对象放入数组。

Arrays.fill()

可以将一个数值复制到数组的所有位置,对于对象数组,可以将一个引用复制到数组的所有位置:

可以填充整个数组,也可以填充数组的指定范围。由于只能向其传入一个数值来填充,因此实际作用不大。

Arrays.setAll()

该方法是 Java 8中引入的,它能利用生成器生成不同的值,具体的生成方式由数组的索引元素决定(生成器通过访问当前索引来读取并修改数组的值)。另外还有以下重载方法签名:

  • void setAll(int[] a, IntUnaryOperator gen)

  • void setAll(long[] a, IntToLongFunction gen)

  • void setAll(double[] a, IntToDoubleFunction gen)

  • void setAll(T[] a, IntFunction<? extends T> gen)

int, long 和 double 由专用版本来处理,其他类型则由泛型版本来处理。生成器不是 Supplier,因为 Supplier 不接受参数,而生成器则必须传入 int 型的数组索引作为参数。

Bob 那里的方法引用是有效的,因为 Bob 的构造器接收 int 类型参数。只要我们传入的函数能接收 int 类型参数并生成预期的结果,这种方式就有效。

要处理 int, long 和 double 之外的基本类型,就需要为该基本类型编写相匹配的包装类型数组,然后使用泛型版本的 setAll()。注意 getChar() 生成的是基本类型,所以这里将其自动装箱为 Character。

增量生成器

介绍一组用来给不同类型生成增量的值的方法。

这些方法以内部类方式实现,以生成易记的名字。如,要使用 Integet 的相关功能,可以使用 new Count.Integer()。使用基本类型 int 相关功能,可以使用 new Count.Pint()(由于不能直接使用基本类型的名字,在前面统一加上 P,表示 primitive)。每个包装类的生成器都使用 get() 方法实现了它的 Supplier 。要使用 Array.setAll() ,一个重载的 get(int n) 方法要接受(并忽略)自身的参数,以便接受 setAll() 传递的索引值。

注意,由于使用了包装类的名字作为内部类的名字,因此必须在实际的包装类名前加上包名 java.lang:

对于 int, long 和 double 三个基本类型的增量生成器,可以使用专用 Supplier 接口分别实现。

下面是如何使用它的例子:

需要注意:基本类型数组 int[], long[], double[] 可以直接使用 Arrays.setAll() 填充,但是其他的基本类型都需要它们自己的包装类型的数组。

通过 Stream.generate() 创建的包装类数组演示了 toArray() 的重载用法,在这里你应该提供给它要创建的数组类型的构造器。

随机数生成器

按照 Count.java 创建一个生成随机值的工具:

对于除了 int 、 long 和 double 之外的所有基本类型元素生成器,只生成数组,而不是 Count 中看到的完整操作集。

下面是对该工具的测试:

泛型和基本类型数组

由于泛型无法应用于基本类型,但是有时必须要实现基本类型数组和包装类型数组之间的相互转换,这就是之前定义的 ConvertTo.java 转换器。

修改已有数组元素

传给 Arrays.setAll() 的生成器函数可以通过传给它的数组索引来修改已存在的数组元素:

这里特别适合使用 lambda 表达式,因为数组正好一直在表达式的作用范围内。

关于数组并行

有一些情况下,不管是否决定尝试并行,也不管是否只有一个处理器,并行实现都是唯一的、最佳的或最合理的选择,因此并行很重要。

策略

从数据的角度思考并行可能是最合适的。对于大量数据(以及可用的额外处理器),并行可能会有所帮助。但也可能使事情变得更糟。

后面会讨论下面这些情况:

  1. 并行是唯一可行的方案。这种情况很罕见。

  2. 有多种可选方案,但是并行版本(通常是最新的版本)被设计成在任何地方都可以使用(甚至在那些不关心并行性的代码中),类似情况 1。我们将按预期使用并行版本。

  3. 情况 1 和情况 2 并不是那么常见。大多数情况下,会看到两种版本的算法,一个用于并行使用,另一个用于正常使用。将描述并行的一个,但不会在普通代码中使用它,因为它也许会产生所有可能的问题。

parallelSetAll()

使用 Stream,假设我们想要创建一个数值由从零开始填充的 Long 型数组:

实际上,Stream 处理的值到达千万时才会开始耗尽堆内存。常规的 setAll() 是有效的,可以使用它初始化更大的数组。如果速度成为一个问题,Arrays.parallelSetAll() 将(可能)更快地执行初始化(请记住并行性中描述的问题)。

数组分配和初始化是在单独的方法中执行的,因为如果两个数组都在 main() 中分配,它会耗尽内存。(还有一些方法可以告诉Java在启动时分配更多的内存)

数组实用工具

之前已经看到了 java.util.Arrays 中的 fill()setAll()/parallelSetAll(),该类中还有更多有用的静态工具方法,大体如下:

  • asList():传入任意的序列或数组,并转化为列表集合(List Collection)

  • copyOf():按新的长度创建已有数组的副本

  • copyOfRange():对已有数组的指定长度范围创建副本

  • equals():比较两个数组是否相等

  • deepEquals():比较两个多维数组是否相等

  • stream():为数组元素创建一个流

  • hashCode():计算数组的哈希值

  • deepHashCode():计算多维数组的哈希值

  • sort():对数组进行排序

  • parallelSort():以并行方式对数组进行排序,以提升速度

  • binarySearch():在已排序的数组中查找一个元素

  • parallelPrefix():使用提供的函数进行并行累积计算,基本上相当于数组的 reduce()方法

  • spliterator():生成数组的分割器(Spliterator),属于流的高级内容

  • toString():生成用于描述数组的字符串

  • deepTostring():生成用于描述多维数组的字符串

数组复制

copyOf()copyOfRange()方法复制数组的速度远远快于用 for 循环等手写代码的实现方式。这类方法对所有类型都有对应的重载版本。下例是 int 和 Integer 类型的数组复制:

只指定要复制的结果大小(size)是最基本的复制数组的方法。通过改变 size(最后一个参数),可以改变结果数组的长度。

copyOf()copyOfRange() 都适用于包装类,且都有创建不同类型数组的重载版本,只需在调用时给方法的最后一个参数传入目标类型,这是用来做“向上转型”和“向下转型”的,即如果想根据一个已有的子类型数组生成它的基类数组,就可以用这些方法。

最后部分的“数组转型”可以通过编译,但如果类型不匹配,则会抛出运行时异常。在此处强行把基类当作其子类是非法的,因为子类对象中的一些数据和方法很可能在基类对象中并不存在。

本例说明了基本类型数组和对象数组都可以进行复制。但是,如果复制对象数组,那么只有引用会被复制,因为并不存在对象自身的副本。这称为浅拷贝

还有一个方法 System.arraycopy() ,它将一个数组复制到另一个已经分配的数组中。此处并不会发生自动装箱或自动拆箱,这是因为相关的两个数组的类型必须完全一致。

数组比较

Arrays 类提供了 equals() 方法比较一维数组是否相等,以及 deepEquals() 方法比较多维数组。这些方法都有针对所有基本类型和对象类型的重载版本实现。

数组相等的含义:数组必须有相同数量的元素,并且每个元素必须与另一个数组中的对应元素相等,对每个元素使用 equals() 判断(对于基本类型,是由其包装类中的 equals() 方法来比较的,如 int 元素实际是由 Integer.equals() 来负责比较)

mdl 和 md2 都是 String 类型的多维数组,并且都由 twoDArray() 来初始化。注意这里 deepEquals() 结果为 true,因为它用了正确的比较方式,而普通的 equals() 方法则得到错误的 false。

流和数组

stream() 方法能够很方便地根据某些类型的数组生成流:

只有 int, long 和 double 这些原生支持的类型适用于 Arrays.stream(),其他类型需要通过包装类数组实现。

相较于直接操作数组,先将数组转化为流通常更容易生成你想要的结果。注意,即使流已经“用完”(不能重复使用它),你仍然拥有该数组,因此可以以其他方式使用它——包括生成另一个流。

数组排序

排序意味着要根据对象的实际类型来进行比较。一种办法是为每种不同的类型都编写不同的排序方法,但是这种方式无法复用于新出现的类型。

程序设计中最重要的目标之一是“分离易变的和不易变的事物”。此处、不变的(代码)是通用的排序算法,而会变化的则是具体对象的比较方式。所以相较于在大量不同的排序逻辑中分别实现不同的比较方法,更应该使用策略设计模式(strategy design pattern)。

在策略模式中,代码中易变的部分被包含在一个独立的类中(即策略对象),然后将策略对象传递到通用不变的代码模块中,后者利用策略对象来补全为完整的算法。通过这种方式,你可以为不同的比较方法写出不同的策略对象,然后填充到相同的排序代码中。

Java 有两种方式来实现比较功能。第一种是给目标类增加“原生”的比较方法,只需实现 java.lang.Comparable 接口,该接口很简单,只有一个方法:compareTo(),该方法接受另一个相同类型的对象作为参数,如果当前对象比传入对象更小就返回负值,相等返回 0,更大就返回正值。

下面示例的类实现了 Comparable 接口,并使用 Java 标准库方法 Arrays.sort() 演示了其可比较性:

定义比较方法时需要决定如何来比较两个对象,本例中,只有 i 的值在比较中被用到,忽略了 j 值。

main() 方法中,Arrays.setAll() 通过调用 get() 来填充 CompType 数组 a,然后对其排序。如果没有实现 Comparable 接口,在调用 sort() 方法时会抛出运行时异常 ClassCastException,这是因为 sort() 方法会将其参数转型为 Comparable。

如果不喜欢这种实现形式,还可以单独创建一个实现了 Comparator 接口(集合章节中有介绍)的类,它有两个方法:compare()equals()。然而,除非有特殊的性能需求,否则无须专门实现 equals()方法。这是因为无论在何时创建一个类,它都隐式地继承于 Object 类,自然就会有 equals()方法。可以直接用默认的 equals() 方法来满足接口的强制规范。

Collections 类包含 reverseOrder() 方法,用来生成和自然排序顺序相反的 Comparator,在本例中的应用如下:

也可以编写自定义的 Comparator,下面示例通过 j 的值,对 CompType 对象进行了比较:

使用 Arrays.sort()

通过内建的排序方法,可以对任何基本类型数组或者对象类型(实现了 Comparable 接口或实现了对应的 Comparator)进行排序。下例中生成了一个随机字符串数组,然后进行排序:

注意,该字符串排序算法中的输出使用的是字典顺序,因此会将所有大写字母幵头的单词放在前面,如果想忽略大小写分组,可以使用 String.CASE_INSENSITIVE_ORDER

Java 标准库中的排序算法已经为各种类型做了最优的设计,对基本类型使用快速排序,对对象使用稳定的归并排序。

并行排序

如果排序性能变成了问题,可以使用 Java 8 中的 parallelSort(),该方法对所有可能的情况都实现了对应的重载版本,包括对数组中的多个部分进行排序,或者使用 Comparator。这里使用了 JMH 来比较 parallelSort() 相较于传统 sort() 的优势:

parallelSort() 的算法不断将大数组分割成较小的数组,直到数组的大小到达限制,继而使用传统的 Arrays.sort()方法,然后将结果合并。该算法要求额外的内存空间,但是不会比原始数组的空间更大。

用 Arrays.binarySearch() 进行二分查找

对于已排序的数组,可以通过 Arrays.binarySearch() 快速查找某个特定的元素。下面示例使用 Rand.Pint 类创建了一个用随机 int 值填充的数组,然后调用了 getAsInt()(因为 Rand.Pint 是个 IntSupplier)来生成被查找的测试值:

在 while 循环中会不断生成随机数作为被查找的目标,直到其中的某一个在数组中被找到。

如果目标在数组中被找到,Arrays.binarySearch() 会返回大于或等于0的值,否则会返回负数。负值的意义为,如果要手动维护该数组的排序,则该目标值应该插入的位置其算法,产生的值是 -(insertion point) - 1

应该插入的位置为比目标值大的第一个元素的索引值,如果数组中所有元素都比目标值小就是 a.size()

由于查找算法在设计时未考虑兼容重复元素,因此无法保证其中哪一个会被查找到。如果需要一个去重的排序数组,可以使用 TreeSet(以维护排列顺序)或者 LinkedHashSet (以维护插入顺序)。这些类会自动管理所有细节,只有当它们成为性能瓶颈时,才应该考虑用手动维护(顺序)的数组替代。

如果使用了 Comparator 来对对象数组进行排序(基本类型的数组不允许使用 Comparator 来排序),那么在使用 binarySearch()(适用的 binarySearch() 重载版本)时也需要引用同一个 Comparator。例如 StringSorting.java 就可以通过改造来实现查找:

Comparator 必须要作为第三个参数传给重载的 binarySearch() 方法。

用 ParallelPrefix() 进行累积计算

不存在 prefix() 方法。该方法很像 Stream 类中的 reduce() 方法,它会对前一个元素和当前元素进行操作,将结果放入当前元素的位置:

这里对数组应用 Integer::sum。在位置 0 中,它将先前计算的值(因为没有先前的值)与原始数组位置 0 中的值合并并更新到位置 0。在位置 1 中,它获取之前计算的值(它只是存储在位置 0 中),并将其与位置 1 中先前计算的值合并并更新到位置 1,依次往复。

使用 Stream.reduce() 只能得到最终的结果,而 Arrays.parallelPrefix() 还能得到所有中间结果值。

下面使用字符串来举例:

用 Stream 来初始化很优雅,但是对于非常大的数组,该方法会导致堆内存耗尽。使用 setAll() 方法来初始化能更高效利用内存:

由于很难保证正确使用,因此除非遇到内存或者性能(或两者同时出现)问题时,可以考虑使用 parallelPrefix(),否则都应该默认使用 Stream.reduce()

总结

所有争议都表明,如果你是基于较新的 Java 版本开发,那么应该“优先选择集合而不是数组”,只有当你能证明性能成了问题(并且如果改用数组实现可以显著改善问题)时,才应该重构为数组。

最后更新于