对比Vector、 ArrayList、 LinkedList有何区别?
典型回答:
- 这三者都是实现集合框架的List,就是所谓的有序集合,因此具体功能也比较近似,比如都提供按照位置进行定位、添加或者删除的操作,都提供迭代器以遍历其内容等。但因为具体的设计区别,在行为、性能、线程安全等方面,表现又有很大不同。
- Vector是Java早期提供的线程安全的动态数组,如果不需要线程安全,并不建议选择,毕竟同步是有额外开销的。Vector内部是使用对象数组来保存数据,可以根据需要自动的增加容量,当数组已满时,会创建新的数组,并拷贝原有数组数据。
- ArrayList是应用更加广泛动态数组实现,本身不是线程安全的,所以性能要好很多。与Vector近似,ArrayList是可以根据需要调整容量,不过两者的调整逻辑有所区别,Vector在扩容时会提高一倍,而ArrayList则是增加50%。
- LinkedList顾名思义是Java提供的双向链表,所以不需要像上面两种那样调整容量,也不是线程安全的。
问题分析:
此题是一道经典的面试题,一般来说,也可以补充一下不同容器类型适合的场景:
- Vector和ArrayList作为动态数组,其内部元素以数组形式顺序存储的,所以非常适合随机访问的场合。除了尾部插入和删除元素,往往性能会相对较差,比如在中间位置插入一个元素,需要移动后续所有元素。
- 而LinkedList进行节点插入,删除却要高效得多,但是随机访问性能要比动态数组慢。
至少需要掌握:
内部排序:至少掌握基础算法如归并排序、交换排序(冒泡、快排)、选择排序、插入排序等
外部排序:掌握利用内存和外部存储处理超大数据集,至少需要理解过程和思路。
知识扩展
先一起来理解集合框架的整体设计,注意,为了避免混淆,这里没有把java.util.concurrent下面的线程安全容器添加进来;
也没有列出Map容器,虽然通常概念上我们也会把Map作为集合框架的一部分,但是它本身并不是真正的集合( Collection)。
我们可以看到Java集合框架,Collection接口是所有集合的根,然后扩展开提供了三大类集合,分别是:
- List:就是前面介绍最多的有序集合,提供了方便的访问、插入、删除等操作
- Set:Set是不允许重复元素的,这是和List最明显的区别,也就是不存在两个对象equals返回true。在日常开发中有很多需要保证元素唯一性的场合。
- Queue/Deque:是Java提供的标准队列结构的实现,除了集合的基本功能,还支持类似先入先出(FIFO,First-in-First-Out)或者后入后出( LIFO, Last-In-FirstOut)等特定行为。这里不包括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 <T> List<T> synchronizedLis(List<T> list)
我们完全可以利用类似方法来实现基本的线程安全集合:
List list = Collections.synchronizedList(new ArrayList());
它的实现,基本就是将每个基本方法,比如get、 set、 add之类,都通过synchronizd添加基本的同步支持,非常简单粗暴,但也非常实用。注意这些方法创建的线程安全集合,都符合迭代时fail-fast行为,当发生意外的并发修改时,尽早抛出ConcurrentModifcationException异常,以避免不可预计的行为。
另外一个经常会被考察到的问题,就是理解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-libs-dev/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<String> list = new ArrayList<>();
list.add("Hello");
list.add("World");
而利用新的容器静态工厂方法,一句代码就够了,并且保证了不可变性。
List<String> simpleList = List.of("Hello","world");
更进一步,通过各种of静态工厂方法创建的实例,还应用了一些我们所谓的最佳实践,比如,它是不可变的,符合我们对线程安全的需求;它因为不需要考虑扩容,所以空间上更加紧凑等。
如果我们去看of方法的源码,你还会发现一个特别有意思的地方:我们知道Java已经支持所谓的可变参数( varargs),但是官方类库还是提供了一系列特定参数长度的方法,看起来似乎非常不优雅,为什么呢?这其实是为了最优的性能, JVM在处理变长参数的时候会有明显的额外开销,如果你需要实现性能敏感的API,也可以进行参考。