高并發(fā)編程學(xué)習(xí)(1)——并發(fā)基礎(chǔ)

為更良好的閱讀體驗(yàn)铜幽,請(qǐng)?jiān)L問(wèn)原文:傳送門(mén)

一抄沮、前言


當(dāng)我們使用計(jì)算機(jī)時(shí),可以同時(shí)做許多事情粥庄,例如一邊打游戲一邊聽(tīng)音樂(lè)丧失。這是因?yàn)椴僮飨到y(tǒng)支持并發(fā)任務(wù),從而使得這些工作得以同時(shí)進(jìn)行惜互。

  • 那么提出一個(gè)問(wèn)題:如果我們要實(shí)現(xiàn)一個(gè)程序能一邊聽(tīng)音樂(lè)一邊玩游戲怎么實(shí)現(xiàn)呢布讹?
public class Tester {

    public static void main(String[] args) {
        System.out.println("開(kāi)始....");
        playGame();
        playMusic();
        System.out.println("結(jié)束....");
    }

    private static void playGame() {
        for (int i = 0; i < 50; i++) {
            System.out.println("玩游戲" + i);
        }
    }

    private static void playMusic() {
        for (int i = 0; i < 50; i++) {
            System.out.println("播放音樂(lè)" + i);
        }
    }
}

我們使用了循環(huán)來(lái)模擬過(guò)程琳拭,因?yàn)椴シ乓魳?lè)和打游戲都是連續(xù)的,但是結(jié)果卻不盡人意描验,因?yàn)楹瘮?shù)體總是要執(zhí)行完之后才能返回白嘁。那么到底怎么解決這個(gè)問(wèn)題?

并行與并發(fā)

并行性和并發(fā)性是既相似又有區(qū)別的兩個(gè)概念挠乳。

并行性是指兩個(gè)或多個(gè)事件在同一時(shí)刻發(fā)生权薯。而并發(fā)性是指兩個(gè)或多個(gè)事件在同一時(shí)間間隔內(nèi)發(fā)生。

在多道程序環(huán)境下睡扬,并發(fā)性是指在一段時(shí)間內(nèi)宏觀上有多個(gè)程序在同時(shí)運(yùn)行盟蚣,但在單處理機(jī)環(huán)境下(一個(gè)處理器),每一時(shí)刻卻僅能有一道程序執(zhí)行卖怜,故微觀上這些程序只能是分時(shí)地交替執(zhí)行屎开。例如,在 1 秒鐘時(shí)間內(nèi)马靠,0 - 15 ms 程序 A 運(yùn)行奄抽;15 - 30 ms 程序 B 運(yùn)行;30 - 45 ms 程序 C 運(yùn)行甩鳄;45 - 60 ms 程序 D 運(yùn)行逞度,因此可以說(shuō),在 1 秒鐘時(shí)間間隔內(nèi)妙啃,宏觀上有四道程序在同時(shí)運(yùn)行档泽,但微觀上,程序 A揖赴、B馆匿、C、D 是分時(shí)地交替執(zhí)行的燥滑。

如果在計(jì)算機(jī)系統(tǒng)中有多個(gè)處理機(jī)渐北,這些可以并發(fā)執(zhí)行的程序就可以被分配到多個(gè)處理機(jī)上,實(shí)現(xiàn)并發(fā)執(zhí)行铭拧,即利用每個(gè)處理機(jī)愛(ài)處理一個(gè)可并發(fā)執(zhí)行的程序赃蛛。這樣,多個(gè)程序便可以同時(shí)執(zhí)行搀菩。以此就能提高系統(tǒng)中的資源利用率焊虏,增加系統(tǒng)的吞吐量。

進(jìn)程和線程

進(jìn)程是指一個(gè)內(nèi)存中運(yùn)行的應(yīng)用程序秕磷。一個(gè)應(yīng)用程序可以同時(shí)啟動(dòng)多個(gè)進(jìn)程,那么上面的問(wèn)題就有了解決的思路:我們啟動(dòng)兩個(gè)進(jìn)程炼团,一個(gè)用來(lái)打游戲澎嚣,一個(gè)用來(lái)播放音樂(lè)疏尿。這當(dāng)然是一種解決方案,但是想象一下易桃,如果一個(gè)應(yīng)用程序需要執(zhí)行的任務(wù)非常多褥琐,例如 LOL 游戲吧,光是需要播放的音樂(lè)就有非常多晤郑,人物本身的語(yǔ)音敌呈,技能的音效,游戲的背景音樂(lè)造寝,塔攻擊的聲音等等等磕洪,還不用說(shuō)游戲本身,就光播放音樂(lè)就需要?jiǎng)?chuàng)建許多許多的進(jìn)程诫龙,而進(jìn)程本身是一種非常消耗資源的東西析显,這樣的設(shè)計(jì)顯然是不合理的。更何況大多數(shù)的操作系統(tǒng)都不需要一個(gè)進(jìn)程訪問(wèn)其他進(jìn)程的內(nèi)存空間签赃,也就是說(shuō)谷异,進(jìn)程之間的通信很不方便,此時(shí)我們就得引入“線程”這門(mén)技術(shù)锦聊,來(lái)解決這個(gè)問(wèn)題歹嘹。

