Java内存模型与volatile关键字

澳门新葡亰手机版 1

Java内存模型(Java Memory Model)

Java内存模型(JMM),不同于Java运行时数据区,JMM的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中读取数据这样的底层细节。JMM规定了所有的变量都存储在主内存中,但每个线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝。线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量,工作内存是线程之间独立的,线程之间变量值的传递均需要通过主内存来完成。

1. 并发编程的3个概念

并发编程时,要想并发程序正确地执行,必须要保证原子性、可见性和有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

Volatile关键字

平时在阅读jdk源码的时候,经常看到源码中有写变量被volatile关键字修饰,但是却不是十分清除这个关键字到底有什么用处,现在终于弄清楚了,那么我就来讲讲这个volatile到底有什么用吧。

当一个变量被定义为volatile之后,就可以保证此变量对所有线程的可见性,即当一个线程修改了此变量的值的时候,变量新的值对于其他线程来说是可以立即得知的。可以理解成:对volatile变量所有的写操作都能立刻被其他线程得知。但是这并不代表基于volatile变量的运算在并发下是安全的,因为volatile只能保证内存可见性,却没有保证对变量操作的原子性。比如下面的代码:

/**
 * 发起20个线程,每个线程对race变量进行10000次自增操作,如果代码能够正确并发,
 * 则最终race的结果应为200000,但实际的运行结果却小于200000。
 * 
 * @author Colin Wang
 *
 */
public class VolatileTest {
    public static volatile int race = 0;

    public static void increase() {
        race++;
    }

    private static final int THREADS_COUNT = 20;

