java多线程(七)---原子操作的实现原理
2019-02-09 本文已影响204人
brock
原子操作(atomic operation)意为“不可被中断的一个或一系列操作”。
1 CPU术语定义
术语定义 | 英文 | 解释 |
---|---|---|
缓存行 | Cache line | 缓存的最小操作单位 |
比较并交换 | Compare and Swap | CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换 |
CPU流水线 | CPU pipeLine | CPU流水线的工作方式就像工业生产上的装配流水线,在CPU中由5-6个不同功能的电路单元组成一条指令处理流水线,然后将一条X86指令分成5-6步后再交由这些电路元件分别执行,这样就能实现在一个CPU时钟周期完成一条指令,因此调高CPU的运算速度。 |
内存顺序冲突 | Memoryorder violation | 内存顺序冲突一般是由假共享是指多个CPU同时修改同一个缓存行的不同部分而引起其中一个CPU的操作无效,当出现这个内存顺序冲突时,CPU必须清空流水线。 |
2 处理器如何实现原子操作
- 32位IA-32处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。
- 首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。
- Pentium 6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,但是复杂的内存操作处理器是不能自动保证其原子性的,比如跨总线宽度、跨多个缓存行和跨页表的访问。
2.1 使用总线锁保证原子性
所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。
2.2 使用缓存所保证原子性
- 在同一时刻,我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大
- 所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效,
- 有两种情况下处理器不会使用缓存锁定:
- 第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line)时,则处理器会调用总线锁定。
- 第二种情况是:有些处理器不支持缓存锁定。对于Intel 486和Pentium处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。
- 针对以上两个机制,我们通过Intel处理器提供了很多Lock前缀的指令来实现。例如,位测试和修改指令:BTS、BTR、BTC;交换指令XADD、CMPXCHG,以及其他一些操作数和逻辑指令(如ADD、OR)等,被这些指令操作的内存区域就会加锁,导致其他处理器不能同时访问它。
3 Java如何实现原子性
在Java中可以通过锁和循环CAS的方式来实现原子操作。
3.1 使用循环CAS实现原子操作
- JVM中的CAS操作正是利用了处理器提供的CMPXCHG指令实现的。
- 自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止。
- 从Java 1.5开始,JDK的并发包里提供了一些类来支持原子操作,如
AtomicBoolean
(用原子方式更新的boolean
值)、AtomicInteger
(用原子方式更新的int值)和AtomicLong
(用原子方式更新的long值)。这些原子包装类还提供了有用的工具方法,比如以原子的方式将当前值自增1和自减1。
3.2 CAS实现原子操作的三大问题
ABA问题,循环时间长开销大,以及只能保证一个共享变量的原子操作。
3.2.1 ABA问题
- 因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。
- ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。
- 从Java 1.5开始,JDK的
Atomic
包里提供了一个类AtomicStampedReference
来解决ABA问题。这个类的compareAndSet
方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
public boolean compareAndSet(
V expectedReference, // 预期引用
V newReference, // 更新后的引用
int expectedStamp, // 预期标志
int newStamp // 更新后的标志
)
3.2.2 循环时间长开销大
- 自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。
- 如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。
-
pause
指令有两个作用:第一,它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;第二,它可以避免在退出循环的时候因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空(CPU Pipeline Flush),从而提高CPU的执行效率。
3.2.3 只能保证一个共享变量的原子操作
- 当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。
- 还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。
4 使用锁机制实现原子操作
- 锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。
- JVM内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。
- 有意思的是除了偏向锁,JVM实现锁的方式都用了循环CAS,即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁。
参考
《java并发编程的艺术》