写点什么

【并发编程的艺术】JAVA 原子操作实现原理

发布于: 2021 年 01 月 21 日

系列文章:

【并发编程的艺术】JVM 体系与内存模型

【并发编程的艺术】JAVA 并发机制的底层原理

前一章中,我们描述了 volatile 和 synchronized 的实现原理,本篇介绍原子操作特性、

一 相关定义

1.1 缓存行(Cache line)

通过前面学习,已经知道 cpu 的多级缓存结构,缓存行是缓存的最小操作单位。

1.2 CAS

即 Compare And Swap,比较并交换。java 的 unsafe 包中提供了相关方法。

1.3 CPU 流水线

CPI pipeline,工作方式类似工业生产上的装配流水线,CPU 中多个不同功能的单元组成一条指令流水线。一条指令可能会被分为 5-6 个步骤之后再分别执行,来实现一个 CPU 时钟周期内完成一条指令,提高运算速度。

1.4 内存顺序冲突

Memory order violation。一般是由“假共享”引起。假共享,是指多 CPU 同时修改一个缓存行的不同部分,导致其中一个 CPU 的操作无效。出现内存顺序冲突时,CPU 必须清空流水线。

二 操作系统中原子操作的实现原理

CPU 提供两个机制来保证复杂内存操作的原子性:总线锁定 和 缓存锁定。

2.1 总线锁

先研究一个典型案例:i++。当两个 CPU 中同时对共享变量 i 执行++操作时,我们预期结果是 i=3,但实际的结果可能是 2:


为什么会出现这样的情况?(回想上一篇文章中提到过的 cpu 缓存结构),各 CPU 可能同时从自己的缓存中读取变量 i 的值,分别进行加 1 操作,再分别写入系统内存。要保证 i++底层操作的原子性,就必须保证 CPU1 修改共享变量时,CPU2 不能同时执行修改操作。

所谓总线锁,就是 CPU 提供 LOCK#信号,当某个 CPU 在总线上输出这个信号时,其他 CPU 请求会被阻塞。

关于总线,再回顾一下 CPU 多级缓存结构,下图中的 Bus 就是总线:


另外,这个 LOCK 信号是否有点眼熟?这就是前篇中的:

2.2 缓存锁

总线锁是通过锁住总线来确保某个 CPU 可以独占共享内存,这样锁的开销其实是比较大的。这会导致锁定期间其他 CPU 无法操作其他内存地址的数据。其他 CPU 其实也可以考虑,只需要保证某个时刻对一个固定的内存地址的操作是原子性的即可,这样就降低了锁的粒度。

频繁使用的内存(数据)会缓存在 CPU 的 L1、L2,L3 级缓存,所以原子操作可以在各级缓存中进行。

缓存锁定,是指内存区域如果被缓存在 CPU 的缓存行中,并且在 Lock 操作期间被锁定,那么当执行锁操作回写内存时,CPU 不能在总线上声言 LOCK#新号,而是修改内存的内存地址,并通过缓存一致性机制阻止同时修改由两个及两个以上 CPU 缓存的内存区域数据,当其他 CPU 回写已被锁定的缓存行的数据时,使缓存行无效。

2.3 缓存行不会被使用的场景

1、操作的数据不能被缓存在 CPU 的缓存内,或操作的数据跨多个缓存行时,CPU 会调用总线锁定;

2、有些 CPU 不支持缓存锁定。已知这类的 CPU:Intel 486 和 Pentium,就算锁定的内存区域在处理器的缓存行中,也会调用总线锁定。

三 Java 中的原子操作实现

两种方式:锁机制 和 CAS。

3.1 锁机制

锁机制,要求只有获得了锁的线程才能操作锁定的内存区域。JVM 中的锁包括偏向锁、轻量级锁和重量级锁(互斥锁)。从具体类或命令的角度说,有最常用的 synchronized 和 Lock。

3.2 CAS

JVM 中的 CAS 是利用 CPU 提供的 CMPXCHG 指令来实现的,通常会配合“自旋”使用,即循环进行 CAS 直到成功为止。

jdk1.5 之后,在并发包中提供了诸如 AtomickInteger、AtomicLong 等方法来执行对应类型变量的原子更新。

CAS 存在的问题:

1、ABA

这也是了解过 CAS 都知道的典型问题。“比较”,是检查值是否发生了变化,但当一个变量原来的值是 A,先变成 B 后又变成 A,这种情况 CAS 的检查机制会误判为没有发生变化。

解决思路:使用版本号。每次更新时把版本号加 1,A->B->A 就变成 1A->2B->3A。jdk1.5 及以后,Atomic 包中提供了类 AtomicStampedReference 来解决 ABA。结构如下:

/**     * Atomically sets the value of both the reference and stamp     * to the given update values if the     * current reference is {@code ==} to the expected reference     * and the current stamp is equal to the expected stamp.     *     * @param expectedReference the expected value of the reference     * @param newReference the new value for the reference     * @param expectedStamp the expected value of the stamp     * @param newStamp the new value for the stamp     * @return {@code true} if successful     */    public boolean compareAndSet(V   expectedReference,                                 V   newReference,                                 int expectedStamp,                                 int newStamp) {        Pair<V> current = pair;        return            expectedReference == current.reference &&            expectedStamp == current.stamp &&            ((newReference == current.reference &&              newStamp == current.stamp) ||             casPair(current, Pair.of(newReference, newStamp)));    }
复制代码


V expectedReference => 预期引用 ,

V newReference => 更新后的引用,

int expectedStamp => 预期标志,

int newStamp => 更新后的标志。


2、循环时间长开销大

循环是为了保证占用 CPU,从而避免上下文切换带来的时间消耗。但这会导致一直占用 CPU 资源。

3、只保证一个共享变量的原子操作

这点是比较好理解的,因为比较、替换都是针对一个变量的操作,所以不适用多个共享变量同时操作的场景。这种情况通常还是会通过锁来实现;或者也可以取巧把多个共享变量合成一个共享变量来操作。

四 总结

本篇把原子操作单独拿出来详细阐述,结合前面两篇文章中的 CPU 多级缓存结构进行串联,加深理解。下一篇会全面研究 Java 的内存模型。


发布于: 2021 年 01 月 21 日阅读数: 22
用户头像

磨炼中成长,痛苦中前行 2017.10.22 加入

微信公众号【程序员架构进阶】。多年项目实践,架构设计经验。曲折中向前,分享经验和教训

评论

发布
暂无评论
【并发编程的艺术】JAVA 原子操作实现原理