JDK1.7的HashMap的put源码到底是怎么回事
最近听了鲁班学院的周瑜老师讲的JDK1.7HashMap源码分析,受益很多,这里就简单记录一下其中核心方法设计的巧妙之处。
相关基本概念
HashMap的数据结构存放原理
HashMap底层是由数组和单链表实现的数据结构实现,每次一个新元素需要put进来的时候,首先通过某种运算计算出一个数组下标index,如果数组[index]处已经有元素的话,就在其该元素位置以链表的形式进行存储,否则就直接存放在该数组[index]的位置即可。
Hash冲突
HashMap是通过计算数组下标来确定放入的位置,那么每次计算出的数组下标位置如果已经有数据的话,或者说是多次计算的hash值映射到了同一个数组的位置,这就称为Hash冲突,同一个下标位置,Hash冲突发生的次数越多,该位置上的链表也就会越长。
位运算符号
JDK1.7中的HashMap源码多处使用了位运算符和移位运算符,与(&),非(~),或(|),异或(^),>>左移(考虑正负数符号),<<右移(考虑正负数符号),>>>左移(不考虑正负数符号),<<<右移(不考虑正负数符号),这些都是针对二进制的数据进行操作,下面的举例通过3(二进制为0011)和4(二进制为0100)进行
符号 | 逻辑 | 例子 |
---|---|---|
与(&) | 都为1才为1,否则为0 | 3 & 4 = 0011 & 0100 = 0000 = 0 |
非(~) | 1变0,0变1 | ~3 = ~0011 =1100 = 12 |
或(l) | 有1就为一,否则为0 | 3 l 4 = 0011 |
异或(^) | 相同为1,不同为0 | 3 ^4 = 0011 ^ 0100 = 1000 = 8 |
<<左移 | 二进制数整体往左移动一位,相当于乘2 | 3<<1 = 0110 = 6 |
>>右移 | 二进制数整体往右移动一位,相当于除2向下取整 | 3>>1 = 0001 = 1 |
<<和<<<,>>和>>>就是考不考虑负数的问题,考虑负数的话,还需要进行补码等操作,比较复杂,这里不做解释,源码中也几乎可以都看成是正数的操作
源码中的成员变量
在HashMap的源码中定义下面这些变量,其中
DEFAULT_INITIAL_CAPACITY默认初始容量。
MAXIMUM_CAPACITY表示最大值,因为int为4个字节,每个字节8个byte,共为32位,且二进制中最高位表示正负符号,所以取0-30共31位设为最大值。至于为什么要是2的幂后面源码分析中继续解释。
EMPTY_TABLE定义一个空数组
table就表示存放数据的数组,默认就是EMPTY_TABLE
size表示元素个数
threshold扩容阈值,只有当HashMap中的元素个数超过这个值了,并且table的长度还没到最大值的时候,才会进行扩容,每次扩容后重新计算threshold
modcount修改次数,比如每次put或者remove一次的时候,都是对HashMap的修改,那么modCount就会+1,为什么要记录,后面分析时候再解释
暂时先了解这些,剩下的在源码分析中会进行说明。
/**默认初始容量 - 必须是 2 的幂。*/
static final int DEFAULT_INITIAL_CAPACITY = 16;
/**最大容量,在两个带参数的构造函数隐式指定更高值时使用。 必须是 2 的幂且<= 1<<30。
也就是<=1073741824*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**在构造函数中未指定时使用的负载因子。*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**定义一个空的数组*/
static final Entry<?,?>[] EMPTY_TABLE = {};
/**表格,根据需要调整大小。 长度必须始终是 2 的幂。*/
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
/**此映射中包含的键值映射数,即为元素的个数*/
transient int size;
/**要调整大小的下一个大小值(容量 * 负载因子)。*/
int threshold;
/**哈希表的负载因子。*/
final float loadFactor;
/**该 HashMap 被结构修改的次数 结构修改是更改 HashMap 中的映射数量或以其他方式修改其内部结构
(例如,重新散列)的那些。 该字段用于在 HashMap 的 Collection-views 上创建迭代器快速失败。*/
transient volatile int modCount;
/**映射容量的默认阈值,高于该阈值的替代散列用于字符串键。
由于字符串键的哈希码计算较弱,替代哈希减少了冲突的发生率。
这个值可以通过定义系统属性jdk.map.althashing.threshold来覆盖。
属性值1强制始终使用替代散列,而-1值确保从不使用替代散列。*/
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;
/**与此实例关联的随机值,应用于键的哈希码,使哈希冲突更难找到。 如果为 0,则禁用替代散列。*/
transient int hashSeed = 0;
源码分析
Entry对象
平时写Map的遍历的时候,如果用迭代器话,进场会看到Entry对象。并且一开始就说了HashMap使用数组的单链表实现的,前面看到了table数组,而链表结构就放在Entry对象里。Entry对象就是HashMap真正存放数据的对象,里面除了放了键值对和hash值,还存放了next,也就是该元素在链表结构中的下一个元素是啥。那么都HashMap就可以比较通俗的理解成横向的一个table加上纵向的每个Entry对象连成的链表。
static class Entry<K,V> implements Map.Entry<K,V> {
final K key; //键
V value; //值
Entry<K,V> next; //单链表连接的下一个对象
final int hash; //hash值
}
put方法
方法第一行首先判断table是否为空,如果为空的话,就执行inflateTable方法,这个先放一下,后面分析。如果不为空的话,继续往下走。
因为HashMap中是允许运放key为null的数据,所以put方法接下来就判断了key是否为null,如果为null,就会调用putForNullKey这个方法进行null值的存放,不为ull的话继续往下走。
跳过hash和indexFor两个方法,看到for循环中有个table[i],table在前面说过了就是HashMap存放数据结构中的数组,那么这个i就是它的下标位置,这里再往回看,i是通过hash和indexFor两个方法算出来的,所以就能理解hash和indexFor两个方法是用来计算每个元素应该放的数组下标位置的方法。
计算出来后,走一个for循环,从table[i]也就是该数组位置上的链表第一个节点开始,一直往下循环,遍历判断该数组位置上的链表中是否有相同的key,如果有的话就进行一个value值的覆盖,并且返回被覆盖的值。这里面还有个recordAccess方法,这个方法是留给HashMap的子类LinkedHashMap实现的,这里并没有用处。
如果没有相同key的话,执行modCount++,记录修改次数+1,最后调用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;
}
put中的addEntry方法
addEntry中会传四个参数,hash哈希值,key键,value值,bucketIndex计算出的数组下标。
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);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
addEntry里面的if和扩容有关。先直接看createEntry方法。
该方法中首先会把目标位置table[bucketIndex]中的元素对象拿出来赋值给e,不管是不是空。
接着执行table[bucketIndex] = new Entry<K,V>(hash, key, value, e);这行代码,这行代码实际意思就是链表的头插法,为什么不用尾插法,因为尾插法每次还要遍历到最后一个节点,效率肯定没有头插法那么快。
上面头插法的一行代码可以拆解为两步:
1.new Entry<K,V>(hash, key, value, e);
先把要加入的键值对包装成一个Entry对象,并且把该元素Entry对象的next指向它要放入的数组下标位置所在的元素
2.table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
把要加入的Entry对象放在数组下标对应的位置
这样一来,就能实现每次有一个新元素放进来的时候,都采用头插法的形式把他插入到计算出的数组下标所在的链表中,具体的图解如下
执行完插入之前,会执行一个判断,这里判断了size,就是元素个数是否大于threshold,这个threshold是定义在HashMap中的一个成员变量,上面已经看到过了。这里判断如果大于的话,就resize一下,也就是进行了一次扩容。resize的分析,放在hash和indexFor方法的分析之后。
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
put中的hash和indexFor方法
根据前面的分析都知道了,对于一个HashMap,每次往里面put元素,都要先通过hash和indexFor方法计算出一个数组的下标,根据这个下标才能进行放入的后续操作。那么再每次计算数组下标的时候,就要遵循两个规则:
1.计算出来的下标位置必须在0-数组长度-1之间。
2.计算到每个数组下标位置的几率尽可能的相近
正好,indexFor方法就是解决第一个问题,hash方法就是解决了第二个问题
先看indexFor方法
//pu中调用的两行代码
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
也就是说它根据hash方法传来的h,和table.length-1也就是数组的长度-1进行了一个与操作。
static int indexFor(int h, int length) {
return h & (length-1);
}
这里的length-1就是用来控制范围上限,然后因为&与运算是都为1才为1,那么也就是说,当不管hash计算出的是什么值,和length - 1与操作后最大的可能就是和length-1相等,最小的可能就是为0,因为length为0的地方,与完之后必定为零,而length为1的地方,与完之后可能为0也可能为1。
这里有个问题,在实际业务中,HashMap的table长度一般不会太长,低四到六位一般足以支撑业务,那么他的高位都为0,低位才会出现1。但传过来的hash值一般较大,如果hash值的低位为零数较多,那么与完之后的结果就会导致在数组的某些位置上永远都存放不了数据,这样就会增大数组上剩余位置的Hash冲突概率,如下图情况
hash方法就是为了解决这个问题,尽可能的减少Hash冲突,或者说是尽可能的让每个位置上发生的Hash冲突次数接近一致并且越小。他调用时候的入参就是key.hashCode(),就是把键的hashCode传了进去,再对这个key.hashCode()得到的hash值进行进一步的操作,让他能够在低位上有更多的1出现,那么就对他进行右移,右移完后,再与原来的值进行异或运算,从而增加低位为1的概率。但是这里只能尽可能的让算出来的hash值跟table.length的低位更接近一致,但也达不到每次都保证一定是一样的。
(这里有一个hashSeed哈希种子的判断,这里先不管,后面分析哈希种子的时候再回头来看)
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);
}
至于为什么这里要取右移20和1,异或完了再右移7和4,在异或,这里官方给的解释是。你要取19和2,8和3也没多大问题把,只是哪一种更好,能让冲突更少。
addEntry中的resize扩容方法
扩容肯定是对数组进行扩容,不会是对数组上某个位置的链表进行扩容,因为链表没有容量的约束。并且通过上面的调用可以看到,每次扩容都会扩成原来的两倍。
该方法首先就先把原数组和原数组长度保存一下,记为oldTable和oldCapacity
接着判断原数组长度是否已经到了最大值(MAXIMUM_CAPACITY前面已经介绍过是table的容量上限,为 1<<30),如果超过了的话,这里会修改threshold的值为Integer.MAX_VALUE(231-1),那么后续对该HashMap就不再进行扩容。
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
如果没达到最大值的话,就新创建一个Entry数组对象,容量为原来的两倍,并且调用transfer方法,该方法就是把原来table里的数组,放的newTable里面去,放完之后,再把newTable赋值给table,也就是更新一下table这个变量。
最后重新根据负载因子loadFactor计算一下threshold,因为有可能扩容完之后达到了容量的最大值,所以还需要和MAXIMUM_CAPACITY默认最大容量+1比较之后,Math.min方法来取两者中较小的那个来保障扩容阈值一定是限制住了最后一次扩容完不允许超过最大容量。
resize中的transfer方法
这个方法大致可以理解为外层for循环在进行数组的横向遍历,内层while循环在进行每个数组上链表的纵向遍历。遍历的话也是把原来的数据利用头插法插入到新的数组中,至于具体的实现不做过多分析,主要看看这其中为什么还要重新调用一次indexFor方法
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
之前已经分析过了indexFor就是传来的hash值,和table.length-1也就是数组的长度-1进行了一个与操作。这里就暗藏玄机了
这里的indexFor方法传来的是e.hash,和新容量。假设原数组长度为4,现在遍历到了下标为2的位置上。现在在该位置上调用了indexFor方法,传入了该元素原本的hash值和新的容量,计算过程如下图
通过上面的计算结论,也就是说扩容了两倍后,原本a位置上的元素会被散列到新数组a位置和a+原数组长度的位置上,这里也正好解释了,为什么扩容的时候,不直接把原来的数组上对应位置的链表表头元素直接放到新数组的对应位置上,因为扩容就是因为原数组上的数据太多,目的本来就是为了把原数组上的数据散列开,提升查找速度。如果仅仅是把原数组每个位置上的数据放到新数组对应位置上,就没有达到扩容的目的,对这个HashMap的查找或其他方法的速度就没有的到提高。
resize中的initHashSeedAsNeeded方法
另外我们在transfer方法中还能看到有个rehash的判断。
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
这个rehash的值其实是从resize扩容方法中传来的。
并且接收的参数为当前数组的容量,具体的源码如下:
final boolean initHashSeedAsNeeded(int capacity) {
boolean currentAltHashing = hashSeed != 0;
boolean useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean switching = currentAltHashing ^ useAltHashing;
if (switching) {
hashSeed = useAltHashing
? sun.misc.Hashing.randomHashSeed(this)
: 0;
}
return switching;
}
因为哈希种子hashSeed默认就是为0的,所以第一行在哈希种子默认为0的情况下currentAltHashing 返回的就是false。
那么hashSeed种子改变的地方只有在switching为true的时候,进入了if (switching) 中,haseSeed才会被赋上新值。并且switching后面是一个异或操作^,也就是说只有当currentAltHashing 和useAltHashing不相等的时候,才会返回true,因为currentAltHashing 默认状态下为false,所以就必须保证useAltHashing为true,才能让哈希种子得到改变。
而useAltHashing 它的赋值是靠sun.misc.VM.isBooted()和(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD)两者,sun.misc.VM.isBooted()和底层虚拟机有关,这里一般都是true,那么也就是说最关键的是看(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD)这个值如果true的话,那么哈希种子的值就有可能改变。
最终也就是说当capacity数组容量大于等于Holder.ALTERNATIVE_HASHING_THRESHOLD这个值的时候,才会对哈希种子HashSeed进行重新赋值。
ALTERNATIVE_HASHING_THRESHOLD是在Holder的静态类中的静态代码块中赋的值,这里就不去Holder这个静态类中具体分析了,没太大意义。直接说结果就是,他会去当前虚拟机的环境变量jdk.map.althashing.threshold中取值,如果不为空的话,赋给ALTERNATIVE_HASHING_THRESHOLD
所以就是说在启动项目的时候,给虚拟机的环境变量ALTERNATIVE_HASHING_THRESHOLD赋值,才会改变我们的哈希种子。否则一般的默认情况哈希种子一直为0,是不会变的。
那么如果我们的哈希种子都变了,在每次扩容的时候就肯定要进行rehash方法,重新计算一下哈希值。
HashSeed哈希种子作用
既然知道了哈希种子一般默认就是为0,也不会变,那如果要用它有什么作用呢?
前面再说hash方法的时候,这里留了一块,每次右移的h其实就是hashSeed哈希种子(不为0的情况下)先和key的hashCode方法进行了异或操作后的结果。这里说白了也就一个目的,为了让hash值更散列,减少hash冲突。
也就是说如果你觉得java原本的这个hash算法还不够好,在你的需求下,哈希冲突还是比较高,那么你就可以自己传入一个虚拟机的jdk.map.althashing.threshold,让这个hash算法的结果更散列一点。
put中的inflateTable方法
这个方法主要是当HashMap的数组为空的时候,进行一个初始化的作用。并且传了个threshold扩容阈值过来。
前面看成员变量的时候HashMap已经规定了table的大小必须是2的n次幂。那么这里的主要看一下roundUpToPowerOf2方法是如何找大于当前toSize的最小的2的n次幂。
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
int capacity = roundUpToPowerOf2(toSize);
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
roundUpToPowerOf2里先判断容量是否已经大雨了最大值,如果没大于的话,调用Integer.highestOneBit方法去找大于当前toSize的最小的2的n次幂。
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;
}
这个highestOneBit它实际是找到小于等于i的一个2的幂次方数。最后在返回值中处理一下。
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);
}
这里全是右移和或运算,因为或运算是有1就为1,所以每次的或运算+右移操作其实就等于把原数字的低位慢慢的全部改为0,也就是下左图的17(0001 0001) -> 31(0001 1111)。
最后得到的结果全部右移一位 31(0001 1111) ->15(0000 1111)
在两者进行相减,因为两者仅相差最高位的1,所以减出来后就剩最高位的一个1。也就是16(0001 0000)。
最后再在返回的Integer.highestOneBit((number - 1) << 1)中进行左移一位,就得到了32(0010 0000),也就是我们想要的结果大于当前数字最小的2的n次幂。
残留问题解释
为什么数组的长度要是2的幂次方
前面我们知道了indeFor方法就是数组长度-1与一个hash值进行&与运算,其实这里就解释了原因。
因为每次计算数组下标的时候,计算出来的下标位置必须在0-数组长度-1之间,不然就越界了。
因为规定了数组的长度是2的n次幂,那么减1之后,在二进制中就是从第一位开始的一段连续的1,比如长度为8,那么8-1=7的二进制就是0111。保证了这一个条件,在和任意的hash值进行与的时候,范围就都会被控制在0到7之间
7 & hash 就是 0111 & xxx 得出的范围就是0000 - 0111 也就是0-7,这样也就保证了不越界
其实通过求余的方式,也就是%也可以达到效果,只需要hash%数组长度-1效果也是一样的,但是除余肯定没有二进制的&与运算效率快对吧。
负载因子loadFactor有什么用?
负载因子loadFactor在每次扩容阈值threshold的计算中都会出现,他就是下一次进行扩容临界点。
如果loadFactor越小,每次扩容后计算的threshold就越小,那么就需要不断的扩容,每个数组位置上的链表也就越短,冲突就越来越小。
如果loadFactor越大,每次扩容后计算的threshold就越大,那么几乎就不需要怎么扩容,每个数组位置上的链表也就越长,冲突就越来越多。
modCount有什么用?
modCount是记录修改HashMap中数据的次数。做一个操作,我们简单遍历HashMap,一遍遍历,一遍删除,那么Java就会抛出个ConcurrentModificationException异常。
例如下面的这个代码,一遍遍历,一边删除。
HashMap<String,String> map = new HashMap<>(10,0.75f);
map.put("1","1");
map.put("2","2");
map.put("3","3");
for(String key : map.keySet()){
map.remove(key);
}
这段代码编译后我们会发现,它其实是通过HashMap的keySet方法创建了一个迭代器进行的遍历。
HashMap<String, String> map = new HashMap(10, 0.75F);
map.put("1", "1");
map.put("2", "2");
map.put("3", "3");
Iterator i$ = map.keySet().iterator();
while(i$.hasNext()) {
String key = (String)i$.next();
map.remove(key);
}
keySet()方式其实点进去看能发现,就是new了一个KeyIterator,而KeyIterator是继承HashIterator
Iterator<K> newKeyIterator() {
return new KeyIterator();
}
private final class KeyIterator extends HashIterator<K> {
public K next() {
return nextEntry().getKey();
}
}
所以在new KeyIterator()的时候,会调用父类的构造函数HashIterator(),这里面就能发现它第一行就把modCount保存了起来,赋值给了expectedModCount
HashIterator() {
expectedModCount = modCount;
if (size > 0) { // advance to first entry
Entry[] t = table;
while (index < t.length && (next = t[index++]) == null)
;
}
}
而我们在进行迭代器的遍历的时候,一步步往里走就能看到它最终的遍历的代码其实是通过一个叫nextEntry的方法执行,这里面它进行了一个判断modCount != expectedModCount的话,就会抛出我们刚才看到的异常。这里的modCount就是当前修改次数,而expectedModCount 是之前开始遍历的时候保存的修改次数,如果我们在遍历的过程中使用map.remove方法移除了某个元素,modCount++了,而expectedModCount没变,那么这里肯定会抛出异常。
final Entry<K,V> nextEntry() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
Entry<K,V> e = next;
if (e == null)
throw new NoSuchElementException();
if ((next = e.next) == null) {
Entry[] t = table;
while (index < t.length && (next = t[index++]) == null)
;
}
current = e;
return e;
}
modCount的意义和为什么要这样做
那么这个modCount有什么意义,这个是java的一个叫fast-fail就是快速失败的容错机制,它让你在发生这个问题的时候,就终止你的代码。那么为什么java会认为HashMap会出现这个问题呢,因为HashMap是线程不安全的,那么就假设一种情况,有两个线程通过对这个HashMap进行操作,一个在遍历,一个在做移除,那么就有可能会发生一种情况,线程二删除了第三条数据的时候,线程一还只遍历到第二条数据,那么这对线程一来说,当他继续遍历下去话,所获得的就是一个错误的结果。所以java在这个地方用了modCount来进行异常的抛出。
JDK1.7的扩容在多线程情况下的问题
…下次更新补上这块内容