Java 字符串與字符串常量池

運行環境爲 JDK1.8。

1. String 的基本介紹

首先看一下 String 類源碼文件的文檔註釋:
The String class represents character strings. All string literals in Java programs, such as “abc”, are implemented as instances of this class.

Strings are constant; their values cannot be changed after they are created. String buffers support mutable strings. Because String objects are immutable they can be shared. For example:

String str = "abc";

is equivalent to:

char data[] = {'a', 'b', 'c'};
String str = new String(data);

Here are some more examples of how strings can be used:

System.out.println("abc");
String cde = "cde";
System.out.println("abc"+cde);
String c = "abc".substring(2, 3);
String d = cde.substring(1, 2);

The class String includes methods for examining individual characters of the sequence, for comparing strings, for searching strings, for extracting substrings, and for creating a copy of a string with all characters translated to uppercase or to lowercase. Case mapping is based on the Unicode Standard version specified by the java.lang.Character class.

The Java language provides special support for the string concatenation operator (+), and for conversion of other objects to strings. String concatenation is implemented through the StringBuilder(or StringBuffer) class and its append method.

String conversions are implemented through the method toString, defined by Object and inherited by all classes in Java. For additional information on string concatenation and conversion, see Gosling, Joy, and Steele, The Java Language Specification.

Unless otherwise noted, passing a null argument to a constructor or method in this class will cause a NullPointerException to be thrown.

A String represents a string in the UTF-16 format in which supplementary characters are represented by surrogate pairs (see the section Unicode Character Representations in the Character class for more information).

Index values refer to char code units, so a supplementary character uses two position in a String. The String class provides methods for dealing with Unicode code points (i.e., characters), in addition to those for dealing with Unicode code units (i.e., char values).

從文檔註釋中,可以得到以下四點關鍵信息:

  1. java 程序中所有的字符串字面量都是這個類的實例。
  2. 因爲字符串對象是不可變的,所以可以共享。
  3. 字符串連接(+)是通過 StringBuilder (或 StringBuffer)類及其 append 方法實現的。
  4. 字符串的編碼格式爲 UTF-16,Unicode 基本平面外的補充字符使用代理對(兩個 char)來表示。

