JDK8 ConcurrentHashMap学习

    此哈希表的主要设计目标是保证并发可读性(主要是通过get()方法,当然也可以通过迭代器及相关方法)的同时最小化更新时竞争。第二个目标是与HashMap相比保持或减少的空间消耗,并且支持多线程下对于一个空表的高初始化插入比率。

    此Map通常当作一个bin哈希表。每一个Key-Value映射都保存在Node节点中。大多数的节点是最基本的Node类的实例,主要包含hash,key,value,next字段。然而,Node还有不同的子类存在--TreeNode是用平衡树来组织数据的,而不是链表。TreeBin中保存TreeNode节点集合的root根。在哈希表扩容的时候,ForwardingNode放置于bin桶的头部。ReservationNodecomputeIfAbsent以及相关方法构建value值的时候被用作占位节点。ForwardingNode ReservationNode并不保存普通的用户Key,Value,以及hash值。它们在Search等方法中很容易区分出来,因为它们的hash值为负数,Key,value值都为Null。这些特殊节点很少用或者是瞬态的,所以携带一些未使用的字段的影响是不足道的)。

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

    该表在首次插入操作时才被延迟初始化,其容量是2N次方。其中每一个bin都包含Node的链表。(通常情况下,链表为空或者只有一个元素)。表的访问需要volatile/atomic读、写以及CAS操作。因为除了增加间接性没有其他的方法来实现,我们使用内在的(sun.misc.Unsafe)操作。

    我们使用Node的hash字段值的头一个bit来达到控制目的—因为寻址限制它总是可用的。拥有负值hash字段的Node节点在map方法中被特殊处理或者直接忽略。

    空bin的第一个节点的插入操作(通过put方法或其变种)直接通过CAS操作直接插入bin。这是目前为止在众多Key/hash版本中最为常用的执行put方法的方案。 其他的更新操作(插入,删除或者替换)需要锁(lock)的支持。我们不想在每一个bin中使用不同的锁对象,这样会浪费空间。所以我们使用bin列表的第一个节点(node)本身作为锁。锁操作依靠内置的synchronized监视器(monitor)支持

    使用一个列表(list)的第一个节点(Node)作为锁本身仍不足够,当一个节点(Node)被锁住时候,任何更新操作必须首先验证该节点在 锁住以后仍然是首节点,若不是,继续尝试验证。因为新的节点通常都是(append)添加到列表(list)的末尾,一旦一个节点是首节点,它就始终是首节点直到被删除或者该bin变得无效(通过resizing扩容操作)。

    每个bin独占一个锁的缺点是,在一个bin 列表中被同一个锁保护的其它节点的其它更新操作会被延迟(stall).比如使用equals或者其它mapping方法会需要一些时间。然而,按统计来说,在随机哈希码下,这不是一个常有的问题。理想来说,在负载因子为0.75的条件下,bin中Node出现的频率满足参数为0.5的泊松分布,当然,它会随着扩容(resizing)粒度不同产生变化。随机哈希下,两个线程访问不同元素的锁竞争概率大概是1/8的概率。

    实际的哈希码分布有时明显偏离统一的随机性(uniform randomness)。这包括当N>(1<<30)的时候肯定会发生碰撞。这种情况同样会发生在一些多个Key被设计拥有同样的哈希码等愚蠢的用法上。所以当一个bin中的节点(Node)个数超过了预设阀值我们使用了第二策略。(即TreeBin)。这些TreeBin使用红黑树来保证搜索效率为O(logN)在TreeBin中每一个搜索步骤至少比普通的链表慢两倍,但是考虑到N不会超过(1<<64)(超出寻址范围之前)只要Key值是Comparable(通常是String,或者Long等类型)这些保证搜索步数,锁持有次数等等维持在一个合理的常量(每次操作最坏情况为100个节点)。同样TreeNode有Next指针,提供同样的迭代遍历。

    当容量超过了负载因子(0.75)哈希表会扩容(resized)。任何线程注意到 满的bin时可能会在最初线程分配以及设置好替换数组后协助其扩容。这些线程会促进插入操作等执行而不是导致延迟。TreeBin的使用使我们得以避免扩容过程中的过度填充这一最坏情形的负面影响。扩容(resizing)通过转换(transferring) bin来执行,一个接着一个,从一个表到另一个表。然而,在此之前线程声明小的索引块转换(通过字段transferIndex)以减少竞争。一个代际标识(generation stamp)字段 sizeCtl保证扩容不会重叠。因为我们使用2的N次方扩展,每个bin中的元素必须要么维持同样的索引,要么移动2的幂次方偏移量。我们通过重用那些next字段不会改变的老旧节点(Node)的来避免创建不必要的节点(Node)。平均下来,当表容量加倍只有1/6的节点需要克隆。随后被代替的节点在不被任何线程引用时会被垃圾回收。在转换(transfer)过程中,旧表bin中只包含一个特殊的(Forwarding Node)前向节点(其hash值为MOVED),前向节点中Key值为下一个表。当遇到前向节点时,访问与更新操作使用新表重新开始。

    每一个bin转换(transfer)都需要它的bin锁,该锁能在扩容时停止那些等待获取锁的线程。因为其他的线程会加入进来帮助扩容(resizing)而不是竞争以获取锁,这样在扩容过程中平均聚集的等待会变短。转换(transfer)操作同样需要确保在旧表和新表中所有可以访问的bin是可遍历的。这部分是通过从最后一个bin遍历到第一个。在看到一个前向节点(Forwarding Node)时,遍历操作会移动到新表中而不是重复访问节点。为了确保即使是乱序移动也没有中间节点被跳过,遍历过程中在首次遇到前向节点时会创建一个栈(TableStack)以便若后续操作当前表时维持它的位置。这种sava/restore机制的需求相对较少,但是当遇到一个前向节点(Forwarding Node)时通常需要这种机制。所以遍历者使用一个简单的缓存机制来避免创造更多新的TableStack节点。

    遍历方案同样应用于多个bin区间范围内的部分遍历(通过可选的Traverser构造器)来支持分区聚集操作。同时如果曾经前向遍历到一个Null表只读的操作会停止,它提供了对当前并未实现的关闭风格(shutdown-style)的 清除操作的支持。

    延迟表初始化最小化了表操作直到首次使用,并且也避免了当首次操作putAll(),使用map参数构造哈希表或者反序列化时的扩容(resizing)。这些情形都尝试着重载初始化容量设置,但是在竞争情况下会失效。

     以上翻译自JDK1.8的ConcurrentHashMap的简介文档。最近学习Java并发,之前看ConcurrentHashMap一直没看懂,而且JDK1.8中的实现与之前版本有了很大的不同。不再是之前的基于Segment分段机制。所以尝试看看源码的说明部分。花了一下午时间只是翻译了其中一部分,确实对其实现有了一些认识了,但后面一部分实难翻译,由于水平有限,如有疏漏错误之处希望高人指出啊。

猜你喜欢

转载自blog.csdn.net/quanzhongzhao/article/details/45396877