美文网首页
Java多线程之原子操作

Java多线程之原子操作

作者: RantLing | 来源:发表于2019-06-20 19:53 被阅读0次

    1. 相关概念

    • 本地缓存:程序运行时,为了提高运行的速度,CPU可以不直接跟内存进行通信,而是先将内存中的数据读到内部缓存,然后再进行操作。这样会提高效率,但是我们不知道本地缓存中的修改何时会回写到共享内存中;
    • 内存可见性:可见性的意思是当一个线程修改了一个共享变量时,其他的线程能够到这个修改的值。(这句话我当初理解的有问题,导致我在修改的代码的时候掉进一个很大的坑里) ;
    • 共享内存:JVM在执行Java程序时会 把它管理的内存划分成几个不同的数据区域。这些区域都有着各自的用途,以及创建和销毁的时间。具体其中有部分数据区域是所有线程共享的,这部分数据区域称之为共享内存,共享内存中的变量叫做共享变量。
      javaRunTimeDataArea(图片来自网络)
      其中是最大的一块共享内存,也是垃圾收集器重点管理的区域。基本上我们所有的实例对象都是在堆上创建的。而方法区主要是用来存储加载过的类信息、常量和静态变量等。内存可见性就是针对于共享内存而言的。
    • 缓存一致性协议:简单来说,就是在多核CPU中一个共享变量被其中一个线程修改且回写到内存中之后,其他的CPU中缓存的这个共享变量就会被置为invalid。在下次读的时候就会更新这个缓存。
    • 原子操作:不可被中断的一个或一系列操作。

    2. 内存可见性的相关实现

    2.1 volatile

    volatile相较于synchronized而言,使用的代价和成本更低,因为它不会因为线程上下文的切换和调度。

    volatile的实现原理

    当我们使用volatile关键字修饰一个变量之后,在程序运行时,它会导致以下两件事:

    1. 本地缓存的数据会立马回写到共享内存中;
    2. 这个回写会导致其他CPU缓存中这个内存地址对应的数据无效,在下次读的时候就需要更新本地缓存。

    volatile的内存语义

    使用volatile 修饰变量,实际是就对变量的单个读/单个写做了同步。相信很多人都知道volatile++不是原子操作。下面我们通过一些等效代码来分析原因。

    单个读写
    首先我们来理解一下单个读写是什么意思。

    public class AtomicVariable {
      volatile int v;
    
      //单次读
      public int getA() {
        return v;
      }
    
      //单次写
      public void setA(int v) {
        this.v = v;
      }
    }
    

    这里的代码等效于:

    public class AtomicVariable {
      volatile int v;
    
      //单次读
      public synchronized int getA() {
        return v;
      }
    
      //单次写
      public synchronized void setA(int v) {
        this.v = v;
      }
    }
    

    通过以上的代码我们可以看出来,针对于volatile修饰的变量,单次的读写,其原子性是可以得到保证的。也就是说,在对于单个线程而言,当读volatile变量时,这个变量肯定是最近修改的值。在写这个变量时,它可以保证下次线程读的这个值是最新的。那对于volatile++的操作为什么不行呢? 首先我们得明白volatile++是一个复合操作,它可以转换成volatile = volatile + 1。即先读了volatile,然后进行加1操作,在写给volatile。这里只有第一步和第三步可以保证原子性,而第二步做不到。我们可以通过代码展示一下。

    public class AtomicVariableTest {
      public volatile int a = 0;
    
      @Test
      public void atomicOperation() throws InterruptedException {
    
        List<Thread> threads = new ArrayList<Thread>();
    
        for (int i = 0; i < 4; i++) {
          threads.add(new Thread(() -> {
            for (int j = 0; j < 100000; j++) {
              int tmp = a + 1;
              a = tmp;
            }
          }));
        }
    
        for(Thread t: threads){
          t.start();
        }
    
        for(Thread t: threads){
          t.join();
        }
      }
    }
    
    399748
    399749
    399750
    399751
    399752
    399753
    399754
    399755
    399756
    399757
    

    从结果来看,这里的读写操作并没有得到同步。

    2.2 Synchronized

    synchronized主要是通过锁来实现同步的,而在java中每一个多对象都可以作为锁。具体的表现有以下几种形式:

    • 对于普通同步方法,锁是当前实例对象;
      public synchronized void set(int v) {
                ...
      }
    
    • 对于静态同步方法,锁是当前类的Class对象;
      public synchronized  static void staticMethod(){
        ...
      }
    
    • 对于同步方法块, 所以Synchronized括号里配置的对象。
    Object ob = new Object();
    synchronized(ob){
         ....
    }
    

    当一个线程试图运行同步代码块时,它必须先获得锁。在同步代码块执行完之后或抛出异常之后,它必须释放锁。
    synchronized用的锁是存储在对象头中的,而锁的状态又分为以下几种:

    1. 重量级锁状态
    2. 轻量级锁状态
    3. 偏向锁状态
    4. 无锁状态

    锁的级别依次递减,锁可以升级但是不可以降级。关于这几种状态,与本文主题没有太大关联,感兴趣的小伙伴可以再去了解。
    synchronized可以保证同步方法或同步代码块一次只能由一个线程访问执行,同时能够保证对共享变量操作的可见性。更改上面的代码:

    public class AtomicVariableTest {
      public  int a = 0;
    
      @Test
      public void atomicOperation() throws InterruptedException {
    
        List<Thread> threads = new ArrayList<Thread>();
    
        for (int i = 0; i < 4; i++) {
    
          threads.add(new Thread(() -> {
            for (int j = 0; j < 100000; j++) {
              increment();
              System.out.println(Thread.currentThread().getName() + "  " + a);
            }
          }));
        }
    
        for(Thread t: threads){
          t.start();
        }
    
        for(Thread t: threads){
          t.join();
        }
      }
    
      public synchronized void increment(){
        a++;
      }
    }
    

    这里的代码便得到了同步,结果为400000.

    小结:上面大概说了一下volatile与sychronized,现在做一下总结。首先,相同点大致有两点

    1. 二者都可以保证可见性,即当前线程的修改,对其他线程下次的读写可见;
    2. 二者对指令的重排序都有一定的限制。

    而不同点则有以下几个方面:

    1. sychronized 可以做到同步(操作的原子性),而仅靠volatile做不到;
    2. sychronized 可以修饰代码块或者方法,而volatile只能修饰单个变量;
    3. volatile不会造成线程阻塞,而synchronzied会。

    3. 原子操作的实现原理

    CPU实现原子操作主要有两种方式:
    1. 总线加锁
    2. 缓存加锁

    3.1 使用总线锁保证原子性

    简单的模型图.png
    我们通过上面的图理解一下总线锁是如何实现原子性的。假设现在共享内存中有一个值a = 1,CPU1到CPU3都要执行int tmp =a + 1; a = tmp;这样的操作。那么共享内存中的a就会被多个处理器同时进行操作。这样的多改写操作就不是原子性的,操作完之后的值可能不是我们想要的。原因很简单,多个CPU都从自己的本地缓存中读取了变量a,然后进行改写操作,而本地缓存中的值可能还是旧值。改写完之后再分别回写,那么内存中值就是最后一次写的。
    而总线锁就是保证了一个CPU在对共享内存中的变量进行读改写操作时,其他的CPU不能操作缓存了该共享变量内存地址的缓存。上列中,就是一个CPU对共享变量a进行了读改写操作,其他的CPU就不能操作本地缓存中的a。
    总线锁就是在CPU1对变量a进行读改写操作时,会在总线输出一个LOCK #信号,之后共享内存就会被CPU1独占,其他处理器的总线请求都会被阻塞。这样子就可以保证对变量的读改写操作是原子性的了。但是使用这种方式会导致对于部分变量的读改写,会让其他CPU无法读写共享内存中的其他变量,开销过大。

    3.2 使用缓存锁来保证原子性

    其实对于一些频繁操作的内存,各个CPU会将其缓存在本地缓存中。那么对于这些内存或变量,我们只要本证他的读改写在各个CPU的缓存之间是原子性的即可,并不需要声明总线锁,带来过大的开销。
    缓存锁是通过内存一致性协议来实现的,即当某个CPU修改了共享内存中个某个变量并回写之后,其他CPU会让缓存了这块内存数据的缓存失效,在下次读的时候就是最新的值了。

    4. Java实现原子操作

    所谓原子操作就是指一个线程在执行一系列操作的时候,需要保证其使用的共享变量不会给其他的线程读改写。Java实现原子操作的方式有两种,一个是使用,另一个是使用循环CAS

    4.1 使用循环CAS来实现原子操作

    Java的中的CAS(Compare And Swap)操作可以保证操作的原子性。CAS操作就是用期望值跟旧值进行比较,如果相同则会将旧值更新成新值,且保证这一过程的原子性。根据这一特性,Java可以实现操作的原子性。且看先下面的Demo:

     public class CommonTest {
    
      private AtomicInteger a = new AtomicInteger(0);
    
      @Test
      public void test() throws InterruptedException {
    
        List<Thread> threads = new ArrayList<Thread>();
    
        for (int i = 0; i < 3; i++) {
          threads.add(new Thread(new CounterRunnable()));
        }
    
        for (Thread t : threads) {
          t.start();
        }
    
        for (Thread t : threads) {
          t.join();
        }
      }
    
      class CounterRunnable implements Runnable {
        @Override
        public void run() {
          for (int i = 0; i < 100000; i++) {
            increment();
          }
        }
    
        private void increment() {
          while (true) {
            int i = a.get();
            boolean changed = a.compareAndSet(i, ++i);
            if (changed) {
              System.out.println(a.get());
              break;
            }
          }
        }
      }
    }
    
    输出结果为300000
    

    相较于锁,CAS可以高效地实现原子操作,但是在使用它的时候也得注意一些可能遇到的问题。

    CAS 实现原子操作可能遇到的三大问题

    1. ABA问题:ABA问题就是当状态连续改变之后,CPU可能感知不到,例如变量从A到B,然后再从B变成A。这个时候,实际上变量是改变过了,但是CAS进行检查的时候会发现他的值并没有改变。
    2. 循环开销过大:在上面的代码样例中,如果一直比较失败的话,线程会一直在那里执行,资源也不会释放,这样的开销是比较大的;
    3. 只能保证一个变量的原子操作:由CAS的使用方式,我们可以看出来CAS只能对单个变量进行原子性的操作。

    4.2 通过锁实现原子操作

    Java锁机制保证了只有获得了锁的线程才能执行锁定的内存区域,同时能保证操作的原子性。但是相比较与CAS而言,一般情况下他的开销更大。使用时需要慎重。


    本文思维导图

    参考:

    • 《Java 并发编程的艺术》 方腾飞,魏鹏,程晓明
    • 《深入理解Java虚拟机》 陶志华

    相关文章

      网友评论

          本文标题:Java多线程之原子操作

          本文链接:https://www.haomeiwen.com/subject/olhfqctx.html