Java并发编程-原子操做

1. 指令重排序

关于指令重排序的概念,比较复杂,很差理解。咱们从一个例子分析:html

public class SimpleHappenBefore {

    /** 这是一个验证结果的变量 */
    private static int a=0;

    /** 这是一个标志位 */
    private static boolean flag=false;

    public static void main(String[] args) throws InterruptedException {

        //因为多线程状况下未必会试出重排序的结论,因此多试一些次
        for(int i = 0; i < 1000; i++){

            ThreadA threadA=new ThreadA();
            ThreadB threadB=new ThreadB();

            threadA.start();
            threadB.start();

            //这里等待线程结束后,重置共享变量,以使验证结果的工做变得简单些.
            threadA.join();
            threadB.join();

            a = 0;
            flag = false;
        }
    }

    static class ThreadA extends Thread{
        public void run(){
            a = 1;
            flag = true;
        }
    }

    static class ThreadB extends Thread{
        public void run(){
            if(flag){
                a = a * 1;
            }

            if(a == 0){
                System.out.println("ha,a==0");
            }
        }
    }
}

一个简单的展现Happen-Before的例子.
这里有两个共享变量:a和flag,初始值分别为0和false.在ThreadA中先给a=1,而后flag=true.java

若是按照有序的话,那么在ThreadB中若是if(flag)成功的话,则应该a=1,而a=a*1以后a仍然为1,下方的if(a==0)应该永远不会为真,永远不会打印.算法

但实际状况是:在试验100次的状况下会出现0次或几回的打印结果,而试验1000次结果更明显,有十几回打印.编程

1.1. 什么是指令重排序

在虚拟机层面,为了尽量减小内存操做速度远慢于CPU运行速度所带来的CPU空置的影响,虚拟机会按照本身的一些规则(这规则后面再叙述)将程序编写顺序打乱——即写在后面的代码在时间顺序上可能会先执行,而写在前面的代码会后执行——以尽量充分地利用CPU。缓存

拿上面的例子来讲:假如不是a=1的操做,而是a=new byte[1024*1024](分配1M空间),那么它会运行地很慢,此时CPU是等待其执行结束呢,仍是先执行下面那句flag=true呢?显然,先执行flag=true能够提早使用CPU,加快总体效率,固然这样的前提是不会产生错误(什么样的错误后面再说)。虽然这里有两种状况:后面的代码先于前面的代码开始执行;前面的代码先开始执行,但当效率较慢的时候,后面的代码开始执行并先于前面的代码执行结束。无论谁先开始,总以后面的代码在一些状况下存在先结束的可能。安全

无论怎么重排序,单线程程序的执行结果不能被改变。编译器、运行时和处理器都必须遵照“as-if-serial”语义。拿个简单例子来讲,多线程

public void execute(){
    int a = 0;
    int b = 1;
    int c = a+b;
}

这里a=0,b=1两句能够随便排序,不影响程序逻辑结果,但c=a+b这句必须在前两句的后面执行。并发

2. happen-before

在JMM中,若是一个操做执行的结果须要对另外一个操做可见,那么这两个操做之间必须存在happens-before关系。app

happens-before原则很是重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,咱们解决在并发环境下两操做之间是否可能存在冲突的全部问题。
happens-before原则定义以下:框架

  1. 若是一个操做happens-before另外一个操做,那么第一个操做的执行结果将对第二个操做可见,并且第一个操做的执行顺序排在第二个操做以前。
  1. 两个操做之间存在happens-before关系,并不意味着必定要按照happens-before原则制定的顺序来执行。若是重排序以后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。