    public static void main(String[] args) {
        Thread[] threads = new Thread[THREADS_COUNT];

        for (int i = 0; i < THREADS_COUNT; i++) {
            threads[i] = new Thread(new Runnable() {

                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }

        while (Thread.activeCount() > 1)
            Thread.yield();

        System.out.println(race);
    }
}

这便是因为race++操作不是一个原子操作,导致一些线程对变量race的修改丢失。若要使用volatale变量,一般要符合以下两种场景:

  1. 变量的运算结果并不依赖于变量的当前值,或能够保证只有单一的线程修改变量的值。
  2. 变量不需要与其他的状态变量共同参与不变约束。

使用volatile变量还可以禁止JIT编译器进行指令重排序优化,这里使用单例模式来举个例子:

/**
 * 单例模式例程一
 * 
 * @author Colin Wang
 *
 */
public class Singleton_1 {

    private static Singleton_1 instance = null;

    private Singleton_1() {
    }

    public static Singleton_1 getInstacne() {
        /*
         * 这种实现进行了两次instance==null的判断,这便是单例模式的双检锁。
         * 第一次检查是说如果对象实例已经被创建了,则直接返回,不需要再进入同步代码。
         * 否则就开始同步线程,进入临界区后,进行的第二次检查是说:
         * 如果被同步的线程有一个创建了对象实例, 其它的线程就不必再创建实例了。
         */
        if (instance == null) {
            synchronized (Singleton_1.class) {
                if (instance == null) {
                    /*
                     * 仍然存在的问题:下面这句代码并不是一个原子操作,JVM在执行这行代码时,会分解成如下的操作:
                     * 1.给instance分配内存,在栈中分配并初始化为null
                     * 2.调用Singleton_1的构造函数,生成对象实例,在堆中分配 
                     * 3.把instance指向在堆中分配的对象
                     * 由于指令重排序优化,执行顺序可能会变成1,3,2,
                     * 那么当一个线程执行完1,3之后,被另一个线程抢占,
                     * 这时instance已经不是null了,就会直接返回。
                     * 然而2还没有执行过,也就是说这个对象实例还没有初始化过。
                     */
                    instance = new Singleton_1();
                }
            }
        }
        return instance;
    }
}

/**
 * 单例模式例程二
 * 
 * @author Colin Wang
 *
 */
public class Singleton_2 {

    /*
     * 为了避免JIT编译器对代码的指令重排序优化,可以使用volatile关键字,
     * 通过这个关键字还可以使该变量不会在多个线程中存在副本,
     * 变量可以看作是直接从主内存中读取,相当于实现了一个轻量级的锁。
     */
    private volatile static Singleton_2 instance = null;

    private Singleton_2() {
    }

    public static Singleton_2 getInstacne() {
        if (instance == null) {
            synchronized (Singleton_2.class) {
                if (instance == null) {
                    instance = new Singleton_2();
                }
            }
        }
        return instance;
    }
}

变量在有了volatile修饰之后,对变量的修改会有一个内存屏障的保护,使得后面的指令不能被重排序到内存屏障之前的位置。volalite变量的读性能与普通变量类似,但是写性能要低一些,因为它需要插入内存屏障指令来保证处理器不会发生乱序执行。即便如此,大多数场景下volatile的总开销仍然要比锁低,所以volatile的语义能满足需求时候,选择volatile要优于使用锁。

1.1. 原子性

原子性:即一个或多个操作要么全部执行并且执行过程中不会被打断,要么都不执行。

一个经典的例子就是银行转账:从账户A向账户B转账1000元,此时包含两个操作:账户A减去1000元,账户B加上1000元。这两个操作必须具备原子性才能保证转账安全。假如账户A减去1000元之后,操作被打断了,账户B却没有收到转过来的1000元,此时就出问题了。 

 

1.2. 可见性

可见性:即多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的最新值。

例如下段代码,线程1修改i的值,线程2却没有立即看到线程1修改的i的最新值:

//线程1执行的代码
int i = 0;
i = 10;

//线程2执行的代码
j = i;

假如执行线程1的是CPU1,执行线程2的是CPU2。当线程1执行 i=10 时,会将CPU1的高速缓存中i的值赋值为10,却没有立即写入主内存中。此时线程2执行 j=i,会先从主内存中读取i的值并加载到CPU2的高速缓存中,此时主内存中的i=0,那么就会使得j最终赋值为0,而不是10。

 

1.3. 有序性

有序性:即程序执行的顺序按代码的先后顺序执行。

例如下面这段代码:

int i = 0;
boolean flag = false;
i = 1;
flag = true;

在代码顺序上 i=1 在 flag=true 前面,而 JVM
在真正执行代码的时候不一定能保证 i=1 在flag=true 前面执行,这里就发生了指令重排序

 

指令重排序

一般是为了提升程序运行效率,编译器或处理器通常会做指令重排序:

  • 编译器重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  • 处理器重排序:如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。CPU
    在指令重排序时会考虑指令之间的数据依赖性,如果指令2必须依赖用到指令1的结果,那么CPU会保证指令1在指令2之前执行。

指令重排序不保证程序中各个语句的执行顺序和代码中的一致,但会保证程序最终执行结果和代码顺序执行的结果是一致的。比如上例中的代码, i=1 和 flag=true 两个语句先后执行对最终的程序结果没有影响,就有可能
CPU 先执行 flag=true,后执行 i=1

2. java 内存模型

由于 volatile 关键字是与 java 内存模型相关的,因此了解 volatile
前,需要先了解下 java 内存模型相关概念 

2.1. 硬件效率与缓存一致性

计算机执行程序时,每条指令都是在 CPU
中执行的,而执行指令过程中,势必涉及到数据的读取和写入。CPU
在与内存交互时,需要读取运算数据、存储结果数据,这些 I/O 操作的速度与
CPU 的处理速度有几个数量级的差距,所以不得不加入一层读写速度尽可能接近
CPU 运算速度的高速缓存(Cache)来作为内存与 CPU
之间的缓冲:将运算需要使用的数据复制到高速Cache中;运算结束后再从高速Cache同步回内存中。这样
CPU 就无需等待缓慢的内存读写了。

澳门新葡亰手机版,这在单线程中运行是没有问题的,但在多线程中运行就引入了 缓存一致性的问题:在多处理系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存。当多个
CPU
的运算任务都涉及同一主内存区域时,将可能导致各自的缓存数据不一致,此时同步回主内存时以谁的数据为准呢?

为了解决缓存一致性问题,通常有两种解决方法:

  1. 在总线加 LOCK# 锁的方式
  2. 缓存一致性协议

早期的 CPU 中,通过在总线上加 LOCK# 锁的形式来解决,因为 CPU
在和其他部件通信时都是通过总线进行,如果对总线加 LOCK# 锁,也就阻塞了
CPU 对其他部件访问(如内存),而使得只能有一个 CPU 使用这个变量的内存。

但这种方式有一个问题,在锁住总线期间,其他 CPU
无法访问内存,导致效率低下。

所有就出现了缓存一致性协议,最著名的就是 Intel 的 MESI
协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的,
它的核心思想是:CPU写数据时,如果操作的变量是共享变量(其他 CPU
的高速缓存中也存在该变量的副本),会发出信号通知其他 CPU
将该变量的缓存设置为无效状态,那么当其他 CPU
读取该变量时,就会从内存重新读取。

JVM 有自己的内存模型,在访问缓存时,遵循一些协议来解决缓存一致性的问题。

 

2.2. 主内存和工作内存

Java虚拟机规范中试图定义一种 Java 内存模型(JMM, Java Memory
Model)来屏蔽硬件和操作系统的内存访问差异,实现 Java
程序在各种平台上达到一致的内存访问效果。

Java
内存模型主要目标:是定义程序中各个变量的访问规则,即存储变量到内存和从内存中取出变量这样的底层细节。为了较好的执行性能,Java
内存模型并没有限制使用 CPU
的寄存器和高速缓存来提升指令执行速度,也没有限制编译器对指令做重排序。也就是说:在
Java 内存模型中,也会存在缓存一致性问题和指令重排序问题。

Java
内存模型规定所有的变量(包括实例字段、静态字段、构成数组对象的元素,不包括线程私有的局部变量和方法参数,因为这些不会出现竞争问题)都存储在主内存中,每条线程有自己的工作内存(可与之前将讲的CPU高速缓存类比),线程的工作内存中保存了被该线程使用到的变量的主内存拷贝副本。线程对变量的所有操作(read,write)都必须在工作内存中进行,而不能直接读写主内存中的变量,线程间变量值的传递需要通过主内存来完成。如图所示: 

澳门新葡亰手机版 1

 

2.3. JMM如何处理原子性

像以下语句:

x = 10;     //语句1
y = x;      //语句2
x++;        //语句3
x = x + 1;  //语句4

只有语句1才是原子性的操作,其他都不是原子性操作。 

语句1是直接将10赋值给x变量,也就是说线程执行这个语句时,会直接将10写入到工作内存中。 

语句2包含了两个操作,先读取x的值,然后将x的值写入到工作内存赋值给y,这两个操作合起来就不是原子性操作了。 

语句3和4都包括3个操作,先读取x的值,然后加1操作,最后写入新值。

单线程环境下,我们可以认为整个步骤都是原子性的。但多线程环境下则不同,只有基本数据类型的访问读写是具备原子性的,如果还需要提供更大范围的原子性保证,可以使用同步代码块
— synchronized 关键字。在 synchronized 块之间的操作具备原子性。