Java 并发基础 一
说到 Java 的并发,便离不开 java.util.concurrent 这个包。这个包封装了 Java 并发相关的类,我们可以基于这些类构建出并发安全且高性能的上层应用。
java.util.concurrent 主要包含三部分:1)提供原子性操作的类;2)锁;3)基于原子操作类以及锁构建的数据结构。
本篇文章讨论 Java 中提供原子性操作的类,这些类都放在 java.util.concurrent.atomic 这个子包下面。
其实,java.util.concurrent.atomic 这个包只是提供了一些工具类,而这些工具类在多线程共享及操作变量上非常有用。更重要的是,这些工具类提供的线程安全操作往往是无锁的。atomic 工具类是对 volatile 变量的一个延伸,它们都提供了这么一个原子性条件更新操作:
boolean compareAndSet(expectedValue, updateValue);
这个方法先判断该变量当前的值是否是 expectedValue,如果是则更新成 updateValue 并且返回 true,否则直接返回 false。这个过程是原子性的。当然,除此之外这些工具类还包括获取值、非条件更新、弱化条件更新等方法。
这些方法的底层实现往往使用处理器支持的原子性指令来达到无锁的目的,但对于不支持这些指令的系统,这些方法可能会采取一些基于锁的手段。因此,它们都没有严格声明为非阻塞的,也就是说一个线程在调用 compareAndSet 方法时可能会被串行化等待。
AtomicBoolean , AtomicInteger , AtomicLong 以及 AtomicReference 这几个类提供了常用类型的原子性访问及更新操作,并且这些类基于这些操作封装了一些非常有用的工具方法。
举个例子,AtomicLong 和 AtomicInteger 提供了原子性递增的工具方法,基于这个方法我们可以很方便地实现一个线程安全的序列号产生器:
class Sequencer {
private final AtomicLong sequenceNumber
= new AtomicLong(0);
public long next() {
return sequenceNumber.getAndIncrement();
}
}
有了 compareAndSet 这个法宝,我们也可以非常方便地定义自己的工具方法。假设当前存在一个转换操作:
long transform(long input)
我们可以将该变量的更新过程变成线程安全的:
long getAndTransform(AtomicLong var) {
long prev, next;
do {
prev = var.get();
next = transform(prev);
} while (!var.compareAndSet(prev, next));
return prev; // return next; for transformAndGet
}
在内存可见性上面,这些原子性操作类的访问及更新操作与 volatile 变量产生相同的内存影响(关于 volatile 的内存可见性感兴趣的可以看下 The Java Language Specification (17.4 Memory Model) ,总结为如下几点:
- get 操作相当于读 volatile 变量。
- set 操作相当于写 volatile 变量。
- lazySet 相当于写 volatile 变量除了它允许与之后的内存操作重排序(但保证与之前的操作的顺序性)。
- weakCompareAndSet 原子性条件读写变量,但不创建任何 happens-before 顺序,也就是说该操作只是对其目标变量有影响,对操作前后的其他变量读写没有任何影响。
下面对这几句话做个简单的解释。
我们将一个变量声明为 volatile,在读写该变量时我们不仅仅获得了内存操作的实时可见性,同时这些读写操作与操作前后的其他变量指令也有一定顺序性。
顺序性是什么意思?难道我写的代码不是按顺序从前往后执行的?
嗯的确不是。为了程序代码有更快的执行性能,代码在编译的时候编译器会进行优化,会对指令进行重排序;而 CPU 在执行机器指令的时候也会进行重排序以得到更好的并行度。这些重排序优化为保证结果正确性,会遵循一个前提:不改变单线程执行语义。也就是说,只要保证我们的代码在单线程下执行结果是正确的,那么指令重排序是可允许的。
举个简单的例子,假如我们的代码如下:
int a = 5; //1
int b = 6; //2
int c = a + b; //3
在上面的代码中,假如编译器把 1 和 2 换个顺序执行,先执行代码 2 再执行代码 1,最终 c 仍然可以得到正确的结果。
但在多线程情况下,这些重排序往往会让程序结果不可预测。因此为了保证多线程情况下的行为可预期,Java 专家组制定了 Java 内存模型 规范。这个规范在上层向我们保证了特定语义(譬如 volatile、监视器锁)的上下文顺序性,而在底层实现中通过使用内存屏障(Memory Barrier) 指令来保证指令顺序。
对于上下文顺序性,我之前整理过 一篇文章 介绍这些 happens-before 规则,这里不再赘述。这里简单介绍下底层实现中的内存屏障。
代码执行无非就是读(Load)和写(Store),为了保证读写顺序性以及内存操作全局可见性,代码编译之后会在适当位置插入内存屏障指令。四种内存屏障指令描述如下:
1) LoadLoad 屏障
序列:Load1, LoadLoad, Load2
作用:确保 Load1 所要读入的数据能够在 Load2 和后续指令访问前读入
2)StoreStore 屏障
序列:Store1, StoreStore, Store2
作用:确保 Store1 的数据在 Store2 以及后续 Store 指令操作相关数据之前对其它处理器可见
3)LoadStore 屏障
序列:Load1, LoadStore, Store2
作用:确保 Load1 的数据在 Store2 和后续 Store 指令被刷新之前读取
4)StoreLoad 屏障
序列:Store1, StoreLoad, Load2
作用:确保 Store1 的数据在被 Load2 和后续的 Load 指令读取之前对其他处理器可见
其中,Normal 表示普通变量,Volatile 表示 volatile 变量,Monitor 表示对象监视器锁。
现在我们再回到上面提到的原子性操作类的 lazySet 方法。它相当于写 volatile 变量除了它允许与之后的内存操作重排序(但保证与之前的操作的顺序性),其实说白了就是,它省略了 StoreLoad 指令。对于一个 Volatile 变量来说,当我们对其进行读写时,在它写之后、读之前会插入 StoreLoad 屏障以保证多 CPU 场景下读操作能读到写的值。但是这个 StoreLoad 指令开销非常昂贵,因此在 lazySet 中省略了这个 StoreLoad 屏障。
这样一来,我们获得了更好的执行性能,但造成的结果是多线程情况下另一个线程不一定能看到 lazySet 的值。那这个 lazySet 有什么用呢?一个主要的使用场景是,如果某些变量没有用了,为了避免长期的内存占用我们将其 lazySet 为 null,但仍然让其他线程暂时看到其原来的值,直到有别的同步操作将该 null 值刷新回内存使得对其他线程可见。
在 java.util.concurrent.atomic 包中,除了有 AtomicInteger 这些操作单个值的类,还包含有不同类型的 Updater 类,这些类可以用来实现任何类的任何 volatile 字段的 compareAndSet 操作。这些 Updater 类目前有: AtomicReferenceFieldUpdater , AtomicIntegerFieldUpdater , AtomicLongFieldUpdater 。它们是基于反射机制来访问相应类的字段的,这样做的缺点是代价相对比较昂贵,但是可以让我们得到非常好的灵活性。就是说,我们不需要一开始就要决定是否使用 AtomicInteger,可以先使用 volatile int,后面实在需要原子性的条件更新时可以使用 AtomicIntegerFieldUpdater 来包装控制。
AtomicIntegerArray , AtomicLongArray , AtomicReferenceArray 这三个类支持数组类型的原子性操作,它们提供了数组元素的 volatile 访问语义。
你可能会问,将数组声明为 volatile(例如 volatile int[])不就行了么,为啥还要 AtomicIntegerArray?
这是行不通的。volatile 的数组只对数组的引用具有 volatile 的语义,而不是它的元素。大家可以参考下 这篇文章 。
另外,atomic 包里有一个 AtomicMarkableReference 类,这个类将一个 boolean 变量与一个引用关联起来。举个使用例子,我们可以在相关数据结构中使用这个 boolean 变量来表示其关联的对象已经被逻辑删除了。而 AtomicStampedReference 则将一个整数关联到一个引用。这个则可以被用来表示关联对象更新的版本号。
最后提醒下,原子类主要用来构造非阻塞的数据结构,其 compareAndSet 方法不是锁的替代方案,但它在单个变量的关键更新及同步上非常有用。原子类也不是 java.lang.Integer 这些类的替代方案,我们要具体场景具体分析。另外,这些原子类并没有定义 equals、hashCode、compareTo 这些方法,因为原子类的对象预期是变化的,它们不能作为 HashMap 这些结构的键值使用。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论