JMM: Java 内存模型
为什么有内存模型?
CPU 缓存一致性问题
谈内存模型,还得说说CPU缓存一致性问题。
现在的CPU都是有多级缓存的,如L1、L2、L3。每一级缓存中所储存的全部数据都是下一级缓存的一部分。
- 当CPU要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。
- 当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中1。
单核CPU:CPU的缓存被线程独占,不会出现访问冲突。
单核CPU、多线程:CPU的缓存被线程轮流独占,也不会出现访问冲突。
多核CPU、多线程:CPU每个核心都有相应的L1缓存,这时每个核心在各自的缓存中保留一份进程中共享内存的一个拷贝。多个线程可以真正并行,因此同一时间多个线程各自读写各自的缓存,造成了缓存之间不一致的问题,即每个核心对同一个数据的缓存内容不一致。
处理器优化与指令重排问题
为了使处理器内部的运算单元能够尽量的被充分利用,处理器可能会对没有数据依赖的输入代码进行乱序执行处理。这就是处理器优化。
现在很多流行的处理器会对代码进行优化乱序处理,很多编程语言的编译器也会有类似的优化,比如Java虚拟机的即时编译器(JIT)也会做指令重排。
可想而知,如果任由处理器优化和编译器对指令重排的话,就可能导致各种各样的问题。
并发编程,为了保证数据的安全,需要满足以下三个特性:
-
原子性是指在一个操作中就是cpu不可以在中途暂停然后再调度,既不被中断操作,要不执行完成,要不就不执行。例如
long
类型的数据在32位机器上的读写。 -
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
-
有序性即程序执行的顺序按照代码的先后顺序执行。但是并发时指令重排可能导致乱序。
缓存一致性问题其实就是可见性问题。而处理器优化是可以导致原子性问题的。指令重排即会导致有序性问题。
Java 内存模型
为了保证并发编程中可以满足原子性、可见性及有序性。有一个重要的概念,那就是——内存模型。
**为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。**通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。他解决了CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。
内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障。
内存模型是解决多线程场景下并发问题的一个重要规范。
那么具体的实现是如何的呢,不同的编程语言,在实现上可能有所不同。
Java程序是需要运行在Java虚拟机上面的,Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。
提到Java内存模型,一般指的是JDK 5 开始使用的新的内存模型。
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。
JMM就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步。
这里面提到的主内存和工作内存,读者可以简单的类比成计算机内存模型中的主存和缓存的概念。特别需要注意的是,主内存和工作内存与JVM内存结构中的Java堆、栈、方法区等并不是同一个层次的内存划分,无法直接类比。《深入理解Java虚拟机》中认为,如果一定要勉强对应起来的话,从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分。工作内存则对应于虚拟机栈中的部分区域。
JMM是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。
JMM 的实现
在Java中提供了一系列和并发处理相关的关键字,比如volatile
、synchronized
、final
、concurrent
包等。其实这些就是Java内存模型封装了底层的实现后提供给程序员使用的一些关键字。
Java内存模型,除了定义了一套规范,还提供了一系列原语,封装了底层实现后,供开发者直接使用。
事实上,Java 内存模型的本质是围绕着 Java 并发过程中的如何处理原子性
、可见性
和有序性
这三个特征来设计的,这三大特性可以直接使用 Java 中提供的关键字实现。
原子性的实现
JMM 保证的原子性变量操作包括 read、load、use、assign、store、write、lock、unlock
除了 JVM 自身提供的对基本数据类型读写操作的原子性外,可以通过 synchronized 和 Lock 来保证方法和代码块内的操作是原子性的。
同步方法通过
ACC_SYNCHRONIZED
关键字隐式的对方法进行加锁。当线程要执行的方法被标注上ACC_SYNCHRONIZED
时,需要先获得锁才能执行该方法。同步代码块通过
monitorenter
和monitorexit
执行来进行加锁。当线程执行到monitorenter
的时候要先获得所锁,才能执行后面的方法。当线程执行到monitorexit
的时候则要释放锁。
可见性的实现
Java中的volatile
关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile
来保证多线程操作时变量的可见性。
除了 volatile,Java 中的 synchronized
和 final
两个关键字也可以实现可见性,只不过实现方式不同。
有序性的实现
在 Java 中,可以使用 synchronized
和 volatile
来保证多线程之间操作的有序性。
实现方式有所区别:volatile
关键字会禁止指令重排。synchronized
关键字保证同一时刻只允许一条线程操作。
锁的内存语义
当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A 对共享变量所做修改的)消息。
当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共 享变量所做修改的)消息。