線程是指進(jìn)程中的一個(gè)執(zhí)行任務(wù)(控制單元),一個(gè)進(jìn)程可以同時(shí)并發(fā)運(yùn)行多個(gè)線程孔庭。我們可以打開(kāi)任務(wù)管理器尺上,觀察到幾乎所有的進(jìn)程都擁有著許多的「線程」(在 WINDOWS 中線程是默認(rèn)隱藏的,需要在「查看」里面點(diǎn)擊「選擇列」史飞,有一個(gè)線程數(shù)的勾選項(xiàng)尖昏,找到并勾選就可以了)。

進(jìn)程和線程的區(qū)別

進(jìn)程:有獨(dú)立的內(nèi)存空間构资,進(jìn)程中的數(shù)據(jù)存放空間(堆空間和棾樗撸空間)是獨(dú)立的,至少有一個(gè)線程吐绵。

線程:堆空間是共享的迹淌,棧空間是獨(dú)立的己单,線程消耗的資源也比進(jìn)程小唉窃,相互之間可以影響的,又稱(chēng)為輕型進(jìn)程或進(jìn)程元纹笼。

因?yàn)橐粋€(gè)進(jìn)程中的多個(gè)線程是并發(fā)運(yùn)行的纹份,那么從微觀角度上考慮也是有先后順序的,那么哪個(gè)線程執(zhí)行完全取決于 CPU 調(diào)度器(JVM 來(lái)調(diào)度),程序員是控制不了的蔓涧。我們可以把多線程并發(fā)性看作是多個(gè)線程在瞬間搶 CPU 資源件已,誰(shuí)搶到資源誰(shuí)就運(yùn)行,這也造就了多線程的隨機(jī)性元暴。下面我們將看到更生動(dòng)的例子篷扩。

Java 程序的進(jìn)程(Java 的一個(gè)程序運(yùn)行在系統(tǒng)中)里至少包含主線程和垃圾回收線程(后臺(tái)線程),你可以簡(jiǎn)單的這樣認(rèn)為茉盏,但實(shí)際上有四個(gè)線程(了解就好):

  • [1] main——main 線程鉴未,用戶程序入口
  • [2] Reference Handler——清除 Reference 的線程
  • [3] Finalizer——調(diào)用對(duì)象 finalize 方法的線程
  • [4] Signal Dispatcher——分發(fā)處理發(fā)送給 JVM 信號(hào)的線程

多線程和單線程的區(qū)別和聯(lián)系?

  1. 單核 CPU 中鸠姨,將 CPU 分為很小的時(shí)間片铜秆,在每一時(shí)刻只能有一個(gè)線程在執(zhí)行,是一種微觀上輪流占用 CPU 的機(jī)制享怀。

  2. 多線程會(huì)存在線程上下文切換羽峰,會(huì)導(dǎo)致程序執(zhí)行速度變慢,即采用一個(gè)擁有兩個(gè)線程的進(jìn)程執(zhí)行所需要的時(shí)間比一個(gè)線程的進(jìn)程執(zhí)行兩次所需要的時(shí)間要多一些添瓷。

結(jié)論:即采用多線程不會(huì)提高程序的執(zhí)行速度梅屉,反而會(huì)降低速度,但是對(duì)于用戶來(lái)說(shuō)鳞贷,可以減少用戶的響應(yīng)時(shí)間坯汤。

多線程的優(yōu)勢(shì)

盡管面臨很多挑戰(zhàn),多線程有一些優(yōu)點(diǎn)仍然使得它一直被使用搀愧,而這些優(yōu)點(diǎn)我們應(yīng)該了解惰聂。

優(yōu)勢(shì)一:資源利用率更好

想象一下,一個(gè)應(yīng)用程序需要從本地文件系統(tǒng)中讀取和處理文件的情景咱筛。比方說(shuō)搓幌,從磁盤(pán)讀取一個(gè)文件需要 5 秒,處理一個(gè)文件需要 2 秒迅箩。處理兩個(gè)文件則需要:

1| 5秒讀取文件A
2| 2秒處理文件A
3| 5秒讀取文件B
4| 2秒處理文件B
5| ---------------------
6| 總共需要14秒

從磁盤(pán)中讀取文件的時(shí)候溉愁,大部分的 CPU 時(shí)間用于等待磁盤(pán)去讀取數(shù)據(jù)。在這段時(shí)間里饲趋,CPU 非常的空閑拐揭。它可以做一些別的事情。通過(guò)改變操作的順序奕塑,就能夠更好的使用 CPU 資源堂污。看下面的順序:

1| 5秒讀取文件A
2| 5秒讀取文件B + 2秒處理文件A
3| 2秒處理文件B
4| ---------------------
5| 總共需要12秒

