Java程序设计 1 初始化与清理

初始化和清除

"不安全"的编程是造成编程代价昂贵的罪魁祸首之一。有两个安全性问题:初始化和清理。C 语言中很多的 bug 都是因为程序员忘记初始化导致的。尤其是很多类库的使用者不知道如何初始化类库组件,甚至他们必须得去初始化。清理则是另一个特殊的问题,因为当你使用一个元素做完事后就不会去关心这个元素,所以你很容易忘记清理它。这样就造成了元素使用的资源滞留不会被回收,直到程序消耗完所有的资源(特别是内存)。
C++ 引入了构造器的概念,这是一个特殊的方法,每创建一个对象,这个方法就会被自动调用。Java采用了构造器的概念,另外还使用了垃圾收集器(Garbage Collector GC)去自动回收不再被使用的对象所占的资源。这一章将讨论初始化和清理的问题,以及在 Java 中对它们的支持。

利用构造器保证初始化

你可能想为每个类创建一个 initialize() 方法,该方法名暗示着在使用类之前需要先调用它。不幸的是,用户必须得记得去调用它。在 Java 中,类的设计者通过构造器保证每个对象的初始化。如果一个类有构造器,那么 Java 会在用户使用对象之前(即对象刚创建完成)自动调用对象的构造器方法,从而保证初始化。下个挑战是如何命名构造器方法。存在两个问题:第一个是任何命名都可能与类中其他已有元素的命名冲突;第二个是编译器必须始终知道构造器方法名称,从而调用它。C++ 的解决方法看起来是最简单且最符合逻辑的,所以 Java 中使用了同样的方式:构造器名称与类名相同。在初始化过程中自动调用构造器方法是有意义的。

工程构建

mvn archetype:generate "-DgroupId=com.eintr" "-DartifactId=Constructor" "-DarchetypeArtifactId=maven-archetype-quickstart" "-DinteractiveMode=false"
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">

<properties>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
</properties>
<modelVersion>4.0.0</modelVersion>
<groupId>com.eintr</groupId>
<artifactId>Constructor</artifactId>
<packaging>jar</packaging>
<version>1.0-SNAPSHOT</version>
<name>Constructor</name>
<url>http://maven.apache.org</url>
<dependencies>
    <dependency>
        <groupId>junit</groupId>
        <artifactId>junit</artifactId>
        <version>3.8.1</version>
        <scope>test</scope>
    </dependency>
</dependencies>
</project>
package com.eintr;

public class Rock {
    Rock() {
        System.out.println("Rock");
    }
}
package com.eintr;

public class App 
{
    public static void main( String[] args )
    {
        for (int i = 0;i < 10;i++) {
            new Rock();
        }
    }
}
mvn clean package

cd target/classes&&java com.eintr.App

现在,当创建一个对象时:new Rock() ,内存被分配,构造器被调用。构造器保证了对象在你使用它之前进行了正确的初始化。

构造器消除了一类重要的问题,使得代码更易读。例如,在上面的代码块中,你看不到对 initialize() 方法的显式调用,而从概念上来看,initialize() 方法应该与对象的创建分离。在 Java 中,对象的创建初始化是统一的概念,二者不可分割。

构造器没有返回值,它是一种特殊的方法。但它和返回类型为void的普通方法不同,普通方法可以返回空值,你还能选择让它返回别的类型;而构造器没有返回值,却同时也没有给你选择的余地(new表达式虽然返回了刚创建的对象的引用,但构造器本身却没有返回任何值)。如果它有返回值,并且你也可以自己选择让它返回什么,那么编译器就还得知道接下来该怎么处理那个返回值(这个返回值没有接收者)。

方法重载

在 Java (C++) 中,一个因素促使了必须使用方法重载:构造器。因为构造器方法名肯定是与类名相同,所以一个类中只会有一个构造器名。那么你怎么通过不同的方式创建一个对象呢?例如,你想创建一个类,这个类的初始化方式有两种:一种是标准化方式,另一种是从文件中读取信息的方式。你需要两个构造器:无参构造器和有一个 String 类型参数的构造器,该参数传入文件名。两个构造器具有相同的名字——与类名相同。因此,方法重载是必要的,它允许方法具有相同的方法名但接收的参数不同。尽管方法重载对于构造器是重要的,但是也可以很方便地对其他任何方法进行重载。

package com.eintr;

public class App 
{
    public static void main( String[] args )
    {
        Tree t1 = new Tree();
        t1.info();
        t1.info("t1");
        Tree t2 = new Tree(5);
        t2.info();
        t2.info("t2");
    }
}
package com.eintr;

public class Tree {
    int height;
    Tree() {
        System.out.println("种下一粒种");
        height = 0;
    }
    Tree(int initheight) {
        height = initheight;
        System.out.println("栽下一株苗 苗高:"+height);
    }
    void info() {
        System.out.println("Tree is " + height + " m tall");
    }
    void info(String s) {
        System.out.println(s + ": Tree is " + height + " m tall");
    }
}

区分重载方法

如果两个方法命名相同,Java是怎么知道你调用的是哪个呢?有一条简单的规则:每个被重载的方法必须有独一无二的参数列表。你稍微思考下,就会很明了了,除了通过参数列表的不同来区分两个相同命名的方法,其他也没什么方式了。你甚至可以根据参数列表中的参数顺序来区分不同的方法,尽管这会造成代码难以维护。

重载与基本类型

基本类型可以自动从较小的类型转型为较大的类型。

返回值的重载

经常会有人困惑,“为什么只能通过方法名和参数列表,不能通过方法名和返回值区分方法呢?”。例如以下两个方法,它们有相同的命名和参数,但是很容易区分:

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

有些情况下,编译器很容易就可以从上下文准确推断出该调用哪个方法,如 int x = f()。

但是,你可以调用一个方法且忽略返回值。这叫做调用一个函数的副作用,因为你不在乎返回值,只是想利用方法做些事。所以如果你直接调用 f(),Java 编译器就不知道你想调用哪个方法,阅读者也不明所以。因为这个原因,所以你不能根据返回值类型区分重载的方法。为了支持新特性,Java 8 在一些具体情形下提高了猜测的准确度,但是通常来说并不起作用。

无参构造器

如前文所说,一个无参构造器就是不接收参数的构造器,用来创建一个"默认的对象"。如果你创建一个类,类中没有构造器,那么编译器就会自动为你创建一个无参构造器
但是,一旦你显式地定义了构造器(无论有参还是无参),编译器就不会自动为你创建无参构造器。

this关键字

对于两个相同类型的对象 a 和 b,你可能在想如何调用这两个对象的 peel() 方法:


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

如果只有一个方法 peel() ,那么怎么知道调用的是对象 a 的 peel()方法还是对象 b 的 peel() 方法呢?编译器做了一些底层工作,所以你可以像这样编写代码。peel() 方法中第一个参数隐密地传入了一个指向操作对象的

引用。因此,上述例子中的方法调用像下面这样:

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

这是在内部实现的,你不可以直接这么编写代码,编译器不会接受,但能说明到底发生了什么。假设现在在方法内部,你想获得对当前对象的引用。但是,对象引用是被秘密地传达给编译器——并不在参数列表中。
方便的是,有一个关键字: this 。
this关键字只能在非静态方法内部使用。当你调用一个对象的方法时,this 生成了一个对象引用。
你可以像对待其他引用一样对待这个引用。如果你在一个类的方法里调用该类的其他方法,不要使用 this,直接调用即可,this 自动地应用于其他方法上了。

package com.eintr;

public class Tree {
  int height;
  Left leave;

  Tree() {
    System.out.println("种下一粒种");
    height = 0;
  }
  Tree(int initheight) {
    height = initheight;
    System.out.println("栽下一株苗 苗高:"+height);
  }
  void info() {
    System.out.println("Tree is " + height + " m tall");
    Left res = new Left();
    for (int i = 0;i < 10;i++) {
      res = res.inc();
      System.out.println("有叶子 " + res.i + "片");
    }
  }
  void info(String s) {
    System.out.println(s + ": Tree is " + height + " m tall");
  }
}

class Left {
  int i = 0;
  Left inc() {
    i++;
    return this;
  }

  void Leftleft() {
    System.out.println("i=" + i);
  }
}
package com.eintr;

public class App {
  public static void main(String[] args) {
    Tree t1 = new Tree();
    t1.info();
    t1.info("t1");
    Tree t2 = new Tree(5);
    t2.info();
    t2.info("t2");
  }
}

因为 inc() 通过 this 关键字返回当前对象的引用,因此在相同的对象上可以轻易地执行多次操作。

this 关键字在向其他方法传递当前对象时也很有用:

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

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

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

public class PassingThis {
    public static void main(String[] args) {
        new Person().eat(new Apple());
    }
}

Apple 因为某些原因(比如说工具类中的方法在多个类中重复出现,你不想代码重复),必须调用一个外部工具方法 Peeler.peel() 做一些行为。必须使用 this 才能将自身传递给外部方法。

在构造器中调用构造器

当你在一个类中写了多个构造器,有时你想在一个构造器中调用另一个构造器来避免代码重复。你通过 this 关键字实现这样的调用。

通常当你说 this,意味着"这个对象"或"当前对象",它本身生成对当前对象的引用。在一个构造器中,当你给 this 一个参数列表时,它是另一层意思。它通过最直接的方式显式地调用匹配参数列表的构造器:

package com.eintr;

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

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

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

    Flower(String s, int petals) {
        // this(petals);
        this(s);
        this.petalCount = petals; // Another use of "this"
        System.out.println("String & int args");
    }

    Flower() {
        this("hi", 47);
        System.out.println("no-arg constructor");
    }

    void printPetalCount() {
        //- this(11); // Not inside constructor!
        System.out.println("petalCount = " + petalCount + " s = " + s);
    }

}

package com.eintr;

public class App {
  public static void main(String[] args) {
        Flower x = new Flower();
        x.printPetalCount();
  }
}

从构造器 Flower(String s, int petals) 可以看出,其中只能通过 this 调用一次构造器。另外,必须首先调用构造器,否则编译器会报错。这个例子同样展示了 this 的另一个用法。参数列表中的变量名 s 和成员变量名 s 相同,会引起混淆。你可以通过 this.s 表明你指的是成员变量 s,从而避免重复。你经常会在 Java 代码中看到这种用法,同时本书中也会多次出现这种写法。在 printPetalCount() 方法中,编译器不允许你在一个构造器之外的方法里调用构造器。

static 的含义

记住了 this 关键字的内容,你会对 static 修饰的方法有更加深入的理解:static 方法中不会存在 this。你不能在静态方法中调用非静态方法(反之可以)。静态方法是为类而创建的,不需要任何对象。事实上,这就是静态方法的主要目的,静态方法看起来就像全局方法一样,但是 Java 中不允许全局方法,一个类中的静态方法可以访问其他静态方法和静态属性。一些人认为静态方法不是面向对象的,因为它们的确具有全局方法的语义。使用静态方法,因为不存在 this,所以你没有向一个对象发送消息。的确,如果你发现代码中出现了大量的 static 方法,就该重新考虑自己的设计了。然而,static 的概念很实用,许多时候都要用到它。至于它是否真的"面向对象",就留给理论家去讨论吧。

垃圾回收器

程序员都了解初始化的重要性,但通常会忽略清理的重要性。毕竟,谁会去清理一个 int 呢?但是使用完一个对象就不管它并非总是安全的。Java 中有垃圾回收器回收无用对象占用的内存。但现在考虑一种特殊情况:你创建的对象不是通过 new 来分配内存的,而垃圾回收器只知道如何释放用 new 创建的对象的内存,所以它不知道如何回收不是 new 分配的内存。为了处理这种情况,Java 允许在类中定义一个名为finalize()的方法。

finalize()

它的工作原理"假定"是这样的:当垃圾回收器准备回收对象的内存时,首先会调用其 finalize() 方法,并在下一轮的垃圾回收动作发生时,才会真正回收对象占用的内存。所以如果你打算使用 finalize() ,就能在垃圾回收时做一些重要的清理工作。finalize() 是一个潜在的编程陷阱,因为一些程序员(尤其是 C++ 程序员)会一开始把它误认为是 C++ 中的析构函数(C++ 在销毁对象时会调用这个函数)。所以有必要明确区分一下:在 C++ 中,对象总是被销毁的(在一个 bug-free 的程序中),而在 Java 中,对象并非总是被垃圾回收,或者换句话说:

  • 对象可能不被垃圾回收。
  • 垃圾回收不等同于析构。

这意味着在你不再需要某个对象之前,如果必须执行某些动作,你得自己去做。Java 没有析构器或类似的概念,所以你必须得自己创建一个普通的方法完成这项清理工作。例如,对象在创建的过程中会将自己绘制到屏幕上。如果不是明确地从屏幕上将其擦除,它可能永远得不到清理。如果在 finalize() 方法中加入某种擦除功能,那么当垃圾回收发生时,finalize() 方法被调用(不保证一定会发生),图像就会被擦除,要是"垃圾回收"没有发生,图像则仍会保留下来。

也许你会发现,只要程序没有濒临内存用完的那一刻,对象占用的空间就总也得不到释放。如果程序执行结束,而垃圾回收器一直没有释放你创建的任何对象的内存,则当程序退出时,那些资源会全部交还给操作系统。这个策略是恰当的,因为垃圾回收本身也有开销,要是不使用它,那就不用支付这部分开销了。

finalize() 的用途
如果你不能将 finalize() 作为通用的清理方法,那么这个方法有什么用呢?

这引入了要记住的第3点:

  • 垃圾回收只与内存有关。

也就是说,使用垃圾回收的唯一原因就是为了回收程序不再使用的内存。所以对于与垃圾回收有关的任何行为来说(尤其是 finalize() 方法),它们也必须同内存及其回收有关。

但这是否意味着如果对象中包括其他对象,finalize() 方法就应该明确释放那些对象呢?不是,无论对象是如何创建的,垃圾回收器都会负责释放对象所占用的所有内存。这就将对 finalize() 的需求限制到一种特殊情况,即通过某种创建对象方式之外的方式为对象分配了存储空间。不过,你可能会想,Java 中万物皆对象,这种情况怎么可能发生?

看起来之所以有 finalize() 方法,是因为在分配内存时可能采用了类似 C 语言中的做法,而非 Java 中的通常做法。这种情况主要发生在使用"本地方法"的情况下,本地方法是一种用 Java 语言调用非 Java 语言代码的形式(关于本地方法的讨论,见本书电子版第2版的附录B)。本地方法目前只支持 C 和 C++,但是它们可以调用其他语言写的代码,所以实际上可以调用任何代码。在非 Java 代码中,也许会调用 C 的 malloc() 函数系列来分配存储空间,而且除非调用 free() 函数,不然存储空间永远得不到释放,造成内存泄露。但是,free() 是 C 和 C++ 中的函数,所以你需要在 finalize() 方法里用本地方法调用它。

读到这里,你可能明白了不会过多使用 finalize() 方法。对,它确实不是进行普通的清理工作的合适场所。那么,普通的清理工作在哪里执行呢?

记住,无论是"垃圾回收"还是"终结",都不保证一定会发生。如果 Java 虚拟机(JVM)并未面临内存耗尽的情形,它可能不会浪费时间执行垃圾回收以恢复内存。

垃圾回收器如何工作

如果你以前用过的语言,在堆上分配对象的代价十分高昂,你可能自然会觉得 Java 中所有对象(基本类型除外)在堆上分配的方式也十分高昂。然而,垃圾回收器能很明显地提高对象的创建速度。这听起来很奇怪——存储空间的释放影响了存储空间的分配,但这确实是某些 Java 虚拟机的工作方式。这也意味着,Java 从堆空间分配的速度可以和其他语言在栈上分配空间的速度相媲美。

例如,你可以把 C++ 里的堆想象成一个院子,里面每个对象都负责管理自己的地盘。一段时间后,对象可能被销毁,但地盘必须复用。在某些 Java 虚拟机中,堆的实现截然不同:它更像一个传送带,每分配一个新对象,它就向前移动一格。这意味着对象存储空间的分配速度特别快。Java 的"堆指针"只是简单地移动到尚未分配的区域,所以它的效率与 C++ 在栈上分配空间的效率相当。当然实际过程中,在簿记工作方面还有少量额外开销,但是这部分开销比不上查找可用空间开销大。

你可能意识到了,Java 中的堆并非完全像传送带那样工作。要是那样的话,势必会导致频繁的内存页面调度——将其移进移出硬盘,因此会显得需要拥有比实际需要更多的内存。页面调度会显著影响性能。最终,在创建了足够多的对象后,内存资源被耗尽。其中的秘密在于垃圾回收器的介入。当它工作时,一边回收内存,一边使堆中的对象紧凑排列,这样"堆指针"就可以很容易地移动到更靠近传送带的开始处,也就尽量避免了页面错误。垃圾回收器通过重新排列对象,实现了一种高速的、有无限空间可分配的堆模型。

其他语言中的GC机制

要想理解 Java 中的垃圾回收,先了解其他系统中的垃圾回收机制将会很有帮助。一种简单但速度很慢的垃圾回收机制叫做引用计数。每个对象中含有一个引用计数器,每当有引用指向该对象时,引用计数加 1。当引用离开作用域或被置为 null 时,引用计数减 1。因此,管理引用计数是一个开销不大但是在程序的整个生命周期频繁发生的负担。垃圾回收器会遍历含有全部对象的列表,当发现某个对象的引用计数为 0 时,就释放其占用的空间(但是,引用计数模式经常会在计数为 0 时立即释放对象)。这个机制存在一个缺点:如果对象之间存在循环引用,那么它们的引用计数都不为 0,就会出现应该被回收但无法被回收的情况。对垃圾回收器而言,定位这样的循环引用所需的工作量极大。引用计数常用来说明垃圾回收的工作方式,但似乎从未被应用于任何一种 Java 虚拟机实现中。

在更快的策略中,垃圾回收器并非基于引用计数。它们依据的是:对于任意"活"的对象,一定能最终追溯到其存活在栈或静态存储区中的引用。这个引用链条可能会穿过数个对象层次,由此,如果从栈或静态存储区出发,遍历所有的引用,你将会发现所有"活"的对象。对于发现的每个引用,必须追踪它所引用的对象,然后是该对象包含的所有引用,如此反复进行,直到访问完"根源于栈或静态存储区的引用"所形成的整个网络。你所访问过的对象一定是"活"的。注意,这解决了对象间循环引用的问题,这些对象不会被发现,因此也就被自动回收了。

在这种方式下,Java 虚拟机采用了一种自适应的垃圾回收技术。至于如何处理找到的存活对象,取决于不同的 Java 虚拟机实现。其中有一种做法叫做停止-复制(stop-and-copy)。顾名思义,这需要先暂停程序的运行(不属于后台回收模式),然后将所有存活的对象从当前堆复制到另一个堆,没有复制的就是需要被垃圾回收的。另外,当对象被复制到新堆时,它们是一个挨着一个紧凑排列,然后就可以按照前面描述的那样简单、直接地分配新空间了。

当对象从一处复制到另一处,所有指向它的引用都必须修正。位于栈或静态存储区的引用可以直接被修正,但可能还有其他指向这些对象的引用,它们在遍历的过程中才能被找到(可以想象成一个表格,将旧地址映射到新地址)。

这种所谓的"复制回收器"效率低下主要因为两个原因。其一:得有两个堆,然后在这两个分离的堆之间来回折腾,得维护比实际需要多一倍的空间。某些 Java 虚拟机对此问题的处理方式是,按需从堆中分配几块较大的内存,复制动作发生在这些大块内存之间。

其二在于复制本身。一旦程序进入稳定状态之后,可能只会产生少量垃圾,甚至没有垃圾。尽管如此,复制回收器仍然会将所有内存从一处复制到另一处,这很浪费。为了避免这种状况,一些 Java 虚拟机会进行检查:要是没有新垃圾产生,就会转换到另一种模式(即"自适应")。这种模式称为标记-清扫(mark-and-sweep),Sun 公司早期版本的 Java 虚拟机一直使用这种技术。对一般用途而言,"标记-清扫"方式速度相当慢,但是当你知道程序只会产生少量垃圾甚至不产生垃圾时,它的速度就很快了。

标记-清扫所依据的思路仍然是从栈和静态存储区出发,遍历所有的引用,找出所有存活的对象。但是,每当找到一个存活对象,就给对象设一个标记,并不回收它。只有当标记过程完成后,清理动作才开始。在清理过程中,没有标记的对象将被释放,不会发生任何复制动作。"标记-清扫"后剩下的堆空间是不连续的,垃圾回收器要是希望得到连续空间的话,就需要重新整理剩下的对象。

停止-复制指的是这种垃圾回收动作不是在后台进行的;相反,垃圾回收动作发生的同时,程序将会暂停。在 Oracle 公司的文档中会发现,许多参考文献将垃圾回收视为低优先级的后台进程,但是早期版本的 Java 虚拟机并不是这么实现垃圾回收器的。当可用内存较低时,垃圾回收器会暂停程序。同样,"标记-清扫"工作也必须在程序暂停的情况下才能进行。

如前文所述,这里讨论的 Java 虚拟机中,内存分配以较大的"块"为单位。**如果对象较大,它会占用单独的块。**严格来说,"停止-复制"要求在释放旧对象之前,必须先将所有存活对象从旧堆复制到新堆,这导致了大量的内存复制行为。有了块,垃圾回收器就可以把对象复制到废弃的块。每个块都有年代数来记录自己是否存活。通常,如果块在某处被引用,其年代数加 1,垃圾回收器会对上次回收动作之后新分配的块进行整理。这对处理大量短命的临时对象很有帮助。

垃圾回收器会定期进行完整的清理动作——大型对象仍然不会复制(只是年代数会增加),含有小型对象的那些块则被复制并整理。Java 虚拟机会监视,如果所有对象都很稳定,垃圾回收的效率降低的话,就切换到"标记-清扫"方式。同样,Java
虚拟机会跟踪"标记-清扫"的效果,如果堆空间出现很多碎片,就会切换回"停止-复制"方式。这就是"自适应"的由来,你可以给它个啰嗦的称呼:"自适应的、分代的、停止-复制、标记-清扫"式的垃圾回收器。

Java 虚拟机中有许多附加技术用来提升速度。尤其是与加载器操作有关的,被称为"即时"(Just-In-Time, JIT)编译器的技术。这种技术可以把程序全部或部分翻译成本地机器码,所以不需要 JVM 来进行翻译,因此运行得更快。当需要装载某个类(通常是创建该类的第一个对象)时,编译器会先找到其 .class 文件,然后将该类的字节码装入内存。你可以让即时编译器编译所有代码,但这种做法有两个缺点:一是这种加载动作贯穿整个程序生命周期内,累加起来需要花更多时间;二是会增加可执行代码的长度(字节码要比即时编译器展开后的本地机器码小很多),这会导致页面调度,从而一定降低程序速度。另一种做法称为惰性评估,意味着即时编译器只有在必要的时候才编译代码。
这样,从未被执行的代码也许就压根不会被 JIT 编译。新版 JDK 中的 Java HotSpot技术就采用了类似的做法,代码每被执行一次就优化一些,所以执行的次数越多,它的速度就越快。

成员初始化

Java 尽量保证所有变量在使用前都能得到恰当的初始化。对于方法的局部变量,这种保证会以编译时错误的方式呈现,所以如果写成:

void f() {
  int i;
  i++;
}

你会得到一条错误信息,告诉你 i 可能尚未初始化。编译器可以为 i 赋一个默认值,但是未初始化的局部变量更有可能是程序员的疏忽,所以采用默认值反而会掩盖这种失误。强制程序员提供一个初始值,往往能帮助找出程序里的 bug。
}

要是类的成员变量是基本类型,情况就会变得有些不同。正如在"万物皆对象"一章中所看到的,类的每个基本类型数据成员保证都会有一个初始值。

在类里定义一个对象引用时,如果不将其初始化,那么引用就会被赋值为 null。

构造器初始化

可以用构造器进行初始化,这种方式给了你更大的灵活性,因为你可以在运行时调用方法进行初始化。但是,这无法阻止自动初始化的进行,他会在构造器被调用之前发生。因此,如果使用如下代码:

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

i 首先会被初始化为 0,然后变为 7。对于所有的基本类型和引用,包括在定义时已明确指定初值的变量,这种情况都是成立的。因此,编译器不会强制你一定要在构造器的某个地方或在使用它们之前初始化元素——初始化早已得到了保证。,

初始化的顺序

在类中变量定义的顺序决定了它们初始化的顺序。即使变量定义散布在方法定义之间,它们仍会在任何方法(包括构造器)被调用之前得到初始化。

/**
 * @author : eintr
 * @created : 2021-05-24
**/
package com.eintr;

public class House {
  Window w1 = new Window(1);

  House() {
    System.out.println("init House");
    Window w3 = new Window(33);
  }

  Window w2 = new Window(2); 
  void f() {
    System.out.println("f()");
  }

  Window w3 = new Window(3);
}

class Window {
  Window(int marker) {
    System.out.println(marker);
  }
}
package com.eintr;

public class App {
  public static void main(String[] args) {
    House h = new House();
    h.f();
  }
}

静态数据的初始化

无论创建多少个对象,静态数据都只占用一份存储区域。static 关键字不能应用于局部变量,所以只能作用于属性(字段、域)。如果一个字段是静态的基本类型,你没有初始化它,那么它就会获得基本类型的标准初值。如果它是对象引用,那么它的默认初值就是 null。

如果在定义时进行初始化,那么静态变量看起来就跟非静态变量一样。

/**
 * @author : eintr
 * @created : 2021-05-24
**/
package com.eintr;

public class House {
  static Window w1 = new Window(1);

  House() {
    System.out.println("init House");
    Window w3 = new Window(33);
  }

  static Window w2 = new Window(2); 
  void f() {
    System.out.println("f()");
  }

  Window w3 = new Window(3);
}

class Window {
  Window(int marker) {
    System.out.println(marker);
  }
}
package com.eintr;

public class App {
  static House h = new House();
  public static void main(String[] args) {
    new House();
    h.f();
  }
}

初始化的顺序先是静态对象(如果它们之前没有被初始化的话),然后是非静态对象,从输出中可以看出。要执行main()方法,必须加载StaticInitialization类,它的静态属性House随后被初始化,这会导致它们对应的类也被加载,而由于它包含静态的Window对象,所以Windows类也会被加载。因此,在这个特殊的程序中,所有的类都会在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. 执行构造器。你将会在"复用"这一章看到,这可能会牵涉到很多动作,尤其当涉及继承的时候。

显式的静态初始化

你可以将一组静态初始化动作放在类里面一个特殊的"静态子句"(有时叫做静态块)中。像下面这样:

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

这看起来像个方法,但实际上它只是一段跟在static关键字后面的代码块。与其他静态初始化动作一样,这段代码仅执行一次:当首次创建这个类的对象或首次访问这个类的静态成员(甚至不需要创建该类的对象)时。

package com.eintr;

public class Cup {
  static cup c1;
  static cup c2;
  
  static {
    c1 = new cup();
    c1 = new cup(1);
    c2 = new cup(2);
  }

  Cup() {
    System.out.printf("Cup()\n");
  }
}


class cup {
   cup() {
    System.out.printf("cup()\n");
  }
   cup(int marker) {
    System.out.printf("cup(%d)\n", marker);
  }

  void f(int marker) {
    System.out.printf("f(%d)\n", marker);
  }
 
}
package com.eintr;

public class App {
  public static void main(String[] args) {
    Cup.c1.f(99);
  }
}

非静态实例初始化

Java 提供了被称为实例初始化的类似语法,用来初始化每个对象的非静态变量

package com.eintr;

public class Mugs {
  Mug m1;
  Mug m2;

