Java 的字串和子串

黃志斌發表於2013-03-15

在《演算法(第4版)》第 129 頁說到:

第1章 基礎

1.4 演算法分析

1.4.9 記憶體

1.4.9.5 字串的值和子字串

一個長度為 N 的 String 物件一般需要使用 40 位元組(String 物件本身)加上(24 + 2N )位元組(字元陣列),總共(64 + 2N )位元組。但字串處理經常會和子字串打交道,所以 Java 對字串的表示希望能夠避免複製字串中的字元。當你呼叫 substring() 方法時,就建立了一個新的 String 物件(40 位元組),但它仍然重用了相同的 value[] 陣列,因此該字串的子字串只會使用 40 位元組的記憶體。含有原始字串的字元陣列的別名存在於子字串中,子字串物件的偏移量和長度域標記了子字串的位置。換句話說,一個子字串所需的額外記憶體是一個常數,構造一個子字串所需的時間也是常數,即使字串和子字串的長度極大也是這樣。某些簡陋的字串表示方法在建立子字串時需要複製其中的字元,這將需要線性的時間和空間。確保子字串的建立所需的空間(以及時間)和其長度無關是許多基礎字串處理演算法的效率的關鍵所在。字串的值與子字串示例如圖 1.4.10 所示。

這段話對應於《演算法(英文版 第4版)》第 202 至 204 頁:

1 Fundamentals

1.4 Analysis of Algorithms

Memory

String values and substrings. A String of length N typically uses 40 bytes (for the String object) plus 24 + 2N bytes (for the array that contains the characters) for a total of 64 + 2N bytes. But it is typical in string processing to work with substrings, and Java’s representation is meant to allow us to do so without having to make copies of the string’s characters. When you use the substring() method, you create a new String object (40 bytes) but reuse the same value[] array, so a substring of an existing string takes just 40 bytes. The character array containing the original string is aliased in the object for the substring; the offset and length fields identify the substring. In other words, a substring takes constant extra memory and forming a substring takes constant time, even when the lengths of the string and the substring are huge. A naive representation that requires copying characters to make substrings would take linear time and space. The ability to create a substring using space (and time) independent of its length is the key to efficiency in many basic string-processing algorithms.

Substring

我當時讀到這裡,就覺得 Java 的字串和子串的實現和 .NET Framework 中的很不一樣。後者正是如 Sedgewick 所說的:

某些簡陋的字串表示方法在建立子字串時需要複製其中的字元,這將需要線性的時間和空間。

我到 OpenJDK 網站下載了 OpenJDK 8 的原始碼:

$ hg clone http://hg.openjdk.java.net/jdk8/jdk8 openjdk8
$ cd openjdk8 && sh ./get_source.sh

然後閱讀了 src/share/classes/java/lang/String.java 的原始碼,覺得實現方法和 .NET Framework 中的一樣,也是在建立子字串時需要複製其中的字元。後來看到老趙的一篇文章:串與繩(1):.NET與Java裡的String型別,知道了以下幾點:

  1. 在 .NET Framework 中建立子字串確實需要複製其中的字元:

    從中可以看出,無論是字串連線還是取部分字串,CPU和記憶體的消耗都與目標字串的長度線性相關。換句話說,字串越長,代價越高,假如要反覆大量地操作一個大型的字串,則會對效能產生很大影響。

    這些應該都是每個.NET程式設計師都瞭若指掌的基礎。

  2. 在 Open JDK 7 中字串和子串的實現確如 Sedgewick 所說:

    嚴格來說,“Java”是一個標準,而沒有限制特定的實現方式,我們這裡分析的是使用最廣泛的OpenJDK實現。

    這裡我們可以看出OpenJDK 7與.NET的不同,後者是直接包含字元序列的內容,而前者則是保留一個字元陣列,並記錄起始位置及其偏移量。這麼做最大的好處是substring方法無需複製記憶體,而完全可以重用內部的字元陣列。

  3. 但是這樣很容易造成記憶體洩露:

    共享字元陣列的優勢顯而易見,而劣勢便是成為了Java程式中最常見的記憶體洩露原因之一。說起來我到十八摸以後寫的第一個程式便遇到了這個問題:從伺服器端得到一個長長的字串形式的資料,經過一個內部解析類庫獲得一小個片段(可能只是記錄個ID)並儲存在記憶體中。不過後來發現記憶體的佔用量上升的很快,且穩定後比預想地要高的多,通過Memory Profiling發現原來是這一小段字串還持有原來完整的內容。

  4. 在 OpenJDK 8 中 String 類的實現改為和 .NET Framework 一致了:

    有意思的是,在未來的OpenJDK 8裡,String類的這方面表現已經改變了。

    OpenJDK 8放棄了保留了近二十年的設計,讓String物件使用各自獨立的字元陣列,就跟.NET一貫以來的做法一樣。這樣,它的相關方法如substring也有了相應改變。

    這裡直接呼叫的已經是之前列舉過的,會複製字元陣列內容的公有建構函式了。所以說,“Java”只是一個標準,可以有各種實現。從外部表現看來,OpenJDK 8的String類相對於之前沒有任何變化。

前面 Sedgewick 說過:

確保子字串的建立所需的空間(以及時間)和其長度無關是許多基礎字串處理演算法的效率的關鍵所在。

那麼,在 Java 語言的未來版本中,《演算法(第4版)》一書中的許多基礎字串處理演算法的效率將大有問題。而且在 C# 語言的當前版本中,也會大有問題。

《演算法(第4版)》第 VII 頁:

前言

作為教材

雖然本書使用 Java 實現演算法和資料結構,但其程式碼風格使得熟悉其他現代程式語言的人也能看懂。我們充分利用了 Java 的抽象性(包括泛型),但不會依賴這門語言的獨門特性。

這裡前半部說得不錯,我就是熟悉 C# 語言,對 Java 語言僅是表面上的瞭解,但理解這本書的內容也非常容易。後半部說“不會依賴這門語言的獨門特性”就有問題了:

一個子字串所需的額外記憶體是一個常數,構造一個子字串所需的時間也是常數

這個就是 Java 語言的獨門特性,而且是 Java 語言當前版本的獨門特性,在 Java 語言的未來版本中也會取消這個獨門特性。

不知道《演算法》這本書再版時,Sedgewick 會不會就此作些改進?

相關文章