Java并发编程之内存模型
- Java内存模型
- 硬件内存结构
- Java内存模型和硬件内存体系结构之间的差距
- 共享对象的可见性
- 竞争条件
Java内存模型指定Java虚拟机如何使用计算机的内存(RAM)。Java虚拟机是整个计算机的模型,因此这个模型自然包括一个内存模型——又名Java内存模型。
如果你想设计正确的并发程序,那么理解Java内存模型是非常重要的。Java内存模型指定了不同的线程如何以及何时可以看到其他线程写入共享变量的值,以及在必要时如何同步对共享变量的访问。
Java内存模型
JVM内部使用的Java内存模型在线程堆栈和堆之间划分内存。
由上图可知,在Java虚拟机中运行的每个线程都有自己的线程堆栈。线程堆栈包含关于线程调用了哪些方法以到达当前执行点的信息。我将把它称为“调用堆栈”。当线程执行其代码时,调用堆栈会发生变化。
线程堆栈还包含正在执行的每个方法的所有本地变量(调用堆栈上的所有方法)。一个线程只能访问它自己的线程堆栈。一个线程创建的局部变量对除创建它的线程之外的所有其他线程都是不可见的。即使两个线程执行完全相同的代码,这两个线程仍然会在各自的线程堆栈中创建该代码的局部变量。因此,每个线程都有每个局部变量的自己版本。
所有基本类型的局部变量(布尔型、字节型、short型、char型、int型、long型、float型、double型)都完全存储在线程堆栈中,因此其他线程不可见。一个线程可以将pritimive变量的副本传递给另一个线程,但是它不能共享原语局部变量本身。
堆包含Java应用程序中创建的所有对象,而与创建该对象的线程无关。这包括基本类型的对象版本(例如字节、整数、Long等)。无论对象是创建并分配给局部变量,还是作为另一个对象的成员变量创建,对象仍然存储在堆中。
上图说明了线程栈和堆中所存储的对象。
局部变量可以是基本类型,在这种情况下,它完全保存在线程堆栈中。
局部变量也可以是对对象的引用。在这种情况下,引用(本地变量)存储在线程堆栈中,但是对象本身存储在堆中。
对象可以包含方法,而这些方法可以包含局部变量。即使方法所属的对象存储在堆中,这些本地变量也存储在线程堆栈中。
对象的成员变量与对象本身一起存储在堆中。不管成员变量是原始类型还是对对象的引用时都是存储在堆中。
静态类变量也与类定义一起存储在堆中。
堆上的对象可以被引用该对象的所有线程访问。当线程访问对象时,它也可以访问该对象的成员变量。如果两个线程同时调用同一个对象上的一个方法,它们都可以访问对象的成员变量,但是每个线程都有自己的本地变量副本。
两个线程有一组局部变量。一个本地变量(本地变量2)指向堆上的一个共享对象(对象3)。这两个线程对同一个对象有不同的引用。它们的引用是本地变量,因此存储在每个线程的线程堆栈中(在每个线程上)。不过,这两个不同的引用指向堆上的同一对象。
硬件内存结构
现代硬件内存架构与内部Java内存模型有些不同。理解硬件内存架构也很重要,理解Java内存模型是如何使用它的。
现代计算机通常有2个或更多的cpu。其中一些cpu可能也有多个核。关键是,在具有2个或更多cpu的现代计算机上,可以同时运行多个线程。每个CPU都能够在任何给定时间运行一个线程。这意味着,如果Java应用程序是多线程的,那么在Java应用程序中,每个CPU可能同时(并发)运行一个线程。
每个CPU包含一组寄存器,这些寄存器本质上是CPU内存。CPU在这些寄存器上执行操作的速度比在主存中的变量上执行操作的速度快得多。这是因为CPU访问这些寄存器的速度要比访问主存快得多。
每个CPU也可以有一个CPU缓存内存区。事实上,大多数现代cpu都有一定大小的缓存内存区。CPU访问缓存比访问主存快得多,但通常不如访问内部寄存器快。所以,CPU缓存的速度介于内部寄存器和主存之间。一些cpu可能有多个缓存区(级别1和级别2),但这对于理解Java内存模型如何与内存交互并不重要。重要的是要知道cpu可以有某种类型的缓存内存区。
计算机还包含一个主存储器区(RAM)。所有的cpu都可以访问主存。主内存区域通常比cpu的缓存内存大得多。
通常,当CPU需要访问主存时,它会将主存的一部分读入CPU缓存。它甚至可以读缓存的一部分到它的内部寄存器,然后执行操作。当CPU需要将结果写回主存时,它会将值从内部寄存器刷新到缓存内存,并在某个时候将值刷新回主存。
当CPU需要将其他内容存储在高速缓存中时,通常会将高速缓存中存储的值刷新回主存储器。 CPU高速缓存可以一次将数据写入其部分内存,并一次刷新其部分内存。 它不必每次更新都读取/写入完整的缓存。 通常,缓存在称为“缓存行”的较小存储块中更新。 可以将一个或多个高速缓存行读入高速缓存存储器,并且可以将一个或多个高速缓存行再次刷新回主存储器。
Java内存模型和硬件内存体系结构之间的差距
如前所述,Java内存模型和硬件内存体系结构是不同的。硬件内存体系结构不区分线程堆栈和堆。在硬件上,线程堆栈和堆都位于主存中。线程堆栈和堆的一部分有时会出现在CPU缓存和CPU内部寄存器中。
当对象和变量可以存储在计算机的不同内存区域时,可能会发生某些问题。两个主要问题是:1.线程更新(写)到共享变量的可见性。2.读取、检查和写入共享变量时的竞态条件。
共享对象的可见性
如果两个或多个线程共享一个对象,而没有正确使用volatile声明或同步,则一个线程对共享对象的更新可能对其他线程不可见。
想象一下,共享对象最初存储在主存储器中。 然后,在CPU一个上运行的线程将共享对象读入其CPU缓存中。 在那里,它更改了共享库。 只要未将CPU高速缓存刷新回主内存,就可以在其他CPU上运行的线程看不到共享对象的更改版本。 这样,每个线程都可以拥有自己的共享库副本,每个副本位于不同的CPU缓存中。
图表说明了大致的情况。左侧CPU上运行的一个线程将共享对象复制到其CPU缓存中,并将其count变量更改为2。这个更改对于在正确的CPU上运行的其他线程是不可见的,因为count的更新还没有被刷新回主存。
解决上面的问题可以使用volatile,volatile关键字可以确保直接从主存中读取给定的变量,并在更新时始终将其写回主存.
竞争条件
如果两个或多个线程共享一个对象,并且有多个线程更新该共享对象中的变量,则可能会发生竞争条件。
想象一下,如果线程A将共享对象的变量计数读取到它的CPU缓存中。再想象一下,线程B也做同样的事情,但是进入了不同的CPU缓存。现在,线程A添加1来计数,线程B也做同样的事情。现在var1增加了两次,在每个CPU缓存中增加一次。
如果这些增量是按顺序执行的,变量计数将增加两次,并将原始值+ 2写回主存。
但是,这两个增量是在没有适当同步的情况下并发执行的。不管线程A和线程B中哪一个将count的更新版本写回主存,更新的值只比原始值高1,尽管有两次增量。
您可以使用Java同步块解决竞争关系。 同步块可确保在任何给定时间只有一个线程可以输入代码的给定关键部分。 同步块还保证将从同步块中读取的所有变量都从主存储器中读取,并且当线程退出同步块时,所有更新的变量将再次刷新回主存储器,而不管该变量是否声明volatile