1. 程式人生 > >讀鄭雨迪《深入拆解Java虛擬機器》 -- 第二講 Java的基本型別

讀鄭雨迪《深入拆解Java虛擬機器》 -- 第二講 Java的基本型別

Java不僅是一門面向物件的語言,它還引進8種基本資料型別來支援數值運算。Java這麼做的原因多半是工程上的考慮,因為使用基本資料型別可以在記憶體使用和運算效率兩方面獲得顯著提升。

今天,我們來了解一下基本資料型別在Java中的實現

public class Foo {
  public static void main(String[] args) {
    boolean 吃過飯沒 = 2; // 直接編譯的話 javac 會報錯
    if (吃過飯沒) System.out.println(" 吃了 ");
    if (true == 吃過飯沒) System.out.println(" 真吃了 ");
  }
}

在上一講中,我們構造了這樣一個程式碼,他將一個boolean型別資料構造為2。

當然,我們直接編譯這段程式碼,編譯器會報錯,所以我們採取迂迴的方式——反彙編器。

賦值語句後面設定了兩個看似一樣的if語句:

  1. 第一個if語句直接判斷“吃過飯沒”,在它成立的情況下,程式碼會列印“吃了”
  2. 第二個if語句判斷“吃過飯沒”和true是否相等,在它成立的情況下,程式碼會答應“真吃了

Java語言中的boolean型別

在Java語言規範中,boolean型別的值只有兩種可能,它們分別用truefalse來表示,顯然這兩種符號是不能被虛擬機器直接使用的、

在Java虛擬機器規範中,boolean型別則被對映成int型別。具體來說,就是

  • true -> 1
  • false -> 0

這個編碼規則約束了Java位元組碼的具體實現。

舉個例子,對於儲存boolean陣列的位元組碼,Java虛擬機器需保證直接存入的值是1或0.

Java虛擬機器規範同時要求Java編譯器遵守這個編碼規則,並且用整數相關的位元組碼來實現邏輯運算,以基於boolean型別條件的跳轉。這樣一來,編譯而成的class檔案,除了欄位和傳入引數外,基本看不出boolean型別的痕跡了。

# Foo.main 編譯後的位元組碼
 0: iconst_2       // 我們用 AsmTools 更改了這一指令
 1: istore_1
 2: iload_1
 3: ifeq 14        // 第一個 if 語句,即運算元棧上數值為 0 時跳轉
 6: getstatic java.lang.System.out
 9: ldc " 吃了 "
11: invokevirtual java.io.PrintStream.println
14: iload_1
15: iconst_1
16: if_icmpne 27   // 第二個 if 語句,即運算元棧上兩個數值不相同時跳轉
19: getstatic java.lang.System.out
22: ldc " 真吃了 "
24: invokevirtual java.io.PrintStream.println
27: return

在前面的例子中,第一個if語句會被翻譯成條件跳轉位元組碼ifeq,翻譯成人的話來說即是,如果區域性變數“吃過飯沒”的值為0,那麼就跳過列印"吃了"的語句。

第二個if語句則會被翻譯成條件跳轉位元組碼if_icmpne,也就是說,如果區域性變數“吃過飯沒”的值和1不相等,那麼就跳過列印“真吃了”的語句。

可以看到,Java編譯器的確遵守了相同的編碼規則。當然,這個約束很容易繞開。除了Asmtools以外,我們還有很過可以修改Java位元組碼的庫,比如ASM等。

對於Java虛擬機器來說,它所看到的boolean型別早就被對映為整數型別。因此,將原本宣告為boolean型別的區域性變數,賦值成為除了0、1之外的變數,在Java虛擬機器看來是合法的。

Java的基本資料型別

除了上面提到的boolean型別外,Java的基本資料型別還包括byte、short、char、int、long、float、double

Java的基本資料型別都有其值域和預設值。可以看到,byte、short、char、int、long、float、double的值域依次擴大,而且前面的值域是後面的值域的真子集。因此,從前面的基本資料型別轉換為後面的基本資料型別,無需強制轉換。另外還需注意的一點是。儘管它們的預設值看起來不一樣,但在記憶體中都是0。

而在它們中間,只有boolean型別和char型別是無符號的。在不考慮違反規範的情況下,boolean型別的取值範圍是0和1,而char型別的取值範圍是[0, 65535]。通常我們可以認定char型別的數值是非負數。這種特性十分有用,比如說作為陣列索引等。

在前面的例子中,我們能夠將整數2儲存到一個宣告為boolean型別的區域性變數中,那麼,宣告為byte、char、short型別的資料是否也能夠儲存超出它們的取值範圍的數值呢?

答案是肯定的。而且這些超出取值範圍的數值也會帶來一些麻煩。比如說,宣告為char型別的變數區域性變數實際上可能為負數。當然,在正常使用Java編譯器的情況下,生成給的位元組碼會遵守Java虛擬機器規範對編譯器的約束,因此你無須過分擔心區域性變數會超出它們的取值範圍。

