Java的内存模型定义了Java虚拟机如何和计算机物理内存进行交互。Java虚拟机是一体化的计算机模型,所以它自然也包含了内存模型。
如果你要设计运行稳健的并发程序,理解Java内存模型是非常必要的。Java内存模型定义了不同线程之间变量读写的可见性以及如何同步访问共享变量。
最原始的Java内存模型设计的很不足。后来到了Java1.5版本就重新制定了,这个版本的内存模型一直延续到了现在。
深入Java内存模型
Java内存模型内部分为线程堆栈和堆两种区域。下图粗略了表示了这种结构。
Java虚拟机中的每个线程都有它自己的线程栈。线程栈记录了当前线程到达当前执行点所经历的一系列方法调用信息。暂且把它称之为【调用栈】。随着线程不停地执行代码,调用栈一直不停的改变。
线程栈记录了正在运行的调用栈里所有方法的局部变量信息。线程只能访问它自己的线程栈。局部变量仅对创建它的线程有效,其它线程是完全看不见的。甚至当两个线程执行的是同一段代码逻辑,它们也会分别在自己的线程栈里创建这些局部变量,每个线程都有它自己的局部变量,互不干扰。
所有原生类型(boolean, byte, short, char, int, long, float, double)的局部变量都是完全存储在当前的线程栈里面,别的线程完全看不到。线程之间可以通过拷贝来分享原生类型的变量,但是无法直接共享。
堆包含了Java应用程序创建的所有对象,不管这些对象是被哪个线程创建的。这些对象包含了所有原生类型的包装类型。无论这些被创建的对象是被赋值给了哪个局部变量还是挂在了某个对象的成员变量上,它们都存储在堆里面。
下图展示了Java虚拟机里面的调用栈和局部变量以及存在堆里面的所有对象。
局部变量可以是原生类型,这样它就完全存在了栈里面。
局部变量也可以是对象的引用,这种情况下对象的引用也就是局部变量本身是存在栈里面的,而对象本身的内容则放在堆里面。
对象里有方法,这些方法又有局部变量。这些局部变量也存在栈里面,尽管这些方法所属的对象是放在堆里面的。
对象的所有字段同对象一样都是放在堆里的,不论这个字段是原生类型还是对象的引用类型。
静态变量和类定义对象一样也是放在堆里的。
堆里的对象可以被所有线程访问,只要这些线程有相应对象的引用。线程如果可以访问一个对象,那一定就可以访问这个对象的成员字段。如果两个线程同时访问同一个对象的同一个方法,那么它们会同时访问对象的字段,当时每个线程都会创建属于自己的局部变量。
具体请看下面这张图
两个线程都有一些局部变量。其中局部变量2指向了堆里面的对象3。这两个线程都有属于自己的指向同一个对象的引用变量。这些引用都是局部变量,都存放在各自的线程栈上,尽管它们都指向同一个对象。
注意到这个共享的对象3持有对象2和对象4的引用作为它的内部成员。通过成员变量引用, 这两个线程都可以访问对象2和对象4。
图中两个线程栈里面的局部变量1分别指向了堆里的不同对象。理论上堆里的所有对象都可以被两个线程访问,但是就图上所示,每个线程都只有一个对象的引用。
上面这张图可以使用下面的代码来表示
如果两个线程同时执行run方法,就呈现出了上面那张图。run方法调用methodOne,methodOne又调用methodTwo。
methodOne定义了一个原生的局部变量和一个对象引用局部变量。两个线程在执行methodOne的时候都会在各自的栈上创建局部变量1和局部变量2,它们互不干扰,一个线程是看不到另外一个线程对局部变量做的任何修改的。
两个线程在执行methodOne时还会创建各自的局部变量2,然后这两个变量都指向堆里的同一个对象。代码设置局部变量2指向一个静态变量引用的对象,这个静态变量是唯一的,也存放在堆里。结果就是这两个局部变量都指向同一个又静态变量指向的ShareObject对象,ShareObject这个对象实例就存放在堆里,就是图中的对象3。
注意到MyShareObject的两个成员变量都是long类型的。因为它们是成员变量,所以也跟对象一起放在堆里。只有局部变量才会放在栈上。
硬件内存结构
现代的硬件内存结构和Java内存模型是不一样的。想要理解Java内存模型是如何同物理内存交互的话,先把硬件内存结构搞懂非常重要。这一节我们来看看硬件内存结构,接下来我们再看Java内存模型是如何与之交互的。
看图
现代计算机一般都有多个CPU,每个CPU又有多个核。所以它们往往可以同时跑多个线程。每个CPU同一时间都可以跑一个线程。这就意味着如果你的Java程序是多线程的,每个CPU都会同时跑着一个线程。
每个CPU内部都有一堆基本的寄存器。CPU和这些寄存器交互要比和内存交互快得多。
每个CPU都有一个缓存层,现代的CPU一般都是特定大小的缓存层。CPU访问这些缓存要比访问内存快,但是还是比不上寄存器。有些CPU可能会有多个缓存层,不过这不影响我们理解Java内存模型是如何同内存进行交互的。我们只要知道CPU有这样一个缓存层就够了。
计算机的主内存所有的线程都可以访问,它的容量往往比CPU缓存大得多。
当CPU要读主内存时,它会首先读取一小块内存到CPU缓存里,接着又会读取缓存的一小块到寄存器里,然后就可以继续操作了。当CPU要写主内存时,它会将寄存器里的值Flush到缓存里,后面又会将缓存里的值Flush到主内存。
当CPU想用这些缓存存点别的什么东西时会先把缓存里的数据会刷回到主内存。CPU缓存可以在从主存加载数据的同时回写数据到主存,这两个操作对象是CPU缓存的不同部分。CPU缓存没必要一次性读写整个缓存区域。一般来说CPU缓存更新的单位是【缓存行】,英文是cache lines。一部分缓存行从主存加载数据,另一部分缓存行回写数据到主存。
Java内存模型和硬件内存结构的异同
前面提到,Java内存模型和硬件内存结构是不一样的。硬件内存结构是不会区分线程栈和堆的。在硬件上,线程栈和堆都放在主内存里,还有一些甚至会在CPU缓存和寄存器里。如下图所示
当对象和对象可以放在不同的存储地方的时候,问题就来了。
线程修改共享变量的可见性
读写共享变量的竞态条件
解下来我们逐个分析
共享对象的可见性
如果多个线程共享一个对象,而这个对象又没有使用volatile修饰也没有任何同步控制的话,一个线程对共享变量的修改是可能不会立即被其它线程看到的。
试想一开始对象是在主存里创建的,然后CPU将对象加载到CPU缓存,在缓存里修改了这个对象而没有立即将缓存写会主存。那这个修改过的对象是不能够被运行在其它CPU上的线程看到的。结果就是每个线程都有这个对象的一个拷贝,分别放在不同的CPU缓存里。
下图能很好的表现这种情况。运行在左边CPU上的线程将count变量加载到CPU缓存中,然后将count修改成了2,而右边CPU上跑的线程则看不到这种改变。因为修改过的缓存行没有同步刷回主内存。
为了解决这个问题就必须用到volatile关键字,volatile关键字会确保读操作会直接读取主内存,对变量的修改也是立即回写主内存。
竞态条件
多个线程共享一个对象,并且同时对这个对象进行修改的时候,竞态条件就产生了。
假设线程A将共享变量count读到CPU缓存,同时线程B也将共享变量count读到另一个不同的CPU缓存,然后同时对count进行加1操作,如此count变量被更新了2次。
如果这两个加1操作是串行执行的话,那么当count变量回写内存的时候,值就是value+2
可现在这两个操作没有进行适当的同步控制,也就是说不是串行的。不管是哪个线程先回写主存,最后的值总是value+1,而不是value+2。下面这张图显示了这种竞态条件。
解决这种问题,你可以使用Java的synchronized关键字。synchronized关键字保证同一时间只有一个线程可以进入临界区。同步块同样也可以保证块内的读变量都是直接读主存,变量修改在退出同步块的时候会立即回写到主存,不管这个变量有没有使用volatile修改都一样。
阅读相关文章,关注微信公众号/知乎专栏/头条号【码洞】