本文思维导图

HashMap简介
HashMap 是很常用的一种集合框架,其底层实现方式在 JDK 1.7和 JDK 1.8中却有很大区别。HashMap 是用来存储数据的,它底层在JDK 1.7是数组+链表实现的,而JDK 1.8是使用数组+链表+红黑树实现,通过对 key 进行哈希计算等操作后得到数组下标,把 value 等信息存放在链表或红黑树存在此位置。如果两个不同的 key 运算后获取的数组下标一致,就出现了哈希冲突。数组默认长度是16,如果实际数组长度超过一定的值,就会进行扩容。HashMap的面试不管小厂还是大厂都是高频问点,特别是大厂一定会深究底层,采用持续的追问,知道你怀疑人生,在Java7和Java8中对HashMap的数据结构进行了很大的优化。今天这篇文章就以HashMap的高频问点为主,层层的剖析HasMap的底层实现,话不多说,直接进入正题。问点一:你了解HashMap的底层数据结构吗?对于HashMap的底层数据结构在Java7和Java8中的实现是不同的,在Java7中是采用数组+链表的数据结构进行实现,而在Java8中是采用数组+链表+红黑树的数据结构实现的。说时迟那时快,刚话说完,从兜里拿出笔和纸,啪地一声放在桌子上画了起来,许久之后,出现了两幅jdk7和jdk8的HashMap的内部结构图:

//key 进行哈希计算
int hash = hash(key);
//获取数组下标
int i = indexFor(hash, table.length);
通过计算后的下标,从而得到数组的对应下标的位置,最后把k,v值存进去,同样的当再次第二次存值的时候,同样把k,v传进来,当k再次进行计算出数组下标index,有可能和第一次计算的index的值相同。为什么有可能相同呢?这个是hash函数的原因,看完上面推荐的那篇hash函数详细介绍你就懂了。当两次的计算index相同,这就是hash冲突。但是,两次的需要存进去的value值是不同的,这就出现了同一个数组后面有一条链表,会比较链表上的每一个value值与当前的value是否相同,若是不相同,通过头插法,将数值插入链表中。如下图所示:
public V put(K key, V value) {
//数组为空就进行初始化
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
//key 进行哈希计算
int hash = hash(key);
//获取数组下标
int i = indexFor(hash, table.length);
//如果此下标有值,遍历链表上的元素,key 一致的话就替换 value 的值
for (Entry 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++;//新增一个key
addEntry(hash, key, value, i);return null;
}
put方法中主要做了以下几件事:- 判断table数组是否为空,若为空进行初始化table数组。
- 判断key值是否为null,将null是作为key存进去。
- 若key不为空,通过key计算出数组下标,判断table[i]是否为空。
- 若是不为空通过链表循环,判断在链表中是否存在与该key相等,若是存在,直接将value替换成新的value。若是table[i]为空或者链表中不存在与之相同的key,就addEntry(hash, key, value, i)新增一个节点。
void addEntry(int hash, K key, V value, int bucketIndex) {
//数组长度大于阈值且存在哈希冲突(即当前数组下标有元素),就将数组扩容至2倍
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);
}
这个方法很简单,直接就是判断当前数组的大小是否>=threshold并且table[bucketIndex]是否为null。若成立扩容,然后rehash,重新得到新数组的下标值,最后 createEntry(hash, key, value, bucketIndex)创建新节点。最后来看一下createEntry(hash, key, value, bucketIndex)创建新节点的源码如下:
void createEntry(int hash, K key, V value, int bucketIndex) {
//此位置有元素,就在链表头部插入新元素(头插法)
Entry e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
该方法就是通过头插法加入新节点,方法非常简单,相信都能看懂。经过上面对put方法的源码分析,在jdk 7 中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[] tab; Node p; int n, i;//数组为空就初始化if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;//当前下标为空,就直接插入if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);else {
Node e; K k;//key 相同就覆盖原来的值if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
e = p;//树节点插入数据else if (p instanceof TreeNode)
e = ((TreeNode)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);//链表长度超过8,就把链表转为红黑树if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);break;
}//key相同就覆盖原来的值if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))break;
p = e;
}
}if (e != null) { // existing mapping for key
V oldValue = e.value;if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);return oldValue;
}
}
++modCount;//数组长度大于阈值,就扩容if (++size > threshold)
resize();
afterNodeInsertion(evict);return null;
}
通过分析源码,上面的方法主要做了以下几件事:- 判断当前桶是否为空,空的就需要初始化(resize 中会判断是否进行初始化)。
- 根据当前 key 的 hashcode 定位到具体的桶中并判断是否为空,为空表明没有 Hash 冲突就直接在当前位置创建一个新桶即可。
- 如果当前桶有值( Hash 冲突),那么就要比较当前桶中的 key、key 的 hashcode 与写入的 key是否相等,相等就赋值给 e。
- 如果当前桶为红黑树,那就要按照红黑树的方式写入数据。
- 如果是个链表,就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)。
- 接着判断当前链表的大小是否大于预设的阈值,大于时就要转换为红黑树。
- 如果在遍历过程中找到 key 相同时直接退出遍历。
- 如果 e != null 就相当于存在相同的 key,那就需要将值覆盖。
- 最后判断是否需要进行扩容。
final void treeifyBin(Node[] tab, int hash) {
int n, index; Node e;//链表转为红黑树时,若此时数组长度小于64,扩容数组if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode hd = null, tl = null;//链表转为树结构do {
TreeNode p = replacementTreeNode(e, null);if (tl == null)
hd = p;else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
由此可以看到1.8中,数组有两种情况会发生扩容:
- 一是超过阈值
- 二是链表转为红黑树且数组元素小于64时

void addEntry(int hash, K key, V value, int bucketIndex) {
//数组长度大于阈值且存在哈希冲突(即当前数组下标有元素),就将数组扩容至2倍
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);
}
而JDK 1.8扩容条件是数组长度大于阈值或链表转为红黑树且数组元素小于64时,源码中的体现如下所示:
//数组长度大于阈值,就扩容
if (++size > threshold)
resize();
//链表转为红黑树时,若此时数组长度小于64,扩容数组
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
问点四:HashMap中的键值可以为Null吗?能简单说一下原理吗?在JDK7中是允许null存进去的,通过 putForNullKey(value)方法来存储key为null值,具体的实现的源代码如下:
if (key == null)
return putForNullKey(value);
而在JDK 8中当传进key为null值的时候,就直接将hash值取0,进行计算存入值的位置。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
问点五:HashMap中能put两个相同的Key吗?为什么能或为什么不能?这个问题比较简单,在JDK7和JDK8中的做法是一样的,若是存入的key值一样,就会将原来的key所对应的value值直接替换掉,可以从源码中看出:
// JDK1.7
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
// 直接替换原来的value值
e.value = value;
e.recordAccess(this);
return oldValue;
}
// JDK 1.8
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 存在key值相同
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
// 替换掉原来value值
e.value = value;
afterNodeAccess(e);
return oldValue;
}
问点六:聊一聊JDK 7的HashMap中的“死锁”是怎么回事?HashMap是线程不安全的,在HashMap的源码中并未对其操作进行同步执行,所以在并发访问的时候就会出现线程安全的问题。由于上一篇的ConcurrentHashMap篇中讲到了死锁,也画了图,但是很多读者说看不懂,这里我的锅,在这里详细的进行图解。假设:有线程A和线程B,并发访问HashMap中的数据。假设HashMap的长度为2(这里只是为了讲解方便假设长度为2),链表的结构图如下所示:
/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry e : table) {while(null != e) {
Entry next = e.next; ---------------------(1)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;
} // while
}
}
此时线程A挂起,在此时在线程A的栈中就会存在如下值:e = 4
next = 8
此时线程B执行put的操作,并发现在进行put操作的时候需要扩容,当线程B执行 transfer函数中的while循环,即会把原来的table变成新一table(线程B自己的栈中),再写入到内存中。执行的过程如下图所示(假设两个元素在新的hash函数下也会映射到同一个位置):


90后中年人の爽点大赏

拼多多的厕所上了热搜,996的大厂员工没有如厕自由

为什么鬼不会攻击被子里的人?
可乐记得加冰,爱我就要置顶 


素质三连biubiubiu~