jmm是个什么玩意儿?一文理解java内存模型

 

jmm是个什么玩意儿?一文理解java内存模型

前言-线程安全问题

多线程环境下,容易出一些问题,尤其是在操作共享数据的时候,这些问题归根到底是由三个原因引起的。

  • 原子性
  • 可见性
  • 有序性

原子性

这个比较好理解,所谓原子就是不可再分割,对于方法来说就是这个方法一旦开始执行,在结束之前是不能被其他方法打断的。看下面这段代码

 


 

 

 


 

 

模拟的是卖票问题,在多线程环境下运行,我们会发现以下问题

  • 同一张票卖了多次
  • 有可能出现负数票

出现问题的原因就是我们的卖票方法不具备原子性,也就是红框里的内容,有可能线程1、线程2都进入了while循环内,线程1此时刚好执行到输出了当前票数,被线程2抢走了cpu执行权,此时线程2也执行了一遍输出当前票数,然后线程1再次抢走执行权,执行了票的数量加减操作。因此同一张票被线程1和线程2都打印了,这就是因为方法不具有原子性,cpu来回切换导致的问题。

可见性

为了后面更好的理解jmm,这里我们需要讲一下广义上的线程问题,下面的内容指的是广义上的多线程(操作系统级别)。

电子硬件的发展非常的迅速, CPU、内存、I/O 设备都在不断迭代,不断朝着更快的方向努力,行业内有种说法是每隔十八个月硬件就会大幅度更新一次。目前,硬件的发展已经逐渐达到了瓶颈,所以在无法大幅提高硬件速度的前提下,我们开始从操作系统和编程上做研究去提升性能。

在电脑发展的过程中,有一个核心矛盾一直存在,就是这三者的速度差异。CPU 和内存的速度差异可以形象地描述为:CPU 是天上一天,内存是地上一年(假设 CPU 执行一条普通指令需要一天,那么 CPU 读写内存得等待一年的时间)。内存和 I/O 设备的速度差异就更大了,内存是天上一天,I/O 设备是地上十年

 


 

 

序里大部分语句都要访问内存,有些还要访问 I/O,根据木桶理论(一只水桶能装多少水取决于它最短的那块木板),程序整体的性能取决于最慢的操作——读写 I/O 设备,也就是说单方面提高 CPU 性能是无效的。

为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:

  • CPU 增加了缓存,以均衡与内存的速度差异
  • 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。-----指令重排序

针对缓存问题,在单核时代,所有的线程都是在一颗 CPU 上执行,CPU 缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个 CPU 的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。例如在下面的图中,线程 1 和线程 2都是操作同一个 CPU 里面的缓存,所以线程 A 更新了变量 V 的值,那么线程 B 之后再访问变量 V,得到的一定是 V 的最新值(线程 A 写过的值)。

 


 

 

多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。比如下图中,线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2 上的缓存,很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。这个就属于硬件程序员给软件程序员挖的“坑”。
当然就算是在单核cpu下执行多线程也会有线程问题,这个主要还是原子性导致的。

 


 

 

一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性

有序性(指令重排序)

cpu为了提高执行效率,会在保证依赖性不损坏的前提下,把你写好的代码打乱,按照一个效率更高的顺序去执行
例如下面代码:

int a1 = x;
int a2 = y;
int a3 = x;

在编译过程中可能会被转化为:

int a2 = y;
int a1 = x;
int a3 = x;

甚至转化为:

int a1 = x;
int a2 = y;
int a3 = a1;

这样和最初的代码相比,少读x一次。

重排序分为三种类型

  • 编译器优化的重排序
  • 指令级并行的重排序
  • 内存系统的重排序

一份代码从写完到最终执行,要经过上面三次的重排序。

以上是线程问题的三个根源,原子性、可见性、有序性。这是从广义层面解读的,或者可以理解为硬件或操作系统级别的多线程。
为了解决线程问题,每种编程语言都会做自己的线程处理,说白了就是定义自己操作多线程时的一些规则。

JMM

java内存模型(java memory model)

导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们程序的性能可就堪忧了。
合理的方案应该是按需禁用缓存以及编译优化。那么,如何做到“按需禁用”呢?对于并发程序,何时禁用缓存以及编译优化只有程序员知道,那所谓“按需禁用”其实就是指按照程序员的要求来禁用。
所以,为了解决可见性和有序性问题,只需要提供给程序员按需禁用缓存和编译优化的方法即可。
Java 内存模型是个很复杂的规范,可以从不同的视角来解读,站在我们这些程序员的视角,本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。
具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则

上文讲到,内存模型的概念不只是java有,所有涉及到多线程的编程语言都会涉及到内存模型,比如C和C++。
针对硬件原生的线程问题,java再此基础上提出了自己的规范,也就是java定义了自己操作多线程时和cpu、内存交互的一些规则。

Java线程之间的通信采用的是共享内存模型,这里提到的共享内存模型指的就是Java内存模型(简称JMM)是一种虚拟机规范,用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果JMM决定一个线程对共享变量的写入何时对另一个线程可见
从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系

  • 线程之间的共享变量存储在主内存(main memory)
  • 线程被CPU执行,每个线程都有一个私有的本地内存(如CPU的高速缓存),本地内存中存储了该线程以读/写共享变量的副本
  • (重点)本地内存是JMM的一个抽象概念,并不真实存在;它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

 


 

 

jmm解决可见性问题

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。程序员自己实现
  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。程序员自己实现
  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

 

enter description here

enter description here

 

上图标注了这些操作所处的流程,每个线程和主内存之间都是这样一种操作流程。
换句话说如果线程1和线程2都需要对共享变量x做操作,而线程1没有及时把本地对x的修改刷新回主存,或者线程2没有及时从主存读最新的值,就会出现数据不一致问题。这就是我们一直说的可见性问题。

Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

  • 如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
  • 不允许read和load、store和write操作之一单独出现
  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

public class VolatileDemo {
//	static volatile int  num=0;
	static  int  num=0;
	
	public static void main(String[] args) {
		new Thread(new Runnable() {
			
			@Override
			public void run() {
				try {
					Thread.sleep(1000);
				} catch (InterruptedException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}
				num=1;
				System.out.println(num);
				
			}
		}).start();
		
		while(num==0) {
			
		}
	}
}

这段代码是很典型的一段代码,很多人在中断线程时可能都会采用这种标记办法。但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。

下面解释一下这段代码为何有可能导致无法中断线程。

在前面已经解释过,每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。

解决方法:加入volatile关键字

volatile相当于一个轻量级的锁,synchronized是重量级锁。jmm定义了变量传递的机制并提出了解决可见性问题的方法,却没有给默认加上(lock和unlock需要程序员自己实现),目的是为了给程序员最大的自主性,如果程序员需要,就自己实现lock和unlock这两个操作,也就是自己加锁。

jmm解决有序性问题

重排序带来的问题

 


 

 

flag变量是个标记,用来标识变量a是否已被写入。这里假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入呢?

答案是:不一定能看到。

由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。

 


 

操作1和操作2做了重排序,按照上图顺序执行,当线程B读取变量a时,a的值还没有被线程A写入。重排序在这里就破坏了程序


jmm属于语言级的内存模型,确保在不同编译器和不同处理器平台上,通过进制特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

对于编译器重排序,jmm会禁止特定类型的编译器重排序
对于处理器重排序,jmm会要求java编译器在生成指令序列时,插入特定类型的内存屏障指令来禁止处理器重排序

jmm里的一些规范和定义

as-if-serial语义:

不管怎么重排序,(单线程)程序的执行结果不能被改变。(编译器、runtime和处理器都必须遵守as-if-serial语义)

happens before:

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }
  public void reader() {
    if (v == true) {
      // 这里x会是多少呢?
    }
  }
}

如果在低于 1.5 版本上运行,x 可能是 42,也有可能是 0;如果在 1.5 以上的版本上运行,x 就是等于 42。
为什么 1.5 以前的版本会出现 x = 0 的情况呢?
变量 x 可能被 CPU 缓存而导致可见性问题。这个问题在 1.5 版本已经被圆满解决了。Java 内存模型在 1.5 版本对 volatile 语义进行了增强。靠的就是Happens-Before 规则。

从JDK 5开始,Java使用新的JSR-133内存模型,JSR-133使用happens-before的概念来阐述操作之间的内存可见性:在JMM中,如果一个操作执行的结果需要对另一个操作可见(两个操作既可以是在一个线程之内,也可以是在不同线程之间)

很多地方喜欢把Happens-Before翻译为“先行先发生”,这是歧义的,它真正要表达的是:前面一个操作的结果对后续操作是可见的

Happens-Before 规则应该是 Java 内存模型里面最晦涩的内容了,和程序员相关的规则一共有如下六项,都是关于可见性的。

  1. 单线程中,前面的代码应该happens before于后面的代码。
  2. volatile字段的写入应该happens before于后面对同一个volatile字段的读取。
  3. 对同一个监视器(锁)的解锁应该happens before于后面的加锁。(一个监视器只能同时被一个线程持有,前一个线程解锁,后面的线程才能加锁,这也是synchronized遵守的规则之一)
  4. 主线程中启动子线程,子线程能看到启动前主线程中的所有操作。
  5. 主线程中启动子线程,然后子线程调用join方法,主线程等待子线程执行结束,执行结束返回后,主线程对看到子线程的所有操作。
  6. 如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。也就是具有传递性

volatile

特点、作用

volatile 关键字并不是 Java 语言的特产,古老的 C 语言里也有,它最原始的意义就是禁用 CPU 缓存。
上面例子里已经见过了volatile的使用。下面总结一下它的特点和原理

  • 使用volatile关键字会强制将修改的值立即写入主存---即禁用缓存,保证可见性
  • 禁止指令重排,保证有序性----这种说法其实不严谨,只是保证被volatile修饰的变量有序,并不能保证其他变量有序。例子在后面
  • 不保证原子性。例子在后面

一定程度上保证有序性的例子

//x、y为非volatile变量
//flag为volatile变量
 
x = 2;        //语句1
y = 0;        //语句2
flag = true;  //语句3
x = 4;         //语句4
y = -1;       //语句5

由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。

并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。

不保证原子性的例子

public class SumDemo {
	
	 private static long count = 0;
	  private void add() {
	    int i = 0;
	    while(i < 10000) {
	      count += 1;
	      i++;
	    }
	  }
	  public static void main(String[] args) throws InterruptedException{
		  SumDemo test = new SumDemo();
		    // 创建两个线程,执行add()操作
		    Thread th1 = new Thread(()->{
		      test.add();
		    });
		    Thread th2 = new Thread(()->{
		      test.add();
		    });
		    // 启动两个线程
		    th1.start();
		    th2.start();
		    // 等待两个线程执行结束
		    th1.join();
		    th2.join();
		    System.out.println(count);
		
	}
}

直觉告诉我们应该是 20000,因为在单线程里调用两次 add() 方法,count 的值就是 20000,但实际上 calc() 的执行结果是个 10000 到 20000 之间的随机数。为什么呢?我们假设线程 A 和线程 B 同时开始执行,那么第一次都会将 count=0 读到各自的 CPU 缓存里,执行完 count+=1 之后,各自 CPU 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2。之后由于各自的 CPU 缓存里都有了 count 的值,两个线程都是基于 CPU 缓存里的 count 值来计算,所以导致最终 count 的值都是小于 20000 的。这就是缓存的可见性问题。通过加volatile可以解决可见性问题,但结果依旧不对,这是由于原子性造成的。解决办法是方法上加锁

原理

观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令

  lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

  1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

  2)它会强制将对缓存的修改操作立即写入主存;

  3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

 


 

 

posted @ 2020-11-30 22:47  风华绝代的二狗子  阅读(226)  评论(0编辑  收藏  举报