ConcurrentHashMap

/*
 * ConcurrentHashMap
 * 
 * 在日常开发中使用的HashMap是线程不安全的,而线程安全类Hashtable只是简单的在方法上加锁实
 * 现线程安全,效率低下,所以在线程安全的环境下通常会使用ConcurrentHashMap
 * 
 * 问题:

- ConcurrentHashMap是怎么做到线程安全的?

- - get方法如何线程安全地获取key、value?
  - put方法如何线程安全地设置key、value?
  - size方法如果线程安全地获取容器容量?
  - 底层数据结构扩容时如果保证线程安全?
  - 初始化数据结构时如果保证线程安全?

- ConcurrentHashMap并发效率是如何提高的?

- 和加锁相比较,为什么它比Hashtable效率高?

可以通过减少锁竞争来优化并发性能,而ConcurrentHashMap则在JDK8-使用了锁分段(减小锁范围)、
JDK8开始大量使用CAS(乐观锁,减小上下文切换开销,无阻塞)和少量的同步代码块等技术

### 节点类型

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结点。Node只有一个next指针,是一个单链表,提供find方法实现链表查询
- 当出现hash冲突时,Node结点会首先以链表的形式链接到table上,当结点数量超过一定数目时,链表
会转化为红黑树。

TreeNode节点

 static final class TreeNode<K,V> extends Node<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;

- TreeNode就是红黑树的结点,TreeNode不会直接链接到table[i]——桶上面,而是由TreeBin链接,
TreeBin会指向红黑树的根结点。

TreeBin节点

static final class TreeBin<K,V> extends Node<K,V> {
        TreeNode<K,V> root;
        volatile TreeNode<K,V> first;
        volatile Thread waiter;
        volatile int lockState;
        // values for lockState
        static final int WRITER = 1; // set while holding write lock
        static final int WAITER = 2; // set when waiting for write lock
        static final int READER = 4; // increment value for setting read lock

- TreeBin会直接链接到table[i]——桶上面,该结点提供了一系列红黑树相关的操作,以及加锁、解锁操作。

另外TreeBin提供了一系列的操作
- TreeBin(TreeNode<K,V> b),将以b为头结点的链表转换为红黑树
- lockRoot(),对红黑树的根结点加写锁
- unlockRoot(),释放写锁
- find(int h, Object k),从根结点开始遍历查找,找到相等的结点就返回它,没找到就返回null,
当存在写锁时,以链表方式进行查找,不阻塞读锁

ForwardingNode

static final class ForwardingNode<K,V> extends Node<K,V> {
        final Node<K,V>[] nextTable;
        
- ForwardingNode在table扩容时使用,内部记录了扩容后的table,即nextTable
- 当table需要扩容时,依次遍历table中的每个槽,如果不为null,把所有元素根据hash值放入扩容后的
nextTable中,在原table的槽内放置一个ForwardingNode
- ForwardingNode是一种临时结点,在扩容进行中才会出现,hash值固定为-1,且不存储实际数据
- 如果旧table数组的一个hash桶中全部的结点都迁移到了新table中,则在这个桶中放置一个ForwardingNode
- 读操作碰到ForwardingNode时,将操作转发到扩容后的新table数组上去执行;写操作碰见它时,则尝试帮助
扩容,扩容是支持多线程一起扩容。

ReservationNode保留结点

 static final class ReservationNode<K,V> extends Node<K,V> {
        ReservationNode() {
            super(RESERVED, null, null);  //-3
        }

        Node<K,V> find(int h, Object k) {
            return null;
        }
    }
    
- 在并发场景下、在从Key不存在到插入的时间间隔内,为了防止哈希槽被其他线程抢占
,当前线程会使用一个reservationNode节点放到槽中并加锁,从而保证线程安全
- hash值固定为-3,不保存实际数据。只在computeIfAbsent和compute这两个函数式
API中充当占位符加锁使用


构造器
 public ConcurrentHashMap() {
    }

public ConcurrentHashMap(int initialCapacity) {  初始化容积--数组长度
        this(initialCapacity, LOAD_FACTOR, 1);
    }
    
 public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) {
        if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
            throw new IllegalArgumentException();
        if (initialCapacity < concurrencyLevel)   // Use at least as many bins
            initialCapacity = concurrencyLevel;   // as estimated threads
        long size = (long)(1.0 + (long)initialCapacity / loadFactor);
        int cap = (size >= (long)MAXIMUM_CAPACITY) ?
            MAXIMUM_CAPACITY : tableSizeFor((int)size);
        this.sizeCtl = cap;
    }
  
初始化ConcurrentHashMap的时候这个`Node[]`数组是还未初始化的,会等到第一次put方法调用时才初始化
  
  //不允许空的key和value,否则异常
  
   final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        ... ...
        //判断Node数组为空
            if (tab == null || (n = tab.length) == 0)
                //初始化Node数组
                tab = initTable();
                
   此时是会有并发问题的,如果多个线程同时调用initTable初始化Node数组怎么办
   
   private final Node<K,V>[] initTable() {
      Node<K,V>[] tab; int sc;
      //每次循环都获取最新的Node数组引用
      while ((tab = table) == null || tab.length == 0) {
        //sizeCtl是一个标记位,若为-1也就是小于0,代表有线程在进行初始化工作了
        if ((sc = sizeCtl) < 0)
          //让出CPU时间片
          Thread.yield(); // lost initialization race; just spin
        //CAS操作,将本实例的sizeCtl变量设置为-1
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
          //如果CAS操作成功了,代表本线程将负责初始化工作
          try {
            //再检查一遍数组是否为空
            if ((tab = table) == null || tab.length == 0) {
              //在初始化Map时,sizeCtl代表数组大小,默认16
              //所以此时n默认为16
              int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
              @SuppressWarnings("unchecked")
              //Node数组
              Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
              //将其赋值给table变量
              table = tab = nt;
              //通过位运算,n减去n二进制右移2位,相当于乘以0.75
              //例如16经过运算为12,与乘0.75一样,只不过位运算更快
              sc = n - (n >>> 2);
            }
          } finally {
            //将计算后的sc(12)直接赋值给sizeCtl,表示达到12长度就扩容
            //由于这里只会有一个线程在执行,直接赋值即可,没有线程安全问题
            //只需要保证可见性
            sizeCtl = sc;
          }
          break;
        }
      }
      return tab
  }
  
  table变量使用了volatile来保证每次获取到的都是最新写入的值:
        transient volatile Node<K,V>[] table;

总结:
    就算有多个线程同时进行put操作,在初始化数组时使用了乐观锁CAS操作来决定
到底是哪个线程有资格进行初始化,其他线程均只能等待。

用到的并发技巧:
- volatile变量(sizeCtl):它是一个标记位,用来告诉其他线程这个坑位有没有
人在,其线程间的可见性由volatile保证。
- CAS操作:CAS操作保证了设置sizeCtl标记位的原子性,保证了只有一个线程能设
置成功

### put操作的线程安全

final V putVal(K key, V value, boolean onlyIfAbsent) {
  if (key == null || value == null) 
      throw new NullPointerException();
  //对key的hashCode进行散列
  int hash = spread(key.hashCode());
  int binCount = 0;
  //一个无限循环,直到put操作完成后退出循环
  for (Node<K,V>[] tab = table;;) {
    Node<K,V> f; int n, i, fh;
    //当Node数组为空时进行初始化
    if (tab == null || (n = tab.length) == 0)
      tab = initTable();
    //Unsafe类volatile的方式取出hashCode散列后通过与运算得出的Node数组下标值对应的Node对象
    //此时的Node对象若为空,则代表还未有线程对此Node进行插入操作
    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;                   // no lock when adding to empty bin
    }
    //查看是否在扩容,先不看,扩容再介绍
    else if ((fh = f.hash) == MOVED)
      //帮助扩容
      tab = helpTransfer(tab, f);
    else {
      V oldVal = null;
      //对Node对象进行加锁
      synchronized (f) {
        //二次确认此Node对象还是原来的那一个
        if (tabAt(tab, i) == f) {
          if (fh >= 0) {
            binCount = 1;
            //无限循环,直到完成put
            for (Node<K,V> e = f;; ++binCount) {
              K ek;
              //和HashMap一样,先比较hash,再比较equals
              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) {
                //和链表头Node节点不冲突,就将其初始化为新Node作为上一个Node节点的next
                //形成链表结构
                pred.next = new Node<K,V>(hash, key,value, null);
                break;
              }
            }
          }
          ...
}
值得关注的是tabAt(tab, i)方法,其使用Unsafe类volatile的操作volatile式地查看值,
保证每次获取到的值都是最新的:

static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
  return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}

虽然上面的table变量加了volatile,但也只能保证其引用的可见性,并不能确保其数组中的
对象是否是最新的,所以需要Unsafe类volatile式地拿到最新的Node。

总结:
由于其减小了锁的粒度,若Hash完美不冲突的情况下,可同时支持n个线程同时put操作,n为
Node数组大小,在默认大小16下,可以支持最大同时16个线程无竞争同时操作且线程安全。当
hash冲突严重时,Node链表越来越长,将导致严重的锁竞争,此时会进行扩容,将Node进行再
散列,

总结一下用到的并发技巧:
- 减小锁粒度:将Node链表的头节点作为锁,若在默认大小16情况下,将有16把锁,大大减小
了锁竞争(上下文切换),就像开头所说,将串行的部分最大化缩小,在理想情况下线程的put
操作都为并行操作。同时直接锁住头节点,保证了线程安全
- Unsafe的getObjectVolatile方法:此方法确保获取到的值为最新。

### 扩容操作的线程安全

在扩容时,ConcurrentHashMap支持多线程并发扩容,在扩容过程中同时支持get查数据,若有
线程put数据,还会帮助一起扩容,这种无阻塞算法将并行最大化

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    ......
    //锁住这个Node
      synchronized (f) {
        //确认Node是原先的Node
        if (tabAt(tab, i) == f) {
    ......
}
在put值的时候,首先会计算hash值,再散列到指定的Node数组下标中
//根据key的hashCode再散列
int hash = spread(key.hashCode());
//使用(n - 1) & hash 运算,定位Node数组中下标值,实际上就是执行 hash % n
(f = tabAt(tab, i = (n - 1) & hash);

其中n为Node数组长度,这里假设为16。
假设有一个key进来,它的散列之后的hash=9,那么它的下标值是多少呢?
- (16 - 1)和 9 进行与运算 -> 0000 1111 和 0000 1001 结果还是 0000 1001 = 9

假设Node数组需要扩容,我们知道,扩容是将数组长度增加两倍,也就是32,那么下标值会是多少呢?
- (32 - 1)和 9 进行与运算 -> 0001 1111 和 0000 1001 结果还是9

此时,我们把散列之后的hash换成20,那么会有怎样的变化呢?
- (16 - 1)和 20 进行与运算 -> 0000 1111 和 0001 0100 结果是 0000 0100 = 4
- (32 - 1)和 20 进行与运算 -> 0001 1111 和 0001 0100 结果是 0001 0100 = 20
    
此时应该可以发现,如果hash在高X位为1,(X为数组长度的二进制-1的最高位),则扩容时是需要变
换在Node数组中的索引值的,不然就hash不到,丢失数据,所以这里在迁移的时候将高X位为1的Node
分类为hn,将高X位为0的Node分类为ln。

然后将其CAS操作放入新的Node数组中
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);

其中,低位链表放入原下标处,而高位链表则需要加上原Node数组长度,上面已经举例说明了,这样就
可以保证高位Node在迁移到新Node数组中依然可以使用hash算法散列到对应下标的数组中去了。

最后将原Node数组中对应下标Node对象设置为fwd标记Node,表示该节点迁移完成,到这里,一个节点
的迁移就完成了,将进行下一个节点的迁移,也就是i-1=14下标的Node节点。


### 扩容时的get操作

假设Node下标为16的Node节点正在迁移,突然有一个线程进来调用get方法,正好key又散列到下标为
16的节点,此时怎么办?

public V get(Object key) {
  Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
  int h = spread(key.hashCode());
  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;
    }
    //假如Node节点的hash值小于0,则有可能是fwd节点
    else if (eh < 0)
      //调用节点对象的find方法查找值
      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;
}
重点看有注释的那两行,在get操作的源码中,会判断Node中的hash是否小于0,是否还记得我们的占位Node,其hash为MOVED,为常量值-1,所以此时判断线程正在迁移,委托给fwd占位Node去查找值:

//内部类 ForwardingNode中
Node<K,V> find(int h, Object k) {
  // loop to avoid arbitrarily deep recursion on forwarding nodes
  // 这里的查找,是去新Node数组中查找的
  // 下面的查找过程与HashMap查找无异,不多赘述
  outer: for (Node<K,V>[] tab = nextTable;;) {
    Node<K,V> e; int n;
    if (k == null || tab == null || (n = tab.length) == 0 ||
        (e = tabAt(tab, (n - 1) & h)) == null)
      return null;
    for (;;) {
      int eh; K ek;
      if ((eh = e.hash) == h &&
          ((ek = e.key) == k || (ek != null && k.equals(ek))))
        return e;
      if (eh < 0) {
        if (e instanceof ForwardingNode) {
          tab = ((ForwardingNode<K,V>)e).nextTable;
          continue outer;
        }
        else
          return e.find(h, k);
      }
      if ((e = e.next) == null)
        return null;
    }
  }
}
```

到这里应该可以恍然大悟了,之所以占位Node需要保存新Node数组的引用也是因为这个,它可以支持在迁移
的过程中照样不阻塞地查找值,可谓是精妙绝伦的设计。

### 多线程协助扩容

在put操作时,假设正在迁移,正好有一个线程进来,想要put值到迁移的Node上,怎么办?
final V putVal(K key, V value, boolean onlyIfAbsent) {
      ......
    //若此时发现了占位Node,证明此时HashMap正在迁移
    else if ((fh = f.hash) == MOVED)
      //进行协助迁移
      tab = helpTransfer(tab, f);
     ...
}

final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
    ... ...
      //sizeCtl加一,标示多一个线程进来协助扩容
      if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
        //扩容
        transfer(tab, nextTab);
        break;
      }
    }
    ... ...
    return nextTab;
  }
  return table;
}

只要数组长度足够长,就可以同时容纳足够多的线程来一起扩容,最大化并行任务,提高性能。


* ### 在什么情况下会进行扩容操作
* - 在put值时,发现Node为占位Node(fwd)时,会协助扩容。
* - 在新增节点后,检测到链表长度大于8时。

final V putVal(K key, V value, boolean onlyIfAbsent) {
      ...
    if (binCount != 0) {
       //TREEIFY_THRESHOLD=8,当链表长度大于8时
       if (binCount >= TREEIFY_THRESHOLD)
          //调用treeifyBin方法
          treeifyBin(tab, i);
       if (oldVal != null)
          return oldVal;
       break;
    }
      ...
}

treeifyBin方法会将链表转换为红黑树,增加查找效率,但在这之前会检查数组长度,若小于
64,则会优先做扩容操作

private final void treeifyBin(Node<K,V>[] tab, int index) {
      Node<K,V> b; int n, sc;
      if (tab != null) {
            //MIN_TREEIFY_CAPACITY=64,若数组长度小于64,则先扩容
            if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
              //扩容为原始数组的1倍
              tryPresize(n << 1);
            else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
                  synchronized (b) {
                    //...转换为红黑树的操作
                  }
            }
      }
}

在每次新增节点之后,都会调用addCount方法,检测Node数组大小是否达到阈值:

final V putVal(K key, V value, boolean onlyIfAbsent) {
      ...
        //此方法统计容器元素数量
        addCount(1L, binCount);
      return null;
}


private final void addCount(long x, int check) {
}

总结
ConcurrentHashMap运用各类CAS操作{Unsafe},将扩容操作的并发性能实现最大化,在扩容过程中,就算有
线程调用get查询方法,也可以安全的查询数据,若有线程进行put操作,还会协助扩容,利用
sizeCtl标记位和各种volatile变量进行CAS操作达到多线程之间的通信、协助,在迁移过程中只锁
一个Node节点,即保证了线程安全,又提高了并发性能。

### get操作的线程安全

对于get操作,其实没有线程安全的问题,只有可见性的问题,只需要确保get的数据是线程之间可见的即可
在get操作中除了增加了迁移的判断以外,基本与HashMap的get操作无异
使用了tabAt方法Unsafe类volatile的方式去获取Node数组中的Node,保证获得到的Node是最新的。
 */
 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值