大佬讲话“如欲征服java并发,需先征服java内存模型,如欲征服java内存模型,需先征服计算机内存模型” -aworker.
咳!咳!,大家都记好笔记了吧。虽然我不是什么大佬,但是这句话说的还是没有毛病的。不了解java的内存模型,就不会从跟不上理解java并发的一些行为和机制,而java内存模型毕竟是jvm模拟出来的一部分,其底子还是建立在现代计算机的物理内存模型上来的,所以我们就按照现代计算机的物理内存模型、java内存模型的顺序来仔细介绍,为彻底了解java并发机制打下底子。
现代计算机的物理内存模型:
现代计算机的物理内存模型
现在计算机最少的都是应该是两核心了,当然我们也经常在买个人电脑的时候听过四核四线程、四核八线程等,可以说现在个人电脑标配都是四核心了,为了方便上图只是列举了2个核心。现代计算机的内存在逻辑上还是一块。有人可能问不对啊,我电脑就插了两块内存,但是操作系统会把两块内存的地址统一抽象,比如每一块的内存是2048MB地址是000000000000-011111111111MB,两块就是0000000000000-0111111111111MB,操作系统会统一编址。所以整体上看还是一块内存。因为CPU的操作速度太快,如果让CPU直接操作内存,那么久是对CPU资源的一种巨大浪费,为了解决这个问题现在计算机都给CPU加上缓存,比如一级缓存,二级缓存,甚至三级缓存。缓存速度比内存快,但是是还是赶不上CPU的数据级别,所以在缓存和CPU之间又有了register,register的存储速度比缓存就快了好多了。
存储速度上有如下关系:
register > 一级缓存 > 二级缓存 > ... > n级缓存 > 内存
容量上一般有如下关系:
内存 > n级缓存 > ... > 二级缓存 > 一级缓存 > register
之所以可以用缓存和register来缓解CPU和内存之间巨大的速度差别是基于如下原理:
CPU访问过的内存地址,很有可能在短时间内会被再次访问。
所以,比如CPU访问了地址为0x001fffff的内存地址,如果没有缓存和register,那么CPU再下次访问这个内存地址的时候就还要去内存读,但是如果有缓存,缓存会把CPU访问过的数据先存储起来,等CPU待会再找地址为0x001fffff的内存地址时候,发现其在缓存中就存在了,那么好了,这就不用在访问内存了。速度自然就提升了。这就涉及到计算机组成原理的知识了,如果想了解可以google一下,这里就不在做更深的介绍了到这里就够用了。
了解现代计算机物理内存模型工作原理后,那么再理解多线程开发中最关系的三个概念就有的放矢了。先介绍下三个概念:
- 操作原子性:一个操作要么全做,要么全不做,那么这个操作就符合原子性。比如你给你老婆银行卡转500块钱,就包括两个操作,自己账户先减500,你老婆账户加500。但是这个转账操作应该满足原子性。如果银行只执行了你自己账户的扣钱操作,没有执行给你老婆账户的加钱操作。丢了500块钱是小事,被老婆大人罚跪搓衣板可就不得了了。所以你自己账户减钱,老婆账户加钱,这两个操作要么都做了,要么都别做。例如如下操作:
a = a + 1;
结合我们上述的现代计算机的内存模型,计算机执行a=a+1时候会分成三个原子性操作:
- 把a的值(比如4)从内存中取出放到CPU的缓存系统中
- 从缓存系统中取出a的值加1(4+1)得到新结果
- 把新结果存回到内存中
一个“a=a+1”操作计算机中被拆分成三个原子性操作,那么完全可以出现CPU执行完1.操作后,去执行别的操作了。这就是并发操作原子性问题的根本来源。
- 操作有序性:例如如下代码:
public class A {
public int a;
public boolean b = false;
public void methodA(){
a = 3;
b = true;
a = a + 1;
}
public void methodB(){
a = 3;
b = (a == 4);
a = a + 1;
}
}
methodA方法代码先经过java编译器编译成字节码,然后字节码然后被操作系统解释成机器指令,在这个解释过程中,操作系统可能发现,咦?在给变量b赋值为true后又操作了a变量,干脆我操作系统自己改改执行顺序,把对a变量的两个操作都执行完,然后再执行对b的操作,这就叫指令重排序。这样就会节省操作时间,如下图没有进行指令重排序时:
图中CPU和缓存系统要进行9次通信,缓存系统和内存要通信7次,假设cpu和缓存系统通信一次用时1ms,缓存系统和内存通信一次用时10ms,那么总用时 9乘1 + 7乘10 = 79ms。经过指令重排序后,总共用时 6乘1 + 6乘10 = 66ms 如下图所示:
有指令重排序
经过指令重排序的确可以提程序运行效率,所以现代计算机都会对指令进行重排序,但是这种重排序也不是无脑重排序,重排序的基础是前后语句不存在依赖关系时,才有可能发生指令重排序。所以A类的methodB方法不会发生指令重排序。指令重排序在单线程环境里面这不会有什么问题,但是多线程中就可能发生意外。比如线程1中执行如下代码:
instance.methodA();
另一个线程2执行如下代码:
while(instance.a != 4){ //a只要不等4,线程就让出CPU,等待调度器再次执行此线程
Thread.yield(); //让出CPU,线程进入就绪态
}
System.out.print(instance.b);
其中instance是A类的一个实例。如果线程1 发生了指令重排序, 那么这线程2的打印结果很有可能是false,这就和我们对代码的直观观察结果出处很大。如果线上产品出错的原因是指令重排序导致的,几乎不能可能排查出来。
-
操作可见性 :
在“操作有序性” 中的线程线程2 ,还有可能会没有任何输出结果。因为线程2 要想有输出必须要满足instance.a =4,但这是在线程1中调用methodA 方法后instance.a 的值才为4 。而要想让线程2 看到这个新值,必须要把线程1的修改及时写回内存, 同时通知线程2 存在缓存系统中的instance.a值已经过期,需要去内存中获取最新值。如果我们的类A和线程1、线程2调用的代码没有特殊的声明,那么操作系统不能保证上述过程一定发生。即可能发生线程1对instance.a的修改对线程2不一定可见,这就是操作的可见性问题。
java多线程的所有问题都植根于“操作原子性”、“操作有序性”、“操作可见性”而引发的。
上面介绍了现代计算机的内存模型以及其引起的在并发编程的三个问题,下面来介绍下java的内存模型。java为了实现其夸平台的特性,使用了一种虚拟机技术,java程序运行在这虚拟机上,那么不管你是windows系统,linux系统,unix系统,只要我java虚拟机屏蔽一切操作系统带来的差异,向java程序提供专用的、各系统无差别的虚拟机,那么java程序员就不需要关心底层到底是什么操作系统了。对于int类型的变量其取值范围永远是 -2^31 -1 至 2^31,即4个字节。但是对C\C++,这个操作系统的int可能是4字节,那个可能是8字节。C++程序员跨平台写代码,痛苦异常。这个给我们编程带来极大方便的虚拟机就是大名鼎鼎的JVM(Java Virtual Machine)。既然是虚拟机那么就需要模拟真正物理机的所有设备,像CPU,网络,存储等。和我们程序员最密切的就是JVM的存储,这就是java内存模型(Java Memory Model 简称JMM)。有别于我们真实的物理存储模型,JMM把存储分为线程栈区和堆区。在JVM中的每个线程都有自己独立的线程栈,而堆区用来存储java的对象实例。java中各种变量的存储有一下规则:
- 成员变量一定存储在堆区。
- 局部变量如果是基本数据类型存储在线程栈中,如果是非基本数据类型存储,其引用存储在线程栈中,但具体的对象实例还是存储在栈中。
因为java内存模型是在具体的物理内存模型的基础上实现的,并且为了运行效率,java也支持指令重排序。所以java并发编程也有“原子性”、“有序性”、“可见性”三个问题。
网友评论