走近Java的内存模型
相信大家在网上也看到过很多Java内存模型的文章,但很多人可能和我一样读完之后还是没有理解。作为一名Java小白,经过一段时间的学习,对Java内存模型有所了解。本篇文章,就来系统整体的对Java内存模型作以解释。目的也非常简单,希望大家读完后能知道Java内存模型是什么,为什么要用Java内存模型,以及Java内存模型可以解决什么问题等。
1.为什么要有内存模型
在介绍Java内存模型时,首先我们需要了解计算机的内存模型,因为Java内存模型是在计算机内存模型基础上进行增加的。
1.1 计算机内存模型
计算机在执行程序时,每条指令都是在CPU中执行的,而执行的时候,又免不了要与数据进行交互。计算机上面的数据,是存放在主存中的,也就是计算机的物理内存。
随着CPU技术的发展,CPU的执行速度执行速度越来越快,而由于内存的技术没有太大的变化,所以从内存中读取与写入数据的速度与CPU的执行速度相比起来差距越来越大,导致CPU每次操作内存都要耗费很多的等待时间。
于是,就有了在CPU与主存之间增加高速缓存的想法。缓存的概念可以理解为保存一份数据拷贝,特点是速度快、体积小。
这时当程序运行时,会将运算需要的数据从主存中复制一份到CPU的高速缓存中,那么CPU进行计算时就可以直接从高速的缓存读取数据以及写入数据,当运算结束后,再将高速缓存中的数据再刷新回主存中。
在多路处理器系统中,每个处理器都有自己的高速缓存,而它们共享同一主存。如下图:
- 程序执行时,把需要用到的数据,从主内存拷贝一份到高速内存中
- CPU处理器计算时,把它从高速的缓存中先进行读取,然后把计算完的数据写入高速缓存
- 当程序计算结束后,把高速缓存的数据刷新回主内存
随着CPU的能力不断增加,一层缓存就慢慢无法满足要求,就逐渐衍生出多级缓存。
按照数据读取顺序与CPU结合的紧密程度,CPU缓存可以分为一级缓存(L1)、二级缓存(L2)、三级缓存(L3)。
有了多级缓存之后,程序的执行就变为:当CPU要读取一个数据时,首先需要从一级缓存中进行查找,如果没有找到再从二级缓存中查找,如果还是没有就从三个缓存或内存中查找。
随着计算机能力的不断提升,开始支持多线程,下面我们来分析下单线程、多线程在单核CPU、多核CPU中的影响。
**单线程** 。CPU的核心的缓存只被一个线程所访问。缓存独占,不会发生访问冲突等问题。
**单CPU,多线程**。进程中的多个线程会同时访问进程的共享数据,CPU将某个内存加载到
缓存中后,不同线程在访问相同的物理地址时,都会映射到相同的缓存位置,这样即使发生
线程的切换,缓存都不会失效。由于任意一个时刻只有一个线程在执行,所以不会发生访问冲突。
多核CPU,多线程。每个核至少有一个一级缓存(L1)。多个线程访问进程中的某个共享内存,多个线程分别在不同的核心上进行执行,每个核心都会在各自的caehe中保存一份共享内存的缓冲。这样可能就会出现多个线程同时写各自缓存的内容,而各自的缓存之间的数据就有可能不同。
在CPU与主存之间增加缓存,在多线程场景下就可能存在缓存一致性问题。也就是说,在多核CPU中,每个核自己缓存中,关于同一个数据缓存的内容可能不一致。
1.2 解决缓存不一致问题的方案
当多个处理器的计算任务都涉及同一块主内存区域,可能会导致缓存不一致问题。如何解决这个问题呢?有两种解决方案:
1.通过总线加Lock锁的方式解决缓存不一致的问题
2.通过缓存一致性协议来解决缓存不一致问题
方式一:总线加Lock锁的方式
总线(Bus)是计算机各种功能部件之间传送信息的公共的通信干线,它是由导线组成的传输线束,按照计算机所传输的信息种类,计算机的总线可以划分为数据总线、地址总线和控制总线,分别用于传输数据、数据地址和控制信号。
CPU和其他功能部件是通过总线进行通信的,如果在总线加Lock锁,那么锁住总线期间,其他CPU是无法访问内存的,这样一来,效率就低了。
方式二:缓存一致性协议
为了解决缓存一致性问题,还可以通过缓存一致性协议。即各个处理器访问缓存时都遵循了一些协议,在读写的时候需要根据读写协议来进行操作。
其中缓存一致性协议最有名的是: Intel的MESI(Modified Exclusive Shared Or Invaild)协议,协议的操作思想为:当CPU写数据时,如果发现操作的变量为共享变量,即在其他CPU也存在该变量的副本,会发出信号去通知其他CPU将变量的缓存行置为无效状态。因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么就会从内存重新读取。
2.Java内存模型(JMM)的实现
前面介绍过计算机内存模型,这是解决多线程场景下并发问题的一个重要规范。那么具体的实现是如何的呢,不同的编程语言,在实现上可能有所不同。
- Java虚拟机规范试图定义一种内存模型,来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台上都能达到一致的内存访问效果。
- Java内存模型类比于计算机内存模型。
- 为了更好的执行性能,java内存模型并没有限制引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制编译器进行调整代码顺序优化。所以Java内存模型会存在缓存一致性问题和指令重排序问题。
- Java内存模型规定所有的变量都是存在主内存当中(类似于计算机模型中的物理内存),每个线程都有自己的工作内存(类似与计算机模型的高速缓存)。这里的变量包含实例变量和静态常量,但是不包含局部变量,因为局部变量是线程私有的。
- 线程的工作内存保存了该线程使用的变量的主内存副本,线程对变量的操作都必须在工作内存中进行,而不能直接操作主内存。并且每个线程不能访问其他线程的工作内存。
举个例子,假设i的初始值为0,执行以下语句:
i=i+1;
首先,线程t1从主内存中读取到i=0,到工作内存。然后在工作内存中,赋值i+1,工作
内存就得到i=1,最后把结果再写回主内存。因此,若是单线程,此语句执行是没有问题的。
但是,线程t2的本地工作内存还没有过期,那么它读到的数据就是脏数据(脏数据一般是指
不符合要求以及不能直接进行相应分析的数据。在常见的数据挖掘工作中,脏数据包括:
缺失值、异常值、不一致的值、重复数据及含有特殊符号的数据)了。
Java内存模型是围绕着如何在并发过程中解决**【原子性、可见性、有序性】**这3个特征来建立的。而这第三个特征是并发编程的3个特性。
原子性
原子性,指操作是不可中断的,要么执行完成,要么不执行。基本数据类型的访问和读写都是具有原子性。
我们可以通过下面例子来深刻体会原子性:
i=23; //语句1
i=j ; //语句2
i=i+1; //语句3
i++; //语句4
- 语句1操作显然是原子性的,将数值23赋值给i,即线程执行这个语句时,直接将数值23写入工作内存中。
- 语句2操作看起来也是原子性的,但它实际上涉及两个操作,先去读j的值,再把j的值写入工作内存,两个操作分开都是原子操作,但是合起来就不满足原子性。
- 语句3读取i的值,加1,再写回主存,这个就不是原子性操作了。
- 语句4等同于语句3,也是非原子性操作。
在Java中可以使用synchronized关键字来保证方法与代码块中的操作是原子性的。
可见性
- 可见性就是指一个线程修改共享变量的值,其他线程能够立即得到这个修改
- Java内存模型是通过在变量修改后将新值同步到主内存中,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性,无论是普通变量还是volatile变量都是如此。
- volatile变量,保证新值能立即同步到主内存中,以及每次使用前立即从主内存中进行刷新。所以我们说volatile关键字保证了多线程操作变量的可见性。
- synchronized和Lock也能保证可见性,线程在释放锁之前,会把共享变量值都刷回主内存。final也可以实现可见性。
有序性
在Java中,可以使用synchronized和volatile来保证多线程之间操作的有序性。
- 实现方式有所区别:volatile关键字会禁止指令重排。synchronized关键字保证同一时刻只允许一条线程来操作。
这里简单介绍了java并发编程中解决原子性、可见性、以及有序性可以使用的关键字。大家似乎可以发现,好像synchronized关键字是万能的,它可以同时满足三个特性,这其实也是很多滥用synchronized的原因。
注意:synchronized是比较影响性能的,虽然编译器提供了很多锁优化技术,但是不建议过度使用。
总结
相信大家读取本文后,对于Java内存模型会有所了解,有兴趣的朋友也可以通过《深入理解Java虚拟机》和《Java并发编程的艺术》了解更多哟。