第5章 初始化与清理

  1. 在Java(和C++)里,构造器是强制重载方法名的另一个原因。既然构造器的名字已经由类名所决定,就只能有一个构造器名。
  2. 如果传入的数据类型(实际参数类型)小于方法中声明的形式参数类型,实际参数类型就会被提升。char型略有不同,如果无法找到恰好接受char参数的方法,就会把char直接提升至int型。如果传入的实际参数较大,就得通过类型转换来执行窄化转换。如果不这样做,编译器就会报错。
  3. this关键字只能在方法内部使用,表示对“调用方法的那个对象”的引用。
  4. static方法就是没有this的方法。有些人认为static方法不是“面向对象”的,因为它们的确具有全局函数的语义;使用static方法时,由于不存在this,所以不是通过“向对象发送消息”的方式来完成的。Java中禁止使用全局方法,但你在类中置入static方法就可以访问其他static方法和static域。
  5. 但也有特殊情况:假定你的对象(并非使用new)获得了一块“特殊”的内存区域,由于垃圾回收器只知道释放那些经由new分配的内存,所以它不知道该如何释放该对象的这块“特殊”内存。为了应对这种情况,Java允许在类中定义一个名为finalize()的方法。它的工作原理“假定”是这样的:一旦垃圾回收器准备好释放对象占用的存储空间,将首先调用其finalize()方法,并且在下一次垃圾回收动作发生时,才会真正回收对象占用的内存。
  6. 看来之所以要有finalize(),是由于在分配内存时可能采用了类似C语言中的做法,而非Java中的通常做法。这种情况主要发生在使用“本地方法”的情况下,本地方法是一种在Java中调用非Java代码的方式。本地方法目前只支持C和C++,但它们可以调用其他语言写的代码,所以实际上可以调用任何代码。
  7. 记住,无论是“垃圾回收”还是“终结”,都不保证一定会发生。如果Java虚拟机并未面临内存耗尽的情形,它是不会浪费时间去执行垃圾回收以恢复内存的。
  8. 在以前所用过的程序语言中,在堆上分配对象的代价十分高昂,因此读者自然会觉得Java中所有对象(基本类型除外)都在堆上分配的方式也非常高昂。然而,垃圾回收器对于提高对象的创建速度,却具有明显的效果。听起来很奇怪——存储空间的释放竟然会影响存储空间的分配,但这确实是某些Java虚拟机的工作方式。这也意味着,Java从堆分配空间的速度,可以和其他语言从堆栈上分配空间的速度相媲美。
  9. 垃圾回收器会在含有全部对象的列表上遍历,当发现某个对象的引用计数器为0时,就释放其占用的空间。这种方法有个缺陷,如果对象之间存在循环引用,可能会出现“对象应该被回收,但引用计数却不为0”的情况。对垃圾回收器而言,定位这样的交互自引用的对象组所需的工作量极大。引用计数常用来说明垃圾收集的工作方式,但似乎从未被应用于任何一种Java虚拟机实现中。
  10. 在一些更快的模式中,垃圾回收器并非基于引用计数技术。它们依据的思想是:对任何“活”的对象,一定能最终追溯到其存活在堆栈或静态存储区之中的引用。
  11. 垃圾多,JVM用停止-复制(先暂停程序的运行(所以它不属于后台回收模式),然后将所有存活的对象从当前堆复制到另一个堆,没有被复制的全都是垃圾。当对象被复制到新堆时,它们是一个挨着一个的,所以新堆保持紧凑排列,然后就可以按前述方法简单、直接地分配新空间了)。垃圾少,JVM用标记-清扫。都需要暂停程序。
  12. 如前文所述,在这里所讨论的Java虚拟机中,内存分配以较大的“块”为单位。如果对象较大,它会占用单独的块。有了块之后,垃圾回收器在回收的时候就可以往废弃的块里拷贝对象了。每个块都用相应的代数(generation count)来记录它是否还存活。垃圾回收器会定期进行完整的清理动作——大型对象仍然不会被复制(只是其代数会增加),内含小型对象的那些块则被复制并整理。Java虚拟机会进行监视,如果所有对象都很稳定,垃圾回收器的效率降低的话,就切换到“标记-清扫”方式;同样,Java虚拟机会跟踪“标记-清扫”的效果,要是堆空间出现很多碎片,就会切换回“停止-复制”方式。
  13. JIT编译器技术可以把程序全部或部分翻译成本地机器码(这本来是Java虚拟机的工作),程序运行速度因此得以提升。当需要装在某个类(通常是在为该类创建第一个对象)时,编译器会先找到其.class文件,然后将该类的字节码装入内存。此时,有两种方案可供选择。另一种做法称为惰性评估(lazy evaluation),意思是即时编译器只在必要的时候才编译代码。这样,从不会被执行的代码也许就压根不会被JIT所编译。新版JDK中的Java HotSpot技术就采用了类似方法,代码每次被执行的时候都会做一些优化,所以执行次数越多,它的速度就越快。
  14. 类的成员变量全部会在构造器调用之前被初始化(即自动初始化)。要执行main()(静态方法),必须加载StaticInitialization类,然后其静态域table和cupboard被初始化,这将导致它们对应的类也被加载,并且由于它们也都包含静态的Bowl对象,因此Bowl随后也被加载。这样,在这个特殊的程序中所有类在main()开始之前就都被加载了。
  15. 对象的创建过程:
    No.1: 即使没有显示地使用static关键字,构造器实际上也是静态方法。因此,当首先创建类型为Dog的对象时,或者Dog类的静态方法/静态域首次被访问时,Java解释器必须查找类路径,以定位Dog.class文件。
    No.2: 然后载入Dog.class(这将创建一个Class对象),有关静态初始化的所有动作都会执行。因此,静态初始化只在Class对象首次加载的时候进行一次。
    No.3: 当用new Dog()创建对象的时候,首先将在堆上为Dog对象分配足够的存储空间。
    No.4: 这块存储空间会被清零,这就自动地将Dog对象中的所有基本数据类型数据都设置成了默认值,而引用则被设置为了null。
    No.5: 执行所有出现于字段定义处的初始化动作。
    No.6: 执行构造器。
  16. 编译器不允许指定数组的大小,这就又把我们带回到有关“引用”的问题上。现在拥有的只是对数组的一个引用(你已经为该引用分配了足够的存储空间),而且也没给数组对象本身分配任何空间。为了给数组创建相应的存储空间,必须写初始化表达式。
    我们不能指定数组大小,是因为我们认为的数组只是一个引用。如果我们要指定数组大小,就要用到new。比如new int[10]。
    如果你创建了一个非基本类型的数组,那么你就创建了一个引用数组。

第6章 访问控制权限

  1. 当编译一个.java文件时,在.java文件中的每个类都会有一个输出文件,而该输出文件的名称与.java文件中每个类的名称相同,只是多了一个后缀名.class。因此,在编译少量.java文件之后,会得到大量的.class文件。
  2. 按照惯例,package名称的第一部分是类的创建者的反顺序的Internet域名。如果你打算发布你的Java程序代码,稍微花点力气去取得一个域名,还是很有必要的。
  3. 这是一个说明private终有其用武之地的示例:可能想控制如何创建对象,并阻止别人直接访问某个特定的构造器(或全部构造器)。在上面的例子中,不能通过构造器来创建Sundae对象,而必须调用makeSundae()方法(当然是静态方法啦)来达到此目的。
  4. 任何可以肯定只是该类的一个“助手”方法的方法,都可以把它指定为private,以确保不会在包内的任何其他地方误用到它,于是也就防止了你会去改变或删除这个方法。
  5. 有时,基类的创建者会希望有某个特定成员,把对它的访问权限赋予派生类而不是所有类。这就需要protected来完成这一工作。protected也提供包访问权限,相同包内的其他类可以访问protected元素。
  6. 类既不可以是private的(这样会使得除该类之外,其他任何类都不可以访问它),也不可以是protected的。(一个内部类可以是private或是protected的)。所以对于类的访问权限,仅有两个选择:包访问权限或public。如果不希望其他任何人对该类拥有访问权限,可以把所有的构造器都指定为private,从而阻止任何人创建该类的对象,但是有一个例外,就是你在该类的static成员内部可以创建。
  7. Soup2用到了所谓的设计模式:singleton(单例),这是因为你始终只能创建它的一个对象。Soup2类的对象是作为Soup2的一个static private成员而创建的,所以有且仅有一个,而且除非是通过public方法access(),否则是无法访问到它的。
class Soup2 {private Soup2 () {}private static Soup2 ps1 = new Soup2();public static Soup2 access() { return ps1; }public void f() {}
}

第7章 复用类

  1. 但继承并不只是复制基类的接口。当创建了一个导出类的对象时,该对象包含了一个基类的子对象。这个子对象与你直接用基类创建的对象是一样的。二者区别在于,后者来自于外部,而基类的子对象被包装在导出类对象内部。
  2. 可以看到,虽然Bart引入了一个新的重载方法(在C++中若要完成这项工作则需要屏蔽基类方法),但是在Bart中Homer的所有重载方法都是可用的。正如读者将在下一章所看到的,使用与基类完全相同的特征签名及返回类型来覆盖具有相同名称的方法,是一件极其平常的事。但它也令人迷惑不解(这也就是为什么C++不允许这样做的原因所在)。
  3. 在面向对象编程中,生成和使用程序代码最有可能采用的方法就是直接将数据和方法包装进一个类中,并使用该类的对象。也可以运用组合技术使用现有类来开发新的类;而继承技术其实是不太常用的。
  4. 可能使用到final的三种情况:数据、方法和类
    No.1: final数据,许多编程语言都有某种方法,来向编译器告知一块数据是恒定不变的。对于基本数据类型,final使数值恒定不变;而用于对象引用,final使引用恒定不变。一旦引用被初始化指向一个对象,就无法再把它改为指向另一个对象。然而,对象其自身却是可以被修改的,Java并未提供使任何对象恒定不变的途径。这一限制同样适用数组,它也是对象。既是static又是final的域(即编译器常量)将用大写表示,并使用下划线分隔各个单词。
    No.2: final方法,想要确保在继承中使方法行为保持不变,并且不会被覆盖。
  5. 由于向上转型是从一个较专用类型向较通用类型转换,所以总是很安全的。在向上转型的过程中,类接口中唯一可能发生的事情是丢失方法,而不是获取它们。这就是为什么编译器在“未曾明确表示转型”或“未曾指定特殊标记”的情况下,仍然允许向上转型的原因。向上转型的例子:
class Instrument {public void play() {}static void tune(Instrument i) {i.play(); }
}
public class Wind extends Instrument {public static void main (String[] args) {Wind flute = new Wind();Instrument.tune(flute); //upcasting}
}

第8章 多态

  1. 这样做是允许的——因为Wind从Instrument继承而来,所以Instrument的接口必定存在于Wind中。
  2. 将一个方法调用同一个方法主体关联起来被称作绑定。若在程序执行前进行绑定(如果有的话,由编译器和连接程序实现),叫做前期绑定。它是面向过程的语言中不需要选择就默认的绑定方式。例如,C只有一种方法调用,那就是前期绑定。后期绑定机制随编程语言的不同而有所不同,但是只要想一下就会得知,不管怎样都必须在对象中安置某种“类型信息”。Java中除了static方法和final方法(private方法属于final方法)之外,其他所有的方法都是后期绑定。
  3. 向上转型可以像下面这条语句这么简单:Shape s = new Circle(); 这里,创建了一个Circle对象,并把得到的引用立即赋值给Shape。
  4. 当Sub对象转型为Super引用时,任何域访问操作都将由编译器解析,因此不是多态的。尽管这看起来好像会成为一个容易令人混淆的问题,但是在实践中,它实际上从来不会发生。首先,你通常会将所有的域都设置为private,因此不能直接访问它们,其副作用是只能调用方法来访问。
  5. 导出类只能访问它自己的成员,不能访问基类中的成员(基类成员通常是private类型)。只有基类的构造器才具有恰当的知识和权限来对自己的元素进行初始化。因此,必须令所有构造器都得到调用,否则就不可能正确地构造完整对象。这正是编译器为什么要强制每个导出类部分都必须调用构造器的原因。
  6. 然而,如果这些成员对象中存在于其他一个或多个对象共享的情况,问题就变得更加复杂了,你就不能简单地假设你可以调用dispose()了。在这种情况下,也许就必须使用引用计数来跟踪仍旧访问着共享对象的对象数量了。
  7. static long counter跟踪所创建的Shared的实例的数量,还可以为id提供数值。counter的类型是long而不是int,这样可以防止溢出(这只是一个良好实践)。id是final的,因为我们不希望它的值在对象生命周期中被改变。
  8. 因此,编写构造器时有一条有效的准则:“用尽可能简单的方法使对象进入正常状态;如果可以的话,避免调用其他方法”。在构造器内唯一能够安全调用的那些方法是 基类中的final方法(也适用于private方法,它们自动属于final方法)。
  9. 事实上,当我们使用现成的类来建立新类时,如果首先考虑使用继承技术,反倒会加重我们的设计负担,使事情变得不必要地复杂起来。更好的方式是首先选择“组合”,尤其是不能十分确定应该使用哪一种方式时。
  10. 一条通用的准则是:“用继承表达行为间的差异,并用字段表达状态上的变化”。在上述例子中,两者都用到了:通过继承得到了两个不同的类,用于表达act()方法的差异;而Stage通过运用组合使自己的状态发生变化。在这种情况下,这种状态的变化也就产生了行为的改变。
  11. 在某些程序设计语言(如C++)中,我们必须执行一个特殊的操作来获得安全的向下转型。但是在Java语言中,所有转型都会得到检查!所以即使我们只是进行一次普通的加括弧形式的类型转换,在进入运行期时仍然会对其进行检查,以便保证它的确是我们希望的那种类型。如果不是,就会返回一个ClassCastException。这种在运行期间对类型进行检查的行为称作“运行时类型识别”(RTTI)。

第9章 接口

  1. 抽象类,是普通的类和接口之间的一种中庸之道。尽管在构建具有某些未实现方法的类时,你的第一想法可能是创建接口,但是抽象类仍旧是用于此目的的一种重要而必须的工具。因为你总不可能总是使用纯接口。
  2. 因此,Instrument只是表示了一个接口,没有具体的实现内容;因此,创建一个Instrument对象就没有什么意义,并且我们可能还想阻止使用者这样做。通过让Instrument中的所有方都产生错误,就可以实现这个目的。但是这样做会将错误信息延迟到运行时才获得,并且需要在客户端进行可靠、详尽的测试。所以最好是在编译时捕获这些问题。
  3. 既然使某个类成为抽象类并不需要所有的方法都是抽象的,所以仅需将某些方法声明为抽象的即可。而interface这个关键字产生了一个完全抽象的类,它根本就没有提供任何具体实现。
  4. 由于toString()方法是根类Object的一部分,因此它不需要出现在接口中。
  5. 接口不仅仅只是一种更纯粹形式的抽象类,它的目标比这更高。因为接口是根本没有任何具体实现的——也就是说,没有任何与接口相关的存储;因此,也就无法阻止多个接口的组合。在C++中,组合多个类的接口的行为被称作多重继承。它可能会使你背负很沉重的包袱,因为每个类都有一个具体实现。在Java中,你可以执行相同的行为,但是只有一个类可以有具体实现;因此,通过组合多个接口,C++中的问题是不会发生在Java中的。
  6. 注意,CanFlight接口与ActionCharacter类中的fight()方法的特征签名是一样的,而且,在Hero中并没有提供fight()的定义。当想要创建对象时,所有的定义首先必须都存在。即使Hero没有显式地提供fight()的定义,其定义也因ActionCharacter而随之而来,这样就使得创建Hero对象成为了可能。
  7. 如果要创建不带任何方法定义和成员变量的基类,那么就应该选择接口而不是抽象类。事实上,如果知道某事物应该成为一个基类,那么第一选择应该是使它成为一个接口。
  8. 例如,Java SE5的Scanner类的构造器接受的就是一个Readable接口。你会发现Readable没有用作Java标准类库中其他任何方法的参数,它是单独为Scanner创建的,以使得Scanner不必将其参数限制为某个特定类。通过这种方式,Scanner可以作用于更多的类型。如果你创建了一个新的类,并且想让Scanner可以作用于它,那么你就应该让它成为Readable。
  9. NestingInterfaces展示了嵌套接口的各种实现方式。特别要注意的是,当实现某个接口时,并不需要实现嵌套在其内部的任何接口。而且,private接口不能在定义它的类之外被使用。(只能在类的内部使用private接口,即使你有类的对象,你也不能直接使用private接口)

第10章 内部类

