前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >【小知识大道理】i++是原子运算么

【小知识大道理】i++是原子运算么

原创
作者头像
曲水流觞
修改2019-11-05 16:54:04
4920
修改2019-11-05 16:54:04
举报
文章被收录于专栏:曲水流觞TechRill曲水流觞TechRill

前言: 前端时间写文章经常是想到哪儿写到哪儿,随心所欲,狂放不羁爱自由... 某日灵光一现,何不以一些日常不怎么起眼的知识点作为入口,慢慢延伸,直至把背后的知识体系整个拎起来串联一体,应该能达到不错的学习效果(这个很考验知识厚度,不晓得自己还能拎起来多少 >_<)。 此文作为“小知识大道理”系列的开山篇,现在就ROLL起来。

小知识:在并发场景下 i++ 这个自增单运算符计算,是一个原子操作么? 首先我们知道 i++ 等同于 i = i + 1, 就这么个貌似简单的加法运算到底是不是原子的呢?话不多说,直接上代码看结果。

1 代码验证

示例代码运行10次,每次都会启动1000个线程来并发计算i++,最后的结果竟然都不是1000!(当然也有概率正好返回1000)

public class AtomicDemo {

    public static CountDownLatch latch;
    public static int i = 0;

    public static void increase() {
        //这里延迟1毫秒,增加线程切换的随机性,也可以不加
        try {
            Thread.sleep(1);
        } catch (Exception e) {
        }

        i++;
    }

    public static void multiThread(int threadCnt) {
        latch = new CountDownLatch(threadCnt);

        for (int i = 0; i < threadCnt; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    AtomicDemo.increase();
                    latch.countDown();
                }
            }).start();
        }

        try {
            latch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {

        int threadCnt = 1000;
        int times = 10;

        System.out.println(threadCnt + "个线程并发计算i++:");
        //运行多次观察不同
        for ( int x = 0; x < times; x++) {
            //初始化变量
            i = 0;
            //同时启动 1000 个线程,并发计算i++
            multiThread(threadCnt);
            System.out.println("运行第 " + (x+1 < 10 ? "0":"") + (x+1) + " 次的结果: i=" + AtomicDemo.i);
        }

    }
}

1000个线程并发计算i++: 运行第 01 次的结果: i=988 运行第 02 次的结果: i=983 运行第 03 次的结果: i=986 运行第 04 次的结果: i=993 运行第 05 次的结果: i=999 运行第 06 次的结果: i=995 运行第 07 次的结果: i=987 运行第 08 次的结果: i=998 运行第 09 次的结果: i=997 运行第 10 次的结果: i=999

运行结果直接证明 i++不是原子操作。究其原因到底为何,且听我娓娓道来:揪起第一个知识点,JAVA基本的内存模型抽象。

2 Java Memory Model

JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory,也有人称之为工作内存),本地内存中存储了该线程以读/写共享变量的副本。其注意本地内存只是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java内存模型的抽象示意图如下:

从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:

  1. 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
  2. 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。

下面通过示意图来说明这两个步骤:

如上图所示,本地内存A和B有主内存中共享变量X的副本。假设初始时,这三个内存中的X值都为0。线程A在执行时,把更新后的X值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的X值刷新到主内存中,此时主内存中的X值变为了1。随后,线程B到主内存中去读取线程A更新后的X值,此时线程B的本地内存的X值也变为了1。

表面上线程A与线程B的消息通信,本质上必须要经过主内存来实现。JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。

3 内存可见性 Memory Visibility

其实上文没有提到一个漏洞,那就是如果线程A在本地内存中修改了共享变量X=1,但并没有及时把更新后的值刷入到主内存中,那么此时线程B从主内存读取的共享变量X的值将会是原始值0,那么我们就说对于线程B来讲,共享变量X的更改对线程B是不可见的。如果共享变量的更新不可见,后果可想而知,线程B会覆盖线程A的更新,从而导致数据不一致的情况。

如何解决呢?内存可见性在Java中就有个无法避开不得不提的关键字:volatile。这个单词的中文释义是挥发性的,不稳定的,在Java里它就是用来保证可见性而存在的。专家建议慎用这个关键字,因为很多人不理解的话会把它与synchronized搞混,以为就是个锁用来满足并发锁的,其实并不然。