  Mugs() {
    System.out.println("Mugs()");
  }

  Mugs(int i) {
    System.out.printf("Mugs(%d)\n", i);
  }

  {
    m1 = new Mug(1);
    m2 = new Mug(2);
  }
}

class Mug {
  Mug(int i) {
    System.out.printf("Mug(%d)\n", i);
  }

}
package com.eintr;

public class App {
  public static void main(String[] args) {
    Mugs m1 = new Mugs();
    Mugs m2 = new Mugs(2);

  }
}

看起来它很像静态代码块,只不过少了static关键字。这种语法对于支持"匿名内部类"(参见"内部类"一章)的初始化是必须的,但是你也可以使用它保证某些操作一定会发生,而不管哪个构造器被调用。从输出看出,实例初始化子句是在两个构造器之前执行的

数组初始化

数组是相同类型的、用一个标识符名称封装到一起的一个对象序列或基本类型数据序列。数组是通过方括号下标操作符 [] 来定义和使用的。要定义一个数组引用,只需要在类型名加上方括号:

int[] a;

方括号也可放在标识符的后面,两者的含义是一样的:

int a[];

编译器不允许指定数组的大小。这又把我们带回有关"引用"的问题上。你所拥有的只是对数组的一个引用(你已经为该引用分配了足够的存储空间),但是还没有给数组对象本身分配任何空间。为了给数组创建相应的存储空间,必须写初始化表达式。对于数组,初始化动作可以出现在代码的任何地方,但是也可以使用一种特殊的初始化表达式,它必须在创建数组的地方出现。这种特殊的初始化是由一对花括号括起来的值组成。这种情况下,存储空间的分配(相当于使用 new) 将由编译器负责。例如:

int a[] = {1, 2, 3, 4, 5};

在 Java 中可以将一个数组赋值给另一个数组,所以可以这样:

a1 = a2;

其实真正做的只是复制了一个引用,就像下面演示的这样:

public class ArraysOfPrimitives {
    public static void main(String[] args) {
        int[] a1 = {1, 2, 3, 4, 5};
        int[] a2;
        a2 = a1;
        for (int i = 0; i < a2.length; i++) {
            a2[i] += 1;
        }
        for (int i = 0; i < a1.length; i++) {
            System.out.println("a1[" + i + "] = " + a1[i]);
        }
    }
}

a1 初始化了,但是 a2 没有;这里,a2 在后面被赋给另一个数组。由于 a1 和 a2 是相同数组的别名,因此通过 a2 所做的修改在 a1 中也能看到。

所有的数组(无论是对象数组还是基本类型数组)都有一个固定成员 length,告诉你这个数组有多少个元素,你不能对其修改。与 C 和 C++ 类似,Java 数组计数也是从 0 开始的,所能使用的最大下标数是 length - 1。超过这个边界,C 和 C++ 会默认接受,允许你访问所有内存,许多声名狼藉的 bug 都是由此而生。但是 Java 在你访问超出这个边界时,会报运行时错误(异常),从而避免此类问题。

a动态数组创建

如果在编写程序时,不确定数组中需要多少个元素,可以使用 new 在数组中创建元素。如下例所示,使用 new 创建基本类型数组。new 不能创建非数组以外的基本类型数据:

package com.eintr;

import java.util.Random;

public class App {
  public static void main(String[] args) {
    System.out.println();

    int b[];
    Random rand = new Random();
    b = new int[rand.nextInt(20)];
    System.out.println(b.length);
    for (int i : b) {
      System.out.printf("%d ", i);
    }

  }
}

数组的大小是通过 Random.nextInt() 随机确定的,这个方法会返回 0 到输入参数之间的一个值。 由于随机性,很明显数组的创建确实是在运行时进行的。此外,程序输出表明,数组元素中的基本数据类型值会自动初始化为默认值(对于数字和字符是 0;对于布尔型是 false)。Arrays.toString() 是 java.util 标准类库中的方法,会产生一维数组的可打印版本。

如果可能的话,应该尽量这么做。

int b[] = new int[rand.nextInt(20)];
package com.eintr;

import java.util.Random;
import java.util.Arrays;

public class App {
  public static void main(String[] args) {
    Integer b[];
    Random rand = new Random();
    // 进行初始化
    b = new Integer[rand.nextInt(20)];
    System.out.println(b.length);
    for (int i = 0;i < b.length;i++) {
      b[i] = rand.nextInt(300);
    }
    System.out.println(Arrays.toString(b));
  }
}

也可以用花括号括起来的列表来初始化数组,有两种形式:

// housekeeping/ArrayInit.java
// Array initialization
import java.util.Random;
import java.util.Arrays;

