關於解決併發問題,99%的程序員都會忽略的一個重要方案!

△Hollis, 一個對Coding有着獨特追求的人△
這是Hollis的第 370 篇原創分享
作者 l zyz1992
來源 l Hollis(ID:hollischuang)
在併發編程的世界裏,共享變量的線程安全問題永遠是一個無法避免且不得不面對的問題,如果只有讀的情況,那麼永遠也不會出現線程安全的問題,因爲多線程讀永遠是線程安全的,但是多線程讀寫一定會存在線程安全的問題。
那既然這麼說是不是通過只讀就能解決併發問題呢?其實最簡單的辦法就是讓共享變量只有讀操作,而沒有寫操作。這個辦法如此重要,以至於被上升到了一種解決併發問題的設計模式:不變性(Immutability)模式
所謂不變性,簡單來講,就是對象一旦被創建之後,狀態就不再發生變化。換句話說,就是變量一旦被賦值,就不允許修改了(沒有寫操作);沒有修改操作,也就是保持了不變性。



1、不可變性的類
在 java 中,如果要實現一個不可變的對象是很簡單的,將其定義爲 final 即可,同樣類也是如此,只需要通過 final 來修飾某個類即可。同時將一個類所有的屬性都設置成 final 的,並且只允許存在只讀方法,那麼這個類基本上就具備不可變性了。
更嚴格的做法是這個類本身也是 final 的,也就是不允許繼承。因爲子類可以覆蓋父類的方法,有可能改變不可變性,所以推薦你在實際工作中,使用這種更嚴格的做法。
我們在日常開發中,已經在不知不覺中享受不可變模式帶來的好處,例如經常用到的 String、Long、Integer、Double 等基礎類型的包裝類都具備不可變性,這些對象的線程安全性都是靠不可變性來保證的。

仔細翻看這些類的聲明、屬性和方法,你會發現它們都嚴格遵守不可變類的三點要求:類是 final 的,屬性也是 final 的。同樣的一旦某個類被 final 修飾,其本身就不能被繼承了,也就無法重寫其方法,即方法是隻讀的。

既然說方法是隻讀的,但是 Java 的 String 方法也有類似字符替換操作,這個不就已經改變了value[] 變量了嗎?因爲 value[] 是這麼定義的。

我們結合 String 的源代碼(jdk8)來看一下 jdk 是如何處理這個問題的,下面是源碼的截圖

它實際上是重新定義了一個新的 buf[] 來保存數據,這樣在最後返回數據的時候確實沒有修改 原始的value[],而是將替換後的字符串作爲返回值返回了。

通過分析 String 的實現,你可能已經發現了,如果具備不可變性的類,需要提供類似修改的功能,具體該怎麼操作呢?做法很簡單,那就是創建一個新的不可變對象,這是與可變對象的一個重要區別,可變對象往往是修改自己的屬性。
所有的修改操作都創建一個新的不可變對象。但是一個問題的解決必然會帶來的新的問題,那就是這樣勢必在每次使用的時候都會創建新的對象,那豈不是無端的降低了系統的性能了浪費了系統的資源?這個時候享元模式就可以大顯神通了。



2、享元模式避免創建重複對象
享元模式你可能實際開發中使用的很少,它是這麼定義的:
享元模式(Flyweight Pattern):是一種軟件設計模式。它使用共享物件,用來儘可能減少內存使用量以及分享資訊給儘可能多的相似物件;它適合用於只是因重複而導致使用無法令人接受的大量內存的大量物件。
通常物件中的部分狀態是可以分享。常見做法是把它們放在外部數據結構,當需要使用時再將它們傳遞給享元
看不懂沒關係,用一句直白話來概括就是:通過對象池的技術來避免重複的創建對象。這就好比是 Spring 中的容器(單例模式下),我們的對象都交給 Spring 容器來管理,這樣我們再使用的時候只需要到容器中去拿即可,而不是每次都去創建新的對象。
利用享元模式可以減少創建對象的數量,從而減少內存佔用。Java 語言裏面 Long、Integer、Short、Byte 等這些基本數據類型的包裝類都用到了享元模式。
享元模式本質上其實就是一個對象池,利用享元模式創建對象的邏輯也很簡單:創建之前,首先去對象池裏看看是不是存在;如果已經存在,就利用對象池裏的對象;如果不存在,就會新創建一個對象,並且把這個新創建出來的對象放進對象池裏
jdk 源碼中是如何使用享元模式的呢?我們以 Long 這個類爲例來解釋說明下。
Long 這個類並沒有照搬享元模式,Long 內部維護了一個靜態的對象池,僅緩存了[-128,127]之間的數字,這個對象池在 JVM 啓動的時候就創建好了,而且這個對象池一直都不會變化,也就是說它是靜態的。之所以採用這樣的設計,是因爲 Long 這個對象的狀態共有 264 種,實在太多,不宜全部緩存,而[-128,127]之間的數字利用率最高。
下面的示例代碼出自 Java 1.8,valueOf() 方法就用到了 LongCache 這個緩存,你可以結合着來加深理解。

在看下 LongCache 中的 cache 方法(關鍵地方都在圖片的註釋中了)


