衆所周知, 在Java中, String類是不可變的。那麼到底什麼是不可變的對象呢? 可以這樣認為:如果一個對象,在它建立完成之後,不能再改變它的狀态,那麼這個對象就是不可變的。不能改變狀态的意思是,不能改變對象内的成員變量,包括基本資料類型的值不能改變,引用類型的變量不能指向其他的對象,引用類型指向的對象的狀态也不能改變。
對于Java初學者, 對于String是不可變對象總是存有疑惑。看下面代碼:
列印結果為: s = ABCabc
s = 123456首先建立一個String對象s,然後讓s的值為“ABCabc”, 然後又讓s的值為“123456”。 從列印結果可以看出,s的值确實改變了。那麼怎麼還說String對象是不可變的呢? 其實這裡存在一個誤區: s隻是一個String對象的引用,并不是對象本身。對象在記憶體中是一塊記憶體區,成員變量越多,這塊記憶體區占的空間越大。引用隻是一個4位元組的資料,裡面存放了它所指向的對象的位址,通過這個位址可以通路對象。 也就是說,s隻是一個引用,它指向了一個具體的對象,當s=“123456”; 這句代碼執行過之後,又建立了一個新的對象“123456”, 而引用s重新指向了這個心的對象,原來的對象“ABCabc”還在記憶體中存在,并沒有改變。記憶體結構如下圖所示:

Java和C++的一個不同點是, 在Java中不可能直接操作對象本身,所有的對象都由一個引用指向,必須通過這個引用才能通路對象本身,包括擷取成員變量的值,改變對象的成員變量,調用對象的方法等。而在C++中存在引用,對象和指針三個東西,這三個東西都可以通路對象。其實,Java中的引用和C++中的指針在概念上是相似的,他們都是存放的對象在記憶體中的位址值,隻是在Java中,引用喪失了部分靈活性,比如Java中的引用不能像C++中的指針那樣進行加減運算。
要了解String的不可變性,首先看一下String類中都有哪些成員變量。 在JDK1.6中,String的成員變量有以下幾個:
在JDK1.7中,String類做了一些改動,主要是改變了substring方法執行時的行為,這和本文的主題不相關。JDK1.7中String類的主要成員變量就剩下了兩個:
由以上的代碼可以看出, 在Java中String類其實就是對字元數組的封裝。JDK6中, value是String封裝的數組,offset是String在這個value數組中的起始位置,count是String所占的字元的個數。在JDK7中,隻有一個value變量,也就是value中的所有字元都是屬于String這個對象的。這個改變不影響本文的讨論。 除此之外還有一個hash成員變量,是該String對象的哈希值的緩存,這個成員變量也和本文的讨論無關。在Java中,數組也是對象(可以參考我之前的文章java中數組的特性)。 是以value也隻是一個引用,它指向一個真正的數組對象。其實執行了String s = “ABCabc”; 這句代碼之後,真正的記憶體布局應該是這樣的:
value,offset和count這三個變量都是private的,并且沒有提供setValue, setOffset和setCount等公共方法來修改這些值,是以在String類的外部無法修改String。也就是說一旦初始化就不能修改, 并且在String類的外部不能通路這三個成員。此外,value,offset和count這三個變量都是final的, 也就是說在String類内部,一旦這三個值初始化了, 也不能被改變。是以可以認為String對象是不可變的了。
那麼在String中,明明存在一些方法,調用他們可以得到改變後的值。這些方法包括substring, replace, replaceAll, toLowerCase等。例如如下代碼:
列印結果為: a = ABCabc
a = aBCabc
那麼a的值看似改變了,其實也是同樣的誤區。再次說明, a隻是一個引用, 不是真正的字元串對象,在調用a.replace('A', 'a')時, 方法内部建立了一個新的String對象,并把這個心的
對象重新賦給了引用a。String中replace方法的源碼可以說明問題
讀者可以自己檢視其他方法,都是在方法内部重新建立新的String對象,并且傳回這個新的對象,原來的對象是不會被改變的。這也是為什麼像replace, substring,toLowerCase等方法都存在傳回值的原因。也是為什麼像下面這樣調用不會改變對象的值:
列印結果: ss = 123456
ss = 123456
從上文可知String的成員變量是private final 的,也就是初始化之後不可改變。那麼在這幾個成員中, value比較特殊,因為他是一個引用變量,而不是真正的對象。value是final修飾的,也就是說final不能再指向其他數組對象,那麼我能改變value指向的數組嗎? 比如将數組中的某個位置上的字元變為下劃線“_”。 至少在我們自己寫的普通代碼中不能夠做到,因為我們根本不能夠通路到這個value引用,更不能通過這個引用去修改數組。 那麼用什麼方式可以通路私有成員呢? 沒錯,用反射, 可以反射出String對象中的value屬性, 進而改變通過獲得的value引用改變數組的結構。下面是執行個體代碼:
列印結果為: s = Hello World
s = Hello_World
在這個過程中,s始終引用的同一個String對象,但是再反射前後,這個String對象發生了變化, 也就是說,通過反射是可以修改所謂的“不可變”對象的。但是一般我們不這麼做。這個反射的執行個體還可以說明一個問題:如果一個對象,他組合的其他對象的狀态是可以改變的,那麼這個對象很可能不是不可變對象。例如一個Car對象,它組合了一個Wheel對象,雖然這個Wheel對象聲明成了private final 的,但是這個Wheel對象内部的狀态可以改變, 那麼就不能很好的保證Car對象不可變。
隻有當字元串是不可變的,字元串池才有可能實作。字元串池的實作可以在運作時節約很多heap空間,因為不同的字元串變量都指向池中的同一個字元串。但如果字元串是可變的,那麼String interning将不能實作(譯者注:String interning是指對不同的字元串僅僅隻儲存一個,即不會儲存多個相同的字元串。),因為這樣的話,如果變量改變了它的值,那麼其它指向這個值的變量的值也會一起改變。
如果字元串是可變的,那麼會引起很嚴重的安全問題。譬如,資料庫的使用者名、密碼都是以字元串的形式傳入來獲得資料庫的連接配接,或者在socket程式設計中,主機名和端口都是以字元串的形式傳入。因為字元串是不可變的,是以它的值是不可改變的,否則黑客們可以鑽到空子,改變字元串指向的對象的值,造成安全漏洞。
因為字元串是不可變的,是以是多線程安全的,同一個字元串執行個體可以被多個線程共享。這樣便不用因為線程安全問題而使用同步。字元串自己便是線程安全的。
類加載器要用到字元串,不可變性提供了安全性,以便正确的類被加載。譬如你想加載java.sql.Connection類,而這個值被改成了myhacked.Connection,那麼會對你的資料庫造成不可知的破壞。
因為字元串是不可變的,是以在它建立的時候hashcode就被緩存了,不需要重新計算。這就使得字元串很适合作為Map中的鍵,字元串的處理速度要快過其它的鍵對象。這就是HashMap中的鍵往往都使用字元串。