Java內(nèi)存模型之可見(jiàn)性(填坑之路)

前幾天路過(guò)一個(gè)經(jīng)常負(fù)責(zé)面試的同事附近,看到幾個(gè)人在討論volatile的可見(jiàn)性問(wèn)題,當(dāng)時(shí)第一感覺(jué)是 :“可見(jiàn)性還不簡(jiǎn)單嗎?volatile修飾一個(gè)變量時(shí),那么在一個(gè)線(xiàn)程都對(duì)這個(gè)變量的更改,其他線(xiàn)程都立即可見(jiàn)?!?/p>

后面聽(tīng)到這樣一句話(huà):“實(shí)際運(yùn)行結(jié)果能刷新你的三觀,網(wǎng)上的例子很多都是有問(wèn)題的”,讓我瞬間產(chǎn)生了興趣。湊近一看,果然跟我的很多認(rèn)知都產(chǎn)生了偏差。

為了解決其中的疑惑,查閱的不少文章,撥開(kāi)了一些迷霧,現(xiàn)將結(jié)果整理出來(lái),與大家一同探討。

基礎(chǔ)Java環(huán)境:
java version "1.8.0_172" Java(TM) SE Runtime Environment (build 1.8.0_172-b11) Java HotSpot(TM) 64-Bit Server VM (build 25.172-b11, mixed mode)

基本概念

Java內(nèi)存模型

首先先復(fù)習(xí)一下內(nèi)存模型的概念:

Java內(nèi)存模型(即Java Memory Model,簡(jiǎn)稱(chēng)JMM)本身是一種抽象的概念,并不真實(shí)存在,它描述的是一組規(guī)則或規(guī)范,通過(guò)這組規(guī)范 定義了程序中各個(gè)變量(包括實(shí)例字段,靜態(tài)字段和構(gòu)成數(shù)組對(duì)象的元素)的訪(fǎng)問(wèn)方式。

JVM程序運(yùn)行的實(shí)體是線(xiàn)程,而每個(gè)線(xiàn)程創(chuàng)建時(shí)JVM都會(huì)為其創(chuàng)建一個(gè)工作內(nèi)存(有些地方稱(chēng)為棧空間),用于存儲(chǔ)線(xiàn)程私有的數(shù)據(jù),而Java內(nèi)存模型中規(guī)定所有變量都存儲(chǔ)在主內(nèi)存,主內(nèi)存是共享內(nèi)存區(qū)域,所有線(xiàn)程都可以訪(fǎng)問(wèn),但線(xiàn)程對(duì)變量的操作(讀取賦值等)必須在工作內(nèi)存中進(jìn)行,首先要將變量從主內(nèi)存拷貝的自己的工作內(nèi)存空間,然后對(duì)變量進(jìn)行操作,操作完成后再將變量寫(xiě)回主內(nèi)存,不能直接操作主內(nèi)存中的變量,工作內(nèi)存中存儲(chǔ)著主內(nèi)存中的變量副本拷貝,前面說(shuō)過(guò),工作內(nèi)存是每個(gè)線(xiàn)程的私有數(shù)據(jù)區(qū)域,因此不同的線(xiàn)程間無(wú)法訪(fǎng)問(wèn)對(duì)方的工作內(nèi)存,線(xiàn)程間的通信(傳值)必須通過(guò)主內(nèi)存來(lái)完成,其簡(jiǎn)要訪(fǎng)問(wèn)過(guò)程如下圖:

image.png

volatile關(guān)鍵字

volatile是老生常談的一個(gè)關(guān)鍵字,大家在編程中其實(shí)用得都很少,面試中比較常見(jiàn),也正是這個(gè)原因,讓大家對(duì)這一塊的理解與實(shí)際結(jié)果產(chǎn)生了偏差。

volatile是Java虛擬機(jī)提供的輕量級(jí)的同步機(jī)制。volatile關(guān)鍵字有如下兩個(gè)作用。
1)保證被volatile修飾的共享變量對(duì)所有線(xiàn)程 總是可見(jiàn)的,也就是當(dāng)一個(gè)線(xiàn)程修改了一個(gè)被volatile修飾共享變量的值,新值總是可以被其他線(xiàn)程立即得知。
2)禁止指令重排序優(yōu)化。

可見(jiàn)性

關(guān)于內(nèi)存模型和volatile的概念本篇不做詳細(xì)贅述,不熟悉的看官建議先百度一下。JMM是圍繞 原子性、有序性、可見(jiàn)性 展開(kāi)的,本文主要圍繞內(nèi)存模型的可見(jiàn)性出發(fā),通過(guò)實(shí)際例子來(lái)探究其運(yùn)行原理。

