无论你是Java还是C,或者其他编程语言编写高并发程序时,都或多或少的会涉及内存模型。高并发程序下数据访问的一致性和安全性受到挑战,为了保证程序正确执行,Java内存模型(以下简称JMM)由此而诞生。如果不理解JMM,就会对内存可见性,有序性等问题出现时无从下手。本文将从以下几个方面进行JMM的说明:
1.内存模型的相关概念
2.可见性
3.有序性
4.原子性
在共享内存模型里,线程间的是通过读-写内存中的公共状态进行隐式通信的,那线程在Java虚拟机里是什么位置呢?Java虚拟机运行时数据区:
堆:解决的是对象数据存储问题。
方法区:是先决条件,储存已经被虚拟机加载过的类信息,常量,静态变量等信息。它和堆描述的是Java共享数据(主)内存模型。
虚拟机栈:栈解决的是程序运行的问题,即程序如何处理数据。从图中可以看栈是线程私有的,函数的调用要用栈实现,函数运算并改变栈中存放数据的引用。所以虚拟机栈描述的是Java执行的内存模型: 每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、 操作数栈、 动态链接、 方法出口等信息。
本地方法栈:和虚拟机栈类似,虚拟机栈为Java方法服务,而本地方法栈为虚拟机使用到的Native方法服务。程序计数器:cpu中的寄存器,它包含当前正在执行的指令的地址(位置)。(寄存器是cpu组成部分,暂存指令、数据和地址--百度百科)。
CPU在执行指令过程中,势必会牵扯到变量的读和写。共享变量存储到内存中,CPU通过高速缓存(Cache)与内存进行通信,但是CPU执行指令比CPU从内存读写共享变量要快的多。而且在多核CPU时代,每条线程可能运行在不同的CPU里。比如:
i=i+1;
两个线程读取i的值并在自己的CPU的高速缓存中+1操作,当第一个线程运算完了存到高速缓存还没有刷新到内存中,线程二读取到i还是0,也做了+1操作,然后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。
最终结果i的值是1,而不是2。这就是缓存一致性问题。怎么解决呢?当CPU写数据时,发现其他CPU也在操作这个共享变量,会让其他CPU的缓存无效且从内存中重新获取。这个缓存一致性协议最出名的就是Intel 的MESI协议。
程序在执行过程中任何时候都可能产生可见性问题,这里只讨论JMM相关的可见性问题。Java线程之间的通信由JMM控制,JMM的抽象示意图如下:
本地内存A和B有主内存中共享变量x的副本。假设初始时三个内存中的x值都为0。线程A修改x=1后-->主内存修改为x=1-->最后由B修改x=1。这些步骤实质上就是A在给B发消息,且要通过主内存,及JMM通过控制主内存在控制线程之间的内存可见性。
我们来看一个Java虚拟机层面产生的可见性问题:
public class Visibility implements Runnable{
private boolean isStoped = false;
public void run() {
int i = 0;
while(!isStoped) {
i++;
}
System.out.println("end and print,i=" + i);
}
public void stopFunction() {
isStoped = true;
}
public boolean getStopStatus(){
return isStoped;
}
public static void main(String[] args) throws Exception {
Visibility visibility = new Visibility();
new Thread(visibility,"visibility").start();
Thread.sleep(1000);
//当主线程直到主线程调用stop方法,改变了v线程中的stop变量的值使循环停止。
visibility.stopFunction();
Thread.sleep(1000);
System.out.println("finish main");
System.out.println(visibility.getStopStatus());
}
}
运行结果:
为什么循环没停止,且没有打印System.out.println("end and print,i=" + i);这句话?原因是主线程调用把isStoped值修改为true对visibility线程并不可见,所以主线程走完了,而visibility线程一直在做i++操作。解决上述变量不可见性的方法:用volatile关键字修饰isStoped变量,它会强制性的从主内存中取值,从而避免本地内存变量不可见问题。
很奇怪?其实我们写代码时只是看上去有序,代码在编译成指令执行时会进行重排序保证程序运行的高效率。 因为下面的语句1和语句2没有什么数据依赖性,可能会打乱顺序执行:
a=b+c; //语句1
d=e-f; //语句2
g=a+3; //语句3
CPU执行一条指令的时候一般有:取指IF-->译码和读取寄存器操作数ID-->执行或者有效地址计算EX(用到CPU中逻辑运算单元)-->存储器访问MEM-->写回WB(用到寄存器)
比如两条指令执行顺序由上到下:
当我们运行语句1和语句2时有:
上图产生气泡X会严重影响效率,所以会把没有相关性的操作加入到有气泡操作里,这就是处理器为了提高程序运行效率,对输入代码进行优化:
一般认为CPU的指令都是原子操作,虽然重排序不会影响单个线程内程序执行的结果,但是多线程会有影响:
public class VolatileSort {
int a =0;
boolean flag = false;
public void write(){
a=1;
flag =true;
}
public void read(){
if(flag){
int i=a+1;
//...
}
}
}
一个线程执行write方法,另一个线程检查flag=true时,a=1还未执行,会导致程序运算错误。解决方法还是使用volatile关键字修饰变量。
volatile关键字最致命的缺点是不支持原子性。比如count++这样的操作就不是原子性的:
public class VolatileTest {
private volatile int count= 0;
//解决方法之一
// Lock lock = new ReentrantLock();
// public void increase() {
// lock.lock();
// try {
// count++;
// } finally{
// lock.unlock();
// }
// }
//解决方法之二
// public synchronized void increase() {
public void increase() {
count++;
}
public static void main(String[] args) {
final VolatileTest test = new VolatileTest();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
//保证前面的线程都执行完
while(Thread.activeCount()>1){
Thread.yield();
}
//执行结果并不是10000
System.out.println(test.count);
}
}
count++其实有三个操作:读取count-->做count+1操作-->然后把count+1后的值写回到count里。
假设有两个线程,当第一个线程读取count=1时,还没进行+1操作,切换到第二个线程,此时第二个线程也读取的是count=1。随后两个线程进行后续+1操作,再赋值回去以后,count不是3,而是2。显然数据出现了不一致性,可以使用上面代码的方法一和方法二加锁或原子类操作:
public class VolatileTest2 {
private AtomicInteger count= new AtomicInteger();
public void increase() {
count.getAndIncrement();
}
public static void main(String[] args) {
final VolatileTest2 test = new VolatileTest2();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1){
Thread.yield();
}
System.out.println(test.count.get());
}
}
其实前面或多或少提到了volatile关键字,这里做一些扩展,volatile是如何保证可见性的呢?
volatile User user =new User();
上述代码在x86处理器通过工具获取JIT编译器生成的汇编指令如下:
0x01a3deld:movb 》$0×0,0×1104800(%esi);0x01a3de4>:lock add1 $0×0,(%esp);
lock指令在多核处理器会引发两件事:
1.将当前缓存行的数据写回到系统内存。
2.这个写回内存操作会使其他CPU里缓存了该内存地址的数据无效。
参考:
《深入理解Java虚拟机》
《Java并发编程的艺术》
《Java高并发程序设计》