Java并發原理無廢話指南

nmfny6z 8年前發布 | 23K 次閱讀 并發 Java Java開發

你們要的后續我寫了,如果喜歡請把賬號推薦給你的朋友,謝謝。

進程、線程和并發實體

《操作系統原理》里面很重要的一個概念是進程。進程是程序動態的概念,它 用來表示程序在執行的一組數據結構 。這組數據結構中記錄了指令加載到內存中的地址,打開的文件,線程信息,共享內存等。

每個進程可以有多個線程。它也是一組數據結構包括:下一條要執行的指令,寄存器,堆棧,狀態等。一幅圖來表示

上圖畫出了4個線程(線程2、3、4和1是一樣的,沒有全畫),如果程序沒有啟動任何線程,其實也會用到線程——主線程(圖中的線程1)。 所以最后消耗CPU的線程而不是進程

其實在Linux中(我實在不知道Windows,我猜應該差不多)進程和線程是同一個數據結構——task_struct,對于內核(kernel)來說并沒有進程和線程的區別,只有進程——kernel稱之為task。所以 在Linux中進程和線程并沒有父子關系而是平行的結構 ,表示進程的數據結構填充的數據多一些,包括了打開文件,共享內存之類的,這個被稱為主線程;其他線程的數據結構這些項目則為空,并且有一個“父進程”的指針,指向了“主線程”。

明白了這一點我們就清楚了, 操作系統調度的最小對象其實是——線程 ,但是名字叫task,教科書上叫進程。。。。有點混亂了嗎?所以我們引入一個新的術語—— 并發實體 。所有CPU調度的最小單位我們統稱為并發實體,無論是進程還是線程或者是其他的什么“怪胎”。(沒錯,我會在下一次介紹這些怪胎。)

為什么要線程

勇敢提出這個問題的人要受到表揚,他冒著被無情嘲諷的危險提出了一個很白癡的問題。(我覺得回答不出來這個問題的人,才是真正的白癡)回答這個問題要回答另一個基本的問題——為什么要并發

我們想象一下,一個Web服務器,可能是下面的代碼

while (true){
     request = next_http_request()
     request_work(request)
}

程序循環獲取新請求(next_http_request),執行請求(request_work),然后繼續下一次循環。request_work會從硬盤讀取文件,然后發送給客戶端作為HTTP的響應,而硬盤I/O是一個阻塞操作,也就是說request_work會一直等待讀取完數據之后才能釋放CPU的控制權,然后下一個請求才有機會被執行。

這就是并發要解決的問題,當request_work發起I/O之后CPU是完全空閑下來的,而可憐的新請求(next_http_request)必須等待I/O完成之后才可以獲取CPU的控制權。所以CPU的利用率非常低, 并發要解決的問題就是提高CPU的利用率 。明白這一點我們也就清楚了,并發只對非CPU密集型程序管用,如果CPU利用率非常高,更多的并發只會讓情況更加糟糕

那么并發為什么一定是多線程而不是多進程呢?其實在Linux下進程和線程的創建成本沒有什么區別(都是task_struct),但是進程之間可以共享數據的方式只能通過非常復雜的IPC來實現, 線程之間代碼都是共享的,地址空間也是共享的,所以共享數據的方式更加高效。 (進程要考慮隔離,一個進程沒有辦法直接訪問另一個進程;線程不用隔離,線程之間共享內存)

我們修改成多線程版本的Web服務器

while (true){
     request = next_http_request()
     request_work_in_thread(request)
}

request_work_in_thread方法會啟動一個線程(work線程),然后CPU開始執行next_http_request獲得下一個請求。

request對象是在主線程創建的,可以直接傳遞給request_work_in_thread中的work線程使用。

我們提高CPU利用率所以需要并行,我們要提高并發實體之間共享數據的效率所以選擇了線程作為并發實體的實現

Java的線程

好了,回到了Java。在Java中啟動一個線程非常簡單——只要new Thread就搞定了。JVM會把它變成操作系統的API,如果是Linux則會生成一個task_struct的結構。至于Runable之類的東西其實最后還是Thread,即便是Java并發包最后也還是用Thread。

所以至此,我們成功把《操作系統原理》中進程、線程和Java的線程“融匯貫通了”。下面開始另一個東西——PV操作。

競爭條件,臨界區、PV信號量

恩,你這一部分估計也已經還給老師了。沒關系,我們一起回憶一下。

舉個例子:

public void plus(int value){
  count = count + value;
}