Java的浮點型別採用IEEE 754浮點數格式。以float為例,浮點型別通常有2個0,+0.0F以及-0.0F。

前者在Java裡是0,後者是符號位為1、其它位為0的浮點數,在記憶體中等同於十六進位制整數0x80000000(即-0.0F 可通過 Float,intBitsToFloat(0x80000000)求得)。儘管它們的記憶體數值不同,但是在Java中+0.0F == -0.0F會返回真。

在有了+0.0F和-0.0F這兩個定義後,我們便可以定義浮點數中的正無窮及負無窮。正無窮就是任意正浮點數(不包括+0.0F)除以+0.0F得到的值,而負無窮是任意正浮點數除以-0.0F得到的值。在Java中,正無窮和負無窮是有確切的值,在記憶體中分別等同於十六進位制整數0x7F800000 和 0xFF800000。

[0x7F800001, 0x7FFFFFFF] 和 [0xFF800001, 0xFFFFFFFF] 對應的都是NaN(Not-a-Number)。當然,一般我們計算得出的NaN,比如說通過+0.0F/+0.0F,在記憶體中應為0x7FC00000。這個數字,我們都稱之為標準的NaN,而其他的我們稱之為不標準的NaN。

NaN有一個有趣的特性:除了"!="始終返回true之外,所有其它比較結果都會返回false。

舉例來說,

  • NaN < 1.0F -> false
  • NaN >= 1.0F -> false
  • f != NaN -> true (f為任意浮點數)
  • f == NaN -> false (f為任意浮點數)

因此,我們在程式裡做浮點數比較時,需要考慮上述特性。

Java基本型別的大小

Java虛擬機器每呼叫一個方法,變回建立一個棧幀。為了方便理解,這裡我只討論供直譯器使用的解釋棧幀(interpreted frame)。

這種棧幀有兩個主要的組成部分,分別是區域性變數區,以及位元組碼的運算元棧。這裡的區域性變數是廣義的,除了普遍意義下的區域性變數之外,它還包含例項方法的"this指標"以及方法所接受的引數。

在Java虛擬機器規範中,區域性變數區等價於一個數組,並且可以用正整數來索引。除了long、double值需要用兩個贖罪單元來儲存外,其它基本型別以及引用型別的值均佔用一個數組單元。

也就是說,boolean、byte、char、short這四種類型,在棧上佔用的空間和int是一樣的,和引用型別是一樣的。因此,在32位的HotSpot中,這些型別在棧上將佔用4個位元組;而64位的HotSpot中,他們將佔用8個位元組。

當然,這種情況僅存在於區域性變數,而並不會出現在儲存於堆中的欄位或者陣列元素上。對於byte、char以及short這三種類型的欄位或者陣列單元,它們在堆上佔用的空間分別為一位元組、兩位元組以及兩位元組。也就是說,跟這些型別的值域想吻合,

因此,當我們將一個Int型別的值,儲存到這些型別的欄位或者陣列時,相當於做了一次隱式的掩碼操作。舉例來說,當我們把0xFFFFFFFF(-1)儲存到一個宣告為char型別的欄位裡時,由於改欄位僅佔兩個位元組,所以高兩位的位元組便會被擷取掉,最終存入"\uFFFF"。

boolean欄位和boolean陣列則標膠特殊。在HotSpot中,boolean型別佔用一個位元組,而boolean陣列則直接用byte陣列來實現的。為了保證堆的boolean型別值是合法的,HotSpot在儲存時顯式地進行掩碼操作,也就是說,只取最後一位的值存入boolean欄位或陣列中。

Java虛擬機器的算數運算幾乎全部依賴於運算元棧。也就是說,我們需要將堆中的boolean、byte、char以及short載入到運算元棧上,而後將棧上的值當成int型別來運算。

  • 對於boolean、char這兩個無符號型別來說,載入時伴隨著零擴充套件。舉個例子,char的大小是兩個位元組。在載入時char的值會被複制到int型別的低二位元組,而高二位元組則會用0來填充。
  • 對於byte、short這兩個型別來說,載入伴隨著符號擴充套件。舉個例子,short的大小是兩個位元組。在載入時short的值同樣會被複制到int型別的低二位元組。如果該short值為非負數,即最高位為0,那麼該int型別的值的高二位元組會用0來填充,否則會用1來填充。

我們來完成老師佈置的作業:將boolean型別的值存入欄位中時,Java虛擬機器所做的掩碼操作。

首先,我們撰寫Java程式碼:

public class Foo{
	static boolean boolValue;//這裡不在棧區

	public static void main(String[] args){
		boolValue = true;
		if(boolValue)
			System.out.println("Hello Java!");
		if(boolValue == true)
			System.out.println("Hello JVM!");
	}
}

使用javac編譯並用java執行它

javac Foo.java
java Foo
Hello Java!
Hello JVM!
java -cp ./asmtools.jar org.openjdk.asmtools.jdis.Main Foo.class > Foo.jasm.1

