在工作中,我發現很多人在設計之初都是直接按照單線程的思路來寫程序的,而忽略了本應該重視的并發問題;等上線后的某天,突然發現詭異的 Bug,再歷經千辛萬苦終于定位到問題所在,卻發現對于如何解決已經沒有了思路。
關于這個問題,我覺得咱們今天很有必要好好聊聊“如何用面向對象思想寫好并發程序”這個話題。
面向對象思想與并發編程有關系嗎?本來是沒關系的,它們分屬兩個不同的領域,但是在 Java 語言里,這兩個領域被無情地融合在一起了,好在融合的效果還是不錯的:在 Java 語言里,面向對象思想能夠讓并發編程變得更簡單。
那如何才能用面向對象思想寫好并發程序呢?結合我自己的工作經驗來看,我覺得你可以從封裝共享變量、識別共享變量間的約束條件和制定并發訪問策略這三個方面下手。
一、封裝共享變量
并發程序,我們關注的一個核心問題,不過是解決多線程同時訪問共享變量的問題。
面向對象思想里面有一個很重要的特性是封裝,封裝的通俗解釋就是將屬性和實現細節封裝在對象內部,外界對象只能通過目標對象提供的公共方法來間接訪問這些內部屬性,這和門票管理模型匹配度相當的高,球場里的座位就是對象屬性,球場入口就是對象的公共方法。我們把共享變量作為對象的屬性,那對于共享變量的訪問路徑就是對象的公共方法,所有入口都要安排檢票程序就相當于我們前面提到的并發訪問策略。
利用面向對象思想寫并發程序的思路,其實就這么簡單:將共享變量作為對象屬性封裝在內部,對所有公共方法制定并發訪問策略。 就拿很多統計程序都要用到計數器來說,下面的計數器程序共享變量只有一個,就是 value,我們把它作為 Counter 類的屬性,并且將兩個公共方法 get()
和 addOne()
聲明為同步方法,這樣 Counter 類就成為一個線程安全的類了。
public class Counter {private long value;synchronized long get(){return value;}synchronized long addOne(){return ++value;}
}
當然,實際工作中,很多的場景都不會像計數器這么簡單,經常要面臨的情況往往是有很多的共享變量,例如,信用卡賬戶有卡號、姓名、身份證、信用額度、已出賬單、未出賬單等很多共享變量。這么多的共享變量,如果每一個都考慮它的并發安全問題,那我們就累死了。但其實仔細觀察,你會發現,很多共享變量的值是不會變的,例如信用卡賬戶的卡號、姓名、身份證。對于這些不會發生變化的共享變量,建議你用 final 關鍵字來修飾。 這樣既能避免并發問題,也能很明了地表明你的設計意圖,讓后面接手你程序的兄弟知道,你已經考慮過這些共享變量的并發安全問題了。
二、識別共享變量間的約束條件
識別共享變量間的約束條件非常重要。因為這些約束條件,決定了并發訪問策略。 例如,庫存管理里面有個合理庫存的概念,庫存量不能太高,也不能太低,它有一個上限和一個下限。關于這些約束條件,我們可以用下面的程序來模擬一下。在類 SafeWM
中,聲明了兩個成員變量 upper
和 lower
,分別代表庫存上限和庫存下限,這兩個變量用了 AtomicLong
這個原子類,原子類是線程安全的,所以這兩個成員變量的 set
方法就不需要同步了。
public class SafeWM {// 庫存上限private final AtomicLong upper = new AtomicLong(0);// 庫存下限private final AtomicLong lower = new AtomicLong(0);// 設置庫存上限void setUpper(long v){upper.set(v);}// 設置庫存下限void setLower(long v){lower.set(v);}// 省略其他業務代碼
}
雖說上面的代碼是沒有問題的,但是忽視了一個約束條件,就是庫存下限要小于庫存上限,這個約束條件能夠直接加到上面的 set
方法上嗎?我們先直接加一下看看效果(如下面代碼所示)。我們在 setUpper()
和 setLower()
中增加了參數校驗,這乍看上去好像是對的,但其實存在并發問題,問題在于存在競態條件。這里我順便插一句,其實當你看到代碼里出現 if 語句的時候,就應該立刻意識到可能存在競態條件。
我們假設庫存的下限和上限分別是 (2,10),線程 A 調用 setUpper(5) 將上限設置為 5,線程 B 調用 setLower(7) 將下限設置為 7,如果線程 A 和線程 B 完全同時執行,你會發現線程 A 能夠通過參數校驗,因為這個時候,下限還沒有被線程 B 設置,還是 2,而 5>2;線程 B 也能夠通過參數校驗,因為這個時候,上限還沒有被線程 A 設置,還是 10,而 7<10。當線程 A 和線程 B 都通過參數校驗后,就把庫存的下限和上限設置成 (7, 5) 了,顯然此時的結果是不符合庫存下限要小于庫存上限這個約束條件的。
public class SafeWM {// 庫存上限private final AtomicLong upper = new AtomicLong(0);// 庫存下限private final AtomicLong lower = new AtomicLong(0);// 設置庫存上限void setUpper(long v){// 檢查參數合法性if (v < lower.get()) {throw new IllegalArgumentException();}upper.set(v);}// 設置庫存下限void setLower(long v){// 檢查參數合法性if (v > upper.get()) {throw new IllegalArgumentException();}lower.set(v);}// 省略其他業務代碼
}
在沒有識別出庫存下限要小于庫存上限這個約束條件之前,我們制定的并發訪問策略是利用原子類,但是這個策略,完全不能保證庫存下限要小于庫存上限這個約束條件。所以說,在設計階段,我們一定要識別出所有共享變量之間的約束條件,如果約束條件識別不足,很可能導致制定的并發訪問策略南轅北轍。
共享變量之間的約束條件,反映在代碼里,基本上都會有 if 語句,所以,一定要特別注意競態條件。
三、制定并發訪問策略
制定并發訪問策略,是一個非常復雜的事情。應該說整個專欄都是在嘗試搞定它。不過從方案上來看,無外乎就是以下“三件事”。
- 避免共享:避免共享的技術主要是利于線程本地存儲以及為每個任務分配獨立的線程。
- 不變模式:這個在 Java 領域應用的很少,但在其他領域卻有著廣泛的應用,例如 Actor 模式、CSP 模式以及函數式編程的基礎都是不變模式。
- 管程及其他同步工具:Java 領域萬能的解決方案是管程,但是對于很多特定場景,使用 Java 并發包提供的讀寫鎖、并發容器等同步工具會更好。
接下來在咱們專欄的第二模塊我會仔細講解 Java 并發工具類以及他們的應用場景,在第三模塊我還會講解并發編程的設計模式,這些都是和制定并發訪問策略有關的。
除了這些方案之外,還有一些宏觀的原則需要你了解。這些宏觀原則,有助于你寫出“健壯”的并發程序。這些原則主要有以下三條。
- 優先使用成熟的工具類:Java SDK 并發包里提供了豐富的工具類,基本上能滿足你日常的需要,建議你熟悉它們,用好它們,而不是自己再“發明輪子”,畢竟并發工具類不是隨隨便便就能發明成功的。
- 迫不得已時才使用低級的同步原語:低級的同步原語主要指的是
synchronized
、Lock
、Semaphore
等,這些雖然感覺簡單,但實際上并沒那么簡單,一定要小心使用。 - 避免過早優化:安全第一,并發程序首先要保證安全,出現性能瓶頸后再優化。在設計期和開發期,很多人經常會情不自禁地預估性能的瓶頸,并對此實施優化,但殘酷的現實卻是:性能瓶頸不是你想預估就能預估的。
總結
寫在最后
很多人感嘆“學習無用”,實際上之所以產生無用論,是因為自己想要的與自己所學的匹配不上,這也就意味著自己學得遠遠不夠。無論是學習還是工作,都應該有主動性,所以如果擁有大廠夢,那么就要自己努力去實現它。
以上學習資料均免費放送,最后祝愿各位身體健康,順利拿到心儀的offer!
由于文章的篇幅有限,所以這次的螞蟻金服和京東面試題答案整理在了PDF文檔里
資料獲取方式:點贊+評論我的文章,關注我,然后戳這里即可免費領取
CuqNXO-1623614570590)]
[外鏈圖片轉存中…(img-dlpWA0LK-1623614570592)]
[外鏈圖片轉存中…(img-mswpUISq-1623614570593)]