您的位置:首页 > 编程语言 > Java开发

Java Thread&Concurrency(2): 深入理解ConcurrentSkipListMap实现原理

2014-06-12 13:35 1276 查看
背景(注释):

一个并发的类似ConcurrentNavigableMap的实现。

这个map通过实现Comparable或者提供一个Comparator来实现排列的,通过构造函数来提供。

这个实现是一个SkipLists的并发版本并且为containsKey/get/put/remove操作提供了log(n)的消耗。插入、删除、更新和读取可以在多个线程之间安全并发。

Iterators和spliterators是弱兼容的。从小到大的key排列中的视图比从大到小的要快。

所有从这些方法中返回的Map.Entry只是某时刻的一个快照。他们不提供setValue方法。(注意到你可以通过改变映射通过使用put、putIfAbsent、replace方法,依赖于你自己想要的效果)

注意不像其他大部分的集合那样,size方法不是一个常量时间运算。因为这个map的异步特性,决定了这个map的元素个数必须通过遍历得到,所以在遍历过程中改变了map那么就会得到一个不精确的结果。并且,这些以大量数据位参数的方法像putAll、equals、toArray、containsValue以及clear不会保证以原子的方式执行。比如,一个遍历操作和putAll操作并发,那么只会看到部分添加的元素。

这个类和它的视图还有迭代器实现了所有的可选的Map和Iterator接口的方法。像其他的并发结合,这个类不支持把null作为key或者value,因为null作为返回值无法区分是否缺少元素。

算法(注释):

这个类实现了一个类似于树的二维跳表,标识段通过链接包含不同数据的基本节点来展示。有两个原因说明为何使用这种方法代替类数组的结构:

数组结构会导致更复杂和消耗更大
我们为繁重的段遍历提供更廉价的算法从而实现能够使得基本链表跑得更快。下图提供一个说明:

* Head nodes Index nodes

* +-+ right +-+ +-+

* |2|---------------->| |--------------------->| |->null

* +-+ +-+ +-+

* | down | |

* v v v

* +-+ +-+ +-+ +-+ +-+ +-+

* |1|----------->| |->| |------>| |----------->| |------>| |->null

* +-+ +-+ +-+ +-+ +-+ +-+

* v | | | | |

* Nodes next v v v v v

* +-+ +-+ +-+ +-+ +-+ +-+ +-+ +-+ +-+ +-+ +-+ +-+

* | |->|A|->|B|->|C|->|D|->|E|->|F|->|G|->|H|->|I|->|J|->|K|->null

* +-+ +-+ +-+ +-+ +-+ +-+ +-+ +-+ +-+ +-+ +-+ +-+

这个基本的链表使用了一个HM链表算法的变形。
基本思想是,通过标记被删除节点的下一个(next)节点从而避免在并发情况下与插入操作的冲突。以及在遍历时维持3元组(前驱,当前节点,后继)从而探测是否需要断开那些被删除的节点。

区别于是用位标记的链表删除(AtomicMarkedReference会速度慢并且内存吃紧),节点直接是用CAS操作下一个引用。在删除时,取代使用标记的方法,我们通过拼接另一个节点从而代替标记引用(没有使用任何域),使用节点从而得到一个类似于“箱子“的标记实现,但是使用新的节点仅仅当节点需要被删除时,不会为每一次链接。这样使用更少的内存和提供了更快的速度。甚至就算标记引用的方法在JVM层面提供了更好的支持,遍历使用这个技术仍然更快,因为每一次搜索最多只需要读取更多的一个节点,相对于标记方法每读取一个节点就需要多读取一个标记域。

这个方法维持了HM算法的一个最重要的性质,通过改变一个节点的next域从而使得在它之上的任何CAS操作都会失败,但是通过这个主意是通过改变这个节点的引用执行另一个i节点,不是标记它。这样可以更加挤压内存,通过定义没有key/value的标记节点(它不需要额外的类型测试消耗)。这个标记节点是在遍历过程中极少遇到的并且会被垃圾回收得非常快(注意,这个技术不能在没有垃圾回收的系统中工作良好)。

为了使用删除标记,这个链表使用null的方法来指示删除,一种类似于延迟删除的模型。如果一个节点的value为null,那么它被认为是局部删除,就算它还是可达的。这样需要组织合适的并发控制在代替vs删除操作--一个代替操作必须失败,如果删除操作首先nulling,以及一个删除操作必须返回删除之前的值。(这个删除是可以和其他方法并发的,如果其他方法返回null说明不存在这个元素)

