并发编程实践一:Non-Blocking队列算法

队列是一种常用的数据结构,这片文章主要是介绍JDK中的非阻塞队列(ConcurrentLinkedQueue)的算法思想,你可以直接阅读JDK的源代码,也许你需要一些预备知识,例如unsafe类,你可以在这里(http://mishadoff.github.io/blog/java-magic-part-4-sun-dot-misc-dot-unsafe/)找到它的资料,源码的注释中也给出了算法的出处(http://www.cs.rochester.edu/u/michael/PODC96.html),但JDK中的实现还是比较晦涩难懂的,也许我的描述可以节省你的许多时间,下面就开始了。

如果你还不了解CAS,可以参考http://blog.csdn.net/hsuxu/article/details/9467651

Unsafe

JDK源码中使用了Unsafe类,因此我在这里对用到的Unsafe类中方法做一个说明,给那些不想花太多时间研究Unsafe的同学节省时间。
Unsafe类提供了很多的方法来直接对内存地址进行读和写的操作,其中大部分的方法都是很底层的,可以对应到硬件指令,并且编译器也对这些方法做了大量的优化,因此效率非常的高。Unsafe中提供了getUnsafe方法来获取一个Unsafe的实例,典型的用法如下:

class MyTrustedClass {
  private static final Unsafe unsafe = Unsafe.getUnsafe();
  ...
  private long myCountAddress = ...;
  public int getCount() { return unsafe.getByte(myCountAddress); }
}


但只能在JDK中的源码中使用,因为JDK之外的代码被认为是不被信任的,因此不能通过这种方式使用Unsafe。但你可以通过另一种方式使用它:

Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
Unsafe unsafe = (Unsafe) f.get(null);


虽然如此,但我们是不应该在我们的应用代码中使用Unsafe的,原因很简单,对内存的直接操作始终都是非常危险的,如果你不想花太多的时间去定位这种问题的话,就不要使用它。下面介绍一下Unsafe中我们会用到的一些方法:

objectFieldOffset:给出指定字段的偏移量,这个偏移量是不变的,同一个类中不同的字段不会存在相同的偏移量;
putObject:存储一个值到指定变量;
compareAndSwapObject:如果当前值是期望的值,则原子的更新该值到新值;
putOrderedObject:存储值到指定字段,但不提供可见性,如果需要具备可见性,则需要指定字段为volatile。


需要注意的是由于Unsafe的大部分方法虽然提供了操作的原子性,但却不提供可见性的保证,因此使用Unsafe操作的变量往往都需要声明为volatile。

Non-Blocking队列

ConcurrentLinkedQueue的主要思想就是使用一个head来指向队列头,使用一个tail来指向队列尾,enqueue操作将数据添加到队列尾,dequeue操作从head移除数据,为了方便算法的讲解,我使用原子变量来重新实现了ConcurrentLinkedQueue,如下:

public class MyConcurrentLinkedQueue<E> {
	/*队列头,之中指向队列首部*/
	private AtomicReference<Node<E>> head = new AtomicReference<>();
	/*队列尾,可能会出现以下情况:
	 * 1、指向队列尾;
	 * 2、指向队列中的其中一个节点;
	 * 3、指向一个已经移除的节点。*/
	private AtomicReference<Node<E>> tail = new AtomicReference<>();
	
	/*队列节点*/
	private static class Node<E> {
		/*值,为了保证可见性,指定为volatile*/
		private volatile E value;
		/*下一个节点指针*/
		private AtomicReference<Node<E>> next;

		public Node(E value, Node<E> next) {
			this.value = value;
			this.next = new AtomicReference<Node<E>>(next);
		}
	}

	public MyConcurrentLinkedQueue() {
		Node<E> node = new Node<E>(null, null);
		head.lazySet(node);
		tail.lazySet(node);
	}

	public boolean enqueue(E value) {
		final Node<E> newNode = new Node<E>(value, null);
		Node<E> curTail = tail.get();
		Node<E> tailP = curTail;
		while (true) {
			Node<E> tailNext = tailP.next.get();
			//表明tailP已经指向队列尾
			if (tailNext == null) {
				//尝试跟新队列尾,如果失败,则需要重做
				if (tailP.next.compareAndSet(null, newNode)) {
					//更新队列尾成功,尝试更新tail,允许失败(可能其它线程已经更新过tail,因此无法保证能够成功)
					tail.compareAndSet(curTail, newNode);
					return true;
				}
			} else if (tailNext == tailP) {
				//如果节点指向自身,则说明该节点已经被移除掉,需要重新更新curTail和tailP信息
				//如果curTail等于tail,则说明tail已经被移除,因此需要重head开始遍历队列,否则tail被其它线程更新,重新获取tail开始遍历
				tailP = (curTail != (curTail = tail.get())) ? curTail : head.get();
			} else {
				//这里判断怎么获取下一个节点
				//1、如果tailP不等于curTail(说明已经在处理curTail之后的节点了),并且curTail不等于tail(说明tail已经改变了),则将tailP等于curTail(重新重当前的tail开始遍历);
				//2、否则取tailP的下一个节点继续。
				//这里需要注意的是,(tailP != curTail)的判定条件可以不要,逻辑上也是正确的,但由于tailP的next不为空,因此继续向下遍历成功的可能性会较高,因此使用它也许可以带来一些效率的好处
				tailP = ((tailP != curTail) && (curTail != (curTail = tail.get()))) ? curTail : tailNext;
			}
		}
	}

	public E dequeue() {
		while (true) {
			Node<E> curHead = head.get();
			Node<E> nextNode = curHead.next.get();
			//head始终指向一个头节点,但该头节点不用来存储队列的数据,因此如果nextNode为空,则队列为空
			if (nextNode != null) {
				//队列不为空,尝试更新队列头,失败则需要重做
				if (head.compareAndSet(curHead, nextNode)) {
					//将移除的节点的next指向自身
					curHead.next.lazySet(curHead);
					return nextNode.value;
				}
			} else {
				return null;
			}
		}
	}
}


整个算法是基于Compare_And_Swap,每次更新时判断如果旧值发生了变化,则重做所有操作。我们来分析算法的特征:
 1)链表总是由节点的next指针连接在一起;
 2)节点只会在队列尾插入;
 3)节点只会从队列头删除;
 4)head总是指向队列头;
 5)tail可能指向:队列尾、中间任意一个节点、已移除节点;
 6)每个移除的节点都不会再使用,因此避免了ABA问题;
 注:使用CAS就可能会出现ABA问题,例如:当你操作一个变量V时,该变量V指向值A,处理后,判断如果V等于A,则更新值到C,但在你获取当前值A和更新V到C期间,其它线程可能做了如下操作:获取V的值A,处理,更新V的值到B,获取V的值B,处理,更新V的值到A。这样,当你判断V是否为A时,会导致相等,这样你就成功将V的值更新到C,但也许你根本就不应该这样做,因为实际上V已经发生了变化,你因该根据V的最新值中做。
 7)该算法是Non-Blocking算法,因此所有的enqueue和dequeue操作都不会导致线程进入wait状态,从而可以有效减少线程之间的上下文切换。

