Java進階知識點4:不可變對象與併發 - 從String提及

1、String的不可變特性

熟悉Java的朋友都知道,Java中的String有一個很特別的特性,就是你會發現不管你調用String的什麼方法,均沒法修改this對象的狀態。當確實須要修改String的值時,String方法的實現是構造一個新的String返回給你。以下:程序員

public static void main(String[] args) {
    String origin = "Test";
    String target = origin.replace("T", "t"); //replace不會修改this對象(即origin對象)的任何狀態
    System.out.println(origin); //輸出"Test"
    System.out.println(target); //輸出"test"
}

 這與C++ STL中的string有很大不一樣,剛從C++轉Java的同窗可能常常會忘記使用replace函數的返回值,覺得調用了replace以後,this對象就已是替換後的字符串了。編程

2、不可變對象

2.1 什麼是不可變對象

其實不光是String對象,Java中的不少對象都符合上述不可改變狀態的特性。簡而言之,當一個對象構造完成後,其狀態就再也不變化,咱們稱這樣的對象爲不可變對象(Immutable Object),這些對象關聯的類爲不可變類(Immutable Class)。緩存

好比Java中的Integer、Double、Long等全部原生類型的包裝器類型,也都是不可變的。安全

那麼明明能夠直接修改this對象,爲什麼Java中還要大費周章地去構造一個全新的對象返回呢?那這就要從不可變對象的好處提及了。多線程

2.2 不可變對象的優勢

2.2.1 對併發友好

提到多線程併發,最讓人苦惱的莫過於線程間共享資源的訪問衝突,從古到今,多少Bug所以而生。即使是最有經驗的程序員,面對多線程編程時,也每每需瞻前顧後,反覆思量後,才能逐漸對本身編寫的代碼產生信心。若是多線程錯誤能夠跟編譯錯誤同樣,可以被自動發現該有多好。併發

目前大多數語言中,面對多線程衝突問題,都是採用序列化訪問共享資源的方案。Java也不例外,Java語言中的synchronize關鍵字,Lock鎖對象等機制,都是爲實施此類方案准備的。此類方案最大的弊端在於:能不能保證多線程間沒有衝突,徹底取決於程序員對共享資源加鎖解鎖的時機對不對。若是程序員加鎖的時機有絲毫差錯,Java是不負責檢測的,可能你的單元測試、集成測試、預發佈測試也發現不了,程序上線後也看上去一切正常,可是等到某一個重要的時刻,它會以一個突如其來的線上Bug的形式通知你,是否是欲哭無淚。編程語言

然而,解決多線程衝突問題還有一個方向,就是從多線程衝突的根因 —— 共享資源上入手。函數式編程

若是徹底沒有共享資源,多線程衝突問題就自然不存在了,好比Java中的ThreadLocal機制就是利用了這一點理念。函數

可是大多數時候,線程間是須要使用共享資源互通訊息的。此時,若是該共享資源誕生以後就徹底再也不變動(猶如一個常量),多線程間共同併發讀取該共享資源是不會產生線程衝突的,由於全部線程不管什麼時候讀取該共享資源,老是能獲取到一致的、完整的資源狀態,這樣也能規避多線程衝突。不可變對象就是這樣一種誕生以後就徹底再也不變動的對象,該類對象能夠天生支持無憂無慮地在多線程間共享。高併發

若是線程間對共享資源的訪問不只侷限於讀,還想改變共享資源的狀態呢,這種時候不可變對象又可否從容應對呢?答案是確定的。原理很簡單,某個線程想要修改共享資源A的狀態時,不要去直接修改A自己的狀態,而是先在本線程中構造一個新狀態的共享資源B,待B構造完整後,再用B去直接替換A,因爲對引用賦值操做是原子性的,因此也不會形成線程衝突問題。不可變對象所提供的方法,不會改變自身的狀態,最多構造一個新狀態的新對象的返回,這也與上述思路徹底契合。可是須要注意可見性問題,若是你想要A替換B後,其餘全部線程實時感知到此變化,須要使用volatile關鍵字保證可見性。

以下:

public class Test {
    private volatile String shared = "shared"; //使用volatile關鍵字保證共享資源的可見性

