这三者都是实现集合框架中的 List,也就是所谓的有序集合,因此具体功能也 比较近似,比如都提供按照位置进行定位、添加或者删除的操作,都提供迭代 器以遍历其内容等。但因为具体的设计区别,在行为、性能、线程安全等方面, 表现又有很大不同。 Verctor 是 Java 早期提供的线程安全的动态数组,如果不需要线程安全,并不 建议选择,毕竟同步是有额外开销的。Vector 内部是使用对象数组来保存数据, 可以根据需要自动的增加容量,当数组已满时,会创建新的数组,并拷贝原有 数组数据。 ArrayList 是应用更加广泛的动态数组实现,它本身不是线程安全的,所以性能 要好很多。与 Vector 近似,ArrayList 也是可以根据需要调整容量,不过两者 的调整逻辑有所区别,Vector 在扩容时会提高 1 倍,而 ArrayList 则是增加 50%。 LinkedList 顾名思义是 Java 提供的双向链表,所以它不需要像上面两种那样调 整容量,它也不是线程安全的。
知识扩展
我们先一起来理解集合框架的整体设计,为了有个直观的印象,我画了一个简 要的类图。注意,为了避免混淆,我这里没有把 java.util.concurrent 下面的线 程安全容器添加进来;也没有列出 Map 容器,虽然通常概念上我们也会把 Map 作为集合框架的一部分,但是它本身并不是真正的集合(Collection)。
List,也就是我们前面介绍最多的有序集合,它提供了方便的访问、插入、 删除等操作。
Set,Set 是不允许重复元素的,这是和 List 最明显的区别,也就是不存 在两个对象 equals 返回 true。我们在日常开发中有很多需要保证元素唯 一性的场合。
Queue/Deque,则是 Java 提供的标准队列结构的实现,除了集合的基 本功能,它还支持类似先入先出(FIFO, First-in-First-Out)或者后入 先出(LIFO,Last-In-First-Out)等特定行为。这里不包括 BlockingQueue,因为通常是并发编程场合,所以被放置在并发包里。
每种集合的通用逻辑,都被抽象到相应的抽象类之中,比如 AbstractList 就集 中了各种 List 操作的通用部分。这些集合不是完全孤立的,比如,LinkedList 本身,既是 List,也是 Deque 哦。 如果阅读过更多源码,你会发现,其实,TreeSet 代码里实际默认是利用 TreeMap 实现的,Java 类库创建了一个 Dummy 对象“PRESENT”作为 value, 然后所有插入的元素其实是以键的形式放入了 TreeMap 里面;同理,HashSet 其实也是以 HashMap 为基础实现的,原来他们只是 Map 类的马甲! 就像前面提到过的,我们需要对各种具体集合实现,至少了解基本特征和典型 使用场景,以 Set 的几个实现为例: TreeSet 支持自然顺序访问,但是添加、删除、包含等操作要相对低效 (log(n) 时间)。 HashSet 则是利用哈希算法,理想情况下,如果哈希散列正常,可以提 供常数时间的添加、删除、包含等操作,但是它不保证有序。 LinkedHashSet,内部构建了一个记录插入顺序的双向链表,因此提供 了按照插入顺序遍历的能力,与此同时,也保证了常数时间的添加、删 除、包含等操作,这些操作性能略低于 HashSet,因为需要维护链表的 开销。 在遍历元素时,HashSet 性能受自身容量影响,所以初始化时,除非有 必要,不然不要将其背后的 HashMap 容量设置过大。而对于 LinkedHashSet,由于其内部链表提供的方便,遍历性能只和元素多少 有关系。 我今天介绍的这些集合类,都不是线程安全的,对于 java.util.concurrent 里面 的线程安全容器,我在专栏后面会去介绍。但是,并不代表这些集合完全不能 支持并发编程的场景,在 Collections 工具类中,提供了一系列的 synchronized 方法,比如 static List synchronizedList(List list) 我们完全可以利用类似方法来实现基本的线程安全集合: List list = Collections.synchronizedList(new ArrayList()); 它的实现,基本就是将每个基本方法,比如 get、set、add 之类,都通过 synchronizd 添加基本的同步支持,非常简单粗暴,但也非常实用。注意这些方 法创建的线程安全集合,都符合迭代时 fail-fast 行为,当发生意外的并发修改 时,尽早抛出 ConcurrentModificationException 异常,以避免不可预计的行为。 另外一个经常会被考察到的问题,就是理解 Java 提供的默认排序算法,具体 是什么排序方式以及设计思路等。 这个问题本身就是有点陷阱的意味,因为需要区分是 Arrays.sort() 还是 Collections.sort() (底层是调用 Arrays.sort());什么数据类型;多大的数据集 (太小的数据集,复杂排序是没必要的,Java 会直接进行二分插入排序)等。 对于原始数据类型,目前使用的是所谓双轴快速排序(Dual-Pivot QuickSort),是一种改进的快速排序算法,早期版本是相对传统的快速 排序,你可以阅读源码。 而对于对象数据类型,目前则是使用 TimSort,思想上也是一种归并和 二分插入排序(binarySort)结合的优化排序算法。TimSort 并不是 Java 的独创,简单说它的思路是查找数据集中已经排好序的分区(这里 叫 run),然后合并这些分区来达到排序的目的。 另外,Java 8 引入了并行排序算法(直接使用 parallelSort 方法),这是为了 充分利用现代多核处理器的计算能力,底层实现基于 fork-join 框架(专栏后面 会对 fork-join 进行相对详细的介绍),当处理的数据集比较小的时候,差距不 明显,甚至还表现差一点;但是,当数据集增长到数万或百万以上时,提高就 非常大了,具体还是取决于处理器和系统环境。 排序算法仍然在不断改进,最近双轴快速排序实现的作者提交了一个更进一步 的改进,历时多年的研究,目前正在审核和验证阶段。根据作者的性能测试对 比,相比于基于归并排序的实现,新改进可以提高随机数据排序速度提高 10%~20%,甚至在其他特征的数据集上也有几倍的提高,有兴趣的话你可以 参考具体代码和介绍:http://mail.openjdk.java.net/pipermail/core-libsdev/2018-January/051000.html 。 在 Java 8 之中,Java 平台支持了 Lambda 和 Stream,相应的 Java 集合框架 也进行了大范围的增强,以支持类似为集合创建相应 stream 或者 parallelStream 的方法实现,我们可以非常方便的实现函数式代码。 阅读 Java 源代码,你会发现,这些 API 的设计和实现比较独特,它们并不是 实现在抽象类里面,而是以默认方法的形式实现在 Collection 这样的接口里! 这是 Java 8 在语言层面的新特性,允许接口实现默认方法,理论上来说,我们 原来实现在类似 Collections 这种工具类中的方法,大多可以转换到相应的接口 上。针对这一点,我在面向对象主题,会专门梳理 Java 语言面向对象基本机 制的演进。 在 Java 9 中,Java 标准类库提供了一系列的静态工厂方法,比如,List.of()、 Set.of(),大大简化了构建小的容器实例的代码量。根据业界实践经验,我们发 现相当一部分集合实例都是容量非常有限的,而且在生命周期中并不会进行修 改。但是,在原有的 Java 类库中,我们可能不得不写成: ArrayList list = new ArrayList<>(); list.add("Hello"); list.add("World"); 而利用新的容器静态工厂方法,一句代码就够了,并且保证了不可变性。 List simpleList = List.of("Hello","world"); 更进一步,通过各种 of 静态工厂方法创建的实例,还应用了一些我们所谓的最 佳实践,比如,它是不可变的,符合我们对线程安全的需求;它因为不需要考 虑扩容,所以空间上更加紧凑等。 如果我们去看 of 方法的源码,你还会发现一个特别有意思的地方:我们知道 Java 已经支持所谓的可变参数(varargs),但是官方类库还是提供了一系列 特定参数长度的方法,看起来似乎非常不优雅,为什么呢?这其实是为了最优 的性能,JVM 在处理变长参数的时候会有明显的额外开销,如果你需要实现性 能敏感的 API,也可以进行参考。