CPU 等待第一個(gè)文件被讀取完龄砰。然后開(kāi)始讀取第二個(gè)文件盟猖。當(dāng)?shù)诙募诒蛔x取的時(shí)候讨衣,CPU 會(huì)去處理第一個(gè)文件。記住扒披,在等待磁盤(pán)讀取文件的時(shí)候值依,CPU 大部分時(shí)間是空閑的。

總的說(shuō)來(lái)碟案,CPU 能夠在等待 IO 的時(shí)候做一些其他的事情。這個(gè)不一定就是磁盤(pán) IO颇蜡。它也可以是網(wǎng)絡(luò)的 IO价说,或者用戶輸入。通常情況下风秤,網(wǎng)絡(luò)和磁盤(pán)的 IO 比 CPU 和內(nèi)存的 IO 慢的多鳖目。

優(yōu)勢(shì)二:程序設(shè)計(jì)在某些情況下更簡(jiǎn)單

在單線程應(yīng)用程序中,如果你想編寫(xiě)程序手動(dòng)處理上面所提到的讀取和處理的順序缤弦,你必須記錄每個(gè)文件讀取和處理的狀態(tài)领迈。相反,你可以啟動(dòng)兩個(gè)線程碍沐,每個(gè)線程處理一個(gè)文件的讀取和操作狸捅。線程會(huì)在等待磁盤(pán)讀取文件的過(guò)程中被阻塞。在等待的時(shí)候累提,其他的線程能夠使用 CPU 去處理已經(jīng)讀取完的文件尘喝。其結(jié)果就是,磁盤(pán)總是在繁忙地讀取不同的文件到內(nèi)存中斋陪。這會(huì)帶來(lái)磁盤(pán)和 CPU 利用率的提升朽褪。而且每個(gè)線程只需要記錄一個(gè)文件,因此這種方式也很容易編程實(shí)現(xiàn)无虚。

優(yōu)勢(shì)三:程序響應(yīng)更快

有時(shí)我們會(huì)編寫(xiě)一些較為復(fù)雜的代碼(這里的復(fù)雜不是說(shuō)復(fù)雜的算法缔赠,而是復(fù)雜的業(yè)務(wù)邏輯),例如友题,一筆訂單的創(chuàng)建嗤堰,它包括插入訂單數(shù)據(jù)、生成訂單趕快找咆爽、發(fā)送郵件通知賣(mài)家和記錄貨品銷(xiāo)售數(shù)量等梁棠。用戶從單擊“訂購(gòu)”按鈕開(kāi)始,就要等待這些操作全部完成才能看到訂購(gòu)成功的結(jié)果斗埂。但是這么多業(yè)務(wù)操作符糊,如何能夠讓其更快地完成呢?

在上面的場(chǎng)景中呛凶,可以使用多線程技術(shù)男娄,即將數(shù)據(jù)一致性不強(qiáng)的操作派發(fā)給其他線程處理(也可以使用消息隊(duì)列),如生成訂單快照、發(fā)送郵件等模闲。這樣做的好處是響應(yīng)用戶請(qǐng)求的線程能夠盡可能快地處理完成建瘫,縮短了響應(yīng)時(shí)間,提升了用戶體驗(yàn)尸折。

其他優(yōu)勢(shì)

多線程還有一些優(yōu)勢(shì)也顯而易見(jiàn):

  • 進(jìn)程之前不能共享內(nèi)存啰脚,而線程之間共享內(nèi)存(堆內(nèi)存)則很簡(jiǎn)單。
  • 系統(tǒng)創(chuàng)建進(jìn)程時(shí)需要為該進(jìn)程重新分配系統(tǒng)資源,創(chuàng)建線程則代價(jià)小很多,因此實(shí)現(xiàn)多任務(wù)并發(fā)時(shí),多線程效率更高.
  • Java 語(yǔ)言本身內(nèi)置多線程功能的支持,而不是單純地作為底層系統(tǒng)的調(diào)度方式,從而簡(jiǎn)化了多線程編程.

上下文切換

即使是單核處理器也支持多線程執(zhí)行代碼实夹,CPU 通過(guò)給每個(gè)線程分配 CPU 時(shí)間片來(lái)實(shí)現(xiàn)這個(gè)機(jī)制橄浓。時(shí)間片是 CPU 分配給各個(gè)線程的時(shí)間,因?yàn)闀r(shí)間片非常短亮航,所以 CPU 通過(guò)不停地切換線程執(zhí)行荸实,讓我們感覺(jué)多個(gè)線程是同時(shí)執(zhí)行的,時(shí)間片一般是幾十毫秒(ms)缴淋。

CPU 通過(guò)時(shí)間片分配算法來(lái)循環(huán)執(zhí)行任務(wù)准给,當(dāng)前任務(wù)執(zhí)行一個(gè)時(shí)間片后會(huì)切換到下一個(gè)任務(wù)。但是重抖,在切換前會(huì)保存上一個(gè)任務(wù)的狀態(tài)露氮,以便下次切換回這個(gè)任務(wù)的時(shí)候,可以再加載這個(gè)任務(wù)的狀態(tài)仇哆。所以任務(wù)從保存到再加載的過(guò)程就是一次上下文切換沦辙。

