乐趣区

关于java:Java高并发学习笔记四volatile关键字

1 起源

  • 起源:《Java 高并发编程详解 多线程与架构设计》,汪文君著
  • 章节:第十二、十三章

本文是两章的笔记整顿。

2 CPU缓存

2.1 缓存模型

计算机中的所有运算操作都是由 CPU 实现的,CPU指令执行过程须要波及数据读取和写入操作,然而 CPU 只能拜访处于内存中的数据,而内存的速度和 CPU 的速度是远远不对等的,因而就呈现了缓存模型,也就是在 CPU 和内存之间退出了缓存层。个别古代的 CPU 缓存层分为三级,别离叫 L1 缓存、L2缓存和 L3 缓存,简略图如下:

  • L1缓存:三级缓存中访问速度最快,然而容量最小,另外 L1 缓存还被划分成了数据缓存(L1ddata首字母)和指令缓存(L1iinstruction首字母)
  • L2缓存:速度比 L1 慢,然而容量比 L1 大,在古代的多核 CPU 中,L2个别被单个核独占
  • L3缓存:三级缓存中速度最慢,然而容量最大,古代 CPU 中也有 L3 是多核共享的设计,比方 zen3 架构的设计

缓存的呈现,是为了解决 CPU 间接拜访内存效率低下的问题,CPU进行运算的时候,将须要的数据从主存复制一份到缓存中,因为缓存的访问速度快于内存,在计算的时候只须要读取缓存并将后果更新到缓存,运算完结再将后果刷新到主存,这样就大大提高了计算效率,整体交互图简略如下:

2.2 缓存一致性问题

尽管缓存的呈现,大大提高了吞吐能力,然而,也引入了一个新的问题,就是缓存不统一。比方,最简略的一个 i++ 操作,须要将内存数据复制一份到缓存中,CPU读取缓存值并进行更新,先写入缓存,运算完结后再将缓存中新的刷新到内存,具体过程如下:

  • 读取内存中的 i 到缓存中
  • CPU读取缓存 i 中的值
  • i 进行加 1 操作
  • 将后果写回缓存
  • 再将数据刷新到主存

这样的 i++ 操作在单线程不会呈现问题,但在多线程中,因为每个线程都有本人的工作内存(也叫本地内存,是线程本人的缓存),变量 i 在多个线程的本地内存中都存在一个正本,如果有两个线程执行 i++ 操作:

  • 假如两个线程为 A、B,同时假如 i 初始值为 0
  • 线程 A 从内存中读取 i 的值放入缓存中,此时 i 的值为 0,线程 B 也同理,放入缓存中的值也是 0
  • 两个线程同时进行自增操作,此时 A、B 线程的缓存中,i的值都是 1
  • 两个线程将 i 写入主内存,相当于 i 被两次赋值为 1
  • 最终后果是 i 的值为 1

这个就是典型的缓存不统一问题,支流的解决办法有:

  • 总线加锁
  • 缓存一致性协定

2.2.1 总线加锁

这是一种乐观的实现形式,具体来说,就是通过处理器收回 lock 指令,锁住总线,总线收到指令后,会阻塞其余处理器的申请,直到占用锁的处理器实现操作。特点是只有一个抢到总线锁的处理器运行,然而这种形式效率低下,一旦某个处理器获取到锁其余处理器只能阻塞期待,会影响多核处理器的性能。

2.2.2 缓存一致性协定

图示如下:

缓存一致性协定中最闻名的就是 MESI 协定,MESI保障了每一个缓存中应用的共享变量的正本都是统一的。大抵思维是,CPU操作缓存中的数据时,如果发现该变量是一个共享变量,操作如下:

  • 读取:不做其余解决,只是将缓存中数据读取到寄存器中
  • 写入:发出信号告诉其余 CPU 将该变量的缓存行设置为有效状态(Invalid),其余 CPU 进行该变量的读取时须要到主存中再次获取

具体来说,MESI中规定了缓存行应用 4 种状态标记:

  • MModified,被批改
  • EExclusive,独享的
  • SShared,共享的
  • IInvalid,有效的

无关 MESI 具体的实现超出了本文的范畴,想要具体理解能够参考此处或此处。

3 JMM

