JMM内存模型&volatile
1、JMM内存模型
什么是JMM模型?
- Java内存模型( Java Memory Model 简称 JMM)是一种抽象的概念,描述的是一组规则或规范。JVM运行程序的实体是线程,而每个线程创建时 JVM 都会为其创建一个工作内存,用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝。工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。
JMM不同于JVM内存区域模型
- JMM与JVM内存区域的划分是不同的概念层次,更恰当说JMM描述的是一组规则。JMM与JVM内存区域唯一相似点,都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。
主内存
- 主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是局部变量,当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发生线程安全问题。
工作内存
- 主要存储主内存中的变量副本拷贝,每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量。由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。
JMM存在的必要性
- 由于 JVM 运行程序的实体是线程,而每个线程创建时 JVM 都会为其创建一个工作内存,用于存储线程私有的数据,线程与主内存中的变量操作必须通过工作内存间接完成,主要过程是将变量从主内存拷贝的每个线程各自的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,如果存在两个线程同时对一个主内存中的实例对象的变量进行操作就有可能诱发线程安全问题。
数据同步八大原子操作
- lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态
- unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
- read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
- use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量
- store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作
- write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中
- 如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步到主内存中,就需要按顺序地执行store和write操作。Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
可见性,原子性与有序性问题
- 原子性
- 原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。
- 解决方案:通过 synchronized和Lock实现原子性。因为synchronized和Lock能够保证任一时刻只有一个线程访问该代码块。
- 可见性
- 可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。对于单线程来说,可见性是不存在的,因为在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。
- 由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题。
- 解决方案:volatile关键字保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值立即被其他的线程看到,即修改的值立即更新到主存中,当其他线程需要读取时,它会去内存中读取新值。synchronized和Lock也可以保证可见性,因为它们可以保证任一时刻只有一个线程能访问共享资源,并在其释放锁之前将修改的变量刷新到内存中。
- 有序性
- 有序性是多线程环境,则可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致。在Java程序中,如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的。
- 解决方案:通过volatile关键字来保证有序性。也可以通过synchronized和Lock来保证有序性,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程串行执行同步代码,自然就保证了有序性。
as-if-serial
- **不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。**编译器、处理器都必须遵守as-if-serial语义。
- 为了遵守as-if-serial,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
2、volatile
- volatile是Java虚拟机提供的轻量级的同步机制。
- volatile关键字主要有如下两个作用:
volatile可见性
-
被volatile修饰的共享变量对所有线程总数可见的,即当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。 -
//示例
public class VolatileVisibilitySample {
private boolean initFlag = false;
public void save(){
this.initFlag = true;
String threadname = Thread.currentThread().getName();
System.out.println("线程:"+threadname+":修改共享变量initFlag");
}
public void load(){
String threadname = Thread.currentThread().getName();
while (!initFlag){
//线程在此处空跑,等待initFlag状态改变
}
System.out.println("线程:"+threadname+"当前线程嗅探到initFlag的状态的改变");
}
public static void main(String[] args){
VolatileVisibilitySample sample = new VolatileVisibilitySample();
Thread threadA = new Thread(()->{
sample.save();
},"threadA");
Thread threadB = new Thread(()->{
sample.load();
},"threadB");
threadB.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
threadA.start();
}
}
//如上示例中,线程A对 initFlag 变量的修改无法被线程B感知,导致线程B一直处于死循环中。
// private volatile boolean initFlag = false;
//只需要将 initFlag 变量用 volatile 修饰,线程A改变 initFlag 之后,线程B马上感知到。
volatile无法保证原子性
-
public class VolatileVisibility {
public static volatile int i =0;
public static void increase(){
i++;
}
}
-
在并发场景下,i 变量的任何改变都会立马反应到其他线程中,但是如此存在多线程同时调用increase()方法的话,就会出现线程安全问题,毕竟 i++; 操作并不具备原子性,该操作是先读取值,然后对值进行 加1操作,最后写回主内存,相当于原来的值加上1,分三步完成。如果线程B在线程A读取旧值和写回新值期间读取 i 的值,那么线程B就会与线程A看到的是同一个值,线程B并执行加1操作,此时这也就造成了线程安全失败。 -
因此对于 increase 方法必须使用synchronized、Lock修饰,以便保证线程安全,需要注意的是一旦使用synchronized、Lock修饰方法后,由于synchronized、Lock本身也具备可见性,因此在这样种情况下就完全可以省去volatile修饰变量。
volatile禁止重排优化
-
volatile另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象。 -
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(); 代码有问题,其底层会分为三个操作:
- 分配一块内存。
- 在内存上初始化成员变量。
- 把 instance 引用指向内存。
-
在这上述三个操作中,操作2和操作3可能重排序,即先把 instance引用指向内存,再初始化成员变量,因为二者并没有先后的依赖关系。此时,另外一个线程可能拿到一个未完全初始化的对象。这时,直接访问里面的成员变量,就可能出错。这就是典型的“构造方法溢出”问题。 -
解决方案:instance变量加上volatile修饰。 -
volatile 实现禁止重排优化只要是通过内存屏障(Memory Barrier)。
内存屏障(Memory Barrier)
- 由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,即通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。
- 由于不同的CPU架构的缓存体系不一样,重排序的策略不一样,所提供的内存屏障指令也就有差异。
- 这里只探讨为了实现volatile关键字的语义的一种参考做法:
- 在volatile写操作的前面插入一个StoreStore屏障。保证volatile写操作不会和之前的写操作重排序。
- 在volatile写操作的后面插入一个StoreLoad屏障。保证volatile写操作不会和之后的读操作重排序。
- 在volatile读操作的后面插入一个LoadLoad屏障+LoadStore屏障。保证volatile读操作不会和之后的读操作、写操作重排序。
- x86平台上,其实不会有LoadLoad、LoadStore和StoreStore重排序,只有StoreLoad一种重排序(内存屏障),也就是只需要在volatile写操作后面加上StoreLoad屏障。
final关键字
-
public class MyClass {
private int num1;
private int num2;
private static MyClass myClass;
public MyClass() {
num1 = 1;
num2 = 2;
}
public static void write() {
myClass = new MyClass();
}
public static void read() {
if (myClass != null) {
int num3 = myClass.num1;
}
}
int num4 = myClass.num2;
}
-
如上示例同样可能出现如DCL的构造方法溢出问题,num3、num4不见得一定等于1,2。 -
myClass = new MyClass()这行代码,分解成三个操作:
- 分配一块内存;
- 在内存上初始化i=1,j=2;
- 把 myClass 引用指向这块内存。
-
在这上述三个操作中,操作2和操作3可能重排序,因此线程B可能看到未正确初始化的值。对于构造方法溢出,即一个对象的构造并不是“原子的”,当一个线程正在构造对象时,另外一个线程却可以读到未构造好的“一半对象”。 -
解决方案:
happen-before规则总结
- 个人理解为:发生在……之前。
- 单线程中的每个操作,happen-before于该线程中任意后续操作。
- 对volatile变量的写,happen-before于后续对这个变量的读。
- 对synchronized的解锁,happen-before于后续对这个锁的加锁。
- 对final变量的写,happen-before于final域对象的读,happen-before于后续对final变量的读。
- 四个基本规则再加上happen-before的传递性,就构成JMM对开发者的整个承诺。在这个承诺以外的部分,程序都可能被重排序,都需要开发者小心地处理内存可见性问题。
|