得到Foo.jasm.1檔案


super public class Foo
	version 52:0
{

static Field boolValue:Z;

public Method "<init>":"()V"
	stack 1 locals 1
{
		aload_0;
		invokespecial	Method java/lang/Object."<init>":"()V";
		return;
}

public static Method main:"([Ljava/lang/String;)V"
	stack 2 locals 1
{
		iconst_2;//看這裡
		putstatic	Field boolValue:"Z";
		getstatic	Field boolValue:"Z";
		ifeq	L18;
		getstatic	Field java/lang/System.out:"Ljava/io/PrintStream;";
		ldc	String "Hello Java!";
		invokevirtual	Method java/io/PrintStream.println:"(Ljava/lang/String;)V";
	L18:	stack_frame_type same;
		getstatic	Field boolValue:"Z";
		iconst_1;
		if_icmpne	L33;
		getstatic	Field java/lang/System.out:"Ljava/io/PrintStream;";
		ldc	String "Hello JVM!";
		invokevirtual	Method java/io/PrintStream.println:"(Ljava/lang/String;)V";
	L33:	stack_frame_type same;
		return;
}

} // end Class Foo

再執行指令(其作用為將Foo.jasm.1檔案中第一個iconst_1 替換為iconst_2, 輸出到檔案Foo.jasm中)

awk 'NR==1,/iconst_1/{sub(/iconst_1/, "iconst_2")} 1' Foo.jasm.1 > Foo.jasm

得到Foo.jasm檔案


super public class Foo
	version 52:0
{

static Field boolValue:Z;

public Method "<init>":"()V"
	stack 1 locals 1
{
		aload_0;
		invokespecial	Method java/lang/Object."<init>":"()V";
		return;
}

public static Method main:"([Ljava/lang/String;)V"
	stack 2 locals 1
{
		iconst_2;//看這裡
		putstatic	Field boolValue:"Z";
		getstatic	Field boolValue:"Z";
		ifeq	L18;
		getstatic	Field java/lang/System.out:"Ljava/io/PrintStream;";
		ldc	String "Hello Java!";
		invokevirtual	Method java/io/PrintStream.println:"(Ljava/lang/String;)V";
	L18:	stack_frame_type same;
		getstatic	Field boolValue:"Z";
		iconst_1;
		if_icmpne	L33;
		getstatic	Field java/lang/System.out:"Ljava/io/PrintStream;";
		ldc	String "Hello JVM!";
		invokevirtual	Method java/io/PrintStream.println:"(Ljava/lang/String;)V";
	L33:	stack_frame_type same;
		return;
}

} // end Class Foo

現在我們將賦給boolValue的值為2,再將其彙編如Foo.class

java -cp ./asmtools.jar org.openjdk.asmtools.jasm.Main Foo.jasm

這時,我們再執行

java Foo

發現沒有任何輸出。

我們再按照上述步驟重複一遍,只需修改指令(即修改賦給boolValue的值為3)

 awk 'NR==1,/iconst_1/{sub(/iconst_1/, "iconst_3")} 1' Foo.jasm.1 > Foo.jasm

得到新的反彙編程式碼


super public class Foo
	version 52:0
{

static Field boolValue:Z;

public Method "<init>":"()V"
	stack 1 locals 1
{
		aload_0;
		invokespecial	Method java/lang/Object."<init>":"()V";
		return;
}

public static Method main:"([Ljava/lang/String;)V"
	stack 2 locals 1
{
		iconst_3; //看這裡
		putstatic	Field boolValue:"Z";
		getstatic	Field boolValue:"Z";
		ifeq	L18;
		getstatic	Field java/lang/System.out:"Ljava/io/PrintStream;";
		ldc	String "Hello Java!";
		invokevirtual	Method java/io/PrintStream.println:"(Ljava/lang/String;)V";
	L18:	stack_frame_type same;
		getstatic	Field boolValue:"Z";
		iconst_1;
		if_icmpne	L33;
		getstatic	Field java/lang/System.out:"Ljava/io/PrintStream;";
		ldc	String "Hello JVM!";
		invokevirtual	Method java/io/PrintStream.println:"(Ljava/lang/String;)V";
	L33:	stack_frame_type same;
		return;
}

} // end Class Foo

再反彙編執行

java -cp ./asmtools.jar org.openjdk.asmtools.jasm.Main Foo.jasm
java Foo
Hello Java!
Hello JVM!

發現全部運行了。

結論:static修飾的成員不是存在棧區,所以在進行boolean型別賦值時會進行掩碼操作,即只保留最低位,2的最低位為0,3的最低位為1,所以將3賦值給靜態boolean型別變數時和true(1)沒有任何差別。

此文從極客時間專欄《深入理解Java虛擬機器》搬運而來,撰寫此文的目的:

  1. 對自己的學習總結歸納

  2. 此篇文章對想深入理解Java虛擬機器的人來說是非常不錯的文章,希望大家支援一下鄭老師。