我們都說,Java 是一門物件導向型程式設計語言,但是它設計出來的「基本資料型別」彷彿又打破了這一點,所以,只能說 Java 是非 100% 純度的物件導向程式設計語言。
但是,為什麼 Sun 公司一直沒有刪除「基本資料型別」,而是為它增設了具有物件導向設計思想的「包裝型別」呢?
想必是有道理的,那麼本文就試著分析一下「基本資料型別」存在的意義以及具有哪些優勢點,還有「包裝類」的具體實現細節。
基本型別 VS 物件型別
Java 中預定義了八種基本資料型別,包括:byte,int,long,double,float,boolean,char,short。基本型別與物件型別最大的不同點在於,基本型別基於數值,物件型別基於引用。
基本型別的變數在棧的區域性變數表中直接儲存的具體數值,而物件型別的變數則儲存的堆中引用。
顯然,相對於基本型別的變數來說,物件型別的變數需要佔用更多的記憶體空間。
上面說到,基本型別基於數值,所以基本型別是沒有類而言的,是不存在類的概念的,也就是說,變數只能儲存數值,而不具備運算元據的方法。物件型別則截然不同,變數實際上是某個類的例項,可以擁有屬性方法等資訊,不再單一的儲存數值,可以提供各種各樣對數值的操作方法,但代價就是犧牲一些效能並佔用更多的記憶體空間。
之所以 Java 裡沒有一刀切了基本型別,就是看在基本型別佔用記憶體空間相對較小,在計算上具有高於物件型別的效能優勢,當然缺點也是不言而喻的。
所以一般都是結合兩者在不同的場合下適時切換,那麼 Java 中提供了哪些「包裝型別」來彌補「基本型別」不具備物件導向思想的劣勢呢?
可以看到,除了 int 和 char 兩者的包裝類名變化有些大以外,其餘六種基本型別對應的包裝類名,都是大寫了首字母而已。
下面我們以 int 和 Integer 為例,通過原始碼簡單看看包裝類具體是如何實現的。
int 與 Integer
首先需要明確一點的是,既然 Integer 是 int 的包裝型別,那麼必然 Integer 也能像 int 一樣儲存整型數值。
/**
* The value of the {@code Integer}.
*
* @serial
*/
private final int value;
複製程式碼
Integer 類的內部定義了一個私有欄位 value,專門用於儲存一個整型數值,整個包裝類就是圍繞著這個 value 封裝了各種不同操作的方法。
而接著我們看看如何構建一個包裝類例項:
public Integer(int value) {
this.value = value;
}
複製程式碼
public Integer(String s) throws NumberFormatException {
this.value = parseInt(s, 10);
}
複製程式碼
Integer 類中提供兩種構造器給我們構建和初始化一個 Integer 類例項。第一種比較直接,允許你直接傳入一個整型數值對 value 進行初始化。第二種間接一點,允許你傳入一個數字的字串,Integer 內部會嘗試著將字串向整型數值進行轉換,如果成功則初始化 value,否則將丟擲一個異常。
所以我們可以通過以下程式碼將一個 int 型別變數轉換成一個 Integer 的包裝類例項:
int age = 22;
Integer iAge = new Integer(age);
複製程式碼
接著,我們知道使用 System.out.println 方法列印一個 Integer 例項時,虛擬機器會使用 Integer 例項的 toString 方法的返回值作為列印方法的引數。
那麼 Integer 內部是如何實現將一個數值轉換為一個整型數值的呢?可能這個問題大家很少想過,因為這樣的細小的問題基本都被封裝的很好了,我們一般的開發並不需要過多的關心,但是如果讓你來寫,你能準確的寫出來嗎?
public String toString() {
return toString(value);
}
複製程式碼
首先,預設無參的 toString 方法會呼叫內部有參的另一個 toString 方法。
public static String toString(int i) {
if (i == Integer.MIN_VALUE)
return "-2147483648";
int size = (i < 0) ? stringSize(-i) + 1 : stringSize(i);
char[] buf = new char[size];
getChars(i, size, buf);
return new String(buf, true);
}
複製程式碼
如果你的值等於 Integer.MIN_VALUE,那麼直接返回預定義好的字串即可,否則將會通過一個方法 stringSize 確定當前傳入的整數 i 是一個幾位的整數,也就是它需要使用幾個字元進行表示,該方法的具體細節我們等會說,這是一個實現很優雅的演算法。
確定了 size,於是可以建立字元陣列,並通過 getChars 方法完成數值向字串的轉換,並最後構建一個字串物件返回。
我們先看看這個 stringSize 方法的具體實現是怎樣的:
final static int [] sizeTable = { 9, 99, 999, 9999, 99999, 999999, 9999999,
99999999, 999999999, Integer.MAX_VALUE };
static int stringSize(int x) {
for (int i=0; ; i++)
if (x <= sizeTable[i])
return i+1;
}
複製程式碼
這段程式碼的實現於我用文字來描述可能不是那麼清晰,我舉個例子,你就能很快明白了。
例如:x 等於 85,那麼比 x 大並且最接近 x 的 sizeTable 元素是 99(兩位數中最大的數值),索引為 1,於是我們得到 x 是一個兩位數(1+1)。
仔細想一想,還是很好理解的,sizeTable 中的每個元素都是同等位數數字下最大的數值,99 是兩位數中最大的,999 是三位數中最大的,等等。那麼當 x 最接近某個索引的元素時,即說明 x 的位數和該元素是一樣的,然後計算該元素的位數即可。
接著我們看看核心的 getChars 方法是如何實現的:
static void getChars(int i, int index, char[] buf) {
int q, r;
int charPos = index;
char sign = 0;
if (i < 0) {
sign = '-';
i = -i;
}
while (i >= 65536) {
q = i / 100;
// really: r = i - (q * 100);
r = i - ((q << 6) + (q << 5) + (q << 2));
i = q;
buf [--charPos] = DigitOnes[r];
buf [--charPos] = DigitTens[r];
}
for (;;) {
q = (i * 52429) >>> (16+3);
r = i - ((q << 3) + (q << 1)); // r = i-(q*10) ...
buf [--charPos] = digits [r];
i = q;
if (i == 0) break;
}
if (sign != 0) {
buf [--charPos] = sign;
}
}
複製程式碼
別看這個方法的程式碼不多,但是卻要求你有一定的二進位制位運算基礎。首先需要明確幾個形參所代表的含義,i 就是我們待轉換成字串的整型數值,index 是該數字的位數,buf 陣列是轉換後的字元儲存的容器,用於儲存結果。
首先,如果 i 是一個負數,那麼變數 sign 的值給它賦為「-」,標識它是一個負數,並將它取正,畢竟正數更方便我們操作。
接著是一個迴圈,只要 i 大於 65536(2^16),就一直執行迴圈體。
q = i / 100;
// q * (2^6 + 2^5 + 2^2) = q * 100
r = i - ((q << 6) + (q << 5) + (q << 2));
q 得到的是 i 去掉個位和十位後的值,而 r 得到的就是丟失的十位和個位,舉個例子:如果 i 等於 12345,那麼 q 等於 123,r 等於 45 。
最後重置 i 的值以便進入下一次迴圈,並通過下面兩條語句完成個位和十位的儲存。
buf [--charPos] = DigitOnes[r];
buf [--charPos] = DigitTens[r];
這兩條賦值語句也很有意思,由於 r 必然是一個兩位數,所以無論怎樣 r 不會超過 100 。例如:r 等於 56,那麼 DigitOnes[r] 將得到 6,DigitTens[r] 將得到 5 。
這段程式碼的設計還是很巧妙的,那麼通過這個迴圈,大於 65536 的位數都被倒序儲存進 buf 陣列中了。
接著的一個 for 迴圈完成就是對小於 65536 的位部分的儲存。
q = (i * 52429) >>> (16+3);
r = i - ((q << 3) + (q << 1));
因為 2^ 19 等於 524288,所以 (i * 52429) >>> (16+3) 等效於 i * 52429 / 524288 約等於 i * 0.1000003814697 ,而 q 是整型數值,所以最終 q 的值其實就等於 i / 10 。
可能為了效率才將簡單的除以十的操作搞這麼複雜的吧,最終 q 儲存的是 i 去掉個位後的數值,r 儲存的是丟失的個位。
例如:i 等於 1234,那麼 q 等於 123,r 等於 4 。
於是可以通過類似的思想一位一位的儲存:
buf [--charPos] = digits [r];
而最後,判斷 sign 標誌位以決定輸出該字串的時候是否需要帶上符號「-」以表示該數值的正負性。
總結一下整個 toString 方法,核心點就兩件事,一是確定該數值的位數,即需要用幾個字元進行表述,二是根據數值轉換成字串。第一步很簡單,不用多說,第二步針對 value 值的大小分步驟進行,大於 65536 的數值採取每次兩位的速度儲存,小於 65536 的數值位採取一位的速度儲存。
Integer 類中還有一類方法,valueOf,這是一個很重要的方法,jdk 1.5 以後實現的自動拆裝箱就是基於它的,具體的我們後面說,先看這個方法的實現。
該方法用到一個 IntegerCache 快取機制,so,我們先看看這個快取機制在 Integer 中的實現情況:
自 jdk 1.5 以後,sun 加入了這個快取類用於複用指定範圍內的 Integer 例項,把內容相同的物件快取起來,減少記憶體開銷。預設可以快取數值在 [-128,127] 之間的例項,當然你可以通過啟動虛擬機器引數 -XX:AutoBoxCacheMax 指定快取區間的最大數值。
而程式的第一步就是讀取虛擬機器啟動引數判斷程式啟動時是否指定了可快取的最大數值,如果 integerCacheHighPropValue 為 null,那麼說明並沒有顯式指定,於是使用 127 作為可快取的最高限定。
否則,根據引數進行一些計算,如果設定的引數小於 127,那麼將取 127 作為快取的最高限定值。理論上,我們可以快取最大到 Integer.MAX_VALUE ,但是實際上是做不到的,因為 Integer[] 陣列可定義的最大長度就是 Integer.MAX_VALUE,而我們還有 127 個負數待快取,顯然陣列容量是不夠的。
所以 IntegerCache 其實最大能快取到 Integer.MAX_VALUE - 129,一旦是設定的引數大於這個值,將預設取用這個值作為最高快取限定。
所以,最終 IntegerCache 能夠快取的數值區間介於 [low,high] 之間。
然後我們看 valueOf 這個方法是如何使用 IntegerCache 的:
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
複製程式碼
如果 i 介於我們的快取區間的話,將直接從 IntegerCache 中返回直接引用,只是它這裡的取值方式有點意思。
cache[0] = -128(128 + -128) cache[1] = -127(128 + -127)
cache[2] = -126(128 + -126) cache[3] = -125(128 + -125)
......
cache[128 + i] = i;
複製程式碼
因為 cache 是從 -128 開始快取的,而索引又是從 0 開始的,所以任意一個數值距離 -128 的差值就是該值快取在 cache 中的索引。
所以,一旦 i 位於我們快取的值區間,那麼將直接從快取池中返回直接引用,否則將會實際建立一個 Integer 例項返回。
我們這裡分析了三到四個方法的原始碼實現,其實 Integer 類中還有很多工具性的方法,限於篇幅我們不能一一敘述,大家可以自行學習一下。
有關於包裝型別和基本型別之間的關係想必大家已經稍有了解了,還有一些有關自動拆裝箱以及一些經典的面試題放在下篇文章。
文章中的所有程式碼、圖片、檔案都雲端儲存在我的 GitHub 上:
(https://github.com/SingleYam/overview_java)
歡迎關注微信公眾號:撲在程式碼上的高爾基,所有文章都將同步在公眾號上。