问:谈谈对volatile的理解?
当用volatile去申明一个变量时,就等于告诉虚拟机,这个变量极有可能会被某些程序或线程修改。为了确保这个变量修改后,应用范围内所有线程都能知道这个改动,虚拟机就要保证这个变量的可见性等特点。最简单的一种方法就是加入volatile关键字。
volatile是JVM提供的轻量级的同步机制。
volatile有三大特性:
- 保证可见性
- 不保证原子性
- 禁止指令重排
要了解它的三大特性,要先了解JMM。
JMM——Java内存模型
- 由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存,工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储到主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作必须在工作内存中进行。
- 首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。
上面提到的概念 主内存 和 工作内存:
- 主内存:就是计算机的内存。主要包括【本地方法区】和【堆】。
- 工作内存:当同时有三个线程同时访问student对象的age变量时,那么每个线程都会拷贝一份,到各自的工作内存。主要包括该线程私有的【栈】等。
如何保证可见性?
用代码验证volatile的可见性:
class MyData {
// 定义int变量
int number = 0;
// 添加方法把变量 修改为 60
public void addTo60() {
this.number = 60;
}
}
public class Test {
public static void main(String[] args) {
// 资源类
MyData myData = new MyData();
// 用lambda表达式创建线程
new Thread(() -> {
System.out.println("线程进来了");
// 线程睡眠三秒,假设在进行运算
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 修改number的值
myData.addTo60();
// 输出修改后的值
System.out.println("线程更新了number的值为" + myData.number);
}).start();
// main线程就一直在这里等待循环,直到number的值不等于零
while (myData.number == 0) {
}
//最后输出这句话,看是否跳出了上一个循环
System.out.println("main方法结束了");
}
}
最后线程没有停止,没有输出 main方法结束了 这句话,说明没有用volatile修饰的变量,是没有可见性的。
当我们给变量 number 添加volatile关键字修饰时,发现可以成功输出结束语句。
volatile 修饰的关键字,是为了增加主线程和线程之间的可见性,只要有一个线程修改了内存中的值,其它线程也能马上感知,是具备JVM轻量级同步机制的。
- volatile保证可见性用到了总线嗅探技术。
总线嗅探技术有哪些缺点:
- 由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和CAS循环,无效的交互会导致总线带宽达到峰值。因此不要大量使用volatile关键字,根据实际应用场景选择。
Volatile不保证原子性
什么是原子性?
不可分割,完整性。也就是说某个线程正在做某个具体业务时,中间不可以被加塞或者被分割,需要具体完成,要么同时成功,要么同时失败。
代码证明volatile不保证原子性
class MyData {
// 定义int变量
volatile int number = 0;
public void addPlusPlus() {
number++;
}
}
public class Test {
public static void main(String[] args) {
MyData myData = new MyData();
// 创建20个线程,线程里面进行1000次循环(20*1000=20000)
for (int i = 0; i < 20; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
myData.addPlusPlus();
}
}).start();
}
/*
需要等待上面20个线程都执行完毕后,再用main线程取得最终的结果
这里判断线程数是否大于2,为什么是2?因为默认有两个线程的,一个main线程,一个gc线程
*/
while (Thread.activeCount() > 2) {
Thread.yield(); // yield表示不执行
}
System.out.println("线程运行完后,number的值为:" + myData.number);
}
}
线程执行完毕后,number输出的值并没有 20000,而是每次运行的结果都不一致,这说明了volatile修饰的变量不保证原子性。
为什么会出现数据丢失?
当 线程A 和 线程B 同时修改各自工作空间里的内容,由于可见性,需要将修改的值写入主内存。这就导致多个线程出现同时写入的情况,线程A 写的时候,线程B 也在写入,导致其中的一个线程被挂起,其中一个线程覆盖了另一个线程的值,造成了数据的丢失。
i++是原子操作吗?
i++不是原子操作,其执行要分为三步:
- 读内存到寄存器
- 在寄存器内自增
- 写回内存
举个例子:现在有A、B两个线程,i 初始为 2。A线程完成第二步的加一操作后,被切换到B线程,B线程中执行完这三步后,再切换回来。此时A寄存器中的 i=3 写回内存,最后 i 的值不是正常的4。
如果解决原子性的问题?
- 在方法上加上
synchronized
public synchronized void addPlusPlus() {
number ++;
}
引入synchronized关键字后,保证了该方法每次只能够一个线程进行访问和操作,保证最后输出的结果。
- AtomicInteger
我们还可以使用JUC下面的原子包装类,i++
可以使用AtomicInteger
来代替
//创建一个原子Integer包装类,默认为0
AtomicInteger number = new AtomicInteger();
public void addAtomic(){
number.getAndIncrement(); //相当于number++
}
Volatile禁止指令重排
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令重排,一般分为以下三种:
源代码 -> 编译器优化的重排 -> 指令并行的重排 -> 内存系统的重排 -> 最终执行指令。
多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确认的,结果无法预测。
举一个指令重排的例子
public void mySort() {
int x = 11;
int y = 12;
x = x + 5;
y = x * x;
}
按照正常单线程环境,执行顺序是1234。
但是在多线程环境中,可能出现以下的顺序:2134、1324。
但是指令排序也是有限制的,例如3不能出现在1面前,因为3需要依赖步骤1的声明,存在数据依赖。
Volatile针对指令重排做了啥?
Volatile实现禁止指令重排优化,从而避免了多线程环境下程序出现乱序执行的现象。
首先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:
- 保证特定操作的顺序
- 保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)
在Volatile的写和读的时候,加入屏障,防止出现指令重排,线程安全获得保障。
Volatile的应用
- 单线程下的单例模式代码(懒汉,适用于单线程)
public class SingletonDemo {
//用静态变量保存这个唯一的实例
private static SingletonDemo instance = null;
//构造器私有化
private SingletonDemo() {
}
//提供一个静态方法,来获取实例对象
public static SingletonDemo getInstance() {
if (instance == null) {
instance = new SingletonDemo();
}
return instance;
}
}
单线程下创建出来的都是同一个对象。但是在多线程的环境下,我们通过SingletonDemo.getInstance()
获取到的对象,并不是同一个。
- 1、方法上引入synchronized
public synchronized static SingletonDemo getInstance() {
if (instance == null) {
instance = new SingletonDemo();
}
return instance;
}
但是synchronizaed属于重量级的同步机制,它只允许一个线程同时访问获取实例的方法,但是因此减低了并发性,因此采用的比较少。
- 2、引入DCL双端检锁机制
就是在 进来、出去 的时候,进行检测。
public static SingletonDemo getInstance() {
if (instance == null) {
synchronized (SingletonDemo.class) {
if (instance == null) {
instance = new SingletonDemo();
}
}
}
return instance;
}
但是DCL机制不一定是线程安全的,原因是因为有指令重排的存在,我们加入Volatile可以禁止指令重排。
private static volatile SingletonDemo instance = null;
因为instance的获取可以分为三步进行完成:
- 分配对象内存空间
- 初始化对象
- 设置instance指向刚刚分配的内存地址,此时
instance != null
因为步骤2、3不存在数据依赖,即可能出现第三步先于第二步执行;此时因为已经给即将创建的instance分配了内存空间,所以instance!=null,但对象的初始化还未完成,造成了线程的安全问题。
-
题外话:单例模式双重校验的目的
去掉第一个判断为空:即懒汉式(线程安全),这会导致所有线程在调用getInstance()方法的时候,直接排队等待同步锁,然后等到排到自己的时候进入同步处理时,才去校验实例是否为空,这样子做会耗费很多时间(即线程安全,但效率低下)。
去掉第二个判断为空:即懒汉式(线程不安全),这会出现 线程A先执行了getInstance()方法,同时线程B在因为同步锁而在外面等待,等到A线程已经创建出来一个实例出来并且执行完同步处理后,B线程将获得锁并进入同步代码,如果这时B线程不去判断是否已经有一个实例了,然后直接再new一个。这时就会有两个实例对象,即破坏了设计的初衷。(即线程不安全,效率高)
双重校验的目的:除了第一次实例化需要进行加锁同步,之后的线程只要进行第一层的if判断不为空即可直接返回,而不用每一次获取单例都加锁同步,因此相比前面两种懒汉式,双重检验锁更佳。(双重校验锁结合了 两种懒汉式 的优点)
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。