有兩個安全性問題:初始化和清理。
1、利用構造器保證初始化
如果一個類有構造器,那麼 Java 會在用戶使用對象之前(即對象剛創建完成)自動調用對象的構造器方法,從而保證初始化。
如何命名構造器方法?存在兩個問題:
- 任何命名都可能與類中其他已有元素的命名衝突
- 編譯器必須始終知道構造器方法名稱,從而調用它
所以構造器名稱與類名相同
2、方法重載
方法是行爲的命名,你通過名字指代所有的對象,屬性和方法。
相同的詞可以表達多種不同的含義——它們被"重載"了
構造器必須使用方法重載
如果兩個方法命名相同,Java是怎麼知道你調用的是哪個呢?有一條簡單的規則:每個被重載的方法必須有獨一無二的參數列表。
爲什麼只能通過類名和參數列表,不能通過方法的返回值區分方法呢?
你可以調用一個方法且忽略返回值。這叫做調用一個函數的副作用,因爲你不在乎返回值,只是想利用方法做些事。因爲這個原因,所以你不能根據返回值類型區分重載的方法。爲了支持新特性,Java 8 在一些具體情形下提高了猜測的準確度,但是通常來說並不起作用。
3、無參構造器
一旦你顯式地定義了構造器(無論有參還是無參),編譯器就不會自動爲你創建無參構造器。
4、this
this 關鍵字只能在非靜態方法內部使用。當你調用一個對象的方法時,this 生成了一個對象引用。你可以像對待其他引用一樣對待這個引用。如果你在一個類的方法裏調用其他該類中的方法,不要使用 this,直接調用即可,this 自動地應用於其他方法上了。
- this 關鍵字只用在一些必須顯式使用當前對象引用的特殊場合。例如,用在 return 語句中返回對當前對象的引用。
- this 關鍵字在向其他方法傳遞當前對象時也很有用
- 當你在一個類中寫了多個構造器,有時你想在一個構造器中調用另一個構造器來避免代碼重複。你通過 this 關鍵字實現這樣的調用。只能通過 this 調用一次構造器。
- static 方法中不會存在 this。靜態方法是爲類而創建的,不需要任何對象。你不能在靜態方法中調用非靜態方法(反之可以)。一個類中的靜態方法可以被其他的靜態方法和靜態屬性訪問。
5、垃圾回收器
用完一個對象就不管它並非總是安全的。
現在考慮一種特殊情況:你創建的對象不是通過 new 來分配內存的,而垃圾回收器只知道如何釋放用 new 創建的對象的內存,所以它不知道如何回收不是 new 分配的內存。爲了處理這種情況,Java 允許在類中定義一個名爲 finalize() 的方法。當垃圾回收器準備回收對象的內存時,首先會調用其 finalize() 方法,並在下一輪的垃圾回收動作發生時,纔會真正回收對象佔用的內存。 所以如果你打算使用 finalize() ,就能在垃圾回收時做一些重要的清理工作。
在 Java 中,對象並非總是被垃圾回收。
- 對象可能不被垃圾回收。
- 垃圾回收不等同於析構。
- 垃圾回收只與內存有關。垃圾回收的唯一原因就是爲了回收程序不再使用的內存。所以對於與垃圾回收有關的任何行爲來說(尤其是 finalize() 方法),它們也必須同內存及其回收有關。
看起來之所以有 finalize() 方法,是因爲在分配內存時可能採用了類似 C 語言中的做法,而非 Java 中的通常做法。這種情況主要發生在使用"本地方法"的情況下,本地方法是一種用 Java 語言調用非 Java 語言代碼的形式(關於本地方法的討論,見本書電子版第2版的附錄B)。本地方法目前只支持 C 和 C++,但是它們可以調用其他語言寫的代碼,所以實際上可以調用任何代碼。在非 Java 代碼中,也許會調用 C 的 malloc() 函數系列來分配存儲空間,而且除非調用 free() 函數,不然存儲空間永遠得不到釋放,造成內存泄露。但是,free() 是 C 和 C++ 中的函數,所以你需要在 finalize() 方法裏用本地方法調用它。
垃圾回收器的存在並不能完全替代析構函數(而且絕對不能直接調用 finalize(),所以這也不是一種解決方案)。如果希望進行除釋放存儲空間之外的清理工作,還是得明確調用某個恰當的 Java 方法:這就等同於使用析構函數了,只是沒有它方便。記住,無論是"垃圾回收"還是"終結",都不保證一定會發生。如果 Java 虛擬機(JVM)並未面臨內存耗盡的情形,它可能不會浪費時間執行垃圾回收以恢復內存。
5.1 垃圾回收器如何工作
Java 從堆空間分配的速度可以和其他語言在棧上分配空間的速度相媲美。
在某些 Java 虛擬機中,堆的實現截然不同:它更像一個傳送帶,每分配一個新對象,它就向前移動一格。這意味着對象存儲空間的分配速度特別快。Java 的"堆指針"只是簡單地移動到尚未分配的區域,所以它的效率與 C++ 在棧上分配空間的效率相當。當然實際過程中,在簿記工作方面還有少量額外開銷,但是這部分開銷比不上查找可用空間開銷大。
當它工作時,一邊回收內存,一邊使堆中的對象緊湊排列,這樣"堆指針"就可以很容易地移動到更靠近傳送帶的開始處,也就儘量避免了頁面錯誤**。垃圾回收器通過重新排列對象,實現了一種高速的、有無限空間可分配的堆模型。**
- 一種簡單但速度很慢的垃圾回收機制叫做引用計數。每個對象中含有一個引用計數器,每當有引用指向該對象時,引用計數加
1。當引用離開作用域或被置爲 null 時,引用計數減
1。因此,管理引用計數是一個開銷不大但是在程序的整個生命週期頻繁發生的負擔。垃圾回收器會遍歷含有全部對象的列表,當發現某個對象的引用計數爲
0 時,就釋放其佔用的空間(但是,引用計數模式經常會在計數爲 0
時立即釋放對象)。這個機制存在一個缺點:如果對象之間存在循環引用,那麼它們的引用計數都不爲 0,就會出現應該被回收但無法被回收的情況。 - 在更快的策略中,垃圾回收器並非基於引用計數。它們依據的是:對於任意"活"的對象,一定能最終追溯到其存活在棧或靜態存儲區中的引用。這個引用鏈條可能會穿過數個對象層次,由此,如果從棧或靜態存儲區出發,遍歷所有的引用,你將會發現所有"活"的對象。對於發現的每個引用,必須追蹤它所引用的對象,然後是該對象包含的所有引用,如此反覆進行,直到訪問完"根源於棧或靜態存儲區的引用"所形成的整個網絡。你所訪問過的對象一定是"活"的。注意,這解決了對象間循環引用的問題,這些對象不會被發現,因此也就被自動回收了。在這種方式下,Java 虛擬機採用了一種自適應的垃圾回收技術。至於如何處理找到的存活對象,取決於不同的 Java 虛擬機實現。其中有一種做法叫做停止-複製(stop-and-copy)。顧名思義,這需要先暫停程序的運行(不屬於後臺回收模式),然後將所有存活的對象從當前堆複製到另一個堆,沒有複製的就是需要被垃圾回收的。另外,當對象被複制到新堆時,它們是一個挨着一個緊湊排列,然後就可以按照前面描述的那樣簡單、直接地分配新空間了。這種所謂的"複製回收器"效率低下主要因爲兩個原因。其一:得有兩個堆,然後在這兩個分離的堆之間來回折騰,得維護比實際需要多一倍的空間。某些 Java 虛擬機對此問題的處理方式是,按需從堆中分配幾塊較大的內存,複製動作發生在這些大塊內存之間。其二在於複製本身。一旦程序進入穩定狀態之後,可能只會產生少量垃圾,甚至沒有垃圾。儘管如此,複製回收器仍然會將所有內存從一處複製到另一處,這很浪費。
- 爲了避免這種狀況,一些 Java 虛擬機會進行檢查:要是沒有新垃圾產生,就會轉換到另一種模式(即"自適應")。這種模式稱爲標記-清掃(mark-and-sweep),Sun 公司早期版本的 Java 虛擬機一直使用這種技術。對一般用途而言,"標記-清掃"方式速度相當慢,但是當你知道程序只會產生少量垃圾甚至不產生垃圾時,它的速度就很快了。"標記-清掃"所依據的思路仍然是從棧和靜態存儲區出發,遍歷所有的引用,找出所有存活的對象。但是,每當找到一個存活對象,就給對象設一個標記,並不回收它。只有當標記過程完成後,清理動作纔開始。在清理過程中,沒有標記的對象將被釋放,不會發生任何複製動作。"標記-清掃"後剩下的堆空間是不連續的,垃圾回收器要是希望得到連續空間的話,就需要重新整理剩下的對象。
"停止-複製"指的是這種垃圾回收動作不是在後臺進行的;相反,垃圾回收動作發生的同時,程序將會暫停。在 Oracle 公司的文檔中會發現,許多參考文獻將垃圾回收視爲低優先級的後臺進程,但是早期版本的 Java 虛擬機並不是這麼實現垃圾回收器的。當可用內存較低時,垃圾回收器會暫停程序。同樣,"標記-清掃"工作也必須在程序暫停的情況下才能進行。
這裏討論的 Java 虛擬機中,內存分配以較大的"塊"爲單位。如果對象較大,它會佔用單獨的塊。嚴格來說,"停止-複製"要求在釋放舊對象之前,必須先將所有存活對象從舊堆複製到新堆,這導致了大量的內存複製行爲。有了塊,垃圾回收器就可以把對象複製到廢棄的塊。每個塊都有年代數來記錄自己是否存活。通常,如果塊在某處被引用,其年代數加 1,垃圾回收器會對上次回收動作之後新分配的塊進行整理。這對處理大量短命的臨時對象很有幫助。垃圾回收器會定期進行完整的清理動作——大型對象仍然不會複製(只是年代數會增加),含有小型對象的那些塊則被複制並整理。Java 虛擬機會監視,如果所有對象都很穩定,垃圾回收的效率降低的話,就切換到"標記-清掃"方式。同樣,Java 虛擬機會跟蹤"標記-清掃"的效果,如果堆空間出現很多碎片,就會切換回"停止-複製"方式。這就是"自適應"的由來,你可以給它個囉嗦的稱呼:"自適應的、分代的、停止-複製、標記-清掃"式的垃圾回收器。
5.2、提升速度的附加技術
-
Java 虛擬機中有許多附加技術用來提升速度。尤其是與加載器操作有關的,被稱爲"即時"(Just-In-Time,JIT)編譯器的技術。這種技術可以把程序全部或部分翻譯成本地機器碼,所以不需要 JVM來進行翻譯,因此運行得更快。當需要裝載某個類(通常是創建該類的第一個對象)時,編譯器會先找到其 .class文件,然後將該類的字節碼裝入內存。 你可以讓即時編譯器編譯所有代碼,但這種做法有兩個缺點:
- 這種加載動作貫穿整個程序生命週期內,累加起來需要花更多時間。
- 會增加可執行代碼的長度(字節碼要比即時編譯器展開後的本地機器碼小很多),這會導致頁面調度,從而一定降低程序速度。
-
另一種做法稱爲惰性評估,意味着即時編譯器只有在必要的時候才編譯代碼。這樣,從未被執行的代碼也許就壓根不會被 JIT 編譯。新版 JDK中的 Java HotSpot 技術就採用了類似的做法,代碼每被執行一次就優化一些,所以執行的次數越多,它的速度就越快。
6、初始化
6.1、成員初始化
Java 儘量保證所有變量在使用前都能得到恰當的初始化。
局部變量必須初始化。
在類裏定義一個對象引用時,如果不將其初始化,那麼引用就會被賦值爲 null。
6.1.1、指定初始化
- 在定義類成員變量的地方爲其賦值。可以用同樣的方式初始化非基本類型的對象
- 通過調用某個方法來提供初值
6.2、構造器初始化
用構造器進行初始化,在運行時調用方法進行初始化。但是,這無法阻止自動初始化的進行,他會在構造器被調用之前發生。
因此,如果使用如下代碼:
// housekeeping/Counter.java
public class Counter {
int i;
Counter() {
i = 7;
}
// ...
}
i 首先會被初始化爲 0,然後變爲 7。
6.2.1、初始化順序
在類中變量定義的順序決定了它們初始化的順序。即使變量定義散佈在方法定義之間,它們仍會在任何方法(包括構造器)被調用之前得到初始化。
6.2.2、靜態數據的初始化
無論創建多少個對象,靜態數據都只佔用一份存儲區域。
static 關鍵字不能應用於局部變量,所以只能作用於屬性(字段、域)。如果一個字段是靜態的基本類型,你沒有初始化它,那麼它就會獲得基本類型的標準初值。如果它是對象引用,那麼它的默認初值就是 null。
class Bowl {
Bowl(int marker) {
System.out.println("Bowl(" + marker + ")");
}
void f1(int marker) {
System.out.println("f1_in_bowl(" + marker + ")");
}
}
class Table {
static Bowl bowl1 = new Bowl(1);
Table() {
System.out.println("Table()");
bowl2.f1(1);
}
void f2(int marker) {
System.out.println("f2_In_Table(" + marker + ")");
}
static Bowl bowl2 = new Bowl(2);
}
class Cupboard {
Bowl bowl3 = new Bowl(3);
static Bowl bowl4 = new Bowl(4);
Cupboard() {
System.out.println("Cupboard()");
bowl4.f1(2);
}
void f3(int marker) {
System.out.println("f3_In_Cupboard(" + marker + ")");
}
static Bowl bowl5 = new Bowl(5);
}
public class StaticInitialization {
public static void main(String[] args) {
System.out.println("main creating new Cupboard()");
new Cupboard();
System.out.println("main creating new Cupboard()");
new Cupboard();
table.f2(1);
cupboard.f3(1);
}
static Table table = new Table();
static Cupboard cupboard = new Cupboard();
}
初始化的順序先是靜態對象(如果它們之前沒有被初始化的話),然後是非靜態對象。概括一下創建對象的過程,假設有個名爲 Dog 的類
- 即使沒有顯式地使用 static 關鍵字,構造器實際上也是靜態方法。所以,當首次創建 Dog 類型的對象或是首次訪問 Dog 類的靜態方法或屬性時,Java 解釋器必須在類路徑中查找,以定位 Dog.class。
- 當加載完 Dog.class 後(後面會學到,這將創建一個 Class 對象),有關靜態初始化的所有動作都會執行。因此,靜態初始化只會在首次加載 Class 對象時初始化一次。
- 當用 new Dog() 創建對象時,首先會在堆上爲 Dog 對象分配足夠的存儲空間。
- 分配的存儲空間首先會被清零,即會將 Dog 對象中的所有基本類型數據設置爲默認值(數字會被置爲 0,布爾型和字符型也相同),引用被置爲 null。
- 執行所有出現在字段定義處的初始化動作。
- 執行構造器。你將會在"複用"這一章看到,這可能會牽涉到很多動作,尤其當涉及繼承的時候。
6.2.3、顯示的靜態初始化
你可以將一組靜態初始化動作放在類裏面一個特殊的"靜態子句"(有時叫做靜態塊)中。像下面這樣:
// housekeeping/Spoon.java
public class Spoon {
static int i;
static {
i = 47;
}
}
實際上它只是一段跟在 static 關鍵字後面的代碼塊。與其他靜態初始化動作一樣,這段代碼僅執行一次:當**首次創建這個類的對象或首次訪問這個類的靜態成員(甚至不需要創建該類的對象)**時。
// housekeeping/ExplicitStatic.java
// Explicit static initialization with "static" clause
class Cup {
Cup(int marker) {
System.out.println("Cup(" + marker + ")");
}
void f(int marker) {
System.out.println("f(" + marker + ")");
}
}
class Cups {
static Cup cup1;
static Cup cup2;
static {
cup1 = new Cup(1);
cup2 = new Cup(2);
}
Cups() {
System.out.println("Cups()");
}
}
public class ExplicitStatic {
public static void main(String[] args) {
System.out.println("Inside main()");
Cups.cup1.f(99); // [1]
}
// static Cups cups1 = new Cups(); // [2]
// static Cups cups2 = new Cups(); // [2]
}
輸出:
Inside main
Cup(1)
Cup(2)
f(99)
無論是通過標爲 [1] 的行訪問靜態的 cup1 對象,還是把標爲 [1] 的行去掉,讓它去運行標爲 [2] 的那行代碼(去掉 [2] 的註釋),Cups 的靜態初始化動作都會執行。如果同時註釋 [1] 和 [2] 處,那麼 Cups 的靜態初始化就不會進行。此外,把標爲 [2] 處的註釋都去掉還是隻去掉一個,靜態初始化只會執行一次。
6.2.4、非靜態實例初始化
Java 提供了被稱爲實例初始化的類似語法,用來初始化每個對象的非靜態變量,例如:
// housekeeping/Mugs.java
// Instance initialization
class Mug {
Mug(int marker) {
System.out.println("Mug(" + marker + ")");
}
}
public class Mugs {
Mug mug1;
Mug mug2;
{ // [1]
mug1 = new Mug(1);
mug2 = new Mug(2);
System.out.println("mug1 & mug2 initialized");
}
Mugs() {
System.out.println("Mugs()");
}
Mugs(int i) {
System.out.println("Mugs(int)");
}
public static void main(String[] args) {
System.out.println("Inside main()");
new Mugs();
System.out.println("new Mugs() completed");
new Mugs(1);
System.out.println("new Mugs(1) completed");
}
}
輸出:
Inside main
Mug(1)
Mug(2)
mug1 & mug2 initialized
Mugs()
new Mugs() completed
Mug(1)
Mug(2)
mug1 & mug2 initialized
Mugs(int)
new Mugs(1) completed
看起來它很像靜態代碼塊,只不過少了 static 關鍵字。這種語法對於支持"匿名內部類"(參見"內部類"一章)的初始化是必須的,但是你也可以使用它保證某些操作一定會發生,而不管哪個構造器被調用。從輸出看出,實例初始化子句是在兩個構造器之前執行的。
6.3、數組初始化
編譯器不允許指定數組的大小。這又把我們帶回有關"引用"的問題上。你所擁有的只是對數組的一個引用(你已經爲該引用分配了足夠的存儲空間),但是還沒有給數組對象本身分配任何空間。
爲了給數組創建相應的存儲空間,必須寫初始化表達式。
對於數組,初始化動作可以出現在代碼的任何地方,但是也可以使用一種特殊的初始化表達式,它必須在創建數組的地方出現。這種特殊的初始化是由一對花括號括起來的值組成。這種情況下,存儲空間的分配(相當於使用 new) 將由編譯器負責。
所有的數組(無論是對象數組還是基本類型數組)都有一個固定成員 length,告訴你這個數組有多少個元素,你不能對其修改。
6.3.1、動態數組創建
可以直接使用 new 在數組中創建元素。
int[] a = new int[new Random(47).nextInt(20)];
如果你創建了一個非基本類型的數組,那麼你創建的是一個引用數組。
Random rand = new Random(47);
Integer[] a = new Integer[rand.nextInt(20)];
它只是一個引用數組,直到通過創建新的 Integer 對象(通過自動裝箱),並把對象賦值給引用,初始化纔算結束。
a[i] = rand.nextInt(500);
如果忘記了創建對象,但試圖使用數組中的空引用,就會在運行時產生異常。
6.3.2、可變參數列表
你可以以一種類似 C 語言中的可變參數列表(C 通常把它稱爲"varargs")來創建和調用方法。這可以應用在參數個數或類型未知的場合。由於所有的類都最後繼承於 Object 類(隨着本書的進展,你會對此有更深的認識),所以你可以創建一個以 Object 數組爲參數的方法,並像下面這樣調用:
// housekeeping/VarArgs.java
// Using array syntax to create variable argument lists
class A {}
public class VarArgs {
static void printArray(Object[] args) {
for (Object obj: args) {
System.out.print(obj + " ");
}
System.out.println();
}
public static void main(String[] args) {
printArray(new Object[] {47, (float) 3.14, 11.11});
printArray(new Object[] {"one", "two", "three"});
printArray(new Object[] {new A(), new A(), new A()});
}
}
默認行爲(如果沒有定義 toString() 方法的話,後面會講這個方法)就是打印類名和對象的地址。
有了可變參數,你就再也不用顯式地編寫數組語法了,當你指定參數時,編譯器實際上會爲你填充數組。你獲取的仍然是一個數組
// housekeeping/NewVarArgs.java
// Using array syntax to create variable argument lists
public class NewVarArgs {
static void printArray(Object... args) {
for (Object obj: args) {
System.out.print(obj + " ");
}
System.out.println();
}
public static void main(String[] args) {
// Can take individual elements:
printArray(47, (float) 3.14, 11.11);
printArray(47, 3.14F, 11.11);
printArray("one", "two", "three");
printArray(new A(), new A(), new A());
// Or an array:
printArray((Object[]) new Integer[] {1, 2, 3, 4});
printArray(); // Empty list is OK
}
}
如果你有一組事物,可以把它們當作列表傳遞,而如果你已經有了一個數組,該方法會把它們當作可變參數列表來接受。
可變參數列表中可以使用任何類型的參數,包括基本類型。
可變參數列表不依賴於自動裝箱,而使用的是基本類型。
可變參數列表使得方法重載更加複雜了,你應該總是在重載方法的一個版本上使用可變參數列表(加入非可變參數區分),或者壓根不用它。
7、枚舉類型
在你創建 enum 時,編譯器會自動添加一些有用的特性。例如,它會創建 toString() 方法,以便你方便地顯示某個 enum 實例的名稱,編譯器還會創建 ordinal() 方法表示某個特定 enum 常量的聲明順序,static values() 方法按照 enum 常量的聲明順序,生成這些常量值構成的數組。
enum 確實是類,並且具有自己的方法。
enum 有一個很實用的特性,就是在 switch 語句中使用。由於 switch 是在有限的可能值集合中選擇,因此它與 enum 是絕佳的組合。注意,enum 的名稱是如何能夠倍加清楚地表明程序的目的的。
小結
初始化在編程語言中的重要地位。錯誤的初始化會導致大量編程錯誤。這些錯誤很難被發現,同樣,不合理的清理也會如此。因爲構造器能保證進行正確的初始化和清理(沒有正確的構造器調用,編譯器就不允許創建對象),所以你就有了完全的控制和安全。
在不需要類似析構器行爲的時候,Java 的垃圾回收器極大地簡化了編程,並加強了內存管理上的安全性。一些垃圾回收器甚至能清理其他資源,如圖形和文件句柄。然而,垃圾回收器確實增加了運行時開銷,由於 Java 解釋器從一開始就很慢,所以這種開銷到底造成多大的影響很難看出來。隨着時間的推移,Java 在性能方面提升了很多,但是速度問題仍然是它涉足某些特定編程領域的障礙。
組合,繼承以及它們如何影響構造器。