大家好!我是CSRobot,从今天开始,我将会发布一些技术文章,内容就是结合春招以来的面试所遇到的问题进行分享,首先会对知识点进行一个探讨和整理,在最后会给出一些面试题并作出解答,希望可以帮助到大家!今天知识点是HashMap,这个知识点会分为两个部分,会分JDK1.7和1.8两个版本进行讲解,今天会分从以下几个方面进行探讨:
- 哈希表概述
- jdk1.7下HashMap重要属性
- 构造方法
- put过程
- 扩容死循环问题
- 面试题分享
一、哈希表
哈希表(HashTable)也叫散列表,是一种非常重要的数据结构,可以运用到非常多的场景业务中,很多的组件都使用了哈希表作为底层的数据结构以提高性能,本文将对JDK7和JDK8中的HashMap实现原理进行讲解,并对其两者进行对比
在讨论哈希表之前,我们首先对其他基础数据结构进行回顾,并了解其新增和查找的性能:
数组:采用连续的存储单元来存储数据 ,支持随机访问,可以通过下标进行查找,时间复杂度为O(1)。
链表: 对于链表的新增,删除等操作,仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)
二叉树: 对一棵平衡二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn)
哈希表:哈希表由key-value键值对组成, 所以在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下(后面会探讨下哈希冲突的情况),仅需一次定位即可完成,时间复杂度为O(1)
二、HashMap
1、HashMap概述
一个HashMap的结构如上图所示:由数组+链表组成(在JDK1.8之后增加了红黑树的结构),当往HashMap中添加元素的时候,首先对key值进行hash运算,寻找要插入的位置;如果发生hash冲突,采用链表法扩展;再根据数组位置上的链表,选择具体的插入位置。
public void HashMapTs(){
Map<String,String> map = new HashMap<>();
map.put("Jack","Titanic");
map.put("Rose","Titanic");
map.put("Kobe","24");
map.put("Wade","3");
System.out.println(map.get("Jack"));
}
2、JDK1.7下的HashMap
1)先看几个重要的参数
- Entry数组,存储HashMap节点,容量为2的幂次方
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
- 默认初始化数组大小,必须是2的幂次方,原因的话大家可以先思考一下。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
- 最大容量,2^30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
- 初始化默认的装载因子:0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
- map中键值对的数量
transient int size;
- HashMap的阙值,它的值等于capacity * loadfactor,当size大于等于此值的时候,HashMap进行两倍扩容
int threshold;
2)构造方法
HashMap的构造方法有三种,分别是无参构造,带容量的有参构造,带容量和loadfactor的有参构造;而在我们使用场景下一般都是使用无参构造。选择无参构造其实也是调用了默认的容量大小和默认的装载因子
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//初始化容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//给装载因子和阙值赋值
this.loadFactor = loadFactor;
//阙值初始化等于默认容量大小,在HashMap初始化时重新计算
threshold = initialCapacity;
init();
}
3)put过程
- 如果Table为空,初始化HashMap
- 如果key为null,插入key为null的节点,也说明HashMap可以插入key为null的节点
- 计算key的hash值,使用hash值与数组容量,计算得到插入元素的数组下标i
- 如果在table[i]为null,那么直接将该节点插入到table[i]位置
- 如果table[i]位置不为null,那么遍历table[i]上的链表,如果链表上有该节点的key的重复值,那么使用新节点替换旧节点,并返回旧节点的value;否则使用头插法将新节点插入table[i]的链表上
public V put(K key, V value) {
//当table为空时,初始化HashMap,具体见源码①
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//如果key为null,插入节点
if (key == null)
return putForNullKey(value);
//计算key的hash值,具体见源码②
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;
}
-
源码①—inflateTable(),初始化HashMap
此方法用户HashMap的初始化,其中比较重要的方法为roundUpToPowerOf2(toSize),此方法会返回toSize的最小的2的次方的数,例如当toSize=10,那么大于10的最小2次方数就为16;当num=17,返回32。这么做的目的一是保证table的容量始终是2的幂次方,其二是保证了table容量的足够大小,而不频繁扩容。
private void inflateTable(int toSize) { // 传入toSize,返回大于toSize的最小的2的次方的数 int capacity = roundUpToPowerOf2(toSize); threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1); table = new Entry[capacity]; initHashSeedAsNeeded(capacity); } 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; }
-
源码②—hash(key),计算key值的hash
在JDK1.7中,计算key值的hashcode比较复杂,对每次的hashcoude进行异或运算,保证每次的hashcode散列性;同时对hashcode进行多次右移位运算,进程容错处理,保证hashcode的散列性防止每次put的key值都在table数组的一条链上。
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);
}
- 源码 ③ —indexFor
大家可以思考一下为什么table容量必须是2的幂次方?为什么计算index需要length-1?
static int indexFor(int h, int length) {
return h & (length-1);
}
- 源码④—addEntry
当插入新节点的时候,需要判断是否需要扩容,当size大于threshold阙值并且当前插入链表不为空时进行两倍扩容。
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
//resize扩容方法,具体见源码⑤
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++;
}
- 源码⑤—HashMap扩容
- 首先判断hashMap中的节点个数是否大于threshold阙值并且要插入数组节点的位置是否为空,如果满足条件进行扩容
- 创建一个新的数组,容量是原来数组的两倍,按原来数组的两倍进行扩容
- 将原来的链表节点倒序转移到新的数组中
- rehash后新的index为oldCap+index
- 同时更新threshold = newCapaticy*loadfactory
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//新建一个两倍数组的Table
Entry[] newTable = new Entry[newCapacity];
//将元素转移到新的两倍数组中
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
//更新threshold阙值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
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);
}
//重新计算hashcode
int i = indexFor(e.hash, newCapacity);
//将原来的链表节点倒序转移到新的数组中
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
4)JDK1.7下HashMap扩容死循环的问题
- 一个正常的HashMap如下:
-
此时进行HashMap进行扩容,根据上文transfer的代码,我们使用e定位,其扩容的步骤如下所示:
-
在多线程下使用HashMap,如果进行扩容,那么将有可能发生死循环的问题
假设有两个线程同时进行HashMap的扩容,那么假设当前线程二已经扩容完成,线程一才刚刚开始初始化一个两倍数组进行扩容,假设线程1此时刚刚进入while循环
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
那么此时线程一的的e和next则有可能直接指向线程二的key(2)和key(3),如下图所示:
这个时候线程一接着执行,首先执行了 newTalbe[i] = e;
然后是e = next,导致了e指向了key(3)
而下一次循环的next = e.next导致了next指向了key(2)
如下图所示:
接着线程一使用头插法,将key(3)放在当前链表的首位, 然后把e和next往下移
这时候,原来的线程二里面的key3的e和key2的next没了,e=key2,next=null
此时线程一下一步扩容又需要把e节点头插到table[3]位置,此时就形成了环形链表:
所以总结来说,就是多线程环境下对于HashMap的扩容,e和next的情况影响到了其他线程的e和next链,所以导致了扩容时候的死循环问题。
三、总结
-
初始容量16
-
最大容量2^30
-
默认装载因子loadfactor 0.75
-
threshold这个成员变量是阈值,它的值为capacity * loadfactor,当size>thresholdHashMap两倍扩容
-
底层是数组+链表
-
并发扩容使用transfer()方法时容易发生死锁
-
当插入一个新值,put方法为头插法
四、面试题
1、HashMap中计算桶位置为什么不直接取余?
这个问题比较简单,HashMap中计算桶位置是使用hashcode和(容量-1)做与运算,二进制的位运算的效率远远大于取余运算。
2、为什么HashMap的容量都是2的幂次方?以及为什么计算桶位置的时候使用hash与length-1?
其实HashMap容量是2的幂次方和计算桶位置的息息相关的,当数组容量是2的幂次方,那么length-1的二进制就一定是“1111”的形式。
例如以32容量的HashMap为例,
32的二进制为:100000
length-1的二进制为:11111
那么使用以上那个值和hashcode进行与运算的结果更散列?那么很明显就是全是1的值进行位运算更加散列,HashMap中更不容易发生hash冲突。
假设容量不是2的幂次方,那么假设当length=33的时候,
length-1 = 100000,那么此时不论你的hashcode计算出来是多少,
制的位运算的效率远远大于取余运算。
2、为什么HashMap的容量都是2的幂次方?以及为什么计算桶位置的时候使用hash与length-1?
其实HashMap容量是2的幂次方和计算桶位置的息息相关的,当数组容量是2的幂次方,那么length-1的二进制就一定是“1111”的形式。
例如以32容量的HashMap为例,
32的二进制为:100000
length-1的二进制为:11111
那么使用以上那个值和hashcode进行与运算的结果更散列?那么很明显就是全是1的值进行位运算更加散列,HashMap中更不容易发生hash冲突。
假设容量不是2的幂次方,那么假设当length=33的时候,
length-1 = 100000,那么此时不论你的hashcode计算出来是多少,
使用hashcode & (length-1)的结果都只能是0或是32,那么就不能达到散列的目的,put的节点全部都插入到了0位置和最后一个位置。