hashmap 源码分析
1、底层原理
-
jdk 1.7 :数组+链表
-
jdk 1.8 :数组+链表+红黑树
引入红黑树的原因:
如果链表长度过长,那么遍历查找的时间复杂度为O(n),效率较慢;引入红黑树,查找效率为O(logn), 效率提升。在jdk 1.8中如果链表的个数大于等于8个且数组长度不小于64,就会转化为红黑树;当个数小于等于6时,又会转化为链表。
1、为什么不一开始就是红黑树,即基于数组+红黑树即可,红黑树相对于链表维护成本大,插入新数据之后可能会通过左旋、右旋、变色来维持平衡,当链路较短时,不适合用红黑树。
2、红黑树转化为链表
TreeNodes(红黑树)占用空间是普通Nodes(链表)的两倍,为了时间和空间的权衡。当hashCode离散性很好的时候,用到红黑树的概率非常小,因为数据均匀分布在数组每个位置中,几乎不会有位置的链表长度达到阈值。但是在随机hashCode下,离散性可能会变差,就可能导致不均匀的数据分布。理想情况下随机hashCode算法下,所有bin中节点的分布频率会遵循泊松分布,一个bin中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。
3、为什么转化为红黑树的阈值8和转化为链表的阈值6不一样 && 为什么转化为链表的阈值不是7
为了避免频繁来回转化。
这是遵循泊松分布,结点数达到6-8的概率,笔者认为基于时间与空间的权衡以及转化所消耗的资源,减少频繁的转化次数等考虑因素不选择7,如果是7,假设不断的增加和删除某个元素,那么就得不停的来回转化
6: 0.00001316
7: 0.00000094
8: 0.00000006
//有关参数说明 /** * 默认长度 16 */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; /** * 最大长度 2的30次方 */ static final int MAXIMUM_CAPACITY = 1 << 30; /** * 负载因子 */ static final float DEFAULT_LOAD_FACTOR = 0.75f; /** * 链表转为红黑树时的个数,即当个数大于等于8时转为红黑树 */ static final int TREEIFY_THRESHOLD = 8; /** * 树转为链表时的个数,即当个数小于等于6时转为链表 */ static final int UNTREEIFY_THRESHOLD = 6; /** * 树化时需满足数组容量不小于64 */ static final int MIN_TREEIFY_CAPACITY = 64;
2、构造方法
1、无参构造方法
public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // 默认为0.75 }
2、带参传入数组长度,实际调用另一种构造方法
public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); }
3、带参构造方法,传入数组长度及负载因子
public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0)//传入参数不符规则 throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY)//传入参数超过最大容量限制,最大容量为2的30次方 initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) //传入参数不符规则 throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity);//为数组初始化时服务,确保数组最终长度是2的幂次方,详情看扩容 }
4、传入一组数据
public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); } final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) { int s = m.size();//元素个数赋值 if (s > 0) { if (table == null) { // 数组为空 float ft = ((float)s / loadFactor) + 1.0F; int t = ((ft < (float)MAXIMUM_CAPACITY) ?//判断是否小于最大容量 (int)ft : MAXIMUM_CAPACITY);//大于等于则设置为最大容量 if (t > threshold) threshold = tableSizeFor(t);//为数组初始化时服务,确保数组最终长度是2的幂次方,详情看扩容 } else if (s > threshold)//个数大于所能承载的最多数量 resize();//扩容 for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) { K key = e.getKey(); V value = e.getValue(); putVal(hash(key), key, value, false, evict); } } }
数组默认长度DEFAULT_INITIAL_CAPACITY
为16,也可以通过传参initialCapacity
的方式规定数组长度。负载因子loadFactor
默认为0.75,threshold =initialCapacity x loadFactor
即数组最多能承载的键值对数量,若超过这个数量会进行扩容。数组table真正初始化在第一次插入数据的时候进行判断与扩容。
数组长度规定必须是2的幂次方,若传入的参数不是2的幂次方,会通过tableSizeFor
方法转化为 大于传入参数的2幂次方。
如 若传进来的长度为10,则最终长度为 16;若传进来的长度为17,则最终长度为 32。
static final int tableSizeFor(int cap) { //假设cap为17 00010001
int n = cap - 1;//保证了n的最右一位与cap的最右一位一定不同 n为16 00010000
// n = 00010000 | 00001000 = 00011000
n |= n >>> 1;
//n = 00011000 | 00000110 = 00011110
n |= n >>> 2;
//n = 00011110 | 00000001 = 00011111
n |= n >>> 4;
//n = 00011111 | 00000000 = 00011111
n |= n >>> 8;
//n = 00011111 | 00000000 = 00011111 = 31
n |= n >>> 16;
//如果n < 0则数组长度为1,否则判断n是否大于最大容量MAXIMUM_CAPACITY,MAXIMUM_CAPACITY等于2的30次方;
//大于等于则设置为等于最大容量,保证数组长度不会超过最大容量;小于则设置为n+1。
//样例最终返回32
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
为什么是2的幂次方
为了使得 得到的索引下标 在数组长度范围内,可以通过模运算,用 hash % 数组长度length,那么就可以保证下标在 0 - (length-1)范围内。计算机底层运算都是通过转化为二进制进行计算,因此如果通过二进制数与运算的效率会比十进制数模运算更快。
为了得到十进制数模运算相同的结果,length -1 与hash值进行与运算操作是能够保证得到的下标索引在数组长度范围内。
假设hash值为17,数组长度为16,十进制模运算 17 % 16 = 1;
二进制数 00010001 & 00001111 = 1;
结合二进制数的性质,hash值若超过16,那么16的倍数所在的位置一定在低四位左方,余数就必在低四位,因此通过 hash & length -1即可得到余数。
如果是其他数 如14 --> 二进制 00001110,这个数值与hash值与运算,那么1、3、5等数据是无法得到的,导致这些位置空缺,造成了空间浪费。
3、hash值
-
jdk 1.7 中 下标索引是直接计算 hashcode & length -1 得到结果
这样会导致只有hashcode值的低位进行了运算,那么低位相同但高位不同的数据得到的索引相同,即哈希冲突。
-
为了减少哈希冲突。jdk 1.8 在进行与运算之前,将hashcode值 与 右移16位的hashcode值进行异或运算,使高位也参与到这个环节。
//jdk 1.8 源码
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
假设 两个hashcode值 分别为 0000 0001 0000 1010,0000 0000 0000 1010;数组长度为16
使用jdk 1.7 的方法 分别得到的索引为 10 ,10
- 0000 0001 0000 1010 & 0000 0000 0000 1111 = 0000 0000 0000 1010 = 10
- 0000 0001 0000 1010 & 0000 0000 0000 1111 = 0000 0000 0000 1010 = 10
使用jdk 1.8 的方法 :
异或运算
- 0000 0001 0000 1010 ^ 0000 0000 0000 0001 = 0000 0001 0000 1011
- 0000 0000 0000 1010 ^ 0000 0000 0000 0000 = 0000 0000 0000 1010
再分别与运算
- 0000 0001 0000 1011 & 0000 0000 0000 1111 = 0000 0000 0000 1011 = 11
- 0000 0001 0000 1010 & 0000 0000 0000 1111 = 0000 0000 0000 1010 = 10
得到的索引下标不同
4、put方法
hashmap 通过 put 方法存储数据,以下是对 jdk 1.8版本 put 方法源码的解读及注释
- 判断数组是否为null 或 长度 是否为0 ,满足其一条件,进行扩容(数组在第一次put数据的时候进行初始化
- 将hash值 与运算 数组长度(length -1)得到索引下标,判断该下标的数组位置是否为null,null 则直接插入
- 不为空,则判断 该下标的结点与新结点的key值是否相同,相同则更新value值,并返回旧value
- 不相同则判断是否为树结构,是则放入树结构
- 不为树结构,则说明为链表结构,遍历链表判断是否有相同key值的结点,有则覆盖,并返回旧value;无则到达链表结尾,插入链表尾部。插入完毕,判断结点数是否大于等于8,转为红黑树结构
- 最终判断当前数组结点数,如果超过数组最多能承载的键值对数量(
threshold
),进行扩容。
public V put(K key, V value) {//调用putVal方法
return putVal(hash(key), key, value, false, true);
}
//onlyIfAbsent 为true ,则若新插入的结点key值已存在,不改变其原有的value值 ,这里为false,即会更新value值
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)//若数组长度为0则扩容
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)//若得到的下标所在的空间为null,即没有数据在该位置
tab[i] = newNode(hash, key, value, null);//直接赋值
else {//该数组下标tab[i]有数据
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))//新结点与tab[i]节点的key值相同
e = p;
else if (p instanceof TreeNode)//新结点与tab[i]节点的key不相同 且 现在是红黑树结构
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) // 若binCount大于等于7,即链表结点数大于等于8
treeifyBin(tab, hash);//该方法会判断数组长度是否小于64,是才会进行转化为红黑树
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))//在链表中存有相同key值的结点
break;
p = e;
}
}
if (e != null) { // 即没有到达链表结尾就停止了,即在链表中存有相同key值的结点
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;//返回旧结点的value值
}
}
++modCount;
if (++size > threshold)//threshold = 数组长度x负载因子,整个数组结点数超过该数
resize();//扩容
afterNodeInsertion(evict);
return null;
}
5、扩容
扩容 :
- 数组初始化操作
- 真正的扩容:一般来说,将数组大小扩大为原来的两倍,新建一个两倍大的数组,将原数组的存储数据复制到新数组;如果数组长度已经超过了最大长度 - - 2的30次方,则通过改变数组最多能承载的结点数
threshold
来进行扩容。
扩容的时候
-
jdk 1.7需要对原数组中的元素进行重新hash定位在新数组的位置
-
1.8采用更简单的判断逻辑,位置不变或原索引+旧容量大小;
元素在重新计算hash之后,因为n变为2倍,那么2n-1的二进制在高位比n-1多1,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+n”
jdk 1.8 假设原数组大小为16 ,length -1 --> 00001111;
新数组大小为 32, length -1 --> 00011111
若原来的hash值为 00001010 ,原索引为 10,新索引为 10;
若原来的hash值为 00011010 ,原索引为 10,新索引为 10 + 16 = 26;
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;//为null则赋值为0,否则赋值为原数组长度
int oldThr = threshold;//数组最多能承载的结点数
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {//如果原数组大小已经超过最大容量-2的30次方
threshold = Integer.MAX_VALUE; //将threshold更改,即通过改变数组最多能承载的结点数来进行扩容
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)//原数组的两倍小于最大容量且原数组长度大于等于16
newThr = oldThr << 1; // 新threshold为两倍原来的threshold,数组新长度也为旧数组两倍
}
//以下else if 与 else语句是对数组进行初始化操作
else if (oldThr > 0) // 说明构造方法中用户传入参数数组长度 最终对threshold赋值
newCap = oldThr;//这是在对数组初始化时,将数组长度赋值为(处理过后的用户传入参数)
else { // 说明构造方法未对数组长度说明 ,即数组长度初始化为默认长度
newCap = DEFAULT_INITIAL_CAPACITY;//16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);// 16 x 0.75 = 12
}
if (newThr == 0) {//原数组长度小于16
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;//两倍原数组长度x负载因子
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
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;
else if (e instanceof TreeNode)//树结构
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // 链表结构
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;
}
}
}
}
}
return newTab;
}
6、线程不安全
以put方法为例(在判断该下标的数组位置为null后,新结点直接插入)
线程1 在判断该下标索引的位置为null,此时线程2 也执行至此,同样判断为空,将新结点插入数组,线程2执行完毕;线程1 也将带着的新结点插入数组,最终导致的结果就是线程2所带的结点被覆盖。
hashmap 在对于线程不安全方面采取的措施是抛出异常 throw new ConcurrentModificationException()
,modCount
会记录操作次数,如果操作前后该数值不符合期待值,则会抛出异常。
public final void forEach(Consumer<? super K> action) {
Node<K,V>[] tab;
if (action == null)
throw new NullPointerException();
if (size > 0 && (tab = table) != null) {
int mc = modCount;//记录原数值
for (int i = 0; i < tab.length; ++i) {
for (Node<K,V> e = tab[i]; e != null; e = e.next)
action.accept(e.key);
}
if (modCount != mc) //若期间有其他线程执行了增删等操作 致使modCount改变,即抛出并发修改异常
throw new ConcurrentModificationException();
}
}
Hashtable 线程安全
使用synchronized关键字来实现线程安全:即线程执行操作过程中,其他线程阻塞
- 如果两个线程间的操作并没有冲突时(如插入的数据在不同的索引),线程1必须等线程2操作方法完毕才能进行操作,效率低。