先思考一個(gè)問(wèn)題:volatile保證的“立即可見(jiàn)”的反義是什么?

這是大家最容易想到的答案,應(yīng)該是“不可見(jiàn)”,且有實(shí)實(shí)在在的例子讓我們覺(jué)得“不可見(jiàn)”深根不移。

示例1:

package com.youzan;

/**
 * Date: 2018/8/12
 * @author xuzhiyi
 */
public class Test1 {

    private static boolean flag = true;

    private static int i = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (flag) {
                i++;
            }
            System.out.printf("**********test1 跳出成功, i=%d **********\n", i);
        });
        thread.start();
        Thread.sleep(100);
        flag = false;
        System.out.printf("**********test1 main thread 結(jié)束, i=%d **********\n", i);
    }
}

示例2:

package com.youzan;

/**
 * Date: 2018/8/12
 * @author xuzhiyi
 */
public class Test2 {

    private static volatile boolean flag = true;

    private static int i = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (flag) {
                i++;
            }
            System.out.printf("**********test2 跳出成功, i=%d **********\n", i);
        });
        thread.start();
        Thread.sleep(100);
        flag = false;
        System.out.printf("**********test2 main thread 結(jié)束, i=%d **********\n", i);
    }
}

示例1和示例2的唯一區(qū)別在于,示例2的flag有volatile修飾。上述示例的運(yùn)行結(jié)果大家都“知道”,示例1會(huì)一直死循環(huán),示例2會(huì)立即跳出循環(huán)。大家可能都運(yùn)行過(guò)這兩段(或者相似的)代碼,大部分人對(duì)結(jié)果很滿(mǎn)意,因?yàn)榉项A(yù)期,沒(méi)有加volatile關(guān)鍵字的成員變量多線(xiàn)程之間不可見(jiàn)。

回到剛剛那個(gè)問(wèn)題,“立即可見(jiàn)”的反義是什么?

通過(guò)上述實(shí)踐我們可以“肯定”的回答:“立即可見(jiàn)”的反義是“不可見(jiàn)”?。。《沂恰耙恢辈豢梢?jiàn)”

說(shuō)到這里,可能有部分人有疑問(wèn)了,“立即可見(jiàn)”的反義應(yīng)該是“不立即可見(jiàn)”,說(shuō)人話(huà)就是“可能過(guò)一段時(shí)間后可見(jiàn),不一定是馬上可見(jiàn)”。可是即使我們運(yùn)行一萬(wàn)遍示例1的代碼,都是一直不可見(jiàn)。怎么辦?繼續(xù)往下看。

實(shí)戰(zhàn)

讓沒(méi)有volatile也能跳出循環(huán)

方式一

示例3:

package com.youzan;

/**
 * Date: 2018/8/12
 * @author xuzhiyi
 */
public class Test3 {

    private static boolean flag = true;

    private static int i = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (flag) {
                i++;
            }
            System.out.printf("**********test3 跳出成功, i=%d **********\n", i);
        });
        thread.start();
        Thread.sleep(1);
        flag = false;
        System.out.printf("**********test3 main thread 結(jié)束, i=%d **********\n", i);
    }
}

在示例3中,我僅將示例1中的sleep時(shí)間改為1毫秒,while循環(huán)即可成功跳出,輸出結(jié)果如下:

**********test3 main thread 結(jié)束, i=60167 **********
**********test3 跳出成功, i=60167 **********

ps:主線(xiàn)程可能由于停頓時(shí)間太短,導(dǎo)致while循環(huán)根本沒(méi)進(jìn)去。重試幾次,當(dāng)i的值不為0即代表已經(jīng)進(jìn)入循環(huán)。

對(duì)比示例1和示例3我們可以得出一個(gè)結(jié)論:

  • 當(dāng)主線(xiàn)程停頓時(shí)間很極短(1~2ms)時(shí),可以跳出循環(huán);
  • 當(dāng)主線(xiàn)程停頓時(shí)間較長(zhǎng)時(shí),無(wú)法跳出循環(huán);

結(jié)論變種1:

  • 當(dāng)子線(xiàn)程循環(huán)執(zhí)行時(shí)間極短(1~2ms)時(shí),可以跳出循環(huán);
  • 當(dāng)子線(xiàn)程循環(huán)執(zhí)行時(shí)間較長(zhǎng)時(shí),無(wú)法跳出循環(huán);

結(jié)論變種2:

  • 當(dāng)子線(xiàn)程循環(huán)次數(shù)較少時(shí),可以跳出循環(huán);
  • 當(dāng)子線(xiàn)程循環(huán)次數(shù)較多時(shí),無(wú)法跳出循環(huán);