  1. Sequence类只是一个固定大小的Object数组,以类的形式包装了起来。要获取Sequence中的每一个对象,可以使用Selector接口。这是“迭代器”设计模式的一个例子。Selector允许你检查序列是否到了末尾了(end()),访问当前对象(current()),以及移到序列中的下一个对象(next())。因为Selector是一个接口,所以别的类可以按它们自己的方式来实现这个接口,并且别的方法能以此接口为参数,来生成更加通用的代码。
  2. 所以内部类自动拥有对其外围类所有成员的访问权。这是如何做到的呢?当某个外部类的对象创建了一个内部类的对象时,此内部类对象必定会秘密地捕获一个指向那个外围类对象的引用。然后,在你访问此外围类的成员时,就是用那个引用来选择外围类的成员。
  3. 要想直接创建内部类的对象,你不能按照你想象的方式,去引用外部类的名字DotNew,而是必须使用外部类的对象来创建该内部类对象:DotNew dn = new DotNew(); DotNew.Inner dni = dn.new Inner();。这也解决了内部类名字作用域的问题,因此你不必声明(实际上你不能声明)dn.new DotNew.Inner()。
  4. 在拥有外部类对象之前是不可能创建内部类对象的。这是因为内部类对象会暗暗地连接到创建它的外部类对象上。但是,如果你创建的是嵌套类(静态内部类),那么它就不需要对外部类对象的引用。
  5. (Contents和Destination是公共接口,是客户端程序员可用的接口,Parcel4在内部类中实现了这两个公共接口)。Parcel4中增加了一些新东西:内部类PContents是private,所以除了Parcel4,没有人能访问它。PDestination是protected,所以只有Parcel4及其子类、还有与Parcel4同一个包中的类(因为protected也给予了包访问权限)能访问PDestination,其他类都不能访问PDestination。这意味着,如果客户端程序员想了解或访问这些成员,那是要受到限制的。实际上,甚至不能向下转型成private内部类(或protected内部类,除非是继承自它的子类),因为不能访问其名字。于是,private内部类给类的设计者提供了一种途径,通过这种方式可以完全阻止任何依赖于类型的编码,并且完全隐藏了实现的细节。
  6. 这种奇怪的语法指的是:“创建一个继承自Contents的匿名类的对象。”通过new表达式返回的引用被自动向上转型为对Contents的引用。
  7. 如果你想要创建某些公共代码,使得它们可以被某个接口的所有不同实现所共用,那么使用接口的内部的嵌套类会显得很方便。
  8. 我曾在本书中建议过,在每个类中都写一个main()方法,用来测试这个类。这样做有一个缺点,那就是必须带着那些已编译过的额外代码。如果这对你是个麻烦,那就可以使用嵌套类来放置测试代码。这生成了一个独立的类TestBed$Tester(要运行这个程序,执行java TestBed$Tester即可,在Unix/Linux系统中必须转义$)。可以使用这个类来做测试,但是不必在发布的产品中包含它,在将产品打包前可以简单地删除TestBed$Tester.class。
  9. 一般来说,内部类继承自某个类或实现某个接口,内部类的代码操作创建它(内部类)的外围类的对象。所以可以认为内部类提供了某种进入其外围类的窗口。
  10. 使用内部类最吸引人的原因是:每个内部类都能独立地继承自一个(接口的)实现,所以无论外围类是否已经继承了某个(接口的)实现,对于内部类都没有影响。
  11. 内部类并没有令人迷惑的“is-a”关系,它就是一个独立的实体。举个例子,如果Sequence.java不适用内部类,就必须声明“Sequence是一个Selector”,对于某个特定的Sequence只能有一个Selector。然而使用内部类很容易就能拥有另一个方法reverseSelector(),用它来生成一个反方向遍历序列的Selector。只有内部类才有这种灵活性。
  12. Callee2继承自MyIncrement,后者已经有了一个不同的increment()方法,并且与Incrementable接口期望的increment()方法完全不相关。所以如果Callee2继承了MyIncrement,就不能为了Incrementable的用途而覆盖increment()方法,于是只能使用内部类独立地实现Incrementable。
  13. 内部类Closure实现了Incrementable,以提供一个返回Callee2的“钩子”(hook)——而且是一个安全的钩子。无论谁获得此Incrementable的引用,都只能调用increment(),除此之外没有其他功能(不像指针那样,允许你做很多事情)。
  14. 10.8.2 内部类与控制框架 很有意思,有时间要摘抄进博客里。
  15. 控制框架的完整实现是由单个的类创建的,从而使得实现的细节被封装了起来。内部类用来表示解决问题所必需的各种不同的action()。
  16. 内部类能够很容易地访问外围类的任意成员,所以可以避免这种实现变得笨拙。如果没有这种能力,代码将变得令人讨厌,以至于你肯定会选择别的方法。
  17. 读者可能注意到了内部类是多么像多重继承:Bell和Restart有Event的所有方法,并且似乎也拥有外围类GreenhouseControls的所有方法。

第11章 持有对象

  1. 这个实例还表明,如果不需要使用每个元素的索引,你可以使用foreach语法来选择List中的每个元素:for(Apple c : apples)。当你指定了某个类型作为泛型参数时,你并不仅限于只能将该确切类型的对象放置到容器中。向上转型也可以像作用于其他类型一样作用于泛型。因此,你可以将Apple的子类型添加到被指定为保存Apple对象的容器中。
  2. Java容器类库的用途是“保存对象”,并将其划分为两个不同的概念:
    No.1:Collection。一个独立元素的序列,这些元素都服从一条或多条规则。List必须按照插入的顺序保存元素,而Set不能有重复元素。Queue按照排队规则来确定对象产生的顺序(通常与它们被插入的顺序相同)。
    No.2:Map。一组成对的“键值对”对象,允许你使用键来查找值。ArrayList(最基本最可靠的容器)允许你使用数字来查找值,因此从某种意义上讲,它将数字与对象关联在了一起。映射表允许我们使用另一个对象来查找某个对象,它被称为“关联数组”,因为它将某些对象与另外一些对象关联在了一起;或者被称为“字典”,因为你可以使用键对象来查找值对象,就像在字典中使用单词来定义一样。Map是强大的编程工具。
  3. List<Apple> apples = new ArrayList<Apple>(); 使用接口的目的在于如果你决定去修改你的实现,你所需的只是在创建处修改它:List<Apple> apples = new LinkedList<Apple>(); 因此,你应该创建一个具体类的对象,将其转型为对应的接口,然后再其余的代码中都使用这个接口。这种方式并非总能奏效,因为某些类具有额外的功能。
  4. 你也可以直接使用Arrays.asList()的输出,将其作为List,但是在这种情况下,其底层表示的是数组,因此不能调整尺寸。如果你试图用add()或delete()方法在这种列表中添加或删除元素,就有可能会引发去改变数组尺寸的尝试,因此你将运行时获得”Unsupported Operation”错误。
  5. 迭代器(也是一种设计模式)是一个对象,它的工作是遍历并选择序列中的对象,而客户端程序员不必知道或关心该序列底层的结构。此外,迭代器通常被称为轻量级对象:创建它的代价小。因此,经常可以见到对迭代器有些奇怪的限制;例如,Java的Iterator只能单向移动,这个Iterator只能用来:
    1)使用方法iterator()要求容器返回一个Iterator。Iterator将准备好返回序列的第一个元素。
    2)使用next()获得序列中的下一个元素。
    3)使用hasNext()检查序列中是否还有元素。
    4)使用remove()将迭代器新近返回的元素删除。
    Iterator能够将遍历序列的操作与序列底层的结构分离。
  6. 如果你只需要栈的行为,这里使用继承就不合适了,因为这样会产生具有LinkedList的其他所有方法的类(就像你在第17章中所看到的,Java 1.0的设计者在创建java.util.Stack时,就犯了这个错误)。(所以应该使用组合。)
  7. Set中最常被使用的是测试归属性,你可以很容易地询问某个对象是否在某个Set中。正因如此,查找就成为了Set中最重要的操作,因此你通常都会选择一个HashSet的实现,它专门对快速查找进行了优化。
  8. TreeSet将元素存储在红-黑树数据结构中,而HashSet使用的是散列函数。LinkedHashList因为查询速度的原因也使用了散列,但是看起来它使用了链表来维护元素的插入顺序。如果你想对结果排序,一种方式是使用TreeSet来代替HashSet。
  9. TextFile继承自List<String>,其构造器将打开文件,并根据正则表达式”\W+”将其断开为单词,这个正则表达式表示“一个或多个字母”。
    Set<String> words = new TreeSet<String>(new TextFile(“SetOperations.java”, “\W+”)); 字典序
    Set<String> words = new TreeSet<String>(String.CASE_INSENSITIVE_ORDER); 比较器就是建立排序顺序的对象
    words.addAll(new TextFile(“SetOperations.java”, “\W+”)); 字母序
  10. Map与数组和其他的Collection一样,可以很容易地扩展到多维,而我们只需将其值设置为Map(这些Map的值可以是其他容器,甚至是其他Map)。因此,我们能够很容易地将容器组合起来从而快速地生成强大的数据结构。例如,假设你正在跟踪拥有多个宠物的人,你所需只是一个Map<Person, List<Pet>>。Map可以返回它的键的Set,它的值的Collection,或者它的键值对的Set。keySet()方法产生了由在petPeople中所有键组成的Set,它在foreach语句中被用来迭代遍历该Map。
  11. 队列常被当作一种可靠的将对象从程序的某个区域传输到另一个区域的途径。队列在并发编程中特别重要,因为它们可以安全地将对象从一个任务传输给另一个任务。
  12. 优先级队列算法通常会在插入时排序(维护一个堆),但是它们也可能在移除时选择最重要的元素。如果对象的优先级在它在队列中等待时可以进行修改,那么算法的选择就显得很重要了。
  13. 但是有一点很有趣,就是我们注意到标准C++类库中并没有其容器的任何公共基类——容器之间的所有共性都是通过迭代器达成的。在Java中,遵循C++的方式看起来似乎很明智,即用迭代器而不是Collection来表示容器之间的共性。但是,这两种方法绑定到了一起,因为实现Collection就意味着需要提供iterator()方法。
  14. 当你要实现一个不是Collection的外部类时,由于让它去实现Collection接口可能非常困难或麻烦,因此使用Iterator就会变得非常吸引人。例如,如果我们通过继承一个持有Pet对象的类来创建一个Collection的实现,那么我们必须实现所有Collection方法,即使我们在display()方法中不必使用它们,也必须如此。尽管这可以通过继承AbstractCollection而很容易地实现,但是你无论如何还是要被强制去实现iterator()和size(),以便提供AbstractCollection没有实现,但是AbstractCollection中的其他方法会使用到的方法。
  15. 之所以能工作,是因为Java SE5引入了新的被称为Iterable的接口,该接口包含了一个能够产生Iterator的iterator()方法,并且Iterable接口被foreach用来在序列中移动。因此如果你创建了任何实现Iterable的类,都可以将它用于foreach语句中。
  16. foreach语句可以用于数组或其他任何Iterable,但是这并不意味着数组肯定也是一个Iterable,而任何自动包装也不会自动发生。不存在任何从数组到Iterable的自动转换,你必须手工执行这种转换。
  17. 意识到Arrays.asList()产生的List对象会使用底层数组作为其物理实现是很重要的。只要你执行的操作会修改这个List,并且你不想原来的数组被修改,那么你就应该在另一个容器中创建一个副本。
  18. 新程序中不应该使用过时的Vector、Hashtable和Stack。
  19. 从本例中,你可以看到,如果你实现Collection,就必须实现iterator(),并且只拿实现iterator()与继承AbstractCollection相比,花费的代价只有略微减少。但是,如果你的类已经继承了其他的类,那么你就不能再继承AbstractCollection了。在这种情况下,要实现Collection,就必须实现该接口中的所有方法。此时,继承并提供创建迭代器的能力就会显得容易得多了。
class PetSequence {protected Pet[] pets = Pets.createArray(8);
}
public class NonCollectionSequence extends PetSequence {public Iterator<Pet> iterator() {return new Iterator<Pet>() {private int index = 0;public boolean hasNext() {return index < pets.length;}public Pet next() { return pets[index++]; }public void remove() { // Not implementedthrow new UnsupportedOperationException();}};}
}

第12章 通过异常处理错误

12.2 基本异常

  1. 所有标准异常类都有两个构造器:一个是默认构造器;另一个是接受字符串作为参数,以便能把相关信息放入异常对象的构造器:throw new NullPointerException(“t = null”);

12.3 捕获异常

  1. 长久以来,尽管程序员们使用的操作系统支持恢复模型的异常处理,但他们最终还是转向使用类似“终止模型”的代码,并且忽略恢复行为。所以虽然恢复模型开始显得很吸引人,但不是很实用。其中的主要原因可能是它所导致的耦合:恢复性的处理程序需要了解异常抛出的地点,这势必要包含依赖于抛出位置的非通用性代码。这增加了代码编写和维护的困难,对于异常可能会从许多地方抛出的大型程序来说,更是如此。

12.6 捕获所有异常

  1. void printStackTrace()
    void printStackTrace(PrintStream)
    void printStackTrace(java.io.PrintWriter)
    打印Throwable和Throwable的调用栈轨迹。调用栈显示了“把你带到异常抛出地点”的方法调用序列。其中第一个版本输出到标准错误,后两个版本允许选择要输出的流。

12.8 使用finally进行清理

  1. 当要把除内存之外的资源恢复到它们的初始状态时,就需要用到finally子句。这种需要清理的资源包括:已经打开的文件或网络连接,在屏幕上画的图形,甚至可以是外部世界的某个开关。【其实finally相当于离开异常处理程序,并对抛出异常的方法做出必要的收尾工作,因为finally子句里的语句是和原始方法相关联的,也就是需要回到原始方法的内存那里。这有点和上面说的恢复模型相似了。因为需要知道异常抛出的地点,才能把抛出异常的方法使用过的资源给关掉。】
  2. 从输出中可以看到,VeryImportantException不见了,它被finally子句里的HoHumException所取代。这是相当严重的缺陷,因为异常可能会以一种比前面例子所示更微妙和难以察觉的方式完全丢失。相比之下,C++把“前一个异常还没处理就抛出下一个异常”的情形看成是糟糕的编程错误。也许在Java的未来版本中会修正这个问题(另一方面,要把所有抛出异常的方法,如上例中的dispose()方法,全部打包放到try-catch子句里面)。

12.9 异常的限制

1. OK to add new exceptions for constructors, but you must deal with the base constructor exceptions.
2. Regular methods must conform to base class.
3. Interface CANNOT add exceptions to existing methods from the base class.
4. If the method doesn't already exist in the base class, the exception is OK.
5. You can choose to not throw any exceptions, even if the base version does.
6. Overridden methods can throw inherited exceptions.
7. What happens if you upcast? You must catch the exceptions from the base-class version of the method.

12.10 构造器

  1. FileReader对象本身用处并不大,但可以用它来建立BufferedReader对象。
  2. 可能你会考虑把上述功能放在finalize()里面,但我在第5章讲过,你不知道finalize()会不会被调用(即使能确定它将被调用,也不知道什么时候调用)。这也是Java的缺陷:除了内存的清理之外,所有的清理都不会自动发生。所以必须告诉客户端程序员,这是他们的责任。
  3. 请仔细观察这里的逻辑:对InputFile对象的构造在其自己的try语句块中有效,如果构造失败,将进入外部的catch子句,而dispose()方法不会被调用。但是,如果构造成功,我们肯定想确保对象能够被清理,因此在构造之后立即创建了一个新的try语句块。执行清理的finally与内部的try语句块相关联。在这种方式中,finally子句在构造失败时是不会执行的,而在构造成功时将总是执行。
  4. 这种通用的清理惯用法在构造器不抛出任何异常时也应该运用,其基本规则是:在创建需要清理的对象之后,立即进入一个try-finally语句块
    If construction cannot fail you can group objects
    If construction can fail you must guard each one

12.11 异常匹配

  1. 换句话说,catch(Annoyance e)会捕获Annoyance以及所有从它派生的异常。

12.12 其他可选方式

  1. 异常处理的一个重要原则是“只有在你知道如何处理的情况下才捕获异常”。
  2. “被检查的异常”使这个问题变得有些复杂,因为它们强制你在可能还没准备好处理错误的时候被迫加上catch子句,这就导致了吞食则有害(harmful if swallowed)的问题。
  3. 然而,如果发现有些“被检查的异常”挡住了路,尤其是发现你不得不去对付那些不知道该如何处理的异常,还是有些办法的。

12.12.3 把异常传递给控制台

public static void main(String[] args) throws Exception {}

12.12.4 把“被检查的异常”转换为“不检查的异常”

问题的实质是,当在一个普通方法里调用别的方法时,要考虑到“我不知道该怎样处理这个异常,但是也不想把它”吞“了,或者打印一些无用的消息”。JDK 1.4的异常链提供了一种新的思路来解决这个问题。可以直接把“被检查的异常”包装进RuntimeException里面,就像这样:

try {// ... to do something useful
} catch(IDontKnowWhatToDoWithThisCheckedException e) {throw new RuntimeException(e);
}

如果想把“被检查的异常”这种功能“屏蔽”掉的话,这看上去像是一个好办法。不用“吞下”异常,也不必把它放到方法的异常说明里面,而异常链还能保证你不会丢失任何原始异常的信息。
这种技巧给了你一种选择,你可以不写try-catch子句和/或异常说明,直接忽略异常,让它自己沿着调用栈往上“冒泡”。同时,还可以用getCause()捕获并处理特定的异常。
【不过如果别人的方法已经使用了“被检查的异常”,而我需要调用这个方法的话,好像还是必须要写try-catch了吧】对于许多类库(例如提到过的I/O库),如果不处理异常,你就无法使用它们。

12.14 总结

