内存模型
-
主内存、工作内存与Java堆、栈、方法区并不是同一个层次的内存划分
-
勉强对应起来
-
从定义来看,主内存对应Java堆中对象实例数据部分,工作内存对应虚拟机栈中部分区域
-
从更低层次来说,主内存就是硬件的内存,工作内存对应寄存器和高速缓存
-
内存交互操作
Java内存模型定义了八种内存交互操作
-
主内存操作
-
lock(锁定):把一个变量标识为线程独占状态
-
unlock(解锁):把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
-
read(读取):把主内存变量的值读取到工作内存中,以便随后的load使用
-
write(写入):把store操作从工作内存中得到的变量值写到主内存的变量中
-
-
工作内存操作
-
load(载入):把read操作从主内存中得到的变量值放入工作内存的变量副本中
-
use(使用):把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作
-
assign(赋值):把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
-
store(存储):把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作
-
内存交互规则
-
一个变量同一时刻只允许一个线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次lock之后必须要执行相同次数的unlock操作,变量才会解锁
-
对一个对象进行lock操作,会清空工作内存中变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值(保证了变量的可见性)
-
如果一个变量事先没有被lock,就不允许对它进行unlock操作,也不允许去unlock一个被其他线程锁住的变量
-
对一个变量执行unlock操作之前,必须将此变量同步回主内存中(执行store、write)
-
一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即对一个变量实施use和store操作之前,必须先执行过了load和assign操作
-
不允许read和load、store和write操作之一单独出现,即不允许加载或同步工作到一半
-
不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后,必须同步回主内存
-
不允许一个线程无原因地(无assign操作)把数据从工作内存同步到主内存中
三大核心问题
可见性
一个线程对共享变量的修改,另外一个线程能够立刻看到,称为可见性
为什么存在可见性问题?
由Java的内存模型决定的,工作内存中的变量副本不能及时刷新到主内存中
1 public class Test { 2 public int a = 0; 3 4 public void increase() { 5 a++; 6 } 7 8 public static void main(String[] args) { 9 final Test test = new Test(); 10 for (int i = 0; i < 10; i++) { 11 new Thread() { 12 public void run() { 13 for (int j = 0; j < 1000; j++) 14 test.increase(); 15 }; 16 }.start(); 17 } 18 19 while (Thread.activeCount() > 1) { 20 // 保证前面的线程都执行完 21 Thread.yield(); 22 } 23 System.out.println(test.a); 24 } 25 }
解决方案
JSR-133内存模型使用happens-before的概念来阐述操作之间的内存可见性。具体参考happens-before章节
原子性
把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。反应在Java并发编程中,即一段代码或者一个变量的操作,在一个线程没有执行完之前,不能被其他线程执行
为什么会有原子性问题?
多线程场景下,由于时间片在线程间轮换,对于同一个变量的操作,一个线程还没操作完但是时间片耗尽,在等待CPU分配时间片时,其他线程可以获取时间片来操作这个变量,导致多个线程同时操作同一个变量,这就是原子性问题
i = 0; // 原子性操作 j = i; // 不是原子性操作,包含了两个操作:读取i,将i值赋值给j i++; // 不是原子性操作,包含了三个操作:读取i值、i + 1 、将+1结果赋值给i i = j + 1; // 不是原子性操作,包含了三个操作:读取j值、j + 1 、将+1结果赋值给i
解决方案
-
atomic
-
synchronized
-
Lock
有序性
有序性:程序执行的顺序按照代码的先后顺序执行
为什么会有有序性问题
为了提高性能,编译器和处理器经常会对指令进行重排序
解决方案
参见重排序章节
happens-before
JVM会对代码进行编译优化,指令会出现重排序的情况,为了避免编译优化对并发编程安全性的影响,需要happens-before规则定义一些禁止编译优化的场景,保证并发编程的正确性。
《JSR-133:Java Memory Model and Thread Specification》对happens-before关系的定义如下:
-
如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作==可见==,而且第一个操作的执行顺序排在第二个操作之前。
-
两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序,遵循as-if-serial语义)
JDK1.5引入Happens-Before原则,只要遵循以下规则就可以达到可见性:
-
程序次序规则(Program Order Rule):在一个线程内,书写在前面的操作先行发生于后面的操作。准确的说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支和循环等结构。如下代码示例:1 happens-before 2、3 happens-before 4
public class VolatileRule { private volatile boolean flag = false; private int a = 0; public void write() { a = 1; // 1 flag = true; //2 } public void read() { if (flag) { // 3 int i = a; // 4 } } }
注意:由于遵循as-if-serial语义,如果两者之间不存在数据依赖,编译优化后实际顺序可能会调换位置,这时候前后关系会发生变化。因此,程序次序规则中前后操作应该看编译后实际位置。上述代码,需要实现1 happens-before 2,对flag用volatile进行了修饰,通过在前面加入了StoreStore屏障,禁止2之前的写操作与其进行重排序
问题:假设线程A执行write()方法,按照volatile会将flag=true写入内存;线程B执行read()方法,按照volatile,线程B会从内存中读取变量a,如果线程B读取到的变量flag为true,那么,此时4中变量a的值是多少呢??
-
volatile 变量规则(Volatile Lock Rule):对于volatile修饰的变量的写的操作,一定happen-before后续对于volatile变量的读操作。如上次序规则示例中,因为变量flag加了volatile修饰,所以2 happens-before 3
-
传递性规则(Transitivity Rule):如果A happens-before B,且B happens-before C,那么A happens-before C
结合【程序次序规则】、【volatile 变量规则】和【传递性规则】再来看【VolatileRule】程序,可以得出如下结论
a = 1 Happens-Before 写变量flag = true,符合【程序次序规则】
写变量程序次序规则flag = true Happens-Before 读变量程序次序规则flag = true,符合【volatile 变量规则】
再根据【传递性规则】,可以得出结论:a = 1 Happens-Before 读变量flag=true
也就是说,如果线程B读取到了flag=true,那么,线程A设置的a=1对线程B就是可见的,即线程B能够访问到a=1
-
监视器锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是必须为同一个锁,而“后面”是指的时间上的先后顺序。如下示例,假如线程A先进去执行过一次a++,然后释放锁,然后线程B再进入同步代码块,那么B获得的x为1
public class LockRule { private static int a = 0; public void increase() { synchronized (this) { // 两个线程同时访问,相互修改的值均对对方可见 a++; } System.out.println(a); } }
-
线程启动规则(Thread Start Rule):Thread对象的start()方法,先行发生行于此线程的每一个动作。如下示例中,因为1 happens-before 2,而2又happens-before线程1内的所有操作,所以a的值对线程t1是可见的
public class StartRule { private static volatile int a = 0; public static void main(String[] args) { Thread t1 = new Thread(() -> { // 主线程修改的值对t1可见 System.out.println(a); }); a = 10; // 1 t1.start(); // 2 } }
-
线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用,先行发生于检测到中断事件的发生,我们可以通过Thread.interrupted()、Thread.currentThread().isInterrupted()方法检测到是否有中断发生,区别在于前者会清除中断标识
public class InterruptRule { public void increase() { Thread t = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { // 检测线程是否中断 System.out.println("逻辑处理完成"); try { Thread.sleep(100); } catch (InterruptedException e) { System.out.println("没有按照预期正常结束"); Thread.currentThread().interrupt(); // sleep()、wait()等中断异常抛出之前会清除线程中断标识,因此需要手动再次中断while循环才可以检测到中断状态 } } }); t.start(); Thread.sleep(1000); t.interrupt(); } }
-
线程终结规则(Thread Termination Rule):线程中所有操作happens-before于对此线程的终止检测,我们可以通过Thread.join()等手段检测到线程已经终止。如下示例中,因为1 happens-before 2,而2又happens-before 3,所有t1中修改了a的值,对主线程可见
public class JoinRule { private static int a = 0; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { a = 100; // 1 }); a = 10; t1.start(); t1.join(); // 2 当前线程对t1加锁,并调用wait,造成当前线程堵塞 System.out.println(a); // 3 x=100 } }
-
对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法
重排序
重排序概念及问题
为了提高性能,编译器和处理器经常会对指令进行重排序,分成三种类型
-
编译器优化重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
-
指令级并行重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
-
内存系统的重排序:由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
例如
int a=1; int b=2;
编译器优化后可能如下
int b=2; int a=1;
在这个例子中,重排序不影响程序的最终结果,但有时候可能导致意想不到的Bug,如:经典的双检锁创建单例
public class Singleton { private static Singleton instance; public static Singleton getInstance(){ if (instance == null) { synchronized(Singleton.class) { if (instance == null) instance = new Singleton(); } } return instance; } }
多线程的情况下可能触发空指针异常,分析如下:
instance = new Singleton(); 创建对象的代码,可分解为三步: 1. 分配内存空间 2. 初始化对象Singleton 3. 将内存空间的地址赋值给instance 但是这三步经过重排之后可能为: 1、分配内存空间 2、将内存空间的地址赋值给instance 3、初始化对象Singleton 当线程A执行完第2步时,线程切换到线程B执行,第一次判断会发现instance!=null,直接返回instance。由于instance未初始化,访问其成员变量或方法可能触发空指针异常
重排序规则
-
重排序遵守as-if-serial 语义:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变
-
同一个线程中,存在数据依赖关系的两个操作,不可以重排序(实际上和as-if-serial一回事,一种具体的阐述)
// 写后读:写一个变量之后,再读这个位置 int a = 1; int b = a; // 写后写:写一个变量之后,再写这个变量 int a = 1; int a = 2; // 读后写:读一个变量之后,再写这个变量 int a = b; int b = 1;
JMM解决方案
-
对于编译器,JMM(Java内存模型)的编译器重排序规则会禁止特定类型的编译器重排序(happens-before)
-
对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,来禁止特定类型的处理器重排序
-
JMM根据代码中的关键字(如:synchronized、volatile)和J.U.C(java.util.concurrent)包下的一些具体类来插入内存屏障
内存屏障
为了保证的有序性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序,如下:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1 LoadLoad Load2 | 确保Load1数据的装载,之前于Load2及所有后续装载指令的装载 |
StoreStore | Store1 StoreStore Store2 | 确保Store1的数据可见(刷新到内存),之前于Store2及所有后续存储指令的存储 |
LoadStore | Load1 LoadStore Store2 | 确保Load1数据的装载,之前于Store2及所有后续存储指令刷新到内存 |
StoreLoad | Store1 StoreLoad Load2 | 确保Store1数据对其他处理器变得可见,之前于Load2及所有后续装载指令的装载 |
store:数据对其他处理器可见,即刷新到内存中
load:让缓存中的数据失效,重新从主内存加载数据
CAS
Compare And Swap,即比较并交换,比较交换的过程 CAS(V,A,B):
-
V-一个内存地址存放的实际值、A-旧的预期值、B-即将更新的值
-
当且仅当预期值 A 和内存值 V 相同时,将内存值修改为 B 并返回 true,否则什么都不做,并返回 false
-
CAS的核心类是Unsafe,根据内存偏移地址获取数据的原值,通过lock前缀指令确保比较替换操作的原子性(缓存加锁,只保证在同一时刻对某个内存地址的操作是原子性的即可)
-
在 java 1.5 后的 atomic 包中提供了 AtomicStampedReference 来解决 ABA 问题
Unsafe
Unsafe类使Java拥有了像C语言的指针一样操作内存空间的能力,一旦能够直接操作内存,这也就意味着
-
不受jvm管理,也就意味着无法被GC,需要我们手动GC,稍有不慎就会出现内存泄漏
-
Unsafe的不少方法中必须提供原始地址(内存地址)和被替换对象的地址,偏移量要自己计算,一旦出现问题就是JVM崩溃级别的异常,会导致整个JVM实例崩溃,表现为应用程序直接crash掉
-
直接操作内存,也意味着其速度更快,在高并发的条件之下能够很好地提高效率
方法归类:
-
初始化操作
-
通过getUnsafe方法实现的单例模式
-
只能通过类加载器BootStrap classLoader加载,否则抛出SecurityException
-
-
操作对象属性
-
操作数组元素
-
内存管理
-
线程挂起和恢复
-
内存屏障
-
CAS机制
参考:Unsafe类详解