String 類的關鍵字段,以及構造函數:

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];
    /** Cache the hash code for the string */
    private int hash; // Default to 0
    // 注意,使用這個構造方法是不必要的,因爲字符串是不可變的。
    public String() {
        this.value = "".value;
    }

    public String(String original) {
        this.value = original.value;
        this.hash = original.hash;
    }
    // 因爲字符數組參數有可能被改變,而 String 是希望不可變的,所以要深拷貝
    public String(char value[]) {
        this.value = Arrays.copyOf(value, value.length);
    }
    public String(char value[], int offset, int count) {
        if (offset < 0) {
            throw new StringIndexOutOfBoundsException(offset);
        }
        if (count <= 0) {
            if (count < 0) {
                throw new StringIndexOutOfBoundsException(count);
            }
            if (offset <= value.length) {
                this.value = "".value;
                return;
            }
        }
        // Note: offset or count might be near -1>>>1.(源碼在這一點上考慮的很細緻)
        if (offset > value.length - count) {
            throw new StringIndexOutOfBoundsException(offset + count);
        }
        this.value = Arrays.copyOfRange(value, offset, offset+count);
    }
    
    // 整型數組參數轉字符串,改變整形數組參數不會影響字符串
    public String(int[] codePoints, int offset, int count) {
        if (offset < 0) {
            throw new StringIndexOutOfBoundsException(offset);
        }
        if (count <= 0) {
            if (count < 0) {
                throw new StringIndexOutOfBoundsException(count);
            }
            if (offset <= codePoints.length) {
                this.value = "".value;
                return;
            }
        }
        // Note: offset or count might be near -1>>>1.
        if (offset > codePoints.length - count) {
            throw new StringIndexOutOfBoundsException(offset + count);
        }

        final int end = offset + count;

        // Pass 1: Compute precise size of char[]
        int n = count;
        for (int i = offset; i < end; i++) {
            int c = codePoints[i];
            if (Character.isBmpCodePoint(c)) // 兩字節字符
                continue;
            else if (Character.isValidCodePoint(c)) // 四字節字符
                n++;
            else throw new IllegalArgumentException(Integer.toString(c));
        }

        // Pass 2: Allocate and fill in char[]
        final char[] v = new char[n];

        for (int i = offset, j = 0; i < end; i++, j++) {
            int c = codePoints[i];
            if (Character.isBmpCodePoint(c))
                v[j] = (char)c;
            else
                Character.toSurrogates(c, v, j++);
        }

        this.value = v;
    }
    private static void checkBounds(byte[] bytes, int offset, int length) {
        if (length < 0)
            throw new StringIndexOutOfBoundsException(length);
        if (offset < 0)
            throw new StringIndexOutOfBoundsException(offset);
        if (offset > bytes.length - length)
            throw new StringIndexOutOfBoundsException(offset + length);
    }
    public String(byte bytes[], int offset, int length, String charsetName)
            throws UnsupportedEncodingException {
        if (charsetName == null)
            throw new NullPointerException("charsetName");
        checkBounds(bytes, offset, length);
        this.value = StringCoding.decode(charsetName, bytes, offset, length);
    }
    public String(byte bytes[], int offset, int length, Charset charset) {
        if (charset == null)
            throw new NullPointerException("charset");
        checkBounds(bytes, offset, length);
        this.value =  StringCoding.decode(charset, bytes, offset, length);
    }
    public String(byte bytes[], String charsetName)
            throws UnsupportedEncodingException {
        this(bytes, 0, bytes.length, charsetName);
    }
    public String(byte bytes[], Charset charset) {
        this(bytes, 0, bytes.length, charset);
    }
    public String(byte bytes[], int offset, int length) {
        checkBounds(bytes, offset, length);
        this.value = StringCoding.decode(bytes, offset, length);
    }
    public String(byte bytes[]) {
        this(bytes, 0, bytes.length);
    }
    
    public String(StringBuffer buffer) {
        synchronized(buffer) {
            this.value = Arrays.copyOf(buffer.getValue(), buffer.length());
        }
    }
    
    public String(StringBuilder builder) {
        this.value = Arrays.copyOf(builder.getValue(), builder.length());
    }
    
    /*
    * Package private constructor which shares value array for speed.
    * this constructor is always expected to be called with share==true.
    * a separate constructor is needed because we already have a public
    * String(char[]) constructor that makes a copy of the given char[].
    */
    String(char[] value, boolean share) {
        // assert share : "unshared not supported";
        this.value = value;
    }
}

在 Java8 中,字符串使用 final 修飾的 char 數組存儲。

2. String 不可改變性

String 不可改變性是指,一旦創建了一個 String 對象後,String 不提供任何修改字符串內容的方法。String 不可改變性的設計,一方面爲了實現字符串對象共享,另一方面則是爲了能夠作爲哈希表的 key。

String 不可改變性,絕對不是因爲 String 的 char 數組被 final 修飾,final 修飾 char 數組僅僅表示,數組引用指向的內存地址不可變,但指向的內存區域,其存儲內容是可變的。真正做到 String 不可變的原因是源碼沒有提供任何改變 char 數組的公有方法,而且 String 內所有會改變字符串的方法都會返回一個新的字符串對象。
例如字符串連接操作:

    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);
    }

例如字符串替換字符操作:

    public String replace(char oldChar, char newChar) {
        if (oldChar != newChar) {
            int len = value.length;
            int i = -1;
            char[] val = value; /* avoid getfield opcode */

            while (++i < len) {
                if (val[i] == oldChar) {
                    break;
                }
            }
            if (i < len) {
                char buf[] = new char[len];
                for (int j = 0; j < i; j++) {
                    buf[j] = val[j];
                }
                while (i < len) {
                    char c = val[i];
                    buf[i] = (c == oldChar) ? newChar : c;
                    i++;
                }
                return new String(buf, true);
            }
        }
        return this;
    }

