前言
我们知道在Java 8中对于HashMap引入了红黑树从而提高操作性能,由于在上一节我们已经通过图解方式分析了红黑树原理,所以在接下来我们将更多精力投入到解析原理而不是算法本身,HashMap在Java中是使用比较频繁的键值对数据类型,所以我们非常有必要详细去分析背后的具体实现原理,无论是C#还是Java原理解析,从不打算一行行代码解释,我认为最重要的是设计思路,重要的地方可能会多啰嗦两句。
HashMap原理分析
我们由浅入深,循序渐进,首先了解下在HashMap中定义的几个属性,稍后会进一步讲解为何要定义这个值,难道是靠拍脑袋吗。
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable { //默认初始化容量 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //最大容量 static final int MAXIMUM_CAPACITY = 1 << 30; //默认负载因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; //链表转红黑树阈值 static final int TREEIFY_THRESHOLD = 8; //取消阈值 static final int UNTREEIFY_THRESHOLD = 6; //最小树容量 static final int MIN_TREEIFY_CAPACITY = 64; }
构造函数分析
public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; }
当实例化HashMap时,我们不指定任何参数,此时定义负载因子为0.75f
public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); }
当实例化HashMap时,我们也可以指定初始化容量,此时默认负载因子仍为0.75f。
public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); }
当实例化HashMap时,我们既指定默认初始化容量,也可指定负载因子,很显然初始化容量不能小于0,否则抛出异常,若初始化容量超过定义的最大容量,则将定义的最大容量赋值与初始化容量,对于负载因子不能小于或等于0,否则抛出异常。接下来根据提供的初始化容量设置阈值,我们接下来看看上述tableSizeFor方法实现。
static final int tableSizeFor(int cap) { int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }
这个方法是在做什么处理呢?阈值 = 2的次幂大于初始化容量的最小值。 到学习java目前为止,我们接触到了模运算【%】、按位左移【<<】、按位右移【>>】,这里我们将学习到按位或运算【|】、无符号按位右移【>>>】。按位或运算就是二进制有1,结果就为1,否则为0,而无符号按位右移只是高位无正负之分而已。不要看到上述【n | = n >>> 1】一脸懵,实际上就是【n = n | n >>> 1】,和我们正常进行四则运算一个道理,只不过是逻辑运算和位运算符号不同而已罢了。我们通过如下例子来说明上述结论,假设初始化容量为5,接下来我们进行如上运算。
0000 0000 0000 0000 0000 0000 0000 0101 cap = 5
0000 0000 0000 0000 0000 0000 0000 0100 n = cap - 1
0000 0000 0000 0000 0000 0000 0000 0010 n >>> 1
0000 0000 0000 0000 0000 0000 0000 0110 n |= n >>> 1
0000 0000 0000 0000 0000 0000 0000 0001 n >>> 2
0000 0000 0000 0000 0000 0000 0000 0111 n |= n >>> 2
0000 0000 0000 0000 0000 0000 0000 0000 n >>> 4
0000 0000 0000 0000 0000 0000 0000 0111 n |= n >>> 4
0000 0000 0000 0000 0000 0000 0000 0000 n >>> 8
0000 0000 0000 0000 0000 0000 0000 0111 n |= n >>> 8
0000 0000 0000 0000 0000 0000 0000 0000 n >>> 16
0000 0000 0000 0000 0000 0000 0000 0111 n |= n >>> 16
如上最终算出来结果为7,然后加上最初计算时减去的1,所以对于初始化容量为5的最小2次幂为8,也就是阈值为8,要是初始化容量为8,那么阈值也为8。接下来到了我们的重点插入操作。
插入原理分析
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
上述插入操作简短一行代码,只不过是调用了putVal方法,但是我们注意到首先计算了键的哈希值,我们看看该方法实现。
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
直接理解方法大意是:若传入的键为空,则哈希值为0,否则直接调用键的本地hashCode方法获取哈希值,然后对其按位向右移16位,最后进行按位异或(只要不同结果就为1)操作。好像还是不懂,我们暂且搁置一下,我们继续看看插入方法具体实现。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; // 步骤【1】:tab为空扩容 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 步骤【2】:计算index,并对null做处理 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; // 步骤【3】:键存在,直接覆盖值 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; // 步骤【4】:若为红黑树 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { // 步骤【5】:若为链表 for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); //若链表长度大于8则转换为红黑树进行处理 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; // 步骤【6】:超过最大容量进行扩容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
我们首先来看来步骤【2】,我们待会再来看步骤【1】实现,我们首先摘抄上述获取键的索引逻辑
if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null);
上述通过计算出键的哈希值并与数组的长度按位与运算,散列算法直接决定键的存储是否分布均匀,否则会发生冲突或碰撞,严重影响性能,所以上述【 (n - 1) & hash 】是发生碰撞的关键所在,难道我们直接调用键的本地hashCode方法获取哈希值不就可以了吗,肯定是不可以的,我们来看一个例子。假设我们通过调用本地的hashCode方法,获取几个键的哈希值为31、63、95,同时默认初始化容量为16。然后调用(n-1 & hash),计算如下:
0000 0000 0000 0000 0000 0000 0001 1111 hash = 31 0000 0000 0000 0000 0000 0000 0000 1111 n - 1 0000 0000 0000 0000 0000 0000 0000 1111 => 15 0000 0000 0000 0000 0000 0000 0011 1111 hash = 63 0000 0000 0000 0000 0000 0000 0000 1111 n - 1 0000 0000 0000 0000 0000 0000 0000 1111 => 15 0000 0000 0000 0000 0000 0000 0111 1111 hash = 95 0000 0000 0000 0000 0000 0000 0000 1111 n - 1 0000 0000 0000 0000 0000 0000 0000 1111 => 15
因为(2 ^ n-1)的低位始终都是1,再按照按位运算(0-1始终为0)所有最终结果都有1111,这就是为什么返回相同索引的原因,因此,尽管我们具有不同的哈希值,但结果却是存储到哈希桶数组相同索引位置。所以为了解决低位根本就没有参与到运算中的问题:通过调用上述hash方法,按位右移16位并异或,解决因低位没有参与运算导致冲突,提高性能。我们继续回到上述步骤【1】,当数组为空,内部是如何进行扩容的呢?我们来看看resize方法实现。
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; ...... }
由上可知:当实例化HashMap并无参时,此时默认初始化容量为16,默认阈值为12,负载因子为0.75f,当指定参数(初始化容量比如为5),此时初始化容量为8,阈值为8,负载因子为0.75f。否则也指定了负载因子,则以指定负载因子为准。同时当超过容量时,扩容后的容量为原容量的2倍。到这里我们发现一个问题:hashTable中的容量可为奇或偶数,而HashMap中的容量永远都为2的次幂即偶数,为何要这样设计呢?
int index = (n - 1) & hash;
如上为HashMap计算在哈希桶数组中的索引位置,若HashMap中的容量不为2的次幂,此时通过按与运算,索引只能为16或0,这也就意味着将发生更多冲突,也将导致性能很差,本可通过O(1)进行检索,现在需要O(log n),因为发生冲突时,给定存储桶中的所有节点都将存储在红黑树中,若容量为2的次幂,此时按与运算符将和hashTable中计算索引存储位置的方式等同,如下:
int index = (hash & 0x7FFFFFFF) % tab.length;
按照HashMap计算索引的方式,当我们从2的次幂中减去1时,得到的是一个二进制末位全为1的数字,例如默认初始化容量为16,如果从中减去1,则得到15,其二进制表示形式是1111,此时如果对1111进行任意数字的按位与运算,我们将得到整数的最后4位,换句话说,等价于对16取模,但是除法运算通常是昂贵的运算,也就是说按位运算比取模运算效率更高。到此我们知道HashMap中容量为2的次幂的原因在于:哈希桶数组索引存储采取按位运算而非取模运算,因其效率比取模运算更高。进行完上述扩容后容量、阈值重新计算后,接下来则需要对哈希桶数组重新哈希(rehash),请继续往下看。
影响HashMap性能因素分析
在讲解上述重新哈希之前,我们需要重头开始进行叙述,直到这里,我们知道HashMap默认初始化容量为16,假如我们有16个元素放入到HashMap中,如果实现了很好的散列算法,那么在哈希桶数组中将在每个存储桶中放入1个元素,在此种情况下,查找元素仅需要1次,如果是HashMap中有256元素,如果实现了很好的散列算法,那么在哈希桶数组中将在每个存储桶中放入16个元素,同理,在此种情况下,查找任何一个元素,最多也只需要16次,到这里我们可以知道,如果HashMap中的哈希桶数组存储的元素增加一倍或几倍,那么在每个存储桶中查找元素的最大时间成本并不会很大,但是,如果持续维持默认容量即16不变,如果每个存储桶中有大量元素,此时,HashMap的映射性能将开始下降。比如现在HashMap中有一千六百万条数据,如果实现了很好的散列算法,将在每个存储桶中分配一百万个元素,也就是说,查找任意元素,最多需要查找一百万次。很显然,我们将存储的元素放大后,将严重影响HashMap性能,那么对此我们有何解决方案呢?让我们回到最初的话题,因为默认存储桶大小为16且当存储的元素条目少时,HashMap性能并未有什么改变,但是当存储桶的数量持续增加时,将影响HashMap性能,这是由于什么原因导致的呢?主要是我们一直在维持容量固定不变,我们却一直增加HashMap中哈希桶数组中存储元素的大小,这完全影响到了时间复杂度。如果我们增加存储桶大小,则当每个存储桶中的总项开始增加时,我们将能够使得每个存储桶中的元素个数保持恒定,并对于查询和插入操作保持O(1)的时间复杂度。那么增加存储桶大小也就是容量的时机是什么时候呢?存储桶的大小(容量)由负载因子决定,负载因子是一种度量,它决定着何时增加存储桶的大小(容量),以便针对查询和插入操作保持O(1)的时间复杂度,因此,何时增加容量的大小取决于乘积(初始化容量 * 负载因子),所以容量和负载因子是影响HashMap性能的根本因素。我们知道默认负载因子是0.75,也就是百分之75,所以增加容量大小的值为(16 * 0.75)= 12,这个值我们称之为阈值,也就意味着,在HashMap中存储直到第12个键值对时,都将保持容量为16,等到第13个键值对插入到HashMap中时,其容量大小将由默认的16变为( 16 * 2)= 32。通过上述计算增加容量大小即阈值的公式,我们从反向角度思考:负载因子比率 = 哈希桶数组中元素个数 / 哈希桶数组桶大小,举个栗子,若默认桶大小为16,当插入第一个元素时,其负载因子比率 = 1 / 16 = 0.0625 > 0.75 吗?若为否无需增加容量,当插入第13个元素时,其负载因子比率 = 13 / 16 = 0.81 > 0.75吗?若为是则需增加容量。讲完这里,我们再来看看重哈希,在讲解为什么要进行重哈希之前,我们需要了解重哈希的概念:重新计算已存储在哈希桶数组中元素的哈希码的过程,当达到阈值时,将其移动到另外一个更大的哈希桶数组中。当存储到哈希桶数组中的元素超过了负载因子的限制时,此时将容量增加一倍并进行重哈希。那么为何要进行重哈希呢?因为容量增加一倍后,如若不处理已存在于哈希桶数组中键值对,那么将容量增加一倍则没有任何意义,同时呢,也是为了保持每一个存储桶中元素保持均匀分布,因为只有将元素均匀的分布到每一个存储桶中才能实现O(1)时间复杂度。接下来我们继续进行重哈希源码分析
重哈希源码分析
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 { // preserve order 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; }
从整体上分析扩容后进行重哈希分为三种情况: ① 哈希桶数组元素为非链表即数组中只存在一个元素 ②哈希桶数组元素为红黑树进行转换 ③哈希桶数组元素为链表。关于①②情况就不用我再叙述,我们接下来重点看看对链表的优化处理。也就是如下这一段代码:
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; }
看到上述代码我们不禁疑惑为貌似声明了两个链表,一个低位链表(lower),一个高位链表(high),我们暂且不是很理解哈,接下来我们进入 do {} while () 循环,然后重点来了这么一句 e.hash & oldCap == 0 ,这是干啥玩意,根据此行代码来分别进入到低位链表和高位链表。好了,我们通过一例子就很好理解了:假设按照默认初始化容量为16,然后我们插入一个为21的元素,根据我们上面的叙述,首先计算出哈希值,然后计算出索引位置,为了便于很直观的理解,我们还是一步步来计算下。
static final int hash(21) { int h; return (21 == null) ? 0 : (h = 21.hashCode()) ^ (h >>> 16); }
调用如上hash方法计算出键21的值仍为21,接下来通过如下按与运算计算出存储到哈希桶数组中的索引位置。
i = (16 - 1) & 21
最终我们计算其索引位置即i等于5,因为初始化容量为16,此时阈值为12,当插入第13个元素开始进行扩容,容量变为32,此时若再次按照上述方式计算索引存储位置为 i = (32 - 1) & 21 ,结果为21。从这里我们得出结论:当容量为16时,插入元素21的索引位置为5,而扩容后容量为32,此时插入元素21的索引位置为21,也就是说【扩容后的新的索引 = 原有索引 + 原有容量】。同理,若插入元素为5,容量为16,那么索引位置为5,若扩容后容量为32,索引位置同样也为5,也就是说【扩容后的索引 = 原有索引】。因为容量始终为原有容量的2倍(比如16到32即从0000 0000 0000 0000 0000 0000 0001 0000 =》0000 0000 0000 0000 0000 0000 0010 0000)从按位考虑则是高位由0变为1,也就是说我们通过计算出元素的哈希值与原有容量按位与运算,若结果等于0,则扩容后索引等于原索引,否则等于原有索引加上原有容量,也就是通过哈希值与原容量按位与运算即 e.hash & oldCap == 0 来判断新索引位置是否发生了改变,说的更加通俗易懂一点,比如(5 & 16 = 0),那么元素5扩容后的索引位置为【新索引 = 原索引 + 0】,同理比如(21 & 16 = 16),那么元素21的扩容后的索引位置为【新索引 = 原索引 + 16】。由于容量始终为2次幂,如此而节省了之前版本而重新计算哈希的时间从而达到优化。到这里,我们可以进一步总结出容量始终为2次幂的意义:①哈希桶数组索引存储采取按位运算而非取模运算,因其效率比取模运算更高 ②优化重新计算哈希而节省时间。最终将索引不变链表即低位链表和索引改变链表即高位链表分别放入扩容后新的哈希桶数组中,最终重新哈希过程到此结束。接下来我们分析将元素如何放入到红黑树中的呢?
将值插入红黑树保持树平衡
// 步骤【4】:若为红黑树 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
然后我们看看上述将值放入到红黑树中具体方法实现,如下:
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab, int h, K k, V v) { Class<?> kc = null; boolean searched = false; TreeNode<K,V> root = (parent != null) ? root() : this; for (TreeNode<K,V> p = root;;) { int dir, ph; K pk; if ((ph = p.hash) > h) dir = -1; else if (ph < h) dir = 1; else if ((pk = p.key) == k || (k != null && k.equals(pk))) return p; else if ((kc == null && (kc = comparableClassFor(k)) == null) || (dir = compareComparables(kc, k, pk)) == 0) { if (!searched) { TreeNode<K,V> q, ch; searched = true; if (((ch = p.left) != null && (q = ch.find(h, k, kc)) != null) || ((ch = p.right) != null && (q = ch.find(h, k, kc)) != null)) return q; } dir = tieBreakOrder(k, pk); } TreeNode<K,V> xp = p; if ((p = (dir <= 0) ? p.left : p.right) == null) { Node<K,V> xpn = xp.next; TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn); if (dir <= 0) xp.left = x; else xp.right = x; xp.next = x; x.parent = x.prev = xp; if (xpn != null) ((TreeNode<K,V>)xpn).prev = x; moveRootToFront(tab, balanceInsertion(root, x)); return null; } } }
我们需要思考的是:(1)待插入元素在红黑树中的具体位置是在哪里呢?(2)找到插入具体位置后,然后需要知道的到底是左边还是右边呢?。我们按照正常思路理解的话还是非常容易想明白,我们从根节点开始遍历树,通过每一个节点的哈希值与待插入节点哈希值比较,若待插入元素位于其父节点的左边,则看父节点的左边是否已存在元素,如果不存在则将其父节点的左边节点留给待插入节点,同理对于父节点的右边也是如此,但是如果父节点的左边和右边都有其引用,那么就继续遍历,直到找到待插入节点的具体位置。这就是我们在写代码或进行代码测试时的一般思路,但是我们还要考虑边界问题,否则说明考虑不完全,针对待插入元素插入到红黑树中的边界问题是什么呢?当遍历的节点和待插入节点的哈希值相等,那么此时我们应该确定元素的顺序来保持树的平衡呢?也就是上述中的如下代码:
else if ((kc == null && (kc = comparableClassFor(k)) == null) || (dir = compareComparables(kc, k, pk)) == 0) { if (!searched) { TreeNode<K,V> q, ch; searched = true; if (((ch = p.left) != null && (q = ch.find(h, k, kc)) != null) || ((ch = p.right) != null && (q = ch.find(h, k, kc)) != null)) return q; } dir = tieBreakOrder(k, pk); }
为了解决将元素插入到红黑树中,如何确定元素顺序的问题。通过两种方案来解决:①实现Comprable接口 ②突破僵局机制(可认为是加时赛)。接下来我们来看实现Comprable例子,如下:
public class PersonComparable implements Comparable<PersonComparable> { int age; public PersonComparable(int age) { this.age = age; } @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (obj instanceof PersonComparable) { PersonComparable p = (PersonComparable) obj; return (this.age == p.age); } return false; } @Override public int hashCode() { return 42; } @Override public int compareTo(PersonComparable o) { return this.age - o.age; } }
然后我们在控制台中,调用如下代码进行测试:
HashMap hashMap = new HashMap(); Person p1 = new Person(1); Person p2 = new Person(2); Person p3 = new Person(3); Person p4 = new Person(4); Person p5 = new Person(5); Person p6 = new Person(6); Person p7 = new Person(7); Person p8 = new Person(8); Person p9 = new Person(9); Person p10 = new Person(10); Person p11 = new Person(11); Person p12 = new Person(12); Person p13 = new Person(13); hashMap.put(p1, "1"); hashMap.put(p2, "2"); hashMap.put(p3, "3"); hashMap.put(p4, "4"); hashMap.put(p5, "5"); hashMap.put(p6, "6"); hashMap.put(p7, "7"); hashMap.put(p8, "8"); hashMap.put(p9, "9"); hashMap.put(p10, "10"); hashMap.put(p11, "11"); hashMap.put(p12, "12"); hashMap.put(p13, "13");
反观上述代码,我们实现了Comprable接口并且直接重写了hashcode为常量值,此时将产生冲突,插入到HashMap中每一个元素的哈希值都相等即索引位置一样,也就是最终将由链表转换为红黑树,既然哈希值一样,那么我们如何确定其顺序呢?,此时我们回到上述 comparableClassFor 方法和 compareComparables 方法(二者具体实现就不一一解释了)
// 若实现Comparable接口返回其具体实现类型,否则返回空 static Class<?> comparableClassFor(Object x) { if (x instanceof Comparable) { Class<?> c; Type[] ts, as; Type t; ParameterizedType p; if ((c = x.getClass()) == String.class) // bypass checks return c; if ((ts = c.getGenericInterfaces()) != null) { for (int i = 0; i < ts.length; ++i) { if (((t = ts[i]) instanceof ParameterizedType) && ((p = (ParameterizedType)t).getRawType() == Comparable.class) && (as = p.getActualTypeArguments()) != null && as.length == 1 && as[0] == c) // type arg is c return c; } } } return null; }
//调用自定义实现Comprable接口比较器,从而确定顺序 static int compareComparables(Class<?> kc, Object k, Object x) { return (x == null || x.getClass() != kc ? 0 : ((Comparable)k).compareTo(x)); }
但是要是上述我们实现的Person类没有实现Comprable接口,此时将使用突破僵局机制(我猜测作者对此方法的命名是否是来自于维基百科《https://en.wikipedia.org/wiki/Tiebreaker》,比较贴切【突破僵局制(英语:tiebreaker),是一种延长赛的制度,主要用于棒球与垒球运动,特别是采淘汰制的季后赛及国际赛,以避免比赛时间过久仍无法分出胜负。】),也就是对应如下代码:
dir = tieBreakOrder(k, pk);
static int tieBreakOrder(Object a, Object b) { int d; if (a == null || b == null || (d = a.getClass().getName(). compareTo(b.getClass().getName())) == 0) d = (System.identityHashCode(a) <= System.identityHashCode(b) ? -1 : 1); return d; }
因为哈希值相等,同时也没有实现Comparable接口,但是我们又不得不解决这样实际存在的问题,可以说是最终采取“迫不得已“的解决方案,通过调用上述 System.identityHashCode 来获取对象唯一且恒定的哈希值从而确定顺序。好了,那么问题来了对于实现Comparable接口的键插入到树中和未实现接口的键插入到树中,二者有何区别呢?如果键实现Comparable接口,查找指定元素将会使用树特性快速查找,如果键未实现Comparable接口,查找到指定元素将会使用遍历树方式查找。问题又来了,既然通过实现Comparable接口的比较器来确定顺序,那为何不直接使用突破僵局机制来作为比较器?我们来看看那如下例子:
Person person1 = new Person(1); Person person2 = new Person(1); System.out.println(System.identityHashCode(person1) == System.identityHashCode(person2));
到这里我们知道,即使是两个相同的对象实例其identityHashCode都是不同的,所以不能使用identityHashCode作为比较器。问题又来了,既然使用identityHashCode确定元素顺序,当查找元素时是采用遍历树的方式,完全没有利用到树的特性,那么为何还要构造树呢?因为HashMap能够包含不同对象实例的键,有些可能实现了Comparable接口,有些可能未实现。我们来看如下混合不同类例子:
public class Person { int age; public Person(int age) { this.age = age; } @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (obj instanceof Person) { Person p = (Person) obj; return (this.age == p.age); } return false; } @Override public int hashCode() { return 42; } }
HashMap hashMap = new HashMap(); PersonComparable p1 = new PersonComparable (1); PersonComparable p2 = new PersonComparable (2); PersonComparable p3 = new PersonComparable (3); PersonComparable p4 = new PersonComparable (4); PersonComparable p5 = new PersonComparable (5); PersonComparable p6 = new PersonComparable (6); Person p7 = new Person(7); Person p8 = new Person(8); Person p9 = new Person(9); Person p10 = new Person(10); Person p11 = new Person(11); Person p12 = new Person(12); Person p13 = new Person(13); hashMap.put(p1, "1"); hashMap.put(p2, "2"); hashMap.put(p3, "3"); hashMap.put(p4, "4"); hashMap.put(p5, "5"); hashMap.put(p6, "6"); hashMap.put(p7, "7"); hashMap.put(p8, "8"); hashMap.put(p9, "9"); hashMap.put(p10, "10"); hashMap.put(p11, "11"); hashMap.put(p12, "12"); hashMap.put(p13, "13");
当使用混合模式时即实现了Comparable接口和未实现Comparable接口的对象实例可以基于类名来比较键。
总结
本节我们详细讲解了HashMap实现原理细节,一些比较简单的地方就没有再一一分析,文中若有叙述不当或理解错误之处,还望指正,感谢您的阅读,我们下节再会。