看完了 CPU 缓存再来看一下 JMM,也就是Java 内存模型,指定了 JVM 如何与计算机的主存进行工作,同时也决定了一个线程对共享变量的写入何时对其余线程可见,JMM定义了线程和主内存之间的形象关系,具体如下:

  • 共享变量存储于主内存中,每个线程都能够拜访
  • 每个线程都有公有的工作内存或者叫本地内存
  • 工作内存只存储该线程对共享变量的正本
  • 线程不能间接操作主内存,只有先操作了工作内存之后能力写入主内存
  • 工作内存和 JMM 内存模型一样也是一个抽象概念,其实并不存在,涵盖了缓存、寄存器、编译期优化以及硬件等

简略图如下:

MESI 相似,如果一个线程批改了共享变量,刷新到主内存后,其余线程读取工作内存的时候发现缓存生效,会从主内存再次读取到工作内存中。

而下图示意了 JVM 与计算机硬件调配的关系:

4 并发编程的三个个性

文章都看了大半了还没到 volatile?别急别急,先来看看并发编程中的三个重要个性,这对正确理解volatile 有很大的帮忙。

4.1 原子性

原子性就是在一次或屡次操作中:

  • 要么所有的操作全副都失去了执行,且不会受到任何因素的烦扰而中断
  • 要么所有的操作都不执行

一个典型的例子就是两个人转账,比方 A 向 B 转账 1000 元,那么这蕴含两个根本的操作:

  • A 的账户扣除 1000 元
  • B 的账户减少 1000 元

这两个操作,要么都胜利,要么都失败,也就是不能呈现 A 账户扣除 1000 然而 B 账户金额不变的状况,也不能呈现 A 账户金额不变 B 账户减少 1000 的状况。

须要留神的是两个原子性操作联合在一起未必是原子性的,比方 i++。实质上来说,i++ 波及到了三个操作:

  • get i
  • i+1
  • set i

这三个操作都是原子性的,然而组合在一起(i++)就不是原子性的。

4.2 可见性

另一个重要的个性是可见性,可见性是指,一个线程对共享变量进行了批改,那么另外的线程能够立刻看到批改后的最新值。

一个简略的例子如下:

public class Main {
    private int x = 0;
    private static final int MAX = 100000;
    public static void main(String[] args) throws InterruptedException {Main m = new Main();
        Thread thread0 = new Thread(()->{while(m.x < MAX) {++m.x;}
        });

        Thread thread1 = new Thread(()->{while(m.x < MAX){ }
            System.out.println("finish");
        });

        thread1.start();
        TimeUnit.MILLISECONDS.sleep(1);
        thread0.start();}
}

线程 thread1 会始终运行,因为 thread1x读入工作内存后,会始终判断工作内存中的值,因为 thread0 扭转的是 thread0 工作内存的值,并没有对 thread1 可见,因而永远也不会输入 finish,应用jstack 也能够看到后果:

4.3 有序性

有序性是指代码在执行过程中的先后顺序,因为 JVM 的优化,导致了代码的编写程序未必是代码的运行程序,比方上面的四条语句:

int x = 10;
int y = 0;
x++;
y = 20;

有可能 y=20x++前执行,这就是指令重排序。一般来说,处理器为了进步程序的效率,可能会对输出的代码指令做肯定的优化,不会严格依照编写程序去执行代码,但能够保障最终运算后果是编码时的冀望后果,当然,重排序也有肯定的规定,须要严格遵守指令之间的数据依赖关系,并不是能够任意重排序,比方:

int x = 10;
int y = 0;
x++;
y = x+1;

y=x+1就不能先优于 x++ 执行。

在单线程下重排序不会导致预期值的扭转,但在多线程下,如果有序性得不到保障,那么将可能呈现很大的问题:

private boolean initialized = false;
private Context context;
public Context load(){if(!initialized){context = loadContext();
        initialized = true;
    }
    return context;
}

如果产生了重排序,initialized=true排序到了 context=loadContext() 的后面,假如两个线程 A、B 同时拜访,且 loadContext() 须要肯定耗时,那么:

  • 线程 A 通过判断后,先设置布尔变量的值为 true,再进行loadContext() 操作
  • 线程 B 中因为布尔变量被设置为true,会间接返回一个未加载实现的context

5 volatile