這就像我們同時(shí)讀兩本書(shū),當(dāng)我們?cè)谧x一本英文的技術(shù)書(shū)時(shí)讹剔,發(fā)現(xiàn)某個(gè)單詞不認(rèn)識(shí)油讯,于是打開(kāi)中英文字典,但是在放下英文技術(shù)書(shū)之前延欠,大腦必須先記住這本書(shū)獨(dú)到了多少頁(yè)的多少行陌兑,等查完單詞之后,能夠繼續(xù)讀這本書(shū)由捎。這樣的切換是會(huì)影響讀書(shū)效率的兔综,同樣上下文切換也會(huì)影響多線程的執(zhí)行速度。

二狞玛、創(chuàng)建線程的兩種方式


繼承 Thread 類(lèi)

public class Tester {

    // 播放音樂(lè)的線程類(lèi)
    static class PlayMusicThread extends Thread {

        // 播放時(shí)間软驰,用循環(huán)來(lái)模擬播放的過(guò)程
        private int playTime = 50;

        public void run() {
            for (int i = 0; i < playTime; i++) {
                System.out.println("播放音樂(lè)" + i);
            }
        }
    }

    // 方式1:繼承 Thread 類(lèi)
    public static void main(String[] args) {
        // 主線程:運(yùn)行游戲
        for (int i = 0; i < 50; i++) {
            System.out.println("打游戲" + i);
            if (i == 10) {
                // 創(chuàng)建播放音樂(lè)線程
                PlayMusicThread musicThread = new PlayMusicThread();
                musicThread.start();
            }
        }
    }
}

運(yùn)行結(jié)果發(fā)現(xiàn)打游戲和播放音樂(lè)交替出現(xiàn),說(shuō)明已經(jīng)成功了心肪。

實(shí)現(xiàn) Runnable 接口

public class Tester {

    // 播放音樂(lè)的線程類(lèi)
    static class PlayMusicThread implements Runnable {

        // 播放時(shí)間锭亏,用循環(huán)來(lái)模擬播放的過(guò)程
        private int playTime = 50;

        public void run() {
            for (int i = 0; i < playTime; i++) {
                System.out.println("播放音樂(lè)" + i);
            }
        }
    }

    // 方式2:實(shí)現(xiàn) Runnable 方法
    public static void main(String[] args) {
        // 主線程:運(yùn)行游戲
        for (int i = 0; i < 50; i++) {
            System.out.println("打游戲" + i);
            if (i == 10) {
                // 創(chuàng)建播放音樂(lè)線程
                Thread musicThread = new Thread(new PlayMusicThread());
                musicThread.start();
            }
        }
    }
}

也能完成效果。

以上就是傳統(tǒng)的兩種創(chuàng)建線程的方式硬鞍,事實(shí)上還有第三種慧瘤,我們后邊再講戴已。

多線程一定快嗎?

先來(lái)一段代碼锅减,通過(guò)并行和串行來(lái)分別執(zhí)行累加操作糖儡,分析:下面的代碼并發(fā)執(zhí)行一定比串行執(zhí)行快嗎?

import org.springframework.util.StopWatch;

// 比較并行和串行執(zhí)行累加操作的速度
public class Tester {

    // 執(zhí)行次數(shù)
    private static final long COUNT = 100000000;
    private static final StopWatch TIMER = new StopWatch();

    public static void main(String[] args) throws InterruptedException {
        concurrency();
        serial();
        // 打印比較測(cè)試結(jié)果
        System.out.println(TIMER.prettyPrint());
    }

    private static void serial() {
        TIMER.start("串行執(zhí)行" + COUNT + "條數(shù)據(jù)");

        int a = 0;
        for (long i = 0; i < COUNT; i++) {
            a += 5;
        }
        // 串行執(zhí)行
        int b = 0;
        for (long i = 0; i < COUNT; i++) {
            b--;
        }

        TIMER.stop();
    }

    private static void concurrency() throws InterruptedException {
        TIMER.start("并行執(zhí)行" + COUNT + "條數(shù)據(jù)");

        // 通過(guò)匿名內(nèi)部類(lèi)來(lái)創(chuàng)建線程
        Thread thread = new Thread(() -> {
            int a = 0;
            for (long i = 0; i < COUNT; i++) {
                a += 5;
            }
        });
        thread.start();

        // 并行執(zhí)行
        int b = 0;
        for (long i = 0; i < COUNT; i++) {
            b--;
        }
        // 等待線程結(jié)束
        thread.join();
        TIMER.stop();
    }
}

