文章内容
- 1、HashMap简介
- 2、类结构
- 3、属性
- 4、构造方法
- 5、方法
- 5.1、put方法
- 5.2、resize方法
- 6、jdk1.8的优化
1、HashMap简介
HashMap基于哈希表的Map接口实现,是以key-value存储形式存在。(除了不同步和允许使用 null 之外,HashMap 类与 Hashtable 大致相同)。在 JDK1.8 中,HashMap 是由数组 + 链表 + 红黑树构成,新增了红黑树作为底层数据结构,结构变得复杂了,但是效率也变的更高效。当一个值中要存储到Map的时候会根据Key的值来计算出他的hash,通过哈希来确认到数组的位置,如果发生哈希碰撞就以链表的形式存储。当链表长度过长时,HashMap会把这个链表转换成红黑树来存储。
但是这样的话问题来了,HashMap为什么要使用红黑树呢,这样结构的话不是更麻烦了吗??
这个问题我也没有想过,其实很多在看的时候只会在乎红黑树的实现而忽略到了为什么要使用的这个问题,我也是在写本文的时候突发疑惑。参考了网上的例子,同时也解释了为什么阀值为8:
因为Map中桶的元素初始化是链表保存的,其查找性能是O(n),而树结构能将查找性能提升到O(log(n))。当链表长度很小的时候,即使遍历,速度也非常快,但是当链表长度不断变长,肯定会对查询性能有一定的影响,所以才需要转成树。至于为什么阈值是8?我想去源码中找寻答案应该是最可靠的途径。
2、类结构
我们来看一下类结构
在阅读源码的时候一直有个问题很困惑就是HashMap已经继承了AbstractMap而AbstractMap类实现了Map接口,那为什么HashMap还要在实现Map接口呢?同样在ArrayList中LinkedList中都是这种结构。
据 java 集合框架的创始人Josh Bloch描述,这样的写法是一个失误。在java集合框架中,类似这样的写法很多,最开始写java集合框架的时候,他认为这样写,在某些地方可能是有价值的,直到他意识到错了。显然的,JDK的维护者,后来不认为这个小小的失误值得去修改,所以就这样存在下来了。
-
Cloneable 空接口,表示可拷贝
-
Serializable 序列化
-
AbstractMap 提供Map实现接口
3、属性
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //默认容量大小(必须是二的n次幂)
/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30; //最大容量(必须是二的n次幂)
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f; //默认负载因子(默认的0.75)
/**
* 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. The value must be greater
* than 2 and should be at least 8 to mesh with assumptions in
* tree removal about conversion back to plain bins upon
* shrinkage.
*/
static final int TREEIFY_THRESHOLD = 8; //当链表的值超过8则会转红黑树(1.8新增)
/**
* The bin count threshold for untreeifying a (split) bin during a
* resize operation. Should be less than TREEIFY_THRESHOLD, and at
* most 6 to mesh with shrinkage detection under removal.
*/
static final int UNTREEIFY_THRESHOLD = 6; //当链表的值小于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.
*/
//当Map里面的容量(即表长度)超过这个值时,链表才能进行树形化 ,否则元素太多时会扩容,而不是树形化
//为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD(因此树形化有两个条件,表长度 > 64 and 链表长度 > 8)
static final int MIN_TREEIFY_CAPACITY = 64;
/* ---------------- Fields -------------- */
/**
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node<K,V>[] table; // table用来初始化,类似容器来存放元素(必须是二的n次幂)
/**
* Holds cached entrySet(). Note that AbstractMap fields are used
* for keySet() and values().
*/
transient Set<Map.Entry<K,V>> entrySet; // 用来存放缓存
/**
* The number of key-value mappings contained in this map.
*/
transient int size; // Map中存储的元素数量
/**
* The number of times this HashMap has been structurally modified
* Structural modifications are those that change the number of mappings in
* the HashMap or otherwise modify its internal structure (e.g.,
* rehash). This field is used to make iterators on Collection-views of
* the HashMap fail-fast. (See ConcurrentModificationException).
*/
transient int modCount; // 用来记录HashMap的修改次数
/**
* The next size value at which to resize (capacity * load factor).
*
* @serial
*/
// (The javadoc description is true upon serialization.
// Additionally, if the table array has not been allocated, this
// field holds the initial array capacity, or zero signifying
// DEFAULT_INITIAL_CAPACITY.)
int threshold; // 阈值,用来调整大小下一个容量的值(计算方式为容量*负载因子)
/**
* The load factor for the hash table.
*
* @serial
*/
final float loadFactor; // 负载因子,创建HashMap也可调整,比如你希望用更多的空间换取时间,可以把负载因子调的更小一些,减少碰撞。
4、构造方法
-
HashMap()
构造一个空的 HashMap ,默认初始容量(16)和默认负载因子(0.75)。 -
HashMap(int initialCapacity)
构造一个空的 HashMap具有指定的初始容量和默认负载因子(0.75)。 -
HashMap(int initialCapacity, float loadFactor)
构造一个空的 HashMap具有指定的初始容量和负载因子。我们来分析一下。
最后落到tableSizeFor方法,后面讲下; -
HashMap(Map<? extends K, ? extends V> m)
通过旧的Map来创建新的HashMap对象。
tableSizeFor(扰动函数)解析
这是HashMap源码中的一个方法,这个方法的作用是找到一个大于或等于cap最近的2的n次方数
例:cap = 14,return 16;cap = 76,return 128;
static final int tableSizeFor(int cap) {
//-1可以保证当传入的数刚好是2的次方时,可以正确的返回其本身,例:传入的是16,经过下面的计算后还是返回16
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;
}
先解释一下|=、>>>这两个运算符。
- 运算符|=
n |= n 等同于 n = n | n;
| 是位运算符(或) - 运算符>>>
>>>是无符号右移运算符
就是把一个二进制数右移指定位数,正数高位补0,负数高位补1;
例:
int num = ; //00000000 00000000 00000000 10111101
//无符号右移1位
num = num>>> = 1;//00000000 00000000 00000000 01011110
//把上一步得到的num再做无符号右移2位运算得到
num = num>>> = 2;//00000000 00000000 00000000 00010111
了解了以上两个运算符的作用,应该就能初步看明白源码中的tableSizeFor方法了吧。但是大部分人第一次看的时候应该都是一脸懵逼的,颇有一种我明明都看明白了每步做了什么,为什么合起来就看不懂了的感觉。下面来解释一下。
先思考一个问题:
如果给我们一个二进制数cap = 00000000 00000000 00000000 10111101这个二进制数的最近的一个2的n次方数是多少呢?学过二进制,我们可以应该可以一眼看出来这个数是cap = 00000000 00000000 00000001 00000000
我们一眼看出来了,但是程序不行,所以要思考一种办法能通过代码找到这个数。
假设我们可以把最高位1以及其后面的位都置为1,然后加1是不是就能实现这个功能了,即:
步骤1、cap = 00000000 00000000 00000000 11111111
步骤2、cap = cap + 1 = 00000000 00000000 00000001 00000000
tableSizeFor就是通过这两个步骤实现的
static final int tableSizeFor(int cap) {
//-1可以保证当传入的数是2的次方时,可以正确的返回其本身,例:传入的是16,经过下面的计算后还是返回16
//步骤一
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
//步骤二n+1
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
这个代码的作用就是把最高位的1后面的所有位都置为1,而且从始至终其实只用到了最高位1这1位数字参与运算,非常的巧妙,膜拜。下面由一个例子来解释一下:
有个int数n,二进制为
n = 00000000 00000001 10111010 000001101;
现在我们只看最高位的1,1后面的位数用x代替,它是0还是1都不重要(后面你会发现确实不重要),表示为:
n = 00000000 00000001 xxxxxxx xxxxxxxx;
把n做无符号右移1位运算并将结果赋值给m:
m = 00000000 00000000 1xxxxxxx xxxxxxxx;
把n与m做或运算,并将结果赋值给n,得到:
n = 00000000 00000001 1xxxxxxx xxxxxxxx;
把n做无符号右移2位运算并将结果赋值给m:
m = 00000000 00000000 011xxxxx xxxxxxxx;
把n与m做或运算,并将结果赋值给n,得到:
n = 00000000 00000001 111xxxxx xxxxxxxx;
把n做无符号右移4位运算并将结果赋值给m:
m = 00000000 00000000 0001111x xxxxxxxx;
把n与m做或运算,并将结果赋值给n,得到:
n = 00000000 00000001 1111111x xxxxxxxx;
把n做无符号右移8位运算并将结果赋值给m:
m = 00000000 00000000 00000001 1111111x ;
把n与m做或运算,并将结果赋值给n,得到:
n = 00000000 00000001 11111111 1111111x ;
把n做无符号右移16位运算并将结果赋值给m:
m = 00000000 00000000 00000000 00000001 ;
把n与m做或运算,并将结果赋值给n,得到:
n = 00000000 00000001 11111111 11111111 ;
有没有发现到不知不觉就把最高位1后面的位数全部都变成1了,而且从始至终其实只有最高位的1以及通过这个1计算得到的数参与了运算,
前面被我们表示为x的位根本没有用到。
因为java中int类型是32位的,所以5次无符号右移刚好能覆盖到32位数。同理如果是8位数只需要位移到4,如果是16位数只需要位移到8,,64位数则需要位移到32。
n |= n >>> 1;//到这一步最多可以得到2位1
n |= n >>> 2;//到这一步最多可以得到4位1
n |= n >>> 4;//到这一步最多可以得到8位1
n |= n >>> 8;//到这一步最多可以得到16位1
n |= n >>> 16;//到这一步最多可以得到32位1
把得到的n加1就能得到了一个最近的大于n的2的次方数。
但这块有个疑问,该方法生成的值为啥赋给threshold??希望路过的大佬给解答下。
5、方法
5.1、put方法
我们可以看到put调用的是putVal来进行数据插入,但是要注意到key在这里执行了一下hash()方法,来看一下Hash方法是如何实现的。
理论上来说字符串的hashCode是一个int类型值,那可以直接作为数组下标了,且不会出现碰撞。但是这个hashCode的取值范围是[-2147483648, 2147483647],有将近40亿的长度,谁也不能把数组初始化的这么大,内存也是放不下的。
默认初始化的Map大小是16,所以获取的Hash值并不能直接作为下标使用,需要与数组长度进行取模运算得到一个下标值。 HashMap源码这里不只是直接获取哈希值,还进行了一次扰动计算,(h = key.hashCode()) ^ (h >>> 16)。把哈希值右移16位,也就正好是自己长度的一半(int类型32位的一半),之后与原哈希值做异或运算,这样就混合了原哈希值中的高位和低位,增大了随机性。
说白了,使用扰动函数就是为了增加随机性,让数据元素更加均衡的散列,减少碰撞。
从上面也可以得知HashMap是支持Key为空的,而HashTable是直接用过Key来获取HashCode所以key为空会抛异常。因为HashMap 使用的方法很巧妙,它通过 hash & (table.length -1)来得到该对象的保存位(数组下标),前面说过 HashMap 底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当 length 总是2n次方时,hash & (length-1)运算等价于对 length 取模,也就是 hash%length,但是&比%具有更高的效率。比如 n % 32 = n & (32 -1)。
现在再来看看需要验证的问题,以及putVal方法的实现:
- 验证容器懒加载(put元素后分配空间)√
- 正常put元素(index位置元素存在及不存在的情况)√
- 容器扩容 √
- 链表转红黑树
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,table 被延迟到插入新数据时再进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;// 空表进行首次扩容初始化table
if ((p = tab[i = (n - 1) & hash]) == null) // i位置上元素不存在(通过i=(n-1)&hash生成元素的数组下标)
tab[i] = newNode(hash, key, value, null); //结点不存在,就创建新的Node结点放到i位置
else { // i位置上元素存在
Node<K,V> e; K k;
// 如果i位置上原结点的hash值与待插入的一样,且key也一样,就是值的更新操作,进行覆盖
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode) // 红黑树结点,就调用特有的putTreeVal方法
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);
// 判断是否进行树化
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; // 向链表尾递推
}
}
// 如果链表头结点或遍历链表过程中某个结点,满足p.hash == hash
// &&((k = p.key) == key || (key != null && key.equals(k))),就进行更新操作,同时返回oldValue值
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;
}
5.2、resize方法
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// Cap 是 capacity 的缩写,容量。如果容量不为空,则说明已经初始化。
if (oldCap > 0) {
// 规定扩容上限就是 1 << 30
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
// 第一次put,由于是无参创建的map会直接用默认的容量及负载因子,同时计算阈值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) { // 阈值为0,则按照tableSize*loadFactor计算出来
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 赋值新阈值、新表大小
threshold = newThr;
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 将原Map中元素迁移到新Map
if (oldTab != null) {
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; // 元素重新rehash下标,并赋值到新Map
else if (e instanceof TreeNode) // 红黑树结点,就调用特有的split方法
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
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;
// 将高低位区分处理:
// 1、低位的loHead和loTail放的桶位置不变
// 2、高位的loHead和loTail放的桶位置变成原索引+旧容量
if ((e.hash & oldCap) == 0) {
// e.hash & oldCap 等价于 e.hash % (oldCap + 1) ,例如16位扩容
// 到32位大小的容器中,则0-15位的则计算为0,否则非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);
// 将某index下的单个链表区分为高位链(hi)及低位链(lo)
// 然后低位原位置不动,高位位置取oldCap + 原index
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
6、jdk1.8的优化
1)扩容元素拆分
拆分元素的过程中,原jdk1.7中会需要重新计算哈希值,但是到jdk1.8中已经进行优化,不在需要重新计算,提升了拆分的性能,设计的还是非常巧妙的。
随机使用一些字符串计算他们分别在16位长度和32位长度数组下的索引分配情况,发现原哈希值与扩容新增出来的长度16,进行&运算,如果值等于0,则下标位置不变。如果不为0,那么新的位置则是原来位置上加16。这样一来,就不需要计算每一个数组中元素的哈希值了。