在计算机中,每条指令的运行是依靠cpu的,指令执行的过程中会涉及到临时数据的读取和写入,这些数据的读取和写入是发生在计算机主存中的。cpu的指令执行是很快的,但是发生在主存中的数据读取和写入却没有这么快,所以就出现了高速缓存的概念。
JMM即为JAVA 内存模型(java memory model)。因为在不同的硬件生产商和不同的操作系统下,内存的访问逻辑
有一定的差异,结果就是当你的代码在某个系统环境下运行良好,并且线程安全,但是换了个系统就出现各种问题。
Java内存模型,就是为了屏蔽系统和硬件的差异,让一套代码在不同平台下能到达相同的访问结果。JMM从java 5
开始的JSR-133发布后,已经成熟和完善起来。
JMM规定了内存主要划分为主内存和工作内存两种,并且对8种内存交互操作制定了相关的规则。
接下来,咱们分几个方面说明,先来谈一下什么是内存交互操作。再来看jmm为内存交互操作制定的规则。
下面咱们画一张图,来演示下:
如上面这张图所示,一个变量被某个线程操作的过程是这样的。首先,这个int类型的变量a一定被声明在主存中,然后,当线程A想要操作这个变量a的时候,首先read读取存在于主存中的变量a,然后将它load到工作内存中。之后,改变这个变量值的时候,use这个变量a使得它被传递到线程A的执行引擎中,之后执行引擎工作结束之后assign这个变量a到工作内存中。最终当变量在线程A的工作内存操作结束之后,执行write和store将变量a刷回主存。
volatile的作用主要体现在两个方面,
演示1:
public class Demo01 {
private static int num = 0;
public static void main(String[] args) {
new Thread(() -> {
while (num == 0) {
}
}).start();
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
num = 1;
System.out.println(num);
}
}
主程序在沉睡一秒之后,打印出主程序中的这段话,但是新开启的线程迟迟无法结束,为啥呀?原因很简单,新开启的线程是将num load到工作内存中操作的,尽管存在于主存中的num最新值被main线程修改,但新开启的线程仍然在使用自己工作内存中的num,也就是0,故while循环无法结束。
这咋办呢?简单,使用volatile修饰这个num,使得这个变量值更新之后其余的线程立即可见!
public class Demo01 {
private static volatile int num = 0;
public static void main(String[] args) {
new Thread(() -> {
while (num == 0) {
System.out.println("新开启的线程结束...");
}
}).start();
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
num = 1;
System.out.println(num);
}
}
通过运行结果可知,volatile修饰的变量在值被修改之后的确可以被其他线程知晓。
演示2:
public class LazyMan {
private static volatile LazyMan lazyMan;
// 私有化构造方法
private LazyMan() {
}
public static LazyMan getLazyMan() {
if (lazyMan == null) {
synchronized (LazyMan.class) {
if (lazyMan == null) {
lazyMan = new LazyMan();
}
}
}
return lazyMan;
}
}
开始,传统的问题有三个
分别回答一下:
1.第一个if判断的作用是检查当前的LazyMan这个对象是不是null,不是null直接return
2.第二个if的作用是当很多线程进来的时候,且LazyMan这个对象还没有创建的时候,有可能很多线程都进入synchronized代码块中,此时可以虽然可以保证这些线程的顺序执行,但有可能多次创建LazyMan对象,故而再次判断是否为null
3.LazyMan这个对象的创建并非是原子操作,分为3步,分配内存空间、执行构造方法,初始化对象、把对象指向这个空间。这三步未必是顺序执行的。有可能会123,也可能132,假设为132,那么当A线程执行完1和3的时候,B线程进来发现这个对象已经指向分配的空间,拿去直接使用了,那就炸了。所以使用volatile防止指令的重排序。
限制条件有两个
下面分别加以说明
演示1:对变量的写操作不依赖于变量当前值
public class Demo02 {
public static int num = 0;
public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
new Thread(Demo02::increase).start();
}
System.out.println(num);
}
public static void increase() {
num++;
}
}
这段程序的执行结果是多少?1000?不是的,你拿去多尝试几次,基本上都是小于1000的,but why?因为volatile无法保证对变量的原子操作。
可能有的朋友就会有疑问,不对啊,上面是对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程
中对num自增完之后,在其他线程中都能看到修改后的值啊,所以有1000个线程共进行了1000次操作,那么最终inc
的值应该是1000;
这里面就有一个误区了,volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性
只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。
在前面已经提到过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么
就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:
假如某个时刻变量num的值为10,
线程1对变量进行自增操作,线程1先读取了变量num的原始值,然后线程1被阻塞了;
然后线程2对变量进行自增操作,线程2也去读取变量num的原始值,由于线程1只是对变量num进行读取操作,而
没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量num的缓存行无效,所以线程2会直接去主
存读取num的值,发现num的值是10,然后进行加1操作,并把11写入工作内存,最后写入主存。
然后线程1接着进行加1操作,由于已经读取了num的值,注意此时在线程1的工作内存中num的值仍然为10,所以
线程1对inc进行加1操作后inc的值为11,然后将11写入工作内存,最后写入主存。
那么两个线程分别进行了一次自增操作后,inc只增加了1。
解释到这里,可能有朋友会有疑问,不对啊,前面不是保证一个变量在修改volatile变量时,会让缓存行无效
吗?然后其他线程去读就会读到新的值,对,这个没错。这个就是happens-before规则中的volatile变量规则,
但是要注意,线程1对变量进行读取操作之后,被阻塞了的话,并没有对num值进行修改。然后虽然volatile能
保证线程2对变量num的值读取是从内存中读取的,但是线程1没有进行修改,所以线程2根本就不会看到修改
的值。
根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。
总结一下,下面的总结我想了很久,与君共勉
volatile无法保证对变量的任何操作都是原子性的。仅仅当 线程1的i++这个操作执行之前,如果线程2将i++
执行完写回了主存。线程1才能看到i的最新值并且刷新。一旦线程1的i++操作中的读取操作结束了,此时即使
别的线程将最新的i的值写回主存线程1也不会再有机会刷新了。
这个问题怎么解决呢,简单,使用Lock、synchronized、原子类都可以解决。
演示2:该变量没有包含在具有其他变量的不变式中
public class NumberRange {
private volatile int lower;
private volatile int upper;
public int getLower() {
return lower; }
public int getUpper() {
return upper; }
public void setLower(int value) {
if (value > upper)
throw new IllegalArgumentException(...);
lower = value;
}
public void setUpper(int value) {
if (value < lower)
throw new IllegalArgumentException(...);
upper = value;
}
}
将 lower 和 upper 字段定义为 volatile 类型不能够充分实现类的线程安全;而仍然需要使用同步——使 setLower()和 setUpper() 操作原子化。
否则,如果凑巧两个线程在同一时间使用不一致的值执行 setLower 和 setUpper 的话,则会使范围处于不一致的状态。例如,如果初始状态是(0, 5),同一时间内,线程 A 调用setLower(4) 并且线程 B 调用setUpper(3),显然这两个操作交叉存入的值是不符合条件的,那么两个线程都会通过用于保护不变式的检查,使得最后的范围值是(4, 3) —— 一个无效值。
就在刚刚说明volatile的时候,已经说明了DCL懒汉式,本篇不探究其余的怎么实现。只问一个问题,你写出的DCL懒汉式一定能保证永远只有一个单例吗?
如果你说能的话,那么下面我们来反驳一下
ok,咱们通过反射new出来一个别的实例。DCL懒汉式不堪一击。那怎么办呢,其实你可以在代码里面比如构造函数处加判断。当我们声明的实例已经不为null就直接抛出异常,但是这其实也还是没结束的。不再往下面讨论了。咱们直接整一个不能被反射破坏的。
public enum EnumSingle {
INSTANCE;
public static EnumSingle gerInstance() {
return INSTANCE;
}
}
大家知道枚举的底层构造方法是什么吗?是无参构造吗,不是的,把枚举的反编译代码贴在下面
我们尝试使用反射获取下不同的实例
失败了,为啥会失败呢?因为凡事总要有一线生机,我们点开 declaredConstructor.newInstance()的源码。
本篇到此,jmm和volatile非常深刻,我们认真体会。