  1. 尽管异常通常被认为是一种工具,使得你可以在运行时报告错误并从错误中恢复,但是我一直怀疑到底有多少时候“恢复”真正得以实现了,或者能够实现。我认为这种情况少于10%,并且即便是这10%,也只是将栈展开到某个已知的稳定状态,而并没有实际执行任何种类的恢复性行为。无论这是否正确,我一直相信“报告”功能是异常的精髓所在。Java坚定地强调将所有的错误都以异常形式报告的这一事实,正是它远远超过诸如C++这类语言的长处之一,因为在C++这类语言中,需要以大量不同的方式来报告错误,或者根本就没有提供错误报告功能。

第13章 字符串

13.2 重载“+”与StringBuilder

  1. 用于String的“+”与“+=”是Java中仅有的两个重载过的操作符,而Java并不允许程序员重载任何操作符。
  2. 其实重载操作符并没有糟糕到只能让它们自己去重载的地步,但具有讽刺意味的是,与C++相比,在Java中使用操作符重载要容易得多。这一点可以在Python与C#中看到,它们都具有垃圾回收与简单易懂的操作符重载机制。
  3. 因此,当你为一个类编写toString()方法时,如果字符串操作比较简单,那就可以信赖编译器,它会为你合理地构造最终的字符串结果。但是,如果你要在toString()方法中使用循环,那么最好自己创建一个StringBuilder对象,用它来构造最终的结果。

13.3 无意识的递归

  1. 这里发生了自动类型转换,由InfiniteRecursion类型转换成String类型。因为编译器看到一个String对象后面跟着一个“+”,而再后面的对象不是String,于是编译器试着将this转换成一个String。它怎么转换呢,正是通过调用this上的toString()方法,于是就发生了递归调用。
  2. 如果你真的想要打印出对象的内存地址,应该调用Object.toString()方法,这才是负责此任务的方法。所以,你不该使用this,而是应该调用super.toString()方法。

13.4 String上的操作

  1. trim() 将String两端的空白字符删除后,返回一个新的String对象。如果没有改变发生,则返回原始的String对象。
  2. intern() 为每个唯一的字符序列生成一个且仅生成一个String引用。
  3. 从这个表中可以看出,当需要改变字符串的内容时,String类的方法都会返回一个新的String对象。同时,如果内容没有发生改变,String的方法只是返回指向原对象的引用而已。

13.5 格式化输出

  1. Java SE5引入的format方法可用于PrintStream或PrintWriter对象,其中也包括System.out对象。format()方法模仿自C的printf()。format()与printf()是等价的,它们只需要一个简单的格式化字符串,加上一串参数即可,每个参数对应一个格式修饰符。
  2. 当你创建一个Formatter对象的时候,需要向其构造器传递一些信息,告诉它最终的结果将向哪里输出。Formatter的构造器经过重载可以接受多种输出目的地,不过最常见的还是PrintStream()、OutputStream和File。
  3. 格式化说明符:%[argument_index$][flags][width][.precision]conversion
    在默认情况下,数据是右对齐,不过可以通过使用“-”标志来改变对齐方向。
    最常见的应用是控制一个域的最小尺寸,这可以通过指定width来实现。
    与width相对的是precision,它用来指明最大尺寸。
    在将precision应用于String时,它表示打印String时输出字符的最大数量。而在将precision应用于浮点数时,它表示小数部分要显示出来的位数(默认是6位小数),如果小数位数过多则舍入,太少则在尾部补零。如果你对整数应用precision,则会触发异常。

13.6 正则表达式

  1. 如果在其他语言中使用过正则表达式,那你立刻就能发现Java对反斜线\的不同处理。在其他语言中,\\表示“我想要在正则表达式中插入一个普通的(字面上的)反斜线,请不要给它任何特殊的意义。”而在Java中,\\的意思是“我要插入一个正则表达式的反斜线,所以其后的字符具有特殊的意义。”例如,如果你想表示一位数字,那么正则表达式应该是\\d。如果你想要插入一个普通的反斜线,则应该这样\\\\。不过换行和制表符之类的东西只需使用单反斜线:\n\t。
  2. Pattern p = Pattern.compile(arg);
    Matcher m = p.matcher(args[0]);
    m有这些方法:boolean matches(), boolean lookingAt(), boolean find(), boolean find(int start).
    matches()方法用来判断整个输入字符串是否匹配正则表达式模式,而lookingAt()则用来判断该字符串(不必是整个字符串)的始部分是否能够匹配模式。
  3. 在这些标记中,Pattern.CASE_INTENSITIVE、Pattern.MULTILINE以及Pattern.COMMENTS(对声明或文档有用)特别有用。
    Pattern.CASR_INTENSITIVE(?i) 默认情况下,大小写不敏感的匹配假定只有US-ASCII字符集中的字符才能进行。这个标记允许模式匹配不必考虑大小写。通过指定UNICODE_CASE标记及结合此标记,基于Unicode的大小写不敏感的匹配就可以开启了。
    Pattern.MULTILINE(?m) 在多行模式下,表达式^和$分别匹配一行的开始和结束。^还匹配输入字符串的开始,而$还匹配输入字符串的结尾。默认情况下,这些表达式仅匹配输入的完整字符串的开始和结束
    Pattern.COMMENTS(?x) 在这种模式下,空格符将被忽略掉,并且以#开始直到行末的注释也会被忽略掉。通过嵌入的标记表达式也可以开启Unix的行模式
  4. appendReplacement(StringBuffer sbuf, String replacement)执行渐进的替换,而不是像replaceFirst()和replaceAll()那样只替换第一个匹配或全部匹配。这是一个非常重要的方法。它允许你调用其他方法来生成或处理replacement(replaceFirst()和replaceAll()则只能使用一个固定的字符串),使你能够以编程的方式将目标分割成组,从而具备更强大的替换功能。
  5. 此外,replaceFirst()和replaceAll()方法用来替换的只是普通的字符串,所以,如果想对这些替换字符串执行某些特殊处理,这两个方法是无法胜任的。如果你想要那么做,就应该使用appendReplacement()方法。该方法允许你在执行替换的过程中,操作用来替换的字符串。【渐进的】
  6. 目的是捕获每行的最后3个词,每行最后以 结束。不过,在正常情况下是将 结束。不过,在正常情况下是将与整个输入序列的末端相匹配。所以我们一定要显式地告知正则表达式注意输入序列中的换行符。这可以由序列开头的模式标记(?m)来完成。
Matcher m = Pattern.compile("(?m)(\\S+)\\s+((\\S+)\\s+(\\S+))$").matcher(POEM);

13.7 扫描输入

  1. StringReader将String转化为可读的流对象,然后用这个对象来构造BufferReader对象,因为我们要使用BufferReader的readLine()方法。
  2. 有了Scanner,所有的输入、分词以及翻译的操作都隐藏在不同类型的next方法中。普通的next()方法返回下一个String。所有的基本类型(除char之外)都有对应的next方法,包括BigDecimal和BigInteger。所有的next方法,只有在找到一个完整的分词之后才会返回。
  3. 在默认情况下,Scanner根据空白字符对输入进行分词,但是你可以用正则表达式指定自己所需的定界符。我们可以用useDelimiter()来设置定界符,同时,还有一个delimiter()方法,用来返回当前正在作为定界符使用的Pattern对象。
scanner.useDelimiter("\\s*,\\s*");

13.8 StringTokenizer

  1. 使用正则表达式或Scanner对象,我们能够以更加复杂的模式来分割一个字符串,而这对于StringTokenizer来说就很困难了。基本上,我们可以放心地说,StringTokenizer已经可以废弃了。

第14章 类型信息

  1. 要理解RTTI在Java中的工作原理,首先必须知道类型信息在运行时是如何表示的。这项工作是由称为Class对象的特殊对象完成了,它包含了与类有关的信息。事实上,Class对象就是用来创建类的所有的“常规”对象的。为了生成这个类的对象,运行这个程序的Java虚拟机(JVM)将使用被称为“类加载器”的子系统。
  2. 类加载器子系统实际上可以包含一条类加载器链,但是只有一个原生类加载器,它是JVM实现的一部分。原生类加载器加载的是所谓的可信类,包括Java API类,它们通常是从本地盘加载的。
  3. 所有的类都是在对其第一次使用时,动态加载到JVM中的。当程序创建第一个对类的静态成员的引用时,就会加载这个类。这个证明构造器也是类的静态方法,即使在构造器之前并没有使用static关键字。因此,使用new操作符创建类的新对象也会被当作类的静态成员的引用。
  4. Class.forName(“Gum”); 这个方法是Class类(所有Class对象都属于这个类)的一个static成员。Class对象就和其他对象一样,我们可以获取并操作它的引用(这也就是类加载器的工作)。forName()是取得Class对象的引用的一种方法。对forName()的调用是为了它产生的“副作用”:如果类Gum还没有被加载就加载它。
  5. 无论何时,只要你想在运行时使用类型信息,就必须首先获得对恰当的Class对象的引用。Class.forName()就是实现此功能的便捷途径,因为你不需要为了获得Class引用而持有该类型的对象。但是,如果你已经拥有了一个感兴趣的类型的对象,那就可以通过调用getClass()方法来获取Class引用了,这个方法属于根类Object的一部分,它将返回表示该对象的实际类型的Class引用。
  6. 在main中调用的Class.getInterfaces()方法返回的是Class对象,它们表示在感兴趣的Class对象中所包含的接口。如果你有一个Class对象,还可以使用getSuperclass()方法查询其直接基类,这将返回你可以用来进一步查询的Class对象。
  7. Class的newInstance()方法是实现“虚拟构造器”的一种途径,虚拟构造器允许你声明:“我不知道你的确切类型,但是无论如何要正确地创建你自己。”在前面的示例中,up仅仅只是一个Class的引用,在编译期不具备任何更进一步的类型信息。当你创建新实例时,会得到Object引用,但是这个引用指向的是Toy对象。
  8. Java还提供了另一种方法来生成对Class对象的引用,即使用类字面常量。FancyToy.class。这样做不仅更简单,而且更安全,因为它在编译时就会受到检查(因此不需要置于try语句块中)。并且它根除了对forName()方法的调用,所以也更高效。
  9. 类字面常量不仅可以应用于普通的类,也可以应用于接口、数组以及基本数据类型。另外,对于基本数据类型的包装器类,还有一个标准字段TYPE。TYPE字段是一个引用,指向对应的基本数据类型的Class对象。
  10. 注意,有一点很有趣,当使用“.class”来创建对Class对象的引用时,不会自动地初始化该Class对象。为了使用类而做的准备工作实际包含三个步骤:
    1)加载,这是由类加载器执行的。该步骤将查找字节码,并从这些字节码中创建一个Class对象。
    2)链接。在链接阶段将验证类中的字节码,为静态域分配存储空间,并且如果必须的话,将解析这个类创建的对其他类的所有引用。
    3)初始化。如果该类具有超类,则对其初始化,执行静态初始化器和静态初始化块。初始化被延迟到了对静态方法(包括构造器)或者非常数静态域进行首次引用时才执行。
  11. 如果一个static final值是“编译期常量”,就像Initable.staticFinal那样,那么这个值不需要对Initable类进行初始化就可以被读取。但是,如果只是将一个域设置为static和final的,还不足以确保这种行为,例如,对Initable.staticFinal2的访问将强制进行类的初始化,因为它不是一个编译期常量。如果一个static域不是final的,那么在对它访问时,总是要求在它被读取之前,要先进行链接(为这个域分配存储空间)和初始化(初始化该存储空间),就像在对Initable2.staticNonFinal的访问中所看到的那样。
  12. 如果你希望稍微放松一些这种限制,应该怎么办呢?乍一看,好像你应该能够执行类似下面这样的操作:Class<Number> genericNumberClass = int.class; 这看起来似乎是起作用的,因为Integer继承自Number。这无法工作,因为Integer Class对象不是Number Class对象的子类。
  13. 在Java SE5中,Class<?>优于平凡的Class,即便它们是等价的,并且平凡的Class如你所见,不会产生编译器警告信息。Class<?>的好处是它表示你并非是碰巧或者由于疏忽,而使用了一个非具体的类引用,你就是选择了非具体的版本。
  14. 为了创建一个Class引用,它被限定为某种类型,或该类型的任何子类型,你需要将通配符与extends关键字相结合,创建了一个范围。因此,与仅仅声明Class<Number>不同,现在做如下声明:Class<? extends Number> bounded = int.class; bounded = double.class; bounded = Number.class;
  15. 在C++中,经典的类型转换“(Shape)”并不使用RTTI。它只是简单地告诉编译器将这个对象作为新的类型对待。而Java要执行类型检查,这通常被称为“类型安全的向下转型”。
  16. RTTI在Java中还有第三种形式,就是关键字instanceof。它返回一个布尔值,告诉我们对象是不是某个特定类型的实例。
  17. 动态的instanceof:Class.isInstance方法提供了一种动态地测试对象的途径。
public void count(Pet pet) {//Class.isInstance() eliminates instanceofs:for(Map.Entry<Class<? extends Pet>, Integer> pair : entrySet())if(pair.getKey().isInstance(pet))put(pair.getKey(), pair.getValue() + 1);
} //传的参数可以是Pet及它的所有继承类。

14.4 注册工厂

  1. 你可能会考虑在每个子类中添加静态初始化器,以使得该初始化器可以将它的类添加到某个List中。遗憾的是,静态初始化器只有在类首先被加载的情况下才能被调用,因此你就碰上了“先有鸡还是先有蛋”的问题:生成器在其列表中不包含这个类,因此它永远不能创建这个类的对象,而这个类也就不能被加载并置于这个列表中。这主要是因为,你被强制要求自己去手工创建这个列表(除非你想编写一个工具,它可以全面搜索和分析源代码,然后创建和编译这个列表)。因此,你最佳的做法是,将这个列表置于一个位于中心的、位置明显的地方,而我们感兴趣的继承结构的基类可能就是这个最佳位置。这里我们需要做的其他修改就是使用工厂方法设计模式,将对象的创建工作交给类自己去完成。工厂方法可以被多态地调用,从而为你创建恰当类型的对象。在下面这个非常简单版本中,工厂方法就是Factory接口中的create()方法:public interface Factory { T create(); } 如果某个类应该由createRandom()方法创建,那么它就包含一个内部Factory类。
