java 进阶

深入理解JMM+Volatile

2019-07-30  本文已影响0人  _zhaoyan_

计算机多核并发缓存架构

下图是计算机运行架构图:


计算机运行缓存并发架构图.png

  由于cpu的运行程序速度远大于主存储的速度,所以会在主存RAM和CPU之间加多级高速缓存,缓存的速度接近cpu的运行速度,木桶效应,水能装多少,取决于短板,因此这样会大大提高计算机的运行速度。

java内存模型

定义(摘抄《深入理解+Java+内存模型_程晓明》)

  java线程内存模型跟cpu缓存模型类似,是基于cpu缓存模型来建立的,java线程内存模型是标准化的。
在java中,所有实例域,静态域和数组元素存储在堆内存中,堆内存在线程之间共享。

  局部变量 ,方法定义参数和异常处理器参数,这些不会在线程之间 共享,它们不会有内存可见性问题,也不受内存模型的影响

  Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个 线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了 线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存 中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不 真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java 内存模型的抽象示意图如下:

以上还是比较通俗易懂的

内存模型的三大特性

可见性:
  可见性是一种复杂的属性,因为可见性中的错误总是会违背我们的直觉。通常,我们无法确保执行读操作的线程能适时地看到其他线程写入的值,有时甚至是根本不可能的事情。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。
  可见性,是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果。另一个线程马上就能看到。比如:用volatile修饰的变量,就会具有可见性。volatile修饰的变量不允许线程内部缓存和重排序,即直接修改内存。所以对其他线程是可见的。但是这里需要注意一个问题,volatile只能让被他修饰内容具有可见性,但不能保证它具有原子性。比如 volatile int a = 0;之后有一个操作 a++;这个变量a具有可见性,但是a++ 依然是一个非原子操作,也就是这个操作同样存在线程安全问题。
  在 Java 中 volatile、synchronized 和 final 实现可见性。

原子性:
  原子是世界上的最小单位,具有不可分割性。比如 a=0;(a非long和double类型) 这个操作是不可分割的,那么我们说这个操作时原子操作。再比如:a++; 这个操作实际是a = a + 1;是可分割的,所以他不是一个原子操作。非原子操作都会存在线程安全问题,需要我们使用同步技术(sychronized)来让它变成一个原子操作。一个操作是原子操作,那么我们称它具有原子性。java的concurrent包下提供了一些原子类,我们可以通过阅读API来了解这些原子类的用法。比如:AtomicInteger、AtomicLong、AtomicReference等。
  在 Java 中 synchronized 和在 lock、unlock 中操作保证原子性。

有序性:
  Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 是因为其本身包含“禁止指令重排序”的语义,synchronized 是由“一个变量在同一个时刻只允许一条线程对其进行 lock 操作”这条规则获得的,此规则决定了持有同一个对象锁的两个同步块只能串行执行。

volatile一致性

看一下下面代码:

package com.zhaoyan.volatileexample;

public class VolatileVisibleness {

    private static boolean flag =false;
    public static void main(String[] args)  throws InterruptedException {
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("等待数据准备中---");
                while(!flag){

                }
                System.out.println("数据准备结束,开始执行...");
            }
        }).start();
        Thread.sleep(2000);
        new Thread(new Runnable() {
            @Override
            public void run() {
                setFlag();
            }
        }).start();
    }

    public static void setFlag() {
        System.out.println("数据准备中---");
        VolatileVisibleness.flag = true;
        System.out.println("数据准备结束---");
    }

}

以上代码分析:
  主函数中有两个线程,一个线程用来获取逻辑处理标签flag结果标签,并继续执行程序,一个线程用来处理flag标签逻辑。我们想要的结果是,在线程休眠2秒之后,flag=true的时候,被第一个线程获取到,并继续执行 程序,打印数据准备结束,开始执行...

执行结果如下:


如图可知,程序在死循环等待中,也就是说
image.png 这行代码执行之后,while的条件没有被执行,数据准备结束,开始执行...未被打印;

这个现象验证了内存模型中存在这样的一块区域是线程独享的。


  当我修改线程B中的局部变量后,线程A并不知道flag的值已经被修改,导致程序死循环。
如何达到预期的效果,使数据准备结束,开始执行...开始执行呢!就是加volatile关键字修改变量。
private static volatile boolean flag =false;

然后在执行的结果如下:

等待数据准备中---
数据准备中---
数据准备结束---
数据准备结束,开始执行...

内存之间的原子操作

Java 内存模型对主内存与工作内存之间的具体交互协议定义了八种操作,具体如下:

read(读取):作用于主内存变量,把一个变量从主内存传输到线程的工作内存中,以便随后的 load 动作使用。

load(载入):作用于工作内存变量,把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。

use(使用):作用于工作内存变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量值的字节码指令时执行此操作。

assign(赋值):作用于工作内存变量,把一个从执行引擎接收的值赋值给工作内存的变量,每当虚拟机遇到一个需要给变量进行赋值的字节码指令时执行此操作。

store(存储):作用于工作内存变量,把工作内存中一个变量的值传递到主内存中,以便后续 write 操作。

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

lock(锁定):作用于主内存变量,把一个变量标识为一条线程独占状态。

unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

普通变量底层执行流程

根据前面代码,我们根据这个图来说明一下程序的执行流程:


volatile修饰后的变量底层执行流程

