1.什么是原子操作?
我们在学习MYSQL时就了解过原子性,即整个事务是不可分割的最小单位,事务中任何一个语句执行失败,所有已经执行成功的语句也要回滚,整个数据库状态要恢复到执行任务前的状态。Java中的原子性其实就是和数据库中说的相似,就是不可在分割,在我们的多线程里面就是相当于一把锁,在当前的线程没有完成对应的操作之前,别的线程不允许切换过来,那么Java中如何实现代码操作中的原子性?在说明这个问题之前,我们先来看一些术语,方便接下来的理解。
2.处理器如何实现操作的原子性?
处理器通常采用缓存加锁或者总线加锁的方式来实现多处理器之间的原子操作。首先处理器会自动保证基本的内存操作的原子性。处理器保证从内存中读取或者写入一个字节是原子的,意思是,当一个处理器读取一个字节时,其他处理器就不能访问这个字节的内存地址。Pentium6和最新的处理器可以保证单处理器对于同一个缓存进行的16/32/64位的操作是原子性的,但是复杂的内存操作处理器是不能自动保证其原子性的,比如跨总线宽度,跨多个缓存行和跨页表的访问。但是,处理器提供总线锁定和缓存行锁定的两个操作来保证复杂内存操作的原子性。
2.1使用总线锁保证原子性:
如果多个处理器同时对共享变量进行改写(例如i++),那么共享变量就会被多个处理器同时进行操作,这样读写操作就不是原子的,操作完之后共享变量的值就会和期望值不一致。
原因可能是多个处理器同时从各自的缓存中读取变量i,分别进行加1操作,然后分别写入各自的内存中。那么要想保证读和写是原子性的,就必须保证CPU1读改写共享变量的时候,CPU2不能操作缓存了该共享变量内存地址的缓存。处理器使用总线锁就是来解决这个问题的。所谓总线索就是使用处理器提供一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器 的请求将被阻塞住,那么该处理器可以独占共享资源。
这里顺便说一下,JVM也就是Java的内存模型:
上图是传统的计算机架构,组成包括以下几个
(1)CPU
一般在大型服务器上会配置多个CPU,每个CPU还会有多个核,这就意味着多个CPU或者多个核可以同时(并发)工作。如果使用Java起了一个多线程任务,很有可能每个CPU都会跑一个线程,那么你的任务在某一时刻就是真正的并发执行了。
(2)CPU Register
CPU Register也就是CPU寄存器。CPU寄存器是CPU内部集成的,在寄存器上执行操作的效率要比在主存上高出几个数量级。
(3)CPU Cache Memory
CPU Cache Memory就是CPU缓存,相对于寄存器来说,通常也可以成为L2二级缓存。相对于硬盘读取速度来说内存读取的效率非常高,但是与CPU还是相差数量级,所以在CPU和主存之间引入了多级缓存,目的就是为了做一下缓冲。
(4)Main Memory
Main Memory就是主存。
2.2使用缓存锁保证原子性:
第二个机制就是使用缓存锁来保证原子性。在同一时刻,我们只需要对某个内存地址的操作是原子性即可,但总线锁把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁的开销较大,目前处理器在某些场合下适应缓存锁来代替总线锁进行优化。
频繁使用的内存会缓存在L1,L2,L3高速缓存中,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁,在Pentium6和目前的处理器中,可以使用”缓存锁定”的方式来实现复杂度原子性。所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存行中,并且在LOCK期间被锁定,那么当他执行所操作回写奥内存时,处理器不再总线上声明LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改两个以上处理器缓存的内存数据区域数据,当其他处理器回写已被修改缓存行的数据时,会使得缓存行无效。
但是有两种情况处理器不会使用缓存锁定:
- 情况一:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行时,处理器会调用总线锁定。
- 情况二:有些处理器不支持缓存锁定
3.Java如何实现原子操作?
在Java中可以通过锁和循环CAS的方式来实现原子操作。
3.1使用CAS实现原子操作
JVM中的CAS操作利用的是处理器提供的CMPXCHG指令实现。自旋CAS实现的基本思路就是循环进行CAS直到成功。举例:
package com.cl.pattern.cas;import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;/*** @desc author Chen lei* @date 2024/9/22 18:29*/
public class Counter {private AtomicInteger atomicInteger = new AtomicInteger(0);private int i = 0;public static void main(String[] args) {final Counter cas = new Counter();List<Thread> ts = new ArrayList<Thread>(600);long start = System.currentTimeMillis();for (int j = 0;j < 100;j++){Thread t = new Thread(new Runnable() {@Overridepublic void run() {for (int i = 0;i < 10000;i++){cas.count();cas.safeCount();}}});ts.add(t);}for (Thread t:ts){t.start();}for (Thread t : ts) {try {t.join();} catch (InterruptedException e) {throw new RuntimeException(e);}}System.out.println(cas.i);System.out.println(cas.atomicInteger.get());System.out.println(System.currentTimeMillis() - start);}private void safeCount(){for (;;){int i = atomicInteger.get();boolean suc = atomicInteger.compareAndSet(i,++i);if (suc){break;}}}private void count(){i++;}
}
3.2CAS实现原子性操作的三大问题
- ABA问题
- 循环时间长,开销大
- 只能保证一个共享变量的原子操作
3.2.1ABA问题:
因为CAS需要在操作值的时候,检查值有么有发生变化,如果没有发生变化则更新,但是如果一个值原来只是A,变成了B,又变成了A,那么使用CAS进行检查时就会发现它的值没有发生变化,但实际上发生变化了。ABA问题的解决思路就是使用版本号,每次变量更新时把版本号+1,那么A-B-A就会变成1A-2B-3A。从jdk1.5开始,JDK的Atomic包里就提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和标志位的值设定为给定的更新值。
3.2.2循环开销时间长问题:
自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令,那么效率就会有一定的提升。pause指令有两个所用:第一,它可以延迟流水线执行指令,使得CPU不会消耗过多的执行资源,延迟时间取决于具体的实现版本,在一些处理器上延迟时间为0;第二,它可以避免在退出循环的时候因为内存顺序冲突而引起CPU流水线被清空,从而提升CPU执行效率。
3.2.3只能保证一个共享变量的原子操作:
对一个共享变量进行CAS操作时,我们可以使用循环CAS的方式来保证操作的原子性,但是多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,还有一个取巧的方法就是把多个共享变量合并成一个共享变量来进行操作。从 Java 1.5 开始, JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行 CAS 操作。
3.2使用锁机制实现原子操作
锁机制保证了只有获得锁的线程可以操作锁定的内存区域。JVM内部实现了很多锁机制,有偏向锁,轻量级锁和互斥锁。有意思的是,除了偏向锁,JVM实现锁的方式都是用来循环CAS,即当一个线程进入同步块时使用循环CAS的方式来获取锁,当他退出同步块时使用循环CAS释放锁。