大家可以自己測(cè)試一下怔匣,每一臺(tái)機(jī)器 CPU 不同測(cè)試結(jié)果可能也會(huì)不同握联,之前在 WINDOWS 本兒上測(cè)試的時(shí)候,多線程的優(yōu)勢(shì)從 1 千萬(wàn)數(shù)據(jù)的時(shí)候才開(kāi)始體現(xiàn)出來(lái)劫狠,但是現(xiàn)在換了 MAC拴疤,1 億條數(shù)據(jù)時(shí)間也差不多,到 10 億的時(shí)候明顯串行就比并行快了... 總之独泞,為什么并發(fā)執(zhí)行的速度會(huì)比串行慢呢?就是因?yàn)榫€程有創(chuàng)建和上下文切換的開(kāi)銷(xiāo)苔埋。

繼承 Thread 類(lèi)還是實(shí)現(xiàn) Runnable 接口懦砂?

想象一個(gè)這樣的例子:給出一共 50 個(gè)蘋(píng)果,讓三個(gè)同學(xué)一起來(lái)吃组橄,并且給蘋(píng)果編上號(hào)碼荞膘,讓他們吃的時(shí)候順便要說(shuō)出蘋(píng)果的編號(hào):

運(yùn)行結(jié)果可以看到,使用繼承方式實(shí)現(xiàn)玉工,每一個(gè)線程都吃了 50 個(gè)蘋(píng)果羽资。這樣的結(jié)果顯而易見(jiàn):是因?yàn)轱@式地創(chuàng)建了三個(gè)不同的 Person 對(duì)象,而每個(gè)對(duì)象在堆空間中有獨(dú)立的區(qū)域來(lái)保存定義好的 50 個(gè)蘋(píng)果遵班。

而使用實(shí)現(xiàn)方式則滿足要求屠升,這是因?yàn)槿齻€(gè)線程共享了同一個(gè) Apple 對(duì)象,而對(duì)象中的 num 數(shù)量是一定的狭郑。

所以可以簡(jiǎn)單總結(jié)出繼承方式和實(shí)現(xiàn)方式的區(qū)別:

繼承方式:

  1. Java 中類(lèi)是單繼承的腹暖,如果繼承了 Thread 了,該類(lèi)就不能再有其他的直接父類(lèi)了翰萨;
  2. 從操作上分析脏答,繼承方式更簡(jiǎn)單,獲取線程名字也簡(jiǎn)單..(操作上亩鬼,更簡(jiǎn)單)
  3. 從多線程共享同一個(gè)資源上分析殖告,繼承方式不能做到...

實(shí)現(xiàn)方式:

  1. Java 中類(lèi)可以實(shí)現(xiàn)多個(gè)接口,此時(shí)該類(lèi)還可以繼承其他類(lèi)雳锋,并且還可以實(shí)現(xiàn)其他接口(設(shè)計(jì)上黄绩,更優(yōu)雅)..
  2. 從操作上分析,實(shí)現(xiàn)方式稍微復(fù)雜點(diǎn)魄缚,獲取線程名字也比較復(fù)雜宝与,需要使用 Thread.currentThread() 來(lái)獲取當(dāng)前線程的引用..
  3. 從多線程共享同一個(gè)資源上分析焚廊,實(shí)現(xiàn)方式可以做到..

在這里,三個(gè)同學(xué)完成搶蘋(píng)果的例子习劫,使用實(shí)現(xiàn)方式才是更合理的方式咆瘟。

對(duì)于這兩種方式哪種好并沒(méi)有一個(gè)確定的答案,它們都能滿足要求诽里。就我個(gè)人意見(jiàn)袒餐,我更傾向于實(shí)現(xiàn) Runnable 接口這種方法。因?yàn)榫€程池可以有效的管理實(shí)現(xiàn)了 Runnable 接口的線程谤狡,如果線程池滿了灸眼,新的線程就會(huì)排隊(duì)等候執(zhí)行,直到線程池空閑出來(lái)為止墓懂。而如果線程是通過(guò)實(shí)現(xiàn) Thread 子類(lèi)實(shí)現(xiàn)的焰宣,這將會(huì)復(fù)雜一些。

有時(shí)我們要同時(shí)融合實(shí)現(xiàn) Runnable 接口和 Thread 子類(lèi)兩種方式捕仔。例如匕积,實(shí)現(xiàn)了 Thread 子類(lèi)的實(shí)例可以執(zhí)行多個(gè)實(shí)現(xiàn)了 Runnable 接口的線程。一個(gè)典型的應(yīng)用就是線程池榜跌。

常見(jiàn)錯(cuò)誤:調(diào)用 run() 方法而非 start() 方法

創(chuàng)建并運(yùn)行一個(gè)線程所犯的常見(jiàn)錯(cuò)誤是調(diào)用線程的 run() 方法而非 start() 方法闪唆,如下所示:

1| Thread newThread = new Thread(MyRunnable());
2| newThread.run();  //should be start();

