简介
HashMap是基于哈希表实现的,每一个元素是一个key-value对,HashMap类声明如下:
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable
它继承于AbstractMap,实现了Map、Cloneable、 Serializable等接口。
HashMap实现了Map接口,可以对它进行哈希表操作;实现了Cloneable接口,能被克隆;实现了Serializable接口,因此它支持序列化,能够通过序列化传输。
HashMap是非线程安全的,只是用于单线程环境下,多线程环境下可以采用concurrent并发包下的concurrentHashMap。
HashMap源码详解
HashMap是基于数组+链表+红黑树来实现的,其整体结构类似于下图:
我们先来看HashMap中的几个关键静态属性:
// HashMap初始化容量的默认大小(16),必须是2的整数次幂 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 // 最大容量(必须是2的幂且小于2的30次方,传入容量过大将被这个值替换) static final int MAXIMUM_CAPACITY = 1 << 30; // HashMap初始化时的默认负载因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; // 将链表转为红黑树的阈值,当一个元素在被添加时,如果链表中的元素个数已经达到8个,则将链表转为红黑树形式 static final int TREEIFY_THRESHOLD = 8; // 将红黑树转为链表的阈值 static final int UNTREEIFY_THRESHOLD = 6; // 链表被转化为红黑树时,哈希表最小的容量。为了避免冲突,该值至少为4 * TREEIFY_THRESHOLD static final int MIN_TREEIFY_CAPACITY = 64;
HashMap中的节点默认都被封装成为了Node类型数据,它是HashMap对应的链表节点:
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; Node(int hash, K key, V value, Node<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } public final K getKey() { return key; } public final V getValue() { return value; } public final String toString() { return key + "=" + value; } // 节点的哈希值等于key和value的哈希值求异或 public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } public final boolean equals(Object o) { if (o == this) return true; if (o instanceof Map.Entry) { Map.Entry<?,?> e = (Map.Entry<?,?>)o; if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue())) return true; } return false; } }
将链表转为红黑树之后,节点不再以Node方式存储,而被转化为了TreeNode节点:
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> { TreeNode<K,V> parent; // red-black tree links // 左节点 TreeNode<K,V> left; // 右节点 TreeNode<K,V> right; // 删除节点时,需要断开链接,这个节点记录了删除节点的前一个节点 TreeNode<K,V> prev; // needed to unlink next upon deletion boolean red; TreeNode(int hash, K key, V val, Node<K,V> next) { super(hash, key, val, next); } // 返回当前节点的树根节点 final TreeNode<K,V> root() { for (TreeNode<K,V> r = this, p;;) { if ((p = r.parent) == null) return r; r = p; } } ... }
和两个类都直接或间接地实现了Map.Entry<K,V>接口。
构造方法
HashMap有如下四个构造方法:
// 参数指定了HashMap初始化时的容量以及负载因子 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.75 public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } // 无参构造方法,默认的初始化容量为16,负载因子为默认的0.75 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } // 根据其他Map来创建HashMap,负载因子为0.75 public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }
我们看到第一个构造方法中计算threshold用到了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的整数次幂的数。我们先看一下这段代码做了什么工作:
static final int MAXIMUM_CAPACITY = 1 << 30; public static void main(String[] args) { int num = tableSizeFor(18); System.out.println("tableSizeFor(18): " + getNumFormatString(num, 2, 16)); } static int tableSizeFor(int cap) { System.out.println("cap: " + getNumFormatString(cap, 2, 16)); int n = cap - 1; System.out.println("n: " + getNumFormatString(n, 2, 16)); n |= n >>> 1; System.out.println("n |= n >>> 1: " + getNumFormatString(n, 2, 16)); n |= n >>> 2; System.out.println("n |= n >>> 2: " + getNumFormatString(n, 2, 16)); n |= n >>> 4; System.out.println("n |= n >>> 4: " + getNumFormatString(n, 2, 16)); n |= n >>> 8; System.out.println("n |= n >>> 8: " + getNumFormatString(n, 2, 16)); n |= n >>> 16; System.out.println("n |= n >>> 16: " + getNumFormatString(n, 2, 16)); return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; } static String getNumFormatString(int n, int radix, int len) { String s = Integer.toString(n, radix); while (s.length() < len) { s = "0" + s; } return s; }
运行结果:
cap: 0000000000010010 n: 0000000000010010 n |= n >>> 1: 0000000000011011 n |= n >>> 2: 0000000000011111 n |= n >>> 4: 0000000000011111 n |= n >>> 8: 0000000000011111 n |= n >>> 16: 0000000000011111 tableSizeFor(18): 0000000000100000
该方法不断地把最高位1后面的位全部变成1,然后加1,即得到了结果,那为什么要cap - 1呢?因为它要计算大于等于cap的值,如果cap为8(1000),且不减1,则计算出的结果为16(10000),为了处理这种情况,需要将cap - 1。
该方法的作用是无论我们指定的容量是多少,构造方法都会将实际容量设为不小于指定容量的2的整数次方的一个数,且最大值不能超过2的30次方。
HashMap有如下几个成员变量:
// 存储链表的数组,table在第一次使用时会进行初始化,如果有必要会有resize的操作 // table的大小总是2的整数次幂 transient Node<K,V>[] table; // 保存entrySet()方法的缓存,要和AbstractMap的keySet()和values()区分,AbstractMap有自己的Set集合,来缓存这两个方法的返回值 transient Set<Map.Entry<K,V>> entrySet; // 键值对的个数 transient int size; // HsahMap结构的修改次数,用于fail-fast机制 transient int modCount; // 下一次resize的阈值大小 = HashMap容量 * 负载因子 int threshold; //哈希表的负载因子 final float loadFactor;
我们下面来看HashMap的几个关键方法:put方法、get方法和remove方法。
put(K key, V value)方法
该方法很简单:
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
put方法首先调用了hash方法来计算哈希值,然后通过putVal方法来添加元素。hash(Object)方法是HashMap的一个静态方法:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
若key为null,则直接返回0,否则通过h = key.hashCode()计算出key的hashcode,然后返回h ^ (h >>> 16)的值。h >>> 16为无符号向右移动16位,移位之后,h的高16位全部变成了0,计算过程如下:
h(h1) : 0001 1001 0010 0001 0000 1011 0000 0101 h >>> 16(h2) : 0000 0000 0000 0000 0001 1001 0010 0001 h1 ^ h2 : 0001 1001 0010 0001 0001 0010 0010 0100
这样做的好处是,低位的信息中混入了高位的信息,这样高位的信息被变相的保留了下来。掺杂的元素多了,那么生成的哈希值的随机性会增大。我们可以看这么一个例子:
int capacity = 1 << 4; int haseCode1 = Integer.parseInt("00011001001000010000101100000101", 2); int haseCode2 = Integer.parseInt("00100011000010100011000001000101", 2); int haseCode3 = Integer.parseInt("01001100001100000000011000010101", 2); System.out.println(haseCode1 & (capacity - 1)); System.out.println(haseCode2 & (capacity - 1)); System.out.println(haseCode3 & (capacity - 1)); System.out.println("-----"); System.out.println((haseCode1 ^ (haseCode1 >>> 16)) & (capacity - 1)); System.out.println((haseCode2 ^ (haseCode2 >>> 16)) & (capacity - 1)); System.out.println((haseCode3 ^ (haseCode3 >>> 16)) & (capacity - 1));
运行结果:
5 5 5 ----- 4 15 5
haseCode & (capacity - 1)是用来计算节点对应的数组下标,我们后面会介绍。可以看到如果直接使用key的hashcode作为节点的哈希值,计算出来的三个几点处于同一位置,这就产生了哈希冲突,而如果我们使用h ^ (h >>> 16)作为哈希值,计算出来的三个节点的位置都不相同,也就是说这种方式计算出的哈希值随机性更大,能使节点的分布更加均匀。
这也正好解释了为什么HashMap的数组长度要取2的整次幂。因为数组长度-1正好相当于一个“低位掩码”。这个掩码和节点的哈希值进行与操作的结果就是哈希值的高位全部归零,只保留低位值,用来做数组下标访问。同时,capacity - 1的二进制表示中的最后一位是1,这样便保证了haseCode & (capacity - 1)的最后一位可能为0,也可能为1,即计算出的下标可能为偶数,也可能为奇数,这样便可以保证散列的均匀性,若capacity - 1的二进制表示中的最后一位是0,则计算出的下标都是偶数,所有奇数下标都没有被使用,不仅不均匀,而且还浪费了一半空间。其实,数组长度要取2的整次幂还有其他好处,我们后续会介绍。
下面我们继续介绍putVal方法:
// onlyIfAbsent为true时,则不覆盖key对应的value值,但是put在调用这个方法时,赋值为false,说明会覆盖原始value // evict为false时,table处于创建模式,put在调用这个方法时,赋值为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; // table为null或者table长度为0,则通过resize方法来初始化table,其中,n为数组的长度 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // (n - 1) & hash是计算出的节点在数组中的下标,若数组对应下标为null,则直接将节点赋值到tab[i] if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); // 若数组对应下标不为null,则表明发生了哈希冲突,其中,p为table[i]中的第一个节点 else { Node<K,V> e; K k; // 如果p节点与插入节点的hash和key相同,则e = p if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; // 否则,判断p节点是否为TreeNode,即判断链表是否已经调整为红黑树,若是的话,则通过putTreeVal来添加红黑树节点 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); // 否则,p为链表节点 else { for (int binCount = 0; ; ++binCount) { // 在链表尾节点处插入新节点 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); // 如果链表节点个数在插入新的节点后,达到转为红黑树的阈值,则需要将链表转为红黑树 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } // 如果插入节点和原链表中的某个key具有相同的hash且key相同,则停止查找 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } // e != null表明哈希表中已经存在键为key的节点 if (e != null) { // existing mapping for key V oldValue = e.value; // 若允许覆盖value值,或旧值为null,则更新key所对应的值 if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } // HashMap结构修改次数加1 ++modCount; // 若节点个数 > threshold,则对table进行扩容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
从上面的源码中我们可以看出,HashMap的key和value都可以为null,因为在计算节点哈希值时,若key为null,则哈希值返回0,而且,插入元素时,会判断key对应的value是否为null,所以,key和value都可以为null。
table的扩容是通过resize方法来完成的,我们来看一看:
// 初始化table或对table进行扩容 final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; // 如果table的原容量 > 0 if (oldCap > 0) { // 如果原容量 >= MAXIMUM_CAPACITY,则将阈值threshold修改为Integer.MAX_VALUE,并返回原table if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } // 计算新容量newCap = 原容量 * 2 // 若newCap < MAXIMUM_CAPACITY且旧容量oldCap >= DEFAULT_INITIAL_CAPACITY,则新的阈值newThr = 旧阈值 * 2 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } // 否则,table的原容量为0,如果原阈值 > 0,则新容量newCap = oldThr else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; // 否则,新容量newCap = DEFAULT_INITIAL_CAPACITY(16) // 新阈值newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY) else { // zero initial threshold signifies using defaults newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } // 如果新阈值newThr为0 if (newThr == 0) { // 计算阈值ft,ft = (float)newCap * loadFactor float ft = (float)newCap * loadFactor; // 根据ft来计算新阈值newThr newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } // 更新阈值 threshold = newThr; // 创建新的哈希表 @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; //更新table为newTab table = newTab; // 如果原table不为null,则需要将原table中的节点复制到新table中 if (oldTab != null) { // 遍历原table数组,j为下标 for (int j = 0; j < oldCap; ++j) { Node<K,V> e; // 原table的j下标存有节点,e为头结点 if ((e = oldTab[j]) != null) { // 将原table[j]处置为null,释放空间 oldTab[j] = null; // 如果e没有后继节点 if (e.next == null) // 将e赋值给新table对应的首节点 newTab[e.hash & (newCap - 1)] = e; // 如果e为红黑树节点 else if (e instanceof TreeNode) // 重构红黑树结构到新table中 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); // e为链表节点 else { // preserve order Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; // 将同一链表中的元素根据(e.hash & oldCap)是否为0进行分割,分成两个不同的链表,完成rehash // 若(e.hash & oldCap)为0,则该节点在新table中的下标不变 // 若(e.hash & oldCap)不为0,则该节点在新table中的下标变为j + oldCap 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; } } } } } // 返回新table return newTab; }
在该方法中,我们要注意一下,扩容之后,原节点的rehash方式,我们用一个图来说明rehash的过程:
有四个节点,它们的哈希值分别为3、11、19、27,若初始容量为8,这四个节点都位于下标为3的位置,在扩容之后,哈希表容量变为16,因为3 & 8 == 0,19 & 8 == 0,所以,这两个节点仍然在新table下标为3的位置,但是11 & 8 != 0,27 & 8 != 0,所以,这两个节点会在新table下表为(3+8) = 11的位置,我们验证一下:11 & (16 - 1) = 11,27 & (16 - 1) = 11,这两个节点确实应该在下标为11的位置。
这里也体现出了哈希表容量为2的整数次幂的另一个好处,在rehash时,节点在新table中的下标计算很方便。
扩容是一个比较耗时的操作,因为它需要重新计算这些元素在新的数组中的位置并进行复制处理。因此,我们在用HashMap的时,最好能提前预估下HashMap中元素的个数,这样有助于提高HashMap的性能。
在putVal方法中,我们还知道,当链表节点个数在插入新的节点后,如果达到转为红黑树的阈值,则需要将链表转为红黑树,将链表转化为红黑树是通过treeifyBin方法来完成的:
final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; // 如果table为空或者table数组太小,不满足转为红黑树的条件 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) // 对table数组进行扩容 resize(); // 如果符合转为红黑树的条件,且hash对应的数组位置不为null,即存在哈希值为hash的节点 else if ((e = tab[index = (n - 1) & hash]) != null) { TreeNode<K,V> hd = null, tl = null; // 遍历链表 do { // 将Node节点转换为TreeNode节点 TreeNode<K,V> p = replacementTreeNode(e, null); if (tl == null) hd = p; else { p.prev = tl; tl.next = p; } tl = p; } while ((e = e.next) != null); if ((tab[index] = hd) != null) hd.treeify(tab); } }
关于红黑树的内容这里不再介绍,相关知识可以查看《算法导论》。
put方法的主要内容我们也就介绍到这,下面看get方法。
get(Object key)方法
public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; }
get(Object)其实是调用了getNode(int, Object)方法:
final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; // table不为null且长度不为0,且存在哈希值为hash的节点,first为对应下标的首节点 if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { // 总是先检查first节点是否符合条件,若符合,则直接返回first节点 if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first; // 否则,若首节点存在后继节点 if ((e = first.next) != null) { // 若首节点是TreeNode类型节点,则从红黑树中查找节点 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); } } // 否则返回null return null; }
get(Object)方法在调用getNode(int, Object)方法之后,会判断getNode(int, Object)的返回值是否为null,若为null,则get(Object)返回null,否则,返回e.value。
这里需要注意的是,get(Object)方法返回结果为null并不是说HashMap中没有对应key的映射,因为HashMap中key和value都允许为null,这也有可能是key对应的value为null。
remove(Object key)方法
public V remove(Object key) { Node<K,V> e; return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value; }
remove(Object)也是直接调用了removeNode方法来删除节点:
// matchValue如果为true,则表示删除一个node的条件是:key和value都一致才删除 // movable如果为false,则表示删除当前节点时,不会移动其它节点 final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) { Node<K,V>[] tab; Node<K,V> p; int n, index; // table不为null且长度不为0,且存在哈希值为hash的节点,p为对应下标的首节点 if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) { Node<K,V> node = null, e; K k; V v; // 总是先检查首节点p是否符合条件,若符合,则node = p if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) node = p; // 否则,若首节点存在后继节点 else if ((e = p.next) != null) { // 若首节点是TreeNode类型节点,则从红黑树中查找节点 if (p instanceof TreeNode) node = ((TreeNode<K,V>)p).getTreeNode(hash, key); // 从链表中查找节点 else { do { // 若找到符合条件的节点,则node = e,退出循环 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { node = e; break; } // 更新p p = e; } while ((e = e.next) != null); } } // 如果找到指定key与哈希值的node,且保证了删除策略matchValue,则可以删除 if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) { // node为红黑树节点,则调用红黑树节点删除方法 if (node instanceof TreeNode) ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable); // 否则,node节点是链表节点,若node == p,p为node的前驱节点,则说明node为首节点,直接更新数组对应下标的首节点 else if (node == p) tab[index] = node.next; // 否则,更新p.next = node.next,删除node节点 else p.next = node.next; // HashMap结构修改次数加1 ++modCount; // 元素个数减1 --size; afterNodeRemoval(node); // 返回删除节点 return node; } } // 返回null return null; }
remove(Object)方法调用removeNode方法,设置的value为null,matchValue为false,这表明,不需要key和value全部匹配才删除节点,只要key匹配就可以删除了。
HashMap其他相关方法
要判断一个key在HashMap中是否存在,是不可以用get(Object)方法来判断的,我们可以用containsKey(Object)方法来判断:
public boolean containsKey(Object key) { return getNode(hash(key), key) != null; }
我们知道,getNode(int, Object)方法会返回key对应的Node节点,若key不存在,则肯定返回的是null,若key存在,则无论value是否为null,key对应的Node节点都不为null。
clear()方法
// 删除HashMap中所有的键值对 public void clear() { Node<K,V>[] tab; modCount++; if ((tab = table) != null && size > 0) { size = 0; for (int i = 0; i < tab.length; ++i) // tab[i] = null,表明JVM可以对节点的内存进行回收,同时table也不再拥有其内存空间 tab[i] = null; } }
HashMap中的modCount的作用这里不再解释,可以参考这篇博客。