首先,java中除了synchronized关键字可以保证线程安全,还有一个关键字volatile也可以保证。你可以理解它是一个轻量级的synchronized,但是它不能保证线程的原子性
接上一篇讲,在上一篇我们提到了CPU的高速缓存L1、L2和L3以及JMM内存模型。那么这一篇我们就开始接着往下讲。
JAVA并发编程的三大特性
对于JAVA并发编程而言,我们始终要遵守这三种特性的规则。才能使我们的代码不出问题。这三大特性是:
- 可见性
- 原子性
- 有序性
对于volatile关键字而言,它只能够保证我们的可见性和有序性,但是并不能保证原子性。想要保证原子性可以使用例如sychronized关键字或者使用ReentrantLock等实现线程的原子性。至于synchronized和ReentrantLock,在后面的文章我会陆续进行讲解。
volatile关键字详解
对于volatile而言,它并不像我们的sychronized可以作用在方法上或者同步代码块上。它只能用作类里面的成员变量身上。当我们作用于一个变量上的时候,我们使用javap命令来看一下在字节码文件的层面,编译器对它做了什么处理。假设我这里有一段代码
这时候我先对它进行一个编译,使用javac命令即可
我们打开它发现是一堆乱码文件
其实这并不是乱码文件,而是编译器的一套编译规则·。具体的字节码文件详解在我以后的文章中会写到。
那么我们应该怎么看字节码文件呢?
这时候我们就要借助另一个命令了
javap -verbose xxxxxx.class -> xxxxxx.txt
这个命令的作用呢是将我们的class文件转换成JVM层面的汇编代码并输出成一个TXT格式的文件。那么我们开始对我们的class文件进行一个操作。
我们直接定位到我们的关键一行
我们发现,被volatile修饰的变量竟然会被编译器做一个标记叫ACC_VOLATILE。那可能就有疑问了,jvm内部到底是如何实现volatile这样一个特性的呢?
volatile之可见性
所谓可见性,就是是我们的共享变量在不同的线程环境中可以察觉到变量的值的变化,下面我用一个例子来演示一下
/**
* @author: 是残月啊
* @create: 2020-08-15
* @description: volatile关键字
**/
public class VolatileKeyword {
public static volatile boolean flag = true;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (flag){
}
System.out.println("线程1执行完毕");
});
Thread t2 = new Thread(() -> {
change();
System.out.println("线程2修改了flag为false");
});
t1.start();
t2.start();
}
public static void change(){
flag = false;
}
}
我们发现当线程2修改了flag为false的时候,线程1仍然在进行while循环,并没有输出线程1执行完毕这句话。
当我们加上了volatile关键字之后呢?
我们可以立马看到在线程2修改了flag之后,线程1立马就嗅探到了,它是如何做到的呢?就是通过一条汇编指令Lock+我们的总线嗅探机制完成的。至于什么是总线嗅探机制,我准备放在下一篇去讲,因为这里涉及到的底层东西很多,所以今天不会探讨的太深入。
那么我们如何看到真正的汇编代码呢(硬件原语)。我们可以借助一个工具叫做hsdis。这里我贴一下地址,大家可以参考这篇博客上写的去操作。地址传送门
-server
-Xcomp
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintAssembly
-XX:CompileCommand=compileonly,*VolatileKeyword.change
我们通过这条语句就可以打印出change方法的汇编代码。
最终我们定位到这里,可以看到
它在进行改变之后对我们的操作加了一个Lock前缀。通过这个Lock指令触发我们CPU的缓存一致性协议和总线嗅探机制,进而使我们的变量能够立马可见,缓存一致性协议我也同样会放到下一篇去讲
volatile之有序性
首先我们要知道到底what is 有序性?
其实在CPU执行代码的时候,并不会傻乎乎的一行接着一行执行,而是会判断如果我将执行的顺序切换切换一下在不影响执行结果的情况下如果执行效率会提高,那么它就会对我们的指令做一个顺序调整,也叫做指令重排序
as-if-serial语义
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序, 因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可 能被编译器和处理器重排序。
但是我们的可能平常创建对象就一个new xxx();就好了,但是对于jvm来说,通常new一个对象需要很多操作。我画图给大家演示一下:
我们可以看到,一个对象创建出来需要经历五个步骤,那么经历这么多操作之后你能保证编译器或CPU不对他进行重排序吗?所以我们需要使用volatile关键字来保证它的有序性。
那么volatile到底是如何保证有序性的呢?
volatile之有序性
在我们的java中,它为我们提供了一个叫做内存屏障的东西。在之前CMS垃圾回收器的文章中,我有讲到CMS使用三色标记和写屏障的方式来解决漏标问题,并且jvm在维护记忆集与卡表的时候也使用到了内存屏障。
但是注意,我们java的内存屏障和hotspot的内存屏障是两码事
内存屏障
首先我们先说说硬件层面的内存屏障。简单来说对于例如像Inter这样的CPU来说,它主要提供了三种内存屏障。
- lfence,我们也叫读屏障(load barrier)
- sfence,我们也叫写屏障(store barrier)
- mfence,具备lfence和sfence的能力
- lock前缀,它不是内存屏障,但是能完成类似内存屏障的功能。它会对BUS总线和CPU高速缓存加锁。
其实它的作用主要只有两个,一是保证特定操作的执 行顺序,二是保证某些变量的内存可见性。在JVM中它也为我们提供了4中内存屏障
- LoadLoad
- StoreStore
- LoadStore
- StoreLoad
我们就按LoadLoad来说,如果你在两个读指令之间加了一个loadload表示第一个load的读取操作在load2及后续读取操作之前执行。例如:Load1; LoadLoad; Load2
又或者StoreStore来说,如果你有两个写操作,如果你在中间加了一个StoreStore则在store2及其后续的写操作执行前保证store1的写操作已经刷新到主内存。例如:Store1; StoreStore; Store2
happens-before原则
在happens-before的八大原则中,其中有一条对我们的volatile也做了一个限定。
我们在一个操作中可以分成三中:
- volatile读
- volatile写
- 普通读写
根据上图所示,假如我有这样一个操作
我们可以看到这里有两个变量,一个volatile 修饰的A,
一个没有用volatile修饰的变量b。那么这两行代码实际上就是普通读写+volatile读的操作。什么意思呢,首先我们的第一个操作是一个没有被volatile 修饰的变量赋值,int b=0,那么这是一次普通读写,第二个操作我们将a赋值给b这是一次volatile读操作。那么也就是说 我们第一个操作是普通读写,第二个操作是volatile读,说明我们在这里是被允许指令重排序的
看到这里,大家是不是忽然想到了我们的双重校验单例时候,不仅用了synchronized也用了volatile,原因就是在于创建对象的时候有可能会被指令重排,所以我们使用volatile保证有序性。
好啦~今天的文章就到这里,喜欢的话记得点赞收藏加关注哦!