【Java并发编程】JMM(Java内存模型)在并发中的原理与应用

苡仁 2020年04月12日 28次浏览

1. JVM内存结构、Java内存模型与Java对象模型 辨析

1.1 JVM内存结构

JVM 内存布局 与 运行时数据区

1.2 Java内存模型

  • Java内存模型则是和并发编程相关。后面会仔细说明

1.3 Java对象模型

  • Java对象模型是指Java对象在虚拟机中的表现形式。

  • Java对象模型是对象自身的存储结构
  • JVM会给类创建一个instanceKlass保存着方法区,用在JVM层表示这个Java类
  • 当我们在代码中使用new创建一个对象时,JVM会创建一个instanceOopDesc对象,这个对象包含了对象头和实例数据。

2. Java内存模型(JMM)解析

  • Java内存模型,(JMM,Java Memory Model)实际上是一组JVM的规范。以便可以统一利用这些规范,更方便得开发多线程程序

2.1 重排序

  1. 重排序案例
/**
 * 重排序示例
 * @author yiren
 */
public class OutOfOrderExecution {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread one = new Thread(() -> {
            a = 1;
            x = b;
        });

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

        one.start();
        two.start();
        one.join();
        two.join();

        System.out.println("x=" + x + ", y=" + y);
    }
}
  • 我们可以想到产生的几种情况:
    1. a=1; x=b; b=1; y=a -> 结果:x=0,y=1
    2. b=1; y=a; a=1; x=b -> 结果:x=1,y=0
    3. b=1; a=1; x=b; y=a -> 结果:x=1,y=1
  • 第三种情况比较难出现,我们改造一下代码
/**
 * 重排序示例
 *
 * @author yiren
 */
public class OutOfOrderExecution {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; x == 0 || y == 0; i++) {
            x = 0;
            y = 0;
            a = 0;
            b = 0;
            CountDownLatch countDownLatch = new CountDownLatch(1);
            Thread one = new Thread(() -> {
                try {
                    countDownLatch.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                a = 1;
                x = b;
            });

            Thread two = new Thread(() -> {
                try {
                    countDownLatch.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                b = 1;
                y = a;
            });

            one.start();
            two.start();
            countDownLatch.countDown();
            one.join();
            two.join();
            System.out.println("i=" + i + ", x=" + x + ", y=" + y);
        }
    }
}
...
i=147267, x=0, y=1
i=147268, x=0, y=1
i=147269, x=0, y=1
i=147270, x=0, y=1
i=147271, x=1, y=1

