一.volatile简介
单线程的环境中,基本用不到volatile这个关键字,但是在多线程环境中,这个关键字随处可见,总的来说它有三个特性:
- 可见性
- 有序性
- 不保证原子性
二.如何保证可见性
1.何为可见性?
在说volatile的可见性之前,先说说什么是可见性,谈到可见性,就得先说说JMM(java memory model)内存模型,JMM内存模型模型是逻辑上的划分,其实并不是真实存在的。Java线程之间的通信就由JMM控制,JMM决定一个线程对共享变量 的写入何时对另一个线程可见。从抽象的角度看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memeory)中,每个线程都有有个私有的本地内存(local memory),本地内存中存储了该线程已读/写共享变量的副本。JMM的抽象图如下:
从上图来看,线程A和线程B之间要进行通信的话,必须要经过两步:
- 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去;
- 其次,线程B到主内存中去读取线程A之前已更新过的共享变量。
如上图所示,我们定义的共享变量,是存储在主内存中的,也就是计算机的内存条中,线程A去操作共享变量的时候,并不是直接操作主内存中的值,而是将主内存中的值拷贝回自己的本地内存中,在本地内存中做修改,修改好后,在将值刷新到主内存中。
假设现在要new 一个person,age是10,这个10是存储在主内存中的,现在两个线程现将10拷贝到自己的本地工作内存中,这时,A线程将10改成了20,然后刷回到主内存中,现在主内存中的值变成了20,。可是B线程并不知道现在主内存中的值改变了,因为A线程所做的操作对B线程是不可见的。我们需要一种机制,即一旦主内存中值发生了改变,就及时通知所有的线程,保证他们对这个变化可见,这就是可见性。
2.为什么volatile能保证可见性?
当变量用volatile修饰时,将会在写操作的后面加一条屏障指令(cpu指令,可以影响数据的可见性),在读操作的前面加一条屏障指令,这样一旦写入完成,就可以保证其他线程读到的是最新值,也就保证了可见性。
3.验证可见性的代码:
public class MyData {
/**
* 没加volatile关键字
*/
int num = 0;
/**
* 加volatile关键字
*/
//volatile int num = 0;
public int changeNum() {
return this.num = 10;
}
}
public class VolatileTest {
public static void main(String[] args) {
MyData myData = new MyData();
new Thread("A") {
public void run() {
try {
Thread.sleep(3000);
// 睡3秒后调用changeNum方法将num改为10
System.err.println(Thread.currentThread().getName() + " update num to " + myData.changeNum());
} catch (InterruptedException e) {
e.printStackTrace();
}
};
}.start();
// 主线程
while (myData.num == 0) {
System.err.println("不可见性发生,陷入死循环中。。。");
}
// 如果主线程读取到的一直都是最开始的0,将造成死循环,这句话将无法输出
System.err.println(Thread.currentThread().getName() + " get num value is " + myData.num);
}
}
以上代码很简单,就是定义一个MyData类,初始化一个num,值为0,然后在main方法中创建另一个线程,将其值改为10,但是这个线程对num的所有操作对main线程是不可见的,所以main线程以为num还是0,因此就会操作死循环.如果num添加了volatile关键字修饰,main线程就会回去到主内存中的最新值,就不会陷入死循环中,这就验证了volatile可以保证可见性.
二.有序性
1.什么叫指令重排?
使用javap命令可以对class文件进行反汇编,可以查看程序底层是如何执行的,像i++这样一个简单地操作,底层会分三步执行,在多线程的情况下,计算机为了提高执行效率,就会对步骤进行重新排序,这个叫指令重排,比如代码:
int a =1;
int b=2;
a=a+3;
b=a+4;
这四句语句,正常的执行顺序就是从上到下依次执行,a的结果是4,b的结果是8,但是在多线程的环境中,编译器指令重排后,执行的顺序就可能变成了1243,这样得出的结果a是4,b是5,这样显然是不正确了.不过编译器在重排的时候也会考虑数据的依赖性,比如执行顺序不可能是2413,因为第四句是依赖a的,使用volatile修饰就会禁止指令重排,让其安装从上到下的顺序执行.
三.不保证原子性
1.volatile不保证原子性解析?
所谓原子性就是一个操作不可能被分割或加塞,要么全部执行要么全部不执行;
java程序在运行时,JVM将java文件编译成了class文件,我们使用javap命令对class文件进行反汇编就可以查看java编译器生成的字节码,最常见的i++问题,其实反汇编后是分三步进行的;
- 将i的初始化值装载进工作内存;
- 在自己的工作内存中进行自增操作;
- 将自己工作内存中的值刷回到主内存;
我们知道线程的执行具有随机性,假设现在i值是0,有A和B两个线程对其进行++操作,首先两个线程将0拷贝到自己的工作内存中,当线程A在自己的工作内存中进行了加一操作变成了1,还没来得及把1刷回到主内存中,这时B线程抢到cpu执行权了,B将自己工作内存中的0进行自增也变成了1,然后A线程将1刷回到主内存中,这时主内存中的值已经变成了1,然后B线程也将1刷回到主内存,主内存中的值还是1,。本来A和B都对i进行了自增,此时主内存中的值应该是2,但是确是1,出现了写丢失的情况。这是因为i++是一个原子操作,但是却被加赛了其他操作,所以说volatile不保证原子性。
2.volatile不保证原子性代码验证?
public class MyData {
volatile int num=0;
//验证volatile不保证原子性
void addPlus() {
this.num++;
}
//验证volatile不保证原子性
public static void main(String[] args) {
MyData data1=new MyData();
for (int i = 0; i <15; i++) {
new Thread("线程"+i) {
public void run() {
try {
for (int j = 0; j < 1000; j++) {
data1.addPlus();
}
} catch (Exception e) {
e.printStackTrace();
}
};
}.start();
}
//保证上面的线程执行完mian线程再输出结果,大于2,因为默认有main线程和gc线程,
//使正在运行中的线程重新变成就绪状态,并重新竞争 CPU 的调度权。它可能会获取到,也有可能被其他线程获取到
while (Thread.activeCount()>2) {
Thread.yield();
}
System.err.println(Thread.currentThread().getName()+"The Num is :"+data1.num);//mainThe Num is :13341或者其他
}
}
案例是有一个volatile修饰的num=0,现在有15个线程,每个线程对其执行1000次自增操作,理论上执行完,main线程输入的结果应该是15000,但是运行之后发现,每次运行的结果都会小于15000,这就是出现了写丢失的情况。
如何解决呢?
- 可以在addPlus方法上添加synchronized;
synchronized void addPlus() {
this.num++;
}
- 可以使用原子包装类AtomicInteger;
//volatile int num=0;
private AtomicInteger num=new AtomicInteger(0);
//验证volatile不保证原子性
void addPlus() {
this.num.getAndIncrement();
}
但是添加Synchronized的方法太重量级了,整个方法都加锁了,第二种比较好,因为AtomicInteger 使用的是CAS算法。
四、哪些地方用到过volatile?
- 最简单的单例模式
饿汉式
/**
* 饿汉式(一上来就创建对象)
* 优点:线程安全,类加载就是初始化,每次都创建对象
* 缺点:容易产生垃圾对象,浪费内存
* @author pc
*
*/
public class SingletonHungry {
private static SingletonHungry singletonHungry=new SingletonHungry();
private SingletonHungry() {
System.err.println("const method done...");
}
public static SingletonHungry getInstance() {
return singletonHungry;
}
}
懒汉式
/**
* 懒汉式(用的时候创建对象)
* 优点:第一次调用才初始化,避免了内存浪费
* 缺点:必须加锁synchronized才能保证单例,但加锁影响效率;
* @author pc
*
*/
public class SingletonLazy {
private static SingletonLazy singleton=null;
private SingletonLazy() {
System.err.println("const method done...");
}
public static SingletonLazy getInstance() {
if(singleton==null) {
singleton=new SingletonLazy();
}
return singleton;
}
}
懒汉式看似很完美,但是多线程的环境下,会出现线程安全问题,测试下:
public static void main(String[] args) {
for (int i = 0; i <20; i++) {
new Thread(()->SingletonLazy.getInstance()).start();
}
}
运行结果:
运行的结果就是打印了5次构造方法,说明创建了5个对象,所以在多线程的环境下这个单例模式是有问题的,可以在方法上添加Synchronized关键字,可以避免,但是会使整个方法加锁,不太好;下面说说双重加锁版单例模式。
- DCL版单例(doubled-checked locking)/双重锁:所谓双端检索,就是在加锁前和加锁后都用进行一次判断,代码如下
/**
* 双锁机制
* 安全且在多线程情况下能保持高性能,getInstance() 的性能对应用程序很关键
* 优点:线程安全,延迟加载,效率高
* @author pc
*
*/
public class SingletonDCL {
private volatile static SingletonDCL singleton=null;
private SingletonDCL() {
System.err.println("const method done...");
}
public static SingletonDCL getInstance() {
//第一次check
if(singleton==null) {
synchronized (SingletonDCL.class) {
//第二次check
if(singleton==null) {
singleton=new SingletonDCL();
}
}
}
return singleton;
}
}
用synchronized只锁住创建实例部分代码,在加锁前后分别进行判断,确实只创建了一个对象,但是volatile关键字还是要加上的,singleton 采用 volatile 关键字修饰也是很有必要的,因为 singleton = new SingletonDCL(); 这段代码其实是分为三步执行:
1.为 singleton 分配内存空间(预定房间)
2.初始化 singleton(打扫房间)
3.将 singleton 指向分配的内存地址(入住,此时这个对象部位null)
但是由于JVM具有指令重排的特性,执行顺序有可能变成1、3、2,指令重排在单线程的情况下是没有问题的,但是在多线程的环境下会导致一个线程获得还没有初始化的实例,例如,线程A执行了1、3,此时B线程调用getIUniqueInstance()后发现对象不为空,因为返回,但是此时此对象还没有初始化,使用volatile可以禁止JVM的指令重排,保证多线程环境下正常运行。
- 枚举版单例(最终版)
public enum Singleton {
INSTANCE;
public void whateverMethod() {
}
public Singleton getInstance() {
return INSTANCE;
}
public static void main(String[] args) {
Singleton instance1 = Singleton.INSTANCE;
Singleton instance2 = Singleton.INSTANCE;
Singleton instance3= Singleton.INSTANCE;
}
}
优点:它不仅能避免多线程同步问题,而且还自动支持序列化机制,防止反序列化重新创建新的对象,绝对防止多次实例化。
缺点:当想实例化一个单例类的时候,必须要记住使用相应的获取对象的方法,而不是使用new。