《Java并发编程实践》五(3):原子变量和非阻塞同步

java并发库(java.util.concurrent)提供了很多(相比锁)性能更优越的同步设施,比如ConcurrentLinkedQueue。本章的主题,是研究此类并发装置的性能秘密:原子变量和非阻塞同步。

锁的性能劣势

对现代JVM来说,在锁未发生竞争的情况下,JVM执行“加锁、释放锁”操作是非常快的;但是一旦发生竞争,就需要执行系统调用来挂起竞争失败的线程,等将来锁释放时再唤醒它们。

挂起和唤醒线程的性能损耗是不能忽视的,对那些需要加锁的高频操作(比如集合读写)来说,锁竞争导致的线程调度消耗的CPU,和业务逻辑操作消耗的CPU之间的比例,有可能会非常之高。换句话说,锁竞争导致CPU一直在执行线程的调入&调出,花在执行业务代码上的时间反而较少。

Volatile变量是一种轻量级的同步机制,不会阻塞线程;但Volatile不能保证变量操作的原子性,所以使用场景非常有限。

乐观锁

排他锁是一种“悲观“锁,它总是假定“坏”的事情(竞争)会发生,在未获得锁之前,不能执行任何操作。对于细粒度的操作,还有一种偏“乐观”的方式,它假定不会发生竞争,先尝试执行操作再说;但是一旦发生冲突,需要能检测到冲突。

为了实现这种“乐观”的同步机制,现代CPU都提供一种原子性的“read-modify-write”指令,最典型的是CAS(Compare and Swap)指令。JVM很早就使用了CAS指令,但一直到Java 5, java代码才能使用它。

CAS指令

CAS指令有三个参数:内存地址、期望的原值、新值,它仅当内存地址存储的值等于“期望的原值”,才将它修改为“新值”,无论成功或失败,该指令返回“指令执行之前,内存地址存储的值”。

CAS的语义,可以用下面的代码来模拟:

@ThreadSafe
public class SimulatedCAS {

	@GuardedBy("this") private int value;
	
	public synchronized int get() { return value; }
	
	//compareAndSwap模拟CAS指令
	public synchronized int compareAndSwap(int expectedValue,int newValue) {
		int oldValue = value;
		if (oldValue == expectedValue)
			value = newValue;
		return oldValue;
	}
	
	//线程用cas执行同步操作,在失败时能得到通知,不会阻塞
	public synchronized boolean compareAndSet(int expectedValue,int newValue) {
		return (expectedValue == compareAndSwap(expectedValue, newValue));
	}
}

原子变量

原子变量是细粒度、轻量级的同步装置,是CAS指令最直接的应用。可以认为原子变量是更好的“Volatile”变量,它保留了Volatile变量的内存可见性保证,同时支持原子的“read-modify-write”操作。

Java的原子变量类型可分为四组:

  • 标量:AtomicInteger, AtomicLong, AtomicBoolean, AtomicReference,LongAdder,LongAccumulator;
  • field upaters:原子性地更新一个对象字段的工具,比如AtomicIntegerFieldUpdater;
  • 数组:支持对数组元素执行原子操作,比如AtomicIntegerArray;
  • 组合变量:AtomicStampedReference和AtomicMarkableReference,特殊用途。

Atomic变量最常用的操作就是compareAndSet,它内部调用了平台的CAS操作。Atomic类型都是对java基本类型的包裹,因为它只能为非常细粒度的操作提供原子性;它无法为多个状态字段的操作提供原子性保证。AtomicStampedReference这种所谓的组合原子变量,是通过内嵌的不可变对象来组合多个字段,本质上就是个AtomicReference。

示例:CasNumberRange

现在用Atomic变量来实现一个线程安全的range结构:

public class CasNumberRange {
	@Immutable
	private static class IntPair {
		final int lower; // Invariant: lower <= upper
		final int upper;
		...
	}
	private final AtomicReference<IntPair> values = new AtomicReference<IntPair>(new IntPair(0, 0));
	
	public int getLower() { return values.get().lower; }
	
	public int getUpper() { return values.get().upper; }
	
	public void setLower(int i) {
		while (true) {
			IntPair oldv = values.get();
			IntPair newv = new IntPair(i, oldv.upper);
			if (values.compareAndSet(oldv, newv))
				return;
		}
	}
}

如果把lower和upper字段替换成Atomic变量是无法凑效的,因为它们并不互相独立。所以将它们封装成一个不可变对象IntPair,修改时替换成新的IntPair实例。

AtomicStampedReference和AtomicMarkableReference的实现也是这个套路。

Atomic Field Updater

Atomic field updater可以用原子操作来修改对象的某个字段,比如ConcurrentLinkedQueue的内部Node结构类似如下:

private class Node<E> {
	private final E item;
	private volatile Node<E> next;
	public Node(E item) {
		this.item = item;
	}
}