看上去是不是有點(diǎn)意思?代碼的執(zhí)行結(jié)果居然跟執(zhí)行時(shí)間、循環(huán)次數(shù)有關(guān)?推斷到這里,有些看官可能已經(jīng)想到了JIT即使編譯優(yōu)化。沒(méi)錯(cuò),正是JIT的優(yōu)化對(duì)運(yùn)行結(jié)果產(chǎn)生了影響。

關(guān)于JIT
當(dāng)虛擬機(jī)發(fā)現(xiàn)某個(gè)方法或代碼塊運(yùn)行特別頻繁時(shí),就會(huì)把這些代碼認(rèn)定為“Hot Spot Code”(熱點(diǎn)代碼),為了提高熱點(diǎn)代碼的執(zhí)行效率,在運(yùn)行時(shí),虛擬機(jī)將會(huì)把這些代碼編譯成與本地平臺(tái)相關(guān)的機(jī)器碼,并進(jìn)行各層次的優(yōu)化,完成這項(xiàng)任務(wù)的正是 JIT 編譯器。
運(yùn)行過(guò)程中會(huì)被即時(shí)編譯器編譯的“熱點(diǎn)代碼”有兩類(lèi):
1)被多次調(diào)用的方法。
2)被多次調(diào)用的循環(huán)體。

如何驗(yàn)證上述結(jié)論呢?

  • -Xint :強(qiáng)制使用解釋執(zhí)行的方式啟動(dòng)java虛擬機(jī),此模式下,不會(huì)使用JIT優(yōu)化,示例1和示例3的代碼都會(huì)跳出循環(huán)。
  • -Xcomp:強(qiáng)制使用編譯執(zhí)行的方式啟動(dòng)java虛擬機(jī),此模式下,代碼會(huì)被優(yōu)化并編譯成機(jī)器碼,示例1和示例3都無(wú)法填出循環(huán)。

總結(jié)一下:mac下默認(rèn)為-Xmixed混合模式,使用java -version可以查看,混合模式下只有熱點(diǎn)代碼達(dá)到一定閾值才會(huì)發(fā)生JIT優(yōu)化,因此導(dǎo)致了上述看到的運(yùn)行時(shí)間長(zhǎng)短對(duì)運(yùn)行結(jié)果的影響。

方式二

不少熱心的網(wǎng)友在自己運(yùn)行示例1代碼的時(shí)候,會(huì)不由自主的加上一行print,如下:
示例4:

package com.youzan;

/**
 * Date: 2018/8/12
 * @author xuzhiyi
 */
public class Test4 {

    private static boolean flag = true;

    private static int i = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (flag) {
                i++;
                System.out.println("i=" + i);
            }
            System.out.printf("**********test4 跳出成功, i=%d **********\n", i);
        });
        thread.start();
        Thread.sleep(100);
        flag = false;
        System.out.printf("**********test4 main thread 結(jié)束, i=%d **********\n", i);
    }
}

上述代碼一運(yùn)行后成功跳出,可能又驚倒了一批看官,為什么多一行print結(jié)果又不一樣了。而且就算在-Xcomp模式優(yōu)化后也可以跳出。有點(diǎn)神奇吧?
為了找出原因,我對(duì)print代碼進(jìn)行了幾次不同的替換:

示例5:

package com.youzan;

import java.util.HashMap;

/**
 * Date: 2018/8/12
 * @author xuzhiyi
 */
public class Test5 {

    private static boolean flag = true;

    private static int i = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (flag) {
                doSomeThing1();
            }
            System.out.printf("**********test4 跳出成功, i=%d **********\n", i);
        });
        thread.start();
        Thread.sleep(10);
        flag = false;
        System.out.printf("**********test4 main thread 結(jié)束, i=%d **********\n", i);
    }

    private static void doSomeThing1() {
        System.out.println("doSomeThing1");
    }

    private static void doSomeThing2() {
        synchronized (Test5.class) {
            i++;
        }
    }

    private static void doSomeThing3() {
        i++;
        Thread.yield();
    }

    private static void doSomeThing4() {
        new HashMap<>();
    }
}

上述代碼中,不論是在循環(huán)體內(nèi)執(zhí)行哪一個(gè)方法(doSomeThing1~ doSomeThing4),都可以正常跳出循環(huán)。為什么呢?究竟是什么影響了線(xiàn)程對(duì)成員變量的可見(jiàn)性呢?我的結(jié)論如下:
根據(jù)java的內(nèi)存模型規(guī)范,一個(gè)線(xiàn)程對(duì)普通變量的修改并不需要立即寫(xiě)回到主存,且另一個(gè)線(xiàn)程讀取也不需要每一次都從主存中去讀取。至于什么時(shí)候與主內(nèi)存同步,虛擬機(jī)只需保證方法出棧時(shí)將修改的值同步到主內(nèi)存。因此這其中有比較寬松的優(yōu)化空間。而上述幾個(gè)方法,都存在一定的同步空間。虛擬機(jī)會(huì)在此時(shí)與主內(nèi)存同步。
ps:以上結(jié)論純屬猜測(cè),沒(méi)有很好的論據(jù),歡迎大家探討!

