1.Java内存模型(JMM)
-
Java内存模型的主要目标是定义程序中各个变量的访问规则,所谓的变量访问规则我们可以简单理解为Java程序在工作过程中,对变量处理的方式。而基于JMM的访问规则主要是为了保证多线程并发的时候数据会产生安全性的问题。这些访问规则都是抽象的概念,我们需要通过编程的手段来保证。
- 当然要强调一下这里所说的变量不包含局部变量,即使在并发的场景下局部变量也是私有的,只有当前线程可见。
-
Java内存模型规定所有的变量存储在主内存中,每条线程有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量和主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中完成,不能直接读写主内存中的变量(下面要说的volatile也不例外)。线程之间也无法直接访问对方工作内存中的变量,传递需要通过主内存完成。
-
访问规则中规定,在并发场景中,需要保证三个特性:
-
可见性
-
有序性
-
原子性
-
2.volatile 讲解
2.1 volatile 保证内存可见性
我们先来看一段代码:
/**
* 商品
*/
public class Goods {
//变量stock代表商品库存
private Integer stock = 1;
//执行库存减一操作
public void decrStock() {
this.stock = this.stock - 1;
System.out.println(Thread.currentThread().getName()+"\t剩余库存:"+stock);
}
public Integer getStock() {
return this.stock;
}
}
public class VolatileDemo {
public static void main(String[] args) {
final Goods goods = new Goods();
new Thread("A") {
public void run() {
try {
TimeUnit.SECONDS.sleep(2);
} catch (Exception e) {
e.printStackTrace();
}
goods.decrStock();
}
}.start();
new Thread("B") {
public void run() {
// 如果库存大于0则一直死等
while (goods.getStock() > 0) {
}
System.out.println(Thread.currentThread().getName() + "\t 现有库存: " + goods.getStock());
}
}.start();
//让出CPU时间片
Thread.yield();
}
}
- 上面的案例中A、B两个线程共同操作goods的stock, 为了演示效果,我这里让main线程主动让出CPU时间片, 主要分析A、B线程的执行。
- 我们的目的是测试在A线程执行了减库存操作后,B线程能不能知道内存变量stock值的改变,当我们运行这段程序会发现,A线程执行减库存操作后,B线程会一直处于死等状态,说明B线程认为stock的值仍是大于0的,并没有及时的知道A线程已经减库存为0的事。 这便是内存可见性的问题场景。
- 假设在并发的场景下出现这种情况,会发生什么问题呢?
- 如果A和B都是执行的购买操作,而当前库存只剩下1个,后果就是库存会产生负值,显然这是不允许的。接下来我们将Goods稍作修改:
/**
* 商品
*/
public class Goods {
//变量stock代表商品库存
private volatile Integer stock = 1;
//执行库存减一操作
public void decrStock() {
this.stock = this.stock - 1;
System.out.println(Thread.currentThread().getName()+"\t剩余库存:"+stock);
}
public Integer getStock() {
return this.stock;
}
}
- 我们给stock使用了volatile进行修饰:
private volatile Integer stock = 1;
- 再次运行测试程序,B线程可以及时知道变量stock被修改了。
小结:这便是volatile的第一个作用,保证内存的可见性。
2.2 volatile 保证有序性 - 防止指令重排
-
为了便于理解我们以生活中的考试为例来说明什么是指令重排: 考试的过程中,我们并不一定会完全按照试卷出题的顺序进行答题,为了保证考试的效果,通常我们会调整答题的顺序,比如把一些较难的题放到最后做,这是一种优化考试答题的手段。
-
同样计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令的执行做重排序。
- 不管是Java程序还是计算机中任何的应用程序,最终都要转成计算机可以直接执行的指令,这些指令就相当于一个个的考试题,而计算机也"比较聪明",他在执行的过程中会调整指令执行的顺序,保证更有效的利用CPU资源,保证程序执行性能。
-
指令重排的依赖性(哪些情况不允许指令重排):
- 数据依赖性,举个例子:
名称 代码示例 说明 写后读 a = 1;b = a; 写一个变量之后,再读这个位置。 写后写 a = 1;a = 2; 写一个变量之后,再写这个变量。 读后写 a = b;b = 1; 读一个变量之后,再写这个变量。
上面的每组指令中都有写操作,这个写操作的位置是不允许变化的,否则将带来不一样的执行结果,此时编译器将不会对存在数据依赖性的程序指令进行重排,这里的依赖性仅仅指单线程情况下的数据依赖性;多线程并发情况下,此规则将失效。
- as-if-serial语义:不管怎么重排序,必须保证单线程程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。
此处参考:https://www.cnblogs.com/tuhooo/p/7921651.html
-
这里我们主要说Java程序编译器中也会存在指令重排的现象,比如:
public class SingletonDemo {
//用于防止指令重排
private static volatile SingletonDemo singletonDemo = null;
private SingletonDemo() {
System.out.println("invoke SingletonDemo Constructor");
}
public static SingletonDemo getInstance() {
// 采用双锁检测机制
if (singletonDemo == null) {
synchronized (SingletonDemo.class) {
if (singletonDemo == null) {
singletonDemo = new SingletonDemo();
}
}
}
return singletonDemo;
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread() {
@Override
public void run() {
SingletonDemo.getInstance();
}
}.start();
}
}
}
- 上面的程序是单例设计模式的实现,在变量singletonDemo使用了volatile修饰,目的是防止指令重排产生的问题,下面我们分析一下为什么要在singletonDemo上使用volatile:
- 首先我们看一下,一个对象的创建过程到给引用赋值的顺序:
- 初始化空间
- 创建对象
- 将对象的空间地址赋值给变量
- 上面的执行顺序,在存在指令重排的场景下,会变为:
- 初始化空间
- 将对象的空间地址赋值给变量
- 创建对象
- 如果在并发的场景下,按照上面重排后的顺序执行,就会产生将一个指向空的内存空间地址的引用返回,影响程序最终的执行结果,严格来讲这是不允许发生的。所以我们使用volatile来防止指令重排产生的问题。
- 首先我们看一下,一个对象的创建过程到给引用赋值的顺序:
关于有序性,这里我们主要了解程序在执行过程中存在指令重排的几率,而这种几率会给程序带来不安全的风险,而使用volatile可以避免这种风险就可以。关于更底层的实现感兴趣的码友钻研一下吧!
2.3 volatile 能保证原子性吗?
- 答案: 不能
- 案例1:
class Account {
private volatile int num = 0;
public int getNum() {
return n;
}
public void addOne() {
n++; // n = n+1;
}
}
public class VolatileTest2 {
public static void main(String[] args) {
final Account account = new Account();
for (int i = 0; i < 10; i++) {
new Thread(){
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
account2.addOne();
}
}
}.start();
}
//确保上面的10个线程执行完毕后 ,打印n的结果
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(account.getNum());
}
}
多次运行结果是:
第一次:75103 第二次:69025 第三次:80708
数据丢失了!(思考为什么呢?)
- 案例继续:
class Account {
private volatile int num = 0;
public int getNum() {
return n;
}
public synchronized void addOne() {
n++; // n = n+1;
}
}
加上synchronized, 问题解决!
- 因为i++操作并不是原子性的,执行是需要先读取变量值到操作数栈、执行运算、写回主内存。 而volatile是无法保证原子操作的,所以第一段程序的结果是不对的。而synchronized是可以保证原子性的。
总结:volatile的主要作用是保证可见性、有序性。
本文参考:《深入理解Java虚拟机——JVM高级特性与最佳实践(第2版)》 共同学习,不当之处请批评指正!