Java内存模型(JMM)
Java内存模型,既是定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。其中,JMM隶属于JVM。JMM主要解决的问题,是处理cpu处理速度和内存存取速度极度不平衡的问题。
以下数据摘自《Jeff Dean在Google全体工程大会的报告》。根据表格中的数据,计算从内存中读取1M的int型数据由CPU进行累加的耗时情况。Java里int型为32位,4个字节,1M数据共有1024*1024/4 = 262,144个整数 ,CPU 计算耗时为 262144 *0.6 = 157,286 纳秒。CPU每次需要从内存读取数据,耗时100纳秒,读取数据需要耗时为262144*100 = 26,214,400。可以看到cpu处理速度和内存读取速度存在数量级上的差异。这还是不包括,如读取运算数据、存储运算结果,以及可能发生的磁盘IO操作。
为了平衡cpu的指令速度远超内存的存取速度的问题,现代计算机引入缓存机制。在cpu和内存之间增加一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为缓冲。将运算需要使用到的数据复制到缓存中,运算结束后再从缓存同步回内存之中,这样cpu就无须等待缓慢的内存读写而造成资源的浪费。
下图是加入了三级缓存的JMM,越接近cpu核心的高速缓存,存取速度越接近cpu的运算速度。上一级缓存的数据由下一级缓存的数据复制而来。L1缓存数据来自于L2,L2来自L3,L3数据来自于内存。现代计算机的缓存,通常集成在cpu的内部。
可见性问题
从线程角度来看,JMM定义了线程和主内存之间的关系。线程之间的共享变量存储在内存(Main Memory)中。同时,每个线程拥有自己的本地内存(Local Memory),存储该线程读写共享变量的副本。这里本地内存是一个抽象概念,并不真实存在,它涵盖了缓存、寄存器等硬件设备。
可见性既是当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。由于线程对变量的操作实在自己的工作内存(本地内存)中进行,然后才从工作内存同步到主内存,而不是直接操作主存的数据。因此,刷新会有一定时间差,存在刷新不及时的问题。如线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了,因而产生可见性问题。
与可见性类似的还有一个概念:原子性。
原子性:表示一个或多个操作,要么全部执行,要么全都不执行。CPU资源的分配是以线程为单位进行分时调用。既操作系统允许某个进程执行一小段时间,过了这个时间,操作系统就会重新选择一个线程来执行,既进行任务切换。而任务切换,可以发生在任何一条CPU 指令执行完。如count++,java里的一句话,实际上包含了3条CPU指令。原子性,则要求这3条指令要么都执行,要么都不执行,不因为cpu切换任务而中断。
Java使用volatile关键字来解决可见性问题。
volatile实现原理
表面上看,对一个volatile变量的读,总是能看到其他线程对这个volatile变量最后的写入。
它的实现原理,通过对OpenJDK中的unsafe.cpp源码的分析,发现被volatile关键字修饰的变量会存在一个“lock:”的前缀。Lock前缀,会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。该指令会将当前处理器的数据直接写到主内存中,并且这个写到主内存的动作会使在其他CPU缓存里缓存了该地址的数据无效。
volatile虽然能保证执行完及时把变量刷到主内存中。但对于非原子性、多cpu指令的操作,如count++,不能保证数据的安全访问。count++多线程下存在这种情形:在cpu进行线程切换时,线程A把count=0加载到工作内存,线程B此时刚好开始工作。如此导致线程A和B执行完的结果都是1,当把结果写回到主内存中,此时count的值还是1而不是2。要保证操作的原子性,就需要用到synchronized关键字(在我的另一篇博客有详细介绍)。