文章目录
- HashMap简介
- 问题集合
- 问点一:你了解HashMap的底层数据结构吗?
- 问点二:为什么JDK 7使用数组+链表?JDK8中为什么要使用红黑树?哈希冲突是怎么回事?HashMap又是怎么解决的?
- 问点三:HashMap的扩容机制是怎么样的?JDK7与JDK8有什么不同吗?
- 问点四:HashMap中的键值可以为Null吗?能简单说一下原理吗?
- 问点五:HashMap中能put两个相同的Key吗?为什么能或为什么不能?
- 问点六:聊一聊JDK 7的HashMap中的“死锁”是怎么回事?
- 问点七:HashMap是线程安全的吗?为什么安全或者不安全?
- 问点八:什么 HashMap 的加载因子是0.75?为什么是超过“8”才用红黑树?
- 问点九:HashMap、HashTable、ConcurrentHashMap的区别
- 问点十:谈谈你理解的 HashMap,讲讲其中的 get和put 过程
由于HashMap在面试中是一个重点,内容十分多,需要单独写一篇文章总结
HashMap在Jdk1.7与1.8之中有很大区别!!
HashMap简介
HashMap
是用来存储数据的,它底层在JDK 1.7
是数组+链表实现的,而JDK 1.8
是使用数组+链表+红黑树实现,通过对 key
进行哈希计算等操作后得到数组下标,把 value
等信息存放在链表或红黑树存在此位置。
如果两个不同的 key
运算后获取的数组下标一致,就出现了哈希冲突。数组默认长度是16,如果实际数组长度超过一定的值,就会进行扩容。
问题集合
问点一:你了解HashMap的底层数据结构吗?
在JDK1.7使用的是数组+链表的实现,在JDK1.8中使用的是数组+链表+红黑树
问点二:为什么JDK 7使用数组+链表?JDK8中为什么要使用红黑树?哈希冲突是怎么回事?HashMap又是怎么解决的?
- JDK 7使用数组+链表,是因为HashMap是根据Key计算Hash值,从而得到哈希表的索引下标,而哈希表本质是数组实现。当出现Hash冲突时,则一个桶可能需要存放多个数据。HashMap将会根据equals()方法,判断Hash冲突的Key是否是同一个值,此时如果仍然不相同,就会利用头插法,将出现Hash冲突的Key+Value存放在链表上。
- 但是如果一个链表比较长,那么查询的效率将会降低,所以JDK8中又使用了红黑树来解决链表过长导致查询效率变差的问题,会在一个桶上链表长度为8时,进行树化。但是树化的时候,会判断当前的长度是否小于64,如果小于,则不进行树化,而是选择进行一次扩容,因为扩容的时候会使哈希表长度增加,hash值会重新计算,将重新打乱当前的元素排列,分配到新的空间上,这样也避免了链表过长。
对于HashMap中的每个key,根据一个Hash函数,计算出一个Hash值,对应就是桶的编号,桶实际上是用数组实现的。
//key 进行哈希计算
int hash = hash(key);
//获取数组下标
int i = indexFor(hash, table.length);
通过计算后的下标,从而得到数组的对应下标的位置,最后把k,v值存进去,同样的当再次第二次存值的时候,同样把k,v
传进来,当k再次进行计算出数组下标index
,有可能和第一次计算的index
的值相同。
但是,两次的需要存进去的value
值是不同的,这就出现了同一个数组后面有一条链表,会比较链表上的每一个value
值与当前的value
是否相同,若是不相同,通过头插法,将数值插入链表中。如下图所示:
接下来通通过源码进行分析,在jdk 7插入的put 方法源码如下:
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<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++;
//新增一个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)
新增一个节点。
接下来看看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<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
在JDK 7
中,链表存储有一个缺点,就是当数据很多的时候,链表就会很长,每次查询都会遍历很长的链表。
因此在JDK 8
中为了优化HashMap
的查询效率,将内部的结构改为数组+链表+和红黑树,当一个哈希桶后面的链表长度>8
的时候,就会将链表转化为红黑树,红黑树是二分查找,提高了查询的效率。接下来通过JDK 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<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)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> 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<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);
//链表长度超过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
,那就需要将值覆盖。 - 最后判断是否需要进行扩容。
继续看下 treeifyBin 的源码:
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//链表转为红黑树时,若此时数组长度小于64,扩容数组
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
//链表转为树结构
do {
TreeNode<K,V> 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时
由此在jdk1.8
中,默认长度为16
情况下,要么元素一直放在同一下标,链表转为红黑树且数组元素小于64时就会扩容,要么超过阈值12
时才会扩容。
依据上面的源码分析,在JDK 1.8
中put
方法的执行的原理图如下
问点三:HashMap的扩容机制是怎么样的?JDK7与JDK8有什么不同吗?
JDK 1.7
的扩容条件是数组长度大于阈值且存在哈希冲突,而JDK 1.8
扩容条件是数组长度大于阈值或链表转为红黑树且数组元素小于64时,源码中的体现如下所示:
JDK1.7扩容源码:
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);
}
JDK1.8扩容源码:
//数组长度大于阈值,就扩容
if (++size > threshold)
resize();
//链表转为红黑树时,若此时数组长度小于64,扩容数组
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
问点四:HashMap中的键值可以为Null吗?能简单说一下原理吗?
JDK两个版本都可以。JDK1.7和1.8本质都是去寻找Hash值为0的那个桶,然后如果key为null,则直接替换值
问点五:HashMap中能put两个相同的Key吗?为什么能或为什么不能?
在
JDK7
和JDK8
中的做法是一样的,若是存入的key
值一样,就会将原来的key所对应的value值直接替换掉
问点六:聊一聊JDK 7的HashMap中的“死锁”是怎么回事?
首先JDK7采用的头插***有链表成环的问题,JDK采用的尾插法,不会有循环链表的问题。
HashMap
是线程不安全的,在HashMap
的源码中并未对其操作进行同步执行,所以在并发访问的时候就会出现线程安全的问题。JDK 7死锁出现在高并发的时候,此时两个线程都将对数据进行扩容,每次扩容的时候,会让链表翻转。
transfer
函数源码(transfer
函数是resize扩容方法中调用的另一个方法):
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; ---------------------(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.next = B , B.next = null:
- 正常情况下,新扩容的数组应该是位null,然后A.next = newTable[i],则
A.next = null
,newTable[i] = A ,B.next = newTable[i],则B.next = A
,newTable[i] = B - 如果两个线程同时转换,此时已然会执行 A.next = newTable[i] , 则将
A.next = null变成A.next = B
,剩下过程一致,出现循环链表
问点七:HashMap是线程安全的吗?为什么安全或者不安全?
不是线程安全的,比如会在扩容的时候产生循环链表;在put的时候会覆盖别的线程的值
问点八:什么 HashMap 的加载因子是0.75?为什么是超过“8”才用红黑树?
因为HashMap底层的数据结构是哈希表,在插入数据的时候会出现hash冲突,而HashMap的解决方式是使用拉链法,即采用额外的空间来解决冲突。
而选择加载因子是0.75主要跟数学上泊松分布有关系,选择的参数平均为0.5的泊松分布,计算出来当前加载因子是0.75,这更是空间与时间的一种选择折中。
而超过8才使用红黑树,源于该泊松分布计算出来的,一个节点哈希冲突8次的概率极为的小,几乎为不可能时间,这也是一次空间与时间的折中。
//1个节点哈希冲突n次的概率
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
问点九:HashMap、HashTable、ConcurrentHashMap的区别
HashMap是线程不安全的,在jdk1.7高并发的时候,可能会在扩容的时候,产生循环链表,所以在高并发的时候,不去使用。
HashTable是一个线程安全的,采用的是synchronize的方式,关键字加在方法上,即对当前HashTable对象加锁,会导致所有的数据加上锁。
ConcurrentHashMap在JDK1.7的时候使用的是Segment分段锁的思想,将数据分成段,每个段都有个可重入锁,则多线程的时候不会影响到其他线程访问其他段的数据。
ConcurrentHashMap在JDK1.8的时候使用的是CAS+synchronized方式,抛弃了Segment分段锁的思想,CAS是一个乐观锁,则在不加锁情况下实现赋值,在当前节点为空的时候,会采用CAS的方式添加节点;而用synchronized而不用是可重入锁的原因是因为官方对synchronized做了很多优化。
HashTable
其中使用synchronize来保证线程安全,即当有一个线程拥有锁的时候,其他的线程都会进入阻塞或者轮询状态,这样会使得效率越来越低。
而ConcurrentHashMapMap
的锁分段技术可以有效的提高并发访问率,HashTable
访问效率低下的原因,就是因为所有的线程在竞争同一把锁。
如果容器中有多把锁,不同的锁锁定不同的位置,这样线程间就不会存在锁的竞争,这样就可以有效的提高并发访问效率,这就是ConcurrentHashMap
所使用的锁分段技术将数据一段一段的存储,为每一段都配一把锁,当一个线程只是占用其中的一个数据段时,其他段的数据也能被其他线程访问。
在JDK1.7中currentHashMap采用的是Segment分段锁的思想方式实现:
- Segment是将数据分成段,默认是16,每个段都有一把锁,则理论最高支持16个线程并发
JDK1.8的currentHashMap采用的是**数组+链表+红黑树,抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized **方式实现:
-
CAS,是一个乐观锁,他主要思想是比较然后再执行操作,比较的是内存的值和预期的原值是否一致,如果不一致,则需要自旋获取新值,但是CAS的最大问题就是可能出现ABA的问题。
-
在
no lock when adding to empty bin
,也就是桶为空的时候采用CAS方式添加数据,而不为空的时候采用synchronized 的方式
问点十:谈谈你理解的 HashMap,讲讲其中的 get和put 过程
put方法:
- 考虑是否要初始化
- 根据key计算哈希值,找到hash表对应的索引
- 然后判断是否出现的hash冲突,如果没有则直接插入,查看是否需要扩容
- JDK1.7出现hash冲突直接插入链表中即可
- JDK1.8出现hash冲突,需要先判断当前是红黑树还是链表,对于链表可能会有一个树化的过程
get方法:
- 思路与上面一致,只是少了一个扩容和树化的过程