原创

JMM内存模型和volatile关键字

JMM内存模型

CPU多核缓存架构
上图是CPU多核缓存架构。

JMM是指Java内存模型,不是JVM,不是所谓的栈、堆、⽅法区。

每个Java线程都有⾃⼰的⼯作内存。操作数据,⾸先从主内存中读,得到⼀份拷⻉,操作完毕后再写回到主内存。

由于JVM运⾏程序的实体是线程,⽽每个线程创建时JVM都会为其创建⼀个⼯作内存(有些地⽅称为栈空间),⼯作内存是每个线程的私有数据区域,⽽Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在⼯作内存中进⾏,⾸先要将变量从主内存拷⻉到⾃⼰的⼯作内存空间,然后对变量进⾏操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的⼯作内存中存储着主内存中的变量副本拷⻉,因此不同的线程间⽆法访问对⽅的⼯作内存,线程间的通信(传值)必须通过主内存来完成,期简要访问过程如下图:

JMM内存模型

从图中我们可以看出JMM会有以下几个问题:

  1. 可见性:某个线程对主内存内容的更改,应该⽴刻通知到其它线程。
  2. 原子性:是指⼀个操作是不可分割的,不能执⾏到⼀半,就不执⾏了。
  3. 有序性:指令是有序的,不会被重排。

JMM与硬件内存架构关系

JMM内存操作指令

volatile关键字

学习volatile关键字需要了解以下内容
volatile关键字学习内容

volatile关键字是Java提供的⼀种轻量级同步机制。

  • 能够保证可⻅性和有序性
  • 不能保证原⼦性
  • 禁⽌指令重排

可见性

import java.util.concurrent.TimeUnit;

public class VolatileDemo {

    public static void main(String[] args) {
        visibilityTest();
    }

    private static void visibilityTest() {
        System.out.println("可见性验证");
        Data data = new Data();
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t开始执行");
            try {
                TimeUnit.SECONDS.sleep(3);
                data.setNumber(10);
                System.out.println(Thread.currentThread().getName() + "\t读取到number=" + data.getNumber());
            } catch (Exception e) {
                e.printStackTrace();
            }
        }, "thread-1").start();

        while (data.getNumber() == 0) {

        }
        System.out.println(Thread.currentThread().getName() + "\t读取到number=" + data.getNumber());
    }
}

class Data {

    private int number = 0;

    public int getNumber() {
        return number;
    }

    public void setNumber(int n) {
        this.number = n;
    }
}

上述代码,thread-1线程修改number的值为10(但是由于没有加volatile关键字,所以不会立刻同步到主存中),但是main线程此时正在执行while (data.getNumber() == 0),因为while语句不会释放CPU时间片,所以main线程一直获取的是本地线程内存number值,不会跳出循环。

此时上面的代码修改为:

class Data {

    private volatile int number = 0;

    public int getNumber() {
        return number;
    }

    public void setNumber(int n) {
        this.number = n;
    }
}

此时执行程序可得到:

可见性验证
thread-1    开始执行
thread-1    读取到number=10
main    读取到number=10

加入volatile关键字,在线程thread-1中修改number=10后会立即写入到主存中,并且在main线程中读取number时会强制同步主存中的值,跳出循环。

原子性

原⼦性指的是什么意思?

不可分割,完整性,也即某个线程正则做某个具体业务时,中间不可以被加塞或者被分割。需要整体完整,要么同时成功,要么同时失败。

import java.util.concurrent.TimeUnit;

public class VolatileDemo {

    public static void main(String[] args) {
        atomicTest();
    }

    private static void atomicTest() {
        // 此处已经添加关键字volatile
        System.out.println("原子性验证");
        Data data = new Data();
        for (int i = 0; i < 30; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    data.addAndPlus();
                }
            }, "thread-" + i).start();
        }
        while (Thread.activeCount() > 2) { // main gc
            Thread.yield();
        }
        // 此处理想结果是30000
        System.out.println(Thread.currentThread().getName() + "\t读取到number=" + data.getNumber());
    }

}

class Data {

    private volatile int number = 0;

    public int getNumber() {
        return number;
    }

    public void addAndPlus() {
        number++;
    }
}

上面代码中已经加入了volatile关键字,看结果返回:

原子性验证
main    读取到number=29736

发现比我们理想的结果30000要小,可以得出volatile并不能保证操作的原⼦性。

我们对上面的代码中的addAndPlus方法进行编译,得到以下指令:

 0 aload_0
 1 dup
 2 getfield #2 <cn/pkspace/demo/volatilekey/Data.number>
 5 iconst_1
 6 iadd
 7 putfield #2 <cn/pkspace/demo/volatilekey/Data.number>
10 return

可以发现number++对应2-5-6-7这4条指令。
假设有3个线程,分别执⾏number++,都先从主内存中拿到最开始的值,number=0,然后三个线程分别进⾏操作。假设线程0执⾏完毕,number=1,也⽴刻通知到了其它线程,但是此时线程1、2已经拿到了number=0,所以结果就是写覆盖,线程1、2将number变成1。

此处有两种解决方式:

  1. addAndPlus方法上加synchronized
  2. number属性使用AtomicInteger替换int

此处synchronized方法就不描述了,后续会有详解

AtomicInteger api示例

对上述代码进行改造,新增属性。

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class VolatileDemo {

    public static void main(String[] args) {
        atomicTest();
    }

    private static void atomicTest() {
        // 此处已经添加关键字volatile
        System.out.println("原子性验证");
        Data data = new Data();
        for (int i = 0; i < 30; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    data.addAndPlus();
                }
            }, "thread-" + i).start();
        }
        while (Thread.activeCount() > 2) { // main gc
            Thread.yield();
        }
        // 此处理想结果是30000
        System.out.println(Thread.currentThread().getName() + "\t读取到number=" + data.getNumber());
        System.out.println(Thread.currentThread().getName() + "\t读取到atomic number=" + data.getAtomicNumber().get());
    }

}

class Data {

    private volatile int number = 0;

    private AtomicInteger atomicNumber = new AtomicInteger();

    public int getNumber() {
        return number;
    }

    public AtomicInteger getAtomicNumber() {
        return atomicNumber;
    }

    public void addAndPlus() {
        number++;
        atomicNumber.getAndIncrement();
    }
}

得到结果:

原子性验证
main    读取到number=28608
main    读取到atomic number=30000

可以看到AtomicIntegeA解决了原子性问题。

有序性

计算机在执⾏程序时,为了提⾼性能,编译器和处理器常常会对指令做重排,⼀般分以下三种:

  1. 编译器优化重排
  2. 指令并行重排
  3. 内存系统重排

指令重排

  • 单线程环境⾥⾯确保程序最终执⾏结果和代码顺序执⾏的结果⼀致;
  • 处理器在进⾏重排序时必须要考虑指令之间的数据依赖性,只有在处理器在认为无数据依赖时会对指令进行重排序;
  • 但在多线程环境中线程交替执⾏,由于编译器优化重排的存在,两个线程中使⽤的变量能否保证⼀致性是⽆法确定的,结果⽆法预测。

volatile可以保证有序性,也就是防⽌指令重排序。

所谓指令重排序,就是出于优化考虑,CPU执⾏指令的顺序跟程序员⾃⼰编写的顺序不⼀致。就好⽐⼀份试卷,题号是⽼师规定的,是程序员规定的,但是考⽣(CPU)可以先做选择,也可以先做填空(前提是没有相互依赖)。

如下代码:

int x = 11; //语句1
int y = 12; //语句2 
x = x + 5; //语句3 
y = x * x; //语句4

可能出现的执⾏顺序有1234、2134、1342,这三个都没有问题,最终结果都是x = 16,y=256。但是如果是4开头,就有问题了,y=0。这个时候就不能指令重排序。

看下⾯代码,在多线程场景下,说出最终值a的结果是多少?

public class ResortSeqDemo {

    int a = 0;
    boolean flag = false;

