Java-集合框架
概述
- 集合接口:6个接口(短虚线表示),表示不同集合类型,是集合框架的基础。
- 抽象类:5个抽象类(长虚线表示),对集合接口的部分实现。可扩展为自定义集合类。
- 实现类:8个实现类(实线表示),对接口的具体实现。
- Collection 接口是一组允许重复的对象。
- Set 接口继承 Collection,集合元素不重复。
- List 接口继承 Collection,允许重复,维护元素插入顺序。
- Map接口是键-值对象,与Collection接口没有什么关系。
集合框架的核心是对各种数据结构和算法的封装,我希望在此只列出容器的线程安全等性质,以后有时间再深入源码。
如何对集合框架进行分类
- 实现的接口
Java使用接口来区分不同的数据接口。 - 是否ordered
ordered容器可以利用排序算法进行排序并按顺序读写。 - 是否允许元素重复
容器的基础是一些经典的数据结构,比如Java的HashMap的底层数据结构是散列表,不允许重复元素,而ArrayList的底层——数组——就没有这个限制。 - 按是否可插入null值
是否能插入null值一般和容器的并发安全性有关,因为如果需要对key加锁的话,key就必须不能为null了。
按实现接口
- Collection接口
表示内容元素构成一组相同类型的集合,它们可能是1对1的关系或没有关系,集合主要包含添加、删除、判断、清空、大小等基本操作。
List,按照插入顺序保存元素
Set,插入元素不能重复且无序
Queue,先进先出 - Map接口
键值对的一组映射,内容元素是1对多的关系。
Map接口有一个内部接口Entry,对集合中的元素定义了一组通用的操作,维护这键值对,可以对键值对进行相应的操作,通过Map接口的entrySet可以返回集合对象的视图集,方便对集合对象进行遍历等操作。Map同样包含添加、删除、判断、清空、大小等基本操作。
按是否ordered
顺序容器指的是元素按链式组织的容器,比如数组、链表(List
按是否允许重复
除了Map之外的容器显而易见都是允许重复元素存在的,Set仅仅是将Map的Value替换为了Boolean,而Map按照散列表的原理是不允许重复的。
按是否允许插入null对象
集合框架的设计都是尽可能通用的,所以每个容器都是尽可能地容纳null对象地插入的,但是需要注意:
- 像PriorityQueue这种插入时需要比较元素之间大小的容器不能插入null;
- 并发安全容器一般不允许插入null对象,因为操作需要对元素进行加锁等操作;
- 插入null后,对容器调用查找排序等方法可能会抛出空指针异常;
Collections
搜索
binarySearch
使用二分搜索法搜索指定列表,以获得指定对象,根据二分搜索的要求,该输入列表必须事先排过序,且元素必须实现Comparable接口,它们必须是可比较的,因此基本类型、没有实现Comparable接口的自定义类型都是不能进行排序的。
排序
排序的前提是知道元素之间的大小顺序,一般比较大小顺序时会使用Comparable或Comparator接口,它们在使用方式上有点区别;在比较元素是否相等时会使用equals方法
sort
对容器进行排序,其基本操作和Arrays.sort差不多,只不过Arrays.sort是对数组排序,Collections.sort是对容器内元素进行排序。
它使用什么算法?
容器线程安全化
synchronizedList/Map/Set 相当于一个包装器,为原来不具备线程安全特性的容器添加上线程安全的特性
容器遍历
Iterator
优点:Iterator可以屏蔽不同数据集合的特点,统一遍历集合的接口,而且对于链式存储的容器来说,for循环遍历时间复杂度为O(n^2),而Iterator只需O(n)
缺点:代码量颇大,特别是Iterator设计模式有很多变体需要考虑,还有fail-fast、fail-safe的问题
1 | Iterator iterator = list.iterator(); |
fail-fast、fail-safe
是Java集合框架的两种错误机制,注意这样的条件:在使用迭代器扫描的时候,容器中的元素不应该变化。
fail-fast在下面两种情况下会抛出ConcurrentModificationException异常:
- 单线程中,使用除了ite.remove()之外的方法修改容器结构
- 多线程中,在一个线程使用迭代器遍历时,另一个线程对这个容器的结构进行了修改 fail-safe 修改前复制一次集合,在复制出的集合上进行修改,虽然不会抛出异常,但是效率也会低很多,而且在频繁修改的时候根本不知道自己在读哪个版本的数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29private class Itr implements Iterator<E> {
int cursor;
int lastRet = -1;
int expectedModCount = ArrayList.this.modCount; // 在创建迭代器时的容器大小
public boolean hasNext() {
return (this.cursor != ArrayList.this.size);
}
public E next() {
// 检查是否发生了修改
checkForComodification();
/** 省略此处代码 */
}
public void remove() {
if (this.lastRet < 0) {
throw new IllegalStateException();
}
// 检查是否发生了修改
this.checkForComodification();
// 在迭代器中remove不会抛出异常,因为同时修改了expectedModCount和modCount
checkForComodification();
/** 省略此处代码 */
}
final void checkForComodification() {
// 如果链表中剩余元素数和创建迭代器时的不同,抛出异常
if(ArrayList.this.modCount != this.expectedModCount) {
throw new ConcurrentModificationException();
}
}
}
现在已知的是一些原本的容器都是fail-fast,包括Collections包装出来的容器(用的是包装的容器的迭代器),在juc包下的容器是fail-safe的(没有仔细验证)。
for循环遍历
优点:按位置读取,比较简单
缺点:效率与容器内部实现相关,如果是链式存储的效率就特别低,这种情况下更好的选择是Iterator,因为Iterator内部维护了当前遍历的位置,所以每次遍历,读取下一个位置并不需要从集合的第一个元素开始查找,只要把指针向后移一位就行了
1 | for (int i = 0; i < list.size(); i++) { |
foreach
foreach内部也是使用了Iterator,只是编译器帮我们生成了这些代码,可以反编译class文件查看,但是要注意的是容器若没实现Iterator是不能使用foreach遍历的。
优点:屏蔽了显式声明的Iterator和计数器,代码简洁,不易出错
缺点:只能做简单的遍历,不能在遍历过程中操作(删除、替换)数据集合,并且因为每次都要做 类型转换检查 ,所以花费的时间比Iterator略长
1 | for (ElementType element : list) { |
最佳实践
Java数据集合框架中,提供了一个RandomAccess接口,该接口没有方法,只是一个标记。通常被List接口的实现使用,用来标记该List的实现是否支持Random Access。
如果一个数据集合实现了该接口,就意味着它支持Random Access,按位置读取元素的平均时间复杂度为O(1)。
有时候代码中会更改集合的实现,如果想要使用集合的代码总是保持较高的遍历效率,最好进行一次类型判断来选择使用for循环遍历还是Iterator遍历。
1 | if (list instanceof RandomAccess) { |
性能测试
在性能方面,关注的主要是不同的集合对象在相同的场景下的性能对比状况,场景设计上分为单线程场景和多线程场景。
在单线程下,按照如下场景测试:
- 测试在不同的集合大小(分别为10、100、1000、10000)下增加、查找及删除100个元素的性能变化情况;
在多线程下,按照如下场景进行测试:
- 测试在不同的集合大小(分别为10、100、1000、10000)及不同的线程数(分别为10、50、100)的情况下,增加、查找及删除100个元素的性能变化情况;
对需要测试的集合对象,都进行以上场景的性能测试,每个场景均运行10次,取平均值,以尽量保证测试能充分地体现集合对象的性能状况。
List
ArrayList
ArrayList的实现和vector差不多,它的底层是一个数组,相对LinkedList来说随机访问列表的速度较快,同时缺点在于插入删除的效率相对较低,使用capacity域来标志分配的空间大小,如果add的时候发现不够了就会重新分配一次空间。
- 线程安全:NO
- null:YES
- 重复:YES
- 时间复杂度
- size O(1) add操作会更新size
- isEmpty O(1) 只需要判断size==0?
- get O(1) 因为是数组容器
- set O(1) 同上
- add O(n) 需要移动元素
- remove O(n) 同上
Vector
实现上和ArrayList区别不大,但是由于给增删改查操作添加了synchronized关键字,Vector是线程安全的。
- 线程安全:YES
LinkedList
- 当实现Queue接口的时候,添加了element()/peek()/poll()/offer()/remove()方法
- getFirst()/element()和peek()
- 获取列表第一个元素
- 列表为空时,element()抛出NoSuchElementException异常,peek()返回null
- removeFirst()/remove()和poll()
- 删除列表第一个元素
- 列表为空时remove()抛出异常,poll()返回null
LinkedList的底层是一个双向链表,还可以当作栈、队列等,但是实现栈或队列更好的还是ArrayDeque
- 线程安全:NO
- null:YES
- 重复:YES
- 时间复杂度
- size O(1)
- isEmpty O(1)
- get O(n)
- set O(n)
- add O(n) 这里仅讨论任意位置插入的情况
- remove O(n)
Map
Hashtable
Hashtable原本是过时的一个容器,在Java4中被重写了,实现了Map接口,所以归到集合框架中来了。
这个容器的核心实现和HashMap几乎是一样的,除了两点:它实现了同步、不可以插入null值。
Hashtable也是fail-fast,它并不是使用Iterator实现迭代,而是Enumerator,但是本质上是差不多的,即发现迭代过程中数据被修改即抛出异常。
- 同步:YES
- null:NO
- 重复:NO
HashMap
HashMap是使用分离链接法实现的哈希容器,使用一个大数组存储键值对,靠hash函数计算key来确定键值对的存储位置。
1 | /** |
inital capacity(初始容量)初始table大小,当initialCapacity > MAXIMUM_CAPACITY,取最大值MAXIMUM_CAPACITY = 1 << 30 = 1073741824
load factor(负载系数)每个哈希槽(或者说桶)的平均存储元素个数,当存储的元素个数超过capacity * load_factor时将触发容器执行rehash
负载因子能表示一个散列表的空间使用程度,initialCapacity * loadFactory = HashMap的容量,负载因子越大则散列表的装填程度越高,也就是能容纳更多的元素,元素多了,链表大了,所以此时索引效率就会降低;反之,负载因子越小则链表中的数据量就越稀疏,此时会对空间造成浪费,但是索引效率相对的就会高一些。
设置inital_capacity和load_factor需要考虑当前场景下是读频繁还是写频繁,若读频繁,不妨把inital_capacity设置得小一些,迭代遍历时能快一些,若写频繁,不妨设置得大一些,以免经常需要rehash。
hashCode哈希表数据结构需要hash函数来计算出一个key应该被插入到哪个位置,在Java中这个函数是每个类重写的hashCode方法
equalsHash容器不允许重复,当发生冲突时需要使用equals方法来判断key是否重复
红黑树优化,每个哈希槽长度增长到一定程度(8)后会自动转换为红黑树,以提升查询效率。
- 同步:NO
- null:YES
- 重复:NO
- 时间复杂度
- get O(n)/Θ(a)/Θ(a/2 + 1) 分别表示最坏、查找失败的平均情况、查找成功的平均情况,其中a代表负载因子,即每个链表中的平均元素个数
- 其他操作与get类似
- printAll O(l * a) 其中l表示哈希表的长度
- resize() 即rehash扩容,initialCapacity和loadFactory会影响到HashMap的扩容,HashMap在每次put操作时都会检查一边size(当前对象数量) > initialCapacity * loadFactor是否成立,如果不成立则HashMap扩容为以前的两倍(数组扩成两倍),然后重新计算每个元素在数组中的位置,然后再进行存储。可见这个过程是比较耗时的,所以最好根据业务预估出HashMap的适当容量,尽量避免频繁的resize()。
HashMap中的扩容是直接扩容,ConcurrentHashMap中的扩容是渐进式的。
Hashtable 与 HashMap 的简单比较
HashTable 基于 Dictionary 类,而 HashMap 是基于 AbstractMap。
Dictionary 是任何可将键映射到相应值的类的抽象父类,而 AbstractMap 是基于 Map 接口的实现,它以最大限度地减少实现此接口所需的工作。
HashMap 的 key 和 value 都允许为 null,而 Hashtable 的 key 和 value 都不允许为 null。HashMap 遇到 key 为 null 的时候,调用 putForNullKey 方法进行处理,而对 value 没有处理;Hashtable遇到 null,直接返回 NullPointerException。
Hashtable 方法是同步,而 HashMap 则不是。我们可以看一下源码,Hashtable 中的几乎所有的 public 的方法都是 synchronized 的,而有些方法也是在内部通过 synchronized 代码块来实现。
所以有人一般都建议如果是涉及到多线程同步时采用 HashTable,没有涉及就采用 HashMap,但是在 Collections 类中存在一个静态方法:synchronizedMap(),该方法创建了一个线程安全的 Map 对象,并把它作为一个封装的对象来返回。
hashmap 并发下有什么缺点?
HashMap 是非线程安全的,多个线程 put 的时候造成了某个 key 值 Entry key List 的死循环,问题就这么产生了。
Hashcode 是一个 native 方法,是用非 java 语言开发。
哈希表的两大特点:直接定址(locate),解决冲突(散列) 为什么散列时要对 16 取余(h%length),因为这样等价于 a 和(lenth-1)的按位与运算,也就是 h%length<==>h&(length-1),位运算效率高于取余运算效率。
Hashmap 在 1.8 增加了红黑树,主要用于解决链表深度太大,降低查询效率,所以当链表长达大于 8 时会产生红黑树
hashmap 如何扩容? 当 HashMap 中的元素个数超过数组大小(数组总大小 length,不是数组中个数 size)* loadFactor 时,就会进行数组扩容,loadFactor 的默认值为 0.75,这是一个折中的取值。
也就是说,默认情况下,数组大小为 16,那么当 HashMap 中元素个数超过 16 * 0.75 = 12(这个值就是代码中的 threshold 值,也叫做临界值)的时候,就把数组的大小扩展为 2 * 16 = 32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知 HashMap 中元素的个数,那么预设元素的个数能够有效的提高 HashMap 的性能。
HashSet
HashSet和TreeSet的思路一样,它仅仅对HashMap的接口加了一层包装。
Properties
Properties是Hashtable的子类,主要用于加载配置文件(load()
),也可以运行时动态修改配置文件(store()
)。
LinkedHashMap
LinkedHashMap是HashMap的子类,它将所有Entry按插入顺序连接起来,主要目的是为了使迭代顺序跟插入顺序相同,保证迭代的时间复杂度只和元素个数相关。
因此实现上和HashMap的差别不大,唯一需要注意的是新增的链表头尾引用header和tail。
WeakHashMap
WeakHashMap使用哈希表来存储弱引用,弱引用的概念在垃圾收集器中出现过,主要用于引用一些有用但不必要的对象,在垃圾回收时会被回收掉,这个特性特别适合于缓存场景。
WeakHashSet
WeakHashSet是不存在的,但是可以通过Collections.newSetFromMap来包装出一个WeakHashSet
Queue
Stack
Java中的Stack类不推荐使用,因为它是一个先进先出的容器,使用链表实现会更好,而设计时却继承了Vector,主要有两点问题:
- Vector底层是由数组实现的,用add和remove操作来实现push和pop虽然很方便,但是数组是静态的,在容量超出elementData.length后需要重新分配,对效率产生影响
- 栈本身应该只允许访问栈顶元素,但实现Vector后可以访问任意位置元素,和栈的理念违背
鉴于此,在使用到栈的地方,最好使用别的比如ArrayDeque来代替
ArrayQueue
ArrayDeque底层是使用循环数组实现的
- 线程安全:NO
- null:NO
- 重复:YES
- 时间复杂度
- 都是O(1)
- 扩容操作没有实现
PriorityQueue
PriorityQueue是使用完全二叉树实现的一个小项堆(小顶堆/最小堆…)
完全二叉树 可以理解为一层一层、从左往右向树上堆节点,可以使用数组实现,区别于完美二叉树
- 线程安全:NO
- null:NO 无法比较null和其他元素大小
- 重复:YES
- 时间复杂度
- peek O(1) 取堆顶元而不删除,不需要修改堆结构
- offer/poll O(log(n)) 需要对堆进行一次上滤/下滤操作调整堆结构
- remove O(n) remove可以删除任意一个元素,删除前需要遍历一遍定位目标位置
参考
- HashMap和ArrayList有什么区别?
而ArrayList是通过equals来比较的,HashMap是通过hashCode来比较两个对象是否相等的。
ArrayList允许重复,而HashMap不允许重复。