10.4 剖析TreeMap

在介绍HashMap时,我们提到,HashMap有一个重要局限,键值对之间没有特定的顺序,我们还提到,Map接口有另一个重要的实现类TreeMap,在TreeMap中,键值对之间按键有序,TreeMap的实现基础是排序二叉树,10.3节介绍了排序二叉树的基本概念和算法,本节我们来详细讨论TreeMap。除了Map接口,因为有序,TreeMap还实现了更多接口和方法。下面,我们先来介绍TreeMap的用法,然后介绍其内部实现。

10.4.1 基本用法

TreeMap有两个基本构造方法:

1
2
public TreeMap()
public TreeMap(Comparator<? super K> comparator)

第一个为默认构造方法,如果使用默认构造方法,要求Map中的键实现Comparabe接口,TreeMap内部进行各种比较时会调用键的Comparable接口中的compareTo方法。

第二个接受一个比较器对象comparator,如果comparator不为null,在TreeMap内部进行比较时会调用这个comparator的compare方法,而不再调用键的compareTo方法,也不再要求键实现Comparable接口。

应该用哪一个呢?第一个更为简单,但要求键实现Comparable接口,且期望的排序和键的比较结果是一致的;第二个更为灵活,不要求键实现Comparable接口,比较器可以用灵活复杂的方式进行实现。

需要强调的是,TreeMap是按键而不是按值有序,无论哪一种,都是对键而非值进行比较。

看段简单的示例代码:

1
2
3
4
5
6
7
8
Map<String, String> map   = new TreeMap<>();
map.put("a", "abstract");
map.put("c", "call");
map.put("b", "basic");
map.put("T", "tree");
for(Entry<String, String> kv : map.entrySet()){
System.out.print(kv.getKey()+"="+kv.getValue()+" ");
}

创建了一个TreeMap,但只是当作Map使用,不过迭代时,其输出却是按键排序的,输出为:

1
T=tree a=abstract b=basic c=call

T排在最前面,是因为大写字母的ASCII码都小于小写字母。如果希望忽略大小写呢?可以传递一个比较器,String类有一个静态成员CASE_INSENSITIVE_ORDER,它就是一个忽略大小写的Comparator对象,替换第一行代码为:

1
Map<String, String> map = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);

输出就会变为:

1
a=abstract b=basic c=call T=tree

正常排序是从小到大,如果希望逆序呢?可以传递一个不同的Comparator对象,第一行代码可以替换为:

1
2
3
4
5
6
Map<String, String> map   = new TreeMap<>(new Comparator<String>(){
@Override
public int compare(String o1, String o2) {
return o2.compareTo(o1);
}
});

这样,输出会变为:

1
c=call b=basic a=abstract T=tree

为什么这样就可以逆序呢?正常排序中,compare方法内是o1.compareTo(o2),两个对象翻过来,自然就是逆序了,Collections类有一个静态方法reverseOrder()可以返回一个逆序比较器,也就是说,上面的代码也可以替换为:

1
Map<String, String> map = new TreeMap<>(Collections.reverseOrder());

如果希望逆序且忽略大小写呢?第一行可以替换为:

1
Map<String, String> map   = new TreeMap<>(Collections.reverseOrder(String.CASE_INSENSITIVE_ORDER));

需要说明的是,TreeMap使用键的比较结果对键进行排重,即使键实际上不同,但只要比较结果相同,它们就会被认为相同,键只会保存一份。比如,如下代码:

1
2
3
4
5
6
Map<String, String> map   = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
map.put("T", "tree");
map.put("t", "try");
for(Entry<String, String> kv : map.entrySet()){
System.out.print(kv.getKey()+"="+kv.getValue()+" ");
}

看上去有两个不同的键”T”和”t”,但因为比较器忽略大小写,所以只会有一个,输出会是:

1
T=try

键为第一次put时的,这里即”T”,而值为最后一次put时的,这里即”try”。

我们再来看一个例子,键为字符串形式的日期,值为一个统计数字,希望按照日期输出,代码为:

1
2
3
4
5
6
7
Map<String, Integer> map   = new TreeMap<>();
map.put("2016-7-3", 100);
map.put("2016-7-10", 120);
map.put("2016-8-1", 90);
for(Entry<String, Integer> kv : map.entrySet()){
System.out.println(kv.getKey()+", "+kv.getValue());
}

输出为:

1
2
3
2016-7-10,120
2016-7-3,100
2016-8-1,90

7月10号的排在了7月3号的前面,与期望的不符,这是因为,它们是按照字符串比较的,按字符串,2016-7-10就是小于2016-7-3,因为第一个不同之处1小于3。

怎么解决呢?可以使用一个自定义的比较器,将字符串转换为日期,按日期进行比较,第一行代码可以改为:

1
2
3
4
5
6
7
8
9
10
11
12
Map<String, Integer> map   = new TreeMap<>(new Comparator<String>() {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
@Override
public int compare(String o1, String o2) {
try {
return sdf.parse(o1).compareTo(sdf.parse(o2));
} catch (ParseException e) {
e.printStackTrace();
return 0;
}
}
});

这样,输出就符合期望了,会变为:

1
2
3
2016-7-3,100
2016-7-10,120
2016-8-1,90

以上就是TreeMap的基本用法,与HashMap相比:相同的是,它们都实现了Map接口,都可以按Map进行操作。不同的是,迭代时,TreeMap按键有序,为了实现有序,它要求要么键实现Comparable接口,要么创建TreeMap时传递一个Comparator对象。

由于TreeMap按键有序,它还支持更多接口和方法,具体来说,它还实现了Sorted-Map和NavigableMap接口,而NavigableMap接口扩展了SortedMap,通过这两个接口,可以方便地根据键的顺序进行查找,如第一个、最后一个、某一范围的键、邻近键等,限于篇幅,我们就不介绍了,具体可参见API文档。

10.4.2 实现原理

TreeMap内部是用红黑树实现的,红黑树是一种大致平衡的排序二叉树,10.3节我们介绍了排序二叉树的基本概念和算法,本节主要看TreeMap的一些代码实现(基于Java 7),先来看TreeMap的内部组成。

1.内部组成

TreeMap内部主要有如下成员:

1
2
3
private final Comparator<? super K> comparator;
private transient Entry<K, V> root = null;
private transient int size = 0;

comparator就是比较器,在构造方法中传递,如果没传,就是null。size为当前键值对个数。root指向树的根节点,从根节点可以访问到每个节点,节点的类型为Entry。Entry是TreeMap的一个内部类,其内部成员和构造方法为:

1
2
3
4
5
6
7
8
9
10
11
12
13
static final class Entry<K, V> implements Map.Entry<K, V> {
K key;
V value;
Entry<K, V> left = null;
Entry<K, V> right = null;
Entry<K, V> parent;
boolean color = BLACK;
Entry(K key, V value, Entry<K, V> parent) {
this.key = key;
this.value = value;
this.parent = parent;
}
}

每个节点除了键(key)和值(value)之外,还有三个引用,分别指向其左孩子(left)、右孩子(right)和父节点(parent),对于根节点,父节点为null,对于叶子节点,孩子节点都为null,还有一个成员color表示颜色,TreeMap是用红黑树实现的,每个节点都有一个颜色,非黑即红。

了解了TreeMap的内部组成,我们来看一些主要方法的实现代码。

2.保存键值对

put方法的代码稍微有点长,我们分段来看。先看第一段,添加第一个节点的情况:

1
2
3
4
5
6
7
8
9
10
public V put(K key, V value) {
Entry<K, V> t = root;
if(t == null) {
compare(key, key); // type (and possibly null) check
root = new Entry<>(key, value, null);
size = 1;
modCount++;
return null;
}
//…

当添加第一个节点时,root为null,执行的就是这段代码,主要就是新建一个节点,设置root指向它,size设置为1, modCount++的含义与之前章节介绍的类似,用于迭代过程中检测结构性变化。

令人费解的是compare调用,compare(key, key); , key与key比,有什么意义呢?我们看compare方法的代码:

1
2
3
4
final int compare(Object k1, Object k2) {
return comparator==null ? ((Comparable<? super K>)k1).compareTo((K)k2)
: comparator.compare((K)k1, (K)k2);
}

其实,这里的目的不是为了比较,而是为了检查key的类型和null,如果类型不匹配或为null,那么compare方法会抛出异常。

如果不是第一次添加,会执行后面的代码,添加的关键步骤是寻找父节点。寻找父节点根据是否设置了comparator分为两种情况,我们先来看已设置的情况,代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int cmp;
Entry<K, V> parent;
//split comparator and comparable paths
Comparator<? super K> cpr = comparator;
if(cpr ! = null) {
do {
parent = t;
cmp = cpr.compare(key, t.key);
if(cmp < 0)
t = t.left;
else if(cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t ! = null);
}

寻找是一个从根节点开始循环的过程,在循环中,cmp保存比较结果,t指向当前比较节点,parent为t的父节点,循环结束后parent就是要找的父节点。t一开始指向根节点,从根节点开始比较键,如果小于根节点,就将t设为左孩子,与左孩子比较,大于就与右孩子比较,就这样一直比,直到t为null或比较结果为0。如果比较结果为0,表示已经有这个键了,设置值,然后返回。如果t为null,则当退出循环时,parent就指向待插入节点的父节点。

我们再来看没有设置comparator的情况,代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
else {
if(key == null)
throw new NullPointerException();
Comparable<? super K> k = (Comparable<? super K>) key;
do {
parent = t;
cmp = k.compareTo(t.key);
if(cmp < 0)
t = t.left;
else if(cmp > 0)
t = t.right;
else
return t.setValue(value);
} while(t ! = null);
}

基本逻辑是一样的,当退出循环时parent指向父节点,只是如果没有设置comparator,则假设key一定实现了Comparable接口,使用Comparable接口的compareTo方法进行比较。

找到父节点后,就是新建一个节点,根据新的键与父节点键的比较结果,插入作为左孩子或右孩子,并增加size和modCount,代码如下:

1
2
3
4
5
6
7
8
Entry<K, V> e = new Entry<>(key, value, parent);
if(cmp < 0)
parent.left = e;
else
parent.right = e;
fixAfterInsertion(e);
size++;
modCount++;

代码大部分都容易理解,不过,里面有一行重要调用fixAfterInsertion(e); ,它就是在调整树的结构,使之符合红黑树的约束,保持大致平衡,其代码我们就不介绍了。

稍微总结一下,其基本思路就是:循环比较找到父节点,并插入作为其左孩子或右孩子,然后调整保持树的大致平衡。

3.根据键获取值

根据键获取值的代码为:

1
2
3
4
public V get(Object key) {
Entry<K, V> p = getEntry(key);
return(p==null ? null : p.value);
}

就是根据key找对应节点p,找到节点后获取值p.value,来看getEntry的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
final Entry<K, V> getEntry(Object key) {
// Offload comparator-based version for sake of performance
if(comparator ! = null)
return getEntryUsingComparator(key);
if(key == null)
throw new NullPointerException();
Comparable<? super K> k = (Comparable<? super K>) key;
Entry<K, V> p = root;
while(p ! = null) {
int cmp = k.compareTo(p.key);
if(cmp < 0)
p = p.left;
else if(cmp > 0)
p = p.right;
else
return p;
}
return null;
}

如果comparator不为空,调用单独的方法getEntryUsingComparator,否则,假定key实现了Comparable接口,使用接口的compareTo方法进行比较,找的逻辑也很简单,从根开始找,小于往左边找,大于往右边找,直到找到为止,如果没找到,返回null。getEntry-UsingComparator方法的逻辑类似,就不赘述了。

4.查看是否包含某个值

TreeMap可以高效地按键进行查找,但如果要根据值进行查找,则需要遍历,我们来看代码:

1
2
3
4
5
6
public boolean containsValue(Object value) {
for(Entry<K, V> e = getFirstEntry(); e ! = null; e = successor(e))
if(valEquals(value, e.value))
return true;
return false;
}

主体就是一个循环遍历,getFirstEntry方法返回第一个节点,successor方法返回给定节点的后继节点,valEquals就是比较值,从第一个节点开始,逐个进行比较,直到找到为止,如果循环结束也没找到则返回false。getFirstEntry的代码为:

1
2
3
4
5
6
7
final Entry<K, V> getFirstEntry() {
Entry<K, V> p = root;
if(p ! = null)
while (p.left ! = null)
p = p.left;
return p;
}

代码很简单,第一个节点就是最左边的节点。

10.3节我们介绍过找后继节点的算法,successor的具体代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static <K, V> TreeMap.Entry<K, V> successor(Entry<K, V> t) {
if(t == null)
return null;
else if(t.right ! = null) {
Entry<K, V> p = t.right;
while (p.left ! = null)
p = p.left;
return p;
} else {
Entry<K, V> p = t.parent;
Entry<K, V> ch = t;
while(p ! = null && ch == p.right) {
ch = p;
p = p.parent;
}
return p;
}
}

如10.3节后继算法所述,有两种情况:
1)如果有右孩子(t.right! =null),则后继节点为右子树中最小的节点。
2)如果没有右孩子,后继节点为某祖先节点,从当前节点往上找,如果它是父节点的右孩子,则继续找父节点,直到它不是右孩子或父节点为空,第一个非右孩子节点的父亲节点就是后继节点,如果父节点为空,则后继节点为null。

代码与算法是对应的,就不再赘述了,这个描述比较抽象,可以参考图10-7,进行对照。

5.根据键删除键值对

根据键删除键值对的代码为:

1
2
3
4
5
6
7
8
public V remove(Object key) {
Entry<K, V> p = getEntry(key);
if(p == null)
return null;
V oldValue = p.value;
deleteEntry(p);
return oldValue;
}

根据key找到节点,调用deleteEntry删除节点,然后返回原来的值。

10.3节介绍过节点删除的算法,节点有三种情况:
1)叶子节点:这个容易处理,直接修改父节点对应引用置null即可。
2)只有一个孩子:就是在父亲节点和孩子节点直接建立链接。
3)有两个孩子:先找到后继节点,找到后,替换当前节点的内容为后继节点,然后再删除后继节点,因为这个后继节点一定没有左孩子,所以就将两个孩子的情况转换为了前面两种情况。

deleteEntry的具体代码也稍微有点长,我们分段来看:

1
2
3
4
5
6
7
8
9
10
11
private void deleteEntry(Entry<K, V> p) {
modCount++;
size--;
//If strictly internal, copy successor's element to p and then make p
//point to successor.
if(p.left ! = null && p.right ! = null) {
Entry<K, V> s = successor(p);
p.key = s.key;
p.value = s.value;
p = s;
} //p has 2 children

这里处理的就是两个孩子的情况,s为后继,当前节点p的key和value设置为了s的key和value,然后将待删节点p指向了s,这样就转换为了一个孩子或叶子节点的情况。

再往下看一个孩子情况的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//Start fixup at replacement node, if it exists.
Entry<K, V> replacement = (p.left ! = null ? p.left : p.right);
if(replacement ! = null) {
//Link replacement to parent
replacement.parent = p.parent;
if(p.parent == null)
root = replacement;
else if(p == p.parent.left)
p.parent.left = replacement;
else
p.parent.right = replacement;
// Null out links so they are OK to use by fixAfterDeletion.
p.left = p.right = p.parent = null;
// Fix replacement
if(p.color == BLACK)
fixAfterDeletion(replacement);
} else if (p.parent == null) { // return if we are the only node.

p为待删节点,replacement为要替换p的孩子节点,主体代码就是在p的父节点p.parent和replacement之间建立链接,以替换p.parent和p原来的链接,如果p.parent为null,则修改root以指向新的根。fixAfterDeletion重新平衡树。

最后来看叶子节点的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
} else if(p.parent == null) { // return if we are the only node.
root = null;
} else { // No children. Use self as phantom replacement and unlink.
if(p.color == BLACK)
fixAfterDeletion(p);
if(p.parent ! = null) {
if(p == p.parent.left)
p.parent.left = null;
else if(p == p.parent.right)
p.parent.right = null;
p.parent = null;
}
}

再具体分为两种情况:一种是删除最后一个节点,修改root为null;另一种是根据待删节点是父节点的左孩子还是右孩子,相应的设置孩子节点为null。

以上就是TreeMap的基本实现原理,与10.3节介绍的排序二叉树的基本概念和算法是一致的,只是TreeMap用了红黑树。

10.4.3 小结

本节介绍了TreeMap的用法和实现原理,与HashMap相比,TreeMap同样实现了Map接口,但内部使用红黑树实现。红黑树是统计效率比较高的大致平衡的排序二叉树,这决定了它有如下特点:
1)按键有序,TreeMap同样实现了SortedMap和NavigableMap接口,可以方便地根据键的顺序进行查找,如第一个、最后一个、某一范围的键、邻近键等。
2)为了按键有序,TreeMap要求键实现Comparable接口或通过构造方法提供一个Com-parator对象。
3)根据键保存、查找、删除的效率比较高,为O(h), h为树的高度,在树平衡的情况下,h为log2(N), N为节点数。

应该用HashMap还是TreeMap呢?不要求排序,优先考虑HashMap,要求排序,考虑TreeMap。HashMap有对应的TreeMap, HashSet也有对应的TreeSet,下节,我们来看TreeSet。

10.3 排序二叉树

