volatile关键字和synchronized关键字一样,在Java多线程开发中,是一道必须要跨越的槛。

之前有篇文章已经分析过synchronized关键字的原理,synchronized关键字的原理

这一次,我们来一步一步分析下volatile关键字的工作原理。

 

volatile 关键字的使用

首先,我们从一个简单的程序来入手。

public class VolatileFoo {
	final static int MAX = 5;		// init_value的最大值
	static int init_value = 0;		// init_value的初始值

	public static void main(String[] args) {
		// 启动一个Reader线程,当发现local_value和init_value不同时,
		// 则输出init_value被修改的信息
		new Thread(() -> {
			int localValue = init_value;
			while(localValue < MAX) {
				if(init_value != localValue) {
					System.out.println("this init_value is updated to " + init_value);
					// 对local_value重新赋值
					localValue = init_value;
				}
			}
		}, "Readder").start();
	
		new Thread(() -> {
			int localValue = init_value;
			while(localValue < MAX) {
				System.out.println("this init_value will be changed to " + ++localValue);
				// 对local_value重新赋值
				init_value = localValue;
				try {
					Thread.sleep(2000);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
		},"Updater").start();
	}
}

上面的程序分别启动了两个线程,一个线程负责对变量进行修改,一个线程负责对变量进行输出。

运行程序,输出结果如下:

this init_value will be changed to 1
this init_value is updated to 1
this init_value will be changed to 2
this init_value will be changed to 3
this init_value will be changed to 4
this init_value will be changed to 5

从输出信息我们发现,Reader输出打印线程没有感知到init_value的变化,我们期望的是在Updater进程更新init_value的值之后,Reader进程能够打印出变化的init_value的值,但结果并不是我们期望的那样。

我们尝试在init_value前面加上volatile,如下

static volatile int init_value = 0;

接着我们再运行下这个程序,输出结果如下:

this init_value will be changed to 1
this init_value is updated to 1
this init_value will be changed to 2
this init_value is updated to 2
this init_value will be changed to 3
this init_value is updated to 3
this init_value will be changed to 4
this init_value is updated to 4
this init_value will be changed to 5
this init_value is updated to 5

这个时候Reader输出打印线程就能够感受到init_value的值的变化了,并且在条件不满足时程序就退出了运行。

 

那么为什么加了个volatile就正常了呢, volatile关键字的作用到底是什么呢?

想要彻底搞清楚volatile关键字,还需要具备Java内存模型、CPU缓存模型、汇编指令等相关知识的,

接下来,我们接下来一步一步来拆解问题。

 

1、CPU 缓存模型

要想对volatile有比较深刻的理解,首先我们需要对CPU的缓存模型有一定的认识。

在计算机中,所有的运算操作都是由CPU的寄存器(指令寄存器 + 数据寄存器)来完成的,CPU指令的执行过程需要涉及数据的读取和写入操作,CPU所能访问的所有数据只能是计算机的主存(通常是指RAM),虽然CPU的发展频率不断得到提升,但受制于制造工艺以及成本的限制,计算机的内存反倒在访问速度上没有多大的突破,因此CPU的处理速度和内存的访问速度之间的差距越拉越大,通常这种差距可以达到上千倍,极端情况下甚至会在上万倍以上。

由于CPU和内存两边速度严重的不对等,通过传统FSB直连内存的访问方式会导致CPU资源受到极大的限制,降低CPU整体的吞吐量,于是就有了CPU和主内存直接增加缓存的设计,现在缓存数量都可以增加到3级了,最靠近CPU的缓存为L1,然后依次是L2,L3和主内存,CPU缓存模型图如下所示:

缓存不全在CPU内核,可能在内存中:

CPU 一级缓存,包含数据和指令缓存,加速给CPU指令执行和数据计算

其中:

L1 Cache:每个CPU内核独享,KB级,例如 32KB;

L2 Cache:可能每个内独享,可能多个内核共享,KB、MB级;

L3 Cache:每个CPU内核共享,MB级,例如 3072KB = 3MB;

说明:

CPU的寄存器和高速缓存,是每个CPU内核独享的,

多个CPU核就分别有多个寄存器和高速缓存

Cache的出现是为了解决CPU直接访问内存效率低下的问题,程序在运行的过程中,会将运算所需要的数据从主内存复制一份到CPU Cache中,这样CPU计算时就可以直接对CPU Cache中的数据进行读取和写入,当运算结束之后,再将CPU Cache中最新的数据刷新到主内存当中,CPU通过直接访问Cache的方式提到直接访问主内存的方式极大地提高了CPU的吞吐能力,有个CPU Cache之后,整体的CPU和主内存之间的交互的架构大致如下图所示:

更多知识,请见米扑博客:为什么寄存器比内存更快

 

2、Java内存模型

由于缓存的出现,极大地提高了CPU的吞吐能力,但是同时也引入了缓存不一致的问题。

在多处理器系统中,每个处理器都有自己的的高速缓存(第一级缓存L1),而它们又共享同一主内存,当多个处理器的运算任务都设计到同一块内存区域时,将可能导致各自的缓存数据不一致,这个时候就需要通过缓存一致性协议来保证数据的正确性,不同的操作系统使用缓存一致性协议都各不相同

因为各种硬件和操作系统的内存访问是有差异的,Java为了程序能在各种平台下运行达到一致的内存访问效果,于是定义了Java内存模型(Java Memory Mode,JMM)来对特定内存或高速缓存的读写访问过程进行抽象。

Java内存模型定义了线程和主内存之间的抽象关系,具体如下。

1)共享变量存储于主内存之中,每个线程都可以访问。

2)每个线程都有私有的工作内存和本地内存。