package typeinfo.factory;
public interface Factory<T>
{T create();
}// ---------------------------------------------------------class Part
{public String toString() {return getClass().getSimpleName();}static List<Factory<? extends Part>> partFactories = new ArrayList<Factory<? extends Part>>();static {// Collections.addAll() gives an "unchecked generic // array creation ... for varargs parameter" warning.partFactories.add(new FuelFilter.Factory());partFactories.add(new AirFilter.Factory());partFactories.add(new CabinAirFilter.Factory());partFactories.add(new OilFilter.Factory());partFactories.add(new FanBelt.Factory());partFactories.add(new PowerSteeringBelt.Factory());partFactories.add(new GeneratorBelt.Factory());}private static Random rand = new Random(47);public static Part createRandom() {int n = rand.nextInt(partFactories.size());return partFactories.get(n).create();}
}class Filter extends Part {}class FuelFilter extends Filter
{// Create a Class Factory for each specific type:public static class Factory implements typeinfo.factory.Factory<FuelFilter> {public FuelFilter create() {return new FuelFilter();}}
}public class RegisteredFactories
{public static void main(String[] args) {for (int i = 0; i < 10 ; i++ ){System.out.println(Part.createRandom());}
}

基类Part保存的是它所有继承类的内部工厂类的列表。在执行
partFactories.add(new FuelFilter.Factory());
时,FuelFilter对象还未创建。因为在第10章 内部类 第三节 使用.this与.new中有这句话:

在拥有外部类对象之前是不可能创建内部类对象的。这是因为内部类对象会暗暗地连接到创建它的外部类对象中。但是,如果你创建的是嵌套类(静态内部类),那么它就不需要对外部类对象的引用。

而我们从FuelFilter类的代码中看到,它的内部类Factory确实是静态的!因而,并且内部类Factory的create()方法提供了统一的创建各继承类对象的方式。
这样,当我们扩大继承结构时,只需要在基类Part的工厂List中添加新的子类的内部Factory类就可以了。

14.5 instanceof与Class的等价性

  1. instanceof保持了类型的概念,它指的是“你是这个类吗,或者你是这个类的派生类吗?”而如果用==比较实际的Class对象,就没有考虑继承——它或者是这个确切的类型,或者不是。

14.6 反射:运行时的类信息

  1. Class类与java.lang.reflect类库一起对反射的概念进行了支持,该类库包含了Field、Method以及Constructor类(每个类都实现了Member接口)。这些类型的对象是由JVM在运行时创建的,用以表示未知类里对应的成员。这样你就可以使用Constructor创建新的对象,用get()和set()方法读取和修改与Field对象关联的字段,用invoke()方法调用与Method对象关联的方法。另外,还可以调用getFields()、getMethods()和getConstructors()等很便利的方法,以返回表示字段、方法以及构造器的对象的数组。这样,匿名对象的类信息就能在运行时被完全确定下来,而在编译时不需要知道任何事情。

  2. 重要的是,要认识到反射机制并没有什么神奇之处。当通过反射与一个未知类型的对象打交道时,JVM只是简单地检查这个对象,看它属于哪个特定的类(就像RTTI那样)。在用它做其他事情之前必须先加载那个类的Class对象。因此,那个类的.class文件对于JVM来说必须是可获取的:要么在本地机器上,要么可以通过网络取得。所以RTTI和反射之间真正的区别只在于,对RTTI来说,编译器在编译时打开和检查.class文件。(换句话说,我们可以用“普通”方式调用对象的所有方法。)而对于发射机制来说,.class文件在编译时是不可获取的,所以是在运行时打开和检查.class文件。

Class<?> c = Class.forName(args[0]);
Method[] methods = c.getMethods();
Constructor[] ctors = c.getConstructors();

14.7 动态代理

  1. 在任何时刻,只要你想要将额外的操作从“实际”对象中分离到不同的地方,特别是当你希望能够很容易地做出修改,从没有使用额外操作转为使用这些操作,或者反过来时,代理就显得很有用(设计模式的关键就是封装修改——因此你需要修改事务以证明这种模式的正确性)。例如,如果你希望跟踪对RealObject中的方法的调用,或者希望度量这些调用的开销,那么你应该怎样做呢?这些代码肯定是你不希望将其合并到应用中的代码,因此代理使得你可以很容易地添加或移除它们。

  2. 通过调用静态方法Proxy.newProxyInstance()可以创建动态代理,这个方法需要得到一个类加载器(你通常可以从已经被加载的对象中获取其类加载器,然后传递给它),一个你希望该代理实现的接口列表(不是类或抽象类),以及InvocationHandler接口的一个实现。动态代理可以将所有调用重定向到调用处理器,因此通常会向调用处理器的构造器传递给一个“实际”对象的引用,从而使得调用处理器在执行其中介任务时,可以将请求转发。通常,你会执行被代理的操作,然后使用Method.invoke()将请求转发给被代理对象,并传入必需的参数。

  3. 空对象最有用之处在于它更靠近数据,因为对象表示的是问题空间内的实体。

  4. 通常,空对象都是单例,因此这里将其作为静态final实例创建。

14.9 接口与类型信息

  1. interface关键字的一种重要目标就是允许程序员隔离构建,进而降低耦合性。如果你编写接口,那么就可以实现这一目标,但是通过类型信息,这种耦合性还是会传播出去——接口并非是对解耦的一种无懈可击的保障。通过使用RTTI,我们发现a是被当作B实现的。通过将其转型为B,我们可以调用不在A中的方法。这是完全合法和可接受的,但是你也许并不想让客户端程序员这么做,因为这给了他们一个机会,使得他们的代码与你的代码的耦合程度超过你的期望。也就是说,你可能认为interface关键字正在保护你,但是它并没有。

  2. 最简单的方式是对实现使用包访问权限,这样在包外部的客户端就不能看到它了:

class C implements A {public void f() {}public void g() {}void u() {}protected void v() {}private void w() {}
}
public class HiddenC {public static A makeA() { return new C(); }
}

在这个包中唯一public的部分,即HiddenC, 在被调用时将产生A接口类型的对象。这里有趣之处在于:即使你从makeA()返回的是C类型,你在包的外部仍旧不能使用A之外的任何方法,因为你不能在包的外部命名C。现在如果你试图将其向下转型为C,则将被禁止,因为在包的外部没有任何C类型可用。

HOWEVER! 通过使用反射,仍旧可以达到并调用所有方法,甚至是private方法!如果知道方法名,你就可以在其Method对象上调用setAccessible(true),就像在callHiddenMethod()中看到的那样。

static void callHiddenMethod(Object a, String methodName) throws Exception {Method g = a.getClass().getDeclaredMethod(methodName);g.setAccessible(true);g.invoke(a);
}
// Oops! Reflection still allows us to call g():
callHiddenMethod(a, "g");
// And even methods that are less accessible!
callHiddenMethod(a, "u");
callHiddenMethod(a, "v");
callHiddenMethod(a, "w");

你可能会认为,可以通过只发布编译后的代码来阻止这种情况,但是这并不解决问题。因为只需运行javap,一个随JDK发布的反编译器即可突破这一限制。下面是一个使用它的命令行:

javap -private C

-private标志表示所有的成员都应该显示,甚至包括私有成员。下面是输出:

class typeinfo.packageaccess.C extends java.lang.Object implements typeinfo.interfacea.A {typeinfo.packageaccess.C();public void f();public void g();void u();protected void v();private void w();
}

如果将接口实现为一个私有内部类C,则对反射仍旧没有隐藏任何东西。如果是匿名类,则对反射仍旧没有隐藏任何东西。看起来没有任何方式可以阻止反射到达并调用那些非公共访问权限的方法。对于域来说,的确如此,即便是private域。

第16章 数组

16.1 数组为什么特殊

  1. 数组与其他种类的容器之间的区别有三方面:效率、类型【已被泛型解决】和保存基本类型【已被自动装箱解决】的能力。在Java中,数组是一种效率最高的存储和随机访问对象引用序列的方式。
  2. 你可能会建议使用ArrayList,它可以通过创建一个新实例,然后把旧实例中所有的引用移到新实例中,从而实现更多空间的自动分配。
  3. 这两种持有对象的方式都是类型检查型的,并且唯一明显的差异就是数组使用[]来访问元素,而List使用的是add()和get()这样的方法。

16.2 数组是第一级对象

  1. 无论使用哪种类型的数组,数组标识符其实只是一个引用,指向在堆中创建的一个真实对象,这个(数组)对象用以保存指向其他对象的引用。只读成员length是数组对象的一部分(事实上,这是唯一一个可以访问的字段或方法),表示此数组对象可以存储多少元素。length是数组的大小,而不是实际保存的元素个数。
  2. 对象数组和基本类型数组在使用上几乎是相同的;唯一的区别就是对象数组保存的是引用,基本类型数组直接保存基本类型的值。

16.3 返回一个数组

  1. 假设你要写一个方法,而且希望它返回的不止一个值,而是一组值。这对于C和C++这样的语言来说就有点困难,因为它们不能返回一个数组,而只能返回指向数组的指针。这会造成一些问题,因为它使得控制数组的生命周期变得困难,并且容易造成内存泄漏。
  2. 在Java中,你只是直接“返回一个数组”,而无需担心要为数组负责——只要你需要它,它就会一直存在,当你使用完后,垃圾回收器会清理掉它。

16.5 数组与泛型

通常,数组与泛型不能很好地结合。你不能实例化具有参数化类型的数组:

Peel<Banana>[] peels = new Peel<Banana>[10]; //Illegal

擦除会移除参数类型信息,而数组必须知道它们所持有的确切类型,以强制保证类型安全。但是,你可以参数化数组本身的类型。

尽管你不能创建实际的持有泛型的数组对象,但是你可以创建非泛型的数组,然后将其转型。

16.6 创建测试数据

  1. CollectionData类将创建一个Collection对象,该对象中所填充的元素是由生成器gen产生的,而元素的数量则由构造器的第二个参数确定。所有的Collection子类型都拥有toArray()方法,该方法将使用Collection中的元素来填充参数数组。这一节的东西非常好,我仅仅看懂了,填充数组,使用Generator定义填充的方式,然后CollectionData类可以使用这个Generator来填充数组,如果数组还不存在,要使用反射先构造出一个类型的数组,这个太精彩了,我摘抄一下源码
public static <T> T[] array(Class<T> type, Generator<T> gen, int size) {T[] a  = (T[])java.lang.reflect.Array.newInstance(type, size);return new CollectionData<T>(gen, size).toArray(a);
}

16.7 Arrays实用功能

  1. System.arraycopy()需要的参数有:源数组,表示从源数组中的什么位置开始复制的偏移量,表示从目标数组的什么位置开始复制的偏移量,以及需要复制的元素个数。然而,如果复制对象数组,那么只是复制了对象的引用——而不是对象本身的拷贝。这被称作浅复制。【对基本类型是深复制就够用了吧,一般的算法题大概就够用了System.arraycopy(i, 0, j, 0, i.length);】
  2. s1的所有元素都指向同一个对象,而数组s2包含五个相互独立的对象。然而,数组相等是基于内容的(通过Object.equals()比较),所以结果为true。
  3. generator()方法生成一个对象,此对象通过创建一个匿名内部类来实现Generator接口。该例中构建CompType对象,并使用随机数加以初始化。在main()中,使用生成器填充CompType的数组,然后对其排序。如果没有实现Comparable接口,调用sort()的时候会抛出ClassCastException这个运行时异常。因为sort()需要把参数的类型转变为Comparable。
  4. 假设有人给你一个并没有实现Comparable的类,或者给你的类实现了Comparable,但是你不喜欢它的实现方式,你需要另外一个不同的比较方法。要解决这个问题,可以创建一个实现了Comparator接口的单独的类。然后Arrays.sort(a, new CompTypeComparator());【对一个对象类型,Arrays.sort()默认使用这个对象实现的Comparable接口作为排序规则,然而也可以用一个实现Comparator接口的类作为第二个参数,作为新的排序规则】
  5. Java标准库中的排序算法针对正排序的特殊类型进行了优化——针对基本类型设计的“快速排序”,以及针对对象设计的“稳定归并排序”。【为什么分两类,我想应该跟浅复制有关吧,一般对对象都是浅复制,不适合用于快排吧】
  6. “插入点”是指,第一个大于查找对象的元素在数组中的位置,如果数组中所有的元素都小于要查找的对象,“插入点”就等于a.size()。
  7. 如果使用Comparator排序了某个对象数组(基本类型数组无法使用Comparator进行排序),在使用binarySearch()时必须提供同样的Comparator(使用binarySearch()方法的重载版本)。

16.8 总结

  1. 所有这些话题都表示:当你使用最近的Java版本编程时,应该“优选容器而不是数组”。只有在已证明性能成为问题(并且切换到数组对性能提高有所帮助)时,你才应该将程序重构为使用数组。

第17章 容器深入研究

17.1 完整的容器分类法

  1. 但是,事实上容器类库包含足够多的功能,任何时刻都可以满足你的需求,因此你通常可以忽略以Abstract开头的这些类。

17.2 填充容器

  1. MapData的组合(类似CollectionData)
    A single Pair Generator
    Two separate Generators
    A Key Generator and a single value
    An Iterable(包括任何Collection) and a value Generator
    An Iterable and a single value
  2. FlyweightMap必须实现entrySet()方法,它需要定制的Set实现和定制的Map.Entry类。这里正是享元部分:每个Map.Entry对象都只存储了它的索引,而不是实际的键和值。当你调用getKey()和getValue()时,它们会使用该索引来返回恰当的DATA元素。
  3. 你可以在EntrySet.Iterator中看到享元其他部分的实现。与为DATA中的每个数据对都创建Map.Entry对象不同,每个迭代器只有一个Map.Entry。Entry对象被用作数据的视窗,它只包含在静态字符串数组中的索引。你每次调用迭代器的next()方法时,Entry中的index都会递增,使其指向下一个元素对,然后从next()返回该Iterator所持有的单一的Entry对象。
  4. 为了从AbstractList创建只读的List,你必须实现get()和size()。这里再次使用了享元解决方案:当你寻找值时,get()将产生它,因此这个List实际上并不组装。【从它们的构造函数就能知道这些只读List,EntrySet实际上并不组装,它们的构造器只有一个size】
  5. 这个类使用Generator在容器中放置所需数量的对象,然后所产生的容器可以传递给任何Collection的构造器,这个构造器会把其中的数据复制到自身中。addAll()方法是所有Collection子类型的一部分,它也可以用来组装现有的Collection。
Set<String> set = new LinkedHashSet<String>(new CollectionData<String>(new Government(), 15));

Government是实现了Generator接口的对象,差不多就是实现了next()方法。
CollectionData是用这个Generator来生成容器的类,它本身继承了ArrayList。

set.addAll(CollectionData.list(new Government(), 15));

CollectionData的list()方法只是再次调用自己的构造器。

17.4 可选操作

  1. 最常见的未获支持的操作,都来源于背后由固定尺寸的数据结构支持的容器。当你用Arrays.asList()将数组转换为List时,就会得到这样的容器。因为Arrays.asList()会生成一个List,它基于一个固定大小的数组,仅支持那些不会改变数组大小的操作,对它而言是有道理的。任何会引起对底层数据结构的尺寸进行修改的方法都会产生一个UnsupportedOperationException异常,以表示对未获支持操作的调用(一个编程错误)。【那怎么办呢?好办,只要把这个List作为Collection容器的构造器的参数就可以了。这很合理嘛,因为是一个不同的对象了嘛。】
  2. Arrays.asList()返回固定尺寸的List,而Collections.unmodifiableList()产生不可修改的列表。

17.5 List的功能方法

  1. Lists allow random access, which is cheap for ArrayList, expensive for LinkedList.
  2. 在Iterator it做完add()和remove()之后,必须要先next()。Must move to an element after add() and remove()
  3. There are some things that only LinkedLists can do:
    Treat it like a stack, pushing: ll.addFirst();
    Like “peeking” at the top of a stack: ll.getFirst();
    Like popping a stack: ll.removeFirst();
    Treat it like a queue, pulling elements off the tail end: ll.removeLast();

17.6 Set和存储顺序

  1. 当你创建自己的类型时,要意识到Set需要一种方式来维护存储顺序,而存储顺序如何维护,则是在Set的不同实现之间会有所变化。
  2. Set 加入Set的元素必须定义equals()方法以确保对象的唯一性。
    HashSet 存入HashSet的元素必须定义hashCode()
    TreeSet 元素必须实现Comparable接口
    在HashSet上打星号表示,如果没有其他的限制,这就应该是你默认的选择,因为它对速度进行了优化。
  3. 对于良好的编程风格而言,你应该在覆盖equals()方法时,总是同时覆盖hashCode()方法。
  4. 对于没有重新定义hashCode()方法的SetType或TreeType,如果将它们放置到任何散列实现中都会产生重复值,这样就违反了Set的基本契约。这相当烦人,因为这甚至不会有运行时错误。但是,默认的hashCode()是合法的,因此这是合法的行为,即便它不正确。
  5. SortedSet中的元素可以保证处于排序状态,这使得它可以通过在SortedSet接口中的下列方法提供附加的功能:Comparator comparator()返回当前Set使用的Comparator;或者返回null,表示以自然方式排序。
  6. 【amazing! 现在Java API真的提供了Deque了,这一定是从Java编程思想得来的,一本书影响这门语言!】

17.8 理解Map

  1. HashMap使用了特殊的值,称作散列码,来取代对键的缓慢搜索。散列码是“相对唯一”的、用以代表对象的int值,它是通过将该对象的某些信息进行转换而生成的。hashCode()是根类Object中的方法,因此所有Java对象都能产生散列码。HashMap就是使用对象的hashCode()进行快速查询的,此方法能够显著提高性能。

17.9 散列与散列码

  1. 这看起来够简单了,但是它不工作——它无法找到数字3这个键。问题出在Groundhog自动地继承自基类Object,所以这里使用Object的hashCode()方法生成散列码,而它默认是使用对象的地址计算散列码。因此,由Groundhog(3)生成的第一个实例的散列码与由Groundhog(3)生成的第二个实例的散列码是不同的,而我们正是使用后者进行查找的。
  2. 再次强调,默认的Object.equals()只是比较对象的地址,所以一个Groundhog(3)并不等于另一个Groundhog(3)。因此,如果要使用自己的类作为HashMap的键,必须同时重载hashCode()和equals()。
  3. 注意,entrySet()使用了HashSet来保存键-值对,并且MapEntry采用了一种简单的方式,即只使用key的hashCode()方法。但这并不是一个恰当的实现,因为它创建了键和值的副本。entrySet()的恰当实现应该在Map中提供视图,而不是副本,并且这个视图允许对原始映射表进行修改(副本就不行)。
  4. 散列则更进一步,它将键保存在某处,以便能够很快找到。存储一组元素最快的数据结构是数组,所以使用它来表示键的信息(请小心留意,我是说键的信息,而不是键本身)。但是因为数组不能调整容量,因此就有一个问题:我们希望在Map中保存数量不确定的值,但是如果键的数量被数组的容量限制了,该怎么办呢?
  5. 答案就是:数组并不保存键本身。而是通过键对象生成一个数字,将其作为数组的下标。这个数字就是散列码,由定义在Object中的、且可能由你的类覆盖的hashCode()方法生成。
  6. 为解决数组容量被固定的问题,不同的键可以产生相同的下标。也就是说,可能会有冲突。因此,数组多大就不重要了,任何键总能在数组中找到它的位置。
  7. 于是查询一个值的过程首先就是计算散列码,然后使用散列码查询数组。如果能够保证没有冲突(如果值的数量是固定的,那么就有可能),那可就有了一个完美的散列函数,但是这种情况只是特例。【通常,冲突由外部链接处理:数组并不直接保存值,而是保持值的list。然后对list中的值使用equals()方法进行线性的查询。这部分的查询自然会比较慢,但是,如果散列函数好的话,数组的每个位置就只有较少的值。因此,不是查询整个list,而是快速地跳到数组的某个位置,只对很少的元素进行比较。】这便是HashMap会如此快的原因。
  8. 由于散列表中的“槽位”(slot)通常称为桶位(bucket),因此我们将表示实际散列表的数组命名为bucket。为使散列分布均匀,桶的数量通常使用质数。事实证明,质数实际上并不是散列桶的理想容量。(经过广泛的测试)Java的散列函数都使用2的整数次方。对现代的处理器来说,除法与求余数是最慢的操作。使用2的整数次方长度的散列表,可用掩码代替除法。因为get()是使用最多的操作,求余数的%操作是其开销最大的部分,而使用2的整数次方可以消除此开销(也可能对hashCode()有些影响)。【浏览java.util.HashMap的源代码】
  9. get()方法按照与put()方法相同的方式计算在buckets数组中的索引(这很重要,因为这样可以保证两个方法可以计算出相同的位置)。
  10. 设计hashCode()时最重要的因素就是:无论何时,对同一个对象调用hashCode()都应该生成同样的值。所以,如果你的hashCode()方法依赖于对象中易变的数据,用户就要当心了,因为此数据发生变化时,hashCode()就会生成一个不同的散列码,相当于产生了一个不同的键。【Object.hashCode()是根据object的地址做hash的,object的地址会变吧?还记得GC的那个标记-整理算法吗,就算是标记-复制也会变吧?】此外,也不应该使hashCode()依赖于具有唯一性的对象信息,尤其是使用this的值,这只能产生很糟糕的hashCode()。因为这样无法生成一个新的键,使之与put()中原始的键值对中的键相同。
  11. String有个特点:如果程序中有多个String对象,都包含相同的字符串序列,那么这些String对象都映射到同一块内存区域。所以new String(“hello”)生成的两个实例,虽然是相互独立的,但是对它们使用hashCode()应该生成同样的结果。【因为String不可变,所以不必担心对象被更改】
  12. 在Effective Java Programming Language Guide这本书中,Joshua Bloch为怎样写出一份像样的hashCode()给出了基本的指导:
    1)给int变量result赋予某个非零值常量,例如17。
    2)为对象内每个有意义的域f(即每个可以做equals()操作的域)计算出一个int散列码c。【规则在第496页,对基本类型差不多就是转成int值,对对象就调用它的hashCode()方法】
    3)合并计算得到的散列码:result = 37 * result + c;【迭代计算,有多少c,就累积计算多少次】
    4)返回result;
    5)检查hashCode()最后生成的结果,确保相同的对象有相同的散列码。
  13. compareTo()方法有一个比较结构,因此它会产生一个排序序列,排序的规则首先按照实际类型排序,然后如果有名字的话,按照name排序,最后按照创建的顺序排序。

