java无锁hashmap原理与实现详解

2022-10-20,,,

java多线程环境中应用hashmap,主要有以下几种选择:使用线程安全的java.util.hashtable作为替代​使用java.util.collections.synchronizedmap方法,将已有的hashmap对象包装为线程安全的。使用java.util.concurrent.concurrenthashmap类作为替代,它具有非常好的性能。
而以上几种方法在实现的具体细节上,都或多或少地用到了互斥锁。互斥锁会造成线程阻塞,降低运行效率,并有可能产生死锁、优先级翻转等一系列问题。

cas(compare and swap)是一种底层硬件提供的功能,它可以将判断并更改一个值的操作原子化。

java中的原子操作

在java.util.concurrent.atomic包中,java为我们提供了很多方便的原子类型,它们底层完全基于cas操作。

例如我们希望实现一个全局公用的计数器,那么可以:

复制代码 代码如下:
privateatomicinteger counter =newatomicinteger(3);

publicvoidaddcounter() {

    for(;;) {

        intoldvalue = counter.get();

        intnewvalue = oldvalue +1;

        if(counter.compareandset(oldvalue, newvalue))

            return;

    }

}

其中,compareandset方法会检查counter现有的值是否为oldvalue,如果是,则将其设置为新值newvalue,操作成功并返回true;否则操作失败并返回false。

当计算counter新值时,若其他线程将counter的值改变,compareandswap就会失败。此时我们只需在外面加一层循环,不断尝试这个过程,那么最终一定会成功将counter值+1。(其实atomicinteger已经为常用的+1/-1操作定义了 incrementandget与decrementandget方法,以后我们只需简单调用它即可)

除了atomicinteger外,java.util.concurrent.atomic包还提供了atomicreference和atomicreferencearray类型,它们分别代表原子性的引用和原子性的引用数组(引用的数组)。

无锁链表的实现
在实现无锁hashmap之前,让我们先来看一下比较简单的无锁链表的实现方法。

以插入操作为例:

首先我们需要找到待插入位置前面的节点a和后面的节点b。
然后新建一个节点c,并使其next指针指向节点b。(见图1)
最后使节点a的next指针指向节点c。(见图2)

但在操作中途,有可能其他线程在a与b直接也插入了一些节点(假设为d),如果我们不做任何判断,可能造成其他线程插入节点的丢失。(见图3)我们可以利用cas操作,在为节点a的next指针赋值时,判断其是否仍然指向b,如果节点a的next指针发生了变化则重试整个插入操作。大致代码如下:

复制代码 代码如下:
privatevoidlistinsert(node head, node c) {

 
    for(;;) {

 
        node a = findinsertionplace(head), b = a.next.get();

 
        c.next.set(b);

        if(a.next.compareandswap(b,c))

            return;
    }
}

(node类的next字段为atomicreference<node>类型,即指向node类型的原子性引用)

无锁链表的查找操作与普通链表没有区别。而其删除操作,则需要找到待删除节点前方的节点a和后方的节点b,利用cas操作验证并更新节点a的next指针,使其指向节点b。

无锁hashmap的难点与突破
hashmap主要有插入、删除、查找以及rehash四种基本操作。一个典型的hashmap实现,会用到一个数组,数组的每项元素为一个节点的链表。对于此链表,我们可以利用上文提到的操作方法,执行插入、删除以及查找操作,但对于rehash操作则比较困难。

如图4,在rehash过程中,一个典型的操作是遍历旧表中的每个节点,计算其在新表中的位置,然后将其移动至新表中。期间我们需要操纵3次指针:

将a的next指针指向d
将b的next指针指向c​
将c的next指针指向e
而这三次指针操作必须同时完成,才能保证移动操作的原子性。但我们不难看出,cas操作每次只能保证一个变量的值被原子性地验证并更新,无法满足同时验证并更新三个指针的需求。

