Java學(xué)習(xí)筆記(4)——并發(fā)基礎(chǔ)

前言

當我們使用計算機時,可以同時做許多事情,例如一邊打游戲一邊聽音樂。這是因為操作系統(tǒng)支持并發(fā)任務(wù),從而使得這些工作得以同時進行。

那么提出一個問題:如果我們要實現(xiàn)一個程序能一邊聽音樂一邊玩游戲怎么實現(xiàn)呢?

提出的問題

我們使用了循環(huán)來模擬過程,因為播放音樂和打游戲都是連續(xù)的,但是結(jié)果卻不盡人意,因為函數(shù)體總是要執(zhí)行完之后才能返回。那么到底怎么解決這個問題?下面來說。

并行與并發(fā)

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

并行性是指兩個或多個事件在同一時刻發(fā)生。而并發(fā)性是指連個或多個事件在同一時間間隔內(nèi)發(fā)生。在多道程序環(huán)境下,并發(fā)性是指在一段時間內(nèi)宏觀上有多個程序在同時運行,但在單處理機環(huán)境下(一個處理器),每一時刻卻僅能有一道程序執(zhí)行,故微觀上這些程序只能是分時地交替執(zhí)行。例如,在1秒鐘時間內(nèi),0-15ms程序A運行;15-30ms程序B運行;30-45ms程序C運行;45-60ms程序D運行,因此可以說,在1秒鐘時間間隔內(nèi),宏觀上有四道程序在同時運行,但微觀上,程序A、B、C、D是分時地交替執(zhí)行的。

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

并發(fā)和并行

進程和線程

進程是指一個內(nèi)存中運行的應(yīng)用程序。一個應(yīng)用程序可以同時啟動多個進程,那么上面的問題就有了解決的思路:我們啟動兩個進程,一個用來打游戲,一個用來播放音樂。這當然是一種解決方案,但是想象一下,如果一個應(yīng)用程序需要執(zhí)行的任務(wù)非常多,例如LOL游戲吧,光是需要播放的音樂就有非常多,人物本身的語音,技能的音效,游戲的背景音樂,塔攻擊的聲音等等等,還不用說游戲本身,就光播放音樂就需要創(chuàng)建許多許多的進程,而進程本身是一種非常消耗資源的東西,這樣的設(shè)計顯然是不合理的。更何況大多數(shù)的操作系統(tǒng)都不需要一個進程訪問其他進程的內(nèi)存空間,也就是說,進程之間的通信很不方便,此時我們就得引入“線程”這門技術(shù),來解決這個問題。

線程是指進程中的一個執(zhí)行任務(wù)(控制單元),一個進程可以同時并發(fā)運行多個線程。打開我們的任務(wù)管理器,在【查看】里面點擊【選擇列】,有一個線程數(shù)的勾選項,找到并勾選,可以看到:

任務(wù)管理器

進程和線程的區(qū)別:

進程:有獨立的內(nèi)存空間,進程中的數(shù)據(jù)存放空間(堆空間和??臻g)是獨立的,至少有一個線程。

線程:堆空間是共享的,??臻g是獨立的,線程消耗的資源也比進程小,相互之間可以影響的,又稱為輕型進程或進程元。

因為一個進程中的多個線程是并發(fā)運行的,那么從微觀角度上考慮也是有先后順序的,那么哪個線程執(zhí)行完全取決于CPU調(diào)度器(JVM來調(diào)度),程序員是控制不了的。我們可以把多線程并發(fā)性看作是多個線程在瞬間搶CPU資源,誰搶到資源誰就運行,這也造就了多線程的隨機性。下面我們將看到更生動的例子。

Java程序的進程(Java的一個程序運行在系統(tǒng)中)里至少包含主線程和垃圾回收線程(后臺線程):

你可以簡單的這樣認為,但實際上有四個線程(了解就好):
[1] main——main線程,用戶程序入口
[2] Reference Handler——清除Reference的線程
[3] Finalizer——調(diào)用對象finalize方法的線程
[4] Signal Dispatcher——分發(fā)處理發(fā)送給JVM信號的線程

多線程的優(yōu)勢:

盡管面臨很多挑戰(zhàn),多線程有一些優(yōu)點使得它一直被使用。這些優(yōu)點是:

  • 資源利用率更好
  • 程序設(shè)計在某些情況下更簡單
  • 程序響應(yīng)更快

(1)資源利用率更好

想象一下,一個應(yīng)用程序需要從本地文件系統(tǒng)中讀取和處理文件的情景。比方說,從磁盤讀取一個文件需要5秒,處理一個文件需要2秒。處理兩個文件則需要:

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

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

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

CPU等待第一個文件被讀取完。然后開始讀取第二個文件。當?shù)诙募诒蛔x取的時候,CPU會去處理第一個文件。記住,在等待磁盤讀取文件的時候,CPU大部分時間是空閑的。

總的說來,CPU能夠在等待IO的時候做一些其他的事情。這個不一定就是磁盤IO。它也可以是網(wǎng)絡(luò)的IO,或者用戶輸入。通常情況下,網(wǎng)絡(luò)和磁盤的IO比CPU和內(nèi)存的IO慢的多。

(2)程序設(shè)計更簡單

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

(3)程序響應(yīng)更快

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

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

多線程的還有一些優(yōu)勢也顯而易見:

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

上下文切換

即使是單核處理器也支持多線程執(zhí)行代碼,CPU通過給每個線程分配CPU時間片來實現(xiàn)這個機制。時間片是CPU分配給各個線程的時間,因為時間片非常短,所以CPU通過不停地切換線程執(zhí)行,讓我們感覺多個線程是同時執(zhí)行的,時間片一般是幾十毫秒(ms)。

CPU通過時間片分配算法來循環(huán)執(zhí)行任務(wù),當前任務(wù)執(zhí)行一個時間片后會切換到下一個任務(wù)。但是,在切換前會保存上一個任務(wù)的狀態(tài),以便下次切換回這個任務(wù)的時候,可以再加載這個任務(wù)的狀態(tài)。所以任務(wù)從保存到再加載的過程就是一次上下文切換。

這就像我們同時讀兩本書,當我們在讀一本英文的技術(shù)書時,發(fā)現(xiàn)某個單詞不認識,于是打開中英文字典,但是在放下英文技術(shù)書之前,大腦必須先記住這本書獨到了多少頁的多少行,等查完單詞之后,能夠繼續(xù)讀這本書。這樣的切換是會影響讀書效率的,同樣上下文切換也會影響多線程的執(zhí)行速度。

創(chuàng)建線程的兩種方式

繼承Thead類:

繼承Thread類

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

實現(xiàn)Runnable接口:

實現(xiàn)Runnable接口

也能完成效果。

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

多線程一定快嗎?

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

多線程一定快嗎?

以下是我測試的結(jié)果,可以看出,當不超過1百萬的時候,并行是明顯比串行要慢的,為什么并發(fā)執(zhí)行的速度會比串行慢呢?這是因為線程有創(chuàng)建和上下文切換的開銷。

測試結(jié)果

繼承Thread類還是實現(xiàn)Runnable接口?

吃蘋果比賽

想象一個這樣的例子:給出一共50個蘋果,讓三個同學(xué)一起來吃,并且給蘋果編上號碼,讓他們吃的時候順便要說出蘋果的編號:

吃蘋果比賽

運行結(jié)果可以看到,使用繼承方式實現(xiàn),每一個線程都吃了50個蘋果。這樣的結(jié)果顯而易見:是因為顯式地創(chuàng)建了三個不同的Person對象,而每個對象在堆空間中有獨立的區(qū)域來保存定義好的50個蘋果。

而使用實現(xiàn)方式則滿足要求,這是因為三個線程共享了同一個Apple對象,而對象中的num數(shù)量是一定的。

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


兩種方式的區(qū)別

對于這兩種方式哪種好并沒有一個確定的答案,它們都能滿足要求。就我個人意見,我更傾向于實現(xiàn)Runnable接口這種方法。因為線程池可以有效的管理實現(xiàn)了Runnable接口的線程,如果線程池滿了,新的線程就會排隊等候執(zhí)行,直到線程池空閑出來為止。而如果線程是通過實現(xiàn)Thread子類實現(xiàn)的,這將會復(fù)雜一些。

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

