Java 技术手册第八版(三)
原文:
zh.annas-archive.org/md5/450d5a6a158c65e96e7be41e1a8ae3c7译者:飞龙
第五章:Java 面向对象设计简介
在本章中,我们将考虑与 Java 中面向对象设计(OOD)相关的几种技术。
我们将讨论如何处理 Java 的对象,涵盖Object的关键方法、面向对象设计的方面以及实现异常处理方案。在整个章节中,我们将介绍一些设计模式——基本上是解决软件设计中一些非常常见情况的最佳实践。在本章的末尾,我们还将考虑安全程序——这些程序设计得不会随着时间的推移而变得不一致。
注意
本章旨在展示一个复杂主题和一些基本原则的示例。我们鼓励您查阅其他资源,比如 Josh Bloch 的Effective Java。
我们将开始考虑 Java 的调用和传递约定以及 Java 值的性质。
Java 值
Java 的值及其与类型系统的关系非常直接。Java 有两种类型的值:原始值和对象引用。
注意
Java 中只有八种不同的原始类型,并且程序员不能定义新的原始类型。
原始值和引用之间的关键区别在于原始值不能被更改;值2始终是相同的值。相比之下,对象引用的内容通常可以更改——通常称为对象内容的突变。
还请注意,变量只能包含适当类型的值。特别是,引用类型的变量始终包含对持有对象的内存位置的引用——它们不直接包含对象内容。这意味着在 Java 中没有等价的解引用运算符或struct。
Java 试图简化一个经常让 C++程序员困惑的概念:“对象的内容”和“对象的引用”的区别。不幸的是,完全隐藏这种差异是不可能的,因此程序员有必要了解引用值在平台上的工作原理。
Java 是按值传递的事实可以非常简单地证明,例如,通过运行以下代码:
public void manipulate(Circle circle) {
circle = new Circle(3);
}
Circle c = new Circle(2);
System.out.println("Radius: "+ c.getRadius());
manipulate(c);
System.out.println("Radius: "+ c.getRadius());
这会两次输出Radius: 2,因此显示即使在调用manipulate()之后,变量c中包含的值也未更改——它仍然持有半径为 2 的Circle对象的引用。如果 Java 是一种按引用传递的语言,那么它将持有对半径为 3 的Circle的引用:
如果我们对这种区别非常小心,并且将对象引用称为 Java 可能的一种值类型之一,则 Java 的一些否则令人惊讶的特性变得明显。要小心!一些较旧的文本在这一点上是含糊的。当我们讨论内存和垃圾收集时,我们将再次遇到 Java 值的概念 第六章。
重要的常见方法
正如我们所述,所有类都直接或间接扩展自java.lang.Object。该类定义了许多有用的方法,其中一些是为你编写的类设计的。示例 5-1 展示了一个重写了这些方法的类。接下来的章节将文档化每个方法的默认实现,并解释为何你可能需要重写它们。
请注意,此示例仅用于演示目的;在实际情况中,我们会将类如Circle表示为记录,并让编译器自动实现许多这些方法。
示例 5-1. 一个重写重要 Object 方法的类
// This class represents a circle with immutable position and radius.
public class Circle implements Comparable<Circle> {
// These fields hold the coordinates of the center and the radius.
// They are private for data encapsulation and final for immutability
private final int x, y, r;
// The basic constructor: initialize the fields to specified values
public Circle(int x, int y, int r) {
if (r < 0) throw new IllegalArgumentException("negative radius");
this.x = x; this.y = y; this.r = r;
}
// This is a "copy constructor"--a useful alternative to clone()
public Circle(Circle original) {
x = original.x; // Just copy the fields from the original
y = original.y;
r = original.r;
}
// Public accessor methods for the private fields.
// These are part of data encapsulation.
public int getX() { return x; }
public int getY() { return y; }
public int getR() { return r; }
// Return a string representation
@Override public String toString() {
return String.format("center=(%d,%d); radius=%d", x, y, r);
}
// Test for equality with another object
@Override public boolean equals(Object o) {
// Identical references?
if (o == this) return true;
// Correct type and non-null?
if (!(o instanceof Circle)) return false;
Circle that = (Circle) o; // Cast to our type
if (this.x == that.x && this.y == that.y && this.r == that.r)
return true; // If all fields match
else
return false; // If fields differ
}
// A hash code allows an object to be used in a hash table.
// Equal objects must have equal hash codes. Unequal objects are
// allowed to have equal hash codes, but we try to avoid that.
// We must override this method because we also override equals().
@Override public int hashCode() {
int result = 17; // This hash code algorithm from
result = 37*result + x; // Effective Java, by Joshua Bloch
result = 37*result + y;
result = 37*result + r;
return result;
}
// This method is defined by the Comparable interface. Compare
// this Circle to that Circle. Return a value < 0 if this < that
// Return 0 if this == that. Return a value > 0 if this > that.
// Circles are ordered top to bottom, left to right, then by radius
public int compareTo(Circle that) {
// Smaller circles have bigger y
long result = (long)that.y - this.y;
// If same compare l-to-r
if (result==0) result = (long)this.x - that.x;
// If same compare radius
if (result==0) result = (long)this.r - that.r;
// We have to use a long value for subtraction because the
// differences between a large positive and large negative
// value could overflow an int. But we can't return the long,
// so return its sign as an int.
return Long.signum(result);
}
}
示例 5-1 展示了我们在第四章中介绍的类型系统的许多扩展特性。首先,该示例实现了一个参数化或泛型版本的Comparable接口。其次,它使用@Override注解来强调(并让编译器验证)某些方法覆盖了Object。
toString()
toString()方法的目的是返回对象的文本表示。在字符串连接和诸如System.out.println()等方法中,对象会自动调用此方法。给对象提供文本表示在调试或日志输出中非常有用,一个精心制作的toString()方法甚至可以帮助生成报告。
继承自Object的toString()版本返回一个字符串,包括对象的类名以及对象的hashCode()值的十六进制表示(本章稍后讨论)。这个默认实现为对象提供了基本的类型和标识信息,但不是非常有用。示例 5-1 中的toString()方法返回一个包含Circle类每个字段值的可读字符串。
equals()
==运算符测试两个引用是否指向同一个对象。如果你想测试两个不同的对象是否相等,你必须使用equals()方法。任何类都可以通过重写equals()方法来定义自己的相等性概念。Object.equals()方法简单地使用==运算符:此默认方法只在两个对象实际上是同一个对象时才认为它们相等。
在示例 5-1 中,equals()方法认为两个不同的Circle对象在它们的字段都相等时是相等的。注意,它首先通过==进行快速的身份测试作为优化,然后使用instanceof检查其他对象的类型:一个Circle只能与另一个Circle相等,且equals()方法不可抛出ClassCastException。注意,instanceof测试还可以排除null参数:如果其左操作数为null,instanceof始终评估为false。
hashCode()
每当您重写 equals(),您也必须重写 hashCode()。此方法返回一个整数,用于哈希表数据结构。如果根据 equals() 方法两个对象相等,则这两个对象必须具有相同的哈希码,这一点至关重要。
对于哈希表的高效操作很重要,但不是必须的,不相等的对象必须具有不相等的哈希码,或者至少不相等的对象不太可能共享一个哈希码。这第二个标准可能会导致涉及轻微复杂算术或位操作的 hashCode() 方法。
Object.hashCode() 方法与 Object.equals() 方法一起使用,根据对象的身份而不是对象的相等性返回一个哈希码。(如果您需要基于身份的哈希码,可以通过静态方法 System.identityHashCode() 访问 Object.hashCode() 的功能。)
警告
当您重写 equals() 时,必须始终重写 hashCode(),以保证相等的对象具有相等的哈希码。否则可能会在程序中引起难以察觉的错误。
因为在示例 5-1 中,equals() 方法基于三个字段的值来判断对象的相等性,所以 hashCode() 方法也基于这三个字段计算其哈希码。从代码可以清楚地看出,如果两个 Circle 对象具有相同的字段值,则它们将具有相同的哈希码。
注意,在示例 5-1 中的 hashCode() 方法并不简单地将三个字段相加并返回它们的和。这样的实现是合法的,但不高效,因为具有相同半径但 x 和 y 坐标互换的两个圆将具有相同的哈希码。重复的乘法和加法步骤“扩展”了哈希码的范围,并显著降低了两个不相等的 Circle 对象具有相同代码的可能性。
在实践中,现代 Java 程序员通常会从他们的 IDE 中自动生成类的 hashCode()、equals() 和 toString() 方法,或者使用记录(records)类型,其中源代码编译器会生成这些方法的标准形式。对于极少数情况,程序员选择不使用这两种方法的情况,《Effective Java》(Addison Wesley)中包含了一种构建高效 hashCode() 方法的有用方法。
Comparable::compareTo()
示例 5-1 包括一个 compareTo() 方法。此方法由 java.lang.Comparable 接口定义,而不是由 Object 定义,但它是一个常见的实现方法,因此我们在本节中包括它。Comparable 及其 compareTo() 方法的目的是允许类的实例以类似于 <, <=, >, 和 >= 操作符比较数字的方式进行比较。如果一个类实现了 Comparable 接口,我们可以调用方法来比较类的实例,从而判断一个实例是否小于、大于或等于另一个实例。这也意味着 Comparable 类的实例可以进行排序。
注意
compareTo() 方法设置了该类型对象的 全序。这被称为该类型的 自然顺序,该方法被称为 自然比较方法。
因为 compareTo() 没有被 Object 类声明,所以每个单独的类都需要确定其实例是否应该以及如何排序,并包括一个实现该排序的 compareTo() 方法。
由 示例 5-1 定义的顺序将 Circle 对象比作页面上的单词。首先,圆按从上到下的顺序排列:具有较大 y 坐标的圆小于具有较小 y 坐标的圆。如果两个圆具有相同的 y 坐标,则按从左到右的顺序排列。具有较小 x 坐标的圆小于具有较大 x 坐标的圆。最后,如果两个圆具有相同的 x 和 y 坐标,则按半径比较。具有较小半径的圆小于另一个圆。
注意,在这种顺序下,仅当三个字段都相等时,两个圆才相等。这意味着 compareTo() 定义的顺序与 equals() 定义的相等性一致。虽然这不是严格要求的,但非常值得,您应该尽可能实现它。
compareTo() 方法返回一个需要进一步解释的 int 值。如果 this 对象小于传递给它的对象,则 compareTo() 应返回一个负数。如果两个对象相等,则应返回 0。如果 this 大于方法参数,则 compareTo() 应返回一个正数。
clone()
Object 定义了一个名为 clone() 的方法,其目的是返回一个字段设置与当前对象完全相同的对象。这是一个不寻常的方法的原因之一。
首先,clone() 被声明为 protected。因此,如果你想让你的对象可以被其他类克隆,你必须重写 clone() 方法,并使其为 public。接下来,Object 中 clone() 的默认实现抛出一个受检异常,CloneNotSupportedException,除非类实现了 java.lang.Cloneable 接口。注意,这个接口不定义任何方法(它是一个标记接口),所以实现它只是在类签名的 implements 子句中列出它。
clone() 的最初目的是提供一个生成对象的“深度拷贝”的机制,但它本质上是有缺陷的,不建议使用。相反,开发人员应该首选声明一个 复制构造函数 来制作他们对象的副本,例如:
Circle original = new Circle(1, 2, 3); // regular constructor
Circle copy = new Circle(original); // copy constructor
当我们考虑工厂方法时,我们将再次遇到复制构造函数。
常量
在 Java 中,常量是一个 static final 字段。这个修饰符的组合给定了一个单一的值(每个类),并且在类加载时初始化,然后不能被更改。
按照惯例,Java 的常量以全大写的形式命名,使用 蛇形命名,例如 NETWORK_SERVER_SOCKET¹,而不是“驼峰命名法”(或“驼峰式”)的约定,如 networkServerSocket 对于一个常规字段。
基本上有
-
public常量:这些构成了类的公共 API 的一部分 -
private常量:当常量仅为该类的内部实现细节时使用 -
包级别的常量:这些没有额外的访问关键字,当常量是需要被同一包中的不同类看到的内部实现细节时使用
最终情况可能会出现,例如,客户端和服务器类实现了一个网络协议,其细节(例如连接和监听的端口号)被捕获在一组符号常量中。
正如前面讨论的,常量出现在接口定义中是一种替代方法。实现接口的任何类都会继承它定义的常量,并且可以像在类本身中直接定义它们一样使用它们。这样做的优点是不需要用接口的名称前缀常量,也不需要提供任何常量的实现。
然而,这种方式相当复杂,所以首选方法是在一个类中定义常量(可以是公共的或包级别的),并通过使用 import static 声明从定义类中导入常量来使用它们。有关详细信息,请参阅 “包和 Java 命名空间”。
处 处理字段
Java 提供了多种访问控制关键字,用于定义字段的访问方式。使用任何这些可能性都是完全合法的,但实际上,Java 开发者通常有三种主要的字段访问选择:
-
常量(
static final):我们刚刚遇到的情况,可能还带有额外的访问控制关键字。 -
不可变字段(
private final):使用此组合的字段在对象创建后无法更改。 -
可变字段(
private):只有在程序员确定字段值在对象生命周期内会改变时才应该使用这种组合。
近年来,许多开发者开始采用尽可能使用不可变数据的实践。这种做法有几个好处,但主要的好处是,如果对象设计得不可修改,那么它们可以在线程之间自由共享。
在编写类时,我们建议根据情况使用上述三种字段修饰符选择。实例字段应始终首先写为final,只有在必要时才应使其可变。
此外,除了常量外,不应使用直接字段访问。应优先使用 getter 方法(以及 setter,对于可变状态的情况)。这样做的主要原因是直接字段访问会非常紧密地耦合定义类和任何客户端代码。如果使用访问器方法,则可以稍后修改这些方法的实现代码而无需更改客户端代码——而直接字段访问则无法做到这一点。
我们还应该指出字段处理中的一个常见错误:从 C++转过来的开发者经常犯的一个错误是省略字段的任何访问修饰符。这是一个严重的缺陷,因为 C++的默认可见性是 private,而 Java 的默认访问权限更加开放。这代表了 Java 中封装的失败,开发者应该注意避免这种情况。
字段继承和访问器
除了上述考虑因素外,Java 还提供了多种可能的方法来解决状态继承的设计问题。程序员可以选择将字段标记为protected,并允许子类直接访问(包括写入)。或者,我们可以提供访问器方法来读取(和写入,如果需要)实际的对象字段,同时保持封装性并将字段保留为private。
让我们重新审视我们之前在第三章末尾的PlaneCircle示例,并明确显示字段继承:
public class Circle {
// This is a generally useful constant, so we keep it public
public static final double PI = 3.14159;
protected double r; // State inheritance via a protected field
// A method to enforce the restriction on the radius
protected void checkRadius(double radius) {
if (radius < 0.0)
throw new IllegalArgumentException("radius may not < 0");
}
// The non-default constructor
public Circle(double r) {
checkRadius(r);
this.r = r;
}
// Public data accessor methods
public double getRadius() { return r; }
public void setRadius(double r) {
checkRadius(r);
this.r = r;
}
// Methods to operate on the instance field
public double area() { return PI * r * r; }
public double circumference() { return 2 * PI * r; }
}
public class PlaneCircle extends Circle {
// We automatically inherit the fields and methods of Circle,
// so we only have to put the new stuff here.
// New instance fields that store the center point of the circle
private final double cx, cy;
// A new constructor to initialize the new fields
// It uses a special syntax to invoke the Circle() constructor
public PlaneCircle(double r, double x, double y) {
super(r); // Invoke the constructor of the superclass
this.cx = x; // Initialize the instance field cx
this.cy = y; // Initialize the instance field cy
}
public double getCenterX() {
return cx;
}
public double getCenterY() {
return cy;
}
// The area() and circumference() methods are inherited from Circle
// A new instance method that checks whether a point is inside the
// circle; note that it uses the inherited instance field r
public boolean isInside(double x, double y) {
double dx = x - cx, dy = y - cy;
// Pythagorean theorem
double distance = Math.sqrt(dx*dx + dy*dy);
return (distance < r); // Returns true or false
}
}
而不是前面的代码,我们可以使用访问器方法来重写PlaneCircle,如下所示:
public class PlaneCircle extends Circle {
// Rest of class is the same as above; the field r in
// the superclass Circle can be made private because
// we no longer access it directly here
// Note that we now use the accessor method getRadius()
public boolean isInside(double x, double y) {
double dx = x - cx, dy = y - cy; // Distance to center
double distance = Math.sqrt(dx*dx + dy*dy); // Pythagorean theorem
return (distance < getRadius());
}
}
这两种方法在 Java 中都是合法的,但它们有一些区别。正如我们在 “数据隐藏和封装” 中讨论的那样,可在类外部写入的字段通常不是模型化对象状态的正确方式。事实上,正如我们稍后将在本章中看到的,并再次在 “Java 对并发的支持” 中看到的,它们可能会对程序的运行状态造成无法修复的损害。
因此,Java 中的 protected 关键字不幸地允许从子类和与声明类在同一包中的类(以及方法)访问字段。这与任何人都可以编写属于任何给定包的类(除了系统包)的能力结合在一起,意味着在 Java 中,受保护的状态继承可能存在缺陷。
警告
Java 不提供一种仅在声明类及其子类中可见成员的机制。
出于所有这些原因,几乎总是更好地使用访问器方法(无论是公共的还是受保护的)来为子类提供对状态的访问——除非继承的状态声明为 final,在这种情况下,受保护的状态继承是完全允许的。
单例
单例模式 是一个非常著名的设计模式。它旨在解决只需要或希望一个类的单个实例的设计问题。Java 提供了多种不同的实现单例模式的方式。在我们的讨论中,我们将使用稍微冗长的形式,这种形式的好处在于非常明确地说明了安全单例所需的操作:
public class Singleton {
private final static Singleton instance = new Singleton();
private static boolean initialized = false;
// Constructor
private Singleton() {
super();
}
private void init() {
/* Do initialization */
}
// This method should be the only way to get a reference
// to the instance
public static synchronized Singleton getInstance() {
if (initialized) return instance;
instance.init();
initialized = true;
return instance;
}
}
关键点在于,单例模式要有效,必须不可能创建多个实例,并且不可能获取到处于未初始化状态的对象的引用(有关这一重要点的更多信息,请参见本章后面)。
为了实现这一点,我们需要一个仅被调用一次的 private 构造函数。在我们的 Singleton 版本中,我们只在初始化私有静态变量 instance 时调用构造函数。我们还将创建唯一 Singleton 对象的过程与初始化分开,初始化过程发生在私有方法 init() 中。
有了这个机制,获取 Singleton 唯一实例的唯一方法是通过静态辅助方法 getInstance()。此方法检查标志 initialized,以查看对象是否已经处于活动状态。如果是,则返回对单例对象的引用。如果不是,则 getInstance() 调用 init() 来激活对象,并将标志设置为 true,这样下次请求 Singleton 的引用时,不会再进行进一步的初始化。
最后,我们还注意到getInstance()是一个synchronized方法。详细信息请参见第 6 章,了解其含义及为何需要这样做,但现在只需知道,它存在是为了防止在多线程程序中使用Singleton时出现意外后果。
提示
单例模式作为最简单的模式之一,经常被滥用。正确使用时,它可以是一种有用的技术,但程序中有太多的单例类通常是代码设计不良的典型迹象。
单例模式有一些缺点,特别是在测试和与其他类分离时可能很难。在多线程代码中使用时也需要注意。尽管如此,开发人员熟悉单例模式并不会意外地重新发明它是很重要的。单例模式经常用于配置管理,但现代代码通常会使用框架(通常是依赖注入框架)自动为程序员提供单例,而不是通过显式的Singleton(或等效)类。
工厂方法
直接使用构造函数的另一种选择是工厂方法模式。这种技术的基本形式是将构造函数设为私有的(或者在某些变体中设为其他非公共修饰符),并提供一个静态方法返回所需的类型。然后客户端代码使用这个静态方法来获取类型的实例。
作为代码作者,我们可能不希望直接暴露构造函数,并可能选择使用工厂。例如,缓存工厂不一定创建新对象,或者因为有多种有效构造对象的方式。
注意
静态工厂方法的方法并不同于经典书籍设计模式中的抽象工厂模式。
让我们重写来自示例 5-1 的构造函数,并引入一些工厂方法:
public final class Circle implements Comparable<Circle> {
private final int x, y, r;
// Main constructor
private Circle(int x, int y, int r) {
if (r < 0) throw new IllegalArgumentException("radius < 0");
this.x = x; this.y = y; this.r = r;
}
// Usual factory method
public static Circle of(int x, int y, int r) {
return new Circle(x, y, r);
}
// Factory method playing the role of the copy constructor
public static Circle of(Circle original) {
return new Circle(original.x, original.y, original.r);
}
// Third factory with intent given by name
public static Circle ofOrigin(int r) {
return new Circle(0, 0, r);
}
// other methods elided
}
这个类包含一个私有构造函数和三个独立的工厂方法:一个与构造函数具有相同签名的“常规”方法,以及两个额外的方法。其中一个额外的工厂实际上是一个复制构造函数,另一个用于处理一个特殊情况:原点处的圆。
使用工厂方法的一个优势是,与构造函数不同,该方法具有名称,因此可以使用名称的一部分指示其意图。在我们的示例中,工厂方法是of(),这是一个非常常见的选择,并且我们通过使用表达这一点的名称ofOrigin()来区分原点圆。
构建器
工厂方法是一种有用的技术,当您不想将构造函数暴露给客户端代码时。然而,工厂方法也有其局限性。当只有少数几个参数是必需的,并且所有这些参数都需要被传递时,它们运行良好。但在某些情况下,我们需要建模数据,其中许多部分是可选的,或者我们的领域对象有许多有效的不同可能的构造。在这种情况下,工厂方法的数量可能会快速增加,以表示所有可能的组合,并且可能会使 API 混乱。
另一种方法是建造者模式。这种模式使用一个辅助建造者对象,该对象与真实领域对象的状态完全相同(假设为不可变)。对于领域对象的每个字段,建造者都有相同的字段——相同的名称和类型。然而,虽然领域对象是不可变的,建造者对象是显式可变的。实际上,建造者有一个 setter 方法,命名方式与字段相同(即按“记录约定”),开发人员将使用该方法来设置状态的一部分。
建造者模式的整体意图是从一个“空白”的建造者对象开始,并向其添加状态,直到建造者准备好转换为实际的领域对象,通常是通过在建造者上调用build()方法。
让我们看一个简单的例子:
// Generic builder interface
public interface Builder<T> {
T build();
}
public class BCircle {
private final int x, y, r;
// The main constructor is now private
private BCircle(CircleBuilder cb) {
if (cb.r < 0)
throw new IllegalArgumentException("negative radius");
this.x = cb.x; this.y = cb.y; this.r = cb.r;
}
public static class CircleBuilder implements Builder<BCircle> {
private int x = 0, y = 0, r = 0;
public CircleBuilder x(int x) {
this.x = x;
return this;
}
public int x() {
return x;
}
// Similarly for y and r
@Override
public BCircle build() {
return new BCircle(this);
}
}
// Other methods elided
}
注意建造者接口通常是泛型的。这是因为在实践中,我们可能会有大量的领域类,所有这些类都将需要建造者,因此使用泛型建造者接口可以消除重复。Builder接口只包含一个方法,因此在技术上它可以作为 lambda 目标类型的候选者。但实际上这几乎从不是意图,因此没有被标记为@FunctionalInterface。build()方法的实现还包含对this引用的非可选使用。
建造者可以通过如下简单的代码驱动:
var cb = new BCircle.CircleBuilder();
cb.x(1).y(2).r(3);
var circle = cb.build();
注意首先我们要实例化建造者。然后,我们调用方法来设置建造者的各种参数。最后,我们通过调用build()方法从建造者创建一个不可变对象。
您可能注意到,增加状态的建造者上的方法都返回this。这种接口设计的目的是可以将调用链式——即可以在同一个可变对象上连续调用方法,例如cb.x(1).y(2).r(3)。另一种描述这种接口设计风格的方式是流畅接口。由于每个方法都返回this,我们知道所有这些调用都是安全的:不会出现NullPointerException。
我们的示例非常简单,有些刻意;它只有三个参数,而且所有这些参数都是必需的。在实践中,当对象参数数量较多且对象状态的“跨度集”有多个可能性时,构建器更加有用。工厂与构建器的使用案例存在重叠;确定在您自己的代码中确切的边界位置是面向对象设计技能开发的一部分。
接口与抽象类的比较
Java 8 彻底改变了 Java 的面向对象编程模型。在 Java 8 之前,接口是纯粹的 API 规范,不包含任何实现。当接口有多个实现时,这可能(并经常)导致代码重复。
为了避免这种浪费的努力,发展出了一个简单的编码模式,利用抽象类可以包含子类可以构建的部分实现。许多子类可以依赖抽象超类(也称为抽象基类)提供的方法实现。
这种模式由包含基本方法 API 规范的接口以及作为抽象类的主要部分实现配对而成。一个很好的例子就是java.util.List,它与java.util.AbstractList配对。JDK 提供的List的两个主要实现(ArrayList和LinkedList)都是AbstractList的子类。
另一个例子:
// Here is a basic interface. It represents a shape that fits inside
// of a rectangular bounding box. Any class that wants to serve as a
// RectangularShape can implement these methods from scratch.
public interface RectangularShape {
void setSize(double width, double height);
void setPosition(double x, double y);
void translate(double dx, double dy);
double area();
boolean isInside();
}
// Here is a partial implementation of that interface. Many
// implementations may find this a useful starting point.
public abstract class AbstractRectangularShape
implements RectangularShape {
// The position and size of the shape
protected double x, y, w, h;
// Default implementations of some of the interface methods
public void setSize(double width, double height) {
w = width; h = height;
}
public void setPosition(double x, double y) {
this.x = x; this.y = y;
}
public void translate (double dx, double dy) { x += dx; y += dy; }
}
Java 8 的默认方法的到来显著改变了这一局面。接口现在可以包含实现代码,就像我们在“默认方法”中看到的那样。
这意味着当定义一个抽象类型(例如Shape),你期望它有许多子类型(例如Circle、Rectangle、Square)时,你面临选择接口和抽象类之间的选择。由于它们现在可能具有相似的特性,因此并不总是清楚该选择哪一个。
请记住,扩展抽象类的类不能扩展任何其他类,而接口仍然不能包含任何非常量字段。这意味着在 Java 程序中使用继承仍然有一些限制。
接口和抽象类之间的另一个重要区别与兼容性有关。如果你将接口定义为公共 API 的一部分,然后稍后向接口添加一个新的强制方法,你将会破坏实现了接口先前版本的任何类——换句话说,任何新的接口方法必须声明为默认方法,并提供一个实现。
然而,如果使用抽象类,可以安全地向该类添加非抽象方法,而无需修改扩展抽象类的现有类。
在这两种情况下,添加新方法可能会与具有相同名称和签名的子类方法发生冲突 —— 子类方法始终胜出。因此,在添加新方法时要仔细考虑,特别是当方法名称对于此类型是“显而易见”或方法可能具有多个可能含义时。
通常,建议的方法是在需要 API 规范时首选接口。接口的强制方法是非默认的,因为它们代表 API 的一部分,必须存在于实现中才能被视为有效。默认方法应仅在方法真正可选时使用,或者如果它们只打算具有单个可能的实现时使用。
最后,旧的(Java 8 之前的)在文档中声明接口的哪些方法被认为是“可选的”,并指示实现在程序员不想实现它们时抛出java.lang.UnsupportedOperationException的技术充满了问题,不应在新代码中使用。
默认方法改变了 Java 的继承模型吗?
在 Java 8 之前,语言的严格单继承模型是清晰的。每个类(除了Object)都有一个直接超类,方法实现只能在类中定义,或者从超类层次结构继承。
默认方法改变了这种情况,因为它们允许方法实现从多个地方继承 —— 可以是从超类层次结构,也可以是从接口提供的默认实现。任何来自不同接口的默认方法之间的潜在冲突都会导致编译时错误。
这意味着不存在实现的多重继承可能性,因为在任何冲突情况下,程序员需要手动消除歧义的方法。
同样,接口仍然没有状态的多重继承:接口仍然没有非常量字段。
这意味着 Java 的多重继承与例如 C++中的一般多重继承不同。实际上,默认方法实际上是来自 C++的Mixin模式(对于熟悉该语言的读者)。一些开发人员还将默认成员视为某些面向对象语言(例如 Scala)中出现的trait语言特性的一种形式。
然而,来自 Java 语言设计者的官方立场是,默认方法并不完全满足完整的特征。 JDK 中随附的代码 —— 即使是java.util.function中的接口(如Function本身)也表现为简单的特征。
例如,考虑以下示例:
public interface IntFunc {
int apply(int x);
default IntFunc compose(IntFunc before) {
return (int y) -> apply(before.apply(y));
}
default IntFunc andThen(IntFunc after) {
return (int z) -> after.apply(apply(z));
}
static IntFunc id() {
return x -> x;
}
}
它是java.util.function中Function接口的简化版本,删除了泛型,并仅处理int作为数据类型。
这种情况显示了现有的功能组合方法(compose() 和 andThen())的一个重要点:这些函数只会以标准方式组合,任何理智的对默认 compose() 方法的重写都几乎不可能存在。
当然,在 java.util.function 中存在的函数类型也是如此,这表明在提供的有限域内,默认方法确实可以被视为一种无状态特征。
使用 Lambdas 的面向对象设计
考虑这个简单的 lambda 表达式:
Runnable r = () -> System.out.println("Hello World");
lvalue(赋值语句左侧)的类型是 Runnable,这是一个接口类型。为了使这个语句有意义,rvalue(赋值语句右侧)必须包含实现 Runnable 的某个类类型的实例(因为接口不能被实例化)。满足这些约束的最小实现是一个类类型(名称不重要),它直接扩展 Object 并实现 Runnable。
请记住,lambda 表达式的意图是允许 Java 程序员表达尽可能接近其他语言中看到的匿名或内联方法的概念。
此外,鉴于 Java 是一种静态类型语言,这直接导致了 lambda 的设计实现。
提示
Lambdas 是一个新类类型的实例构造的简写,这个新类类型本质上是 Object 增强了一个方法。
lambda 的额外方法由接口类型提供签名,并且编译器将检查 rvalue 是否与此类型签名一致。
Lambdas 与嵌套类
在 Java 8 中向语言添加 lambda 相对较晚,与其他编程语言相比。因此,Java 社区已经建立了模式来解决 lambda 的缺失问题。这表现在大量使用非常简单的嵌套(也称为内部)类来填补通常由 lambda 占据的空缺。
在现代的 Java 项目中,开发人员通常会尽可能地使用 lambda。我们还强烈建议,在重构旧代码时,您花一些时间将内部类转换为 lambda,只要有可能。一些 IDE 甚至提供了自动转换功能。
然而,这仍然留下了一个设计问题,即何时使用 lambda,何时使用嵌套类仍然是正确的解决方案。
有些情况是显而易见的;例如,当扩展某些功能的默认实现时,嵌套类方法是适当的,原因有两个:
-
自定义实现可能必须重写多个方法。
-
基础实现是一个类,而不是一个接口。
另一个要考虑的主要用例是有状态 lambda。由于没有地方声明任何字段,乍一看似乎 lambda 不能直接用于涉及状态的任何事物—语法只给出了声明方法体的机会。
然而,lambda 可能引用 lambda 所创建的范围内定义的变量,因此我们可以创建一个闭包,如在 第四章 中讨论的那样,来扮演有状态 lambda 的角色。
Lambda 与方法引用
何时使用 lambda 和何时使用方法引用大多数是个人品味和风格问题。当然,在一些情况下创建 lambda 是必要的。然而,在许多简单情况下,lambda 可以被方法引用替代。
一种可能的方法是考虑 lambda 符号是否增加了代码的可读性。例如,在流 API 中,使用 lambda 形式可能会带来潜在的好处,因为它使用 -> 操作符。这提供了一种视觉隐喻形式——流 API 是一种惰性抽象,可以将数据项“通过函数管道流动”。
例如,让我们考虑一个 Person 对象,它具有标准特征,如姓名、年龄等。我们可以使用类似以下的流水线计算平均值:
List<Person> persons = ... // derived from somewhere
double aveAge = persons.stream()
.mapToDouble(o -> o.getAge())
.reduce(0, (x, y) -> x + y ) / persons.size();
mapToDouble() 方法具有运动或转换方面的概念,使用显式 lambda 很明显。对于经验不足的程序员,这也引起了对函数式 API 的注意。
对于其他用例(例如 分发表),方法引用可能更合适。例如:
public class IntOps {
private Map<String, BinaryOperator> table =
Map.of("add", IntOps::add, "subtract", IntOps::sub);
private static int add(int x, int y) {
return x + y;
}
private static int sub(int x, int y) {
return x - y;
}
public int eval(String op, int x, int y) {
return table.get(op).apply(x, y);
}
}
在可以使用任一符号的情况下,随着时间的推移,您会形成适合个人风格的偏好。关键考虑因素是在重新阅读数月(或数年)前编写的代码时,符号选择是否仍然合理且代码易于阅读。
使用封闭类型的面向对象设计
我们在 第三章 中第一次遇到封闭类,并在 第四章 中引入了封闭接口。除了我们已经遇到的情况外,还有一个更简单的可能性,即封闭类型只能由定义在同一编译单元内的类(即 Java 源文件)扩展,例如:
// Note the absence of a permits clause
public abstract sealed class Shape {
public static final class Circle extends Shape {
// ...
}
public static final class Rectangle extends Shape {
// ...
}
}
类 Shape.Circle 和 Shape.Rectangle 是 Shape 的唯一允许的子类:任何试图扩展 Shape 的尝试都将导致编译错误。这实际上只是额外的细节,因为一般概念保持不变;sealed 表示一个只有有限可能兼容的类型。
这里有一个有趣的二元性:
-
枚举是只有有限数量实例的类——任何枚举对象都是这些实例之一
-
封闭类型仅有限数量的兼容类——任何封闭对象都属于其中一个类
现在考虑一个接受枚举的 switch 表达式,例如:
var temp = switch(season) {
case WINTER -> 2.0;
case SPRING -> 10.5;
case SUMMER -> 24.5;
case AUTUMN -> 16.0;
};
System.out.println("Average temp: "+ temp);
所有季节的可能枚举常量都出现在这个 switch 表达式中,因此匹配被称为完全的。在这种情况下,不需要包含default,因为编译器可以利用枚举常量的详尽性推断出永远不会激活默认情况。
不难看出我们可以对密封类型进行类似的操作。一些代码如下所示:
Shape shape = ...
if (shape instanceof Shape.Circle c) {
System.out.println("Circle: "+ c.circumference());
} else if (shape instanceof Shape.Rectangle r) {
System.out.println("Rectangle: "+ r.circumference());
}
对于人类来说,这显然是详尽无遗的,但当前(截至 Java 17)并未直接被编译器识别。
这是因为,截至 Java 17,密封类型实质上是一种不完整的功能。在 Java 的未来版本中,打算扩展 switch 表达式功能并将其与新形式的instanceof(以及其他新语言特性)结合起来,以提供称为模式匹配的功能。
这一新特性将使开发人员能够编写代码,例如“对变量的类型进行切换”,这将解锁由函数式编程启发的新设计模式,在 Java 中实现起来并不容易。
注意
附录包含有关模式匹配和其他未来功能的更多信息。
尽管截至 Java 17 还不完全完整,但密封类型在其当前形式下仍然非常有用,也可以与记录结合以产生一些引人注目的设计。
使用记录的 OOD
记录在第三章中引入,以其最简单的形式代表“仅仅是字段”或“数据包”。在其他一些编程语言中,这由一个元组表示,但 Java 的记录与元组有两个重要的不同之处:
-
Java 记录是具名类型,而元组是匿名的
-
Java 记录可以拥有方法、辅助构造函数以及几乎类似类的一切内容
这两者都源于记录是一种特殊类型的类的事实。这使得程序员可以从使用记录作为基本字段集合开始其设计,然后从那里进化。
例如,让我们将示例 5-1 重写为一条记录(简化了Comparable接口):
public record Circle(int x, int y, int r) {
// Primary (compact) constructor
public Circle {
// Validation code in the constructor
// This would be impossible in a tuple
if (r < 0) {
throw new IllegalArgumentException("negative radius");
}
}
// Factory method playing the role of the copy constructor
public static Circle of(Circle original) {
return new Circle(original.x, original.y, original.r);
}
}
请注意,我们引入了一种新类型的构造函数,称为紧凑构造函数。它仅适用于记录,并且在我们希望在构造函数中做一些额外工作以及初始化字段的情况下使用。紧凑构造函数没有(或不需要)参数列表,因为它们的参数列表始终与记录的声明相同。
这段代码比示例 5-1 要简短得多,并清楚地区分了记录的主构造函数(“真实形式”)与复制构造函数及可能存在的其他工厂之间的情况。
Java 记录的设计意味着它们是程序员的一种非常灵活的选择。一个实体可以最初被建模为仅仅是字段,并且随着时间的推移,可以获得更多的方法、实现接口等。
另一个重要方面是记录可以与密封接口结合使用。让我们来看一个例子:一个快递公司有不同类型的订单:基本订单(免费送货)和快速订单(速度更快但需额外费用)。
订单的基本接口如下所示:
sealed interface Order permits BasicOrder, ExpressOrder {
double price();
String address();
LocalDate delivery();
}
并且有两个实现:
public record BasicOrder(double price,
String address,
LocalDate delivery) implements Order {}
public record ExpressOrder(double price,
String address,
LocalDate delivery,
double deliveryCharge) implements Order {}
请记住,所有记录类型的超类型是java.lang.Record,因此对于这种类型的用例,我们必须使用接口;不可能使不同的订单类型扩展抽象基类。我们的选择是:
-
将实体建模为类,并使用
sealed abstract基类。 -
将实体建模为记录,并使用密封接口。
在第二种情况下,任何常见的记录组件都需要提升到接口中,就像我们在Order示例中看到的那样。
实例方法还是类方法?
实例方法是面向对象编程的一个关键特性。然而,并不意味着您应该避免使用类方法。在许多情况下,定义类方法是完全合理的。
提示
请记住,在 Java 中,类方法使用static关键字声明,术语static method和class method可互换使用。
例如,在使用Circle类时,您可能经常需要计算具有给定半径的圆的面积,但不想费事地创建表示该圆的Circle对象。在这种情况下,类方法更方便:
public static double area(double r) { return PI * r * r; }
一个类可以定义多个具有相同名称的方法,只要这些方法具有不同的参数列表是完全合法的。area()方法的这个版本是一个类方法,因此它没有隐含的this参数,并且必须有一个参数来指定圆的半径。这个参数使它与同名的实例方法有所区别。
另一个关于实例方法和类方法选择的例子是,考虑定义一个名为bigger()的方法,它检查两个Circle对象,并返回半径较大的那个。我们可以将bigger()编写为实例方法,如下所示:
// Compare the implicit "this" circle to the "that" circle passed
// explicitly as an argument and return the bigger one.
public Circle bigger(Circle that) {
if (this.r > that.r) return this;
else return that;
}
我们还可以将bigger()实现为一个类方法,如下所示:
// Compare circles a and b and return the one with the larger radius
public static Circle bigger(Circle a, Circle b) {
if (a.r > b.r) return a;
else return b;
}
给定两个Circle对象x和y,我们可以使用实例方法或类方法来确定哪个更大。然而,这两种方法的调用语法有显著区别:
// Instance method: also y.bigger(x)
Circle biggest = x.bigger(y);
Circle biggest = Circle.bigger(x, y); // Static method
这两种方法都很有效,并且从面向对象设计的角度来看,这两种方法都没有比另一种方法更“正确”的说法。实例方法在形式上更符合面向对象,但其调用语法存在某种不对称性。在这种情况下,选择实例方法或类方法仅仅是一种设计决策。根据情况,其中一种方法可能更为自然。
关于 System.out.println()的一点说明
我们经常遇到方法System.out.println() — 它用于将输出显示到终端窗口或控制台。我们从未解释过为什么这个方法有这么长、笨拙的名字,或者这两个点在里面做什么。现在你理解了类和实例字段以及类和实例方法,更容易理解正在发生的事情:System是一个类。它有一个名为out的公共类字段。这个字段是java.io.PrintStream类型的对象,它有一个名为println()的实例方法。
我们可以使用静态导入来缩短这个过程,比如import static java.lang.System.out; — 这将使我们能够将打印方法简化为out.println(),但由于这是一个实例方法,我们无法进一步缩短它。
组合与继承
继承并不是面向对象设计中我们唯一可以使用的技术。对象可以包含对其他对象的引用,因此可以从更小的组件部分聚合出更大的概念单元;这被称为组合。
一个重要的相关技术是委托,其中特定类型的对象持有对兼容类型的次要对象的引用,并将所有操作转发到次要对象。这通常通过接口类型来完成,正如本例中展示的那样,我们在这里模拟软件公司的就业结构:
public interface Employee {
void work();
}
public class Programmer implements Employee {
public void work() { /* program computer */ }
}
public class Manager implements Employee {
private Employee report;
public Manager(Employee staff) {
report = staff;
}
public Employee setReport(Employee staff) {
report = staff;
}
public void work() {
report.work();
}
}
Manager类被认为是委托work()操作给他们的直接报告,Manager对象不执行任何实际工作。此模式的变体涉及在委托类中执行一些工作,只将一些调用转发到委托对象。
另一种有用的相关技术称为装饰者模式。这提供了在运行时扩展对象功能的能力。设计时需要一些额外的工作量。让我们看一个装饰者模式的例子,应用于模拟在塔科店出售的卷饼。为了保持简单,我们仅模拟要装饰的一个方面——卷饼的价格:
// The basic interface for our burritos
interface Burrito {
double getPrice();
}
// Concrete implementation-standard size burrito
public class StandardBurrito implements Burrito {
private static final double BASE_PRICE = 5.99;
public double getPrice() {
return BASE_PRICE;
}
}
// Larger, super-size burrito
public class SuperBurrito implements Burrito {
private static final double BASE_PRICE = 6.99;
public double getPrice() {
return BASE_PRICE;
}
}
这些涵盖了可以提供的基本卷饼——两种不同的尺寸,不同的价格。让我们通过添加一些可选的额外配料——辣椒和鳄梨酱来增强这一点。这里的关键设计点是使用一个抽象基类,所有可选的装饰组件都将其子类化:
/*
* This class is the Decorator for Burrito. It represents optional
* extras that the burrito may or may not have.
*/
public abstract class BurritoOptionalExtra implements Burrito {
private final Burrito burrito;
private final double price;
protected BurritoOptionalExtra(Burrito toDecorate,
double myPrice) {
burrito = toDecorate;
price = myPrice;
}
public final double getPrice() {
return (burrito.getPrice() + price);
}
}
结合一个abstract基类BurritoOptionalExtra,以及一个protected构造函数,意味着获取BurritoOptionalExtra的唯一有效方法是构造其子类的实例,因为它们具有公共构造函数。这种方法还可以隐藏组件价格的设置,使客户端代码无法访问。
注意
当然,装饰者也可以与密封类型结合使用,以允许仅限于已知的有限列表的装饰者。
让我们来测试这个实现:
Burrito lunch = new Jalapeno(new Guacamole(new SuperBurrito()));
// The overall cost of the burrito is the expected $8.09.
System.out.println("Lunch cost: "+ lunch.getPrice());
装饰器模式被广泛应用,尤其是在 JDK 实用类中。当我们在第十章中讨论 Java I/O 时,我们将看到更多实际应用中的装饰器示例。
异常和异常处理
我们在“已检查和未检查异常”中遇到了已检查和未检查异常。在本节中,我们讨论异常设计的一些附加方面以及如何在您自己的代码中使用它们。
请记住,在 Java 中,异常是一个对象。这个对象的类型是 java.lang.Throwable,或者更常见的是 Throwable 的某个子类,更具体地描述了发生的异常类型。Throwable 有两个标准的子类:java.lang.Error 和 java.lang.Exception。属于 Error 子类的异常通常指示不可恢复的问题:虚拟机已经耗尽了内存,或者类文件已损坏且无法读取,例如。这类异常可以被捕获和处理,但很少这样做 —— 这些是先前提到的未检查异常。
另一方面,属于 Exception 子类的异常指示的是较不严重的情况。这些异常可以被合理地捕获和处理。它们包括诸如 java.io.EOFException(表示文件结束)和 java.lang.ArrayIndexOutOfBoundsException(指示程序尝试读取超出数组末尾的位置)等异常。这些是来自第二章中的已检查异常(除了 RuntimeException 子类,它们也是一种未检查异常)。在本书中,我们使用术语“异常”来指代任何异常对象,无论该异常的类型是 Exception 还是 Error。
因为异常是一个对象,它可以包含数据,并且它的类可以定义操作该数据的方法。Throwable 类及其所有子类都包括一个 String 字段,用于存储描述异常条件的可读错误消息。异常对象创建时设置该字段,并可以通过 getMessage() 方法从异常中读取。大多数异常只包含这一条消息,但有些异常会添加其他数据。例如,java.io.InterruptedIOException 添加了一个名为 bytesTransferred 的字段,指定了在异常条件中断之前完成的输入或输出量。
在设计自己的异常时,应考虑与异常对象相关的其他建模信息。这通常是关于中止操作的具体信息,以及遇到的异常情况(正如我们在 java.io.InterruptedIOException 中看到的)。
在应用程序设计中使用异常存在一些权衡。使用受检异常意味着编译器可以强制处理(或向上传播到调用堆栈)已知的可能恢复或重试的条件。这也意味着更难忘记实际处理错误——从而减少忘记错误条件导致系统在生产中失败的风险。
另一方面,有些应用程序将无法从某些条件中恢复,即使这些条件在理论上由受检异常建模。例如,如果应用程序要求在文件系统中特定位置放置配置文件,并且在启动时找不到它,则可能只能打印错误消息并退出——尽管java.io.FileNotFoundException是一个受检异常。在这些情况下,强制处理或传播无法从中恢复的异常,边缘情况,打印错误并退出是唯一真正明智的操作。
在设计异常方案时,以下是一些您应该遵循的良好实践:
-
考虑需要放置在异常上的附加状态——记住它也是一个像其他任何对象一样的对象。
-
Exception有四个公共构造函数——在正常情况下,自定义异常类应该实现所有这些函数——用于初始化额外状态或自定义消息。 -
不要在您的 API 中创建许多细粒度的自定义异常类——Java I/O 和反射 API 都受到此类问题的困扰,并且这样做只会不必要地复杂化与这些包的工作。
-
不要用一个单一的异常类型描述太多的条件。
-
在确定需要抛出异常之前,永远不要创建异常。异常创建可能是一个昂贵的操作。
最后,有两种异常处理反模式您应该避免:
// Never just swallow an exception
try {
someMethodThatMightThrow();
} catch(Exception e){
}
// Never catch, log, and rethrow an exception
try {
someMethodThatMightThrow();
} catch(SpecificException e){
log(e);
throw e;
}
前者只是忽略了几乎肯定需要采取某些行动的条件(即使只是在日志中通知)。这增加了系统中其他地方发生失败的可能性——可能远离原始的真实来源。
第二个只是制造噪音。我们记录了一条消息,但实际上没有处理这个问题;我们仍然需要在系统中更高级别的其他代码来实际处理这个问题。
安全的 Java 编程
编程语言有时被描述为类型安全;然而,这个术语在工作程序员中使用得比较宽泛。关于类型安全有许多不同的观点和定义,并非所有观点都是相互兼容的。对于我们的目的来说,最有用的观点是类型安全是编程语言的一个属性,可以防止在运行时错误地标识数据的类型。这应该被视为一个滑动尺度——更有助于将语言视为在类型安全性方面更多(或更少)的语言,而不是一个简单的安全/不安全的二元属性。
在 Java 中,类型系统的静态特性通过产生编译错误来防止大量可能的错误,例如,如果程序员尝试将不兼容的值分配给变量。然而,Java 并不是完全类型安全的,因为我们可以在任何两个引用类型之间进行强制类型转换——如果值不兼容,这将在运行时失败,抛出 ClassCastException。
在本书中,我们倾向于将安全性视为不可分割的正确性主题。这意味着我们应该以程序为中心,而不是语言。这强调了一个观点:安全代码并不是任何广泛使用的语言所保证的,反而需要相当大的程序员努力(和严格的编码纪律)才能确保最终结果真正安全和正确。
我们通过与状态模型抽象的合作来接近安全程序的视角,如图 5-1 所示。一个 安全 的程序是指:
-
所有对象在创建后都处于合法状态。
-
外部可访问的方法在合法状态之间转换对象。
-
外部可访问的方法不得在对象处于不一致状态时返回。
-
外部可访问的方法在抛出异常之前必须将对象重置为合法状态。
在这种情况下,“外部可访问”意味着 public、包私有(package-private)或 protected。这为程序的安全性定义了一个合理的模型,因为它与定义我们的抽象类型有关,使其方法确保状态的一致性,因此合理地将满足这些要求的程序称为“安全程序”,无论该程序是在何种语言中实现。
警告
私有方法不需要以合法状态的对象开始或结束,因为它们不能被外部代码调用。
如 正如你可能想象的,实际实现大量代码,以确保状态模型和方法遵守这些属性,可能是相当艰巨的任务。在像 Java 这样的语言中,程序员直接控制预先多任务执行线程的创建,这个问题要复杂得多。
图 5-1. 程序状态转换
从我们对面向对象设计的介绍过渡到 Java 语言和平台的最后一个方面,需要理解一个坚实的基础。这就是内存和并发的特性——这是平台中最复杂的之一,但也正是通过细致研究带来了巨大的回报。它是我们下一章的主题,并结束了第一部分。
¹ 从技术上讲,这应该被称为 SCREAMING_SNAKE_CASE。
第六章:Java 对内存和并发的处理方式
本章是介绍 Java 平台中的并发(多线程)和内存处理的入门。这些主题本质上是相互交织在一起的,因此将它们一起处理是有意义的。我们将涵盖以下内容:
-
Java 内存管理简介
-
基本的标记-清除垃圾收集(GC)算法
-
HotSpot JVM 如何根据对象的生命周期优化 GC
-
Java 的并发原语
-
数据的可见性和可变性
Java 内存管理的基本概念
在 Java 中,当不再需要对象时,对象所占用的内存会自动释放。这是通过一种称为 垃圾收集(或 GC)的过程来完成的。垃圾收集是一种已经存在多年的技术,由诸如 Lisp 等语言率先采用。对于那些习惯于诸如 C 和 C++ 之类的语言,在其中必须调用 free() 函数或 delete 运算符来回收内存的程序员来说,这需要一些适应。
注意
不需要记住销毁每个创建的对象的事实是使 Java 成为一种愉快的工作语言的特性之一。这也是使用 Java 编写的程序比那些不支持自动垃圾收集的语言编写的程序更不容易出现错误的特性之一。
不同的虚拟机实现以不同的方式处理垃圾收集,并且规范对 GC 的实现没有很严格的限制。在本章的后面,我们将讨论 HotSpot JVM(这是 Oracle 和 OpenJDK Java 实现的基础)。尽管这不是你可能会遇到的唯一的 JVM,但它在服务器端部署中是最常见的,并提供了现代生产 JVM 的参考示例。
Java 中的内存泄漏
Java 支持垃圾收集的事实大大降低了 内存泄漏 的发生率。内存泄漏是指分配了内存但从未释放的情况。乍一看,似乎垃圾收集可以防止所有内存泄漏,因为它回收了所有未使用的对象。
然而,在 Java 中,如果对未使用的对象保留了一个有效的(但未使用的)引用,仍然可能发生内存泄漏。例如,当一个方法运行了很长时间(或永远)时,该方法中的局部变量可能会比实际需要的时间长保留对象引用。以下代码为例:
public static void main(String args[]) {
int bigArray[] = new int[100000];
// Do some computations with bigArray and get a result.
int result = compute(bigArray);
// We no longer need bigArray. It will get garbage collected when
// there are no more references to it. Because bigArray is a local
// variable, it refers to the array until this method returns. But
// this method doesn't return.
// If we explicitly sever the reference by assigning it to
// null then the garbage collector knows it can reclaim the array.
bigArray = null;
// Loop forever, handling the user's input
for(;;) handle_input(result);
}
当你使用 HashMap 或类似的数据结构将一个对象与另一个对象关联起来时,也可能会发生内存泄漏。即使这两个对象都不再需要,关联仍然存在于映射中,直到映射本身被回收。如果映射的寿命远远长于它所持有的对象,这可能会导致内存泄漏。
介绍标记-清除算法
Java GC 通常依赖于被广泛称为标记-清除的算法家族。要理解这些算法,回顾一下所有 Java 对象都是在堆中创建的,当对象创建时,一个引用(基本上是指针)存储在 Java 局部变量(或字段)中。局部变量存在于方法的堆栈帧中,如果一个对象从方法中返回,那么当方法退出时,引用将传回调用者的堆栈帧。
由于所有对象都分配在堆中,当堆变满时(或者在细节上取决之前),GC 将会触发。标记-清除的基本思想是追踪堆并确定哪些对象仍在使用中。这可以通过检查每个 Java 线程的堆栈帧(以及一些其他引用来源)并跟随任何引用到堆中来完成。定位到的每个对象都会被标记为仍然存活,并且随后可以检查它是否具有任何引用类型的字段。如果有,这些引用也可以被跟踪和标记。
当递归跟踪活动完成时,所有剩余的未标记对象都被认为不再需要,并且它们占据的堆空间可以作为垃圾进行清除,即,它们使用的内存可以用于进一步的对象分配。如果此分析可以精确执行,那么这种类型的收集器被称为精确垃圾收集器,这并不奇怪。在所有实际目的上,所有 Java GC 都可以被认为是精确的,但在其他软件环境中可能并非如此。
在实际的 JVM 中,堆内存很可能会有不同的区域,并且真实的程序将在正常操作中使用它们全部。在图 6-1 中,我们展示了堆的一个可能布局,其中两个线程(T1 和 T2)持有指向堆的引用。
这些不同的区域被称为伊甸园、幸存者和老年代;我们稍后将在本章中了解每一个区域及其彼此之间的关系。为了简单起见,图示展示了 Java 堆的旧形式,其中每个内存区域是一个单一的内存块。现代的收集器实际上并不是这样布置对象的,但首先这样考虑会更容易理解!
图 6-1. 堆结构
图中还显示,在程序运行时移动应用线程引用的对象将是危险的。
为了避免这种情况,像刚才描述的简单追踪 GC 将在运行时导致停顿(STW)。这是因为所有应用程序线程都被停止,然后进行 GC,最后再次启动应用程序线程。运行时通过在达到安全点时停止应用程序线程来处理此问题——例如,循环开始或方法调用返回之前。在这些执行点上,运行时知道可以停止应用程序线程而没有问题。
这些暂停有时会让开发人员担心,但对于大多数主流用途来说,Java 运行在一个不断在处理器核心上交换进程的操作系统(可能还有多个虚拟化层)上,因此这种轻微的额外停顿通常不是一个问题。在 HotSpot 情况下,已经做了大量工作来优化 GC 并减少 STW 时间,对于那些对应用程序工作负载重要的情况。我们将在下一节讨论其中的一些优化。
JVM 如何优化垃圾收集
弱分代假设(WGH)是我们在第一章中介绍的关于软件的运行时事实的一个很好的例子。简单来说,对象倾向于具有少数几种可能的寿命期望(称为代)。
通常对象只存活了很短的时间(有时称为瞬态对象),然后就变得符合垃圾回收的条件。然而,有一小部分对象存活时间较长,注定成为程序长期状态的一部分(有时称为工作集)。这可以在图 6-2 中看到,我们看到内存量(或创建的对象数)根据预期生命周期进行了绘制。
图 6-2. 弱分代假设
这个事实无法从程序的静态分析中推断出来,然而当我们测量软件的运行时行为时,我们看到这在广泛的工作负载范围内都是正确的。
HotSpot JVM 有一个专门设计用于利用弱分代假设的垃圾收集子系统,在本节中,我们将讨论这些技术如何适用于短寿命对象(这是主要情况)。这个讨论直接适用于 HotSpot,但其他 JVM 通常也采用类似或相关的技术。
在其最简单的形式中,分代垃圾收集器是一种注意到WGH的收集器。他们认为,通过监视内存的一些额外记录,将会比通过友好对待WGH而获得的收益更加有利。在最简单的分代收集器中,通常只有两代——通常被称为年轻代和老年代。
疏散
在我们最初的标记-清除方法中,在清理阶段,GC 回收了个别对象以便重用。这在某种程度上是可以接受的,但会导致内存碎片化以及 GC 需要维护一个可用的“空闲列表”内存块。然而,如果 WGH 成立,并且在任何给定的 GC 周期中大多数对象都是死的,那么使用替代方法来回收空间可能是有意义的。
这种方法通过将堆划分为不同的内存空间来实现;新对象在Eden空间创建。然后,在每次 GC 运行时,我们仅定位活动对象并将它们移动到不同的空间,这个过程称为evacuation。执行这种操作的收集器称为evacuating collectors,它们的特性是在收集结束时可以清空整个内存空间,以便重复使用。
图 6-3 展示了一个正在运行的 evacuating collector,实心块表示存活对象,而斜线框表示已分配但已经死亡(且不可达)的对象。
图 6-3. 疏散收集器
这比朴素的收集方法潜在地更高效,因为不会触及死亡对象。这意味着 GC 时间与活动对象的数量成正比,而不是已分配对象的数量。唯一的缺点是略微增加的簿记成本——我们必须支付复制活动对象的成本,但这几乎总是与通过疏散策略实现的巨大收益相比微不足道。
使用疏散收集器还允许使用每线程分配。这意味着每个应用程序线程可以被分配一个连续的内存块(称为thread-local allocation buffer或 TLAB),用于分配新对象时的独占使用。当分配新对象时,只需在分配缓冲区中递增指针,这是一个极其廉价的操作。
如果一个对象在收集开始前刚创建,那么它将没有时间完成其目的并在 GC 周期开始之前死亡。在只有两个代的收集器中,这种短寿命对象将被移动到长寿命区域,几乎立即死亡,并在下一次完全收集之前一直保留在那里。由于这些收集事件较少(通常也更昂贵),这显得相当浪费。
为了缓解这个问题,HotSpot 引入了survivor space的概念,用于存放已经经历过前几次年轻对象收集的对象。存活的对象在tenuring threshold达到之前会在 survivor spaces 之间被疏散收集器复制,当对象被promoted到老年代时,称为Tenured或OldGen。这解决了短寿命对象堆积在老年代的问题,但也增加了 GC 子系统的复杂性。
压缩
另一种收集算法形式称为compacting collector。这些收集器的主要特点是,在收集周期结束时,分配的内存(即存活对象)被安排在收集区域内的单一连续区域中。
正常情况下,所有存活对象都已经在内存池(或区域)内“洗牌”到了内存范围的开始位置,现在有一个指针指示可供应用程序线程重新启动后写入对象的空闲空间的开始位置。
压缩收集器将避免内存碎片化,但在消耗 CPU 量方面通常比疏散收集器昂贵得多。这两种算法之间存在设计权衡(其细节超出本书的范围),但这两种技术都在 Java(以及许多其他编程语言的)生产收集器中使用。长期存活对象最终进入的空间通常使用压缩收集器进行清理。
本书不讨论 GC 子系统的详细细节。对于需要关心这些细节的生产应用程序,应当查阅专业材料,比如优化 Java(O'Reilly)。
HotSpot 堆
HotSpot JVM 是一个相对复杂的代码片段,由解释器和即时编译器组成,以及一个用户空间内存管理子系统。它由 C、C++以及相当大量的平台特定的汇编代码组成。
注意
HotSpot 管理 JVM 堆本身,基本上完全在用户空间中进行,并且不需要执行系统调用来分配或释放内存。对象最初创建的区域通常称为 Eden(或者 Nursery),大多数生产 JVM 都会在收集 Eden 时使用疏散策略。
现在,让我们总结一下 HotSpot 堆的描述,并回顾其基本特性:
-
Java 堆是 JVM 启动时预留的连续内存块。
-
只有一部分堆最初分配给各种内存池。
-
应用程序运行时,内存池会根据需要调整大小。
-
这些调整由 GC 子系统执行。
-
对象由应用程序线程在 Eden 中创建,并在非确定性 GC 周期中删除。
-
在必要时(即内存紧张时),GC 周期会运行。
-
堆分为两代,年轻代和老年代。
-
年轻代由 Eden 和幸存者空间组成,而老年代只是一个内存空间。
-
经过几个 GC 周期后,对象会晋升到老年代。
-
只收集年轻代的集合通常非常廉价(就所需计算而言)。
-
HotSpot 使用一种高级形式的标记-清除,并准备额外的簿记以提高 GC 性能。
在讨论垃圾收集器时,开发人员应了解另一个重要的术语区分:
并行收集器
一个垃圾收集器可以使用多个线程执行收集。
并发收集器
一个垃圾收集器可以在应用程序线程运行时同时运行。
到目前为止,我们描述的收集算法隐含地都是并行的,但不是并发的收集器。
注意
在现代 GC 方法中,越来越多地采用部分并发算法的趋势。这些类型的算法比 STW 算法更为复杂且计算成本更高,并涉及权衡。然而,今天的应用程序通常愿意为减少应用程序暂停而进行一些额外的计算。
在传统的 Java 版本(版本 8 及更早版本)中,堆具有简单的结构:每个内存池(Eden、幸存者空间和 Tenured)都是连续的内存块。这是我们在图表中展示的结构,因为它更容易让初学者可视化。这些旧版本中老年代的默认收集器称为并行。然而,在现代版本的 HotSpot 中,一个名为垃圾优先(G1)的新型部分并发收集算法已成为默认。
G1
G1 是基于区域的收集器的一个示例,并且与旧式堆的布局不同。一个区域是一个内存区域(通常大小为 1M,但较大堆可能有 2、4、8、16 或 32M 的区域),其中所有对象都属于同一个内存池。然而,在区域收集器中,组成池的不同区域不一定位于内存中的相邻位置。这与 Java 8 堆不同,在 Java 8 堆中,每个池都是连续的,尽管在这两种情况下整个堆仍然是连续的。
警告
G1 在每个 Java 版本中使用不同版本的算法,各版本在性能和其他行为方面有一些重要的差异。从 Java 8 升级到更高版本并采用 G1 时,进行全面性能重新测试非常重要。您可能会发现,切换到 Java 11 或 17 时,您需要更少的资源(甚至可以节省金钱)。
G1 的注意点在于它主要集中在大部分是垃圾的区域上,因为这些区域有最佳的自由内存回收。它是一个撤离收集器,在撤离单个区域时进行增量整理。
最初,G1 收集器的目标是取代之前的 CMS 作为低暂停时间收集器,并允许用户根据 GC 时的暂停时间和频率指定暂停目标。
JVM 提供了一个命令行开关来控制收集器的暂停目标:-XX:MaxGCPauseMillis=200。这意味着默认的暂停时间目标是 200 毫秒,但您可以根据需要更改此值。
当然,收集器能推动的极限是有限的。Java GC 受到新分配内存速度的驱动,对于许多 Java 应用程序来说,这是非常不可预测的。
如前所述,G1 最初是为了成为一种低暂停的替代收集器。然而,其行为的整体特性使得它实际上演变成了一种更通用的收集器(这就是为什么它现在成为默认收集器的原因)。
需要注意的是,开发一个适用于通用使用的新生产级收集器并非一蹴而就的过程。在接下来的章节中,让我们继续讨论 HotSpot 提供的替代收集器(包括 Java 8 的并行收集器)。
详细的全面处理超出了本书的范围,但了解备选收集器的存在是值得的。对于非 HotSpot 用户,您应该查阅您的 JVM 文档,了解可能适合您的选项。
并行老年代(ParallelOld)
默认情况下,在 Java 8 中,老年代的收集器是一个并行(但不是并发)的标记-清除收集器。乍看之下,它似乎与年轻代使用的收集器相似。然而,它在一个非常重要的方面有所不同:它不是一个疏散式收集器。相反,老年代在进行收集时进行压缩。这是重要的,以防止内存空间随着时间的推移变得碎片化。
ParallelOld 收集器非常高效,但有两个特性使其在现代应用中不太理想。它是:
-
完全 STW
-
堆大小的暂停时间线性增长
这意味着一旦 GC 开始,就无法提前中止,并且必须允许循环完成。随着堆大小的增加,这使得ParallelOld 收集器比 G1 收集器不那么吸引人,后者可以在管理可分配速率的情况下通常保持恒定的暂停时间。
在现代部署中,特别是针对 Java 11+,G1 通常在以前使用ParallelOld的大多数应用程序中表现更好。ParallelOld 收集器截至 Java 17 仍然可用,供那些(希望很少)仍然需要的应用程序使用,但平台的方向显而易见——尽可能地使用 G1。
串行
串行和 SerialOld 收集器与并行收集器的运行方式类似,但有一个重要区别:它们仅使用单个 CPU 核心执行完全 STW 的 GC。
在现代多核系统上,使用这些收集器没有任何好处,因此不应使用它们,因为它们只是并行收集器的低效形式。然而,您仍可能在容器中运行 Java 应用程序时遇到这些收集器。有关容器化 Java 的完整讨论超出了本书的范围。但是,如果您的应用程序在容器中运行的环境过小(内存不足或仅有单个 CPU),那么 JVM 将自动选择串行收集器。
因此,我们不建议在单核容器中运行 Java,因为串行收集器在几乎所有实际负载场景下的表现明显比 G1 差。
Shenandoah
Shenandoah 是 Red Hat 开发的一种新的 GC 算法,可以有效地处理某些情况下 G1 和其他算法表现不佳的情况。
Shenandoah 的目标是降低暂停时间,特别是在大堆上,并尽可能地保证暂停时间不会超过 1 毫秒,无论堆的大小如何。
像 G1 一样,Shenandoah 是一种执行并发标记的疏散区域收集器。区域的疏散导致增量压缩,但关键的区别在于,在 G1 中,疏散发生在 STW 阶段,而在 Shenandoah 中,疏散是与应用线程并发进行的。
然而,没有免费的午餐,Shenandoah 的用户可能会经历高达 15% 的额外开销(即,应用吞吐量的减少),但确切的数字将取决于工作负载的细节。例如,在一些特定的基准测试中,您可以观察到显著的开销,接近预期范围的上限。
可以通过以下命令行开关激活 Shenandoah:
-XX:+UseShenandoahGC
需要注意的一个重要点是,在撰写本文时,Shenandoah 还不是一个分代收集器,尽管正在进行将分代添加到实现中的工作。
ZGC
除了 Shenandoah 外,Oracle 还创建了一种新的超低暂停收集器,称为 ZGC。它的设计旨在吸引与 Shenandoah 类似的工作负载,并且在意图、效果和开销上基本相似。ZGC 是一种单代、区域化、NUMA-aware、压缩收集器。然而,ZGC 的实现与 Shenandoah 大不相同。
可以通过以下命令行开关激活 ZGC:
-XX:+UseZGC
ZGC 只需一个停止-世界暂停来执行根扫描,这意味着 GC 暂停时间不会随着堆的大小或活动对象的数量增加而增加。由于其预期的适用领域(大堆的超低暂停),ZGC 最常由 Oracle 客户在 Oracle 支持的 Java 构建上使用。
终结
为了完整起见,开发人员应该知道一种称为 终结 的旧的资源管理技术。然而,这种技术已经 极度 被弃用,绝大多数 Java 开发人员在任何情况下 不应该 直接使用它。
注意
终结已经被弃用,并将在未来的发布中删除。目前机制仍然默认启用,但可以使用开关禁用。在未来的发布中,它将默认禁用,然后最终删除。
最终化机制旨在自动释放不再需要的资源。垃圾收集器可以自动释放对象使用的内存资源,但对象可能持有其他类型的资源,例如打开的文件和网络连接。垃圾收集器无法释放这些额外的资源,因此最终化机制旨在允许开发人员执行清理任务,如关闭文件、终止网络连接、删除临时文件等。
最终化机制的工作方式如下:如果一个对象有一个finalize()方法(通常称为终结器),那么在对象变得未使用(或不可达)但在垃圾收集器回收对象分配的空间之前,将调用该方法。终结器用于执行对象的资源清理工作。
最终化的核心问题在于 Java 不保证何时进行垃圾收集或对象收集的顺序。因此,平台无法保证何时(甚至是否)调用终结器或调用终结器的顺序。
最终化细节
最终化机制是试图在其他语言和环境中实现类似概念的一种尝试。特别是,C++有一种称为 RAII(资源获取即初始化)的模式,以类似的方式提供自动资源管理。在该模式中,程序员提供一个析构函数(在 Java 中称为finalize()),用于在对象销毁时执行清理和释放资源。
这个基本用例非常简单:当对象被创建时,它获取某些资源的所有权,并且对象对该资源的所有权与对象的生命周期相关联。当对象销毁时,资源的所有权会自动释放,因为平台会调用析构函数而无需程序员介入。
尽管最终化听起来与此机制表面上相似,但实际上它们是根本不同的。事实上,最终化语言特性存在致命缺陷,这是由于 Java 与 C++的内存管理方案之间的差异。
在 C++的情况下,内存是手动处理的,对象的生命周期由程序员控制。这意味着在对象删除后可以立即调用析构函数(平台保证这一点),因此资源的获取和释放直接与对象的生命周期相关联。
另一方面,Java 的内存管理子系统是一个根据需要运行的垃圾收集器,响应内存不足以分配时运行。因此,它以可变(非确定性)的间隔运行,因此finalize()只有在对象被收集时才会运行,而这将在未知时间发生。
如果使用finalize()机制自动释放资源(例如文件句柄),则不能保证这些资源何时(如果有的话)会实际可用。这导致最终化机制在其所声明的自动资源管理目的上基本不适用——我们无法保证最终化会在资源不足之前足够快地发生。作为保护稀缺资源(例如文件句柄)的自动清理机制,最终化在设计上存在缺陷。
最终化只有极少数合法用例,只有少数 Java 开发人员会遇到它们。如果有任何疑问,请不要使用最终化——通常正确的替代方法是使用try-with-resources。有关try-with-resources 的更多详细信息,请参见第十章。
Java 对并发的支持
线程的概念是执行的轻量级单位——比进程小,但仍能执行任意 Java 代码。通常的实现方式是每个线程作为操作系统的完全成熟执行单元,但属于一个进程,该进程的地址空间与该进程组成的其他线程共享。这意味着每个线程可以独立调度,具有自己的堆栈和程序计数器,但与同一进程中的其他线程共享内存和对象。
Java 平台从第一个版本开始就支持多线程编程。该平台为开发人员提供了创建新执行线程的能力。
要理解这一点,首先我们必须详细考虑 Java 程序启动时发生的情况以及原始应用程序线程(通常称为主线程)的出现:
-
程序员执行
java Main(也可能是其他启动方式)。 -
这使得 Java 虚拟机,即所有 Java 程序运行的上下文,启动起来。
-
JVM 检查其参数,并看到程序员请求从
Main.class的入口点(main()方法)开始执行。 -
假设
Main通过类加载检查,为程序的执行启动了一个专用线程(主线程)。 -
JVM 字节码解释器在主线程上启动。
-
主线程的解释器读取
Main::main()的字节码,执行开始,逐个字节码执行。
每个 Java 程序都是这样开始的,但这也意味着:
-
每个 Java 程序都作为管理模型的一部分开始,每个线程都有一个解释器。
-
每个 Java 程序始终作为多线程操作系统进程的一部分运行。
-
JVM 具有控制 Java 应用程序线程的能力。
由此引发的是,在 Java 代码中创建新的执行线程通常很简单:
Thread t = new Thread(() -> {System.out.println("Hello Thread");});
t.start();
这段小代码创建并启动一个新线程,该线程执行 lambda 表达式的主体,然后执行。从技术上讲,lambda 在传递给Thread构造函数之前被转换为Runnable接口的实例。
线程机制允许新线程与原始应用程序线程以及 JVM 本身为各种目的启动的线程并发执行。
对于 Java 平台的主流实现,每当我们调用Thread::start()时,此调用被委托给操作系统,并创建一个新的 OS 线程。这个新的 OS 线程exec()了 JVM 字节码解释器的一个新副本。解释器从run()方法(或等效地从 lambda 的主体)开始执行。
这意味着应用程序线程的 CPU 访问由操作系统的调度程序控制——这是操作系统的内置部分,负责管理处理器时间片段(并且不允许应用程序线程超出其分配的时间)。
在 Java 的较新版本中,出现了越来越多的运行时管理并发的趋势。这是指对于许多目的来说,显式管理线程并不理想。相反,运行时应该提供“点火并忘记”的能力,程序指定需要做什么,但如何完成这些工作的底层细节留给运行时处理。这一观点可以在java.util.concurrent中的并发工具包中看到,我们在第八章中简要讨论。
在本章的其余部分,我们将介绍 Java 平台提供的低级并发机制,每个 Java 开发人员都应该了解。强烈建议读者在进行任何重要的并发编程之前,理解基于低级Thread和运行时管理方法。
线程生命周期
让我们从查看应用程序线程的生命周期开始。每个操作系统都有一个线程视图,其细节可能不同(但在大多数情况下在高层次上是大致相似的)。Java 努力将这些细节抽象化,并有一个称为Thread.State的枚举,它包装了操作系统对线程状态的视图。Thread.State的值提供了线程生命周期的概述:
NEW
线程已创建,但尚未调用其start()方法。所有线程都从这个状态开始。
RUNNABLE
当操作系统安排它时,线程正在运行或可运行。
BLOCKED
线程没有运行,因为它正在等待获取锁定,以便可以进入synchronized方法或块。在本节的后面部分,我们将进一步了解synchronized方法和块。
WAITING
线程没有运行,因为它已调用Object.wait()或Thread.join()。
TIMED_WAITING
线程不在运行,因为它已调用Thread.sleep()或已调用Object.wait()或Thread.join()并带有超时值。
TERMINATED
线程已完成执行。它的run()方法已正常退出或通过抛出异常退出。
这些状态代表了一个线程的视图,这在至少主流操作系统中是通用的,导致一个像图 6-4 那样的视图。
图 6-4. 线程生命周期
线程也可以使用Thread.sleep()方法使其休眠。这需要一个以毫秒为单位的参数,表示线程希望休眠的时间,如下所示:
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
注
sleep方法的参数是对操作系统的请求,而不是一个要求。例如,您的程序可能因负载和其他特定于运行时环境的因素而睡眠时间超过请求的时间。
我们将在本章稍后讨论Thread的其他方法,但首先我们需要涵盖一些重要的理论,这些理论涉及线程如何访问内存,并且对理解为何多线程编程困难且可能给开发者带来许多问题至关重要。
可见性和可变性
在主流的 Java 实现中,一个进程中所有 Java 应用程序线程都有自己的调用堆栈(和本地变量),但共享一个单一的堆。这使得在线程之间共享对象非常容易,因为只需从一个线程传递一个引用到另一个线程即可。这在图 6-5 中有所说明。
这导致了 Java 的一个通用设计原则——对象默认是可见的。如果我有一个对象的引用,我可以复制它并将其交给另一个线程,没有任何限制。Java 的引用实质上是指向堆中某个位置的类型化指针——线程共享同一个堆,因此默认可见是一种自然的模型。
除了默认可见性,Java 还有另一个对完全理解并发性非常重要的属性,即对象是可变的:对象实例字段的内容通常可以更改。我们可以通过使用final关键字使个别变量或引用常量化,但这并不适用于对象的内容。
正如我们将在本章的其余部分看到的那样,这两个属性的结合——跨线程的可见性和对象的可变性——在尝试推理并发 Java 程序时引发了许多复杂性。
图 6-5. 线程之间共享的内存
并发安全性
如果我们要编写正确的多线程代码,那么我们希望我们的程序满足某个重要的属性。
在第五章中,我们定义了一个安全的面向对象程序,即通过调用其可访问方法将对象从合法状态移动到合法状态。这个定义对于单线程代码很有效。然而,当我们尝试将其扩展到并发程序时,会遇到一个特殊的困难。
提示
安全的多线程程序 是指任何对象不论调用什么方法,以及应用程序线程由操作系统按何种顺序调度,都不会被其他对象看到处于非法或不一致状态的程序。
对于大多数主流情况,操作系统将根据负载和系统中其他运行情况,在特定处理器核心上安排线程运行。如果负载很高,可能还有其他需要运行的进程。
操作系统如果需要的话,会强制从 CPU 核心上删除 Java 线程。该线程会立即暂停,无论它当前在做什么,包括正在执行方法的中间过程。然而,正如我们在第五章中讨论的那样,一个方法在操作对象时可能会将其临时置于非法状态,只要在方法退出之前纠正即可。
这意味着,如果一个线程在完成长时间方法之前被换出,即使程序遵循安全规则,也可能会使对象处于不一致状态。另一种说法是,即使正确建模了单线程情况下的数据类型,也仍需保护免受并发影响。为此添加额外保护层的代码称为并发安全 或(更非正式地)线程安全。
在下一节中,我们将讨论实现这种安全性的主要手段,而在本章末尾,我们将介绍一些在某些情况下也可能有用的其他机制。
排除和保护状态
任何修改或读取可能导致状态不一致的代码都必须受到保护。为此,Java 平台只提供了一种机制:排除。
考虑一个方法,其中包含一系列操作,如果中途被中断,可能会使对象处于不一致或非法状态。如果这种非法状态对其他对象可见,则可能导致代码执行错误。
例如,考虑一个 ATM 或其他发放现金的机器:
public class Account {
private double balance = 0.0; // Must be >= 0
// Assume the existence of other field (e.g., name) and methods
// such as deposit(), checkBalance(), and dispenseNotes()
public Account(double openingBal) {
balance = openingBal;
}
public boolean withdraw(double amount) {
if (balance >= amount) {
try {
Thread.sleep(2000); // Simulate risk checks
} catch (InterruptedException e) {
return false;
}
balance = balance - amount;
dispenseNotes(amount);
return true;
}
return false;
}
}
withdraw() 内发生的操作序列可能会使对象处于不一致状态。特别是在检查余额后,第二个线程可能在第一个线程在模拟风险检查时休眠时介入,并且账户可能会透支,违反了balance >= 0的约束。
这是一个系统的例子,对象上的操作是单线程安全的(因为如果从单个线程调用,则对象不会达到非法状态(balance < 0)),但不是并发安全的。
为了让开发人员使此类代码具备并发安全性,Java 提供了synchronized关键字。此关键字可以应用于块或方法,当使用它时,平台将其用于限制块或方法内部代码的访问。
注意
因为synchronized包围了代码,许多开发人员得出结论,Java 中的并发性是关于代码的。一些文本甚至将位于同步块或方法内部的代码称为临界区,并认为这是并发性的关键方面。实际情况并非如此;相反,我们必须防范的是数据的不一致性,正如我们将看到的那样。
Java 平台为其创建的每个对象跟踪一个特殊的标记,称为监视器。这些监视器(也称为锁)被synchronized用来指示以下代码可能会暂时使对象不一致。synchronized块或方法的事件序列是:
-
线程需要修改对象,并可能在中间步骤中使其暂时不一致
-
线程获取监视器,表示它需要临时独占对象的访问权限
-
线程修改对象,在完成时将其保留在一致的、合法状态
-
线程释放监视器
如果另一个线程在修改对象时尝试获取锁定,则获取锁定的尝试将被阻塞,直到持有线程释放锁定。
请注意,除非您的程序创建了多个共享数据的线程,否则无需使用synchronized语句来保护数据结构。如果只有一个线程访问数据结构,就无需用synchronized保护它。
一个关键的要点是——获取监视器并不会阻止访问对象。它只是防止其他线程获取锁。正确的并发安全代码要求开发人员确保所有可能修改或读取潜在不一致状态的访问都在操作或读取该状态之前获取对象监视器。
换句话说,如果一个synchronized方法正在操作一个对象并将其置于非法状态,而另一个(非同步的)方法读取该对象,则可能看到不一致的状态。
注意
同步是一种保护状态的协作机制,因此非常脆弱。一个错误(例如在所需的方法上漏掉一个synchronized关键字)可能会对系统整体的安全性产生灾难性的后果。
使用关键字synchronized作为“需要临时独占访问”的关键字的原因是,除了获取监视器之外,JVM 还会在进入代码块时重新从主内存中读取对象的当前状态。同样,在退出synchronized块或方法时,JVM 会将对象的任何修改状态刷新回主内存。
没有同步,系统中不同的 CPU 核心可能看到内存的不同视图,内存不一致性可能会损坏运行程序的状态,就像我们在 ATM 示例中看到的那样。
这最简单的例子称为丢失更新,如下面的代码所示:
public class Counter {
private int i = 0;
public int increment() {
return i = i + 1;
}
public int getCounter() { return i; }
}
可以通过一个简单的控制程序来驱动这一点:
Counter c = new Counter();
int REPEAT = 10_000_000;
Runnable r = () -> {
for (int i = 0; i < REPEAT; i++) {
c.increment();
}
};
Thread t1 = new Thread(r);
Thread t2 = new Thread(r);
t1.start();
t2.start();
t1.join();
t2.join();
int anomaly = (2 * REPEAT) - c.getCounter();
double perc = ((double) anomaly * 100) / (2 * REPEAT);
System.out.println("Lost updates: "+ anomaly +" ; % = " + perc);
如果这个并发程序是正确的,那么异常值(丢失更新的数量)应该完全为零。但事实并非如此,因此我们可以得出结论,非同步访问基本上是不安全的。
相比之下,我们还看到将关键字synchronized添加到增量方法中足以将丢失更新异常减少到零—即使在多个线程存在的情况下,也能使方法正确。
volatile
Java 提供了另一个关键字来处理对数据的并发访问。这就是volatile关键字,它指示在应用程序代码使用字段或变量之前,必须重新从主内存中读取其值。同样,在修改了volatile值后,一旦写入变量完成,它必须立即写回主内存。
volatile关键字的一个常见用法是“运行直到关闭”模式。这在多线程编程中被使用,其中外部用户或系统需要向处理线程发出信号,告知它应该完成当前正在进行的工作,然后优雅地关闭。有时这被称为“优雅完成”模式。让我们看一个典型的例子,假设这段代码用于实现Runnable的类中的处理线程:
private volatile boolean shutdown = false;
public void shutdown() {
shutdown = true;
}
public void run() {
while (!shutdown) {
// ... process another task
}
}
只要另一个线程没有调用shutdown()方法,处理线程就会继续顺序处理任务(这通常与BlockingQueue结合非常有用以传递工作)。一旦另一个线程调用了shutdown(),处理线程立即看到shutdown标志改为true。这不会影响正在运行的作业,但一旦任务完成,处理线程将不会接受另一个任务,而是会优雅地关闭。
然而,尽管 volatile 关键字很有用,但它并不能完全保护状态——正如我们在 Counter 中使用它来标记字段 volatile 一样。我们可能天真地假设这会保护 Counter 中的代码。然而,实际上并非如此。为了验证这一点,修改之前的 Counter 示例,并在字段 i 中添加 volatile 关键字,然后重新运行示例。观察到的非零异常值(因此丢失更新问题的存在)告诉我们,仅凭 volatile 关键字并不能使代码线程安全。
Thread 的有用方法
Thread 类具有许多方法,可在创建新应用程序线程时为您提供便利。这不是一个详尽的列表——Thread 上还有许多其他方法,但这是一些常见方法的描述。
getId()
此方法返回线程的 ID 号,作为 long 类型。此 ID 在线程生命周期内保持不变,并且在此 JVM 实例中保证是唯一的。
getPriority() 和 setPriority()
这些方法用于控制线程的优先级。调度程序决定如何处理线程优先级;例如,一种策略可能是在高优先级线程等待时不运行低优先级线程。在大多数情况下,无法影响调度程序如何解释优先级。线程优先级表示为一个介于 1 和 10 之间的整数,10 表示最高优先级。
setName() 和 getName()
这些方法允许开发者为每个线程设置或检索名称。命名线程是一种良好的实践,特别是在像 JDK Mission Control 这样的工具中,它可以极大地简化调试(我们将在第十三章中简要讨论)。
getState()
返回一个 Thread.State 对象,指示此线程处于哪种状态,其值如“线程生命周期”中定义的那样。
isAlive()
此方法用于测试线程是否仍然存活。
start()
此方法用于创建一个新的应用程序线程,并安排其运行,其中 run() 方法是执行的入口点。当线程在其 run() 方法中达到结尾或在该方法中执行 return 语句时,线程会正常终止。
interrupt()
如果线程在 sleep()、wait() 或 join() 调用中被阻塞,则在表示该线程的 Thread 对象上调用 interrupt() 将导致线程收到 InterruptedException(并唤醒)。
如果线程涉及可中断 I/O,则 I/O 将被终止,并且线程将收到 ClosedByInterruptException。即使线程未参与可以中断的任何活动,线程的中断状态也将设置为 true。
join()
当前线程等待,直到与 Thread 对象对应的线程已经终止。可以将其视为一条指令,要求在另一个线程完成之前不要继续执行。
setDaemon()
用户线程是一种线程,如果它仍然活动,将阻止进程退出 — 这是线程的默认行为。有时,程序员希望线程不阻止退出的发生 — 这些称为守护线程。线程作为守护线程或用户线程的状态可以通过setDaemon()方法来控制,并可以使用isDaemon()来检查。
setUncaughtExceptionHandler()
当线程通过抛出异常(即程序没有捕获的异常)退出时,默认行为是打印线程的名称、异常类型、异常消息和堆栈跟踪。如果这不足够,您可以为线程安装自定义的未捕获异常处理程序。例如:
// This thread just throws an exception
Thread handledThread =
new Thread(() -> { throw new UnsupportedOperationException(); });
// Giving threads a name helps with debugging
handledThread.setName("My Broken Thread");
// Here's a handler for the error.
handledThread.setUncaughtExceptionHandler((t, e) -> {
System.err.printf("Exception in thread %d '%s':" +
"%s at line %d of %s%n",
t.getId(), // Thread id
t.getName(), // Thread name
e.toString(), // Exception name and message
e.getStackTrace()[0].getLineNumber(),
e.getStackTrace()[0].getFileName()); });
handledThread.start();
在某些情况下,这可能很有用;例如,如果一个线程正在监督一组其他工作线程,那么这种模式可以用来重新启动任何死掉的线程。
还有setDefaultUncaughtExceptionHandler(),一个static方法,设置一个备用处理程序来捕获任何线程的未捕获异常。
线程的弃用方法
除了Thread的有用方法外,还有一些危险的方法不应使用。这些方法是原始 Java 线程 API 的一部分,但很快就被发现不适合开发者使用。不幸的是,由于 Java 的向后兼容性要求,无法将它们从 API 中移除。开发者需要意识到它们,并在所有情况下都避免使用。
stop()
Thread.stop() 几乎不可能正确使用,因为stop()会立即终止线程,而不给它恢复对象到合法状态的机会。这与并发安全等原则直接相反,因此永远不应使用。
suspend()、resume()和 countStackFrames()
当suspend()机制挂起时,不会释放它所持有的任何监视器,因此任何试图访问这些监视器的其他线程都将导致死锁。在实践中,这种机制在这些死锁和resume()之间产生竞态条件,使得这组方法无法使用。方法countStackFrames()仅在对挂起线程调用时才起作用,因此也被此限制禁用。
destroy()
此方法从未被实现 — 如果实现了,它将会遇到与suspend()相同的竞态条件问题。
所有这些已弃用的方法都应该被始终避免使用。已开发出一组安全替代模式,这些模式实现了与前述方法相同的预期目标。其中一个良好的示例是我们已经见过的运行至关闭模式。
多线程处理
要有效地处理多线程代码,您需要掌握关于监视器和锁的基本事实。这份清单包含了您应该了解的主要事实:
-
同步是保护对象状态和内存,而不是代码。
-
同步是线程之间的一种合作机制。一个 bug 可能会破坏这种合作模型,并产生深远的后果。
-
获取监视器只会防止其他线程获取监视器 —— 它并不会保护对象本身。
-
非同步方法在锁定对象的监视器时可能会看到(并修改)不一致的状态。
-
锁定
Object[]不会锁定各个对象。 -
基本类型是不可变的,因此它们不能(也不需要)被锁定。
-
在接口方法声明中不能出现
synchronized。 -
内部类只是语法糖,因此对内部类的锁定对外部类没有影响(反之亦然)。
-
Java 的锁是 可重入 的。这意味着如果持有监视器的线程遇到同一监视器的同步块,它可以进入该块。¹
我们还看到线程可以被要求睡眠一段时间。同样有用的是无限期地睡眠,直到满足条件。在 Java 中,这通过 Object 上存在的 wait() 和 notify() 方法来处理。
就像每个 Java 对象都有一个关联的锁一样,每个对象都维护着一个等待线程的列表。当线程调用对象的 wait() 方法时,线程持有的任何锁都会被临时释放,并且该线程会被添加到该对象的等待线程列表中,并停止运行。当另一个线程调用相同对象的 notifyAll() 方法时,对象会唤醒等待的线程,并允许它们继续运行。
例如,让我们看一个简化版本的队列,它对多线程使用是安全的:
/*
* One thread calls push() to put an object on the queue.
* Another calls pop() to get an object off the queue. If there is no
* data, pop() waits until there is some, using wait()/notify().
*/
public class WaitingQueue<E> {
LinkedList<E> q = new LinkedList<E>(); // storage
public synchronized void push(E o) {
q.add(o); // Append the object to the end of the list
this.notifyAll(); // Tell waiting threads that data is ready
}
public synchronized E pop() {
while(q.size() == 0) {
try { this.wait(); }
catch (InterruptedException ignore) {}
}
return q.remove();
}
}
如果队列为空(导致 pop() 失败),这个类会在 WaitingQueue 的实例上使用 wait()。等待的线程会暂时释放其监视器,允许另一个线程来获取它 —— 一个可能会在队列上 push() 新内容的线程。当原始线程再次被唤醒时,它会从最初等待的位置重新开始运行,并重新获取它的监视器。
注意
wait() 和 notify() 必须在 synchronized 方法或块中使用,因为它们需要临时放弃锁才能正常工作。
一般情况下,大多数开发者不应该像本例中这样自己编写类——而是应该使用 Java 平台为你提供的库和组件。
总结
在本章中,我们讨论了 Java 对内存和并发的视图,以及这些主题如何密切相关。
Java 的垃圾回收是简化开发的重要方面之一,因为它消除了程序员手动管理内存的需要。我们已经看到 Java 提供了先进的 GC 能力,以及现代版本默认使用部分并发的 G1 收集器。
此外,随着处理器开发出越来越多的核心,我们将需要使用并发编程技术来有效地利用这些核心。换句话说,并发性是未来高性能应用的关键。
Java 的线程模型基于三个基本概念:
共享的、默认可见的可变状态
对象可以在进程中的不同线程之间轻松共享,并且任何持有它们引用的线程都可以对它们进行改变(“突变”)。
抢占式线程调度
操作系统线程调度程序可以随时在核心上切换线程。
对象状态只能由锁来保护
锁可能难以正确使用,状态在意想不到的地方(如读操作)也很容易受到影响。
综上所述,Java 在并发处理方面的这三个方面解释了为什么多线程编程会给开发者带来这么多头痛。
¹ 除了 Java 之外,不是所有的锁实现都具有这种属性。
第二部分:使用 Java 平台
第二部分介绍了一些随 Java 附带的核心库以及一些中高级 Java 程序常见的编程技术。
-
第七章,“编程和文档约定”
-
第八章,“Java 集合框架使用”
-
第九章,“处理常见数据格式”
-
第十章,“文件处理和 I/O”
-
第十一章,“类加载、反射和方法句柄”
-
第十二章,“Java 平台模块”
-
第十三章,“平台工具”
第七章:编程和文档约定
本章解释了许多重要且有用的 Java 编程和文档约定。它涵盖了:
-
一般的命名和大小写约定
-
可移植性提示和约定
-
javadoc文档注释语法和约定
命名和大小写约定
以下广泛采用的命名约定适用于 Java 中的模块、包、引用类型、方法、字段和常量。由于这些约定几乎被普遍遵循,并且因为它们影响你所定义的类的公共 API,你应该也要采用它们:
模块
由于从 Java 9 开始,模块是 Java 应用程序的首选分发单元,所以当命名它们时应特别小心。
模块名必须是全局唯一的——模块系统基本上是以此假设为前提的。由于模块实际上是超级包(或者包的聚合),模块名应与分组到模块中的包名密切相关。一个推荐的做法是将包分组到模块中,并使用包的 根名称 作为模块名。例如,如果一个应用程序的所有包都位于 com.mycompany.* 下,则 com.mycompany 是你的模块的一个好名字。
包
通常习惯确保你公开可见的包名是唯一的。一种常见的做法是用你拥有的互联网域名的倒置名称作为前缀(例如,com.oreilly.javanutshell)。
现在对这个约定的严格遵循已经不像以前那样严格了,一些项目仅仅采用一个简单、可识别且唯一的前缀。所有包名应该是小写的。
类
类型名应该以大写字母开头,并使用驼峰命名法(例如,String)。这通常被称为 帕斯卡命名法。如果一个类名由多个单词组成,每个单词应该以大写字母开头(例如,StringBuffer)。如果类型名或类型名中的一个词是一个首字母缩写词,那么首字母缩写词可以用全大写字母来书写(例如,URL,HTMLParser)。
因为类和枚举类型被设计用来表示对象,所以你应该选择名词作为类名(例如,Thread,Teapot,FormatConverter)。
枚举类型是具有有限实例数量的类的特殊情况。除非是非常特殊的情况,它们应该被命名为名词。enum 类型定义的常量通常也是按照下面的常量规则写成全大写字母。
接口
Java 程序员通常以以下两种方式使用接口:要么传达一个类具有额外的、补充的方面或行为;要么指示该类是接口的一个可能的实现,而对于这个接口有多种有效的实现选择。
当一个接口用于提供关于实现它的类的附加信息时,通常选择一个形容词作为接口名称(例如 Runnable,Cloneable,Serializable)。
当一个接口旨在更像一个抽象超类时,使用名词作为名称(例如,Document,FileNameMap,Collection)。按照惯例,不要通过名称表明它是一个接口(即不要使用 IDocument 或 DocumentInterface)。
方法
方法名始终以小写字母开头。如果名称包含多个单词,则从第二个单词开始每个单词的首字母大写(例如,insert(),insertObject(),insertObjectAt())。这通常被称为驼峰命名法。
方法名称通常选择使第一个单词为动词。方法名称可以尽可能长以清晰表达其目的,但在可能的情况下选择简洁的名称。避免过于通用的方法名称,如 performAction(),go(),或可怕的 doIt()。
字段和常量
非常量字段名称遵循与方法名称相同的大写规范。应选择最能描述字段用途或值的名称。不鼓励使用前缀来指示字段的类型或可见性。
如果一个字段是 static final 常量,则应使用全大写字母编写。如果常量的名称包含多个单词,则应使用下划线分隔这些单词(例如,MAX_VALUE)。
参数
方法参数遵循与非常量字段相同的大写规范。方法参数的名称出现在方法的文档中,因此应选择能够尽可能清楚地表明参数用途的名称。尽量将参数名称保持为单个单词,并且在使用时保持一致。例如,如果一个 WidgetProcessor 类定义了许多接受 Widget 对象作为第一个参数的方法,则将该参数命名为 widget。
局部变量
局部变量名称是实现细节,从不在类外部可见。尽管如此,选择良好的名称可以使您的代码更易于阅读、理解和维护。通常,变量的命名遵循与方法和字段相同的约定。
除了特定类型名称的约定外,还有关于您应在名称中使用哪些字符的约定。Java 允许在任何标识符中使用 $ 字符,但按照惯例,其使用应保留给源代码处理器生成的合成名称。例如,Java 编译器用它来使内部类工作。不应在您创建的任何名称中使用 $ 字符。
Java 允许名称使用来自整个 Unicode 字符集的任何字母数字字符。虽然这对于非英语系程序员来说可能很方便,但 Unicode 的使用从未真正普及,这种用法非常罕见。
实用命名
我们赋予构造物的名称非常重要。命名是将我们的抽象设计传达给同行的关键过程。将软件设计从一个人的头脑转移到另一个人的头脑的过程很难——在许多情况下,比将我们的设计从头脑转移到将执行它的机器更难。
因此,我们必须尽一切努力确保这一过程得以简化。名称是这一过程的关键。在审查代码时(所有代码都应该经过审查),特别注意已选择的名称:
-
类型的名称是否反映了这些类型的目的?
-
每个方法是否确切地执行其名称所暗示的操作?理想情况下,既不多也不少?
-
名称是否足够描述性?是否可以使用更具体的名称?
-
这些名称是否适合描述它们所描述的领域?
-
名称是否在整个领域中保持一致?
-
名称是否混合了隐喻?
-
名称是否重复使用了软件工程中的常见术语?
-
布尔返回方法的名称是否包括否定?这些通常需要更多注意力才能理解(例如,
notEnabled()vs.enabled())。
在软件中,混合隐喻很常见,尤其是在应用程序发布了几个版本之后。一个系统最初完全合理地使用称为Receptionist(用于处理传入连接)、Scribe(用于持久化订单)和Auditor(用于检查和调解订单)的组件,很容易在后续版本中以一个称为Watchdog的类结束,用于重新启动进程。这并不是很糟糕,但它打破了先前存在的人们职称的已建立模式。
还有一点非常重要,那就是要意识到软件随时间变化很多。发布第 1 版时非常适当的名称可能到第 4 版时已经非常误导。应该注意的是,随着系统的重心和意图的变化,名称应该与代码一起进行重构。现代 IDE 对全局搜索和替换符号没有问题,因此在不再有用时没有必要固守过时的隐喻。
最后要注意的一点是:过于严格地解释这些指南可能会导致开发人员产生一些非常奇怪的命名结构。有许多优秀的描述,说明了将这些约定推向极端可能导致的一些荒谬行为。
换句话说,这里描述的约定并非强制性的。在绝大多数情况下,遵循它们将使您的代码更易于阅读和维护。但是,如果因为更易于阅读和理解而偏离这些指南,也不必害怕。
宁可违反这些规则,也不要说出任何显得十分粗野的话。
乔治·奥威尔
最重要的是,您应该对您编写的代码预期的寿命有所了解。银行中的风险计算系统可能有十年或更长的寿命,而初创公司的原型可能仅在几周内相关。因此,需要相应地进行文档编写 - 代码越长时间活跃,其文档和命名就需要越好。
Java 文档注释
Java 代码中的大多数普通注释解释了该代码的实现细节。相比之下,Java 语言规范定义了一种特殊类型的注释,称为文档注释,用于记录您代码的 API。
文档注释是普通的多行注释,以/**开头(而不是通常的/*),以*/结尾。文档注释出现在类型或成员定义之前,包含该类型或成员的文档。文档可以包括简单的 HTML 格式化标记和其他特殊关键字,提供额外的信息。
编译器会忽略文档注释,但可以通过javadoc程序提取并自动转换为在线 HTML 文档(请参阅第十三章以获取有关javadoc的更多信息)。
这里是一个包含适当文档注释的示例类:
/**
* This immutable class represents <i>complex numbers</i>.
*
* @author David Flanagan
* @version 1.0
*/
public class Complex {
/**
* Holds the real part of this complex number.
* @see #y
*/
protected double x;
/**
* Holds the imaginary part of this complex number.
* @see #x
*/
protected double y;
/**
* Creates a new Complex object that represents the complex number
* x+yi.
* @param x The real part of the complex number.
* @param y The imaginary part of the complex number.
*/
public Complex(double x, double y) {
this.x = x;
this.y = y;
}
/**
* Adds two Complex objects and produces a third object that
* represents their sum.
* @param c1 A Complex object
* @param c2 Another Complex object
* @return A new Complex object that represents the sum of
* <code>c1</code> and <code>c2</code>.
* @exception java.lang.NullPointerException
* If either argument is <code>null</code>.
*/
public static Complex add(Complex c1, Complex c2) {
return new Complex(c1.x + c2.x, c1.y + c2.y);
}
}
文档注释的结构
文档注释的正文应以对被记录的类型或成员的一句摘要开始。这句话可能会单独显示为摘要文档,因此应编写得能够独立存在。初始句子后面可以跟随任意数量的其他句子和段落,详细描述类、接口、方法或字段。
在描述性段落之后,文档注释可以包含任意数量的其他段落,每个段落以特殊的文档注释标签开头,例如@author、@param或@returns。这些标记段落为javadoc程序以标准方式显示提供了有关类、接口、方法或字段的具体信息。文档注释标签的完整集合将在下一节中列出。
文档注释中的描述材料可以包含简单的 HTML 标记,如用于强调的<i>;用于类、方法和字段名称的<code>;以及用于多行代码示例的<pre>。它还可以包含<p>标记以将描述分隔成单独的段落,以及<ul>、<li>和相关标记以显示项目符号列表和类似结构。但请记住,您编写的材料嵌入在更大、更复杂的 HTML 文档中。因此,文档注释不应包含可能干扰更大文档结构的主要结构 HTML 标记,如<h2>或<hr>。
避免在文档注释中使用 <a> 标签来包含超链接或交叉引用。相反,请使用特殊的 {@link} 文档注释标签,与其他文档注释标签不同,它可以出现在文档注释的任何位置。正如在下一节所述,{@link} 标签允许您指定到其他类、接口、方法和字段的超链接,而无需了解 javadoc 使用的 HTML 结构约定和文件名。
如果要在文档注释中包含图像,请将图像文件放置在源代码目录的 doc-files 子目录中。将图像命名为与类相同,并带有整数后缀。例如,名为 Circle 类文档注释中的第二个图像可以使用以下 HTML 标签包含:
<img src="doc-files/Circle-2.gif">
因为文档注释的行被嵌入在 Java 注释中,每行注释的开头空格和星号 (*) 在处理之前都会被去除。因此,您不需要担心星号出现在生成的文档中,也不需要担心注释的缩进会影响使用 <pre> 标签包含在注释中的代码示例的缩进。
文档注释标签
javadoc 程序识别一些特殊标签,每个标签以 @ 字符开头。这些文档注释标签允许您以标准化的方式将特定信息编码到您的注释中,并允许 javadoc 选择适合该信息的输出格式。例如,@param 标签允许您指定方法的单个参数的名称和含义。javadoc 可以提取此信息并使用 HTML <dl> 列表、HTML <table> 或其他适合的方式显示它。
下面是javadoc所识别的文档注释标签;一个文档注释应按照这里列出的顺序使用这些标签:
@author name
添加一个包含指定名称的“Author:”条目。这个标签应该用于每个类或接口的定义,但不能用于单独的方法和字段。如果一个类有多个作者,可以在相邻的行上使用多个 @author 标签。例如:
@author David Flanagan
@author Ben Evans
@author Jason Clark
按照时间顺序列出作者,首先是原始作者。如果作者未知,您可以使用“未署名”。除非指定了 -author 命令行参数,否则 javadoc 不会输出作者信息。
@version text
插入一个包含指定文本的“Version:”条目。例如:
@version 1.32, 08/26/04
这个标签应该包含在每个类和接口的文档注释中,但不能用于单独的方法和字段。这个标签通常与版本控制系统(如 git、Perforce 或 SVN)的自动版本编号功能一起使用。除非指定了 -version 命令行参数,否则javadoc不会在生成的文档中输出版本信息。
@param parameter-name description
将指定的参数及其描述添加到当前方法的“Parameters:”部分。方法或构造函数的文档注释必须包含方法期望的每个参数的一个@param标记。这些标记应按方法指定的参数顺序出现。此标记仅可用于方法和构造函数的注释。
鼓励您在可能的情况下使用短语和句子片段,以保持描述的简洁性。但是,如果一个参数需要详细的文档,描述可以换行并包含尽可能多的文本。为了在源代码形式中的可读性,考虑使用空格来对齐描述。例如:
@param o the object to insert
@param index the position to insert it at
@return description
插入一个包含指定描述的“Returns:”部分。除非方法返回void或是构造函数,否则该标记应出现在每个方法的文档注释中。描述可以尽可能长,但考虑使用句子片段以保持简短。例如:
@return <code>true</code> if the insertion is successful, or
<code>false</code> if the list already contains the object.
@exception full-classname description
添加一个包含指定异常名称和描述的“Throws:”条目。方法或构造函数的文档注释应该为其throws子句中出现的每个已检查异常包含一个@exception标记。例如:
@exception java.io.FileNotFoundException
If the specified file could not be found
当方法可能抛出用户可能希望捕获的未检查异常(即RuntimeException的子类)时,可以选择使用@exception标记。如果方法可能抛出多个异常,请在相邻的行上使用多个@exception标记,并按字母顺序列出异常。描述可以简短或长到足以描述异常的重要性。此标记仅可用于方法和构造函数的注释。@throws标记是@exception的同义词。
@throws full-classname description
此标记是@exception的同义词。
@see reference
添加一个包含指定引用的“See Also:”条目。此标记可以出现在任何类型的文档注释中。*reference*的语法在 “交叉引用在文档注释中” 中有解释。
@deprecated explanation
该标记指定以下类型或成员已被弃用,应避免使用。javadoc在文档中添加一个突出显示的“Deprecated”条目,并包含指定的*explanation*文本。此文本应指明类或成员被弃用的时间,如果可能的话,建议替换类或成员,并包含指向其的链接。例如:
@deprecated As of Version 3.0, this method is replaced
by {@link #setColor}.
@deprecated标记是javac忽略所有注释的一般规则的例外情况。当此标记出现时,编译器会在生成的类文件中记录此过时信息。这使得它能够为依赖于过时特性的其他类发出警告。
@since version
指定类型或成员添加到 API 的时间。此标签应跟随版本号或其他版本规范。例如:
@since JNUT 3.0
每个类型的文档注释都应包括一个 @since 标签,而在类型的初始发布之后添加的任何成员应在其文档注释中具有 @since 标签。
@serial 描述
从技术上讲,类序列化的方式是其公共 API 的一部分。如果你编写了一个希望被序列化的类,你应该使用 @serial 和相关标签来记录其序列化格式,这些标签列在下面。对于任何作为 Serializable 类序列化状态的一部分的字段,@serial 应该出现在其文档注释中。
对于使用默认序列化机制的类,这意味着所有非声明为 transient 的字段,包括声明为 private 的字段。描述 应为序列化对象中字段及其目的的简要描述。
你也可以在类和包级别使用 @serial 标签来指定是否为类或包生成“序列化形式页面”。语法是:
@serial include
@serial exclude
@serialField 名称 类型 描述
可序列化类可以通过在名为 serialPersistentFields 的字段中声明 ObjectStreamField 对象的数组来定义其序列化格式。对于这样的类,serialPersistentFields 的文档注释应包括数组每个元素的 @serialField 标签。每个标签指定了类序列化状态中特定字段的名称、类型和描述。
@serialData 描述
可序列化类可以定义 writeObject() 方法来写入除了默认序列化机制之外的数据。Externalizable 类定义 writeExternal() 方法来负责将对象的完整状态写入序列化流。应该在这些 writeObject() 和 writeExternal() 方法的文档注释中使用 @serialData 标签,而 描述 则应该描述方法使用的序列化格式。
内联文档注释标签
除了前面列出的标签外,javadoc 还支持几个内联标签,它们可以出现在文档注释中的 HTML 文本任何位置。由于这些标签直接出现在 HTML 文本的流中,它们需要使用大括号作为定界符,以将标记文本与 HTML 文本分隔开。支持的内联标签包括:
{@link 引用 }
{@link} 标签类似于 @see 标签,不同之处在于它会在行内插入链接,而不是将链接放在特定的“See Also:”部分中。 {@link} 标签可以出现在文档注释中的任何 HTML 文本位置。换句话说,它可以出现在类、接口、方法或字段的初始描述以及与 @param、@returns、@exception 和 @deprecated 标签相关联的描述中。 {@link} 标签的 reference 使用下面 “Cross-References in Doc Comments” 中描述的语法。例如:
@param regexp The regular expression to search for. This string
argument must follow the syntax rules described for
{@link java.util.regex.Pattern}.
{@linkplain reference }
{@linkplain} 标签与 {@link} 标签类似,不同之处在于链接的文本使用正常字体而不是 {@link} 标签使用的代码字体。当 reference 同时包含要链接到的 feature 和指定要在链接中显示的 label 时,这是最有用的。更多关于 reference 参数中 feature 和 label 部分的信息,请参见 “Cross-References in Doc Comments”。
{@inheritDoc}
当一个方法覆盖超类中的方法或实现接口中的方法时,可以省略文档注释,javadoc 会自动从被覆盖或实现的方法继承文档。可以使用 {@inheritDoc} 标签来继承单个标签的文本。此标签还允许继承和增强注释的描述性文本。要继承单个标签,请像这样使用它:
@param index {@inheritDoc}
@return {@inheritDoc}
{@docRoot}
此内联标签不带参数,将替换为生成文档的根目录的引用。它在引用外部文件(如图像或版权声明)的超链接中非常有用:
<img src="{@docroot}/images/logo.gif">
This is <a href="{@docRoot}/legal.xhtml">Copyrighted</a> material.
{@literal text }
此内联标签以文本的形式显示 text,逐字显示其中的 HTML 并忽略其中可能包含的 javadoc 标签。它不保留空格格式,但在 <pre> 标签内使用时非常有用。
{@code text }
此标签类似于 {@literal} 标签,但以代码字体显示 text 的文字。相当于:
<code>{@literal <replaceable>text</replaceable>}</code>
{@value}
{@value} 标签不带参数,用于文档注释中的 static final 字段中,将替换为该字段的常量值。
{@value reference }
此 {@value} 标签的变体包括对 static final 字段的 reference 引用,并替换为该字段的常量值。
文档注释中的交叉引用
@see 标签和内联标签 {@link}、{@linkplain} 和 {@value} 都编码了对某些其他文档源的交叉引用,通常是对某些其他类型或成员的文档注释的引用。
*reference可以采用三种不同的形式。如果以引号字符开头,则视为书籍名称或其他印刷资源的名称,并按原样显示。如果reference*以<字符开头,则视为任意 HTML 超链接,使用<a>标签,并将超链接插入输出文档中。@see标签的此形式可以插入到其他在线文档中,例如程序员指南或用户手册。
如果*reference*不是引号括起的字符串或超链接,则预期其具有以下形式:
*`feature`* [*`label`*]
在这种情况下,javadoc输出由*label指定的文本,并将其编码为指定feature的超链接。如果通常省略label,javadoc将使用指定feature*的名称。
*feature*可以引用包、类型或类型成员,采用以下形式之一:
pkgname
对命名包的引用。例如:
@see java.lang.reflect
pkgname.typename
对使用其完整包名指定的类、接口、枚举类型或注解类型的引用。例如:
@see java.util.List
typename
对未指定其包名的类型的引用。例如:
@see List
javadoc通过搜索当前包和导入类列表来解析此引用,以找到具有此名称的类。
typename # methodname
对指定类型中命名方法或构造函数的引用。例如:
@see java.io.InputStream#reset
@see InputStream#close
如果类型没有指定其包名,则按照*typename*描述的方式解析。如果方法重载或类定义了同名字段,则此语法存在歧义。
typename # methodname ( paramtypes )
对方法或构造函数的引用,其参数类型明确指定。在交叉引用重载方法时很有用。例如:
@see InputStream#read(byte[], int, int)
# methodname
对当前类或接口中的非重载方法或构造函数的引用,或者对当前类或接口的包含类、超类或超接口中的一个方法的引用。使用这种简洁形式来引用同一类中的其他方法。例如:
@see #setBackgroundColor
# methodname ( paramtypes )
对当前类或接口或其超类或包含类中方法或构造函数的引用。此形式适用于重载方法,因为它明确列出了方法参数的类型。例如:
@see #setPosition(int, int)
typename # fieldname
对指定类中命名字段的引用。例如:
@see java.io.BufferedInputStream#buf
如果类型没有指定其包名,则按照*typename*描述的方式解析。
# fieldname
对当前类型或其包含类、超类或超接口中的字段的引用。例如:
@see #x
包的文档注释
Java 源代码中类、接口、方法、构造函数和字段的文档注释出现在其定义之前。javadoc 也可以读取并显示包的摘要文档。由于包在目录中定义,而不是在单个源代码文件中,因此 javadoc 在包含该包类的源代码目录中查找名为 package.xhtml 的文件以获取包文档。
package.xhtml 文件应包含包的简单 HTML 文档。它还可以包含 @see、@link、@deprecated 和 @since 标签。因为 package.xhtml 不是 Java 源代码文件,所以其中的文档应为 HTML,并且不应为 Java 注释(即不应该包含在 /** 和 */ 字符之间)。最后,在 package.xhtml 中出现的任何 @see 和 @link 标签都必须使用完全限定的类名。
除了为每个包定义 package.xhtml 文件外,还可以通过在这些包的源树中定义 overview.xhtml 文件来为一组包提供高级文档。当对该源树运行 javadoc 时,它将使用 overview.xhtml 作为显示的最高级概述。
文档生成器
用于生成 HTML 文档的 javadoc 工具基于标准 API。自 Java 9 以来,此标准接口已通过模块 jdk.javadoc 提供,并且通常使用此 API 的工具称为 doclets(其中 javadoc 被称为标准文档生成器)。
Java 9 发布还包括标准文档生成器的主要升级。特别是自 Java 10 起,默认生成现代 HTML5。这允许其他改进,例如实现 WAI-ARIA 标准 以提高可访问性。该标准使视觉或其他障碍的人能够使用屏幕阅读器等工具访问 javadoc 输出。
注意
javadoc 还已经增强以理解新的平台模块,因此构成 API 的语义含义(以及应该记录的内容)现在与模块化 Java 定义对齐。
标准文档生成器现在在生成文档时还会自动索引代码,并在 JavaScript 中创建客户端索引。生成的网页具有搜索功能,允许开发人员轻松找到一些常见的程序组件,例如:
-
模块
-
包
-
类型和成员
-
方法参数类型
开发人员还可以使用 @index 内联 javadoc 标签添加搜索术语或短语。
便携程序的约定
Java 最早的口号之一是“一次编写,到处运行”。这强调了 Java 使编写便携程序变得容易,但仍然可能编写不会自动在任何 Java 平台上成功运行的 Java 程序。以下提示有助于避免可移植性问题:
本地方法
可移植的 Java 代码可以使用核心 Java API 中的任何方法,包括作为native方法实现的方法。但是,可移植的代码不得定义自己的 native 方法。由于其本质,native 方法必须适应每个新平台,因此直接违反了 Java“一次编写,到处运行”的承诺。
Runtime.exec()方法
在可移植代码中,几乎不允许调用Runtime.exec()方法来生成一个进程并在本地系统上执行外部命令。这是因为无法保证要执行的本地操作系统命令在所有平台上都存在或行为相同。
在可移植代码中,唯一允许使用Runtime.exec()的时候是用户可以指定要运行的命令,可以通过在运行时输入命令或在配置文件或首选项对话框中指定命令来实现。
如果程序员希望控制外部进程,则应通过 Java 9 引入的增强的ProcessHandle功能而不是使用Runtime.exec()和解析输出来实现。这并非完全可移植,但至少可以减少控制外部进程所需的特定于平台的逻辑量。
System.getenv()方法
使用System.getenv()在本质上是不可移植的。不同的操作系统具有不同的大小写约定(例如,Windows 不区分大小写,而 Unix 系统则不同)。此外,环境中常见的值在操作系统和组织之间有很大的差异。如果良好记录,使用System.getenv()来参数化应用程序所期望的特定值是可以接受的;这在容器化应用程序中经常这样做。但是,访问更广泛的环境可能会产生不兼容的行为。
未记录的类
可移植的 Java 代码必须仅使用作为 Java 平台文档的一部分记录的类和接口。大多数 Java 实现都附带有作为实现的一部分但不属于 Java 平台规范的额外未记录的公共类。
模块系统防止程序使用和依赖这些实现类,但即使在 Java 17 中增加了更多限制,仍然可以通过使用反射来绕过此保护(尽管允许反射的确切运行时开关在最近的版本中已经改变;请参阅第十二章了解更多详情)。
但是,这样做是不可移植的,因为实现类不能保证存在于所有 Java 实现或所有平台上,并且它们可能会在未来版本中更改或消失。即使你对可移植性不是很在意,使用未记录的类也可能会大大复杂化未来 JDK 版本的升级。
特别需要注意的是 sun.misc.Unsafe 类,它提供了许多“不安全”方法,开发者可以利用这些方法绕过 Java 平台的关键限制。无论何种情况下,开发者都不应直接使用 Unsafe 类。
实现特定的特性
可移植的代码不能依赖于特定于单个实现的特性。例如,在 Java 的早期年份,Microsoft 发布了一个包含许多额外方法的 Java 运行时系统版本,这些方法不属于规范定义的 Java 平台。任何依赖此类扩展的程序显然无法在其他平台上移植。
实现特定的 bug
正如可移植的代码不得依赖于特定于实现的特性,它也不得依赖于特定于实现的 bug。如果类或方法的行为与规范所述的不同,可移植程序不能依赖于这种行为,因为它可能在不同的平台上有所不同,并且将来的版本可能会修复该 bug,从而阻碍 JDK 的升级。
实现特定的行为
有时不同平台和不同实现会呈现不同的行为,所有这些行为都符合 Java 规范的要求。可移植的代码不能依赖于任何特定的行为。例如,Java 规范没有指示相同优先级的线程是否共享 CPU,或者一个长时间运行的线程是否可以饿死同一优先级的另一个线程。如果应用程序假定其中一种行为,可能无法在所有平台上正确运行。
定义系统类
可移植的 Java 代码永远不会尝试在系统或标准扩展包中定义类。这样做违反了这些包的保护边界,并暴露了包可见的实现细节,即使在模块系统不禁止的情况下,也是如此。
硬编码的文件名
可移植程序不包含硬编码的文件或目录名。这是因为不同的平台有着显著不同的文件系统组织和不同的目录分隔符字符。如果需要处理文件或目录,应该让用户指定文件名,或者至少指定基本目录,在运行时、配置文件或程序的命令行参数中进行规范。在将文件或目录名称连接到目录名称时,应使用 File() 构造函数、File.separator 常量或 Path.of() 方法。
行分隔符
不同系统使用不同字符或字符序列作为行分隔符。不要在程序中硬编码 \n、\r 或 \r\n 作为行分隔符。相反,使用 PrintStream 或 PrintWriter 的 println() 方法,该方法会自动以适合平台的行分隔符终止行,或使用 line.separator 系统属性的值。您还可以使用 java.util.Formatter 及其相关类的 printf() 和 format() 方法中的 “%n” 格式字符串。
摘要
在本章中,我们看到了关于命名 Java 代码部分的标准约定。虽然语言允许超出这些约定的许多内容,但遵循这些约定的代码将更容易让其他人阅读和理解。
良好的文档是创建可维护系统的核心。javadoc 工具允许我们在代码中编写大部分文档,保持文档与代码的关联性。各种文档标签可生成清晰一致的文档。
JVM 的吸引力之一是它在许多操作系统和硬件类型上的广泛安装基础。然而,如果您在几个方面不注意,可能会损害应用程序的可移植性,因此本章回顾了围绕其中最典型的障碍的指导方针以避免出错。
接下来,我们将看一下 Java 标准库中最常用的部分之一:集合。