集合源码通读之ConcurrentHashMap

@TOC

类前注释

  • 此类包含了HashTable中所有的方法。
  • 此哈希表是线程安全的,并且无需甚至是无法对整个表加锁。
  • 在并发场景下,调用此类的sizeisEmptycontainsValue等方法得到的结果只能反映一小段时间内的状态,因此请勿将这些方法作为程序控制的一部分。
  • 当此哈希表出现了过多的哈希碰撞时,会动态扩展其容量。但是动态扩展和rehash操作总是非常耗时的,最好能在创建哈希表时就指定一个初始容量,这样能提高效率。
  • 当键是可比较(Comparable)的时,此类可能会使用比较的顺序来打破性能瓶颈(help break ties)。
  • 和HashTable类似,但是和HashMap不同,本哈希表不允许null值作为键或值。

类定义

public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
    implements ConcurrentMap<K,V>, Serializable 

继承自AbstractMap抽象类,并实现了ConcurrentMapSerializable接口。看到类名很容易误会此类是由HashMap派生而来,实际上ConcurrentMapHashMapEnumMap等都是AbstractMap派生而来。

overview

最初设计这个哈希表的目标是为了在并发竞争下维持可读性。第二个目标是为了在内存消耗上与HashMap保持一致甚至是优于它,并支持多线程写入。

每个哈希映射都保存在一个Node节点中,但是,存在各种子类:TreeNode被安排在平衡树中,而不是列表中。 TreeBins拥有TreeNode集合的根。 在调整大小期间,ForwardingNodes放置在桶(bin)的顶部。

该表在第一次插入时被延迟初始化为2的幂。 表中的每个bin通常包含一个Node列表(大多数情况下,该列表只有零个或一个Node)。 表访问需要volatile/原子性 读,写和CASes。使用sun.misc.Unsafe实现。

我们将Node哈希字段的最高(符号)位用于控制目的,带有负哈希字段的节点在map方法中会经过特殊处理或忽略。

将第一个节点插入到一个空的桶中,使用CAS将其插入。其他更新操作则需要加锁。主要是对桶的第一个节点加锁,对这些锁的锁定支持依赖于内置的“synchronized”监视器。

关键成员

static final int MOVED     = -1; // hash for forwarding nodes
static final int TREEBIN   = -2; // hash for roots of trees
static final int RESERVED  = -3; // hash for transient reservations
static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash
  • 当哈希值是MOVED时,说明当前节点是一个转发节点;
  • 当哈希值是TREEBIN时,说明这是一个红黑树的根;
  • 当哈希值是RESERVED时,说明这是一个暂时保留的节点?还是说这个字段是暂时保留的?
  • HASH_BITS用于正常哈希值的取模操作,防止哈希值溢出。

Unsafe机制

首先获取以下变量的内存地址,然后通过unsafe包的native方法实现CAS操作,保证线程安全。

// Unsafe mechanics
private static final sun.misc.Unsafe U;
private static final long SIZECTL;
private static final long TRANSFERINDEX;
private static final long BASECOUNT;
private static final long CELLSBUSY;
private static final long CELLVALUE;
private static final long ABASE;
private static final int ASHIFT;

static {
  try {
    U = sun.misc.Unsafe.getUnsafe();
    Class<?> k = ConcurrentHashMap.class;
    SIZECTL = U.objectFieldOffset
      (k.getDeclaredField("sizeCtl"));
    TRANSFERINDEX = U.objectFieldOffset
      (k.getDeclaredField("transferIndex"));
    BASECOUNT = U.objectFieldOffset
      (k.getDeclaredField("baseCount"));
    CELLSBUSY = U.objectFieldOffset
      (k.getDeclaredField("cellsBusy"));
    Class<?> ck = CounterCell.class;
    CELLVALUE = U.objectFieldOffset
      (ck.getDeclaredField("value"));
    Class<?> ak = Node[].class;
    ABASE = U.arrayBaseOffset(ak);
    int scale = U.arrayIndexScale(ak);
    if ((scale & (scale - 1)) != 0)
      throw new Error("data type scale not a power of two");
    ASHIFT = 31 - Integer.numberOfLeadingZeros(scale);
  } catch (Exception e) {
    throw new Error(e);
  }
}

3个核心tabAt方法

ConcurrentHashMap在操作Node数据中的节点时,为了保证线程安全,定义了以下三个原子操作,使用unsafe机制实现。

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);
}

static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                    Node<K,V> c, Node<K,V> v) {
  return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}

