您的位置:首页 > 编程语言 > Java开发

Java编程思想 笔记

2008-04-10 13:12 351 查看
第2章 一切都是对象
1、用引用(reference)操纵对象
在Java里一切都被视为对象,因此可采用单一固定的语法操纵数据。尽管一切都“看作”对象,但操纵的标识符实际上是对象的一个“引用”
2、必须由你创建所有对象
一旦创建了一个引用,就希望它能与一个新的对象相连接。我们通常用new关键字来实现这一目的。
2.1、存储到什么地方
1. 寄存器(register)。这是最快的存储区,因为它位于不同于其他存储区的地方——处理器内部。但是寄存器的数量极其有限,所以寄存器由编译器根据需求进行分配。你不能直接控制,也不能在程序中感觉到寄存器存在的任何迹象。
2. 堆栈(stack)。位于通用RAM(random-access memory,随机访问存储器)中,但通过它的“堆栈指针”可以从处理器那里获得直接支持。堆栈指针若向下移动,则分配新的内存;若向上移动,则释放那些内存。这是一种快速有效的分配存储方法,仅次于寄存器。创建程序时,Java编译器必须知道存储在堆栈内所有数据的确切大小和生命周期,因为它必须生成相应的代码,以便上下移动堆栈指针。这一约束限制了程序的灵活性,所以虽然某些Java数据存储于堆栈中——特别是对象引用,但是Java对象并不存储于其中。
3. 堆(heap)。一种通用性的内存池(也存在于RAM区),用于存放所有的Java对象。堆不同于堆栈的好处是:编译器不需要知道要从堆里分配多少存储区域,也不必知道存储的数据在堆里存活多长时间。因此,在堆里分配存储有很大的灵活性。当你需要创建一个对象时,只需用new写一行简单的代码,当执行这行代码时,会自动在堆里进行存储分配。当然,为这种灵活性必须要付出相应的代价。用堆进行存储分配比用堆栈进行存储存储需要更多的时间(如果确实可以在Java中像在C++中一样用栈保存对象)。
4. 静态存储(static storage)。这里的“静态”是指“在固定的位置”(尽管也在RAM里)。静态存储里存放程序运行时一直存在的数据。你可用关键字Static来标识一个对象的特定元素是静态的,但Java对象本身从来不会存放在静态存储空间里。
5. 常量存储(constant storage)。常量值通常直接存放在程序代码内部,这样做是安全的,因为它们永远不会被改变。
6. 非RAM存储(non-RAM storage)。如果数据完全存活于程序之外,那么它可以不受程序的任何控制,在程序没有运行时也可以存在。其中两个基本的例子是“流对象(streamed object)”和“持久化对象(persistent object)”。在“流对象”中,对象转化成字节流,通常被发送给另一台机器。在“持久化对象”中,对象被存放于磁盘上,因此,即使程序终止,它们仍可以保持自己的状态。这种存储方式的技巧在于:把对象转化成可以存放在其它媒介上的事物,在需要时,可恢复成常规的、基于RAM的对象。
2.2、特例:基本类型(primitive type)
有一系列类型经常在程序设计中被用到,它们需要特殊对待。你可以把它们想象成“基本(primitive)”类型。之所以特殊对待,是因为new将对象存储在“堆”里,故用new创建一个对象——特别是小的、简单的变量,往往不是很有效。因此,对于这些类型,Java采取与C和C++相同的方法。也就是说,不用new来创建变量,而是创建一个并非是“引用”的“自动”变量。这个变量拥有它的“值”,并置于堆栈中,因此更加高效。
Java要确定每种基本类型所占存储空间的大小。它们的大小并不像其它大多数语言那样随机器硬件架构的变化而变化。这种所占存储空间大小的不变性是Java程序具有可移植性的原因之一。
基本类型具有的包装器类,使你可以在堆中创建一个非基本对象,用来表示对应的基本类型。
2.3、Java中的数组(Array)
当你创建一个数组对象时,实际上就是创建了一个引用数组,并且每个引用都会自动被初始化为一个特定值,该值拥有自己的关键字null。一旦Java看到null,就知道这个引用还没有指向某个对象。在使用任何引用前,必须为其指定一个对象;如果你试图使用一个还是null的引用,在运行时将会报错。
3、永远不需要销毁对象
3.1、作用域(scoping)
作用域决定了在其内定义的变量名的可见性和生命周期。在C、C++和Java中,作用域由花括号的位置决定
在作用域里定义的变量只可用于作用域结束之前
在C和C++里将一个较大作用域的变量“隐藏”起来的做法,在Java里是不允许的
3.2、对象作用域(scope of object)
Java对象不具备和基本类型一样的生命周期。当你用new创建一个Java对象时,它可以存活于作用域之外。
Java有一个“ 垃圾回收器”,用来监视用new创建的所有对象,并辨别那些不会再被引用的对象。随后,释放这些对象的内存空间,以便供其它新的对象使用。也就是说,你根本不必担心内存回收的问题。你只需要创建对象,一旦不再需要,它们就会自行消失。这样做就消除了这类编程问题:即所谓的“内存溢出”,即由于程序员忘记释放内存而产生的问题。
4、构建一个Java程序
4.1、名字可视性(Name visibility)
为了给一个类库生成不会与其它名字混淆的名字,Java采用了与Internet域名相似的指定符。实际上,Java设计者希望程序员反过来使用自己的Internet域名,因为这样可以保证它们肯定是独一无二的
4.2、运用其它构件
Import指示编译器导入一个包,也就是一个类库
4.3、Static 关键字
当你声明一个事物是Static时,就意味着这个数据或方法不会与包含它的那个类的任何对象实例关联在一起。所以,即使从未创建某个类的任何对象,也可以调用其Static方法或访问其Static数据。通常,你必须创建一个对象,并用它来访问数据或方法。因为非Static数据和方法必须知道它们一起运作的特定对象。由于在用Static方法前,不需要创建任何对象;所以对于Static方法,不能只是简单地通过调用其它方法,而没有指定某个对象,来直接访问非Static成员或方法
尽管当static作用于某个域时,肯定会改变数据创建的方式(因为一个static域对每个类来说都只有一份存储空间,而非static域则是对每个对象有一个存储空间),但是如果static作用于某个方法,差别却没有那么大。Static方法的一个重要用法就是在不创建任何对象的
前提下,就可以调用它。正如我们将会看到的那样,这一点对定义main( )方法时很重要。这个方法是运行一个应用时的入口点。
和其它任何方法一样,static方法可以创建或使用与其类型相同的被命名对象,因此,static方法常常拿来做“牧羊人”的角色,负责看护与其隶属同一类型的
5、注释
Java里有两种注释风格。一种是传统的C语言风格的注释,C++也继承了这种风格。此种注释以“/*”开始,随后是注释内容,并可跨越多行,最后以“*/”结束
第二种风格的注释也源于C++。这种注释是“单行注释”,以一个“//”起头,直到句末
6、编码风格
类名的首字母要大写;如果类名由几个单词构成,那么把它们并在一起(也就是说,不要用下划线来分隔名字),其中每个内部单词的首字母都采用大写形式。这种风格有时称作“驼峰风格(camel-casing)”。几乎其它的所有内容:方法、域(成员变量)以及对象引用名称等,公认的风格与类的风格一样,只是标识符的第一个字母采用小写。
第4章 初始化与清除
随着计算机革命的发展,“不安全”的编程方式已逐渐成为编程代价高昂的主因之一。 “初始化(initialization)”和“清除(cleanup)”正是涉及安全的两个问题
C++引入了“构造器(constructor)”的概念。这是一个在创建对象时被自动调用的特殊方法。Java中也采用了构造器,并额外提供了“垃圾回收器”。对于不再使用的内存资源,垃圾回收器能自动将其释放。
1、以构造器确保初始化
在Java中,通过提供“构造器”这种特殊方法,类的设计者可确保每个对象都会得到初始化。当对象被创建时,将会为对象分配存储空间,如果其类具有构造器,Java就会在用户有能力操作对象之前自动调用相应的构造器,所以初始化动作得以确保。
由于构造器的名称必须与类名完全相同,所以“每个方法首字母小写”的编码风格并不适用于构造器。
从概念上讲,“初始化”与“创建”是彼此独立的,然而在上面的代码中,你却找不到对类似initialize( )方法的直接调用。在Java中,“初始化”和“创建”被捆绑在一起,两者不能分离。
构造器比较特殊,因为它没有返回值
2、方法重载(method overloading)
2.1、区分重载方法
每个重载的方法都必须有一个独一无二的参数类型列表。
2.2、涉及基本类型的重载
基本类型能从一个“较小”的类型自动提升至一个“较大”的类型,此过程一旦牵涉到重载,可能会造成一些混淆
常数值被当作int值处理。所以如果有某个重载方法接受int型参数,它就会被调用。至于其他情况,如果传入的实际参数类型“小于”方法中声明的形式参数类型,实际参数的类型就会被“提升”。char型略有不同,如果无法找到恰好接受char参数的方法,就会把char直接提升至int型。 如果传入的实际参数“较大”,你就得在圆括号里写上类型名称,做必要的类型转换。如果不这样做,编译器就会报错。
2.3、以返回值区分重载方法
根据方法的返回值来区分重载方法是行不通的
2.4、缺省构造器
缺省构造器(又名“无参”构造器)是没有形式参数的。它的作用是创建一个“基本对象”。如果你写的类中没有构造器,则编译器会自动帮你创建一个缺省构造器。但是,如果你已经定义了一个构造器(无论是否有参数),编译器就不会帮你自动创建缺省构造器
2.5、this关键字
为了能用简便、面向对象的语法来编写代码——即“发送消息给对象”,编译器做了一些幕后工作。它暗自把“所操作对象的引用”作为第一个参数传递给f( )。
this关键字只能在方法内部使用,表示对“调用方法的那个对象”的引用。this的用法和其它对象引用并无不同。但要注意,如果在方法内部调用同一个类的方法,就不必使用this,直接调用即可。当前方法中的this引用会自动应用于同一类中的其他方法
只有当你需要明确指出当前对象的引用时,才需要使用this关键字
在构造器中调用构造器
通常你写this的时候,都是指“这个对象”或者“当前对象”,而且它本身表示对当前对象的引用。在构造器中,如果为this添加了参数列表,那么就有了不同的含义:这将产生对符合此参数列表的某个构造器的明确调用。这样,就有了一个直接的途径来调用其它构造器
尽管你可以用this调用一个构造器,但你却不能用相同的方法调用两个构造器。此外,你必须将构造器调用置于最起始处,否则编译器会报错。
除构造器之外,编译器禁止你在其他任何方法中调用构造器。
static的含义
静态方法就是没有this的方法。在“静态方法”的内部不能调用“非静态方法”3,反过来倒是可以的。而且你可以在没有创建任何对象的前提下,仅仅通过类本身来调用静态方法。
3、清除(cleanup):终结(finalization)和垃圾回收(garbage collection)
Java有垃圾回收器来回收无用对象占据的内存资源。但也有特殊情况:假定你的对象(并非使用new)获得了一块“特殊”的内存区域,由于垃圾回收器只知道释放那些经由new分配的内存,所以它不知道该如何释放该对象的这块 “特殊”内存。为了应对这种情况,Java允许你在类中定义一个名为finalize( )的方法。它的工作原理“应该”是这样的:一旦垃圾回收器准备好释放对象占用的存储空间,将首先调用其finalize( )方法,并且在下一次垃圾回收动作发生时,才会真正回收对象占用的内存。所以要是你打算用finalize( ),就能在“垃圾回收时刻”做一些重要的清除工作。
区分一下:在C++中,对象一定会被“销毁”(如果程序中没有错误的话);而Java里的对象却并非总是被“垃圾回收”的。或者换句话说:
1. 对象可能不被回收。
2. 垃圾回收并不等于“析构”。
这意味着在你不再需要某个对象之前,如果必须执行某些动作,那么你得自己去做。Java并未提供“析构函数”或相似的概念,要做类似的清除工作,你必须自己动手创建一个执行清除工作的普通方法
只要程序没有濒临存储空间用完的那一刻,对象占用的空间就总也得不到释放。如果程序执行结束,并且垃圾回收器一直都没有释放你创建的任何对象的存储空间,则随着程序的退出,那些资源会全部交还给操作系统。
3.1、finalize用途
3、垃圾回收只与内存有关
垃圾回收器存在的唯一原因是为了回收程序不再使用的内存。所以对于与垃圾回收有关的任何行为来说(尤其是finalize( )方法),它们也必须同内存及其回收有关
之所以要有finalize( ),是由于你可能在分配内存时,采用了类似C语言中的做法而非Java中的通常做法。这种情况主要发生在使用“本地方法”的情况下,它是在Java中调用非Java代码的一种方式
3.2、你必须执行清除
在C++中,所有对象都会被销毁,或者说, “应该”被销毁。如果在C++中创建了一个局部对象(就是在堆栈上创建,Java中可不行),此时的销毁动作发生在以“右花括号”为边界的、此对象作用域的末尾处进行。如果对象是用new创建的(类似于Java),那么当程序员调用C++的delete( )时(Java没有这个命令),就会调用相应的析构函数。如果程序员忘了,那么永远不会调用析构函数,就会出现内存泄露,对象的其他部分也不会得到清除
相反,Java不允许创建局部对象,你必须使用new。在Java中,也没有“delete”来释放对象,因为垃圾回收器会帮助你释放存储空间。甚至可以肤浅地认为,正是由于垃圾收集机制的存在,使得Java没有析构函数。然而,随着学习的深入,你就会明白垃圾回收器的存在并不能完全代替析构函数。(而且你绝对不能直接调用finalize( ),所以这也不是一个恰当的途径。)如果你希望进行除释放存储空间之外的清除工作,你还是得明确调用某个恰当的Java方法。这就等同于使用析构函数了,而且没有它方便。
无论是“垃圾回收”还是“终结”,都不保证一定会发生。如果Java虚拟机并未面临内存耗尽的情形,它是不会浪费时间在回收垃圾以及恢复内存上的
4、成员初始化
Java尽力保证:所有变量在使用前都能得到恰当的初始化。对于定义于方法内部的局部变量,Java以编译时刻错误的形式来贯彻这种保证
要是类的数据成员是基本类型,情况就会变得有些不同。因为类中的任何方法都可以初始化或用到这个数据,所以强制用户一定得在使用数据前将其初始化成一个适当的值并不现实。然而,任其含有无意义的值同样也是不安全的。因此,一个类的所有基本类型数据成员都会保证有一个初始值。在类里定义一个对象引用时,如果不将其初始化,此引用就会获得一个特殊值null
4.1、指定初始化
在定义类成员变量的地方为其赋值(注意在C++里不能这样做,尽管C++的新手们总想这样做.这种初始化方法既简单又直观。但有个限制:类的每个对象都会具有相同的初值
4.2、构造器初始化
可以用构造器来进行初始化。但要牢记:你无法屏蔽自动初始化的进行,它将在构造器被调用之前发生
初始化顺序
在类的内部,变量定义的先后顺序决定了初始化的顺序。即使变量定义散布于方法定义之间,它们仍旧会在任何方法(包括构造器)被调用之前得到初始化
静态数据的初始化
如果数据是静态的(static),情况并无不同:如果它属于某个基本类型,而且你也没有对它进行初始化,那么它就会获得基本类型的标准初值;如果它是一个对象引用,那么除非你新创建一个对象,并指派给该引用,否则它就是空值(null)。
如果想在定义处进行初始化,采取的方法与非静态数据没什么不同。无论创建多少个对象,静态数据都只占用一份存储区域。但是当你要对这个静态存储区域进行初始化时,问题就来了
初始化的顺序是先“静态”,(如果它们尚未因前面的对象创建过程而被初始化),后“非静态”。
对象的创建过程
假设有个名为Dog的类:
1. 当首次创建类型为Dog的对象时(构造器可以看成静态方法),或者Dog类的静态方法/静态域首次被访问时,Java解释器必须查找类路径,以定位Dog.class文件。
2. 然后载入Dog.class(后面会学到,这将创建一个Class对象),有关静态初始化的动作都会执行。因此,静态初始化只在Class对象首次加载的时候进行一次。
3. 当你用new Dog( )创建对象的时候,首先将在堆上为Dog对象分配足够的存储空间。
4. 这块存储空间会被清零,这就自动地将Dog中的所有基本类型数据设置成了默认值(对数字来说就是0,对布尔型和字符型也相同),而引用则被设置成了null。
5. 执行所有出现于域定义处的初始化动作。
6. 执行构造器。正如你将在第6章中看到的,这可能会牵涉到很多动作,尤其是涉及继承的时候。
明确进行的静态初始化
Java允许将多个静态初始化动作组织成一个特殊的“静态子句”(有时也叫作“静态块”)
尽管上面的代码看起来象个方法,但它实际只是一段跟在static关键字后面的代码。与其他静态初始化动作一样,这段代码仅执行一次:当你首次生成这个类的一个对象时,或者首次访问属于那个类的一个静态成员时。
非静态实例初始化
Java中也有类似于“静态子句”的语法用来初始化每一个对象的非静态变量,看起来它与静态初始化子句一模一样,只不过少了static关键字
5、数组初始化
数组只是相同类型的、用一个标识符名称封装到一起的一个对象序列或基本类型数据序列。数组是通过方括号索引操作符[ ]来定义和使用的。
int[] a;
a也是一个引用变量,为了给数组创建相应的存储空间,你必须写初始化表达式。对于数组,初始化动作可以出现在代码的任何地方,但也可以使用一种特殊的初始化表达式,它必须在创建数组的地方出现。这种特殊的初始化是由一对花括号括起来的值组成的。在这种情况下,存储空间的分配(等价于使用new)将由编译器负责
所有数组(无论它们的元素是对象还是基本类型)都有一个固有成员,你可以通过它获知数组内包含了多少个元素,但不能对其修改。这个成员就是length
如果在编写程序时,并不能确定在数组里需要多少个元素,可以在运行时直接用new初始化数组,当然,数组也可以在定义的同时进行初始化。
如果数组里的元素不是基本数据类型,那么你必须使用new。在这里,你会再次遇到引用问题,因为你创建的数组里每个元素都是一个引用。直到通过创建新的对象,并且把对象赋值给引用,初始化进程才算结束。如果你忘记了创建对象,并且试图使用数组中的空引用,就会在运行时刻产生“异常”。也可以用花括号括起来的列表来初始化对象数组
第5章 隐藏具体实现
1、包(package):程序库单元
使用关键字import来导入整个程序库或单一的类
名字之间的潜在冲突使得在java中对名称空间进行完全控制,并能够不受Internet的限制创建唯一的名字就成为了非常重要的事情。我们之所以要导入,就是要提供一个管理名字空间(name spaces)的机制
如果你编写一个java源代码文件,此文件通常被称为编译单元(compilation unit)(有时也被称为转译单元(translation unit))。每个编译单元都必须有一个后缀名.java,而在编译单元之中则可以有一个public类,该类的名称必须于文件的名称相同(包括大小写,但不包括文件的后缀名.java)。每个编译单元只能有一个public类,否则编译器就不会接受。如果在该编译单元之中还有额外的类的话,那么在包之外的世界是无法看见这些类的,这是因为它们不是public类,而且它们主要是被用于为主public类提供支持。
当你编译一个.java文件时,在.java文件中每个类都会有一个输出文件,而该输出文件的名称与.java文件中每个类的名称又恰好相同,只是多了一个后缀名.class。因此,你在编译少量.java文件之后,会得到大量的.class文件。如果你已经用编译型语言编写过程序,那么对于编译器产生一个中间文件(通常是一个“obj”文件),然后再与通过链接器(linker,用以创建一个可执行文件)或程序库产生器(librarian,用以创建一个程序库)产生的其它同类文件捆绑在一起的情况,你可能早已习惯。但这并不是java的工作方式。一个java可运行程序是一组可以打包并压缩为一个java 文档文件(JAR,用Java的 jar文档生成器)的. class文件。Java解释器(interpreter)负责对这些文件的查找、装载和解释。
程序库实际上是一组类文件。其中每个文件都有一个public类(并非强制的,但这很典型),因此每个文件都是一个构件(component)。如果你希望这些构件(每一个都有它们自己分离开的.java和.class文件)从属于同一个群组,就可以使用关键字package。
当你在文件起始处写道:
package mypackage;
就表示你在声明该编译单元是名为mypackage的程序库的一部分(如果使用了一个package语句,就它必须是文件中除注释以外的第一句程序代码)。或者,换种说法,你正在声明该编译单元中的public类名称是位于mypackage名称的遮蔽之下。任何想要使用该名称的人都必须指定全名或是在与mypackage的结合中使用关键字import
请注意,Java 包的命名规则全部使用小写字母,包括中间的字也是如此。
package 和import关键字允许你做的,是将单一的全局名字空间分割开,使得无论多少人使用Internet并用java编写类,都不会出现名称冲突问题
1.1、创建独一无二的包名
既然一个包从未真正将被打包的东西包装成一个单一的文件,并且一个包可以由许多文件构成,那么情况就有点复杂了。为了避免这种情况的发生,一种合乎逻辑的作法就是将特定包的所有.class文件都置于一个单一目录之下。也就是说,利用操作系统的层次化的文件结构来解决这一问题。这是java解决混乱问题的一种方式
Java解释器(interpreter)的运行过程如下:首先,找出环境变量CLASSPATH,CLASSPATH包含一个或多个目录,用来作为查找.class文件的根目录。从根目录开始,解释器获取包的名称并将每个句点替换成反斜杠以从CLASSPATH 根中产生一个路径名称得到的路径会与CLASSPATH中的各个不同的项相连接,解释器就在这些目录中查找与你所要创建的类相关的名称的.class文件。(解释器还会去查找某些相对于它所在位置的标准目录)
2、Java访问权限修饰词
public, protected和 private这几个java访问权限修饰词在使用时,是置于你的类中每个成员的定义之前的,无论它是一个域或是一个方法。每个访问权限修饰词仅控制它所修饰的特定定义的访问权。
2.1、包访问权限
默认访问权限没有任何关键字,但通常是指包访问权限。这就意味着当前的包中的其他类对那个成员有访问权限,但对于在这个包之外的所有类,这个成员却是private。
由于一个编译单元,即一个文件,只能隶属于一个单一的包,所以经由包访问权限,处于某个单一编译单元之中的所有类彼此之间都是自动可访问的。
2.2、public:接口访问权限
当你使用关键字public,就意味着public之后紧跟着的成员声明对每个人都是可用的,尤其是使用程序库的客户端程序员更是如此
缺省包
Java将同处于一个相同的目录并且没有给自己设定任何包名称的文件,自动看作是隶属于该目录的缺省包之中,于是它们为该目录中所有其他的文件都提供了包访问权限。
2.3、Private:私有访问权
关键字private的意思是,除了包含该成员的类之外,其他任何类都是无法访问这个成员的
2.4、Protected:继承访问权
有时,基类的创建者会希望获得一个特定的成员,并赋予派生类,而不是所有类以访问权。这就需要protected来完成这一工作。protected也提供包访问权限,也就是说,相同包内的其他类可以访问protected元素
3、类的访问权限
在java中,访问权限修饰词也可以用于确定在某个程序库中的类哪些对于该库的使用者是可用的。
为了控制某个类的访问权限,修饰词必须出现于关键字class之前。
1. 每个编译单元(文件)都只能有一个public类。这表示,每个编译单元都有一个单一的公共接口,用public类来表现。
2. public类的名称必须完全与含有该编译单元的文件名相匹配,包括大小写。
3. 虽然不是很常用,但编译单元内完全不带public类也是可能的。在这种情况下,你可以随意对文件命名。
如果你没能为类访问权限指定一个访问修饰符,它就会缺省得到包访问权限。这就意味着该类的对象可以由包内任何其他类来创建,但在包外则是不行的。(一定要记住,相同目录下的所有不具有明确package声明的文件,都被视作是该目录下缺省包的一部分。)然而,如果该类的某个static成员是public的话,则客户端程序员仍旧可以调用该static成员,哪怕是他们并不能生成该类的对象。
第6章 复用类
组合:在新的类中产生现有类的对象,新的类是由现有类的对象组成
继承:按照现有类型来创建新类,无需改变旧有类的形式,仅仅只是采用它的形式并在其中添加新代码
1、组合语法
仅需将对象引用置于新类之中即可
类中的基本类型数据能够自动被初始化为零。但是对象引用会被初始化为null,而且如果你试图为它们调用任何方法,都会得到一个异常(exception)。
编译器并不是简单地为每一个引用都创建缺省对象,这一点是很有意义的,因为真要是那样做的话,就会在许多情况下增加不必要的负担。如果你想初始化这些引用,可以在代码中的下列位置进行:
1. 在定义对象的地方。这意味着它们总是能够在构造器被调用之前被初始化。
2. 在类的构造器中。
3. 就在你确实需要使用这些对象之前
2、继承语法
在继承过程中,你需要先声明:“新类与旧类相似。”通常,你首先给类确定一个名称,但在书写类主体的左边花括号之前,应先写下关键字extends,并在其后写下基类的名称。当你这么做时,会自动得到基类中所有的数据成员和成员方法。
为了继承,一般的规则是将所有的数据成员都指定为private,将所有的方法指定为public
在子类中对基类中定义的方法进行修改是可行的,在子类中修改过的方法调用从基类继承而来的方法,需要使用Java提供的关键字super,关键字super指代的是当前类的父类
在继承的过程中,你并不一定非得使用基类的方法。你也可以在导出类中添加新方法,其添加方式与在类中添加任意方法一样,即对其加以定义即可
2.1、初始化基类
当你创建了一个导出类的对象时,该对象包含了一个基类的子对象(subobject)。这个子对象与你用基类直接创建的对象是一样的。二者区别在于,后者来自于外部,而基类的子对象被包装在导出类对象内部。
对基类子对象的正确初始化也是至关重要的,而且也仅有一种方法来保证这一点:在构造器中调用具有执行基类初始化所需要的所有知识和能力的基类构造器来执行初始化。Java会自动在导出类的构造器中插入对基类构造器的调用
构建过程是从基类“向外”扩散的,所以基类在导出类构造器可以访问它之前,就已经完成了初始化
2.2、带参数的构造器
如果你的类没有缺省的参数,或者是你想调用一个带参数的基类构造器,你就必须用关键字super显式地编写调用基类构造器的语句,并且配以适当的参数列表
如果你不在导出类中调用基类构造器,编译器将“抱怨”无法找到符合缺省构造形式的构造器。而且,调用基类构造器必须是你在导出类构造器中要做的第一件事
3、结合使用组合与继承
3.1、确保正确清除
Java中没有C++的析构函数的概念,析构函数是一种在对象被销毁时可以被自动调用的函数。其原因可能是在Java中,我们的习惯只是忘掉而不是销毁对象,并且让垃圾回收器在必要时释放其内存
有时你的类可能要在其生命周期内执行一些必需的清除活动。但你并不知道垃圾回收器何时将会被调用,或者它是否将被调用。因此,如果你想要某个类清除一些东西,就必须显式地编写一个特殊方法来做这件事,并要确保客户端程序员知晓他们必须要调用这一方法,其首要任务就是将这一清除动作置于finally字句之中,以预防异常的出现。
清除最好的办法是除了内存以外,不要依赖垃圾回收器去做任何事,如果你需要进行清除,最好是编写你自己的清除方法,但不要依赖于finalize()
3.2、名字屏蔽
如果Java的基类拥有某个已被多次重载的方法名称,那么在导出类中重新定义该方法名称,并不会屏蔽其在基类中的任何版本(这一点与C++不同)。因此,无论是该层或者它的基类中对方法进行定义,重载机制都可以正常工作
4、组合与继承之间选择
组合和继承都允许你在新的类中设置子对象(subobject),组合是显式地这样做的,而继承则是隐式的。
组合技术通常用于你想要在新类中使用现有类的功能而非它的接口的情形
在继承的时候,你会使用某个现有类,并开发一个它的特殊版本。通常,这意味着你在使用一个通用性(general-purpose)的类,并为了某种特殊需要而将其特殊化
5、受保护的
将某些事物尽可能对这个世界隐藏起来,但仍然允许让导出类的成员访问它们。关键字protected就是对这一实用主义的首肯。它指明“就类用户而言,这是private,但对于任何继承于此类的导出类或其他任何位于同一个包内的类来说,却是可以进行访问的。”(在Java中,protected也提供了包内访问权限。)
6、向上转型
“为新的类提供方法”并不是继承技术中最重要的一个方面。其最重要的方面是它被用来表现新类和基类之间的关系。这种关系可以用“新类是现有类的一种类型”这句话来加以概括。
由导出类转型成基类,在继承图上是向上移动的,因此一般称为“向上转型”由于向上转型是从一个较专用类型向较通用类型转,所以总是很安全的。也就是说,导出类是基类的一个超集,它可能比基类含有更多的方法,但它必须至少具备基类中所含有的方法。在向上转型过程中,对于类接口唯一有可能发生的事情是丢失方法,而不是获取他们。这就是为什么编译器在“未曾明确表示转型”或“未曾指定特殊标记”的情况下,仍然允许向上转型的原因
7、关键字 final
根据上下文环境,Java的关键字final的含义存在着细微的区别,但通常它指的是“这是无法改变的。”你可能出于两种理由而需要阻止改变:设计或效率
7.1、final数据
许多编程语言都有某种方法,来向编译器告知一块数据是恒定不变的。由于以下两种原因,数据的恒定不变是很有用的:
1. 它可以是一个永不改变的“编译期常量(compile-time constant)”。
2. 它可以是一个在运行期被初始化的值,而你不希望它被改变。
在编译期常量的情况下,编译器可以将该常量值带入任何可能用到它的计算式中。就是说,可以在编译期执行计算式,减轻了一些运行期的负担。在Java中,这类常量必须是原始的并且以关键字final表示。在对这个常量进行定义的时候,必须对其进行赋值
我们不能因为某数据是final的就认为在编译期可以知道它的值,final数据也可以在运行期初始化
空白 final
Java允许生成空白final(Blank final),所谓空白final是指被声明为final但又未给定初值的数据成员。无论什么情况,编译器都确保空白 final在使用前必须被初始化。但是,空白final在关键字final的使用上提供了更大的灵活性,为此,一个类中的final数据成员就可以实现依对象而有所不同,却又保持其恒定不变的特性。
final 参数
Java允许你以在参数列表中以声明的方式将参数指明为final。这意味着你在方法中可以读参数,但无法修改参数
7.2、final方法
使用final方法的原因有两个:
第一个原因是把方法锁定,以预防任何继承类修改它的意义。这是出于设计的考虑:你想要确保在继承中方法行为保持不变,并且不会被重载。
使用final方法的第二个原因是效率。如果你将一个方法指明为final,就是同意编译器将针对该方法的所有调用都转为内嵌(inline)调用。
final和private
类中所有的private方法都被隐含是final的。由于你无法取用private方法,所以你也无法重载之
“重载”只有在某方法是基类的接口的一部分时才会出现。即,你必须能将一个对象向上转型为它的基本类型并调用相同的方法.如果某方法为private,它就不是基类的接口的一部分。它仅是一些隐藏于类中的程序代码,不过具有相同的名称而已。如果在导出类中以相同的名称生成一个public、protected或包访问权限(package access)方法的话,并没有重载方法,仅是生成了一个新的方法。
7.3、final类
当你将某个类的整体定义为final时(通过将关键字final置于它的定义之前),你就声明了你不打算继承该类,而且也不允许别人这样做
final类的数据成员可以是final,也可以不是。不论类是否被定义为final,相同的规则都适用于final的数据成员。然而,由于final类禁止继承,所以final 类中所有的方法都被隐含是final的,因为它们是不会被重载的
8、初始化及类的加载
在许多传统语言中,其程序是作为启动过程的一部分立刻被加载的,然后是初始化,紧接着程序开始运行。这些语言的初始化过程必须小心控制,以确保static的初始化顺序不会造成麻烦
Java采用了一种不同的加载方式。在Java中,每个类的编译代码都存在于它自己的独立的文件中。该文件只在需要使用程序代码时才会被加载。一般来说,你可以说:“类的代码在初次使用时才加载。”这通常是指知道类的第一个对象被构建时才发生加载,但是当访问static数据成员或是static方法时,也会发生加载。
初次使用之处也是静态初始化(static初始化)发生之处。所有的static对象和static代码段都会在加载时依程序中的顺序(即,你定义类时的书写顺序)依次初始化。当然,static只会被初始化一次。
继承与初始化
你在导出类上运行Java时,所发生的第一件事情就是你试图访问导出类的main( )(一个static方法),于是加载器开始启动并找出导出类被编译的程序代码(它被编译到了一个名为导出类名 .class的文件之中)。在对它进行加载的过程中,编译器注意到它有一个基类(这是由关键字extends告知的),于是它继续进行加载。不管你是否打算产生一个该基类的对象,这都要发生。如果该基类还有其自身的基类,那么第二个基类就会被加载,如此类推。接下来,根基类中的静态初始化(在此例中为Insect)即会被执行,然后是下一个导出类,以此类推。这种方式很重要,因为导出类的静态初始化可能会依赖于基类成员能否被正确初始化的。
至此为止,必要的类都已加载完毕,对象就可以被创建了。首先,对象中所有的原始类型都会被设为缺省值,对象引用被设为零——这是通过将对象内存设为二进制零值而一举生成的。然后,基类的构造器会被调用。基类构造器和导出类的构造器一样,以相同的顺序来经历相同的过程。在基类构造器完成之后,实例变量(instance variables)按其次序被初始化。最后,构造器的其余部分被执行
第7章 多态
多态通过分离“做什么”和“怎么做”,从另一角度将接口和实现分离开来
多态方法调用允许一种类型表现出与其他相似类型之间的区别,只要它们都是从同一基类导出而来的。这种区别是根据方法行为的不同来而表示出来的,虽然这些方法都可以通过同一个基类来调用。
1、向上转型
对象既可以作为它自己本身的类型使用,也可以作为它的基类型使用。而这种将对某个对象的引用视为对其基类型的引用的做法被称作“向上转型(upcasting)”――因为在继承树的画法中,基类是放置在上方的
忘记对象类型
如果我们只写这样一个简单方法,它仅接收基类作为参数,而不是那些特殊的导出类。也就是说,如果我们不管导出类的存在,编写的代码只是与基类打交道,这正是多态所允许的
2、曲解
对于一个接受基类引用作为形式参数的方法而言,在方法执行时,编译器怎样知道这个基类引用指向的是哪一个导出类对象。为了深入理解这个问题,有必要研究一下“绑定”这个话题
2.1、方法调用绑定
将一个方法调用同一个方法主体关联起来被称作“绑定(binding)”。
若在程序执行前进行绑定(如果有的话,由编译器和链接程序实现),叫做“前期绑定(early binding)”
“后期绑定(late binding)”,的含义就是在运行时,根据对象的类型进行绑定。如果一种语言想实现后期绑定,就必须具有某些机制,以便在运行时能判断对象的类型,以调用恰当的方法。也就是说,编译器仍不知道对象的类型,但是方法调用机制能找到正确的方法体,并加以调用。后期绑定机制随编程语言的不同而有所不同,但是我们只要想象一下就会得知,不管怎样都必须在对象中安置某种“类型信息”
Java中除了static和final方法(private方法属于final)之外,其他所有的方法都是后期绑定。这意味着通常情况下,我们不必判定是否应该进行后期绑定---它会自动发生。
2.2、扩展性
在一个设计良好的OOP程序中,我们的大多数或者所有方法只与基类接口通信。我们说这样的程序是“可扩展的”,因为我们可以从通用的基类继承出新的数据类型,从而新添一些功能。那些操纵基类接口的如方法不需要任何改动就可以应用于新类
2.3、缺陷:“重载”私有方法
private方法被自动认为就是final方法,而且对于导出类是屏蔽的。在这种情况下,导出类中与基类函数原形相同的方法就是一个全新的方法
结论就是:只有非private方法才可以被重载;但是我们还需要密切注意重载private方法的现象,虽然编译不会出错,但是不会按照我们所期望的来执行。明白地说,在导出类中,对于基类中的private方法,我们最好用一个不同的名字。
3、抽象类和抽象方法
  基类中的方法往往是“哑”方法,若要调用这些方法,就会出现一些错误。基类的目的是为它的所有导出类创建一个通用接口
建立这个通用接口的唯一原因是,不同的子类可以用不同的方式表示此接口。它建立起一个基本形式,用来表示所有导出类的共同部分。另一种说法是将这种含有“哑”方法的类称作“抽象基类”。当我们想通过这个通用接口操纵一系列类时,就需创建一个抽象类。与任何基类所声明的签名相符的导出类方法,都会通过动态绑定机制来调用。(如果导出类中方法名与基类的相同,但是参数不同,就会出现重载)
建立这种“抽象基类”的对象,几乎没有任何意义。也就是说抽象基类只是表示了一个接口,没有具体的实现内容,因此,创建一个“抽象基类”的对象没有什么意义,并且我们可能还想阻止使用这样做。通过在“抽象基类”的所有方法中打印错误信息,就可以实现这个目的,但这样做会将错误信息延迟到运行期才可获得。所以,最好是在编译器捕获这些问题
抽象方法
Java供一个叫做“抽象方法(abstract method)”的机制。这种方法是不完整的;仅有声明而没有方法体。
抽象类
包含抽象方法的类叫做“抽象类(abstract class)”。如果一个类包含一个或多个抽象方法,该类必须被限制为是抽象的。(否则,编译器就会报错)
由于为一个抽象类创建对象是不安全的,所以我们会从编译器那里得到一条出错信息。这里,编译器会确保抽象类的纯粹性,我们不必担心会误用它
如果从一个抽象类继承,并想创建该新类的对象,那么我们就必须为基类中的所有抽象方法提供方法定义。如果不这样做(可以选择不做),那么导出类便也是抽象类,且编译器将会强制我们用abstract关键字来限制修饰这个类。
我们也可能创建一个没有任何抽象方法的抽象类
创建抽象类和抽象方法非常有用,因为它们可以显化一个类的抽象性,并告诉用户和编译器怎样按照它所预期的方式来使用
4、构造器和多态
尽管构造器并不具有多态性(它们实际上是Static方法,只不过该Static声明是隐式的),但还是非常有必要理解构造器怎样通过多态在复杂的层次结构中运作
4.1、构造器的调用顺序
基类的构造器总是在导出类的构造过程中被调用,而且按照继承层次逐渐向上链接,以使每个基类的构造器都能得到调用。这样做是有意义的,因为构造器具有一项特殊任务:检查对象是否被正确地构造。导出类只能访问它自己的成员,不能访问基类中的成员(基类成员通常是private类型)。只有基类的构造器才具有恰当的知识和权限对自己的元素进行初始化。因此,必须令所有构造器都得到调用,否则所有对象就不可能被正确构造。这正是编译器为什么要强制每个导出类部分都必须调用构造器的原因。在导出类的构造器主体中,如果我们没有明确指定调用某个基类构造器,它就会“默默”地调用缺省构造器。如果不存在缺省构造器,编译器就会报错
复杂对象调用构造器的顺序:
1. 调用基类构造器。这个步骤会不断地反复递归下去,首先是构造这种层次结构的根,然后是下一层导出类,等等。直到最低层的导出类。
2. 按声明顺序调用成员的初始状态设置模块。
3. 调用导出类构造器的主体。
4.2、继承与清除
某个子对象要依赖于其他对象,清除处理的顺序应该和初始化顺序相反。对于属性,则意味着与声明的顺序相反(因为属性的初始化是按照声明的顺序进行)。对于基类(遵循C++中析构函数的形式),我们应该首先对其导出类进行清除,然后才是基类。这是因为导出类的清除可能会调用基类中的某些方法,所以需要使基类中的构件仍起作用而不应过早地销毁她。
4.3、构造器内部多态方法的行为
如果要调用构造器内部的一个动态绑定方法,就要用到那个方法的被重载的定义。然而,产生的效果可能相当难于预料,并且可能造成一些难于发现的隐藏错误。
从概念上讲,构造器的工作实际上是创建对象。在任何构造器内部,整个对象可能只有部分形成——我们只知道基类对象已经进行初始化,但却不知道哪些类是从我们这里继承而来的。然而,一个动态绑定的方法调用却会向外深入到继承层次结构内部。它可以调用导出类里的方法。如果我们是在构造器内部这样做,那么我们可能会调用某个方法,而这个方法所操纵的成员可能还未进行初始化——这肯定是招惹灾难的倪端
初始化的实际过程是:
1. 在其他任何事物发生之前,将分配给对象的存储空间初始化成二进制的零。
2. 如前所述的那样,调用基类构造器。
3. 按照声明的顺序调用成员的初始化代码。
4. 调用导出类的构造器主体
编写构造器时有一条有益的规则:“用尽可能简单的方法使对象进入正常状态;如果可以的话,避免调用其他方法”。在构造器内唯一能够安全调用的那些方法是基类中的final方法(也适用于private方法,它们自动属于final方法)。这些方法不能被重载,因此也就不会出现上述令人惊讶的问题
5、用继承进行设计
组合不会强制我们的程序设计进入继承的层次结构中。同时,组合更加灵活,因为它可以动态选择类型(因此,也就是选择了行为),相反,继承在编译期间就需要知道确切类型
5.1、纯继承与扩展
纯继承——只有在基类或接口中已经建立的方法才可以在导出类中被重载
扩展——导出类就像是一个基类,它有着相同的基本接口,但是它还具有由额外方法实现的其他特性
5.2、向下转型与运行期类型识别
由于我们在向上转型(在继承层次中向上移动)过程中丢失了具体的类型信息,所以我们就可以用向下转型——也就是在继承层次中向下移动——从而检索到类型信息
在Java语言中,所有转型都会得到检查,所以即使我们只是进行一次普通的加括弧形式的强制转换,在进入运行期时,仍然会对其进行检查,以便保证它的确是我们希望的那种类型。如果不是,就会返回一个ClassCastException(转型异常)。这种在运行期间对类型进行检查的行为称作“运行期类型识别”(RTTI)
第9章 异常与错误处理
1、基本异常
“异常情形”(exceptional condition)是指引发阻止当前方法或作用域继续执行的问题。把异常情形与普通问题相区分很重要,这里的普通问题是指,你在当前环境下能得到足够的信息,总能处理这个错误。而对于异常情形,你就不能继续下去了,因为你在当前环境下无法获得必要的信息来解决问题。你所能做的就是从当前的环境中跳出,并且把问题提交给上一级别的环境。这就是抛出异常时所发生的事情。
当你抛出异常后,有几件事会随之发生。首先,同Java中其它对象的创建一样,将使用new在堆上创建异常对象。然后,当前的执行路径(你不能继续下去了)被终止,并且从当前环境中弹出异常对象的引用。此时,异常处理机制接管程序,并开始寻找一个恰当的地方来继续执行程序。这个恰当的地方就是“异常处理程序”(exception handler),它的任务是将程序从错误状态中恢复:以使程序能要么换一种方式运行,要么继续运行下去。
异常形式参数
与Java中的其它对象一样,你总是用new在堆上创建异常对象,这也伴随着存储空间的分配和构造器的调用。所有标准异常类都有两个构造器:一个是缺省构造器;另一个是接受字符串作为参数,用来把相关信息放入异常对象的构造器
2、捕获异常
如果方法要抛出异常,它必须假定异常将被“捕获”并得到处理。异常处理的好处之一就是,使你得以先在一个地方专注于正在解决的问题,然后在别的地方处理这些代码中可能发生的错误。
2.1、Try块
有了异常处理机制,你可以把所有动作都放在try区块里,然后只需在一个地方就可以捕获所有异常。这意味着代码将更容易被编写和阅读,因为完成任务的代码没有与错误检查的代码混在一起。
2.2、异常处理程序
当然,抛出的异常必须在某处得到处理。这个“地点”就是“异常处理程序”(exception handler),针对每个要捕获的异常,你得准备相应的处理程序。异常处理程序紧跟在try区块之后,以关键字catch表示
每个catch子句(异常处理程序)看起来就像是仅仅接受一个特定参数的方法。可以在处理程序的内部使用标识符(id1,id2等等),这与方法参数的使用很相似。有时你可能用不到标识符,因为异常的类型已经给了你足够的信息来对异常进行处理,但标识符并不可以省略。
异常处理程序必须紧跟在try块之后。当异常被抛出时,异常处理机制将负责搜寻参数与异常类型相匹配的第一个处理程序。然后进入catch子句执行,此时认为异常得到了处理。一旦catch子句结束,则处理程序的查找过程结束。注意,只有匹配的catch子句才能得到执行;这与switch语句不同,switch语句需要你在每一个case后面跟一个break,以避免执行后续的case子句。
注意在try块的内部,不同的方法调用可能会产生类型相同的异常,你只需要提供一个针对此类型的异常处理程序。
终止与恢复
异常处理理论上有两种基本模型。一种称为“终止模型”(它是Java和C++所支持的模型)。在这种模型中,将假设错误非常关键,以至于程序无法返回到异常发生的地方继续执行。一旦异常被抛出,就表明错误已无法挽回,也不能回来继续执行。
另一种称为“恢复模型”。意思是异常处理程序的工作是修正错误,然后重新尝试调用出问题的方法,并认为第二次能成功。对于恢复模型,你希望异常被处理之后,能继续执行程序。
3、创建自定义异常
要自己定义异常类,你必须从已有的异常类继承
4、异常说明
Java提供了相应的语法(并强制你使用这个语法),使你能以礼貌的方式告知客户端程序员某个方法可能会抛出的异常类型,然后客户端程序员就可以进行相应的处理。这就是“异常说明”(exception specification),它属于方法声明的一部分,紧跟在形式参数列表之后。
异常说明使用了附加的关键字throws,后面接一个所有潜在异常类型的列表
你的代码必须与异常说明保持一致。如果方法里的代码产生了异常却没有进行处理,编译器会发现这个问题并提醒你:要么处理这个异常,要么就在异常说明中表明此方法将产生异常。通过这种自顶向下强制执行的异常说明机制,Java在编译期就可以保证相当程度的异常一致性。
这种在编译期被强制检查的异常称为“被检查的异常”
5、捕获所有异常
你可以只写一个异常处理程序来捕获所有类型的异常。通过捕获异常类型的基类Exception,就可以做到这一点
5.1、重新抛出异常
有时你希望把刚捕获的异常重新抛出,尤其是在使用Exception捕获所有异常的时候。既然你已经得到了当前异常对象的引用,你可以直接把它重新抛出
重抛异常会把异常抛给上一级环境中的异常处理程序。同一个try块的后续catch子句将被忽略。此外,异常对象的所有信息都得以保持,所以高一级环境中捕获此异常的处理程序可以从这个异常对象中得到所有信息
如果你只是把当前异常对象重新抛出,那么printStackTrace( )方法显示的将是原来异常抛出点的调用栈信息,而并非重新抛出点的信息。要想更新这个信息,你可以调用fillInStackTrace( )方法,这将返回一个Throwable对象,它是通过把当前调用栈信息填入原来那个异常对象而建立的
你有可能会在捕获异常之后抛出另一种新的异常。这么做的话,将得到类似使用fillInStackTrace( )的效果,有关原来异常发生地点的信息会丢失,剩下的是与新的抛出地点有关的信息
你永远不用为清理前一个异常对象而担心,或者说为异常对象的清理担心。它们都是用new在堆上创建的对象,所以垃圾回收器会自动把它们清理掉
5.2、异常链
你常常会想要在捕获一个异常然后抛出另一个异常,并且希望把原始异常的信息保存下来,这被称为“异常链”
所有Throwable的子类在构造器中都可以接受一个cause对象作为参数。这个cause就用来表示原始异常,这样通过把原始异常传递给新的异常,使得即使你在当前位置创建并抛出了新的异常,你也能通过这个异常链追踪到异常最初发生的位置
6、Java标准异常
Throwable这个Java类被用来表示任何可以作为异常被抛出的类
Throwable对象可分为两种类型(指从Throwable继承而得到的类型):
Error用来表示你不用关心的编译期和系统错误(除了特殊情况)
Exception是可以被抛出的基本类型
在Java类库﹑用户方法以及运行时故障中都可能抛出Exception型异常。所以Java程序员关心的主要是Exception
运行期异常的特例
如果在null引用上调用方法,Java会自动抛出NullPointerException异常
属于运行期异常的类型有很多。它们会自动被Java虚拟机抛出,所以你不必在异常说明中把它们列出来。这些异常都是从RuntimeException类继承而来,所以既体现了继承的优点,使用起来也很方便。这构成了一组具有相同特征和行为的异常类型。并且,你也不再需要在异常说明中声明方法将抛出RuntimeException类型的异常(或者任何从RuntimeException继承的异常),它们也被称为“未被检查的异常”(unchecked exception)。这种异常属于错误,将被自动捕获,就不用你亲自动手了。不过尽管你通常不用捕获RuntimeException异常,但还是可以在代码中抛出RuntimeException类型的异常
如果RuntimeException没有被捕获而直达main( ),那么在程序退出前将调用异常的printStackTrace( )方法
请务必记住:你只能在代码中忽略RuntimeException(及其子类)类型的异常,其它类型异常的处理都是由编译器强制实施的
7、使用finally进行清理
对于一些代码,你可能会希望无论try块中的异常是否抛出,它们都能得到执行。这通常适用于内存回收(由垃圾回收器完成)之外的情况。为了达到这个效果,你可以在异常处理程序后面加上finally子句
甚至在异常没有被当前的异常处理程序捕获的情况下,异常处理机制也会在跳到更高一层的异常处理程序之前,执行finally子句
7.1、finally用来做什么
当你要把除内存之外的资源恢复到它们的初始状态时,就要用到finally子句
8、异常的限制
当你重载方法的时候,你只能抛出在父类方法的异常说明里列出的那些异常或其派生类。这个限制很有用,因为这样的话,对父类能工作的代码应用到子类对象的时候,一样能够工作(当然,这是面向对象的基本概念),异常也不例外
异常限制对构造器不起作用。你会发现StormInning的构造器可以抛出任何异常,而不必理会基类构造器的异常说明。然而,因为基类构造器必须以这样或那样的方式被调用(这里缺省构造器将自动被调用),派生类构造器的异常说明必须包含基类构造器的异常说明。注意,派生类构造器不能捕获基类构造器抛出的异常。
尽管在继承过程中,编译器会对异常说明做强制要求,但异常说明本身并不属于方法原型的一部分,方法原型是由方法的名字与参数的类型组成的,理解这一点非常有用。因此,你不能根据异常说明的不同来重载方法。此外,一个出现在基类方法的异常说明中的异常,不一定会出现在派生类方法的异常说明里。这点同继承的规则明显不同,在继承中,基类的方法必须出现在派生类里,换一句话说,在继承和重载的过程中,方法的“异常说明的接口”不是变大了而是变小了――这恰好和类接口在继承时的情形相反。
9、异常匹配
抛出异常的时候,异常处理系统会按照你书写代码的顺序找出“最近”的处理程序。找到匹配的处理程序之后,它就认为异常将得到处理,然后就不再继续查找。
查找的时候并不要求抛出的异常同处理程序所声明的异常完全匹配。派生类的对象也可以匹配处理程序中声明的基类
10.1、把异常传递给控制台
对于简单的程序,比如本书中的许多例子,最简单而又不用写多少代码就能保持异常信息的方法,就是把它们从main( )传递到控制台
main( )作为一个方法也可以有异常说明,这里异常的类型是Exception,它也是所有“被检查的异常”的基类。通过把它传递到控制台,你就不必在main( )里写try-catch子句了。
10.2、把“被检查的异常”转换为“不检查的异常”
用异常链把“被检查的异常”包装进RuntimeException里面,把“被检查的异常”的功能“屏蔽”掉。
你不用吞没异常,也不必把它放到方法的异常说明里面,而异常链还能保证你不会丢失任何原始异常的信息
这种技巧给了你一种选择,你可以不写try-catch子句或者异常说明,直接忽略异常,让它自己沿着调用栈往上跑。同时,你还可以用getCause( )捕获并处理特定的异常
第10章 类型检查
运行期类型识别(RTTI,run-time type identification)的概念初看起来非常简单:当你只有一个指向对象的基类的引用时,RTTI 机制可以让你找出这个对象确切的类型
Java 在运行期识别对象和类的信息。主要有两种方式:一种是传统的RTTI,它假定我们在编译期和运行期已经知道了所有的类型;另一种是“反射机制(reflection)”,它允许我们在运行期获得类的信息。
1、为什么需要RTTI
面向对象编程基本的目的是:你的代码只操纵对基类的引用。这样,如果你要添加一个新类来扩展程序,就不会影响到原来的代码
shape基类中动态绑定了draw()方法,目的就是让客户端程序员使用一般化的Shape的引用来调用draw()。draw()在所有派生类里都会被重载,并且由于它是被动态绑定的,所以即使是通过通用的Shape引用来调用,也能产生正确行为。这就是多态
因此,我们通常会创建一个特定的对象(Circle,Square,或者Triangle),把它向上转型成Shape(忽略对象的特定类型),并在后面的程序中使用匿名(译注:即不知道具体类型)的Shape引用
1.1、Class对象
类型信息在运行期是通过被称为“Class对象”的特殊对象来表示的,Class对象包含了与类有关的信息。事实上,Class对象正是被用来创建“常规”对象的
作为程序一部分,每个类都有一个Class对象。换言之,每当你编写并且编译了一个新类,就会产生一个Class对象(更恰当地说,是被保存在一个同名的.class文件中)。在运行期,一旦我们想生成这个类的一个对象,运行这个程序的Java虚拟机(JVM)首先检查这个类的Class对象是否已经加载。如果尚未加载,JVM就会根据类名查找.class文件,并将其载入。所以Java程序并不是一开始执行,就被完全加载的,这一点与许多传统语言都不同。
一旦某个类的Class对象被载入内存,它就被用来创建这个类的所有对象。
Class.forName("Gum")
这是Class类(所有Class对象都属于这个类型)的一个static成员。Class对象就和其他对象一样,我们可以获取并操作它的引用(这也就是类加载器的工作)。forName()是取得Class对象的引用的一种方法。它是用一个包含目标类的文本名(注意拼写和大小写)的String作输入参数,返回的是一个Class对象的引用,上面的代码忽略了返回值。对forName()的调用是为了它产生的“副作用”:如果类Gum还没有被加载就加载它。在加载的过程中,Gum的static语句被执行
类字面常量
Java 还提供了另一种方法来生成Class对象的引用:使用“类字面常量(class literal)”。
Gum.class
这样做不仅更简单,而且更安全,因为它在编译期就会受到检查。并且它无需方法调用,所以也更高效。
1.2、类型转换前先作检查
迄今为止,我们已知的RTTI形式包括:
1. 经典的类型转换,如"(Shape)",由RTTI确保类型转换的正确性,如果你执行了一个错误的类型转换,就会抛出一个ClassCastException异常。
2. 代表对象类型的Class对象。通过查询Class对象可以获取运行期所需的信息。
RTTI在Java中还有第三种形式,就是关键字instanceof。它返回一个布尔值,告诉我们对象是不是某个特定类型的实例
Object o=new Pet();
o instanceof Pet
对instanceof有比较严格的限制:你只可将其与类型的名字进行比较,而不能与Class对象作比较
动态的instanceof
Class.isInstance方法提供了一种动态地调用instanceof运算符的途径
Object o=new Pet();
Class Pet=Pet.class;
Pet.isinstance(o)
等价性: instanceof vs. Class
instanceof保持了类型的概念,它指的是“你是这个类吗,或者你是这个类的派生类吗?”而另一种情况是,如果你用==比较实际的Class对象,就不包含继承关系,——它或者恰好是这个确切的类型,或者不是
2、RTTI语法
Java是通过Class对象来实现RTTI机制的
首先,你需要获得指向适当的Class对象的引用。一种办法是用字符串以及Class.forName()方法,这种做法很方便,因为你在获取Class的引用事,并不需要生成该Class类型的对象。然而,如果你已经有了一个你感兴趣的类型的对象,那么你就可以通过调用getClass()来获取Class的引用,这是根类Object提供的方法。它返回Class的引用,用来表示对象的实际类型
Class.getInterfaces()方法返回Class对象的数组,这些对象代表的是某个Class对象所包含的接口
如果你有一个Class对象,那么你就可以通过getSuperclass()获取它的直接基类。这个方法自然也是返回一个Class的引用,所以你可以进一步查询其基类。这意味着在运行期,你可以找到一个对象完整的类层次结构。
Class的newInstance()方法创建一个新的对象
3、反射(Reflection):运行期的类信息
如果你不知道某个对象的确切类型,RTTI可以告诉你。但是有一个限制:这个类型在编译期必须已知,才能使用RTTI识别它,并利用这些信息做一些有用的事。换句话说,在编译期,编译器必须知道你要通过RTTI来处理的所有类
Class类(本章前面已有论述)支持反射的概念,Java附带的库java.lang.reflect包含了Field,Method以及Constructor类(每个类都实现了Member接口)。这些类型的对象是由JVM在运行期创建的,用以表示未知类里对应的成员
当你通过反射与一个未知类型的对象打交道时,JVM只是简单地检查这个对象,看它属于哪个特定的类(就象RTTI那样)。但在这之后,在做其它事情之前,必须加载那个类的Class对象。因此,那个类的.class文件对于JVM来说必须是可获取的,要么在本地机器上,要么可以通过网络取得。
RTTI和反射之间真正的区别只在于,对RTTI来说,编译器在编译期打开和检查.class文件。(换句话说,我们可以用“普通”方式调用一个对象的所有方法。)而对于反射机制来说.class文件在编译期间是不可获取的,所以是在运行期打开和检查.class文件
第11章 对象的集合
通常,你的程序会根据运行时才知道的条件创建新对象。不到运行期,不会知道所需对象的数量,甚至不知道确切的类型。为解决这个普遍的编程问题,需要能够在任意时刻,任意位置,创建任意数量的对象。所以,你就不能指望创建具名的引用来持有每一个对象
Java 有多种方式保存对象(应该说是对象的引用reference)
1、数组
数组与其它种类的容器之间的区别有三方面:效率、类型和持有基本类型的能力。
效率:在Java中,数组是一种效率最高的存储和随机访问对象引用序列的方式。数组就是个简单的线性序列,这使得元素访问非常快速,但也损失了其他一些特性。当你创建了一个数组对象(将数组本身作为对象看待),数组的大小就被固定了,并且这个数组的生命周期也是不可改变的。
类型和保存基本类型的能力:通用的容器类List, Set, 和Map,它们不以具体的类型来处理对象。换句话说,它们将所有对象都按Object类型处理,即Java中所有类的基类。从某个角度来说,这种方式很好:你只需要做一个容器,任意的Java对象都可以放入其中。(除了基本类型,可以使用Java包装类将其作为常量包装后存入容器,或者用你自己的类将其作为变量包装起来存入容器)这正是数组比通用容器优越的第二点:当你创建一个数组时,它只能保存特定类型的数据(这与第三点相关——数组可以保存基本类型,容器则不能)。这意味着会在编译期做类型检查,以防止你将错误的类型插入数组,或取出数据时弄错类型。当然,无论在编译期还是运行期,Java都会阻止你向对象发送不恰当的消息。
1.1、数组是第一级的对象
无论使用哪种数组,数组标识符其实只是一个引用,指向在堆(heap)中创建的一个真实对象,这个(数组)对象用以保存指向其他对象的引用。可以作为数组初始化语法的一部分隐式地创建此对象,或者用new表达式显式地创建。只读成员length 是数组对象的一部分,表示此数组对象可以存储多少元素。 []语法是访问数组对象唯一的方式。
对象数组和基本类型数组在使用上几乎是同样的。唯一的区别就是对象数组保存的是引用,基本类型数组直接保存基本类型的值
1.2、返回一个数组
返回一个数组与返回任何其他对象(本质是引用)没什么区别。数组是在flavorSet()中被创建的,还是在别的地方被创建的并不重要。当你使用完毕后,垃圾回收器负责清除数组,而只要你还需要它,此数组就会一直存在
1.3、Arrays类
在java.util类库中可以找到Arrays类,它有一套static方法,提供操作数组的实用功能。其中有四个基本方法:
equals(),比较两个数组是否相等
fill(),用某个值填充整个数组
sort(),对数组排序
binarySearch(),在已经排序的数组中查找元素
所有这些方法都被各种基本类型和Object类重载过。
此外,方法asList()接受任意的数组为参数,并将其转变为List容器
1.4、填充数组
Java标准类库Arrays也有fill()方法,但是它作用有限。只能用同一个值填充各个位置,对于保存对象的数组,就是复制同一个引用进行填充。
1.5、复制数组
Java标准类库提供有static方法System.arraycopy(),用它复制数组比用for循环复制要快很多。System.arraycopy()为所有类型作了重载。
arraycopy()需要的参数有:源数组、表示从源数组中的什么位置开始复制的偏移量、表示从目标数组的什么位置开始复制的偏移量、以及需要复制的元素个数
1.6、数组的比较
Arrays类重载了equals()方法,用来比较整个数组。同样的,此方法被所有基本类型与Object都作了重载。数组相等的条件时元素个数必须相等,并且对应位置的元素也相等,这可以通过对每一个元素使用equals()做比较来判断。
数组相等是基于内容的
1.7、数组元素的比较
程序设计的基本目标是“将保持不变的事物与会发生改变的事物相分离”,而这里,不变的是通用的排序算法,变化的是各种对象相互比较的方式。因此,不是将进行比较的代码编写成为不同的子程序,而是使用回调技术(callback)。通过使用回调,可以将会发生变化的代码分离出来,然后由不会发生变化的代码回头调用会发生变化的代码。
Java 有两种方式提供比较功能:
实现java.lang.Comparable接口,使你的类具有“天生”的比较能力。此接口很简单,只有compareTo()一个方法。如果没有实现Comparable接口,调用sort()的时候会抛出ClassCastException的运行期异常。因为sort()需要把参数的类型转变为Comparable
使用策略设计模式。通过使用策略,将会发生变化的代码包装在类中(即所谓策略对象)。将策略对象交给保持不变的代码,后者使用此策略实现它的算法。也就是说,可以为不同的比较方式生成不同的对象,将它们用在同样的排序程序中。此处,通过定义一个实现了Comparator接口的类而创建了一个策略。这个类有compare() 和equals() 两个方法。然而,不是一定要实现equals()方法,除非是为了特别的性能需要。因为无论何时创建一个类,都是间接继承自Object,而Object带有equals()方法。所以只用默认的Object的equals()方法就可以满足接口的要求了。
1.8、数组排序
使用内置的排序方法,就可以对任意的基本类型数组排序,也可以对任意的对象数组进行排序,只要该对象实现了Comparable接口或具有相关联的Comparator。
1.9、在已排好序的数组中查找
如果数组已经排好序了,就可以使用Arrays.binarySearch()执行快速查找
如果找到了目标,Arrays.binarySearch()的返回值等于或大于0。否则,返回负值,表示为了保持数组的排序状态,此目标元素应该插入的位置。这个负值的计算方式是:
-(插入点)- 1
“插入点”是指,第一个大于查找对象的元素在数组中的位置,如果数组所有的元素都小于要查找的对象,“插入点”就等于a.size()
如果使用Comparator排序某个对象数组(基本类型数组无法使用Comparator进行排序),在使用binarySearch()时必须提供同样的Comparator
2、容器简介
Java的容器类库的作用是“保存对象”,有两种基本类型,区别在于容器中每个位置保存的元素个数。
Collection每个位置只能保存一个元素,此类容器包括List,它以特定的顺序保存一组元素;Set,元素不能重复。
Map保存的是键值对,就像一个小型数据库。
2.1、容器的打印
使用容器类默认的打印行为(toString()方法)即可生成可读性良好的结果
2.2、填充容器
与Arrays一样,Collections也有一个实用的static方法集,其中包括有fill()。此fill()方法也是用同一个对象的引用来填充容器的,并且只对List对象有用,而对Set或Map并不起作用
3、容器的缺点:未知类型
使用Java容器有个“缺点”,在将对象加入容器的时候就丢失了类型信息。因为使用容器的程序员不关心你想要添入容器的对象的具体类型。如果容器只能保存你自己的类型,就失去了作为通用工具的意义。所以容器只保存Object型的引用,这是所有类的基类,因此容器可以保存任何类型的对象。不过:
1. 因为在你将对象的引用加入容器时就丢失了类型的信息,所以对于添入容器的对象没有类型限制
2. 因为丢失了类型信息,容器只知道它保存的是Object类型的引用。在使用容器中的元素前必须要做类型转换操作
好在Java并不会让你误用容器中的对象。如果你将“狗”丢入存放“猫”的容器,然后将其中的每件东西都作为“猫”,当你将指向“狗”的引用取出容器,类型转换为“猫”时会收到RuntimeException异常
如果编译器需要的是String对象,而它并没有得到,编译器就自动调用toString()方法,这是定义在Object中的方法,可以被任意的Java类重载
4、迭代器
迭代器是一个对象,它的工作是遍历并选择序列中的对象。客户端程序员不关心序列底层的结构。此外,迭代器通常被称为“轻量级”对象:创建它的代价小。因此,经常可以见到对迭代器有些奇怪的限制。
Java的Iterator就是迭代器受限制的例子,它只能用来:
1. 使用方法iterator()要求容器返回一个Iterator。第一次调用Iterator的next()方法时,它返回序列的第一个元素。
2. 使用next()获得序列中的下一个元素。
3. 使用hasNext()检查序列中是否还有元素。
4. 使用remove()将上一次返回的元素从迭代器中移除。
有了Iterator就不必为容器中元素的数量操心,由hasNext()和next()为你照看着。
5、容器的分类法
与持有对象有关的接口是Collection,List,Set和Map。最理想的情况是,你的代码只与这些接口打交道,仅在创建容器的时候说明容器的特定类型
我们只关心顶层的接口和“具体类”。典型情况是你会生成一个“具体类”的对象,然后将它向上转型为对应的接口,在代码中使用接口操作它
6、Collection的功能方法
Object[] toArray():返回一个数组,包含容器中的所有元素
Object[] toArray(Object[] a):返回一个数组,包含容器中的所有元素,其类型与数组a的类型相同,而不是单纯的Object类型
7、List的功能方法
List接口:次序是List最重要的特点,它保证维护元素特定的顺序。List为Collection添加了许多方法,使得能够向List中间插入与移除元素。(这只推荐LinkedList使用。)一个List可以生成ListIterator,使用它可以从两个方向遍历List,也可以从List中间插入和移除元素
ArrayList:由数组实现的List。允许对元素进行快速随机访问,但是向List中间插入与移除元素的速度很慢。ListIterator只应该用来由后向前遍历ArrayList,而不是用来插入和移除元素,因为那比LinkedList开销要大很多。
LinkedList:由链表实现的List,对顺序访问进行了优化,向List中间插入与删除的开销并不大。随机访问则相对较慢。还具有下列方法:addFirst(),addLast(),getFirst(),getLast(),removeFirst(),和removeLast(),这些方法使得LinkedList可以当作堆栈、队列和双向队列使用
8、Set的功能方法
Set具有与Collection完全一样的接口,因此没有任何额外的功能,Set不保存重复的元素
Set接口:存入Set的每个元素都必须是唯一的,因为Set不保存重复元素。加入Set的元素必须定义equals()方法以确保对象的唯一性。Set与Collection有完全一样的接口。Set接口不保证维护元素的次序
HashSet:为快速查找设计的Set。存入HashSet的对象必须定义hashCode()
TreeSet:保持次序的Set,底层为树结构。使用它可以从Set中提取有序的序列
LinkedHashSet:具有HashSet的查询速度,且内部使用链表维护元素的顺序(插入的次序)。于是在使用迭代器遍历Set时,结果会按元素插入的次序显示。
HashSet维护的元素次序不同于TreeSet和LinkedHashSet,因为它们保存元素的方式各有不同,使得以后还能找到元素。(TreeSet采用红黑树的数据结构排序元素,HashSet则采用散列函数,这是专门为快速查询设计的。LinkedHashSet内部使用散列以加快查询速度,同时使用链表维护元素的次序,使得看起来元素是以插入的顺序保存的。)
作为一种编程风格,当你重载equals()的时候,就应该同时重载hashCode()
SortedSet
使用SortedSet(TreeSet是其唯一的实现),可以确保元素处于排序状态。
注意,SortedSet的意思是“按比较函数对元素排序”,而不是指“元素插入的次序”。
9、Map的功能方法
从概念上讲,它看起来就像是ArrayList,只是不再用数字下标查找对象,而是以另一个对象来进行查找。
Map接口即是此概念在Java中的体现。方法put(Object key, Object value)添加一个“值”(value)(想要的东西)和与“值”相关联的“键”(key)(使用它来查找)。方法get(Object key)返回与给定“键”相关联的“值”。可以用containsKey()和containsValue()测试Map中是否包含某个“键”或“值”。
标准的Java类库中包含了几种不同的Map:HashMap,TreeMap,LinkedHashMap,WeakHashMap,IdentityHashMap。它们都有同样的基本接口Map,但是行为、效率、排序策略、保存对象的生命周期和判定“键”等价的策略等各不相同
HashMap使用了特殊的值,称作“散列码”(hash code),来取代对“键”的缓慢搜索。“散列码”是“相对唯一”用以代表对象的int值,它是通过将该对象的某些信息进行转换而生成的。所有Java对象都能产生散列码,因为hashCode()是定义在基类Object中的方法。HashMap就是使用对象的hashCode()进行快速查询的。此方法能够显著提高性能。
Map接口:维护“键值对”的关联性,使你可以通过“键”找到“值”
HashMap:Map基于散列表的实现。插入和查询“键值对”的开销是固定的。可以通过构造器设置容量capacity和负载因子load factor,以调整容器的性能
TreeMap:基于红黑树的实现。查看“键/值”对时,它们会被排序(次序由Comparable或Comparator决定)。TreeMap的特点在于,所得到的结果是经过排序的。
LinkedHashMap:类似于HashMap,但是迭代遍历时,取得“键/值”对的顺序是其插入次序,或者是最近最少使用的次序
9.1、SortedMap
使用SortedMap(TreeMap是其唯一的实现),可以确保“键”处于排序状态。
9.2、LinkedHashMap
为了提高速度,LinkedHashMap散列化所有的元素,但是在遍历“键值对”时,却又以元素的插入顺序返回“键值对”。此外,可以在构造器中设定LinkedHashMap,使之采用基于访问的“最近最少使用”(LRU)算法,于是没有被访问过的(可被看作需要删除的)元素就会出现在队列的前面。
9.3、散列法与散列码
默认的hashCode()方法使用对象的地址计算散列码
默认的equals()方法比较对象的地址
如果要使用自己的类作为HashMap的“键”,必须同时覆盖hashCode()和equals()
正确的equals()方法必须满足下列5个条件:
1. 自反性:对任意x,x.equals(x)一定返回true。
2. 对称性:对任意x和y,如果y.equals(x)返回true,则x.equals(y)也返回true。
3. 传递性:对任意x,y,z,如果有x.equals(y)返回ture,y.equals(z)返回true,则x.equals(z)一定返回true。
4. 一致性:对任意x和y,如果对象中用于等价比较的信息没有改变,那么无论调用x.equals(y)多少次,返回的结果应该保持一致,要么一直是true,要么一直是false。
5. 对任何不是null的x,x.equals(null)一定返回false。
hashCode()并不需要总是能够返回唯一的标识码(稍后你会更加理解其原因),但是equals()方法必须能够严格地判断两个对象是否相同
理解hashCode()
散列的价值在于速度:散列使得查询得以快速进行.它将“键”保存在某处,使你能够很快速的找到。存储一组元素最快的数据结构是数组,所以使用它来代表“键”的信息
数组并不保存“键”本身。而是通过“键”对象生成一个数字,将其作为数组的下标索引。这个数字就是散列码,由定义在Object中的hashCode()生成。你的类总是应该重载hashCode()方法。为解决数组容量被固定的问题,不同的“键”可以产生相同的下标。
查询一个“值”的过程首先就是计算散列码,然后使用散列码查询数组。如果能够保证没有冲突,那你可就有了一个完美的散列函数,但是这种情况很特殊。通常,冲突是由“外部链接”处理:数组并不直接保存“值”,而是保存“值”的list。然后对list中的“值”使用equals()方法进行线性的查询。这部分的查询自然会比较慢,但是,如果有好的散列函数,数组的每个位置就只有较少的“值”。因此,不是查询所有的list,而是快速地跳到数组的某个位置,只对很少的元素进行比较。这便是HashMap会如此快的原因。
HashMap的性能因子
容量(Capacity):散列表中桶的数量。
初始化容量(Initial capacity):创建散列表时桶的数量。HashMap和HashSet都允许你在构造器中指定初始化容量。
尺寸(Size):当前散列表中记录的数量。
负载因子(Load factor):等于“size/capacity”。 负载因子为0,表示空的散列表,0.5表示半满的散列表,依此类推。轻负载的散列表具有冲突少、适宜插入与查询的特点(但是使用迭代器遍历会变慢)。HashMap与HashSet的构造器允许你指定负载因子。这意味着,当负载达到指定值时,容器会自动成倍的增加容量(桶的数量),并将原有的对象重新分配,存入新的桶内(这称为“重散列”rehashing)。HashMap默认的负载因子为0.75
覆盖hasnCode()
hashCode()生成的结果,经过处理后成为“桶”的索引
设计hashCode()时最重要的因素就是:无论何时,对同一个对象调用hashCode()都应该生成同样的值。
hashCode的产生应使用“键”对象内有意义的识别信息
10、再论迭代器
Iterator将遍历一个序列的操作与此序列底层结构相分离
11、选择接口的不同实现
实际上只有三种容器:Map,List,和Set,但是每种接口都有不止一个实现版本。
容器之间的区别,通常归结为由什么在背后“支持”它们。也就是说,你使用的接口是由什么样的数据结构实现的
ArrayList底层由数组支持;而LinkedList是由双向链表实现的,其中的每个对象包含数据的同时,还包含指向链表中前一个与后一个元素的引用。
总结
1. 数组将数字与对象联系起来。它保存类型明确的对象,查询对象时,不需要对结果做类型转换。它可以是多维的,可以保存基本类型的数据。但是,数组一旦生成,其容量就不能改变。
2. Collection保存单个的元素,而Map保存相关联的键值对。
3. 像数组一样,List也建立数字与对象的关联,可以认为数组和List都是排好序的容器。List能够自动扩充容量。但是List不能保存基本类型,只能保存Object的引用,因此必须对从容器中取出的Object结果做类型转换。
4. 如果要进行大量的随机访问,就使用ArrayList;如果要经常从List中间插入或删除元素,则应该使用LinkedList。
5. 队列、双向队列以及栈的行为,由LinkedList提供支持。
6. Map是一种将对象与对象相关联的设计。HashMap着重于快速访问;TreeMap保持“键”始终处于排序状态,所以没有HashMap快。LinkedHashMap保持元素插入的顺序,也可以使用LRU算法对其重排序。
7. Set不接受重复元素。HashSet提供最快的查询速度,TreeSet保持元素处于排序状态。LinkedHashSet以插入顺序保存元素。
8. 新程序中不应该使用过时的Vector、Hashtable和Stack。
第13章 并发
所谓“进程”(process),是一个独立运行着的程序,它有自己的地址空间。“多任务”操作系统通过周期性地将处理器切换到不同的任务,使其能够同时运行不止一个进程(程序),而每个进程都象是连续运行、一气呵成的。线程是进程内部的单一控制序列流。因此一个进程内可以具有多个并发执行的线程。
多线程有多种用途,不过通常用法是,你的程序中的某个部分与一个特定的事件或资源联系了在一起,而你又不想让这种联系阻碍程序其余部分的运行。这时,可以创建一个与这个事件或资源相关联的线程,并且让此线程独立于主程序运行。
随着对线程的支持在大多数微机操作系统中的出现,在编程语言和程序库中也出现了对线程的扩展
1、动机
使用并发最强制性的原因:
产生能够作出响应的用户界面
并发还可以用来优化程序的吞吐量
线程模型为编程带来了便利,它简化了在单一程序中交织在一起同时运行的多个操作。在使用线程时,处理器将轮流给每个线程分配其占用时间。每个线程都觉得自己在一直占用处理器,但事实上处理器时间是划分成片段分配给了所有的线程。
2、基本线程
写一个线程最简单的做法是从java.lang.Thread继承,这个类已经具有了创建和运行线程所必要的架构。Thread最重要的方法是run( ),你得重载这个方法,以实现你要的功能。这样,run()里的代码就能够与程序里的其它线程“同时”执行。在run()方法返回的地点,将由线程机制终止此线程
Thread对象的run()方法一般总会有某种形式的循环,使得线程一直运行下去直到不再需要,所以要设定跳出循环的条件
Thread类的start( )方法将为线程执行特殊的初始化动作,然后调用run( )方法
当在main( )中创建若干个Thread对象的时候,并没有获得它们中任何一个的引用。对于普通的对象,这会使它成为垃圾回收器要回收的目标,但对于Thread对象就不会了。每个Thread对象需要“注册”自己,所以实际上在某个地方存在着对它的引用,垃圾收集器只有在线程离开了run( )并且死亡之后才能把它清理掉。
2.1、让步
如果你知道run( )方法中已经完成了所需的工作,你可以给线程调度机制一个暗示:你的工作已经做得差不多了,可以让别的线程使用处理器了。这个暗示将通过调用yield( )方法的形式来作出。不过这只是一个暗示,没有任何机制保证它将会被采纳
2.2、休眠
另一种能控制线程行为的方法是调用sleep( ),这将使线程停止执行一段时间,该时间由你给定的毫秒数决定
在你调用sleep()方法的时候,必须把它放在try块中,这是因为sleep()方法在休眠时间到期之前有可能被中断。如果某人持有此线程的引用,并且在此线程上调用了interrupt()方法,就会发生这种情况。通常,如果你想使用interrupt( )来中断一个挂起的线程,那么挂起的时候最好使用wait( )而不是sleep( ),这样就不可能在catch子句里的结束了。
如果你必须要控制线程的执行顺序,你最好是根本不用线程,而是自己编写以特定顺序彼此控制的协作子程序。
2.3、优先权
线程的“优先权”(priority)能告诉调度程序其重要性如何。尽管处理器处理现有线程集的顺序是不确定的,但是如果有许多线程被阻塞并在等待运行,那么调度程序将倾向于让优先权最高的线程先执行。然而,这并不是意味着优先权较低的线程将得不到执行(也就是说,优先权不会导致死锁)。优先级较低的线程仅仅是执行的频率较低。
向控制台打印不能被中断
2.4、后台线程
所谓“后台”(daemon)线程,是指程序运行的时候,在后台提供一种通用服务的线程,并且这种服务并不属于程序中不可或缺的部分。因此,当所有的非后台线程结束,程序也就终止了。反过来说,只要有任何非后台线程还在运行,程序就不会终止
你必须在线程启动之前调用setDaemon( )方法,才能把它设置为后台线程
你可以通过调用isDaemon( )方法来确定线程是否是一个后台线程。如果是一个后台线程,那么它创建的任何线程将被自动设置成后台线程
2.5、加入到某个线程
一个线程可以在其它线程之上调用join( )方法,其效果是等待一段时间直到第二个线程结束才继续执行。如果某个线程在另一个线程t上调用t.join( ),此线程将被挂起,直到目标线程t结束才恢复(即t.isAlive( )返回为假)
对join()方法的调用可以被中断,做法是在调用线程上调用interrupt()方法,这时需要用到try-catch子句
2.6、编码的变体
可以使用实现Runnable接口的方法来替代继承Thread类。要实现Runnable接口,只需实现run( )方法,Thread也是从Runnable接口实现而来的。
当某个对象具有Runnable接口,即表明它有run( )方法,但也就仅此而已,不像从Thread继承的那些类,它本身并不带有任何和线程有关的特性。所以要从Runnable对象产生一个线程,你必须建立一个单独的Thread对象,并把Runnable对象传给专门的Thread构造器。然后你可以对这个线程对象调用start( ),去执行一些通常的初始化动作,然后调用run( )。
3、共享受限资源
在多线程的环境中,可以同时做多件事情。但是,“两个或多个线程同时使用同一个受限资源”的问题也出现了。必须防止这种资源访问的冲突
要防止这类冲突,只要在线程使用资源的时候给它加一把锁就行了。访问资源的第一个线程给资源加锁,接着其它线程就只能等到锁被解除以后才能访问资源,这时某个线程就可以对资源加锁以进行访问
3.1、解决共享资源竞争
基本上所有的多线程模式,在解决线程冲突问题的时候,都是采用“序列化”(serialize)访问共享资源的方案。这意味着在给定时刻只允许一个线程访问共享资源。通常这是通过在代码前面加上一条锁语句来实现的,这就保证了在一段时间内只有一个线程运行这段代码。因为锁语句产生了一种互相排斥的效果,所以常常称为“互斥量”(mutex)。
Java以提供关键字synchronized的形式,为防止资源冲突提供了内置支持。
典型的共享资源是以对象形式存在的内存片断,但也可以是文件,输入/输出端口,或者是打印机。要控制对共享资源的访问,你得先把它包装进一个对象。然后把所有要访问这个资源的方法标记为synchronized。也就是说,一旦某个线程处于一个标记为synchronized的方法中,那么在这个线程从该方法返回之前,其它要调用类中任何标记为synchronized方法的线程都会被阻塞。
一般来说类的数据成员都被声明为私有的,只能通过方法来访问这些数据。所以你可以把方法标记为synchronized来防止资源冲突
每个对象都含有一个单一的锁(也称为监视器),这个锁本身就是对象的一部分(你不用写任何特殊代码)。当你在对象上调用其任意synchronized方法的时候,此对象都被加锁,这时对象上的其它synchronized方法只有等到前一个方法调用完毕并释放了锁之后才能被调用。
一个线程可以多次获得对象的锁。如果一个方法在同一个对象上调用了第二个方法,后者又调用了同一对象上的另一个方法,就会发生这种情况。JVM负责跟踪对象被加锁的次数。如果一个对象被解锁,其计数为0。在线程第一次给对象加锁的时候,计数变为1。每次线程在这个对象上获得了锁,计数都会增加。显然,只有首先获得了锁的线程才能允许继续获取多个锁。每当线程离开一个synchronized方法,计数减少,当计数为零的时候,锁被完全释放,此时别的线程就可以使用此资源。
针对每个类,也有一个锁(作为类的Class对象的一部分),所以synchronized static方法可以在类的范围内防止对静态数据的并发访问。
3.2、临界区
有时,你只是希望防止多个线程同时访问方法内部的部分代码而不是整个方法。通过这种方式分离出来的代码段被称为“临界区”(critical section),它也使用synchronized关键字建立。这里,synchronized被用来指定某个对象,此对象的锁被用来对花括号内的代码进行同步控制
synchronized(syncObject) {
// This code can be accessed
// by only one thread at a time
}
这也被称为“同步控制块”(synchronized block),在进入此段代码前,必须得到syncObject对象的锁。如果其它线程已经得到这个锁,那么就得等到锁被释放以后,才能进入临界区。
4、线程状态
一个线程可以处于以下四种状态之一:
1.新建(new):线程对象已经建立,但还没有启动,所以它还不能运行。
2.就绪(Runnable):在这种状态下,只要调度程序把时间片分配给线程,线程就可以运行。也就是说,在任意时刻,线程可以运行也可以不运行。只要调度程序能分配时间片给线程,它就可以运行;这不同于死亡和阻塞状态。
3.死亡(Dead):线程死亡的通常方式是从run( )方法返回。
4.阻塞(Blocked):线程能够运行,但有某个条件阻止它的运行。当线程处于阻塞状态时,调度机制将忽略线程,不会分配给线程任何处理器时间。直到线程重新进入了就绪状态,它才有可能执行操作。
进入阻塞状态的条件
1.你通过调用sleep(milliseconds)使线程进入休眠状态,在这种情况下,线程在指定的时间内不会运行。
2.你通过调用wait( )使线程挂起。直到线程得到了notify( )或notifyAll( )消息,线程才会进入就绪状态。
3.线程在等待某个输入/输出完成。
4.线程试图在某个对象上调用其同步控制方法,但是对象锁不可用。
5、线程之间的同步
线程之间的协作是通过握手来进行的,可以通过Object的方法wait( )和notify( )来安全的实现
5.1、等待与通知
调用sleep( )的时候锁并没有被释放,理解这一点很重要。另一方面,wait( )方法的确释放了锁,这就意味着在调用wait( )期间,可以调用线程中对象的其他同步控制方法。当一个线程在方法里遇到了对wait( )的调用的时候,线程的执行被挂起,对象上的锁被释放。
有两种形式的wait( )。第一种接受毫秒作为参数,意思与sleep( )方法里参数的意思相同,都是指“在此期间暂停” 。不同之处在于,对于wait( ):
1.在wait( )期间锁是释放的。
2.你可以通过notify( )、notifyAll( ),或者时间到期,从wait( )中恢复执行。
第二种形式的wait( )不要参数; wait( )将无限等待直到线程接收到notify( )或者notifyAll( )消息。
wait( ), notify( ),以及notifyAll( )的一个比较特殊的方面是这些方法是基类Object的一部分,而不是像Sleep( )那样属于Thread的一部分。
你只能在同步控制方法或同步控制块里调用wait( ), notify( )和notifyAll( )(因为不用操作锁,所以sleep( )可以在非同步控制方法里调用)。如果你在非同步控制方法里调用这些方法,程序能通过编译,但运行的时候,你将得到IllegalMonitorStateException异常,调用 wait( ), notify( )和notifyAll( )的线程在调用这些方法前必须“拥有”(获取)线程对象的锁。
synchronized(x) {
x.notify();
}
当你在等待某个条件,这个条件必须由当前方法以外的因素才能改变的时候,就应该使用wait( )。wait( )允许你在等待外部条件的时候,让线程休眠,只有在收到notify( )或notifyAll( )的时候线程才唤醒并对变化进行检查。所以,wait( )为在线程之间进行同步控制提供了一种方法。
6、死锁
因为线程可以阻塞,并且对象可以具有同步控制方法,用以防止别的线程在锁还没有释放的时候就访问这个对象。所以就可能出现这种情况:某个线程在等待另一个线程,而后者又等待别的线程,这样一直下去,直到这个链条上的线程又在等待第一个线程释放锁。你将得到一个线程之间相互等待的连续循环,没有哪个线程能继续。这被称之为“死锁”
7、中断阻塞线程
调用Thread.interrupt()方法把阻塞线程中断
总结
明白什么时候应该使用并发,什么时候应该避免使用并发是非常关键的。使用它的原因主要是:要处理很多任务,它们交织在一起,能够更有效地使用计算机(包括在多个处理器上透明地分配任务的能力),能够更好地组织代码,或者更便于用户使用。资源均衡的经典案例是在等待输入/输出时使用处理器。使用户方便的经典案例是在长时间的下载过程中监视“停止”按钮是否被按下。
线程的一个额外好处是它们提供了轻量级的执行上下文切换(大约100条指令),而不是重量级的进程上下文切换(要上千条指令)。因为一个给定进程内的所有线程共享相同的内存空间,轻量级的上下文切换只是改变了程序的执行序列和局部变量。进程切换(重量级的上下文切换)必须改变所有内存空间。
多线程的主要缺陷有:
1.等待共享资源的时候性能降低。
2.需要处理线程的额外CPU耗费。
3.糟糕的程序设计导致不必要的复杂度。
4.有可能产生一些病态行为,如饿死﹑竞争﹑死锁和活锁。
5.不同平台导致的不一致性。
因为多个线程可能共享一个资源,比如内存中的对象,而且你必须确定多个线程不会同时读取和改变这个资源,这就是线程产生的最大难题。这需要明智的使用synchronized关键字,它仅仅是个工具,同时它会引入潜在的死锁条件,所以要对它有透彻的理解。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: