1. 程式人生 > >Java程式設計思想學習(五)----第5章:初始化與清理

Java程式設計思想學習(五)----第5章:初始化與清理

隨著計算機革命的發展,“不安全”的程式設計方式已逐漸成為程式設計代價高昂的主因之一。

C++引入了構造囂(constructor)的概念,這是一個在建立物件時被自動呼叫的特殊方法。Java中也採用了構造器,並額外提供了“垃圾回收器”。對於不再使用的記憶體資源,垃圾回收器能自動將其釋放。

5.1 用構造器確保初始化

//:initialization/SimpleConstructor.java
//Demonstration of a simple constructor.
class Rock 
{
    Rock()
    {
        System.out.print("Rock ");
    }
}
public class SimpleConstructor { public static void main(String[] args) { for (int i = 0; i < 10; i++) { new Rock(); } } }/*Output Rock Rock Rock Rock Rock Rock Rock Rock Rock Rock *///:~

  在建立物件時:new Rock();將會為物件分配儲存空間,並呼叫相應的構造器。這就確保了在你能操作物件之前,它已經被恰當地初始化了。

  請注意,由於構造器的名稱必須與類名完全相同,所以“每個方法首字母小寫”的編碼風格並不適用於構造器。

//:initialization/SimpleConstructor2.java
class Rock2 
{
    Rock2(int i)
    {
        System.out.print("Rock2 "+i+" ");
    }
}
public class SimpleConstructor2
{
    public static void main(String[] args)
    {
        for (int i = 0; i < 8; i++)
        {
            
new Rock2(i); } } }/*Output Rock2 0 Rock2 1 Rock2 2 Rock2 3 Rock2 4 Rock2 5 Rock2 6 Rock2 7 *///:~

  有了構造器形式引數,就可以在初始化物件時提供實際引數。例知,假設類Tree有一個構造器,它接受一個整型變數來表示樹的高度,就可以這樣建立一個Tree物件:

Tree t = new Tree(12); //12-foot tree

  如果Tree(int)是Tree類中唯一的構造器,那麼編譯器將不會允許你以其他任何方式建立Tree物件。

  構造器有助於減少錯誤,並使程式碼更易於閱讀。從概念上講,“初始化”與“建立”是彼此獨立的,然而在上面的程式碼中,你卻找不到對initialize()方法的明確呼叫。在Java中,“初始化”和“建立”捆綁在一起,兩者不能分離。

  • 練習1:(1)建立一個類,它包含一個未初始化的String引用。驗證該引用被Java初始化成了null。
  • 練習2:(2)建立一個類,它包含一個在定義時就被初始化了的String域,以及另一個通過構造器初始化的String域。這兩種方式有何差異?

5.2 方法過載

  當建立一個物件時,也就給此物件分配到的儲存空間取了一個名字。所謂方法則是給某個動作取的名字。

  大多數程式設計語言(尤其是C)要求為每個方法(在這些語言中經常稱為函式)都提供一個獨一無二的識別符號。

  所以絕不能用名為print()的函式顯示了整數之後,又用一個名為print()的函式顯示浮點數——每個函式都要有唯一的名稱。

   構造器是強制過載方法名的另一個原因。既然構造器的名字已經由類名所決定,就只能有一個構造器名。那麼要想用多種方式建立一個物件該怎麼辦呢?假設你要建立一個類,既可以用標準方式進行初始化,也可以從檔案裡讀取資訊來初始化。這就需要兩個構造器:一個預設構造器,另一個取字串作為形式引數——該字串表示初始化物件所需的檔名稱。由於都是構造器,所以它們必須有相同的名字,即類名。為了讓方法名相同而形式引數不同的構造器同時存在,必須用到方法過載。

下面這個例子同時示範了過載的構造器和過載的方法:

class Tree
{
   int height;

   public Tree()
   {
       height = 0;
       System.out.println("種植樹苗");
   }

   public Tree(int initialHeight)
   {
       height = initialHeight;
       System.out.println("新建立了一顆 " + height + " 高的樹");
   }

   void info()
   {
       System.out.println("本樹高為 " + height);
   }

   void info(String s)
   {
       System.out.println(s + ":本樹高為 " + height);
   }
}

public class Overloading 
{
    public static void main(String[] args)
    {
        for (int i = 0; i < 3; i++)
        {
            Tree t = new Tree(i);
            t.info();
            t.info("過載的方法");
        }
        //過載構造器
        new Tree();
    }
}/*Output
    新建立了一顆 0 高的樹
    本樹高為 0
    過載的方法:本樹高為 0

    新建立了一顆 1 高的樹
    本樹高為 1
    過載的方法:本樹高為 1

    新建立了一顆 2 高的樹
    本樹高為 2
    過載的方法:本樹高為 2

    種植樹苗
    *///:~

5.2.1 區分過載方法

規則很簡單:每個過載的方法都必須有一個獨一無二的引數型別列表。

甚至引數順序的不同也足以區分兩個方法。不過,一般情況下別這麼做,因為這會使程式碼 
難以維護:

public class OverloadingOrder
{
    static void f(String s, int i)
    {
        System.out.println("String: " + s + ", int: " + i);
    }

    static void f(int i, String s)
    {
        System.out.println("int: " + i + ", String: " + s);
    }

    public static void main(String[] args)
    {
        f("String first", 11);
        f(99, "int first");
    }
}/*Output
    String: String first, int: 11
    int: 99, String: int first
    *///:~

上例中兩個f()方法雖然聲明瞭相同的引數,但順序不同,因此得以區分。

5.2.2 涉及基本型別的過載

  基本型別能從一個“較小一的型別自動提升至一個“較大”的型別,此過程一旦牽涉到過載,可能會造成一些混淆。以下例子說明了將基本型別傳遞給過載方法時發生的情況:

public class PrimitiveOverloading
{
    //*******************f1***************//
    void f1(char x)
    {
        System.out.print("f1(char) ");
    }

    void f1(byte x)
    {
        System.out.print("f1(byte) ");
    }

    void f1(short x)
    {
        System.out.print("f1(short) ");
    }

    void f1(int x)
    {
        System.out.print("f1(int) ");
    }

    void f1(long x)
    {
        System.out.print("f1(long) ");
    }

    void f1(float x)
    {
        System.out.print("f1(float) ");
    }

    void f1(double x)
    {
        System.out.print("f1(double) ");
    }

    //********************f2**************//
    void f2(byte x)
    {
        System.out.print("f2(byte) ");
    }

    void f2(short x)
    {
        System.out.print("f2(short) ");
    }

    void f2(int x)
    {
        System.out.print("f2(int) ");
    }

    void f2(long x)
    {
        System.out.print("f2(long) ");
    }

    void f2(float x)
    {
        System.out.print("f2(float) ");
    }

    void f2(double x)
    {
        System.out.print("f2(double) ");
    }

    //*******************f3***************//
    void f3(short x)
    {
        System.out.print("f3(short) ");
    }

    void f3(int x)
    {
        System.out.print("f3(int) ");
    }

    void f3(long x)
    {
        System.out.print("f3(long) ");
    }

    void f3(float x)
    {
        System.out.print("f3(float) ");
    }

    void f3(double x)
    {
        System.out.print("f3(double) ");
    }

    //********************f4***************//
    void f4(int x)
    {
        System.out.print("f4(int) ");
    }

    void f4(long x)
    {
        System.out.print("f4(long) ");
    }

    void f4(float x)
    {
        System.out.print("f4(float) ");
    }

    void f4(double x)
    {
        System.out.print("f4(double) ");
    }

    //********************f5**************//
    void f5(long x)
    {
        System.out.print("f5(long) ");
    }

    void f5(float x)
    {
        System.out.print("f5(float) ");
    }

    void f5(double x)
    {
        System.out.print("f5(double) ");
    }
    //********************f6**************//

    void f6(float x)
    {
        System.out.print("f6(float) ");
    }

    void f6(double x)
    {
        System.out.print("f6(double) ");
    }

    //********************f7**************//
    void f7(double x)
    {
        System.out.print("f7(double) ");
    }

    void testConstVal()
    {
        System.out.print("5: ");
        f1(5);
        f2(5);
        f3(5);
        f4(5);
        f5(5);
        f6(5);
        f7(5);
        System.out.println();
    }

    void testChar()
    {
        char x = 'x';
        System.out.print("char: ");
        f1(x);
        f2(x);
        f3(x);
        f4(x);
        f5(x);
        f6(x);
        f7(x);
        System.out.println();
    }

    void testByte()
    {
        byte x = 0;
        System.out.print("byte: ");
        f1(x);
        f2(x);
        f3(x);
        f4(x);
        f5(x);
        f6(x);
        f7(x);
        System.out.println();
    }

    void testShort()
    {
        short x = 0;
        System.out.print("short: ");
        f1(x);
        f2(x);
        f3(x);
        f4(x);
        f5(x);
        f6(x);
        f7(x);
        System.out.println();
    }

    void testInt()
    {
        int x = 0;
        System.out.print("int: ");
        f1(x);
        f2(x);
        f3(x);
        f4(x);
        f5(x);
        f6(x);
        f7(x);
        System.out.println();
    }

    void testLong()
    {
        long x = 0;
        System.out.print("long: ");
        f1(x);
        f2(x);
        f3(x);
        f4(x);
        f5(x);
        f6(x);
        f7(x);
        System.out.println();
    }

    void testFloat()
    {
        float x = 0;
        System.out.print("float: ");
        f1(x);
        f2(x);
        f3(x);
        f4(x);
        f5(x);
        f6(x);
        f7(x);
        System.out.println();
    }

    void testDouble()
    {
        double x = 0;
        System.out.print("double: ");
        f1(x);
        f2(x);
        f3(x);
        f4(x);
        f5(x);
        f6(x);
        f7(x);
        System.out.println();
    }

    public static void main(String[] args)
    {
        PrimitiveOverloading p = new PrimitiveOverloading();
        p.testConstVal();
        p.testChar();
        p.testByte();
        p.testShort();
        p.testInt();
        p.testLong();
        p.testFloat();
        p.testDouble();
    }
}/*Output
    5: f1(int) f2(int) f3(int) f4(int) f5(long) f6(float) f7(double) 
    char: f1(char) f2(int) f3(int) f4(int) f5(long) f6(float) f7(double) 
    byte: f1(byte) f2(byte) f3(short) f4(int) f5(long) f6(float) f7(double) 
    short: f1(short) f2(short) f3(short) f4(int) f5(long) f6(float) f7(double) 
    int: f1(int) f2(int) f3(int) f4(int) f5(long) f6(float) f7(double) 
    long: f1(long) f2(long) f3(long) f4(long) f5(long) f6(float) f7(double) 
    float: f1(float) f2(float) f3(float) f4(float) f5(float) f6(float) f7(double) 
    double: f1(double) f2(double) f3(double) f4(double) f5(double) f6(double) f7(double) 
    *///:~

  你會發現常數值5被當作int值處理,所以如果有某個過載方法接受int型引數,它就會被呼叫。至於其他情況,如果傳入的資料型別(實際引數型別)小於方法中宣告的形式引數型別,實際資料型別就會被提升。 char型略有不同,如果無法找到恰好接受char引數的方法,就會把char直接提升至int型。

  如果傳入的實際引數大於過載方法宣告的形式引數,會出現什麼情況呢?修改上述程式,就能得到答案。

public class Demotion()
{  //*******************f1***************//
    void f1(char x)
    {
        System.out.print("f1(char) ");
    }

    void f1(byte x)
    {
        System.out.print("f1(byte) ");
    }

    void f1(short x)
    {
        System.out.print("f1(short) ");
    }

    void f1(int x)
    {
        System.out.print("f1(int) ");
    }

    void f1(long x)
    {
        System.out.print("f1(long) ");
    }

    void f1(float x)
    {
        System.out.print("f1(float) ");
    }

    void f1(double x)
    {
        System.out.print("f1(double) ");
    }

    //********************f2**************//
    void f2(char x)
    {
        System.out.print("f2(char) ");
    }

    void f2(byte x)
    {
        System.out.print("f2(byte) ");
    }

    void f2(short x)
    {
        System.out.print("f2(short) ");
    }

    void f2(int x)
    {
        System.out.print("f2(int) ");
    }

    void f2(long x)
    {
        System.out.print("f2(long) ");
    }

    void f2(float x)
    {
        System.out.print("f2(float) ");
    }


    //*******************f3***************//
    void f3(char x)
    {
        System.out.print("f3(char) ");
    }

    void f3(byte x)
    {
        System.out.print("f3(byte) ");
    }

    void f3(short x)
    {
        System.out.print("f3(short) ");
    }

    void f3(int x)
    {
        System.out.print("f3(int) ");
    }

    void f3(long x)
    {
        System.out.print("f4(long) ");
    }

    //********************f4***************//
    void f4(char x)
    {
        System.out.print("f4(char) ");
    }

    void f4(byte x)
    {
        System.out.print("f4(byte) ");
    }

    void f4(short x)
    {
        System.out.print("f4(short) ");
    }

    void f4(int x)
    {
        System.out.print("f4(int) ");
    }

    //********************f5**************//
    void f5(char x)
    {
        System.out.print("f5(char) ");
    }

    void f5(byte x)
    {
        System.out.print("f5(byte) ");
    }

    void f5(short x)
    {
        System.out.print("f5(short) ");
    }

    //********************f6**************//

    void f6(char x)
    {
        System.out.print("f6(char) ");
    }

    void f6(byte x)
    {
        System.out.print("f6(byte) ");
    }

    //********************f7**************//
    void f7(char x)
    {
        System.out.print("f7(char) ");
    }


    void testDouble()
    {
        double x = 0;
        System.out.print("double argument: ");
        f1(x);
        f2((float) x);
        f3((long) x);
        f4((int) x);
        f5((short) x);
        f6((byte) x);
        f7((char) x);
        System.out.println();
    }

    public static void main(String[] args)
    {
        Demotion d = new Demotion();
        d.testDouble();
    }
}/*Output
    double argument: f1(double) f2(float) f4(long) f4(int) f5(short) f6(byte) f7(char) 
    *///:~

  在這裡,方法接受較小的基本型別作為引數。如果傳入的實際引數較大,就得通過型別轉換來執行窄化轉換。如果不這樣做,編譯器就會報錯。

5.2.3 以返回值區分過載方法

void f()
{
}

int f() 
{ 
    return 1; 
}
f();

  此時Java如何才能判斷該呼叫哪一個f()呢?別人該如何理解這種程式碼呢?因此,根據方法的返回值來區分過載方法是行不通的。

5.3 預設構造器

  如果你寫的類中沒有構造器,則編譯器會自動幫你建立一個預設構造器。

//: initialization/DefaultConstructor.java
class Bird 
{
}

public class DefaultConstructor 
{
    public static void main (String[] args) 
    {
        Brid b = new Bird();//Default
    }
}///:~

  表示式 new Bird()行建立了一個新物件,並呼叫其預設構造器——即使你沒有明確定義它。沒有它的話,就沒有方法可呼叫,就無法建立物件。但是,如果已經定義了一個構造器(無論是否有引數),編譯器就不會幫你自動建立預設構造器:

//: initialization/NoSynthesis.java
class  bird2  
{
    Bird2(int i) 
    {
    }

    Bird2(double d) 
    {
    }
}

public class NoSynthesis
{
    public staticvoid main(String[] args)
    {
        //! Bird2 b = new Bird2(); // No default
        Bird2 b2=new Bird2(1);
        Bird2 b3=new Bird2(1.0);
    }
 }  ///:~

  要是你這樣寫:new Bird2()編譯器就會報錯:沒有找到匹配的構造器。

  • 練習3:(1)建立一個帶預設構造器(即無參構造器)的類,在構造器中列印一條訊息。為這個類建立一個物件。
  • 練習4:(1)為前一個練習中的類新增一個過載構造器,令其接受一個字串引數,並在構造器中把你自己的訊息和接收的引數一起打印出來。
  • 練習5:(2)建立一個名為Dog的類,它具有過載的bark()方法。此方法應根據不同的基本資料型別進行過載,並根據被呼叫的版本,打印出不同型別的狗吠(barking)、咆哮(howling)等資訊。編寫main()來呼叫所有不同版本的方法。
  • 練習6:(1)修改前一個練習的程式,讓兩個過載方法各自接受兩個型別的不同的引數,但二者順序相反。驗證其是否工作。
  • 練習7:(1)建立一個沒有構造器的類,並在main()中建立其物件,用以驗證編譯器是否真的自動加入了預設構造器。

5.4 this關鍵字

  如果有同一型別的兩個物件,分別是a和b。你可能想知道,如何才能讓這兩個物件都能呼叫peel()方法呢:

public class BananaPeel  
{
    public static void main(String[] args)I
    {
        Banana a = new Banana(),b = new Banana();
        a.peel(1);
        b.peel(2);
    }
}///:~

  如果只有一個peel()方法,它如何知道是被a還是被b所呼叫的呢?

  它暗自把“所操作物件的引用”作為第一個引數傳遞給peel()。所以上述兩個方法的呼叫就變成了這樣:

Banana.peel(a,1);
Banana.peel(b,2);

  this關鍵字只能在方法內部使用,表示對“呼叫方法的那個物件”的引用。this的用法和其他物件引用並無不同。但要注意,如果在方法內部呼叫同一個類的另一個方法,就不必使用this,直接呼叫即可。當前方法中的this引用會自動應用於同一類中的其他方法。所以可以這樣寫程式碼:

public class Apricot 
{
    void pick ()
    {
        /*...*/
    }

    void pit()
    {
        pick();
        /*...*/
    }
}///:~

  在pit()內部,你可以寫this.pick(),但無此必要。編譯器能幫你自動新增。只有當需要明確指出對當前物件的引用時,才需要使用this關鍵字。例如,當需要返回對當前物件的引用時,就常常在return語句裡這樣寫:

public class Leaf
{
    int i = 0;
    Leaf increment()
    {
        i++;
        return this;
    }

    void print()
    {
        System.out.println("i = "+i);   
    }

    public static void main(String[] args)
    {
        Leaf l = new Leaf();
        l.increment().increment().increment().print();
    }
}/*Output
    i = 3
    ///:~

  由於increment()通過this關鍵字返回了對當前物件的引用,所以很容易在一條語句裡對同一個物件執行多次操作。

  this關鍵字對於將當前物件傳遞給其他方法也很有用:

class Person
{
    public void eat(Apple apple)
    {
        Apple peeled = apple.getPeeled();
        System.out.println("Yummy");
    }
}

class Peeler
{
    static Apple peel(Apple apple)
    {
        //...remove peel
        return apple;
    }
}

class Apple
{
     Apple getPeeled()
     {
         return Peeler.peel(this);
     }
}

public class PassingThis 
{
    public static void main(String[] args)
    {
        new Person().eat(new Apple());
    }
}/*Output
    Yummy
    *///:~

  Apple需要呼叫Peeler.peel()方法,它是一個外部的工具方法,將執行由於某種原因而必須放在Apple外部的操作(也許是因為該外部方法要應用於許多不同的類,而你卻不想重複這些程式碼)。為了將其自身傳遞給外部方法,Apple必須使用this關鍵字。

  • 練習8:(1)編寫具有兩個方法的類,在第一個方法內呼叫第二個方法兩次:第一次呼叫時不使用this關鍵字,第二次呼叫時使用this關鍵字——這裡只是為了驗證它是起作用的,你不應該在實踐中使用這種方式。

5.4.1 在構造器中呼叫構造器

  可能為一個類寫了多個構造器,有時可能想在一個構造器中呼叫另一個構造器,以避免重複程式碼。可用this關鍵字做到達一點。

  通常寫this的時候,都是指“這個物件”或者“當前物件”,而且它本身表示對當前物件的引用。在構造器中,如果為this添加了引數列表,那麼就有了不同的含義。這將產生對符合此引數列表的某個構造器的明確呼叫,這樣,呼叫其他構造器就有了直接的途徑:

class Flower
{
    int petalCount = 0;
    String s = "initial value";

    Flower(int petals)
    {
        petalCount = petals;
        System.out.println("構造器 w/ int arg only,petalCount =" + petalCount);
    }

    Flower(String ss)
    {
        s = ss;
        System.out.println("構造器 w/ String arg only,s =" + s);
    }

    Flower(String s, int petals)
    {
        this(petals);
        //! this(s);//不能呼叫兩次構造器
        this.s = s;
        System.out.println("String and int arg");
    }

    Flower()
    {
        this("hi", 47);
        System.out.println("預設構造器(無參)");
    }

    void printPetalCount()
    {
        //! this(11); //不要在非構造方法裡使用
        System.out.println("petalCount=" + petalCount + " s=" + s);
    }

     public static void main(String[] args)
    {
        Flower f = new Flower();
        f.printPetalCount();
    }
}/*Output
    構造器 w/ int arg only,petalCount =47
    String and int arg
    預設構造器(無參)
    petalCount=47 s=hi
    *///:~

  構造器Flower(String s,int petals)表明:儘管可以用this呼叫一個構造器,但卻不能呼叫兩個。此外,必須將構造器呼叫置於最起始處,否則編譯器會報錯。 這個例子也展示了this的另一種用法。由於引數s的名稱和資料成員s的名字相同,所以會產生歧義。使用this.s來代表資料成員就能解決這個問題。在Java程式程式碼中經常出現這種寫法,本書中也常這麼寫。 printPetalCount()方法表明,除構造器之外,編譯器禁止在其他任何方法中呼叫構造器。 

  • 練習9:(1)編寫具有兩個(過載)構造器的類,並在第一個構造器中通過this呼叫第二個構造器。

5.4.2 static的含義

  在static方法的內部不能呼叫非靜態方法,反過來倒是可以的。(只會建立一次)

5.5 清理:終結處理和垃圾回收

  Java有垃圾回收器負責回收無用物件佔據的記憶體資源。但也有特殊情況:假定你的物件(並非使用new)獲得了一塊“特殊”的記憶體區域,由於垃圾回收器只知道釋放那些經由new分配的記憶體,所以它不知道該如何釋放該物件的這塊“特殊”記憶體。為了應對這種情況,Java允許在類中定義一個名為finalize()的方法。它的工作原理“假定”是這樣的:一旦垃圾回收器準備好釋放物件佔用的儲存空間,將首先呼叫其finalize()方法,並且在下一次垃圾回收動作發生時,才會真正回收物件佔用的記憶體。所以要是你打算用finalize(),就能在垃圾回收時刻做一些重要的清理工作。

  在C++中,物件一定會被銷燬(如果程式中沒有缺陷的話);而Java裡的物件卻並非總是被垃圾回收。或者換句話說:

  1. 物件可能不被垃圾回收。
  2. 垃圾回收並不等於“析構”。
  3. 垃圾回收只與記憶體有關。

   Java並未提供“解構函式”或相似的概念,要做類似的清理工作,必須自己動手建立一個執行清理工作的普通方法。

  也許你會發現,只要程式沒有瀕臨儲存空間用完的那一刻,物件佔用的空間就總也得不到釋放。如果程式執行結束,並且垃圾回收器一直都沒有釋放你建立的任何物件的儲存空間,則隨著程式的退出,那些資源也會全部交還給作業系統。這個策略是恰當的,因為垃圾回收本身也有開銷,要是不使用它,那就不用支付這部分開銷了。

5.5.1 finalize()的用途何在

  讀者或許已經明白了不要過多地使用finalize()的道理了 。對,它確實不是進行普通的清理工作的合適場所。那麼,普通的清理工作應該在哪裡執行呢?

5.5.2 你必須實施清理

  Java不允許建立區域性物件,必須使用new建立物件。在Java中,也沒有用於釋放物件的delete,因為垃圾回收器會幫助你釋放儲存空間。甚至可以膚淺地認為,正是由於垃圾收集機制的存在,使得Java沒有解構函式。

  無論是“垃圾回收”還是“終結”,都不保證一定會發生。如果Java虛擬機器(JVM)並未面臨記憶體耗盡的情形,它是不會浪費時間去執行垃圾回收以恢復記憶體的。

5.5.3 終結條件

  以下是個簡單的例子,示範了fifinalize()可能的使用方式:

 class Book
{
    boolean checkedOut = false;

    Book(boolean checkedOut)
    {
        this.checkedOut = checkedOut;
    }

    void checkIn()
    {
        checkedOut = false;
    }

    @Override
    protected void finalize() throws Throwable
    {
        if (checkedOut)
            System.out.println("ERROR: checked out");
        //通常情況下,你也會這麼做:
        super.finalize(); //呼叫基類方法
    }

    public static void main(String[] args)
    {
        Book novel = new Book(true);
        //適當的清理
        novel.checkIn();
        //作為參考,故意忘了清理
        new Book(true);
        //垃圾收集和終結
        System.gc();
    }
}/*Output
    ERROR: checked out
    *///:~

  System.gc()用於強制進行終結動作。即使不這麼做,通過重複地執行程式(假設程式將分配大量的儲存空間而導致垃圾回收動作的執行),最終也能找出錯誤的book物件。

  • 練習10:(2)編寫具有finalize()方法的類,並在方法中列印訊息。在main()中為該類建立一個物件。試解釋這個程式的行為。
  • 練習11:(4)修改前一個練習的程式,讓你的finalize()總會被呼叫。
  • 練習12:(4)編寫名為Tank的類,此類的狀態可以是“滿的”或“空的”。其終結條件是:物件被清理時必須處於空狀態。請編寫finalize()以檢驗終結條件是否成立。在main()中測試Tank可能發生的幾種使用方式。

5.5.4 垃圾回收器如何工作

  在以前所用過的程式語言中,在堆上分配物件的代價十分高昂,因此讀者自然會覺得Java中所有物件(基本型別除外),都在堆上分配的方式也非常高昂。然而,垃圾回收器對於提高物件的建立速度,卻具有明顯的效果。聽起來很奇怪——儲存空間的釋放竟然會影響儲存空間的分配,但這確實是某些Java虛擬機器的工作方式。這也意味著,Java從堆分配空間的速度,可以和其他語言從堆疊上分配空間的速度相媲美。

  打個比方,你可以把C++裡的堆想像成一個院子,裡面每個物件都負責管理自己的地盤。一段時間以後,物件可能被銷燬,但地盤必須加以重用。在某些Java虛擬機器中,堆的實現截然不同:它更像一個傳送帶,每分配一個新物件,它就往前移動一格。這意味著物件儲存空間的分配速度非常快。Java的“堆指標”只是簡單地移動到尚未分配的區域,其效率比得上C++在堆疊上分配空間的效率。當然,實際過程中在簿記工作方面還有少量額外開銷,但比不上查詢可用空間開銷大。

  讀者也許已經意識到了,Java中的堆未必完全像傳送帶那樣工作。要真是那樣的話,勢必會導致頻繁的記憶體頁面排程——將其移進移出硬碟,因此會顯得需要擁有比實際需要更多的記憶體。頁面排程會顯著地影響效能,最終,在建立了足夠多的物件之後,記憶體資源將耗盡。其中的祕密在於垃圾回收器的介入。當它工作時,將一面回收空間,一面使堆中的物件緊湊排列,這樣“堆指標”就可以很容易移動到更靠近傳送帶的開始處,也就儘量避免了頁面錯誤。通過垃圾回收器對物件重新排列,實現了一種高速的、有無限空間可供分配的堆模型。

  要想更好地理解Java中的垃圾回收,先了解其他系統中的垃圾回收機制將會很有幫助。引用記數是一種簡單但速度很慢的垃圾回收技術。每個物件都含有一個引用記數器,當有引用連線至物件時,引用計數加1。當引用離開作用域或被置為null時,引用計數減1。雖然管理引用記數的開銷不大,但這項開銷在整個程式生命週期中將持續發生。垃圾回收器會在含有全部物件的列表上遍歷,當發現某個物件的引用計數為0時,就釋放其佔用的空間(但是,引用記數模式經常會在記數值變為0時立即釋放物件)。這種方法有個缺陷,如果物件之間存在迴圈引用,可能會出現“物件應該被回收,但引用計數卻不為零”的情況。對垃圾回收器而言,定位這樣的互動自引用的物件組所需的工作量極大。引用記數常用來說明垃圾收集的工作方式,但似乎從未被應用於任何一種Java虛擬機器實現中。

  在一些更快的模式中,垃圾回收器並非基於引用記數技術。它們依據的思想是:對任何“活”的物件,一定能最終追溯到其存活在堆疊或靜態儲存區之中的引用。這個引用鏈條可能會穿過數個物件層次。由此,如果從堆疊和靜態儲存區開始,遍歷所有的引用,就能找到所有“活”的物件。對於發現的每個引用,必須追蹤它所引用的物件,然後是此物件包含的所有引用,如此反覆進行,直到“根源於堆疊和靜態儲存區的引用”所形成的網路全部被訪問為止。你所訪問過的物件必須都是“活”的。注意,這就解決了“互動自引用的物件組”的問題——這種現象根本不會被發現,因此也就被自動回收了。

  在這種方式下,Java虛擬機器將採用一種自適應的垃圾回收技術。至於如何處理找到的存活物件,取決於不同的Java虛擬機器實現。有一種做法名為停止一複製(stop-and-copy)。顯然這意味著,先暫停程式的執行(所以它不屬於後臺回收模式),然後將所有存活的物件從當前堆複製到另一個堆,沒有被複制的全部都是垃圾。當物件被複制到新堆時,它們是一個挨著一個的,所以新堆保持緊湊排列,然後就可以按前述方法簡單、直接地分配新空間了。

  當把物件從一處搬到另一處時,所有指向它的那些引用都必須修正。位於堆或靜態儲存區的引用可以直接被修正,但可能還有其他指向這些物件的引用,它們在遍歷的過程中才能被找到(可以想像成有個表格,將舊地址對映至新地址)。

  對於這種所謂的“複製式回收器”而言,效率會降低,這有兩個原因。首先,得有兩個堆,然後得在這兩個分離的堆之間來回搗騰,從而得維護比實際需要多一倍的空間。某些Java虛擬機器對此問題的處理方式是,按需從堆中分配幾塊較大的記憶體,複製動作發生在這些大塊記憶體之間。

  第二個問題在於複製。程式進入穩定狀態之後,可能只會產生少量垃圾,甚至沒有垃圾。儘管如此,複製式回收器仍然會將所有記憶體自一處複製到另一處,這很浪費。為了避免這種情形,一些Java虛擬機器會進行檢查:要是沒有新垃圾產生,就會轉換到另一種工作模式(即“自適應”)。這種模式稱為標記一清掃(mark-and-sweep),Sun公司早期版本的Java虛擬機器使用了這種技術。對一般用途而言,“標記一清掃”方式速度相當慢,但是當你知道只會產生少量垃圾甚至不會產生垃圾時,它的速度就很快了。

  “標記一清掃”所依據的思路同樣是從堆疊和靜態儲存區出發,遍歷所有的引用,進而找出所有存活的物件。每當它找到一個存活物件,就會給物件設一個標記,這個過程中不會回收任何物件。只有全部標記工作完成的時候,清理動作才會開始。在清理過程中,沒有標記的物件將被釋放,不會發生任何複製動作。所以剩下的堆空間是不連續的,垃圾回收器要是希望得到連續空間的話,就得重新整理剩下的物件。

  “停止一複製”的意思是這種垃圾回收動作不是在後臺進行的。相反,垃圾回收動作發生的同時,程式將會被暫停。在Sun公司的文件中會發現,許多參考文獻將垃圾回收視為低優先順序的後臺程序,但事實上垃圾回收器在Sun公司早期版本的Java虛擬機器中並非以這種方式實現的。當可用記憶體數量較低時,Sun版本的垃圾回收器會暫停執行程式,同樣,“標記一清掃”工作也必須在程式暫停的情況下才能進行。

  如前文所述,在這裡所討論的Java虛擬機器中,記憶體分配以較大的“塊”為單位。如果物件較大,它會佔用單獨的塊。嚴格來說,“停止一複製”要求在釋放舊有物件之前,必須先把所有存活物件從舊堆複製到新堆,這將導致大量記憶體複製行為。有了塊之後,垃圾回收器在回收的時候就可以往廢棄的塊裡拷貝物件了。每個塊都用相應的代數(generation count)來記錄它是否還存活。通常,如果塊在某處被引用,其代數會增加。垃圾回收器將對上次回收動作之後新分配的塊進行整理。這對處理大量短命的臨時物件很有幫助。垃圾回收器會定期進行完整的清理動作——大型物件仍然不會被複制(只是其代數會增加),內含小型物件的那些塊則被複制並整理。Java虛擬機器會進行監視,如果所有物件都很穩定,垃圾回收器的效率降低的話,就切換到 “標記一清掃”方式;同樣,Java虛擬機器會跟蹤“標記一清掃”的效果,要是堆空間出現很多碎片,就會切換回“停止一複製”方式。這就是“自適應”技術,你可以給它個羅嗦的稱呼:“自適應的、分代的、停止一複製、標記一清掃”式垃圾回收器。

  Java虛擬機器中有許多附加技術用以提升速度。尤其是與載入器操作有關的,被稱為“即時”(Just-In-Time,JIT)編譯器的技術。這種技術可以把程式全部或部分翻譯成本地機器碼(這本來是Java虛擬機器的工作),程式執行速度因此得以提升。當需要裝載某個類(通常是在為該類建立第一個物件)時,編譯器會先找到其.class檔案,然後將該類的位元組碼裝入記憶體。此時,有兩種方案可供選擇。一種是就讓即時編譯器編譯所有程式碼。但這種做法有兩個缺陷:這種載入動作散落在整個程式生命週期內,累加起來要花更多時間.並且會增加可執行程式碼的長度(位元組碼要比即時編譯器展開後的本地機器碼小很多),這將導致頁面排程,從而降低程式速度。另一種做法稱為惰性評估(lazy evaluation),意思是即時編譯器只在必要的時候才編譯程式碼。這樣,從不會被執行的程式碼也許就壓根不會被JIT所編譯。新版JDK中的Java HotSpot技術就採用了類似方法,程式碼每次被執行的時候都會做一些優化,所以執行的次數越多,它的速度就越快。