3、基本類型包裝類作爲鎖對象
正是由於這些包裝類內部用了享元模式,所以基本上所有的基礎類型的包裝類都不適合做鎖,因爲它們內部用到了享元模式,這會導致看上去私有的鎖,其實是共有的。看下下面的代碼,我們假設以 Long 對象作爲鎖,

class A {

    //定義一個 A 對象名字叫 aObj ,值爲 1

    private Long aObj = Long.valueOf(1);

    //定義一個 B 對象名字叫 bObj,值爲 1

    private Long bObj = Long.valueOf(1);

    private void a() {

        //鎖對象是 aObj

        synchronized (aObj) {

            System.out.println("正在執行A方法,5秒以後退出");

            try {

                TimeUnit.SECONDS.sleep(5);

                System.out.println("A執行結束......");

            } catch (InterruptedException e) {

                e.printStackTrace();

            }

        }

    }

    private void b() {

        //鎖對象是 bObj

        synchronized (bObj) {

            System.out.println("正在執行B方法,2秒以後退出");

            try {

                TimeUnit.SECONDS.sleep(2);

                System.out.println("B執行結束......");

            } catch (InterruptedException e) {

                e.printStackTrace();

            }

        }

    }

    public static void main(String[] args) throws InterruptedException {

        A a = new A();

        //開通兩個線程來執行,因爲aObj 和 bObj 是不同的對象,所以理論上應該是互不干擾的

        new Thread(a::a).start();

        TimeUnit.SECONDS.sleep(1);

        new Thread(a::b).start();

    }

}


 

但是卻出現了上面這樣的結果?爲什麼會是同步的執行呢?就是因爲享元模式導致的,因爲 1 是在 [-128~127] 的,所以定義再多的對象都是直接從緩存池中拿的,並不會創建新的對象,即鎖的是同一個對象。現在改成一個不在 [-128~127] 範圍之內的,假設是128

結果爲:

這個時候發現兩個是互不干擾的,也就是兩個鎖並不是同一個對象



4、 使用 Immutability 模式的注意事項
在使用 Immutability 模式的時候,需要注意以下兩點:
  1. 對象的所有屬性都是 final 的,並不能保證不可變性;
  2. 不可變對象也需要正確發佈。
在 Java 語言中,final 修飾的屬性一旦被賦值,就不可以再修改,但是如果屬性的類型是普通對象,那麼這個普通對象的屬性是可以被修改的。什麼鬼?亂七八糟的。別急,我們來看個例子(畢竟光說含義就是等於在耍流氓)。

class D {

    final C c;

    public D(C c) {

        this.c = c;

    }

    private void changeValue(int salary) {

        c.setSalary(salary);

    }

    public static void main(String[] args) {

        C c = new C();

        c.setSalary(1);

        System.out.println("c.getSalary() = " + c.getSalary());

        D d = new D(c);

        d.changeValue(3);

        System.out.println("c.getSalary() = " + c.getSalary());

    }

}



在使用 Immutability 模式的時候一定要確認保持不變性的邊界在哪裏,是否要求屬性對象也具備不可變性。這裏的C對象是不可變的,但是裏面的屬性卻是可以修改的。如果想要屬性也不可以被修改,那麼屬性也必須要定義爲 final 的。像這樣的臨界問題在處理的時候一定要加倍小心。



5、本文小結
利用 Immutability 模式解決併發問題,也許你覺得有點陌生,其實你天天都在享受它的戰果。Java 語言裏面的 String 和 Long、Integer、Double 等基礎類型的包裝類都具備不可變性,這些對象的線程安全性都是靠不可變性來保證的。Immutability 模式是最簡單的解決併發問題的方法,建議當你試圖解決一個併發問題時,可以首先嚐試一下 Immutability 模式,看是否能夠快速解決。
具備不變性的對象,只有一種狀態,這個狀態由對象內部所有的不變屬性共同決定。其實還有一種更簡單的不變性對象,那就是無狀態。無狀態對象內部沒有屬性,只有方法。除了無狀態的對象,你可能還聽說過無狀態的服務、無狀態的協議等等。無狀態有很多好處,最核心的一點就是性能。在多線程領域,無狀態對象沒有線程安全問題,無需同步處理,自然性能很好;在分佈式領域,無狀態意味着可以無限地水平擴展,所以分佈式領域裏面性能的瓶頸一定不是出在無狀態的服務節點上。
 
技術交流羣

最近有很多人問,有沒有讀者交流羣,想知道怎麼加入。

最近我創建了一些羣,大家可以加入。交流羣都是免費的,只需要大家加入之後不要隨便發廣告,多多交流技術就好了。

目前創建了多個交流羣,全國交流羣、北上廣杭深等各地區交流羣、面試交流羣、資源共享羣等。

有興趣入羣的同學,可長按掃描下方二維碼,一定要備註:全國 Or 城市 Or 面試 Or 資源,根據格式備註,可更快被通過且邀請進羣。

▲長按掃描



     
     
     

往期推薦

居然有人提問“國家何時整治程序員的高薪現象”?


再見了,谷歌


Windows 11 再惹“衆怒”!網友:微軟就是逼我去買新電腦!



如果你喜歡本文,
請長按二維碼,關注 Hollis.
轉發至朋友圈,是對我最大的支持。

點個 在看 
喜歡是一種感覺
在看是一種支持
↘↘↘

本文分享自微信公衆號 - Hollis(hollischuang)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章