记录学习日常 - 1
最近看了一下JDK1.7HashMap的源码,总结一下自己的收获
HashMap
重要属性
Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
在JDK1.7中,HashMap的底层是数组+链表,底层的实现其实就是这个属性,table是底层的数组结构,而Entry对象则是个链表,默认是个空数组。
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
}
int modCount;
这个属性可以理解为变更次数,在JDK中很多集合代码中都有体现,是一个快速失败的属性,在map数据发生变化的时候,都会增加modCount,如果使用迭代器的时候,迭代器创建时会记录expectedModCount等于map当前的modCount,当迭代器遍历的时候,会判断expectedModCount和modCount是否相等,来判断map在迭代器生成之后是否发生改变,如果发生改变就抛出异常。
final Entry<K,V> nextEntry() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
...
}
构造器
默认无参构造器和传容量的构造器在代码实现上都是使用的传递容量和加载因子的构造器,构造器相对来讲比较简单,校验容器,设置加载因子,设置阈值为传递进来的容量。
put方法
在执行put方法中,当判断数组是默认的空数组时,调用inflateTable方法进行一次初始化
当key是null时,调用putForNullKey方法
当key不是null值时
- HashMap的hash方法取到hash值
- 计算下标
- 遍历数组当前下标的Entry链表,判断是不是存在key相等的节点,如果存在就替换这个key的value为当前的value,否则调用addEntry方法进行添加
inflateTable方法
数组初始化,设置数组的长度为数组比设置容量小的最大的2的幂(这个设计会在计算下标的时候提到),设置阈值为容量 * 加载因子。
例如
设置容量是17得到实际容量为16
设置容量是34得到实际容量为32.
Integer的这段代码很有意思,他是通过位运算把一个整形的数字分别右移1,2,4,8,16位然后每次都跟自己本身取或,其实就是一个把为1的最高位之后所有的位都设置位1,然后再减去这个数右移1位,得到的其实就是最高位数的1,从而也就找到了比设置容量大的最小的2的幂。
i |= (i >> 1):
0000 0001 0000 0001
0000 0000 1000 0000
0000 0001 1000 0001
i |= (i >> 2):
0000 0001 1000 0001
0000 0000 0110 0000
0000 0001 1110 0001
number - 1其实是为了避免传入的值本身就是二的幂,比如16,得到的容器却出现32的场景
Integer
public static int highestOneBit(int i) {
// HD, Figure 3-1
i |= (i >> 1);
i |= (i >> 2);
i |= (i >> 4);
i |= (i >> 8);
i |= (i >> 16);
return i - (i >>> 1);
}
HashMap
private static int roundUpToPowerOf2(int number) {
// assert number >= 0 : "number must be non-negative";
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}
putForNullKey方法
HashMap是允许null值并且因为null没有hashcode,所以它的做法是将null存放在下标为0的数组链表中。
做法:遍历数组下标为0的Entry链表,判断是不是存在key为null的节点,如果存在就替换这个key的value为当前的value,否则调用addEntry方法进行添加
hase方法
取得当前key的hashcode,然后跟hashseed进行异或运算,然后得到的值分别和本身右移20,12,7,4进行 ^ 操作,返回结果。
大部分使用场景下可以默认hashseed是0
多次右移加异或是为了让高位的hash变化,可以更好地体现在低位上,后续做下标处理的时候会有提到
indexFor方法
首先我们需要知道,在设计角度我们需要计算下标的时候有两个要求
第一:计算出来的下标数必须比当前的数组容量小
第二:计算出来的下标必须在数组的每个下标都尽可能平均
HashMap的做法是拿到key的hase值获得值与(数组长度-1)进行 & 运算
在上文我们得知,数组长度一定是个2的幂,这个数减一,可以得到一个后面位数都是1的原码,然后与hash值进行 & 运算,这种设计使得计算的下标一定会在0 - 15之间,然后hash的设计又让散列更加明显,从而让值都会尽可能的平均。
16: 0000 0000 0001 0000
15: 0000 0000 0000 1111
addEntry方法
当前map的size大于阈值并且当前数组下标值不是null时,会调用resize方法进行扩容,扩容的的容器是当前数组容量的两倍。扩容完重新计算下标,新建一个Entry对象使用头插法插入到链表,并且将数组当前下标的引用指向新的Entry对象。
头插法:将新的Entry的next指向当前Entry的head。
单纯从单向链表层面来讲,头插法肯定是优于尾插法的,因为头插法减少了遍历的过程,但是在HashMap的put方法中,它本身存在遍历寻找相同key的过程,所以其实头插尾插在HashMap中效率基本相同
resize方法
扩容步骤:
- 新建一个数组
- 转移旧数组的引用到新的数组
- 遍历数组
- 遍历数组下标的链表
- 重新取得hash值与新容量一起计算新数组下标
- 使用头插法放入到新数组的下标链表里
- 将新的数组赋值到map的table变量
- 重新计算阈值
这个扩容在多线程的环境中,会产生死循环的问题 场景:在当两个线程同时进行扩容,进行到同一个下标的链表,链表顺序是 1-2-3
- 第一个线程的遍历进行到设置 局部变量 next = e.next时,线程拿不到CPU,进入假死状态,也就是 e = 1, next = 2
- 第二个线程完全执行完扩容,链表会变成 3-2-1的形式
- 第一个线程拿到CPU,执行扩容逻辑,会造成新的数组出现一个1-2-1的循环数组
- 这个时候任何线程对这个下标的数组进行put或者get操作进行遍历,都会造成死循环
get方法
get方法相对来讲比较简单
key为null时,下标为0
否则,拿到key的hash值和数组长度计算得到下标
遍历数组下标的链表,判断key是否相等,相等则返回,全不相等返回null