一般情况下,HashMap是以数组加链表的形式存储的,和数据结构中的散列表的概念基本相同。
在java的HashMap中,每一对key-value键值对被看做一对Entry.
java会根据entry中的key计算hash值。根据这个hash值计算出哈希表中要存储的哈希桶中,如果计算出不同key的哈希值相同,那么此时就产生了哈希碰撞(哈希冲突)那么在同一哈希桶中以链表形式存储这两个entry。不同的jdk版本存储的方式不同,jdk7中是头插法,即在距离数组近的一端插入新来的元素。jdk8在远离数组一端插入新来的元素。
大致图解如下:
HashMap的查找复杂度和插入、删除复杂度概念上都是O(1),可以说是非常高效的一种存储方式。
HashMap的实例化源码:
构造器为空时:
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
//new HashMap();未指定容量时,初始容量16,DEFAULT_LOAD_FACTOR为0.75
}
空参构造器调用了两个参数的构造器:
我们先来看这两个定值的多少:
static final int DEFAULT_INITIAL_CAPACITY = 16;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
可以看出,未指定参数时,默认会创建一个大小为16,负载因子为0.75的HashMap。
下面我们来看这个有两个参数的构造器:
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)//初始容量小于零,报错
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)//初始容量大于了最大容量常量1073741824
initialCapacity = MAXIMUM_CAPACITY;//将容量设为最大容量常量1073741824
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
// Find a power of 2 >= initialCapacity
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1; //这里创建的容量永远为2的整数次方
//比如new HashMap(15) 实际上new了大小为16的HashMap
this.loadFactor = loadFactor;//0.75---加载因子,下面让16乘加载因子等于临界值(12)
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];//即填超过12个数的时候就开始扩容,而不是到16以上才扩容
useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
init();
}
如果手动填入了这两个参数:
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1; //这里创建的容量永远为2的整数次方
//比如new HashMap(15) 实际上new了大小为16的HashMap
这里的左移其实就是乘二,为什么要左移是因为java底层中移位操作效率比直接乘要高。
将capacity 容量设置为1,不断左移乘2,直到得到的结果比填入的参数大为止。
可以看出,HashMap底层初始化的真实容量:
1.在没有填入参数时,容量为16.
2.在填入了参数大小时,容量为比这个填入的参数大的最小2的整数次方。
3.hashmap的扩容永远是两倍两倍的扩的。
this.loadFactor = loadFactor;//0.75---加载因子,下面让16乘加载因子等于临界值(12)
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];//即填超过12个数的时候就开始扩容,而不是到16以上才扩容
负载因子的意思就是,当hashmap的元素数量超过了 容量 * 负载因子 的时候,hashmap会自动扩容,而不是等hashmap满了之后再扩容。
jdk源码中设计0.75位负载因子是因为,0.75是对hashmap的动态扩容时的时间效率和空间效率上面的一个平衡。负载因子越高,空间占用效率就越高,但是可能会造成链表过长,导致查询效率低下。负载因子太低,会有比较多的空间浪费。之所以定在0.75是因为:由于泊松分布,同一哈希桶内有8个以上的概率极小,基本可以忽略。所以0.75作为负载因子可以让出现8个元素的链表的概率变得极小。
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);//可以放null的key
int hash = hash(key);//计算key的哈希值
int i = indexFor(hash, table.length);//return hash & (table.length-1)
for (Entry<K,V> e = table[i]; e != null; e = e.next) {//遍历链表的循环
Object k;
//如果哈希值相等,且key相等,用新定义的entry替换原有的entry
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);//没有进去的情况下,直接在空位置放进去k,v即可
return null;
}
我们重点看源码中怎样将entry要放置的位置计算出来的,关键在于这两句:
int hash = hash(key);//计算key的哈希值
int i = indexFor(hash, table.length);//return hash & (table.length-1)
我们继续看indexFor这个静态方法:
static int indexFor(int h, int length) {
return h & (length-1);
}
h & (length-1)
这个就是计算entry的放置位置index的方法。
这个问题现在就可以解释的通了,因为entry存储的位置是由 h & (length-1) 计算得到的,假设数组的长度始终为2的n次方,那么我们可以知道:2的n次方length底层的二进制数都是这样婶儿的:
1000000000…
他们的首位始终为1,后面所有位均为0,那么如果将这个数字减1,就可以得到(length - 1)大致是这样婶儿的:
011111111…
他们的首位是0,但是后面所有的位均为1
我们知道按位与的操作是:两个数字对应位置上均为1,得到的结果才为1,我们别忘了计算index的公式是:
h & (length-1)
如果我们计算index的时候,length-1这个数字二进制有很多位上为0的话,那么这些位置上面与操作得到的结果永远永远不可能为1!。这样就会造成计算出来的存储位置重合性太高,有的哈希桶一直为空,不仅浪费空间,还容易造成链表过长,可谓是即浪费空间也浪费效率。可以说,将HashMap的长度设置为2的n次方是一个兼顾效率和空间的完美选择。
final int hash(Object k) {
int h = 0;
if (useAltHashing) {//默认false,进不来
if (k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h = hashSeed;
}
h ^= k.hashCode();//得到Object 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);
}
jdk7中,计算hash值的方法为,先得到hashCode并亦或,然后再通过一系列的无符号右移和亦或来得到最后的hash值。
为什么计算hash值需要这么多的操作?
无论jdk7 / 8, hashCode往往很长,但是length往往很短,这样的话容易造成如果计算hash值时,如果只取到了低位的一些信息,那么很容易发生哈希碰撞,所以需要不断的右移,将高位的信息补充进来,这样可以减少碰撞概率。
jdk8中的计算hash值的方法被大大简化,
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
首先还是取得hashCode,然后**(h = key.hashCode()) ^ (h >>> 16)**
即hashCode的低16位和高16位进行亦或操作。这样也可以达到补充hashCode高位信息的效果。
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {//长度大于12且要放的位置不为空时,进行下面操作
resize(2 * table.length);//扩容为原来的二倍
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);//不需要扩容
}
可以看出如果加上一个元素之后元素数量超过了 threshold(capacity * load_factory),那么就会自动扩容。扩容之后重新计算hash值,并依据新的hash值计算index。
这里点进resize()这个扩容方法进去:
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];
boolean oldAltHashing = useAltHashing;
useAltHashing |= sun.misc.VM.isBooted() &&
(newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean rehash = oldAltHashing ^ useAltHashing;
transfer(newTable, rehash);
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;
}
}
}
e.next = newTable[i];
newTable[i] = e;
e = next;
这三行代码完成了HashMap扩容之后的元素的头插法的操作。过程如下图:
jdk8中不再使用头插法。
死锁问题的分析可以看这篇文章
https://coolshell.cn/articles/9606.html
总结来说就是多线程环境下,t1线程t2线程要扩容,t1在扩容时,t2进来并完成了扩容。t1继续完成扩容时,要添加元素指针指向了线程二已经扩容好的链表尾部,这样扩容之后再次进行查询操作就会形成环形链表,产生死锁。
jdk7中扩容会产生死锁问题在于头插法,头插法没有保证元素扩容前后的顺序保持不变。
而jdk8中,使用尾插法添加元素,并且保持扩容前后元素的顺序,减少了死锁的发生。
而且jdk8中,元素在扩容后新的哈希桶的位置总是在原来的位置或者:(原来的位置+原来map的容量) 处的哈希桶内。
我们使用的是 2 次幂的扩展(指长度扩为原来 2 倍),所以,元素的位置要么是在原位置,要么是在原位置再移动 2 次幂的位置。 看下图可以明白这句话的意思,n 为 table 的长度,图(a)表示扩容前的 key1 和 key2 两种 key 确定索引位置的示例,图(b)表示扩容后 key1 和 key2 两种 key 确定索引位置的示例, 其中 hash1 是 key1 对应的哈希与高位运算结果。
元素在重新计算 hash 之后,因为 n 变为 2 倍,那么 n-1 的 mask 范围在高位多 1bit(红色),因 此新的 index 就会发生这样的变化:
及扩容前假如只取了4位,扩容后直接看最高位的数字是1还是0即可,0的话还是以前的位置,1的话向高位走一个旧容量的距离。
1.以上提到的计算hash值的方法。
2.为了避免死锁放弃头插法,并且扩容时保持链表顺序,且大大简化了重新哈希的过程,只看最高位是1还是0即可确定新的位置。
3.链表长度超过8的时候转化为红黑树存储,提高效率,为什么设计成8个?因为哈希桶内的元素个数符合e=0.5的泊松分布,n>8时,概率约为十万分之一非常小。当红黑树元素少于6个的时候,再次退化为链表。