Java内存模型和底层原理

1. 什么是JMM

JMM:Java Memory Model,JMM并不像JVM内存结构一样是真实存在的。他只是一个抽象的概念;

1.1 JVM内存结构 VS Java内存模型 VS Java对象模型

JVM内存结构 VS Java内存模型 VS Java对象模型

1.2 JMM是一组规范

JMM是和多线程相关的,他描述了一组规则或规范,需要各个JVM的实现来遵守JMM规范,以便于开发者可以利用这些规范,更方便的开发多线程程序;

Java的内存模型不仅受到Java底层的影响,更是受到硬件影响,现在计算机的CPU都有多个核心,每个核心都有自己的寄存器和高速缓存器,CPU在处理对象时和线程一样会从主内存中Copy一份到Cache内存中,Cache内存在修改完这个对象的数据副本再刷新到主内存,这样就会造成缓存不一致的问题,Java层面必须要想办法解决这个问题
在这里插入图片描述

为了屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量

所以我认为可以这样理解:JMM = 对运行时底层内存模型的抽象+一组能保证线程之间可见性,原子性,有序性的规则

① 关于底层内存的抽象
关于底层内存模型的抽象就是对上面说的CPU,寄存器,主存,高速缓存等的抽象

② 一组能保证线程之间可见性,原子性,有序性的规则
也就是我们常常听到的happens-before规则

1.3 JMM是工具类和关键字的原理

volatile,synchronized,lock等的原理都是JMM,如果没有JMM,那就需要我们自己指定什么时候使用内存栅栏等,那是相当麻烦的,幸好有了JMM,让我们只需要用同步工具和关键字就能开发并发程序

对于JMM重要的三点内容是重排序,可见性,原子性

1.4 JMM的规定

JMM有以下规定
① 所有变量都存储在主内存中,同时每个线程也有自己的独立的工作内存(对寄存器,一级缓存,二级缓存等的抽象),工作内存中的变量内容都是主内存中的拷贝
② 线程不能直接读写主内存中的变量,而是只能操作自己工作内存中的变量,然后再同步到主内存中
③ 主内存是多个线程共享的,但线程间不共享工作内存,如果线程间需要通信,必须借助主内存中转来完成

2. 可见性

要编写正确的并发程序,关键在于正确管理共享的可变状态,我们不仅希望防止某个线程正在使用对象状态而另一个状态在同时修改该状态,而且希望确保当一个线程修改了对象状态后,其他线程能看到发生的状态变化;
线程同步可以处理这个问题,但是除此之外还有更细粒度的方法去保证共享变量的可见性

2.1 失效数据 - 退不出的循环

static boolean run = true;
static int num = 0;

public static void main(String[] args) throws InterruptedException {
	Thread t = new Thread(() -> {
		while (run) {            
			// ....        
		}   
		System.out.println(number);
	});    
	t.start();
	sleep(1);
	number = 43;
	run = false; // 线程t不会如预想的停下来 
	}
}

在单线程环境中,如果向某个变量写入值,然后在没有其他写入操作的情况下读取这变量,那么总能得到相同的值

然而当读操作和写操作在不同线程中执行情况却并非如此,比如运行上面的代码发现t线程可能会永远运行停不下来,就好像主线程对状态变量run的修改对t线程不可见一样,这就是内存可见性问题

2.2 为什么会出现可见性问题

CPU有多级缓存,导致读的数据过期

  • 高速缓存的容量比主内存小,但是速度仅次于寄存器,所以在cpu和主内存之间就多了Cache层
  • 线程间的对于共享变量的可见性问题不是由多核引起的,而是由多缓存引起的
  • 如果所有的核心都只用一个缓存,那么也就不存在内存可见性问题
  • 每个核心都会将自己需要的数据读到独占的缓存中,数据修改也是写入到缓存,然后等待刷入主存中,所以会导致有些核心读取的值是一个过期的值

依旧是开头的那段代码,他的运行流程是这样的

  1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
    在这里插入图片描述

  2. 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中, 减少对主存中 run 的访问,提高效率
    在这里插入图片描述

  3. 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量 的值,结果永远是旧值
    在这里插入图片描述

2.3 从八大数据原子操作细看可见性问题

上面只是大致说了一下可见性问题产生的原因,实际上对于程序的运行过程中共享变量是如何从主内存加载到工作内存中,又是怎么从工作内存同步回主内存,JMM底层定义了一系列的数据原子操作

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

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

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

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

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

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

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

  • write(写入): 作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中

