一、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.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 建議
在自己能力范圍內,盡量優先考慮使用不可變對象的設計。性能問題可以不必過於擔心,如果引發了性能瓶頸,再有針對性地做出調整。
三、總結
1、當一個對象構造完成后,其狀態就不再變化,這樣的對象即為不可變對象。不可變對象的所有方法,均不會改變this對象的狀態,最多構造一個新狀態的對象返回給你。
2、不可變對象對並發編程友好、易於在進程內緩存、且擁有更好的可維護性,建議在自己能力范圍內,盡量優先考慮使用不可變對象的設計。