17.10 选择接口的不同实现

  1. get和set测试都使用了随机数生成器来执行对List的随机访问。在输出中,你可以看到,对于背后有数组支撑的List和ArrayList,无论列表的大小如何,这些访问都很快速和一致;而对于LinkedList,访问时间对于较大的列表将明显增加。
  2. LinkedList对List的端点会进行特殊处理——这使得在将LinkedList用作Queue时,速度可以得到提高。
  3. LinkedHashMap在插入时比HashMap慢一点,因为它维护散列数据结构的同时还要维护链表(以保持插入顺序)。正是由于这个列表,使得其迭代速度更快。【同时具备散列数据结构和链表结构是完全可以的,链表结构基本上可以附在任何数据结构上,比如二叉链接树,线性表就不可以,但也因而,使得散列数据结构中的桶上的链表里的值要多一块next】
  4. HashMap的术语:
    容量:表中的桶位数。
    尺寸:表中当前存储的项数【看到没,全是在讲“表”,就是哈希表】
    负载因子:尺寸/容量。
    HashMap使用的默认负载因子是0.75,更高的负载因子可以降低表所需的空间,但是会增加查找代价,这很重要,因为查找是我们在大多数时间里所做的操作(包括get()和put())。
  5. HashMap和HashSet都具有允许你指定负载因子的构造器,表示当负载情况达到该负载因子的水平时,容器将自动增加其容量(桶位数),实现方式是使容量大致加倍,并重新将【现有对象】分布到新的桶位集中(这被称为再散列)。

17.11 实用方法

  1. Java中有大量用于容器的卓越的使用方法,它们被表示为java.util.Collections类内部的静态方法。
    rotate(List, int distance) 所有元素向后移动distance个位置,将末尾的元素循环到前面来。
    swap(List, int i, int j) 交换list中位置i与位置j的元素。通常比你自己写的代码快。
    nCopies(int n, T x) 返回大小为n的List<T>,此List不可改变,其中的引用都指向x。
    disjoint(Collection, Collection) 当两个集合没有任何相同元素时,返回true。
    frequency(Collection, Object x) 返回Collection中等于x的元素个数。
    emptyList() emptyMap() emptySet() 返回不可变的空List、Map或Set。这些方法都是泛型的,因此产生的结果将被参数化为所希望的类型。
    singleton(T x) singletonList(T x) singletonMap(K key, V value) 产生不可变的Set<T>、List<T>或Map<K,V>,它们都只包含基于所给定参数的内容而形成的单一项。
  2. 程序运行时发生了异常,因为在容器取得迭代器之后,又有东西被放入到了该容器中。在此例中,应该添加完所有的元素之后,再获取迭代器。
  3. 设定Collection或Map为不可修改
Collection<String> c = Collections.unmodifiableCollection(new ArrayList<String>(data));
List<String> a = Collections.unmodifiableList(new ArrayList<String>(data));
Set<String> s = Collections.unmodifiableSet(new HashSet<String>(data));
Set<String> ss = Collections.unmodifiableSortedSet(new TreeSet<String>(data));
Map<String, String> m = Collections.unmodifiableMap(new HashMap<String, String>(Countries.capticals(6)));

Collection或Map的同步控制

Collection<String> c = Collections.synchronizedCollection(new ArrayList<String>());
List<String> list = Collections.synchronizedList(new ArrayList<String>());
Set<String> s = Collections.synchronizedSet(new HashSet<String>());
Map<String, String> m = Collections.synchronizedMap(new HashMap<String, String>());

17.12 持有引用

  1. 如果想继续持有对某个对象的引用,希望以后还能够访问到该对象,但是也希望能够允许垃圾回收器释放它,这时就应该使用Reference对象。这样,你可以继续使用该对象,而在内存消耗殆尽的时候又允许释放该对象。
  2. 以Reference对象作为你和普通引用之间的媒介(代理),另外,一定不能有普通的引用指向那个对象,这样就能达到上述目的。(普通的引用指没有经过Reference对象包装过的引用。)
  3. SoftReference用以实现内存敏感的高速缓存。WeakReference是为实现“规范映射”(canonicalizing mappings)而设计的,它不妨碍垃圾回收器回收映射的“键”(或“指”)。“规范映射”中对象的实例可以在程序的多处被同时使用,以节省存储空间。

第18章 Java I/O系统

不仅存在各种I/O源端和想要与之通信的接收端(文件、控制台、网络链接等),而且还需要以多种不同的方式与它们进行通信(顺序、随机存取、缓冲、二进制、按字符、按行、按字等)。

18.1 File类

  1. File(文件)类这个名字有一定的误导性;我们可能会认为它指代的是文件,实际上却并非如此。它既能代表一个特定文件的名称,又能代表一个目录下的一组文件的名称。实际上,FilePath(文件路径)对这个类来说是个更好的名字。
  2. 创建这个类的目的在于把accept()方法提供给list()使用,使list()可以回调accept(),进而以决定哪些文件包含在列表中。因为list()接受FilenameFilter对象作为参数,这意味着我们可以传递实现了FilenameFilter接口的任何类的对象,用以选择(甚至在运行时)list()方法的行为方式。
  3. accept()方法必须接受一个代表某个特定文件所在目录的File对象,以及包含了那个文件名的一个String。记住一点:【list()方法会为此目录对象下的每个文件名调用accept(),来判断该文件是否包含在内】;判断结果由accept()返回的布尔值表示。
  4. 这个例子很适合用一个匿名内部类进行改写。首先创建一个filter()方法,它会返回一个指向FilenameFilter的引用。【注意,传向filter()的参数必须是final的。这在匿名内部类中是必需的,这样它才能够用来自该类范围之外的对象。】我们可以进一步修改该方法,定义一个作为list()参数的匿名内部类。既然匿名内部类直接使用args[0],那么传递给main()方法的参数现在就是final的。

18.2 输入和输出

  1. 编程语言的I/O类库中常使用流这个抽象概念,它代表任何有能力产出数据的数据源对象或者是有能力接收数据的接收端对象。
  2. 我们很少使用单一的类来创建流对象,而是通过叠合多个对象来提供所期望的功能(这是装饰器设计模式)。实际上,Java中“流”类库让人迷惑的主要原因就在于:创建单一的结果流,却需要创建多个对象。
  3. InputStream的作用是用来表示那些从不同数据源产生输入的类。这些数据源包括:字节数组。String对象。文件。“管道”,工作方式与实际管道相似,即,从一端输入,从另一端输出。一个由其他种类的流组成的序列,以便我们可以将它们收集合并到一个流内。其他数据源,如Internet连接等。另外,FilterInputStream也属于一种InputStream,为“装饰器”(decorator)类提供基类,其中,“装饰器”类可以把属性或有用的接口与输入流连接在一起。
  4. ByteArrayInputStream 允许将内存的缓冲区当作InputStream使用
    ByteArrayOutputStream 在内存中创建缓冲区。所有送往“流”的数据都要放置在此缓冲区
  5. 其他FilterInputStream类则在内部修改InputStream的行为方式:是否缓冲,是否保留它所读过的行(允许我们查询行数或设置行数),以及是否把单一字符推回输入流等等。最后两个类看起来更像是为了创建一个编译器(它们被添加进来可能是为了对“用Java构建编译器”实验提供支持),因此我们在一般编程中不会用到它们。【又黑Java…】
  6. 我们几乎每次都要对输入进行缓冲——不管我们正在连接的是什么I/O设备,所以,【I/O类库把无缓冲输入(而不是缓冲输入)作为特殊情况(或只是方法调用)就显得更加合理了。BufferedInputStream】
  7. DataInputStream允许我们读取不同的基本类型数据以及String对象(所有方法都以“read”开头,例如readByte()、readFloat()等等)。搭配相应的DataOutputStream,我们就可以通过数据“流”将基本类型的数据从一个地方迁移到另一个地方。

18.4 Reader和Writer

  1. 几乎所有原始的Java I/O流类都有相应的Reader和Writer类来提供天然的Unicode操作。然而在某些场合,面向字节的InputStream和OutputStream才是正确的解决方案;特别是,java.util.zip类库就是面向字节的而不是面向字符的。【因此,最明智的做法是尽量尝试使用Reader和Writer,一旦程序代码无法成功编译,我们就会发现自己不得不使用面向字节的类库。】
  2. 无论我们何时使用readLine(),都不应该使用DataInputStream(这会遭到编译器的强烈反对),而应该使用BufferedReader。除了这一点,DataInputStream仍是I/O类库的首选成员。

18.6 I/O流的典型使用方式

【一定要分清,流有两种,一种是数据的来源和去处,一种是更改流的行为,在一种流操作中(输入或输出),一般要结合它们(前一个流作为后一个流的构造器的参数)】

18.6.1 缓冲输入文件

  1. 如果想要打开一个文件用于字符输入,可以使用以String或File对象作为文件名的FileReader。为了提高速度,我们希望对那个文件进行缓冲,那么我们将所产生的引用传给一个BufferedReader构造器。由于BufferedReader也提供readLine()方法,所以这是我们的最终对象和进行读取的接口。当readLine()返回null时,你就达到了文件的末尾。
BufferedReader in = new BufferedReader(new FileReader(filename));
String s;
StringBuilder sb = new StringBuilder();
while((s = in.readLine())!=null)sb.append(s + "\n");
in.close();

18.6.3 格式化的内存输入

  1. 如果我们从DataInputStream用readByte()一次一个字节地读取字符,那么任何字节的值都是合法的结果,因此返回值不能用来检测输入是否结束。相反,我们可以使用available()方法查看还有多少可供存取的字符。

18.6.4 基本的文件输出

  1. FileWriter对象可以向文件写入数据。首先,创建一个与指定文件连接的FileWriter。实际上,我们通常会用BufferedWriter将其包装起来用以缓冲输出。在本例中,为了提供格式化机制,它被装饰成了PrintWriter。
    static String file = “BasicFileOutput.out”;
    PrintWriter out = new PrintWriter(new BufferedWriter(new FileWriter(file)));
  2. 文本文件输出的快捷方式。Java SE5在PrintWriter中添加了一个辅助构造器,使得你不必在每次希望创建文本文件并向其中写入时,都去执行所有的装饰工作。
    PrintWriter out = new PrintWriter(file);
    你仍旧是在进行缓存,只是不必自己去实现。遗憾的是,其他常见的写入任务都没有快捷方式,因此典型的I/O仍旧包含大量的冗余文本。但是,本书所使用的在本章稍后进行定义的TextFile工具简化了这些常见任务。

18.6.5 存储和恢复数据

DataOutputStream out = new DataOutputStream(new BufferedOutputStream(new FileOutputStream("Data.txt")));
out.writeDouble(3.14159);
out.writeUTF("That was pi");
out.writeDouble(1.41413);
out.writeUTF("Square root of 2");
out.close();
DataInputStream in = new DataInputStream(new BufferedInputStream(new FileInputStream("Data.txt")));
System.out.println(in.readDouble());
// Only readUTF() will recover the Java-UTF String properly
System.out.println(in.readUTF());
System.out.println(in.readDouble());
System.out.println(in.readUTF());
  1. 注意DataOutputStream和DataInputStream是面向字节的,因此要使用InputStream和OutputStream。
  2. 如果我们使用DataOutputStream写入数据,Java保证我们可以使用DataInputStream准确地读取数据——无论读和写数据的平台多么不同。只要两个平台上都有Java。
  3. UTF-8是一种多字节格式,其编码长度根据实际使用的字符集会有所变化。如果我们使用的只是ASCII或者几乎都是ASCII字符(只占7位),那么就显得极其浪费空间和带宽,所以UTF-8将ASCII字符编码成单一字节的形式,而非ASCII字符则编码成两到三个字节的形式。另外,字符串的长度存储在UTF-8字符串的前两个字节中。但是,writeUTF()和readUTF()使用的是适合于Java的UTF-8变体,因此如果我们用一个非Java程序读取用writeUTF()所写的字符串时,必须编写一些特殊代码才能正确读取字符串。
  4. 因此,我们必须:要么为文件中的数据采用固定的格式;要么将额外的信息保存到文件中,以便能够对其进行解析以确定数据的存放位置。注意,对象序列化和XML可能是更容易地存储和读取复杂数据结构的方式。

18.6.6 读写随机访问文件

  1. 使用RandomAccessFile,类似于组合使用了DataInputStream和DataOutputStream(因为它实现了相同的接口:DataInput和DataOutput)。
  2. 因为double总是8字节长,所以为了用seek()查找第5个双精度值,你只需用5*8来产生查找位置。
  3. RandomAccessFile除了实现DataInput和DataOutput接口之外,有效地与I/O继承层次结构的其他部分实现了分离。因为它不支持装饰,所以不能将其与InputStream及OutputStream子类的任何部分组合起来。我们必须假定RandomAccessFile已经被正确缓冲,因为我们不能为它添加这样的功能。
  4. 你可能会考虑使用“内存映射文件”来代替RandomAccessFile。

18.7 文件读写的使用工具

  1. 它包含的static方法可以像简化字符串那样读写文本文件,并且我们可以创建一个TextFile对象,它用一个ArrayList来保存文件的若干行(如此,当我们操纵文件内容时,就可以使用ArrayList的所有功能)。
  2. 因为这个类希望将读取和写入文件的过程简单化,因此所有的IOException都被转型为RuntimeException,因此用户不必使用try-catch语句块。但是,你可能需要创建另一种版本将IOException传递给调用者。
  3. 另一个解决读写问题的方法是使用在Java SE5中引入的java.util.Scanner类。但是,这只能用于读取文件,而不能用于写入文件,并且这个工具(你会注意到它不在java.io包中)主要是设计用来创建编程语言的扫描器或“小语言”的。
  4. 一个很常见的程序化任务就是读取文件到内存,修改,然后再写出。Java I/O类库的问题之一就是:它需要编写相当多的代码去执行这些常用操作——没有任何基本的帮助功能可以为我们做这一切。更糟糕的是,装饰器会使得要记住如何打开文件变成一件相当困难的事。

18.8 标准I/O

  1. 程序的所有输入都可以来自于标准输入,它的所有输出也都可以发送到标准输出,以及所有的错误信息都可以发送到标准错误。标准I/O的意义在于:我们可以很容易地把程序串联起来,一个程序的标准输出可以成为另一程序的标准输入。这真是一个强大的工具。

18.8.1 从标准输入中读取

  1. System.out已经事先被包装成了printStream对象。System.err同样也是PrintStream,但System.in却是一个没有被包装过的未经加工的InputStream。这意味尽管我们可以立即使用System.out和System.err,但是在读取System.in之前必须对其进行包装。
  2. 通常我们会用readLine()一次一行地读取输入,为此,我们将System.in包装成BufferedReader来使用。这要求我们必须用InputStreamReader把System.in转换成Reader。