3)工作内存值存储该线程对共享变量的副本。

4)线程不能直接操作主内存,只有先操作了工作内存之后才能写入主内存。

工作内存和Java内存模型一样也是一个抽象的概念,它其实并不存在,它涵盖了缓存、寄存器、编译优化以及硬件等。

Java内存模型定义了一套主内存和工作内存的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之类的实现细节。具体有8种操作来完成,分别为 lock、unlock、read、load、use、assign、store和write。除此之外,Java内存模型还规定在执行这8种操作的时候必须满足8种规则,由于篇幅问题,这里就不一一列举了,具体可参看深入理解Java虚拟机第12章的Java内存模型与线程

Java内存模型是一个抽象的概念,其与计算机硬件的结构并不完全一样,比如计算机物理内存不会存在栈内存和堆内存的划分,无论是堆内存还是虚拟机栈内存都会对应到物理的主内存,当然也有一部分堆栈内存数据可能会存入CPU Cache寄存器中。具体可参考下图:

 

3、对于volatile变量的特殊规则

介绍了CPU缓存模型以及Java内存模型之后,我们再来说volatile关键字,这样更能加深我们对于volatile关键字的理解。 volatile关键字是Java虚拟机提供的最轻量级的同步机制,很多人由于对它理解不够,往往更愿意使用synchronized来做同步。

Java内存模型对volatile关键字定义了一些特殊的访问规则,当一个变量被volatile修饰后,它将具备两种特性,或者说volatile具有下列两层语义:

第一、保证了不同线程对这个变量进行读取时的可见性, 即一个线程修改了某个变量的值, 这新值对其他线程来说是立即可见的。 (volatile 解决了线程间共享变量的可见性问题)。

第二、禁止进行指令重排序, 阻止编译器对代码的优化。

针对第一点,volatile保证了不同线程对这个变量进行读取时的可见性,具体表现为:

第一: 使用 volatile 关键字会强制将在某个线程中修改的共享变量的值立即写入主内存

第二: 使用 volatile 关键字的话, 当线程 2 进行修改时, 会导致线程 1 的工作内存中变量的缓存行无效(反映到硬件层的话, 就是 CPU 的 L1或者 L2 缓存中对应的缓存行无效);

第三: 由于线程 1 的工作内存中变量的缓存行无效, 所以线程 1再次读取变量的值时会去主存读取

基于这一点,所以我们经常会看到文章中或者书本中会说volatile 能够保证可见性。

volatile 能够保证可见性,但是volatile不能保证程序的原子性。

public class VolatileTest {
	public static volatile int race = 0;
	
