一、内存可见性

1、内存可见性介绍

可见性: 一个线程对共享变量值的修改,能够及时的被其他线程看到

共享变量: 如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量

线程 A 与线程 B 之间如要通信的话,必须要经历下面 2 个步骤:

(1)、首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。

(2)、然后,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量。

如上图所示,本地内存 A 和 B 有主内存中共享变量 x 的副本。假设初始时,这三个内存中的 x 值都为0。线程 A 在执行时,把更新后的 x 值(假设值为 1)临时存放在自己的本地内存 A 中。当线程 A 和线程 B 需要通信时,线程 A 首先会把自己本地内存中修改后的 x 值刷新到主内存中,此时主内存中的 x值变为了 1。随后,线程 B 到主内存中去读取线程 A 更新后的 x 值,此时线程 B 的本地内存的 x 值也变为了 1。

从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 java 程序员提供内存可见性保证。

JMM就是围绕着多线程通信以及与其相关的一系列特性而建立的模型

2、可见性问题

前面讲过多线程的内存可见性,现在我们写一个内存不可见的问题。

案例如下:

public class Demo1Jmm {
    public static void main(String[] args) throws InterruptedException {
        JmmDemo demo = new JmmDemo();
        Thread t = new Thread(demo);
        t.start();
        Thread.sleep(100);
        demo.flag = false;
        System.out.println("已经修改为false");
        System.out.println(demo.flag);
    }

    static class JmmDemo implements Runnable {
        public boolean flag = true;
        public void run() {
            System.out.println("子线程执行。。。");
            while (flag) {
            }
            System.out.println("子线程结束。。。");
        }
    }
}

结果:

子线程执行。。。
已经修改为false
false

按照 main方法的逻辑,我们已经把flag设置为false,那么从逻辑上讲,子线程就应该跳出while死循环,因为这个时候条件不成立,但是我们可以看到,程序仍旧执行中,并没有停止。

原因:线程之间的变量是不可见的,因为读取的是副本,没有及时读取到主内存结果。

解决办法:强制线程每次读取该值的时候都去“主内存”中取值

二、synchronized实现可见性

synchronized可以保证方法或者代码块在运行时,同一时刻只有一个线程执行synchronized声明的代码块。还可以保证共享变量的内存可见性

同一时刻只有一个线程执行,这部分代码块的重排序也不会影响其执行结果。也就是说使用了synchronized可以保证并发的原子性,可见性,有序性

1、解决可见性问题

JMM关于synchronized的两条规定:

线程解锁前(退出同步代码块时):必须把自己工作内存中共享变量的最新值刷新到主内存

线程加锁时(进入同步代码块时):将清空本地内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值(加锁与解锁是同一把锁)

做如下修改,在死循环中添加同步代码块

while (flag) {
      synchronized (this) {
     }
   }

代码如下:

public class Demo1Jmm {
    public static void main(String[] args) throws InterruptedException {
        JmmDemo demo = new JmmDemo();
        Thread t = new Thread(demo);
        t.start();
        Thread.sleep(100);
        demo.flag = false;
        System.out.println("已经修改为false");
        System.out.println(demo.flag);
    }

    static class JmmDemo implements Runnable {
        public boolean flag = true;
        public void run() {
            System.out.println("子线程执行。。。");
            while (flag) {
                synchronized (this){
                    
                }
            }
            System.out.println("子线程结束。。。");
        }
    }
}

结果如下:

子线程执行。。。
已经修改为false
子线程结束。。。
false

synchronized实现可见性的过程

(1)、获得互斥锁(同步获取锁)

(2)、清空本地内存

(3)、从主内存拷贝变量的最新副本到本地内存

(4)、执行代码

(5)、将更改后的共享变量的值刷新到主内存

(6)、释放互斥锁

2、同步原理

synchronized的同步可以解决原子性、可见性和有序性的问题,那是如何实现同步的呢?

Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:

(1)、普通同步方法,锁是当前实例对象this

(2)、同步方法块,锁是括号里面的对象

(3)、静态同步方法,锁是当前类的class对象

当一个线程访问同步代码块时,它首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁

synchronized的同步操作主要是monitorenter(监听进入)和monitorexit(监听退出)这两个jvm指令实现的,先写一段简单的代码:

public class Demo2Synchronized {
  public void test2() {
    synchronized (this) {
   }
 }
}

在 cmd命令行执行javac编译和javap -c Java 字节码的指令

D:\project\prism\java9-test\src\test\java\com\zwh>javac Demo2Synchronized.java