Process finished with exit code 0
  • 另外还有不容易想到的情况:也就是重排序的情况!x=0 y=0 我们修改下代码,将main函数前面代码改成如下:
    public static void main(String[] args) throws InterruptedException {
        x = 1;
        for (int i = 0; x != 0 || y != 0; i++) {
            x = 0;
            ....
...
i=557, x=1, y=0
i=558, x=0, y=1
i=559, x=1, y=0
i=560, x=1, y=0
i=561, x=0, y=0

Process finished with exit code 0
  • 如上:结果我们发现出现了x=0,y=0,为什么会出现这种情况呢?
    • 也就是我们程序在执行的时候 b=1和y=a 、a=1和x=b重排序了,也就是交换了执行顺序。
  1. 重排序说明
  • 什么是重排序?

    • 也就是代码的执行顺序和代码在Java代码中的顺序不一致,他们的顺序被改变了,这就是重排序
  • 重排序的好处:提供处理速度

    • 重排序会对底层的指令进行优化!如下代码底层指令优化

  • 重排序的几种情况

    • 编译器优化:包括JVM,JIT编译器等
    • CPU指令重排序:如果编译器不指令重排序,CPU也可能对指令进行重排序
    • 内存的“重排序”:线程A的修改,线程B看不到,这实际上是内存可见性的问题。

2.2 可见性

  1. 可见性案例

/**
 * 可见性
 * @author yiren
 */
public class FieldVisibility {
    int a = 1;
    int b = 2;
    public void change() {
        a = 3;
        b = a;
    }
    public void print() {
        System.out.println("FieldVisibility{" +
                "a=" + a +
                ", b=" + b +
                '}');
    }
    public static void main(String[] args) {
        while (true) {
            FieldVisibility visibility = new FieldVisibility();
            Thread one = new Thread(() -> {
                try {
                    TimeUnit.MILLISECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                visibility.change();

            });

            Thread two = new Thread(() -> {
                try {
                    TimeUnit.MILLISECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                visibility.print();
            });

            one.start();
            two.start();
        }
    }
}
...
FieldVisibility{a=3, b=3}
FieldVisibility{a=3, b=3}
FieldVisibility{a=1, b=2}
FieldVisibility{a=1, b=2}
FieldVisibility{a=3, b=3}
FieldVisibility{a=3, b=3}
FieldVisibility{a=3, b=3}
FieldVisibility{a=1, b=2}
FieldVisibility{a=1, b=3}
FieldVisibility{a=3, b=3}
FieldVisibility{a=3, b=3}
...
  • 我们可以想到三种情况:

  • 另外还有一种:FieldVisibility,实际上就是发生了可见性问题,线程2在打印的时候,看到了b=3,但是此时,由于a=3的数据还没有同步到本地内存,所以本地内存的值还是1,就打印出了1和3.

  • 这种多线程操作数据就小概率会出现内存同步不及时。怎么处理呢?我们可以用volatile来解决。修改代码如下部分:

public class FieldVisibility {
    volatile int a = 1;
    volatile int b = 2;
  • 此时两个线程在操作a和b的时候就会强制和主内存或者说线程共享内存同步数据。再次运行就不会再出现a=1, b=3的情况了
  1. 可见性说明

  • CPU有多级缓存(如上图),导致数据过期
    • 高速缓存容量比主存小很多,但是速度非常快,仅次于寄存器的,所以CPU和主存间有多级缓存(现在一般CPU都有3级缓存)
    • 线程间对于共享变量的可见性问题不是多核CPU引起的,而是多级缓存引起的。
    • 假如每个核心只用一个缓存就不会存在可见性问题。
    • 但每个核心运算时都会把自己需要的数据读到独占缓存中,然后直接在独占缓存中修改,然后再刷入主存,所以期间就会有个延时。导致主存的数据和独占缓存中的数据不一致,不同线程操作数据的时候就会残生数据不一致。
  1. JMM,主内存和本地内存

  • 什么是主内存和本地内存?
    • Java屏蔽了底层细节,用JMM规范了一套读写内存数据规范,我们就无需去关心CPU内的工作,但是我们需要知道主内存和本地内存的概念。
    • 本地内存:不是给每个线程分配内存,而是JMM对寄存器,多级缓存的一个抽象。
  • JMM对主内存和本地内存的关系的规定
    • 所有变量都存储在主内存中,但是每个线程有自己的独立的本地内存,本地内存中的变量数据是从主内存中拷贝的
    • 线程不直接读写主内存,而是直接操作本地内存的数据,然后同步到主内存中。
    • 主内存是多线程共享的,线程的本地内存之间不互通,如果要互通只能通过主内存中转。
  • 所有共享变量都存储在主内存中,各个线程拥有自己的本地内存,线程间读写共享数据是通过本地内存的操作实现,所以导致了可见性问题。

2.3 happens-Before原则

  • 前面的操作对后续的操作都是可见的,比如 A happen before B 的意思并不是说 A 操作发生在 B 操作之前,而是说 A 操作对于 B 操作一定是可见的。

遵循happens-before的一些情况:

(1) 单线程规则

happends-Before是运用在一个线程内的。线程内所有的操作无论是否发生重排序后面的语句都可以看到前面语句做的操作。

(2) 锁操作( synchronized 和 Lock )

(3) volatile变量

任何一个线程的操作只要完成了,其他线程就一定能看到它的修改

(4) 线程启动

在一个线程中启动一个子线程,子线程就一定能看到start()前的数据操作。

(5) 线程join

某一线程等待子线程完成,join()后方的语句一定可以看到子线程内部完成的数据操作。

(6) 传递性

如果代码A hb B,B hb C,我们就可以推出: A hb C

(7) 中断

某线程被其他线程interrupt时,那检测中断的isInterrupted 和 抛出异常 InterruptedException 一定能看到。

(8) 构造方法

构造方法的最后一行指令hb于finalize()方法的第一行指令。

(9) 工具类的Happens-Before原则

  • 线程安全容器的get一定能看到在此之前的put等操作。如:ConcurrentHashMap

  • CountDownLatch

  • Semaphore

  • Future

  • 线程池

  • CyclicBarrier

  • 上面可见性案例的volatile的应用解决a=1,b=3的情况,可以更改成只在b变量上加volatile

    • 给b加了volatile,不仅b被印象,也可以实现轻量级的同步关系,b=a之前的写入对于之后的读取,都是可见的,所以在写线程里面对a的赋值(a=3),一定会对读线程的读取可见。a即使不加volatile,只要b读到的是3,那么此时a=3 hb b=a,b=a hb print(b a) 这个时候,happens-before原则就保证了读取到的a是3,而不是1。

2.4 volatile关键字

  1. volatile是什么?
  • volatile是一种同步机制,比synchronizedLock相关类更轻量,因为使用volatile并不会发生上下文切换等开销很大的行为。
  • 如果一个变量被volatile修饰,JVM就会知道这个变量可能会被并发修改。
  • 开销小,能力也小,虽然volatile是用来同步保证线程安全的 ,但是volatile做不到synchronized那样的原子保护。它仅仅在有限的场景下发挥作用。
  1. volatile的使用场景
  • 不适用

    • index++
    /**
     * index ++
     * @author yiren
     */
    public class VolatileNoValid {
        private static volatile int index = 0;
        private static AtomicInteger atomicInteger = new AtomicInteger();
        public static void main(String[] args) throws InterruptedException {
            Runnable runnable = () -> {
                for (int i = 0; i < 10000; i++) {
                    index++;
                    atomicInteger.incrementAndGet();
                }
            };
            Thread thread = new Thread(runnable);
            thread.start();
            Thread thread1 = new Thread(runnable);
            thread1.start();
    
            thread.join();
            thread1.join();
    
            System.out.println(index);
            System.out.println(atomicInteger.get());
        }
    
    }
    
    16793
    20000
    
    Process finished with exit code 0
    
  • 适用

    • 赋值场景:一个共享变量自始至终只被各个线程赋值,而没有其他操作,就可以使用volatile替代synchronized或者线程安全类,因为赋值本省是有原子性的,而volatile有保证了可见性,所以足以保证线程安全。如:boolean flag

    • 作为刷新之前变量的触发器。

      • 很好第一个案例就是前面的可见性的时候的案例,设置b为volatile,就把a的数据刷新了。
      /**
       * 可见性
       * @author yiren
       */
      public class FieldVisibility {
          int a = 1;
          volatile int b = 2;
          public void change() {
              a = 3;
              b = a;
          }
          public void print() {
              System.out.println("FieldVisibility{" +
                      "a=" + a +
                      ", b=" + b +
                      '}');
          }
          public static void main(String[] args) {
              while (true) {
                  FieldVisibility visibility = new FieldVisibility();
                  Thread one = new Thread(() -> {
                      try {
                          TimeUnit.MILLISECONDS.sleep(1);
                      } catch (InterruptedException e) {
                          e.printStackTrace();
                      }
                      visibility.change();
      
                  });
      
                  Thread two = new Thread(() -> {
                      try {
                          TimeUnit.MILLISECONDS.sleep(1);
                      } catch (InterruptedException e) {
                          e.printStackTrace();
                      }
                      visibility.print();
                  });
      
                  one.start();
                  two.start();
              }
          }
      }
      
      • 还有一种常见的做法:当两个线程衔接的时候可以利用volatile保证数据的可见性。
        • 利用volatile保证可见性 和 happens-before原则 就可以完成如下的线程同步。
      /**
       * @author yiren
       */
      public class VolatileExample11 {
      
          private static Map<String, String> configOptions;
          private static char[] configText;
          private static volatile boolean initialized = false;
      
          private static void processConfig(Map<String, String> configOptions, char[] configText) {
              // ... do something
          }
      
      
          public static void main(String[] args) {
              Thread configThread = new Thread(() -> {
                  configOptions = new HashMap<>();
                  configText = "......".toCharArray();
                  processConfig(configOptions, configText);
                  initialized = true;
              });
      
              Thread workThread = new Thread(() -> {
                  while (!initialized) {
                      try {
                          TimeUnit.MILLISECONDS.sleep(200);
                      } catch (InterruptedException e) {
                          e.printStackTrace();
                      }
                      // use config.....
                  }
              });
      
              configThread.start();
              workThread.start();
          }
      }
      
  1. volatile的作用:可见性、禁止重排序
    • 可见性:读取volatile变量时本地内存数据失效,必须到主存中读取最新值;写入volatile变量是会立即刷入到主内存。
    • 禁止指令重排序:如:解决单例双重锁乱序问题。(后面会有案例)
  2. volatile和synchronized的关系
    • volatile可以看做一个轻量版的synchronized:如果一个共享变量自始至终只被各个线程赋值,而没有其他的操作,那么就可以用volatile来替代synchronized或替代原子变量,赋值是原子性的操作,并且volatile又保证了可见性。所以就足够以保证线程安全。
  3. 用volatile解决重排序问题。
    • 我们可以通过添加volatile关键字 解决2.1中的案例的重排序问题。
  4. 关于volatile的点:
    1. volatile属性的读写操作都是无锁的,因为它没有提供原子性和互斥性,不能替代synchronized;而也因为无锁,它的成本很低。
    2. volatile只作用于属性,在对volatile属性做操作时,编译器会禁止指令重排序。
    3. volatile提供了可见性:任何线程对其修改和查看都会与主存交互刷新。
    4. volatile提供了happens-before保证,可以利用传递性来做一些数据刷新和可见性保证
    5. volatile可以使long和double的赋值编程原子操作

2.5 保证可见性的方法与对synchronized可见性的理解

  1. 保证可见性的方法

    • synchronizedLock、并发集合、Thread.join()Thread.start()等都可以保证可见性
    • 具体看happens-before原则章节
  2. synchronized可见性的理解

    • synchronized 不仅保证了原子性,还保证了可见性。 如多线程运算index++代码:
    /**
     * @author yiren
     */
    public class SynchronizedDisappear {
        private static Object object = new Object();
        private static int index = 0;
    
        public static void main(String[] args) throws InterruptedException {
            Runnable runnable = () -> {
                synchronized (object) {
                    while (index < 10000) {
                        index++;
                    }
                }
            };
    
            Thread thread = new Thread(runnable);
            Thread thread1 = new Thread(runnable);
    
            thread.start();
            thread1.start();
    
            thread.join();
            thread1.join();
            System.out.println(index);
        }
    }
    
    • synchronized仅仅让代码块中的代码安全,也会让之前的代码具有可见性, 如下案例:
    /**
     * 可见性 利用Synchronized
     *
     * @author yiren
     */
    public class FieldVisibilitySynchronized {
        private int a = 1;
        private int b = 2;
        private int c = 2;
        private int d = 2;
    
        public void change() {
            a = 3;
            b = 4;
            c = 5;
            synchronized (this) {
                d = 6;
            }
        }
    
        public void print() {
            int aa;
            synchronized (this) {
                aa = a;
            }
            int bb = b;
            int cc = c;
            int dd = d;
    
            System.out.println("FieldVisibility{" +
                    "a=" + aa +
                    ", b=" + bb +
                    ", c=" + cc +
                    ", d=" + dd +
                    '}');
        }
    
        public static void main(String[] args) {
            FieldVisibilitySynchronized visibility = new FieldVisibilitySynchronized();
            Thread one = new Thread(() -> {
                try {
                    TimeUnit.MILLISECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                visibility.change();
    
            });
    
            Thread two = new Thread(() -> {
                try {
                    TimeUnit.MILLISECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                visibility.print();
            });
    
            one.start();
            two.start();
        }
    }
    
    FieldVisibility{a=3, b=4, c=5, d=6}
    
    Process finished with exit code 0
    

2.6 原子性与单例

  1. 原子性:
  • 什么是原子性?

    • 一系列操作,要么全部执行成功,要么全部不执行,不会执行一部分,是不可分割的。(要么做要么不做)

    !!!i++不是原子性操作!!! 但是可以用synchronized来实现原子性操作,系列文章中有讲到这个操作。

  • Java中的一些原子性操作

    • 除了longdouble的6个基本类型的赋值操作
    • 所有reference的赋值操作,无论32还是64位机器
    • java.concurrent.atomic.*包下的类
  • longdouble的原子性

    官方文档说明:https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.7 如下:

    17.7. Non-Atomic Treatment of double and long
    For the purposes of the Java programming language memory model, a single write to a non-volatile long or double value is treated as two separate writes: one to each 32-bit half. This can result in a situation where a thread sees the first 32 bits of a 64-bit value from one write, and the second 32 bits from another write.
    
    Writes and reads of volatile long and double values are always atomic.
    
    Writes to and reads of references are always atomic, regardless of whether they are implemented as 32-bit or 64-bit values.
    
    Some implementations may find it convenient to divide a single write action on a 64-bit long or double value into two write actions on adjacent 32-bit values. For efficiency's sake, this behavior is implementation-specific; an implementation of the Java Virtual Machine is free to perform writes to long and double values atomically or in two parts.
    
    Implementations of the Java Virtual Machine are encouraged to avoid splitting 64-bit values where possible. Programmers are encouraged to declare shared 64-bit values as volatile or synchronize their programs correctly to avoid possible complications.
    
    出于Java编程语言内存模型的目的,一次对非易失性long或 double值的写入被视为两次单独的写入:一次写入每个32位的一半。这可能导致线程从一次写入中看到64位值的前32位,而从另一次写入中看到后32位的情况。
    
    被volatile修饰的double和long的值的写入和读取始终是原子的。
    
    引用的写入和读取始终是原子的,无论它们是实现为32位还是64位值。
    
    一些实现可能会发现将对64位long或double值的单个写操作划分为对相邻32位值的两个写操作很方便。为了提高效率,此行为是特定于实现的;Java虚拟机的实现可以自由执行原子和两部分的写入long和double值。
    
    鼓励Java虚拟机的实现避免在可能的情况下拆分64位值。鼓励程序员将共享的64位值声明为volatile或正确同步其程序,以避免可能的复杂性。
    
    • 注意:在32位JVM上,long和double的操作不是原子的,但是64位的JVM是原子的。
    • 由于上面是java虚拟机的一些规范,实际开发中,商用java虚拟机中不会出现。
  • 原子性+原子性 != 原子性

    • 举个例子:把HashMap的操作全部做同步处理加上synchronized,此时单个操作,都是原子的,但是如果对HashMap去组合操作此时就不是同步的了。
  1. 单例的8中写法

    • 作用:节省内存和计算(避免反复计算和获取)、保证结果正确(多线程共享)、方便管理(工具类)

    • 使用场景:无状态工具类、全局信息类等

    • 有8种写法(详见GitHub代码),主要讲一下double-check

    /**
     * 懒汉式-6.(线程安全 推荐面试使用)
     * @author yiren
     */
    public class Singleton6DoubleCheck {
        private static volatile Singleton6DoubleCheck INSTANCE;
    
        private Singleton6DoubleCheck() {
        }
    
        public static Singleton6DoubleCheck getInstance() {
            if (null == INSTANCE) {
                synchronized (Singleton6DoubleCheck.class) {
                    if (null == INSTANCE) {
                        INSTANCE = new Singleton6DoubleCheck();
                    }
    
                }
            }
            return INSTANCE;
        }
    }
    
    • 优点:延迟加载 线程安全 效率较高
    • 为什么要用double-check?
      1. 保证线程安全
      2. 单check(synchronized里面的if去掉)行不行?不行!这样会有并发问题
      3. 把synchronized放到方法上用单check可以吗?可以,但是性能低。
    • 为什么要用volatile?
      • 新建对象有三个步骤:(1) 创建一个空的对象;(2)调用构造方法;(3)赋值给引用
      • 如果重排序了。可能会带来NPE
      • volatile是为了防止重排序

3. 面试问题

  1. 单例模式的8种写法、单例和并发的关系,为什么要用double-check?为什么double-check要用volatile? 那种模式最好?(枚举)

  2. 讲一讲什么是Java内存模型?

    • 是一个规范
    • 特性:重排序、可见性、happens-before原则、volatile关键字,原子性。
  3. volatile和synchronized的异同?

  4. 什么是原子操作?Java中有哪些原子操作?生成对象的过程是不是原子操作?

  5. 什么是内存可见性? -》 cpu的多级缓存和RAM的关系 -》JMM

  6. 64位的double和long写入的时候是原子的吗?

    • 规范前后32位分开写不是原子性的->我们用的商用虚拟机是处理过这个问题的,是原子操作。

  • 文章内容来源:
  • 《Java并发编程艺术》、JDK1.8版本源码、慕课网悟空JUC课程