1 缘起
说到Java第一问,很多人的第一反应是三大特性,那么接下来,可能就是集合了。
Collection是Java必知必会,即使没有系统学习,在实际的开发过程中,Collection也是应用最广泛的。
当然,一般的开发过程,直接使用Collection接口的有限,大多都是使用Java工具包提供的Collection接口实现类。
对于Collection,简单理解是根接口,提供通用方法,符合面向接口编程,常用的集合实现接口类有:List、Set和Queue,这些接口类的实现才是应用的重头戏,如ArrayList、HashSet、PriorityQueue等。
表面上看,List、Set和Queue都是单值类集合,但是,具体的封装实现还是有差异的,如使用NavigableMap、HashMap、LinkedHashMap以及Object[]存储元素,
本文就从源码的角度解读集合Collection的实现,系统学习和理解集合,
帮助读者轻松应对知识交流与考核。
2 Collection
Collection是集合层次结构中的根接口,用于表示一组对象,对象称为集合的元素。
部分集合允许重复的元素存在,如List,而部分集合不允许重复的元素存在,如Set。
有些集合是有序的,如List,有些集合是无序的,如HashSet。
JDK不提供Collection接口的直接实现,提供具体的子接口实现,如List和Set。
上一张Collection相关的实现图,如下图所示。
Collection用于传递集合,并在需要保证最大通用性的地方使用。
所有通用Collection的实现类(通过Collection子接口间接实现Collection,如通过List,Set子接口的实现类ArrayList、HashSet)都应提供两个标准构造函数:void无参数构造函数,用于构建空集合;单Collection类型参数的构造函数,用于构建具有相同参数类型的集合。
单Collection类型参数的构造函数可以复制任何允许的集合,从而生成需要实现类型的等效集合。
虽然接口不能包含构造函数,但是,Java平台库所有Collection的实现都符合上面的两条约定。
Collection接口中包含“破坏性”方法,即修改集合的方法,如果集合不支持该操作会抛出UnsupportedOperationException。
一些集合的实现对其包含的元素有限制,如有些禁止空元素,有些对元素类型有限制,添加不符合条件的元素会引发检查异常,如NullPointerException或ClassCastException。查询不符合条件的元素可能引发异常或返回false。
操作不符合条件的元素时,如果操作的执行不会将元素插入集合,可能引发异常或者成功,取决于实现,此类异常在该接口中标记为可选。
每个集合决定自己的同步策略,集合的实现缺少保证的情况下,未定义的行为可能被另一个线程调用,包括直接调用、将集合传递给可能执行调用的方法以及使用现有迭代器检查集合。
集合框架接口中的许多方法是根据equals方法定义的,如contains(Object o)方法在集合至少包含一个元素e,使o==null?e==null:o.equals(e)
成立,才返回true。
Collection的所有方法如下图所示。
JDK中分别提供了Collection的抽象类,如AbstractList、AbstractSet和AbstractQueue作为通用的工具,这里不展开讲解。
为了排版,拆分目录,将Collection的实现拆分为独立的章节,
没有直接接着Collection排版,本文就是从Collection切入,如果直接从Collection续,就只有一个章节了,阅读理解不便,所以,将Collection拆分。
3 Set
Set是不包含相同元素集合,是数学意义上的集合,包含交集、补集、并集、差集等操作。
只允许至多一个空值元素(null)。
Set接口在所有构造函数中添加了Collection之外的约束,添加的约束要保证通过构造函数创建set时不能有重复的元素。
如果将可变对象作为集合元素需要格外注意,如果对象值的改变影响equals,则不指定集合的行为,特殊的情况,即集合不作为自身元素。
3.1 TreeSet
位置:java.util.TreeSet
先来看下TreeSet这个普通类,
TreeSet是基于TreeMap的NavigableSet实现,根据使用的构造函数,
元素使用自然排序或在创建时提供的比较器进行排序,元素是按顺序排列的。
TreeSet为基本操作(添加、删除以及查询是否包含)提供了时间复杂度为log(n)。
如果要正确实现Set接口,集合维护的顺序(无论是否提供显式比较器)必须与equals一致。因为Set接口时根据equals定义的,
TreeSet实例使用compareTo方法执行所有元素比较,因此从集合的角度看,此方法认为相等的两个元素时相等的。
虽然排序与equals不一致,但是集合的行为仍然是明确的,仅仅是不遵循Set接口的一般约定。
TreeSet不是同步的,如果多个线程同时访问一个TreeSet,并且至少有一个线程修改了TreeSet,则必须从外部进行同步。
通常通过再自然封装集合的某个对象上进行同步实现。如果不存在这样的对象,应使用Collections.synchronizedSortedSet方法包装,
并且最好在创建时完成,防止以外的非同步访问:
SortedSet s = Collections.synchronizedSortedSet(new TreeSet(...));
TreeSet迭代器方法返回的迭代器是快速失败的,如果在创建迭代器之后的任何时间修改集合,迭代器会抛出异常:ConcurrentModificationException,除非通过迭代器自己的remove方法。
因此,在并发修改的情况下,迭代器会快速失败,而不是在未来某个不确定的时间。
迭代器不能保证快速失败,一般而言,在非同步并发修改的情况下,迭代器不能做出任何保证。
快速失败迭代器会尽最大努力抛出ConcurrentModificationException,
迭代器排除异常仅用于检测错误,
编写程序不能依赖此异常来保证正确性。
TreeSet使用NavigableMap存储数据,
数据作为键(key),值使用Object填充,源码如下图所示。
3.2 HashSet
位置:java.util.HashSet
HashSet继承AbstractSet,实现Set接口。
不保证集合元素的顺序,并且元素的顺序不会随时间而发生改变,允许null元素存在。
HashSet的基础操作是常量时间(add,remove,contains和size),假定Hash函数正确地将元素分散到桶中。
集合迭代的时间与HashSet实例的大小(元素数量)以及HashMap的实例容量(桶数)之和成正比。
因此,如果迭代性能很重要,则不能将初始容量设置的过高(或者负载因子设置的过低)。
同样,HashSet不支持同步,如果需要多线程安全操作HashSet,可以在初始化时使用Collections.synchronizedSet,
Set s = Collections.synchronizedSet(new HashSet(...));
HashSet支持快速失败。
HashSet继承AbstractSet,实现Set接口,
使用HashMap存储数据,集合的元素作为Map的键,
使用Object填充Map的值,源码如下图所示。
3.3 LinkedHashSet
LinkedHashSet是Set接口的哈希表和链表的实现,元素顺序是可知的。
与HashSet不同的是,LinkedHashSet维护一个贯穿所有条目的双链接列表。
链接列表定义了迭代顺序,LinkedHashSet集合元素的顺序即插入时的顺序。
如果将元素重新插入集合,插入顺序不受影响。
调用s.add(e)时元素e重新插入集合s(调用s.contains(e)前返回true)。
LinkedHashSet避免HashSet的元素无序,不会增加TreeSet相关的成本。
可以生成与原始集合具有相同顺序的集合副本,无序考虑原始集合的实现。
void foo(Set s) {
Set copy = new LinkedHashSet(s);
...
}
这是非常有用的技术,如果模块在输入时获取一个集合,复制它,然后返回的顺序由复制顺序决定。
LinkedHashSet提供所有Set的操作,允许null元素,与HashSet一样,具有常量级的操作时间(add,contains和remove),
性能可能略低于HashSet,因为维护链表增加了开销,但是有一个例外:LinkedHashSet迭代需要与集合大小成正比,与容量无关。
HashSet迭代可能更昂贵,需要的时间与容量成正比。
LinkedHashSet有两个影响性能的参数:初始容量和负载因子。
与HashSet的参数相同,不过,LinkedHashSet初始容量的性能影响低于HashSet,
因为LinkedHashSet迭代时间不受容量影响。
LinkedHashSet同样不支持同步,多线程操作,可使用Collections.synchronizedSet(new LinkedHashSet(…))初始化,
Set s = Collections.synchronizedSet(new LinkedHashSet(...));
LinkedHashSet支持快速失败。
LinkedHashSet是Hash表和链表的结合,是因为继承HashSet,
HashSet的有参构造函数使用了LinkedHashMap,源码如下图所示。
4 List
有序集合(又称序列)。用户可以精确控制每个元素在列表中的插入位置,
可以通过整数索引访问元素,并在列表中搜索元素。
与Set不同,list通常允许重复的元素、null存在。
List接口在迭代器、add、remove、equals和hashCode方法的约定上添加了除Collection接口以外的约束。
List接口提供四种方法访问列表中的元素。列表索引从0开始,对于某些实现(如LinkedList),操作耗时可能与索引成正比。
调用方如果不知道实现的细节,遍历列表中的元素比索引查询更可靠。
List接口提供了一个特殊的迭代器:ListIterator,除了iterator接口正常的功能外,还允许元素插入和替换以及双向访问。
提供了一种方法来获取从指定位置开始的列表迭代器。
List接口提供了两种搜索指定对象的方法,从性能角度考量,应小心使用,在许多实现中,这些操作是昂贵的线性搜索。
List提供了两种方法有效地在列表中热议位置插入和删除多个元素。
注意:虽然列表允许包含自身作为元素,但是,equals和hashCode方法是没有明确定义的。
4.1 ArrayList
位置:java.util.ArrayList
ArrayList是可调整大小的List接口实现,包括所有list功能,允许任何元素存储到ArrayList中,包括null。
除了实现List接口外,ArrayList提供了一些方法来操作内部用于存储列表的数组大小(几乎等价于Vector,但是ArrayList是非同步的)。
ArrayList的方法:size、isEmpty、get、set、迭代器和listiterator均是常量时间。加法运算在均摊常数时间范围内,即n个元素相加时间复杂度为O(n)。其他操作在线性时间内运行。与LinkedList实现相比,常量因子较小。
每个ArrayList都有一个容量,容量即用于存储列表元素的数组大小,与列表大小一致。当元素添加到ArrayList中时,容量会自动增加。除了增加元素有固定的摊销时间成本,增长策略的细节没有明确规定。
应用程序在添加大量元素前可以使用ensureCapacity增加ArrayList实例的容量,这可能减少增量再分配的数量。
ArrayList同样是非同步的,多线程操作存在安全问题,可以在创建列表时使用Collections.synchronizedList,如下:
List list = Collections.synchronizedList(new ArrayList(...));
ArrayList同样支持快速失败。
ArrayList使用数组存储数据Object[],通过capacity配置数组容量,
源码如下图所示,ensureCapcity配置数组容量。
4.2 LinkedList
位置:java.util.LinkedList
LinkedList是List和Deque接口的双向链表实现,包括所有可选的列表操作,允许存储所有元素(包括null)。
LinkedList的所有操作与双向链表一致,索引操作从开始或末尾遍历列表,以接近指定索引的数据为准。
LinkedList是同步的,多线程操作存在安全问题,可以在创建列表时使用Collections.synchronizedList,如下:
List list = Collections.synchronizedList(new LinkedList(...));
LinkedList同样支持快速失败。
LinkedList使用双节点实现双向列表,分别记录头节点和尾节点,
源码如下图所示。
5 Queue
位置:java.util.Queue
Queue是为存储预处理的元素而设计的集合。
Queue除了Collection的基础操作外,添加了插入、提取和检查操作,这些方法均提供两种形式:操作失败抛出异常;返回null或false。
返回null或false的插入操作是专门为容量有限制的队列设计,大多数情况下,插入操作不会失败。
队列通常以FIFO(先进先出)方式对元素进行排序,例外的队列有优先级队列、LIFO队列(或堆栈),
其中,优先级队列根据提供的比较器或元素的自然排序对元素排序。
无论何种排序,队列的头都是通过remove()或者poll()移除元素。
FIFO队列新进入的元素放在队尾,其他类型的队列使用各自的规则。
Queue的实现必须指定排序属性。
offer方法正常情况插入元素,异常返回false,与Collection.add方法不同,offer方法是为失败是正常而非异常的情况而设计,如固定容量的队列插入元素,插入失败是正常的情况,而不应该抛出异常,Collection.add只能通过抛出未知异常告知插入失败。
remove()方法和poll()方法移除并返回队列头部元素。准确地讲,是移除按照排序策略确定的那个元素,不同的排序策略,移除的元素不同。
remove和poll方法不同的是:当队列为空时,remove抛出异常,poll返回null。
element和peek方法仅返回队列头部元素,不做删除操作。
Queue接口没有定义阻塞队列的方法,这些等待元素出现或空间可用的方法在java.util.concurrent.BlockingQueue中定义。
队列实现通常不允许插入null元素,尽管某些实现如LinkedList允许插入null。即使在允许的实现中,也不应将null插入到队列中,
因为poll()方法将null作为特殊值返回,表明队列时空队列,不含元素。
队列实现通常不定义基于元素版本equals和hashCode的方法,而是从Object继承,
因为对于相同元素不同排序策略的队列,元素相同是模糊的,因为按照不同的排序策略,可以认定不同的元素是相同的,如多个属性的对象,存在相同的属性值,和不同的属性值,按照相同的属性值排序,认定对象是相同的。
User(name=xiaohua,sex=femle)
User(name=xiaohua,sex=male)
这两个对象,只按照属性name判断,认定对象相同,同时按照name和sex属性判断,是不同对象,
这也是equals和hashCode的作用,指定属性集合判断对象是否相同。
5.1 PriorityQueue
位置:java.util.PriorityQueue
PriorityQueue是基于优先级堆的无界优先级队列。优先级队列的元素根据它们的自然顺序进行排序,或者由队列构建时提供的比较器进行排序,具体取决于使用的构造函数。优先级队列不允许null元素。优先级队列依赖自然排序,因此不允许插入无法排序的对象,否则抛出ClassCastException异常。
优先级队列头部的元素是排序最小的,如果多个元素都绑定在最小序号上,头部元素是这些元素中的一个–绑定会自动断开。
队列的poll、remove、peek和元素访问都是访问头部元素。
优先级队列是无界的,但是有容量属性来控制存储到队列元素的数量。当元素添加到队列中时,容量会自动增长。
优先级队列实现了Collection和Iterator接口的所有可选方法,Iterator提供的迭代器不能保证按照特定顺序遍历优先级队列,如果需要有序遍历,可考虑使用Arrays.sort(pa.toArray())。
PriorityQueue是非同步的,如果多线程使用,推荐使用:java.tuil.concurrent.PriorityBlockingQueue。
PriorityQueue使用对象数组存储数据,源码如下图所示。
5.2 BlockingQueue
位置:java.util.concurrent.BlockingQueue
阻塞队列是一个接口,实现该接口的类有:ArrayBlockingQueue、LinkedBlockingQueue和PriorityBlockingQueue等。
阻塞队列支持检锁元素时等待,直到队列中有元素进入;
支持存储元素时等待队列有可用空间时再插入元素。
阻塞队列的方法返回有四种形式:
(1)抛出异常;
(2)返回特殊值:null或false;
(3)阻塞,阻塞当前线程,直到操作成功;
(4)超时,在给定时间范围内执行,超时后放弃。
序号 | 操作 | 抛出异常的方法 | 返回特殊值的方法 | 阻塞当前线程的方法 | 超时 |
---|---|---|---|---|---|
1 | 插入(Insert) | add(e) | offer(e) | put(e) | offer(e, time, unit) |
2 | 移除(Remove) | remove() | poll() | take() | poll(time, unit) |
3 | 检查(Examine) | element() | peek() | / | / |
BlockingQueue不接受null元素,使用add/put/offer插入null会抛出NullPointerException异常。null用于标记操作异常。
阻塞队列有容量限制,操作阻塞队列时,阻塞队列有剩余容量,超过容量后,插入元素需要等待,不设定容量约束的情况下,阻塞队列的容量为Integer.MAX_VALUE。
阻塞队列主要用于生产-消费队列,但是同样支持Collection。如可以使用remove(x)从队列中删除元素,但是,这类操作执行效率通常不高,使用频次比较低,如取消排队消息。
阻塞队列时线程安全的,所有队列方法都使用内部锁或其他方法实现并发控制。
但是addAll/containsAll/retailAll/removeAll不一定是原子实现,除非有特定实现。
如,在c中只添加一些元素后,addAll©可能会失败,抛出异常。
阻塞队列本质上不支持任何类型的关闭(close或shutdown),用于表明队列不可再添加元素。这也就为不同的实现提供了自定义功能,使用者在队列中添加特殊的字符用于表明队列的情况,如添加流结束符或其他对象。
内存一致性影响:与其他并发集合一样,一个线程将数据插入阻塞队列发生在其他线程读取或移除该数据之前。
5.3 LinkedBlockingQueue
位置:java.util.concurrent.LinkedBlockingQueue
链表阻塞队列是基于链表的可选有界队列,按照FIFO(先进先出)对元素排序。
队列头部元素是在队列中停留时间最长的元素,队尾的元素是队列中停留时间最短的元素。
新插入队列的元素放在队尾,队列检索获取队头元素,链表队列通常数组队列具有更高的吞吐量,但是大多数并发应用中,链表阻塞队列的性能是不可预估的。
可选容量边界的构造函数可以防止过度扩展队列,如果未制定队列容量,默认容量上限为Integer.MAX_VALUE。
链表节点每次插入时会动态创建,除非元素数量超过队列容量上限。
链表阻塞队列使用节点存储数据,源码如下图所示。
5.4 ArrayBlockingQueue
位置:java.util.concurrent.ArrayBlockingQueue
数组阻塞队列是由数组支持的有界队列,元素采用先进先出方式排序,头部元素是队列中停留最长时间的元素,尾部元素是队列中停留时间最短的元素。新元素插入队列放在队尾,队列检索获取队列头部元素。
数组阻塞队列是典型的有界缓冲,生产者和消费者在固定尺寸的队列中插入和提取元素。
数组阻塞队列一旦构建,容量就随之固定,不可改变。
向满队列中添加元素会导致操作阻塞,同样,从空队列中取元素会被阻塞。
数组阻塞队列为排序等待的生产者和消费者线程提供公平策略,默认情况下,不保证顺序。
当配置公平策略为true时,会保证线程的先进先出。
公平策略通常会降低吞吐量,但是可以避免队列“饥饿”。
数组阻塞队列使用对象数据存储元素,源码如下图所示。
数组阻塞队列通过可重入锁保证多线程的公平,
源码如下图所示。
5.5 PriorityBlockingQueue
位置:java.util.concurrent.PriorityBlockingQueue
优先级阻塞队列是一种无界队列,与PriorityQueue使用一样的排序规则,并提供阻塞检索功能。
虽然优先级阻塞队列在逻辑上是无界的,但是由于资源耗尽(如内存溢出OutOfMemory Error)导致添加元素失败。
不允许null元素,不允许插入不可比较的对象,否则抛出异常ClassCastException。
优先级阻塞队列实现了Collection和Iterator接口的所有可选功能。
迭代器提供的iterator不能保证以特定顺序遍历元素,如果需要有序遍历,可以考虑Array.sort(pq.toArray())。
drainTo方法可以用于移除一些或所有元素,并将它们放到另一个集合中。
优先级阻塞队列不能保证具有同等优先级的元素排序,如果需要强制排序,可以自定义类或比较器,
这些类或比较器使用辅助键打破主优先级值之间的关系。
优先级阻塞队列使用对象数组存储元素,同时有默认尺寸和最大数组尺寸,
理论上是无界,但是要依据硬件条件而定,源码如下图所示。
6 小结
序号 | 数据类型 | 存储介质 | 是否允许null | 是否有界 | 是否有序 |
---|---|---|---|---|---|
1 | TreeSet | NavigableMap | 允许 | 无界 | 有序 |
2 | HashSet | HashMap | 允许 | 无界 | 无序 |
3 | LinkedHashSet | LinkedHashMap | 允许 | 无界 | 有序 |
4 | ArrayList | Object[] | 允许 | 无界 | 有序 |
5 | LinkedList | Object[] | 允许 | 无界 | 有序 |
6 | PriorityQueue | Object[] | 不允许 | 无界 | 无序 |
7 | PriorityBlockingQueue | Object[] | 不允许 | 无界 | 无序 |
8 | LinkedBlockingQueue | Object[] | 不允许 | 可选 | 有序 |
9 | ArrayBlockingQueue | Object[] | 不允许 | 有界 | 有序 |
Collection相关实现类的特性总结如下图所示。