这里有一个当节点删除时的事件序列(b:前驱,n:当前节点,f:后继),初始化:

+------+ +------+ +------+

... | b |------>| n |----->| f | ...

+------+ +------+ +------+

1 首先通过CAS操作让n的value从non-null变为null。现在没有公共操作会认为这个映射(n)会存在了。当然,其他的不间断的插入或者删除操作还是可能改变n的指向下一个的引用的。

2 CAS操作n的next引用指向一个新的标记节点。现在没有其他节点能够被附加到n的后面。从而能够在基于CAS的链表中避免删除错误。

+------+ +------+ +------+ +------+

... | b |------>| n |----->|marker|------>| f | ...

+------+ +------+ +------+ +------+

3 CAS操作b的next引用从而忽略了n和他的标记节点。现在,没有新的遍历会遭遇n,最后n和marker会被垃圾回收。

+------+ +------+

... | b |----------------------------------->| f | ...

+------+ +------+

第一步的失败会导致简单的重试(因为另一个操作而竞争失败)。二、三两步失败是因为其他线程在遍历的过程中注意到一个节点有null值,通过协作的方式帮忙marking或者unlinking了。这种协作的方式保证了没有线程会因为执行删除的线程还没有进展而卡住等待。这种标记节点的用法稍微复杂化了协作代码,因为遍历过程必须确保一直地读取四个节点(b,n,marker,f),不是仅仅(b,n,f),当一个节点的next域指向了一个marker,它就不会改变。

跳表的模型中增加了段,所以基础遍历开始于接近目的地--经常只需要遍历很少的节点。不需要改变算法除了只要确保遍历开始于前驱(here,b),没有被删除(结构上),否则在处理这个删除之后重试。

段层级以链表的形式通过volatile的next来使用CAS操作。在段上的竞争会比如新增/删除节点会导致链接失败。就算这个发生时,段链表依然保持有序,从而可以作为划分。这个会影响性能,但是跳表本身就是依赖概率的,结果就是"p"值可能会小于虚值。这个竞争窗口会保持得足够小,从而在实际上失败是非常少的,甚至在大量竞争的情况下。

因为使用了一些重试逻辑从而使得base和index链表的重试是比较廉价的。遍历会在尝试了大多数”协作“CAS操作之后执行。这个不是非常必要,但是隐含的价值是可以帮助减少其他下游的CAS失败操作,从而好于重新开始的开销。这样恶化了坏情况,但是改进了高度竞争的情况。

区别于其他的跳表实现,段插入和删除需要一个分开的遍历过程在基本层面的动作之后,增加或者删除段节点。这样增加了一个线程的消耗,但是提升了多个线程的竞争性能,通过缩小干扰窗口,删除使得所有index节点不可达在删除操作返回后,从而也避免了垃圾回收。这个在这里是非常重要的,因为我们不能够直接把拥有key的节点直接去除,因为他们仍然可能被读取。

段使用了保持良好性能的稀疏策略:初始的k=1,p=0.5意味着四分之一的节点有段中的下标。。。。。。这个期待的总共的空间比我们的java.util.TreeMap稍微少点。

改变段的层级(这个类似树结构的高度)使用CAS操作。初始化的高度为1.当创建一个比当前层级高的段时会在头上增加一个层级。为了保持良好的性能,删除方法中会使用启发式的方法去降低层级,如果最高的层级上是空的。这样可能出现在没有层级的段上遭遇竞争。这样不会造成多大伤害,实际上相对于没有限制的提升层级,这是个更好的选择。

实现这些的代码比你想象的更详细。大多数运算会涉及定位元素(或者插入元素的位置)。这些代码不能被非常好的分块,因为子运算需要马上得到前面运算的结果,不这样做的话会增加GC的负担。(这是又一个我希望JAVA提供宏的地方)findPredecessor()操作搜仅仅索段节点,返回最底层节点的前驱。findNode()操作完成最底层节点的搜索。这种方法同样出现了一点代码的复制。

为了在线程之间参数随机的值,我们使用了JDK中的随机支持(通过"secondary seed")。

实现:

让我们来看源代码,首先是put方法:

    public V put(K key, V value) {
        if (value == null)
            throw new NullPointerException();
        return doPut(key, value, false);
    }