HashMap和HashSet的共同实现机制是哈希表,一个共同的限制是没有顺序,我们提到,它们都有一个能保持顺序的对应类TreeMap和TreeSet,这两个类的共同实现基础是排序二叉树。为了更好地理解TreeMap和TreeSet,本节先介绍排序二叉树的一些基本概念和算法。

10.3.1 基本概念

先来说树的概念。现实中,树是从下往上长的,树会分叉,在计算机程序中,一般而言,与现实相反,树是从上往下长的,也会分叉,有个根节点,每个节点可以有一个或多个孩子节点,没有孩子节点的节点一般称为叶子节点。

二叉树是一棵树,每个节点最多有两个孩子节点,一左一右,左边的称为左孩子,右边的称为右孩子,示例如图10-5所示。

图10-5中,两棵树都是二叉树,图10-5(a)所示二叉树的根节点为5,除了叶子节点外,每个节点都有两个孩子节点;图10-5(b)所示二叉树的根节点为7,有的节点有两个孩子节点,有的只有一个。树有一个高度或深度的概念,是从根到叶子节点经过的节点个数的最大值,左边树的高度为3,右边树的高度为5。

epub_923038_74

图10-5 二叉树示例

排序二叉树也是二叉树,但它没有重复元素,而且是有序的二叉树。什么顺序呢?对每个节点而言:

  • 如果左子树不为空,则左子树上的所有节点都小于该节点;
  • 如果右子树不为空,则右子树上的所有节点都大于该节点。

图10-5中的两棵二叉树都是排序二叉树。比如左边的树,根节点为5,左边的都小于5,右边的都大于5。再看右边的树,根节点为7,左边的都小于7,右边的都大于7,在以3为根的左子树中,其右子树的值都大于3。

10.3.2 基本算法

排序二叉树有什么优点?如何在树中进行基本操作(如查找、遍历、插入和删除)呢?我们来看一下基本的算法。

1.查找

排序二叉树有一个很好的优点,在其中查找一个元素时很方便、也很高效,基本步骤为:
1)首先与根节点比较,如果相同,就找到了;
2)如果小于根节点,则到左子树中递归查找;
3)如果大于根节点,则到右子树中递归查找。

这个步骤与在数组中进行二分查找的思路是类似的,如果二叉树是比较平衡的,类似图10-5(a)所示二叉树,则每次比较都能将比较范围缩小一半,效率很高。

此外,在排序二叉树中,可以方便地查找最小值和最大值。最小值即为最左边的节点,从根节点一路查找左孩子即可;最大值即为最右边的节点,从根节点一路查找右孩子即可。

2.遍历

排序二叉树也可以方便地按序遍历。用递归的方式,用如下算法即可按序遍历:
1)访问左子树;
2)访问当前节点;
3)访问右子树。

比如,遍历访问图10-6所示的二叉树。

epub_923038_75

图10-6 排序二叉树示例

从根节点开始,但先访问根节点的左子树,一直到最左边的节点,所以第一个访问的是1,1没有右子树,返回上一层,访问3,然后访问3的右子树,4没有左子树,所以访问4,然后是4的右子树6,以此类推,访问顺序就是有序的:1、3、4、6、7、8、9。

不用递归的方式,也可以实现按序遍历,第一个节点为最左边的节点,从第一个节点开始,依次找后继节点。给定一个节点,找其后继节点的算法为:
1)如果该节点有右孩子,则后继节点为右子树中最小的节点。
2)如果该节点没有右孩子,则后继节点为父节点或某个祖先节点,从当前节点往上找,如果它是父节点的右孩子,则继续找父节点,直到它不是右孩子或父节点为空,第一个非右孩子节点的父节点就是后继节点,如果找不到这样的祖先节点,则后继为空,遍历结束。

文字描述比较抽象,我们来看个图,以图10-6为例,每个节点的后继节点如图10-7浅色箭头所示。

epub_923038_76

图10-7 排序二叉树节点后继

对每个节点,对照算法,我们再详细解释下:
1)第一个节点1没有右孩子,它不是父节点的右孩子,所以它的后继节点就是其父节点3;
2)3有右孩子,右子树中最小的就是4,所以3的后继节点为4;
3)4有右孩子,右子树中只有一个节点6,所以4的后继节点为6;
4)6没有右孩子,往上找父节点,它是父节点4的右孩子,4又是父节点3的右孩子, 3不是父节点7的右孩子,所以6的后继节点为3的父节点7;
5)7有右孩子,右子树中最小的是8,所以7的后继节点为8;
6)8没有右孩子,往上找父节点,它不是父节点9的右孩子,所以它的后继节点就是其父节点9;
7)9没有右孩子,往上找父节点,它是父节点7的右孩子,接着往上找,但7已经是根节点,父节点为空,所以后继为空。

怎么构建排序二叉树呢?可以在插入、删除元素的过程中形成和保持。

3.插入

在排序二叉树中,插入元素首先要找插入位置,即新节点的父节点,怎么找呢?与查找元素类似,从根节点开始往下找,其步骤为:
1)与当前节点比较,如果相同,表示已经存在了,不能再插入;
2)如果小于当前节点,则到左子树中寻找,如果左子树为空,则当前节点即为要找的父节点;
3)如果大于当前节点,则到右子树中寻找,如果右子树为空,则当前节点即为要找的父节点。

找到父节点后,即可插入,如果插入元素小于父节点,则作为左孩子插入,否则作为右孩子插入。我们来看个例子,依次插入7、3、4、1、9、6、8的过程,这个过程如图10-8所示。

epub_923038_77

图10-8 排序二叉树插入过程

4.删除

从排序二叉树中删除一个节点要复杂一些,有三种情况:

  • 节点为叶子节点;
  • 节点只有一个孩子节点;
  • 节点有两个孩子节点。

我们分别介绍。

如果节点为叶子节点,则很简单,可以直接删掉,修改父节点的对应孩子节点为空即可。

如果节点只有一个孩子节点,则替换待删节点为孩子节点,或者说,在孩子节点和父节点之间直接建立链接。比如,在图10-9中,左边二叉树中删除节点4,就是让4的父节点3与4的孩子节点6直接建立链接。

epub_923038_78

图10-9 排序二叉树删除节点:节点只有一个孩子节点的情况

如果节点有两个孩子节点,则首先找该节点的后继节^1,找到后继节点后,替换待删节点为后继节点的内容,然后再删除后继节点。后继节点没有左孩子,这就将两个孩子节点的情况转换为了叶子节点或只有一个孩子节点的情况。比如,在图10-10中,从左边二叉树中删除节点3,3有两个孩子节点,后继节点为4,首先替换3的内容为4,然后再删除节点4。

epub_923038_80

图10-10 排序二叉树删除节点:节点有两个孩子节点的情况

10.3.3 平衡的排序二叉树

从前面的描述中可以看出,排序二叉树的形状与插入和删除的顺序密切相关,极端情况下,排序二叉树可能退化为一个链表。比如,如果插入顺序为1、3、4、6、7、8、9,则排序二叉树如图10-11所示。

退化为链表后,排序二叉树的优点就都没有了,即使没有退化为链表,如果排序二叉树高度不平衡,效率也会变得很低。

epub_923038_81

图10-11 退化的排序二叉树

平衡具体定义是什么呢?有一种高度平衡的定义,即任何节点的左右子树的高度差最多为一。满足这个平衡定义的排序二叉树又被称为AVL树,这个名字源于它的发明者G.M. Adelson-Velsky和E.M. Landis。在他们的算法中,在插入和删除节点时,通过一次或多次旋转操作来重新平衡树。

在TreeMap的实现中,用的并不是AVL树,而是红黑树,与AVL树类似,红黑树也是一种平衡的排序二叉树,也是在插入和删除节点时通过旋转操作来平衡的,但它并不是高度平衡的,而是大致平衡的。所谓大致是指,它确保任意一条从根到叶子节点的路径,没有任何一条路径的长度会比其他路径长过两倍。红黑树减弱了对平衡的要求,但降低了保持平衡需要的开销,在实际应用中,统计性能高于AVL树。

为什么叫红黑树呢?因为它对每个节点进行着色,颜色或黑或红,并对节点的着色有一些约束,满足这个约束即可以确保树是大致平衡的。

对AVL树和红黑树,它们保持平衡的细节都是比较复杂的,我们就不介绍了,需要知道的是,它们都是排序二叉树,都通过在插入和删除时执行开销不大的旋转操作保持了树的高度平衡或大致平衡,从而保证了树的查找效率。

10.3.4 小结

本小节介绍了排序二叉树的基本概念和算法。

排序二叉树保持了元素的顺序,而且是一种综合效率很高的数据结构,基本的保存、删除、查找的效率都为O(h), h为树的高度。在树平衡的情况下,h为log2(N),N为节点数。比如,如果N为1024,则log2(N)为10。

基本的排序二叉树不能保证树的平衡,可能退化为一个链表。有很多保持树平衡的算法,AVL树能保证树的高度平衡,但红黑树是实际中使用更为广泛的,虽然红黑树只能保证大致平衡,但降低了维持树平衡需要的开销,整体统计效果更好。

与哈希表一样,树也是计算机程序中一种重要的数据结构和思维方式。为了能够快速操作数据,哈希和树是两种基本的思维方式,不需要顺序,优先考虑哈希,需要顺序,考虑树。除了容器类TreeMap/TreeSet,数据库中的索引结构也是基于树的(不过基于B树,而不是二叉树),而索引是能够在大量数据中快速访问数据的关键

理解了排序二叉树的基本概念和算法,理解TreeMap和TreeSet就比较容易了,让我们在接下来的小节中探讨这两个类。

10.2 剖析HashSet

10.1节提到了Set接口,Map接口的两个方法keySet和entrySet返回的都是Set,本节介绍Set接口的一个重要实现类HashSet。与HashMap类似,字面上看,HashSet由两个单词组成:Hash和Set。其中,Set表示接口,实现Set接口也有多种方式,各有特点,Hash-Set实现的方式利用了Hash。下面,我们先来看HashSet的用法,然后看实现原理,最后总结分析HashSet的特点。

10.2.1 用法

我们先介绍Set接口,然后介绍HashSet的使用和应用场景。

Set表示的是没有重复元素、且不保证顺序的容器接口,它扩展了Collection,但没有定义任何新的方法,不过,对于其中的一些方法,它有自己的规范。Set接口的完整定义如代码清单10-3所示。

代码清单10-3 Set接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public interface Set<E> extends Collection<E> {
int size();
boolean isEmpty();
boolean contains(Object o);
//迭代遍历时,不要求元素之间有特别的顺序
//HashSet的实现就是没有顺序,但有的Set实现可能会有特定的顺序,比如TreeSet
Iterator<E> iterator();
Object[] toArray();
<T> T[] toArray(T[] a);
//添加元素, 如果集合中已经存在相同元素了,则不会改变集合,直接返回false,
//只有不存在时,才会添加,并返回true
boolean add(E e);
boolean remove(Object o);
boolean containsAll(Collection<? > c);
//重复的元素不添加,不重复的添加,如果集合有变化,返回true,没变化返回false
boolean addAll(Collection<? extends E> c);
boolean retainAll(Collection<? > c);
boolean removeAll(Collection<? > c);
void clear();
boolean equals(Object o);
int hashCode();
}

与HashMap类似,HashSet的构造方法有:

1
2
3
4
public HashSet()
public HashSet(int initialCapacity)
public HashSet(int initialCapacity, float loadFactor)
public HashSet(Collection<? extends E> c)

initialCapacity和loadFactor的含义与HashMap中的是一样的。

HashSet的使用也很简单,比如:

1
2
3
4
5
6
7
Set<String> set = new HashSet<String>();
set.add("hello");
set.add("world");
set.addAll(Arrays.asList(new String[]{"hello", "老马"}));
for(String s : set){
System.out.print(s+" ");
}

输出为:

1
hello 老马 world

“hello”被添加了两次,但只会保存一份,输出也没有什么特别的顺序。

与HashMap类似,HashSet要求元素重写hashCode和equals方法,且对于两个对象,如果equals相同,则hashCode也必须相同,如果元素是自定义的类,需要注意这一点。比如,有一个表示规格的类Spec,有大小和颜色两个属性:

1
2
3
4
5
6
7
8
9
10
11
12
class Spec {
String size;
String color;
public Spec(String size, String color) {
this.size = size;
this.color = color;
}
@Override
public String toString() {
return "[size=" + size + ", color=" + color + "]";
}
}

Spec的Set为:

1
2
3
4
Set<Spec> set = new HashSet<Spec>();
set.add(new Spec("M", "red"));
set.add(new Spec("M", "red"));
System.out.println(set);

输出为:

1
[[size=M, color=red], [size=M, color=red]]

同一个规格输出了两次,为避免这一点,需要为Spec重写hashCode和equals方法。利用IDE开发工具往往可以自动生成这两个方法,比如Eclipse中,可以通过”Source”->”Generate hashCode() and equals() …”,我们就不赘述了。

HashSet有很多应用场景,比如:
1)排重,如果对排重后的元素没有顺序要求,则HashSet可以方便地用于排重;
2)保存特殊值,Set可以用于保存各种特殊值,程序处理用户请求或数据记录时,根据是否为特殊值判断是否进行特殊处理,比如保存IP地址的黑名单或白名单;
3)集合运算,使用Set可以方便地进行数学集合中的运算,如交集、并集等运算,这些运算有一些很现实的意义。比如,用户标签计算,每个用户都有一些标签,两个用户的标签交集就表示他们的共同特征,交集大小除以并集大小可以表示他们的相似程度。

10.2.2 实现原理

HashSet内部是用HashMap实现的,它内部有一个HashMap实例变量,如下所示:

1
private transient HashMap<E, Object> map;

我们知道,Map有键和值,HashSet相当于只有键,值都是相同的固定值,这个值的定义为:

1
private static final Object PRESENT = new Object();

理解了这个内部组成,它的实现方法也就比较容易理解了,我们来看下代码。

HashSet的构造方法,主要就是调用了对应的HashMap的构造方法,比如:

1
2
3
public HashSet(int initialCapacity, float loadFactor) {
map = new HashMap<>(initialCapacity, loadFactor);
}

接受Collection参数的构造方法稍微不一样,代码为:

1
2
3
4
public HashSet(Collection<? extends E> c) {
map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
addAll(c);
}

也很容易理解,c.size()/.75f用于计算initialCapacity,0.75f是loadFactor的默认值。

我们看add方法的代码:

1
2
3
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}

就是调用map的put方法,元素e用于键,值就是固定值PRESENT, put返回null表示原来没有对应的键,添加成功了。HashMap中一个键只会保存一份,所以重复添加HashMap不会变化。

检查是否包含元素,代码为:

1
2
3
public boolean contains(Object o) {
return map.containsKey(o);
}

就是检查map中是否包含对应的键。

删除元素的代码为:

1
2
3
public boolean remove(Object o) {
return map.remove(o)==PRESENT;
}

就是调用map的remove方法,返回值为PRESENT表示原来有对应的键且删除成功了。

迭代器的代码为:

1
2
3
public Iterator<E> iterator() {
return map.keySet().iterator();
}

就是返回map的keySet的迭代器。

10.2.3 小结

本节介绍了HashSet的用法和实现原理,它实现了Set接口,内部实现利用了HashMap,有如下特点:
1)没有重复元素;
2)可以高效地添加、删除元素、判断元素是否存在,效率都为O(1);
3)没有顺序。

HashSet可以方便高效地实现去重、集合运算等功能。如果要保持添加的顺序,可以使用HashSet的一个子类LinkedHashSet。Set还有一个重要的实现类TreeSet,它可以排序。这两个类,我们在后续小节介绍。

第10章 Map和Set

上一章介绍了ArrayList、LinkedList和ArrayDeque,它们的一个共同特点是:查找元素的效率都比较低,都需要逐个进行比较,本章介绍各种Map和Set,它们的查找效率要高得多。Map和Set都是接口,Java中有多个实现类,主要包括HashMap、HashSet、TreeMap、TreeSet、LinkedHashMap、LinedHashSet、EnumMap、EnumSet等,它们都有什么用?有什么不同?是如何实现的?本章进行深入剖析,我们先从最常用的HashMap开始。

10.1 剖析HashMap

字面上看,HashMap由Hash和Map两个单词组成,这里Map不是地图的意思,而是表示映射关系,是一个接口,实现Map接口有多种方式,HashMap实现的方式利用了哈希(Hash)。下面先来看Map接口,接着看HashMap的用法,然后看实现原理,最后总结分析HashMap的特点。

10.1.1 Map接口

Map有的概念。一个键映射到一个值,Map按照键存储和访问值,键不能重复,即一个键只会存储一份,给同一个键重复设值会覆盖原来的值。使用Map可以方便地处理需要根据键访问对象的场景,比如:

  • 一个词典应用,键可以为单词,值可以为单词信息类,包括含义、发音、例句等;
  • 统计和记录一本书中所有单词出现的次数,可以以单词为键,以出现次数为值;
  • 管理配置文件中的配置项,配置项是典型的键值对;
  • 根据身份证号查询人员信息,身份证号为键,人员信息为值。

数组、ArrayList、LinkedList可以视为一种特殊的Map,键为索引,值为对象。

Java 7中Map接口的定义如代码清单10-1所示,用注释表示方法的含义。