public class ArrayInit {
    public static void main(String[] args) {
        Integer[] a = {
                1, 2,
                3, // Autoboxing
        };
        Integer[] b = new Integer[] {
                1, 2,
                3, // Autoboxing
        };
        System.out.println(Arrays.toString(a));
        System.out.println(Arrays.toString(b));

    }
}

尽管第一种形式很有用,但是它更加受限,因为它只能用于数组定义处。第二种形式可以用在任何地方,甚至用在方法的内部。例如,你创建了一个 String 数组,将其传递给另一个类的 main() 方法,如下:

package com.eintr;

public class App {
  public static void main(String[] args) {
    Other.main(new String[]{"Hello", "World", "!!!"});
  }
}

class Other {
  public static void main(String []Args) {
    for (String i : Args) {
      System.out.printf("%s ", i);
    }
  }
}

可变参数列表

你可以以一种类似 C 语言中的可变参数列表(C通常把它称为varargs)来创建和调用方法。这可以应用在参数个数或类型未知的场合。由于所有的类都最后继承于Object类,所以你可以创建一个以 Object 数组为参数的方法,并像下面这样调用:

package com.eintr;

public class VarArgs {
  static void printArray(Object[] args) {
    for (Object obj : args) {
      System.out.println(obj+" ");
    }
    System.out.println();
  }

  public static void main(String Args[]){
    printArray(new Object[] {22, "Hello", 3.14, new A()});
  }
}

class A {}
package com.eintr;

public class VarArgs {
  static void printArray(Object... args) {
    for (Object obj : args) {
      System.out.println(obj+" ");
    }
    System.out.println();
  }

  public static void main(String Args[]){
    printArray(new Object[] {22, "Hello", 3.14, new A(), new B()});
  }
}

class A {}
class B {}

有了可变参数,你就再也不用显式地编写数组语法了,当你指定参数时,编译器实际上会为你填充数组。你获取的仍然是一个数组,这就是为什么 printArray() 可以使用 for-in 迭代数组的原因。但是,这不仅仅只是从元素列表到数组的自动转换。注意程序的倒数第二行,一个 Integer 数组(通过自动装箱创建)被转型为一个 Object 数组(为了移除编译器的警告),并且传递给了 printArray()。显然,编译器会发现这是一个数组,不会执行转换。因此,如果你有一组事物,可以把它们当作列表传递,而如果你已经有了一个数组,该方法会把它们当作可变参数列表来接受。

package com.eintr;

public class VarArgs {
  static void printArray(Object... args) {
    for (Object obj : args) {
      System.out.println(obj+" "+obj.getClass());
    }
    System.out.println();
  }

  public static void main(String Args[]){
    printArray(new Object[] {22, "Hello", 3.14, new A(), new B()});
  }
}

class A {}
class B {}

getClass() 方法属于 Object 类,将在"类型信息"一章中全面介绍。它会产生对象的类,并在打印该类时,看到表示该类类型的编码字符串。前导的 [ 代表这是一个后面紧随的类型的数组,I 表示基本类型 int;为了进行双重检查,我在最后一行创建了一个 int 数组,打印了其类型。这样也验证了使用可变参数列表不依赖于自动装箱,而使用的是基本类型。]

然而,可变参数列表与自动装箱可以和谐共处,如下:

public class AutoboxingVarargs {
    public static void f(Integer... args) {
        for (Integer i: args) {
            System.out.print(i + " ");
        }
        System.out.println();
    }
    
    public static void main(String[] args) {
        f(1, 2);
        f(4, 5, 6, 7, 8, 9);
        f(10, 11, 12);
        
    }
}
public class OverloadingVarargs {
    static void f(Character... args) {
        System.out.print("first");
        for (Character c: args) {
            System.out.print(" " + c);
        }
        System.out.println();
    }
    
    static void f(Integer... args) {
        System.out.print("second");
        for (Integer i: args) {
            System.out.print(" " + i);
        }
        System.out.println();
    }
    
    static void f(Long... args) {
        System.out.println("third");
    }
    
    public static void main(String[] args) {
        f('a', 'b', 'c');
        f(1);
        f(2, 1);
        f(0);
        f(0L);
        //- f(); // Won's compile -- ambiguous
    }
}

枚举类型

Java 5 中添加了一个看似很小的特性 enum 关键字,它使得我们在需要群组并使用枚举类型集时,可以很方便地处理。以前,你需要创建一个整数常量集,但是这些值并不会将自身限制在这个常量集的范围内,因此使用它们更有风险,而且更难使用。枚举类型属于非常普遍的需求,C、C++ 和其他许多语言都已经拥有它了。在 Java 5 之前,Java 程序员必须了解许多细节并格外仔细地去达成 enum 的效果。现在 Java 也有了 enum,并且它的功能比 C/C++ 中的完备得多

public enum Spiciness {
    NOT, MILD, MEDIUM, HOT, FLAMING
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值