    /*
    多线程下flag=true可能先执⾏,还没⾛到a=1就被挂起。
    其它线程进⼊method02的判断,修改a的值=5,⽽不是6。
    */
    public void method01() {
        // 此处两行代码可能发生指令重排
        a = 1;
        flag = true;
    }

    public void method02() {
        if (flag) {
            a += 5;
            System.out.println("*****最终值a: " + a);
        }
    }

    public static void main(String[] args) {
        ResortSeqDemo resortSeq = new ResortSeqDemo();
        new Thread(()->{resortSeq.method01();},"Thread-1").start();
        new Thread(()->{resortSeq.method02();},"Thread-2").start();
    }
}

详解

缓存一致性协议

在可见性验证代码实例中:

class Data {

    private int number = 0;

    public int getNumber() {
        return number;
    }

    public void setNumber(int n) {
        this.number = n;
    }
}

public class VolatileDemo {

    public static void main(String[] args) throws InterruptedException {
        visibilityTest();
    }

    private static void visibilityTest() throws InterruptedException {
        System.out.println("可见性验证");
        Data data = new Data();
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t开始执行");
            try {
                TimeUnit.SECONDS.sleep(3);
                data.setNumber(10);
                System.out.println(Thread.currentThread().getName() + "\t读取到number=" + data.getNumber());
            } catch (Exception e) {
                e.printStackTrace();
            }
        }, "thread-1").start();

        while (data.getNumber() == 0) {
        }
        System.out.println(Thread.currentThread().getName() + "\t读取到number=" + data.getNumber());
    }
}

其执行流程如下图:
volatile详解1

如何在不加volatile关键字时让其跳出循环呢,我们在while循环中加入休眠:

while (data.getNumber() == 0) {
    TimeUnit.SECONDS.sleep(1);
}

再次执行时结果是:

可见性验证
thread-1    开始执行
thread-1    读取到number=10
main    读取到number=10

那么是为什么呢?
当执行休眠时,当前线程会释放CPU时间片,此时线程本地缓存将会清除,当休眠时间到了main线程重新获取时间片时,从主内存中再次读取number到线程本地缓存中,此时number已经修改为10,那么就会跳出循环。

那么volatile是怎么操作的呢?

使用 -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp 命令查看java执行过程中生成的汇编指令

在属性上添加volatile关键字时(此时可以把while循环中的休眠去掉,防止产生干扰),使用上面的指令运行,可以看到程序执行期间使用到的汇编指令。
汇编指令可参考IA-32架构开发人员手册

可以发现添加volatile关键字时会在字节码编译机器码阶段添加总线锁(Lock)或者缓存一致性协议(总线锁优化后)。
通过学习编译原理我们可以得知,线程从主存读取数据到本地缓存中是通过总线。
如图所示:
volatile详解2
图中线程A执行number+1,线程B执行number+2

  • 如果没有volatile时,两个线程间会存在数据覆盖的可能性,即number最后可能为1也可能是2。
  • 如果添加了volatile时,上面说的,会在总线添加总线锁(总线嗅探机制--类似于监听),添加了总线锁,如果线程A先获取到锁时,线程B想要再从主存中读取number属性时就要等到线程A先释放锁,即number=1,再计算number+2,最后结果为3。
  • 操作系统后续优化总线锁Lock的方式(性能问题),使用缓存一致性协议(MESI-修改Modify、独占Exclusive、共享Share、无效Invalid),上述情况有所变化:
    volatile详解3
    缓存一致性协议是乐观锁的一种实现。并且有使用限制(最大只支持64字节,超出时还是使用总线锁机制),其实现是降低了总线锁的粒度,只在从线程本地缓存到主存这一个阶段上锁。

内存屏障

public class MemoryBarrierDemo {

    static int x = 0, y = 0, a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        while (true) {
            i++;
            x = 0;
            y = 0;
            a = 0;
            b = 0;

            Thread t1 = new Thread(() -> {
                a = 1;
                x = b;
            });

            Thread t2 = new Thread(() -> {
                b = 1;
                y = a;
            });

            t1.start();
            t2.start();

            t1.join();
            t2.join();

            System.out.println(i + "\tx=" + x + ",y=" + y);
            if (x == 0 && y == 0) {
                break;
            }
        }
    }
}

