Java集合类框架学习 1 —— 概述与基础
转载自https://blog.****.net/u011392897/article/details/54983068
Java集合类框架(Java Collection Framework)是用于实现和使用集合类的统一框架,让实现细节和方法使用尽量独立。 主要是为了降低变成复杂度,同时提升集合类的性能。 让一些不相干的API能够互相操作,减少了设计和学习新API的工作量,并促进软件重用。 这个框架包含了数十个接口以及它们的实现类,不同的实现类能够提供更多的选择性。
集合类框架的整体结构图,图中只有 interface,没有画出 class。
一、基础型接口
这部分主要是参考api docs,加上一些个人的理解。
1、Collection
可以理解为只包含一组对象且不显式保存映射关系的抽象容器。jdk里面没有直接的实现类,通常是用它来当做某些方法的参数来增强便利性和普遍性,比如Collections.min/max/synchronizedCollection,以及工具包中常用的CollectionUtils.isEmpty等等。
其他工具包中会有提供这个接口的直接实现类,比如guava的Multiset,apache commons collections的Bag。multiset/bag可以看成是一种可以成功地重复添加元素的set,这里的成功指的是add操作能改变自身,set添加重复元素不会改变自身。具体有什么用,一个常见的使用multiset的场景就是计数器,比如我要统计接口访问次数,可以用HashMultiiSet<String>。这样子和直接使用HashMap<String, Integer>很像,的确,HashMultiiSet底层也是这样实现的,不过这样使用更直接,能够更好的表达程序逻辑。
2、List
相对与Collection,增加一个核心特性,那就是每个元素都有唯一的数字下标,List的有序性是唯一数字下标带来的。
常见的有数组实现的支持随机访问的ArrayList,和双向链表实现的不支持随机访问的LinkedList。
3、Set
Set的核心特性是不能存在两个”相等“的元素,这里的”相等“怎么判断是依赖于具体实现的。在严格遵守(符合常理的实现)java api docs中写的==、equals和hashCode以及几个别的接口比如Comparable等等的规范下,”相等“可以理解为”非空用equals,空用null“。无序性不是Set的硬性要求,你喜欢也可以用List实现个Set,这样可以也可以有顺序,LinkedHashSet也能算是有顺序的Set。
4、Queue
这个接口java api docs里面说得很详细,我就搬一下。
提供了两套用于普通队列操作的方法。普通队列就是只能在尾部添加元素、在头部删除/获取元素的队列。add这一条主要是保留Collection接口的风格习惯,Collection接口都是通过抛出异常来表明不能添加删除。提供offer这套主要是因为Queue接口的实现类有个很重要的场景,那就是生产者消费者模型中任务队列,这时候应该有队列状态检查机制,返回特殊值的一套方法就利用特殊值来通知操作者队列处于特殊状态(队列空、队列满),无法完成操作。当然这种队列状态检查也可以使用异常来间接完成,不用异常个人觉得有两点:1、因为队列处于特殊状态而不能添加成功应该是一种正常状态,不需要用异常;2、异常开销大。
|
抛出异常 |
返回特殊值 |
插入 | add(e) | offer(e) |
删除头元素,并返回 | remove() | poll() |
获取头元素 | element() | peek() |
返回特殊值:对于删除和获取元素这两个操作,一般是返回null作为特殊值表示删除失败;对于插入操作,一般是返回false表示删除失败。
特别注意下add(e)这个方法,此方法是继承自Collection接口的,它是有返回值的。实际使用中根据规范来:如果是不继承Queue接口,比如Set,其add操作在add失败时可以返回false(Set中已经有equals一样的元素),也可以抛出异常(比如此Set实现不支持null元素);如果有继承Queue这一支的几个接口,那么建议add方法实现成功返回true,失败时直接抛出异常,不处理return。
5、Deque
提供双端队列功能,双端队列是头部尾部都能进行添加、删除、获取操作的队列。双端队列还可以用于实现Stack这种结构,因此在Deque接口中额外提供了Stack有关的几个方法,建议是用Deque的实现类代替遗留的Stack类。
Deque接口特殊性质跟Queue接口一样,注意几套方法的区别就行。
头部操作 |
头部操作 |
尾部操作 |
尾部操作 |
|
|
抛出异常 |
返回特殊值 |
抛出异常 |
返回特殊值 |
插入 | addFirst(e) | offerFirst(e) | addLast(e) | offerLast(e) |
删除头元素,并返回 | removeFirst() | pollFirst() | removeLast() | pollLast() |
获取头元素 | getFirst() | peekFirst() | getLast() | peekLast() |
双端队列Deque的功能包含了队列Queue的所有功能,但额外又引进了不同的方法,因此下面的方法在各种实现中强烈建议被实现为等价的,即:
add(e) <---> addLast(e)
offer(e) <---> offerLast(e)
remove() <---> removeFirst()
poll() <---> pollFirst()
element() <---> getFirst()
peek() <---> peekFirst()
对于栈操作,也是可以通过双端队列操作实现的,因此也建议下面的的方法实现为等价的,即:
push(e) <---> addFirst(e)
pop() <---> removeFirst()
peek() <---> peekFirst()
此外还添加了几个别的方法:
boolean removeFirstOccurrence(Object o):从头部开始移除第一个等于o的元素e,这里的等于指的是o==null ? e==null : o.equals(e)。如果遍历完了还没有找到等于的元素,则不改变这个Deque;如果成功移除,则返回true。
boolean removeLastOccurrence(Object o):基本同上,方向为从尾部开始。
Iterator<E> descendingIterator():返回一个和iterator方法方向相反的迭代器。通常iterator方法是从头到尾迭代,descendingIterator是从尾到头迭代。
6、Map
单映射、键值对,要求key不能存在”相等“的,一个key只能对应一个value,”相等“依赖于具体实现。Map可以说是逻辑上最最广义的容器,如果不知道用什么具体容器,那么用就用Map。
二、几种强化功能的子接口
1、SortedSet/SortedMap:根据Comparable或者Comparator,来将元素整体排序(Map用key排序)的容器,提供获取”最大“元素(first) ,”最小“元素(last) ,”更大“”更小“”介于中间“的部分(headXXX/tailXXX/subXXX)的方法。
2、NavigableSet/NavigableMap:继承SortedXXX,提供更多与”大小比较产生的顺序“有关的方法,出了这个接口后就很少直接实现SortedXXX了,直接实现这个更好。
3、BlockingQueue/BlockingDeque:提供阻塞和有超时返回的阻塞这两种操作,基本都是用来强化Queue/Deque,多提供两套操作。
4、TransferQueue:1.7开始才有的接口。它是一种特殊的阻塞队列,提供一种特殊的对象传递方法transfer,常规的阻塞添加是阻塞到元素被添加到队列,transfer方法则更进一步强化阻塞功能。如果当前存在一个正在等待元素的消费者线程,则立刻将元素交给这个消费者;如果没有正在等待元素的消费者线程,生成者线程会将元素放到队列尾部,并一直阻塞到这个元素被某一个消费者线程取走。
总共提供5个新的方法:
transfer(E e):如果当前存在一个正在等待元素的消费者线程,则立刻将元素交给这个消费者;如果没有正在等待元素的消费者线程,生成者线程会将元素放到队列尾部,并一直阻塞到这个元素被某一个消费者线程取走。
tryTransfer(E e):尝试立即将元素传递给正在等待的消费者,不是阻塞方法。如果传递成功,此方法立即返回true;如果没有正在等待的消费者,此方法立即返回false,并且不会把元素放在队列中。
tryTransfer(E e, long timeout , TimeUnit unit):尝试在指定时间内将元素传递给正在等待的消费者。如果在指定时间内传递成功,返回true;如果在超时,则返回false,并且会将已经放到队列中的这个元素移除。
hasWaitingConsumer():判断当前时刻是否有消费者线程正在等待获取元素,有则返回true,无则false。
getWaitingConsumerCount():返回当前时刻正在等待获取元素的消费者线程的估计值,不是准确值,建议这个方法用于监视和响应,而不是精确的同步控制。
三、与集合类有关的其他基础知识
1、Iterable
此接口是1.5新增的,目的就是为了for-each循环(增强型for循环)。除了数组外,其他类想要使用for-each循环必须实现这个接口。
这个接口在1.8之前只有一个方法:
Iterator<T> iterator();
此方法返回一个迭代器。
在jdk1.5之前,Collection接口中就有同样的方法,后面只是为了for-each循环才单独抽取出这个方法成为一个单独的接口,因此所有Collection的实现类都是可以不作更改直接使用for-each循环的。像ArrayList这种基于数组实现的可以利用传统for循环实现迭代器,提升迭代器以及for-each循环的效率,但是还是比直接使用传统for循环要慢些。
在1.8中新增了两个方法:
default void forEach(Consumer<? super T> action):默认实现为一个隐式包含迭代的方法,不用你显式写出迭代过程,只用关心Consumer操作。
default Spliterator<T> spliterator():返回一个分割器,用于并行迭代。
2、Iterator
所有迭代器的*接口。此类有三个基本方法:
boolean hasNext():是否还有没有被迭代的元素。
E next():返回下一个要迭代的元素,大多数实现中此方法会将迭代器当前指向位置往后移动一个元素。
void remove():此方法只能在一次next方法被调用后至多一次,删除之前最近的next方法返回的元素。jdk8后remove是一个default方法,默认实现中是抛出异常,变成了真正可选实现的方法。
default void forEachRemaining(Consumer<? super E> action):jdk1.8新增的方法,默认实现就是隐式迭代。
3、RandomAccess
此接口没有方法要实现,它就是一个标记,用于标记List接口的实现类,表明它能在O(1)的时间内进行通过下标获取对应的元素的操作,从而能支持一些特别的算法/操作来加速代码执行。
对于ArrayList,它实现了此接口,在通常情况下,
for (int i=0, n=list.size(); i < n; i++)
list.get(i);
的运行速度比下面的循环要快:
for (Iterator i=list.iterator(); i.hasNext();)
i.next();
所以在ArrayList中有很多直接写普通for循环的操作。java.util.Collections这个工具类中对List进行操作时大都会用 instanceof RandomAccess 区别是否实现RandomAccess接口,从而使用不同的实现。
集合类的for-each循环(增强型for循环)底层也是用迭代器实现的,所以ArrayList的for (int i=0, n=list.size(); i < n; i++)通常是比for(E e : list)要快。
关于上面三个,可以看下我之前写的这篇博文,简单分析for-each循环(非Stream中的foreach方法)的实现原理。
4、集合类与线程安全
线程安全的定义有很多版本,本人找一个认可性权威性比较高的定义:
当多个线程访问某个类是,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
出自《Java并发编程实战》(Java Concurrency in Practice)中文译本第13页。
在另外一本国内Java中知名度很高的书《深入理解Java虚拟机》中,对给出了线程共享数据的5种分类:
不可变:类似java.lang.String这种,一旦创建,这个类的对外可见的数据就不会改变了,这种肯定是数据安全的。
绝对线程安全:满足上面线程安全定义的类,常见的又符合规范的工具类、无状态的类,这种多数是没有可共享改变的类变量的类,实际上不共享可以被修改的数据,所以线程安全。
相对线程安全:类的每一个单独的操作都满足上面定义的线程安全,组合操作可能会出现问题,不满足上面定义,仍然需要进行额外同步保证线程安全,这种就是常说的”线程安全“。常见的就是下面这些集合类 Vector/Hashtable/Collections.syncXXX,它们的方法实际上都是同步的;ConcurrentHashMap提供的简单的写操作是没问题的,但是putAll/clear实际上相当于是组合操作,严格来说它是为了并发而设计的集合类,线程安全是并发中很重要的一点,但是性能也是非常重要的一点,ConcurrentHashMap为了性能,牺牲了一些线程安全性。
线程兼容:普通的类,自身没有进行线程安全控制,需要外控制。
线程对立:就是不能多线程运行的类,这种基本上设计上就不会设计出来,所以基本上没有。
集合类中几个自带的”线程安全“类都不是绝对线程安全的,有时候(最常见的就是各种方法的组合操作)还是会出问题的,自己根据实际业务分析把握下。
5、modCount与ConcurrentModificationException
modCount用于记录这个集合类的修改次数,它是单调递增,这样可以避免直接使用size/count造成的ABA问题。当集合类被修改时,这个数字会变化,通常是自增1。这里的修改指的是改变结构的修改,像ArrayList的set,HashMap的put相同的key,这些都不算是集合类本身结构的修改,不会改变modCount(ConcurrentHashMap的replace,相当于set操作或者是put相同的key,从1.7开始会改变Segment.modCount,但是它的迭代器是弱一致性、不会抛出并发修改异常的,Segment.modCount主要用于在size、containsValue方法中快速检测到Segment的改变)。这样看来modCount相当于是乐观锁中的版本号/时间戳一类的,当前的值和预期的值不一样时,说明集合类被修改过了。
大多数实现类中,主要是在迭代器中使用modCount检测修改,迭代器提供的remove/add等方法都是不应该修改modCount的,所以碰到modCount被修改了说明是别的线程修改了集合类(一些不好的集合类实现、写得不好的代码中,自己这个线程也是能在迭代中修改modCount的)。当检测到modCount不一样时,如果直接抛出ConcurrentModificationException异常,这个迭代器就是fail-fast快速失败的,这种迭代器能够尽量保证集合类迭代的一致性。有些实现类,比如ConcurrentHashMap,它的迭代器的实现中没有使用modCount,迭代器迭代时,如果map被修改,有可能会反映到迭代过程中,碰见这种情况,它是不会抛出ConcurrentModificationException异常的。这会导致它的迭代过程有可能和预期的不一致,它这种迭代器称为weakly consistent弱一致性迭代器。
非同步、并发设计的集合类,它们的迭代器的快速失败行为是不能被严格保证的,并发修改时它会尽量但不100%保证抛出ConcurrentModificationException。因此,依赖于此异常的代码的正确性是没有保障的,迭代器的快速失败行为应该仅用于检测bug。
实际中这个modCount机制是个很弱的检测机制,主要用在一些不太紧要的地方,提供不太严格的检测功能,或者用于检测错误。另外,在很多实现中modCount并不是volatile变量,也不是严格保证在真正的修改操作之前执行。一个集合类保证了这两点,能强化这个机制,但是你应该知道,多线程操作时应该优先使用那些为并发设计的集合,而不是依靠这个简单的机制来保障你业务代码。需要强一致性的迭代时,可以自己加锁,或者用CopyOnWirteXXX这种更偏向于并发迭代的集合。
6、equals、hashCode和==
这个搬下java api docs的,算是比较重要的基础。
equals的通常规定(实际上就是标准规定)是:
对于非空对象,它的equals应该具有以下性质:
自反性:对于任何非空引用值 x,x.equals(x) 都应返回 true;
对称性:对于任何非空引用值 x 和 y,当且仅当 y.equals(x) 返回 true 时,x.equals(y) 才应返回 true;
传递性:对于任何非空引用值 x、y 和 z,如果 x.equals(y) 返回 true,并且 y.equals(z) 返回 true,那么 x.equals(z) 应返回 true;
一致性:对于任何非空引用值 x 和 y,多次调用 x.equals(y) 始终返回 true 或始终返回 false,前提是对象上 equals 比较中所用的信息没有被修改。
null:对于任何非空引用值 x,x.equals(null) 都应返回 false。
Object 类的 equals 方法实现的是对象上差别可能性最大的相等关系;即,对于任何非空引用值 x 和 y,当且仅当 x 和 y 引用同一个对象时,此方法才返回 true(x == y 具有值 true)。
注意:当此方法被重写时,通常有必要重写 hashCode 方法,以维护 hashCode 方法的通常规定中的“equals相等的对象必须具有相等的hashCode”。
hashCode的通常规定是:
在 Java 应用程序执行期间,对同一对象多次调用 hashCode 方法时,必须一致地返回相同的整数,前提是将对象进行 equals 比较时所用的信息没有被修改。从某一应用程序的一次执行到同一应用程序的另一次执行,该整数无需保持一致;
如果根据 equals(Object) 方法,两个对象是相等的,那么对这两个对象中的每个对象调用 hashCode 方法都必须生成相同的整数结果;
如果根据 equals(java.lang.Object) 方法,两个对象不相等,那么对这两个对象中的任一对象上调用 hashCode 方法不要求一定生成不同的整数结果。但是,程序员应该意识到,为不相等(unequal)的对象生成不同整数结果可以提高哈希表的性能。
实际上,由 Object 类定义的 hashCode 方法确实会针对不同(!=)的对象返回不同的整数(这一般是通过将该对象的内部地址转换成一个整数来实现的)。
遵守上面的这两个规定时,hashCode并不是一个准确的判别对象是否相等的方法,equals在判别是否相等时的优先级是绝对高于hashCode的,所以绝大多数的地方都只用equals方法判别是否相等。但是在HashMap/HashSet中,hash值(一般由hashCode通过运算生成,可以理解为hashCode)的优先级是高于equals的(判别相等时先判别hash值,hash值相等时,再判断equals/==)。遵守这两个方法的通常规定对于HashXXX这几个集合类的使用是很重要的,这样能避免出现很多奇怪的逻辑错误。在遵守规定时,HashXXX中判别相等的实际语义就等价于equals/==,这样看起来就正常多了。
7、Comparable和Comparator
SortedXXX/NavigableXXX以及很多Arrays.sort方法都依赖这个。自然排序指的是类实现了Comparable,具体的可以看下java api docs,这里简单说下。
a > b <---> a.compareTo(b) > 0
a = b <---> a.compareTo(b) = 0
a < b <---> a.compareTo(b) < 0
这个是通常规定,也可以直接叫标准规定。
另外还有一条,叫作与 equals 的一致性,这一条性质指的是:
a.equals(b) = true <---> a.compareTo(b) = 0
这一条平时不用遵守,比如基于人的年龄比较,相等的年龄的人在很多逻辑中不一定是”相等“(equals = true)的。但是在TreeMap以及TreeSet中,这一条很重要,因为它的实现是基于红黑色(二叉搜索树的一种)实现的,compareTo方法的结果在这里就是唯一的判别标识。如果两个对象a, b,有a.compareTo(b) = 0但是a.equals(b) = false,那么在TreeMap中用a和b作为key时,它们实际上同一个key,它们是TreeXXX中是逻辑上”相等“的,可以互相替换。但是在外面的大多数逻辑中它们又不是”相等“(equals = true) 的,这就造成了逻辑不一致。所以使用TreeMap/TreeSet等基于排序树的实现类,注意下你的compareTo/compare方法。
Comparator接口是在一个类没有实现Comparable接口时,提供一个外部比较方法,方便程序实现以及程序变更,其他的和Comparable一样。
6、7两点,如果你对这些接口/方法规定觉得麻烦了,用String/Integer,或者直接使用的它们的equals/hashCode/compareTo去实现对应方法的类,作为你的Map的key,是个很好的办法,它们是能够完全遵守这些规定的。
8、数据结构和算法
这个不说了,自己看书。
四、其他知识
除了jdk中自带的集合类的使用及实现源码,你还需要学会使用java.util.Collections和java.util.Arrays这两个类,同时过一下里面的代码,对与集合类的理解与学习很有帮助。
其他工具包中,常用的与集合类有关的,日常使用推荐apache commons collections以及guava commom collect。它们是java集合类框架很好的扩展,同时封装了很多工具方法与新功能的集合类,功能强大,简单学习下,过下其中的部分代码也是很有必要的。