如何让工作内存的变量相等!!!


image.png

早期解决变量一致性的问题设计:

总线加锁:

  cpu从主内存读取数据到高速缓存,会在总线对这个数据加锁,这样其他cpu没法去读或写这个数据,直到cpu使用完数据释放锁之后,其他cpu才能读取该数据。



  在线程B读数据到工作内存中的时候进行加锁,一直到cpu执行结束并将数据重新写到对内存之后才能释放锁,这个时候线程A再读取已经改变的数据加载到线程A的本地内存中操作。
  这样的做存在的问题有:
(1)性能降低
(2)没有充分利用多核计算机的特性

缓存一致性协议:

  多个cpu从主内存读取同一个数据到各自的高速缓存,当其中某个cpu修改了缓存里的数据,该数据会马上同步回主内存,其他cpu通过总线嗅探机制(监听)可以感知数据的变化从而将自己的缓存里的数据失效。



  对比总线加锁,缓存一致性协议的锁的力度小了很多,只在总线写入前开始加锁,为了保证总线监听到的数据一定是被写入了堆内存。看volatile的执行汇编底层语言,也会看到在数据修改前会加lock。

  以上是volatile的可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile 变量最后的写入;

volatile原子性

  在JSR -133之前的旧内存模型中,一个64位long/ double型变量的读/ 写操作可以被拆分为两个32位的读/写操作来执行。从JSR -133内存模型开始 (即从JDK5开始),仅仅只允许把一个64位long/ double型变量的写操作拆分 为两个32位的写操作来执行,任意的读操作在JSR -133中都必须具有原子性(即 任意读操作必须要在单个读事务中执行)

  对任意单个volatile变量的读/写具有原子性,但类似于volatile++这 种复合操作不具有原子性。

  锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这 意味着对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最 后的写入。

看一下下面代码:

package com.zhaoyan.volatileexample;
public class VolatileAtomic {

    private static volatile long num = 0L;

    private static void increase() {
        num++;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[10];
        for (int i = 0; i < threads.length; i++) {
            Thread thread = threads[i];
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 1000; i++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }
        for (Thread thread : threads) {
            thread.join();
        }
        System.out.println(num);
    }
}

以上代码分析如下:
  十个线程一起执行num+1的操作,等十个线程都执行结束之后,将num的数值输出,num用volatile修饰,保证内存的可见性;

代码执行结果如下:



可以看到,执行的结果是10000以下;

为什么会出现这种情况?
结合图例:

jieguo3
分析:
  线程A加载num之后,use,进行num++,之后assign,num=1,在num store总线加锁之前,线程B执行,num的assign,num=1;这个时候,线程A进行lock,根据总线嗅探机制,这个时候线程B发现数据有变化,进行变量失效,导致,线程B执行的mum++的结果值失效,进而形成程序执行的效果;
  锁的语义决定了临界区代码的执行具有原子性。这意味着即使是64位的long型和 double型变量,只要它是volatile变量,对该变量的读写就将具有原子性。如果 是多个volatile操作或类似于volatile++这种复合操作,这些操作整体上不具有原 子性。
以上我们验证了volatile的原子性;

volatile有序性

  在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。
  为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存 屏障来禁止特定类型的处理器重排序。
  代码样例:

package com.zhaoyan.volatileexample;

import java.util.HashMap;
import java.util.Map;

/**
 * @ClassName VolatileOrder
 * @Description TODO
 * @Author zhaoyan
 * @Date 2019/7/31 11:03
 * @Version 1.0
 **/
public class VolatileOrder {
    static int x = 0, y = 0;


    public static void main(String[] args) throws Exception {
        Map<String, Integer> map = new HashMap<>();
        for (int i = 0; i < 10000; i++) {
            x = 0;
            y = 0;
            map.clear();
            Thread one = new Thread(new Runnable() {
                @Override
                public void run() {
                    int a = y; //1
                    x = 1;     //2
                    map.put("a", a);
                }
            });
            Thread two = new Thread(new Runnable() {
                @Override
                public void run() {
                    int b = x;   //3
                    y = 1;       //4
                    map.put("b", b);
                }
            });

            one.start();
            two.start();
            one.join();
            two.join();
            System.out.println(map.toString());
        }
    }
}

以上代码分析:
正常理解的情况下,我们能推测出程序答应的结果可能是
(1)线程1先执行,然后再执行线程2,这样输出的结果是[0,1];
(2)线程2先执行,然后再执行线程1,这样输出的结果是[1,0];
(3)线程1先执行,但是没有执行到x=1的时候,线程2 执行,结果是[0,0];

实际结果如下:


1.png
2.png
3.png
4.png

  我们看到执行结果有4种情况,那么[1,1]的情况是怎么出现的呢?
  这里面涉及到JMM模型的重排序原理,主要有if-else-serial语义,数据依赖性,程序顺序规则等相关知识。不一一说明了;
volatile修饰的变量可以阻止这种重排序,利用的内存屏障的原理;
  volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排 序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。从编译器重排序 规则和处理器内存屏障插入策略来看,只要volatile变量与普通变量之间的重排序 可能会破坏volatile的内存语义,这种重排序就会被编译器重排序规则和处理器内 存屏障插入策略禁止。
以上说明了volatile在并发三大特性中所发挥的作用,如果觉得可以,请留下爱心;

上一篇下一篇

猜你喜欢

热点阅读