java内存划分

JMM规定了内存主要划分为主内存和工作内存两种。此处的主内存和工作内存跟JVM内存划分(堆、栈、方法区)是在不同的层次上进行的,如果非要对应起来,主内存对应的是Java堆中的对象实例部分,工作内存对应的是栈中的部分区域,从更底层的来说,主内存对应的是硬件的物理内存,工作内存对应的是寄存器和高速缓存。具体如图所示:

image

正常情况下,java中所有的数据都是放在主存中的,如图所示:

image

随着 CPU 技术的发展,CPU的执行速度越来越快,但内存的技术并没有太大的变化,如果JAVA线程每次读取和写入变量都直接操作主内存,对性能影响比较大,所以每条线程拥有各自的工作内存,工作内存中的变量是主内存中的一份拷贝,线程对变量的读取和写入,直接在工作内存中操作,而不能直接去操作主内存中的变量。

因此,为了“压榨”处理性能,达到“高并发”的效果,在CPU中添加了高速缓存(cache)来作为缓冲。

image

但是这样就会出现一个问题,当一个线程修改了自己工作内存中变量,对其他线程是不可见的,会导致线程不安全的问题。也就是缓存一致性的问题。

缓存一致性

现在市面上的手机通常有两个或者多个CPU,其中一些CPU还有多核。每个CPU在某一时刻都能运行一个线程,这就意味着,如果你的Java程序是多线程的,那么就有可能存在多个线程在同一时刻被不同的CPU执行的情况。

指令重排

除了缓存一致性问题,还存在另外一种硬件问题,也比较重要:为了使CPU内部的运算单元能够尽量被充分利用,处理器可能会对输入的字节码指令进行重排序处理,也就是处理器优化。除了CPU之外,很多编程语言的编译器也会有类似的优化,比如Java虚拟机的即时编译器(JIT)也会做指令重排。

比如如下代码:

1
2
3
int a = 1;
int b = 1;
a = a + 1;

经过CPU指令重排后,可能会变成如下代码:

1
2
3
int a = 1;
a = a + 1;
int b = 1;

如果我们任由 CPU 优化或者编译器指令重排,那我们编写的Java代码最终执行效果可能会极大的出乎意料。为了解决这个问题,让Java代码在不同硬件、不同操作系统中,输出的结果达到一致,Java 虚拟机规范提出了一套机制——Java 内存模型。

什么是java内存模型

JMM (Java Memory Model)是Java内存模型,JMM定义了程序中各个共享变量的访问规则,即在虚拟机中将变量存储到内存和从内存读取变量这样的底层细节。解决了CPU多级缓存、CPU 优化、指令重排等导致的内存访问问题,从而保证Java程序(尤其是多线程程序)在各种平台下对内存的访问效果一致。

内存交互操作

内存交互操作有8种,虚拟机实现必须保证每一个操作都是原子的,不可在分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台上允许例外)

  • lock (锁定):作用于主内存的变量,把一个变量标识为线程独占状态
  • unlock (解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  • read (读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load (载入):作用于工作内存的变量,它把read操作从主存中变量放入工作内存中
  • use (使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令
  • assign (赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中
  • store (存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用
  • write (写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中

JMM对这八种指令的使用,制定了如下规则:

  • 不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write
  • 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存
  • 不允许一个线程将没有assign的数据从工作内存同步回主内存
  • 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是怼变量实施use、store操作之前,必须经过assign和load操作
  • 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁
  • 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值
  • 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量
  • 对一个变量进行unlock操作之前,必须把此变量同步回主内存

Happens-before 先行发生原则

先行发生原则用于描述2个操作之间的内存可见性。意思就是当A操作先行发生于B操作,则在发生B操作的时候,操作A产生的影响能被B观察到,“影响”包括修改了内存中的共享变量的值、发送了消息、调用了方法等。

JMM 中定义了以下几种情况是自动符合 happens-before 规则的:

  • 程序次序规则(Program Order Rule):在一个线程内,程序的执行规则跟程序的书写规则是一致的,从上往下执行。
  • 锁定规则(Monitor Lock Rule):无论是在单线程环境还是多线程环境,一个锁如果处于被锁定状态,那么必须先执行 unlock 操作后才能进行 lock 操作。
  • volatile变量规则(volatile Variable Rule):对同一个volatile的变量,先行发生的写操作,肯定早于后续发生的读操作
  • 线程启动规则(Thread Start Rule):Thread 对象的 start() 方法先行发生于此线程的每一个动作。假定线程 A 在执行过程中,通过执行 ThreadB.start() 来启动线程 B,那么线程 A 对共享变量的修改在线程 B 开始执行后确保对线程 B 可见。
  • 线程中止规则(Thread Termination Rule):Thread对象的中止检测(如:Thread.join(),Thread.isAlive()等)操作,必行晚于线程中所有操作
  • 线程中断规则(Thread Interruption Rule):对线程的interruption()调用,先于被调用的线程检测中断事件(Thread.interrupted())的发生
  • 对象中止规则(Finalizer Rule):一个对象的初始化方法先于一个方法执行Finalizer()方法
  • 传递性(Transitivity):如果操作A先于操作B、操作B先于操作C,则操作A先于操作C

总结

  • Java 内存模型的来源:主要是因为CPU缓存和指令重排等优化会造成多线程程序结果不可控。

  • Java 内存模型是什么:本质上它就是一套规范,在这套规范中有一条最重要的 happens-before 原则。

参考

拉勾教育 - Android 工程师进阶34讲