好了终于到了 volatile 了,后面说了这么多,目标就是为了能彻底了解和明确volatile。这部分分为四个大节:

  • volatile的语义
  • 如何保障有序性以及可见性
  • 实现原理
  • 应用场景
  • synchronized 区别

先来介绍一下 volatile 的语义。

5.1 语义

volatile 润饰的实例变量或者类变量具备两层语义:

  • 保障了不同线程之间对共享变量操作时的可见性
  • 禁止对指令进行重排序操作

5.2 如何保障可见性以及有序性

先说论断:

  • volatile能保障可见性
  • volatile能保障有序性
  • volatile不能保障原子性

上面别离进行介绍。

5.2.1 可见性

Java中保障可见性有如下形式:

  • volatile:当一个变量被 volatile 润饰时,对共享资源的读操作会间接在主内存中进行(精确来说也会读取到工作内存中,然而如果其余线程进行了批改就必须从主内存从新读取),写操作是先批改工作内存,然而批改完结后立刻刷新到主内存中
  • synchronizedsynchronized一样能保障可见性,可能保障同一时刻只有一个线程获取到锁,而后执行同步办法,并且确保锁开释之前,变量的批改被刷新到主内存中
  • 应用显式锁 LockLocklock办法能保障同一时刻只有一个线程可能获取到锁而后执行同步办法,并且确保锁开释之前可能将对变量的批改刷新到主内存中

具体来说,能够看一下之前的例子:

public class Main {
    private int x = 0;
    private static final int MAX = 100000;
    public static void main(String[] args) throws InterruptedException {Main m = new Main();
        Thread thread0 = new Thread(()->{while(m.x < MAX) {++m.x;}
        });

        Thread thread1 = new Thread(()->{while(m.x < MAX){ }
            System.out.println("finish");
        });

        thread1.start();
        TimeUnit.MILLISECONDS.sleep(1);
        thread0.start();}
}

下面说过这段代码会一直运行,始终没有输入,就是因为批改后的 x 对线程 thread1 不可见,如果在 x 的定义中加上了 volatile,就不会呈现没有输入的状况了,因为此时对x 的批改是线程 thread1 可见的。

5.2.2 有序性

JMM中容许编译期和处理器对指令进行重排序,在多线程的状况下有可能会呈现问题,为此,Java同样提供了三种机制去保障有序性:

  • volatile
  • synchronized
  • 显式锁Lock

另外,对于有序性不得不提的就是 Happens-before 准则。Happends-before准则说的就是如果两个操作的执行秩序无奈从该准则推导进去,那么就无奈保障有序性,JVM或处理器能够任意重排序。这么做的目标是为了尽可能进步程序的并行度,具体规定如下:

  • 程序秩序规定:在一个线程内,代码依照编写时的秩序执行,编写在前面的操作产生与编写在后面的操作之后
  • 锁定规定:如果一个锁处于锁定状态,则 unlock 操作要后行产生于对同一个锁的 lock 操作
  • volatile变量规定:对一个变量的写操作要早于对这个变量之后的读操作
  • 传递规定:如果操作 A 先于操作 B,操作 B 先于操作 C,那么操作 A 先于操作 C
  • 线程启动规定:Thread对象的 start() 办法后行产生于对该线程的任何动作
  • 线程中断规定:对线程执行 interrupt() 办法必定要优于捕捉到中断信号,换句话说,如果收到了中断信号,那么在此之前必然调用了interrupt()
  • 线程终结规定:线程中所有操作都要后行产生于线程的终止检测,也就是逻辑单元的执行必定要产生于线程终止之前
  • 对象终结规定:一个对象初始化的实现后行产生于 finalize() 之前

对于 volatile,会间接禁止对指令重排,然而对于volatile 前后无依赖关系的指令能够随便重排,比方:

int x = 0;
int y = 1;
//private volatile int z;
z = 20;
x++;
y--;

z=20 之前,先定义 x 或先定义 y 并没有要求,只须要在执行 z=20 的时候,能够保障 x=0,y=1 即可,同理,x++y-- 具体先执行哪一个并没有要求,只须要保障两者执行在 z=20 之后即可。

5.2.3 原子性