效率测试

我在我的机器上面用MyConcurrentLinkedQueue和JDK的ConcurrentLinkedQueue做了一个性能的对比测试,插入2000万数据,这个是测试结果(单位ms,值顺序:MyConcurrentLinkedQueue,ConcurrentLinkedQueue):
 1个线程(2000万/线程):29043,16047
 2个线程(1000万/线程):28485,17606
 4个线程(500万/线程):27704,17740
 8个线程(250万/线程):27585,15695
 16个线程(125万/线程):27873,14659
从测试数据上可以看出线程的增加并没有让插入效率发生太大的变化,但却存在一个问题,就是MyConcurrentLinkedQueue的执行效率要比ConcurrentLinkedQueue慢很多,我们可以分析一下原因,首先看ConcurrentLinkedQueue的node的实现:

private static class Node<E> {
	volatile E item;
	volatile Node<E> next;
	Node(E item) {
		UNSAFE.putObject(this, itemOffset, item);
	}
	boolean casItem(E cmp, E val) {
		return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val);
	}
	void lazySetNext(Node<E> val) {
		UNSAFE.putOrderedObject(this, nextOffset, val);
	}
	boolean casNext(Node<E> cmp, Node<E> val) {
		return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
	}
	private static final sun.misc.Unsafe UNSAFE;
	private static final long itemOffset;
	private static final long nextOffset;
	static {
		try {
			UNSAFE = sun.misc.Unsafe.getUnsafe();
			Class<?> k = Node.class;
			itemOffset = UNSAFE.objectFieldOffset
				(k.getDeclaredField("item"));
			nextOffset = UNSAFE.objectFieldOffset
				(k.getDeclaredField("next"));
		} catch (Exception e) {
			throw new Error(e);
		}
	}
}


对比MyConcurrentLinkedQueue的node来看,ConcurrentLinkedQueue中使用了几个静态变量来保存UNSAFE、itemOffset和nextOffset,而MyConcurrentLinkedQueue则每个节点都需要new一个AtomicReference,估计代价主要就是处在这里,因此,我用Unsafe重写了MyConcurrentLinkedQueue的node,如下:

private static class Node<E> {
	private volatile E value;
	private volatile Node<E> next;
	private static final Unsafe unsafe;
	private static final long nextOffset;
	private static final long valueOffset;
	static {
		try {
			Field f = Unsafe.class.getDeclaredField("theUnsafe");
			f.setAccessible(true);
			unsafe = (Unsafe) f.get(null);
			Class<?> k = Node.class;
			valueOffset = unsafe.objectFieldOffset(k.getDeclaredField("value"));
			nextOffset = unsafe.objectFieldOffset(k.getDeclaredField("next"));
		} catch (Exception e) {
			throw new Error(e);
		}
	}

	public Node(E value) {
		unsafe.putObject(this, valueOffset, value);
	}
	
	public void lazySetNext(Node<E> val) {
		unsafe.putOrderedObject(this, nextOffset, val);
	}

	public boolean casNext(Node<E> cmp, Node<E> val) {
		return unsafe.compareAndSwapObject(this, nextOffset, cmp, val);
	}
}


然后我们再重新来做一次对比测试,插入2000万数据,看测试结果(单位ms,值顺序:MyConcurrentLinkedQueue,ConcurrentLinkedQueue):
 1个线程(2000万/线程):15984,16047
 2个线程(1000万/线程):17972,17606
 4个线程(500万/线程):17674,17740
 8个线程(250万/线程):16068,15695
 16个线程(125万/线程):15293,14659
这次的测试结果就非常接近了,因此直接使用ConcurrentLinkedQueue往往要比你自己的实现效率高出许多,如果没有特殊理由,尽量使用JDK提供的并发组件吧。

存在的问题

Non-Blocking队列主要的问题就是对它的遍历,队列在遍历的过程中,可能任然在不断的改变(增加或者删除),因此遍历的结果可能存在偏差,这也导致Non-Blocking队列的size方法的结果是不精确的,这在大部分的场合可能都不是问题,但你在使用的过程中需要注意。

你可能感兴趣的:(并发编程实践一:Non-Blocking队列算法)