static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
  U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}

第一个方法从主内存中读数据而不是工作内存,第二个方法CAS设置节点,第三个方法将节点的值直接更新到主内存。

put方法

public V put(K key, V value) {
  return putVal(key, value, false);
}

putVal方法中,当键值有一者为空时,就会抛出空指针异常。

if (key == null || value == null) throw new NullPointerException();

随后用key的hashCode计算哈希值:

int hash = spread(key.hashCode());
static final int spread(int h) {
  return (h ^ (h >>> 16)) & HASH_BITS;//HASH_BITS是2147483647
}

计算出哈希值后开启一个死循环:

for (Node<K,V>[] tab = table;;) {
  Node<K,V> f; int n, i, fh;
  ...
}

首先判断哈希表是否为空,如果为空,就初始化表:

if (tab == null || (n = tab.length) == 0)
  tab = initTable();

initTable方法中, 使用sizeCtl中记录的大小来初始化哈希表,如果sizeCtl小于0,就会调用yeild()方法自旋等待。

while ((tab = table) == null || tab.length == 0) {
  if ((sc = sizeCtl) < 0)
    Thread.yield(); // lost initialization race; just spin

如果sizeCtl大于等于0,就CAS设置变量sizeCtl的值,如果sizeCtl的值和sc的值相等(在CAS开始前并没有线程修改sizeCtl的值,不考虑ABA问题),就将sizeCtl设置为-1。

else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {

设置成功后执行try..finally块,判断sc是否大于0,如果大于0,说明其他线程已经设置过了sc的值,那么就将现有的sc赋值给n;如果仍旧小于0,那说明sc的值仍旧是-1,此时就将DEFAULT_CAPACITY赋值给n

try {
  if ((tab = table) == null || tab.length == 0) {
    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
    ...
private static final int DEFAULT_CAPACITY = 16;

这个变量n中存储着哈希表的初始化容量,随后创建node数组,完成初始化。

Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);

有个关键点,就是sc = n - (n >>> 2)这行代码。要解释为什么需要这行代码就需要知道sizeCtl的详细作用:

  • sizeCtl是用于控制初始化以及扩容的变量。
  • 当它为负值时,说明此哈希表正在经历初始化或者扩容。-1代表初始化, -N代表有N-1个线程正在进行扩容。
  • 当它不为负值时,如果哈希表是空的,那么sizeCtl存储着初始化容量或者是一个默认值为0。在初始化完毕后,sizeCtl中存储着下一个要调整表大小的元素计数值。

也就是说sc = n - (n >>> 2)设置了下一个要调整表大小的元素计数值(可能为8),当表中元素计数大于等于8的时候,可能需要扩容,或者小于等于8的时候,需要缩容,具体还要看resize的过程。

OK,initTable方法就看到这里,回到put方法。

如果哈希表不为空,但是哈希到的这个位置是一个空的bin,就采用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

当哈希到的bin位置仍旧是空的时候,就将新节点放入。

上述代码里,计算bin在内存中的偏移量的语句是(long)i << ASHIFT) + ABASE,其中i是取模过后的哈希值,ABASE是通过反射获取的数组第一个元素的偏移地址,ASHIFT的计算比较复杂,如下所示:

int scale = U.arrayIndexScale(ak);
if ((scale & (scale - 1)) != 0)
  throw new Error("data type scale not a power of two");
ASHIFT = 31 - Integer.numberOfLeadingZeros(scale);

首先通过arrayIndexScale方法获取数组的转换因子,也就是数组中元素的增量地址(当前数组一个元素占用的字节数)。

再调用Integer.numberOfLeadingZeros方法计算出scale的二进制形式中,前导0的个数。

ASHIFT就是31-前导0的个数。

最后通过(long)i << ASHIFT) + ABASE计算出哈希值为i的元素的内存地址。实际上通过ABASE + i * Scale也能计算出内存地址,我看不太懂源码是如何算的,兴许只是想通过位运算来提高效率。

至此,当哈希表不为空且bin桶为空的情况已经分析完毕。最后就是bin桶不为空的情况了。

如果当前bin的哈希值为MOVED,说明当前正在发生resize操作,当前线程需要协助完成resize,即调用helpTransfer方法。

else if ((fh = f.hash) == MOVED)
  tab = helpTransfer(tab, f);

先跳过helpTransfer,这个方法放到后面说。

如果当前bin的哈希值不为MOVED,就开始对节点f加锁(链表头节点),执行插入操作:

V oldVal = null;
synchronized (f) {
  if (tabAt(tab, i) == f) {
    ...
  }
}

进入synchronized代码块后,再次检查一下当前哈希值的节点是否变化,没变化才会继续执行操作。

如果哈希值大于等于0,说明当前节点是一个普通的链表头节点,可以执行链表的查询和插入操作。因此继续对链表遍历,找到key的位置,如果不存在,就插入到链表末尾。

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;
    }
  }
}

