Java多线程之原子操作

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虚拟机》 陶志华
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,383评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,522评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,852评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,621评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,741评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,929评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,076评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,803评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,265评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,582评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,716评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,395评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,039评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,027评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,488评论 2 361
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,612评论 2 350