起初你并不會(huì)感覺(jué)到有什么不妥,因?yàn)?run() 方法的確如你所愿的被調(diào)用了钓葫。但是悄蕾,事實(shí)上,run() 方法并非是由剛創(chuàng)建的新線程所執(zhí)行的础浮,而是被創(chuàng)建新線程的當(dāng)前線程所執(zhí)行了帆调。也就是被執(zhí)行上面兩行代碼的線程所執(zhí)行的。想要讓創(chuàng)建的新線程執(zhí)行 run() 方法霸旗,必須調(diào)用新線程的 start() 方法贷帮。

三、線程的安全問(wèn)題


吃蘋(píng)果游戲的不安全問(wèn)題

我們來(lái)考慮一下上面吃蘋(píng)果的例子诱告,會(huì)有什么問(wèn)題撵枢?

盡管,Java 并不保證線程的順序執(zhí)行精居,具有隨機(jī)性锄禽,但吃蘋(píng)果比賽的案例運(yùn)行多次也并沒(méi)有發(fā)現(xiàn)什么太大的問(wèn)題。這并不是因?yàn)槌绦驔](méi)有問(wèn)題靴姿,而只是問(wèn)題出現(xiàn)的不夠明顯沃但,為了讓問(wèn)題更加明顯,我們使用 Thread.sleep() 方法(經(jīng)常用來(lái)模擬網(wǎng)絡(luò)延遲)來(lái)讓線程休息 10 ms佛吓,讓其他線程去搶資源宵晚。(注意:在程序中并不是使用 Thread.sleep(10)之后,程序才出現(xiàn)問(wèn)題,而是使用之后,問(wèn)題更明顯.)

為什么會(huì)出現(xiàn)這樣的錯(cuò)誤呢垂攘?

先來(lái)分析第一種錯(cuò)誤:為什么會(huì)吃重復(fù)的蘋(píng)果呢?就拿 B 和 C 都吃了編號(hào)為 47 的蘋(píng)果為例吧:

  • A 線程拿到了編號(hào)為 48 的蘋(píng)果淤刃,打印輸出然后讓 num 減 1晒他,睡眠 10 ms,此時(shí) num 為 47逸贾。
  • 這時(shí) B 和 C 同時(shí)都拿到了編號(hào)為 47 的蘋(píng)果陨仅,打印輸出,在其中一個(gè)線程作出了減一操作的時(shí)候铝侵,A 線程從睡眠中醒過(guò)來(lái)灼伤,拿到了編號(hào)為 46 的蘋(píng)果,然后輸出咪鲜。在這期間并沒(méi)有任何操作不允許 B 和 C 線程不能拿到同一個(gè)編號(hào)的蘋(píng)果狐赡,之前沒(méi)有明顯的錯(cuò)誤僅僅可能只是因?yàn)檫\(yùn)行速度太快了。

再來(lái)分析第二種錯(cuò)誤:照理來(lái)說(shuō)只應(yīng)該存在 1-50 編號(hào)的蘋(píng)果疟丙,可是 0 和-1 是怎么出現(xiàn)的呢猾警?

  • 當(dāng) num = 1 的時(shí)候,A隆敢,B,C 三個(gè)線程同時(shí)進(jìn)入了 try 語(yǔ)句進(jìn)行睡眠崔慧。
  • C 線程先醒過(guò)來(lái)拂蝎,輸出了編號(hào)為 1 的蘋(píng)果,然后讓 num 減一惶室,當(dāng) C 線程醒過(guò)來(lái)的時(shí)候發(fā)現(xiàn) num 為 0 了温自。
  • A 線程醒過(guò)來(lái)一看,0 都沒(méi)有了皇钞,只有 -1 了悼泌。

歸根結(jié)底是因?yàn)闆](méi)有任何操作來(lái)限制線程來(lái)獲取相同的資源并對(duì)他們進(jìn)行操作,這就造成了線程安全性問(wèn)題夹界。

如果我們把打印和減一的操作分成兩個(gè)步驟馆里,會(huì)更加明顯:



ABC 三個(gè)線程同時(shí)打印了 50 的蘋(píng)果,然后同時(shí)做出減一操作可柿。

像這樣的原子操作鸠踪,是不允許分步驟進(jìn)行的,必須保證同步進(jìn)行复斥,不然可能會(huì)引發(fā)不可設(shè)想的后果营密。

要解決上述多線程并發(fā)訪問(wèn)一個(gè)資源的安全性問(wèn)題,就需要引入線程同步的概念目锭。

線程同步

多個(gè)執(zhí)行線程共享一個(gè)資源的情景评汰,是最常見(jiàn)的并發(fā)編程情景之一纷捞。為了解決訪問(wèn)共享資源錯(cuò)誤或數(shù)據(jù)不一致的問(wèn)題,人們引入了臨界區(qū)的概念:用以訪問(wèn)共享資源的代碼塊被去,這個(gè)代碼塊在同一時(shí)間內(nèi)只允許一個(gè)線程執(zhí)行主儡。

