靈魂拷問:為什麼 Java 字符串是不可變的?

  • 2019 年 11 月 20 日
  • 筆記

這是 Java極客技術的第 251 篇原創文章

在逛 programcreek 的時候,發現了一些精妙絕倫的主題。比如說:為什麼 Java 字符串是不可變的?像這類靈魂拷問的主題,非常值得深思。

對於絕大多數的初級程序員來說,往往停留在「知其然不知其所以然」的層面上——會用,但要說底層的原理,可就只能撓撓頭雙手一攤一張問號臉了。

很長一段時間內,我也一直處於這種層面上。導致的局面就是,我在挖一些高深點的技術方案時,往往束手無策;在讀一些高深點的技術文章時,往往理解不了作者在說什麼。

藉此機會,我就和大家一起,對「為什麼 Java 字符串是不可變的」進行一次深入地研究。注意了,準備打怪升級了!

01. 圖文分析

來看下面這行代碼。

String alita = "阿麗塔";  

這行代碼在字符串常量池中創建了一個內容為「阿麗塔」的對象,並將其賦值給了字符串變量 alita(存儲的是字符串對象"阿麗塔"的引用)。如下圖所示。

再來看下面這行代碼。

String wanger = alita;  

這行代碼將字符串變量 alita 賦值給了字符串變量 wanger。這時候,wanger 和 alita 存儲的是同一個字符串對象的引用。如下圖所示。

再來看下面這行代碼。

alita = "戰鬥天使".concat(alita);  

這行代碼將字符串「戰鬥天使」拼接在字符串變量 alita 的前面,並重新賦值給 alita。這個過程就比之前的複雜了。我們需要先來看看 concat() 方法做了什麼,源碼如下所示。

public String concat(String str) {    int otherLen = str.length();    if (otherLen == 0) {    	return this;    }    int len = value.length;    char buf[] = Arrays.copyOf(value, len + otherLen);    str.getChars(buf, len);    return new String(buf, true);  }  

可以看得出,"戰鬥天使".concat(alita) 這行代碼會先在字符串常量池中創建一個新的字符串對象,內容為「戰鬥天使」,然後 concat() 方法會將其對應的字符數組和「阿麗塔」對應的字符數組複製到一個新的字符數組 buf 中,最後,再通過 new 關鍵字創建了一個新的字符串對象,並返回。如下圖所示。

從上圖中可以得出結論,alita 此時引用的是在堆中新創建的字符串對象。

02. 對象和對象引用

可能有些讀者看完上面的圖文分析沒有理解反而更疑惑了:alita 不是變了嗎?從「阿麗塔」變為「戰鬥天使阿麗塔」?怎麼還說字符串是不可變的呢?

這裡需要給大家解釋一下,什麼是對象,什麼是對象引用。

在 Java 中,由於不能直接操作對象本身,所以就有了對象引用這個概念,對象引用存儲的是對象在內存中的地址。

PS:Java 虛擬機在執行程序的過程中會把內存區域劃分為若干個不同的數據區域,如下圖所示。

對象存儲在堆(heap)中,而對象的引用存儲在棧(stack)中。

我們通常所說的「字符串是不可變的」是指「字符串對象是不可變的」。alita 是字符串對象「阿麗塔」或者「戰鬥天使阿麗塔」的引用。這下應該明白了吧?

03. 源碼分析

我們來看一下 String 類的部分源碼。

public final class String  implements java.io.Serializable, Comparable<String>, CharSequence {  /** The value is used for character storage. */  private final char value[];  }  

可以看得出, String 類其實是通過操作字符數組 value 實現的。而 value 是 private 的,也沒有提供 serValue() 這樣的方法進行修改;況且 value 還是 final 的,意味着 value 一旦被初始化,就無法進行改變。

另外呢,String 類提供的方法,比如說 substring()

public String substring(int beginIndex) {  int subLen = value.length - beginIndex;  return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);  }  

toLowerCase()

public String toLowerCase(Locale locale) {  return new String(result, 0, len + resultOffset);  }  

還有之前提到的 concat(),看似都能改變字符串的內容,但其實都是在方法內部使用 new 關鍵字重新創建的新字符串對象。

04. 為什麼要不可變

String 類的源碼中還有一個重要的字段 hash,用來保存字符串對象的 hashCode。

public final class String  implements java.io.Serializable, Comparable<String>, CharSequence {      /** Cache the hash code for the string */    private int hash; // Default to 0      public int hashCode() {      int h = hash;      if (h == 0 && value.length > 0) {      	char val[] = value;        	for (int i = 0; i < value.length; i++) {        	h = 31 * h + val[i];        }      	hash = h;      }      return h;    }  }

因為字符串是不可變的,所以一旦被創建,它的 hash 值就不會再改變了。由此字符串非常適合作為 HashMap 的 key 值,這樣可以極大地提高效率。

另外呢,不可變對象天生是線程安全的,因此字符串可以在多個線程之間共享。

舉個反面的例子,假如字符串是可變的,那麼數據庫的用戶名和密碼(字符串形式獲得數據庫連接)將不再安全,一些高手可以隨意篡改,從而導致嚴重的安全問題。

05. 最後

總結一下,字符串一旦在內存中被創建,就無法被更改。String 類的所有方法都不會改變字符串本身,而是返回一個新的字符串對象。如果需要一個可修改的字符序列,建議使用 StringBuffer 或 StringBuilder 類代替 String 類,否則每次創建的字新符串對象會導致 Java 虛擬機花費大量的時間進行垃圾回收。

參考鏈接:

Diagram to show Java String’s Immutability