我们来对应下面的代码看JMM的底层操作,更深层度的探究经典的退不出的循环到底是哪里出现了问题

public class VolatileVisibilityTest {
    private static boolean initFlag = false;

    public static void main(String[] args) throws InterruptedException {
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("wait data");
                while (!initFlag){

                }
                System.out.println("===success===");
            }
        }).start();

        TimeUnit.SECONDS.sleep(1);
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("prepareData");
                initFlag = true;
            }
        }).start();
    }
}

① 可以看到对initFlag操作的有两个线程,initFlag变量最初在主内存中
在这里插入图片描述
② 多个线程开始运行,对于第一个线程要把共享变量initFlag从主内存读取出来(read),在加载到自己的工作内存中去(load)
在这里插入图片描述
③ 在线程1中我要对变量initFlag取反进行判断,那就是cpu对加载到工作内存的这个变量进行use操作,不断检查,不断空转
在这里插入图片描述
④ 对于线程2也一样的操作把主内存中的共享变量读取到自己的工作内存,再交给CPU使用
在这里插入图片描述
⑤ 只不过它对initFlag进行了赋值操作,操作完要把结果写回到工作内存(assign
在这里插入图片描述
⑥ 等线程2执行完,再把工作内存中的变量同步回主内存

⑦ 为了同步,是先把改变的值写回主内存(store)
在这里插入图片描述
⑧ 再执行write操作进行赋值
在这里插入图片描述
⑨ 你这里就发现了线程2改变的数据只在线程2执行完同步到了主内存,而线程1中的数据依旧没变,所以永远都退不出循环

对于这个问题有一个简单的解决方法就是对t线程和主线程加锁做同步处理
内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果
在这里插入图片描述
如图所示,当线程A进入同步代码块时B无法再进入此同步代码块访问共享变量,只有当A退出同步代码块释放锁B才能进入,这种情况可以保证B执行由锁保护的同步代码块时,可以看到线程A之前在同一个同步代码块的所有操作结果

3. 重排序

3.1 什么是重排序

回到开头的代码

static boolean run = true;
static int num = 0;

public static void main(String[] args) throws InterruptedException {
	Thread t = new Thread(() -> {
		while (run) {            
			// ....        
		}   
		System.out.println(number);
	});    
	t.start();
	sleep(1);
	number = 43;
	run = false; // 线程t不会如预想的停下来 
	}
}

上面的代码运行还有可能出现一种情况,t线程退出但是number值输出为0;这就好像线程先看到了状态变量run的改变而没有看到number的改变,仿佛主线程中number的改变被放到的run的后面,这就是重排序问题:在没有同步的情况下,编译器,处理器以及运行时都可能对操作的执行顺序进行调整

3.2 为什么会有重排序问题

一个好的内存模型实际上会放松对处理器和编译器规则的束缚,也就是说软件技术和硬件技术都为同一个目标而进行奋斗:在不改变程序执行结果的前提下,尽可能提高并行度。JMM对底层尽量减少约束,使其能够发挥自身优势。因此,在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序
在这里插入图片描述

  1. 编译器重排序
    编译器优化的重排序: 编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;

  2. 处理器重排序
    指令级并行的重排序: 现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
    内存系统的重排序: 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的

针对编译器重排序,JMM的编译器重排序规则会禁止一些特定类型的编译器重排序;
针对处理器重排序,编译器在生成指令序列的时候会通过插入内存屏障指令来禁止某些特殊的处理器重排序

在这里插入图片描述

4. 利用规则保证可见性 :happens-before规则

4.1 数据依赖性

如果两个操作访问同一个变量,且这两个操作有一个为写操作,此时这两个操作就存在数据依赖性
读后写;② 写后写;③ 写后读,这三种操作都是存在数据依赖性的,如果重排序会对最终执行结果会存在影响。

编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序

4.2 as-if-serial

as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提供并行度),(单线程)程序的执行结果不能被改变

编译器,runtime和处理器都必须遵守as-if-serial语义。as-if-serial语义把单线程程序保护了起来

as-if-serial语义使程序员不必担心单线程中重排序的问题干扰他们,也无需担心内存可见性问题

遵守as-if-serial语义的编译器,runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的

4.3 happens-before规则

用来解决可见性问题
一会是编译器重排序一会是处理器重排序,如果让程序员再去了解这些底层的实现以及具体规则,那么程序员的负担就太重了,严重影响了并发编程的效率。因此,JMM为程序员在上层提供了六条规则,这样我们就可以根据规则去推论跨线程的内存可见性问题,而不用再去理解底层重排序的规则