BufferedReader stdin = new BufferedReader(new InputStreamReader(System.in));

18.8.2 将System.out转换成PrintWriter

  1. System.out是一个PrintStream,而PrintStream是一个OutputStream。PrintWriter有一个可以接受OutputStream作为参数的构造器。因此,只要需要,就可以使用那个构造器把System.out转换成PrintWriter。

18.8.3 标准I/O重定向

PrintStream console = System.out;
BufferedInputStream in = new BufferedInputStream(new FileInputStream("Redirecting.java"));
PrintStream out = new PrintStream(new BufferedOutputStream(new FileOutputStream("test.out")));
System.setIn(in);
System.setOut(out);
System.setErr(out);
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
String s;
while((s = br.readLine()) != null)System.out.println(s);
out.close(); // Remember this!
System.setOut(console);
  1. 这个程序将标准输入附接到文件上,并将标准输出和标准错误重定向到另一个文件。注意,它在程序开头处存储了对最初的System.out对象的引用,并且在结尾处将系统输出恢复到了该对象上。
  2. I/O重定向操纵的是字节流,而不是字符流;因此我们使用的是InputStream和OutputStream,而不是Reader和Writer。

18.10 新I/O

  1. 实际上,旧的I/O包已经使用nio重新实现过,以便充分利用这种速度提高,因此,即使我们不显式地用nio编写代码,也能从中受益。
  2. 速度的提高来自于所使用的结构更接近于操作系统执行I/O的方式:通道和缓冲器。我们并没有直接和通道交互;我们只是和缓冲区交互,并把缓冲器派送到通道。通道要么从缓冲器获得数据,要么向缓冲器发送数据。
  3. 唯一直接与通道交互的缓冲器是ByteBuffer——也就是说,可以存储未加工字节的缓冲器。java.nio.ByteBuffer是相当基础的类:通过告知分配多少存储空间来创建一个ByteBuffer对象,并且还有一个方法选择集,用于以原始的字节形式或基本数据类型输出和读取数据。但是,没办法输出或读取对象,即使是字符串对象也不行。这种处理虽然很低级,但却正好,因为这是大多数操作系统中更有效的映射方式。
  4. 旧I/O类库中有三个类被修改了,用以产生FileChannel。这三个被修改的类是FileInputStream、FileOutputStream以及用于既读又写的RandomAccessFile。
    FileChannel fc = new FileOutputStream(“data.txt”).getChannel();
    fc.write(ByteBuffer.wrap(“Some text “.getBytes()));
    fc.close();
  5. 对于只读访问,我们必须显式地使用静态的allocate()方法来分配ByteBuffer。nio的目标就是快速移动大量数据,因此ByteBuffer的大小就显得尤为重要——实际上,这里使用的1K可能比我们通常要使用的小一点(必须通过实际运行应用程序来找到最佳尺寸)。
  6. ByteBuffer被分配了空间,当FileChannel.read()返回-1时(一个分界符,毋庸置疑,它源于Unix和C),表示我们已经到达了输入的末尾。每次read()操作之后,就会将数据输入到缓冲器中,flip()则是准备缓冲器以便它的信息可以由write()提取。write()操作之后,信息仍在缓冲器中,接着clear()操作则对所有的内部指针重新安排,以便缓冲器在另一个read()操作期间能够做好接收数据的准备。
  7. 特殊方法transferTo()和transferFrom()则允许我们将一个通道和另一个通道直接相连
    in.transferTo(0, in.size(), out);
    // Or: out.transferFrom(in, 0, in.size());
  8. 向ByteBuffer插入基本类型数据的最简单的方法是:利用asCharBuffer()、asShortBuffer()等获得该缓冲器上的视图,然后使用视图的put()方法。使用ShortBuffer的put()方法时,需要进行类型转换(注意类型转换会截取或改变结果)。而其他所有的视图缓冲器在使用put()方法时,不需要进行类型转换。
  9. “big endian”(高位优先)将最重要的字节存放在地址最低的【存储器单元】。而”little endian”(低位优先)则是将最重要的字节放在地址最高的【存储器单元】。当存储量大于一个字节时,像int、float等,就要考虑字节的顺序问题了。ByteBuffer是以高位优先的形式存储数据的,并且数据在网上传送时也常常使用高位优先的形式。我们可以使用带有参数ByteOrder.BIG_ENDIAN或ByteOrder.LITTLE_ENDIAN的order()方法改变ByteBuffer的字节排序方式。
  10. 如果想把一个字节数组写到文件中去,那么就应该使用ByteBuffer.wrap()方法把字节数组包装起来,然后用getChannel()方法在FileOutputStream上打开一个通道,接着将来自于ByteBuffer的数据写到FileChannel中。
  11. 一旦调用read()来告知FileChannel向ByteBuffer存储字节,就必须调用缓冲器上的flip(),让它做好让别人读取字节的准备(是的,这似乎有一点拙劣,但是请记住,它是很拙劣的,但却适用于获取最大速度)。如果我们打算使用缓冲器执行进一步的read()操作,我们也必须得调用clear()来为每个read()做好准备。
{Args: ChannelCopy.java test.txt}
private static final int BSIZE = 1024;
FileChannel in = new FileInputStream(args[0]).getChannel();
FileChannel out = new FileOutputStream(args[1]).getChannel();
ByteBuffer buffer = ByteBuffer.allocate(BSIZE);
while(in.read(buffer) != -1) {buffer.flip(); // Prepare for writingout.write(buffer);buffer.clear(); // Prepare for reading
}

18.10.5 缓冲器的细节

  1. Buffer由数据和可以高效地访问及操纵这些数据的四个索引组成,这四个索引是:mark(标记),position(位置),limit(界限)和capacity(容量)。
  2. clear() 清空缓冲区,将position设置为0,limit设置为容量。我们可以调用此方法覆写缓冲区。
    flip() 将limit设置为position,position设置为0。此方法用于准备从缓冲区读取已经写入的数据。
  3. 尽管可以通过对某个char数组调用wrap()方法来直接产生一个CharBuffer,但是在本例中取而代之的是分配一个底层的ByteBuffer,产生的CharBuffer只是ByteBuffer上的一个视图而已,这里要强调的是,我们总是以操纵ByteBuffer为目标,因为它可以和通道进行交互。
  4. 使用reset()把position的值设为mark的值。如果想显示缓冲器的全部内容,必须使用rewind()把position设置到缓冲器的开始位置,mark的值则变得不明确。

18.10.6 内存映射文件

  1. 内存映射文件允许我们创建和修改那些因为太大而不能放入内存的文件。有了内存映射文件,我们就可以假定整个文件都放在内存中,而且可以完全把它当作非常大的数组来访问。这种方法极大地简化了用于修改文件的代码。
  2. 尽管“映射写”似乎要用到FileOutputStream,但是映射文件中的所有输出必须使用RandomAccessFile。注意test()方法包括初始化各种I/O对象的时间,因此,即使建立映射文件的花费很大,但是整体受益比起I/O流来说还是很显著的。
  3. 为了既能写又能读,我们先由RandomAccessFile开始,获得该文件上的通道,然后调用map()产生MappedByteBuffer,这是一种特殊类型的直接缓冲器。我们必须指定映射文件的初始位置和映射区域的长度,这意味着我们可以映射某个大文件的较小的部分。
MappedByteBuffer out = new RandomAccessFile("test.dat", "rw").getChannel().map(FileChannel.MapMode.READ_WRITE, 0, length);

18.10.7 文件加锁

  1. JDK 1.4引入了文件加锁机制,它允许我们同步访问某个作为共享资源的文件。不过,竞争同一个文件的两个线程可能在不同的Java虚拟机上;或者一个是Java线程,另一个是操作系统中其他的某个本地线程。文件锁对其他的操作系统进城是可见的,因为Java的文件加锁直接映射到了本地操作系统的加锁工具。
  2. SocketChannel、DatagramChannel和ServerSocketChannel不需要加锁,因为它们是从单进程实体继承而来;我们通常不在两个进城之间共享网络socket。
  3. 我们可能需要对这种巨大的文件进行部分加锁,以便其他进城可以修改文件中未被加锁的部分。例如,数据库就是这样,因此多个用户可以同时访问到它。
  4. 线程类LockAndModify创建了缓冲区和用于修改的slice(),然后在run()中,获得文件通道上的锁(我们不能获得缓冲器上的锁,只能是通道上的)。
  5. 如果有Java虚拟机,它会自动释放锁,或者关闭加锁的通道。不过我们也可以像程序中那样,显示地为FileLock对象调用release()来释放锁。
  6. 也可以使用如下方法对文件的一部分上锁:
    tryLock(long position, long size, boolean shared)
    或者 lock(long position, long size, boolean shared)
    其中,加锁的区域由size-position决定。

18.11 压缩

18.11.1 用GZIP进行简单压缩

