1. 引入
- 为何需要定义Java内存模型?使用之前的JVM内存结构不是已经够了吗?
- 答:目前计算机硬件都会为了平衡CPU计算速度和读取内存IO速度,而设计出寄存器和高速缓存。JVM为了能够使用到这些技术(不管具体硬件)就需要设计出一套Java内存模型出来。
2. 具体模型
image.png- 所有变量都存储与主内存中。
- 每个线程拥有自己的工作内存,保存了该线程使用到的变量的主内存副本。
- 线程对变量的操作(读取和修改)都要通过工作内存实现。
- 线程之间的工作内存不连通,通信通过主内存实现。
这里引发几个问题:
- 显然工作内存一般位于寄存器和高速缓存上,那为何以线程为单位?不应该是以CPU为单位吗,因为工作内存存在的目的就是为了平衡CPU速度和主内存的速度?
答:显然在编程语言层面上不可能定义以具体CPU为单位的内存。因为总不能JVM上来先判断当前系统有几个CPU,然后怎么样,且内存的使用者是程序,程序的运行方式是线程,且知道一个CPU同一时刻只能一个线程在运行。所以这里可以定义线程级别的是没问题的。 - 引入工作内存所带来的问题:读取/修改变量可能会经历好几步原子性操作,变成了是非原子性操作,引发的数据同步问题。
这里注意:我们知道工作内存是线程为单位的,显然数据同步问题的关注点在于实例对象和静态变量等线程共享的数据上。局部变量是没有同步安全问题的。 - 注意这里说的主内存和工作内存是不同纬度上的划分,他们之间并没有任何关系。
3. 内存间的交互操作
JVM定义了8种原子操作。
1、lock(锁定):作用于主内存的变量,它把一个变量标示为一条线程独占的状态。
2、unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
3、read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到工作内存中,以便随后的load动作使用。
4、load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
5、use(使用):作用于工作内存的变量,它把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值得字节码指令时将会执行这个操作。
6、assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
7、store(存储):作用于工作内存的变量,它把工作内存中的一个变量的值传递到主内存中,以便随后的write操作使用。
8、write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量值放入主内存的变量中。
从主内存中读取变量还是将工作内存的变量写回主内存等等一系列操作都是靠这8个原子完成的。了解一下就好,不需要死记硬背。
4. volatile关键字
总的说来被volatile修饰的变量有两个特性:
- 内存可见性
- 禁止指令重排
1. 内存可见性
- 表现上
一个线程修改了volatile变量,其他线程可以立刻得到最新值。不会存在其他线程还读取旧值的情况。 - 内部实现上
JVM会在对volatile变量赋值语句后额外生成一条lock指令。它的作用就是将工作内存中的值写回主内存,并另其他线程的工作内存失效。这样其他线程读取的时候就会从主内存中捞到最新值。 - 可见性不等于线程安全
这个应该很容易理解。比如c是volatile的,但c++这个操作显然不是原子操作,不是原子操作就不会保证线程安全。
2. 禁止指令重排
JVM会对字节码生成的汇编指令进行指令重排(目前计算机处理器都会这么做来提高执行效率。但会保证最终结果的正确性。即指令执行顺序可以打乱,但不会影响代码的结果正确性。JVM也吸取了这个特性)。
- 表现上
被volatile修饰的变量,当执行到赋值操作时,可以保证上面的代码都已执行结束。包括赋值操作本身。
例如: - 内部实现上
还是那个lock指令。我们知道JVM会在赋值语句后生成lock指令。它的作用相当于一个内存屏障,指令重排不可能将屏障后的指令放到屏障前面。
这里为何相当于?
这里存在两种说法:禁止指令重排JVM有两种实现方式:lock指令和内存屏障。这个我们后续有时间再研究。 - 经典例子
禁止指令重排的经典例子就是DCL(双锁检测)方式的单例模式。
public class Singleton {
private volatile static Singleton singleton = null;
private Singleton(){}//禁止使用构造函数来实例化对象
public static Singleton getSingletonInstance(){
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
这里我们解释一下如果singleton变量不加volatile将会发生什么?
我们看一下这一条语句:
singleton = new Singleton();
其实这条语句会生成三条指令:
(1) memory = allocate();//申请内存
(2) cotrInstance(memeory);//实例化内存对象
(3) singleton = memory;//将singleton指向内存
- 第2步依赖第1步,所以1和2不会重排。但是3和2可能会发生重排。即singleton指向了一块内存,此时这块内存还没初始化结束。
- 所以如果此时线程1执行到了1->3的时候,线程2访问getSingletonInstance函数,最外层的if的时候,发现singleton!=null,然后就会返回singleton。但此时singleton其实并没有初始化结束,所以返回的是构造不完全的对象。
- 有了volatile修饰之后,就会禁止指令排序,即(3)执行的时候,(1)和(2)已经执行完成。这也是jdk1.5之前无法使用DCL这种方式写单例模式的原因。因为jdk1.5之前volatile仍不完全避免指令重排。
5. 对long和double型变量的特殊规则
Java内存模型定义了一个规定:对于64位数据(long和double),允许虚拟机将没有被volatile修饰的64位数据的读取操作分为两次32位操作来进行。
- 这个规定有点坑,意思就是如果多个县城共享一个未声明volatile的long或double变量,则可能会读取一个既非原值也不是其他线程修饰值的“半个变量”数值。
- 但目前商用的Java虚拟机不会这么做。还是把long和double变量的修改变成原子操作。
6. 原子性、可见性和有序性
1. 原子性
我们知道java内存模型中的8种原子操作都是原子性的。
加互斥锁(比如synchronized)可以保证一段代码是原子性的。
2. 可见性
synchronized、final、volatile都可以保证可见性。
说一下final为何可以?
在对象初始化完之后,对象的状态都不可变了,自然保证了线程的可见性。
3. 有序性
synchronized、volatile都可以保证可见性。
volatile就不说了,synchronized保证了一段代码同一个时间只有一个线程访问,自然不会出现指令重排造成的问题。指令重排造成的问题只会在多线程下存在。
7. 先行发生原则
Java内存模型定义了一些默认的偏序规则,即这些规则无需任何显式同步代码来实现。所以不满足如下规则的且没有同步代码实现的都可能会被重排。
1、程序次序规则。在一个线程内,书写在前面的代码先行发生于后面的。确切地说应该是,按照程序的控制流顺序,因为存在一些分支结构。
2、Volatile变量规则。对一个volatile修饰的变量,对他的写操作先行发生于读操作。
3、线程启动规则。Thread对象的start()方法先行发生于此线程的每一个动作。
4、线程终止规则。线程的所有操作都先行发生于对此线程的终止检测。
5、线程中断规则。对线程interrupt()方法的调用先行发生于被中断线程的代码所检测到的中断事件。
6、对象终止规则。一个对象的初始化完成(构造函数之行结束)先行发生于发的finilize()方法的开始。
7、传递性。A先行发生B,B先行发生C,那么,A先行发生C。
8、管程锁定规则。一个unlock操作先行发生于后面对同一个锁的lock操作。
无需死记硬背,了解一下就好。
网友评论