Java 中,所有对根本数据类型变量的读取赋值操作都是原子性的,对援用类型的变量读取和赋值也是原子性的,然而:

  • 将一个变量赋值给另一个变量的操作不是原子性的,因为波及到了一个变量的读取以及一个变量的写入,两个原子性操作联合在一起就不是原子性操作
  • 多个原子性操作在一起就不是原子性操作,比方i++
  • JMM只保障根本读取和赋值的原子性操作,其余的均不保障,如果须要具备原子性,那么能够应用 synchronizedLock,或者 JUC 包下的原子操作类

也就是说,volatile并不能保障原子性,例子如下:

public class Main {
    private volatile int x = 0;
    private static final CountDownLatch latch = new CountDownLatch(10);

    public void inc() {++x;}

    public static void main(String[] args) throws InterruptedException {Main m = new Main();
        IntStream.range(0, 10).forEach(i -> {new Thread(() -> {for (int j = 0; j < 1000; j++) {m.inc();
                }
                latch.countDown();}).start();});
        latch.await();
        System.out.println(m.x);
    }
}

最初输入的 x 的值会少于10000,而且每次运行的后果也并不相同,至于起因,能够从两个线程 A、B 开始剖析,图示如下:

  • 0-t1:线程 A 将 x 读入工作内存,此时x=0
  • t1-t2:线程 A 工夫片完,CPU调度线程 B,线程 B 将 x 读入工作内存,此时x=0
  • t2-t3:线程 B 对工作内存中的 x 进行自增操作,并更新到工作内存中
  • t3-t4:线程 B 工夫片完,CPU调度线程 A,同理线程 A 对工作内存中的 x 自增
  • t4-t5:线程 A 将工作内存中的值写回主内存,此时主内存中的值为x=1
  • t5当前:线程 A 工夫片完,CPU调度线程 B,线程 B 也将本人的工作内存写回主内存,再次将主内存中的 x 赋值为 1

也就是说,多线程操作的话,会呈现两次自增然而实际上只进行一次数值批改的操作。想要 x 的值变为 10000 也很简略,加上 synchronized 即可:

new Thread(() -> {synchronized (m) {for (int j = 0; j < 1000; j++) {m.inc();
        }
    }
    latch.countDown();}).start();

5.3 实现原理

后面曾经晓得,volatile能够保障有序性以及可见性,那么,具体是如何操作的呢?

答案就是一个 lock; 前缀,该前缀实际上相当于一个内存屏障,该内存屏障会为指令的执行提供如下几个保障:

  • 确保指令重排序时不会将其前面的代码排到内存屏障之前
  • 确保指令重排序时不会将其后面的代码排到内存屏障之后
  • 确保执行到内存屏障润饰的指令时后面的代码全副执行实现
  • 强制将线程工作内存中的值批改刷新到主存中
  • 如果是写操作,会导致其余线程工作内存中的缓存数据生效

5.4 应用场景

一个典型的应用场景是利用开关进行线程的敞开操作,例子如下:

public class ThreadTest extends Thread{
    private volatile boolean started = true;

    @Override
    public void run() {while (started){}}

    public void shutdown(){this.started = false;}
}

如果布尔变量没有被 volatile 润饰,那么很可能新的布尔值刷新不到主内存中,导致线程不会完结。

5.5 与 synchronized 的区别

  • 应用上的区别:volatile只能用于润饰实例变量或者类变量,然而不能用于润饰办法、办法参数、局部变量等,另外能够润饰的变量为 null。但synchronized 不能用于对变量的润饰,只能润饰办法或语句块,而且 monitor 对象不能为null
  • 对原子性的保障:volatile无奈保障原子性,然而 synchronized 能够保障
  • 对可见性的保障:volatilesynchronized 都能保障可见性,然而 synchronized 是借助于 JVM 指令 monitor enter/monitor exit 保障的,在 monitor exit 的时候所有共享资源都被刷新到主内存中,而 volatile 是通过 lock; 机器指令实现的,迫使其余线程工作内存生效,须要到主内存加载
  • 对有序性的保障:volatile可能禁止 JVM 以及处理器对其进行重排序,而 synchronized 保障的有序性是通过程序串行化执行换来的,并且在 synchronized 代码块中的代码也会产生指令重排的状况
  • 其余区别:volatile不会使线程陷入阻塞,但 synchronized
退出移动版