1、JMM内存模型
计算机的存储介质分为两大类:外存和内存,外存就是硬盘,磁盘等持久化的介质,数据存在外存中,机器下电后依然能保存数据;内存就是我们平常说的内存条,我们运行程序(CPU执行指令)时会把数据从外存读取到内存中,然后CPU再将数据从内存中读取,由于CPU执行指令的速度远远快于从内存中读取数据的速度,因此CPU从内存中加载数据的速度会成为一个性能瓶颈,因此缓存出现了。
缓存是有分级的:一级缓存、二级缓存…,缓存是CPU的一部分,自然CPU从缓存里读取数据的速度会很快。CPU读取数据时,会先从缓存中读取数据,如果缓存里找不到数据,就会从内存里读数据,从内存中读取数据后,会先在缓存里留一份数据副本,CPU就可以从缓存中读取和写入数据,CPU处理完数据后把缓存里的数据刷新,再写回内存中。
Java内存模型和计算机的内外存模型有很多类似的地方。Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存),线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作,并且每个线程不能访问其他线程的工作内存。如图所示,图摘自参考链接2。
2、原子性、可见性、有序性
通常我们说的线程安全,可以理解为多个线程访问临界变量时,需保证以下三个性质:原子性、可见性和有序性。
2.1 原子性
原子性是指:一个或者一组操作,要么全部执行,要么一个都不执行,不能出现一组操作中,有几个操作执行了,另外几个操作没执行,例子就是银行转账的例子。
java中的原子操作包括:
- 除long和double之外的基本类型的赋值操作
- 所有引用reference的赋值操作
- java.concurrent.Atomic.* 包中所有类的一切操作
看似是原子操作实际不是的操作:
y = x;
x++;
x = x + 1;
这里以x++举例说明,分为以下三步:
- 线程将x的值从主内存读入工作内存;
- 在工作内存中将x的值加1;
- 将加1后的x的值从工作内存写回主内存。
以上3个步骤并不能保证原子性,中途可能那个步骤线程会阻塞。前面讲的同步方法,比如synchronized关键字和Lock都是java里保证操作原子性的措施。
2.2 可见性
可见性是指:多个线程共同访问一个变量并修改变量的值,其中一个线程修改了变量的值,其他线程能感知到变量修改后的值。举个例子:两个线程,一个线程将变量的值从主存加载到工作内存,在工作内存中将变量值+10,此时该线程阻塞,并没有将+10后的值从工作线程刷回到主存中;此时另一个线程读取该变量值,由于主存中该变量值没刷新,因此第二个线程拿到的值还是+10之前的值,并没有感知到+10的变化,+10这个操作对第二个线程是不可见的。
java中保证可见性最经典的就是今天要讲的volatile关键字,此外synchronized关键字和ReentrantLock也可以保证。
2.3 有序性
有序性是指:程序是按照代码中书写的顺序执行的。在实际JVM执行代码时,并不能保证写在前面的代码一定会发生在写在后面的代码之前,因为会发生指令重排序。指令重排序是指:处理器为了提高程序运行效率,可能会对输入的代码进行优化,它不保证程序中各个语句的执行顺序与代码中的顺序一致,但是它会保证程序最终执行结果和代码中顺序执行的结果是一致的。
单个线程里发生指令重排序不会出现问题,多个线程里出现指令重排序会有问题。举例如下:
代码块1:
// 语句1
context = loadContext();
// 语句2
inited = true;
代码块2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
代码块1:中,loadContext()方法加载上下文,加载成功后将标志位inited置为true;代码块2中,有一个循环时刻监测着标志位inited,一旦inited为true,即上下文加载成功后,代码块2跳出循环,调用doSomethingwithconfig()方法,该方法依赖加载后的上下文context。
问题场景描述:由于存在指令重排序的机制,线程1首先执行代码块1中的语句2,此时没有调用loadContext()方法加载上下文,但标志位inited已经置为true;然后线程2执行代码块2,发现inited已经为true,因为线程2继续执行代码块2中的doSomethingwithconfig()方法。问题就出现了,此时上下文context并没有被加载,而强依赖context的doSomethingwithconfig(context)方法已经执行了,会出现问题。还是要注意:只有在多个线程中的交互中,指令重排序才会出现问题。
3、volatile关键字工作机制
3.1 底层原理
volatile关键字是用来修饰临界资源的,临界资源即可能出现多个线程同时进行写操作或者读操作的资源。
一个变量被volatile关键字修饰后将具备如下两个特性:
(1)保证此变量对所有线程的可见性
当一个写线程修改完volatile关键字修饰的变量后,会立刻将修改后的值刷新到主内存中,并强迫其他工作线程中的变量副本失效,这样其他线程在读取该变量时,不会从工作内存中获取而是从主内存中重新读取变量值,保证可见性;
(2)禁止指令重排序
被volatile关键字修饰的变量赋值后,在赋值语句后会插入一个内存屏障,指令重排序时不能把后面的指令重排序到内存屏障之前的位置,保证有序性。
关于内存屏障:
加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供2个功能:
- 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
- 它会强制对临界变量的写操作立即刷新至主存中,并使其他线程的工作内存失效。
因此volatile关键字底层还是靠这个内存屏障对应的lock指令保证的。
3.2 使用举例
(1)状态标记量
比如我们设置一个开关,在一个线程完成某件事情时,其他所有线程将不再继续执行新任务。主要是利用了volatile关键字提供的可见性。
(2)double check
在单例模式的写法中,有一种经典的写法:双重检查(DCL, Double Check Lock),需要将单例对象用volatile关键字修饰,详情可参考我这篇文章:https://www.yuque.com/docs/share/f42591c9-a83c-42e4-8b60-3fd4eeb4d8ba?# 《单例模式》
4、为什么volatile关键字不保证原子性
还是举上面的例子,起10个异步线程,每个线程执行1000次临界资源的自增操作,注意让主线程休眠5秒,保证10个异步线程全部执行完毕后再打印临界资源的值,如下:
package Volatile;
/**
* @ClassName VolatileDemo
* @Description TODO
* @Auther Jerry
* @Date 2020/3/20 - 22:38
* @Version 1.0
*/
public class VolatileDemo {
// 初始化临界资源,值为0
private volatile int val = 0;
public static void main(String[] args) {
VolatileDemo volatileDemo = new VolatileDemo();
for (int i = 0; i < 10; ++i) {
new Thread(volatileDemo::increase).start();
}
// 让主线程休眠5秒,保证前面起的10个异步线程都执行完毕
try {
Thread.sleep(5000);
} catch (InterruptedException e)
{
e.printStackTrace();
}
System.out.println(volatileDemo.getVal());
}
private void increase() {
for (int i = 0; i < 1000; ++i) {
this.val++;
}
}
private int getVal() {
return this.val;
}
}
结果每次执行都不一样,有时候出现10000,有时候出现9000等值,但是按照预期,就应该是10*1000=10000啊,这是因为volatile关键字不能保证原子性。
简单的说,修改volatile变量分为四步:
- 从主内存读取volatile变量到线程的工作内存;
- 修改变量值;
- 工作内存中变量的副本值写回主内存;
- 插入内存屏障,即lock指令,让其他线程可见。
这样就很容易看出来,前三步都是不安全的,取值和写回之间,不能保证没有其他线程修改,需要锁来保证。
回到上面的例子,变量i被volatile关键字修饰,i在主内存的值为100,起两个异步线程分别执行++i操作,线程1从主内存里将i=100读取到线程1的工作内存后被阻塞,同时线程2从主内存里将i=100读取到线程2的工作内存并完成+1操作, 但线程2并没有将+1后的结果刷新到主内存里,而是直接被阻塞,此时线程1被唤醒,由于线程2并没有来得及将+1后的结果刷新到主内存中,因此线程1里的缓存没有失效,线程1继续将缓存里的i=100+1=101执行后刷新到主内存里,然后线程2唤醒再将101刷新到主内存里,最后i的值是101而不是102。
解决上面问题的方法还是前面讲的线程同步的方法,用synchronized方法、synchronized块、lock,但是这种简单的+1操作还是推荐用JUC包下的原子类。
参考
Java并发编程:volatile关键字解析 - Matrix海子 - 博客园 www.cnblogs.com
深入理解Java内存模型 www.jianshu.com
volatile什么时候使用 www.jianshu.com
volatile为什么不能保证原子性?