代码清单10-1 Map接口
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
public interface Map<K, V> { //K和V是类型参数,分别表示键(Key)和值(Value)的类型
V put(K key, V value); //保存键值对,如果原来有key,覆盖,返回原来的值
V get(Object key); //根据键获取值, 没找到,返回null
V remove(Object key); //根据键删除键值对, 返回key原来的值,如果不存在,返回null
int size(); //查看Map中键值对的个数
boolean isEmpty(); //是否为空
boolean containsKey(Object key); //查看是否包含某个键
boolean containsValue(Object value); //查看是否包含某个值
void putAll(Map<? extends K, ? extends V> m); //保存m中的所有键值对到当前Map
void clear(); //清空Map中所有键值对
Set<K> keySet(); //获取Map中键的集合
Collection<V> values(); //获取Map中所有值的集合
Set<Map.Entry<K, V>> entrySet(); //获取Map中的所有键值对
interface Entry<K, V> { //嵌套接口,表示一条键值对
K getKey(); //键值对的键
V getValue(); //键值对的值
V setValue(V value);
boolean equals(Object o);
int hashCode();
}
boolean equals(Object o);
int hashCode();
}
boolean containsValue(Object value);
Set<K> keySet();

Java 8增加了一些默认方法,如getOrDefault、forEach、replaceAll、putIfAbsent、replace、computeIfAbsent、merge等,Java 9增加了多个重载的of方法,可以方便地根据一个或多个键值对构建不变的Map,具体可参见API文档,我们就不介绍了。

Set是一个接口,表示的是数学中的集合概念,即没有重复的元素集合。Java 7中的Set定义为:

1
2
public interface Set<E> extends Collection<E> {
}

它扩展了Collection,但没有定义任何新的方法,不过,它要求所有实现者都必须确保Set的语义约束,即不能有重复元素。Java 9增加了多个重载的of方法,可以根据一个或多个元素生成不变的Set,具体可参见API文档。关于Set,10.2节我们再详细介绍。

Map中的键是没有重复的,所以ketSet()返回了一个Set。keySet()、values()、entrySet()有一个共同的特点,它们返回的都是视图,不是复制的值,基于返回值的修改会直接修改Map自身,比如:

1
map.keySet().clear();

会删除所有键值对。

10.1.2 HashMap

HashMap实现了Map接口,我们通过一个简单的例子来看如何使用。在7.6节,我们介绍过如何产生随机数,现在,我们写一个程序,来看随机产生的数是否均匀。比如,随机产生1000个0~3的数,统计每个数的次数,如代码清单10-2所示。

代码清单10-2 使用HashMap统计随机数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Random rnd = new Random();
Map<Integer, Integer> countMap = new HashMap<>();
for(int i=0; i<1000; i++){
int num = rnd.nextInt(4);
Integer count = countMap.get(num);
if(count==null){
countMap.put(num, 1);
}else{
countMap.put(num, count+1);
}
}
for(Map.Entry<Integer, Integer> kv : countMap.entrySet()){
System.out.println(kv.getKey()+", "+kv.getValue());
}

一次运行的输出为:

1
2
3
4
0,269
1,236
2,261
3,234

次数分别是269、236、261、234,代码比较简单,就不解释了。除了默认构造方法, HashMap还有如下构造方法:

1
2
3
public HashMap(int initialCapacity)
public HashMap(int initialCapacity, float loadFactor)
public HashMap(Map<? extends K, ? extends V> m)

最后一个以一个已有的Map构造,复制其中的所有键值对到当前Map。前两个涉及参数initialCapacity和loadFactor,它们是什么意思呢?我们需要看下HashMap的实现原理。

10.1.3 实现原理

我们先来看HashMap的内部组成,然后分析一些主要方法的实现,代码基于Java7。

1.内部组成

HashMap内部有如下几个主要的实例变量:

1
2
3
4
transient Entry<K, V>[] table = (Entry<K, V>[]) EMPTY_TABLE;
transient int size;
int threshold;
final float loadFactor;

size表示实际键值对的个数。table是一个Entry类型的数组,称为哈希表或哈希桶,其中的每个元素指向一个单向链表,链表中的每个节点表示一个键值对。Entry是一个内部类,它的实例变量和构造方法代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
static class Entry<K, V> implements Map.Entry<K, V> {
final K key;
V value;
Entry<K, V> next;
int hash;
Entry(int h, K k, V v, Entry<K, V> n) {
value = v;
next = n;
key = k;
hash = h;
}
}

其中,key和value分别表示键和值,next指向下一个Entry节点,hash是key的hash值,待会我们会介绍其计算方法。直接存储hash值是为了在比较的时候加快计算,待会我们看代码。

table的初始值为EMPTY_TABLE,是一个空表,具体定义为:

1
static final Entry<? , ? >[] EMPTY_TABLE = {};

当添加键值对后,table就不是空表了,它会随着键值对的添加进行扩展,扩展的策略类似于ArrayList。添加第一个元素时,默认分配的大小为16,不过,并不是size大于16时再进行扩展,下次什么时候扩展与threshold有关。

threshold表示阈值,当键值对个数size大于等于threshold时考虑进行扩展。threshold是怎么算出来的呢?一般而言,threshold等于table.length乘以loadFactor。比如,如果table. length为16, loadFactor为0.75,则threshold为12。loadFactor是负载因子,表示整体上table被占用的程度,是一个浮点数,默认为0.75,可以通过构造方法进行修改。

下面,我们通过一些主要方法的代码来介绍HashMap是如何利用这些内部数据实现Map接口的。先看默认构造方法。需要说明的是,为清晰和简单起见,我们可能会省略一些非主要代码。

2.默认构造方法

默认构造方法的代码为:

1
2
3
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}

DEFAULT_INITIAL_CAPACITY为16, DEFAULT_LOAD_FACTOR为0.75,默认构造方法调用的构造方法主要代码为:

1
2
3
4
public HashMap(int initialCapacity, float loadFactor) {
this.loadFactor = loadFactor;
threshold = initialCapacity;
}

主要就是设置loadFactor和threshold的初始值。

3.保存键值对

下面,我们来看HashMap是如何把一个键值对保存起来的,代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public V put(K key, V value) {
if(table == EMPTY_TABLE) {
inflateTable(threshold);
}
if(key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for(Entry<K, V> e = table[i]; e ! = null; e = e.next) {
Object k;
if(e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);
return null;
}

如果是第一次保存,首先调用inflateTable()方法给table分配实际的空间,inflateTable的主要代码为:

1
2
3
4
5
6
private void inflateTable(int toSize) {
//Find a power of 2 >= toSize
int capacity = roundUpToPowerOf2(toSize);
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
}

默认情况下,capacity的值为16, threshold会变为12, table会分配一个长度为16的Entry数组。接下来,检查key是否为null,如果是,调用putForNullKey单独处理,我们暂时忽略这种情况。在key不为null的情况下,下一步调用hash方法计算key的hash值。hash方法的代码为:

1
2
3
4
5
6
final int hash(Object k) {
int h = 0
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}

基于key自身的hashCode方法的返回值又进行了一些位运算,目的是为了随机和均匀性。有了hash值之后,调用indexFor方法,计算应该将这个键值对放到table的哪个位置,代码为:

1
2
3
static int indexFor(int h, int length) {
return h & (length-1);
}

HashMap中,length为2的幂次方,h&(length-1)等同于求模运算h%length。找到了保存位置i, table[i]指向一个单向链表。接下来,就是在这个链表中逐个查找是否已经有这个键了,遍历代码为:

1
for (Entry<K, V> e = table[i]; e ! = null; e = e.next)

而比较的时候,是先比较hash值,hash相同的时候,再使用equals方法进行比较,代码为:

1
if(e.hash == hash && ((k = e.key) == key || key.equals(k)))

为什么要先比较hash呢?因为hash是整数,比较的性能一般要比equals高很多,hash不同,就没有必要调用equals方法了,这样整体上可以提高比较性能。如果能找到,直接修改Entry中的value即可。modCount++的含义与ArrayList和LinkedList中介绍一样,为记录修改次数,方便在迭代中检测结构性变化。如果没找到,则调用addEntry方法在给定的位置添加一条,代码为:

1
2
3
4
5
6
7
8
void addEntry(int hash, K key, V value, int bucketIndex) {
if((size >= threshold) && (null ! = table[bucketIndex])) {
resize(2 * table.length);
hash = (null ! = key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}

如果空间是够的,不需要resize,则调用createEntry方法添加。createEntry的代码为:

1
2
3
4
5
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K, V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}

代码比较直接,新建一个Entry对象,插入单向链表的头部,并增加size。如果空间不够,即size已经要超过阈值threshold了,并且对应的table位置已经插入过对象了,具体检查代码为:

1
if((size >= threshold) && (null ! = table[bucketIndex]))

则调用resize方法对table进行扩展,扩展策略是乘2, resize的主要代码为:

1
2
3
4
5
6
7
8
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

分配一个容量为原来两倍的Entry数组,调用transfer方法将原来的键值对移植过来,然后更新内部的table变量,以及threshold的值。transfer方法的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for(Entry<K, V> e : table) {
while(null ! = e) {
Entry<K, V> next = e.next;
if(rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}

参数rehash一般为false。这段代码遍历原来的每个键值对,计算新位置,并保存到新位置,具体代码比较直接,就不解释了。

以上就是保存键值对的主要代码,简单总结一下,基本步骤为:
1)计算键的哈希值;
2)根据哈希值得到保存位置(取模);
3)插到对应位置的链表头部或更新已有值;
4)根据需要扩展table大小。

以上描述可能比较抽象,我们通过一个例子,用图示的方式进行说明,代码如下:

1
2
3
4
Map<String, Integer> countMap = new HashMap<>();
countMap.put("hello", 1);
countMap.put("world", 3);
countMap.put("position", 4);

在通过new HashMap()创建一个对象后,内存中的结构如图10-1所示。

epub_923038_70

图10-1 HashMap:初始结构

接下来执行保存键值对的代码,”hello”的hash值为96207088,模16的结果为0,所以插入table[0]指向的链表头部,内存结构变为图10-2所示。

epub_923038_71

图10-2 HashMap对象示例:保存一个键值对后

“world”的hash值为111207038,模16结果为14,所以保存完”world”后,内存结构如图10-3所示。

epub_923038_72

图10-3 HashMap对象示例:保存两个键值对后

“position”的hash值为771782464,模16结果也为0, table[0]已经有节点了,新节点会插到链表头部,内存结构变为如图10-4所示。理解了键值对在内存是如何存放的,就比较容易理解其他方法了。

epub_923038_73

图10-4 HashMap对象示例:保存三个键值对后

4.查找方法

根据键获取值的get方法的代码为:

1
2
3
4
5
6
public V get(Object key) {
if(key == null)
return getForNullKey();
Entry<K, V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}

HashMap支持key为null, key为null的时候,放在table[0],调用getForNullKey()获取值;如果key不为null,则调用getEntry()获取键值对节点entry,然后调用节点的getValue()方法获取值。getEntry方法的代码是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
final Entry<K, V> getEntry(Object key) {
if(size == 0) {
return null;
}
int hash = (key == null) ? 0 : hash(key);
for(Entry<K, V> e = table[indexFor(hash, table.length)];
e ! = null; e = e.next) {
Object k;
if(e.hash == hash &&
((k = e.key) == key || (key ! = null && key.equals(k))))
return e;
}
return null;
}

逻辑也比较简单,具体如下。

1)计算键的hash值,代码为:

1
int hash = (key == null) ? 0 : hash(key);

2)根据hash找到table中的对应链表,代码为:

1
table[indexFor(hash, table.length)];

3)在链表中遍历查找,遍历代码:

1
2
for(Entry<K, V> e = table[indexFor(hash, table.length)];
e ! = null; e = e.next)

4)逐个比较,先通过hash快速比较,hash相同再通过equals比较,代码为:

1
2
if(e.hash == hash &&
((k = e.key) == key || (key ! = null && key.equals(k))))

containsKey方法的逻辑与get是类似的,节点不为null就表示存在,具体代码为:

1
2
3
public boolean containsKey(Object key) {
return getEntry(key) ! = null;
}

HashMap可以方便高效地按照键进行操作,但如果要根据值进行操作,则需要遍历, containsValue方法的代码为:

1
2
3
4
5
6
7
8
9
10
public boolean containsValue(Object value) {
if(value == null)
return containsNullValue();
Entry[] tab = table;
for(int i = 0; i < tab.length ; i++)
for(Entry e = tab[i] ; e ! = null ; e = e.next)
if(value.equals(e.value))
return true;
return false;
}

如果要查找的值为null,则调用containsNullValue单独处理;如果要查找的值不为null,遍历的逻辑也很简单,就是从table的第一个链表开始,从上到下,从左到右逐个节点进行访问,通过equals方法比较值,直到找到为止。

5.根据键删除键值对

根据键删除键值对的代码为:

1
2
3
4
public V remove(Object key) {
Entry<K, V> e = removeEntryForKey(key);
return(e == null ? null : e.value);
}

removeEntryForKey的代码为:

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
final Entry<K, V> removeEntryForKey(Object key) {
if(size == 0) {
return null;
}
int hash = (key == null) ? 0 : hash(key);
int i = indexFor(hash, table.length);
Entry<K, V> prev = table[i];
Entry<K, V> e = prev;
while(e ! = null) {
Entry<K, V> next = e.next;
Object k;
if(e.hash == hash && ((k = e.key) == key || (key ! = null && key.equals(k)))) {
modCount++;
size--;
if(prev == e)
table[i] = next;
else
prev.next = next;
e.recordRemoval(this);
return e;
}
prev = e;
e = next;
}
return e;
}

基本逻辑分析如下。

1)计算hash,根据hash找到对应的table索引,代码为:

1
2
int hash = (key == null) ? 0 : hash(key);
int i = indexFor(hash, table.length);

2)遍历table[i],查找待删节点,使用变量prev指向前一个节点,next指向后一个节点,e指向当前节点,遍历结构代码为:

1
2
3
4
5
6
7
8
9
10
11
Entry<K, V> prev = table[i];
Entry<K, V> e = prev;
while(e ! = null) {
Entry<K, V> next = e.next;
if(找到了){
//删除
return;
}
prev = e;
e = next;
}

3)判断是否找到,依然是先比较hash值,hash值相同时再用equals方法比较。
4)删除的逻辑就是让长度减小,然后让待删节点的前后节点链起来,如果待删节点是第一个节点,则让table[i]直接指向后一个节点,代码为:

1
2
3
4
5
size--;
if(prev == e)
table[i] = next;
else
prev.next = next;

e.recordRemoval(this);在HashMap中代码为空,主要是为了HashMap的子类扩展使用。

6.实现原理小结

以上就是HashMap的基本实现原理,内部有一个哈希表,即数组table,每个元素table[i]指向一个单向链表,根据键存取值,用键算出hash值,取模得到数组中的索引位置buketIndex,然后操作table[buketIndex]指向的单向链表。

存取的时候依据键的hash值,只在对应的链表中操作,不会访问别的链表,在对应链表操作时也是先比较hash值,如果相同再用equals方法比较。这就要求,相同的对象其hashCode返回值必须相同,如果键是自定义的类,就特别需要注意这一点。这也是hash-Code和equals方法的一个关键约束。

需要说明的是,Java 8对HashMap的实现进行了优化,在哈希冲突比较严重的情况下,即大量元素映射到同一个链表的情况下(具体是至少8个元素,且总的键值对个数至少是64), Java 8会将该链表转换为一个平衡的排序二叉树,以提高查询的效率,关于排序二叉树我们在10.3节介绍,Java 8的具体代码就不介绍了。

10.1.4 小结

本节介绍了HashMap的用法和实现原理,它实现了Map接口,可以方便地按照键存取值,内部使用数组链表和哈希的方式进行实现,这决定了它有如下特点:
1)根据键保存和获取值的效率都很高,为O(1),每个单向链表往往只有一个或少数几个节点,根据hash值就可以直接快速定位;
2)HashMap中的键值对没有顺序,因为hash值是随机的。

如果经常需要根据键存取值,而且不要求顺序,那么HashMap就是理想的选择。如果要保持添加的顺序,可以使用HashMap的一个子类LinkedHashMap,我们在10.6节介绍。Map还有一个重要的实现类TreeMap,它可以排序,我们在10.4节介绍。

需要说明的是,HashMap不是线程安全的,Java中还有一个类Hashtable,它是Java最早实现的容器类之一,实现了Map接口,实现原理与HashMap类似,但没有特别的优化,它内部通过synchronized实现了线程安全。在HashMap中,键和值都可以为null,而在Hashtable中不可以。在不需要并发安全的场景中,推荐使用HashMap。在高并发的场景中,推荐使用17.2节介绍的ConcurrentHashMap。

根据哈希值存取对象、比较对象是计算机程序中一种重要的思维方式,它使得存取对象主要依赖于自身Hash值,而不是与其他对象进行比较,存取效率也与集合大小无关,高达O(1),即使进行比较,也利用Hash值提高比较性能

9.3 剖析ArrayDeque

LinkedList实现了队列接口Queue和双端队列接口Deque, Java容器类中还有一个双端队列的实现类ArrayDeque,它是基于数组实现的。我们知道,一般而言,由于需要移动元素,数组的插入和删除效率比较低,但ArrayDeque的效率却非常高,它是怎么实现的呢?本节就来详细探讨。