如果哈希值小于0,并且节点f是一个树节点,那么就执行红黑树的插入操作。

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;
  }
}

在上述遍历链表的过程中,会记录节点的总数,如果节点总数大于等于TREEIFY_THRESHOLD(8),会将链表转化为红黑树。

最后调用addCount(1L, binCount);方法,这个方法也放到后面讲。

至此,put方法分析完毕。

addCount方法

此方法的一个作用是增加计数,如果发现哈希表太小了(占用率大),并且没有触发resize操作,那么就调用transfer进行扩容,如果已经在resize了,就调用helpTransfer帮助执行扩容。在扩容完毕后重新检查占用率,因为调整大小操作是滞后的,需要重新判断是否已经满足了扩容需求。

这个方法中用到了CounterCell类,这个类是ConcurrentHashMap专门用来记录元素个数的。在无线程安全的集合中,我们可以直接选择使用一个成员变量来记录元素个数,但是无法保证线程安全,例如在多线程环境下调用HashMap的size()方法不能准确得知元素个数。ConcurrentHashMap为了保证线程安全设计了这个类。

以下内容能够帮助理解CounterCell的作用,参考自这里

为什么不直接for循环对当前map对象cas操作 baseCount 加 1,却要引入CounterCell数组?

因为for循环cas这种方式可以提高多线程并发效率,如果cas的是当前map对象,同一时刻还是只有一个线程能cas成功;当引入CounterCell数组,cas的是当前线程对应在数组中特定位置的元素,也就是说如果位置不冲突,n个长度的CounterCell数组是可以支持n个线程同时cas成功的。

这也是ConcurrentHashMap在JDK1.8后采用的并发思想。

ConcurrentHashMap用一个volatile修饰的CounterCell数组采用分片的方法来保存元素个数。CounterCell数组的每个元素都存储一个元素计数,当需要获取元素数量时,调用sumCount方法统计。volatile则保证了内存可见性。

final long sumCount() {
  CounterCell[] as = counterCells; CounterCell a;
  long sum = baseCount;
  if (as != null) {
    for (int i = 0; i < as.length; ++i) {
      if ((a = as[i]) != null)
        sum += a.value;
    }
  }
  return sum;
}

回到addCount方法,首先是进行并发计数。获取CounterCell数组,如果不为空就进入if代码块;如果为空就CAS设置BASECOUNT的值(BASECOUNT实际上是在未发生并发竞争的情况下元素的计数器),CAS失败了就进入if代码块。

if ((as = counterCells) != null ||
    !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {

很好理解,如果CounterCell不为空,那说明当前处于多线程环境,需要并发计数。如果CounterCell为空,但是CAS失败了,说明当前正处于多线程环境不久,也需要并发计数。

if代码块中,首先将未冲突标志位设为true。然后接连判断以下条件,任何一个条件成立,就进入fullAddCount方法。

  1. as == null,说明是前面CAS失败了;
  2. (m = as.length - 1) < 0CounterCell数组没有元素但不为空,说明早处于并发环境;
  3. (a = as[ThreadLocalRandom.getProbe() & m]) == null,在as数组里用当前线程作为参数找的槽为空(如果不为空,可以直接CAS元素a了);
  4. !(uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))),CAS向槽中填v失败。

当出现以上并发问题时,就调用fullAddCount(x, uncontended)方法,把x填入CounterCell数组中。

这里不对fullAddCount方法过度分析了,可以简单解释一下:

  • 当出现情况1时,调用这个方法能够创建CounterCell数组来支持并发计数。
  • 当出现情况2时,调用这个方法能够初始化CounterCell数组。
  • 当出现情况3时,调用这个方法为当前线程创建CounterCell计数器,并放入CounterCell数组中。
  • 当出现情况4时,调用这个方法能够对CounterCell数组扩容,因为多个线程在CASCounterCell元素时,产生了哈希碰撞,所以需要扩容。

回到addCount方法:

if (check <= 1)
  return;
s = sumCount();