重排序在多线程环境下出现的几率仍是挺高的,在关键字上有volatile和synchronized能够禁用重排序,除此以外还有一些规则,也正是这些规则,使得咱们在平时的编程工做中没有感觉到重排序的坏处。

  1. 程序次序规则(Program Order Rule):在一个线程内,按照代码顺序,书写在前面的操做先行发生于书写在后面的操做。准确地说应该是控制流顺序而不是代码顺序,由于要考虑分支、循环等结构。
    一段代码在单线程中执行的结果是有序的。注意是执行结果,由于虚拟机、处理器会对指令进行重排序(重排序后面会详细介绍)。虽然重排序了,可是并不会影响程序的执行结果,因此程序最终执行的结果与顺序执行的结果是一致的。故而这个规则只对单线程有效,在多线程环境下没法保证正确性。
  2. 监视器锁定规则(Monitor Lock Rule):一个unlock操做先行发生于后面对同一个对象锁的lock操做。这里强调的是同一个锁,而“后面”指的是时间上的前后顺序,如发生在其余线程中的lock操做。
    这个规则比较好理解,不管是在单线程环境仍是多线程环境,一个锁处于被锁定状态,那么必须先执行unlock操做后面才能进行lock操做。
  3. volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操做发生于后面对这个变量的读操做,这里的“后面”也指的是时间上的前后顺序。
    这是一条比较重要的规则,它标志着volatile保证了线程可见性。通俗点讲就是若是一个线程先去写一个volatile变量,而后一个线程去读这个变量,那么这个写操做必定是happens-before读操做的。
  4. 线程启动规则(Thread Start Rule):Thread独享的start()方法先行于此线程的每个动做。
    假定线程A在执行过程当中,经过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行后确保对线程B可见。
  5. 线程终止规则(Thread Termination Rule):线程中的每一个操做都先行发生于对此线程的终止检测,咱们能够经过Thread.join()方法结束、Thread.isAlive()的返回值检测到线程已经终止执行。
  6. 线程中断规则(Thread Interruption Rule):对线程interrupte()方法的调用优先于被中断线程的代码检测到中断事件的发生,能够经过Thread.interrupted()方法检测线程是否已中断。
  7. 对象终结原则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
    假定线程A在执行的过程当中,经过制定ThreadB.join()等待线程B终止,那么线程B在终止以前对共享变量的修改在线程A等待返回后可见。
  8. 传递性(Transitivity):若是操做A先行发生于操做B,操做B先行发生于操做C,那就能够得出操做A先行发生于操做C的结论。
    体现了happens-before原则具备传递性

若是不符合以上规则,那么在多线程环境下就不能保证执行顺序等同于代码顺序,也就是“若是在本线程中观察,全部的操做都是有序的;若是在一个线程中观察另一个线程,则不符合以上规则的都是无序的”,所以,若是咱们的多线程程序依赖于代码书写顺序,那么就要考虑是否符合以上规则,若是不符合就要经过一些机制使其符合,最经常使用的就是synchronized、Lock以及volatile修饰符。

上面八条是原生Java知足Happens-before关系的规则,可是咱们能够对他们进行推导出其余知足happens-before的规则:

  1. 将一个元素放入一个线程安全的队列的操做Happens-Before从队列中取出这个元素的操做
  2. 将一个元素放入一个线程安全容器的操做Happens-Before从容器中取出这个元素的操做
  3. 在CountDownLatch上的倒数操做Happens-Before CountDownLatch#await()操做
  4. 释放Semaphore许可的操做Happens-Before得到许可操做
  5. Future表示的任务的全部操做Happens-Before Future#get()操做
  6. 向Executor提交一个Runnable或Callable的操做Happens-Before任务开始执行操做

happen-before原则是JMM中很是重要的原则,它是判断数据是否存在竞争、线程是否安全的主要依据,保证了多线程环境下的可见性。

tu

3. Volatile

volatile至关于synchronized的弱实现,相似于synchronized的语义,可是没有锁机制。在JDK及开源框架随处可见,可是在JDK6以后synchronized关键字性能被大幅优化以后,几乎没有使用了场景。

3.1. 语义

第一条语义:JMM不会对volatile指令的操做进行重排序。这个保证了对volatile变量的操做时按照指令的出现顺序执行的。

第二条语义是保证线程间变量的可见性,简单地说就是当线程A对变量X进行了修改后,在线程A后面执行的其余线程能看到变量X的变更,更详细地说是要符合如下两个规则:

  1. 线程对变量进行修改以后,要马上回写到主内存。
  2. 线程对变量读取的时候,要从主内存中读,而不是缓存。

虽然volatile字段保证了可见性,可是因为缺乏同步机制,因此volatile的字段的操做不是原子性的,并不能保证线程安全。

3.2. 应用原则

  1. volatile是在synchronized性能低下的时候提出的。现在synchronized的效率已经大幅提高,因此volatile存在的意义不大。
  2. 现在非volatile的共享变量,在访问不是超级频繁的状况下,已经和volatile修饰的变量有一样的效果了。
  3. volatile不能保证原子性,这点是你们没太搞清楚的,因此很容易出错。
  4. volatile能够禁止重排序

一般应用场景以下:

volatile boolean done = flase;
//...

while(!done){
   // ...
}

4. CAS

4.1. 锁机制存在的问题

  1. 在多线程竞争下,加锁、释放锁会致使比较多的上下文切换和调度延时,引发性能问题。
  2. 一个线程持有锁会致使其它全部须要此锁的线程挂起。
  3. 若是一个优先级高的线程等待一个优先级低的线程释放锁会致使优先级倒置,引发性能风险。

