Java集合框架
集合接口与实现分离
与现代的数据结构类库的常见做法一样,Java集合类库也将接口与实现分离。
public interface List<E> extends Collection<E> {
int size();
boolean isEmpty();
boolean contains(Object o);
Iterator<E> iterator();
Object[] toArray();
boolean add(E e);
boolean remove(Object o);
ListIterator<E> listIterator();
...
}
使用接口类型存放集合引用,只有在构造集合对象时,才会使用具体的类。一旦已经构造了集合,就不需要知道究竟使用了哪种实现。利用这种方法,一旦改变了想法,就可以很轻松地使用另一种不同的实现。只需对程序的一个地方做出修改,即调用构造器的地方。
List<String> list = new ArrayList<>();
List<String> list = new LinkedList<>();
Collection接口
在Java类库中,集合类的基本接口是Collection接口。这个接口有两个基本方法:
public interface Collection<E> {
boolean add(E element);
Iterator<E> iterator();
}
add方法用于向集合中添加元素。
iterator方法用于返回一个实现了Iterator接口的对象。
##迭代器
Iterator接口包含4个方法:
public interface Iterator<E> {
E next();
boolean hasNext();
void remove();
default void forEachRemaining(Consumer<? super E> action);
}
通过反复调用next方法,可以逐个访问集合中的每个元素。但是,如果到达了集合的末尾,next方法将抛出一个NoSuchElementException。因此,需要在调用next之前调用hasNext方法。如果迭代器对象还有多个可以访问的元素,这个方法就返回true。
Collection<String> c = ...;
Iterator<String> iter = c.iterator();
while (iter.hasNext()) {
String element = iter.next();
...
}
用“for each”循环可以更加简练地表示同样的循环操作,编译器简单地将“for each”循环转换为带有迭代器的循环。“for each”循环可以处理任何实现了Iterable接口的对象,这个接口只包含一个抽象方法:
public interface Iterable<E> {
Iterator<E> iterator();
...
}
Collection接口扩展了Iterable接口。因此,对于标准类库中的任何集合都可以使用“for each”循环。
Java中迭代器的查找操作与位置变更紧密耦合。查找一个元素的唯一方法就是调用next,而在执行查找操作时,迭代器的位置就会随之向前移动。因此,可以认为Java迭代器位于两个元素之间。当调用next时,迭代器就越过下一个元素,并返回刚刚越过的那个元素的引用。
Iterator接口的remove方法将会删除上次调用next方法时返回的元素。更重要的是,next方法和remove方法调用之间存在依赖性。如果调用remove之前没有调用next,将是不合法的。如果这样做,将会抛出一个IllegalStateException异常。
集合框架中的接口
具体集合
集合类型 | 描述 |
---|---|
ArrayList | 可以动态增长和缩减的一个索引序列 |
LinkedList | 可以在任何位置高效插入和删除的一个有序序列 |
ArrayDeque | 实现为循环数组的一个双端队列 |
HashSet | 没有重复元素的一个无序集合 |
TreeSet | 一个有序集 |
EnumSet | 一个包含枚举类型值的集 |
LinkedHashSet | 一个可以记住元素插入次序的集 |
PriorityQueue | 允许高效删除最小元素的一个集合 |
HashMap | 存储键/值关联的一个数据结构 |
TreeMap | 键有序的一个映射 |
EnumMap | 键属于枚举类型的一个映射 |
LinkedHashMap | 可以记住键/值项添加次序的一个映射 |
WeakHashMap | 值不会在别处使用时就可以被垃圾回收的一个映射 |
IdentityHashMap | 用==而不是equals比较键的一个映射 |
链表
在Java中,所有链表实际上都是双向链接的——即每个链接还存放着其前驱的引用。
LinkedList类可以使用ListIterator类从前后两个方向遍历链表中的元素,以及删除和添加元素。
链表不支持快速随机访问。如果要查看链表中的第n个元素,就必须从头开始,越过n-1个元素。鉴于这个原因,当需要按整数索引访问元素时,通常不选用链表。
get方法做了一个微小的优化:如果索引大于等于size()/2,就从列表尾端开始搜索元素。
建议避免使用以整数索引表示链表中位置的所有方法。如果需要对集合进行随机访问,就使用数组或ArrayList,而不要使用链表。
数组列表
ArrayList封装了一个动态再分配的对象数组。
对于一个经验丰富的Java程序员来说,在需要动态数组时,可能会使用Vector类。原因是:Vector类的所有方法都是同步的。可以安全地从两个线程访问一个Vector对象。但是,如果只从一个线程访问Vector,代码就会在同步操作上白白浪费大量的时间。而与之不同,ArrayList方法不是同步的,因此,建议在不需要同步时使用ArrayList,而不使用Vector。
散列集
链表和数组允许你根据意愿指定元素的次序。但是,如果想要查看某个指定的元素,却又不记得它的位置,就需要访问所有元素,直到找到为止。如果集合中包含的元素很多,这将会需要很长时间。如果不在意元素的顺序,有几种能够快速查找元素的数据结构。其缺点是无法控制元素出现的次序。这些数据结构按照对自己最方便的方式组织元素。
散列表 是一种用于快速查找对象的数据结构。散列表为每个对象计算一个整数,称为散列码(hash code)。散列码是由对象的实例字段得出的一个整数。更准确地说,有不同数据的对象将产生不同的散列码。
如果你定义你自己的类,你就要负责实现自己的hashCode方法。你的实现应该与equals方法兼容,即如果a.equals(b)为true,a与b必须有相同的散列码。
在Java中,散列表用链表数组实现。每个列表被称为桶(bucket)。当桶已经被填充时,这种现象称为散列冲突,这时需要将新对象与桶中的所有对象进行比较,查看这个对象是否已经存在,如果散列码合理地随机分布,桶的数目也足够大,需要比较的次数就会很少。
在Java 8中,桶满时会从链表变为平衡二叉树。如果选择的散列函数不好,会产生很多的冲突,或者如果有恶意代码试图在散列表中填充多个有相同散列码的值,这样改为平衡二叉树能提高性能。
如果想更多地控制散列表的性能,可以指定一个初始桶数。桶数是指用于收集有相同散列值的桶的数目。如果要插入到散列表中的元素太多,就会增加冲突数量,降低检索性能。
如果大致知道最终会有多少个元素要插入到散列表中,就可以设置桶数。通常,将桶数设置为预计元素个数的75%~150%。标准类库使用的桶数是2的幂,默认值为16。
当然,并不总是能够知道需要存储多少个元素,也有可能最初的估计过低。如果散列表太满,就需要再散列(rehashed)。如果要对散列表再散列,就需要创建一个桶数更多的表,并将所有元素插入到这个新表中,然后丢弃原来的表。装填因子(load factor)可以确定何时对散列表进行再散列。对大多数应用程序来说,装填因子为0.75是合理的。
Java中集合类库提供了一个HashSet类,它实现了基于散列表的集。
散列表迭代器将依次访问所有的桶。由于散列将元素分散在表中,所以会以一种看起来随机的顺序访问元素。
树集
TreeSet类与散列集十分类似,不过,它比散列集有所改进。树集是一个有序集合。可以以任意顺序将元素插入到集合中。在对集合进行遍历时,值将自动地按照排序后的顺序呈现。
正如TreeSet类名所示,排序是用一个树数据结构完成的(当前实现使用的是红黑树)。每一次将一个元素添加到树中时,都会将其放置在正确的排序位置上。因此,迭代器总是以有序的顺序访问每个元素。
将一个元素添加到树中要比添加到散列表中慢。但是,与检查数组或链表中的重复元素相比,使用树会快很多。如果树中包含n个元素,查找新元素的正确位置平均需要 l o g 2 n log_2n log2n 次比较。
要使用树集,必须能够比较元素。这些元素必须实现Comparable接口,或者构造集时必须提供一个Comparator。
队列与双端队列
队列允许你高效地在尾部添加元素,并在头部删除元素。双端队列(即deque)允许在头部和尾部都高效地添加或删除元素。不支持在队列中间添加元素。Java 6中引入了Deque接口,ArrayDeque和LinkedList类实现了这个接口。这两个类都可以提供双端队列,其大小可以根据需要扩展。
优先队列
优先队列(priority queue)中的元素可以按照任意的顺序插入,但会按照有序的顺序进行检索。也就是说,无论何时调用remove方法,总会获得当前优先队列中最小的元素。不过,优先对了并没有对所有元素进行排序。如果迭代处理这些元素,并不需要对它们进行排序。优先队列使用了一个精巧且高效的数据结构,称为堆 (heap)。堆是一个可以自组织的二叉树,其添加和删除操作可以让最小的元素移动到根,而不必花费时间对元素进行排序。
与TreeSet一样,优先队列既可以保存实现了Comparable接口的类对象,也可以保存构造器中提供的Comparator对象。
优先队列的典型用法是任务调度。每一个任务有一个优先级,任务随机顺序添加到队列中。每当启动一个新的任务时,都将优先级最高的任务从队列中删除。
映射
集是一个集合,允许你快速地查找现有的元素。但是,要查找一个元素,需要有所要查找的那个元素的准确副本。这不是一种常见的查找方式。通常,我们知道某些关键信息,希望查找与之关联的元素。映射数据结构就是为此设计的。映射用来存放键/值对。如果提供了键,就能查找到值。
基本映射操作
Java类库为映射提供了两个通用的实现:HashMap和TreeMap。这两个类都实现了Map接口。
散列映射对键进行散列,树映射根据键的顺序将元素组织为一个搜索树。散列或比较函数只应用于键。与键关联的值不进行散列或比较。
与集一样,散列稍微快一些,如果不需要按照有序的顺序访问键,最好选择散列映射。
键必须是唯一的。不能对同一个键存放两个值。如果对同一个键调用两次put方法,第二个值就会取代第一个值。
要迭代处理映射的键和值,最容易的方法是使用forEach方法。可以提供一个接收键和值的lambda表达式。映射中的每一项会依序调用这个表达式。
scores.forEach((k,v)->System.out.println("key="+k+",value="+v));
映射视图
集合框架不认为映射本身是一个集合(其他数据结构框架认为映射是一个键/值对集合,或者是按键索引的值集合)。不过,可以得到映射的视图(view)——这是实现了Collection接口或某个子接口的对象。
有3种视图:键集、值集合以及键/值对集。键和键/值对可以构成一个集,因为映射中一个键只能有一个副本。下面的方法会分别返回这3个视图:
Set<K> keySet()
Collection<V> values()
Set<Map.Entry<K,V>> entrySet()
需要说明的是,keySet不是HashSet或TreeSet,而是实现了Set接口的另外某个类的对象。Set接口扩展了Collection接口。因此,可以像使用任何集合一样使用keySet。
例如,可以枚举一个映射的所有键:
Set<String> keys = map.keySet();
for (String key:keys) {
...
}
如果同时查看键和值,可以通过枚举映射条目来避免查找值。
for (Map.Entry<String,Employee> entry:staff.entrySet()) {
String k = entry.getKey();
Employee v = entry.getValue()
...
}
现在可以使用:
map.forEach((k,v) -> {
...
});
如果在键集视图上调用迭代器的remove方法,实际上会从映射中删除这个键和与它关联的值。不过,不能向键集视图中添加元素。
弱散列映射
设计WeakHashMap类是为了解决一个有趣的问题。如果有一个值,它对应的键已经不再在程序中的任何地方使用,将会出现什么情况呢?假定对某个键的最后一个引用已经消失,那么不再有任何途径可以引用这个值的对象了。但是,由于程序中的任何部分不会再有这个键,所以,无法从映射中删除这个键/值对。为什么垃圾回收器不能删除它呢?删除无用对象不就是垃圾回收器的工作吗?
垃圾回收器会跟踪活动的对象。只要映射对象是活动的,其中的所有桶也是活动的,它们不能被回收。因此需要由程序负责从长期存活的映射表中删除那些无用的值。或者,你可以使用WeakHashMap。当对键的唯一引用来自散列表映射条目时,这个数据结构将与垃圾回收器协同工作一起删除键/值对。
WeakHashMap使用弱引用(weak references)保存键。WeakReference对象将包含另一个对象的引用,在这里,就是一个散列表键。对于这种类型的对象,垃圾回收器采用一种特有的方式进行处理。正常情况下,如果垃圾回收器发现某个特定的对象已经没有他人引用了,就将其回收。然而,如果某个对象只能由WeakReference引用,垃圾回收器也会将其回收,但会将引用这个对象的弱引用放入一个队列。WeakHashMap将周期性地检查队列,以便找出新添加的弱引用。一个弱引用进入队列意味着这个键不再被他人使用,并且已经回收。于是,WeakHashMap将删除相关联的映射条目。
链接散列集与映射
LinkedHashSet和LinkedHashMap类会记住插入元素项的顺序。这样就可以避免散列表中的项看起来顺序是随机的。在表中插入元素项时,就会并入到双向链表中。
或者链接散列映射可以使用访问顺序而不是插入顺序来迭代处理映射条目。每次调用get或put时,受到影响的项将从当前的位置删除,并放到项链表的尾部(只影响项在链表中的位置,而散列表的桶不会受到影响。映射条目总是在键散列码对应的桶中)。要构造这样一个散列映射,需要调用
LinkedHashMap<K,V>(initialCapacity, loadFactor, true)
访问顺序对于实现缓存的“最近最少使用”原则十分重要。例如,你可能希望将访问频率高的元素放在内存中,而访问频率低的元素从数据库中读取。当在表中找不到元素项而且表已经相当满时,可以得到表的一个迭代器,并删除它枚举的前几个元素。这些项是近期最少使用的几个元素。
甚至可以让这一过程自动化。构造LinkedHashMap的一个子类,然后覆盖下面这个方法:
protected boolean removeEldestEntry(Map.Entry<K,V> eldest)
每当你的方法返回true时,添加一个新映射条目就会导致删除eldest项。例如,下面的缓存最多可以存放100个元素:
var cache = new LinkedHashMap<K,V>(128,0.75F,true) {
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return size() > 100;
}
};
枚举集与映射
EnumSet是一个枚举类型元素集的高效实现。由于枚举类型只有有限个实例,所以EnumSet内部用位序列实现。如果对应的值在集中,则相应的位被置为1。
EnumSet类没有公共的构造器。要使用静态工厂方法构造这个集:
public static void main(String[] args) {
EnumSet<Weekday> always = EnumSet.allOf(Weekday.class);
EnumSet<Weekday> never = EnumSet.noneOf(Weekday.class);
EnumSet<Weekday> workday = EnumSet.range(Weekday.MON,Weekday.FRI);
System.out.println(always);
System.out.println(never);
System.out.println(workday);
}
enum Weekday {MON,TUE,WED,THU,FRI,SAT,SUN};
输出:
[MON, TUE, WED, THU, FRI, SAT, SUN]
[]
[MON, TUE, WED, THU, FRI]
可以使用Set接口的常用方法来修改EnumSet。
EnumMap是一个键类型为枚举类型的映射。它可以直接且高效地实现为一个值数组。需要在构造器中指定键类型:
var personInCharge = new EnumMap<Weekday,Employee>(Weekday.class);
标识散列映射
类IdentityHashMap有特殊的用途。在这个类中,键的散列值不是用hashCode函数计算的,而是用System.identityHashCode方法计算的。这是Object.hashCode根据对象的内存地址计算散列码时所使用的方法。而且,在对两个对象进行比较时,IdentityHashMap类使用==,而不使用equals。
也就是说,不同的键对象即使内容相同,也被视为不同的对象。在实现对象遍历算法时,这个类非常有用,可以用来跟踪哪些对象已经遍历过。
遗留的集合
Hashtable类
经典的Hashtable类与HashMap类的作用一样,实际上,接口也基本相同。与Vector类的方法一样,Hashtable方法也是同步的。如果对与遗留代码的兼容性没有任何要求,就应该使用HashMap。如果需要高并发访问,则要使用ConcurrentHashMap。
枚举
遗留的集合使用Enumeration接口遍历元素序列。Enumeration接口有两个方法,即hasMoreElements和nextElement。这两个方法完全类似于Iterator接口的hasNext方法和next方法。
如果发现遗留的类实现了这个接口,可以使用Collections.list将元素收集到一个ArrayList中。
属性映射
属性映射(property map)是一个特殊类型的映射结构。它有下面3个特性:
- 键与值都是字符串。
- 这个映射可以很容易地保存到文件以及从文件加载。
- 有一个二级表存放默认值。
实现属性映射的Java平台类名为Properties。属性映射对于指定程序的配置选项很有用。
var settings = new Properties();
settings.setProperty("width","600.0");
settings.setProperty("filename","/home/cay/books/cj11/code/v1ch11/raven.html");
可以使用store方法将属性映射列表保存在一个文件中。在这里,我们将属性映射保存在文件program.properties中。第二个参数是包含在这个文件中的注释:
var out = new FileOutputStream("program.properties");
settings.store(out,"Program Properties");
要从文件加载属性,可以使用以下调用:
var in = new FileInputStream("program.properties");
settings.load(in);
System.getProperties方法会生成Properties对象描述系统信息。可以用getProperties方法读取这个信息,它将键作为一个字符串返回:
String useDir = System.getProperty("user.home");
栈
从1.0版开始,标准类库中就包含了Stack类,其中有大家熟悉的push方法和pop方法。但是,Stack类扩展了Vector类,从理论角度来看,Vector类并不太令人满意,你甚至可以使用并非栈操作的insert和remove方法在任何地方插入和删除值,而不只是在栈顶。
位集
Java平台的BitSet类用于存储一个位序列(它不是数学上的集,如果称为位向量或位数组可能更合适)。如果需要高效地存储位序列,就可以使用位集。由于位集包装在字节里,所以使用位集要比使用Boolean对象的ArrayList高效得多。
BitSet类提供了一个便于读取、设置或重置各个位的接口。使用这个接口可以避免掩码和其他调整位的操作,如果将位存储在int或long变量中就必须做这些繁琐的操作。