ArrayDeque有如下构造方法:

1
2
3
public ArrayDeque()
public ArrayDeque(int numElements)
public ArrayDeque(Collection<? extends E> c)

numElements表示元素个数,初始分配的空间会至少容纳这么多元素,但空间不是正好numElements这么大,待会我们会介绍其实现细节。

ArrayDeque实现了Deque接口,同LinkedList一样,它的队列长度也是没有限制的, Deque扩展了Queue,有队列的所有方法,还可以看作栈,有栈的基本方法push/pop/peek,还有明确的操作两端的方法如addFirst/removeLast等,具体用法与LinkedList一节介绍的类似,就不赘述了,下面看其实现原理(基于Java
7)。

9.3.1 实现原理

ArrayDeque内部主要有如下实例变量:

1
2
3
private transient E[] elements;
private transient int head;
private transient int tail;

elements就是存储元素的数组。ArrayDeque的高效来源于head和tail这两个变量,它们使得物理上简单的从头到尾的数组变为了一个逻辑上循环的数组,避免了在头尾操作时的移动。我们来解释下循环数组的概念。

1.循环数组

对于一般数组,比如arr,第一个元素为arr[0],最后一个为arr[arr.length-1]。但对于ArrayDeque中的数组,它是一个逻辑上的循环数组,所谓循环是指元素到数组尾之后可以接着从数组头开始,数组的长度、第一个和最后一个元素都与head和tail这两个变量有关,具体来说:
1)如果head和tail相同,则数组为空,长度为0。
2)如果tail大于head,则第一个元素为elements[head],最后一个为elements[tail-1],长度为tail-head,元素索引从head到tail-1。
3)如果tail小于head,且为0,则第一个元素为elements[head],最后一个为elements [elements.length-1],元素索引从head到elements.length-1
4)如果tail小于head,且大于0,则会形成循环,第一个元素为elements[head],最后一个是elements[tail-1],元素索引从head到elements.length-1,然后再从0到tail-1

我们来看一些图示。第一种情况,数组为空,head和tail相同,如图9-6所示。

epub_923038_63

图9-6 循环数组:head和tail相同

第二种情况,tail大于head,如图9-7所示,都包含三个元素。

epub_923038_64

图9-7 循环数组:tail大于head

第三种情况,tail为0,如图9-8所示。

epub_923038_65

图9-8 循环数组:tail为0

第四种情况,tail不为0,且小于head,如图9-9所示。

epub_923038_66

图9-9 循环数组:tail不为0且小于head

2.构造方法

默认构造方法的代码为:

1
2
3
public ArrayDeque() {
elements = (E[]) new Object[16];
}

分配了一个长度为16的数组。如果有参数numElements,代码为:

1
2
3
public ArrayDeque(int numElements) {
allocateElements(numElements);
}

不是简单地分配给定的长度,而是调用了allocateElements。这个方法的代码看上去比较复杂,我们就不列举了,它主要就是在计算应该分配的数组的长度,计算逻辑如下:
1)如果numElements小于8,就是8。
2)在numElements大于等于8的情况下,分配的实际长度是严格大于numElements并且为2的整数次幂的最小数。比如,如果numElements为10,则实际分配16,如果num-Elements为32,则为64。

为什么要为2的幂次数呢?我们待会会看到,这样会使得很多操作的效率很高。为什么要严格大于numElements呢?因为循环数组必须时刻至少留一个空位,tail变量指向下一个空位,为了容纳numElements个元素,至少需要numElements+1个位置。

看最后一个构造方法:

1
2
3
4
public ArrayDeque(Collection<? extends E> c) {
allocateElements(c.size());
addAll(c);
}

同样调用allocateElements分配数组,随后调用了addAll,而addAll只是循环调用了add方法。下面我们来看add的实现。

3.从尾部添加

add方法的代码为:

1
2
3
4
public boolean add(E e) {
addLast(e);
return true;
}

addLast的代码为:

1
2
3
4
5
6
7
public void addLast(E e) {
if(e == null)
throw new NullPointerException();
elements[tail] = e;
if( (tail = (tail + 1) & (elements.length - 1)) == head)
doubleCapacity();
}

将元素添加到tail处,然后tail指向下一个位置,如果队列满了,则调用doubleCapa-city扩展数组。tail的下一个位置是(tail+1) & (elements.length-1),如果与head相同,则队列就满了。

进行与操作保证了索引在正确范围,与(elements.length-1)相与就可以得到下一个正确位置,是因为elements.length是2的幂次方,(elements.length-1)的后几位全是1,无论是正数还是负数,与(elements.length-1)相与都能得到期望的下一个正确位置。

比如,如果elements.length为8,则(elements.length-1)为7,二进制表示为0111,对于负数-1,与7相与,结果为7,对于正数8,与7相与,结果为0,都能达到循环数组中找下一个正确位置的目的。这种位操作是循环数组中一种常见的操作,效率也很高,后续代码中还会看到。

doubleCapacity将数组扩大为两倍,代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private void doubleCapacity() {
assert head == tail;
int p = head;
int n = elements.length;
int r = n - p; //number of elements to the right of p
int newCapacity = n << 1;
if(newCapacity < 0)
throw new IllegalStateException("Sorry, deque too big");
Object[] a = new Object[newCapacity];
System.arraycopy(elements, p, a, 0, r);
System.arraycopy(elements, 0, a, r, p);
elements = (E[])a;
head = 0;
tail = n;
}

分配一个长度翻倍的新数组a,将head右边的元素复制到新数组开头处,再复制左边的元素到新数组中,最后重新设置head和tail, head设为0, tail设为n。

我们来看一个例子,假设原长度为8, head和tail为4,现在开始扩大数组,扩大前后的结构如图9-10所示。

epub_923038_67

图9-10 循环数组:扩容前后对比

add是在末尾添加,我们再看在头部添加的代码。

4.从头部添加

addFirst()方法的代码为:

1
2
3
4
5
6
7
public void addFirst(E e) {
if(e == null)
throw new NullPointerException();
elements[head = (head - 1) & (elements.length - 1)] = e;
if(head == tail)
doubleCapacity();
}

在头部添加,要先让head指向前一个位置,然后再赋值给head所在位置。head的前一个位置是(head-1) & (elements.length-1)。刚开始head为0,如果elements.length为8,则(head-1) & (elements.length-1)的结果为7。比如,执行如下代码:

1
2
3
Deque<String> queue = new ArrayDeque<>(7);
queue.addFirst("a");
queue.addFirst("b");

执行完后,内部结构如图9-11所示。

epub_923038_68

图9-11 循环数组:从头部添加后

介绍完了添加,下面来看删除。

5.从头部删除

removeFirst方法的代码为:

1
2
3
4
5
6
public E removeFirst() {
E x = pollFirst();
if(x == null)
throw new NoSuchElementException();
return x;
}

主要调用了pollFirst方法,pollFirst方法的代码为:

1
2
3
4
5
6
7
8
9
public E pollFirst() {
int h = head;
E result = elements[h]; //Element is null if deque empty
if(result == null)
return null;
elements[h] = null; //Must null out slot
head = (h + 1) & (elements.length - 1);
return result;
}

代码比较简单,将原头部位置置为null,然后head置为下一个位置,下一个位置为(h+1) & (elements.length-1)。从尾部删除的代码是类似的,就不赘述了。

6.查看长度

ArrayDeque没有单独的字段维护长度,其size方法的代码为:

1
2
3
public int size() {
return (tail - head) & (elements.length - 1);
}

通过该方法即可计算出size。

7.检查给定元素是否存在

contains方法的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
public boolean contains(Object o) {
if(o == null)
return false;
int mask = elements.length - 1;
int i = head;
E x;
while( (x = elements[i]) ! = null) {
if(o.equals(x))
return true;
i = (i + 1) & mask;
}
return false;
}

就是从head开始遍历并进行对比,循环过程中没有使用tail,而是到元素为null就结束了,这是因为在ArrayDeque中,有效元素不允许为null。

8. toArray方法

toArray方法的代码为:

1
2
3
public Object[] toArray() {
return copyElements(new Object[size()]);
}

copyElements的代码为:

1
2
3
4
5
6
7
8
9
10
private <T> T[] copyElements(T[] a) {
if(head < tail) {
System.arraycopy(elements, head, a, 0, size());
} else if(head > tail) {
int headPortionLen = elements.length - head;
System.arraycopy(elements, head, a, 0, headPortionLen);
System.arraycopy(elements, 0, a, headPortionLen, tail);
}
return a;
}

如果head小于tail,就是从head开始复制size个,否则,复制逻辑与doubleCapacity方法中的类似,先复制从head到末尾的部分,然后复制从0到tail的部分。

9.原理小结

以上就是ArrayDeque的基本原理,内部它是一个动态扩展的循环数组,通过head和tail变量维护数组的开始和结尾,数组长度为2的幂次方,使用高效的位操作进行各种判断,以及对head和tail进行维护。

9.3.2 ArrayDeque特点分析

ArrayDeque实现了双端队列,内部使用循环数组实现,这决定了它有如下特点。
1)在两端添加、删除元素的效率很高,动态扩展需要的内存分配以及数组复制开销可以被平摊,具体来说,添加N个元素的效率为O(N)。
2)根据元素内容查找和删除的效率比较低,为O(N)。
3)与ArrayList和LinkedList不同,没有索引位置的概念,不能根据索引位置进行操作。

ArrayDeque和LinkedList都实现了Deque接口,应该用哪一个呢?如果只需要Deque接口,从两端进行操作,一般而言,ArrayDeque效率更高一些,应该被优先使用;如果同时需要根据索引位置进行操作,或者经常需要在中间进行插入和删除,则应该选LinkedList。

至此,关于列表和队列的内容就介绍完了,无论是ArrayList、LinkedList还是Array-Deque,按内容查找元素的效率都很低,都需要逐个进行比较,有没有更有效的方式呢?让我们下一章来看各种Map和Set。

9.2 剖析LinkedList

ArrayList随机访问效率很高,但插入和删除性能比较低;LinkedList同样实现了List接口,它的特点与ArrayList几乎正好相反,本节我们就来详细介绍LinkedList。

除了实现了List接口外,LinkedList还实现了Deque和Queue接口,可以按照队列、栈和双端队列的方式进行操作。本节会介绍这些用法,同时介绍其实现原理。我们先来看它的用法。

9.2.1 用法

LinkedList的构造方法与ArrayList类似,有两个:一个是默认构造方法,另外一个可以接受一个已有的Collection,如下所示:

1
2
public LinkedList()
public LinkedList(Collection<? extends E> c)

比如,可以这么创建:

1
2
3
List<String> list = new LinkedList<>();
List<String> list2 = new LinkedList<>(
Arrays.asList(new String[]{"a", "b", "c"}));

LinkedList与ArrayList一样,同样实现了List接口,而List接口扩展了Collection接口,Collection又扩展了Iterable接口,所有这些接口的方法都是可以使用的,使用方法与上节介绍的一样,本节不再赘述。LinkedList还实现了队列接口Queue,所谓队列就类似于日常生活中的各种排队,特点就是先进先出,在尾部添加元素,从头部删除元素,它的接口定义为:

1
2
3
4
5
6
7
8
public interface Queue<E> extends Collection<E> {
boolean add(E e);
boolean offer(E e);
E remove();
E poll();
E element();
E peek();
}

Queue扩展了Collection,它的主要操作有三个:

  • 在尾部添加元素(add、offer);
  • 查看头部元素(element、peek),返回头部元素,但不改变队列;
  • 删除头部元素(remove、poll),返回头部元素,并且从队列中删除。

每种操作都有两种形式,有什么区别呢?区别在于,对于特殊情况的处理不同。特殊情况是指队列为空或者队列为满,为空容易理解,为满是指队列有长度大小限制,而且已经占满了。LinkedList的实现中,队列长度没有限制,但别的Queue的实现可能有。在队列为空时,element和remove会抛出异常NoSuchElementException,而peek和poll返回特殊值null;在队列为满时,add会抛出异常IllegalStateException,而offer只是返回false。

把LinkedList当作Queue使用也很简单,比如,可以这样:

1
2
3
4
5
6
7
Queue<String> queue = new LinkedList<>();
queue.offer("a");
queue.offer("b");
queue.offer("c");
while(queue.peek()! =null){
System.out.println(queue.poll());
}

输出有三行,依次为a、b和c。

我们在介绍函数调用原理的时候介绍过栈。栈也是一种常用的数据结构,与队列相反,它的特点是先进后出、后进先出,类似于一个储物箱,放的时候是一件件往上放,拿的时候则只能从上面开始拿。Java中没有单独的栈接口,栈相关方法包括在了表示双端队列的接口Deque中,主要有三个方法:

1
2
3
void push(E e);
E pop();
E peek();

解释如下。
1)push表示入栈,在头部添加元素,栈的空间可能是有限的,如果栈满了,push会抛出异常IllegalStateException。
2)pop表示出栈,返回头部元素,并且从栈中删除,如果栈为空,会抛出异常NoSuch-ElementException。
3)peek查看栈头部元素,不修改栈,如果栈为空,返回null。

把LinkedList当作栈使用也很简单,比如,可以这样:

1
2
3
4
5
6
7
Deque<String> stack = new LinkedList<>();
stack.push("a");
stack.push("b");
stack.push("c");
while(stack.peek()! =null){
System.out.println(stack.pop());
}

输出有三行,依次为c、b和a。

Java中有一个类Stack,单词意思是栈,它也实现了栈的一些方法,如push/pop/peek等,但它没有实现Deque接口,它是Vector的子类,它增加的这些方法也通过synchronized实现了线程安全,具体就不介绍了。不需要线程安全的情况下,推荐使用LinkedList或下节介绍的ArrayDeque。

栈和队列都是在两端进行操作,栈只操作头部,队列两端都操作,但尾部只添加、头部只查看和删除。有一个更为通用的操作两端的接口Deque。Deque扩展了Queue,包括了栈的操作方法,此外,它还有如下更为明确的操作两端的方法:

1
2
3
4
5
6
7
8
9
10
11
12
void addFirst(E e);
void addLast(E e);
E getFirst();
E getLast();
boolean offerFirst(E e);
boolean offerLast(E e);
E peekFirst();
E peekLast();
E pollFirst();
E pollLast();
E removeFirst();
E removeLast();

xxxFirst操作头部,xxxLast操作尾部。与队列类似,每种操作有两种形式,区别也是在队列为空或满时处理不同。为空时,getXXX/removeXXX会抛出异常,而peekXXX/pollXXX会返回null。队列满时,addXXX会抛出异常,offerXXX只是返回false。

栈和队列只是双端队列的特殊情况,它们的方法都可以使用双端队列的方法替代,不过,使用不同的名称和方法,概念上更为清晰。

Deque接口还有一个迭代器方法,可以从后往前遍历:

1
Iterator<E> descendingIterator();

比如,看如下代码:

1
2
3
4
5
6
Deque<String> deque = new LinkedList<>(
Arrays.asList(new String[]{"a", "b", "c"}));
Iterator<String> it = deque.descendingIterator();
while(it.hasNext()){
System.out.print(it.next()+" ");
}

输出为:

1
c b a

简单总结下:LinkedList的用法是比较简单的,与ArrayList用法类似,支持List接口,只是,LinkedList增加了一个接口Deque,可以把它看作队列、栈、双端队列,方便地在两端进行操作。如果只是用作List,那应该用ArrayList还是LinkedList呢?我们需要了解LinkedList的实现原理。

9.2.2 实现原理

我们先来看LinkedList的内部组成,然后分析它的一些主要方法的实现,代码基于Java 7。

1.内部组成

我们知道,ArrayList内部是数组,元素在内存是连续存放的,但LinkedList不是。LinkedList直译就是链表,确切地说,它的内部实现是双向链表,每个元素在内存都是单独存放的,元素之间通过链接连在一起,类似于小朋友之间手拉手一样。

为了表示链接关系,需要一个节点的概念。节点包括实际的元素,但同时有两个链接,分别指向前一个节点(前驱)和后一个节点(后继)。节点是一个内部类,具体定义为:

1
2
3
4
5
6
7
8
9
10
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}

Node类表示节点,item指向实际的元素,next指向后一个节点,prev指向前一个节点。

LinkedList内部组成就是如下三个实例变量:

1
2
3
transient int size = 0;
transient Node<E> first;
transient Node<E> last;

我们暂时忽略transient关键字,size表示链表长度,默认为0, first指向头节点,last指向尾节点,初始值都为null。

LinkedList的所有public方法内部操作的都是这三个实例变量,具体是怎么操作的?链接关系是如何维护的?我们看一些主要的方法,先来看add方法。

2. add方法

add方法的代码为:

1
2
3
4
public boolean add(E e) {
linkLast(e);
return true;
}

主要就是调用了linkLast,它的代码为:

1
2
3
4
5
6
7
8
9
10
11
void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if(l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}

代码的基本步骤如下。
1)创建一个新的节点newNode。l和last指向原来的尾节点,如果原来链表为空,则为null。代码为:

1
Node<E> newNode = new Node<>(l, e, null);

2)修改尾节点last,指向新的最后节点newNode。代码为:

1
last = newNode;

3)修改前节点的后向链接,如果原来链表为空,则让头节点指向新节点,否则让前一个节点的next指向新节点。代码为:

1
2
3
4
if(l == null)
first = newNode;
else
l.next = newNode;

4)增加链表大小。代码为:

1
size++

modCount++的目的与ArrayList是一样的,记录修改次数,便于迭代中间检测结构性变化。

我们通过一些图示来进行介绍。比如,代码为:

1
2
3
List<String> list = new LinkedList<String>();
list.add("a");
list.add("b");

执行完第一行后,内部结构如图9-1所示。

epub_923038_58

图9-1 LinkedList对象内部结构:初始状态

添加完“a”后,内部结构如图9-2所示。

epub_923038_59

图9-2 LinkedList对象内部结构:添加一个元素后

添加完“b”后,内部结构如图9-3所示。

epub_923038_60

图9-3 LinkedList对象内部结构:添加两个元素后

可以看出,与ArrayList不同,Linked-List的内存是按需分配的,不需要预先分配多余的内存,添加元素只需分配新元素的空间,然后调节几个链接即可。

3.根据索引访问元素get

添加了元素,如何根据索引访问元素呢?我们看下get方法的代码:

1
2
3
4
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}

checkElementIndex检查索引位置的有效性,如果无效,则抛出异常,代码为:

1
2
3
4
5
6
7
private void checkElementIndex(int index) {
if(! isElementIndex(index))
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
private boolean isElementIndex(int index) {
return index >= 0 && index < size;
}

如果index有效,则调用node方法查找对应的节点,其item属性就指向实际元素内容,node方法的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
Node<E> node(int index) {
if(index < (size >> 1)) {
Node<E> x = first;
for(int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for(int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}

size>>1等于size/2,如果索引位置在前半部分(index<(size>>1)),则从头节点开始查找,否则,从尾节点开始查找。可以看出,与ArrayList明显不同,ArrayList中数组元素连续存放,可以根据索引直接定位,而在LinkedList中,则必须从头或尾顺着链接查找,效率比较低。

4.根据内容查找元素

我们看下indexOf的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public int indexOf(Object o) {
int index = 0;
if(o == null) {
for(Node<E> x = first; x ! = null; x = x.next) {
if(x.item == null)
return index;
index++;
}
} else {
for(Node<E> x = first; x ! = null; x = x.next) {
if(o.equals(x.item))
return index;
index++;
}
}
return -1;
}

代码也很简单,从头节点顺着链接往后找,如果要找的是null,则找第一个item为null的节点,否则使用equals方法进行比较。

5.插入元素

add是在尾部添加元素,如果在头部或中间插入元素呢?可以使用如下方法:

1
public void add(int index, E element)

它的代码是:

1
2
3
4
5
6
7
public void add(int index, E element) {
checkPositionIndex(index);
if(index == size)
linkLast(element);
else
linkBefore(element, node(index));
}

如果index为size,添加到最后面,一般情况,是插入到index对应节点的前面,调用方法为linkBefore,它的代码为:

1
2
3
4
5
6
7
8
9
10
11
void linkBefore(E e, Node<E> succ) {
final Node<E> pred = succ.prev;
final Node<E> newNode = new Node<>(pred, e, succ);
succ.prev = newNode;
if(pred == null)
first = newNode;
else
pred.next = newNode;
size++;
modCount++;
}

参数succ表示后继节点,变量pred表示前驱节点,目标是在pred和succ中间插入一个节点。插入步骤是:
1)新建一个节点newNode,前驱为pred,后继为succ。代码为:

1
Node<E> newNode = new Node<>(pred, e, succ);

2)让后继的前驱指向新节点。代码为:

1
succ.prev = newNode;

3)让前驱的后继指向新节点,如果前驱为空,那么修改头节点指向新节点。代码为:

1
2
3
4
if (pred == null)
first = newNode;
else
pred.next = newNode;

4)增加长度。我们通过图示来进行介绍。还是上面的例子,比如,添加一个元素:

1
list.add(1, "c");

内存结构如图9-4所示。

epub_923038_61

图9-4 LinkedList对象内部结构:在中间插入元素后

可以看出,在中间插入元素,LinkedList只需按需分配内存,修改前驱和后继节点的链接,而ArrayList则可能需要分配很多额外空间,且移动所有后续元素。

6.删除元素

我们再来看删除元素,代码为:

1
2
3
4
public E remove(int index) {
checkElementIndex(index);
return unlink(node(index));
}

通过node方法找到节点后,调用了unlink方法,代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
E unlink(Node<E> x) {
final E element = x.item;
final Node<E> next = x.next;
final Node<E> prev = x.prev;
if(prev == null) {
first = next;
} else {
prev.next = next;
x.prev = null;
}
if(next == null) {
last = prev;
} else {
next.prev = prev;
x.next = null;
}
x.item = null;
size--;
modCount++;
return element;
}

删除x节点,基本思路就是让x的前驱和后继直接链接起来,next是x的后继,prev是x的前驱,具体分为两步。
1)让x的前驱的后继指向x的后继。如果x没有前驱,说明删除的是头节点,则修改头节点指向x的后继。
2)让x的后继的前驱指向x的前驱。如果x没有后继,说明删除的是尾节点,则修改尾节点指向x的前驱。

通过图示进行说明。还是上面的例子,如果删除一个元素:

1
list.remove(1);

内存结构如图9-5所示。

epub_923038_62

图9-5 LinkedList对象内部结构:删除元素后

以上,我们介绍了LinkedList的内部组成,以及几个主要方法的实现代码,其他方法的原理也都类似,我们就不赘述了。

前面我们提到,对于队列、栈和双端队列接口,长度可能有限制,LinkedList实现了这些接口,不过LinkedList对长度并没有限制。

9.2.3 LinkedList特点分析

用法上,LinkedList是一个List,但也实现了Deque接口,可以作为队列、栈和双端队列使用。实现原理上,LinkedList内部是一个双向链表,并维护了长度、头节点和尾节点,这决定了它有如下特点。
1)按需分配空间,不需要预先分配很多空间。
2)不可以随机访问,按照索引位置访问效率比较低,必须从头或尾顺着链接找,效率为O(N/2)。
3)不管列表是否已排序,只要是按照内容查找元素,效率都比较低,必须逐个比较,效率为O(N)。
4)在两端添加、删除元素的效率很高,为O(1)。
5)在中间插入、删除元素,要先定位,效率比较低,为O(N),但修改本身的效率很高,效率为O(1)。

理解了LinkedList和ArrayList的特点,就能比较容易地进行选择了,如果列表长度未知,添加、删除操作比较多,尤其经常从两端进行操作,而按照索引位置访问相对比较少,则LinkedList是比较理想的选择。

第9章 列表和队列

从本章开始,我们探讨Java中的容器类。所谓容器,顾名思义就是容纳其他数据的。计算机课程中有一门课叫数据结构,可以粗略对应于Java中的容器类。容器类可以说是日常程序开发中天天用到的,没有容器类,难以想象能开发什么真正有用的程序。

我们不会介绍所有数据结构的内容,但会介绍Java中的主要实现。在本章中,我们先介绍关于列表和队列的一些主要类,具体包括ArrayList、LinkedList以及ArrayDeque,我们会介绍它们的用法、背后的实现原理、数据结构和算法,以及应用场景等。

9.1 剖析ArrayList

第8章介绍泛型的时候,我们自己实现了一个简单的动态数组容器类DynaArray,本节将介绍Java中真正的动态数组容器类ArrayList。本节会介绍它的基本用法、迭代操作、实现的一些接口(Collection、List和RandAccess),最后分析它的特点。

9.1.1 基本用法

ArrayList是一个泛型容器,新建ArrayList需要实例化泛型参数,比如:

1
2
ArrayList<Integer> intList = new ArrayList<Integer>();
ArrayList<String> strList = new ArrayList<String>();

ArrayList的主要方法有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public boolean add(E e) //添加元素到末尾
public boolean isEmpty() //判断是否为空
public int size() //获取长度
public E get(int index) //访问指定位置的元素
public int indexOf(Object o) //查找元素, 如果找到,返回索引位置,否则返回-1
public int lastIndexOf(Object o) //从后往前找
public boolean contains(Object o) //是否包含指定元素,依据是equals方法的返回值
public E remove(int index) //删除指定位置的元素, 返回值为被删对象
//删除指定对象,只删除第一个相同的对象,返回值表示是否删除了元素
//如果o为null,则删除值为null的元素
public boolean remove(Object o)
public void clear() //删除所有元素
//在指定位置插入元素,index为0表示插入最前面,index为ArrayList的长度表示插到最后面
public void add(int index, E element)
public E set(int index, E element) //修改指定位置的元素内容

这些方法简单直接,就不多解释了,我们看个简单示例:

1
2
3
4
5
6
ArrayList<String> strList = new ArrayList<String>();
strList.add("老马");
strList.add("编程");
for(int i=0; i<strList.size(); i++){
System.out.println(strList.get(i));
}

9.1.2 基本原理

可以看出,ArrayList的基本用法是比较简单的,它的基本原理也是比较简单的。Array-List的基本原理与我们在上一章介绍的DynaArray类似,内部有一个数组elementData,一般会有一些预留的空间,有一个整数size记录实际的元素个数(基于Java
7),如下所示:

1
2
private transient Object[] elementData;
private int size;

我们暂时可以忽略transient这个关键字。各种public方法内部操作的基本都是这个数组和这个整数,elementData会随着实际元素个数的增多而重新分配,而size则始终记录实际的元素个数。

下面,我们具体来看下add和remove方法的实现。add方法的主要代码为:

1
2
3
4
5
public boolean add(E e) {
ensureCapacityInternal(size + 1);
elementData[size++] = e;
return true;
}

它首先调用ensureCapacityInternal确保数组容量是够的,ensureCapacityInternal的代码是:

1
2
3
4
5
6
private void ensureCapacityInternal(int minCapacity) {
if(elementData == EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}

它先判断数组是不是空的,如果是空的,则首次至少要分配的大小为DEFAULT_CAPACITY, DEFAULT_CAPACITY的值为10,接下来调用ensureExplicitCapacity,主要代码为:

1
2
3
4
5
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
if(minCapacity - elementData.length > 0)
grow(minCapacity);
}

modCount++是什么意思呢?modCount表示内部的修改次数,modCount++当然就是增加修改次数,为什么要记录修改次数呢?我们待会解释。

如果需要的长度大于当前数组的长度,则调用grow方法,其主要代码为:

1
2
3
4
5
6
7
8
9
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
//右移一位相当于除2,所以,newCapacity相当于oldCapacity的1.5倍
int newCapacity = oldCapacity + (oldCapacity >> 1);
//如果扩展1.5倍还是小于minCapacity,就扩展为minCapacity
if(newCapacity - minCapacity < 0)
newCapacity = minCapacity;
elementData = Arrays.copyOf(elementData, newCapacity);
}

代码中已有注释说明,不再赘述。我们再来看remove方法的代码:

1
2
3
4
5
6
7
8
9
10
public E remove(int index) {
rangeCheck(index);
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1; //计算要移动的元素个数
if(numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index, numMoved);
elementData[--size] = null; //将size减1,同时释放引用以便原对象被垃圾回收
return oldValue;
}

它也增加了modCount,然后计算要移动的元素个数,从index往后的元素都往前移动一位,实际调用System.arraycopy方法移动元素。elementData[--size] =null;这行代码将size减1,同时将最后一个位置设为null,设为null后不再引用原来对象,如果原来对象也不再被其他对象引用,就可以被垃圾回收。

其他方法大多是比较简单的,我们就不赘述了。上面的代码中,为便于理解,我们删减了一些边界情况处理的代码,完整代码要晦涩复杂一些,但接口一般都是简单直接的,这就是使用容器类的好处,这也是计算机程序中的基本思维方式,封装复杂操作,提供简化接口

9.1.3 迭代

理解了ArrayList的基本用法和原理,接下来,我们来看一个ArrayList的常见操作:迭代。我们看一个迭代操作的例子,循环打印ArrayList中的每个元素,ArrayList支持foreach语法:

1
2
3
4
5
6
7
ArrayList<Integer> intList = new ArrayList<Integer>();
intList.add(123);
intList.add(456);
intList.add(789);
for(Integer a : intList){
System.out.println(a);
}

当然,这种循环也可以使用如下代码实现:

1
2
3
for(int i=0; i<intList.size(); i++){
System.out.println(intList.get(i));
}

不过,foreach看上去更为简洁,而且它适用于各种容器,更为通用。

这种foreach语法背后是怎么实现的呢?其实,编译器会将它转换为类似如下代码:

1
2
3
4
Iterator<Integer> it = intList.iterator();
while(it.hasNext()){
System.out.println(it.next());
}

接下来,我们解释其中的代码。

1.迭代器接口

ArrayList实现了Iterable接口,Iterable表示可迭代,Java 7中的定义为:

1
2
3
public interface Iterable<T> {
Iterator<T> iterator();
}

定义很简单,就是要求实现iterator方法。iterator方法的声明为:

1
public Iterator<E> iterator()

它返回一个实现了Iterator接口的对象,Java 7中Iterator接口的定义为:

1
2
3
4
5
public interface Iterator<E> {
boolean hasNext();
E next();
void remove();
}

hasNext()判断是否还有元素未访问,next()返回下一个元素,remove()删除最后返回的元素,只读访问的基本模式类似于:

1
2
3
4
Iterator<Integer> it = intList.iterator();
while(it.hasNext()){
System.out.println(it.next());
}

我们待会再看迭代中间要删除元素的情况。

只要对象实现了Iterable接口,就可以使用foreach语法,编译器会转换为调用Iterable和Iterator接口的方法。初次见到Iterable和Iterator,可能会比较容易混淆,我们再澄清一下:

  • Iterable表示对象可以被迭代,它有一个方法iterator(),返回Iterator对象,实际通过Iterator接口的方法进行遍历;
  • 如果对象实现了Iterable,就可以使用foreach语法;
  • 类可以不实现Iterable,也可以创建Iterator对象。

需要了解的是,Java 8对Iterable添加了默认方法forEach和spliterator,对Iterator增加了默认方法forEachRemaining和remove,具体可参见API文档,我们就不介绍了。

2. ListIterator

除了iterator(), ArrayList还提供了两个返回Iterator接口的方法:

1
2
public ListIterator<E> listIterator()
public ListIterator<E> listIterator(int index)

ListIterator扩展了Iterator接口,增加了一些方法,向前遍历、添加元素、修改元素、返回索引位置等,添加的方法有:

1
2
3
4
5
6
7
8
public interface ListIterator<E> extends Iterator<E> {
boolean hasPrevious();
E previous();
int nextIndex();
int previousIndex();
void set(E e);
void add(E e);
}

listIterator()方法返回的迭代器从0开始,而listIterator(int index)方法返回的迭代器从指定位置index开始。比如,从末尾往前遍历,代码为:

1
2
3
4
5
6
public void reverseTraverse(List<Integer> list){
ListIterator<Integer> it = list.listIterator(list.size());
while(it.hasPrevious()){
System.out.println(it.previous());
}
}

3.迭代的陷阱

关于迭代器,有一种常见的误用,就是在迭代的中间调用容器的删除方法。比如,要删除一个整数ArrayList中所有小于100的数,直觉上,代码可以这么写:

1
2
3
4
5
6
7
public void remove(ArrayList<Integer> list){
for(Integer a : list){
if(a<=100){
list.remove(a);
}
}
}

但运行时会抛出异常:

1
java.util.ConcurrentModificationException

发生了并发修改异常,为什么呢?因为迭代器内部会维护一些索引位置相关的数据,要求在迭代过程中,容器不能发生结构性变化,否则这些索引位置就失效了。所谓结构性变化就是添加、插入和删除元素,只是修改元素内容不算结构性变化。

如何避免异常呢?可以使用迭代器的remove方法,如下所示:

1
2
3
4
5
6
7
8
public static void remove(ArrayList<Integer> list){
Iterator<Integer> it = list.iterator();
while(it.hasNext()){
if(it.next()<=100){
it.remove();
}
}
}

迭代器如何知道发生了结构性变化,并抛出异常?它自己的remove方法为何又可以使用呢?我们需要看下迭代器实现的原理。

4.迭代器实现的原理

我们来看下ArrayList中iterator方法的实现,代码为:

1
2
3
public Iterator<E> iterator() {
return new Itr();
}

新建了一个Itr对象,Itr是一个成员内部类,实现了Iterator接口,声明为:

1
private class Itr implements Iterator<E>

它有三个实例成员变量,为:

1
2
3
int cursor;        //下一个要返回的元素位置
int lastRet = -1; //最后一个返回的索引位置,如果没有,为-1
int expectedModCount = modCount;

cursor表示下一个要返回的元素位置,lastRet表示最后一个返回的索引位置,expected-ModCount表示期望的修改次数,初始化为外部类当前的修改次数modCount,回顾一下,成员内部类可以直接访问外部类的实例变量。每次发生结构性变化的时候modCount都会增加,而每次迭代器操作的时候都会检查expectedModCount是否与modCount相同,这样就能检测出结构性变化。

我们来具体看下,它是如何实现Iterator接口中的每个方法的,先看hasNext(),代码为:

1
2
3
public boolean hasNext() {
return cursor ! = size;
}

cursor与size比较,比较直接,看next方法:

