开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情
HashMap在面试中也是一个比较高频的问题,一般都会问JDK1.7与JDK1.8中的区别,这时候如果我们只是粗略的说下那很大概率会回家等通知了。今天我们就分别从1.7跟1.8中HashMap底层实现来一起探讨下,看看他俩有何不同。
HashMap底层实现
先看两行基础代码:
HashMap map = new HashMap();
map.put(key1,value1);
上边这两行代码我们再熟悉不过了,那我们就拿这两行代码开刀,分别从JDK1.7与JDK1.8中的底层实现看看我们是真的熟悉还是只是最熟悉的陌生人。
JDK1.7
在map实例化之后,底层为我们创建了一个长度为16的一维数组Entry[] table。然后我们进行put操作时:
- 调用key1所在类的hashCode()方法计算key1的hash值,此hash值再经过计算后,得到在Entry中存放的位置
- 如果此位置上的数据为空,此时key1-value1添加成功
- 如果此位置上的数据不为空(此位置存在一个或多个数据(链表形式)),然后再比较key1与该位置数据的hash值
- 如果key1的hash值与已存在的数据的hash值都不相同,此时key1-value1添加成功 ①
- 如果key1的hash值与已存在的某一数据(此处用key2表示)的hash值相同,会继续调用key1所在类的key1.equals(key2)进行比较,此时会有两种情况:
- 返回false:此时key1-vale1添加成功②
- 返回true:value1覆盖已存在的value2。
①、②场景下key1-value1与原来的数据以链表的方式存储
- HashMap底层使用数组进行存储,我们都知道,数组的长度是固定,我们不断的调用map.put()添加数据的话,还会涉及到扩容的问题,而hashMap的默认扩容方式是扩容为原来得2倍,并将原来的数据复制到新数组中。
JDK1.8
JDK1.8中在实例化后,不会创建数组,而是在首次调用map.put()方法时,创建一个长度为16的Node[] 数组,看清楚啊不是Entry[] 数组,并且数据结构在数组加链表的基础上增加了红黑树。
- jdk1.7:数组+链表
- jdk1.8:数组+链表+红黑树 红黑树进化条件:当数组的某一个索引位置上的数据以链表的形式存在的数据个数>8且当前数组长度>64时,此时此索引位置上的所有数据改为使用红黑树存储。
HashMap源码分析
JDK1.7
存储结构: 在jdk1.7中,Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。 Entry是HashMap中的一个静态内部类。
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next; //存储指向下一个Entry的引用,单链表结构
int hash; //对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算
/**
* Creates new entry.
**/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
//...
}
默认构造方法:
/**
* Constructs an empty <tt>HashMap</tt> with the default initial capacity
* (16) and the default load factor (0.75).
**/
public HashMap() {
//DEFAULT_INITIAL_CAPACITY = 1 << 4 默认的初始容量16
//DEFAULT_LOAD_FACTOR = 0.75f 默认的加载因子0.75
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);
// HashMap参数赋值
this.loadFactor = loadFactor;
threshold = initialCapacity;
// 空方法,在其子类如 LinkedHashMap 中就会有对应实现
init();
}
put方法:
public V put(K key, V value) {
// 如果table数组为空数组{},进行数组初始化
if (table == EMPTY_TABLE) { // EMPTY_TABLE = {} 空数组
// 分配数组空间
// 入参为threshold,此时threshold为initialCapacity 默认是1<<4(=16)
inflateTable(threshold);
}
// 如果key为null,存储位置为table[0]的数组和链表上
if (key == null)
return putForNullKey(value);
// 对key的hashcode进一步计算,通过异或运算确保散列均匀
int hash = hash(key);
// 获取在table中的实际位置下标
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
// 如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this); //调用value的回调函数,这个函数也为空实现
return oldValue;
}
}
// 记录修改次数,保证并发访问时,若HashMap内部结构发生变化,快速响应失败
modCount++;
// 新增一个entry
addEntry(hash, key, value, i);
return null;
}
inflateTable方法: inflateTable方法用于对数组进行初始化操作。
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
// capacity一定是2的次幂,比如toSize=13,则capacity=16
int capacity = roundUpToPowerOf2(toSize);
// 依据加载因子为HashMap的扩容阈值赋值
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
// 分配空间
table = new Entry[capacity];
// 选择合适的Hash因子
initHashSeedAsNeeded(capacity);
}
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1) 通过默认值计算得出threshold=12,也就是不会等到长度达到16是扩容,而是到12的时候会进行扩容
hash方法:
final int hash(Object k) {
int h = hashSeed;
// 这里针对String优化了Hash函数,是否使用新的Hash函数和Hash因子有关
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方法: 通过indexFor进一步处理来获取实际的存储位置。h &(length-1)保证获取的index一定在数组范围内
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
}
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);
}
// 把元素放入HashMap的桶的对应位置
createEntry(hash, key, value, bucketIndex);
}
createEntry方法:
//头插法
void createEntry(int hash, K key, V value, int bucketIndex) {
// 获取待插入位置元素
Entry<K,V> e = table[bucketIndex];
// 新插入的元素指向原有元素,并将新的Entry放在数组的第一个位置
table[bucketIndex] = new Entry<>(hash, key, value, e);
// 元素个数+1
size++;
}
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];
// 将老的表中的数据拷贝到新的数组中
transfer(newTable, initHashSeedAsNeeded(newCapacity));
// 修改HashMap的底层数组
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);
}
// 定位新的Hash桶位置下标(并没有重新计算hash值,而是与新的容量进行&操作,只会在两个位置中选择)
int i = indexFor(e.hash, newCapacity);
// 元素连接到桶中,这里相当于单链表的插入,总是插入在最前面
e.next = newTable[i];
// newTable[i]的值总是最新插入的值
newTable[i] = e;
// 继续遍历下一个元素
e = next;
}
}
}
JDK1.8
put方法:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// Node数组非Entry
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
// 数组未初始化时,进行初始化数组
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
// 通过 hash 找到对应的数组下标,如果当前下标中的内容为null,直接将数据放进去
tab[i] = newNode(hash, key, value, null);
else {
// 如果通过 hash 找到对应下标的位置有数据,会发生 hash 碰撞
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 如果要插入 key 和当前数组对应的下标的 key 一致,就把当前节点赋值给临时节点e
e = p;
else if (p instanceof TreeNode)
// 如果是红黑树,根据红黑树插入
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 最后桶中的数据为链表,遍历链表
for (int binCount = 0; ; ++binCount) {
// 链表中没有要插入的节点,将要插入数据插入到链表尾部
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // 链表达到树化阈值,转红黑树
treeifyBin(tab, hash);
break;
}
// 链表中找到和要插入的节点 key 一致,将该节点赋值给临时节点e
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 如果临时节点e不为 null,说明要插入的数据已经存在当前 HashMap 中,更新该节点的值
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)// 如果 HashMap 中的存储的元素个数大于阈值,会触发扩容
resize();
afterNodeInsertion(evict);
return null;
}
总结:
- 数据结构不同:1.7中的hashMap基于数组+链表 1.8中的hashMa基于数组+链表+红黑树(链表长度>8时,转化为红黑树)
- 插入方式不同:1.7是头插法,1.8是尾部插入
- hash计算方式不同:1.7是9次扰动处理(4次位运算+5次异或);1.8是两次扰动处理(1次位运算+1次异或)
- 扩展策略不同:1.7是插入前扩展;1.8是插入成功后扩容
- 1.7中的resize()方法负责扩容,inflateTable()负责创建表,而1.8中的resize()表为空时创建表,表有值时扩容表。
- 1.7中hashMap在遍历时如果不存在,会调用addEntity()将节点添加到链表的头部,而1.8中元素处于链表的情况下遍历时如果不存在,会直接将节点在插入到尾部
- 1.7中新增节点采用头部插入法,1.8中新增节点采用尾部插入法,这也是1.8不容易出现循环链接的原因
- 1.7通过hashSeed值修改节点hash值从而达到rehash时的链表分散;1.8中键hash值不会改变,rehash时根据(hash&oldCap) == 0将链表分散。
- 1.8rehash时保证原链表的顺序,而1.7 rehash时有可能改变原链表的顺序(头插法导致的)