JSR-133使用happens-before的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以是在不同线程之间。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证

(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)

① 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

② 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)

happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的

具体规则如下:

  • 程序顺序规则: 一个线程中的每个操作,happens-before于该线程中的任意后续操作。(即单线程内按代码顺序执行。但是,在不影响在单线程环境执行结果的前提下,编译器和处理器可以进行重排序,这是合法的。换句话说,这一是规则无法保证编译重排和指令重排)

  • 监视器锁规则(Synchronized 和lock): 对一个锁的解锁,happens-before于随后对这个锁的加锁。
    在这里插入图片描述

  • volatile变量规则(volatile 规则): 对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

  • 传递性: 如果A happens-before B,且B happens-before C,那么A happens-before C。

  • start()规则(线程启动规则): 如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。

  • join()规则: 如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

  • 程序中断规则: 对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。

  • 对象finalize规则: 一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始

5. volatile变量规则

5.1 什么是volatile

volatile的两点作用
① 可见性:读一个volatile变量之前,需要先使相应的本地缓存失效,这样就必须到主内存读取最新值,写一个volatile属性会立即刷入到主内存
② 禁止指令重排序优化:解决单例双重锁乱序问题

5.1 利用volatile解决可见性问题

5.1.1 从happens-before规则看volatile

JMM规定对一个 volatile域的写, happens-before于后续对这个 volatile域的读

(一个线程写了volatile域,其他线程如果执行读操作就会知道它改变了),其实就是如果一个变量声明成是 volatile的,那么当我读变量时,总是能读到它的最新值,这里最新值是指不管其它哪个线程对该变量做了写操作,都会立刻被更新到主存里,我也能从主存里读到这个刚写入的值。

至此,开头的失效数据 - 退不出的循环就能使用volatile关键字解决

static volatile boolean run = true;
static int num = 0;

public static void main(String[] args) throws InterruptedException {
	Thread t = new Thread(() -> {
		while (run) {            
			// ....        
		}   
		System.out.println(number);
	});    
	t.start();
	sleep(1);
	number = 43;
	run = false; 
	}
}

5.1.2 从内存语义上来看volatile
  • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存

  • 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量

而在更底层是通过MESI缓存一致性协议实现的

MESI缓存一致性协议:多个CPU从主存读取同一个数据到各自的高速缓存,当其中某个CPU修改了缓存里面的数据,该数据会马上同步回主存,其他CPU通过总线嗅探机制可以感知到数据的变化从而将自己的缓存中的数据失效

回到上面使用8大原子操作详解可见性问题
① 当被volatile修饰的变量在线程2被修改了以后,会立马将当前处理器缓存行的数据写回主存而不是等方法执行完再同步

② 其他CPU通过总线嗅探机制可以感知到数据的变化从而将自己的缓存中的数据失效,那么下一次的读取就要去主存中读取了,也就是最新的值

③ 在store前,为了防止多个线程同时对主存的更改,要对这个行为进行加锁,也就是lock

在这里插入图片描述

5.1.3 近朱者赤
int a = 1;
volatile int b = 2;
public void change(){
	a = 3;
	b = a;
}

5.2 禁止重排序

volatile
为了提供一种比锁更轻量级的线程间的通信机制,JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序

volatile关键字通过“内存屏障”来防止指令被重排序**,为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序

什么是内存屏障?硬件层面,内存屏障分两种:读屏障(Load Barrier)和写屏障(Store Barrier)。内存屏障有两个作用:

① 阻止屏障两侧的指令重排序;
② 强制把写缓冲区/高速缓存中的脏数据等写回主内存,或者让缓存中相应的数据失效。

编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。编译器选择了一个比较保守的JMM内存屏障插入策略,这样可以保证在任何处理器平台,任何程序中都能得到正确的volatile内存语义。这个策略是:

  • 在每个volatile写操作的前面插入一个StoreStore屏障,禁止上面的普通写和下面的volatile写重排序;

  • 在每个volatile写操作的后面插入一个StoreLoad屏障,防止上面的volatile写与下面可能有的volatile读/写重排序

  • 在每个volatile读操作的后面插入一个LoadLoad屏障,禁止下面所有的普通读操作和上面的volatile读重排序

  • 在每个volatile读操作的后面插入一个LoadStore屏障,禁止下面所有的普通写操作和上面的volatile读重排序
    在这里插入图片描述
    在这里插入图片描述

再逐个解释一下这几个屏障。注:下述Load代表读操作,Store代表写操作
在这里插入图片描述

LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。

StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,这个屏障会吧Store1强制刷新到内存,保证Store1的写入操作对其它处理器可见。

LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的(冲刷写缓冲器,清空无效化队列)。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

总结一下volatile与普通变量的重排序规则:

① 如果第一个操作是volatile读,那无论第二个操作是什么,都不能重排序;

② 如果第二个操作是volatile写,那无论第一个操作是什么,都不能重排序;

③ 如果第一个操作是volatile写,第二个操作是volatile读,那不能重排序。

static volatile boolean run = true;
static int num = 0;

public static void main(String[] args) throws InterruptedException {
	Thread t = new Thread(() -> {
		while (run) {            
			// ....        
		}   
		System.out.println(number);
	});    
	t.start();
	sleep(1);
	number = 43;//step 1
	run = false; //step 2
	}
}

所以对于这个例子,step 1,是普通变量的写,step 2是volatile变量的写,那符合第2个规则,不能重排序

5.3 volatile的适用场景

当且仅当满足下面条件的时候才使用volatile变量

  • 对变量的写入操作不依赖变量的当前值,或者能保证只有单个线程更新变量的值
  • 该变量不会与其他状态变量一起纳入不变性条件中
  • 在访问变量的时候不需要加锁

boolean flag,如果一个共享变量自始至终只是被各个线程赋值,而没有其他的操作,那么就可以用volatile来代替synchronized或者代替原子变量,因为赋值自身是原子性的,而volatile又保证了可见性,所以就足以保证线程安全

② 作为刷新之前的触发器

Map configOptions;
char[] configText;
volatile boolean initialized = false;
//Thread A
configOptions = new HashMap();
configOptions  = readConfigFile(fileName);
processconfigOptions(configText, configOptions);
initialized = true;

//Thread B
while(!initialized)
	sleep();
//use configOptions 

6. volatile vs synchronized

① volatile可以看作是轻量版的synchronized
如果一个共享变量自始至终只是被各个线程赋值,而没有其他操作,那么就可以用volatile来代替synchronzed或者原子变量,因为赋值操作自身是有原子性的,而volatile又保证了可见性,所以能保证线程安全

② volatile属性的读写操作都是无锁的,他不能替代synchronized,因为他没有提供原子性和互斥性,因为无锁,不需要花费时间在获取锁和释放锁上,所以说他是低成本的

③ volatile只能作用于属性
我们用volatile修饰属性,这样编译器不会对这个属性做指令重排序

④ volatile提供happens-before保证,保证了可见性

⑤ volatile可以使得long和double的赋值的原子的
在没有同步的情况下读取变量时可能会得到一个失效值,但至少这个值是由之前的某个线程设置的而不是一个随机值,这种安全性保证被称为最低安全性

但是对于与long和double等变量,最低安全性是不适用的;

java内存模型要求,变量的读取操作和写入操作都是原子操作不可再分,但是对于非volatile类型的long和double变量,jvm运行将64位的读操作或写操作分解为两个32位的操作,也就是分为高32位和低32位两次操作

当读取一个非volatile类型的long变量的时候,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能会读到某个值的高32位和另一个值的低32位

7. synchronized的可见性和有序性

看网上说synchronized也可以保证数据的有效性和可见性,最初实在没能理解这句话的意思,现在想想原来他说的是对于synchronized代码块中的代码,多个线程可以保证有序性和可见性

  • 有序性: synchronized代码块中的代码的确会重排,但是因为加了锁,所以对于两个线程来说synchronized代码块中的代码是在串行之行的,而同样一个代码块中又遵循了as-if-serial,不管怎么重排序(编译器和处理器为了提供并行度),这个代码块的执行结果不能被改变,那当然就保证了他们说的有序性

  • 可见性: 看happens-before中的监视器锁规则,对一个锁的解锁,happens-before于随后对这个锁的加锁,也就是说如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值、对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store和write操作)

锁的内存语义:
当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中
当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量

打印语句为什么能保证可见性

static boolean run = true;
static int num = 0;

public static void main(String[] args) throws InterruptedException {
	Thread t = new Thread(() -> {
		while (run) {   
			System.out.println(run);//打印语句中使用了同步代码块         
			// ....        
		}   
		System.out.println(number);
	});    
	t.start();
	sleep(1);
	number = 43;
	run = false; 
	}
}

8. 单例模式与JMM与双重锁

单例模式SingletonPattern

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值