高并发研究室05-ConcurrentHashMap

本章主要讲解一些HashMap与ConcurrentHashMap的原理与对比。

HashMap线程不安全

put源码如下: 这个modCount 是类变量,也就是在jvm内存结构中存放在方法区的,方法区是共享的,那么也就是modCount这个变量不是线程安全。而且源码中++modCount不是原子操作。这个其实有三个步骤先增加,后保存,最后读取。这三个操作不是加锁同步的。因此HashMap的put()是线程不安全的。

jdk1.8中有多个线程同时使用put来添加元素,而且恰好两个put的数据hash值一样,并且该位置数据为null。此时有一个线程的数据就会被覆盖,丢失。

HashMap 是线程不安全的,在多线程使用场景中如果需要使用 Map,应该尽量避免使用线程不安全的 HashMap。同时,虽然 Collections.synchronizedMap(new HashMap()) 是线程安全的,但是效率低下,因为内部用了很多的 synchronized,多个线程不能同时操作。推荐使用线程安全同时性能比较好的 ConcurrentHashMap。

ConcurrentHashMap

Java7过时了就直接讲java8的ConcurrentHashMap

在讲ConcurrentHashMap的时候,我们先了解一些HashMap

HashMap

图中的节点有三种类型。

  • 第一种是最简单的,空着的位置代表当前还没有元素来填充。
  • 第二种就是和 HashMap 非常类似的拉链法结构,在每一个槽中会首先填入第一个节点,但是后续如果计算出相同的 Hash 值,就用链表的形式往后进行延伸。
  • 第三种结构就是红黑树结构,这是 Java 7 的HashMap 中所没有的结构,在此之前我们可能也很少接触这样的数据结构。

当第二种情况的链表长度大于某一个阈值(默认为 8),且同时满足一定的容量要求的时候,HashMap 便会把这个链表从链表的形式转化为红黑树的形式,目的是进一步提高它的查找性能。所以,Java 8 的一个重要变化就是引入了红黑树的设计,由于红黑树并不是一种常见的数据结构,所以我们在此简要介绍一下红黑树的特点。

红黑树

红黑树是每个节点都带有颜色属性的二叉查找树,颜色为红色或黑色,红黑树的本质是对二叉查找树 BST 的一种平衡策略,我们可以理解为是一种平衡二叉查找树,查找效率高,会自动平衡,防止极端不平衡从而影响查找效率的情况发生。

由于自平衡的特点,即左右子树高度几乎一致,所以其查找性能近似于二分查找,时间复杂度是 O(log(n)) 级别;反观链表,它的时间复杂度就不一样了,如果发生了最坏的情况,可能需要遍历整个链表才能找到目标元素,时间复杂度为 O(n),远远大于红黑树的 O(log(n)),尤其是在节点越来越多的情况下,O(log(n)) 体现出的优势会更加明显。

红黑树的一些其他特点:

  • 每个节点要么是红色,要么是黑色,但根节点永远是黑色的。
  • 红色节点不能连续,也就是说,红色节点的子和父都不能是红色的。
  • 从任一节点到其每个叶子节点的路径都包含相同数量的黑色节点。

正是由于这些规则和要求的限制,红黑树保证了较高的查找效率,所以现在就可以理解为什么 Java 8 的 ConcurrentHashMap 要引入红黑树了。好处就是避免在极端的情况下冲突链表变得很长,在查询的时候,效率会非常慢。而红黑树具有自平衡的特点,所以,即便是极端情况下,也可以保证查询效率在 O(log(n))。

ConcurrentHashMap

JDK1.8的实现已经摒弃了Segment(Java7的设计,已过时)的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap,虽然在JDK1.8中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本. 讲解源码前需要了解的几个参数

  • table 所有的数据都存在在表中,table的容量是可以扩容
  • TreeBin 用于包装红黑树结构的节点类型
  • ForwardingNode 扩容时存放的节点类型,并发扩容的实现关键之一
  • Node 普通节点类型
  • nextTable 扩容时存放数据的变量,扩容完后才能置为NULL
  • sizeCtl 以volatile修饰的sizeCtl用于数组初始化与扩容控制,它有以下几个值:
当前未初始化:
 = 0  //未指定初始容量
 > 0  //由指定的初始容量计算而来,再找最近的2的幂次方。
  //比如传入6,计算公式为6+6/2+1=10,最近的2的幂次方为16,所以sizeCtl就为16。
初始化中:
 = -1 //table正在初始化  = -N //N是int类型,分为两部分,高15位是指定容量标识,低16位表示  //并行扩容线程数+1,具体在resizeStamp函数介绍。 初始化完成:  =table.length * 0.75 //扩容阈值调为table容量大小的0.75倍 
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;
 // ... } 

