HashMap底层实现原理
Map接口
Java为数据结构中的映射定义了一个接口java.util.Map
,此接口主要有四个常用的实现类,分别是HashMap
、Hashtable
、LinkedHashMap
和TreeMap
,类继承关系如下图所示:
HashMap:
- 根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。
- HashMap最多只允许一条记录的键为null,允许多条记录的值为null。
- HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用
Collections
的synchronizedMap
方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap
。
Hashtable:
- Hashtable是遗留类,很多映射的常用功能与HashMap类似,不同的是它承自
Dictionary
类。 - Hashtable线程安全的,任一时间只有一个线程能写Hashtable,并发性不如
ConcurrentHashMap
,因为ConcurrentHashMap
引入了分段锁。 - Hashtable不建议在新代码中使用,不需要线程安全的场合可以用HashMap替换,需要线程安全的场合可以用
ConcurrentHashMap
替换。
LinkedHashMap:
- LinkedHashMap是HashMap的一个子类,保存了记录的插入顺序,在用
Iterator
遍历LinkedHashMap时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。
TreeMap:
- TreeMap实现SortedMap接口,能够把保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator遍历TreeMap时,得到的记录是排过序的。如果使用排序的映射,建议使用TreeMap。
- 在使用TreeMap时,key必须实现
Comparable
接口,或者在构造TreeMap传入自定义的Comparator
,否则会在运行时抛出java.lang.ClassCastException
类型的异常。
对于上述四种Map类型的类,要求映射中的key是不可变对象。不可变对象是该对象在创建后它的哈希值不会被改变。如果对象的哈希值发生变化,Map对象很可能就定位不到映射的位置了。
HashMap遍历方式
- 通过遍历entrySet来实现
Map<Integer, Integer> map = new HashMap<Integer, Integer>(); for(Map.Entry<Integer, Integer> entry : map.entrySet()){ System.out.println("key = " + entry.getKey() + ", value = " + entry.getValue()) }
- 通过遍历keySet、value来遍历所有键值对
Map<Integer, Integer> map = new HashMap<Integer, Integer>(); // iterating over keys only for (Integer key : map.keySet()) { System.out.println("Key = " + key); } // iterating over values only for (Integer value : map.values()) { System.out.println("Value = " + value); }
- 通过entrySet的Iterator来遍历
Map<Integer, Integer> map = new HashMap<Integer, Integer>(); Iterator<Map.Entry<Integer, Integer>> entries = map.entrySet().iterator(); while (entries.hasNext()) { Map.Entry<Integer, Integer> entry = entries.next(); System.out.println("Key = " + entry.getKey() + ", Value = " + entry.getValue()); }
- 迭代keys并搜索values(低效的)
Map<Integer, Integer> map = new HashMap<Integer, Integer>(); for (Integer key : map.keySet()) { Integer value = map.get(key); System.out.println("Key = " + key + ", Value = " + value); }
HashMap与HashTable区别
- 从层级结构上看,HashMap、HashTable有一个共用的Map接口。另外,HashTable还单独继承了一个抽象类Dictionary(已废弃);
- HashTable线程安全,HashMap线程不安全;
- 初始值和扩容方式不同。HashTable的初始值为11,扩容为原大小的\(2*d+1\)。容量大小都采用奇数且为素数,且采用取模法,这种方式散列更均匀。但有个缺点就是对素数取模的性能较低(涉及到除法运算);而HashMap的长度都是2的次幂,这种方式的取模都是直接做位运算,性能较好。
- HashMap的key、value都可为null,且value可多次为null,key多次为null时会覆盖。但HashTable的key、value都不可为null,否则直接NPE(NullPointException)。
存储结构
从结构实现来讲,HashMap是数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的。
- JDK 1.7中HashMap内部为数组+链表的结构,会根据key的hashCode值来确定数组的索引(确认放在哪个桶里)。遇到索引相同的key,那么它们就会被分到一个桶中(hash冲突),HashMap会将同一个桶中的数据以链表的形式存储。
- 在JDK1.7中,当Hash冲突严重时,在桶上形成的链表会变的越来越长,这样在查询时的效率就会越来越低,时间复杂度为\(O(N)\)。
- JDK1.8中对大链表做了优化,如果链表长度到达阀值(默认是8),就会将链表转换成红黑二叉树,利用红黑树快速增删改查的特点提高HashMap的性能,修改后查询效率直接提高到了\(O(logN)\)。
HashMap存储方法初步理解
HashMap就是使用哈希表来存储的。哈希表为解决冲突,可以采用开放地址法和链地址法等来解决问题,Java中HashMap采用了链地址法。链地址法,就是数组加链表的结合。在每个数组元素上都添加一个链表结构,当数据被Hash后,得到数组下标,把数据放在对应下标元素的链表上:
map.put(, "a");
- 系统将调用"A"这个key的
hashCode()
方法得到其hashCode值(该方法适用于每个Java对象); - 然后再通过Hash算法的后两步运算(高位运算和取模运算)来定位该键值对的存储位置。
- 有时两个key会定位到相同的位置,表示发生了Hash碰撞。当然Hash算法计算结果越分散均匀,Hash碰撞的概率就越小,map的存取效率就会越高。
如果哈希桶数组很大,即使较差的Hash算法也会比较分散;如果哈希桶数组数组很小,即使好的Hash算法也会出现较多碰撞,所以就需要在空间成本和时间成本之间权衡。其实就是根据实际情况确定哈希桶数组的大小,并在此基础上设计好的hash算法减少Hash碰撞。
那么通过什么方式来控制map使得Hash碰撞的概率又小,哈希桶数组(Node[] table
)占用空间又少呢?
答案就是好的Hash算法和扩容机制。
HashMap字段
由源码可知,HashMap类中有一个非常重要的字段,就是Node[] table
,即哈希桶数组,它是一个Node[JDK1.8]的数组:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // 用来定位数组索引位置
final K key;
V value;
Node<K,V> next; // 链表的下一个node
Node(int hash, K key, V value, Node<K,V> 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) { ... }
}
Node是HashMap的一个内部类,实现了Map.Entry
接口,本质是就是一个映射(键值对)。上图中的每个黑色圆点就是一个Node对象。
从HashMap的默认构造函数源码可知,构造函数就是对下面几个字段进行初始化:
/**
* The number of key-value mappings contained in this map.
*/
transient int size;
/**
* The number of times this HashMap has been structurally modified
*/
transient int modCount;
/**
* The next size value at which to resize (capacity * load factor). 所能容纳的key-value对极限
*/
int threshold;
/**
* The load factor for the hash table. 负载因子
*/
final float loadFactor;
哈希表初始容量
Node[] table
的初始化长度length
(默认值是16)
- 初始容量过大,那么遍历时速度就会受影响;
- 初始容量过小,散列表再散列(扩容的次数)可能就变得多,扩容也是一件非常耗费性能的一件事。
在HashMap中,哈希桶数组table的长度length大小必须为\(2^n\)(一定是合数),这是一种非常规的设计。常规的设计是把桶的大小设计为素数,相对来说素数导致冲突的概率要小于合数。Hashtable初始化桶大小为11,就是桶大小设计为素数的应用(Hashtable扩容后不能保证还是素数)。HashMap采用这种非常规设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。
装载因子
Load factor
为负载因子(默认值是0.75)
- 装载因子初始值大了,可以减少散列表再散列(减少扩容的次数),但同时会导致散列冲突的可能性变大!
- 装载因子初始值小了,可以减小散列冲突的可能性,但同时扩容的次数可能就会变多!
默认的负载因子0.75是对空间和时间效率的一个平衡选择,建议大家不要修改,除非在时间和空间比较特殊的情况下:
- 如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值;
- 相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。
threshold
是HashMap所能容纳的最大数据量的Node(键值对)个数。threshold = length * Load factor
。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。
threshold
结合负载因子的定义公式可知,threshold
就是在此Load factor
和length
(数组长度)对应下允许的最大元素数目,超过这个数目就重新resize(扩容),扩容后的HashMap容量是之前容量的两倍。
size和modCount
size
这个字段是HashMap中实际存在的键值对数量。注意和table的长度length
、容纳最大键值对数量threshold
的区别。而modCount
字段主要用来记录HashMap内部结构发生变化的次数,主要用于迭代的快速失败。内部结构发生变化指的是结构发生变化,例如put新键值对,但是某个key对应的value值被覆盖不属于结构变化。
与红黑树相关的参数
/**
* The bin count threshold for using a tree rather than list for a bin.
* Bins are converted to trees when adding an element to a bin with at least this many nodes.
* 1.桶的树化阈值:即链表转成红黑树的阈值,在存储数据时,当链表长度 > 该值时,则将链表转换成红黑树
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* The bin count threshold for untreeifying a (split) bin during a resize operation.
* 2. 桶的链表还原阈值:即红黑树转为链表的阈值,当在扩容resize()时(此时HashMap的数据存储位置会重新计算),在重新计算存储位置后,当 原有的红黑树内数量 < 6 时,则将红黑树转换成链表
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* The smallest table capacity for which bins may be treeified.(Otherwise the table is resized if too many nodes in a bin.)
* Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts between resizing and treeification thresholds.
* 3. 最小树形化容量阈值:即 当哈希表中的容量 > 该值 时,才允许进行树形化链表操作(即将链表转换成红黑树)。
* 否则,若桶(哈希表中的一个小格子)内元素太多时,则直接扩容,而不是树形化(如果不满足,不是先进行树形化,而是看是否需要扩容)。
* 为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
* 含义:得先把哈希表扩容变大(64),然后才考虑树形化链表操作
*/
static final int MIN_TREEIFY_CAPACITY = 64;
为什么在JDK1.8中进行对HashMap优化的时候,把链表转化为红黑树的阈值是8?
- 选择6和8(如果链表小于等于6树还原转为链表,大于等于8转为树),中间有个差值7可以有效防止链表和树频繁转换。
- 容器中节点分布在hash桶中的频率遵循泊松分布,桶的长度超过8的概率非常非常小。
- 理想情况下,HashCode随机分布,当负载因子设置成0.75时,那么在桶中元素个数的概率大致符合0.5的泊松分布,桶中元素个数达到8的概率小于千万分之一,因为转化为红黑树还是比较耗时耗力的操作,自然不希望经常进行,但如果设置得过大,将失去设置该值的意义。
功能实现-方法
HashMap的内部功能实现很多,下面主要从根据key获取哈希桶数组索引位置、put方法、get方法的详细执行过程、扩容过程三个具有代表性的点深入展开讲解。
确定哈希桶数组索引位置
不管增加、删除、查找键值对,定位到哈希桶数组的位置都是很关键的第一步。
HashMap的数据结构是数组和链表的结合,所以当然希望这个HashMap里面的元素位置尽量分布均匀些,尽量使得每个位置上的元素数量只有一个。那么当用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是要的,不用遍历链表,大大优化了查询的效率。
散列函数是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数,它有如下一个基本特性:根据同一散列函数计算出的散列值如果不同,那么输入值肯定也不同。但是,根据同一散列函数计算出的散列值如果相同,输入值不一定相同。两个不同的输入值,根据同一散列函数计算出的散列值相同的现象叫做碰撞。
// 方法一:
// JDK 1.8实现:将键key转换成哈希码(hash值)操作 = 使用hashCode() + 1次位运算 + 1次异或运算(2次扰动)
static final int hash(Object key) {
int h;
// h = key.hashCode() 第一步:取hashCode值
// h ^ (h >>> 16) 第二步:异或运算(高位参与运算)
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// JDK 1.7实现:将键key转换成哈希码(hash值)操作 = 使用hashCode() + 4次位运算 + 5次异或运算(9次扰动)
static final int hash(int h) {
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
// 方法二:计算存储位置的函数
static int indexFor(int h, int length) { // jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
return h & (length-1); // 第三步:jdk1.7取模运算(与运算)
}
这里的Hash算法本质上就是三步:取key的hashCode值、高位运算、取模运算。
- 对于任意给定的对象,只要它的
hashCode()
返回值相同,那么程序调用方法一所计算得到的Hash码值总是相同的。key.hashCode()
函数调用的是key键值类型自带的哈希函数,返回int型散列值。理论上散列值是一个int型,如果直接拿散列值作为下标访问HashMap主数组的话,考虑到2进制32位带符号的int表值范围从-2147483648到2147483648,前后加起来大概40亿的映射空间。只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。- 但问题是一个40亿长度的数组,内存是放不下的。HashMap扩容之前的数组初始大小才16。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来访问数组下标。
- 把hash值对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,模运算的消耗还是比较大的。在HashMap中是这样做的:调用方法二来计算该对象应该保存在table数组的哪个索引处。
- 这个方法通过
h & (table.length -1)
来得到该对象的保存位,而HashMap底层数组的长度总是\(2^n\),这是HashMap在速度上的优化。当length总是\(2^n\)时,h& (length-1)
运算等价于对length取模,也就是h%length
,但是&
比%
具有更高的效率。
- 这个方法通过
小结:
在HashMap中,哈希桶数组table的长度length大小必须为\(2^n\)(一定是合数),这是一种非常规的设计。HashMap采用这种非常规设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。
JDK 1.8中HashMap是通过key的hashCode的高16位和低16位异或(不同则为1)后,和桶的数量(哈希表容量)取模,得到索引位置,即key.hashcode()^(hashcode>>>16)%length
,当length是\(2^n\)时,h & (length-1)
运算等价于h % length
,而&
操作比%
效率更高。并且采用高16位和低16位进行异或,也可以让所有的位数都参与运算,使得在length比较小的时候也可以做到尽量的散列。
图中n为table的长度:
扰动处理
// JDK 1.8实现:将键key转换成哈希码(hash值)操作 = 使用hashCode() + 1次位运算 + 1次异或运算(2次扰动)
static final int hash(Object key) {
int h;
// h = key.hashCode() 第一步:取hashCode值
// h ^ (h >>> 16) 第二步:异或运算(高位参与运算)
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// JDK 1.7实现:将键key转换成哈希码(hash值)操作 = 使用hashCode() + 4次位运算 + 5次异或运算(9次扰动)
static final int hash(int h) {
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
问题1:为什么不直接采用经过hashCode()处理的哈希码作为存储数组table的下标位置?
HashTable
对key直接hashCode()
,若key为null时,会抛出异常,所以HashTable的key不可为null。- 容易出现哈希码与数组大小范围不匹配的情况,即计算出来的哈希码可能不在数组大小范围内,从而导致无法匹配存储位置。
key.hashCode()
函数调用的是key键值类型自带的哈希函数,返回int型散列值。理论上散列值是一个int型,如果直接拿散列值作为下标访问HashMap主数组的话,考虑到2进制32位带符号的int表值范围(\(-2^{31}\)\(\rightarrow\)\(2^{31}-1\))从-2147483648到2147483648,前后加起来大概40亿的映射空间。只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。- 但问题是一个40亿长度的数组,内存是放不下的。HashMap扩容之前的数组初始大小才16(最大\(2^{30}\))。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来访问数组下标。
问题2:为什么在计算数组下标前,需对哈希码进行二次处理:扰动处理?
- 实际上只能根据数组长度,取哈希码的低几位作为存储数组的下标位置。而哈希码的低几位位数有限,非常容易发生Hash冲突。因此通过扰动处理,加大哈希码低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性h和均匀性,最终减少Hash冲突。
- 所有处理的根本目的,都是为了提高存储
key-value
的数组下标位置的随机性和分布均匀性,尽量避免出现hash值冲突。即:对于不同key,存储的数组下标位置要尽可能不一样。
HashMap的put方法
HashMap的put方法执行过程可以通过下图来理解:
①判断键值对数组table[i]
是否为空或为null
,如果是,则执行resize()
进行扩容;
②根据键值key计算hash值得到插入的数组索引i,如果table[i]==null
,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;
③判断table[i]
的首个元素是否和key
一样,如果相同直接覆盖value
,否则转向④。这里的相同指的是hashCode
以及equals
;
④判断table[i]
是否为treeNode,即table[i]
是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;
⑤遍历table[i]
,判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在,直接覆盖value即可;
⑥插入成功后,判断实际存在的键值对数量size
,是否超多了最大容量threshold
,如果超过,进行扩容。
JDK1.8HashMap的put方法源码如下:
1 public V put(K key, V value) { // map.put("Java", 2);
2 // 对key的hashCode()做hash
3 return putVal(hash(key), key, value, false, true);
4 }
5
6 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
7 boolean evict) {
8 Node<K,V>[] tab; Node<K,V> p; int n, i;
9 // 步骤①:tab为空则创建
10 if ((tab = table) == null || (n = tab.length) == 0)
11 n = (tab = resize()).length;
12 // 步骤②:计算index,并对null做处理
13 if ((p = tab[i = (n - 1) & hash]) == null)
14 tab[i] = newNode(hash, key, value, null);
15 else {
16 Node<K,V> e; K k;
17 // 步骤③:节点key存在,直接覆盖value
18 if (p.hash == hash &&
19 ((k = p.key) == key || (key != null && key.equals(k))))
20 e = p;
21 // 步骤④:判断该链为红黑树
22 else if (p instanceof TreeNode)
23 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
24 // 步骤⑤:该链为链表
25 else {
26 for (int binCount = 0; ; ++binCount) {
27 if ((e = p.next) == null) {
28 p.next = newNode(hash, key,value,null);
// 链表长度大于8转换为红黑树进行处理
29 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
30 treeifyBin(tab, hash);
31 break;
32 }
// key已经存在直接覆盖value
33 if (e.hash == hash &&
34 ((k = e.key) == key || (key != null && key.equals(k))))
35 break;
36 p = e;
37 }
38 }
39
40 if (e != null) { // existing mapping for key
41 V oldValue = e.value;
42 if (!onlyIfAbsent || oldValue == null)
43 e.value = value;
44 afterNodeAccess(e);
45 return oldValue;
46 }
47 }
48 ++modCount;
49 // 步骤⑥:超过最大容量,就扩容
50 if (++size > threshold)
51 resize();
52 afterNodeInsertion(evict);
53 return null;
54 }
HashMap的get方法
计算需获取数据的hash值 \(\rightarrow\) 计算存放在数组table中的位置 \(\rightarrow\) 依次在数组、链表和红黑树中查找:
查询过程
首先会把输入的Key做一次Hash映射,得到对应的index:index = Hash("apple")
,由于Hash冲突,同一个位置有可能匹配到多个Entry,这时候就需要顺着对应链表的头节点,一个一个向下来查找。假设要查找的Key是“apple”:
第一步,查看的是头节点Entry6,Entry6的Key是banana,显然不是要找的结果。
第二步,查看的是Next节点Entry1,Entry1的Key是apple,正是要找的结果。之所以把Entry6放在头节点,是因为HashMap的发明者认为,后插入的Entry被查找的可能性更大(JDK1.7)。
HashMap的get方法实现思路:
- 判断表或key是否是null,如果是直接返回null;
- 判断索引处第一个key与传入key是否相等,如果相等直接返回;
- 如果不相等,判断链表是否是红黑二叉树,如果是,直接从树中取值,时间复杂度为\(O(logn)\);
- 如果不是树,就遍历链表查找,时间复杂度为\(O(n)\)。
get源码
/**
* 作用:根据键key,向HashMap获取对应的值
*/
map.get(key);
public V get(Object key) {
Node<K,V> e;
// 1. 计算需获取数据的hash值
// 2. 通过getNode()获取所查询的数据
// 3. 获取后,判断数据是否为空
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/**
* Implements Map.get and related methods
*/
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 如果表不是空的,并且要查找索引处有值,就判断位于第一个的key是否是要查找的key
// 定位键值对所在桶的位置(计算存放在数组table中的位置)
if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
// 先在数组中找,若存在,则直接返回
if (first.hash == hash && // always check first node
((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;
}
这里通过(n - 1)& hash
即可算出在桶数组中的位置。HashMap中桶数组的大小length总是2的幂,此时,(n - 1)& hash
等价于对length取余。但取余的计算效率没有位运算高。
扩容机制
扩容(resize)就是重新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组。
- JDK1.7衡量HashMap是否进行Resize的条件为:
HashMap.Size >= Capacity * LoadFactor
! - JDK1.7在扩容后,需按照原来方法重新计算,即
hashCode()->> 扰动处理 ->>(h & length-1)
!- 因为长度扩大以后,根据公式:
index = HashCode(Key) & (Length - 1)
,当原数组长度为8时,Hash运算是和111B
做与运算;新数组长度为16,Hash运算是和1111B
做与运算。Hash结果显然不同。
- 因为长度扩大以后,根据公式:
JDK1.7扩容方法
以JDK1.7的resize代码为例,进行分析:
1 void resize(int newCapacity) { // 传入新的容量
2 Entry[] oldTable = table; // 引用扩容前的Entry数组
3 int oldCapacity = oldTable.length;
4 if (oldCapacity == MAXIMUM_CAPACITY) { // 扩容前的数组大小如果已经达到最大(2^30)了
5 threshold = Integer.MAX_VALUE; // 修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
6 return;
7 }
8
9 Entry[] newTable = new Entry[newCapacity]; // 初始化一个新的Entry数组
10 transfer(newTable); //!!将数据转移到新的Entry数组里
11 table = newTable; // HashMap的table属性引用新的Entry数组
12 threshold = (int)(newCapacity * loadFactor);// 修改阈值
13 }
这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()
方法将原有Entry数组的元素拷贝到新的Entry数组里:
1 void transfer(Entry[] newTable) {
2 Entry[] src = table; // src引用了旧的Entry数组
3 int newCapacity = newTable.length;
4 for (int j = 0; j < src.length; j++) { // 遍历旧的Entry数组
5 Entry<K,V> e = src[j]; // 取得旧Entry数组的每个元素
6 if (e != null) {
7 src[j] = null; // 释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
8 do {
9 Entry<K,V> next = e.next;
10 int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
11 e.next = newTable[i]; // 标记[1]
12 newTable[i] = e; // 将元素放在数组上
13 e = next; // 访问下一个Entry链上的元素
14 } while (e != null);
15 }
16 }
17 }
static int indexFor(int h, int length) { // jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
return h & (length-1); // jdk1.7取模运算(与运算)
}
newTable[i]
(标记[1])的引用赋给了e.next
,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素,终会被放到Entry链的尾部(如果发生了hash冲突的话),这一点和Jdk1.8有区别解。在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。
下面举个例子说明下扩容过程:
假设hash算法就是简单的用key mod 表的大小(也就是数组的长度)
。其中的哈希桶数组table的length=2
,key = 3、7、5
,put顺序依次为5、7、3
。在mod 2
以后都冲突在table[1]
这里。这里假设负载因子 loadFactor=1
,即当键值对的实际大小size(3) 大于 table的实际大小(length=2)时,进行扩容。接下来的步骤是哈希桶数组resize成4,然后所有的Node重新rehash的过程。
JDK1.8扩容方法
- 什么场景下会触发扩容?
- 场景1:哈希table为null或长度为0;
- 场景2:Map中存储的k-v对数量超过了阈值threshold;
- 场景3:链表中的长度超过了TREEIFY_THRESHOLD,但表长度却小于MIN_TREEIFY_CAPACITY。
- 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示意图:
JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置。
JDK1.8的resize源码:
1 final Node<K,V>[] resize() {
2 Node<K,V>[] oldTab = table;
3 int oldCap = (oldTab == null) ? 0 : oldTab.length;
4 int oldThr = threshold;
5 int newCap, newThr = 0;
6 if (oldCap > 0) {
7 // 超过最大值就不再扩充了,就只好随你碰撞去吧
8 if (oldCap >= MAXIMUM_CAPACITY) {
9 threshold = Integer.MAX_VALUE;
10 return oldTab;
11 }
12 // 没超过最大值,就扩充为原来的2倍
13 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
14 oldCap >= DEFAULT_INITIAL_CAPACITY)
15 newThr = oldThr << 1; // double threshold
16 }
17 else if (oldThr > 0) // initial capacity was placed in threshold
18 newCap = oldThr;
19 else { // zero initial threshold signifies using defaults
20 newCap = DEFAULT_INITIAL_CAPACITY;
21 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
22 }
23 // 计算新的resize上限
24 if (newThr == 0) {
25
26 float ft = (float)newCap * loadFactor;
27 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
28 (int)ft : Integer.MAX_VALUE);
29 }
30 threshold = newThr;
31 @SuppressWarnings({"rawtypes","unchecked"})
32 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
33 table = newTab;
34 if (oldTab != null) {
35 // 把每个bucket都移动到新的buckets中
36 for (int j = 0; j < oldCap; ++j) {
37 Node<K,V> e;
38 if ((e = oldTab[j]) != null) {
39 oldTab[j] = null;
40 if (e.next == null)
41 newTab[e.hash & (newCap - 1)] = e;
42 else if (e instanceof TreeNode)
43 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
44 else { // 链表优化重hash的代码块
45 Node<K,V> loHead = null, loTail = null;
46 Node<K,V> hiHead = null, hiTail = null;
47 Node<K,V> next;
48 do {
49 next = e.next;
50 // 原索引
51 if ((e.hash & oldCap) == 0) {
52 if (loTail == null)
53 loHead = e;
54 else
55 loTail.next = e;
56 loTail = e;
57 }
58 // 原索引+oldCap
59 else {
60 if (hiTail == null)
61 hiHead = e;
62 else
63 hiTail.next = e;
64 hiTail = e;
65 }
66 } while ((e = next) != null);
67 // 原索引放到bucket里
68 if (loTail != null) {
69 loTail.next = null;
70 newTab[j] = loHead;
71 }
72 // 原索引+oldCap放到bucket里
73 if (hiTail != null) {
74 hiTail.next = null;
75 newTab[j + oldCap] = hiHead;
76 }
77 }
78 }
79 }
80 }
81 return newTab;
82 }
线程安全性
在多线程使用场景中,应该尽量避免使用线程不安全的HashMap,而使用线程安全的ConcurrentHashMap
。那么为什么说HashMap是线程不安全的?
put的时候导致的多线程数据不一致
假设有两个线程A和B,首先A希望插入一个key-value
对到HashMap中,首先计算记录所要落到的桶的索引坐标
,然后获取到该桶里面的链表头结点
,此时线程A的时间片用完了。
而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的桶索引,和线程B要插入的记录计算出来的桶索引是一样
的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。
HashMap的get操作可能因为resize而引起死循环
下面举例子说明在并发的多线程使用场景中使用HashMap可能造成死循环。代码例子如下(便于理解,仍然使用JDK1.7的环境):
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();
}
}
其中,map初始化为一个长度为2的数组,loadFactor=0.75,threshold=2*0.75=1.5,也就是说当put第二个key的时候,map就需要进行resize。
1 void resize(int newCapacity) { // 传入新的容量
2 Entry[] oldTable = table; // 引用扩容前的Entry数组
3 int oldCapacity = oldTable.length;
4 if (oldCapacity == MAXIMUM_CAPACITY) { // 扩容前的数组大小如果已经达到最大(2^30)了
5 threshold = Integer.MAX_VALUE; // 修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
6 return;
7 }
8
9 Entry[] newTable = new Entry[newCapacity]; // 初始化一个新的Entry数组
10 transfer(newTable); //!!将数据转移到新的Entry数组里
11 table = newTable; // HashMap的table属性引用新的Entry数组
12 threshold = (int)(newCapacity * loadFactor);// 修改阈值
13 }
这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()
方法将原有Entry数组的元素拷贝到新的Entry数组里:
1 void transfer(Entry[] newTable) {
2 Entry[] src = table; // src引用了旧的Entry数组
3 int newCapacity = newTable.length;
4 for (int j = 0; j < src.length; j++) { // 遍历旧的Entry数组
5 Entry<K,V> e = src[j]; // 取得旧Entry数组的每个元素
6 if (e != null) {
7 src[j] = null; // 释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
8 do {
9 Entry<K,V> next = e.next;
10 int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
11 e.next = newTable[i]; // 标记[1]
12 newTable[i] = e; // 将元素放在数组上
13 e = next; // 访问下一个Entry链上的元素
14 } while (e != null);
15 }
16 }
17 }
通过设置断点让线程1和线程2同时debug到transfer方法的首行。注意此时两个线程已经成功添加数据。放开thread1的断点至transfer方法的Entry next = e.next;
这一行;然后放开线程2的的断点,让线程2进行resize。结果如下图:
注意,Thread1的e
指向了key(3),而next
指向了key(7),其在线程二rehash后,指向了线程二重组后的链表。
线程一被调度回来执行,先是执行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)
, 环形链表就这样出现了:
于是,当用线程一调用map.get(11)
时,悲剧就出现了——Infinite Loop。
JDK1.8与JDK1.7对比
HashMap中,如果key经过hash算法得出的数组索引位置全部不相同,即Hash算法非常好,那样的话,getKey方法的时间复杂度就是\(O(1)\);如果Hash算法技术的结果碰撞非常多,假如Hash算极其差,所有的Hash算法结果得出的索引位置一样,那样所有的键值对都集中到一个桶中,或者在一个链表中,或者在一个红黑树中,时间复杂度分别为\(O(N)\)和\(O(logN)\)。 鉴于JDK1.8做了多方面的优化,总体性能优于JDK1.7。
JDK1.7扩容:
- JDK1.7衡量HashMap是否进行Resize的条件为:哈希table为null或长度为0;
HashMap.Size >= Capacity * LoadFactor
! - JDK1.7在扩容后,需按照原来方法重新计算,即
hashCode()->> 扰动处理 ->>(h & length-1)
!- 因为长度扩大以后,根据公式:
index = HashCode(Key) & (Length - 1)
,当原数组长度为8时,Hash运算是和111B
做与运算;新数组长度为16,Hash运算是和1111B
做与运算。Hash结果显然不同。
- 因为长度扩大以后,根据公式:
JDK1.8扩容:
- 什么场景下会触发扩容?
- 场景1:哈希table为null或长度为0;
- 场景2:Map中存储的k-v对数量超过了阈值threshold;
- 场景3:链表中的长度超过了TREEIFY_THRESHOLD,但表长度却小于MIN_TREEIFY_CAPACITY。
- JDK1.8做的优化:使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。
HashSet
HashSet底层就是基于HashMap实现的。(HashSet的源码非常少,除了clone()、writeObject()、readObject()是HashSet本身实现之外,其他方法都是直接调用HashMap中的方法。
HashMap | HashSet |
---|---|
实现Map接口 | 实现Set接口 |
存储键值对 | 仅存储对象 |
调用put() 向map中添加元素 |
调用add() 方法向Set中添加元素 |
HashMap使用键(key)计算hashcode | HashSet使用成员对象来计算hashcode值,对于两个对象来说,hashcode可能相同,所以equals()方法从是用来判断对象的相等性 |
HashSet如何检查重复
- 当对象add()入HashSet时,会先计算对象的hashcode值,来判断对象加入的位置,同时也会与其他加入的对象的hashcode值作比较;
- 如果没有相符的hashcode,HashSet会假设对象没有重复出现;
- 如果发现有相同hashcode值的对象,这时会调用
equals()
方法来检查hashcode相等的对象是否真的相等。如果两者相同,HashSet就不会让加入操作成功。
import java.util.Set;
import java.util.HashSet;
public class TestHashSet
{
public static void main(String[] args)
{
// Create a hashset
Set<String> set = new HashSet<>();
// Add strings to the set
set.add("London");
set.add("Beijing");
set.add("New York");
set.add("Beijing");
System.out.println(set);
// Display the elements in the hash set
for (String s : set)
System.out.print(s.toUpperCase() + " ");
}
}
/*
[Beijing, New York, London]
BEIJING NEW YORK LONDON
*/
Beijing
被添加多次,但是只有一个被存储,因为集合不允许有重复的元素。- 字符串没有按照它们被插入集合时的顺序存储,因为散列集中的元素没有特定的顺序。要强加给它们一个顺序,就需要使用
LinkedHashSet
类。 Collection
接口继承Iterable
接口,因此集合中的元素是可遍历的。使用了foreach
循环来遍历集合中的所有元素。
ConcurrentHashMap
想要避免HashMap的线程安全问题,可以改用HashTable
或者Collections.synchronizedMap
。但是,这两者有着共同的问题——性能!无论读操作还是写操作,它们都会给整个集合加锁
,导致同一时间的其他操作因其而阻塞。
</font color=red>ConcurrentHashMap优势就是采用了锁分段
技术,每一个Segment就相当于一个自治区,读写操作高度自治,Segment之间互不影响。其中Segment继承于ReentrantLock。不会像HashTable那样不管是put还是get操作都需要做同步处理。
JDK1.7结构
static final class Segment<K,V> extends ReentrantLock implements Serializable
Segment本身就相当于一个HashMap对象。同HashMap一样,Segment包含一个HashEntry数组,数组中的每一个HashEntry既是一个键值对,也是一个链表的头节点。
单一的Segment结构如下:
像这样的Segment对象,在ConcurrentHashMap集合中有多少个呢?有\(2^N\)个,共同保存在一个名为segments的数组当中。
因此整个ConcurrentHashMap的结构如下:
ConcurrentHashMap是一个二级哈希表,在一个总的哈希表下面,有若干个子哈希表。
</font color=red>ConcurrentHashMap优势就是采用了锁分段
技术,每一个Segment就相当于一个自治区,读写操作高度自治,Segment之间互不影响。
ConcurrentHashMap采用了分段锁技术,其中Segment继承于ReentrantLock。不会像HashTable那样不管是put还是get操作都需要做同步处理。理论上ConcurrentHashMap支持CurrencyLevel (Segment 数组数量)的线程并发。每当一个线程占用锁访问一个Segment时,不会影响到其他的Segment。
JDK1.7并发读写情形
1.不同Segment的并发写入:
不同Segment的写入
是可以并发执行的。
2.同一Segment的写读:
同一Segment的写和读
是可以并发执行的。
3.同一Segment的并发写入:
Segment的写入是需要上锁的,因此对同一Segment的并发写入会被阻塞。
由此可见,ConcurrentHashMap当中每个Segment各自持有一把锁。在保证线程安全的同时降低了锁的粒度,让并发操作效率更高。
JDK1.7Get和Put方法
get方法
- 为输入的Key做Hash运算,得到hash值。
- 通过hash值,定位到对应的Segment对象
- 再次通过hash值,定位到Segment当中数组的具体位置。
put方法
- 为输入的Key做Hash运算,得到hash值;
- 通过hash值,定位到对应的Segment对象;
- 获取可重入锁;
- 再次通过hash值,定位到Segment当中数组的具体位置;
- 插入或覆盖HashEntry对象;
- 释放锁。
ConcurrentHashMap的写
需要二次定位
:首先定位到Segment
,然后再定位到Segment内的具体数组下标
!
JDK1.7 size方法
既然每一个Segment都各自加锁,那么在调用size方法时,如何解决一致性问题?
size方法的目的是统计ConcurrentHashMap的总元素数量,自然需要把各个Segment内部的元素数量汇总起来。但是,如果在统计Segment元素数量的过程中,已统计过的Segment瞬间插入新的元素,这时候该怎么办呢?
线程A:size = 3 + 3 ?
ConcurrentHashMap的size方法是一个嵌套循环,大体逻辑如下:
- 遍历所有的Segment。
- 把Segment的元素数量累加起来。
- 把Segment的
修改次数
累加起来。 - 判断所有Segment的
总修改次数
是否大于上一次的总修改次数
。如果大于,说明统计过程中有修改,重新统计,尝试次数+1;如果不是。说明没有修改,统计结束。 - 如果尝试次数超过阈值,则对每一个Segment
加锁
,再重新统计。 - 再次判断所有Segment的总修改次数是否大于上一次的总修改次数。由于已经加锁,次数一定和上次相等。
- 释放锁,统计结束。
为什么这样设计呢?
这种思想和乐观锁悲观锁的思想如出一辙。为了尽量不锁住所有Segment,首先乐观
地假设Size过程中不会有修改。当尝试一定次数,才无奈转为悲观锁,锁住所有Segment保证强一致性。
JDK1.8
抛弃了原有的Segment分段锁,而采用了CAS + synchronized
来保证并发安全性。也将1.7中存放数据的HashEntry改为Node,但作用都是相同的。其中的val, next
都用了volatile修饰,保证了可见性。