第 1 章:语法基础
Java 语言中存在四种类型:
- 接口 interface
- 类 class
- 数组 array
- 基本类型 primitive type
前三种是引用类型,类实例和数组是对象,基本类型不是对象。
在 Java 中一共有 8 种基本数据类型,其中有 4 种整型,2 种浮点类型,1 种用于表示 Unicode 编码的字符单元的字符类型和 1 种用于表示真值的 boolean 类型。(一个字节等于 8 个 bit)
- 整型
类型 | 存储需求 | bit 数 | 取值范围 | 备注 |
---|---|---|---|---|
byte | 1 字节 | 1*8 | -128~127 | |
short | 2 字节 | 2*8 | -32768~32767 | |
int | 4 字节 | 4*8 | -231 ~ 231-1 | |
long | 8 字节 | 8*8 | -263 ~ 263-1 |
Integer.MAX_VALUE = 2147483647 Integer.MIN_VALUE = -2147483648
Long.MAX_VALUE = 9223372036854775807 Long.MIN_VALUE = -9223372036854775808
- 浮点型
类型 | 存储需求 | bit 数 | 取值范围 | 备注 |
---|---|---|---|---|
float | 4 字节 | 4*8 | 2-149 ~ (2-2-23)·2^127 | float 类型的数值有一个后缀 F(例如:3.14F) |
double | 8 字节 | 8*8 | 2-1074 ~ (2-2-52)·2^1023 | 没有后缀 F 的浮点数值(如 3.14) 默认为 double 类型, Double 有静态变量 MIN_VALUE 和 MAX_VALUE 。 |
3.char 类型
类型 | 存储需求 | bit 数 | 取值范围 | 备注 |
---|---|---|---|---|
char | 2 字节 | 2*8 | 0 ~ 65,535 |
4.boolean 类型
类型 | 存储需求 | bit 数 | 取值范围 | 备注 |
---|---|---|---|---|
boolean | 1 字节 | 1*8 | false、true |
补充:Java 有一个能够表示任意精度的算术包,通常称为“大数值”(big number)。虽然被称为大数值,但它并不是一种 Java 类型,而是一个 Java 对象。 如果基本的整数和浮点数精度不能够满足需求,那么可以使用 java.math
包中的两个很有用的类:BigInteger
和 BigDecimal
(Android SDK 中也包含了java.math
包以及这两个类)这两个类可以处理包含任意长度数字序列的数值。BigInteger 类实现了任意精度的整数运算,BigDecimal 实现了任意精度的浮点数运算。具体的用法可以参见 Java API。
BigInteger 和 BigDecimal 都是不可变 immutable ,类似于 String, 在使用
BigInteger sum = new BigInteger.valueOf(0);
sum.add(BigInteger.valueOf(10)); // wrong way, sum is still 0
sum = sum.add(BigInteger.valueOf(10)); // right way, add() return a BigInteger Object.
第 2 章:创建和销毁对象
1. 静态工厂方法代替构造器
优点:
- 静态工厂方法有名字;
- 不必要每次调用的时候都创建一个新的对象。
- 返回原返回类型的任何子类型对象。
- 返回对象的类可以随调用的不同而变化,作为输入参数的函数
- 当编写包含方法的类时,返回对象的类不需要存在
下面是一些静态工厂方法的常用名称。这个列表还远不够详尽:
- from,一种型转换方法,该方法接受单个参数并返回该类型的相应实例,例如:
Date d = Date.from(instant);
- of,一个聚合方法,它接受多个参数并返回一个包含这些参数的实例,例如:
Set<Rank> faceCards = EnumSet.of(JACK, QUEEN, KING);
- valueOf,一种替代 from 和 of 但更冗长的方法,例如:
BigInteger prime = BigInteger.valueOf(Integer.MAX_VALUE);
- instance 或 getInstance,返回一个实例,该实例由其参数(如果有的话)描述,但不具有相同的值,例如:
StackWalker luke = StackWalker.getInstance(options);
- create 或 newInstance,与 instance 或 getInstance 类似,只是该方法保证每个调用都返回一个新实例,例如:
Object newArray = Array.newInstance(classObject, arrayLen);
- getType,类似于 getInstance,但如果工厂方法位于不同的类中,则使用此方法。其类型是工厂方法返回的对象类型,例如:
FileStore fs = Files.getFileStore(path);
- newType,与 newInstance 类似,但是如果工厂方法在不同的类中使用。类型是工厂方法返回的对象类型,例如:
BufferedReader br = Files.newBufferedReader(path);
- type,一个用来替代 getType 和 newType 的比较简单的方式,例如:
List<Complaint> litany = Collections.list(legacyLitany);
2. 遇到多个构造器参数时要考虑使用构建器
Builder 模式的优势:
- 代码易读,模拟了具名的可选参数
- build 方法可检验约束条件
- builder 可以自动填充某些域,比如每次创建对象的时自动增加序列号
何时使用 Builder 模式:
- 类的构造器或者静态工厂方法中具有多个参数时,设计这种类构造方式时可以考虑 Builder 模式,尤其是当大多数参数都是可选的时候。
- 当类的参数初始化时有相互关联时使用 Builder 模式。
3. 用私有构造器或者枚举类型强化 Singleton 属性
共有静态成员 final 域
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { … }
public void leaveTheBuilding() {…}
}
静态方法所有调用,都会返回同一个对象引用,不会创建其他 Elvis 实例。
公有的成员是静态工厂方法:
public class Elvis {
private static final Elvis INSTANCE = new Elvis();
private Elvis() { … }
public static Elvis getInstance() { return INSTANCE; }
public void leaveTheBuilding() {…}
}
4. 通过私有构造器强化不可实例化的能力
希望一个类不能被实例化,则私有化构造方法
5. 依赖注入优于硬连接资源
许多类依赖于一个或多个底层资源。例如,拼写检查程序依赖于字典。常见做法是,将这种类实现为静态实用工具类:
// Inappropriate use of static utility - inflexible & untestable!
public class SpellChecker {
private static final Lexicon dictionary = ...;
private SpellChecker() {} // Noninstantiable
public static boolean isValid(String word) { ... }
public static List<String> suggestions(String typo) { ... }
}
类似地,我们也经常看到它们的单例实现:
// Inappropriate use of singleton - inflexible & untestable!
public class SpellChecker {
private final Lexicon dictionary = ...;
private SpellChecker(...) {}
public static INSTANCE = new SpellChecker(...);
public boolean isValid(String word) { ... }
public List<String> suggestions(String typo) { ... }
}
你可以尝试让 SpellChecker 支持多个字典:首先取消 dictionary 字段的 final 修饰,并在现有的拼写检查器中添加更改 dictionary 的方法。但是在并发环境中这种做法是笨拙的、容易出错的和不可行的。静态实用工具类和单例不适用于由底层资源参数化的类。
所需要的是支持类的多个实例的能力(在我们的示例中是 SpellChecker),每个实例都使用客户端需要的资源(在我们的示例中是 dictionary)。满足此要求的一个简单模式是在创建新实例时将资源传递给构造函数。 这是依赖注入的一种形式:字典是拼写检查器的依赖项,在创建它时被注入到拼写检查器中。
// Dependency injection provides flexibility and testability
public class SpellChecker {
private final Lexicon dictionary;
public SpellChecker(Lexicon dictionary) {
this.dictionary = Objects.requireNonNull(dictionary);
}
public boolean isValid(String word) { ... }
public List<String> suggestions(String typo) { ... }
}
虽然拼写检查器示例只有一个资源(字典),但是依赖注入可以处理任意数量的资源和任意依赖路径。它保持了不可变性,因此多个客户端可以共享依赖对象(假设客户端需要相同的底层资源)。依赖注入同样适用于构造函数、静态工厂和构建器。
这种模式的一个有用变体是将资源工厂传递给构造函数。工厂是一个对象,可以反复调用它来创建类型的实例。这样的工厂体现了工厂方法模式。Java 8 中引入的 Supplier<T>
非常适合表示工厂。在输入中接受 Supplier<T>
的方法通常应该使用有界通配符类型来约束工厂的类型参数,以允许客户端传入创建指定类型的任何子类型的工厂。例如,这里有一个生产瓷砖方法,每块瓷砖都使用客户提供的工厂来制作马赛克:
Mosaic create(Supplier<? extends Tile> tileFactory) { ... }
总之,不要使用单例或静态实用工具类来实现依赖于一个或多个底层资源的类,这些资源的行为会影响类的行为,也不要让类直接创建这些资源。相反,将创建它们的资源或工厂传递给构造函数(或静态工厂或构建器)。这种操作称为依赖注入,它将大大增强类的灵活性、可重用性和可测试性。
6. 避免创建不必要的对象
String s1 = "string";
String s2 = new String("string"); // don‘t do this
当心无意识的基本类型自动装箱。
7. 清除过期的对象引用
Java 内存泄露可能发生在:
-
类自我管理内存
比如在实现 Stack 栈时,弹出栈时,消除对象引用,结束变量的生命周期。
-
缓存
-
监听器或者其他回调。
8. 避免使用终结方法
提供显式的终止方法,要求类客户端在每个实例不再有用的时候调用这个方法。Java 中 FileInputStream,FileOutputStream,Timer 和 Connection 都具有终结方法。
9.使用 try-with-resources 优于 try-finally
使用 try-finally 语句关闭资源的正确代码(如前两个代码示例所示)也有一个细微的缺陷。try 块和 finally 块中的代码都能够抛出异常。例如,在 firstLineOfFile 方法中,由于底层物理设备发生故障,对 readLine 的调用可能会抛出异常,而关闭的调用也可能出于同样的原因而失败。在这种情况下,第二个异常将完全覆盖第一个异常。异常堆栈跟踪中没有第一个异常的记录,这可能会使实际系统中的调试变得非常复杂(而这可能是希望出现的第一个异常,以便诊断问题)。虽然可以通过编写代码来抑制第二个异常而支持第一个异常,但实际上没有人这样做,因为它太过冗长。
当 Java 7 引入 try-with-resources 语句 [JLS, 14.20.3]时,所有这些问题都一次性解决了。要使用这个结构,资源必须实现 AutoCloseable 接口,它由一个单独的 void-return close 方法组成。Java 库和第三方库中的许多类和接口现在都实现或扩展了 AutoCloseable。如果你编写的类存在必须关闭的资源,那么也应该实现 AutoCloseable。
// try-with-resources on multiple resources - short and sweet
static void copy(String src, String dst) throws IOException {
try (InputStream in = new FileInputStream(src);OutputStream out = new FileOutputStream(dst)) {
byte[] buf = new byte[BUFFER_SIZE];
int n;
while ((n = in.read(buf)) >= 0)
out.write(buf, 0, n);
}
}
教训很清楚:在使用必须关闭的资源时,总是优先使用 try-with-resources,而不是 try-finally。前者的代码更短、更清晰,生成的异常更有用。使用 try-with-resources 语句可以很容易地为必须关闭的资源编写正确的代码,而使用 try-finally 几乎是不可能的。
第 3 章 对象的通用方法
所有对象都通用的方法,非 final 方法(equals、hashCode、toString、clone 和 finalize)都有明确的通用约定,被设计成被覆盖。
10. 覆盖 equals 时请遵守通用约定
类的每个实例都只与它自身相等:
- 类的每个实例本质都是 唯一 的
- 不关心类是否提供“逻辑相等”的测试功能
- 超类已经覆盖 equals,从超类继承过来的行为对于子类也合适
- 类是私有的或者包级私有,可以确定 equals 方法永远不会被调用
什么时候覆盖 Object.equals ?
- 类具有特定“逻辑相等”概念,不等同于对象等同的概念。
- 并且超类没有覆盖 equals 实现。
equals 等价关系:自反,对称,传递,一致
里氏替换原则 Liskov substitution principle ,类型的任何重要属性也将适用于它的子类型。
11. 覆盖 equals() 时一定要覆盖 hashCode()
12. 始终覆盖 toString
将类的基本信息放入。IDE 中一般可以自动生成该方法。
13. 谨慎地覆盖 clone
浅拷贝指拷贝对象时仅仅拷贝对象本身(包括对象中的基本变量),而不拷贝对象包含的引用指向的对象。深拷贝不仅拷贝对象本身,而且拷贝对象包含的引用指向的所有对象。
14. 考虑实现 Comparable 接口
实现对象实例之间的比较。
第 4 章 类和接口
15. 类和成员的可访问性最小化
encapsulation 封装,模块化
原因:有效地解除组成系统各个模块之间的耦合关系,使得模块之间可以独立开发、测试、优化、使用、理解和修改。
- 尽可能使每个类或者成员不被外界访问
- 实例域决不能是拥有的
- 类具有公有的静态 final 数组域,或者返回这种域的访问方法,总是错误的,客户端能够轻易的修改数组中的内容
类成员的公开级别:
- 私有 private,只有声明该类的内部才能访问
- 包级私有 package-private 缺省访问级别,声明该成员的包内部任何类都能访问
- 受保护 protected,声明该类的子类可以访问,包内部其他类可以访问
- 公有 public,任何地方都可以访问,如果做成公有接口,则需要考虑并有责任永远支持该方法,以保持兼容性
公有的静态 final 域来暴露常量,惯例,使用 大写字母 加 下划线,这些域要么包含基本类型,要么包含指向不可变对象的引用。
16. 公有类中使用访问方法而非公有域
未来改变类的内部表示时非常有效,如果类是包级私有,或者私有嵌套类,直接暴露数据域并没有本质的错误。
17. 使可变性最小化
不可变类 实例不能被修改,实例包含的信息必须在创建时提供,并且在对象生命周期内固定不变。 String 基本类型包装类, BigInteger 和 BigDecimal 是不可变类。
原因:不可变类易于设计、实现和使用,不容易出错,更加安全
类不可变类要遵循一下规则:
- 不要提供任何会修改对象状态的方法
- 保证类不会被扩展, 将类定义成 final 类
- 使所有域都是 final
- 使所有域都是私有的
- 确保任何可变组件互斥访问
- 优点:1. 不可变对象本质上是线程安全的,他们不要求同步。2. 不可变对象可以被自由地共享,将频繁使用的值提供公有的静态 final 常量,将频繁被请求的实例缓存起来,当现有实例可以符合请求时,不用创建新的实例。基本类型的包装类和 BigInteger 都有这样的静态工厂。3. 不可变对象为其他对象提供了大量的构建。
缺点:不可变类唯一的缺点就是,对于每个不同的值都需要一个单独的对象。
18. 复合优先于继承
不扩展现有的类,而是在新的类中增加一个私有域引用一个类的实例,这种设计叫做“复合” Composition。
优点:与方法调用不用,继承打破了封装性,子类依赖于父类中特定功能的实现细节,因此复合被提出,不扩展现有的类,而是在新的类中增加一个私有域来组成新的类。
缺点:包装类没什么缺点,但需要注意,包装类不适合用在回调框架 callback framework。
19. 为继承而设计,提供文档说明,否则就禁止继承
类必须有文档说明它可覆盖的方法的自用性。在发布类之前,编写子类进行测试。
为了允许继承,类需要遵守的约束条件:
- 构造函数绝不能调用可被覆盖的方法
- clone 和 readObject 都不可以调用可覆盖的方法,不管是以直接还是间接的方式。
- 如果决定在一个为了继承而设计的类中实现 Serializable ,并且该类有 readResolve 或者 writeReplace 方法,则必须使 readResolve 或者 writeReplace 成为受保护的方法,而不是私有方法。
两种方法禁止子类化:
- 声明类 final class
- 所有构造函数变成私有,或者包级私有
20. 接口优于抽象类
Java 提供两种机制,来允许多实现,接口与抽象类。重要区别在于:
- 抽象类 允许包含某些方法的实现 ,接口则不允许
- 为了实现抽象类定义的类型,类必须成为抽象类的子类,Java 只允许单继承,抽象类受到一些限制
优点:
- 现有的类可以很容易被更新,实现新的接口
- 接口是定义 mixin (混合类型)的理想选择
- 接口允许我们构造非层次结构的类型框架
21. 接口只用于定义类型
常量接口 constant interface ,不包含任何方法,只包含静态 final 域,每个域都是一个常量。但是,不值得使用。
导出常量可选合理方案,将常量添加到 相关类 或者接口中,尽量使用枚举类型 enum type,否则应该使用不可实例化的工具类 utility class 来导出常量。
接口只应该被用来定义类型,不应该被用来导出常量。
22. 类层次优于标签类
标签类指在类中用标签区别类的行为模式,比如使用枚举变量,根据枚举不同显示不同内容。尽量不使用标签类,使用子类抽象,将标签类中每个方法都定义成一个包含抽象方法的抽象类。
23. 优先考虑静态成员类
嵌套类 nested class 指被定义在另一个类内部的类。
嵌套类有四种:
- 静态成员类 static member class
- 非静态成员类 nonstatic member class
- 匿名类 anonymous class
- 局部类 local class
除去第一种之外,其他三类都被称为内部类 inner class。
静态成员类,最简单的嵌套类,普通类,静态成员类可以访问外围类的所有成员,包括私有。访问性原则同类中其他成员。
静态成员类,常见用法作为公有辅助类,与外部类一起使用时才有意义。
非静态成员类,语法层面只有一个 static 修饰符的不同,但是两者区别很大。非静态成员类的每一个实例都隐含着与外围类的一个外围实例 enclosing instance。嵌套的实例需要外围类的实例。
非静态成员类的一种常见用法是定义 adapter,它允许外部类的实例被看做是另一个不相关的类的实例。
私有静态成员类 常见用法是代表外围类所代表的对象的组件。
匿名类没有名字,在使用的同时被声明和实例化。匿名类可以出现在代码中任何允许存在表达式的地方。
匿名类的一种常见用法是动态地创建函数对象 function object。 另一种常见用法是创建过程对象 process object ,比如 Runnable , Thread 或者 TimeTask 实例。第三种常见用法是在静态工厂方法的内部。
局部类是嵌套类中用得最少的类。任何可以声明局部变量的地方都可以声明局部类。
成员类的每个实例都需要一个指向外围实例的引用,就要把成员类做成非静态的,否则做成静态的。
嵌套类属于方法内部,只需要一个地方创建实例,匿名类,否则就是局部类
第 5 章 泛型
24. 消除非受检警告
非受检强制转化警告 unchecked cast warnings
非受检方法调用警告
非受检普通数组创建警告
非受检转换警告 unchecked conversation warnings
尽可能小的使用 SuppressWarnings(“unchecked”) 注解
25. 列表优先 于数组
数组是协变 covariant 的,泛型是不可变的 invariant。
数组在运行时才知道并检查元素类型。泛型在编译时就能检查类型。
26. 优先考虑泛型
27. 优先考虑泛型方法
编写泛型方法和编写泛型类相似
泛型方法的特性是,无需明确指定类型参数的值,编译器通过检查方法参数的类型来计算类型参数的值。类型推导。
28. 利用有限制通配符来提升 API 的灵活性
参数化类型是不可变的
Interface Iterable<T>
接口,实现这个接口可以让对象使用 foreach 循环,或者使用
// If you have Iterable<String> , you can do:
for (String str : myIterable) {
...
}
大部分的容器类都实现了这个接口。
http://docs.oracle.com/javase/6/docs/api/java/lang/Iterable.html
Java 提供了特殊的参数化类型,有限制的通配符类型 bounded wildcard type
生产者使用 extends,消费者使用 super(PECS)。还要记住,所有的 comparable 和 comparator 都是消费者。
29. 优先考虑类型安全的异构容器
以集合的 API 为例的泛型在正常使用时将每个容器的类型参数限制为固定数量。你可以通过将类型参数放置在键
上而不是容器上来绕过这个限制。你可以使用 Class 对象作为此类类型安全异构容器的键
。以这种方式使用的 Class 对象称为类型标记。还可以使用自定义键
类型。例如,可以使用 DatabaseRow 类型表示数据库行(容器),并使用泛型类型 Column<T>
作为它的键
// Typesafe heterogeneous container pattern - implementation
public class Favorites {
private Map<Class<?>, Object> favorites = new HashMap<>();
public <T> void putFavorite(Class<T> type, T instance) {
favorites.put(Objects.requireNonNull(type), instance);
}
public <T> T getFavorite(Class<T> type) {
return type.cast(favorites.get(type));
}
}
第6章 枚举和注解
JAVA 支持两种特殊用途的引用类型:一种称为枚举类型的类,以及一种称为注解类型的接口。
30. 用枚举类型 enum 代替 int 常量
枚举类型是指由一组固定常量组成合法值的类型。
相比枚举类型, int枚举模式的缺点:
- 采用 int 枚举模式,程序非常脆弱
- 将 int 枚举打印成字符串没有便利方法
- 使用 String 常量无效
富枚举类型例子:
// Enum type with data and behavior
public enum Planet {
MERCURY(3.302e+23, 2.439e6),
VENUS (4.869e+24, 6.052e6),
EARTH (5.975e+24, 6.378e6),
MARS (6.419e+23, 3.393e6),
JUPITER(1.899e+27, 7.149e7),
SATURN (5.685e+26, 6.027e7),
URANUS (8.683e+25, 2.556e7),
NEPTUNE(1.024e+26, 2.477e7);
private final double mass; // In kilograms
private final double radius; // In meters
private final double surfaceGravity; // In m / s^2
// Universal gravitational constant in m^3 / kg s^2
private static final double G = 6.67300E-11;
// Constructor
Planet(double mass, double radius) {
this.mass = mass;
this.radius = radius;
surfaceGravity = G * mass / (radius * radius);
}
public double mass() { return mass; }
public double radius() { return radius; }
public double surfaceGravity() { return surfaceGravity; }
public double surfaceWeight(double mass) {
return mass * surfaceGravity; // F = ma
}
}
要将数据与枚举常量关联,可声明实例字段并编写一个构造函数,该构造函数接受数据并将其存储在字段中。 枚举本质上是不可变的,因此所有字段都应该是 final。字段可以是公共的,但是最好将它们设置为私有并提供公共访问器
public class WeightTable {
public static void main(String[] args) {
double earthWeight = Double.parseDouble(args[0]);
double mass = earthWeight / Planet.EARTH.surfaceGravity();
for (Planet p : Planet.values())
System.out.printf("Weight on %s is %f%n",p, p.surfaceWeight(mass));
}
}
31. 用实例域代替序数
所有枚举都有 ordinal 方法,返回枚举常量在类型中的位置,不要使用该方法。永远不要根据枚举的序数导出与它关联的值,而是应该将它保存到实例域中。
32. 用 EnumSet 代替位域
33. 用 EnumMap 代替序数索引
34. 用接口模拟可伸缩的枚举
35. 注解优先于命名模式
不必定义注解类型,但是都应该使用 java 平台所提供的预定义注解类型。
36. 坚持使用 Override 注解
37. 用标记接口定义类型
标记接口 marker interface 没有包含任何方法声明的接口,指明一个类实现的某种属性的接口。
第 7 章 lambda表达式和流
在 Java 8 中,为了更容易地创建函数对象,添加了函数式接口、lambda 表达式和方法引用;流 API 也与这些语言特性一并添加进来,为处理数据元素序列提供库支持。
38. λ 表达式优于匿名类
匿名类对于需要函数对象的典型面向对象设计模式来说已经足够了,尤其是策略模式。然而,匿名类的冗长使函数式编程在 Java 中变得毫无吸引力。下面是一个按长度对字符串列表进行排序的代码片段,使用一个匿名类来创建排序的比较函数(它强制执行排序顺序):
// Anonymous class instance as a function object - obsolete!
Collections.sort(words, new Comparator<String>() {
public int compare(String s1, String s2) {
return Integer.compare(s1.length(), s2.length());
}
});
在 Java 8 中官方化了一个概念,即具有单个抽象方法的接口是特殊的,应该得到特殊处理。这些接口现在被称为函数式接口,允许使用 lambda 表达式创建这些接口的实例。Lambda 表达式在功能上类似于匿名类,但是更加简洁。
// Lambda expression as function object (replaces anonymous class)
Collections.sort(words,(s1, s2) -> Integer.compare(s1.length(), s2.length()));
关于类型推断,有些警告应该被提及。要使用原始类型,要优先使用泛型,要优先使用泛型方法。在使用 lambda 表达式时,这些建议尤其重要,因为编译器获得了允许它从泛型中执行类型推断的大部分类型信息。如果不提供此信息,编译器将无法进行类型推断,并且必须在 lambda 表达式中手动指定类型,这将大大增加它们的冗长。举例来说,如果变量声明为原始类型 List 而不是参数化类型 List<String>
,那么上面的代码片段将无法编译。
但是有一些匿名类可以做的事情是 lambda 表达式不能做的。Lambda 表达式仅限于函数式接口。如果想创建抽象类的实例,可以使用匿名类,但不能使用 lambda 表达式。类似地,你可以使用匿名类来创建具有多个抽象方法的接口实例。最后,lambda 表达式无法获得对自身的引用。在 lambda 表达式中,this 关键字指的是封闭实例,这通常是你想要的。在匿名类中,this 关键字引用匿名类实例。如果你需要从函数对象的内部访问它,那么你必须使用一个匿名类。
39. 方法引用优于 λ 表达式
下面是一个程序的代码片段,该程序维护从任意键到 Integer 类型值的映射。如果该值被解释为键实例数的计数,那么该程序就是一个多集实现。该代码段的功能是,如果数字 1 不在映射中,则将其与键关联,如果键已经存在,则将关联值递增:
map.merge(key, 1, (count, incr) -> count + incr);
注意,这段代码使用了 merge 方法,它是在 Java 8 中添加到 Map 接口的。如果给定键没有映射,则该方法只插入给定的值;如果已经存在映射,则 merge 将给定的函数应用于当前值和给定值,并用结果覆盖当前值。这段代码代表了 merge 方法的一个典型用例。们可以简单地传递一个引用到这个方法,并得到相同的结果,同时减少视觉混乱:
map.merge(key, 1, Integer::sum);
总之,方法引用通常为 lambda 表达式提供了一种更简洁的选择。如果方法引用更短、更清晰,则使用它们;如果没有,仍然使用 lambda 表达式。
40. 优先使用标准函数式接口
现在 Java 已经有了 lambda 表达式,编写 API 的最佳实践已经发生了很大的变化。例如,模板方法模式 [Gamma95],其中子类覆盖基类方法以专门化其超类的行为,就没有那么有吸引力了。现代的替代方法是提供一个静态工厂或构造函数,它接受一个函数对象来实现相同的效果。更一般地,你将编写更多以函数对象为参数的构造函数和方法。选择正确的函数参数类型需要谨慎。如果一个标准的函数式接口可以完成这项工作,那么你通常应该优先使用它,而不是使用专门构建的函数式接口。
Operator 接口表示结果和参数类型相同的函数。Predicate 接口表示接受参数并返回布尔值的函数。Function 接口表示参数和返回类型不同的函数。Supplier 接口表示一个不接受参数并返回(或「供应」)值的函数。最后,Consumer 表示一个函数,该函数接受一个参数,但不返回任何内容,本质上是使用它的参数。六个基本的函数式接口总结如下:
Interface | Function Signature | Example |
---|---|---|
UnaryOperator<T> | T apply(T t) | String::toLowerCase |
BinaryOperator<T> | T apply(T t1, T t2) | BigInteger::add |
Predicate<T> | boolean test(T t) | Collection::isEmpty |
Function<T,R> | R apply(T t) | Arrays::asList |
Supplier<T> | T get() | Instant::now |
Consumer<T> | void accept(T t) | System.out::println |
大多数标准函数式接口的存在只是为了提供对基本类型的支持。不要尝试使用带有包装类的基本函数式接口,而不是使用基本类型函数式接口。
使用 @FunctionalInterface
注释进行标记。这种注释类型在本质上类似于 @Override
。它是程序员意图的声明,有三个目的:它告诉类及其文档的读者,接口的设计是为了启用 lambda 表达式;它使你保持诚实,因为接口不会编译,除非它只有一个抽象方法;它还可以防止维护者在接口发展过程中意外地向接口添加抽象方法。总是用 @FunctionalInterface
注释你的函数接口。
41. 明智地使用流
在 Java 8 中添加了流 API,以简化序列或并行执行批量操作的任务。这个 API 提供了两个关键的抽象:流(表示有限或无限的数据元素序列)和流管道(表示对这些元素的多阶段计算)。流中的元素可以来自任何地方。常见的源包括集合、数组、文件、正则表达式的 Pattern 匹配器、伪随机数生成器和其他流。流中的数据元素可以是对象的引用或基本数据类型。支持三种基本数据类型:int、long 和 double。
流管道由源流、零个或多个 Intermediate 操作和一个 Terminal 操作组成。每个 Intermediate 操作以某种方式转换流,例如将每个元素映射到该元素的一个函数,或者过滤掉不满足某些条件的所有元素。中间操作都将一个流转换为另一个流,其元素类型可能与输入流相同,也可能与输入流不同。Terminal 操作对最后一次 Intermediate 操作所产生的流进行最终计算,例如将其元素存储到集合中、返回特定元素、或打印其所有元素。
流范式中最重要的部分是将计算构造为一系列转换,其中每个阶段的结果都尽可能地接近上一阶段结果的纯函数。纯函数的结果只依赖于它的输入:它不依赖于任何可变状态,也不更新任何状态。为了实现这一点,传递到流操作(包括 Intermediate 操作和 Terminal 操作)中的任何函数对象都应该没有副作用。
流管道的计算是惰性的:直到调用 Terminal 操作时才开始计算,并且对完成 Terminal 操作不需要的数据元素永远不会计算。这种惰性的求值机制使得处理无限流成为可能。请注意,没有 Terminal 操作的流管道是无动作的,因此不要忘记包含一个 Terminal 操作。
总之,流管道编程的本质是无副作用的函数对象。这适用于传递给流和相关对象的所有函数对象。Terminal 操作 forEach 只应用于报告由流执行的计算结果,而不应用于执行计算。为了正确使用流,你必须了解 collector。最重要的 collector 工厂是 toList、toSet、toMap、groupingBy 和 join。
42. 优先选择 Collection 而不是流作为返回类型
在编写返回元素序列的方法时,请记住,有些用户可能希望将它们作为流处理,而有些用户可能希望对它们进行迭代。试着适应这两个群体。如果可以返回集合,那么就这样做。如果你已经在一个集合中拥有了元素,或者序列中的元素数量足够小,可以创建一个新的元素,那么返回一个标准集合,例如 ArrayList 。否则,请考虑像对 power 集那样实现自定义集合。如果返回集合不可行,则返回流或 iterable,以看起来更自然的方式返回。如果在未来的 Java 版本中,流接口声明被修改为可迭代的,那么你应该可以随意返回流,因为它们将允许流处理和迭代。
43. 谨慎使用并行流
// Stream-based program to generate the first 20 Mersenne primes
public static void main(String[] args) {
primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20)
.forEach(System.out::println);
}
static Stream<BigInteger> primes() {
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
如果通过向流管道添加对 parallel()
的调用来加速它,该项目会无限期停留在那里
这是怎么回事?简单地说,stream 库不知道如何并行化这个管道,因此启发式会失败。即使在最好的情况下,如果源来自 Stream.iterate
或使用 Intermediate 操作限制,并行化管道也不太可能提高其性能。 这条管道必须解决这两个问题。更糟糕的是,默认的并行化策略通过假设处理一些额外的元素和丢弃任何不需要的结果没有害处来处理极限的不可预测性。在这种情况下,找到每一个 Mersenne 素数所需的时间大约是找到上一个 Mersenne 素数所需时间的两倍。因此,计算单个额外元素的成本大致等于计算之前所有元素的总和,而这条看上去毫无问题的管道将自动并行化算法推到了极致。不要不加区别地将流管道并行化。 性能后果可能是灾难性的。
通常,并行性带来的性能提升在 ArrayList、HashMap、HashSet 和 ConcurrentHashMap 实例上的流效果最好;int 数组和 long 数组也在其中。 这些数据结构的共同之处在于,它们都可以被精确且廉价地分割成任意大小的子程序,这使得在并行线程之间划分工作变得很容易。stream 库用于执行此任务的抽象是 spliterator,它由流上的 spliterator 方法返回并可迭代。
所有这些数据结构的另一个重要共同点是,当按顺序处理时,它们提供了非常好的引用位置:顺序元素引用一起存储在内存中。这些引用引用的对象在内存中可能彼此不太接近,这降低了引用的位置。引用位置对于并行化批量操作非常重要:如果没有它,线程将花费大量时间空闲,等待数据从内存传输到处理器的缓存中。具有最佳引用位置的数据结构是基本数组,因为数据本身是连续存储在内存中的。
并行化流不仅会导致糟糕的性能,包括活动失败;它会导致不正确的结果和不可预知的行为(安全故障)。 如果管道使用映射器、过滤器和其他程序员提供的函数对象,而这些对象没有遵守其规范,则并行化管道可能导致安全故障。流规范对这些功能对象提出了严格的要求。
在适当的情况下,可以通过向流管道添加并行调用来实现处理器内核数量的近乎线性的加速。某些领域,如机器学习和数据处理,特别适合于这些加速。
作为一个简单的例子,一个流管道并行性是有效的,考虑这个函数计算 π(n)
,质数数目小于或等于 n:
// Prime-counting stream pipeline - benefits from parallelization
static long pi(long n) {
return LongStream.rangeClosed(2, n)
.mapToObj(BigInteger::valueOf)
.filter(i -> i.isProbablePrime(50))
.count();
}
在我的机器上,需要 31 秒计算 π(108)
使用这个函数。简单地添加 parallel()
调用将时间缩短到 9.2 秒:
// Prime-counting stream pipeline - parallel version
static long pi(long n) {
return LongStream.rangeClosed(2, n)
.parallel()
.mapToObj(BigInteger::valueOf)
.filter(i -> i.isProbablePrime(50))
.count();
}
总之,甚至不要尝试并行化流管道,除非你有充分的理由相信它将保持计算的正确性以及提高速度。不适当地并行化流的代价可能是程序失败或性能灾难。如果你认为并行性是合理的,那么请确保你的代码在并行运行时保持正确,并在实际情况下进行仔细的性能度量。如果你的代码保持正确,并且这些实验证实了你对提高性能的怀疑,那么,并且只有这样,才能在生产代码中并行化流。
第8章 方法
44. 检查参数的有效性
大多数方法和构造函数都对传递给它们的参数值有一些限制。例如,索引值必须是非负的,对象引用必须是非空的,这种情况并不少见。你应该清楚地在文档中记录所有这些限制,并在方法主体的开头使用检查来实施它们。你应该在错误发生后尽快找到它们,这是一般原则。如果不这样做,就不太可能检测到错误,而且即使检测到错误,确定错误的来源也很难。
在 Java 7 中添加的 Objects.requireNonNull
方法非常灵活和方便,因此不再需要手动执行空检查。 如果愿意,可以指定自己的异常详细信息。该方法返回它的输入,所以你可以执行一个空检查,同时你使用一个值:
// Inline use of Java's null-checking facility
this.strategy = Objects.requireNonNull(strategy, "strategy");
45. 必要时进行保护性拷贝
在传入可变参数时需要特别注意,外部可能会通过改变传入参数而间接的影响到类内部的实现。因此需要在初始化的时候进行保护性拷贝。(如Date使用final修饰,虽然其引用不可变,但其内部却可以发I生变化, 在java9后通常用Instant替代Date)
考虑下面的类,它表示一个不可变的时间段:
// Broken "immutable" time period class
public final class Period {
private final Date start;
private final Date end;
/**
* @param start the beginning of the period
* @param end the end of the period; must not precede start
* @throws IllegalArgumentException if start is after end
* @throws NullPointerException if start or end is null
*/
public Period(Date start, Date end) {
if (start.compareTo(end) > 0)
throw new IllegalArgumentException(start + " after " + end);
this.start = start;
this.end = end;
}
public Date start() {
return start;
}
public Date end() {
return end;
}
... // Remainder omitted
}
-
改造构造函数
// Repaired constructor - makes defensive copies of parameters public Period(Date start, Date end) { this.start = new Date(start.getTime()); this.end = new Date(end.getTime()); if (this.start.compareTo(this.end) > 0) throw new IllegalArgumentException(this.start + " after " + this.end); }
虽然替换构造函数成功地防御了之前的攻击,但是仍然可以修改 Period 实例,因为它的访问器提供了对其可变内部结构的访问:
// Second attack on the internals of a Period instance Date start = new Date(); Date end = new Date(); Period p = new Period(start, end); p.end().setYear(78); // Modifies internals of p!
要防御第二次攻击,只需修改访问器,返回可变内部字段的防御副本:
// Repaired accessors - make defensive copies of internal fields public Date start() { return new Date(start.getTime()); } public Date end() { return new Date(end.getTime()); }
记住,非零长度数组总是可变的。因此,在将内部数组返回给客户端之前,应该始终创建一个防御性的副本。或者,你可以返回数组的不可变视图。
46. 谨慎设计方法签名
- 谨慎地选择方法名称,遵循命名习惯
- 不过于追求提供便利的方法
- 避免过长的参数列表
有三种方法可以缩短长参数列表
- 将方法拆解成多个方法
- 创建辅助类 helper class , 保存参数分组,一般作为静态成员类
- 从对象构建到方法调用都采用 Builder 模式,多次 setter
对于参数类型,要优先使用接口而非类, Map 接口作为参数,可以传入 Hashtable, HashMap,TreeMap,TreeMap 子映射表 submap 等等
对于 boolean 参数,优先使用两个元素的枚举类型 , 除非布尔值的含义在方法名中明确。枚举使代码更容易读和写。此外,它们使以后添加更多选项变得更加容易。例如,你可能有一个 Thermometer 类型与静态工厂,采用枚举:
public enum TemperatureScale { FAHRENHEIT, CELSIUS }
Thermometer.newInstance(TemperatureScale.CELSIUS)
不仅比 Thermometer.newInstance(true)
更有意义,而且你可以在将来的版本中向 TemperatureScale 添加 KELVIN,而不必向 Thermometer 添加新的静态工厂。
47. 慎用重载
永远不要导出两个具有相同参数数目的重载方法。
48. 慎用可变参数
Java 1.5 版本中增加了 可变参数 varargs 方法,一般称为 variable arity method 。可匹配不同长度的变量的方法。
可变参数方法接受 0 个或者多个指定类型的参数,先创建一个数组,数组大小为在调用位置所传递的参数数量,然后将参数值传到数组中,最后将数组传递给方法。
在性能关键的情况下使用可变参数时要小心。每次调用可变参数方法都会导致数组分配和初始化。如果你已经从经验上确定你负担不起这个成本,但是你仍需要可变参数的灵活性,那么有一种模式可以让你鱼与熊掌兼得。假设你已经确定对方法 95% 的调用只需要三个或更少的参数。可以声明该方法的 5 个重载,每个重载 0 到 3 个普通参数,当参数数量超过 3 个时引入可变参数:
49. 返回零长度的数组或者集合,而不是 null
50. 明智地的返回 Optional
在 Java 8 之前,在编写在某些情况下无法返回值的方法时,可以采用两种方法。要么抛出异常,要么返回 null(假设返回类型是对象引用类型)。这两种方法都不完美。应该为异常条件保留异常,并且抛出异常代价高昂,因为在创建异常时捕获整个堆栈跟踪。如果方法返回 null,客户端必须包含特殊情况代码来处理 null 返回的可能性,除非程序员能够证明 null 返回是不可能的。如果客户端忽略检查 null 返回并将 null 返回值存储在某个数据结构中,那么 NullPointerException 可能会在将来的某个时间,在代码中的某个与该问题无关的位置产生。
在 Java 8 中,还有第三种方法来编写可能无法返回值的方法。Optional<T>
类表示一个不可变的容器,它可以包含一个非空的 T 引用,也可以什么都不包含。不包含任何内容的 Optional 被称为空。一个值被认为存在于一个非空的 Optional 中。Optional 的本质上是一个不可变的集合,它最多可以容纳一个元素。Optional<T>
不实现 Collection<T>
,但原则上可以。
理论上应返回 T,但在某些情况下可能无法返回 T 的方法可以将返回值声明为 Optional<T>
。这允许该方法返回一个空结果来表明它不能返回有效的结果。具备 Optional 返回值的方法比抛出异常的方法更灵活、更容易使用,并且比返回 null 的方法更不容易出错。
// Returns maximum value in collection as an Optional<E>
public static <E extends Comparable<E>> Optional<E> max(Collection<E> c) {
if (c.isEmpty())
return Optional.empty();
E result = null;
for (E e : c)
if (result == null || e.compareTo(result) > 0)
result = Objects.requireNonNull(e);
return Optional.of(result);
}
返回一个 Optional要使用适当的静态工厂创建。如:Optional.empty()
返回一个空的 Optional,Optional.of(value)
返回一个包含给定非空值的可选值。将 null 传递给 Optional.of(value)
是一个编程错误。如果你这样做,该方法将通过抛出 NullPointerException。Optional.ofNullable(value)
方法接受一个可能为空的值,如果传入 null,则返回一个空的 Optional。永远不要从具备 Optional 返回值的方法返回空值: 它违背了这个功能的设计初衷。
如果一个方法返回一个 Optional,客户端可以选择如果该方法不能返回值该采取什么操作。你可以指定一个默认值:
// Using an optional to provide a chosen default value
String lastWordInLexicon = max(words).orElse("No words...");
如果你能证明一个 Optional 非空,你可以从 Optional 获取值,而不需要指定一个操作来执行,如果 Optional 是空的,但是如果你错了,你的代码会抛出一个 NoSuchElementException:
// Using optional when you know there’s a return value
Element lastNobleGas = max(Elements.NOBLE_GASES).get();
并不是所有的返回类型都能从 Optional 处理中获益。容器类型,包括集合、Map、流、数组和 Optional,不应该封装在 Optional 中。 你应该简单的返回一个空的 List<T>
,而不是一个空的 Optional<List<T>>
总之,如果你发现自己编写的方法不能总是返回确定值,并且你认为该方法的用户在每次调用时应该考虑这种可能性,那么你可能应该让方法返回一个 Optional。但是,你应该意识到,返回 Optional 会带来实际的性能后果;对于性能关键的方法,最好返回 null 或抛出异常。最后,除了作为返回值之外,你几乎不应该以任何其他方式使用 Optional。
第9章 通用程序设计
51. 将局部变量的作用域最小化
在每个局部变量第一次使用的地方声明。
几乎每一个局部变量的声明都应该包含一个初始化表达式。
52. for-each 循环优于传统 for 循环
有三种情况无法使用 for-each 循环
- 破坏性过滤,如果需要遍历一个集合并删除选定元素,则需要使用显式的迭代器,以便调用其 remove 方法。通过使用 Collection 在 Java 8 中添加的 removeIf 方法,通常可以避免显式遍历
- 转换,如果需要遍历一个 List 或数组并替换其中部分或全部元素的值,那么需要 List 迭代器或数组索引来替换元素的值。
- 并行迭代,如果需要并行遍历多个集合,那么需要显式地控制迭代器或索引变量,以便所有迭代器或索引变量都可以同步执行(如上述牌和骰子示例中无意中演示的错误那样)。如果发现自己处于这些情况中的任何一种,请使用普通的 for 循环,并警惕本条目中提到的陷阱。
53. 了解使用类库
使用标准类库,可以充分利用这些编写标准类库的专家知识,以及在你之前其他人的使用经验。
将时间花在应用程序上,而不是底层细节。
都应该熟悉
- java.lang
- java.util
- java.io 中的内容。
- java.util.concurrent 并发工具
54. 精确计算避免使用 float 和 double
使用 BigDecimal 做精确计算。
使用 int 或者 long 数值范围没有超过 9 位十进制数,可以使用 int,没有超过 18 位,可用 long ,超过 18 位,就必须使用 BigDecimal.
55. 基本类型优先于装箱基本类型
基本类型 primitive int double boolean
对应的引用类型 reference type ,称为装箱基本类型 boxed primitive ,对应为 Integer、Double、Boolean
何时使用装箱基本类型:
- 作为集合中的元素、键、值
56. 如果其他类型更适合,则尽量避免使用字符串
- 字符串不适合代替其他的值类型
- 字符串不适合代替枚举类型
- 字符串不适合代替聚集类型,如果需要用 String 来描述实体,通常不建议这样做,通常应该用一个私有静态成员类来描述。
- 字符串不适合代替能力表 capabilities
如果可以使用更加合适的数据类型,或者可以编写更加合适的数据类型,就应该避免使用字符串来表示对象。
57. 小心字符串连接的性能
字符串连接操作符 “+”
获得可接受的性能,使用 StringBuilder 代替 String append 方法
58. 通过接口引用对象
如果存在合适的接口类型,那么应该使用接口类型声明参数、返回值、变量和字段。 惟一真正需要引用对象的类的时候是使用构造函数创建它的时候。
使用接口作为类型,程序会更加灵活 , 如果没有合适的接口,就使用类层次结构中提供所需功能的最底层的类
59. 接口优先于反射机制
核心反射机制 core reflection facility java.lang.reflect 通过程序来访问关于已装载的类信息的能力。给定 Class 实例,可以获得 Constructor Method 和 Field 实例。
丧失了编译时类型检查的好处
执行反射访问所需要的代码笨拙冗长
性能损失
反射功能只是在设计时被用到,通常普通应用程序在运行时不应该以反射方式访问对象。
60. 谨慎使用本地方法
JNI 方法调用本地 Native method
使用本地方法有严重的缺点。由于本地语言不安全,使用本地方法的应用程序不再能免受内存毁坏错误的影响。由于本地语言比 Java 更依赖于平台,因此使用本地方法的程序的可移植性较差。它们也更难调试。如果不小心,本地方法可能会降低性能,因为垃圾收集器无法自动跟踪本地内存使用情况,而且进出本地代码会产生相关的成本。最后,本地方法需要「粘合代码」,这很难阅读,而且编写起来很乏味。
61. 谨慎地进行优化
- 规则 1:不要进行优化
- 规则 2:(仅针对专家)还是不要进行优化,再没有绝对清晰的未优化方案之前不要进行优化
不要试图去编写快速的程序 —- 应该努力编写好的程序,速度随之而来。再设计系统时,设计 API、线路层协议和永久数据格式时候,一定要考虑性能因素。
62. 遵守普遍接受的命名惯例
包名,层次,句号分割,小写字母和数字(少使用) 不以 java 和 javax 开头
包名其余部分通常不超过 8 个字符
第10章 异常
63. 只针对异常情况才使用异常
// Horrible abuse of exceptions. Don't ever do this!
try {
int i = 0;
while(true)
range[i++].climb();
}
catch (ArrayIndexOutOfBoundsException e) {
}
利用错误判断机制来提高性能是错误的。这种思路有三点误区:
- 因为异常是为特殊情况设计的,所以 JVM 实现几乎不会让它们像显式测试一样快。
- 将代码放在 try-catch 块中会抑制 JVM 可能执行的某些优化。
- 遍历数组的标准习惯用法不一定会导致冗余检查。许多 JVM 实现对它们进行了优化。
64. 对可恢复的情况使用受检异常,对编程错误使用运行异常
Java 提供三种可抛出结构:
- 受检的异常 checked exception
- 运行时异常 run-time exception
- 错误 error
期望调用者能够适当地恢复,使用受检异常。
用运行时异常来表明编程错误。
runtimeException 子类
1、 java.lang.ArrayIndexOutOfBoundsException
数组索引越界异常。当对数组的索引值为负数或大于等于数组大小时抛出。
2、java.lang.ArithmeticException
算术条件异常。譬如:整数除零等。
3、java.lang.NullPointerException
空指针异常。当应用试图在要求使用对象的地方使用了 null 时,抛出该异常。譬如:调用 null 对象的实例方法、访问 null 对象的属性、计算 null 对象的长度、使用 throw 语句抛出 null 等等
4、java.lang.ClassNotFoundException
找不到类异常。当应用试图根据字符串形式的类名构造类,而在遍历 CLASSPAH 之后找不到对应名称的 class 文件时,抛出该异常。
5、java.lang.NegativeArraySizeException 数组长度为负异常 6、java.lang.ArrayStoreException 数组中包含不兼容的值抛出的异常
7、java.lang.SecurityException 安全性异常
8、java.lang.IllegalArgumentException 非法参数异常
错误保留给 JVM 使用,以指示:资源不足、不可恢复故障或其他导致无法继续执行的条件。考虑到这种约定被大众认可,所以最好不要实现任何新的 Error 子类。因此,你实现的所有 unchecked 可抛出项都应该继承 RuntimeException(直接或间接)。不仅不应该定义 Error 子类,而且除了 AssertionError 之外,不应该抛出它们。
65. 避免不必要地使用受检的异常
如果 checked 异常是方法抛出的唯一 checked 异常,那么 checked 异常给程序员带来的额外负担就会大得多。如果还有其他方法,则该方法必须已经出现在 try 块中,并且此异常最多需要另一个 catch 块。如果一个方法抛出一个 checked 异常,那么这个异常就是该方法必须出现在 try 块中而不能直接在流中使用的唯一原因。在这种情况下,有必要问问自己是否有办法避免 checked 异常。
消除 checked 异常的最简单方法是返回所需结果类型的 Optional 对象。该方法只返回一个空的 Optional 对象,而不是抛出一个 checked 异常。这种技术的缺点是,该方法不能返回任何详细说明其无法执行所需计算的附加信息。相反,异常具有描述性类型,并且可以导出方法来提供附加信息
总之,如果谨慎使用,checked 异常可以提高程序的可靠性;当过度使用时,它们会使 API 难以使用。如果调用者不应从失败中恢复,则抛出 unchecked 异常。如果恢复是可能的,并且你希望强制调用者处理异常条件,那么首先考虑返回一个 Optional 对象。只有当在失败的情况下,提供的信息不充分时,你才应该抛出一个 checked 异常。
66. 优先使用标准异常
复用标准异常有几个好处。其中最主要的是,它使你的 API 更容易学习和使用,因为它符合程序员已经熟悉的既定约定。其次,使用你的 API 的程序更容易阅读,因为它们不会因为不熟悉的异常而混乱。最后(也是最不重要的),更少的异常类意味着更小的内存占用和更少的加载类的时间。
Exception | Occasion for Use |
---|---|
IllegalArgumentException | Non-null parameter value is inappropriate(非空参数值不合适) |
IllegalStateException | Object state is inappropriate for method invocation(对象状态不适用于方法调用) |
NullPointerException | Parameter value is null where prohibited(禁止参数为空时仍传入 null) |
IndexOutOfBoundsException | Index parameter value is out of range(索引参数值超出范围) |
ConcurrentModificationException | Concurrent modification of an object has been detected where it is prohibited(在禁止并发修改对象的地方检测到该动作) |
UnsupportedOperationException | Object does not support method(对象不支持该方法调用) |
67. 抛出与抽象相对应的异常
当一个方法抛出一个与它所执行的任务没有明显关联的异常时,这是令人不安的。这种情况经常发生在由方法传播自低层抽象抛出的异常。它不仅令人不安,而且让实现细节污染了上层的 API。如果高层实现在以后的版本中发生变化,那么它抛出的异常也会发生变化,可能会破坏现有的客户端程序。
为了避免这个问题,高层应该捕获低层异常,并确保抛出的异常可以用高层抽象解释。 这个习惯用法称为异常转换:
// Exception Translation
try {
... // Use lower-level abstraction to do our bidding
} catch (LowerLevelException e) {
throw new HigherLevelException(...);
}
如果低层异常可能有助于调试高层异常的问题,则需要一种称为链式异常的特殊异常转换形式。低层异常(作为原因)传递给高层异常,高层异常提供一个访问器方法(Throwable 的 getCause 方法)来检索低层异常:
// Exception Chaining
try {
... // Use lower-level abstraction to do our bidding
}
catch (LowerLevelException cause) {
throw new HigherLevelException(cause);
}
高层异常的构造函数将原因传递给能够接收链式异常的超类构造函数,因此它最终被传递给 Throwable 的一个接收链式异常的构造函数,比如 Throwable(Throwable)
:
// Exception with chaining-aware constructor
class HigherLevelException extends Exception {
HigherLevelException(Throwable cause) {
super(cause);
}
}
如果不可能从低层防止异常,那么下一个最好的方法就是让高层静默处理这些异常,使较高层方法的调用者免受低层问题的影响。在这种情况下,可以使用一些适当的日志工具(如 java.util.logging
)来记录异常。这允许程序员研究问题,同时将客户端代码和用户与之隔离。
68. 每个方法抛出的异常都要有文档
69. 在细节消息中包含能捕获失败的信息
70. 努力使失败保持原子性
在对象抛出异常之后,通常希望对象仍然处于定义良好的可用状态,即使在执行操作时发生了故障。对于 checked 异常尤其如此,调用者希望从异常中恢复。一般来说,失败的方法调用应该使对象处于调用之前的状态。 具有此属性的方法称为具备故障原子性。
-
设计不可变对象。如果对象是不可变的,则故障原子性是必然的。如果一个操作失败,它可能会阻止创建一个新对象,但是它不会让一个现有对象处于不一致的状态,因为每个对象的状态在创建时是一致的,并且在创建后不能修改。
对于操作可变对象的方法,实现故障原子性的最常见方法是在执行操作之前检查参数的有效性。这使得大多数异常在对象修改开始之前被抛出。例如,考虑
Stack.pop
方法:
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // Eliminate obsolete reference
return result;
}
如果取消了初始大小检查,当该方法试图从空堆栈中弹出元素时,仍然会抛出异常。但是,这会使 size 字段处于不一致的(负值)状态,导致以后该对象的任何方法调用都会失败。
- 对计算进行排序,以便可能发生故障的部分都先于修改对象的部分发生。当执行某部分计算才能检查参数时,这种方法是前一种方法的自然扩展。例如,考虑 TreeMap 的情况,它的元素按照一定的顺序排序。为了向 TreeMap 中添加元素,元素的类型必须能够使用 TreeMap 的顺序进行比较。在以任何方式修改「树」之前,由于在「树」中搜索元素,试图添加类型不正确的元素自然会失败,并导致 ClassCastException 异常。
- 以对象的临时副本执行操作,并在操作完成后用临时副本替换对象的内容。当数据存储在临时数据结构中后,计算过程会更加迅速,这种办法就是很自然的。例如,一些排序函数在排序之前将其输入 list 复制到数组中,以降低访问排序内循环中的元素的成本。这样做是为了提高性能,但是作为一个额外的好处,它确保如果排序失败,输入 list 将保持不变。
- 编写恢复代码,拦截在操作过程中发生的故障,并使对象回滚到操作开始之前的状态。这种方法主要用于持久的(基于磁盘的)数据结构。
71. 不要忽略异常
空 catch 块违背了异常的目的, 它的存在是为了强制你处理异常情况。
在某些情况下,忽略异常是合适的。例如,在关闭 FileInputStream 时,忽略异常可能是合适的。你没有更改文件的状态,因此不需要执行任何恢复操作,并且已经从文件中读取了所需的信息,因此没有理由中止正在进行的操作。记录异常可能是明智的,这样如果这些异常经常发生,你应该研究起因。如果你选择忽略异常,catch 块应该包含一条注释,解释为什么这样做是合适的,并且应该将变量命名为 ignore
第11章 并发
72. 同步访问共享的可变数据
synchronized 保证同一个时刻只有一个线程执行某一段代码块
什么时候使用锁:
- 当多个线程共享可变数据时,每个读或者写数据的线程都必须执行同步。
语言规范保证读取或写入变量是原子性的,除非变量的类型是 long 或 double 。换句话说,读取 long 或 double 之外的变量将保证返回某个线程存储在该变量中的值,即使多个线程同时修改该变量,并且没有同步时也是如此。
以下建议是错误的: 为了提高性能,在读取或写入具有原子性的数据时应该避免同步。虽然语言规范保证线程在读取字段时不会觉察任意值,但它不保证由一个线程编写的值对另一个线程可见(无法同步读取共享变量)。线程之间能可靠通信以及实施互斥,同步是所必需的。
考虑从一个线程中使另一个线程停止的任务。库提供了 Thread.stop
方法,但是这个方法很久以前就被弃用了,因为它本质上是不安全的,它的使用可能导致数据损坏。不要使用 Thread.stop
。 一个建议的方法是让第一个线程轮询一个 boolean 字段,该字段最初为 false,但第二个线程可以将其设置为 true,以指示第一个线程要停止它自己。由于读写布尔字段是原子性的,一些程序员在访问该字段时不需要同步:
// Broken! - How long would you expect this program to run?
public class StopThread {
private static boolean stopRequested;
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (!stopRequested)
i++;
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
stopRequested = true;
}
}
此代码会无限循环!
问题在于在缺乏同步的情况下,无法保证后台线程何时(如果有的话)看到主线程所做的 stopRequested 值的更改。在缺乏同步的情况下,虚拟机可以很好地转换这段代码:
while (!stopRequested)
i++;
into this code:
if (!stopRequested)
while (true)
i++;
这种优化称为提升,这正是 OpenJDK 服务器 VM 所做的。结果是活性失败:程序无法取得进展。解决此问题的一种方法是同步对 stopRequested 字段的访问。程序在大约一秒内结束,正如预期:
private static synchronized void requestStop() {
stopRequested = true;
}
private static synchronized boolean stopRequested() {
return stopRequested;
}
注意,写方法(requestStop)和读方法(stopRequested)都是同步的。仅同步写方法是不够的!除非读和写操作都同步,否则不能保证同步工作。 有时,只同步写(或读)的程序可能在某些机器上显示有效,但在这种情况下,不能这么做。
即使没有同步,StopThread 中同步方法的操作也是原子性的。换句话说,这些方法的同步仅用于其通信效果,而不是互斥。虽然在循环的每个迭代上同步的成本很小,但是有一种正确的替代方法,它不那么冗长,而且性能可能更好。如果 stopRequested 声明为 volatile,则可以省略 StopThread 的第二个版本中的锁。虽然 volatile 修饰符不执行互斥,但它保证任何读取字段的线程都会看到最近写入的值
private static volatile boolean stopRequested;
在使用 volatile 时一定要小心。考虑下面的方法,它应该生成序列号:
// Broken - requires synchronization!
private static volatile int nextSerialNumber = 0;
public static int generateSerialNumber() {
return nextSerialNumber++;
}
如果没有同步,该方法将无法正常工作。问题在于增量运算符 (++)
不是原子性的。它对 nextSerialNumber 字段执行两个操作:首先读取值,然后返回一个新值,旧值再加 1。如果第二个线程在读取旧值和写入新值之间读取字段,则第二个线程将看到与第一个线程相同的值,并返回相同的序列号
修复方法:
- 将 synchronized 修饰符添加到它的声明中
- 使用 AtomicLong 类,它是
java.util.concurrent.atomic
的一部分。这个包为单变量的无锁、线程安全编程提供了基本类型。虽然 volatile 只提供同步的通信效果,但是这个包提供原子性。
// Lock-free synchronization with java.util.concurrent.atomic
private static final AtomicLong nextSerialNum = new AtomicLong();
public static long generateSerialNumber() {
return nextSerialNum.getAndIncrement();
}
最佳方法是不共享可变数据。要么共享不可变数据,要么完全不共享。换句话说,应当将可变数据限制在一个线程中。 如果采用此策略,重要的是对其进行文档化,以便随着程序的发展维护该策略。
73. 避免过度同步
如果你正在编写一个可变的类,你有两个选择:你可以省略所有同步并允许客户端在需要并发使用时在外部进行同步,或者你可以在内部进行同步,从而使类是线程安全的。只有当你能够通过内部同步实现比通过让客户端在外部锁定整个对象获得高得多的并发性时,才应该选择后者。java.util
中的集合(废弃的 Vector 和 Hashtable 除外)采用前一种方法,而 java.util.concurrent
中的方法则采用后者。
74. Executor、task、流优于直接使用线程
java.util.concurrent
包有一个 Executor 框架,它是一个灵活的基于接口的任务执行工具。
你可以使用 executor 服务做更多的事情。例如,你可以等待一个特定任务完成(使用 get 方法),你可以等待任务集合中任何或全部任务完成(使用 invokeAny 或 invokeAll 方法),你可以等待 executor 服务终止(使用 awaitTermination 方法),你可以一个接一个检索任务,获取他们完成的结果(使用一个 ExecutorCompletionService),还可以安排任务在特定时间运行或定期运行(使用 ScheduledThreadPoolExecutor),等等。
如果希望多个线程处理来自队列的请求,只需调用一个不同的静态工厂,该工厂创建一种称为线程池的不同类型的 executor 服务。你可以使用固定或可变数量的线程创建线程池。java.util.concurrent.Executors
类包含静态工厂,它们提供你需要的大多数 executor。你可以直接使用 ThreadPoolExecutor 类。这个类允许你配置线程池操作的几乎每个方面。
75. 并发工具优先于 wait 和 notify
java.util.concurrent
中级别较高的实用工具可分为三类:Executor 框架;并发集合;同步器
并发集合是标准集合接口,如 List、Queue 和 Map 的高性能并发实现。为了提供高并发性,这些实现在内部管理它们自己的同步。因此,不可能从并发集合中排除并发活动;锁定它只会使程序变慢。
除了提供优秀的并发性,ConcurrentHashMap 还非常快。在我的机器上,上面的 intern 方法比 String.intern
快六倍多(但是请记住,String.intern
必须使用一些策略来防止在长时间运行的应用程序中内存泄漏)。并发集合使同步集合在很大程度上过时。例如,使用 ConcurrentHashMap 而不是 Collections.synchronizedMap
。 只要用并发 Map 替换同步 Map 就可以显著提高并发应用程序的性能。
同步器是允许线程彼此等待的对象,允许它们协调各自的活动。最常用的同步器是 CountDownLatch 和 Semaphore。较不常用的是 CyclicBarrier 和 Exchanger。最强大的同步器是 Phaser。
Countdown latches are single-use barriers,允许一个或多个线程等待一个或多个其他线程执行某些操作。CountDownLatch 的惟一构造函数接受一个 int,这个 int 是在允许所有等待的线程继续之前,必须在锁存器上调用倒计时方法的次数。
例如,假设你想要构建一个简单的框架来为一个操作的并发执行计时。这个框架由一个方法组成,该方法使用一个 executor 来执行操作,一个并发级别表示要并发执行的操作的数量,一个 runnable 表示操作。所有工作线程都准备在 timer 线程启动时钟之前运行操作。当最后一个工作线程准备好运行该操作时,计时器线程「发令枪」,允许工作线程执行该操作。一旦最后一个工作线程完成该操作,计时器线程就停止时钟。在 wait 和 notify 的基础上直接实现这种逻辑至少会有点麻烦,但是在 CountDownLatch 的基础上实现起来却非常简单:
// Simple framework for timing concurrent execution
public static long time(Executor executor, int concurrency,Runnable action) throws InterruptedException {
CountDownLatch ready = new CountDownLatch(concurrency);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(concurrency);
for (int i = 0; i < concurrency; i++) {
executor.execute(() -> {
ready.countDown(); // Tell timer we're ready
try {
start.await(); // Wait till peers are ready
action.run();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown(); // Tell timer we're done
}
});
}
ready.await(); // Wait for all workers to be ready
long startNanos = System.nanoTime();
start.countDown(); // And they're off!
done.await(); // Wait for all workers to finish
return System.nanoTime() - startNanos;
}
注意,该方法使用三个倒计时锁。第一个是 ready,工作线程使用它来告诉 timer 线程它们什么时候准备好了。工作线程然后等待第二个锁存器,即 start。当最后一个工作线程调用 ready.countDown
时。timer 线程记录开始时间并调用 start.countDown
,允许所有工作线程继续。然后计时器线程等待第三个锁存器 done,直到最后一个工作线程运行完操作并调用 done.countDown
。一旦发生这种情况,timer 线程就会唤醒并记录结束时间。
还有一些细节值得注意。传递给 time 方法的 executor 必须允许创建至少与给定并发级别相同数量的线程,否则测试将永远不会完成。这被称为线程饥饿死锁 [Goetz06, 8.1.1]。如果工作线程捕捉到 InterruptedException,它使用习惯用法 Thread.currentThread().interrupt()
重申中断,并从它的 run 方法返回。这允许执行程序按照它认为合适的方式处理中断。请注意,System.nanoTime
是用来计时的。对于间隔计时,始终使用 System.nanoTime
而不是 System.currentTimeMillis
。 System.nanoTime
不仅更准确,而且更精确,而且不受系统实时时钟调整的影响。最后,请注意,本例中的代码不会产生准确的计时,除非 action 做了相当多的工作,比如一秒钟或更长时间。准确的微基准测试是出了名的困难,最好是借助诸如 jmh 这样的专业框架来完成。
始终使用 wait 习惯用法,即循环来调用 wait 方法;永远不要在循环之外调用它。 循环用于在等待之前和之后测试条件。
有理由使用 notifyAll 来代替 notify。正如将 wait 调用放在循环中可以防止公共访问对象上的意外或恶意通知一样,使用 notifyAll 代替 notify 可以防止不相关线程的意外或恶意等待。否则,这样的等待可能会「吞下」一个关键通知,让预期的接收者无限期地等待。
76. 线程安全性的文档化
文档说明类是否可以被多个线程安全使用
- 不可变 immutable 类实例不可变,不需要外部同步,String Long BigInteger
- 无条件的线程安全 unconditionally thread-safe 实例可变,但是有足够的内部同步 ,实例可被并发使用,无需外部同步, Random ConcurrentHashMap
- 有条件的线程安全 conditionally thread-safe Collections.synchronized
- 非线程安全 not thread-safe 类实例可变,需要外部同步 ArrayList HashMap
- 线程对立 thread-hostile 类不能安全地被多个线程并发使用
77. 慎用延迟初始化
延迟初始化的最佳建议是「除非需要,否则不要这样做」(第67项)。延迟初始化是一把双刃剑。它降低了初始化类或创建实例的成本,代价是增加了访问延迟初始化字段的成本。根据这些字段中最终需要初始化的部分、初始化它们的开销以及初始化后访问每个字段的频率,延迟初始化实际上会损害性能(就像许多「优化」一样)。延迟初始化也有它的用途。如果一个字段只在类的一小部分实例上访问,并且初始化该字段的代价很高,那么延迟初始化可能是值得的。
如果使用延迟初始化来取代初始化 circularity,请使用同步访问器,因为它是最简单、最清晰的替代方法:
// Lazy initialization of instance field - synchronized accessor
private FieldType field;
private synchronized FieldType getField() {
if (field == null)
field = computeFieldValue();
return field;
}
如果需要在静态字段上使用延迟初始化来提高性能,use the lazy initialization holder class idiom. 这个用法可保证一个类在使用之前不会被初始化。它是这样的:
// Lazy initialization holder class idiom for static fields
private static class FieldHolder {
static final FieldType field = computeFieldValue();
}
private static FieldType getField() { return FieldHolder.field; }
第一次调用 getField 时,它执行 FieldHolder.field,导致初始化 FieldHolder 类。这个习惯用法的优点是 getField 方法不是同步的,只执行字段访问,所以延迟初始化实际上不会增加访问成本。典型的 VM 只会同步字段访问来初始化类。初始化类之后,VM 会对代码进行修补,这样对字段的后续访问就不会涉及任何测试或同步。
如果需要使用延迟初始化来提高实例字段的性能,请使用双重检查模式。这个模式避免了初始化后访问字段时的锁定成本。这个模式背后的思想是两次检查字段的值(因此得名 double check):一次没有锁定,然后,如果字段没有初始化,第二次使用锁定。只有当第二次检查指示字段未初始化时,调用才初始化字段。由于初始化字段后没有锁定,因此将字段声明为 volatile 非常重要。下面是这个模式的示例:
// Double-check idiom for lazy initialization of instance fields
private volatile FieldType field;
private FieldType getField() {
FieldType result = field;
if (result == null) { // First check (no locking)
synchronized(this) {
if (field == null) // Second check (with locking)
field = result = computeFieldValue();
}
}
return result;
}
如果不关心每个线程是否都会重新计算字段的值,并且字段的类型是 long 或 double 之外的基本类型,那么您可以选择在单检查模式中从字段声明中删除 volatile 修饰符。(利用可见性)这种变体称为原生单检查模式。它加快了某些架构上的字段访问速度,代价是需要额外的初始化(每个访问该字段的线程最多需要一个初始化)。
78. 不要依赖于线程调度器
依赖于线程调度器的程序,很有可能都是不可移植的。
确保可运行线程的平均数量不显著大于处理器的数量。
线程不应该处于 busy 到 wait 的循环,而应该反复检查一个共享对象,等待它的状态发生变化。除了使程序容易受到线程调度器变化无常的影响之外,繁忙等待还大大增加了处理器的负载,还影响其他人完成工作。
第 12 章 序列化
用于将对象编码为字节流(序列化),并从对象的编码中重构对象(反序列化)。对象序列化后,可以将其编码从一个 VM 发送到另一个 VM,或者存储在磁盘上,以便今后反序列化。
79. 使用Java序列化的替代方案
序列化的一个根本问题是它的可攻击范围太大,且难以保护,而且问题还在不断增多:通过调用 ObjectInputStream 上的 readObject 方法反序列化对象图。这个方法本质上是一个神奇的构造函数,可以用来实例化类路径上几乎任何类型的对象,只要该类型实现 Serializable 接口。在反序列化字节流的过程中,此方法可以执行来自任何这些类型的代码,因此所有这些类型的代码都在攻击范围内。
Java 反序列化是一个明显且真实的危险源,因为它被应用程序直接和间接地广泛使用,比如 RMI(远程方法调用)、JMX(Java 管理扩展)和 JMS(Java 消息传递系统)。不可信流的反序列化可能导致远程代码执行(RCE)、拒绝服务(DoS)和一系列其他攻击。应用程序很容易受到这些攻击,即使它们本身没有错误。
避免序列化利用的最好方法是永远不要反序列化任何东西。
可以使用跨平台结构化数据机制在对象和字节序列之间进行转换,
领先的跨平台结构化数据表示是 JSON 和 Protocol Buffers,也称为 protobuf。
JSON 和 protobuf 之间最显著的区别是 JSON 是基于文本的,并且是人类可读的,而 protobuf 是二进制的,但效率更高;JSON 是一种专门的数据表示,而 protobuf 提供模式(类型)来记录和执行适当的用法。
80. 谨慎地实现 Serializable 接口
序列化成功需要:
- 实现 java.io.Serializable
- 类的属性必须是可序列化的
Externalizable 接口继承了 java 的序列化接口,并增加了
void writeExternal(ObjectOutput out) throws IOException;
void readExternal(ObjectInput in) throws IOException, ClassNotFoundException;
为继承而设计的类,尽量避免去实现 Serializable 接口。
内部类不应该实现 Serializable, 静态成员类可以
81. 考虑使用自定义的序列化形式
即使你认为默认的序列化形式是合适的,你通常也必须提供 readObject 方法来确保不变性和安全性。
无论选择哪种序列化形式,都要在编写的每个可序列化类中声明显式的序列版本 UID。
总而言之,如果已经决定一个类应该是可序列化的,要考虑一下序列化的形式。只有在合理描述对象的逻辑状态时,才使用默认的序列化形式;否则,设计一个适合描述对象的自定义序列化形式。设计类的序列化形式应该和设计导出方法花的时间应该一样多,都应该严谨对待。正如不能从未来版本中删除导出的方法一样,也不能从序列化形式中删除字段;必须永远保存它们,以确保序列化兼容性。选择错误的序列化形式可能会对类的复杂性和性能产生永久性的负面影响。
82. 保护性编写 readObject 方法
readObject 方法实际上是另一个公共构造函数,它与任何其他构造函数有相同的注意事项。如,构造函数必须检查其参数的有效性并在适当的地方制作防御性副本一样,readObject 方法也必须这样做。如果 readObject 方法没有做到这两件事中的任何一件,那么攻击者就很容易违反类的不变性。
编写 readObject 方法的指导原则:
-
对象引用字段必须保持私有的的类,应防御性地复制该字段中的每个对象。不可变类的可变组件属于这一类。
-
检查任何不变量,如果检查失败,则抛出 InvalidObjectException。检查动作应该跟在任何防御性复制之后。
-
如果必须在反序列化后验证整个对象图,那么使用 ObjectInputValidation 接口(在本书中没有讨论)。
-
不要直接或间接地调用类中任何可被覆盖的方法。