前言
1. 存储结构
(1) 从源码可知,HashMap类中有一个非常重要的字段,就是 Node[] table,即哈希桶数组,明显它是一个Node的数组。我们先来看看Node是什么。
static class Node implements Map.Entry<K,V> {final int hash;final K key;V value;Node next;//指向链表的下一个nodeNode(int hash, K key, V value, Node next) { ... }public final K getKey(){ ... }public final V getValue() { ... }public final String toString() { ... }public final int hashCode() { ... }public final V setValue(V newValue) { ... }public final boolean equals(Object o) { ... }
}复制代码
(2) HashMap就是使用哈希表来存储的。哈希表为解决冲突,可以采用开放地址法和链地址法等来解决问题,Java中HashMap采用了链地址法。
链地址法,简单来说,就是数组加链表的结合。在每个数组元素上都有一个链表结构,当数据被Hash后,得到数组下标,把数据放在对应下标元素的链表上(具体内容下文会说到)。
在理解Hash和扩容流程之前,我们得先了解下HashMap的几个字段。从HashMap的默认构造函数源码可知,构造函数就是对下面几个字段进行初始化,源码如下:
int threshold; // 所能容纳的key-value对极限
final float loadFactor; // 负载因子
int modCount;
int size;复制代码
size这个字段其实很好理解,就是HashMap中实际存在的键值对数量。注意和table的长度length、容纳最大键值对数量threshold的区别。
modCount字段主要用来记录HashMap内部结构发生变化的次数,主要用于迭代的快速失败。强调一点,内部结构发生变化指的是结构发生变化,例如put新键值对,但是某个key对应的value值被覆盖不属于结构变化。
这里存在一个问题,即使负载因子和Hash算法设计的再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响HashMap的性能。于是,在JDK1.8版本中,对数据结构做了进一步的优化,引入了红黑树。而当链表长度太长(默认超过8)时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能,其中会用到红黑树的插入、删除、查找等算法。
1.1. 拉链法的工作原理
- 新建一个 HashMap,默认大小为 16;
- 插入 <K1,V1> 键值对,先计算 K1 的 hashCode 为 115,使用除留余数法得到所在的桶下标 115%16=3。
- 插入 <K2,V2> 键值对,先计算 K2 的 hashCode 为 118,使用除留余数法得到所在的桶下标 118%16=6。
- 插入 <K3,V3> 键值对,先计算 K3 的 hashCode 为 118,使用除留余数法得到所在的桶下标 118%16=6,插在 <K2,V2> 前面。
- 计算键值对所在的桶;
- 在链表上顺序查找,时间复杂度显然和链表的长度成正比。
1.2. 构造函数解析
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. 实现原理
2.1. 确定哈希桶数组索引位置
方法一:
static final int hash(Object key) { //jdk1.8 & jdk1.7int h;// h = key.hashCode() 为第一步 取hashCode值// h ^ (h >>> 16) 为第二步 高位参与运算return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
方法二:
static int indexFor(int h, int length) {
//jdk1.7的源码,jdk1.8没有这个方法,原理是一样的只不过放到其他方法中去,例如put()return h & (length-1); //第三步 取模运算
}复制代码
对于任意给定的对象,只要它的hashCode()返回值相同,那么程序调用方法一所计算得到的Hash码值总是相同的。我们首先想到的就是把hash值对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,模运算的消耗还是比较大的,在HashMap中是这样做的:调用方法二来计算该对象应该保存在table数组的哪个索引处。
这个方法非常巧妙,它通过h & (table.length -1)
来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h& (length-1)
运算等价于对length取模,也就是h%length
,但是&比%具有更高的效率。
x : 00010000
x-1 : 00001111
复制代码
y : 10110010
x-1 : 00001111
y&(x-1) : 00000010复制代码
y : 10110010
x : 00010000
y%x : 00000010复制代码
2.2. 分析HashMap的put方法
public V put(K key, V value) {// 对key的hashCode()做hashreturn 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;// 步骤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:节点key存在,直接覆盖valueif (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);// 步骤5:该链为链表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 treeifyBin(tab, hash);break;}// key已经存在直接覆盖valueif (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k)))) break;p = e;}}if (e != null) { // existing mapping for keyV oldValue = e.value;if (!onlyIfAbsent || oldValue == null)e.value = value;afterNodeAccess(e);return oldValue;}}++modCount;// 步骤6:超过最大容量 就扩容if (++size > threshold)resize();afterNodeInsertion(evict);return null;}复制代码
private V putForNullKey(V value) {for (Entry<K,V> e = table[0]; e != null; e = e.next) {if (e.key == null) {V oldValue = e.value;e.value = value;e.recordAccess(this);return oldValue;}}modCount++;addEntry(0, null, value, 0);return null;
}复制代码
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);
}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(int h, K k, V v, Entry<K,V> n) {value = v;next = n;key = k;hash = h;
}
复制代码
2.3. 扩容-基本原理
和扩容相关的参数主要有:capacity、size、threshold 和 load_factor。
参数 | 含义 |
capacity | table 的容量大小,默认为 16。需要注意的是 capacity 必须保证为 2 的 n 次方。 |
size | table 的实际使用量。 |
threshold | size 的临界值,size 必须小于 threshold,如果大于等于,就必须进行扩容操作。 |
loadFactor | 装载因子,table 能够使用的比例,threshold = capacity * loadFactor。 |
static final int DEFAULT_INITIAL_CAPACITY = 16;
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
transient Entry[] table;
transient int size;
int threshold;
final float loadFactor;
transient int modCount;复制代码
void addEntry(int hash, K key, V value, int bucketIndex) {Entry<K,V> e = table[bucketIndex];table[bucketIndex] = new Entry<>(hash, key, value, e);if (size++ >= threshold)resize(2 * table.length);
}复制代码
我们分析下resize的源码,这里使用的是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);//修改阈值
}
复制代码
void transfer(Entry[] newTable) {Entry[] src = table; //src引用了旧的Entry数组int newCapacity = newTable.length;for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组Entry e = src[j]; //取得旧Entry数组的每个元素if (e != null) {
//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)src[j] = null;do {Entry next = e.next;//!!重新计算每个元素在数组中的位置int i = indexFor(e.hash, newCapacity);e.next = newTable[i]; //标记[1]newTable[i] = e; //将元素放在数组上e = next; //访问下一个Entry链上的元素} while (e != null);}}
} 复制代码
其实上面的数组扩容、重新计算下标值并将旧数据插入到新数组的过程并不难,有意思的一点是JDK1.8在这里对扩容后的元素移动操作做了优化,具体过程如下。
经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。
看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。
具体细节大家不妨可以去看看JDK1.8的源码中resize是怎么写的,这个思路的确十分巧妙。
3. 为什么HashMap线程不安全?
public class HashMapInfiniteLoop { private static HashMap map = new HashMap(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(); }
} 复制代码
线程一被调度回来执行,先是执行 newTalbe[i] = e, 然后是e = next,导致了e指向了key(7),而下一次循环的next = e.next导致了next指向了key(3)。
4. 与 HashTable 的比较
- hashmap不是线程安全的、hashtable是安全的
- HashMap允许将 null 作为一个 entry 的 key 或者 value,而 Hashtable 不允许。
- HashMap 把 Hashtable 的 contains 方法去掉了,改成 containsValue 和 containsKey。因为 contains 方法容易让人引起误解。
- HashTable 继承自 Dictionary 类,而 HashMap 是 Java1.2 引进的 Map interface 的一个实现。
- HashTable 的方法是 Synchronized 的,而 HashMap 不是,在多个线程访问 Hashtable 时,不需要自己为它的方法实现同步,而 HashMap 就必须为之提供外同步,或者改为使用ConcurrentHashMap。
- Hashtable 和 HashMap 采用的 hash/rehash 算法都大概一样,所以性能不会有很大的差异。
- HashMap 不能保证随着时间的推移 Map 中的元素次序是不变的。
- HashMap 的迭代器是 fail-fast 迭代器。
5. 总结
- 扩容是一个特别耗性能的操作,所以当程序员在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。
- 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改。
- HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap。
- JDK1.8引入红黑树很大程度优化了HashMap的性能。
阅读源码是一件十分耗费精力的事情,但从中你可以领悟到JDK作者的巧妙思路,在源码层面去理解为什么HashMap是线程不安全的,HashMap的扩容机制等等,而不是仅仅停留在会用HashMap这个容器的表面理解上。
参考自:tech.meituan.com/2016/06/24/…