1. HashMap
1.1 什么是HashMap
HashMap的本质是一个“Map”,因为它实现了Map接口。那么什么是Map呢?
An object that maps keys to values. A map cannot contain duplicate keys; each key can map to at most one value.
Map表示的是使key和value二者形成映射关系,从而组成一组键值对的对象。key-value键值对在Map中用Entry表示;Map需要保证Key是不重复的。(如何保证key不重复?重复了会怎么样?)
HashMap是基于哈希表(hash table,本质是一个Entry数组)来实现Map的,它只允许有一个key为null,但接受多个value为null(key为null如何处理?)。HashMap不保证元素的顺序,也就是说,遍历HashMap中的元素时,遍历的顺序不保证与元素插入的顺序相同。
在HashMap中有几个参数需要注意:
- capacity:容量。表示hash桶的个数,即Entry数组的长度(length),默认值为16。数组中的一个格子在这里叫做一个hash桶(bucket / bin)。
- size:表示HashMap中存在多少个键值对,即已存在多少个Entry。
- load factor:负载因子,表示在哈希表发生扩容之前,可以存放的键值对个数与容量的比例。该数值默认0.75,是结合性能和容量权衡而得。
- threshold:threshold = capacity * loadFactor。表示当HashMap中键值对的个数超过该数值后,会发生扩容(数组长度扩大为原来的两倍),并将键值对的位置重新排布,以上称为rehash。
1.2 构造HashMap
构造HashMap时,常用的构造函数是无参构造函数和指定capacity的构造函数。loadFactor建议保持默认值0.75不动。
在HashMap中,capacity必须是2的幂,若指定的数值不是2的幂,那么会取大于该数值且最接近该数值的2的幂数作为初始容量。做这件事的,是构造函数中调用的tableSizeFor(initialCapacity)
方法。
/**
* Constructs an empty <tt>HashMap</tt> with the specified initial
* capacity and load factor.
*
* @param initialCapacity the initial capacity
* @param loadFactor the load factor
* @throws IllegalArgumentException if the initial capacity is negative
* or the load factor is nonpositive
*/
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实例时,并没有将hash表创建出来。这一步被推迟到了第一次调用put()方法的时候。
- JDK 1.7
- JDK 1.8
2. 向HashMap中存放数据
向HashMap中存放数据,调用的方法是HashMap的put()方法。HashMap使用key计算出一个hash值,然后和hash表长度,即Entry[]数组长度取模,得到存放该数据的hash桶的下标index。
2.1 为啥capacity的值要是2的幂?
2.1.1 性能提升
capacity的值为啥是2的幂,这个事的背景源于上面提到的取模操作。
取模操作很简单,常规的写法如下:
int result = num1 % num2;
但是JDK这帮人比较追求极致,要把取模这事的性能优化一波,而位运算是性能最diao的,于是提出了用按位与&
这种操作来搞定这件事。
那么,按位与就等同于取模了吗?那不可能。它有个前提,就是只有当对 2 n 2^n 2n取模时才等效于对 2 n − 1 2^n - 1 2n−1做按位与。
X % 8;
等价于
X ^ (8 - 1);
为啥呢?
正常的取模操作,都是先做除法,假设X是13,那么13 % 8第一步就是要13 / 8,而13 / 8在二进制角度来看,等价于13 >>> 3,即右移动3位。那么被移出来的这3位正好是取模的结果。
被移出来的三位101转成十进制整数得5,正好是13%8的结果。
上面提到,13 % 8 等价于 13 ^ (8 - 1),从二进制角度来看:
从上图就可以比较清晰的看出其中的秘密了。
JDK 1.7的代码看着清晰一点:
JDK 1.8代码也是同样的方式:
2.1.2 干掉负数
public native int hashCode();
hashCode()方法返回的是一个int,它可没说是正整数啊,所以是能得到负数的。那 负数 % n 得到的还是个负数,hash表可没有index为负数的位置。
那么在计算index前,得干掉负数。
最容易想到的,取hashCode()绝对值。但这个不可行,如果hashCode()得到的是Integer.MIN_VALUE,那一取绝对值,溢出了,因为int的取值范围是 − 2 31 -2^{31} −231 到 2 31 − 1 2 ^ {31} -1 231−1。
在二进制中,第一位代表符号位,0是正,1是负。那把符号位改成0就行了。按位与操作,由于相与的数是一个整数,那它的符号位是0,即使hash值是负的,按位与后符号位也变0了.
比如,-13 ^ (8 - 1)
2.2 HashMap中的hash()函数
上面提到,想计算hash桶的位置,要用key的hash值和capacity - 1按位与。那么,key的hash值是怎么计算的?
很容易想到,Object类中提供了hashCode()方法,直接用key.hashCode()就完事儿了。实际上HashMap中也是这么做的,只不过它在这个基础上,又做了些骚操作,并将这一坨操作封装到一个hash()方法中。
-
JDK 1.7 hash()
final int hash(Object k) { int h = hashSeed; if (0 != h && k instanceof String) { return sun.misc.Hashing.stringHash32((String) k); } h ^= k.hashCode(); // This function ensures that hashCodes that differ only by // constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor). h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
-
JDK 1.8 hash()
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
这是想干啥?
还得回到那个按位与操作来看。
hash值是一个int类型整数,int是4字节,32位。从中间来一刀,前16位我们称为高位,后面16位为低位。
我们调用"hello".hashCode()得到99162322,用二进制表示就是00000101_11101001_00011000_11010010。
假设现在HashMap的capacity是16,想看看"hello"应该放在哪个桶,就要计算hashCode & (16 - 1),即99162322 & 15:
从上图可以发现,这个按位与基本上都是低16位在那忙活,高16位没它啥事儿啊。。。
假设有一连串key-value过来要放到HashMap中,恰好这一串key的hashCode()计算出来的数值,它们的高16位不同,但低16位相同,那他们几个与capacity - 1按位与之后,得到的结果是相同的,这就发生了碰撞,它们将放到同一个hash桶中。这不是我们想看到的结果,我们希望元素在hash表中越分散越好。那咋办?
HashMap的hash()函数中引入的一套操作就是为解决这个问题而生,称为“扰动”。刚才不是高16位没啥事么,那想办法让高16位也参与进来,那碰撞的概率不就小了么。
1.7 和 1.8版本的HashMap的初衷都是一样的,但1.8中的实现方式更简单,即让高16位与hashCode()得到的值做按位异或,得到新的值作为最终参与按位与的hash值。
还用刚才"hello"的例子结合1.8版hash()方法来看:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
h就是"hello".hashCode(),h >>> 16得到高16位。
然后再按位异或:
这样我们拿新的hash值再去做&,比之前直接用hashCode()的值去做&,冲突的概率会降低。
2.3 特殊处理
2.3.1 key为null
当key为null的时候,HashMap的hash函数该key分配的hash值是0,因此key为null的键值对,会被放入0号位hash桶中。
需要注意的是,key=null的键值对总是会放进0号位hash桶,但不代表0号位hash桶只能放key=null的键值对,任何取模为0的键值对都会放进去。
2.3.2 hash冲突
当1个以上的key被分配在同一个hash桶中时,称为hash冲突。HashMap中解决的办法是让这些在同一个桶中的键值对自成单链表。
如图,B、C、D被分配在3号桶,它们自成一个单链表。
在冲突严重的情况下,链表会越来越长,查询效率也会收到影响,JDK1.8中对此采取了优化。当链表长度超过8个节点时,会转为红黑树。同样,当一个hash桶中的节点数小于6时,会从红黑树再回到链表。
// 一个hash桶中的节点数超过该值 链表 -> 红黑树
static final int TREEIFY_THRESHOLD = 8;
// 一个hash桶中的节点数小于该值 红黑树 -> 链表
static final int UNTREEIFY_THRESHOLD = 6;
需要注意的是,一个良好的hash算法+合适的扩容机制,可以很好的将键值对分散地分布在hash表中,在某一个hash桶上,hash冲突的概率符合泊松分布。
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
3. 扩容机制
当hash表中key-value键值对数量达到一定值后,hash表会扩大其长度,并将已有的键值对重新分布在新的hash表中。扩容 + 键值对的重排 = rehash。
扩容的阈值即threshold = capacity * loadFactor;
每次扩容,新的容量 newCapacity = oldCapacity * 2;
3.1 扩容的时机
达到阈值或超过阈值后,一定会扩容吗?这在JDK 1.7和JDK 1.8中的实现有所不同。
-
JDK 1.7 —— 节选自put()和addEntry()方法
public V put(K key, V value) { if (table == EMPTY_TABLE) { inflateTable(threshold); } if (key == null) return putForNullKey(value); int hash = hash(key); int i = indexFor(hash, table.length); for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; addEntry(hash, key, value, i); 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); }
JDK 1.8 —— 节选自putVal()方法 640~651和662~663行
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;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
if (++size > threshold)
resize();
有啥不同呢?
-
扩容时机不同
在1.7中,会先算出key的hash值,求得hash桶号,然后准备插入键值对。在插入之前,判断当前hash表中键值对个数是否已经>=阈值。若这个条件满足,还会进一步判断待插入的键值对所分配的hash桶是否为空,如果是空桶,则该元素直接放入桶中,此时并不会扩容。
在1.8中,在计算hash桶号之后,会直接将键值对插入到对应的桶中,之后再判断当前hash表中键值对个数是否超过了阈值,若超过阈值则发生扩容。
-
是否重复计算hash值
在1.7中,若插入元素前,满足扩容条件,则hash表发生扩容,并将已有键值对重新分布。之后会再次计算带插入key的hash值,并分配hash桶。也就是说,待插入的key前后被计算了两次hash值。
在1.8中,由于元素先插入,再扩容,因此只会计算一次hash值。
3.2 键值对重排
hash表扩容后,对现有键值对的位置重排的策略在1.7和1.8中也有不同的实现。
1.7中比较简单,会拿Entry对象中保存的hash值再与当前hash表长度取模,计算新的桶位,然后将其插入到相应的位置。
由于每次扩容,新容量 = 原容量 * 2,再加上 hash & (capacity - 1)这种取模手法,新的桶位与原来的桶位相比,有一个规律:
新桶位 = 原桶位 + 原hash表容量,即 index = oldIndex + oldCapacity;或者还是原来的位置不动。
为啥?回想一下hash & (capacity - 1)。
假如oldCapacity = 8,8 - 1 = 7即00000111。
扩容后,newCapacity = oldCapacity * 2 = 16,16 -1 = 15 即00001111。
假如hash值是13,扩容前、扩容后取模过程如下:
假如hash值是5,扩容前、扩容后取模过程如下:
注意看有颜色的一列,扩容后,蓝色部分0变1,与hash值相应的那一位相与,若结果也0变1,则新桶位 = 原桶位 + 原hash表容量;若保持0不变,则新桶位 = 原桶位。
介于这一点,在1.8中,会先遍历原桶中的所有元素,然后与oldCapicity按位与,若结果为0,则表示扩容后位置不变;结果不为0,则扩容后新桶位 = 原桶位 + 原hash表容量。
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;
}
}
4. 并发问题
HashMap不是线程安全的,这在1.7和1.8中有不同的表现。
4.1 1.7的环形链表
1.7中,表现为并发场景下,rehash时可能出现环形链表。原因是,当发生hash冲突时,HashMap将被分配在同一个桶中的节点形成一个链表。在1.7中,采用的是头插法,即后来的元素从链表的头部插入。
如分别put()三个值A、B、C,且它们三个hash冲突,分配在同一个桶中,则它们在桶中的链表是C -> B -> A -> null。
当rehash时,会从头节点开始,依次遍历链表中的元素(即取出C,再取出B,再取出A),计算新的桶位,并继续按头插法将节点插入到新的桶位中。
假设扩容后A、B、C依然冲突,那么它们在重新分配后,正确的顺序应该由C -> B -> A -> null变为 A -> B -> C -> null。前后的顺序颠倒,导致在并发时,有可能形成一个环。
4.2 1.8的节点丢失
1.8中,将插入顺序改为了尾插法,即后来的元素从链表尾部插入。不会颠倒元素顺序。但在并发场景下,可能发生节点丢失。
即put()三个值A、B、C,且它们三个hash冲突,分配在同一个桶中,则它们在桶中的链表是A -> B -> C -> null。
假设现在HashMap中已经有了A,之后一个线程put(B),另一个线程put©,它们此时拿到的当前节点都是A,都要一个执行A.next = B,一个执行A.next = C。在并发时,它们其中一个的操作会被另一个覆盖掉,即发生了节点丢失。
5. 遍历顺序
5.1 HashMap不保证有序
HashMap不保证遍历时的顺序与插入元素时的顺序相同。HashMap在遍历键值对时,使用的是其内部实现的迭代器,其迭代的顺序是从hash表0号桶开始,向后找到第一个非空的桶,然后按链表的顺序,依次遍历元素。
如图:
5.2 LinkedHashMap保证有序
LinkedHashMap能够保证遍历顺序与插入顺序相同;还能按元素访问的频率的从小到大的顺序遍历。
5.2.1 保证遍历与插入顺序相同
LinkedHashMap继承了HashMap,主要扩展了Node节点,将节点与节点之间,会按插入顺序,使用双向链表联系起来。迭代器遍历顺序也不是从0号桶开始向后遍历,而是从双向链表的头节点开始,向后遍历;先插入的在前,后插入的在后。
5.2.2 按访问频率遍历
LinkedHashMap有一个构造器,可以指定一个accessOrder的boolean值,默认为false。当指定为true时,每次get()操作后,会将目标节点设为双向链表的尾节点。因此,越是最近访问的节点,越靠后;相反,访问频率低的节点,会慢慢被移到链表前端。
在遍历时,迭代器从头节点一次向后遍历,即保证了遍历顺序等同于节点的访问频次,从小到大排列。
在此基础上,LinkedHashMap可以作为LRU缓存的实现。
在创建LinkedHashMap实例时,重写它的removeEldestEntry()方法,该方法会在插入节点后调用。若它返回true,则会在插入新节点后,同步删除掉头指针指向的节点。
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}
下面是MyBatis中,使用LinkedHashMap实现LRU缓存的例子:
public class LruCache implements Cache {
private final Cache delegate;
private Map<Object, Object> keyMap;
private Object eldestKey;
public void setSize(final int size) {
keyMap = new LinkedHashMap<Object, Object>(size, .75F, true) {
private static final long serialVersionUID = 4267176411845948333L;
@Override
protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) {
boolean tooBig = size() > size;
if (tooBig) {
eldestKey = eldest.getKey();
}
return tooBig;
}
};
}
}
参考资料
https://www.hollischuang.com/archives/2091 hash函数分析
https://blog.youkuaiyun.com/yimi099/article/details/62043566 hashmap扩容理解
https://coolshell.cn/articles/9606.html 1.7 环形链表死锁