check小于等于1时,直接返回。这个check形参是由put方法中的binCount传递来的,所以当check小于等于1时,说明put时未发生哈希碰撞,也就无需再往下执行扩容了。

接下来进入了重点环节,当上一个if条件未成立或者check>1时会进入以下逻辑。

当以下三个条件均成立时,才进入while循环:

  • 元素总数大于等于阈值sizeCtl(前文已经分析过sizeCtl的作用了,不再赘述)。
  • 当前哈希表不为空。
  • 表长度仍旧小于MAXIMUM_CAPACITY(1 << 30)
while (s >= (long)(sc = sizeCtl) &&
       (tab = table) != null &&
       (n = tab.length) < MAXIMUM_CAPACITY) {

满足扩容的条件,开始着手扩容。首先调用resizeStamp方法:返回标记位,以调整大小为n的表的大小。 向左移动RESIZE_STAMP_SHIFT时必须为负。

接着判断sc是否小于0:

  • 如果小于0,检测到其他线程正在初始化,就直接break。否则CAS设置SIZECTL的值,成功了就执行transfer方法。

    if (sc < 0) {
      if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
          sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
          transferIndex <= 0)
        break;
      if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
        transfer(tab, nt);
    }
    
  • 如果大于等于0,就CAS设置SIZECTL的值,成功了就执行transfer方法。

    else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                 (rs << RESIZE_STAMP_SHIFT) + 2))
      transfer(tab, null);
    

接着来看transfer方法的逻辑。

transfer方法

这是ConcurrentHashMap扩容操作的核心,这个方法相当的长而且非常复杂,因为它支持多线程并发进行扩容操作,并且没有用锁。

扩容操作分为两个步骤:

  1. 构建一个nextTable,它的容量是原来的两倍,这个操作是单线程完成的。这个单线程的保证是通过RESIZE_STAMP_SHIFT这个常量经过一次运算来保证的。
  2. 将原来table中的元素复制到nextTable中,这里允许多线程进行操作。

方法开始给stride变量赋值,先判断CPU的核心数,如果是单核,直接将表长度n赋值给stride,表示单核需要承担rehash所有桶的任务;如果是多核的,让表长度除8(为什么要除8?),然后再除核心数,让每个核心需要处理的桶一样多。如果平均分完后,每个核心分配的桶数量小于16,那么就默认为16。也就是说:

  1. 通过 计算CPU核心数和旧哈希表的长度得到每个核心需要负责迁移多少个桶,每个核心负责的桶数是平均的。
  2. 一个核心至少负责16个桶的任务。
  3. 许多博客将核心数和线程数弄混了,虽然这里一个核心对应一个线程,但是概念不能混淆,否则下面的多线程并发迁移就理解不了了。
int n = tab.length, stride;
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
  stride = MIN_TRANSFER_STRIDE; // subdivide range

紧接着判断新的哈希表是否已经创建,如果没有就自行创建:将新表的大小设置为原表的2倍,如果报了OOM,那么就将sizeCtl设置为理论上的最大值Integer.MAX_VALUE,作为下一次扩容尝试的大小(见前文对sizeCtl的解释)。

if (nextTab == null) {            // initiating
  try {
    @SuppressWarnings("unchecked")
    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];//两倍
    nextTab = nt;
  } catch (Throwable ex) {      // try to cope with OOME//报OOM了
    sizeCtl = Integer.MAX_VALUE;
    return;
  }
  nextTable = nextTab;
  transferIndex = n;
}

在继续之前,我们先来看看ConcurrenthashMap中的一个静态内部类ForwardingNode,这个类的实例仅存在于扩容过程。此内部类继承自Node类,除此之外,有一个final修饰的Node数组类型的成员变量nextTable,表示扩容后的新的哈希表,由构造器传入参数赋值。

static final class ForwardingNode<K,V> extends Node<K,V> {
  final Node<K,V>[] nextTable;
  ForwardingNode(Node<K,V>[] tab) {
    super(MOVED, null, null, null);
    this.nextTable = tab;
  }

这个类还有一个find方法,这个方法的作用是:为读线程在扩容期间提供跳转到新表的入口,如果读线程在旧表中没有找到需要的节点,就到ForwardingNodenextTable字段引用的新表中继续查找需要的节点。这个方法最外层是一个死循环,跳出这个循环的条件有:

  1. 该桶为null
  2. 该桶不为null,并成功查找到了所需节点(包括以红黑树方式查找)。

否则会不断通过ForwardingNode到新表查找节点。

首先保证传入的Object参数k非空,新哈希表也非空并且长度不为0,新哈希表索引h的位置有元素存在,否则直接返回null。前三个条件很好理解,第四个条件有这么几种情况:

  1. 旧表中的该桶索引位置本来就没有节点。
  2. 该位置扩容完成了,被迁移到了新节点中。这个情况也直接返回null,因为扩容完成了,不该由ForwadingNode来参与查找。
if (k == null || tab == null || (n = tab.length) == 0 ||
    (e = tabAt(tab, (n - 1) & h)) == null)
  return null;

上述条件均不成立的情况下,说明可以通过ForwardingNode查找节点。随后开启一个死循环,在这个循环体中,取出索引h位置的节点,比较其哈希值和key值,判断是否与传入的参数相同,如果相同就返回该节点;如果不相同,就判断这个节点的哈希值是否为负数,前面提到哈希值为负数有三种情况,分别代表该节点为正在扩容的节点(ForwardingNode)、是红黑树节点(TreeBin)以及保留字段。如果是红黑树,就在红黑树中查找返回,如果是ForwardingNode,就将该节点指向的nextTable取出,赋值给tab变量,重新循环在nextTable中查找节点。

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;
}

回到transfer方法,创建了一个ForwardingNode节点后,这里有两个标志为:

  • advance代表是否可以继续处理下一个桶,如果为true,表示当前桶已经处理完毕,可以处理下一个了。
  • finishing用于控制扩容何时结束,该标识的另一个用途:最后一个扩容线程会负责重新检查一遍数组查看是否有遗漏的桶。
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
for (int i = 0, bound = 0;;) {
  ...
}

随后开启了一个死循环用于处理一个 stride 长度的任务,其中,i代表当前处理的槽位序号,bound代表需要处理的槽位边界。循环体内通过不断地左移i的值来遍历旧表,进行rehash

在循环体中:首先开启一个while循环,使用CAS不断尝试为当前线程分配任务,直到分配成功或任务队列已经被全部分配完毕。如果当前线程已经被分配过bucket区域,那么会通过--i指向下一个待处理bucket然后退出该循环(哈希表从后往前的顺序分配)。

while (advance) {
  int nextIndex, nextBound;
  if (--i >= bound || finishing)
    advance = false;
  else if ((nextIndex = transferIndex) <= 0) {
    i = -1;
    advance = false;
  }
  else if (U.compareAndSwapInt
           (this, TRANSFERINDEX, nextIndex,
            nextBound = (nextIndex > stride ?
                         nextIndex - stride : 0))) {
    bound = nextBound;
    i = nextIndex - 1;
    advance = false;
  }
}

退出这个循环有3个机会:

  1. --i >= bound || finishing成立一个,如果--i>=bound说明当前线程已经分配过bucket区域了,如果finishing == true,说明扩容已经完成了。
  2. nextIndex = transferIndex <= 0,表示所有bucket已经被分配完毕了,因为transferIndex<=0transferIndex代表着下一个被划分的索引。
  3. CAS执行成功,说明成功为当前线程分配了bucket区域。

上面都失败了,就循环重来吧。

–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=

这里可以举个例子解释一下具体是怎么划分的,以单核CPU、首次扩容(旧表长度为8)为例。当前stride等于旧哈希表长ntransferIndex首先被赋值为n了。接下来代码进入了上面的循环体中,首先会CAS竞争修改transferIndex值,如果当前的nextIndex仍旧等于transferIndex,那么CAS成功,将transferIndex的值设置为nextBoundnextBound的计算方式如下:

nextBound = (nextIndex > stride ? nextIndex - stride : 0)

也就是说,当nextIndex小于等于stride时,将transferIndex设置为0。那么什么情况下,nextIndextransferIndex == nextIndex)会大于stride?回顾上面的代码,只有当核心数大于1时,stride会比transferIndex小。当核心数等于1时,stride只可能大于等于transferIndex。所以nextBound在这个例子中必然等于0。

CAS成功后,设置区间为[0, n-1],即[0,15],将这个区间划分给当前核心的一条扩容线程。

–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=

接下来先跳过下面这个if代码块看后面的代码。

调用tabAt方法获得旧表的i位置的节点。

  • 如果为空,就在旧表的这个位置放入forwardingNode节点指向新节点,起占位标识和转发作用,这是触发并发扩容的关键。
  • 如果不为空,并且哈希值为MOVED,说明当前节点已经是forwardingNode了,说明该位置已经被其他线程迁移过了,将 advance 设置为 true ,以便继续往下一个桶检查并进行迁移操作。
  • 否则开始对这个索引位置的链表进行rehash
