Java内存模型的简介
Java程序是需要运行在Java虚拟机上面的,Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。
线程之间的通信机制有两种:共享内存和消息传递。Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。JMM决定一个线程对共享变量的写入何时对另一个线程可见。JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。
JMM是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。
Java内存模型图如下:
如果线程A与线程B之间要通信的话,必须经历如下2个步骤:
- 线程A把本地内存A中更新过的共享变量刷新到主内存中;
- 线程B到主内存中去读取线程A已更新过的共享变量。
总结:JMM通过控制主内存与每个线程的本地内存之间的交互,提供内存可见性保证。
线程对共享变量的所有操作都必须在本地内存进行,不能直接从主内存中读写。
不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量值的传递需要通过主内存来完成。
原子性是指在一个操作中就是cpu不可以在中途暂停然后再调度,既不被中断操作,要不执行完成,要不就不执行。
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
有序性即程序执行的顺序按照代码的先后顺序执行。
重排序
在执行程序时,为了提高性能,编译器和处理器会对指令做重排序。主要有3种:
- 编译器优化的重排序: ----编译器
- 指令级并行的重排序; -----处理器
- 内存系统的重排序 ------处理器
从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序,如图:
上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。
数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个是写操作,此时这两个操作之间就存在数据依赖性。
编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,不会改变存在数据依赖关系的两个操作的执行顺序。
这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不考虑。
as-if-serial语义
不管怎么重排序,单线程程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。
重排序对多线程的影响
先来看下下面这段代码:
public class Example {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; // 1
flag = true; // 2
}
public void reader() {
if (flag) { // 3
int i = a * 2; // 4
...
}
}
这里如果有线程A和线程B,线程A执行write操作,线程B执行read操作,由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序; 同样,操作3和操作4没有依赖关系,编译器和处理器也可以对这两个操作重排序。这样线程B在执行操作4时就有可能看不到线程A对共享变量a的写入,如何解决这种问题?
在单线程中,对存在控制依赖的操作重排序,不会改变程序的执行结果;但在多线程中,对存在控制依赖的操作重排序,可能改变程序的执行结果。
可见性分析:
导致共享变量在线程间不可见的原因:
- 线程的交叉执行
- 重排序结合线程交叉执行
- 共享变量更新后的值没有在工作内存与主内存间及时更新
其中1和2可通过synchronized的原子性解决,3通过synchronized的可见性解决。
Synchronized
Synchronized能实现:原子性和可见性
JMM关于的规定:
线程解锁前,必须把共享变量的最新值刷新到主内存中
线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值。(这里加锁与加锁需要是同一把锁)
线程解锁前对共享变量的修改在下次加锁时对其他线程可见。
线程执行互斥代码的过程(实现可见性):
- 获取互斥锁
- 清空工作内存
- 从主内存拷贝变量的最新副本到工作内存
- 执行互斥代码
- 将更改后的共享变量的值刷新到主内存
- 释放互斥锁
保证原子性操作的方法:
- 使用Synchronized关键字
- 使用ReentrantLock(java.util.current.locks包下)
- 使用AutomicInterger(vava.util.concurrent.atomic包下)
public class VolatileTest {
private int number = 0;
public int getNumber(){
return this.number;
}
public void increase(){
this.number++;
}
public static void main(String[] args){
final VolatileTest volatileTest = new VolatileTest();
for(int i=0; i<100; i++){
new Thread(new Runnable() {
@Override
public void run() {
volatileTest.increase();
}
}).start();
}
while(Thread.activeCount()>1){
Thread.yield();
}
System.out.println("number="+volatileTest.getNumber());
}
}
运行多次,可以发现number的值有可能不是100,发生这种情况主要是多线程的交叉执行导致的number自增的原子性破坏。可通过加Synchronized来解决。
synchronized (this){
this.number++;
}
Volatile
一个volatile变量的单个读/写操作,与一个普通变量的读写操作都是使用同一个锁来同步,它们之间的执行效果相同。
锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,也就是说,一个volatile变量的读,总是能看到任意线程对这个volatile变量最后的读入。
Volatile的特性:
可见性:
通过加入内存屏障和禁止重排序优化来实现。
——对volatile变量执行写操作时,会在写操作后加入一条store屏障指令;
——对volatile变量执行读操作时,会在写操作后加入一条load屏障指令;
Volatile变量在每次被线程访问时,都强迫从主内存中重读该变量的值,而当该变量发生变化时,又会强迫线程将最新的值刷新到主内存中。这样任何时刻,不同的线程总能看到该变量的最新值。
线程写volatile变量的过程:
- 改变线程工作内存中volatile变量副本的值
- 将改变后的副本的值从工作内存刷新到主内存中
线程读volatile变量的过程:
- 从主内存中读取volatile变量的最新值到工作内存中
- 从工作内存中读取volatile变量的副本
原子性:对任意单个volatile变量的读写具有原子性,但类似volatile++这种符合操作不具有原子性。
Volatile的使用场合:
- 对变量的写入操作不依赖其当前值
不满足:number++、count= count*2等
满足:boolean变量、记录温度变化的变量等
- 该变量没有包含在具有其他变量的不变式
不满足:不变式low < up
Synchronized与volatile的比较:
Volatile不需要加锁,比synchronized更轻量级,不会阻塞线程;
从内存可见性角度讲,volatile读相当于加锁,写相当于解锁;
Synchronized可以保证可见性和原子性,而volatile只能保证可见性,无法保证原子性。
锁
锁可以让临界区互斥执行,是Java并发变成呢个中最重要的同步机制,还可以让释放锁的线程向获取同一个锁的线程发送消息。
当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。
线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息
Final域
对于final域,编译器和处理器要遵守两个重排序规则:
- .在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
- 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个从啊做之间不能重排序。
public class FinalExample {
int i; // 普通变量
final int j; // final变量
static FinalExample obj;
public FinalExample () { // 构造函数
i = 1; // 写普通域
j = 2; // 写final域
}
public static void writer () { // 写线程A执行
obj = new FinalExample ();
}
public static void reader () { // 读线程B执行
FinalExample object = obj; // 读对象引用
int a = object.i; // 读普通域
int b = object.j; // 读final域
}
}
假设这里线程A执行write方法,线程B执行read方法
写final域的重排序规则
写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含
下面2个方面。
1)JMM禁止编译器把final域的写重排序到构造函数之外。
2)编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。
writer()方法只包含一行代码:finalExample=new FinalExample()
这行代码包含两个步骤,如下。
1)构造一个FinalExample类型的对象。
2)把这个对象的引用赋值给引用变量obj。
写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被
正确初始化过了,而普通域不具有这个保障。以上图为例,在读线程B“看到”对象引用obj时,很可能obj对象还没有构造完成(对普通域i的写操作被重排序到构造函数外,此时初始值1还没有写入普通域i)
读final域的重排序规则
读final域的重排序规则是,在一个线程中,初次读对象引用与初次读该对象包含的final
域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。
初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。由于
编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接
依赖,也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序
(比如alpha处理器),这个规则就是专门用来针对这种处理器的。
reader()方法包含3个操作。
·初次读引用变量obj。
·初次读引用变量obj指向对象的普通域j。
·初次读引用变量obj指向对象的final域i。
现在假设写线程A没有发生任何重排序,同时程序在不遵守间接依赖的处理器上执行,下图是一种可能的执行时序。
读对象的普通域的操作被处理器重排序到读对象引用之前。读普通域时,该域还没有被写线程A写入,这是一个错误的读取操作。而读final域的重排序规则会把读对象final域的操作“限定”在读对象引用之后,此时该final域已经被A线程初始化过了,这是一个正确的读取操作。
读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final
域的对象的引用。在这个示例程序中,如果该引用不为null,那么引用对象的final域一定已经被A线程初始化过了
本文参考《java并发编程的艺术》