Java 中 char 和 String 的細節和使用注意
-
char 數據類型的使用注意
-
Unicode 字符集
-
UTF-16
-
不建議在 Java 程序中使用 char 數據類型
-
-
String 的細節
-
獲取字符串長度
-
盡量不要使用 String 來存儲密碼等敏感信息
-
-
char 數據類型的使用注意
在 Java 中使用 char 數據類型來表示字符,但是 char 類型并不能表示一切字符。
Unicode 字符集
首先需要知道我們在 Java 中使用的是 Unicode 字符集。在其出現之前有已經有了很多字符集,如 ANSI、GB2312 等等。由于存在眾多標準不同的字符集,這就導致了兩個問題:
- 對于任意給定的一個代碼值,在不同的字符集中可能對應不同的字符;
- 采用大字符集的語言其編碼長度可能不同,如對常用字符采用單字節編碼,而其他字符采用多字節編碼。
Unicode 字符集的出現就是為了統一編碼,消除以上的問題。所謂字符集就是一個由眾多不同的字符組成的集合。Unicode 字符集對每一個字符都分配了一個唯一的 代碼點(code point) 用來標識字符本身。所謂代碼點就是一個添加了 U+ 前綴的十六進制整數,如字母 A 的代碼點就是 U+0041。
有了Unicode 字符集后,我們要考慮的就是以什么樣的方式對這些字符進行傳輸和存儲,這就是 Unicode 編碼的實現方式,我們稱為 Unicode 轉換格式(Unicode Transformation Format,簡稱 UTF)。我們熟悉的 UTF-8、 UTF-16 等就是不同的 Unicode編碼實現方式。
在 Unicode 字符集誕生之初,采用 UCS-2(2-byte Universal Character Set) 這種定長的編碼方式對 Unicode 字符集進行編碼,這種方式采用 16 bit 的長度來進行字符編碼,所以最多可以對 2^16 = 65536 個字符進行編碼(編碼范圍從 U+0000 ~ U+FFFF)。在當時的情況下,設計者們用了不到一半的數量就對所有字符進行了編碼,并且認為剩余的空間足夠用于未來新增字符的編碼。
不幸的是,隨著中文、日文、韓文等表意文字不斷的加入,Unicode 字符集中的字符數量很快超過了 16 位所能編碼的最大字符數量,于是設計者們對 Unicode 字符集進行了新的設計。
新的設計將字符集中的所有字符分為 17 個 代碼平面(code plane)。其中 U+0000 ~ U+FFFF 這個代碼點范圍被劃定為 基本多語言平面(Basic MultilingualPlane,簡記為 BMP),其余的字符分別劃入 16 個 輔助平面(Supplementary Plane),代碼點范圍為 U+10000 ~ U+10FFFF,這些處于輔助平面的字符我們稱作 增補字符(supplementary characters)。
在 Unicode 字符集中的字符被重新劃分到不同平面后,需要注意以下兩個方面:
- BMP 范圍內的字符和 UCS-2 下的字符編碼基本保持一致,但是 BMP 中的 U+D800 ~ U+DFFF 部分被留空,不分配給任何字符,作用是用于給輔助平面內的字符進行編碼。
- 不是每個平面內的每個位置都被分配給了指定的字符,原因是:
- 特殊用途,如 BMP 中的 U+D800 ~ U+DFFF 部分;
- 作為保留空間
- 沒有足夠的字符
UTF-16
UTF-16 同樣使用 16 bit 的編碼來表示 Unicode 字符,也就是說 UTF-16 的 代碼單元(code unit) 為 16 位。代碼單元指的是字符編碼的一個最基本單元,即任意一個字符必然是由 n(n≥1) 個代碼單元組成的。
在 UTF-16 下,由于 16 位長度只能表示 65536 個字符,所以就默認映射所有在 BMP 范圍內的字符,由此 U+D800 ~ U+DFFF 這個部分就留空了,那么輔助平面的字符也就能借助這個留空的部分來表達。這就是 UTF-16 設計的巧妙之處,在不浪費空間的情況下解決所有字符的編碼問題。
那么怎么表達輔助平面的字符呢?其實就是將輔助平面字符的代碼點編碼為 一對 16 bit 長的代碼單元,稱之為 代理對(surrogate pair),而代理對必然落在 BMP 中的 U+D800 ~ U+DFFF 部分。這樣就解決了用 16 位的代碼單元編碼整個 Unicode 字符集的問題。需要注意的是 U+D800 ~ U+DFFF 這個部分我們可以稱作 代理區,其中 U+D800 ~ U+DBFF 這個部分稱為 高位代理區(前導代理區),U+DC00 ~ U+DFFF 這個部分稱為 低位代理區(后尾代理區)。
下面通過將 U+64321 這個處于輔助平面的字符進行 UTF-16 編碼的實例來講解輔助平面字符的編碼方式。
1、首先將這個字符的代碼點減去 0x10000,得到長度為 20 bit 的一個值,這個值的范圍必然在 0x0000 ~ 0xFFFF之內。
V = 0x64321
Vx= V - 0x10000
= 0x54321
= 0101 0100 0011 0010 0001
2、將 Vx 的高位 10 bit 的值作為高位代理的運算基數 Vh,將低位 10 bit 的值作為低位代理的運算基數 Vl。這兩個 10 bit 的值的取值范圍都必然在 0x0000 ~ 0x3FF 之間。
Vh = 0101 0100 00
Vl = 11 0010 0001
3、將 Vh 和 Vl 分別與高位代理區和低位代理區起始位置的代碼點進行 按位或 運算,得到的結果就是這個處于輔助平面的字符 U+64321 的 UTF-16 編碼。
W1 = 0xD800
= 1101 1000 0000 0000
W2 = 0xDC00
= 1101 1100 0000 0000
W1 = W1 | Vh
= 1101 1000 0000 0000
| 01 0101 0000
= 1101 1001 0101 0000
= 0xD950
W2 = W2 | Vl
= 1101 1100 0000 0000
| 11 0010 0001
= 1101 1111 0010 0001
= 0xDF21
4、所以最終 U+64321 這個字符就被編碼成了由高位代理和低位代理組成的一個代理對,我們需要同時用 0xD950 和 0xDF21 來表示這個字符。
通過上面的例子我們可以看到,任何輔助平面內的字符在 UTF-16 下都會被編碼為由兩個長度為 16 位的代理編碼組成的代理對,在程序中表示這個字符時,需要占用的就不再是 16 位的空間,而是 32 位。
不建議在 Java 程序中使用 char 數據類型
經過上面對 Unicode 字符集和 UTF-16 的講解,我們現在來討論為什么不建議在 Java 程序中使用 char 數據類型。
由于 Java 采用的是 16 位的 Unicode 字符集,即 UTF-16,所以在 Java 中 char 數據類型是定長的,其長度永遠只有 16 位,char 數據類型永遠只能表示代碼點在 U+0000 ~ U+FFFF 之間的字符,也就是在 BMP 內的字符。如果代碼點超過了這個范圍,即使用了增補字符,那么 char 數據類型將無法支持,因為增補字符需要 32 位的長度來存儲,我們只能轉而使用 String 來存儲這個字符。
char c1 = '??';
char c2 = '\u64321';
如上編寫的代碼,使用 char 數據類型來保存輔助平面的字符,編譯器將會報錯 Invalid character constant。
隨著互聯網用戶的不斷增多以及互聯網語言的不斷豐富,用戶越來越高頻率的在互聯網上使用一些特殊字符來表達豐富的語義,而這些字符很有可能是屬于輔助平面里的增補字符,所以如果我們使用 char 類型來進行處理,就很有可能減低我們程序的健壯性。
- String 的細節
獲取字符串長度
String 是我們在編程時使用的非常多的數據類型,它用來表示一個字符串。查看 String 的源碼,我們可以看到其底層實際是使用一個 char 類型數組在存儲我們的字符。
/** The value is used for character storage. */
private final char value[];
我們也知道調用其 length() 方法可以得到字符串的長度,即字符串中字符的數量。其實現是直接返回底層 value 數組的長度,代碼如下:
/**
* Returns the length of this string.
* The length is equal to the number of Unicode code units in the string.
*
* @return the length of the sequence of characters represented by this object.
*/
public int length() {
return value.length;
}
結合我們上面對于字符編碼的知識,我們知道 Java 中 char 的長度永遠是 16 位,如果我們在字符串中使用了增補字符,那就意味著需要 2 個 char 類型的長度才能存儲,對于 String 底層存儲字符的數組 value 來說,就需要 2 個數組元素的位置。所以下面的這個程序我們將得到一個意料之外的結果:
String tt = "我喜歡??這個字符";
System.out.println(tt.length()); // 9
按照我們的想法,字符串 tt 中應該只有 8 個字符,然而實際輸出卻是 9 個。上面我們已經講過 Java 采用的是 16 位的 Unicode 字符集,所以在 Java 中一個代碼單元的長度也是 16 位。一個增補字符需要兩個代碼單元來表示,所以 tt 字符串中的字符 ?? 需要占用 value 數組的兩個位置,這就是輸出 9 而不是 8 的原因。
這里就體現了 Java 中 char 類型無法表示一個增補字符的問題。其實我們仔細閱讀 length() 方法上的注釋也可以知道,這個方法返回的是這個字符串中 Unicode 代碼單元的數量。
那么有沒有什么辦法能夠獲取到我們想要的 8 呢?我們可以調用 codePointCount(int beginIndex, int endIndex) 這個方法來實現。顧名思義,這個方法返回的是字符串中指定部分的代碼點的數量,不管你是處于 BMP 范圍內的字符還是輔助平面的字符,你的代碼點都只能是一個,所以這就可以精確的得到字符串中的字符數量,我們來看這個方法的實現:
public int codePointCount(int beginIndex, int endIndex) {
if (beginIndex < 0 || endIndex > value.length || beginIndex > endIndex) {
throw new IndexOutOfBoundsException();
}
return Character.codePointCountImpl(value, beginIndex, endIndex - beginIndex);
}
這個方法首先是判斷傳入的范圍是否合法,然后調用 java.lang.Character 的 static int codePointCountImpl(char[] a, int offset, int count) 方法進行代碼點計算,我們來看具體實現:
static int codePointCountImpl(char[] a, int offset, int count) {
int endIndex = offset + count;
int n = count;
for (int i = offset; i < endIndex; ) {
if (isHighSurrogate(a[i++]) && i < endIndex &&
isLowSurrogate(a[i])) {
n--;
i++;
}
}
return n;
}
這個方法默認返回的是傳入的指定的字符串的長度,也就是說默認字符串中每個字符都是 BMP 中的字符。接下來的 for 循環里就是核心邏輯,依次判斷字符串中的第 n 個字符和 n+1 個字符是否分別落在高位代理區和低位代理區。如果滿足判斷條件,則默認返回的字符總數減一。
因為如果第 n 個字符和 n+1 個字符分別落在高位代理區和低位代理區就表示這是一個增補字符,增補字符占用兩個代碼單元,所以需要將默認返回的字符總數減一,這樣得到的才是真正的字符總數。
盡量不要使用 String 來存儲密碼等敏感信息
我們通常使用一個 String 類型變量來保存用戶提交的密碼等敏感信息,但實際上這是不安全的做法。
從 String 類的簽名可以看到,String 的對象都是不可變的,也就是說 String 對象一旦被創建就不能通過任何方法(除了使用反射)對它進行修改,直到其被垃圾回收器回收(這段時間這個 String 對象通常會存在于常量池中)。這也就意味著在 String 對象被創建到垃圾回收器對它進行回收的這段時間,一旦內存被 dump,那么密碼等敏感信息將以明文的形式暴露。
另外,我們可能在編程中無意的將密碼打印到了日志中,這也可能因為日志文件被盜取而導致敏感信息被泄露。最后,在 Java 官方文檔中對 基于密碼的加密 這部分也建議不使用 String 對象來保存密碼。
那么我們該使用什么方式來保存密碼這類敏感信息呢?正確的選擇是使用 char 數組。因為使用數組我們能夠在對敏感信息的業務邏輯處理完成后及時的將其設置為其他任何值,這樣就可以清楚掉我們的密碼信息。同樣,如果我們無意中對密碼進行了日志打印,那么 char 數組輸出的也是內存地址而不是我們的敏感數據。
需要注意的是,即使使用 char 數組來保存敏感信息依然不能保證絕對的安全,因為在內存中可能還會存在這些數據的零散碎片。更加安全的做法是對保存的敏感信息進行 hash,且最好是加鹽 hash,這樣能夠更進一步的提高信息的安全性。但不得不說,沒有絕對的安全,只能更可靠的安全防護方式。
來自:https://zhuanlan.zhihu.com/p/23654187