解析JMM与Synchronized,Volatile之间的关系

Volatile能够说是咱们Java虚拟机给咱们提供的一个轻量级的同步机制,与Synchronized相似,可是却没有它那么强大。关于Volatile最主要的特色呢就是它的三大特性:java

  • 保证可见性
  • 不保证原子性
  • 禁止指令重排

而要了解Volatile的话,咱们就须要有JMM的基础,因此咱们要介绍JMM的相关知识。编程

1 初步了解JMM

1.1 什么是JMM呢?

JMM是Java内存模型的缩写(Java Memory Model),是一种逻辑的东西,物理上不存在的。能够说是一种概念或者约定。好比关于约定有如下的一些:安全

一、线程在解锁前,必须把共享的变量马上刷新回主存!多线程

二、线程在加锁前,必须读取主存中最新的值到工做内存(线程有本身的工做内存)中!并发

三、加锁和解锁是同一把锁app

1.2 JMM的内存操做

JMM呢咱们逻辑上能够把它分为主内存和工做内存。而两个内存之间也是有进行交互的,就是一个变量如何从主内存传输到工做内存中,如何把修改后的变量从工做内存回到主内存。关于这些操做咱们主要是有八种:函数

  • lock(锁定):做用于主内存的变量,一个变量在同一时间只能一个线程锁定,该操做表示这条线成独占这个变量this

  • unlock(解锁):做用于主内存的变量,表示这个变量的状态由处于锁定状态被释放,这样其余线程才能对该变量进行锁定线程

  • read(读取):做用于主内存变量,表示把一个主内存变量的值传输到线程的工做内存,以便随后的load操做使用设计

  • load(载入):做用于线程的工做内存的变量,表示把read操做从主内存中读取的变量的值放到工做内存的变量副本中(副本是相对于主内存的变量而言的)

  • use(使用):做用于线程的工做内存中的变量,表示把工做内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个须要使用变量的值的字节码指令时就会执行该操做

  • assign(赋值):做用于线程的工做内存的变量,表示把执行引擎返回的结果赋值给工做内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就会执行该操做

  • store(存储):做用于线程的工做内存中的变量,把工做内存中的一个变量的值传递给主内存,以便随后的write操做使用

  • write(写入):做用于主内存的变量,把store操做从工做内存中获得的变量的值放入主内存的变量中

在使用上的流程就是以下顺序,咱们能够画一个图就更加清晰明了:

个人主内存有一个flag = false;经过线程A来修改成true

可是须要注意的是,在使用这些指令的时候也是须要知足一些规则的:

  • 不容许read和load、store和write操做之一单独出现,即不容许一个变量从主内存读取了但工做内存不接受,或者工做内存发起回写了但主内存不接受的状况出现。
  • 不容许一个线程丢弃它最近的assign操做,即变量在工做内存中改变了以后必须把该变化同步回
    主内存
  • 不容许一个线程无缘由地(没有发生过任何assign操做)把数据从线程的工做内存同步回主内存
    中。
  • 一个新的变量只能在主内存中“诞生”,不容许在工做内存中直接使用一个未被初始化(load或
    assign)的变量,换句话说就是对一个变量实施use、store操做以前,必须先执行assign和load操做。
  • 一个变量在同一个时刻只容许一条线程对其进行lock操做,但lock操做能够被同一条线程重复执
    行屡次,屡次执行lock后,只有执行相同次数的unlock操做,变量才会被解锁。(也就是可重入锁的概念)
  • 若是对一个变量执行lock操做,那将会清空工做内存中此变量的值,在执行引擎使用这个变量
    前,须要从新执行load或assign操做以初始化变量的值。
  • 若是一个变量事先没有被lock操做锁定,那就不容许对它执行unlock操做,也不容许去unlock一个被其余线程锁定的变量。
  • 对一个变量执行unlock操做以前,必须先把此变量同步回主内存中(执行store、write操做)。

这8种内存访问操做以及上述规则限定明确地描述了在咱们Java程序中哪些内存访问操做在并发下是安全的。可是这样操做倒是极其繁琐的,因此被简化成了read,write,lock,unlock四种操做,但也只是语言上的简化,实际模型的基础设计并未简化。

1.3 JMM的特性

咱们在开头提到了volatile的三大特性,而后要介绍就要先普及JMM的基础,其实并非没有道理的。关于JMM咱们也有三大特性(JMM保证),总结起来就是:

  • 原子性
  • 可见性
  • 有序性

能够发现,这与volatile是很相似的。下面一张图能够很好的理清之间的关系:

