為更良好的閱讀體驗(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)系?
單核 CPU 中鸠姨,將 CPU 分為很小的時(shí)間片铜秆,在每一時(shí)刻只能有一個(gè)線程在執(zhí)行,是一種微觀上輪流占用 CPU 的機(jī)制享怀。
多線程會(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ū)別:
繼承方式:
- Java 中類(lèi)是單繼承的腹暖,如果繼承了 Thread 了,該類(lèi)就不能再有其他的直接父類(lèi)了翰萨;
- 從操作上分析脏答,繼承方式更簡(jiǎn)單,獲取線程名字也簡(jiǎn)單..(操作上亩鬼,更簡(jiǎn)單)
- 從多線程共享同一個(gè)資源上分析殖告,繼承方式不能做到...
實(shí)現(xiàn)方式:
- Java 中類(lèi)可以實(shí)現(xiàn)多個(gè)接口,此時(shí)該類(lèi)還可以繼承其他類(lèi)雳锋,并且還可以實(shí)現(xiàn)其他接口(設(shè)計(jì)上黄绩,更優(yōu)雅)..
- 從操作上分析,實(shí)現(xiàn)方式稍微復(fù)雜點(diǎn)魄缚,獲取線程名字也比較復(fù)雜宝与,需要使用
Thread.currentThread()
來(lái)獲取當(dāng)前線程的引用.. - 從多線程共享同一個(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