每个 Node 里面是 key-value 的形式,并且把 value 用 volatile 修饰,以便保证可见性,同时内部还有一个指向下一个节点的 next 指针,方便产生链表结构。

put 方法源码分析
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) {
        throw new NullPointerException();
    }
    //计算 hash 值
 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 = initTable();  }  // 找该 hash 值对应的数组下标  else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {  //如果该位置是空的,就用 CAS 的方式放入新值  if (casTabAt(tab, i, null,  new Node<K, V>(hash, key, value, null))) {  break;  }  }  //hash值等于 MOVED 代表在扩容  else if ((fh = f.hash) == MOVED) {  tab = helpTransfer(tab, f);  }  //槽点上是有值的情况  else {  V oldVal = null;  //用 synchronized 锁住当前槽点,保证并发安全  synchronized (f) {  if (tabAt(tab, i) == f) {  //如果是链表的形式  if (fh >= 0) {  binCount = 1;  //遍历链表  for (Node<K, V> e = f; ; ++binCount) {  K ek;  //如果发现该 key 已存在,就判断是否需要进行覆盖,然后返回  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;  //到了链表的尾部也没有发现该 key,说明之前不存在,就把新值添加到链表的最后  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;  binCount = 2;  //调用 putTreeVal 方法往红黑树里增加数据  if ((p = ((TreeBin<K, V>) f).putTreeVal(hash, key,  value)) != null) {  oldVal = p.val;  if (!onlyIfAbsent) {  p.val = value;  }  }  }  }  }  if (binCount != 0) {  //检查是否满足条件并把链表转换为红黑树的形式,默认的 TREEIFY_THRESHOLD 阈值是 8  if (binCount >= TREEIFY_THRESHOLD) {  treeifyBin(tab, i);  }  //putVal 的返回是添加前的旧值,所以返回 oldVal  if (oldVal != null) {  return oldVal;  }  break;  }  }  }  addCount(1L, binCount);  return null; } 

通过以上的源码分析,我们对于 putVal 方法有了详细的认识,可以看出,方法中会逐步根据当前槽点是未初始化、空、扩容、链表、红黑树等不同情况做出不同的处理。

添加是发现槽点没有值就使用CAS来存放槽点新值。如果槽点有值,就使用synchronized来锁住槽点

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 对应的 value 不存在,直接返回 null
 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;  }  //如果头结点 hash 值小于 0,说明是红黑树或者正在扩容,就用对应的 find 方法来查找  else if (eh < 0)  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; } 
  1. 计算 Hash 值,并由此值找到对应的槽点;
  2. 如果数组是空的或者该位置为 null,那么直接返回 null 就可以了;
  3. 如果该位置处的节点刚好就是我们需要的,直接返回该节点的值;
  4. 如果该位置节点是红黑树或者正在扩容,就用 find 方法继续查找;
  5. 否则那就是链表,就进行遍历链表查找。
为啥hashmap中槽点(桶)超过8个转为红黑树

在源码中已经有了为啥槽点到8会转为红黑树 链表查询的复杂度是O(n),红黑树是O(log(n))。n越变大时,链表的查询效率越慢。但是TreeNode的存储是普通的Node的两倍空间。所以需要一个阈值来转换。因为只有key键的hash值相同是,数据才会存到链表中。如源码注释,链表长度的为8的可能性是千万分之一

HashTable与ConcurrentHashMap

众所周知,HashTable是线程安全的,那为啥还要用ConcurrentHashMap

出现的版本

HashTable是1.0出来的,ConcurrentHashMap是在1.5出来的。

实现线程方式不同

Hashtable 是通过synchronized 锁住方法来实现的,几乎每个方法都被 synchronized 关键字所修饰了,这也就保证了线程安全。

Collections.SynchronizedMap(new HashMap()) 的原理和 Hashtable 类似。也是利用 synchronized 实现的。

ConcurrentHashMap是通过CAS + synchronized + Node 节点的方式。也就是所为的分段锁。

性能不同

正因为实现线程同步的方式不同,导致性能不同。当线程数量增加的时候,Hashtable 的性能会急剧下降,因为每一次修改都需要锁住整个对象,而其他线程在此期间是不能操作的。ConcurrentHashMap 中,就算上锁也仅仅会对一部分上锁而不是全部都上锁,所以多线程中的吞吐量通常都会大于单线程的情况,也就是说,在并发效率上,ConcurrentHashMap 比 Hashtable 提高了很多。

迭代时修改的不同

HashTable在迭代时不能修改,会抛出ConcurrentModificationException。ConcurrentHashMap可以修改

请大家关注我的公众号,一起讨论后端技术

本文使用 mdnice 排版

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值