    public void test() {
        new Thread(() -> {
            String newValue = shared.replace("s", "S"); //在本線程中先構建一個新String
            shared = newValue; //用新String替換共享資源,引用的賦值是原子性的
        }).start();
    }
}

值得注意的是,線程安全需同時考慮原子性和可見性問題,因此網上常說的不可變對象是線程安全的,實際上是不嚴謹的。

因此,不可變對象的好處在於,只要對象符合不可變原則,該對象在線程間傳遞是不會產生衝突的。這就將之前的處處多是坑的多線程編程解耦爲安全的兩步,首先使用不可變對象,而後在線程間傳遞不可變對象。這能顯著減小人腦須要考慮的狀況分支,讓編程更加輕鬆和可控。

其實,全部的函數式編程語言Lisp、Haskell、Erlang等,都從語法層面保證你只能使用不可變對象,因此全部函數編程語言是天生對併發友好的,這也是在一些高併發場景中,函數式編程語言更受青睞的緣由。

2.2.2 易於在進程內緩存

當一個對象被頻繁訪問,而生成該對象的開銷較大時,常常須要進行進程內緩存,即將頻繁訪問的對象存入一個緩存集合中(好比Map),當須要使用該對象時,優先從緩存中提取。

使用進程內緩存就不得不面對緩存污染問題,當緩存的對象被提取使用時,若是上層業務代碼修改了該緩存對象的狀態,那麼當再次從緩存中提取該對象時,該對象的狀態已經再也不是最開始加入緩存時的狀態了,即已經被污染了。緩存污染會致使不少問題,好比業務數據被意外篡改、業務數據間的互相干擾等。

一般爲了保證緩存不被污染,當咱們從緩存中提取對象時,會返回原始緩存對象的一個深拷貝,這樣不管上層業務代碼對提取到的對象如何修改,均不會對緩存自己形成影響。

可是深拷貝畢竟有額外的性能開銷,此時若是緩存的是不可變對象,就皆大歡喜了。由於你能夠放心大膽的把緩存對象的引用返回給上層代碼使用,由於不管上層代碼怎樣操做,它也沒法修改一個不可變對象的狀態,這也就自然規避了緩存污染問題,同時也可將深拷貝帶來的性能開銷延遲到真正須要修改對象時才發生。

2.2.3 更好的可維護性

當咱們在代碼中看到一個不可變對象時,心情是輕鬆的,由於這類對象很單純,不會在哪一個隱藏的邏輯分支中偷偷改變自身的狀態,對代碼的測試、調試和閱讀理解都有好處。

 2.3 不可變對象的侷限

既然不可變對象這麼好用,那它是否是萬能的呢,不可變對象有沒有什麼缺點呢?使用不可變對象主要有以下問題。

2.3.1 編程思惟的轉變

若是全部對象都被設計爲不可變的,等價於使用函數式編程思惟,編程思惟上的變化並不是全部程序員都能很好的適應,若是適應不了,強行推廣只會拔苗助長。何況Java自己也並非純粹的函數式編程語言。

2.3.2 性能上的額外開銷

因爲不可變對象須要複製一份狀態用於修改後返回新的的對象,若是設計和使用不當的話,可能所以造成性能瓶頸點。

可是沒必要過於擔憂性能問題,一方面內存拷貝速度極快,另外也並不是全部額外的性能開銷都是不可容忍的,代碼性能測試時,你可能會發現不少各式各樣的性能瓶頸點,大部分可能都是你意想不到的,因此過早考慮性能而放棄編碼安全是不可取的。就比如彙編效率最高,可是也不會所以全部代碼都直接彙編編程,遇到真正的性能瓶頸時,有針對性的作彙編層面的調優纔是上策。

2.4 建議

在本身能力範圍內,儘可能優先考慮使用不可變對象的設計。性能問題能夠沒必要過於擔憂,若是引起了性能瓶頸,再有針對性地作出調整。

3、總結

一、當一個對象構造完成後,其狀態就再也不變化,這樣的對象即爲不可變對象。不可變對象的全部方法,均不會改變this對象的狀態,最多構造一個新狀態的對象返回給你。

二、不可變對象對併發編程友好、易於在進程內緩存、且擁有更好的可維護性,建議在本身能力範圍內,儘可能優先考慮使用不可變對象的設計。

相關文章
相關標籤/搜索