在疫苗:Java HashMap的死循环疫苗:Java HashMap的死循环中,咱们看到,java.util.HashMap并不能直接应用于多线程环境。对于多线程环境中应用HashMap,主要有如下几种选择:java
而以上几种方法在实现的具体细节上,都或多或少地用到了互斥锁。互斥锁会形成线程阻塞,下降运行效率,并有可能产生死锁、优先级翻转等一系列问题。数组
CAS(Compare And Swap)是一种底层硬件提供的功能,它能够将判断并更改一个值的操做原子化。关于CAS的一些应用,无锁队列的实现无锁队列的实现"一文中有很详细的介绍。安全
在java.util.concurrent.atomic包中,Java为咱们提供了不少方便的原子类型,它们底层彻底基于CAS操做。bash
例如咱们但愿实现一个全局公用的计数器,那么能够:多线程
private AtomicInteger counter = new AtomicInteger(3);
public void addCounter() {
for (;;) {
int oldValue = counter.get();
int newValue = oldValue + 1;
if (counter.compareAndSet(oldValue, newValue))
return;
}
}
复制代码
其中,compareAndSet方法会检查counter现有的值是否为oldValue,若是是,则将其设置为新值newValue,操做成功并返回true;不然操做失败并返回false。性能
当计算counter新值时,若其余线程将counter的值改变,compareAndSwap就会失败。此时咱们只需在外面加一层循环,不断尝试这个过程,那么最终必定会成功将counter值+1。(其实AtomicInteger已经为经常使用的+1/-1操做定义了incrementAndGet与decrementAndGet方法,之后咱们只需简单调用它便可)ui
除了AtomicInteger外,java.util.concurrent.atomic包还提供了AtomicReference和AtomicReferenceArray类型,它们分别表明原子性的引用和原子性的引用数组(引用的数组)。atom
无锁链表的实现
在实现无锁HashMap以前,让咱们先来看一下比较简单的无锁链表的实现方法。spa
以插入操做为例:线程
首先咱们须要找到待插入位置前面的节点A和后面的节点B。
而后新建一个节点C,并使其next指针指向节点B。(见图1)
最后使节点A的next指针指向节点C。(见图2)
但在操做中途,有可能其余线程在A与B直接也插入了一些节点(假设为D),若是咱们不作任何判断,可能形成其余线程插入节点的丢失。(见图3)咱们能够利用CAS操做,在为节点A的next指针赋值时,判断其是否仍然指向B,若是节点A的next指针发生了变化则重试整个插入操做。大体代码以下:
private void listInsert(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时,二、18在一个组内;三、十一、27在另外一个组内。每组的开始,插入一个哨兵节点,以方便后续操做。为了使哨兵节点正确排在组的最前方,咱们将正常节点Hash的最高位(翻转后变为最低位)置为1,而哨兵节点不设置这一位。
当数组扩容至16时(见图6),第二组分裂为一个只含3的组和一个含有十一、27的组,但节点之间的相对顺序并未改变。这样在ReHash时,咱们就不须要移动节点了。
因为扩容时数组的复制会占用大量的时间,这里咱们采用了将整个数组分块,懒惰创建的方法。这样,当访问到某下标时,仅需判断此下标所在块是否已创建完毕(若是没有则创建)。
另外定义size为当前已使用的下标范围,其初始值为2,数组扩容时仅需将size加倍便可;定义count表明目前HashMap中包含的总节点个数(不算哨兵节点)。
初始时,数组中除第0项外,全部项都为null。第0项指向一个仅有一个哨兵节点的链表,表明整条链的起点。初始时全貌见图7,其中浅绿色表明当前未使用的下标范围,虚线箭头表明逻辑上存在,但实际未创建的块。
数组中为null的项都认为处于未初始化状态,初始化某个下标即表明创建其对应的哨兵节点。初始化是递归进行的,即若其父下标未初始化,则先初始化其父下标。(一个下标的父下标是其移除最高二进制位后获得的下标)大体代码以下:
private void initializeBucket(int bucketIdx) {
int parentIdx = bucketIdx ^ Integer.highestOneBit(bucketIdx);
if (getBucket(parentIdx) == null)
initializeBucket(parentIdx);
Node dummy = new Node();
dummy.hash = Integer.reverse(bucketIdx);
dummy.next = new AtomicReference<>();
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。