private V doPut(K key, V value, boolean onlyIfAbsent) {
Node<K,V> z;             // added node
if (key == null)
throw new NullPointerException();
Comparator<? super K> cmp = comparator;
outer: for (;;) {
for (Node<K,V> b = findPredecessor(key, cmp), n = b.next;;) {
if (n != null) {
Object v; int c;
Node<K,V> f = n.next;
if (n != b.next)               // inconsistent read
break;
if ((v = n.value) == null) {   // n is deleted
n.helpDelete(b, f);
break;
}
if (b.value == null || v == n) // b is deleted
break;
if ((c = cpr(cmp, key, n.key)) > 0) {
b = n;
n = f;
continue;
}
if (c == 0) {
if (onlyIfAbsent || n.casValue(v, value)) {
@SuppressWarnings("unchecked") V vv = (V)v;
return vv;
}
break; // restart if lost race to replace value
}
// else c < 0; fall through
}

z = new Node<K,V>(key, value, n);
if (!b.casNext(n, z))
break;         // restart if lost race to append to b
break outer;
}
}

int rnd = ThreadLocalRandom.nextSecondarySeed();
if ((rnd & 0x80000001) == 0) { // test highest and lowest bits
int level = 1, max;
while (((rnd >>>= 1) & 1) != 0)
++level;
Index<K,V> idx = null;
HeadIndex<K,V> h = head;
if (level <= (max = h.level)) {
for (int i = 1; i <= level; ++i)
idx = new Index<K,V>(z, idx, null);
}
else { // try to grow by one level
level = max + 1; // hold in array and later pick the one to use
@SuppressWarnings("unchecked")Index<K,V>[] idxs =
(Index<K,V>[])new Index<?,?>[level+1];
for (int i = 1; i <= level; ++i)
idxs[i] = idx = new Index<K,V>(z, idx, null);
for (;;) {
h = head;
int oldLevel = h.level;
if (level <= oldLevel) // lost race to add level
break;
HeadIndex<K,V> newh = h;
Node<K,V> oldbase = h.node;
for (int j = oldLevel+1; j <= level; ++j)
newh = new HeadIndex<K,V>(oldbase, newh, idxs[j], j);
if (casHead(h, newh)) {
h = newh;
idx = idxs[level = oldLevel];
break;
}
}
}
// find insertion points and splice in
splice: for (int insertionLevel = level;;) {
int j = h.level;
for (Index<K,V> q = h, r = q.right, t = idx;;) {
if (q == null || t == null)
break splice;
if (r != null) {
Node<K,V> n = r.node;
// compare before deletion check avoids needing recheck
int c = cpr(cmp, key, n.key);
if (n.value == null) {
if (!q.unlink(r))
break;
r = q.right;
continue;
}
if (c > 0) {
q = r;
r = r.right;
continue;
}
}

if (j == insertionLevel) {
if (!q.link(r, t))
break; // restart
if (t.node.value == null) {
findNode(key);
break splice;
}
if (--insertionLevel == 0)
break splice;
}

if (--j >= insertionLevel && j < level)
t = t.down;
q = q.down;
r = q.right;
}
}
}
return null;
}


首先put方法直接调用doPut方法,这里的关键加入了参数onlyIfAbsent,用于指明是否只在缺少的情况下添加。

由于doPut方法比较长(超过100行),我们把它分为两部分来看(详情如下):

第一部分是outer嵌套的双层循环:

首先调用findPredecessor(稍后介绍),从而通过上层的Index取得底层的节点Node,b。
取得next:n,在n不为空的情况下取得n.next:f,从而三元组:b/n/f。
接着在n!=b.next、n.value==null、b.value=null、n.value=n的情况下说明n或者b应该被删除的,从而break重试。
否则,若key要大于当前节点n的值,则重置b/n从而比较下一个节点。
否则如果key等于当前节点n的值,则在onlyIfAbsent为true时返回当前值,否则在CAS操作成功后返回当前值,否则重试。
否则构造新节点z,试着添加到b的后面,然后break outer。

走完上面的过程,那么要么在key存在的情况下完成了所有操作。要么已经在最底层Node处添加了新节点z(key),那么还需要第二部分的操作即在上层Index里面添加一系列元素。

通过ThreadLocalRandom.nextSecondarySeed()取得线程无关的随机数。