以上代码可能出现集中结果?

x=0,y=0
x=1,y=0
x=0,y=1
x=1,y=1

分析:

1. x=y=1 是线程 thread-1 执行 a=1 ,且线程 thread-2 执行 b = 1 后,线程 thread-1 又执行 x = b,线程 thread-2 又执行 y = a
2. x=0,y=1 是线程 thread-1 执行 a = 1 和 y = b (0)后线程 thread-2 执行 b = 1 和 x = a (1)
3. x=1,y=0 是线程 thread-2 执行 b = 1 和 x = a (1)后线程 thread-1 执行 a = 1 和 y = b (0)
4. 那么 x=0,y=0 是为什么出现呢?
两个原因共同促成
  4.1 可见性问题,当线程 thread-1 修改了 a=1 时未及时返回到主存中,线程 thread-2 执行到 y=a 时,获取到 a=0 ,所以此时赋值 y=0 ,但是按理说 y=0 ,那么一定 b=1 ,而后线程 thread-1 执行 x=b=1 才对,这里就会牵扯第二个问题
  4.2 指令重排问题,当操作系统认为 
      b = 1;
      y = a;
      两行代码无数据依赖时,在JIT运行时可能会发生指令重排序,即 y=a 先于 b=1 执行,就会出现 x=0,y=0 的情况,跳出循环

如果在a,b上增加volatile关键字后,还会跳出循环么,答案是不会,其原因是volatile通过内存屏障禁止了指令重排序。

内存屏障(Memory Barrier)⼜称内存栅栏,是⼀个CPU指令,volatile底层就是⽤CPU的内存屏障(Memory Barrier)指令来实现的,它有两个作⽤

  1. ⼀个是保证特定操作的顺序性
  2. ⼆是保证变量的可⻅性

写内存屏障

读内存屏障

由于编译器和处理器都能够执⾏指令重排优化。所以,如果在指令间插⼊⼀条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插⼊内存屏障可以禁⽌在内存屏障前后的指令进⾏重排序优化。内存屏障另外⼀个作⽤是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读到这些数据的最新版本。

内存屏障类型

内存屏障有以下几种:

  1. LoadLoad volatile读和普通读之间
  2. StoreStore volatile写和普通写之间
  3. StoreLoad volatile写和普通读/volatile读之间
  4. LoadStore volatile读和普通写/volatile写之间

我们除了volatile关键字可以添加内存屏障外,还可以通过Unsafe类添加:

import sun.misc.Unsafe;

import java.lang.reflect.Field;

public class UnsafeFactory {

    public static Unsafe getUnsafe() throws NoSuchFieldException, IllegalAccessException {
        Field field = Unsafe.class.getField("theUnsafe");
        field.setAccessible(true);
        return (Unsafe) field.get(null);
    }

}

使用如上代码获取Unsafe实例

public class MemoryBarrierDemo {

    static int x = 0, y = 0, a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        while (true) {
            i++;
            x = 0;
            y = 0;
            a = 0;
            b = 0;

            Thread t1 = new Thread(() -> {
                a = 1;
                try {
                    UnsafeFactory.getUnsafe().storeFence();
                } catch (NoSuchFieldException e) {
                    e.printStackTrace();
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                }
                x = b;
            });

            Thread t2 = new Thread(() -> {
                b = 1;
                try {
                    UnsafeFactory.getUnsafe().storeFence();
                } catch (NoSuchFieldException e) {
                    e.printStackTrace();
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                }
                y = a;
            });

            t1.start();
            t2.start();

            t1.join();
            t2.join();

            System.out.println(i + "\tx=" + x + ",y=" + y);
            if (x == 0 && y == 0) {
                break;
            }
        }
    }
}

可以看到使用Unsafe添加了StoreLoad屏障。同样实现了禁止指令重排。(与volatile相同,同样是使用Lock原语实现)

总结

volatile可以实现高并发场景下的可见性(Lock,缓存一致性),有序性(内存屏障),但是无法实现高并发场景下的原子性(因为volatile并在CPU执行过程阶段加锁)。

