综述(官方文档翻译):
HashMap是基于哈希表的Map接口实现。该实现提供了所有可选的映射操作,并允许null值和null键。(HashMap类大致等同于Hashtable,不同的是它是不同步的,并且允许为空。) 这个类不保证映射的顺序;特别是,它不能保证顺序随时间保持不变。
这个实现为基本操作(get和put)提供恒定的时间性能,假设散列函数将元素适当地分散在存储桶中。迭代集合视图需要的时间与HashMap实例的“容量”(桶的数量)加上它的大小(键-值映射的数量)成比例。因此,如果迭代性能很重要,那么不要将初始容量设置得太高(或者负载系数设置得太低),这一点非常重要。
如果许多映射要存储在一个HashMap实例中,那么使用足够大的容量创建它将使映射能够更有效地存储,而不是根据需要执行自动重新散列来增长表。注意,使用多个键具有相同的hashCode()肯定会降低任何哈希表的性能。为了减轻影响,当键具有可比性时,该类可以使用键之间的比较顺序来帮助打破联系。
注意,这个实现是不同步的。
HashMap 通常表现为一张哈希表,每个桶里面放一个元素,当有多个元素的时候,变为链表,这时候每个桶里面都是放链表;但是当链表的长度达到一个临界的时候,链表转换为树,每个树的结构就像 TreeMap 一样,这时候,每个桶里面就是一个树形结构;
大多数方法只是用普通的桶,即里面只是链表;但是在合适的时候,链表会被转为树,比如检查每个节点的时候;因为这时候转换为树,不但支持原来链表的遍历和使用,同时还能获得更快的查找;
但是由于大多数时候,每个桶都没有被过度填充,即里面都是链表,还达不到转换为树的条件,因此 HashMap 的方法可能会延迟检查桶里面到底是链表还是树形结构 ;
当桶里面的结构是树形结构的时候,通常情况下是按照 HashCode 来算下标位置的;但是如果要插入的元素实现了 Comparable 接口,则使用接口的 compareTo 方法,计算排序的位置 ;
树形结构(HashMap中的树是红黑树)保证了在元素具有 不同的哈希码或者可以排序 的情况下,插入元素复杂度在最坏的情况下是 O log(n) ,因此,将桶中的链表在一定情况下转成树是值得的;
因此,如果恶意的将 hashCode 方法的返回值故意分布在一起,比如返回同一个哈希码,或者实现了 Comparable 接口,但是 compareTo 永远返回 0,这时候 HashCdoe 的性能会下降 ;
如果这两种方法都不适用(不同的哈希码或者可以排序),与不采取预防措施相比,我们可能会浪费大约两倍的时间和空间。
serialVersionUID
用来在序列化验证版本一致`
常量说明:
HashMap实例有两个影响其性能的参数:初始容量和负载系数。 容量是哈希表中的桶数,初始容量只是创建哈希表时的容量。负载系数是一种衡量在自动增加容量之前哈希表允许的满度的度量。当哈希表中的条目数超过负载因子和当前容量的乘积时,哈希表被重新哈希(扩容),以便哈希表的桶数大约是桶数的两倍。
默认容量 :容量相关参数都必须是2的幂,原因在下文 “确定哈希桶数组索引” 中提到
最大容量:
默认负载因子:
作为一般规则**,默认负载系数(0.75)在时间和空间成本之间提供了很好的权衡。**较高的值会减少空间开销,但会增加查找成本(反映在HashMap类的大多数操作中,包括get和put)。在设置map的初始容量时,应该考虑map的预期条目数和它的负载因子,以减少重新哈希操作的数量。如果初始容量大于条目的最大数量除以负载系数,则不会发生重新哈希操作。
默认负载因子的推导:
https://segmentfault.com/a/1190000023308658
这也就是为什么stackoverflow上说接近于ln2的原因了。然后再去考虑hashmap一些内置的要求:
乘16可以最好一个整数。
那么在0.5~1之间找一个小数,满足这要求的只有0.625(5/8),0.75(3/4),0.875(7/8)。这三个数让我选,从审美角度,还是从中位数角度,我都会挑0.75。毕竟碰撞是个概率问题,这个0.75我觉得不错,我没办法预知使用者的数据到底什么样子的,0.75是最为折中的一个选择。
树化的长度阈值:
文档解释:
* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins. In
* usages with well-distributed user hashCodes, tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million
因为树节点的大小大约是普通节点的两倍,所以我们只在桶中包含足够的节点(TREEIFY_THRESHOLD = 8)时才进行链表转换成树的操作;
当树因为删除节点变得很小的时候,会再次转换回链表;
如果 HashCode 方法设计的很好的话,哈希冲突降低,链表的长度基本就不会很长,树是很少用到的;
理想情况下,随机的哈希码,遵循 Poisson (泊松)分布;
同时给出了桶中元素个数和概率的对照表。
从上面的表中可以看到当桶中元素到达8个的时候,概率已经变得非常小,也就是说用0.75作为加载因子,每个碰撞位置的链表长度超过8个是几乎不可能的。
取消树化的还原阈值:
即 红黑树转为链表的阈值,当在扩容(resize())时(此时HashMap的数据存储位置会重新计算),在重新计算存储位置后,当原有的红黑树内数量 < 6时,则将 红黑树转换成链表。
最小树化的容量阈值:
当哈希表中的容量 > 该值时,才允许树形化链表 (即 将链表 转换成红黑树。否则,若桶内元素太多时,则直接扩容,而不是树形化。 为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
因为如果桶的数量过少,又发生了严重的hash碰撞,那么根本问题其实是桶的数量太少了,所以此时树化的意义就不大,就会先优先扩容。
指定容量:tableSizeFor()
首先,通过源码的注释我们可以简单的了解到tableSizeFor()的功能:
该方法是如果给定目标容量小于最大容量时,返回一个大于等于给定目标容量的最小的2次方幂;否则,返回最大容量。
/**
* Returns a power of two size for the given target capacity.
*/
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;
}
确定哈希桶数组索引位置:
对于任意给定的对象,只要它的 hashCode() 返回值相同,那么计算得到的 hash 值总是相同的。我们首先想到的就是把 hash 值对 table 长度取模运算,这样一来,元素的分布相对来说是比较均匀的。
JDK 1.7:
但是模运算消耗还是比较大的,我们知道计算机比较快的运算为位运算,因此 JDK 团队对取模运算进行了优化,位与运算来代替模运算。这个方法非常巧妙,它通过(table.length -1) & h
来得到该对象的索引位置,这个优化是基于以下公式:x mod 2^n = x & (2^n - 1)
。我们知道 HashMap 底层数组的长度总是 2 的 n 次方(这容量为什么是2的次幂的原因),并且取模运算为 “h mod table.length
”,对应上面的公式,可以得到该运算等同于“h & (table.length - 1)
”。这是 HashMap 在速度上的优化,因为 & 比 % 具有更高的效率。
static int indexFor(int h, int length) { //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
return h & (length-1); //第三步 取模运算
}
JDK 1.8:
在1.7的基础上,将高位部分与低位部分做异或运算,主要是为了在 table 的 length 较小的时候,让高位也参与运算,并且不会有太大的开销。在结构上,删除了jdk1.7的indexFor方法,hash()计算高位和低位的异或,当需要确定索引index时,直接计算而不调用indexFor:
index = (n - 1) & hash;
这里的Hash算法本质上就是三步:取key的hashCode值、高位运算、取模运算。
Put 方法
public V put(K key, V value) {
// 对key的hashCode()做hash
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;
// 步骤①:tab为空则创建
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 步骤②:计算index,并对null做处理
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K, V> e;
K k;
// 步骤③:节点key存在,直接覆盖value
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);
//链表长度大于8转换为红黑树进行处理
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st 30 treeifyBin(tab, hash);
break;
}
// key已经存在直接覆盖value
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 步骤⑥:超过最大容量 就扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
HashMap 扩容机制
JDK 1.7
扩容(resize)就是重新计算容量,向HashMap对象里不停的添加元素,而 HashMap 对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,就像我们用一个小桶装水,如果想装更多的水,就得换大水桶。
我们分析下resize的源码,鉴于JDK1.8融入了红黑树,较复杂,为了便于理解我们仍然使用JDK1.7的代码,好理解一些,本质上区别不大,具体区别后文再说。
void resize(int newCapacity) { //传入新的容量
Entry[] oldTable = table; //引用扩容前的Entry数组
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了
threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
return;
}
Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组
transfer(newTable); //!!将数据转移到新的Entry数组里
table = newTable; //HashMap的table属性引用新的Entry数组
threshold = (int) (newCapacity * loadFactor);//修改阈值
}
这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。
void transfer(Entry[] newTable) {
Entry[] src = table; //src引用了旧的Entry数组
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
Entry<K, V> e = src[j]; //取得旧Entry数组的每个元素
if (e != null) {
src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
do {
Entry<K, V> next = e.next;
int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
e.next = newTable[i]; //标记[1]
newTable[i] = e; //将元素放在数组上
e = next; //访问下一个Entry链上的元素
} while (e != null);
}
}
}
newTable[i] 的引用赋给了 e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在第一个索引上的元素终会被放到Entry链的尾部(如果发生了hash冲突的话),这一点和JDK 1.8有区别,下文详解。在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上(倒置)。
JDK1.8
经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。
元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:
这个设计省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置。
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;
}
// 没超过最大值,就扩充为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
} else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int) (DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 计算新的resize上限
if (newThr == 0) {
float ft = (float) newCap * loadFactor;
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;
if (oldTab != null) {
// 把每个bucket都移动到新的buckets中
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 { // 链表优化重hash的代码块
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;
}
// 原索引+oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到bucket里
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到bucket里
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
线程安全性
JDK 1.7 死循环问题:
在多线程使用场景中,应该尽量避免使用线程不安全的HashMap,而使用线程安全的ConcurrentHashMap。那么为什么说HashMap是线程不安全的,下面举例子说明在并发的多线程使用场景中使用HashMap可能造成死循环。
public class HashMapInfiniteLoop {
private static HashMap<Integer,String> map = new HashMap<Integer,String>(2,0.75f);
public static void main(String[] args) {
map.put(5, "C");
new Thread("Thread1") {
public void run() {
map.put(7, "B");
System.out.println(map);
};
}.start();
new Thread("Thread2") {
public void run() {
map.put(3, "A);
System.out.println(map);
};
}.start();
}
}
为了方便,把上文提到的 JDK1.7 扩容时调用的 transfer 方法拿过来
void transfer(Entry[] newTable) {
Entry[] src = table; //src引用了旧的Entry数组
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
Entry<K, V> e = src[j]; //取得旧Entry数组的每个元素
if (e != null) {
src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
do {
Entry<K, V> next = e.next; //-------断点
int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
e.next = newTable[i]; //标记[1]
newTable[i] = e; //将元素放在数组上
e = next; //访问下一个Entry链上的元素
} while (e != null);
}
}
}
其中,map初始化为一个长度为2的数组,loadFactor=0.75
,threshold=2*0.75=1
,也就是说当put
第二个key
的时候,map
就需要进行resize
。
通过设置断点让线程1和线程2同时debug到transfer
方法的首行。注意此时两个线程已经成功添加数据。 放开thread1的断点至transfer方法的“Entry next = e.next;
” 这一行;然后放开线程2的的断点,让线程2进行resize
。结果如下图。
注意,Thread1
的 e 此时指向了key(3)
,而next
指向了key(7)
,Thread 2 rehash
后,指向了重组后的链表。
Thread1
被调度回来执行,先是执行 newTalbe[i] = e
, 然后是e = next
,导致了e指向了key(7)
,而下一次循环的 next = e.next
导致了next
指向了key(3)
。
e.next = newTable[i]
导致key(3).next
指向了key(7)
。注意:此时的key(7).next
已经指向了key(3)
, 环形链表就这样出现了。
JDK1.8
上文JDK1.8 resize()
方法中,声明两对指针,维护两个链表,依次在末端添加新的元素。(在多线程操作的情况下,无非是第二个线程重复第一个线程一模一样的操作)
..........
else if (e instanceof TreeNode)
((TreeNode<K, V>) e).split(this, newTab, j, oldCap);
else { // 链表优化重hash的代码块
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;
}
// 原索引+oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到bucket里
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到bucket里
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
JDK 1.8中HashMap
的确不会出现 JDK1.7 中的死循环问题,但是依然死循环的可能或数据丢失等等。因此多线程情况下还是建议使用ConcurrentHashMap
。
JDK1.8与JDK1.7的性能对比
HashMap中,如果key经过hash算法得出的数组索引位置全部不相同,即Hash算法非常好,那样的话,getKey方法的时间复杂度就是O(1),如果Hash算法技术的结果碰撞非常多,假如Hash算法极其差,所有的Hash算法结果得出的索引位置一样,那样所有的键值对都集中到一个桶中,或者在一个链表中,或者在一个红黑树中,时间复杂度分别为O(n)和O(lgn)。 鉴于JDK1.8做了多方面的优化,总体性能优于JDK1.7。
Hash较均匀的情况
在测试中会查找不同的值,然后度量花费的时间,为了计算getKey的平均时间,我们遍历所有的get方法,计算总的时间,除以key的数量,计算一个平均值,主要用来比较,绝对值可能会受很多环境因素的影响。结果如下:
JDK1.8的性能要高于JDK1.7 15%以上,在某些size的区域上,甚至高于100%。由于Hash算法较均匀,JDK1.8引入的红黑树效果不明显,下面我们看看Hash不均匀的的情况。
Hash极不均匀的情况
从表中结果中可知,随着size的变大,JDK1.7的花费时间是增长的趋势,而JDK1.8是明显的降低趋势,并且呈现对数增长稳定。当一个链表太长的时候,HashMap会动态的将它替换成一个红黑树这话的话会将时间复杂度从O(n)降为O(logn)。hash算法均匀和不均匀所花费的时间明显也不相同,这两种情况的相对比较,可以说明一个好的hash算法的重要性。
补充:HashTable 与 HashMap 的区别
- HashMap是线程不安全的,HashTable是线程安全的;
- 由于线程安全,所以HashTable的效率比不上HashMap;
- HashMap最多只允许一条记录的键为null,允许多条记录的值为null,而HashTable不允许;
- HashMap默认初始化数组的大小为16,HashTable为11,前者扩容时,扩大两倍,后者扩大两倍+1;
- HashMap需要重新计算hash值,而HashTable直接使用对象的hashCode;
小结:
- 扩容是一个特别耗性能的操作,所以当程序员在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。
- 当哈希表中的条目数超过负载因子和当前容量的乘积时,哈希表被扩容
- 容量必须是2的次幂,如果设定的值不满足,将被转为比设定值大的最接近的2的次幂
- 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊。
- HashMap 是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap。
- JDK1.8 引入红黑树大程度优化了HashMap的性能。
- 转换为树的条件:
- 链表长度大于 TREEIFY_THRESHOLD(默认 = 8)
- 容量大于 MIN_TREEIFY_CAPACITY (默认 = 64)
还原为链表的条件:
当原有的红黑树内数量 < UNTREEIFY_THRESHOLD(默认 = 6)时,则将 红黑树转换成链表。
参考:
- Java 8系列之重新认识HashMap
- HashMap的负载因子为什么是0.75