D:\project\prism\java9-test\src\test\java\com\zwh>javap -c Demo2Synchronized.class
Compiled from "Demo2Synchronized.java"
public class com.zwh.Demo2Synchronized {
  public com.zwh.Demo2Synchronized();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public void test2();
    Code:
       0: aload_0
       1: dup
       2: astore_1
       3: monitorenter
       4: aload_1
       5: monitorexit
       6: goto          14
       9: astore_2
      10: aload_1
      11: monitorexit
      12: aload_2
      13: athrow
      14: return
    Exception table:
       from    to  target type
           4     6     9   any
           9    12     9   any
}

从结果可以看出,同步代码块是使用monitorenter和monitorexit这两个jvm指令实现的:

三、Volatile实现可见性

通过前面内容我们了解了synchronized,虽然JVM对它做了很多优化,但是它还是一个重量级的锁。而接下来要介绍的volatile则是轻量级的synchronized

如果一个变量使用volatile,则它比使用synchronized的成本更加低,因为它不会引起线程上下文的切换和调度。

Java语言规范对volatile的定义如下:

Java允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量

通俗点讲就是说一个变量如果用volatile修饰了,则Java可以确保所有线程看到这个变量的值是一致的,如果某个线程对volatile修饰的共享变量进行更新,那么其他线程可以立马看到这个更新,这就是内存可见性

volatile虽然看起来比较简单,使用起来无非就是在一个变量前面加上volatile即可,但是要用好并不容易。

1、解决内存可见性问题

如果不加volatile

public class Demo1Jmm {
    public static void main(String[] args) throws InterruptedException {
        JmmDemo demo = new JmmDemo();
        Thread t = new Thread(demo);
        t.start();
        Thread.sleep(100);
        demo.flag = false;
        System.out.println("已经修改为false");
        System.out.println(demo.flag);
    }

    static class JmmDemo implements Runnable {
        public boolean flag = true;
        public void run() {
            System.out.println("子线程执行。。。");
            while (flag) { // 如果flag为true就会一直循环,不会结束
            }
            System.out.println("子线程结束。。。");
        }
    }
}

执行后,打印如下:

发现并没有停止。

原因:默认情况下,内存是不可见的,即线程之间的数据是不可见的,因为使用的是自己本地内存的数据,别的线程是看不到自己本地内存的数据的,故子线程一直把flag当作true不断的循环而不会停止。

在可见性问题案例中进行如下修改,添加volatile关键词,变成volatile变量

private volatile boolean flag = true;

代码如下:

public class Demo1Jmm {
    public static void main(String[] args) throws InterruptedException {
        JmmDemo demo = new JmmDemo();
        Thread t = new Thread(demo);
        t.start();
        Thread.sleep(100);
        demo.flag = false;
        System.out.println("已经修改为false");
        System.out.println(demo.flag);
    }

    static class JmmDemo implements Runnable {
        public volatile boolean flag = true;
        public void run() {
            System.out.println("子线程执行。。。");
            while (flag) { // 如果步修改flag的值为false,就会一直循环,不会停止
            }
            System.out.println("子线程结束。。。");
        }
    }
}

结果如下:

子线程执行。。。
已经修改为false
子线程结束。。。
false

Volatile实现内存可见性的过程

线程写Volatile变量的过程:

(1)、改变线程本地内存中Volatile变量副本的值;

(2)、将改变后的副本的值从本地内存刷新到主内存

线程读Volatile变量的过程:

(1)、从主内存中读取Volatile变量的最新值到线程的本地内存中

(2)、从本地内存中读取Volatile变量的副本

Volatile 实现内存可见性原理(了解):

写操作时,通过在写操作指令后加入一条store屏障指令,让本地内存中变量的值能够刷新到主内存中;

读操作时,通过在读操作前加入一条load屏障指令,及时读取到变量在主内存的值

PS: 内存屏障(Memory Barrier)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序

volatile的底层实现是通过插入内存屏障,但是对于编译器来说,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,所以,JMM采用了保守策略。如下:

(1)、StoreStore 屏障可以保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中。

(2)、StoreLoad 屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序

(3)、LoadLoad 屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。

(4)、LoadStore 屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。

java内存模型为了实现volatile可见性和禁止指令重排两个语义,使用如下内存屏障插入策略:

(1)、每个volatile写操作前边插入Store-Store屏障,后边插入Store-Load(全能)屏障;

(2)、每个volatile读操作前边插入Load-Load屏障和Load-Stroe屏障;

 如图所示:写volatile屏障指令插入策略可以保证在volatile写之前,所有写操作都已经刷新到主存对所有处理器可见了。其后全能型屏障指令为了避免写volatile与其后volatile读写指令重排序。

 读volatile时,会在其后插入两条指令防止volatile读操作与其后的读写操作重排序。

 

posted on 2022-02-07 17:30  周文豪  阅读(355)  评论(0编辑  收藏  举报