常見的錯誤:調(diào)用run()方法而非start()方法

創(chuàng)建并運行一個線程所犯的常見錯誤是調(diào)用線程的run()方法而非start()方法,如下所示:

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

起初你并不會感覺到有什么不妥,因為run()方法的確如你所愿的被調(diào)用了。但是,事實上,run()方法并非是由剛創(chuàng)建的新線程所執(zhí)行的,而是被創(chuàng)建新線程的當前線程所執(zhí)行了。也就是被執(zhí)行上面兩行代碼的線程所執(zhí)行的。想要讓創(chuàng)建的新線程執(zhí)行run()方法,必須調(diào)用新線程的start方法。

吃蘋果比賽的問題:線程不安全問題

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

吃蘋果比賽中的問題

為什么會出現(xiàn)這樣的錯誤呢?

先來分析第一種錯誤:為什么會吃重復(fù)的蘋果呢?就拿B和C都吃了編號為47的蘋果為例吧:

  • ① A線程拿到了編號為48的蘋果,打印輸出然后讓num減1,睡眠10ms,此時num為47。
  • ② 這時B和C同時都拿到了編號為47的蘋果,打印輸出,在其中一個線程作出了減一操作的時候,A線程從睡眠中醒過來,拿到了編號為46的蘋果,然后輸出。在這期間并沒有任何操作不允許B和C線程不能拿到同一個編號的蘋果,之前沒有明顯的錯誤僅僅可能只是因為運行速度太快了。

再來分析第二種錯誤:照理來說只應(yīng)該存在1-50編號的蘋果,可是0和-1是怎么出現(xiàn)的呢?

  • ① 當num=1的時候,A,B,C三個線程同時進入了try語句進行睡眠。
  • ② C線程先醒過來,輸出了編號為1的蘋果,然后讓num減一,當C線程醒過來的時候發(fā)現(xiàn)num為0了。
  • ③ A線程醒過來一看,0都沒有了,只有-1了。

歸根結(jié)底是因為沒有任何操作來限制線程來獲取相同的資源并對他們進行操作,這就造成了線程安全性問題。

如果我們把打印和減一的操作分成兩個步驟,會更加明顯:


拆成兩個步驟

ABC三個線程同時打印了50的蘋果,然后同時做出減一操作。

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

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

線程同步

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

為了幫助編程人員實現(xiàn)這個臨界區(qū),Java(以及大多數(shù)編程語言)提供了同步機制,當一個線程試圖訪問一個臨界區(qū)時,它將使用一種同步機制來查看是不是已經(jīng)有其他線程進入臨界區(qū)。如果沒有其他線程進入臨界區(qū),他就可以進入臨界區(qū)。如果已經(jīng)有線程進入了臨界區(qū),它就被同步機制掛起,直到進入的線程離開這個臨界區(qū)。如果在等待進入臨界區(qū)的線程不止一個,JVM會選擇其中的一個,其余的將繼續(xù)等待。

synchronized關(guān)鍵字

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

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


synchronized應(yīng)放在哪里?

發(fā)現(xiàn)如果還再把synchronized關(guān)鍵字加在if里面的話,0和-1又會出來了。這其實是因為當ABC同是進入到if語句中,等待臨界區(qū)釋放的時,拿到1編號的線程已經(jīng)又把num減一操作了,而此時最后一個等待臨界區(qū)的進程拿到的就會是-1了。

同步鎖(Lock)

Lock機制提供了比synchronized代碼塊和synchronized方法更廣泛的鎖定操作,同步代碼塊/同步方法具有的功能Lock都有,除此之外更強大,更體現(xiàn)面向?qū)ο蟆?/p>

摘自JDK1.6中文版說明的代碼

參考資料:


歡迎轉(zhuǎn)載,轉(zhuǎn)載請注明出處!
簡書ID:@我沒有三顆心臟
github:wmyskxz
歡迎關(guān)注公眾微信號:wmyskxz
分享自己的學(xué)習(xí) & 學(xué)習(xí)資料 & 生活
想要交流的朋友也可以加qq群:3382693

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

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

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