Life is not a ridiculous number of life, the meaning of life lies in life itself
HashMap源码
散列集
数组和链表可以保持元素插入的顺序,对数组来说,他的优点是拥有连续的存储空间,因此可以使用元素下标快速访问,但缺点在于如果要在数组中第n位删除或插入一个新元素,就需要移动n后面的所有元素,比如在ArrayList中删除某个元素就是调用系统的arraycopy方法将数组要删除的元素后面的所有元素向前复制一位得到的。
private void fastRemove(int index) {modCount++;int numMoved = size - index - 1;if (numMoved > 0)// 将elementData中从index + 1开始的numMoved长度的元素复制到elementData的index位置System.arraycopy(elementData, index+1, elementData, index,numMoved);elementData[--size] = null; // clear to let GC do its work
}
并且定义数组时必须指定容量,如果需要扩容就得重新申请一个更大的数组,然后把原来的数据复制到新数组中,
这就导致数组查询很快,但增删性能不高。
而对链表来说,它的内存空间不是连续的,也就不需要考虑容量问题,但这就导致链表的查询需要逐个遍历LinkedList中虽然可以通过索引来get元素,但也是从头部开始遍历的(如果索引大于size/2就从尾部遍历),效率很低。
散列集(hash table)可以说是数组与链表的组合,
往散列集中添加元素时,通过hash函数可以得到一个该元素的一个哈希值,Java中哈希值的范围在-2147483648~2147483647
之间,Object类的hashCode()
方法可以返回对象的哈希值,通过hashCode可以确定将该元素存到哪一个数组中,
不能直接使用hashCode,因为它的范围将近40亿,不可能有这么大的数组空间,所以需要对hashCode值做一定的处理,使之在数组容量范围内,最简单的办法是对数组容量取余,但取余有效率问题,所以Java使用了&操作,
如果key是null, 就返回0,否则返回原来哈希值与哈希值右移16位后的结果
比如一个元素的hashCode经过运算得到的值是5,他就会被放在第六个数组中。
应为数组容量是有限的,就一定存在运算后得到同样索引值的情况,称为哈希碰撞,解决哈希碰撞有两种方法:开放地址法和拉链法 ,开放地址法是指如果当前的数组已经有元素了,就通过别的算法算出一个新位置插入,像python中dict的实现就使用了开放地址法;而Java中则使用了后者——拉链法,他的思路是如果当前位置有元素了,就把新元素链到旧元素上。
jdk 1.7 以及之前拉链使用一个链表实现,每次有冲突的新元素过来就会把新元素放到数组中,原来的旧链链接到新元素后面【头插法】;
jdk 1.8 开始加入了红黑树,如果数组某个位置的长度超过8并且数组容量超过32就会把链表转换为红黑树,如果红黑树经过删除节点数小于6,就会把树重新转换回链表,以此来提高效率。
JDK 1.7 中的实现
jdk 1.7 以及之前那个数组是Entry类型的,里面封装了key和value,也就是链表的一个节点。
static class Entry<K,V> implements Map.Entry<K,V> {final K key;V value;Entry<K,V> next;int hash;
}
基本属性
// 数组的默认大小,必须是2的倍数, 默认16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16// 数组最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;// 默认负载因子,如果数组中75%被占满,就要扩容
static final float DEFAULT_LOAD_FACTOR = 0.75f;// hashMap中数据的数量
transient int size;// 与快速失败有关
transient int modCount;
put方法
public V put(K key, V value) {if (table == EMPTY_TABLE) {// 初始化一个数组inflateTable(threshold);}// key为null的情况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;
}
如果当前table是空的的时候(实例化后第一次执行put),需要通过inflateTable()
对哈希表进行初始化
private void inflateTable(int toSize) {// Find a power of 2 >= toSize// 计算实际的数组大小int capacity = roundUpToPowerOf2(toSize);// 计算出扩容的临界值thresholdthreshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);// 实例化一个新数组table = new Entry[capacity];initHashSeedAsNeeded(capacity);
}
由于数组容量要求是2的倍数,所以这个方法会先通过roundUpToPowerOf2()
根据我们指定的数组容量计算出真实的数组容量capacity,然后实例化一个capacity大小的Entry数组。最后这个initHashSeedAsNeeded()
允许你配置一个哈希种子,来手动影响散列结果。
初始化后,由于HashMap允许null作为key值,所以如果key是null,就执行putForNullKey()
方法把null: value
存入哈希表.
private V putForNullKey(V value) {// 遍历数组0位的链表for (Entry<K,V> e = table[0]; e != null; e = e.next) {// 如果数组0位链表某个节点key也是null,就替换该节点的值,返回旧值。if (e.key == null) {V oldValue = e.value;e.value = value;// 空方法e.recordAccess(this);return oldValue;}}// 如果0位没有key为null的节点,就创建新节点并加入链表modCount++;addEntry(0, null, value, 0);return null;
}
void addEntry(int hash, K key, V value, int bucketIndex) {// 如果HashMap中元素的数量大于临界值并且发生了冲突,就扩容if ((size >= threshold) && (null != table[bucketIndex])) {resize(2 * table.length);hash = (null != key) ? hash(key) : 0;bucketIndex = indexFor(hash, table.length);}// 创建新的Entry对象createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {// 原来的链表Entry<K,V> e = table[bucketIndex];// 实例化一个新的Entry对象,next指向旧的链表etable[bucketIndex] = new Entry<>(hash, key, value, e);// 元素个数加一size++;
}
- HashMap允许null作为key,并且这个元素始终放在数组第0位
回到正常情况,key是null就确定它存放在数组0位,但其他的key就需要通过计算得到index值,jdk1.7中首先在hash()
方法中对对象原本的hashCode做一系列移位操作后,再在indexFor()
方法中与数组长度做与运算得出对象最终应该被放在数组的哪一位。
final int hash(Object k) {// 可以设置环境变量来提供一个哈希种子int h = hashSeed;if (0 != h && k instanceof String) {return sun.misc.Hashing.stringHash32((String) k);}// 这个种子会通过与对象原来的hashCode做异或从而影响最终散列效果h ^= k.hashCode();h ^= (h >>> 20) ^ (h >>> 12);return h ^ (h >>> 7) ^ (h >>> 4);
}
static int indexFor(int h, int length) {return h & (length-1);
}
出于性能的考虑,在获得最终的index时,Java采用了&
操作而不是更简单的取余,这就导致数组长度必须是2的倍数,同时hash()
方法中多次移位和异或也是应为这样。
比如一个字符串 “重地” 通过
hashCode()
方法得到它原先的hashCode值为 1179395,假设数组没扩容,哈希种子是默认值0,那它计算index的过程应该是:
与hashSeed做异或,得到的还是它本身
右移20位的结果与右移12位的结果做异或
h = : 0000 0000 0001 0001 1111 1111 0000 0011 (1179395) a = h >>> 20: 0000 0000 0000 0000 0000 0000 0000 0001 (1) b = h >>> 12: 0000 0000 0000 0000 0000 0001 0001 1111 (287) ---------------------------------------------------------------- a ^ b = : 0000 0000 0000 0000 0000 0001 0001 1110 (286) h = : 0000 0000 0001 0001 1111 1111 0000 0011 (1179395) ---------------------------------------------------------------- h = : 0000 0000 0001 0001 1111 1110 0001 1101 (1179165) c = h >>> 7 : 0000 0000 0000 0000 0010 0011 1111 1100 ---------------------------------------------------------------- h ^ c = : 0000 0000 0001 0001 1101 1101 1110 0001 d = h >>> 4 : 0000 0000 0000 0001 0001 1101 1101 1110 ---------------------------------------------------------------- h ^ d = : 0000 0000 0001 0000 1100 0000 0011 1111 len - 1 : 0000 0000 0000 0000 0000 0000 0000 1111 ---------------------------------------------------------------- index & : 0000 0000 0000 0000 0000 0000 0000 1111
到最后发现,真正参与运算的只有低四位,之所以做多次位移和异或运算,就是为了把hashCode的高位也参与到最后的与运算中,让得到的index尽量分散,如果把最高位用A表示,可以看到经过上面的算法,最高位究竟影响了哪些位置:
h = : A000 0000 0001 0001 1111 1111 0000 0011 (1179395) a = h >>> 20: 0000 0000 0000 0000 000A 0000 0000 0001 (1) b = h >>> 12: 0000 0000 0000 A000 0000 0001 0001 1111 (287) ---------------------------------------------------------------- a ^ b = : 0000 0000 0000 A000 000A 0001 0001 1110 (286) h = : 0000 0000 0001 0001 1111 1111 0000 0011 (1179395) ---------------------------------------------------------------- h = : 0000 0000 0001 A001 111A 1110 0001 1101 (1179165) c = h >>> 7 : 0000 0000 0000 0000 001A 0011 11A1 1100 ---------------------------------------------------------------- h ^ c = : 0000 0000 0001 A001 110A 1101 11A0 0001 d = h >>> 4 : 0000 0000 0000 0001 A001 110A 1101 11A0 ---------------------------------------------------------------- h ^ d = : 0000 0000 0001 A000 110A 000A 00A1 11A1 len - 1 : 0000 0000 0000 0000 0000 0000 0000 1111 ---------------------------------------------------------------- index & : 0000 0000 0000 A000 000A 000A 00A0 11A1
最高位最后影响了低四位。
为什么数组容量要是2的倍数
让与运算之后的结果分布在 0 ~ (len -1) 之间
算出index之后的代码逻辑就和putForNullKey差不多了,唯一的区别在于:
if (e.hash == hash && ((k = e.key) == key || key.equals(k))){...}
这样设计的原因在于:
- 哈希值不同一定不是同一个对象
- 同一个对象哈希值不一定相同
扩容
是否扩容的判断在addEntry方法中,如果满足扩容条件,是先扩容,再添加新元素
void addEntry(int hash, K key, V value, int bucketIndex) {if ((size >= threshold) && (null != table[bucketIndex])) {// 2倍扩容resize(2 * table.length);hash = (null != key) ? hash(key) : 0;bucketIndex = indexFor(hash, table.length);}createEntry(hash, key, value, bucketIndex);}
扩容需要满足两个条件:
- HashMap中元素个数大于等于threshold
- 即将要新插入的元素发生了冲突
第一个条件 size是总元素个数,但threshold是根据数组容量算的。
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
void resize(int newCapacity) {// 得到旧数组的引用Entry[] oldTable = table;int oldCapacity = oldTable.length;// 如果旧数组已经不能再长了,就不扩容了if (oldCapacity == MAXIMUM_CAPACITY) {threshold = Integer.MAX_VALUE;return;}// 创建一个2倍旧数组大小的新数组Entry[] newTable = new Entry[newCapacity];// 将旧数组的元素转移到新数组transfer(newTable, initHashSeedAsNeeded(newCapacity));table = newTable;// 重新计算扩容临界值threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
扩容最核心的就是数据转移,也就是transfer()
方法
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;}}
}
由于数组容量变了两倍,所以index也许需要重新计算,但计算中其实前面的步骤都一样,只不过最后一步时 length - 1 在最前面多了一个1,所以哪怕index值改变,变化后的index与原来的也是2的倍数关系(1.8中用到了这个规律)
扩容过程中出现的循环链表的情况
这是两个线程进入transfer后一开始的情况(两个线程现在都有了自己新的数组),如果线程1正常执行完成,线程2阻塞在Entry<K,V> next = e.next;
之后,那结果就是:
然后线程2开始执行
就出现了循环链表的情况。
参考
参考2