面试时很可能遇到这样一个问题:使用volatile修饰int型变量i,多个线程同时进行i++操作,这样可以实现线程安全吗?提到线程安全、线程同步,我们经常会想到两个关键字:volatile和synchronized,那么这两者有什么区别呢?
一,volatile关键字的可见性
要想理解volatile关键字,得先了解下JAVA的内存模型,Java内存模型的抽象示意图如下:
从图中可以看出:
①每个线程都有一个自己的本地内存空间--线程栈空间???线程执行时,先把变量从主内存读取到线程自己的本地内存空间,然后再对该变量进行操作
②对该变量操作完后,在某个时间再把变量刷新回主内存
关于JAVA内存模型,更详细的可参考: 深入理解Java内存模型(一)——基础
因此,就存在内存可见性问题,看一个示例程序:(摘自书上)
1 public class RunThread extends Thread { 2 3 private boolean isRunning = true; 4 5 public boolean isRunning() { 6 return isRunning; 7 } 8 9 public void setRunning(boolean isRunning) { 10 this.isRunning = isRunning; 11 } 12 13 @Override 14 public void run() { 15 System.out.println("进入到run方法中了"); 16 while (isRunning == true) { 17 } 18 System.out.println("线程执行完成了"); 19 } 20 } 21 22 public class Run { 23 public static void main(String[] args) { 24 try { 25 RunThread thread = new RunThread(); 26 thread.start(); 27 Thread.sleep(1000); 28 thread.setRunning(false); 29 } catch (InterruptedException e) { 30 e.printStackTrace(); 31 } 32 } 33 }
Run.java 第28行,main线程 将启动的线程RunThread中的共享变量设置为false,从而想让RunThread.java 第14行中的while循环结束。
如果,我们使用JVM -server参数执行该程序时,RunThread线程并不会终止!从而出现了死循环!!
原因分析:
现在有两个线程,一个是main线程,另一个是RunThread。它们都试图修改 第三行的 isRunning变量。按照JVM内存模型,main线程将isRunning读取到本地线程内存空间,修改后,再刷新回主内存。
而在JVM 设置成 -server模式运行程序时,线程会一直在私有堆栈中读取isRunning变量。因此,RunThread线程无法读到main线程改变的isRunning变量
从而出现了死循环,导致RunThread无法终止。这种情形,在《Effective JAVA》中,将之称为“活性失败”
解决方法,在第三行代码处用 volatile 关键字修饰即可。这里,它强制线程从主内存中取 volatile修饰的变量。
volatile private boolean isRunning = true;
扩展一下,当多个线程之间需要根据某个条件确定 哪个线程可以执行时,要确保这个条件在 线程 之间是可见的。因此,可以用volatile修饰。
综上,volatile关键字的作用是:使变量在多个线程间可见(可见性)
二,volatile关键字的非原子性
所谓原子性,就是某系列的操作步骤要么全部执行,要么都不执行。
比如,变量的自增操作 i++,分三个步骤:
①从内存中读取出变量 i 的值
②将 i 的值加1
③将 加1 后的值写回内存
这说明 i++ 并不是一个原子操作。因为,它分成了三步,有可能当某个线程执行到了第②时被中断了,那么就意味着只执行了其中的两个步骤,没有全部执行。
关于volatile的非原子性,看个示例:
1 public class MyThread extends Thread { 2 public volatile static int count; 3 4 private static void addCount() { 5 for (int i = 0; i < 100; i++) { 6 count++; 7 } 8 System.out.println("count=" + count); 9 } 10 11 @Override 12 public void run() { 13 addCount(); 14 } 15 } 16 17 public class Run { 18 public static void main(String[] args) { 19 MyThread[] mythreadArray = new MyThread[100]; 20 for (int i = 0; i < 100; i++) { 21 mythreadArray[i] = new MyThread(); 22 } 23 24 for (int i = 0; i < 100; i++) { 25 mythreadArray[i].start(); 26 } 27 } 28 }
MyThread类第2行,count变量使用volatile修饰
Run.java 第20行 for循环中创建了100个线程,第25行将这100个线程启动去执行 addCount(),每个线程执行100次加1
期望的正确的结果应该是 100*100=10000,但是,实际上count并没有达到10000
原因是:volatile修饰的变量并不保证对它的操作(自增)具有原子性。(对于自增操作,可以使用JAVA的原子类AutoicInteger类保证原子自增)
比如,假设 i 自增到 5,线程A从主内存中读取i,值为5,将它存储到自己的线程空间中,执行加1操作,值为6。此时,CPU切换到线程B执行,从主从内存中读取变量i的值。由于线程A还没有来得及将加1后的结果写回到主内存,线程B就已经从主内存中读取了i,因此,线程B读到的变量 i 值还是5
相当于线程B读取的是已经过时的数据了,从而导致线程不安全性。这种情形在《Effective JAVA》中称之为“安全性失败”
综上,仅靠volatile不能保证线程的安全性。(原子性)
此外,volatile关键字修饰的变量不会被指令重排序优化。这里以《深入理解JAVA虚拟机》中一个例子来说明下自己的理解:
线程A执行的操作如下:
Map configOptions ;
char[] configText;
volatile boolean initialized = false;
//线程A首先从文件中读取配置信息,调用process...处理配置信息,处理完成了将initialized 设置为true
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfig(configText, configOptions);//负责将配置信息configOptions 成功初始化
initialized = true;
线程B等待线程A把配置信息初始化成功后,使用配置信息去干活.....线程B执行的操作如下:
while(!initialized)
{
sleep();
}
//使用配置信息干活
doSomethingWithConfig();
如果initialized变量不用 volatile 修饰,在线程A执行的代码中就有可能指令重排序。
即:线程A执行的代码中的最后一行:initialized = true 重排序到了 processConfig方法调用的前面执行了,这就意味着:配置信息还未成功初始化,但是initialized变量已经被设置成true了。那么就导致 线程B的while循环“提前”跳出,拿着一个还未成功初始化的配置信息去干活(doSomethingWithConfig方法)。。。。
因此,initialized 变量就必须得用 volatile修饰。这样,就不会发生指令重排序,也即:只有当配置信息被线程A成功初始化之后,initialized 变量才会初始化为true。综上,volatile 修饰的变量会禁止指令重排序(有序性)
三,volatile 与 synchronized 的比较
volatile主要用在多个线程感知实例变量被更改了场合,从而使得各个线程获得最新的值。它强制线程每次从主内存中讲到变量,而不是从线程的私有内存中读取变量,从而保证了数据的可见性。
关于synchronized,可参考:JAVA多线程之Synchronized关键字--对象锁的特点
比较:
①volatile轻量级,只能修饰变量。synchronized重量级,还可修饰方法
②volatile只能保证数据的可见性,不能用来同步,因为多个线程并发访问volatile修饰的变量不会阻塞。
synchronized不仅保证可见性,而且还保证原子性,因为,只有获得了锁的线程才能进入临界区,从而保证临界区中的所有语句都全部执行。多个线程争抢synchronized锁对象时,会出现阻塞。
四,线程安全性
线程安全性包括两个方面,①可见性。②原子性。
从上面自增的例子中可以看出:仅仅使用volatile并不能保证线程安全性。而synchronized则可实现线程的安全性。
总结如下:
1. volatile修饰的变量具有可见性
volatile是变量修饰符,其修饰的变量具有可见性。
可见性也就是说一旦某个线程修改了该被volatile修饰的变量,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,可以立即获取修改之后的值。
在Java中为了加快程序的运行效率,对一些变量的操作通常是在该线程的寄存器或是CPU缓存上进行的,之后才会同步到主存中,而加了volatile修饰符的变量则是直接读写主存。
例子请查看下面的3.1,帮助理解。
2. volatile禁止指令重排
volatile可以禁止进行指令重排。
指令重排是指处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证各个语句的执行顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。
程序执行到volatile修饰变量的读操作或者写操作时,在其前面的操作肯定已经完成,且结果已经对后面的操作可见,在其后面的操作肯定还没有进行。
例子请查看下面3.2,帮助理解。
3. synchronized
synchronized可作用于一段代码或方法,既可以保证可见性,又能够保证原子性。
可见性体现在:通过synchronized或者Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存中。
原子性表现在:要么不执行,要么执行到底。
例子请查看下面3.3,帮助理解。
2. 总结
(1)从而我们可以看出volatile虽然具有可见性但是并不能保证原子性。
(2)性能方面,synchronized关键字是防止多个线程同时执行一段代码,就会影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized。
但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。
3. volatile与synchronized的使用场景举例(结合第1部分进行理解学习)
3.1 volatile的使用举例
- class MyThread extends Thread {
- private volatile boolean isStop = false;
- public void run() {
- while (!isStop) {
- System.out.println("do something");
- }
- }
- public void setStop() {
- isStop = true;
- }
- }
线程执行run()的时候我们需要在线程中不停的做一些事情,比如while循环,那么这时候该如何停止线程呢?如果线程做的事情不是耗时的,那么只需要使用一个标志即可。如果需要退出时,调用setStop()即可。这里就使用了关键字volatile,这个关键字的目的是如果修改了isStop的值,那么在while循环中可以立即读取到修改后的值。
如果线程做的事情是耗时的,那么可以使用interrupt方法终止线程 。如果在子线程“睡觉”时被interrupt,那么子线程可以catch到InterruptExpection异常,处理异常后继续往下执行。
3.2 volatile的使用举例
- //线程1:
- context = loadContext(); //语句1 context初始化操作
- inited = true; //语句2
- //线程2:
- while(!inited ){
- sleep()
- }
- doSomethingwithconfig(context);
因为指令重排序,有可能语句2会在语句1之前执行,可能导致context还没被初始化,而线程2中就使用未初始化的context去进行操作,导致程序出错。
这里如果用volatile关键字对inited变量进行修饰,就不会出现这种问题了。
3.3 必须使用synchronized而不能使用volatile的场景
- 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()>1) //保证前面的线程都执行完
- Thread.yield();
- System.out.println(test.inc);
- }
- }
例子中用new了10个线程,分别去调用1000次increase()方法,每次运行结果都不一致,都是一个小于10000的数字。自增操作不是原子操作,volatile 是不能保证原子性的。回到文章一开始的例子,使用volatile修饰int型变量i,多个线程同时进行i++操作。比如有两个线程A和B对volatile修饰的i进行i++操作,i的初始值是0,A线程执行i++时刚读取了i的值0,就切换到B线程了,B线程(从内存中)读取i的值也为0,然后就切换到A线程继续执行i++操作,完成后i就为1了,接着切换到B线程,因为之前已经读取过了,所以继续执行i++操作,最后的结果i就为1了。同理可以解释为什么每次运行结果都是小于10000的数字。
但是使用synchronized对部分代码进行如下修改,就能保证同一时刻只有一个线程获取锁然后执行同步代码。运行结果必然是10000。
- public int inc = 0;
- public synchronized void increase() {
- inc++;
- }