在上面基本的数据类型读或写,咱们看到了long,double除外,这涉及到了针对long和double型变量的特殊规则。

Java内存模型要求lock、unlock、read、load、assign、use、store、write这八种操做都具备原子性,可是对于64位的数据类型(long和double),在模型中特别定义了一条宽松的规定:容许虚拟机将没有被volatile修饰的64位数据的读写操做划分为两次32位的操做来进行,即容许虚拟机实现自行选择是否要保证64位数据类型的load、store、read和write这四个操做的原子性,这就是所谓的“long和double的非原子性协定”(Non-Atomic Treatment of double and long Variables)。

关于三大特性,咱们这里简单介绍一下

1.3.1 原子性

原子性呢是指操做是一体的,要么成功,要么失败,没有第三种状况。上图咱们也说到Java中的基本数据类型的访问,读或写都是具有原子性的。这里咱们举一个例子

int i = 5;

这里咱们的赋值操做就是原子性,而一个比较经典的就是

int i = 0;
i++;

这里i++就不是原子性的,咱们能够看做它是先获取了i的值,而后进行写入值i = 1的操做。咱们常见的数据类型的操做都是原子性的,可是若是应用场景须要一个更大范围的原子性保证的话,Java内存模型提供了lock和unlock操做来知足这种需求。也提供了更方便快捷的synchronized关键字,一样具有原子性。

1.3.2 可见性

可见性就是指当一个线程修改了共享变量的值时,其余线程可以当即得知这个修改。好比当咱们没有任何操做来处理两个线程的时候:

int i = 0; //主线程
i++; //线程1
j = i; //线程2

咱们能够发现线程1修改了i的值,可是没有刷回主内存,线程2读取了i的值,赋值给了j,咱们指望j就是1,可是由于虽然线程1修改了,没有来得及复制到主内存中,线程2读取后,j仍是0。这就是内存不可见性,同理咱们就能够理解了可见性。

常见的咱们能够用volatile关键字来修饰变量,达到了内存可见性。

Java内存模型是经过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存做为传递媒介的方式来实现可见性的,不管是普通变量仍是volatile变量都是如此。普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能当即同步到主内存,以及每次使用前当即从主内存刷新。所以咱们能够说volatile保证了多线程操做时变量的可见性,而普通变量则不能保证这一点。

除了volatile之类,还有synchronized和final也能够保证可见性。syschronized是由于JMM的规则限定对一个变量执行unlock操做以前,必须先把此变量同步回主内存中(执行store、write操做),而final关键字的可见性是指:

被final修饰的字段在构造器中一旦被初始化完成,而且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其余线程有可能经过这个引用访问到“初始化了一半”的对象),那么在其余线程中就能看见final字段的值。

1.3.3 有序性

一句话总结的话就是

若是在本线程内观察,全部的操做都是有序的;若是在一个线程中观察另外一个线程,全部的操做都是无序的。

问题来了,为何在一个线程观察另外一个线程的时候,操做都是无序的呢?

这就涉及到了指令重排:你写的程序,计算机并非按照你写的那样去执行的,咱们能够举个例子来讲明

int x = 1; // 1
int y = 2; // 2
x = x + 5; // 3
y = x * x; // 4

咱们指望的程序执行顺序是1->2->3->4,咱们发现若是程序是2->1->3->4执行结果也是同样的,或者1->3->2->4也行,可是若是按照1—>2->4->3之类的呢?就得不到咱们的指望结果,这就是指令重排致使的。

Java语言提供了volatile和synchronized两个关键字来保证线程之间操做的有序性,volatile关键字自己就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只容许一条线程对其进行lock操做”这条规则得到的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。

可是若是全部的有序性都靠这两个关键字来完成的话,那么不少操做就会变得特别啰嗦,因此就有了一个Happens-Before原则。

1.4 Happens-Before原则

  • 程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操做先行发生于书写在后面的操做。注意,这里说的是控制流顺序而不是程序代码顺序,由于要考虑分支、循环等结构。
  • 管程锁定规则(Monitor Lock Rule):一个unlock操做先行发生于后面对同一个锁的lock操做。这里必须强调的是“同一个锁”,而“后面”是指时间上的前后。
    ·volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操做先行发生于后面对这个变量的读操做,这里的“后面”一样是指时间上的前后。
  • 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每个动做。
  • 线程终止规则(Thread Termination Rule):线程中的全部操做都先行发生于对此线程的终止检测,咱们能够经过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止
    执行。
  • 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,能够经过Thread::interrupted()方法检测到是否有中断发生。
  • 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
  • 传递性(Transitivity):若是操做A先行发生于操做B,操做B先行发生于操做C,那就能够得出操做A先行发生于操做C的结论。