	public static void increase() {
		race ++;
	}
	private static final int THREAD_COUNT = 20;

	public static void main(String[] args) {
		Thread[] threads = new Thread[THREAD_COUNT];
		for(int i =0 ;i<THREAD_COUNT;i++) {
			threads[i] = new Thread(() ->{
				for(int j =0;j< 10000;j++) {
					increase();
				}
			});
			threads[i].start();
		}
		
		while(Thread.activeCount() > 1)
			Thread.yield();
		
		System.out.println(race);
	}
}

这段代码发起了20个线程,每个线程对race变量进行10000次自增操作,如果这段代码能够正确并发的话,最后输出的结果应该是200000。我们运行完这段代码之后,并没有获得期望的结果,而且发现每次运行程序。输出的结果都不一样,都是一个小于200000的数字。

问题就出在自增运算”race++“之中,我们用 javap反编译这段代码后发现只有一行代码的increase()方法在Class文件中是由4条字节码指令构成的。

  public static void increase();
    Code:
       0: getstatic     #13                 // Field race:I
       3: iconst_1
       4: iadd
       5: putstatic     #13                 // Field race:I
       8: return

从字节码层面上很容易分析出原因了:当getstatic指令把race的值取到操作栈时,volatile关键字保证了race的值此时是正确的,但是在执行iconst_1、iAdd这些指令的时候,其他线程可能已经把race的值加大了,而在操作栈顶的值就变成了过期的数据,所以putstati指令执行后就可能把较小的值同步回主内存中去了。

其实这里我们通过字节码来分析这个问题是不严谨的,因为即使编译出来的只有一条字节指令,也并不意味执行这条指令就是一个原子操作。一条字节码指令在解释执行时,解释器将要运行许多行代码才能实现它的语义,如果是编译执行,一条字节码指令也可能转化成若干条本地机器码指令。关于解释执行和编译执行,我们还会再讲到。

由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(synchronized或java.util.concurrent中的原子类)来保证原子性。

  • 运输结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
  • 变量不需要与其他状态变量共同参与不变约束。

类似下面的场景就时候采用volatile来控制并发。

volatile boolean shutdownRequested;
public void shutdown() {
	shutdownRequested = true;
}
public void doWork() {
	while(!shutdownRequested) {
		//do stuff
	}
}

如果我们想让上面的那个自增操作保持原子性,我们可以使用 AtomicInteger

具体程序如下,这里就不多做介绍了。

import java.util.concurrent.atomic.AtomicInteger;

public class VolatileTest {
//	public static volatile int race = 0;
	public static AtomicInteger race = new  AtomicInteger(0);
	
	public static void increase() {
//		race++;
		race.incrementAndGet();
	}
	private static final int THREAD_COUNT = 20;

	public static void main(String[] args) {
		Thread[] threads = new Thread[THREAD_COUNT];
		for(int i =0 ;i<THREAD_COUNT;i++) {
			threads[i] = new Thread(() ->{
				for(int j =0;j< 10000;j++) {
					increase();
				}
			});
			threads[i].start();
		}
		while(Thread.activeCount() > 1)
			Thread.yield();
		
		System.out.println(race.get());
	}
}

回到volatile关键字的第二层语义:禁止指令重排

普通的变量仅仅会保证在该方法的执行过程中,所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。

我们用一段伪代码来帮助下理解:

Map configOptions;
char[] configText;
//此变量必须定义为volatile
volatile boolean initialized = false;

//假设一下代码在线程A中执行
//模拟读取配置信息,当读取完成后将initialized设置为true以通知其他线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processCongigOptions(configText,configOptions);
initialized = true

//假设一下代码在线程B中执行
//等待initialized为true,代表线程A已经吧配置信息初始化完成
while(!initialized) {
	sleep();
}
//使用线程A中初始化好的配置信息
doSomethingWithConfig();

上面这段代码如果定义的initialized没有使用volatile来修饰,就可能会由于指令重排序的优化,导致位于线程A中最后一句代码initialized = true被提前执行(这里虽然使用Java作为伪代码,但所指的重排序优化是机器级的优化操作,提前执行时值这句话对于的汇编代码被提前执行),这样在线程B中使用配置信息的代码就可能出现错误,而volatile能避免此类情况的发生。

 