volatile的傳播范圍

思考兩個(gè)問(wèn)題:

  1. 把volatile對(duì)象傳遞給另一個(gè)對(duì)象,新對(duì)象是否立即可見(jiàn)呢?
  2. 當(dāng)volatile修飾對(duì)象時(shí),如果對(duì)象的嵌套的層級(jí)較深,那該對(duì)象的內(nèi)部是否立即可見(jiàn)呢?

示例6:

package com.youzan;

/**
 * Date: 2018/8/12
 * @author xuzhiyi
 */
public class Test6 {

    private static volatile ReferenceFlag referenceFlag = new ReferenceFlag();

    private static int i = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            BaseFlag baseFlag = referenceFlag.baseFlag;
            while (baseFlag.flag) {
                i++;
            }
            System.out.printf("**********test6 跳出成功, i=%d **********\n", i);
        });
        thread.start();
        Thread.sleep(100);
        referenceFlag.baseFlag.flag = false;
        System.out.printf("**********test6 main thread 結(jié)束, i=%d **********\n", i);
    }

    static class BaseFlag {
        boolean flag = true;
    }

    static class ReferenceFlag {
        volatile BaseFlag baseFlag = new BaseFlag();
    }
}

在示例6中,使用了引用嵌套的方式來(lái)驗(yàn)證volatile是否可以傳遞給一個(gè)局部變量,示例中的引用都是用來(lái)volatile關(guān)鍵字來(lái)修飾,運(yùn)行結(jié)果是無(wú)法跳出。

結(jié)論一:當(dāng)使用一個(gè)變量來(lái)接受一個(gè)volatile修飾的變量時(shí),volatile的可見(jiàn)性并不會(huì)傳遞。即新的變量不再具有volatile特性。

示例2:

package com.youzan;

/**
 * Date: 2018/8/12
 * @author xuzhiyi
 */
public class Test7 {

    private static int i = 0;

    private static volatile DeapReferenceInnerFlag deapReferenceInnerFlag = new DeapReferenceInnerFlag();

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (deapReferenceInnerFlag.referenceInnerFlag.baseFlag.flag) {
                i++;
            }
            System.out.printf("**********test7 跳出成功, i=%d **********\n", i);
        });
        thread.start();
        Thread.sleep(100);
        deapReferenceInnerFlag.referenceInnerFlag.baseFlag.flag = false;
        System.out.printf("**********test7 main thread 結(jié)束, i=%d **********\n", i);
    }

    static class BaseFlag {
        boolean flag = true;
    }

    static class ReferenceInnerFlag {
        BaseFlag baseFlag = new BaseFlag();
    }

    static class DeapReferenceInnerFlag {
        ReferenceInnerFlag referenceInnerFlag = new ReferenceInnerFlag();
    }
}

示例7是一個(gè)多層嵌套的對(duì)象,只有最外層使用volatile修飾,當(dāng)其內(nèi)部的值改變后,使用鏈?zhǔn)秸{(diào)用的方式,則一直可以取到最新的值。

結(jié)論二:對(duì)于多層嵌套的對(duì)象,最外層使用volatile修飾,使用鏈?zhǔn)秸{(diào)用的方式,volatile的可見(jiàn)性可以傳播。
ps:結(jié)論二沒(méi)有很好的理論依據(jù),僅從實(shí)踐上看是如此。

總結(jié)

本篇結(jié)合實(shí)際的幾個(gè)例子,講述了幾個(gè)認(rèn)識(shí)誤區(qū)。僅通過(guò)運(yùn)行結(jié)果說(shuō)明了一些問(wèn)題,但依然不夠深入,不足之處,還望指出。想深入探究的看官,可以參考下面的幾篇文章。

參考文章

  1. 全面理解Java內(nèi)存模型(JMM)及volatile關(guān)鍵字
  2. 并行編程之多線(xiàn)程共享非volatile變量,會(huì)不會(huì)可能導(dǎo)致線(xiàn)程while死循環(huán)
  3. 深入淺出 JIT 編譯器
  4. 一個(gè)由JIT優(yōu)化引發(fā)的問(wèn)題
  5. JVM執(zhí)行篇:使用HSDIS插件分析JVM代碼執(zhí)行細(xì)節(jié)
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容