结合上面说的漏洞,如果我们用 volatile 来修饰变量X,那么线程B在读取X的值时,就必然会读到线程A修改的最新值。volatile 使编译器与运行时都会注意到这个变量是共享的,volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。另外,在访问 volatile 变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。

如上图使用 volatile 修饰的count变量计数,JVM虚拟机只是保证从主内存加载到线程工作内存(也就是read+load两步)的值是最新的。而一旦当前线程对工作内存的volatile变量进行了修改(assign),必然会在后续其他线程read count变量之前写回到主内存中。

但万万不可把volatile等同于原子操作,比如两个线程AB同时read主内存中count值=8,并同时load到工作内存中; 这时线程A先use原始值8加1变成了9,assgin给count变量并store回工作内存; 随后将最新值9 write到主内存中,主内存中的变量值也就成了9。而线程B由于已经进行read,load操作, 在线程B的工作内存中count=8,假设线程B进行了减运算将8-1=7,写回到主内存后count的值变成7,覆盖了线程A之前的运算。所以千万请不要把volatile当成同步的原子操作。

总结下,volatile有两个语义:1)保证线程间变量的可见性 2)禁止指令重排序。第二点后文再展开。volatile的原理可以简单理解为每次访问变量时都会进行一次刷新,因此每次访问都是主内存中最新的版本。

面对多线程,我们常常会提到并发、并行、同步或者锁等术语,中文博大精深貌似相同的单词,其实英文完全不同,并发=Concurrency, 并行=Parallelism, 同步=Synchronization。具体解释如下:

如果某个系统支持两个或者多个动作(Action)同时存在,那么这个系统就是一个并发系统。如果某个系统支持两个或者多个动作同时执行,那么这个系统就是一个并行系统。在并发程序中可以同时拥有两个或者多个线程。这意味着,如果程序在单核处理器上运行,那么这两个线程将交替地换入或者换出内存。每个线程都处于执行过程中的某个状态,这是并发。如果程序能够并行执行,那么就一定是运行在多核处理器上。

在同一时间间隔内有多个线程在同时执行,就是线程并发。多个线程在逻辑上互有前因后果的关系,所以要对他们的执行顺序进行控制和协调,这就是线程同步。系统为了提高性能和吞吐量,采用了多线程并发来解决,但同时也引入了线程同步的问题。可以这样理解线程并发和同步的因果关系。

关于锁请参见我之前的一篇文章《理解锁以及分布式锁》

理解了上面的几个概念,我们再特别说下同步这件事。

4 线程同步 Synchronization

我们先看下Oracle官方对于同步的解释: 同步是基于一个内部实体实现,它又称为内部锁或者监视锁(API说明书经常简单地视为监视)。内部锁在同步场景扮演了两种角色:1)强制对对象状态的排他访问 2)建立必需可见的happens-before关系。

https://docs.oracle.com/javase/tutorial/essential/concurrency/locksync.html

Synchronization is built around an internal entity known as the intrinsic lock or monitor lock. (The API specification often refers to this entity simply as a "monitor.") Intrinsic locks play a role in both aspects of synchronization: enforcing exclusive access to an object's state and establishing happens-beforerelationships that are essential to visibility.

** 排他性或者说独占性比较好理解,我们经常用的各种锁就是用来实现排他的。而从线程运行完毕释放锁,到后续任何对这个锁的获取,期间做的事情就是建立happens-before关系。

字面上这个术语比较好理解,因为你要获取锁必需等待当前的拥有者释放锁,那么释放锁必然发生(happen)在获取锁之前(before)。在深入解释它之前我们继续拎一个概念: 指令重排序

5 指令重排序 Instruction Reordering

重排序这个概念在volatile的第二语义提到过,同步的第二个目标也说过。定义很简单,编译器或者运行时环境为了优化程序性能,在保证执行结果不变的前提下采取的对指令进行重新排序执行的手段。主要分为三类:

1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。 2)处理器可以乱序或者并行的执行指令。 3)缓存会改变写入提交到主内存的变量的次序。

JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