4、volatile 关键字深入解析

上面讲到volatile关键字的两层语义,那么volatile保证可见性以及有序性到底是如何做到的呢?它的底层逻辑是什么呢?

这里我们尝试获得Java程序的汇编代码,通过比较变量加入volatile修饰和未加入volatile修饰的区别。

这里主要使用的是HSDIS插件,HSDIS是一个Sun官方推荐的HotSpot虚拟机JIT编译代码的反汇编插件,网上有关于这个插件的下载,不过有的链接已经失效,我这里是从这里获取的,hsdis,再把这个clone下来之后,编译成功之后,使用下面这个命令拷贝到jre的server目录,具体可以查看这个repo中README文件,里面写的很详细。

sudo cp build/macosx-amd64/hsdis-amd64.dylib /Library/Java/JavaVirtualMachines/jdk1.8.0_131.jdk/Contents/Home/jre/lib/server/ 

接下来就可以尝试反汇编了。

public class Singleton {
	private static Singleton instance;
	
	public static Singleton getInstance() {
		if(instance ==null) {
			synchronized(Singleton.class) {
				if(instance == null) {
					instance = new Singleton();
				}
			}
		}
		return instance;
	}
	
	public static void main(String[] args) {
		Singleton.getInstance();
	}
}

上面这个是我们尝试反汇编的程序代码,如果是命令行,我们可以使用下面这个指令。

java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly Singleton

如果是eclipse,在下图的VM arguments中添加 XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly

然后运行程序,这样在控制台就会输出汇编代码。

程序运行后,在控制台会输出很多内容,由于输出太大,所以截取了前面一段输出。

Java HotSpot(TM) 64-Bit Server VM warning: PrintAssembly is enabled; turning on DebugNonSafepoints to gain additional output
Loaded disassembler from /Library/Java/JavaVirtualMachines/jdk1.8.0_171.jdk/Contents/Home/jre/lib/server/hsdis-amd64.dylib
Decoding compiled method 0x0000000112e9ad50:
Code:
[Disassembling for mach='i386:x86-64']
[Entry Point]
[Constants]
  # {method} {0x000000010ce1f000} 'hashCode' '()I' in 'java/lang/String'
  #           [sp+0x40]  (sp of caller)
  0x0000000112e9aec0: mov    0x8(%rsi),%r10d
  0x0000000112e9aec4: shl    $0x3,%r10
  0x0000000112e9aec8: cmp    %rax,%r10
  0x0000000112e9aecb: jne    0x0000000112de0e60  ;   {runtime_call}
  0x0000000112e9aed1: data16 data16 nopw 0x0(%rax,%rax,1)
  0x0000000112e9aedc: data16 data16 xchg %ax,%ax
[Verified Entry Point]
  0x0000000112e9aee0: mov    %eax,-0x14000(%rsp)
  .....

得到这个输出之后,我使用Singleton全局搜索了下,发现还无结果。

反编译的却没有得到相应的内容,这是什么问题呢? 带着这个问题Google了好久,终于搞明白原因了。

于是,我们又要来补充些虚拟机编译的知识了。

我们在使用 java -version查看JDK版本的时候,可以看到最后有个 mixed mode,这里其实表明的是Java 虚拟机的编译方式,在HotSpot虚拟机中,提供了两种编译模式:解释执行 和 即时编译(JIT,Just-In-Time),即时编译也可以称为编译执行,解释执行即逐条翻译字节码为可运行的机器码,而即时编译则以方法为单位将字节码翻译成机器码。