為了幫助編程人員實(shí)現(xiàn)這個(gè)臨界區(qū),Java(以及大多數(shù)編程語(yǔ)言)提供了同步機(jī)制编振,當(dāng)一個(gè)線程試圖訪問(wèn)一個(gè)臨界區(qū)時(shí)缀辩,它將使用一種同步機(jī)制來(lái)查看是不是已經(jīng)有其他線程進(jìn)入臨界區(qū)。如果沒(méi)有其他線程進(jìn)入臨界區(qū)踪央,他就可以進(jìn)入臨界區(qū)臀玄。如果已經(jīng)有線程進(jìn)入了臨界區(qū),它就被同步機(jī)制掛起畅蹂,直到進(jìn)入的線程離開(kāi)這個(gè)臨界區(qū)健无。如果在等待進(jìn)入臨界區(qū)的線程不止一個(gè),JVM 會(huì)選擇其中的一個(gè)液斜,其余的將繼續(xù)等待累贤。

synchronized 關(guān)鍵字

如果一個(gè)對(duì)象已用 synchronized 關(guān)鍵字聲明,那么只有一個(gè)執(zhí)行線程被允許訪問(wèn)它少漆。使用 synchronized 的好處顯而易見(jiàn):保證了多線程并發(fā)訪問(wèn)時(shí)的同步操作臼膏,避免線程的安全性問(wèn)題。但是壞處是:使用 synchronized 的方法/代碼塊的性能比不用要低一些示损。所以好的做法是:盡量減小 synchronized 的作用域渗磅。

我們還是先來(lái)解決吃蘋(píng)果的問(wèn)題,考慮一下 synchronized 關(guān)鍵字應(yīng)該加在哪里呢检访?

發(fā)現(xiàn)如果還再把 synchronized 關(guān)鍵字加在 if 里面的話始鱼,0 和 -1 又會(huì)出來(lái)了。這其實(shí)是因?yàn)楫?dāng) ABC 同是進(jìn)入到 if 語(yǔ)句中脆贵,等待臨界區(qū)釋放的時(shí)医清,拿到 1 編號(hào)的線程已經(jīng)又把 num 減一操作了,而此時(shí)最后一個(gè)等待臨界區(qū)的進(jìn)程拿到的就會(huì)是 -1 了卖氨。

同步鎖 Lock

Lock 機(jī)制提供了比 synchronized 代碼塊和 synchronized 方法更廣泛的鎖定操作会烙,同步代碼塊/ 同步方法具有的功能 Lock 都有,除此之外更強(qiáng)大双泪,更體現(xiàn)面向?qū)ο蟪炙选T诓l(fā)包的類(lèi)族中,Lock 是 JUC 包的頂層接口焙矛,它的實(shí)現(xiàn)邏輯并未用到 synchronized葫盼,而是利用了 volatile 的可見(jiàn)性。

使用 Lock 最典型的代碼如下:

class X {

    private final ReentrantLock lock = new ReentrantLock();

    public void m() {
        lock.lock();
        try {
            // ..... method body
        } finally {
            lock.unlock();
        }
    }
}

線程安全問(wèn)題

線程安全問(wèn)題只在多線程環(huán)境下才會(huì)出現(xiàn)村斟,單線程串行執(zhí)行不存在此類(lèi)問(wèn)題贫导。保證高并發(fā)場(chǎng)景下的線程安全抛猫,可以從以下四個(gè)維度考量:

維度一:數(shù)據(jù)單線程可見(jiàn)

單線程總是安全的。通過(guò)限制數(shù)據(jù)僅在單線程內(nèi)可見(jiàn)孩灯,可以避免數(shù)據(jù)被其他線程篡改闺金。最典型的就是線程局部變量,它存儲(chǔ)在獨(dú)立虛擬機(jī)棧幀的局部變量表中峰档,與其他線程毫無(wú)瓜葛败匹。TreadLocal 就是采用這種方式來(lái)實(shí)現(xiàn)線程安全的。

維度二:只讀對(duì)象

只讀對(duì)象總是安全的讥巡。它的特性是允許復(fù)制掀亩、拒絕寫(xiě)入。最典型的只讀對(duì)象有 String欢顷、Integer 等槽棍。一個(gè)對(duì)象想要拒絕任何寫(xiě)入,必須要滿足以下條件:

  • 使用 final 關(guān)鍵字修飾類(lèi)抬驴,避免被繼承炼七;
  • 使用 private final 關(guān)鍵字避免屬性被中途修改;
  • 沒(méi)有任何更新方法布持;
  • 返回值不能為可變對(duì)象豌拙。

維度三:線程安全類(lèi)

某些線程安全類(lèi)的內(nèi)部有非常明確的線程安全機(jī)制。比如 StringBuffer 就是一個(gè)線程安全類(lèi)题暖,它采用 synchronized 關(guān)鍵字來(lái)修飾相關(guān)方法姆蘸。

維度四:同步與鎖機(jī)制