1
2
3
4
5
6
7
8
9
10
11
public E next() {
checkForComodification();
int i = cursor;
if(i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if(i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}

首先调用了checkForComodification,它的代码为:

1
2
3
4
final void checkForComodification() {
if(modCount ! = expectedModCount)
throw new ConcurrentModificationException();
}

所以,next前面部分主要就是在检查是否发生了结构性变化,如果没有变化,就更新cursor和lastRet的值,以保持其语义,然后返回对应的元素。remove的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
public void remove() {
if(lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}

它调用了ArrayList的remove方法,但同时更新了cursor、lastRet和expectedModCount的值,所以它可以正确删除。不过,需要注意的是,调用remove方法前必须先调用next,比如,通过迭代器删除所有元素,直觉上,可以这么写:

1
2
3
4
5
6
public static void removeAll(ArrayList<Integer> list){
Iterator<Integer> it = list.iterator();
while(it.hasNext()){
it.remove();
}
}

实际运行,会抛出异常java.lang.IllegalStateException,正确写法是:

1
2
3
4
5
6
7
public static void removeAll(ArrayList<Integer> list){
Iterator<Integer> it = list.iterator();
while(it.hasNext()){
it.next();
it.remove();
}
}

当然,如果只是要删除所有元素,ArrayList有现成的方法clear()。

listIterator()的实现使用了另一个内部类ListItr,它继承自Itr,基本思路类似,我们就不赘述了。

5.迭代器的好处

为什么要通过迭代器这种方式访问元素呢?直接使用size()/get(index)语法不也可以吗?在一些场景下,确实没有什么差别,两者都可以。不过,foreach语法更为简洁一些,更重要的是,迭代器语法更为通用,它适用于各种容器类。

此外,迭代器表示的是一种关注点分离的思想,将数据的实际组织方式与数据的迭代遍历相分离,是一种常见的设计模式。需要访问容器元素的代码只需要一个Iterator接口的引用,不需要关注数据的实际组织方式,可以使用一致和统一的方式进行访问。

而提供Iterator接口的代码了解数据的组织方式,可以提供高效的实现。在ArrayList中, size/get(index)语法与迭代器性能是差不多的,但在后续介绍的其他容器中,则不一定,比如LinkedList,迭代器性能就要高很多。

从封装的思路上讲,迭代器封装了各种数据组织方式的迭代操作,提供了简单和一致的接口。

9.1.4 ArrayList实现的接口

Java的各种容器类有一些共性的操作,这些共性以接口的方式体现,我们刚刚介绍的Iterable接口就是,此外,ArrayList还实现了三个主要的接口:Collection、List和Random-Access,我们逐个介绍。

1. Collection

Collection表示一个数据集合,数据间没有位置或顺序的概念,Java 7中的接口定义为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public interface Collection<E> extends Iterable<E> {
int size();
boolean isEmpty();
boolean contains(Object o);
Iterator<E> iterator();
Object[] toArray();
<T> T[] toArray(T[] a);
boolean add(E e);
boolean remove(Object o);
boolean containsAll(Collection<? > c);
boolean addAll(Collection<? extends E> c);
boolean removeAll(Collection<? > c);
boolean retainAll(Collection<? > c);
void clear();
boolean equals(Object o);
int hashCode();
}

这些方法中,除了两个toArray方法和几个xxxAll()方法外,其他我们已经介绍过了。toArray方法我们待会再介绍。这几个xxxAll()方法的含义基本也是可以顾名思义的, addAll表示添加,removeAll表示删除,containsAll表示检查是否包含了参数容器中的所有元素,只有全包含才返回true, retainAll表示只保留参数容器中的元素,其他元素会进行删除。Java 8对Collection接口添加了几个默认方法,包括removeIf、stream、spliterator等,具体可参见API文档。

抽象类AbstractCollection对这几个方法都提供了默认实现,实现的方式就是利用迭代器方法逐个操作。比如,我们看removeAll方法,代码为:

1
2
3
4
5
6
7
8
9
10
11
public boolean removeAll(Collection<? > c) {
boolean modified = false;
Iterator<? > it = iterator();
while(it.hasNext()) {
if(c.contains(it.next())) {
it.remove();
modified = true;
}
}
return modified;
}

代码比较简单,就不解释了。ArrayList继承了AbstractList,而AbstractList又继承了AbstractCollection, ArrayList对其中一些方法进行了重写,以提供更为高效的实现,具体不再介绍。

2. List

List表示有顺序或位置的数据集合,它扩展了Collection,增加的主要方法有(Java 7):

1
2
3
4
5
6
7
8
9
10
boolean addAll(int index, Collection<? extends E> c);
E get(int index);
E set(int index, E element);
void add(int index, E element);
E remove(int index);
int indexOf(Object o);
int lastIndexOf(Object o);
ListIterator<E> listIterator();
ListIterator<E> listIterator(int index);
List<E> subList(int fromIndex, int toIndex);

这些方法都与位置有关,容易理解,就不介绍了。Java 8对List接口增加了几个默认方法,包括sort、replaceAll和spliterator; Java 9增加了多个重载的of方法,可以根据一个或多个元素生成一个不变的List,具体就不介绍了,可参看API文档。

3. RandomAccess

RandomAccess的定义为:

1
2
public interface RandomAccess {
}

没有定义任何代码。这有什么用呢?这种没有任何代码的接口在Java中被称为标记接口,用于声明类的一种属性。

这里,实现了RandomAccess接口的类表示可以随机访问,可随机访问就是具备类似数组那样的特性,数据在内存是连续存放的,根据索引值就可以直接定位到具体的元素,访问效率很高。下节我们会介绍LinkedList,它就不能随机访问。

有没有声明RandomAccess有什么关系呢?主要用于一些通用的算法代码中,它可以根据这个声明而选择效率更高的实现。比如,Collections类中有一个方法binarySearch,在List中进行二分查找,它的实现代码就根据list是否实现了RandomAccess而采用不同的实现机制,如下所示:

1
2
3
4
5
6
7
public static <T>
int binarySearch(List<? extends Comparable<? super T>> list, T key) {
if(list instanceof RandomAccess || list.size()<BINARYSEARCH_THRESHOLD)
return Collections.indexedBinarySearch(list, key);
else
return Collections.iteratorBinarySearch(list, key);
}

9.1.5 ArrayList的其他方法

ArrayList中还有一些其他方法,包括构造方法、与数组的相互转换、容量大小控制等,我们来看下。ArrayList还有两个构造方法:

1
2
public ArrayList(int initialCapacity)
public ArrayList(Collection<? extends E> c)

第一个方法以指定的大小initialCapacity初始化内部的数组大小,代码为:

1
this.elementData = new Object[initialCapacity];

在事先知道元素长度的情况下,或者,预先知道长度上限的情况下,使用这个构造方法可以避免重新分配和复制数组。第二个构造方法以一个已有的Collection构建,数据会新复制一份。

ArrayList中有两个方法可以返回数组:

1
2
public Object[] toArray()
public <T> T[] toArray(T[] a)

第一个方法返回是Object数组,代码为:

1
2
3
public Object[] toArray() {
return Arrays.copyOf(elementData, size);
}

第二个方法返回对应类型的数组,如果参数数组长度足以容纳所有元素,就使用该数组,否则就新建一个数组,比如:

1
2
3
4
5
6
7
8
ArrayList<Integer> intList = new ArrayList<Integer>();
intList.add(123);
intList.add(456);
intList.add(789);
Integer[] arrA = new Integer[3];
intList.toArray(arrA);
Integer[] arrB = intList.toArray(new Integer[0]);
System.out.println(Arrays.equals(arrA, arrB));

输出为true,表示两种方式都是可以的。

Arrays中有一个静态方法asList可以返回对应的List,如下所示:

1
2
Integer[] a = {1,2,3};
List<Integer> list = Arrays.asList(a);

需要注意的是,这个方法返回的List,它的实现类并不是本节介绍的ArrayList,而是Arrays类的一个内部类,在这个内部类的实现中,内部用的数组就是传入的数组,没有拷贝,也不会动态改变大小,所以对数组的修改也会反映到List中,对List调用add、remove方法会抛出异常。

要使用ArrayList完整的方法,应该新建一个ArrayList,如下所示:

1
List<Integer> list = new ArrayList<Integer>(Arrays.asList(a));

ArrayList还提供了两个public方法,可以控制内部使用的数组大小,一个是:

1
public void ensureCapacity(int minCapacity)

它可以确保数组的大小至少为minCapacity,如果不够,会进行扩展。如果已经预知ArrayList需要比较大的容量,调用这个方法可以减少ArrayList内部分配和扩展的次数。

另一个方法是:

1
public void trimToSize()

它会重新分配一个数组,大小刚好为实际内容的长度。调用这个方法可以节省数组占用的空间。

9.1.6 ArrayList特点分析

后续我们会介绍各种容器类和数据组织方式。之所以有各种不同的方式,是因为不同方式有不同特点,而不同特点有不同适用场合。考虑特点时,性能是其中一个很重要的部分,但性能不是一个简单的高低之分,对于一种数据结构,有的操作性能高,有的操作性能比较低。

作为程序员,就是要理解每种数据结构的特点,根据场合的不同,选择不同的数据结构。

对于ArrayList,它的特点是内部采用动态数组实现,这决定了以下几点。
1)可以随机访问,按照索引位置进行访问效率很高,用算法描述中的术语,效率是O(1),简单说就是可以一步到位。
2)除非数组已排序,否则按照内容查找元素效率比较低,具体是O(N), N为数组内容长度,也就是说,性能与数组长度成正比。
3)添加元素的效率还可以,重新分配和复制数组的开销被平摊了,具体来说,添加N个元素的效率为O(N)。
4)插入和删除元素的效率比较低,因为需要移动元素,具体为O(N)。

9.1.7 小结

本节详细介绍了ArrayList, ArrayList是日常开发中最常用的类之一。我们介绍了ArrayList的用法、基本实现原理、迭代器及其实现、Collection/List/RandomAccess接口、ArrayList与数组的相互转换,最后分析了ArrayList的特点。

需要说明的是,ArrayList不是线程安全的,关于线程我们在第15章介绍,实现线程安全的一种方式是使用Collections提供的方法装饰ArrayList,这个我们会在12.2节介绍。此外,需要了解的是,还有一个类Vector,它是Java最早实现的容器类之一,也实现了List接口,基本原理与ArrayList类似,内部使用synchronized(15.2节介绍)实现了线程安全,不需要线程安全的情况下,推荐使用ArrayList。

ArrayList的插入和删除的性能比较低,下一节,我们来看另一个同样实现了List接口的容器类:LinkedList,它的特点可以说与ArrayList正好相反。

8.3 细节和局限性

本节介绍泛型中的一些细节和局限性,这些局限性主要与Java的实现机制有关。Java中,泛型是通过类型擦除来实现的,类型参数在编译时会被替换为Object,运行时Java虚拟机不知道泛型这回事,这带来了很多局限性,其中有的部分是比较容易理解的,有的则是非常违反直觉的。

一项技术,往往只有理解了其局限性,才算是真正理解了它,才能更好地应用它。下面我们将从以下几个方面来介绍这些细节和局限性:

  • 使用泛型类、方法和接口。
  • 定义泛型类、方法和接口。
  • 泛型与数组。

8.3.1 使用泛型类、方法和接口

在使用泛型类、方法和接口时,有一些值得注意的地方,比如:

  • 基本类型不能用于实例化类型参数。
  • 运行时类型信息不适用于泛型。
  • 类型擦除可能会引发一些冲突。

我们逐个来看下。Java中,因为类型参数会被替换为Object,所以Java泛型中不能使用基本数据类型,也就是说,类似下面的写法是不合法的:

1
Pair<int> minmax = new Pair<int>(1,100);

解决方法是使用基本类型对应的包装类。

在介绍继承的实现原理时,我们提到在内存中每个类都有一份类型信息,而每个对象也都保存着其对应类型信息的引用。关于运行时信息,后续章节我们会进一步详细介绍,这里简要说明一下。在Java中,这个类型信息也是一个对象,它的类型为Class, Class本身也是一个泛型类,每个类的类型对象可以通过<类名>.class的方式引用,比如String. class、Integer.class。这个类型对象也可以通过对象的getClass()方法获得,比如:

1
Class<? > cls = "hello".getClass();

这个类型对象只有一份,与泛型无关,所以Java不支持类似如下写法:

1
Pair<Integer>.class

一个泛型对象的getClass方法的返回值与原始类型对象也是相同的,比如,下面代码的输出都是true:

1
2
3
4
Pair<Integer> p1 = new Pair<Integer>(1,100);
Pair<String> p2 = new Pair<String>("hello", "world");
System.out.println(Pair.class==p1.getClass()); //true
System.out.println(Pair.class==p2.getClass()); //true

之前,我们介绍过instanceof关键字,instanceof后面是接口或类名,instanceof是运行时判断,也与泛型无关,所以,Java也不支持类似如下写法:

1
if(p1 instanceof Pair<Integer>)

不过,Java支持如下写法:

1
if(p1 instanceof Pair<?>)

由于类型擦除,可能会引发一些编译冲突,这些冲突初看上去并不容易理解,我们通过一些例子介绍。8.2.3节我们介绍过一个例子,有两个类Base和Child, Base的声明为:

1
class Base implements Comparable<Base>

Child的声明为:

1
class Child extends Base

Child没有专门实现Comparable接口,8.2.3节我们说Base类已经有了比较所需的全部信息,所以Child没有必要实现,可是如果Child希望自定义这个比较方法呢?直觉上,可以这样修改Child类:

1
2
3
class Child extends Base implements Comparable<Child>{
//主体代码
}

遗憾的是,Java编译器会提示错误,Comparable接口不能被实现两次,且两次实现的类型参数还不同,一次是Comparable<Base>,一次是Comparable<Child>。为什么不允许呢?因为类型擦除后,实际上只能有一个。

那Child有什么办法修改比较方法呢?只能是重写Base类的实现,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
class Child extends Base {
@Override
public int compareTo(Base o) {
if(! (o instanceof Child)){
throw new IllegalArgumentException();
}
Child c = (Child)o;
//比较代码
return 0;
}
//其他代码
}

另外,你可能认为可以如下定义重载方法:

1
2
public static void test(DynamicArray<Integer> intArr)
public static void test(DynamicArray<String> strArr)

虽然参数都是DynamicArray,但实例化类型不同,一个是DynamicArray<Integer>,另一个是DynamicArray<String>,同样,遗憾的是,Java不允许这种写法,理由同样是类型擦除后它们的声明是一样的。

8.3.2 定义泛型类、方法和接口

在定义泛型类、方法和接口时,也有一些需要注意的地方,比如:

  • 不能通过类型参数创建对象。
  • 泛型类类型参数不能用于静态变量和方法。
  • 了解多个类型限定的语法。

我们逐个介绍。不能通过类型参数创建对象,比如,T是类型参数,下面的写法都是非法的:

1
2
T elm = new T();
T[] arr = new T[10];

为什么非法呢?因为如果允许,那么用户会以为创建的就是对应类型的对象,但由于类型擦除,Java只能创建Object类型的对象,而无法创建T类型的对象,容易引起误解,所以Java干脆禁止这么做。

那如果确实希望根据类型创建对象呢?需要设计API接受类型对象,即Class对象,并使用Java中的反射机制。第21章会介绍反射,这里简要说明一下。如果类型有默认构造方法,可以调用Class的newInstance方法构建对象,类似这样:

1
2
3
4
5
6
7
public static <T> T create(Class<T> type){
try {
return type.newInstance();
} catch (Exception e) {
return null;
}
}

比如:

1
2
Date date = create(Date.class);
StringBuilder sb = create(StringBuilder.class);

对于泛型类声明的类型参数,可以在实例变量和方法中使用,但在静态变量和静态方法中是不能使用的。类似下面这种写法是非法的:

1
2
3
4
5
6
7
8
9
public class Singleton<T> {
private static T instance;
public synchronized static T getInstance(){
if(instance==null){
//创建实例
}
return instance;
}
}

如果合法,那么对于每种实例化类型,都需要有一个对应的静态变量和方法。但由于类型擦除,Singleton类型只有一份,静态变量和方法都是类型的属性,且与类型参数无关,所以不能使用泛型类类型参数。

不过,对于静态方法,它可以是泛型方法,可以声明自己的类型参数,这个参数与泛型类的类型参数是没有关系的。

之前介绍类型参数限定的时候,我们提到上界可以为某个类、某个接口或者其他类型参数,但上界都是只有一个,Java中还支持多个上界,多个上界之间以&分隔,类似这样:

1
T extends Base & Comparable & Serializable

Base为上界类,Comparable和Serializable为上界接口。如果有上界类,类应该放在第一个,类型擦除时,会用第一个上界替换。

8.3.3 泛型与数组

泛型与数组的关系稍微复杂一些,我们单独介绍。

引入泛型后,一个令人惊讶的事实是,不能创建泛型数组。比如,我们可能想这样创建一个Pair的泛型数组,以表示7.6节中介绍的奖励面额和权重。

1
2
3
Pair<Object, Integer>[] options = new Pair<Object, Integer>[]{
new Pair("1元",7), new Pair("2元", 2), new Pair("10元", 1)
};

Java会提示编译错误,不能创建泛型数组。这是为什么呢?我们先来进一步理解一下数组。

前面我们解释过,类型参数之间有继承关系的容器之间是没有关系的,比如,一个DynamicArray<Integer>对象不能赋值给一个DynamicArray<Number>变量。不过,数组是可以的,看代码:

1
2
3
Integer[] ints = new Integer[10];
Number[] numbers = ints;
Object[] objs = ints;

后面两种赋值都是允许的。数组为什么可以呢?数组是Java直接支持的概念,它知道数组元素的实际类型,知道Object和Number都是Integer的父类型,所以这个操作是允许的。

虽然Java允许这种转换,但如果使用不当,可能会引起运行时异常,比如:

1
2
3
Integer[] ints = new Integer[10];
Object[] objs = ints;
objs[0] = "hello";

编译是没有问题的,运行时会抛出ArrayStoreException,因为Java知道实际的类型是Integer,所以写入String会抛出异常。

理解了数组的这个行为,我们再来看泛型数组。如果Java允许创建泛型数组,则会发生非常严重的问题,我们看看具体会发生什么:

1
2
3
Pair<Object, Integer>[] options = new Pair<Object, Integer>[3];
Object[] objs = options;
objs[0] = new Pair<Double, String>(12.34, "hello");

如果可以创建泛型数组options,那它就可以赋值给其他类型的数组objs,而最后一行明显错误的赋值操作,则既不会引起编译错误,也不会触发运行时异常,因为Pair<Double, String>的运行时类型是Pair,和objs的运行时类型Pair[]是匹配的。但我们知道,它的实际类型是不匹配的,在程序的其他地方,当把objs[0]作为Pair<Object, Integer>进行处理的时候,一定会触发异常。

也就是说,如果允许创建泛型数组,那就可能会有上面这种错误操作,它既不会引起编译错误,也不会立即触发运行时异常,却相当于埋下了一颗炸弹,不定什么时候爆发,为避免这种情况,Java干脆就禁止创建泛型数组。

但现实需要能够存放泛型对象的容器,怎么办呢?可以使用原始类型的数组,比如:

1
2
3
4
Pair[] options = new Pair[]{
new Pair<String, Integer>("1元",7),
new Pair<String, Integer>("2元", 2),
new Pair<String, Integer>("10元", 1)};

更好的选择是,使用后续章节介绍的泛型容器。目前,可以使用我们自己实现的Dy-namicArray,比如:

1
2
3
4
DynamicArray<Pair<String, Integer>> options = new DynamicArray<>();
options.add(new Pair<String, Integer>("1元",7));
options.add(new Pair<String, Integer>("2元",2));
options.add(new Pair<String, Integer>("10元",1));

DynamicArray内部的数组为Object类型,一些操作插入了强制类型转换,外部接口是类型安全的,对数组的访问都是内部代码,可以避免误用和类型异常。

有时,我们希望转换泛型容器为一个数组,比如,对于DynamicArray,我们可能希望它有这么一个方法:

1
public E[] toArray()

而希望可以这么用:

1
2
3
4
DynamicArray<Integer> ints = new DynamicArray<Integer>();
ints.add(100);
ints.add(34);
Integer[] arr = ints.toArray();

先使用动态容器收集一些数据,然后转换为一个固定数组,这也是一个常见的合理需求,怎么来实现这个toArray方法呢?可能想先这样:

1
E[] arr = new E[size];

遗憾的是,如之前所述,这是不合法的。Java运行时根本不知道E是什么,也就无法做到创建E类型的数组。另一种想法是这样:

1
2
3
4
5
public E[] toArray(){
Object[] copy = new Object[size];
System.arraycopy(elementData, 0, copy, 0, size);
return (E[])copy;
}

或者使用之前介绍的Arrays方法:

1
2
3
public E[] toArray(){
return (E[])Arrays.copyOf(elementData, size);
}

结果都是一样的,没有编译错误了,但运行时会抛出ClassCastException异常,原因是Object类型的数组不能转换为Integer类型的数组。

那怎么办呢?可以利用Java中的运行时类型信息和反射机制,这些概念我们后续章节再详细介绍。这里我们简要介绍下。Java必须在运行时知道要转换成的数组类型,类型可以作为参数传递给toArray方法,比如:

1
2
3
4
5
public E[] toArray(Class<E> type){
Object copy = Array.newInstance(type, size);
System.arraycopy(elementData, 0, copy, 0, size);
return (E[])copy;
}

Class<E>表示要转换成的数组类型信息,有了这个类型信息,Array类的newInstance方法就可以创建出真正类型的数组对象。调用toArray方法时,需要传递需要的类型,比如,可以这样:

1
Integer[] arr = ints.toArray(Integer.class);

我们来稍微总结下泛型与数组的关系:

  • Java不支持创建泛型数组。
  • 如果要存放泛型对象,可以使用原始类型的数组,或者使用泛型容器。
  • 泛型容器内部使用Object数组,如果要转换泛型容器为对应类型的数组,需要使用反射。

8.3.4 小结

本节介绍了泛型的一些细节和局限性,这些局限性主要是由于Java泛型的实现机制引起的,这些局限性包括:不能使用基本类型,没有运行时类型信息,类型擦除会引发一些冲突,不能通过类型参数创建对象,不能用于静态变量等。我们还单独讨论了泛型与数组的关系。

我们需要理解这些局限性,幸运的是,一般并不需要特别去记忆,因为用错的时候, Java开发环境和编译器会进行提示,当被提示时能够理解并从容应对即可。

至此,关于泛型的介绍就结束了。泛型是Java容器类的基础,理解了泛型,接下来,就让我们开始探索Java中的容器类。

8.2 解析通配符

本节主要讨论泛型中的通配符概念。通配符有着令人费解和混淆的语法,但通配符大量应用于Java容器类中,它到底是什么?下面我们逐步来解析。

8.2.1 更简洁的参数类型限定

在8.1节最后,我们提到一个例子,为了将Integer对象添加到Number容器中,我们的类型参数使用了其他类型参数作为上界,我们提到,这种写法有点烦琐,它可以替换为更为简洁的通配符形式:

1
2
3
4
5
public void addAll(DynamicArray<? extends E> c) {
for(int i=0; i<c.size; i++){
add(c.get(i));
}
}

这个方法没有定义类型参数,c的类型是DynamicArray<? extends E>, ?表示通配符,<? extends E>表示有限定通配符,匹配E或E的某个子类型,具体什么子类型是未知的。使用这个方法的代码不需要做任何改动,还可以是:

1
2
3
4
5
DynamicArray<Number> numbers = new DynamicArray<>();
DynamicArray<Integer> ints = new DynamicArray<>();
ints.add(100);
ints.add(34);
numbers.addAll(ints);

这里,E是Number类型,DynamicArray<? extends E>可以匹配DynamicArray<Integer>

那么问题来了,同样是extends关键字,同样应用于泛型,<T extends E><?extends E>到底有什么关系?它们用的地方不一样,我们解释一下:
1)<T extends E>用于定义类型参数,它声明了一个类型参数T,可放在泛型类定义中类名后面、泛型方法返回值前面。
2)<? extends E>用于实例化类型参数,它用于实例化泛型变量中的类型参数,只是这个具体类型是未知的,只知道它是E或E的某个子类型。

虽然它们不一样,但两种写法经常可以达成相同目标,比如,前面例子中,下面两种写法都可以:

1
2
public void addAll(DynamicArray<? extends E> c)
public <T extends E> void addAll(DynamicArray<T> c)

那么,到底应该用哪种形式呢?我们先进一步理解通配符,然后再解释。

8.2.2 理解通配符

除了有限定通配符,还有一种通配符,形如DynamicArray<? >,称为无限定通配符。我们来看个例子,在DynamicArray中查找指定元素,代码如下:

1
2
3
4
5
6
7
8
public static int indexOf(DynamicArray<? > arr, Object elm){
for(int i=0; i<arr.size(); i++){
if(arr.get(i).equals(elm)){
return i;
}
}
return -1;
}

其实,这种无限定通配符形式也可以改为使用类型参数。也就是说,下面的写法:

1
public static int indexOf(DynamicArray<? > arr, Object elm)

可以改为:

1
public static <T> int indexOf(DynamicArray<T> arr, Object elm)

不过,通配符形式更为简洁。虽然通配符形式更为简洁,但上面两种通配符都有一个重要的限制:只能读,不能写。怎么理解呢?看下面的例子:

1
2
3
4
5
6
DynamicArray<Integer> ints = new DynamicArray<>();
DynamicArray<? extends Number> numbers = ints;
Integer a = 200;
numbers.add(a); //错误!
numbers.add((Number)a); //错误!
numbers.add((Object)a); //错误!

三种add方法都是非法的,无论是Integer,还是Number或Object,编译器都会报错。为什么呢?问号就是表示类型安全无知,? extends Number表示是Number的某个子类型,但不知道具体子类型,如果允许写入,Java就无法确保类型安全性,所以干脆禁止。我们来看个例子,看看如果允许写入会发生什么:

1
2
3
4
5
6
DynamicArray<Integer> ints = new DynamicArray<>();
DynamicArray<? extends Number> numbers = ints;
Number n = new Double(23.0);
Object o = new String("hello world");
numbers.add(n);
numbers.add(o);

如果允许写入Object或Number类型,则最后两行编译就是正确的,也就是说,Java将允许把Double或String对象放入Integer容器,这显然违背了Java关于类型安全的承诺。

大部分情况下,这种限制是好的,但这使得一些理应正确的基本操作无法完成,比如交换两个元素的位置,看如下代码:

1
2
3
4
5
public static void swap(DynamicArray<? > arr, int i, int j){
Object tmp = arr.get(i);
arr.set(i, arr.get(j));
arr.set(j, tmp);
}

这个代码看上去应该是正确的,但Java会提示编译错误,两行set语句都是非法的。不过,借助带类型参数的泛型方法,这个问题可以如下解决:

1
2
3
4
5
6
7
8
private static <T> void swapInternal(DynamicArray<T> arr, int i, int j){
T tmp = arr.get(i);
arr.set(i, arr.get(j));
arr.set(j, tmp);
}
public static void swap(DynamicArray<? > arr, int i, int j){
swapInternal(arr, i, j);
}

swap可以调用swapInternal,而带类型参数的swapInternal可以写入。Java容器类中就有类似这样的用法,公共的API是通配符形式,形式更简单,但内部调用带类型参数的方法。

除了这种需要写的场合,如果参数类型之间有依赖关系,也只能用类型参数,比如,将src容器中的内容复制到dest中:

1
2
3
4
5
6
public static <D, S extends D> void copy(DynamicArray<D> dest,
DynamicArray<S> src){
for(int i=0; i<src.size(); i++){
dest.add(src.get(i));
}
}

S和D有依赖关系,要么相同,要么S是D的子类,否则类型不兼容,有编译错误。不过,上面的声明可以使用通配符简化,两个参数可以简化为一个,如下所示:

1
2
3
4
5
6
public static <D> void copy(DynamicArray<D> dest,
DynamicArray<? extends D> src){
for(int i=0; i<src.size(); i++){
dest.add(src.get(i));
}
}

如果返回值依赖于类型参数,也不能用通配符,比如,计算动态数组中的最大值,如下所示:

1
2
3
4
5
6
7
8
9
public static <T extends Comparable<T>> T max(DynamicArray<T> arr){
T max = arr.get(0);
for(int i=1; i<arr.size(); i++){
if(arr.get(i).compareTo(max)>0){
max = arr.get(i);
}
}
return max;
}

上面的代码就难以用通配符代替。

现在我们再来看泛型方法到底应该用通配符的形式还是加类型参数。两者到底有什么关系?我们总结如下。
1)通配符形式都可以用类型参数的形式来替代,通配符能做的,用类型参数都能做。
2)通配符形式可以减少类型参数,形式上往往更为简单,可读性也更好,所以,能用通配符的就用通配符。
3)如果类型参数之间有依赖关系,或者返回值依赖类型参数,或者需要写操作,则只能用类型参数。
4)通配符形式和类型参数往往配合使用,比如,上面的copy方法,定义必要的类型参数,使用通配符表达依赖,并接受更广泛的数据类型。

8.2.3 超类型通配符

还有一种通配符,与形式<? extends E>正好相反,它的形式为<? super E>,称为超类型通配符,表示E的某个父类型。它有什么用呢?有了它,我们就可以更灵活地写入了。

如果没有这种语法,写入会有一些限制。来看个例子,我们给DynamicArray添加一个方法:

1
2
3
4
5
public void copyTo(DynamicArray<E> dest){
for(int i=0; i<size; i++){
dest.add(get(i));
}
}

这个方法也很简单,将当前容器中的元素添加到传入的目标容器中。我们可能希望这么使用:

1
2
3
4
5
DynamicArray<Integer> ints = new DynamicArray<Integer>();
ints.add(100);
ints.add(34);
DynamicArray<Number> numbers = new DynamicArray<Number>();
ints.copyTo(numbers);

Integer是Number的子类,将Integer对象拷贝入Number容器,这种用法应该是合情合理的,但Java会提示编译错误,理由我们之前也说过了,期望的参数类型是Dynamic-Array<Integer>, DynamicArray<Number>并不适用。

如之前所说,一般而言,不能将DynamicArray<Integer>看作DynamicArray<Number>,但我们这里的用法是没有问题的,Java解决这个问题的方法就是超类型通配符,可以将copyTo代码改为:

1
2
3
4
5
public void copyTo(DynamicArray<? super E> dest){
for(int i=0; i<size; i++){
dest.add(get(i));
}
}

这样,就没有问题了。

超类型通配符另一个常用的场合是Comparable/Comparator接口。同样,我们先来看下如果不使用会有什么限制。以前面计算最大值的方法为例,它的方法声明是:

1
public static <T extends Comparable<T>> T max(DynamicArray<T> arr)

这个声明有什么限制呢?举个简单的例子,有两个类Base和Child, Base的代码是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Base implements Comparable<Base>{
private int sortOrder;
public Base(int sortOrder) {
this.sortOrder = sortOrder;
}
@Override
public int compareTo(Base o) {
if(sortOrder < o.sortOrder){
return -1;
}else if(sortOrder > o.sortOrder){
return 1;
}else{
return 0;
}
}
}

Base代码很简单,实现了Comparable接口,根据实例变量sortOrder进行比较。Child代码是:

1
2
3
4
5
class Child extends Base {
public Child(int sortOrder) {
super(sortOrder);
}
}

这里,Child非常简单,只是继承了Base。注意:Child没有重新实现Comparable接口,因为Child的比较规则和Base是一样的。我们可能希望使用前面的max方法操作Child容器,如下所示:

1
2
3
4
DynamicArray<Child> childs = new DynamicArray<Child>();
childs.add(new Child(20));
childs.add(new Child(80));
Child maxChild = max(childs);

遗憾的是,Java会提示编译错误,类型不匹配。为什么不匹配呢?我们可能会认为,Java会将max方法的类型参数T推断为Child类型,但类型T的要求是extendsComparable<T>,而Child并没有实现Comparable<Child>,它实现的是Comparable<Base>

但我们的需求是合理的,Base类的代码已经有了关于比较所需要的全部数据,它应该可以用于比较Child对象。解决这个问题的方法,就是修改max的方法声明,使用超类型通配符,如下所示:

1
public static <T extends Comparable<? super T>> T max(DynamicArray<T> arr)

这么修改一下就可以了,这种写法比较抽象,将T替换为Child,就是:

1
Child extends Comparable<? super Child>

<? super Child>可以匹配Base,所以整体就是匹配的。

我们比较一下类型参数限定与超类型通配符,类型参数限定只有extends形式,没有super形式,比如,前面的copyTo方法的通配符形式的声明为:

1
public void copyTo(DynamicArray<? super E> dest)

如果类型参数限定支持super形式,则应该是:

1
public <T super E> void copyTo(DynamicArray<T> dest)

事实是,Java并不支持这种语法。

前面我们说过,对于有限定的通配符形式<? extends E>,可以用类型参数限定替代,但是对于类似上面的超类型通配符,则无法用类型参数替代

8.2.4 通配符比较

本节介绍了泛型中的三种通配符形式<? ><? super E><? extends E>,并分析了与类型参数形式的区别和联系,它们比较容易混淆,我们总结比较如下:
1)它们的目的都是为了使方法接口更为灵活,可以接受更为广泛的类型。
2)**<? super E>用于灵活写入或比较,使得对象可以写入父类型的容器,使得父类型的比较方法可以应用于子类对象,它不能被类型参数形式替代。
3)
<? ><? extends E>用于灵活读取**,使得方法可以读取E或E的任意子类型的容器对象,它们可以用类型参数的形式替代,但通配符形式更为简洁。

Java容器类的实现中,有很多使用通配符的例子,比如,类Collections中就有如下方法:

1
2
3
4
public static <T extends Comparable<? super T>> void sort(List<T> list)
public static <T> void sort(List<T> list, Comparator<? super T> c)
public static <T> void copy(List<? super T> dest, List<? extends T> src)
public static <T> T max(Collection<? extends T> coll,Comparator<? super T> comp)

通过前面两节,我们应该可以理解这些方法声明的含义了。关于泛型,还有一些细节以及限制,让我们下一节继续探讨。

第8章 泛型

之前章节中我们多次提到过泛型这个概念,本章我们就来详细讨论Java中的泛型。虽然泛型的基本思维和概念是比较简单的,但它有一些非常令人费解的语法、细节,以及局限性。