与0x80000001做&操作为0的情况下执行方法(极大概率,意思是说32位的rnd最高位和最低位不为1)。
用最低位连续1的个数来取得需要的层级数level。假如层级数小于等于当前head的level,则直接构造Index并且完成down方向上的构造,idx为添加节点最高层的Index。
否则,层级数大于当前level,那么就使用层级数level+1,这个时候需要先构造层级数组以及类似之前的Index数组,然后再构造HeadIndex从而拼接添加层的Index。这里使用CAS操作,最后成功置换head之后退出,同样保持idx为最高层的Index。
接下来就要完成最后一步,试着在每一层添加Index:注意,加入当前线程完成置换head,那么就只需要添加oldLevel个级别,否则需要添加level个级别。这个数值由level来记录。
这里采取的方式为:(q,r)为当前层的可能将要被idx插入的节点,t用于标识idx的值。
insertionLevel为开始插入的节点,当level抵达之前不会做link操作。由于过程中可能回遇到被删除节点,所以这里也使用了双重循环for以及失败重试的策略。
最后当insertionLevel为0时所有层的拼接都完成。(探测“删除”---》节点前进---》拼接当前Index---》探测删除---》拼接下一层)
最终返回null。

由于这个过程中用到了findPredecessor和findNode(该方法是private控制符,仅用于内部操作),所以我们接下来首先看这两个。
private Node<K,V> findPredecessor(Object key, Comparator<? super K> cmp) {
if (key == null)
throw new NullPointerException(); // don't postpone errors
for (;;) {
for (Index<K,V> q = head, r = q.right, d;;) {
if (r != null) {
Node<K,V> n = r.node;
K k = n.key;
if (n.value == null) {
if (!q.unlink(r))
break;           // restart
r = q.right;         // reread r
continue;
}
if (cpr(cmp, key, k) > 0) {
q = r;
r = r.right;
continue;
}
}
if ((d = q.down) == null)
return q.node;
q = d;
r = d.right;
}
}
}

实际上这里的工作方式与doPut里面做的事情类似(所以不展开详细),它只是在发现删除节点时试着删除那一层的Index并且重试或者前进,最后返回了最下层的Node。

private Node<K,V> findNode(Object key) {
if (key == null)
throw new NullPointerException(); // don't postpone errors
Comparator<? super K> cmp = comparator;
outer: for (;;) {
for (Node<K,V> b = findPredecessor(key, cmp), n = b.next;;) {
Object v; int c;
if (n == null)
break outer;
Node<K,V> f = n.next;
if (n != b.next)                // inconsistent read
break;
if ((v = n.value) == null) {    // n is deleted
n.helpDelete(b, f);
break;
}
if (b.value == null || v == n)  // b is deleted
break;
if ((c = cpr(cmp, key, n.key)) == 0)
return n;
if (c < 0)
break outer;
b = n;
n = f;
}
}
return null;
}
findNode的原理实际上也在doPut中出现过了,它会进行如下操作:

调用findPredecessor方法寻找前驱。
当发现目标节点时(c==0)直接返回节点n。
如果已经key小于了当前节点,那么就说明不存在,直接返回null。

也就是说,findPredecessor和findNode都有消除删除节点的副作用,也是被这么用的。

最后我们来看删除操作的源代码:

public V remove(Object key) {
return doRemove(key, null);
}
final V doRemove(Object key, Object value) {
if (key == null)
throw new NullPointerException();
Comparator<? super K> cmp = comparator;
outer: for (;;) {
for (Node<K,V> b = findPredecessor(key, cmp), n = b.next;;) {
Object v; int c;
if (n == null)
break outer;
Node<K,V> f = n.next;
if (n != b.next)                    // inconsistent read
break;
if ((v = n.value) == null) {        // n is deleted
n.helpDelete(b, f);
break;
}
if (b.value == null || v == n)      // b is deleted
break;
if ((c = cpr(cmp, key, n.key)) < 0)
break outer;
if (c > 0) {
b = n;
n = f;
continue;
}
if (value != null && !value.equals(v))
break outer;
if (!n.casValue(v, null))
break;
if (!n.appendMarker(f) || !b.casNext(n, f))
findNode(key);                  // retry via findNode
else {
findPredecessor(key, cmp);      // clean index
if (head.right == null)
tryReduceLevel();
}
@SuppressWarnings("unchecked") V vv = (V)v;
return vv;
}
}
return null;
}


这里的remove操作调用doRemove操作,用第三个参数value来指示是否需要值匹配。
doRemove工作原理如下:

首先根据findPredecessor来定位节点Node,然后类似其他操作一样处理删除节点,当key不存在时跳出。
当找到所需要的节点时,试着使用CAS操作将value变为null,然后试着在其后添加一个标记节点(appendMarker)以及通过CAS操作讲删除节点和标记节点一同清除(假如之一失败则通过findNode的方式协作清除)。
成功后使用findPredecessor来清除属于该节点的上层Index,并且假如此时层数过多则试着降低层数(tryReduceLevel)。
然后返回被删除的key所对应的值,若不存在则返回null。

存在的问题:
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: