Java 核心技术 卷Ⅰ
看书过程整理的一些笔记,大多是书上摘抄内容。可能有些无用功,但强迫症还是使我做了,不然看了啥都没留下,书也不是很方便,有个电子版笔记必要时我也能翻一翻。
第1章 Java程序设计概述
1.4 Java发展简史
1996年Sun发布了Java的第1个版本。2004发布5.0版。是自1.1版以来第一个对Java语言做出重大改进的版本。增加了泛型类、“for each”循环、可变元参数、自动装箱、元数据、枚举、静态导入。Sun公司于2009年被Oracle收购。Java的开发停止了很长一段时间。- 知到2011年Oracle发布了Java 7,其中只做了一些简单的改进。
2014年,Java 8终于发布,在近20年中这个版本发生的改变最大。引入了lambda表达式、包含默认方法的接口、流和时间/日期库。2018年开始,每6个月就会发布一个Java版本,以支持更快地引入新特性。Java 11 设计为长期支持版本。
第2章 Java程序设计环境
2.4 JShell
JShell会评估输入,然后打印结果,等待下一个输入。
不需要启动一个庞大的开发环境,简单高效。
第3章 Java的基本程序设计结构
第4章 对象与类
第5 章 继承
第六章 接口、lambda 表达式与内部类
6.1 接口
6.1.1 接口的概念
public interface Comparable<T>{
int compareTo(T other)
}
任何实现了Comparable接口的类都必须包含compareTo方法。
CompareTo方法返回一个整数:
- 负值:第一个小于第二个;
- 0:两个整数相等;
- 正值:第一个大于第二个;
- 若比较两个整数ID,需确信ID为非负整数,或者他们的绝对值不会超过
(Integer.MAX_VALUE - 1) / 2,否则调用Integer.compare方法。 CompareTo方法应当与equals方法兼容;- 如果要让一个类使用排序服务必须让它实现
CompareTo方法;
接口中的所有方法都自动
public,因此在声明时不必提供关键字public,但在类实现时必须把方法声明为public。可以将接口堪称没有实例字段的抽象类。
类实现某个接口使用关键字
implements。
6.1.2 接口的属性
- 可以使用
instanceof检查一个对象是否实现了某个特定的接口; - 接口可以扩展,允许多条接口链;
- 接口中不能包含实例字段,但可以包含常量;
- 接口中的字段总是
public static final;
有些接口只定义常量,而没有定义方法;如标准库
SwingConstants接口,其中只包含了NORTH、SOUTH等常量,任何实现了该接口的类都自动继承了这些常量,在方法中可以直接引用,而不必采用SwingConstants.NORTH这样繁琐的书写形式。但这样使用接口更像是退化,建议最好不要这样使用。
实现
Cloneable接口,Object类中的cone方法就可以创建类对象的一个准确副本。
6.1.3 接口与抽象类
- 每个类只能继承一个
抽象类,但可以实现多个接口; C++允许多重继承,但Java不允许;
6.1.4 静态和私有方法
Java 8允许在接口中增加静态方法;- 目前,通常的做法都是将静态方法放在伴随类中:在标准库中常有成对出现的接口和实用工具类,如
Collection/Collections或Path/Paths,其中Collections和Paths都是伴随类。若允许在接口中增加静态方法,就没有必要再为实用工具方法另外提供一个伴随类; Java 9中接口的方法可以是private,可以修饰静态或实例方法,但用法很有限。
6.1.5 默认方法
为接口方法提供一个默认实现,必须用default修饰符标记这个方法:
public interface Comparable<T>{
default int compareTo(T other) {return 0;}
}
接口演化:假设很久以前你提供了一个类public class Bag implements Collection。后来在Java 8中,又为这个接口增加了一个stream方法。
若stream方法不是一个默认方法,那么Bag类将不能编译,因为没有实现这个新方法。为接口增加一个非默认方法不能保证“源代码兼容”。
若不重新编译这个类,仍能正常加载,尽管没有这个新方法,但调用时会出现AbstractMethodError错误。
将方法实现为一个默认方法就可以解决这两个问题:Bag类能正常编译;若没有编译直接加载这个类,一个实例调用了该方法,会调用Collection.stream方法。
6.1.6 解决默认方法冲突
-
超类优先:若超类提供了一个具体方法,接口中同名且参数类型相同的默认方法会被覆盖;
-
接口冲突:若一个类同时实现了两个接口,两个接口中有相同的默认方法实现,则子类需要提供同样的方法,在该方法中选择超类其中之一,如:
class Student implements Person, Named { public String getName() {return Person.super.getName();} } -
接口冲突:若其中一个接口没有提供默认实现,只是定义,也需要程序员主动解决这个二义性;
-
接口冲突:若两个接口都没有默认实现,子类可以选择实现这个方法,或者不实现(抽象类);
-
一个类扩展了一个超类,同时实现了一个接口,并从超类和接口继承了相同的方法:这种情况下,只会考虑超类的方法,接口的所有默认方法都会被忽略(类优先)。
6.1.7 接口与回调
下面是一个接口:
public interface ActionListener {
void actionPerformed(ActionEvent event);
}
假设希望每隔1秒打印一条信息,那么可以定义一个实现ActionListener接口的类,然后把想要执行的语句放在actionPerformed中:
class TimePrinter implements ActionListener{
public void actionPerformed(ActionEvent event){
System.out.println("At the tone, the time is " + Instant.ofEpochMilli(event.getWhen()));
Toolkit.getDefaultToolkit().beep();
}
}
然后通过javax.swing.Timer中的Timer(int interval, ActionListener listener)构造一个定时器:
var listener = new TimePrinter();
// 构造定时器,每秒执行以此
var timer = new Timer(1000, listener);
timer.start(); //定时器启动
// timer.stop() //定时器停止
6.1.8 Comparator接口
想要对一个对象数组排序,可以实现Comparable接口,然后用对象的compareTo方法进行比较。
也可以用比较器的compare方法:比较器是一个类,实现了Comparator接口:
public interface Comparator<T>{
int compare(T first, T second);
}
要按长度比较字符串,可以定义如下一个比较器:
class LengtnComparator implements Comparator<String>{
public int compare(String first, String second){
return first.length() - second.length();
}
}
具体比较是进行如下操作:
var comp = new LengthComparator();
if (comp.compare(word[i]), word[j] > 0) { ... }
6.1.9 克隆对象
Object中的clone方法:是一个proteced方法,这是浅拷贝,如果拷贝对象中的所有数据字段都是数值或其他基本类型,拷贝这些字段没有任何问题。
但是如果对象包含子对象的引用,原对象和克隆对象仍存在一些共享信息。
如果原对象和克隆对象的子对象是不可变的,那么这也是安全的。
如果子对象是可变的,必须重新定义clone方法来建立一个深拷贝,同时克隆所有子对象:
- 实现
Cloneable接口; - 重新定义
clone方法,并指定public访问修饰符;
子类只能调用受保护的
clone方法来克隆它自己的对象。必须重新定义clone为public才能允许所有方法克隆对象。
Cloneable接口是一个标记接口:不包含任何方法,唯一作用是允许在类型查询中实用instanceof。
下面是一个在自己类中实现clone方法,以此建立深拷贝的例子:
public Employee clone() throws CloneNotSupportedException{
// call Object.clone()
Employee cloned = (Employee) super.clone();
// clone mutable fields
cloned.hireDay = (Date) hireDay.clone();
return cloned;
}
6.2 lambda表达式
6.2.1 为什么引入lambda表达式
lambda表达式时一个可传递的代码块,可以在以后执行一次或多次。
6.2.2 lambda 表达式的语法
-
参数,箭头(
->)以及一个表达式。表达式可以放在{}中,并包含显式的return语句:(String first, String second) -> { if (first.length() < second.length()) return -1; else if (first.length() > second.length()) return 1; else return 0; } -
即使
lambda表达式没有参数,仍要提供空括号:() -> { for (int i = 100; i>= 0; i--) System.out.println(i); } -
如果可以推导出一个
lambda表达式的参数类型,则可以忽略其类型,例如:Comparator<String> comp = (first, second) -> first.length() - second.length(); -
如果方法只有一个参数,且这个参数的类型可以推导得出,那么小括号也可以省略:
ActionListener listener = event -> System.out.println("The time is" + Instant.ofEpochMilli(event.getWhen())); -
lambda表达式的返回类型总是会由上下文推导得出。
6.2.3 函数式接口
对于只有一个抽象方法的接口,需要这种接口的对象时,就可以提供一个lambda表达式,这种接口成为函数式接口。
java.util.function包中有一个尤其有用的接口Predicate:
public interface Predicate<T> {
boolean test(T t);
}
ArrayList类中有一个removeIf方法,它的参数就是一个Predicate。这个接口专门用来传递lambda表达式,下面是一个删除所有null值的例子:
list.removeIf(e -> e == null);
另一个有用的函数式接口时Supplier<T>:
public interface Supplier<T> {
T get();
}
供应者(supplier)没有参数,调用时会生成一个T类型的值。供应者用于实现懒计算,考虑如下例子:
LocalDate hireDay = Objects.requireNoNullOrElse(day, new LocalDate(1970, 1, 1));
这不是最优的,我们预计day很少为null,所以希望只在必要时才构造默认的LocalDate。通过供应者,就能延迟这个计算:
LocalDate hireDay = Objects.requireNoNullOrElseGet(day, () -> new LocalDate(1970, 1, 1));
6.2.4 方法引用
假设希望只要出现一个定时器事件就打印这个事件对象,可以如下调用:
var timer = new Timer(1000, event -> System.out.println(event));
可以做得更好,直接把println方法传递到Timer构造器:
var timer = new Timer(1000, System.out::println);
System.out::println就是一个方法引用。这个例子中,会生成一个ActionListener,它的actionPerformed(ActionEvent e)方法要调用System.out.println(e)。
假设想对字符串进行排序,而不考虑字母的大小写,可以传递以下方法表达式:
Arrays.sort(strings, String::compareToIgnoreCase)
从这些例子可以看出,要用::运算符分隔方法名与对象或类名。主要有三种情况:
object::instanceMethod:方法引用等价于向方法传递参数的lambda表达式,参数就是对象自己。对于System.out::println,对象是System.out;Class::instanceMethod:第一个参数会成为方法的隐式参数。如String::compareToIgnoreCase等同于(x, y) -> x.compareToIgnoreCase(y);Class::staticMethod:所有参数都传递到静态方法,Math::pow等价于(x, y) -> Math.pow(x, y)。
只有当
lambda表达式的体只调用一个方法而不做其他操作时,才能把lambda表达式重写为方法引用
有时
API包含一些专门用作方法引用的方法。如Objects类有一个方法isNull,用于测试一个对象引用是否为null。可以把方法引用传递到任何有Predicate参数的方法。例如,要从一个列表删除所有引用,就可以调用:list.removeIf(Objects::isNull); // 而不是 list.removeIf(e -> e == null);
可以在方法引用中实用this和super参数:
this::instanceMethod:如this::equals等价于x -> this.equals(x)。super::instanceMethod:使用this作为目标,会调用给定方法的超类版本。
6.2.5 构造器引用
构造器引用与方法引用很类似,只不过方法名为new。
Integer::new:等价于x -> new Integer(x);Integer[]::new:等价于n -> new Integer[n];
Java无法构造泛型类型T的数组。
假设我们需要一个Person对象数组,Stream接口有一个toArray方法可以返回Object数组:
Object[] people = stream.toArray();
不过,希望得到一个Person引用数组,流库利用构造器引用解决了这个问题:
Person[] people = stream.toArray(Person[]::new);
6.2.6 变量作用域
lambda表达式有三个部分:
- 一个代码块;
- 参数;
- 自由变量的值,这是指非参数而且不再代码中定义的变量;
在
Java中,lambda表达式是闭包的。
- 在
lambda表达式中,不能改变变量的引用值; - 如果所引用的变量可能在外部发生改变,也是不合法的;
lambda表达式中捕获的变量必须实际上是事实最终变量(这个变量初始化之后就不会再为它赋新值);lambda表达式的体与嵌套块有相同的作用域;
在一个表达式中使用this关键字时,是指创建这个lambda表达式的方法的this参数。例如:
public class Application {
public void init() {
ActionListener listener = event -> {
System.out.println(this.toString());
...
}
...
}
}
表达式中this.toString()会调用Application对象的toString方法,而不是ActionListener实例的方法。lambda表达式的作用域嵌套在init方法中,与出现在init方法中的其他位置一样,lambda表达式中的this的含义并没有发生变化。
6.2.7 处理 lambda 表达式
使用lambda表达式的重点是延迟执行。毕竟如果想要立即执行代码,完全可以直接执行,而无须把它包装在一个lambda表达式中。之所以希望延迟执行,有很多原因:
- 在一个单独的线程中运行代码;
- 多次运行代码;
- 在算法的适当位置运行代码(如,排序中的比较操作);
- 发生某种情况时执行代码(如,点击了一个按钮,数据到达,等等);
- 只在必要时才运行代码。
一个简单例子:假设想要重复一个动作n次。将这个动作传到一个repeat方法中:
repeat(10, () -> System.out.println("Hello, World"));
要接受这个lambda表达式,需要选择一个函数式接口。在这里可以选择Runnable接口:
public static void repeat(int n, Runnable action){
for(int i = 0; i < n; i++) action.run();
}
调用action.run()时会执行这个lambda表达式的主体。
下面给出常用函数式接口:
| 函数式接口 | 参数类型 | 返回类型 | 抽象方法名 | 描述 | 其他方法 |
|---|---|---|---|---|---|
Runnable | 无 | void | run | 作为无参数或返回值的动作运行 | |
Supplier<T> | 无 | T | get | 提供一个T类型的值 | |
Consumer<T> | T | void | accept | 处理一个T类型的值 | andThen |
BiConsumer<T, U> | T, U | void | accept | 处理T和U类型的值 | andThen |
Finction<T, R> | T | R | apply | 有一个T类型参数的函数 | compose, andThen, identity |
BiFunction<T, U, R> | T, U | R | apply | 有T和U类型参数的函数 | andThen |
UnaryOperator<T> | T | T | apply | 类型T上的一元操作符 | compose, andThen, identity |
BinaryOperator<T> | T, T | T | apply | 类型T上的二元操作符 | andThen, maxBy, minBy |
Predicate<T> | T | boolean | test | 布尔值函数 | and, or, negate, isEqual |
BiPredicate<T, U> | T, U | boolean | test | 有两个参数的布尔值函数 | and, or, negate |
让上面的例子更复杂一些.希望告诉这个动作它出现在那一次迭代中。为此,需要选择一个合适的函数式接口,其中瑶包含一个方法,这个方法有一个int参数而且返回类型为void。处理int值的标准接口如下:
public interface IntConsumer{
void accpet(int value);
}
下面给出repeat方法的改进版本:
public static void repeat(int n, IntConsumer action){
for(int i = 0; i < n; i++) action.accept(i);
}
可以如下调用:
repeat(10, i -> System.out.println("Countdown: " + (9 - i)));
下面列出了基本类型int、long和double的34个可用的特殊化接口。使用这些特殊化接口比使用通用接口更高效。
| 函数式接口 | 参数类型 | 返回类型 | 抽象方法名 |
|---|---|---|---|
BoolenSupplier | 无 | boolean | getAsBoolean |
PSupplier | 无 | p | getAsP |
PConsumer | p | void | accept |
ObjPConsumer<T> | T, p | void | accept |
PFunction<T> | p | T | apply |
PToQFunction | p | q | applyAsQ |
ToPFunction<T> | T | p | applyAsp |
ToPBiFunction<T, U> | T, U | p | applyAsp |
PUnaryOperator | p | p | applyAsp |
PBinaryOperator | p, p | p | applyAsp |
PPredicate | p | boolean | test |
p、q是int、long、double;P、Q是Int、Long、Double;
平常最好使用前两个表格中的接口。大多数标准函数式接口都提供了非抽象方法来生成或合并函数。例如,
Predicate.isEqual(a)等同于a::equals,如果a为null也能正常工作。已经提供了默认方法and、or和negate来合并谓词。例如,Predicate.isEqual(a).or(Predicate.isEqual(b))就等同于x -> a.equals(x) || b.equals(x)。
如果自己设计的接口,其中只有一个抽象方法,可以用
@FunctionalInterface注解来标记这个接口。这样有两个优点:1. 无意中增加了另一个抽象方法,编译器会报错;2.javadoc页面会指出该接口是一个函数式接口。
6.2.8 再谈Comparator
Comparator接口包含很多方便的静态方法来创建比较器。这些方法可以用于lambda表达式或方法引用。
静态comparing方法取一个“键提取器”函数,它将类型T映射为一个可比较的类型(如String)。对要比较的对象应用这个函数,然后对返回的键完成比较。例如,假设有一个Person对象数组,可以如下按名字对这些对象进行排序:
Arrays.sort(people, Comparator.comparing(Person::getName));
可以把比较器与thenComparing方法串起来,来处理比较结果相同的情况。例如,
Arrays.sort(people, Comparator.comparing(Person::getLastName).thenComparing(Person::getFirstName));
如果两个人的姓相同,就会使用第二个比较器。
这些方法有不少变体形式。可以为comparing和thenComparing方法提取的键指定一个比较器。如下可以根据人名长度完成排序:
Arrays.sort(people, Comparator.comparing(Person::getName, (s, t) -> Integer.compare(s.length(), t.length())));
还有更容易的做法:
Arrays.sort(people, Comparator.comparingInt(p -> p.getName().length()));
6.3 内部类
内部类是定义在另一个类中的类。使用内部类主要有两个原因:
- 内部类可以对用一个包中的其他类隐藏。
- 内部类方法可以访问定义这个类的作用域中的数据,包括原本私有的数据。
6.3.1 使用内部类访问对象状态
一个内部类方法可以访问自身的数据字段,也可以方法创建它的外围类对象的数据字段。
为此,内部类的对象总有一个隐式引用,指向创建它的外部类对象。
也可以把内部类声明为私有的,这样就只有外围类才可以构造内部类对象。
6.3.2 内部类的特殊语法规则
使用外围类引用的正规语法:
OuterClass.this //表示外围类引用
// 例如
public void actionPerformed(ActionEvent event){
...
if(TalkingClock.this.beep) Toolkit.getDefaultToolkit().beep();
}
可以使用下面语法更加明确地编写内部类对象的构造器:
outerObject.new InnerClass(construction parameters)
例如:
ActionListener listener = this.new TimePrinter();
内部类中声明的所有静态字段都必须是
final,并初始化为一个编译时常量。如果这个字段不是一个常量,就可能不唯一内部类不能有
static方法。
6.3.3 内部类是否有用、必要和安全
Java语言在Java 1.1增加内部类。
6.3.4 局部内部类
当内部类的名字只出现了一次,也即只使用了一次,就可以在一个方法中局部地定义这个类。
public void start(){
public TimePrinter implements ActionListener{
public void actionPerformed(ActionEvent event){
System.out.println("At the tone, the time is "
+ Instant.ofEpochMilli(event.getWhen()));
if (beep) Toolkit.getDefaultToolkit().beep();
}
}
var listener = new TimePrinter();
var timer = new Timer(interval, listener);
timer.start();
}
声明局部类时不能有访问说明符(即
public和private)。局部类的作用域被限定在声明这个局部类的块中。
局部类有一个很大的优势,即对外部世界完全隐藏,甚至外围类中的其它代码也不能访问。除了定义这个局部类的方法,没有任何方法知道局部类的存在。
6.3.5 由外部方法访问变量
局部类不仅能够访问外部类的字段,还可以访问局部变量。不过局部变量必须是事实最终变量(一旦赋值就绝不会改变)。
public void start(int interval, boolean beep){
public TimePrinter implements ActionListener{
public void actionPerformed(ActionEvent event){
System.out.println("At the tone, the time is "
+ Instant.ofEpochMilli(event.getWhen()));
if (beep) Toolkit.getDefaultToolkit().beep();
}
}
var listener = new TimePrinter();
var timer = new Timer(interval, listener);
timer.start();
}
timer.start()方法启动后,start方法退出,此时start方法的beep参数变量将不复存在。
TimePrinter类在beep参数消失之前会将beep字段复制为start方法的局部变量。
6.3.6 匿名内部类
只需要创建这个类的一个对象,甚至不需要指定名字,这样就可以使用匿名内部类。
public void start(int interval, boolean beep){
var listener = new ActionListener(){
public void actionPerformed(ActionEvent event){
System.out.println("At the tone, the time is "
+ Instant.ofEpochMilli(event.getWhen()));
if (beep) Toolkit.getDefaultToolkit().beep();
}
};
var timer = new Timer(interval, listener);
timer.start();
}
含义是:创建一个类的新对象,这个类实现了ActionListener接口,需要实现的方法actionPerformed在括号{}内定义。
一般地,语法如下:
new SuperType(construction parameters){
inner class methods and data
}
其中SuperType可以是接口,如ActionListener,这样,内部类就需要实现这个接口。SuperType也可以是一个类,如果是这样,内部类就需要扩展这个类。
匿名内部类不能有构造器;
只要内部类实现一个接口,就不能有任何构造参数。
new InterfaceType(){ methods and data }
如果构造参数列表的结束小括号后面跟一个开始大括号,就是在定义匿名内部类。
尽管匿名类不能有构造器,但可以提供一个对象初始化块:
var count = new Person("Dracula"){ { initialization } }
6.3.7 静态内部类
若只是为了把一个类隐藏到另一个类的内部,并不需要内部类有外围类对象的一个引用。为此可以将内部类声明为static,这样就不会生成那个引用。
考虑这样一个任务:计算数组中的最小值和最大值。当然,可以编写两个方法分别求最大和最小值,数组遍历两次。如果只遍历数组一次,并同事计算出最小和最大值,那么就可以大大地提高效率了。
class ArrayAlg{
//静态内部类
public static class Pair{
private double first;
private double second;
public Pair(double f, double s){
first = f;
second = s;
}
public double getFirst(){
return first;
}
public double getSecond(){
return second;
}
}
// ArrayAlg的一个方法
public static Pair minmax(double[] values){
double min = Double.POSITIVE_INFINITY;
double max = Double.NAGETIVE_INFINITY;
for(double v : values){
if(min > v) min = v;
if(max < v) max = v;
}
return new Pair(min, max);
}
}
调用如下:
var values = new double[20];
for (int i = 0; i < values.length; i++)
values[i] = 100 * Math.random();
ArrayAlg.Pair p = ArrayAlg.minmax(values);
System.out.println("min = " + p.getFirst());
System.out.println("max = " + p.getSecond());
6.4 服务加载器
暂不必理会
6.5 代理
只有在编译时期无法确定实现哪个接口时才有必要使用代理。
暂不必理会
第7章 异常、断言和日志
本章的第1部分先介绍Java的异常。
在测试期间,需要运行大量的检查以确保程序操作的正确性。然而,这些检查可能非常耗时,在测试完成后也没有必要保留。也可以简单的将这些代码删除,需要另做测试时再将它们粘贴回来,不过这样做会很繁琐。本章的第2部分将介绍如何使用断言有选择地启动检查。
当程序出现错误时,并不总是能够与用户或终端进行通信。此时,我们可能希望记录出现的问题,以备日后进行分析。本章的第3部分将讨论标准Java日志框架。
7.1 处理错误
- 用户输入错误
- 设备错误
- 物理限制
- 代码错误
7.1.1 异常分类
异常对象都是派生与Throwable类的一个类实例。
所有异常都是由
Throwable继承而来,但在下一层立即分解为两个分支:Error和Exception。
Error类层次结构描述了Java运行时系统的内部错误和资源耗尽错误。自己程序中不应该抛出这种类型的对象。如果出现,除了通知用户,并尽力妥善地终止程序之外,几乎无能为力。- 在设计
Java程序时,重点关注Exception层次结构。这个层次结构又分解为两个分支:一个分支派生于RuntimeException;另一个分支包含其他异常。一般规则是:由程序错误导致的异常属于RuntimeException;如果程序本身没有问题,但由于像I/O错误这类问题导致的异常属于其他异常。
派生于RuntimeException的异常包括以下问题:
- 错误的强制类型转换
- 数组访问越界
- 访问
null指针
不是派生于RuntimeException的异常包括:
- 试图超越文件末尾继续读取数据
- 试图打开一个不存在的文件
- 试图根据给定的字符串查找
Class对象,而这个字符串表示的类并不存在
如果出现RuntimeException异常,那么就一定是你的问题。
Java语言将派生于Error类或RuntimeException类的所有异常称为**非检查型(unchecked)异常,所有其他异常称为检查型(checked)**异常。
7.1.2 声明检查型异常
声明异常用throws关键字。下面4四种情况会抛出异常:
- 调用了一个抛出检查型异常的方法,如,
FileInputStream构造器。 - 检测到一个错误,并利用
throw语句抛出一个非检查型异常。 - 程序出现错误,如,
a[-1]=0会抛出一个非检查型异常。 Java虚拟机或运行时库出现的内部错误。
出现前两种情况,则必须告诉调用这个方法的程序员有可能抛出异常。
class MyAnimation{
...
public Image loadImage(String s) throws FileNotFoundException, EOFException{
...
}
}
但是,不需要声明Java内部错误,即从Error继承的异常。同样,也不应该声明从RuntimeException继承的那些非检查型异常。
下面就是个坏例子:
class MyAnimation{
...
void drawImage(int s) throws ArrayIndexOutofBoundsException{
...
}
}
总之,一个方法必须声明所有可能抛出的检查型异常,而非检查型异常要么在你的控制范围之外(Error),要么是由从一开始就应该避免的情况所导致的(RuntimeException)。如果方法没有声明所有可能发生的检查型异常,编译器就会报错。
当然除了声明异常,还可以捕获异常。这样就不会从这个方法抛出这个异常,所以也没必要使用throws。
如果在子类中覆盖了超类的一个方法,子类方法中声明的检查型异常不能比超类方法中声明的异常更通用。
7.1.3 如何抛出异常
- 找到一个合适的异常类
- 创建这个类的一个对象
- 将对象用
throw抛出
String readData(Scanner in) throws EOFException {
...
while(...) {
if (!in.hasNext()) //EOF 发生
{
if (n < len) throw new EOFException();
}
...
}
return s;
}
7.1.4 创建异常类
只需定义一个派生于Exception的类,或者派生于Exception的某个子类,如IOException。自定义的这个类应该包含两个构造器,一个是默认的构造器,另一个是包含详细描述信息的构造器(超类Throwable的toString方法会返回一个字符串,其中包含这个详细信息,这在调试中非常有用)。
class FileFormatException extends IOException {
public FileFormatException() {}
public FileFormatException(String gripe) {
super(gripe);
}
}
java.lang.Throwable
Throwable()
- 构造一个新的
Throwable对象,没有详细的描述信息Throwable(String message)
- 构造一个新的
Throwable对象,带有指定的详细描述信息String getMessage()
- 获得
Throwable对象的详细描述信息
7.2 捕获异常
7.2.1 捕获异常
try/catch语法块:
try {
code
more code
more code
}catch (ExceptionType e){
处理异常,如
e.printStackTrace();
}
也可以什么都不做,将异常传递给调用者,就需要在该方法用throws声明异常。
一般经验:要捕获那些你知道如何处理的异常,而继续传播那些你不知道怎样处理的异常。
但子类声明的异常不能比超类更广泛,如果超类没有声明异常,此时子类就应该捕获异常。
7.2.2 捕获多个异常
在一个try语句块中可以捕获多个异常类型,并对不同类型的异常做出不同的处理,要为每个异常类型使用一个单独的catch子句。如下所示:
try{
code that migth throw exceptions
} catch (FileNotFoundException e) {
emergency action for missing files
} catch (UnknownHostException e) {
emergency action for unknown hosts
} catch (IOException e) {
emergency action for all other I/O problems
}
异常对象可能包含有关异常性质的信息,得到详细的错误信息语句:
e.getMessage()
或得到异常对象的实际类型:
e.getClass().getName()
在Java 7中,可以在同一个catch语句中捕获多个异常类型:
try{
code that migth throw exceptions
} catch (FileNotFoundException | UnknownHostException e) {
emergency action for missing files and unknown hosts
} catch (IOException e) {
emergency action for all other I/O problems
}
捕获多个异常时,异常变量隐含为
final变量。捕获多个异常不仅让代码看起来更简单,还会更高效。
7.2.3 再次抛出异常与异常链
可以在catch语句中抛出一个异常。通常,希望改变异常的类型时会这样做。
try {
access the database
} catch (SQLException original){
var e = new ServletException("database error");
e.initCause(original); //将原始异常设置为新异常的“原因”
throw e;
}
捕获到这个异常时,可以使用下面这条语句获取原始异常:
Throwable original caughtException.getCause();
这种包装技术可以在子系统中抛出高层异常,而不会丢失原始异常。
7.2.4 finally子句
不管是否有异常被捕获,finally子句中的代码都会执行,可以将在try子句中获取的资源在finally中释放。如下示例,所有情况下程序都将关闭输入流:
var in = new FileInputStream();
try {
// 1
code that might throw exceptions
// 2
} catch (IOException e) {
// 3
show error message
// 4
} finally {
// 5
in.close();
}
// 6
程序有以下几种运行情况:
try子句中没有抛出异常:1、2、5、6try子句中抛出异常,catch子句只捕获不抛出:1、3、4、5、6try子句中抛出异常,catch子句捕获后抛出异常:1、3、5try子句中抛出异常,但没有任何catch捕获这个异常:1、5
try语句可以只有finally子句,而没有catch子句。
7.2.5 try-with-Resources语句
资源属于一个实现了AutoCloseable接口的类,其有一个方法:
void close() throws Exception
try-with-resources语句的最简形式:
try (Resource res = ...) {
work with res
}
try块退出时,会自动调用res.close()。下面是一个读取文件的例子,可以指定多个资源:
try (var in = new Scanner(new FileInputStream("path"), StandardCharsets.UTF-8);
var out = new PrintWriter("out.txt", StandardCharsets.UTF-8)){
while (in.hasNext()){
out.println(in.hasNext().toUpperCase());
}
}
无论这个块如何退出,in和out都会关闭。
在Java 9中,可以在try首部中提供之前声明的事实最终变量:
public static void printAll(String[] lines, PrintWriter out){
try(out) {
for(String line : lines) { //事实最终变量
out.println(line);
}
} //out.close()被调用
}
如果try块抛出一个异常,而且close方法也抛出一个异常。try-with-resources语句会将原来的异常重新抛出,而close方法抛出的异常会”被抑制“。这些异常将自动捕获,并由addSuppressed方法增加到原来的异常。
7.2.6 分析堆栈轨迹元素
堆栈轨迹时程序执行过程中某个特定点上所有挂起的方法调用的一个列表。
可以使用Throwable类的printStackTrace方法访问堆栈轨迹的文本描述信息。
更灵活的方法是使用StackWalker类,它会生成一个StackWalker.StackFrame实例流,其中每个实例分别描述一个栈帧。可以利用以下调用迭代处理这些栈帧:
var walker = StackWalker.getInstance();
walker.forEach(System.out::println); //直接打印了这个栈帧
7.3 使用异常的技巧
- 异常处理不能代替简单的测试
- 一个示例,试着将一个空栈弹出10 000 000次。首先弹栈之间查看栈是否为空:
if (!s.empty()) s.pop();。 - 强制要求不论栈是否为空都执行弹出操作,然后捕获
EmptyStackException异常。
- 一个示例,试着将一个空栈弹出10 000 000次。首先弹栈之间查看栈是否为空:
- 不要过分地细化异常
- 不要将一个任务的每条语句都分别放在一个
try块中,可以将整个任务放在try块中。
- 不要将一个任务的每条语句都分别放在一个
- 充分利用异常层次结构
- 不要只抛出
RuntimeException异常。应该寻找一个适合的子类或创建自己的异常类。 - 不要只捕获
Throwable异常,否则,会使代码更难读、更难维护。 - 考虑检查型异常与非检查型异常的区别。检查型异常本来就很大了,不要为逻辑错误抛出这些异常。
- 如果能将一种异常转换成另一种更加合适的异常,那么不要犹豫!例如,在解析某个文件中的一个整数时,可以捕获
NumberFormatException异常,然后将它转换成IOException的一个子类或者MySubsystemException。
- 不要只抛出
- 不要压制异常
- 假设有个方法很久才会抛出一个异常,而我的一个方法调用了这个方法,那编译器会对调用了我的方法的所有方法报错,所以我选择用
try/catch将其关闭。 - 但这个异常一旦发生,会悄无声息的被忽略。如果异常都非常重要,就应该适当地进行处理。
- 假设有个方法很久才会抛出一个异常,而我的一个方法调用了这个方法,那编译器会对调用了我的方法的所有方法报错,所以我选择用
- 在检测错误时,“苛刻”要比放任好(早抛出)
- 在用无效的参数调用一个方法时,返回一个虚拟值是不是要比抛出一个异常更好?
- 如,当栈为空时,
Stack.pop是应该返回一个null,还是抛出一个异常? - 最好在出错的地方抛出一个
EmptyStackException异常,这要好于以后抛出一个NullPointerException异常。
- 不要羞于传递异常(晚捕获)
- 如果调用了一个抛出异常的方法,如,
FileInputStream构造器或readLine方法,最好时继续传递这个异常,而不是自己捕获。 - 更高层的方法通常可以更好的通知用户发生了错误,或者放弃不成功的命令。
- 如果调用了一个抛出异常的方法,如,
7.4 使用断言
在一个具有自我保护能力的程序中,断言很常用。
7.4.1 断言的概念
断言机制允许在测试期间向代码中插入一些检查,而在生产代码中会自动删除这些检查。
两种形式:
//形式一
assert condition;
//形式二
assert condition : expression;
计算条件为false,则抛出一个AssertionError异常。在第二个语句中,表达式将传入AssertionError对象的构造器,并转换成一个消息字符串。
表达式部分的唯一目的是产生一个消息字符串。
想要断言x是一个非负数:
assert x >= 0;
或者想x的实际值传递给AssertionError对象,以便以后显示:
assert x >= 0 : x;
7.4.2 启用和禁用断言
默认情况下,断言是禁用的,可以在程序运行时用-enableassertions或-ea选项启用断言。
不比重新编译程序来启用或禁用断言。启用或禁用是类加载器的功能。禁用断言时,类加载器会去除断言代码,因此不会降低程序运行速度。
也可以在某个类或整个包中启用断言:
java -ea:MyClass -ea:com.mycompany.mylib MyApp
也可以使用-disableassertions或-da在某个特定类和包中禁用断言。
7.4.3 使用断言完成参数检查
Java语言中,给出了3种处理系统错误的机制:
- 抛出一个异常
- 日志
- 使用断言
什么时候使用断言?
- 断言失败是致命的、不可恢复的错误
- 断言检查只在开发和测试阶段打开
例如,如果一个方法的参数不能为null,就可以使用断言。
7.4.4 使用断言提供假设文档
if (i % 3 == 0)
...
else if (i % 3 == 1)
...
else //(i % 3 == 2)
...
在这个示例中使用断言更好:
if (i % 3 == 0)
...
else if (i % 3 == 1)
...
else {
assert i % 3 == 2;
...
}
当然这里实际假设i是非负值,因此,最好在if语句之前使用以下断言:
assert i >= 0;
总的来说断言有点像假设,我假设咋的咋的,然后就在那个地方使用断言
7.5 日志
日志API的主要优点:
- 可以很容易地取消全部日志记录,或者仅仅取消某个级别以下的日志,而且可以很容易再次打开日志开关。
- 可以很简单地禁止日志记录。
- 日志记录可以被定向到不同的处理器,如控制台显示、写至文件等等。
- 可以对记录进行过滤。
- 日志记录可以采用不同的方式格式化。
- 应用程序可以使用多个日志记录器。
- 日志系统的配置由配置文件控制。
7.5.1 基本日志
生成简单的日志记录,可以使用全局日志记录器,并调用其info方法:
Logger.getGlobal().info("TEST");
在默认情况下,会打印如下记录:
Jan 06, 2025 8:35:53 PM REPL.$JShell$15 do_it$
INFO: TEST
但,如果在适当的地方(如main最前面)调用
Logger.getGlobal().setLevel(Level.OFF);
将会取消所有日志。
java.util.logging.Logger
java.util.logging.Level
7.5.2 高级日志
可以调动getLogger方法创建或获取日志记录器:
private static final Logger myLogger = Logger.getLogger("myLogger"); //myLogger是日志记录器名
未被任何变量引用的日志记录器可能会被垃圾回收,所以要使用静态变量存储日志记录器的引用。
通常,日志有以下7个级别:
- SEVERE
- WARNING
- INFO
- CONFIG
- FINE
- FINER
- FINEST
在默认情况下,实际上只记录前3个级别。也可以设置一个不同的级别,如:
logger.setLevel(Level.FINE)
现在,FINE以及所有更高级别的日志都会记录。
另外可以用Level.ALL开启所有级别日志,Level.OFF关闭所有级别日志。
所有级别都有日志记录方法,如:
logger.warning(message);
logger.fine(message);
或者,还可以使用log方法并指定级别,如:
logger.log(Level.FINE, message);
记录一个描述进入/退出方法(由给定的参数和返回值)的日志记录:
void entring(String className, String methodName)
void exiting(String className, String methodName)
可以使用下面两个方法在日志记录中包含异常的描述:
void throwing(Sting className, String methodName, Throwable t)
void log(Level l, String message, Throwable t)
典型用法:
if (...) {
var e = new IOException("...");
logger.throw("className", "methodName", e);
}
try{
...
} catch (IOExeption e) {
Logger.getLogger("myLogger").log(Level.WARNING, "Reading image", e);
}
7.5.3 修改配置文件
可以通过编辑配置文件来修改日志系统的各个属性。
7.5.4 本地化
本地化的应用程序包含资源包中的本地特定信息。资源包包括一组映射,分别对应各个本地化环境(如美国或德国)。
7.5.6 处理器
默认情况下,日志记录器会把记录发送到父处理器,而最终的祖先处理器(名为"")有一个ConsoleHandler,会由它输出到System.err流。
处理器也有日志级别,对于一个要记录的日志记录,它的日志级别必须高于日志记录器和处理器二者的阈值。默认为INFO。
可以绕过配置文件,安装自己的处理器:
Logger logger = Logger.getLogger("muLogger");
logger.setLevel(Level.FINE);
logger.setUseParentHandlers(false);
var handler = new ConsoleHandler();
handler.setLevel(Level.FINE);
logger.addHandler(handler);
默认情况,日志记录器将记录发送到自己的处理器和父日志记录器的处理器。我们的日志记录器是祖先日志记录器的子类,而这个祖先日志记录器会把所有等于或高于INFO几辈的记录发送到控制台。不过,我们并不想两次看到这些记录,因此应该将useParentHandlers属性设置为false。
要将日志记录发送到其他地方,就要添加其他处理器。
FileHandler:将记录收集到文件中。SocketHandler:将记录发送到指定的主机和端口。
可以如下直接将记录发送到默认文件处理器:
var handler = new FileHandler();
logger.addHandler(handler);
这些记录被发送到用户主目录的javan.log文件中,n是保证文件唯一的一个编号。默认情况下,记录会格式化为XML。
可以通过设置日志管理器配置文件中的不同参数,或使用另一个构造器,来修改文件处理器的默认行为。具体请查看书上P310。
如果多个应用工程需(或同一个应用程序的多个副本)使用同一个日志文件,就应该开启append标志。
可以通过扩展
Handler类或StreamHandler类自定义处理器。
7.5.6 过滤器
每个日志记录器和处理器都有一个可选的过滤器来完成附加的过滤。要定义一个过滤器,需要实现Filter接口并定义以下方法:
boolean isLoggable(LogRecord record)
在这个方法中,可以根据自己喜欢的标准分析日志记录,对那些应该包含在日志记录中的返回true。例如,某个过滤器可能只对entering方法和exiting方法生成的消息感兴趣,这个过滤器就可以调用record.getMessage()方法,并检查消息是否以ENTRY或RETURN开头。
要想将一个过滤器安装到一个日志记录器或处理器中,只需要调用setFilter方法就可以了。
同一时刻,最多只能有一个过滤器。
7.5.7 格式化器
需要扩展java.util.logging.Formatter类并覆盖下面这个方法:
String format(LogRecord record)
在format方法中,可能会调用下面这个方法:
String formatMessage(LogRecord record)
这个方法对记录中的消息部分进行格式化,将替换参数并应用本地化处理。
很多文件格式(如XML)需要再已格式化的记录的前后加上一个头部和尾部。为此,需要覆盖下面这两个方法:
String getHead(Handler h)
String getTail(Handler h)
最后,调用setFormatter方法将格式化器安装到处理器中。
7.5.8 日志技巧
- 对一个简单的应用,选择一个日志记录器。可以把日志记录器命名为与主应用包一样的名字。
- 改变配置的过程有些复杂,最好在自己的应用中安装一个更合适的默认配置。
- 所有级别为
INFO、WARNING和SEVERE的消息都将显示到控制台上。因此,最好只将对程序用户有意义的消息设置为这几个级别。将程序员想要的日志消息设定为FINE级别是一个很好的选择。- 想要调用
System.out,println时,可以换成logger.fine()。
- 想要调用
Logger可以添加多个Handler用于处理日志输出(例如将日志输出到控制台、文件等不同的目标位置)。如果想要删除Handler,先通过getHandlers()方法获取到了当前Logger所关联的所有Handler数组,然后遍历这个数组,使用instanceof关键字来判断每个Handler是否是想要删除的类型(这里以ConsoleHandler为例,实际中可能是其他自定义或者标准的Handler类型),如果是符合条件的要删除的Handler,就调用removeHandler方法从Logger中移除它。
7.6 调试技巧
-
可以用下面的方法打印或记录任意变量的值:
System.out.println("x=" + x);Logger.getGlobal().info("x=" + x);
-
可以在每一个类中放置一个单独的
main方法。这样就可以提供一个单元测试桩,能够独立地测试类。 -
可以到
http://junit.org网站上查看JUnit单元测试框架。 -
日志代理。
-
利用
Throwable类的printStackTrace方法,可以从任意的异常对象获得堆栈轨迹。- 不一定要通过捕获来生成堆栈轨迹,只要在代码某个位置插入下面语句就可以获得堆栈轨迹:
Thread.dumpStack()
-
一般,堆栈轨迹显示在
System.err上。可以将它捕获到一个字符串中:-
var out = new StringWriter(); new Throwable().printStackTrace(new PrintWriter(out)); String description = out.toString();
-
-
通常,将程序错误记入一个文件会很有用。
-
使用静态方法
Thread.setDefaultUncaughtExceptionHandler改变未捕获异常的处理器。 -
使用
-verbose标志启动Java虚拟机,可以观察类的加载过程。 -
-Xlint选项告诉编译器找出常见的代码问题。
第8章 泛型程序设计
Java 5中泛型的引入称为Java程序设计语言自最初发行以来最显著的变化。Java的一个主要设计目标是支持与之前版本的兼容性。因此,泛型有一些让人不快的局限性。
8.1 为什么要使用泛型程序设计
8.1.1 类型参数的好处
最初,泛型程序设计是用继承实现的,ArrayList类只维护一个Object引用的数组。
-
当获取一个值时需要进行强制类型转换。
-
没有错误检查,可以向数组列表添加任何类的值。
类型参数可以让程序更易读,也更安全。
8.1.2 谁想成为泛型程序员
通配符类型:将一个ArrayList<Manager>中的所有元素添加到一个ArrayList<Employee>中去,反过来就不行。利用通配符类型,可以编写出尽可能灵活的方法。
经验来说,如果代码中原本涉及大量通用类型(如Object或Comparable接口)的强制类型转换,只有这些代码才会因使用类型参数而受益。
8.2 定义简单泛型类
泛型类就是有一个或多个类型变量的类。如下所示:
public class Pair<T> {
private T first;
private T second;
public Pair() { first = null; second = null; }
public Pair(T first, T second) { this.first = first; this.second = second; }
public T getFirst() { return first; }
public T getSecond() { return second; }
public void setFirst(T newValue) { first = newValue; }
public void setSecond(T newValue) { second = newValue; }
}
大写字母表示类型变量。
E表示集合,K,V表示键和值。T(必要时用相邻字母U和S)表示任意类型。
泛型类相当于普通类的工厂。
8.3 泛型方法
class ArrayAlg {
public static <T> T getMiddle(T... a){
return a[a.length / 2];
}
}
类型变量在修饰符的后面,并在返回类型的前面。具体调用时,可以把具体类型包围在尖括号中,放在方法名前面:
String middle = ArrayAlg.<String>getMiddle("Jone", "Q", "Public");
大多是情况下,可以省略尖括号,编译器有足够信息推断出想要的方法:
String middle = ArrayAlg.getMiddle("Jone", "Q", "Public");
8.4 类型变量的限定
考虑下面例子:
class ArrayAlg {
public static <T extends Comparable> Pair<T> minmax(T[] a) {
if (a == null || a.length == 0) return null;
T min = a[0];
T max = a[0];
for (int i = 1; i < a.length; i++) {
if (min.compareTo(a[i]) > 0) min = a[i];
if (max.compareTo(a[i]) < 0) max = a[i];
}
return new Pair<>(min, max);
}
}
变量min和max需要调用comparaTo方法,所以T所属的类必须为实现了Comparable接口。可以设置一个限定extends完成。
<T extends BoudingType>
表示T应该是限定类型的子类型。T和限定类型可以是类,也可以是接口。
一个类型变量或通配符可以有多个限定,如:
T extends Comparable & Serializable
限定类型用&分隔,而用逗号分隔类型变量。
8.5 泛型代码和虚拟机
虚拟机没有泛型类型对象,所有对象都属于普通类。
8.5.1 类型擦除
无论何时定义一个泛型类型,都会自动提供一个相应的原始类。这个原始类型的名字就是去掉类型参数后的泛型类型名。类型变量会被擦除,并替换为其限定类型(或者,对于无限定的变量则替换为Object)。例如Pair<T>的原始类型如下所示:
public class Pair {
private Object first;
private Object second;
public Pair() { first = null; second = null; }
public Pair(Object first, Object second) { this.first = first; this.second = second; }
public Object getFirst() { return first; }
public Object getSecond() { return second; }
public void setFirst(Object newValue) { first = newValue; }
public void setSecond(Object newValue) { second = newValue; }
}
原始类型用第一个限定来替换类型变量,如果没有给定限定,就替换为Object。上面的Pair就没有限定。
public class Interval<T extends Comparable & Serializable> implements Serializable
原类型Interval为:
public class Interval implements Serilizable{
private Comparable lower;
...
}
如果限定切换为:
public class Interval<T extends Serializable & Comparable> implements Serializable
原始类型会用Serializable替换T,而编译器在必要时向Comparable插入强制类型转换。
为了提高效率,应该将标签接口(即没有方法的接口)放在限定列表的末尾。
8.5.2 转换泛型表达式
编写一个泛型方法调用时,如果擦除了放回类型,编译器会插入强制类型转换,例如:
Pair<Employee> buddies = ...;
Employee buddy = buddies.getFirst();
getFirst擦除类型后的返回类型是Object。编译器自动插入转换到Employee的强制类型转换。
8.5.3 转换泛型方法
类型擦除也会出现在泛型方法中,方法的擦除会带来两个复杂问题。
-
类型擦除与多态性发生了冲突
-
为解决这个问题,编译器会生成一个桥方法:
-
public void setSecond(Object second) { setSecond(LocalData) second }
-
-
方法擦除后会生成两个
getSecond方法:-
LocalDate getSecond() Object getSecond() -
在虚拟机中,会由参数类型和返回来兴共同指定一个方法。虚拟机能正确处理这种情况。
-
8.5.4 调用遗留代码
由一个遗留类得到一个原始类型的对象,可以将它赋给一个类型使用了泛型的变量,但是会得到一个警告。使用注解让警告消失:
@SuppressWarnings("unchecked") //局部变量或者整个方法
8.6 限制与局限性
8.6.1 不能使用基本类型实例化类型参数
没有Paire<double>,只有Pair<Double>。
8.6.2 运行时类型查询只适用于原始类型
如下示例都是错误的:
if (a instanceof Pair<String>) // ERROR
if (a instanceof Pair<T>) // ERROR
//强制类型转换也有问题
Pair<String> p = (Pair<String>) a; //警告,无法判断a是一个Pair
同样的道理,getClass方法总是返回原始类型。
Pair<String> stringPair = ...;
Pair<Employee> employeePair = ...;
if (stringPair.getClass() == employeePair.getClass()) //相等
8.6.3 不能创建参数化类型数组
var table = new Pair<String>[10] //错误
擦除之后,table的类型时Pair[]。可以把它转换为Object[]:
Object[] objarray = table;
数组会记住它的元素类型,如果试图存储其他类型的元素,就会抛出一个ArrayStoreException异常:
objarray[0] = "Hello"; //错误
只是不允许创建这些数组,而声明类型为
Pair<String>[]的变量仍是合法的。不过不能用new Pair<String>[10]初始化这个变量。
ArrayList:ArrayList<Pair<String>>更安全、有效。
8.6.4 Varargs警告
public static <T> void addAll(Collection<T> coll, T... ts){
for (T t : ts) coll.add(t);
}
如下调用:
Collection<Pair<String>> table = ...;
Pair<String> pair1 = ...;
Pair<String> pair2 = ...;
addAll(table, pair1, pair2);
为了调用这个方法,Java虚拟机必须建立一个Pair<String>数组,这就违反了前面的规则。不过对于这种情况,规则有所放松,只会得到一个警告,而不是错误。可以用两种方法来抑制这个警告:
- 为包含
addAll调用的方法增加注解:@SuppressWarnings("unchecked")。 @SafeVarargs直接注解addAll方法。
8.6.5 不能实例化类型变量
不能在类似new T(...)的表达式中使用类型变量。
public Pair() { first = new T(); second = new T(); } //错误
Java 8 之后,最好的解决办法是让调用者提供一个构造器表达式:
Pair<String> p = Pair.makePair(String::new);
// makePair方法接收一个Supplier<T>,这是一个函数式接口
public static <T> Pair<T> makePair(Supplier<T> constr) {
return new Pair<>(constr.get(), constr.get());
}
比较传统的方法是实用反射调用Constructor.newInstance方法来构造泛型对象。
8.6.6 不能构造泛型数组
public static <T extends Comparable> T[] minmax(T... a) {
T[] mm = new T[2]; //错误
...
}
最好让用户提供一个数组构造器表达式:
String[] names = ArrayAlg.minmax(String[]::new, "Tom", "Dick", "Harry");
public static <T extends Comparable> T[] minmax(IntFunction<T[]> constr, T... a) {
T[] result = constr.apply(2);
...
}
比较老的方式是利用反射,并调用Array.newInstance。
8.6.7 泛型类的静态上下文中类型变量无效
public class Singleton<T> {
private static T singleInstance; //错误
private static T getSingleInstance() { //错误
...
}
}
8.6.8 不能抛出或捕获泛型类的实例
泛型类扩展Throwable甚至是不合法的。
public class Problem<T> extends Exception { ... } //错误
//catch子句中不能使用类型变量
catch(T e){ //错误
...
}
//异常规范中使用类型变量是允许的
public static <T extends Throwable> void d{
...
catch (Throwable realCause){
t.initCause(realCause);
throw t;
}
}
8.6.9 可以取消对检查型异常的检查
@SuppressWarnings("unckecked")
static <T extends Throwable> void throwAs(Throwable t) throws T{
throw (T) t;
}
假设这个方法包含在接口Task中,如果有一个检查型异常e,并调用:
Task.<RuntimeException>throwAs)(e);
编译器就会认为e是一个非检查型异常。
8.6.10 注意擦除后的冲突
倘若两个接口类型是同一接口的不同参数化,一个类或类型变量就不能同时作为这两个接口类型的子类。下面代码是非法的:
class Employee implements Comparable<Employee> {...}
class Manager extends Employee implements Comparable<Manager> {...} //错误
Manager会实现Comparable<Employee>和Comparable<Manager>,这是同一接口的不同参数化。
8.7 泛型类型的继承规则
Pair<Manager>不是Pair<Employee>的子类,两者没有关系。
泛型类可以扩展或实现其他的泛型类。如ArrayList<T>类实现了List<T>接口,则一个ArrayList<Manager>可以转换为一个List<Manager>。但一个ArrayList<Manager>不是一个ArrayList<Employee>或List<Employee>。
8.8 通配符类型
8.8.1 通配符概念
在通配符类型中,允许类型参数发生变化:
Pair<? extends Employee>
表示任何泛型Pair类型,它的类型参数是Employee的子类,如Pair<Manager>,但不是Pair<String>。
所以:
Pair<? extends Employee>是原始Pair的子类;Pair<Manager>和Pair<Employee>是Pair<? extends Employee>的子类。
子类限定不能调用修改器方法
setFirst(? extends Employee),编译器只知道需要某个子类型,但不知道具体是什么类型。它拒绝传递任何特定类型。毕竟?不能匹配。
访问器方法安全,但修改器方法不安全
8.8.2 通配符的超类限定
? super Manager
这个通配符限制为Manager的所有超类。
可以为方法提供参数,但不能使用返回值。因为调用
getFirst,不能保证返回对象的类型,只能把它赋给一个Object。
带有超类型限定的通配符允许写入一个泛型对象,而带有子类型限定的通配符允许读取一个泛型对象。
8.8.3 无限定通配符
类型Pair<?>有以下方法:
? getFirst()
void setFirst()
getFirst返回值只能赋给一个Object。setFirst不能被调用,甚至不能用Object调用。
但它对于许多简单操作非常有用。如,下面这个方法可用来测试一个对组是否包含一个null引用,它不需要实际的类型:
public static boolean hasNulls(Pair<?> p) {
return p.getFirst() == null || p.getSecond() == null;
}
通过将hasNulls转换成泛型方法,可以避免使用通配符类型:
public static <T> boolean hasNulls(Pair<T> p)
8.8.4 通配符捕获
下面编写一个方法来交换对组的元素:
public static void swap(Pair<?> p)
通配符不是类型变量,因此,不能在编写代码中使用?作为一种类型,也就是说下面代码是非法的:
? t = p.getFirst(); //错误
p.setFirst(p.getSecond());
p.setSecond(t);
解决方法:编写一个辅助方法swapHelper:
public static <T> void swapHelper(Pair<T> p) {
T t = p.getFirst();
p.setFirst(p.getSecond());
p.setSecond(t);
}
通配符捕获只有在非常限定的情况下才是合法的。
8.9 反射和泛型
反射允许在运行时分析任意对象。
8.9.1 泛型Class类
Class类是泛型的。例如,String.class实际上是一个Class<String>类的对象(事实上,是唯一对象)。
类型参数十分有用,Class<T>的以下方法就使用了类型参数:
T newInstance():返回这个类的一个实例。T cast(Object obj):如果给定对象的类型实际上是T =的一个子类型,cast方法就会返回这个给定对象(现在声明为T)。T[] getEnumConstants():如果这个了不是emun类或T类型枚举值的数组,返回null。Class<? super T> getSuperclass():返回这个类的超类,如果不是一个类或Object类,则返回null。Constructor<T> getConstructor(Class... parameterTypes)Constructor<T> getDeclaredConstructor(Class... parameterTypers)
8.9.2 使用Class<T> 参数进行类型匹配
匹配泛型方法中Class<T>参数的类型变量有时会很有用。下面是一个标准的示例:
public static <T> Pair<T> makePair(Class<T> c) throws InstantiationException, IllegalAccessException {
return new Pair<>(c.newInstance(), c.newInstance());
}
如果调用:
makePair(Employee.class)
Employee.class将是一个Class<Employee>类型的对象。makePair方法的类型参数T同Employee匹配,编译器可以推断出这个方法将返回一个Pair<Employee>对象。
8.9.3 虚拟机中的泛型类型信息
擦除的类仍然保留原先泛型的微弱记忆。例如,原始的Pair类知到它源自于泛型类Pair<T>,尽管一个Pair类型的对象无法区分它是构造为Pair<String>还是Pair<Employee>。
可以通过反射API重新构造实现者声明的泛型类和方法的所有有关内容。但是,不会知道对于特定的对象或方法调用会如何解析类型参数。
第五章当时有个错误:
Class<?> cl = Class.forName(name);
8.9.4 类型字面量
由值的类型决定程序的行为。
捕获Type接口的一个实例,然后构造一个匿名子类。
8.10 总结
8.10.1 谈谈你对泛型的理解
泛型是Java 5 引入的一个新特性,一个主要设计目的是保持与之前版本的兼容性。泛型可以使编写的代码对多种不同类型的对象重用,增强了程序的可读性和安全性。例如,在没有泛型之前,ArrayList类只维护一个Object引用数组,这存在两个问题:1. 当获取一个值时必须进行强制类型转换; 2. 可以向数组列表添加任何类的值,编译器没有错误检查。而使用类型参数,编译器就知道返回值的类型,不需要强制转换。另一方面,编译器可以检查,防止插入错误类型的对象。
8.10.2 泛型的使用方式?
- 泛型类。在类的声明中使用尖括号引入类型参数,在实例化时,使用具体的类替换类型变量。
- 泛型方法。类型变量放在修饰符后面,并在放回类型的前面。
- 泛型接口。类似于泛型类,在声明时引入类型参数,在接口的方法中使用类型参数。
8.10.3 介绍下泛型擦除
因为虚拟机中没有泛型,只有普通的类和方法,所以所有的类型参数都会替换为它们的限定类型(对于无限定的变量则替换为Object),这个过程称为擦除。由于方法的擦除与多态发生了冲突,编译器会合成桥方法来保持多态。另外为了保持类型安全性,在必要时会插入强制类型转换。
泛型擦除会带了一些局限性:
- 不能使用基本类型实例化类型参数。比如,没有
Pair<int>,只有Pair<Integer>。因为擦除后,Object不能存储基本类型。 - 不能实例化类型变量。比如,不能用
new T()表达式,因为擦除后就是new Object()。最好解决方法是让调用者提供一个构造器表达式。 - 不能构造泛型数组。同样也无法直接使用
T[] mm = new T[]。 - 不能使用带有类型变量的静态字段和方法。静态方法可以使用自己声明的类型变量。
- …
第9章 集合
9.1 Java集合框架
Java最初的版本只为最常用的数据结构提供了很少的一组类:Vector、Stack、Hashtable、BitSet与Enumeration接口,其中的Enumeration接口提供了一种用于访问任意容器中各个元素的抽象机制。
9.1.1 集合接口与实现分离
Java集合类库也将接口与实现分离。下面以队列(queue)来说明如何分离。
队列接口指出可以在队列的尾部添加元素,在队列的头部删除元素,并且可以查找队列中元素的个数。当需要收集对象,兵按照“先进先出”方式检索对象时就应该使用队列。队列接口的最简形式可能类似下面这样:
public interface Queue<E> {
void add(E element);
E remove();
int size();
}
队列有两种实现方式:
- 使用循环数组
- 使用链表
每一个实现都可以用一个实现了Queue接口的类表示:
public class CircularArrayQueue<E> implements Queue<E> {
private int head;
private int tail;
CircularArrayQueue(int capacity) {...}
public void add(E element) {...}
public E remove() {...}
public int size() {...}
private E[] elements;
}
public class LinkedListQueue<E> implements Queue<E> {...}
实际上,
Java类库没有这两个类,这里只是示例来解释集合接口与实现在概念上的区分。如果需要一个循环数组队列,可以使用ArrayDeque类。如果需要一个链表队列,就直接使用LinkedList类,这个类实现了Queue接口。
当在程序中使用队列时,一旦已经构造了集合,就不需要知到究竟使用了哪种实现。可以使用接口类型存放集合引用。
Queu<Customer> expressLane = new CircularArrayQueue<>(100);
expressLane.add(new Customer("Harry");
这样,一旦改变了想法,就可以轻松地使用另外一种不同的实现。只需对调用构造器的地方进行修改即可。
循环数组比链表更高效。但循环数组是一个有界集合,容量有限。如果程序中要收集的对象数量没有上限,就最好使用链表来实现。
如果想要实现自己的队列类,可以扩展
AbstractQueue。其他以Abstract开头的集合也如此。
9.1.2 Collection接口
Java类库中,集合类的基本接口是Collection接口。这个接口有两个基本方法:
public interface Collection<E> {
boolean add(E element);
Iterator<E> iterator();
...
}
add方法用于向集合中添加元素。如果添加元素的确改变了集合就返回true;如果集合没有发生改变就返回false。iterator方法用于返回要给实现了Iterator接口的对象。可以用这个迭代器对象依次访问集合中元素。
9.1.3 迭代器
Iterator接口有4个方法:
public interface Iterator<E> {
E next();
boolean hasNext();
void remove();
default void forEachRemaining(Consumer<? super E> action);
}
- 反复调用
next方法,可以逐个访问集合中的每个元素。如果达到了集合末尾,会抛出异常,所以需要先用hasNext方法判断。 hasNext方法判断是否还有可以访问的元素。
如果要遍历集合,可以请求一个迭代器,当hasNext返回true时就反复调用next方法。不过,用for each循环可以更加简练地表示同样的循环操作:
for (String element : c){
...
}
for each循环可以处理任何实现了Iterable接口的对象,这个接口只包含一个抽象方法:
public interface Iterable<E> {
Iterator<E> iterator();
...
}
Collection接口扩展了Iterable接口。因此,对于标准库中的任何集合都可以用for each循环。也可以不写循环,而调用forEachRemaining方法并提供一个lambda表达式(它会处理一个元素)。将对迭代器的每一个元素调用这个lambda表达式,直到没有元素为止:
iterator.forEachRemaining(element -> do something with element);
访问元素的顺序取决于集合类型。如处理ArrayList,迭代器索引从0开始,每迭代一次,索引值加1。不过对于HashSet中的元素,基本是一种随机的顺序访问元素。
Iterator接口的next和hasNext方法与Enumeration接口的nextElement和hasMoreElements方法的作用一样。因为设计者不喜欢这个接口累赘的方法名,于是引入了较短的方法名的新接口。
Java集合类库中的迭代器与其他类库中的迭代器在概念上有重要区别。传统集合类库中,迭代器是可以查找储存在指定位置上的元素,而Java只能通过next,而在执行查找操作的同时,迭代器的位置就会随之向前移动。
因此,可以认为Java迭代器位于两个元素之间。当调用next时,迭代器就越过下一个元素,并返回刚刚越过的那个元素的引用。
可以将
Iterator.next与InputStream.read看成等效的。从数据流中读取一个字节,就会自动“消耗掉”这个字节。
Iterator接口的remove方法将会删除上次调用next方法时返回的元素。例如,可以如下删除第一个元素:
Iterator<String> it = c.iterator();
i.next(); //跳过了第一个元素
it.remove(); //现在删除了第一元素
next方法和remove方法调用之间存在依赖性。如果调用remove之前,没有调用next,将是不合法的。
如果想删除两个相邻的元素,不能直接调用:
it.remove();
it.remove(); //错误
必须先调用next越过将要删除的元素:
it.remove();
it.next();
it.remove(); //正确
9.1.4 泛型使用方法
Collection接口声明了许多实用方法,但要实现该接口的没一个类都要提供如此多的例行方法,将很烦人!所以Java类库提供了一个AbstractCollection类,它保持基础方法size和iterator仍为抽象方法,其他都提供了实现。
Collection中有如下实用方法:
boolean isEmpty()
boolean contains(Object obj)
boolean containsAll(Collection<?> other)
boolean add(E element)
boolean addAll(Collection<? extends E> other)
boolean remove(Object obj)
...
//请看书P372
boolean retainAll(Collection<?> other)
//从这个集合中删除所有与other集合中元素不同的元素,求交集。如果改变了这个集合,返回true
default boolean removeIf(Predicate<? super E> filter)
//从这个集合中删除filter返回true的所有元素。如果改变了集合,返回true
9.2 集合框架中的接口
Java集合框架为不同类型的集合定义了大量接口,如图所示。
集合的两个基本接口:
Collection:插入元素add方法。读取用迭代器。Map:插入键值对put(K key, V value)方法。读取用get方法。
List是一个有序集合。元素会增加到特定位置,两种访问方式:
- 迭代器访问
- 索引访问(随机访问)
List接口定义了多个用于随机访问的方法:
void add(int index, E element)
void remove(int index)
E get(int index)
E set(int index, E element)
ListIterator接口是Iterator的一个子接口。它定义了一个方法用于在迭代器位置前面增加一个元素:
void add(E element)
Set接口等同于Collection接口,其add方法不允许添加重复的元素。
9.3 具体集合
下图展示了Java类库中的集合:
下表展示了每个集合类的用途。
| 集合类型 | 描述 |
|---|---|
ArrayList | 可以动态增长和缩减的一个索引序列 |
LinkedList | 可以在任何位置高效插入和删除的一个有序序列 |
ArrayDeque | 实现为循环数组的一个双端队列 |
HashSet | 没有重复元素的一个无序集合 |
TreeSet | 一个有序集 |
EnumSet | 一个包含枚举类型值的集 |
LinkedHashSet | 一个可以记住元素插入次序的集 |
PriorityQueue | 允许高效删除最小元素的一个集合 |
HashMap | 存储键 / 值关联的一个数据结构 |
TreeMap | 键有序的一个映射 |
EnumMap | 键属于枚举类型的一个映射 |
LinkedHashMap | 可以记住键 / 值项添加次序的一个映射 |
WeakHashMap | 值不会在别处使用时就可以被垃圾回收的一个映射 |
IdentityHashMap | 用 == 而不是用 equals 比较键的一个映射 |
9.3.1 链表
LinkedList是一个有序集合。通常需要添加元素到链表中间,由于迭代器描述了集合中的位置,所以这种依赖于位置的add方法由迭代器负责。子接口ListIterator提供了add方法:
interface ListIterator<E> extends Iterator<E> {
void add(E element); //在当前位置之前添加元素
void set(E newElement); //用新元素替换next或previous访问的上一个元素
E previous(); //反向遍历与next一样
boolean hasPrevious(); //反向遍历与hasNext一样
int nextIndex(); //返回下一次调用next方法时将返回的元素的索引
int previousIndex(); //返回下一次调用previous方法时将返回的元素的索引
...
}
LinkedList类的listIterator方法返回一个实现了ListIterator接口的迭代器对象。
链表的
get方法是“虚假”的随机访问方法,避免使用以整数索引表示链表中位置的所有方法。
9.3.2 数组列表
ArrayList类封装了一个动态再分配的对象数组。可以用get和set方法随机访问每个元素。
9.3.3 散列集
向查找某个元素,但是不知道位置。用散列集:可以快速地查找对象,不需要关注元素出现的次序。散列表为每个对象计算一个整数,称为散列码。
Java中,散列表用链表数组实现。每个列表称为“桶”。
public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, Serializable {
HashSet(); //构造一个空散列集
HashSet(Collection<? extends E> elements);
HashSet(int initialCapacity); //指定容量(桶数)
HashSet(int initialCapacity, float loadFactor); //指定容量和装填因子(0.0-1.0,散列表填充的百分比大于这个百分比时,散列表进行再散列。)
}
填充因子默认0.75,装满75%时,就会自动再散列,新表的桶数是原来的两倍。
散列集没有重复元素。只有不关心集合中元素的顺序时才应该用HashSet。
9.3.4 树集
TreeSet类与散列集十分类似。不过树集是一个有序集合。可以按任意顺序插入到集合中,在对集合遍历时,值将自动按照排序后的顺序呈现。
当前使用红黑树实现数据结构。
要使用树集,必须能够比较元素。这些元素必须实现
Comparable接口,或者构造树集时提供一个Comparator。
树的排序必须是全序。也即任意两个元素都必须是可比的,并且只有在两个元素相等时结果才为0。
TreeSet实现了NavigableSet接口。这个接口增加了几个查找元素以及反向遍历的便利方法。
public class TreeSet<E> extends AbstractSet<E> implements NavigableSet<E>, Cloneable, Serializable {
TreeSet(Comparator<? super E> comparator); //构造时提供一个比较器
TreeSet(SortedSet<E> s); //构造时提供一个有序集,使用同样的顺序
}
public interface NavigableSet<E> extends SortedSet<E> {
E higher(E e);
E lower(E e);
//返回大于value的最小元素或小于value的最大元素,没则返回null
E ceiling(E e);
E floor(E e);
//返回大于等于value的最小元素 或 小于等于value的最大元素,没则返回null
E pollFirst();
E pollLast();
//删除并返回最大或最小元素,为空时返回null
Iterator<E> descendingIterator();
//返回一个按照递减顺序遍历集中元素的迭代器
}
9.3.5 队列与双端队列
双端队列允许在头部和尾部都高效地添加或删除元素。
Deque接口,ArrayDeque和LinkedList都实现了这个接口。这两个类都可以提供双端队列。
ArrayDeque()
ArrayDeque(int numElements)
//用初始容量16或给定的初始容量构造一个无限定双端队列
9.3.6 优先队列
优先队列中的元素可以按照任意的顺序插入,但会按照有序的顺序进行检索。无论何时调用remove方法,总会获得当前优先队列中最小的元素。
使用了数据结构:堆(一种完全二叉树)。堆增加和删除都可以让最小的元素移动到根。
与TreeSet一样,优先队列既可以保存实现了Comparable接口的对象,也可以保存构造器中提供的Comparator对象。
优先队列的典型用法是任务调度。
public class PriorityQueue<E> extends AbstractQueue<E> implements Serializable{
PriorityQueue();
PriorityQueue(int initialCapacity);
//构造一个存放Comparable对象的优先队列
PriorityQueue(int initialCapacity, Comparator<? super E> comparator);
//构造一个优先队列,并使用指定的比较器对元素进行排序
}
9.4 映射
9.4.1 基本映射操作
两个通用实现:HashMap和TreeMap。都实现了Map接口。
public interface Map<K,V> {
V get(Object key);
//获取与键关联的值,没有则返回null
default V getOrDefault(Object key, V defaultValue);
//获取与键关联的值,没有找到这个键,则返回defaultValue
V put(K key, V value);
//将关联的一对键值放到映射中,如果键已经存在则替换旧值,返回旧值。之前没有这个键,则返回null
V remove(Object key);
//根据键移除映射
default void forEach(BiConsumer<? super K,? super V> action);
//对映射中的所有键值对应用这个动作,可以遍历映射
}
9.4.2 更新映射条目
常用的方法:
public interface Map<K,V> {
default V putIfAbsent(K key, V value);
//如果key不存在或者与null关联,则将它与Value关联,并返回null。否则返回键本来关联的值
default V merge(K key, V value, BiFunction<? super V,? super V,? extends V> remappingFunction);
//默认实现如下
/**
V oldValue = map.get(key);
V newValue = (oldValue == null) ? value :
remappingFunction.apply(oldValue, value);
if (newValue == null)
map.remove(key);
else
map.put(key, newValue);
*/
//如果key与一个非null值oldValue关联,则将函数应用到oldValue和value,将key与结果关联,或者如果结果为null,则删除这个键。
//否则,将key与value关联。
//counts.merge(word, 1, Integer::sum)表示把word与1对应,否则原值+1后与word关联。
}
9.4.3 映射视图
映射视图——这是实现了Collection接口或某个子接口的对象。有3种视图:
Set<k> keySet():键集Collection<V> values:值集Set<Map.Entry<K, V> entrySet():键值对集合
keySet()不是HashSet或TreeSet,而是实现了Set接口的另外某个类的对象。Set接口扩展了Collection接口,可以像任何集合一样使用keySet。
同时查看键和值:
for (Map.Entry<String, Employee> entry : staff.entrySet()) {
String k = entry.getKey();
Employee v = entry.getValue;
...
}
//不过现在只需要使用forEach方法
map.forEach((k, v) -> {
do something with k, v
});
- 不能向键集视图中添加元素。因为会同时在映射中添加这个键,但没添加值,所以是无意义的。
- 视图的删除操作也会影响映射的元素。
9.4.4 弱散列映射
WeekHashMap:当对键的唯一引用来自散列表映射条目是,这个数据结构将与垃圾回收期协同工作一起删除键值对。
9.4.5 链接散列集与映射
LinkedHashSet与LinkedHashMap类会记住插入元素项的顺序。
var staff = new LinkedHashMap<String, Employee>();
staff.put("144-25-5464", new Employee("Amy Lee"));
staff.put("567-24-2546", new Employee("Harry Hacker"));
staff.put("157-62-7935", new Employee("Gary Cooper"));
staff.put("456-62-5527", new Employee("Francesca Cruz"));
//然后,staff.keySet().iterator()以下面次序枚举键:
/**
144-25-5464
567-24-2546
157-62-7935
456-62-5527
*/
链接散列映射可以使用访问顺序而不是插入顺序来迭代处理映射条目。每次调用get或put时,受到影响的项将从当前的位置删除,并放到项链表的尾部(只影响项在链表中的位置,而散列表的桶不会受到影响。映射条目总是在键散列码对应的桶中)。
LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder)
//用给定容量、填充因子和顺序构造一个空的链接散列映射。accessOrder为true表示访问顺序,false表示插入顺序。
访问顺序对于实现缓存的“最近最少使用”原则十分重要。让删除过程自动化,可以构造的一个子类,并覆盖下面这个方法,能实现自己的移除规则。
//要想删除eldest元素,就要覆盖为返回true。eldest是预期可能删除的元素,这个方法在向映射中添加一个元素后自动调用。
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return size() > 100;
//最多存放100个元素,超过100就删除最老的元素
//也可以考虑eldest元素,来决定是否将它删除
}
9.4.6 枚举集与映射
EnumSet是一个枚举类型元素集的高效实现。内部用位序列实现。
EnumSet没有公共的构造器,要用静态工厂方法构造这个集。
enum Weekday{ MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY };
EnumSet<Weekday> always = EnumSet.allof(Weekday.class);
//返回一个包含给定枚举类型的所有值的可变集
EnumSet<Weekday> never = EnumSet.noneof(Weekday.class);
//返回一个初始为空的可变集
EnumSet<Weekday> workday = EnumSet.range(Weekday.MONDAY, Weekday.FRIDAY);
//返回一个包含范围的所有值的可变集
EnumSet<Weekday> mwf = EnumSet.of(Weekday.MONDAY);
//返回不包括null的给定元素的可变集
EnumMap是一个键类型为枚举类型的映射。它可以直接且高效地实现为一个值数组。需要在构造器中指定键类型。
var personInChange = new EnumMap<Weekday, Employee>(Weekday.class)
9.4.7 标识散列映射
IdentityHashMap类中,键的散列值不是用hashCode方法计算的,而是用System.identityHashCode方法计算的。这是Object.hashCode根据对象的内存地址计算散列码时所用的方法。而且,在对两个对象进行比较是,IdentityHashMap类使用==,而不是equals。
也即,不同的键对象即使内容相同,也被视为不同的对象。在实现对象遍历算法(如对象串行化)时,这个类非常有用,可以用来跟踪哪些对象已经遍历过。
IdentityHashMap(int expectedMaxSize);
//构造一个空的表示散列映射集,其容量是大于 1.5 * expectedMaxSize 的2的最小幂值(expectedMaxSize默认21)。
static int java.lang.System.identityHashCode(Object obj);
//返回Object.hashCode计算的相同散列码(根据对象内存地址得出),即使obj所属类已经重新定义了hashCode方法。
9.5 视图与包装器
keySet方法返回了一个实现了Set接口的;类对象,由这个类的方法操纵原映射。这种集合称为视图。
9.5.1 小集合
Java 9引入了一些静态方法,可以生成给定元素的集或列表,以及给定键/值对的映射。
List.of();
Set.of();
import static java.util.Map.entry;
Map<Integer,String> map = Map.ofEntries(
entry(1, "a"),
entry(2, "b"),
entry(3, "c"),
...
entry(26, "z")
);
of和ofEntries方法可以生成某些类的对象,这些集合对象是不可修改的,若想得到可修改的集合,可以将这个不可修改的集合传递到构造器:
var names = new ArrayList<>(List.of("Peter", "Paul", "Mary"));
Collections.ncopies(n, anObject)会返回一个实现了List接口的不可变的对象。
Java中没有
Pair类,现在可以调用Map.entry(first, second)作为对组。
9.5.2 子范围
可以为集合建立子范围视图,如:
List<Employee> group2 = staff.subList(10, 20);
//左闭右开,可以对子范围应用任何操作,操作都会自动反映到整个列表
SortedSet<E> subSet(E from, E to);
SortedSet<E> headSet(E to);
SortedSet<E> taiSet(E from);
//将返回大于等于from 且小于to的所有元素构成的子集。
SortedMap<K, V> subMap(K from, K to);
SortedMap<K, V> headMap(K to);
SortedMap<K, V> tailMap(K from);
//返回映射视图
NavigableSet<E> subSet(E fromElement, boolean fromInclusive, E toElement, boolean toInclusive);
NavigableSet<E> headSet(E toElement, boolean inclusive);
NavigableSet<E> tailSet(E fromElement, boolean inclusive);
//NavigableSet允许更多地控制子范围操作,例如指定是否包括边界
9.5.3 不可修改的视图
不可修改视图对现有集合增加了一个运行时检查。
可以使用下面8个方法来获得不可修改视图:
Collections.unmodifiableCollection
Collections.unmodifiableList
Collections.unmodifiableSet
Collections.unmodifiableSortedSet
Collections.unmodifiableNavigableSet
Collections.unmodifiableMap
Collections.unmodifiableSortedMap
Collections.unmodifiableNavigableMap
例如,想要让某些代码查看但不能修改一个集合的内容,就可以如下操作:
var staff = new LinkedList<String>();
...
lookAt(Collecions.unmodifiableList(staff));
视图只包装了接口而不是具体的集合对象,所以只能访问接口中定义的方法,如List接口中的方法,而不能访问ArrayList类中的方法。
9.5.4 同步视图
Collections.synchronizedMap方法可以将任何一个映射转换成有同步访问方法的Map,每个方法调用必须完全结束,另一个线程才能调用另一个方法。
9.5.5 检查型视图
“检查型”视图用来对泛型类型可能出现的问题提供调试支持。例如下面代码:
var strings = new ArrayList<String>();
ArrayList rawList = strings; //仅仅警告,能编译
rawList.add(new Date()); //现在包含了一个错误的对象
这个错误的add命令在运行时检测不到。只有在另一部分代码调用get时,并强制转换为String时,才会出现一个类强制转换异常。
检查型视图可以探测这类问题。下面定义一个安全列表:
List<String> safeStrings = Collections.checkedList(strings, String.class);
ArrayList rawList = safeStrings;
rawList.add(new Date()); //抛出检查型异常
9.5.6 关于可选操作的说明
//Collections中
static <E> List<E> singletoList(E value);
static <E> Set<E> singleton(E value);
static <K, V> Map<K, V> singletonMap(K key, V value);
//生成一个单例列表、集或映射。在Java 9 中要使用相应的of方法。
9.6 算法
9.6.1 为什么要使用泛型算法
考虑一个计算集合中最大元素的算法。对于数组使用索引访问元素,对于数组列表使用get访问元素,会优点区别。但如果是链表呢?链表没有高效的随机访问操作,但是可以使用迭代器。
但是这项任务并不需要随机访问,可以直接迭代处理元素来得出最大元素,因此可以将max方法实现为能够接收任何实现了Collection接口的对象。
public static <T extends Comparable> T max(Collection<T> c) {
if(c.isEmpty()) throw new NoSuchElementException();
Iterator<T> iter = c.iterator();
T target = iter.next();
while(iter.hasNext()) {
T next = iter.next();
if(target.compareTo(next) < 0) {
target = next;
}
}
return target;
}
现在就可以使用一个方法计算链表、数组列表或数组中的最大元素了。
9.6.2 排序与混排
Collection类中的sort可以对实现了List接口的集合进行排序:
var staff = new LinkedList<String>();
...
Collections.sort(staff)
也可以使用List接口的sort方法并传入一个Comparator对象。下面按工资排序:
staff.sort(Comparator.comparingDouble(Employee::getSalary));
逆序排序:
-
Collection.reverseOrder():返回一个比较器,比较器则返回b.comparaTo(a);-
Arrays.sort(a, Collections.reverseOrder());
-
-
Comparator.reverseOrder()也如此;-
staff.sort(Comparator.reverseOrder());
-
-
或者使用
reversed();-
staff.sort(Comparator.comparingDouble(Employee::getSalary).reverse())
-
集合类库的排序算法要比快速排序慢一些,快速排序是通用排序算法的传统选择。
排序传递的列表必须是可修改的,但不一定可以改变大小。
- 列表支持
set方法,则是可修改的。 - 列表支持
add和remove方法,则是可改变大小的。
Collections.shuffe与排序刚搞相反,随机地混排列表中元素顺序:
Collections.shuffe(staff)
9.6.3 二分查找
i = Collections.binarySearch(c, element);
i = Collections.binarySearch(c, element, comparator);
- 集合必须是有序的,否则返回错误答案。
- 集合必须实现
List接口。 - 结合没有用
compareTo方法进行排序,则还需提供一个Comparator对象。 - 只有采用随机访问,二分查找才有意义。
返回值:
- 非负值,表示匹配对象的索引:
c.get(i)可以得到与element相等的这个元素。 - 负值,表示没有匹配的元素。不过可以将该元素插入到合适的位置
-i -1,以保持集合的有序性。
复杂度:
O(a(n)logn)。n是列表长度,a(n)是访问一个元素的平均时间。
9.6.4 简单算法
Collections类中包含很多简单算法,如:
- 查找集合中的最大元素;
- 将一个列表中的元素复制到另一个列表中;
- 用一个常量值填充容器;
- 逆置一个列表的元素顺序。
默认的Collection.removeIf和List.replaceAll稍有些复杂。要提供一个lambda表达式来测试或转换元素。如:
words.removeIf(w -> w.length() <= 3);
words.replaceAll(String::toLowerCase);
下面是Collections中的常用算法:
T min/max(Collection<T> elements)
T min/max(Collection<T> elements, Comparator<? super T> c)
void copy(List<? super T> l, T value)
boolean addALL(Collection<? super T> c, T... values)
boolean repalceAll(List<T> l, T oldValue, T newValue)
int indexOfSubList(List<?> l, List<?> s)
int lastIndexOfSubList(List<?> l, List<?> s)
//返回l中第一个或最后一个等于s的子列表的索引。如果不存在则返回-1。
void swap(List<?> l, int i, int j)
void reverse(List<?> l)
//时间复杂度为O(n)
void rotate(List<?> l, int d)
//旋转列表中的索引,将索引i的元素移动到位置(i + d) % l.size()。例如,[t, a, r]旋转2个位置后得到[a, r, t]。复杂度为O(n)
int frequency(Collection<?> c, Object o)
//返回c中与o相等的个数
boolean disjoint(Collection<?> cl, Collection<?> c2)
//如果两个集合都没有共同元素,则返回true。
9.6.5 批操作
很多操作都会成批出现,有了批操作就无需编写循环。
-
coll1.removeAll(coll2)- 将从
coll1中删除coll2中出现的所有元素。相当于求补集。
- 将从
-
coll1.retainAll(coll2)- 将从
coll1中删除所有未在coll2中出现的元素。相当于求交集。
- 将从
-
可以对视图应用批操作:
Map<String, Employee> staff = ...; Set<String> terminatedIDs = ...; //只需建立一个键集,并删除终止聘用关系的所有员工的ID。 staff.keySet().removeAll(terminatedIDs);
9.6.6 集合与数组的转换
从数组转换为集合,List.of包装器:
String[] values = ...;
var staff = new HashSet<>(List.of(values));
从集合到数组困难一些:
Object[] values = staff.toArray();
//这样做,不能使用强制类型转换
String[] values = staff.toArray(new String[0]);
//提供一个指定类型且长度为0的数组,返回的数组就会创建为相同的数组类型
staff.toArray(new String[staff.size()]);
//也可以提供一个大小正确的数组,不过不会创建新数组,推荐上面做法
9.6.7 编写自己的算法
应该尽可能地使用接口,而不要使用具体的实现(例如,方法参数)。
问自己完成这项工作的最通用的接口是什么?关心顺序吗?如果顺序很重要,就应当接受
List。如果顺序不重要,就可以接受任意类型的集合。
方法返回也尽可能返回一个接口,而不是具体类。
9.7 遗留的集合
在集合框架出现之前已经存在大量“遗留的”容器类。这些类已经集成到集合框架中。
9.7.1 Hashtable类
经典的Hashtable类与HashMap类的作用一样,实际上,接口也基本相同。与Vector类的方法一样,Hashtable方法也是同步的。如果对遗留代码的兼容性没有任何要求,就应该使用HashMap。如果需要并发访问,则需要使用ConcurrentHashMap。
9.7.2 枚举
遗留的集合使用Enumeration接口遍历元素序列。有两个方法hasMoreElements和nextElement,与迭代器的两个方法一样。
该类中有asIeterator方法,可以将一个枚举转换为一个迭代器,进而迭代处理枚举的元素。
9.7.3 属性映射
属性映射是一个特殊类型的映射结构,有3个特性:
- 键与值都是字符串。
- 这个映射可以很容易地保存到文件以及从文件加载。
- 有一个二级表存放默认值。
实现属性映射的类名为Properties。属性映射对于指定程序的配置选项很有用。例如:
var settings = new Properties();
settings.setProperties("width", "600");
settings.setProperties("fileName", "path");
//可以使用store方法将属性映射列表保存到一个文件中。
var out = new FileOutputStream("program.properties");
settings.store(out, "包含在文件中的注释")
//从文件中加载属性
var in = new FileInputStream("program.properties");
settings.load(in);
//获取属性值
settings.getProperty("width");
//设置属性值
settings.setProperty("width", "900")
System.getProperties方法会生成Properties对象来描述系统信息。例如:
//得到主目录
String userDir = System.getProperty("user.home");
//返回 "/home/luke"
//得到java虚拟机版本,查找"java.version"属性
String version = System.getProperty("java.version");
//返回"21.0.5"
//获取系统所有属性
System.getProperties();
//返回一个Properties对象
用一组默认值创建一个空属性映射:
var settings = new Properties(Properties defaults)
9.7.4 栈
Stack类,有三个常用方法:
E push(E item): 入栈,并返回值。E pop():出栈并返回栈顶元素。如果栈为空,不要调用。E peek():返回栈顶元素,但不弹出。栈为空,则不要调用。
9.7.5 位集
BitSet类用于存储一个位序列(不是数学上的集,称为位向量或位数组更合适)。如果需要高效存储位序列(如标志),就可以使用位集。由于位集将位包装在字节里,所以使用位集要比使用Boolean对象的ArrayList高效得多。
如一个名为bucketOfBits的BistSet,
bucketOfBits.get(i);
//如果第i位处于“开”,返回true;否则返回false。
bucketOfBits.set(i);
//将第i位置为“开”状态
bucketOfBits.clear(i);
//将第i位置为“关”状态
常用API:
int length():返回位集的“逻辑长度”,即1加上位集的最高位的索引。void and(BitSet set):这个位集与另一个位集进行逻辑“与”。void or(BitSet set):这个位集与另一个位集进行逻辑“或“。void xor(BitSet set):这个位集与另一个位集进行逻辑“异或“。void andNot(BitSet set):对应另一个位集中设置为1的所有位,将这个位集中相应的位清除为0。
第12章 并发
多进程与多线程区别:
- 本质区别:每个进程都拥有自己的一整套变量,而线程则共享数据。
- 与进程相比较,线程更“轻量级”,创建、撤销一个线程比启动新进程的开销要小得多。
12.1 什么是线程
在一个单独的线程中运行一个任务的简单过程:
- 将执行的任务放在
Runnable中的run方法中,可以直接用lambda表达式。 - 从
Runnable构造一个Thread对象:var t = new Thread(r)。 - 启动线程:
t.start()。
还可以通过建立
Thread类的一个子类来定义线程。任务放在run方法中,然后调用对象的start方法。
不要直接调用
Thread类或Runnable对象的run方法。直接调用只会在同一个线程中执行这个任务——而没有启动新的线程。
//java.lang.Thread
Thread(Runnable task)
//构造一个新线程,调用指定目标的run()方法
void start()
//启动这个线程,从而调用run()方法。这个方法会立即返回,新线程会并发执行。
void run()
//调用相关Runnable的run方法
static void sleep(long millis)
//休眠指定的毫秒数
12.2 线程状态
有6种状态:
New(新建)Runnable(可运行)Blocked(阻塞)Waiting(等待)Timed waiting(计时等待)Terminated(终止)
要确定也给线程当前的状态,只需要调用getState方法。
12.2.1 新建线程
new Thread(r)新建线程,还没开始运行。这意味着它的状态时新建。在运行之前还有一个基础工作要做。
12.2.2 可运行线程
一旦调用start方法,线程就处于可运行状态。一个可运行的线程可能正在运行也可能没有运行。要有操作系统为线程提供具体的运行时间。
现在所有的桌面以及服务器操作系统都使用抢占式调度(调度系统给每一个可运行线程一个时间片来执行任务。当时间片用完时,操作系统剥夺该线程的运行权,并交给另一个线程一个机会来运行)。但是像手机这样的小型设备可能适用协作式调度。
static void yield()
//使当前正在执行的线程向另一个线程交出运行权。这是Thread类的一个静态方法
12.2.3 阻塞和等待线程
当线程处于阻塞或等待状态时,它暂时时不活动的。不运行任何代码,且消耗最少的资源。要由调度器重新激活这个线程。具体细节取决于它是怎样到达非活动状态的。
- 当一个线程试图获取一个内部的对象锁(而不是
java.util,concurrent库种的Lock),而这个锁目前被其他线程占有,该线程就会被阻塞。当其他线程都释放了这个锁,并且线程调度器允许该线程持有这个锁时,它将变成非阻塞状态。 - 当线程等待另一个线程通知调度器出现一个条件时,这个线程会进入等待状态。调用
Object.wait或Thread.join方法,或是等待java.util.concurrent库中的Lock或Condition时,就会出现这种情况。实际上,阻塞状态与等待状态并没有太大区别。 - 有几个方法有超时参数,调用这些方法会让线程进入计时等待状态。这一状态将一致保持到超时期满或者接收到适当的通知。带有超时参数的方法有:
Thread.sleep和计时版的Object.wait、Thread.join、Lock.tryLock以及Condition.await。
当一个线程被重新激活(例如,因为超时期满或成功地获得了一个锁),调度器检查它是否具有比当前运行线程更高的优先级。如果有,调度器就会剥夺某个当前运行线程的运行权,选择一个新线程运行。

12.2.4 终止线程
线程会由于以下两个原因之一而终止:
run方法正常退出,线程自然终止。- 因为一个没有捕获的异常终止了
run方法,使线程意外终止。
可以调用线程的stop方法杀死一个线程,该方法抛出一个ThreadDeath错误对象,这会杀死线程。不过,stop方法已经废弃,不要在自己的代码中调用这个方法。
//java.lang.Thread
void join()
//等待终止指定的线程
void join(long mills)
//等待指定的线程终止或等待经过指定的毫秒数
Thread.State getState()
//获取线程的状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED
void stop()
//停止该线程。已废弃
void suspend()
//暂停这个线程的执。已废弃
void resume()
//恢复线程。只能在调用suspend()之后使用。已废弃。
12.3 线程属性
中断的状态、守护线程、未捕获异常的处理器、不应使用的一些遗留特性。
12.3.1 中断线程
除了已经废弃的stop方法,没有办法可以强制线程终止。不过interrupt方法可以用来请求终止一个线程。
- 调用
interrupt时,会设置线程的中断状态。 - 得出是否设置了中断状态:
Thread.currentThread().isInterrupted()。 - 如果线程被阻塞,就无法检查中断状态。但这时若调用了
interrupt,会被一个InterruptedException异常中断。
线程的run方法如下所示。
Runnable r = () -> {
try {
...
while(!Thread.currentThread().isInterrupted() && more work to do){
//TODO do more work
}
}catch (InterruptedException e) {
//线程在sleep或者wait被中断
} finnally {
cleanup, if required
}
//TODO
};
- 如果在每次工作迭代之后都调用了
sleep方法(或者其他可中断方法),isInterrupted检查既没有必要也没有用处。 - 如果设置了中断状态,此时倘若调用了
sleep方法,它不会休眠。它会清除中断状态,并抛出InterruptedException异常。 - 因此,如果循环调用了
sleep,不要监测中断状态,而应当捕获InterruptedException异常。
Runnable r = () -> {
try {
...
while(more work to do){
//TODO do more work
Thread.sleep(delay);
}
}catch (InterruptedException e) {
//线程在sleep或者wait被中断
} finnally {
cleanup, if required
}
//TODO
};
不要在catch中抑制InterruptedException异常,可以如下做法:
-
catch (InterruptedException e) { Thread.currentThread().interrupt(); } -
void mySubTask() throws InterruptedException { ... sleep(delay); ... }
//java.lang.Thread
void interrupt()
//向线程发送中断请求。中断状态被设置为true。若线程被一个sleep调用阻塞,则抛出InterruptedException
static boolean interrupted()
//测试当前线程是否被中断。副作用——它将当前线程的中断状态重置为false
boolean isInterrupted()
//测试线程是否被中断。这个方法不改变线程的中断状态
static Thread currentThread()
//返回当前正在执行的线程的Thread对象
12.3.2 守护线程
可以调用t.setDaemon将一个线程转换为守护。守护线程的唯一用途是为其他线程提供服务。
- 计时器线程
- 清空过时缓存项线程
当只剩下守护线程时,虚拟机就会退出。
void setDaemon(boolean isDaemon)
12.3.3 线程名
默认为Thread-*,可以更改线程名,在线程转储时可能很有用。
t.setName("Web craawler");
12.3.4 未捕获异常的处理器
线程的run方法不能捕获任何检查型异常,但是非检查型异常可能会导致线程终止。线程在死亡之前,异常会传递到一个用于处理未捕获异常的处理器。这个处理器必须属于一个实现了Thread.UncaughtExceptionHandler接口的类。这个接口只有一个方法:
void uncaughtException(Thread t, Throwable e)
可以用setUncaughtExceptionHandler方法为任何线程安装一个处理器。也可以用Thread类的静态方法setDefaultUncaughtExceptionHandler为所有线程安装也给默认的处理器。替代处理器可以使用日志API将未捕获异常的报告发送到一个日志文件。
如果没有安装默认处理器,默认处理器就是null。但是,如果没有为单个线程安装处理器,那么处理器就是该线程的ThreadGroup对象。
12.3.5 线程优先级
默认情况下,一个线程会继承构造它的那个线程的优先级。可以用setPriority方法提高或降低一个线程的优先级。可以设置为MIN_PRIORITY与MAX_PRIORITY之间的任何值。NORM_PRIORITY定义为5。
线程优先级高度依赖于系统。现在不要使用线程优先级了。
12.4 同步
两个或两个以上啊的线程需要共享对同一数据的存取。这种情况成为竟态条件。
12.4.1 竟态条件的一个例子
为了避免多线程破坏共享数据,必须学习如何同步存取。
银行转账是一个经典的例子。
12.4.2 竟态条件详解
假设有两个线程同时执行指令:
accounts[to] += amount;
这个指令不是原子操作,可能如下处理:
- 将
accounts[to]加到寄存器; - 增加
amount; - 将结果写回
accounts[to]。
假定第1各线程执行步骤1和2,然后,它的运行权被第2个线程抢占,更新account数组中的同一个元素。然后第1个线程被唤醒并完成其第3步更新。
这个动作会抹去第2个线程所做的更新。这样,金额就不正确了。
12.4.3 锁对象
两种机制可防止并发访问代码块:
synchronized关键字;ReentrantLock类。
用ReentrantLock保护代码块的基本结构如下:
myLock.lock(); //一个ReentrantLock对象
try {
//临界区
} finally {
myLock.unlock(); //至关重要,如果临界区抛出异常,锁必须释放,否则,其他线程将永远阻塞。
}
这个结构确保任何时刻只有一个线程进入临界区。一旦一个线程锁定了对象,其他任何线程都无法通过lock语句。
不能使用
try-with-resources语句。首先,解锁方法名不是close。即使重命名,也不能正常工作,它的首部希望声明一个新变量。但使用锁,是希望多个线程共享一个变量。
这个锁称为重入锁,因为线程可以返回获得已拥有的锁。锁有一个持有计数来跟踪对lock方法的嵌套调用。线程每调用一次lock后都要调用unlock来释放锁。
//java.util.concurrent.locks.Lock
void lock()
//获得这个锁;如果被占有,则阻塞
void unlock()
//释放这个锁
//java.util.concurrent.locks.ReentrantLock
ReentrantLock()
//构造一个重入锁
ReentrantLock(boolean fair)
//构造一个采用公平策略的锁。一个公平锁倾向于等待时间最长的线程,不过严重影响性能。默认不要求公平。
12.4.4 条件对象
线程进入临界区后却发现只有满足了某个条件后才能执行,可以使用一个条件对象来管理那些已经获得了一个锁却不能做有用工作的线程。以银行转账是资金不足为例子。
一个锁对象可以有一个或多个相关联的条件对象,可以用newCondition方法获得一个条件对象。
class Bank {
private Condition sufficientFunds;
public Bank () {
...
sufficientFunds = bankLock.newCondition(); //返回一个与这个锁相关联的条件对象
}
public void transfer(int from, int to, int amount) {
bankLock.lock();
try{
while(accounts[from] < amount)
sufficientFunds.await();
//转账
//TODO
sufficientFunds.signalAll();
}finally{
bankLock.unlock();
}
}
}
当transfer方法发现资金不足,它会调用sufficientFunds.await方法。当前线程现在暂停,并放弃锁。
等待获得锁的线程和已经调用了await方法的线程存在本质上的不同。一旦一个线程调用了await方法,它就进入这个条件的等待集。当锁可用时,该线程并不会变为可运行状态。实际上,它仍保持非活动状态,直到另一个线程在同一条件上调用signalAll方法。
signalAll调用不会立即激活一个等待线程,它只是解除等待线程的阻塞,使这些线程可以在当前线程释放锁之后竞争访问对象。
另一个方法signal只是随机选择等待集中的一个线程,并解除这个线程的阻塞状态。
12.4.5 synchronized关键字
对锁和条件的要点总结:
- 锁用来保护代码片段,一次只能有一个线程执行被保护的代码。
- 锁可以管理试图进入被保护代码段的线程。
- 一个锁可以有一个或多个相关联的条件对象。
- 每个条件对象管理哪些已经进入被保护代码段但还不能运行的线程。
Lock和Condition允许程序员充分控制锁定。从1.0开始,Java中每个对象都有一个内部锁。如果一个方法声明时有synchronized关键字,那么对象的锁将保护整个方法。
所以
public synchronized void method(){
//method body
}
等价于
public void method(){
this.intrinsicLock.lock();
try{
//method body
}finally{
this,intrinsicLock.unlock();
}
}
内部对象锁只有一个关联条件,wait方法将一个线程增加到等待集中,notifyAll/notify方法可以解除等待线程的阻塞。
wait、notifyAll、notify时Object类的final方法。
例如可以如下实现Bank类的方法:
public synchronized void transfer(int from, int to, double amount) throws InterruptedException{
while (accounts[from] < amount)
wait(); //发通知,等地啊
System.out.print(Thread.currentThread());
accounts[from] -= amount;
System.out.printf(" %10.2f from %d to %d", amount, from, to);
accounts[to] += amount;
System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
notifyAll(); //通知等待线程
}
将静态方法声明为同步也是合法的。如果调用这样的方法,它会获得相关类对象的内部锁。
内部锁和条件存在一些限制:
- 不能中断一个正在尝试获得锁的线程。
- 不能指定尝试获得锁时的超时时间。
- 每个锁仅有一个条件可能是不够的。
建议最好既不使用
Lock/Condition也不使用synchronized关键字。在许多情况下,可以使用java.util.concurrent包中的某种机制,它会处理所有的锁定。
12.4.6 同步块
线程还有另一种机制可以获得锁:即进入一个同步块。当线程进入如下形式的块时,它会获得obj的锁。
synchronized (obj) {
//临界区
}
有一些“专用锁”:
synchronized(lock){ //辅助锁
accounts[from] -= amount;
accounts[to] += amount;
}
使用一个对象的锁来实现额外的原子操作,称为客户端锁定。
客户端锁定非常脆弱,不推荐使用
12.4.7 监视器概念
不用显示锁就可以保证线程的安全性。监视器特性:
- 监视器是指包含私有字段的类。
- 监视器类的每个对象都有一个关联的锁。
- 所有方法由这个锁锁定。
- 锁可以由任意多个相关联的条件。
Java设计者以不太严格的方式采用了监视器概念,Java中的每一个对象都有一个内部锁和一个内部条件。
Java对象在以下3个方面不同于监视器:
- 字段不要求是
private。 - 方法不要求是
synchronized。 - 内部锁对客户是可用的。
12.4.8 volatile 字段
有时,仅为读写一两个实例字段而使用同步,带来的开销好像有些划不来。
volatile关键字为实例字段的同步访问提供了一种免锁机制。如果声明一个字段为volatile,那么编译器和虚拟机就知道该字段可能被另一个线程并发更新。
下面的情况,就很合适:
private volatile boolean done;
public boolean isDone() { return done; }
public void setDone() { done = true; }
编译器会插入适当的代码,以确保如果一个线程对done变量做了修改,这个修改对读取这个变量的所有其他线程都可见。
volatile变量不能提供原子性。
12.4.9 final 变量
将字段声明为final时,可以安全地访问一个共享字段。如:
final var accounts = new HashMap<String, Double>();
其他线程会在构造器完成构造之后才能看到这个accounts变量。如果不使用,则其他线程看到的可能是null。
12.4.10 原子性
假设对共享变量除了赋值之外并不做其他操作,那么可以将这些共享变量声明为volatile。
java.util.concurrent.atomic包中有很多类使用了很高效的机器指令(而没有用锁)来保证其他操作的原子性。如AtomicInteger类提供了方法incrementAndGet和decrementAndGet,它们分别以原子方式将一个整数进行自增或自减。例如,可以安全地生成一个数值序列:
public static AtomicLong nextNumber = new AtomicLong();
//在某些线程
long id = nextNumber.incrementAndGet();
还有很多其他方法,以及提供lambda表达式的方法。
如果有带量线程要访问相同的原子值,性能就会大幅下降,因为乐观更新需要太多次重试。LongAdder和LongAccumulator类解决了这个问题。此时就不要使用AtomicLong了。
var adder = new LongAdder();
for(...)
...
if(...) adder.increment();
...
long total = adder.sum();
12.4.11 死锁
所有线程都被阻塞称为死锁。
signal调用很容易引起死锁。它只为一个线程解除阻塞,而且,它很可能选择一个根本不能继续运行的线程。
12.4.12 线程局部变量
有时需要避免共享变量,使用ThreadLocal辅助类为各个线程提供各自的实例。为每个线程构造一个实例,可以使用下面代码:
public static final ThreadLocal<SimpleDateFormat> dateFormat
= ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
要访问具体的格式化方法,可以调用:
String dateStamp = dateFormat.get().format(new Date())
//原先是dateFormat.format(new Date())
在一个给定线程首次调用get时,会调用构造器中的lambda表达式。在此之后,get方法会返回属于当前线程的那个实例。
在多个线程中生成随机数也存在类似的问题。不过Java 7 提供了另一个便利类:
int random = ThreadLocalRandom.current().nextInt(upperBound);
ThreadLocalRandom.current()调用会返回特定于当前线程的Random类的实例。
//java.lang.ThreadLocal<T>
T get()
//得到这个线程的当前值。首次调用,则调用initialize来得到这个值
void set(T t)
//为这个线程设置一个新值
void remove()
//删除对应这个线程的值
static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier)
//创建一个线程局部变量,其初始值通过调用给定的提供者生成
12.4.13 为什么废弃stop和suspend方法
两个方法的共同点:都试图控制一个给定线程的行为,而没有线程的互操作。
stop方法天生就是不安全的,suspend方法经常会导致死锁。
- 假设转账时,前已经转出,但还未存入目标账户,此时线程被终止,对象就被破坏了。
- 如果
suspend挂起一个持有锁的线程,那么,在线程恢复运行之前是不可用的。如果调用suspend方法的线程试图获得同一个锁,那么程序死锁。
12.5 线程安全的集合
12.5.1 阻塞队列
很多线程问题可以使用一个或多个队列以优雅而安全的方式来描述。生产者线程向队列插入元素,消费者线程则获取元素。
BlockingQueue阻塞队列方法分为以下3类,这取决于当队列满或空时向他们完成的动作。
- 如果使用队列作为线程管理工具,将用到
put和take方法。 - 当试图向满队列添加元素或者想从空队列得到对头元素时,
add、remove和element操作会抛出异常。 - 在一个多线程程序中,队列可能会在任何时候变空或变满,因此,应当使用
offer、poll和peek方法作为替代。
还有带有超时时间的offer方法和poll方法。
队列满,put方法阻塞;队列空,take方法阻塞。
java.util.concurrent包提供了阻塞队列的几个变体:
-
默认情况,
LinkedBlockingQueue的容量没有上界,但也可指定。是一个双端队列。队列实现为一个链表。 -
ArrayBlockingQueue在构造时需要指定容量,可选参数指定是否需要公平性。队列实现为一个循环数组。 -
PriorityBlockingQueue是一个优先队列。容量没有上限。实现为一个堆。 -
DelayQueue包含实现了Delayed接口的对象:interface Delay extends Comparable<Delayed> { long getDelay(TimeUnit unit); }getDelay方法返回对象的剩余延迟。负值表示延迟已经结束。元素只有在延迟结束的情况下才能从DelayQueue移除。还需要实现compareTo方法。
12.5.2 高效的映射、集和队列
java.util.concurrent包提供了映射、有序集和队列的高效实现:ConcurrentHashMap、ConcurrentSkipListMap、ConcurrentSkipListSet和ConCurrentLinkedQueue。
- 这些类的
size方法不一定在常量时间内完成操作。 - 集合返回弱一致性的迭代器。
- 并发散列映射可以高效地支持大量阅读器和一定数量的书写器。默认情况下认为可以有至多16个同时运行的书写器线程。当然可以更多,但多于16个,其他线程将被阻塞。
较新版的Java版本中,并发散列映射将桶组织为树,而不是列表,键类型实现
Comparable,从而可以保证性能为O(log(n))。
12.5.3 映射条目的原子更新
ConcurrentHashMap在新版本中提供了一些新方法,可以很方便地完成原子更新操作。调用compute方法时,可以提供一个键和一个计算新值的函数。这个函数接收键和相关联的值(没有值,则为null),它会计算新值:
map.compute(word, (k, v) -> v == null ? 1 : v + 1);
ConcurrentHashMap和HashMap中的方法都差不多,与集合一章共同理解。
12.5.4 对并发散列映射的批操作
search(搜索)为每个键或值应用一个函数,直到函数生成一个非null的结果。然后搜索终止,返回这个函数的结果。reduce(归约)组合所有键或值,这里要使用所提供的一个累加函数。forEach为所有键或值应用一个函数。
每个操作有4个版本:
operationKeys:处理键operationValues:处理值operation:处理键和值operationEntries:处理Map.Entry对象
对于上述各操作,需要指定一个参数化阈值。如果映射包含的元素多于这个阈值,就会并行完成批操作。如果希望批操作在一个线程中运行,可以使用阈值Long.MAX_VALUE。如果希望尽可能多的线程运行批操作,可以使用阈值1。
search有以下版本:
U searchKeys()
U searchValues()
U search()
U searchEntries()
例如,找出第一个出现次数超过1000次的单词。需要搜索键和值:
String result = map.search(threshold, (k, v) -> v > 1000 ? k : null);
forEach有两种形式。第一种形式只对各个映射条目应用一个消费者函数,如:
map.forEach(threshold, (k, v) -> System.out.println(k + " -> " + v));
第二种形式还有一个额外的转换器函数作为参数,要先应用这个函数,其结果会传递到消费者:
map.forEach(threshold, (k, v) -> k + " -> " + v, System.out::println);
转换器可以用作一个过滤器。只要转换器返回null,这个值就会被悄无声息地跳过。
reduce操作用一个累加函数组合其输入。例如,计算所有值的总和:
Long sum = map.reduceValues(threshold, Long::sum);
与forEach类似,也可以提供一个转换器函数。转换器也可以作为一个过滤器。
12.5.5 并发集视图
没有专门的ConcurrentHashSet类,可以用包含假值的ConcurrentHashMap:
Set<String> words = ConcurrentHashMap.<String>newKeySet();
静态newKeySet方法会生成一个Set<K>,这是ConcurrentHashMap<K, Boolean>的一个包装器。所有映射值都为Boolean.TRUE。
如果原来有个映射,可以使用keySet方法:
Set<String> words = map.keySet(1L);
//参数表示提供了默认值,可不要。
words.add("Java");
//提供默认值后,如果“Java”在words中不存在,就会有一个1L值
视图的更改会引起映射的更改。
12.5.6 写数组的拷贝
CopOnWriteArrayList和CopyOnWriteArraySet是线程安全的集合。
12.5.7 并行数组算法
Arrays类提供了大量并行化操作。静态Arrays.paralleSort方法可以对一个基本类型值或对象的数组排序。例如:
var contents = new String(File.readAllBytes(Path.of("alice.txt")), StandardCharsets.UTF-8);
String[] words = contents.split("[\\P{L}]+");
Arrays.paralleSort(words);
对对象排序时,可以提供一个Comparator。
对于所有方法都可以提供一个范围的边界。
paralleSetAll方法会用由一个函数计算得到的值填充一个数组。这个函数接收元素索引、然后计算相应位置上的值:
Arrays.paralleSetAll(values, i -> i % 10);
//填充0到9
还有一个parallePrefix方法,它会用一个给定结合操作的相应前缀的累加结果替换各个数组元素。考虑数组[1, 2, 3, 4, ...]和×操作。执行Arrays.parallePrefix(values, (x, y) -> x * y)之后,数组将包含:
[1, 1
× 2, 1
× 2
× 3, 1
× 3
× 4, ...]
有点高深,看不懂。
12.5.8 较早的线程安全集合
Vector和Hashtable提供了线程安全的实现。不过已经过时。任何集合类都可以通过使用同步包装器变成线程安全的:
List<E> synchArrayList = Collections.synchronizedList(new ArrayList<E>());
应该确保没有任何线程通过原始的非同步方法访问数据结构。做到这一点,最容易的就像例子所做那样简单构造并立即传递给包装器。
如果希望迭代访问一个集合,同时另一个线程仍有机会更改这个集合,那么仍然需要使用“客户端”锁定:
synchronized(synchHashMap){
Iterator<K> iter = synchHashMap.keySet().iterator();
while(iter.hasNext()) ...;
}
12.6 任务和线程池
因为构造一个新的线程开销有些大,涉及与操作系统的交互。线程持中包含许多准备运行的线程。为线程持提供一个Runnable,就会有一个线程调用run方法。当run方法退出时,这个线程不会死亡,而是留在池中准备为下一个请求提供服务。
12.6.1 Callable 与 Future
Callable与Runnable类似,但是有返回值。Callable接口是一个参数化的类型,只有一个方法call。
public interface Callable<V> {
V call() throws Exception;
}
参数类型是返回值的类型。例如,
Callable<Integer>表示最终返回Integer对象的异步计算。
Future保存异步计算的结果。可以启动一个计算,将Future对象交给某个线程,然后忘掉它。这个Future对象的所有者在结果计算好之后就可以获得结果。
//有如下方法Future<V>
V get()
//调用会阻塞,直到计算完成
V get(long timeout, TimeUnit unit)
//也会阻塞,不过如果在计算完成之前调用超时,会抛出一个TimeoutException异常
void cancel(boolean mayInterrupt)
//如果计算还没开始,会被取消且不再开始。如果计算正在进行,如果mayInterrupt参数为true,它就会被中断
boolean isCancelled()
boolean isDone()
执行Callable的一种方法是使用FutureTask,它实现了Future和Runnable接口,所以可以构造一个线程来运行这个任务:
Callable<Integer> task = ...;
var futureTask = new FutureTask<Integedr>(task); //构造一个既是Future又是Runnable的对象
var t = new Thread(futureTask); //是一个Runnable
t.start();
...
Integer result = futureTask.get(); //是一个Future
更常见的是将一个Callable传递到一个执行器。
12.6.2 执行器
执行器(Executors)类有许多静态工厂方法,用来构造线程池。
| 方法 | 描述 |
|---|---|
newCachedThreadPool | 必要时创建新线程;空闲线程会保留60秒 |
newFixedThreadPool | 池中包含固定数目的线程;空闲线程会一直保留 |
newWorkStealingPool | 一种适合”fork-join“任务的线程池,其中复杂的任务会分解成更为简单的任务,空闲线程会”密取“较简单的任务 |
newSingleThreadExecutor | 只有一个线程的”池“,会顺序地执行所提交的任务 |
newScheduledThreadPool | 用于调度执行的固定线程池 |
newSingleThreadScheduledExecutor | 用于调度执行的单线程“池” |
newCachedThreadPool会立即执行任务,没有线程则创建线程。适合线程生存期短,或者大量时间都在阻塞。newFixedThreadPool没有空闲线程,则排队等待。为了得到最优的运行速度,并发线程数等于处理器内核数,就应当使用固定线程池。newSingleThreadExecutor由一个线程顺序执行所有任务。对分析性能很有帮助。
这三个方法返回实现了ExecutorService接口的ThreadPoolExecutor类的对象。
可用下面的方法之一将Runnable或Callable对象提交给ExecutorService:
Future<T> submit(Callable<T> task)
//调用会得到一个Future对象
Future<?> submit(Runnable task)
//可以使用对象来调用isDone、cancel或isCancelled。但是get方法在完成时只是简单返回null
Future<T> submit(Runnable task, T result)
//get方法在完成时返回指定的result对象
使用完一个线程池时,调用shutdown。这个方法启动线程池的关闭序列,不再接受新的任务,所有任务都完成时,线程池中的线程死亡。
或者调用shutdownNow,线程池会取消所有尚未开始的任务。
使用连接池所做的工作:
- 调用
Executors的静态方法。 - 调用
submit提交Runnable或Callable对象。 - 保存好返回的
Future对象,以便得到结果或取消任务。 - 当不想在提交任何任务时,调用
shutdown。
12.6.3 控制任务组
使用执行器有更策略性的原因:需要控制一组任务。
invokeAny方法提交一个Callable对象集合中的所有对象,并返回某个已完成任务的结果。不知道是哪个任务的结果,往往是最快的那个。对于搜索问题,如果愿意接受任何一种答案,就可以使用这个方法。
invokeAll方法提交一个Callable对象集合中的所有对象,这个方法会阻塞,直到所有任务都完成,并返回表示所有任务答案的一个Future对象列表。
在自己的程序中,应当使用执行器服务来管理线程而不要单个地启动线程。
程序清单12-8是一个很好的例子。
package executors;
import java.io.*;
import java.nio.file.*;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.stream.*;
/**
* This program demonstrates the Callable interface and executors.
* @version 1.0 2018-01-04
* @author Cay Horstmann
*/
public class ExecutorDemo
{
/**
* Counts occurrences of a given word in a file.
* @return the number of times the word occurs in the given word
*/
public static long occurrences(String word, Path path)
{
try (var in = new Scanner(path))
{
int count = 0;
while (in.hasNext())
if (in.next().equals(word)) count++;
return count;
}
catch (IOException ex)
{
return 0;
}
}
/**
* Returns all descendants of a given directory--see Chapters 1 and 2 of Volume II.
* @param rootDir the root directory
* @return a set of all descendants of the root directory
*/
public static Set<Path> descendants(Path rootDir) throws IOException
{
try (Stream<Path> entries = Files.walk(rootDir))
{
return entries.filter(Files::isRegularFile)
.collect(Collectors.toSet());
}
}
/**
* Yields a task that searches for a word in a file.
* @param word the word to search
* @param path the file in which to search
* @return the search task that yields the path upon success
*/
public static Callable<Path> searchForTask(String word, Path path)
{
return () -> {
try (var in = new Scanner(path))
{
while (in.hasNext())
{
if (in.next().equals(word)) return path;
if (Thread.currentThread().isInterrupted())
{
System.out.println("Search in " + path + " canceled.");
return null;
}
}
throw new NoSuchElementException();
}
};
}
public static void main(String[] args)
throws InterruptedException, ExecutionException, IOException
{
try (var in = new Scanner(System.in))
{
System.out.print("Enter base directory (e.g. /opt/jdk-9-src): ");
String start = in.nextLine();
System.out.print("Enter keyword (e.g. volatile): ");
String word = in.nextLine();
Set<Path> files = descendants(Path.of(start));
var tasks = new ArrayList<Callable<Long>>();
for (Path file : files)
{
Callable<Long> task = () -> occurrences(word, file);
tasks.add(task);
}
ExecutorService executor = Executors.newCachedThreadPool();
// use a single thread executor instead to see if multiple threads
// speed up the search
// ExecutorService executor = Executors.newSingleThreadExecutor();
Instant startTime = Instant.now();
List<Future<Long>> results = executor.invokeAll(tasks);
long total = 0;
for (Future<Long> result : results)
total += result.get();
Instant endTime = Instant.now();
System.out.println("Occurrences of " + word + ": " + total);
System.out.println("Time elapsed: "
+ Duration.between(startTime, endTime).toMillis() + " ms");
var searchTasks = new ArrayList<Callable<Path>>();
for (Path file : files)
searchTasks.add(searchForTask(word, file));
Path found = executor.invokeAny(searchTasks);
System.out.println(word + " occurs in: " + found);
if (executor instanceof ThreadPoolExecutor) // the single thread executor isn't
System.out.println("Largest pool size: "
+ ((ThreadPoolExecutor) executor).getLargestPoolSize());
executor.shutdown();
}
}
}
12.6.4 fork-join框架
Java 7 引入了fork-join框架,主要用来完成计算密集型任务,将任务分解成子任务。
例如,想统计一个数组中有多少个元素满足某个特定的属性。可以将这个数组一分为二,分队对两部分进行统计,然后将结果相加。
采用框架可用的一种方式完成这种递归计算,需要提供一个扩展RecursiveTask<T>的类(如果计算会生成一个类型为T的结果,或者提供一个扩展RecursiveAction的类(如果不生产任何结果)。再覆盖compute方法来生成并调用子任务。然后合并其结果。
package forkJoin;
import java.util.concurrent.*;
import java.util.function.*;
//统计了长度为10000000的数组中数大于0.5的个数
public class ForkJoinTest
{
public static void main(String[] args)
{
final int SIZE = 10000000;
var numbers = new double[SIZE];
for (int i = 0; i < SIZE; i++) numbers[i] = Math.random();
var counter = new Counter(numbers, 0, numbers.length, x -> x > 0.5);
var pool = new ForkJoinPool();
pool.invoke(counter);
System.out.println(counter.join());
}
}
class Counter extends RecursiveTask<Integer>
{
public static final int THRESHOLD = 1000;
private double[] values;
private int from;
private int to;
private DoublePredicate filter;
public Counter(double[] values, int from, int to, DoublePredicate filter)
{
this.values = values;
this.from = from;
this.to = to;
this.filter = filter;
}
protected Integer compute()
{
if (to - from < THRESHOLD)
{
int count = 0;
for (int i = from; i < to; i++)
{
if (filter.test(values[i])) count++;
}
return count;
}
else
{
int mid = (from + to) / 2;
var first = new Counter(values, from, mid, filter);
var second = new Counter(values, mid, to, filter);
////Java.util.concurrent.ForkJoinTask<V>中的invokeAll和join方法
invokeAll(first, second);
//invokeAll方法接收到很多任务并阻塞,直到所有这些任务全部完成
return first.join() + second.join();
//join方法将生成结果,对每个子任务应用join,并返回其总和
}
}
}
12.7 异步计算
目前为止,都是先分解一个任务,然后等待,直到所有部分都已完成。可以实现无等待或异步的计算。
12.7.1 可完成 Future
CompletableFuture类实现了Future接口,它提供了获得结果的另一种机制。要注册一个回调,一旦结果可用,就会再某个线程中利用该结果调用这个回调。
CompletableFuture<String> f = ...;
f.thenAccept(s -> Process the result string s);
通过这种方法,无须阻塞就可以在结果可用时对结果进行处理。
有一些API方法会返回CompletableFuture对象。不过大多数情况都需要建立自己的CompletableFuture。要想异步运行任务并得到CompletableFuture,不要把它直接提交给执行器,而应调用静态方法CompletableFuture.supplyAsync。
CompletableFuture可以采用两种方式完成:
- 得到一个结果
- 有一个未捕获的异常。
要处理这两种情况,可以使用whenComplete方法。要对结果(没有就为null)和异常(没有就为null)调用所提供的函数。
f.whenComplete((s, t) -> {
if(t == null) { //处理s }
else { //处理 Throwable t }
})
CompletableFuture之所以被称为可完成的,是因为可以手动地设置一个完成值。
与普通
Future不同,调用cancel时,CompletableFuture的计算不会中断,取消只会把这个Future对象设置为以异常方式完成。
12.7.2 组合可完成 Future
CompletableFuture可以将异步任务组合为一个处理管线。
CompletableFuture<U> future.thenApply(f);
//返回一个future,结果可用时,会对future的结果应用f
CompletableFuture<U> future.thenApplyAsync(f);
//在另一个线程中运行f
更多方法请见P618。
12.7.3 用户界面回调中的长时间运行任务
在程序中使用线程的理由之一时提高程序的响应性能。
例如,当用户点击一个按钮就读取一个文件,不要直接放在主线程,而应该在一个单独的线程中完成这个工作:
不过,不能直接从执行长时间运行任务的工作线程更新用户界面。需要调度所有UI更新都在UI线程中执行。
12.8 进程
有时还需要执行另一个程序。为此,可以使用ProcessBuilder和Process类。Process类在一个单独的操作系统进程中执行一个命令,允许我们与标准输入、输出和错误流交互。ProcessBuilder类则允许我们配置Process对象。
12.8.1 建立一个进程
首先指定想要执行的命令。可以提供一个List<String>,或者直接提供命令字符串。
var builder = new ProcessBuilder("gcc", "myapp.c")
- 每个进程都有一个工作目录,用来解析相对目录名。默认情况下,进程的工作目录与虚拟机相同,通常时启动Java程序的那个目录。可以用
directory方法改变工作目录。 - 指定如何处理进程的标准输入、输出和错误流。
- 改变进程的环境变量。
- …
ProcessBuilder的各个方法都返回其自身,所以可以把命令串起来。
12.8.2 运行一个进程
配置完后,start方法启动进程。
- 等待进程,可以调用:
int result = process.waitFor()。 - …
12.8.3 进程句柄
进程句柄(ProcessHandle)可以获得一个进程的更多信息,包括ID、父进程、子进程和后代进程。
获取句柄的方式:
- 给定一个
Process对象p,p.toHandle()会生成它的ProcessHandle。 - 给定一个
long类型的操作系统进程ID,ProcessHandle.of(id)可以生成这个进程的句柄。 Process.current()是运行这个Java虚拟机的进程的句柄。ProcessHandle.allProcesses()可以生成对当前进程可见的所有操作系统进程的Stream<ProcessHandle>。
用框架可用的一种方式完成这种递归计算,需要提供一个扩展RecursiveTask<T>的类(如果计算会生成一个类型为T的结果,或者提供一个扩展RecursiveAction的类(如果不生产任何结果)。再覆盖compute方法来生成并调用子任务。然后合并其结果。
package forkJoin;
import java.util.concurrent.*;
import java.util.function.*;
//统计了长度为10000000的数组中数大于0.5的个数
public class ForkJoinTest
{
public static void main(String[] args)
{
final int SIZE = 10000000;
var numbers = new double[SIZE];
for (int i = 0; i < SIZE; i++) numbers[i] = Math.random();
var counter = new Counter(numbers, 0, numbers.length, x -> x > 0.5);
var pool = new ForkJoinPool();
pool.invoke(counter);
System.out.println(counter.join());
}
}
class Counter extends RecursiveTask<Integer>
{
public static final int THRESHOLD = 1000;
private double[] values;
private int from;
private int to;
private DoublePredicate filter;
public Counter(double[] values, int from, int to, DoublePredicate filter)
{
this.values = values;
this.from = from;
this.to = to;
this.filter = filter;
}
protected Integer compute()
{
if (to - from < THRESHOLD)
{
int count = 0;
for (int i = from; i < to; i++)
{
if (filter.test(values[i])) count++;
}
return count;
}
else
{
int mid = (from + to) / 2;
var first = new Counter(values, from, mid, filter);
var second = new Counter(values, mid, to, filter);
////Java.util.concurrent.ForkJoinTask<V>中的invokeAll和join方法
invokeAll(first, second);
//invokeAll方法接收到很多任务并阻塞,直到所有这些任务全部完成
return first.join() + second.join();
//join方法将生成结果,对每个子任务应用join,并返回其总和
}
}
}
12.7 异步计算
目前为止,都是先分解一个任务,然后等待,直到所有部分都已完成。可以实现无等待或异步的计算。
12.7.1 可完成 Future
CompletableFuture类实现了Future接口,它提供了获得结果的另一种机制。要注册一个回调,一旦结果可用,就会再某个线程中利用该结果调用这个回调。
CompletableFuture<String> f = ...;
f.thenAccept(s -> Process the result string s);
通过这种方法,无须阻塞就可以在结果可用时对结果进行处理。
有一些API方法会返回CompletableFuture对象。不过大多数情况都需要建立自己的CompletableFuture。要想异步运行任务并得到CompletableFuture,不要把它直接提交给执行器,而应调用静态方法CompletableFuture.supplyAsync。
CompletableFuture可以采用两种方式完成:
- 得到一个结果
- 有一个未捕获的异常。
要处理这两种情况,可以使用whenComplete方法。要对结果(没有就为null)和异常(没有就为null)调用所提供的函数。
f.whenComplete((s, t) -> {
if(t == null) { //处理s }
else { //处理 Throwable t }
})
CompletableFuture之所以被称为可完成的,是因为可以手动地设置一个完成值。
与普通
Future不同,调用cancel时,CompletableFuture的计算不会中断,取消只会把这个Future对象设置为以异常方式完成。
12.7.2 组合可完成 Future
CompletableFuture可以将异步任务组合为一个处理管线。
CompletableFuture<U> future.thenApply(f);
//返回一个future,结果可用时,会对future的结果应用f
CompletableFuture<U> future.thenApplyAsync(f);
//在另一个线程中运行f
更多方法请见P618。
12.7.3 用户界面回调中的长时间运行任务
在程序中使用线程的理由之一时提高程序的响应性能。
例如,当用户点击一个按钮就读取一个文件,不要直接放在主线程,而应该在一个单独的线程中完成这个工作:
不过,不能直接从执行长时间运行任务的工作线程更新用户界面。需要调度所有UI更新都在UI线程中执行。
12.8 进程
有时还需要执行另一个程序。为此,可以使用ProcessBuilder和Process类。Process类在一个单独的操作系统进程中执行一个命令,允许我们与标准输入、输出和错误流交互。ProcessBuilder类则允许我们配置Process对象。
12.8.1 建立一个进程
首先指定想要执行的命令。可以提供一个List<String>,或者直接提供命令字符串。
var builder = new ProcessBuilder("gcc", "myapp.c")
- 每个进程都有一个工作目录,用来解析相对目录名。默认情况下,进程的工作目录与虚拟机相同,通常时启动Java程序的那个目录。可以用
directory方法改变工作目录。 - 指定如何处理进程的标准输入、输出和错误流。
- 改变进程的环境变量。
- …
ProcessBuilder的各个方法都返回其自身,所以可以把命令串起来。
12.8.2 运行一个进程
配置完后,start方法启动进程。
- 等待进程,可以调用:
int result = process.waitFor()。 - …
12.8.3 进程句柄
进程句柄(ProcessHandle)可以获得一个进程的更多信息,包括ID、父进程、子进程和后代进程。
获取句柄的方式:
- 给定一个
Process对象p,p.toHandle()会生成它的ProcessHandle。 - 给定一个
long类型的操作系统进程ID,ProcessHandle.of(id)可以生成这个进程的句柄。 Process.current()是运行这个Java虚拟机的进程的句柄。ProcessHandle.allProcesses()可以生成对当前进程可见的所有操作系统进程的Stream<ProcessHandle>。

4825

被折叠的 条评论
为什么被折叠?



