1. JMM
1.1 什么是java内存模型
什么是java内存模型,就我理解,可以如上图所示。首先,因为java的内存模型是一个二级缓存,线程首先需要将数据写入到本地缓存,然后才能将本地缓存的数据刷到主内存中,所以就出现了可见性的和有序性的问题。由于cpu会进行优化,会进行cpu级别指令重排序。并且在java前端编译器中,也会对java代码进行优化,也有可能对代码进行重排序,这就导致了有序性问题。
为了解决上述问题,jmm是怎么做的呢?jmm会禁用掉部分指令重排序。同时针对java的二级缓存带来的问题,java会在部分指令之间加入内存屏障。经过上面的操作,java就给程序员提供了一个承诺,也就是8大happens-before原则。所以,我们在写代码的时候,需要遵循happens-before原则,才能保证代码的线程安全。
接下来,我们就聊聊上面的4个模块。
1.2 java的缓存模型和指令重排
1.2.1 java缓存模型
java的缓存模型如图所示,是一个二级缓存的结构,线程A如果想传递变量给线程B,会将值写入到本地缓存A,然后再刷入到共享缓存,线程B再从共享缓存中读入到线程B的本地缓存中。
所以这里就会有一个问题,比如对于变量X,在共享缓存中值为0,如果线程A将其更新为1,并写入到了线程A的本地缓存中,还未同步到共享缓存,时间片便被用完。线程B开始运行,从共享缓存读取变量A,这个时候读取到的应该是1。
上述就出现了,内存可见性问题,即对本地内存的写操作其实是只能保证是对本线程可见的。
1.2.3 指令重排序
1.编译期指令重排序
在java的前端编译器中,会有许多优化策略,所以可能对代码进行指令重排。
2.内存系统重排序
内存系统重排序就是,其实就是如1.2.1中所展示的那样,最终表现出来的也是代码的执行顺序是混乱的。
3.cpu指令级别重排序
cpu为了让多条指令重叠执行,所以对cpu的指令进行重排。
1.3 java针对上述问题所做的操作
1.3.1 代码级别
在编译期,禁用掉编译器的部分指令重排序。
1.3.2 cpu级别
在编译期,加入内存屏障,保证内存可见性。
以storeLoad屏障为例,如果加入store1 storeLoad load2的内存屏障,可以保证在执行load2操作的时候,store1的数据一定是刷入到共享内存中的,所以在进行load2操作时一定能够看到store1的数据。
1.4 happens-before原则
happens-before原则是jmm对程序员提供的承诺。
1.在单个线程中,书写在前面的操作,是对书写在后面的操作可见的;
2.对一个监视器的加锁是对这个监视器解锁可见的;
3.volatile变量的的写操作是对volatile的读操作可见的;
4.A happens-before B,B happens-before C,则A happens-before C;
5.线程启动对后续该线程操作可见;
6.线程所有操作对线程终止可见;
7.线程interrupt()方法的调用对线程中断事件检测可见;
8.对象的创建对对象的finalize()方法调用可见。
A happens-before B,并不代表A一定先于B执行,只是A的操作结果一定是对B可见的,比如就同一个线程中,书写在前面的操作,是对书写在后面的操作可见的这一个原则,因为jmm在保证单线程执行结果一致的情况下,也可能对指令进行重排。
2.long和double类型的特殊处理
java虚拟机规范规定可以不保证long类型和double类型的原子性。但是建议虚拟机实现的时候,需要保证这两个64位类型的原子性,现在市面上的大部分虚拟机都是如此实现的。
3.volatile关键字
3.1 volatile的写读的内存语义
volatile关键字的读写的内存语义其实就和对读写加锁的语义一致,比如如下代码其实是等效的:
volatile int l = 1;
public synchronized void set(long l) { //volatile关键字的写等价于对set方法进行加锁
vl = l;
}
public synchronized void get() { //volatile关键字的读等价于对get方法进行加锁
vl = l;
}
再从底层看volatile关键字的顺序性和可见性:
1. 对于java的二级缓存,volatile关键字的写,会将写之前的数据全部刷新到共享缓存中,所以在volatile写后面的读取数据一定能看到volatile写之间操作的数据;并且禁止volatile写之前的指令重排到volatile写之后(JSR133对volatile关键字内存语义的增强)。
2.volatile关键字的读,会使得本地缓存失效,直接获取共享缓存中的数据;并且禁止volatile读之前的指令重排到volatile读之后(JSR133对volatile关键字内存语义的增强)。
3.如果要实现线程间的通信,可以采用volatile关键字的读写来实现。通过1和2可以看出,如果先利用volatile写,后再利用volatile读,一定能保证写操作之间的数据是对读操作后的数据可见的。
4.volatile类型的符合操作是不具有原子性的,比如volatile++这种,是不具备原子性的。
3.2 volatile关键字与单例模式
如下是单例模式的双重锁模式的实现:
public class Singleton {
private volatile static Singleton INSTANCE; // 加 volatile
private Singleton () {}
public static Singleton getSingleton() {
synchronized(Singleton.class) { // 加 synchronized
if (INSTANCE == null) {
INSTANCE = new Singleton ();
}
}
return INSTANCE;
}
}
那为什么这里需要将Instance加上volatile关键字呢?在解释这个问题之前,我们先看一下new一个对象的过程:
对象分配过程:
1.检测该对象的类是否被加载,如果为被加载,便去加载该类;
2.通过碰撞指针或者空闲列表为对象分配内存空间;
3.将对象属性设置为初始化值;
4.设置对象头;
5.执行对象的赋值操作和构造方法;
6.返回对象引用。
可以看出INSTANCE = new Singleton ()这一行代码执行了很多操作,所以如果发生指令重排的话,可能在第2步执行完成过后,就返回对象应用,这个时候的对象并不是一个预期的对象。如果加上volatile关键字,根据volatile读的内存语义可以知道,volatile读之前的操作一定是对volatile读可见,所以返回的一定是一个完整的对象。
4.final关键字
final关键字主要强调的是,构造函数和final域的可见性问题。
4.1 final域是普通类型
1.在返回包含final域的对象引用之前一定,完成了final域的赋值。
public class Test{
private final int i = 1;
}
public static writer() {
Test test = new Test();//在此处,一定完成了final域i的赋值
}
2.在读取fianl域的时候,一定完成了返回了对象引用。这条语义,对于hotspot虚拟机来说,普通变量也是生效的。
public class Test{
private final int i = 1;
public int geti() {
return i;
}
}
public static reader() {
Test test = new Test();
int j = test .geti();//在此处,一定完成了test引用的赋值
}
4.2 final域是对象类型
如果final域是引用类型,则在构造函数内对final域内引用的赋值一定是先于在构造函数外获取该引用。
5.synchronize关键字
5.1 synchronize关键字的使用
package common;
public class Test {
public static synchronized void doSomething() {
System.out.println("同步方法,类锁");
}
public synchronized void doSomething1() {
System.out.println("同步方法,对象锁");
}
public synchronized void doSomething2() {
synchronized (Test.class) {
System.out.println("同步代码块,类锁");
}
}
public synchronized void doSomething3() {
synchronized (this) {
System.out.println("同步代码块,方法锁");
}
}
}
可以看出,synchronize可以对类和对象加锁,类是也是一个Class对象,所以得出结论,synchronize其实是对class对象加锁。
5.2 synchronzied的实现原理
在对类进行编译的时候,会将同步方法设置为一个为ACC_SYNCHRONIZED,当线程访问该方法时,首先会后去到监视器锁,如果能够获取到监视器锁,便可以执行方法内的操作,并且在完成过后,便会释放该锁。注意,如果同步代码内部抛出异常,也会自动释放监视器锁。
什么是监视器锁呢?在C++的实现中,每个java对象都持有一个objectMonitor的对象,objectMonitor主要有如下几个属性:
_onwer:获取该对象的线程
_entryset:等待该锁的线程队列
_count:重入次数
如果想要进入代码块的时候,首先会进入到锁的等待队列中,如果后去到锁,便将count++,同时将onwer设置为该线程。这就是monitorenter指令的作用。
如果想要释放该锁,便会将owner设置为null,同时将count--,这就是monitorexit指令的作用。
从上面可以看出synchronized关键字是可重入锁。
参考
1.周志明.深入理解java虚拟机
2.程晓明.深入理解java内存模型
3.https://www.yuque.com/hollis666/un6qyk/gxq5p0