如果想要對(duì)某個(gè)對(duì)象進(jìn)行并發(fā)更新操作,但又不屬于上述三類(lèi)芙委,需要開(kāi)發(fā)工程師在代碼中實(shí)現(xiàn)安全的同步機(jī)制。雖然這個(gè)機(jī)制支持的并發(fā)場(chǎng)景很有價(jià)值狂秦,但非常復(fù)雜且容易出現(xiàn)問(wèn)題灌侣。

處理線程安全的核心理念

要么只讀,要么加鎖裂问。

合理利用好 JDK 提供的并發(fā)包侧啼,往往能化腐朽為神奇。Java 并發(fā)包(java.util.concurrent堪簿,JUC)中大多數(shù)類(lèi)注釋都寫(xiě)有:@author Doug Lea痊乾。如果說(shuō) Java 是一本史書(shū),那么 Doug Lea 絕對(duì)是開(kāi)疆拓土的偉大人物椭更。Doug Lea 在當(dāng)大學(xué)老師時(shí)哪审,專(zhuān)攻并發(fā)編程和并發(fā)數(shù)據(jù)結(jié)構(gòu)設(shè)計(jì),主導(dǎo)設(shè)計(jì)了 JUC 并發(fā)包虑瀑,提高了 Java 并發(fā)編程的易用性湿滓,大大推進(jìn)了 Java 的商用進(jìn)程滴须。

參考資料


  • 《Java 零基礎(chǔ)入門(mén)教程》 - http://study.163.com/course/courseMain.htm?courseId=1003108028
  • 《Java 并發(fā)編程的藝術(shù)》
  • 《Java 7 并發(fā)編程實(shí)戰(zhàn)手冊(cè)》
  • 《碼出高效 Java 開(kāi)發(fā)手冊(cè)》 - 楊冠寶(孤盡) 高海慧(鳴莎)著

按照慣例黏一個(gè)尾巴:

歡迎轉(zhuǎn)載叽奥,轉(zhuǎn)載請(qǐng)注明出處扔水!
獨(dú)立域名博客:wmyskxz.com
簡(jiǎn)書(shū) ID:@我沒(méi)有三顆心臟
github:wmyskxz
歡迎關(guān)注公眾微信號(hào):wmyskxz
分享自己的學(xué)習(xí) & 學(xué)習(xí)資料 & 生活
想要交流的朋友也可以加 qq 群:3382693

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市朝氓,隨后出現(xiàn)的幾起案子魔市,更是在濱河造成了極大的恐慌,老刑警劉巖赵哲,帶你破解...
    沈念sama閱讀 219,589評(píng)論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件待德,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡誓竿,警方通過(guò)查閱死者的電腦和手機(jī)磅网,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,615評(píng)論 3 396
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)筷屡,“玉大人涧偷,你說(shuō)我怎么就攤上這事”兴溃” “怎么了燎潮?”我有些...
    開(kāi)封第一講書(shū)人閱讀 165,933評(píng)論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)扼倘。 經(jīng)常有香客問(wèn)我确封,道長(zhǎng),這世上最難降的妖魔是什么再菊? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,976評(píng)論 1 295
  • 正文 為了忘掉前任爪喘,我火速辦了婚禮,結(jié)果婚禮上纠拔,老公的妹妹穿的比我還像新娘秉剑。我一直安慰自己,他們只是感情好稠诲,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,999評(píng)論 6 393
  • 文/花漫 我一把揭開(kāi)白布侦鹏。 她就那樣靜靜地躺著,像睡著了一般臀叙。 火紅的嫁衣襯著肌膚如雪略水。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 51,775評(píng)論 1 307
  • 那天劝萤,我揣著相機(jī)與錄音渊涝,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛驶赏,可吹牛的內(nèi)容都是我干的炸卑。 我是一名探鬼主播,決...
    沈念sama閱讀 40,474評(píng)論 3 420
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼煤傍,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼盖文!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起蚯姆,我...
    開(kāi)封第一講書(shū)人閱讀 39,359評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤五续,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后龄恋,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體疙驾,經(jīng)...
    沈念sama閱讀 45,854評(píng)論 1 317
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,007評(píng)論 3 338
  • 正文 我和宋清朗相戀三年郭毕,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了它碎。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,146評(píng)論 1 351
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡显押,死狀恐怖扳肛,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情乘碑,我是刑警寧澤挖息,帶...
    沈念sama閱讀 35,826評(píng)論 5 346
  • 正文 年R本政府宣布,位于F島的核電站兽肤,受9級(jí)特大地震影響套腹,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜资铡,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,484評(píng)論 3 331
  • 文/蒙蒙 一电禀、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧笤休,春花似錦鞭呕、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 32,029評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)瓦糕。三九已至底洗,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間咕娄,已是汗流浹背亥揖。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,153評(píng)論 1 272
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人费变。 一個(gè)月前我還...
    沈念sama閱讀 48,420評(píng)論 3 373
  • 正文 我出身青樓摧扇,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親挚歧。 傳聞我的和親對(duì)象是個(gè)殘疾皇子扛稽,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,107評(píng)論 2 356

推薦閱讀更多精彩內(nèi)容