2 关于Synchronized关键字

​ 上面咱们介绍了JMM的特性的时候,也了解到了Synchronized是一个比较全能的同步块,能够保证不少的特性。

synchronized块是Java提供的一种原子内置锁,Java中的每一个对象均可以把它看成一个同步块来使用,这些Java内置的使用者看不到的锁被称为内部锁,也叫作监视器锁。程的执行代码在进入synchronized代码块前会自动获取内部锁,这时候其余线程访问该同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异后或者在同步块内调用了该内置锁资源的wai t系列方法时释放该内置锁。内置锁是排它锁,也就是当一个线程获取这个锁后,其余线程必须等待该线程释放锁后才能获取该锁。

2.1 synchronized的内存语义

使用synchronized能够解决共享变量内存可见性的问题。

synchronized块的内存语义是把在synchronized块内使用到的变量从线程的工做内存中清除,这样在synchronized块内使用到该变量时就不会从线程的工做内存中获取,而是直接从主内存中获取。退出synchronized块的内存语义是把在synchronized块内对共享变量的修改刷新到主内存。其实这也是加锁和释放锁的语义,当获取锁后会清空锁块内本地内存中将会被用到的共享变量,在使用这些共享变量时从主内存进行加载,在释放锁时将本地内存中修改的共享变量刷新到主内存。

除能够解决共享变量内存可见性问题外,synchronized常常被用来实现原子性操做。另外请注意,synchronized关键字会引发线程上下文切换并带来线程调度开销。

关于synchronized的使用,这里能够看看个人synchronized实现生产者消费者问题

3 关于Volatile关键字

其实关于volatile的特性,咱们在介绍JMM的时候都已经了解很大一部分了。可是咱们也说过volatile是不保证原子性的,这里咱们仍是须要用代码来展现的。

咱们开十个线程,每一个线程都对被volatile修饰的共享变量进行1000次自增操做。

public class Demo01 {

    private volatile int num = 0;

    private void increase(){
        num++;
    }

    public static void main(String[] args) throws InterruptedException {
        final Demo01 demo01 = new Demo01();
        for(int i = 0; i < 10; i++){
            new Thread(()->{
                for(int j = 0; j < 1000; j++){
                    demo01.increase();
                }
            }).start();
        }
        //保证在主线程结束以前,其余线程执行完毕
        TimeUnit.SECONDS.sleep(2);
        System.out.println(demo01.num);
    }

}

咱们执行以后就能够看,打印的数有时候并非咱们想要的10000,并且接近这个数。这充分体现了volatile并不能保证原子性。而要解决这个问题的话,就须要用加锁或者用synchronized来修饰方法。

还有一种状况比较经典的以下:

public class Demo02 {
    
    private int value;

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }
}

这里在并发下是不安全的,由于没有适当的同步措施。就会致使内存不可见,getValue有时候取到的值的以前调用了setValue,可是尚未刷回主内存。这里咱们就能够用到synchronized或者是volatile:

public class Demo02 {

    private int value;

    public synchronized int getValue() {
        return value;
    }

    public synchronized void setValue(int value) {
        this.value = value;
    }
}
public class Demo02 {

    private volatile int value;

    public  int getValue() {
        return value;
    }

    public  void setValue(int value) {
        this.value = value;
    }
}

在这里这两种方法是等价的,均可以解决内存可见性的问题。可是须要注意的是synchronized内置的锁是独占锁,这个时候同时只能有一个线程调用getValue方法,其余线程会被阻塞,同时也会存在线程上下文切换和线程从新调度的开销,这也是使用锁很差的地方。

到了这里咱们也了解到了volatile关键字的两个比较重要的特性,那么咱们如何将两个特性用在该用的地方呢?也就是下面最后的一个问题

3.1 那通常在何时使用volatile关键字呢?

  • 写入变量值不依赖变量的当前值。由于若是依赖当前值,将是一个获取-计算-写入三步操做,三步操做不是原子性的,而volatile不保证原子性。
  • 读写变量值时没有加锁。由于加锁自己已经保证了内存可见性,这个时候再用volatile无异于画蛇添足。而锁也多了volatile没有的原子性。

4 参考资料

《深刻理解Java虚拟机:Jvm高级特性与最佳实践》

《Java并发编程之美》

相关文章
相关标签/搜索