例如去除字符串兩端空格的操作:

    public String trim() {
        int len = value.length;
        int st = 0;
        char[] val = value;    /* avoid getfield opcode */

        while ((st < len) && (val[st] <= ' ')) {
            st++;
        }
        while ((st < len) && (val[len - 1] <= ' ')) {
            len--;
        }
        // substring 方法只要截取的不是整個字符串,返回的也是一個新的字符串對象
        return ((st > 0) || (len < value.length)) ? substring(st, len) : this;
    }

那麼 Java 字符串真的沒有任何辦法修改它嗎?答案是否,我們可以利用 java 的反射機制可以修改 String 對象的值。

package test;

import java.lang.reflect.Field;

public class Test {
	public static void main(String[] args) {
		String a = "1+1=3";
		try {
			Field valueField = String.class.getDeclaredField("value");
			valueField.setAccessible(true); // value字段本來是private的,這裏設爲可訪問的
			char[] value = (char[])valueField.get(a);
			value[value.length-1] = '2'; // 原本是'3',這裏改成'2'
		} catch (NoSuchFieldException | SecurityException | 
				IllegalArgumentException | IllegalAccessException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
		System.out.println(a); // 輸出:1+1=2
	}
}

雖然可以通過反射修改字符串對象的值,但是在實際編碼中不建議這麼做。這可能會導致字符串長度函數和哈希函數的返回值與實際字符串不一致。

    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;
    }

從源碼中可以看到,字符串的哈希值只會在第一次調用 hashCode 函數時計算,之後再調用 hashCode 不會重新計算哈希值。

3. 字符串相加的編譯期優化

Java 對直接相加的字符串常量,例如 "hello"+"world",會在編譯期優化爲 "helloworld"。而間接相加(與字符串的引用相加)則不會優化。
所以有:

String s1 = "ab";
String s2 = "a" + "b";
System.out.println(s1 == s2); // true

其中,變量 s1 和 s2 都是字符串常量池中 “ab” 的引用。
以及

String s1 = "ab";
String a = "a";
String s2 = a + "b";
System.out.println(s1 == s2); // false

但是,如果字符串變量被 final 修飾,則在編譯期間,所有使用該 final 變量的地方都會直接替換爲 final 變量的真實值。
所以有:

String s1 = "ab";
final String a = "a";
String s2 = a + "b"; // 相當於 "a" + "b"
System.out.println(s1 == s2); // true

對於 final 修飾的變量,只有在聲明時就賦值爲常量表達式的,纔會被編譯期優化。
例如:

final String a;
a = "a";

沒有在聲明時就賦值,不會被編譯期優化。
例如:

final String a = null == null ? "a":null;

賦值爲非常量表達式,不會被編譯期優化。

4. 字符串常量池

字符串常量池存在的意義就是實現字符串的共享,節省內存空間。像 Integer 等部分基本類型的包裝類也實現了常量池技術,但是它們都是直接在 Java 源碼層面實現的,而字符串常量池是在 JVM 層面使用 C 語言實現的。字符串常量池的底層實現其實就是一個哈希表,可以把它理解成不能自動擴容的 HashMap。

4.1 intern() 函數

字符串添加到字符串常量池的途徑有兩種:

  1. 經過編譯期優化的字符串字面量在運行時會自動添加到常量池。
  2. 運行時通過 intern 函數向常量池添加字符串。

