本文引用自pdai.tech《Java 集合框架》 作者:@pdai,
Map 接口 键值对的集合 (双列集合) ├———Hashtable 接口实现类, 同步, 线程安全 ├———HashMap 接口实现类 ,没有同步, 线程不安全 │—————–├ LinkedHashMap 双向链表和哈希表实现 │—————–└ WeakHashMap ├ ——–TreeMap 红黑树对所有的key进行排序 └———IdentifyHashMap
Map HashSet & HashMap Java7 HashMap 概述 之所以把HashSet
和HashMap
放在一起讲解,是因为二者在Java里有着相同的实现,前者仅仅是对后者做了一层包装,也就是说HashSet
里面有一个HashMap
(适配器模式)。因此本文将重点分析HashMap
。
HashMa
实现Ma
接口,即允许放入key
为null
的元素,也允许插入value
为null
的元素;除该类未实现同步外,其余跟Hashtable
大致相同;TreeMa
不同,该容器不保证元素顺序,根据需要该容器可能会对元素重新哈希,元素的顺序也会被重新打散,因此不同时间迭代同一个HashMap
的顺序可能会不同。 根据对冲突的处理方式不同,哈希表有两种实现方式,一种开放地址方式(Open addressing),另一种是冲突链表方式(Separate chaining with linked lists)。Java7 HashMap采用的是冲突链表方式
。
从上图容易看出,如果选择合适的哈希函数,put()
和get()
方法可以在常数时间内完成。但在对HashMap
进行迭代时,需要遍历整个table以及后面跟的冲突链表。因此对于迭代比较频繁的场景,不宜将HashMap
的初始大小设的过大。
有两个参数可以影响HashMap
的性能: 初始容量(inital capacity)和负载系数(load factor)。初始容量指定了初始table
的大小,负载系数用来指定自动扩容的临界值。当entry
的数量超过capacity
load_factor时,容器将自动扩容并重新哈希。对于插入元素较多的场景,将初始容量设大可以减少重新哈希的次数。
将对象放入到HashMap
或HashSet
中时,有两个方法需要特别关心: hashCode()
和equals()
。hashCode()
方法决定了对象会被放到哪个bucket
里,当多个对象的哈希值冲突时,equals()
方法决定了这些对象是否是“同一个对象”。所以,如果要将自定义的对象放入到HashMap
或HashSet
中,需要@Override hashCode()
和equals()
方法。
get() get(Object key)
方法根据指定的key
值返回对应的value
,该方法调用了getEntry(Object key)
得到相应的entry
,然后返回entry.getValue()
。因此getEntry()
是算法的核心。 算法思想是首先通过hash()
函数得到对应bucket
的下标,然后依次遍历冲突链表,通过key.equals(k)
方法来判断是否是要找的那个entry
。
上图中hash(k)&(table.length-1)
等价于hash(k)%table.length
,原因是HashMap
要求table.length
必须是2的指数,因此table.length-1
就是二进制低位全是1,跟hash(k)
相与会将哈希值的高位全抹掉,剩下的就是余数了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 final Entry<K,V> getEntry (Object key) { ...... int hash = (key == null ) ? 0 : hash(key); for (Entry<K,V> e = table[hash&(table.length-1 )]; e != null ; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } return null ; }
put() put(K key, V value)
方法是将指定的key, value
对添加到map
里。该方法首先会对map
做一次查找,看是否包含该元组,如果已经包含则直接返回,查找过程类似于getEntry()
方法;如果没有找到,则会通过addEntry(int hash, K key, V value, int bucketIndex)
方法插入新的entry
,插入方式为头插法
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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 = hash & (table.length-1 ); } Entry<K,V> e = table[bucketIndex]; table[bucketIndex] = new Entry<>(hash, key, value, e); size++; }
remove() remove(Object key)
的作用是删除key
值对应的entry
,该方法的具体逻辑是在removeEntryForKey(Object key)
里实现的。removeEntryForKey()
方法会首先找到key
值对应的entry
,然后删除该entry
(修改链表的相应引用)。查找过程跟getEntry()
过程类似。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 final Entry<K,V> removeEntryForKey (Object key) { ...... 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; return e; } prev = e; e = next; } return e; }
Java8 HashMap Java8 对 HashMap 进行了一些修改,最大的不同就是利用了红黑树,所以其由 数组+链表+红黑树
组成。
根据 Java7 HashMap 的介绍,我们知道,查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。
为了降低这部分的开销,在 Java8 中,当链表中的元素达到了 8 个时,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。
来一张图简单示意一下吧:
注意,上图是示意图,主要是描述结构,不会达到这个状态的,因为这么多数据的时候早就扩容了。
下面,我们还是用代码来介绍吧,个人感觉,Java8 的源码可读性要差一些,不过精简一些。
Java7 中使用 Entry 来代表每个 HashMap 中的数据节点,Java8 中使用 Node,基本没有区别,都是 key,value,hash 和 next 这四个属性,不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode。
我们根据数组元素中,第一个节点数据类型是 Node 还是 TreeNode 来判断该位置下是链表还是红黑树的。
put 过程分析 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 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 public V put (K key, V value) { return putVal(hash(key), key, value, false , true ); } final V putVal (int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0 ) n = (tab = resize()).length; if ((p = tab[i = (n - 1 ) & hash]) == null ) tab[i] = newNode(hash, key, value, null ); else { Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this , tab, hash, key, value); else { for (int binCount = 0 ; ; ++binCount) { if ((e = p.next) == null ) { p.next = newNode(hash, key, value, null ); if (binCount >= TREEIFY_THRESHOLD - 1 ) treeifyBin(tab, hash); break ; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break ; p = e; } } if (e != null ) { V oldValue = e.value; if (!onlyIfAbsent || oldValue == null ) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null ; }
和 Java7 稍微有点不一样的地方就是,Java7 是先扩容后插入新值的,Java8 先插值再扩容,不过这个不重要。
数组扩容 resize() 方法用于初始化数组或数组扩容,每次扩容后,容量为原来的 2 倍,并进行数据迁移。
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 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null ) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0 ; if (oldCap > 0 ) { if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1 ) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1 ; } else if (oldThr > 0 ) newCap = oldThr; else { newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int )(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } if (newThr == 0 ) { float ft = (float )newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float )MAXIMUM_CAPACITY ? (int )ft : Integer.MAX_VALUE); } threshold = newThr; Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; if (oldTab != null ) { for (int j = 0 ; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null ) { oldTab[j] = null ; if (e.next == null ) newTab[e.hash & (newCap - 1 )] = e; else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this , newTab, j, oldCap); else { Node<K,V> loHead = null , loTail = null ; Node<K,V> hiHead = null , hiTail = null ; Node<K,V> next; do { next = e.next; if ((e.hash & oldCap) == 0 ) { if (loTail == null ) loHead = e; else loTail.next = e; loTail = e; } else { if (hiTail == null ) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null ); if (loTail != null ) { loTail.next = null ; newTab[j] = loHead; } if (hiTail != null ) { hiTail.next = null ; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
get 过程分析 相对于 put 来说,get 真的太简单了。
计算 key 的 hash 值,根据 hash 值找到对应数组下标: hash & (length-1)
判断数组该位置处的元素是否刚好就是我们要找的,如果不是,走第三步
判断该元素类型是否是 TreeNode,如果是,用红黑树的方法取数据,如果不是,走第四步
遍历链表,直到找到相等(==或equals)的 key
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 public V get (Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; } final Node<K,V> getNode (int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1 ) & hash]) != null ) { if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k)))) return first; if ((e = first.next) != null ) { if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null ); } } return null ; }
HashSet 前面已经说过HashSet
是对HashMap
的简单包装,对HashSet
的函数调用都会转换成合适的HashMap
方法,因此HashSet
的实现非常简单,只有不到300行代码。这里不再赘述。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class HashSet <E > { ...... private transient HashMap<E,Object> map; private static final Object PRESENT = new Object(); public HashSet () { map = new HashMap<>(); } ...... public boolean add (E e) { return map.put(e, PRESENT)==null ; } ...... }
LinkedHashSet&Map 总体介绍 如果你已看过前面关于HashSet
和HashMap
,以及TreeSet
和TreeMap
的讲解,一定能够想到本文将要讲解的LinkedHashSet
和LinkedHashMap
其实也是一回事。LinkedHashSet
和LinkedHashMap
在Java里也有着相同的实现,前者仅仅是对后者做了一层包装,也就是说LinkedHashSet里面有一个LinkedHashMap(适配器模式)
。因此本文将重点分析LinkedHashMap
。
LinkedHashMap
实现了Map
接口,即允许放入key
为null
的元素,也允许插入value
为null
的元素。从名字上可以看出该容器是linked list
和HashMap
的混合体,也就是说它同时满足HashMap
和linked list
的某些特性。可将LinkedHashMap
看作采用 linked list 增强的 HashMap。
事实上LinkedHashMap
是HashMap
的直接子类,二者唯一的区别是
LinkedHashMap在
HashMap的基础上,采用双向链表(doubly-linked list)的形式将所有
entry连接起来,这样是为保证元素的迭代顺序跟插入顺序相同
。上图给出了LinkedHashMap
的结构图,主体部分跟HashMap
完全一样,多了header
指向双向链表的头部(是一个哑元),该双向链表的迭代顺序就是
entry的插入顺序
。
除了可以保迭代历顺序,这种结构还有一个好处 : 迭代LinkedHashMap
时不需要像HashMap
那样遍历整个table
,而只需要直接遍历header
指向的双向链表即可,也就是说
LinkedHashMap的迭代时间就只跟
entry的个数相关,而跟
table的大小无关。
有两个参数可以影响LinkedHashMap
的性能: 初始容量(inital capacity)和负载系数(load factor)。初始容量指定了初始table
的大小,负载系数用来指定自动扩容的临界值。当entry
的数量超过capacity
load_factor时,容器将自动扩容并重新哈希。对于插入元素较多的场景,将初始容量设大可以减少重新哈希的次数。
将对象放入到LinkedHashMap
或LinkedHashSet
中时,有两个方法需要特别关心: hashCode()
和equals()
。hashCode()
方法决定了对象会被放到哪个bucket
里,当多个对象的哈希值冲突时,equals()
方法决定了这些对象是否是“同一个对象”。所以,如果要将自定义的对象放入到LinkedHashMap
或LinkedHashSet
中,需要@Override hashCode()
和equals()
方法。
通过如下方式可以得到一个跟源Map
迭代顺序
一样的LinkedHashMap
:
1 2 3 4 5 void foo (Map m) { Map copy = new LinkedHashMap(m); ... }
出于性能原因,LinkedHashMap
是非同步的(not synchronized),如果需要在多线程环境使用,需要程序员手动同步;或者通过如下方式将LinkedHashMap
包装成(wrapped)同步的:
1 Map m = Collections.synchronizedMap(new LinkedHashMap(...));
方法剖析 get() get(Object key)
方法根据指定的key
值返回对应的value
。该方法跟HashMap.get()
方法的流程几乎完全一样。
put() put(K key, V value)
方法是将指定的key, value
对添加到map
里。该方法首先会对map
做一次查找,看是否包含该元组,如果已经包含则直接返回,查找过程类似于get()
方法;如果没有找到,则会通过addEntry(int hash, K key, V value, int bucketIndex)
方法插入新的entry
。
注意,这里的插入有两重含义
:
从table
的角度看,新的entry
需要插入到对应的bucket
里,当有哈希冲突时,采用头插法将新的entry
插入到冲突链表的头部。
从header
的角度看,新的entry
需要插入到双向链表的尾部。
addEntry()
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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 = hash & (table.length-1 ); } HashMap.Entry<K,V> old = table[bucketIndex]; Entry<K,V> e = new Entry<>(hash, key, value, old); table[bucketIndex] = e; e.addBefore(header); size++; }
上述代码中用到了addBefore()
方法将新entry e
插入到双向链表头引用header
的前面,这样e
就成为双向链表中的最后一个元素。addBefore()
的代码如下:
1 2 3 4 5 6 7 private void addBefore (Entry<K,V> existingEntry) { after = existingEntry; before = existingEntry.before; before.after = this ; after.before = this ; }
上述代码只是简单修改相关entry
的引用而已。
remove() remove(Object key)
的作用是删除key
值对应的entry
,该方法的具体逻辑是在removeEntryForKey(Object key)
里实现的。removeEntryForKey()
方法会首先找到key
值对应的entry
,然后删除该entry
(修改链表的相应引用)。查找过程跟get()
方法类似。
注意,这里的删除也有两重含义
:
从table
的角度看,需要将该entry
从对应的bucket
里删除,如果对应的冲突链表不空,需要修改冲突链表的相应引用。
从header
的角度来看,需要将该entry
从双向链表中删除,同时修改链表中前面以及后面元素的相应引用。
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 27 final Entry<K,V> removeEntryForKey (Object key) { ...... 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.before.after = e.after; e.after.before = e.before; return e; } prev = e; e = next; } return e; }
LinkedHashSet 前面已经说过LinkedHashSet
是对LinkedHashMap
的简单包装,对LinkedHashSet
的函数调用都会转换成合适的LinkedHashMap
方法,因此LinkedHashSet
的实现非常简单,这里不再赘述。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public class LinkedHashSet <E > extends HashSet <E > implements Set <E >, Cloneable , java .io .Serializable { ...... public LinkedHashSet (int initialCapacity, float loadFactor) { map = new LinkedHashMap<>(initialCapacity, loadFactor); } ...... public boolean add (E e) { return map.put(e, PRESENT)==null ; } ...... }
LinkedHashMap经典用法 LinkedHashMap
除了可以保证迭代顺序外,还有一个非常有用的用法: 可以轻松实现一个采用了FIFO替换策略的缓存。具体说来,LinkedHashMap有一个子类方法protected boolean removeEldestEntry(Map.Entry<K,V> eldest)
,该方法的作用是告诉Map是否要删除“最老”的Entry,所谓最老就是当前Map中最早插入的Entry,如果该方法返回true
,最老的那个元素就会被删除。在每次插入新元素的之后LinkedHashMap会自动询问removeEldestEntry()是否要删除最老的元素。这样只需要在子类中重载该方法,当元素个数超过一定数量时让removeEldestEntry()返回true,就能够实现一个固定大小的FIFO策略的缓存。示例代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 class FIFOCache <K , V > extends LinkedHashMap <K , V > { private final int cacheSize; public FIFOCache (int cacheSize) { this .cacheSize = cacheSize; } @Override protected boolean removeEldestEntry (Map.Entry<K,V> eldest) { return size() > cacheSize; } }
TreeSet & TreeMap 总体介绍 之所以把TreeSet
和TreeMap
放在一起讲解,是因为二者在Java里有着相同的实现,前者仅仅是对后者做了一层包装,也就是说TreeSet
里面有一个TreeMap
(适配器模式)。因此本文将重点分析
TreeMap`。
Java TreeMap
实现了SortedMap
接口,也就是说会按照key
的大小顺序对Map
中的元素进行排序,key
大小的评判可以通过其本身的自然顺序(natural ordering),也可以通过构造时传入的比较器(Comparator)。
TreeMap底层通过红黑树(Red-Black tree)实现 ,也就意味着containsKey()
, get()
, put()
, remove()
都有着log(n)
的时间复杂度。其具体算法实现参照了《算法导论》。
出于性能原因,TreeMap
是非同步的(not synchronized),如果需要在多线程环境使用,需要程序员手动同步;或者通过如下方式将TreeMap
包装成(wrapped)同步的:
1 SortedMap m = Collections.synchronizedSortedMap(new TreeMap(...));
红黑树是一种近似平衡的二叉查找树,它能够确保任何一个节点的左右子树的高度差不会超过二者中较低那个的一陪 。具体来说,红黑树是满足如下条件的二叉查找树(binary search tree):
每个节点要么是红色,要么是黑色。
根节点必须是黑色
红色节点不能连续(也即是,红色节点的孩子和父亲都不能是红色)。
对于每个节点,从该点至null
(树尾端)的任何路径,都含有相同个数的黑色节点。
在树的结构发生改变时(插入或者删除操作),往往会破坏上述条件3或条件4,需要通过调整使得查找树重新满足红黑树的约束条件。
预备知识 前文说到当查找树的结构发生改变时,红黑树的约束条件可能被破坏,需要通过调整使得查找树重新满足红黑树的约束条件。调整可以分为两类: 一类是颜色调整,即改变某个节点的颜色;另一类是结构调整,集改变检索树的结构关系。结构调整过程包含两个基本操作** : 左旋(Rotate Left),右旋(RotateRight)**。
左旋 左旋的过程是将x
的右子树绕x
逆时针旋转,使得x
的右子树成为x
的父亲,同时修改相关节点的引用。旋转之后,二叉查找树的属性仍然满足。
TreeMap
中左旋代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 private void rotateLeft (Entry<K,V> p) { if (p != null ) { Entry<K,V> r = p.right; p.right = r.left; if (r.left != null ) r.left.parent = p; r.parent = p.parent; if (p.parent == null ) root = r; else if (p.parent.left == p) p.parent.left = r; else p.parent.right = r; r.left = p; p.parent = r; } }
右旋 右旋的过程是将x
的左子树绕x
顺时针旋转,使得x
的左子树成为x
的父亲,同时修改相关节点的引用。旋转之后,二叉查找树的属性仍然满足。
TreeMap
中右旋代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 private void rotateRight (Entry<K,V> p) { if (p != null ) { Entry<K,V> l = p.left; p.left = l.right; if (l.right != null ) l.right.parent = p; l.parent = p.parent; if (p.parent == null ) root = l; else if (p.parent.right == p) p.parent.right = l; else p.parent.left = l; l.right = p; p.parent = l; } }
寻找节点后继 对于一棵二叉查找树,给定节点t,其后继(树中比大于t的最小的那个元素)可以通过如下方式找到:
t的右子树不空,则t的后继是其右子树中最小的那个元素。
t的右孩子为空,则t的后继是其第一个向左走的祖先。
后继节点在红黑树的删除操作中将会用到。
TreeMap
中寻找节点后继的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 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; } }
方法剖析 get() get(Object key)
方法根据指定的key
值返回对应的value
,该方法调用了getEntry(Object key)
得到相应的entry
,然后返回entry.value
。因此getEntry()
是算法的核心。算法思想是根据key
的自然顺序(或者比较器顺序)对二叉查找树进行查找,直到找到满足k.compareTo(p.key) == 0
的entry
。
具体代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 final Entry<K,V> getEntry (Object 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 ; }
put() put(K key, V value)
方法是将指定的key
, value
对添加到map
里。该方法首先会对map
做一次查找,看是否包含该元组,如果已经包含则直接返回,查找过程类似于getEntry()
方法;如果没有找到则会在红黑树中插入新的entry
,如果插入之后破坏了红黑树的约束条件,还需要进行调整(旋转,改变某些节点的颜色)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public V put (K key, V value) { ...... int cmp; Entry<K,V> parent; 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 ); Entry<K,V> e = new Entry<>(key, value, parent); if (cmp < 0 ) parent.left = e; else parent.right = e; fixAfterInsertion(e); size++; return null ; }
上述代码的插入部分并不难理解: 首先在红黑树上找到合适的位置,然后创建新的entry
并插入(当然,新插入的节点一定是树的叶子)。难点是调整函数fixAfterInsertion()
,前面已经说过,调整往往需要1.改变某些节点的颜色,2.对某些节点进行旋转。
调整函数fixAfterInsertion()
的具体代码如下,其中用到了上文中提到的rotateLeft()
和rotateRight()
函数。通过代码我们能够看到,情况2其实是落在情况3内的。情况4~情况6跟前三种情况是对称的,因此图解中并没有画出后三种情况,读者可以参考代码自行理解。
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 34 35 36 37 38 39 40 41 42 private void fixAfterInsertion (Entry<K,V> x) { x.color = RED; while (x != null && x != root && x.parent.color == RED) { if (parentOf(x) == leftOf(parentOf(parentOf(x)))) { Entry<K,V> y = rightOf(parentOf(parentOf(x))); if (colorOf(y) == RED) { setColor(parentOf(x), BLACK); setColor(y, BLACK); setColor(parentOf(parentOf(x)), RED); x = parentOf(parentOf(x)); } else { if (x == rightOf(parentOf(x))) { x = parentOf(x); rotateLeft(x); } setColor(parentOf(x), BLACK); setColor(parentOf(parentOf(x)), RED); rotateRight(parentOf(parentOf(x))); } } else { Entry<K,V> y = leftOf(parentOf(parentOf(x))); if (colorOf(y) == RED) { setColor(parentOf(x), BLACK); setColor(y, BLACK); setColor(parentOf(parentOf(x)), RED); x = parentOf(parentOf(x)); } else { if (x == leftOf(parentOf(x))) { x = parentOf(x); rotateRight(x); } setColor(parentOf(x), BLACK); setColor(parentOf(parentOf(x)), RED); rotateLeft(parentOf(parentOf(x))); } } } root.color = BLACK; }
remove() remove(Object key)
的作用是删除key
值对应的entry
,该方法首先通过上文中提到的getEntry(Object key)
方法找到key
值对应的entry
,然后调用deleteEntry(Entry<K,V> entry)
删除对应的entry
。由于删除操作会改变红黑树的结构,有可能破坏红黑树的约束条件,因此有可能要进行调整。
getEntry()
函数前面已经讲解过,这里重点放deleteEntry()
上,该函数删除指定的entry
并在红黑树的约束被破坏时进行调用fixAfterDeletion(Entry<K,V> x)
进行调整。
由于红黑树是一棵增强版的二叉查找树,红黑树的删除操作跟普通二叉查找树的删除操作也就非常相似,唯一的区别是红黑树在节点删除之后可能需要进行调整 。现在考虑一棵普通二叉查找树的删除过程,可以简单分为两种情况:
删除点p的左右子树都为空,或者只有一棵子树非空。
删除点p的左右子树都非空。
对于上述情况1,处理起来比较简单,直接将p删除(左右子树都为空时),或者用非空子树替代p(只有一棵子树非空时);对于情况2,可以用p的后继s(树中大于x的最小的那个元素)代替p,然后使用情况1删除s(此时s一定满足情况1.可以画画看)。
基于以上逻辑,红黑树的节点删除函数deleteEntry()
代码如下:
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 34 35 36 37 38 private void deleteEntry (Entry<K,V> p) { modCount++; size--; if (p.left != null && p.right != null ) { Entry<K,V> s = successor(p); p.key = s.key; p.value = s.value; p = s; } Entry<K,V> replacement = (p.left != null ? p.left : p.right); if (replacement != null ) { 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; p.left = p.right = p.parent = null ; if (p.color == BLACK) fixAfterDeletion(replacement); } else if (p.parent == null ) { root = null ; } else { 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 ; } } }
上述代码中占据大量代码行的,是用来修改父子节点间引用关系的代码,其逻辑并不难理解。下面着重讲解删除后调整函数fixAfterDeletion()
。首先请思考一下,删除了哪些点才会导致调整?只有删除点是BLACK的时候,才会触发调整函数 ,因为删除RED节点不会破坏红黑树的任何约束,而删除BLACK节点会破坏规则4。
跟上文中讲过的fixAfterInsertion()
函数一样,这里也要分成若干种情况。记住,无论有多少情况,具体的调整操作只有两种: 1.改变某些节点的颜色,2.对某些节点进行旋转。
上述图解的总体思想是: 将情况1首先转换成情况2,或者转换成情况3和情况4。当然,该图解并不意味着调整过程一定是从情况1开始。通过后续代码我们还会发现几个有趣的规则: a).如果是由情况1之后紧接着进入的情况2,那么情况2之后一定会退出循环(因为x为红色);b).一旦进入情况3和情况4,一定会退出循环(因为x为root)。
删除后调整函数fixAfterDeletion()
的具体代码如下,其中用到了上文中提到的rotateLeft()
和rotateRight()
函数。通过代码我们能够看到,情况3其实是落在情况4内的。情况5~情况8跟前四种情况是对称的,因此图解中并没有画出后四种情况,读者可以参考代码自行理解。
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 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 private void fixAfterDeletion (Entry<K,V> x) { while (x != root && colorOf(x) == BLACK) { if (x == leftOf(parentOf(x))) { Entry<K,V> sib = rightOf(parentOf(x)); if (colorOf(sib) == RED) { setColor(sib, BLACK); setColor(parentOf(x), RED); rotateLeft(parentOf(x)); sib = rightOf(parentOf(x)); } if (colorOf(leftOf(sib)) == BLACK && colorOf(rightOf(sib)) == BLACK) { setColor(sib, RED); x = parentOf(x); } else { if (colorOf(rightOf(sib)) == BLACK) { setColor(leftOf(sib), BLACK); setColor(sib, RED); rotateRight(sib); sib = rightOf(parentOf(x)); } setColor(sib, colorOf(parentOf(x))); setColor(parentOf(x), BLACK); setColor(rightOf(sib), BLACK); rotateLeft(parentOf(x)); x = root; } } else { Entry<K,V> sib = leftOf(parentOf(x)); if (colorOf(sib) == RED) { setColor(sib, BLACK); setColor(parentOf(x), RED); rotateRight(parentOf(x)); sib = leftOf(parentOf(x)); } if (colorOf(rightOf(sib)) == BLACK && colorOf(leftOf(sib)) == BLACK) { setColor(sib, RED); x = parentOf(x); } else { if (colorOf(leftOf(sib)) == BLACK) { setColor(rightOf(sib), BLACK); setColor(sib, RED); rotateLeft(sib); sib = leftOf(parentOf(x)); } setColor(sib, colorOf(parentOf(x))); setColor(parentOf(x), BLACK); setColor(leftOf(sib), BLACK); rotateRight(parentOf(x)); x = root; } } } setColor(x, BLACK); }
TreeSet 前面已经说过TreeSet
是对TreeMap
的简单包装,对TreeSet
的函数调用都会转换成合适的TreeMap
方法,因此TreeSet
的实现非常简单。这里不再赘述。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public class TreeSet <E > extends AbstractSet <E > implements NavigableSet <E >, Cloneable , java .io .Serializable { ...... private transient NavigableMap<E,Object> m; private static final Object PRESENT = new Object(); public TreeSet () { this .m = new TreeMap<E,Object>(); } ...... public boolean add (E e) { return m.put(e, PRESENT)==null ; } ...... }
WeakHashMap JDK版本
JDK 1.8.0_110
总体介绍 在Java集合框架系列文章的最后,笔者打算介绍一个特殊的成员: WeakHashMap
,从名字可以看出它是某种 Map
。它的特殊之处在于 WeakHashMap
里的entry
可能会被GC自动删除,即使程序员没有调用remove()
或者clear()
方法。
更直观的说,当使用 WeakHashMap
时,即使没有显示的添加或删除任何元素,也可能发生如下情况:
调用两次size()
方法返回不同的值;
两次调用isEmpty()
方法,第一次返回false
,第二次返回true
;
两次调用containsKey()
方法,第一次返回true
,第二次返回false
,尽管两次使用的是同一个key
;
两次调用get()
方法,第一次返回一个value
,第二次返回null
,尽管两次使用的是同一个对象。
遇到这么奇葩的现象,你是不是觉得使用者一定会疯掉? 其实不然,WeakHashMap
的这个特点特别适用于需要缓存的场景。在缓存场景下,由于内存是有限的,不能缓存所有对象;对象缓存命中可以提高系统效率,但缓存MISS也不会造成错误,因为可以通过计算重新得到。
要明白 WeakHashMap
的工作原理,还需要引入一个概念 : 弱引用(WeakReference)
。我们都知道Java中内存是通过GC自动管理的,GC会在程序运行过程中自动判断哪些对象是可以被回收的,并在合适的时机进行内存释放。GC判断某个对象是否可被回收的依据是,是否有有效的引用指向该对象
。如果没有有效引用指向该对象(基本意味着不存在访问该对象的方式),那么该对象就是可回收的。这里的有效引用
并不包括弱引用
。也就是说,虽然弱引用可以用来访问对象,但进行垃圾回收时弱引用并不会被考虑在内,仅有弱引用指向的对象仍然会被GC回收
。
WeakHashMap
内部是通过弱引用来管理entry
的,弱引用的特性对应到 WeakHashMap
上意味着什么呢?将一对key, value
放入到 WeakHashMap
里并不能避免该key
值被GC回收,除非在 WeakHashMap
之外还有对该key
的强引用。
关于强引用,弱引用等概念以后再具体讲解,这里只需要知道Java中引用也是分种类的,并且不同种类的引用对GC的影响不同就够了。
具体实现 WeakHashMap的存储结构类似于HashSet & HashMap ,这里不再赘述。
关于强弱引用的管理方式,博主将会另开专题单独讲解。
Weak HashSet? 如果你看过前几篇关于 Map
和 Set
的讲解,一定会问: 既然有 WeakHashMap
,是否有 WeekHashSet
呢? 答案是没有:( 。不过Java Collections
工具类给出了解决方案,Collections.newSetFromMap(Map<E,Boolean> map)
方法可以将任何 Map
包装成一个Set
。通过如下方式可以快速得到一个 Weak HashSet
:
1 2 3 4 Set<Object> weakHashSet = Collections.newSetFromMap( new WeakHashMap<Object, Boolean>());
不出你所料,newSetFromMap()
方法只是对传入的 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 26 27 28 29 30 31 32 33 34 public static <E> Set<E> newSetFromMap (Map<E, Boolean> map) { return new SetFromMap<>(map); } private static class SetFromMap <E > extends AbstractSet <E > implements Set <E >, Serializable { private final Map<E, Boolean> m; private transient Set<E> s; SetFromMap(Map<E, Boolean> map) { if (!map.isEmpty()) throw new IllegalArgumentException("Map is non-empty" ); m = map; s = map.keySet(); } public void clear () { m.clear(); } public int size () { return m.size(); } public boolean isEmpty () { return m.isEmpty(); } public boolean contains (Object o) { return m.containsKey(o); } public boolean remove (Object o) { return m.remove(o) != null ; } public boolean add (E e) { return m.put(e, Boolean.TRUE) == null ; } public Iterator<E> iterator () { return s.iterator(); } public Object[] toArray() { return s.toArray(); } public <T> T[] toArray(T[] a) { return s.toArray(a); } public String toString () { return s.toString(); } public int hashCode () { return s.hashCode(); } public boolean equals (Object o) { return o == this || s.equals(o); } public boolean containsAll (Collection<?> c) {return s.containsAll(c);} public boolean removeAll (Collection<?> c) {return s.removeAll(c);} public boolean retainAll (Collection<?> c) {return s.retainAll(c);} ...... }