Java需要學習的東東

小飛鶴發表於2015-03-11




Java 深度歷險(作者成富,是IBM 中國軟體開發中心的高階工程師) 

2
目錄
序 .................................................................................................................................. 1
目錄 ............................................................................................................................... 2
JAVA位元組程式碼的操縱 .................................................................................................... 4
動態編譯JAVA原始檔 ......................................................................................................................... 4
JAVA位元組程式碼增強 ............................................................................................................................. 6
JAVA.LANG.INSTRUMENT ........................................................................................................................... 8
總結 .................................................................................................................................................. 9
參考資料 ........................................................................................................................................ 10
JAVA類的載入、連結和初始化 ................................................................................... 11
JAVA類的載入 .................................................................................................................................. 11
JAVA類的連結 .................................................................................................................................. 12
JAVA類的初始化 ............................................................................................................................... 13
建立自己的類載入器 ..................................................................................................................... 14
參考資料 ........................................................................................................................................ 15
JAVA執行緒:基本概念、可見性與同步 ....................................................................... 16
JAVA執行緒基本概念 ........................................................................................................................... 16
可見性 ............................................................................................................................................ 17
JAVA中的鎖 ...................................................................................................................................... 18
JAVA執行緒的同步 ............................................................................................................................... 19
中斷執行緒 ........................................................................................................................................ 20
參考資料 ........................................................................................................................................ 20
JAVA垃圾回收機制與引用型別 ................................................................................... 22
JAVA垃圾回收機制 ........................................................................................................................... 22
JAVA引用型別 .................................................................................................................................. 23
參考資料 ........................................................................................................................................ 27
JAVA泛型 ..................................................................................................................... 28
型別擦除 ........................................................................................................................................ 28
例項分析 ........................................................................................................................................ 29
萬用字元與上下界 ............................................................................................................................. 30
型別系統 ........................................................................................................................................ 31
開發自己的泛型類 ......................................................................................................................... 32
最佳實踐 ........................................................................................................................................ 32
參考資料 ........................................................................................................................................ 33
目錄
3
JAVA註解 ..................................................................................................................... 34
使用註解 ......................................................................................................................................... 34
開發註解 ......................................................................................................................................... 35
處理註解 ......................................................................................................................................... 35
例項分析 ......................................................................................................................................... 38
參考資料 ......................................................................................................................................... 39
JAVA反射與動態代理 .................................................................................................. 40
基本用法 ......................................................................................................................................... 40
處理泛型 ......................................................................................................................................... 42
動態代理 ......................................................................................................................................... 42
使用案例 ......................................................................................................................................... 43
參考資料 ......................................................................................................................................... 44
JAVA I/O ........................................................................................................................ 45
流 ..................................................................................................................................................... 45
緩衝區 ............................................................................................................................................. 47
字元與編碼 ..................................................................................................................................... 48
通道 ................................................................................................................................................. 49
參考資料 ......................................................................................................................................... 52
JAVA安全 ..................................................................................................................... 53
認證 ................................................................................................................................................. 53
許可權控制 ......................................................................................................................................... 55
加密、解密與簽名 .......................................................................................................................... 57
安全套接字連線 .............................................................................................................................. 58
參考資料 ......................................................................................................................................... 59
JAVA物件序列化與RMI ................................................................................................ 60
基本的物件序列化 .......................................................................................................................... 60
自定義物件序列化 .......................................................................................................................... 61
序列化時的物件替換 ...................................................................................................................... 62
序列化與物件建立 .......................................................................................................................... 63
版本更新 ......................................................................................................................................... 63
序列化安全性 ................................................................................................................................. 64
RMI ................................................................................................................................................... 64
參考資料 ......................................................................................................................................... 66


Java 深度歷險
4
 1 