BufferedReader in = new BufferedReader(new FileReader("GZIPcompress.java");
BufferedOutputStream out = new BufferedOutputStream(new GZIPOutputStream(new FileOutputStream("test.gz")));BufferedReader in2 = new BufferedReader(new InputStreamReader(new GZIPInputStream(new FileInputStream("test.gz"))));//InputStreamReader和OutputStreamReader是适配器,能够将流转换成Reader,压缩类总是最贴近流source和dest的InputStream或OutputStream,接着再被其他装饰类包装

18.11.2 用Zip进行多文件保存

FileOutputStream f = new FileOutputStream("test.zip");
CheckedOutputStream csum = new CheckedOutputStream(f, new Adler32());
ZipOutputStream zos = new ZipOutputStream(csum);
BufferedOutputStream out = new BufferedOutputStream(zos);
for(String arg : args) {BufferedReader in = new BufferedReader(new FileReader(arg));zos.putNextEntry(new ZipEntry(arg));int c;while((c = in.read()) != -1)out.write(c);in.close();out.flush();
}FileInputStream fi = new FileInputStream("test.zip");
CheckedInputStream csumi = new CheckedInputStream(fi, new Adler32());
ZipInputStream in2 = new ZipInputStream(csumi);
BufferedInputStream bis = new BufferedInputStream(in2);
ZipEntry ze;
while((ze = in2.getNextEntry()) != null) {print("Reading file " + ze);int x;while((x = bis.read()) != -1)System.out.write(x);
}
  1. 虽然CheckedInputStream和CheckedOutputStream都支持Adler32和CRC32两种类型的校验和,但是ZipEntry类只有一个支持CRC的接口。虽然这是一个底层Zip格式的限制,但却限制了人们不能使用速度更快的Adler32。
  2. jar [options] destination [manifest] inputfiles(s)
    c 创建一个新的或空的压缩文档
    f 意指:“我打算指定一个文件名。”
    O 只存储文件,不压缩文件(用来创建一个可放在类路径中的JAR文件)

18.12 对象序列化

  1. Java的对象序列化将那些实现了Serializable接口的对象转换成一个字节序列,并能够在以后将这个字节序列化完全恢复为原来的对象。这一过程甚至可通过网络进行;这意味着序列化机制能自动弥补不同操作系统之间的差异。也就是说,可以在运行Windows系统的计算机上创建一个对象,将其序列化,通过网络将它发送给一台运行Unix系统的计算机,然后在那里准确地重新组装,而却不必担心数据在不同机器上的表示会不同,也不必关心字节的顺序或者其他任何细节。
  2. 一是Java的远程方法调用(Remote Method Invocation, RMI),它使存活于其他计算机上的对象使用起来就像是存活于本机上一样。当向远程对象发送消息时,需要通过对象序列化来传输参数和返回值。
  3. 只要对象实现了Seriablizable接口(该接口仅是一个标记接口,不包括任何方法),对象的序列化处理就会非常简单。
  4. 要序列化一个对象,首先要创建某些OutputStream对象,然后将其封装在一个ObjectOutputStream对象内。这时,只需调用writeObject()即可将对象序列化,并将其发送给OutputStream(对象序列化是基于字节的,因要使用InputStream和OutputStream继承层次结构)。要反向进行该过程,需要将一个InputStream封装在ObjectInputStream内,然后调用readObject()。和往常一样,我们最后获得的是一个引用,它指向一个向上转型的Object,所以必须向下转型才能直接设置它们。
  5. 对象序列化特别“聪明”的一个地方是它不仅保存了对象的“全景图”,而且能追踪对象内所包含的所有引用,并保存那些对象;接着又能对对象内包含的每个这样的引用进行追踪;依此类推。
  6. 必须保证Java虚拟机能找到相关的.class文件。

18.12.2 序列化的控制

  1. 恢复b1后,会调用Blip1默认构造器。这与恢复一个Seriablizable对象不同。对于Seriablizable对象,对象完全以它存储的二进制位为基础来构造,而不调用构造器。而对于一个Externalizable对象,所有普通的默认构造器都会被调用(包括在字段定义时的初始化),然后调用readExternal()。
  2. 其中,字段s和i只在第二个构造器中初始化,而不是在默认的构造器中初始化。这意味着假如不在readExternal()中初始化s和i,s就会为null,而i就会为零(因为在创建对象的第一步中将对象的存储空间清理为0)。
  3. 因此,为了正常运行,我们不仅需要在writeExternal()方法(没有任何默认行为来为Externalizable对象写入任何成员对象)中奖来自对象的重要信息写入,还必须在readExternal()方法中恢复数据。
  4. 当我们对序列化进行控制时,可能某个特定子对象不想让Java的序列化机制自动保存与恢复。如果子对象表示的是我们不希望将其序列化的敏感信息(如密码),通常就会面临这种情况。即使对象中的这些信息是private属性,一经序列化处理,人们就可以通过读取文件或者拦截网络传输的方式来访问到它。
  5. 有一种办法可防止对象的敏感部分被序列化,就是将类实现为Externalizable,如前面所示。这样一来,没有任何东西可以自动序列化,并且可以在writeExternal()内部只对所需部分进行显式的序列化。
  6. 然而,如果我们正在操作的是一个Serializable对象,那么所有序列化操作都会自动进行。为了能够予以控制,可以用transient(瞬时)关键字逐个字段地关闭序列化。
  7. 我们可以实现Serializable接口,并添加名为writeObject()和readObject()的方法。这样一旦对象被序列化或者反序列化还原,就会自动地分别调用这两个方法。也就是说,只要我们提供了这两个方法,就会使用它们而不是默认的序列化机制。这些方法必须具有准确的方法特征签名:
    private void writeObject(ObjectOutputStream stream) throws IOException;
    private void readObject(ObjectInputStream stream) throws IOException, ClassNotFoundException;
  8. 序列化发生在这行代码当中:o.writeObject(sc);
    writeObject()方法必须检查sc,判断它是否拥有自己的writeObject()方法(不是检查接口——这里根本就没有接口,也不是检查类的类型,而是利用反射来真正地搜索方法)。

18.12.3 使用“持久性”

  1. 如果我们想保存系统状态,最安全的做法是将其作为“原子”操作进行序列化。如果我们序列化了某些东西,再去做其他一些工作,再来序列化更多的东西,如此等等,那么将无法安全地保存系统状态。取而代之的是,将构成系统状态的所有对象都置入单一容器内,并在一个操作中将该容器直接写出。然后同样只需一次方法调用,即可以将其恢复。
  2. 看上去似乎static数据根本没有被序列化!确实如此——尽管Class类是Seriablizable的,但它却不能按我们所期望的方式运行。所以假如想序列化static值,必须自己动手去实现。这正是Line中的serializeStaticState()和deserializeStaticState()两个static方法的用途。可以看到,它们是作为存储和读取过程的一部分被显式地调用的。

18.15 总结

一旦我们理解了装饰器模式,并开始在某些情况下使用该类库以利用其提供的灵活性,那么你就开始从这个设计中受益了。到那个时候,为此额外多写几行代码的开销应该不至于使人觉得太麻烦。

第21章 并发

21.1 并发的多面性

  1. 事实上,从性能的角度看,如果没有任务会阻塞,那么在单处理器机器上使用并发就没有任何意义。
  2. 进程总是很吸引人,因为操作系统通常会将进程互相隔离开,因此它们不会彼此干涉,这使得用进程编程相对容易一些。
  3. 用并发解决的问题大体上可以分为“速度”和“设计可管理性”两种。
  4. 更重要的是,对进程来说,它们之间没有任何彼此通信的需要,因为它们都是完全独立的。
  5. 与在多任务操作系统中分叉外部进程不同,线程机制是在由执行程序表示的单一进程中创建任务。这种方式产生的一个好处是操作系统的透明性,这对Java而言,是一个重要的设计目标。
  6. 但是,并发提供了一种重要的组织结构上的好处:你的程序设计可以极大地简化。
  7. 并发需要付出代价,包含复杂性代价,但是这些代价与在程序设计、资源负载均衡以及用户方便使用方面的改进相比,就显得微不足道了。通常,线程使你能够创建更加松散耦合的设计,否则,你的代码中各个部分都必须显示地关注那些通常可以由线程来处理的任务。

21.2 基本的线程机制

  1. 一个线程就是在进程中的一个单一的顺序控制流,因此,单个进程可以拥有多个并发执行的任务,但是你的程序使得每个任务都好像有其自己的CPU一样。
  2. 线程的一大好处是可以使你从这个层次抽身出来,即代码不必知道它是运行在具有一个还是多个CPU的机器上。所以,使用线程机制是一种建立透明的、可扩展的程序的方法,如果程序运行得太慢,为机器增添一个CPU就能很容易地加快程序的运行速度。
  3. 通常,run()被写成无限循环的形式,这就意味着,除非有某个条件使得run()终止,否则它将永远运行下去。
  4. 在run()中对静态方法Thread.yield()的调用是对线程调度器(Java线程机制的一部分,可以将CPU从一个线程转移给另一个线程)的一种建议,它在声明:“我已经执行完生命周期中最重要的部分了,此刻正是切换给其他任务执行一段时间的大好时机。”这完全是选择性的,但是这里使用它是因为它会在这些示例中产生更加有趣的输出:你更有可能会看到任务换进换出的证据。
  5. 当从Runnable导出一个类时,它必须具有run()方法,但是这个方法并无特殊之处——它不会产生任何内在的线程能力。要实现线程行为,你必须显示地将一个任务附着到线程上。
  6. 假设我们写了一个public class LiftOff implements Runnable,那么将Runnable对象转变为工作任务的传统方式是把它提交给一个Thread构造器:Thread t = new Thread(new LiftOff()); t.start();。但是也可以它自己来(我不喜欢,因为不能显示自身是线程):LiftOff launch = new LiftOff(); launch.run();而且怎么能直接调用run()方法呢。。。
  7. 尽管start()看起来是产生了一个对长期运行方法的调用,但是从输出中可以看到,start()迅速地返回了,因为Waiting for LiftOff消息在倒计时完成之前就出现了。实际上,你产生的是对LiftOff.run()的方法调用,并且这个方法还没有完成,但是因为LiftOff.run()是由不同的线程执行的,因此你仍旧可以执行main()线程中的其他操作(这种能力并不局限于main()线程,任何线程都可以启动另一个线程)。因此,程序会同时运行两个方法,main()和LiftOff.run()是程序中与其他线程“同时”执行的代码。

21.2.3 使用Executor

我们可以使用Executor来代替显示地创建Thread对象。

No.1 CachedThreadPool
ExecutorService exec = Executors.newCachedThreadPool();
for(int i-0; i<5; i++)exec.execute( new LiftOff());
exec.shutdown();
No.2 FixedThreadPool
ExecutorService exec = Executors.newFixedThreadPool(5);
//同上

有了FixedThreadPool,你就可以一次性预先执行代价高昂的线程分配,因而也就可以限制线程的数量了。这可以节省时间,因为你不用为每个任务都固定地付出创建线程的开销。在事件驱动的系统中,需要线程的事件处理器,通过直接从池中获取线程,也可以尽快得到服务。你不会滥用可获得的资源。注意,在任何线程池中,现有线程在可能的情况下,都会被自动复用。CachedThreadPool在程序执行过程中通常会创建与所需数量相同的线程,然后它回收旧线程时停止创建新线程。

假设你有大量的线程,那它们运行的任务将使用文件系统。你可以用SingleThreadExecutor来运行这些线程,以确保任意时刻在任何线程中都只有唯一的任务在运行。

21.2.5 休眠

  1. 对sleep()的调用可以抛出InterruptedException异常,并且你可以看到,它在run()中被捕获。因为异常不能跨线程传播回main(),所以你必须在本地处理所有任务内部产生的异常。
  2. 尽管向控制台打印也是开销较大的操作,但在那种情况下看不出优先级的效果,因为向控制台打印不能被中断(否则的话,在多线程情况下控制台显示就乱套了),而数学运算是可以中断的。这里运算时间足够的长,因此线程调度机制才来得及介入,交换任务并关注优先级,使得最高优先级线程被优先选择。
  3. 所谓后台(daemon)线程,是指在程序运行的时候在后台提供一种通用服务的线程,并且这种线程并不属于程序中不可或缺的部分。因此,当所有的非后台线程结束时,程序也就终止了,同时会杀死进程中的所有后台线程。反过来说,只要有任何非后台线程还在运行,程序就不会终止。比如,执行main()的就是一个非后台线程。必须在线程启动之前调用setDaemon()方法,才能把它设置为后台线程。
  4. Daemon线程被设置成了后台模式,然后派生出许多子线程,这些线程并没有被显示地设置为后台模式,不过它们的确是后台线程。接着,Daemon线程进入了无限循环,并在循环里调用yield()方法把控制权交给其他进程。你应该意识到后台进程在不执行finally子句的情况下就会终止其run()方法。
  5. 这种行为是正确的,即便你基于前面对finally给出的承诺,并不希望出现这种行为,但情况仍将如此。当最后一个非后台线程终止时,后台线程会“突然”终止。因此一旦main()退出,JVM就会立即关闭所有的后台进程,而不会有任何你希望出现的确认形式。因为你不能以优雅的方式来关闭后台进程,所以它们几乎不是一种好的思想。非后台的Executor通常是一种更好的方式,因为Executor控制的所有任务可以同时被关闭。
  6. ThreadMethod类展示了在方法内部如何创建线程。当你准备好运行线程时,就可以调用这个方法,而在线程开始之后,该方法将返回。如果该线程只执行辅助操作,而不是该类的重要操作,那么这与在该类的构造器内部启动线程相比,可能是一种更加有用而适合的方式。
  7. 要执行的任务与驱动它的线程之间有一个差异,这个差异在Java类库中尤为明显,因为你对Thread类实际没有任何控制权(并且这种隔离在使用执行器时更加明显,因为执行器将替你处理线程的创建和管理)。你创建任务,并通过某种方式将一个线程附着到任务上,以使得这个线程可以驱动任务。
  8. 在Java中,Thread类自身不执行任何操作,它只是驱动赋予它的任务,但是线程研究中总是不变地使用“线程执行这项或那项动作”这样的语言。
  9. 如果某个线程在另一个线程t上调用t.join(),此线程将被挂起,直到目标线程t结束才恢复(即t.isAlive()返回为假)。
  10. 要想让程序有响应,就得把计算程序放在run()方法中,这样它就能让出处理器给别的程序。

21.3 共享受限资源

  1. 有一点很重要,那就是要注意到递增程序自身也需要多个步骤,并且在递增过程中任务可能会被线程机制挂起——也就是说,在Java中,递增不是原子性的操作。因此,如果不保护任务,即使单一的递增也不是安全的。
  2. 如前所述,可以通过yield()和setPriority()来给线程调度器提供建议,但这些建议未必会有多大效果,这取决于你的具体平台和JVM实现。
  3. Java以提供关键字synchronized的形式,为防止资源冲突提供了内置支持。当任务要执行被synchronized关键字保护的代码片段的时候,它将检查锁是否可用,然后获取锁,执行代码,释放锁。
  4. 共享资源一般是以对象形式存在的内存片段,但也可以是文件、输入/输出端口,或者是打印机。要控制对共享资源的访问,得先把它包装进一个对象。然后把所有要访问这个资源的方法标记为synchronized。如果某个任务处于一个对标记为synchronized的方法的调用中,那么在这个线程从该方法返回之前,其他所有要调用类中任何标记为synchronized方法的线程都会被阻塞。
  5. 所有对象都自动含有单一的锁(也称为监视器)。当在对象上调用其任意synchronized方法的时候,此对象都被加锁,这时该对象上的其他synchronized方法只有等到前一个方法调用完毕并释放了锁之后才能被调用。
  6. 所以,对于某个特定对象来说,其所有synchronized方法共享同一个锁,这可以被用来防止多个任务同时访问被编码为对象内存。
  7. 注意,在使用并发时,将域设置为private是非常重要的,否则,synchronized关键字就不能防止其他任务直接访问域,这样就会产生冲突。
  8. 一个任务可以多次获得对象的锁。如果一个方法在同一个对象上调用了第二个方法,后者又调用了同一个对象上的另一个方法,就会发生这种情况。JVM负责跟踪对象被加锁的次数。显然,只有首先获得了锁的任务才能允许继续获取多个锁。
  9. 如果在你的类中有超过一个方法在处理临界数据,那么你必须同步所有相关的方法。如果只同步一个方法,那么其他方法将会随意地忽略这个对象锁,并可以在无任何惩罚的情况下被调用。这是很重要的一点:每个访问临界共享资源的方法都必须被同步,否则它们就不会正确地工作。
  10. import java.util.concurrent.locks.*;
    private Lock lock = new ReentrantLock();
    lock.lock();
    lock.unlock();
    显式的互斥机制。
  11. 当你在使用Lock对象时,将这里所示的惯用法内部化是很重要的:紧接着的对lock()的调用,你必须放置在finally子句中带有unlock()的try-finally语句中。注意,return语句必须在try子句中出现,以确保unlock()不会过早发生,从而将数据暴露给了第二个任务。
  12. 大体上,当你使用synchronized关键字时,需要写的代码量更少,并且用户错误出现的可能性也会降低,因此通常只有在解决特殊问题时,才使用显式的Lock对象。例如,用synchronized关键字不能尝试着获取锁且最终获取锁会失败,或者尝试着获取锁一段时间,然后放弃它:lock.tryLock(); lock.tryLock(2, TimeUnit.SECONDS);
  13. 显式的Lock对象在加锁和释放锁方面,相对于内建的synchronized锁来说,还赋予了你更细粒度的控制力。这对于实现专有同步结构是很有用的,例如用于遍历链接列表中的节点的节点传递的加锁机制(也称为锁耦合),这种遍历代码必须在释放当前节点的锁之前捕获下一个节点的锁。
  14. 依赖于原子性是很棘手且很危险的。如果你可以编写用于现代微处理器的高性能JVM,那么就有资格去考虑是否可以避免同步。
  15. 原子性可以应用于除long和double之外的所有基本类型之上的“简单操作”。对于读取和写入除long和double之外的基本类型变量这样的操作,可以保证它们会被当作不可分的操作来操作内存。
  16. 但是,当你定义long或double变量时,如果使用volatile关键字,就会获得(简单的赋值与返回操作的)原子性。
  17. 一个任务做出的修改,即使在不中断的意义上讲是原子性的,对其他任务也可能是不可视的(例如,修改只是暂时性地存储在本地处理器的缓存中),因此不同的任务对应用的状态有不同的视图。另一方面,同步机制强制在处理器系统中,一个任务做出的修改必须在应用中是可视的。如果没有同步机制,那么修改时可视将无法确定。
  18. volatile关键字还确保了应用中的可视性。如果你将一个域声明为volatile的,那么只要对这个域产生了写操作,那么所有的读操作就都可以看到这个修改。即便用了本地缓存,情况也确实如此,volatile域会立刻被写入到主存中,而读取操作就发生在主存中。
  19. 当一个域的值依赖于它之前的值时(例如递增一个计数器),volatile就无法工作了。如果某个域的值受到其他域的值的限制,那么volatile也无法工作,例如Range类的lower和upper边界就必须遵循lower<=upper的限制。
  20. 在PairManager1中,整个increment()方法是被同步控制的;但在PairManager2中,increment()方法使用同步控制块进行同步。注意,synchronized关键字不属于方法特征签名的组成部分,所以可以在覆盖方法的时候加上去。store()方法将一个Pair对象添加到了synchronized ArrayList中,所以这个操作是线程安全的。因此,该方法不必进行防护,可以放置在PairManager2的synchronized语句块的外部。
  21. 尽管每次运行的结果可能会非常不同,但一般来说,对于PairChecker的检查频率,PairManager1.increment()不允许有PairManager2.increment()那样多。后者采用同步控制块进行同步,所以对象不加锁的时间更长。这也是宁愿使用同步控制块而不是对整个方法进行同步控制的典型原因:使得其他线程能更多地访问(在安全的情况下尽可能多)。
  22. synchronized块必须给定一个在其上进行同步的对象,并且最合理的方式是,使用其方法正在被调用的当前对象:synchronized(this),这正是PairManager2所使用的方式。
  23. 两个任务可以同时进入同一个对象,只要这个对象上的方法是在不同的锁上同步的即可。DualSync.f()(通过同步整个方法)在this同步,而g()有一个在syncObject上同步的synchronized块。因此,这两个同步是互相独立的。通过在main()中创建调用f()的Thread对这一点进行了演示,因为main()线程是被用来调用g()的。从输出中可以看到,这两个方式在同时运行,因此任何一个方法都没有因为对另一个方法的同步而被阻塞。

21.4 终结任务

  1. 因为Entrance.canceled是一个volatile布尔标志,而它只会被读取和赋值(不会与其他域组合在一起被读取),所以不需要同步对其的访问,就可以安全地操作它。
  2. ExecutorService.awaitTermination()等待每个任务结束,如果所有的任务在超时时间达到之前全部结束,则返回true,否则返回false,表示不是所有的任务都已经结束了。尽管这会导致每个任务都退出其run()方法,并因此作为任务而终止,但是Entrance对象仍旧是有效的,因为在构造器中,每个Entrance对象都存储在称为entrances的静态List<Entrance>中。因此,sumEntrances()仍旧可以作用于这些有效的Entrance对象。
  3. 就绪(Runnable):在这种状态下,只要调度器把时间片分配给线程,线程就可以运行。也就是说,在任意时刻,线程可以运行也可以不运行。只要调度器能分配时间片给线程,它就可以运行;这不同于死亡和阻塞状态。
  4. 阻塞(Blocked):线程能够运行,但有某个条件阻止它的运行。当线程处于阻塞状态时,调度器将忽略线程,不会分配给线程任何CPU时间。直到线程重新进入了就绪状态,它才有可能执行操作。
  5. 一个任务进入阻塞状态,原因2)你通过调用wait()使线程挂起。直到线程得到了notify()或notifyAll()消息(或者在Java SE5的java.util.concurrent类库中等价的signal()或signalAll()消息),线程才会进入就绪状态。
  6. 如果你在Executor上调用shutdownNow(),那么它将发送一个interrupt()调用给它启动的所有线程。这么做是有意义的,因为当你完成工程中的某个部分或者整个程序时,通常会希望同时关闭某个特定Executor的所有任务。然而,你有时也会希望只中断某个单一任务。如果使用Executor,那么通过调用submit()而不是execute()来启动任务,就可以持有该任务的上下文。submit()将返回一个泛型Future<?>,其中有一个未修饰的参数,因为你永远都不会在其上调用get()——持有这种Future的关键在于你可以在其上调用cancel(),并因此可以使用它来中断某个特定任务。如果你将true传递给cancel(),那么它就会拥有在该线程上调用interrupt()以停止这个线程的权限。因此,cancel()是一种中断由Executor启动的单个线程的方式。
  7. SleepBlock是可中断的阻塞示例,而IOBlocked和SynchronizedBlocked是不可中断的阻塞示例。这个程序证明I/O和在synchronized块上的等待是不可中断的,但是通过浏览代码,你也可以预见到这一点——无论是I/O还是尝试调用synchronized方法,都不需要仍和InterruptedException处理器。
  8. 但是,为了演示SynchronizedBlock,我们必须首先获取锁。这是通过在构造器中创建匿名的Thread类的实例来实现的,这个匿名Thread类的对象通过调用f()获取了对象锁(这个线程必须有别于为SynchronizedBlock驱动run()的线程,因为一个线程可以多次获得某个对象锁)。由于f()永远都不返回,因此这个锁永远都不会释放,而SynchronizedBlock.run()在试图调用f(),并阻塞以等待这个锁被释放。
  9. 在main()中创建了一个调用f1()的Thread,然后f1()和f2()互相调用直至count变为0。由于这个任务已经在第一个对f1()的调用中获得了multiLock对象锁,因此同一个任务将在对f2()的调用中再次获取这个锁,依次类推。这么做是有意义的,因为一个任务应该能够调用在同一个对象中的其他的synchronized方法,而这个任务已经持有锁了。
  10. 注意,当你在线程上调用interrupt()时,中断发生的唯一时刻是在任务要进入到阻塞操作中,或者已经在阻塞操作内部时。因此,如果你调用interrupt()以停止某个任务,那么在run()循环碰巧没有产生任何阻塞调用的情况下,你的任务将需要第二种方式来退出。

21.5 线程之间的协作

