概述
如果你想设计行为正确的并发程序,理解Java的内存模型是非常重要的。Java的内存模型说明了如何及什么时候 ,不同的线程可以看到其他线程写入到共享对象中的值,以及如何在需要的时候同步访问共享对象。
这个Java的内存模型是在Java1.5版本的时候修正过的,该版本的Java内存模型一直沿用到今天(Java 16+)。
JVM的内存模型
我们参照下图:

每一个运行在Java虚拟机里的线程都有它自己的线程栈。线程栈包含关于哪些线程的方法被调用以达当前执行点的信息。我们将其称为“调用栈”。因为线程执行代码,调用栈就会发生改变。
线程栈也包含每个正在执行的方法的所有局部变量(调用栈上的所有方法)。一个线程只能访问它自己的线程栈。通过一个线程创建的局部变量相对于创建它的其他的所有线程都是不可见的。甚至如果两个线程正在执行确定的相同的代码,两个线程仍然在他们各自的线程栈中创建局部变量的副本。因此,每一个线程都有它自己的局部变量的版本。
所有的原始类型的局部变量(boolean、byte、short、char、int、long、float、double)是完全存储在线程栈中的,从而使它们相对于其他线程不可见。一个线程可以通过一个原始变量的拷贝将其传递给其他线程,但是却不可以自己分享这些原始局部变量。
堆包含你的Java应用程序中创建的所有对象,不管是什么线程创建的对象。这包括原始类型的对象版本(Boolean、Byte、Short、Character、Integer、Long、Float、Double)。是否一个对象被创建并且分配给一个局部变量,或者作为一个其他对象的成员变量被创建,都无关紧要,这些对象依然存在堆中。
下面的图片展示了存储在线程栈上的调用栈以和局部变量,以及存储在堆上的所有对象:

局部变量可以是原始类型。这种情形下,它完全存储在线程栈中。
局部变量也可以是对象的引用。这种情况下,这个引用(局部变量)存储在线程栈上,但是对象自己存储在堆上。
对象可以包含方法,并且方法可以包含局部变量。这些局部变量也存储在线程栈上,即使方法所属的对象是存储在堆上的。
对象的成员变量同这个对象本身一起存储在堆中。无论成员变量是原始类型,还是一个对象的引用,它们都是如此——存储在堆中。
静态的类变量和类定义本身也都是存储在堆上的。
堆上的对象可以由所有引用该对象的线程访问。当一个线程可以访问到一个对象,它也就可以访问这些对象的成员变量。如果两个线程在同一时间调用同一个对象上的方法,他们都将可以访问这个对象的成员变量,但是,每一个线程都将有它们自己的局部变量的拷贝。
我们将上面的图进行改造一下,然后向大家展示其线程栈与堆中的对象之间的关系。

两个线程都有一组局部变量。两个线程中的局部变量(Local variable 2)都指向一个在堆上的共享对象(Object 2)。每个线程都有指向同一个对象的不同的引用。它们的引用是局部变量,因此分别存储在各自的线程栈上。尽管两个不同的引用指向的是同一个堆上的对象。
注意,共享对象(Object 2)有两个成员变量,它们是指向Object 5对象及Object 6对象的引用(图中Object 2有两个箭头,分别指向Object 5和Object 6)。通过Object 2中的这些成员变量引用可以访问Object 5及Object 6。
图中也展示了指向堆上两个不同的对象的局部变量。在这个情景中,引用指向两个不同的对象(Object 1和Object 6),不是同一个对象。因此两个线程分别可以访问Object 1及Object 6。
硬件内存架构
现代硬件内存架构与内部Java内存模型是有一些不同的。懂得硬件内存模型也是很重要的,要理解它与Java内存模型是如何一起工作的。本段用于描述通用的硬件内存架构,并且最后会描述它与Java内存模型是如何一起工作的。
下面是现代计算机内存架构的一个简单描述图:

