這篇文章為大家帶來了關於java的相關知識,其中主要整理了volatile的相關問題,包括了volatile保證可見性、volatile不保證原子性、volatile禁止指令重排等等內容,下面一起來看一下,希望對大家有幫助。
推薦學習:《java影片教學》
問:請談談你對volatile的理解?
答:volatile是Java虛擬機器提供的輕量級的同步機制,它有3個特性:
1)保證可見性
# 2)不保證原子性
3)禁止指令重排
剛學完java基礎,如果有人問你什麼是volatile?它有什麼作用的話,相信一定非常懵逼…
可能看了答案,也完全不明白,什麼是同步機制?什麼是可見性?什麼是原子性?什麼是指令重排?
要想理解什麼是可見性,首先要先理解JMM。
JMM(Java記憶體模型,Java Memory Model)本身是一種抽象的概念,並非真實存在。它描述的是一組規則或規範,透過這組規範,定了程式中各個變數的存取方法。 JMM關於同步的規定:
1)線程解鎖前,必須把共享變數的值刷新回主記憶體;
2)線程加鎖前,必須讀取主記憶體的最新值到自己的工作記憶體;
3)加上鎖解鎖是同一把鎖;
由於JVM運行程式的實體是線程,創建每個線程時,JMM會為其建立一個工作記憶體(有些地方稱為堆疊空間) ,工作記憶體是每個執行緒的私有資料區域。
Java記憶體模型規定所有變數都儲存在主內存,主記憶體是共享記憶體區域,所有執行緒都可以存取。
但執行緒對變數的操作(讀取、賦值等)必須在工作記憶體中進行。因此首先要將變數從主內存拷貝到自己的工作內存,然後對變數進行操作,操作完成後再將變數寫入主內存。
看了上面對JMM的介紹,可能還是優點懵,接下來用一個賣票系統來進行舉例:
1)如下圖,此時賣票系統後端只剩下1張票,並已讀入主內存:ticketNum=1。
2)此時網路上有多個使用者都在搶票,那麼此時就有多個線程同時都在進行買票服務,假設此時有3個線程都讀入了目前的票數:ticketNum =1,那麼接著就會買票。
3)假設線程1先搶占到cpu的資源,先買好票,並在自己的工作內存中將ticketNum的值改為0:ticketNum=0,然後再寫回到主內存中。
此時,線程1的用戶已經買到票了,那麼線程2,線程3此時應該不能再繼續買票了,因此需要係統通知線程2,線程3,ticketNum此時已經等於0了:ticketNum=0。 如果有這樣的通知操作,你可以理解為就具有可見性。
透過上面對JMM的介紹和舉例,可以簡單總結下。
JMM記憶體模型的可見性是指,當多執行緒存取主記憶體的某一個資源時,如果某一個執行緒在自己的工作記憶體中修改了該資源,並寫回主內存,那麼JMM記憶體模型應該要通知其他執行緒從新取得最新的資源,來確保最新資源的可見性。
在1.1中,已經基本上理解了可見性的含義,接下來用程式碼來驗證一下,volatile確實可以保證可見性。
先驗證下,不使用volatile,是不是就是沒有可見性。
package com.koping.test;import java.util.concurrent.TimeUnit;class MyData{ int number = 0; public void add10() { this.number += 10; }}public class VolatileVisibilityDemo { public static void main(String[] args) { MyData myData = new MyData(); // 启动一个线程修改myData的number,将number的值加10 new Thread( () -> { System.out.println("线程" + Thread.currentThread().getName()+"\t 正在执行"); try{ TimeUnit.SECONDS.sleep(3); } catch (Exception e) { e.printStackTrace(); } myData.add10(); System.out.println("线程" + Thread.currentThread().getName()+"\t 更新后,number的值为" + myData.number); } ).start(); // 看一下主线程能否保持可见性 while (myData.number == 0) { // 当上面的线程将number加10后,如果有可见性的话,那么就会跳出循环; // 如果没有可见性的话,就会一直在循环里执行 } System.out.println("具有可见性!"); }}
運行結果如下圖,可以看到雖然執行緒0已經將number的值改為了10,但是主執行緒還是在循環中,因為此時number不具有可見性,系統不會主動通知。
在上面程式碼的第7行給變數number加入volatile後再次測試,如下圖,此時主線程成功退出了循環,因為JMM主動通知了主執行緒更新number的值了,number已經不為0了。
理解了上面说的可见性之后,再来理解下什么叫原子性?
原子性是指不可分隔,完整性,即某个线程正在做某个业务时,中间不能被分割。要么同时成功,要么同时失败。
还是有点抽象,接下来举个例子。
如下图,创建了一个测试原子性的类:TestPragma。在add方法中将n加1,通过查看编译后的代码可以看到,n++被拆分为3个指令进行执行。
因此可能存在线程1正在执行第1个指令,紧接着线程2也正在执行第1个指令,这样当线程1和线程2都执行完3个指令之后,很容易理解,此时n的值只加了1,而实际是有2个线程加了2次,因此这种情况就是不保证原子性。
在2.1中已经进行了举例,可能存在2个线程执行n++的操作,但是最终n的值却只加了1的情况,接下来对这种情况再用代码进行演示下。
首先给MyData类添加一个add方法
package com.koping.test;class MyData { volatile int number = 0; public void add() { number++; }}
然后创建测试原子性的类:TestPragmaDemo。测试下20个线程给number各加1000次之后,number的值是否是20000。
package com.koping.test;public class TestPragmaDemo { public static void main(String[] args) { MyData myData = new MyData(); // 启动20个线程,每个线程将myData的number值加1000次,那么理论上number值最终是20000 for (int i=0; i { for (int j=0; j2){ Thread.yield(); } System.out.println("number值加了20000次,此时number的实际值是:" + myData.number); }}
运行结果如下图,最终number的值仅为18410。
可以看到即使加了volatile,依然不保证有原子性。
上面介绍并证明了volatile不保证原子性,那如果希望保证原子性,怎么办呢?以下提供了2种方法
方法1是在add方法上添加synchronized,这样每次只有1个线程能执行add方法。
结果如下图,最终确实可以使number的值为20000,保证了原子性。
但是,实际业务逻辑方法中不可能只有只有number++这1行代码,上面可能还有n行代码逻辑。现在为了保证number的值是20000,就把整个方法都加锁了(其实另外那n行代码,完全可以由多线程同时执行的)。所以就优点杀鸡用牛刀,高射炮打蚊子,小题大做了。
package com.koping.test;class MyData { volatile int number = 0; public synchronized void add() { // 在n++上面可能还有n行代码进行逻辑处理 number++; }}
给MyData新曾一个原子整型类型的变量num,初始值为0。
package com.koping.test;import java.util.concurrent.atomic.AtomicInteger;class MyData { volatile int number = 0; volatile AtomicInteger num = new AtomicInteger(); public void add() { // 在n++上面可能还有n行代码进行逻辑处理 number++; num.getAndIncrement(); }}
让num也同步加20000次。结果如下图,可以看到,使用原子整型的num可以保证原子性,也就是number++的时候不会被抢断。
package com.koping.test;public class TestPragmaDemo { public static void main(String[] args) { MyData myData = new MyData(); // 启动20个线程,每个线程将myData的number值加1000次,那么理论上number值最终是20000 for (int i=0; i { for (int j=0; j2){ Thread.yield(); } System.out.println("number值加了20000次,此时number的实际值是:" + myData.number); System.out.println("num值加了20000次,此时number的实际值是:" + myData.num); }}
在第2节中理解了什么是原子性,现在要理解下什么是指令重排?
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排:
源代码–>编译器优化重排–>指令并行重排–>内存系统重排–>最终执行指令
处理器在进行重排时,必须要考虑指令之间的数据依赖性。
单线程环境中,可以确保最终执行结果和代码顺序执行的结果一致。
但是多线程环境中,线程交替执行,由于编译器优化重排的存在,两个线程使用的变量能否保持一致性是无法确定的,结果无法预测。
看了上面的文字性表达,然后看一个很简单的例子。
比如下面的mySort方法,在系统指令重排后,可能存在以下3种语句的执行情况:
1)1234
2)2134
3)1324
以上这3种重排结果,对最后程序的结果都不会有影响,也考虑了指令之间的数据依赖性。
public void mySort() { int x = 1; // 语句1 int y = 2; // 语句2 x = x + 3; // 语句3 y = x * x; // 语句4}
看完指令重排的简单介绍后,然后来看下单例模式的代码。
package com.koping.test;public class SingletonDemo { private static SingletonDemo instance = null; private SingletonDemo() { System.out.println(Thread.currentThread().getName() + "\t 执行构造方法SingletonDemo()"); } public static SingletonDemo getInstance() { if (instance == null) { instance = new SingletonDemo(); } return instance; } public static void main(String[] args) { // 单线程测试 System.out.println("单线程的情况测试开始"); System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance()); System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance()); System.out.println("单线程的情况测试结束\n"); }}
首先是在单线程情况下进行测试,结果如下图。可以看到,构造方法只执行了一次,是没有问题的。
接下来在多线程情况下进行测试,代码如下。
package com.koping.test;public class SingletonDemo { private static SingletonDemo instance = null; private SingletonDemo() { System.out.println(Thread.currentThread().getName() + "\t 执行构造方法SingletonDemo()"); } public static SingletonDemo getInstance() { if (instance == null) { instance = new SingletonDemo(); } // DCL(Double Check Lock双端检索机制)// if (instance == null) {// synchronized (SingletonDemo.class) {// if (instance == null) {// instance = new SingletonDemo();// }// }// } return instance; } public static void main(String[] args) { // 单线程测试// System.out.println("单线程的情况测试开始");// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());// System.out.println("单线程的情况测试结束\n"); // 多线程测试 System.out.println("多线程的情况测试开始"); for (int i=1; i { SingletonDemo.getInstance(); }, String.valueOf(i)).start(); } }}
在多线程情况下的运行结果如下图。可以看到,多线程情况下,出现了构造方法执行了2次的情况。
在3.3中的多线程单里模式下,构造方法执行了两次,因此需要进行改进,这里使用双端检锁机制:Double Check Lock, DCL。即加锁之前和之后都进行检查。
package com.koping.test;public class SingletonDemo { private static SingletonDemo instance = null; private SingletonDemo() { System.out.println(Thread.currentThread().getName() + "\t 执行构造方法SingletonDemo()"); } public static SingletonDemo getInstance() {// if (instance == null) {// instance = new SingletonDemo();// } // DCL(Double Check Lock双端检锁机制) if (instance == null) { // a行 synchronized (SingletonDemo.class) { if (instance == null) { // b行 instance = new SingletonDemo(); // c行 } } } return instance; } public static void main(String[] args) { // 单线程测试// System.out.println("单线程的情况测试开始");// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());// System.out.println("单线程的情况测试结束\n"); // 多线程测试 System.out.println("多线程的情况测试开始"); for (int i=1; i { SingletonDemo.getInstance(); }, String.valueOf(i)).start(); } }}
在多次运行后,可以看到,在多线程情况下,此时构造方法也只执行1次了。
需要注意的是3.4中的DCL版的单例模式依然不是100%准确的!!!
是不是不太明白为什么3.4DCL版单例模式不是100%准确的原因?
是不是不太明白在3.1讲完指令重排的简单理解后,为什么突然要讲多线程的单例模式?
因为3.4DCL版单例模式可能会由于指令重排而导致问题,虽然该问题出现的可能性可能是千万分之一,但是该代码依然不是100%准确的。如果要保证100%准确,那么需要添加volatile关键字,添加volatile可以禁止指令重排。
接下来分析下,为什么3.4DCL版单例模式不是100%准确?
查看instance = new SingletonDemo();编译后的指令,可以分为以下3步:
1)分配对象内存空间:memory = allocate();
2)初始化对象:instance(memory);
3)设置instance指向分配的内存地址:instance = memory;
由于步骤2和步骤3不存在数据依赖关系,因此可能出现执行132步骤的情况。
比如线程1执行了步骤13,还没有执行步骤2,此时instance!=null,但是对象还没有初始化完成;
如果此时线程2抢占到cpu,然后发现instance!=null,然后直接返回使用,就会发现instance为空,就会出现异常。
这就是指令重排可能导致的问题,因此要想保证程序100%正确就需要加volatile禁止指令重排。
在3.1中简单介绍了下执行重排的含义,然后通过3.2-3.5,借助单例模式来举例说明多线程情况下,为什么要使用volatile的原因,因为可能存在指令重排导致程序异常。
接下来就介绍下volatile能保证禁止指令重排的原理。
首先要了解一个概念:内存屏障(Memory Barrier),又称为内存栅栏。它是一个CPU指令,有2个作用:
1)保证特定操作的执行顺序;
2)保证某些变量的内存可见性;
由于编译器和处理器都能执行指令重排。如果在指令之间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说,通过插入内存屏障,禁止在内存屏障前后的指令执行重排需优化。
内存屏障的另一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。
推荐学习:《java视频教程》
以上是Java基礎之volatile詳解的詳細內容。更多資訊請關注PHP中文網其他相關文章!