Java 位元組程式碼的操縱
在一般的Java應用開發過程中,開發人員使用Java的方式比較簡單。開啟慣用的IDE,
編寫Java原始碼,再利用IDE提供的功能直接執行Java 程式就可以了。這種開發模式
背後的過程是:開發人員編寫的是Java原始碼檔案(.java),IDE會負責呼叫Java的編
譯器把Java原始碼編譯成平臺無關的位元組程式碼(byte code),以類檔案的形式儲存在
磁碟上(.class)。Java虛擬機器(JVM)會負責把Java位元組程式碼載入並執行。Java通過這
種方式來實現其 “編寫一次,到處執行(Write once, run anywhere)” 的目標。Java
類檔案中包含的位元組程式碼可以被不同平臺上的JVM所使用。Java位元組程式碼不僅可以以
檔案形式存在於磁碟上,也可以通過網路方式來下載, 還可以只存在於記憶體中。JVM
中的類載入器會負責從包含位元組程式碼的位元組陣列(byte[])中定義出Java類。在某些
情況下,可能會需要動態的生成 Java位元組程式碼,或是對已有的Java位元組程式碼進行修
改。這個時候就需要用到本文中將要介紹的相關技術。首先介紹一下如何動態編譯
Java原始檔。
動態編譯Java 原始檔
在一般情況下,開發人員都是在程式執行之前就編寫完成了全部的Java原始碼並且
成功編譯。對有些應用來說,Java原始碼的內容在執行時刻才能確定。這個時候就
需要動態編譯原始碼來生成Java位元組程式碼,再由JVM來載入執行。典型的場景是很多
演算法競賽的線上評測系統(如PKU JudgeOnline),允許使用者上傳Java程式碼,由系統在
後臺編譯、執行並進行判定。在動態編譯Java原始檔時,使用的做法是直接在程式
中呼叫Java編譯器。
JSR 199引入了Java編譯器API。如果使用JDK 6 的話,可以通過此API來動態編譯Java
程式碼。比如下面的程式碼用來動態編譯最簡單的Hello World類。該Java類的程式碼是保
存在一個字串中的。
public class CompilerTest {
public static void main(String[] args) throws Exception {
String source = "public class Main { public static void main(String[]
args) {System.out.println(\"Hello World!\");} }";
第一章Java 位元組程式碼的操縱
5
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
StandardJavaFileManager fileManager =
compiler.getStandardFileManager(null, null, null);
StringSourceJavaObject sourceObject = new
CompilerTest.StringSourceJavaObject("Main", source);
Iterable< extends JavaFileObject> fileObjects =
Arrays.asList(sourceObject);
CompilationTask task = compiler.getTask(null, fileManager, null,
null, null, fileObjects);
boolean result = task.call();
if (result) {
System.out.println("編譯成功。");
}
}
static class StringSourceJavaObject extends SimpleJavaFileObject {
private String content = null;
public StringSourceJavaObject(String name, String content) throws
URISyntaxException {
super(URI.create("string:///" + name.replace('.','/') +
Kind.SOURCE.extension), Kind.SOURCE);
this.content = content;
}
public CharSequence getCharContent(boolean
ignoreEncodingErrors) throws IOException {
return content;
}
}
}
如 果 不 能 使 用JDK 6 提供的Java 編譯器API 的話, 可以使用JDK 中的工具
類com.sun.tools.javac.Main,不過該工具類只能編譯存放在磁碟上的檔案,類似於
直接使用javac命令。
另外一個可用的工具是Eclipse JDT Core提供的編譯器。這是Eclipse Java開發環境使用
的增量式Java編譯器,支援執行和除錯有錯誤的程式碼。該編譯器也可以單獨使用。Play
框架在內部使用了JDT的編譯器來動態編譯Java原始碼。在開發模式下,Play框架會
定期掃描專案中的Java原始碼檔案,一旦發現有修改,會自動編譯Java原始碼。因此
在修改程式碼之後,重新整理頁面就可以看到變化。使用這些動態編譯的方式的時候,需
要確保JDK中的tools.jar在應用的 CLASSPATH中。
下面介紹一個例子,是關於如何在Java裡面做四則運算,比如求出來(3+4)*7-10 的值。
Java 深度歷險
6
一般的做法是分析輸入的運算表示式,自己來模擬計算過程。考慮到括號的存在和
運算子的優先順序等問題,這樣的計算過程會比較複雜,而且容易出錯。另外一種做
法是可以用JSR 223引入的指令碼語言支援,直接把輸入的表示式當做JavaScript或是
JavaFX指令碼來執行,得到結果。下面的程式碼使用的做法是動態生成Java原始碼並編譯,
接著載入Java類來執行並獲取結果。這種做法完全使用Java來實現。
private static double calculate(String expr) throws CalculationException
{
String className = "CalculatorMain";
String methodName = "calculate";
String source = "public class " + className
+ " { public static double " + methodName + "() { return " + expr
+ "; } }";
//省略動態編譯Java原始碼的相關程式碼,參見上一節
boolean result = task.call();
if (result) {
ClassLoader loader = Calculator.class.getClassLoader();
try {
Class<?> clazz = loader.loadClass(className);
Method method = clazz.getMethod(methodName, new Class<?>[] {});
Object value = method.invoke(null, new Object[] {});
return (Double) value;
} catch (Exception e) {
throw new CalculationException("內部錯誤。");
}
} else {
throw new CalculationException("錯誤的表示式。");
}
}
上面的程式碼給出了使用動態生成的 Java 位元組程式碼的基本模式,即通過類載入器來加
載位元組程式碼,建立Java 類的物件的例項,再通過Java 反射API 來呼叫物件中的方法。
Java 位元組程式碼增強
Java 位元組程式碼增強指的是在Java位元組程式碼生成之後,對其進行修改,增強其功能。
這種做法相當於對應用程式的二進位制檔案進行修改。在很多Java框架中都可以見到
這種實現方式。Java位元組程式碼增強通常與Java原始檔中的註解(annotation)一塊使
用。註解在Java原始碼中宣告瞭需要增強的行為及 相關的後設資料,由框架在執行時
刻完成對位元組程式碼的增強。Java位元組程式碼增強應用的場景比較多,一般都集中在減
少冗餘程式碼和對開發人員遮蔽底層的實現細節 上。用過JavaBeans的人可能對其中
第一章Java 位元組程式碼的操縱
7
那些必須新增的getter/setter方法感到很繁瑣,並且難以維護。而通過位元組程式碼增強,
開發人員只需要宣告Bean中的屬性即可,getter/setter方法可以通過修改位元組程式碼來
自動新增。用過JPA的人,在除錯程式的時候,會發現實體類中被新增了一些額外
的 域和方法。這些域和方法是在執行時刻由JPA的實現動態新增的。位元組程式碼增強
在面向方面程式設計(AOP)的一些實現中也有使用。
在討論如何進行位元組程式碼增強之前,首先介紹一下表示一個Java 類或介面的位元組代
碼的組織形式。
類檔案 {
0xCAFEBABE,小版本號,大版本號,常量池大小,常量池陣列,
訪問控制標記,當前類資訊,父類資訊,實現的介面個數,實現的介面資訊
陣列,域個數,域資訊陣列,方法個數,方法資訊陣列,屬性個數,屬
性資訊陣列
}
如上所示,一個類或介面的位元組程式碼使用的是一種鬆散的組織結構,其中所包含的
內容依次排列。對於可能包含多個條目的內容,如所實現的介面、域、方法和屬性
等,是以陣列來表示的。而在陣列之前的是該陣列中條目的個數。不同的內容型別,
有其不同的內部結構。對於開發人員來說,直接操縱包含位元組程式碼的位元組陣列的話,
開發效率比較低,而且容易出錯。已經有不少的開源庫可以對位元組程式碼進行修改或
是從頭開始建立新的Java類的位元組程式碼內容。這些類庫包括ASM、cglib、serp和BCEL
等。使用這些類庫可以在一定程度上降低增強位元組程式碼的複雜度。比如考慮下面一
個簡單的需求,在一個Java類的所有方法執行之前輸出相應的日誌。熟悉AOP的人都
知道,可以用一個前增強(before advice)來解決這個問題。如果使用ASM的話,相
關的程式碼如下:
ClassReader cr = new ClassReader(is);
ClassNode cn = new ClassNode();
cr.accept(cn, 0);
for (Object object : cn.methods) {
MethodNode mn = (MethodNode) object;
if ("<init>".equals(mn.name) || "<clinit>".equals(mn.name)) {
continue;
}
InsnList insns = mn.instructions;
InsnList il = new InsnList();
il.add(new FieldInsnNode(GETSTATIC, "java/lang/System", "out",

Java 深度歷險
8
"Ljava/io/PrintStream;"));
il.add(new LdcInsnNode("Enter method -> " + mn.name));
il.add(new MethodInsnNode(INVOKEVIRTUAL, "java/io/PrintStream",
"println", "(Ljava/lang/String;)V"));
insns.insert(il); mn.maxStack += 3;
}
ClassWriter cw = new ClassWriter(0);
cn.accept(cw);
byte[] b = cw.toByteArray();
從 ClassWriter就 可以獲取到包含增強之後的位元組程式碼的位元組陣列,可以把位元組程式碼
寫回磁碟或是由類載入器直接使用。上述示例中,增強部分的邏輯比較簡單,只是
遍歷Java類中的所有方法並新增對System.out.println方法的呼叫。在位元組程式碼中,Java
方法體是由一系列的指令組成的。而要做的是生成呼叫 System.out.println方法的指
令,並把這些指令插入到指令集合的最前面。ASM對這些指令做了抽象,不過熟悉
全部的指令比較困難。ASM提供了一個工具類ASMifierClassVisitor,可以列印出Java
類的位元組程式碼的結構資訊。當需要增強某個類的時候,可以先在原始碼上做出修改,
再通過此工具類來比較修改前後的位元組程式碼的差異,從而確定該如何編寫增強的代
碼。
對類檔案進行增強的時機是需要在Java 原始碼編譯之後,在JVM 執行之前。比較常
見的做法有:
 由IDE在完成編譯操作之後執行。如Google App Engine的Eclipse外掛會在編譯之
後執行DataNucleus來對實體類進行增強。
 在構建過程中完成,比如通過 Ant 或Maven 來執行相關的操作。
 實現自己的 Java 類載入器。當獲取到Java 類的位元組程式碼之後,先進行增強處理,
再從修改過的位元組程式碼中定義出Java 類。
 通過 JDK 5 引入的java.lang.instrument 包來完成。
java.lang.instrument
由於存在著大量對Java位元組程式碼進行修改的需求,JDK 5引入了java.lang.instrument
包並在JDK 6中 得到了進一步的增強。基本的思路是在JVM啟動的時候新增一些代
理(agent)。每個代理是一個jar包,其清單(manifest)檔案中會指定一個 代理類。
這個類會包含一個premain方法。JVM在啟動的時候會首先執行代理類的premain方
法,再執行Java程式本身的main方法。在 premain方法中就可以對程式本身的位元組
第一章Java 位元組程式碼的操縱
9
程式碼進行修改。JDK 6 中還允許在JVM啟動之後動態新增代理。java.lang.instrument
包支援兩種修改的場景,一種是重定義一個Java類,即完全替換一個 Java類的位元組
程式碼;另外一種是轉換已有的Java類,相當於前面提到的類位元組程式碼增強。還是以
前面提到的輸出方法執行日誌的場景為例, 首先需要實
現java.lang.instrument.ClassFileTransformer介面來完成對已有Java類的轉換。
static class MethodEntryTransformer implements ClassFileTransformer {
public byte[] transform(ClassLoader loader, String className,
Class<?> classBeingRedefined, ?ProtectionDomain protectionDomain,
byte[] classfileBuffer)
throws IllegalClassFormatException {
try {
ClassReader cr = new ClassReader(classfileBuffer);
ClassNode cn = new ClassNode();
//省略使用ASM進行位元組程式碼轉換的程式碼
ClassWriter cw = new ClassWriter(0);
cn.accept(cw);
return cw.toByteArray();
} catch (Exception e){
return null;
}
}
}
有了這個轉換類之後,就可以在代理的 premain 方法中使用它。
public static void premain(String args, Instrumentation inst) {
inst.addTransformer(new MethodEntryTransformer());
}
把該代理類打成一個 jar 包,並在jar 包的清單檔案中通過Premain-Class 宣告代理類
的名稱。執行Java 程式的時候,新增JVM 啟動引數-javaagent:myagent.jar。這樣的
話,JVM 會在載入Java 類的位元組程式碼之前,完成相關的轉換操作。
總結
操縱Java 位元組程式碼是一件很有趣的事情。通過它,可以很容易的對二進位制分發的Java
程式進行修改,非常適合於效能分析、除錯跟蹤和日誌記錄等任務。另外一個非常
重要的作用是把開發人員從繁瑣的Java 語法中解放出來。開發人員應該只需要負責
編寫與業務邏輯相關的重要程式碼。對於那些只是因為語法要求而新增的,或是模式
固定的程式碼,完全可以將其位元組程式碼動態生成出來。位元組程式碼增強和原始碼生成是
不同的概念。原始碼生成之後,就已經成為了程式的一部分,開發人員需要去維護
Java 深度歷險
10
它:要麼手工修改生成出來的原始碼,要麼重新生成。而位元組程式碼的增強過程,對
於開發人員是完全透明的。妥善使用Java 位元組程式碼的操縱技術,可以更好的解決某
一類開發問題。
參考資料
 Java位元組程式碼格式
 Java 6.0 Compiler API
 深入探討Java類載入器
Java 深度歷險
11
 2 
Java 類的載入、連結和初始化
在上一篇文章中介紹了Java位元組程式碼的操縱,其中提到了利用Java類載入器來載入
修改過後的位元組程式碼並在JVM上執行。本文接著上一篇的話題,討論Java類的載入、
連結和初始化。Java位元組程式碼的表現形式是位元組陣列(byte[]),而Java類在JVM中的
表現形式是java.lang.Class類 的物件。一個Java類從位元組程式碼到能夠在JVM中被使用,
需要經過載入、連結和初始化這三個步驟。這三個步驟中,對開發人員直接可見的
是Java類的加 載,通過使用Java類載入器(class loader)可以在執行時刻動態的加
載一個Java類;而連結和初始化則是在使用Java類之前會發生的動作。本文會詳細介
紹Java類的載入、連結和 初始化的過程。
Java 類的載入
Java類的載入是由類載入器來完成的。一般來說,類載入器分成兩類:啟動類載入
器(bootstrap)和使用者自定義的類載入器(user-defined)。兩者的區別在於啟動類
載入器是由JVM的原生程式碼實現的,而使用者自定義的類載入器都繼承自Java中
的java.lang.ClassLoader類。在使用者自定義類載入器的部分,一般JVM都會提供一些
基本實現。應用程式的開發人員也可以根據需要編寫自己的類載入器。JVM中最常
使用的是系統類載入器(system),它用來啟動Java應用程式的載入。通過
java.lang.ClassLoader的getSystemClassLoader()方法可以獲取到該類載入器物件。
類載入器需要完成的最終功能是定義一個Java類,即把Java位元組程式碼轉換成JVM中的
java.lang.Class類的物件。但是類載入的過程並不 是這麼簡單。Java類載入器有兩個
比較重要的特徵:層次組織結構和代理模式。層次組織結構指的是每個類載入器都
有一個父類載入器,通過getParent()方法可以獲取到。類載入器通過這種父親-後代
的方式組織在一起,形成樹狀層次結構。代理模式則指的是一個類載入器既可以自
己完成Java類的定義工作,也可 以代理給其它的類載入器來完成。由於代理模式的
存在,啟動一個類的載入過程的類載入器和最終定義這個類的類載入器可能並不是
一個。前者稱為初始類載入器, 而後者稱為定義類載入器。兩者的關聯在於:一個
Java類的定義類載入器是該類所匯入的其它Java類的初始類載入器。比如類A通過
Java 深度歷險
12
import匯入了類 B,那麼由類A的定義類載入器負責啟動類B的載入過程。
一般的類載入器在嘗試自己去載入某個Java類之前,會首先代理給其父類載入器。
當父類載入器找不到的時候, 才會嘗試自己載入。這個邏輯是封裝在
java.lang.ClassLoader類的loadClass()方法中的。一般來說,父類優先的策略就足夠好
了。在某些情況下,可能需要採取相反的策略,即先嚐試自己載入,找不到的時候
再代理給父類載入器。這種做法在Java的Web容器中比較常見,也是Servlet規範推
薦的做法。比如,Apache Tomcat為每個Web應用都提供一個獨立的類載入器,使用
的就是自己優先載入的策略。IBM WebSphere Application Server則允許Web應用選擇
類載入器使用的策略。
類載入器的一個重要用途是在JVM中為相同名稱的Java類建立隔離空間。在JVM中,
判斷兩個類是否相同,不僅是根據該類的二進位制名稱,還需要根據兩個類的定義類
載入器。只有兩者完全一樣,才認為兩個類的是相同的。因此,即便是同樣的Java
位元組程式碼,被兩個不同的類載入器定義之後,所得到的Java類也是不同的。如果試
圖在兩個類的物件之間進行賦值操作,會丟擲java.lang.ClassCastException。這個特
性為同樣名稱的Java類在JVM中共存創造了條件。在實際的應用中,可能會要求同一
名稱的Java類的不同版本在JVM中可以同時存在。通過類載入器就可以滿足這種需
求。這種技術在OSGi中得到了廣泛的應用。
Java 類的連結
Java類的連結指的是將Java類的二進位制程式碼合併到JVM的執行狀態之中的過程。在鏈
接之前,這個類必須被成功載入。類的連結包括驗證、準備和解析等幾個步驟。驗
證是用來確保Java類的二進位制表示在結構上是完全正確的。如果驗證過程出現錯誤
的話,會丟擲java.lang.VerifyError錯誤。準備過程則是建立Java類中的靜態域,並將
這些域的值設為預設值。準備過程並不會執行程式碼。在一個Java類中會包含對其它
類或介面的形式引用,包括它的父類、所實現的介面、方法的形式引數和返回值的
Java類等。解析的過程就是確保這些被引用的類能被正確的找到。解析的過程可能
會導致其它的 Java類被載入。
不同的JVM 實現可能選擇不同的解析策略。一種做法是在連結的時候,就遞迴的把
所有依賴的形式引用都進行解析。而另外的做法則可能是隻在一個形式引用真正需
要的時候才進行解析。也就是說如果一個Java 類只是被引用了,但是並沒有被真正
用到,那麼這個類有可能就不會被解析。考慮下面的程式碼:
第二章Java 類的載入、連結和初始化
13
public class LinkTest {
public static void main(String[] args) {
ToBeLinked toBeLinked = null;
System.out.println("Test link.");
}
}
類 LinkTest 引用了類ToBeLinked,但是並沒有真正使用它,只是宣告瞭一個變數,
並沒有建立該類的例項或是訪問其中的靜態域。在 Oracle 的JDK 6 中,如果把編譯
好的ToBeLinked 的Java 位元組程式碼刪除之後,再執行LinkTest,程式不會丟擲錯誤。
這是因為ToBeLinked 類沒有被真正用到,而Oracle 的JDK 6 所採用的連結策略使得
ToBeLinked 類不會被載入,因此也不會發現ToBeLinked 的Java 位元組程式碼實際上是不
存在的。如果把程式碼改成ToBeLinked toBeLinked = new ToBeLinked();之後,再按照相
同的方法執行,就會丟擲異常了。因為這個時候ToBeLinked 這個類被真正使用到了,
會需要載入這個類。
Java 類的初始化
當一個Java 類第一次被真正使用到的時候,JVM 會進行該類的初始化操作。初始化
過程的主要操作是執行靜態程式碼塊和初始化靜態域。在一個類被初始化之前,它的
直接父類也需要被初始化。但是,一個介面的初始化,不會引起其父介面的初始化。
在初始化的時候,會按照原始碼中從上到下的順序依次執行靜態程式碼塊和初始化靜
態域。考慮下面的程式碼:
public class StaticTest {
public static int X = 10;
public static void main(String[] args) {
System.out.println(Y); //輸出60
}
static {
X = 30;
}
public static int Y = X * 2;
}
在上面的程式碼中,在初始化的時候,靜態域的初始化和靜態程式碼塊的執行會從上到
下依次執行。因此變數X 的值首先初始化成10,後來又被賦值成30;而變數Y 的
值則被初始化成60。
Java 類和介面的初始化只有在特定的時機才會發生,這些時機包括:
 建立一個 Java 類的例項。如
Java 深度歷險
14
MyClass obj = new MyClass()
 呼叫一個 Java 類中的靜態方法。如
MyClass.sayHello()
 給 Java 類或介面中宣告的靜態域賦值。如
MyClass.value = 10
 訪問 Java 類或介面中宣告的靜態域,並且該域不是常值變數。如
int value = MyClass.value
 在頂層 Java 類中執行assert 語句。
通過Java 反射API 也可能造成類和介面的初始化。需要注意的是,當訪問一個Java
類或介面中的靜態域的時候,只有真正宣告這個域的類或介面才會被初始化。考慮
下面的程式碼:
class B {
static int value = 100;
static {
System.out.println("Class B is initialized."); //輸出
}
}
class A extends B {
static {
System.out.println("Class A is initialized."); //不會輸出
}
}
public class InitTest {
public static void main(String[] args) {
System.out.println(A.value); //輸出100
}
}
在上述程式碼中,類InitTest 通過A.value 引用了類B 中宣告的靜態域value。由於value
是在類B 中宣告的,只有類B 會被初始化,而類A 則不會被初始化。
建立自己的類載入器
在Java 應用開發過程中,可能會需要建立應用自己的類載入器。典型的場景包括實
現特定的Java 位元組程式碼查詢方式、對位元組程式碼進行加密/解密以及實現同名 Java 類
的隔離等。建立自己的類載入器並不是一件複雜的事情, 只需要繼承自
java.lang.ClassLoader 類並覆寫對應的方法即可。java.lang.ClassLoader 中提供的方法
有不少,下面介紹幾個建立類載入器時需要考慮的:
第二章Java 類的載入、連結和初始化
15
 defineClass():這個方法用來完成從Java位元組程式碼的位元組陣列到java.lang.Class的轉
換。這個方法是不能被覆寫的,一般是用原生程式碼來實現的。
 findLoadedClass():這個方法用來根據名稱查詢已經載入過的Java類。一個類加
載器不會重複載入同一名稱的類。
 findClass():這個方法用來根據名稱查詢並載入Java類。
 loadClass():這個方法用來根據名稱載入Java類。
 resolveClass():這個方法用來連結一個Java類。
這裡比較 容易混淆的是findClass()方法和loadClass()方法的作用。前面提到過,在
Java 類的連結過程中,會需要對Java 類進行解析,而解析可能會導致當前Java 類所
引用的其它Java 類被載入。在這個時候,JVM 就是通過呼叫當前類的定義類載入器
的loadClass()方法來載入其它類的。findClass()方法則是應用建立的類載入器的擴充套件
點。應用自己的類載入器應該覆寫findClass()方法來新增自定義的類載入邏輯。
loadClass()方法的預設實現會負責呼叫findClass()方法。
前面提到,類載入器的代理模式預設使用的是父類優先的策略。這個策略的實現是
封裝在loadClass()方法中的。如果希望修改此策略,就需要覆寫loadClass()方法。
下面的程式碼給出了自定義的類載入的常見實現模式:
public class MyClassLoader extends ClassLoader {
protected Class<?> findClass(String name) throws
ClassNotFoundException {
byte[] b = null; //查詢或生成Java類的位元組程式碼
return defineClass(name, b, 0, b.length);
}
}
參考資料
 Java語言規範(第三版)- 第十三章:執行
 JVM規範(第二版) - 第五章:載入、連結和初始化
 深入探討Java類載入器
Java 深度歷險
16
 3 
Java 執行緒:基本概念、可見性與同步
開發高效能併發應用不是一件容易的事情。這類應用的例子包括高效能Web伺服器、
遊戲伺服器和搜尋引擎爬蟲等。這樣的應用可能需要同時處理成千上萬個請求。對
於這樣的應用,一般採用多執行緒或事件驅動的架構。對於Java來說,在語言內部提
供了執行緒的支援。但是Java的多執行緒應用開發會遇到很多問題。首先是很難編寫正
確,其次是很難測試是否正確,最後是出現 問題時很難除錯。一個多執行緒應用可能
執行了好幾天都沒問題,然後突然就出現了問題,之後卻又無法再次重現出來。如
果在正確性之外,還需要考慮應用的吞吐量和效能優化的話,就會更加複雜。本文
主要介紹Java中的執行緒的基本概念、可見性和執行緒同步相關的內容。
Java 執行緒基本概念
在作業系統中兩個比較容易混淆的概念是程式(process)和執行緒(thread)。 操
作系統中的程式是資源的組織單位。程式有一個包含了程式內容和資料的地址空間,
以及其它的資源,包括開啟的檔案、子程式和訊號處理器等。不同程式的地址空間
是互相隔離的。而執行緒表示的是程式的執行流程,是CPU排程的基本單位。執行緒有
自己的程式計數器、暫存器、棧和幀等。引入執行緒的動機在於作業系統中阻塞式I/O
的存在。當一個執行緒所執行的I/O被阻塞的時候,同一程式中的其它執行緒可以使用CPU
來進行計算。這樣的話,就提高了應用的執行效率。執行緒的概 念在主流的作業系統
和程式語言中都得到了支援。
一部分的Java程式是單執行緒的。程式的機器指令按照程式中給定的順序依次執行。
Java語言提供了java.lang.Thread類來為執行緒提供抽象。有兩種方式建立一個新的線
程:一種是繼承java.lang.Thread類並覆寫其中的run()方法,另外一種則是在建立
java.lang.Thread類的物件的時候,在建構函式中提供一個實現了java.lang.Runnable接
口的類的物件。在得到了java.lang.Thread類的物件之後,通過呼叫其start()方法就可
以啟動這個執行緒的執行。
一個執行緒被建立成功並啟動之後,可以處在不同的狀態中。這個執行緒可能正在佔用
Java 深度歷險
17
CPU 時間執行;也可能處在就緒狀態,等待被排程執行;還可能阻塞在某個資源或
是事件上。多個就緒狀態的執行緒會競爭CPU 時間以獲得被執行的機會,而CPU 則採
用某種演算法來排程執行緒的執行。不同執行緒的執行順序是不確定的,多執行緒程式中的
邏輯不能依賴於CPU 的排程演算法。
可見性
可見性(visibility)的問題是Java 多執行緒應用中的錯誤的根源。在一個單執行緒程式
中,如果首先改變一個變數的值,再讀取該變數的值的時候,所讀取到的值就是上
次寫操作寫入的值。也就是說前面操作的結果對後面的操作是肯定可見的。但是在
多執行緒程式中,如果不使用一定的同步機制,就不能保證一個執行緒所寫入的值對另
外一個執行緒是可見的。造成這種情況的原因可能有下面幾個:
 CPU 內部的快取:現在的CPU 一般都擁有層次結構的幾級快取。CPU 直接操作
的是快取中的資料,並在需要的時候把快取中的資料與主存進行同步。因此在
某些時刻,快取中的資料與主存內的資料可能是不一致的。某個執行緒所執行的
寫入操作的新值可能當前還儲存在CPU 的快取中,還沒有被寫回到主存中。這
個時候,另外一個執行緒的讀取操作讀取的就還是主存中的舊值。
 CPU 的指令執行順序:在某些時候,CPU 可能改變指令的執行順序。這有可能
導致一個執行緒過早的看到另外一個執行緒的寫入操作完成之後的新值。
 編譯器程式碼重排:出於效能優化的目的,編譯器可能在編譯的時候對生成的目
標程式碼進行重新排列。
現實的情況是:不同的CPU可能採用不同的架構,而這樣的問題在多核處理器和多
處理器系統中變得尤其複雜。而Java的目標是要實現“編寫一次,到處執行”,因此
就有必要對Java程式訪問和操作主存的方式做出規範,以保證同樣的程式在不同的
CPU架構上的執行結果是一致的。Java記憶體模型(Java Memory Model)就是為了這
個目的而引入的。JSR 133則進一步修正了之前的記憶體模型中存在的問題。總得來說,
Java記憶體模型描述了程式中共享變數的關係以及在主存中寫入和讀取這些變數值的
底層細節。Java記憶體模型定義了Java語言中的synchronized、volatile和final等關鍵詞
對主存中變數讀寫操作的意義。Java開發人員使用這些關鍵詞來描述程式所期望的
行為,而編譯器和JVM負責保證生成的程式碼在執行時刻的行為符合記憶體模型的描述。
比如對宣告為volatile的變數來說,在讀取之前,JVM會確保CPU中快取的值首先會失
效,重新從主存中進行讀取;而寫入之後,新的值會被馬上寫入到主存中。而
第三章Java 執行緒:基本概念、可見性與同步
18
synchronized和volatile關鍵詞也會對編譯器優化時候的程式碼重排帶來額外的限制。比
如編譯器不能把synchronized塊中的程式碼移出來。對volatile變數的讀寫操作是不能與
其它讀寫操作一塊重新排列的。
Java 記憶體模型中一個重要的概念是定義了“在之前發生(happens-before)”的順序。
如果一個動作按照“在之前發生”的順序發生在另外一個動作之前,那麼前一個動
作的結果在多執行緒的情況下對於後一個動作就是肯定可見的。最常見的“在之前發
生”的順序包括:對一個物件上的監視器的解鎖操作肯定發生在下一個對同一個監
視器的加鎖操作之前;對宣告為volatile 的變數的寫操作肯定發生在後續的讀操作之
前。有了“在之前發生”順序,多執行緒程式在執行時刻的行為在關鍵部分上就是可
預測的了。編譯器和JVM 會確保“在之前發生”順序可以得到保證。比如下面的一
個簡單的方法:
public void increase() {
this.count++;
}
這是一個常見的計數器遞增方法,this.count++實際是this.count = this.count + 1,由
一個對變數this.count 的讀取操作和寫入操作組成。如果在多執行緒情況下,兩個線
程執行這兩個操作的順序是不可預期的。如果 this.count 的初始值是1,兩個執行緒
可能都讀到了為1 的值,然後先後把this.count 的值設為2,從而產生錯誤。錯誤的
原因在於其中一個執行緒對this.count 的寫入操作對另外一個執行緒是不可見的,另外
一個執行緒不知道this.count 的值已經發生了變化。如果在increase() 方法宣告中加上
synchronized 關鍵詞,那就在兩個執行緒的操作之間強制定義了一個“在之前發生”
順序。一個執行緒需要首先獲得當前物件上的鎖才能執行,在它擁有鎖的這段時間完
成對this.count 的寫入操作。而另一個執行緒只有在當前執行緒釋放了鎖之後才能執行。
這樣的話,就保證了兩個執行緒對 increase()方法的呼叫只能依次完成,保證了執行緒之
間操作上的可見性。
如果一個變數的值可能被多個執行緒讀取,又能被最少一個執行緒鎖寫入,同時這些讀
寫操作之間並沒有定義好的“在之前發生”的順序的話,那麼在這個變數上就存在
資料競爭(data race)。資料競爭的存在是Java 多執行緒應用中要解決的首要問題。解
決的辦法就是通過synchronized 和volatile 關鍵詞來定義好“在之前發生”順序。
Java 中的鎖
當資料競爭存在的時候,最簡單的解決辦法就是加鎖。鎖機制限制在同一時間只允
Java 深度歷險
19
許一個執行緒訪問產生競爭的資料的臨界區。Java 語言中的 synchronized 關鍵字可以
為一個程式碼塊或是方法進行加鎖。任何Java 物件都有一個自己的監視器,可以進行
加鎖和解鎖操作。當受到 synchronized 關鍵字保護的程式碼塊或方法被執行的時候,
就說明當前執行緒已經成功的獲取了物件的監視器上的鎖。當程式碼塊或是方法正常執
行完成或是發生異常退出的時候,當前執行緒所獲取的鎖會被自動釋放。一個執行緒可
以在一個Java 物件上加多次鎖。同時JVM 保證了在獲取鎖之前和釋放鎖之後,變數
的值是與主存中的內容同步的。
Java 執行緒的同步
在有些情況下,僅依靠執行緒之間對資料的互斥訪問是不夠的。有些執行緒之間存在協
作關係,需要按照一定的協議來協同完成某項任務,比如典型的生產者-消費者模式。
這種情況下就需要用到Java提供的執行緒之間的等待-通知機制。當執行緒所要求的條件
不滿足時,就進入等待狀態;而另外的執行緒則負責在合適的時機發出通 知來喚醒等
待中的執行緒。Java中的java.lang.Object類中的wait/notify/notifyAll方法組就是完成線
程之間的同步的。
在某個Java物件上面呼叫wait方法的時候,首先要檢查當前執行緒是否獲取到了這個對
象上的鎖。如果沒有的話,就會直接丟擲java.lang.IllegalMonitorStateException異常。
如果有鎖的話,就把當前執行緒新增到物件的等待集合中,並釋放其所擁有的鎖。當
前執行緒被阻塞,無法繼續執行,直到被從物件的等待集合中移除。引起某個執行緒從
物件的等待集合中移除的原因有很多:物件上的notify方法被呼叫時,該執行緒被選中;
物件上的notifyAll方法被呼叫;執行緒被中斷;對於有超時限制的wait操作,當超過時
間限制時;JVM內部實現在非正常情況下的操作。
從上面的說明中,可以得到幾條結論:wait/notify/notifyAll 操作需要放在synchronized
程式碼塊或方法中,這樣才能保證在執行 wait/notify/notifyAll 的時候,當前執行緒已經
獲得了所需要的鎖。當對於某個物件的等待集合中的執行緒數目沒有把握的時候,最
好使用 notifyAll 而不是notify。notifyAll 雖然會導致執行緒在沒有必要的情況下被喚醒
而產生效能影響,但是在使用上更加簡單一些。由於執行緒可能在非正常情況下被意
外喚醒,一般需要把wait 操作放在一個迴圈中,並檢查所要求的邏輯條件是否滿足。
典型的使用模式如下所示:
private Object lock = new Object();
synchronized (lock) {
while (/* 邏輯條件不滿足的時候*/) {
第三章Java 執行緒:基本概念、可見性與同步
20
try {
lock.wait();
} catch (InterruptedException e) {}
}
//處理邏輯
}
上述程式碼中使用了一個私有物件 lock 來作為加鎖的物件,其好處是可以避免其它代
碼錯誤的使用這個物件。
中斷執行緒
通過一個執行緒物件的interrupt()方 法可以向該執行緒發出一箇中斷請求。中斷請求是
一種執行緒之間的協作方式。當執行緒A通過呼叫執行緒B的interrupt()方法來發出中斷請求
的時候,執行緒A 是在請求執行緒B的注意。執行緒B應該在方便的時候來處理這個中斷請
求,當然這不是必須的。當中斷髮生的時候,執行緒物件中會有一個標記來記錄當前
的中斷狀態。通過isInterrupted()方法可以判斷是否有中斷請求發生。如果當中斷請
求發生的時候,執行緒正處於阻塞狀態,那麼這個中斷請求會導致該執行緒退出阻塞狀
態。可能造成執行緒處於阻塞狀態的情況有:當執行緒通過呼叫wait()方法進入一個物件
的等待集合中,或是通過sleep()方法來暫時休眠,或是通過join()方法來等待另外一
個執行緒完成的時候。線上程阻塞的情況下, 當中斷髮生的時候, 會拋
出java.lang.InterruptedException, 程式碼會進入相應的異常處理邏輯之中。實際上在
呼叫wait/sleep/join方法的時候,是必須捕獲這個異常的。中斷一個正在某個物件的
等待集合中的執行緒,會使得這個執行緒從等待集合中被移除,使得它可以在再次獲得
鎖之後,繼續執行java.lang.InterruptedException異常的處 理邏輯。
通過中斷執行緒可以實現可取消的任務。在任務的執行過程中可以定期檢查當前執行緒
的中斷標記,如果執行緒收到了中斷請求,那麼就可以終止這個任務的執行。當遇到
java.lang.InterruptedException 的異常,不要捕獲了之後不做任何處理。如果不想在
這個層次上處理這個異常,就把異常重新丟擲。當一個在阻塞狀態的執行緒被中斷並
且丟擲java.lang.InterruptedException 異常的時候,其物件中的中斷狀態標記會被清
空。如果捕獲了java.lang.InterruptedException 異常但是又不能重新丟擲的話,需要
通過再次呼叫interrupt()方法來重新設定這個標記。
參考資料
 現代作業系統
Java 深度歷險
21
 Java語言規範(第三版)第17 章:執行緒與鎖
 Java記憶體模型FAQ
 Fixing the Java Memory Model, Part 1 & Part 2
Java 深度歷險
22
 4 
Java 垃圾回收機制與引用型別
Java語言的一個重要特性是引入了自動的記憶體管理機制,使得開發人員不用自己來
管理應用中的記憶體。C/C++開發人員需要通過malloc/free 和new/delete等函式來顯
式的分配和釋放記憶體。這對開發人員提出了比較高的要求,容易造成記憶體訪問錯誤
和記憶體洩露等問題。一個常見的問題是會產生“懸掛引用 (dangling references)”,
即一個物件引用所指向的記憶體區塊已經被錯誤的回收並重新分配給新的物件了,程
序如果繼續使用這個引用的話會造成不可預期的結果。開發人員有可能忘記顯式的
呼叫釋放記憶體的函式而造成記憶體洩露。而自動的記憶體管理則是把管理記憶體的任務交
給程式語言的執行環境來完成。開發人員並不需要關心記憶體的分配和回收的底層細
節。Java平臺通過垃圾回收器來進行自動的記憶體管理。
Java 垃圾回收機制
Java 的垃圾回收器要負責完成3 件任務:分配記憶體、確保被引用的物件的記憶體不被
錯誤回收以及回收不再被引用的物件的記憶體空間。垃圾回收是一個複雜而且耗時的
操作。如果JVM 花費過多的時間在垃圾回收上,則勢必會影響應用的執行效能。一
般情況下,當垃圾回收器在進行回收操作的時候,整個應用的執行是被暫時中止
(stop-the-world)的。這是因為垃圾回收器需要更新應用中所有物件引用的實際內
存地址。不同的硬體平臺所能支援的垃圾回收方式也不同。比如在多CPU 的平臺上,
就可以通過並行的方式來回收垃圾。而單CPU 平臺則只能序列進行。不同的應用所
期望的垃圾回收方式也會有所不同。伺服器端應用可能希望在應用的整個執行時間
中,花在垃圾回收上的時間總數越小越好。而對於與使用者互動的應用來說,則可能
希望所垃圾回收所帶來的應用停頓的時間間隔越小越好。對於這種情況,JVM 中提
供了多種垃圾回收方法以及對應的效能調優引數,應用可以根據需要來進行定製。
Java 垃圾回收機制最基本的做法是分代回收。記憶體中的區域被劃分成不同的世代,
物件根據其存活的時間被儲存在對應世代的區域中。一般的實現是劃分成3 個世代:
年輕、年老和永久。記憶體的分配是發生在年輕世代中的。當一個物件存活時間足夠
長的時候,它就會被複制到年老世代中。對於不同的世代可以使用不同的垃圾回收
第四章Java 垃圾回收機制與引用型別
23
演算法。進行世代劃分的出發點是對應用中物件存活時間進行研究之後得出的統計規
律。一般來說,一個應用中的大部分物件的存活時間都很短。比如區域性變數的存活
時間就只在方法的執行過程中。基於這一點,對於年輕世代的垃圾回收演算法就可以
很有針對性。
年輕世代的記憶體區域被進一步劃分成伊甸園(Eden)和兩個存活區(survivor space)。
伊甸園是進行記憶體分配的地方,是一塊連續的空閒記憶體區域。在上面進行記憶體分配
速度非常快,因為不需要進行可用記憶體塊的查詢。兩個存活區中始終有一個是空白
的。在進行垃圾回收的時候,伊甸園和其中一個非空存活區中還存活的物件根據其
存活時間被複制到當前空白的存活區或年老世代中。經過這一次的複製之後,之前
非空的存活區中包含了當前還存活的物件,而伊甸園和另一個存活區中的內容已經
不再需要了,只需要簡單地把這兩個區域清空即可。下一次垃圾回收的時候,這兩
個存活區的角色就發生了交換。一般來說,年輕世代區域較小,而且大部分物件都
已經不再存活,因此在其中查詢存活物件的效率較高。
而對於年老和永久世代的記憶體區域,則採用的是不同的回收演算法,稱為“標記-清除-
壓縮(Mark-Sweep-Compact)”。標記的過程是找出當前還存活的物件,並進行標
記;清除則遍歷整個記憶體區域,找出其中需要進行回收的區域;而壓縮則把存活對
象的記憶體移動到整個記憶體區域的一端,使得另一端是一塊連續的空閒區域,方便進
行記憶體分配和複製。
JDK 5 中提供了4 種不同的垃圾回收機制。最常用的是序列回收方式,即使用單個
CPU 回收年輕和年老世代的記憶體。在回收的過程中,應用程式被暫時中止。回收方
式使用的是上面提到的最基本的分代回收。序列回收方式適合於一般的單CPU 桌面
平臺。如果是多CPU 的平臺,則適合的是並行回收方式。這種方式在對年輕世代 進
行回收的時候,會使用多個CPU 來並行處理,可以提升回收的效能。併發標記-清除
回收方式適合於對應用的響應時間要求比較 高的情況,即需要減少垃圾回收所帶來
的應用暫時中止的時間。這種做法的優點在於可以在應用執行的同時標記存活物件
與回收垃圾,而只需要暫時中止應用比較短 的時間。
通過JDK中提供的JConsole可以很容易的檢視當前應用的記憶體使用情況。在JVM啟動
的時候新增引數 -verbose:gc 可以檢視垃圾回收器的執行結果。
Java 引用型別
如果一個記憶體中的物件沒有任何引用的話,就說明這個物件已經不再被使用了,從
Java 深度歷險
24
而可以成為被垃圾回收的候選。不過由於垃圾回收器的執行時間不確定,可被垃圾
回收的物件的實際被回收時間是不確定的。對於一個物件來說,只要有引用的存在,
它就會一直存在於記憶體中。如果這樣的物件越來越多,超出了JVM中的記憶體總數,
JVM就會丟擲OutOfMemory錯誤。雖然垃圾回收的具體執行是由JVM來控制的,但
是開發人員仍然可以在一定程度上與垃圾回收器進行互動,其目的在於更好的幫助
垃圾回收器管理好應用的記憶體。這種互動方式就是使用JDK 1.2 引入的java.lang.ref
包。
強引用
在一般的Java 程式中,見到最多的就是強引用(strong reference)。如Date date = new
Date(),date 就是一個物件的強引用。物件的強引用可以在程式中到處傳遞。很多
情況下,會同時有多個引用指向同一個物件。強引用的存在限制了物件在記憶體中的
存活時間。假如物件A 中包含了一個物件B 的強引用,那麼一般情況下,物件B 的
存活時間就不會短於物件A。如果物件A 沒有顯式的把物件B 的引用設為null 的話,
就只有當物件A 被垃圾回收之後,物件B 才不再有引用指向它,才可能獲得被垃圾
回收的機會。
除了強引用之外,java.lang.ref 包中提供了對一個物件的不同的引用方式。JVM 的垃
圾回收器對於不同型別的引用有不同的處理方式。
軟引用
軟引用(soft reference)在強度上弱於強引用,通過類SoftReference來表示。它的
作用是告訴垃圾回收器,程式中的哪些物件是不那麼重要,當記憶體不足的時候是可
以被暫時回收的。當JVM中的記憶體不足的時候,垃圾回收器會釋放那 些只被軟引用
所指向的物件。如果全部釋放完這些物件之後,記憶體還不足,才會丟擲OutOfMemory
錯誤。軟引用非常適合於建立快取。當系統記憶體不足的時候,快取中的內容是可以
被釋放的。比如考慮一個影象編輯器的程式。該程式會把影象檔案的全部內容都讀
取到記憶體中,以方便進行處理。而使用者也可以同時開啟 多個檔案。當同時開啟的文
件過多的時候,就可能造成記憶體不足。如果使用軟引用來指向影象檔案內容的話,
垃圾回收器就可以在必要的時候回收掉這些記憶體。
public class ImageData {
private String path;
第四章Java 垃圾回收機制與引用型別
25
private SoftReference<byte[]> dataRef;
public ImageData(String path) {
this.path = path;
dataRef = new SoftReference<byte[]>(new byte[0]);
}
private byte[] readImage() {
return new byte[1024 * 1024]; //省略了讀取檔案的操作
}
public byte[] getData() {
byte[] dataArray = dataRef.get();
if (dataArray == null || dataArray.length == 0) {
dataArray = readImage();
dataRef = new SoftReference<byte[]>(dataArray);
}
return dataArray;
}
}
在執行上面程式的時候,可以使用 -Xmx 引數來限制JVM可用的記憶體。由於軟引用
所指向的物件可能被回收掉,在通過get方法來獲取軟引用所實際指向的物件的時
候,總是要檢查該物件是否還存活。
弱引用
弱引用(weak reference)在強度上弱於軟引用,通過類WeakReference來 表示。它
的作用是引用一個物件,但是並不阻止該物件被回收。如果使用一個強引用的話,
只要該引用存在,那麼被引用的物件是不能被回收的。弱引用則沒有這個問題。在
垃圾回收器執行的時候,如果一個物件的所有引用都是弱引用的話,該物件會被回
收。弱引用的作用在於解決強引用所帶來的物件之間在存活時間上的耦合關係。弱
引用最常見的用處是在集合類中,尤其在雜湊表中。雜湊表的介面允許使用任何Java
物件作為鍵來使用。當一個鍵值對被放入到雜湊表中之後,雜湊表 物件本身就有了
對這些鍵和值物件的引用。如果這種引用是強引用的話,那麼只要雜湊表物件本身
還存活,其中所包含的鍵和值物件是不會被回收的。如果某個存活 時間很長的雜湊
表中包含的鍵值對很多,最終就有可能消耗掉JVM中全部的記憶體。
對於這種情況的解決辦法就是使用弱引用來引用這些物件,這樣雜湊表中的鍵和值
物件都能被垃圾回收。Java中提供了WeakHashMap來滿足這一常見需求。
Java 深度歷險
26
幽靈引用
在介紹幽靈引用之前,要先介紹Java提供的物件終止化機制(finalization)。在Object
類裡面有個finalize方法,其設計的初衷是在一個物件被真正回收之前,可以用來執
行一些清理的工作。因為Java並沒有提供類似C++的解構函式一樣的機制,就通過
finalize方法來實現。但是問題在於垃圾回收器的執行時間是不固定的,所以這些清
理工作的實際執行時間也是不能預知的。幽靈引用(phantom reference)可以解決
這個問題。在建立幽靈引用PhantomReference的時候必須要指定一個引用佇列。當
一個物件的finalize方法已經被呼叫了之後,這個物件的幽靈引用會被加入到佇列中。
通過檢查該佇列裡面的內容就知道一個物件是不是已經準備要被回收了。
幽靈引用及其佇列的使用情況並不多見,主要用來實現比較精細的記憶體使用控制,
這對於移動裝置來說是很有意義的。程式可以在確定一個物件要被回收之後,再申
請記憶體建立新的物件。通過這種方式可以使得程式所消耗的記憶體維持在一個相對較
低的數量。比如下面的程式碼給出了一個緩衝區的實現示例。
public class PhantomBuffer {
private byte[] data = new byte[0];
private ReferenceQueue<byte[]> queue = new ReferenceQueue<byte[]>();
private PhantomReference<byte[]> ref = new
PhantomReference<byte[]>(data, queue);
public byte[] get(int size) {
if (size <= 0) {
throw new IllegalArgumentException("Wrong buffer size");
}
if (data.length < size) {
data = null;
System.gc(); //強制執行垃圾回收器
try {
queue.remove(); //該方法會阻塞直到佇列非空
ref.clear(); //幽靈引用不會自動清空,要手動執行
ref = null;
data = new byte[size];
ref = new PhantomReference<byte[]>(data, queue);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return data;
}
}
第四章Java 垃圾回收機制與引用型別
27
在上面的程式碼中,每次申請新的緩衝區的時候,都首先確保之前的緩衝區的位元組數
組已經被成功回收。引用佇列的remove 方法會阻塞直到新的幽靈引用被加入到隊
列中。不過需要注意的是,這種做法會導致垃圾回收器被執行的次數過多,可能會
造成程式的吞吐量過低。
引用佇列
在有些情況下,程式會需要在一個物件的可達到性發生變化的時候得到通知。比如
某個物件的強引用都已經不存在了,只剩下軟引用或是弱引用。但是還需要對引用
本身做一些的處理。典型的情景是在雜湊表中。引用物件是作為WeakHashMap中的
鍵物件的,當其引用的實際物件被垃圾回收之後,就需要把該鍵值對從雜湊表中刪
除。有了引用佇列(ReferenceQueue),就可以方便的獲取到這些弱引用物件,將它
們從表中刪除。在軟引用和弱引用物件被新增到佇列之前,其對實際物件的引用會
被自動清空。通過引用佇列的poll/remove方法就可以分別以非阻塞和阻塞的方式獲
取佇列中的引用物件。
參考資料
 Java記憶體管理白皮書
 Tuning Garbage Collection with the 5.0 Java[tm] Virtual Machine
 Plugging memory leaks with soft references
 Plugging memory leaks with weak references
Java 深度歷險
28
 5 
Java 泛型
Java泛型(generics)是JDK 5 中引入的一個新特性,允許在定義類和介面的時候使
用型別引數(type parameter)。宣告的型別引數在使用時用具體的型別來替換。泛
型最主要的應用是在JDK 5 中的新集合類框架中。對於泛型概念的引入,開發社群
的觀點是褒貶不一。 從好的方面來說,泛型的引入可以解決之前的集合類框架在
使用過程中通常會出現的執行時刻型別錯誤,因為編譯器可以在編譯時刻就發現很
多明顯的錯誤。而從不好的地方來說,為了保證與舊有版本的相容性,Java泛型的
實現上存在著一些不夠優雅的地方。當然這也是任何有歷史的程式語言所需要承擔
的歷史包袱。後續的版本更新會為早期的設計缺陷所累。
開發人員在使用泛型的時候,很容易根據自己的直覺而犯一些錯誤。比如一個方法
如果接收List<Object>作為形式引數,那麼如果嘗試將一個List<String>的物件作為實
際引數傳進去,卻發現無法通過編譯。雖然從直覺上來說,Object 是String 的父類,
這種型別轉換應該是合理的。但是實際上這會產生隱含的型別轉換問題,因此編譯
器直接就禁止這樣的行為。本文試圖對Java 泛型做一個概括性的說明。
型別擦除
正確理解泛型概念的首要前提是理解型別擦除(type erasure)。 Java中的泛型基本
上都是在編譯器這個層次來實現的。在生成的Java位元組程式碼中是不包含泛型中的類
型資訊的。使用泛型的時候加上的型別引數,會被編譯器在編譯的時候去掉。這個
過程就稱為型別擦除。如在程式碼中定義的List<Object>和List<String>等型別,在編譯
之後都會變成List。JVM看到的只是List,而由泛型附加的型別資訊對JVM來說是不可
見的。Java編譯器會在編譯時儘可能的發現可能出錯的 地方,但是仍然無法避免在
執行時刻出現型別轉換異常的情況。型別擦除也是Java的泛型實現方式與C++模板機
制實現方式之間的重要區別。
很多泛型的奇怪特性都與這個型別擦除的存在有關,包括:
 泛型類並沒有自己獨有的Class 類物件。比如並不存在List<String>.class 或是
第五章Java 泛型
29
List<Integer>.class,而只有List.class。
 靜態變數是被泛型類的所有例項所共享的。對於宣告為 MyClass<T>的類,訪問
其中的靜態變數的方法仍然是 MyClass.myStaticVar 。不管是通過new
MyClass<String>還是new MyClass<Integer>建立的物件,都是共享一個靜態變數。
 泛型的型別引數不能用在 Java 異常處理的catch 語句中。因為異常處理是由JVM
在執行時刻來進行的。由於型別資訊被擦除,JVM 是無法區分兩個異常型別
MyException<String>和MyException<Integer>的。對於JVM 來說,它們都是
MyException 型別的。也就無法執行與異常對應的catch 語句。
型別擦除的基本過程也比較簡單,首先是找到用來替換型別引數的具體類。這個具
體類一般是Object。如果指定了型別引數的上界的話,則使用這個上界。把程式碼中
的型別引數都替換成具體的類。同時去掉出現的型別宣告,即去掉<>的內容。比如
T get()方法宣告就變成了Object get();List<String>就變成了List。接下來就可能需要
生成一些橋接方法(bridge method)。這是由於擦除了型別之後的類可能缺少某些
必須的方法。比如考慮下面的程式碼:
class MyString implements Comparable<String> {
public int compareTo(String str) {
return 0;
}
}
當 類 型 信 息被擦除之後, 上述類的宣告變成了class MyString implements
Comparable。但是這樣的話,類MyString 就會有編譯錯誤,因為沒有實現介面
Comparable 宣告的int compareTo(Object)方法。這個時候就由編譯器來動態生成這個
方法。
例項分析
瞭解了型別擦除機制之後,就會明白編譯器承擔了全部的型別檢查工作。編譯器禁
止某些泛型的使用方式,正是為了確保型別的安全性。以上面提到的List<Object>
和List<String>為例來具體分析:
public void inspect(List<Object> list) {
for (Object obj : list) {
System.out.println(obj);
}
list.add(1); //這個操作在當前方法的上下文是合法的。
Java 深度歷險
30
}
public void test() {
List<String> strs = new ArrayList<String>();
inspect(strs); //編譯錯誤
}
這段程式碼中,inspect方法接受List<Object>作為引數,當在test方法中試圖傳入
List<String>的 時候,會出現編譯錯誤。假設這樣的做法是允許的,那麼在inspect方
法就可以通過list.add(1)來向集合中新增一個數字。這樣在test方法看來,其宣告為
List<String>的集合中卻被新增了一個Integer型別的物件。這顯然是違反型別安全的
原則的,在某個時候肯定會 丟擲ClassCastException。因此,編譯器禁止這樣的行為。
編譯器會盡可能的檢查可能存在的型別安全問題。對於確定是違反相關原則的地方,
會給出編譯錯誤。當編譯器無法判斷型別的使用是否正確的時候,會給出警告資訊。
萬用字元與上下界
在使用泛型類的時候,既可以指定一個具體的型別,如List<String>就宣告瞭具體的
型別是String;也可以用萬用字元?來表示未知型別,如List<?>就宣告瞭List 中包含的
元素型別是未知的。 萬用字元所代表的其實是一組型別,但具體的型別是未知的。
List<?>所宣告的就是所有型別都是可以的。但是List<?>並不等同於List<Object>。
List<Object>實際上確定了List 中包含的是Object 及其子類,在使用的時候都可以通
過Object 來進行引用。而List<?>則其中所包含的元素型別是不確定。其中可能包含
的是String,也可能是 Integer。如果它包含了String 的話,往裡面新增Integer 型別
的元素就是錯誤的。正因為型別未知,就不能通過new ArrayList<?>()的方法來建立
一個新的ArrayList 物件。因為編譯器無法知道具體的型別是什麼。但是對於 List<?>
中的元素確總是可以用Object 來引用的,因為雖然型別未知,但肯定是Object 及其
子類。考慮下面的程式碼:
public void wildcard(List<?> list) {
list.add(1);//編譯錯誤
}
如上所示,試圖對一個帶萬用字元的泛型類進行操作的時候,總是會出現編譯錯誤。
其原因在於萬用字元所表示的型別是未知的。
因為對於List<?>中的元素只能用Object 來引用,在有些情況下不是很方便。在這些
情況下,可以使用上下界來限制未知型別的範圍。如List<? extends Number>說明List
中可能包含的元素型別是Number 及其子類。而List<? super Number>則說明List 中
第五章Java 泛型
31
包含的是Number 及其父類。當引入了上界之後,在使用型別的時候就可以使用上
界類中定義的方法。比如訪問 List<? extends Number>的時候,就可以使用Number
類的intValue 等方法。
型別系統
在Java中,大家比較熟悉的是通過繼承機制而產生的型別體系結構。比如String繼承
自Object。根據Liskov替換原則,子類是可以替換父類的。當需要Object類的引用的
時候,如果傳入一個String物件是沒有任何問題的。但是反過來的話,即用父類的引
用替換子類引用 的時候,就需要進行強制型別轉換。編譯器並不能保證執行時刻這
種轉換一定是合法的。這種自動的子類替換父類的型別轉換機制,對於陣列也是適
用的。String[]可以替換Object[]。但是泛型的引入,對於這個型別系統產生了一定的
影響。正如前面提到的List<String>是 不能替換掉List<Object>的。
引入泛型之後的型別系統增加了兩個維度:一個是型別引數自身的繼承體系結構,
另外一個是泛型類或介面自身的繼承體系結構。第一個指的是對於 List<String>和
List<Object>這樣的情況,型別引數String 是繼承自Object 的。而第二種指的是 List
介面繼承自Collection 介面。對於這個型別系統,有如下的一些規則:
 相同型別 引數的泛型類的關係取決於泛型類自身的繼承體系結構。即
List<String> 是 Collection<String> 的子型別, List<String> 可以替換
Collection<String>。這種情況也適用於帶有上下界的型別宣告。
 當泛型類的型別宣告中使用了萬用字元的時候,其子型別可以在兩個維度上分別
展開。如對Collection<? extends Number>來說,其子型別可以在Collection 這個
維度上展開,即List<? extends Number>和Set<? extends Number>等;也可以在
Number 這個層次上展開,即Collection<Double>和 Collection<Integer>等。如此
迴圈下去,ArrayList<Long>和 HashSet<Double>等也都算是Collection<? extends
Number>的子型別。
 如果泛型類中包含多個型別引數,則對於每個型別引數分別應用上面的規則。
理解了上面的規則之後,就可以很容易的修正例項分析中給出的程式碼了。只需要把
List<Object>改成List<?>即可。List<String>是List<?>的子型別,因此傳遞引數時不會
發生錯誤。
Java 深度歷險
32
開發自己的泛型類
泛型類與一般的Java 類基本相同,只是在類和介面定義上多出來了用<>宣告的型別
引數。一個類可以有多個型別引數,如 MyClass<X, Y, Z>。 每個型別引數在宣告的
時候可以指定上界。所宣告的型別引數在Java 類中可以像一般的型別一樣作為方法
的引數和返回值,或是作為域和區域性變數的型別。但是由於型別擦除機制,型別參
數並不能用來建立物件或是作為靜態變數的型別。考慮下面的泛型類中的正確和錯
誤的用法。
class ClassTest<X extends Number, Y, Z> {
private X x;
private static Y y; //編譯錯誤,不能用在靜態變數中
public X getFirst() {
//正確用法
return x;
}
public void wrong() {
Z z = new Z(); //編譯錯誤,不能建立物件
}
}
最佳實踐
在使用泛型的時候可以遵循一些基本的原則,從而避免一些常見的問題。
 在程式碼中避免泛型類和原始型別的混用。比如 List<String>和List 不應該共同使
用。這樣會產生一些編譯器警告和潛在的執行時異常。當需要利用JDK 5 之前開
發的遺留程式碼,而不得不這麼做時,也儘可能的隔離相關的程式碼。
 在使用帶萬用字元的泛型類的時候,需要明確萬用字元所代表的一組型別的概念。
由於具體的型別是未知的,很多操作是不允許的。
 泛型類最好不要同陣列一塊使用。你只能建立new List<?>[10]這樣的陣列,無法
建立new List<String>[10]這樣的。這限制了陣列的使用能力,而且會帶來很多費
解的問題。因此,當需要類似陣列的功能時候,使用集合類即可。
 不要忽視編譯器給出的警告資訊。
第五章Java 泛型
33
參考資料
 Generics gotchas
 Java Generics FAQs
 Generics in Java Programming Language
Java 深度歷險
34
 6 
Java 註解
在開發Java程式,尤其是Java EE應用的時候,總是免不了與各種配置檔案打交道。
以Java EE中典型的S(pring)S(truts)H(ibernate)架構來說,Spring、Struts和Hibernate這
三個框架都有自己的XML格式的配置檔案。這些配置檔案需要與Java原始碼儲存同
步,否則的話就可能出現錯誤。而且這些錯誤有可能到了執行時刻才被發現。把同
一份資訊儲存在兩個地方,總是個壞的主意。理想的情況是在一個地方維護這些信
息就好了。其它部分所需的資訊則通過自動的方式來生成。JDK 5 中引入了原始碼中
的註解(annotation)這一機制。註解使得Java原始碼中不但可以包含功能性的實現
程式碼,還可以新增後設資料。註解的功能類似於程式碼中的註釋,所不同的是註解不是
提供程式碼功能的說明,而是實現程式功能的重要組成部分。Java註解已經在很多框
架中得到了廣泛的使用,用來簡化程式中的配置。
使用註解
在一般的Java開發中,最常接觸到的可能就是@Override和@SupressWarnings這 兩
個註解了。使用@Override的時候只需要一個簡單的宣告即可。這種稱為標記註解
(marker annotation ),它的出現就代表了某種配置語義。而其它的註解是可以有
自己的配置引數的。配置引數以名值對的方式出現。使用@SupressWarnings的時候
需要類似@SupressWarnings({"uncheck", "unused"})這樣的語法。在括號裡面的是該注
解可供配置的值。由於這個註解只有一個配置引數,該引數的名稱預設為value,並
且可以省略。而花括號則表示是陣列型別。在JPA中的@Table註解使用類似
@Table(name = "Customer", schema = "APP")這樣的語法。從這裡可以看到名值對的
用法。在使用註解時候的配置引數的值必須是編譯時刻的常量。
從某種角度來說,可以把註解看成是一個XML 元素,該元素可以有不同的預定義的
屬性。而屬性的值是可以在宣告該元素的時候自行指定的。在程式碼中使用註解,就
相當於把一部分後設資料從XML 檔案移到了程式碼本身之中,在一個地方管理和維護。
Java 深度歷險
35
開發註解
在一般的開發中,只需要通過閱讀相關的API 文件來了解每個註解的配置引數的含
義,並在程式碼中正確使用即可。在有些情況下,可能會需要開發自己的註解。這在
庫的開發中比較常見。註解的定義有點類似介面。下面的程式碼給出了一個簡單的描
述程式碼分工安排的註解。通過該註解可以在原始碼中記錄每個類或介面的分工和進
度情況。
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Assignment {
String assignee();
int effort();
double finished() default 0;
}
@interface用來宣告一個註解,其中的每一個方法實際上是宣告瞭一個配置引數。方
法的名稱就是引數的名稱,返回值型別就是引數的型別。可以通過default來宣告參
數的預設值。在這裡可以看到@Retention和@Target這樣的元註解,用來宣告註解
本身的行為。@Retention用來宣告註解的保留策略,有CLASS、RUNTIME和SOURCE
這三種,分別表示註解儲存在類檔案、JVM執行時刻和原始碼中。只有當宣告為
RUNTIME的時候,才能夠在執行時刻通過反射API來獲取到註解的資訊。@Target用
來宣告註解可以被新增在哪些型別的元素上,如型別、方法和域等。
處理註解
在程式中新增的註解,可以在編譯時刻或是執行時刻來進行處理。在編譯時刻處理
的時候,是分成多趟來進行的。如果在某趟處理中產生了新的Java原始檔,那麼就
需要另外一趟處理來處理新生成的原始檔。如此往復,直到沒有新檔案被生成為止。
在完成處理之後,再對Java程式碼進行編譯。JDK 5 中提供了apt工具用來對註解進行
處理。apt是一個命令列工具,與之配套的還有一套用來描述程式語義結構的Mirror
API。Mirror API(com.sun.mirror.*)描述的是程式在編譯時刻的靜態結構。通過Mirror
API可以獲取到被註解的Java型別元素的資訊,從而提供相應的處理邏輯。具體的處
理工作交給apt工具來完成。編寫註解處理器的核心是AnnotationProcessorFactory
和AnnotationProcessor兩個介面。後者表示的是註解處理器,而前者則是為某些注
解型別建立註解處理器的工廠。
以上面的註解Assignment 為例,當每個開發人員都在原始碼中更新進度的話,就可
第六章Java 註解
36
以通過一個註解處理器來生成一個專案整體進度的報告。 首先是註解處理器工廠的
實現。
public class AssignmentApf implements AnnotationProcessorFactory {
public AnnotationProcessor
getProcessorFor(Set<AnnotationTypeDeclaration> atds,?
AnnotationProcessorEnvironment env) {
if (atds.isEmpty()) {
return AnnotationProcessors.NO_OP;
}
return new AssignmentAp(env); //返回註解處理器
}
public Collection<String> supportedAnnotationTypes() {
return
Collections.unmodifiableList(Arrays.asList("annotation.Assignment"))
;
}
public Collection<String> supportedOptions() {
return Collections.emptySet();
}
}
AnnotationProcessorFactory介面有三個方法:getProcessorFor是根據註解的型別來返
回特定的註解處理 器;supportedAnnotationTypes是返回該工廠生成的註解處理器
所能支援的註解型別;supportedOptions用來表示所支 持的附加選項。在執行apt
命令列工具的時候,可以通過-A來傳遞額外的引數給註解處理器,如-Averbose=true。
當工廠通過 supportedOptions方法宣告瞭所能識別的附加選項之後,註解處理器就
可以在執行時刻通過AnnotationProcessorEnvironment的getOptions方法獲取到選項
的實際值。註解處理器本身的基本實現如下所示。
public class AssignmentAp implements AnnotationProcessor {
private AnnotationProcessorEnvironment env;
private AnnotationTypeDeclaration assignmentDeclaration;
public AssignmentAp(AnnotationProcessorEnvironment env) {
this.env = env;
assignmentDeclaration = (AnnotationTypeDeclaration)
env.getTypeDeclaration("annotation.Assignment");
}
public void process() {
Collection<Declaration> declarations =
env.getDeclarationsAnnotatedWith(assignmentDeclaration);
for (Declaration declaration : declarations) {
processAssignmentAnnotations(declaration);
}
}
private void processAssignmentAnnotations(Declaration declaration)
Java 深度歷險
37
{
Collection<AnnotationMirror> annotations =
declaration.getAnnotationMirrors();
for (AnnotationMirror mirror : annotations) {
if
(mirror.getAnnotationType().getDeclaration().equals(assignmentDeclar
ation)) {
Map<AnnotationTypeElementDeclaration, AnnotationValue>
values = mirror.getElementValues();
String assignee = (String) getAnnotationValue(values,
"assignee"); //獲取註解的值
}
}
}
}
註解處理器的處理邏輯都在process方法中完成。通過一個宣告(Declaration)的
getAnnotationMirrors方法就可以獲取到該宣告上所新增的註解的實際值。得到這些
值之後,處理起來就不難了。
在建立好註解處理器之後,就可以通過apt 命令列工具來對原始碼中的註解進行處
理。 命令的執行格式是apt -classpath bin -factory annotation.apt.AssignmentApf
src/annotation/work/*.java,即通過-factory 來指定註解處理器工廠類的名稱。實際
上,apt 工具在完成處理之後,會自 動呼叫javac 來編譯處理完成後的原始碼。
JDK 5 中的apt工具的不足之處在於它是Oracle提供的私有實現。在JDK 6 中,通過JSR
269 把自定義註解處理器這一功能進行了規範化, 有了新
的javax.annotation.processing這個新的API。對Mirror API也進行了更新,形成了新
的javax.lang.model包。註解處理器的使用也進行了簡化,不需要再單獨執行apt這樣
的命令列工具,Java編譯器本身就可以完成對註解的處理。對於同樣的功能,如果
用JSR 269 的做法,只需要一個類就可以了。
@SupportedSourceVersion(SourceVersion.RELEASE_6)
@SupportedAnnotationTypes("annotation.Assignment")
public class AssignmentProcess extends AbstractProcessor {
private TypeElement assignmentElement;
public synchronized void init(ProcessingEnvironment processingEnv)
{
super.init(processingEnv);
Elements elementUtils = processingEnv.getElementUtils();
assignmentElement =
elementUtils.getTypeElement("annotation.Assignment");
}
public boolean process(Set<? extends TypeElement> annotations,
第六章Java 註解
38
RoundEnvironment roundEnv) {
Set<? extends Element> elements =
roundEnv.getElementsAnnotatedWith(assignmentElement);
for (Element element : elements) {
processAssignment(element);
}
}
private void processAssignment(Element element) {
List<? extends AnnotationMirror> annotations =
element.getAnnotationMirrors();
for (AnnotationMirror mirror : annotations) {
if
(mirror.getAnnotationType().asElement().equals(assignmentElement)) {
Map<? extends ExecutableElement, ? extends
AnnotationValue>
values = mirror.getElementValues();
String assignee = (String) getAnnotationValue(values,
"assignee"); //獲取註解的值
}
}
}
}
仔細比較上面兩段程式碼,可以發現它們的基本結構是類似的。不同之處在於JDK 6
中通過元註解@SupportedAnnotationTypes來宣告所支援的註解型別。另外描述程式
靜態結構的javax.lang.model包使用了不同的型別名稱。使用的時候也更加簡單,只
需要通過javac -processor annotation.pap.AssignmentProcess Demo1.java這樣的方式
即可。
上面介紹的這兩種做法都是在編譯時刻進行處理的。而有些時候則需要在執行時刻
來完成對註解的處理。這個時候就需要用到Java的反射API。反射API提供了在執行時
刻讀取註解資訊的支援。不過前提是註解的保留策略宣告的是執行時。Java反射API
的AnnotatedElement介面提供了獲取類、方法和域上的註解的實用方法。比如獲取
到一個Class類物件之後,通過getAnnotation方法就可以獲取到該類上新增的指定注
解型別的註解。
例項分析
下面通過一個具體的例項來分析說明在實踐中如何來使用和處理註解。假定有一個
公司的僱員資訊系統,從訪問控制的角度出發,對僱員的工資的更新只能由具有特
定角色的使用者才能完成。考慮到訪問控制需求的普遍性,可以定義一個註解來讓開
Java 深度歷險
39
發人員方便的在程式碼中宣告訪問控制許可權。
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RequiredRoles {
String[] value();
}
下一步則是如何對註解進行處理,這裡使用的Java的反射API並結合動態代理。下
面是動態代理中的InvocationHandler介面的實現。
public class AccessInvocationHandler<T> implements InvocationHandler {
final T accessObj;
public AccessInvocationHandler(T accessObj) {
this.accessObj = accessObj;
}
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable {
RequiredRoles annotation =
method.getAnnotation(RequiredRoles.class); //通過反射API獲取註解
if (annotation != null) {
String[] roles = annotation.value();
String role = AccessControl.getCurrentRole();
if (!Arrays.asList(roles).contains(role)) {
throw new AccessControlException("The user is not allowed
to invoke this method.");
}
}
return method.invoke(accessObj, args);
}
}
在 具 體 使 用 的 時 候 , 首 先 要 通 過 Proxy.newProxyInstance 方法建立一個
EmployeeGateway 的介面的代理類,使用該代理類來完成實際的操作。
參考資料
 JDK 5和JDK 6中的apt工具說明文件
 Pluggable Annotation Processing API
 APT: Compile-Time Annotation Processing with Java
Java 深度歷險
40
 7 
Java 反射與動態代理
在上一篇文章中介紹Java註解的時候,多次提到了Java的反射API。與javax.lang.model
不同的是,通過反射API可以獲取程式在執行時刻的內部結構。反射API中提供的動
態代理也是非常強大的功能,可以原生實現AOP中的方法攔截功能。正如英文單詞
reflection的含義一樣,使用反射API的時候就好像在看一個Java類在水中的倒影一樣。
知道了Java類的內部結構之後,就可以與它進行互動,包括建立新的物件和呼叫對
象中的方法等。這種互動方式與直接在原始碼中使用的效果是相同的,但是又額外
提供了執行時刻的靈活性。使用反射的一個最大的弊端是效能比較差。相同的操作,
用反射API所需的時間大概比直接的使用要慢一兩個數量級。不過現在的JVM實現
中,反射操作的效能已經有了很大的提升。在靈活性與效能之間,總是需要進行權
衡的。應用可以在適當的時機來使用反射API。
基本用法
Java反射API的第一個主要作用是獲取程式在執行時刻的內部結構。這對於程式的檢
查工具和偵錯程式來說,是非常實用的功能。只需要短短的十幾行程式碼,就可以遍歷
出來一個Java類的內部結構,包括其中的構造方法、宣告的域和定義的方法等。這
不得不說是一個很強大的能力。只要有了java.lang.Class類 的物件,就可以通過其中
的方法來獲取到該類中的構造方法、域和方法。對應的方法分別
是getConstructor、getField和getMethod。這三個方法還有相應的getDeclaredXXX版
本,區別在於getDeclaredXXX版本的方法只會獲取該類自身所宣告的元素,而不會考
慮繼承下來的。Constructor、Field和Method這三個類分別表示類中的構造方法、域
和方法。這些類中的方法可以獲取到所對應結構的後設資料。
反射API 的另外一個作用是在執行時刻對一個Java 物件進行操作。這些操作包括動
態建立一個Java 類的物件,獲取某個域的值以及呼叫某個方法。在Java 原始碼中編
寫的對類和物件的操作,都可以在執行時刻通過反射API 來實現。考慮下面一個簡
單的Java 類。
class MyClass {
Java 深度歷險
41
public int count;
public MyClass(int start) {
count = start;
} public void increase(int step) {
count = count + step;
}
}
使用一般做法和反射API 都非常簡單。
MyClass myClass = new MyClass(0); //一般做法
myClass.increase(2);
System.out.println("Normal -> " + myClass.count);
try {
//獲取構造方法
Constructor constructor = MyClass.class.getConstructor(int.class);
//建立物件
MyClass myClassReflect = constructor.newInstance(10);
//獲取方法
Method method = MyClass.class.getMethod("increase", int.class);
//呼叫方法
method.invoke(myClassReflect, 5);
//獲取域
Field field = MyClass.class.getField("count");
//獲取域的值
System.out.println("Reflect -> " + field.getInt(myClassReflect));
} catch (Exception e) {
e.printStackTrace();
}
由於陣列的特殊性,Array類提供了一系列的靜態方法用來建立陣列和對陣列中的元
素進行訪問和操作。
Object array = Array.newInstance(String.class, 10); // 等價於new
String[10]
Array.set(array, 0, "Hello"); //等價於array[0] = "Hello"
Array.set(array, 1, "World"); //等價於array[1] = "World"
System.out.println(Array.get(array, 0)); //等價於array[0]
使用Java反射API的時候可以繞過Java預設的訪問控制檢查,比如可以直接獲取到對
象的私有域的值或是呼叫私有方法。只需要在獲取到Constructor、Field和Method類
的物件之後,呼叫setAccessible方法並設為true即可。有了這種機制,就可以很方便
第七章Java 反射與動態代理
42
的在執行時刻獲取到程式的內部狀態。
處理泛型
Java 5 中引入了泛型的概念之後,Java反射API也做了相應的修改,以提供對泛型的
支援。由於型別擦除機制的存在,泛型類中的型別引數等資訊,在執行時刻是不存
在的。JVM看到的都是原始型別。對此,Java 5 對Java類檔案的格式做了修訂, 添
加了Signature屬性,用來包含不在JVM型別系統中的型別資訊。比如以java.util.List
介面為例, 在其類檔案中的 Signature 屬性的宣告是
<E:Ljava/lang/Object;>Ljava/lang/Object;Ljava/util /Collection<TE;>;; ,這就說明List接
口有一個型別引數E。在執行時刻,JVM會讀取Signature屬性的內容並提供給反射API
來使用。
比如在程式碼中宣告瞭一個域是List<String>型別的,雖然在執行時刻其型別會變成原
始型別List,但是仍然可以通過反射來獲取到所用的實際的型別引數。
Field field = Pair.class.getDeclaredField("myList"); //myList的型別是
List
Type type = field.getGenericType();
if (type instanceof ParameterizedType) {
ParameterizedType paramType = (ParameterizedType) type;
Type[] actualTypes = paramType.getActualTypeArguments();
for (Type aType : actualTypes) {
if (aType instanceof Class) {
Class clz = (Class) aType;
System.out.println(clz.getName()); //輸出java.lang.String
}
}
}
動態代理
熟悉設計模式的人對於代理模式可 能都不陌生。 代理物件和被代理物件一般實現
相同的介面,呼叫者與代理物件進行互動。代理的存在對於呼叫者來說是透明的,
呼叫者看到的只是介面。代理物件則可以封裝一些內部的處理邏輯,如訪問控制、
遠端通訊、日誌、快取等。比如一個物件訪問代理就可以在普通的訪問機制之上添
加快取的支援。這種模式在RMI和EJB中 都得到了廣泛的使用。傳統的代理模式的
實現,需要在原始碼中新增一些附加的類。這些類一般是手寫或是通過工具來自動
生成。JDK 5 引入的動態代理機制,允許開發人員在執行時刻動態的建立出代理類及
Java 深度歷險
43
其物件。在執行時刻,可以動態建立出一個實現了多個介面的代理類。每個代理類
的物件都會關聯一個表示內部處理邏輯的InvocationHandler介面的實現。當使用者
呼叫了代理物件所代理的介面中的方法的時候,這個呼叫的資訊會被傳遞給
InvocationHandler的invoke方法。在 invoke方法的引數中可以獲取到代理物件、方法
對應的Method物件和呼叫的實際引數。invoke方法的返回值被返回給使用者。這種
做法實際上相當於對方法呼叫進行了攔截。熟悉AOP的人對這種使用模式應該不陌
生。但是這種方式不需要依賴AspectJ等AOP框架。
下面的程式碼用來代理一個實現了List 介面的物件。所實現的功能也非常簡單,那就
是禁止使用List 介面中的add 方法。如果在getList 中傳入一個實現List 介面的物件,
那麼返回的實際就是一個代理物件,嘗試在該物件上呼叫add 方法就會丟擲來異常。
public List getList(final List list) {
return (List)
Proxy.newProxyInstance(DummyProxy.class.getClassLoader(), new Class[]
{ List.class },
new InvocationHandler() {
public Object invoke(Object proxy, Method method, Object[]
args) throws Throwable {
if ("add".equals(method.getName())) {
throw new UnsupportedOperationException();
}
else {
return method.invoke(list, args);
}
}
});
}
這裡的實際流程是,當代理物件的add 方法被呼叫的時候,InvocationHandler 中的
invoke 方法會被呼叫。引數method 就包含了呼叫的基本資訊。因為方法名稱是add,
所以會丟擲相關的異常。如果呼叫的是其它方法的話,則執行原來的邏輯。
使用案例
Java 反射API 的存在,為Java 語言新增了一定程度上的動態性,可以實現某些動態
語言中的功能。比如在JavaScript 的程式碼中,可以通過 obj["set" + propName]()來根
據變數propName 的值找到對應的方法進行呼叫。雖然在Java 原始碼中不能這麼寫,
但是通過反射API 同樣可以實現類似的功能。這對於處理某些遺留程式碼來說是有幫
助的。比如所需要使用的類有多個版本,每個版本所提供的方法名稱和引數不盡相
同。而呼叫程式碼又必須與這些不同的版本都能協同工作,就可以通過反射API 來依
第七章Java 反射與動態代理
44
次檢查實際的類中是否包含某個方法來選擇性的呼叫。
Java 反射API 實際上定義了一種相對於編譯時刻而言更加鬆散的契約。如果被呼叫
的Java 物件中並不包含某個方法,而在呼叫者程式碼中進行引用的話,在編譯時刻就
會出現錯誤。而反射API 則可以把這樣的檢查推遲到執行時刻來完成。通過把Java
中的位元組程式碼增強、類載入器和反射API 結合起來,可以處理一些對靈 活性要求很
高的場景。
在有些情況下,可能會需要從遠端載入一個Java 類來執行。比如一個客戶端Java
程式可以通過網路從伺服器端下載Java 類來執行,從而可以實現自動更新的機制。
當程式碼邏輯需要更新的時候,只需要部署一個新的Java 類到伺服器端即可。一般的
做法是通過自定義類載入器下載了類位元組程式碼之後,定義出 Class 類的物件,再通
過newInstance 方法就可以建立出例項了。不過這種做法要求客戶端和伺服器端都
具有某個介面的定義,從伺服器端下載的是這個介面的實現。這樣的話才能在客戶
端進行所需的型別轉換,並通過介面來使用這個物件例項。如果希望客戶端和服務
器端採用更加鬆散的契約的話,使用反射API 就可以了。兩者之間的契約只需要在
方法的名稱和引數這個級別就足夠了。伺服器端Java 類並不需要實現特定的介面,
可以是一般的Java 類。
動態代理的使用場景就更加廣泛了。需要使用AOP中的方法攔截功能的地方都可以
用到動態代理。Spring框架的AOP實現預設也使用動態代理。不過JDK中的動態代理
只支援對介面的代理,不能對一個普通的Java類提供代理。不過這種實現在大部分
的時候已經夠用了。

參考資料
Classworking toolkit: Reflecting generics
 Decorating with dynamic proxies
Java 深度歷險
45
 8 
Java I/O
在應用程式中,通常會涉及到兩種型別的計算:CPU 計算和I/O 計算。對於大多數
應用來說,花費在等待I/O 上的時間是佔較大比重的。通常需要等待速度較慢的磁
盤或是網路連線完成I/O 請求,才能繼續後面的CPU 計算任務。因此提高I/O 操作
的效率對應用的效能有較大的幫助。本文將介紹Java 語言中與I /O 操作相關的內容,
包括基本的Java I/O 和Java NIO,著重於基本概念和最佳實踐。

Java語言提供了多個層次不同的概念來對I/O操作進行抽象。Java I/O中最早的概念是
流,包括輸入流和輸出流,早在JDK 1.0 中就存在了。簡單的來說,流是一個連續的
位元組的序列。輸入流是用來讀取這個序列,而輸出流則構建這個序列。InputStream
和OutputStream所操縱的基本單元就是位元組。每次讀取和寫入單個位元組或是位元組數
組。如果從位元組的層次來處理資料型別的話,操作會非常繁瑣。可以用更易使用的
流實現來包裝基本的位元組流。如果想讀取或輸出Java的基本資料型別,可以使
用DataInputStream和DataOutputStream。它們所提供的類似readFloat和writeDouble
這樣的方法,會讓處理基本資料型別變得很簡單。如果希望讀取或寫入的是Java中
的物件的話,可以使用ObjectInputStream和ObjectOutputStream。它們與物件的序
列化機制一起,可以實現Java物件狀態的持久化和資料傳遞。基本流所提供的對於
輸入和輸出的控制比較弱。InputStream只提供了順序讀取、跳過部分位元組和標記/
重置的支援,而OutputStream則只能順序輸出。
流的使用
由於I/O操作所對應的實體在系統中都是有限的資源,需要妥善的進行管理。每個打
開的流都需要被正確的關閉以釋放資源。所遵循的原則是誰開啟誰釋放。如果一個
流只在某個方法體內使用,則通過finally語句或是JDK 7 中的try-with-resources語句
來確保在方法返回之前,流被正確的關閉。如果一個方法只是作為流的使用者,就
不需要考慮流的關閉問題。典型的情況是在servlet 實現中並不需要關閉
Java 深度歷險
46
HttpServletResponse中的輸出流。如果你的程式碼需要負責開啟一個流,並且需要在不
同的物件之間進行傳遞的話,可以考慮使用Execute Around Method模式。如下面的
程式碼所示:
public void use(StreamUser user) {
InputStream input = null;
try {
input = open();
user.use(input);
} catch(IOException e) {
user.onError(e);
} finally {
if (input != null) {
try {
input.close();
} catch (IOException e) {
user.onError(e);
}
}
}
}
如上述程式碼中所看到的一樣,由專門的類負責流的開啟和關閉。流的使用者
StreamUser 並不需要關心資源釋放的細節,只需要對流進行操作即可。
在使用輸入流的過程中,經常會遇到需要複用一個輸入流的情況,即多次讀取一個
輸入流中的內容。比如通過URL.openConnection方法開啟了一 個遠端站點連線的輸
入流,希望對其中的內容進行多次處理。這就需要把一個InputStream物件在多個對
象中傳遞。為了保證每個使用流的物件都能獲取到正確的內容,需要對流進行一定
的處理。通常有兩種解決的辦法,一種是利用InputStream的標記支援。如果一個流
支援標記的話(通過markSupported方法判斷),就可以在流開始的地方通過mark方
法新增一個標記,當完成一次對流的使用之後,通過reset方法就可以把流的讀取位
置重置到上次標記的位置,即流開始的地方。如此反覆,就可以複用這個輸入流。
大部分輸入流的實現是不支援標記的。可以通過BufferedInputStream進行包裝來支
持標記。
private InputStream prepareStream(InputStream ins) {
BufferedInputStream buffered = new BufferedInputStream(ins);
buffered.mark(Integer.MAX_VALUE);
return buffered;
}
private void resetStream(InputStream ins) throws IOException {
ins.reset();
第八章Java I/O
47
ins.mark(Integer.MAX_VALUE);
}
如上面的程式碼所示,通過 prepareStream 方法可以用一個BufferedInputStream 來包
裝基本的InputStream。通過 mark 方法在流開始的時候新增一個標記,允許讀入
Integer.MAX_VALUE 個位元組。每次流使用完成之後,通過resetStream 方法重置即可。
另外一種做法是把輸入流的內容轉換成位元組陣列,進而轉換成輸入流的另外一個實
現ByteArrayInputStream。這樣做的好處是使用位元組陣列作為引數傳遞的格式要比輸
入流簡單很多,可以不需要考慮資源相關的問題。另外也可以儘早的關閉原始的輸
入流,而無需等待所有使用流的操作完成。這兩種做法的思路其實是相似的。
BufferedInputStream在內部也建立了一個位元組陣列來儲存從原始輸入流中讀入的內
容。
private byte[] saveStream(InputStream input) throws IOException {
ByteBuffer buffer = ByteBuffer.allocate(1024);
ReadableByteChannel readChannel = Channels.newChannel(input);
ByteArrayOutputStream output = new ByteArrayOutputStream(32 * 1024);
WritableByteChannel writeChannel = Channels.newChannel(output);
while ((readChannel.read(buffer)) > 0 || buffer.position() != 0) {
buffer.flip();
writeChannel.write(buffer);
buffer.compact();
}
return output.toByteArray();
}
上面的程式碼中 saveStream 方法把一個InputStream 儲存為位元組陣列。
緩衝區
由於流背後的資料有可能比較大,在實際的操作中,通常會使用緩衝區來提高效能。
傳統的緩衝區的實現是使用陣列來完成。比如經典的從InputStream到OutputStream
的複製的實現,就是使用一個位元組陣列作為中間的緩衝區。NIO中引入的Buffer類
及其子類,可以很方便的用來建立各種基本資料型別的緩衝區。相對於陣列而言,
Buffer類及其子類提供了更加豐富的方法來對其中的資料進行操作。後面會提到的通
道也使用Buffer類進行資料傳遞。
在Buffer上進行的元素新增和刪除操作,都圍繞3 個屬性position、limit和capacity展
開,分別表示Buffer當前的讀寫位置、可用的讀寫範圍和容量限制。容量限制是在創
建的時候指定的。Buffer提供的get/put方法都有相對和絕對兩種形式。相對讀寫時
Java 深度歷險
48
的位置是相對於position的值,而絕對讀寫則需要指定起始的序號。在使用Buffer的
常見錯誤就是在讀寫操作時沒有考慮到這3 個元素的值,因為大多數時候都是使用
的是相對讀寫操作,而position的值可能早就發生了變化。一些應該注意的地方包括:
將資料讀入緩衝區之前, 需要呼叫clear方法;將緩衝區中的資料輸出之前,需要
呼叫flip方法。
ByteBuffer buffer = ByteBuffer.allocate(32);
CharBuffer charBuffer = buffer.asCharBuffer();
String content = charBuffer.put("Hello
").put("World").flip().toString();
System.out.println(content);
上面的程式碼展示了 Buffer 子類的使用。首先可以在已有的ByteBuffer 上面建立出其
它資料型別的緩衝區檢視,其次Buffer 子類的很多方法是可以級聯的,最後是要注
意flip 方法的使用。
字元與編碼
在程式中,總是免不了與字元打交道,畢竟字元是使用者直接可見的資訊。而與字元
處理直接相關的就是編碼。相信不少人都曾經為了程式中的亂碼問題而困擾。要弄
清楚這個問題,就需要理解字符集和編碼的概念。字符集,顧名思義,就是字元的
集合。一個字符集中所包含的字元通常與地區和語言有關。字符集中的每個字元通
常會有一個整數編碼與其對應。常見的字符集有ASCII、ISO-8859-1 和Unicode 等。
對於字符集中的每個字元,為了在計算機中表示,都需要轉換某種位元組的序列,即
該字元的編碼。同一個字符集可以有不同的編碼方式。如果某種編碼格式產生的字
節序列,用另外一種編碼格式來解碼的話,就可能會得到錯誤的字元,從而產生亂
碼的情況。所以將一個位元組序列轉換成字串的時候,需要知道正確的編碼格式。
NIO中的java.nio.charset包提供了與字符集相關的類,可以用來進行編碼和解碼。其
中的CharsetEncoder和CharsetDecoder允許對編碼和解碼過程進行精細的控制,如處
理非法的輸入以及字符集中無法識別的字元等。通過這兩個類可以實現字元內容的
過濾。比如應用程式在設計的時候就只支援某種字符集,如果使用者輸入了其它字元
集中的內容,在介面顯示的時候就是亂碼。對於這種情況,可以在解碼的時候忽略
掉無法識別的內容。
tring input = "你123好";
Charset charset = Charset.forName("ISO-8859-1");
CharsetEncoder encoder = charset.newEncoder();
encoder.onUnmappableCharacter(CodingErrorAction.IGNORE);
第八章Java I/O
49
CharsetDecoder decoder = charset.newDecoder();
CharBuffer buffer = CharBuffer.allocate(32);
buffer.put(input);
buffer.flip();
try {
ByteBuffer byteBuffer = encoder.encode(buffer);
CharBuffer cbuf = decoder.decode(byteBuffer);
System.out.println(cbuf); //輸出123
} catch (CharacterCodingException e) {
e.printStackTrace();
}
上面的程式碼中,通過使用 ISO-8859-1 字符集的編碼和解碼器,就可以過濾掉字串
中不在此字符集中的字元。
Java I/O在處理位元組流字之外,還提供了處理字元流的類,即Reader/Writer類及其子
類,它們所操縱的基本單位是char型別。在位元組和字元之間的橋樑就是編碼格式。
通過編碼器來完成這兩者之間的轉換。在建立 Reader/Writer子類例項的時候,總是
應該使用兩個引數的構造方法,即顯式指定使用的字符集或編碼解碼器。如果不顯
式指定,使用的是JVM的預設字符集,有可能在其它平臺上產生錯誤。
通道
通道作為NIO 中的核心概念,在設計上比之前的流要好不少。通道相關的很多實現
都是介面而不是抽象類。通道本身的抽象層次也更加合理。通道表示的是對支援I/O
操作的實體的一個連線。一旦通道被開啟之後,就可以執行讀取和寫入操作,而不
需要像流那樣由輸入流或輸出流來分別進行處理。與流相比,通道的操作使用的是
Buffer 而不是陣列,使用更加方便靈活。通道的引入提升了I/O 操作的靈活性和性
能,主要體現在檔案操作和網路操作上。
檔案通道
對檔案操作方面,檔案通道FileChannel提供了與其它通道之間高效傳輸資料的能力,
比傳統的基於流和位元組陣列作為緩衝區的做法,要來得簡單和快速。比如下面的把
一個網頁的內容儲存到本地檔案的實現。
FileOutputStream output = new FileOutputStream("baidu.txt");
FileChannel channel = output.getChannel();
URL url = new URL("http://www.baidu.com");
InputStream input = url.openStream();
ReadableByteChannel readChannel = Channels.newChannel(input);
Java 深度歷險
50
channel.transferFrom(readChannel, 0, Integer.MAX_VALUE);
檔案通道的另外一個功能是對檔案的部分片段進行加鎖。當在一個檔案上的某個片
段加上了排它鎖之後,其它程式必須等待這個鎖釋放之後,才能訪問該檔案的這個
片段。檔案通道上的鎖是由JVM 所持有的,因此適合於與其它應用程式協同時使用。
比如當多個應用程式共享某個配置檔案的時候,如果Java 程式需要更新此檔案,則
可以首先獲取該檔案上的一個排它鎖,接著進行更新操作,再釋放鎖即可。這樣可
以保證檔案更新過程中不會受到其它程式的影響。
另外一個在效能方面有很大提升的功能是記憶體對映檔案的支援。通過FileChannel
的map方法可以建立出一個MappedByteBuffer物件,對這個緩衝區的操作都會直接
反映到檔案內容上。這點尤其適合對大檔案進行讀寫操作。
套接字通道
在套接字通道方面的改進是提供了對非阻塞I/O和多路複用I/O的支援。傳統的流的
I/O操作是阻塞式的。在進行I/O操作的時候,執行緒會處於阻塞狀態等待操作完成。
NIO 中引入了非阻塞I/O 的支援, 不過只限於套接字I/O 操作。所有繼承
自SelectableChannel的通道類都可以通過configureBlocking方法來設定是否採用非阻
塞模式。在非阻塞模式下,程式可以在適當的時候查詢是否有資料可供讀取。一般
是通過定期的輪詢來實現的。
多路複用I/O是一種新的I/O程式設計模型。傳統的套接字伺服器的處理方式是對於每一
個客戶端套接字連線,都新建立一個執行緒來進行處理。建立執行緒是很耗時的操作,
而有的實現會採用執行緒池。不過一個請求一個執行緒的處理模型並不是很理想。原因
在於耗費時間建立的執行緒,在大部分時間可能處於等待的狀態。而多路複用 I/O的
基本做法是由一個執行緒來管理多個套接字連線。該執行緒會負責根據連線的狀態,來
進行相應的處理。多路複用I/O依靠作業系統提供的select或相似系統呼叫的支援,
選擇那些已經就緒的套接字連線來處理。可以把多個非阻塞I/O通道註冊在某
個Selector上,並宣告所感興趣的操作型別。每次呼叫Selector的select方法,就可以
選擇到某些感興趣的操作已經就緒的通道的集合,從而可以進行相應的處理。如果
要執行的處理比較複雜,可以把處理轉發給其它的執行緒來執行。
下面是一個簡單的使用多路複用I/O 的伺服器實現。當有客戶端連線上的時候,服
務器會返回一個Hello World 作為響應。
第八章Java I/O
51
private static class IOWorker implements Runnable {
public void run() {
try {
Selector selector = Selector.open();
ServerSocketChannel channel = ServerSocketChannel.open();
channel.configureBlocking(false);
ServerSocket socket = channel.socket();
socket.bind(new InetSocketAddress("localhost", 10800));
channel.register(selector, channel.validOps());
while (true) {
selector.select();
Iterator iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (!key.isValid()) {
continue;
}
if (key.isAcceptable()) {
ServerSocketChannel ssc = (ServerSocketChannel)
key.channel();
SocketChannel sc = ssc.accept();
sc.configureBlocking(false);
sc.register(selector, sc.validOps());
}
if (key.isWritable()) {
SocketChannel client = (SocketChannel)
key.channel();
Charset charset = Charset.forName("UTF-8");
CharsetEncoder encoder = charset.newEncoder();
CharBuffer charBuffer = CharBuffer.allocate(32);
charBuffer.put("Hello World");
charBuffer.flip();
ByteBuffer content = encoder.encode(charBuffer);
client.write(content);
key.cancel();
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
上面的程式碼給出的只是非常簡單的示例程式,只是展示了多路複用I/O的基本使用方
式。在開發複雜網路應用程式的時候,使用一些Java NIO網路應用框架會讓你事半功
Java 深度歷險
52
倍。目前來說最流行的兩個框架是Apache MINA和Netty。在使用了Netty之後,Twitter
的搜尋功能速度提升達到了3 倍之多。網路應用開發人員都可以使用這兩個開源
的優秀框架。
參考資料
 Java 6 I/O-related APIs & Developer Guides
 Top Ten New Things You Can Do with NIO
 Building Highly Scalable Servers with Java NIO
Java 深度歷險
53
 9 
Java 安全
安全性是Java應用程式的非功能性需求的重要組成部分,如同其它的非功能性需求
一樣,安全性很容易被開發人員所忽略。當然,對於Java EE的開發人員來說,安全
性的話題可能沒那麼陌生,使用者認證和授權可能是絕大部分Web應用都有的功能。
類似Spring Security這樣的框架,也使得開發變得更加簡單。本文並不會討論Web應
用的安全性,而是介紹Java安全一些底層和基本的內容。
認證
使用者認證是應用安全性的重要組成部分,其目的是確保應用的使用者具有合法的身
份。 Java安全中使用術語主體(Subject)來表示訪問請求的來源。一個主體可以是
任何的實體。一個主體可以有多個不同的身份標識(Principal)。比如一個應用的用
戶這類主體,就可以有使用者名稱、身份證號碼和手機號碼等多種身份標識。除了身份
標識之外,一個主體還可以有公開或是私有的安全相關的憑證(Credential),包括
密碼和金鑰等。
典型的使用者認證過程是通過登入操作來完成的。在登入成功之後,一個主體中就具
備了相應的身份標識。Java提供了一個可擴充套件的登入框架,使得應用開發人員可以
很容易的定製和擴充套件與登入相關的邏輯。登入的過程由LoginContext啟動。在建立
LoginContext的時候需要指定一個登入配置(Configuration)的名稱。該登入配置中
包含了登入所需的多個LoginModule的資訊。每個LoginModule實現了一種登入方式。
當呼叫LoginContext的login方法的時候,所配置的每個LoginModule會被呼叫來執行
登入操作。如果整個登入過程成功,則通過getSubject方法就可以獲取到包含了身
份標識資訊的主體。開發人員可以實現自己的LoginModule來定製不同的登入邏輯。
每個LoginModule的登入方式由兩個階段組成。第一個階段是在login方法的實現中。
這個階段用來進行必要的身份認證,可能需要獲取使用者的輸入,以及通過資料庫、
網路操作或其它方式來完成認證。當認證成功之後,把必要的資訊儲存起來。如果
認證失敗,則丟擲相關的異常。第二階段是在commit或abort方 法中。由於一個登
第九章Java 安全
54
錄過程可能涉及到多個LoginModule。LoginContext會根據每個LoginModule的認證結
果以及相關的配置資訊 來確定本次登入是否成功。LoginContext用來判斷的依據是
每個LoginModule對整個登入過程的必要性,分成必需、必要、充分和可選這四種情
況。如果登入成功,則每個LoginModule的commit方法會被呼叫,用來把身份標識關
聯到主體上。如果登入失敗,則LoginModule 的abort方法會被呼叫,用來清除之前
儲存的認證相關資訊。
在LoginModule 進行認證的過程中, 如果需要獲取使用者的輸入, 可以通
過CallbackHandler和對應的Callback來完成。每個Callback可以用來進行必要的資料
傳遞。典型的啟動登入的過程如下:
public Subject login() throws LoginException {
TextInputCallbackHandler callbackHandler = new
TextInputCallbackHandler();
LoginContext lc = new LoginContext("SmsApp", callbackHandler);
lc.login();
return lc.getSubject();
}
這裡的 SmsApp 是登入配置的名稱,可以在配置檔案中找到。該配置檔案的內容也
很簡單。
SmsApp {
security.login.SmsLoginModule required;
};
這裡宣告瞭使用 security.login.SmsLoginModule 這個登入模組,而且該模組是必需的。
配置檔案可以通過啟動程式時的引數java.security.auth.login.config 來指定,或修改
JVM 的預設設定。下面看看SmsLoginModule 的核心方法login 和commit。
public boolean login() throws LoginException {
TextInputCallback phoneInputCallback = new TextInputCallback("Phone
number: ");
TextInputCallback smsInputCallback = new TextInputCallback("Code:
");
try {
handler.handle(new Callback[] {phoneInputCallback,
smsInputCallback});
} catch (Exception e) {
throw new LoginException(e.getMessage());
}
String code = smsInputCallback.getText();
boolean isValid = code.length() > 3; //此處只是簡單的進行驗證。
if (isValid) {
phoneNumber = phoneInputCallback.getText();
Java 深度歷險
55
}
return isValid;
}
public boolean commit() throws LoginException {
if (phoneNumber != null) {
subject.getPrincipals().add(new PhonePrincipal(phoneNumber));
return true;
}
return false;
}
這裡使用了兩個TextInputCallback來獲取使用者的輸入。當使用者輸入的編碼有效的時
候,就把相關的資訊記錄下來,此處是使用者的手機號碼。在commit方法中,就把該
手機號碼作為使用者的身份標識與主體關聯起來。
許可權控制
在驗證了訪問請求來源的合法身份之後,另一項工作是驗證其是否具有相應的許可權。
許可權由Permission及其子類來表示。每個許可權都有一個名稱,該名稱的含義與許可權
型別相關。某些許可權有與之對應的動作列表。比較典型的是檔案操作權
限FilePermission,它的名稱是檔案的路徑,而它的動作列表則包括讀取、寫入和執
行等。Permission類中最重要的是implies方法,它定義了許可權之間的包含關係,是
進行驗證的基礎。
許可權控制包括管理和驗證兩個部分。管理指的是定義應用中的許可權控制策略,而驗
證指的則是在執行時刻根據策略來判斷某次請求是否合法。策略可以與主體關聯,
也可以沒有關聯。策略由Policy來表示,JDK提供了基於檔案儲存的基本實現。開發
人員也可以提供自己的實現。在應用執行過程中,只可能有一個Policy處於生效的狀
態。驗證部分的具體執行者是AccessController,其中的checkPermission方法用來驗
證給定的許可權是否被允許。在應用中執行相關的訪問請求之前,都需要呼叫
checkPermission 方法來進行驗證。如果驗證失敗的話, 該方法會拋
出AccessControlException異常。JVM中內建提供了一些對訪問關鍵部分內容的訪問
控制檢查,不過只有在啟動應用的時通過引數-Djava.security.manager啟用了安全管
理器之後才能生效,並與策略相配合。
與訪問控制相關的另外一個概念是特權動作。特權動作只關心動作本身所要求的權
限是否具備,而並不關心呼叫者是誰。比如一個寫入檔案的特權動作,它只要求對
該檔案有寫入許可權即可,並不關心是誰要求它執行這樣的動作。特權動作根據是否
第九章Java 安全
56
丟擲受檢異常,分為PrivilegedAction和PrivilegedExceptionAction。這兩個介面都只
有一個run 方法用來執行相關的動作, 也可以向呼叫者返回結果。通過
AccessController的doPrivileged方法就可以執行特權動作。
Java安全使用了保護域的概念。每個保護域都包含一組類、身份標識和許可權,其意
義是在當訪問請求的來源是這些身份標識的時候,這些類的例項就自動具有給定的
這些許可權。保護域的許可權既可以是固定, 也可以根據策略來動態變
化。ProtectionDomain類用來表示保護域,它的兩個構造方法分別用來支援靜態和動
態的許可權。一般來說,應用程式通常會涉及到系統保護域和應用保護域。不少的方
法呼叫可能會跨越多個保護域的邊界。因此,在AccessController進行訪問控制驗證
的時候,需要考慮當前操作的呼叫上下文,主要指的是方法呼叫棧上不同方法所屬
於 的不同保護域。這個呼叫上下文一般是與當前執行緒繫結在一起的。通過
AccessController 的getContext 方法可以獲取到表示呼叫上下文
的AccessControlContext物件,相當於訪問控制驗證所需的呼叫棧的一個快照。在有
些情況下,會需要傳遞此物件以方便在其它執行緒中進行訪問控制驗證。
考慮下面的許可權驗證程式碼:
Subject subject = new Subject();
ViewerPrincipal principal = new ViewerPrincipal("Alex");
subject.getPrincipals().add(principal);
Subject.doAsPrivileged(subject, new PrivilegedAction<Object>() {
public Object run() {
new Viewer().view();
return null;
}
}, null);
這裡建立了一個新的 Subject 物件並關聯上身份標識。通常來說,這個過程是由登
錄操作來完成的。通過Subject 的doAsPrivileged 方法就可以執行一個特權動作。
Viewer 物件的view 方法會使用AccessController 來檢查是否具有相應的許可權。策略
配置檔案的內容也比較簡單,在啟動程式的時候通過引數java.security.auth.policy 指
定檔案路徑即可。
grant Principal security.access.ViewerPrincipal "Alex" {
permission security.access.ViewPermission "CONFIDENTIAL";
};//這裡把名稱為CONFIDENTIAL的ViewPermission授權給了身份標識為Alex的主體。
Java 深度歷險
57
加密、解密與簽名
構建安全的Java應用離不開加密和解密。Java的密碼框架採用了常見的服務提供者架
構,以提供所需的可擴充套件性和互操作性。該密碼框架提供了一系列常用的服務,包
括加密、數字簽名和報文摘要等。這些服務都有服務提供者介面(SPI),服務的實
現者只需要實現這些介面,並註冊到密碼框架中即可。比如加密服務Cipher的SPI
介面就是CipherSpi。每個服務都可以有不同的演算法來實現。密碼框架也提供了相應
的工廠方法用來獲取到服務的例項。比如想使用採用MD5 演算法的報文摘要服務,只
需要呼叫MessageDigest.getInstance("MD5")即可。
加密和解密過程中並不可少的就是金鑰(Key)。加密演算法一般分成對稱和非對稱兩
種。對稱加密演算法使用同一個金鑰進行加密和解密;而非對稱加密演算法使用一對
公鑰和私鑰,一個加密的時候,另外一個就用來解密。不同的加密演算法,有不同的
金鑰。對稱加密演算法使用的是SecretKey,而非對稱加密演算法則使用PublicKey
和PrivateKey。與金鑰Key對應的另一個介面是KeySpec,用來描述不同演算法的金鑰
的具體內容。比如一個典型的使用對稱加密的方式如下:
KeyGenerator generator = KeyGenerator.getInstance("DES");
SecretKey key = generator.generateKey();
saveFile("key.data", key.getEncoded());
Cipher cipher = Cipher.getInstance("DES");
cipher.init(Cipher.ENCRYPT_MODE, key);
String text = "Hello World";
byte[] encrypted = cipher.doFinal(text.getBytes());
saveFile("encrypted.bin", encrypted);
加密的時候首先要生成一個金鑰,再由Cipher 服務來完成。可以把金鑰的內容儲存
起來,方便傳遞給需要解密的程式。
byte[] keyData = getData("key.data");
SecretKeySpec keySpec = new SecretKeySpec(keyData, "DES");
Cipher cipher = Cipher.getInstance("DES");
cipher.init(Cipher.DECRYPT_MODE, keySpec);
byte[] data = getData("encrypted.bin");
byte[] result = cipher.doFinal(data);
解密的時候先從儲存的檔案中得到金鑰編碼之後的內容,再通過SecretKeySpec獲取
到金鑰本身的內容,再進行解密。
報文摘要的目的在於防止資訊被有意或無意的修改。通過對原始資料應用某些算
法,可以得到一個校驗碼。當收到資料之後,只需要應用同樣的演算法,再比較校驗
第九章Java 安全
58
碼是否一致,就可以判斷資料是否被修改過。相對原始資料來說,校驗碼長度更小,
更容易進行比較。訊息認證碼(Message Authentication Code)與報文摘要類似,不
同的是計算的過程中加入了金鑰,只有掌握了金鑰的接收者才能驗證資料的完整性。
使用公鑰和私鑰就可以實現數字簽名的功能。某個傳送者使用私鑰對訊息進行加密,
接收者使用公鑰進行解密。由於私鑰只有傳送者知道,當接收者使用公鑰解密成功
之後,就可以判定訊息的來源肯定是特定的傳送者。這就相當於傳送者對訊息進行
了簽名。數字簽名由Signature服務提供,簽名和驗證的過程都比較直接。
Signature signature = Signature.getInstance("SHA1withDSA");
KeyPairGenerator keyGenerator = KeyPairGenerator.getInstance("DSA");
KeyPair keyPair = keyGenerator.generateKeyPair();
PrivateKey privateKey = keyPair.getPrivate();
signature.initSign(privateKey);
byte[] data = "Hello World".getBytes();
signature.update(data);
byte[] signatureData = signature.sign(); //得到簽名
PublicKey publicKey = keyPair.getPublic();
signature.initVerify(publicKey);
signature.update(data);
boolean result = signature.verify(signatureData); //進行驗證
驗證數字簽名使用的公鑰可以通過檔案或證照的方式來進行釋出。
安全套接字連線
在各種資料傳輸方式中,網路傳輸目前使用較廣,但是安全隱患也更多。安全套接
字連線指的是對套接字連線進行加密。加密的時候可以選擇對稱加密演算法。但是如
何在傳送者和接收者之間安全的共享金鑰,是個很麻煩的問題。如果再用加密演算法
來加密金鑰,則成為了一個迴圈問題。非對稱加密演算法則適合於這種情況。私鑰自
己保管,公鑰則公開出去。傳送資料的時候,用私鑰加密,接收者用公開的公鑰解
密;接收資料的時候,則正好相反。這種做法解決了共享金鑰的問題,但是另外的
一個問題是如何確保接收者所得到的公鑰確實來自所宣告的傳送者,而不是偽造的。
為此,又引入了證照的概念。證照中包含了身份標識和對應的公鑰。證照由使用者所
信任的機構簽發,並用該機構的私鑰來加密。在有些情況下,某個證照籤發機構的
真實性會需要由另外一個機構的證照來證明。通過這種證明關係,會形成一個證照
的鏈條。而鏈條的根則是公認的值得信任的機構。只有當證照鏈條上的所有證照都
被信任的時候,才能信任證照中所給出的公鑰。
Java 深度歷險
59
日常開發中比較常接觸的就是HTTPS,即安全的HTTP連線。大部分用Java程式訪問
採用HTTPS網站時出現的錯誤都與證照鏈條相關。有些網站採用的不是由正規安全
機構簽發的證照,或是證照已經過期。如果必須訪問這樣的HTTPS網站的話,可以
提供自己的套接字工廠和主機名驗證類來繞過去。另外一種做法是通過keytool工具
把證照匯入到系統的信任證照庫之中。
URL url = new URL("https://localhost:8443");
SSLContext context = SSLContext.getInstance("TLS");
context.init(new KeyManager[] {}, new TrustManager[] {new
MyTrustManager()}, new SecureRandom());HttpsURLConnection connection =
(HttpsURLConnection) url.openConnection();
connection.setSSLSocketFactory(context.getSocketFactory());
connection.setHostnameVerifier(new MyHostnameVerifier());
這裡的MyTrustManager實現了X509TrustManager介面,但是所有方法都是預設實現。
而MyHostnameVerifier實現了HostnameVerifier介面,其中的verify方法總是返回true。
參考資料
 Java安全體系結構、Java密碼框架(JCA)參考指南、Java認證和授權服務(JAAS)
參考指南、Java安全套接字擴充套件(JSSE)參考指南
Java 深度歷險
60
 10 
Java 物件序列化與RMI
對於一個存在於Java虛擬機器中的物件來說,其內部的狀態只保持在記憶體中。JVM停止
之後,這些狀態就丟失了。在很多情況下,物件的內部狀態是需要被持久化下來的。
提到持久化,最直接的做法是儲存到檔案系統或是資料庫之中。這種做法一般涉及
到自定義儲存格式以及繁瑣的資料轉換。物件關係對映(Object-relational mapping)
是一種典型的用關聯式資料庫來持久化物件的方式,也存在很多直接儲存物件的物件
資料庫。物件序列化機制(object serialization)是Java語言內建的一種物件持久化
方式,可以很容易的在JVM中的活動物件和位元組陣列(流)之間進行轉換。除了可
以很簡單 的實現持久化之外,序列化機制的另外一個重要用途是在遠端方法呼叫
中,用來對開發人員遮蔽底層實現細節。
基本的物件序列化
由於Java提供了良好的預設支援,實現基本的物件序列化是件比較簡單的事。待序
列化的Java類只需要實現Serializable介面即可。Serializable僅是一個標記介面,並不
包含任何需要實現的具體方法。實現該介面只是為了宣告該Java類的物件是可以被
序列化的。實際的序列化和反序列化工作是通過ObjectOuputStream
和ObjectInputStream來完成的。ObjectOutputStream的writeObject方法可以把一個
Java物件寫入到流中,ObjectInputStream的readObject方 法可以從流中讀取一個Java
物件。在寫入和讀取的時候,雖然用的引數或返回值是單個物件,但實際上操縱的
是一個物件圖,包括該物件所引用的其它物件,以 及這些物件所引用的另外的物件。
Java會自動幫你遍歷物件圖並逐個序列化。除了物件之外,Java中的基本型別和陣列
也是可以通過 ObjectOutputStream和ObjectInputStream來序列化的。
try {
User user = new User("Alex", "Cheng");
ObjectOutputStream output = new ObjectOutputStream(new
FileOutputStream("user.bin"));
output.writeObject(user);
output.close();
} catch (IOException e) {
第十章Java 物件序列化與RMI
61
e.printStackTrace();
}
try {
ObjectInputStream input = new ObjectInputStream(new
FileInputStream("user.bin"));
User user = (User) input.readObject();
System.out.println(user);
} catch (Exception e) {
e.printStackTrace();
}
上面的程式碼給出了典型的把 Java 物件序列化之後儲存到磁碟上,以及從磁碟上讀取
的基本方式。User 類只是宣告瞭實現Serializable 介面。
在預設的序列化實現中,Java物件中的非靜態和非瞬時域都會被包括進來,而與域
的可見性宣告沒有關係。這可能會導致某些不應該出現的域被包含在序列化之後的
位元組陣列中,比如密碼等隱私資訊。由於Java物件序列化之後的格式是固定的,其
它人可以很容易的從中分析出其中的各種資訊。對於這種情況,一種解決辦法是把
域宣告為瞬時的, 即使用transient 關 鍵詞。另外一種做法是新增一個
serialPersistentFields? 域來宣告序列化時要包含的域。從這裡可以看到在Java序列化
機制中的這種僅在書面層次上定義的契約。宣告序列化的域必須使用固定的名稱和
型別。在後面還可以看到其它類似這樣的契約。雖然Serializable只是一個標記介面,
但它其實是包含有不少隱含的要求。下面的程式碼給出了 serialPersistentFields的宣告
示例,即只有firstName這個域是要被序列化的。
private static final ObjectStreamField[] serialPersistentFields = {
new ObjectStreamField("firstName", String.class)
};
自定義物件序列化
基本的物件序列化機制讓開發人員可以在包含哪些域上進行定製。如果想對序列化
的過程進行更加細粒度的控制,就需要在類中新增writeObject 和對應的 readObject
方法。這兩個方法屬於前面提到的序列化機制的隱含契約的一部分。在通過
ObjectOutputStream 的 writeObject 方法寫入物件的時候,如果這個物件的類中定義
了writeObject 方法,就會呼叫該方法,並把當前 ObjectOutputStream 物件作為參
數傳遞進去。writeObject 方法中一般會包含自定義的序列化邏輯,比如在寫入之前
修改域的值,或是寫入額外的資料等。對於writeObject 中新增的邏輯,在對應的
readObject 中都需要反轉過來,與之對應。
Java 深度歷險
62
在新增自己的邏輯之前,推薦的做法是先呼叫Java的預設實現。在writeObject方法中
通過ObjectOutputStream的defaultWriteObject來完成,在readObject方法則通過
ObjectInputStream的defaultReadObject來實現。下面的程式碼在物件的序列化流中寫
入了一個額外的字串。
private void writeObject(ObjectOutputStream output) throws IOException
{
output.defaultWriteObject();
output.writeUTF("Hello World");
}
private void readObject(ObjectInputStream input) throws IOException,
ClassNotFoundException {
input.defaultReadObject();
String value = input.readUTF();
System.out.println(value);
}
序列化時的物件替換
在有些情況下,可能會希望在序列化的時候使用另外一個物件來代替當前物件。其
中的動機可能是當前物件中包含了一些不希望被序列化的域,比如這些域都是從另
外一個域派生而來的;也可能是希望隱藏實際的類層次結構;還有可能是新增自定
義的物件管理邏輯,如保證某個類在JVM中只有一個例項。相對於把無關的域都設
成transient來說,使用物件替換是一個更好的選擇,提供了更多的靈活性。替換對
象的作用類似於Java EE中會使用到的傳輸物件(Transfer Object)。
考慮下面的例子,一個訂單系統中需要把訂單的相關資訊序列化之後,通過網路來
傳輸。訂單類Order 引用了客戶類Customer。在預設序列化的情況下,Order 類對
象被序列化的時候,其引用的Customer 類物件也會被序列化,這可能會造成使用者信
息的洩露。對於這種情況,可以建立一個另外的物件來在序列化的時候替換當前的
Order 類的物件,並把使用者資訊隱藏起來。
private static class OrderReplace implements Serializable {
private static final long serialVersionUID = 4654546423735192613L;
private String orderId;
public OrderReplace(Order order) {
this.orderId = order.getId();
}
private Object readResolve() throws ObjectStreamException {
//根據orderId查詢Order物件並返回
}
}
第十章Java 物件序列化與RMI
63
這個替換物件類OrderReplace 只儲存了Order 的ID。在Order 類的writeReplace 方
法中返回了一個OrderReplace 物件。這個物件會被作為替代寫入到流中。同樣的,
需要在OrderReplace 類中定義一個readResolve 方法,用來在讀取的時候再轉換回
Order 類物件。這樣對呼叫者來說,替換物件的存在就是透明的。
private Object writeReplace() throws ObjectStreamException {
return new OrderReplace(this);
}
序列化與物件建立
在通過ObjectInputStream 的readObject 方法讀取到一個物件之後,這個物件是一個
新的例項,但是其構造方法是沒有被呼叫的,其中的域的初始化程式碼也沒有被執行。
對於那些沒有被序列化的域,在新建立出來的物件中的值都是預設的。也就是說,
這個物件從某種角度上來說是不完備的。這有可能會造成一些隱含的錯誤。呼叫者
並不知道物件是通過一般的new 操作符來建立的,還是通過反序列化所得到的。解
決的辦法就是在類的readObject 方法裡面,再執行所需的物件初始化邏輯。對於一
般的Java 類來說,構造方法中包含了初始化的邏輯。可以把這些邏輯提取到一個方
法中,在readObject 方法中呼叫此方法。
版本更新
把一個Java物件序列化之後,所得到的位元組陣列一般會儲存在磁碟或資料庫之中。
在儲存完成之後,有可能原來的Java類有了更新,比如新增了額外的域。這個時候
從相容性的角度出發,要求仍然能夠讀取舊版本的序列化資料。在讀取的過程中,
當ObjectInputStream發現一個物件的定義的時候,會嘗試在當前JVM中查詢其Java類
定義。這個查詢過程不能僅根據Java類的全名來判斷,因為當前JVM中可能存在名稱
相同,但是含義完全不同的 Java 類。這個對應關係是通過一個全域性惟一識別符號
serialVersionUID來實現的。通過在實現了Serializable介面的類中定義該域,就宣告瞭
該Java類的一個惟一的序列化版本號。JVM會比對從位元組陣列中得出的類的版本號,
與JVM中查詢到的類的版本號是否一致,來決定兩個類是否是相容的。對於開發人
員來說,需要記得的就是在實現了Serializable介面的類中定義這樣的一個域,並在
版本更新過程中保持該值不變。當然,如果不希望 維持這種向後相容性,換一個版
本號即可。該域的值一般是綜合Java類的各個特性而計算出來的一個雜湊值,可以
通過Java提供的serialver命令來生成。在Eclipse中,如果Java類實現了Serializable介面,
Eclipse會提示並幫你生成這個serialVersionUID。
Java 深度歷險
64
在類版本更新的過程中,某些操作會破壞向後相容性。如果希望維持這種向後相容
性,就需要格外的注意。一般來說,在新的版本中新增東西不會產生什麼問題,而
去掉一些域則是不行的。
序列化安全性
前面提到,Java物件序列化之後的內容格式是公開的。所以可以很容易的從中提取
出各種資訊。從實現的角度來說,可以從不同的層次來加強序列化的安全性。
對序列化之後的流進行加密。這可以通過CipherOutputStream來實現。
實現自己的writeObject 和readObject 方法,在呼叫defaultWriteObject 之前,先對
要序列化的域的值進行加密處理。
使用一個SignedObject 或SealedObject 來封裝當前物件, 用SignedObject 或
SealedObject進行序列化。
在從流中進行反序列化的時候,可以通過ObjectInputStream的registerValidation方法
新增ObjectInputValidation介面的實現,用來驗證反序列化之後得到的物件是否合
法。
RMI
RMI(Remote Method Invocation)是Java中的遠端過程呼叫(Remote Procedure Call,
RPC)實現,是一種分散式Java應用的實現方式。它的目的在於對開發人員遮蔽橫跨
不同JVM和網路連線等細節,使得分佈在不同JVM上的對 象像是存在於一個統一的
JVM中一樣,可以很方便的互相通訊。之所以在介紹物件序列化之後來介紹RMI,主
要是因為物件序列化機制使得RMI非常簡單。呼叫一個遠端伺服器上的方法並不是
一件困難的事情。開發人員可以基於Apache MINA或是Netty這樣的框架來寫自己的
網路伺服器,亦或是可以採用REST架構風格來 編寫HTTP服務。但這些解決方案中,
不可迴避的一個部分就是資料的編排和解排(marshal/unmarshal)。需要在Java物件
和傳輸格式之間進行互相轉換,而且這一部分邏輯是開發人員無法迴避的。RMI的
優勢在於依靠Java序列化機制,對開發人員遮蔽了資料編排和解排的細節,要做的
事情非常少。JDK 5 之後,RMI通過動態代理機制去掉了早期版本中需要通過工具進
行程式碼生成的繁瑣方式,使用起來更加簡單。
RMI採用的是典型的客戶端-伺服器端架構。首先需要定義的是伺服器端的遠端介面,
第十章Java 物件序列化與RMI
65
這一步是設計好伺服器端需要提供什麼樣的服務。對遠端介面的要求很簡單,只需
要繼承自RMI中的Remote介面即可。Remote和Serializable一樣,也是標記介面。遠
程介面中的方法需要丟擲RemoteException。定義好遠端介面之後,實現該介面即可。
如下面的Calculator是一個簡單的遠端介面。
public interface Calculator extends Remote {
String calculate(String expr) throws RemoteException;
}
實現了遠端介面的類的例項稱為遠端物件。建立出遠端物件之後,需要把它註冊到
一個登錄檔之中。這是為了客戶端能夠找到該遠端物件並呼叫。
public class CalculatorServer implements Calculator {
public String calculate(String expr) throws RemoteException {
return expr;
}
public void start() throws RemoteException, AlreadyBoundException {
Calculator stub = (Calculator)
UnicastRemoteObject.exportObject(this, 0);
Registry registry = LocateRegistry.getRegistry();
registry.rebind("Calculator", stub);
}
}
CalculatorServer是遠端物件的Java類。在它的start方法中通過UnicastRemoteObject
的exportObject把當前物件暴露出來,使得它可以接收來自客戶端的呼叫請求。再
通過Registry的rebind方法進行註冊,使得客戶端可以查詢到。
客戶端的實現就是首先從登錄檔中查詢到遠端介面的實現物件,再呼叫相應的方法
即可。實際的呼叫雖然是在伺服器端完成的,但是在客戶端看來,這個介面中的方
法就好像是在當前JVM 中一樣。這就是RMI 的強大之處。
public class CalculatorClient {
public void calculate(String expr) {
try {
Registry registry = LocateRegistry.getRegistry("localhost");
Calculator calculator = (Calculator)
registry.lookup("Calculator");
String result = calculator.calculate(expr);
System.out.println(result);
} catch (Exception e) {
e.printStackTrace();
}
}
}
Java 深度歷險
66
在執行的時候,需要首先通過rmiregistry 命令來啟動RMI 中用到的登錄檔伺服器。
為了通過Java的序列化機制來進行傳輸,遠端介面中的方法的引數和返回值,要麼
是Java的基本型別,要麼是遠端物件,要麼是實現了 Serializable介面的Java類。當客
戶端通過RMI登錄檔找到一個遠端介面的時候,所得到的其實是遠端介面的一個動
態代理物件。當客戶端呼叫 其中的方法的時候,方法的引數物件會在序列化之後,
傳輸到伺服器端。伺服器端接收到之後,進行反序列化得到引數物件。並使用這些
引數物件,在伺服器端呼叫 實際的方法。呼叫的返回值Java物件經過序列化之後,
再傳送回客戶端。客戶端再經過反序列化之後得到Java物件,返回給呼叫者。這中
間的序列化過程對於使用者來說是透明的,由動態代理物件自動完成。除了序列化
之外,RMI還使用了動態類載入技術。當需要進行反序列化的時候,如果該物件的
類定義在當前JVM中沒有找到,RMI會嘗試從遠端下載所需的類檔案定義。可以在
RMI程式啟動的時候,通過JVM引數java.rmi.server.codebase來指定動態下載Java類文
件的URL。

相關文章