volatile单例使用

单例模式的安全问题

public class SingletonDemo {

    private static SingletonDemo instance = null;

    private SingletonDemo() {
        super();
        System.out.println(Thread.currentThread().getName() +"\tSingletonDemo构造⽅法执⾏了");
    }

    public static SingletonDemo getInstance() {
        if (instance == null) {
            instance = new SingletonDemo();
        }
        return instance;
    }

    public static void main(String[] args) {
        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
    }

}

如下代码单线程执行结果是

main    SingletonDemo构造⽅法执⾏了
true
true
true

将以上代码修改成多线程,如下:

public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> SingletonDemo.getInstance(), "thread-" + i).start();
        }
    }

执行结果如下:

thread-0    SingletonDemo构造⽅法执⾏了
thread-5    SingletonDemo构造⽅法执⾏了
thread-4    SingletonDemo构造⽅法执⾏了
thread-3    SingletonDemo构造⽅法执⾏了
thread-2    SingletonDemo构造⽅法执⾏了
thread-1    SingletonDemo构造⽅法执⾏了

我们发现SingletonDemo构造⽅法执⾏了很多次,这样违背了单例的原则。我们进行第一步调整,添加双重检查锁(DCL):

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

此处也可以在getInstance方法上加synchronized锁,但是这样锁的粒度太大,影响执行性能。
这样修改后我们执行代码结果是:

thread-0    SingletonDemo构造⽅法执⾏了

发现貌似解决了多例问题,但实际上我们依然有两个问题:

  1. 可见性问题
    双重检查锁可见性问题
  2. 有序性问题
    我们对上面的代码编译成字节码指令为:
    0 getstatic #11 <cn/pkspace/demo/volatilekey/SingletonDemo.instance>
    3 ifnonnull 37 (+34)
    6 ldc #12 <cn/pkspace/demo/volatilekey/SingletonDemo>
    8 dup
    9 astore_0
    10 monitorenter
    11 getstatic #11 <cn/pkspace/demo/volatilekey/SingletonDemo.instance>
    14 ifnonnull 27 (+13)
    17 new #12 <cn/pkspace/demo/volatilekey/SingletonDemo>
    20 dup
    21 invokespecial #13 <cn/pkspace/demo/volatilekey/SingletonDemo.<init>>
    24 putstatic #11 <cn/pkspace/demo/volatilekey/SingletonDemo.instance>
    27 aload_0
    28 monitorexit
    29 goto 37 (+8)
    32 astore_1
    33 aload_0
    34 monitorexit
    35 aload_1
    36 athrow
    37 getstatic #11 <cn/pkspace/demo/volatilekey/SingletonDemo.instance>
    40 areturn
    
    代码中:
    instance = new SingletonDemo();
    
    对应指令:
    17 new #12 <cn/pkspace/demo/volatilekey/SingletonDemo>
    20 dup
    21 invokespecial #13 <cn/pkspace/demo/volatilekey/SingletonDemo.<init>>
    24 putstatic #11 <cn/pkspace/demo/volatilekey/SingletonDemo.instance>
    
    底层Java Native Interface中的C语⾔代码内容,开辟空间的步骤:
    memory = allocate(); //步骤1.分配对象内存空间
    instance(memory); //步骤2.初始化对象
    instance = memory; //步骤3.设置instance指向刚分配的内存地址,此时instance != null
    
    如果未使用volatile关键字,步骤2和步骤3不存在数据依赖关系,⽽且⽆论重排前还是重排后,程序的执⾏结果在单线程中并没有改变,因此这种重排优化是允许的,步骤3可能发生在步骤2之前,那么如图:
    双重检查锁有序性问题

此时上述代码中需要对instance属性添加volatile关键字。

public class SingletonDemo {

    private volatile static SingletonDemo instance = null;

    private SingletonDemo() {
        super();
        System.out.println(Thread.currentThread().getName() +"\tSingletonDemo构造⽅法执⾏了");
    }

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

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> SingletonDemo.getInstance(), "thread-" + i).start();
        }
    }
}
正文到此结束