else if ((f = tabAt(tab, i)) == null)
  advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
  advance = true; // already processed
else {//开始rehash

开始rehash前,先获得该链表头节点的监视器锁,并进行加锁后的检验:

synchronized (f) {
  if (tabAt(tab, i) == f) {

首先判断节点f哈希值是否大于等于0,根据不同的情况进行rehash

  • 如果是,说明是普通的链表头节点。
  • 如果不是,说明有可能是红黑树的根节点。

普通链表的迁移过程:

  • 遍历整条链表,找出 lastRun 节点。
  • 根据 lastRun 节点的哈希高位标识(0 或 1,为0说明是低位,为1说明是高位),首先将 lastRun设置为 ln (LowNode)或者 hn (HighNode)链的末尾部分节点,后续的节点使用头插法拼接。
  • 使用高位和低位两条链表进行迁移,使用头插法拼接链表。
  • 使用 volatile 的方式将 ln 链设置到新数组下标为 i 的位置上。
  • 使用 volatile 的方式将 hn 链设置到新数组下标为 i + n(n为原数组长度) 的位置上
  • 迁移完成后使用 volatile 的方式将占位对象fwd设置到旧 hash 桶上,该占位对象的用途是标识该hash桶已被处理过,以及查询请求的转发作用
  • 设置advance为 true 表示当前 hash 桶已处理完,可以继续处理下一个 hash 桶

什么是lastRun节点?图出处

img

红黑树的迁移过程省略,本人没有对红黑树深入研究。
回到前面跳过的if代码块:

if (i < 0 || i >= n || i + n >= nextn) {
  int sc;
  if (finishing) {
    nextTable = null;
    table = nextTab;
    sizeCtl = (n << 1) - (n >>> 1);
    return;
  }
  if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
    if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)//成立,说明该线程不是扩容大军里面的最后一条线程,直接return回到上层while循环
      return;
    finishing = advance = true;
    i = n; // recheck before commit
  }
}

如果扩容结束了,就做收尾工作:将 nextTable 设置为 null,表示扩容已结束,将 table 指向新数组,sizeCtl 设置为扩容阈值。

每一条线程扩容结束时,就会调用上述代码块中的CAS语句,将并发扩容线程数-1。除了修改结束标识之外,还得设置 i = n; 以便重新检查一遍数组,防止有遗漏未成功迁移的桶。

以上过程是多线程处理扩容的操作,如果在扩容期间,其他写线程访问到了ForwardingNode,那么这些线程会加入到并发扩容的行列。前面介绍put方法的时候,提到了一个helptransfer方法, 它就是来干这个的。

小结

为了彻底理解,我们在宏观上来看看并发迁移的过程:

  • 首先某个线程在往哈希表中添加元素后会调用addCount方法计数;
  • 当计数结果发现当前元素总数超过了设定的阈值sizeCtl,于是触发了resize操作,即调用了transfer方法。
  • 如果当前CPU核心数是1个的话,那么这条rehash线程只能自己默默地负责所有迁移工作。
  • 如果当前CPU核心数是多个的话,那么这条线程很有可能有帮手了!假设核心1的线程正在执行rehash,核心2的线程这时对哈希表执行了写操作(读操作不会调用helptransfer),发现当前哈希表中存在ForwardingNode,于是它就立马调用helptransfer方法帮助核心1的线程进行rehash操作。核心3和核心n遵循核心2的操作。

我觉得理解并发迁移的关键,就是分清楚单核多线程和多核多线程的关系。

借用大佬的图:

img

提问

以下问题摘录自链接

哈希桶迁移中以及迁移后如何处理get和put方法?
  • 还未被迁移到的hash桶正常进行getput操作,因为没有遇到ForwardingNode
  • 正在迁移的桶遇到了get请求, 通过ForwardingNodenextTable字段转发到新的表上查询。
  • 正在迁移的桶遇到了put请求,在rehash时,链表头节点的哈希值会被设置为MOVED,写入线程会去协助rehash
如果 lastRun 节点正好在一条全部都为高位或者全部都为低位的链表上,会不会形成死循环?

答:在数组长度为64之前会导致一直扩容,但是到了64或者以上后就会转换为红黑树,因此不会一直死循环 。

为什么 get 方法不需要加锁?

答:get操作全程不需要加锁是因为Node的成员val是用volatile修饰的 。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值