5.6 成員初始化

  Java盡力保證:所有變數在使用前都能得到恰當的初始化。對於方法的區域性變數,Java以編譯時錯誤的形式來貫徹這種保證。所以如果寫成:

void f()
{
    int i;
    i++;//錯誤,變數i可能沒有被初始化
}

  就會得到一條出錯訊息,告訴你i可能尚未初始化。當然,編譯器也可以為i賦一個預設值,但是未初始化的區域性變數更有可能是程式設計師的疏忽,所以採用預設值反而會掩蓋這種失誤。因此強制程式設計師提供一個初始值,往往能夠幫助找出程式裡的缺陷。

  要是類的資料成員(即欄位)是基本型別,情況就會變得有些不同。正如在“一切都是物件”一章中所看到的,類的每個基本型別資料成員保證都會有一個初始值。下面的程式可以驗證這類情況,並顯示它們的值:

public class InitialValues
{
    boolean t;
    char c;
    byte b;
    short s;
    int i;
    long l;
    float f;
    double d;
    InitialValues iv;

    void printInitialValues()
    {
        print("Data type  Initial Value");
        print("boolean  " + t);
        print("char  " + c);
        print("byte  " + b);
        print("short  " + s);
        print("int  " + i);
        print("long  " + l);
        print("float  " + f);
        print("double  " + d);
        print("iv  " + iv);
    }

    public static void main(String[] args)
    {
        new InitialValues().printInitialValues();
    }
}/*Output
    Data type  Initial Value
    boolean  false
    char   
    byte  0
    short  0
    int  0
    long  0
    float  0.0
    double  0.0
    iv  null
    *///:~

  可見儘管資料成員的初值沒有給出,但它們確實有初值(char值為0,所以顯示為空白)。這樣至少不會冒“未初始化變數”的風險了。

  在類裡定義一個物件引用時,如果不將其初始化,此引用就會獲得一個特殊值null。

5.6.1 指定初始化

  如果想為某個變數賦初值,該怎麼做呢?有一種很直接的辦法,就是在定義類成員變數的地方為其賦值(注意在C++裡不能這樣做,儘管C++的新手們總想這樣做)。以下程式碼片段修改了InitialValues類成員變數的定義,直接提供了初值。

public class InitialValues2
{
    boolean t = true;
    char c = 'x';
    byte b = 47;
    short s = 0xff;
    int i = 999;
    long l = 1;
    float f = 3.14f;
    double d = 3.14159;
}

  也可以用同樣的方法初始化非基本型別的物件。如果Depth是一個類,那麼可以像下面這樣建立一個物件並初始化它:

class Depth
{
}

public class Measurement
{
    Depth d = new Depth();
}

  如果沒有為d指定初始值就嘗試使用它,就會出現執行時錯誤,告訴你產生了一個異常(這在第12章中詳述)。

  甚至可以通過呼叫某個方法來提供初值:

public class MethodInit
{
    int i = f();
    int f()
    {
        return 11;
    }   
}

  這個方法也可以帶有引數,但這些引數必須是已經被初始化了的。因此,可以這樣寫:

public class MethodInit2
{
    int i = f();
    int j = g(i);
    int f()
    {
        return 11;
    }   
    int g(int n)
    {
        return n*10;
    }
}

  但像下面這樣寫就不對了:

public class MethodInit3
{
    //! int j = g(i); //非法向前引用
    int i = f();
    int f()
    {
        return 11;
    }   
    int g(int n)
    {
        return n*10;
    }
}

  顯然,上述程式的正確性取決於初始化的順序,而與其編譯方式無關。所以,編譯器恰當地對“向前引用”發出了警告。

  這種初始化方法既簡單又直觀。但有個限制:類InitialValues的每個物件都會具有相同的初值。有時,這正是所希望的,但有時卻需要更大的靈活性。

5.7 構造器初始化

  可以用構造器來進行初始化。在執行時刻,可以呼叫方法或執行某些動作來確定初值,這為程式設計帶來了更大的靈活性。但要牢記:無法阻止自動初始化的進行,它將在構造器被呼叫之前發生。因此,假如使用下述程式碼:

public class Counter
{
    int i;
    Counter()
    {
        i = 7;
    }
}

  那麼i首先會被置0,然後變成7。對於所有基本型別和物件引用,包括在定義時已經指定初值的變數,這種情況都是成立的.因此,編譯器不會強制你一定要在構造器的某個地方或在使用它們之前對元素進行初始化——因為初始化早已得到了保證。

5.7.1 初始化順序

  在類的內部,變數定義的先後順序決定了初始化的順序。即使變數定義散佈於方法定義之間,它們仍舊會在任何方法(包括構造器)被呼叫之前得到初始化。例如:

class Window
{
    Window(int marker)
    {
        print("Window(" + marker + ")");
    }
}

class House
{
    Window w1 = new Window(1);//在構造器之前

    House()
    {
        print("House()");
        w3 = new Window(33);//重新賦值
    }

    Window w2 = new Window(2);//在構造器之後

    void f()
    {
        print("f()");
    }

