JMM

JAVA Memory Model 简称JMM,是JAVA内存模型。这个和虚拟机内存模型里面的栈,堆其实没有太大的联系。该模型指的是JAVA 运行的时候,每个线程的内存访问机制。

主内存和工作内存

【JAVA线程内存模型】_赋值 这是一张不是很清晰的图片,选自《深入JAVA虚拟机》一书。首先是主内存,所有的变量都是存在于主内存里面,JAVA的每个线程使用的是自己的工作内存,工作内存中存放的是主内存变量的拷贝,也就是说JAVA线程想要使用主内存的变量不是直接操作主内存中的变量,而是首先把内存拷贝到自己的工作内存中去,然后再去操作。

内存间的数据操作

JAVA线程想要使用主内存的数据,就需要把它拷贝的本地,那么这个使用的过程其实并不是简单的做一份拷贝就结束了,因为多线程环境下,有的变量正在被使用,所以会造成很多问题,比如脏读/幻读/不可重复读等。为了保证这一操作在多线程下是安全的,java内存模型定义了8种操作。需要注意的是这些操作都是原子操作,不可中断。
lock:作用于主内存的变量,用来锁定某个变量,标识该变量正在被线程占用。
ulock:作用于主内存变量,用来解锁某个变量,解锁之后,该变量可以被其他线程锁定。
read:将主内存的变量读到工作内存中去。
load:把read得到的变量放到工作内存的拷贝里面。
use:作用于工作内存里的变量,把变量的值交给执行引擎。
assign:赋值,把执行引擎得到的结果赋值给工作内存中的某个变量。
store:把工作内存中的变量写回主内存
write:把写回来的值重新赋值给该变量。
虽然说是八个操作,其实可以分为四组。锁定/解锁,读取,赋值,写回。
同时规定了,read和load必须按照顺序出现,store和write比如按照顺序出现。
说的就是从主内存读取变量到工作内存后,必须写入拷贝,把拷贝的值写入主内存中之后必须更新之前的值。

操作的规则

加锁:线程可以多一个变量多次lock,同时ulock的次数也要对应。不能去lock一个被其他线程锁定的变量,不能unlock一个没有被lock的变量。这些都是挺自然的规定。
强制写回:线程对某个变量赋值后,必须同步回主内存。
不能无原因写回:没有任何赋值操作不能把拷贝写回主内存。
还有很多,只是提几点比较重要的。

volatile关键字

volatile是java提供的一个最简单的同步变量的方式。其实可以简单的理解为,被该关键字修饰的变量它的值在工作线程中是无效的,所有修改对所有的线程都是可见的。一般的线程同步都是把值更新之后,写回主内存这样才能够被其他线程看见。
特点:被volatile修饰的变量的修改对其他线程都是可见的,但是也不是立刻可见的,举个简单的例子:

public class Main {
public static volatile int race=1;
public static void Increase(){
race++;
}
public static final int THREAD_NUM=20;
public static void main(String[] args) {
Thread []threads=new Thread[THREAD_NUM];
for(int i=0;i<THREAD_NUM;i++){
threads[i]=new Thread(new Runnable() {
@Override
public void run() {
for (int i=0;i<10000;i++){
Increase();
}
}
});
threads[i].start();
}
while(Thread.activeCount()>1)
Thread.yield();
System.out.println(race);
}
}

这段代码,开启20个线程,每次对i+1000,结果应该是200000,但是会发现,其实结果比这个小一点。这说明线程其实同步是失败的。
原因:race这个值的修改,并不是立即可见,当race被加1时,其他线程的修改没有被同步到这里来,导致这个加一失效了,所以值偏小。一般的同步我们使用synchorized加锁同步。这里多说一点就是存在数据依赖,对于race的计算依赖于它之前的值,然而它之前的值并没有被同步过来。所以volatile的同步也是通过写回主内存的同步,只不过这里的定义比较强,一旦被修改就会被写回主内存,如果不存在前后的数据依赖,多线程使用这个关键字修饰来同步,还是很稳的。

禁止指令重排

这是并发的过程中最难以发现的错误之一,指令重排序是计算机结构原理里面重会谈到的一个概念,一言以蔽之就是:因为要最大化的利用CPU的时间,所以在翻译成汇编时,常常把指令重排。但是这个时候会出现一些比较奇怪的问题,比如:
【JAVA线程内存模型】_java_02
initFlag是标识userName有没有被初始化,这两条指令的是有先后顺序的,必须当initFlag为true时才能使用userName,否则会得到一个空值。如果编译器优化,把initFlag先赋值,然后对于userName的赋值在后边,那么这时线程阻塞,结果其他线程看到的initFlag是true,就认为userName已经被赋值过了,这样会出现问题。所以使用volatile修饰initFlag可以避免这一问题。
【JAVA线程内存模型】_java_03被volatile修饰关键字的赋值的地方,是被插入了很多的内存屏障,来保证不进行指令重排。所以从这一角度来看,似乎加锁和直接使用volatile来做,效率其实差别不大。

特例

在前边说了内存模型的8种操作,但是有两个特例,long,double类型的数据,这是64位数据,虚拟机可以不保证操作的原子性,把64为拆分为两个32位数据,然后对这两个32为数据的操作保证原子性,所以这在理论上会出现一种只读取到半个变量的值的错误,但是这种一般虚拟机都有保证机制,不会出现这种奇怪的错误。