JMM内存模型和volatile关键字


JMM

JMM 的全称是 Java Memory Model,即 Java 内存模型。

一般来说,编程语言可以直接使用操作系统层面的内存模型。不过不同操作系统内存模型不同,如果直接使用操作系统层面的内存模型,就可能导致同样一套代码换了一个操作系统就无法执行了。而我们知道Java语言是跨平台的,因此它需要自己提供一套内存模型以屏蔽系统的差异,这也是 JMM 存在的其中一个原因。

JMM 实际上是一种抽象的概念,并不存在,你可以把它看作是Java定义并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。

JMM是如何抽象线程和主内存之间的关系?

Java内存模型(JMM)抽象了线程和主内存之间的关系,在讲解这个概念之前我们先看Java内存模型的抽象示意图:

主内存:所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量)

本地内存:每个线程都有一个私有的本地内存来存储共享变量的副本,本地内存是 JMM 抽象出来的一个概念,存储了主内存中的共享变量副本

因此每个线程要对共享变量进行修改时,需先将共享变量拷贝到本地内存,再将修改过的共享变量副本的值同步到主内存中去。若上图线程 1 要与线程 2 进行通信的话,则需经历下面 2 个步骤:

①线程 1 把本地内存中修改过的共享变量副本的值同步到主内存中去;②线程 2 到主存中读取对应的共享变量的值

JMM 的三大特性

原子性

不可分割,完整性,也就是一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行

在 Java 中,可以借助synchronized 、各种 Lock 以及各种原子类实现原子性。

synchronized 和各种 Lock 可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。各种原子类是利用 CAS操作(可能也会用到 volatile或者final关键字)来保证原子操作

可见性

当一个线程对共享变量进行了修改,那么其他的线程能够立即看到修改后的最新值

如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取

有序性

在介绍有序性之前,我们需先了解指令重排,指令重排简单来说就是在多线程的环境下,系统执行代码的顺序并不一定是按照你写的代码的顺序依次执行

Java源代码会经历编译器优化重排 —> 指令并行重排 —> 内存系统重排 的过程,最终才变成操作系统可执行的指令序列

指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题

而有序性就是禁止指令进行重排序优化,从而避免多线程环境下可能造成的问题,如在 Java 中,volatile 关键字可以禁止指令进行重排序优化。

volatile关键字

volatile 是Java虚拟机提供的轻量级的同步机制,说直白点就是轻量级的synchronizedvolatile 满足 JMM 三大特性中的两个:可见性和有序性,但并不保证原子性,synchronized三者都满足

保证可见性

在 Java 中,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取,这也就保证了变量的可见性

保证有序性

volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序。 如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序,从而避免了多线程环境下程序出现乱序执行的现象。

下面我们通过volatile 禁止指令重排这一特性实现一个单例模式,其实现应用了双重检验锁方式

双重校验锁实现对象单例(线程安全)

public class Singleton {
    private volatile static Singleton instance;

    private Singleton(){
        System.out.println(Thread.currentThread().getName() + "/t 我是构造方法Singleton");
    }

    public static Singleton getInstance() {
        //先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (instance == null){
            // 类对象加锁
            synchronized (Singleton.class){
                if (instance == null){
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

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

上述代码 instance 如果没用 volatile 修饰,1000个线程只生成了一个Singleton对象,从结果上看是能够保证单例模式的正确性,但是还是存在问题

双重检验锁方式不一定是线程安全的,例如上述代码instance = new Singleton();其实是分以下三步执行的:

​ ①为 instance 分配内存空间

​ ②初始化 instance

​ ③将 instance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序会发生改变。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 ①和 ③,此时 T2 调用 getinstance() 后发现 instance 不为空,因此返回 instance,但此时 instance 还未被初始化,因此还是有必要加入volatile关键字,禁止指令重排

不保证原子性

在数据库事务四大特性 ACID 中也经常提到原子性

volatile不保证原子性代码证明:

public class VolatileAtomicityDemo {
    public volatile static int number = 0;

    public void increase(){
        number++;
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileAtomicityDemo volatileAtomicityDemo = new VolatileAtomicityDemo();
        for (int i = 0; i < 20; i++){
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    volatileAtomicityDemo.increase();
                }
            },String.valueOf(i)).start();
        }

        Thread.sleep(1500);
        System.out.println(Thread.currentThread().getName() + "t final number value is " + number);
    }
}

最终的结果我们会发现,number 输出的值并不是 20000,而且每次运行的结果都不一致,这说明 volatile 修饰的变量不保证原子性

这里出现的原因是number++这个操作并不是原子性的,通过查看该代码的字节码文件可以发现number++实际上是一个复合操作,包含以下三步:

​ ①从主内存拿到原始number,并拷贝到本地内存

​ ②执行加1操作

​ ③把累加后的值写回主内存

volatile是无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现:

​ ①线程 1 对 number 进行读取操作之后,还未对其进行修改。线程 2 又读取了 number的值并对其进行修改(+1),再将number 的值写回内存。

​ ②线程 2 操作完毕后,线程 1 对 number的值进行修改(+1),再将number 的值写回内存。

这也就导致两个线程分别对 inc 进行了一次自增操作后,number 实际上只增加了 1。

其实,如果想要保证上面的代码运行正确也非常简单,利用 synchronizedLock或者AtomicInteger都可以。

  • 使用 synchronized 改进:
public synchronized void increase() {
    inc++;
}
  • 使用 ReentrantLock 改进:
Lock lock = new ReentrantLock();
public void increase() {
    lock.lock();
    try {
        inc++;
    } finally {
        lock.unlock();
    }
}

使用 synchronizedReentrantLock 虽然能够保证原子性,但是为了解决 number++ 而引入重量级的同步机制,有种杀鸡焉用牛刀的感觉

除了引用 synchronizedLock外,还可以使用 JUC 下面的原子包装类,即刚刚的 int 类型的 number ,可以使用 AtomicInteger 来代替

  • 使用 AtomicInteger 改进:
public AtomicInteger inc = new AtomicInteger();

public void increase() {
    inc.getAndIncrement();
}

参考

  • JavaGuide(Java面试+学习指南)
  • 尚硅谷Java大厂面试题第2季

文章作者: 不才叶某
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 不才叶某 !
评论
  目录