一个现代的计算机通常有两个或更多的CPU。一些CPU可能还是多核心的。关键是,在一个现在计算机上有两个或多个CPU,可能有很多个线程同时运行。每一个CPU都有能力在给定的时间里运行一个线程。这就意味着如果你的应用程序是多线程的,一个线程一个CPU,在你的应用程序中,多个线程可能是同时(并发)运行。
每一个CPU包含一组寄存器(CPU Registers),其本质上就是CPU内部的内存。CPU在它们的寄存器上执行操作的速度会比在主存储器中的变量上执行的速度更快。这是因为CPU访问寄存器比访问主存储器快得多。
每一个CPU可能还有CPU缓存层。实际上现代CPU都有一定数量的缓存层。CPU访问它们的缓存会比主存储器要快,但是没有访问它们内部的寄存器快。所以,CPU缓存的速度是介于主存储器(内存)与寄存器之间的。一些CPU可能有多个缓存层(Level 1和Level 2——即一级缓存与二级缓存),但是它对于理解Java内存模型与内存的交互就没有那么重要了。
计算机还包含一个主存储器(RAM——Random-access memory)。所有的CPU都可以访问内存。内存通常会比CPU的缓存大很多。
通常,当CPU需要访问内存的时候,它将读取内存中的一部分到CPU缓存中。它将缓存中的部分读取到内部寄存器中,然后对其执行操作。当CPU需要将结果写回内存时,它会将这些值从内部寄存器刷新到缓存,并且在某个时间点将值返回给内存。
这些存储在缓存中的值通常会在CPU需要在缓存中存储一些其他东西的时候刷新到内存中。CPU有时写入一部分数据到缓存,有时从缓存刷新一部分数据到内存。不需要在每次更新的时候读或写全部缓存。一般的,缓存更新的最小的内存块叫做“缓存行”。可以读取缓存中的一个或多个缓存行,也可以刷新一个或多个缓存行到内存中。
Java内存模型与硬件内存模型
正因为Java的内存模型与硬件内存模型是不同的。硬件内存模型不区分线程栈与堆。在硬件中,线程栈与堆都位于主存储器(内存)中。部分线程栈和堆有时候也会出现在CPU缓存及内部寄存器中。如下图所示:

当对象与变量可以存储在计算机中的各种不同的内存区域中的时候,可能会出现下面两种问题:
- 线程更新(写)到共享变量的可见性
- 读取、检查及写入共享变量时的竞态条件
下面我们来解释上面的两个概念:
共享对象的可见性
如果两个或多个线程共享一个对象,没有正确的使用volatile声明或者synchronized,通过一个线程对共享对象的更新可能对其他线程不可见。
设想一下,共享对象最初存储在内存中。一个线程运行在其中的一个CPU上,然后读取共享对象到它的CPU缓存中。在那里它对共享对象进行了改变,只要CPU缓存没有刷新回内存,共享对象的改变对于运行在其他CPU上的线程就是不可见的。通过这种方式,每一个线程可能最终都拥有它自己的共享对象的副本,每一个拷贝设置在不同的CPU缓存中。
下面的示例图描述了这样的场景。运行在左面CPU上的一个线程拷贝了一个共享对象到它的CPU缓存中,并且将它的count变量的值更改为2。这个改变对于运行在右面的CPU上的其他线程是不可见的,因为count的更新还没有刷新回主内存。

为了解决这个问题,你可以使用Java的volatile关键字,这个volatile关键字可以确保从内存中直接读取给定的变量,并且当变量更新的时候总是能够写回到内存中。
竞争条件
如果两个或多个线程共享一个对象,并且一个以上的线程在共享对象中更新变量的值,竞争条件就可能发生。下面我们先给出图示:

图中所示,如果线程A读取共享对象的count变量到自己的CPU缓存中,线程B同样如此,只是它们的CPU缓存却是不同的。也就是说,图中两个CPU各执行一个线程,每个线程都读取count变量到自己的CPU缓存中,当然它们的缓存是不同的。现在线程A给count的值加1后变为2,并且线程B也给count的值加1后也变成2 。
如果这些增量是顺序执行的,那么变量count应该增加了两次,其原始值+2后写到回内存中。
然而,这两个增量是并发执行的且没有做适当的同步。不管线程A或线程B,将它们的count变量的更新版本写回到内存中,更新的值只是比原始值多1而已,尽管count已经发生了两次增加。
你可以使用Java的同步代码块来解决这个问题。同步代码块保证在给定的时间内只有一个线程可以进入代码的给定临界段。同步块也保证同步块中的访问的所有变量都是从内存中读取的,并且当线程退出同步代码块的时候,所有更新的变量都将再次刷新回内存,而不管这个变量是否使用volatile关键字声明与否。