于是我们不妨换一个思路,既然移动节点的操作如此困难,我们可以使所有节点始终保持有序状态,从而避免了移动操作。在典型的hashmap实现中,数组的长度始终保持为2i,而从hash值映射为数组下标的过程,只是简单地对数组长度执行取模运算(即仅保留hash二进制的后i位)。当rehash时,数组长度加倍变为2i+1,旧数组第j项链表中的每个节点,要么移动到新数组中第j项,要么移动到新数组中第j+2i项,而它们的唯一区别在于hash值第i+1位的不同(第i+1位为0则仍为第j项,否则为第j+2i项)。

如图5,我们将所有节点按照hash值的翻转位序(如1101->1011)由小到大排列。当数组大小为8时,2、18在一个组内;3、 11、27在另一个组内。每组的开始,插入一个哨兵节点,以方便后续操作。为了使哨兵节点正确排在组的最前方,我们将正常节点hash的最高位(翻转后变为最低位)置为1,而哨兵节点不设置这一位。

当数组扩容至16时(见图6),第二组分裂为一个只含3的组和一个含有11、27的组,但节点之间的相对顺序并未改变。这样在rehash时,我们就不需要移动节点了。

实现细节

由于扩容时数组的复制会占用大量的时间,这里我们采用了将整个数组分块,懒惰建立的方法。这样,当访问到某下标时,仅需判断此下标所在块是否已建立完毕(如果没有则建立)。

另外定义size为当前已使用的下标范围,其初始值为2,数组扩容时仅需将size加倍即可;定义count代表目前hashmap中包含的总节点个数(不算哨兵节点)。

初始时,数组中除第0项外,所有项都为null。第0项指向一个仅有一个哨兵节点的链表,代表整条链的起点。初始时全貌见图7,其中浅绿色代表当前未使用的下标范围,虚线箭头代表逻辑上存在,但实际未建立的块。

初始化下标操作

数组中为null的项都认为处于未初始化状态,初始化某个下标即代表建立其对应的哨兵节点。初始化是递归进行的,即若其父下标未初始化,则先初始化其父下标。(一个下标的父下标是其移除最高二进制位后得到的下标)大致代码如下:

复制代码 代码如下:
privatevoidinitializebucket(intbucketidx) {

    intparentidx = bucketidx ^ integer.highestonebit(bucketidx);

    if(getbucket(parentidx) ==null)

        initializebucket(parentidx);

    node dummy =newnode();

    dummy.hash = integer.reverse(bucketidx);

    dummy.next =newatomicreference<>();

    setbucket(bucketidx, listinsert(getbucket(parentidx), dummy));

 
}

其中getbucket即封装过的获取数组某下标内容的方法,setbucket同理。listinsert将从指定位置开始查找适合插入的位置插入给定的节点,若链表中已存在hash相同的节点则返回那个已存在的节点;否则返回新插入的节点。

插入操作

首先用hashmap的size对键的hashcode取模,得到应插入的数组下标。
然后判断该下标处是否为null,如果为null则初始化此下标。
构造一个新的节点,并插入到适当位置,注意节点中的hash值应为原hashcode经过位翻转并将最低位置1之后的值。
将节点个数计数器加1,若加1后节点过多,则仅需将size改为size*2,代表对数组扩容(rehash)。

查找操作

找出待查找节点在数组中的下标。
判断该下标处是否为null,如果为null则返回查找失败。
从相应位置进入链表,顺次寻找,直至找出待查找节点或超出本组节点范围。

删除操作

找出应删除节点在数组中的下标。
判断该下标处是否为null,如果为null则初始化此下标。
找到待删除节点,并从链表中删除。(注意由于哨兵节点的存在,任何正常元素只被其唯一的前驱节点所引用,不存在被前驱节点与数组中指针同时引用的情况,从而不会出现需要同时修改多个指针的情况)
将节点个数计数器减1。

《java无锁hashmap原理与实现详解.doc》

下载本文的Word格式文档,以方便收藏与打印。