我们在反编译Singleton这个类的时候,因为虚拟机使用的是解释执行,这样我们是得不到汇编代码的。在深入理解Java虚拟机一书中介绍可以加上-Xcomp来触发JIT编译,但是我用的是JDK1.8,这个 -Xcomp`已经被移除了,具体哪个版本被移除了,目前我也没仔细研究过了。

那要怎样才能触发JIT编译呢?

答案是循环。通过足够多次数的循环来触发JIT编译。我们需要确保写的Java方法被调用的次数足够多,以触发C1(客户端)编译,并大约10000次触发C2(服务器)编译器并打开高级优化。换句话说,要想查看汇编代码,我们所写的Java源代码文件不能太过于简单,要足够复杂。

注意:C1,C2都是HotSpot虚拟机内置的即时编译器。

C1:即Client编译器,面向对启动性能有要求的客户端GUI程序,采用的优化手段比较简单,因此编译的时间较短。

C2:即Server编译器,面向对性能峰值有要求的服务端程序,采用的优化手段复杂,因此编译时间长,但是在运行过程中性能更好。

public class Singleton {
	private static Singleton instance;
	
	public static Singleton getInstance() {
		if(instance ==null) {
			synchronized(Singleton.class) {
				if(instance == null) {
					instance = new Singleton();
				}
			}
		}
		return instance;
	}
	
	public static void main(String[] args) {
		for(int i=0;i<100;i++) {
			print();
		}
	}
	private static void print() {
		for(int i =0;i<=1000;i++) {
			Singleton.getInstance();
		}
	}
}

于是我在代码里加上了两层循环,然后在尝试获取一些汇编代码。

这次发现终于能得到Singleton相关的汇编代码了。

于是我们分别编译了两次,

第一个是没有使用volatile关键字修饰instance,

第二个是使用volatile关键字,

然后我们分别取出Singleton::getInstance这一段来进行比较。

 // 未使用volatile修饰
  0x000000010d29e931: movabs $0x7955f12a8,%rsi  ;   {oop(a 'java/lang/Class' = 'main/Singleton')}
  0x000000010d29e93b: mov    %rax,%r10
  0x000000010d29e93e: shr    $0x3,%r10
  0x000000010d29e942: mov    %r10d,0x68(%rsi)
  0x000000010d29e946: shr    $0x9,%rsi
  0x000000010d29e94a: movabs $0xfe403000,%rax
  0x000000010d29e954: movb   $0x0,(%rsi,%rax,1)  ;*putstatic instance
                                                ; - main.Singleton::getInstance@24 (line 10)

// 使用volatile修饰
 0x000000011435394f: movabs $0x7955f12a8,%rsi  ;   {oop(a 'java/lang/Class' = 'main/Singleton')}
  0x0000000114353959: mov    %rax,%r10
  0x000000011435395c: shr    $0x3,%r10
  0x0000000114353960: mov    %r10d,0x68(%rsi)
  0x0000000114353964: shr    $0x9,%rsi
  0x0000000114353968: movabs $0x10db6e000,%rax
  0x0000000114353972: movb   $0x0,(%rsi,%rax,1)
  0x0000000114353976: lock addl $0x0,(%rsp)     ;*putstatic instance
                                                ; - main.Singleton::getInstance@24 (line 10)

虽然对于汇编指令了解不多,但还是能从两个对比中看出差异所在。

很明显,movb $0x0,(%rsi,%rax,1) 之后,加了volatile修饰的汇编代码后面多了一条汇编指令lock addl $0x0,(%rsp)这个操作相当于一个内存屏障,指令重排时不能把后面的指令重排序到内存屏障之前的位置,当只有一个CPU访问内存时,并不需要内存屏障,当如果有两个或多个CPU访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证一致性了。

lock addl $0x0,(%rsp) 表示把rsp的寄存器的值加0,这显然是一个空操作,关键在于lock前缀。

查询IA32手册,lock前缀会强制执行原子操作,它的作用是是的本CPU的Cache写入了内存,该写入动作会引起别的CPU无效化其Cache。所有通过这样一个空操作,可让前面volatile变量的便是对其他CPU可见。

那为什么说它能禁止指令重排呢?

从硬件架构上讲,指令重排序是指CPU采用了运行将多条指令不按程序规定的顺序,分开发送给各相应的CPU单元处理,但并不是指令任意重排,CPU需要能正确处理指令依赖情况以保障程序能得出正确的执行结果。

lock addl $0x0,(%rsp) 指令把修改同步到内存时,意味着所有之前的操作都已经执行完成,这样便形成了" 指令重排序无法越过内存屏障"的效果。

 

volatitle 深入理解图解

volatitle是一个确保共享变量能够被准确和一致地更新的关键字(保证可见性),只能对变量使用

volatitle 解决了两个问题:

第一、保证了不同线程对这个变量进行读取时的可见性, 即一个线程修改了某个变量的值, 这新值对其他线程来说是立即可见的。 (volatile 解决了线程间共享变量的可见性问题)。

第二、禁止进行指令重排序, 阻止编译器对代码的优化。

小结:volatitle 保证了各线程可见性,禁止指令重排

 

1、volatile是如何保证可见性

在对有volatile修饰符修饰的共享变量进行写操作时,汇编代码回多一条lock前缀的指令。

lock addl $0x0,(%rsp)

该指令有如下两个作用:

1)将当前CPU缓存(CPU寄存器和一级缓存L1)的数据回写到内存中

2)使其他CPU里缓存(CPU寄存器和一级缓存L1)的该内存地址的数据无效(缓存一致性机制),重新从内存中加载进CPU缓存里来

volatitle修饰的变量能够保证可见性,不保证原子性,每个线程能够获取该变量的最新值。

实现的机制:在写volatitle变量写到主内存时,指令前会加上lock,该指令有两个影响:

1)将当前处理器缓存行的数据写回系统内存;

2)这个写回内存的操作会使得其他CPU里缓存了该内存地址的数据无效。

在多核处理器中,其他线程发现本地缓存失效,就会到主内存重读这个变量,

因此在一个volatitle变量发生变化,会发生以下变化:

a)Lock前缀的指令会引起处理器缓存写回内存;

b)一个处理器的缓存回写到内存,会导致其它处理器的缓存失效;

c)当其它处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值;

这样针对volatile变量通过这样的机制,就使得每个线程都能获得该变量的最新值。

 

2、volatile是如何防止指令重排序

volatitle 内存语义实现:JMM内存屏障在不改变正确语义的情况下,会允许编译器和处理器对指令进行重排

1)当第一个操作为普通的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作(1,3)

2)当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前(第二行)

3)当第一个操作是volatile写,第二个操作是volatile读时,不能重排序(3,2)

4)当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序(第三列)

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

 

如何实现对重排的控制呢?答案就是内存屏障。

对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers, intel称之为memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。

先介绍几种内存屏障的类型

屏障类型 解释
LoadLoad Barriers 确保Load1数据的装载先于Load2及所有后续指令的装载
StoreStore Barriers 确保Store1数据对其他处理器的可见(刷新到内存),先于Store2及所有后续存储指令的存储
LoadStore Barriers 确保Load1数据装载先于Store2及所有后续的存储指令刷新到内存
StoreLoad Barriers 确保Store1数据对其他处理器的可见先于Load2及所有后续装载指令的装载

下面继续分析一下volatile是如何防止指令重排序的

1)在每个volatile写操作的前面插入一个StoreStore屏障

2)在每个volatile写操作的后面插入一个StoreLoad屏障

3)在每个volatile读操作的后面插入一个LoadLoad屏障

4)在每个volatile读操作的后面插入一个LoadStore屏障

 

总结来说,内存屏障有两个作用:

先于这个内存屏障的指令必须先执行, 后于这个内存屏障的指令必须后执行。

如果你的字段是volatile,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据。

在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存。

 

 

JMM 重排序与内存屏障

JMM(Java Memory Model,Java 内存模型),因为在不同的硬件生产商和不同的操作系统下,内存的访问逻辑有一定的差异,结果就是当你的代码在某个系统环境下运行良好,并且线程安全,但是换了个系统就出现各种问题。

Java 内存模型,就是为了屏蔽系统和硬件的差异,让一套代码在不同平台下能到达相同的访问结果。

JMM从Java 5开始的JSR-133发布后,已经成熟和完善起来。

JMM规定了内存主要划分为主内存和工作内存两种。此处的主内存和工作内存跟JVM内存划分(堆、栈、方法区)是在不同的层次上进行的,如果非要对应起来,主内存对应的是Java堆中的对象实例部分,工作内存对应的是栈中的部分区域,从更底层的来说,主内存对应的是硬件的物理内存,工作内存对应的是寄存器和高速缓存

JVM在设计时候考虑到,如果Java线程每次读取和写入变量都直接操作主内存,对性能影响比较大,所以每条线程拥有各自的工作内存,工作内存中的变量是主内存中的一份拷贝,线程对变量的读取和写入,直接在工作内存中操作,而不能直接去操作主内存中的变量。但是这样就会出现一个问题,当一个线程修改了自己工作内存中变量,对其他线程是不可见的,会导致线程不安全的问题。因为JMM制定了一套标准来保证开发者在编写多线程程序的时候,能够控制什么时候内存会被同步给其他线程。

 

内存交互操作

内存交互操作有8种,虚拟机实现必须保证每一个操作都是原子的,不可在分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台上允许例外)

  1. lock     (锁定):作用于主内存的变量,把一个变量标识为线程独占状态
  2. unlock (解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  3. read    (读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
  4. load     (载入):作用于工作内存的变量,它把read操作从主存中变量放入工作内存中
  5. use      (使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令
  6. assign  (赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中
  7. store    (存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用
  8. write  (写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中

 

JMM对这八种指令的使用,制定了如下规则:

  1. 不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write
  2. 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存
  3. 不允许一个线程将没有assign的数据从工作内存同步回主内存
  4. 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是怼变量实施use、store操作之前,必须经过assign和load操作
  5. 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁
  6. 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值
  7. 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量
  8. 对一个变量进行unlock操作之前,必须把此变量同步回主内存

JMM对这八种操作规则和对 volatile的一些特殊规则就能确定哪里操作是线程安全,哪些操作是线程不安全的了。但是这些规则实在复杂,很难在实践中直接分析。所以一般我们也不会通过上述规则进行分析。更多的时候,使用java的happen-before规则来进行分析。

参考:Java内存模型JMM理解整理

 

重排序

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序,

重排序有三种类型:

1. 编译器优化的重排序

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

2. 指令级并行的重排序

现代处理器采用了指令级并行技术,来将多条指令重叠执行。

如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序

3. 内存系统的重排序

由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行

对于编译器,JMM的编译器重排序规则会禁止特性类型的编译器重排序

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

 

处理器重排序与内存屏障指令

现代处理器使用写缓冲区,来临时保存向内存写入的数据

写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来,等待向内存写入数据而产生的延迟

同时,通过以批处理的方式刷新写缓冲期,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用

每个处理器上的写缓冲区,仅仅对它所在的处理器可见,这个特性会对内存操作的执行顺序产生重要的影响:

处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致

处理器A和处理器B可能同时把a和b写入各自的写缓冲区

再从内存中读取对方的共享变量a和b赋值给x和y

最后才把自己写缓冲区中保存的脏数据(a和b)刷新到内存中

把本来应该是A1->A3->A2的内存操作重排序为A1->A2->A3了

导致处理器和内存执行的操作不一致

 

总结

Java 关键字 volatile,解决了两个问题

1)保证了各线程可见性,但不保证原子性(可结合 java.util.concurrent.atomic.AtomicInteger 实现原子性)

2)禁止指令重排,防止前后读写的结果不一致

解决以上两项,基本就可以实现线程同步的原理了

 

 

参考推荐:

Java 四种线程池

Java 线程同步的七种方法

Java ThreadLocal 原理及应用

Java同步方式(2)——wait和notify/notifyall

String、StringBuilder、StringBuffer用法比较

ArrayList、LinkedList、Vector、Map用法比较

Java类的生命周期详解

JVM优点与缺点的深入分析

Windows消息机制VC

为什么寄存器比内存更快  (推荐

程序员应当更新现代CPU的知识

编写你的第一个垃圾收集器

C语言编译全过程剖析

JVM 基础知识

Spring 定时任务的几种实现

进程、线程、协程的区别

进程、线程、协程的故事图解

epoll 两种触发模式

5种服务器网络编程模型讲解

Servlet 工作原理解析

Tomcat 系统架构与设计模式:工作原理

PHP 多线程的应用实例