HashMap、ConcurrentHash面试题
HashMap
首先介绍hashMap是线程不安全的,HashMap是数组+链表+红黑树(JDK1.8)实现的
HashMap中几个重要变量如下:
//默认的初始化容量为16,必须是2的n次幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//默认的加载因子0.75,乘以数组容量得到的值,用来表示元素个数达到多少时,需要扩容。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//刚才提到了当链表长度过长时,会有一个阈值,超过这个阈值8就会转化为红黑树
static final int TREEIFY_THRESHOLD = 8;
//当红黑树上的元素个数,减少到6个时,就退化为链表
static final int UNTREEIFY_THRESHOLD = 6;
//链表转化为红黑树,除了有阈值的限制,还有另外一个限制,需要数组容量至少达到64,才会树化。
//这是为了避免,数组扩容和树化阈值之间的冲突。
HashMap中put方法分析
-
进入到put方法之后,首先会将你给定的key通过hash算法以及与运算的方法将其运算得出数组的下标
-
如果数组的下标位置元素为空,则将对应的key value 封装成一个对象放入该数组位置,其实也就是存放key value键值对的对象而已
-
如果数组下标元素不为空的话,(JDK1.8)
(1)如果该位置上的key与插入元素的key相等且不为null,则更新数组下标元素
(2)如果Node节点是红黑树节点,会将key value封装成一个红黑树的Node节点将其添加到红黑树中,并且在这个过程中会判断红黑树是否存在要插入的key,如果存在该key,则直接更新value即可
(3)如果该位置上Node节点的类型是链表的话,同样将该key value 封装成一个链表Node节点。然后使用尾插法的方式插入到该链表的最后位置中,在进行遍历的时候,同样会遍历链表的key值,如果存在插入的key值的话,那么直接更新value值即可。如果不存在就插入到链表的最后一个位置上。插入到链表之后,会将链表的长度更新,如果链表长度大于等于8的话,会将该链表更新会红黑树。 -
将key value封装成Node对象将其插入到链表或者红黑树中后,在判断是否需要扩容,如果需要就进行扩容的操作,如果不需要扩容那么就退出put方法。返回执行的操作。
put方法源码
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // hashMap初始数组大小 使用二进制更快因为计算机内部就是二进制
static final float DEFAULT_LOAD_FACTOR = 0.75f; //负载因子(进行扩容) 当前数组大小>DEFAULT_INITIAL_CAPACITY*DEFAULT_LOAD_FACTOR
static final int TREEIFY_THRESHOLD = 8;//阈值 当链表长度达到阈值时进行重构为红黑树(平均效率为logn 比较稳定)
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);
}
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)
//没有初始化 我去初始化我的空间,or 扩容我的数组
n = (tab = resize()).length;
//tab代表当前Node数组
// 计算需要存放的下标(0 ~(n-1))index=i = (n - 1) & hash
if ((p = tab[i = (n - 1) & hash]) == null) //代表当前index没有存储元素
tab[i] = newNode(hash, key, value, null); //存入新值
else {
//如果 i位 已经被占了 判断当前元素key是否已经存在
Node<K,V> e; K k;
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) {
//找到节点为null的进行插入链表节点
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//如果链表长度达到8或大于8 将此链表转为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//判断是否有重复的元素
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; //迭代器遍历的时候会用到此数据
//如果size>threshold阈值 则扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
注意:
- :在put方法中解决hash碰撞的方式很清楚,即当两个entry的hash值相同时,需要对key值是否相同进行判断,只有key和hash都相同,才能进行修改,否则认为不是同一个entry。
- :在两个线程同时进行put时可能造成一个线程数据的丢失
- :链表长度>8 并数组大小>=64时 则才会转为红黑树(数组capacity不能太小)
- 在JDK1.7以及前是在头结点插入的,在JDK1.8之后是在尾节点插入的。
HashMap多线程不安全,能举出例子来吗?
-
第一,如果多个线程同时使用put方法添加元素。假设正好存在两个线程在put时,它们的key发生了碰撞(hash值一样),那么根据HashMap的实现,这两个key会添加到数组的同一个位置,这样最终就会发生其中一个线程的put的数据被覆盖。
假设线程1和线程2同时执行put,线程1执行put(“1”, “A”),线程2执行put(“5”, “B”),假如都冲突在table[1]这里了
在正常情况下
下面来看看异常情况
两个线程都执行到if ((p = tab[i = (n - 1) & hash]) == null)
这句代码是。
此时假设线程1 先执行tab[i] = newNode(hash, key, value, null),
那么table会变成如下状态:
紧接着线程2也执行tab[i] = newNode(hash, key, value, null),
此时table会变成如下状态:
这样一来,元素A就丢失了。 -
第二,如果多个线程同时检测到元素个数超过数组大小。这样会发生多个线程同时对hash数组进行扩容,都在重新计算元素位置以及复制数据,但是最终只有一个线程扩容后的数组会赋给table,也就是说其他线程的都会丢失,并且各自线程put的数据也丢失。
-
第三 put和get并发时,可能导致get为null
线程1执行put时,因为元素个数超出threshold而导致resize,线程2此时执行get,有可能导致这个问题。
在进行扩容时,resize方法中首先会将实例变量table赋值给oldTab,之后用新计算的容量new了一个新的hash表,然后将新创建的空hash表赋值给实例变量table。注意此时实例变量table是空的,如果此时另一个线程执行get,就会get出null。
(最后是通过遍历oldTab来给新的Hash表进行赋值)
ConcurrentHash
- ConcurrentHash是线程安全的HashMap,
- synchronizedMap也是线程安全的HashMap,一般不用。因为是采用synchronized方法上加锁,使用阻塞同步,效率低。
JDK1.7 ConcurrentHash
在JDK1.7版本中,ConcurrentHashMap的数据结构是由一个Segment数组和多个HashEntry组成,如下图所示:
Segment数组的意义就是将一个大的table分割成多个小的table来进行加锁,也就是上面的提到的锁分离技术,而每一个Segment元素存储的是HashEntry数组+链表,这个和HashMap的数据存储结构一样
JDK1.8 ConcurrentHash
- 和 1.8 HashMap 结构类似,也是数组+链表+红黑树的。取消了Segment 分段锁
- 红黑树转换条件和HashMap 一模一样:链表长度超过 8并且数组(桶)的长度超过 64
为什么ConcurrentHash是保证线程安全的?
是采用CAS + synchronized + volatile 来保证并发安全性
- 对当前的table进行无条件自循环直到put成功(cas自旋)。
casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null))
- 如果数组下标没有Node节点,就用CAS+自旋添加链表头节点。
- 如果有Node节点,就加synchronized,然后再添加链表或红黑树节点。
- get操作,由于数组被volatile修饰了,因此不用担心数组的可见性问题。
volatile Node<K,V>[] table;
ConcurrentHashd的put方法源码如下
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
//1. 计算key的hash值
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//2. 判断Node[]数组是否初始化,没有则进行初始化操作
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//3. 通过hash定位数组的索引坐标,是否有Node节点,如果没有则使用CAS进行添加(链表的头节点),添加失败则进入下次循环。
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
//4. 检查到内部正在扩容,就帮助它一块扩容。
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// 如果该坐标Node不为null且没有正在扩容,就加锁,进行链表/红黑树 节点添加操作
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
//5. 当前为链表,在链表中插入新的键值对
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
// 6.当前为红黑树,将新的键值对插入到红黑树中
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
// 7.判断链表长度已经达到临界值8(默认值),当节点超过这个值就需要把链表转换为树结构
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
//8.对当前容量大小进行检查,如果超过了临界值(实际大小*加载因子)就需要扩容
addCount(1L, binCount);
return null;
}