看下方代码,方法体中两个变量的定义,第二行布尔变量 bExit 赋值的指令一定是运行在第一行的整型变量 x 赋值之后么? 答案是不一定。因为这两条指令没有明显的数据依赖关系(或者说happens-before关系)。

1 int x = 0; 
2 boolean bExit = false;

再贴一个面试题,初始化两个变量,没有volatile修饰;两个没有同步的线程分别修改变量和打印变量。请问线程2有可能打印出 x=0 么?

int x = 0;
boolean bExit = false;

Thread 1 (not synchronized)
x = 1; 
bExit = true;

Thread 2 (not synchronized)
if (bExit == true) 
System.out.println("x=" + x);

从指令重排序的角度理解这个问题,线程1完全可能因为重排序优先运行了bExit=true,而在执行x=1之前线程2获取到了bExit的最新值true,并打印了x的初始值0。 这个重排序的触发没有发现万全的手段可以重现,所以该部分我们只说下理论。 **

6 Happens-before

从JDK5开始,JAVA使用新的JSR -133内存模型。JSR-133提出了happens-before的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。 与程序员密切相关的happens-before规则如下:

  1. 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
  2. 监视器锁规则:对一个监视器锁的解锁,happens-before 于随后对这个监视器锁的加锁。
  3. volatile变量规则:对一个volatile域的写,happens-before 于任意后续对这个volatile域的读。
  4. 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

234点比较好理解,但对于第1点可能大家会有疑问,一个线程中的每个操作指令明明可能会被重排序,两个说法不是矛盾了?其实happens-before关系并不等同于说前一个操作必须要在后一个操作之前执行,而是指前一个操作的执行结果必须对后一个操作可见,如果不满足这个要求那就不允许这两个操作进行重排序。对照上文示例代码来说,x和bExit之间没有结果可见的必要性,或者说数据依赖,编译器完全可以重排序;但如果定义成 int x=0; int y=x+1; 产生了数据依赖,或者说变量y依赖于x的赋值结果可见,那就不允许重排序。

这里大家特别要注意一下,重排序指的是同一线程内的指令排序,而happens-before规则是针对一个或者多个线程之间的内存可见性顺序,千万不要把happens-before简单的视作指令执行顺序。 ** 回到上文的volatile第二语义“禁止指令重排序”,它是怎么做到的呢?拎出今天的最后一个概念内存屏障。

7 内存屏障/栅栏 Memory barrier/fence

首先,内存屏障是个CPU指令,它是这样一条指令: a)确保一些特定操作执行的顺序; b)影响一些数据的可见性(可能是某些指令执行后的结果)。前文我们提到编译器和CPU可以在保证输出结果一样的情况下对指令重排序,使性能得到优化。而插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。

内存屏障有两个作用:

  1. 阻止屏障两边的指令重排序
  2. 强制把写缓冲区/高速缓存中(对标上文的本地内存或者工作内存)的脏数据等写回主内存,让缓存中相应的数据失效。

对读屏障Load Barrier来说,在读指令之前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据;对写屏障Store Barrier来说,在写指令****之后插入写屏障,能让写入缓存的最新数据写回到主内存。

Java内存模型中volatile变量就是通过在写操作之后会插入一个store屏障,在读操作之前会插入一个load屏障,来实现的“禁止指令重排序”。

8 总结

本文我们涵盖了Java内存模型,重排序,内存屏障等概念,抽丝剥茧以期让读者对整个知识链条有个整体认知,后续的深入还是要靠个人去挖掘。

归纳总结下,代码中看似简单的运算背后隐藏着不简单的各个计算机组件的通信、计算或存储操作,而程序里编写的代码顺序也不一定是真正的执行顺序。只有理解它们背后的基本原理,才有可能成长为真正的专家。

本文的内容组织和编排谈不上是否科学合理,还是比较随意,如有任何建议或疑问,欢迎留言。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1 代码验证
  • 2 Java Memory Model
  • 3 内存可见性 Memory Visibility
  • 4 线程同步 Synchronization
  • 5 指令重排序 Instruction Reordering
  • 6 Happens-before
  • 7 内存屏障/栅栏 Memory barrier/fence
  • 8 总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档