當多線程同時調用plus的時候程序的邏輯是錯誤的。count+value并不是一個原子操作,它會被變成三個CPU指令

  • 獲取count的數據到寄存器(還記得嗎?CPU只訪問寄存器)

  • 寄存器+value,并且寫回寄存器

  • 寄存器寫回到內存

    如果T1,T2兩個線程同時執行(count=0)

  • T1 獲取count的數據到寄存器

  • T1 將寄存器的值加10

  • T2 獲取count的數據到寄存器

  • T2 將寄存器的值加30

  • T2 寄存器寫回到內存(count=30)

  • T1 寄存器寫回到內存(count=10)

    我們期望的可能是40,但是實際情況是10,因為T2訪問數據的時候T1還沒有來得及寫回到內存中。當兩個線程訪問同一個數據,最后的結果依賴于線程的順序這個就叫競爭條件。避免競爭條件的方法就是——通過臨界區把一組動作“原子化”。例子中就是把:count = count + value,原子化。(原子化是指一次做完;其他人排隊等候。)

就像你去買咖啡,收銀員是所有人共享的(競爭條件),如果他經歷:問杯型、種類、口味;收費;給你發票,這三個過程不能被打斷否則會亂掉的。所以大家需要依次排隊。(臨界區)

如何實現臨界區?答案是PV信號量(也叫PV操作,PV原語)。它是著名“河南籍”計算機科學家——E.W.Dijkstra設計的一套算法,老爺子這套嚴密的理論是現代并發的基礎。簡單來說他定義了兩個操作

設一個計數器s

  • P s-1,如果s小于0則休眠否則繼續執行

  • V s+1,如果s<=0則喚醒等待進程否則繼續執行

P操作相當于使用資源,執行這個操作相當于:這個桌子我承包了,你們等我用過之后再用。(如果桌子有人那就只能乖乖等著了)

V操作相當于釋放資源,執行這個操作相當于:這個桌子我不用了,下一個是誰?來用吧。(如果沒有下一個人,那就直接走人了)

沒錯,PV就是“鎖”。《操作系統原理》中競爭條件、臨界區都是現實中不存在的概念,只有PV操作被具體實現了,就是我們稱之為鎖的東西。

Java中的鎖

所有的“鎖”都是一種PV操作,鎖的區別在于你選擇的“計數器”是什么?比如你選擇的計數器是當前對象那么對應的關鍵是“synchronized”(還有個名字叫管程,天真的科學家們覺得面向對象的鎖好牛B,就賜予它一個專門的名詞),如果你的計數器是一個原子類型的值那么可能就是AtomicInteger的inc或者dec操作。這個就是 鎖的粒度 ,鎖越小競爭條件就越小。就像你買咖啡,把整個咖啡館當做“競爭條件”或者把收銀員當做“競爭條件”,很像然后者對咖啡館的利用率更高。(還有一些買過咖啡的人至少可以逗留一會)

總結

我不想在文章中介紹Java的API,并發包的類。(這些介紹資料一抓一大把實在太多了)我的目的是通過回憶基礎課程,嘗試把我們認為最沒用的東西聯系到實際中。讓大家知道——原來教科書上不是騙人的,真的是無處不在。希望你看完這篇文章,能夠默默的去翻開塵封已久的《操作系統原理》。郭德綱老師有句話說:演員和演員最后比拼的是文化底蘊;同樣的道理, 程序員和程序員最后比拼的是基礎 ,當你羨慕別人“游刃有余”的輕松解決一個很難纏的BUG時;動動手指搞定解決了性能問題時;短時間內迅速領悟到一個新技術時,請不要忘記他在這個背后可能花了幾年甚至幾十年的功夫在練習被你遺棄的“教科書”。

最后推薦幾本靠譜的操作系統書(不要浮躁,每本書都值得我們花上一年半載的慢慢欣賞)

  • 現代操作系統 我從未見過如此牛B的操作系統書,沒有之一!!!用最通熟易懂的語言,最簡單的例子解釋最復雜的道理。最牛B的是,它還很現代,非常現代。

  • 計算機的心智:操作系統之哲學原理 鄒恒明,上海交大的教授寫的。本地書的特點就是薄,你喜歡看薄書這個是一個好選擇。

  • Linux內核設計與實現,薄書。如果你想了解Linux Kernel有不想太深入,這本書絕對的適合你

 

來自: http://mp.weixin.qq.com/s?__biz=MzIxMjAzMDA1MQ==&mid=2648945438&idx=1&sn=20deb07c871a3460b7d8d5fb2f304e05#rd

 

 本文由用戶 nmfny6z 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!