Java内存模型与同步关系

前言

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

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

而要了解Volatile的话,我们就需要有JMM的基础,所以我们要介绍JMM的相关知识。

一、初步了解JMM

什么是JMM呢?

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

1、线程在解锁前,必须把共享的变量立刻刷新回主存!

2、线程在加锁前,必须读取主存中最新的值到工作内存(线程有自己的工作内存)中!

3、加锁和解锁是同一把锁

JMM的内存操作

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

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

  • 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四种操作,但也只是语言上的简化,实际模型的基础设计并未简化。

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)。

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

原子性

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

int i = 5;

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

int i = 0;
i++;

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

可见性

可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。比如当我们没有任何操作来处理两个线程的时候:

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字段的值。

有序性

一句话总结的话就是

如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。

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

这就涉及到了指令重排:你写的程序,计算机并不是按照你写的那样去执行的,我们可以举个例子来说明

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原则。

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的结论。

二、关于Synchronized关键字

上面我们介绍了JMM的特性的时候,也了解到了Synchronized是一个比较全能的同步块,可以保证很多的特性。

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

synchronized的内存语义

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

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

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

关于synchronized的使用,这里可以看看我的synchronized实现生产者消费者问题

synchronized 锁升级原理
在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候 threadid 为空,jvm 让其持有偏向锁,并将 threadid 设置为其线程 id,再次进入的时候会先判断 threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了 synchronized 锁的升级。

synchronized是Java内置的机制,是JVM层面的,而Lock则是接口,是JDK层面的。

锁的升级的目的:锁升级是为了减低了锁带来的性能消耗。在 Java 6 之后优化 synchronized 的实现方式,使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,从而减低了锁带来的性能消耗。

synchronized的底层实现原理

底层主要是依赖一个monitor的监视器来实现的。主要是涉及到两个指令monitorenter和monitorexit。

具体的过程原理如下:

每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。

2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.

3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

然后执行monitorexit的线程必须是objectref所对应的monitor的所有者。

指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

三、关于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关键字的两个比较重要的特性,那么我们如何将两个特性用在该用的地方呢?也就是下面最后的一个问题

那一般在什么时候使用volatile关键字呢?

  • 写入变量值不依赖变量的当前值。因为如果依赖当前值,将是一个获取-计算-写入三步操作,三步操作不是原子性的,而volatile不保证原子性。
  • 读写变量值时没有加锁。因为加锁本身已经保证了内存可见性,这个时候再用volatile无异于多此一举。而锁也多了volatile没有的原子性。

(补充)

之前忘记介绍了volatile的禁止指令重排和可见性的实现原理。这里通过别人的博客知识补充一下。

(1)可见性的实现原理

volatile实现可见性主要是因为当一个线程写入该值后,另一个线程读取必定是新值。为什么读取的新值呢?其实volatile保证了修饰的共享变量在转换为汇编语言时,会加上一个以lock为前缀的指令,当CPU发现这个指令时,立即会做两件事情:

  1. 将当前内核中线程工作内存中该共享变量刷新到主存;
  2. 通知其他内核里缓存的该共享变量内存地址无效;

(具体的图可以参考JMM的图过程)

其实呢这里涉及到一个MESI协议:在早期的CPU中,是通过在总线加LOCK#锁的方式实现的,但是这种方式开销太大,所以Intel开发了缓存一致性协议,也就是MESI协议。该缓存一致性思路:当CPU写数据时,如果发现操作的变量是共享变量,即其他线程的工作内存也存在该变量,于是会发信号通知其他CPU该变量的内存地址无效。当其他线程需要使用这个变量时,如内存地址失效,那么它们会在主存中重新读取该值。

(2)禁止指令重排的实现原理

之前说过了被volatile修饰的变量,会加一个lock前缀的汇编指令。若变量被修改后,会立刻将变量由工作内存回写到主存中。那么意味了之前的操作已经执行完毕,这就是内存屏障。

四、参考资料

《深入理解Java虚拟机:Jvm高级特性与最佳实践》
《Java并发编程之美》

Volatile可见性实现原理

posted @ 2020-08-06 13:36  CryFace  阅读(396)  评论(0编辑  收藏  举报