后续章节我们会介绍各种容器类。容器类可以说是日常程序开发中天天用到的,没有容器类,难以想象能开发什么真正有用的程序。而容器类是基于泛型的,不理解泛型,就难以深刻理解容器类。那泛型到底是什么呢?本章我们分为三节逐步来讨论:8.1节主要介绍泛型的基本概念和原理;8.2节重点介绍令人费解的通配符;8.3节介绍一些细节和泛型的局限性。

8.1 基本概念和原理

之前我们一直强调数据类型的概念,Java有8种基本类型,可以定义类,类相当于自定义数据类型,类之间还可以有组合和继承。我们也介绍了接口,其中提到,很多时候我们关心的不是类型,而是能力,针对接口和能力编程,不仅可以复用代码,还可以降低耦合,提高灵活性。

泛型将接口的概念进一步延伸,“泛型”的字面意思就是广泛的类型。类、接口和方法代码可以应用于非常广泛的类型,代码与它们能够操作的数据类型不再绑定在一起,同一套代码可以用于多种数据类型,这样,不仅可以复用代码,降低耦合,而且可以提高代码的可读性和安全性。

这么说可能比较抽象,接下来,我们通过一些例子逐步进行说明。在Java中,类、接口、方法都可以是泛型的,我们先来看泛型类。

8.1.1 一个简单泛型类

我们通过一个简单的例子来说明泛型类的基本概念、基本原理和泛型的好处。

1.基本概念

我们直接来看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Pair<T> {
T first;
T second;
public Pair(T first, T second){
this.first = first;
this.second = second;
}
public T getFirst() {
return first;
}
public T getSecond() {
return second;
}
}

Pair就是一个泛型类,与普通类的区别体现在:

1)类名后面多了一个<T>
2)first和second的类型都是T。

T是什么呢?T表示类型参数,泛型就是类型参数化,处理的数据类型不是固定的,而是可以作为参数传入。怎么用这个泛型类,并传递类型参数呢?看代码:

1
2
3
Pair<Integer> minmax = new Pair<Integer>(1,100);
Integer min = minmax.getFirst();
Integer max = minmax.getSecond();

Pair<Integer>中的Integer就是传递的实际类型参数。Pair类的代码和它处理的数据类型不是绑定的,具体类型可以变化。上面是Integer,也可以是String,比如:

1
Pair<String> kv = new Pair<String>("name", "老马");

类型参数可以有多个,Pair类中的first和second可以是不同的类型,多个类型之间以逗号分隔,来看改进后的Pair类定义:

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
29
30
31
32
public class Pair<T> {
T first;
T second;
public Pair(T first, T second){
this.first = first;
this.second = second;
}
public T getFirst() {
return first;
}
public T getSecond() {
return second;
}
}
Pair<String> kv = new Pair<String>("name", "老马");
Pair<Integer> minmax = new Pair<Integer>(1,100);
Integer min = minmax.getFirst();
Integer max = minmax.getSecond();
public class Pair<U, V> {
U first;
V second;
public Pair(U first, V second){
this.first = first;
this.second = second;
}
public U getFirst() {
return first;
}
public V getSecond() {
return second;
}
}

可以这样使用:

1
Pair<String, Integer> pair = new Pair<String, Integer>("老马",100);

<String, Integer>既出现在了声明变量时,也出现在了new后面,比较烦琐,从Java 7开始,支持省略后面的类型参数,可以如下使用:

1
Pair<String, Integer> pair = new Pair<>("老马",100);

2.基本原理

泛型类型参数到底是什么呢?为什么一定要定义类型参数呢?定义普通类,直接使用Object不就行了吗?比如,Pair类可以写为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Pair {
Object first;
Object second;
public Pair(Object first, Object second){
this.first = first;
this.second = second;
}
public Object getFirst() {
return first;
}
public Object getSecond() {
return second;
}
}

使用Pair的代码可以为:

1
2
3
4
5
6
Pair minmax = new Pair(1,100);
Integer min = (Integer)minmax.getFirst();
Integer max = (Integer)minmax.getSecond();
Pair kv = new Pair("name", "老马");
String key = (String)kv.getFirst();
String value = (String)kv.getSecond();

这样是可以的。实际上,Java泛型的内部原理就是这样的。

我们知道,Java有Java编译器和Java虚拟机,编译器将Java源代码转换为.class文件,虚拟机加载并运行.class文件。对于泛型类,Java编译器会将泛型代码转换为普通的非泛型代码,就像上面的普通Pair类代码及其使用代码一样,将类型参数T擦除,替换为Object,插入必要的强制类型转换。Java虚拟机实际执行的时候,它是不知道泛型这回事的,只知道普通的类及代码。

再强调一下,Java泛型是通过擦除实现的,类定义中的类型参数如T会被替换为Object,在程序运行过程中,不知道泛型的实际类型参数,比如Pair<Integer>,运行中只知道Pair,而不知道Integer。认识到这一点是非常重要的,它有助于我们理解Java泛型的很多限制。

Java为什么要这么设计呢?泛型是Java 5以后才支持的,这么设计是为了兼容性而不得已的一个选择。

3.泛型的好处

既然只使用普通类和Object就可以,而且泛型最后也转换为了普通类,那为什么还要用泛型呢?或者说,泛型到底有什么好处呢?泛型主要有两个好处:

  • 更好的安全性。
  • 更好的可读性。

语言和程序设计的一个重要目标是将bug尽量消灭在摇篮里,能消灭在写代码的时候,就不要等到代码写完程序运行的时候。只使用Object,代码写错的时候,开发环境和编译器不能帮我们发现问题,看代码:

1
2
3
Pair pair = new Pair("老马",1);
Integer id = (Integer)pair.getFirst();
String name = (String)pair.getSecond();

看出问题了吗?写代码时不小心把类型弄错了,不过,代码编译时是没有任何问题的,但运行时程序抛出了类型转换异常ClassCastException。如果使用泛型,则不可能犯这个错误,比如下面的代码:

1
2
3
Pair<String, Integer> pair = new Pair<>("老马",1);
Integer id = pair.getFirst(); //有编译错误
String name = pair.getSecond(); //有编译错误

开发环境(如Eclipse)会提示类型错误,即使没有好的开发环境,编译时Java编译器也会提示。这称之为类型安全,也就是说,通过使用泛型,开发环境和编译器能确保不会用错类型,为程序多设置一道安全防护网。使用泛型,还可以省去烦琐的强制类型转换,再加上明确的类型信息,代码可读性也会更好。

8.1.2 容器类

泛型类最常见的用途是作为容器类。所谓容器类,简单地说,就是容纳并管理多项数据的类。数组就是用来管理多项数据的,但数组有很多限制,比如,长度固定,插入、删除操作效率比较低。计算机技术有一门课程叫数据结构,专门讨论管理数据的各种方式。

这些数据结构在Java中的实现主要就是Java中的各种容器类,甚至Java泛型的引入主要也是为了更好地支持Java容器。后续章节我们会详细讨论主要的Java容器,本节先实现一个非常简单的Java容器,来解释泛型的一些概念。

我们来实现一个简单的动态数组容器。所谓动态数组,就是长度可变的数组。底层数组的长度当然是不可变的,但我们提供一个类,对这个类的使用者而言,好像就是一个长度可变的数组。Java容器中有一个对应的类ArrayList,本节我们来实现一个简化版,如代码清单8-1所示。

代码清单8-1 动态数组DynamicArray
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
29
30
31
32
33
public class DynamicArray<E> {
private static final int DEFAULT_CAPACITY = 10;
private int size;
private Object[] elementData;
public DynamicArray() {
this.elementData = new Object[DEFAULT_CAPACITY];
}
private void ensureCapacity(int minCapacity) {
int oldCapacity = elementData.length;
if(oldCapacity >= minCapacity){
return;
}
int newCapacity = oldCapacity * 2;
if(newCapacity < minCapacity)
newCapacity = minCapacity;
elementData = Arrays.copyOf(elementData, newCapacity);
}
public void add(E e) {
ensureCapacity(size + 1);
elementData[size++] = e;
}
public E get(int index) {
return (E)elementData[index];
}
public int size() {
return size;
}
public E set(int index, E element) {
E oldValue = get(index);
elementData[index] = element;
return oldValue;
}
}

DynamicArray就是一个动态数组,内部代码与我们之前分析过的StringBuilder类似,通过ensureCapacity方法来根据需要扩展数组。作为一个容器类,它容纳的数据类型是作为参数传递过来的,比如,存放Double类型:

1
2
3
4
5
6
7
DynamicArray<Double> arr = new DynamicArray<Double>();
Random rnd = new Random();
int size = 1+rnd.nextInt(100);
for(int i=0; i<size; i++){
arr.add(Math.random());
}
Double d = arr.get(rnd.nextInt(size));

这就是一个简单的容器类,适用于各种数据类型,且类型安全。后文还会以Dynamic-Array为例进行扩展,以解释泛型概念。

具体的类型还可以是一个泛型类,比如,可以这样写:

1
DynamicArray<Pair<Integer, String>> arr = new DynamicArray<>()

arr表示一个动态数组,每个元素是Pair<Integer, String>类型。

8.1.3 泛型方法

除了泛型类,方法也可以是泛型的,而且,一个方法是不是泛型的,与它所在的类是不是泛型没有什么关系。我们看个例子:

1
2
3
4
5
6
7
8
public static <T> int indexOf(T[] arr, T elm){
for(int i=0; i<arr.length; i++){
if(arr[i].equals(elm)){
return i;
}
}
return -1;
}

这个方法就是一个泛型方法,类型参数为T,放在返回值前面,它可以如下调用:

1
indexOf(new Integer[]{1,3,5}, 10)

也可以如下调用:

1
indexOf(new String[]{"hello", "老马", "编程"}, "老马")

indexOf表示一个算法,在给定数组中寻找某个元素,这个算法的基本过程与具体数据类型没有什么关系,通过泛型,它可以方便地应用于各种数据类型,且由编译器保证类型安全。

与泛型类一样,类型参数可以有多个,以逗号分隔,比如:

1
2
3
4
public static <U, V> Pair<U, V> makePair(U first, V second){
Pair<U, V> pair = new Pair<>(first, second);
return pair;
}

与泛型类不同,调用方法时一般并不需要特意指定类型参数的实际类型,比如调用make-Pair:

1
makePair(1, "老马");

并不需要告诉编译器U的类型是Integer, V的类型是String, Java编译器可以自动推断出来。

8.1.4 泛型接口

接口也可以是泛型的,我们之前介绍过的Comparable和Comparator接口都是泛型的,它们的代码如下:

1
2
3
4
5
6
7
public interface Comparable<T> {
public int compareTo(T o);
}
public interface Comparator<T> {
int compare(T o1, T o2);
boolean equals(Object obj);
}

与前面一样,T是类型参数。实现接口时,应该指定具体的类型,比如,对Integer类,实现代码是:

1
2
3
4
5
6
public final class Integer extends Number implements Comparable<Integer>{
public int compareTo(Integer anotherInteger) {
return compare(this.value, anotherInteger.value);
}
//其他代码
}

通过implements Comparable<Integer>, Integer实现了Comparable接口,指定了实际类型参数为Integer,表示Integer只能与Integer对象进行比较。

再看Comparator的一个例子,String类内部一个Comparator的接口实现为:

1
2
3
4
5
6
private static class CaseInsensitiveComparator
implements Comparator<String> {
public int compare(String s1, String s2) {
//省略主体代码
}
}

这里,指定了实际类型参数为String。

8.1.5 类型参数的限定

在之前的介绍中,无论是泛型类、泛型方法还是泛型接口,关于类型参数,我们都知之甚少,只能把它当作Object,但Java支持限定这个参数的一个上界,也就是说,参数必须为给定的上界类型或其子类型,这个限定是通过extends关键字来表示的。这个上界可以是某个具体的类或者某个具体的接口,也可以是其他的类型参数,我们逐个介绍其应用。

1.上界为某个具体类

比如,上面的Pair类,可以定义一个子类NumberPair,限定两个类型参数必须为Number,代码如下:

1
2
3
4
5
6
public class NumberPair<U extends Number, V extends Number>
extends Pair<U, V> {
public NumberPair(U first, V second) {
super(first, second);
}
}

限定类型后,就可以使用该类型的方法了。比如,对于NumberPair类,first和second变量就可以当作Number进行处理了。比如可以定义一个求和方法,如下所示:

1
2
3
public double sum(){
return getFirst().doubleValue() +getSecond().doubleValue();
}

可以这么用:

1
2
NumberPair<Integer, Double> pair = new NumberPair<>(10, 12.34);
double sum = pair.sum();

限定类型后,如果类型使用错误,编译器会提示。指定边界后,类型擦除时就不会转换为Object了,而是会转换为它的边界类型,这也是容易理解的。

2.上界为某个接口

在泛型方法中,一种常见的场景是限定类型必须实现Comparable接口,我们来看代码:

1
2
3
4
5
6
7
8
9
public static <T extends Comparable> T max(T[] arr){
T max = arr[0];
for(int i=1; i<arr.length; i++){
if(arr[i].compareTo(max)>0){
max = arr[i];
}
}
return max;
}

max方法计算一个泛型数组中的最大值。计算最大值需要进行元素之间的比较,要求元素实现Comparable接口,所以给类型参数设置了一个上边界Comparable, T必须实现Comparable接口。

不过,直接这么编写代码,Java中会给一个警告信息,因为Comparable是一个泛型接口,它也需要一个类型参数,所以完整的方法声明应该是:

1
2
3
public static <T extends Comparable<T>> T max(T[] arr){
//主体代码
}

<T extends Comparable<T>>是一种令人费解的语法形式,这种形式称为递归类型限制,可以这么解读:T表示一种数据类型,必须实现Comparable接口,且必须可以与相同类型的元素进行比较。

3.上界为其他类型参数

上面的限定都是指定了一个明确的类或接口,Java支持一个类型参数以另一个类型参数作为上界。为什么需要这个呢?我们看个例子,给上面的DynamicArray类增加一个实例方法addAll,这个方法将参数容器中的所有元素都添加到当前容器里来,直觉上,代码可以如下书写:

1
2
3
4
5
public void addAll(DynamicArray<E> c) {
for(int i=0; i<c.size; i++){
add(c.get(i));
}
}

但这么写有一些局限性,我们看使用它的代码:

1
2
3
4
5
DynamicArray<Number> numbers = new DynamicArray<>();
DynamicArray<Integer> ints = new DynamicArray<>();
ints.add(100);
ints.add(34);
numbers.addAll(ints); //会提示编译错误

numbers是一个Number类型的容器,ints是一个Integer类型的容器,我们希望将ints添加到numbers中,因为Integer是Number的子类,应该说,这是一个合理的需求和操作。

但Java会在numbers.addAll(ints)这行代码上提示编译错误:addAll需要的参数类型为DynamicArray<Number>,而传递过来的参数类型为DynamicArray<Integer>,不适用。Integer是Number的子类,怎么会不适用呢?

事实就是这样,确实不适用,而且是很有道理的,假设适用,我们看下会发生什么。

1
2
3
DynamicArray<Integer> ints = new DynamicArray<>();
DynamicArray<Number> numbers = ints; //假设这行是合法的
numbers.add(new Double(12.34));

那最后一行就是合法的,这时,DynamicArray<Integer>中就会出现Double类型的值,而这显然破坏了Java泛型关于类型安全的保证。

我们强调一下,虽然Integer是Number的子类,但DynamicArray<Integer>并不是DynamicArray<Number>的子类,DynamicArray<Integer>的对象也不能赋值给Dynamic-Array<Number>的变量,这一点初看上去是违反直觉的,但这是事实,必须要理解这一点。

不过,我们的需求是合理的,将Integer添加到Number容器中并没有问题。这个问题可以通过类型限定来解决:

1
2
3
4
5
public <T extends E> void addAll(DynamicArray<T> c) {
for(int i=0; i<c.size; i++){
add(c.get(i));
}
}

E是DynamicArray的类型参数,T是addAll的类型参数,T的上界限定为E,这样,下面的代码就没有问题了:

1
2
3
4
5
DynamicArray<Number> numbers = new DynamicArray<>();
DynamicArray<Integer> ints = new DynamicArray<>();
ints.add(100);
ints.add(34);
numbers.addAll(ints);

对于这个例子,这种写法有点烦琐,8.2节中我们会介绍一种简化的方式。

8.1.6 小结

泛型是计算机程序中一种重要的思维方式,它将数据结构和算法与数据类型相分离,使得同一套数据结构和算法能够应用于各种数据类型,而且可以保证类型安全,提高可读性。在Java中,泛型广泛应用于各种容器类中,理解泛型是深刻理解容器的基础。

本节介绍了泛型的基本概念,包括泛型类、泛型方法和泛型接口,关于类型参数,我们介绍了多种上界限定,限定为某具体类、某具体接口或其他类型参数。泛型类最常见的用途是容器类,我们实现了一个简单的容器类DynamicArray,以解释泛型概念。

在Java中,泛型是通过类型擦除来实现的,它是Java编译器的概念,Java虚拟机运行时对泛型基本一无所知,理解这一点是很重要的,它有助于我们理解Java泛型的很多局限性。

关于泛型,Java中有一个通配符的概念,用得很广泛,但语法非常令人费解,而且容易混淆,8.2节中,我们力图对它进行清晰的剖析。