我们知道虚拟机(Virtual Machine)是指通过软件模拟的具有完整硬件系统功能的、运行在一个完全隔离环境中的完整计算机系统,所以 Java 虚拟机和真正的计算机一样,也有它自己的内存模型 – 称为 Java 内存模型。
如果要开发设计正确的并发程序,了解 Java 内存模型非常重要。Java 内存模型规定了不同的线程如何、以及何时可以看到其他线程写入共享变量的值,以及如何在必要时同步对共享变量的访问。Java 1.5 对 Java 内存模型进行了修改和优化,这个版本的 Java 内存模型今天仍在使用。
Java 内存模型
JVM 内部使用的 Java 内存模型,在线程堆栈和堆之间划分内存。此图从逻辑角度说明了 Java 内存模型:
我们知道,Java 虚拟机中运行的每个线程都有自己的线程堆栈。线程堆栈包含了每个方法中的局部变量、调用了哪些方法等信息。不同线程堆栈上的信息,彼此之间是不可见、不可互相访问的。同一个方法,被多个线程调用,则每个线程都有自己的堆栈,且堆栈上的信息互相隔离。
而线程创建的对象,是在堆内存上存储的。这个大家应该都知道。
堆栈中的局部变量如果是 Java 中的基本类型(boolean, byte, short, char, int, long, float, double),是直接保存在线程堆栈上的;如果是对象,则实际存储是在堆内存中,但线程的堆栈上会保存指向该对象的引用。如果两个线程同时调用同一个对象的方法,它们都可以访问对象的成员变量,但每个线程都有自己的局部变量副本【重点】。
另外注意,静态类变量也与类定义一起存储在堆上。
硬件内存架构
现代『硬件内存架构』与 『JVM 的内存模型』是有些不同的。了解硬件内存架构也很重要,可以帮助了解 Java 内存模型如何与它一起工作。本文介绍常见的硬件内存架构,后面的部分将介绍 Java 内存模型如何与它一起工作。
这是现代计算机硬件架构的简化图:
现代计算机中通常有 2 个或更多 CPU,一些 CPU 也可能具有多个内核。关键是,在具有 2 个或更多 CPU 的现代计算机上,可以同时运行多个线程。每个 CPU 都能够在任何给定时间运行一个线程。这意味着如果您的 Java 应用程序是多线程的,那么每个 CPU 一个线程可能会在您的 Java 应用程序中同时(并发)运行。
每个 CPU 都包含一组寄存器,这些寄存器本质上是 CPU 内存。CPU 在这些寄存器上执行操作的速度比它在主存储器中对变量执行的速度要快得多。这是因为 CPU 访问这些寄存器的速度比访问主存储器的速度要快得多。(可以阅读王爽的《汇编语言》这本书了解寄存器的作用)
大多数现代 CPU 都有一定大小的高速缓存。CPU 可以比主存储器更快地访问其高速缓存,但通常不如访问其内部寄存器那么快。因此,CPU 高速缓存的速度介于内部寄存器和主存储器的速度之间,一些 CPU 可能有多个高速缓存(L1 、L2、、L3)。
计算机的主存储区 (RAM),其实就是我们经常看到的电脑配置内存是4G、8G、16G 的那个内存。所有 CPU 都可以访问主存。主存储器的容量比 CPU 的高速缓存大得多的多。
通常,当 CPU 需要访问主存时,它会将部分主存中的数据读入其 CPU 高速缓存。它甚至可以将部分高速缓存中的数据读入其内部寄存器,然后对其执行操作。当 CPU 需要将结果写回主存时,它会将值从其内部寄存器刷新到高速缓存,并在某个时候将值刷新回主存。
当 CPU 需要在高速缓存中存储内容、但高速缓存剩余容量不足时,就会将存储在高速缓存中的值刷新回主内存。CPU 高速缓存可以每次将部分数据刷新回主内存,而不必在每次更新数据时将全部数据都刷新回主存储器。
『Java 内存模型』和『硬件内存架构』之间的差距
如前所述,『Java 内存模型』和『硬件内存架构』是不同的。『硬件内存架构』不区分线程堆栈和堆。在硬件上,线程栈和堆都位于主存中。部分线程堆栈和堆有时可能存在于 CPU 缓存和内部 CPU 寄存器中。如下图所示:
当对象和变量可以存储在计算机的各种不同内存区域中时,可能会出现某些问题。两个主要问题是:
- 线程更新(写入)共享变量时的可见性问题。
- 读取、检查和写入共享变量时的竞争条件。
共享对象的可见性
如果两个或多个线程共享一个对象,而没有正确使用 volatile
声明或同步,则一个线程对共享对象的更新可能对其他线程不可见。
想象一下,某共享对象最初存储在主内存中。然后在 CPU 上运行的线程将共享对象读入其 CPU 高速缓存。它在那里对共享对象进行了更改。只要 CPU 高速缓存没有被刷新回主内存,共享对象的更改版本对运行在其他 CPU 上的线程是不可见的。这样,每个线程最终可能会拥有自己的共享对象副本,每个副本位于不同的 CPU 缓存中。
下图说明了大概的情况。左侧 CPU 上运行的一个线程将共享对象复制到其 CPU 缓存中,并将其 count
变量更改为 2。此更改对右侧 CPU 上运行的其他线程不可见,因为 count
值尚未更新刷新回主内存.
要解决这个问题,您可以使用 Java 的 volatile 关键字。该volatile
关键字可以确保给定变量直接从主存中读取,并在更新时始终写回主存。
竞争条件
如果两个或多个线程共享一个对象,并且多个线程更新该共享对象中的变量, 则可能会出现竞争条件。
想象一种场景,线程 A 将 count
这个共享变量读入它的 CPU 缓存中,线程 B 也做了同样的事情,但进入不同的 CPU 缓存。现在线程 A 将 count
值加1,线程 B 也这样做。现在
值已经被增加了两次,如果这些增量是按顺序执行的,则变量count
count
将增加两次,将原始值加 2 然后写回主存储器。
然而,这两次对
值的增加是在没有同步的情况下同时进行的。无论线程 A 和 B 中的哪个线程将其更新版本的 count
count
值写回主内存,更新后的值只会比原始值大 1,尽管程序中是有两次对 count
值加1。
要解决这个问题,可以使用 Java 同步块。同步块保证了在任何给定时间里只有一个线程可以进入代码中的指定部分。同步块还保证在同步块内访问的所有变量都会从主存中读入,并且当线程退出同步块时,所有更新的变量都会再次刷新回主存,无论该变量是声明为 volatile 还是不是。