IT评测·应用市场-qidao123.com
标题:
Java面试题:HashMap为什么线程不安全、ConcurrentHashMap原理(JDK1.7与JD
[打印本页]
作者:
反转基因福娃
时间:
2024-9-1 16:27
标题:
Java面试题:HashMap为什么线程不安全、ConcurrentHashMap原理(JDK1.7与JD
还记得
HashMap的实现原理、jdk1.7与jdk1.8的HashMap有什么区别吗
?如果忘记可以到这里重新温习: Java面试题:ArrayList底层实现原理、HashMap的实现原理、HashMap的jdk1.7和jdk1.8有什么区别
1.HashMap 为什么线程不安全
1.1 概述——HashMap线程不安全的体现、原因、改善
HashMap
是线程不安全
的,它黑白同步的数据结构。主要
体现
在:
jdk1.7中,在多线程环境下,扩容时会出现
死循环、数据丢失
问题
jdk1.8中,在多线程环境下,会发生
数据覆盖
的环境
HashMap线程不安全原因
(具体原因见1.2、1.3):
JDK1.7 中,HashMap扩容时使用
头插法插入元素
。具体原因:在 HashMap 触发扩容时,正好两个线程同时在操纵同一个链表,当线程A被挂起,线程B已完成数据迁徙,等CPU资源释放后被挂起的线程A重新实行之前的逻辑,数据已经被改变,形成环形链表,造成
死循环、数据丢失
问题 —— 在JDK1.8中接纳了
尾插法
插入元素,再扩容时会保持链表本来的次序,制止了死循环的问题
JDK1.8 中,由于多线程对HashMap进行put操纵,调用了HashMap#putVal(),如果两个线程并发实行 put 操纵,并且两个数据的 hash 值冲突,就大概出现数据覆盖。具体原因:线程 A 判断 hash 值位置为 null,还未写入数据、由于时间片耗尽导致被挂起,此时线程 B 正常插入数据。接着线程 A 得到时间片,由于线程 A 之前已进行hash碰撞的判断,所以此时不会再进行判断、而是直接进行插入,就会把刚才线程 B 写入的
数据覆盖
掉
为了在多线程环境下使用安全的HashMap,可以接纳以下步伐:
使用线程安全的替换品
:使用线程安全的聚集类,如
ConcurrentHashMap
,它是专门设计用于多线程环境的哈希表,提供了高效的并发性能。
显式同步
:如果必须使用普通的HashMap,确保在访问和修改HashMap时进行适当的同步,使用
synchronized
关键字或其他同步机制来掩护共享资源。如
Collections.synchronizedMap(map)
,包装成同步Map,原理就是在HashMap的所有方法上synchronized
使用线程局部变量
:为每个线程维护一个独立的HashMap实例,以制止线程间竞争。ThreadLocal<Map<String, Integer>> threadLocalMap = ThreadLocal.withInitial(HashMap::new);
1.2 jdk1.7中的线程不安全——死循环、数据丢失
jdk1.7 HashMap的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);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
复制代码
在对table进行扩容到newTable后,需要将原来数据转移到newTable中,留意10-12行代码,这里可以看出在转移元素的过程中,使用的是头插法,也就是链表的次序会翻转,这里也是形成死循环的关键点。下面进行详细分析。
条件条件:此处假设
hash算法为简单的用key mod链表的大小。
最开始hash表size=2,key=3,7,5,则都在table[1]中
然后进行resize,使size变成4。
未resize前的数据结构如下:
如果在单线程中,末了的结果如下:
在多线程环境下,假设有两个线程A和B都在进行put操纵。线程A在实行到transfer函数中第11行代码 newTable
= e 处挂起,此时线程A中运行结果如下,e=3、next=7、e.next=null:
线程A挂起后,此时线程B正常实行,并完成resize操纵,结果如下:
重点来了,根据Java内存模式可知,线程B实行完数据迁徙后,此时主内存中newTable和table都是最新的,也就是说:7.next=3、3.next=null。
此时切换到线程A上,在线程A挂起时内存中值如下:e=3,next=7,newTable[3]=null,代码实行过程如下:
newTable[3]=e ----> newTable[3]=3
e=next ----> e=7
复制代码
此时结果如下:
继承循环:
e=7
next=e.next ----> next=3【从主存中取值】
e.next=newTable[3] ----> e.next=3【从主存中取值】
newTable[3]=e ----> newTable[3]=7
e=next ----> e=3
复制代码
结果如下:
再次进行循环:
e=3
next=e.next ----> next=null
e.next=newTable[3] ----> e.next=7 即:3.next=7
newTable[3]=e ----> newTable[3]=3
e=next ----> e=null
复制代码
留意此次循环:e.next=7,而在前次循环中7.next=3,出现环形链表,并且此时e=null循环结束。结果如下
上面说了此时e.next=null即next=null,当实行完e=null后,将不会进行下一轮循环。到此线程A、B的扩容操纵完成,很明显当线程A实行完后,HashMap中出现了环形结构,当在以后对该HashMap进行操纵时会出现死循环。
并且从上图可以发现,元素5在扩容期间被莫名的丢失了,这就发生了数据丢失的问题。
参考回复:
在jdk1.7的hashmap中在数组进行扩容的时候,因为链表是头插法,在进行数据迁徙的过程中,有大概导致死循环。
比如说,现在有两个线程
线程一:读取到当前的hashmap数据,数据中一个链表,在准备扩容时,线程二参与
线程二:也读取hashmap,直接进行扩容。因为是头插法,链表的次序会进行颠倒过来。比如原来的次序是AB,扩容后的次序是BA,线程二实行结束。
线程一:继承实行的时候就会出现死循环的问题。
线程一先将A移入新的链表,再将B插入到链头,由于另外一个线程的原因,B的next指向了A,所以B->A->B,形成循环。当然,JDK 8 将扩容算法做了调解,不再将元素参加链表头(而是保持与扩容前一样的次序),
尾插法
,就制止了jdk7中死循环的问题。
1.3 jdk1.8中的线程不安全——数据覆盖
在jdk1.8中对HashMap进行了优化,在发生hash碰撞,不再接纳头插法方式,而是直接插入链表尾部 即
尾插法
,保持了链表元素的次序,解决了扩容造成的死循环、数据丢失问题。如果你去阅读1.8的源码会发现找不到HashMap#transfer(),因为JDK1.8直接在HashMap#resize()中完成了数据迁徙。
但在多线程的环境下仍然不安全,会发生
数据覆盖
问题。
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) //如果没有hash碰撞则直接插入元素
tab[i] = newNode(hash, key, value, null);
else {
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) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
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;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
复制代码
根据putVal源码,留意第6行代码,如果没有hash碰撞则会直接插入元素。假设两个线程A、B都在进行put操纵,并且hash函数盘算出的插入下标是相同的,当线程A实行完第6行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入;然后线程A得到时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
除此之前,还有代码的第38行处++size,假设线程A、B同时进行put操纵,当前HashMap的zise大小为10,当线程A实行到第38行代码时,从主内存中得到size的值为10后准备进行+1操纵,但是由于时间片耗尽只好让出CPU;线程B拿到CPU还是从主内存中拿到size的值10进行+1操纵,完成了put操纵并将size=11写回主内存,然后线程A再次拿到CPU并继承实行(此时size的值仍为10),当实行完put操纵后,还是将size=11写回内存,此时,线程A、B都实行了一次put操纵,但是size的值只增长了1,所有说还是由于数据覆盖又导致了线程不安全。
1.4 如何在多线程环境下使用安全的HashMap
为了在多线程环境下使用安全的HashMap,可以接纳以下步伐:
使用线程安全的替换品
:使用线程安全的聚集类,如
ConcurrentHashMap
,它是专门设计用于多线程环境的哈希表,提供了高效的并发性能。
显式同步
:如果必须使用普通的HashMap,确保在访问和修改HashMap时进行适当的同步,使用
synchronized
关键字或其他同步机制来掩护共享资源。如
Collections.synchronizedMap(map)
,包装成同步Map,原理就是在HashMap的所有方法上synchronized
使用线程局部变量
:为每个线程维护一个独立的HashMap实例,以制止线程间竞争。ThreadLocal<Map<String, Integer>> threadLocalMap = ThreadLocal.withInitial(HashMap::new);
2.ConcurrentHashMap原理、分段锁、局部锁、线程安全
HashMap 在多线程环境下操纵不安全,ConcurrentHashMap是HashMap的线程安全版本。
JDK1.8中 ConcurrentHashMap内部是使用 数组 + 链表 + 红黑树 的结构来存储元素。相比于同样线程安全的HashTable来说,服从等各方面都有极大地进步。
ConcurrentHashMap 的上风在于分身性能和线程安全,一个线程进行写操纵时,它会锁住一小部分,其他部分的读写不受影响,其他线程访问没上锁的地方不会被阻塞。
ConcurrentHashMap
底层数据结构
:
JDK1.7底层接纳分段的数组+链表实现
JDK1.8接纳的数据结构跟HashMap1.8的结构一样,数组+链表+红黑树。
加锁的方式
:
JDK1.7接纳Segment分段锁,底层使用的是ReentrantLock
JDK1.8接纳CAS添加新节点,接纳synchronized锁定链表或红黑二叉树的首节点,相对Segment分段锁粒度更细,性能更好
JDK1.8中的ConcurrentHashMap比JDK1.7中的ConcurrentHashMap幸亏那里?
JDK1.8的实现低落锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点)。
JDK1.8版本的数据结构变得更加简单,使得操纵也更加清晰流通,因为已经使用synchronized来进行同步,所以不需要分段锁的概念,也就不需要Segment这种数据结构了,由于粒度的低落,实现的复杂度也低落了。
JDK1.8使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历服从是很快的,取代一定阈值的链表。
JDK1.8为什么使用内置锁synchronized来取代重入锁ReentrantLock,有以下几点:
因为锁粒度低落了,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点)。在相对而言的低粒度加锁方式,synchronized并不比ReentrantLock差,在粗粒度加锁中ReentrantLock大概通过Condition来控制各个低粒度的边界,更加的机动,而在低粒度中,Condition的上风就没有了。
JVM的开辟团队从来都没有放弃synchronized,而且基于JVM的synchronized优化空间更大,使用内嵌的关键字比使用API更加自然。
在大量的数据操纵下,对于JVM的内存压力,基于API的ReentrantLock会开销更多的内存,虽然不是瓶颈,但是也是一个选择依据。
2.1 ConcurrentHashMap概述
java.util.concurrent.ConcurrentHashMap属于JUC包下的一个聚集类,可以实现线程安全。
ConcurrentHashMap和HashMap一样,是一个存放键值对的容器。使用hash算法来获取值的地点,因此时间复杂度是O(1)。查询非常快。同时,ConcurrentHashMap是线程安全的HashMap。专门用于多线程环境。
JDK1.7
ConcurrentHashMap 接纳
分段锁
策略,由多个 Segment 组合而成,其中 Segment 可以看成一个 HashMap, 不同点是
Segment 继承自 ReentrantLock
,在操纵的时候给 Segment 赋予了一个对象锁(Put 操纵时,锁的是某个 Segment,其他线程对其他 Segment 的读写操纵均不影响),从而保证多线程环境下并发操纵安全。
不同Segment的并发写入(可以并发实行);同一Segment的一写一读(可以并发实行,table有volatile关键字修饰,保证每次获取值都是最新的);同一Segment的并发写入,会阻塞
ConcurrentHashMap 中每个Segment各自持有一把锁。在保证线程安全的同时低落了锁的粒度,让并发操纵服从更高。
JDK1.8
相比于 JDK1.7 中的 ConcurrentHashMap,JDK1.8 中 ConcurrentHashMap 类
取消了 Segment 分段锁,接纳 CAS + synchronized 来保证并发安全
;数据结构跟jdk1.8中HashMap一样,数组+链表改为
数组+链表+红黑树
,当冲突链表长度大于8时,会将链表转变成红黑树结构。
ConcurrentHashMap 中 synchronized 只锁定当前链表或红黑二叉树的首节点,只要节点 hash 不冲突,就不会产生并发,相比 JDK1.7 的 ConcurrentHashMap 服从又提拔了 N 倍!
2.2 ConcurrentHashMap源码 jdk1.8
组成
//ConcurrentHashMap使用volatile修饰节点数组,保证其可见性,禁止指令重排。
//而HashMap没有使用volatile, transient Node<K,V>[] table;
transient volatile Node<K,V>[] table;
复制代码
put方法
put()方法没有效synchronized修饰
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
// key和value都不能为null
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) { //死循环,可视为乐观锁
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
// 如果tab未初始化或者个数为0,则初始化node数组
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
// 如果使用CAS插入元素时,发现已经有元素了,则进入下一次循环,重新操作
// 如果使用CAS插入元素成功,则break跳出循环,流程结束
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
// 如果要插入的元素所在的tab的第一个元素的hash是MOVED,则当前线程帮忙一起迁移元素
tab = helpTransfer(tab, f);
else { //发生hash冲突
// 如果这个tab不为空且不在迁移元素,则锁住这个tab(分段锁)
// 并查找要插入的元素是否在这个tab中
// 存在,则替换值(onlyIfAbsent=false)
// 不存在,则插入到链表结尾或插入树中
V oldVal = null;
synchronized (f) {
// 再次检测第一个元素是否有变化,如果有变化则进入下一次循环,从头来过
if (tabAt(tab, i) == f) {
// 如果第一个元素的hash值大于等于0(说明不是在迁移,也不是树)
// 那就是tab中的元素使用的是链表方式存储
if (fh >= 0) {
// tab中元素个数赋值为1
binCount = 1;
// 遍历整个tab,每次结束binCount加1
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
// 如果找到了这个元素,则赋值了新值(onlyIfAbsent=false),并退出循环
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;
}
}
}
else if (f instanceof TreeBin) {
// 如果第一个元素是树节点
Node<K,V> p;
// tab中元素个数赋值为2
binCount = 2;
// 调用红黑树的插入方法插入元素,如果成功插入则返回null,否则返回寻找到的节点
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
// 如果找到了这个元素,则赋值了新值(onlyIfAbsent=false),并退出循环
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
// 如果binCount不为0,说明成功插入了元素或者寻找到了元素
if (binCount != 0) {
// 如果链表元素个数达到了8,则尝试树化
// 因为上面把元素插入到树中时,binCount只赋值了2,并没有计算整个树中元素的个数,所以不会重复树化
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
// 如果要插入的元素已经存在,则返回旧值
if (oldVal != null)
return oldVal;
// 退出外层大循环,流程结束
break;
}
}
}
// 成功插入元素,元素个数加1(是否要扩容在这个里面)
addCount(1L, binCount);
// 成功插入元素返回null
return null;
}
复制代码
put操纵总结:
做插入操纵时,首先进入乐观锁,在乐观锁中判断容器是否初始化,
如果没初始化则初始化容器;如果已经初始化,则判断该hash位置的节点是否为空,
如果为空,则通过CAS操纵进行插入。
如果该节点不为空,再判断容器是否在扩容中,如果在扩容,则资助其扩容。如果没有扩容,则进行末了一步,先加锁,然后找到hash值相同的那个节点(hash冲突),
循环判断这个节点上的链表,决定做覆盖操纵还是插入操纵。
循环结束,插入完毕。
首先会判断 key、value是否为空,如果为空就抛异常;
接着会判断容器数组是否为空,如果为空就初始化数组;
进一步判断,要插入的元素f,在当前数组下标是否第一次插入、即该hash位置的节点是否为空,如果是就通过 CAS 方式插入;
如果不为空,再接着判断f.hash == -1是否成立,如果成立,说明当前f是ForwardingNode节点,表现有别的线程正在扩容,则一起进行扩容操纵;
其他的环境,就是把新的Node节点按链表或红黑树的方式插入到符合的位置;
节点插入完成之后,接着判断链表长度是否超过8,如果超过8个,就将链表转化为红黑树结构;
末了,插入完成之后,进行扩容判断。
get方法
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// 计算hash
int h = spread(key.hashCode());
// 判断数组是否为空,通过key定位到数组下标是否为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 如果第一个元素就是要找的元素,直接返回
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
else if (eh < 0)
// hash小于0,说明是树或者正在扩容
// 使用find寻找元素,find的寻找方式依据Node的不同子类有不同的实现方式
return (p = e.find(h, key)) != null ? p.val : null;
// 遍历整个链表寻找元素
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
复制代码
步调如下:
判断数组是否为空,通过key定位到数组下标是否为空;
判断node节点第一个元素是不是要找到,如果是直接返回;
如果是红黑树结构,就从红黑树内里查询;
如果是链表结构,循环遍历判断。
ConcurrentHashMap的get()方法没有加synchronized锁,为什么可以不加锁?因为table有volatile关键字修饰,保证每次获取值都是最新的。
【Hashtable的get(Object key)方法加了synchronized锁,性能较差】
remove方法
public V remove(Object key) {
// 调用替换节点方法
return replaceNode(key, null, null);
}
final V replaceNode(Object key, V value, Object cv) {
// 计算hash
int hash = spread(key.hashCode());
// 循环遍历数组
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//校验参数
if (tab == null || (n = tab.length) == 0 ||
(f = tabAt(tab, i = (n - 1) & hash)) == null)
break;
else if ((fh = f.hash) == MOVED)
// 如果正在扩容中,协助扩容
tab = helpTransfer(tab, f);
else {
V oldVal = null;
// 标记是否处理过
boolean validated = false;
//用 synchronized 同步锁,保证并发时元素移除安全
synchronized (f) {
// 再次验证当前tab元素是否被修改过
if (tabAt(tab, i) == f) {
if (fh >= 0) {
// fh>=0表示是链表节点
validated = true;
// 遍历链表寻找目标节点
for (Node<K,V> e = f, pred = null;;) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
V ev = e.val;
if (cv == null || cv == ev ||
(ev != null && cv.equals(ev))) {
oldVal = ev;
if (value != null)
e.val = value;
else if (pred != null)
pred.next = e.next;
else
setTabAt(tab, i, e.next);
}
break;
}
pred = e;
// 遍历到链表尾部还没找到元素,跳出循环
if ((e = e.next) == null)
break;
}
}
else if (f instanceof TreeBin) {
// 如果是树节点
validated = true;
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> r, p;
// 遍历树找到了目标节点
if ((r = t.root) != null &&
(p = r.findTreeNode(hash, key, null)) != null) {
V pv = p.val;
if (cv == null || cv == pv ||
(pv != null && cv.equals(pv))) {
oldVal = pv;
if (value != null)
p.val = value;
else if (t.removeTreeNode(p))
setTabAt(tab, i, untreeify(t.first));
}
}
}
}
}
// 如果处理过,不管有没有找到元素都返回
if (validated) {
// 如果找到了元素,返回其旧值
if (oldVal != null) {
// 如果要替换的值为空,元素个数减1
if (value == null)
addCount(-1L, -1);
return oldVal;
}
break;
}
}
}
// 没找到元素返回空
return null;
}
复制代码
步调如下:
循环遍历数组,接着校验参数;
判断是否有别的线程正在扩容,如果是一起扩容;
用 synchronized 同步锁,保证并发时元素移除安全;
因为 check= -1,所以不会进行扩容操纵,利用CAS操纵修改baseCount值。
2.3 ConcurrentHashMap结构 jdk1.7–>jdk1.8
jdk1.7下的ConcurrentHashMap
它由多个 Segment 组合而成。Segment 本身就相当于一个 HashMap 对象。
同 HashMap 一样,Segment 包含一个 HashEntry 数组,数组中的每一个 HashEntry 既是一个键值对,也是一个链表的头节点。
单一的 Segment 结构如下:
像这样的 Segment 对象,在 ConcurrentHashMap 聚集中有多少个呢?有 2 的 N 次方个,共同保存在一个名为 segments 的数组当中。
因此整个ConcurrentHashMap的结构如下:
可以说,ConcurrentHashMap 是一个二级哈希表。在一个总的哈希表下面,有多少个子哈希表。
它的核心属性
final Segment<K,V>[] segments;
transient Set<K> keySet;
transient Set<Map.Entry<K,V>> entrySet;
复制代码
其中,Segment是它的一个内部类,主要组成如下:
static final class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
// 和 HashMap 中的 HashEntry 作用一样,真正存放数据的桶
transient volatile HashEntry<K,V>[] table;
transient int count;
transient int modCount;
transient int threshold;
final float loadFactor;
// ...
}
复制代码
HashEntry也是一个内部类,主要组成如下:
static class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
//...
}
复制代码
和 HashMap 的 Entry 基本一样,唯一的区别就是其中的核心数据如 value ,以及链表都是 volatile 修饰的,保证了获取时的可见性。
get方法
为输入的Key做Hash运算,得到hash值。
通过hash值,定位到对应的Segment对象
再次通过hash值,定位到Segment当中数组的具体位置。
put方法
为输入的Key做Hash运算,得到hash值。
通过hash值,定位到对应的Segment对象
获取可重入锁
再次通过hash值,定位到Segment当中数组的具体位置。
插入或覆盖HashEntry对象。
释放锁。
高并发线程安全
:Put 操纵时,锁的是某个 Segment,其他线程对其他 Segment 的读写操纵均不影响。因此解决了线程安全问题。
jdk1.8下的COncurrentHashMap
虽然 JDK1.7 中的 ConcurrentHashMap 解决了 HashMap 并发的安全性,但是当冲突的链表过长时,在查询遍历的时候依然很慢。因此JDK8有所改进:
(在 JDK1.8 中,HashMap 引入了红黑二叉树设计,当冲突的链表长度大于8时,会将链表转化成红黑二叉树结构,红黑二叉树又被称为平衡二叉树,在查询服从方面,又大大的进步了不少。)
1)结构改变
首先是结构上的变化,相比于 JDK1.7 中的 ConcurrentHashMap,JDK1.8 中 ConcurrentHashMap
取消了 Segment 分段锁,接纳 CAS + synchronized 来保证并发安全
;数据结构跟jdk1.8中HashMap一样,数组+链表改为
数组+链表+红黑树
,当冲突链表长度大于8时,会将链表转变成红黑树结构。
ConcurrentHashMap 中 synchronized 只锁定当前链表或红黑二叉树的首节点,只要节点 hash 不冲突,就不会产生并发,相比 JDK1.7 的 ConcurrentHashMap 服从又提拔了 N 倍!
2)HashEntry 改为 Node
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
}
复制代码
和 JDK7 的 HasEntry 作用相同,对 val 和 next 都用了 volatile 关键字,保证了可见性。
3)Put操纵的变化
根据 key 盘算出 hashcode,然后开始遍历 table;
判断是否需要初始化;
f 即为当前 key 定位出的 Node,如果为空表现当前位置可以写入数据,利用 CAS 实验写入,失败则自旋保证乐成。
如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
如果都不满足,则利用 synchronized 锁写入数据。
如果数目大于 TREEIFY_THRESHOLD 则要转换为红黑树。
4)Get操纵的变化
根据盘算出来的 hashcode 寻址,如果就在桶上那么直接返回值。
如果是红黑树那就按照树的方式获取值。
都不满足那就按照链表的方式遍历获取值。
为什么取消分段锁,分段锁有什么问题
分段锁内存开销大
锁粒度太小,经常涉及跨多个锁操纵,性能太低(有些方法需要跨段,比如size()和containsValue(),它们大概需要锁定整个表而而不但仅是某个段,这需要按次序锁定所有段,操纵完毕后,又按次序释放所有段的锁)
扩容会牵扯到多个分段锁,并发操纵复杂性太高
2.4 ConcurrentHashMap总结
底层数据结构
:
JDK1.7底层接纳分段的数组+链表实现
JDK1.8接纳的数据结构跟HashMap1.8的结构一样,数组+链表+红黑树。
加锁的方式
:
JDK1.7接纳Segment分段锁,底层使用的是ReentrantLock
JDK1.8接纳CAS添加新节点,接纳synchronized锁定链表或红黑二叉树的首节点,相对Segment分段锁粒度更细,性能更好
JDK1.7
ConcurrentHashMap 接纳
分段锁
策略,由多个 Segment 组合而成,其中 Segment 可以看成一个 HashMap, 不同点是
Segment 继承自 ReentrantLock
,在操纵的时候给 Segment 赋予了一个对象锁(Put 操纵时,锁的是某个 Segment,其他线程对其他 Segment 的读写操纵均不影响),从而保证多线程环境下并发操纵安全。
ConcurrentHashMap 中每个Segment各自持有一把锁。在保证线程安全的同时低落了锁的粒度,让并发操纵服从更高。
JDK1.8
相比于 JDK1.7 中的 ConcurrentHashMap,JDK1.8 中 ConcurrentHashMap 类
取消了 Segment 分段锁,接纳 CAS + synchronized 来保证并发安全
;数据结构跟jdk1.8中HashMap一样,数组+链表改为
数组+链表+红黑树
,当冲突链表长度大于8时,会将链表转变成红黑树结构。
ConcurrentHashMap 中 synchronized 只锁定当前链表或红黑二叉树的首节点,只要节点 hash 不冲突,就不会产生并发,相比 JDK1.7 的 ConcurrentHashMap 服从又提拔了 N 倍。
3.HashMap与ConcurrentHashMap工作原理、区别、总结
3.1 HashMap与ConcurrentHashMap区别
是否线程安全:
HashMap不是线程安全的;
ConcurrentHashMap是线程安全的,通过
Segment分段锁–继承 ReentrantLock(JDK1.7重入锁)
、
CAS和synchronized(JDK1.8内置锁)
来进行加锁 ,保证线程安全
底层数据结构
HashMap:JDK1.8之前HashMap的结构为数组+链表,JDK1.8之后为数组+链表+红黑树;
ConcurrentHashMap:JDK1.8之前ConcurrentHashMap的结构为 Segment数组+数组+链表,JDK1.8之后为 数组+链表+红黑树(类似于JDK1.8版本的HashMap)。
3.2 工作原理
3.2.1 HashMap
HashMap的工作原理、底层数据结构 可以查看 Java面试题:ArrayList底层实现原理、HashMap的实现原理、HashMap的jdk1.7和jdk1.8有什么区别
HashMap的数据结构
: 底层使用hash表数据结构,即数组和链表或红黑树。jdk1.7使用的是 数组+链表,jdk1.8 当链表长度大于阈值(默认为8)并且数组长度达到64时 会转换为红黑树
初始容量
:HashMap 的初始容量是 0,这是一种懒加载机制,直到第一次 put 操纵才会初始化数组大小,默认大小是 16。
扩容逻辑:
HashMap 使用的是拉链法来解决散列冲突,扩容并不是必须的,但是不扩容的话会造成拉链的长度越来越长,导致散列表的时间复杂度会倾向于 O(n) 而不是 O(1)。
HashMap 扩容的触发时机出现在元素个数超过阈值(容量 * loadFactor)的时候时,会将聚集的一维数组扩大一倍,然后重新盘算每个元素的位置。
当我们往HashMap中put元素时,利用key的hashCode重新hash盘算出当前对象的元素在数组中的下标
存储时,如果出现hash值相同的key,此时有两种环境。
如果key相同,则覆盖原始值;
如果key不同(出现冲突),则将当前的key-value放入链表或红黑树中
获取时,直接找到hash值对应的下标,在进一步判断key是否相同,从而找到对应值。
留意:链表的长度大于8 且 数组长度大于64转换为红黑树
3.2.2 ConcurrentHashMap
JDK1.7
ConcurrentHashMap 接纳
分段锁
策略,由多个 Segment 组合而成,其中 Segment 可以看成一个 HashMap, 不同点是
Segment 继承自 ReentrantLock
,在操纵的时候给 Segment 赋予了一个对象锁(Put 操纵时,锁的是某个 Segment,其他线程对其他 Segment 的读写操纵均不影响),从而保证多线程环境下并发操纵安全。
ConcurrentHashMap 中每个Segment各自持有一把锁。在保证线程安全的同时低落了锁的粒度,让并发操纵服从更高。
JDK1.8
相比于 JDK1.7 中的 ConcurrentHashMap,JDK1.8 中 ConcurrentHashMap 类
取消了 Segment 分段锁,接纳 CAS + synchronized 来保证并发安全
;数据结构跟jdk1.8中HashMap一样,数组+链表改为
数组+链表+红黑树
,当冲突链表长度大于8时,会将链表转变成红黑树结构。
ConcurrentHashMap 中 synchronized 只锁定当前链表或红黑二叉树的首节点,只要节点 hash 不冲突,就不会产生并发,相比 JDK1.7 的 ConcurrentHashMap 服从又提拔了 N 倍。
细节如下:
底层数据结构
:
JDK1.7底层接纳分段的数组+链表实现
JDK1.8接纳的数据结构跟HashMap1.8的结构一样,数组+链表+红黑树。
加锁的方式
:
JDK1.7接纳Segment分段锁,底层使用的是ReentrantLock
JDK1.8接纳CAS添加新节点,接纳synchronized锁定链表或红黑二叉树的首节点,相对Segment分段锁粒度更细,性能更好
4.HashMap与Hashtable的区别
Hashtable和HashMap都是 基于hash表实现的K-V结构的聚集,Hashtable是jdk1.0引入的一个线程安全的聚集类,内部使用数组+链表的形式来实现
从功能特性的角度来说
1、Hashtable是线程安全的(HashTable 对每个方法都增长了 synchronized),而HashMap不是
2、HashMap的性能要比Hashtable更好,因为Hashtable接纳了全局同步锁来保证安全性,对性能影响较大
从内部实现的角度来说
1)Hashtable使用数组加链表,HashMap JDK1.7数组+链表、JDK1.8 数组+链表+红黑树
2)HashMap初始容量是16,Hashtable初始容量是11
3)HashMap可以使用null作为key;而Hashtable不允许 null 作为 Key,会抛出NullPointerException异常
他们两个的key的散列算法不同:Hashtable直接是使用key的hashcode对数组长度取模;而HashMap对key的hashcode做了二次散列,从而制止key的分布不均匀影响到查询性能
5.HashMap、Hashtable、ConcurrentHashMap区别
HashMap、Hashtable、ConcurrentHashMap都是 基于hash表实现的K-V结构的聚集,在线程安全、底层数据结构方面有所区别
HashMap:;线程不安全,因为HashMap中操纵都没有加锁,因此在多线程环境下会导致数据覆盖之类的问题,所以,在多线程中使用HashMap是会抛出异常的
Hashtable:线程安全,但是Hashtable只是单纯的在添加put、删除remove、查询get方法上加synchronized,保证插入时阻塞其他线程的插入操纵。虽然安全,但因为设计简单,所以性能低下(HashMap的性能要比Hashtable更好,因为Hashtable接纳了全局同步锁来保证安全性,对性能影响较大)
ConcurrentHashMap:线程安全,ConcurrentHashMap并非锁住整个方法,而是通过原子操纵和局部加锁的方法保证了多线程的线程安全,且尽大概淘汰了性能消耗。
Segment分段锁–继承 ReentrantLock(JDK1.7重入锁)
、
CAS和synchronized(JDK1.8内置锁)
6.为什么 HashMap 接纳拉链法而不是开放地点法?
Java 给予 HashMap 的定位是一个相对通用的散列表容器,它应该在面对各种输入的时候都体现稳固。而开辟地点法相对来说容易出现数据堆积,在数据量较大时大概出现连续冲突的环境,性能不敷稳固。
我们可以举个反例,在 Java 原生的数据结构中,也存在使用开放地点法的散列表 —— 就是 ThreadlLocal。因为项目中不会大量使用 ThreadLocal 线程局部存储,所以它是一个小规模数据场景,这里使用开辟地点法是没问题的。
7.Map总结
实现类数据结构是否线程安全key是否可为null是否有序HashMap哈希表结构,jdk1.7 数组+链表,jdk1.8 数组+链表+红黑树否是否ConcurrentHashMap哈希表结构,jdk1.7 数组+链表,jdk1.8 数组+链表+红黑树是否否Hashtable哈希表结构,数组+链表是否否LinkedHashMap继承自HashMap,数组+链表+红黑树+双重链接列表否是是TreeMap红黑树否否是 参考 黑马程序员相干视频与条记、HashMap源码分析 —— 一篇文章搞定HashMap面试、HashMap为什么线程不安全、一文彻底弄懂ConcurrentHashMap,轻松应对面试官!、深入浅出ConcurrentHashMap详解、HashMap与ConcurrentHashMap工作原理、区别和总结
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
欢迎光临 IT评测·应用市场-qidao123.com (https://dis.qidao123.com/)
Powered by Discuz! X3.4