Node.next的更新需要原子性,但Node.next并不是Atomic变量,而是一个普通的volatile变量,ConcurrentLinkedQueue通过AtomicReferenceFieldUpdater来更新它:

private static AtomicReferenceFieldUpdater<Node, Node> nextUpdater = AtomicReferenceFieldUpdater.newUpdater(Node.class, Node.class, "next");

Atomic Field Updater提供了对象字段的一个atomic操作工具,但是程序是有可能绕过updater来修改字段的,所以updater的原子性保证并不完整;因而,updater只能用来更新被完全封装的内部对象字段。

使用updater的动机有两个:

  • 避免Atomic破坏对象的序列化;
  • 减少一层对象,获得微弱的性能优势(如果next字段的类型是AtomicReference,相当于多了一层对象访问)。

所以一般的情况下,我们是没有必要使用updater的。

ABA问题

Atomic通过对比内存地址的期望值实际值来判断是否存在冲突。存在一种极限情况:线程在获取旧值和置换新值两个操作之间,有另外一个线程将旧值修改了两次(先改成另外一个,又改回来),这就是所谓的ABA问题。在绝大多数情况下,ABA问题对业务没有任何影响,我们可以忽略它。CAS操作本身无法避开ABA问题,Atomic提供了两种类型来提供解决方案:AtomicStampedReference和AtomicMarkableReference,

AtomicStampedReference给reference加上一个int标记,每次修改,这个标记加1;AtomicMarkableReference则维护一个bool标记。

Atomic VS Lock

CAS指令消耗的CPU周期在10~150之间;而且在市场竞争的驱动下,处理器的CAS指令会越来越快。可以定性认为,在都没有发生竞争的情况下,Atomic的操作的速度大概是Lock加锁解锁的两倍;而发生竞争的情况下,Lock会导致线程挂起,Atomic不会阻塞线程,二者不具备可比性,除非Atomic变量操作陷入了“活锁“,否则性能远优于Lock。

非阻塞同步算法&数据结构

基于Atomic变量或CAS操作,可以设计出非阻塞且线程安全的算法或数据结构,由于没有阻塞,此类算法不会有死锁风险。另一方面,由于它不会锁住数据状态然后再操作,而是任由多个线程并发地操作状态,因此算法复杂度要大很多。

示例1:ConcurrentStack

现在我们使用Atomic来实现一个并发安全的Stack数据结构。

@ThreadSafe
public class ConcurrentStack <E> {

	AtomicReference<Node<E>> top = new AtomicReference<Node<E>>();
	
	public void push(E item) {
		Node<E> newHead = new Node<E>(item);
		Node<E> oldHead;
		do {
			oldHead = top.get();
			newHead.next = oldHead;
			//如果有其他线程并发第push或pop,top会发生变化,compareAndSet操作会失败
		} while (!top.compareAndSet(oldHead, newHead));
	}
	
	public E pop() {
		Node<E> oldHead;
		Node<E> newHead;
		do {
			oldHead = top.get();
			if (oldHead == null)
				return null;
			newHead = oldHead.next;
			//如果有其他线程并发第push或pop,top会发生变化,compareAndSet操作会失败
		} while (!top.compareAndSet(oldHead, newHead));
		
		return oldHead.item;
	}
	
	private static class Node <E> {
		public final E item;
		public Node<E> next;
		public Node(E item) {
			this.item = item;
		}
	}
}

ConcurrentStack将元素放入Node,将Node组织为一个单链表,只需要持有栈顶节点——top,就可以了。

ConcurrentStack.push操作步骤:

  1. 创建出新的栈顶节点newHead;
  2. 获取当前栈顶的元素(oldHead),让newHead.next=oldHead,维护了单向链表;
  3. 通过cas操作将top设置为newHead
    • 如果没有并发竞争,那么cas操作成功
    • 如果发生竞争,那么oldHead会过期,那么cas操作会失败,返回步骤2重试

ConcurrentStack.push的正确性源自:Atomic提供了和volatile一样的内存可见性,且compareAndSet能检测到并发冲突。ConcurrentStack.pop的操作步骤是类似的,不再赘述。

ConcurrentStack是一个极简的并发安全数据结构,但是足以展示出此类算法的精髓,ConcurrentLinkedQueue、ConcurrentHashMap的实现原理是类似的,只不过更复杂罢了。

总结

基于硬件处理器的CAS指令,Atomic变量提供了细粒度的非阻塞的原子操作,基于Atomic,我们又可以构建很多非阻塞的并发安全数据结构(见java.util.concurrent)。相比锁,Atomic变量和并发数据结构,具备优越的并发性能。但是我们也要意识到,非阻塞同步机制不是在任何情况下可以取代锁,非阻塞同步只能保证细粒度的原子操作,它无法为涉及多个状态字段的操作提供原子性保证。一个并发数据结构是无法被锁定的,线程永远无法准确定判定一个并发集合是否empty,它的size是多少。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值