    Window w3 = new Window(3);//在末尾
}
public class OrderOfInitialization
{
    public static void main(String[] args)
    {
        new House().f();
    }
}/*Output
    Window(1)
    Window(2)
    Window(3)
    House()
    Window(33)
    f()
    *///:~

  在House類中,故意把幾個Window物件的定義散佈到各處,以證明它們全都會在呼叫構造器或其他方法之前得到初始化。此外,w3在構造器內再次被初始化。

  由輸出可見,w3這個引用會被初始化兩次:一次在呼叫構造器前,一次在呼叫期間(第一次引用的物件將被丟棄,並作為垃圾回收)。試想,如果定義了一個過載構造器,它沒有初始化w3;同時在w3的定義裡也沒有指定預設值,那會產生什麼後果呢?所以儘管這種方法似乎效率不高,但它的確能使初始化得到保證。

5.7.2 靜態資料的初始化

  無論建立多少個物件,靜態資料都只佔用一份儲存區域。static關鍵字不能應用於區域性變數,因此它只能作用於域。如果一個域是靜態的基本型別域,且也沒有對它進行初始化,那麼它就會獲得基本型別的標準初值;如果它是一個物件引用,那麼它的預設初始化值就是null

  如果想在定義處進行初始化,採取的方法與非靜態資料沒什麼不同。

  要想了解靜態儲存區域是何時初始化的,就請看下面這個例子:

class Bowl
{
    Bowl(int marker)
    {
        print("Bowl(" + marker + ")");
    }

    void f1(int marker)
    {
        print("f1(" + marker + ")");
    }
}

class Table
{
    static Bowl b1 = new Bowl(1);

    Table()
    {
        print("Table()");
        b2.f1(1);
    }


    void f2(int marker)
    {
        print("f2(" + marker + ")");
    }

    static Bowl b2 = new Bowl(2);
}

class Cupboard
{
     Bowl b3 = new Bowl(3);
     static Bowl b4 = new Bowl(4);

     Cupboard()
     {
         print("Cupboard()");
         b4.f1(2);
     }

     void f3(int marker)
     {
         print("f3(" + marker + ")");
     }

     static Bowl b5 = new Bowl(5);
 }
public class Staticlnitialization
{
    public static void main(String[] args)
    {
        print("在main()中新建一個Cupboard");
        new Cupboard();

        print("在main()中再建一個Cupboard");
        new Cupboard();

        table.f2(1);
        cupboard.f3(1);
    }

    static Table table = new Table();
    static Cupboard cupboard = new Cupboard();

}/*Output
    Bowl(1)
    Bowl(2)
    Table()
    f1(1)

    Bowl(4)
    Bowl(5)
    Bowl(3)
    Cupboard()
    f1(2)

    在main()中新建一個Cupboard
    Bowl(3)
    Cupboard()
    f1(2)

    在main()中再建一個Cupboard
    Bowl(3)
    Cupboard()
    f1(2)

    f2(1)
    f3(1)
    *///:~

  Bowl類使得看到類的建立,而Table類和Cupboard類在它們的類定義中加入了Bowl型別的靜態資料成員。注意,在靜態資料成員定義之前,Cupboard類先定義了一個Bowl型別的非靜態資料成員b3。

  由輸出可見,靜態初始化只有在必要時刻才會進行。如果不建立Table物件,也不引用Table.b1Table.b2,那麼靜態的Bowl b1b2永遠都不會被建立。只有在第一個Table物件被建立(或者第一次訪問靜態資料)的時候,它們才會被初始化。此後,靜態物件不會再次被初始化。

  初始化的順序是先靜態物件(如果它們尚未因前面的物件建立過程而被初始化),而後是“非靜態”物件。從輸出結果中可以觀察到這一點。要執行main()(靜態方法),必須載入Staticlnitialization類,然後其靜態域table和cupboard被初始化,這將導致它們對應的類也被載入,並且由於它們也都包含靜態的Bowl物件,因此Bowl隨後也被載入。這樣,在這個特殊的程式中的所有類在main()開始之前就都被載入了。實際情況通常並非如此,因為在典型的程式中,不會像在本例中所做的那樣,將所有的事物都通過static聯絡起來。

總結一下物件的建立過程,假設有個名為Dog的類:

  1. 即使沒有顯式地使用static關鍵字,構造器實際上也是靜態方法。因此,當首次建立型別為Dog的物件時(構造器可以看成靜態方法),或者Dog類的靜態方法/靜態域首次被訪問時,Java直譯器必須查詢類路徑,以定位Dog.class檔案。
  2. 然後載入Dog.class(後面會學到,這將建立一個Class物件),有關靜態初始化的所有動作都會執行。因此,靜態初始化只在Class物件首次載入的時候進行一次
  3. 當用new Dog()建立物件的時候,首先將在堆上為Dog物件分配足夠的儲存空間。
  4. 這塊儲存空間會被清零,這就自動地將Dog物件中的所有基本型別資料都設定成了預設值(對數字來說就是0,對布林型和字元型也相同),而引用則被設定成了null。
  5. 執行所有出現於欄位定義處的初始化動作。
  6. 執行構造器。正如將在第7章所看到的,這可能會牽涉到很多動作,尤其是涉及繼承的時候。

5.7.3 顯式的靜態初始化

  Java允許將多個靜態初始化動作組織成一個特殊的“靜態子句”(有時也叫做“靜態塊”)。就像下面這樣:

public class Spoon
{
    static int i;
    static 
    {
        i = 47;
    }
}

  儘管上面的程式碼看起來像個方法,但它實際只是一段跟在static關鍵字後面的程式碼。與其他靜態初始化動作一樣,這段程式碼僅執行一次:當首次生成這個類的一個物件時,或者首次訪問屬於那個類的靜態資料成員時(即便從未生成過那個類的物件)。例如:

class Cup
{
    Cup(int marker)
    {
        print