独占锁是一种悲观锁,synchronized就是一种独占锁,会致使其它全部须要锁的线程挂起,等待持有锁的线程释放锁。而另外一个更加有效的锁就是乐观锁。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操做,若是由于冲突失败就重试,直到成功为止。

4.2. 原理

CAS 操做包含三个操做数——内存位置(V)、预期原值(A)和新值(B)。若是内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。不然,处理器不作任何操做。不管哪一种状况,它都会在 CAS 指令以前返回该位置的值。(在 CAS 的一些特殊状况下将仅返回 CAS 是否成功,而不提取当前值。)CAS 有效地说明了“我认为位置 V 应该包含值 A;若是包含该值,则将 B 放到这个位置;不然,不要更改该位置,只告诉我这个位置如今的值便可。”

一般将 CAS 用于同步的方式是从地址 V 读取值 A,执行多步计算来得到新值 B,而后使用 CAS 将 V 的值从 A 改成 B。若是 V 处的值还没有同时更改,则 CAS 操做成功。

CAS其底层是经过CPU的1条指令来完成3个步骤,所以其自己是一个原子性操做,不存在其执行某一个步骤的时候而被中断的可能。

从性能角度考虑:
若是使用锁来进行并发控制,当某一个线程(T1)抢占到锁以后,那么其余线程再尝试去抢占锁时就会被挂起,当T1释放锁以后,下一个线程(T2)再抢占到锁后而且从新恢复到原来的状态大约须要通过8W个时钟周期。

假设咱们业务代码自己并不具有很复杂的操做,执行整个操做可能就花费3-10个时钟周期左右,那么当咱们使用无锁操做时,线程T1和线程T2对共享变量进行并发的CAS操做,假设T1成功了,T2最多再执行一次,它执行屡次的所消耗的时间远远小于因为线程所挂起到恢复所消耗的时间,所以无锁的CAS操做在性能上要比同步锁高不少。

示例代码:

public class SimulatedCAS {
     private int value;
 
     public synchronized int getValue() { return value; }
 
    public synchronized int compareAndSwap(int expectedValue, int newValue) {
         int oldValue = value;
         if (value == expectedValue)
             value = newValue;
         return oldValue;
     }
}

非阻塞算法:一个线程的失败或者挂起不该该影响其余线程的失败或挂起的算法。

基于CAS的并发算法称为非阻塞算法,CAS 操做成功仍是失败,在任何一种状况中,它都在可预知的时间内完成。若是 CAS 失败,调用者能够重试 CAS 操做或采起其余适合的操做。下面显示了从新编写的计数器类来使用 CAS 替代锁定:

public class CasCounter {
    private SimulatedCAS value;
    public int getValue() {
        return value.getValue();
    }
    public int increment() {
        int oldValue = value.getValue();
        while (value.compareAndSwap(oldValue, oldValue + 1) != oldValue)
            oldValue = value.getValue();
        return oldValue + 1;
    }
}

不管是直接的仍是间接的,几乎 java.util.concurrent 包中的全部类都使用原子变量,而不使用同步。相似 ConcurrentLinkedQueue 的类也使用原子变量直接实现无等待算法,而相似 ConcurrentHashMap 的类使用 ReentrantLock 在须要时进行锁定。而后, ReentrantLock 使用原子变量来维护等待锁定的线程队列。

4.3. 缺陷

CAS策略有以下须要注意的事项:

  1. 在线程抢占资源特别频繁的时候(相对于CPU执行效率而言),会形成长时间的自旋,耗费CPU性能。
  2. 有ABA问题(即在更新前的值是A,但在操做过程当中被其余线程更新为B,又更新为A),这时当前线程认为是能够执行的,实际上是发生了不一致现象,若是这种不一致对程序有影响(真正有这种影响的场景不多,除非是在变量操做过程当中以此变量为标识位作一些其余的事,好比初始化配置),则须要使用AtomicStampedReference(除了对更新前的原值进行比较,也须要用更新前的stamp标志位来进行比较)。
  3. 只能对一个变量进行原子性操做。若是须要把多个变量做为一个总体来作原子性操做,则应该使用AtomicReference来把这些变量放在一个对象里,针对这个对象作原子性操做。

5. 引用

  1. java的多线程机制引用
  2. 【死磕Java并发】-----Java内存模型之happens-before
  3. 深刻浅出 Java Concurrency (4): 原子操做 part 3 指令重排序与happens-before法则
  4. 流行的原子
相关文章
相关标签/搜索