21.5.1 wait()与notifyAll()

  1. wait()使你可以等待某个条件发生变化,而改变这个条件超出了当前方法的控制能力。wait()会在等待外部世界产生变化的时候将任务挂起,并且只有在notify()或notifyAll()发生时,即表示发生了某些感兴趣的事物,这个任务才会被唤醒并去检查所发生的变化。
  2. 调用sleep()的时候锁并没有被释放,调用yield()也属于这种情况,理解这一点很重要。另一方面,当一个任务在方法里遇到了对wait()的调用的时候,线程的执行被挂起,对象上的锁被释放。因为wait()将释放锁,这就意味着另一个任务可以获得这个锁,因此在该对象(现在是未锁定的)中的其他synchronized方法可以在wait()期间被调用。这一点至关重要,因为这些其他的方法通常将会产生改变,而这种改变正是使被挂起的任务重新唤醒所感兴趣的变化。
  3. wait()、notify()以及notifyAll()有一个比较特殊的方面,那就是这些方法是基类Object的一部分,而不是属于Thread的一部分。尽管开始看起来有点奇怪——仅仅针对线程的功能却作为通用基类的一部分而实现,不过这是有道理的,因为这些方法操作的锁也是所有对象的一部分。所以,你可以把wait()放进任何同步控制方法里,而不用考虑这个类是继承自Thread还是实现了Runnable接口。实际上,只能在同步控制方法或同步控制块里调用wait()、notify()和notifyAll()(因为不用操作锁,所以sleep()可以在非同步控制方法里调用)。如果在非同步控制方法里调用这些方法,程序能通过编译,但运行的时候,将得到IllegalMonitorStateException异常,并伴随一些含糊的消息,比如“当前线程不是拥有者”。
  4. 前面的示例强调你必须用一个检查感兴趣的条件的while循环包围wait()。这很重要,因为:
    No.1:你可能有多个任务出于相同的原因在等待同一个锁,而第一个唤醒任务可能会改变这种状况(即使你没有这么做,有人也会通过继承你的类去这么做)。如果属于这种情况,那么这个任务应该被再次挂起,直至其感兴趣的条件发生变化。
    No.2:在这个任务从其wait()中被唤醒的时刻,有可能会有某个其他的任务已经做出了改变,从而使得这个任务在此时不能执行,或者执行其操作已显得无关紧要。此时,应该通过再次调用wait()来将其重新挂起。
    No.3:也有可能某些任务出于不同的原因在等待你的对象上的锁(在这种情况下必须使用notifyAll())。在这种情况下,你需要检查是否已经经由正确的原因唤醒,如果不是,就再次调用wait()。
  5. 可以让另一个对象执行某种操作以维护其自己的锁。要这么做的话,必须首先得到对象的锁。比如,如果要向对象x发送notifyAll(),那么就必须在能够取得x的锁的同步控制块中这么做:
synchronized(x) {x.notifyAll();
}

21.5.2 notify()与notifyAll()

  1. 在有关Java的线程机制的讨论中,有一个令人困惑的描述:notifyAll()将唤醒“所有正在等待的任务”。这是否意味着在程序中任何地方,任何处于wait()状态的任务都将被任何对notifyAll()的调用唤醒呢?在下面的示例中,与Task2相关的代码说明了情况并非如此——事实上,当notifyAll()因某个特定锁而被调用时,只有等待这个锁的任务才会被唤醒。
  2. Blocker.waitingCall()非常简单,以至于在本例中,你只需声明for(;;)而不是while(!Thread.interrupted())就可以达到相同的效果,因为在本例中,由于异常而离开循环和通过检查interrupted()标志离开循环是没有任何区别的——在两种情况下都要执行相同的代码。但是,事实上,这个示例选择了检查interrupted(),因为存在着两种离开循环的方式。如果在以后的某个时刻,你决定要在循环中添加更多的代码,那么如果没有覆盖从这个循环中退出的这两条路径,就会产生引入错误的风险。

21.5.3 生产者与消费者

  1. 请注意观察,对notifyAll()的调用必须首先捕获waitPerson上的锁,而在WaitPerson.run()中的对wait()的调用会自动地释放这个锁,因此这是有可能实现的。因为调用notifyAll()必然拥有这个锁,所以这可以保证两个试图在同一个对象上调用notifyAll()的任务不会互相冲突。
  2. 通过把整个run()方法体放到一个try语句块中,可使得这两个run()方法都被设计为可以有序地关闭。catch子句将紧挨着run()方法的结束括号之前结束,因此,如果这个任务收到了InterruptedException异常,它将在捕获异常之后立即结束。
  3. 记住,shutdownNow()将向所有由ExecutorService启动的任务发送interrupt(),但在Chef中,任务并没有在获得该interrupt()之后立即关闭,因为当任务试图进入一个(可中断的)阻塞操作时,这个中断只能抛出InterruptedException。因此,你将看到首先显示了“Order up!”,然后当Chef试图调用sleep()时,抛出了InterruptedException。如果移除对sleep()的调用,那么这个任务将回到run()循环的顶部,并由于Thread.interrupted()测试而退出,同时并不抛出异常。
  4. 在Car的构造器中,单个的Lock将产生一个Condition对象,这个对象被用来管理任务间的通信。但是,这个Condition对象不包含任何有关处理状态的信息,因此你需要管理额外的表示处理状态的信息,即boolean waxOn。

21.6 死锁

死锁的四个条件,以哲学家就餐问题为例:
1. 互斥条件。任务使用的资源中至少有一个是不能共享的。这里,一根Chopstick一次就只能被一个Philosopher使用。
2. 至少有一个任务它必须持有一个资源且正在等待获取一个当前被别的任务持有的资源。也就是说,要发生死锁,Philosopher必须拿着一根Chopstick并且等待另一根。
3. 资源不能被任务抢占,任务必须把资源释放当作普通事件。Philosopher很有礼貌,他们不会从其他Philosopher那里抢Chopstick。
4. 必须有循环等待,这时,一个任务等待其他任务所持有的资源,后者又在等待另一个任务所持有的资源,这样一直下去,直到有一个任务在等待第一个任务所持有的资源,使得大家都被锁住。在DeadlockingDiningPhilosophers.java中,因为每个Philosopher都试图先得到右边的Chopstick,然后得到左边的Chopstick,所以发生了循环等待。

21.7 新类库中的构件

21.7.2 CyclicBarrier

  1. CyclicBarrier适用于这样的情况:你希望创建一组任务,它们并行地执行工作,然后在进行下一个步骤之前等待,直至所有任务都完成(看起来有些像join())。它使得所有的并行任务都将在栅栏处列队,因此可以一致地向前移动。这非常像CountDownLatch,只是CountDownLatch是只触发一次事件,而CyclicBarrier可以多次重用。
  2. 可以向CyclicBarrier提供一个“栅栏动作”,它是一个Runnable,当计数值到达0时自动执行——这是CyclicBarrier和CountDownLatch之间的另一个区别。这里,栅栏动作是作为匿名内部类创建的,它被提交给了CyclicBarrier的构造器。

21.7.4 PriorityBlockingQueue

  1. PrioritizedTaskProducer和PrioritizedTaskConsumer通过PriorityBlockingQueue彼此连接。因为这种队列的阻塞特性提供了所有必需的同步,所以你应该注意到了,这里不需要任何显式的同步——不必考虑当你从这种队列中读取时,其中是否有元素,因为这个队列在没有元素时,将直接阻塞读取者。

21.7.6 Semaphore

  1. 正常的锁(来自concurrent.locks或内建的synchronized锁)在任何时刻都只允许一个任务访问一项资源,而计数信号量允许n个任务同时访问这个资源。你还可以将信号量看作是在向外分发使用资源的“许可证”,尽管实际上没有使用任何许可证对象。
  2. 在main()中,创建了一个持有Fat对象的Pool,而一组CheckoutTask则开始操练这个Pool。然后,main()线程签出池中的Fat对象,但是并不签入它们。一旦池中所有对象都被签出,Semaphore将不再允许执行任何签出操作。blocked的run()方法因此会被阻塞,2秒钟之后,cancel()方法被调用,以此来挣脱Future的束缚。注意,冗余的签入将被Pool忽略。

21.7.7 Exchanger

  1. 在main()中,创建了用于两个任务的单一的Exchanger,以及两个用于互换的CopyOnWriteArrayList。这个特定的List变体允许在列表被遍历时调用remove()方法,而不会抛出ConcurrentModificationException异常。ExchangeProducer将填充这个List,然后将这个满列表交换为ExchangerConsumer传递给它的空列表。因为有了Exchanger,填充一个列表和消费另一个列表便可以同时发生了。

21.8 仿真

21.8.1 银行出纳员仿真

  1. 这个经典的仿真可以表示任何属于下面这种类型的情况:对象随机地出现,并且要求由数量有限的服务器提供随即数量的服务时间。通过构建仿真可以确定理想的服务器数量。
  2. Teller从CustomerLine中取走Customer,在任何时刻他都只能处理一个顾客,并且跟踪在这个特定的班次中有他服务的Customer的数量。当没有足够多的顾客时,他会被告知去执行doSomethingElse(),而当出现了许多顾客时,他会被告知去执行serveCustomerLine()。为了选择下一个出纳员,让其回到服务顾客的业务上,compareTo()方法将查看出纳员服务过的顾客数量,使得PriorityQueue可以自动地将工作量最小的出纳员推向前台。

21.8.2 饭店仿真

  1. 它还引入了Java SE5的SynchronousQueue,这是一种没有内部容量的阻塞队列,因此每个put()都必须等待一个take(),反之亦然。这就好像是你在把一个对象交给某人——没有任何桌子可以放置这个对象,因此只有在这个人伸出手,准备好接收这个对象时,你才能工作。在本例中,SynchronousQueue表示设置在用餐者面前的某个位置,以加强在任何时刻只能上一道菜这个概念。
  2. 关于这个示例,需要观察的一项非常重要的事项,就是使用队列在任务间通信所带来的管理复杂度。这个单项技术通过反转控制极大地简化了并发编程的过程:任务没有直接地互相干涉,而是经由队列互相发送对象。接收任务将处理对象,将其当作一个消息来对待,而不是向它发送消息。【如果只要可能就遵循这项技术,那么你构建出健壮的并发系统的可能性就会大大增加。】

21.9 性能调优

21.9.1 比较各类互斥技术

  1. 这个程序使用了模板方法设计模式,将所有共用代码都放置到基类中,并将所有不同的代码隔离在导出类的accumulate()和read()的实现中。【accumulate()和read()是基类中的抽象方法】
  2. 程序中必须有一个CyclicBarrier,因为我们希望确保所有的任务在声明每个测试完成之前都已经完成。【barrier.await()在三处用到:private class Modifier implements Runnable里做完所有accumulate(),private class Reader implements Runnable里做完所有read(),public void timedTest()方法里ExecutorService exec执行完所有的Modifier和Reader后。private static CyclicBarrier barrier = new CyclicBarrier(N*2+1);这个参数在前两次使用时好像没用啊】
  3. 也就是说,很明显,使用Lock通常会比使用synchronized要高效许多,而且synchronized的开销看起来变化范围太大,而Lock相对比较一致。
  4. 其次,阅读本章中的代码就会发现,很明显,synchronized关键字所产生的代码,与Lock所需的“加锁-try/catch-解锁”惯用法所产生的代码相比,可读性提高了很多,这就是为什么本章主要使用synchronized关键字的原因。就像我在本书其他地方提到的,代码被阅读的次数远多于被编写的次数。在编程时,与其他人交流相对于与计算机交流而言,要重要得多,因此代码的可读性至关重要。因此,以synchronized关键字入手,只有在性能调优时才替换为Lock对象这种做法,是具有实际意义的。

21.9.2 免锁容器

  1. 这些免锁容器背后的通用策略是:对容器的修改可以与读取操作同时发生,只要读取者只能看到完成修改的结果即可。修改是在容器数据结构的某个部分的一个单独的副本(有时是整个数据结构的副本)上执行的,并且这个副本在修改过程中是不可视的。只有当修改完成时,被修改的结构才会自动地与主数据结构进行交换,之后读取者就可以看到这个修改了。
  2. 在CopyOnWriteArrayList中,写入将导致创建整个底层数组的副本,而源数组将保留在原地,使得复制的数组在被修改时,读取操作可以安全地执行。当修改完成时,一个原子性的操作将把新的数组换入,使得新的读取操作可以看到这个新的修改。CopyOnWriteArrayList的好处之一是当多个迭代器同时遍历和修改这个列表时,不会抛出ConcurrentModificationException,因此你不必编写特殊的代码去防范这种异常,就像你以前必须作的那样。
  3. CopyOnWriteArraySet将使用CopyOnWriteArrayList来实现其免锁行为。
  4. ConcurrentHashMap和ConcurrentLinkedQueue使用了类似的技术,允许并发的读取和写入,但是容器中只有部分内容而不是整个容器可以被复制和修改。
  5. 从输出中可以看到,synchronized ArrayList无论读取者和写入者的数量是多少,都具有大致相同的性能——读取者与其他读取者竞争锁的方式与写入者相同。但是,CopyOnWriteArrayList在没有写入者时,速度会快许多,并且在有5个写入者时,速度仍旧明显地快。看起来你应该尽量使用CopyOnWriteArrayList,对列表写入的影响并没有超过短期同步整个列表的影响。当然,你必须在你的具体应用中尝试这两种不同的方式,以了解到底哪个更好一些。
  6. 向ConcurrentHashMap添加写入者的影响甚至还不如CopyOnWriteArrayList明显,这是因为ConcurrentHashMap使用了一种不同的技术,它可以明显地最小化写入所造成的影响。

《Thinking in Java》Fourth Edition中文版笔记相关推荐

  1. Real-Time Rendering Fourth Edition读书笔记1

    Real-Time Rendering Fourth Edition读书笔记1:第一章-第四章 思维导图链接: 链接:https://pan.baidu.com/s/1ic8O_y2mHdoc68tE ...

  2. Thinking in Java,Fourth Edition(Java 编程思想,第四版)学习笔记(二)之Introduction to Objects...

    The genesis of the computer revolution was a machine. The genesis of out programming languages thus ...

  3. 《Computer Networks》Fifth Edition中文版笔记

    5.6 Internet的网络层 模块开发.这条原则直接导致了协议栈的思想,每一层的协议完全独立于所有其他的协议. 寻找好的而不是完美的设计.通常设计者有一个好的设计,但是它不能够处理一些怪异的特殊情 ...

  4. Real-Time Rendering Fourth Edition 学习笔记之 -- 第一章:介绍

    实时渲染关心的是在计算机上重复的制作图像.这是计算机图形学中交互最高的领域. 图像显示的频率使用FPS或者Hz才测量.如果是每秒一帧,就只会有一点交互的感觉,用户等到每张新图像的显示是很痛苦的:当6F ...

  5. Thinking in Java(Fourth Edition)2.11

      即使从未创建某个类的任何对象,也可以调用其static方法,或访问其static 域

  6. Java学习笔记 算法 Algorithms Fourth Edition

    标字符 字符串 字符 1.数字不能作为标识符的首字母 2.标识符不能有空格 3.严格区分大小写 4.不能是关键字. 5.标识符最好能反映其作用 6.下划线开头是可以的,数字开头是不行的 7.Void ...

  7. Java资源大全中文版

    Java资源大全中文版 我想很多程序员应该记得 GitHub 上有一个 Awesome - XXX 系列的资源整理.awesome-java 就是 akullpp 发起维护的 Java 资源列表,内容 ...

  8. Java资源大全中文版-Awesome - java

    本文来自GitHub 上 Awesome - java 系列的资源整理. awesome-java是 akullpp 发起维护的 Java 资源列表,内容包括:构建工具.数据库.框架.模板.安全.代码 ...

  9. Java资源大全中文版(转)

    Java资源大全中文版(Awesome最新版) Awesome系列的Java资源整理.awesome-java 就是akullpp发起维护的Java资源列表,内容包括:构建工具.数据库.框架.模板.安 ...

最新文章

  1. 12.前K个高频元素---使用优先队列和哈希表解决
  2. 23岁创业,28岁成为福布斯亚洲青年领袖,这个“刷脸的男人”有点牛
  3. Python之web开发(error):python使用django框架搭建网站之文件配置
  4. 配置nginx/apache的404状态码来申请百度闭站保护
  5. angular 图片引入_推荐一个 angular 图像加载插件
  6. tslib 编译移植步骤
  7. CodeForces - 1454E Number of Simple Paths(基环树+思维)
  8. rotateright_Java Long类rotateRight()方法的示例
  9. 还没学python_2个月过去了!还没学会python?用《流畅的python》15天带你学会
  10. 插入顶部_最快速地把同一内容插入到Word文档不同页面的相同位置
  11. 二线城市的海归创业路:拥抱还是逃离?
  12. C#winform两个或多个panel重叠布局时如何显示一个
  13. 软件测试教程视频入门基础篇-千锋出品
  14. 小米硬盘路由器服务器,随手分享攻略 篇九:小米路由器更换10T硬盘 —— 小白教程...
  15. 银行卡卡号基于Luhn算法的格式校验
  16. 润雅信息完成B轮融资 大数据平台赋能汽车销售 1
  17. 原生js瀑布流效果,css+html+js,复制下来可直接使用
  18. 自学java的难度_自学java难不难 搞定这十三步就够了
  19. 一鸣心所向学习力提升培训:前人智慧,后人总结!如何10倍提升自己学习能力!
  20. Office2003/2007/2010/2013强力卸载工具

热门文章

  1. 面试了一位 46 岁的程序员,思绪万千,最后结局竟让我大惊失色!
  2. 「光驱」先锋 DVR-111XL 刻录
  3. 计算机整体硬盘销毁,如何完全销毁硬盘上的数据?
  4. android rom打包解包工具,Android ROM包定制(解包,增删模块,打包)
  5. Android 底部导航栏-极致简单版
  6. 吸血鬼数字java_吸血鬼数字
  7. 实现一个简洁朴素的左侧贴边导航栏html+CSS+javaScript
  8. AFM模型原理及Pytorch代码复现
  9. 用手机访问电脑的本地服务器
  10. Monash call (莫纳什来电) -开篇