String 類中的 intern 函數:

    /**
     * Returns a canonical representation for the string object.
     * <p>
     * A pool of strings, initially empty, is maintained privately by the
     * class {@code String}.
     * <p>
     * When the intern method is invoked, if the pool already contains a
     * string equal to this {@code String} object as determined by
     * the {@link #equals(Object)} method, then the string from the pool is
     * returned. Otherwise, this {@code String} object is added to the
     * pool and a reference to this {@code String} object is returned.
     * <p>
     * It follows that for any two strings {@code s} and {@code t},
     * {@code s.intern() == t.intern()} is {@code true}
     * if and only if {@code s.equals(t)} is {@code true}.
     * <p>
     * All literal strings and string-valued constant expressions are
     * interned. String literals are defined in section 3.10.5 of the
     * <cite>The Java&trade; Language Specification</cite>.
     *
     * @return  a string that has the same contents as this string, but is
     *          guaranteed to be from a pool of unique strings.
     */
    public native String intern();

可以看到,intern 函數是一個本地方法。註釋中說到,如果常量池中已經存在當前字符串,則返回常量池中的該字符串,如果不存在則將當前字符串添加到常量池,並返回其引用。

關於 intern 函數的一段代碼:

String s1 = new String("1");
s1.intern();
String s2 = "1";
System.out.println(s1 == s2); // false

String s3 = new String("1") + new String("1");
s3.intern();
String s4 = "11";
System.out.println(s3 == s4); // true

在 JDK1.8 中上面代碼的運行結果爲 false true。但是如果把這段代碼拿到 JDK1.6 中運行,你得到的結果將會是 false false。
原因是 JVM 在字符串常量池的實現上有所改動。在 JDK1.6 中,字符串常量池不在堆中,在永久代中,intern 函數向字符串常量池添加的是字符串對象,而在 JDK1.8 中,字符串常量池在堆中,intern 函數向常量池添加的不再是字符串對象,而是字符串對象在堆中的引用。
關於這部分的詳細內容可以閱讀美團的這篇技術文章深入解析String#intern

第一種添加途徑中,經過編譯期優化的字符串字面量指的是,對於 String s = “a”+“a” 這種寫法,經過編譯後,相當於 String s = “aa”。所以運行時被放入常量池的只有 “aa”,而不會有 “a”。
可以使用 intern 函數來驗證:

String s = "a"+"a";
String s1 = new String(s);
System.out.println(s1 == s1.intern()); // false
String s2 = s.substring(1);
System.out.println(s2 == s2.intern()); // true

運行結果爲 false true,第一個 false 說明在執行 s1.intern() 前,字符串常量池中已經有 “aa” ,所以 s1 不會被放入到常量池。s1.intern() 返回的是常量池中的引用,當然不等於 s1。第二個 true 表明執行 s2.intern() 前,字符串常量池中沒有 “a”,執行 s2.intern() 時,“a” 的引用 s2 被添加到字符串常量池,並返回 s2,所以比較的是同一個引用,當然相等。

4.2 字符串常量池的位置

在 JDK1.8 中,字符串常量池的位置在堆中。
下面通過代碼來驗證這個結論:
運行代碼前,爲了儘快看到結果,需要設置虛擬機參數 -Xmx2m,含義是設置堆的最大值爲 2M。
設置步驟爲:

  1. 右鍵 => Run As => Run Configurations
  2. 切換到第二個選項卡:(x)= Arguments => 在 VM arguments 裏填入 -Xmx2m

設置界面如下:
設置界面
驗證代碼:

package test;

import java.util.ArrayList;
import java.util.List;

public class Test {
	public static void main(String[] args) {
        List<String> list = new ArrayList<String>(); // 保持引用,防止垃圾回收
        int i=0;
        while(true){
            list.add(String.valueOf(i++).intern()); // 不停地向字符串常量池中添加字符串
        }
	}
}

運行結果:
運行結果
結果表明堆空間溢出,字符串常量池在堆中。

附錄:final 關鍵字

  1. final + 屬性(或局部變量)
    如果屬性是基本數據類型,則變爲常量,值不能修改;如果屬性是引用類型,則引用地址不能修改。
  2. final + 方法
    該方法變爲“最終方法”,不能被子類重寫。
  3. final + 類
    該類變爲“最終類”,不能被子類繼承。如 Java 中的 String、System 等均爲最終類。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章