面试中如何回答volitile关键字
一,什么是JMM?
JMM是一系列的Java虚拟机平台对开发者提供的多线程环境下的内存可见性、是否可以重排序等问题的无关具体平台的统一的保证。值得注意的是,JMM是一个抽象概念,不是真实存在的。
JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行。
二,讲讲缓存不一致的问题。
使用工作内存和主存,虽然加快的速度,但是也带来了一些问题。
比如
i = i + 1;
假设i初值为0,当只有一个线程执行它时,结果肯定得到1,当两个线程执行时,会得到结果2吗?这倒不一定了。可能存在这种情况:
线程1: load i from 主存 // i = 0
i + 1 // i = 1
线程2: load i from主存 // 因为线程1还没将i的值写回主存,所以i还是0
i + 1 //i = 1
线程1: save i to 主存
线程2: save i to 主存
如果两个线程按照上面的执行流程,那么i最后的值居然是1了。如果最后的写回生效的慢,你再读取i的值,都可能是0,这就是缓存不一致问题。
JMM主要就是围绕着如何在并发过程中如何处理原子性、可见性和有序性这3个特征来建立的,通过解决这三个问题,可以解除缓存不一致的问题。而volatile跟可见性和有序性都有关。
三,volatile关键字有什么作用?
1、保证被volatile修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总数可以被其他线程立即得知。也可以简单的说,保证了不同线程对该变量操作的内存可见性。
2、禁止指令重排序优化。
四,讲讲三个特性,原子性、可见性和有序性。
解释原子性。Java中,对基本数据类型的读取和赋值操作是原子性操作,所谓原子性操作就是指这些操作是不可中断的,要做一定做完,要么就没有执行。比如:
i = 2;
j = i;
i++;
i = i + 1;
上面4个操作中,i=2
是读取操作,必定是原子性操作。
j=i
分为两步,一是读取i的值,然后再赋值给j,这就是2步操作了,称不上原子操作。
i++
和i = i + 1
其实是等效的,读取i的值,加1,再写回主存,那就是3步操作了。所以上面的举例中,最后的值可能出现多种情况,就是因为满足不了原子性。
这么说来,只有简单的读取,赋值是原子操作,还只能是用数字赋值,用变量的话还多了一步读取变量值的操作。有个例外是,虚拟机规范中允许对64位数据类型(long和double),分为2次32为的操作来处理,但是最新JDK实现还是实现了原子操作的。
JMM只实现了基本的原子性,像上面i++
那样的操作,必须借助于synchronized
和Lock
来保证整块代码的原子性了。线程在释放锁之前,必然会把i
的值刷回到主存的。
解释内存可见性。
可见性指的是当一个线程修改了某个共享变量的值,其他线程能够马上得知这个修改的值。Java就是利用volatile来提供可见性的。
当一个变量被volatile修饰时,那么对它的修改会立刻刷新到主存,当其它线程需要读取该变量时,会去内存中读取新值。而普通变量则不能保证这一点。
其实通过synchronized和Lock也能够保证可见性,线程在释放锁之前,会把共享变量值都刷回主存,但是synchronized和Lock的开销都更大。
解释有序性。JMM是允许编译器和处理器对指令重排序的,但是规定了as-if-serial语义,即不管怎么重排序(单线程的情况下),程序的执行结果不能改变。比如下面的程序段:
double pi = 3.14; //A
double r = 1; //B
double s= pi * r * r;//C
上面的语句,可以按照A->B->C执行,结果为3.14,但是也可以按照B->A->C的顺序执行,因为A、B是两句独立的语句,而C则依赖于A、B,所以A、B可以重排序,但是C却不能排到A、B的前面。JMM保证了重排序不会影响到单线程的执行,但是在多线程中却容易出问题。
比如以下代码。
int a = 0;
bool flag = false;
public void write() {
a = 2; //1
flag = true; //2
}
public void multiply() {
if (flag) { //3
int ret = a * a;//4
}
}
如果我们启动两个线程,1线程执行write方法,2线程执行multiply方法,由于指令重排序,最后的结果却不一定是4。
1线程先执行flag=true的操作,随后线程2切入执行,最终a的值变为2。
如果我们给flag加上volatile来修饰,就可以避免指令重排序。
需要知道的是,JMM具备一些先天的有序性,即不需要通过任何手段就可以保证的有序性,通常称为happens-before原则。关于这个,可以参考
五,volatile能否保证内存可见性?
可以,如果一个变量声明成是volatile的,那么当我读变量时,总是能读到它的最新值,这里最新值是指不管其它哪个线程对该变量做了写操作,都会立刻被更新到主存里,我也能从主存里读到这个刚写入的值。也就是说volatile关键字可以保证可见性。
六,volatile能否保证有序性?
可以,依然可以用上面的代码来说明。
int a = 0;
bool flag = false;
public void write() {
a = 2; //1
flag = true; //2
}
public void multiply() {
if (flag) { //3
int ret = a * a;//4
}
}
假设线程1先执行write操作,线程2再执行multiply操作,由于线程1是在工作内存里把flag赋值为1,不一定立刻写回主存,所以线程2执行时,multiply再从主存读flag值,仍然可能为false,那么括号里的语句将不会执行。
给flag加上volatile修饰。
int a = 0;
volatile bool flag = false;
public void write() {
a = 2; //1
flag = true; //2
}
public void multiply() {
if (flag) { //3
int ret = a * a;//4
}
}
线程1先执行write,线程2再执行multiply。根据happens-before原则,这个过程会满足以下3类规则:
程序顺序规则:1 happens-before 2; 3 happens-before 4; (volatile限制了指令重排序,所以1 在2 之前执行)
volatile规则:2 happens-before 3
传递性规则:1 happens-before 4
七,volatile能否保证原子性?
不能。volatile只是对单个volatile变量的读/写具有原子性,但是我们都知道i++操作是一个复合操作,这个变量如果用volatile修饰,也不能使i++变成一个原子性操作。
比如以下代码。
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>2) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
现在我们进行10000次inc++的操作,如果多次运行程序,可以发现,结果有时候并不是10000,虽然volatile修饰保证了inc变量的内存可见性,而inc++是一个复合操作,包括读取inc的值,对其自增,然后再写回主存。
假设线程A读取inc值为10时,线程A被暂时阻塞,这时候线程B读取inc值也为10,线程B对inc进行自增操作,并把结果写回主存,这时候才轮到线程A对inc进行自增操作,然后写回主存,11又被写了一次。两个线程对inc++操作,但是结果并不是+2。
虽然volatile使线程的本地内存缓存无效,但是线程A读取inc时,线程B还没有对inc进行修改,也就是说缓存无效的认定条件是,线程进行读取操作时,必须是本地内存的值无效,才会从主存刷新值。
八,写一下volatile的一些常见用法。
①用volatile修饰一个状态量。
int a = 0;
volatile bool flag = false;
public void write() {
a = 2; //1
flag = true; //2
}
public void multiply() {
if (flag) { //3
int ret = a * a;//4
}
}
②单例模式的实现,典型的双重检查锁定
先写一个没有用volatile修饰的uniqueInstance
public class Singleton {
private static Singleton uniqueInstance;
private Singleton(){
}
public static Singleton getInstance(){
if(uniqueInstance == null){ //#1
synchronized(Singleton.class){ //#2
if(uniqueInstance == null){ //#3
uniqueInstance = new Singleton(); //#4
System.out.println(Thread.currentThread().getName() + ": uniqueInstance is initalized..."); //#5.1
} else {
System.out.println(Thread.currentThread().getName() + ": uniqueInstance is not null now..."); //#5.2
}
}
}
return uniqueInstance;
}
}
public class TestSingleton {
public static void main(final String[] args) throws InterruptedException {
for (int i = 1; i <= 100000; i++) {
final Thread t1 = new Thread(new ThreadSingleton());
t1.setName("thread" + i);
t1.start();
}
}
public static class ThreadSingleton implements Runnable {
@Override
public void run() {
Singleton.getInstance();
}
}
}
uniqueInstance = new Singleton(); //#4
在这句代码中,其实不是一个原子性操作。
事实上在 JVM 中这句话大概做了下面 3 件事情:
1.给 instance 分配内存
2.调用 Singleton 的构造函数来初始化成员变量
3.将instance对象指向分配的内存空间(执行完这步 instance 就为非 null 了)。
但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错。
参考:
https://www.cnblogs.com/damonhuang/p/5431866.html