菜鸟修行之路----java多线程与并发:java内存模型
1.JMM概述
java内存模型(Java Memory Model,JMM)是java虚拟机规范定义的,用来屏蔽掉java程序在各种不同的硬件和操作系统对内存的访问的差异,这样就可以实现java程序在各种不同的平台上都能达到内存访问的一致性。
总的来说:
JMM的核心功能:解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码重排序、处理器会对代码乱序执行等带来的问题。
JMM并不是真实存在的,它是个Java虚拟机所定义的一组抽象规则(规范),通过该规则定义了程序中各个变量(实例字段,静态字段,构成数组对象的元素)的访问方式,用于保证多个线程可以有效地、正确地协同工作
具体访问方式如下图所示:
1.1 主内存与工作内存
注:这里的主内存、工作内存与JVM内存区域中的堆、栈、方法区不是同一层次内存划分,这两者基本上没有关系,不要混淆。
- 主内存(Main Memory):共享、类信息、常量、静态变量。
- 工作内存(Working Memory):存主内存中数据的副本
1.2 内存间的交互操作
操作 | 作用对象 | 功能 |
---|---|---|
lock(锁定) | 主内存的变量 | 一个变量在同一时间只能一个线程锁定,该操作表示这条线程独占这个变量 |
unlock(解锁) | 主内存的变量 | 表示这个变量的状态由处于锁定状态被释放,这样其他线程才能对该变量进行锁定 |
read(读取) | 主内存变量 | 表示把一个主内存变量的值传输到线程的工作内存,以便随后的load操作使用 |
load(载入) | 线程的工作内存的变量 | 表示把read操作从主内存中读取的变量的值放到工作内存的变量副本中(副本是相对于主内存的变量而言的)(可以理解为拷贝) |
use(使用) | 线程的工作内存中的变量 | 表示把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行该操作 |
assign(赋值) | 线程的工作内存的变量 | 表示把执行引擎返回的结果赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就会执行该操作 |
store(存储) | 线程的工作内存中的变量 | 工作内存中的一个变量的值传递给主内存,以便随后的write操作使用 |
write(写入) | 主内存的变量 | 把store操作从工作内存中得到的变量的值放入主内存的变量中 |
1.2.1操作规则
- 这8个操作具有原子性(操作不可中断,一旦开始执行,就会执行完成,不会被其他线程打断)。
- 不允许read和load、store和write操作之一单独出现,也就是不允许从主内存读取了变量的值但是工作内存不接收的情况,或者不允许从工作内存将变量的值回写到主内存但是主内存不接收的情况
- 不允许一个线程丢弃最近的assign操作,也就是不允许线程在自己的工作线程中修改了变量的值却不同步/回写到主内存
- 不允许一个线程回写没有修改的变量到主内存,也就是如果线程工作内存中变量没有发生过任何assign操作,是不允许将该变量的值回写到主内存
- 变量只能在主内存中产生,不允许在工作内存中直接使用一个未被初始化的变量,也就是没有执行load或者assign操作。也就是说在执行use、store之前必须对相同的变量执行了load、assign操作
- 一个变量在同一时刻只能被一个线程对其进行lock操作,也就是说一个线程一旦对一个变量加锁后,在该线程没有释放掉锁之前,其他线程是不能对其加锁的,但是同一个线程对一个变量加锁后,可以继续加锁,同时在释放锁的时候释放锁次数必须和加锁次数相同。
- 对变量执行lock操作,就会清空工作空间该变量的值,执行引擎使用这个变量之前,需要重新load或者assign操作初始化变量的值
- 不允许对没有lock的变量执行unlock操作,如果一个变量没有被lock操作,那也不能对其执行unlock操作,当然一个线程也不能对被其他线程lock的变量执行unlock操作
- 对一个变量执行unlock之前,必须先把变量同步回主内存中,也就是执行store和write操作
1.2.2 特殊规则
1.2.2.1volatile修饰的变量的特殊规则:
关键字volatile是java虚拟机中提供的最轻量级的同步机制。java内存模型对volatile专门定义了一些特殊的访问规则。
所以对于volatile修饰的变量,在进行read、load、use、assign、store和write操作的时候需要满足如下规则:
-
valatile类型的变量保证对所有线程的可见性
可见性:指当一个线程修改了这个变量的值,修改后的值对于其他线程来说是立即可以得知的。
volatile类型的变量每次值被修改了就立即同步回主内存,每次使用时就需要从主内存重新读取值。
注意:volatile的规则,保证了read、load、use的顺序和连续行,同理assign、store、write也是顺序和连续的。这几个动作是原子性的,但是对变量的修改,或者对变量的运算,却不能保证是原子性的。如果对变量的修改是分为多个步骤的,那么多个线程同时从主内存拿到的值是最新的,但是经过多步运算后回写到主内存的值是有可能存在覆盖情况发生的。示例:
public class VolatileTest {
public static volatile int race = 0;
public static void increase() {
race++;
}
private static final int THREADS_COUNT = 20;
public static void main(String[] args) {
//基于volatile类型的变量创建了20个线程
Thread[] threads = new Thread[THREADS_COUNT];
for (int i= 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(new Runnable(){
@Override
public void run() {
//每个线程对变量执行1w次加1操作
for (int j = 0; j < 10000; j++) {
increase();
}
}
});
threads[i].start();
}
while (Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(race);
}
}
程序结果:rece小于20w
分析:race++
操作不是原子性的,是分多个步骤完成的。
总结:虽然valatile类型的变量保证对所有线程的可见性,但是基于volatile变量的运算在并发下不是线程安全的。
- volatile变量禁止指令重排序优化
在某些情况下,volatile的同步机制性能要优于锁(使用synchronized关键字或者java.util.concurrent包中的锁)。
volatile变量的读操作和普通变量的读操作几乎没有差异,但是写操作会性能差一些,慢一些,因为要在本地代码中插入许多内存屏障指令来禁止指令重排序,保证处理器不发生代码乱序执行行为。
1.2.2.2 long和double型变量的特殊规则
long和double是64位的数据类型,如果没有被volatile修饰,每次操作划分为2次32位进行操作,原子性对于这个不起作用,如果多个线程操作这样的数据,会出现数据错误。
1.3 重排序
在执行程序时,为了提高性能,编译器和处理器常常会对指令作优化。如下面这种情况:
重排序是指代码指令不严格按照代码语言顺序执行的。
重排序示例:
/**
* 演示重排序的现象
*/
public class OutOfOrderExecution {
private static int x = 0,y = 0;
private static int a = 0,b = 0;
private static int c;
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(1);
for (;;){
c ++;
a = 0;
b = 0;
x = 0;
y = 0;
Thread one = new Thread(new Runnable() {
@Override
public void run() {
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
a = 1;
x = b;
}
});
Thread two = new Thread(new Runnable() {
@Override
public void run() {
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
b = 1;
y = a;
}
});
one.start();
two.start();
latch.countDown();
one.join();
two.join();
System.out.println("x = " + x + ",y = " + y);
}
}
}
执行结果,有以下情况:
- (正常情况)线程1先执行完,线程2再开始执行,那么输出结果为x = 1,y = 0
- (正常情况) 线程2先执行完,线程1再开始执行,那么输出结果为x = 0,y = 1
- (正常情况)线程1执行a = 1后被调度掉线程2执行,对b = 1赋值,然后输出结果是x = 1,y = 1
- (重排序情况)x= b,y = a重排序到a = 1,b = 1前执行,执行顺序为(y = a,a = 1,x = b,b=1)那么输出结果是x = 0,y = 0
重排序分类:
- 编译器优化:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令重排序:CPU的优化行为,通过乱序执行,提高执行效率
- 内存重排序:主内存与工作内存不一致会导致程序出现乱序行为。
指令重排序的限制条件:
- 在单线程环境下不能改变程序运行的结果;
- 存在数据依赖关系的不允许重排序
2.并发三大特性
- 原子性
一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。
synchronized具有原子性
基础数据类型的访问与读写都是具备原子性的。
-
可见性
可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
volatile、synchronized、final具有可见性
-
有序性
对于任何线程,操作都是有序的
有序性是指对于单线程环境下代码执行的最终结果和按顺序依次执行的结果一致。但对于多线程环境,则可能出现乱序现象。因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致。
java语言中对于有序性的描述:
如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的
解析:前半句是指“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。
3.Happens-Before原则
happens-before规则用于定义一些禁止编译优化的场景,保证并发编程的正确性。
具有以下规则:
- 程序顺序原则:在一个线程内一段代码的执行结果是有序的。虽然还会指令重排,但是随便它怎么排,结果是按照我们代码的顺序生成的不会变。
- 锁规则:无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果。
- volatile变量规则:如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见。
- 线程启动规则:在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。
- 线程结束规则:在主线程A执行过程中,子线程B终止,那么线程B在终止之前对共享变量的修改结果在线程A中可见。
- 中断规则:线程interrupt()方法的调用比检测线程中断的事件发生的早,可以通过Thread.interrupted()检测到是否发生中断。
- 终结器规则:一个对象的初始化的完成,也就是构造函数执行的结束一定早于happens-before它的finalize()方法。
- 传递性:A happens-before B , B happens-before C,那么A happens-before C。
特殊说明:final关键字
final在Java中是一个保留的关键字,可以声明成员变量、方法、类以及本地变量。 被final修饰的变量不能被修改,方法不能被重写,类不能被继承。
对于final,编译器和处理器要遵守两个重排序规则:写规则和读规则
4.总结
-
JMM就是一组规则,解决在并发编程可能出现的线程安全问题,提供了内置解决方案(happen-before原则)及其外部可使用的同步手段(synchronized/volatile等),确保了程序在多线程并发执行中的原子性、可见性、有序性。
-
重排序是多核CPU等为了性能进行的优化操作,但会导致可见性等问题。为了解决这些问题,所以JMM需要制定 一些规则,不让其随意重排序。
-
as-if-serial只保证单线程环境的不可随意重排序。
-
多线程下用,happens-before是JMM制定的最终目的,内存屏障则是实现happens-before的具体手段。
修行之路艰辛,与君共勉
----2020年3月 成都