13. Java 集合
集合框架体系概述
1. 为什么出现集合类?
方便多个对象的操作,就对对象进行存储,集合就是存储对象最常用的一种方法
2. 数组和集合类有何不可?
- 数组虽然也可存储对象,但长度固定; 而集合长度可变
- 集合只用于存储对象,集合长度是可变的, 集合可以存储不同类型的对象
Java 集合定义了两种基本的数据结构,一种是 Collection
,表示一组对象的集合;另一种是 Map
,表示对象间的一系列映射或关联关系。Java 集合的基本架构如下图。
在这种架构中,Set
是一种 Collection
,不过其中没有重复的对象;List
也是一种 Collection
,其中的元素按顺序排列(允许有重复)。
SortedSet
和 SortedMap
是特殊的集和映射,其中的元素按顺序排列。
Collection
、Set
、List
、Map
、SortedSet
和 SortedMap
都是接口,不过 java.util 包定义了多个具体实现,例如基于数组和链表的列表,基于哈希表或二叉树的映射和集。除此之外,还有两个重要的接口, Iterator 和 Iterable ,用于遍历集合中的对象,稍后会介绍。
Collection 共性方法
注意: 集合存储的都是对象的引用(地址)
1 | boolean add(E e)添加指定的元素(可选操作) |
1 | // 创建几个集合,供后续使用 |
上述各个方法都能用于 Set、List 或 Queue。这几个子接口可能会对集合中的元素做些限制或有顺序上的约束,但都提供了相同的基本方法。
修改集合的方法,例如 add()、remove()、clear() 和 retainAll(),是可选的 API。不过,这个规则在很久以前就定下了,那时认为如果不提供这些方法,明智的做法是抛出 UnsupportedOperationException 异常。因此,某些实现(尤其是只读方法)可能会抛出未检异常。
Collection
(集合)和Map
(映射) 及其子接口都没扩展 Cloneable 或 Serializable 接口。不过,在 Java 集合框架中,实现集合和映射的所有类都实现了这两个接口。- 有些集合对其可以包含的元素做了限制。例如,有的集合禁止使用 null 作为元素。EnumSet 要求其中的元素只能是特定的枚举类型。
- 如果尝试把禁止使用的元素添加到集合中,会抛出未检异常,例如
NullPointerException
或ClassCastException
。检查集合中是否包含禁止使用的元素,可能也会抛出这种异常,或者仅仅返回 false。
List 接口
List 是一组有序的对象集合。列表中的每个元素都有特定的位置,而且 List 接口定义了一些方法,用于查询或设定特定位置(或叫索引)的元素。从这个角度来看,List 对象和数组类似,不过列表的大小能按需变化,以适应其中元素的数量。和Set
不同,列表允许出现重复的元素。
除了基于索引的 get() 和 set() 方法之外,List 接口还定义了一些方法,用于把元素添加到特定的索引,把元素从特定的索引移除,或者返回指定值在列表中首次出现或最后出现的索引。从 Collection 接口继承的 add() 和 remove() 方法,前者把元素添加到列表末尾,后者把指定值从列表中首次出现的位置移除。继承的 addAll() 方法把指定集合中的所有元素添加到列表的末尾,或者插入指定的索引。retainAll() 和 removeAll() 方法的表现与其他 Collection 对象一样,如果需要,会保留或删除多个相同的值。
List 接口没有定义操作索引范围的方法,但是定义了一个 subList() 方法。这个方法返回一个 List 对象,表示原列表指定范围内的元素。子列表会回馈父列表,只要修改了子列表,父列表立即就能察觉到变化。下述代码演示了 subList() 方法和其他操作 List 对象的基本方法:
1 | // 创建两个列表,供后面的代码使用 |
集合中的迭代器
接下来讲一下用于查找的 Iterator 迭代器接口
Iterator it = al.iterator();
实际上是集合类在 List 和 Set 都包含的 iterator 方法,返回 Iterator 对象,具体实现方式是内部类。可以认为是继承了 AbstractList,实现了 Iterable 接口。把取出方式定义成内部类,每个容器的数据结构不同,取出的动作细节也不一样。但是都用共性的判断和取出,可以将共性方法抽取,对外提供了 Iterator 方法。
1 | // 一般写法 |
List 共性方法
(List 也被成为序列,它的对象的元素有序可重复,正因为有序,所以操作角标的方法都是该体系特有的方法)
- 增
void add(int index, E element)
在列表的指定位置插入指定元素(可选操作)。 - 删
E remove(int index)
移除列表中指定位置的元素(可选操作)。 - 改
E set(int index, E element)
用指定元素替换列表中指定位置的元素(可选操作)。 - 查
ListIterator<E> listIterator()
返回此列表元素的列表迭代器(按适当顺序)。 - 获取
E get(int index)
返回列表中指定位置的元素。
1 | //由于get(index)方法, list因此多了一种取出所有元素的方法。但还是常用迭代器 |
获取 <E> subList(int fromIndex, int toIndex)
返回列表中指定的 fromIndex(包括)和 toIndex(不包括)之间的部分视图。
ListIterator
List 有自己更强功能的的 ListIterator 是 Iterator 的子接口,是带下标的。
集合引用和迭代器引用在同时操作元素,通过集合获取到对应的迭代器后,在迭代中,进行集合引用的元素添加,迭代器并不知道,所以会出现 ConcurrentModificationException 异常情况。ListIterator 列表迭代器接口具备了对元素的增、删、改、查的动作。
原 查 next() 但是 增加了 previous()
原 删 void remove()
增加了特有了
- 增 void add(E e)
- 改 void set(E e)
- 和独有的 int previousIndex() 和 int nextIndex()
List 集合的三个常见子类对象(List有序可重复,因为体系有索引)
- ArrayList: 底层使用数组结构, 查询块,增删稍慢。线程不同步, JDK 1.2 以上
- LinkedList: 底层是链表结构, 增删块,查询稍慢, 线程不同步, JDK 1.2 以上
- Vector: 底层使用数组结构, 查询块,增删慢。线程同步。被 ArrayList 替代了。枚举就是 Vector 特有的取出方式.
ArrayList 详解:拥有角标的方法是其特有方法
可变长度数组的原理 :当元素超出数组长度,会产生一个新数组,将原数组的数据复制到新数组中,再将新的元素添加到新数组中。
- ArrayList:是按照原数组的 50% 延长构造一个初始容量为 10 的空列表。
- Vector:是按照原数组的 **100%**延长
LinkedList 详解:
特有的 add, get, remove 方法
1 | //在 1.6 后新方法 |
Vector(过时)详解
枚举是Vector特有的取出方式 hasMoreElements() 和 nextElement() 方法,发现枚举和迭代器很像.其实枚举和迭代一样的.
在 List 下的 ArrayList 和 LinkedList 的 contains 和 remove 方法都是使用了 Object 的equals方法。可以自己重写 equals 方法判断集合内两对象是否"一致"
随机访问列表中的元素
我们一般期望实现 List 接口的类能高效迭代,而且所用时间和列表的大小成正比。然而,不是所有列表都能高效地随机访问任意索引上的元素。按顺序访问的列表,例如 LinkedList 类,提供了高效的插入和删除操作,但降低了随机访问性能。提供高效随机访问的类都实现了标记接口 RandomAccess,因此,如果需要确定是否能高效处理列表,可以使用 instanceof 运算符测试是否实现了这个接口:
1 | // 随便创建一个列表,供后面的代码处理 |
在 List 对象上调用 iterator() 方法会得到一个 Iterator 对象,这个对象按照元素在列表中的顺序迭代各元素。List 实现了 Iterable 接口,因此列表可以像其他集合一样使用遍历循环迭代。
下表总结了 Java 平台中五种通用的 List 实现。Vector 和 Stack 类已经过时,别再用了。CopyOnWriteArrayList 类在 java.util.concurrent 包中,只适合在多线程环境中使用。
Set 接口
- Set 集合的方法和 Collection 一致,不用多讲, 但对这些方法做了限制, 是无重复对象组成的集合
下表列出了实现 Set 接口的类,而且总结了各个类的内部表示方式、排序特性、对成员的限制,以及 add()、remove()、contains 等基本操作和迭代的性能。这些类的详细信息,请参见各自的文档。注意,CopyOnWriteArraySet 类在 java.util.concurrent 包中,其他类则在 java.util 包中。还要注意,java.util.BitSet 类没有实现 Set 接口,这个类过时了,用于紧凑而高效地表示布尔值组成的列表,但不是 Java 集合框架的一部分。
注意:是无重复对象组成的集合,所有在使用 add 方法的时候,如果是重复元素则会拒绝被添加。
SortedSet(接口)
SortedSet 接口提供了多个有趣的方法,这些方法都考虑到了元素是有顺序的,如下述代码所示:
1 | public static void testSortedSet(String[] args) { |
必须加上
\0
字符,因为 tailSet() 等方法要使用某个元素后面的元素,对字符串来说,要在后面加上 NULL 字符(对应于 ASCII 中的 0)。
TreeSet(类)
TreeSet 类使用红黑树数据结构维护集,这个集中的元素按照 Comparable 对象的自然顺序升序迭代,或者按照 Comparator 对象指定的顺序迭代。其实,TreeSet 实现的是 Set 的子接口,SortedSet 接口。
TreeSet 排序
- 第一种方式: 需要比较的对象实现 Comparable 接口,覆盖 int compareTo() 方法,让元素自身具备比较性
- 第二种方式:构造实现 java.util.Comparator 接口,覆盖 int compare(T o1, T o2) 方法,将比较器对象作为参数传递给 TreeSet 集合的构造函数.
HashSet(类)
线程不同步
- 底层数据结构是哈希表,线程非同步.
- 通过 hasHashCode()和equals()来完成
- 如果元素的 HashCode 相同,才会判断 equals 是否为 true
- 如果元素的 HashCode 不同,不会调用 equals,直接是不等.
注意,对于判断元素是否存在,以及删除等操作,依赖的方法依次是 hashcode 和 equals 方法。在使用 HashSet,一定要覆盖int hashCode()和 boolean equals (Object obj)方法.
Map 接口
将键映射到值的对象,一对一对往里存,而且要保证键的唯一性.
映射(map)是一系列键值对,一个键对应一个值。Map 接口定义了用于定义和查询映射的 API。Map 接口属于 Java 集合框架,但没有扩展 Collection 接口,因此 Map 只是一种集合,而不是 Collection 类型。Map 是参数化类型,有两个类型变量。类型变量 K 表示映射中键的类型,类型变量 V 表示键对应的值的类型。例如,如果有个映射,其键是 String 类型,对应的值是 Integer 类型,那么这个映射可以表示为 Map<String,Integer>。
Map 接口定义了几个最有用的方法:put() 方法定义映射中的一个键值对,get() 方法查询指定键对应的值,remove() 方法把指定的键及对应的值从映射中删除。一般来说,实现 Map 接口的类都要能高效执行这三个基本方法:一般应该运行在常数时间中,而且绝不能比在对数时间中运行的性能差。
Map 的重要特性之一是,可以视作集合。虽然 Map 对象不是 Collection 类型,但映射的键可以看成 Set 对象(因此不能有重复元素。),映射的值可以看成 Collection 对象,而映射的键值对可以看成由 Map.Entry 对象组成的 Set 对象。(Map.Entry 是 Map 接口中定义的嵌套接口,表示一个键值对。)
1 | 添加 |
下述示例代码展示了如何使用 get()、put() 和 remove() 等方法操作 Map 对象,还演示了把 Map 对象视作集合后的一些常见用法:
1 | // 新建一个空映射 |
java.util.concurrent 包中的 ConcurrentHashMap 和 ConcurrentSkipListMap 两个类实现了同一个包中的 ConcurrentMap 接口。ConcurrentMap 接口扩展 Map 接口,而且定义了一些对多线程编程很重要的原子操作方法。例如,putIfAbsent() 方法,它的作用和 put() 方法类似,不过,仅当指定的键没有映射到其他值上时,才会把键值对添加到映射中。
TreeMap 类实现 SortedMap 接口。这个接口扩展 Map 接口,添加了一些方法,利用这种映射的有序特性。SortedMap 接口和 SortedSet 接口非常相似。firstKey() 和 lastKey() 方法分别返回 keySet() 所得集的第一个和最后一个键。而 headMap()、tailMap() 和 subMap() 方法都返回一个新映射,由原映射特定范围内的键值对组成。
Map 集合的共性方法注意
- 添加元素,如果出现相同的键,那么后添加的值会覆盖原有键对应的值, put方法会会返回被覆盖的值
- 可通过get方法的返回值来判断一个键是否存在,通过返回null判断.
- 获取map集合中所有的值
两个重要的获取方法: keySet()和entrySet()
- 通过keyset()获取key的Set集合,然后Iterator获取key,最终get(Object key) 获取.
- 通过entryset()获取关系,然后Iterator获取键值对,最终Map.Entry的getKey和getValue方法获取。(其实Map.Entry也是一个接口,它是Map接口中的一个内部接口)
Map和Set很像,其实Set底层就是使用了Map集合.
练习TreeMap
- key: 学生Student, value: 地址addr
- 学生属性:姓名和年龄,注意姓名和年龄相同视为同一个学生,需保证学生的唯一性
Queue 接口和 BlockingQueue 接口
队列(queue)是一组有序的元素,提取元素时按顺序从队头读取。队列一般按照插入元素的顺序实现,因此分成两类:先进先出(first-in, first-out,FIFO)队列和后进先出(last-in, first-out,LIFO)队列。
LIFO 队列也叫栈(stack),Java 提供了 Stack 类,但强烈不建议使用——应该使用实现 Deque 接口的类。
队列也可以使用其他顺序:优先队列(priority queue)根据外部 Comparator 对象或 Comparable 类型元素的自然顺序排序元素。与 Set 不同的是,Queue 的实现往往允许出现重复的元素。而与 List 不同的是,Queue 接口没有定义处理任意索引位元素的方法,只有队列的头一个元素能访问。Queue 的所有实现都要具有一个固定的容量:队列已满时,不能再添加元素。类似地,队列为空时,不能再删除元素。很多基于队列的算法都会用到满和空这两个状态,所以 Queue 接口定义的方法通过返回值表明这两个状态,而不会抛出异常。具体而言,peek() 和 poll() 方法返回 null 表示队列为空。因此,多数 Queue 接口的实现不允许用 null 作元素。
阻塞式队列(blocking queue)是一种定义了阻塞式 put() 和 take() 方法的队列。put() 方法的作用是把元素添加到队列中,如果需要,这个方法会一直等待,直到队列中有存储元素的空间为止。而 take() 方法的作用是从队头移除元素,如果需要,这个方法会一直等待,直到队列中有元素可供移除为止。阻塞式队列是很多多线程算法的重要组成部分,因此 BlockingQueue 接口(扩展 Queue 接口)在 java.util.concurrent 包中定义。
队列不像集、列表和映射那么常用,只在特定的多线程编程风格中会用到。这里,我们不举实例,而是试着厘清一些令人困惑的队列插入和移除操作。
1. 把元素添加到队列中
add()方法
这个方法在 Collection 接口中定义,只是使用常规的方式添加元素。对有界的队列来说,如果队列已满,这个方法可能会抛出异常。
offer()方法
这个方法在 Queue 接口中定义,但是由于有界的队列已满而无法添加元素时,这个方法返回 false,而不会抛出异常。
BlockingQueue 接口定义了一个超时版 offer() 方法,如果队列已满,会在指定的时间内等待空间。这个版本和基本版一样,成功插入元素时返回 true,否则返回 false。
put()方法
这个方法在 BlockingQueue 接口中定义,会阻塞操作:如果因为队列已满而无法插入元素,put() 方法会一直等待,直到其他线程从队列中移除元素,有空间插入新元素为止。
2. 把元素从队列中移除
remove()方法
Collection 接口中定义了 remove() 方法,把指定的元素从队列中移除。除此之外,Queue接口还定义了一个没有参数的 remove() 方法,移除并返回队头的元素。如果队列为空,这个方法会抛出 NoSuchElementException 异常。
poll()方法
这个方法在 Queue 接口中定义,作用和 remove() 方法类似,移除并返回队头的元素,不过,如果队列为空,这个方法会返回 null,而不抛出异常。
BlockingQueue 接口定义了一个超时版 poll() 方法,在指定的时间内等待元素添加到空队列中。
take()方法
这个方法在 BlockingQueue 接口中定义,用于删除并返回队头的元素。如果队列为空,这个方法会等待,直到其他线程把元素添加到队列中为止。
drainTo()方法
这个方法在 BlockingQueue 接口中定义,作用是把队列中的所有元素都移除,然后把这些元素添加到指定的 Collection 对象中。这个方法不会阻塞操作,等待有元素添加到队列中。这个方法有个变体,接受一个参数,指定最多移除多少个元素。
3. 查询
就队列而言,“查询”的意思是访问队头的元素,但不将其从队列中移除。
element()方法
这个方法在 Queue 接口中定义,其作用是返回队头的元素,但不将其从队列中移除。如果队列为空,这个方法抛出 NoSuchElementException 异常。
peek()方法
这个方法在 Queue 接口中定义,作用和 element() 方法类似,但队列为空时,返回 null。
使用队列时,最好选定一种处理失败的方式。例如,如果想在操作成功之前一直阻塞,应该选择 put() 和 take() 方法;如果想检查方法的返回值,判断操作是否成功,应该选择 offer() 和 poll() 方法。
LinkedList 类也实现了 Queue 接口,提供的是无界 FIFO 顺序,插入和移除操作需要常数时间。LinkedList 对象可以使用 null 作元素,不过,当列表用作队列时不建议使用 null。
java.util 包中还有另外两个 Queue 接口的实现。一个是 PriorityQueue
类,这种队列根据Comparator 对象排序元素,或者根据 Comparable
类型元素的 compareTo() 方法排序元素。PriorityQueue 对象的队头始终是根据指定排序方式得到的最小值。另外一个是 ArrayDeque类,实现的是双端队列,一般在需要用到栈的情况下使用。
java.util.concurrent 包中也包含一些 BlockingQueue 接口的实现,目的是在多线程编程环境中使用。有些实现很高级,甚至无需使用同步方法。
实用方法
java.util.Collections 类定义了一些静态实用方法,用于处理集合。其中有一类方法很重要,是集合的包装方法:这些方法包装指定的集合,返回特殊的集合。包装集合的目的是把集合本身没有提供的功能绑定到集合上。包装集合能提供的功能有:线程安全性、写保护和运行时类型检查。包装集合都以原来的集合为后盾,因此,在包装集合上调用的方法其实会分派给原集合的等效方法完成。这意味着,通过包装集合修改集合后,改动也会体现在原集合身上;反之亦然。
第一种包装方法为包装的集合提供线程安全性。java.util 包中的集合实现,除了过时的 Vector 和 Hashtable 类之外,都没有 synchronized 方法,不能禁止多个线程并发访问。如果需要使用线程安全的集合,而且不介意同步带来的额外开销,可以像下面这样创建集合:
1 | List<String> list = |
第二种包装方法创建的集合对象不能修改底层集合,得到的集合是只读的,只要试图修改集合的内容,就会抛出 UnsupportedOperationException 异常。如果要把集合传入方法,但不允许修改集合,也不允许使用任何方式改变集合的内容,就可以使用这种包装集合:
1 | List<Integer> primes = new ArrayList<Integer>(); |
java.util.Collections 类还定义了用来操作集合的方法。其中最值得关注的是排序和搜索集合元素的方法:
1 | Collections.sort(list); |
Collections 类中还有些方法值得关注:
1 | // 把list2中的元素复制到list1中,覆盖list1 |
你最好全面熟悉 Collections 和 Arrays 类中的实用方法,这样遇到常见任务时就不用自己动手实现了。
特殊的集合
除了包装方法之外,java.util.Collections 类还定义了其他实用方法,一些用于创建只包含一个元素的不可变集合实例,一些用于创建空集合。singleton()、singletonList() 和 singletonMap() 方法分别返回不可变的 Set、List 和 Map 对象,而且只包含一个指定的对象或键值对。如果要把单个对象当成集合传入方法,可以使用这些方法。
Collections 类还定义了一些返回空集合的方法。如果你编写的方法要返回一个集合,遇到没有返回值的情况时,一般最好返回空集合,而不要返回 null 等特殊的值:
1 | Set<Integer> si = Collections.emptySet(); |
最后还有个 nCopies() 方法。这个方法返回一个不可变的 List 对象,包含指定数量个指定对象的副本:
List<Integer> tenzeros = Collections.nCopies(10, 0);
数组和辅助方法
由对象组成的数组和集合的作用类似,而且二者之间可以相互转换:
1 | String[] a ={ "this", "is", "a", "test" }; // 一个数组 |
除此之外,还有一些有用的辅助方法,用于处理 Java 数组。为了完整性,这里也会介绍。
java.lang.System 类定义了一个 arraycopy() 方法,作用是把一个数组中的指定元素复制到另一个数组的指定位置。这两个数组的类型必须相同,甚至可以是同一个数组:
1 | char[] text = "Now is the time".toCharArray(); |
Arrays 类还定义了一些有用的静态方法:
1 | int[] intarray = new int[] { 10, 5, 7, -3 }; // 由整数组成的数组 |
在 Java 中,数组可以视作对象,也可以按照对象的方法处理。假如有个对象 o,可以使用类似下面的代码判断这个对象是否为数组。如果是,则可以判断是什么类型的数组:
1 | public static void main(String args[]) { |
小知识
List如何在遍历时删除元素
当尝试用 foreach 或者 Iterator 遍历集合时进行删除或者插入元素的操作时,会抛出这样的异常:java.util.ConcurrentModificationException
关于这个异常的原因,看了很多文章,基本上解释如下:ArrayList 的父类 AbstarctList 中有一个域 modCount,每次对集合进行修改(增添、删除元素)时 modCount 都会 +1。
而 foreach 的实现原理就是迭代器 Iterator,在这里,迭代 ArrayList 的 Iterator 中有一个变量 expectedModCount,该变量会初始化和 modCount 相等,但当对集合进行插入,删除操作,modCount 会改变,就会造成expectedModCount != modCount,此时就会抛出 java.util.ConcurrentModificationException异常
结论:不要在 foreach 循环里进行元素的 remove/add 操作。remove 元素请使用 Iterator 方式
总结
JAVA Set 交集,差集,并集
交集
result.retainAll(set2);
差集
result.removeAll(set2);
并集
result.addAll(set2);
参考
- 免费公开课_传智播客和黑马程序员免费公开课 http://yun.itheima.com/open
- Java 技术手册: 第 6 版
- 第 15 章 对象容器——集合-图灵社区 http://www.ituring.com.cn/book/tupubarticle/17746