我们在学习MySQL的时候就了解过原子性,即整个事务是不可分割的最小单位,事务中任何一个语句执行失败,所有已经执行成功的语句也要回滚,整个数据库状态要恢复到执行事务前到状态。Java中的原子性其实跟先前在数据库的内容里面说的类似,就是不可在分割,在我们的多线程里面就是相当于一把锁,在当前的线程没有完成对应的操作之前,别的线程不允许切换过来,那么Java中是如何实现代码操作中的原子性的呢?在说明这个问题之前,我们先来看一些术语,方便接下来的理解。
处理器通常采用缓存加锁或者总线加锁的方式来实现多处理器之间的原子操作。首先处理器会自动保证基本的内存操作的原子性。处理器保证从内存中读取或者写入一个字节是原子的,意思是,当一个处理器读取一个字节时,其他处理器就不能访问这个字节的内存地址。Pentium 6和最新的处理器可以保证单处理器对于同一个缓存行进行的16/32/64位的操作是原子性的,但是复杂的内存操作处理器是不能自动保证其原子性的,比如跨总线宽度、跨多个缓存行和跨页表的访问。但是,处理器提供总线锁定和缓存行锁定两个操作来保证复杂内存操作的原子性。
第一个机制就是通过总线锁保证原子性。如果多个处理器同时对共享变量进行改写(例如i++),那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值就会个期望的值不一致。
原因可能是读个处理器同时从各自的缓存中读取数变量i,分别进行加1操作,然后分别写入各自的系统内存中。那么要想保证读改写操作是原子的,就必须保证CPU1读改写共享变量的时候,CPU2不能操作缓存了该共享变量内存地址的缓存。处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。
这里顺便说一下JMM也就是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 就是主存。
主存比 L1、L2 缓存要大很多,部分高端机器还有 L3、L4 缓存。
第二个机制就是使用缓存锁来保证原子性。在同一时刻,我们只需要对某个内存地址的操作是原子性的即可,但总线锁吧CPU和内存之间的通信锁住了,这使得锁顶期间,其他处理器不能操作其他内存地址的数据,所以总线锁的开销比较大,目前处理器在某些场合下适应缓存锁来代替总线锁进行优化。
频繁使用的内存会缓存在L1、L2、L3高速缓存中,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁,在Pentium 6和目前的处理器中,可以使用“缓存锁定”的方式来实现复杂的原子性。所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存行中,并且在Lock期间被锁定,那么当他执行锁操作回写到内内存时,处理器不在总线上声明LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存数据区域数据,当其他处理器回写已被修改的缓存行的数据时,会使缓存行无效。
但是有两种情况处理器不会使用缓存锁定:
在Java中可以通过锁和循环CAS的方式来实现原子操作。
JVM中的CAS操作利用的正是利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本思路就是循环进行CAS直到成功。举例:
private AtomicInteger atomicI = new AtomicInteger(0);private int i = 0;public static void main(String[] args) {final Counter cas = new Counter();List ts = new ArrayList(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) {e.printStackTrace();}}System.out.println(cas.i);System.out.println(cas.atomicI.get());System.out.println(System.currentTimeMillis() - start);}/*** 使用 CAS 实现线程安全计数器*/private void safeCount() {for (; ; ) {int i = atomicI.get();boolean suc = atomicI.compareAndSet(i, ++i);if (suc) {break;}}}/*** 非线程安全计数器*/private void count() {i++;}
在Java并发包中有一些并发框架也使用了自旋CAS的方式来实现原子操作,比如LinkedTransferQueue类的Xfer方法。虽然CAS很高效的解决了原子操作,但是CAS仍然存在三大问题:
1.ABA问题
2.循环时间长,开销大问题
3.只能保证一个共享变量的原子操作
因为CAS需要在操作值得时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它得值没有发生变化,但实际上却发生变化了。ABA问题得解决思路就是使用版本号,每次变量更新的时候把版本号+1,那么A-B-A就会变成1A-2B-3A。从jdk1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志位的值设定为给定的更新值。
自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令,那么效率就会有一定的提升。pause指令有两个作用:第一,它可以延迟流水线执行指令,使CPU不会消耗过多的执行资源,延迟时间取决于具体的实现版本,在一些处理器上延迟时间为0;第二,它可以避免在退出循环的时候因为内存顺序冲突而引起CPU流水线被清空,从而提升CPU执行效率。
对一个共享变量进行CAS操作的时,我们可以使用循环CAS的方式来保证操作的原子性,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,还有一个取巧的方法就是把多个共享变量合并成一个共享变量来进行操作。从 Java 1.5 开始, JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行 CAS 操作。
锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM内部实现了很多锁机制,有偏向锁、轻量级锁和互斥锁。有意思的是,除了偏向锁,JVM实现锁的方式都使用了循环CAS,即当一个线程想要进入同步块时使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁。
上一篇: 春节的街头小学日记
下一篇:Vue 3.0 Data选项