Java17-教程-续-全-

Java17 教程·续(全)

原文:More Java 17

协议:CC BY-NC-SA 4.0

一、注解

在本章中,您将学习:

  • 什么是注解

  • 如何声明注解

  • 如何使用注解

  • 什么是元注解以及如何使用它们

  • 常用的批注,用于弃用 API、取消命名的编译时警告、重写方法和声明函数接口

  • 如何在运行时访问注解

  • 如何处理源代码中的注解

本章中的所有示例程序都是清单 1-1 中声明的jdojo.annotation模块的成员。

// module-info.java
module jdojo.annotation {
    exports com.jdojo.annotation;
}

Listing 1-1The Declaration of a jdojo.annotation Module

什么是注解?

在我定义注解并讨论它们在编程中的重要性之前,让我们看一个简单的例子。假设您有一个Employee类,它有一个名为setSalary()的方法来设置雇员的工资。该方法接受一个类型为double的参数。下面的代码片段显示了Employee类的一个简单实现:

public class Employee {
    public void setSalary(double salary) {
        System.out.println("Employee.setSalary():" +
            salary);
    }
}

一个Manager类继承自Employee类。您希望为经理设置不同的工资。您决定覆盖Manager类中的setSalary()方法。Manager类的代码如下:

public class Manager extends Employee {
    // Override setSalary() in the Employee class
    public void setSalary(int salary) {
        System.out.println("Manager.setSalary():" +
            salary);
    }
}

当您试图覆盖setSalary()方法时,Manager类中有一个错误。你很快就会改正错误。您使用了int数据类型作为被错误覆盖的方法的参数类型。是时候给经理定工资了。下面的代码用于实现这一点:

Employee ken = new Manager();
int salary = 200;
ken.setSalary(salary);
Employee.setSalary():200.0

这段代码应该调用Manager类的setSalary()方法,但是输出没有显示预期的结果。

您的代码中哪里出错了?在Manager类中定义setSalary()方法的目的是覆盖Employee类的setSalary()方法,而不是重载它。你犯了个错误。您使用类型int作为setSalary()方法中的参数类型,而不是Manager类中的类型double。您可以在Manager类中添加注解,表明您打算覆盖该方法。但是,评论并不能阻止你犯逻辑错误。您可能会像每个程序员一样,花费数小时来调试由这种逻辑错误导致的错误。在这种情况下谁能帮助你?在类似这样的情况下,注解可能会对您有所帮助。

让我们使用注解重写您的Manager类。此时,您不需要了解任何关于注解的知识。你要做的就是在你的程序中添加一个单词。以下代码是Manager类的修改版本:

public class Manager extends Employee {
    @Override
    public void setSalary(int salary) {
        System.out.println("Manager.setSalary():" +
            salary);
    }
}

您所添加的只是对Manager类的一个@Override注解,并删除了“愚蠢的”注解。试图编译修改后的Manager类会导致编译时错误,该错误指向对Manager类的setSalary()方法使用了@Override注解:

Manager.java:2: error: method does not override or
            implement a method from a supertype
        @Override
        ^
1 error

使用@Override注解达到了目的。@Override注解与非静态方法一起使用,表示程序员打算在超类中覆盖该方法。在源代码级别,它服务于文档的目的。当编译器遇到@Override注解时,它会确保该方法确实覆盖了超类中的方法。如果注解的方法没有覆盖超类中的方法,编译器将生成一个错误。在您的例子中,Manager类中的setSalary(int salary)方法没有覆盖超类Employee中的任何方法。这就是你出错的原因。您可能会意识到使用注解就像记录源代码一样简单。但是,它们有编译器支持。您可以使用它们来指示编译器实现一些规则。注解提供的好处比您在这个例子中看到的要多得多。让我们回到编译时错误。您可以通过执行以下两项操作之一来修复错误:

  • 您可以从Manager类的setSalary(int salary)方法中移除@Override注解。它将使该方法成为重载方法,而不是重写其超类方法的方法。

  • 您可以将方法签名从setSalary(int salary)更改为setSalary(double salary)

因为您想覆盖Manager类中的setSalary()方法,所以使用第二个选项并修改Manager类,如下所示:

public class Manager extends Employee {
    @Override
    public void setSalary(double salary) {
        System.out.println("Manager.setSalary():" +
            salary);
    }
}

现在,以下代码将按预期工作:

Employee ken = new Manager();
int salary = 200;
ken.setSalary(salary);
Manager.setSalary():200.0

注意,Manager类的setSalary()方法中的@Override注解可以节省调试时间。假设您更改了Employee类中的方法签名。如果在Employee类中的改变使得这个方法不再在Manager类中被覆盖,那么当你再次编译Manager类时,你将得到同样的错误。你开始理解注解的力量了吗?有了这个背景,让我们开始深入研究注解。

根据韦氏词典词典,注解的意思是

通过评论或解释的方式添加的注解。

这正是 Java 中注解的含义。它允许您将元数据(或注解)关联到 Java 程序中的程序元素。程序元素可以是模块、包、类、接口、类的字段、局部变量、方法、方法的参数、枚举、注解、通用类型/方法声明中的类型参数、类型使用等。换句话说,您可以注解 Java 程序中的任何声明或类型使用。注解在程序元素的声明中作为“修饰符”使用,就像任何其他修饰符(public、private、final、static 等)一样。).与修饰符不同,注解不会修改程序元素的含义。它就像它所注解的程序元素的装饰或注解。

注解在许多方面不同于常规文档。一个常规的文档只供人类阅读,它是“愚蠢的”它没有相关的智能。如果您拼错了一个单词,或者在文档中陈述了一些东西,而在代码中做了相反的事情,那么您只能靠自己了。在运行时以编程方式读取文档的元素是非常困难和不切实际的。Java 允许你从你的文档中生成 Javadocs,这就是常规文档。这并不意味着你不需要记录你的程序。你确实需要正规的文件。同时,您需要一种使用类似文档的机制来执行您的意图的方法。您的文档应该对编译器和运行时可用。一个注解服务于这个目的。它是人类可读的,可以作为文档。它是编译器可读的,让编译器验证程序员的意图;例如,如果遇到方法的@Override注解,编译器会确保程序员确实覆盖了该方法。注解在运行时也是可用的,因此程序可以出于任何目的读取和使用它。例如,一个工具可以读取注解并生成样板代码。如果您使用过 Enterprise JavaBeans (EJB ),您就会知道保持所有接口和类同步以及向 XML 配置文件添加条目的痛苦。EJB 3.0 使用注解来生成样板代码,这使得 EJB 开发对程序员来说没有痛苦。框架/工具中使用注解的另一个例子是 JUnit 版。JUnit 是 Java 程序的单元测试框架。它使用注解来标记作为测试用例的方法。在此之前,您必须遵循测试用例方法的命名约定。注解有多种用途,包括文档、验证、编译器的执行、运行时验证、框架/工具的代码生成等。

要使注解对编译器和运行时可用,注解必须遵循规则。事实上,注解是类和接口的另一种类型。由于您必须在使用类类型或接口类型之前声明它们,因此您还必须声明注解类型。

注解不会改变它所注解的程序元素的语义(或含义)。从这个意义上说,注解就像注解一样,不会影响被注解的程序元素的工作方式。例如,setSalary()方法的@Override注解并没有改变方法的工作方式。你(或者一个工具/框架)可以基于一个注解改变一个程序的行为。在这种情况下,您使用注解,而不是注解自己做任何事情。关键是注解本身总是被动的。

声明注解类型

除了一些限制之外,声明注解类型类似于声明接口类型。根据 Java 规范,注解类型声明是一种特殊的接口类型声明。您可以使用 interface 关键字来声明注解类型,该关键字前面有@符号(at 符号)。以下是声明注解类型的一般语法:

[modifiers] @ interface <annotation-type-name> {
    // Annotation type body goes here
}

注解声明的[modifiers]interface声明相同。例如,您可以在公共或包级别声明注解类型。@符号和interface关键字可以用空格分开,也可以放在一起。按照惯例,他们被放在一起作为@interfaceinterface关键字后面是注解类型名。它应该是有效的 Java 标识符。注解类型 body 放在大括号内。

假设您想要用版本信息来注解您的程序元素,那么您可以准备一份关于在您产品的特定版本中添加的新程序元素的报告。要使用定制注解类型(与内置注解相反,如@Override),您必须首先声明它。您希望在版本信息中包含发行版本的主要版本和次要版本。清单 1-2 包含了第一个注解声明的完整代码。

// Version.java
package com.jdojo.annotation;
public @interface Version {
    int major();
    int minor();
}

Listing 1-2The Declaration of an Annotation Type Named Version

比较Version注解的声明和接口的声明。它与接口定义的区别仅在于一个方面:它在名称前使用了@符号。您在Version注解类型中声明了两个抽象方法:major()minor()。注解类型中的抽象方法被称为其元素。你可以换一种方式思考:一个注解可以声明零个或多个元素,它们被声明为抽象方法。抽象方法名是注解类型的元素名。您已经为Version注解类型声明了两个元素majorminor。两个元素的数据类型都是 int。

Note

虽然您可以在接口类型中声明静态和默认方法,但是它们不允许在批注类型中使用。静态和默认方法意味着包含一些逻辑。注解意味着只表示注解类型中元素的值。这就是注解类型中不允许使用静态和默认方法的原因。

您需要编译注解类型。当Version.java文件被编译时,会产生一个Version.class文件。您的注解类型的简单名称是Version,它的完全限定名称是com.jdojo.annotation.Version。使用注解类型的简单名称遵循任何其他类型(例如,类、接口等)的规则。).您需要像导入任何其他类型一样导入注解类型。

如何使用注解类型?您可能认为您将声明一个实现Version注解类型的新类,并且您将创建该类的一个对象。您可能会松一口气,因为您不需要采取任何额外的步骤来使用Version注解类型。注解类型一旦被声明和编译,就可以使用了。要创建注解类型的实例并使用它来注解程序元素,需要使用以下语法:

@annotationType(name1=value1, name2=value2, name3=value3...)

注解类型前面有一个@符号。接下来是一列用圆括号括起来的逗号分隔的name=value对。name=value对中的名称是注解类型中声明的元素的名称,值是用户为该元素提供的值。name=value对不必按照注解类型中声明的顺序出现,尽管按照惯例name=value对的使用顺序与注解类型中元素声明的顺序相同。

让我们使用一个Version类型的实例,它的主要元素值为 1,次要元素值为 0。下面是您的Version注解类型的一个实例:

@Version(major=1, minor=0)

您可以将该注解重写为@Version(minor=0, major=1)而不改变其含义。您也可以使用批注类型的完全限定名作为

@com.jdojo.annotation.Version(major=0, minor=1)

您可以在程序中使用任意多的Version注解类型的实例。例如,您有一个VersionTest类,它从 1.0 版本开始就存在于您的应用程序中。您已经在 1.1 版中添加了一些方法和实例变量。您可以使用您的Version注解来记录不同版本中对VersionTest类的添加。您可以将类声明注解为

@Version(major=1, minor=0)
public class VersionTest {
    // Code goes here
}

添加注解的方式与为程序元素添加修饰符的方式相同。您可以将程序元素的注解与其其他修饰符混合使用。您可以将注解放在与其他修改器相同的行中,也可以放在单独的行中。是使用单独的线来放置注解,还是将它们与其他修饰符混合在一起,这是个人的选择。按照惯例,程序元素的注解放在所有其他修饰符之前。让我们遵循这个约定,将注解单独放在一行中,如图所示。以下两个声明在技术上是相同的:

// Style #1
@Version(major=1, minor=0) public class VersionTest {
    // Code goes here
}

// Style #2
public @Version(major=1, minor=0)
class VersionTest {
    // Code goes here
}

清单 1-3 显示了VersionTest类的样本代码。

// VersionTest.java
package com.jdojo.annotation;
// Annotation for class VersionTest
@Version(major=1, minor=0)
public class VersionTest {
    // Annotation for instance variable xyz
    @Version(major=1, minor=1)
    private int xyz = 110;
    // Annotation for constructor VersionTest()
    @Version(major=1, minor=0)
    public VersionTest() {
    }

    // Annotation for constructor VersionTest(int xyz)
    @Version(major=1, minor=1)
    public VersionTest(int xyz) {
        this.xyz = xyz;
    }

    // Annotation for the printData() method
    @Version(major=1, minor=0)
    public void printData() {
    }
    // Annotation for the setXyz() method
    @Version(major=1, minor=1)
    public void setXyz(int xyz) {
        // Annotation for local variable newValue
        @Version(major=1, minor=2)
        int newValue = xyz;
        this.xyz = xyz;
    }
}

Listing 1-3A VersionTest Class with Annotated Elements

在清单 1-3 中,您使用@Version注解来注解类声明、类字段、局部变量、构造函数和方法。在VersionTest类的代码中没有什么特别的。您只是向该类的各种元素添加了@Version注解。即使您删除了所有的@Version注解,这个VersionTest类也会同样工作。需要强调的是,在程序中使用注解根本不会改变程序的行为。注解的真正好处来自于在编译时和运行时读取它。

接下来你会对Version注解类型做什么?您已经将其声明为类型。你在你的VersionTest课上用过。下一步是在运行时读取它。让我们暂时推迟这一步;我将在后面的章节中详细介绍它。我首先讨论更多关于注解类型声明的内容。

注解类型的限制

注解类型是一种特殊类型的接口,有一些限制。我将在接下来的章节中介绍一些限制。

限制#1

批注类型不能从另一个批注类型继承。也就是说,不能在批注类型声明中使用 extends 子句。以下声明将不会编译,因为您使用了 extends 子句来声明WrongVersion注解类型:

// Won't compile
public @interface WrongVersion extends BasicVersion {
    int extended();
}

每个注解类型都隐式继承自java.lang.annotation.Annotation接口,声明如下:

package java.lang.annotation;
public interface Annotation {
    boolean equals(Object obj);
    int hashCode();
    String toString();
    Class<? extends Annotation> annotationType();
}

这意味着在Annotation接口中声明的所有四个方法在所有注解类型中都可用。

Caution

使用抽象方法声明来声明注解类型的元素。在Annotation接口中声明的方法不声明注解类型中的元素。您的Version注解类型只有两个元素,majorminor,它们是在Version类型本身中声明的。您不能将注解类型Version用作@Version(major=1, minor=2, toString="Hello")Version注解类型没有将toString声明为元素。它从Annotation接口继承了toString()方法。

Annotation接口中的前三个方法是来自Object类的方法。annotationType()方法返回注解实例所属的注解类型的类引用。Java 在运行时动态创建一个代理类,它实现了注解类型。当您获得一个注解类型的实例时,该实例类就是动态生成的代理类,您可以使用注解实例上的getClass()方法获得它的引用。如果您在运行时获得了一个Version注解类型的实例,它的getClass()方法将返回动态生成的代理类的类引用,而它的annotationType()方法将返回com.jdojo.annotation.Version注解类型的类引用。

限制#2

批注类型中的方法声明不能指定任何参数。一个方法为注解类型声明一个元素。批注类型中的元素允许您将数据值与批注的实例相关联。注解中的方法声明不会被调用来执行任何类型的处理。把一个元素想象成一个类中的实例变量,这个类有两个方法,一个 setter 和一个 getter。对于注解,Java 运行时创建一个实现注解类型的代理类(这是一个接口)。每个注解实例都是代理类的一个对象。您在注解类型中声明的方法成为您在注解中指定的元素的值的 getter 方法。例如,参见清单 1-2 中的int major();int minor();方法声明。Java 运行时将负责为注解元素设置指定的值。因为在注解类型中声明方法的目的是使用数据元素,所以不需要(也不允许)在方法声明中指定任何参数。下面的批注类型声明无法编译,因为它声明了一个 concatenate()方法,该方法接受两个参数:

// Won't compile
public @interface WrongVersion {
    // Cannot have parameters
    String concatenate(int major, int minor);
}

限制#3

批注类型中的方法声明不能有throws子句。注解类型中的方法被定义为表示数据元素。抛出异常来表示数据值是没有意义的。由于major()方法有一个throws子句,下面的注解类型声明无法编译:

// Won't compile
public @interface WrongVersion {
    int major() throws Exception; // Cannot have a
                                  // throws clause
    int minor(); // OK
}

限制#4

在批注类型中声明的方法的返回类型必须是以下类型之一:

  • 任意原始类型:byteshortintlongfloatdoublebooleanchar

  • java.lang.String

  • java.lang.Class

  • 枚举类型

  • 注解类型

  • 前面提到的任何类型的数组,例如,String[]int[]等。返回类型不能是嵌套数组。例如,您不能拥有返回类型String[][]int[][]

Note

这些数据类型限制背后的原因是,允许的数据类型的所有值都必须在源代码中表示,编译器应该能够表示这些值以便进行编译时分析。

Class的返回类型需要稍微解释一下。代替Class类型,您可以使用一个通用的返回类型,它将返回一个用户定义的类类型。假设你有一个Test类,你想在Test类型的注解类型中声明一个方法的返回类型。您可以声明注解方法,如下所示:

public @interface GoodOne {
    Class element1();
      // <- Any Class type
    Class<Test> element2();
      // <- Only Test class type
    Class<? extends Test> element3();
      // <- Test or its subclass type
}

限制#5

注解类型不能声明方法,这相当于覆盖了Object类或Annotation接口中的方法。

限制#6

批注类型不能是泛型。

注解元素的默认值

注解类型声明的语法允许您为其元素指定默认值。对于在其声明中指定了缺省值的注解元素,不要求也可以指定值。可以使用以下通用语法指定元素的默认值:

[modifiers] @interface <annotation-type-name> {
    <data-type> <element-name>() default <default-value>;
}

关键字default用于指定默认值。类型的默认值必须与元素的数据类型兼容。

假设您有一个不经常发布的产品,那么它不太可能有一个非零的次要版本。您可以通过将次要元素的默认值指定为零来简化您的Version注解类型,如下所示:

public @interface Version {
    int major();
    int minor() default 0; // Set zero as default value
                           // for minor
}

一旦为元素设置了默认值,在使用这种类型的注解时就不必传递它的值。Java 将使用缺省元素的缺省值:

@Version(major=1)          // minor is zero, which is
                           // its default value
@Version(major=2)          // minor is zero, which is
                           // its default value
@Version(major=2, minor=1) // minor is 1, which is the
                           // specified value

所有默认值都必须是编译时常量。如何指定数组类型的默认值?你需要使用数组初始化语法。以下代码片段显示了如何为数组和其他数据类型指定默认值:

// Shows how to assign default values to elements of
// different types
public @interface DefaultTest {
    double d() default 12.89;
    int num() default 12;
    int[] x() default {1, 2};
    String s() default "Hello";
    String[] s2() default {"abc", "xyz"};
    Class c() default Exception.class;
    Class[] c2() default {Exception.class,
        java.io.IOException.class};
}

元素的默认值不与注解一起编译。当程序试图在运行时读取元素的值时,从注解类型定义中读取它。例如,当您使用@Version(major=2)时,这个注解实例会按原样编译。它不添加默认值为零的minor元素。换句话说,这个注解在编译时没有被修改为@Version(major=2, minor=0)。然而,当您在运行时读取这个注解的minor元素的值时,Java 将检测到没有指定minor元素的值。它将参考Version注解类型定义以获得其默认值。这种机制的含义是,如果您更改了一个元素的默认值,无论何时程序试图读取它,都会读取更改后的默认值,即使带注解的程序是在您更改默认值之前编译的。

注解类型及其实例

我经常使用术语“注解类型”和“注解”。注解类型是一种类似于接口的类型。理论上,只要可以使用接口类型,就可以使用注解类型。实际上,我们将它的使用仅限于注解程序元素。您可以声明注解类型的变量,如下所示:

Version v = null; // Here, Version is an annotation type

像接口一样,您也可以在类中实现注解类型。但是,您永远不应该这样做,因为这将违背将注解类型作为新构造的目的。您应该总是在类中实现接口,而不是注解类型。从技术上讲,清单 1-4 中用于DoNotUseIt类的代码是有效的。这只是为了演示的目的。即使可以工作,也不要在类中实现注解。

// DoNotUseIt.java
package com.jdojo.annotation;
import java.lang.annotation.Annotation;
public class DoNotUseIt implements Version {
    // Implemented method from the Version annotation
    // type
    @Override
    public int major() {
        return 0;
    }
    // Implemented method from the Version annotation
    // type
    @Override
    public int minor() {
        return 0;
    }
    // Implemented method from the Annotation annotation
    // type, which is the supertype of the Version
    // annotation type
    @Override
    public Class<? extends Annotation> annotationType() {
        return null;
    }
}

Listing 1-4A Class Implementing an Annotation Type

Java 运行时实现了代理类的注解类型。它为您提供了一个类的对象,为您在程序中使用的每个注解实现您的注解类型。您必须区分注解类型和该注解类型的实例(或对象)。在您的示例中,Version是一个注解类型。每当您将它用作@Version(major=2, minor=4)时,您就创建了一个Version注解类型的实例。注解类型的实例简称为注解。例如,我们说@Version(major=2, minor=4)是一个注解或者是Version注解类型的一个实例。注解应该易于在程序中使用。语法@Version(...)是创建一个类、创建该类的一个对象以及设置其元素值的简写。我将在本章的后面讲述如何在运行时获得一个注解类型的对象。

使用注解

在这一节中,我将讨论在声明注解类型时使用不同类型元素的细节。请记住,为批注元素提供的值必须是编译时常量表达式,并且不能将 null 用作批注中任何类型元素的值。

原始类型

注解类型中的元素的数据类型可以是任何原始数据类型:byteshortintlongfloatdoublebooleancharVersion注解类型声明了两个元素majorminor,并且都是int数据类型。下面的代码片段声明了一个名为PrimitiveAnnTest的注解类型:

public @interface PrimitiveAnnTest {
    byte a();
    short b();
    int c();
    long d();
    float e();
    double f();
    boolean g();
    char h();
}

您可以使用一个PrimitiveAnnTest类型的实例作为

@PrimitiveAnnTest(a=1, b=2, c=3, d=4, e=12.34F, f=1.89, g=true, h='Y')

您可以使用编译时常数表达式来指定注解元素的值。以下两个Version注解实例是有效的,它们的元素具有相同的值:

@Version(major=2+1, minor=(int)13.2)
@Version(major=3, minor=13)

字符串类型

您可以在注解类型中使用String类型的元素。清单 1-5 包含名为Name的注解类型的代码。它有两个元素,firstlast,属于String类型。

// Name.java
package com.jdojo.annotation;
public @interface Name {
    String first();
    String last();
}

Listing 1-5Name Annotation Type, Which Has Two Elements, first and last, of the String Type

以下代码片段显示了如何在程序中使用Name注解类型:

@Name(first="John", last="Jacobs")
public class NameTest {
    @Name(first="Wally", last="Inman")
    public void aMethod() {
        // More code goes here...
    }
}

String类型元素的值表达式中使用字符串串联运算符(+)是有效的。以下两个注解是等效的:

@Name(first="Jo" + "hn", last="Ja" + "cobs")
@Name(first="John", last="Jacobs")

通常,当您想要使用编译时常量(如final class变量)作为注解元素值的一部分时,您会在注解中使用字符串串联。在下面的注解中,Test是一个类,它定义了一个名为UNKNOWN的编译时常量String类变量:

@Name(first="Mr. " + Test.UNKNWON, last=Test.UNKNOWN)

因为表达式new String("John")不是编译时常量表达式,所以@Name注解的以下用法无效:

@Name(first=new String("John"), last="Jacobs")

类别类型

在注解类型中使用Class类型作为元素的好处并不明显。通常,它用于工具/框架读取带有类类型元素的注解,并对元素的值执行一些专门的处理或生成代码的情况。让我们看一个使用类类型元素的简单例子。假设您正在编写一个测试运行器工具,用于运行 Java 程序的测试用例。您的注解将用于编写测试用例。如果您的测试用例在被测试运行程序调用时必须抛出一个异常,那么您需要使用一个注解来指出这一点。让我们创建一个DefaultException类,如清单 1-6 所示。

// DefaultException.java
package com.jdojo.annotation;
public class DefaultException
        extends java.lang.Throwable {
    public DefaultException() {
    }
    public DefaultException(String msg) {
        super(msg);
    }
}

Listing 1-6A DefaultException Class That Is Inherited from the Throwable Exception Class

清单 1-7 显示了一个TestCase注解类型的代码。

// TestCase.java
package com.jdojo.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface TestCase {
    Class<? extends Throwable> willThrow() default
        DefaultException.class;
}

Listing 1-7A TestCase Annotation Type Whose Instances Are Used to Annotate Test Case Methods

willThrow元素的返回类型被定义为Throwable类的通配符,因此用户将只指定Throwable类或其子类作为元素的值。您可以使用Class<?>类型作为 willThrow 元素的类型。然而,这将允许该注解类型的用户传递任何类类型作为它的值。注意,您已经为TestCase注解类型使用了两个注解,@Retention@Target@Retention注解类型指定@TestCase注解将在运行时可用。有必要对您的TestCase注解类型使用RUNTIME的保留策略,因为这意味着测试运行器工具将在运行时读取它。@Target注解声明TestCase注解只能用于注解方法。在后面讨论元注解时,我将详细介绍@Retention@Target注解类型。清单 1-8 展示了TestCase注解类型的使用。

// PolicyTestCases.java
package com.jdojo.annotation;
import java.io.IOException;
public class PolicyTestCases {
    // Must throw IOException
    @TestCase(willThrow=IOException.class)
    public static void testCase1(){
        // Code goes here
    }
    // We are not expecting any exception
    @TestCase()
    public static void testCase2(){
        // Code goes here
    }
}

Listing 1-8A Test Case That Uses the TestCase Annotations

testCase1()方法使用@TestCase注解指定它将抛出一个IOException。测试运行工具将确保当它调用这个方法时,这个方法确实抛出了一个IOException。否则,它将无法通过测试用例。testCase2()方法没有指定它将抛出一个异常。如果它在测试运行时抛出了一个异常,那么这个工具应该会使这个测试用例失败。

枚举类型

批注可以包含枚举类型的元素。假设您想要声明一个名为Review的注解类型,它可以描述程序元素的代码审查状态。让我们假设它有一个状态元素,它可以有四个值之一:PENDINGFAILEDPASSEDPASSEDWITHCHANGES。您可以将枚举声明为批注类型成员。清单 1-9 显示了一个Review注解类型的代码。

// Review.java
package com.jdojo.annotation;
public @interface Review {
    ReviewStatus status() default ReviewStatus.PENDING;
    String comments() default "";
    // ReviewStatus enum is a member of the Review
    // annotation type
    public enum ReviewStatus {PENDING, FAILED, PASSED,
        PASSEDWITHCHANGES};
}

Listing 1-9An Annotation Type That Uses an enum Type Element

Note

用作批注元素类型的枚举类型不需要声明为批注类型的嵌套枚举类型,如本例所示。枚举类型也可以在批注类型之外声明。

Review注解类型声明了一个ReviewStatus枚举类型,四个审查状态是该枚举的元素。它有两个元素,statuscomments。状态元素的类型是枚举类型ReviewStatus。状态元素的默认值为ReviewStatus.PENDING。您有一个空字符串作为 comments 元素的默认值。

下面是一些Review注解类型的实例。您需要在您的程序中导入com.jdojo.annotation.Review.ReviewStatus枚举,以使用ReviewStatus枚举类型的简单名称:

import com.jdojo.annotation.Review.ReviewStatus;
...
// Have default for status and comments. Maybe the code
// is new.
@Review()
// Leave status as Pending, but add some comments
@Review(comments=
    "Have scheduled code review on December 1, 2017")
// Fail the review with comments
@Review(status=ReviewStatus.FAILED,
    comments="Need to handle errors")
// Pass the review without comments
@Review(status=ReviewStatus.PASSED)

下面是注解测试类的示例代码,表明它通过了代码审查:

import com.jdojo.annotation.Review.ReviewStatus;
import com.jdojo.annotation.Review;
@Review(status=ReviewStatus.PASSED)
public class Test {
    // Code goes here
}

注解类型

注解类型可以用在 Java 程序中任何可以使用类型的地方。例如,您可以使用注解类型作为方法的返回类型。您也可以使用注解类型作为另一个注解类型声明中的元素类型。假设您想要一个名为Description的新注解类型,它将包含作者姓名、版本和程序元素的注解。您可以重用您的NameVersion注解类型作为它的nameversion元素类型。清单 1-10 显示了Description注解类型的代码。

// Description.java
package com.jdojo.annotation;
public @interface Description {
    Name name();
    Version version();
    String comments() default "";
}

Listing 1-10An Annotation Type Using Other Annotation Types As Its Elements

要为注解类型的元素提供值,需要使用创建注解类型实例的语法。例如,@Version(major=1, minor=2)创建了一个Version注解的实例。请注意,在下面的代码片段中,一个批注嵌套在另一个批注内:

@Description(name=@Name(first="John", last="Jacobs"),
    version=@Version(major=1, minor=2),
    comments="Just a test class")
public class Test {
    // Code goes here
}

数组类型注解元素

批注可以包含数组类型的元素。数组类型可以是以下类型之一:

  • 原始类型

  • java.lang.String类型

  • java.lang.Class类型

  • 枚举类型

  • 注解类型

您需要为大括号内的数组元素指定值。数组的元素由逗号分隔。假设您想用一个简短的描述来注解您的程序元素,这个描述是您需要处理的事情的列表。清单 1-11 为此创建了一个ToDo注解类型。

// ToDo.java
package com.jdojo.annotation;

public @interface ToDo {
    String[] items();
}

Listing 1-11ToDo Annotation Type with a String Array As Its Sole Element

以下代码片段显示了如何使用@ToDo注解:

@ToDo(items={"Add readFile method", "Add error handling"})
public class Test {
    // Code goes here
}

如果数组中只有一个元素,可以省略大括号。

以下两个ToDo注解类型的注解实例是等效的:

@ToDo(items={"Add error handling"})
@ToDo(items="Add error handling")

Note

如果没有有效值传递给数组类型的元素,可以使用空数组。例如,@ToDo(items={})是一个有效的注解,其中 items 元素被分配了一个空数组。

批注中没有空值

不能使用null引用作为注解中元素的值。注意,允许对String类型元素使用空字符串,对数组类型元素使用空数组。使用以下注解将导致编译时错误:

@ToDo(items=null)
@Name(first=null, last="Jacobs")

速记注解语法

速记注解语法在某些情况下更容易使用。假设您有一个注解类型Enabled,它的元素有一个默认值,如下所示:

public @interface Enabled {
    boolean status() default true;
}

如果您想用Enabled注解类型注解一个程序元素,并使用其元素的缺省值,那么您可以使用@Enabled()语法。您不需要为 status 元素指定值,因为它有一个默认值。在这种情况下,您可以使用速记,这样可以省略括号。你可以只用@Enabled而不用@Enabled()Enabled注解可以使用以下两种形式:

@Enabled
public class Test {
    // Code goes here
}

@Enabled()
public class Test {
    // Code goes here
}

只有一个元素的注解类型也有简写语法。

如果遵循注解类型中唯一元素的命名规则,可以使用这种简写方式。元素的名称必须是value。如果一个注解类型只有一个名为value的元素,您可以在注解中省略name=value对中的名称。下面的代码片段声明了一个Company注解类型,它只有一个名为 value 的元素:

public @interface Company {
    String value(); // the element name is value
}

使用Company注解时,可以省略name=value对中的名称,如下所示。如果你想使用带有Company注解的元素名,你总是可以这样做

@Company(value="Abc Inc.")
@Company("Abc Inc.")
public class Test {
    // Code goes here
}

您可以使用这种从注解中省略元素名称的简写方式,即使元素数据类型是数组。考虑下面称为Reviewers的注解类型:

public @interface Reviewers {
    String[] value(); // the element name is value
}

由于Reviewers注解类型只有一个元素,名为 value,所以在使用时可以省略元素名:

// No need to specify name of the element
@Reviewers({"John Jacobs", "Wally Inman"})
public class Test {
    // Code goes here
}

如果在数组中只为Reviewers注解类型的 value 元素指定了一个元素,也可以省略大括号:

@Reviewers("John Jacobs")
public class Test {
    // Code goes here
}

您刚刚看到了几个使用元素名称作为值的例子。这里是在注解中省略元素名称的一般规则:如果在使用注解时只提供一个值,则假定元素名称为value。这意味着您不需要在注解类型中只有一个名为value的元素,从而在注解中省略其名称。如果您有一个注解类型,它有一个名为value(有或没有缺省值)的元素,并且所有其他元素都有缺省值,您仍然可以在此类型的注解实例中省略该元素的名称。以下是一些说明这一规则的例子:

public @interface A {
    String value();
    int id() default 10;
}
// Same as @A(value="Hello", id=10)
@A("Hello")
public class Test {
    // Code goes here
}
// Won't compile. Must use only one value to omit the
// element name
@A("Hello", id=16)
public class WontCompile {
    // Code goes here
}
// OK. Must use name=value pair when passing more than
// one value
@A(value="Hello", id=16)
public class Test {
    // Code goes here
}

标记注解类型

标记注解类型不声明任何元素,甚至不声明具有默认值的元素。通常,标记注解由注解处理工具使用,注解处理工具基于标记注解类型生成某种样板代码:

public @interface Marker {
    // No element declarations
}
@Marker
public class Test {
    // Code goes here
}

一个例子是由某个性能监控工具监控的方法的@Monitor注解:

public class Calculator {
    ...
    @Monitor
    public void calc() {
        ...
    }
}

该工具将自动添加用于测量经过时间、呼叫频率等的代码。

元注解类型

元注解类型用于注解其他注解类型声明。以下是元注解类型:

  • Target

  • Retention

  • Inherited

  • Documented

  • Repeatable

元注解类型是 Java 类库的一部分。它们在java.lang.annotation包中声明。我将在后续章节中详细讨论元注解类型。

Note

java.lang.annotation包包含一个Native注解类型,它不是元注解。它用于注解字段,指示该字段可以从本机代码引用。这是一个标记注解。通常,它由基于该注解生成一些代码的工具使用。

目标注解类型

作为元注解集的第一个成员,Target注解类型用于指定注解类型可以使用的上下文。它只有一个名为 value 的元素,这是一个java.lang.annotation.ElementType枚举类型的数组。表 1-1 列出了ElementType枚举中的所有常量。

表 1-1

Java . lang . annotation . element type 枚举中的常数列表

|

常数名称

|

描述

|
| --- | --- |
| ANNOTATION_TYPE | 用于注解另一个注解类型声明。这使得注解类型成为元注解。 |
| CONSTRUCTOR | 用于注解构造函数。 |
| FIELD | 用于注解字段和枚举常量。 |
| LOCAL_VARIABLE | 用于注解局部变量。 |
| METHOD | 用于注解方法。 |
| MODULE | 用于注解模块。它是在 Java 9 中添加的。 |
| PACKAGE | 用于注解包声明。 |
| PARAMETER | 用于标注参数。 |
| TYPE | 用于批注类、接口(包括批注类型)或枚举声明。 |
| TYPE_PARAMETER | 用于注解泛型类、接口、方法等中的类型参数。它是在 Java 8 中添加的。 |
| TYPE_USE | 用于注解所有类型的使用。它是在 Java 8 中添加的。在可以使用带有ElementType.TYPEElementType.TYPE_PARAMETER的注解的地方,也可以使用该注解。它也可以用在构造函数之前,在这种情况下,它表示由构造函数创建的对象。 |

下面的Version注解类型声明用Target元注解注解注解类型声明,元注解指定Version注解类型只能用于三种类型的程序元素:任意类型(类、接口、枚举和注解类型)、构造函数和方法。

// Version.java
package com.jdojo.annotation;
import java.lang.annotation.Target;
import java.lang.annotation.ElementType;
@Target({ElementType.TYPE, ElementType.CONSTRUCTOR,
    ElementType.METHOD})
public @interface Version {
    int major();
    int minor();
}

除了在其Target注解中指定的三种类型外,Version注解类型不能用于任何程序元素。它的以下用法不正确,因为它正用于实例变量(字段):

public class WontCompile {
    // A compile-time error. Version annotation cannot
    // be used on a field.
    @Version(major = 1, minor = 1)
    int id = 110;
}

以下对Version注解的使用是有效的:

// OK. A class type declaration
@Version(major = 1, minor = 0)
public class VersionTest {
    // OK. A constructor declaration
    @Version(major = 1, minor = 0)
    public VersionTest() {
        // Code goes here
    }
    // OK. A method declaration
    @Version(major = 1, minor = 1)
    public void doSomething() {
        // Code goes here
    }
}

在 Java 8 之前,方法的形参以及包、类、方法、字段和局部变量的声明都允许使用注解。Java 8 增加了对在任何类型使用和类型参数声明中使用注解的支持。短语“任何类型的使用”需要一点解释。类型在许多上下文中使用,例如,在 extends 子句后作为超类型,在 new 运算符后的对象创建表达式中,在 cast 中,在 throws 子句中,等等。从 Java 8 开始,只要使用了类型,注解就可能出现在类型的简单名称之前。请注意,类型的简单名称只能用作名称,而不能用作类型,例如,在 import 语句中。考虑清单 1-12 和 1-13 中显示的FatalNonZero注解类型的声明。

// NonZero.java
package com.jdojo.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Target;
@Target({ElementType.TYPE_USE})
public @interface NonZero {
}

Listing 1-13A NonZero Annotation Type That Can Be Used with Any Type Use

// Fatal.java
package com.jdojo.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Target;
@Target({ElementType.TYPE_USE})
public @interface Fatal {
}

Listing 1-12A Fatal Annotation Type That Can Be Used with Any Type Use

在任何使用类型的地方都可以使用FatalNonZero注解类型。它们在以下上下文中的使用是有效的:

public class Test {
    public void processData() throws @Fatal Exception {

        double value = getValue();
        int roundedValue = (@NonZero int) value;
        Test t = new @Fatal Test();
        // More code goes here
    }
    public double getValue() {
        double value = 189.98;
        // More code goes here
        return value;
    }
}

Note

如果没有用Target注解类型注解注解类型,注解类型可以在任何地方使用,除了在类型参数声明中。

保留注解类型

您可以将注解用于不同的目的。您可能希望仅出于文档目的使用它们,由编译器处理,和/或在运行时使用它们。注解可以在三个级别保留:

  • 仅源代码

  • 仅类文件(默认)

  • 类文件和运行时

Retention元注解类型用于指定 Java 应该如何保留注解类型的注解实例。这也称为注解类型的保留策略。如果注解类型具有“仅源代码”保留策略,则在编译到类文件中时,该类型的实例将被删除。如果保留策略是“仅类文件”,注解实例将保留在类文件中,但在运行时无法读取。如果保留策略是“类文件和运行时”(简称为运行时),注解实例将保留在类文件中,并且可以在运行时读取。

Retention 元注解类型声明了一个名为 value 的元素,它属于java.lang.annotation.RetentionPolicy枚举类型。RetentionPolicy枚举有三个常量,SOURCECLASSRUNTIME,分别用于指定仅源代码、仅类和类和运行时的保留策略。下面的代码在Version注解类型上使用了Retention元注解。它指定Version注解应该在运行时可用。注意在Version注解类型上使用了两个元注解:TargetRetention

// Version.java
package com.jdojo.annotation;

import java.lang.annotation.Target;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

@Target({ElementType.TYPE, ElementType.CONSTRUCTOR,
    ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Version {
    int major();
    int minor();
}

Note

如果不在注解类型上使用Retention元注解,其保留策略默认为仅类文件。这意味着您将无法在运行时阅读这些注解。一开始你会犯这种常见的错误。您会尝试读取注解,但运行时不会返回任何值。在运行时尝试读取它们之前,确保您的注解类型已经用保留策略为RetentionPolicy.RUNTIME的保留元注解进行了注解。无论注解类型的保留策略如何,局部变量声明上的注解在类文件中或运行时都是不可用的。这种限制的原因是 Java 运行时不允许您在运行时使用反射来访问局部变量;除非您有权在运行时访问局部变量,否则您无法读取它们的注解。

继承的批注类型

Inherited注解类型是标记元注解类型。如果注解类型是用Inherited元注解来注解的,那么它的实例会被子类声明继承。如果注解类型用于注解除类声明之外的任何程序元素,则没有任何效果。让我们考虑两个注解类型声明:Ann2Ann3。注意,Ann2没有用Inherited元注解进行注解,而Ann3有。

public @interface Ann2 {
    int id();
}
@Inherited
public @interface Ann3 {
    int id();
}

让我们声明两个类,AB,如下所示。注意,类B继承了类A:

@Ann2(id=505)
@Ann3(id=707)
public class A {
    // Code for class A goes here
}
// Class B inherits Ann3(id=707) annotation from the
// class A
public class B extends A {
    // Code for class B goes here
}

在这段代码中,类B从类A继承了@Ann3(id=707)注解,因为Ann3注解类型已经用Inherited元注解进行了注解。类B不继承@Ann2(id=505)注解,因为Ann2注解类型没有用Inherited元注解进行注解。

记录的注解类型

Documented注解类型是标记元注解类型。如果注解类型用Documented注解进行了注解,Javadoc 工具将为它的所有实例生成文档。清单 1-14 包含了Version注解类型的最终版本的代码,它已经用一个Documented元注解进行了注解。

// Version.java
package com.jdojo.annotation;

import java.lang.annotation.Documented;
import java.lang.annotation.Target;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

@Target({ElementType.TYPE, ElementType.CONSTRUCTOR,
    ElementType.METHOD, ElementType.MODULE,
    ElementType.PACKAGE, ElementType.LOCAL_VARIABLE,
    ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Version {
    int major();
    int minor();
}

Listing 1-14The Final Version of the Version Annotation Type

假设您用您的Version注解类型注解了一个Test类,如下所示:

package com.jdojo.annotation;
@Version(major=1, minor=0)
public class Test {
    // Code for Test class goes here
}

当您使用 Javadoc 工具为Test类生成文档时,Test类声明上的Version注解也会作为文档的一部分生成。如果从Version注解类型声明中移除Documented注解,Test类文档将不会包含关于其Version注解的信息。

可重复注解类型

如果允许重复使用,注解类型声明必须用@Repeatable注解进行注解。Repeatable注解类型只有一个名为 value 的元素,其类型是另一个注解类型的类类型。创建可重复注解类型是一个两步过程:

  • 声明一个注解类型(比如说T,并用Repeatable元注解对其进行注解。将注解的值指定为另一个注解,该注解包含所声明的可重复注解类型的注解。

  • 用一个元素声明包含的批注类型,该元素是可重复批注的数组。

清单 1-15 和 1-16 包含对ChangeLogChangeLogs注解类型的声明。ChangeLog被标注了@Repeatable(ChangeLogs.class)标注,这意味着它是一个可重复的标注类型,其包含的标注类型是ChangeLogs

// ChangeLogs.java
package com.jdojo.annotation;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
public @interface ChangeLogs {
    ChangeLog[] value();
}

Listing 1-16A Containing Annotation Type for the ChangeLog Repeatable Annotation Type

// ChangeLog.java
package com.jdojo.annotation;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(ChangeLogs.class)
public @interface ChangeLog {
    String date();
    String comments();
}

Listing 1-15A Repeatable Annotation Type That Uses the ChangeLogs As the Containing Annotation Type

您可以使用ChangeLog注解来记录Test类的更改历史,如下所示:

@ChangeLog(date="08/28/2017",
    comments="Declared the class")
@ChangeLog(date="09/21/2017",
    comments="Added the process() method")
public class Test {
    public static void process() {
        // Code goes here
    }
}

常用的标准注解

Java API 定义了许多标准的注解类型。本节讨论四种最常用的标准注解。它们在java.lang包中定义。他们是

  • Deprecated

  • Override

  • SuppressWarnings

  • FunctionalInterface

贬低 API

在 Java 中弃用 API 是提供关于 API 生命周期信息的一种方式。您可以弃用模块、包、类型、构造函数、方法、字段、参数和局部变量。当你反对一个 API 的时候,你是在告诉它的用户

  • 不要使用 API,因为它很危险

  • 因为有更好的 API 替代品,所以从 API 中迁移出来

  • 从 API 中迁移出来,因为该 API 将在未来的版本中被删除

JDK 包含两个用于弃用 API 的构造:

  • Javadoc 标签

  • java.lang.Deprecated标注类型

Javadoc 标签允许您使用 HTML 丰富的文本格式特性来指定关于弃用的细节。java.lang.Deprecated注解类型可以用在 API 元素上,但不推荐使用。

运行时会保留Deprecated注解类型。

标签@deprecated和注解@Deprecated应该一起使用。两者都应该出席或都缺席。@Deprecation注解不允许您指定弃用的描述,因此您必须使用@deprecated标签来提供描述。

Note

在 API 元素上使用@deprecated标签,而不是@Deprecated注解,会产生编译器警告。

清单 1-17 包含一个名为FileCopier的类的声明。假设这个类是作为库的一部分提供的。

// FileCopier.java
package com.jdojo.deprecation;
import java.io.File;
/**
* The class consists of static methods that can be used
* to copy files and directories.
*
* @deprecated Deprecated since 1.4\. Not safe to use. Use
* the <code>java.nio.file.Files</code> class instead. This
* class will be removed in a future release of this library.
*
* @since 1.2
*/

@Deprecated
public class FileCopier {
    // No direct instantiation supported
    private FileCopier() {
    }
    /**
     * Copies the contents of src to dst.
     * @param src The source file
     * @param dst The destination file
     * @return true if the copy is successfully,
     * false otherwise.
     */
    public static boolean copy(File src, File dst) {
        // More code goes here
        return true;
    }
    // More code goes here
}

Listing 1-17A FileCopier Utility Class

使用@Deprecated注解不赞成使用FileCopier类。它的 Javadoc 使用@deprecated标签给出弃用的详细信息,比如何时弃用、替换以及移除通知。在 JDK9 之前,@Deprecated注解类型不包含任何元素,所以您必须使用 Javadoc 中的@deprecated标签为不推荐的 API 提供关于不推荐的所有细节。请注意,Javadoc 中使用的@since标记表示FileCopier类从该库的 1.2 版本起就已经存在,而@deprecated标记表示该类从该库的 1.4 版本起已被废弃。

Javadoc 工具将@deprecated 标记的内容移到生成的 Javadoc 的顶部,以引起读者的注意。当不推荐使用的代码使用不推荐使用的 API 时,编译器会生成警告。用@Deprecated注解 API 不会生成警告;然而,使用一个用@Deprecated注解标注的 API 就可以了。如果您在类本身之外使用了FileCopier类,您将收到一个关于使用不推荐使用的类的编译时警告。

假设您编译了代码并将其部署到生产环境中。如果您升级了包含旧应用程序使用的新的、不推荐使用的 API 的 JDK 版本或库/框架,您不会收到任何警告,并且您将错过从不推荐使用的 API 中进行迁移的机会。您必须重新编译代码才能收到警告。没有工具可以扫描和分析编译后的代码(例如 JAR 文件)并报告废弃 API 的使用情况。更糟糕的情况是,当一个不推荐使用的 API 从新版本中删除时,您的旧的编译后的代码会收到意外的运行时错误。当开发人员看到不赞成使用的元素 Javadoc 时,他们也感到困惑——没有办法表达 API 何时不赞成使用,以及不赞成使用的 API 是否会在未来的版本中删除。在 JDK9 之前,您所能做的就是在文本中将这些信息指定为@deprecated 标记的一部分。出于这个原因,有两个额外的元素增强了@Deprecated注解(从 JDK9 开始):sinceforRemoval。它们声明如下:

  • String since()默认为“”;

  • boolean forRemoval()默认值为 false

两个新元素都指定了默认值,因此注解的现有使用不会中断。since元素指定带注解的 API 元素被弃用的版本。它是一个字符串,您应该遵循与 JDK 版本方案相同的版本命名约定,例如,“9”代表 JDK9。它默认为空字符串。请注意,JDK9 没有向@Deprecated注解类型添加元素来指定弃用的描述。这样做有两个原因:

  • 运行时会保留注解。向注解添加描述性文本会增加运行时内存。

  • 描述性文本不能只是纯文本。例如,它需要提供一个链接来替换不推荐使用的 API。现有的@deprecated Javadoc 标记已经提供了此功能。

forRemoval元素表示带注解的 API 元素将在未来的版本中被移除,您应该从 API 中迁移出来。它默认为 false。

Note

元素上的@since Javadoc 标签指示 API 元素何时被添加,而@Deprecated注解的since元素指示 API 元素何时被弃用。在 JDK9 中,已经做出了合理的努力,在 Java SE APIs 中的@Deprecated注解的大多数(如果不是全部)使用位置中回填这两个元素的值。

@Deprecation注解类型中添加了forRemoval元素之后,又增加了五个用例。当一个 API 被弃用并且forRemoval设置为 false 时,这样的弃用被称为普通弃用,在这种情况下发出的警告被称为普通弃用警告。当一个 API 被弃用并且forRemoval被设置为true时,这种弃用被称为终端弃用,在这种情况下发出的警告被称为终端弃用警告或移除警告。表 1-2 显示了弃用警告矩阵(在 JDK9 中发布)。

表 1-2

弃用警告矩阵

|

API 使用-站点

|

API 声明站点,不推荐使用

|

API 声明站点,已过时

|

API 声明站点,通常不推荐使用

|
| --- | --- | --- | --- |
| 不推荐使用 | 没有警告 | 普通折旧警告 | 删除弃用警告 |
| 通常已弃用 | 没有警告 | 没有警告 | 删除弃用警告 |
| 已弃用 | 没有警告 | 没有警告 | 删除弃用警告 |

在一种情况下发出的警告需要稍微解释一下,在这种情况下,API 和它的使用站点最终都被否决了。API 和使用它的代码都已经被弃用了,而且它们都将在未来被移除,那么在这种情况下得到警告有什么意义呢?这样做是为了涵盖最终被否决的 API 和它的使用位置在两个不同的代码库中并且被独立维护的情况。如果 use-site 代码库比 API 代码库存在的时间长,use-site 将得到一个意外的运行时错误,因为它使用的 API 不再存在。在使用站点发布一个警告将会给它的维护者一个机会来计划替代方案,以防在使用站点的代码之前,最终被否决的 API 消失。

如果使用@SuppressWarnings("deprecation"),编译器只抑制普通的反对警告。要抑制删除警告,您需要使用@SuppressWarnings("removal")。如果你想抑制普通警告和删除警告,你需要使用@SuppressWarnings({"deprecation", "removal"})

作为一个例子,我用一个简单的例子向您展示了不赞成使用的 API 的所有用例,使用了不赞成使用的 API,并且取消了警告。在示例中,我只反对方法,并使用它们来生成编译时警告。然而,你并不仅限于贬低方法。对这些方法的注解应该有助于您理解预期的行为。清单 1-18 包含一个名为Box的类的代码。该类包含三种方法,每种不推荐使用的类别中有一种,即不推荐使用、通常不推荐使用和最终不推荐使用。我保持了类的简单性,所以你可以把重点放在使用的弃用上。编译Box类不会生成任何不推荐使用的警告,因为该类不使用任何不推荐使用的 API 相反,它包含了不推荐使用的 API。

// Box.java
package com.jdojo.annotation;
/**
* This class is used to demonstrate how to deprecate APIs.
*/
public class Box {
    /**
     * Not deprecated
     */
    public static void notDeprecated() {
        System.out.println("notDeprecated...");
    }
    /**
     * Deprecated ordinarily.
     * @deprecated  Do not use it.
     */
    @Deprecated(since="2")
    public static void deprecatedOrdinarily() {
        System.out.println("deprecatedOrdinarily...");
    }
    /**
     * Deprecated terminally.
     * @deprecated  It will be removed in a future release.
     * Migrate your code now.
     */
    @Deprecated(since="2", forRemoval=true)
    public static void deprecatedTerminally() {
        System.out.println("deprecatedTerminally...");
    }
}

Listing 1-18A Box Class with Three Types of Methods: Not Deprecated, Ordinarily Deprecated, and Terminally Deprecated

清单 1-19 包含了一个BoxTest类的代码。该类使用了Box类的所有方法。在BoxTest类中的一些方法已经被弃用。前九个方法对应于表 1-2 中的九个用例,将生成四个弃用警告——一个普通警告和三个终止警告。像m4X()这样命名的方法,其中X是一个数字,向您展示了如何抑制普通的和最终的弃用警告。

// BoxTest.java
package com.jdojo.annotation;

public class BoxTest {
    /**
     * API: Not deprecated
     * Use-site: Not deprecated
     * Deprecation warning: No warning
     */
    public static void m11() {
        Box.notDeprecated();
    }
    /**
    * API: Ordinarily deprecated
    * Use-site: Not deprecated
    * Deprecation warning: No warning
    */
    public static void m12() {
        Box.deprecatedOrdinarily();
    }
    /**
     * API: Terminally deprecated
     * Use-site: Not deprecated
     * Deprecation warning: Removal warning
     */

    public static void m13() {
        Box.deprecatedTerminally();
    }
    /**
     * API: Not deprecated
     * Use-site: Ordinarily deprecated
     * Deprecation warning: No warning
     * @deprecated Dangerous to use.
     */
    @Deprecated(since="1.1")
    public static void m21() {
        Box.notDeprecated();
    }
    /**
    * API: Ordinarily deprecated
    * Use-site: Ordinarily deprecated
    * Deprecation warning: No warning
    * @deprecated Dangerous to use.
    */
    @Deprecated(since="1.1")
    public static void m22() {
        Box.deprecatedOrdinarily();
    }

    /**
     * API: Terminally deprecated
     * Use-site: Ordinarily deprecated
     * Deprecation warning: Removal warning
     * @deprecated Dangerous to use.
    */
    @Deprecated(since="1.1")
    public static void m23() {
        Box.deprecatedTerminally();
    }
    /**
     * API: Not deprecated
     * Use-site: Terminally deprecated
     * Deprecation warning: No warning
     * @deprecated Going away.
     */
    @Deprecated(since="1.1", forRemoval=true)
    public static void m31() {
        Box.notDeprecated();
    }

    /**
    * API: Ordinarily deprecated
    * Use-site: Terminally deprecated
    * Deprecation warning: No warning
    * @deprecated Going away.
    */
    @Deprecated(since="1.1", forRemoval=true)
    public static void m32() {
        Box.deprecatedOrdinarily();
    }
    /**
     * API: Terminally deprecated
     * Use-site: Terminally deprecated
     * Deprecation warning: Removal warning
     * @deprecated Going away.
    */
    @Deprecated(since="1.1", forRemoval=true)
    public static void m33() {
        Box.deprecatedTerminally();
    }
    /**
     * API: Ordinarily and Terminally deprecated
     * Use-site: Not deprecated
     * Deprecation warning: Ordinary and removal warnings
    */
    public static void m41() {
        Box.deprecatedOrdinarily();
        Box.deprecatedTerminally();
    }

    /**
     * API: Ordinarily and Terminally deprecated
     * Use-site: Not deprecated
     * Deprecation warning: Ordinary warnings
    */    

    @SuppressWarnings("deprecation")
    public static void m42() {
        Box.deprecatedOrdinarily();
        Box.deprecatedTerminally();
    }
    /**
     * API: Ordinarily and Terminally deprecated
     * Use-site: Not deprecated
     * Deprecation warning: Removal warnings
    */
    @SuppressWarnings("removal")
    public static void m43() {
        Box.deprecatedOrdinarily();
        Box.deprecatedTerminally();
    }
    /**
     * API: Ordinarily and Terminally deprecated
     * Use-site: Not deprecated
     * Deprecation warning: Removal warnings
    */
    @SuppressWarnings({"deprecation", "removal"})
    public static void m44() {
        Box.deprecatedOrdinarily();
        Box.deprecatedTerminally();
    }
}

Listing 1-19A BoxTest Class That Uses Deprecated APIs and Suppresses Deprecation Warnings

您需要使用-Xlint:deprecation编译器标志来编译BoxTest类,因此编译器会发出弃用警告:

C:\Java9LanguageFeatures>javac -Xlint:deprecation ^
    -d build\modules\jdojo.annotation ^
src\jdojo.annotation\classes\com\jdojo\annotation\
    BoxTest.java
src\jdojo.annotation\classes\com\jdojo\annotation\
    BoxTest.java:20: warning: [deprecation]
deprecatedOrdinarily() in Box has been deprecated
        Box.deprecatedOrdinarily();
           ^

src\jdojo.annotation\classes\com\jdojo\annotation\
    BoxTest.java:29: warning: [removal]
deprecatedTerminally() in Box has been deprecated
    and marked for removal
        Box.deprecatedTerminally();
           ^
src\jdojo.annotation\classes\com\jdojo\annotation\
    BoxTest.java:62: warning: [removal]
deprecatedTerminally() in Box has been deprecated
    and marked for removal
        Box.deprecatedTerminally();
           ^
src\jdojo.annotation\classes\com\jdojo\annotation\
    BoxTest.java:95: warning: [removal]
deprecatedTerminally() in Box has been deprecated
    and marked for removal
        Box.deprecatedTerminally();
           ^
src\jdojo.annotation\classes\com\jdojo\annotation\
    BoxTest.java:104: warning: [deprecation]
deprecatedOrdinarily() in Box has been deprecated
        Box.deprecatedOrdinarily();
           ^
src\jdojo.annotation\classes\com\jdojo\annotation\
    BoxTest.java:105: warning: [removal]
deprecatedTerminally() in Box has been deprecated
    and marked for removal
        Box.deprecatedTerminally();
           ^
src\jdojo.annotation\classes\com\jdojo\annotation\
    BoxTest.java:116: warning: [removal]
deprecatedTerminally() in Box has been deprecated
    and marked for removal
        Box.deprecatedTerminally();
           ^
src\jdojo.annotation\classes\com\jdojo\annotation\
    BoxTest.java:126: warning: [deprecation]
deprecatedOrdinarily() in Box has been deprecated
        Box.deprecatedOrdinarily();
           ^
8 warnings

(命令中的“批注”后没有换行符和空格。)

回想一下,弃用警告是编译时警告。如果为您部署的应用程序编译的代码开始使用通常不推荐使用的 API,或者由于曾经有效的 API 已被最终不推荐使用并移除而生成运行时错误,您将不会收到任何警告。JDK9 和更高版本通过提供一个名为jdeprscan的静态分析工具来改善这种情况,该工具扫描编译后的代码,给出正在使用的不推荐使用的 API 的列表。目前,该工具仅报告了已弃用的 JDK API 的使用。如果您编译的代码使用了来自其他库(比如 Spring 或 Hibernate)或您自己的库的不推荐使用的 API,该工具将不会报告这些使用。

jdeprscan工具在JDK_HOME\bin目录中。使用该工具的一般语法如下:

jdeprscan [options] {dir|jar|class}

这里,[options]是零个或多个选项的列表。您可以指定一系列以空格分隔的目录、jar、完全限定的类名或类文件路径作为要扫描的参数。可用选项如下:

  • -l–list

  • –class-path <CLASSPATH>

  • –for-removal

  • –release <6|7|8|9|...|17>

  • -v–verbose

  • –version

  • –full-version

  • -h–help

–list选项列出了 Java SE 中一组不推荐使用的 API。使用此选项时,不应指定指定编译类位置的参数。

–class-path指定在扫描过程中用于查找依赖类的类路径。

–for-removal选项将扫描或列表限制为仅扫描那些不赞成删除的 API。

–release选项指定了 Java SE 版本,该版本在扫描期间提供了一组不推荐使用的 API。例如,要列出 JDK15 中所有不推荐使用的 API,您将使用如下工具:jdeprscan –list –release 15

–verbose选项在扫描过程中打印附加信息。

–version–full-version选项分别打印jdeprscan工具的缩略版和完整版。

–help选项打印关于jdeprscan工具的详细帮助信息。

清单 1-20 包含了一个JDeprScanTest类的代码。代码很简单。它的目的只是编译,而不是运行。运行它不会产生任何有趣的输出。它创建了两个线程。一个线程使用 thread 类的stop()方法停止,另一个线程使用Thread类的destroy()方法销毁。分别从 JDK 1.2 和 JDK 1.5 开始,stop()destroy()方法已经被弃用。JDK9 最终弃用了destroy()方法,而它继续保留了通常弃用的stop()方法。我在下面的例子中使用这个类。

// JDeprScanTest.java
package com.jdojo.annotation;
public class JDeprScanTest {
    public static void main(String[] args) {
        Thread t = new Thread(() ->
            System.out.println("Test"));
        t.start();
        t.stop();
        Thread t2 = new Thread(() ->
            System.out.println("Test"));
        t2.start();
        t2.destroy();
    }
}

Listing 1-20A JDeprScanTest Class That Uses the Ordinarily Deprecated Method stop() and the Terminally Deprecated Method destroy() of the Thread Class

以下命令打印 JDK16 中所有不推荐使用的 API 的列表。该命令需要几秒钟时间开始打印结果,因为它扫描整个 JDK:

C:\Java9LanguageFeatures>jdeprscan --list --release 16
@Deprecated(since="16", forRemoval=true)
    javax.management.relation.RoleStatus()
@Deprecated(since="9") interface
    java.beans.AppletInitializer
...

以下命令打印 JDK16 中所有已过时的 API。也就是说,它打印所有已标记为在未来版本中删除的不推荐使用的 API:

C:\Java9LanguageFeatures>jdeprscan --list --for-removal ^
    --release 16
@Deprecated(since="16", forRemoval=true)
    javax.management.relation.RoleStatus()
...

以下命令打印 JDK8 中不推荐使用的所有 API 的列表:

C:\ Java9LanguageFeatures >jdeprscan --list --release 8
@Deprecated class javax.swing.text.TableView.TableCell
...

以下命令打印了 JDK16 中由java.lang.Thread类使用的不推荐使用的 API 列表:

C:\Java9LanguageFeatures>jdeprscan --release 16 ^
    java.lang.Thread
class java/lang/Thread uses deprecated method
    java/lang/Thread::resume()V (forRemoval=true)

注意,前面的命令没有打印出Thread类中不推荐使用的 API 的列表。更确切地说,它打印了使用这些不赞成使用的 API 的Thread类中的 API 列表。

以下命令列出了某个目录中不推荐使用的 JDK API 的所有用法:

C:\Java9LanguageFeatures>jdeprscan --release 16 ^
    path/to/folder
class com/test/Jdk17 uses deprecated method
    java/lang/Integer::<init>(I)V (forRemoval=true)

jdeprscan工具是一个静态分析工具,所以它将跳过不赞成使用的 API 的动态使用。例如,您可以使用反射调用不推荐使用的方法,该工具将在扫描过程中错过该方法。您还可以在由ServiceLoader加载的提供程序中调用不推荐使用的方法,这将被该工具忽略。

在 JDK9 之前,如果您使用import语句导入不推荐使用的构造,编译器会生成警告,即使您在不推荐使用的导入构造的所有使用位置上使用了@SuppressWarnings注解。如果您试图在代码中消除所有不赞成使用的警告,这是一件令人烦恼的事情。你就是无法摆脱它们,因为你无法注解import的陈述。JDK9 对此进行了改进,省略了对import语句的反对警告。

取消命名的编译时警告

SuppressWarnings注解类型用于隐藏指定的编译时警告。它声明了一个名为value的元素,其数据类型是一个String数组。让我们考虑一下SuppressWarningsTest类的代码,它在test()方法中使用了ArrayList<T>的原始类型。当您使用原始类型时,编译器会生成未检查的命名警告。见清单 1-21 。

// SuppressWarningsTest.java
package com.jdojo.annotation;
import java.util.ArrayList;
public class SuppressWarningsTest {
    public void test() {
        ArrayList list = new ArrayList();
        list.add("Hello"); // The compiler issues an
                           // unchecked warning
    }
}

Listing 1-21A Class That Will Generate Warnings When Compiled

使用以下命令编译带有生成未检查警告选项的SuppressWarningsTest类:

javac -Xlint:unchecked SuppressWarningsTest.java
com\jdojo\annotation\SuppressWarningsTest.java:10:
    warning: [unchecked] unchecked call to add(E) as a
    member of the raw type ArrayList
                list.add("Hello");
                        ^
  where E is a type-variable
    E extends Object declared in class ArrayList
1 warning

作为开发人员,有时您会意识到这种编译器警告,并且希望在编译代码时抑制它们。您可以通过在程序元素上使用@SuppressWarnings注解来实现这一点,方法是提供一个要取消的警告名称列表。例如,如果在类声明中使用它,则该类声明中所有方法的所有指定警告都将被取消。建议您在想要取消警告的最内层程序元素上使用此批注。

清单 1-22 在test()方法上使用了一个@SuppressWarnings注解。它指定了两个命名警告:“未检查”和“不推荐”test()方法不包含会生成“已弃用”警告的代码。这里包含它是为了向您展示,您可以使用一个SuppressWarnings注解来抑制多个命名警告。如果您使用前面显示的相同选项重新编译SuppressWarningsTest类,它不会生成任何编译器警告。

// SuppressWarningsTest.java
package com.jdojo.annotation;
import java.util.ArrayList;
public class SuppressWarningsTest {
    @SuppressWarnings({"unchecked", "deprecation"})
    public void test() {
        ArrayList list = new ArrayList();
        list.add("Hello"); // The compiler does not
                           // issue an unchecked warning
    }
}

Listing 1-22The Modified Version of the SuppressWarningsTest Class

重写方法

java.lang.Override注解类型是标记注解类型。它只能用在方法上。它表示用该注解注解的方法覆盖了在其父类型中声明的方法。这对于开发人员避免导致程序逻辑错误的错别字非常有帮助。如果你想覆盖一个超类型中的方法,建议用一个@Override注解来注解被覆盖的方法。编译器将确保带注解的方法确实覆盖了超类型中的方法。如果带注解的方法没有覆盖超类型中的方法,编译器将生成一个错误。

考虑两个类,AB。类别B继承自类别A。类B中的m1()方法覆盖了其超类A中的m1()方法。类B中的m1()方法上的注解@Override只是对这个意图做了一个声明。编译器验证该语句,并在以下情况下发现其为真:

public class A {
    public void m1() {
    }
}
public class B extends A {
    @Override
    public void m1() {
    }
}

让我们考虑一下类别C:

// Won't compile because m2() does not override any method
public class C extends A {
    @Override
    public void m2() {
    }
}

C中的方法m2()有一个@Override注解。然而,在其超类A中没有m2()方法。方法m2()是类C中的一个新方法。编译器发现类C中的方法m2()没有覆盖任何超类方法,即使它的开发者已经指出了这一点。在这种情况下,编译器会生成一个错误。

声明功能接口

具有一个抽象方法声明的接口称为函数接口。以前,函数接口被称为 SAM(单一抽象方法)类型。编译器会验证所有用@FunctionalInterface标注的接口是否真的包含且只有一个抽象方法。如果用该注解注解的接口不起作用,就会产生编译时错误。在类、注解类型和枚举上使用此注解也是一个编译时错误。FunctionalInterface注解类型是标记注解。

下面的Runner接口声明使用了一个@FunctionalInterface注解。接口声明可以很好地编译:

@FunctionalInterface
public interface Runner {
    void run();
}

下面的Job接口声明使用了一个@FunctionalInterface注解,这将产生一个编译时错误,因为Job接口声明了两个抽象方法,因此它不是一个函数接口:

@FunctionalInterface
public interface Job {
    void run();
    void abort();
}

下面的Test类声明使用了一个@FunctionalInterface注解,这将产生一个编译时错误,因为@FunctionalInterface注解只能在接口上使用:

@FunctionalInterface
public class Test {
    public void test() {
        // Code goes here
    }
}

Note

一个只有一个抽象方法的接口总是一个函数接口,不管它是否有注解。注解的使用指示编译器验证接口确实是一个功能接口。

注解包

注解程序元素(如类和字段)是很直观的,因为您是在声明它们时对它们进行注解的。你如何注解一个包?包声明作为顶级类型声明的一部分出现在编译单元中。此外,相同的包声明在不同的编译单元中出现多次。问题出现了:如何以及在哪里注解一个包声明?

您需要创建一个名为package-info.java的文件,并将带注解的包声明放入其中。清单 1-23 显示了package-info.java文件的内容。当你编译package-info.java文件时,会创建一个类文件。

// package-info.java
@Version(major=1, minor=0)
package com.jdojo.annotation;

Listing 1-23Contents of a package-info.java File

您可能需要一些import语句来导入注解类型,或者您可以在package-info.java文件中使用注解类型的完全限定名。即使import语句出现在包声明之后,使用导入的类型也是可以的。在一个package-info.java文件中可以有如下内容:

// package-info.java
@com.jdojo.myannotations.Author("John Jacobs")
@Reviewer("Wally Inman")
package com.jdojo.annotation;
import com.jdojo.myannotations.Reviewer;

注解模块

您可以在module声明中使用注解。为此,java.lang.annotation.ElementType枚举有一个名为MODULE的值。如果在注解声明中使用MODULE作为目标类型,它允许在模块中使用注解类型。两个注解java.lang.Deprecatedjava.lang.SuppressWarnings可用于模块声明,如下所示:

@Deprecated(since="1.2", forRemoval=true)
@SuppressWarnings("unchecked")
module com.jdojo.myModule {
    // Module statements go here
}

当一个模块被弃用时,在requires中使用该模块,而不是在exportsopens语句中使用该模块,会导致发出警告。该规则基于这样一个事实,即如果模块M被弃用,需要得到弃用警告的模块用户将使用“requires M”语句。其他语句,如exportsopens都在不推荐使用的模块中。不推荐使用的模块不会导致针对模块内类型的使用发出警告。同样,如果在模块声明中取消了警告,则该取消适用于模块声明中的元素,而不适用于该模块中包含的类型。

Note

您不能注解单个模块语句。例如,您不能用一个@Deprecated注解来注解一个exports语句,表示导出的包将在未来的版本中被删除。在早期设计阶段,考虑并拒绝了该功能,理由是该功能将占用大量时间,而目前并不需要。如果需要,这可以在将来添加。

运行时访问注解

访问程序元素上的注解很容易。程序元素上的注解是 Java 对象。你只需要知道如何在运行时获得一个注解类型的对象的引用。允许您访问注解的程序元素实现了java.lang.reflect.AnnotatedElement接口。在AnnotatedElement接口中有几种方法可以让您访问程序元素的注解。此接口中的方法允许您检索程序元素上的所有注解、程序元素上所有声明的注解以及程序元素上指定类型的注解。我将展示一些使用这些方法的例子。下面的类实现了AnnotatedElement接口:

  • java.lang.Class

  • java.lang.reflect.Executable

  • java.lang.reflect.Constructor

  • java.lang.reflect.Field

  • java.lang.reflect.Method

  • java.lang.reflect.Module

  • java.lang.reflect.Parameter

  • java.lang.Package

  • java.lang.reflect.AccessibleObject

接口的方法被用来访问这些类型对象的注解。

Caution

非常重要的一点是,要在运行时访问一个注解类型,必须用带有runtime保留策略的Retention元注解对其进行注解。如果一个程序元素有多个注解,那么您将只能访问那些使用runtime作为保留策略的注解。

假设您有一个Test类,您想打印它的所有注解。下面的代码片段将打印Test类的类声明上的所有注解:

// Get the class object reference
Class<Test> cls = Test.class;
// Get all annotations on the class declaration
Annotation[] allAnns = cls.getAnnotations();
System.out.println("Annotation count: " + allAnns.length);
// Print all annotations
for (Annotation ann : allAnns) {
    System.out.println(ann.toString());
}

批注接口的toString()方法返回批注的字符串表示。假设您想在Test类上打印Version注解。您可以这样做:

Class<Test> cls = Test.class;
// Get the instance of the Version annotation of Test
// class
Version v = cls.getAnnotation(Version.class);
if (v == null) {
    System.out.println(
        "Version annotation is not present.");
} else {
    int major = v.major();
    int minor = v.minor();
    System.out.println("Version: major=" + major +
        ", minor=" + minor);
}

这段代码显示了您可以使用major()minor()方法来读取Version注解的majorminor元素的值。它还展示了您可以声明一个注解类型的变量(例如Version v,它可以引用该注解类型的一个实例。注解类型的实例由 Java 运行时创建。永远不要使用new操作符创建注解类型的实例。

您将使用VersionDeprecated注解类型来注解您的程序元素,并在运行时访问这些注解。您还将注解一个包声明和一个方法声明。您将使用Version注解类型的代码,如清单 1-24 中所列。请注意,它使用了@Retention(RetentionPolicy.RUNTIME)注解,需要在运行时读取它的实例。

// Version.java
package com.jdojo.annotation;

import java.lang.annotation.Documented;
import java.lang.annotation.Target;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

@Target({ElementType.TYPE, ElementType.CONSTRUCTOR,
    ElementType.METHOD, ElementType.MODULE,
    ElementType.PACKAGE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Version {
    int major();
    int minor();
}

Listing 1-24A Version Annotation Type

清单 1-25 显示了您需要保存在package-info.java文件中并与其他程序一起编译的代码。它注解了com.jdojo.annotation包。清单 1-26 包含了一个用于演示的类的代码,其中有一些注解。

// AccessAnnotation.java
package com.jdojo.annotation;
@Version(major=1, minor=0)
public class AccessAnnotation {
    @Version(major=1, minor=1)
    public void testMethod1() {
        // Code goes here
    }
    @Version(major=1, minor=2)
    @Deprecated
    public void testMethod2() {
        // Code goes here
    }
}

Listing 1-26AccessAnnotation Class Has Some Annotations, Which Will Be Accessed at Runtime

// package-info.java
@Version(major=1, minor=0)
package com.jdojo.annotation;

Listing 1-25Contents of the package-info.java File

清单 1-27 是演示如何在运行时访问注解的程序。它的输出显示您能够成功地读取在AccessAnnotation类中使用的所有注解。printAnnotations()方法访问注解。它接受一个AnnotatedElement类型的参数,并打印其参数的所有注解。如果注解是Version注解类型,它打印其主要和次要版本的值。

// AccessAnnotationTest.java
package com.jdojo.annotation;
import java.lang.annotation.Annotation;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Method;

public class AccessAnnotationTest {
    public static void main(String[] args) {
        // Read annotations on the class declaration
        Class<AccessAnnotation> cls =
            AccessAnnotation.class;
        System.out.println("Annotations for class: " +
            cls.getName());
        printAnnotations(cls);
        // Read annotations on the package declaration
        Package p = cls.getPackage();
        System.out.println("Annotations for package: " +
            p.getName());
        printAnnotations(p);
        // Read annotations on the methods declarations
        System.out.println("Method annotations:");
        Method[] methodList = cls.getDeclaredMethods();
        for (Method m : methodList) {
            System.out.println("Annotations for method: " +
                m.getName());
            printAnnotations(m);
        }
    }
    public static void printAnnotations(
            AnnotatedElement programElement) {
        Annotation[] annList = programElement.
            getAnnotations();
        for (Annotation ann : annList) {
            System.out.println(ann);
            if (ann instanceof Version) {
                Version v = (Version) ann;
                int major = v.major();
                int minor = v.minor();
                System.out.println(
                    "Found Version annotation: "
                        + "major=" + major +
                        ", minor=" + minor);
            }
        }
        System.out.println();
    }
}
Annotations for class:
    com.jdojo.annotation.AccessAnnotation
@com.jdojo.annotation.Version(major=1, minor=0)
Found Version annotation: major=1, minor=0
Annotations for package: com.jdojo.annotation
@com.jdojo.annotation.Version(major=1, minor=0)
Found Version annotation: major=1, minor=0
Method annotations:
Annotations for method: testMethod1
@com.jdojo.annotation.Version(major=1, minor=1)
Found Version annotation: major=1, minor=1
Annotations for method: testMethod2
@com.jdojo.annotation.Version(major=1, minor=2)
Found Version annotation: major=1, minor=2
@java.lang.Deprecated(forRemoval=false, since="")

Listing 1-27Using the AccessAnnotationTest Class to Access Annotations

访问可重复注解的实例略有不同。回想一下,可重复注解有一个包含注解类型的同伴。例如,您声明了一个ChangeLogs注解类型,它是ChangeLog可重复注解类型的包含注解类型。您可以使用注解类型或包含注解类型来访问重复的注解。使用getAnnotationsByType()方法,向其传递可重复注解类型的类引用,以获取数组中可重复注解的实例。使用getAnnotation()方法,向其传递包含注解类型的类引用,以获取可重复注解的实例,作为其包含注解类型的实例。

清单 1-28 包含一个RepeatableAnnTest类的代码。类声明已经用ChangeLog注解注解了两次。main()方法使用这两种方法访问类声明中重复的注解。

// RepeatableAnnTest.java
package com.jdojo.annotation;
@ChangeLog(date = "09/18/2017",
    comments = "Declared the class")
@ChangeLog(date = "10/22/2017",
    comments = "Added the main() method")
public class RepeatableAnnTest {
    public static void main(String[] args) {
        Class<RepeatableAnnTest> mainClass =
            RepeatableAnnTest.class;
        Class<ChangeLog> annClass = ChangeLog.class;
        // Access annotations using the ChangeLog type
        System.out.println("Using the ChangeLog type...");
        ChangeLog[] annList = mainClass.
            getAnnotationsByType(ChangeLog.class);
        for (ChangeLog log : annList) {
            System.out.println("Date=" + log.date() +
            ", Comments=" + log.comments());
        }
        // Access annotations using the ChangeLogs
        // containing annotation type
        System.out.println(
            "\nUsing the ChangeLogs type...");
        Class<ChangeLogs> containingAnnClass =
            ChangeLogs.class;
        ChangeLogs logs = mainClass.getAnnotation(
            containingAnnClass);
        for (ChangeLog log : logs.value()) {
            System.out.println("Date=" + log.date() +
                ", Comments=" + log.comments());
        }
    }
}
Using the ChangeLog type...
Date=09/18/2017, Comments=Declared the class
Date=10/22/2017, Comments=Added the main() method

Using the ChangeLogs type...
Date=09/18/2017, Comments=Declared the class
Date=10/22/2017, Comments=Added the main() method

Listing 1-28Accessing Instances of Repeatable Annotations at Runtime

演变注解类型

注解类型可以在不破坏使用它的现有代码的情况下发展。如果向注解类型添加新元素,则需要提供其默认值。注解的所有现有实例都将使用新元素的默认值。如果在没有为元素指定默认值的情况下向现有批注类型添加新元素,使用该批注的代码将会中断。

源代码级别的注解处理

这一部分是为有经验的程序员准备的。如果你是第一次学习 Java,你可以跳过这一节。我们将详细讨论如何开发注解处理器,以便在编译 Java 程序时在源代码级别处理注解。

Note

华盛顿大学开发了一个 Checker 框架,它包含了许多程序中使用的注解。它还附带了许多注解处理器。您可以从 https://checkerframework.org/ 下载 Checker 框架。它包含使用不同类型处理器的教程和如何创建自己的处理器的教程。

Java 允许您在运行时和编译时处理注解。您已经看到了如何在运行时处理注解。现在,我简要地讨论如何在编译时(或者在源代码级别)处理注解。

为什么要在编译时处理注解?编译时处理注解提供了多种可能性,可以在应用程序开发过程中帮助 Java 程序员。它也极大地帮助了 Java 工具的开发者。例如,样板代码和配置文件可以基于源代码中的注解生成;基于注解的定制规则可以在编译时进行验证,等等。

编译时的注解处理是一个两步过程。首先,您需要编写一个定制的注解处理器。其次,您需要使用javac命令行实用工具。您需要使用–processor-modulepath选项指定自定义注解处理器到javac编译器的模块路径。以下命令编译 Java 源文件MySourceFile.java:

javac --processor-module-path <path> MySourceFile.java

使用-proc选项,javac命令让您指定是否要处理注解和/或编译源文件。您可以使用-proc选项作为-proc:none-proc:only-proc:none选项不执行标注处理。它只编译源文件。-proc:only选项只执行注解处理,跳过源文件编译。如果在同一个命令中指定了-proc:none-processor选项,则-processor选项被忽略。以下命令使用定制处理器处理源文件MySourceFile.java中的注解:MyProcessor1MyProcessor2。它不编译MySourceFile.java文件中的源代码:

javac -proc:only --processor-module-path <path> ^
    MySourceFile.java

要查看运行中的编译时注解处理,您必须使用javax.annotation.processing包中的类编写一个注解处理器,该包在java.compiler模块中。

在编写自定义注解处理器时,您经常需要访问源代码中的元素,例如,类名及其修饰符、方法名及其返回类型等。您需要使用javax.lang.model包及其子包中的类来处理源代码的元素。在您的例子中,您将为您的@Version注解编写一个注解处理器。它将验证源代码中使用的所有@Version注解,以确保Versionmajorminor值总是零或大于零。例如,如果源代码中使用了@Version(major=-1, minor=0),注解处理器会打印一条错误消息,因为版本的主值是负的。

注解处理器是一个类的对象,它实现了Processor接口。AbstractProcessor类是一个抽象注解处理器,它为Processor接口的所有方法提供了一个默认实现,除了为process()方法提供了一个实现。默认实现在大多数情况下都可以。要创建自己的处理器,您需要从AbstractProcessor类继承您的处理器类,并为process()方法提供一个实现。如果AbstractProcessor类不能满足您的需要,您可以创建自己的处理器类,它实现了Processor接口。让我们调用您的处理器类VersionProcessor,它继承自AbstractProcessor类,如下所示:

public class VersionProcessor extends AbstractProcessor {
    // Code goes here
}

注解处理器对象由编译器使用无参数构造函数进行实例化。您的处理器类必须有一个无参数构造函数,以便编译器可以实例化它。您的VersionProcessor类的默认构造函数将满足这一要求。

下一步是向处理器类添加两条信息。第一个问题是这个处理器支持哪种注解处理。您可以在类级别使用@SupportedAnnotationTypes注解来指定支持的注解类型。下面的代码片段显示了VersionProcessor支持处理com.jdojo.annotation.Version注解类型:

@SupportedAnnotationTypes({"com.jdojo.annotation.Version"})
public class VersionProcessor extends AbstractProcessor {
    // Code goes here
}

您可以单独使用星号(),也可以将星号作为受支持注解类型的注解名称的一部分。星号用作通配符。例如,“com.jdojo.”表示名称以“com.jdojo”开头的任何批注类型。仅星号(“”)表示所有注解类型。请注意,当星号用作名称的一部分时,名称的形式必须是PartialName.*。例如,“com”和“com。*jdojo "是受支持的批注类型中星号的无效用法。您可以使用SupportedAnnotationTypes注解传递多个支持的注解类型。下面的代码片段显示处理器支持处理com.jdojo.Ann1注解和任何名称以com.jdojo.annotation开头的注解:

@SupportedAnnotationTypes({"com.jdojo.Ann1",
    "com.jdojo.annotation.*"})

您需要使用一个@SupportedSourceVersion注解来指定您的处理器支持的最新源代码版本。以下代码片段将源代码版本 17 指定为VersionProcessor类支持的源代码版本:

@SupportedAnnotationTypes({"com.jdojo.annotation.Version"})
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public class VersionProcessor extends AbstractProcessor {
    // Code goes here
}

下一步是在处理器类中提供process()方法的实现。注解处理是循环执行的。RoundEnvironment接口的一个实例代表一轮。javac编译器通过传递处理器声明支持的所有注解和一个RoundEnvironment对象来调用处理器的process()方法。process()方法的返回类型是布尔值。如果它返回true,传递给它的注解就被认为是处理器所要求的。所声明的注解不会被传递给其他处理器。如果它返回false,传递给它的注解被认为没有被声明,其他处理器将被要求处理它们。下面的代码片段展示了process()方法的框架:

public boolean process(Set<? extends TypeElement>
        annotations, RoundEnvironment roundEnv) {
    // The processor code goes here
}

您在process()方法中编写的代码取决于您的需求。在您的例子中,您想要查看源代码中每个@Version注解的majorminor值。如果它们中的任何一个小于零,您希望打印一条错误消息。为了处理每个Version注解,您将遍历传递给process()方法的所有Version注解实例,如下所示:

for (TypeElement currentAnnotation : annotations) {
    // Code to validate each Version annotation goes here
}

您可以使用TypeElement接口的getQualifiedName()方法获得注解的全限定名称:

Name qualifiedName = currentAnnotation.getQualifiedName();
// Check if it is a Version annotation
if (qualifiedName.contentEquals(
        "com.jdojo.annotation.Version")) {
    // Get Version annotation values to validate
}

一旦确定有了一个Version注解,就需要从源代码中获取它的所有实例。要从源代码中获取信息,需要使用RoundEnvironment对象。下面的代码片段将获得源代码的所有元素(例如,类、方法、构造函数等。)用一个Version注解进行了注解:

Set<? extends Element> annotatedElements =
    roundEnv.getElementsAnnotatedWith(currentAnnotation);

此时,您需要遍历所有用Version注解标注的元素;获取出现在它们上面的Version注解的实例;并验证majorminor元素的值。您可以按如下方式执行此逻辑:

for (Element element : annotatedElements) {
    Version v = element.getAnnotation(Version.class);
    int major = v.major();
    int minor = v.minor();
    if (major < 0 || minor < 0) {
        // Print the error message here
    }
}

您可以使用MessagerprintMessage()方法打印错误信息。processingEnv是在AbstractProcessor类中定义的一个实例变量,您可以在处理器内部使用它来获取Messager对象引用,如下所示。如果您将源代码元素的引用传递给printMessage()方法,您的消息将被格式化为包含源代码文件名和该元素源代码中的行号。printMessage()方法的第一个参数表示消息的类型。您可以使用Kind.NOTEKind.WARNING作为第一个参数来分别打印注解和警告。

String errorMsg = "Version cannot be negative. major=" +
    major + " minor=" + minor;
Messager messager = this.processingEnv.getMessager();
messager.printMessage(Kind.ERROR, errorMsg, element);

最后,您需要从process()方法中返回truefalse。如果处理器返回true,这意味着它声明了传递给它的所有注解。否则,这些注解被认为是无人认领的,它们将被传递给其他处理器。通常,注解处理器应该封装在一个单独的模块中。清单 1-29 包含了一个jdojo.annotation.processor模块的声明,该模块包含了用于Version注解类型的名为VersionProcessor的注解处理器,如清单 1-30 所示。

// module-info.java
module jdojo.annotation.processor {
    exports com.jdojo.annotation.processor;
    requires jdojo.annotation;
    requires java.compiler;
    provides javax.annotation.processing.Processor
        with
        com.jdojo.annotation.processor.VersionProcessor;
}

Listing 1-29The Declaration for a jdojo.annotation.processor Module

该模块读取jdojo.annotation模块,因为它使用了VersionProcessor类中的Version注解类型。它读取java.compiler模块以使用注解处理器相关的类型。注意模块声明中 provides 语句的使用。Java 将在 provides 语句的with子句中提到的处理器模块路径上加载所有注解处理器。该语句指定了VersionProcessor类为Processor服务接口提供了一个实现。有关provides声明和实现服务的更多详细信息,请参考第七章。

// VersionProcessor.java
package com.jdojo.annotation.processor;

import java.util.Set;
import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.Messager;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.Name;
import javax.lang.model.element.TypeElement;
import javax.tools.Diagnostic.Kind;

@SupportedAnnotationTypes({
    "com.jdojo.annotation.Version"})
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public class VersionProcessor extends AbstractProcessor {
    // A no-args constructor is required for an
    // annotation processor
    public VersionProcessor() {
    }
    @Override
    public boolean process(Set<? extends TypeElement>
        annotations, RoundEnvironment roundEnv) {
        // Process all annotations
        for (TypeElement currentAnnotation: annotations) {
            Name qualifiedName = currentAnnotation.
                getQualifiedName();
            // check if it is a Version annotation
            if (qualifiedName.contentEquals(
                    "com.jdojo.annotation.Version" )) {
                // Look at all elements that have Version
                // annotations
                Set<? extends Element> annotatedElements;
                annotatedElements = roundEnv.
                    getElementsAnnotatedWith(
                    currentAnnotation);
                for (Element element: annotatedElements) {
                    Version v = element.getAnnotation(
                        Version.class);
                    int major = v.major();
                    int minor = v.minor();
                    if (major < 0 || minor < 0) {
                        // Print the error message
                        String errorMsg =
                            "Version cannot be negative." +
                            " major=" + major +
                            " minor=" + minor;
                        Messager messager = this.
                            processingEnv.getMessager();
                        messager.printMessage(Kind.ERROR,
                            errorMsg, element);
                    }

                }
            }
        }
        return true;
    }
}

Listing 1-30An Annotation Processor to Process Version Annotations

现在您有了一个注解处理器。是时候看看它的实际效果了。您需要有一个在Version注解中对majorminor元素使用无效值的源代码。您将把源代码放在一个名为jdojo.annotation.test的模块中,如清单 1-31 所示。清单 1-32 中的VersionProcessorTest类使用了三次Version注解。它为类本身和方法m2()majorminor元素使用负值。当您为VersionProcessorTest类编译源代码时,处理器应该会捕捉到这两个错误。

// VersionProcessorTest.java
package com.jdojo.annotation.test;
@Version(major = -1, minor = 2)
public class VersionProcessorTest {
    @Version(major = 1, minor = 1)
    public void m1() {
    }
    @Version(major = -2, minor = 1)
    public void m2() {
    }
}

Listing 1-32A Test Class to Test VersionProcessor

// module-info.java
module jdojo.annotation.test {
    exports com.jdojo.annotation.test;
    requires jdojo.annotation;
}

Listing 1-31The Declaration of a jdojo.annotation.test Module

要查看处理器的运行情况,您需要运行以下命令。您需要使用–processor-module-path选项为VersionProcessor类的模块指定路径。注解处理器所依赖的模块也应该在处理器模块路径中指定。当命令运行时,编译器将自动发现VersionProcessor作为注解处理器,并将所有@Version实例传递给这个处理器。输出显示两个错误,包括源文件名和在源文件中发现错误的行号:

C:\Java9LanguageFeatures>javac --module-path ^
    dist\jdojo.annotation.jar ^
    --processor-module-path ^
    dist\jdojo.annotation.processor.jar;
    dist\jdojo.annotation.jar ^
    -d build\modules\jdojo.annotation.test
src\jdojo.annotation.test\classes\module-info.java
src\jdojo.annotation.test\classes\com\jdojo\annotation\
    test\VersionProcessorTest.java
src\jdojo.annotation.test\classes\com\jdojo\annotation\
    test\VersionProcessorTest.java:7:
error: Version cannot be negative. major=-1 minor=2
public class VersionProcessorTest {
       ^
src\jdojo.annotation.test\classes\com\jdojo\annotation\
    test\VersionProcessorTest.java:13:
error: Version cannot be negative. major=-2 minor=1
    public void m2() {
                ^
2 errors

(在“dist \ jdo jo . annotation . processor . jar”之后没有换行符和空格。)

摘要

注解是 Java 中的类型。它们用于将信息与 Java 程序中程序元素或类型使用的声明相关联。使用注解不会改变程序的语义。

注解只能在源代码、类文件或运行时使用。它们的可用性由声明注解类型时指定的保留策略控制。

有两种类型的注解:常规注解或简单注解和元注解。注解用于注解程序元素,而元注解用于注解其他注解。当您声明一个注解时,您可以指定它的目标,即它可以注解的程序元素的类型。注解可以在同一个元素上重复。

Java 库包含了许多可以在 Java 程序中使用的注解类型,如DeprecatedOverrideSuppressWarningsFunctionalInterface等。是一些常用的注解类型。它们有编译器支持,这意味着如果用这些注解标注的程序元素不符合特定的规则,编译器会生成错误。

Java 允许您编写注解处理器,这些处理器可以插入到 Java 编译器中,以便在编译 Java 程序时处理注解。您可以编写处理器来实现基于注解的定制规则。

Java 中的弃用是提供 API 生命周期信息的一种方式。放弃一个 API 告诉它的用户迁移出去,因为这个 API 使用起来很危险,有更好的替代品存在,或者它将在未来的版本中被删除。使用不推荐使用的 API 会生成编译时不推荐使用的警告。@deprecated Javadoc 标签和@Deprecated注解一起使用来取代 API 元素,如模块、包、类型、构造函数、方法、字段、参数和局部变量。该注解在运行时被保留。

Deprecated注解类型包含sinceforRemoval作为元素。since元素默认为空字符串。它的值表示 API 元素被弃用的 API 版本。forRemoval元素的类型为布尔型,默认为false。它的值true表示 API 元素将在未来的版本中被移除。

编译器(从 JDK9 开始)根据@Deprecated注解的forRemoval元素的值生成两种类型的弃用警告:当forRemoval=false时的普通弃用警告和针对forRemoval=true的移除警告。

您需要使用@SuppressWarnings("deprecation")来抑制普通警告,使用@SuppressWarnings("removal")来抑制删除警告,使用@SuppressWarnings({"deprecation", "removal"})来抑制这两种类型的警告。仅导入不推荐使用的构造,而不实际使用它,不会生成不推荐使用警告。

练习

练习 1

什么是注解?你如何申报它们?

练习 2

什么是元注解?

运动 3

注解类型和注解实例之间有什么区别?

演习 4

可以从另一个注解类型继承一个注解类型吗?

锻炼 5

什么是标记注解?描述它们的用途。说出 Java SE API 中两个可用的标记注解。

锻炼 6

命名其实例用于注解重写方法的注解类型。这个注解类型的完全限定名是什么?

锻炼 7

批注类型声明中的方法允许哪些返回类型?

运动 8

声明一个名为Table的注解类型。它包含一个名为nameString元素。唯一元素没有任何默认值。此批注只能用于类。它的实例应该在运行时可用。

演习 9

下面的注解类型声明有什么问题?

public @interface Version extends BasicVersion {
    int extended();
}

运动 10

下面的注解类型声明有什么问题?

public @interface Author {
    void name(String firstName, String lastName);
}

简述以下内置元注解的使用:TargetRetentionInheritedDocumentedRepeatableNative

演习 11

声明一个名为ModuleOwner的注解类型,它包含一个元素name,该元素属于String类型。类型的实例应该只保留在源代码中,并且应该只在模块声明中使用。

运动 12

声明一个名为Author的可重复注解类型。它包含两个String类型的元素:firstNamelastName。这个注解可以用在类型、方法和构造函数上。它的实例应该在运行时可用。将Author注解类型包含的注解类型命名为Authors

运动 13

你用什么注解类型来反对你的 API?描述这种注解类型的所有元素。

运动 14

你用什么类型的注解来注解一个函数接口?

运动 15

你如何注解一个包?

演习 16

创建一个名为Owner的注解类型。它应该有一个String类型的元素name。它的实例应该在运行时保留。它应该是可重复的。它应该只用于类型、方法、构造函数和模块。在com.jdojo.annotation.exercises包中创建一个名为jdojo.annotation.test的模块和一个名为Test的类。向类中添加构造函数和方法。用Owner注解类型注解类、它的模块、构造函数和方法。向Test类添加一个main()方法,并编写代码来访问和打印这些Owner注解实例的详细信息。

演习 17

考虑以下名为Status的注解类型声明:

public @interface Status {
    boolean approved() default false;
    String approvedBy();
}

稍后,您需要向Status注解类型添加另一个元素。

修改注解的声明,以包含一个名为approvedOn的新元素,它属于String类型。新元素将包含 ISO 格式的日期,其默认值可以设置为“1900-01-01”。

演习 18

考虑下面名为LuckyNumber的注解类型的声明:

public @interface LuckyNumber {
    int[] value() default {19};
}

LuckyNumber注解类型的下列哪种用法是无效的?解释你的答案。

  1. @LuckyNumber

  2. @LuckyNumber({})

  3. @LuckyNumber(10)

  4. LuckyNumber({8, 10, 19, 28, 29, 26})

  5. LuckyNumber(value={8, 10, 19, 28, 29, 26})

  6. @LuckyNumber(null)

演习 19

给定一个LuckyNumber注解类型,下面的变量声明是否有效?

LuckNumber myLuckNumber = null;

运动 20

考虑下面对一个jdojo.annotation.exercises模块的声明:

module jdojo.annotation.exercises {
    exports com.jdojo.annotation.exercises;
}

该模块从版本 1.0 开始就存在。该模块已被弃用,将在下一版本中删除。注解模块声明以反映这些信息。

二、反射

在本章中,您将学习:

  • 什么是反射

  • 什么是类装入器以及关于内置类装入器

  • 如何使用反射获取类、构造函数、方法等信息?运行时

  • 如何使用反射访问对象和类的字段

  • 如何使用反射创建类的对象

  • 如何使用反射调用类的方法

  • 如何使用反射创建数组

本章中的大多数示例程序都是清单 2-1 中声明的jdojo.reflection module的成员。我在这一章中使用了更多的模块,我将在后面展示。

// module-info.java
module jdojo.reflection {
    exports com.jdojo.reflection;
}

Listing 2-1The Declaration of a jdojo.reflection Module

什么是反思?

反射是程序在执行过程中查询和修改其状态“作为数据”的能力。程序查询或获取自身信息的能力称为自省。程序在执行过程中修改其执行状态、修改其自身的解释或含义,或者向程序添加新行为的能力称为调解。反射进一步分为两类:

  • 结构反射

  • 行为反思

程序查询其数据和代码实现的能力称为结构自省,而修改或创建新的数据结构和代码的能力称为结构调解。

程序获取其运行时环境信息的能力称为行为自省,而修改运行时环境的能力称为行为调解。

为程序提供查询或修改其状态的能力需要一种将执行状态编码为数据的机制。换句话说,程序应该能够将其执行状态表示为数据元素(如 Java 等面向对象语言中的对象),以便可以查询和修改。将执行状态编码成数据的过程称为具体化。如果一种编程语言为程序提供了反射能力,那么它就被称为反射语言。

Java 中的反射

Java 对反射的支持大多局限于内省。它以非常有限的形式支持代祷。Java 提供的自省特性允许您在运行时获得关于对象的类信息。Java 还允许您在运行时获得关于字段、方法、修饰符和类的超类的信息。

Java 提供的调解特性允许您创建一个类的实例,直到运行时才知道它的名称,在这样的实例上调用方法,并获取/设置它的字段。然而,Java 不允许你在运行时改变数据结构。例如,您不能在运行时向对象添加新的字段或方法。一个对象的所有字段总是在程序启动时被确定。行为调解的例子是在运行时改变方法执行或在运行时向类添加新方法的能力。Java 不提供任何这些中介特性。也就是说,你不能在运行时改变一个类的方法代码来改变它的执行行为;也不能在运行时向类中添加新方法。

Java 通过为类及其方法、构造函数、字段等提供对象表示来提供具体化。运行时。在大多数情况下,Java 不支持泛型类型的具体化。Java 5 增加了对泛型类型的支持。参见第三章了解通用类型的更多细节。程序可以在具体化的对象上工作,以便获得关于运行时执行的信息。例如,你一直在使用java.lang.Class类的对象来获取关于一个对象的类的信息。类对象是对象的类的字节码的具体化。当您想要收集关于一个对象的类的信息时,您不必担心实例化该对象的类的字节码。相反,Java 将字节码具体化为Class类的对象。

Java 中的反射功能是通过反射 API 提供的。大多数反射 API 类和接口都在java.lang.reflect包中。Java 中反射的核心Class类在java.lang package中。表 2-1 中列出了反射中一些常用的类。

表 2-1

反射中常用的类

|

类别名

|

描述

|
| --- | --- |
| Class | 这个类的对象代表 JVM 中由类装入器装入的单个类。 |
| Field | 这个类的对象代表一个类或一个接口的单个字段。由该对象表示的字段可以是静态字段或实例字段。 |
| Constructor | 这个类的对象代表一个类的单个构造函数。 |
| Method | 这个类的对象表示一个类的方法或一个接口。该对象表示的方法可以是类方法或实例方法。 |
| Modifier | 该类具有静态方法,用于解码类及其成员的访问修饰符。 |
| Parameter | 这个类的对象代表一个方法的参数。 |
| Array | 该类提供了用于在运行时创建数组的静态方法。 |

使用 Java 中的反射特性可以做的一些事情如下:

  • 如果有对象引用,就可以确定对象的类名。

  • 如果你有一个类名,你可以知道它的完整描述,例如,它的包名,它的访问修饰符,等等。

  • 如果你有一个类名,你可以决定类中定义的方法,它们的返回类型,访问修饰符,参数类型,参数名,等等。Java 8 中增加了对参数名的支持。

  • 如果您有一个类名,您可以确定该类的所有字段描述。

  • 如果你有一个类名,你可以确定类中定义的所有构造函数。

  • 如果您有一个类名,您可以使用该类的一个构造函数来创建该类的对象。

  • 如果有一个对象引用,只要知道方法名和方法的参数类型,就可以调用它的方法。

  • 您可以在运行时获取或设置对象的状态。

  • 您可以在运行时动态创建一个类型的数组并操作其元素。

加载类

在 Java 中,Class<T>类是反射的核心。Class<T>类是一个泛型类。它接受一个类型参数,这是由Class对象表示的类的类型。例如,Class<String>表示String类的类对象。Class<?>表示未知类别的类别类型。

Class类让你在运行时发现关于一个类的一切。Class类的对象表示运行时程序中的一个类。当您在程序中创建一个对象时,Java 加载该类的字节码,并创建一个Class类的对象来表示字节码。Java 使用那个Class对象来创建该类的任何对象。无论在程序中创建多少个类对象,Java 都只为 JVM 中的类装入器从一个模块装入的每个类创建一个Class对象。模块中的每个类也只由一个特定的类装入器装入一次。在 JVM 中,一个类由它的完全限定名、它的类装入器和它的模块唯一地标识。如果两个不同的类装入器装入同一个类,这两个装入的类被认为是两个不同的类,它们的对象互不兼容。

您可以通过以下方式之一获取对某个类的类对象的引用:

  • 使用类文本

  • 使用Object类的getClass()方法

  • 使用Class类的forName()静态方法

使用类文本

类文字是类名或接口名,后跟一个点和单词“class”例如,如果你有一个类Test,它的类文字是Test.class,你可以写

Class<Test> testClass = Test.class;

请注意,类文本总是与类名一起使用,而不是与对象引用一起使用。以下获取类引用的语句无效:

Test t = new Test();
Class<Test> testClass = t.class;   // A compile-time error.
                                   // Must use Test.class

您还可以使用类文字获得原始数据类型的类对象和关键字 void,如boolean.classbyte.classchar.classshort.classint.classlong.classfloat.classdouble.classvoid.class。这些原始数据类型的每个包装器类都有一个名为TYPE的静态字段,该字段引用了它所代表的原始数据类型的类对象。所以int.classInteger.TYPE指的是同一个类对象,表达式int.class == IntegerTYPE评估为true。表 2-2 显示了所有原始数据类型的类文字和void关键字。

表 2-2

原始数据类型的类文字和void关键字

|

数据类型

|

原始类文字

|

包装类静态字段

|
| --- | --- | --- |
| boolean | boolean.class | Boolean.TYPE |
| Byte | byte.class | Byte.TYPE |
| Char | char.class | Character.TYPE |
| Short | short.class | Short.TYPE |
| Int | int.class | Integer.TYPE |
| Long | long.class | Long.TYPE |
| Float | float.class | Float.TYPE |
| Double | double.class | Double.TYPE |
| Void | void.class | Void.TYPE |

使用Object::getClass()方法

Object类包含一个getClass()方法,该方法返回对该对象的类的Class对象的引用。这个方法在 Java 中的每个类中都可用,因为 Java 中的每个类都显式或隐式地继承了Object类。该方法被声明为 final,因此任何子类都不能重写它。例如,如果您将testRef作为对类Test的对象的引用,您可以获得对Test类的Class对象的引用,如下所示:

Test testRef = new Test();
Class<?> testClass = testRef.getClass();

使用Class::forName()方法

Class类有一个forName()静态方法,它加载一个类并返回对其Class对象的引用。这是一个重载的方法。其声明如下:

  • Class<?> forName(String className) throws ClassNotFoundException

  • Class<?> forName(String className, boolean initialize, ClassLoader loader) throws ClassNotFoundException

  • Class<?> forName(Module module, String className)

forName(String className)方法接受要加载的类的完全限定名。它加载类,初始化它,并返回对它的Class对象的引用。如果该类已经被加载,它只是返回对该类的Class对象的引用。

forName(String className, boolean initialize, ClassLoader loader)方法为您提供了在加载类时初始化或不初始化类的选项,以及哪个类加载器应该加载类。如果类不能被加载,该方法的前两个版本抛出一个ClassNotFoundException

forName(Module module, String className)方法加载指定模块中具有指定类名的类,而不初始化加载的类。如果没有找到该类,该方法返回null

要加载名为pkg1.Test的类,您应该编写

Class testClass = Class.forName("pkg1.Test");

要使用forName()方法获得一个Class对象引用,在运行时之前不必知道类名。如果类还没有初始化,那么forName(String className方法会初始化这个类,而使用类的文字并不会初始化这个类。当一个类被初始化时,它所有的静态初始化器都被执行,所有的静态字段都被初始化。清单 2-2 列出了一个只有一个静态初始化器的Bulb类,它在控制台上打印一条消息。清单 2-3 使用各种方法来加载和初始化Bulb类。

// BulbTest.java
package com.jdojo.reflection;
public class BulbTest {
    public static void main(String[] args) {
        /* Uncomment only one of the following statements
           at a time. Observe the output to see the
           difference in the way the Bulb class is loaded
           and initialized.
         */
        BulbTest.createObject();
        // BulbTest.forNameVersion1();
        // BulbTest.forNameVersion2();
        // BulbTest.forNameVersion3();
        // BulbTest.classLiteral();
    }

    public static void classLiteral() {
        // Will load the class, but won't initialize it.
        Class<Bulb> c = Bulb.class;
    }
    public static void forNameVersion1() {
        try {
            String className = "com.jdojo.reflection.Bulb";
            // Will load and initialize the class
            Class c = Class.forName(className);
        } catch (ClassNotFoundException e) {
            System.out.println(e.getMessage());
        }
    }
    public static void forNameVersion2() {
        try {
            String className = "com.jdojo.reflection.Bulb";
            boolean initialize = false;
            // Get the classloader for the current class
            ClassLoader cLoader = BulbTest.class.
                getClassLoader();
            // Will load, but not initialize the class,
            // because we have set the initialize variable
            // to false
            Class c = Class.forName(className, initialize,
                cLoader);
        } catch (ClassNotFoundException e) {
            System.out.println(e.getMessage());
        }
    }
    public static void forNameVersion3() {
        String className = "com.jdojo.reflection.Bulb";
        // Get the module reference for the current class
        Module m = BulbTest.class.getModule();
        // Will load, but not initialize, the class
        Class c = Class.forName(m, className);
        if(c == null) {
            System.out.println(
                "The bulb class was not loaded.");
        } else {
            System.out.println(
                "The bulb class was loaded.");
        }
    }

    public static void createObject() {
        // Will load and initialize the Bulb class
        new Bulb();
    }
}
Loading class Bulb...

Listing 2-3Testing Class Loading and Initialization

// Bulb.java
package com.jdojo.reflection;
public class Bulb {
    static {
        // This will execute when this class is loaded
        // and initialized
        System.out.println("Loading class Bulb...");
    }
}

Listing 2-2A Bulb Class to Demonstrate Initialization of a Class

类装入器

在运行时,每个类型都由一个类加载器加载,它由一个java.lang. ClassLoader类的实例表示。你可以通过使用Class类的getClassLoader()方法来获取一个类型的类加载器的引用。下面的代码片段显示了如何获取Bulb类的类加载器:

Class<Bulb> cls = Bulb.class;
ClassLoader loader = cls.getClassLoader();

Java 运行时使用三个类加载器来加载类,如图 2-1 所示。箭头的方向表示委托方向。这些类装入器从不同的位置装入不同类型的类。您可以添加更多的类装入器,这将是ClassLoader类的一个子类。使用自定义类加载器,您可以从自定义位置加载类,对用户代码进行分区,以及卸载类。对于大多数应用程序,内置的类装入器就足够了。

img/323070_3_En_2_Fig1_HTML.jpg

图 2-1

类装入器层次结构

Note

从 JDK9 开始,应用程序类加载器可以委托给平台类加载器以及引导类加载器;平台类加载器可以委托给应用程序类加载器。

引导类加载器在库代码和虚拟机中实现。如果你调用getClassLoader(),在Object.class.getClassLoader() == null中,它托管的类返回null。并非所有的 Java SE 平台和 JDK 模块都由引导类加载器加载。举几个例子,由引导类加载器加载的模块有java.basejava.loggingjava.prefsjava.desktop。其他 Java SE 平台和 JDK 模块是由平台类加载器和应用程序类加载器加载的,这将在下面描述。使用-Xbootclasspath/a选项指定额外的引导类路径。其值存储在系统属性jdk.boot.class.path.append中。

平台类加载器可用于实现类加载扩展机制(不再支持用于加载类的 JDK8 扩展机制)。ClassLoader类包含一个名为 getPlatformClassLoader()的新静态方法,该方法返回平台类加载器的引用。表 2-3 列出了平台类加载器加载的模块。

表 2-3

平台类加载器加载的 JDK 模块

| `java.compiler` | `java.net` `.http` | `java.scripting` | | `java.security.jgss` | `java.smartcardio` | `java.sql` | | `java.sql.rowset` | `java.transaction.xa` | `java.xml.crypto` | | `jdk.accessibility` | `jdk.charsets` | `jdk.crypto.cryptoki` | | `jdk.crypto.ec` | `jdk.dynalink` | `jdk.httpserver` | | `jdk.jsobject` | `jdk.localedata` | `jdk.naming.dns` | | `jdk.security.auth` | `jdk.security.jgss` | `jdk.xml.dom` | | `jdk.zipfs` |   |   |

平台类加载器还有另一个用途。默认情况下,引导类装入器装入的类被授予所有权限。但是,有几个类不需要所有权限。这样的类由平台类加载器加载。

应用程序类加载器加载在模块路径上找到的应用程序模块和一些提供工具或导出工具 API 的 JDK 模块,如表 2-4 所列。您仍然可以使用ClassLoader类的名为 getSystemClassLoader()的静态方法来获取应用程序类加载器的引用。

表 2-4

由应用程序类加载器加载的 JDK 模块

| `jdk.compiler` | `jdk.internal.opt` | `jdk.jartool` | | `jdk.javadoc` | `jdk.jdeps` | `jdk.jlink` | | `jdk.unsupported.desktop` |   |   |

Note

在 JDK9 之前,扩展类加载器和应用类加载器都是java.net .URLClassLoader类的实例。在 JDK9 和更高版本中,平台类加载器(以前的扩展类加载器)和应用程序类加载器是内部 JDK 类的一个实例。如果您的代码依赖于特定于URLClassLoader类的方法,那么您的 JDK9 之前的代码可能会在 JDK9 或更高版本中中断。

表 2-3 和 2-4 中未列出的 JDK 模块由引导类加载器加载。清单 2-4 展示了如何打印模块名和它们的类装入器名。显示了部分输出。输出取决于运行时解析的模块。要打印所有的 JDK 模块及其类装入器,您应该在运行这个类之前在模块声明中添加一个“requires java.se.ee”。我将在第七章讨论模块层。

// ModulesByClassLoader.java
package com.jdojo.reflection;
public class ModulesByClassLoader {
    public static void main(String[] args) {
        // Get the boot layer
        ModuleLayer layer = ModuleLayer.boot();
        // Print all module's names and their class loader
        // names in the boot layer
        for (Module m : layer.modules()) {
            ClassLoader loader = m.getClassLoader();
            String moduleName = m.getName();
            String loaderName = loader == null ?
                "bootstrap" : loader.getName();
            System.out.printf("%s: %s%n", loaderName,
                moduleName);
        }
    }
}
bootstrap: java.base
platform: java.net.http
bootstrap: java.security.sasl
app: jdk.internal.opt
...

Listing 2-4Listing the Names of Loaded Modules by Class Loader

三个内置的类装入器协同工作来装入类。当应用程序类装入器需要装入一个类时,它搜索为所有类装入器定义的模块。如果为这些类装入器之一定义了合适的模块,则该类装入器装入该类,这意味着应用程序类装入器现在可以委托给引导程序类装入器和平台类装入器。如果在为这些类装入器定义的命名模块中没有找到某个类,应用程序类装入器将委托给它的父类,即平台类装入器。如果类仍然没有被加载,应用程序类加载器搜索类路径。如果在类路径上找到该类,它将该类作为其未命名模块的成员加载。如果在类路径上没有找到类,就会抛出一个ClassNotFoundException

当平台类装入器需要装入一个类时,它搜索为所有类装入器定义的模块。如果为这些类加载器之一定义了合适的模块,则该类加载器加载该类,这意味着平台类加载器可以委托给引导类加载器以及应用程序类加载器。如果在为这些类装入器定义的命名模块中没有找到某个类,那么平台类装入器将委托给它的父类,即引导类装入器。

当引导类装入器需要装入一个类时,它会搜索自己的命名模块列表。如果没有找到某个类,它会搜索通过命令行选项指定的文件和目录列表:Xbootclasspath/a。如果它在 bootstrap 类路径上找到一个类,它会将该类作为其未命名模块的成员加载。如果仍然没有找到一个类,那么抛出一个ClassNotFoundException

反思班级

本节演示了 Java 反射的特性,这些特性使您能够获得类的描述,比如它的包名、访问修饰符等。您将使用一个Person类,如清单 2-5 中所列,来演示反射特性。这是一个简单的类,有两个实例字段、两个构造函数和一些方法。它实现了两个接口。

// Person.java
package com.jdojo.reflection;
import java.io.Serializable;
public class Person implements Cloneable, Serializable {
    private int id = -1;
    private String name = "Unknown";
    public Person() {
    }
    public Person(int id, String name) {
        this.id = id;
        this.name = name;
    }
    public int getId() {
        return id;
    }
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }

    @Override
    public Person clone() {
        try {
            return (Person) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new RuntimeException(e.getMessage());
        }
    }
    @Override
    public String toString() {
        return "Person: id=" + this.id + ", name=" +
             this.name;
    }
}

Listing 2-5A Person Class Used to Demonstrate Reflection

清单 2-6 展示了如何获得一个类的描述。它列出了类访问修饰符、类名、超类名以及该类实现的所有接口。

// ClassReflection.java
package com.jdojo.reflection;
import java.lang.reflect.Modifier;
import java.lang.reflect.TypeVariable;
public class ClassReflection {
    public static void main(String[] args) {
        // Print the declaration of the Person class
        String clsDecl = getClassDescription(Person.class);
        System.out.println(clsDecl);
        // Print the declaration of the Class class
        clsDecl = getClassDescription(Class.class);
        System.out.println(clsDecl);
        // Print the declaration of the Runnable interface
        clsDecl = getClassDescription(Runnable.class);
        System.out.println(clsDecl);
        // Print the declaration of the class representing
        // the int data type
        clsDecl = getClassDescription(int.class);
        System.out.println(clsDecl);
    }
    public static
    String getClassDescription(Class<?> cls) {
        StringBuilder classDesc = new StringBuilder();
        // Prepare the modifiers and construct keyword
        // (class, enum, interface etc.)
        int modifierBits = 0;
        String keyword = " ";

        // Add keyword @interface, interface or class
        if (cls.isPrimitive()) {
            // We do not want to add anything
        } else if (cls.isInterface()) {
            modifierBits = cls.getModifiers() & Modifier.
                interfaceModifiers();
            // An annotation is an interface
            if (cls.isAnnotation()) {
                keyword = "@interface";
            } else {
                keyword = "interface";
            }
        } else if (cls.isEnum()) {
            modifierBits = cls.getModifiers() &
                Modifier.classModifiers();
            keyword = "enum";
        } else {
            modifierBits = cls.getModifiers() &
                Modifier.classModifiers();
            keyword = "class";
        }
        // Convert modifiers to their string representation
        String modifiers = Modifier.toString(modifierBits);
        // Append modifiers
        classDesc.append(modifiers);
        // Append the construct keyword
        classDesc.append(" ");
        classDesc.append(keyword);
        // Append simple name
        String simpleName = cls.getSimpleName();
        classDesc.append(" ");
        classDesc.append(simpleName);
        // Append generic parameters
        String genericParms = getGenericTypeParams(cls);
        classDesc.append(genericParms);
        // Append super class
        Class superClass = cls.getSuperclass();
        if (superClass != null) {
            String superClassSimpleName = superClass.
                getSimpleName();
            classDesc.append(" extends ");
            classDesc.append(superClassSimpleName);
        }
        // Append Interfaces
        String interfaces = ClassReflection.
            getClassInterfaces(cls);
        if (interfaces != null) {
            classDesc.append(" implements ");
            classDesc.append(interfaces);
        }
        return classDesc.toString().trim();
    }
    public static String getClassInterfaces(Class<?> cls) {
        // Get a comma-separated list of interfaces
        // implemented by the class
        Class<?>[] interfaces = cls.getInterfaces();
        if (interfaces.length == 0) {
            return null;
        }
        String[] names = new String[interfaces.length];
        for (int i = 0; i < interfaces.length; i++) {
            names[i] = interfaces[i].getSimpleName();
        }
        String interfacesList = String.join(", ", names);
        return interfacesList;
    }
    public static
    String getGenericTypeParams(Class<?> cls) {
        StringBuilder sb = new StringBuilder();
        TypeVariable<?>[] typeParms = cls.
            getTypeParameters();
        if (typeParms.length == 0) {
            return "";
        }
        String[] paramNames = new String[typeParms.
            length];
        for (int i = 0; i < typeParms.length; i++) {
            paramNames[i] = typeParms[i].getTypeName();
        }
        sb.append('<');
        String parmsList = String.join(",", paramNames);
        sb.append(parmsList);
        sb.append('>');
        return sb.toString();
    }
}

public class Person extends Object implements Cloneable,
     Serializable
public final class Class<T> extends Object implements
     Serializable, GenericDeclaration,
Type, AnnotatedElement
public abstract interface Runnable
int

Listing 2-6Reflecting on a Class

Class类的getName()方法返回该类的全限定名。要获得简单的类名,使用Class类的getSimpleName()方法,如下所示:

String simpleName = c.getSimpleName();

类的修饰符是在类声明中出现在关键字类之前的关键字。在下面的例子中,public 和 abstract 是MyClass类的修饰符:

public abstract class MyClass {
    // Code goes here
}

Class类的getModifiers()方法返回该类的所有修饰符。注意,getModifiers()方法返回一个整数。要获得修饰符的文本形式,您需要调用Modifier类的toString(int modifiers)静态方法,以整数形式传递修饰符值。假设cls是一个Class对象的引用,你得到这个类的修饰符,如下所示:

// You need to AND the returned value from the
// getModifiers() method with appropriate value returned
// from xxxModifiers() method of the Modifiers class
int mod = cls.getModifiers() & Modifier.classModifiers(); String modStr = Modifier.toString(mod);

获得一个类的超类的名字是很简单的。使用Class类的getSuperclass()方法获取超类的引用。注意,Java 中的每个类都有一个超类,除了Object类。如果在Object类上调用了getSuperclass()方法,它将返回null:

Class superClass = cls.getSuperclass();
if (superClass != null) {
    String superClassName = superClass.getSimpleName();
}

Note

Class类的getSuperclass()方法在表示Object类、接口类如List.class和基本类型类如int.classvoid.class等时返回null

要获得一个类实现的所有接口的名称,可以使用Class类的getInterfaces()方法。它返回一个Class对象的数组。数组中的每个元素表示由类实现的一个接口:

// Get all interfaces implemented by cls
Class<?>[] interfaces = cls.getInterfaces();

ClassReflection类的getClassDescription()方法将类声明的所有部分放入一个字符串并返回该字符串。这个类的main()方法演示了如何使用这个类。

Class类的一个名为toGenericString()的方法返回一个描述该类的字符串。该字符串包含该类的修饰符和类型参数。呼叫Person.class.toGenericString()将返回public class com.jdojo.reflection.Person

反思领域

类的字段由java.lang.reflect.Field类的对象表示。Class类中的以下四个方法可用于获取关于类字段的信息:

  • Field[] getFields()

  • Field[] getDeclaredFields()

  • Field getField(String name)

  • Field getDeclaredField(String name)

getFields()方法返回类或接口的所有可访问的公共字段。可访问的公共字段包括在类中声明的或从其超类继承的公共字段。getDeclaredFields()方法返回出现在类声明中的所有字段。它不包括继承的字段。另外两个方法,getField()getDeclaredField(),用于获取Field对象,如果您知道字段的名称。让我们考虑下面的类 A 和 B 以及一个接口IConstants的声明:

interface IConstants {
    int DAYS_IN_WEEK = 7;
}
class A implements IConstants {
    private int aPrivate;
    public int aPublic;
    protected int aProtected;
}
class B extends A {
    private int bPrivate;
    public int bPublic;
    protected int bProtected;
}

如果bClass是类 B 的Class对象的引用,表达式bClass.getFields()将返回以下三个可访问的公共字段:

  • public int B.bPublic

  • public int A.aPublic

  • public static final int IConstants.DAYS_IN_WEEK

bClass.getDeclaredFields()方法将返回在类 B 中声明的三个字段:

  • 私人 int B.bPrivate

  • 公共 int B.bPublic

  • 受保护的 int 受保护的

要获取一个类及其超类的所有字段,必须使用getSuperclass()方法获取超类的引用,并使用这些方法的组合。清单 2-7 展示了如何获取一个类的字段信息。注意,当您在Person类的Class对象上调用getFields()方法时,您不会得到任何东西,因为Person类不包含任何公共字段。

// FieldReflection.java
package com.jdojo.reflection;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
public class FieldReflection {
    public static void main(String[] args) {
        Class<Person> cls = Person.class;
        // Print declared fields
        ArrayList<String> fieldsDescription =
            getDeclaredFieldsList(cls);
        System.out.println("Declared Fields for " +
            cls.getName());
        for (String desc : fieldsDescription) {
            System.out.println(desc);
        }
        // Get the accessible public fields
        fieldsDescription = getFieldsList(cls);
        System.out.println("\nAccessible Fields for " +
            cls.getName());
        for (String desc : fieldsDescription) {
            System.out.println(desc);
        }
    }
    public static
    ArrayList<String> getFieldsList(Class c) {
        Field[] fields = c.getFields();
        ArrayList<String> fieldsList =
            getFieldsDescription(fields);
        return fieldsList;
    }
    public static
    ArrayList<String> getDeclaredFieldsList(Class c) {
        Field[] fields = c.getDeclaredFields();
        ArrayList<String> fieldsList =
            getFieldsDescription(fields);
        return fieldsList;
    }

    public static ArrayList<String>
    getFieldsDescription(Field[] fields) {
        ArrayList<String> fieldList = new ArrayList<>();
        for (Field f : fields) {
            // Get the modifiers for the field
            int mod = f.getModifiers() &
                Modifier.fieldModifiers();
            String modifiers = Modifier.toString(mod);
            // Get the simple name of the field type
            Class<?> type = f.getType();
            String typeName = type.getSimpleName();
            // Get the name of the field
            String fieldName = f.getName();
            fieldList.add(modifiers + " " + typeName +
                " " + fieldName);
        }
        return fieldList;
    }
}

Declared Fields for com.jdojo.reflection.Person
private int id
private String name
Accessible Fields for com.jdojo.reflection.Person

Listing 2-7Reflecting on Fields of a Class

Note

您不能使用这种技术来描述数组对象的长度字段。每种数组类型都有相应的类。当您试图使用getFields()方法获取数组类的字段时,您会得到一个长度为零的Field对象的数组。数组长度不是数组的类定义的一部分。相反,它作为数组对象的一部分存储在对象头中。

反映在可执行文件上

Method类的一个实例代表一个方法。Constructor类的一个实例代表一个构造函数。在结构上,方法和构造函数有一些共同点。两者都使用修饰符、参数和 throws 子句。两者都可以执行。这些类从一个公共的抽象超类Executable继承而来。两者通用的检索信息的方法是Executable类的方法。

Executable中的参数由Parameter类的对象表示。Executable类中的getParameters()方法返回一个Executable的所有参数作为Parameter[]。默认情况下,形参名称不存储在类文件中,以保持文件较小。Parameter类的getName()方法返回合成的参数名,如arg0arg1等。除非保留实际的参数名。如果您想在类文件中保留实际的参数名,您需要使用-parameters选项和javac编译器来编译源代码。

Executable类的getExceptionTypes()方法返回一个Class对象的数组,该数组描述了由Executable抛出的异常。如果在throws子句中没有列出异常,它将返回一个长度为零的数组。

Executable类的getModifiers()方法将修饰符作为int返回。

Executable类的getTypeParameters()方法返回一个代表泛型方法/构造函数的类型参数的TypeVariable数组。本章中的示例不包括方法/构造函数中的泛型类型变量声明。

清单 2-8 包含一个由静态方法组成的实用程序类,用于获取关于Executable的信息,比如修饰符、参数和异常的列表。在后面的章节中讨论方法和构造函数时,我会用到这个类。

// ExecutableUtil.java
package com.jdojo.reflection;

import java.lang.reflect.Constructor;
import java.lang.reflect.Executable;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.Parameter;
import java.util.ArrayList;

public class ExecutableUtil {
    public static
    ArrayList<String> getParameters(Executable exec) {
        Parameter[] parms = exec.getParameters();
        ArrayList<String> parmList = new ArrayList<>();
        for (int i = 0; i < parms.length; i++) {
            // Get modifiers, type, and name of the
            // parameter
            int mod = parms[i].getModifiers() &
                Modifier.parameterModifiers();
            String modifiers = Modifier.toString(mod);
            String parmType = parms[i].getType().
                getSimpleName();
            String parmName = parms[i].getName();
            String temp = modifiers + " " + parmType +
                " " + parmName;
            // Trim it as it may have leading spaces when
            // modifiers are absent
            parmList.add(temp.trim());
        }

        return parmList;
    }
    public static
    ArrayList<String> getExceptionList(Executable exec) {
        ArrayList<String> exceptionList =
            new ArrayList<>();
        for (Class<?> c : exec.getExceptionTypes()) {
            exceptionList.add(c.getSimpleName());
        }

        return exceptionList;
    }
    public static String
    getThrowsClause(Executable exec) {
        ArrayList<String> exceptionList =
            getExceptionList(exec);
        String exceptions = ExecutableUtil.
            arrayListToString(exceptionList, ",");
        String throwsClause = "";

        if (exceptionList.size() > 0) {
            throwsClause = "throws " + exceptions;
        }
        return throwsClause;
    }
    public static String getModifiers(Executable exec) {
        // Get the modifiers for the class
        int mod = exec.getModifiers();
        if (exec instanceof Method) {
            mod = mod & Modifier.methodModifiers();
        } else if (exec instanceof Constructor) {
            mod = mod & Modifier.constructorModifiers();
        }
        return Modifier.toString(mod);
    }
    public static String
    arrayListToString(ArrayList<String> list,
                      String saparator) {
        String[] tempArray = new String[list.size()];
        tempArray = list.toArray(tempArray);
        String str = String.join(saparator, tempArray);
        return str;
    }
}

Listing 2-8A Utility Class to Get Information for an Executable

反思方法

Class类中的以下四个方法可用于获取关于一个类的方法的信息:

  • Method[] getMethods()

  • Method[] getDeclaredMethods()

  • Method getMethod(String name, Class... parameterTypes)

  • Method getDeclaredMethod(String name, Class... parameterTypes)

getMethods()方法返回该类所有可访问的公共方法。可访问的公共方法包括在类中声明的或从超类继承的任何公共方法。getDeclaredMethods()方法返回所有只在类中声明的方法。它不返回任何从超类继承的方法。另外两个方法,getMethod()getDeclaredMethod(),用于获取Method对象,前提是您知道方法的名称及其参数类型。

Method类的getReturnType()方法返回Class对象,该对象包含关于方法返回类型的信息。

清单 2-9 展示了如何获取关于一个类的方法的信息。您可以取消注释main()方法中的代码,以打印 Person 类中的所有方法——在Person类中声明并从Object类继承。

// MethodReflection.java
package com.jdojo.reflection;
import java.lang.reflect.Method;
import java.util.ArrayList;
public class MethodReflection {
    public static void main(String[] args) {
        Class<Person> cls = Person.class;
        // Get the declared methods
        ArrayList<String> methodsDescription =
            getDeclaredMethodsList(cls);
        System.out.println("Declared Methods for " +
            cls.getName());
        for (String desc : methodsDescription) {
            System.out.println(desc);
        }
        /* Uncomment the following code to print all
           methods in the Person class
        // Get the accessible public methods
        methodsDescription = getMethodsList(cls);
        System.out.println("\nMethods for " + cls.getName());
        for (String desc : methodsDescription) {
            System.out.println(desc);
        }
         */
    }
    public static ArrayList<String>
    getMethodsList(Class c) {
        Method[] methods = c.getMethods();
        ArrayList<String> methodsList =
            getMethodsDescription(methods);
        return methodsList;
    }
    public static ArrayList<String>
    getDeclaredMethodsList(Class c) {
        Method[] methods = c.getDeclaredMethods();
        ArrayList<String> methodsList =
            getMethodsDescription(methods);
        return methodsList;
    }
    public static ArrayList<String>
    getMethodsDescription(Method[] methods) {
        ArrayList<String> methodList = new ArrayList<>();

        for (Method m : methods) {
            String modifiers = ExecutableUtil.
                getModifiers(m);
            // Get the method return type
            Class returnType = m.getReturnType();
            String returnTypeName =
                returnType.getSimpleName();
            // Get the name of the method
            String methodName = m.getName();
            // Get the parameters of the method
            ArrayList<String> paramsList =
                ExecutableUtil.getParameters(m);
            String params = ExecutableUtil.
                arrayListToString(paramsList, ",");
            // Get the Exceptions thrown by method
            String throwsClause = ExecutableUtil.
                getThrowsClause(m);
            methodList.add(modifiers + " " +
                returnTypeName + " " + methodName +
                    "(" + params + ") " + throwsClause);
        }
        return methodList;
    }
}

Declared Methods for com.jdojo.reflection.Person
public String toString()
public Object clone()
public String getName()
public int getId()
public void setName(String arg0)

Listing 2-9Reflecting on Methods of a Class

反思构造函数

获取关于类的构造函数的信息类似于获取关于类的方法的信息。Class类中的以下四个方法用于获取关于由Class对象表示的构造函数的信息:

  • Constructor[] getConstructors()

  • Constructor[] getDeclaredConstructors()

  • Constructor<T> getConstructor(Class... parameterTypes)

  • Constructor<T> getDeclaredConstructor(Class... parameterTypes)

getConstructors()方法返回所有公共构造函数。getDeclaredConstructors()方法返回所有声明的构造函数。另外两个方法,getConstructor()getDeclaredConstructor(),用于获取Constructor对象,如果你知道构造函数的参数类型。清单 2-10 展示了如何获取由Class对象表示的构造函数的信息。

// ConstructorReflection.java
package com.jdojo.reflection;
import java.lang.reflect.Constructor;
import java.util.ArrayList;
public class ConstructorReflection {
    public static void main(String[] args) {
        Class<Person> cls = Person.class;
        // Get the declared constructors
        System.out.println("Constructors for " +
            cls.getName());
        Constructor[] constructors = cls.getConstructors();
        ArrayList<String> constructDescList =
            getConstructorsDescription(constructors);
        for (String desc : constructDescList) {
            System.out.println(desc);
        }
    }
    public static
    ArrayList<String> getConstructorsDescription(
            Constructor[] constructors) {
        ArrayList<String> constructorList =
            new ArrayList<>();
        for (Constructor constructor : constructors) {
            String modifiers = ExecutableUtil.
                getModifiers(constructor);
            // Get the name of the constructor
            String constructorName = constructor.getName();
            // Get the parameters of the constructor
            ArrayList<String> paramsList = ExecutableUtil.
                getParameters(constructor);
            String params = ExecutableUtil.
                arrayListToString(paramsList, ",");
            // Get the Exceptions thrown by the constructor
            String throwsClause = ExecutableUtil.
                getThrowsClause(constructor);
            constructorList.add(modifiers + " " +
                constructorName + "(" + params + ") " +
                throwsClause);
        }
        return constructorList;
    }
}

Constructors for com.jdojo.reflection.Person
public com.jdojo.reflection.Person()
public com.jdojo.reflection.Person(int arg0,String arg1)

Listing 2-10Reflecting on Constructors of a Class

创建对象

Java 允许你使用反射来创建一个类的对象。运行时才需要知道类名。您可以通过使用反射调用类的构造函数之一来创建对象。您还可以访问对象字段的值,设置它们的值,并调用它们的方法。如果您知道类名,并且在编译时可以访问该类代码,请不要使用反射来创建其对象;相反,在代码中使用 new 运算符来创建类的对象。通常,框架和库使用反射来创建对象。

您可以使用反射创建一个类的对象。在创建对象之前,需要获取构造函数的引用。上一节向您展示了如何获取一个类的特定构造函数的引用。使用Constructor类的newInstance()方法创建一个对象。您可以将实际参数传递给newInstance()方法的构造函数,声明如下:

public T newInstance(Object... initargs) throws
  InstantiationException,
  IllegalAccessException,
  IllegalArgumentException,
  InvocationTargetException

这里,initargs是构造函数的实际参数。您不会为无参数构造函数传递任何参数。

下面的代码片段获取对Person类的无参数构造函数的引用并调用它。为了简洁起见,我省略了异常处理:

Class<Person> cls = Person.class;
// Get the reference of the Person() constructor
Constructor<Person> noArgsCons = cls.getConstructor();
Person p = noArgsCons.newInstance();

清单 2-11 包含了完整的代码来说明如何使用Person类的Person(int, String)构造函数来创建一个使用反射的Person对象。注意Constructor<T>类是一个泛型类型。它的类型参数是声明构造函数的类类型,例如,Constructor<Person>类型代表Person类的一个构造函数。

// InvokeConstructorTest.java
package com.jdojo.reflection;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
public class InvokeConstructorTest {
    public static void main(String[] args) {
        Class<Person> personClass = Person.class;

        try {
            // Get the constructor "Person(int, String)"
            Constructor<Person> cons = personClass.
                getConstructor(int.class, String.class);
            // Invoke the constructor with values for id
            // and name
            Person chris = cons.newInstance(1994, "Chris");
            System.out.println(chris);
        } catch (NoSuchMethodException | SecurityException
                | InstantiationException
                | IllegalAccessException
                | IllegalArgumentException
                | InvocationTargetException e) {
            System.out.println(e.getMessage());
        }
    }
}

Person: id=1994, name=Chris

Listing 2-11Using a Specific Constructor to Create a New Object

调用方法

您可以使用反射调用对象的方法。您需要获取对想要调用的方法的引用。假设您想要调用Person类的setName()方法。您可以获取对 setName()方法的引用,如下所示:

Class<Person> personClass = Person.class;
Method setName = personClass.getMethod("setName",
    String.class);

要调用这个方法,在方法的引用上调用invoke()方法,声明如下:

public Object invoke(Object obj, Object... args)
    throws IllegalAccessException,
           lllegalArgumentException,
           InvocationTargetException

invoke()方法的第一个参数是您想要调用该方法的对象。如果Method对象代表一个静态方法,第一个参数被忽略或者可能是null。第二个参数是一个 varargs 参数,在该参数中,您按照方法声明中声明的顺序传递所有实际参数。

由于Person类的setName()方法接受一个String参数,您需要将一个String对象作为第二个参数传递给invoke()方法。清单 2-12 展示了如何使用反射调用Person对象上的方法。

// InvokeMethodTest.java
package com.jdojo.reflection;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

public class InvokeMethodTest {
    public static void main(String[] args) {
        Class<Person> personClass = Person.class;
        try {
            // Create an object of Person class
            Person p = personClass.newInstance();
            // Print the details of the Person object
            System.out.println(p);
            // Get the reference of the setName() method
            Method setName = personClass.getMethod(
                "setName", String.class);
            // Invoke the setName() method on p passing
            // passing "Ann" as the actual parameter
            setName.invoke(p, "Ann");
            // Print the details of the Person object
            System.out.println(p);
        } catch (InstantiationException
                | IllegalAccessException
                | NoSuchMethodException
                | SecurityException
                | IllegalArgumentException
                | InvocationTargetException e) {
            System.out.println(e.getMessage());
        }
    }
}

Person: id=-1, name=Unknown
Person: id=-1, name=Ann

Listing 2-12Invoking a Method on an Object Reference Using Reflection

访问字段

您可以使用反射读取或设置对象的字段值。首先,您需要获得您想要使用的字段的引用。要读取字段的值,需要对字段调用getXxx()方法,其中Xxx是字段的数据类型。例如,要读取一个布尔字段值,可以调用getBoolean()方法,要读取一个整型字段,可以调用getInt()方法。要设置字段的值,可以调用相应的setXxx()方法。下面是getInt()setInt()方法的声明,其中第一个参数 obj 是对象的引用,其字段正在被读取或写入:

  • int getint(object obj)throws illegal arguments exception,illegalaccessexception

  • 请参阅 setnt(object obj,int new value)throws illegal arguments exception,illegalaccessexception

Note

静态和实例字段的访问方式相同。在静态字段的情况下,get()set()方法的第一个参数是类/接口的引用。

请注意,您只能访问已声明为可访问的字段,如公共字段。在Person类中,所有字段都被声明为私有。因此,您不能使用普通的 Java 编程语言规则来访问这些字段。要访问通常不可访问的字段,例如,如果它被声明为 private,请参阅本章后面的“深度反射”一节。您将使用清单 2-13 中列出的PublicPerson类来学习访问字段的技术。

// PublicPerson.java
package com.jdojo.reflection;
public class PublicPerson {
    private int id = -1;
    public String name = "Unknown";
    public PublicPerson() {
    }
    @Override
    public String toString() {
        return "Person: id=" + this.id + ", name=" +
            this.name;
    }
}

Listing 2-13A PublicPerson Class with a Public Name Field

清单 2-14 演示了如何获取一个对象的字段的引用,以及如何读取和设置它的值。

// FieldAccessTest.java
package com.jdojo.reflection;
import java.lang.reflect.Field;
public class FieldAccessTest {
    public static void main(String[] args) {
        Class<PublicPerson> ppClass = PublicPerson.class;
        try {
            // Create an object of the PublicPerson class
            PublicPerson p = ppClass.newInstance();
            // Get the reference of the name field
            Field name = ppClass.getField("name");
            // Get and print the current value of the
            // name field
            String nameValue = (String) name.get(p);
            System.out.println("Current name is " +
                nameValue);

            // Set the value of name to Ann
            name.set(p, "Ann");
            // Get and print the new value of name field
            nameValue = (String) name.get(p);
            System.out.println("New name is " + nameValue);
        } catch (InstantiationException
                | IllegalAccessException
                | NoSuchFieldException
                | SecurityException
                | IllegalArgumentException e) {
            System.out.println(e.getMessage());
        }
    }
}

Current name is Unknown
New name is Ann

Listing 2-14Accessing Fields Using Reflection

深刻的反思

使用反射可以做两件事:

  • 描述一个实体

  • 访问实体的成员

描述一个实体意味着了解实体的细节。例如,描述一个类意味着知道它的名字、修饰符、包、模块、字段、方法和构造函数。访问实体的成员意味着读写字段和调用方法和构造函数。描述一个实体不会引起任何访问控制的问题。如果您可以访问一个类文件,那么您应该能够知道该类文件中表示的实体的详细信息。然而,对实体成员的访问是由 Java 语言访问控制来控制的。例如,如果将某个类的某个字段声明为 private,则该字段应该只能在该类中访问。该类之外的代码应该不能访问该类的私有字段。然而,这是对的一半。静态访问成员时会应用 Java 语言访问控制规则。当您使用反射访问成员时,可以取消访问控制规则。

下面的代码片段访问了Person类的私有名称字段。这段代码将只在Person类中编译:

Person john = new Person();
String name = john.name;  // Accessing the private field
                          // name statically

Java 已经允许使用反射访问相当难访问的成员,比如类外部的私有字段。这叫深刻反思。对不可访问成员的反射访问使得在 Java 中拥有许多优秀的框架成为可能,比如 Hibernate 和 Spring。这些框架使用深度反射来完成大部分工作。您可以使用深度反射在Person类之外访问Person类的私有名称字段。

到目前为止,在这一章中,我保持了例子的简单性,并且没有违反 Java 语言访问控制。我只访问了公共字段、方法和构造函数;被访问的成员和访问代码在同一个模块中。在 JDK9 之前,访问不可访问的成员很容易。您所要做的就是在访问不可访问的FieldMethodConstructor对象之前调用setAccessible(true)方法。JDK9 中模块系统的引入让深度反思变得有点复杂。在本节及其子节中,我将带您浏览 JDK9 和更高版本中用于深度反射的规则和示例。

Note

如果存在安全管理器,执行深度反射的代码必须拥有ReflectPermission("suppressAccessChecks")权限。

要执行深度反射,需要使用Class对象的getDeclaredXxx()方法获取所需字段、方法和构造函数的引用,其中Xxx可以是FieldMethodConstructor。注意,使用getXxx()方法来获取不可访问的字段、方法或构造函数的引用将抛出一个IllegalAccessExceptionFieldMethodConstructor类将AccessibleObject类作为它们的超类。AccessibleObject类包含以下方法,允许您使用可访问标志:

  • void setAccessible(boolean flag)

  • static void setAccessible(AccessibleObject[] array, boolean flag)

  • boolean trySetAccessible()

  • boolean canAccess(Object obj)

setAccessible(boolean flag)方法将成员的可访问标志(FieldMethodConstructor)设置为truefalse。如果你试图访问一个不可访问的成员,你需要在访问成员之前调用成员对象上的setAccessible(true)。如果不能设置可访问标志,该方法抛出一个InaccessibleObjectException。静态setAccessible(AccessibleObject[] array, boolean flag)是为指定数组中的所有AccessibleObject设置可访问标志的方便方法。

trySetAccessible()方法试图在调用它的对象上将可访问标志设置为true。如果可访问标志被设置为true,则返回true,否则返回false。将此方法与setAccessible(true)方法进行比较。这个方法不会在失败时抛出运行时异常,而setAccessible(true)会。

如果调用者可以访问指定的obj对象的成员,则canAccess(Object obj)方法返回true。否则返回false。如果成员是静态成员或构造函数,obj 必须是null

我将在接下来的章节中讨论如何访问模块内、模块间、未命名模块中以及 JDK 模块中不可访问的成员。

模块内的深层反射

先说个例子。您想要访问一个Person对象的私有名称字段。首先,在一个Field对象中获取 name 字段的引用,并尝试读取它的当前值。清单 2-15 包含了IllegalAccess1类的代码。

// IllegalAccess1.java
package com.jdojo.reflection;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;

public class IllegalAccess1 {
    public static void main(String[] args)
            throws Exception {
        // Get the class reference for the Person class
        String className = "com.jdojo.reflection.Person";
        Class<?> cls = Class.forName(className);
        // Create a Person object
        Constructor<?> cons = cls.getConstructor();
        Object person = cons.newInstance();
        // Get the reference of the name field
        Field nameField = cls.getDeclaredField("name");
        // Try accessing the name field by reading its
        // value
        String name = (String) nameField.get(person);
        // Print the person and its name separately
        System.out.println(person);
        System.out.println("name=" + name);
    }
}

Exception in thread "main" java.lang.
    IllegalAccessException: class com.jdojo.reflection.
IllegalAccess1 (in module jdojo.reflection) cannot access
    a member of class com.jdojo.
reflection.Person (in module jdojo.reflection) with
    modifiers "private"
       at java.base/jdk.internal.reflect.Reflection.
          newIllegalAccessException(Reflection.java:361)
       at java.base/java.lang.reflect.AccessibleObject.
          checkAccess(AccessibleObject.java:589)
       at java.base/java.lang.reflect.Field.checkAccess(
          Field.java:1075)
       at java.base/java.lang.reflect.Field.get(
          Field.java:416)
       at jdojo.reflection/com.jdojo.reflection.
          IllegalAccess1.main(IllegalAccess1.java:21)

Listing 2-15Accessing the Private Name Field of the Person Class

在清单 2-15 中,我在main()方法的throws子句中添加了Exception类,以保持方法内部的逻辑简单。我对本节中的所有示例都这样做,这样您就可以专注于非法访问规则,而不是异常处理。IllegalAccess1Person级在同一个jdojo.reflection模块中。您能够成功地创建一个Person对象,因为您使用了Person类的公共无参数构造函数。Person类中的 name 字段被声明为私有,从另一个类访问它失败。修复这个错误很简单——使用setAccessible(true)trySetAccessible()方法将accessible标志设置为Field对象。清单 2-16 包含完整的代码。

// IllegalAccess2.java
package com.jdojo.reflection;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;

public class IllegalAccess2 {
    public static void main(String[] args)
            throws Exception {
        // Get the class reference for the Person class
        String className = "com.jdojo.reflection.Person";
        Class<?> cls = Class.forName(className);
        // Create a Person object
        Constructor<?> cons = cls.getConstructor();
        Object person = cons.newInstance();
        // Get the reference of the name field
        Field nameField = cls.getDeclaredField("name");
        // Try making the name field accessible before
        // accessing it
        boolean accessEnabled = nameField.
            trySetAccessible();
        if (accessEnabled) {
            // Try accessing the name field by reading
            // its value
            String name = (String) nameField.get(person);
            // Print the person and its name separately
            System.out.println(person);
            System.out.println("name=" + name);
        } else {
            System.out.println("The Person.name field " +
                "is not accessible.");
        }
    }
}

Person: id=-1, name=Unknown
name=Unknown

Listing 2-16Accessing the Private Name Field of the Person Class After Making It Accessible

到目前为止,一切看起来都很好。你可能认为如果你不能访问一个类的私有成员,你总是可以使用反射来访问它们。然而,事实并非总是如此。对类中不可访问成员的访问通过 Java 安全管理器来处理。默认情况下,在计算机上运行应用程序时,不会为应用程序安装安全管理器。您的应用程序缺少安全管理器,这使得您可以在将 accessible 标志设置为true之后,在同一个模块中访问一个类的所有字段、方法和构造函数,就像您在前面的示例中所做的那样。但是,如果为您的应用程序安装了安全管理器,您是否可以访问不可访问的类成员取决于授予您的应用程序访问此类成员的权限。您可以使用以下代码检查您的应用程序是否安装了安全管理器:

SecurityManager smgr = System.getSecurityManager();
if (smgr == null) {
    System.out.println(
        "Security manager is not installed.");
}

运行 Java 应用程序时,可以通过在命令行上传递- Djava.security.manager选项来安装默认的安全管理器。安全管理器使用 Java 安全策略文件来实现该策略文件中指定的规则。Java 安全策略文件是使用- Djava.security.policy命令行选项指定的。如果您想用 Java 安全管理器运行IllegalAccess2类,并将 Java 策略文件存储在C:\Java17LanguageFeatures\conf\myjava.policy文件中,您可以使用下面的命令:

C:\Java17LanguageFeatures>java -Djava.security.manager
-Djava.security.policy=conf\myjava.policy --module-path
    build\modules\jdojo.reflection
--module jdojo.reflection/com.jdojo.reflection.
    IllegalAccess2
Exception in thread "main" java.security.
    AccessControlException: access denied
("java.lang.reflect.ReflectPermission"
    "suppressAccessChecks")
        at java.base/java.security.AccessControlContext.
           checkPermission
           (AccessControlContext.java:472)
        at java.base/java.security.AccessController.
           checkPermission
           (AccessController.java:895)
        at java.base/java.lang.SecurityManager.
           checkPermission(SecurityManager.java:558)
        at java.base/java.lang.reflect.AccessibleObject.
           checkPermission
           (AccessibleObject.java:85)
        at java.base/java.lang.reflect.AccessibleObject.
           trySetAccessible
           (AccessibleObject.java:245)
        at jdojo.reflection/com.jdojo.reflection.
           IllegalAccess2.main
           (IllegalAccess2.java:26)

运行该命令时,myjava.policy文件为空,这意味着应用程序没有禁止 Java 语言访问控制的权限。

如果你想让你的程序使用反射来访问一个类的不可访问的字段,那么myjava.policy文件的内容应该如清单 2-17 所示。

grant {
    // Grant permission to all programs to access
    // inaccessible members
    permission java.lang.reflect.ReflectPermission
        "suppressAccessChecks";
};

Listing 2-17Contents of the conf\myjava.policy File

让我们用清单 2-17 所示的安全管理器和 Java 策略重新运行IllegalAccess2类:

C:\Java17LanguageFeatures>java -Djava.security.manager ^
-Djava.security.policy=conf\myjava.policy ^
--module-path build\modules\jdojo.reflection ^
--module ^
jdojo.reflection/com.jdojo.reflection.IllegalAccess2

Person: id=-1, name=Unknown
name=Unknown

这一次,当您授予适当的安全权限时,您能够访问Person类的私有名称字段。访问不可访问成员的规则才刚刚开始。当获得非法访问的代码和被非法访问的代码在同一个模块中时,您看到了在模块中进行深度反射的规则。下一节将描述跨模块的非法访问行为。

跨模块的深度反射

让我们建立一个名为jdojo.reflection.model的新模块,如清单 2-18 所示,以及其中一个名为Phone的简单类,如清单 2-19 所示。模块声明不包含模块语句。Phone类包含一个数字实例变量、两个构造函数、一个数字实例变量的 getter 和 setter。toString()方法返回电话号码。

// Phone.java
package com.jdojo.reflection.model;
public class Phone {
    private String number = "9999999999";

    public Phone() {
    }
    public Phone(String number) {
        this.number = number;
    }
    public String getNumber() {
        return number;
    }
    public void setNumber(String number) {
        this.number = number;
    }
    @Override
    public String toString() {
        return this.number;
    }
}

Listing 2-19A Phone Class

// module-info.java
module jdojo.reflection.model {
    // No module statements at this time
}

Listing 2-18The Declaration of a jdojo.reflection.model Module

让我们在jdojo.reflection模块中创建一个名为IllegalAccess3的类。该类将尝试在jdojo.reflection.model模块中创建一个Phone类的对象,并读取该对象的私有字段 number。清单 2-20 中的IllegalAccess3类包含完整的代码。和IllegalAccess2级很像。唯一的区别是,您跨越模块边界访问的是Phone类及其私有实例变量。

// IllegalAccess3.java
package com.jdojo.reflection;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
public class IllegalAccess3 {
    public static void main(String[] args)
            throws Exception {
        // Get the class reference for the Phone class
        String className =
            "com.jdojo.reflection.model.Phone";
        Class<?> cls = Class.forName(className);
        // Create a Phone object
        Constructor<?> cons = cls.getConstructor();
        Object phone = cons.newInstance();
        // Get the reference of the number field
        Field numberField = cls.getDeclaredField("number");
        // try making the number field accessible before
        // accessing it
        boolean accessEnabled = numberField.
            trySetAccessible();
        if (accessEnabled) {
            // Try accessing the number field by reading
            // its value
            String number = (String) numberField.
                get(phone);
            // Print the phone number
            System.out.println("number=" + number);
        } else {
            System.out.println("The Phone.number field " +
                "is not accessible.");
        }
    }
}

Listing 2-20Accessing the Private Number Field of the Phone Class

让我们使用以下命令运行IllegalAccess3类:

C:\Java17LanguageFeatures>java ^
--module-path build\modules\jdojo.reflection;build\modules\
    jdojo.reflection.model ^
--module ^
    jdojo.reflection/com.jdojo.reflection.IllegalAccess3
Exception in thread "main"
    java.lang.ClassNotFoundException:
    com.jdojo.reflection.model.Phone
        at java.base/jdk.internal.loader.
           BuiltinClassLoader.loadClass(BuiltinClassLoader.
           java:582)
        at java.base/jdk.internal.loader.ClassLoaders$
           AppClassLoader.loadClass(ClassLoaders.
           java:185)
        at java.base/java.lang.ClassLoader.loadClass
           (ClassLoader.java:496)
        at java.base/java.lang.Class.forName0
           (Native Method)
        at java.base/java.lang.Class.forName
           (Class.java:292)
        at jdoj9o.reflection/com.jdojo.reflection.
           IllegalAccess3.main(IllegalAccess3.java:11)

(“模块”后没有换行符和空格。)

你能猜出这个命令有什么问题吗?该错误表明运行时没有找到Phone类。您能够编译IllegalAccess3类,因为该类不使用源代码中的Phone类引用。它试图在运行时使用反射来使用Phone类。您已经在模块路径中包含了jdojo.reflection.model模块。但是,在模块路径中包含一个模块并不能解析该模块。jdojo.reflection模块不读取jdojo.reflection.model模块,所以运行IllegalAccess3不会解析jdojo.reflection.model模块,这就是运行时找不到Phone类的原因。您需要使用–addmodules命令行选项手动解析模块:

C:\Java17LanguageFeatures>java ^
--module-path build\modules\jdojo.reflection;build\modules\
    jdojo.reflection.model ^
--add-modules jdojo.reflection.model ^
--module ^
    jdojo.reflection/com.jdojo.reflection.IllegalAccess3
Exception in thread "main" java.lang.
    IllegalAccessException: class com.jdojo.reflection.
IllegalAccess3 (in module jdojo.reflection) cannot access
    class com.jdojo.reflection.model.Phone (in module
    jdojo.reflection.model) because module jdojo.
    reflection.model does not export com.jdojo.reflection.
    model to module jdojo.reflection
        at java.base/jdk.internal.reflect.Reflection.
           newIllegalAccessException
           (Reflection.java:361)
        at java.base/java.lang.reflect.AccessibleObject.
           checkAccess
           (AccessibleObject.java:589)
        at java.base/java.lang.reflect.Constructor.
           newInstance
           (Constructor.java:479)
        at jdojo.reflection/com.jdojo.reflection.
           IllegalAccess3.main
           (IllegalAccess3.java:15)

(“模块”后没有换行符和空格。)

这一次,运行时能够找到Phone类,但是它抱怨从另一个模块jdojo.reflection访问jdojo.reflection.model模块中的Phone类。该错误表明jdojo.reflection.model模块没有导出com.jdojo.reflection.model包,所以Phone类在com.jdojo.reflection.model包中,在jdojo.reflection.model模块之外是不可访问的。清单 2-21 包含了jdojo.reflection.model模块的修改版本。现在它导出了com.jdojo.reflection.model包。

// module-info.java
module jdojo.reflection.model {
    exports com.jdojo.reflection.model;
}

Listing 2-21The Modified Declaration of a jdojo.reflection.model Module

让我们使用前面的命令重新运行IllegalAccess3类:

C:\Java17LanguageFeatures>java ^
--module-path ^
    build\modules\jdojo.reflection;
    build\modules\jdojo.reflection.model
--add-modules jdojo.reflection.model ^
--module ^
    jdojo.reflection/com.jdojo.reflection.IllegalAccess3

The Phone.number field is not accessible.

(“反射”后没有换行符和空格。)

这一次,您能够实例化Phone类,但是您不能访问它的私有number字段。请注意,jdojo.reflection模块不读取jdojo.reflection.model模块。尽管如此,IllegalClass3类仍然能够访问Phone类,并使用反射对其进行实例化。如果您在IllegalAccess3类中编写以下代码片段,它将不会编译:

Phone phone = new Phone();

当模块 M 使用反射访问模块 N 中的类型时,从模块 M 到模块 N 的读取被隐式授予。当静态地需要这样的访问(没有反射)时,必须使用requires语句显式地指定这样的读取。这就是前面的命令在创建一个Phone类的对象时所做的事情。

如果您使用了IllegalAccess3类中的setAccessible(true)来使number字段可访问,那么前面的命令将会产生类似于下面的错误消息:

Exception in thread "main" java.lang.reflect.
InaccessibleObjectException: Unable to make field private
java.lang.String com.jdojo.reflection.model.Phone.number
accessible: module jdojo.reflection.model does not "opens
com.jdojo.reflection.model" to module jdojo.reflection
...

这个错误信息非常清晰。它表明运行时无法使私有的number字段可访问,因为jdojo.reflection.model模块没有向jdojo.reflection模块打开com.jdojo.reflection.model包。这里出现了打开一个模块的包和打开整个模块的概念。

导出模块的包会将对包中公共类型的访问权限以及这些类型的可访问公共成员授予另一个模块。导出包会在编译时和运行时授予访问权限。您可以使用反射来访问无需反射即可访问的相同可访问公共成员。也就是说,对于模块的导出包,总是强制执行 Java 语言访问控制。

如果您希望在运行时允许其他模块中的代码对一个模块中的包类型进行深度反射,您需要使用 opens 语句打开该模块的包。opens 语句的语法如下:

opens <package-name> [to <module-name>,<module-name>...];

该语法允许您向所有其他模块或一组特定模块打开一个包。在下面的声明中,模块M向模块ST打开它的包p:

module M {
    opens p to S, T;
}

在下面的声明中,模块N向所有其他模块开放其包q:

module N {
    opens q;
}

一个模块可能会导出并打开同一个包。如果其他模块需要在编译时和运行时静态访问包中的类型,并在运行时使用深度反射,则需要使用它。下面的模块声明将同一个包p导出并打开给所有其他模块:

module J {
    exports p;
    opens p;
}

模块声明中的一个opens语句允许你打开一个包给所有其他模块或选择性模块。如果您想向所有其他模块开放一个模块的所有包,您可以将该模块本身声明为开放模块。您可以通过在模块声明中使用 open 修饰符来声明一个开放的模块。下面声明了一个名为 K 的开放模块:

open module K {
    // Other module statements go here
}

打开的模块不能包含opens语句。这是因为一个开放的模块意味着它已经向所有其他模块开放了它的所有包以进行深度反射。模块 L 的以下声明是无效的,因为它将该模块声明为 open,同时包含一个opens语句:

open module L {
    opens p; // A compile-time error
    // Other module statements go here
}

可以在打开的模块中导出包。模块D的以下声明是有效的:

open module D {
    exports p;
    // Other module statements go here
}

所以,现在你知道如何处理jdojo.reflection.model模块,让jdojo.reflection模块对Phone类进行深度反射。您需要执行以下任一操作:

  • jdojo.reflection.model模块的com.jdojo.reflection.model包对所有其他模块打开,或者至少对 jdojo.reflection 模块打开。

  • jdojo.reflection.model模块声明为开放模块。

清单 2-22 和 2-23 包含了jdojo.reflection.model模块修改后的模块声明。您需要使用其中一个,而不是两个都用。对于这个例子,您不需要在模块的声明中导出包,因为您在编译时没有访问jdojo.reflection模块中的Phone类。

// module-info.java
open module jdojo.reflection.model {
    exports com.jdojo.reflection.model;
}

Listing 2-23The Modified Declaration of a model Module, Which Declares It As an Open Module

// module-info.java
module jdojo.reflection.model {
    exports com.jdojo.reflection.model;
    opens com.jdojo.reflection.model;
}

Listing 2-22The Modified Declaration of a model Module, Which Opens the com.jdojo.reflection.model Package to All Other Modules

让我们在打开com.jdojo.reflection.model包的情况下,使用前面的命令重新运行 IllegalAccess3 类。这一次,您将收到想要的输出:

C:\Java17LanguageFeatures>java ^
--module-path build\modules\jdojo.reflection;
    build\modules\jdojo.reflection.model ^
--add-modules jdojo.reflection.model ^
--module ^
    jdojo.reflection/com.jdojo.reflection.IllegalAccess3

number=9999999999

(“反射”后没有换行符和空格。)

深度反射和未命名模块

未命名模块中的所有包对所有其他模块都是开放的。因此,您总是可以在未命名模块中对类型执行深度反射。

对 JDK 模的深层思考

在 JDK9 之前,所有类型的成员 JDK9 内部成员和您的类型——都允许深度反射。JDK9 的主要目标之一是强大的封装,并且您不应该能够使用深度反射来访问对象的相当不可访问的成员。从 JDK9 开始,对 JDK 模块的深度反射只能在未命名的模块中进行。如果应用程序是模块化的,对 JDK 模块的深度反射是非法的。未命名模块的弱化限制只是为了向后兼容;现代应用程序不应该像私有字段一样访问 JDK 内部。

让我们看一个这样的例子。java.lang.Long类是不可变的。它包含一个名为value的私有字段,用于保存该对象表示的长整型值。清单 2-24 向您展示了如何使用深度反射访问和修改Long类的私有值字段,这在静态使用Long类时是不可能的。

// IllegalAccessJDKType.java
package com.jdojo.reflection;
import java.lang.reflect.Field;
public class IllegalAccessJDKType {
    public static void main(String[] args)
            throws Exception {
        // Create a Long object
        Long num = 1969L;
        System.out.println("#1: num = " + num);
        // Get the class reference for the Long class
        String className = "java.lang.Long";
        Class<?> cls = Class.forName(className);
        // Get the value field reference
        Field valueField = cls.getDeclaredField("value");
        // try making the value field accessible before
        // accessing it
        boolean accessEnabled = valueField.
            trySetAccessible();
        if (accessEnabled) {
            // Get and print the current value of the
            // Long.value private field of the num object
            // that you created in the beginning of this
            // method
            Long value = (Long) valueField.get(num);
            System.out.println("#2: num = " + value);
            // Change the value of the Long.value field
            valueField.set(num, 1968L);
            value = (Long) valueField.get(num);
            System.out.println("#3: num = " + value);
        } else {
            System.out.println("The Long.value field is " +
                "not accessible.");
        }
    }
}

Listing 2-24Accessing and Modifying the Private Value Field of the java.lang.Long Class Using Deep Reflection

在 main()方法的开头,您创建了一个名为numLong对象,并将其值设置为1969L:

Long num = 1969L;
System.out.println("#1: num = " + num);

稍后,您获取对Long类的Class对象的引用,并获取私有value字段的引用,并尝试使其可访问。如果您能够使字段可访问,那么您可以读取它的当前值,即 1969L。现在,您将它的值更改为 1968L,并在程序中读回它。

IllegalAccessJDKType类是jdojo.reflection模块的成员。让我们使用以下命令运行它:

C:\Java17LanguageFeatures>java ^
--module-path build\modules\jdojo.reflection ^
--module ^
jdojo.reflection/com.jdojo.reflection.IllegalAccessJDKType

#1: num = 1969
The Long.value field is not accessible.

您无法使Long类的私有value字段可访问,因为IllegalAccessJDKType类是命名模块的一部分,并且命名模块中的代码不允许非法访问 JDK 内部类型的成员。下面的命令从类路径重新运行该类(有效地取消对它的模块化,并隐式地使用未命名的模块),您将获得所需的输出。即使您已经访问私有字段三次,也要注意一次性警告:

C:\Java17LanguageFeatures>java ^
--class-path build\modules\jdojo.reflection ^
com.jdojo.reflection.IllegalAccessJDKType

#1: num = 1969
WARNING: An illegal reflective access operation has
    occurred
WARNING: Illegal reflective access by com.jdojo.reflection.
    IllegalAccessJDKType
(file:/C:/Java17LanguageFeatures/build/modules/
    jdojo.reflection/) to field java.lang.Long.value
WARNING: Please consider reporting this to the maintainers
of com.jdojo.reflection.IllegalAccessJDKType
WARNING: Use --illegal-access=warn to enable warnings of
    further illegal reflective access operations
WARNING: All illegal access operations will be denied in a
future release
#2: num = 1969
#3: num = 1968

在数组上反射

Java 提供了特殊的 API 来处理数组。Class类让你通过使用它的isArray()方法来发现Class引用是否代表一个数组。您还可以创建一个数组,并使用反射读取和修改其元素的值。java.lang.reflect.Array类用于动态创建一个数组并操作其元素。如前所述,您不能使用普通的反射过程来反射数组的length字段。然而,Array类提供了getLength()方法来获取数组的长度值。注意,Array类中的所有方法都是静态的,大多数方法都将第一个参数作为它们所操作的数组对象的引用。

要创建数组,请使用 array 类的newInstance()静态方法。方法是重载的,有两个版本:

  • Object newInstance(Class<?> componentType, int arrayLength)

  • Object newInstance(Class<?> componentType, int... dimensions)

该方法的一个版本创建指定组件类型和数组长度的数组。另一个版本创建指定组件类型和维度的数组。注意,newInstance()方法的返回类型是Object。您需要使用适当的强制转换将其转换为实际的数组类型。

如果你想创建一个长度为 5 的整型数组,你应该写

int[] ids = (int[]) Array.newInstance(int.class, 5);

该语句与下面的语句具有相同的效果:

int[] ids = new int[5];

如果你想创建一个 5x8 维的 int 数组,你应该写

int[][] matrix = (int[][]) Array.newInstance(
int.class, 5, 8);

清单 2-25 展示了如何动态创建一个数组并操作它的元素。

// ArrayReflection.java
package com.jdojo.reflection;
import java.lang.reflect.Array;
public class ArrayReflection {
    public static void main(String[] args) {
        try {
            // Create the array of int of length 2
            Object arrayObject = Array.newInstance(
                int.class, 2);
            // Print the values in array element. Default
            // values will be zero
            int n1 = Array.getInt(arrayObject, 0);
            int n2 = Array.getInt(arrayObject, 1);
            System.out.println("n1 = " + n1 +
                ", n2 = " + n2);
            // Set the values to both elements
            Array.set(arrayObject, 0, 101);
            Array.set(arrayObject, 1, 102);
            // Print the values in array element again
            n1 = Array.getInt(arrayObject, 0);
            n2 = Array.getInt(arrayObject, 1);
            System.out.println("n1 = " + n1 +
                ", n2 = " + n2);
        } catch (NegativeArraySizeException
                | IllegalArgumentException
                | ArrayIndexOutOfBoundsException e) {
            System.out.println(e.getMessage());
        }
    }
}

n1 = 0, n2 = 0
n1 = 101, n2 = 102

Listing 2-25Reflecting on Arrays

Java 不支持真正的多维数组。相反,它支持数组的数组。Class类包含一个名为getComponentType()的方法,该方法返回数组元素类型的Class对象。清单 2-26 展示了如何获得一个数组的维数。

// ArrayDimension.java
package com.jdojo.reflection;
public class ArrayDimension {
    public static void main(String[] args) {
        int[][][] intArray = new int[6][3][4];
        System.out.println("int[][][] dimension is " +
             getArrayDimension(intArray));
    }
    public static int getArrayDimension(Object array) {
        int dimension = 0;
        Class c = array.getClass();
        // Perform a check that the object is really
        // an array
        if (!c.isArray()) {
            throw new IllegalArgumentException(
                "Object is not an array.");
        }
        while (c.isArray()) {
            dimension++;
            c = c.getComponentType();
        }

        return dimension;
    }
}
int[][][] dimension is 3

Listing 2-26

Getting the Dimension of an Array

扩展数组

创建数组后,不能更改其长度。您可以创建一个更大的数组,并在运行时将旧数组元素复制到新数组中。Java 集合类如ArrayList应用了这种技术,让您可以向集合中添加元素,而不用担心它的长度。您可以使用Class类的getComponentType()方法和Array类的newInstance()方法的组合来创建一个给定类型的新数组。您可以使用System类的arraycopy()静态方法将旧数组元素复制到新数组中。清单 2-27 展示了如何使用反射创建一个特定类型的数组。为了清楚起见,所有运行时检查都被省略了。

// ExpandingArray.java
package com.jdojo.reflection;
import java.lang.reflect.Array;
import java.util.Arrays;
public class ExpandingArray {
    public static void main(String[] args) {
        // Create an array of length 2
        int[] ids = {101, 102};
        System.out.println("Old array length: " +
            ids.length);
        System.out.println("Old array elements: " +
            Arrays.toString(ids));
        // Expand the array by 1
        ids = (int[]) expandBy(ids, 1);
        // Set the third element to 103
        ids[2] = 103; // This is newly added element
        System.out.println("New array length: " +
            ids.length);
        System.out.println("New array elements: " +
            Arrays.toString(ids));
    }
    public static Object
    expandBy(Object oldArray, int increment) {
        // Get the length of old array using reflection
        int oldLength = Array.getLength(oldArray);
        int newLength = oldLength + increment;
        // Get the class of the old array
        Class<?> cls = oldArray.getClass();
        // Create a new array of the new length
        Object newArray = Array.newInstance(
            cls.getComponentType(), newLength);
        // Copy the old array elements to new array
        System.arraycopy(oldArray, 0, newArray,
            0, oldLength);
        return newArray;
    }
}

Old array length: 2
Old array elements: [101, 102]
New array length: 3
New array elements: [101, 102, 103]

Listing 2-27Expanding

an Array Using Reflection

谁应该使用反射?

如果您已经使用任何集成开发环境(IDE)开发了使用拖放功能的 GUI 应用程序,那么您已经使用了以某种形式使用反射的应用程序。所有允许您在设计时设置控件(比如按钮)属性的 GUI 工具都使用反射来获取该控件的属性列表。其他工具如类浏览器和调试器也使用反射。作为一名应用程序程序员,除非您正在开发使用反射 API 提供的动态性的高级应用程序,否则您不会经常使用反射。应该注意的是,使用过多的反射会降低应用程序的性能。

摘要

反射是程序在执行过程中“作为数据”查询和修改其状态的能力。Java 将类的字节码表示为Class类的对象,以方便反射。类字段、构造函数和方法可以分别作为FieldConstructorMethod类的对象来访问。使用一个Field对象,你可以访问和改变字段的值。使用一个Method对象,您可以调用该方法。使用一个Constructor对象,您可以调用一个类的给定构造函数。使用Array类,您还可以使用反射创建指定类型和维度的数组,并操作数组的元素。

Java 已经允许使用反射访问相当难访问的成员,比如类外部的私有字段。这叫深刻反思。在访问不可访问的成员之前,您需要调用该成员上的setAccessible(true),它可能是一个FieldMethodConstructor。如果可访问性不能被启用,setAccessible()方法抛出一个运行时异常。JDK9 出于同样的目的添加了一个trySetAccessible()方法,它不会抛出运行时异常。相反,如果可访问性被启用,它返回true,否则返回false

默认情况下,JDK9 和更高版本中禁止跨模块的深度反射。如果一个模块想要允许给定包中类型的深度反射,该模块必须至少向将使用深度反射的模块打开该包。您可以在模块声明中使用opens语句打开一个包。您可以将一个模块声明为开放模块,这将打开该模块中的所有包以进行深度反射。如果一个已命名的模块M使用反射来访问另一个模块N中的类型,那么模块M隐式地读取模块N。未命名模块中的所有包都打开进行深度反射。

JDK9 和更高版本只允许来自未命名模块或未模块化应用程序的代码对 JDK 内部类型进行深度反射。

练习

练习 1

什么是反思?

练习 2

说出两个包含反射相关类和接口的 Java 包。

运动 3

Class类的实例代表什么?

演习 4

列出三种获取类实例引用的方法。

锻炼 5

什么时候使用Class类的forName()方法来获取Class类的实例?

锻炼 6

说出三个内置的类装入器。你如何获得这些类装入器的引用?

锻炼 7

如果你得到一个Class类的引用,你怎么知道这个引用是否代表一个接口?

运动 8

FieldConstructorMethod类的实例代表什么?

演习 9

使用Class类的getFields()getDeclaredFields()方法有什么区别?

运动 10

您需要使用AccessibleObject类的setAccessible(true)trySetAccessible()方法来使FieldConstructorMethod对象可访问,即使它们是不可访问的(例如,它们被声明为私有)。这两种方法有什么区别?

演习 11

假设您有两个名为RS的模块。模块R包含一个带有公共方法m()的公共p.Test类。模块S中的代码需要使用类p.Test来声明变量并创建其对象。模块S也需要使用反射来访问模块Rp.Test类的公共方法m()。在声明模块R时,你至少需要做什么,模块S才能执行这些任务?

运动 12

什么是在模块中打开包?什么是开放模块?

运动 13

导出和打开一个模块的包有什么区别?举例说明何时需要导出并打开一个模块的同一个包。

运动 14

考虑名为jdojo.reflection.exercise.model的模块和该模块中的MagicNumber类的声明,如下所示:

// module-info.java
module jdojo.reflection.exercises.model {
    /* Add your module statements here */
}
// MagicNumber.java
package com.jdojo.reflection.exercises.model;
public class MagicNumber {
    private int number;
    public int getNumber() {
        return number;
    }
    public void setNumber(int number) {
        this.number = number;
    }
}

修改模块声明,以便其他模块中的代码可以对MagicNumber类的对象执行深度反射。在名为jdojo.reflection.exercises的模块中创建一个名为MagicNumberTest的类。MagicNumberTest类中的代码应该使用反射创建一个MagicNumber类的对象,直接设置它的private数字字段,并使用getNumber()方法读取number字段的当前值。

运动 15

在 Java 9 或更高版本中可以访问 JDK 类的私有成员吗?如果回答为是,请描述这种访问的规则和限制。

演习 16

假设有两个模块,PQ。模块P是一个开放模块。模块Q想要对模块P中的类型进行深度反射。模块Q需要读取其模块声明中的模块P吗?

演习 17

假设有两个模块,MN。模块M不向任何模块打开它的任何包,但是它向所有其他模块导出一个com.jdojo.m。模块N可以使用反射来访问模块Mcom.jdojo.m包的公共可访问成员吗?

三、泛型

在本章中,您将学习:

  • 什么是泛型

  • 如何定义泛型类型、方法和构造函数

  • 如何定义类型参数的界限

  • 如何使用通配符作为实际的类型参数

  • 编译器如何推断泛型类型使用的实际类型参数

  • 泛型及其在数组创建中的局限性

  • 泛型的不正确使用如何导致堆污染

本章中的所有示例程序都是清单 3-1 中声明的jdojo.generics模块的成员。

// module-info.java
module jdojo.generics {
    exports com.jdojo.generics;
}

Listing 3-1The Declaration of a jdojo.generics Module

什么是泛型?

泛型让你可以编写真正的多态代码,可以处理任何类型的代码。

在我定义什么是泛型以及它们能为你做什么之前,让我们讨论一个简单的例子。假设您想要创建一个新类,它的唯一工作是存储对任何类型的引用,其中“任何类型”意味着任何引用类型。让我们称这个类为ObjectWrapper,如清单 3-2 所示。

// ObjectWrapper.java
package com.jdojo.generics;
public class ObjectWrapper {
    private Object ref;

    public ObjectWrapper(Object ref) {
        this.ref = ref;
    }
    public Object get() {
        return ref;
    }
    public void set(Object ref) {
        this.ref = ref;
    }
}

Listing 3-2A Wrapper Class to Store a Reference of Any Type

作为一名 Java 开发人员,您会同意在不知道必须处理的对象类型时编写这种代码。ObjectWrapper类可以存储 Java 中任何类型的引用,比如StringIntegerPerson等。你如何使用ObjectWrapper类?以下是使用它与String型配合工作的方法之一:

ObjectWrapper stringWrapper = new ObjectWrapper("Hello");
stringWrapper.set("Another string");
String myString = (String) stringWrapper.get();

这段代码中有一个问题。即使您知道您在stringWrapper对象中存储了一个字符串,您也必须将get()方法的返回值转换为(String) stringWrapper.get()中的String类型。考虑编写以下代码片段:

ObjectWrapper stringWrapper = new ObjectWrapper("Hello");
stringWrapper.set(new Integer(101));
String myString =(String) stringWrapper.get();

这段代码可以很好地编译。但是,第三条语句在运行时抛出了一个ClassCastException,因为您在第二条语句中存储了一个Integer,并试图在第三条语句中将一个Integer转换为一个String。首先,它允许您在stringWrapper中存储一个Integer。其次,它没有抱怨第三条语句中的代码,因为它不知道您的意图,即您只想将StringstringWrapper一起使用。

Java 在帮助开发人员编写类型安全程序方面取得了一些进展。如果ObjectWrapper类允许你指定只对一个特定的类型使用这个类,比如这次使用String而下一次使用Integer,这不是很好吗?Java 中的泛型实现了你的愿望。它们允许您用类型(类或接口)指定类型参数。这种类型称为泛型类型(更确切地说是泛型类或泛型接口)。当您声明泛型类型的变量并创建泛型类型的对象时,可以指定类型参数值。您已经看到了为方法指定参数。这一次,我讨论的是为类或接口等类型指定参数。

Note

声明中带有类型参数的类型称为泛型类型。

让我们重写ObjectWrapper类来使用泛型,简单地命名新类为Wrapper。泛型类型的形参在泛型类型的声明中指定。参数名是有效的 Java 标识符,在参数化类型名后面的尖括号(< >)中指定。您将使用T作为Wrapper类的类型参数名称:

public class Wrapper<T> {
}

类型参数名是一个字符,用T表示参数是类型,E表示参数是元素,K表示参数是键,N表示参数是数字,V表示参数是值,这是一个不成文的约定。在前面的示例中,您可以为类型参数使用任何名称,如下所示:

public class Wrapper<Hello> {
}
public class Wrapper<MyType> {
}

多个类型参数由逗号分隔。下面的MyClass声明采用了四个类型参数,分别为TUVW:

public class MyClass<T, U, V, W> {
}

您将在实例变量声明、构造函数、get()方法和set()方法的类代码中使用名为T的类型参数。现在,T对你来说意味着任何类型,当你使用这个类的时候就知道了。清单 3-3 包含了Wrapper类的完整代码。

// Wrapper.java
package com.jdojo.generics;
public class Wrapper<T> {
    private T ref;
    public Wrapper(T ref) {
        this.ref = ref;
    }

    public T get() {
        return ref;
    }
    public void set(T ref) {
        this.ref = ref;
    }
}

Listing 3-3Using a Type Parameter to Define a Generic Class

你对在清单 3-3 中使用T感到困惑吗?这里,T表示任何类类型或接口类型。可能是StringObjectcom.jdojo.generics.Person等。如果您在这个程序中处处用Object替换T,并从类名中删除<T>,它就是您拥有的ObjectWrapper class的相同代码。

如何使用Wrapper类?由于它的类名不仅仅是Wrapper,而是Wrapper<T>,您可以指定(但不是必须)为T赋值。要在Wrapper对象中存储一个String引用,您需要如下创建它:

Wrapper<String> greetingWrapper =
    new Wrapper<String>("Hello");

你如何使用Wrapper类的set()get()方法?因为您已经将类Wrapper<T>的类型参数指定为String,所以set()get()方法将只适用于String类型。这是因为您在set()方法中使用了T作为参数类型,在get()方法声明中使用了T作为返回类型。想象一下用String替换类定义中的T,理解下面的代码应该没有问题:

greetingWrapper.set("Hi");
    // <- OK to pass a String
String greeting = greetingWrapper.get();
    // <- No need to cast

这一次,您不必强制转换get()方法的返回值。编译器知道greetingWrapper已经被声明为类型Wrapper<String>,所以它的get()方法返回一个String。让我们试着在greetingWrapper中存储一个Integer对象:

// A compile-time error. You can use greetingWrapper
// only to store a String.
greetingWrapper.set(new Integer(101));

该语句将生成以下编译时错误:

error: incompatible types: Integer cannot be converted to
    String
        greetingWrapper.set(new Integer(101));

您不能将Integer传递给set()方法。编译器将生成一个错误。如果您想使用Wrapper类来存储一个Integer,您的代码将如下所示:

Wrapper<Integer> idWrapper =
    new Wrapper<Integer>(new Integer(101));
idWrapper.set(new Integer(897));
    // <- OK to pass an Integer
Integer id = idWrapper.get();
// A compile-time error. You can use idWrapper only
// with an Integer.
idWrapper.set("hello");

假设存在一个包含带两个参数的构造函数的Person类,您将一个Person对象存储在Wrapper中,如下所示:

Wrapper<Person> personWrapper = new Wrapper<Person>(
    new Person(1, "Chris"));
personWrapper.set(new Person(2, "Laynie"));
Person laynie = personWrapper.get();

在类型声明中指定的参数称为形参;例如,TWrapper<T>类声明中的一个形式类型参数。当你用实际类型替换形式类型参数时(例如,在Wrapper<String>中,你用String替换形式类型参数T,它被称为参数化类型。Java 中的引用类型接受一个或多个类型参数,称为泛型类型。泛型类型主要是在编译器中实现的。JVM 不了解泛型类型。所有实际的类型参数都在编译时通过一个称为擦除的过程被擦除。编译时类型安全是您在代码中使用参数化泛型类型而无需使用强制转换时获得的好处。

多态性是指根据一种类型编写代码,这种类型也适用于许多其他类型。在任何一本关于 Java 的入门书籍中,您都会学到如何使用继承和接口编写多态代码。Java 中的继承提供了包含多态性,您可以根据基本类型编写代码,代码也可以处理该基本类型的所有子类型。在这种情况下,您必须将所有其他类型归入一个继承层次结构中。也就是说,多态代码工作的所有类型都必须从单个基类型继承。Java 中的接口解除了这一限制,允许您根据接口编写代码。代码适用于实现接口的所有类型。这一次,代码适用的所有类型不必都属于一个类型层次结构。但是,您有一个约束,即所有这些类型必须实现相同的接口。Java 中的泛型让你离编写“真正的”多态代码更近了一步。使用泛型编写的代码适用于任何类型。Java 中的泛型对于在代码中如何处理泛型确实有一些限制。本章讨论的主题是向您展示在 Java 中使用泛型可以做些什么,以及详细说明这些限制。

超类型-子类型关系

让我们玩一个把戏。下面的代码创建了Wrapper<T>类的两个参数化实例,一个用于String类型,一个用于Object类型:

Wrapper<String> stringWrapper =
    new Wrapper<String>("Hello");
stringWrapper.set("a string");
Wrapper<Object> objectWrapper =
    new Wrapper<Object>(new Object());
objectWrapper.set(new Object());
// Use a String object with objectWrapper
objectWrapper.set("a string"); // OK

objectWrapper中存储一个String对象就可以了。毕竟,如果你打算在objectWrapper中存储一个Object,一个String也是一个Object。允许以下作业吗?

objectWrapper = stringWrapper;

不,这个作业是不允许的。也就是说,Wrapper<String>Wrapper<Object>的赋值不兼容。为了理解为什么不允许这种赋值,让我们假设它是允许的,您可以编写如下代码:

// Now objectWrapper points to stringWrapper
objectWrapper = stringWrapper;
// We could store an Object in stringWrapper using
// objectWrapper
objectWrapper.set(new Object());
// The following statement will throw a runtime
// ClassCastException
String s = stringWrapper.get();

你看到允许像objectWrapper= stringWrapper这样的任务的危险了吗?如果允许这种赋值,编译器不能确保stringWrapper只存储一个String类型的引用。

记住,a String是一个Object,因为StringObject的子类。然而,Wrapper<String>并不是Wrapper<Object>。普通的超类型/子类型规则不适用于参数化类型。如果你不理解,不要担心记住这条规则。如果你尝试这样的赋值,编译器会告诉你不可以。

原始类型

Java 中泛型类型的实现是向后兼容的。如果现有的非泛型类被重写以利用泛型,使用该类的非泛型版本的现有代码应该继续工作。代码可以通过省略对泛型类型参数的引用来使用(尽管不推荐)泛型类的非泛型版本。泛型类型的非泛型版本称为原始类型。不鼓励使用原始类型。如果在代码中使用原始类型,编译器将生成未检查的警告,如下面的代码片段所示:

// Use the Wrapper<T> generic type as a raw type Wrapper
Wrapper rawType = new Wrapper("Hello"); // An unchecked
                                        // warning
// Using the Wrapper<T> generic type as a parameterized
// type Wrapper<String>
Wrapper<String> genericType = new Wrapper<String>("Hello");
// Assigning the raw type to the parameterized type
genericType = rawType; // An unchecked warning
// Assigning the parameterized type to the raw type
rawType = genericType;

编译这段代码时,编译器会生成以下警告:

warning: [unchecked] unchecked call to Wrapper(T) as a
    member of the raw type Wrapper
        Wrapper rawType = new Wrapper("Hello");
                          ^
  where T is a type-variable:
    T extends Object declared in class Wrapper
warning: [unchecked] unchecked conversion
        genericType = rawType;
                      ^
  required: Wrapper<String>
  found:    Wrapper
2 warnings

无限通配符

先说个例子。它将帮助您理解在泛型类型中使用通配符的必要性。让我们为Wrapper类构建一个实用程序类,并将其命名为WrapperUtil。向该类添加一个名为printDetails()的静态实用方法,它将接受一个Wrapper<T>类的对象。你应该如何定义这个方法的参数?以下是第一次尝试:

public class WrapperUtil {
    public static
    void printDetails(Wrapper<Object> wrapper){
        // More code goes here
    }
}

既然你的printDetails()方法应该打印任何类型的Wrapper<T>的细节,那么Object作为类型参数似乎更合适。让我们使用你的新printDetails()方法,如图所示:

Wrapper<Object> objectWrapper =
    new Wrapper<Object>(new Object());
WrapperUtil.printDetails(objectWrapper); // OK
Wrapper<String> stringWrapper =
    new Wrapper<String>("Hello");
WrapperUtil.printDetails(stringWrapper); // A compile-time
                                         // error

编译时错误如下:

error: method printDetails in class WrapperUtil cannot be
    applied to given types;
        WrapperUtil.printDetails(stringWrapper);
                   ^
  required: Wrapper<Object>
  found: Wrapper<String>
  reason: argument mismatch; Wrapper<String> cannot be
    converted to Wrapper<Object>
1 error

你可以用Wrapper<Object>类型调用printDetails()方法,但不能用Wrapper<String>类型,因为它们不是赋值兼容的,这与你的直觉相矛盾。要完全理解它,您需要了解泛型中的通配符类型。通配符类型用问号表示,如在<?>中。对于泛型类型,通配符类型就像Object类型对于原始类型一样。您可以将已知类型的类属指定给通配符类型的类属。下面是示例代码:

// Wrapper of String type
Wrapper<String> stringWrapper = new Wrapper<String>("Hi");
// You can assign a Wrapper<String> to Wrapper<?> type
Wrapper<?> wildCardWrapper = stringWrapper;

通配符泛型类型中的问号(例如,<?>)表示未知类型。当您使用通配符(表示未知)作为参数类型来声明参数化类型时,这意味着它不知道自己的类型:

// wildCardWrapper has unknown type
Wrapper<?> wildCardWrapper;
// Better to name it as an unknownWrapper
Wrapper<?> unknownWrapper;

可以创建一个未知类型的Wrapper<T>对象吗?让我们假设约翰为你做饭。他把食物打包,然后交给你。你把包交给唐娜。唐娜问你包里是什么。你的答案是你不知道。约翰能像你一样回答吗?不。他一定知道他做了什么,因为他是做饭的人。即使你不知道包里装的是什么,你也可以毫不费力地拿着它交给唐娜。如果唐娜让你给她包装里的蔬菜,你会怎么回答?你会说你不知道蔬菜是否在包装里。

以下是使用通配符(未知)泛型类型的规则。因为它不知道自己的类型,所以不能用它来创建未知类型的对象。以下代码是非法的:

// Cannot use <?> with new operator. It is a compile-time
// error.
new Wrapper<?>("");
error: unexpected type
        new Wrapper<?>("");
                   ^
  required: class or interface without bounds
  found:    ?
1 error

当您拿着未知食物类型的包时(John 在烹饪食物时知道食物的类型),通配符泛型类型可以引用已知的泛型类型对象,如下所示:

Wrapper<?> unknownWrapper = new Wrapper<String>("Hello");

关于通配符泛型类型引用可以对对象做什么,有一个复杂的规则列表。然而,有一个简单的经验法则需要记住。使用泛型的目的是获得编译时类型安全。只要编译器确信该操作在运行时不会产生任何意外的结果,它就允许对通配符泛型类型引用执行该操作。

让我们将经验法则应用于您的unknownWrapper参考变量。这个unknownWrapper变量可以确定的一件事是,它引用了一个已知类型的Wrapper<T>类的对象。但是,它不知道已知的类型是什么。可以用下面的get()方法吗?以下语句会生成编译时错误:

String str = unknownWrapper.get();
error: incompatible types: CAP#1 cannot be converted
    to String
        String str = unknownWrapper.get();
                                       ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Object from capture of ?
1 error

编译器知道Wrapper<T>类的get()方法返回一个T类型的对象。然而,对于unknownWrapper变量,类型T未知。因此,编译器不能确保方法调用unknownWrapper.get()将返回一个String,并且在运行时它对str变量的赋值是正确的。你所要做的就是让编译器相信这个赋值不会在运行时抛出一个ClassCastException。下面一行代码会编译吗?

Object obj = unknownWrapper.get(); // OK

这段代码会编译,因为编译器确信这条语句不会在运行时抛出ClassCastException。它知道get()方法返回一个类型的对象,而这个类型对于unknownWrapper变量来说是未知的。无论get()方法返回什么类型的对象,它总是与Object类型赋值兼容。毕竟,Java 中的所有引用类型都是Object类型的子类型。下面的代码片段可以编译吗?

unknownWrapper.set("Hello");        // A compile-time error
unknownWrapper.set(new Integer());  // A compile-time error
unknownWrapper.set(new Object());   // A compile-time error
unknownWrapper.set(null);           // OK

您对这段代码中的错误感到惊讶吗?你会发现这并不像看起来那么令人惊讶。set(T a)方法接受泛型类型参数。这个类型T对于unknownWrapper来说是未知的,因此编译器不能确定这个未知类型是String类型、Integer类型还是Object类型。这就是为什么对set()的前三次调用会被编译器拒绝。为什么第四次调用set()方法是正确的?在 Java 中,null对任何引用类型都是赋值兼容的。编译器认为,无论set(T a)方法中的T是什么类型,对于unknownWrapper引用变量所指向的对象,使用null总是安全的。下面是你的printDetails()方法的代码。如果您将一个null Wrapper对象传递给这个方法,它将抛出一个NullPointerException:

public class WrapperUtil {
    public static void printDetails(Wrapper<?> wrapper) {
        // Can assign get() return value to an Object
        Object value = wrapper.get();
        String className = null;
        if (value != null) {
            className = value.getClass().getName();
        }
        System.out.println("Class: " + className);
        System.out.println("Value: " + value);
    }
}

Note

仅使用问号作为参数类型(<?>)被称为无界通配符。它没有限制可以引用什么类型。您还可以使用通配符设置上限或下限。我将在接下来的两节中讨论有界通配符。

上限通配符

假设您想向您的WrapperUtil类添加一个方法。该方法应该接受包装在您的Wrapper对象中的两个数字,并将返回它们的总和。被包裹的物体可以是IntegerLongByteShortDoubleFloat。您的第一个尝试是编写如下所示的sum()方法:

public static double sum(Wrapper<?> n1, Wrapper<?> n2) {
    //Code goes here
}

这个方法签名有一些明显的问题。参数n1n2可以是Wrapper<T>类的任何参数化类型。例如,下面的调用将是对sum()方法的有效调用:

// Try adding an Integer and a String
sum(new Wrapper<Integer>(new Integer(125)),
    new Wrapper<String>("Hello"));

计算一个Integer和一个String的和是没有意义的。然而,代码会编译,你应该准备好根据sum()方法的实现得到一些运行时异常。您必须限制这种代码的编译。它应该接受两个Number类型的Wrapper对象或者它的子类,而不仅仅是任何东西。因此,您知道了Wrapper对象应该具有的实际参数类型的上限。上限是Number型。如果您传递任何其他类型,它是Number类型的一个子类,那就没问题。然而,任何不是Number类型或其子类类型的东西都应该在编译时被拒绝。您可以将通配符的上限表示为

<? extends T>

这里,T是一个类型。<?扩展T>意味着任何类型T或其子类是可接受的。使用你的上限作为Number,你可以定义你的方法为

public static double sum(Wrapper<? extends Number> n1,
        Wrapper<? extends Number> n2) {
    Number num1 = n1.get();
    Number num2 = n2.get();
    double sum = num1.doubleValue() + num2.doubleValue();
    return sum;
}

方法中的以下代码片段编译正常:

Number num1 = n1.get();
Number num2 = n2.get();

无论你为n1n2传递什么,它们总是与Number的赋值兼容,因为编译器会确保传递给sum()方法的参数遵循其<? extends Number>声明中指定的规则。试图计算一个Integer和一个String的和将被编译器拒绝。考虑以下代码片段:

Wrapper<Integer> intWrapper =
    new Wrapper<Integer>(new Integer(10));
Wrapper<? extends Number> numberWrapper = intWrapper;
    // <- OK
numberWrapper.set(new Integer(1220));
    // <- A compile-time error
numberWrapper.set(new Double(12.20));
    // <- A compile-time error

你能找出这段代码的问题吗?numberWrapper的类型是<? extends Number>,这意味着它可以引用(或者是赋值兼容的)任何属于Number类的子类型的东西。由于IntegerNumber的子类,所以允许将intWrapper赋值给numberWrapper。当你试图在numberWrapper上使用set()方法时,编译器开始抱怨,因为它不能在编译时确定numberWrapperIntegerDouble的类型,它们是Number的子类型。使用泛型时,要小心这种编译时错误。从表面上看,这对您来说可能是显而易见的,您会认为代码应该编译和运行良好。除非编译器确保该操作是类型安全的,否则它不会允许您继续操作。毕竟,编译时和运行时类型安全是泛型的主要目标!

下界通配符

指定下限通配符与指定上限通配符相反。使用下界通配符的语法是<? super T>,这意味着“任何是 t 的超类型的东西”。您将调用新方法copy(),它将把值从源包装器对象复制到目标包装器对象。这是第一次尝试。<T>copy()方法的形式类型参数。它指定 source 和 dest 参数必须是同一类型。我将在下一节详细解释泛型方法。

public class WrapperUtil {
    public static <T> void
    copy(Wrapper<T> source, Wrapper<T> dest) {
        T value = source.get();
        dest.set(value);
    }
}

使用您的copy()方法将Wrapper<String>的内容复制到Wrapper<Object>将不起作用:

Wrapper<Object> objectWrapper =
    new Wrapper<Object>(new Object());
Wrapper<String> stringWrapper =
    new Wrapper<String>("Hello");
WrapperUtil.copy(stringWrapper, objectWrapper);
    // <- A compile-time error

这段代码将生成一个编译时错误,因为copy()方法要求sourcedest参数是同一类型。然而,实际上,String永远是Object。这里,您需要使用下界通配符,如下所示:

public class WrapperUtil {
    // New definition of the copy() method
    public static <T> void
    copy(Wrapper<T> source, Wrapper<? super T> dest){
        T value = source.get();
        dest.set(value);
    }
}

现在你说copy()方法的dest参数可以是T,与source相同,或者是它的任何超类型。您可以使用copy()方法将Wrapper<String>的内容复制到Wrapper<Object>中,如下所示:

Wrapper<Object> objectWrapper =
    new Wrapper<Object>(new Object());
Wrapper<String> stringWrapper =
    new Wrapper<String>("Hello");
WrapperUtil.copy(stringWrapper, objectWrapper);
    // <- OK with the new copy() method

由于ObjectString的超类型,新的copy()方法将会工作。然而,你不能用它从一个Object类型的包装器复制到一个String类型的包装器,因为“一个对象是一个字符串”并不总是正确的。清单 3-4 显示了WrapperUtil类的完整代码。

// WrapperUtil.java
package com.jdojo.generics;
public class WrapperUtil {
    public static void printDetails(Wrapper<?> wrapper) {
        // Can assign get() return value to Object
        Object value = wrapper.get();
        String className = null;
        if (value != null) {
            className = value.getClass().getName();
        }
        System.out.println("Class: " + className);
        System.out.println("Value: " + value);
    }
    public static double sum(Wrapper<? extends Number> n1,
            Wrapper<? extends Number> n2) {
        Number num1 = n1.get();
        Number num2 = n2.get();
        double sum = num1.doubleValue() +
            num2.doubleValue();
        return sum;
    }
    public static <T> void copy(Wrapper<T> source,
            Wrapper<? super T> dest) {
        T value = source.get();
        dest.set(value);
    }
}

Listing 3-4A WrapperUtil Utility Class That Works with Wrapper Objects

清单 3-5 向您展示了如何使用WrapperWrapperUtil类。

// WrapperUtilTest.java
package com.jdojo.generics;

public class WrapperUtilTest {
   public static void main(String[] args) {
       Wrapper<Integer> n1 = new Wrapper<>(10);
       Wrapper<Double> n2 = new Wrapper<>(15.75);
       // Print the details
       WrapperUtil.printDetails(n1);
       WrapperUtil.printDetails(n2);
       // Add numeric values in two WrapperUtil
       double sum = WrapperUtil.sum(n1, n2);
       System.out.println("sum: " + sum);
       // Copy the value of a Wrapper<Double> to a
       // Wrapper<Number>
       Wrapper<Number> holder = new Wrapper<>(45);
       System.out.println("Original holder: " +
           holder.get());
       WrapperUtil.copy(n2, holder);
       System.out.println("After copy holder: " +
           holder.get());
   }
}

Class: java.lang.Integer
Value: 10
Class: java.lang.Double
Value: 15.75
sum: 25.75
Original holder: 45
After copy holder: 15.75

Listing 3-5Using the WrapperUtil Class

泛型方法和构造函数

您可以在方法声明中定义类型参数。它们在方法的返回类型前的尖括号中指定。包含泛型方法声明的类型不一定是泛型类型,因此可以在非泛型类型中包含泛型方法。类型及其方法也可以定义不同的类型参数。

Note

为泛型类型定义的类型参数在该类型的静态方法中不可用。因此,如果静态方法需要是泛型的,它必须定义自己的类型参数。如果一个方法需要是泛型的,只将该方法定义为泛型,而不是将整个类型定义为泛型。

下面的代码片段定义了一个泛型类型Test,它的类型参数名为T。它还定义了一个通用实例方法m1(),该方法定义了自己的通用类型参数V。该方法还使用类型参数T,该参数由其类定义。注意在m1()方法的返回类型void之前<V>的使用。它为该方法定义了一个名为V的新泛型类型。

public class Test<T> {
    public <V> void m1(Wrapper<V> a, Wrapper<V> b, T c) {
        // Do something
    }
}

您能想到为m1()方法定义和使用泛型类型参数V的含义吗?看看它在定义方法的第一个和第二个参数为Wrapper<V>时的用法。它强制第一个和第二个参数为同一类型。第三个参数必须是相同的类型T,这是类实例化的类型。

当你想调用一个方法时,你如何指定这个方法的泛型类型?通常,在调用方法时,不需要指定实际的类型参数。编译器会使用您传递给方法的值为您计算出来。但是,如果您需要为方法的形式类型参数传递实际类型参数,您必须在方法调用中的点和方法名称之间的尖括号(< >)中指定它,如下所示:

Test<String> t = new Test<String>();
Wrapper<Integer> iw1 =
    new Wrapper<Integer>(new Integer(201));
Wrapper<Integer> iw2 =
    new Wrapper<Integer>(new Integer(202));
// Specify that Integer is the actual type for the type
// parameter for m1()
t.<Integer>m1(iw1, iw2, "hello");
// Let the compiler figure out the actual type parameters
// using types for iw1 and iw2
t.m1(iw1, iw2, "hello"); // OK

清单 3-4 展示了如何声明一个通用的静态方法。不能在静态方法中引用包含类的类型参数。静态方法只能引用它自己声明的类型参数。

这是来自WrapperUtil类的copy()静态方法的副本。它定义了一个类型参数T,用于约束参数sourcedest的类型:

public static <T> void copy(Wrapper<T> source,
        Wrapper<? super T> dest) {
    T value = source.get();
    dest.set(value);
}

编译器会计算出一个方法的实际类型参数,不管这个方法是非静态的还是静态的。但是,如果要为静态方法调用指定实际的类型参数,可以按如下方式进行:

WrapperUtil.<Integer>copy(iw1, iw2);

也可以像定义方法一样定义构造函数的类型参数。下面的代码片段为类Test的构造函数定义了一个类型参数U。它设置了一个约束,即构造函数的类型参数U必须与其类类型参数T的实际类型相同或者是其实际类型的子类型:

public class Test<T> {
    public <U extends T> Test(U k) {
        // Do something
    }
}

编译器将通过检查您传递给构造函数的参数来计算出传递给构造函数的实际类型参数。如果要为构造函数指定实际的类型参数值,可以在 new 运算符和构造函数名称之间的尖括号中指定,如下面的代码片段所示:

// Specify the actual type parameter for the constructor
// as Double
Test<Number> t1 = new <Double>Test<Number>(
    new Double(12.89));
// Let the compiler figure out that we are using Integer
// as the actual type parameter for the constructor
Test<Number> t2 = new Test<Number>(new Integer(123));

通用对象创建中的类型推理

在许多情况下,当您创建泛型类型的对象时,编译器可以推断出对象创建表达式中类型参数的值。注意,对象创建表达式中的类型推断支持仅限于类型明显的情况。请考虑以下陈述:

List<String> list = new ArrayList<String>();

list 声明为List<String>,很明显你想创建一个类型参数为<String>ArrayList。在这种情况下,您可以指定空尖括号<>(称为菱形运算符或简称为菱形),作为ArrayList的类型参数。您可以重写该语句,如下所示:

List<String> list = new ArrayList<>();

请注意,如果在对象创建表达式中没有为泛型类型指定类型参数,则该类型是原始类型,编译器会生成未检查的警告。例如,下面的语句将编译为带有未检查的警告:

// Using ArrayList as a raw type, not a generic type
List<String> list = new ArrayList(); // Generates an
                                     // unchecked warning
warning: [unchecked] unchecked conversion
        List<String> list = new ArrayList();
                            ^
  required: List<String>
  found:    ArrayList
1 warning

有时,编译器无法正确推断对象创建表达式中类型的参数类型。在这些情况下,您需要指定参数类型,而不是使用菱形运算符(<>)。否则,编译器会推断出错误的类型,从而产生错误。

当在对象创建表达式中使用菱形运算符时,编译器使用四步过程来推断参数化类型的参数类型。让我们考虑一个典型的对象创建表达式:

  1. 首先,它试图从构造函数参数的静态类型中推断出类型参数。请注意,构造函数参数可能为空,例如,new ArrayList<>()。如果在此步骤中推断出类型参数,则该过程继续到下一步骤。

  2. 它使用赋值运算符的左侧来推断类型。在前面的语句中,如果构造函数参数为空,它会将T2推断为类型。请注意,对象创建表达式可能不是赋值语句的一部分。在这种情况下,它将使用下一步。

  3. 如果对象创建表达式用作方法调用的实际参数,编译器会通过查看被调用方法的形参类型来推断类型。

  4. 如果所有其他的都失败了,并且它不能使用这些步骤推断类型,它推断Object作为类型参数。

T1<T2> var = new T3<>(constructor-arguments);

让我们讨论几个涉及类型推断过程中所有步骤的例子。创建两个列表,List<String>类型的list1List<Integer>类型的list2:

import java.util.Arrays;
import java.util.List;
// More code goes here...
List<String> list1 = Arrays.asList("A", "B");
List<Integer> list2 = Arrays.asList(9, 19, 1969);

考虑以下使用菱形运算符的语句:

List<String> list3 = new ArrayList<>(list1);
    // <- Inferred type is String

编译器使用构造函数参数list1来推断类型。list1的静态类型是List<String>,所以类型String是由编译器推断出来的。前面的语句编译正常。在推理过程中,编译器没有使用赋值操作符List<String> list3的左侧。你可能不相信这个论点。考虑下面的陈述来证明这一点:

List<String> list4 = new ArrayList<>(list2);
    // <- A compile-time error

required: List<String>
found:    ArrayList<Integer>
1 error

你现在相信了吗?构造函数参数是list2,它的静态类型是List<Integer>。编译器推断类型为Integer,并将ArrayList<>替换为ArrayList<Integer>。变量list4的类型是List<String>,它与ArrayList<Integer>的赋值不兼容,这导致了编译时错误。

考虑以下语句:

List<String> list5 = new ArrayList<>();
    // <- Inferred type is String

这一次,没有构造函数参数。编译器使用第二步查看赋值操作符的左侧来推断类型。在左侧,它找到了List<String>,并正确地推断出类型为String。考虑一个如下声明的process()方法:

public static void process(List<String> list) {
    // Code goes here
}

下面的语句调用了process()方法,推断出的类型参数是String:

// The inferred type is String
process(new ArrayList<>());

编译器查看process()方法的形参类型,找到List<String>,并将类型推断为String

Note

使用菱形操作符可以节省一些输入。当类型推断很明显时使用它。但是,为了提高可读性,最好在复杂的对象创建表达式中指定类型,而不是菱形运算符。总是选择可读性而不是简洁。

JDK9 增加了对匿名类中 diamond 操作符的支持,如果推断的类型是可拒绝的。如果推断的类型是不可指定的,则不能对匿名类使用 diamond 运算符,即使在 JDK9 或更高版本中也是如此。Java 编译器使用不能用 Java 程序编写的类型。可以用 Java 程序编写的类型称为可命名类型。编译器知道但不能用 Java 程序编写的类型称为不可命名类型。例如,String是一个可命名的类型,因为你可以在程序中用它来表示一个类型;然而,Serializable & CharSequence不是一个可命名的类型,即使它对于编译器是一个有效的类型。它是一个交集类型,表示实现两个接口SerializableCharSequence的类型。泛型类型定义中允许交集类型,但不能使用此交集类型声明变量:

// Not allowed in Java code. Cannot declare a variable
// of an intersection type.
Serializable & CharSequence var;
// Allowed in Java code
class Magic<T extends Serializable & CharSequence> {
    // More code goes here
}

Java 在java.util.concurrent package中包含一个通用的Callable<V>接口。兹声明如下:

public interface Callable<V> {
    V call() throws Exception;
}

在 JDK9 和更高版本中,编译器将在下面的代码片段中推断匿名类的类型参数为Integer:

// A compile-time error in JDK8, but allowed in JDK9.
Callable<Integer> c = new Callable<>() {
    @Override
    public Integer call() {
        return 100;
    }
};

没有通用异常类

异常在运行时抛出。如果在 catch 子句中使用泛型异常类,编译器无法确保异常在运行时的类型安全,因为擦除过程会在编译期间擦除任何类型参数。这就是试图定义泛型类是编译时错误的原因,泛型类是java.lang.Throwable的直接或间接子类。

没有通用匿名类

匿名类是一次性类。您需要一个类名来指定实际的类型参数。匿名类没有名字。因此,不能有泛型匿名类。但是,匿名类中可以有泛型方法。匿名类可以继承泛型类。匿名类可以实现通用接口。除了异常类型、枚举和匿名内部类之外,任何类都可以有类型参数。

泛型和数组

让我们看看下面这个名为GenericArrayTest的类的代码:

public class GenericArrayTest<T> {
    private T[] elements;
    public GenericArrayTest(int howMany) {
        elements = new T[howMany]; // A compile-time error
    }
    // More code goes here
}

GenericArrayTest类声明一个类型参数T。在构造函数中,它试图创建一个泛型类型的数组。您不能编译前面的代码。编译器会抱怨以下语句:

elements = new T[howMany]; // A compile-time error

回想一下,当编译泛型类或使用泛型类型参数的代码时,所有对泛型类型参数的引用都将从代码中删除。数组在创建时需要知道它的类型,这样当元素存储在数组中时,它可以在运行时执行检查,以确保元素与数组类型的赋值兼容。如果使用类型参数创建数组,则数组的类型信息在运行时将不可用。这就是不允许使用该语句的原因。

您不能创建泛型类型的数组,因为编译器不能确保数组元素赋值的类型安全。您不能编写以下代码:

Wrapper<String>[] gsArray = null;
// Cannot create an array of generic type
gsArray = new Wrapper<String>[10]; // A compile-time error

允许创建一个无界通配符泛型类型数组,如下所示:

Wrapper<?>[] anotherArray = new Wrapper<?>[10]; // Ok

假设你想使用一个泛型类型的数组。您可以通过使用java.lang.reflect.Array类的newInstance()方法来这样做,如下所示。由于数组创建语句中使用的强制转换,您必须在编译时处理未检查的警告。下面的代码片段显示,当您试图将一个Object偷偷放入一个Wrapper<String>数组时,您仍然可以绕过编译时类型安全检查。然而,这是您在使用泛型时必须忍受的后果,泛型在运行时不携带其类型信息。Java 泛型就像你能想象的那样肤浅。

Wrapper<String>[] a = (Wrapper<String>[]) Array.
    newInstance(Wrapper.class, 10);
Object[] objArray = (Object[]) a;
objArray[0] = new Object();
    // <- Will throw a java.lang.
    // ArrayStoreExceptionxception
a[0] = new Wrapper<String>("Hello");
    // <- OK. Checked by compiler

通用对象的运行时类类型

参数化类型的对象的类类型是什么?考虑清单 3-6 中的程序。

// GenericsRuntimeClassTest.java
package com.jdojo.generics;
public class GenericsRuntimeClassTest {
    public static void main(String[] args) {
        Wrapper<String> a =
            new Wrapper<String>("Hello");
        Wrapper<Integer> b =
            new Wrapper<Integer>(new Integer(123));

        Class aClass = a.getClass();
        Class bClass = b.getClass();
        System.out.println("Class for a: " +
            aClass.getName());
        System.out.println("Class for b: " +
            bClass.getName());
        System.out.println("aClass == bClass: " +
            (aClass == bClass));
    }
}

Class for a: com.jdojo.generics.Wrapper
Class for b: com.jdojo.generics.Wrapper
aClass == bClass: true

Listing 3-6All Objects of a Parameterized Type Share the Same Class at Runtime

程序创建Wrapper<String>Wrapper<Integer>的对象。它打印两个对象的类名,它们是相同的。输出显示相同泛型类型的所有参数化对象在运行时共享相同的类对象。如前所述,您提供给泛型类型的类型信息在编译期间会从代码中移除。编译器将Wrapper<String> a;语句改为Wrapper a;。对于 JVM,一切照常(在泛型出现之前)!

堆积污染

在运行时表示一个类型被称为具体化。可以在运行时表示的类型被称为可重用类型。在运行时没有完全表示的类型被称为不可再具体化类型。大多数泛型类型是不可再具体化的,因为泛型是使用擦除实现的,擦除在编译时移除类型的参数信息。比如你写Wrapper<String>的时候,编译器去掉了类型参数<String>,运行时看到的只是Wrapper而不是Wrapper<String>

堆污染是一种当参数化类型的变量引用不属于相同参数化类型的对象时发生的情况。如果编译器检测到可能的堆污染,它会发出未经检查的警告。如果您的程序编译时没有任何未检查的警告,堆污染就不会发生。考虑以下代码片段:

Wrapper nWrapper = new Wrapper<Integer>(101);    // #1
// Unchecked warning at compile-time and heap pollution
// at runtime
Wrapper<String> sWrapper = nWrapper; // #2
String str = sWrapper.get();         // #3
                                     // ClassCastException

第一条语句(标记为#1)编译正常。第二条语句(标记为#2)生成一个未检查的警告,因为编译器无法确定nWrapper是否属于类型Wrapper<String>。由于参数类型信息在编译时被删除,运行时无法检测这种类型不匹配。第二条语句中的堆污染使得在运行时在第三条语句(标记为#3)中获得一个ClassCastException成为可能。如果第二条语句不被允许,第三条语句将不会引起ClassCastException

堆污染也可能因为未检查的强制转换操作而发生。考虑以下代码片段:

Wrapper<? extends Number> nW = new Wrapper<Long>(1L); // #1
// Unchecked cast and unchecked warning occurs when the
// following statement #2 is compiled. Heap pollution
// occurs, when it is executed.
Wrapper<Short> sw = (Wrapper<Short>) nW; // #2
short s = sw.get();                      // #3
                                         // ClassCastException

标记为#2 的语句使用了未检查的强制转换。编译器发出未经检查的警告。在运行时,它会导致堆污染。因此,标记为#3 的语句生成一个运行时ClassCastException

Varargs 方法和堆污染警告

Java 通过将 varargs 参数转换为数组来实现 varargs 方法的 varargs 参数。如果 varargs 方法使用泛型类型 varargs 参数,Java 不能保证类型安全。不可重新验证的泛型类型 varargs 参数可能会导致堆污染。考虑下面的代码片段,它声明了一个带有参数化类型参数的process()方法。方法体中的注释指出了堆污染和其他类型的问题:

public static void process(Wrapper<Long>...nums) {
    Object[] obj = nums;               // Heap pollution
    obj[0] = new Wrapper<>("Hello");   // An array
                                       // corruption
    Long lv = nums[0].get();           // A ClassCastException
    // Other code goes here
}

Note

您需要在 javac 编译器中使用-Xlint:unchecked,varargs选项来查看 unchecked 和 varargs 警告。

process()方法被编译时,编译器从其参数化类型参数中移除类型信息<Long>,并将其签名改为process(Wrapper[] nums)。当您编译process()方法的声明时,您会得到以下未检查的警告:

warning: [unchecked] Possible heap pollution from
    parameterized vararg type Wrapper<Long>
       public static void process(Wrapper<Long>...nums) {
                                                         ^
1 warning

考虑下面调用process()方法的代码片段:

Wrapper<Long> v1 = new Wrapper<>(10L);
Wrapper<Long> v2 = new Wrapper<>(11L);
process(v1, v2); // An unchecked warning

编译这段代码时,它会生成以下编译器未检查警告:

warning: [unchecked] unchecked generic array creation for
    varargs parameter of type
Wrapper<Long>[]
                process(v1, v2);
                       ^
1 warning

警告是在方法声明和方法调用位置生成的。如果您创建了这样一个方法,那么您有责任确保在您的方法体中不会发生堆污染。

如果您创建了一个带有不可重用类型参数的 varargs 方法,那么您可以通过使用@SafeVarargs注释来隐藏方法声明位置以及方法调用位置的未检查警告。通过使用@SafeVarargs,您断言您的 varargs 方法和不可重证的类型参数可以安全使用。下面的代码片段使用了带有process()方法的@SafeVarargs注释:

@SafeVarargs
public static void process(Wrapper<Long>...nums) {
    Object[] obj = nums;
        // <- Heap pollution
    obj[0] = new Wrapper<String>("Hello");
        // <- An array corruption
    Long lv = nums[0].get();
        // <- A ClassCastException
        // Other code goes here
}

当您编译这个process()方法的声明时,您不会得到一个未检查的警告。但是,您会得到下面的 varargs 警告,因为当 varargs 参数nums被分配给Object数组 obj 时,编译器会发现可能的堆污染:

warning: [varargs] Varargs method could cause heap
    pollution from non-reifiable varargs
parameter nums
                Object[] obj = nums;
                               ^
1 warning

通过使用如下的@SuppressWarnings注释,可以取消带有不可重证类型参数的 varargs 方法的未检查和 varargs 警告:

@SuppressWarnings({"unchecked", "varargs"})
public static void process(Wrapper<Long>...nums) {
    // Code goes here
}

请注意,在 varargs 方法中使用@SuppressWarnings注释时,它只在方法声明的位置取消警告,而不在调用方法的位置取消警告。

摘要

泛型是 Java 语言的特性,允许您声明使用类型参数的类型(类和接口)。当使用泛型类型时,指定类型参数。与实际类型参数一起使用的类型称为参数化类型。当使用泛型类型而没有指定其类型参数时,它被称为原始类型。例如,如果Wrapper<T>是泛型类,Wrapper<String>是参数化类型,String是实际类型参数,Wrapper是原始类型。也可以为构造函数和方法指定类型参数。泛型允许您使用适用于所有类型的类型参数在 Java 代码中编写真正的多态代码。

默认情况下,类型参数是无界的,这意味着您可以为类型参数指定任何类型。例如,如果用类型参数<T>声明一个类,那么可以指定 Java 中可用的任何类型,比如<String><Object><Person><Employee><Integer>等。,实际类型为T。类型声明中的类型参数也可以指定为有上限或下限。声明Wrapper<U extends Person>是为类型参数U指定上限的一个例子,它指定U可以是Person的类型或Person的子类型。声明Wrapper<?super Person>是指定下限的一个例子;它指定类型参数是类型Person还是超类型Person

Java 还允许您将通配符(一个问号)指定为实际的类型参数。通配符作为实际参数意味着实际类型参数未知;例如,Wrapper<?>意味着泛型类型Wrapper<T>的类型参数T未知。

编译器尝试使用泛型来推断表达式的类型,这取决于使用表达式的上下文。如果编译器无法推断类型,它会生成编译时错误,您需要显式指定类型。

参数化类型不存在超类型-子类型关系。比如Wrapper<Long>不是Wrapper<Number>的子类型。

编译器使用名为类型删除的过程删除泛型类型参数。因此,泛型类型参数在运行时不可用。比如Wrapper<Long>Wrapper<String>的运行时类型是一样的,都是Wrapper

练习

练习 1

什么是泛型(或泛型类型)、参数化类型和原始类型?给出一个泛型类型及其参数化类型的例子。

练习 2

Number类是Long类的超类。以下代码片段无法编译。解释一下。

List<Number> list1= new ArrayList<>();
List<Long> list2= new ArrayList<>();
list1 = list2;  // A compile-time error

运动 3

运行下面的ClassNamePrinter类时,写下输出。编译器在编译过程中删除类型参数T后,重写该类的print()方法的代码:

// ClassNamePrinter.java
package com.jdojo.generics.exercises;
public class ClassNamePrinter {
    public static void main(String[] args) {
        ClassNamePrinter.print(10);
        ClassNamePrinter.print(10L);
        ClassNamePrinter.print(10.2);
    }
    public static <T extends Number> void
            print(T obj) {
        String className = obj.getClass().
            getSimpleName();
        System.out.println(className);
    }
}

演习 4

什么是无界通配符?为什么下面的代码片段无法编译?

List<?> list = new ArrayList<>();
list.add("Hello"); // A compile-time error

锻炼 5

考虑下面的Util类的不完整声明:

// Util.java
package com.jdojo.generics.exercises;

import java.lang.reflect.Array;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class Util {
    public static void main(String[] args) {
        Integer[] n1 = {1, 2};
        Integer[] n2 = {3, 4};
        Integer[] m = merge(n1, n2);
        System.out.println(Arrays.toString(m));
        String[] s1 = {"one", "two"};
        String[] s2 = {"three", "four"};
        String[] t = merge(s1, s2);
        System.out.println(Arrays.toString(t));
        List<Number> list = new ArrayList<>();
        add(list, 10, 20, 30L, 40.5F, 50.9);
        System.out.println(list);
    }

    public static <T> T[] merge(T[] a, T[] b) {
    }
    public static /* Add type parameters here */ void
    add(List<T> list, U... elems) {
        /* Your code to add elems to list goes here */
    }
}

完成merge()方法的主体,这样它可以连接作为参数传入的两个数组,并返回连接的数组。通过指定类型参数并在其主体中添加代码来完成add()方法。该方法的第一个参数是参数化的List<T>,第二个参数是类型为T或其后代的 varargs 参数。也就是说,第二个参数类型是其对象可以添加到List<T>的任何类型。运行Util类应该产生以下输出:

[1, 2, 3, 4]
[one, two, three, four]
[10, 20, 30, 40.5, 50.9]

锻炼 6

创建一个通用的Stack<E>类。它的对象表示一个堆栈,可以存储其类型参数E的元素。以下是该类的模板。您需要为它的所有方法提供实现。编写测试代码来测试所有方法。方法名是堆栈的标准方法名。任何对堆栈的非法访问都会引发运行时异常。

// Stack.java
package com.jdojo.generics.exercises;
import java.util.LinkedList;
import java.util.List;
public class Stack<E> {
    // Use LinkedList instead of ArrayList
    private final List<E> stack = new LinkedList<>();
    public void push(E e) {}
    public E pop() { }
    public E peek() { }
    public boolean isEmpty() { }
    public int size() { }
}

锻炼 7

什么是堆污染?当编译器检测到堆污染的可能性时,它会生成什么类型的警告?如何在编译期间打印这样的警告?你如何抑制这种警告?

运动 8

描述下面的Test类声明不能编译的原因:

public class Test {
   public <T> void test(T t) {
      // More code goes here
   }
   public <U> void test(U u) {
      // More code goes here
   }
}public class Test {
   public <T> void test(T t) {
      // More code goes here
   }
   public <U> void test(U u) {
      // More code goes here
   }
}

四、Lambda 表达式

在本章中,您将学习:

  • 什么是 lambda 表达式

  • 为什么我们需要 lambda 表达式

  • 定义 lambda 表达式的语法

  • lambda 表达式的目标类型

  • 常用的内置功能接口

  • 方法和构造函数引用

  • lambda 表达式的词法范围

本章中的所有示例程序都是清单 4-1 中声明的jdojo.lambda模块的成员。

// module-info.java
module jdojo.lambda {
    exports com.jdojo.lambda;
}

Listing 4-1The Declaration of a jdojo.lambda Module

什么是 Lambda 表达式?

lambda 表达式是一个未命名的代码块(或未命名的函数),带有一个形参列表和一个主体。有时,一个 lambda 表达式被简称为 lambda。lambda 表达式的主体可以是块语句或表达式。箭头(->)用于分隔参数列表和主体。术语“lambda”源于 Lambda calculus,它使用希腊字母 lambda ( lambda)来表示函数抽象。以下是 Java 中 lambda 表达式的一些示例:

// Takes an int parameter and returns the parameter value
// incremented by 1
(int x) -> x + 1
// Takes two int parameters and returns their sum
(int x, int y) -> x + y
// Takes two int parameters and returns the maximum of
// the two
(int x, int y) -> { int max = x > y ? x : y;
                    return max;
                  }
// Takes no parameters and returns void
() -> { }
// Takes no parameters and returns a string "OK"
() -> "OK"
// Takes a String parameter and prints it on the standard
// output
(String msg) -> { System.out.println(msg); }
// Takes a parameter and prints it on the standard output
msg -> System.out.println(msg)
// Takes a String parameter and returns its length
(String str) -> str.length()

此时,您将无法完全理解 lambda 表达式的语法。我很快会详细介绍语法。现在,只要有感觉就行了,记住 lambda 表达式的语法类似于声明方法的语法。

Note

lambda 表达式不是方法,尽管它的声明看起来像方法。顾名思义,lambda 表达式是表示函数接口实例的表达式。

Java 中的每个表达式都有一个类型,lambda 表达式也是如此。lambda 表达式的类型是函数接口类型。当函数接口的抽象方法被调用时,lambda 表达式的主体被执行。考虑采用String参数并返回其长度的 lambda 表达式:

(String str) -> str.length()

这个 lambda 表达式的类型是什么?答案是我们不知道。通过查看 lambda 表达式,你只能说它接受一个String参数并返回一个int,这是String参数的长度。它的类型可以是任何带有抽象方法的函数接口类型,该方法将一个String作为参数并返回一个int。以下是这种功能界面的一个示例:

@FunctionalInterface
interface StringToIntMapper {
    int map(String str);
}

lambda 表达式表示出现在赋值语句中的StringToIntMapper函数接口的一个实例,如下所示:

StringToIntMapper mapper =
    (String str) -> str.length();

在这个语句中,编译器发现赋值操作符的右边是一个 lambda 表达式。为了推断它的类型,它查看赋值操作符的左侧,该操作符需要一个StringToIntMapper接口的实例;它验证 lambda 表达式符合StringToIntMapper接口中map()方法的声明;最后,它推断 lambda 表达式的类型是StringToIntMapper接口类型。当您调用映射器变量上的map()方法并传递一个String时,lambda 表达式的主体被执行,如下面的代码片段所示:

StringToIntMapper mapper = (String str) -> str.length();
String name = "Kristy";
int mappedValue = mapper.map(name);
System.out.println("name=" + name +
    ", mapped value=" + mappedValue);

name=Kristy, mapped value=6

到目前为止,您还没有看到任何不使用 lambda 表达式就无法在 Java 中完成的事情。以下代码片段使用匿名类来实现与上一示例中使用的 lambda 表达式相同的结果:

StringToIntMapper mapper = new StringToIntMapper() {
    @Override
    public int map(String str) {
        return str.length();
    }
};
String name = "Kristy";
int mappedValue = mapper.map(name);
System.out.println("name=" + name +
    ", mapped value=" + mappedValue);

name=Kristy, mapped value=6

此时,lambda 表达式似乎是编写匿名类的一种简洁方式,就语法而言确实如此。两者在语义上有一些微妙的区别。我将在稍后讨论更多细节时讨论这些差异。

Note

Java 是一种强类型语言,这意味着编译器必须知道 Java 程序中使用的所有表达式的类型。lambda 表达式本身没有类型,因此不能用作独立的表达式。lambda 表达式的类型总是由编译器根据使用它的上下文来推断。

为什么我们需要 Lambda 表达式?

Java 从一开始就支持面向对象编程。在面向对象编程中,程序逻辑是基于可变对象的。类的方法包含逻辑。在对象上调用方法,这通常会修改对象的状态。在面向对象编程中,方法调用的顺序很重要,因为每个方法调用都可能修改对象的状态,从而产生副作用。程序逻辑的静态分析是困难的,因为程序状态取决于代码执行的顺序。用变化的对象编程也对并发编程提出了挑战,在并发编程中,程序的多个部分可能试图同时修改同一对象的状态。

随着近年来计算机处理能力的增加,要处理的数据量也增加了。如今,处理兆兆字节大小的数据是很常见的,这就需要并行编程。现在,电脑普遍拥有多核处理器,让用户有机会更快地运行软件程序;与此同时,这给程序员提出了一个挑战,即利用处理器中所有可用的内核来编写更多的并行程序。Java 从一开始就支持并发编程。它通过 fork/join 框架增加了对 Java 7 中并行编程的支持,这个框架不太好用。

基于 Lambda 演算的函数式编程早在面向对象编程之前就存在了。它基于函数的概念,函数是接受值(称为参数)的代码块,执行代码块来计算结果。功能代表一种功能或操作。函数不修改数据,包括它的输入,因此不会产生副作用;因此,函数的执行顺序在函数式编程中并不重要。在函数式编程中,高阶函数是一个匿名函数,可以被视为数据对象。也就是说,它可以存储在变量中,并从一个上下文传递到另一个上下文。它可能在不一定定义它的上下文中被调用。注意,高阶函数是匿名函数,所以调用上下文不需要知道它的名字。闭包是与其定义环境打包在一起的高阶函数。定义闭包时,它会携带作用域中的变量,即使在定义这些变量的上下文之外的上下文中调用闭包,它也可以访问这些变量。

近年来,函数式编程因其在并发、并行和事件驱动编程中的适用性而变得流行起来。C#、Groovy、Python、Scala 等现代编程语言都支持函数式编程。Java 不想落后,因此,它引入了 lambda 表达式来支持函数式编程,这可以与它已经流行的面向对象特性相结合来开发健壮的、并发的、并行的程序。Java 采用的 lambda 表达式的语法与其他编程语言中使用的语法非常相似,比如 C# 和 Scala。

在面向对象编程中,函数被称为方法,它总是类的一部分。如果您想在 Java 中传递功能,您需要创建一个类,向该类添加一个方法来表示功能,创建该类的一个对象,并传递该对象。Java 中的 lambda 表达式就像函数式编程中的高阶函数,它是一个未命名的代码块,表示可以像数据一样传递的功能。lambda 表达式可以捕获其定义范围内的变量,并且它可以稍后在没有定义所捕获变量的上下文中访问这些变量。这些特性允许您使用 lambda 表达式在 Java 中实现闭包。

那么我们为什么需要 lambda 表达式,在哪里需要呢?匿名类使用庞大的语法。Lambda 表达式使用非常简洁的语法来实现相同的结果。Lambda 表达式不能完全替代匿名类。在一些情况下,您仍然需要使用匿名类。为了理解 lambda 表达式的简洁性,请比较前一节中创建了一个StringToIntMapper接口实例的以下两条语句;一个使用匿名类,有 6 行代码,另一个使用 lambda 表达式,只有一行代码:

// Using an anonymous class
StringToIntMapper mapper = new StringToIntMapper() {
    @Override
    public int map(String str) {
        return str.length();
    }
};
// Using a lambda expression
StringToIntMapper mapper = (String str) -> str.length();

Lambda 表达式的语法

lambda 表达式描述了一个匿名函数。使用 lambda 表达式的一般语法与声明方法非常相似。一般语法是

(<LambdaParametersList>) -> { <LambdaBody> }

lambda 表达式由一个参数列表和一个由箭头(->))分隔的主体组成。参数列表的声明方式与方法的参数列表相同。参数列表用括号括起来,方法也是如此。lambda 表达式的主体是用大括号括起来的代码块。像方法的主体一样,lambda 表达式的主体可以声明局部变量;使用语句包括breakcontinuereturn;抛出异常;等等。与方法不同,lambda 表达式没有以下四个部分:

  • lambda 表达式没有名称。

  • lambda 表达式没有返回类型。它由编译器从它的使用上下文和它的主体中推断出来。

  • lambda 表达式没有throws子句。从它的使用上下文和它的主体来推断。

  • lambda 表达式不能声明类型参数。也就是说,lambda 表达式不能是泛型的。

表 4-1 包含一些 lambda 表达式和等效方法的例子。我给方法取了一个合适的名字,因为在 Java 中,方法不能没有名字。编译器推断 lambda 表达式的返回类型。

表 4-1

Lambda 表达式和等效方法的示例

|

Lambda 表达式

|

等效方法

|
| --- | --- |
| (int x, int y) -> {``return x + y;``} | int sum(int x, int y) {``return x + y;``} |
| (Object x) -> {``return x;``} | Object identity(Object x)``return x;``} |
| (int x, int y) -> {``if (x > y)``return x;``} else {``return y;``}``} | int getMax(int x, int y) {``if (x > y)``return x;``} else {``return y;``}``} |
| (String msg) -> {``System.out.println(msg);``} | void print(String msg) {``System.out.println(msg);``} |
| () -> {``System.out.println(LocalDate.now());``} | void printCurrentDate() {``System.out.println(LocalDate.now());``} |
| () -> {``// No code goes here``} | void doNothing() {``// No code goes here``} |

lambda 表达式的目标之一是保持其语法简洁,并让编译器推断细节。下面几节讨论声明 lambda 表达式的简写语法。

省略参数类型

您可以省略参数的声明类型。编译器将从使用 lambda 表达式的上下文中推断参数的类型:

// Types of parameters are declared
(int x, int y) -> { return x + y; }
// Types of parameters are omitted
(x, y) -> { return x + y; }

如果省略参数类型,则必须为所有参数省略或不省略。你不能忽略一些人而忽略另一些人。以下 lambda 表达式将不会编译,因为它声明了一个参数的类型,而省略了另一个参数的类型:

// A compile-time error
(int x, y) -> { return x + y; }

Note

不声明其参数类型的 lambda 表达式称为隐式 lambda 表达式或隐式类型化 lambda 表达式。声明其参数类型的 lambda 表达式称为显式 lambda 表达式或显式类型化 lambda 表达式。

对参数使用局部变量语法

您可以对 lambda 表达式中的参数使用局部变量语法:

// A compile-time error
(var x, var y) -> { return x + y; }

编译器将从使用 lambda 表达式的上下文中推断参数的类型,并记住每个变量的类型。在 JDK11 中,lambda 表达式参数的局部变量语法被添加到 Java 中。

声明单个参数

有时,lambda 表达式只接受一个参数。可以省略单参数 lambda 表达式的参数类型,就像可以省略具有多个参数的 lambda 表达式一样。如果在单参数 lambda 表达式中省略了参数类型,也可以省略括号。以下是用单个参数声明 lambda 表达式的三种方法:

// Declares the parameter type
(String msg) -> { System.out.println(msg); }
// Omits the parameter type
(msg) -> { System.out.println(msg); }
// Omits the parameter type and parentheses
msg -> { System.out.println(msg); }

只有当单个参数也省略其类型时,才能省略括号。以下 lambda 表达式将不会编译:

// Omits parentheses, but not the parameter type, which is not allowed.
String msg -> { System.out.println(msg); }

未声明任何参数

如果 lambda 表达式不带任何参数,则需要使用空括号:

// Takes no parameters
() -> { System.out.println("Hello"); }

当 lambda 表达式不带参数时,不允许省略括号。以下声明不会编译:

-> { System.out.println("Hello"); }

带修饰符的参数

您可以在显式 lambda 表达式的参数声明中使用修饰符,如final。以下两个 lambda 表达式是有效的:

(final int x, final int y) -> { return x + y; }
(int x, final int y) -> { return x + y; }

以下 lambda 表达式将不会编译,因为它在参数声明中使用了 final 修饰符,但省略了参数类型:

(final x, final y) -> { return x + y; }

声明 Lambda 表达式的主体

lambda 表达式的主体可以是块语句或单个表达式。block 语句用大括号括起来;单个表达式没有用大括号括起来。

lambda 表达式的主体以与方法主体相同的方式执行。一个return语句或主体的结尾将控制返回给 lambda 表达式的调用者。

当一个表达式被用作主体时,它被求值并返回给调用者。如果表达式的计算结果为void,则不会向调用者返回任何内容。以下两个 lambda 表达式是相同的;一个使用块语句,另一个使用表达式:

/ Uses a block statement. Takes two int parameters and
// returns their sum.
(int x, int y) -> { return x + y; }
// Uses an expression. Takes two int parameters and
// returns their sum.
(int x, int y) -> x + y

以下两个 lambda 表达式是相同的;一个使用 block 语句作为主体,另一个使用计算结果为void的表达式:

// Uses a block statement
(String msg) -> { System.out.println(msg); }
// Uses an expression
(String msg) -> System.out.println(msg)

目标分类

每个 lambda 表达式都有一个类型,这是一个函数接口类型。换句话说,lambda 表达式代表一个函数接口的实例。考虑以下 lambda 表达式:

(x, y) -> x + y

这个 lambda 表达式的类型是什么?换句话说,这个 lambda 表达式代表哪个函数接口的实例?此时,我们不知道这个 lambda 表达式的类型。关于这个 lambda 表达式,我们所能自信地说的是,它有两个名为xy的参数。我们无法判断它的返回类型,因为表达式x + y根据xy的类型,可能会计算出一个数字(intlongfloatdouble)或一个String。这是一个隐式 lambda 表达式,因此,编译器必须使用使用该表达式的上下文来推断两个参数的类型。这个 lambda 表达式可以是不同的函数接口类型,这取决于使用它的上下文。

Java 中有两种类型的表达式:

  • 独立表达式

  • 多边形表达式

独立表达式是一种无需知道其使用上下文即可确定其类型的表达式。以下是独立表达式的示例:

// The type of expression is String
new String("Hello")
// The type of expression is String (a String literal
// is also an expression)
"Hello"
// The type of expression is ArrayList<String>
new ArrayList<String>()

聚合表达式是在不同上下文中具有不同类型的表达式。编译器确定类型。允许使用多边形表达式的上下文称为多边形上下文。Java 中所有的 lambda 表达式都是 poly 表达式。您必须在上下文中使用它才能知道它的类型。例如,表达式new ArrayList<>()是一个多边形表达式。除非提供其使用的上下文,否则无法判断其类型。此表达式在以下两种上下文中用于表示两种不同的类型:

// The type of new ArrayList<>() is ArrayList<Long>
ArrayList<Long> idList = new ArrayList<>();
// The type of new ArrayList<>() is ArrayList<String>
ArrayList<String> nameList = new ArrayList<>();

编译器推断 lambda 表达式的类型。使用 lambda 表达式的上下文需要一个称为目标类型的类型。从上下文推断 lambda 表达式类型的过程称为目标类型化。考虑以下赋值语句的伪代码,其中类型为T的变量被赋予一个 lambda 表达式:

T t = <LambdaExpression>;

这个上下文中 lambda 表达式的目标类型是T。编译器使用以下规则来确定<LambdaExpression>是否与其目标类型T的赋值兼容:

  • T必须是功能接口类型。

  • lambda 表达式的参数数量和类型与T的抽象方法相同。对于隐式 lambda 表达式,编译器会从T的抽象方法中推断出参数的类型。

  • lambda 表达式主体返回值的类型与抽象方法T的返回类型赋值兼容。

  • 如果 lambda 表达式的主体抛出任何检查过的异常,这些异常必须与抽象方法T的声明 throws 子句兼容。如果 lambda 表达式的目标类型的方法不包含throws子句,则从 lambda 表达式的主体中抛出检查过的异常是一个编译时错误。

让我们看几个目标类型的例子。考虑两个功能接口AdderJoiner,分别如清单 4-2 和 4-3 所示。

// Joiner.java
package com.jdojo.lambda;
@FunctionalInterface
public interface Joiner {
    String join(String s1, String s2);
}

Listing 4-3A Functional Interface Named Joiner

// Adder.java
package com.jdojo.lambda;
@FunctionalInterface
public interface Adder {
    double add(double n1, double n2);
}

Listing 4-2A Functional Interface Named Adder

Adder接口的add()方法将两个数相加。Joiner接口的join()方法连接两个字符串。这两个接口都用于琐碎的目的;然而,它们将很好地用于演示 lambda 表达式的目标类型。考虑下面的赋值语句:

Adder adder = (x, y) -> x + y;

加法器变量的类型是Adder。lambda 表达式被赋值给变量adder,因此,lambda 表达式的目标类型是Adder。编译器验证Adder是一个函数接口。lambda 表达式是隐式 lambda 表达式。编译器发现Adder接口包含一个double add(double, double)抽象方法。它将xy参数的类型分别推断为doubledouble。此时,编译器将该语句视为如下所示:

Adder adder = (double x, double y) -> x + y;

如果你写

Adder adder = (var x, var y) -> x + y;

编译器将再次从上下文中知道xydouble s。因此我们再次拥有一个隐式的 lambda 表达式。与完全省略类型相比,var name语法更好地表达了为 lambda 表达式创建局部变量,尽管我们对实际声明类型并不感兴趣。

编译器现在验证 lambda 表达式返回值和add()方法返回类型的兼容性。add()方法的返回类型是double。lambda 表达式返回x + y,这将是一个double,因为编译器已经知道xy的类型是double。lambda 表达式不抛出任何检查过的异常。因此,编译器不需要为此进行任何验证。此时,编译器推断 lambda 表达式的类型是类型Adder

对以下赋值语句应用目标类型规则:

Joiner joiner = (x, y) -> x + y;

这一次,编译器推断 lambda 表达式的类型为Joiner。你是否看到了一个聚合表达式的例子,其中同一个 lambda 表达式(x, y) -> x + y在一个上下文中属于类型Adder,而在另一个上下文中属于类型Joiner

清单 4-4 展示了如何在程序中使用这些 lambda 表达式。

// TargetTypeTest.java
package com.jdojo.lambda;
public class TargetTypeTest {
    public static void main(String[] args)  {
        // Creates an Adder using a lambda expression
        Adder adder = (x, y) -> x + y;
        // Creates a Joiner using a lambda expression
        Joiner joiner = (x, y) -> x + y;
        // Adds two doubles
        double sum1 = adder.add(10.34, 89.11);
        // Adds two ints
        double sum2 = adder.add(10, 89);
        // Joins two strings
        String str = joiner.join("Hello", " lambda");
        System.out.println("sum1 = " + sum1);
        System.out.println("sum2 = " + sum2);
        System.out.println("str = " + str);
    }
}

sum1 = 99.45
sum2 = 99.0
str = Hello lambda

Listing 4-4Examples of Using Lambda Expressions

我现在在方法调用的上下文中讨论目标类型。您可以将 lambda 表达式作为参数传递给方法。考虑清单 4-5 中显示的LambdaUtil类的代码。

// LambdaUtil.java
package com.jdojo.lambda;
public class LambdaUtil {
    public void testAdder(Adder adder) {
        double x = 190.90;
        double y = 8.50;
        double sum = adder.add(x, y);
        System.out.print("Using an Adder:");
        System.out.println(x + " + " + y + " = " + sum);
    }
    public void testJoiner(Joiner joiner) {
        String s1 = "Hello";
        String s2 = "World";
        String s3 = joiner.join(s1,s2);
        System.out.print("Using a Joiner:");
        System.out.println("\"" + s1 + "\" + \"" + s2 +
            "\" = \"" + s3 + "\"");
    }
}

Listing 4-5A LambdaUtil Class That Uses Functional Interfaces As an Argument in Methods

LambdaUtil类包含两个方法:testAdder()testJoiner()。一个方法将一个Adder作为参数,另一个方法将一个Joiner作为参数。这两种方法都有简单的实现。考虑以下代码片段:

LambdaUtil util = new LambdaUtil();
util.testAdder((x, y) -> x + y);

第一条语句创建了一个LambdaUtil类的对象。第二条语句调用对象上的testAdder()方法,传递一个(x, y) -> x + y的 lambda 表达式。编译器必须推断 lambda 表达式的类型。lambda 表达式的目标类型是类型Adder,因为testAdder(Adder adder)的参数类型是Adder。目标键入过程的其余部分与您在前面的赋值语句中看到的一样。最后,编译器推断 lambda 表达式的类型是Adder

清单 4-6 中的程序创建了一个LambdaUtil类的对象,并调用了testAdder()testJoiner()方法。

// LambdaUtilTest.java
package com.jdojo.lambda;
public class LambdaUtilTest {
    public static void main(String[] args)  {
        LambdaUtil util = new LambdaUtil();
        // Call the testAdder() method
        util.testAdder((x, y) -> x + y);
        // Call the testJoiner() method
        util.testJoiner((x, y) -> x + y);
        // Call the testJoiner() method. The Joiner will
        // add a space between the two strings
        util.testJoiner((x, y) -> x + " " + y);
        // Call the testJoiner() method. The Joiner will
        // reverse the strings and join resulting
        // strings in reverse order adding a comma in
        //between
        util.testJoiner((x, y) -> {
            StringBuilder sbx = new StringBuilder(x);
            StringBuilder sby = new StringBuilder(y);
            sby.reverse().append(",").
                append(sbx.reverse());
            return sby.toString();
        });
    }
}

Using an Adder:190.9 + 8.5 = 199.4
Using a Joiner:"Hello" + "World" = "HelloWorld"
Using a Joiner:"Hello" + "World" = "Hello World"
Using a Joiner:"Hello" + "World" = "dlroW,olleH"

Listing 4-6Using Lambda Expressions As Method Arguments

注意LambdaUtilTest类的输出。testJoiner()方法被调用了三次,每次都打印出不同的连接两个字符串“Hello”和“World”的结果。这是可能的,因为不同的 lambda 表达式被传递给了该方法。此时,你可以说你已经参数化了testJoiner()方法的行为。也就是说,testJoiner()方法的行为取决于它的参数。通过方法的参数来改变方法的行为被称为行为参数化。这也被称为将代码作为数据传递,因为您将封装在 lambda 表达式中的代码(逻辑、功能或行为)传递给方法,就好像它是数据一样。

编译器并不总是能够推断出 lambda 表达式的类型。在某些情况下,编译器无法推断 lambda 表达式的类型;这些上下文不允许使用 lambda 表达式。一些上下文可能允许使用 lambda 表达式,但是使用本身对于编译器来说可能是不明确的;其中一种情况是将 lambda 表达式传递给重载方法。

考虑清单 4-7 中显示的LambdaUtil2类的代码。这个类的代码与清单 4-5 中的LambdaUtil类的代码相同,除了这个类将两个方法的名字改成了同一个名字test(),使其成为一个重载方法。

// LambdaUtil2.java
package com.jdojo.lambda;
public class LambdaUtil2 {
    public void test(Adder adder) {
        double x = 190.90;
        double y = 8.50;
        double sum = adder.add(x, y);
        System.out.print("Using an Adder:");
        System.out.println(x + " + " + y + " = " + sum);
    }
    public void test(Joiner joiner) {
        String s1 = "Hello";
        String s2 = "World";
        String s3 = joiner.join(s1,s2);
        System.out.print("Using a Joiner:");
        System.out.println("\"" + s1 + "\" + \"" + s2 +
            "\" = \"" + s3 + "\"");
    }
}

Listing 4-7A LambdaUtil2 Class That Uses Functional Interfaces As an Argument in Methods

考虑以下代码片段:

LambdaUtil2 util = new LambdaUtil2();
util.test((x, y) -> x + y); // A compile-time error

第二条语句导致以下编译时错误:

Reference to test is ambiguous. Both method test(Adder) in
LambdaUtil2 and method test(Joiner) in LambdaUtil2 match.

test()方法的调用失败,因为 lambda 表达式是隐式的,并且它匹配两个版本的test()方法。编译器不知道使用哪种方法:test(Adder adder)还是test(Joiner joiner)。在这种情况下,您需要通过提供更多信息来帮助编译器。以下是帮助编译器解决歧义的一些方法:

  • 如果 lambda 表达式是隐式的,则通过指定参数的类型使其显式。

  • 使用石膏。

  • 不要直接使用 lambda 表达式作为方法参数。首先,将它赋给所需类型的变量,然后将变量传递给方法。

让我们讨论解决编译时错误的所有三种方法。以下代码片段将 lambda 表达式更改为显式 lambda 表达式:

LambdaUtil2 util = new LambdaUtil2();
util.test((double x, double y) -> x + y);
// <- OK. Will call test(Adder adder)

在 lambda 表达式中指定参数类型解决了这个问题。编译器有两个候选方法:test(Adder adder)test(Joiner joiner)。有了(double x, double y)参数信息,只有test(Adder adder)方法匹配。

以下代码片段使用强制转换将 lambda 表达式转换为类型Adder:

LambdaUtil2 util = new LambdaUtil2();
util.test((Adder)(x, y) -> x + y);
// <- OK. Will call test(Adder adder)

使用强制转换告诉编译器 lambda 表达式的类型是Adder,从而帮助它选择test(Adder adder)方法。

考虑下面的代码片段,它将方法调用分解为两条语句:

LambdaUtil2 util = new LambdaUtil2();
Adder adder = (x, y) -> x + y;
util.test(adder);
// <- OK. Will call test(Adder adder)

lambda 表达式被分配给一个类型为Adder的变量,该变量被传递给test()方法。同样,它帮助编译器根据adder变量的编译时类型选择test(Adder adder)方法。

清单 4-8 中的程序类似于清单 4-6 中的程序,除了它使用了LambdaUtil2类。它使用显式 lambda 表达式和强制转换来解决 lambda 表达式的不明确匹配。

// LambdaUtil2Test.java
package com.jdojo.lambda;
public class LambdaUtil2Test {
    public static void main(String[] args) {
        LambdaUtil2 util = new LambdaUtil2();
        // Calls the testAdder() method
        util.test((double x, double y) -> x + y);
        // Calls the testJoiner() method
        util.test((String x, String y) -> x + y);
        // Calls the testJoiner() method. The Joiner will
        // add a space between the two strings
        util.test((Joiner) (x, y) -> x + " " + y);
        // Calls the testJoiner() method. The Joiner will
        // reverse the strings and join resulting strings
        // in reverse order adding a comma in between
        util.test((Joiner) (x, y) -> {
            StringBuilder sbx = new StringBuilder(x);
            StringBuilder sby = new StringBuilder(y);
            sby.reverse().append(",").
                append(sbx.reverse());
            return sby.toString();
        });
    }
}

Using an Adder:190.9 + 8.5 = 199.4
Using a Joiner:"Hello" + "World" = "HelloWorld"
Using a Joiner:"Hello" + "World" = "Hello World"
Using a Joiner:"Hello" + "World" = "dlroW,olleH"

Listing 4-8Resolving Ambiguity During Target Typing

Lambda 表达式只能在以下上下文中使用:

  • 赋值上下文:lambda 表达式可能出现在赋值语句中赋值操作符的右边。例如:

  • 方法调用上下文:lambda 表达式可能作为方法或构造函数调用的参数出现。例如:

ReferenceType variable1 = LambdaExpression;

  • 返回上下文:lambda 表达式可能出现在方法内部的return语句中,因为它的目标类型是该方法声明的返回类型。例如:
util.testJoiner(LambdaExpression);

  • 强制转换上下文:如果 lambda 表达式前面有强制转换,则可以使用该表达式。强制转换中指定的类型是其目标类型。例如:
return LambdaExpression;

(Joiner) LambdaExpression;

功能界面

函数接口就是一个只有一个抽象方法的接口。接口中的以下类型的方法不包括在定义函数接口中:

  • 默认方法

  • 静态方法

  • Object类继承的公共方法

注意,一个接口可能有不止一个抽象方法,如果除了其中一个之外的所有方法都是对Object类中方法的重新声明,那么它仍然可以是一个函数接口。考虑在java.util包中的Comparator类的声明,如下所示:

package java.util;
@FunctionalInterface
public interface Comparator<T> {
    // An abstract method declared in the interface
    int compare(T o1, T o2);
    // Re-declaration of the equals() method in the
    // Object class
    boolean equals(Object obj);
    // Many more static and default methods that are
    // not shown here.
}

Comparator接口包含两个抽象方法:compare()equals()。在Comparator接口中的equals()方法是对Object类的equals()方法的重新声明,因此它不违背作为一个函数接口的抽象方法需求。Comparator接口包含几个默认的静态方法,这里没有显示。

lambda 表达式用于表示函数式编程中使用的未命名函数。一个函数接口用它唯一的抽象方法来表示一种类型的功能/操作。这种共性是 lambda 表达式的目标类型总是函数接口的原因。

使用@FunctionalInterface注释

一个函数接口的声明可以选择用注释@FunctionalInterface进行注释,它在java.lang包中。至此,本章声明的所有功能接口,如AdderJoiner,均已标注@FunctionalInterface。这个注释的存在告诉编译器要确保声明的类型是一个函数接口。如果注释@FunctionalInterface用在非功能接口或其他类型(如类)上,就会出现编译时错误。如果你没有在一个具有抽象方法的接口上使用注释@FunctionalInterface,这个接口仍然是一个函数接口,并且它可以是 lambda 表达式的目标类型。使用这个注释可以从编译器那里获得额外的保证。注释的存在还可以防止您不小心将一个函数接口变成了一个非函数接口,因为编译器会发现它。

下面对一个Operations接口的声明将不会被编译,因为接口声明使用了@FunctionalInterface注释,并且它不是一个函数接口(定义了两个抽象方法):

@FunctionalInterface
public interface Operations {
    double add(double n1, double n2);
    double mult(double n1, double n2);
}

要编译Operations接口,要么移除两个抽象方法中的一个,要么移除@FunctionalInterface注释。下面对一个Test类的声明将不会被编译,因为@FunctionalInterface不能用在除了函数接口之外的类型上:

@FunctionalInterface
public class Test {
    // Code goes here
}

通用功能接口

函数接口可以有类型参数。也就是说,功能接口可以是通用的。通用函数参数的一个例子是带有一个类型参数TComparator接口:

@FunctionalInterface
public interface Comparator<T> {
    int compare(T o1, T o2);
}

一个函数接口可以有一个通用的抽象方法。也就是说,抽象方法可以声明类型参数。下面是一个名为Processor的非泛型函数接口的例子,它的抽象方法process()是泛型的:

@FunctionalInterface
public interface Processor {
    <T> void process(T[] list);
}

lambda 表达式不能声明类型参数,因此,它不能有抽象方法为泛型的目标类型。例如,您不能使用 lambda 表达式来表示Processor接口。在这种情况下,您需要使用方法引用(我将在下一节讨论)或匿名类。

让我们看一个通用函数接口的简短例子,并用 lambda 表达式实例化它。清单 4-9 显示了名为Mapper的功能接口的代码。

// Mapper.java
package com.jdojo.lambda;
@FunctionalInterface
public interface Mapper<T> {
    // An abstract method
    int map(T source);
    // A generic static method
    public static <U> int[] mapToInt(U[] list,
            Mapper<? super U> mapper) {
        int[] mappedValues = new int[list.length];
        for (int i = 0; i < list.length; i++) {
            // Map the object to an int
            mappedValues[i] = mapper.map(list[i]);
        }
        return mappedValues;
    }
}

Listing 4-9A Mapper Functional Interface

Mapper是带有类型参数T的通用函数接口。它的抽象方法map()将类型为T的对象作为参数,并返回一个intmapToInt()方法是一个通用的静态方法,它接受一个类型为U的数组和一个类型为U本身或U的超类型的Mapper。该方法返回一个int数组,其元素包含作为数组传递的对应元素的映射值。

清单 4-10 中的程序展示了如何使用 lambda 表达式实例化Mapper<T>接口。该程序将一个String数组和一个Integer数组映射到int数组。

// MapperTest.java
package com.jdojo.lambda;
public class MapperTest {
    public static void main(String[] args) {
        // Map names using their length
        System.out.println(
            "Mapping names to their lengths:");
        String[] names = {"David", "Li", "Doug"};
        int[] lengthMapping = Mapper.mapToInt(names,
            (String name) -> name.length());
        printMapping(names, lengthMapping);
        System.out.println("\nMapping integers to " +
            "their squares:");
        Integer[] numbers = {7, 3, 67};
        int[] countMapping = Mapper.mapToInt(numbers,
            (Integer n) -> n * n);
        printMapping(numbers, countMapping);
    }
    public static void printMapping(Object[] from,
            int[] to) {
        for (int i = 0; i < from.length; i++) {
            System.out.println(from[i] + " mapped to " +
                to[i]);
        }
    }
}

Mapping names to their lengths:
David mapped to 5
Li mapped to 2
Doug mapped to 4
Mapping integers to their squares:
7 mapped to 49
3 mapped to 9
67 mapped to 4489

Listing 4-10Using the Mapper Functional Interface

交集类型和 Lambda 表达式

可以声明一个交集类型,它是多个类型的交集(或子类型)(从 Java 8 开始)。在强制转换中,交集类型可能作为目标类型出现。在两种类型之间使用了一个与号(&),比如(Type1 & Type2 & Type3),它代表的是一个新类型,是Type1Type2Type3的交集。考虑一个名为Sensitive的标记接口,如清单 4-11 所示。

// Sensitive.java
package com.jdojo.lambda;
public interface Sensitive {
    // It is a marker interface. So, no methods exist.
}

Listing 4-11A Marker Interface Named Sensitive

假设有一个 lambda 表达式分配给了一个Sensitive类型的变量:

Sensitive sen = (x, y) -> x + y;
// <- A compile-time error

这条语句不能编译。lambda 表达式的目标类型必须是函数接口;Sensitive不是功能接口。但是,您应该能够进行这样的赋值,因为标记接口不包含任何方法。在这种情况下,您需要使用交集类型的强制转换来创建一个新的合成类型,该类型是所有类型的子类型。下面的语句将被编译:

Sensitive sen = (Sensitive & Adder) (x, y) -> x + y;
// <- OK

交集类型Sensitive & Adder仍然是一个函数接口,因此,lambda 表达式的目标类型是一个函数接口,具有来自Adder接口的一个方法。

在 Java 中,你可以把一个对象转换成一个字节流,然后再把它还原。这就是所谓的序列化。一个类必须为其要序列化的对象实现java.io.Serializable标记接口。如果你想要一个 lambda 表达式被序列化,你需要使用一个交集类型的转换。以下语句将 lambda 表达式赋给Serializable接口的变量:

Serializable ser = (Serializable & Adder) (x, y) -> x + y;

常用功能界面

java.util.function包包含许多有用的功能接口。它们在表 4-2 中列出。

表 4-2

java.util.function包中声明的功能接口

|

接口名称

|

方法

|

描述

|
| --- | --- | --- |
| Function<T,R> | R apply(T t) | 表示采用类型为T的参数并返回类型为R的结果的函数。 |
| BiFunction<T,U,R> | R apply(T t, U u) | 表示一个函数,它接受两个类型为TU的参数,并返回类型为R的结果。 |
| Predicate<T> | boolean test(T t) | 在数学中,谓词是一个布尔值函数,它接受一个参数并返回truefalse。该函数表示为指定参数返回truefalse的条件。 |
| BiPredicate<T,U> | boolean test(T t, U u) | 表示具有两个参数的谓词。 |
| Consumer<T> | void accept(T t) | 表示一种操作,它接受一个参数,对其进行操作以产生一些副作用,并且不返回任何结果。 |
| BiConsumer<T,U> | void accept(T t, U u) | 表示采用两个参数的操作,对它们进行操作以产生一些副作用,并且不返回任何结果。 |
| Supplier<T> | T get() | 表示返回值的供应商。 |
| UnaryOperator<T> | T apply(T t) | 继承自Function<T,T>。表示接受参数并返回相同类型结果的函数。 |
| BinaryOperator<T> | T apply(T t1, T t2) | 继承自BiFunction<T,T,T>。表示采用两个相同类型的参数并返回相同结果的函数。 |

该表仅显示了功能接口的通用版本。这些接口有几种专门的版本。它们专门用于经常使用的原始数据类型;比如IntConsumer就是Consumer<T>的专门版本。表中的一些接口包含方便的默认和静态方法。该表只列出了抽象方法,没有列出默认方法和静态方法。

使用Function<T,R>界面

Function<T,R>接口有六种专门化:

  • IntFunction<R>

  • LongFunction<R>

  • DoubleFunction<R>

  • ToIntFunction<T>

  • ToLongFunction<T>

  • ToDoubleFunction<T>

IntFunction<R>LongFunction<R>DoubleFunction<R>分别以一个intlongdouble为自变量,返回值类型为RToIntFunction<T>ToLongFunction<T>ToDoubleFunction<T>接受类型为T的参数,并分别返回一个int、一个long和一个double。表中列出的其他类型的通用函数也有类似的专用函数。

Note

您的com.jdojo.lambda.Mapper<T>接口表示与java.util.function包中的ToIntFunction<T>相同的函数类型。您创建了Mapper<T>接口来学习如何创建和使用通用功能接口。从现在开始,先看看内置的功能接口,再创建自己的;如果它们满足你的需求,就使用它们。

以下代码片段显示了如何使用相同的 lambda 表达式来表示接受 int 并返回其 square 的函数,使用了四种不同的Function<T, R>函数类型:

// Takes an int and returns its square
Function<Integer, Integer> square1 = x -> x * x;
IntFunction<Integer> square2 = x -> x * x;
ToIntFunction<Integer> square3 = x -> x * x;
UnaryOperator<Integer> square4 = x -> x * x;
System.out.println(square1.apply(5));
System.out.println(square2.apply(5));
System.out.println(square3.applyAsInt(5));
System.out.println(square4.apply(5));

25
25
25
25

Function接口包含以下默认和静态方法:

  • default <V> Function<T,V> andThen(Function<? super R,? extends V> after)

  • default <V> Function<V,R> compose(Function<? super V,? extends T> before)

  • static <T> Function<T,T> identity()

andThen()方法返回一个复合的Function,它将这个函数应用于参数,然后将指定的 after 函数应用于结果。compose()函数返回一个复合函数,该复合函数将指定的before函数应用于参数,然后将该函数应用于结果。identify()方法返回一个总是返回其参数的函数。

以下代码片段演示了如何使用Function接口的默认和静态方法来组合新函数:

// Create two functions
Function<Long, Long> square = x -> x * x;
Function<Long, Long> addOne = x -> x + 1;
// Compose functions from the two functions
Function<Long, Long> squareAddOne = square.andThen(addOne);
Function<Long, Long> addOneSquare = square.compose(addOne);
// Get an identity function
Function<Long, Long> identity = Function.<Long>identity();
// Test the functions
long num = 5L;
System.out.println("Number: " + num);
System.out.println("Square and then add one: " +
    squareAddOne.apply(num));
System.out.println("Add one and then square: " +
    addOneSquare.apply(num));
System.out.println("Identity: " + identity.apply(num));

Number: 5
Square and then add one: 26
Add one and then square: 36
Identity: 5

您并不局限于构建一个由两个以特定顺序执行的函数组成的函数。一个函数可以由任意多的函数组成。您可以链接 lambda 表达式,在一个表达式中创建一个组合函数。请注意,当您链接 lambda 表达式时,您可能需要向编译器提供提示,以解决可能出现的目标类型歧义。下面是一个通过链接三个函数组成的函数的示例。提供强制转换来帮助编译器。没有强制转换,编译器将无法推断目标类型:

// Square the input, add one to the result, and square
// the result
Function<Long, Long> chainedFunction =
    ((Function<Long, Long>)(x -> x * x))
                        .andThen(x -> x + 1)
                        .andThen(x -> x * x);
System.out.println(chainedFunction.apply(3L));

100

使用Predicate<T>界面

一个谓词表示一个条件,对于给定的输入,它要么是true要么是falsePredicate接口包含以下默认和静态方法,这些方法允许您使用逻辑 NOT、and 和 OR 基于其他谓词来组合一个谓词:

  • default Predicate<T> negate()

  • default Predicate<T> and(Predicate<? super T> other)

  • default Predicate<T> or(Predicate<? super T> other)

  • static <T> Predicate<T> isEqual(Object targetRef)

negate()方法返回一个Predicate,它是原始谓词的逻辑否定。and()方法返回这个谓词和指定谓词的一个短路逻辑 AND 谓词。or()方法返回该谓词和指定谓词的短路逻辑 OR 谓词。isEqual()方法返回一个谓词,该谓词根据Objects.equals(Object o1, Object o2)测试指定的targetRef是否等于谓词的指定参数;如果两个输入是null,这个谓词的计算结果是true。您可以链接对这些方法的调用来创建复杂的谓词。下面的代码片段展示了一些创建和使用谓词的示例:

// Create some predicates
Predicate<Integer> greaterThanTen = x -> x > 10;
Predicate<Integer> divisibleByThree = x -> x % 3 == 0;
Predicate<Integer> divisibleByFive = x -> x % 5 == 0;
Predicate<Integer> equalToTen = Predicate.isEqual(null);
// Create predicates using NOT, AND, and OR on other
// predicates
Predicate<Integer> lessThanOrEqualToTen =
    greaterThanTen.negate();
Predicate<Integer> divisibleByThreeAndFive =
    divisibleByThree.and(divisibleByFive);
Predicate<Integer> divisibleByThreeOrFive =
    divisibleByThree.or(divisibleByFive);
// Test the predicates
int num = 10;
System.out.println("Number: " + num);
System.out.println("greaterThanTen: " +
    greaterThanTen.test(num));
System.out.println("divisibleByThree: " +
    divisibleByThree.test(num));
System.out.println("divisibleByFive: " +
    divisibleByFive.test(num));
System.out.println("lessThanOrEqualToTen: " +
    lessThanOrEqualToTen.test(num));
System.out.println("divisibleByThreeAndFive: " +
    divisibleByThreeAndFive.test(num));
System.out.println("divisibleByThreeOrFive: " +
    divisibleByThreeOrFive.test(num));
System.out.println("equalsToTen: " +
    equalToTen.test(num));

Number: 10
greaterThanTen: false
divisibleByThree: false
divisibleByFive: true
lessThanOrEqualToTen: true
divisibleByThreeAndFive: false
divisibleByThreeOrFive: true
equalsToTen: false

使用功能接口

两种不同类型的用户在两种环境中使用功能界面:

  • 由库设计者设计 API

  • 由库用户使用 API

功能接口被库设计者用来设计 API。它们用于在方法声明中声明参数的类型和返回类型。它们的使用方式与非功能性接口的使用方式相同(功能性接口从一开始就存在于 Java 中)。

库用户使用函数接口作为 lambda 表达式的目标类型。也就是说,当 API 中的方法将函数接口作为参数时,API 的用户应该使用 lambda 表达式来传递参数。使用 lambda 表达式的好处是使代码简洁,可读性更强。

在这一节中,我将向您展示如何使用函数接口设计 API,以及如何使用 lambda 表达式来使用 API。在为集合和流 API 设计 Java 库时,大量使用了函数接口。

在后面的例子中,我使用了一个枚举和两个类。清单 4-12 中显示的Gender枚举包含两个常量来表示一个人的性别。清单 4-13 中所示的Person类表示一个人;除了其他方法之外,它还包含一个返回人员列表的getPersons()方法。

// Person.java
package com.jdojo.lambda;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import static com.jdojo.lambda.Gender.MALE;
import static com.jdojo.lambda.Gender.FEMALE;

public class Person {
    private String firstName;
    private String lastName;
    private LocalDate dob;
    private Gender gender;
    public Person(String firstName, String lastName,
          LocalDate dob, Gender gender) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.dob = dob;
        this.gender = gender;
    }
    public String getFirstName() {
        return firstName;
    }
    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }
    public String getLastName() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }
    public LocalDate getDob() {
        return dob;
    }
    public void setDob(LocalDate dob) {

        this.dob = dob;
    }
    public Gender getGender() {
        return gender;
    }
    public void setGender(Gender gender) {
        this.gender = gender;
    }
    @Override
    public String toString() {
        return firstName + " " + lastName + ", " +
            gender + ", " + dob;
    }
    // A convenience method
    public static List<Person> getPersons() {
        ArrayList<Person> list = new ArrayList<>();
        list.add(new Person("John", "Jacobs",
          LocalDate.of(1975, 1, 20), MALE));
        list.add(new Person("Wally", "Inman",
          LocalDate.of(1965, 9, 12), MALE));
        list.add(new Person("Donna", "Jacobs",
          LocalDate.of(1970, 9, 12), FEMALE));
        return list;
    }
}

Listing 4-13A Person Class

// Gender.java
package com.jdojo.lambda;
public enum Gender {
    MALE, FEMALE
}

Listing 4-12A Gender enum

清单 4-14 中的FunctionUtil类是一个实用程序类。它的方法对一个List应用一个函数。List是一个由ArrayList类实现的接口。forEach()方法对列表中的每一项应用一个动作,通常会产生副作用;该动作由一个Consumer表示。filter()方法根据指定的Predicate过滤列表。map()方法使用Function将列表中的每一项映射到一个值。作为库设计者,您将使用函数接口设计这些方法。

// FunctionUtil.java
package com.jdojo.lambda;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
public class FunctionUtil {
    // Applies an action on each item in a list
    public static <T> void forEach(List<T> list,
          Consumer<? super T> action) {
        for (T item : list) {
            action.accept(item);
        }
    }
    // Applies a filter to a list and returns the
    // filtered list items
    public static <T> List<T> filter(List<T> list,
          Predicate<? super T> predicate) {
        List<T> filteredList = new ArrayList<>();
        for (T item : list) {
            if (predicate.test(item)) {
                filteredList.add(item);
            }
        }
        return filteredList;
    }
    // Maps each item in a list to a value
    public static <T, R> List<R> map(List<T> list,
          Function<? super T, R> mapper) {
        List<R> mappedList = new ArrayList<>();
        for (T item : list) {
            mappedList.add(mapper.apply(item));
        }
        return mappedList;
    }
}

Listing 4-14A FunctionUtil Class

现在,您将使用FunctionUtil类作为库用户,并使用函数接口作为 lambda 表达式的目标类型。清单 4-15 展示了如何使用FunctionUtil类。

// FunctionUtilTest.java
package com.jdojo.lambda;
import static com.jdojo.lambda.Gender.MALE;
import java.util.List;
public class FunctionUtilTest {
    public static void main(String[] args) {
        List<Person> list = Person.getPersons();
        // Use the forEach() method to print each person
        // in the list
        System.out.println("Original list of persons:");
        FunctionUtil.forEach(list, p ->
            System.out.println(p));
        // Filter only males
        List<Person> maleList = FunctionUtil.filter(list,
            p -> p.getGender() == MALE);
        System.out.println("\nMales only:");
        FunctionUtil.forEach(maleList,
            p -> System.out.println(p));
        // Map each person to his/her year of birth
        List<Integer> dobYearList = FunctionUtil.map(list,
            p -> p.getDob().getYear());
        System.out.println("\nPersons mapped to year of " +
            "their birth:");
        FunctionUtil.forEach(dobYearList,
            year -> System.out.println(year));
        // Apply an action to each person in the list.
        // Add one year to each male's dob
        FunctionUtil.forEach(maleList,
            p -> p.setDob(p.getDob().plusYears(1)));
        System.out.println("\nMales only after adding " +
            "1 year to DOB:");
        FunctionUtil.forEach(maleList,
            p -> System.out.println(p));
    }
}

Original list of persons:
John Jacobs, MALE, 1975-01-20
Wally Inman, MALE, 1965-09-12
Donna Jacobs, FEMALE, 1970-09-12
Males only:
John Jacobs, MALE, 1975-01-20
Wally Inman, MALE, 1965-09-12
Persons mapped to year of their birth:
1975

1965
1970
Males only after adding 1 year to DOB:
John Jacobs, MALE, 1976-01-20
Wally Inman, MALE, 1966-09-12

Listing 4-15Using Functional Interfaces As Target Types of Lambda Expressions As Library Users

该程序获得一个人员列表,对该列表应用一个过滤器以获得一个只有男性的列表,将人员映射到他们的出生年份,并在每个男性的出生日期上加一年。它使用 lambda 表达式执行这些操作。注意代码的简洁;它只使用一行代码来执行每个操作。最值得注意的是使用了forEach()方法。这个方法采用了一个Consumer函数。然后每一项都被传递给这个函数。该函数可以对项目采取任何操作。您传递了一个在标准输出上打印项目的Consumer,如下所示:

FunctionUtil.forEach(list,
    p -> System.out.println(p));

通常情况下,Consumer会对收到的物品施加一个动作来产生副作用。在这种情况下,它只是打印项目,而不会产生任何副作用。

方法引用

lambda 表达式表示被视为函数接口实例的匿名函数。方法引用是使用现有方法创建 lambda 表达式的一种快捷方式。使用方法引用使你的 lambda 表达式更易读、更简洁;它还允许您将现有方法用作 lambda 表达式。如果 lambda 表达式包含的主体是使用方法调用的表达式,则可以使用方法引用来代替该 lambda 表达式。

Note

方法引用不是 Java 中的新类型。它不是其他一些编程语言中使用的函数指针。它只是使用现有方法编写 lambda 表达式的简写。它只能在可以使用 lambda 表达式的地方使用。

在我解释方法引用的语法之前,让我们考虑一个例子。考虑以下代码片段:

import java.util.function.ToIntFunction;
...
ToIntFunction<String> lengthFunction = str ->
    str.length();
String name = "Ellen";
int len = lengthFunction.applyAsInt(name);
System.out.println("Name = " + name +
    ", length = " + len);

Name = Ellen, length = 5

代码使用一个 lambda 表达式来定义一个匿名函数,该函数将一个String作为参数并返回它的长度。lambda 表达式的主体只包含一个方法调用,即String类的length()方法。您可以使用对String类的length()方法的方法引用来重写 lambda 表达式,如下所示:

import java.util.function.ToIntFunction;
...
ToIntFunction<String> lengthFunction = String::length;
String name = "Ellen";
int len = lengthFunction.applyAsInt(name);
System.out.println("Name = " + name +
    ", length = " + len);
Name = Ellen, length = 5

方法引用的一般语法是

<Qualifier>::<MethodName>

<Qualifier>取决于方法引用的类型。两个连续的冒号充当分隔符。<MethodName>是方法的名称。例如,在方法引用String::length中,String是限定符,length是方法名。

Note

方法引用在声明时不调用方法。稍后调用其目标类型的方法时,会调用该方法。

方法引用的语法只允许指定方法名。您不能指定该方法的参数类型和返回类型。回想一下,方法引用是 lambda 表达式的简写。目标类型通常是一个函数接口,它决定了方法的细节。如果该方法是重载方法,编译器将根据上下文选择最具体的方法。参见表 4-3 。

表 4-3

方法引用的类型

|

句法

|

描述

|
| --- | --- |
| TypeName::staticMethod | 对类、接口或枚举的静态方法的方法引用。 |
| objectRef::instanceMethod | 对指定对象的实例方法的方法引用。 |
| ClassName::instanceMethod | 对指定类的任意对象的实例方法的方法引用。 |
| TypeName.super::instanceMethod | 对特定对象的超类型的实例方法的方法引用。 |
| ClassName::new | 对指定类的构造函数的构造函数引用。 |
| ArrayTypeName::new | 对指定数组类型的构造函数的数组构造函数引用。 |

使用方法引用在开始时可能会有点混乱。混淆的主要原因是将实际方法中参数的数量和类型映射到方法引用的过程。为了帮助理解语法,我在所有示例中使用了方法引用及其等效的 lambda 表达式。

静态方法引用

静态方法引用使用类型的静态方法作为 lambda 表达式。该类型可以是类、接口或枚举。考虑下面的Integer类的静态方法:

static String toBinaryString(int i)

toBinaryString()方法表示一个函数,它将一个int作为参数并返回一个String。您可以在 lambda 表达式中使用它,如下所示:

// Using a lambda expression
Function<Integer,String> func1 =
    x -> Integer.toBinaryString(x);
System.out.println(func1.apply(17));

10001

编译器通过使用目标类型Function<Integer,String>,将x的类型推断为Integer,将 lambda 表达式的返回类型推断为String

您可以使用静态方法引用重写该语句,如下所示:

// Using a method reference
Function<Integer, String> func2 =
    Integer::toBinaryString;
System.out.println(func2.apply(17));

10001

编译器在赋值操作符的右边找到了对Integer类的toBinaryString()方法的静态方法引用。toBinaryString(方法将一个int作为参数,并返回一个String。方法引用的目标类型是一个以Integer为参数并返回String的函数。编译器验证在将目标类型的Integer参数类型解装箱到int之后,方法引用和目标类型是赋值兼容的。

考虑Integer类中的另一个静态方法sum():

static int sum(int a, int b)

方法引用应该是Integer::sum。让我们像在前面的例子中使用toBinaryString()方法一样使用它:

Function<Integer,Integer> func2 = Integer::sum;
// <- A compile-time error

Error: incompatible types: invalid
        Function<Integer, Integer>
method sum in class Integer cannot
required: int,int
found: Integer
reason: actual and formal argument

method reference
func2 = Integer::sum;
be applied to given types

lists differ in length

错误消息指出方法引用Integer::sum与目标类型Function<Integer,Integer>的赋值不兼容。sum(int, int)方法有两个int参数,而目标类型只有一个Integer参数。参数数量不匹配导致了编译时错误。

要修复这个错误,方法引用Integer::sum的目标类型应该是一个函数接口,其抽象方法接受两个int参数并返回一个int。使用一个BiFunction<Integer,Integer, Integer>作为目标类型将有效。以下代码片段显示了如何使用方法引用Integer::sum以及等效的 lambda 表达式:

// Uses a lambda expression
BiFunction<Integer,Integer,Integer> func1 =
    (x, y) -> Integer.sum(x, y);
System.out.println(func1.apply(17, 15));
// Uses a method reference
BiFunction<Integer,Integer,Integer> func2 =
    Integer::sum;
System.out.println(func2.apply(17, 15));

32
32

让我们尝试使用Integer类的重载静态方法valueOf()的方法引用。该方法有三个版本:

  • static Integer valueOf(int i)

  • static Integer valueOf(String s)

  • static Integer valueOf(String s, int radix)

下面的代码片段展示了不同的目标类型将如何使用三个不同版本的Integer.valueOf()静态方法。读者可以练习使用 lambda 表达式编写以下代码片段:

// Uses Integer.valueOf(int)
Function<Integer,Integer> func1 = Integer::valueOf;

// Uses Integer.valueOf(String)
Function<String,Integer> func2 = Integer::valueOf;

// Uses Integer.valueOf(String, int)
BiFunction<String,Integer,Integer> func3 =
    Integer::valueOf;

System.out.println(func1.apply(17));
System.out.println(func2.apply("17"));
System.out.println(func3.apply("10001", 2));

17
17
17

下面是这一类的最后一个例子。清单 4-13 中显示的Person类包含一个getPersons()静态方法,声明如下:

static List<Person> getPersons()

该方法不接受任何参数,并返回一个List<Person>。一个Supplier<T>表示一个没有参数的函数,返回一个T类型的结果。下面的代码片段使用方法引用Person::getPersons作为Supplier<List<Person>>:

Supplier<List<Person>> supplier = Person::getPersons;
List<Person> personList = supplier.get();
FunctionUtil.forEach(personList,
    p -> System.out.println(p));

John Jacobs, MALE, 1975-01-20
Wally Inman, MALE, 1965-09-12
Donna Jacobs, FEMALE, 1970-09-12

实例方法引用

在对象的引用上调用实例方法。调用实例方法的对象引用被称为方法调用的接收者。方法调用的接收者可以是对象引用,也可以是计算结果为对象引用的表达式。下面的代码片段显示了String类的length()实例方法的接收者:

String name = "Kannan";
// name is the receiver of the length() method
int len1 = name.length();
// "Hello" is the receiver of the length() method
int len2 = "Hello".length();
// (new String("Kannan")) is the receiver of the length()
// method
int len3 = (new String("Kannan")).length();

在实例方法的方法引用中,可以显式指定方法调用的接收者,也可以在调用方法时隐式提供它。前者被称为绑定接收者,后者被称为非绑定接收者。实例方法引用的语法支持两种变体:

  • objectRef::instanceMethod

  • ClassName::instanceMethod

对于绑定的接收者,使用objectRef::instanceMethod语法。考虑以下代码片段:

Supplier<Integer> supplier = () -> "Ellen".length();
System.out.println(supplier.get());

5

该语句使用一个 lambda 表达式来表示一个不带参数并返回一个int的函数。表达式主体使用一个名为“Ellen”的String对象来调用String类的length()实例方法。您可以使用实例方法引用重写该语句,将“Ellen”对象作为绑定接收者,并将Supplier<Integer>作为目标类型,如下所示:

Supplier<Integer> supplier = "Ellen"::length;
System.out.println(supplier.get());

5

考虑下面的代码片段来表示一个将String作为参数并返回voidConsumer<String>:

Consumer<String> consumer = str -> System.out.println(str);
consumer.accept("Hello");

Hello

这个 lambda 表达式调用了System.out对象上的println()方法。这可以用一个方法引用重写,用System.out作为绑定的接收者,如下所示:

Consumer<String> consumer = System.out::println;
consumer.accept("Hello");

Hello

当使用方法引用System.out::println时,编译器查看其目标类型,即Consumer<String>。它表示一个函数类型,该函数类型将一个String作为参数并返回void。编译器在System.out对象的PrintStream类中找到一个println(String)方法,并使用该方法作为方法引用。

作为这个类别的最后一个例子,您将使用方法引用System.out::println来打印人员列表,如下所示:

List<Person> list = Person.getPersons();
FunctionUtil.forEach(list, System.out::println);

John Jacobs, MALE, 1975-01-20
Wally Inman, MALE, 1965-09-12
Donna Jacobs, FEMALE, 1970-09-12

对于未绑定的接收器,使用ClassName::instanceMethod语法。考虑以下语句,其中 lambda 表达式将一个Person作为参数,并返回一个String:

Function<Person,String> fNameFunc =
  (Person p) -> p.getFirstName();

可以使用实例方法引用重写该语句,如下所示:

Function<Person,String> fNameFunc = Person::getFirstName;

一开始,这是令人困惑的,原因有二:

  • 语法与静态方法的方法引用的语法相同。

  • 这就提出了一个问题:哪个对象是实例方法调用的接收者?

第一个困惑可以通过查看方法名并检查它是静态方法还是实例方法来消除。如果方法是实例方法,则方法引用表示实例方法引用。

第二个困惑可以通过记住一个规则来消除,即目标类型所表示的函数的第一个参数是方法调用的接收者。考虑一个名为String::length的实例方法引用,它使用了一个未绑定的接收器。接收者作为第一个参数提供给apply()方法,如下所示:

Function<String,Integer> strLengthFunc = String::length;
String name = "Ellen";
// name is the receiver of String::length
int len = strLengthFunc.apply(name);
System.out.println("name = " + name +
    ", length = " + len);
name = Ellen, length = 5

String类的实例方法concat()有如下声明:

String concat(String str)

方法引用String::concat代表一个目标类型的实例方法引用,其函数采用两个String参数并返回一个String。第一个参数将是concat()方法的接收者,第二个参数将被传递给concat()方法。以下代码片段显示了一个示例:

String greeting = "Hello";
String name = " Laynie";
// Uses a lambda expression
BiFunction<String,String,String> func1 =
    (s1, s2) -> s1.concat(s2);
System.out.println(func1.apply(greeting, name));
// Uses an instance method reference on an unbound
// receiver
BiFunction<String,String,String> func2 = String::concat;
System.out.println(func2.apply(greeting, name));

Hello Laynie
Hello Laynie

作为这一类别的最后一个例子,您将使用方法引用Person::getFirstName,它是一个未绑定接收器上的实例方法引用,如下所示:

List<Person> personList = Person.getPersons();
// Maps each Person object to its first name
List<String> firstNameList = FunctionUtil.map(personList,
    Person::getFirstName);
// Prints the first name list
FunctionUtil.forEach(firstNameList, System.out::println);

John
Wally
Donna

超类型实例方法引用

关键字super用作限定符来调用类或接口中被覆盖的方法。该关键字仅在实例上下文中可用。使用以下语法构造一个方法引用,该方法引用超类型中的实例方法和在当前实例上调用的方法:

TypeName.super::instanceMethod

考虑清单 4-16 和 4-17 中的Priced接口和Item类。Priced接口包含一个返回1.0的默认方法。Item类实现了Priced接口。它覆盖了Object类的toString()方法和Priced接口的getPrice()方法。我向Item类添加了三个构造函数,它们在标准输出中显示一条消息。我将在下一节的例子中使用它们。

// Item.java
package com.jdojo.lambda;
import java.util.function.Supplier;
public class Item implements Priced {
    private String name = "Unknown";
    private double price = 0.0;
    public Item() {
        System.out.println("Constructor Item() called.");
    }
    public Item(String name) {
        this.name = name;
        System.out.println("Constructor Item(String) " +
          "called.");
    }
    public Item(String name, double price) {
        this.name = name;
        this.price = price;
        System.out.println("Constructor " +
          "Item(String, double) called.");
    }
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    public void setPrice(double price) {
        this.price = price;
    }
    @Override
    public double getPrice() {
        return price;
    }
    @Override
    public String toString() {
        return "name = " + getName() +
          ", price = " + getPrice();
    }
    public void test() {
        // Uses the Item.toString() method
        Supplier<String> s1 = this::toString;
        // Uses the Object.toString() method
        Supplier<String> s2 = Item.super::toString;
        // Uses the Item.getPrice() method
        Supplier<Double> s3 = this::getPrice;
        // Uses the Priced.getPrice() method
        Supplier<Double> s4 = Priced.super::getPrice;
        // Uses all method references and prints the
        // results
        System.out.println("this::toString: " + s1.get());
        System.out.println("Item.super::toString: " +
          s2.get());
        System.out.println("this::getPrice: " + s3.get());
        System.out.println("Priced.super::getPrice: " +
          s4.get());
    }
}

Listing 4-17An Item Class That Implements the Priced Interface

// Priced.java
package com.jdojo.lambda;
public interface Priced {
    default double getPrice() {
        return 1.0;
    }
}

Listing 4-16A Priced Interface with a Default Method of getPrice()

Item类中的test()方法使用四个方法引用和一个绑定的接收器。接收者是对其调用test()方法的Item对象。

  • 方法引用this::toString指的是Item类的toString()方法。

  • 方法引用Item.super::toString指的是Object类的toString()方法,它是Item类的超类。

  • 方法引用this::getPrice指的是Item类的getPrice()方法。

  • 方法引用Priced.super::getPrice指的是定价接口的getPrice()方法,它是Item类的超接口。

清单 4-18 中的程序创建一个Item类的对象并调用它的test()方法。输出显示了四个方法引用正在使用的方法。

// ItemTest.java
package com.jdojo.lambda;
public class ItemTest {
    public static void main(String[] args) {
        Item apple = new Item("Apple", 0.75);
        apple.test();
    }
}

Constructor Item(String, double) called.
this::toString: name = Apple, price = 0.75
Item.super::toString: com.jdojo.lambda.Item@24d46ca6
this::getPrice: 0.75
Priced.super::getPrice: 1.0

Listing 4-18Testing the Item Class

构造函数引用

有时候,lambda 表达式的主体可能只是一个对象创建表达式。考虑下面两个使用String对象创建表达式作为 lambda 表达式主体的语句:

Supplier<String> func1 = () -> new String();
Function<String,String> func2 = str -> new String(str);

您可以通过用构造函数引用替换 lambda 表达式来重写这些语句,如下所示:

Supplier<String> func1 = String::new;
Function<String,String> func2 = String::new;

使用构造函数的语法如下:

  • ClassName::new

  • ArrayTypeName::new

ClassName::new中的ClassName是可以实例化的类的名称;它不能是抽象类的名称。关键字new指的是类的构造函数。一个类可以有多个构造函数。语法没有提供引用特定构造函数的方法。编译器根据上下文选择特定的构造函数。它查看目标类型和目标类型的抽象方法中的参数数量。选择其参数数量与目标类型的抽象方法中的参数数量相匹配的构造函数。考虑下面的代码片段,它在 lambda 表达式中使用了清单 4-17 中所示的Item类的三个构造函数:

Supplier<Item> func1 = () -> new Item();
Function<String,Item> func2 = name -> new Item(name);
BiFunction<String,Double,Item> func3 =
    (name, price) -> new Item(name, price);
System.out.println(func1.get());
System.out.println(func2.apply("Apple"));
System.out.println(func3.apply("Apple", 0.75));

Constructor Item() called.
name = Unknown, price = 0.0
Constructor Item(String) called.
name = Apple, price = 0.0
Constructor Item(String, double) called.
name = Apple, price = 0.75

下面的代码片段用构造函数引用Item::new替换了 lambda 表达式。输出显示了与之前相同的构造函数:

Supplier<Item> func1 = Item::new;
Function<String,Item> func2 = Item::new;
BiFunction<String,Double,Item> func3 = Item::new;
System.out.println(func1.get());
System.out.println(func2.apply("Apple"));
System.out.println(func3.apply("Apple", 0.75));

Constructor Item() called.
name = Unknown, price = 0.0
Constructor Item(String) called.
name = Apple, price = 0.0
Constructor Item(String, double) called.
name = Apple, price = 0.75

当声明

Supplier<Item> func1 = Item::new;

时,编译器发现目标类型Supplier<Item>不接受参数。因此,它使用了Item类的无参数构造函数。当声明

Function<String,Item> func2 = Item::new;

时,编译器发现目标类型Function<String,Item>带有一个String参数。因此,它使用接受String参数的Item类的构造函数。当声明

BiFunction<String,Double,Item> func3 = Item::new;

执行时,编译器发现目标类型BiFunction<String,Double,Item>有两个参数:一个String和一个Double。因此,它使用了接受一个String和一个double参数的Item类的构造函数。

以下语句生成一个编译时错误,因为编译器在接受Double参数的Item类中找不到构造函数:

Function<Double,Item> func4 = Item::new;
// <- A compile-time error

Java 中的数组没有构造函数。使用数组的构造函数引用有一个特殊的语法。数组构造函数被视为有一个 int 类型的参数,即数组的大小。以下代码片段显示了 lambda 表达式及其对一个int数组的等效构造函数引用:

// Uses a lambda expression
IntFunction<int[]> arrayCreator1 = size -> new int[size];
int[] empIds1 = arrayCreator1.apply(5);
// <- Creates an int array of five elements
// Uses an array constructor reference
IntFunction<int[]> arrayCreator2 = int[]::new;
int[] empIds2 = arrayCreator2.apply(5);
// <- Creates an int array of five elements

您还可以使用Function<Integer,R>类型来使用数组构造函数引用,其中R是数组类型:

// Uses an array constructor reference
Function<Integer,int[]> arrayCreator3 = int[]::new;
int[] empIds3 = arrayCreator3.apply(5);
// <- Creates an int array of five elements

数组的构造函数引用的语法支持创建多维数组。但是,您只能指定第一个尺寸的长度。以下语句创建一个二维 int 数组,其中第一维的长度为 5:

// Uses an array constructor reference
IntFunction<int[][]> TwoDimArrayCreator = int[][]::new;
int[][] matrix = TwoDimArrayCreator.apply(5);
// <- Creates an int[5][] array

您可能会尝试使用BiFunction<Integer,Integer,int[][]>来使用二维数组的构造函数引用来提供两个维度的长度。但是,不支持该语法。数组构造函数应该只接受一个参数——第一维的长度。以下语句会生成编译时错误:

BiFunction<Integer,Integer,int[][]> arrayCreator =
    int[][]::new;

泛型方法引用

通常,当方法引用引用泛型方法时,编译器会计算出泛型类型参数的实际类型。考虑下面的java.util.Arrays类中的泛型方法:

static <T> List<T> asList(T... a)

asList()方法接受一个T类型的varargs参数并返回一个List<T>。你可以使用Arrays::asList作为方法参考。方法引用的语法允许您在两个连续的冒号后指定方法的实际类型参数。例如,如果将String对象传递给asList()方法,其方法引用可以写成Arrays::<String>asList

Note

方法引用的语法还支持为泛型类型指定实际的类型参数。实际的类型参数是在两个连续的冒号之前指定的。例如,构造函数引用ArrayList<Long>::new指定Long作为通用ArrayList<T>类的实际类型参数。

下面的代码片段包含一个为泛型方法Arrays.asList()指定实际类型参数的示例。在代码中,Arrays::asList将同样工作,因为编译器将通过检查目标类型来推断String作为asList()方法的类型参数:

import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
...
Function<String[],List<String>> asList =
    Arrays::<String>asList;
String[] namesArray = {"Jim", "Ken", "Li"};
List<String> namesList = asList.apply(namesArray);
for(String name : namesList) {
    System.out.println(name);
}

Jim
Ken
Li

词法范围

作用域是 Java 程序的一部分,在其中可以使用不带限定符的名字。类和方法定义了它们自己的作用域。范围可以是嵌套的。例如,一个方法作用域不是独立存在的,因为一个方法总是另一个构造的一部分,例如,一个类;内部类出现在另一个类的范围内;局部类和匿名类出现在方法的范围内。

尽管 lambda 表达式看起来像一个方法声明,但它并没有定义自己的作用域。它存在于它的封闭范围内。这被称为 lambda 表达式的词法范围。例如,当在方法内部使用 lambda 表达式时,lambda 表达式存在于方法的范围内。

关键字thissuper在 lambda 表达式及其封闭方法中的含义是相同的。请注意,这与本地匿名内部类中这些关键字的含义不同,在本地匿名内部类中,关键字this指的是本地匿名内部类的当前实例,而不是其封闭类。

清单 4-19 包含一个名为Printer的函数接口的代码,您将使用它来打印本节示例中的消息。

// Printer.java
package com.jdojo.lambda;
@FunctionalInterface
public interface Printer {
    void print(String msg);
}

Listing 4-19A Printer Functional Interface

清单 4-20 中的程序创建了Printer接口的两个实例:一个在getLambdaPrinter()方法中使用 lambda 表达式,另一个在getAnonymousPrinter()方法中使用匿名内部类。两个实例都在print()方法中使用关键字this。这两种方法都打印关键字this引用的类名。输出显示关键字thisgetLambdaPrinter()方法和 lambda 表达式中具有相同的含义。然而,关键字thisgetAnonymousPrinter()方法和匿名类中有不同的含义。

// ScopeTest.java
package com.jdojo.lambda;
public class ScopeTest {
    public static void main(String[] args) {
        ScopeTest test = new ScopeTest();
        Printer lambdaPrinter = test.getLambdaPrinter();
        lambdaPrinter.print("Lambda Expressions");
        Printer anonymousPrinter = test.
          getAnonymousPrinter();
        anonymousPrinter.print("Anonymous Class");
    }
    public Printer getLambdaPrinter() {
        System.out.println("getLambdaPrinter(): " +
          this.getClass());
        // Uses a lambda expression
        Printer printer = msg -> {
            // Here, this refers to the current object
            // of the ScopeTest class
            System.out.println(msg + ": " +
              this.getClass());
        };
        return printer;
    }
    public Printer getAnonymousPrinter() {
        System.out.println("getAnonymousPrinter(): " +
          this.getClass());
        // Uses an anonymous class
        Printer printer = new Printer() {
            @Override
            public void print(String msg) {
                // Here, this refers to the current
                // object of the anonymous class
                System.out.println(msg + ": " +
                  this.getClass());
            }
        };
        return printer;
    }
}

getLambdaPrinter(): class com.jdojo.lambda.ScopeTest
Lambda Expressions: class com.jdojo.lambda.ScopeTest
getAnonymousPrinter(): class com.jdojo.lambda.ScopeTest
Anonymous Class: class com.jdojo.lambda.ScopeTest\$1

Listing 4-20Testing Scope of a Lambda Expression and an Anonymous Class

lambda 表达式的词法范围意味着 lambda 表达式中声明的变量(包括其参数)存在于封闭范围内。范围中的简单名称必须是唯一的。这意味着 lambda 表达式不能用封闭范围内已经存在的名称重新定义变量。

main()方法中 lambda 表达式的以下代码生成了一个编译时错误,因为它的参数名msg已经在main()方法的作用域中定义了:

public class Test {
    public static void main(String[] args) {
        String msg = "Hello";
        // A compile-time error. The msg variable is
        // already defined and the lambda parameter is
        // attempting to redefine it.
        Printer printer = msg -> System.out.println(msg);
    }
}

以下代码生成编译时错误的原因与名为msg的局部变量在 lambda 表达式主体内的范围内相同,并且 lambda 表达式试图用相同的名称msg声明局部变量:

public class Test {
    public static void main(String[] args) {
        String msg = "Hello";
        Printer printer = msg1 -> {
            String msg = "Hi"; // A compile-time error
            System.out.println(msg1);
        };
    }
}

可变捕获

像局部和匿名内部类一样,lambda 表达式可以有效地访问最终局部变量。在以下两种情况下,局部变量实际上是最终变量:

  • 声明为final

  • 没有声明final,只是初始化一次。

在下面的代码片段中,msg变量实际上是 final,因为它已经被声明为final。lambda 表达式访问其主体内部的变量:

public Printer test() {
    final String msg = "Hello"; // msg is effectively final
    Printer printer = msg1 -> System.out.println(msg +
      " " + msg1);
    return printer;
}

在下面的代码片段中,msg变量实际上是 final 变量,因为它被初始化了一次。lambda 表达式访问其主体内部的变量:

public Printer test() {
    String msg = "Hello"; // msg is effectively final
    Printer printer = msg1 ->
        System.out.println(msg + " " + msg1);
    return printer;
}

下面的代码片段与前面的示例略有不同。msg变量实际上是最终变量,因为它只被初始化过一次:

public Printer test() {
    String msg;
    msg = "Hello"; // msg is effectively final
    Printer printer = msg1 ->
        System.out.println(msg + " " + msg1);
    return printer;
}

在下面的代码片段中,msg变量实际上不是最终变量,因为它被赋值了两次。lambda 表达式正在访问生成编译时错误的msg变量:

public Printer test() {
    // msg is not effectively final as it is changed later
    String msg = "Hello";
    // A compile-time error
    Printer printer = msg1 ->
        System.out.println(msg + " " + msg1);
    msg = "Hi";
    // <- msg is changed making it effectively non-final
    return printer;
}

以下代码片段生成了一个编译时错误,因为 lambda 表达式访问了在使用后按词法声明的msg变量。在 Java 中,不允许在方法范围内向前引用变量名。请注意,msg变量实际上是最终变量。

public Printer test() {
    // A compile-time error. The msg variable is not
    // declared yet.
    Printer printer = msg1 ->
        System.out.println(msg + " " + msg1);
    String msg = "Hello";  // msg is effectively final
    return printer;
}

您能猜到为什么下面的代码片段会产生编译时错误吗?

public Printer test() {
    String msg = "Hello";
    Printer printer = msg1 ->  {
        msg = "Hi " + msg1; // A compile-time error.
                            // Attempting to modify msg.
        System.out.println(msg);
    };
    return printer;
}

Lambda 表达式访问局部变量msg。在 lambda 表达式中访问的任何局部变量实际上都必须是 final。lambda 表达式试图修改其主体内的msg变量,这会导致编译时错误。

Note

lambda 表达式可以访问一个类的实例和类变量,不管它们是否是最终的。如果实例和类变量不是最终的,它们可以在 lambda 表达式的主体中修改。lambda 表达式保留了其主体中使用的局部变量的副本。如果局部变量是引用变量,则保留引用的副本,而不是对象的副本。

清单 4-21 中的程序演示了如何访问 lambda 表达式中的局部变量和实例变量。

// VariableCapture.java
package com.jdojo.lambda;
public class VariableCapture {
    private int counter = 0;
    public static void main(String[] args) {
        VariableCapture vc1 = new VariableCapture();
        VariableCapture vc2 = new VariableCapture();
        // Create lambdas
        Printer p1 = vc1.createLambda(1);
        Printer p2 = vc2.createLambda(100);
        // Execute the lambda bodies
        p1.print("Lambda #1");
        p2.print("Lambda #2");
        p1.print("Lambda #1");
        p2.print("Lambda #2");
        p1.print("Lambda #1");
        p2.print("Lambda #2");
    }
    public Printer createLambda(int incrementBy) {
        Printer printer = msg -> {
            // Accesses instance and local variables
            counter += incrementBy;
            System.out.println(msg + ": counter = " +
              counter);
        };
        return printer;
    }
}

Lambda #1: counter = 1
Lambda #2: counter = 100
Lambda #1: counter = 2
Lambda #2: counter = 200
Lambda #1: counter = 3
Lambda #2: counter = 300

Listing 4-21Accessing Local and Instance Variables Inside Lambda Expressions

createLambda()方法使用 lambda 表达式来创建Printer函数接口的实例。lambda 表达式使用方法的参数incrementBy。在主体内部,它增加实例变量counter并打印其值。main()方法创建了VariableCapture类的两个实例,并通过将1100作为incrementBy值传递来调用这些实例上的createLambda()方法。对于这两个实例,Printer对象的print()方法被调用了三次。输出显示,lambda 表达式捕获了incrementBy值,并在每次调用时递增counter实例变量。

跳跃和退出

诸如breakcontinuereturnthrow之类的语句允许出现在 lambda 表达式的主体中。这些语句表示方法内部的跳转和方法的退出。在 lambda 表达式中,它们表示 lambda 表达式体中的跳转和从 lambda 表达式体中退出。它们表示 lambda 表达式中的局部跳转和退出。lambda 表达式中不允许非局部跳转和退出。清单 4-22 中的程序演示了在 lambda 表达式体中有效使用breakcontinue语句。

// LambdaJumps.java
package com.jdojo.lambda;
import java.util.function.Consumer;
public class LambdaJumps {
    public static void main(String[] args) {
        Consumer<int[]> printer = ids -> {
            int printedCount = 0;
            for (int id : ids) {
                if (id % 2 != 0) {
                    continue;
                }
                System.out.println(id);
                printedCount++;
                // Break out of the loop after printing 3
                // ids
                if (printedCount == 3) {
                    break;
                }
            }
        };
        // Print an array of 8 integers
        printer.accept(new int[]{1, 2, 3, 4, 5, 6, 7, 8});
    }
}

2
4
6

Listing 4-22Using break and continue Statements Inside the Body of a Lambda Expression

在下面的代码片段中,break语句位于for loop 语句中,也位于 lambda 语句体中。如果这个break语句被允许,它将跳出 lambda 表达式的主体。这就是代码生成编译时错误的原因:

public void test() {
    for(int i = 0; i < 5; i++) {
        Consumer<Integer> evenIdPrinter = id -> {
            if (id < 0) {
                // A compile-time error. Attempting to
                // break out of the lambda body
                break;
            }
        };
    }
}

递归 Lambda 表达式

有时,一个函数可能从它的主体中调用它自己。这样的函数称为递归函数。Lambda 表达式代表一个函数。然而,lambda 表达式不支持递归调用。如果需要递归函数,需要使用方法引用或者匿名内部类。

清单 4-23 中的程序展示了当需要递归 lambda 表达式时如何使用方法引用。它定义了一个名为factorial()的递归方法来计算一个整数的阶乘。在main()方法中,它使用方法引用RecursiveTest::factorial来代替 lambda 表达式。

// RecursiveTest.java
package com.jdojo.lambda;
import java.util.function.IntFunction;
public class RecursiveTest {
    public static void main(String[] args) {
        IntFunction<Long> factorialCalc =
          RecursiveTest::factorial;
        int n = 5;
        long fact = factorialCalc.apply(n);
        System.out.println("Factorial of " + n +
          " is " + fact);
    }
    public static long factorial(int n) {
        if (n < 0) {
            String msg = "Number must not be negative.";
            throw new IllegalArgumentException(msg);
        }
        if (n == 0) {
            return 1;
        } else {
            return n * factorial(n - 1);
        }
    }
}

factorial of 5 is 120

Listing 4-23Using a Method Reference When a Recursive Lambda Expression Is Needed

您可以使用匿名内部类获得相同的结果,如下所示:

IntFunction<Long> factorialCalc = new IntFunction<Long>() {
    @Override
    public Long apply(int n) {
        if (n < 0) {
            String msg = "Number must not be negative.";
            throw new IllegalArgumentException(msg);
        }
        if (n == 0) {
            return 1L;
        } else {
            return n * this.apply(n - 1);
        }
    }
};

比较对象

Comparator接口是一个函数接口,声明如下:

package java.util;
@FunctionalInterface
public interface Comparator<T> {
    int compare(T o1, T o2);
    /* Other methods are not shown. */
}

Comparator<T>接口包含许多默认和静态方法,可以与 lambda 表达式一起使用来创建它的实例。值得研究该接口的 API 文档。在这一节中,我讨论了下面两个Comparator接口的方法:

  • static <T,U extends Comparable<? super U» Comparator<T> comparing(Function<? super T,? extends U> keyExtractor)

  • default <U extends Comparable<? super U» Comparator<T> thenComparing(Function<? super T,? extends U> keyExtractor)

comparing()方法接受一个Function并返回一个ComparatorFunction应该返回一个用于比较两个对象的Comparable。您可以创建一个Comparator对象来根据名字比较Person对象,如下所示:

Comparator<Person> firstNameComp =
    Comparator.comparing(Person::getFirstName);

thenComparing()方法是默认方法。如果两个对象在基于主比较的排序顺序上相同,则使用它来指定次比较。下面的语句创建了一个Comparator<Person>,它根据Person对象的姓、名和 DOB 对这些对象进行排序:

Comparator<Person> lastFirstDobComp =
    Comparator.comparing(Person::getLastName)
              .thenComparing(Person::getFirstName)
              .thenComparing(Person::getDob);

清单 4-24 中的程序展示了如何使用方法引用来创建一个Comparator对象来排序Person对象。它使用List接口的sort()默认方法对人员列表进行排序。sort()方法将一个Comparator作为参数。感谢 lambda 表达式和接口中的默认方法让排序任务变得如此简单!

/ ComparingObjects.java
package com.jdojo.lambda;
import java.util.Comparator;
import java.util.List;
public class ComparingObjects {
    public static void main(String[] args) {
        List<Person> persons = Person.getPersons();
        // Sort using the first name
        persons.sort(Comparator.comparing(
            Person::getFirstName));
        // Print the sorted list
        System.out.println("Sorted by the first name:");
        FunctionUtil.forEach(persons, System.out::println);
        // Sort using the last name, first name, and then
        // DOB
        persons.sort(Comparator.comparing(
            Person::getLastName)
                     .thenComparing(Person::getFirstName)
                     .thenComparing(Person::getDob));
        // Print the sorted list
        System.out.println("\nSorted by the last name, " +
            "first name, and dob:");
        FunctionUtil.forEach(persons, System.out::println);
    }
}

Sorted by the first name:
Donna Jacobs, FEMALE, 1970-09-12
John Jacobs, MALE, 1975-01-20
Wally Inman, MALE, 1965-09-12
Sorted by the last name, first name, and dob:
Wally Inman, MALE, 1965-09-12
Donna Jacobs, FEMALE, 1970-09-12
John Jacobs, MALE, 1975-01-20

Listing 4-24Sorting a List of Person Objects

摘要

lambda 表达式是一个未命名的代码块(或未命名的函数),带有一个形参列表和一个主体。与匿名内部类相比,lambda 表达式提供了一种简洁的方式来创建函数接口的实例。就 Java 编程的表现力和流畅性而言,Lambda 表达式和接口中的默认方法赋予了 Java 编程语言新的生命。Java 集合库从 lambda 表达式中受益最大。

定义 lambda 表达式的语法类似于声明方法。lambda 表达式可能有一个形参列表和一个主体。lambda 表达式被计算为函数接口的实例。计算表达式时,不执行 lambda 表达式的主体。当调用函数接口的方法时,执行 lambda 表达式的主体。

lambda 表达式的设计目标之一是保持它的简洁和可读性。lambda 表达式语法支持常见用例的简写。方法引用是指定使用现有方法的 lambda 表达式的简写。

多边形表达式是一种类型取决于其使用上下文的表达式。lambda 表达式始终是 poly 表达式。lambda 表达式不能单独使用。它的类型由编译器从上下文中推断出来。lambda 表达式可用于赋值、方法调用、返回和强制转换。

当 lambda 表达式出现在方法内部时,它是词汇范围的。也就是说,lambda 表达式不定义自己的范围;相反,它发生在方法的范围内。lambda 表达式可以使用方法的有效最终局部变量。lambda 表达式可以使用诸如breakcontinuereturnthrow之类的语句。breakcontinue语句指定了 lambda 表达式主体内部的局部跳转。试图跳转到 lambda 表达式的主体之外会产生编译时错误。returnthrow语句退出 lambda 表达式的主体。

练习

练习 1

什么是 lambda 表达式,它们与函数接口有什么关系?

练习 2

lambda 表达式和匿名类有什么不同?你能总是用匿名类替换 lambda 表达式吗,反之亦然?

运动 3

下面两个 lambda 表达式有区别吗?

a. (int x, int y) -> { return x + y; }
b. (int x, int y) -> x + y

演习 4

如果有人向您展示以下 lambda 表达式,请解释它们可能代表的功能:

a. (int x, int y) -> x + y \\
b. (x, y) -> x + y \\
c. (String msg) -> { System.out.println(msg); }\\
d. () -> {}

锻炼 5

下面的 lambda 表达式可能代表哪种函数?

x -> x;

锻炼 6

下面一个MathUtil接口的声明会编译吗?解释你的答案。

@FunctionalInterface
public interface Operations {
    int factorial(int n);
    int abs(int n);
}

锻炼 7

下面的语句会编译吗?解释你的答案。

Object obj = x -> x + 1;

运动 8

下面的语句会编译吗?解释你的答案。

Function<Integer,Integer> f = x -> x + 1;
Object obj = f;

演习 9

当您运行下面的Scope类时,输出会是什么?

// Scope.java
package com.jdojo.lambda.exercises;
import java.util.function.Function;
public class Scope {
    private static long n = 100;
    private static Function<Long,Long> f = n -> n + 1;
    public static void main(String[] args) {
        System.out.println(n);
        System.out.println(f.apply(n));
        System.out.println(n);
    }
}

运动 10

为什么下面的方法声明不编译?

public static void test() {
    int n = 100;
    Function<Integer,Integer> f = n -> n + 1;
    System.out.println(f.apply(100));
}

演习 11

当下面的Capture类运行时,输出会是什么?

// Capture.java
package com.jdojo.lambda.exercises;
import java.util.function.Function;
public class Capture {
    public static void main(String[] args) {
        test();
        test();
    }
    public static void test() {
        int n = 100;
        Function<Integer,Integer> f = x -> n + 1;
        System.out.println(f.apply(100));
    }
}

运动 12

假设有一个Person类,它包含四个构造函数。其中一个构造函数是无参数构造函数。给定一个构造函数引用,Person::new,你能说出它引用的是Person的哪个构造函数吗?

运动 13

下面的FeelingLucky接口声明会编译吗?请注意,它已经用@FunctionalInterface进行了注释。

@FunctionalInterface
public interface FeelingLucky {
    void gamble();
    public static void hitJackpot() {
        System.out.println("You have won 80M dollars.");
    }
}

运动 14

为什么下面的Mystery接口声明不编译?

@FunctionalInterface
public interface Mystery {
    @Override
    String toString();
}

运动 15

当下面的PredicateTest类运行时,输出会是什么?

// PredicateTest.java
package com.jdojo.lambda.exercises;
import java.util.function.Predicate;
public class PredicateTest {
   public static void main(String[] args) {
       int[] nums = {1, 2, 3, 4, 5};
       filterThenPrint(nums, n -> n%2 == 0);
       filterThenPrint(nums, n -> n%2 == 1);
   }
   static void filterThenPrint(int[] nums,
         Predicate<Integer> p) {
       for(int x : nums) {
           if(p.test(x)) {
               System.out.println(x);
           }
       }
   }
}

演习 16

当下面的SupplierTest类运行时,输出会是什么?解释你的答案。

/ SupplierTest.java
package com.jdojo.lambda.exercises;
import java.util.function.Supplier;
public class SupplierTest {
    public static void main(String[] args) {
        Supplier<Integer> supplier = () -> {
            int counter = 0;
            return ++counter;
        };
        System.out.println(supplier.get());
        System.out.println(supplier.get());
    }
}

演习 17

当下面的ConsumerTest类运行时,输出会是什么?

// ConsumerTest.java
package com.jdojo.lambda.exercises;
import java.util.function.Consumer;
public class ConsumerTest {
    public static void main(String[] args) {
         Consumer<String> c1 = System.out::println;
         Consumer<String> c2 = s -> {};
         consume(c1, "Hello");
         consume(c2, "Hello");
    }
    static <T> void consume(Consumer<T> consumer,
          T item) {
        consumer.accept(item);
    }
}

五、线程

在本章中,您将学习:

  • 什么是线程

  • 如何在 Java 中创建线程

  • 如何在单独的线程中执行代码

  • 什么是 Java 内存模型

  • 线程的生命周期

  • 如何使用对象监视器同步线程对临界区的访问

  • 如何中断、停止、挂起和恢复线程

  • 原子变量、显式锁、同步器、执行器框架、fork/join 框架和线程局部变量

本章中的所有示例程序都是清单 5-1 中声明的jdojo.threads模块的成员。

// module-info.java
module jdojo.threads {
    exports com.jdojo.threads;
}

Listing 5-1The Declaration of a jdojo.threads Module

什么是线程?

线程是一个庞大的主题。他们应该得到一整本书。本章不详细讨论线程的概念。相反,它讨论了如何使用 Java 结构处理线程。在我定义线程这个术语之前,有必要了解一些相关术语的含义,比如程序、进程、多任务、顺序编程、并发编程等。

程序是用编程语言表达的算法。进程是一个正在运行的程序实例,所有系统资源都由操作系统分配给该程序实例。通常,一个进程由唯一标识符、程序计数器、可执行代码、地址空间、系统资源的打开句柄、安全上下文和许多其他内容组成。程序计数器,也称为指令指针,是一个保存在 CPU 寄存器中的值,用于跟踪 CPU 正在执行的指令。它在指令执行结束时自动递增。您也可以将进程视为操作系统中的一个活动单元(或一个工作单元,或一个执行单元,或一条执行路径)。进程的概念允许一个计算机系统支持多个执行单元。

多任务是操作系统一次执行多个任务(或进程)的能力。在单 CPU 机器上,真正意义上的多任务是不可能的,因为一个 CPU 一次只能为一个进程执行指令。在这种情况下,操作系统通过将单个 CPU 的时间分配给所有正在运行的进程并在进程之间快速切换来实现多任务处理,从而给人一种所有进程同时运行的印象。CPU 在进程间的切换被称为上下文切换。在上下文切换中,正在运行的进程被停止,其状态被保存,将要获取 CPU 的进程的状态被恢复,并且新的进程被运行。在将 CPU 分配给另一个进程之前,需要保存正在运行的进程的状态,这样当这个进程再次获得 CPU 时,就可以从它离开的同一点开始执行。通常,进程的状态由程序计数器、进程使用的寄存器值以及以后恢复进程所需的任何其他信息组成。操作系统将过程状态存储在数据结构中,这种数据结构称为过程控制块或交换帧。上下文切换是一项相当昂贵的任务。

多任务有两种类型:合作型和抢先型。在协作式多任务处理中,正在运行的进程决定何时释放 CPU,以便其他进程可以使用 CPU。在抢占式多任务处理中,操作系统给每个进程分配一个时间片。一旦一个进程用完了它的时间片,它就会被抢占,操作系统会将 CPU 分配给另一个进程。在协同多任务中,一个进程可能会长时间独占 CPU,其他进程可能得不到运行的机会。在抢占式多任务处理中,操作系统确保所有进程都获得 CPU 时间。UNIX、OS/2 和 Windows(Windows 3 . x 除外)使用抢占式多任务处理。Windows 3.x 使用了协同多任务处理。

多重处理是计算机同时使用多个处理器的能力。并行处理是系统在多个处理器上同时执行同一任务的能力。您可能会注意到,对于并行处理,任务必须分成子任务,以便子任务可以在多个处理器上同时执行。让我们考虑一个由六条指令组成的程序:

Instruction-1
Instruction-2
Instruction-3
Instruction-4
Instruction-5
Instruction-6

为了完全执行这个程序,CPU 必须执行所有六条指令。假设前三条指令相互依赖。假设Instruction-2使用Instruction-1的结果;Instruction-3使用Instruction-2的结果。假设最后三条指令也像前三条指令一样相互依赖。假设前三条和后三条指令作为两组,互不依赖。你希望如何执行这六条指令以获得最佳结果?执行它们的方法之一是当它们出现在程序中时按顺序执行。这给了你程序中的一个执行序列。执行它们的另一种方式是有两个执行序列。一个执行序列将执行Instruction-1Instruction-2Instruction-3,同时,另一个执行序列将执行Instruction-4Instruction-5Instruction-6。短语“执行单元”和“执行顺序”意思相同;我交替使用它们。这两种情况如图 5-1 所示。

img/323070_3_En_5_Fig1_HTML.jpg

图 5-1

将一个程序分成多个执行单元

请注意,进程也是一个执行单元。因此,这两组指令可以作为两个进程运行,以实现它们执行的并发性。到目前为止,我们已经假设这两组指令是相互独立的。假设这个假设仍然成立。如果这两组指令访问一个共享内存会怎样;或者,当两组指令都完成运行时,您需要合并两组指令的结果来计算最终结果?通常不允许进程访问另一个进程的地址空间。它们必须使用诸如套接字、管道等进程间通信设施进行通信。当多个进程需要通信或共享资源时,进程的本质(独立于其他进程运行)可能会带来问题。所有现代操作系统都允许您在一个进程中创建多个执行单元,所有执行单元可以共享分配给该进程的地址空间和资源,从而解决了这个问题。进程中的每个执行单元称为一个线程。

每个进程至少有一个线程。如果需要,一个进程可以创建多个线程。操作系统及其实现可用的资源决定了一个进程可以创建的最大线程数。一个进程中的所有线程共享所有资源,包括地址空间;它们还可以很容易地相互通信,因为它们在同一个进程中运行,并且共享同一个内存。进程中的每个线程都独立于同一进程中的其他线程运行。

一个线程维护两件事情:一个程序计数器和一个堆栈。程序计数器让线程跟踪它当前正在执行的指令。有必要为每个线程维护一个单独的程序计数器,因为一个进程中的每个线程可能同时执行不同的指令。每个线程维护自己的堆栈来存储局部变量的值。一个线程还可以维护它的私有内存,这些内存不能与其他线程共享,即使它们在同一个进程中。由线程维护的私有内存称为线程本地存储(TLS)。图 5-2 描述了一个流程中表示的线程。

img/323070_3_En_5_Fig2_HTML.jpg

图 5-2

进程和线程

在所有现代操作系统中,线程被安排在 CPU 上执行,而不是进程。因此,CPU 上下文切换发生在线程之间。与进程间的上下文切换相比,线程间的上下文切换开销较小。由于易于通信、在一个进程内的线程之间共享资源以及更便宜的上下文切换,所以最好将一个程序分成多个线程,而不是多个进程。有时,线程也称为轻量级进程。如前所述,具有六条指令的程序也可以在一个进程中分成两个线程,如图 5-3 所示。在多处理器机器上,一个进程的多个线程可以在不同的处理器上调度,从而提供一个程序的真正的并发执行。使用多线程的程序称为多线程程序。

img/323070_3_En_5_Fig3_HTML.jpg

图 5-3

划分程序逻辑以在一个进程中使用两个线程

您可以将进程和线程之间的关系想象成Process = address space + resources + threads,其中线程是进程中的执行单元;它们维护自己独特的程序计数器和堆栈;它们共享进程地址空间和资源;它们被独立地调度在一个 CPU 上,并且可以在不同的 CPU 上执行(如果有的话)。

在 Java 中创建线程

Java API 使得使用线程变得很容易。它允许你将一个线程表示为一个对象。java.lang.Thread类的一个对象代表一个线程。在 Java 中创建和使用线程就像创建一个Thread类的对象并在程序中使用该对象一样简单。让我们从用 Java 创建线程这个最简单的例子开始。使用线程至少需要两个步骤:

  • 创建一个Thread类的对象

  • 调用Thread类的start()方法启动线程

在 Java 中创建一个Thread类的对象与创建任何其他类的对象是一样的。最简单的形式是,您可以使用Thread类的无参数构造函数来创建一个Thread对象:

// Creates a thread object
Thread simplestThread = new Thread();

创建一个Thread类的对象会在堆上为该对象分配内存。它不启动或运行线程。你必须调用Thread对象的start()方法来启动线程:

// Starts the thread
simplestThread.start();

在完成一些日常工作后,start()方法返回。它将线程置于可运行状态。在这种状态下,线程准备接收 CPU 时间。注意,调用一个Thread对象的start()方法并不能保证这个线程“什么时候”开始获得 CPU 时间。也就是说,它不能保证线程何时开始运行。它只是调度线程来接收 CPU 时间。

让我们用这两条语句编写一个简单的 Java 程序,如清单 5-2 所示。这个程序不会做任何有用的事情。然而,它将让你开始使用线程。

// SimplestThread.java
package com.jdojo.threads;
public class SimplestThread {
    public static void main(String[] args) {
        // Creates a thread object
        Thread simplestThread = new Thread();
        // Starts the thread
        simplestThread.start();
    }
}

Listing 5-2The Simplest Thread in Java

当您运行SimplestThread类时,您看不到任何输出。程序将无声地开始和结束。尽管您没有看到任何输出,但是在执行main()方法中的两条语句时,JVM 还是做了一些事情:

  • 当执行第二条语句simplestThread.start()时,JVM 调度这个线程来执行。

  • 在某个时间点,这个线程获得了 CPU 时间并开始执行。Java 中的一个线程在获得 CPU 时间后开始执行什么代码?

  • Java 中的线程总是在一个run()方法中开始执行。当你创建一个Thread类的对象时,你可以定义一个由线程执行的run()方法。在您的例子中,您使用它的无参数构造函数创建了一个Thread类的对象。当您使用Thread类的无参数构造函数创建其对象时(如在 new Thread()中),线程开始执行时会调用Thread类的run()方法。本章的以下部分解释了如何为线程定义自己的run()方法。

  • Thread类的run()方法检查Thread类的对象是如何创建的。如果 thread 对象是使用 Thread 类的无参数构造函数创建的,则它不做任何事情并立即返回。所以在你的程序中,当线程得到 CPU 时间的时候,调用了 thread 类的run()方法,这个方法没有执行任何有意义的代码,并返回。

  • 当 CPU 执行完run()方法,线程就死了,也就是说线程不会再获得 CPU 时间。

图 5-4 描述了最简单的线程示例是如何工作的。

img/323070_3_En_5_Fig4_HTML.jpg

图 5-4

最简单的线程执行

目前的讨论中有两个要点需要补充:

  • 当一个线程死了,并不意味着线程对象被垃圾收集。请注意,线程是一个执行单元。“线程死了”意味着该线程所代表的执行单元已经完成了它的工作。但是,表示执行单元的线程对象仍然存在于内存中。在线程死了之后,对象将根据用于任何其他 Java 对象的相同垃圾收集规则进行垃圾收集。存在一些限制,规定了可以在死线程上调用的方法。例如,您不能再次调用它的start()方法。也就是说,一个线程对象只能启动一次。但是,您仍然可以通过调用 thread 对象的isAlive()方法来检查线程是否死了。

  • 线程不会一次性获得 CPU 时间来执行run()方法。操作系统决定分配的时间量以及何时将时间分配给线程。这意味着在线程完成执行run()方法之前,可能会发生多次上下文切换。

为线程指定代码

有三种方法可以指定线程要执行的代码:

  • 通过从Thread类继承你的类

  • 通过在你的类中实现Runnable接口

  • 通过使用对不带参数并返回 void 的方法的方法引用

Note

如果你的类已经从另一个类继承,从Thread类继承你的类可能是不可能的。在这种情况下,您需要使用第二种方法。您可以使用 Java 8 中的第三种方法。在 Java 8 之前,通常使用匿名类来定义一个线程对象,其中匿名类要么从Thread类继承,要么实现Runnable接口。

Thread类继承你的类

当您从Thread类继承您的类时,您应该覆盖run()方法并提供由线程执行的代码:

public class MyThreadClass extends Thread {
    @Override
    public void run() {
        System.out.println("Hello Java threads!");
    }
    // More code goes here
}

创建线程对象和启动线程的步骤是相同的:

MyThreadClass myThread = new MyThreadClass();
myThread.start();

线程将执行MyThreadClass类的run()方法。

实现Runnable接口

您可以创建一个实现java.lang.Runnable接口的类。Runnable是一个功能接口,在java.lang包中声明如下:

@FunctionalInterface
public interface Runnable {
    void run();
}

Runnable的一个简单实现示例如下

public class HelloRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("Hello Java threads!");
    }
}

// Creating an instance:
Runnable aRunnableObject = new HelloRunnable();

相反,您也可以使用 lambda 表达式来创建Runnable接口的实例:

Runnable aRunnableObject = () ->
    System.out.println("Hello Java threads!");

使用接受Runnable对象的构造函数创建一个Thread类的对象:

Thread myThread = new Thread(aRunnableObject);

通过调用线程对象的start()方法启动线程:

myThread.start();

线程将执行 lambda 表达式主体中包含的代码。

使用方法引用

为了进一步提高简洁性,您可以使用不带参数的方法(静态或实例)的方法引用,并返回void作为线程要执行的代码。下面的代码声明了一个包含execute()方法的ThreadTest类。该方法包含要在线程中执行的代码:

public class ThreadTest {
    public static void execute() {
        System.out.println("Hello Java threads!");
    }
}

以下代码片段使用ThreadTest类的execute()方法的方法引用来创建一个Runnable对象:

Thread myThread = new Thread(ThreadTest::execute);
myThread.start();

线程将执行包含在ThreadTest类的execute()方法中的代码。

一个简单的例子

让我们看一个简单的例子,在一个新线程中打印从1500的整数。清单 5-3 包含了执行这个任务的PrinterThread类的代码。当该类运行时,它在标准输出上打印从1500的整数。

// PrinterThread.java
package com.jdojo.threads;
public class PrinterThread {
    public static void main(String[] args) {
        // Create a Thread object
        Thread t = new Thread(PrinterThread::print);
        // Start the thread
        t.start();
    }
    public static void print() {
        for (int i = 1; i <= 500; i++) {
            System.out.print(i + " ");
        }
    }
}

1 2 3 4 5 6 7 8 9 10 11 12 13 14  ... 497 498 499 500

Listing 5-3Printing Integers from 1 to 500 in a New Thread

在示例中,我使用了一个方法引用来创建线程对象。您可以使用前面讨论过的任何其他方法来创建线程对象。

在程序中使用多线程

在 Java 程序中使用多线程就像创建多个Thread对象并调用它们的start()方法一样简单。Java 对一个程序中可以使用的线程数量没有任何上限。它受到操作系统和程序可用内存的限制。清单 5-4 使用两个线程。两个线程都打印从1500的整数。代码在每个整数后打印一个新行。但是,输出会在每个整数后显示一个空格,以保持输出简短。仅显示了部分输出。

// MultiPrinterThread.java
package com.jdojo.threads;
public class MultiPrinterThread {
    public static void main(String[] args) {
        // Create two Thread objects
        Thread t1 = new Thread(MultiPrinterThread::print);
        Thread t2 = new Thread(MultiPrinterThread::print);
        // Start both threads
        t1.start();
        t2.start();
    }
    public static void print() {
        for (int i = 1; i <= 500; i++) {
            System.out.println(i);
        }
    }
}

1  2  3  4  5  1  2  3  4  5  6  7  8  9  10  11  12  13
14  15  16  17  18  19  20  21  22  23  24  25  26  6  7
27  28  8  9  10  11  12  29  30  31  13  14  32  15  16
17  ...  496  497  498  499  500  424  425 ... 492  493
494  495  496  497  498  499  500

Listing 5-4Running Multiple Threads in a Program

你会在输出中发现一些有趣的东西。每次运行这个程序,可能会得到不同的输出。但是,您计算机上的输出性质可以与此处显示的输出进行比较。在非常快的机器上,输出可能会打印出15001500。然而,让我们把注意力集中在假设您的输出与图中所示类似的讨论上。

程序创建了两个线程。每个线程打印从1500的整数。它首先启动线程t1,然后启动线程t2。你可能期望线程t1首先开始打印从1500的整数,然后线程t2开始打印从1500的整数。然而,从输出中可以明显看出,程序并没有按照您预期的方式运行。

Thread 类的start()方法立即返回。也就是说,当您调用线程的start()方法时,JVM 会注意到您启动线程的指令。但是,它不会立即启动线程。在真正启动线程之前,它必须做一些内务处理。当一个线程启动时,由操作系统决定何时以及给该线程多少 CPU 时间。因此,一旦t1.start()t2.start()方法返回,你的程序就进入了不确定领域。也就是说,两个线程都将开始运行;然而,你不知道它们什么时候开始运行,以什么顺序运行来执行它们的代码。当您启动多个线程时,您甚至不知道哪个线程将首先开始运行。查看输出,您可以观察到其中一个线程启动了,在被抢占之前,它获得了足够的 CPU 时间来打印从15的整数。另一个线程在被抢占之前获得了从126打印的 CPU 时间。第二次,第一个线程(最先开始打印整数的线程)得到了 CPU 时间,它只打印了两个整数,67,以此类推。您可以看到两个线程都占用了 CPU 时间。然而,CPU 时间的总量以及它们获得 CPU 时间的顺序是不可预测的。每次运行这个程序,您可能会得到不同的输出。您从这个程序中得到的唯一保证是,在1500之间的所有整数都将按照某种顺序打印两次。

使用多线程的问题

当你在一个程序中使用多线程时,会涉及到一些问题。只有当多个线程必须基于某些条件或某些共享资源进行协调时,才需要考虑这些问题。

在前面的章节中,涉及线程的例子是微不足道的。他们只是在标准输出上打印了一些整数。让我们看一个使用多线程的不同类型的例子,它访问和修改一个变量的值。清单 5-5 显示了BalanceUpdate类的代码。

// BalanceUpdate.java
package com.jdojo.threads;
public class BalanceUpdate {
    // Initialize balance to 100
    private static int balance = 100;
    public static void main(String[] args) {
        startBalanceUpdateThread();
        // <- Thread to update the balance value
        startBalanceMonitorThread();
        // <- Thread to monitor the balance value
    }
    public static void updateBalance() {
        // Add 10 to balance and subtract 10 from balance
        balance = balance + 10;
        balance = balance - 10;
    }
    public static void monitorBalance() {
        int b = balance;
        if (b != 100) {
            System.out.println("Balance changed: " + b);
            System.exit(0); // Exit the program
        }
    }
    public static void startBalanceUpdateThread() {
        // Start a new thread that calls the
        // updateBalance() method in an infinite loop
        Thread t = new Thread(() -> {
            while (true) {
                updateBalance();
            }
        });
        t.start();
    }
    public static void startBalanceMonitorThread() {
        // Start a thread that monitors the balance value
        Thread t = new Thread(() -> {
            while (true) {
                monitorBalance();
            }
        });
        t.start();
    }
}

Balance changed: 110

Listing 5-5Multiple Threads Modifying the Same Variable

该类每个组件的简要描述如下:

  • balance:类型为int的静态变量。它被初始化为100

  • updateBalance():是静态变量balance10,减去10的静态方法。该方法完成后,静态变量balance的值预计将保持与100相同。

  • startBalanceUpdateThread():它启动一个新线程,这个线程在无限循环中不断调用updateBalance()方法。也就是说,一旦你调用了这个方法,一个线程就会不断地将10加到余额变量中,并从中减去10

  • startBalanceMonitorThread():它启动一个新线程,通过重复调用monitorBalance()方法来监控静态变量balance的值。当线程检测到balance变量的值不是100时,它打印当前值并退出程序。

  • main():该方法用于运行程序。它启动一个线程,使用updateBalance()方法更新循环中的balance类变量。它还启动了另一个线程来监视balance类变量的值。

该程序由两个线程组成。一个线程调用updateBalance()方法,该方法将10加到balance上,并从中减去10。也就是说,在这个方法执行完毕后,balance变量的值应该保持不变。另一个线程监控balance变量的值。当它检测到balance变量的值不是100时,它打印新值并退出程序。在System.exit(0)方法调用中指定零表示您想正常终止程序。

直观上,余额监视器线程不应该打印任何东西,因为余额应该总是100,并且程序应该永远不会结束,因为两个线程都在使用无限循环。然而,事实并非如此。如果你运行这个程序,你会发现,在很短的时间内,程序会打印出100以外的余额值并退出。

假设在特定机器上,语句balance = balance + 10;被实现为以下机器指令,假设register-1为 CPU 寄存器:

register-1 = balance;
register-1 = register-1 + 10;
balance = register-1;

类似地,假设语句balance = balance - 10;被实现为以下机器指令,假设register-2是另一个 CPU 寄存器:

register-2 = balance;
register-2 = register-2 - 10;
balance = register-2;

当调用updateBalance()方法时,CPU 必须执行六条指令将10加到变量balance上,并从变量balance上减去10。当余额更新线程正在执行前三条指令中的任何一条时,余额监控线程将读取余额值作为100。当余额更新线程执行完第三条指令时,余额监控线程将读取其值110。只有当余额更新线程执行第六条指令时,余额变量的值110才会恢复为100。请注意,如果平衡监视器线程在平衡更新线程执行第三条指令之后和执行第六条指令之前的任何时候读取balance变量的值,它将读取与updateBalance()方法执行开始时存在的值不同的值。表 5-1 显示了两个线程将如何修改和读取balance变量的值。

表 5-1

多线程的指令执行

| 语句(假设余额值从 100 开始) | 余额更新线程正在执行的指令 | 由余额监视器线程读取的余额值 | | `balance = balance + 10;` | `register-1 = balance;``register-1 = register-1 + 10;``balance = register-1;` | `100``Before execution: 100``After execution: 110` | | `balance = balance - 10;` | `register-2 = balance;``register-2 = register-2 - 10;``balance = register-2;` | `110``110``Before execution: 110``After execution: 100` |

在您的程序中,监控线程能够将balance变量的值读取为110,因为您允许两个线程同时修改和读取 balance 变量的值。如果一次只允许一个线程处理(修改或读取)balance变量,那么除了100之外,余额监视器线程将永远不会读取余额变量的值。

多个线程同时操作和访问一个共享数据,其结果取决于线程执行的顺序,这种情况称为竞争情况。程序中的竞争条件可能会导致不可预知的结果。清单 5-5 是一个竞争条件的例子,其中程序输出依赖于两个线程的执行顺序。

为了避免程序中的竞争情况,您需要确保一次只有一个竞争线程处理共享数据。为了解决这个问题,您需要同步对BalanceUpdate类的两个方法updateBalance()monitorBalance()的访问。也就是说,一次只能有一个线程访问这两个方法中的一个。换句话说,如果一个线程正在执行updateBalance()方法,另一个想要执行monitorBalance()方法的线程必须等到执行updateBalance()方法的线程完成。类似地,如果一个线程正在执行monitorBalance()方法,另一个想要执行updateBalance()方法的线程必须等到执行monitorBalance()方法的线程完成。这将确保当一个线程正在更新balance变量时,没有其他线程会读取balance变量的不一致值,并且如果一个线程正在读取balance变量,没有其他线程会同时更新balance变量。

这种需要同步多个线程对 Java 程序中一段代码的访问的问题可以使用synchronized关键字来解决。为了理解关键字synchronized的用法,我需要简单讨论一下 Java 内存模型和对象的锁和等待集。

Java 内存模型

程序中的所有程序变量(实例字段、静态字段和数组元素)都从计算机的主内存中分配内存。每个线程都有一个工作内存(处理器缓存或寄存器)。Java 内存模型(JMM)描述了程序变量如何、何时以及以何种顺序存储到主内存中,以及从主内存中读取。Java 语言规范中详细描述了 JMM。你可以想象 JMM,如图 5-5 所示。

img/323070_3_En_5_Fig5_HTML.jpg

图 5-5

Java 内存模型

图 5-5 显示两个线程共享主内存。假设您有一个运行两个线程的 Java 程序,thread-1thread-2,每个线程运行在不同的处理器上。假设thread-1在其工作内存中读取object-1的一个实例变量的值,更新该值,并且不将更新后的值写回主内存。让我们来看几个可能的场景:

  • 如果thread-2试图从主存中读取object-1的同一个实例变量的值会怎么样?thread-2能从主存储器中读取旧值吗,或者它能从thread-1的工作存储器中读取更新值吗?

  • 假设thread-1正在将更新后的值写入主存储器,同时thread-2正在尝试从主存储器读取相同的值。thread-2会因为值没有完全写回主存而从主存中读取旧值或一些垃圾值吗?

JMM 回答了所有这些问题。本质上,JMM 描述了 Java 程序中指令执行的三个重要方面。它们如下:

  • 原子数

  • 能见度

  • 排序

原子数

JMM 描述了应该自动执行的动作。它描述了关于实例变量、静态变量和数组元素的读写操作的原子性规则。它保证对除了longdouble之外的任何类型的对象字段的读写总是原子的。然而,如果类型为longdouble的字段被声明为volatile(我将在本章后面详细讨论volatile关键字),那么对该字段的读写也保证是原子的。

能见度

JMM 描述了一个线程中的动作产生的效果对其他线程可见的条件。主要是,它决定一个线程何时向一个字段写入一个值,该字段的新值何时对其他线程可见。在本章后面讨论锁、同步和可变变量时,我会详细讨论 JMM 的可见性。为了完整起见,以下是一些可见性规则:

  • 当一个线程第一次读取一个字段的值时,它将读取该字段的初始值或者由其他线程写入该字段的某个值。

  • 对易失性变量的写入总是被写入主存储器。

  • 对可变变量的读取总是从主存储器中读取。也就是说,volatile 变量永远不会缓存在线程的工作内存中。实际上,对 volatile 变量的任何写入都会被刷新到主内存中,从而立即使新值对其他线程可见。

  • 当一个线程终止时,该线程的工作内存被立即写入主内存。也就是说,在线程终止后,所有仅对被终止的线程可见的变量值对所有线程都可见。

  • 当一个线程进入一个synchronized块时,该线程重新加载其工作内存中所有变量的值。当一个线程离开一个synchronized块时,它将所有变量的值从它的工作内存写到主内存。

排序

JMM 描述了线程内和线程间执行动作的顺序。它保证在一个线程中执行的所有操作都是有序的。不同线程中的操作不能保证以任何顺序执行。通过使用本章后面介绍的同步技术,您可以在处理多线程时实现一定的排序。

Note

Java 程序中的每个线程都使用两种内存:工作内存和主内存。一个线程不能访问另一个线程的工作内存。主内存由线程共享。线程使用主内存相互通信。每个线程都有自己的堆栈,用来存储局部变量。

对象的监视器和线程同步

在多线程程序中,如果由多个线程同时执行,可能会对程序的结果产生不良影响的代码段称为临界段。通常,不良影响是由临界区中的多个线程同时使用一个资源引起的。有必要控制对程序中临界区的访问,以便一次只有一个线程可以执行临界区。

在 Java 程序中,临界区可以是一个语句块或一个方法。Java 没有内置的机制来识别程序中的关键部分。然而,Java 有许多内置的构造,允许程序员声明一个临界区,并控制和协调对它的访问。程序员的责任是识别程序中的关键部分,并控制多线程对这些关键部分的访问。控制和协调多个线程对临界区的访问被称为线程同步。编写多线程程序时,线程同步总是一项具有挑战性的任务。在清单 5-5 中,updateBalance()monitorBalance()方法是关键部分,您必须同步线程对这两个方法的访问以获得一致的输出。Java 编程语言内置了两种线程同步:

  • 互斥同步

  • 条件同步

在互斥同步中,在某个时间点,只允许一个线程访问一段代码。清单 5-5 是一个需要互斥同步的程序的例子,这样在一个时间点只有一个线程可以执行updateBalance()monitorBalance()。在这种情况下,你可以把互斥看作是一个线程对balance变量的独占访问。

有条件的同步允许多个线程一起工作来实现一个结果。例如,考虑一个多线程程序来解决生产者/消费者问题。程序中有两个线程:一个线程产生数据(生产者线程),另一个线程消耗数据(消费者线程)。消费者线程必须等待,直到生产者线程产生数据并使其可供消费。生产者线程必须在产生数据时通知消费者线程,以便消费者线程可以消费它。换句话说,生产者线程和消费者线程必须相互协调/合作来完成任务。在条件同步期间,可能还需要互斥同步。假设生产者线程一次产生一个字节的数据,并将数据放入一个容量也是一个字节的缓冲区。消费者线程从同一个缓冲区消费数据。在这种情况下,一次应该只有一个线程可以访问缓冲区(互斥)。如果缓冲区已满,生产者线程必须等待消费者线程清空缓冲区;如果缓冲区为空,消费者线程必须等待生产者线程产生一个字节的数据,并将其放入缓冲区(条件同步)。

互斥同步是通过锁实现的。锁支持两种操作:获取和释放。想要独占访问资源的线程必须获得与该资源相关联的锁。只要一个线程拥有资源的锁,其他线程就不能获得相同的锁。一旦拥有锁的线程使用完资源,它就会释放锁,以便另一个线程可以获取它。

条件同步是通过条件变量和三个操作实现的:等待、信号和广播。条件变量定义线程同步的条件。等待操作使线程等待某个条件变为真,这样它才能继续执行。信号操作唤醒了一个等待条件变量的线程。广播操作唤醒所有等待条件变量的线程。注意,信号操作和广播操作的区别在于,前者只唤醒一个等待线程,而后者唤醒所有等待线程。

监视器是一种编程结构,它有一个锁、条件变量和与之相关的操作。Java 程序中的线程同步是使用监视器实现的。Java 程序中的每个对象都有一个相关的监视器。

Java 程序中的临界区是相对于对象的监视器来定义的。线程必须先获得对象的监视器,然后才能开始执行声明为临界区的代码。synchronized关键字用于声明一个临界区。有两种方法可以使用synchronized关键字:

  • 将方法声明为临界区

  • 将语句块声明为临界区

您可以通过在方法的返回类型前使用关键字synchronized将方法声明为临界区,如下所示:

public class CriticalSection {
    public synchronized void someMethod_1() {
        // Method code goes here
    }
    public static synchronized void someMethod_2() {
        // Method code goes here
    }
}

Note

您可以将实例方法和静态方法都声明为synchronized。构造函数不能声明为 synchronized。一个构造函数只被一个线程调用一次,这个线程正在创建对象。因此同步对构造函数的访问是没有意义的。

在同步实例方法的情况下,整个方法是一个关键部分,它与为其执行该方法的对象的监视器相关联。也就是说,在执行该对象的同步实例方法内部的代码之前,线程必须获取该对象的监视器锁。例如:

// Create an object called cs1
CriticalSection cs1 = new CriticalSection();
// Execute the synchronized instance method. Before this
// method execution starts, the thread that is executing
// this statement must acquire the monitor lock of the cs1
// object
cs1.someMethod_1();

在静态方法的情况下,整个方法是一个关键部分,它与代表那个类的类对象相关联。也就是说,在执行该类的同步静态方法中的代码之前,线程必须获得类对象的监视器锁。例如:

// Execute the synchronized static method. Before this
// method execution starts, the thread that is executing
// this statement must acquire the monitor lock of the
// CriticalSection.class object
CriticalSection.someMethod_2();

将代码块声明为临界区的语法如下:

synchronized(<objectReference>) {
    // one or more statements of the critical section
}

<objectReference>是对象的引用,其监控锁将用于同步对临界区的访问。该语法用于将方法体的一部分定义为临界区。这样,一个线程只需要获得对象的监视器锁,同时执行方法代码的一小部分,它被声明为临界区。

其他线程仍然可以并发执行方法体的其他部分。此外,这种声明临界区的方法允许您将部分或整个构造函数声明为临界区。回想一下,您不能在构造函数的声明部分使用关键字synchronized。但是,您可以在构造函数体中使用它来声明代码块是同步的。以下代码片段说明了关键字synchronized的用法:

public class CriticalSection2 {
    public synchronized void someMethod10() {
        // Method code goes here. Only one thread can
        // execute here at a time.
    }
    public void someMethod11() {
        synchronized(this) {
            // Method code goes here. Only one thread
            // can execute here at a time.
        }
    }
    public void someMethod12() {
        // Some statements go here. Multiple threads can
        // execute here at a time.
        synchronized(this) {
            // Some statements go here. Only one thread
            // can execute here at a time.
        }
        // Some statements go here. Multiple threads can
        // execute here at a time.
    }
    public static synchronized void someMethod20() {
        // Method code goes here. Only one thread can
        // execute here at a time.
    }
    public static void someMethod21() {
        synchronized(CriticalSection2.class) {
            // Method code goes here. Only one thread can
            // execute here at a time.
        }
    }
    public static void someMethod_22() {
        // Some statements go here: section_1\. Multiple
        // threads can execute here at a time.
        synchronized(CriticalSection2.class) {
            // Some statements go here: section_2\. Only
            // one thread can execute here at a time.
        }
        // Some statements go here: section_3\.  Multiple
        // threads can execute here at a time
    }
}

CriticalSection2类有六个方法:三个实例方法和三个类方法。someMethod10()方法是同步的,因为在方法声明中使用了synchronized关键字。someMethod11()方法与someMethod10()方法的不同之处仅在于它使用了synchronized关键字。它将整个方法体作为一个块放在关键字synchronized中,这实际上与声明方法synchronized的效果相同。方法someMethod12()不同。它只将方法体的一部分声明为synchronized块。可以有多个线程同时执行someMethod12()。然而,在一个时间点上,它们中只有一个可以在synchronized块中执行。其他方法— someMethod20()someMethod21()someMethod22()—是类方法,它们的行为方式相同,只是将使用类的对象监视器来实现线程同步。

获取和释放对象的监视器锁的过程由 JVM 处理。您唯一需要做的就是将一个方法(或者一个块)声明为synchronized。在进入一个synchronized方法或块之前,线程获取对象的监视器锁。在退出 synchronized 方法或块时,它会释放对象的监视器锁。已经获得对象的监视器锁的线程可以根据需要多次获得它。但是,为了让另一个线程获得同一个对象的监控锁,它必须释放该对象的监控锁,释放的次数与它获得的次数一样多。让我们考虑下面这个MultiLocks类的代码:

public class MultiLocks {
    public synchronized void method1() {
        // Some statements go here
        this.method2();
        // Some statements go here
    }
    public synchronized void method2() {
        // Some statements go here
    }
    public static synchronized void method3() {
        // Some statements go here
        MultiLocks.method4();
        // Some statements go here
    }
    public static synchronized void method4() {
        // Some statements go here
    }
}

MultiLocks类有四个方法,它们都是同步的。其中两个是实例方法,它们使用将对其进行方法调用的对象的引用进行同步。其中两个是类方法,使用MultiLocks类的类对象的引用进行同步。如果一个线程想要执行method1()method2(),它必须首先获取调用该方法的对象的监控锁。你正在从方法method1()内部调用method2()。由于正在执行method1()的线程必须已经获取了对象的监控锁,并且对method2()的调用需要获取相同的锁,所以当线程从method1()内部执行method2()时,它将自动重新获取相同对象的监控锁,而不会与其他线程竞争获取对象的监控锁。

因此,当一个线程从method1()内部执行method2()时,它将两次获得对象的监控锁。当它退出method2()时,它会解锁一次;当它退出method1()时,会第二次解锁;然后该对象的监控锁将可供其他线程获取。同样的论点也适用于从method3()内部对method4()的调用,除了在这种情况下,MultiLocks类对象的监视器锁参与了同步。考虑从method1()调用method3(),就像这样:

public class MultiLocks {
    public synchronized void method1() {
        // Some statements go here
        this.method2();
        MultiLocks.method3();
        // Some statements go here
    }
    // Rest of the code remains the same as shown before
}

假设你调用method1(),像这样:

MultiLocks ml = new MultiLocks();
ml.method1();

ml.method1()被执行时,执行线程必须获取对象ml的监控锁。然而,执行线程必须获得MultiLocks.class对象的监控锁才能执行MultiLocks.method3()方法。注意mlMultiLocks.class是两个不同的对象。想要从method1()方法执行MultiLocks.method3()方法的线程必须同时拥有两个对象的监控锁。

您可以应用相同的参数来处理synchronized块。例如,您可以有如下代码片段:

synchronized (objectReference) {
    // Trying to synchronize again on the same object is ok
    synchronized(objectReference) {
        // Some statements go here
    }
}

是时候使用对象的监视器更深入地了解线程同步的工作方式了。图 5-6 描述了多线程如何使用一个对象的监视器。

img/323070_3_En_5_Fig6_HTML.jpg

图 5-6

使用对象监视器的多线程

在讨论线程同步时,我使用了一个医患类比。假设一个医生有一个诊所给病人看病。我们知道,一次只允许一名患者看医生是非常重要的。否则,医生可能会把一个病人的症状和另一个病人的症状混淆起来;一个发烧的病人可能会得到一个头痛的处方!因此,我们将假设在任何时间点只有一个患者可以接触到医生。同样的假设是,一次只有一个线程(病人)可以访问一个对象的监视器(医生)。

任何想看医生的病人都必须签到并在候诊室等候。类似地,每个对象监视器都有一个条目集(等待新人的空间),任何想要获取对象的监视器锁的线程都必须首先进入该条目集。如果患者签到,他们可以立即接触到医生,如果医生不治疗患者并且没有患者在候诊室中等待轮到他们。类似地,如果一个对象的监视器的入口集为空,并且没有其他线程拥有该对象的监视器锁,则进入该入口集的线程会立即获得该对象的监视器锁。但是,如果有病人在候诊室等待,或者有一个病人正在接受医生的治疗,则签到的病人将被阻止,必须等待医生再次有空。类似地,如果一个线程进入了条目集合,而其他线程已经被阻塞在该条目集合中,或者另一个线程已经拥有了该对象的监视器锁,则刚刚登录的线程被认为被阻塞,必须在该条目集合中等待。

进入条目集合的线程由标记为Enter的箭头示出。螺纹本身在图 5-6 中用圆圈表示。带有文本B的圆圈表示在条目集中被阻塞的线程。带有文本R的圆圈表示一个已经获得对象监视器的线程。

条目集中被阻塞的线程会发生什么情况?他们什么时候有机会获得目标的监视器?你可以想想那些被堵在候诊室的病人,然后轮到他们接受医生的治疗。许多因素决定了下一个要治疗的病人。首先,接受治疗的病人必须在另一个病人可以接触到医生之前释放医生。在 Java 中,拥有对象监视器所有权的线程必须释放对象的监视器,然后条目集中被阻塞的任何线程才能拥有对象监视器的所有权。患者可能出于以下两种原因之一释放医生:

  • 病人完成了治疗,准备回家。这是一个病人在治疗结束后释放医生的简单例子。

  • 一个病人正在接受治疗。然而,他们必须等待一段时间,以便医生恢复他们的治疗。让我们假设诊所有一个专门的候诊室(与刚刚签名等候的病人的候诊室分开),用于那些正在接受治疗的病人。这个案子需要一些解释。假设医生是眼科专家,在他们诊所有一些病人。正在接受治疗的病人需要进行眼科检查,为此他们必须先放大瞳孔。患者在接受眼药水后大约需要 30 分钟来进行全面的瞳孔扩张,这是检查所需要的。医生应该等 30 分钟让病人的瞳孔放大吗?这个病人是否应该释放医生 30 分钟,让其他病人接触到医生?你会同意,如果医生的时间可以用来治疗其他病人,而这个病人的瞳孔正在扩大,这个病人可以让医生出院。然而,当这个病人的瞳孔放大,而医生还在忙着治疗另一个病人时,该怎么办呢?医生不能在治疗过程中离开任何病人。因此,释放医生并等待某个条件为真的患者(这里是要完成的扩张过程)必须等到医生再次空闲。我将在本章后面详细解释这个问题,并尝试将这种情况与线程和对象的监控锁联系起来。

我必须在医生-病人示例的上下文中讨论另一个问题,然后才能将它与 monitor-threads 的情况进行比较。当医生有空,只有一个病人在等着见他们时,就没有问题了。等待医生的唯一的病人将会立即得到他们。然而,当医生变得有空,并且有不止一个病人等待访问他们时,会发生什么呢?等待中的哪个病人应该先去看医生?应该是病人先来(先进先出还是先进先出)?应该是最后来的病人(后进先出还是后进先出)?应该是病人需要最少(或最多)的治疗时间吗?病情最严重的应该是病人吗?答案取决于诊所管理层遵循的政策。

与医生-病人示例中的病人相似,线程也可以释放对象的监视器锁,原因有两个:

  • 此时,线程已经完成了它已经获得对象的监视器锁的工作。图 5-6 中标有“释放和退出”的箭头表示图中的这种情况。当一个线程简单地退出一个同步的方法/块时,它释放它已经获得的对象的监视器锁。

  • 该线程正在执行一项任务,它需要等待某个条件为真才能完成剩余的任务。让我们考虑生产者/消费者问题。假设生产者获得了缓冲区对象的监视器锁,并希望将一些数据写入缓冲区。但是,它发现缓冲区已满,消费者必须先消费数据并清空缓冲区,然后才能写入数据。在这种情况下,生产者必须释放缓冲区对象的监视器锁,并等待,直到消费者获得锁并清空缓冲区。当消费者获得缓冲区的监视器锁并发现缓冲区为空时,同样的逻辑也适用于消费者。这时,消费者必须释放锁,并等待生产者产生一些数据。这种暂时释放对象的监视器锁并等待某个条件发生的方式在标有“释放并等待”箭头的图中显示。一个对象可以有多个线程,这些线程可以同时处于“释放并等待”状态。所有已经释放了对象的监视器锁并正在等待某些条件发生的线程都被放在一个称为等待集的集合中。

一个线程如何被放置在等待集中?请注意,只有当线程获得了对象的监视器锁时,它才能被放入对象监视器的等待集中。一旦线程获得了对象的监视器锁,它必须调用对象的 wait()方法,以便将自己放入等待集。这意味着线程必须总是从同步方法或块内部调用 wait()方法。wait()方法在 java.lang.Object 类中定义,声明为 final 也就是说,Java 中没有其他类可以覆盖这个方法。在调用对象的 wait()方法之前,必须考虑以下两条规则。

规则 1

wait()方法的调用必须放在synchronized方法(静态或非静态)或synchronized块中。

规则二

必须在当前线程已经获得其监视器的对象上调用wait()方法。它抛出一个java.lang.InterruptedException。调用此方法的代码必须处理此异常。当当前线程不是对象监视器的所有者时,wait()方法抛出一个IllegalMonitorStateException。下面的代码片段没有将wait()方法调用放在 try-catch 中,以保持代码简单易读。例如,在一个synchronized非静态方法中,对wait()方法的调用可能如下所示:

public class WaitMethodCall {
    // Object that is used to synchronize a block
    private Object objectRef = new Object();
    public synchronized void someMethod_1() {
        // The thread running here has already acquired
        // the monitor lock on the object represented by
        // the reference this because it is a
        // synchronized non-static method

        // other statements go here
        while (some condition is true) {
            // It is ok to call the wait() method on this,
            // because the current thread possesses
            // monitor lock on this
            this.wait();
        }
        // other statements go here
    }
    public static synchronized void someMethod_2() {
        // The thread executing here has already acquired
        // the monitor lock on the class object represented
        // by the WaitMethodCall.class reference because it
        // is a synchronized static method

        while (some condition is true) {
            // It is ok to call the wait() method on
            // WaitMethodCall.class because the current
            // thread possesses monitor lock on
            // WaitMethodCall.class object
            WaitMethodCall.class.wait();
        }
        // other statements go here
    }

    public void someMethod_3() {
        // other statements go here
        synchronized(objectRef) {
            // Current thread possesses monitor lock of
            // objectRef
            while (some condition is true) {
                // It is ok to call the wait() method on
                // objectRef because the current thread
                // possesses monitor lock on objectRef
                objectRef.wait();
            }
        }
        // other statements go here
    }
}

注意objectRef是一个实例变量,它的类型是java.lang.Object。它唯一的用途是在someMethod_3()方法中同步线程对块的访问。因为它被声明为实例变量,所有调用someMethod_3()的线程将使用它的监视器来执行synchronized块。初学者常犯的一个错误是将objectRef声明为方法内部的局部变量,并在synchronized块中使用。以下代码片段显示了这样一个错误:

public void wrongSynchronizationMethod {
    // This objectRef is created every time a thread calls
    // this method
    Object objectRef = new Object();
    // It is a blunder to use objectRef for
    // synchronization below
    synchronized(objectRef) {
        // In fact, this block works as if there is no
        // synchronization, because every thread  creates a
        // new objectRef and acquires its monitor lock
        // immediately.
    }
}

记住这段代码,您必须使用一个所有线程通用的对象引用来同步对块的访问。

让我们回到这样一个问题,当他们再次有空的时候,哪一个病人将能够接触到医生。是登记后在候诊室等候的病人,还是在治疗过程中在另一个候诊室等候的病人?在你回答这个问题之前,让我们弄清楚在登记后在等候室等候的患者和在另一个等候室等候某种情况(例如,扩张完成)发生的患者之间是有区别的。在签到之后,病人等待医生的空闲,而在治疗过程中的病人等待特定情况的发生。对于第二类患者来说,在他们能够寻求看医生之前,必须满足特定的条件,而第一类患者则准备尽快地看医生。因此,必须有人通知第二类患者发生了特定情况,是他们再次寻求医生帮助以继续治疗的时候了。让我们假设这个通知必须来自于医生当前正在治疗的患者。也就是说,当前可以接触到医生的患者通知在治疗过程中等待的患者准备好再次接触医生。请注意,这只是一个通知,说明某些情况已经发生,并且只发送给正在等待治疗的患者。不能保证正在接受治疗的患者在当前患者与医生完成治疗后是否能立即接触到医生。它仅保证在通知时患者正在等待的条件保持不变,并且等待的患者可以尝试接触医生以继续他们的治疗。让我们将这个例子与 monitor-threads 例子联系起来。

条目集中的线程被阻塞,它们准备好尽快获取对监视器的访问。等待集中的线程正在等待某个条件发生。拥有监视器所有权的线程必须通知在等待集中等待的线程,它们正在等待的条件已经满足。在 Java 中,通知是通过调用Object类的notify()notifyAll()方法来实现的。像wait()方法一样,notify()notifyAll()方法也被声明为final。像wait()方法一样,这两个方法必须由线程使用一个对象来调用,该对象的监视器已经被线程获取。如果一个线程在获取对象的监视器之前调用这些方法,就会抛出一个IllegalMonitorStateException。对notify()方法的调用从等待集中唤醒一个线程,而对notifyAll()方法的调用唤醒等待集中的所有线程。在notify()方法调用的情况下,被唤醒的线程是任意选择的。注意,当线程调用notify()notifyAll()方法时,它仍然持有对象监视器上的锁。等待集中的线程仅由notify()notifyAll()调用唤醒。它们不会立即获得对象的监视器锁。当调用notify()notifyAll()方法的线程通过“释放并退出”或“释放并等待”释放对象的监视器锁时,等待集中被唤醒的线程与条目集中的线程竞争再次获取对象的监视器。因此,对notify()notifyAll()的调用仅作为对等待集中线程的唤醒调用,并不保证对对象监视器的访问。

Note

没有办法唤醒等待集中的特定线程。对notify()的调用任意选择一个线程,而对notifyAll()的调用唤醒所有线程。当你不确定用哪种方法时,就用notifyAll()

下面的代码片段显示了使用notifyAll()方法和wait()方法的伪代码。您可能会注意到对wait()notify()方法的调用是在同一个对象上进行的,因为如果objectRef.wait()将一个线程放入objectRef对象的等待集中,objectRef.notify()objectRef.notifyAll()方法将从objectRef对象的等待集中唤醒该线程:

public class WaitAndNotifyMethodCall {
    private Object objectRef = new Object();
    public synchronized void someMethod_1() {
        while (some condition is true) {
            this.wait();
        }
        if (some other condition is true) {
            // Notify all waiting threads
            this.notifyAll();
        }
    }
    public static synchronized void someMethod_2() {
        while (some condition is true) {
            WaitAndNotifyMethodCall.class.wait();
        }
        if (some other condition is true) {
            // Notify all waiting threads
            WaitAndNotifyMethodCall.class.notifyAll();
        }
    }

    public void someMethod_3() {
        synchronized(objectRef) {
            while (some condition is true) {
                objectRef.wait();
            }
            if (some other condition is true) {
                // Notify all waiting threads
                objectRef.notifyAll();
            }
        }
    }
}

一旦一个线程在等待集合中被唤醒,它必须与条目集合中的线程竞争来获取对象的监视器锁。当一个线程在等待集中被唤醒并获得对象的监控锁后,它有两种选择:做一些工作并通过再次调用wait()方法(释放并等待)来释放锁,或者通过退出同步部分(释放并退出)来释放锁。关于对wait()方法的调用,需要记住的重要一点是,通常对wait()方法的调用是放在一个循环中的。这就是为什么有必要这样做的原因。一个线程寻找一个条件来保持。它通过调用wait()方法进行等待,如果条件不成立,就将自己放入等待集。当被另一个线程通知时,该线程被唤醒,该线程调用notify()notifyAll()方法。当被唤醒的线程获得锁时,通知时保持的条件可能不再保持。因此,当线程醒来并获得锁时,有必要再次检查条件,以确保它所寻找的条件为真,并且它可以继续工作。例如,考虑生产者/消费者问题。假设有一个生产者和许多消费者。假设消费者如下调用wait()方法:

if (buffer is empty) {
    buffer.wait();
}
buffer.consume();

假设缓冲区是空的,所有的消费者都在等待集合中等待。生产者产生一些数据,它调用buffer.notifyAll()方法来唤醒等待集中的所有消费者线程。所有消费者线程都被唤醒;然而,接下来只有一个人有机会获得监视器锁。第一个获取锁并执行buffer.consume()方法来清空缓冲区。当下一个消费者获得监视器锁时,它也将执行buffer.consume()语句。然而,在这个用户之前醒来并获得锁的用户已经清空了缓冲区。前面代码片段中的逻辑错误是对wait()方法的调用被放在了一个if语句中,而不是一个循环中。也就是说,在一个线程醒来后,在尝试使用数据之前,它不会检查缓冲区是否包含一些数据。更正后的代码片段如下:

while (buffer is empty) {
    buffer.wait();
}
buffer.consume();

在你看到这个关于线程同步的大讨论之前,我再回答一个问题。问题是,“当入口集中有一些被阻塞的线程,等待集中有一些被唤醒的线程时,哪个线程有机会获得对象的监视器锁?”注意,等待集中的线程不会竞争对象的监视器,直到它们被notify()notifyAll()调用唤醒。这个问题的答案是,这取决于操作系统的调度程序的算法。

清单 5-6 包含了BalanceUpdateSynchronized类的代码,它是清单 5-5 中列出的BalanceUpdate类的修改版本。这两个类之间唯一的区别是使用了synchronized关键字来声明新类中的updateBalance()monitorBalance()方法,因此一次只有一个线程可以进入其中一个方法。当您运行新类时,您将看不到任何输出,因为除了100之外,monitorBalance()方法将永远看不到balance变量的值。您需要手动终止程序,例如,在 Windows 上使用Ctrl+C

// BalanceUpdateSynchronized.java
package com.jdojo.threads;
public class BalanceUpdateSynchronized {
    // Initialize balance to 100
    private static int balance = 100;
    public static void main(String[] args) {
        startBalanceUpdateThread();
        // <- Thread to update the balance value
        startBalanceMonitorThread();
        // <- Thread to monitor the balance value
    }
    public static synchronized void updateBalance() {
        // Add 10 to balance and subtract 10 from balance
        balance = balance + 10;
        balance = balance - 10;
    }

    public static synchronized void monitorBalance() {
        int b = balance;
        if (b != 100) {
            System.out.println("Balance changed: " + b);
            System.exit(1); // Exit the program
        }
    }
    public static void startBalanceUpdateThread() {
        // Start a new thread that calls the
        // updateBalance() method in an infinite loop
        Thread t = new Thread(() -> {
            while (true) {
                updateBalance();
            }
        });
        t.start();
    }

    public static void startBalanceMonitorThread() {
        // Start a thread that monitors the balance value
        Thread t = new Thread(() -> {
            while (true) {
                monitorBalance();
            }
        });

        t.start();
    }
}

Listing 5-6Synchronized Balance Update

我将在下一节展示使用wait()notify()方法的例子,这一节将讨论生产者/消费者问题。Object类中的wait()方法被重载,它有三个版本:

  • wait():线程在对象的等待集合中等待,直到另一个线程在同一个对象上调用notify()notifyAll()方法。

  • wait(long timeinMillis):线程在对象的等待集合中等待,直到另一个线程对同一个对象调用notify()notifyAll()方法,或者经过了指定的timeinMillis时间。

  • 这个版本允许你用毫秒和纳秒来指定时间。

生产者/消费者同步问题

生产者/消费者是一个典型的使用wait()notify()方法的线程同步问题。我保持简单。

问题陈述是这样的。有四个等级:BufferProducerConsumerProducerConsumerTest。一个Buffer类的对象将有一个整数数据元素,它将由生产者生产,由消费者消费。因此,在这个例子中,Buffer对象在某个时间点只能保存一个整数。您的目标是同步对缓冲区的访问,因此只有当Buffer为空时Producer才会产生一个新的数据元素,而Consumer只有在缓冲区的数据可用时才会使用它。ProducerConsumerTest类用于测试程序。

清单 5-7 到 5-10 包含四个类的代码。

// ProducerConsumerTest.java
package com.jdojo.threads;
public class ProducerConsumerTest {
    public static void main(String[] args) {
        // Create Buffer, Producer and Consumer objects
        Buffer buffer = new Buffer();
        Producer p = new Producer(buffer);
        Consumer c = new Consumer(buffer);
        // Start the producer and consumer threads
        p.start();
        c.start();
    }
}
Produced: 1872733184
Consumed: 1872733184
...

Listing 5-10A ProducerConsumerTest Class

to Test the Producer/Consumer Synchronization

// Consumer.java
package com.jdojo.threads;
public class Consumer extends Thread {
    private final Buffer buffer;
    public Consumer(Buffer buffer) {
        this.buffer = buffer;
    }
    @Override
    public void run() {
        int data;
        while (true) {
            // Consume the data from the buffer. We are
            // not using the consumed data for any other
            // purpose here
            data = buffer.consume();
        }
    }
}

Listing 5-9A Consumer Class

for Producer/Consumer Synchronization

// Producer.java
package com.jdojo.threads;
import java.util.Random;
public class Producer extends Thread {
    private final Buffer buffer;
    public Producer(Buffer buffer) {
        this.buffer = buffer;
    }
    @Override
    public void run() {
        Random rand = new Random();
        while (true) {
            // Generate a random integer and store it in
            // the buffer
            int n = rand.nextInt();
            buffer.produce(n);
        }
    }
}

Listing 5-8A Producer Class

for Producer/Consumer Synchronization

// Buffer.java
package com.jdojo.threads;
public class Buffer {
    private int data;
    private boolean empty;

    public Buffer() {
        this.empty = true;
    }
    public synchronized void produce(int newData) {
        // Wait until the buffer is empty
        while (!this.empty) {
            try {
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // Store the new data produced by the producer
        this.data = newData;
        // Set the empty flag to false, so the consumer
        // may consume the data
        this.empty = false;
        // Notify the waiting consumer in the wait set
        this.notify();
        System.out.println("Produced: " + newData);
    }
    public synchronized int consume() {
        // Wait until the buffer gets some data
        while (this.empty) {
            try {
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // Set the empty flag to true, so that the
        // producer can store new data
        this.empty = true;
        // Notify the waiting producer in the wait set
        this.notify();
        System.out.println("Consumed: " + data);
        return data;
    }
}

Listing 5-7A Buffer Class

for Producer/Consumer Synchronization

当您运行ProducerConsumerTest类时,您可能会得到不同的输出。然而,您的输出看起来将是相似的,因为打印的两行将始终是以下形式,其中XXX表示一个整数:

Produced: XXX
Consumed: XXX

在这个例子中,Buffer类需要一些解释。它有两个实例变量:

  • private int data

  • private boolean empty

生产者使用data实例变量来存储新数据。消费者阅读它。empty实例变量被用作缓冲区是否为空的指示器。在构造函数中,它被初始化为true,表示新的缓冲区是空的。

它有两个同步方法:produce()consume()。这两个方法都被声明为synchronized,因为目标是保护被多个线程同时使用的Buffer对象。如果生产者通过调用produce()方法产生新的数据,消费者必须等待直到生产者完成消费数据,反之亦然。生产者线程调用produce()方法,将新生成的数据传递给它。然而,在新数据存储到data实例变量之前,生产者确保缓冲区是空的。如果缓冲区不为空,它调用this.wait()方法将自己放入缓冲区对象的等待集,直到消费者使用consume()方法中的this.notify()方法通知它。

一旦生产者线程检测到缓冲区为空,它就将新数据存储在data实例变量中,将empty标志设置为假,并调用this.notify()来唤醒等待集中的消费者线程来消费数据。最后,它还在控制台上打印一条消息,表明数据已经生成。

Buffer类的consume()方法与其对应的produce()方法相似。唯一的区别是消费者线程调用这个方法,它执行与produce()方法相反的逻辑。例如,它在使用数据之前检查缓冲区是否为空。

ProducerConsumer类继承自Thread类。它们覆盖了Thread类的run()方法。它们都在构造函数中接受了一个Buffer类的对象,以便在它们的run()方法中使用它。Producer类在无限循环中的run()方法中生成一个随机整数,并不断将其写入缓冲区。Consumer类在无限循环中不断消耗缓冲区中的数据。

ProducerConsumerTest类创建所有三个对象(一个缓冲区、一个生产者和一个消费者),并启动生产者和消费者线程。因为两个类(ProducerConsumer)都在run()方法中使用无限循环,所以如果你从 Windows 命令提示符下运行这个程序,你必须强制终止程序,比如按下Ctrl+C

哪个线程正在执行?

Thread类有一些有用的静态方法;其中之一就是currentThread()法。它返回调用这个方法的Thread对象的引用。请考虑以下陈述:

Thread t = Thread.currentThread();

该语句会将执行该语句的线程对象的引用赋给变量t。请注意,Java 中的语句可以在程序执行期间的不同时间点由不同的线程执行。因此,当该语句在同一程序中的不同时间执行时,t可能被赋予不同的Thread对象的引用。清单 5-11 展示了currentThread()方法的使用。您可能会在输出中得到相同的文本,但是顺序不同。

// CurrentThread.java
package com.jdojo.threads;
public class CurrentThread extends Thread {
    public CurrentThread(String name) {
        super(name);
    }
    @Override
    public void run() {
        Thread t = Thread.currentThread();
        String threadName = t.getName();
        System.out.println("Inside run() method: " +
            threadName);
    }
    public static void main(String[] args) {
        CurrentThread ct1 = new CurrentThread(
            "Thread #1");
        CurrentThread ct2 = new CurrentThread(
            "Thread #2");
        ct1.start();
        ct2.start();
        // Let's see which thread is executing the
        // following statement
        Thread t = Thread.currentThread();
        String threadName = t.getName();
        System.out.println("Inside main() method: " +
            threadName);
    }
}

Inside main() method: main
Inside run() method: Thread #1
Inside run() method: Thread #2

Listing 5-11Using the Thread.currentThread() Method

两个不同的线程调用了CurrentThread类的run()方法中的Thread.currentThread()方法。方法返回执行调用的线程的引用。程序只是打印正在执行的线程的名字。有趣的是,当您在main()方法中调用Thread.currentThread()方法时,一个名为main的线程执行了代码。当运行一个类时,JVM 启动一个名为main的线程,它负责执行main()方法。

让线程休眠

Thread类包含一个静态的sleep()方法,它让一个线程休眠一段指定的时间。它接受超时作为参数。您可以用毫秒或毫秒和纳秒来指定超时。执行此方法的线程在指定的时间内休眠。操作系统调度程序不会调度休眠线程来接收 CPU 时间。如果一个线程在进入睡眠状态之前拥有一个对象的监控锁,它将继续持有这些监控锁。sleep()方法可能抛出一个InterruptedException,你的代码应该准备好处理它。清单 5-12 展示了sleep()方法的使用。

// LetMeSleep.java
package com.jdojo.threads;
public class LetMeSleep {
    public static void main(String[] args) {
        try {
            System.out.println(
                "I am going to sleep for 5 seconds.");
            Thread.sleep(5000);
            // <- The "main" thread will sleep
            System.out.println("I woke up.");
        } catch (InterruptedException e) {
            System.out.println(
                "Someone interrupted me in my sleep.");
        }
        System.out.println("I am done.");
    }
}

I am going to sleep for 5 seconds.
I woke up.
I am done.

Listing 5-12A Sleeping Thread

Note

java.util.concurrent包中的TimeUnit enum 表示各种单位的时间度量,如毫秒、秒、分钟、小时、天等。它有一些方便的方法。其中之一是sleep()法。Thread.sleep()方法接受以毫秒为单位的时间。如果你想让一个线程休眠五秒钟,你需要调用这个方法作为Thread.sleep(5000),把秒转换成毫秒。您可以使用TimeUnitsleep()方法来避免持续时间转换,如下所示:

TimeUnit.SECONDS.sleep(5); // Same as Thread.sleep(5000)

我会和你一起去天堂

我可以把这部分的标题改写成“我会一直等到你死。”没错。一个线程可以等待另一个线程死亡(或终止)。假设有两个线程,t1t2。如果线程t1执行t2.join(),线程t1开始等待,直到线程t2终止。换句话说,调用t2.join()一直阻塞到t2终止。如果一个线程在另一个线程完成执行之前不能继续执行,那么在程序中使用join()方法是很有用的。

清单 5-13 中有一个例子,当程序执行完毕时,你想在标准输出上打印一条消息。要打印的信息是“我们完成了。”

// JoinWrong.java
package com.jdojo.threads;
public class JoinWrong {
    public static void main(String[] args) {
        Thread t1 = new Thread(JoinWrong::print);
        t1.start();
        System.out.println("We are done.");
    }
    public static void print() {
        for (int i = 1; i <= 5; i++) {
            try {
                System.out.println("Counter: " + i);
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

We are done.
Counter: 1
Counter: 2
Counter: 3
Counter: 4
Counter: 5

Listing 5-13An Incorrect Way of Waiting for a Thread to Terminate

main()方法中,创建并启动一个线程。线程打印从15的整数。它在打印一个整数后会休眠一秒钟。最后,main()方法输出一条消息。这个程序似乎应该打印出从15的数字,然后是你的最后一条消息。但是,如果你看输出,它是在相反的顺序。这个程序有什么问题?

JVM 启动一个名为main的新线程,负责执行您运行的类的main()方法。在您的例子中,JoinWrong类的main()方法是由main线程执行的。该线程将执行以下语句:

Thread t1 =  new Thread(JoinWrong::print);
t1.start();
System.out.println("We are done.");

t1.start()方法调用返回时,除了主线程之外,您的程序中又多了一个线程(线程t1)。t1线程负责打印从15的整数,而主线程负责打印消息“我们完成了”因为有两个线程负责两个不同的任务,所以不能保证哪个任务会先完成。解决办法是什么?你必须让你的主线程等待线程t1终止。这可以通过在main()方法中调用t1.join()方法来实现。

清单 5-14 包含清单 5-13 的正确版本,在打印最终消息之前使用t1.join()方法调用。当主线程执行join()方法调用时,它会一直等到t1线程终止。Thread类的join()方法可能抛出一个InterruptedException,你的代码应该准备好处理它。

// JoinRight.java
package com.jdojo.threads;
public class JoinRight {
    public static void main(String[] args) {
        Thread t1 = new Thread(JoinRight::print);
        t1.start();
        try {
            t1.join();
            // <- "main" thread waits until t1 is
            //    terminated
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("We are done.");
    }

    public static void print() {
        for (int i = 1; i <= 5; i++) {
            try {
                System.out.println("Counter: " + i);
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

Counter: 1
Counter: 2
Counter: 3
Counter: 4
Counter: 5
We are done.

Listing 5-14A Correct Way of Waiting for a Thread to Terminate

Thread类的join()方法被重载。它的另外两个版本接受超时参数。如果您使用带有超时的join()方法,调用方线程将一直等待,直到调用它的线程被终止或者超时结束。如果用t1.join(1000)替换JoinRight类中的t1.join()语句,您会发现输出的顺序不同,因为主线程只需等待一秒钟,让t1线程在打印最终消息之前终止。

一个线程可以连接多个线程吗?答案是肯定的。一个线程可以连接多个线程,如下所示:

t1.join(); // Join t1
t2.join(); // Join t2
t3.join(); // Join t3

你应该在线程启动后调用它的join()方法。如果在尚未启动的线程上调用join()方法,它会立即返回。类似地,如果您在一个已经终止的线程上调用join()方法,它会立即返回。

一根线能自己接合吗?答案是肯定的,也是否定的。从技术上来说,线程加入自己是允许的。但是,在大多数情况下,线程不应该自行连接。在这种情况下,线程会等待自己终止。换句话说,线程永远在等待。

// "Bad" call (not if you know what you are doing) to
// join. It waits forever until another thread interrupts
// it.
Thread.currentThread().join();

如果你写这个语句,确保你的程序使用其他线程来中断等待线程。在这种情况下,等待线程将通过抛出一个InterruptedExceptionjoin()方法调用返回。

体谅他人,让步

一个线程可以通过调用Thread类的静态yield()方法自愿放弃 CPU。对yield()方法的调用是对调度程序的一个暗示,它可能会暂停正在运行的线程,将 CPU 让给其他线程。只有在长循环中执行而没有等待或阻塞的情况下,线程才希望调用此方法。如果一个线程频繁地等待或阻塞,那么yield()方法调用就不是很有用,因为这个线程不会独占 CPU,当这个线程被阻塞或等待时,其他线程将获得 CPU 时间。建议不要依赖yield()方法,因为它只是给调度器的一个提示。它不能保证在不同的平台上给出一致的结果。调用yield()方法的线程继续持有监视器锁。请注意,无法保证放弃的线程何时会再次获得 CPU 时间。你可以这样使用它:

// The run() method of a thread class
public void run() {
    while(true) {
        // do some processing here...
        Thread.yield(); // Let's yield to other threads
    }
}

线程的生命周期

线程总是处于以下六种状态之一:

  • 新的

  • 可追捕的

  • 堵塞的

  • 等待

  • 定时等待

  • 终止的

线程的所有这些状态都是 JVM 状态。它们不代表操作系统分配给线程的状态。

当线程被创建并且它的start()方法还没有被调用时,它处于新的状态:

Thread t = new SomeThreadClass();
// <- t is in the new state

准备运行或正在运行的线程处于可运行状态。换句话说,有资格获得 CPU 时间的线程处于可运行状态。

Note

JVM 结合了两种 OS 级线程状态:准备运行和运行到一种称为可运行状态的状态。处于准备运行操作系统状态的线程意味着它正在等待获得 CPU 时间。处于运行操作系统状态的线程意味着它正在 CPU 上运行。

如果一个线程试图进入(或重新进入)一个同步的方法或块,但监视器正被另一个线程使用,则称该线程处于阻塞状态。条目集中等待获取监视器锁的线程处于阻塞状态。等待集中的线程在被唤醒后等待重新获取监视器锁,也处于阻塞状态。

线程可以通过调用表 5-2 中列出的方法之一将自己置于等待状态。通过调用表 5-3 中列出的方法之一,线程可以将自己置于定时等待状态。我将在本章后面讨论parkNanos()parkUntil()方法。

表 5-3

将线程置于定时等待状态的方法

|

方法

|

描述

|
| --- | --- |
| sleep() | 这个方法在Thread类中。 |
| wait (long millis)``wait(long millis, int nanos) | 这些方法在Object类中。 |
| join(long millis)``join(long millis, int nanos) | 这些方法在Thread类中。 |
| parkNanos (long nanos)``parkNanos (Object blocker, long nanos) | 这些方法在LockSupport类中,该类在java.util.concurrent.locks包中。 |
| parkUntil (long deadline)``parkUntil (Object blocker, long nanos) | 这些方法在LockSupport类中,该类在java.util.concurrent.locks包中。 |

表 5-2

将线程置于等待状态的方法

|

方法

|

描述

|
| --- | --- |
| wait() | 这是Object类的wait()方法,如果一个线程想要等待一个特定的条件保持,它可以调用这个方法。回想一下,一个线程必须拥有一个对象的监视器锁,才能在该对象上调用wait()方法。另一个线程必须在同一个对象上调用notify()notifyAll()方法,以便等待线程转换到可运行状态。 |
| join() | 这是Thread类的join()方法。调用此方法的线程希望等待,直到调用此方法的线程终止。 |
| park() | 这是LockSupport类的park()方法,它在java.util.concurrent.locks包中。调用该方法的线程可以通过调用线程上的unpark()方法来等待,直到获得许可。我将在本章后面介绍LockSupport类。 |

已经完成执行的线程被称为处于终止状态。当线程退出其run()方法或调用其stop()方法时,线程被终止。终止的线程不能转换到任何其他状态。您可以在线程启动后使用线程的isAlive()方法来了解它是活动的还是终止的。

您可以使用Thread类的getState()方法随时获取线程的状态。这个方法返回一个Thread.State枚举类型的常量。清单 5-15 和 5-16 演示了线程从一种状态到另一种状态的转换。清单 5-16 的输出显示了线程在其生命周期中的一些状态。

// ThreadStateTest.java
package com.jdojo.threads;
public class ThreadStateTest {
    public static void main(String[] args) {
        Object syncObject = new Object();
        ThreadState ts = new ThreadState(syncObject);
        System.out.println(
            "Before start()-ts.isAlive(): " +
            ts.isAlive());
        System.out.println("#1: " + ts.getState());
        // Start the thread
        ts.start();
        System.out.println(
            "After start()-ts.isAlive(): " +
            ts.isAlive());
        System.out.println("#2: " + ts.getState());
        ts.setWait(true);
        // Make the current thread sleep, so the thread
        // starts waiting
        sleepNow(100);
        synchronized (syncObject) {
            System.out.println("#3: " + ts.getState());
            ts.setWait(false);
            // Wake up the waiting thread
            syncObject.notifyAll();
        }
        // Make the current thread sleep, so ts thread
        // wakes up
        sleepNow(2000);
        System.out.println("#4: " + ts.getState());
        ts.setKeepRunning(false);
        // Make the current thread sleep, so the ts thread
        // will wake up
        sleepNow(2000);
        System.out.println("#5: " + ts.getState());
        System.out.println("At the end. ts.isAlive(): " +
            ts.isAlive());
    }
    public static void sleepNow(long millis) {
        try {
            Thread.currentThread().sleep(millis);
        } catch (InterruptedException e) {
        }
    }
}

Before start()-ts.isAlive(): false
#1: NEW
After start()-ts.isAlive(): true
#2: RUNNABLE
#3: WAITING
#4: RUNNABLE
#5: TERMINATED
At the end. ts.isAlive(): false

Listing 5-16A ThreadStateTest Class

to Demonstrate the States of a Thread

// ThreadState.java
package com.jdojo.threads;
public class ThreadState extends Thread {
    private boolean keepRunning = true;
    private boolean wait = false;
    private final Object syncObject;
    public ThreadState(Object syncObject) {
        this.syncObject = syncObject;
    }
    @Override
    public void run() {
        while (keepRunning) {
            synchronized (syncObject) {
                if (wait) {
                    try {
                        syncObject.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }
    public void setKeepRunning(boolean keepRunning) {
        this.keepRunning = keepRunning;
    }
    public void setWait(boolean wait) {
        this.wait = wait;
    }
}

Listing 5-15A ThreadState Class

线程的优先级

线程有优先级。优先级由110之间的整数表示。优先级为1的线程被认为具有最低的优先级。优先级为10的线程被称为具有最高优先级。在Thread类中定义了三个常量来代表三种不同的线程优先级,如表 5-4 中所列。

表 5-4

Thread类中定义的线程优先级常数

|

线程优先级常数

|

整数值

|
| --- | --- |
| MIN_PRIORITY | 1 |
| NORM_PRIORITY | 5 |
| MAX_PRIORITY | 10 |

线程的优先级是对调度器的一个提示,表明调度器应该调度该线程的重要性(或紧迫性)。线程的优先级越高,表明该线程的重要性越高,调度程序应该优先将 CPU 时间分配给该线程。注意,线程的优先级只是给调度器的一个提示;这取决于调度程序是否尊重该提示。不建议依赖线程优先级来保证程序的正确性。例如,如果有十个最大优先级线程和一个最小优先级线程,这并不意味着调度器将在所有十个最大优先级线程都被调度和完成之后调度最小优先级线程。这种调度方案将导致线程饥饿,低优先级线程将不得不无限期等待或等待很长时间来获得 CPU 时间。

Thread类的setPriority()方法为线程设置一个新的优先级。getPriority()方法返回线程的当前优先级。创建线程时,其优先级默认设置为创建新线程的线程的优先级。

清单 5-17 演示了如何设置和获取线程的优先级。它还演示了新线程如何获得创建它的线程的优先级。在这个例子中,线程t1t2在它们被创建时获得主线程的优先级。

// ThreadPriority.java
package com.jdojo.threads;
public class ThreadPriority {
    public static void main(String[] args) {
        // Get the reference of the current thread
        Thread t = Thread.currentThread();
        System.out.println("main Thread Priority: " +
            t.getPriority());
        // Thread t1 gets the same priority as the main
        // thread at this point
        Thread t1 = new Thread();
        System.out.println("Thread(t1) Priority: " +
            t1.getPriority());
        t.setPriority(Thread.MAX_PRIORITY);
        System.out.println("main Thread Priority: " +
            t.getPriority());
        // Thread t2 gets the same priority as main
        // thread at this point, which is
        // Thread.MAX_PRIORITY (10)
        Thread t2 = new Thread();
        System.out.println("Thread(t2) Priority: " +
            t2.getPriority());
        // Change thread t2 priority to minimum
        t2.setPriority(Thread.MIN_PRIORITY);
        System.out.println("Thread(t2) Priority: " +
            t2.getPriority());
    }
}

main Thread Priority: 5
Thread(t1) Priority: 5
main Thread Priority: 10
Thread(t2) Priority: 10
Thread(t2) Priority: 1

Listing 5-17Setting and Getting a Thread’s Priority

是恶魔还是守护进程?

线程可以是守护线程或用户线程。单词“daemon”和“demon”发音相同然而,线程上下文中的 daemon 一词与恶魔毫无关系!

守护线程是一种服务提供者线程,而用户线程(或非守护线程)是使用守护线程服务的线程。如果没有服务消费者,服务提供者就不应该存在。JVM 应用了这个逻辑。当 JVM 检测到应用程序中的所有线程都只是守护线程时,它会退出应用程序。注意,如果一个应用程序中只有守护线程,JVM 在退出应用程序之前不会等待这些守护线程完成。

您可以通过使用setDaemon()方法将true作为参数传递来使线程成为守护线程。在启动线程之前,必须调用线程的setDaemon()方法。否则,抛出一个IllegalThreadStateException。您可以使用isDaemon()方法来检查一个线程是否是守护线程。

Note

JVM 启动一个垃圾收集器线程来收集所有未使用的对象内存。垃圾收集器线程是一个守护线程。

创建线程时,其守护进程属性与创建它的线程相同。换句话说,一个新线程继承了它的创建者线程的守护进程属性。

清单 5-18 创建一个线程并将该线程设置为守护线程。线程打印一个整数,并在无限循环中休眠一段时间。在main()方法结束时,程序向标准输出打印一条消息,说明它正在退出main()方法。由于线程 t 是一个守护线程,当main()方法执行完毕时,JVM 将终止应用程序。您可以在输出中看到这一点。应用程序在退出前只打印线程中的一个整数。运行这个程序时,您可能会得到不同的输出。

// DaemonThread.java
package com.jdojo.threads;
public class DaemonThread {
    public static void main(String[] args) {
        Thread t = new Thread(DaemonThread::print);
        t.setDaemon(true);
        t.start();
        System.out.println("Exiting main method");
    }
    public static void print() {
        int counter = 1;
        while (true) {
            try {
                System.out.println("Counter: " +
                    counter++);
                Thread.sleep(2000); // sleep for 2 seconds
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

Exiting main method
Counter: 1

Listing 5-18A Daemon Thread Example

清单 5-19 与清单 5-18 是相同的程序,除了它将线程设置为非守护线程。因为这个程序有一个非守护进程(或用户)线程,JVM 将继续运行这个应用程序,甚至在main()方法完成之后。您必须手动停止该应用程序,因为线程在无限循环中运行。

// NonDaemonThread.java
package com.jdojo.threads;
public class NonDaemonThread {
    public static void main(String[] args) {
        Thread t = new Thread(NonDaemonThread::print);
        // t is already a non-daemon thread because the
        // "main" thread that runs the main() method is a
        // non-daemon thread. You can verify it by using
        // t.isDaemon() method. It will return false.
        // Still we will use the following statement to
        // make it clear that we want t to be a non-daemon
        // thread.
        t.setDaemon(false);
        t.start();
        System.out.println("Exiting main method");
    }
    public static void print() {
        int counter = 1;
        while (true) {
            try {
                System.out.println("Counter: " +
                    counter++);
                Thread.sleep(2000); // sleep for 2 seconds
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

Exiting main method
Counter: 1
Counter: 2
...

Listing 5-19A Non-daemon Thread Example

我被打扰了吗?

您可以通过使用interrupt()方法来中断一个活动的线程。线程上的这个方法调用只是向线程表明,程序的某个其他部分正在试图引起它的注意。如何响应中断取决于线程。Java 为每个线程使用一个中断状态标志来实现中断机制。

当线程被中断时,它可能处于两种状态之一:正在运行或被阻塞。如果一个线程在运行时被中断,它的中断状态由 JVM 设置。正在运行的线程可以通过调用Thread.interrupted()静态方法来检查其中断状态,如果当前线程被中断,该方法将返回 true。对Thread.interrupted()方法的调用清除了线程的中断状态。也就是说,如果在同一个线程上再次调用这个方法,并且如果第一次调用返回了true,那么后续调用将返回false,除非线程在第一次调用之后、后续调用之前被中断。

清单 5-20 显示了中断主线程并打印线程中断状态的代码。注意,对Thread.interrupted()方法的第二次调用返回false,如输出#3: false所示。这个例子也显示了一个线程可以中断自己。在这个例子中,负责运行 main()方法的主线程正在中断自己。

// SimpleInterrupt.java
package com.jdojo.threads;
public class SimpleInterrupt {
    public static void main(String[] args) {
        System.out.println("#1: " + Thread.interrupted());
        // Now interrupt the main thread
        Thread.currentThread().interrupt();
        // Check if it has been interrupted
        System.out.println("#2: " + Thread.interrupted());
        // Check again if it has been interrupted
        System.out.println("#3: " + Thread.interrupted());
    }
}

#1: false
#2: true
#3: false

Listing 5-20A Simple Example of Interrupting a Thread

让我们看另一个同类的例子。这一次,一个线程将中断另一个线程。清单 5-21 启动一个线程,递增计数器,直到线程被中断。最后,线程打印计数器的值。main()方法启动线程;它休眠一秒钟,让计数器线程做一些工作;它会中断线程。由于线程在继续 while 循环之前会检查自己是否被中断,因此一旦中断,它就会退出循环。运行这个程序时,您可能会得到不同的输出。

// SimpleInterruptAnotherThread.java
package com.jdojo.threads;
public class SimpleInterruptAnotherThread {
    public static void main(String[] args) {
        Thread t = new Thread(
            SimpleInterruptAnotherThread::run);
        t.start();
        try {
            // Let the main thread sleep for 1 second
            Thread.currentThread().sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // Now interrupt the thread
        t.interrupt();
    }
    public static void run() {
        int counter = 0;
        while (!Thread.interrupted()) {
            counter++;
        }
        System.out.println("Counter: " + counter);
    }
}

Counter: 1313385352

Listing 5-21A Thread Interrupting Another Thread

Thread类有一个非静态的isInterrupted()方法,可以用来测试线程是否被中断。当你调用这个方法时,不像interrupted()方法,线程的中断状态不会被清除。清单 5-22 展示了这些方法之间的区别。

// SimpleIsInterrupted.java
package com.jdojo.threads;
public class SimpleIsInterrupted {
    public static void main(String[] args) {
        // Check if the main thread is interrupted
        System.out.println("#1: " +
            Thread.interrupted());
        // Now interrupt the main thread
        Thread mainThread = Thread.currentThread();
        mainThread.interrupt();
        // Check if it has been interrupted
        System.out.println("#2: " +
            mainThread.isInterrupted());
        // Check if it has been interrupted
        System.out.println("#3: " +
            mainThread.isInterrupted());
        // Now check if it has been interrupted using the
        // static method which will clear the interrupted
        // status
        System.out.println("#4: " +
            Thread.interrupted());
        // Now, isInterrupted() should return false,
        // because previous statement Thread.interrupted()
        // has cleared the flag
        System.out.println("#5: " +
            mainThread.isInterrupted());
    }
}

#1: false
#2: true
#3: true
#4: true
#5: false

Listing 5-22Difference Between the interrupted() and isInterrupted() Methods

你可以中断一个被阻塞的线程。回想一下,线程可能会通过执行sleep()wait()join()方法之一来阻塞自己。如果在这三个方法上被阻塞的线程被中断,抛出一个InterruptedException,并且线程的中断状态被清除,因为线程已经接收到一个异常来通知中断。

清单 5-23 启动一个线程,休眠一秒钟,打印一条消息,直到被中断。主线程休眠五秒钟,因此休眠线程有机会休眠并打印几次消息。当主线程醒来时,它会中断睡眠线程。运行该程序时,您可能会得到不同的输出。

// BlockedInterrupted.java
package com.jdojo.threads;
public class BlockedInterrupted {
    public static void main(String[] args) {
        Thread t = new Thread(BlockedInterrupted::run);
        t.start();
        // main thread sleeps for 5 seconds
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // Interrupt the sleeping thread
        t.interrupt();
    }
    public static void run() {
        int counter = 1;
        while (true) {
            try {
                Thread.sleep(1000);
                System.out.println("Counter: " +
                    counter++);
            } catch (InterruptedException e) {
                System.out.println("I got interrupted!");
                // Terminate the thread by returning
                return;
            }
        }
    }
}

Counter: 1
Counter: 2
Counter: 3
Counter: 4
I got interrupted!

Listing 5-23Interrupting a Blocked Thread

如果一个线程在一个 I/O 上被阻塞,如果您使用的是旧的 I/O API,那么中断一个线程实际上没有任何作用。但是,如果您使用新的 I/O API,您的线程将收到一个ClosedByInterruptException,它在java.nio.channels包中声明。

线程在一个组中工作

线程总是线程组的成员。默认情况下,线程的线程组是其创建者线程的组。JVM 创建一个名为 main 的线程组和这个组中一个名为 main 的线程,负责在启动时运行 main 类的main()方法。Java 程序中的线程组由ThreadGroup类的对象表示。Thread类的getThreadGroup()方法返回对线程的ThreadGroup的引用。清单 5-24 展示了默认情况下,一个新线程是其创建线程的线程组的成员。

// DefaultThreadGroup.java
package com.jdojo.threads;
public class DefaultThreadGroup {
    public static void main(String[] args) {
        // Get the current thread, which is called "main"
        Thread t1 = Thread.currentThread();
        // Get the thread group of the main thread
        ThreadGroup tg1 = t1.getThreadGroup();
        System.out.println(
            "Current thread's name: " +
            t1.getName());
        System.out.println(
            "Current thread's group name: " +
            tg1.getName());
        // Creates a new thread. Its thread group is the
        // same that of the main thread.
        Thread t2 = new Thread("my new thread");
        ThreadGroup tg2 = t2.getThreadGroup();
        System.out.println("New thread's name: " +
            t2.getName());
        System.out.println("New thread's group name: " +
            tg2.getName());
    }
}

Current thread's name: main
Current thread's group name: main
New thread's name: my new thread
New thread's group name: main

Listing 5-24Determining the Default Thread Group of a Thread

您还可以创建一个线程组,并在该线程组中放置一个新线程。要在线程组中放置一个新线程,您必须使用接受一个ThreadGroup对象作为参数的Thread类的一个构造函数。以下代码片段在特定线程组中放置了一个新线程:

// Create a new ThreadGroup
ThreadGroup myGroup = new ThreadGroup("My Thread Group");
// Make the new thread a member of the myGroup thread group
Thread t = new Thread(myGroup, "myThreadName");

线程组以树状结构排列。一个线程组可以包含另一个线程组。ThreadGroup类的getParent()方法返回一个线程组的父线程组。顶级线程组的父线程是null

ThreadGroup类的activeCount()方法返回组中活动线程的估计数量。ThreadGroup类的enumerate(Thread[] list)方法可以用来获取线程组中的线程。

Java 程序中的线程组可用于实现基于组的策略,该策略适用于线程组中的所有线程。例如,通过调用线程组的interrupt()方法,可以中断线程组及其子组中的所有线程。

易变变量

我在前面的章节中讨论了关键字synchronized的使用。当一个线程执行一个synchronized方法/块时会发生两件事:

  • 线程必须获得方法/块在其上同步的对象的监视器锁。

  • 就在线程获得锁之后,线程的共享变量的工作副本在主存中用这些变量的值更新。就在线程释放锁之前,用线程的工作副本值更新主存储器中共享变量的值。也就是说,在同步方法/块的开始和结束时,线程的工作内存和主内存中的共享变量的值是同步的。

在不使用同步方法/块的情况下,如何才能只实现第二点呢?也就是说,如何让线程工作内存中的变量值与它们在主内存中的值保持同步?答案是关键词volatile。您可以像这样声明一个变量 volatile:

volatile boolean flag = true;

对于可变变量的每个读请求,一个线程从主存储器中读取值。对于一个可变变量的每个写请求,一个线程将该值写入主内存。换句话说,线程不会在工作内存中缓存可变变量的值。注意,只有在多线程环境中,对于线程间共享的变量,使用 volatile 变量才有用。它比使用synchronized块更快更便宜。

您只能将类成员变量(实例或静态字段)声明为volatile。您不能将局部变量声明为volatile,因为局部变量总是线程私有的,不会与其他线程共享。你不能声明一个volatile变量final,因为volatile关键字与一个变化的变量一起使用。

您可以使用一个volatile变量来停止一个线程,方法是将该变量的值作为一个标志。如果设置了标志,线程可以继续运行。如果另一个线程清除了该标志,该线程应该停止。因为两个线程共享这个标志,所以您需要声明它volatile,这样在每次读取时,线程将从主内存中获得它的更新值。

清单 5-25 展示了volatile变量的使用。如果keepRunning变量没有声明为volatile,JVM 就可以自由地永远运行run()方法中的 while 循环,因为keepRunning的初始值被设置为true,线程可以将这个值缓存在它的工作内存中。由于keepRunning变量被声明为volatile,JVM 将在每次使用时从主内存中读取它的值。当另一个线程使用stopThread()方法将keepRunning变量的值更新为false时,while 循环的下一次迭代将读取其更新值并停止循环。即使您没有将keepRunning声明为volatile,您的程序也可以像清单 5-25 一样工作。然而,根据 JVM 规范,这种行为是不被保证的。如果正确实现了 JVM 规范,以这种方式使用一个volatile变量可以确保程序的正确行为。

// VolatileVariable.java
package com.jdojo.threads;
public class VolatileVariable extends Thread {
    private volatile boolean keepRunning = true;
    @Override
    public void run() {
        System.out.println("Thread started...");
        // keepRunning is volatile. So, for every read,
        // the thread reads its latest value from the main
        // memory
        while (keepRunning) {
            try {
                System.out.println("Going to sleep ...");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("Thread stopped...");
    }
    public void stopThread() {
        this.keepRunning = false;
    }
    public static void main(String[] args) {
        // Create the thread
        VolatileVariable vv = new VolatileVariable();
        // Start the thread
        vv.start();
        // Let the main thread sleep for 3 seconds
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // Stop the thread
        System.out.println(
            "Going to set the stop flag to true...");
        vv.stopThread();
    }
}

Thread started...
Going to sleep ...
Going to sleep ...
Going to sleep ...
Going to set the stop flag to true...
Thread stopped...

Listing 5-25Using a volatile Variable in a Multi-threaded Program

Note

long 和 double 类型的volatile变量被原子地处理,用于读写目的。回想一下,longdouble类型的非易失性变量被非原子地处理。也就是说,如果两个线程分别向一个非易失性变量longdouble写入两个不同的值,比如说v1v2,你的程序可能会看到该变量的一个既不是v1也不是v2的值。然而,如果那个longdouble变量被声明为volatile,你的程序会在给定的时间点看到值v1v2。您不能创建数组元素volatile

停止、挂起和恢复线程

Thread类中的stop()suspend()resume()方法分别让您停止线程、暂停线程和恢复暂停的线程。这些方法已被弃用,因为它们的使用容易出错。

您可以通过调用stop()方法来停止线程。当线程的stop()方法被调用时,JVM 抛出一个ThreadDeath错误。由于引发了此错误,所有被停止的线程锁定的监视器都将被解锁。监控锁用于保护一些重要的共享资源(通常是 Java 对象)。如果当线程停止时,受监视器保护的任何共享资源处于不一致状态,则其他线程可以看到这些资源的不一致状态。这将导致程序的不正确行为。这就是不推荐使用stop()方法的原因;建议您不要在您的程序中使用它。

不使用线程的stop()方法如何停止线程?您可以通过设置正在运行的线程将定期检查的标志来停止线程。如果设置了标志,线程应该停止执行。这种停止线程的方式在上一节的清单 5-25 中有说明。

您可以通过调用线程的suspend()方法来暂停线程。要恢复一个挂起的线程,需要调用它的resume()方法。然而,suspend()方法已经被弃用,因为它容易出错,并且可能导致死锁。让我们假设挂起的线程持有一个对象的监视器锁。将恢复挂起线程的线程正试图获取同一对象的监视器锁。这将导致死锁。挂起的线程将保持挂起,因为没有线程将恢复它,并且将恢复它的线程将保持阻塞,因为它试图获得的监视器锁被挂起的线程持有。这就是为什么suspend()方法被弃用的原因。也不推荐使用resume()方法,因为它是与suspend()方法一起调用的。您可以使用类似的技术来模拟程序中Thread类的suspend()resume()方法,就像您模拟stop()方法一样。

清单 5-26 演示了如何在线程中模拟Thread类的stop()suspend()resume()方法。

// StopSuspendResume.java
package com.jdojo.threads;
public class StopSuspendResume extends Thread {
    private volatile boolean keepRunning = true;
    private boolean suspended = false;
    public synchronized void stopThread() {
        this.keepRunning = false;
        // Notify the thread in case it is suspended when
        // this method is called, so  it will wake up and
        // stop.
        this.notify();
    }
    public synchronized void suspendThread() {
        this.suspended = true;
    }
    public synchronized void resumeThread() {
        this.suspended = false;
        this.notify();
    }
    @Override
    public void run() {
        System.out.println("Thread started...");
        while (keepRunning) {
            try {
                System.out.println("Going to sleep...");
                Thread.sleep(1000);
                // Check for a suspended condition must be
                // made inside a synchronized block to call
                // the wait() method
                synchronized (this) {
                    while (suspended) {
                        System.out.println("Suspended...");
                        this.wait();
                        System.out.println("Resumed...");
                    }
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        System.out.println("Thread stopped...");
    }
    public static void main(String[] args) {
        StopSuspendResume t = new StopSuspendResume();
        // Start the thread
        t.start();
        // Sleep for 2 seconds
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // Suspend the thread
        t.suspendThread();
        // Sleep for 2 seconds
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // Resume the thread
        t.resumeThread();
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // Stop the thread
        t.stopThread();
    }
}

Thread started...
Going to sleep...
Going to sleep...
Going to sleep...
Suspended...
Resumed...
Going to sleep...
Going to sleep...
Going to sleep...
Thread stopped...

Listing 5-26Stopping, Suspending, and Resuming a Thread

注意在StopSuspendResume类中有两个实例变量。挂起的实例变量没有声明volatile。没有必要声明它volatile,因为它总是在synchronized方法/块中被访问。run()方法中的以下代码用于实现挂起和恢复功能:

synchronized (this) {
    while (suspended) {
        System.out.println("Suspended...");
        this.wait();
        System.out.println("Resumed...");
    }
}

suspended实例变量被设置为true时,线程调用自身的wait()方法等待。注意synchronized块的使用。它将此用作要同步的对象。这就是你可以在synchronized块中调用this.wait()的原因,因为在进入 synchronized 块之前,你已经获得了这个对象的锁。一旦调用了this.wait()方法,线程就释放这个对象上的锁,并一直等待,直到另一个线程调用resumeThread()方法来通知它。我还在stopThread()方法中使用了this.notify()方法调用,因为如果在调用stopThread()方法时线程被挂起,线程将不会停止;相反,它将保持暂停状态。

本例中的线程在其run()方法中只休眠了一秒钟。假设您的线程休眠了一段时间。在这种情况下,调用stopThread()方法不会立即停止线程,因为线程只有在唤醒并在下一次循环迭代中检查其keepRunning实例变量值时才会停止。在这种情况下,您可以在stopThread()方法中使用interrupt()方法来中断睡眠/等待线程,当抛出InterruptedException时,您需要适当地处理它。

如果你使用清单 5-26 中的技术来停止一个线程,在某些情况下你可能会遇到问题。在run()方法中的 while 循环依赖于在stopThread()方法中设置的keepRunning实例变量。这个清单中的例子很简单。它只是为了演示如何停止、挂起和恢复线程的概念。假设在run()方法中,您的代码等待其他资源,比如调用方法someBlockingMethodCall(),如下所示:

while (keepRunning) {
    try {
        someBlockingMethodCall();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

如果您调用了stopThread()方法,而这个线程在方法调用someBlockingMethodCall()上被阻塞,那么这个线程将不会停止,直到它从阻塞的方法调用中返回或者被中断。为了克服这个问题,您需要改变停止线程的策略。依靠线程的中断技术来提前停止它是一个好主意。stopThread()方法可以更改如下:

public void stopThread() {
    // interrupt this thread
    this.interrupt();
}

此外,应该修改run()方法内的 while 循环,以检查线程是否被中断。如果该线程在被阻塞时被中断,您需要修改异常处理代码来退出循环。以下代码片段说明了这一逻辑:

public void run() {
    while (Thread.currentThread().isInterrupted())) {
        try {
            // Do the processing
        } catch (InterruptedException e) {
            // Stop the thread by exiting the loop
            break;
        }
    }
}

旋转等待提示

有时,一个线程可能必须等待另一个线程更新一个volatile变量。当volatile变量被更新为某个值时,第一个线程可以继续执行。如果等待时间可能更长,建议第一个线程通过休眠或等待来释放 CPU,并在可以恢复工作时通知它。但是,让线程休眠或等待会有延迟。为了短时间等待并减少延迟,线程通常通过检查某个条件是否为真来循环等待。考虑一个类中的代码,它使用一个循环来等待一个名为dataReadyvolatile变量成为true:volatile boolean data ready;

...
@Override
public void run() {
    // Wait in a loop until data is ready
    while (!dataReady) {
        // No code
    }
    processData();
}
private void processData() {
    // Data processing logic goes here
}

这段代码中的 while 循环称为自旋循环、忙自旋、忙等待或自旋等待。while 循环一直循环,直到dataReady变量的值变成true

虽然旋转等待因其不必要的资源使用而不被鼓励,但它通常是需要的。在这个例子中,优点是一旦变量dataReady变为true,线程将开始处理数据。但是,您需要为性能和功耗付出代价,因为线程是主动循环的。

某些处理器可以被提示线程处于自旋等待状态,并且如果可能的话,可以优化资源使用。例如,x86 处理器支持暂停指令来指示旋转等待。该指令将线程的下一条指令的执行延迟有限的一小段时间,从而提高资源利用率。

Thread类的静态onSpinWait()方法可以用来给处理器一个提示,即调用者线程暂时无法继续,因此可以优化资源使用。当基础平台不支持此类提示时,此方法的可能实现可能是 no-op。

清单 5-27 包含示例代码。请注意,使用自旋等待提示不会改变程序的语义。如果底层硬件支持提示,它可能会执行得更好。

// SpinWaitTest.java
package com.jdojo.misc;
public class SpinWaitTest implements Runnable {
    private volatile boolean dataReady = false;
    @Override
    public void run() {
        // Wait while data is ready
        while (!dataReady) {
            // use a spin-wait hint
            Thread.onSpinWait();
        }
        processData();
    }
    private void processData() {
        // Data processing logic goes here
    }
    public void setDataReady(boolean dataReady) {
        this.dataReady = dataReady;
    }
}

Listing 5-27Sample Code for Using a Spin-Wait Hint to the Processor Using the static Thread.onSpinWait() Method

处理线程中未捕获的异常

您可以处理线程中抛出的未捕获的异常。使用实现嵌套的Thread.UncaughtExceptionHandler接口的类的对象来处理它。该接口包含一个方法:void uncaughtException(Thread t, Throwable e)

这里,t是抛出异常的线程对象引用,e是抛出的未被捕获的异常。清单 5-28 包含了一个类的代码,该类的对象可以被用作线程的未捕获异常处理程序。

// CatchAllThreadExceptionHandler.java
package com.jdojo.threads;
public class CatchAllThreadExceptionHandler
        implements Thread.UncaughtExceptionHandler {
    @Override
    public void uncaughtException(Thread t,
          Throwable e) {
        System.out.println(
            "Caught Exception from Thread: " +
            t.getName());
    }
}

Listing 5-28An Uncaught Exception Handler for a Thread

该类只是打印一条消息和线程名,说明线程中未捕获的异常已经得到处理。通常,您可能想要做一些清理工作,或者在处理程序的uncaughtException()方法中将异常记录到文件或数据库中。Thread类包含两个为线程设置未捕获异常处理程序的方法:一个是静态的setDefaultUncaughtExceptionHandler()方法,另一个是非静态的setUncaughtExceptionHandler()方法。使用静态方法为应用程序中的所有线程设置默认处理程序。使用非静态方法为特定线程设置处理程序。当线程出现未捕获的异常时,将采取以下步骤:

  • 如果线程使用setUncaughtExceptionHandler()方法设置了一个未捕获的异常处理程序,则调用该处理程序的uncaughtException()方法。

  • 如果线程没有设置未捕获的异常处理程序,则调用其线程组的uncaughtException()方法。如果线程组有父线程组,则调用其父线程组的uncaughtException()方法。否则,它会检查是否设置了默认的未捕获异常处理程序。如果它找到一个默认的未被捕获的异常处理程序,它就调用它的uncaughtException()方法。如果没有找到默认的未捕获异常处理程序,则在标准错误流中打印一条消息。如果没有找到默认的未捕获异常处理程序,它什么也不做,并抛出一个ThreadDeath异常。

清单 5-29 展示了如何为线程中未捕获的异常设置一个处理程序。它创建一个类CatchAllThreadExceptionHandler的对象,并将其设置为主线程的未捕获异常的处理程序。主线程在其最后一条语句中抛出一个未检查的异常。输出显示处理程序处理了在main()方法中抛出的异常。

// UncaughtExceptionInThread.java
package com.jdojo.threads;
public class UncaughtExceptionInThread {
    public static void main(String[] args) {
        CatchAllThreadExceptionHandler handler =
            new CatchAllThreadExceptionHandler();
        // Set an uncaught exception handler for the
        // main thread
        Thread.currentThread().
            setUncaughtExceptionHandler(handler);
        // Throw an exception
        throw new RuntimeException();
    }
}

Caught Exception from Thread: main

Listing 5-29Setting an Uncaught Exception Handler for a Thread

线程并发包

尽管 Java 从一开始就内置了对多线程的支持,但是开发一个使用高级并发结构的多线程 Java 程序并不容易。比如synchronized关键字,用来锁定一个对象的监视器,从一开始就存在。但是,如果锁不可用,试图锁定对象监视器的线程就会阻塞。在这种情况下,开发商别无选择,只能退出。拥有一个基于“尝试并锁定”哲学而不是“锁定或阻止”哲学的结构不是很好吗?在这种策略中,如果对象的监视器锁不可用,锁定监视器的调用将立即返回。

java.util.concurrent包及其两个子包java.util.concurrent.atomicjava.util.concurrent.locks,包含了非常有用的并发结构。只有在开发高级多线程程序时才使用它们。在这一节中,我没有涵盖所有的并发结构,因为描述这些包中的所有可用内容可能需要一百多页。我简要介绍了这些包中一些最有用的并发结构。您可以将这些并发功能大致分为四类:

  • 原子变量

  • 同步器

  • 并发收款

原子变量

通常,当您需要在线程间共享可更新变量时,会使用同步。过去,多线程之间的同步是使用 synchronized 关键字实现的,它基于对象的监视器。如果一个线程不能获得一个对象的监视器,那么这个线程就被挂起,以后必须恢复。这种同步方式(暂停和恢复)使用了大量的系统资源。问题不在于监视器锁的锁定和解锁机制;相反,它是在挂起和恢复线程。如果没有争用锁,使用 synchronized 关键字来同步线程不会有太大的伤害。

原子变量使用单个变量的无锁同步。请注意,如果您的程序需要同步多个共享变量,您仍然需要使用旧的同步方法。所谓无锁同步,我的意思是多线程可以不使用对象监视器锁安全地访问一个共享变量。JDK 利用称为“比较和交换”(CAS)的硬件指令来实现一个变量的无锁同步。

国际体育仲裁法庭

Compare And Swap 是一条内部指令,用于维护单个原子变量的无锁同步。

CAS 基于三个操作数:内存位置 M、预期的旧值 V 和新值 N。如果内存位置 M 包含值 V,CAS 会自动将其更新为 N;否则,它不会做任何事情。CAS 总是返回在 CAS 操作开始之前存在的位置 M 处的当前值。CAS 的伪代码如下:

CAS(M, V, N) {
    currentValueAtM = get the value at Location M;
    if (currentValueAtM == V) {
        set value at M to N;
    }
    return currentValueAtM;
}}

CAS 指令是无锁的。大多数现代计算机硬件都直接支持它。然而,CAS 并不总是保证在多线程环境中成功。CAS 采取乐观的方法,假设没有其他线程更新位置M的值;如果位置M包含值V,则更新为N;如果位置M的值不是V,不要做任何事情。因此,如果多个线程试图同时将位置M处的值更新为不同的值,则只有一个线程会成功,其他线程会失败。

使用锁的同步采用悲观的方法,假设其他线程可能正在使用位置M,并在位置M开始工作之前获取一个锁,这样当一个线程正在使用位置M时,其他线程将不会访问它。在 CAS 失败的情况下,调用方线程可能会再次尝试该操作或放弃;使用 CAS 的调用方线程从不阻塞。但是,在使用锁进行同步的情况下,如果调用方线程无法获得锁,它可能必须挂起并恢复。使用同步,您还会面临死锁、活锁和其他与同步相关的故障的风险。

原子变量类的命名类似于AtomicXxx,可以用来在不使用任何锁的情况下自动执行单个变量上的多条指令。这里,Xxx用不同的词代替,表示用于不同目的的不同类;例如,AtomicInteger类被用来表示一个int变量,它应该被原子地操作。Java 类库中的 12 个类支持原子地对单个变量进行读-修改-写操作。他们在java.util.concurrent.atomic的包裹里。它们可以分为四类,下面几节将对此进行讨论。

标量原子变量类

AtomicIntegerAtomicLongAtomicBoolean类分别支持对原始数据类型intlongboolean的操作。

如果您需要使用其他原始数据类型,请使用AtomicInteger类。您可以直接使用它来处理byteshort数据类型。通过使用Float.floatToIntBits()方法将float值转换为int数据类型,使用AtomicInteger.floatValue()方法将int值转换为float数据类型,使用它来处理float数据类型。

通过使用Double.doubleToLongBits()方法将double值转换为long数据类型,使用AtomicLong.doubleValue()方法将long值转换为double数据类型,您可以使用AtomicLong类来处理double数据类型。

当引用变量需要自动更新时,AtomicReference<V>类用于处理引用数据类型。

原子数组类

有三个类——称为AtomicIntegerArrayAtomicLongArrayAtomicReferenceArray <E>—代表一个由intlong和引用类型组成的数组,这些引用类型的元素可以自动更新。

原子字段更新程序类

有三个类——称为AtomicLongFieldUpdaterAtomicIntegerFieldUpdater和 AtomicReferenceFieldUpdater——可用于使用反射原子地更新类的 volatile字段。这些类没有构造函数。要获得对这些类的对象的引用,需要使用它们的工厂方法newUpdater()

原子复合变量类

CAS 通过询问“位置M处的值仍然是V吗?”如果答案是肯定的,它将位置M的值从V更新为N。在典型的场景中,一个线程可以从位置M读取值作为V。当这个线程试图将值从V更新为N时,另一个线程已经将位置M处的值从V更改为P,并从P更改回V。因此,调用CAS(M, V, N)将会成功,因为位置M处的值仍然是V,即使它在线程上次读取值V后被更改了两次(vP再回到V)。在某些情况下,这是好的。想要更新位置M处的值的线程不关心它上次读取的旧值V是否在它自己更新之前被更新,只要位置M处的值在它将值更新为N时是V。但是,在某些情况下,这是不可接受的。如果一个线程从位置M读取值V,这个线程想要确保在它读取该值之后,没有其他线程更新该值。在这种情况下,CAS 需要询问“自从我最后一次读取位置M的值为V以来,它是否发生了变化?”为了实现这个功能,您需要存储一对值:您想要使用的值及其版本号。每次更新也会更新版本号。AtomicMarkableReferenceAtomicStampedReference类属于原子复合变量类的范畴。

让我们看一个使用原子类的简单例子。如果您想编写一个类来使用内置的 Java 同步生成一个计数器,它将类似于清单 5-30 中所示的代码。

// SynchronizedCounter.java
package com.jdojo.threads;
public class SynchronizedCounter {
    private long value;
    public synchronized long next() {
        return ++value;
    }
}

Listing 5-30A Counter Class That Uses Synchronization

你可以使用AtomicLong类重写SynchronizedCounter类,如清单 5-31 所示。

// AtomicCounter.java
package com.jdojo.threads;
import java.util.concurrent.atomic.AtomicLong;
public class AtomicCounter {
    private final AtomicLong value = new AtomicLong(0L);
    public long next() {
        return value.incrementAndGet();
    }
}

Listing 5-31A Counter Class Using an Atomic Variable

请注意,AtomicCounter类不使用任何显式同步。它利用了 CAS 硬件指令。对AtomicCounter类的next()方法中的incrementAndGet()方法的调用是自动执行的。

您也可以使用一个AtomicLong类的对象作为线程安全的计数器对象,如下所示:

AtomicLong aCounter = new AtomicLong(0L);

然后您可以使用aCounter.incrementAndGet()方法生成一个新的计数器。AtomicLong类的incrementAndGet()方法增加其当前值并返回新值。您还有一个对应的方法叫做getAndIncrement(),它递增它的值并返回它以前的值。

AtomicXxx变量类有一个compareAndSet()方法。它是比较和交换(CAS)的变体。唯一的区别是compareAndSet()方法返回一个boolean。如果成功,它返回true;否则返回false。下面是compareAndSet()方法的伪代码表示:

compareAndSet(M, V, N) {
    // Call CAS (see CAS pseudocode) if CAS succeeded,
    // return true; otherwise, return false.
    return (CAS(M, V, N) == V)
}

显式锁

显式锁定机制可用于在多线程环境中协调对共享资源的访问,而无需使用synchronized关键字。在java.util.concurrent.locks包中声明的Lock接口定义了显式的锁定操作。同一个包中的ReentrantLock类是Lock接口的具体实现。Lock接口包含以下方法:

  • void lock();

  • Condition newCondition();

  • void lockInterruptibly() throws InterruptedException;

  • boolean tryLock();

  • boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

  • void unlock();

使用lock()方法获取锁的行为与使用synchronized关键字的行为相同。synchronized关键字的使用要求一个线程应该在同一块代码中获取和释放一个对象的监控锁。当您使用synchronized关键字获取一个对象的监视器锁时,当程序离开获取锁的块时,锁就会被 JVM 释放。这个特性使得使用内部锁变得非常简单,并且不容易出错。然而,在Lock接口的情况下,在同一代码块中获取和释放锁的限制并不适用。这使得它使用起来有点灵活;然而,它更容易出错,因为获取和释放锁的责任都在开发人员身上。获取了锁却忘了释放,从而产生难以发现的 bug,这并不困难。您必须确保在锁定完成后,通过调用Lock接口的unlock()方法来释放锁定。您可以使用最简单形式的lock()unlock()方法,如清单 5-32 所示。

// SimpleExplicitLock.java
package com.jdojo.threads;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class SimpleExplicitLock {
    // Instantiate the lock object
    private final Lock myLock = new ReentrantLock();
    public void updateResource() {
        // Acquire the lock
        myLock.lock();
        try {
            // Logic for updating/reading the shared
            // resource goes here
        } finally {
            // Release the lock
            myLock.unlock();
        }
    }
}

Listing 5-32Using an Explicit Lock in Its Simplest Form

注意在updateResource()方法中使用 try-finally 块来释放锁。在这种情况下,使用 try-finally 块是必要的,因为在调用myLock.lock()之后,无论您如何完成从该方法的返回,您都希望释放锁。只有在调用finally块中的unlock()方法时,才能确保这一点。

您可能想知道为什么您会使用清单 5-32 中列出的代码结构,而您本可以使用synchronized关键字来达到同样的效果,就像这样:

public void updateResource() {
    // Acquire the lock and the lock will be released
    // automatically by the JVM when your code exits the
    // block
    synchronized (this) {
        // Logic for updating/reading the shared
        // resource goes here
    }
}

您认为在这种情况下使用关键字synchronized会更好,这是正确的。在这种情况下,使用关键字synchronized要简单得多,也不容易出错。当您遇到无法使用synchronized关键字或者使用起来非常麻烦的情况时,使用新的Lock界面的威力就变得显而易见了。例如,如果您想在updateResource()方法中获取锁,并在其他一些方法中释放它,您就不能使用synchronized关键字。如果您需要获得两个锁来使用一个共享资源,而只有一个锁可用,那么您希望做些别的事情,而不是等待另一个锁变得可用。如果您使用synchronized关键字或Lock接口的lock()方法来获取一个锁,如果锁不能立即使用,调用就会阻塞,一旦您请求了锁,您就无法选择后退。这种被阻塞的线程也不能被中断。Lock接口的两个方法,tryLock()lockInterruptibly(),让您能够尝试获取一个锁(而不是获取一个锁或块)。如果已经获得锁的线程被阻塞,它可能会被中断。使用Lock接口获取和释放锁的语法应该使用 try-finally 或 try-catch-finally 块结构,通过将unlock()调用放在finally块中来避免意外的错误。

您将使用显式锁构造来解决一个经典的同步问题,即哲学家进餐问题。问题是这样的:五个哲学家花费他们所有的时间或者思考或者吃饭。他们围坐在一张有五把椅子和五把叉子的圆形桌子周围,如图 5-7 所示。只有五把叉子,五个哲学家都需要挑离他们最近的两把(一把在他们的左边,一把在他们的右边)叉子吃饭。

img/323070_3_En_5_Fig7_HTML.jpg

图 5-7

餐桌上的五位哲学家

哲学家一旦吃完饭,就放下两把叉子,开始思考。如果一个哲学家的邻居在用叉子,他就不能拿起它。如果五个哲学家各自从自己的右边拿起一把叉子,等着自己左边的叉子被邻座松开,会发生什么?这将是一个僵局,没有哲学家能够吃饭。使用Lock接口的tryLock()方法可以很容易地避免这种死锁情况。该方法立即返回,并且从不阻塞。如果锁可用,它获取锁并返回true。如果锁不可用,则返回false。清单 5-33 中的类可以用来模拟哲学家,假设ReentrantLock类的一个对象代表一个叉子。

// Philosopher.java
package com.jdojo.threads;
import java.util.concurrent.locks.Lock;
public class Philosopher {
    private final Lock leftFork;
    private final Lock rightFork;
    private final String name; // Philosopher's name
    public Philosopher(Lock leftFork, Lock rightFork,
          String name) {
        this.leftFork = leftFork;
        this.rightFork = rightFork;
        this.name = name;
    }
    public void think() {
        System.out.println(name + " is thinking...");
    }
    public void eat() {
        // Try to get the left fork
        if (leftFork.tryLock()) {
            try {
                // try to get the right fork
                if (rightFork.tryLock()) {
                    try {
                        // Got both forks. Eat now
                        System.out.println(name +
                            " is eating...");
                    } finally {
                        // release the right fork
                        rightFork.unlock();
                    }
                }
            } finally {
                // release the left fork
                leftFork.unlock();
            }
        }
    }
}

To create philosophers, you would use code like:
Lock fork1 = new ReentrantLock();
Lock fork2 = new ReentrantLock();
...
Lock fork5 = new ReentrantLock();
Philosopher p1 = new Philosopher(fork1, fork2, "John");
Philosopher p2 = new Philosopher(fork2, fork3, "Wallace");
...

Philosopher p5 = new Philosopher(fork5, fork1, "Charles");

Listing 5-33A Philosopher Class 

to Represent a Philosopher

留给读者的练习是完成代码并在五个不同的线程中运行所有五个哲学家来模拟哲学家进餐问题。你也可以思考如何使用synchronized关键字来解决同样的问题。仔细阅读eat()方法中的代码。它试图一次得到一个左右叉。如果你只能得到一把叉子,而另一把没有,你就放下你得到的那把,这样别人就可以得到它。eat()方法中的代码只有获取分叉的逻辑。在一个真实的程序中,如果你不能同时获得两个分叉,你可能会等待一段时间,然后再次尝试获得两个分叉。你将不得不写那个逻辑。

您可以在实例化ReentrantLock类时指定锁的公平性。公平性表示当多个线程等待获取锁时,将锁分配给一个线程的方式。在公平锁中,线程按照它们请求锁的顺序获得锁。在非公平锁中,允许向前跳转一个线程。例如,在非公平锁中,如果一些线程正在等待一个锁,而另一个稍后请求同一锁的线程在等待线程之前获得该锁,如果该锁在第二个线程请求它时变得可用。这听起来可能有点奇怪,因为让等待线程等待并把锁授予稍后请求锁的线程是不公平的。但是,它有一个性能增益。使用非公平锁定可以减少挂起和恢复线程的开销。ReentrantLock类的tryLock()方法总是使用非公平锁。您可以创建公平锁和非公平锁,如下所示:

Lock nonFairLock1 = new ReentrantLock();
    // <- A non-fair lock (Default is non-fair)
Lock nonFairLock2 = new ReentrantLock(false);
    // <- A non-fair lock
Lock fairLock2 = new ReentrantLock(true);
    // <- A fair lock

一个ReentrantLock提供了一个互斥的锁定机制。也就是说,一次只有一个线程可以拥有ReentrantLock。如果你有一个由ReentrantLock保护的数据结构,写线程和读线程必须一次获取一个锁来修改或读取数据。如果您的数据结构经常被读取而很少被修改,那么ReentrantLock的这种限制(一次只能由一个线程拥有)可能会降低性能。在这种情况下,您可能希望多个读取器线程同时访问数据结构。然而,如果数据结构被修改,则只有一个写线程应该能够访问该数据结构。读写锁允许您使用ReadWriteLock接口的实例来实现这种锁定机制。它有两个方法:一个获取读取器锁,另一个获取写入器锁,如下所示:

public interface ReadWriteLock {
    Lock readLock();
    Lock writeLock();
}

一个ReentrantReadWriteLock类是一个ReadWriteLock接口的实现。只有一个线程可以持有ReentrantReadWriteLock的写锁,而多个线程可以持有它的读锁。清单 5-34 演示了ReentrantReadWriteLock的用法。请注意,在getValue()方法中,您使用了读锁,因此多个线程可以并发地读取数据。setValue()方法使用写锁,因此在给定时间只有一个线程可以修改数据。

Note

ReadWriteLock允许您拥有同一个锁的读版本和写版本。只要另一个线程不拥有写锁,多个线程就可以拥有一个读锁。但是,一次只能有一个线程拥有写锁。

// ReadMostlyData.java
package com.jdojo.threads;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadMostlyData {
    private int value;
    private final ReentrantReadWriteLock rwLock =
        new ReentrantReadWriteLock();
    private final Lock rLock = rwLock.readLock();
    private final Lock wLock = rwLock.writeLock();
    public ReadMostlyData(int value) {
        this.value = value;
    }
    public int getValue() {
        // Use the read lock, so multiple threads may
        // read concurrently
        rLock.lock();
        try {
            return this.value;
        } finally {
            rLock.unlock();
        }
    }
    public void setValue(int value) {
        // Use the write lock, so only one thread can
        // write at a time
        wLock.lock();
        try {
            this.value = value;
        } finally {
            wLock.unlock();
        }
    }
}

Listing 5-34Using a ReentrantReadWriteLock

to Guard a Read-Mostly Data Structure

同步器

我讨论了如何使用内在锁和显式锁的互斥机制来协调多线程对临界区的访问。一些称为同步器的类用于在需要对临界区进行非互斥访问的情况下协调一组线程的控制流。同步器对象与一组线程一起使用。它维护一个状态,根据它的状态,它让一个线程通过或者强制它等待。本节讨论以下类型的同步器:

  • 旗语

  • 障碍

  • 短语

  • 理解

  • 交换器

其他类也可以充当同步器,比如阻塞队列。

旗语

信号量用于控制可以访问资源的线程数量。一个synchronized块还控制对一个资源的访问,该资源是关键部分。那么,信号量和synchronized块有什么不同呢?一个synchronized块只允许一个线程访问一个资源(一个临界区),而一个信号量允许N线程(N可以是任何正数)访问一个资源。

如果N设置为 1,信号量可以充当synchronized块,以允许线程互斥地访问资源。信号量维护许多虚拟许可证。为了访问一个资源,一个线程获得一个许可,当它处理完这个资源时,它释放这个许可。如果许可证不可用,请求线程将被阻塞,直到许可证可用。您可以将信号量的许可看作一个令牌。

让我们讨论一个日常生活中使用信号量的例子。假设有一家餐厅有三张餐桌。那家餐馆一次只能有三个人吃饭。当一个人到达餐馆时,他们必须带一个代币去找一张桌子。当他们吃完后,他们会归还代币。每个代币代表一张餐桌。如果一个人在三张桌子都在使用的时候到达餐馆,他们必须等到有桌子空出来。如果一张桌子不能马上得到,你可以选择等到有空的时候或者去另一家餐馆。让我们使用信号量来模拟这个例子。你会有一个有三个许可的信号量。每个许可证代表一张餐桌。java.util.concurrent包中的Semaphore类代表信号量同步器。使用信号量的一个构造函数创建信号量:

final int MAX_PERMITS = 3;
Semaphore s = new Semaphores(MAX_PERMITS);

Semaphore类的另一个构造函数将公平性作为第二个参数:

final int MAX_PERMITS = 3;
Semaphore s = new Semaphores(MAX_PERMITS, true);
    // <- A fair semaphore

信号量的fairness与锁的含义相同。如果你创建一个公平的信号量,在多个线程请求许可的情况下,信号量将保证先进先出(FIFO)。也就是说,首先请求许可的线程将首先获得许可。

要获得许可,请使用acquire()方法。如果有许可证,它会立即返回。如果没有许可证,它将被阻止。线程在等待许可变得可用时可以被中断。Semaphore类的其他方法让您一次获得一个或多个许可。

要发布许可证,请使用release()方法。

清单 5-35 包含了一个Restaurant类的代码。它将餐馆中可用桌子的数量作为其构造函数中的一个参数,并创建一个信号量,其许可数量等于桌子的数量。客户使用其getTable()returnTable()方法分别获取和返回一个表。在getTable()方法中,您获得了一个许可。如果客户调用了getTable()方法,但是没有可用的表,他们必须等待,直到有一个表可用。这个类依赖于清单 5-36 中声明的RestaurantCustomer类。

// Restaurant.java
package com.jdojo.threads;
import java.util.concurrent.Semaphore;
public class Restaurant {
    private final Semaphore tables;
    public Restaurant(int tablesCount) {
        // Create a semaphore using number of tables we
        // have
        this.tables = new Semaphore(tablesCount);
    }
    public void getTable(int customerID) {
        try {
            System.out.println("Customer #" + customerID
                + " is trying to get a table.");
            // Acquire a permit for a table
            tables.acquire();
            System.out.println("Customer #" + customerID
                + " got a table.");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    public void returnTable(int customerID) {
        System.out.println("Customer #" + customerID +
            " returned a table.");
        tables.release();
    }

    public static void main(String[] args) {
        // Create a restaurant with two dining tables
        Restaurant restaurant = new Restaurant(2);
        // Create five customers
        for (int i = 1; i <= 5; i++) {
            RestaurantCustomer c = new RestaurantCustomer(
                restaurant, i);
            c.start();
        }
    }
}

Customer #4 is trying to get a table.
Customer #5 is trying to get a table.
Customer #1 is trying to get a table.
Customer #3 is trying to get a table.

Listing 5-35A Restaurant Class

, Which Uses a Semaphore to Control Access to Tables

清单 5-36 包含了一个RestaurantCustomer类的代码,该类的对象代表一个餐馆中的顾客。客户线程的run()方法从餐馆获取一张桌子,随机吃一段时间,然后将桌子返回给餐馆。当您运行Restaurant类时,您可能会得到相似但不相同的输出。您可能会注意到,您创建了一个只有两张桌子的餐厅,有五位顾客正在用餐。在任何给定的时间,只有两个客户在吃饭,如输出所示。

// RestaurantCustomer.java
package com.jdojo.threads;
import java.util.Random;
class RestaurantCustomer extends Thread {
    private final Restaurant r;
    private final int customerID;
    private static final Random random = new Random();
    public RestaurantCustomer(Restaurant r,
          int customerID) {
        this.r = r;
        this.customerID = customerID;
    }
    @Override
    public void run() {
        r.getTable(this.customerID); // Get a table
        try {
            // Eat for some time. Use number between 1
            // and 30 seconds
            int eatingTime = random.nextInt(30) + 1;
            System.out.println("Customer #"
                    + this.customerID
                    + " will eat for "
                    + eatingTime + " seconds.");
            Thread.sleep(eatingTime * 1000);
            System.out.println("Customer #"
                    + this.customerID
                    + " is done eating.");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            r.returnTable(this.customerID);
        }
    }
}

Listing 5-36A RestaurantCustomer Class to Represent a Customer in a Restaurant

信号量不受创建它所使用的许可数量的限制。每个release()方法增加一个许可证。因此,如果调用release()方法的次数比调用其acquire()方法的次数多,那么最终会比开始时拥有更多的许可。许可证不是在每个线程的基础上获取的。一个线程可以从信号量获取许可,另一个线程可以返回它。这就把正确使用获取和发布许可的负担留给了开发者。信号量有其他方法来获得许可,如果许可不能立即获得,这将让您后退而不是强迫您等待,例如tryAcquire()acquireUninterruptibly()方法。

障碍

屏障用于使一组线程在屏障点相遇。到达屏障的组中的线程等待,直到该组中的所有线程都到达。一旦组中的最后一个线程到达屏障,组中的所有线程都被释放。当任务可以分成子任务时,可以使用屏障;每个子任务都可以在一个单独的线程中执行,每个线程必须在一个公共点相遇,以组合它们的结果。图 5-8 到 5-11 描述了屏障同步器如何让一组三个线程在屏障点相遇并让它们继续前进。

img/323070_3_En_5_Fig11_HTML.jpg

图 5-11

所有三个线程都成功通过了屏障

img/323070_3_En_5_Fig10_HTML.jpg

图 5-10

所有三个线程到达屏障,然后立即被释放

img/323070_3_En_5_Fig9_HTML.jpg

图 5-9

一个线程等待另外两个线程到达屏障

img/323070_3_En_5_Fig8_HTML.jpg

图 5-8

三个线程到达一个障碍

java.util.concurrent包中的CyclicBarrier类提供了屏障同步器的实现。它被称为循环屏障,因为一旦屏障点的所有等待线程都被释放,您就可以通过调用它的reset()方法来重用屏障。它还允许您将一个 barrier 动作与其相关联,这是一个Runnable任务(一个实现了Runnable接口的类的对象)。就在所有线程被释放之前,屏障动作被执行。当所有线程在屏障处相遇时,但在它们被释放之前,您可以将屏障操作视为“聚会时间”。以下是在程序中使用屏障需要执行的步骤:

  1. 用组中的线程数:

    CyclicBarrier barrier = new CyclicBarrier(5);
    // <- 5 threads
    
    

    创建一个CyclicBarrier类的对象,如果你想在所有线程都遇到栅栏时执行栅栏动作,你可以使用CyclicBarrier类的另一个构造函数:

    // Assuming a BarrierAction class implements the
    // Runnable interface
    Runnable barrierAction = new BarrierAction();
    CyclicBarrier barrier = new CyclicBarrier(
        5, barrierAction);
    
    
  2. 当一个线程准备在栅栏处等待时,该线程执行CyclicBarrier类的await()方法。await()方法有两种。一个让您无条件地等待所有其他线程,另一个让您指定超时。

清单 5-37 中的程序演示了如何使用循环屏障。您可能会得到不同的输出。然而,事件的顺序将是相同的:所有三个线程将工作一段时间,在障碍处等待其他人到达,有一个聚会时间,并通过障碍。

// MeetAtBarrier.java
package com.jdojo.threads;
import java.util.Random;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.BrokenBarrierException;
public class MeetAtBarrier extends Thread {
    private final CyclicBarrier barrier;
    private final int ID;
    private static final Random random = new Random();
    public MeetAtBarrier(int ID, CyclicBarrier barrier) {
        this.ID = ID;
        this.barrier = barrier;
    }
    @Override
    public void run() {
        try {
            // Generate a random number between 1 and 30
            // to wait
            int workTime = random.nextInt(30) + 1;
            System.out.println("Thread #" + ID
                    + " is going to work for "
                    + workTime + " seconds");
            // Yes. Sleeping is working for this thread!!!
            Thread.sleep(workTime * 1000);
            System.out.println("Thread #" + ID
                    + " is waiting at the barrier...");
            // Wait at barrier for other threads in group
            // to arrive
            this.barrier.await();
            System.out.println("Thread #" + ID
                    + " passed the barrier...");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (BrokenBarrierException e) {
            System.out.println("Barrier is broken...");
        }
    }
    public static void main(String[] args) {
        // Create a barrier for a group of three threads
        // with a barrier action
        String msg =
            "We are all together. It's party time...";
        Runnable barrierAction = () ->
             System.out.println(msg);
        CyclicBarrier barrier =
             new CyclicBarrier(3, barrierAction);
        for (int i = 1; i <= 3; i++) {
            MeetAtBarrier t =
                new MeetAtBarrier(i, barrier);
            t.start();
        }
    }
}

Thread #2 is going to work for 15 seconds
Thread #3 is going to work for 2 seconds
Thread #1 is going to work for 30 seconds
Thread #3 is waiting at the barrier...
Thread #2 is waiting at the barrier...
Thread #1 is waiting at the barrier...
We are all together. It's party time...
Thread #3 passed the barrier...
Thread #2 passed the barrier...
Thread #1 passed the barrier...

Listing 5-37A Class That Demonstrates How to Use a CyclicBarrier in a Program

你可能已经注意到,在MeetAtBarrier类的run()方法中,你正在捕捉BrokenBarrierException。如果线程超时或者在等待障碍点时被中断,则认为障碍被破坏。超时的线程用一个TimeoutException释放,而所有在关卡等待的线程用一个BrokenBarrierException释放。

注意cyclic barrier 类的 await()方法返回调用它的线程的到达索引。到达屏障的最后一个线程的索引为零,第一个线程的索引为组中线程数减一。您可以使用这个索引在您的程序中进行任何特殊的处理。例如,最后到达屏障的线程可以记录所有参与线程完成特定一轮计算的时间。

短语

java.util.concurrent包中的Phaser类提供了另一个名为 phaser 的同步屏障的实现。一个Phaser提供类似于CyclicBarrierCountDownLatch同步器的功能。我将在下一节介绍CountDownLatch同步器。但是,它更强大、更灵活。它提供了以下功能:

  • CyclicBarrier一样,Phaser也是可重用的。

  • CyclicBarrier不同,在Phaser上同步的参与方数量可以动态变化。在CyclicBarrier中,参与者的数量在屏障建立时就已经确定了。但是,在Phaser中,您可以随时添加或删除参与方。

  • 一个Phaser有一个相关的相位号,从零开始。当所有注册方到达一个Phaser时,Phaser前进到下一个阶段,并且阶段号增加 1。相数的最大值为Integer.MAX_VALUE。达到最大值后,相数从零开始。

  • 一个Phaser有一个终止状态。在终止状态下对Phaser调用的所有同步方法会立即返回,而不会等待前进。Phaser类提供了不同的方法来终止相位器。

  • 有三种类型的交易方计数:注册交易方计数、到达交易方计数和未到达交易方计数。registered parties count 是为同步而注册的参与方数。到达方计数是相位器当前阶段已经到达的参与方的数量。未到达方计数是尚未到达相位器当前阶段的参与方的数量。当最后一队到达时,相位器进入下一阶段。请注意,所有三种类型的交易方计数都是动态的。

  • Optionally, a Phaser lets you execute a phaser action when all registered parties arrive at the phaser. Recall that a CyclicBarrier lets you execute a barrier action, which is a Runnable task. Unlike a CyclicBarrier, you specify a phaser action by writing code in the onAdvance() method of your Phaser class. It means you need to use your own phaser class by inheriting it from the Phaser class and override the onAdvance() method to provide a Phaser action. I discuss an example of this kind shortly.

    img/323070_3_En_5_Fig12_HTML.jpg

    图 5-12

    具有三个相位的相位器,每个相位中有不同数量的参与方

图 5-12 显示了三相相位器。它在每个阶段同步不同数量的参与方。图中箭头代表一方。

使用Phaser有几个步骤。您可以使用默认构造函数创建一个没有初始注册方的Phaser:

// A phaser with no registered parties
Phaser phaser = new Phaser();

另一个构造函数允许您在创建Phaser时注册参与方:

// A phaser with 5 registered parties
Phaser phaser = new Phaser(5);

一个Phaser可以以树状结构排列。其他构造函数允许您通过指定新创建的Phaser的父代来创建一个Phaser。一旦创建了一个Phaser,下一步就是注册对 phaser 上的同步感兴趣的各方。您可以通过以下方式注册交易方:

  • 通过在创建一个Phaser对象时指定要在Phaser类的构造函数中注册的参与方的数量

  • 通过使用Phaser类的register()方法一次注册一方

  • 通过使用Phaser类的bulkRegister(int parties)方法批量注册指定数量的参与方

Phaser的注册方可以通过注册新的注册方或撤销已注册方的注册而随时变更。您可以使用Phaser类的arriveAndDeregister()方法取消注册方的注册。该方法让一方到达Phaser并取消注册,而无需等待其他方到达。如果一方被撤销注册,在下一阶段的Phaser中,该方的数量将减少一个。

通常,Phaser中的一方意味着一个线程。然而,Phaser并不将参与方的注册与特定线程相关联。它只是维护一个计数,当一方注册时该计数加 1,当一方注销时该计数减 1。

一个Phaser最重要的部分是多方在上面同步的方式。在Phaser上同步的一种典型方式是让注册的用户到达,并在Phaser等待其他注册用户到达。一旦最后一个注册方到达Phaser,所有方进入Phaser的下一阶段。

Phaser类的arriveAndAwaitAdvance()方法让一方到达Phaser并等待其他方到达,然后才能继续。

Phaser类的arriveAndDeregister()方法让一方到达Phaser并注销,而不等待其他方到达。注销后,进入下一阶段所需的缔约方数量减少一个。通常,arriveAndDeregister()方法由控制方使用,其工作是控制其他方的进展,而不参与进展本身。通常,控制方向Phaser注册自己,并等待某些条件发生;当所需的条件发生时,它到达并从Phaser中注销自己,因此各方可以在Phaser上同步并前进。

让我们看一个使用Phaser来同步一组任务的例子,这样它们可以同时开始。清单 5-38 中所示的StartTogetherTask类的一个实例代表了本例中的一个任务。

// StartTogetherTask.java
package com.jdojo.threads;
import java.util.Random;
import java.util.concurrent.Phaser;
public class StartTogetherTask extends Thread {
    private final Phaser phaser;
    private final String taskName;
    private static Random rand = new Random();
    public StartTogetherTask(String taskName,
          Phaser phaser) {
        this.taskName = taskName;
        this.phaser = phaser;
    }

    @Override
    public void run() {
        System.out.println(taskName + ":Initializing...");
        // Sleep for some time between 1 and 5 seconds
        int sleepTime = rand.nextInt(5) + 1;
        try {
            Thread.sleep(sleepTime * 1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(taskName + ":Initialized...");
        // Wait for all parties to arrive to start the task
        phaser.arriveAndAwaitAdvance();
        System.out.println(taskName + ":Started...");
    }
}

Listing 5-38A StartTogetherTask Class

to Represent Tasks That Start Together by Synchronizing on a Phaser

StartTogetherTask类继承自Thread类。它的构造函数接受一个任务名和一个 Phaser 实例。在它的run()方法中,它输出一条正在初始化的消息。它通过休眠一段随机的时间15秒来伪装它的初始化。之后,它会打印一条消息,说明它已初始化。在这个阶段,它通过调用PhaserarriveAndAwaitAdvance()方法来等待Phaser前进。该方法将被阻止,直到所有注册方到达Phaser。当此方法返回时,它会打印一条消息,表明任务已经开始。清单 5-39 包含测试三个StartTogetherTask类型任务的代码。

// StartTogetherTaskTest.java
package com.jdojo.threads;
import java.util.concurrent.Phaser;
public class StartTogetherTaskTest {
    public static void main(String[] args) {
        // Start with 1 registered party
        Phaser phaser = new Phaser(1);
        // Let's start three tasks
        final int TASK_COUNT = 3;
        for (int i = 1; i <= TASK_COUNT; i++) {
            // Register a new party with the phaser for
            // each task
            phaser.register();
            // Now create the task and start it
            String taskName = "Task #" + i;
            StartTogetherTask task =
                new StartTogetherTask(taskName, phaser);
            task.start();
        }
        // Now, deregister the self, so all tasks can
        // advance
        phaser.arriveAndDeregister();
    }
}

Task #3:Initializing...
Task #2:Initializing...
Task #1:Initializing...
Task #3:Initialized...
Task #1:Initialized...
Task #2:Initialized...
Task #2:Started...
Task #1:Started...
Task #3:Started...

Listing 5-39Testing Some Objects of the StartTogetherTask Class

with a Phaser

首先,程序通过将1指定为初始注册方来创建一个Phaser对象:

// Start with 1 registered party
Phaser phaser = new Phaser(1);

你用Phaser一次注册一个任务。如果一个任务(或一方)在其他任务注册之前注册并开始,第一个任务将推进 phaser,因为将有一个注册方,它将自己到达 phaser。因此,您需要从一个注册方开始。它充当其他任务的控制方。

您在一个循环中创建了三个任务。在这个循环中,您用Phaser注册一个 party(代表一个任务),创建一个任务,并启动它。一旦完成了任务的设置,就可以调用PhaserarriveAndDeregister()方法。这将处理您在创建Phaser时已经注册的一个额外的参与方。该方法使一方到达Phaser并取消注册,而无需等待其他注册方到达。这个方法调用结束后,到达Phaser并前进就靠三个任务了。一旦三个任务都到达Phaser,它们将同时前进,从而使它们同时开始。您可能会得到不同的输出。然而,输出中的最后三条消息总是关于启动这三个任务。

如果您不想使用额外的一方作为控制器,您需要提前注册所有任务,以使该程序正常工作。您可以重写StartTogetherTaskTest类的main()方法中的代码,如下所示:

public static void main(String[] args) {
    // Start with 0 registered party
    Phaser phaser = new Phaser();
    // Let's start three tasks
    final int TASK_COUNT = 3;
    // Initialize all tasks in one go
    phaser.bulkRegister(TASK_COUNT);
    for(int i = 1; i <= TASK_COUNT; i++) {
        // Now create the task and start it
        String taskName = "Task #" + i;
        StartTogetherTask task =
            new StartTogetherTask(taskName, phaser);
        task.start();
    }
} 

这一次,您创建一个没有注册方的Phaser。您可以使用bulkRegister()方法一次性注册所有政党。请注意,您不再需要在循环中注册一个参与方。新法典与旧法典具有同等效力。只不过是用不同的方式写出了同样的逻辑。

CyclicBarrier一样,Phaser允许您使用其onAdvance()方法在阶段推进时执行动作。您需要通过从Phaser类继承来创建自己的 phaser 类,并覆盖onAdvance()方法来编写自定义的Phaser动作。在每次相位提前时,相位器的onAdvance()方法被调用。Phaser类中的onAdvance()方法声明如下。第一个参数是相数,第二个参数是注册方数:protected boolean onAdvance(int phase, int registeredParties)

除了定义一个阶段推进动作,Phaser类的onAdvance()方法还控制一个Phaser的终止状态。如果一个PhaseronAdvance()方法返回true,则该Phaser被终止。您可以使用Phaser类的isTerminated()方法来检查 phaser 是否被终止。您也可以使用 phaser 的forceTermination()方法来终止它。

清单 5-40 演示了如何添加一个Phaser动作。这是一个微不足道的例子。但是,它演示了添加和执行 Phaser 操作的概念。它使用一个匿名类来创建一个自定义 Phaser 类。匿名类覆盖 onAdvance()方法来定义 Phaser 动作。它只是在 onAdvance()方法中打印一条消息作为 Phaser 操作。它返回 false,这意味着 phaser 不会从 onAdvance()方法中终止。稍后,它将自己注册为一方,并使用 arriveAndDeregister()方法触发阶段推进。在每次相位超前时,执行 onAdvance()方法定义的 Phaser 操作。

// PhaserActionTest.java
package com.jdojo.threads;
import java.util.concurrent.Phaser;
public class PhaserActionTest {
    public static void main(String[] args) {
        // Create a Phaser object using an anonymous class
        // and override its onAdvance() method to define a
        // phaser action
        Phaser phaser = new Phaser() {
            @Override
            protected boolean onAdvance(int phase,
                  int parties) {
                System.out.println(
                    "Inside onAdvance(): phase = "
                    + phase + ", Registered Parties = "
                    + parties);
                // Do not terminate the phaser by returning
                // false
                return false;
            }
        };
        // Register the self (the "main" thread) as a party
        phaser.register();
        // Phaser is not terminated here
        System.out.println("#1: isTerminated(): " +
            phaser.isTerminated());
        // Since we have only one party registered, this
        // arrival will advance the phaser and registered
        // parties reduces to zero
        phaser.arriveAndDeregister();
        // Trigger another phase advance
        phaser.register();
        phaser.arriveAndDeregister();
        // Phaser is still not terminated
        System.out.println("#2: isTerminated(): " +
            phaser.isTerminated());
        // Terminate the phaser
        phaser.forceTermination();
        // Phaser is terminated
        System.out.println("#3: isTerminated(): " +
            phaser.isTerminated());
    }

}

#1: isTerminated(): false
Inside onAdvance(): phase = 0, Registered Parties = 0
Inside onAdvance(): phase = 1, Registered Parties = 0
#2: isTerminated(): false
#3: isTerminated(): true

Listing 5-40Adding a Phaser Action to a Phaser

让我们考虑用一个Phaser来解决一个复杂的任务。这一次,Phaser通过在每个阶段同步多方来在多个阶段中工作。多个任务在每个阶段生成随机整数,并将它们添加到一个List。在Phaser终止后,您计算所有随机生成的整数的总和。

清单 5-41 包含任务的代码。我们姑且称这个任务为AdderTask。在它的run()方法中,它在110之间创建一个随机整数,将该整数加到一个List,并等待一个Phaser前进。在Phaser的每个阶段,它不断向列表中添加一个整数,直到Phaser终止。

// AdderTask.java
package com.jdojo.threads;
import java.util.List;
import java.util.Random;
import java.util.concurrent.Phaser;
public class AdderTask extends Thread {
    private final Phaser phaser;
    private final String taskName;
    private final List<Integer> list;
    private static Random rand = new Random();
    public AdderTask(String taskName, Phaser phaser,
          List<Integer> list) {
        this.taskName = taskName;
        this.phaser = phaser;
        this.list = list;
    }

    @Override
    public void run() {
        do {
            // Generate a random integer between 1 and 10
            int num = rand.nextInt(10) + 1;
            System.out.println(taskName + " added " +
                num);
            // Add the integer to the list
            list.add(num);
            // Wait for all parties to arrive at the phaser
            phaser.arriveAndAwaitAdvance();
        } while (!phaser.isTerminated());
    }
}

Listing 5-41An AdderTask Class

Whose Instances Can Be Used with a Phaser to Generate Some Integers

清单 5-42 通过从Phaser类继承一个匿名类来创建一个Phaser。在它的onAdvance()方法中,它在第二次提前之后终止相位器,这由PHASE_COUNT常数控制,或者如果注册方减少到零。您使用一个同步的List来收集加法器任务生成的随机整数。您计划使用三个加法器任务,所以您用 phaser 注册了四个参与方(比任务数多一个)。附加方将用于同步每个阶段。它等待每个相位提前,直到Phaser终止。最后,计算所有加法器任务生成的随机整数之和,并显示在标准输出上。您可能会得到不同的输出。

// AdderTaskTest.java
package com.jdojo.threads;

import java.util.List;
import java.util.ArrayList;
import java.util.Collections;
import java.util.concurrent.Phaser;

public class AdderTaskTest {
    public static void main(String[] args) {
        final int PHASE_COUNT = 2;
        Phaser phaser = new Phaser() {
            @Override
            public boolean onAdvance(int phase,
                  int parties) {
                // Print the phaser details
                System.out.println("Phase:" + phase
                        + ", Parties:"
                        + parties
                        + ", Arrived:"
                        + this.getArrivedParties());
                boolean terminatePhaser = false;
                // Terminate the phaser when we reach the
                // PHASE_COUNT or there is no registered
                // party
                if (phase >= PHASE_COUNT - 1 ||
                        parties == 0) {
                    terminatePhaser = true;
                }
                return terminatePhaser;
            }
        };
        // Use a synchronized List
        List<Integer> list = Collections.synchronizedList(
            new ArrayList<>());
        // Let's start three tasks
        final int ADDER_COUNT = 3;
        // Register parties one more than the number of
        // adder tasks. The extra party will synchronize to
        // compute the result of all generated integers by
        // all adder tasks
        phaser.bulkRegister(ADDER_COUNT + 1);
        for (int i = 1; i <= ADDER_COUNT; i++) {
            // Create the task and start it
            String taskName = "Task #" + i;
            AdderTask task = new AdderTask(taskName,
                phaser, list);
            task.start();
        }

        // Wait for the phaser to terminate, so we can
        // compute the sum of all generated integers by the
        // adder tasks
        while (!phaser.isTerminated()) {
            phaser.arriveAndAwaitAdvance();
        }
        // Phaser is terminated now. Compute the sum
        int sum = 0;
        for (Integer num : list) {
            sum = sum + num;
        }
        System.out.println("Sum = " + sum);
    }
}

Task #2 added 2
Task #1 added 2
Task #3 added 5
Phase:0, Parties:4, Arrived:4
Task #3 added 5
Task #1 added 1
Task #2 added 7
Phase:1, Parties:4, Arrived:4
Sum = 22

Listing 5-42A Program to Use Multiple AdderTask Tasks with a Phaser

理解

闩锁的工作方式类似于屏障,因为它也让一组线程等待,直到到达其终止状态。一旦一个锁存器到达它的终端状态,它就让所有的线程通过。与屏障不同,它是一次性对象。一旦达到终止状态,就不能重置和重用。闩锁可用于在一定数量的一次性活动完成之前许多活动无法继续的情况。例如,一项服务应该在它所依赖的所有服务都启动后才启动。

java.util.concurrent包中的CountDownLatch类提供了一个闩锁的实现。使用其构造函数将它初始化为一个计数。调用闩锁对象的await()方法的所有线程都被阻塞,直到闩锁的countDown()方法被调用的次数达到其计数被设置的次数。当调用countDown()方法的次数和它的计数相同时,它就到达了终止状态,所有被阻塞的线程都被释放。一旦一个锁存器到达它的终端状态,它的await()方法立即返回。您可以将为闩锁设置的计数视为一组线程将等待发生的事件数。事件的每次发生都会调用它的countDown()方法。

清单 5-43 和 5-44 包含分别代表助手服务和主服务的类。主服务依赖于助手服务来启动。所有助手服务启动后,主服务才能启动。

// LatchMainService.java
package com.jdojo.threads;
import java.util.concurrent.CountDownLatch;
public class LatchMainService extends Thread {
    private final CountDownLatch latch;
    public LatchMainService(CountDownLatch latch) {
        this.latch = latch;
    }

    @Override
    public void run() {
        try {
            System.out.println(
                "Main service is waiting for helper " +
                "services to start...");
            latch.await();
            System.out.println(
                "Main service has started...");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

Listing 5-44A Class to Represent the Main Service That Depends on Helper Services to Start

// LatchHelperService.java
package com.jdojo.threads;
import java.util.concurrent.CountDownLatch;
import java.util.Random;
public class LatchHelperService extends Thread {
    private final int ID;
    private final CountDownLatch latch;
    private final Random random = new Random();
    public LatchHelperService(int ID,
          CountDownLatch latch) {
        this.ID = ID;
        this.latch = latch;
    }

    @Override
    public void run() {
        try {
            int startupTime = random.nextInt(30) + 1;
            System.out.println("Service #" + ID
                    + " starting in "
                    + startupTime + " seconds...");
            Thread.sleep(startupTime * 1000);
            System.out.println("Service #" + ID
                    + " has started...");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            // Count down on the latch to indicate that
            // it has started
            this.latch.countDown();
        }
    }
}

Listing 5-43A Class to Represent a Helper Service

清单 5-45 列出了一个测试带有闩锁的助手和主服务概念的程序。创建一个初始化为 2 的锁存器。首先启动主服务线程,它调用 latch 的await()方法来等待助手服务启动。一旦两个助手线程都调用了闩锁的countDown()方法,主服务就会启动。输出清楚地解释了事件的顺序。

// LatchTest.java
package com.jdojo.threads;
import java.util.concurrent.CountDownLatch;
public class LatchTest {
    public static void main(String[] args) {
        // Create a countdown latch with 2 as its counter
        CountDownLatch latch = new CountDownLatch(2);
        // Create and start the main service
        LatchMainService ms = new LatchMainService(latch);
        ms.start();
        // Create and start two helper services
        for (int i = 1; i <= 2; i++) {
            LatchHelperService lhs =
                new LatchHelperService(i, latch);
            lhs.start();
        }
    }
}

Main service is waiting for helper services to start...
Service #1 starting in 12 seconds...
Service #2 starting in 2 seconds...
Service #2 has started...
Service #1 has started...
Main service has started...

Listing 5-45A Class to Test the Concept of a Latch with Helper and Main Services

交换器

交换器是屏障的另一种形式。像屏障一样,交换器让两个线程在同步点互相等待。当两个线程都到达时,它们交换一个对象并继续它们的活动。这在构建两个独立方需要不时交换信息的系统时很有用。图 5-13 到 5-15 描述了一个交换器如何与两个线程一起工作并让它们交换一个对象。

img/323070_3_En_5_Fig15_HTML.jpg

图 5-15

两个线程在交换点相遇并交换对象

img/323070_3_En_5_Fig14_HTML.jpg

图 5-14

一个线程到达交换点,并等待另一个线程到达

img/323070_3_En_5_Fig13_HTML.jpg

图 5-13

两个线程独立执行它们的工作

Exchanger<V>类提供了一个交换器同步器的实现。它有一个不带参数的构造函数。类型参数V是双方将要交换的 Java 对象的类型。您可以创建一个让两个线程交换一个Long的交换器,如下所示:

Exchanger<Long> exchanger = new Exchanger<>();

Exchanger类只有一个方法exchange()。当一个线程准备与另一个线程交换对象时,它调用交换器的exchange()方法,等待另一个线程交换对象。等待交换对象的线程可能会被中断。

另一个重载版本的exchange()方法接受超时期限。如果指定了超时期限,调用此方法的线程将等待另一个线程交换对象,直到超时期限结束。exchange()方法将对象作为参数传递给另一个线程,并返回另一个线程传递的对象。你这样称呼exchange()法:

objectReceived = exchanger.exchange(objectedPassed);

清单 5-46 到 5-48 展示了在构建生产者/消费者系统时交换缓冲区的使用,缓冲区是整数对象的ArrayList。要声明整数对象的数组列表,必须按如下方式声明:

ArrayList<Integer> buffer = new ArrayList<Integer>();

在清单 5-48 中,您已经创建了一个交换器

Exchanger<ArrayList<Integer>> exchanger =
          new Exchanger<ArrayList<Integer>>();

类型声明Exchanger<ArrayList<Integer»表示交换器将让两个线程交换类型为ArrayList<Integer>的对象。您还可以注意到,ExchangerProducerExchangerConsumer类中的类型声明与前面的声明相匹配。生成器填充数据,并等待一段时间,让用户觉得它真的在填充数据。它等待消费者用来自消费者的空缓冲区来交换填满的缓冲区。消费者做的正好相反。它等待生产者交换缓冲区。当它从生产者那里得到一个满的缓冲区时,它清空缓冲区并再次等待生产者用一个满的缓冲区来交换它的空缓冲区。由于生产者和消费者在无限循环中运行,程序不会结束。您必须手动结束该程序。您将得到与清单 5-48 所示类似的输出。

// ExchangerProducerConsumerTest.java
package com.jdojo.threads;
import java.util.concurrent.Exchanger;
import java.util.ArrayList;
public class ExchangerProducerConsumerTest {
    public static void main(String[] args) {
        Exchanger<ArrayList<Integer>> exchanger =
            new Exchanger<>();
        // The producer will produce 5 integers at a time
        ExchangerProducer producer =
            new ExchangerProducer(exchanger, 5);
        ExchangerConsumer consumer =
            new ExchangerConsumer(exchanger);
        producer.start();
        consumer.start();
    }
}

Producer is filling the buffer with data...
Consumer is waiting to exchange the data...
Producer has produced:[1, 2, 3, 4, 5]
Producer is waiting to exchange the data...
Producer is filling the buffer with data...
Consumer has received:[1, 2, 3, 4, 5]
Consumer is emptying data from the buffer...
...

Listing 5-48A Class to Test a Producer/Consumer System with an Exchanger

// ExchangerConsumer.java
package com.jdojo.threads;
import java.util.concurrent.Exchanger;
import java.util.ArrayList;
import java.util.Random;
public class ExchangerConsumer extends Thread {
    private final Exchanger<ArrayList<Integer>> exchanger;
    private ArrayList<Integer> buffer = new ArrayList<>();
    private final Random random = new Random();
    public ExchangerConsumer(
          Exchanger<ArrayList<Integer>> exchanger) {
        this.exchanger = exchanger;
    }
    @Override
    public void run() {
        // keep consuming the integers
        while (true) {
            try {
                // Let's wait for the consumer to exchange
                // data
                System.out.println(
                    "Consumer is waiting to exchange" +
                    " the data...");
                buffer = exchanger.exchange(buffer);
                System.out.println(
                    "Consumer has received:" + buffer);
                System.out.println(
                    "Consumer is emptying data from" +
                    " the buffer...");
                // Wait for some time by sleeping
                int sleepTime = random.nextInt(20) + 1;
                // Sleep for some time
                Thread.sleep(sleepTime * 1000);
                // Empty the buffer
                this.emptyBuffer();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    public void emptyBuffer() {
        buffer.clear();
    }
}

Listing 5-47A Consumer Thread That Will Use an Exchanger to Exchange Data with a Producer

// ExchangerProducer.java
package com.jdojo.threads;
import java.util.concurrent.Exchanger;
import java.util.ArrayList;
import java.util.Random;
public class ExchangerProducer extends Thread {
    private final Exchanger<ArrayList<Integer>> exchanger;
    private ArrayList<Integer> buffer = new ArrayList<>();
    private final int bufferLimit;
    private final Random random = new Random();
    private int currentValue = 0; // to produce values
    public ExchangerProducer(
            Exchanger<ArrayList<Integer>> exchanger,
            int bufferLimit) {
        this.exchanger = exchanger;
        this.bufferLimit = bufferLimit;
    }
    @Override
    public void run() {
        // keep producing integers
        while (true) {
            try {
                System.out.println(
                    "Producer is filling the buffer" +
                    " with data...");
                // Wait for some time by sleeping
                int sleepTime = random.nextInt(20) + 1;
                Thread.sleep(sleepTime * 1000);
                // Fill the buffer
                this.fillBuffer();
                System.out.println(
                    "Producer has produced:" + buffer);
                // Let's wait for the consumer to
                // exchange data
                System.out.println(
                    "Producer is waiting to exchange" +
                    " the data...");
                buffer = exchanger.exchange(buffer);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    public void fillBuffer() {
        for (int i = 1; i <= bufferLimit; i++) {
            buffer.add(++currentValue);
        }
    }
}

Listing 5-46A Producer Thread That Will Use an Exchanger to Exchange Data with a Consumer

执行者框架

任务是工作的逻辑单元,通常使用线程来表示和执行任务。在程序中建模之前,应该考虑任务执行的许多方面。任务的几个方面如下:

  • 它是如何产生的。

  • 提交执行的方式。

  • 它是如何执行的。是同步执行还是异步执行?

  • 它被执行的时间。提交后是立即执行还是排队执行?

  • 哪个线程执行它?它是在提交它的线程中执行还是在另一个线程中执行?

  • 当任务执行完毕时,我们如何得到它的结果?

  • 我们如何知道在其执行过程中发生的错误?

  • 是否依赖其他任务来完成它的执行?

任务可以表示为一个Runnable。如果您想使用线程管理任务,请遵循下面描述的步骤。您可以创建一个类来表示任务:

public class MyTask implements Runnable {
    public void run() {
        // Task processing logic goes here
    }
}

您可以按如下方式创建任务:

MyTask task1 = new MyTask();
MyTask task2 = new MyTask();
MyTask task3 = new MyTask();

要执行这些任务,可以按如下方式使用线程:

Thread t1 = new Thread(task1);
Thread t2 = new Thread(task2);
Thread t3 = new Thread(task3);
t1.start();
t2.start();
t3.start();

如果您想获得任务执行的结果,您必须编写额外的代码。你可能会注意到,管理这样的任务很困难,如果不是不可能的话。任务执行还有一个非常重要的方面:应该创建多少个线程来执行一组任务?一种方法是为每个任务创建一个线程。为每个任务创建一个线程有以下缺点:

  • 创建和销毁线程需要开销和时间,这又会延迟任务执行的开始。

  • 每个线程都消耗资源。如果线程的数量超过了可用的 CPU 数量,其他线程将会闲置并消耗资源。

  • 每个平台都有支持最大线程数的限制。如果一个应用程序超过这个限制,它甚至可能崩溃!另一种方法是创建一个线程,让它处理所有任务的执行。这是另一种极端情况,具有以下缺点:

  • 让一个线程执行所有任务会使它成为一个顺序执行者。

  • 如果一个任务提交了另一个任务,并且依赖于它提交的任务的结果,那么这个策略很容易出现死锁。

  • 如果您有长时间运行的任务,等待它们执行的其他任务似乎没有响应,因为启动挂起的任务需要很长时间。

executor 框架试图解决任务执行的所有这些问题。该框架提供了一种将任务提交与任务执行分开的方法。您创建一个任务并将其提交给执行者。执行者负责任务的执行细节。它提供可配置的策略来控制任务执行的许多方面。

java.util.concurrent包中的Executor接口是 executor 框架的基础。该接口只包含一个方法,如下所示:

public interface Executor {
    void execute (Runnable command);
}

您可以使用 executor 框架来执行前面提到的三个任务,如下所示:

// Get an executor instance.
Executor executor = Executors.newCachedThreadPool();
// Submit three tasks to the executor
executor.execute(task1);
executor.execute(task2);
executor.execute(task3);

注意,当您使用执行器时,您并没有创建三个线程来执行这三个任务。遗嘱执行人会替你决定。您只是调用了执行者的execute()方法来提交任务。执行器将管理将执行任务的线程和关于任务执行的其他细节。

executor 框架提供了一个类库来选择执行任务的线程使用策略。您可以选择在一个线程、固定数量的线程或可变数量的线程中运行所有任务。事实上,您可以选择一个线程池来执行您的任务,并且线程池可以配置池中有多少个线程以及如何维护这些线程。在任何情况下,池中的所有线程在变得可用时都会被重用。使用线程池来执行提交的任务有两个重要的优点:

  • 创建新线程和在使用完线程后销毁它们的开销减少了。执行器重用线程池中的线程。

  • 如果在任务提交时线程池中有可用的线程,则任务可以立即开始。这消除了线程创建和任务执行之间的时间延迟。

这里有必要提一下另一个名为ExecutorService的接口。它提供了执行器的一些高级特性,包括管理执行器的关闭和检查提交任务的状态。它继承自Executor接口。这个接口的一些重要方法有shutdown()shutdownNow()submit()awaitTermination()。我很快会讨论它们。

当不再需要执行程序时,关闭它是很重要的。执行器框架创建非守护线程来执行任务。通常,当一个线程执行完一个任务时,它不会被销毁。相反,它被保存在线程池中以备将来重用——线程是被销毁还是被保留取决于线程池的配置。如果一些非守护线程仍然存在,Java 应用程序将不会退出。因此,如果您忘记关闭执行器,您的应用程序可能永远不会退出。

执行者如何处理任务执行?为了避免详细冗长的讨论,这里有一个简单的解释。您可以在创建执行器时指定执行器用来管理任务的线程池类型。提交给执行者的所有任务都在一个队列中排队,这个队列称为工作队列。当一个线程变得可用时,它从工作队列中删除一个任务并执行它。当一个线程执行完一项任务时,根据线程池的类型,执行器要么销毁该线程,要么将它放回线程池中,以便可以重用它来执行另一项任务。您有许多选择来决定对执行器使用哪种线程池:

  • 您可以使用Executors类的一个工厂方法来获得一个执行器,它有一个预配置的线程池,如果您愿意,可以让您重新配置它。您将使用这种方法在您的示例中获得一个执行程序。您还可以使用这个类来获得一个无法重新配置的预配置执行器。Executors类获取执行器服务的常用方法如下:

    • newCachedThreadPool():返回一个ExecutorService对象。如果先前创建的线程可用,线程池将重用它们。否则,它会创建一个新线程来执行任务。它销毁并删除池中的空闲线程。线程池具有根据工作负载扩展和收缩的特性。

    • newFixedThreadPool(int nThreads):返回一个ExecutorService对象。线程池维护固定数量的线程。在任何时候,线程池都将拥有最大nThread数量的线程。如果一个任务到达工作队列,并且所有线程都忙于执行其他任务,那么该任务必须等待执行,直到一个线程变得可用。如果一个线程在任务执行过程中由于意外失败而被终止,它将被一个新线程替换。

    • newSingleThreadExecutor():返回一个ExecutorService对象。线程池只维护一个线程来执行所有任务。它保证一次只执行一个任务。如果单独的线程意外死亡,它将被一个新线程替换。

  • 您可以实例化ThreadPoolExecutor类并配置线程池。

  • 您可以从头开始创建自己的遗嘱执行人。

清单 5-49 包含了一个RunnableTask类的完整代码。

// RunnableTask.java
package com.jdojo.threads;
import java.util.Random;
public class RunnableTask implements Runnable {
    private final int taskId;
    private final int loopCounter;
    private final Random random = new Random();
    public RunnableTask(int taskId, int loopCounter) {
        this.taskId = taskId;
        this.loopCounter = loopCounter;
    }

    @Override
    public void run() {
        for (int i = 1; i <= loopCounter; i++) {
            try {
                int sleepTime = random.nextInt(10) + 1;
                System.out.println("Task #" + this.taskId
                        + " - Iteration #" + i
                        + " is going to sleep for "
                        + sleepTime + " seconds.");
                Thread.sleep(sleepTime * 1000);
            } catch (InterruptedException e) {
                System.out.println("Task #" + this.taskId
                        + " has been interrupted.");
                break;
            }
        }
    }
}

Listing 5-49A Runnable Task

RunnableTask类的一个对象代表你程序中的一个任务。您将有一个任务,它将休眠一段时间,并在标准输出上打印一条消息。睡眠时间将在110秒之间随机确定。每个任务将被分配一个任务 ID 和一个循环计数器。任务 ID 用于标识任务。循环计数器用于控制run()方法中的循环。清单 5-50 包含测试Runnable任务类的完整代码。

// RunnableTaskTest.java
package com.jdojo.threads;
import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;
public class RunnableTaskTest {
    public static void main(String[] args) {
        final int THREAD_COUNT = 3;
        final int LOOP_COUNT = 3;
        final int TASK_COUNT = 5;
        // Get an executor with three threads in its
        // thread pool
        ExecutorService exec =
            Executors.newFixedThreadPool(THREAD_COUNT);
        // Create five tasks and submit them to the
        // executor
        for (int i = 1; i <= TASK_COUNT; i++) {
            RunnableTask task =
                new RunnableTask(i, LOOP_COUNT);
            exec.submit(task);
        }
        // Let's shutdown the executor
        exec.shutdown();
    }
}

Task #1 - Iteration #1 is going to sleep for 9 seconds.
Task #2 - Iteration #1 is going to sleep for 2 seconds.
Task #3 - Iteration #1 is going to sleep for 7 seconds.
Task #2 - Iteration #2 is going to sleep for 5 seconds.
Task #2 - Iteration #3 is going to sleep for 7 seconds.
Task #3 - Iteration #2 is going to sleep for 2 seconds.
...

Listing 5-50A Class to Test an Executor to Run Some Runnable Tasks

RunnableTaskTest类创建一个有三个线程的Executor。它创建了五个RunnableTask类的实例——每个任务在其run()方法中进行三次迭代。五项任务全部提交给Executor。您已经使用了一个带有固定线程数的线程池的执行器。您的执行器的线程池中只有三个线程,一次只能执行三个任务。当执行器完成前三个任务中的一个时,它开始第四个。注意在提交所有任务后关闭执行器的exec.shutdown()方法调用。执行器的shutdownNow()方法调用试图通过中断来停止正在执行的任务,并丢弃未决的任务。它返回所有已放弃的挂起任务的列表。如果您替换main()方法中的exec.shutdown()exec.shutdownNow(),您可能会得到类似所示的输出:

Task #1 - Iteration #1 is going to sleep for 7 seconds.
Task #2 - Iteration #1 is going to sleep for 10 seconds.
Task #3 - Iteration #1 is going to sleep for 9 seconds.
Task #2 has been interrupted.
Task #3 has been interrupted.
Task #1 has been interrupted.

产生结果的任务

当一项任务完成时,你如何得到它的结果?可以在执行时返回结果的任务必须表示为Callable<V>接口的实例:

public interface Callable<V> {
    V call() throws Exception;
}

类型参数V是任务结果的类型。注意,Runnable接口的run()方法不能返回值,也不能抛出任何检查过的异常。Callable接口的call()方法可以返回任何类型的值。它还允许您抛出异常。

让我们将清单 5-49 中的RunnableTask类重做为CallableTask,如清单 5-51 所示。

// CallableTask.java
package com.jdojo.threads;
import java.util.Random;
import java.util.concurrent.Callable;
public class CallableTask implements Callable<Integer> {
    private final int taskId;
    private final int loopCounter;
    private final Random random = new Random();
    public CallableTask(int taskId, int loopCounter) {
        this.taskId = taskId;
        this.loopCounter = loopCounter;
    }
    @Override
    public Integer call() throws InterruptedException {
        int totalSleepTime = 0;
        for (int i = 1; i <= loopCounter; i++) {
            try {
                int sleepTime = random.nextInt(10) + 1;
                System.out.println("Task #" + this.taskId
                        + " - Iteration #" + i
                        + " is going to sleep for "
                        + sleepTime + " seconds.");
                Thread.sleep(sleepTime * 1000);
                totalSleepTime = totalSleepTime +
                    sleepTime;
            } catch (InterruptedException e) {
                System.out.println("Task #" + this.taskId
                        + " has been interrupted.");
                throw e;
            }
        }
        return totalSleepTime;
    }
}

Listing 5-51A Callable Task

任务的call()方法返回所有休眠周期的总和。清单 5-52 展示了Callable任务的用法。每次运行程序时,您可能会得到不同的输出。

// CallableTaskTest.java
package com.jdojo.threads;

import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.concurrent.ExecutionException;

public class CallableTaskTest {
    public static void main(String[] args) {
        // Get an executor with three threads in its
        // thread pool
        ExecutorService exec =
            Executors.newFixedThreadPool(3);
        // Create the callable task with loop counter as 3
        CallableTask task = new CallableTask(1, 3);
        // Submit the callable task to executor
        Future<Integer> submittedTask = exec.submit(task);
        try {
            Integer result = submittedTask.get();
            System.out.println(
                "Task's total sleep time: " + result +
                " seconds");
        } catch (ExecutionException e) {
            System.out.println(
                "Error in executing the task.");
        } catch (InterruptedException e) {
            System.out.println(
                "Task execution has been interrupted.");
        }
        // Let's shutdown the executor
        exec.shutdown();
    }
}

Task #1 - Iteration #1 is going to sleep for 6 seconds.
Task #1 - Iteration #2 is going to sleep for 5 seconds.
Task #1 - Iteration #3 is going to sleep for 4 seconds.
Task's total sleep time: 15 seconds

Listing 5-52A Class to Demonstrate How to Use a Callable Task with an Executor

我一步一步地解释这两个清单中的逻辑。

CallableTask类定义了call()方法,它包含任务处理的逻辑。它将任务的所有睡眠时间加起来并返回。

CallableTaskTest类使用一个线程池中有三个线程的执行器。

ExecutorService.submit()方法返回一个Future<V>对象。Future是一个界面,可以让你跟踪你提交的任务的进度。它包含以下方法:

  • boolean cancel(boolean mayInterruptIfRunning)

  • V get() throws InterruptedException, ExecutionException

  • V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException

  • boolean isCancelled()

  • boolean isDone()

get()方法返回任务执行的结果,该结果与Callable对象的call()方法的返回值相同。如果任务还没有执行完,get()方法就会阻塞。您可以使用另一种版本的get()方法来指定等待任务执行结果的超时时间。

cancel()方法取消一个提交的任务。它的调用对已完成的任务没有影响。它接受一个boolean参数来指示如果任务仍在运行,执行器是否应该中断该任务。如果你使用cancel(true)来取消一个任务,确保任务对中断做出正确的反应。

isDone()方法告诉您任务是否已经执行完毕。如果任务正常执行完毕、被取消或者在执行过程中出现异常,则返回true

CallableTaskTest类中,将返回的Future对象保存在submittedTask变量中。Future<Integer>声明表示您的任务返回一个Integer对象作为它的结果:

Future<Integer> submittedTask = exec.submit(task);

另一个重要的方法调用是对submittedTaskget()方法:

Integer result = submittedTask.get();

我将对get()方法的调用放在一个 try-catch 块中,因为它可能会抛出一个异常。如果任务没有执行完,get()方法将会阻塞。程序打印任务执行的结果,这是任务在执行过程中花费在睡眠上的总时间。

最后,使用它的shutdown()方法关闭执行器。

计划任务

executor 框架允许您计划将来运行的任务。您可以在给定的延迟后或定期运行任务。使用ScheduledExecutorService接口的实例来调度任务,可以使用Executors类的一个静态工厂方法来获得这个接口。你也可以使用这个接口的具体实现,也就是ScheduledThreadPoolExecutor类。要获得ScheduledExecutorService接口的实例,请使用下面的代码片段:

// Get scheduled executor service with 3 threads
ScheduledExecutorService sexec =
    Executors.newScheduledThreadPool(3);

要在某个延迟(比如说10秒)后安排一个任务(比如说task1,使用

sexec.schedule(task1, 10, TimeUnit.SECONDS);

要在某个延迟(比如说10秒)后安排一个任务(比如说task2,并在某个时间段(比如说25秒)后重复,请使用

sexec.scheduleAtFixedRate(task2, 10, 25,
    TimeUnit.SECONDS);

经过10秒的延迟后,task2将第一次执行。随后,它将在10 + 25秒、10 + 2 * 25秒、10 + 3 * 25秒后继续执行,以此类推。

您还可以在一次执行结束和下一次执行开始之间设置延迟时间来调度任务。要在40秒后第一次调度task3,并在每次执行完成后每隔60秒调度一次,请使用

sexec.scheduleWithFixedDelay(task3, 40, 60,
    TimeUnit.SECONDS);

ScheduledExecutorService接口没有提供使用绝对时间调度任务的方法。但是,您可以使用以下技术安排任务在绝对时间执行。假设scheduledDateTime是您想要执行任务的日期和时间:

import java.time.LocalDateTime;
import static java.time.temporal.ChronoUnit.SECONDS;
import java.util.concurrent.TimeUnit;
...
LocalDateTime scheduledDateTime =
    get the scheduled date and time for the task...
// Compute the delay from the time you schedule the task
long delay = SECONDS.between(LocalDateTime.now(),
    scheduledDateTime);
// Schedule the task
sexec.schedule(task, delay, TimeUnit.MILLISECONDS);

Note

ExecutorServicesubmit()方法提交任务立即执行。通过指定初始延迟为零,您可以使用ScheduledExecutorService.schedule()方法提交一个立即执行的任务。负的初始延迟将任务调度为立即执行。

清单 5-53 包含了一个Runnable任务的代码。它只是打印运行时的日期和时间。

// ScheduledTask.java
package com.jdojo.threads;
import java.time.LocalDateTime;
public class ScheduledTask implements Runnable {
    private final int taskId;
    public ScheduledTask(int taskId) {
        this.taskId = taskId;
    }
    @Override
    public void run() {
        LocalDateTime now = LocalDateTime.now();
        System.out.println("Task #" + this.taskId +
           " ran at " + now);
    }
}

Listing 5-53A Scheduled Task

清单 5-54 演示了如何调度任务。第二个任务已被安排重复运行。为了让它运行几次,在关闭执行程序之前,让主线程休眠60秒。关闭执行器会丢弃所有未完成的任务。停止重复的计划任务的一个好方法是在一定延迟后使用另一个计划任务取消它。当您运行ScheduledTaskTest类时,您可能会得到不同的输出。

// ScheduledTaskTest.java
package com.jdojo.threads;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class ScheduledTaskTest {
    public static void main(String[] args) {
        // Get an executor with 3 threads
        ScheduledExecutorService sexec =
            Executors.newScheduledThreadPool(3);
        // Task #1 and Task #2
        ScheduledTask task1 = new ScheduledTask(1);
        ScheduledTask task2 = new ScheduledTask(2);
        // Task #1 will run after 2 seconds
        sexec.schedule(task1, 2, TimeUnit.SECONDS);
        // Task #2 runs after 5 seconds delay and keep
        // running every 10 seconds
        sexec.scheduleAtFixedRate(task2, 5, 10,
            TimeUnit.SECONDS);
        // Let the current thread sleep for 60 seconds
        // and shut down the executor that will cancel
        // the task #2 because it is scheduled
        // to run after every 10 seconds
        try {
            TimeUnit.SECONDS.sleep(60);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // Shut down the executor
        sexec.shutdown();
    }
}

Task #1 ran at 2020-10-07T10:47:48.800387200
Task #2 ran at 2020-10-07T10:47:51.753682400
Task #2 ran at 2020-10-07T10:48:01.754210400
Task #2 ran at 2020-10-07T10:48:11.754739100
Task #2 ran at 2020-10-07T10:48:21.755259400
Task #2 ran at 2020-10-07T10:48:31.755795600
Task #2 ran at 2020-10-07T10:48:41.756322800

Listing 5-54A Class to Test Scheduled Task Executions Using the Executor Framework

处理任务执行中未捕获的异常

当任务执行过程中出现未捕获的异常时会发生什么?executor 框架很好地为您处理了这种未被捕获的异常。如果您使用Executorexecute()方法执行一个Runnable任务,任何未被捕获的运行时异常都将暂停任务执行,并且异常堆栈跟踪将被打印在控制台上,如清单 5-55 的输出所示。

// BadRunnableTask.java
package com.jdojo.threads;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class BadRunnableTask {
    public static void main(String[] args) {
        Runnable badTask = () -> {
            throw new RuntimeException(
                "The task threw an exception...");
        };
        ExecutorService exec = Executors.
            newSingleThreadExecutor();
        exec.execute(badTask);
        exec.shutdown();
    }
}
Exception in thread "pool-1-thread-1" java.lang.
    RuntimeException: The task threw an exception...
        at jdojo.threads/com.jdojo.threads.
           BadRunnableTask.
           lambda$main$0(BadRunnableTask.java:10)
        at java.base/java.util.concurrent.
           ThreadPoolExecutor.runWorker(
           ThreadPoolExecutor.java:1167)
        at java.base/java.util.concurrent.
           ThreadPoolExecutor\$Worker.
           run(ThreadPoolExecutor.java:641)
        at java.base/java.lang.Thread.run(
           Thread.java:844)

Listing 5-55Printing the Runtime Stack Trace from the execute() Method of the Executor

如果您使用ExecutorServicesubmit()方法提交任务,当您使用get()方法获得任务执行的结果时,executor 框架会处理异常并向您指出。Future 实例的get()方法抛出一个ExecutionException,将实际的异常包装为其原因。清单 5-56 说明了这种例子。即使提交了一个Runnable任务,也可以使用Future实例的get()方法。任务成功执行后,get()方法将返回null。如果在任务执行期间抛出一个未被捕获的异常,它会抛出一个ExecutionException

// BadCallableTask.java
package com.jdojo.threads;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Callable;
import java.util.concurrent.Future;
import java.util.concurrent.ExecutionException;

public class BadCallableTask {
    public static void main(String[] args) {
        Callable<Object> badTask = () -> {
            throw new RuntimeException(
                "The task threw an exception...");
        };
        // Create an executor service
        ExecutorService exec = Executors.
            newSingleThreadExecutor();
        // Submit a task
        Future submittedTask = exec.submit(badTask);
        try {
            // The get method should throw
            // ExecutionException
            Object result = submittedTask.get();
        } catch (ExecutionException e) {
            System.out.println(
                "Execution exception has occurred: "
                + e.getMessage());
            System.out.println(
                "Execution exception cause is: "
                + e.getCause().getMessage());
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        exec.shutdown();
    }
}

Execution exception has occurred:
    java.lang.RuntimeException:
    The task threw an exception...
Execution exception cause is:
    The task threw an exception...

Listing 5-56Future’s get() Method Throws ExecutionException, Wrapping the Actual Exception Thrown in Task Execution As Its Cause

遗嘱执行人的完成服务

在前面的小节中,我解释了如何使用Future对象获取任务执行的结果。要获取已提交任务的结果,您必须保留从执行器返回的Future对象的引用,如清单 5-52 所示。但是,如果您有许多已经提交给执行者的任务,并且希望在它们可用时知道它们的结果,那么您需要使用执行者的完成服务。它由一个CompletionService<V>接口的实例来表示。它结合了一个执行器和一个阻塞队列来保存已完成的任务引用。ExecutorCompletionService<V>类是CompletionService<V>接口的具体实现。下面是使用它的步骤:

  1. 创建一个执行者对象:

  2. 创建一个ExecutorCompletionService类的对象,将上一步中创建的执行器传递给它的构造函数:

ExecutorService exec = Executors.
    newScheduledThreadPool(3);

  1. 完成服务的take()方法返回已完成任务的引用。如果不存在已完成的任务,它将阻塞。如果您不想等待,万一没有已完成的任务,您可以使用poll()方法,如果队列中没有已完成的任务,该方法将返回null。这两种方法都从队列中删除已完成的任务(如果它们找到一个任务的话)。
  • 执行器完成服务在内部使用一个阻塞队列来保存已完成的任务。您还可以使用自己的阻塞队列来保存已完成的任务。
ExecutorCompletionService CompletionService =
    new ExecutorCompletionService(exec);

清单 5-57 到 5-59 说明了完成服务的使用。TaskResult类的一个实例代表一个任务的结果。有必要使用一个像TaskResult这样的定制对象来表示任务的结果,因为完成服务只是告诉您任务已经完成,您可以得到它的结果。它不会告诉您哪个任务已经完成。要识别已完成的任务,您需要在任务的结果中识别该任务。您的SleepingTask通过嵌入任务 ID 和任务的总睡眠时间,从它的call()方法返回一个TaskResult

// CompletionServiceTest.java
package com.jdojo.threads;

import java.util.concurrent.Future;
import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorCompletionService;

public class CompletionServiceTest {
    public static void main(String[] args) {
        // Get an executor with three threads in its thread
        // pool
        ExecutorService exec = Executors.
            newFixedThreadPool(3);

        // Completed task returns an object of the
        // TaskResult class
        ExecutorCompletionService<TaskResult>
        completionService
            = new ExecutorCompletionService<>(exec);
        // Submit five tasks and each task will sleep three
        // times for a random period between 1 and 10
        // seconds
        for (int i = 1; i <= 5; i++) {
            SleepingTask task = new SleepingTask(i, 3);
            completionService.submit(task);
        }
        // Print the result of each task as they are
        // completed
        for (int i = 1; i <= 5; i++) {
            try {
                Future<TaskResult> completedTask =
                    completionService.take();
                TaskResult result = completedTask.get();
                System.out.println("Completed a task - " +
                    result);
            } catch (ExecutionException ex) {
                System.out.println(
                    "Error in executing the task.");
            } catch (InterruptedException ex) {
                System.out.println("Task execution" +
                    " has been interrupted.");
            }
        }
        // Let's shut down the executor
        exec.shutdown();
    }
}

Task #3 - Iteration #1 is going to sleep for 3 seconds.
...
Task #4 - Iteration #1 is going to sleep for 5 seconds.
Completed a task - Task Name: Task #2, Task Result:15
    seconds
...
Completed a task - Task Name: Task #4, Task Result:15
    seconds
Completed a task - Task Name: Task #5, Task Result:18
    seconds

Listing 5-59A Class to Test the Completion Service

// SleepingTask.java
package com.jdojo.threads;
import java.util.Random;
import java.util.concurrent.Callable;

public class SleepingTask implements Callable<TaskResult> {
    private int taskId;
    private int loopCounter;
    private Random random = new Random();
    public SleepingTask(int taskId, int loopCounter) {
        this.taskId = taskId;
        this.loopCounter = loopCounter;
    }
    @Override
    public TaskResult call() throws InterruptedException {
        int totalSleepTime = 0;
        for (int i = 1; i <= loopCounter; i++) {
            try {
                int sleepTime = random.nextInt(10) + 1;
                System.out.println("Task #" + this.taskId
                    + " - Iteration #" + i
                    + " is going to sleep for "
                    + sleepTime + " seconds.");
                Thread.sleep(sleepTime * 1000);
                totalSleepTime = totalSleepTime +
                    sleepTime;
            } catch (InterruptedException e) {
                System.out.println("Task #" + this.taskId
                        + " has been interrupted.");
                throw e;
            }
        }
        return new TaskResult(taskId, totalSleepTime);
    }
}

Listing 5-58A Class Whose Object Represents a Callable Task and Produces a TaskResult As Its Result

// TaskResult.java
package com.jdojo.threads;
public class TaskResult {
    private final int taskId;
    private final int result;
    public TaskResult(int taskId, int result) {
        this.taskId = taskId;
        this.result = result;
    }
    public int getTaskId() {
        return taskId;
    }
    public int getResult() {
        return result;
    }
    @Override
    public String toString() {
        return "Task Name: Task #" + taskId +
            ", Task Result:" + result + " seconds";
    }
}

Listing 5-57A Class to Represent the Result of a Task

Fork/Join 框架

fork/join 框架是 executor 服务的一个实现,其重点是有效地解决这些问题,它可以通过利用机器上的多个处理器或多个内核来使用分治算法。该框架有助于解决涉及并行性的问题。通常,fork/join 框架适用于以下情况

  • 一个任务可以分成多个可以并行执行的子任务。

  • 当子任务完成后,可以将部分结果合并得到最终结果。

fork/join 框架创建了一个线程池来执行子任务。当一个线程正在等待一个子任务完成时,框架使用该线程来执行其他线程的其他未决子任务。一个空闲线程执行其他线程的任务的技术被称为工作窃取。该框架使用工作窃取算法来提高性能。java.util.concurrent包中的以下四个类是学习 fork/join 框架的核心:

  • ForkJoinPool

  • ForkJoinTask<V>

  • RecursiveAction

  • RecursiveTask<V>

ForkJoinPool类的一个实例代表一个线程池。ForkJoinTask类的一个实例代表一个任务。ForkJoinTask类是一个抽象类。它有两个具体的子类:RecursiveActionRecursiveTask。Java 8 增加了一个名为CountedCompleter<T>的抽象子类ForkJoinTask。该框架支持两种类型的任务:

  • 不产生结果的任务和产生结果的任务。RecursiveAction类的一个实例表示一个不产生结果的任务。

  • RecursiveTask类的一个实例代表一个产生结果的任务。

一项任务可能会也可能不会产生结果。两个类,RecursiveActionRecursiveTask,都提供了一个抽象的compute()方法。您的类(其对象代表一个 fork/join 任务)应该从这些类中的一个继承,并为compute()方法提供一个实现。通常情况下,compute()方法内部的逻辑编写如下:

if (Task is small) {
    Solve the task directly.
} else {
    Divide the task into subtasks.
    Launch the subtasks asynchronously (the fork stage).
    Wait for the subtasks to finish (the join stage).
    Combine the results of all subtasks.
}

ForkJoinTask 类的以下两个方法在任务执行期间提供了两个重要的功能:

  • fork()方法从异步执行的任务中启动一个新的子任务。

  • 方法让一个任务等待另一个任务完成。

使用 Fork/Join 框架的步骤

使用 fork/join 框架包括以下五个步骤。

步骤 1:声明一个代表任务的类

创建一个继承自RecursiveActionRecursiveTask类的类。此类的一个实例表示您想要执行的任务。如果任务产生一个结果,你需要从RecursiveTask类继承它。否则就从RecursiveAction类继承。RecursiveTask是一个泛型类。它采用一个类型参数,即任务结果的类型。返回Long结果的MyTask类可以声明如下:

public class MyTask extends RecursiveTask<Long> {
    // Code for your task goes here
}

步骤 2:实现compute()方法

执行任务的逻辑在类的compute()方法中。compute()方法的返回类型与任务返回的结果类型相同。MyTask类的compute()方法的声明如下所示:

public class MyTask extends RecursiveTask<Long> {
    public Long compute() {
        // Logic for the task goes here
    }
}

步骤 3:创建一个 Fork/Join 线程池

您可以使用ForkJoinPool类创建一个工作线程池来执行您的任务。该类的默认构造函数创建一个线程池,其并行度与机器上可用的处理器数量相同:

ForkJoinPool pool = new ForkJoinPool();

其他构造函数允许您指定池的并行性和其他属性。

步骤 4:创建 Fork/Join 任务

您需要创建任务的一个实例:

MyTask task = MyTask();

步骤 5:将任务提交给 Fork/Join 池执行

您需要调用ForkJoinPool类的invoke()方法,将您的任务作为参数传递。如果您的任务返回一个结果,那么invoke()方法将返回任务的结果。以下语句将执行您的任务:

long result = pool.invoke(task);

Fork/Join 示例

让我们考虑一个使用 fork/join 框架的简单例子。您的任务将生成几个随机整数,并计算它们的总和。清单 5-60 显示了您的任务的完整代码。

// RandomIntSum.java
package com.jdojo.threads;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.RecursiveTask;

public class RandomIntSum extends RecursiveTask<Long> {
    private static final Random randGenerator =
        new Random();
    private final int count;
    public RandomIntSum(int count) {
        this.count = count;
    }
    @Override
    protected Long compute() {
        long result = 0;
        if (this.count <= 0) {
            return 0L; // We do not have anything to do
        }
        if (this.count == 1) {
            // Compute the number directly and return the
            // result
            return (long) this.getRandomInteger();
        }
        // Multiple numbers. Divide them into many single
        // tasks. Keep the references of all tasks to call
        // their join() method later
        List<RecursiveTask<Long>> forks =
            new ArrayList<>();
        for (int i = 0; i < this.count; i++) {
            RandomIntSum subTask = new RandomIntSum(1);
            subTask.fork(); // Launch the subtask
            // Keep the subTask references to combine the
            // results later
            forks.add(subTask);
        }
        // Now wait for all subtasks to finish and combine
        // the results
        for (RecursiveTask<Long> subTask : forks) {
            result = result + subTask.join();
        }
        return result;
    }
    public int getRandomInteger() {
        // Generate the next random integer between
        // 1 and 100
        int n = randGenerator.nextInt(100) + 1;
        System.out.println("Generated a random integer: " +
            n);
        return n;
    }
}

Listing 5-60A ForkJoinTask Class to Compute the Sum of a Few Random Integers

RandomIntSum类继承自RecursiveTask<Long>类,因为它产生了Long类型的结果。结果是所有随机整数的和。它声明了一个用于生成随机数的randGenerator实例变量。count实例变量存储您想要使用的随机数的数量。count实例变量的值在构造函数中设置。

getRandomInteger()方法在1100之间生成一个随机整数,在标准输出上打印该整数值,并返回该随机整数。

compute()方法包含执行任务的主要逻辑。如果要使用的随机数的数量是 1,它将计算结果并将其返回给调用者。如果随机数的数量大于 1,它将启动与随机数数量一样多的子任务。请注意,如果您使用十个随机数,它将启动十个子任务,因为每个随机数都可以独立计算。最后,您需要组合所有子任务的结果。因此,您需要保留子任务的引用以备后用。您使用了一个List来存储所有子任务的引用。注意使用fork()方法来启动一个子任务。以下代码片段执行此逻辑:

List<RecursiveTask<Long>> forks = new ArrayList<>();
for(int i = 0; i < this.count; i++) {
    RandomIntSum subTask = new RandomIntSum(1);
    subTask.fork(); // Launch the subtask
    // Keep the subTask references to combine the
    // results at the end
    forks.add(subTask);
}

一旦所有的子任务都启动了,你需要等待所有的子任务完成,然后组合所有的随机整数得到总和。下面的代码片段执行这个逻辑。注意join()方法的使用,这将使当前任务等待子任务完成:

for(RecursiveTask<Long> subTask : forks) {
    result = result + subTask.join();
}

最后,compute()方法返回结果,这是所有随机整数的和。清单 5-61 有执行任务的代码,它是RandomIntSum类的一个实例。您可能会得到不同的输出。

// ForkJoinTest.java
package com.jdojo.threads;
import java.util.concurrent.ForkJoinPool;
public class ForkJoinTest {
    public static void main(String[] args) {
        // Create a ForkJoinPool to run the task
        ForkJoinPool pool = new ForkJoinPool();
        // Create an instance of the task
        RandomIntSum task = new RandomIntSum(3);
        // Run the task
        long sum = pool.invoke(task);
        System.out.println("Sum is " + sum);
    }
}

Generated a random integer: 26
Generated a random integer: 5
Generated a random integer: 68
Sum is 99

Listing 5-61Using a Fork/Join Pool to Execute a Fork/Join Task

这是使用 fork/join 框架的一个非常简单的例子。建议您探索 fork/join 框架类,以了解更多关于框架的信息。在任务的compute()方法中,可以用复杂的逻辑将任务分成子任务。与本例不同,您可能不知道需要启动多少子任务。您可以启动一个子任务,该子任务可以启动另一个子任务,依此类推。

线程局部变量

线程局部变量提供了一种为每个线程维护单独的变量值的方法。java.lang包中的ThreadLocal<T>类提供了线程局部变量的实现。它有五种方法:

  • T get()

  • protected T initialValue()

  • void remove()

  • void set(T value)

  • static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier)

get()set()方法分别用于获取和设置线程局部变量的值。initialValue()方法用于设置变量的初始值,它有一个protected访问。要使用它,你需要子类化ThreadLocal类并覆盖这个方法。您可以使用remove()方法删除该值。withInitial()方法允许您创建一个带有初始值的ThreadLocal

让我们创建一个CallTracker类,如清单 5-62 所示,来记录线程调用其call()方法的次数。

// CallTracker.java
package com.jdojo.threads;
public class CallTracker {
    // threadLocal variable is used to store counters for
    // all threads
    private static final ThreadLocal<Integer>
        threadLocal = new ThreadLocal<Integer>();
    public static void call() {
        Integer counterObject = threadLocal.get();
        // Initialize counter to 1
        int counter = 1;
        if (counterObject != null) {
            counter = counterObject + 1;
        }
        // Set the new counter
        threadLocal.set(counter);
        // Print how many times this thread has called
        // this method
        String threadName = Thread.currentThread().
            getName();
        System.out.println("Call counter for " +
            threadName + " = " + counter);
    }
}

Listing 5-62A Class That Uses a ThreadLocal Object

to Track Calls to Its Method

ThreadLocal类的get()方法基于线程工作。它通过执行get()方法的同一个线程返回由set()方法设置的值。如果一个线程第一次调用get()方法,它返回null。如果是第一次调用,程序将调用者线程的调用计数器设置为1。否则,它将呼叫计数器增加1。它在threadLocal对象中设置新的计数器。最后,call()方法输出一条关于当前线程调用该方法的次数的消息。

清单 5-63 在三个线程中使用了CallTracker类。每个线程在15之间随机调用这个方法若干次。您可以在输出中观察到,计数器是为每个线程的调用分别维护的。您可能会得到不同的输出。

// CallTrackerTest.java
package com.jdojo.threads;
import java.util.Random;
public class CallTrackerTest {
    public static void main(String[] args) {
        // Let's start three threads to the
        // CallTracker.call() method
        new Thread(CallTrackerTest::run).start();
        new Thread(CallTrackerTest::run).start();
        new Thread(CallTrackerTest::run).start();
    }
    public static void run() {
        Random random = new Random();
        // Generate a random value between 1 and 5
        int counter = random.nextInt(5) + 1;
        // Print the thread name and the generated random
        // number by the thread
        System.out.println(Thread.currentThread().getName()
                + " generated counter: " + counter);
        for (int i = 0; i < counter; i++) {
            CallTracker.call();
        }
    }
}

Thread-0 generated counter: 4
Thread-1 generated counter: 2
Thread-2 generated counter: 3
Call counter for Thread-0 = 1
Call counter for Thread-2 = 1
Call counter for Thread-1 = 1
Call counter for Thread-2 = 2
Call counter for Thread-0 = 2
Call counter for Thread-2 = 3
Call counter for Thread-1 = 2
Call counter for Thread-0 = 3
Call counter for Thread-0 = 4

Listing 5-63A Test Class for the CallTracker Class

initialValue()方法为每个线程设置线程局部变量的初始值。如果已经设置了初始值,那么在调用set()方法之前,对get()方法的调用将返回初始值。这是一个受保护的方法。您必须在子类中覆盖它。您可以使用匿名类将调用计数器的初始值设置为1000,如下所示:

// Create an anonymous subclass ThreadLocal class and
// override its initialValue()
// method to return 1000 as the initial value
private static ThreadLocal<Integer> threadLocal =
    new ThreadLocal<Integer>() {
        @Override
        public Integer initialValue() {
            return 1000;
        }
    };

子类化ThreadLocal类仅仅是为了有一个带有初始值的ThreadLocal实例是多余的。最后,类设计者实现了它(在 Java 8 中),并在ThreadLocal类中提供了一个名为withInitial()的工厂方法,可以指定初始值。该方法声明如下:

public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier)

指定的供应商为ThreadLocal提供初始值。供应商的get()方法用于获取初始值。您可以重写此逻辑,并用 lambda 表达式替换匿名类,如下所示:

// Create a ThreadLocal with an initial value of 1000
ThreadLocal<Integer> threadLocal = T
    hreadLocal.withInitial(() -> 1000);

有一个供应商作为初始值的供应商,您可以基于一些逻辑延迟生成初始值。以下语句创建一个 ThreadLocal,其初始值为检索初始值时当前时间的第二部分:

// Return the second part of the current time as the
// initial value
ThreadLocal<Integer> threadLocal =
    ThreadLocal.withInitial(() ->
        LocalTime.now().getSecond()
    );

您可以使用remove()方法来重置线程的线程局部变量的值。在调用了remove()方法之后,第一次调用get()方法就像第一次被调用一样,返回初始值。

线程局部变量的典型用途是存储线程的用户 ID、事务 ID 或事务上下文。线程在开始时设置这些值,在线程执行期间任何代码都可以使用这些值。有时,一个线程可能会启动子线程,这些子线程可能需要使用父线程中线程局部变量的值集。您可以通过使用从ThreadLocal类继承而来的InheritableThreadLocal<T>类的对象来实现这一点。子线程从父线程继承其初始值。然而,子线程可以使用set()方法设置自己的值。

设置线程的堆栈大小

JVM 中的每个线程都被分配了自己的堆栈。线程在执行期间使用其堆栈来存储所有局部变量。局部变量用于构造函数、方法或块(静态或非静态)中。每个线程的堆栈大小将限制一个程序中可以拥有的线程数量。局部变量在其作用域内被分配给堆栈上的内存。一旦它们超出范围,它们所使用的内存就会被回收。如果程序中某个线程使用了太多的线程,那么优化该线程的堆栈大小是非常必要的。如果堆栈太大,程序中可以有较少的线程。线程的数量将受到 JVM 可用内存的限制。如果堆栈太小,无法存储一次使用的所有局部变量,您可能会遇到一个StackOverflowError。要设置每个线程的堆栈大小,可以使用一个名为-Xss<size>的非标准 JVM 选项,其中<size>是线程堆栈的大小。要将堆栈大小设置为512 KB,可以使用如下命令:

java –Xss512k <other-arguments>

摘要

线程是程序中的一个执行单位。Thread类的一个实例代表 Java 程序中的一个线程。线程在Thread类或其子类的run()方法中开始执行。要在一个线程中执行您的代码,您需要子类化Thread类并覆盖它的run()方法;您还可以使用Runnable接口的实例作为线程的目标。从 Java 8 开始,您可以使用任何不带参数并返回 void 作为线程目标的方法的方法引用。通过使用Thread类的start()方法来调度线程。

有两种类型的线程:守护进程和非守护进程。非守护线程也称为用户线程。当只有在 JVM 中运行的线程都是守护线程时,JVM 退出。

Java 中的每个线程都有一个优先级,它是介于110之间的整数,1是最低优先级,10是最高优先级。线程的优先级是对操作系统的一个暗示,可以忽略它对获取 CPU 时间的重要性。

在多线程程序中,如果由多个线程同时执行,可能会对程序的结果产生不良影响的代码段称为临界段。您可以使用 synchronized 关键字标记 Java 程序中的关键部分。方法也可以声明为synchronized。任何线程一次只能执行一个对象的synchronized实例方法。任何线程一次只能执行一个类的synchronized类方法。

Java 程序中的线程会经历一系列决定其生命周期的状态。线程可以处于以下任何一种状态:新的、可运行的、阻塞的、等待的、定时等待的或终止的。状态由Thread.State枚举的常数表示。使用Thread类的getState()方法获取线程的当前状态。

线程可以被中断、停止、挂起和恢复。无法重新启动已停止的线程或已完成执行的线程。

原子变量、显式锁、同步器、执行器框架和 fork/join 框架作为类库提供给 Java 开发人员,以帮助开发并发应用程序。原子变量是可以在不使用显式同步的情况下自动更新的变量。显式锁的特性允许您获取锁,并在锁不可用时退出。executor 框架帮助调度任务。fork/join 框架是在 executor 框架之上编写的,以帮助处理可以划分为子任务的任务,最后可以组合它们的结果。

线程局部变量通过ThreadLocal<T>类实现。它们基于线程存储值。它们适用于线程本地的、其他线程看不到的值。

练习

练习 1

什么是线程?线程可以共享内存吗?什么是线程本地存储?

练习 2

什么是多线程程序?

运动 3

在 Java 程序中,对象代表线程的类的名称是什么?

演习 4

假设您创建了一个Thread类的对象:

Thread t = new Thread();

接下来需要做什么才能让这个Thread对象获得 CPU 时间?

锻炼 5

使用多线程时,什么是竞争条件?你如何在你的程序中避免竞争情况?

锻炼 6

什么是程序中的关键部分?

锻炼 7

在方法声明中使用synchronized关键字有什么影响?

运动 8

什么是线程同步?Java 程序中线程同步是如何实现的?

演习 9

什么是对象的入口集和等待集?

运动 10

描述线程同步中wait()notify()notifyAll()方法的用户。

演习 11

你使用Thread类的什么方法来检查一个线程是终止的还是活动的?

运动 12

描述线程的以下六种状态:新建、可运行、阻塞、等待、定时等待和终止。Thread类中的什么方法返回线程的状态?

运动 13

可以在线程终止后通过调用其start()方法重启线程吗?

运动 14

什么是线程饥饿?

运动 15

什么是守护线程?当 JVM 检测到应用程序中只有守护线程在运行时会发生什么?主线程和垃圾收集器线程是守护线程吗?

演习 16

如何中断一个线程?调用Thread类的实例isInterrupted()方法和static interrupted()方法有什么区别?当一个被阻塞的线程被中断时会发生什么?

演习 17

什么是线程组?一个线程的默认线程组是什么?如何估计一个线程组中的活动线程?

演习 18

描述可变变量在 Java 程序中的使用。

演习 19

使用带有synchronized获取器和设置器的AtomicLong变量和long变量有什么区别?

运动 20

什么是信号量、屏障、相位器、闩锁和交换器?用 Java 命名代表这些同步器实例的类。

演习 21

什么是执行者框架?Executor接口的实例和ExecutorService接口的实例有什么区别?你用什么类来获得一个预配置的Executor实例?

演习 22

如果要向Executor提交一个有结果的任务,这个任务需要是哪个接口的实例:Runnable还是Callable<T>

练习 23

Future<T>接口的实例代表什么?

演习 24

使用shutdown()shutdownNow()方法关闭一个执行程序有什么区别?

锻炼 25

什么是 fork/join 框架?

演习 26

描述ThreadLocal<T>类的用法。

演习 27

您使用哪个 JVM 选项来设置 Java 线程的堆栈大小?

演习 28

创建一个从Thread类继承它的类。当类的一个实例作为一个线程运行时,它应该打印类似于1<name> 2<name> ...N<name>的文本,其中<name>是您指定的线程的名称,N是要打印的从1开始的整数个数的上限。例如,如果您用 100 和“A”创建您的类的一个实例,它应该打印出1A 2A 3A ...100A。创建类的三个线程,并同时运行它们。

演习 29

创建一个名为BankAccount的类。这个类的一个实例代表一个银行账户。它应该包含三种方法:deposit()withdraw()balance()。他们存入、提取并归还账户中的余额。它的balance实例变量应该存储账户中的余额,它被初始化为100。账户余额不得低于100。不要在这个类中使用任何线程同步构造或关键字。创建一个BankAccount类的实例。将这个实例传递给四个线程——两个线程应该存钱,两个线程应该取钱。存取金额应在110之间随机选择。启动另一个线程,一个监控线程,它一直调用balance()方法来检查余额是否低于100。当余额低于100时,应打印一条信息并退出应用程序。

锻炼 30

创建BankAccount类的另一个副本,并将其命名为Account。使用线程同步来保护对Account类中balance实例变量的访问,这样它的值永远不会低于100。运行与上一个练习相同数量的线程五分钟。这一次,监视器线程不应该打印任何消息。五分钟后,您的所有线程都应该被中断,并且您的线程应该通过完成其任务来响应中断。这样,您的应用程序应该在五分钟后正常退出。

六、流

在本章中,您将学习:

  • 什么是流

  • 集合和流之间的差异

  • 如何从不同类型的数据源创建流

  • 如何使用Optional类表示可选值

  • 对流应用不同类型的操作

  • 使用收集器从流中收集数据

  • 对流的数据进行分组和分区

  • 在流中查找和匹配数据

  • 如何使用并行流

本章中的所有示例程序都是清单 6-1 中声明的jdojo.streams模块的成员。

// module-info.java
module jdojo.streams {
    exports com.jdojo.streams;
}

Listing 6-1The Declaration of a jdojo.streams Module

什么是流?

聚合运算从值的集合中计算出单个值。聚合操作的结果可能只是一个原始值、一个对象或一个void。注意,对象可以表示单个实体,例如人,或者值的集合,例如列表、集合、地图等。

流是支持顺序和并行聚合操作的数据元素序列。计算整数流中所有元素的总和,将列表中的所有名称映射到它们的长度,等等。是对流进行聚合操作的例子。

看流的定义,好像是集合一样。那么,流和集合有什么不同呢?两者都是数据元素集合的抽象。集合关注数据元素的存储以实现高效访问,而流关注来自数据源的数据元素的聚合计算,数据源通常是集合,但不一定是集合。

在本节中,我将讨论流的以下特性,并在必要时将它们与集合进行比较:

  • 流没有储存空间。

  • 流可以表示无限元素的序列。

  • 流的设计基于内部迭代。

  • 流被设计为并行处理,不需要开发人员做额外的工作。

  • 流被设计成支持函数式编程。

  • 流支持惰性操作。

  • 流可以是有序的,也可以是无序的。

  • 不能重用流。

接下来的部分展示了使用流的简短代码片段。该代码旨在让您对 Streams API 有所了解,并将 Streams API 与 Collections API 进行比较。此时,您不需要完全理解代码。我稍后会详细解释。

流没有储存空间

集合是存储其所有元素的内存数据结构。所有元素在添加到集合之前都必须存在于内存中。流没有存储;它不存储元素。流按需从数据源提取元素,并将它们传递给操作管道进行处理。

无限的流

集合不能表示一组无限的元素,而流可以。集合将其所有元素存储在内存中,因此集合中不可能有无限数量的元素。拥有无限数量元素的集合将需要无限数量的内存,并且存储过程将永远持续下去。流从数据源中提取元素,数据源可以是集合、生成数据的函数、I/O 通道等。因为函数可以生成无限数量的元素,而流可以按需从中提取数据,所以流可以表示一系列无限的数据元素。

内部迭代与外部迭代

集合基于外部迭代。您为一个集合获取一个迭代器,并使用迭代器连续处理集合中的元素。假设你有一个从15的整数列表。您可以计算列表中所有奇数的平方和,如下所示:

List<Integer> numbers = List.of(1, 2, 3, 4, 5);
int sum = 0;
for (int n : numbers) {
    if (n % 2 == 1) {
        int square = n * n;
        sum = sum + square;
    }
}

此示例使用 for-each 循环对整数列表执行外部迭代。简单地说,客户端代码(本例中的 for 循环)从集合中提取元素,并应用逻辑来获得结果。考虑下面的代码片段,它使用流来计算同一列表中所有奇数的平方和:

int sum = numbers.stream()
                 .filter(n -> n % 2 == 1)
                 .map(n -> n * n)
                 .reduce(0, Integer::sum);

你注意到流的力量和简单了吗?你用一句话代替了五句话。然而,代码简洁并不是我想说的重点。重点是,当您使用流时,没有迭代列表中的元素。流在内部为你做了那件事。这就是我所说的由流支持的内部迭代。通过向流传递一个使用 lambda 表达式的算法,向流指定您想要的内容,流通过内部迭代其元素将您的算法应用于其数据元素,并向您提供结果。

通常,使用外部迭代会产生顺序代码;也就是说,代码只能由一个线程执行。例如,当您编写使用 for-each 循环计算总和的逻辑时,该循环必须仅由一个线程执行。所有现代计算机都配有多核处理器。利用多核处理器并行执行逻辑不是很好吗?Java 库提供了一个 fork/join 框架,可以递归地将一个任务分成子任务,并利用多核处理器并行执行这些子任务。然而,fork/join 框架使用起来并不那么简单,尤其是对于初学者。

小溪来救你了!它们被设计成并行处理它们的元素,而你甚至不会注意到它!这并不意味着流会自动决定何时串行或并行处理它们的元素。你只需要告诉一个流你想使用并行处理,流会处理剩下的事情。流负责内部使用 fork/join 框架的细节。您可以并行计算列表中奇数整数的平方和,如下所示:

int sum = numbers.parallelStream()
                 .filter(n -> n % 2 == 1)
                 .map(n -> n * n)
                 .reduce(0, Integer::sum);

您所要做的就是用parallelStream()替换名为stream()的方法。Streams API 使用多线程来过滤奇数整数,计算它们的平方,并将它们相加以计算部分和。最后,它将部分和相加,给出结果。在这个例子中,列表中只有五个元素,使用多线程来处理它们是多余的。你不会对如此琐碎的计算使用并行处理。我举这个例子是为了说明使用流来并行化计算是免费的;你只需要使用不同的方法名就可以得到它!第二点是,并行化计算之所以成为可能,是因为流提供了内部迭代。

流被设计成使用内部迭代。它们提供了一个iterator()方法,返回一个迭代器,用于元素的外部迭代。您将“永远”不需要自己使用迭代器来迭代流中的元素。如果你需要它,以下是使用方法:

// Get a list of integers from 1 to 5
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
...
// Get an iterator from the stream
Iterator<Integer> iterator = numbers.stream().iterator();

// That's not normally the way you'd use streams!
while(iterator.hasNext()) {
    int n = iterator.next();
    ...
}

命令式与功能式

集合支持命令式编程,而流支持声明式编程。这是支持外部迭代的集合的一个分支,而流支持内部迭代。当你使用收藏时,你需要知道“你想要什么”和“如何”得到它;这就是命令式编程的特点。当您使用流时,您只指定流操作中您想要的“什么”;“如何”部分由 Streams API 负责。Streams API 支持函数式编程。流上的操作在不修改数据源的情况下产生结果。像在函数式编程中一样,当您使用 streams 时,您可以使用 Streams API 提供的内置方法来指定要对其元素执行“什么”操作,通常是通过向这些方法传递一个 lambda 表达式,自定义这些操作的行为。

流操作

流支持两种类型的操作:

  • 中间操作

  • 终端操作

中间操作也称为惰性操作。终端操作也称为急切操作。根据从数据源提取数据元素的方式,操作被称为懒惰和急切操作。在流上调用另一个急切操作之前,流上的惰性操作不会处理流的元素。

流通过一系列操作连接在一起,形成一个流管道。一个流本质上是懒惰的,直到你对它调用一个终端操作。对一个流的中间操作产生另一个流。当您在流上调用终端操作时,将从数据源中提取元素并通过流管道传递。每个中间操作从输入流中提取元素,并转换这些元素以产生输出流。终端操作从流中获取输入并产生结果。图 6-1 显示了一个流管道,有一个数据源、三个流和三个操作。过滤和映射操作是中间操作,而归约操作是终止操作。

img/323070_3_En_6_Fig1_HTML.jpg

图 6-1

流管道

在图中,第一个流(在左边)从数据源提取数据,并成为过滤操作的输入源。过滤操作产生另一个包含过滤条件为真的数据的流。过滤操作产生的流成为映射操作的输入。映射操作产生另一个包含映射数据的流。映射操作产生的流成为归约操作的输入。归约操作是终止操作。它计算并返回结果,然后流处理结束。

Note

我在前面的讨论中使用了短语“流从它的数据源中提取/消费元素”。这并不意味着流从数据源中移除元素;它只读取它们。流被设计为支持函数式编程,在函数式编程中,数据元素被读取,并且对读取的数据元素的操作产生新的数据元素。然而,数据元素没有被修改(或者至少不应该被修改)。

直到终端操作被调用,流处理才开始。如果您只是在一个流上调用中间操作,没有什么令人兴奋的事情发生,除了它们在内存中创建另一个对象流,而不从数据源读取数据。这意味着您必须在流上使用终端操作来处理数据以产生结果。这也是终端操作被称为结果承载操作,中间操作也被称为非结果承载操作的原因。

您看到了下面的代码,它使用流操作的管道来计算从15的奇数整数的平方和:

List<Integer> numbers = List.of(1, 2, 3, 4, 5);
int sum = numbers.stream()
                 .filter(n -> n % 2 == 1)
                 .map(n -> n * n)
                 .reduce(0, Integer::sum);

图 6-2 至 6-5 显示了添加操作时的流水线状态。请注意,在调用 reduce 操作之前,没有数据流经该流。最后一张图显示了操作的输入流中的整数以及操作产生的映射(或转换)整数。reduce终端操作产生结果35

img/323070_3_En_6_Fig5_HTML.jpg

图 6-5

调用 reduce 操作后的流管道

img/323070_3_En_6_Fig4_HTML.jpg

图 6-4

调用映射操作后的流管道

img/323070_3_En_6_Fig3_HTML.jpg

图 6-3

调用过滤操作后的流管道

img/323070_3_En_6_Fig2_HTML.jpg

图 6-2

创建流对象后的流管道

有序流

流可以是有序的,也可以是无序的。有序流保持其元素的顺序。Streams API 允许您将有序流转换为无序流。流可以是有序的,因为它表示有序的数据源,如列表或排序集。您还可以通过应用中间操作(如排序)将无序流转换为有序流。

如果迭代器遍历元素的顺序是可预测且有意义的,那么就说数据源具有相遇顺序。例如,数组和列表的相遇顺序总是从索引0处的元素到最后一个索引处的元素。所有有序数据源的元素都有一个相遇顺序。基于具有相遇顺序的数据源的流对于它们的元素也具有相遇顺序。有时,一个流操作可能会在一个原本无序的流上强加一个相遇顺序。例如,HashSet的元素没有相遇顺序。但是,在基于HashSet的流上应用排序操作会强加一个相遇顺序,因此元素是按照排序后的顺序产生的。

流是不可重用的

与集合不同,流是不可重用的。它们是一次性对象。在对流调用终端操作后,不能重用流。如果需要对来自同一数据源的相同元素再次执行计算,则必须重新创建流管道。如果检测到流正在被重用,流实现可能会抛出一个IllegalStateException

Streams API 的架构

图 6-6 显示了流相关接口的类图。与流相关的接口和类在java.util.stream包中。

img/323070_3_En_6_Fig6_HTML.jpg

图 6-6

Streams API 中流相关接口的类图

所有流接口都继承自BaseStream接口,该接口继承自java.lang包的AutoCloseable接口。实际上,大多数流使用集合作为它们的数据源,集合不需要关闭。当流基于可关闭的数据源(如文件 I/O 通道)时,您可以使用 try-with-resources 语句创建流的实例,使其自动关闭。所有类型的流共有的方法在BaseStream接口中声明如下:

  • Iterator<T> iterator():返回流的迭代器。您几乎不需要在代码中使用这个方法。这是一个终端操作。调用此方法后,不能在流上调用任何其他方法。

  • S sequential():返回一个顺序流。如果流已经是连续的,它将返回自身。使用此方法将并行流转换为顺序流。这是一个中间操作。

  • S parallel():返回并行流。如果流已经是并行的,它将返回自身。使用此方法将并行流转换为顺序流。这是一个中间操作。

  • boolean isParallel():如果流是平行的,返回true,否则返回false。在调用终端流操作方法后调用此方法时,结果是不可预测的。

  • S unordered():返回一个无序版本的流。如果流已经是无序的,它返回自己。这是一个中间操作。

  • 关闭流。您不需要关闭基于集合的流。在封闭流上操作抛出一个IllegalState-Exception

  • S onClose(Runnable closeHandler):它返回一个等价的流,带有一个额外的关闭处理程序。关闭处理程序在流上调用close()方法时运行,并按添加顺序执行。

Stream<T>接口表示元素类型T的流;例如,Stream<Person>代表一串Person对象。该接口包含表示中间和终端操作的方法,如filter()map()reduce()collect()max()min()等。当您处理流时,大多数时候会用到这些方法。我很快会详细讨论每种方法。

注意,Stream<T>接口接受一个类型参数T,这意味着您只能使用它来处理引用类型的元素。如果你必须使用原始类型的流,比如intlong等等。,当需要原始值时,使用Stream<T>会导致装箱和拆箱元素的额外开销。例如,添加一个Stream<Integer>的所有元素将需要取消所有Integer元素到 int 的装箱。Streams API 的设计者意识到了这一点,他们提供了三个专门的流接口,称为IntStreamLongStreamDoubleStream来处理原语;这些接口包含处理原始值的方法。请注意,您没有表示其他原语类型的流接口,例如floatshort等。因为这三种流类型可用于处理其他原始类型值。

一个简单的例子

让我们看一个使用流的简单例子。该代码读取一个整数列表,并计算列表中所有奇数的平方和。

Collection接口中的stream()方法返回一个顺序流,其中Collection充当数据源。下面的代码片段创建了一个List<Integer>并从列表中获得了一个Stream<Integer>:

// Get a list of integers from 1 to 5
List<Integer> numbersList = List.of(1, 2, 3, 4, 5);
// Get a stream from the list
Stream<Integer> numbersStream = numbersList.stream();

Stream<T>接口的filter()方法将一个Predicate<? super T>作为参数,并返回一个Stream<T>,其中包含指定的Predicate返回true的原始流的元素。下面的语句只获取奇数整数的流:

// Get a stream of odd integers
Stream<Integer> oddNumbersStream =
    numbersStream.filter(n -> n % 2 == 1);

注意使用 lambda 表达式作为filter()方法的参数。如果流中的元素不能被2整除,lambda 表达式返回true

Stream<T>接口的map()方法以Function<? super T,? extends R>作为参数。流中的每个元素都被传递给这个Function,并生成一个新的流,其中包含来自Function的返回值。以下语句将所有奇数整数映射到它们的平方:

// Get a stream of the squares of odd integers
Stream<Integer> squaredNumbersStream =
    oddNumbersStream.map(n -> n * n);

最后,你需要将所有奇数的平方相加来得到结果。Stream<T>接口的reduce(T identity, BinaryOperator<T> accumulator)方法对流执行归约操作,将流归约为单个值。它接受一个初始值和一个累加器(T2)作为参数。第一次,累加器接收初始值和流的第一个元素作为参数,并返回值。第二次,累加器接收从上一次调用返回的值和流中的第二个元素。这个过程一直持续到流的所有元素都被传递到累加器。累加器最后一次调用的返回值是从reduce()方法返回的。以下代码片段执行流中所有整数的求和:

// Sum all integers in the stream
int sum = squaredNumbersStream.
    reduce(0, (n1, n2) -> n1 + n2);

Integer类包含一个静态的sum()方法来执行两个整数的求和。您可以使用方法引用重写前面的语句,如下所示:

// Sum all integers in the stream
int sum = squaredNumbersStream.
    reduce(0, Integer::sum);

在这个例子中,我将流上的每个操作分解成一条语句。您不能使用从中间操作返回的流,除非对它们应用其他操作。通常,您关心终端操作的结果,而不是中间流。流被设计成支持方法链以避免临时变量,您在这个例子中使用了临时变量。您可以将这些语句组合成一条语句,如下所示:

// Sum the squares of all odd integers in the numbers list
int sum = numbersList.stream()
                     .filter(n -> n % 2 == 1)
                     .map(n -> n * n)
                     .reduce(0, Integer::sum);

在后面的例子中,我将流上的所有方法调用链接起来,只形成一个语句。清单 6-2 包含了这个例子的完整程序。请注意,在这个示例中,您只处理整数。为了获得更好的性能,您可以在本例中使用IntStream。稍后我会告诉你如何使用一个IntStream

// SquaredIntsSum.java
package com.jdojo.streams;
import java.util.List;
public class SquaredIntsSum {
    public static void main(String[] args) {
        // Get a list of integers from 1 to 5
        List<Integer> numbers = List.of(1, 2, 3, 4, 5);
        // Compute the sum of the squares of all odd
        // integers in the list
        int sum = numbers.stream()
                         .filter(n -> n % 2 == 1)
                         .map(n -> n * n)
                         .reduce(0, Integer::sum);
        System.out.println("Sum = " + sum);
    }
}

Sum = 35

Listing 6-2Computing the Sum of the Squares of All Odd Integers from 1 to 5

我展示了许多在不同类型的流上执行聚合操作的例子。大多数时候,使用数字流和字符串流更容易解释流操作。我通过使用一个Person对象流展示了一些使用流的真实例子。清单 6-3 包含了对Person类的声明。

// Person.java
package com.jdojo.streams;
import java.time.LocalDate;
import java.time.Month;
import java.util.List;
public class Person {
    // An enum to represent the gender of a person
    public static enum Gender {
        MALE, FEMALE
    }
    private long id;
    private String name;
    private Gender gender;
    private LocalDate dob;
    private double income;

    public Person(long id, String name, Gender gender,
          LocalDate dob, double income) {
        this.id = id;
        this.name = name;
        this.gender = gender;
        this.dob = dob;
        this.income = income;
    }
    public long getId() {
        return id;
    }
    public void setId(long id) {
        this.id = id;
    }
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    public Gender getGender() {
        return gender;
    }
    public boolean isMale() {
        return this.gender == Gender.MALE;
    }

    public boolean isFemale() {
        return this.gender == Gender.FEMALE;
    }
    public void setGender(Gender gender) {
        this.gender = gender;
    }
    public LocalDate getDob() {
        return dob;
    }
    public void setDob(LocalDate dob) {
        this.dob = dob;
    }
    public double getIncome() {
        return income;
    }
    public void setIncome(double income) {
        this.income = income;
    }
    public static List<Person> persons() {
        Person ken = new Person(1, "Ken",
                Gender.MALE,
                LocalDate.of(
                  1970, Month.MAY, 4), 6000.0);
        Person jeff = new Person(2, "Jeff",
                Gender.MALE,
                LocalDate.of(
                  1970, Month.JULY, 15), 7100.0);
        Person donna = new Person(3, "Donna",
                Gender.FEMALE,
                LocalDate.of(
                  1962, Month.JULY, 29), 8700.0);
        Person chris = new Person(4, "Chris",
                Gender.MALE,
                LocalDate.of(
                  1993, Month.DECEMBER, 16), 1800.0);
        Person laynie = new Person(5, "Laynie",
                Gender.FEMALE,
                LocalDate.of(
                  2012, Month.DECEMBER, 13), 0.0);
        Person lee = new Person(6, "Li",
                Gender.MALE,
                LocalDate.of(
                  2001, Month.MAY, 9), 2400.0);
        // Create a list of persons
        List<Person> persons = List.of(
            ken, jeff, donna, chris, laynie, lee);
        return persons;
    }
    @Override
    public String toString() {
        String str = String.format(
                "(%s, %s, %s, %s, %.2f)",
                id, name, gender, dob, income);
        return str;
    }
}

Listing 6-3A Person Class

Person类包含一个静态的Gender枚举来表示一个人的性别。该类声明了五个实例变量(idnamegenderdobincome)、getters 和 setters。isMale()isFemale()方法已经被声明用作 lambda 表达式中的方法引用。您将经常使用人员列表,为此,该类包含一个名为persons()的静态方法来获取人员列表。

创建流

创建流的方法有很多种。Java 库中的许多现有类都接收了返回流的新方法。根据数据源,流创建可以分类如下:

  • 来自值的流

  • 空流

  • 来自函数的流

  • 来自数组的流

  • 集合中的流

  • 来自文件的流

  • 来自其他来源的流

来自值的流

Stream 接口包含以下三个静态方法,用于从单个值和多个值创建顺序的Stream:

  • <T> Stream<T> of(T t)

  • <T> Stream<T> of(T...values)

  • <T> Stream<T> ofNullable(T t)

以下代码片段创建了两个流:

// Creates a stream with one string element
Stream<String> stream = Stream.of("Hello");
// Creates a stream with four string elements
Stream<String> stream = Stream.of(
    "Ken", "Jeff", "Chris", "Ellen");

如果指定的值为非空,则ofNullable()方法返回一个具有单个值的流。否则,它返回一个空流:

String str = "Hello";
// Stream s1 will have one element "Hello"
Stream<String> s1 = Stream.ofNullable(str);
str = null;
// Stream s2 is an empty stream because str is null
Stream<String> s2 = Stream.ofNullable(str);

您创建了一个List<Integer>并调用它的stream()方法来获得清单 6-2 中的流对象。您可以使用Stream.of()方法重写该示例,如下所示:

import java.util.stream.Stream;
...
// Compute the sum of the squares of all odd integers in
// the list
int sum = Stream.of(1, 2, 3, 4, 5)
                .filter(n -> n % 2 == 1)
                .map(n -> n * n)
                .reduce(0, Integer::sum);
System.out.println("Sum = " + sum);

Sum = 35

注意,of()方法的第二个版本采用了一个varargs参数,您也可以用它从一个对象数组中创建一个流。下面的代码片段从一个String数组创建一个流:

String[] names  = {"Ken", "Jeff", "Chris", "Ellen"};
// Creates a stream of four strings in the names array
Stream<String> stream = Stream.of(names);

Note

方法创建了一个元素是引用类型的流。如果您想从一个原始类型数组中创建一个原始值流,您需要使用Arrays.stream()方法,我将很快对此进行解释。

下面的代码片段从一个从String类的split()方法返回的String数组中创建一个字符串流:

String str  = "Ken,Jeff,Chris,Ellen";
// The stream will contain 4 elements:
// "Ken", "Jeff", "Chris", and "Ellen"
Stream<String> stream = Stream.of(str.split(","));

Stream接口还支持使用 builder 模式创建流,使用的是Stream.Builder<T>接口,它的实例代表一个流构建器。Stream接口的builder()静态方法返回一个流构建器:

// Gets a stream builder
Stream.Builder<String> builder = Stream.builder();

Stream.Builder<T>接口包含以下方法:

  • void accept(T t)

  • Stream.Builder<T> add(T t)

  • Stream<T> build()

accept()add()方法向正在构建的流中添加元素。您可能想知道生成器中是否存在两种添加元素的方法。Stream.Builder<T>接口继承自Consumer<T>接口,因此它继承了Consumer<T>接口的accept()方法。您可以将构建器的实例传递给接受消费者的方法,该方法可以使用accept()方法向构建器添加元素。

add()方法返回对构建器的引用,这使得它适合使用方法链接添加多个元素。添加完元素后,调用build()方法来创建流。调用build()方法后,不能向流中添加元素;这样做会导致IllegalStateException运行时异常。下面的代码片段使用 builder 模式创建一个包含四个字符串的流:

Stream<String> stream = Stream.<String>builder()
    .add("Ken")
    .add("Jeff")
    .add("Chris")
    .add("Ellen")
.build();

注意,代码在获取构建器Stream.<String>builder()时将类型参数指定为String。如果不指定类型参数,编译器将无法推断出它。如果单独获取构建器,编译器会将类型推断为String,如下图:

// Obtain a builder
Stream.Builder<String> builder = Stream.builder();
// Add elements and build the stream
Stream<String> stream = builder.add("Ken")
    .add("Jeff")
    .add("Chris")
    .add("Ellen")
.build();

IntStream接口包含四个静态方法,允许您从值创建IntStream:

  • IntStream of(int value)

  • IntStream of(int... values)

  • IntStream range(int start, int end)

  • IntStream rangeClosed(int start, int end)

of()方法允许您通过指定单个值来创建一个IntStreamrange()rangeClosed()方法产生一个IntStream,其中包含指定开始和结束位置之间的有序整数。指定的结束在range()方法中是独占的,而在rangeClosed()方法中是包含的。以下代码片段使用这两种方法创建一个IntStream,其元素为整数12345:

// Create an IntStream containing 1, 2, 3, 4, and 5
IntStream oneToFive = IntStream.range(1, 6);
// Create an IntStream containing 1, 2, 3, 4, and 5
IntStream oneToFive = IntStream.rangeClosed(1, 5);

LongStream接口还包含range()rangeClosed()方法,它们接受long类型的参数并返回一个LongStreamLongStreamDoubleStream接口还包含of()方法,这些方法使用longdouble值,并分别返回一个LongStream和一个DoubleStream

空流

空流是没有元素的流。Stream接口包含一个empty()静态方法来创建一个空的顺序流:

// Creates an empty stream of strings
Stream<String> stream = Stream.empty();

IntStreamLongStreamDoubleStream接口还包含一个empty()静态方法来创建一个原始类型的空流。这里有一个例子:

// Creates an empty stream of integers
IntStream numbers = IntStream.empty();

来自函数的流

无限流是具有能够生成无限数量元素的数据源的流。请注意,我说的是数据源应该“能够生成”无限数量的元素,而不是数据源应该拥有或包含无限数量的元素。由于内存和时间的限制,不可能存储无限数量的任何类型的元素。但是,有一个函数可以根据需要生成无限多的值。Stream接口包含以下两个静态方法来生成无限流:

  • <T> Stream<T> iterate(T seed, Predicate<? super T> hasNext, Unary-Operator<T> next)

  • <T> Stream<T> iterate(T seed, UnaryOperator<T> f)

  • <T> Stream<T> generate(Supplier<? extends T> s)

iterate()方法创建顺序有序的流,而generate()方法创建顺序无序的流。以下部分向您展示了如何使用这些方法。

原始值IntStreamLongStreamDoubleStream的流接口还包含iterate()generate()静态方法,它们接受特定于其原始类型的参数。例如,这些方法在IntStream界面中定义如下:

  • static IntStream iterate(int seed, IntPredicate hasNext, IntUnaryOperator next)

  • IntStream iterate(int seed, IntUnaryOperator f)

  • IntStream generate(IntSupplier s)

第一个版本的iterate()方法声明如下:

static <T> Stream<T> iterate(
    T seed,
    Predicate<? super T> hasNext,
    UnaryOperator<T> next)

该方法有三个参数:种子、谓词和函数。只要hasNext谓词是true,它就通过迭代应用next函数来产生元素。seed参数是初始元素。调用此方法类似于使用 for 循环,如下所示:

for (int index = seed;
       hasNext.test(index);
       index = next.applyAsInt(index)) {
    // index is the next element in the stream
}

下面的代码片段产生一个从110的整数流:

Stream<Integer> nums =
    Stream.iterate(1, n -> n <= 10, n -> n + 1);

第二个版本的iterate()方法声明如下:

static <T> Stream<T> iterate(T seed, UnaryOperator<T> f)

该方法有两个参数:一个种子和一个函数。第一个参数是种子,它是流的第一个元素。通过将该函数应用于第一元素来生成第二元素。第三个元素是通过对第二个元素应用函数而生成的,依此类推。它的元素有seedf(seed)f(f(seed))f(f(f(seed)))等等。以下语句创建自然数的无限流和所有奇数自然数的无限流:

// Creates a stream of natural numbers
Stream<Long> naturalNumbers =
    Stream.iterate(1L, n -> n + 1);
// Creates a stream of odd natural numbers
Stream<Long> oddNaturalNumbers =
    Stream.iterate(1L, n -> n + 2);

你用无限流做什么?你明白不可能消耗一个无限流的所有元素。这只是因为流处理将永远无法完成。通常,通过应用将输入流截断为不超过指定大小的限制操作,可以将无限流转换为固定大小的流。极限操作是产生另一个流的中间操作。您使用Stream接口的limit(long maxSize)方法应用限制操作。下面的代码片段创建了第一个10自然数的流:

// Creates a stream of the first 10 natural numbers
Stream<Long> tenNaturalNumbers =
    Stream.iterate(1L, n -> n + 1).
    limit(10);

您可以使用Stream接口的forEach(Consumer<? super T> action)方法在流上应用forEach操作。该方法返回void

这是一个终端操作。以下代码片段在标准输出中打印前五个奇数自然数:

Stream.iterate(1L, n -> n + 2)
      .limit(5)
      .forEach(System.out::println);

1
3
5
7
9

让我们看一个创建素数无限流的现实例子。清单 6-4 包含一个名为PrimeUtil的实用程序类。该类包含两个实用工具方法。next()实例方法返回最后找到的质数之后的下一个质数。next(long after)静态方法返回指定数字后的质数。静态方法检查一个数是否是质数。

// PrimeUtil.java
package com.jdojo.streams;
public class PrimeUtil {
    // Used for a stateful PrimeUtil
    private long lastPrime = 0L;
    // Computes the prime number after the last generated
    // prime
    public long next() {
        lastPrime = next(lastPrime);
        return lastPrime;
    }

    // Computes the prime number after the specified
    // number
    public static long next(long after) {
        long counter = after;
        // Keep looping until you find the next prime
        // number
        while (!isPrime(++counter));
        return counter;
    }
    // Checks if the specified number is a prime number
    public static boolean isPrime(long number) {
        // <= 1 is not a prime number
        if (number <= 1) {
            return false;
        }
        // 2 is a prime number
        if (number == 2) {
            return true;
        }
        // Even numbers > 2 are not prime numbers
        if (number % 2 == 0) {
            return false;
        }

        long maxDivisor = (long) Math.sqrt(number);
        for (int counter = 3;
              counter <= maxDivisor;
              counter += 2) {
            if (number % counter == 0) {
                return false;
            }
        }
        return true;
    }
}

Listing 6-4A Utility Class to Work with Prime Numbers

下面的代码片段创建了一个素数的无限流,并在标准输出中打印出前五个素数:

Stream.iterate(2L, PrimeUtil::next)
      .limit(5)
      .forEach(System.out::println);

2
3
5
7
11

还有一种方法可以得到前五个素数。您可以生成一个无限的自然数流,应用过滤操作只挑选质数,并将过滤后的流限制为五个。下面的代码片段显示了使用PrimeUtil类的isPrime()方法的逻辑:

// Print the first 5 prime numbers
Stream.iterate(2L, n -> n + 1)
      .filter(PrimeUtil::isPrime)
      .limit(5)
      .forEach(System.out::println);

2
3
5
7
11

有时,您可能想要丢弃流中的一些元素。这是使用skip操作完成的。Stream接口的skip(long n)方法丢弃(或跳过)流的第一个n元素。这是一个中间操作。下面的代码片段使用这个操作打印五个质数,跳过第一个100质数:

Stream.iterate(2L, PrimeUtil::next)
      .skip(100)
      .limit(5)
      .forEach(System.out::println);

547
557
563
569
571

利用你所学的关于流的一切,你能写一个流管道来打印五个大于3000的质数吗?这是留给读者的一个练习。

generate(Supplier<? extends T> s)方法使用指定的Supplier生成一个无限的顺序无序流。下面的代码片段使用Math类的random()静态方法打印了五个大于或等于0.0且小于1.0的随机数。您可能会得到不同的输出:

Stream.generate(Math::random)
      .limit(5)
      .forEach(System.out::println);

0.05958352209327644
0.8122226657626394
0.5073323815997652
0.9327951597282766
0.4314430923877808

如果您想使用generate()方法生成一个无限流,其中下一个元素是基于前一个元素的值生成的,那么您需要使用一个Supplier来存储最后生成的元素。注意,PrimeUtil对象可以充当Supplier,它的next()实例方法会记住最后生成的质数。下面的代码片段在跳过第一个100后打印出五个质数:

Stream.generate(new PrimeUtil()::next)
      .skip(100)
      .limit(5)
      .forEach(System.out::println);

547
557
563
569
571

java.util包中的Random类包含专门定制的方法来处理流。所以我们有像ints()longs()doubles()这样的方法,它们分别返回无限的IntStreamLongStreamDoubleStream,其中包含intlongdouble类型的随机数。以下代码片段打印了从Random类的ints()方法返回的IntStream中的五个随机int值:

// Print five random integers
new Random().ints()
            .limit(5)
            .forEach(System.out::println);

-1147567659
285663603
-412283607
412487893
-22795557

每次运行代码时,您可能会得到不同的输出。您可以使用Random类的nextInt()方法作为generate()方法中的Supplier来获得相同的结果:

// Print five random integers
Stream.generate(new Random()::nextInt)
      .limit(5)
      .forEach(System.out::println);

如果您想只处理原始值,您可以使用原始类型流接口的generate()方法。例如,下面的代码片段使用IntStream接口的generate()静态方法打印五个随机整数:

IntStream.generate(new Random()::nextInt)
         .limit(5)
         .forEach(System.out::println);

如何生成无限的重复值流?例如,你如何生成一个无限的零流?以下代码片段向您展示了如何做到这一点:

IntStream zeroes = IntStream.generate(() -> 0);

来自数组的流

java.util包中的Arrays类包含一个重载的stream()静态方法,用于从数组创建顺序流。您可以使用它从int数组创建一个IntStream,从long数组创建一个LongStream,从double数组创建一个DoubleStream,从引用类型T的数组创建一个Stream<T>。以下代码片段从一个int数组和一个String数组创建了一个IntStream和一个Stream<String>:

// Creates a stream from an int array with elements
// 1, 2, and 3
IntStream numbers = Arrays.stream(new int[]{1, 2, 3});
// Creates a stream from a String array with elements
// "Ken", and "Jeff"
Stream<String> names = Arrays.stream(
    new String[] {"Ken", "Jeff"});

Note

您可以使用两种方法从引用类型数组创建流:Arrays.stream(T[] t)Stream.of(T...t)。在库中提供两个方法来完成同样的事情是有意的。

来自集合的流

Collection接口包含stream()parallelStream()方法,分别从Collection创建顺序流和并行流。以下代码片段从一组字符串创建流:

import java.util.HashSet;
import java.util.Set;
import java.util.stream.Stream;
...
// Create and populate a set of strings
Set<String> names = Set.of("Ken", "jeff");
// Create a sequential stream from the set
Stream<String> sequentialStream = names.stream();
// Create a parallel stream from the set
Stream<String> parallelStream = names.parallelStream();

来自文件的流

java.iojava.nio.file包的类中有许多方法支持使用流的 I/O 操作。例如:

  • 您可以将文件中的文本作为字符串流读取,其中每个元素代表文件中的一行文本。

  • 你可以从一个JarFile获得一个JarEntry的流。

  • 您可以获得一个目录中的条目列表,作为一个Path流。

  • 您可以获得一个Path流,这是在指定目录中搜索文件的结果。

  • 您可以获得一个包含指定目录的文件树的Path流。

在这一节中,我展示了一些将流用于文件 I/O 的例子。有关流相关方法的更多细节,请参考 API 文档中的java.nio.file.Filesjava.io.BufferedReaderjava.util.jar.JarFile类。

BufferedReaderFiles类包含一个lines()方法,该方法缓慢地读取一个文件,并将内容作为字符串流返回。流中的每个元素代表文件中的一行文本。当您处理完该流时,需要关闭该文件。在流上调用close()方法将关闭底层文件。或者,您可以在 try-with-resources 语句中创建流,以便自动关闭基础文件。

清单 6-5 中的程序展示了如何使用流读取文件内容。它还遍历当前工作目录的整个文件树,并打印目录中的条目。程序假设您在当前工作目录中有源代码提供的luci1.txt文件。如果该文件不存在,将打印一条错误消息,其中包含预期文件的绝对路径。运行该程序时,您可能会得到不同的输出。

// IOStream.java
package com.jdojo.streams;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.stream.Stream;

public class IOStream {
    public static void main(String[] args) {
        // Read the contents of the file luci1.txt
        readFileContents("luci1.txt");
        // Print the file tree for the current working
        // directory
        listFileTree();
    }
    public static void readFileContents(String filePath) {
        Path path = Paths.get(filePath);
        if (!Files.exists(path)) {
            System.out.println("The file "
                    + path.toAbsolutePath()
                    + " does not exist.");
            return;
        }
        try (Stream<String> lines = Files.lines(path)) {
            // Read and print all lines
            lines.forEach(System.out::println);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    public static void listFileTree() {
        Path dir = Paths.get("");
        System.out.printf("%nThe file tree for %s%n",
            dir.toAbsolutePath());
        try (Stream<Path> fileTree = Files.walk(dir)) {
            fileTree.forEach(System.out::println);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

STRANGE fits of passion have I known:
And I will dare to tell,
But in the lover's ear alone,
What once to me befell.

The file tree for C:\Java9LanguageFeatures
build
build\modules
build\modules\com
build\modules\com\jdojo
...

Listing 6-5Performing File I/O Using Streams

来自其他来源的流

许多保存某种内容的类提供了返回它们在流中表示的数据的方法。下面解释两种您可能经常使用的方法:

  • CharSequence接口中的chars()方法返回一个IntStream,其元素是表示CharSequence字符的 int 值。您可以在StringStringBuilderStringBuffer上使用chars()方法来获取它们内容的字符流,因为这些类实现了CharSequence接口。

  • java.util.regex. Pattern类的splitAsStream(CharSequence input)方法返回一个元素与模式匹配的String流。

让我们看一个这两类的例子。下面的代码片段从一个字符串创建一个字符流,过滤掉所有数字和空白,并打印剩余的字符:

String str = "5 apples and 25 oranges";
str.chars()
   .filter(n -> !Character.isDigit((char)n)
                && !Character.isWhitespace((char)n))
   .forEach(n -> System.out.print((char)n));

applesandoranges

下面的代码片段通过使用正则表达式(“,”)拆分字符串来获取字符串流。匹配的字符串打印在标准输出中:

String str = "Ken,Jeff,Lee";
Pattern.compile(",")
       .splitAsStream(str)
       .forEach(System.out::println);

Ken
Jeff
Lee

表示可选值

在 Java 中,null用来表示“无”或“空”结果。大多数情况下,如果一个方法没有结果要返回,它就会返回null。这是 Java 程序中经常出现NullPointerException的一个原因。考虑打印一个人的出生年份,如下所示:

Person ken = new Person(1, "Ken", Person.Gender.MALE,
    null, 6000.0);
int year = ken.getDob().getYear();
// <- Throws a NullPointerException
System.out.println("Ken was born in the year " + year);

代码在运行时抛出一个NullPointerException。问题出在返回nullken.getDob()方法的返回值上。在null引用上调用getYear()方法会导致NullPointerException。那么,解决办法是什么呢?事实上,除非你想用一种新的语言取代 Java,否则在语言层面上没有真正的解决方案。但是 Java 提供了一个库结构来帮助避免NullPointerExceptions。在java.util包中有一个Optional<T>类来优雅地处理NullPointerExceptions。可能不返回任何东西的方法应该返回一个Optional而不是null

Optional是一个容器对象,可能包含也可能不包含非空值。如果它包含一个非空值,它的isPresent()方法返回true,否则返回false。它的get()方法如果包含非空值则返回非空值,否则抛出NoSuchElementException。这意味着当一个方法返回一个Optional时,作为一种实践,您必须在向它请求值之前检查它是否包含一个非空值。如果在确保包含非空值之前使用get()方法,可能会得到NoSuchElementException而不是NullPointerException。这就是为什么我在前一段说NullPointerException没有真正的解决方案。然而,返回一个Optional肯定是处理null的一个更好的方法,因为开发人员将习惯于按照设计的方式使用Optional对象。

如何创建一个Optional<T>对象?Optional<T>类提供了以下静态工厂方法来创建它的对象:

  • <T> Optional<T> empty():返回一个空的Optional。也就是说,从该方法返回的Optional不包含非空值。

  • <T> Optional<T> of(T value):返回一个包含指定值作为非空值的Optional。如果指定的值是null,它抛出一个NullPointerException

  • <T> Optional<T> ofNullable(T value):如果值为非空,则返回包含指定值的Optional。如果指定的值是null,则返回空的Optional

以下代码片段显示了如何创建Optional对象:

// Create an empty Optional
Optional<String> empty = Optional.empty();
// Create an Optional for the string "Hello"
Optional<String> str = Optional.of("Hello");
// Create an Optional with a String that may be null
String nullableString = "";
// <- get a string that may be null...
Optional<String> str2 = Optional.of(nullableString);

如果Optional中包含非空值,下面的代码片段将打印该值:

// Create an Optional for the string "Hello"
Optional<String> str = Optional.of("Hello");
// Print the value in Optional
if (str.isPresent()) {
    String value = str.get();
    System.out.println("Optional contains " + value);
} else {
    System.out.println("Optional is empty.");
}

Optional contains Hello

您可以使用Optional类的ifPresent(Consumer<? super T> action)方法对包含在Optional中的值采取行动。如果Optional为空,这个方法不做任何事情。您可以重写前面的代码来打印一个Optional中的值,如下所示。注意,如果Optional为空,代码不会打印任何内容:

// Create an Optional for the string "Hello"
Optional<String> str = Optional.of("Hello");
// Print the value in the Optional, if present
str.ifPresent(value ->
    System.out.println("Optional contains " + value));

Optional contains Hello

以下是获取Optional值的四种方法:

  • T get():返回Optional中包含的值。如果Optional为空,它抛出一个NoSuchElementException

  • T orElse(T defaultValue):返回Optional中包含的值。如果Optional为空,则返回指定的defaultValue

  • T orElseGet(Supplier<? extends T> defaultSupplier):返回Optional中包含的值。如果Optional为空,则返回从指定的defaultSupplier返回的值。

  • <X extends Throwable> T orElseThrow(Supplier<? extends X> exceptionSupplier) throws X extends Throwable:返回Optional中包含的值。如果Optional为空,则抛出从指定的exceptionSupplier返回的异常。

Optional<T>类描述了一个非空的引用类型值或者它的缺失。java.util包包含另外三个名为OptionalIntOptionalLongOptionalDouble的类来处理可选的原始值。除了获取它们的值之外,它们包含应用于原始数据类型的类似命名的方法。它们不包含get()方法。为了返回它们的值,OptionalInt类包含一个getAsInt(),OptionalLong类包含一个getAsLong(),OptionalDouble类包含一个getAsDouble()方法。像Optional类的get()方法一样,原始可选类的 getters 也在它们为空时抛出一个NoSuchElementException。与Optional类不同,它们不包含ofNullable()工厂方法,因为原始值不能是null。以下代码片段显示了如何使用OptionalInt类:

// Create an empty OptionalInt
OptionalInt empty = OptionalInt.empty();
// Use an OptionalInt to store 287
OptionalInt number = OptionalInt.of(287);
if (number.isPresent()){
    int value = number.getAsInt();
    System.out.println("Number is " + value);
} else {
    System.out.println("Number is absent.");
}

Number is 287

Streams API 中的几个方法在没有任何东西可返回时,会返回一个OptionalOptionalIntOptionalLongOptionalDouble的实例。例如,所有类型的流都允许您计算流中的最大元素。如果流为空,则没有最大元素。请注意,在流管道中,由于过滤或其他操作(如 limit、skip 等),您可能以非空流开始,以空流结束。因此,所有流类中的max()方法都返回一个可选对象。清单 6-6 中的程序展示了如何从IntStream中获取最大整数。

// OptionalTest.java
package com.jdojo.streams;

import java.util.Comparator;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.stream.IntStream;
import java.util.stream.Stream;

public class OptionalTest {
    public static void main(String[] args) {
        // Get the maximum of odd integers from the stream
        OptionalInt maxOdd = IntStream.of(10, 20, 30)
                                .filter(n -> n % 2 == 1)
                                .max();
        if (maxOdd.isPresent()) {
            int value = maxOdd.getAsInt();
            System.out.println("Maximum odd integer is " +
                value);
        } else {
            System.out.println("Stream is empty.");
        }

        // Get the maximum of odd integers from the stream
        OptionalInt numbers = IntStream.of(
                1, 10, 37, 20, 31)
            .filter(n -> n % 2 == 1)
            .max();
        if (numbers.isPresent()) {
            int value = numbers.getAsInt();
            System.out.println("Maximum odd integer is " +
                value);
        } else {
            System.out.println("Stream is empty.");
        }
        // Get the longest name
        Optional<String> name =
            Stream.of("Ken", "Ellen", "Li")
            .max(Comparator.comparingInt(String::length));
        if (name.isPresent()) {
            String longestName = name.get();
            System.out.println("Longest name is " +
                longestName);
        } else {
            System.out.println("Stream is empty.");
        }
    }
}

Stream is empty.
Maximum odd integer is 37
Longest name is Ellen

Listing 6-6Working

with Optional Values

此外,Optional<T>类包含以下方法:

  • void ifPresentOrElse(Consumer<? super T> action, Runnable empty-Action)

  • Optional<T> or(Supplier<? extends Optional<? extends T» supplier)

  • Stream<T> stream()

在我描述这些方法并给出一个完整的程序展示它们的用法之前,先考虑下面的一个Optional<Integer>列表:

List<Optional<Integer>> optionalList = List.of(
    Optional.of(1),
    Optional.empty(),
    Optional.of(2),
    Optional.empty(),
    Optional.of(3));

列表包含五个Optional元素,其中两个为空,三个包含值123。我在随后的讨论中参考了这个列表。

ifPresentOrElse()方法允许您提供两种可选的操作过程。如果存在一个值,它将使用该值执行指定的操作。

否则,它执行指定的emptyAction。下面的代码片段遍历列表中的所有元素,如果Optional包含值,则使用流打印值,如果Optional为空,则使用“空”字符串:

optionalList.stream()
        .forEach(p -> p.ifPresentOrElse(
                System.out::println,
                () -> System.out.println("Empty")));

1

Empty
2
Empty
3

如果Optional包含非空值,则or()方法返回Optional本身。否则,返回指定的supplier返回的Optional。以下代码片段从列表Optional中创建一个流,并使用or()方法将所有空选项映射到值为零的Optional:

optionalList.stream()
            .map(p -> p.or(() -> Optional.of(0)))
            .forEach(System.out::println);

Optional[1]
Optional[0]
Optional[2]
Optional[0]
Optional[3]

stream()方法返回一个连续的元素流,其中包含了Optional中的值。如果Optional为空,则返回一个空流。假设你有一个Optional的列表,你想在另一个列表中收集所有的当前值。您可以通过以下方式实现这一点:

// Print the values in all non-empty Optionals
optionalList.stream()
            .filter(Optional::isPresent)
            .map(Optional::get)
            .forEach(System.out::println);

1
2
3

您必须使用过滤器过滤掉所有空的选项,并将剩余的选项映射到它们的值。使用新的stream()方法,您可以将filter()map()操作合并成一个flatMap()操作,如图所示。我将在本章后面的“展平流”部分详细讨论展平流。

// Print the values in all non-empty Optionals
optionalList.stream()
            .flatMap(Optional::stream)
            .forEach(System.out::println);

1
2
3

将操作应用于流

表 6-1 列出了一些常用的流操作、它们的类型和描述。Stream接口包含一个与表中操作名称同名的方法。在前面的章节中,您已经看到了其中的一些操作。后续部分将详细介绍它们。

表 6-1

Streams API 支持的常用流操作列表

|

操作

|

类型

|

描述

|
| --- | --- | --- |
| Distinct | 中间的 | 返回由流的不同元素组成的流。如果e1.equals(e2)返回true,则元素e1e2被认为相等。 |
| Filter | 中间的 | 返回一个流,该流由与指定谓词匹配的元素组成。 |
| flatMap | 中间的 | 返回一个流,该流由对该流中的元素应用指定函数的结果组成。该函数为每个输入元素生成一个流,输出流被展平。执行一对多映射。 |
| Limit | 中间的 | 返回由流中的元素组成的流,该流被截断为不超过指定的大小。 |
| Map | 中间的 | 返回一个流,该流由对该流中的元素应用指定函数的结果组成。执行一对一映射。 |
| peek | 中间的 | 返回一个流,其元素由该流组成。它在使用该流的元素时应用指定的操作。它主要用于调试目的。 |
| Skip | 中间的 | 丢弃流中的前 N 个元素,并返回剩余的流。如果此流包含的元素少于 N 个,则返回一个空流。 |
| dropWhile | 中间的 | 返回流的元素,从谓词为真的开始处丢弃元素。这个操作被添加到 Java 9 的 Streams API 中。 |
| takeWhile | 中间的 | 从流的开头返回与谓词匹配的元素,丢弃其余的元素。这个操作被添加到 Java 9 的 Streams API 中。 |
| sorted | 中间的 | 返回由流中的元素组成的流,根据自然顺序或指定的比较器排序。对于有序流,排序是稳定的。 |
| allMatch | 末端的 | 如果流中的所有元素都匹配指定的谓词,则返回true,否则返回false。如果流为空,则返回 true。 |
| anyMatch | 末端的 | 如果流中的任何元素匹配指定的谓词,则返回true,否则返回 false。如果流为空,则返回false。 |
| findAny | 末端的 | 返回流中的任何元素。为空流返回空的Optional。 |
| findFirst | 末端的 | 返回流的第一个元素。对于有序流,它返回相遇顺序中的第一个元素;对于无序流,它返回任何元素。 |
| noneMatch | 末端的 | 如果流中没有元素匹配指定的谓词,则返回true,否则返回false。如果流为空,则返回true。 |
| forEach | 末端的 | 对流中的每个元素应用一个操作。 |
| Reduce | 末端的 | 应用归约运算来计算流中的单个值。 |

调试流管道

您在流上应用一系列操作。每个操作都转换输入流的元素,要么产生另一个流,要么产生一个结果。有时,您可能需要在流通过管道时查看流的元素。您可以通过使用仅用于调试目的的Stream<T>接口的peek(Consumer<? super T> action)方法来做到这一点。它在对每个输入元素应用一个动作后产生一个流。IntStreamLongStreamDoubleStream方法还包含一个peek()方法,它将一个IntConsumer、一个LongConsumer和一个DoubleConsumer作为参数。通常,使用带有peek()方法的 lambda 表达式来记录描述正在处理的元素的消息。下面的代码片段在三个地方使用了peek()方法来打印通过流管道的元素:

int sum = Stream.of(1, 2, 3, 4, 5)
    .peek(e -> System.out.println("Taking integer: "
         + e))
    .filter(n -> n % 2 == 1)
    .peek(e -> System.out.println("Filtered integer: "
         + e))
    .map(n -> n * n)
    .peek(e -> System.out.println("Mapped integer: "
         + e))
    .reduce(0, Integer::sum);
System.out.println("Sum = " + sum);

Taking integer: 1
Filtered integer: 1
Mapped integer: 1
Taking integer: 2
Taking integer: 3
Filtered integer: 3
Mapped integer: 9
Taking integer: 4
Taking integer: 5
Filtered integer: 5
Mapped integer: 25
Sum = 35

请注意,输出显示了从数据源获取的偶数,但没有通过筛选操作。

应用 ForEach 操作

forEach操作对流的每个元素采取一个动作。该操作可以简单地将流的每个元素打印到标准输出,或者将流中每个人的收入增加10%Stream<T>接口包含两种方法来执行forEach操作:

  • void forEach(Consumer<? super T> action)

  • void forEachOrdered(Consumer<? super T> action)

IntStreamLongStreamDoubleStream也包含相同的方法,只是它们的参数类型是原语的专用消费者类型;例如,IntStream 中的forEach()方法的参数类型是 IntConsumer。

为什么有两种方法来执行forEach操作?有时候,对流中的元素应用操作的顺序很重要,有时候则不重要。forEach()方法不保证流中每个元素的动作被应用的顺序。forEachOrdered()方法按照流定义的元素相遇顺序执行动作。仅在必要时对并行流使用forEachOrdered()方法,因为它可能会降低处理速度。以下代码片段打印了人员列表中女性的详细信息:

Person.persons()
      .stream()
      .filter(Person::isFemale)
      .forEach(System.out::println);

(3, Donna, FEMALE, 1962-07-29, 8700.00)
(5, Laynie, FEMALE, 2012-12-13, 0.00)

清单 6-7 中的程序展示了如何使用forEach()方法将所有女性的收入提高10%。输出显示,只有唐娜得到了增加,因为另一位名叫蕾妮的女性以前有过0.0收入。

// ForEachTest.java
package com.jdojo.streams;
import java.util.List;
public class ForEachTest {
    public static void main(String[] args) {
        // Get the list of persons
        List<Person> persons = Person.persons();
        // Print the list
        System.out.println(
            "Before increasing the income: " + persons);
        // Increase the income of females by 10%
        persons.stream()
           .filter(Person::isFemale)
           .forEach( ->
               p.setIncome(p.getIncome() * 1.10));
        // Print the list again
        System.out.println(
            "After increasing the income: " + persons);
    }
}

Before increasing the income:
    [(1, Ken, MALE, 1970-05-04, 6000.00),
     (2, Jeff, MALE, 197007-15, 7100.00),
     (3, Donna, FEMALE, 1962-07-29, 8700.00),
     (4, Chris, MALE, 1993-12-16,1800.00),
     (5, Laynie, FEMALE, 2012-12-13, 0.00),
     (6, Li, MALE, 2001-05-09, 2400.00)]
After increasing the income:
    [(1, Ken, MALE, 1970-05-04, 6000.00),
     (2, Jeff, MALE, 197007-15, 7100.00),
     (3, Donna, FEMALE, 1962-07-29, 9570.00),
     (4, Chris, MALE, 1993-12-16,1800.00),
     (5, Laynie, FEMALE, 2012-12-13, 0.00),
     (6, Li, MALE, 2001-05-09, 2400.00)]

Listing 6-7Applying the ForEach Operation on a List of Persons

应用地图操作

映射操作(也称为映射)将函数应用于输入流的每个元素,以生成另一个流(也称为输出流或映射流)。输入和输出流中的元素数量是相同的。该操作不会修改输入流的元素——至少不应该这样做。

图 6-7 描述了地图操作在流上的应用。它显示输入流中的元素e1被映射到映射流中的元素et1,元素e2被映射到et2,等等。

img/323070_3_En_6_Fig7_HTML.jpg

图 6-7

地图操作的图示视图

将一个流映射到另一个流不限于任何特定类型的元素。您可以将T的流映射到类型S的流,其中TS可以是相同或不同的类型。例如,您可以将一个Person流映射到一个int流,其中输入流中的每个Person元素都映射到映射流中的这个人的 ID。您可以使用Stream<T>接口的以下方法之一在流上应用映射操作:

  • <R> Stream<R> map(Function<? super T,? extends R> mapper)

  • DoubleStream mapToDouble(ToDoubleFunction<? super T> mapper)

  • IntStream mapToInt(ToIntFunction<? super T> mapper)

  • LongStream mapToLong(ToLongFunction<? super T> mapper)

映射操作将函数作为参数。输入流中的每个元素都被传递给函数。函数的返回值是映射流中的映射元素。使用map()方法执行到引用类型元素的映射。如果映射的流是基元类型,则使用其他方法;例如,使用mapToInt()方法将引用类型的流映射到int的流。IntStreamLongStreamDoubleStream接口包含类似的方法,以便于将一种类型的流映射到另一种类型。在IntStream上支持地图操作的方法如下:

  • IntStream map(IntUnaryOperator mapper)

  • DoubleStream mapToDouble(IntToDoubleFunction mapper)

  • LongStream mapToLong(IntToLongFunction mapper)

  • <U> Stream<U> mapToObj(IntFunction<? extends U> mapper)

下面的代码片段创建了一个IntStream,它的元素是从15的整数,将流的元素映射到它们的方块,并在标准输出中打印映射的流。注意,代码中使用的map()方法是IntStream接口的map()方法:

IntStream.rangeClosed(1, 5)
         .map(n -> n * n)
         .forEach(System.out::println);

1
4
9
16
25

下面的代码片段将人员流的元素映射到他们的名字,并打印映射的流。注意,代码中使用的map()方法是Stream接口的map()方法:

Person.persons()
      .stream()
      .map(Person::getName)
      .forEach(System.out::println);

Ken
Jeff
Donna
Chris
Laynie
Li

使河流变平

在上一节中,您看到了简化一对一映射的映射操作。输入流的每个元素都映射到输出流中的一个元素。Streams API 还通过flatMap操作支持一对多映射。它的工作原理如下:

  1. 它接受一个输入流,并使用映射函数产生一个输出流。

  2. 映射函数从输入流中获取一个元素,并将该元素映射到一个流。输入元素的类型和映射流中的元素可能不同。这一步产生了一个流的流。假设输入流是Stream<T>,映射流是Stream<Stream<R»,其中TR可以相同也可以不同。

  3. 最后,它展平输出流(即流的流)以产生流。也就是说,Stream<Stream<R»被展平为Stream<R>

理解平面图操作需要一些时间。假设您有一个包含三个数字的流:123。你想产生一个包含数字和数字平方的流。您希望输出流包含112439。以下是实现这一点的第一次不正确的尝试:

Stream.of(1, 2, 3)
      .map(n -> Stream.of(n, n * n))
      .forEach(System.out::println);

java.util.stream.ReferencePipeline$Head@372f7a8d
java.util.stream.ReferencePipeline$Head@2f92e0f4
java.util.stream.ReferencePipeline\$Head@28a418fc

你对输出感到惊讶吗?您在输出中看不到数字。map()方法的输入流包含三个整数:123map()方法为输入流中的每个元素生成一个元素。在这种情况下,map()方法为输入流中的每个整数产生一个Stream<Integer>。它产生三个Stream<Integer>s。第一个流包含11;第二个包含24;第三个包含39forEach()方法接收Stream<Integer>对象作为其参数,并打印从每个Stream<Integer>toString()方法返回的字符串。你可以在一个流上调用forEach(),所以让我们嵌套它的调用来打印流的元素流,就像这样:

Stream.of(1, 2, 3)
      .map(n -> Stream.of(n, n * n))
      .forEach(e -> e.forEach(System.out::println));

1
1
2
4
3
9

你可以打印出数字和它们的方块。但是您还没有达到在Stream<Integer>中获得这些数字的目标。他们还在Stream<Stream<Integer»里。解决方法是使用flatMap()方法,而不是map()方法。以下代码片段实现了这一点:

Stream.of(1, 2, 3)
      .flatMap(n -> Stream.of(n, n * n))
      .forEach(System.out::println);

1
1
2
4
3
9

图 6-8 显示了flatMap()方法在这个例子中是如何工作的。如果您仍然对flatMap操作的工作方式有疑问,您可以以相反的顺序考虑它的名字。读作 mapFlat,意思是“将输入流的元素映射到流,然后展平映射的流。”

img/323070_3_En_6_Fig8_HTML.jpg

图 6-8

使用平面图方法展平流

我们再举一个平面图操作的例子。假设你有一串字符串。你将如何计算字符串中 e 的个数?以下代码片段向您展示了如何做到这一点:

long count = Stream.of("Ken", "Jeff", "Ellen")
           .map(name -> name.chars())
           .flatMap(intStream -> intStream.
               mapToObj(n -> (char)n))
           .filter(ch -> ch == 'e' || ch == 'E')
           .count();
System.out.println("Es count: " + count);

Es count: 4

代码将字符串映射到IntStream。注意,String类的chars()方法返回一个IntStream,而不是一个Stream<Character>map()方法的输出是Stream<IntStream>flatMap()方法将Stream<IntStream>映射到Stream<Stream<Character»,最后,将其展平以产生一个Stream<Character>。所以,flatMap()方法的输出是Stream<Character>filter()方法过滤掉所有不是Ee的字符。最后,count()方法返回流中元素的数量。主要逻辑是将Stream<String>转换为Stream<Character>。您也可以使用下面的代码达到同样的效果:

long count = Stream.of("Ken", "Jeff", "Ellen")
           .flatMap(name ->
               IntStream.range(0, name.length())
               .mapToObj(name::charAt))
           .filter(ch -> ch == 'e' || ch == 'E')
           .count();

IntStream.range()方法创建一个包含输入字符串中所有字符索引的IntStreammapToObj()方法将IntStream转换成Stream<Character>,其元素是输入字符串中的字符。

应用过滤操作

过滤操作应用于输入流以产生另一个流,该流被称为过滤流。过滤后的流包含谓词评估为true的输入流的所有元素。谓词是一个接受流元素并返回一个boolean值的函数。与映射流不同,过滤流与输入流的类型相同。

过滤操作产生输入流的子集。如果谓词对输入流的所有元素都评估为false,则过滤后的流是一个空流。图 6-9 显示了对流应用过滤操作的示意图。该图显示输入流中的两个元素(e1 和 en)进入了过滤流,而另外两个元素(e2 and e3)被过滤掉了。

img/323070_3_En_6_Fig9_HTML.jpg

图 6-9

过滤器操作的图示视图

您可以使用StreamIntStreamLongStreamDoubleStream接口的filter()方法对流应用过滤操作。该方法接受一个Predicate。Streams API 提供了不同风格的过滤操作,我将在几个使用filter()方法的例子之后讨论这些操作。

Note

在映射操作中,新流包含相同数量的元素,这些元素的值不同于输入流。在筛选操作中,新流包含不同数量的元素,这些元素具有来自输入流的相同值。

下面的代码片段使用了一个人流,并且只过滤女性。它将雌性映射到它们的名字,并将它们输出到标准输出:

Person.persons()
      .stream()
      .filter(Person::isFemale)
      .map(Person::getName)
      .forEach(System.out::println);

Donna
Laynie

以下代码片段应用两个筛选操作来打印所有收入超过 5000.0 的男性的姓名:

Person.persons()
      .stream()
      .filter(Person::isMale)
      .filter(p -> p.getIncome() > 5000.0)
      .map(Person::getName)
      .forEach(System.out::println);

Ken
Jeff

您可以使用下面的语句完成同样的任务,该语句仅使用一个过滤操作,该操作将两个谓词过滤成一个谓词:

Person.persons()
      .stream()
      .filter(p -> p.isMale() && p.getIncome() > 5000.0)
      .map(Person::getName)
      .forEach(System.out::println);

Ken
Jeff

以下方法可用于将过滤操作应用于流:

  • Stream<T> skip(long count)

  • Stream<T> limit(long maxCount)

  • default Stream<T> dropWhile(Predicate<? super T> predicate)

  • default Stream<T> takeWhile(Predicate<? super T> predicate)

skip()方法从开始跳过指定的 count 元素后返回流的元素。limit()方法从流的开头返回等于或小于指定的maxCount的元素。其中一种方法从开头删除元素,另一种方法从开头删除剩余的元素。两者都基于元素的数量。dropWhile()takeWhile()分别类似于skip()limit()方法;然而,他们处理的是Predicate而不是元素的数量。

您可以认为dropWhile()takeWhile()方法类似于filter()方法,只是有一点例外。filter()方法评估所有元素上的谓词,而dropWhile()takeWhile()方法从流的开始评估元素上的谓词,直到谓词评估为false

对于一个有序流,dropWhile()方法返回流的元素,从指定谓词为true的开始处丢弃元素。考虑以下有序整数流:

1, 2, 3, 4, 5, 6, 7

如果在dropWhile()方法中使用一个谓词,该谓词为小于5的整数返回true,该方法将丢弃前四个元素并返回其余的元素:

Stream.of(1, 2, 3, 4, 5, 6, 7)
             .dropWhile(e -> e < 5)
             .forEach(System.out::println);

5
6
7

对于无序的流,dropWhile()方法的行为是不确定的。它可以选择删除与谓词匹配的任何元素子集。当前的实现从一开始就丢弃匹配的元素,直到找到不匹配的元素。下面的代码片段对一个无序流使用了dropWhile()方法,只有一个匹配谓词的元素被删除:

Stream.of(1, 5, 6, 2, 3, 4, 7)
      .dropWhile(e -> e < 5)
      .forEach(System.out::println);

5

6
2
3
4
7

dropWhile()方法有两种极端情况。如果第一个元素与谓词不匹配,该方法将返回原始流。如果所有元素都与谓词匹配,该方法将返回一个空流。

takeWhile()方法的工作方式与dropWhile()方法相同,除了它从流的开头返回匹配的元素并丢弃其余的元素。

Caution

对有序的并行流使用dropWhile()takeWhile()方法时要非常小心,因为您可能会看到性能下降。在有序的并行流中,在这些方法可以返回之前,必须对元素进行排序并从所有线程中返回。这些方法在处理顺序流时表现最佳。

应用归约操作

reduce 操作通过重复应用组合函数来组合流中的所有元素以产生单个值。它也被称为归约操作或折叠。计算总和、最大值、平均值、计数等。整数流的元素是归约操作的例子。在ListSetMap中收集流的元素也是归约操作的一个例子。

reduce 操作有两个参数,称为种子(也称为初始值)和累加器。累加器是一个函数。如果流是空的,种子就是结果。否则,种子表示部分结果。部分结果和一个元素被传递给累加器,累加器返回另一个部分结果。如此重复,直到所有元素都被传递到累加器。从累加器返回的最后一个值是归约操作的结果。图 6-10 显示了缩减操作的示意图。

img/323070_3_En_6_Fig10_HTML.jpg

图 6-10

应用归约操作的图示视图

与流相关的接口包含两个名为 reduce()和collect()的方法来执行一般的 reduce 操作。方法如sum()max()min()count()等。也可用于执行专门的归约操作。请注意,专用方法并不适用于所有类型的流。例如,在Stream<T>接口中有一个sum()方法是没有意义的,因为添加引用类型元素,比如添加两个人,是没有意义的。所以,你只会在IntStreamLongStreamDoubleStream接口中找到类似sum()的方法。计算流中元素的数量对所有类型的流都有意义。因此,count()方法适用于所有类型的流。我将在本节讨论reduce()方法。我将在接下来的几节中讨论collect()方法。

让我们考虑下面的代码片段,它以命令式编程风格执行 reduce 操作。该代码计算列表中所有整数的总和:

// Create the list of integers
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
// Declare an accumulator called sum and initialize
// (or seed) it to zero
int sum = 0;
for(int num : numbers) {
    // Accumulate the partial result in sum
    sum = sum + num;
}
// Print the result
System.out.println(sum);

15

代码声明了一个名为 sum 的变量,并将该变量初始化为0。如果列表中没有元素,sum 的初始值将成为结果。for-each 循环遍历列表,并将部分结果存储在sum变量中,将其用作累加器。当 for-each 循环结束时,sum变量包含结果。正如本章开头所指出的,这样的 for 循环没有并行化的空间;整个逻辑必须在单个线程中执行。

考虑另一个计算列表中人员收入总和的示例:

// Declare an accumulator called sum and initialize
// it to zero
double sum = 0.0;
for(Person person : Person.persons()) {
    // Map the Person to his income double
    double income = person.getIncome();
    // Accumulate the partial result in sum
    sum = sum + income;
}
System.out.println(sum);

这一次,您必须执行一个额外的步骤来将Person映射到他们的收入,然后才能在sum变量中累积部分结果。

Stream<T>接口包含一个reduce()方法来执行归约操作。该方法有三个重载版本:

  • T reduce(T identity, BinaryOperator<T> accumulator)

  • <U> U reduce(U identity, BiFunction<U,? super T,U> accumulator, BinaryOperator<U> combiner)

  • Optional<T> reduce(BinaryOperator<T> accumulator)

第一个版本的reduce()方法将一个标识和一个累加器作为参数,并将流简化为同类型的单个值。您可以重新编写计算列表中整数之和的示例,如下所示:

List<Integer> numbers = List.of(1, 2, 3, 4, 5);
int sum = numbers.stream()
                 .reduce(0, Integer::sum);
System.out.println(sum);

15

让我们尝试用第二个例子做同样的事情,它计算收入的总和。下面的代码生成一个编译时错误。仅显示错误消息的相关部分:

double sum = Person.persons()
                   .stream()
                   .reduce(0.0, Double::sum);

error: no suitable method found for
    reduce(double,Double::sum)
    .reduce(0.0, Double::sum);
    ^
    method Stream.reduce(Person,BinaryOperator
    <Person>) is not applicable
      (argument mismatch;
    double cannot be converted to Person) ...

Person.persons().stream()中的stream()方法返回一个Stream<Person>,因此,reduce()方法应该对Person对象执行一个归约。然而,该方法的第一个参数是0.0,这意味着该方法试图操作Double类型,而不是Person类型。预期参数类型Person和实际参数类型Double的不匹配导致了错误。

你想计算所有人的收入总和。您需要使用映射操作将人流映射到他们的收入流,如下所示:

double sum = Person.persons()
                   .stream()
                   .map(Person::getIncome)
                   .reduce(0.0, Double::sum);
System.out.println(sum);

26000.0

执行 map-reduce 操作是函数式编程中的典型操作。reduce 方法的第二个版本(为了便于参考,再次显示)允许您执行映射操作,然后执行 reduce 操作。

<U> U reduce(U identity,
    BiFunction<U,? super T,U> accumulator,
    BinaryOperator<U> combiner)

请注意,第二个参数是累加器,它接受一个类型可能不同于流类型的参数。这用于映射操作以及累积部分结果。当并行执行 reduce 操作时,第三个参数用于组合部分结果,稍后我会详细说明。以下代码片段打印了所有人的收入总和:

double sum = Person.persons()
    .stream()
    .reduce(0.0, (partialSum, person) ->
        partialSum + person.getIncome(), Double::sum);
System.out.println(sum);

26000.0

如果您检查代码,在这种情况下,reduce()方法的第二个参数足以产生期望的结果。那么,第三个参数Double::sum的目的是什么,也就是合并器?事实上,合并器根本没有在reduce()操作中使用,即使您指定了它。您可以使用下面的代码来验证合并器没有被使用,该代码从合并器中打印一条消息:

double sum = Person.persons()
    .stream()
    .reduce(0.0, (partialSum, person) ->
        partialSum + person.getIncome(),
        (a, b) -> {
            System.out.println(
                "Combiner called: a = " + a + "b = " + b );
            return a + b;
        });
System.out.println(sum);

26000.0

输出证明合并器没有被调用。为什么不用的时候需要提供合路器?并行执行归约操作时会用到它。在这种情况下,每个线程将使用累加器累加部分结果。最后,组合器用于组合来自所有线程的部分结果以获得结果。下面的代码片段展示了顺序归约操作是如何工作的。该代码在几个步骤中打印一条消息,以及正在执行操作的当前线程名称:

double sum = Person.persons()
   .stream()
   .reduce(0.0,
       (Double partialSum, Person p) -> {
          double accumulated = partialSum + p.getIncome();
          System.out.println(
              Thread.currentThread().getName() +
              " - Accumulator: partialSum = " +
              partialSum + ", person = " + p +
              ", accumulated = " + accumulated);
         return accumulated;
       },
       (a, b) -> {
           double combined = a + b;
           System.out.println(
             Thread.currentThread().getName() +
             " - Combiner: a = " + a + ", b = " + b +
             ", combined = " + combined);
          return combined;
       });
System.out.println(sum);

main - Accumulator: partialSum = 0.0,
  person = (1, Ken, MALE, 1970-05-04, 6000.00),
  accumulated = 6000.0
main - Accumulator: partialSum = 6000.0,
  person = (2, Jeff, MALE, 1970-07-15, 7100.00),
  accumulated = 13100.0
main - Accumulator: partialSum = 13100.0,
  person = (3, Donna, FEMALE, 1962-07-29, 8700.00),
  accumulated = 21800.0
main - Accumulator: partialSum = 21800.0,
  person = (4, Chris, MALE, 1993-12-16, 1800.00),
  accumulated = 23600.0
main - Accumulator: partialSum = 23600.0,
  person = (5, Laynie, FEMALE, 2012-12-13, 0.00),
  accumulated = 23600.0
main - Accumulator: partialSum = 23600.0,
  person = (6, Li, MALE, 2001-05-09, 2400.00),
  accumulated = 26000.0
26000.0

输出显示累加器足以产生结果,而合并器从未被调用。注意,只有一个名为 main 的线程处理流中的所有人。

让我们把流变成一个并行流,保留所有的调试消息。下面的代码使用并行流来获得所有人的收入总和。您可能会得到包含不同消息的不同输出,但是总和值将与26000.0相同。

double sum = Person.persons()
   .parallelStream()
   .reduce(0.0,
       (Double partialSum, Person p) -> {
          double accumulated = partialSum + p.getIncome();
          System.out.println(
              Thread.currentThread().getName() +
              " - Accumulator: partialSum = " +

              partialSum + ", person = " + p +
              ", accumulated = " + accumulated);
         return accumulated;
       },
       (a, b) -> {
           double combined = a + b;
           System.out.println(
             Thread.currentThread().getName() +
             " - Combiner: a = " + a + ", b = " + b +
             ", combined = " + combined);
          return combined;
       });
System.out.println(sum);

ForkJoinPool.commonPool-worker-4 -
  Accumulator: partialSum = 0.0,
  person = (5, Laynie, FEMALE, 2012-12-13, 0.00),
  accumulated = 0.0
ForkJoinPool.commonPool-worker-2 -
  Accumulator: partialSum = 0.0,
  person = (6, Li, MALE, 2001-05-09, 2400.00),
  accumulated = 2400.0
ForkJoinPool.commonPool-worker-1 -
  Accumulator: partialSum = 0.0,
  person = (2, Jeff, MALE, 1970-07-15, 7100.00),
  accumulated = 7100.0
ForkJoinPool.commonPool-worker-2 -
  Combiner: a = 0.0, b = 2400.0, combined = 2400.0
ForkJoinPool.commonPool-worker-5 -
  Accumulator: partialSum = 0.0,
  person = (3, Donna, FEMALE, 1962-07-29, 8700.00),
  accumulated = 8700.0
main - Accumulator: partialSum = 0.0,
  person = (4, Chris, MALE, 1993-12-16, 1800.00),
  accumulated = 1800.0
ForkJoinPool.commonPool-worker-3 -
  Accumulator: partialSum = 0.0,
  person = (1, Ken, MALE, 1970-05-04, 6000.00),
  accumulated = 6000.0
main - Combiner: a = 1800.0, b = 2400.0,
  combined = 4200.0
ForkJoinPool.commonPool-worker-5 -
  Combiner: a = 7100.0, b = 8700.0, combined = 15800.0
ForkJoinPool.commonPool-worker-5 -
  Combiner: a = 6000.0, b = 15800.0, combined = 21800.0
ForkJoinPool.commonPool-worker-5 -
  Combiner: a = 21800.0, b = 4200.0, combined = 26000.0
26000.0

输出显示六个线程(五个 fork/join 工作线程和一个主线程)执行了并行 reduce 操作。它们都使用累加器执行部分归约以获得部分结果。最后,使用组合器将部分结果组合起来得到结果。

有时,您不能为归约操作指定默认值。假设你想从一个整数流中获取一个最大的整数值。如果流为空,则不能将最大值默认为0。在这种情况下,结果是不确定的。第三种版本的reduce(BinaryOperator<T> accumulator)方法用于执行这样的归约操作。该方法返回一个Optional<T>,包装结果或结果的缺失。如果流只包含一个元素,则该元素就是结果。如果流包含多个元素,则前两个元素被传递到累加器,随后部分结果和剩余元素被传递到累加器。以下代码片段计算流中整数的最大值:

Optional<Integer> max = Stream.of(1, 2, 3, 4, 5)
                              .reduce(Integer::max);
if (max.isPresent()) {
    System.out.println("max = " + max.get());
} else {
    System.out.println("max is not defined.");
}

max = 5

以下代码片段尝试获取空流中的最大整数:

Optional<Integer> max = Stream.<Integer>empty()
                              .reduce(Integer::max);
if (max.isPresent()) {
    System.out.println("max = " + max.get());
} else {
    System.out.println("max is not defined.");
}

max is not defined.

下面的代码片段打印了个人列表中收入最高者的详细信息:

Optional<Person> person = Person.persons()
    .stream()
    .reduce((p1, p2) ->
        p1.getIncome() > p2.getIncome() ? p1 : p2);
if (person.isPresent()) {
    System.out.println(
        "Highest earner: " + person.get());
} else {
    System.out.println(
        "Could not get the highest earner.");
}

Highest earner: (3, Donna, FEMALE, 1962-07-29, 8700.00)

计算总和、最大值、最小值、平均值等。对于数字流,您不需要使用reduce()方法。您可以将非数字流映射到三种数字流类型(IntStreamLongStreamDoubleStream)中的一种,并使用专门的方法来实现这些目的。下面的代码片段打印了所有人的收入总和。请注意将Stream<Person>转换为DoubleStreammapToDouble()方法的使用。在DoubleStream上调用sum()方法。

double totalIncome = Person.persons()
    .stream()
    .mapToDouble(Person::getIncome)
    .sum();
System.out.println("Total Income: " + totalIncome);

Total Income : 26000.0

要获得流的最小值和最大值,使用特定流的min()max()方法。Stream<T>接口中的这些方法将一个Comparator作为参数,并返回一个Optional<T>。它们在IntStreamLongStreamDoubleStream接口中不接受任何参数,并分别返回OptionalIntOptionalLongOptionalDouble。下面的代码片段打印了人员列表中收入最高者的详细信息:

Optional<Person> person = Person.persons()
    .stream()
    .max(Comparator.comparingDouble(Person::getIncome));
if (person.isPresent()) {
    System.out.println(
        "Highest earner: " + person.get());
} else {
    System.out.println(
        "Could not get the highest earner.");
}

Highest earner: (3, Donna, FEMALE, 1962-07-29, 8700.00)

下面的代码片段使用DoubleStreammax()方法打印人员列表中的最高收入:

OptionalDouble income = Person.persons()
    .stream()
    .mapToDouble(Person::getIncome)
    .max();
if (income.isPresent()) {
    System.out.println(
        "Highest income: " + income.getAsDouble());
} else {
    System.out.println(
        "Could not get the highest income.");
}

Highest income: 8700.0

你将如何在一个流管道中获得男性中最高的收入和女性中最高的收入?到目前为止,您已经学习了如何使用 reduce 运算计算单个值。在这种情况下,您需要将这些人分成两组,男性和女性,然后计算每组中收入最高的人。在下一节讨论collect()方法时,我将向您展示如何执行分组和收集多个值。

流通过count()方法支持计数操作,该方法简单地将流中元素的数量作为一个long返回。以下代码片段打印了人流中的元素数量:

long personCount = Person.persons()
    .stream()
    .count();
System.out.println("Person count: " + personCount);

Person count: 6

计数操作是一种专门归约操作。您是否想过使用map()reduce()方法来计算一个流中的元素数量?更简单的方法是将流中的每个元素映射到1并计算总和。这种方法不使用reduce()方法。以下是您的操作方法:

long personCount = Person.persons()
    .stream()
    .mapToLong(p -> 1L)
    .sum();

以下代码片段使用map()reduce()方法来实现计数操作:

long personCount = Person.persons()
    .stream()
    .map(p -> 1L)
    .reduce(0L, Long::sum);

以下代码片段仅使用reduce()方法来实现计数操作:

long personCount = Person.persons()
    .stream()
    .reduce(0L, (partialCount, person) ->
        partialCount + 1L,
        Long::sum);

Note

本节向您展示了在流上执行相同归约操作的许多方法。根据流类型和使用的并行化,一些方法可能比其他方法执行得更好。尽可能使用基元类型流,以避免取消装箱的开销;尽可能使用并行流,以利用机器上可用的多内核。

使用收集器收集数据

到目前为止,您已经在流上应用了归约来产生单个值(原始值或参考值)或void。例如,您使用了Stream<Integer>接口的reduce()方法来计算一个long值,该值是其元素的总和。有几种情况下,您希望将执行流管道的结果收集到一个集合中,如ListSetMap等。有时,您可能想要应用复杂的逻辑来总结流的数据。例如,您可能希望按性别对人员进行分组,并计算每个性别组中收入最高的人员。这可以通过使用Stream<T>接口的collect()方法来实现。collect()方法重载了两个版本:

  • <R> R collect(Supplier<R> supplier, BiConsumer<R,? super T> accumulator, BiConsumer<R,R> combiner)

  • <R,A> R collect(Collector<? super T,A,R> collector):该方法使用可变归约运算。它使用一个可变的容器,比如一个可变的Collection来计算来自输入流的结果。第一个版本的collect()方法有三个参数:

    • 提供可变容器来存储(或收集)结果的供应商

    • 将结果累积到可变容器中的累加器

    • 并行进行归约操作时组合部分结果的组合器

Note

使用collect()方法收集数据的容器不必是Collection。它可以是任何可以累积结果的可变对象,比如一个StringBuilder

假设您有一个人流,您想在一个ArrayList<String>中收集所有人的名字。下面是实现这一点的步骤。

首先,您需要有一个将返回一个ArrayList<String>来存储名称的供应商。您可以使用以下任一语句来创建供应商:

// Using a lambda expression
Supplier<ArrayList<String>> supplier =
    () -> new ArrayList<>();
// Using a constructor reference
Supplier<ArrayList<String>> supplier =
    ArrayList::new;

其次,您需要创建一个接收两个参数的累加器。第一个参数是从供应商返回的容器,在本例中是ArrayList<String>。第二个参数是流的元素。您的累加器应该简单地将名字添加到列表中。您可以使用以下任一语句来创建累加器:

// Using a lambda expression
BiConsumer<ArrayList<String>, String> accumulator =
    (list, name) -> list.add(name);
// Using a method reference
BiConsumer<ArrayList<String>, String> accumulator =
    ArrayList::add;

最后,您需要一个合并器,将两个ArrayList<String>s的结果合并成一个ArrayList<String>。请注意,只有当您使用并行流收集结果时,才会使用合并器。在顺序流中,累加器足以收集所有结果。你的合并器会很简单;它将使用addAll()方法将第二个列表中的所有元素添加到第一个列表中。您可以使用以下任一语句来创建合并器:

// Using a lambda expression
BiConsumer<ArrayList<String>,
           ArrayList<String>> combiner =
    (list1, list2) -> list1.addAll(list2);
// Using a method reference
BiConsumer<ArrayList<String>,
           ArrayList<String>> combiner =
    ArrayList::addAll;

现在,您已经准备好使用下面的代码片段使用collect()方法来收集列表中所有人的名字:

List<String> names = Person.persons()
    .stream()
    .map(Person::getName)
    .collect(ArrayList::new,
             ArrayList::add,
             ArrayList::addAll);
System.out.println(names);

[Ken, Jeff, Donna, Chris, Laynie, Li]

您可以使用类似的方法在SetMap中收集数据。仅仅在一个简单的集合(如列表)中收集数据似乎就要做很多准备工作。另一种版本的collect()方法提供了一个更简单的解决方案。它将Collector接口的一个实例作为参数,并为您收集数据。Collector接口在java.util.stream包中,声明如下。只显示了抽象方法:

public interface Collector<T,A,R> {
    Supplier<A> supplier();
    BiConsumer<A,T> accumulator();
    BinaryOperator<A> combiner();
    Function<A,R> finisher();
    Set<Collector.Characteristics> characteristics();
}

Collector接口接受三个类型参数,称为TAR,其中T是输入元素的类型,A是累加器的类型,R是结果的类型。前三种方法看起来很熟悉;您只是在前一个示例中使用了它们。finisher用于将中间类型A转换为结果类型RCollector的特征描述了由Collector.Characteristics枚举的常量所代表的属性。

Streams API 的设计者意识到推出自己的收集器工作量太大。他们提供了一个名为Collectors的实用程序类,为常用的收集器提供现成的实现。Collectors类最常用的三种方法是toList()toSet()toCollection()toList()方法返回一个Collector,它收集List中的数据;toSet()方法返回一个在Set中收集数据的CollectortoCollection()接受一个Supplier,该Supplier返回一个用于收集数据的Collection。下面的代码片段收集了一个List<String>中所有人的名字:

List<String> names = Person.persons()
    .stream()
    .map(Person::getName)
    .collect(Collectors.toList());
System.out.println(names);

[Ken, Jeff, Donna, Chris, Laynie, Li]

请注意,这一次您以一种更加干净的方式获得了相同的结果。

下面的代码片段收集了一个Set<String>中的所有名字。注意,Set只保存唯一的元素。

Set<String> uniqueNames = Person.persons()
    .stream()
    .map(Person::getName)
    .collect(Collectors.toSet());
System.out.println(uniqueNames);

[Donna, Ken, Chris, Jeff, Laynie, Li]

输出没有特定的顺序,因为Set没有对其元素进行任何排序。您可以使用toCollection()方法在一个有序集合中收集姓名,如下所示:

SortedSet<String> uniqueSortedNames= Person.persons()
    .stream()
    .map(Person::getName)
    .collect(Collectors.toCollection(TreeSet::new));
System.out.println(uniqueSortedNames);

[Chris, Donna, Jeff, Ken, Laynie, Li]

回想一下,toCollection()方法将一个Supplier作为用于收集数据的参数。在这种情况下,您使用了构造函数引用TreeSet::new作为Supplier。这就产生了使用一个TreeSet来收集数据的效果,它是一个有序的集合。

您也可以使用排序操作对名称列表进行排序。接口Streamsorted()方法产生另一个流,该流包含排序后的相同元素。以下代码片段显示了如何在列表中收集已排序的名称:

List<String> sortedName = Person.persons()
    .stream()
    .map(Person::getName)
    .sorted()
    .collect(Collectors.toList());
System.out.println(sortedName);

[Chris, Donna, Jeff, Ken, Laynie, Li]

请注意,代码在收集姓名之前会应用排序。收集器注意到它正在收集一个有序的流(已排序的名称),并在收集过程中保持排序。

您将在Collectors类中找到许多静态方法,它们返回一个用于嵌套收集器的Collector。其中一个方法是返回输入元素数量的counting()方法。下面是一个统计流中人数的例子:

long count = Person.persons()
    .stream()
    .collect(Collectors.counting());
System.out.println("Person count: " + count);

Person count: 6

您可能会争辩说,使用Stream接口的count()方法可以获得相同的结果,如下所示:

long count = Person.persons()
    .stream()
    .count();
System.out.println("Persons count: " + count);

Persons count: 6

什么时候使用Collectors.counting()方法而不是Stream.count()方法来计算一个流中元素的数量?如前所述,收集器可以嵌套。您将很快看到嵌套收集器的示例。Collectors类中的这些方法旨在作为嵌套收集器使用,而不仅仅是为了计算流中元素的数量。两者之间的另一个区别是它们的类型:Stream.count()方法表示流上的操作,而Collectors. counting()方法返回一个Collector。清单 6-8 显示了收集列表中已排序名字的完整程序。

// CollectTest.java
package com.jdojo.streams;
import java.util.List;
import java.util.stream.Collectors;
public class CollectTest {
    public static void main(String[] args) {
        List<String> sortedNames = Person.persons()
            .stream()
            .map(Person::getName)
            .sorted()
            .collect(Collectors.toList());
        System.out.println(sortedNames);
    }
}

[Chris, Donna, Jeff, Ken, Laynie, Li]

Listing 6-8Collecting Results into a Collection

收集汇总统计数据

在以数据为中心的应用程序中,您需要计算一组数字数据的汇总统计数据。例如,您可能想知道所有人收入的最大值、最小值、总和、平均值和计数。java.util包包含三个收集统计数据的类:

  • DoubleSummaryStatistics

  • LongSummaryStatistics

  • IntSummaryStatistics

这些类不一定需要与流一起使用。您可以使用它们来计算任何数字数据组的汇总统计数据。使用这些类很简单:创建该类的一个对象,使用accept()方法不断添加数字数据,最后,调用 getter 方法如getCount()getSum()getMin()getAverage()getMax()来获取数据组的统计信息。清单 6-9 展示了如何计算一些double值的统计数据。

// SummaryStats.java
package com.jdojo.streams;
import java.util.DoubleSummaryStatistics;
public class SummaryStats {
    public static void main(String[] args) {
        DoubleSummaryStatistics stats =
            new DoubleSummaryStatistics();
        stats.accept(100.0);
        stats.accept(500.0);
        stats.accept(400.0);
        // Get stats
        long count = stats.getCount();
        double sum = stats.getSum();
        double min = stats.getMin();
        double avg = stats.getAverage();
        double max = stats.getMax();
        System.out.printf("count=%d, sum=%.2f, " +
                "min=%.2f, max=%.2f, average=%.2f%n",
                count, sum, min, max, avg);
    }
}

count=3, sum=1000.00, min=100.00, max=500.00,
average=333.33

Listing 6-9Computing Summary Statistics on a Group of Numeric Data

汇总统计类被设计用于流。它们包含一个组合两个汇总统计数据的combine()方法。你能猜出它的用途吗?回想一下,当您从流中收集数据时,您需要指定一个组合器,这个方法可以充当两个汇总统计信息的组合器。以下代码片段计算所有人收入的汇总统计数据:

DoubleSummaryStatistics incomeStats =
    Person.persons()
          .stream()
          .map(Person::getIncome)
          .collect(DoubleSummaryStatistics::new,
                   DoubleSummaryStatistics::accept,
                   DoubleSummaryStatistics::combine);
System.out.println(incomeStats);

DoubleSummaryStatistics{count=6, sum=26000.000000,
min=0.000000, average=4333.333333,
max=8700.000000}

Collectors类包含获取收集器的方法,以计算特定类型数字数据的汇总统计信息。这些方法被命名为summarizingDouble()summarizingLong()summarizingInt()。它们接受应用于流元素的函数,并分别返回一个DoubleSummaryStatistics、一个LongSummaryStatistics和一个IntSummaryStatistics。您可以按如下方式重写前面示例的代码:

DoubleSummaryStatistics incomeStats =
    Person.persons()
          .stream()
          .collect(Collectors.summarizingDouble(Person::getIncome));
System.out.println(incomeStats);

DoubleSummaryStatistics{count=6, sum=26000.000000, min=0.000000, average=4333.333333,
max=8700.000000}

Collectors类包含诸如counting()summingXxx()averagingXxx()minBy()maxBy(之类的方法,这些方法返回一个收集器,以对使用summarizingXxx()方法一次性获得的一组数字数据执行特定类型的汇总计算。这里的Xxx可以是DoubleLongInt

在地图中收集数据

您可以将流中的数据收集到一个Map中。Collectors类的toMap()方法返回一个收集器来收集Map中的数据。该方法是重载的,它有三个版本:

  • toMap(Function<? super T,? extends K> keyMapper, Function<? super T,? extends U> valueMapper)

  • toMap(Function<? super T,? extends K> keyMapper, Function<? super T,? extends U> valueMapper, BinaryOperator<U> mergeFunction)

  • toMap(Function<? super T,? extends K> keyMapper, Function<? super T,? extends U> valueMapper, BinaryOperator<U> mergeFunction, Supplier<M> mapSupplier)

第一个版本有两个参数。两种说法都是Functions。第一个参数将流元素映射到映射中的键。第二个参数将流元素映射到 map 中的值。如果发现重复的键,就会抛出一个IllegalStateException。下面的代码片段在一个Map<long,String>中收集一个人的数据,其键是这个人的 id,值是这个人的名字:

Map<Long,String> idToNameMap = Person.persons()
    .stream()
    .collect(Collectors.toMap(Person::getId,
Person::getName));
System.out.println(idToNameMap);

{1=Ken, 2=Jeff, 3=Donna, 4=Chris, 5=Laynie, 6=Li}

假设你想根据性别收集一个人的名字。下面是第一个不正确的尝试,它抛出了一个IllegalStateException。仅显示了部分输出。

Map<Person.Gender,String> genderToNamesMap =
    Person.persons()
    .stream()
    .collect(Collectors.toMap(Person::getGender,
                              Person::getName));

Exception in thread "main"
java.lang.IllegalStateException: Duplicate key Ken ...

运行时抱怨重复的键,因为Person::getGender将返回这个人的性别作为键,并且您在流中有多个男性和女性。

解决方案是使用第二个版本的toMap()方法来获取集合。它允许您指定一个合并函数作为第三个参数。向合并函数传递重复键的旧值和新值。该函数应该合并这两个值,并返回一个将用于键的新值。在你的例子中,你可以把所有男性和女性的名字连接起来。以下代码片段实现了这一点:

Map<Person.Gender,String> genderToNamesMap =
    Person.persons()
    .stream()
    .collect(Collectors.toMap(
            Person::getGender,
            Person::getName,
            (oldValue, newValue) ->
                String.join(", ", oldValue, newValue)));
System.out.println(genderToNamesMap);

{FEMALE=Donna, Laynie, MALE=Ken, Jeff, Chris, Li}

toMap()方法的前两个版本为您创建了Map。第三个版本让你通过一个Supplier来自己提供一个Map。我没有介绍使用这个版本的toMap()方法的例子。

有了两个在地图中收集数据的例子,您能想到在按性别汇总人数的地图中收集数据的逻辑吗?以下是如何实现这一点:

Map<Person.Gender, Long> countByGender = Person.persons()
    .stream()
    .collect(Collectors.toMap(
            Person::getGender,
            p -> 1L,
            (oldCount, newCount) -> oldCount + 1));
System.out.println(countByGender);

{MALE=4, FEMALE=2}

键映射器功能保持不变。值映射函数是p -> 1L,也就是说当第一次遇到属于某个性别的人时,它的值被设置为1。在出现重复键的情况下,调用合并函数,简单地将旧值增加1

清单 6-10 显示了这个类别中最后一个按性别收集 a Map中最高收入者的例子。

// CollectIntoMapTest.java
package com.jdojo.streams;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
public class CollectIntoMapTest {
    public static void main(String[] args) {
        Map<Person.Gender, Person> highestEarnerByGender =
          Person.persons()
            .stream()
            .collect(Collectors.toMap(
                Person::getGender,
                Function.identity(),
                (oldPerson, newPerson) ->
                    newPerson.getIncome() >
                    oldPerson.getIncome() ?
                    newPerson:oldPerson));
        System.out.println(highestEarnerByGender);
    }
}

{ FEMALE=(3, Donna, FEMALE, 1962-07-29, 8700.00),
  MALE=(2, Jeff, MALE, 1970-07-15, 7100.00)
}

Listing 6-10Collecting the Highest Earner by Gender in a Map

程序将Person对象存储为地图中的值。注意使用Function.identity()作为映射值的函数。该方法返回一个 identity 函数,该函数只返回传递给它的值。你可以用一个person -> person的 Lambda 表达式来代替它。merge 函数比较已经存储为键值的个人收入。如果新的人比现有的人有更多的收入,它返回新的人。

将数据收集到地图中是一种非常有效的汇总数据的方式。当我稍后讨论数据的分组和分区时,您将再次看到映射。

Note

toMap()方法返回一个非并发映射,当流被并行处理时,它会产生性能开销。它有一个名为toConcurrentMap()的伴生方法,返回并行处理流时应该使用的并发收集器。

使用收集器连接字符串

Collectors类的 joining()方法返回一个连接CharSequence流元素的收集器,并将结果作为String返回。串联按相遇顺序发生。joining()方法是重载的,它有三个版本:

  • joining()

  • joining(CharSequence delimiter)

  • joining(CharSequence delimiter, CharSequence prefix, CharSequence suffix)

不带参数的版本只是连接所有元素。第二个版本在两个元素之间使用分隔符。第三个版本使用分隔符、前缀和后缀。前缀添加到结果的开头,后缀添加到结果的结尾。清单 6-11 展示了如何使用joining()方法。

// CollectJoiningTest.java
package com.jdojo.streams;
import java.util.List;
import java.util.stream.Collectors;
public class CollectJoiningTest {
    public static void main(String[] args) {
        List<Person> persons = Person.persons();
        String names = persons.stream()
            .map(Person::getName)
            .collect(Collectors.joining());
        String delimitedNames = persons.stream()
            .map(Person::getName)
            .collect(Collectors.joining(", "));
        String prefixedNames = persons.stream()
            .map(Person::getName)
            .collect(Collectors.joining(
                ", ", "Hello ", ". Goodbye."));
        System.out.println("Joined names: " + names);
        System.out.println("Joined, delimited names: " +
            delimitedNames);
        System.out.println(prefixedNames);
    }
}

Joined names: KenJeffDonnaChrisLaynieLi
Joined, delimited names:
    Ken, Jeff, Donna, Chris, Laynie, Li
Hello Ken, Jeff, Donna, Chris, Laynie, Li. Goodbye.

Listing 6-11Joining a Stream of CharSequence Using a Collector

分组数据

出于报告目的对数据进行分组是很常见的。例如,您可能想知道按性别划分的平均收入,按性别划分的最年轻的人,等等。在前面的小节中,您使用了Collectors类的toMap()方法来获取可用于对地图中的数据进行分组的收集器。Collectors类的groupingBy()方法返回一个收集器,该收集器在将数据收集到一个Map中之前对它们进行分组。如果您使用过 SQL 语句,这类似于使用“group by”子句。groupingBy()方法是重载的,它有三个版本:

  • groupingBy(Function<? super T,? extends K> classifier)

  • groupingBy(Function<? super T,? extends K> classifier, super T,A,D> downstream)

  • groupingBy(Function<? super T,? extends K> classifier, Supplier<M> mapFactory, Collector<? super T,A,D> downstream)

我讨论第一个和第二个版本。第三个版本与第二个版本相同,除了它让您指定一个用作获取Map的工厂的Supplier。在前两个版本中,收集器负责为您创建Map

Note

groupingBy()方法返回一个非并发映射,当流被并行处理时,它会产生性能开销。它有一个名为groupingByConcurrent()的伴生方法,该方法返回一个并发收集器,应该在并行流处理中使用该收集器以获得更好的性能。

在最通用的版本中,groupingBy()方法有两个参数:

  • 一个分类器,它是一个在映射中生成关键字的函数

  • 对与每个键关联的值执行归约操作的收集器

第一个版本的groupingBy()方法返回一个收集器,该收集器将数据收集到一个Map<K, List<T»中,其中K是分类器函数的返回类型,T是输入流中元素的类型。请注意,map 中分组键的值是来自流的元素列表。以下代码片段按性别收集人员列表:

Map<Person.Gender, List<Person>> personsByGender =
    Person.persons()
          .stream()
          .collect(Collectors.groupingBy(
                  Person::getGender));
System.out.println(personsByGender);

{FEMALE=[(3, Donna, FEMALE, 1962-07-29, 8700.00),
  (5, Laynie, FEMALE, 2012-12-13, 0.00)],
MALE=[(1, Ken, MALE, 1970-05-04, 6000.00),
  (2, Jeff, MALE, 1970-07-15, 7100.00),
  (4, Chris, MALE, 1993-12-16, 1800.00),
  (6, Li, MALE, 2001-05-09, 2400.00)]}

假设您想获得一个按性别分组的姓名列表。您需要使用第二个版本的groupingBy()方法,它允许您对每个键的值执行归约操作。注意,第二个参数的类型是CollectorCollectors类包含许多方法,这些方法返回一个Collector,您将使用它作为第二个参数。

让我们尝试一个简单的例子,你想把人们按性别分组,并统计每组的人数。Collectors类的counting()方法返回一个Collector来计算流中元素的数量。以下代码片段实现了这一点:

Map<Person.Gender, Long> countByGender =
    Person.persons()
          .stream()
          .collect(Collectors.groupingBy(
              Person::getGender,
              Collectors.counting()));
System.out.println(countByGender);

{MALE=4, FEMALE=2}

让我们回到按性别列出一个人的名字的例子。您需要使用Collectors类的mapping()方法来获取一个收集器,该收集器会将一个键值中的人员列表映射到他们的姓名并加入他们。mapping()的签名方法如下:

mapping(Function<? super T,? extends U> mapper,
        Collector<? super U,A,R> downstream)

注意mapping()方法的第二个参数的类型。又是一个Collector。这就是处理分组数据变得复杂的地方。您需要在收集器中嵌套收集器。为了简化分组过程,您需要对数据进行分解。您已经按性别对人员进行了分组。地图中每个键的值是一个List<Person>。现在您想将List<Person>简化为一个String,其中包含一个逗号分隔的所有人的名字列表。您需要单独考虑这个操作,以避免混淆。您可以按如下方式完成这一减少:

  1. 使用函数将每个人与其姓名对应起来。这个函数可以简单到像Person::getName这样的方法引用。把这一步的输出想象成一个组中的人名流。

  2. 您希望如何处理第一步中生成的名称流?您可能希望将它们收集在一个StringListSet或其他数据结构中。在这种情况下,您想要加入人名,所以您使用从Collectors类的joining()方法返回的收集器。

以下代码片段显示了如何按性别对人名进行分组:

Map<Person.Gender, String> namesByGender =
    Person.persons()
          .stream()
          .collect(Collectors.groupingBy(Person::getGender,
             Collectors.mapping(
                 Person::getName,
                 Collectors.joining(", "))));
System.out.println(namesByGender);

{MALE=Ken, Jeff, Chris, Li, FEMALE=Donna, Laynie}

代码以逗号分隔的String形式收集一个组的名称。你能想出在一个List里收集名字的方法吗?这很容易做到。使用由Collectors类的toList()方法返回的收集器,如下所示:

Map<Person.Gender, List<String>> namesByGender =
    Person.persons()
          .stream()
          .collect(Collectors.groupingBy(Person::getGender,
              Collectors.mapping(
                  Person::getName,
                  Collectors.toList())));
System.out.println(namesByGender);

{FEMALE=[Donna, Laynie], MALE=[Ken, Jeff, Chris, Li]}

组可以嵌套。让我们创建一个按性别分组的报表。在每个性别组中,它根据出生月份创建另一个组,并列出该组中出生的人的姓名。这是一个非常简单的计算。你已经知道如何按性别分组。

您需要做的就是对键值执行另一个分组,这只是再次使用groupingBy()方法获得的另一个收集器。在这种情况下,映射中代表顶级分组(按性别)的键的值是一个Map。清单 6-12 包含了完成这项工作的完整代码。

注意使用静态导入从Collectors类导入静态方法,以获得更好的代码可读性。该程序假设每个人都有出生日期。

// NestedGroupings.java
package com.jdojo.streams;

import java.time.Month;
import java.util.Map;
import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.mapping;
import static java.util.stream.Collectors.joining;

public class NestedGroupings {
    public static void main(String[] args) {
        Map<Person.Gender, Map<Month, String>>
        personsByGenderAndDobMonth
            = Person.persons()
                 .stream()
                 .collect(groupingBy(Person::getGender,
                       groupingBy(p ->
                             p.getDob().getMonth(),
                       mapping(Person::getName,
                             joining(", ")))));
        System.out.println(personsByGenderAndDobMonth);
    }
}

{FEMALE={DECEMBER=Laynie, JULY=Donna},
MALE={DECEMBER=Chris, JULY=Jeff, MAY=Ken, Li}}

Listing 6-12Using Nested Groupings

注意,输出有两个基于性别的顶级组:男性和女性。对于每个性别组,都有基于该人出生月份的嵌套组。对于每个月组,您都有一个在该月出生的人的列表。例如,Ken 和李出生在五月,他们是男性,所以他们一起列在输出中。

作为本节的最后一个例子,让我们总结一下按性别分组的人的收入。清单 6-13 中的程序按性别计算收入的汇总统计。我使用静态导入来使用来自Collectors类的方法名,以保持代码更整洁。看看产量,你会发现女性的平均收入比男性多 25 美元。您可以将组嵌套在另一个组中。对组的嵌套层次没有限制。

// IncomeStatsByGender.java
package com.jdojo.streams;

import java.util.DoubleSummaryStatistics;
import java.util.Map;
import static
    java.util.stream.Collectors.groupingBy;
import static
    java.util.stream.Collectors.summarizingDouble;

public class IncomeStatsByGender {
    public static void main(String[] args) {
        Map<Person.Gender, DoubleSummaryStatistics>
        incomeStatsByGender =
            Person.persons()
              .stream()
              .collect(
                    groupingBy(Person::getGender,
                    summarizingDouble(Person::getIncome)));
        System.out.println(incomeStatsByGender);
    }
}

{MALE=DoubleSummaryStatistics{count=4, sum=17300.000000,
min=1800.000000,average=4325.000000, max=7100.000000},
FEMALE=DoubleSummaryStatistics{count=2, sum=8700.000000,
min=0.000000, average=4350.000000, max=8700.000000}}

Listing 6-13Summary Statistics of Income Grouped by Gender

数据分区

数据分区是数据分组的一种特殊情况。数据分组基于函数返回的键。从函数中返回的不同键的数量与组的数量一样多。分区将数据分为两组:对于一组,条件为真;对于另一个,同样的条件是错误的。使用Predicate指定分区条件。到目前为止,您可能已经猜到了Collectors类中返回收集器来执行分区的方法的名称。方法是partitioningBy()。它是重载的,有两个版本:

  • partitioningBy(Predicate<? super T> predicate)

  • partitioningBy(Predicate<? super T> predicate, Collector<? super T,A,D> downstream)

groupingBy()方法一样,partitioningBy()方法也在一个Map中收集数据,其键总是类型Boolean。注意,从收集器返回的Map总是包含两个条目:一个键值为 true,另一个键值为 false。

第一个版本的partitioningBy()方法返回一个基于指定谓词执行分区的收集器。键值存储在List中。如果谓词对一个元素的求值结果为true,那么该元素将被添加到值为true的键的列表中;否则,该值将被添加到具有false值的键的值列表中。以下代码片段根据人员是否为男性来划分人员:

Map<Boolean, List<Person>> partitionedByMaleGender =
    Person.persons()
          .stream()
          .collect(Collectors.partitioningBy(
              Person::isMale));
System.out.println(partionedByMaleGender);

{false=[(3, Donna, FEMALE, 1962-07-29, 8700.00),
  (5, Laynie, FEMALE, 2012-12-13, 0.00)],
true=[(1, Ken, MALE, 1970-05-04, 6000.00),
  (2, Jeff, MALE, 1970-07-15, 7100.00),
  (4, Chris, MALE, 1993-12-16, 1800.00),
  (6, Li, MALE, 2001-05-09, 2400.00)]}

该方法的第二个版本允许您指定另一个收集器,该收集器可以对每个键的值执行归约操作。在上一节中,当您使用groupingBy()方法对数据进行分组时,您已经看到了几个这样的例子。以下代码片段将人们分为男性和非男性,并以逗号分隔的字符串形式收集他们的姓名:

Map<Boolean,String> partionedByMaleGender =
    Person.persons()
          .stream()
          .collect(Collectors.partitioningBy(
              Person::isMale,
              Collectors.mapping(Person::getName,
                  Collectors.joining(", "))));
System.out.println(partionedByMaleGender);

{false=Donna, Laynie, true=Ken, Jeff, Chris, Li}

调整采集器结果

到目前为止,你已经看到了收集者自己在做伟大的工作:你指定你想要什么,收集者为你做所有的工作。还有一种收集器可以收集数据,并允许您在收集数据前后修改结果。您可以使收集器的结果适应不同的类型;您可以在元素分组后、收集前对其进行筛选;在对元素进行分组时,但在收集它们之前,映射元素。Collectors类中的以下静态方法返回这样的收集器:

  • <T,A,R,RR> Collector<T,A,RR> collectingAndThen(Collector<T,A,R> downstream, Function<R,RR> finisher)

  • <T,A,R> Collector<T,?,R> filtering(Predicate<? super T> predicate, Collector<? super T,A,R> downstream)

  • <T,U,A,R> Collector<T,?,R> flatMapping(Function<? super T,? extends Stream<? extends U» mapper, Collector<? super U,A,R> downstream)

Java 9 中的Collectors类增加了filtering()flatMapping()方法。

collectingAndThen()方法允许您在收集器收集完所有元素后修改收集器的结果。它的第一个参数是收集数据的收集器。第二个参数是整理器,它是一个函数。整理程序被传递一个结果,它可以自由地修改结果,包括它的类型。这种收集器的返回类型是整理器的返回类型。整理器的一个常见用途是返回所收集数据的不可修改的视图。下面是一个返回不可修改的人名列表的示例:

List<String> names = Person.persons()
    .stream()
    .map(Person::getName)
    .collect(Collectors.collectingAndThen(
        Collectors.toList(),
        result ->
            Collections.unmodifiableList(result)));
System.out.println(names);

[Ken, Jeff, Donna, Chris, Laynie, Li]

收集器收集可变列表中的名字,整理器将可变列表包装在不可修改的列表中。让我们再举一个使用整理器的例子。假设您想要打印一个日历,其中包含按出生日期月份排列的人名。您已经收集了按出生月份分组的姓名列表。您可能有一个月不包含任何生日。但是,您仍然希望打印月份名称,只需添加“None”这是第一次尝试:

Map<Month,String> dobCalendar = Person.persons()
    .stream()
    .collect(groupingBy(p -> p.getDob().getMonth(),
             mapping(Person::getName, joining(", "))));
dobCalendar.entrySet().forEach(System.out::println);

MAY=Ken, Li
DECEMBER=Chris, Laynie
JULY=Jeff, Donna

该日历有三期:

  • 它不是按月份排序的。

  • 它不包括所有月份。

  • 它是可修改的。从collect()方法返回的Map是可修改的。

您可以通过使用从collectingAndThen()方法返回的收集器并指定一个整理器来解决这三个问题。完成程序将在地图中添加缺失的月份,将地图转换为排序地图,最后,将地图包裹在不可修改的地图中。collect()方法返回从修整器返回的地图。清单 6-14 包含完整的代码。

// DobCalendar.java
package com.jdojo.streams;

import java.time.Month;
import java.util.Collections;
import java.util.Map;
import java.util.TreeMap;
import static
  java.util.stream.Collectors.collectingAndThen;
import static
  java.util.stream.Collectors.groupingBy;
import static
  java.util.stream.Collectors.joining;
import static
  java.util.stream.Collectors.mapping;

public class DobCalendar {
    public static void main(String[] args) {
        Map<Month, String> dobCalendar = Person.persons()
            .stream().collect(collectingAndThen(
                groupingBy(p -> p.getDob().getMonth(),
                mapping(Person::getName, joining(", "))),
                result -> {
                    // Add missing months
                    for (Month m : Month.values()) {
                       result.putIfAbsent(m, "None");
                    }
                    // Return a sorted, unmodifiable map
                    return Collections.unmodifiableMap(
                        new TreeMap<>(result));
                }));

        dobCalendar.entrySet().
            forEach(System.out::println);
    }
}

JANUARY=None
FEBRUARY=None
MARCH=None
APRIL=None
MAY=Ken, Li
JUNE=None
JULY=Jeff, Donna
AUGUST=None
SEPTEMBER=None
OCTOBER=None
NOVEMBER=None
DECEMBER=Chris, Laynie

Listing 6-14Adapting the Collector Result

filtering()方法允许您对元素进行分组,在每个组中应用过滤器,并收集过滤后的元素。以下代码片段向您展示了如何按性别对人们进行分组,并只收集那些收入超过8000.00的人:


Map<Person.Gender, List<Person>> makingOver8000 =
    Person.persons()
    .stream()
    .collect(groupingBy(
        Person::getGender,
        filtering(p ->
            p.getIncome() > 8000.00, toList())));
System.out.println(makingOver8000);

{MALE=[], FEMALE=[(3, Donna, FEMALE, 1962-07-29, 8700.00)]}

请注意男性组中的空列表。在收集器中,收集了两组:雄性和雌性。filtering( )方法过滤掉了男性组中的所有元素,所以你得到了一个空列表。如果您对原始流使用了filter()方法来过滤掉制造8000.00或更少的人,您将不会在输出中看到男性组,因为收集器根本不会看到男性组。

在“分组数据”一节中,您已经看到了由Collectors类的mapping()函数返回的收集器的用法,它允许您在收集器中累积元素之前对每个元素应用一个函数。flatMapping()方法让你在每个元素上应用一个平面映射函数。考虑表 6-2 中的人员名单。假设您想通过按性别对人进行分组以及列出每种性别类型的人使用的独特语言来汇总表中的数据。

表 6-2

一系列的人,他们的性别,以及他们说的语言

|

名字

|

性别

|

语言

|
| --- | --- | --- |
| 肯恩(男名) | 男性的 | 英语、法语 |
| 杰夫 | 男性的 | 西班牙语,吴 |
| (意)女士(置于女士名字前的尊称ˌ相当于 Madam) | 女性的 | 英语、法语 |
| 克莉丝 | 男性的 | 吴、老 |
| 莱妮 | 女性的 | 英语、德语 |
| 里 | 男性的 | 英语 |

对于这个例子,我使用一个Map.Entry<String,Set<String»实例来表示这个表中的一行。我在表格的每一行只使用性别和口语,忽略人名。清单 6-15 包含完整的代码。

// FlatMappingTest.java
package com.jdojo.streams;

import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import static java.util.Map.entry;
import java.util.Set;
import static java.util.stream.Collectors.flatMapping;
import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.toSet;

public class FlatMappingTest {
    public static void main(String[] args) {
        // Represent the gender and the list of spoken
        // languages
        List<Entry<String, Set<String>>> list = List.of(
            entry("Male", Set.of("English", "French")),
            entry("Male", Set.of("Spanish", "Wu")),
            entry("Female", Set.of("English", "French")),
            entry("Male", Set.of("Wu", "Lao")),
            entry("Female", Set.of("English", "German")),
            entry("Male", Set.of("English")));
        Map<String, Set<String>> langByGender =
            list.stream()
            .collect(groupingBy(Entry::getKey,
                flatMapping(e ->
                    e.getValue().stream(), toSet())));
        System.out.println(langByGender);
    }
}

{Female=[English, French, German],
Male=[English, French, Spanish, Lao, Wu]}

Listing 6-15Applying a Flat Mapping Operation After Grouping

Entry::getKey方法引用用于按性别对列表元素进行分组。第一个参数将列表中的每个条目映射到一个Stream<String>,其中包含该元素使用的语言。flatMapping()方法展平产生的流并收集结果,这些结果是Set<String>中口语的名称,给你一个按性别排列的口语的唯一列表。

在流中查找和匹配

Streams API 支持流元素上不同类型的查找和匹配操作。例如,您可以检查流中是否有任何元素匹配谓词,是否所有元素都匹配谓词,等等。Stream界面中的以下方法用于执行查找和匹配操作:

  • boolean allMatch(Predicate<? super T> predicate)

  • boolean anyMatch(Predicate<? super T> predicate)

  • boolean noneMatch(Predicate<? super T> predicate)

  • Optional<T> findAny()

  • Optional<T> findFirst()

诸如IntStreamLongStreamDoubleStream之类的原始类型流也包含相同的方法,这些方法使用一个谓词和一个可选的原始类型谓词。例如,IntStream中的allMatch()方法以一个IntPredicate作为参数,findAny()方法返回一个OptionalInt

所有查找和匹配操作都是终端操作。它们也是短路操作。短路操作可能不必处理整个流来返回结果。例如,allMatch()方法检查流中所有元素的指定谓词是否都是true。如果一个元素的谓词计算结果为false,那么这个方法返回false就足够了。一旦谓词对一个元素求值为 false,它将停止对元素的进一步处理(短路),并将结果作为false返回。同样的论点适用于所有其他方法。注意,findAny()findFirst()方法的返回类型是Optional<T>,因为如果流是空的,这些方法可能没有结果。

清单 6-16 中的程序展示了如何在流上执行查找和匹配操作。该程序使用顺序流,因为流的大小非常小。如果必须在大型流上执行匹配,请考虑使用并行流。在这种情况下,任何线程都可以找到匹配或找不到匹配来结束匹配操作。

// FindAndMatch.java
package com.jdojo.streams;
import java.util.List;
import java.util.Optional;
public class FindAndMatch {
public static void main(String[] args) {
        // Get the list of persons
        List<Person> persons = Person.persons();
        // Check if all persons are males
        boolean allMales = persons.stream()
            .allMatch(Person::isMale);
        System.out.println("All males: " + allMales);
        // Check if any person was born in 1970
        boolean anyoneBornIn1970 =  persons.stream()
            .anyMatch(p -> p.getDob().getYear() == 1970);
        System.out.println("Anyone born in 1970: " +
            anyoneBornIn1970);
        // Check if any person was born in 1955
        boolean anyoneBornIn1955 = persons.stream()
            .anyMatch(p -> p.getDob().getYear() == 1955);
        System.out.println("Anyone born in 1955: " +
            anyoneBornIn1955);
        // Find any male
        Optional<Person> anyMale = persons.stream()
            .filter(Person::isMale)
            .findAny();
        if (anyMale.isPresent()) {
            System.out.println("Any male: " +
                anyMale.get());
        } else {
            System.out.println("No male found.");
        }
        // Find the first male
        Optional<Person> firstMale = persons.stream()
            .filter(Person::isMale)
            .findFirst();
        if (firstMale.isPresent()) {
            System.out.println("First male: " +
                anyMale.get());
        } else {
            System.out.println("No male found.");
        }
    }
}

All males: false
Anyone born in 1970: true
Anyone born in 1955: false
Any male: (1, Ken, MALE, 1970-05-04, 6000.00)
First male: (1, Ken, MALE, 1970-05-04, 6000.00)

Listing 6-16Performing Find and Match Operations on Streams

平行流

流可以是顺序的,也可以是并行的。使用一个线程串行处理顺序流上的操作。使用多线程并行处理并行流上的操作。您不需要采取额外的步骤来处理流,因为它们是顺序的或并行的。您所需要做的就是调用适当的方法来产生顺序流或并行流。其他一切都由 Streams API 负责。这就是为什么我在本章开始时指出,你可以“几乎”免费获得流处理中的并行性。

默认情况下,Streams API 中的大多数方法都会产生顺序流。为了从一个集合中产生一个并行流,比如一个List或者一个Set,你需要调用Collection接口的parallelStream()方法。在流上使用parallel()方法将顺序流转换成并行流。相反,在流上使用sequential()方法将并行流转换为顺序流。以下代码片段显示了流管道的串行处理,因为流是连续的:

String names = Person
    .persons()              // The data source
    .stream()               // Produces a sequential stream
    .filter(Person::isMale) // Processed in serial
    .map(Person::getName)   // Processed in serial
    .collect(Collectors.
        joining(", "));     // Processed in serial

以下代码片段显示了流管道的并行处理,因为流是并行的:

String names = Person
    .persons()              // The data source
    .parallelStream()       // Produces a parallel stream
    .filter(Person::isMale) // Processed in parallel
    .map(Person::getName)   // Processed in parallel
    .collect(Collectors.
        joining(", "));     // Processed in parallel

以下代码片段显示了混合模式下的流管道处理,因为管道中的操作会产生串行和并行流:

String names = Person
    .persons()              // The data source
    .stream()               // Produces a sequential stream
    .filter(Person::isMale) // Processed in serial
    .parallel()             // Produces a parallel stream
    .map(Person::getName)   // Processed in parallel
    .collect(Collectors.
        joining(", "));     // Processed in parallel

串行流之后的操作是串行执行的,并行流之后的操作是并行执行的。免费处理流时,您可以获得并行性。那么什么时候在流处理中使用并行性呢?每当你使用并行时,你得到它的好处了吗?答案是否定的。在使用并行流之前,必须满足一些条件。有时,使用并行流可能会导致更差的性能。

Streams API 使用 fork/join 框架来处理并行流。fork/join 框架使用多线程。它将流元素分成块;每个线程处理一大块元素以产生一个部分结果,这些部分结果被组合起来以给出结果。启动多个线程、将数据分成块以及合并部分结果会占用 CPU 时间。从完成任务的总时间来看,这种开销是合理的。例如,六个人的流并行处理比串行处理需要更长的时间。为如此少量的工作设置线程和协调它们的开销是不值得的。

您已经看到了使用Iterator来遍历集合的元素。Streams API 使用一个Spliterator(一个可拆分的迭代器)来遍历流的元素。SpliteratorIterator的概括。迭代器提供了对数据元素的顺序访问。一个Spliterator提供了数据元素的顺序访问和分解。当您创建一个Spliterator时,它知道它将处理的数据块。您可以将一个Spliterator一分为二:每个都将获得自己的数据块进行处理。Spliteratorjava.util包中的一个接口。它主要用于将流元素分割成块,由多个线程处理。作为 Streams API 的用户,您将永远不必直接使用Spliterator。流的数据源提供了一个Spliterator。如果Spliterator知道流的大小,流的并行处理会更快。流可以基于具有固定大小或未知大小的数据源。如果不能确定流的大小,就不可能将流元素分割成块。在这种情况下,即使您可以使用并行流,也可能无法获得并行的好处。

并行处理中的另一个考虑因素是元素的排序。如果元素是有序的,线程需要在处理结束时保持有序。如果排序对您来说不重要,您可以使用unordered()方法将有序流转换成无序流。

Spliterators将数据元素分成块。重要的是,流的数据源在流处理期间不会改变;否则,结果未定义。例如,如果您的流使用列表/集合作为数据源,则在处理流时,不要从列表/集合中添加或移除元素。

流处理基于函数式编程,在处理过程中不会修改数据元素。它创建新的数据元素,而不是修改它们。同样的规则也适用于流处理,尤其是并行处理时。流管道中的操作被指定为 lambda 表达式,它不应该修改正在处理的元素的可变状态。

让我们举一个例子,在一个大范围的自然数中计算质数,比如从2214748364。数字214748364Integer.MAX_VALUE的十分之一。以下代码片段执行串行计数:

// Process the stream in serial
long count = IntStream.rangeClosed(2, Integer.MAX_VALUE/10)
                      .filter(PrimeUtil::isPrime)
                      .count();

代码花了758秒完成。让我们尝试将流转换为并行流,如下所示:

// Process the stream in parallel
long count = IntStream.rangeClosed(2, Integer.MAX_VALUE/10)
                      .parallel()
                      .filter(PrimeUtil::isPrime)
                      .count();

这一次,代码只花了181秒,大约是串行处理时的24%秒。这是一个重大的收获。这两段代码都运行在一台配备八核处理器的机器上。在您的机器上完成代码可能需要不同的时间。

摘要

流是支持顺序和并行聚合操作的数据元素序列。Java 中的集合侧重于数据存储和数据访问,而流侧重于数据计算。流没有存储。他们从数据源获取数据,数据源通常是一个集合。但是,流可以从其他来源获取数据,如文件 I/O 通道、函数等。流也可以基于能够生成无限数据元素的数据源。

流通过形成管道的操作连接起来。流支持两种类型的操作:中间操作和终端操作。流上的中间操作产生另一个流,该流可以作为另一个中间操作的输入流。终端操作产生单个值形式的结果。在对流调用终端操作后,不能重用流。

对流的一些操作被称为短路操作。短路操作不一定要处理流中的所有数据。例如,findAny是一个短路操作,它在流中查找指定谓词为true的任何元素。一旦找到一个元素,操作就丢弃流中剩余的元素。

流天生懒惰。他们按需处理数据。在流上调用中间操作时,不会处理数据。终端操作的调用处理流数据。

流管道可以串行或并行执行。默认情况下,流是串行的。您可以通过调用流的parallel()方法将串行流转换为并行流。您可以通过调用流的sequential()方法将并行流转换为串行流。

Streams API 支持函数式编程中支持的大部分操作,如filtermapforEachreduceallMatchanyMatchfindAnyfindFirst等。流包含一个用于调试目的的peek()方法,它允许您对通过流的每个元素进行操作。Streams API 提供了用于收集集合中的数据的收集器,例如maplistset等。Collectors类是一个实用程序类,它提供了收集器的几种实现。使用 streams 的collect()方法和提供的收集器,可以很容易地执行流数据的映射、分组和分区。

并行流利用多核处理器。他们使用 fork/join 框架来并行处理流的元素。

练习

练习 1

什么是流和流上的聚合操作?

练习 2

流和集合有什么不同?

运动 3

填空:

  1. 集合有存储,而流有 ______ 存储。

  2. 集合支持外部迭代,而流支持 _________ 迭代。

  3. 集合支持命令式编程,而流支持 _________ 编程。

  4. 集合支持有限数量的元素,而流支持 _________ 数量的元素。

  5. 流支持对其元素的顺序和 _________ 处理。

  6. 在对流调用 _________ 操作之前,流不会开始从其数据源提取元素。

  7. 一旦在一个流上调用了一个终端操作,这个流就 _________ 被重用。

演习 4

描述流的中间操作和终端操作的区别。

锻炼 5

创建一个从1030的所有整数的Stream<Integer>,并计算列表中所有整数的和。

锻炼 6

完成以下代码片段,该代码片段使用流计算姓名列表中的字符总数:

List<String> names = List.of(
    "Mo", "Jeff", "Li", "Dola");
int sum = names.stream()
    ./* your code goes here */;
System.out.println("Total characters: " + sum);

预期产出如下:

Total characters: 12

锻炼 7

完成下面的代码片段,它创建了两个空的Stream<String>s

你应该使用Stream接口的不同方法来完成代码:

Stream<String> noNames1 = Stream.
    /* Your code goes here */;
Stream<String> noNames2 = Stream.
    /* Your code goes here */;

运动 8

接口的什么方法用于将流中的元素数量限制到指定的大小?

演习 9

接口的什么方法用于跳过流中指定数量的元素?

运动 10

描述以下代码片段产生的流的特征:

Stream<Integer> stream = Stream.
    generate(() -> 1969);

演习 11

Optional<T>类的实例有什么用?

运动 12

完成下面的代码片段,它应该打印人员的姓名以及列表中非空选项中姓名的字符数:

List<Optional<String>> names = List.of(
    Optional.of("Ken"),
    Optional.empty(),
    Optional.of("Li"),
    Optional.empty(),
    Optional.of("Toto"));
names.stream()
     .flatMap(/* Your code goes here */)
     .forEach(/* Your code goes here */);

预期产出如下:

Ken: 3
Li: 2
Toto: 4

运动 13

Stream接口中的peek()方法有什么用?

运动 14

Stream接口中的map()flatMap()方法有什么用?

运动 15

比较流中的筛选和映射操作的输入和输出流中的元素类型和元素数量。

演习 16

什么是流上的归约操作?说出三种常用的流归约运算。

演习 17

使用并行流和Stream接口的reduce()方法编写逻辑来计算以下数组中所有整数的和:

int[] nums = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

演习 18

完成以下代码片段以打印地图中的唯一非空值:

Map<Integer, String> map = new HashMap<>();
map.put(1, "One");
map.put(2, "One");
map.put(3, null);
map.put(4, "Two");
map.entrySet()
   .stream()
   .flatMap(/* Your code goes here */)
   ./* Your code goes here */
   .forEach(System.out::println);

预期产出如下:

One
Two

演习 19

完成以下代码片段中缺少的一段代码,这段代码用于计算整数列表中偶数和奇数整数的个数:

List<Integer> list = List.of(
    10, 19, 20, 40, 45, 50);
Map<String,Long> oddEvenCounts = list.stream()
        .map(/* Your code goes here */)
        .collect(/* Your code goes here */);
System.out.println(oddEvenCounts);

预期产出如下:

{Even=4, Odd=2}

运动 20

下面的代码片段应该打印列表中奇数整数的排序列表,用冒号分隔。完成代码中缺失的部分:

List<Integer> list = List.of(5, 1, 2, 7, 3, 4, 8);
String str = list.stream()
             ./* Multiple method calls go here */;
System.out.println(str);

预期产出如下:

1:3:5:7

七、实现服务

在本章中,您将学习:

  • 什么是服务、服务接口和服务提供者

  • 如何用 Java 实现服务

  • 如何使用 Java 接口作为服务实现

  • 如何使用ServiceLoader类加载服务提供者

  • 如何在模块声明中使用uses语句来指定当前模块使用ServiceLoader类发现和加载的服务接口

  • 如何使用provides语句指定当前模块提供的服务提供者

  • 如何根据服务提供者的类型发现、过滤和选择服务提供者,而不需要实例化它们

什么是服务?

由应用程序(或库)提供的特定功能被称为服务。例如,你可以让不同的库提供一个素数服务,它可以检查一个数是否是素数,并在给定的数之后生成下一个素数。为服务提供实现的应用程序和库被称为服务提供者。使用服务的应用程序被称为服务消费者客户端。客户如何使用服务?客户认识所有的服务提供商吗?客户在不知道任何服务提供商的情况下获得服务吗?我在这一章回答这些问题。

Java 的早期版本(SE 6 以上)已经提供了一种机制,允许服务提供者和服务消费者之间的松散耦合。也就是说,服务消费者可以在不知道服务提供商的情况下使用服务提供商提供的服务。有了模块系统,这种架构模式变得更加标准化,并且更直接地应用于 Java 项目。

在 Java 中,服务是由一组接口和类定义的。服务包含一个接口或者一个抽象类,它定义了服务所提供的功能,它被称为服务提供者接口或者简称为服务接口。注意,“服务提供者接口”和“服务接口”中的术语“接口”不是指 Java 中的接口构造。服务接口可以是 Java 接口或抽象类。可以使用具体的类作为服务接口,但不推荐这样做。有时,服务接口也被称为服务类型——用于标识服务的类型。

一个服务的具体实现被称为一个服务提供者。一个服务接口可以有多个服务提供者。通常,服务提供者由几个接口和类组成,为服务接口提供实现。

JDK 包含一个java.util.ServiceLoader<S>类,其唯一目的是在运行时为类型S的服务接口发现和加载服务提供者。ServiceLoader类允许服务提供者与服务消费者分离。服务消费者只知道服务接口;ServiceLoader类使实现服务接口的服务提供者的实例对消费者可用。图 7-1 显示了一个服务、服务提供者和服务消费者的安排的图示视图。

img/323070_3_En_7_Fig1_HTML.jpg

图 7-1

服务、服务提供者和服务消费者的安排

通常,服务将使用ServiceLoader类来加载所有服务提供者,并使它们对服务消费者(或客户端)可用。这种体系结构允许一种插件机制,可以在不影响服务和服务消费者的情况下添加或删除服务提供者。服务消费者只知道服务接口。他们不知道服务接口的任何具体实现(服务提供者)。

Note

我建议阅读java.util.ServiceLoader类的文档,以全面理解服务加载工具。

在本章中,我使用了一个服务和三个服务提供者。表 7-1 中列出了它们的模块、类/接口名称和简要描述。

表 7-1

示例章节中使用的模块、类和接口

|

组件

|

类别/接口

|

描述

|
| --- | --- | --- |
| jdojo.prime | PrimeChecker | 它充当服务、服务接口和服务提供者。它为服务接口提供了默认实现。 |
| jdojo.prime.faster | FasterPrimeChecker | 服务提供商。 |
| jdojo.prime.probable | ProbablePrimeChecker | 服务提供商。 |
| jdojo.prime.client | Main | 服务消费者。 |

图 7-2 显示了被安排为服务、服务提供者和服务消费者的类/接口,可以与图 7-1 进行比较。

img/323070_3_En_7_Fig2_HTML.jpg

图 7-2

一个服务、三个服务提供者和一个服务消费者的安排

发现服务

为了使用一个服务,需要发现并加载它的提供者。ServiceLoader类完成发现和加载服务提供者的工作。发现并加载服务提供者的模块必须在其声明中包含一个uses语句,其语法如下:

uses <service-interface>;

这里,<service-interface>是服务接口的名称,是 Java 接口名、类名或者注释类型名。如果一个模块使用ServiceLoader<S>类为名为S的服务接口加载服务提供者的实例,那么模块声明必须包含以下语句:

uses S;

在我看来,声明名uses,似乎是用词不当。乍一看,似乎当前模块将使用指定的服务。然而,事实并非如此。服务由客户端使用,而不是由定义服务的模块使用。更直观的语句名称应该是discoversloads。如果你把它的定义理解为:拥有uses语句的模块使用ServiceLoader来加载这个服务接口的服务提供者,你就能正确理解它的意思。您不需要在客户端模块中使用uses语句,除非您的客户端模块加载服务的服务提供者。客户端模块加载服务是不常见的。

一个模块可以发现并加载多个服务。下面的模块声明使用了两个uses语句,表示它将发现并加载由com.jdojo.PrimeCheckercom.jdojo.CsvParser接口标识的服务:

module jdojo.loader {
    uses com.jdojo.PrimeChecker;
    uses com.jdojo.CsvParser:
    // Other module statements go here
}

一个模块声明允许import语句。为了提高可读性,可以将该模块声明重写如下:

// Import types from other packages
import com.jdojo.PrimeChecker;
import com.jdojo.CsvParser:
module jdojo.loader {
    uses PrimeChecker;
    uses CsvParser:
    // Other module statements go here
}

uses语句中指定的服务接口可以在当前模块或另一个模块中声明。如果在另一个模块中声明,则服务接口必须可被当前模块中的代码访问;否则,会发生编译时错误。例如,前面声明中的uses语句中使用的com.jdojo.CsvParser服务接口可以在jdojo.loader模块或另一个模块中声明,比如说jdojo.csvUtil。在后一种情况下,com.jdojo.CsvParser接口必须可被jdojo.loader模块访问。

服务提供者发现发生在运行时。发现服务提供者的模块通常不会(也不需要)声明对服务提供者模块的编译时依赖,因为不可能预先知道所有的提供者模块。service discoverer 模块不声明对服务提供者模块的依赖性的另一个原因是为了保持服务提供者和服务消费者的分离。

提供服务实现

为服务接口提供实现的模块必须包含一个provides语句。如果一个模块包含一个服务提供者,但是在其声明中没有包含一个provides语句,这个服务提供者将不会通过ServiceLoader类被加载。也就是说,模块声明中的provides语句是告诉ServiceLoader类“嘿!我为服务提供了一个实现。无论何时你需要服务,你都可以把我当作服务提供商。”provides语句的语法如下:

provides <service-interface> with
    <service-implementation-name>;

(你当然可以用一行来写这个。)

这里,provides子句指定服务接口的名称,with子句指定实现服务提供者接口的类的名称。服务提供者可以指定一个接口作为服务接口的实现。这听起来可能不正确,但却是事实。我在本章中提供了一个例子,其中一个接口作为服务提供者的实现类型。下面的模块声明包含两个provides语句:

module com.jdojo.provider {
    provides com.jdojo.PrimeChecker with
        com.jdojo.impl.PrimeCheckerFactory;
    provides com.jdojo.CsvParser with
        com.jdojo.impl.CsvFastParser;
    // Other module statements go here
}

第一个provides语句声明com.jdojo.impl.PrimeCheckerFactory是名为com.jdojo.PrimeChecker的服务接口的一个可能实现。第二个provides语句声明com.jdojo.impl.CsvFastParser是名为com.jdojo.CsvParser的服务接口的一个可能实现。实现PrimeCheckerFactoryCsvParser通常是类,但是也可以使用接口。

一个模块可以包含任意组合的usesprovides语句——同一个模块可以为一个服务提供实现并发现同一个服务;它只能为一个或多个服务提供实现,或者它可以为一个服务提供实现并发现另一种类型的服务。以下模块声明发现并提供了同一服务的实现:

module com.jdojo.parser {
    uses com.jdojo.XmlParser;
    provides com.jdojo.XmlParser with
        com.jdojo.xml.impl.XmlParserFactory;
    // Other module statements go here
}

Note

provides 语句的 with 子句中指定的服务实现类/接口必须在当前模块中声明。否则,会发生编译时错误。

ServiceLoader类创建服务实现的实例。当服务实现是一个接口时,它调用接口的provider()静态方法来获取提供者的实例。服务实现(类或接口)必须遵循以下规则:

  • 如果服务实现隐式或显式地声明了一个没有形参的公共构造函数,这个构造函数就被称为提供者构造函数

  • 如果服务实现包含一个名为provider的没有形参的公共静态方法,这个方法被称为提供者方法

  • 提供者方法的返回类型必须是服务接口类型或其子类型。

  • 如果服务实现不包含提供者方法,则服务实现的类型必须是具有提供者构造函数的类,并且该类必须是服务接口类型或其子类型。

当请求ServiceLoader类发现并加载服务提供者时,它检查服务实现是否包含提供者方法。如果找到了提供者方法,该方法的返回值就是由ServiceLoader类返回的服务。如果没有找到提供者方法,它将使用提供者构造函数实例化服务实现。如果服务实现既不包含提供者方法也不包含提供者构造函数,则会发生编译时错误。

有了这些规则,就有可能使用 Java 接口作为服务实现。该接口应该有一个名为provider的公共静态方法,该方法返回服务接口类型的实例。

下面几节将向您介绍使用模块实现服务的步骤。最后一节解释了如何让相同的服务在非模块化环境中工作。

定义服务接口

在本节中,您将开发一个名为 prime checker 的服务。我保持服务简单,这样您就可以专注于使用 Java 中的服务提供者机制,而不是编写复杂的代码来实现服务功能。这项服务的要求如下:

  • 该服务应该提供一个 API 来检查一个数是否是质数。

  • 客户应该能够知道可用服务提供商的名称。服务提供者的名称将是服务提供者类或接口的完全限定名称。

  • 服务应该提供服务接口的默认实现。

  • 客户端应该能够在不指定服务提供者名称的情况下检索服务实例。在这种情况下,将返回默认的服务提供者。

  • 客户端应该能够通过指定服务提供者完全限定的名称来检索服务实例。如果指定名称的服务提供者不存在,则返回null

让我们来设计服务。服务提供的功能将由一个名为PrimeChecker的接口来表示。它包含一种方法:

public interface PrimeChecker {
    boolean isPrime(long n);
}

如果指定的参数是质数,isPrime()方法返回true,否则返回false。所有服务提供商都将实现PrimeChecker接口。PrimeChecker接口是我们的服务接口(或服务类型)。

获取服务提供者实例

服务需要向客户机提供 API 来检索服务提供者的实例。该服务需要发现并加载所有服务提供者,然后才能将它们提供给客户端。使用ServiceLoader类加载服务提供者。该类没有公共构造函数。您可以使用它的一个load()方法来获取它的实例。您需要指定服务接口对load()方法的类引用。ServiceLoader类包含一个iterator()方法,为这个ServiceLoader加载的特定服务接口的所有服务提供者返回一个IteratorServiceLoader类还实现了Iterable接口,因此您也可以使用for-each语句迭代所有的服务提供者。下面的代码片段向您展示了如何加载和遍历PrimeChecker的所有服务提供者实例:

// Load the service providers for PrimeChecker
ServiceLoader<PrimeChecker> loader =
    ServiceLoader.load(PrimeChecker.class);
// Iterate through all service provider instances
Iterator<PrimeChecker> iterator = loader.iterator();
if (iterator.hasNext()) {
   PrimeChecker checker = iterator.next();
   // Use the prime checker here...
}

以下代码片段向您展示了如何在for-each语句中使用ServiceLoader实例来迭代所有服务提供者实例:

ServiceLoader<PrimeChecker> loader =
    ServiceLoader.load(PrimeChecker.class);
for (PrimeChecker checker : loader) {
    // checker is your service provider instance
}

有时,您会希望根据类名来选择提供程序。例如,您可能希望只选择那些完全限定类名以com.jdojo开头的主要服务提供者。实现这一点的典型逻辑是使用由ServiceLoader类的iterator()方法返回的迭代器。然而,这是昂贵的。迭代器在返回之前实例化一个提供者。JDK9 在ServiceLoader类中添加了一个新的stream()方法:

public Stream<ServiceLoader.Provider<S>> stream()

该方法返回ServiceProvider.Provider<S>接口的实例流,该接口在ServiceLoader类中声明为嵌套接口,如下所示:

public static interface Provider<S> extends Supplier<S> {
    // Returns a Class reference of the class of the
    // service provider
    Class<? extends S> type();
    @Override
    S get();
}

ServiceLoader.Provider接口的一个实例代表一个服务提供者。它的type()方法返回服务实现的Class对象。get()方法返回服务提供者的一个实例。

ServiceLoader.Provider界面有什么帮助?当您使用stream()方法时,流中的每个元素都是ServiceLoader.Provider类型的。您可以根据提供程序的类名或类型过滤流,这不会实例化提供程序。您可以在过滤器中使用type()方法。当您找到所需的提供者时,调用get()方法实例化该提供者。这样,当你知道你需要一个提供者时,你就实例化了它,而不是当你遍历所有的提供者时。下面是一个使用ServiceLoader类的stream()方法的例子。它给出了类名以com.jdojo开头的所有主要服务提供者的列表:

static List<PrimeChecker> startsWith(String prefix) {
    return ServiceLoader.load(PrimeChecker.class)
            .stream()
            .filter((Provider p) ->
                p.type().getName().startsWith(prefix))
            .map(Provider::get)
            .collect(Collectors.toList());
}

您的 prime checker 服务应该让客户机使用服务提供者类或接口名来找到服务提供者。您可以使用ServiceLoader类的stream()方法提供一个newInstance(String providerName)方法,如下所示:

static PrimeChecker newInstance(String providerName) {
    // Try to find the first service provider with the
    // specified providerName
    Optional<Provider<PrimeChecker>> optional =
        ServiceLoader.load(PrimeChecker.class)
            .stream()
            .filter((Provider p) ->
                p.type().getName().equals(providerName))
            .findFirst();
    PrimeChecker checker = null;
    // Instantiate the provider if we found one
    if (optional.isPresent()) {
        Provider<PrimeChecker> provider = optional.get();
        checker = provider.get();
    }
    return checker;
}

使用ServiceLoader类的Iteratorstream()方法来寻找服务提供者有很大的不同。Iterator为您提供了服务提供者的实例,您可以使用它来确定实际服务提供者实现类的细节。服务提供者可以使用提供者构造函数或提供者方法来提供其实例。stream()方法不创建服务提供者实例。相反,它查看提供者构造函数和提供者方法,为您提供服务提供者实现的类型。如果使用提供者构造函数,stream()方法知道服务实现的实际类名。如果使用 provider 方法,stream()方法不会(也不能)查看 provider 方法内部来查看实际的实现类类型。在这种情况下,它只查看提供者方法的返回类型,它的type()方法返回该返回类型的Class引用。考虑下面的PrimeChecker服务类型的提供者方法实现:

// FasterPrimeChecker.java
package com.jdojo.prime.faster;
import com.jdojo.prime.PrimeChecker;
public class FasterPrimeChecker implements PrimeChecker {
    // No provider constructor
    private FasterPrimeChecker() {
        // No code
    }
    // Define a provider method
    public static PrimeChecker provider() {
        return new FasterPrimeChecker();
    }
    @Override
    public boolean isPrime(long n) {
        // More code goes here
    }
}

假设FasterPrimeChecker类作为服务提供者可用。当您使用ServiceLoader类的stream()方法时,您将获得这个服务提供者的ServiceLoader.Provider元素,其type()方法将返回com.jdojo.prime.PrimeChecker接口的Class引用,这是provider()方法的返回类型。当您调用ServiceLoader.Provider实例的get()方法时,它将调用provider()方法并返回从provider()方法返回的FasterPrimeChecker类对象的引用。如果您尝试编写以下代码来查找FasterPrimeChecker提供者,将会失败:

String providerName =
    "com.jdojo.prime.faster.FasterPrimeChecker";
Optional<Provider<PrimeChecker>> optional =
    ServiceLoader.load(PrimeChecker.class)
        .stream()
        .filter((Provider p) ->
            p.type().getName().equals(providerName))
        .findFirst();

如果您想使用ServiceLoader类的stream()方法通过类名找到这个服务提供者,您可以更改provider()方法的返回类型,如下所示:

// FasterPrimeChecker.java
package com.jdojo.prime.faster;
import com.jdojo.prime.PrimeChecker;
public class FasterPrimeChecker implements PrimeChecker {
    // No provider constructor
    private FasterPrimeChecker() {
        // No code
    }
    // Define a provider method
    public static FasterPrimeChecker provider() {
        return new FasterPrimeChecker();
    }
    @Override
    public boolean isPrime(long n) {
        // More code goes here
    }
}

定义服务

您可以创建一个类来为您的服务提供发现、加载和检索功能。但是因为可以向接口添加静态方法,所以也可以将接口用于相同的目的。让我们向服务接口添加两个静态方法:

public interface PrimeChecker {
    // Part of the service interface
    boolean isPrime(long n);
    // Part of the service
    static PrimeChecker newInstance() { /*...*/ };
    static PrimeChecker newInstance(String providerName) {
        /*...*/ };
    static List<PrimeChecker> providers() { /*...*/ };
    static List<String> providerNames(/*...*/);
 }

newInstance()方法将返回默认服务提供者PrimeChecker的实例。newInstance(String providerName)方法将返回具有指定提供者名称的服务提供者的实例。providers()方法将返回所有提供者实例,而providerNames()方法将返回所有提供者名称的列表。

请注意,您的PrimeChecker接口将服务于两个目的:

  • 它充当一个服务接口,而isPrime()方法是该服务接口中唯一的方法。客户端将使用PrimeChecker接口作为服务类型。

  • 它通过两个版本的newInstance()方法、providers()方法和providerNames()方法作为服务。

此时,您可以选择拥有一个单独的服务类,比如说一个包含newInstance()providers()providerNames()方法的PrimeService类——只留下PrimeChecker接口中的isPrime()方法。如果您决定这样做,客户端将使用PrimeService类来获得服务提供者。

Note

向接口添加方法在某种程度上违背了接口的原始概念。严格地说,描述方法的接口和实现这些方法并包含实际可执行代码的类之间应该有明显的区别。如果您希望将这样的接口方法与接口声明本身中给出的代码一起使用,或者如果您希望提供允许实例化服务的特殊基础结构类,这取决于您。

清单 7-1 包含了PrimeChecker接口的完整代码。

// PrimeChecker.java
package com.jdojo.prime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.ServiceLoader;
import java.util.ServiceLoader.Provider;
import java.util.stream.Collectors;
public interface PrimeChecker {
    boolean isPrime(long n);
    static PrimeChecker newInstance() {
        // Return the default service provider
        String defaultSP =
            "com.jdojo.prime.impl.GenericPrimeChecker";
        return newInstance(defaultSP);
    }
    static PrimeChecker newInstance(String providerName) {
        Optional<Provider<PrimeChecker>> optional =
            ServiceLoader.load(PrimeChecker.class)
                .stream()
                .filter((Provider p) ->
                    p.type().getName().
                        equals(providerName))
                .findFirst();
        PrimeChecker checker = null;
        if (optional.isPresent()) {
            Provider<PrimeChecker> provider =
                optional.get();
            checker = provider.get();
        }
        return checker;
    }
    static List<PrimeChecker> providers() {
        List<PrimeChecker> providers = new ArrayList<>();
        ServiceLoader<PrimeChecker> loader =
            ServiceLoader.load(PrimeChecker.class);
        for (PrimeChecker checker : loader) {
            providers.add(checker);
        }
        return providers;
    }
    static List<String> providerNames() {
        List<String> providers =
            ServiceLoader.load(PrimeChecker.class)
                .stream()
                .map((Provider p) -> p.type().getName())
                .collect(Collectors.toList());
        return providers;
    }
}

Listing 7-1A Service Provider Interface Named PrimeChecker

jdojo.prime模块的声明如清单 7-2 所示。它导出com.jdojo.prime包,因为其他服务提供者模块需要使用PrimeChecker接口。

// module-info.java
module jdojo.prime {
    exports com.jdojo.prime;
    uses com.jdojo.prime.PrimeChecker;
}

Listing 7-2The Declaration of the jdojo.prime Module

您需要使用带有PrimeChecker接口的完全限定名的uses语句,因为该模块中的代码将使用ServiceLoader类来加载该接口的服务提供者。您还没有完成jdojo.prime模块的声明。在下一节中,您将向该模块添加一个默认的服务提供者。

定义服务提供商

在接下来的部分中,您将为PrimeChecker服务接口创建三个服务提供者。第一个服务提供商将是您的默认 prime checker 服务提供商。你将把它和jdojo.prime模块打包在一起。您将调用第二个服务提供商作为更快的素数检查器 提供商。您将调用第三个服务提供者作为可能的主要检查者提供者。稍后,您将创建一个客户端来测试服务。您可以选择使用这些服务提供商之一或全部。

这些服务提供商将实现算法来检查一个给定的数字是否是素数。理解质数的定义对你会有帮助。除非被 1 或它本身整除,否则不能被除尽的正整数称为素数。1 不是素数。素数的几个例子是 2、3、5、7 和 11。

定义默认主要服务提供商

在本节中,您将为PrimeChecker服务定义一个默认的服务提供者。为服务定义服务提供者只需创建一个实现服务接口的类。对于我们的例子,您将创建一个名为GenericPrimeChecker的类,它实现了PrimeChecker接口,并将包含一个提供者构造函数。

这个服务提供者将在同一个模块jdojo.prime中定义,该模块也包含您的服务接口。清单 7-3 包含了一个名为GenericPrimeChecker的类的完整代码。它实现了PrimeChecker接口,因此它的实例可以用作服务提供者。注意,我将这个类放在了com.jdojo.prime.impl包中,只是为了将公共接口和私有实现分开。该类的isPrime()方法检查指定的参数是否是质数。这种方法的实现不是最佳的。下一个服务提供了更好的实现。

// GenericPrimeChecker.java
package com.jdojo.prime.impl;
import com.jdojo.prime.PrimeChecker;
public class GenericPrimeChecker implements PrimeChecker {
    @Override
    public boolean isPrime(long n) {
        if (n <= 1) {
            return false;
        }
        if (n == 2) {
            return true;
        }
        if (n % 2 == 0) {
            return false;
        }
        for (long i = 3; i < n; i += 2) {
            if (n % i == 0) {
                return false;
            }
        }
        return true;
    }
}

Listing 7-3A Service Implementation Class for the PrimeChecker Service Interface

为了使GenericPrimeChecker类作为PrimeChecker服务接口的服务提供者对ServiceLoader类可用,您需要在jdojo.prime模块的声明中包含一个provides语句。清单 7-4 包含了jdojo.prime模块声明的修改版本。

// module-info.java
module jdojo.prime {
    exports com.jdojo.prime;
    uses com.jdojo.prime.PrimeChecker;
    provides com.jdojo.prime.PrimeChecker
        with com.jdojo.prime.impl.GenericPrimeChecker;
}

Listing 7-4The Modified Declaration of the jdojo.prime Module

provides语句指定这个模块为PrimeChecker接口提供一个实现,它的with子句指定实现类的名称。实现类必须满足以下条件:

  • 它必须是公共具体类或公共接口。它可以是顶级或嵌套的静态类。它不能是内部类或abstract类。

  • 它必须提供提供者构造函数或提供者方法。您有一个公共的无参数构造函数,它充当提供者构造函数。这个构造函数由ServiceLoader类使用反射来实例化服务提供者。

  • 实现类的实例必须与服务提供者接口的赋值兼容。

如果不满足这些条件中的任何一个,就会发生编译时错误。请注意,您不需要导出包含服务实现类的com.jdojo.prime.impl包,因为没有客户端应该直接依赖于服务实现。客户端只需要引用服务接口,而不是任何特定的服务实现类。ServiceLoader类可以访问和实例化实现类,而不需要模块导出包含服务实现的包。

Note

如果一个模块使用了一个provides语句,那么指定的服务接口可能在当前模块或者另一个可访问的模块中。在with子句中指定的服务实现类/接口必须在当前模块中定义。

这就是本模块的全部内容。将这个模块编译并打包成一个模块化的 JAR。此时,没有什么可测试的。

定义更快的主要服务提供商

在本节中,您将为PrimeChecker服务接口定义另一个服务提供者。让我们称之为更快的服务提供者,因为您将实现一个更快的算法来检查素数。这个服务提供者将在一个名为jdojo.prime.faster的独立模块中定义,服务实现类称为FasterPrimeChecker

清单 7-5 包含模块声明,类似于我们对jdojo.prime模块的声明。这一次,只有with子句中的类名发生了变化。

// module-info.java
module jdojo.prime {
    exports com.jdojo.prime;
    uses com.jdojo.prime.PrimeChecker;
    provides com.jdojo.prime.PrimeChecker
        with com.jdojo.prime.impl.GenericPrimeChecker;
}

Listing 7-5The Module Declaration for the com.jdojo.prime.faster Module

FasterPrimeChecker类需要实现PrimeChecker接口,该接口在jdojo.prime模块中。读取jdojo.prime模块需要requires语句。

清单 7-6 包含了FasterPrimeChecker类的代码,它的isPrime()方法比GenericPrimeChecker类的isPrime()方法执行得更快。这一次,该方法遍历所有奇数,从 3 开始,到要测试素数的数字的平方根结束。

// FasterPrimeChecker.java
package com.jdojo.prime.faster;
import com.jdojo.prime.PrimeChecker;
public class FasterPrimeChecker implements PrimeChecker {
    // No provider constructor
    private FasterPrimeChecker() {
        // No code
    }
    // Define a provider method
    public static FasterPrimeChecker provider() {
        return new FasterPrimeChecker();
    }
    @Override
    public boolean isPrime(long n) {
        if (n <= 1) {
            return false;
        }
        if (n == 2) {
            return true;
        }
        if (n % 2 == 0) {
            return false;
        }
        long limit = (long) Math.sqrt(n);
        for (long i = 3; i <= limit; i += 2) {
            if (n % i == 0) {
                return false;
            }
        }
        return true;
    }
}

Listing 7-6An Implementation for the PrimeChecker Service Interface

注意GenericPrimeCheckerFasterPrimeChecker类之间的区别,如清单 7-3 和 7-6 所示。GenericPrimeChecker 类包含一个默认构造函数,用作提供程序构造函数。它不包含提供者方法。FasterPrimeChecker类使无参数构造函数成为私有的,这并没有使构造函数成为提供者构造函数。FasterPrimeChecker类提供了 provider 方法,声明如下:

public static FasterPrimeChecker provider() { /*...*/ }

ServiceLoader类需要实例化更快的 prime 服务时,就会调用这个方法。这个方法非常简单——它创建并返回一个FasterPrimeChecker类的对象。

这就是目前您学习本模块所需的全部内容。要编译这个模块,jdojo.prime模块需要在模块路径中。将这个模块编译并打包成一个模块化的 JAR。此时,没有什么可测试的。

定义可能的主要服务提供商

在这一节中,我将向您展示如何使用 Java 接口作为服务实现。您将为PrimeChecker服务接口定义另一个服务提供者。让我们称之为可能的素数服务提供者,因为它告诉你一个数可能是素数。这个服务提供者将在一个名为jdojo.prime.probable的独立模块中定义,服务实现接口称为ProbablePrimeChecker

这项服务是关于检查一个质数。java.math.BigInteger类包含一个名为isProbablePrime(int certainty)的方法。如果这个方法返回true,这个数字可能是一个质数。如果方法返回false,这个数肯定不是素数。certainty参数决定了该方法在返回true之前确保数字是质数的程度。参数certainty的值越大,该方法产生的成本就越高,当该方法返回true时,该数字是素数的概率就越高。

清单 7-7 包含模块声明,类似于我们之前对jdojo.prime.faster模块的声明。这一次,只有with子句中的类/接口名称发生了变化。清单 7-8 包含了 ProbablePrimeChecker 类的代码。

// ProbablePrimeChecker.java
package com.jdojo.prime.probable;
import com.jdojo.prime.PrimeChecker;
import java.math.BigInteger;
public interface ProbablePrimeChecker
        extends PrimeChecker {
    // A provider method
    public static ProbablePrimeChecker provider() {
        int certainty = 1000;
        ProbablePrimeChecker checker = n ->
            BigInteger.valueOf(n).
            isProbablePrime(certainty);
        return checker;
    }
}

Listing 7-8An Implementation Interface for the PrimeChecker Service Interface

// module-info.java
module jdojo.prime.probable {
    requires jdojo.prime;
    provides com.jdojo.prime.PrimeChecker
        with com.jdojo.prime.probable.ProbablePrimeChecker;
}

Listing 7-7The Module Declaration for the com.jdojo.prime.probable Module

ProbablePrimeChecker接口扩展了PrimeChecker接口,并且只包含一个方法,即提供者方法:

public static ProbablePrimeChecker provider() {/*...*/}

ServiceLoader类需要实例化可能的 prime 服务时,它会调用这个方法。这个方法非常简单——它创建并返回一个ProbablePrimeChecker接口的实例。它使用 lambda 表达式来创建提供者。isPrime()方法使用BigInteger类来检查数字是否是可能的质数。

清单 7-9 包含了将ProbablePrimeChecker接口作为服务提供者的另一种声明。

// ProbablePrimeChecker.java
package com.jdojo.prime.probable;
import com.jdojo.prime.PrimeChecker;
import java.math.BigInteger;
public interface ProbablePrimeChecker {
    // A provider method
    public static PrimeChecker provider() {
        int certainty = 1000;
        PrimeChecker checker = n ->
            BigInteger.valueOf(n).
            isProbablePrime(certainty);
        return checker;
    }
}

Listing 7-9An Alternative Declaration of the ProbablePrimeChecker Interface

这一次,接口没有扩展PrimeChecker接口。要成为一个服务实现,其提供者方法必须返回服务接口(PrimeChecker接口)或其子类型的实例。通过将提供者方法的返回类型声明为PrimeChecker,您已经满足了这个需求。声明ProbablePrimeChecker接口,如清单 7-9 所示,有一个缺点,即在没有实例化服务提供者的情况下,使用ServiceLoader类的stream()方法,无法通过服务提供者的类名com.jdojo.probable.ProbablePrimeChecker找到该服务提供者。ServiceLoader.Providertype()方法会返回com.jdojo.prime.PrimeChecker接口的Class引用,是provider()方法的返回类型。我使用这个接口的声明,如清单 7-8 所示。

这就是本模块的全部内容。要编译这个模块,需要将jdojo.prime模块添加到模块路径中。将这个模块编译并打包成一个模块化的 JAR。此时,没有什么可测试的。

测试主要服务

在本节中,您将通过创建一个客户端应用程序来测试服务,该应用程序将在一个名为jdojo.prime.client的单独模块中定义。清单 7-10 包含了模块声明。

// module-info.java
module jdojo.prime.client {
    requires jdojo.prime;
}

Listing 7-10The Declaration 

of the jdojo.prime.client Module

客户端模块只需要知道服务接口。在这种情况下,jdojo.prime模块定义了服务接口。因此,客户端模块只读取服务接口模块,而不读取其他内容。在现实世界中,客户端模块要比这复杂得多,它也可能读取其他模块。图 7-3 为jdojo.prime.client模块的模块图。

img/323070_3_En_7_Fig3_HTML.png

图 7-3

com.jdojo.prime.client 模块的模块图

Note

客户端模块不知道服务提供者模块,因此它不需要直接读取它们。服务的职责是发现所有服务提供者,并使它们的实例对客户端可用。在这种情况下,jdojo.prime模块定义了com.jdojo.prime.PrimeChecker接口,它是一个服务接口,也充当服务。

清单 7-11 包含使用PrimeChecker服务的客户端代码。

// Main.java
package com.jdojo.prime.client;
import com.jdojo.prime.PrimeChecker;
public class Main {
    public static void main(String[] args) {
        // Numbers to be checked for prime
        long[] numbers = {3, 4, 121, 977};
        // Use the default service provider
        PrimeChecker checker = PrimeChecker.newInstance();
        System.out.println(
            "Using default service provider:");
        checkPrimes(checker, numbers);
        // Try faster prime service provider
        String fasterProviderName =
            "com.jdojo.prime.faster.FasterPrimeChecker";
        PrimeChecker fasterChecker =
            PrimeChecker.newInstance(fasterProviderName);
        if (fasterChecker == null) {
            System.out.println(
                "\nFaster service provider is not" +
                " available.");
        } else {
            System.out.println(
                "\h nUsing faster service provider:");
            checkPrimes(fasterChecker, numbers);
        }
        // Try probable prime service provider
        String probableProviderName =
           "com.jdojo.prime.probable.ProbablePrimeChecker";
        PrimeChecker probableChecker =
            PrimeChecker.newInstance(probableProviderName);
        if (probableChecker == null) {
            System.out.println(
                "\nProbable service provider is not" +
                " available.");
        } else {
            System.out.println(
                "\nUsing probable service provider:");
            checkPrimes(probableChecker, numbers);
        }
    }
    public static void checkPrimes(PrimeChecker checker,
            long... numbers) {
        for (long n : numbers) {
            if (checker.isPrime(n)) {
                System.out.printf(
                    "%d is a prime.%n", n);
            } else {
                System.out.printf(
                    "%d is not a prime.%n", n);
            }
        }
    }
}

Listing 7-11A Main Class to Test the PrimeChecker Service

checkPrimes()方法接受一个PrimeChecker实例和变量long数字。它使用PrimeChecker来检查数字是否是质数,并打印相应的信息。main()方法检索默认的PrimeChecker服务提供者实例以及更快的和可能的服务提供者实例。它使用所有三个服务提供者的实例来检查相同的一组数是否是质数。编译并打包模块的代码。在模块路径中运行只有两个模块jdojo.primejdojo.prime.clientMain类,如下所示(删除“;”后的换行符和空格)):

C:\Java9LanguageFeatures>java ^
--module-path dist\jdojo.prime.jar;
              dist\jdojo.prime.client.jar ^
--module jdojo.prime.client/com.jdojo.prime.client.Main

Using default service provider:
3 is a prime.
4 is not a prime.
121 is not a prime.
977 is a prime.
Faster service provider is not available.
Probable service provider is not available.

模块路径中只有一个服务提供者,它是与jdojo.prime模块打包在一起的默认服务提供者。因此,检索更快和可能的服务提供商的尝试失败了。从输出中可以明显看出这一点。

Note

当模块系统在已解析模块的模块声明中遇到一个uses语句时,它会扫描模块路径,以找到所有包含provides语句的模块,这些语句指定了在uses语句中指定的服务接口的实现。从这个意义上说,模块中的uses语句表示对其他模块的间接可选依赖,这是自动为您解决的。因此,要使用服务提供者,只需将服务提供者模块放在模块路径上;它将被ServiceLoader级发现并装载。

让我们通过将jdojo.prime.faster模块包含到模块路径中来运行相同的命令,如下所示(删除“;”后面的换行符和空格)):

C:\Java9LanguageFeatures>java ^
--module-path dist\jdojo.prime.jar;
    dist\jdojo.prime.client.jar;
    dist\jdojo.prime.faster.jar ^
--module jdojo.prime.client/com.jdojo.prime.client.Main

Using default service provider:
3 is a prime.
4 is not a prime.
121 is not a prime.
977 is a prime.
Using faster service provider:
3 is a prime.
4 is not a prime.
121 is not a prime.
977 is a prime.
Probable service provider is not available.

这一次,您在模块路径上有两个服务提供者,并且两者都被运行时找到,这从输出中可以明显看出。

以下命令包括模块路径上的jdojo.primejdojo.prime.fasterjdojo.prime.probable模块。将找到所有三个服务提供者,这从输出中可以明显看出(删除“;”后的换行符和空格)):

C:\Java9LanguageFeatures>java ^
--module-path dist\jdojo.prime.jar;
    dist\jdojo.prime.client.jar;
    dist\jdojo.prime.faster.jar;
    dist\jdojo.prime.probable.jar ^
--module jdojo.prime.client/com.jdojo.prime.client.Main

Using default service provider:
3 is a prime.
4 is not a prime.
121 is not a prime.
977 is a prime.
Using faster service provider:
3 is a prime.
4 is not a prime.
121 is not a prime.
977 is a prime.
Using probable service provider:
3 is a prime.
4 is not a prime.
121 is not a prime.
977 is a prime.

在这种情况下,模块是这样解析的:

  • 主类在jdojo.prime.client模块中,所以这个模块是根模块,先解析。

  • jdojo.prime.client模块读取jdojo.prime模块,因此jdojo.prime模块被解析。

  • jdojo.prime模块包含一个uses语句,该语句将com.jdojo.prime.PrimeChecker指定为服务接口类型。运行时扫描模块路径中的所有模块,检查它们是否包含指定相同服务接口的provides语句。它找到包含这些provides语句的jdojo.primejdojo.prime.fasterjdojo.prime.probable模块。在上一步中已经解决了jdojo.prime模块。此时jdojo.prime.fasterjdojo.probable模块被解析。

您可以使用下面的–show-module-resolution命令行选项来查看模块解析过程。显示部分输出(删除“;”后的换行符和空格)):

C:\Java9LanguageFeatures>java ^
--module-path dist\jdojo.prime.jar;
    dist\jdojo.prime.client.jar;
    dist\jdojo.prime.faster.jar;
    dist\jdojo.prime.probable.jar ^
--show-module-resolution ^
--module jdojo.prime.client/com.jdojo.prime.client.Main

root jdojo.prime.client ...
jdojo.prime.client requires jdojo.prime ...
jdojo.prime binds jdojo.prime.probable ...
jdojo.prime binds jdojo.prime.faster...
...

在传统模式下测试主要服务

并非所有的应用程序都将被迁移以使用模块。主服务的模块化 jar 可以和类路径上的其他 jar 一起使用。假设您将主要服务的所有模块化 jar 放在了C:\Java9LanguageFeatures\lib目录中。通过使用以下命令将四个模块化 jar 放在类路径上来运行com.jdojo.prime.client.Main类(删除“;”后的换行符和空格)):

C:\Java9Revealed>java ^
--class-path lib\com.jdojo.prime.jar;
    lib\com.jdojo.prime.client.jar;
    lib\com.jdojo.prime.faster.jar;
    lib\com.jdojo.prime.generic.jar;
    lib\com.jdojo.prime.probable.jar ^
com.jdojo.prime.client.Main

Using default service provider:
Exception in thread "main" java.lang.NullPointerException
        at com.jdojo.prime.client.Main.checkPrimes
            (Main.java:39)
        at com.jdojo.prime.client.Main.main
            (Main.java:14)

输出表明使用遗留模式——JDK 9 之前的模式,将所有模块化 jar 放在类路径上——找不到任何服务提供者。在传统模式中,服务提供者发现机制是不同的。ServiceLoader类扫描类路径上的所有 jar,寻找META-INF/services目录中的文件。文件名是完全限定的服务接口名称。文件路径如下所示:

META-INF/services/<service-interface>

该文件的内容是服务提供者实现类/接口的全限定名称列表。每个类名需要在单独的一行上。您可以在文件中使用单行注释。从#字符开始的行上的文本被认为是注释。

服务接口名为com.jdojo.prime.PrimeChecker,因此三个服务提供者的模块化 jar 将有一个名为com.jdojo.prime.PrimeChecker的文件,路径如下:

META-INF/services/com.jdojo.prime.PrimeChecker

您需要将META-INF/services目录添加到源代码目录的根目录中。如果您使用的是 NetBeans 之类的 IDE,IDE 会为您打包文件。清单 7-12 到 7-14 包含三个主要服务提供商模块的模块化 jar 文件的内容。

# The probable service provider implementation interface
# name
com.jdojo.prime.probable.ProbablePrimeChecker

Listing 7-14Contents of the META-INF/services/com.jdojo.prime.PrimeChecker File in the Modular JAR for the com.jdojo.prime.probable Module

# The faster service provider implementation class name
com.jdojo.prime.faster.FasterPrimeChecker

Listing 7-13Contents of the META-INF/services/com.jdojo.prime.PrimeChecker File in the Modular JAR for the com.jdojo.prime.faster Module

# The generic service provider implementation class name
com.jdojo.prime.impl.GenericPrimeChecker

Listing 7-12Contents of the META-INF/services/com.jdojo.prime.PrimeChecker File in the Modular JAR for the com.jdojo.prime Module

为通用和更快的 prime checker 服务提供者重新编译和打包模块化 jar。运行以下命令(删除“;”后的换行符和空格)):

C:\Java9LanguageFeatures>java ^
--class-path lib\jdojo.prime.jar;
    lib\jdojo.prime.client.jar;
    lib\jdojo.prime.faster.jar;
    lib\jdojo.prime.probable.jar ^
com.jdojo.prime.client.Main

Using default service provider:
3 is a prime.
4 is not a prime.
121 is not a prime.
977 is a prime.
Exception in thread "main"
    java.util.ServiceConfigurationError:
    com.jdojo.prime.
PrimeChecker:
com.jdojo.prime.faster.FasterPrimeChecker
Unable to get public no-arg  constructor
...
   Caused by: java.lang.NoSuchMethodException:
   com.jdojo.prime.faster.
FasterPrimeChecker.<init>()
...

显示了部分输出。当ServiceLoader类试图实例化更快的主要服务提供者时,输出指示运行时异常。当试图实例化可能的主要服务提供者时,您将得到相同的错误。在META-INF/services目录中添加关于服务的信息是实现服务的遗留方式。为了向后兼容,服务实现必须是具有公共无参数构造函数的类。回想一下,您只为GenericPrimeChecker类提供了一个提供者构造函数。因此,在遗留模式下,默认的主要检查器服务提供者工作,而其他两个不工作。您可以向FasterPrimeChecker类添加一个提供者构造函数来使其工作。但是,不能向接口添加提供者构造函数,并且ProbablePrimeChecker在类路径模式下不工作。您必须从一个显式模块加载它才能使它工作。

摘要

由应用程序(或库)提供的特定功能被称为服务。提供服务实现的应用程序和库被称为服务提供者。使用这些服务提供商提供的服务的应用程序被称为服务消费者客户

在 Java 中,服务是由一组接口和类定义的。服务包含一个接口或抽象类,定义了服务提供的功能,它被称为服务提供者接口服务接口服务类型。服务接口的具体实现被称为服务提供者。一个服务接口可以有多个服务提供者。服务提供者可以是一个类或一个接口。

JDK 包含一个java.util.ServiceLoader<S>类,其唯一目的是在运行时为指定的服务接口发现并加载类型为S的服务提供者。如果包含服务提供者的 JAR(模块化的或非模块化的)放在类路径上,ServiceLoader类使用META-INF/services目录来查找服务提供者。该目录中的文件名应该与服务接口的完全限定名相同。该文件包含服务提供者实现类的完全限定名—每行一个类名。文件可以使用一个#字符作为单行注释的开始。ServiceLoader类扫描类路径上的所有META-INF/services目录来发现服务提供者。

在模块化的环境中,不需要META-INF/services目录。使用ServiceLoader类来发现和加载服务提供者的模块需要使用uses语句来指定服务接口。在uses语句中指定的服务接口可以在当前模块或当前模块可访问的任何模块中声明。您可以使用ServiceLoader类的iterator()方法来迭代所有的服务提供者。stream()方法提供了一个元素流,这些元素是ServiceLoader.Provider接口的实例。您可以使用流根据提供者的类名筛选和选择特定类型的提供者,而不必实例化所有提供者。

包含服务提供者的模块需要使用provides语句指定服务接口及其实现类。实现类必须在当前模块中声明。

练习

练习 1

Java 中的服务、服务接口、服务提供者是什么?

练习 2

为名为M的模块编写声明,该模块加载服务接口的服务提供者,该服务接口的完全限定名为p.S

运动 3

为名为N的模块编写声明,该模块提供服务接口p.S的实现。服务实现类的全限定名是q.C

演习 4

使用ServiceLoader类,一个模块可以加载多少类型的服务?

锻炼 5

一个模块可以提供多少个服务类型的服务实现?

锻炼 6

什么时候使用java.util.ServiceLoader<S>类?

锻炼 7

什么时候使用嵌套的java.util.ServiceLoader.Provider<S>接口?

运动 8

您可以使用ServiceLoader类的iterator()方法或stream()方法来发现和加载特定类型的服务提供者。当您必须根据服务提供者实现类或接口的名称来选择服务提供者时,哪种方法具有更好的性能?

演习 9

什么是提供者构造函数和提供者方法?如果两者都可用,那么从模块化 jar 加载服务时使用哪一个?

运动 10

在定义一个打包在模块化 JAR 中的服务时,如果放在类路径中也应该工作,您会采取什么步骤?

八、网络编程

在本章中,您将学习:

  • 什么是网络编程

  • 什么是网络协议套件

  • 什么是 IP 地址,不同的 IP 编址方案是什么

  • 特殊 IP 地址及其用途

  • 什么是端口号以及如何使用它们

  • 使用 TCP 和 UDP 客户端和服务器套接字在远程计算机之间进行通信

  • URI、URL 和 URN 的定义以及如何在 Java 程序中表示它们

  • 如何使用非阻塞套接字

  • 如何使用异步套接字通道

  • 面向数据报的套接字通道和多播数据报通道

本章中的所有示例程序都是清单 8-1 中声明的jdojo.net模块的成员。

// module-info.java
module jdojo.net {
    exports com.jdojo.net;
}

Listing 8-1The Declaration of a jdojo.net Module

本章的前几节旨在为没有计算机科学背景的读者快速概述与网络技术相关的基础知识。如果您理解像 IP 地址、端口号和网络协议套件这样的术语,您可以跳过这些部分,开始阅读“套接字 API 和客户机-服务器范例”部分。

什么是网络编程?

网络是一组两台或多台计算机或其他类型的电子设备(如打印机),它们为了共享信息而连接在一起。链接到网络的每个设备被称为一个节点。一台与网络相连的计算机被称为主机。Java 中的网络编程包括编写 Java 程序,以促进网络上不同计算机上运行的进程之间的信息交换。

Java 使编写网络程序变得容易。向另一台计算机上运行的进程发送消息就像向本地文件系统写入数据一样简单。类似地,接收从另一台计算机上运行的进程发送的消息就像从本地文件系统中读取数据一样简单。本章中的大多数程序都涉及到通过网络读写数据,它们类似于文件 I/O。本章中的几个新类有助于网络上两台计算机之间的通信。

要理解或编写本章中的 Java 程序,您不需要具备网络技术的高级知识。本章涵盖了网络通信中所涉及的一些事物的高级细节。

网络可以根据不同的标准进行分类。根据网络分布的地理区域,网络可分为以下几类:

  • 局域网(LAN):它覆盖一个小区域,如一栋大楼或一组大楼。

  • 校园网(CAN):它覆盖一个校园,如大学校园,在校园内互连多个局域网。

  • 城域网:它比局域网覆盖更大的地理区域。通常,它覆盖一个城市。

  • 广域网(WAN):它覆盖更大的地理区域,例如一个国家的一个区域或世界上不同国家的多个区域。

当两个或两个以上的网络使用路由器(也称为网关)连接时,称为网间互联,由此产生的组合网络称为网间互联,简称互联网(注意互联网中小写的 I)。全球互联网络,包括世界上所有连接在一起的网络,被称为互联网(注意互联网中的大写 I)。

根据拓扑结构(网络中节点的排列),网络可以分为星形总线混合式等。

根据网络传输数据的技术,可以分为以太网LocalTalk光纤分布式数据接口(FDDI)令牌环网异步传输模式(ATM) 等。

我不涉及不同种类的网络的任何细节。请参考任何有关网络的标准教科书,详细了解网络和网络技术。

计算机上两个进程之间的通信很简单,它是使用操作系统定义的进程间通信来实现的。当在互联网上的两台不同的计算机上运行的两个进程需要通信时,这是一项非常乏味的任务。在这两个进程开始通信之前,您需要考虑通信的许多方面。您需要考虑的一些要点如下:

  • 两台计算机可能使用不同的技术,如不同的操作系统、不同的硬件等。

  • 它们可能位于使用不同网络技术的两个不同网络上。

  • 它们可能被使用不同技术的许多其它网络分隔开。也就是说,两台计算机不在两个直接互连的网络上。您需要考虑的不仅仅是两个网络,而是来自一台计算机的数据必须通过才能到达另一台计算机的所有网络。

  • 他们可能相隔几英里,或者在地球的另一边。你如何高效地传输信息而不用担心两台电脑之间的距离?

  • 一台计算机可能不理解另一台计算机发送的信息。

  • 通过网络发送的信息可能会被复制、延迟或丢失。接收方和发送方应该如何处理这些异常情况?

简单地说,网络上的两台计算机使用消息(0 和 1 的序列)进行通信。

必须有定义良好的规则来处理前面提到的问题(以及更多)。处理特定任务的规则集被称为协议。处理网络通信涉及许多类型的任务。有一个协议来处理每个特定的任务。有一堆协议(也称为协议簇)一起用于处理网络通信。

网络协议组

现代网络被称为分组交换网络,因为它们以被称为分组的块传输数据。每个分组独立于其他分组传输。这使得使用不同的路由从同一台计算机向同一目的地传输数据包变得容易。然而,如果一台计算机向一台远程计算机发送两个数据包,而第二个数据包在第一个数据包到达之前到达,这可能会成为一个问题。因此,每个数据包都有一个数据包编号和目的地址。在目的计算机上有重新排列无序到达的数据包的规则。下面的讨论试图解释用于处理网络通信中的分组的一些机制。

图 8-1 显示了一个分层协议套件,称为互联网参考模型TCP/IP 分层模型。这是使用最广泛的协议套件。模型中的每一层都执行定义明确的任务。拥有分层协议模型的主要优点是任何层都可以被改变而不影响其他层。新协议可以添加到任何层,而无需更改其他层。

img/323070_3_En_8_Fig1_HTML.jpg

图 8-1

显示其五个协议层的互联网协议套件

每一层只知道它上面和下面的那一层。每一层都有两个接口,一个用于上一层,一个用于下一层。例如,传输层有到应用层和互联网层的接口。也就是说,传输层只知道如何与应用层和 internet 层通信。它对网络接口层或物理层一无所知。

Java 程序等用户应用程序使用应用层与远程应用程序进行通信。用户应用程序必须指定它想要用来与远程应用程序通信的协议。应用层中的协议定义了用于格式化消息以及将含义与包含在消息中的信息(例如消息类型、描述它是请求还是响应等)相关联的规则。应用层格式化消息后,将消息移交给传输层。应用层中的协议的例子是超文本传输协议(HTTP)、文件传输协议(FTP)、Gopher、电信网络(Telnet)、简单邮件传输协议(SMTP)和网络新闻传输协议(NNTP)。

传输层协议处理将消息从一台计算机上的一个应用程序传输到远程计算机上的另一个应用程序的方式。它控制数据流、数据传输过程中的错误处理以及两个应用程序之间的连接。例如,用户应用程序可能会将非常大的数据块移交给传输层,以便传输到远程应用程序。远程计算机可能无法一次处理如此大量的数据。传输层负责一次向远程计算机传送适量的数据,这样远程应用程序就可以根据自己的能力处理数据。通过网络传递到远程计算机的数据可能由于各种原因在途中丢失。传输层负责重新传输丢失的数据。请注意,应用层只向传输层传递一次要传输的数据。在传输过程中,是传输层(而不是应用层)跟踪传递的数据和丢失的数据。可能有多个应用程序正在运行,所有这些应用程序都使用不同的协议,并与不同的远程应用程序交换信息。传输层负责将发送到远程应用程序的消息正确地传递出去。例如,您可能正在使用 HTTP 协议从一个远程 web 服务器浏览 Internet,并使用 FTP 协议从另一个 FTP 服务器下载文件。您的计算机正在从两台远程计算机接收消息,这些消息是针对您的计算机上运行的两个不同的应用程序的,一个 web 浏览器接收 HTTP 数据,一个 FTP 应用程序接收 FTP 数据。传输层负责将传入的数据传递给适当的应用程序。您可以看到协议组的不同层在网络数据传输中扮演着不同的角色。根据所使用的传输层协议,传输层将相关信息添加到消息中,并将其传递到下一层,即 internet 层。传输层中使用的协议示例有传输控制协议(TCP)、用户数据报协议(UDP)和流控制传输协议(SCTP)。

互联网层接受来自传输层的消息,并准备适合通过互联网发送的数据包。它包括互联网协议(IP)。IP 准备的数据包也称为 IP 数据报。除了其他信息外,它还包括一个报头和一个数据区。报头包含发送方的 IP 地址、目的 IP 地址、生存时间(TTL,是一个整数)、报头校验和以及协议中指定的许多其他信息。IP 将消息准备成数据报,准备通过互联网传输。IP 数据报报头中的 TTL 根据路由器的数量指定了 IP 数据报在需要被丢弃之前可以保持传输多长时间。它的大小是一个字节,其值可以在 1 到 255 之间。当 IP 数据报在到达目的地的路由中到达路由器时,路由器将 TTL 值减 1。如果递减值为零,路由器将丢弃该数据报,并使用互联网控制消息协议(ICMP)向发送方发回一条错误消息。如果 TTL 值仍然是正数,路由器将数据报转发到下一个路由器。IP 使用地址方案,为每台计算机分配一个唯一的地址。该地址称为 IP 地址。我将在下一节详细讨论 IP 寻址方案。互联网层将 IP 数据报交给下一层,即网络接口层。互联网层中的协议的例子是互联网协议(IP)、互联网控制消息协议(ICMP)、互联网组管理协议(IGMP)和互联网协议安全(IPsec)。

网络接口层准备要在网络上传输的数据包。该数据包被称为。网络接口层位于物理层之上,物理层包括硬件。请注意,IP 层使用 IP 地址来标识网络上的目的地。IP 地址是一个虚拟地址,完全由软件维护。硬件不知道 IP 地址,也不知道如何使用 IP 地址传输帧。必须给硬件一个硬件地址,也称为媒体访问控制 (MAC)地址,它需要将帧传输到目的地。这一层从 IP 地址中解析出目的硬件地址,并将其放入帧头中。它将帧移交给物理层。网络接口层中的协议示例有开放最短路径优先(OSPF)、点对点协议(PPP)、点对点隧道协议(PPTP)和第 2 层隧道协议(L2TP)。

物理层由硬件组成。它负责将信息比特转换成信号,并通过线路传输信号。

Note

数据包是一个通用术语,在网络编程中用来表示一个独立的数据块。协议的每一层也使用特定的术语来表示它所处理的数据包。例如,一个数据包在 TCP 层被称为一个段;它在 IP 层被称为数据报;它在网络接口和物理层被称为帧。每一层在准备要通过网络传输的数据包时,都会将报头(有时也包括报尾)添加到从上一层接收的数据包中。当每一层从其下一层接收到数据包时,它会执行相反的操作。它从数据包中删除报头;如果需要,执行一些操作;并将数据包移交给其上一层。

当应用程序发送的数据包到达远程计算机时,它必须以相反的顺序通过同一层协议。每一层都将删除其报头,执行一些操作,并将数据包传递给其上一层。最后,数据包到达远程应用程序时的格式与它从发送方计算机上的应用程序开始时的格式相同。图 8-2 显示了发送方和接收方计算机的数据包传输。P1、P2、P3 和 P4 是相同数据的不同格式的数据包。目的地的协议层从其下一层接收相同的数据包,该数据包是由同一协议层传递给发送方计算机上的下一层的。

img/323070_3_En_8_Fig2_HTML.jpg

图 8-2

通过发送方和接收方计算机上的协议层传输数据包

IP 寻址方案

IP 使用称为 IP 地址的唯一地址将 IP 数据报路由到目的地。IP 地址唯一标识计算机和路由器之间的连接。通常,IP 地址标识一台计算机。但是,需要强调的是,它标识的是计算机和路由器之间的连接,而不仅仅是计算机。路由器也会被分配一个 IP 地址。一台计算机可以使用多个路由器连接到多个网络,并且计算机和路由器之间的每个连接都有一个唯一的 IP 地址。在这种情况下,计算机将被分配多个 IP 地址,该计算机被称为多宿主。多宿主增加了计算机网络连接的可用性。如果一个网络连接失败,计算机可以使用其他可用的网络连接。

一个 IP 地址包含两部分——一个网络标识符(我称之为前缀)和一个主机标识符(我称之为后缀)。前缀唯一地标识互联网上的网络;后缀在网络中唯一标识主机。两台主机的 IP 地址可能有相同的后缀,只要它们有不同的前缀。

互联网协议有两个版本——IP v4(或简称 IP)和 IPv6,其中 v4 和 v6 分别代表版本 4 和版本 6。IPv6 也被称为下一代互联网协议(IPng)。注意没有 IPv5。IP 最流行的时候是在第 4 版。在 IPng 被分配第 6 版之前,第 5 版已经被分配给了另一个被称为互联网流协议(ST)的协议。

IPv4 和 IPv6 都使用 IP 地址来标识网络上的主机。然而,两个版本中的寻址方案有很大不同。接下来的两节解释了 IPv4 和 IPv6 使用的寻址方案。

由于 IP 地址必须是唯一的,它的分配由一个名为互联网号码分配机构 (IANA)的组织控制。IANA 为属于某个组织的每个网络分配一个唯一的地址。该组织使用网络地址和一个唯一的数字为网络上的每台主机形成一个唯一的 IP 地址。IANA 将 IP 地址分配给五个地区互联网注册管理机构(RIR),这些机构在表 8-1 中列出的特定地区分配 IP 地址。您可以在 www.iana.com找到更多关于如何从 IANA 获得您所在地区的网络地址的信息。

表 8-1

用于分配网络 IP 地址的区域互联网注册中心

|

地区互联网注册管理机构名称

|

覆盖的区域

|
| --- | --- |
| 非洲网络信息中心 | 非洲区域 |
| 亚太网络信息中心 | 亚太地区 |
| 美国互联网号码注册局(ARIN) | 北美地区 |
| 拉丁美洲和加勒比互联网地址注册处 | 拉丁美洲和一些加勒比海岛屿 |
| 欧洲 IP 网络网络协调中心(RIPE NCC) | 欧洲、中东和中亚 |

IPv4 寻址方案

IPv4(或简称 IP)使用 32 位数字来表示 IP 地址。IP 地址包含两部分—前缀和后缀。前缀标识网络,后缀标识网络上的主机,如图 8-3 所示。

img/323070_3_En_8_Fig3_HTML.jpg

图 8-3

IPv4 寻址方案

人类要记住二进制格式的 32 位数字并不容易。IPv4 允许您使用四个十进制数字的替代形式。每个十进制数的范围是从 0 到 255。程序负责将十进制数转换成计算机将使用的 32 位二进制数。IPv4 的十进制数字格式称为点分十进制格式,因为点用于分隔两个十进制数字。每个十进制数代表 32 位数字中 8 位包含的值。例如,二进制格式的 IPv4 地址1100 0000 1010 1000 0000 0001 1110 0111可以表示为点分十进制格式的192.168.1.231。将二进制 IPv4 转换为十进制的过程如图 8-4 所示。在192.168.1.231中,192.168.1部分标识网络地址(前缀),而231(后缀)部分标识该网络上的主机。

img/323070_3_En_8_Fig4_HTML.jpg

图 8-4

二进制和十进制格式的 IPv4 地址的一部分

你怎么知道192.168.1代表 IPv4 地址192.168.1.231中的前缀?规则控制 IPv4 中前缀和后缀的值。

更准确地说,IPv4 地址空间被分为五类,称为网络类,即ABCDE。类别类型定义了 32 位中有多少位将用于表示 IP 地址的网络地址部分。前缀中的前导位定义了 IP 地址的类别。这也被称为自识别有类 IP 地址,因为你可以通过查看 IP 地址来判断它属于哪个类。

表 8-2 列出了 IPv4 中的五个网络类别及其特征。IP 地址中的前导位标识网络的类别。例如,如果一个 IP 地址看起来像0XXX,其中XXX是 32 位中的最后 31 位,那么它属于A类网络;如果一个 IP 地址看起来像110XXX,其中XXX是 32 位的最后 29 位,它属于C类网络。只能有类A类型的128网络,每个网络可以有16777214个主机。一个A类网络可以拥有的主机数量非常大,一个网络拥有那么多主机的可能性非常小。在 class C类型的网络中,一个网络可以拥有的最大主机数量限制为 254 台。

表 8-2

有类编址方案中的五类 IPv4

|

网络类

|

前缀

|

后缀

|

前缀中的前导位

|

网络数量

|

每个网络的主机数量

|
| --- | --- | --- | --- | --- | --- |
| A | 8 位 | 24 位 | Zero | One hundred and twenty-eight | Sixteen million seven hundred and seventy-seven thousand two hundred and fourteen |
| B | 16 位 | 16 位 | Ten | Sixteen thousand three hundred and eighty-four | Sixty-five thousand five hundred and thirty-four |
| C | 24 位 | 8 位 | One hundred and ten | Two million ninety-seven thousand one hundred and fifty-two | Two hundred and fifty-four |
| D | 未定义 | 未定义 | One thousand one hundred and ten | 未定义 | 未定义 |
| E | 未定义 | 未定义 | One thousand one hundred and eleven | 未定义 | 未定义 |

如果一个组织被分配了一个来自类C的网络地址,而它只有 10 台主机连接到该网络,会发生什么情况?该网络中 IP 地址的剩余插槽仍未使用。回想一下,IP 地址中的主机(或后缀)部分在网络中必须是唯一的(前缀部分)。另一方面,如果一个组织需要将 300 台计算机连接到网络,它需要获得两个C类网络地址,因为获得一个能够容纳65534主机的B类网络地址将再次浪费大量 IP 地址。

注意,如果为后缀分配的位数是N,则可以使用的主机数是2N - 2。两位模式(全 0 和全 1)不能用于主机地址。它们有特殊的用途。这就是一个C类网络最多可以有 254 台主机而不是 256 台主机的原因。类别D地址用作组播地址。类别E地址被保留。

互联网的快速发展和大量未使用的 IP 地址促使人们制定新的编址方案。该方案仅基于一个标准,即应该能够在 IP 地址的前缀和后缀部分之间使用任意边界,而不是 8、16 和 24 位的预定义边界。这将使未使用的地址最少。例如,如果一个组织需要一个只有 20 台主机的网络的网络号,该组织只能使用 27 位前缀和 5 位后缀。

称为子网划分超网划分的两个术语用于描述后缀中的一些位用作前缀以及前缀中的一些位用作后缀的情况。当后缀中的位用作前缀时,实质上是以主机地址为代价创建更多的网络地址。额外的网络地址称为子网。通过使用一个称为子网掩码地址掩码的数字来实现子网划分。子网掩码是一个 32 位的数字,用于根据 IP 地址计算网络地址。使用子网掩码消除了网络类别必须预先定义 IP 地址的网络号部分的限制。对 IP 地址和子网掩码执行逻辑AND以计算网络号。在这种编址方案中,IP 地址总是用其子网掩码来指定。IP 地址后面跟一个正斜杠和子网掩码。例如,140.10.11.9/255.255.0.0表示带有子网掩码255.255.0.0的 IP 地址140.10.11.9。可以使用四个十进制部分在 0 到 255 范围内的任何子网掩码。在这个例子中,140.10.11.9是一个类B地址。类别B地址使用 16 位作为前缀,16 位作为后缀。让我们把后缀去掉 6 位,加到前缀上。现在前缀是 22 位,后缀只有 10 位。通过这样做,您以主机数量为代价创建了额外的网络数量。要描述这个子网划分方案中的 IP 地址,您需要使用子网掩码255.255.252.0。如果您将此子网掩码用作140.10.11.9/255.255.252.0来书写 IP 地址,网络地址将被计算为140.10.8.0,如下所示:

IP Address: 10001100 00001010 00001011 00001001
Subnet Mask: 11111111 11111111 11111100 00000000
------------------------------------------------
Logical AND: 10001100 00001010 00001000 00000000
              (140)     (10)      (8)    (0)

无类域间路由(CIDR)是另一种 IPv4 寻址方案,在该方案中,IPv4 地址被指定为四个带点的十进制数字以及由正斜杠分隔的另一个十进制数字,例如192.168.1.231/24,其中最后一个数字24表示 32 位 IPv4 地址中的前缀长度(或用于网络号的位数)。请注意,CIDR 编址方案允许您在 32 位 IPv4 的任意位定义前缀/后缀边界。通过将这些位从前缀移到后缀,您可以组合多个网络并增加每个网络的主机数量。这被称为超网。您可以使用 CIDR 符号创建超网和子网。

IPv4 寻址方案中的一些 IP 地址是为广播和多播 IP 地址保留的。我将在本章后面讨论广播和组播。

IPv6 寻址方案

IPv6 是 IP 的新版本,是 IPv4 的继任者。在快速发展的互联网世界中,IPv4 中的地址空间正在耗尽。IPv6 旨在提供足够的地址空间,以便在未来几十年内,世界上的每台计算机都可以获得一个唯一的 IP 地址。以下是 IPv6 的一些主要功能:

  • IPv6 使用 128 位数字作为 IP 地址,而不是 IPv4 中使用的 32 位数字。

  • 它的 IP 数据包报头格式与 IPv4 不同。IPv4 的每个数据报只有一个报头,而 IPv6 的每个数据报只有一个基本报头,后跟多个可变长度的扩展报头。

  • IPv6 支持比 IPv4 更大的数据报。

  • 在 IPv4 中,路由器执行 IP 数据包分段。在 IPv6 中,应该由发送方主机而不是路由器来执行数据包分段。这意味着使用 IPv6 的主机必须预先知道最大传输单元(MTU)的路径,即所有网络到目的主机的最大数据包大小的最小值。当 IP 数据报不得不进入一个比数据报离开的网络具有更小传输容量的网络时,就会发生 IP 数据报的分段。在 IPv4 中,分段由路由器执行,它检测路由中传输容量较低的网络。因为 IPv6 只允许主机执行分段,所以主机必须发现最小大小的数据报,该数据报可以通过所有可能的路由从源主机路由到目的主机。

  • IPv6 支持在报头中指定数据报的路由信息,以便路由器可以使用它通过特定的路由来路由数据报。此功能有助于传递时间关键的信息。

  • IPv6 具有可扩展性。可以将任意数量的扩展报头添加到 IPv6 数据报中,这可以用一种新的方式来解释。

IPv6 使用 128 位 IP 地址。它使用易于理解的符号以文本形式表示 IP 地址。这 128 位被分成 8 个字段,每个字段 16 位。每个字段都以十六进制形式书写,并用冒号分隔。以下是 IPv6 地址的一些示例:

  • F6DC:0:0:4015:0:BA98:C0A8:1E7

  • F6DC:0:0:7678:0:0:0:A21D

  • F6DC:0:0:0:0:0:0:A21D

  • 0:0:0:0:0:0:0:1

IPv6 地址中有许多字段的值为零是很常见的,尤其是对于所有 IPv4 地址。IPv6 地址表示法允许您通过使用两个连续的冒号来压缩连续的零值字段。在一个地址中,只能使用两个冒号来隐藏一次连续的零值字段。可以使用零压缩技术重写先前的 IPv6 地址:

  • F6DC::4015:0:BA98:C0A8:1E7

  • F6DC:0:0:7678::A21D

  • F6DC::A21D

  • ::1

注意,我们可以只抑制第二个地址F6DC:0:0:7678::A21D中两组连续零字段中的一组。将其重写为F6DC::7678::A21D是无效的,因为它不止一次使用了两个冒号。您可以使用两个冒号来隐藏连续的零字段,这些零字段可能出现在地址字符串的开头、中间或结尾。如果一个地址全是零,你可以简单地用::来表示。

您也可以在 IPv6 地址中混合十六进制和十进制格式。当您有一个 IPv4 地址并希望以 IPv6 格式书写时,这种表示法非常有用。如前所述,您可以使用十六进制表示法写入前六个 16 位字段,并对 IPv4 使用点分十进制表示法写入后两个 16 位字段。混合记数法采用X:X:X:X:X:X:D.D.D.D的形式,其中X是十六进制数,D是十进制数。您可以使用以下表示法重写以前的 IPv6 地址:

  • F6DC::4015:0:BA98:192.168.1.231

  • F6DC:0:0:7678::0.0.162.29

  • F6DC::0.0.162.29

  • ::0.0.0.1

与 IPv4 不同,IPv6 不基于网络类别分配 IP 地址。像 IPv4 一样,它使用 CIDR 地址,因此 IP 地址中前缀和后缀之间的边界可以在任意位指定。例如,::1可以用 CIDR 符号表示为::1/128,其中 128 是前缀长度。

Note

当 IPv6 地址作为 URL 的一部分在文字字符串中使用时,应该用括号([])括起来。此规则不适用于 IPv4。例如,如果您使用 IPv4 地址访问环回地址上的 web 服务器,您可以使用类似于 http://127.0.0.1/index.html的 URL。在 IPv6 地址符号中,你需要使用类似http://[::1]/index.html的 URL。在使用之前,请确保您的浏览器在其 URL 中支持 IPv6 地址表示法。

特殊 IP 地址

一些 IP 地址用于特殊目的。一些这样的 IP 地址如下:

  • 环回 IP 地址

  • 单播 IP 地址

  • 多播 IP 地址

  • 任播 IP 地址

  • 广播 IP 地址

  • 未指定的 IP 地址

以下部分详细描述了这些特殊 IP 地址的使用。

环回 IP 地址

您需要至少两台通过网络连接的计算机来测试或运行网络程序。有时,当您想在项目的开发阶段测试网络程序时,建立网络可能不可行或不理想。IP 的设计者们意识到了这种需求。

IP 编址方案中规定将 IP 地址视为环回地址,以便只使用一台计算机测试网络程序。当协议组中的互联网层检测到回送 IP 地址作为 IP 数据报的目的地时,它不会将分组传递到其下的协议层(即网络接口层)。相反,它会返回(或环回,因此得名环回地址)并将数据包路由回同一台计算机上的传输层。传输层将数据包传送到同一台主机上的目的地进程,就像数据包来自远程主机一样。回送 IP 地址使得使用一台计算机测试网络程序成为可能。图 8-5 描述了 IP 处理发往环回 IP 地址的互联网数据包的方式。数据包不会离开源计算机。它被互联网层截获,并被路由回它所来自的同一台计算机。

img/323070_3_En_8_Fig5_HTML.jpg

图 8-5

将回送 IP 地址作为其目的地的因特网分组被路由回相同的目的地

回送 IP 地址是保留地址,IP 不需要将回送 IP 地址作为目的地址的数据包转发到网络接口层。

在 IPv4 寻址方案中,127.X.X.X块是为环回地址保留的,其中X是 0 到 255 之间的十进制数。通常,127.0.0.1被用作 IPv4 中的环回地址。但是,您并不局限于仅使用127.0.0.1作为唯一的环回地址。如果您愿意,也可以使用127.0.0.2127.3.5.11作为有效的环回地址。通常,名称localhost被映射到计算机上的回送地址127.0.0.1

在 IPv6 寻址方案中,只有一个环回地址,足以对网络程序执行任何本地测试。就是0:0:0:0:0:0:0:1或者干脆就是::1

单播 IP 地址

单播是网络上两台计算机之间的一对一通信,其中 IP 数据包被传送到一台远程主机。单播 IP 地址标识网络上唯一的主机。IPv4 和 IPv6 支持单播 IP 地址。

多播 IP 地址

多播是一种一对多的通信,其中一台计算机发送一个 IP 数据包,该数据包被传送到多台远程计算机。多播让您实现组交互的概念,如音频或视频会议,其中一台计算机向组中的所有计算机发送信息。使用多播代替多个单播的好处是发送方只发送一份数据包。数据包的一个副本会尽可能长地沿着网络传输。如果包的接收者在多个网络上,则在需要时制作包的副本,并且包的每个副本被独立地路由。最后,每个接收者都会收到一份单独的数据包副本。多播是群组成员之间通信的有效方式,因为它减少了网络流量。

一个 IP 数据包只有一个目的 IP 地址。如何使用多播将 IP 数据包传送到多台主机?IP 在其地址空间中包含一些地址作为多播地址。如果数据包的地址是组播地址,该数据包将被传送到多台主机。多播数据包传送的概念与活动的组成员资格相同。当一个组形成时,该组被给予一个组 ID。寻址到该组 ID 的任何信息被传递给所有组成员。在多播通信中,使用多播 IP 地址(类似于组 ID)。多播数据包被寻址到该多播地址。每个感兴趣的主机向其感兴趣的本地路由器注册其 IP 地址,以便在该多播地址上进行通信。主机和本地路由器之间的注册过程是使用互联网组管理协议(IGMP)完成的。当路由器收到带有组播地址的数据包时,它会将该数据包的副本发送给向其注册了该组播地址的每台主机。接收者可以通过通知路由器来选择在任何时候离开多播组。

多播数据包在到达接收主机之前可能会经过许多路由器。多播数据包的所有接收者可能不在同一个网络上。有许多处理多播数据包路由的协议,如距离矢量多播路由协议(DVMRP)。

IPv4 和 IPv6 都支持组播寻址。在 IPv4 中,D类网络地址用于多播。也就是说,IPv4 中的多播地址中的四个最高位是1110。在 IPv6 中,组播地址的前 8 位设置为 1。也就是说,IPv6 中的组播地址总是以FF开头。例如,FF0X:0:0:0:0:0:2:0000是 IPv6 中的组播地址。

任播 IP 地址

任播是一种一对一的群组通信,其中一台计算机向一组计算机发送数据包,但该数据包只发送给该组中的一台计算机。IPv4 不支持任播。IPv6 支持任播。在任播中,同一个地址被分配给多台计算机。当路由器收到发往任播地址的数据包时,它会将该数据包传送到最近的计算机。当一项服务已经在许多主机上复制,并且您希望在离客户端最近的主机上提供该服务时,任播非常有用。有时,任播寻址也称为集群寻址。使用单播地址空间中的任播地址。您无法通过查看位的排列来区分单播地址和任播地址。当同一个单播地址被分配给多台主机时,它被视为一个任播地址。请注意,路由器必须知道分配了任播地址的主机,这样它才能将寻址到该任播地址的数据包传送到最近的主机之一。

广播 IP 地址

广播是一种一对多的通信,其中一台计算机发送一个数据包,该数据包将被传送到网络上的所有计算机。IPv4 分配一些地址作为广播地址。当所有 32 位都设置为 1 时,它就形成了一个广播地址,数据包将被发送到本地子网上的所有主机。当主机地址中的所有位都设置为 1 并且指定了网络地址时,它就形成了指定网络号的广播地址。例如,255.255.255.255是本地子网的广播地址,192.168.1.255是网络192.168.1.0的广播地址。IPv6 没有广播地址。在 IPv6 中,您需要使用多播地址作为广播地址。

未指定的 IP 地址

IPv4 中的0.0.0.0和 IPv6 中的::(注意,::表示 128 位 IPv6 地址,所有位都设置为零)被称为未指定地址。主机使用此地址作为源地址来表示它还没有 IP 地址,例如在启动过程中,它还没有被分配 IP 地址。

端口号

端口号是 16 位无符号整数,范围从 0 到 65535。有时,端口号也简称为端口。一台计算机运行许多进程,这些进程与远程计算机上运行的其他进程进行通信。当传输层收到来自 Internet 层的传入数据包时,它需要知道该数据包应该发送到该计算机上的哪个进程(运行在应用层)。端口号是一个逻辑编号,传输层使用它来识别计算机上数据包的目的进程。

每个传入传输层的数据包都有一个协议;例如,传输层中的 TCP 协议处理程序处理 TCP 数据包,而传输层中的 UDP 协议处理程序处理 UDP 数据包。

在应用程序层,一个进程使用它希望与远程进程通信的每个通信信道的独立协议。一个进程为它为特定协议打开的每个通信信道使用一个唯一的端口号,并在传输层的特定协议模块中注册该端口号。因此,对于特定协议,端口号必须是唯一的。例如,进程 P1 可以对 TCP 协议使用端口号 1988,而另一个被调用的进程 P2 可以在同一台计算机上对 UDP 协议使用相同的端口号 1988。主机上的进程使用远程进程的协议和端口号向远程进程发送数据。

计算机上的进程如何开始与远程进程通信?比如你访问雅虎的网站,只需输入 http://www.yahoo.com 作为网页地址。在该网页地址中,http表示应用层协议,其使用 TCP 作为传输层协议, www.yahoo.com 是机器名称,其使用域名系统(DNS)解析为 IP 地址。由 www.yahoo.com 标识的机器可能正在运行许多进程,这些进程可能使用http协议。您的网络浏览器连接到 www.yahoo.com 上的哪个进程?由于许多人使用雅虎的网站,它需要在一个众所周知的端口运行其http服务,这样每个人都可以使用该端口连接到它。通常,http web 服务器运行在端口 80。可以用 http://www.yahoo.com:80 ,和用 http://www.yahoo.com 一样。并不总是需要在端口 80 运行http web 服务器。如果您没有在端口 80 运行您的http web 服务器,想要使用您的http服务的人必须知道您正在使用的端口。IANA 负责推荐哪些端口号用于知名服务。IANA 将端口号分为三个范围:

  • 已知端口:0–1023

  • 注册端口:1024–49151

  • 动态和/或专用端口:49152–65535

众所周知的端口号被全球提供的最常用的服务使用,例如 HTTP、FTP 等。表 8-3 列出了一些用于知名应用层协议的知名端口。通常,您需要管理权限才能使用计算机上众所周知的端口。

表 8-3

用于某些应用层协议的部分知名端口列表

|

应用层协议

|

通道数

|
| --- | --- |
| 回声 | seven |
| 文件传送协议 | Twenty-one |
| 用于远程联接服务的标准协议或者实现此协议的软件(可为动词) | Twenty-three |
| 简单邮件传输协议 | Twenty-five |
| 超文本传送协议 | Eighty |
| 安全超文本传输协议 | Four hundred and forty-three |
| POP3 | One hundred and ten |
| 网络新闻传输协议(Network News Transfer Protocol) | One hundred and nineteen |

组织(或用户)可以在应用程序要使用的已注册端口范围中向 IANA 注册一个端口号。例如,已经为 RMI 注册表注册了 1099 (TCP/UDP)端口(RMI 代表远程方法调用)。

任何应用程序都可以使用动态/私有端口号范围内的端口号。

套接字 API 和客户机-服务器范例

我还没有开始讨论在 Java 程序中使网络通信成为可能的 Java 类。在这一节中,我将介绍套接字和在两台远程主机之间的网络通信中使用的客户机-服务器范例。

在前面几节中,我简要介绍了不同的底层协议及其职责。是时候在协议栈中向上移动,讨论应用层和传输层之间的交互了。应用程序如何使用这些协议与远程应用程序通信?操作系统提供了一个名为套接字的应用程序接口(API ),它允许两个远程应用程序通信,利用协议栈中的低层协议。套接字不是另一层协议。它是传输层和应用层之间的接口。它提供了两层之间的标准通信方式,这反过来又提供了两个远程应用程序之间的标准通信方式。有两种插座:

  • 面向连接的套接字

  • 无连接插座

面向连接的套接字也称为流套接字。无连接套接字也称为数据报套接字。请注意,数据总是使用 IP 数据报从互联网上的一台主机一次一个数据报地发送到另一台主机。

传输层中使用的传输控制协议(TCP)是提供面向连接的套接字的最广泛使用的协议之一。应用程序将数据传递给 TCP 套接字,TCP 负责将数据传输到目的主机。TCP 处理所有问题,如排序、分段、组装、丢失数据检测、重复数据传输等。这给应用程序的印象是,数据像连续的字节流一样从源应用程序流向目标应用程序。使用 TCP 套接字的两台主机之间不存在硬件级的物理连接。都是用软件实现的。有时,它也被称为虚拟连接。两个插座的组合唯一地定义了一个连接。

在面向连接的套接字通信中,客户端和服务器端创建套接字,建立连接,并交换信息。TCP 负责处理数据传输过程中可能出现的错误。TCP 也称为可靠的传输层协议,因为它保证数据的传输。如果它由于某种原因不能传递数据,它将通知发送方应用程序有关错误的情况。发送数据后,它会等待接收方的确认,以确保数据到达目的地。然而,TCP 提供的可靠性是有代价的。与无连接协议相比,开销更大,速度也更慢。TCP 确保发送方向接收方发送一定量的数据,这可以由接收方的缓冲区大小来处理。它还处理网络上的流量拥塞。当它检测到交通拥堵时,它会减慢数据传输速度。Java 支持 TCP 套接字。

传输层中使用的用户数据报协议(UDP)是使用最广泛的提供无连接套接字的协议。它不可靠,但是快得多。它允许您发送有限大小的数据,一次一个包,这与 TCP 不同,TCP 允许您将数据作为任意大小的流发送,并处理将数据分段成适当大小的包的细节。当您使用 UDP 发送数据时,无法保证数据传递。但是,它仍然在许多应用程序中使用,而且效果非常好。发送方将 UDP 数据包发送到目的地,然后忘记了它。如果接收者得到了它,它就得到它。否则,接收方无法知道是否有 UDP 数据包发送给了它。您可以将 TCP 和 UDP 中使用的通信与电话和邮件中使用的通信进行比较。电话交谈是可靠的,它提供了通信双方之间的确认。当你邮寄一封信时,你不知道收信人什么时候收到,或者他们是否收到了。UDP 和 TCP 还有一个重要的区别。UDP 不保证数据的排序。也就是说,如果您使用 UDP 向目的地发送五个数据包,这五个数据包可能以任何顺序到达。但是,TCP 保证数据包将按照发送的顺序传送。Java 支持 UDP 套接字。

你应该使用哪种协议:TCP 还是 UDP?这取决于应用程序将如何使用。如果数据完整性至关重要,您应该使用 TCP。如果速度优先于较低的数据完整性,您应该使用 UDP。例如,文件传输应用程序应该使用 TCP,而视频会议应用程序应该使用 UDP。如果您丢失了几个像素的视频数据,这对视频会议没有太大影响。它可以继续。但是,如果在传输文件时丢失了几个字节的数据,该文件可能根本就不可用。

两个远程应用程序如何开始通信?哪个应用程序发起通信?应用程序如何知道远程应用程序有兴趣与之通信?你有没有拨过一个公司的客服电话和客服代表通话?如果你与一家公司的客户服务代表交谈过,你就已经体验过两个远程应用程序的通信。我在本节中参考了使用公司客服来解释远程通信的机制。你和一个公司的代表在两个遥远的地方。你需要一项服务,公司就提供这项服务。换句话说,你是客户,公司是服务商(或者服务器)。你不知道什么时候你会需要公司的服务。公司提供了客服电话,你可以联系公司。公司还做了一件事。公司必须做什么来为你提供服务?你能猜到吗?它会按照给你的电话号码等待你的来电。沟通必须发生在你和公司之间,而公司已经在沟通中向前迈进了一步,被动地等待你的电话。一旦你拨了公司的号码,就建立了一个连接,你和公司的代表交换信息。最后,你们俩都挂断了电话,停止了交流。使用套接字的网络通信类似于您和公司代表之间的通信。如果你理解了这个通信的例子,理解套接字就很容易了。

两个远程应用程序使用一对套接字进行通信。任何通信都需要两个端点。套接字是通信信道两端的通信端点。一对套接字上的通信遵循典型的客户机-服务器通信范例。一个应用程序创建一个套接字,被动地等待另一个远程应用程序的联系。等待远程应用程序联系它的应用程序被称为服务器应用程序,或者简称为服务器。另一个应用程序创建一个套接字,并启动与等待的服务器应用程序的通信。这被称为客户端应用或者简称为客户端。在客户端和服务器可以交换信息之前,必须执行许多其他步骤。例如,服务器必须公布其位置和其他详细信息,以便客户可以联系它。

套接字会经历不同的状态。每个状态都标志着一个事件。套接字的状态告诉你套接字能做什么,不能做什么。通常,套接字的生命周期由表 8-4 中列出的八个原语来描述。

表 8-4

典型的套接字原语及其描述

|

基元

|

描述

|
| --- | --- |
| Socket | 创建一个套接字,应用程序使用该套接字作为通信端点。 |
| Bind | 将本地地址与套接字关联。本地地址包括 IP 地址和端口号。端口号必须是 0 到 65535 之间的数字。对于计算机上用于套接字的协议,它应该是唯一的。例如,如果 TCP 套接字使用端口 12456,UDP 套接字也可以使用相同的端口号 12456。 |
| Listen | 为客户端请求定义其等待队列的大小。它只能由面向连接的服务器套接字执行。 |
| Accept | 等待客户端请求到达。它只能由面向连接的服务器套接字执行。 |
| Connect | 尝试建立到服务器套接字的连接,该套接字正在等待一个accept原语。它是由面向连接的客户端套接字执行的。 |
| Send/Sendto | 发送数据。通常,send表示在面向连接的套接字上的发送操作,Sendto表示在无连接套接字上的发送操作。 |
| Receive/ReceiveFrom | 接收数据。他们是SendSendto的对应。 |
| Close | 关闭连接。 |

下面几节详细阐述了每个套接字原语。

套接字原语

服务器通过指定套接字的类型来创建套接字:流套接字或数据报套接字。

绑定原语

bind原语将套接字与本地 IP 地址和端口号相关联。请注意,一台主机可以有多个 IP 地址。套接字可以绑定到主机的一个 IP 地址或所有 IP 地址。将套接字绑定到主机的所有可用 IP 地址也称为绑定到通配符地址。绑定为这个套接字保留端口号。没有其他套接字可以使用该端口号进行通信。传输协议(TCP 和 UDP)将使用绑定端口来路由发往此套接字的数据。在本节的稍后部分,我将详细解释传输层和套接字之间的数据传输。现在,只需要理解,在绑定中,套接字告诉传输层这是我的 IP 地址和端口号,如果您获得了寻址到该地址的任何数据,请将该数据传递给我。套接字绑定的 IP 地址和端口号分别称为套接字的本地地址本地端口

Listen 原语

服务器通知操作系统将套接字置于被动模式,以便它等待传入的客户端请求。此时,服务器还没有准备好接受任何客户机请求。服务器还指定套接字的等待队列大小。当客户机在这个套接字上联系服务器时,客户机请求被放入那个队列中。最初,队列是空的。如果客户机在这个套接字上联系服务器,并且等待队列已满,那么客户机的请求将被拒绝。

接受原语

服务器通知操作系统这个套接字已经准备好接受客户机请求。如果服务器使用无连接传输协议(如 UDP)的套接字,则不执行此步骤。对 TCP 服务器套接字执行此步骤。当套接字向操作系统发送接受消息时,它会一直阻塞,直到接收到客户端对新连接的请求。

连接原语

只有面向连接的客户端套接字执行此步骤。这是套接字通信中最重要的阶段。客户端套接字向服务器套接字发送请求以建立连接。服务器套接字已经发出了accept,并且一直在等待客户端请求的到达。客户机套接字发送服务器套接字的 IP 地址和端口号。回想一下,服务器套接字在开始侦听和接受来自外部的连接之前绑定了 IP 地址和端口号。随着它的请求,客户机套接字也发送它自己的 IP 地址和它已经绑定到的端口号。

这时出现了一个重要的问题。TCP 等传输层如何知道来自客户端的数据包(以连接请求的形式)必须被传递给服务器套接字?在绑定阶段,套接字指定其本地 IP 地址和本地端口号,以及远程 IP 地址和远程端口号。如果服务器套接字只想接受来自特定远程主机 IP 地址和端口号的连接,它可以这样做。通常,服务器套接字将接受来自任何客户端的连接,并将指定一个未指定的 IP 地址和一个零端口号作为其远程地址。服务器套接字向传输层传递五条信息——本地 IP 地址、本地端口号、远程 IP 地址、远程端口号和缓冲区。传输层将它们存储在一个称为传输控制块 (TCB)的特殊结构中,以备将来使用。当来自外部的数据包到达传输层时,它会根据传入数据包中包含的四条信息查找其 TCB,<源 IP 地址,源端口号,目的 IP 地址,目的端口号>。回想一下,客户端将每个 TCP 数据包中的源地址和目的地址发送给服务器。传输层试图找到与源地址和目的地址相关联的缓冲区。如果它找到一个缓冲区,它将传入的数据传输到缓冲区,并通知套接字缓冲区中有它的一些信息。如果服务器套接字接受来自任何客户机的请求(远程地址全为零),来自任何客户机的数据都将被路由到其缓冲区。

一旦服务器套接字检测到来自客户机的请求,它就用远程客户机的地址信息创建一个新的套接字。使用绑定新的套接字,并且创建新的缓冲区并将其绑定到这个组合地址。事实上,为一个套接字创建了两个缓冲区:一个用于传入数据,一个用于传出数据。此时,服务器套接字让新套接字与请求连接的客户机套接字进行通信。服务器套接字本身可以关闭自己(不再接受客户端的连接请求),或者它可以再次开始等待接受另一个客户端的连接请求。

在两个套接字(客户机和服务器)之间建立连接后,它们可以交换信息。TCP 连接支持全双工连接。也就是说,数据可以同时双向发送或接收。

客户端套接字在尝试连接到服务器之前,知道其本地 IP 地址、本地端口号、远程 IP 地址和远程端口号。在客户端,TCB 的创建遵循类似的规则。

一旦客户机和服务器套接字就位,两个套接字(客户机套接字和专用于客户机的服务器套接字)就定义了一个连接。

服务器套接字就像坐在办公室(服务器)前台的接待员。一位客户走进来,先和接待员交谈。连接请求来自客户端到服务器,首先联系服务器套接字。接待员将客户交给另一名工作人员。在这一点上,接待员的工作对客户来说已经结束了。他们继续他们的工作,等待迎接另一个客户来到办公室。同时,第一个客户可以继续与另一个员工交谈,只要他们需要。类似地,服务器套接字创建一个新的套接字,并将该新的套接字分配给客户端以进行进一步的通信。一旦服务器套接字为客户机分配了一个新的套接字,它的工作就结束了。它将等待来自另一个客户端的另一个连接请求。请注意,除了许多其他细节之外,一个套接字还有五个重要的相关信息:协议、本地 IP 地址、本地端口号、远程 IP 地址和远程端口号。

Send/Sendto 原语

这是套接字发送数据的阶段。

接收/接收自原语

这是套接字接收数据的阶段。

封闭原语

是说再见的时候了。最后,服务器和客户端套接字关闭连接。

后续部分将讨论支持不同类型套接字的 Java 类,以便于网络编程。与网络编程相关的 Java 类在java.netjavax.netjavax.net.ssl包中。

表示机器地址

互联网协议使用机器的 IP 地址来传送数据包。在程序中使用 IP 地址并不容易,因为它是数字格式。您可能能够记住并使用 IPv4 地址,因为它们的长度只有四个十进制数字。记住和使用 IPv6 地址有点困难,因为它们是十六进制格式的八个数字。每台电脑也有一个名字,如 www.yahoo.com 。在你的程序中使用一个计算机名会使你的生活变得更容易。Java 提供了允许您在 Java 程序中使用计算机名或 IP 地址的类。如果您使用计算机名,Java 会使用域名系统(DNS)将计算机名解析为其 IP 地址。

InetAddress类的对象代表一个 IP 地址。它有两个子类,Inet4AddressInet6Address,分别代表 IPv4 和 IPv6 地址。InetAddress类没有公共构造函数。它提供以下工厂方法来创建其对象。它们如下—全部抛出一个勾选的UnknownHostException:

  • static InetAddress[] getAllByName(String host)

  • static InetAddress getByAddress(byte[] addr)

  • static InetAddress getByAddress(String host, byte[] addr)

  • static InetAddress getByName(String host)

  • static InetAddress getLocalHost()

  • static InetAddress getLoopbackAddress()

host参数指的是标准格式的计算机名或 IP 地址。addr参数以字节数组的形式引用 IP 地址的各个部分。如果指定 IPv4 地址,addr必须是 4 元素的byte数组。对于 IPv6 地址,它应该是一个 16 元素的byte数组。InetAddress类负责使用 DNS 将主机名解析为 IP 地址。

有时,一台主机可能有多个 IP 地址。getAllByName()方法将所有地址作为InetAddress对象的数组返回。

通常,使用这些工厂方法之一创建一个InetAddress类的对象,并在套接字创建和连接期间将该对象传递给其他方法。下面的代码片段演示了它的一些用法。当您使用InetAddress类或它的子类时,您将需要处理异常。

// Get the IP address of the yahoo web server
InetAddress yahooAddress = InetAddress.
    getByName("www.yahoo.com");
// Get the loopback IP address
InetAddress loopbackAddress = InetAddress.
    getByName(null);
/* Get the address of the local host. Typically, a name
   "localhost" is mapped to a loopback address. Here, we
   are trying to get the IP address of the local computer
   where this code executes and not the loopback address.
*/ 

InetAddress myComputerIPAddress =
    InetAddress.getLocalHost();

以下代码片段显示了如何打印执行代码的计算机的名称和 IP 地址:

try {
    InetAddress addr = InetAddress.getLocalHost();
    System.out.println("My computer name: " +
        addr.getHostName());
    System.out.println("My computer IP address: " +
        addr.getHostAddress());
} catch (UnknownHostException e) {
    e.printStackTrace();
}

清单 8-2 展示了 InetAddress 类及其一些方法的使用。运行该程序时,您可能会得到不同的输出。

// InetAddressTest.java
package com.jdojo.net;
import java.io.IOException;
import java.net.InetAddress;
public class InetAddressTest {
    public static void main(String[] args) {
        // Print www.yahoo.com address details
        printAddressDetails("www.yahoo.com");
        // Print the loopback address details
        printAddressDetails(null);
        // Print the loopback address details using IPv6
        // format 

        printAddressDetails("::1");
    }
    public static void printAddressDetails(String host) {
        System.out.println("Host name: " + host);
        try {
            InetAddress addr = InetAddress.getByName(host);
            System.out.println("Host IP Address: " +
                addr.getHostAddress());
            System.out.println("Canonical Host Name: " +
                addr.getCanonicalHostName());
            int timeOutinMillis = 10000;
            System.out.println("isReachable(): " +
                addr.isReachable(timeOutinMillis));
            System.out.println("isLoopbackAddress(): " +
                addr.isLoopbackAddress());
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            System.out.println(
                "-------------------------------\n");
        }
    }
}

Host name: www.yahoo.com
Host IP Address: 98.138.252.39
Canonical Host Name:
    media-router-fp2.prod.media.vip.ne1.yahoo.com
isReachable(): true
isLoopbackAddress(): false
-------------------------------
Host name: null
Host IP Address: 127.0.0.1
Canonical Host Name: 127.0.0.1
isReachable(): true
isLoopbackAddress(): true
-------------------------------
Host name: ::1
Host IP Address: 0:0:0:0:0:0:0:1
Canonical Host Name: 0:0:0:0:0:0:0:1
isReachable(): true
isLoopbackAddress(): true
------------------------------- 

Listing 8-2Demonstrating the Use of the InetAddress Class

表示套接字地址

套接字地址包含两部分,一个 IP 地址和一个端口号。InetSocketAddress类的一个对象代表一个套接字地址。您可以使用以下构造函数来创建一个InetSocketAddress类的对象:

  • InetSocketAddress(InetAddress addr, int port)

  • InetSocketAddress(int port)

  • InetSocketAddress(String hostname, int port)

所有构造函数都会尝试将主机名解析为 IP 地址。如果主机名无法解析,套接字地址将被标记为未解析,您可以使用isUnresolved()方法进行测试。如果不希望该类在创建其对象时解析地址,可以使用以下工厂方法来创建套接字地址:

static InetSocketAddress createUnresolved(
    String host, int port)

InetSocketAddress类的getAddress()方法返回一个InetAddress。如果主机名没有被解析,getAddress()方法返回null。如果您使用一个带有套接字的未解析的InetSocketAddress对象,在绑定过程中会尝试解析主机名。

清单 8-3 展示了如何创建已解析和未解析的InetSocketAddress对象。运行该程序时,您可能会得到不同的输出。

// InetSocketAddressTest.java
package com.jdojo.net;
import java.net.InetSocketAddress;
public class InetSocketAddressTest {
    public static void main(String[] args) {
        InetSocketAddress addr1 = new InetSocketAddress(
            "::1", 12889);
        printSocketAddress(addr1);
        InetSocketAddress addr2 = InetSocketAddress.
            createUnresolved("::1", 12881);
        printSocketAddress(addr2);
    }
    public static void
    printSocketAddress(InetSocketAddress sAddr) {
        System.out.println("Socket Address: " +
            sAddr.getAddress());
        System.out.println("Socket Host Name: " +
            sAddr.getHostName());
        System.out.println("Socket Port: " +
            sAddr.getPort());
        System.out.println("isUnresolved(): " +
            sAddr.isUnresolved());
        System.out.println();
    }
}

Socket Address: /0:0:0:0:0:0:0:1
Socket Host Name: 0:0:0:0:0:0:0:1
Socket Port: 12889
isUnresolved(): false
Socket Address: null
Socket Host Name: ::1
Socket Port: 12881
isUnresolved(): true

Listing 8-3Creating an InetSocketAddress Object

创建 TCP 服务器套接字

ServerSocket类的一个对象代表一个 TCP 服务器套接字。一个ServerSocket对象用于接受来自远程客户端的连接请求。ServerSocket类提供了许多构造函数。您可以使用 no-args 构造函数创建一个未绑定的服务器套接字,并使用它的bind()方法将其绑定到一个本地端口和一个本地 IP 地址。以下代码片段向您展示了如何创建服务器套接字:

// Create an unbound server socket
ServerSocket serverSocket = new ServerSocket();
// Create a socket address object
InetSocketAddress endPoint = new InetSocketAddress(
    "localhost", 12900);
// Set the wait queue size to 100
int waitQueueSize = 100;
// Bind the server socket to localhost at port 12900
// with a wait queue size of 100
serverSocket.bind(endPoint, waitQueueSize);

ServerSocket类中没有单独的listen()方法对应于listen套接字原语。它的bind()方法负责指定套接字的等待队列大小。

通过使用ServerSocket类的以下任意构造函数,可以将createbindlisten操作组合在一个步骤中。等待队列大小的默认值是 50。本地 IP 地址的默认值是通配符地址,这意味着服务器的所有 IP 地址。

  • ServerSocket(int port)

  • ServerSocket(int port, int waitQueueSize)

  • ServerSocket(int port, int waitQueueSize, InetAddress bindAddr)

您可以将套接字创建和绑定步骤合并到一个语句中,如下所示:

// Create a server socket at port 12900, with 100 as the
// wait queue size at the localhost loopback address
ServerSocket serverSocket =
    new ServerSocket(12900, 100, InetAddress.
getByName("localhost"));

一旦创建并绑定了服务器套接字,它就可以接受来自远程客户端的连接请求。要接受远程连接请求,需要在服务器套接字上调用accept()方法。在来自远程客户端的请求到达其等待队列之前,accept()方法调用会一直阻塞。当服务器套接字接收到一个连接请求时,它从请求中读取远程 IP 地址和远程端口号,并创建一个新的活动套接字。新创建的活动套接字的引用从accept()方法返回。Socket类的一个对象代表新的活动套接字。accept()方法返回一个新的主动套接字,因为它不像服务器套接字那样是一个被动套接字,它等待一个远程请求。它是一个活动套接字,因为它是为与远程客户端的活动通信而创建的。有时,这个活动套接字也被称为连接套接字,因为它处理连接上的数据传输:

// Wait for a new remote connection request
Socket activeSocket = serverSocket.accept();

一旦服务器套接字从accept()方法调用返回,服务器应用程序中的套接字数量就增加一个。您有一个被动服务器套接字和一个主动套接字。新的活动套接字是新客户端连接在服务器上的端点。此时,您需要使用新的活动套接字来处理与客户端的通信。

现在,您可以在新套接字表示的连接上读写数据了。Java TCP 套接字提供全双工连接。它允许您从连接中读取数据以及向连接中写入数据。为此,Socket类包含两个名为getInputStream()getOutputStream()的方法。getInputStream()方法返回一个InputStream对象,您可以使用它从连接中读取数据。getOutputStream()方法返回一个OutputStream对象,您可以使用它将数据写入连接。您可以使用InputStreamOutputStream对象,就好像您正在从本地文件系统上的一个文件中读取和写入一样。我假设您熟悉 Java I/O。当您在连接上读/写完数据后,关闭InputStream/OutputStream,最后关闭套接字。下面的代码片段从客户端读取一条消息,并将该消息回显给客户端。请注意,在开始通信之前,服务器和客户端必须就消息的格式达成一致。以下代码片段假设客户端一次发送一行文本:

// Create a buffered reader and a buffered writer from
// the socket's input and output streams, so that we can
// read/write one line at a time
BufferedReader br = new BufferedReader(
    new InputStreamReader(activeSocket.
        getInputStream()));
BufferedWriter bw = new BufferedWriter(
    new OutputStreamWriter(activeSocket.
        getOutputStream()));

您可以使用brbw来读取文件或写入文件。从输入流中读取数据的尝试会一直阻塞,直到数据在连接上可用。

// Read one line of text from the connection
String inMsg = br.readLine();
// Write some text to the output buffer
bw.write("Hello from server");
bw.flush();

最后,使用套接字的close()方法关闭连接。关闭套接字也会关闭其输入和输出流。事实上,您可以关闭三个中的一个(输入流、输出流或套接字),其他两个将自动关闭。试图在关闭的套接字上读/写会抛出一个java.net.SocketException。您可以通过使用其isClosed()方法来检查套接字是否关闭,如果套接字关闭,该方法将返回true

// Close the socket
activeSocket.close();

Note

一旦你关闭了一个套接字,你就不能再使用它。在使用新套接字之前,必须创建一个新套接字并绑定它。

服务器处理两种工作:接受新的连接请求和响应已经连接的客户端。如果回应客户只需要很少的时间,您可以使用如下所示的策略:

ServerSocket serverSocket = ...;
// <- create a server socket here;
while(true) {
    Socket activeSocket = serverSocket.accept();
    // Handle the client request on activeSocket here
}

这种策略一次处理一个客户端。只有当并发传入连接数非常低,并且客户端的请求只需要很少的时间来响应时,它才适用。如果一个客户端请求需要很长时间才能得到响应,那么所有其他客户端都必须等待才能得到服务。

处理多个客户机请求的另一个策略是在一个单独的线程中处理每个客户机的请求,这样服务器就可以同时为多个客户机服务。以下伪代码概述了这一策略:

ServerSocket serverSocket = ...;
// <- create a server socket here;
while(true) {
    Socket activeSocket = serverSocket.accept();
    Runnable runnable = () -> {
        // Handle the client request on the activeSocket
        // here
    };
    new Thread(runnable).start(); // start a new thread
}

这种策略似乎可以很好地工作,直到为并发客户端连接创建了太多的线程。另一个在大多数情况下都有效的策略是用一个线程池来服务所有的客户端连接。如果池中的所有线程都忙于为客户端提供服务,那么请求应该等待,直到有一个线程可以为其提供服务。

清单 8-4 包含了一个 echo 服务器的完整代码。它创建一个新线程来处理每个客户端请求。您现在可以运行 echo 服务器程序了。然而,它不会做很多事情,因为你没有一个客户端程序连接到它。在下一节学习如何创建 TCP 客户端套接字之后,您将看到它的实际应用。

// TCPEchoServer.java
package com.jdojo.net;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
public class TCPEchoServer {
    public static void main(String[] args) {
        try { 

            // Create a Server socket
            ServerSocket serverSocket =
                new ServerSocket(12900, 100,
                     InetAddress.getByName("localhost"));
            System.out.println("Server started at: " +
                serverSocket);
            // Keep accepting client connections in an
            // infinite loop
            while (true) {
                System.out.println(
                    "Waiting for a connection...");
                // Accept a connection
                final Socket activeSocket =
                    serverSocket.accept();
                System.out.println(
                    "Received a connection from " +
                    activeSocket);
                // Create a new thread to handle the new
                // connection
                Runnable runnable = () ->
                    handleClientRequest(activeSocket);
                new Thread(runnable).start();
                // <- start a new thread
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    public static void handleClientRequest(Socket socket) {
        BufferedReader socketReader = null;
        BufferedWriter socketWriter = null;
        try {
            // Create a buffered reader and writer for
            // the socket
            socketReader = new BufferedReader(
                new InputStreamReader(
                    socket.getInputStream()));
            socketWriter = new BufferedWriter(
                new OutputStreamWriter(
                    socket.getOutputStream()));
            String inMsg = null;
            while ((inMsg = socketReader.readLine())
                  != null) {

                System.out.println(
                    "Received from client: " + inMsg);
                // Echo the received message to the client
                String outMsg = inMsg;
                socketWriter.write(outMsg);
                socketWriter.write("\n");
                socketWriter.flush();
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                socket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

Listing 8-4An Echo Server Based on TCP Sockets

创建 TCP 客户端套接字

Socket类的一个对象代表一个 TCP 客户端套接字。您已经看到了Socket类的对象如何与 TCP 服务器套接字一起工作。对于服务器套接字,您从服务器套接字的accept()方法获得了一个Socket类的对象作为返回值。对于客户端套接字,您必须执行三个步骤:创建、绑定和连接。Socket类提供了许多构造函数,让您指定远程 IP 地址和端口号。这些构造函数将套接字绑定到一个本地主机和一个可用的端口号。以下代码片段显示了如何创建 TCP 客户端套接字:

// Create a client socket, which is bound to the
// localhost at any available port
// connected to remote IP 192.168.1.2 at port 3456
Socket socket = new Socket("192.168.1.2", 3456);

// Create an unbound client socket. bind it, and
// connect it.
Socket socket = new Socket();

socket.bind(new InetSocketAddress("localhost", 14101));
socket.connect(new InetSocketAddress("localhost", 12900));

一旦得到一个连接的Socket,就可以分别使用getInputStream()getOutputStream()方法来使用它的输入和输出流。您可以在连接上读/写,就像您使用输入和输出流读/写文件一样。

清单 8-5 包含了一个 echo 客户端应用程序的完整代码。它接收来自用户的输入,将输入发送到清单 8-4 中列出的 echo 服务器,并在标准输出上打印服务器的响应。echo 服务器和 echo 客户机这两个应用程序必须就它们将要交换的消息格式达成一致。他们一次交换一行文本。值得注意的是,您必须为通过连接发送的每条消息附加一个新行,因为您使用的是BufferedReader类的readLine()方法,该方法只有在遇到新行时才返回。客户端应用程序必须使用服务器套接字接受连接的相同 IP 地址和端口号。

// TCPEchoClient.java
package com.jdojo.net;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.net.Socket;
public class TCPEchoClient {
    public static void main(String[] args) {
        Socket socket = null;
        BufferedReader socketReader = null;
        BufferedWriter socketWriter = null;
        try {
            // Create a socket that will connect to
            // localhost at port 12900.
            // Note that the server must also be running
            // at localhost and 12900.
            socket = new Socket("localhost", 12900);
            System.out.println("Started client socket at "
                    + socket.getLocalSocketAddress());
            // Create a buffered reader and writer using
            // the socket's input and output streams
            socketReader = new BufferedReader(
                new InputStreamReader(
                    socket.getInputStream()));
            socketWriter = new BufferedWriter(
                new OutputStreamWriter(
                    socket.getOutputStream()));
            // Create a buffered reader for user's input
            BufferedReader consoleReader =
                new BufferedReader(
                    new InputStreamReader(System.in));
            String promptMsg =
                "Please enter a message (Bye to quit):";
            String outMsg = null;
            System.out.print(promptMsg);
            while ((outMsg = consoleReader.readLine())
                    != null) {
                if (outMsg.equalsIgnoreCase("bye")) {
                    break;
                } 

                // Add a new line to the message to the
                // server, because the server reads one
                // line at a time.
                socketWriter.write(outMsg);
                socketWriter.write("\n");
                socketWriter.flush();
                // Read and display the message from the
                // server
                String inMsg = socketReader.readLine();
                System.out.println("Server: " + inMsg);
                System.out.println(); // Print a blank line
                System.out.print(promptMsg);
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            // Finally close the socket
            if (socket != null) {
                try {
                    socket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

Listing 8-5An Echo Client Based on TCP Sockets

将 TCP 服务器和客户端放在一起

图 8-6 显示了三个客户端连接到一个服务器的设置。两个Socket对象,一端一个,代表一个连接。服务器中的ServerSocket对象一直在等待来自客户机的连接请求。

img/323070_3_En_8_Fig6_HTML.jpg

图 8-6

使用 ServerSocket 和 Socket 对象的客户机-服务器设置

清单 8-4 和 8-5 列出了 TCP echo 服务器和客户端应用程序的完整程序。您需要首先运行TCPEchoServer类,然后运行TCPEchoClient类。服务器应用程序等待客户端应用程序连接。客户端应用程序提示用户在控制台上输入文本消息。一旦用户输入一条文本消息并按下Enter键,客户端应用程序就会将该文本发送到服务器。服务器用相同的消息进行响应。这两个应用程序都将关于对话的详细信息打印到标准输出中。以下是 echo 服务器和 echo 客户端的输出。您可以运行TCPEchoClient应用程序的多个实例。服务器应用程序在单独的线程中处理每个客户端连接。

以下是服务器应用程序的输出示例:

Server started at: ServerSocket[addr=localhost/
    127.0.0.1,port=0,localport=12900]
Waiting for a connection ...
Received a connection from Socket[addr=/127.0.0.1,
    port=1698,localport=12900]
Waiting for a connection ...
Received from client: Hello

以下是客户端应用程序的输出示例:

Started client socket at /127.0.0.1:53498
Please enter a message (Bye to quit):Hello
Server: Hello
Please enter a message (Bye to quit):Bye

使用 UDP 套接字

基于 UDP 的套接字是无连接的,并且是基于数据报的,与 TCP 套接字相反,TCP 套接字是面向连接的,并且是基于流的。作为无连接套接字的效果是两个套接字(客户机和服务器)在通信之前不建立连接。回想一下,TCP 有一个服务器套接字,其唯一的功能是侦听来自远程客户端的连接请求。因为 UDP 是一种无连接协议,所以在使用 UDP 时不会有服务器套接字。在 TCP 套接字中,客户端和服务器之间具有面向流的数据传输的印象是由 TCP 在传输层产生的,因为它具有面向连接的特性。TCP 维护连接两端传输的数据的状态。UDP 是一种无连接协议,其含义是每一方(客户端和服务器)发送或接收一个数据块,而无需事先了解它们之间的通信。在使用 UDP 的通信中,发送到同一目的地的每个数据块都独立于以前发送的数据。使用 UDP 发送的数据块称为数据报或 UDP 数据包。每个 UDP 数据包都包含数据、目的 IP 地址和目的端口号。UDP 是一种不可靠的协议,因为它不保证数据包到目标接收方的传递和传递顺序。

Note

尽管 UDP 是一种无连接协议,但您可以在应用程序中使用 UDP 构建面向连接的通信。您将需要编写逻辑来处理丢失的数据包、无序的数据包传递以及许多其他事情。TCP 在传输层提供了所有这些特性,您的应用程序不必担心这些特性。

使用 UDP 套接字编写应用程序比使用 TCP 套接字编写应用程序更容易。您只需要处理两个类:

  • DatagramPacket

  • DatagramSocket

DatagramPacket类的对象代表 UDP 数据报,它是 UDP 套接字上的数据传输单元。DatagramSocket类的一个对象代表一个 UDP 套接字,用于发送或接收数据报数据包。以下是使用 UDP 套接字需要执行的步骤:

  • 创建一个DatagramSocket类的对象,并将其绑定到一个本地 IP 地址和一个本地端口号。

  • 创建一个DatagramPacket类的对象来保存目的地址和要传输的数据。

  • 使用DatagramSocket类的send(DatagramPacket packet)方法将数据包发送到目的地。在接收端,使用receive(DatagramPacket packet)方法读取数据包。

您可以使用其中一个构造函数来创建一个DatagramSocket类的对象。它们都将创建套接字,并将其绑定到本地 IP 地址和本地端口号。请注意,UDP 套接字没有远程 IP 地址和远程端口号,因为它从未连接到远程套接字。它可以从/向任何 UDP 套接字接收/发送数据报分组。

// Create a UDP Socket bound to a port number 15900
// at localhost
DatagramSocket udpSocket =
    new DatagramSocket(15900, "localhost");

DatagramSocket类提供了一个bind()方法,允许您将套接字绑定到一个本地 IP 地址和一个本地端口号。通常,您不需要使用此方法,因为您已经在构造函数中指定了它需要绑定到的套接字地址,就像您刚才所做的那样。

一个DatagramPacket包含三样东西:目的 IP 地址、目的端口号和数据。DatagramPacket类的构造函数分为两类。其中一个类别的构造函数允许您创建一个DatagramPacket对象来接收数据包。它们只需要缓冲区大小、偏移量和缓冲区中数据的长度。另一类构造函数允许您创建一个DatagramPacket对象来发送数据包。它们要求您指定目的地址和数据。如果您已经创建了一个没有指定目的地址的DatagramPacket,您可以使用setAddress()setPort()方法设置目的地址。

创建数据包以接收数据的DatagramPacket类的构造函数如下:

  • DatagramPacket(byte[] buffer, int length)

  • DatagramPacket(byte[] buffer, int offset, int length)

创建数据包发送数据的DatagramPacket类的构造函数如下:

  • DatagramPacket(byte[] buffer, int length, InetAddress address, int port)

  • DatagramPacket(byte[] buffer, int offset, int length, InetAddress address, int port)

  • DatagramPacket(byte[] buffer, int length, SocketAddress address)

  • DatagramPacket(byte[] buffer, int offset, int length, SocketAddress address)

以下代码片段演示了创建数据报数据包的一些方法:

// Create a packet to receive 1024 bytes of data
byte[] data = new byte[1024];
DatagramPacket packet =
    new DatagramPacket(data, data.length);
// Create a packet that a has buffer size of 1024, but it
// will receive data starting at offset 8 (offset zero
// means the first element in the array) and it will
// receive only 32 bytes of data.
byte[] data2 = new byte[1024];
DatagramPacket packet2 = new DatagramPacket(data2, 8, 32);
// Create a packet to send 1024 bytes of data that has a
// destination address of "localhost" and port 15900.
// Will need to populate data3 array before sending the
// packet.
byte[] data3 = new byte[1024];
DatagramPacket packet3 = new DatagramPacket(data3, 1024,
    InetAddress.getByName("localhost"), 15900);
// Create a packet to send 1024 bytes of data that has a
// destination address of "localhost" and port 15900.
// Will need to populate data4 array before sending the
// packet. The code sets the destination address by
// calling methods on the packet instead of specifying
// it in its constructor.
byte[] data4 = new byte[1024];
DatagramPacket packet4 = new DatagramPacket(data4, 1024);
packet4.setAddress(InetAddress.getByName("localhost"));
packet4.setPort(15900);

了解数据包中的数据始终具有指定的偏移量和长度非常重要。在从数据包中读取数据时,您需要使用这两条信息。假设一个receivedPacket对象引用代表一个从远程 UDP 套接字接收的DatagramPacketDatagramPacket类的getData()方法返回包的缓冲区(一个字节数组)。数据包可以有比从远程客户端接收的数据更大的缓冲区。在这种情况下,您必须使用偏移量和长度从缓冲区中读取接收到的数据,而不接触缓冲区中的垃圾数据。如果数据包的缓冲区大小小于接收的数据大小,多余的字节会被忽略。您应该使用类似下面的代码来读取套接字接收的数据。要点是您应该使用接收缓冲区中的数据,从其指定的offset开始,并使用其length属性指示的尽可能多的字节:

// Get the packet's buffer, offset, and length
byte[] dataBuffer = receivedPacket.getData();
int offset = receivedPacket.getOffset();
int length = receivedPacket.getLength();
// Copy the received data using offset and length to
// receivedData array, which will hold all good data
byte[] receivedData = new byte[length];
System.arraycopy(dataBuffer, offset,
    receivedData, 0,
    length);

创建 UDP 套接字(客户机和服务器)就像创建一个DatagramSocket类的对象一样简单。你可以用它的send()方法发送一个包。您可以使用receive()方法从远程套接字接收数据包。receive()方法阻塞,直到一个包到达。您向receive()方法提供一个空的数据包。套接字用它从远程套接字接收的信息填充它。如果所提供的数据报分组的数据缓冲区大小小于所接收的数据报分组的数据缓冲区大小,则所接收的数据被无声地截断以适合所提供的数据报分组。如果提供的数据报包的数据缓冲区大小大于接收的数据报包的数据缓冲区大小,则套接字会将接收的数据复制到由其offsetlength属性指示的数据段中的提供的数据缓冲区,而不会触及缓冲区的其他部分。请注意,可用的数据缓冲区大小不是字节数组的大小。相反,它是由length属性定义的。例如,假设您有一个数据报数据包,其字节数组包含 32 个元素,偏移量为 2,数据缓冲区长度为 8。如果您将这个数据报数据包传递给receive()方法,将复制最多 8 个字节的接收数据。数据将从缓冲器中的第三个元素复制到第十一个元素,分别由偏移量 2 和长度 8 表示。

// Create a UDP socket bound to a port number 15900 at
// localhost
DatagramSocket socket =
    new DatagramSocket(15900,
        InetAddress.getByName("localhost"));
// Send a packet assuming that you have a datagram packet
// in p
socket.send(p);
// Receive a packet
DatagramPacket p2 =
    new DatagramPacket(new byte[1024], 1024);
socket.receive(p2);

创建 UDP Echo 服务器

使用 UDP 创建 echo 服务器非常容易。它只需要四行真正的代码。使用以下步骤创建 UDP echo 服务器:

  • 创建一个DatagramSocket对象来表示 UDP 套接字。

  • 创建一个DatagramPacket对象来接收来自远程客户端的数据包。

  • 调用套接字的receive()方法来等待数据包到达。

  • 调用套接字的send()方法,传递您收到的同一个数据包。

当服务器收到 UDP 数据包时,它包含发送者的地址。您不需要更改数据包中的任何内容就可以将相同的消息回显给数据包的发送者。当准备发送数据报数据包时,需要设置目的地址。当数据包到达目的地时,它包含了发送者的地址。这在接收者想要响应数据报分组的发送者的情况下是有用的。

以下代码片段向您展示了如何编写 UDP echo 服务器:

DatagramSocket socket =
    new DatagramSocket(15900);
DatagramPacket packet =
    new DatagramPacket(new byte[1024], 1024);
while(true) {
    // Receive the packet
    socket.receive(packet);
    // Send back the same packet to the sender
    socket.send(packet);
}

清单 8-6 包含了 UDP echo 服务器相同代码的扩展版本。它包含与前面所示相同的基本逻辑。此外,它还包含处理错误和在标准输出中打印数据包详细信息的代码。

// UDPEchoServer.java
package com.jdojo.net;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
public class UDPEchoServer {
    public static void main(String[] args) {
        final int LOCAL_PORT = 15900;
        final String SERVER_NAME = "localhost";
        try {
            DatagramSocket udpSocket = new DatagramSocket(
                LOCAL_PORT,
                InetAddress.getByName(SERVER_NAME));
            System.out.println(
                "Created UDP server socket at " +
                udpSocket.getLocalSocketAddress() +
                "...");
            // Wait for a message in a loop and echo the
            // same message to the sender
            while (true) {
                System.out.println(
                    "Waiting for a UDP packet" +
                    " to arrive...");
                // Prepare a packet to hold the received
                // data
                DatagramPacket packet =
                    new DatagramPacket(
                        new byte[1024], 1024);
                // Receive a packet
                udpSocket.receive(packet);
                // Print the packet details
                displayPacketDetails(packet);
                // Echo the same packet to the sender
                udpSocket.send(packet);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    public static void
    displayPacketDetails(DatagramPacket packet) {
        // Get the message
        byte[] msgBuffer = packet.getData();
        int length = packet.getLength();
        int offset = packet.getOffset();
        int remotePort = packet.getPort();
        InetAddress remoteAddr = packet.getAddress();
        String msg = new String(
            msgBuffer, offset, length);
        System.out.println(
            "Received a packet:[IP Address="
            + remoteAddr + ", port=" + remotePort
            + ", message=" + msg + "]");
    }
}

Listing 8-6An Echo Server Based on UDP Sockets

清单 8-7 包含使用 UDP 套接字向/从 UDP echo 服务器发送/接收消息的客户端应用程序的程序。请注意,客户端和服务器一次交换一行文本。

// UDPEchoClient.java
package com.jdojo.net;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.UnknownHostException;
public class UDPEchoClient {
    public static void main(String[] args) {
        DatagramSocket udpSocket = null;
        BufferedReader br = null;
        try {
            // Create a UDP socket at localhost using an
            // available port
            udpSocket = new DatagramSocket();
            String msg = null;
            // Create a buffered reader to get an input
            // from a user
            br = new BufferedReader(
                new InputStreamReader(System.in));
            String promptMsg =
                "Please enter a message (Bye to quit):";
            System.out.print(promptMsg);
            while ((msg = br.readLine()) != null) {
                if (msg.equalsIgnoreCase("bye")) {
                    break;
                }
                // Prepare a packet to send to the server
                DatagramPacket packet =
                    UDPEchoClient.getPacket(msg);
                // Send the packet to the server
                udpSocket.send(packet);
                // Wait for a packet from the server
                udpSocket.receive(packet);
                // Display the packet details received
                // from the server
                displayPacketDetails(packet);
                System.out.print(promptMsg);
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            // Close the socket
            if (udpSocket != null) {
                udpSocket.close();
            }
        }
    }
    public static void
    displayPacketDetails(DatagramPacket packet) {
        byte[] msgBuffer = packet.getData();
        int length = packet.getLength();
        int offset = packet.getOffset();
        int remotePort = packet.getPort();
        InetAddress remoteAddr = packet.getAddress();
        String msg = new String(msgBuffer, offset, length);
        System.out.println(
            "[Server at IP Address=" + remoteAddr
            + ", port=" + remotePort + "]: " + msg);
        // Add a line break
        System.out.println();
    }
    public static DatagramPacket
    getPacket(String msg) throws UnknownHostException {
        // We will send and accept a message of 1024
        // bytes in length.
        // Longer messages will be truncated
        final int PACKET_MAX_LENGTH = 1024;
        byte[] msgBuffer = msg.getBytes();
        int length = msgBuffer.length;
        if (length > PACKET_MAX_LENGTH) {
            length = PACKET_MAX_LENGTH;
        }
        DatagramPacket packet =
            new DatagramPacket(msgBuffer, length);
        // Set the destination address and the port number
        int serverPort = 15900;
        final String SERVER_NAME = "localhost";
        InetAddress serverIPAddress =
            InetAddress.getByName(SERVER_NAME);
        packet.setAddress(serverIPAddress);
        packet.setPort(serverPort);
        return packet;
    }
}

Listing 8-7An Echo Client Based on UDP Sockets

为了测试 UDP echo 应用程序,您需要运行UDPEchoServerUDPEchoClient类。您需要首先运行服务器。客户端应用程序将提示您输入一条消息。输入文本消息,然后按 Enter 键将该消息发送到服务器。服务器将回显相同的消息。两个应用程序都在标准输出上显示正在交换的消息。它们还显示数据包的详细信息,如发送者的 IP 地址和端口号。服务器应用程序使用端口号 15900,客户端应用程序使用计算机上任何可用的 UDP 端口。如果您得到一个错误,这意味着端口号 15900 正在使用,因此您需要在服务器程序中更改端口号,并在客户端程序中使用新的端口号来寻址数据包。该服务器被设计为同时处理多个客户端。您可以运行UDPEchoClient类的多个实例。请注意,服务器在无限循环中运行,您必须手动停止服务器应用程序。

以下是服务器控制台上的日志示例:

Created UDP server socket at /127.0.0.1:15900...
Waiting for a UDP packet to arrive...
Received a packet:[IP Address=/127.0.0.1,
    port=61119, message=Hello]
Waiting for a UDP packet to arrive...
Received a packet:[IP Address=/127.0.0.1,
    port=61119, message=Nice talking to you]
Waiting for a UDP packet to arrive...

以下是客户端控制台上的日志示例:

Please enter a message (Bye to quit):
    Hello
[Server at IP Address=localhost/127.0.0.1,
    port=15900]: Hello
Please enter a message (Bye to quit):
    Nice talking to you
[Server at IP Address=localhost/127.0.0.1, port=15900]:
    Nice talking to you
Please enter a message (Bye to quit):
    Bye

已连接的 UDP 套接字

UDP 套接字不像 TCP 套接字那样支持端到端连接。DatagramSocket类包含一个connect()方法。这种方法允许应用程序将 UDP 数据包的发送和接收限制到特定端口号上的特定 IP 地址。考虑以下代码片段:

InetAddress localIPAddress =
    InetAddress.getByName("192.168.11.101");
int localPort = 15900;
DatagramSocket socket =
    new DatagramSocket(localPort, localIPAddress);
// Connect the socket to a remote address
InetAddress remoteIPAddress =
    InetAddress.getByName("192.168.12.115");
int remotePort = 17901;
socket.connect(remoteIPAddress, remotePort);

套接字绑定到本地 IP 地址192.168.11.101和本地 UDP 端口号15900。它连接到一个远程 IP 地址192.188.12.15和一个远程 UDP 端口号17901。这意味着socket对象只能用于向/从另一个运行在 IP 地址192.168.12.115和端口号17901的 UDP 套接字发送/接收数据包。在 UDP 套接字上调用了connect()方法之后,您不需要为传出的数据报数据包设置目的地 IP 地址和端口号。套接字会将在connect()方法调用中使用的目的 IP 地址和端口号添加到所有传出的数据包中。如果您在发送数据包之前提供了目的地址,那么套接字将确保数据包中提供的目的地址与connect()方法调用中使用的远程地址相同。否则,send()方法将抛出一个IllegalArgumentException

使用 UDP 套接字的connect()方法有两个优点:

  • 每次您发送数据包时,它都会设置传出数据包的目的地址。

  • 它限制套接字只与远程主机通信,该主机的 IP 地址在connect()方法的调用中使用。

现在您了解了 UDP 套接字是无连接的,并且您没有使用 UDP 套接字的真正连接。DatagramSocket类中的connect()方法没有为 UDP 套接字提供任何类型的连接。相反,它有助于将通信限制到特定的远程 UDP 套接字。

UDP 多播套接字

Java 支持 UDP 多播套接字,这些套接字可以接收发送到多播 IP 地址的数据报数据包。MulticastSocket类的一个对象代表一个多播套接字。使用MulticastSocket套接字与使用DatagramSocket套接字类似,只有一点不同——多播套接字是基于组成员的。在创建并绑定了一个多播套接字之后,需要调用它的joinGroup(InetAddress multiCastIPAddress)方法,使这个套接字成为由指定的多播 IP 地址multiCastIpAddress定义的多播组的成员。一旦它成为一个多播组的成员,任何发送到该组的数据包都将被传送到这个套接字。一个多播组中可以有多个成员。多播套接字可以是多个多播组的成员。如果一个成员决定不接收来自一个组的多播包,它可以通过调用leaveGroup(InetAddress multiCastIPAddress)方法离开该组。

在 IPv4 中,224.0.0.0239.255.255.255范围内的任何 IP 地址都可以作为组播地址发送数据报包。IP 地址224.0.0.0是保留的,你不应该在你的应用程序中使用它。多播 IP 地址不能用作数据报数据包的源地址,这意味着您不能将套接字绑定到多播地址。

套接字本身不一定要成为多播组的成员才能将数据报数据包发送到多播地址。

在 Java 中,IP 多播功能是DatagramChannel类的一部分。请参阅本章后面的“使用数据报通道的多播”一节,了解如何使用数据报通道进行 IP 多播。

清单 8-8 包含一个创建组播套接字的程序,该套接字接收寻址到230.1.1.1组播 IP 地址的数据包。

// UDPMultiCastReceiver.java
package com.jdojo.net;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.InetAddress;
import java.net.MulticastSocket;
public class UDPMultiCastReceiver {
    public static void main(String[] args) {
        int mcPort = 18777;
        String mcIPStr = "230.1.1.1";
        MulticastSocket mcSocket = null;
        InetAddress mcIPAddress = null;
        try {
            mcIPAddress = InetAddress.getByName(mcIPStr);
            mcSocket = new MulticastSocket(mcPort);
            System.out.println(
                "Multicast Receiver running at:"
                + mcSocket.getLocalSocketAddress());
            // Join the group
            mcSocket.joinGroup(mcIPAddress);
            DatagramPacket packet =
                new DatagramPacket(new byte[1024], 1024);
            while (true) {
                System.out.println(
                    "Waiting for a multicast message...");
                mcSocket.receive(packet);
                String msg = new String(
                    packet.getData(),
                    packet.getOffset(),
                    packet.getLength());
                System.out.println(
                    "[Multicast Receiver] Received:" +
                    msg);
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (mcSocket != null) {
                try {
                    mcSocket.leaveGroup(mcIPAddress);
                    mcSocket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

Listing 8-8A UDP Multicast Socket That Receives UDP Multicast Messages

清单 8-9 包含了一个向同一个组播地址发送消息的程序。注意,您可以运行UDPMulticastReceiver类的多个实例,所有这些实例都将成为同一个多播组的成员。当您运行UDPMulticastSender类时,它将向组发送一条消息,组中的所有成员都将收到同一条消息的副本。UDPMulticastSender类使用DatagramSocket,而不是MulticastSocket,来发送多播消息。

// UDPMultiCastSender.java
package com.jdojo.net;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
public class UDPMultiCastSender {
    public static void main(String[] args) {
        int mcPort = 18777;
        String mcIPStr = "230.1.1.1";
        DatagramSocket udpSocket = null;
        try {
            // Create a datagram socket
            udpSocket = new DatagramSocket();
            // Prepare a message
            InetAddress mcIPAddress =
                InetAddress.getByName(mcIPStr);
            byte[] msg = "Hello multicast socket".
                getBytes();
            DatagramPacket packet =
                new DatagramPacket(msg, msg.length);
            packet.setAddress(mcIPAddress);
            packet.setPort(mcPort);
            udpSocket.send(packet);
            System.out.println(
                "Sent a multicast message.");
            System.out.println(
                "Exiting application");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (udpSocket != null) {
                try {
                    udpSocket.close();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

Listing 8-9A UDP Datagram Socket, a Multicast Sender Application

要查看实际的多播,运行一个或多个UDPMulticastReceiver类的实例,然后运行一个UDPMulticastSender类的实例。下面是运行UDPMulticastReceiver类时的示例输出。请注意,当UDPMulticastSender运行时,程序接收到一条组播消息:

Multicast Receiver running at:
    0.0.0.0/0.0.0.0:18777
Waiting for a multicast message...
[Multicast Receiver] Received:
    Hello multicast socket
Waiting for a multicast message...

以下是运行UDPMulticastSender类时的示例输出:

Sent a multicast message.
Exiting application

URI、URL 和 URN

统一资源标识符(URI)是标识资源的字符序列。征求意见稿(RFC) 3986 定义了 URI 的通用语法。此 RFC 的全文可在 www.ietf.org/rfc/rfc3986.txt 获得。资源标识符可以通过位置、名称或两者来标识资源。本节概述了 URI。如果您对 URI 的细节感兴趣,建议您阅读 RFC3986。

使用位置来标识资源的 URI 称为统一资源定位器(URL)。例如, http://www.yahoo.com/index.html 表示在主机 www.yahoo.com上标识名为index.html的文档的 URL。URL 的另一个例子是mailto:ksharan@jdojo.com,其中mailto协议指示解释它的应用程序打开电子邮件应用程序,向 URL 中指定的电子邮件地址发送电子邮件。在这种情况下,URL 没有定位任何资源。相反,它是识别电子邮件的细节。您还可以使用mailto协议设置电子邮件的主题和正文部分。因此,URL 并不总是意味着资源的位置。有时,资源可能是抽象的,如在mailto协议的情况下。使用 URL 找到资源后,可以对资源执行一些操作,如检索、更新或删除。如何执行操作的细节取决于 URL 中使用的方案。URL 只是标识资源位置和定位它的方案的一部分,而不是可以在资源上执行的任何操作的细节。

使用名称来标识资源的 URI 称为统一资源名(URN)。例如,URN:ISBN:978-1-4302-6661-7表示一个 URN,它使用国际标准书号(ISBN)名称空间来标识一本书。

URL 和 URN 是 URI 的子集。因此,关于 URI 的讨论适用于 URL 和 URN。URI 的详细语法取决于它使用的方案。在这一节中,我将介绍 URI 的一般语法,它通常是一个 URL。下一节将探讨在 Java 程序中用来表示 URIs 和 URL 的 Java 类。

URI 可以是绝对的,也可以是相对的。一个相对的 URI 总是在另一个绝对的 URI 的背景下被解释,后者被称为基本的 URI。换句话说,你必须有一个绝对的 URI,才能使相对的 URI 有意义。绝对 URI 具有以下通用格式:

<scheme>:<scheme-specific-part>

<scheme-specific-part>依赖于<scheme>。例如,http方案使用一种格式,而mailto方案使用另一种格式。URI 的另一种通用形式如下。通常,但不一定,它代表一个 URL:

<scheme>://<authority><path>?<query>#<fragment>

这里,<scheme>表示访问资源的方法。是协议名称,如httpftp等。在 URI 规范中,我们都使用术语“协议”来表示“方案”。如果术语“scheme”使您迷惑,那么无论它何时出现在本节中,您都可以将其理解为“protocol”。URI 需要<scheme><path>零件。所有其他部分都是可选的。<path>部分可以是空字符串。

<authority>部分表示服务器名(或 IP 地址)或特定于方案的注册表。如果<authority>部分代表一个服务器名,它可以写成<userinfo>@host:port的形式。如果 URI 中有一个<authority>,它以两个正斜杠开始;这是一个可选部分。例如,一个标识机器上本地文件系统中的一个文件的 URL 使用file方案作为file:///c:/documents/welcome.doc

URI 语法在它的<path>部分使用了分层语法,它在服务器上定位资源。<path>的多个部分由正斜杠(/)分隔。

<query>部分表示通过执行指定的查询获得资源。它由用&符号(&)分隔的名称-值对组成。名称和值由等号(=)分隔。例如,id=123&rate=5.5是一个查询,它有两部分,idrate

id的值为123,而rate的值为5.5

<fragment>部分标识次要资源,通常是由 URI 的另一部分标识的主要资源的子集。

下面是一个 URI 的例子,它也被分成几个部分:

URI:       http://www.jdojo.com/java/intro.html?
           id=123#conclusion
Scheme:    http
Authority: www.jdojo.com
Path:      /java/intro.html
Query:     id=123
Fragment:  conclusion

URI 代表一个 URL,该 URL 指向在 www.jdojo.com 服务器上名为intro.html的文档。方案http表明可以使用http协议检索文档。查询id=123表明该文档是通过执行该查询获得的。片段部分conclusion可以由使用该文档的不同应用程序进行不同的解释。在 HTML 文档的情况下,片段部分被 web 浏览器解释为主文档的一部分。

并非 URI 的所有部分都是强制性的。哪些部分是强制性的,哪些部分是可选的,这取决于所使用的方案。使用 URI 来标识资源的目标之一是使其具有普遍的可读性。因此,有一组定义明确的字符可以用来表示 URI。URI 语法使用一些具有特殊含义的保留字符,它们只能用于 URI 的特定部分。在其他部分,需要对保留字符进行转义。使用百分号后跟十六进制格式的 ASCII 值对字符进行转义。比如 space 的 ASCII 值十进制格式是 32,十六进制格式是 20。如果您想在 URI 中使用空格字符,您必须使用%20,它是空格的转义形式。因为百分号被用作转义字符的一部分,所以您必须使用%25来表示 URI 中的%字符(25 是十进制数 37 的十六进制值。%的 ASCII 值是十进制的 37)。例如,如果要在查询中使用值 5.2%,以下是无效的 URI:

http://www.jdojo.com/details?rate=5.2%

要使其成为有效的 URI,您需要将百分号字符转义为%25,如下所示:

http://www.jdojo.com/details?rate=5.2%25

理解相对 URI 的用法很重要。一个相对的 URI 总是在一个绝对的 URI 的背景下被解释,后者被称为基础 URI。绝对的 URI 始于一个计划。一个亲戚 URI 继承了它的基地 URI 的一些部分。让我们考虑一个引用 HTML 文档的 URI,如下所示:

http://www.jdojo.com/java/intro.html

URI 所指的文件是intro.html。它的路径是/java/intro.html。假设名为brief_intro.htmldetailed_intro.html的两个文档(物理上或逻辑上)与intro.html位于相同的路径层次结构中。以下是所有三个文档的绝对 URIs:

如果您已经在intro.html上下文中,使用它们的名字而不是它们的绝对 URI 来引用另外两个文档会更容易。在intro.html环境中意味着什么?当您使用 http://www.jdojo.com/java/intro.html URI 来标识一个资源时,它有三个部分:一个方案(http)、一个服务器名( www.jdojo.com )和一个文档路径(/java/intro.html)。该路径表明该文档位于java路径层次结构下,而后者又位于路径层次结构的根。所有细节——方案、服务器名称、路径细节,不包括文档名称本身(intro.html)—构成了intro.html文档的上下文。如果您查看前面列出的其他两份文件的 URI,您会注意到关于它们的所有细节都与intro.html相同。换句话说,您可以声明其他两个文档的上下文与intro.html的相同。在这种情况下,以intro.html文档的绝对 URI 作为基本 URI,其他两个文档的相对 URIs 是它们的名称:brief_intro.htmldetailed_intro.html。可以列举如下:

  • 基础 uri:

  • 相对 URI: brief_intro.html

  • 相对 URI: detailed_intro.html

在该列表中,两个相对 URIs 从基本 URI 继承方案、服务器名称和路径层次结构。需要强调的是,一个相对的 URI 如果不指明它的基地 URI 是没有意义的。

当必须使用相对 URI 时,它必须被解析为其等价的绝对 URI。URI 规范规定了解决相对 URI 的规则。我讨论了一些最常用的相对 URIs 形式及其解决方案。有两个特殊字符用于定义 URI 的<path>部分。它们是一个点和两个点。点表示当前的路径层次。两个点表示路径层次中的上一级。您一定见过在文件系统中使用这两组字符来表示当前目录和父目录。您可以用同样的方式思考它们在 URI 中的含义,但是 URI 并不假定任何目录层次结构。在 URI 中,路径被认为是分层的,它根本不依赖于文件系统的分层结构。然而,在实践中,当您使用基于 web 的应用程序时,URL 通常被映射到一个文件系统层次结构。在 URI 的规范化形式中,点会被适当替换。比如s://sn/a/./b归一化为s://sn/a/bs://sn/a/../b归一化为s://sn/b。非规范化和规范化形式指的是同一个 URL。规范化格式移除了多余的字符。只看这两个 URIs,你不能说它们指的是同一个资源。在比较它们是否相等之前,必须对它们进行规范化。在比较过程中,方案、服务器名称和十六进制数字不区分大小写。以下是解决相对 URI 的一些规则:

  • 如果一个 URI 以一个计划开始,它被认为是一个绝对的 URI。

  • 如果一个相对 URI 以一个权威开始,它从它的基础 URI 继承一个方案。

  • 如果一个相对 URI 是一个空字符串,它与基 URI 相同。

  • 如果相对 URI 只有片段部分,则解析后的 URI 将使用新片段。如果一个 URI 有一个片段,它将被相对 URI 的片段所取代。否则,将相对 URI 的片段添加到基本 URI 中。

  • 相对 URI 的路径不是以正斜杠(/)开始的。如果基本 URI 有路径,则删除基本 URI 中路径的最后一个组件,并附加相对 URI。注意,路径的最后一部分可能是一个空字符串,如 http://www.abc.com/

  • 如果相对 URL 以路径开头,而路径又以正斜杠(/)开头,则基本 URI 路径将被相对 URI 路径替换。

表 8-5 包含使用这些规则的例子。表中的示例符合 Java URI 和 URL 类中遵循的规则。Java 规则在某些情况下与 URI 规范中的规则略有不同。

表 8-5

使用基本 URI 将相对 URI 解析为绝对 URI 的示例

|

基本 URI

|

相对 URI

|

已解析的 URI 亲戚

|

相对 URI 的描述

|
| --- | --- | --- | --- |
| h://sn/a/b/c | http://sn2/fooh://sn2/foo | 这绝对是 URI。 |   |
| h://sn/a/b/c | //sn2/h/k``h://sn2/h/k | 它始于一个权威。 |   |
| h://sn/a/b/c |   | h://sn/a/b/c | 它是一个空字符串。 |
| h://sn/a/b/c | #k | h://sn/a/b/c#k | 它只包含一个片段。 |
| h://sn/a/b/c#a | #k | h://sn/a/b/c#k | 它只包含一个片段。 |
| h://sn/a/b/ | 富(中国姓氏) | h://sn/a/b/foo | 路径不是以/开头。 |
| h://sn/a/b/c | 富(中国姓氏) | h://sn/a/b/foo | 路径不是以/开头。 |
| h://sn/a/b/c?d=3 | 富(中国姓氏) | h://sn/a/b/foo | 路径不是以/开头。 |
| h://sn/ | 富(中国姓氏) | h://sn/foo | 路径不是以/开头。 |
| h://sn | 富(中国姓氏) | h://sn/foo | 路径不是以/开头。 |
| h://sn/a/b/ | /foo | h://sn/foo | 路径以/开头。 |
| h://sn/a/b/c | /foo | h://sn/foo | 路径以/开头。 |
| h://sn/a/b/c?d=3 | /foo | h://sn/foo | 路径以/开头。 |
| h://sn/ | /foo | h://sn/foo | 路径以/开头。 |
| h://sn/ | /foo | h://sn/foo | 路径以/开头。 |

Note

您也可以使用主机名或 IP 地址作为 URI 中的授权机构。IPv4 可以使用点分十进制格式,如 http://192.168.10.178/docs/toc.html IPv6 必须用括号括起来,如http://[1283::8:800:200C:A43A]/docs/toc.html

作为 Java 对象的 URI 和 URL

Java 将 URIs 和 URL 表示为对象。它提供了以下四个类,您可以使用它们在 Java 程序中将 URIs 和 URL 作为对象进行处理:

  • java.net.URI

  • java.net.URL

  • java.net.URLEncoder

  • java.net.URLDecoder

一个URI类的对象代表一个 URI。URL类的一个对象代表一个 URL。URLEncoderURLDecoder是帮助编码和解码 URI 字符串的实用程序类。在下一节中,我将介绍其他 Java 类,它们用于检索由 URL 标识的资源。

URI类有许多构造函数,允许您从 URI 的各个部分(方案、权限、路径、查询和片段)的组合中创建一个URI对象。如果你用来构造一个URI对象的字符串可能不符合 URI 规范,所有的构造函数都会抛出一个检查过的异常URISyntaxException

// Create a URI object
URI baseURI = new URI("http://www.yahoo.com");
// Create a URI with relative URI string and resolve it
// using baseURI
URI relativeURI = new URI("welcome.html");
URI resolvedRelativeURI = baseURI.resolve(relativeURI);

清单 8-10 展示了如何在 Java 程序中使用URI类。

// URITest.java
package com.jdojo.net;
import java.net.URI;
import java.net.URISyntaxException;
public class URITest {
    public static void main(String[] args) {
        String baseURIStr =
            "http://www.jdojo.com/javaintro.html?"
            + "id=25&rate=5.5%25#foo";
        String relativeURIStr = "../sports/welcome.html";
        try {
            URI baseURI = new URI(baseURIStr);
            URI relativeURI = new URI(relativeURIStr);
            // Resolve the relative URI with respect to
            // the base URI
            URI resolvedURI = baseURI.resolve(relativeURI);
            printURIDetails(baseURI);
            printURIDetails(relativeURI);
            printURIDetails(resolvedURI);
        } catch (URISyntaxException e) {
            e.printStackTrace();
        }
    } 

    public static void printURIDetails(URI uri) {
        System.out.println("URI:" + uri);
        System.out.println("Normalized:"
                + uri.normalize());
        String parts = "[Scheme=" + uri.getScheme()
                + ", Authority=" + uri.getAuthority()
                + ", Path=" + uri.getPath()
                + ", Query:" + uri.getQuery()
                + ", Fragment:" + uri.getFragment()
                + "]";
        System.out.println(parts);
        System.out.println();
    }
}

URI:http://www.jdojo.com/javaintro.html?
    id=25&rate=5.5%25#foo
Normalized:http://www.jdojo.com/javaintro.html?
    id=25&rate=5.5%25#foo
[Scheme=http,
  Authority=www.jdojo.com,
  Path=/javaintro.html,
  Query:id=25&rate=5.5%,
Fragment:foo]
URI:../sports/welcome.html
Normalized:../sports/welcome.html
[
  Scheme=null,
  Authority=null,
  Path=../sports/welcome.html,
  Query:null,
  Fragment:null
]
URI:http://www.jdojo.com/../sports/welcome.html
Normalized:http://www.jdojo.com/../sports/welcome.html
[
  Scheme=http,
  Authority=www.jdojo.com,
  Path=/../sports/welcome.html,
  Query:null,
  Fragment:null
]

Listing 8-10A Sample Class That Demonstrates the Use of the java.net.URI Class

您也可以使用toURL()方法从URI对象获取URL对象,如下所示:

URL baseURL = baseURI.toURL();

您还可以使用 URI 类的create(String str)静态方法创建一个URI对象。create()方法不会抛出一个检查过的异常。它抛出一个运行时异常。因此,它的使用不会强制您处理异常。只有当您知道 URI 字符串是格式良好的时,才应该使用此方法:

URI uri2 = URI.create("http://www.yahoo.com");

java.net.URL类的一个实例代表 Java 程序中的一个 URL。虽然每个 URL 也是一个 URI,但是 Java 并没有从URI类继承URL类。Java 使用术语协议来指代 URI 规范中的方案部分。您可以通过提供一个将 URL 的所有部分连接在一起的字符串或通过单独提供这些部分来创建 URL 对象。如果您为创建 URL 对象提供的字符串无效,URL 类的构造函数将抛出一个MalformedURLException checked 异常。

清单 8-11 演示了如何创建一个URL对象。URL类允许您使用它的一个构造函数从一个相对 URL 和一个基本 URL 创建一个绝对 URL。

// URLTest.java
package com.jdojo.net;
import java.net.URL;
public class URLTest {
    public static void main(String[] args) {
        String baseURLStr =
            "http://www.ietf.org/rfc/rfc3986.txt";
        String relativeURLStr = "rfc2732.txt";
        try {
            URL baseURL = new URL(baseURLStr);
            URL resolvedRelativeURL =
                new URL(baseURL, relativeURLStr);
            System.out.println(
                "Base URL:" + baseURL);
            System.out.println(
                "Relative URL String:" +
                relativeURLStr);
            System.out.println(
                "Resolved Relative URL:" +
                resolvedRelativeURL);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Base URL:http://www.ietf.org/rfc/rfc3986.txt
Relative URL String:rfc2732.txt
Resolved Relative URL:http://www.ietf.org/rfc/rfc2732.txt

Listing 8-11A Sample Class That Demonstrates the Use of the java.net.URL Class

通常,创建一个URL对象来检索由 URL 标识的资源。请注意,只要 URL 的文本格式良好,并且处理 URL 的协议可用,就可以创建一个URL类的对象。在 Java 程序中成功创建一个URL对象并不保证在 URL 中指定的服务器上存在该资源。URL类提供了一些方法,您可以结合其他类使用这些方法来检索由 URL 标识的资源。

URL类确保它能够处理 URL 字符串中指定的协议。例如,它不会让你创建一个带有字符串 ppp://www.sss.com/URL对象,除非你为一个名为ppp的协议开发并提供一个协议处理程序。我将在下一节介绍如何检索由 URL 标识的资源。

有时,您事先不知道 URL 字符串的各个部分。您在运行时从程序的其他部分或用户那里获得 URL 的各个部分作为输入。在这种情况下,您需要对 URL 的各个部分进行编码,然后才能使用它们来创建一个URL对象。有时,你得到一个编码形式的字符串,你希望它被解码。编码后的字符串将所有受限字符正确转义。

URLEncoderURLDecoder类分别用于编码和解码字符串。URLEncoder.encode(String source, String encoding) static方法用于使用指定的编码对源字符串进行编码。URLDecoder.decode(String source, String encoding) static方法用于使用指定的编码对源字符串进行解码。以下代码片段显示了如何编码/解码字符串。通常,在 URL 的查询部分对名称-值对的值部分进行编码/解码。请注意,不要试图对整个 URL 字符串进行编码。否则,它将对一些保留字符(如正斜杠)进行编码,结果 URL 字符串将无效。

String source = "this is a test for 2.5% and &" ;
String encoded = URLEncoder.encode(source, "utf-8");
String decoded = URLDecoder.decode(encoded, "utf-8");
System.out.println("Source: " + source);
System.out.println("Encoded: " + encoded);
System.out.println("Decoded: " + decoded);
Source: this is a test for 2.5% and &
Encoded: this+is+a+test+for+2.5%25+and+%26
Decoded: this is a test for 2.5% and &

访问 URL 的内容

URL 有一个协议,用于与托管 URL 内容的远程应用程序进行通信。例如,URL http://www.yahoo.com/index.html 使用的是http协议。在 URL 中,您可以指定协议套件中应用层使用的协议。当您需要访问 URL 的内容时,计算机将使用协议组中较低层的某种协议(传输层、互联网层等)。)与远程主机通信。http应用层协议在较低层使用TCP/IP协议。在分布式应用程序中,经常需要检索(或读取)由 URL 标识的资源(可以是文本、html 内容、图像文件、音频/视频文件或任何其他类型的信息)。虽然每次需要读取 URL 的内容时都可以打开一个套接字,但对于程序员来说,这既费时又麻烦。毕竟,程序员需要一些方法来提高效率,而不是为看似例行公事的工作编写重复的代码。Java 设计者意识到了这种需求,他们提供了一种非常简单(是的,非常简单)的方法来从/向 URL 读取/写入数据。本节探索了从非常简单到非常复杂的从/向 URL 读取/写入数据的一些方法。

当数据在协议组中从一层传递到另一层时,每一层都会向数据添加一个报头。由于 URL 使用应用层的协议,所以它也包含自己的头。报头的格式取决于所使用的协议。当http请求被发送到远程主机时,源主机中的应用层向数据添加http报头。远程主机有一个处理http协议的应用层,它使用报头信息来解释内容。总之,一个 URL 数据将有两个部分:标题部分和内容部分。URL类和其他一些类可以让你读/写 URL 的头部和内容部分。我从读取 URL 内容这个最简单的例子开始。

在读取/写入 URL 之前,您需要有一个可以访问的工作 URL。您可以阅读互联网上公开的任何 URL 的内容。对于这个讨论,我使用了一个位于 www.httpbin.org/ 的网站,它提供了几个用于测试的 URL。该网站提供了几个用于测试目的的端点。请访问该网站获取完整的端点列表。表 8-6 包含两个您将在本节示例中使用的端点。

表 8-6

示例中使用的 www.httpbin.org 处的有用端点

|

统一资源定位器

|

描述

|
| --- | --- |
| http://www.httpbin.org/get | 接受 HTTP GET请求,并以 JSON 格式返回传递给该 URL 的参数。如果您将值为 1969 的 year 参数传递给这个端点,您的 URL 将如下所示: http://www.httpbin.org/get?y ear=1069 |
| http://www.httpbin.org/post | 接受 HTTP POST请求,并以 JSON 格式返回传递给该 URL 的相同的POST数据。 |

通过编写如下所示的两行代码,URL类允许您读取 URL 的内容(不是标题):

URL url = new URL("your URL string goes here");
InputStream ins = url.openStream();

清单 8-12 包含读取 URL http://httpbin.org/get?year=1969内容的完整程序。输出显示服务器返回了在args对象中传递的GET参数(年份=1969)。如果你想使用POST方法发送一个请求到一个 URL,你将需要使用URLConnection类,我将在下面解释。为了更好的可读性,我对输出进行了格式化。

// SimpleURLContentReader.java
package com.jdojo.net;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URL;
public class SimpleURLContentReader {
    public static void main(String[] args) {
        String urlStr = "http://httpbin.org/get?year=1969";
        String content = getURLContent(urlStr);
        System.out.println(content);
    }
    public static String getURLContent(String urlStr) {
        BufferedReader br = null;
        try {
            URL url = new URL(urlStr);
            // Get the input stream wrapped into a
            // BufferedReader
            br = new BufferedReader(
                new InputStreamReader(
                    url.openStream()));
            StringBuilder sb = new StringBuilder();
            String msg = null;
            while ((msg = br.readLine()) != null) {
                sb.append(msg);
                sb.append("\n");
                // <- Append a new line
            }
            return sb.toString();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (br != null) {
                try {
                    br.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        } 

        // If we get here it means there was an error
        return null;
    }
}

{
  "args": {
    "year": "1969"
  },
  "headers": {
    "Accept": "text/html, image/gif,
        image/jpeg, *; q=.2, */*; q=.2",
    "Connection": "close",
    "Host": "httpbin.org",
    "User-Agent": "Java/9"
  },
  "origin": "50.58.251.82",
  "url": "http://httpbin.org/get?year=1969"
}

Listing 8-12A Simple URL Content Reader Program

一旦获得输入流,就可以用它来读取 URL 的内容。读取 URL 内容的另一种方式是使用URL类的getContent()方法。因为getContent()可以返回任何类型的内容,所以它的返回类型是Object类型。在使用对象的内容之前,您需要检查它返回哪种对象。例如,它可能返回一个InputStream对象,在这种情况下,您将需要从输入流中读取数据。以下是getContent()方法的两个版本:

  • final Object getContent() throws IOException

  • final Object getContent(Class[] classes) throws IOException

方法的第二个版本允许您传递类类型的数组。它将尝试将内容对象转换为您按指定顺序传递给它的某个类。如果内容对象不匹配任何类型,它将返回null。您仍然需要编写if语句来知道从getContent()方法返回的是什么类型的对象,如下所示:

URL baseURL = new URL ("your url string goes here");
Class[] c = new Class[] {
    String.class,
    BufferedReader.class,
    InputStream.class
};
Object content = baseURL.getContent(c);
if (content == null) {
    // Contents are not of any of the three kinds
} else if (content instanceof String) {
    // You got a string
} else if (content instanceof BufferedReader) {
    // You got a reader
} else if (content instanceof InputStream) {
    // You got an input stream
}

如果您使用openStream()getContent()方法读取 URL 的内容,那么URL类会处理许多在内部使用套接字的复杂性。这种方法的缺点是您无法控制连接设置。您不能使用这种方法将数据写入 URL。此外,您无权访问 URL 中使用的协议的标头信息。不要绝望;Java 提供了另一个名为URLConnection的类,让您以一种简单明了的方式完成这些工作。

URLConnection是抽象类,不能直接创建它的对象。你需要使用 URL 对象的openConnection()方法来获得一个URLConnection对象。URL类将处理URLConnection对象的创建,该对象适合于处理 URL 中使用的协议的数据。下面的代码片段展示了如何使用一个URLConnection对象来读取和写入数据到一个 URL:

URL url = new URL("your URL string goes here");
// Get a connection object
URLConnection connection = url.openConnection();
// Indicate that you will be writing to the connection
connection.setDoOutput(true);
// Get output/input streams to write/read data
OutputStream ous = connection.getOutputStream();
InputStream ins = connection.getInputStream();
// <- Caution. Read below

URL类的openConnection()方法返回一个URLConnection对象,它还没有连接到 URL 源。在连接之前,您必须为该对象设置所有与连接相关的参数。例如,如果要将数据写入 URL,必须在连接之前调用连接对象上的setDoOutput(true)方法。当你调用一个URLConnection对象的connect()方法时,它就会被连接起来。但是,当您调用需要连接的方法时,它是隐式连接的。例如,向 URL 写入数据并读取 URL 的数据或标题字段将自动连接URLConnection对象,如果它还没有连接的话。

如果你想避免在使用URLConnection读写 URL 数据时出现问题,你必须遵循以下几点:

  • 当您只从 URL 读取数据时,您可以使用它的getInputStream()方法获得输入流。使用输入流读取数据。它将使用一个GET方法向远程主机发出请求。也就是说,如果要向 URL 传递一些参数,必须通过向 URL 添加查询部分来实现。

  • 如果你在从一个 URL 中写入和读取数据,你必须在连接前调用setDoOutput(true)。在开始读取数据之前,必须先将数据写入 URL。将数据写入 URL 会将请求方法更改为POST。在将数据写入 URL 之前,您甚至无法获得输入流。事实上,getInputStream()方法向远程主机发送一个请求。您的目的是将数据发送到远程主机,并从远程主机读取响应。这一次变得非常棘手。这里有更多的解释,使用一段代码,假设connection是一个URLConnection对象:

  • 使用getInputStream()方法和使用任何方法(如getHeaderField(String headerName))读取标题字段具有相同的效果。URL 的服务器提供标题和内容。一个URLConnection必须发出请求才能得到它们。

// Incorrect – 1\. Get input and output streams
// you must get the output stream first
InputStream ins = connection.getInputStream();
OutputStream ous = connection.getOutputStream();
// Incorrect – 2\. Get output and input streams
// you must get the output stream and finish writing
// before you should get the input stream
OutputStream ous = connection.getOutputStream();
InputStream ins = connection.getInputStream();
// Correct. Get output stream and get done with it.
// And, then get the input stream and read data.
OutputStream ous = connection.getOutputStream();
// Write logic to write data using ous object here.
// Make sure you are done writing data before you
// call the getInputStream() method as shown below
InputStream ins = connection.getInputStream();
// Write logic to read data

清单 8-13 包含向/从 http://www.httpbin/post URL 写入/读取数据的完整代码。

// URLConnectionReaderWriter.java
package com.jdojo.net;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.UnsupportedEncodingException;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLEncoder;
import java.util.Map;
public class URLConnectionReaderWriter {
    public static String
    getURLContent(String urlStr, String input) {
        BufferedReader br = null;
        BufferedWriter bw = null;
        try {
           URL url = new URL(urlStr);
           URLConnection connection =
               url.openConnection();
           // Must call setDoOutput(true) to indicate
           // that you will write to the connection. By
           // default, it is false.
           // By default, setDoInput() is set to true.
           connection.setDoOutput(true);
           // Now, connect to the remote object
           connection.connect();
           // Write data to the URL first before reading
           // the response 

           OutputStream ous = connection.getOutputStream();
           bw = new BufferedWriter(
               new OutputStreamWriter(ous));
           bw.write(input);
           bw.flush();
           bw.close();
           // Must be placed after writing the data.
           // Otherwise, it will result in error, because
           // if write is performed, read must be performed
           // after the write.
           printRequestHeaders(connection);
           InputStream ins = connection.getInputStream();
           // Wrap the input stream into a reader
           br = new BufferedReader(
               new InputStreamReader(ins));
           StringBuilder sb = new StringBuilder();
           String msg = null;
           while ((msg = br.readLine()) != null) {
                sb.append(msg);
                sb.append("\n");
                // <- Append a new line
           }
           return sb.toString();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (br != null) {
                try {
                    br.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            } 

        }
        // If we arrive here it means there was an error
        return null;
    }
    public static void
    printRequestHeaders(URLConnection connection) {
        Map headers = connection.getHeaderFields();
        System.out.println("Request Headers are:");
        System.out.println(headers);
        System.out.println();
    }
    public static void main(String[] args) {
        // Change the URL to point to the echo_params.jsp
        // page on your web server
        String urlStr = "http://www.httpbin.org/post";
        String query = null;
        try {
            // Encode the query. We need to encode only
            // the value of the name parameter. Other
            // names and values are fine
            query = "id=789&name=" +
                URLEncoder.encode("John & Co.", "utf-8");
            // Get the content and display it on the console
            String content = getURLContent(urlStr, query);
            System.out.println(
                "Returned data from the server is:");
            System.out.println(content);
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
    }
}

Request Headers are:
{

  null=[HTTP/1.1 200 OK],
  X-Processed-Time=[0.000935077667236],
  Server=[meinheld/0.6.1],
  Access-Control-Allow-Origin=[*],
  Access-Control-Allow-Credentials=[true],
  Connection=[keep-alive],
  Content-Length=[462],
  Date=[Wed, 03 Jan 2018 19:37:10 GMT],
  Via=[1.1 vegur],
  X-Powered-By=[Flask],
  Content-Type=[application/json]
}
Returned data from the server is:
{
  "args": {},
  "data": "",
  "files": {},
  "form": {
    "id": "789",
    "name": "John & Co."
  },
  "headers": {
    "Accept": "text/html, image/gif, image/jpeg, *;
               q=.2, */*; q=.2",
    "Connection": "close",
    "Content-Length": "24",
    "Content-Type": "application/x-www-form-urlencoded",
    "Host": "www.httpbin.org",
    "User-Agent": "Java/9"
  },
  "json": null,
  "origin": "50.58.251.82",
  "url": "http://www.httpbin.org/post"
}

Listing 8-13A URL Reader/Writer Class That Writes/Reads Data to/from a URL

这一次,您将使用POST方法向 URL 发送数据。注意,您发送的数据已经使用URLEncoder类进行了编码。您只需要对名称字段的值进行编码,即"John & Co.",因为值中的&符号将与查询字符串中的名称-值对分隔符冲突。如果你改变任何语句的顺序,程序有大量的注释来警告你任何危险。

该程序打印出一个java.util.Map对象中返回的所有标题的信息。URLConnection类提供了几种获取头字段值的方法。对于常用的头,它提供了一个直接的方法。例如,名为getContentLength()getContentType()getContentEncoding()的方法返回头字段的值,分别表示 URL 内容的长度、类型和编码。如果您知道标题字段名或它的索引,您可以使用getHeaderField(String headerName)getHeaderField(int headerIndex)方法来获取它的值。getHeaderFields()方法返回一个Map对象,它的键代表标题字段名称,值代表标题字段值。读取标题字段时要小心,因为它对URLConnection对象的影响与读取内容相同。如果您希望将数据写入 URL,您必须先写入数据,然后才能读取标题字段。

Java 允许您使用jar协议读取 JAR 文件的内容。假设您有一个名为myclasses.jar的 JAR 文件,其中有一个路径为myfolder/Abc.class的类文件。您可以从 URL 获取一个JarURLConnection,并使用它的方法来访问 JAR 文件数据。请注意,您只能从 URL 读取 JAR 文件内容。您不能写入 JAR 文件 URL。下面的代码片段显示了如何获得一个JarURLConnection对象。您将需要使用它的方法来获取特定于 JAR 的数据:

String str =
    "jar:http://www.abc.com/myclasses.jar!/myfolder/" +
    "Abc.class";
URL url = new URL(str);
JarURLConnection connection = (JarURLConnection)
    url.openConnection();
// Use the connection object to access any jar related
// data.

Note

在本节中,您已经读到了许多关于使用 URLConnection 对象的警告。还有一个:URLConnection 对象只能用于一个请求。它基于“获得-使用-丢弃”的概念。如果您希望多次从 URL 写入或读取数据,则每次都必须单独调用 URL 的 openConnection()。

非阻塞套接字编程

在前面的章节中,我解释了 TCP 和 UDP 套接字。SocketServerSocket类的connect()accept()read()write()方法阻塞,直到操作完成。例如,如果客户端套接字的线程调用read()方法从服务器读取数据,直到数据可用,它就会被阻塞。如果您可以在客户端套接字上调用read()方法并开始做其他事情,直到来自服务器的数据到达,这不是很好吗?当从服务器获得数据时,将通知客户机套接字,客户机套接字将在适当的时间读取数据。套接字编程面临的另一个大问题是服务器应用程序的可伸缩性。在前面的小节中,我建议您需要创建一个新的线程来处理每个客户端连接,或者您将有一个线程池来处理所有的客户端连接。无论哪种方式,你都将在你的程序中创建和维护一堆线程。如果您不必在一个服务器程序中处理线程来处理多个客户端,那不是很好吗?非阻塞套接字通道提供了所有这些好的特性。一如既往,一个好的特性有一个与之相关的价格标签;非阻塞套接字通道也是如此。这需要一点学习过程。你习惯于编程,事情按顺序发生。有了非阻塞的套接字通道,你将需要改变在程序中执行事情的思维方式。改变心态需要时间。你的程序将会执行多项不会按顺序执行的任务。如果您是第一次学习 Java,您可以跳过这一节,以后当您在编写复杂的 Java 程序方面获得更多经验时再来看它。

假设您已经很好地理解了使用ServerSocketSocket类的套接字编程。进一步假设您对 Java 中使用缓冲区和通道的新输入/输出有基本的了解。本节使用了一些包含在java.niojava.nio.channelsjava.nio.charset包中的类。

让我们从比较阻塞和非阻塞套接字通信中涉及的类开始。表 8-7 列出了阻塞和非阻塞套接字应用中使用的主要类。

表 8-7

阻塞和非阻塞套接字编程中涉及的类的比较

|

用于阻止基于套接字的通信的类

|

在基于非阻塞套接字的通信中使用的类

|
| --- | --- |
| ServerSocket | ServerSocketChannel``ServerSocket类仍然存在于幕后。 |
| Socket | SocketChannel``Socket类仍然存在于幕后。 |
| InputStream``OutputStream | 不存在相应的类。SocketChannel用于读取/写入数据。 |
| 不存在相应的类。 | Selector |
| 不存在相应的类。 | SelectionKey |

您将使用一个ServerSocketChannel对象主要是为了在服务器中接受一个新的连接请求,而不是使用一个ServerSocketServerSocket并没有消失。它仍在幕后运作。如果需要内部使用的ServerSocket对象的引用,可以通过使用ServerSocketChannel对象的socket()方法来获取。你可以把一个ServerSocketChannel对象看作是一个ServerSocket对象的包装器。

您将使用一个SocketChannel来代替一个Socket在客户端和服务器之间进行通信。一个Socket物体仍在幕后发挥作用。您可以使用SocketChannel类的socket()方法来获取Socket对象的引用。你可以把一个SocketChannel对象看作是一个Socket对象的包装器。

在我开始讨论非阻塞套接字为您提供更高效、更可伸缩的应用程序接口所使用的机制之前,看一个真实的例子会有所帮助。让我们讨论一下在快餐店点餐和上菜的方式。假设餐馆在任何时候都期望最多十个顾客,最少零个顾客。一位顾客来到餐厅,下了订单,然后就可以享用食物了。那家餐馆应该雇用多少服务员?在最好的情况下,它可能只使用一台服务器来处理接收来自所有客户的订单并为他们提供食物。在最坏的情况下,它可能有十台服务器—一台服务器留给一个客户。在后一种情况下,如果餐厅只有三个客户,那么将有七个服务器处于空闲状态。

让我们在餐馆管理中走中间道路。让我们在厨房有几个服务器做饭,在柜台有一个服务器接收订单。顾客来到柜台,向服务员下订单,顾客得到订单 ID,顾客离开柜台,柜台的服务员将订单传递给厨房的一个服务员,服务员开始接受下一个顾客的订单。此时,顾客可以在准备订单的同时自由地做其他事情。柜台的服务员正在接待其他顾客。厨房里的服务员正忙着根据订单准备食物。没有人在等谁。一旦订单中的食品准备好,柜台的服务员就会从厨房的服务员那里收到食品,并呼叫订单号,这样下订单的顾客就可以取走他们的食品。顾客可以分多次购买食物。他们可以在厨房准备他们点的菜时吃他们点的食物。这种建筑是餐馆中最高效的建筑。它让每个人大部分时间都很忙,并有效地利用了资源。这是非阻塞套接字通道遵循的方法。

另一种方法是,客户进来,下订单,等待订单完成并送达,然后下一个客户下订单,依此类推。这是阻塞套接字遵循的方法。如果您了解快餐店为有效利用资源所采取的方法,您就可以很容易地理解非阻塞套接字通道。在下面的讨论中,我将餐馆示例中使用的人与非阻塞套接字中使用的对象进行了比较。

我们先来讨论一下服务器端的情况。服务器端是你的餐厅。柜台上与所有顾客打交道的人被称为“选择者”。选择器是Selector类的一个对象。它唯一的工作就是与外界互动。它位于与服务器交互的远程客户端和服务器内部的事物之间。远程客户机从不与在服务器内部工作的对象交互,就像餐馆里的顾客从不与厨房里的服务器直接交互一样。图 8-7 显示了非阻塞套接字通道通信的架构。它显示了选择器在体系结构中的位置。

img/323070_3_En_8_Fig7_HTML.jpg

图 8-7

非阻塞客户机-服务器套接字的体系结构

不能使用选择器对象的构造函数直接创建选择器对象。您需要调用它的open()静态方法来获得一个选择器对象,如下所示:

// Get a selector object
Selector selector = Selector.open();

ServerSocketChannel用于监听来自客户端的新连接请求。同样,您不能使用其构造函数创建新的ServerSocketChannel对象。你需要调用它的open()静态方法如图所示:

// Get a server socket channel
ServerSocketChannel ssChannel = ServerSocketChannel.open();

默认情况下,服务器套接字通道或套接字通道是阻塞通道。您需要对其进行配置,使其成为非阻塞通道,如下所示:

// Configure the server socket channel to be non-blocking
ssChannel.configureBlocking(false);

您的服务器套接字通道需要绑定到一个本地 IP 地址和一个本地端口号,以便远程客户端可以联系它以获得新的连接。您使用其bind()方法绑定服务器套接字通道,该方法是ServerSocketChannelSocketChannel类的一部分:

InetAddress hostIPAddress =
    InetAddress.getByName("localhost");
int port = 19000;
ssChannel.bind(new InetSocketAddress(hostIPAddress, port));

现在迈出了最重要的一步。服务器套接字必须向选择器注册自己,以显示对某种操作的兴趣。这就像餐馆里的比萨饼师傅让柜台的服务员知道他们已经准备好为顾客做比萨饼了,并且他们需要在订购比萨饼时得到通知。有四种操作可以在选择器中注册通道。它们被定义为表 8-8 中列出的SelectionKey类中的整数常量。

表 8-8

选择器识别的操作

|

操作类型

|

值(SelectionKey 类中的常数)

|

谁可以注册这个操作

|

描述

|
| --- | --- | --- | --- |
| Connect | OP _ 连接 | 客户端的套接字通道 | 选择器将通知连接操作的进度。 |
| Accept | OP _ 接受 | 服务器上的 ServerSocketChannel | 当客户端请求新连接时,选择器会发出通知。 |
| Read | 操作 _ 读取 | 客户端和服务器端的套接字通道 | 当通道准备好读取某些数据时,选择器会发出通知。 |
| Write | OP_WRITE | 客户端和服务器端的套接字通道 | 当通道准备好写入一些数据时,选择器将发出通知。 |

A ServerSocketChannel只监听接受新的客户端连接请求,因此它只能注册一个操作,如下所示:

// Register the server socket channel with the selector
// for accept operation
ssChannel.register(selector, SelectionKey.OP_ACCEPT);

ServerSocketChannelregister()方法返回一个类型为SelectionKey的对象。您可以将这个对象视为带有选择器的注册证书。如果以后需要使用这个 key 对象,可以将它存储在一个变量中。该示例忽略了这一点。选择器有您的密钥(注册详细信息)的副本,并将在将来使用它来通知您您的通道已准备好的任何操作。

此时,您的选择器已经准备好拦截客户机连接的传入请求,并将其传递给服务器套接字通道。假设此时有一个客户端试图连接到服务器套接字通道。选择器和服务器套接字通道之间的交互是如何发生的?当选择器检测到它有一个已注册的键,并准备好进行操作时,它将该键(SelectionKey类的对象)放在一个单独的组中,称为就绪集。一个java.util.Set对象代表一个就绪集合。您可以通过调用Selector对象的select()方法来确定处于就绪状态的键的数量:

// Get the key count in the ready set
int readyCount = selector.select();

一旦你在就绪集合中得到至少一个就绪键,你就需要得到这个键并查看细节。您可以从就绪集合中获取所有就绪密钥,如下所示:

// Get the set of ready keys
Set readySet = selector.selectedKeys();

请注意,您为一个或多个操作注册了一个密钥。您需要查看特定操作的关键细节。如果一个键准备好接受新的连接请求,它的isAcceptable()方法将返回true。如果一个键准备好进行连接操作,它的isConnectable()方法将返回true。如果一个键准备好进行读写操作,它的isReadable()isWritable()方法将返回true。您可能会发现有一种方法可以检查每种操作类型的准备情况。当您处理就绪集合时,您还需要从就绪集合中移除密钥。下面是在服务器应用程序中处理就绪集的一些典型代码。在服务器应用程序中,无限循环是很典型的,因为一旦完成了当前的就绪集,就需要继续寻找下一个就绪集:

while(true) {
    // Get the count of keys in the ready set. If ready
    // key count is greater than zero, process each key
    // in the ready set.
}

以下代码片段显示了可用于处理就绪集中所有键的典型逻辑:

SelectionKey key = null;
Iterator iterator = readySet.iterator();
while (iterator.hasNext()) {
    // Get the next ready selection key object
    key = (SelectionKey)iterator.next();
    // Remove the key from ready set
    iterator.remove();
    // Process the key according to the operation
    if (key.isAcceptable()) {
        // Process new connection
    }
    if (key.isReadable()) {
        // Read from the channel
    }
    if (key.isWritable()) {
        // Write to the channel
    }
}

如何在服务器套接字通道上接受来自远程客户端的连接请求?逻辑类似于使用ServerSocket对象接受远程连接请求。一个SelectionKey对象引用了注册它的ServerSocketChannel。您可以使用channel()方法访问SelectionKey对象的ServerSocketChannel对象。您需要调用ServerSocketChannel对象上的accept()方法来接受新的连接请求。accept()方法返回SocketChannel类的一个对象,用于与远程客户端通信(读写)。您需要将新的SocketChannel对象配置为非阻塞套接字通道。您需要理解的最重要的一点是,新的SocketChannel对象必须向选择器注册自己的读、写或两种操作,以开始在连接通道上读/写数据。以下代码片段显示了接受远程连接请求的逻辑:

ServerSocketChannel ssChannel =
    (ServerSocketChannel)key.channel();
SocketChannel sChannel = (SocketChannel)ssChannel.accept();
sChannel.configureBlocking(false);
// Register only for read. Your message is small and you
// write it back to the client as soon as you read it.
sChannel.register(key.selector(), SelectionKey.OP_READ);

如果您希望向选择器注册套接字通道以进行读取和写入,您可以如下所示进行操作:

// Register for read and write
sChannel.register(key.selector(),
    SelectionKey.OP_READ | SelectionKey.OP_WRITE);

一旦您的套接字通道向选择器注册,当它从远程客户端接收到任何数据时,或者当您可以在其通道上向远程客户端写入数据时,它将通过选择器的就绪集得到通知。

如果数据在套接字通道上变得可用,key.isReadable()将为该套接字通道返回true。典型的读取操作如下所示。要使用通道和缓冲区读取数据,您必须对 Java NIO(新输入/输出)有基本的了解。

SocketChannel sChannel = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
int bytesCount = sChannel.read(buffer);
String msg = "";
if (bytesCount > 0) {
    buffer.flip();
    Charset charset = Charset.forName("UTF-8");
    CharsetDecoder decoder = charset.newDecoder();
    CharBuffer charBuffer = decoder.decode(buffer);
    msg = charBuffer.toString();
    System.out.println("Received Message: " + msg);
}

如果您可以写入通道,选择器会将相关的键放入其就绪集合中,其isWritable()方法将返回true。同样,您需要理解 Java NIO 来使用ByteBuffer对象在通道上写数据。

SocketChannel sChannel = (SocketChannel)key.channel();
String msg =
    "message to be sent to remote client goes here";
ByteBuffer buffer = ByteBuffer.wrap(msg.getBytes());
sChannel.write(buffer);

客户端发生的事情很容易理解。首先获得一个选择器对象,然后通过调用SocketChannel.open()方法获得一个SocketChannel对象。此时,您需要在连接到服务器之前将套接字通道配置为非阻塞的。现在,您已经准备好向选择器注册您的套接字通道了。通常,您向选择器注册连接、读取和写入操作。处理选择器就绪集的方式与在服务器应用程序中处理选择器就绪集的方式相同。读取和写入通道的代码类似于服务器端代码。以下代码片段显示了客户端应用程序中使用的典型逻辑:

InetAddress serverIPAddress =
    InetAddress.getByName("localhost");
int port = 19000;
InetSocketAddress serverAddress =
    new InetSocketAddress(serverIPAddress, port);
// Get a selector
Selector selector = Selector.open();
// Create and configure a client socket channel
SocketChannel channel = SocketChannel.open();
channel.configureBlocking(false);
// Connect to the server
channel.connect(serverAddress);
// Register the channel for connect, read and write
// operations
int operations = SelectionKey.OP_CONNECT |
    SelectionKey.OP_READ |
    SelectionKey.OP_WRITE;
channel.register(selector, operations);
// Process the ready set of the selector here

当您在客户端SocketChannel上获得一个连接操作时,它可能意味着成功或失败的连接。您可以在SocketChannel对象上调用finishConnect()方法来完成连接过程。如果连接失败,finishConnect()调用将抛出一个IOException。通常,您可以按如下方式处理连接操作:

if (key.isConnectable()) {
    try {
        // Call to finishConnect() is in a loop as it is
        // non-blocking for your channel
        while(channel.isConnectionPending()) {
            channel.finishConnect();
        }
    } catch (IOException e) {
        // Cancel the channel's registration with the
        // selector
        key.cancel();
        e.printStackTrace();
    }
}

是时候使用这些通道构建 echo 客户端应用程序和 echo 服务器应用程序了。清单 8-14 和 8-15 分别包含 echo 服务器和 echo 客户端的非阻塞套接字通道的完整代码。

您需要首先运行NonBlockingEchoServer类,然后运行NonBlockingEchoClient类的一个或多个实例。它们的工作方式类似于另外两个 echo 客户机-服务器程序。请注意,这一次,在客户端应用程序中输入消息后,您可能看不到来自服务器的消息。客户端应用程序向服务器发送一条消息,它不等待消息被回显。相反,当套接字通道收到来自选择器的通知时,它会处理服务器消息。因此,有可能同时从服务器获得两条消息的回显。为了保持代码的简单性和可读性,这些例子中省略了异常处理。

// NonBlockingEchoClient.java
package com.jdojo.net;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.util.Iterator;
import java.util.Set;
public class NonBlockingEchoClient {
    private static BufferedReader userInputReader = null;
    public static void main(String[] args)
            throws Exception {
        InetAddress serverIPAddress =
            InetAddress.getByName("localhost");
        int port = 19000;
        InetSocketAddress serverAddress =
            new InetSocketAddress(serverIPAddress, port);
        // Get a selector
        Selector selector = Selector.open();
        // Create and configure a client socket
        // channelHello
        try (SocketChannel channel =
                SocketChannel.open()) {
            channel.configureBlocking(false);
            channel.connect(serverAddress);
            // Register the channel for connect, read and
            // write operations
            int operations =
                SelectionKey.OP_CONNECT |
                SelectionKey.OP_READ |
                SelectionKey.OP_WRITE;
            channel.register(selector, operations);
            userInputReader =
                new BufferedReader(
                    new InputStreamReader(System.in));
            while (true) {
                if (selector.select() > 0) {
                    boolean doneStatus =
                        processReadySet(
                            selector.selectedKeys());
                    if (doneStatus) { 

                        break;
                    }
                }
            }
        }
    } 

    public static boolean
    processReadySet(Set readySet) throws Exception {
        SelectionKey key = null;
        Iterator iterator = null;
        iterator = readySet.iterator();
        while (iterator.hasNext()) {
            // Get the next ready selection key object
            key = (SelectionKey) iterator.next();
            // Remove the key from the ready key set
            iterator.remove();
            if (key.isConnectable()) {
                boolean connected = processConnect(key);
                if (!connected) {
                    return true; // Exit
                }
            }
            if (key.isReadable()) {
                String msg = processRead(key);
                System.out.println("[Server]: " + msg);
            }
            if (key.isWritable()) {
                String msg = getUserInput();
                if (msg.equalsIgnoreCase("bye")) {
                    return true; // Exit
                }
                processWrite(key, msg);
            }
        }
        return false; // Not done yet
    }
    public static boolean
    processConnect(SelectionKey key) {
        SocketChannel channel =
            (SocketChannel) key.channel();
        try { 

            // Call the finishConnect() in a loop as it is
            // non-blocking for your channel
            while (channel.isConnectionPending()) {
                channel.finishConnect();
            }
        } catch (IOException e) {
            // Cancel the channel's registration with the
            // selector
            key.cancel();
            e.printStackTrace();
            return false;
        }
        return true;
    }
    public static String
    processRead(SelectionKey key) throws Exception {
        SocketChannel sChannel =
            (SocketChannel) key.channel();
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        sChannel.read(buffer);
        buffer.flip();
        Charset charset = Charset.forName("UTF-8");
        CharsetDecoder decoder = charset.newDecoder();
        CharBuffer charBuffer = decoder.decode(buffer);
        String msg = charBuffer.toString();
        return msg;
    }
    public static void
    processWrite(SelectionKey key, String msg)
    throws IOException {
        SocketChannel sChannel =
            (SocketChannel) key.channel();
        ByteBuffer buffer =
            ByteBuffer.wrap(msg.getBytes());
        sChannel.write(buffer);
    }
    public static String
    getUserInput() throws IOException {
        String promptMsg =
            "Please enter a message(Bye to quit): ";
        System.out.print(promptMsg);
        String userMsg = userInputReader.readLine();
        return userMsg;
    }
}

Listing 8-15A Non-blocking Socket Channel Echo Client Program

// NonBlockingEchoServer.java
package com.jdojo.net;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.util.Iterator;
import java.util.Set;
public class NonBlockingEchoServer {
    public static void main(String[] args)
            throws Exception {
        InetAddress hostIPAddress =
            InetAddress.getByName("localhost");
        int port = 19000;
        // Get a selector
        Selector selector = Selector.open();
        // Get a server socket channel
        ServerSocketChannel ssChannel =
            ServerSocketChannel.open();
        // Make the server socket channel non-blocking
        // and bind it to an address
        ssChannel.configureBlocking(false);
        ssChannel.socket().bind(
            new InetSocketAddress(hostIPAddress, port));
        // Register a socket server channel with the
        // selector for accept operation, so that it can
        // be notified when a new connection request
        // arrives
        ssChannel.register(selector,
            SelectionKey.OP_ACCEPT);
        // Now we will keep waiting in a loop for any kind
        // of request that arrives to the server -
        // connection, read, or write request. If a
        // connection request comes in, we will accept the
        // request and register a new socket channel with
        // the selector for read and write operations. If
        // read or write requests come in, we will forward
        // that request to the registered channel.
        while (true) {
            if (selector.select() <= 0) {
                continue;
            }
            processReadySet(selector.selectedKeys());
        }
    } 

    public static void
    processReadySet(Set readySet) throws Exception {
        SelectionKey key = null;
        Iterator iterator = null;
        iterator = readySet.iterator();
        while (iterator.hasNext()) {
            // Get the next ready selection key object
            key = (SelectionKey) iterator.next();
            // Remove the key from the ready key set
            iterator.remove();
            // Process the key according to the operation
            // it is ready for
            if (key.isAcceptable()) {
                processAccept(key);
            }
            if (key.isReadable()) {
                String msg = processRead(key);
                if (msg.length() > 0) {
                    echoMsg(key, msg);
                }
            }
        }
    } 

    public static void
    processAccept(SelectionKey key) throws IOException {
        // This method call indicates that we got a new
        // connection request. Accept the connection
        // request and register the new socket channel
        // with the selector, so that client can
        // communicate on a new channel
        ServerSocketChannel ssChannel =
            (ServerSocketChannel)key.channel();
        SocketChannel sChannel =
            (SocketChannel) ssChannel.accept();
        sChannel.configureBlocking(false);
        // Register only for read. Our message is small
        // and we write it back to the client as soon
        // as we read it
        sChannel.register(key.selector(),
            SelectionKey.OP_READ);
    }
    public static String
    processRead(SelectionKey key) throws Exception {
        SocketChannel sChannel =
            (SocketChannel) key.channel();
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        int bytesCount = sChannel.read(buffer);
        String msg = "";
        if (bytesCount > 0) {
            buffer.flip();
            Charset charset = Charset.forName("UTF-8");
            CharsetDecoder decoder = charset.newDecoder();
            CharBuffer charBuffer = decoder.decode(buffer);
            msg = charBuffer.toString();
            System.out.println("Received Message: " + msg);
        }
        return msg;
    }
    public static void
    echoMsg(SelectionKey key, String msg)
    throws IOException {
        SocketChannel sChannel =
            (SocketChannel) key.channel();
        ByteBuffer buffer =
            ByteBuffer.wrap(msg.getBytes());
        sChannel.write(buffer);
    }
}

Listing 8-14A Non-blocking Socket Channel Echo Server Program

套接字安全权限

您可以使用java.net.SocketPermission类的实例来控制 Java 程序使用套接字的访问。用于在 Java 策略文件中授予套接字权限的通用格式如下:

grant {
    permission java.net.SocketPermission
        "target", "actions";
};

目标的形式是<host name>:<port range>。动作的可能值是acceptconnectlistenresolve

只有当“localhost”用作主机名时,listen动作才有意义。resolve动作指的是 DNS 查找,如果其他三个动作中的任何一个存在,它就是隐含的。

主机名可以是 DNS 名称或 IP 地址。您可以在 DNS 主机名中使用星号(*)作为通配符。如果使用星号,它必须用作 DNS 名称中最左边的字符。如果主机名只包含一个星号,则表示任何主机。主机名的“localhost”指的是本地机器。如表 8-9 所述,您可以用不同的格式表示主机名的端口范围。这里,N1 和 N2 表示端口号(0 到 65535),并且假设 N1 小于 N2。表 8-9 列出了用于指示端口范围的格式。

表 8-9

java.net.SocketPermission 安全设置的格式

|

端口范围值

|

描述

|
| --- | --- |
| N1 | 只有一个端口号——N1 |
| N1-N2 | 从 N1 到 N2 的端口号 |
| N1- | N1 和更大地区的端口号 |
| N1 | N1 及以下国家的端口号 |

以下是在 Java 策略文件中使用java.net.SocketPermission的例子:

// Grant to all codebase
grant {
    // Permission to connect with 192.168.10.123
    // at port 5000
    permission java.net.SocketPermission
        "192.168.10.123:5000", "connect";
    // Connect permission to any host at port 80
    permission java.net.SocketPermission
        "*:80", "connect";
    // All socket permissions to on port >= 1024
    // on the localhost
    permission java.net.SocketPermission
        "localhost:1024-", "listen, accept, connect";
};

异步套接字通道

Java 支持异步套接字操作,如连接、读取和写入。异步套接字操作使用以下两个套接字通道类来执行:

  • java.nio.channels.AsynchronousServerSocketChannel

  • java.nio.channels.AsynchronousSocketChannel

一个AsynchronousServerSocketChannel作为一个服务器套接字来监听新的客户端连接。一旦它接受了一个新的客户机连接,客户机和服务器之间的交互就由两端的一个AsynchronousSocketChannel处理。异步套接字通道的设置与同步套接字非常相似。这两种设置的主要区别在于,异步套接字操作的请求会立即返回,并在操作完成时通知请求者,而在同步套接字操作中,套接字操作的请求会一直阻塞,直到操作完成。由于异步套接字通道操作的异步特性,处理套接字操作完成或失败的代码有点复杂。

在异步套接字通道中,使用异步套接字通道类的方法之一请求操作。该方法立即返回。稍后您会收到操作完成或失败的通知。允许您请求异步操作的方法被重载。一个版本返回一个Future对象,让您检查所请求操作的状态。这些方法的另一个版本允许您传递一个CompletionHandler。当请求的操作成功完成时,调用CompletionHandlercompleted()方法。当请求的操作失败时,调用CompletionHandlerfailed()方法。下面的代码片段演示了处理请求的异步套接字操作的完成/失败的两种方法。它显示了服务器套接字通道如何异步接受客户端连接:

/* Using a Future Object */
// Get a server socket channel instance
AsynchronousServerSocketChannel server =
   ... /* get a server instance */;
// Bind the socket to a host and a port
server.bind(your_host, your_port);
// Start accepting a new client connection. Note that the
// accept() method returns immediately by returning a
// Future object
Future<AsynchronousSocketChannel> result = server.accept();
// Wait for the new client connection by calling the get()
// method of the Future object. Alternatively, you can poll
// the Future object periodically using its isDone() method
AsynchronousSocketChannel newClient = result.get();
// Handle the newClient here and call the server.accept()
// again to accept another client connection

/* Using a CompletionHandler Object */
// Get a server socket channel instance
AsynchronousServerSocketChannel server =
   ... /* get a server instance */;
// Bind the socket to a host and a port
server.bind(your_host, your_port);
// Start accepting a new client connection. The accept()
// method returns immediately. The completed() or failed()
// method of the ConnectionHandler will be called upon
// completion or failure of the requested operation
YourAnyClass attach = ...; // Get an attachment
server.accept(attach, new ConnectionHandler());

这个版本的accept()方法接受任何类的对象作为附件。它可能是一个null参考。附件被传递给完成处理程序的completed()failed()方法,在本例中完成处理程序是ConnectionHandler的一个对象。ConnectionHandler类可能如下所示:

private static class ConnectionHandler
    implements CompletionHandler
               <AsynchronousSocketChannel, YourAnyClass> {
    @Override
    public void
    completed(AsynchronousSocketChannel client,
            YourAnyClass attach) {
        // Handle the new client connection here and again
        // start accepting a new client connection
    }
    @Override
    public void
    failed(Throwable e, YourAnyClass attach) {
        // Handle the failure here
    }
}

在这一节中,我将详细介绍以下三个步骤。在讨论过程中,我构建了一个由 echo 服务器和客户机组成的应用程序。客户端将向服务器异步发送消息,服务器将消息异步回显给客户端。假设您熟悉使用缓冲区和通道。

  • 设置异步服务器套接字通道

  • 设置异步客户端套接字通道

  • 将异步服务器和客户端套接字通道投入使用

设置异步服务器套接字通道

AsynchronousServerSocketChannel类的一个实例被用作异步服务器套接字通道来监听新的客户端连接。一旦建立了到客户机的连接,就使用AsynchronousSocketChannel类的一个实例与客户机通信。AsynchronousServerSocketChannel类的静态open()方法返回一个AsynchronousServerSocketChannel类的对象,该对象尚未绑定:

// Create an asynchronous server socket channel object
AsynchronousServerSocketChannel server =
    AsynchronousServerSocketChannel.open();
// Bind the server to the localhost and the port 8989
String host = "localhost";
int port = 8989;
InetSocketAddress sAddr =
    new InetSocketAddress(host, port);
server.bind(sAddr);

此时,您的服务器套接字通道可以通过调用它的accept()方法来接受新的客户端连接,如下所示。代码使用了两个类,AttachmentConnectionHandler,这将在后面描述。

// Prepare the attachment
Attachment attach = new Attachment();
attach.server = server;
// Accept new connections
server.accept(attach, new ConnectionHandler());

通常,服务器应用程序会无限期运行。您可以通过在main()方法中等待主线程来使服务器应用程序永远运行,如下所示:

try {
    // Wait indefinitely until someone interrupts the
    // main thread
    Thread.currentThread().join();
} catch (InterruptedException e) {
    e.printStackTrace();
}

您将使用完成处理程序机制来处理服务器套接字通道的完成/失败通知。下面的Attachment类的一个对象将被用作完成处理程序的附件。附件对象用于传递服务器套接字的上下文,该服务器套接字可能在完成处理程序的completed()failed()方法中使用:

class Attachment {
    AsynchronousServerSocketChannel server;
    AsynchronousSocketChannel client;
    ByteBuffer buffer;
    SocketAddress clientAddr;
    boolean isRead;
}

您需要一个CompletionHandler实现来处理一个accept()调用的完成。让我们称你的班级为ConnectionHandler,如图所示:

private static class ConnectionHandler
    implements CompletionHandler
               <AsynchronousSocketChannel, Attachment> {
    @Override
    public void
    completed(AsynchronousSocketChannel client,
            Attachment attach) {
        try {
            // Get the client address
            SocketAddress clientAddr =
                client.getRemoteAddress();
            System.out.format(
                "Accepted a connection from %s%n",
                clientAddr);
            // Accept another connection
            attach.server.accept(attach, this);
            // Handle the client connection by invoking an
            // asyn read
            Attachment newAttach = new Attachment();
            newAttach.server = attach.server;
            newAttach.client = client;
            newAttach.buffer = ByteBuffer.allocate(2048);
            newAttach.isRead = true;
            newAttach.clientAddr = clientAddr;
            // Create a new completion handler for reading
            // to and writing from the new client
            ReadWriteHandler readWriteHandler =
                new ReadWriteHandler();
            // Read from the client
            client.read(newAttach.buffer, newAttach,
                readWriteHandler);
        } catch (IOException e) {
            e.printStackTrace();
        }
    } 

    @Override
    public void failed(Throwable e, Attachment attach) {
        System.out.println(
            "Failed to accept a connection.");
        e.printStackTrace();
    }
}

ConnectionHandler类很简单。在其failed()方法中,它打印异常堆栈跟踪。在它的completed()方法中,它打印一个新的客户端连接已经建立的消息,并通过再次调用服务器套接字上的accept()方法开始监听另一个新的客户端连接。注意附件在另一个completed()方法内部的accept()方法调用中的重用。它再次使用同一个CompletionHandler对象。注意,attach.server.accept(attach, this)方法调用使用关键字this来引用完成处理程序的同一个实例。最后,它准备了一个Attachment类的新实例,该实例包装了处理(读取和写入)新客户端连接的细节,并在客户端套接字上调用read()方法来从客户端读取。注意,read()方法使用了另一个完成处理程序,它是ReadWriteHandler类的一个实例。ReadWriteHandler的代码如下:

private static class ReadWriteHandler
        implements CompletionHandler<Integer, Attachment> {
    @Override
    public void
    completed(Integer result, Attachment attach) {
        if (result == -1) {
            try {
                attach.client.close();
                System.out.format(
                    "Stopped listening to the client %s%n",
                    attach.clientAddr);
            } catch (IOException ex) {
                ex.printStackTrace();
            }
            return;
        }
        if (attach.isRead) {
            // A read to the client was completed
            // Get the buffer ready to read from it
            attach.buffer.flip();
            int limits = attach.buffer.limit();
            byte bytes[] = new byte[limits];
            attach.buffer.get(bytes, 0, limits);
            Charset cs = Charset.forName("UTF-8");
            String msg = new String(bytes, cs);
            // Print the message from the client
            System.out.format(
                "Client at %s says: %s%n",
                attach.clientAddr, msg);
            // Let us echo back the same message to the
            // client
            attach.isRead = false; // It is a write
            // Prepare the buffer to be read again
            attach.buffer.rewind();
            // Write to the client again
            attach.client.write(attach.buffer,
                attach, this);
        } else {
            // A write to the client was completed.
            // Perform another read from the client.
            attach.isRead = true;
            // Prepare the buffer to be filled in
            attach.buffer.clear();
            // Perform a read from the client
            attach.client.read(attach.buffer, attach,
                this);
        }
    }
    @Override
    public void
    failed(Throwable e, Attachment attach) {
        e.printStackTrace();
    }
}

completed()方法的第一个参数result是从客户端读取或写入客户端的字节数。它的值–1 表示流的结束,在这种情况下,客户端套接字关闭。如果读操作完成,它会在标准输出上显示读取的文本,并将相同的文本写回客户端。如果对客户端的写操作已完成,它会在同一客户端上执行读操作。

清单 8-16 包含了异步服务器套接字通道的完整代码。它使用三个内部类:一个用于附件,一个用于连接完成处理程序,一个用于读/写完成处理程序。现在可以运行AsyncEchoServerSocket类了。但是,它不会做任何工作,因为它需要一个客户端连接到它,以回显从客户端发送的消息。在下一节中,您将开发您的异步客户机套接字通道,然后,在下一节中,您将一起测试服务器和客户机套接字通道。

// AsyncEchoServerSocket.java
package com.jdojo.net;
import java.io.IOException;
import java.net.SocketAddress;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.net.InetSocketAddress;
import java.nio.channels.CompletionHandler;
import java.nio.channels.AsynchronousSocketChannel;
import java.nio.channels.AsynchronousServerSocketChannel;
public class AsyncEchoServerSocket {
    private static class Attachment {
        AsynchronousServerSocketChannel server;
        AsynchronousSocketChannel client;
        ByteBuffer buffer;
        SocketAddress clientAddr;
        boolean isRead;
    }
    private static class ConnectionHandler implements
            CompletionHandler
            <AsynchronousSocketChannel, Attachment> {
        @Override
        public void
        completed(AsynchronousSocketChannel client,
                Attachment attach) {
            try {
                // Get the client address
                SocketAddress clientAddr = client.
                    getRemoteAddress();
                System.out.format(
                    "Accepted a connection from %s%n",
                    clientAddr);
                // Accept another connection
                attach.server.accept(attach, this);
                // Handle the client connection by using
                // an asyn read
                ReadWriteHandler rwHandler =
                    new ReadWriteHandler();
                Attachment newAttach = new Attachment();
                newAttach.server = attach.server;
                newAttach.client = client;
                newAttach.buffer = ByteBuffer.
                    allocate(2048);
                newAttach.isRead = true;
                newAttach.clientAddr = clientAddr;
                client.read(newAttach.buffer, newAttach,
                    rwHandler);
            } catch (IOException e) {
                e.printStackTrace();
            }
        } 

        @Override
        public void
        failed(Throwable e, Attachment attach) {
            System.out.println(
                "Failed to accept a connection.");
            e.printStackTrace();
        }
    }
    private static class ReadWriteHandler
            implements CompletionHandler
            <Integer, Attachment> {
        @Override
        public void
        completed(Integer result, Attachment attach) {
            if (result == -1) {
                try {
                    attach.client.close();
                    System.out.format(
                        "Stopped listening to the" +
                        " client %s%n",
                        attach.clientAddr);
                } catch (IOException ex) {
                    ex.printStackTrace();
                }
                return;
            }
            if (attach.isRead) {
                // A read to the client was completed.
                // Get the buffer ready to read from it
                attach.buffer.flip();
                int limits = attach.buffer.limit();
                byte bytes[] = new byte[limits];
                attach.buffer.get(bytes, 0, limits);
                Charset cs = Charset.forName("UTF-8");
                String msg = new String(bytes, cs);
                // Print the message from the client
                System.out.format(
                    "Client at %s says: %s%n",
                    attach.clientAddr, msg);
                // Let us echo back the same message to
                // the client
                attach.isRead = false; // It is a write
                // Prepare the buffer to be read again
                attach.buffer.rewind();
                // Write to the client
                attach.client.write(attach.buffer,
                    attach, this);
            } else { 

                // A write to the client was completed.
                // Perform another read.
                attach.isRead = true;
                // Prepare the buffer to be filled in
                attach.buffer.clear();
                // Perform a read from the client
                attach.client.read(attach.buffer,
                    attach, this);
            }
        }
        @Override
        public void
        failed(Throwable e, Attachment attach) {
            e.printStackTrace();
        }
    } 

    public static void main(String[] args) {
        try (AsynchronousServerSocketChannel server
                = AsynchronousServerSocketChannel.open()) {
            // Bind the server to the localhost and the
            // port 8989
            String host = "localhost";
            int port = 8989;
            InetSocketAddress sAddr
                    = new InetSocketAddress(host, port);
            server.bind(sAddr);
            // Display a message that server is ready
            System.out.format(
                "Server is listening at %s%n", sAddr);
            // Prepare the attachment
            Attachment attach = new Attachment();
            attach.server = server;
            // Accept new connections
            server.accept(attach,
                new ConnectionHandler());
            try {
                // Wait until the main thread is
                // interrupted
                Thread.currentThread().join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Listing 8-16A Server Application That Uses an Asynchronous Server Socket Channel

设置异步客户端套接字通道

在客户端应用程序中,AsynchronousSocketChannel类的一个实例被用作异步客户端套接字通道。AsynchronousSocketChannel类的静态open()方法返回一个AsynchronousSocketChannel类型的开放通道,该通道尚未连接到服务器套接字通道。通道的connect()方法用于连接到服务器套接字通道。下面的代码片段显示了如何创建异步客户端套接字通道并将其连接到服务器套接字通道。它使用一个Future对象来完成与服务器的连接:

// Create an asynchronous socket channel
AsynchronousSocketChannel channel =
    AsynchronousSocketChannel.open();
// Connect the channel to the server
String serverName = "localhost";
int serverPort = 8989;
SocketAddress serverAddr =
    new InetSocketAddress(serverName, serverPort);
Future<Void> result = channel.connect(serverAddr);
System.out.println("Connecting to the server...");
// Wait for the connection to complete
result.get();
// Connection to the server is complete now
System.out.println("Connected to the server...");

一旦客户端套接字通道连接到服务器,您就可以开始使用通道的read()write()方法异步地从服务器读取数据和向服务器写入数据。这两种方法都允许您使用一个Future对象或一个CompletionHandler对象来处理操作的完成。您将使用如图所示的Attachment类将上下文传递给完成处理程序:

class Attachment {
    AsynchronousSocketChannel channel;
    ByteBuffer buffer;
    Thread mainThread;
    boolean isRead;
}

Attachment类中,channel实例变量保存对客户端通道的引用。buffer实例变量保存对数据缓冲区的引用。您将使用相同的数据缓冲区进行读取和写入。mainThread实例变量保存对应用程序主线程的引用。当客户端通道完成时,您可以中断正在等待的主线程,这样客户端应用程序就终止了。isRead实例变量表示操作是读还是写。如果是true,表示是读操作。否则,它是一个写操作。

清单 8-17 包含了异步客户端套接字通道的完整代码。它使用了两个名为AttachmentReadWriteHandler的内部类。一个Attachment类的实例被用作read()write()异步操作的附件。ReadWriteHandler类的一个实例被用作read()write()操作的完成处理器。它的getTextFromUser()方法提示用户在标准输入上输入消息,并返回用户输入的消息。完成处理程序的completed()方法检查它是读操作还是写操作。如果是读取操作,它会在标准输出中打印从服务器读取的文本。它会提示用户输入另一条消息。如果用户输入Bye,它通过中断正在等待的主线程来终止应用程序。注意,当程序退出try程序块时,通道自动关闭,因为它是在main()方法的try-with-resources程序块内打开的。

// AsyncEchoClientSocket.java
package com.jdojo.net;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.util.concurrent.Future;
import java.nio.channels.CompletionHandler;
import java.util.concurrent.ExecutionException;
import java.nio.channels.AsynchronousSocketChannel;
public class AsyncEchoClientSocket {
    private static class Attachment {
        AsynchronousSocketChannel channel;
        ByteBuffer buffer;
        Thread mainThread;
        boolean isRead;
    }
    private static class ReadWriteHandler
        implements CompletionHandler<Integer, Attachment>
    {
        @Override
        public void
        completed(Integer result, Attachment attach) {
            if (attach.isRead) {
                attach.buffer.flip();
                // Get the text read from the server
                Charset cs = Charset.forName("UTF-8");
                int limits = attach.buffer.limit();
                byte bytes[] = new byte[limits];
                attach.buffer.get(bytes, 0, limits);
                String msg = new String(bytes, cs);
                // A read from the server was completed
                System.out.format(
                    "Server Responded: %s%n", msg);
                // Prompt the user for another message
                msg = this.getTextFromUser();
                if (msg.equalsIgnoreCase("bye")) {
                    // Interrupt the main thread, so the
                    // program terminates
                    attach.mainThread.interrupt();
                    return;
                } 

                // Prepare buffer to be filled in again
                attach.buffer.clear();
                byte[] data = msg.getBytes(cs);
                attach.buffer.put(data);
                // Prepared buffer to be read
                attach.buffer.flip();
                attach.isRead = false; // It is a write
                // Write to the server
                attach.channel.write(
                    attach.buffer, attach, this);
            } else {
                // A write to the server was completed.
                // Perform another read from the server
                attach.isRead = true;
                // Prepare the buffer to be filled in
                attach.buffer.clear();
                // Read from the server
                attach.channel.read(attach.buffer,
                    attach, this);
            }
        }
        @Override
        public void
        failed(Throwable e, Attachment attach) {
            e.printStackTrace();
        }
        private String getTextFromUser() {
            System.out.print(
                "Please enter a message (Bye to quit):");
            String msg = null;
            BufferedReader consoleReader =
                new BufferedReader(
                    new InputStreamReader(System.in));
            try { 

                msg = consoleReader.readLine();
            } catch (IOException e) {
                e.printStackTrace();
            }
            return msg;
        }
    }
    public static void main(String[] args) {
        // Use a try-with-resources to open a channel
        try (AsynchronousSocketChannel channel =
                AsynchronousSocketChannel.open()) {
            // Connect the client to the server
            String serverName = "localhost";
            int serverPort = 8989;
            SocketAddress serverAddr =
                new InetSocketAddress(serverName,
                    serverPort);
            Future<Void> result = channel.
                connect(serverAddr);
            System.out.println(
                "Connecting to the server...");
            // Wait for the connection to complete
            result.get();
            // Connection to the server is complete now
            System.out.println(
                "Connected to the server...");
            // Start reading from and writing to the server
            Attachment attach = new Attachment();
            attach.channel = channel;
            attach.buffer = ByteBuffer.allocate(2048);
            attach.isRead = false;
            attach.mainThread = Thread.currentThread();
            // Place the "Hello" message in the buffer
            Charset cs = Charset.forName("UTF-8");
            String msg = "Hello";
            byte[] data = msg.getBytes(cs);
            attach.buffer.put(data);
            attach.buffer.flip();
            // Write to the server
            ReadWriteHandler readWriteHandler =
                new ReadWriteHandler();
            channel.write(attach.buffer, attach,
                readWriteHandler);
            // Let this thread wait for ever on its own
            // death until interrupted
            attach.mainThread.join();
        } catch (ExecutionException | IOException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            System.out.println(
                "Disconnected from the server.");
        }
    }
}

Listing 8-17An Asynchronous Client Socket Channel

将服务器和客户端放在一起

此时,您的异步服务器和客户端程序已经准备好了。您需要使用以下步骤来运行服务器和客户端。

运行清单 8-16 中列出的AsyncEchoServerSocket类。您应该会在标准输出中得到如下消息:

Server is listening at localhost/127.0.0.1:8989

如果您收到此消息,您需要继续下一步。如果您没有收到此消息,很可能是端口 8989 正在被另一个进程使用。在这种情况下,您应该会收到以下错误消息:

java.net.BindException: Address already in use: bind

如果您得到了"Address already in use"错误消息,您需要将AsyncEchoServerSocket类中的端口值从 8989 更改为其他值,并重试运行AsyncEchoServerSocket类。如果在服务器程序中更改端口号,还必须在客户端程序中更改端口号以匹配服务器端口号。服务器套接字通道在一个端口上侦听,客户端必须连接到服务器侦听的同一个端口。

在继续此步骤之前,请确保您能够成功执行上一步。运行清单 8-17 中列出的AsyncEchoClientSocket类的一个或多个实例。如果客户端应用程序能够成功连接到服务器,您应该会在标准输出中看到以下消息:

Connecting to the server...
Connected to the server...
Server Responded: Hello
Please enter a message (Bye to quit):

当您尝试运行AsyncEchoClientSocket类时,可能会收到以下错误消息:

Connecting to the server...
java.util.concurrent.ExecutionException:
    java.io.IOException: The remote system refused
the network connection.

通常,此错误消息指示以下问题之一:

  • 服务器没有运行。如果是这种情况,请确保服务器正在运行。

  • 客户端正试图连接到与服务器侦听的主机和端口不同的主机和端口上的服务器。如果是这种情况,请确保服务器和客户端使用相同的主机名(或 IP 地址)和端口号。

您需要手动停止服务器程序,例如在 Windows 的命令提示符下按 Ctrl+C 键。

面向数据报的套接字通道

java.nio.channels.DatagramChannel类的一个实例代表一个数据报通道。默认情况下,它是阻止的。您可以使用configureBlocking(false)方法将其配置为非阻塞的。

要创建一个DatagramChannel,您需要调用它的一个open()静态方法。如果您想将其用于 IP 多播,您需要指定多播组的地址类型(或协议族)作为其open()方法的参数。open()方法创建了一个没有连接的DatagramChannel。如果您希望您的数据报通道只向特定的远程主机发送和接收数据报,您需要使用它的connect()方法将通道连接到那个特定的主机。未连接的数据报通道可以向任何远程主机发送数据报,也可以从任何远程主机接收数据报。以下部分概述了使用数据报通道发送/接收数据报通常需要的步骤。

创建数据报通道

您可以使用DatagramChannel类的open()方法创建数据报通道。以下代码片段显示了创建数据报通道的三种不同方式:

// Create a new datagram channel to send/receive datagram
DatagramChannel channel = DatagramChannel.open();
// Create a datagram channel to receive datagrams from a
// multicast group
// that uses IPv4 address type
DatagramChannel ipv4MulticastChannel =
    DatagramChannel.open(StandardProtocolFamily.INET);
// Create a datagram channel to receive datagrams from a
// multicast group that uses IPv6 address type
DatagramChannel iPv6MulticastChannel =
    DatagramChannel.open(StandardProtocolFamily.INET6);

设置频道选项

您可以使用DatagramChannel类的setOption()方法设置通道选项。有些选项必须在将通道绑定到特定地址之前设置,而有些可以在绑定之后设置。下面的代码片段显示了如何设置通道选项。套接字选项在StandardSocketOptions类中被定义为常量。关于所有类型的套接字都支持的套接字选项的完整列表,请参考StandardSocketOptions类的 Javadoc。表 8-10 包含插座选项列表,其描述由DatagramChannel支持。

表 8-10

标准插座选项

|

套接字选项名称

|

描述

|
| --- | --- |
| SO_SNDBUF | 套接字发送缓冲区的大小,以字节为单位。它的值属于Integer类型。 |
| SO_RCVBUF | 套接字接收缓冲区的大小,以字节为单位。它的值属于Integer类型。 |
| SO_REUSEADDR | 对于数据报套接字,它允许多个程序绑定到同一个地址。它的值属于Boolean类型。应该为使用数据报通道的 IP 多播启用此选项。 |
| SO_BROADCAST | 允许传输广播数据报。它的值属于类型Boolean。 |
| IP_TOS | 互联网协议(IP)报头中的服务类型(ToS)八位字节。它的值属于Integer类型。 |
| IP_MULTICAST_IF | 网际协议(IP)多播数据报的网络接口。其值是一个NetworkInterface类型的引用。 |
| IP_MULTICAST_TTL | 网际协议(IP)多播数据报的生存时间。其值属于类型Integer,范围为 0 到 255。 |
| IP_MULTICAST_LOOP | 网际协议(IP)多播数据报的环回。它的值属于类型Boolean。 |

SocketServerSocketDatagramSocket类中有三种方法setOption()getOption()supportedOptions()。这些方法允许您设置套接字选项,查询套接字选项的值,并通过套接字获得一组受支持的套接字选项。有关如何使用这些方法的更多详细信息,请参考这些类的 Javadoc。

要将多个套接字绑定到同一个套接字地址,需要为套接字设置SO_REUSEADDR选项,如下所示:

channel.setOption(StandardSocketOptions.SO_REUSEADDR, true)

使用DatagramChannel类的bind()方法将数据报通道绑定到特定的本地地址和端口。如果使用null作为绑定地址,这个方法会自动将套接字绑定到一个可用的地址。以下代码片段显示了如何绑定数据报通道:

// Bind the channel to any available address automatically
channel.bind(null);
// Bind the channel to "localhost" and port 8989
InetSocketAddress sAddr =
    new InetSocketAddress("localhost", 8989);
channel.bind(sAddr);

发送数据报

要向远程主机发送数据报,请使用DatagramChannel类的send()方法。该方法接受一个ByteBuffer和一个远程SocketAddress。如果在未绑定的数据报通道上调用send()方法,send()方法会自动将通道绑定到一个可用的地址:

// Prepare a message to send
String msg = "Hello";
ByteBuffer buffer = ByteBuffer.wrap(msg.getBytes());
// Pack the remote address and port into an object
InetSocketAddress serverAddress =
    new InetSocketAddress("localhost", 8989);
// Send the message to the remote host
channel.send(buffer, serverAddress);

DatagramChannel类的receive()方法让数据报通道从远程主机接收数据报。这种方法要求你提供一个ByteBuffer来接收数据。接收到的数据被复制到指定的ByteBuffer的当前位置。如果ByteBuffer的可用空间少于接收到的数据,多余的数据将被自动丢弃。receive()方法返回远程主机的地址。如果数据报通道处于非阻塞模式,receive()方法通过返回null立即返回。否则,它会一直等待,直到收到数据报。

// Prepare a ByteBufer to receive data
ByteBuffer buffer = ByteBuffer.allocate(1024);
// Wait to receive data from a remote host
SocketAddress remoteAddress = channel.receive(buffer);

最后,使用其close()方法关闭数据报通道:

// Close the channel
channel.close();

清单 8-18 包含了一个充当 echo 服务器的程序。清单 8-19 有一个充当客户端的程序。echo 服务器等待来自远程客户端的消息。它回显从远程客户端接收的消息。在启动客户端程序之前,您需要启动 echo 服务器程序。您可以同时运行多个客户端程序。显示了客户端和服务器程序的示例输出。

// DGCEchoClient.java
package com.jdojo.net;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.DatagramChannel;
public class DGCEchoClient {
    public static void main(String[] args) {
        // Create a new datagram channel
        try (DatagramChannel client =
                DatagramChannel.open()) {
            // Bind the client to any available local
            // address and port
            client.bind(null);
            // Prepare a message for the server
            String msg = "Hello";
            ByteBuffer buffer =
                ByteBuffer.wrap(msg.getBytes());
            InetSocketAddress serverAddress =
                new InetSocketAddress("localhost", 8989);
            // Send the message to the server
            client.send(buffer, serverAddress);
            // Reuse the buffer to receive a response from
            // the server
            buffer.clear();
            // Wait for the server to respond
            client.receive(buffer);
            // Prepare the buffer to read the message
            buffer.flip();
            // Convert the buffer into a string
            int limits = buffer.limit();
            byte bytes[] = new byte[limits];
            buffer.get(bytes, 0, limits);
            String response = new String(bytes);
            // Print the server message on the standard
            // output
            System.out.println("Server responded: " +
                response);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Server responded: Hello

Listing 8-19A Client Program Based on the Datagram Channel

// DGCEchoServer.java
package com.jdojo.net;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.DatagramChannel;
public class DGCEchoServer {
    public static void main(String[] args) {
        // Create a datagram channel and bind it to
        // localhost at port 8989
        try (DatagramChannel server =
                DatagramChannel.open()) {
            InetSocketAddress sAddr =
                new InetSocketAddress("localhost", 8989);
            server.bind(sAddr);
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            // Wait in an infinite loop for a client to
            // send data
            while (true) {
                System.out.println(
                    "Waiting for a message from"
                    + " a remote host at " + sAddr);
                // Wait for a client to send a message
                SocketAddress remoteAddr =
                    server.receive(buffer);
                // Prepare the buffer to read the message
                buffer.flip();
                // Convert the buffer data into a String
                int limits = buffer.limit();
                byte bytes[] = new byte[limits];
                buffer.get(bytes, 0, limits);
                String msg = new String(bytes);
                System.out.println(
                    "Client at " + remoteAddr +
                    " says: " + msg);
                // Reuse the buffer to echo the message to
                // the client
                buffer.rewind();
                // Send the message back to the client
                server.send(buffer, remoteAddr);
                // Prepare the buffer to receive the next
                // message
                buffer.clear();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Waiting for a message from a remote host
    at localhost/127.0.0.1:8989
Client at /127.0.0.1:62644 says: Hello
Waiting for a message from a remote host
    at localhost/127.0.0.1:8989

Listing 8-18An Echo Server Based on the Datagram Channel

使用数据报信道的多播

Java 支持使用数据报通道的 IP 多播。对接收多播数据报感兴趣的数据报通道加入多播组。发送到多播组的数据报被传递给其所有成员。以下部分概述了设置对接收多播数据报感兴趣的客户端应用程序通常需要的步骤。

创建数据报通道

创建数据报通道以使用特定的多播地址类型,如下所示。在您的应用程序中,您将使用 IPv4 或 IPv6,而不是两者都使用。

// Need to use INET protocol family for an IPv4 addressing
// scheme
DatagramChannel client =
    DatagramChannel.open(StandardProtocolFamily.INET);

// Need to use INET6 protocol family for an IPv6
// addressing scheme
DatagramChannel client =
    DatagramChannel.open(StandardProtocolFamily.INET6);

设置频道选项

使用setOption()方法设置客户端通道的选项,如下所示:

// Let other sockets reuse the same address
client.setOption(StandardSocketOptions.SO_REUSEADDR, true);

绑定频道

将客户端通道绑定到本地地址和端口,如下所示:

int MULTICAST_PORT = 8989;
client.bind(new InetSocketAddress(MULTICAST_PORT));

设置多播网络接口

设置套接字选项IP_MULTICAST_IF,指定客户端通道将加入多播组的网络接口:

// Get the reference of a network interface named "eth1"
NetworkInterface interf =
    NetworkInterface.getByName("eth1");
// Set the IP_MULTICAST_IF option
client.setOption(StandardSocketOptions.IP_MULTICAST_IF,
    interf);

清单 8-20 包含打印机器上所有可用网络接口名称的完整程序。它还打印网络接口是否支持多播以及它是否启动。在您的机器上运行代码时,您可能会得到不同的输出。您将需要使用一个支持多播的可用网络接口的名称,并且该网络接口应该是打开的。例如,如输出所示,在我的机器上,名为eth2的网络接口启动并支持多播,所以我使用eth2作为处理多播消息的网络接口。

// ListNetworkInterfaces.java
package com.jdojo.net;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.util.Enumeration;
public class ListNetworkInterfaces {
    public static void main(String[] args) {
        try {
            Enumeration<NetworkInterface> e =
                NetworkInterface.getNetworkInterfaces();
            while (e.hasMoreElements()) {
                NetworkInterface nif = e.nextElement();
                System.out.println("Name: "
                    + nif.getName()
                    + ", Supports Multicast: "
                    + nif.supportsMulticast()
                    + ", isUp(): " + nif.isUp());
            }
        } catch (SocketException ex) {
            ex.printStackTrace();
        }
    }
}

Name: lo, Supports Multicast: true, isUp(): true
Name: eth0, Supports Multicast: true, isUp(): false
Name: net0, Supports Multicast: true, isUp(): false
Name: wlan0, Supports Multicast: true, isUp(): false
Name: net1, Supports Multicast: true, isUp(): false
Name: wlan1, Supports Multicast: true, isUp(): false
Name: wlan2, Supports Multicast: true, isUp(): false
Name: eth1, Supports Multicast: true, isUp(): false
Name: wlan3, Supports Multicast: true, isUp(): false
Name: wlan4, Supports Multicast: true, isUp(): false
Name: eth2, Supports Multicast: true, isUp(): true
Name: eth3, Supports Multicast: true, isUp(): false
Name: eth4, Supports Multicast: true, isUp(): false
Name: eth5, Supports Multicast: true, isUp(): false
Name: eth6, Supports Multicast: true, isUp(): false
Name: wlan5, Supports Multicast: true, isUp(): false
Name: wlan6, Supports Multicast: true, isUp(): false
Name: wlan7, Supports Multicast: true, isUp(): false
Name: wlan8, Supports Multicast: true, isUp(): false
Name: wlan9, Supports Multicast: true, isUp(): false
Name: wlan10, Supports Multicast: true, isUp(): false
Name: wlan11, Supports Multicast: true, isUp(): false
Name: wlan12, Supports Multicast: true, isUp(): false
Name: wlan13, Supports Multicast: true, isUp(): false
Name: wlan14, Supports Multicast: true, isUp(): false
Name: wlan15, Supports Multicast: true, isUp(): false
Name: wlan16, Supports Multicast: true, isUp(): false
Name: wlan17, Supports Multicast: true, isUp(): false

Listing 8-20Listing the Available Network Interface on a Machine

加入多播组

现在是使用如下的join()方法加入多播组的时候了。请注意,您必须为该组使用多播 IP 地址:

String MULTICAST_IP = "239.1.1.1";
// Join the multicast group on interf interface
InetAddress group = InetAddress.getByName(MULTICAST_IP);
MembershipKey key = client.join(group, interf);

join()方法返回一个MembershipKey类的对象,表示数据报通道与多播组的成员关系。如果数据报通道不再对接收多播数据报感兴趣,它可以使用keydrop()方法从多播组中删除其成员。

Note

数据报信道可以决定只从选择的源接收多播数据报。您可以使用MembershipKey类的block(InetAddress source)方法来阻止来自指定的source地址的多播数据报。它的unblock(InetAddress source)让你解锁一个先前被封锁的源地址。

接收消息

此时,接收寻址到多播组的数据报只需调用通道上的receive()方法,如下所示:

// Prepare a buffer to receive the message from the
// multicast group
ByteBuffer buffer = ByteBuffer.allocate(1048);
// Wait to receive a message from the multicast group
client.receive(buffer);

使用完该频道后,您可以从组中删除其成员,如下所示:

// We are no longer interested in receiving multicast
// message from the group. So, we need to drop the
// channel's membership from the group
key.drop();

关闭频道

最后,您需要使用它的close()方法关闭通道,如下所示:

// Close the channel
client.close();

要向多播组发送消息,您不需要成为该多播组的成员。您可以使用DatagramChannel类的send()方法向多播组发送数据报。

清单 8-21 包含一个带有三个常量的类,这三个常量在后面的两个类中用于构建多播应用程序。这些常量包含多播 IP 地址、多播端口号和多播网络接口名称,将在后续示例中使用。确保MULTICAST_INTERFACE_NAME常量的值eth1是您的机器上支持多播的网络接口名称,并且它是打开的。您可以通过运行清单 8-20 中的程序获得您机器上所有网络接口的列表。

// DGCMulticastUtil.java
package com.jdojo.net;
public class DGCMulticastUtil {
    public static final String MULTICAST_IP = "239.1.1.1";
    public static final int MULTICAST_PORT = 8989;
    /* You need to change the following network interface
       name "eth2" to the network interface name that
       supports multicast and is up on your machine.
       Please run the ListNetworkInterfaces class to
       get the list of all available network interface on
       your machine.
     */
    public static final String MULTICAST_INTERFACE_NAME =
        "eth2";
}

Listing 8-21A DatagramChannel-Based Multicast Client Program

清单 8-22 包含一个作为成员加入多播组的程序。它等待来自多播组的消息到达,打印该消息,然后退出。清单 8-23 包含一个向多播组发送消息的程序。您可以运行DGCMulticastClient类的多个实例,然后运行DGCMulticastServer类。所有客户端实例都应该在标准输出中接收和打印相同的消息。

// DGCMulticastServer.java
package com.jdojo.net;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.NetworkInterface;
import java.net.StandardSocketOptions;
import java.nio.ByteBuffer;
import java.nio.channels.DatagramChannel;
public class DGCMulticastServer {
    public static void main(String[] args) {
        // Get a datagram channel object to act as a server
        try (DatagramChannel server =
                DatagramChannel.open()) {
            // Bind the server to any available local
            // address
            server.bind(null);
            // Set the network interface for outgoing
            // multicast data
            NetworkInterface interf =
                NetworkInterface.getByName(
                    DGCMulticastUtil.
                    MULTICAST_INTERFACE_NAME);
            server.setOption(
                StandardSocketOptions.IP_MULTICAST_IF,
                interf);
            // Prepare a message to send to the multicast
            // group
            String msg = "Hello from multicast!";
            ByteBuffer buffer =
                ByteBuffer.wrap(msg.getBytes());
            // Get the multicast group reference to send
            // data to
            InetSocketAddress group =
                new InetSocketAddress(
                    DGCMulticastUtil.MULTICAST_IP,
                    DGCMulticastUtil.MULTICAST_PORT);
            // Send the message to the multicast group
            server.send(buffer, group);
            System.out.println(
                "Sent the multicast message: " + msg);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Sent the multicast message: Hello from multicast!

Listing 8-23A DatagramChannel-Based Multicast Program That Sends a Message to a Multicast Group

// DGCMulticastClient.java
package com.jdojo.net;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.NetworkInterface;
import java.net.StandardProtocolFamily;
import java.net.StandardSocketOptions;
import java.nio.ByteBuffer;
import java.nio.channels.DatagramChannel;
import java.nio.channels.MembershipKey;
public class DGCMulticastClient {
    public static void main(String[] args) {
        MembershipKey key = null;
        // Create, configure and bind the client datagram
        // channel
        try (DatagramChannel client =
                DatagramChannel.open(
                    StandardProtocolFamily.INET)) {
            // Get the reference of a network interface
            NetworkInterface interf =
                NetworkInterface.getByName(
                    DGCMulticastUtil.
                    MULTICAST_INTERFACE_NAME);
            client.setOption(
                StandardSocketOptions.SO_REUSEADDR,
                true);
            client.bind(
                new InetSocketAddress(
                    DGCMulticastUtil.MULTICAST_PORT));
            client.setOption(
                StandardSocketOptions.IP_MULTICAST_IF,
                interf);
            // Join the multicast group on the interf
            // interface
            InetAddress group =
                InetAddress.getByName(
                    DGCMulticastUtil.MULTICAST_IP);
            key = client.join(group, interf);
            // Print some useful messages for the user
            System.out.println(
                "Joined the multicast group:" + key);
            System.out.println(
                "Waiting for a message from the"
                + " multicast group....");
            // Prepare a data buffer to receive a message
            // from the multicast group
            ByteBuffer buffer = ByteBuffer.allocate(1048);
            // Wait to receive a message from the
            // multicast group
            client.receive(buffer);
            // Convert the message in the ByteBuffer
            // into a string
            buffer.flip();
            int limits = buffer.limit();
            byte bytes[] = new byte[limits];
            buffer.get(bytes, 0, limits);
            String msg = new String(bytes);
            System.out.format(
                "Multicast Message:%s%n", msg);
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            // Drop the membership from the multicast
            // group
            if (key != null) {
                key.drop();
            }
        }
    }
}

Joined the multicast group:<239.1.1.1,eth3>
Waiting for a message from the multicast group....
Multicast Message:Hello from multicast!

Listing 8-22A DatagramChannel-Based Multicast Client Program

进一步阅读

用 Java 进行网络编程是一个很大的话题。有几本书是专门写这个话题的。本章仅涵盖 Java 中可用的网络编程支持的基础知识。Java 还支持使用安全套接字层(SSL)协议的安全套接字通信。安全套接字通信编程的类在javax.net.ssl包中。本章不包括 SSL 套接字。我还没有介绍许多可以在 Java 程序中使用的套接字选项。如果你想用 Java 进行高级网络编程,建议你在读完这一章后,读一本专门用 Java 进行网络编程的书。

摘要

网络是一组两台或多台计算机或其他类型的电子设备(如打印机),它们为了共享信息而连接在一起。连接到网络的每个设备称为一个节点。与网络相连的计算机称为主机。Java 中的网络编程包括编写 Java 程序,以促进网络上不同计算机上运行的进程之间的信息交换。

两台远程主机之间的通信是通过称为 Internet 参考模型或 TCP/IP 分层模型的分层协议套件来执行的。该协议组由五层组成,即应用层、传输层、互联网层、网络接口层和物理层。Java 程序等用户应用程序使用应用层与远程应用程序进行通信。传输层协议处理将消息从一台计算机上的一个应用程序传输到远程计算机上的另一个应用程序的方式。互联网层接受来自传输层的消息,并准备适合通过互联网发送的数据包。它包括互联网协议(IP)。由 IP 准备的数据包也称为 IP 数据报,它由报头和数据区以及其他信息组成。网络接口层准备要在网络上传输的数据包。这个数据包称为一个帧。网络接口层位于物理层之上,物理层涉及硬件。物理层由硬件组成。它负责将信息比特转换成信号,并通过线路传输信号。

IP 地址唯一标识计算机和路由器之间的连接。互联网协议有两个版本——IP v4(或简称 IP)和 IPv6,其中 v4 和 v6 分别代表版本 4 和版本 6。IPv6 也被称为下一代互联网协议(IPng)。在 Java 程序中,InetAddress类的一个对象代表一个 IP 地址。InetAddress类有两个子类,Inet4AddressInet6Address,分别代表 IPv4 和 IPv6 地址。

端口号是 16 位无符号整数,范围从 0 到 65535,用于唯一标识特定协议的进程。

InetSocketAddress类的一个对象表示一个套接字地址,它结合了一个 IP 地址和一个端口号。

ServerSocket类的一个对象代表一个 TCP 服务器套接字,用于接受来自远程主机的连接。Socket类的一个对象代表一个服务器/客户端套接字。客户端和服务器应用程序使用Socket类的对象交换信息。Socket类提供了getInputStream()getOutputStream()方法来分别获取套接字的输入和输出流。套接字的输入流用于从套接字读取数据,套接字的输出流用于向套接字写入数据。

DatagramPacket类的对象代表 UDP 数据报,它是 UDP 套接字上的数据传输单元。DatagramSocket类的一个对象代表一个 UDP 服务器/客户端套接字。

统一资源标识符(URI)是标识资源的字符序列。使用位置来标识资源的 URI 称为统一资源定位器(URL)。使用名称来标识资源的 URI 称为统一资源名(URN)。URL 和 URN 是 URI 的子集。java.net.URI类的一个对象代表 Java 中的一个 URI。java.net.URL类的一个对象代表 Java 中的一个 URL。Java 提供了访问由 URL 标识的内容的类。

Java 支持使用java.nio.channels包中的ServerSocketChannelSocketChannelSelectorSelectionKey类的非阻塞套接字通道。

Java 还通过java.nio.channels包中的AsynchronousServerSocketChannelAsynchronousSocketChannel类支持异步套接字通道。

Java 通过DatagramChannel类支持面向数据报的套接字通道。数据报通道也支持 IP 多播。

练习

练习 1

Java 中的网络编程是什么?

练习 2

网络类型有哪些:局域网、局域网、城域网和广域网?

运动 3

什么是网络协议?

演习 4

什么是 IP 地址?一台计算机可以有多个 IP 地址吗?

锻炼 5

在 IPv4 和 IPv6 中,一个 IP 地址用多少字节来表示?描述以 IPv4 和 IPv6 格式表示 IP 地址的文本格式。

锻炼 6

您有一个 IP 地址0.0.0.0,它是 IPv4 格式的。您将如何以 IPv6 格式重写此 IP 地址?

锻炼 7

描述以下地址类型的用法:环回 IP 地址、单播 IP 地址、多播 IP 地址、任播 IP 地址、广播 IP 地址和未指定的 IP 地址。

运动 8

什么是端口号,为什么使用端口号?

演习 9

什么是插座?面向连接的套接字和无连接的套接字有什么区别?举一个支持这些类型套接字的协议的例子。

运动 10

InetAddress类的实例代表什么?编写一个程序,打印执行该程序的计算机的名称和 IP 地址。

演习 11

InetSocketAddress类的实例代表什么?

运动 12

ServerSocketSocket类的实例代表什么?

运动 13

DatagramSocketDatagramPacket类的实例代表什么?

运动 14

UDP 套接字不像 TCP 套接字那样支持端到端连接。代表 UDP 套接字的DatagramSocket类包含一个connect()方法。这个connect()方法的目的是什么?

运动 15

MulticastSocket类的实例代表什么?套接字必须是多播组的成员才能将数据报数据包发送到多播地址吗?

演习 16

什么是 URI、URL 和 URN?如何在 Java 程序中表示它们?

九、Java 远程方法调用

在本章中,您将学习:

  • 什么是 Java 远程方法调用(RMI)和 RMI 架构

  • 如何开发和打包 RMI 服务器和客户机应用程序

  • 如何启动rmiregistry、RMI 服务器和客户端应用程序

  • 如何对 RMI 应用程序进行故障排除和调试

  • RMI 应用程序中的动态类下载

  • RMI 应用程序中远程对象的垃圾收集

RMI 应用程序包含分为三部分的类和接口:

  • 服务器部分

  • 客户端部分

  • 公共部分,同时存在于客户端和服务器端

您将把本章中示例应用程序的三个部分打包成三个模块,分别命名为jdojo.rmi.commonjdojo.rmi.serverjdojo.rmi.client。这些模块的声明如清单 9-1 至 9-3 所示。

// module-info.java
module jdojo.rmi.client {
    requires java.rmi;
    requires jdojo.rmi.common;
    exports com.jdojo.rmi.client;
}

Listing 9-3The Declaration of a jdojo.rmi.client Module

// module-info.java
module jdojo.rmi.server {
    requires java.rmi;
    requires jdojo.rmi.common;
    exports com.jdojo.rmi.server;
}

Listing 9-2The Declaration of a jdojo.rmi.server Module

// module-info.java
module jdojo.rmi.common {
    requires java.rmi;
    exports com.jdojo.rmi.common;
}

Listing 9-1The Declaration of a jdojo.rmi.common Module

RMI 相关的类和接口在java.rmi模块中。包含 RMI 程序的模块需要读取java.rmi模块。jdojo.rmi.common模块包含将被服务器和客户端应用程序使用的类型,这就是jdojo.rmi.serverjdojo.rmi.client模块读取jdojo.rmi.common模块的原因。

什么是 Java 远程方法调用?

Java 支持各种应用程序架构,这些架构决定了应用程序代码如何以及在哪里部署和执行。在最简单的应用程序架构中,所有的 Java 代码都驻留在一台机器上,一个 JVM 管理所有的 Java 对象以及它们之间的交互。这是一个独立应用程序的例子,其中所需要的只是一台可以启动 JVM 的机器。Java 还支持分布式应用程序架构,其中应用程序的代码和执行可以分布在多台机器上。

在第八章中,你学习了用 Java 进行网络编程,这涉及到至少两个运行在不同机器上的 JVM,它们为客户机和服务器套接字执行 Java 代码。通常,套接字用于在两个应用程序之间传输数据。在套接字编程中,客户端程序可以向服务器程序发送消息。服务器程序创建一个 Java 对象,调用该对象上的方法,并将方法调用的结果返回给客户端程序。最后,客户端程序使用套接字读取结果。在这种情况下,客户机能够调用驻留在不同 JVM 中的 Java 对象上的方法。这种可能性为新的应用程序架构打开了大门,称为分布式编程,其中一个应用程序可以利用多台机器,运行多个 JVM 来处理业务逻辑。虽然可以使用套接字编程来调用驻留在不同 JVM(也可能在不同的机器上)中的对象的方法,但是编写代码并不容易。为了实现这一点,Java 提供了一种称为 Java 远程方法调用(Java RMI)的独立机制。

Java RMI 允许 Java 应用程序调用远程 JVM 中 Java 对象的方法。我使用术语“远程对象”来指由 JVM 创建和管理的 Java 对象,而不是管理调用该“远程对象”上的方法的 Java 代码的 JVM。通常,远程对象还意味着它是由 JVM 管理的,该 JVM 运行在访问它的机器之外的机器上。然而,Java 对象作为远程对象并不一定要存在于不同机器上的 JVM 中。出于学习目的,您将使用一台机器在一个 JVM 中部署远程对象,并在不同的 JVM 中启动另一个应用程序来访问远程对象。RMI 允许您将远程对象视为本地对象。在内部,它使用套接字来处理对远程对象的访问并调用其方法。

RMI 应用程序由两个程序组成,一个客户机和一个服务器,它们运行在两个不同的 JVM 中。服务器程序创建 Java 对象,并使远程客户端程序可以访问这些对象来调用这些对象上的方法。客户端程序需要知道远程对象在服务器上的位置,这样它就可以对它们调用方法。服务器程序创建一个远程对象,并将其引用注册(或绑定)到 RMI 注册表。RMI 注册表是一种名称服务,用于将远程对象引用绑定到名称,因此客户端可以使用注册表中基于名称的查找来获取远程对象的引用。RMI 注册表运行在独立于服务器程序的进程中。它是作为名为rmiregistry的工具提供的。当你在你的机器上安装一个 JDK/JRE 时,它被复制到 JDK/JRE 安装目录下的bin子目录中。

在客户端程序获得远程对象的远程引用后,它调用使用该引用的方法,就好像它是对本地对象的引用一样。RMI 技术负责调用在不同机器上的不同 JVM 上运行的服务器程序中的远程引用上的方法的细节。在 RMI 应用程序中,Java 代码是根据接口编写的。服务器程序包含接口的实现。客户机程序使用接口和远程对象引用来调用存在于服务器 JVM 中的远程对象上的方法。所有支持 Java RMI 的 Java 库类都在java.rmi包及其子包中。

RMI 体系结构

图 9-1 以简化的形式显示了 RMI 架构。图中的矩形框表示 RMI 应用程序中的一个组件。箭头线显示了沿箭头方向从一个组件发送到另一个组件的消息。显示从 1 到 11 的数字的椭圆表示在典型的 RMI 应用中发生的步骤序列。我将在本节中详细解释这些步骤。

img/323070_3_En_9_Fig1_HTML.jpg

图 9-1

RMI 体系结构

让我们假设您已经开发了 RMI 应用程序所需的所有 Java 类和接口。在这一节中,我将带您完成运行 RMI 应用程序时涉及的所有步骤。在接下来的几节中,您将开发每一步所需的 Java 代码。

RMI 应用程序的第一步是在服务器中创建一个 Java 对象。该对象将被用作远程对象。要使普通的 Java 对象成为远程对象,还需要执行一个额外的步骤。这一步被称为导出远程对象。当一个普通的 Java 对象作为远程对象导出时,它就可以接收/处理来自远程客户机的调用了。导出过程产生一个远程对象引用(也称为存根)。远程引用知道导出对象的细节,比如它的位置和可以远程调用的方法。该步骤在图中没有标出。它发生在服务器程序内部。当这一步完成时,远程对象已经在服务器中创建好了,并准备好接收远程方法调用。

下一步由服务器执行,向 RMI 注册中心注册(或绑定)远程引用。服务器为它在 RMI 注册表中注册的每个远程引用选择一个惟一的名称。远程客户端需要使用相同的名称在 RMI 注册表中查找远程引用。这在图中被标记为#1。当这一步完成时,RMI 注册中心已经注册了远程对象引用,对调用远程对象上的方法感兴趣的客户机可以从 RMI 注册中心请求它的引用。

Note

出于安全原因,RMI 注册中心和服务器必须运行在同一台机器上,以便服务器可以向 RMI 注册中心注册远程引用。如果没有施加这种限制,黑客可能会从他们的机器上向您的 RMI 注册表注册他们自己的有害 Java 对象。

这一步包括客户机和 RMI 注册中心之间的交互。通常,客户机和 RMI 注册中心运行在两台不同的机器上。客户机向 RMI 注册中心发送一个远程引用的查找请求。客户端使用名称在 RMI 注册表中查找远程引用。该名称与步骤#1 中服务器用来绑定 RMI 注册表中的远程引用的名称相同。查找步骤在图中标记为#2。RMI 注册中心将远程引用(或存根)返回给客户机,如图中步骤 3 所示。如果远程引用没有在 RMI 注册表中与客户机在查找请求中使用的名称绑定,RMI 注册表将抛出一个NotBoundException。如果这一步成功完成,客户机就收到了远程对象的远程引用(或存根)。

在这一步中,客户机调用存根上的一个方法。如图中步骤#4 所示。此时,存根连接到服务器并传输调用远程对象上的方法所需的信息,例如方法的名称、方法的参数等。存根知道服务器的位置以及如何联系服务器上的远程对象的细节。该步骤在图中被标记为步骤#5。网络层的许多不同层参与了从存根到服务器的信息传输。

框架是客户端存根的服务器端对应部分。它的工作是接收存根发送的数据。这在图中显示为步骤#6。在一个框架收到数据后,它将数据重组为更有意义的格式,并调用远程对象上的方法,如图中的步骤 7 所示。一旦服务器上的远程方法调用结束,框架就接收方法调用的结果(步骤#8),并通过网络层将信息传输回存根(步骤#9)。存根接收远程方法调用的结果(步骤#10),重组结果,并将结果传递给客户端程序(步骤#11)。

可以重复步骤#4 到#11 来调用同一远程对象上的相同或不同的方法。如果一个客户机想要调用一个不同的远程对象上的方法,它必须在启动一个远程方法调用之前首先执行步骤#2 和#3。

在 RMI 应用程序中,典型的情况是,客户机在开始时联系 RMI 注册中心以获得远程对象的存根。如果客户机需要运行在服务器上的另一个远程对象的存根,它可以通过调用它已经拥有的存根上的方法来获得它。请注意,远程对象的方法也可以向远程客户端返回一个存根。这样,远程客户端可以在启动时只在 RMI 注册表中执行一次查找。除了在 RMI 注册表中查找远程对象引用之外,为 RMI 应用程序编写的 Java 代码与非 RMI 应用程序没有什么不同。

开发 RMI 应用程序

本节将向您介绍编写 Java 代码来开发 RMI 应用程序的步骤。您将开发一个远程工具 RMI 应用程序,它将允许您执行三件事情:从服务器回显一条消息,从服务器获取当前日期和时间,以及将两个整数相加。编写 RMI 应用程序涉及以下步骤:

  • 编写远程接口。

  • 在类中实现远程接口。这个类的对象充当远程对象。

  • 编写服务器程序。它创建一个实现远程接口的类的对象,并将其注册到 RMI 注册表中。

  • 编写一个客户端程序来访问服务器上的远程对象。

编写远程接口

远程接口类似于任何其他 Java 接口,其方法应该从运行在不同 JVM 中的远程客户端调用。它有四个特殊要求:

  • 它必须扩展标记Remote接口。

  • 远程接口中的所有方法都必须抛出一个RemoteException或异常,即它的超类,如IOExceptionExceptionRemoteException是被检查的异常。远程方法还可以抛出任意数量的其他特定于应用程序的异常。

  • 远程方法可以接受远程对象的引用作为参数。它也可以返回远程对象的引用作为它的返回值。如果远程接口中的方法接受或返回远程对象引用,则参数或返回类型必须声明为类型Remote,而不是实现Remote接口的类的类型。

  • 远程接口只能在其方法的参数或返回值中使用三种数据类型。它可以是基本类型、远程对象或可序列化的非远程对象。远程对象通过引用传递,而非远程可序列化对象通过复制传递。如果一个对象的类实现了java.io.Serializable接口,那么这个对象就是可序列化的。

您将您的远程接口命名为RemoteUtility。清单 9-4 包含了RemoteUtility远程接口的代码,它是jdojo.rmi.common模块的一个成员。它包含三个方法,分别叫做echo()getServerTime()add(),提供了你想要的三个功能。

// RemoteUtility.java
package com.jdojo.rmi.common;
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.time.ZonedDateTime;
public interface RemoteUtility extends Remote {
    // Echoes a string message back to the client
    String echo(String msg) throws RemoteException;
    // Returns the current date and time to the client
    ZonedDateTime getServerTime() throws RemoteException;
    // Adds two integers and returns the result to the
    // client
    int add(int n1, int n2) throws RemoteException;
}

Listing 9-4A RemoteUtility Interface

实现远程接口

这一步包括创建一个实现远程接口的类。你将把这个类命名为RemoteUtilityImpl。它将实现RemoteUtility远程接口,并将提供三种方法的实现:echo()getServerTime()add()。这个类中可以有任意数量的其他方法。您必须做的唯一一件事就是为在RemoteUtility远程接口中定义的所有方法提供实现。远程客户端将只能调用该类的远程方法。如果在这个类中定义的方法不同于远程接口中定义的方法,那么这些方法对于远程方法调用是不可用的。但是,您可以使用其他方法来实现远程方法。清单 9-5 包含了RemoteUtilityImpl类的代码,它是jdojo.rmi.server模块的一个成员。

// RemoteUtilityImpl.java
package com.jdojo.rmi.server;
import com.jdojo.rmi.common.RemoteUtility;
import java.time.ZonedDateTime;
public class RemoteUtilityImpl implements RemoteUtility {
    public RemoteUtilityImpl() {
    }
    @Override
    public String echo(String msg) {
        return msg;
    }
    @Override
    public ZonedDateTime getServerTime() {
        return ZonedDateTime.now();
    }
    @Override
    public int add(int n1, int n2) {
        return n1 + n2;
    }
}

Listing 9-5An Implementation Class for the RemoteUtility Remote Interface

远程对象实现类非常简单。它实现了RemoteUtility接口,并为该接口的三个方法提供了实现。注意,RemoteUtilityImpl类中的这些方法没有声明它们抛出了一个RemoteException。声明所有远程方法抛出一个RemoteException的要求是针对远程接口的,而不是实现远程接口的类。

有两种方法可以为远程接口编写实现类。一种方法是从java.rmi.server.UnicastRemoteObject类继承它。另一种方法是不从任何类或者从除了UnicastRemoteObject类之外的任何类继承它。清单 9-5 采取了后一种方法。它没有从任何类继承RemoteUtilityImpl类。

如果远程接口的实现类继承自UnicastRemoteObject类或其他类,会有什么不同呢?远程接口的实现类用于创建远程对象,远程对象的方法被远程调用。这个类的对象必须经过一个导出过程,这使得它适合于远程方法调用。UnicastRemoteObject类的构造函数自动为您导出对象。所以,如果你的实现类继承自UnicastRemoteObject类,它将为你以后的整个过程节省一步。有时,您的实现类必须从另一个类继承,这将迫使您不要从UnicastRemoteObject类继承它。需要注意的一点是,UnicastRemoteObject类的构造函数抛出了一个RemoteException。如果从UnicastRemoteObject类继承远程对象实现类,实现类的构造函数必须在其声明中抛出一个RemoteException

清单 9-6 通过继承UnicastRemoteObject类重写了RemoteUtilityImpl类。这个实现中有两个新东西——在类声明中使用了extends子句,在构造函数声明中使用了throws子句。其他一切都保持不变。当你在本章的后面编写服务器程序时,我将讨论使用清单 9-5 和 9-6 中所示的RemoteUtilityImpl类的实现的区别。

// RemoteUtilityImpl.java
package com.jdojo.rmi.server;
import com.jdojo.rmi.common.RemoteUtility;
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
import java.time.ZonedDateTime;
public class RemoteUtilityImpl
        extends UnicastRemoteObject
        implements RemoteUtility {
    // Must throw the RemoteException
    public RemoteUtilityImpl() throws RemoteException {
    }
    @Override
    public String echo(String msg) {
        return msg;
    }
    @Override
    public ZonedDateTime getServerTime() {
        return ZonedDateTime.now();
    }
    @Override
    public int add(int n1, int n2) {
        return n1 + n2;
    }
}

Listing 9-6Rewriting the RemoteUtilityImpl Class by Inheriting It from the UnicastRemoteObject Class

编写 RMI 服务器程序

服务器程序的职责是创建远程对象,并使远程客户端可以访问它。服务器程序执行以下任务:

  • 安装安全管理器

  • 创建并导出远程对象

  • 向 RMI 注册应用程序注册远程对象

后续部分将详细讨论这些步骤。

您需要确保服务器代码在安全管理器下运行。如果 RMI 程序没有运行安全管理器,它就不能从远程位置下载 Java 类。没有安全管理器,它只能使用本地 Java 类。在 RMI 服务器和 RMI 客户机中,程序可能需要从远程位置下载类文件。您将很快看到从远程位置下载 Java 类的例子。当在安全管理器下运行 Java 程序时,还必须通过 Java 策略文件控制对特权资源的访问。以下代码片段显示了如何安装安全管理器(如果尚未安装)。您可以使用java.lang.SecurityManager类或java.rmi.RMISecurityManager类的对象来安装安全管理器。

SecurityManager secManager = System.getSecurityManager();
if (secManager == null) {
    System.setSecurityManager(new SecurityManager());
}

安全管理器通过策略文件控制对特权资源的访问。您需要设置适当的权限来访问 Java RMI 应用程序中使用的资源。对于本例,您将向所有代码授予所有权限。但是,您应该在生产环境中使用适当控制的策略文件。您需要在策略文件中输入以下条目来授予所有权限:

grant {
    permission java.security.AllPermission;
};

通常,Java 策略文件驻留在计算机上用户的主目录中,它被命名为.java.policy。请注意,文件名以点开始。

RMI 服务器程序执行的下一步是创建一个实现远程接口的类的对象,它将作为一个远程对象。在您的例子中,您将创建一个RemoteUtilityImpl类的对象:

RemoteUtilityImpl remoteUtility = new RemoteUtilityImpl();

您需要导出远程对象,以便远程客户端可以调用它的远程方法。如果您的远程对象类(本例中为RemoteUtility类)继承自UnicastRemoteObject类,那么您不需要导出它。它会在您创建时自动导出。如果您的远程对象的类不是从UnicastRemoteObject类继承的,您需要使用UnicastRemoteObject类的exportObject()静态方法之一显式导出它。当您导出远程对象时,您可以指定一个端口号,它可以在该端口号上侦听远程方法调用。默认情况下,它监听端口 0,这是一个匿名端口。以下语句导出远程对象:

int port = 0;
RemoteUtility remoteUtilityStub = (RemoteUtility)
    UnicastRemoteObject.exportObject(remoteUtility, port);

exportObject()方法返回导出的远程对象的引用,也称为存根或远程引用。您需要保留存根的引用,这样就可以向 RMI 注册中心注册它。

服务器程序执行的最后一步是使用名称向 RMI 注册表注册(或绑定)远程对象引用。RMI 注册中心是一个提供名称服务的独立应用程序。要在 RMI 注册表中注册一个远程引用,您必须首先找到它。RMI 注册表在特定端口的机器上运行。默认情况下,它运行在端口 1099 上。找到注册表后,需要调用它的bind()方法来绑定远程引用。您还可以使用它的rebind()方法,如果指定名称的旧绑定已经存在,它将替换旧绑定。使用的名字是一个String。您将使用名称MyRemoteUtility作为您的远程引用的名称。最好遵循 RMI 注册表中绑定引用对象的命名约定,以避免名称冲突。

Registry registry =
    LocateRegistry.getRegistry("localhost", 1099);
String name = "MyRemoteUtility";
registry.rebind(name, remoteUtilityStub);

这就是编写服务器程序所需的全部内容。清单 9-7 包含 RMI 服务器的完整代码,它是jdojo.rmi.server模块的成员。它假设RemoteUtilityImpl类不从UnicastRemoteObject类继承,如清单 9-5 所示。

// RemoteServer.java
package com.jdojo.rmi.server;
import com.jdojo.rmi.common.RemoteUtility;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.UnicastRemoteObject;
public class RemoteServer {
    public static void main(String[] args) {
        SecurityManager secManager =
            System.getSecurityManager();
        if (secManager == null) {
            System.setSecurityManager(
                new SecurityManager());
        }
        try {
            RemoteUtilityImpl remoteUtility =
                new RemoteUtilityImpl();
            // Export the object as a remote object
            int port = 0; // An anonymous port
            RemoteUtility remoteUtilityStub =
                (RemoteUtility) UnicastRemoteObject.
                exportObject(remoteUtility, port);
            // Locate the registry
            Registry registry =
                LocateRegistry.
                getRegistry("localhost", 1099);
            // Bind the exported remote reference in the
            // registry
            String name = "MyRemoteUtility";
            registry.rebind(name, remoteUtilityStub);
            System.out.println(
                "Remote server is ready...");
        } catch (RemoteException e) {
            e.printStackTrace();
        }
    }
}

Listing 9-7An RMI Remote Server Program

如果您使用清单 9-6 中列出的RemoteUtilityImpl类的实现,您将需要修改清单 9-7 中的代码。try-catch块中的代码将变为如下代码。所有其他代码将保持不变:

RemoteUtilityImpl remoteUtility = new RemoteUtilityImpl();
// No need to export the object
// Locate the registry
Registry registry = LocateRegistry.
    getRegistry("localhost", 1099);
// Bind the exported remote reference in the registry
String name = "MyRemoteUtility";
registry.rebind(name, remoteUtility);
System.out.println("Remote server is ready...");

您还没有准备好启动您的服务器程序。在接下来的小节中,我将讨论如何启动 RMI 应用程序。

出于安全原因,您只能从与 RMI 注册表运行在同一台机器上的 RMI 服务器程序将远程引用绑定到 RMI 注册表。否则,黑客可能会将任何任意的、可能有害的远程引用绑定到您的 RMI 注册表。默认情况下,LocateRegistry类的getRegistry()静态方法返回一个运行在同一台机器上端口 1099 的注册表的存根。您可以只使用以下代码在服务器程序中定位注册表:

// Get a registry stub for a local machine at port 1099
Registry registry = LocateRegistry.getRegistry();

请注意,对LocateRegistry.getRegistry()方法的调用并不试图连接到注册表应用程序。它只是返回注册表的存根。它是对这个存根控件的后续调用、bind()rebind()或任何其他方法调用,试图连接到注册中心应用程序。

编写 RMI 客户端程序

RMI 客户端程序调用远程对象上的方法,这些方法存在于远程服务器上。客户端程序必须做的第一件事是知道远程对象的位置。RMI 服务器程序创建并知道远程对象的位置。发布远程对象的位置细节是服务器程序的责任,这样客户端就可以定位和使用它。服务器程序通过将远程对象与 RMI 注册表绑定来发布远程对象的位置细节,并给它一个名称,在您的例子中是MyRemoteUtility。客户端程序联系 RMI 注册表,并执行基于名称的查找来获取远程引用。获得远程引用后,客户端程序调用远程引用上的方法,这些方法在服务器中执行。通常,RMI 客户端程序执行以下操作:

  • 它确保它在安全管理器下运行:

  • 它定位远程引用被服务器绑定的注册表。您必须知道机器名或 IP 地址,以及运行 RMI 注册表的端口号。在真实的 RMI 程序中,您不会在客户端程序中使用localhost来定位注册表。相反,RMI 注册中心将在一台单独的机器上运行。对于本例,您将在同一台机器上运行所有三个程序——RMI 注册表、服务器和客户机:

SecurityManager secManager =
    System.getSecurityManager();
if (secManager == null) {
    System.setSecurityManager(
    new SecurityManager());

  • 它使用Registry接口的lookup()方法在注册表中执行查找。它将绑定的远程引用的名称传递给lookup()方法,并获取远程引用(或存根)。注意,lookup()方法必须使用与服务器绑定/重新绑定远程引用相同的名称。lookup()方法返回一个Remote对象。您必须将其转换为远程接口的类型。以下代码片段将从lookup()方法返回的远程引用转换为RemoteUtility接口类型:
// Locate the registry
Registry registry =
    LocateRegistry.getRegistry(
        "localhost", 1099);

  • 它调用远程引用(或存根)上的方法。客户端程序将remoteUtilStub引用视为对本地对象的引用。对它进行的任何方法调用都被发送到服务器执行。所有远程方法都抛出一个RemoteException。当您调用任何远程方法时,您必须处理RemoteException
String name = "MyRemoteUtility";
RemoteUtility remoteUtilStub =
    (RemoteUtility) registry.
    lookup(name);

// Call the echo() method
String reply = remoteUtilStub.echo(
    "Hello from the RMI client.");
...

清单 9-8 包含了客户端程序的完整代码,它是jdojo.rmi.client模块的一个成员。暂时不要运行这个程序。在接下来的几节中,您将一步一步地运行您的 RMI 应用程序。您可能会注意到,编写 RMI 代码并不复杂。RMI 中不同组件的管道是复杂的。

// RemoteClient.java
package com.jdojo.rmi;

import com.jdojo.rmi.common.RemoteUtility;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.UnicastRemoteObject;
public class RemoteClient {
    public static void main(String[] args) {
        SecurityManager secManager =
            System.getSecurityManager();
        if (secManager == null) {
            System.setSecurityManager(
                new SecurityManager());
        }
        try {
            // Locate the registry
            Registry registry =
                LocateRegistry.getRegistry(
                    "localhost", 1099);
            String name = "MyRemoteUtility";
            RemoteUtility remoteUtilStub =
                (RemoteUtility) registry.
                lookup(name);
            // Call the echo() method
            String reply = remoteUtilStub.echo(
                "Hello from the RMI client.");
            System.out.println("Reply: " +
                reply);
        } catch (RemoteException e) {
            e.printStackTrace();
        } catch (NotBoundException e) {
            e.printStackTrace();
        }
    }
}

Listing 9-8An RMI Remote Client Program

分离服务器和客户端代码

在 RMI 应用程序中,将服务器和客户机程序的代码分开是很重要的。服务器程序需要有以下三个组件:

  • 远程接口

  • 远程接口的实现类

  • 服务器程序

客户端程序需要有以下两个组件:

  • 远程接口

  • 客户端程序

从本章一开始,您就为客户机-服务器代码分离做好了准备。为了实现这一点,您将把jdojo.rmi.serverjdojo.rmi.common模块部署到服务器上,并且将jdojo.rmi.clientjdojo.rmi.common模块部署到客户机上。当您运行 RMI 应用程序时,在后面的部分中我将这些模块化 jar 称为jdojo.rmi.server.jarjdojo.rmi.client.jarjdojo.rmi.common.jar

运行 RMI 应用程序

您需要按照以下特定顺序启动 RMI 应用程序中涉及的所有程序:

  • 运行 RMI 注册表。

  • 运行 RMI 服务器程序。

  • 运行 RMI 客户端程序。

如果在运行任何程序时遇到任何问题,请参阅本章后面的“排除 RMI 应用程序故障”一节。

您的服务器和客户端程序使用安全管理器。在成功运行 RMI 应用程序之前,必须正确配置 Java 策略文件。出于学习目的,您可以将所有安全权限授予 RMI 应用程序。您可以通过创建一个名为rmi.policy的文本文件(您可以使用您想要的任何其他文件名)并输入以下内容来做到这一点,这将向所有代码授予所有权限:

grant {
    permission java.security.AllPermission;
};

当运行 RMI 客户机或服务器程序时,需要使用java.security.policy JVM 选项将rmi.policy文件设置为 Java 安全策略文件。假设您已经将rmi.policy文件保存在 Windows 的C:\mypolicy文件夹中:

java - Djava.security.policy=^
file:///C:/mypolicy/rmi.policy <other-options>

这种设置 Java 策略文件的方法具有暂时的效果。它应该仅用于学习目的。您需要在生产环境中设置细粒度的安全性。

运行 RMI 注册表

RMI 注册表应用程序是随 JDK/JRE 安装一起提供的。它被复制到相应安装主文件夹的bin子文件夹中。在 Windows 平台上,它是rmiregistry.exe可执行文件。您可以通过使用命令提示符启动rmiregistry应用程序来运行 RMI 注册表。它接受将在其上运行的端口号。默认情况下,它运行在端口 1099 上。以下命令在 Windows 上使用命令提示符在端口 1099 启动它:

C:\java9\bin> rmiregistry

以下命令在端口 8967 启动 RMI 注册表:

C:\java9\bin> rmiregistry 8967

rmiregistry应用程序不在提示符下打印任何启动信息。通常,它是作为后台进程启动的。

很可能,该命令在您的机器上不起作用。使用该命令,您将能够成功启动rmiregistry。然而,当您在下一节运行 RMI 服务器应用程序时,您将得到ClassNotFoundExceptionrmiregistry应用程序需要访问 RMI 服务器应用程序中使用的一些类(已注册的类)。有三种方法可以让rmiregistry使用这些类:

  • 适当设置CLASSPATH

  • java.rmi.server.codebase JVM 属性设置为包含rmiregistry所需类的 URL。

  • 将名为java.rmi.server.useCodebaseOnly的 JVM 属性设置为false。该属性默认设置为true。如果该属性设置为falsermiregistry可以从服务器下载需要的类文件。

在启动rmiregistry之前,以下命令将包含服务器类和公共接口的 jar 添加到CLASSPATH:

C:\java9\bin> SET CLASSPATH=^
C:\Java9APIsAndModules\dist\jdojo.rmi.common.jar;^
C:\Java9APIsAndModules\dist\jdojo.rmi.server.jar
C:\java9\bin> rmiregistry

除了设置CLASSPATH来使类对rmiregistry可用,您还可以设置java.rmi.server.codebase JVM 属性,它是一个用空格分隔的 URL 列表,如下所示:

C:\java9\bin> rmiregistry ^
-J-Djava.rmi.server.codebase=^
file:///C:/Java9APIsAndModules/dist/jdojo.rmi.common.jar ^
file:///C:/Java9APIsAndModules/dist/jdojo.rmi.server.jar

下面的命令重置CLASSPATH并将 JVM 的java.rmi.server.useCodebaseOnly属性设置为false,这样rmiregistry将从 RMI 服务器下载任何需要的类文件。您的示例将使用以下命令:

C:\java9\bin> SET CLASSPATH=
C:\java9\bin> rmiregistry ^
-J-Djava.rmi.server.useCodebaseOnly=false

运行 RMI 服务器

在运行 RMI 服务器之前,必须运行 RMI 注册表。回想一下,服务器在一个安全管理器下运行,该安全管理器要求您授予在 Java 策略文件中执行某些操作的权限。确保您已经在策略文件中输入了所需的授权。您可以使用以下命令来运行服务器程序。命令文本在一行中输入;为了清楚起见,已经用多行显示了它。命令文本中的每个部分都应该用空格分隔,而不是换行。在命令中,您需要更改 JAR 和策略文件的路径,以反映它们在您机器上的路径:

C:\Java9APIsAndModules>java --module-path ^
dist\jdojo.rmi.common.jar;dist\jdojo.rmi.server.jar ^
-Djava.security.policy=file:///C:/mypolicy/rmi.policy ^
-Djava.rmi.server.codebase=^
file:///C:/Java9APIsAndModules/dist/jdojo.rmi.common.jar ^
--module ^
jdojo.rmi.server/com.jdojo.rmi.server.RemoteServer

Remote server is ready...

你需要设置一个java.rmi.server.codebase属性。如果 RMI 注册表和客户机程序需要下载它们没有的类文件,它们就会使用这个方法。该属性的值是一个 URL,它可以指向本地文件系统、web 服务器、FTP 服务器或任何其他资源。URL 可以指向一个 JAR 文件,就像本例中一样,也可以指向一个目录。如果它指向一个目录,URL 必须以正斜杠结束。以下命令使用文件夹作为其基本代码。如果 RMI 注册中心和客户机需要任何类文件,它们将尝试从 URL file:///C:/myrmi/classes/下载类文件。

java -Djava.rmi.server.codebase=^
file:///C:/myrmi/classes/ <other-options>

您还可以设置一个java.rmi.server.codebase属性来指向一个 web 服务器,在那里您可以存储您需要的类文件,如下所示:

java -Djava.rmi.server.codebase=^
http://www.jdojo.com/rmi/classes/ <other-options>

如果将类文件存储在多个位置,可以指定所有位置,用空格分隔,如下所示:

java -Djava.rmi.server.codebase=^
  "http://www.jdojo.com/rmi/classes/
   ftp://www.jdojo.com/rmi/some/classes/c.jar" ^
<other-options>

它将一个位置指定为目录,将另一个位置指定为 JAR 文件。一个使用http协议,另一个使用ftp协议。这两个值由一个空格分隔,并且它们在一行上,而不是如图所示的两行。当您运行服务器或客户端程序时,可能会发生ClassNotFoundException,这很可能是由于java.rmi.server.codebase属性设置不正确或根本没有设置该属性而导致的。

运行 RMI 客户端程序

成功启动 RMI 注册表和服务器应用程序之后,就该启动 RMI 客户机应用程序了。您可以使用以下命令来运行客户端程序:

C:\Java9APIsAndModules>java ^
--module-path ^
dist\jdojo.rmi.common.jar;dist\jdojo.rmi.client.jar ^
-Djava.rmi.server.codebase=^
file:///C:/Java9APIsAndModules/dist/jdojo.rmi.common.jar ^
-Djava.security.policy=file:///C:/mypolicy/rmi.policy ^
--module ^
jdojo.rmi.client/com.jdojo.rmi.client.RemoteClient

Reply: Hello from the RMI client.

对于这个例子,当您运行前面的命令时,您不必包含一个java.rmi.server.codebase选项。但是,如果客户端程序在远程方法中使用参数,并且服务器上没有这些参数类型的类文件,则需要包含此选项。在这种情况下,服务器将从指定的java.rmi.server.codebase选项下载这些类文件。

当客户端程序成功运行时,您应该能够在控制台上看到输出。运行该程序时,您可能会得到不同的输出,因为它会打印当前日期和时间以及运行服务器应用程序的服务器的时区信息。

RMI 应用程序故障排除

在第一次运行 RMI 应用程序之前,很可能会出现许多错误。本节列出了您可能会收到的一些错误。它还将列出这些错误的一些可能原因和一些可能的解决方案。当您试图运行 RMI 应用程序时,不可能列出所有可能的错误。通过查看错误的堆栈打印,您应该能够找出大多数错误。

Java . RMI . server . export exception

当您试图运行rmiregistry应用程序或服务器应用程序时,您会得到一个ExportException。当您试图运行rmiregistry应用程序时,如果您得到这个异常,那么异常堆栈跟踪将类似于图中所示:

java.rmi.server.ExportException:
   Port already in use: 1099; nested exception is:
   java.net.BindException: Address already in use:
   JVM_Bind...

它表明端口号 1099(在您的情况下可能是不同的号码)已经被使用。也许您已经在端口 1099(这是一个rmiregistry应用程序的默认端口号)启动了rmiregistry应用程序,或者一些其他应用程序正在使用端口 1099。您可以执行以下两项操作之一来解决此问题:

  • 您可以停止使用端口 1099 的应用程序。

  • 您可以在 1099 以外的端口启动rmiregistry应用程序。

如果在运行服务器程序时得到一个ExportException,这是由于远程对象的导出过程失败造成的。导出过程失败的原因有很多。以下异常堆栈跟踪(显示了部分跟踪)是由两次导出同一个远程对象引起的:

java.rmi.server.ExportException:
    object already exported
  at sun.rmi.transport.ObjectTable.putTarget(
    ObjectTable.java:189)
  at sun.rmi.transport.Transport.exportObject(
    Transport.java:92)...

检查您的服务器程序,并确保您只导出远程对象一次。从UnicastRemoteObject类继承远程对象实现类并使用UnicastRemoteObject类的exportObject()方法导出远程对象是一个常见的错误。当您从UnicastRemoteObject类继承远程对象的实现类时,您创建的远程对象会被自动导出。如果您尝试使用exportObject()方法再次导出它,您将得到这个异常。在讨论远程接口实现类时,我已经多次强调了这一点。当你开发一个 RMI 应用程序时,记住这句话,“犯错的是程序员,惩罚的是 Java。”即使是 RMI 程序设置中的一个小错误也可能需要几个小时来检测和修复。

Java . security . accesscontrolexception

当您的 Java 策略文件没有运行 RMI 应用程序所必需的grant条目时,您会得到这个异常。下面是一个异常的部分堆栈跟踪,它是在您尝试运行服务器程序,并尝试将远程对象绑定到 RMI 注册表时导致的:

java.security.AccessControlException:
    access denied (java.net.SocketPermission
127.0.0.1:1099 connect,resolve)...

注册中心、服务器和客户端之间的通信是使用套接字执行的。为了安全起见,您必须在 Java 策略文件中授予适当的套接字权限,以便 RMI 应用程序的三个组件能够进行通信。大多数与安全相关的异常可以通过在 Java 策略文件中授予适当的权限来修复。

Java . lang . class notfounindexception

当没有找到 Java 运行时需要的类文件时,您会得到一个ClassNotFoundException异常。到目前为止,您一定已经多次收到这个异常。大多数情况下,当CLASSPATH没有正确设置时,您会收到这个异常。在 RMI 应用程序中,这个异常可能是另一个异常的原因。以下堆栈跟踪显示抛出了java.rmi.ServerException异常,其原因在于ClassNotFoundException异常:

java.rmi.ServerException:
    RemoteException occurred in server thread;
    nested exception is:
        java.rmi.UnmarshalException:
        error unmarshalling arguments;
        nested exception is:
            java.lang.ClassNotFoundException:
            com.jdojo.rmi.RemoteUtility
...
Caused by: java.lang.ClassNotFoundException:
    com.jdojo.rmi.RemoteUtility
  at java.net.URLClassLoader$1.run(
    URLClassLoader.java:220)
  at java.net.URLClassLoader$1.run(
    URLClassLoader.java:209)

当您运行服务器或客户端应用程序时,java.rmi.server.codebase选项没有正确设置或根本没有设置,就会引发这种类型的异常。

当服务器程序在没有使用java.rmi.server.codebase选项的情况下启动,并且rmiregistry应用程序在没有设置CLASSPATH的情况下运行时,这个异常被抛出。当您试图将一个远程引用与一个rmiregistry应用程序绑定/重新绑定时,服务器应用程序会将该远程引用发送给rmiregistry应用程序。在 JVM 中将远程引用表示为 Java 对象之前,rmiregistry应用程序必须加载该类。此时,rmiregistry将尝试从服务器启动时使用java.rmi.server.codebase属性指定的位置下载所需的类文件。

如果在运行客户端程序时遇到这个异常,请确保在运行客户端程序时设置了java.rmi.server.codebase属性。

请在运行服务器和客户端程序时检查CLASSPATHjava.rmi.server.codebase属性,以避免此异常。

当您运行客户端程序时,您会得到一个ClassNotFoundException,因为服务器无法找到在服务器端解组客户端调用所需的一些类定义。异常的部分堆栈跟踪示例如下所示:

java.rmi.ServerException:
    RemoteException occurred in server thread;
    nested exception is:
        java.rmi.UnmarshalException:
        error unmarshalling arguments;
        nested exception is: java.lang.
            ClassNotFoundException:
            com.jdojo.rmi.client.Square
  at sun.rmi.server.UnicastServerRef.dispatch(
      UnicastServerRef.java:336)
  at sun.rmi.transport.Transport$1.run(
      Transport.java:159)...

在远程接口中定义的远程方法可以接受参数,该参数可以是接口或类类型。客户端可以传递实现接口的类的对象或远程接口的方法签名中定义的类型的子类的对象。如果服务器上不存在该类定义,服务器将尝试使用客户端应用程序中设置的java.rmi.server.codebase属性下载该类。您需要确保出现该错误的类(异常堆栈跟踪显示类名为com.jdojo.rmi.client.Square)在服务器 JVM 的CLASSPATH中,或者在运行远程客户端时设置java.rmi.server.codebase属性,以便服务器可以下载该类。

调试 RMI 应用程序

通过将名为java.rmi.server.logCalls的 JVM 属性设置为true,可以为 RMI 服务器应用程序打开 RMI 日志记录。默认情况下,它被设置为false。以下命令启动您的RemoteServer应用程序,将java.rmi.server.logCalls属性设置为true:

C:\Java9APIsAndModules>java ^
--module-path ^
dist\jdojo.rmi.common.jar;dist\jdojo.rmi.server.jar ^
-Djava.rmi.server.logCalls=true ^
-Djava.security.policy=file:///C:/mypolicy/rmi.policy ^
-Djava.rmi.server.codebase=^
file:///C:/Java9APIsAndModules/dist/jdojo.rmi.common.jar ^
--module ^
jdojo.rmi.server/com.jdojo.rmi.server.RemoteServer

当服务器 JVM 的java.rmi.server.logCalls属性被设置为true时,对服务器的所有传入调用以及在传入调用执行期间抛出的任何异常的堆栈跟踪都被记录到标准错误中。

RMI 运行时还允许您将服务器应用程序中的传入调用记录到一个文件中,而不考虑为服务器 JVM 的java.rmi.server.logCalls属性设置的值。您可以使用java.rmi.server.RemoteServer类的setLog(OutputStream out)静态方法将所有来电细节记录到一个文件中。通常,您在服务器程序代码的开头设置用于日志记录的文件输出流,比如您的com.jdojo.rmi.server.RemoteServer类的main()方法中的第一条语句。下面的代码片段支持将远程服务器应用程序中的调用记录到一个C:\rmilogs\rmi.log文件中。您可以通过使用null作为setLog()方法中的OutputStream来禁用呼叫记录:

try {
    java.io.OutputStream os =
        new java.io.FileOutputStream(
            "C:\\rmilogs\\rmi.log");
    java.rmi.server.RemoteServer.setLog(os);
} catch (FileNotFoundException e) {
    System.err.println(
        "Could not enable incoming calls logging.");
    e.printStackTrace();
}

当安全管理器安装在服务器上时,允许记录到文件的运行代码必须有一个权限目标为"control"java.util.logging.LoggingPermission。Java 策略文件中的以下 grant 条目将授予该权限。您还必须在 Java 策略文件中授予日志文件的"write"权限(在本例中为C:\rmilogs\rmi.log):

grant {
    permission java.io.FilePermission
        "c:\\rmilogs\\rmi.log", "write";
    permission java.util.logging.LoggingPermission
        "control";
};

如果您想要获得关于 RMI 客户机应用程序的调试信息,那么在启动 RMI 客户机应用程序时,将一个非标准的sun.rmi.client.logCalls属性设置为true。它将显示关于标准错误的调试信息。由于该属性不是公共规范的一部分,因此在未来的版本中可能会被删除。关于调试选项的更多细节,您需要参考 RMI 规范。你可以在 https://docs.oracle.com/javase/8/docs/technotes/guides/rmi/faq.html 找到 RMI 规范。

如果您在编译和运行 RMI 应用程序时仍有问题,您可以参考位于 https://docs.oracle.com/javase/8/docs/technotes/guides/rmi/faq.html 的网页。这个网页提供了使用 RMI 应用程序时几个常见问题的答案。

动态类下载

JVM 在创建类的对象之前加载类定义。它使用一个类加载器在运行时加载一个类。类加载器是java.lang.ClassLoader类的一个实例。类装入器必须先定位类的字节码,然后才能将其定义装入 JVM。Java 类加载器能够从任何位置(比如本地文件系统和网络)加载类的字节码。一个 JVM 中可以有多个类装入器,它们可以是系统定义的,也可以是自定义的。

JVM 在启动时创建一个类加载器,称为引导类加载器。引导类加载器负责加载基本 JVM 功能所需的初始类。基于父子关系,类装入器被组织成树状结构。引导类装入器没有父类。所有其他类装入器都将引导类装入器作为它们的直接或间接父类。在一个典型的类加载过程中,当一个类加载器被要求加载一个类的字节码时,它要求它的父类加载这个类,这个类反过来又要求它的父类,以此类推,直到引导类加载器得到加载这个类的请求。如果没有一个父类装入器能够装入该类,收到装入该类的初始请求的类装入器将尝试装入该类。

RMI 运行时使用一个特殊的 RMI 类加载器,负责加载 RMI 应用程序中的类。当一个对象在 RMI 应用程序中从一个 JVM 传递到另一个 JVM 时,发送 JVM 必须序列化和封送该对象,接收 JVM 必须反序列化和解封送该对象。发送 JVM 将属性java.rmi.server.codebase的值添加到对象的序列化流中。当在另一端接收到对象流时,接收 JVM 必须使用类装入器装入对象的类定义,然后才能将对象流转换成 Java 对象。JVM 指示 RMI 类加载器加载对象的类定义,它已经以流的形式接收到了该定义。类装入器试图从它的 JVM CLASSPATH中装入类定义。如果使用CLASSPATH没有找到类定义,那么类加载器使用对象流中java.rmi.server.codebase属性的值来加载类定义。

注意,java.rmi.server.codebase属性是在一个 JVM 中设置的,它用于下载另一个 JVM 中的类定义。当您运行 RMI 服务器或客户端程序时,可以设置该属性。当一方(服务器或客户机)将一个对象传输到另一方时,另一方没有字节码来表示正在接收的对象的类定义,发送方必须在发送对象时设置java.rmi.server.codebase属性,以便接收端可以使用该属性下载类字节码。java.rmi.server.codebase属性的值是一个用空格分隔的 URL 列表。

从安全的角度来看,从 RMI 服务器下载代码到客户机可能没有问题。有时,从客户端下载代码到服务器可能被认为是不安全的。默认情况下,禁止从远程 JVM 下载类。RMI 允许您通过使用一个java.rmi.server.useCodebaseOnly属性来启用/禁用这个特性。默认设置为true。如果设置为true,JVM 的类装入器将只从本地CLASSPATH或本地设置的java.rmi.server.codebase属性装入类。也就是说,如果设置为true,类加载器将不会从接收到的对象流中读取java.rmi.server.codebase的值来下载类定义。相反,它会在它的 JVM CLASSPATH中寻找类定义,并使用设置为它自己的 JVM 的java.rmi.server.codebase属性值的 URL。也就是说,当java.rmi.server.useCodebaseOnly属性被设置为true时,RMI 类加载器忽略从发送 JVM 在对象流中发送的codebase的值。属性名useCodebaseOnly似乎用词不当。如果把它命名为useLocallySetCodebaseOnly,可能会更好地表达它的意思。以下是在运行 RMI 服务器时如何设置该属性:

java –Djava.rmi.server.codebase=^
"http://www.myurl.com/rmiclasses" ^
     -Djava.rmi.server.useCodebaseOnly=true ^
     <other-options> ^
     com.jdojo.rmi.RemoteServer

Note

属性的默认值被设置为 true。这意味着,默认情况下,不允许应用程序从其他 JVM 下载类。

java.rmi.server.useCodebaseOnly属性设置为true有两种含义:

  • 如果服务器需要一个类作为来自客户端的远程调用的一部分,它将总是在它的CLASSPATH中查找,或者它将使用您为服务器程序设置的java.rmi.server.codebase的值。在前面的例子中,服务器中的所有类都必须在它的CLASSPATH或 URLT3 中找到。

  • 如果客户端需要在远程方法调用中使用新的类类型,服务器必须预先知道新的类类型,因为服务器永远不会使用客户端关于从哪里下载所需新类的指令(在客户端使用java.rmi.server.codebase属性设置)。这意味着您必须在服务器的CLASSPATH中或者在指定为服务器的java.rmi.server.codebase属性的 URL 中提供远程客户端将使用的新类。当远程方法接受一个接口类型,并且客户端发送实现该接口的类的对象时,可能会出现这种情况。在这种情况下,服务器可能没有与客户端相同的接口新实现的定义。

如果您为运行 RMI 客户机应用程序的 JVM 将java.rmi.server.useCodebaseOnly属性设置为true,那么前面的论点同样适用于运行 RMI 客户机应用程序。如果客户端应用程序的这个属性被设置为true,那么您必须使所有必需的类对客户端可用,要么将它们放在它的CLASSPATH中,要么将它们放在 URL 中,并将 URL 设置为客户端的java.rmi.server.codebase属性的值。

远程对象的垃圾收集

在 RMI 应用程序中,远程对象是在服务器上的 JVM 中创建的。RMI 注册表和远程客户端保存远程对象的引用。远程对象会被垃圾收集吗?而且,如果它确实被垃圾收集了,它是什么时候发生的,又是如何发生的?本地对象的垃圾收集很容易。在同一个 JVM 中创建和引用了一个本地对象。对于垃圾收集器来说,确定一个本地对象在 JVM 中不再被引用是一件容易的事情。

在 RMI 应用程序中,您需要一个垃圾收集器来跟踪远程 JVM 中远程对象的引用。假设一个 RMI 服务器创建了一个RemoteUtilityImpl类的远程对象,五个客户机获得了它的远程引用。RMI 注册中心也是一个客户机,它在绑定/重新绑定过程中获取远程引用。服务器何时以及如何垃圾收集被五个客户端引用的RemoteUtilityImpl类的唯一对象?

拥有远程对象的服务器上的 JVM 和五个不同客户机上的五个 JVM 必须交互,因此当服务器 JVM 中的远程对象不再被任何远程客户机使用时,可以对其进行垃圾收集。在这个讨论中,让我们忽略服务器 JVM 中远程对象的本地引用。远程客户机和 RMI 服务器之间的交互依赖于许多不可靠的因素。例如,网络可能会中断,远程客户端可能无法与服务器通信。第二个要考虑的问题是谁发起远程客户机和服务器之间的交互?是服务器不断询问远程客户端是否有实时远程引用吗?是远程客户端一直告诉服务器它仍然有一个活动的远程引用吗?客户端和服务器之间的交互由双方共同承担。远程客户端需要向服务器更新其远程引用的有效性。如果服务器在一段时间内没有收到任何客户端的消息,它会单方面决定将远程对象作为未来垃圾收集的候选对象。

RMI 垃圾收集器是基于引用计数的。参考计数具有关联的租约。租约有有效期。当一个远程客户机(包括一个 RMI 注册中心)获得一个对远程对象的引用时,它向服务器上的 RMI 运行时发送一个消息,请求租用那个远程对象引用。服务器向该客户端授予特定时间段的租约。服务器将该远程对象的引用计数递增 1,并将租约发送回客户端。默认情况下,RMI 服务器授予远程对象十分钟的租约。现在,以下是一些可能性:

  • 客户机可以在其从服务器获得租用的时间段内完成远程对象引用。

  • 客户可能希望将租期延长一段时间。

  • 客户端崩溃。服务器不接收来自客户端的任何消息,并且客户端获取的远程引用的租期到期。

让我们看看每一种可能性。客户端在三种不同的情况下向服务器发送消息。它在第一次收到远程引用时就发送一条消息。它告诉服务器它有一个远程对象的引用。第二次,当它想要更新远程引用的租约时,它向服务器发送一条消息。第三次,当完成远程引用时,它向服务器发送一条消息。事实上,当一个远程引用在客户机应用程序中被垃圾收集时,它会向服务器发送一条消息,表明它已经完成了对远程对象的处理。在内部,远程客户端发送给服务器的消息只有两种类型:脏的和干净的。发送消息以获得租约,发送干净消息以移除/取消租约。这两条消息使用java.rmi.dgc.DGC接口的dirty()clean()方法从远程客户端发送到服务器。作为一名开发人员,除了可以自定义租期之外,您对这些消息(发送或接收)没有任何控制权。租用时间段控制这些消息发送到服务器的频率。

当一个客户机完成一个远程对象引用时,它向服务器发送一个消息,表明它已经完成了。当客户机的 JVM 中的远程引用被垃圾收集时,发送该消息。因此,重要的是,一旦使用完毕,就将客户端程序代码中的远程引用设置为null。否则,服务器将继续保留远程对象,即使远程客户端不再使用它。您无法控制该消息从远程客户端发送到服务器的时间。要加快这个消息的发送,您所能做的就是将客户机代码中的远程对象引用设置为null,这样垃圾收集器将尝试对它进行垃圾收集,并向服务器发送一个干净的消息。

RMI 运行时跟踪远程客户端 JVM 中远程引用的租约。当租约到期到一半时,远程客户端向服务器发送一个租约续订请求,并续订租约。当远程客户机的租约为远程引用续订时,服务器会跟踪租约到期时间,并且不会对远程对象进行垃圾收集。理解为远程引用设置租期的重要性是很重要的。如果太小,大量的网络带宽将用于频繁更新租约。如果它太大,服务器将保持远程对象活动更长时间,以防客户端完成其远程引用,并且它不会通知服务器取消租用。我将简要讨论如何在 RMI 应用程序中设置租期值。

如果服务器没有从远程客户机听到任何关于客户机已经获得的远程引用的租用的消息,则在租用期到期后,它简单地取消租用并将该远程对象的引用计数减 1。这种由服务器做出的单方面决定对于处理行为不良的远程客户端(没有告诉服务器它是通过远程引用完成的)或任何可能阻止远程客户端与服务器通信的网络/系统故障非常重要。

当所有客户机都完成了对一个远程对象的远程引用时,它在服务器中的引用计数将下降到零。当远程客户端的租约到期或者它已经向服务器发送了干净的消息时,远程客户端被认为完成了远程引用。在这种情况下,RMI 运行时将使用一个弱引用来引用远程对象,因此如果没有对远程对象的本地引用,它可能会被垃圾收集。

默认情况下,租期设置为十分钟。当您启动 RMI 服务器时,您可以使用java.rmi.dgc.leaseValue属性来设置租期。租期的值以毫秒为单位指定。以下命令启动服务器程序,租期设置为 5 分钟(300000 毫秒):

C:\Java9APIsAndModules>java --module-path ^
dist\jdojo.rmi.common.jar;dist\jdojo.rmi.server.jar ^
-Djava.security.policy=file:///C:/mypolicy/rmi.policy ^
-Djava.rmi.dgc.leaseValue=300000 ^
-Djava.rmi.server.codebase=^
file:///C:/Java9APIsAndModules/dist/jdojo.rmi.common.jar ^
--module ^
jdojo.rmi.server/com.jdojo.rmi.server.RemoteServer

Remote server is ready...

除了设置租用时间段之外,所有事情都由 RMI 运行时处理。RMI 运行时为您提供了关于远程对象垃圾收集的更多信息。它可以告诉你远程对象的引用计数何时降到零。如果一个远程对象持有一些资源,而您希望在没有远程客户端引用它时释放这些资源,那么得到这个通知是很重要的。要获得这个通知,您需要在您的远程对象实现类中实现java.rmi.server.Unreferenced接口。其声明如下:

public interface Unreferenced {
    void unreferenced()
}

当远程对象的远程引用计数变为零时,调用unreferenced()方法。如果您想在您的示例中为RemoteUtility远程对象获得通知,您需要修改RemoteUtilityImpl类的声明,如清单 9-9 所示。

// RemoteUtilityImpl.java
package com.jdojo.rmi.server;
import com.jdojo.rmi.common.RemoteUtility;
import java.rmi.server.Unreferenced;
import java.time.ZonedDateTime;
public class RemoteUtilityImpl implements
        RemoteUtility, Unreferenced {
    public RemoteUtilityImpl() {
    }
    @Override
    public String echo(String msg) {
        return msg;
    }
    @Override
    public ZonedDateTime getServerTime() {
        return ZonedDateTime.now();
    }
    @Override
    public int add(int n1, int n2) {
        return n1 + n2;
    }
    @Override
    public void unreferenced() {
        System.out.println(
            "RemoteUtility unreferenced at: " +
            ZonedDateTime.now());
    }
}

Listing 9-9A Modified Version of the RemoteUtilityImpl Class That Implements the Unreferenced Interface

您可能会注意到,这一次,RemoteUtilityImpl类实现了Unreferenced接口并提供了unreferenced()方法的实现,该方法在其引用计数变为零时向标准输出打印一条消息。RMI 运行时将调用unreferenced()方法。为了测试是否调用了unreferenced()方法,您可以启动 RMI 注册表应用程序,然后启动 RMI 服务器应用程序。RMI 注册表将继续更新远程对象的租约。只要 RMI 注册中心还在运行,您就永远不会看到unreferenced()方法被调用。您需要关闭 RMI 注册表应用程序,并等待远程对象引用的租约到期,或者在您关闭它时被 RMI 注册表取消。RMI 注册表关闭后,您将在服务器程序的标准输出上看到由unreferenced()方法打印的消息。

RMI 注册表应该被用作启动远程客户端的引导工具。稍后,远程客户端可以接收远程对象的引用,作为对另一个远程对象的方法调用。如果远程客户机通过对远程对象的远程方法调用接收远程对象引用,则该远程对象的引用不需要在 RMI 注册表中注册。在这种情况下,在最后一个远程客户机完成远程引用后,当远程对象被绑定到 RMI 注册表时,服务器将对其进行垃圾收集,而不是将其保存在内存中。

摘要

Java 远程方法调用(RMI)允许在一个 JVM 中运行的程序调用在另一个 JVM 中运行的 Java 对象上的方法。RMI 提供了一个 API 来使用 Java 编程语言开发分布式应用程序。

一个 RMI 应用程序包含运行在三个 JVM 中的三个应用程序:rmiregistry应用程序、服务器应用程序和客户机应用程序。JDK 附带了rmiregistry应用程序。您负责开发服务器和客户端应用程序。服务器应用程序创建称为远程对象的 Java 对象,并将它们注册到rmiregistry中,供客户机以后查找名称。客户端应用程序使用逻辑名称在rmiregistry中查找远程对象,并取回远程对象的引用。客户端应用程序调用发送到服务器应用程序的远程对象引用上的方法,以便在远程对象上执行该方法。方法调用的结果从服务器应用程序发送回客户端应用程序。

RMI 应用程序必须遵循一些规则来开发远程通信中涉及的类和接口。您需要创建一个必须从Remote接口继承的接口(称为远程接口)。接口中的所有方法必须包含一个至少抛出RemoteExceptionthrows子句。远程对象的类必须实现远程接口。服务器应用程序创建实现远程接口的类的对象,导出该对象以给出真实远程对象的状态,并将其注册到rmiregistry。客户端应用程序只需要远程接口。

如果这三个应用程序中的任何一个需要本地没有的类,它们可以在运行时动态下载。对于动态下载类的 JVM 来说,java.rmi.server.useCodebaseOnly属性必须设置为false。默认情况下,它被设置为true,这将禁止动态下载 JVM 中的类。除了远程对象引用,JVM 还接收一个名为java.rmi.server.codebase的属性值,这是一个 URL,JVM 可以从这个 URL 下载(如果它自己的java.rmi.server.useCodebaseOnly属性设置允许的话)使用远程对象引用所需的类。

RMI 应用程序中有几个组件协同工作,这使得调试变得很困难。通过在 JVM 属性java.rmi.server.logCalls设置为true的情况下运行 RMI 服务器,您可以记录对它的所有调用。所有对服务器的调用都将被记录为标准错误。您还可以将 RMI 服务器调用记录到文件中。

RMI 为运行在 RMI 服务器中的远程对象提供自动垃圾收集。远程对象的垃圾收集基于引用计数和租约。当客户机应用程序获得远程对象的引用时,它也从服务器应用程序获得远程对象的租约。租约在一段时间内有效。只要客户端应用程序保留远程对象引用,它就会定期更新租约。服务器应用程序跟踪远程对象的引用计数和租约。当客户机应用程序完成远程引用时,它向服务器应用程序发送一条消息,服务器应用程序将远程对象的引用计数减一。当远程对象的引用计数在服务器应用程序中减少到零时,远程对象被垃圾收集。

练习

练习 1

什么是 Java 远程方法调用?

练习 2

每个远程接口必须扩展的接口的全限定名称是什么?

运动 3

创建远程对象后,需要在 RMI 服务器程序中执行哪些步骤,以便客户端可以使用远程对象?

演习 4

什么是 RMI 注册表,它位于哪里?

锻炼 5

在 RMI 应用程序中,可以将 RMI 注册中心和 RMI 服务器部署到两台不同的机器上吗?如果你的答案是否定的,请解释原因。

锻炼 6

描述 RMI 客户端程序调用远程对象上的方法需要执行的典型步骤序列。

锻炼 7

RMI 应用程序包括三层应用程序:客户机、RMI 注册中心和服务器。这些应用程序必须按什么顺序运行?

运动 8

描述在运行 RMI 客户机和服务器应用程序时如何使用java.rmi.server.codebase命令行选项。

演习 9

运行 RMI 服务器程序时使用java.rmi.server.logCalls=true命令行选项会有什么影响?

运动 10

如何将 RMI 服务器应用程序中的远程调用记录到文件中?

演习 11

运行 RMI 应用程序时使用java.rmi.server.useCodebaseOnly=true命令行选项会有什么影响?

运动 12

简要解释远程对象是如何被垃圾收集的。

运动 13

描述当远程对象不再被引用时获得通知的步骤。

十、使用 Java 编写脚本

在本章中,您将学习:

  • 什么是 Java 脚本

  • 如何从 Java 执行脚本以及如何向脚本传递参数

  • 如何在执行脚本时使用ScriptContext

  • 如何在脚本中使用 Java 编程语言

  • 如何实现脚本引擎

除非另有说明,本章中的所有示例程序都是清单 10-1 中声明的jdojo.script模块的成员。

// module-info.java
module jdojo.script {
    requires java.scripting;
    requires jdk.unsupported;
    // <- needed for GraalVM JavaScript
    exports com.jdojo.script;
}

Listing 10-1The Declaration of a jdojo.script Module

JDK 的脚本支持在java.scripting模块中。使用 Java 脚本 API 的模块需要像jdojo.script模块一样读取java.scripting模块。

Java 中的脚本是什么?

有人认为 Java 虚拟机(JVM)可以执行只用 Java 编程语言编写的程序,这是不正确的。JVM 执行语言无关的字节码。如果程序可以被编译成 Java 字节码,它可以执行用任何编程语言编写的程序。

脚本语言是一种编程语言,它提供了编写脚本的能力,这些脚本由一个叫做脚本引擎(或解释器)的运行时环境评估(或解释)。脚本是使用脚本语言的语法编写的字符序列,用作由解释器执行的程序的源。解释器解析脚本;产生中间代码,它是程序的内部表示;并执行中间代码。解释器将脚本中使用的变量存储在名为符号表的数据结构中。

通常,与编译编程语言不同,脚本语言中的源代码(称为脚本)不是编译的,而是在运行时解释的。然而,用一些脚本语言编写的脚本可以被编译成 Java 字节码,由 JVM 运行。

Java 已经包含了对 Java 平台的脚本支持,允许 Java 应用程序执行用 JavaScript、Groovy、Jython、JRuby 等脚本语言编写的脚本。支持双向通信。它还允许脚本访问由宿主应用程序创建的 Java 对象。Java 运行时和脚本语言运行时可以相互通信并使用彼此的功能。

Java 对脚本语言的支持来自 Java 脚本 API。Java 脚本 API 中的所有类和接口都在javax.script包中,这个包在java.scripting模块中。

在 Java 应用程序中使用脚本语言有几个好处:

  • 大多数脚本语言都是动态类型的,这使得编写程序更加简单。

  • 它们为开发和测试小型应用程序提供了一种更快捷的方式。

  • 最终用户可以进行定制。

  • 脚本语言可以提供 Java 中没有的特定领域的特性。

脚本语言也有一些缺点。例如,动态类型有利于编写更简单的代码;然而,当一个类型被错误地解释时,它就变成了一个缺点,你必须花很多时间去调试它。

Java 中的脚本支持让您可以利用两个世界的优势:它允许您使用 Java 编程语言来开发应用程序的静态类型、可伸缩和高性能部分,并使用适合特定领域需求的脚本语言来开发其他部分。

我在本章中经常使用术语脚本引擎。一个脚本引擎是一个执行用脚本语言编写的程序的软件组件。通常,但不一定,脚本引擎是脚本语言的解释器的实现。Java 已经实现了几种脚本语言的解释器。它们公开了编程接口,因此 Java 程序可以与它们进行交互。

JDK 曾经和一个叫做 Nashorn JavaScript 的脚本引擎捆绑在一起。不过在甲骨文的 JDK15 中已经去掉了 Nashorn,虽然在 OpenJDK 16 中你还能找到它。我们在这一章中不谈论纳松。

Java 可以执行任何为脚本引擎提供实现的脚本语言的脚本。比如 Java 可以执行用 GraalVM JavaScript、Groovy、Jython、JRuby 等编写的脚本。本章中的例子使用 Groovy 语言。

Note

作为 Nashorn JavaScript 引擎的替代品,您可以考虑使用 GraalVM 提供的 JavaScript 脚本引擎。不幸的是,这个不能很好地与 OpenJDK 17 配合使用。

在 Maven 中安装脚本引擎

如果您使用 Maven 作为构建工具,安装脚本引擎是很容易的。您所需要做的就是在您的pom.xml中添加一个非标准的存储库和某些依赖项:

<project xmlns:="http://maven.apache.org/POM/4.0.0"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation=
      "http://maven.apache.org/POM/4.0.0
       https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>your.project.group.id</groupId>
  <artifactId>your.project.artifact.id</artifactId>
  <version>your.project.version</version>

<dependencies>
  <dependency>
    <groupId>org.codehaus.groovy</groupId>
    <artifactId>groovy-jsr223</artifactId>
    <version>3.0.8</version>
  </dependency>

  <!-- Other script engines:-->
  <dependency>
    <groupId>org.scijava</groupId>
    <artifactId>scripting-jython</artifactId>
    <version>1.0.0</version>
  </dependency>
  <dependency>
    <groupId>org.scijava</groupId>
    <artifactId>scripting-jruby</artifactId>
    <version>0.3.1</version>
  </dependency>
  <dependency>
    <groupId>org.graalvm.js</groupId>
    <artifactId>js-scriptengine</artifactId>
    <version>21.1.0</version>
  </dependency>
  <dependency>

    <!-- needed for GraalVM.js -->
    <groupId>org.graalvm.truffle</groupId>
    <artifactId>truffle-api</artifactId>
    <version>21.1.0</version>
  </dependency>
  ...
</dependencies>

<repositories>
  <repository>
    <id>Maven Repo</id>
    <url>
https://repo1.maven.org/maven2/
    </url>
  </repository>
  <repository>
    <id>Maven Repo 2</id>
    <url>
http://maven.imagej.net/content/repositories/releases/
    </url>
  </repository>
</repositories>

...
</project>

当然,您可以注释掉或删除不需要的脚本引擎。

执行您的第一个脚本

在本节中,您将使用 Groovy 在标准输出中打印一条消息。使用任何其他脚本语言都可以使用相同的步骤来打印消息,只有一点不同:您需要使用特定于脚本语言的代码来打印消息。要用 Java 运行脚本,您需要执行以下三个步骤:

  • 创建脚本引擎管理器。

  • 从脚本引擎管理器获取脚本引擎的实例。

  • 调用脚本引擎的eval()方法执行脚本。

脚本引擎管理器是ScriptEngineManager类的一个实例:

// Create an script engine manager
ScriptEngineManager manager = new ScriptEngineManager();

接口的一个实例代表了 Java 程序中的一个脚本引擎。ScriptEngineManagergetEngineByName(String engineShortName)方法返回一个脚本引擎的实例。要获得 Groovy 引擎的实例,使用Groovy作为引擎的简称,如下所示:

// Get the reference of the Groovy engine
ScriptEngine engine =
    manager.getEngineByName("Groovy");

Note

脚本引擎的简称区分大小写。有时,一个脚本引擎有多个简称。groovy 引擎有以下简称:Groovy,Groovy。您可以使用这些引擎的简称中的任何一个,通过使用ScriptEngineManager类的getEngineByName()方法来获得它的实例。请注意可能与其他脚本引擎的名称冲突。

在 Groovy 中,println()函数在标准输出中打印一条消息。Groovy 中的字符串是用单引号或双引号括起来的字符序列。下面的代码片段将一个 Groovy 脚本存储在一个 Java String对象中,该对象将Hello Scripting!打印到标准输出:

// Store a Groovy script in a String
String script = "println('Hello Scripting!')";

如果您想在 Groovy 中使用双引号将字符串括起来,该语句将如下所示:

// Store a Groovy script in a String
String script = "println(\"Hello Scripting!\")";

或者

// Store a Groovy script in a String
String script = """println("Hello Scripting!")""";

要执行脚本,需要将脚本传递给脚本引擎的eval()方法。脚本引擎在运行脚本时可能会抛出一个ScriptException。因此,当您调用ScriptEngineeval()方法时,您需要处理这个异常。以下代码片段执行存储在script变量中的脚本:

try {
    engine.eval(script);
} catch (ScriptException e) {
    e.printStackTrace();
}

清单 10-2 包含程序在标准输出上打印信息的完整代码。

// HelloScripting.java
package com.jdojo.script;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
public class HelloScripting {
    public static void main(String[] args) {
        // Create a script engine manager
        ScriptEngineManager manager =
            new ScriptEngineManager();
        // Obtain a Groovy script engine from the manager
        ScriptEngine engine =
            manager.getEngineByName("Groovy");
        // Store the Groovy script in a String
        String script = """
            println('Hello Scripting!')
        """;
        try {
            // Execute the script
            engine.eval(script);
        } catch (ScriptException e) {
            e.printStackTrace();
        }
    }
}

Hello Scripting!

Listing 10-2Printing a Message on the Standard Output Using Groovy

使用其他脚本语言

在 Java 程序中使用除 Groovy 之外的脚本语言非常简单。在使用脚本引擎之前,您只需要执行一项任务:在应用程序模块路径中包含特定脚本引擎的 JAR 文件。脚本引擎的实现者提供这些 JAR 文件。

Java 的服务提供者机制将列出其模块化 JAR 或 JAR 文件已经包含在应用程序的模块路径中的所有脚本引擎。接口的一个实例用于创建和描述一个脚本引擎。脚本引擎的提供者为ScriptEngineFactory接口提供了一个实现。ScriptEngineManagergetEngineFactories()方法返回所有可用脚本引擎工厂的List<ScriptEngineFactory>ScriptEngineFactorygetScriptEngine()方法返回ScriptEngine的一个实例。工厂的其他几个方法返回关于引擎的元数据。

清单 10-3 展示了如何打印所有可用脚本引擎的细节。输出显示 Groovy 的脚本引擎是可用的。它之所以可用,是因为我已经向 Maven 项目添加了org.codehaus.groovy:groovy-jsr223:3.0.8工件,这导致在我的机器上包含了模块路径所需的所有 jar。当您在模块路径中包含了一个脚本引擎,并且想知道脚本引擎的简称时,这个程序会很有帮助。运行该程序时,您可能会得到不同的输出。

// ListingAllEngines.java
package com.jdojo.script;
import java.util.List;
import javax.script.ScriptEngineFactory;
import javax.script.ScriptEngineManager;
public class ListingAllEngines {
    public static void main(String[] args) {
        ScriptEngineManager manager =
            new ScriptEngineManager();
        // Get the list of all available engines
        List<ScriptEngineFactory> list =
            manager.getEngineFactories();
        // Print the details of each engine
        for (ScriptEngineFactory f : list) {
            System.out.println("Engine Name:" +
                f.getEngineName());
            System.out.println("Engine Version:" +
                f.getEngineVersion());
            System.out.println("Language Name:" +
                f.getLanguageName());
            System.out.println("Language Version:" +
                f.getLanguageVersion());
            System.out.println("Engine Short Names:" +
                f.getNames());
            System.out.println("Mime Types:" +
                f.getMimeTypes());
            System.out.println(
                "----------------------------");
        }
    }
}

ScriptEngineFactory Info
Script Engine: Groovy Scripting Engine (2.0)
Engine Alias: groovy
Engine Alias: Groovy
Language: Groovy (3.0.8)

Listing 10-3Listing All Available Script Engines

清单 10-4 展示了如何使用 JavaScript、Groovy、Jython 和 JRuby 在标准输出中打印消息。如果脚本引擎不可用,程序会打印一条消息说明这一点。您可能会得到不同的输出。

// HelloEngines.java
package com.jdojo.script;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
public class HelloEngines {
    public static void main(String[] args) {
        // Get the script engine manager
        ScriptEngineManager manager =
            new ScriptEngineManager();
        // Try executing scripts in JavaScript, Groovy,
        // Jython, and JRuby
        execute(manager, "JavaScript",
            "print('Hello JavaScript')");
        execute(manager, "Groovy",
            "println('Hello Groovy')");
        execute(manager, "jython",
            "print 'Hello Jython'");
        execute(manager, "jruby",
            "puts('Hello JRuby')");
    }
    public static void
    execute(ScriptEngineManager manager, String engineName,
            String script) {
        // Try getting the engine
        ScriptEngine engine =
            manager.getEngineByName(engineName);
        if (engine == null) {
            System.out.println(engineName +
                " is not available.");
            return;
        }
        // If we get here, it means we have the engine
        // installed. So, run the script
        try {
            engine.eval(script);
        } catch (ScriptException e) {
            e.printStackTrace();
        }
    }
}

JavaScript is not available.
Hello Groovy
jython is not available.
jruby is not available.

Listing 10-4Printing

a Message on the Standard Output Using Different Scripting Languages

探索 javax.script 包

Java 中的 Java 脚本 API 由少量的类和接口组成。它们在java.scripting模块的javax.script包中。本节包含对这个包中的类和接口的简要描述。我将在后续章节中讨论它们的用法。

ScriptEngine 和 ScriptEngineFactory 接口

ScriptEngine接口是 Java 脚本 API 中的主要接口,它的实例促进了以特定脚本语言编写的脚本的执行。

ScriptEngine接口的实现者也提供了ScriptEngineFactory接口的实现。一辆ScriptEngineFactory执行两项任务:

  • 它创建脚本引擎的实例。

  • 它提供关于脚本引擎的信息,如引擎名称、版本、语言等。

AbstractScriptEngine 类

AbstractScriptEngine是一个抽象类。它为ScriptEngine接口提供了部分实现。除非实现脚本引擎,否则不能直接使用该类。

ScriptEngineManager 类

ScriptEngineManager类为脚本引擎提供了发现和实例化机制。它还维护一个键-值对的映射,作为存储状态的Bindings接口的一个实例,由它创建的所有脚本引擎共享。

可编译接口和 CompiledScript 类

可选地,可以通过脚本引擎来实现Compilable接口,该脚本引擎允许编译脚本以便重复执行,而无需重新编译。

CompiledScript类被声明为抽象的。它是由脚本引擎的提供者扩展的。它以编译形式存储脚本,无需重新编译即可重复执行。请注意,使用ScriptEngine重复执行脚本会导致脚本每次都要重新编译,从而降低性能。支持脚本编译不需要脚本引擎。如果支持脚本编译,它必须实现Compilable接口。

可调用的接口

可选地,Invocable接口可以由脚本引擎实现,该脚本引擎可以允许调用先前已经编译过的脚本中的过程、函数和方法。

绑定接口和简单绑定类

实现Bindings接口的类的一个实例是一个键-值对的映射,有一个限制,即一个键必须是非 null、非空的String。它扩展了java.util.Map接口。SimpleBindings类是Bindings接口的一个实现。

ScriptContext 接口和 SimpleScriptContext 类

接口的一个实例充当 Java 主机应用程序和脚本引擎之间的桥梁。它用于将 Java 主机应用程序的执行上下文传递给脚本引擎。脚本引擎可以在执行脚本时使用上下文信息。脚本引擎可以将其状态存储在实现ScriptContext接口的类的实例中,Java 主机应用程序可以访问该接口。

SimpleScriptContext类是ScriptContext接口的一个实现。

ScriptException 类

ScriptException类是一个异常类。如果在脚本的执行、编译或调用过程中出现错误,脚本引擎会抛出一个ScriptException。该类包含三个有用的方法,分别叫做getLineNumber()getColumnNumber()getFileName()。这些方法报告发生错误的脚本的行号、列号和文件名。ScriptException类覆盖了Throwable类的getMessage()方法,并在它返回的消息中包含行号、列号和文件名。

发现和实例化脚本引擎

您可以使用ScriptEngineFactoryScriptEngineManager创建脚本引擎。谁真正负责创建一个脚本引擎:ScriptEngineFactoryScriptEngineManager,或者两者都有?简单的回答是,ScriptEngineFactory总是负责创建脚本引擎的实例。下一个问题是“a ScriptEngineManager的作用是什么?”

A ScriptEngineManager使用服务提供者机制来定位所有可用的脚本引擎工厂。服务提供者机制已经在本书的第七章中讨论过了。

一个ScriptEngineManager定位并实例化所有可用的ScriptEngineFactory类。您可以使用ScriptEngineManager类的getEngineFactories()方法获得所有工厂类的实例列表。当您调用管理器的一个方法来获得一个基于某个标准的脚本引擎时,例如通过名称获得引擎的getEngineByName(String shortName)方法,管理器搜索该标准的所有工厂并返回匹配的脚本引擎引用。如果没有工厂能够提供匹配的引擎,经理返回null。请参考清单 10-3 了解更多关于列出所有可用工厂和描述它们可以创建的脚本引擎的详细信息。

现在你知道了ScriptEngineManager并不创建脚本引擎的实例。相反,它查询所有可用的工厂,并将工厂创建的脚本引擎的引用传递回调用者。

为了使讨论完整,让我们添加一个创建脚本引擎的方法。您可以通过三种方式创建脚本引擎的实例:

  • 直接实例化脚本引擎类。

  • 直接实例化脚本引擎工厂类,调用其getScriptEngine()方法。

  • 使用ScriptEngineManager类的getEngineByXxx()方法之一。

建议使用ScriptEngineManager类来获取脚本引擎的实例。这个方法允许由同一个管理器创建的所有引擎共享一个状态,这个状态是作为Bindings接口的一个实例存储的一组键-值对。ScriptEngineManager实例存储这个状态。使用此方法还会使您的代码不知道实际的脚本引擎/工厂实现类。

Note

一个应用程序中可能有多个ScriptEngineManager类的实例。在这种情况下,每个ScriptEngineManager实例维护一个它创建的所有引擎共有的状态。也就是说,如果两个引擎是由ScriptEngineManager类的两个不同实例获得的,那么这些引擎将不会共享由它们的管理器维护的一个公共状态,除非您以编程方式实现这一点。

执行脚本

一个ScriptEngine可以执行一个String和一个Reader中的脚本。使用Reader,您可以执行存储在网络或文件中的脚本。ScriptEngineeval()方法的以下版本之一用于执行脚本:

  • Object eval(String script)

  • Object eval(Reader reader)

  • Object eval(String script, Bindings bindings)

  • Object eval(Reader reader, Bindings bindings)

  • Object eval(String script, ScriptContext context)

  • Object eval(Reader reader, ScriptContext context)

eval()方法的第一个参数是脚本的源。第二个参数允许您将信息从宿主应用程序传递到脚本引擎,这些信息可以在脚本执行期间使用。

在清单 10-2 中,您看到了如何使用第一个版本的eval()方法使用String来执行脚本。在本节中,您将把您的脚本存储在一个文件中,并使用一个Reader对象作为脚本的源,它将使用第二个版本的eval()方法。下一节将讨论eval()方法的其他四个版本。通常,脚本文件会被赋予一个.js扩展名。

清单 10-5 显示了名为helloscript.groovy的文件的内容。它在 Groovy 中只包含一个在标准输出中打印消息的语句。

// Print a message
println('Hello from Groovy!')

Listing 10-5The Contents of the helloscript.groovy File

清单 10-6 有执行保存在helloscript.groovy文件中脚本的 Java 程序,该文件应该保存在当前目录下的scripts子目录中。如果没有找到脚本文件,程序会在需要的地方打印出helloscript.js文件的完整路径。如果您在执行脚本文件时遇到问题,请尝试在main()方法中使用绝对路径,例如 Windows 上的C:\scripts\helloscript.js,假设helloscript.js文件保存在C:\scripts目录中。本章示例中使用的所有脚本都在源代码中的Java9APIsAndModules\scripts目录下提供。

// ReaderAsSource.java
package com.jdojo.script;
import java.io.IOException;
import java.io.Reader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
public class ReaderAsSource {
    public static void main(String[] args) {
        // Construct the script file path
        String scriptFileName =
            "scripts/helloscript.groovy";
        Path scriptPath = Paths.get(scriptFileName);
        // Make sure the script file exists. If not,
        // print the full path of the script file and
        // terminate the program.
        if (!Files.exists(scriptPath)) {
            System.out.println(
                scriptPath.toAbsolutePath() +
                " does not exist.");
            return;
        }
        // Get the Groovy script engine
        ScriptEngineManager manager =
            new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByName(
            "Groovy");
        try {
            // Get a Reader for the script file
            Reader scriptReader = Files.newBufferedReader(
                scriptPath);
            // Execute the script in the file
            engine.eval(scriptReader);
        } catch (IOException | ScriptException e) {
            e.printStackTrace();
        }
    }
}

Hello from Groovy!

Listing 10-6Executing a Script Stored in a File

在实际应用程序中,您应该将所有脚本存储在允许修改脚本而无需修改和重新编译 Java 代码的文件中。在本章的大部分例子中,你不会遵循这个规则;您将把您的脚本存储在String对象中,以保持代码简短。

传递参数

Java 脚本 API 允许您将参数从主机环境(Java 应用程序)传递到脚本引擎,反之亦然。在本节中,您将看到宿主应用程序和脚本引擎之间的参数传递机制的技术细节。

从 Java 代码向脚本传递参数

Java 程序可以向脚本传递参数。Java 程序也可以在脚本执行后访问脚本中声明的全局变量。让我们讨论一个简单的例子,Java 程序向脚本传递一个参数。考虑清单 10-7 中向脚本传递参数的程序。

// PassingParam.java
package com.jdojo.script;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
public class PassingParam {
    public static void main(String[] args) {
        // Get the Groovy engine
        ScriptEngineManager manager =
            new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByName(
            "Groovy");
        // Store the script in a String. Here, msg is a
        // variable that we have not declared in the script
        String script = "println(msg)";
        try {
            // Store a parameter named msg in the engine
            engine.put("msg",
                "Hello from the Java program");
            // Execute the script
            engine.eval(script);
        } catch (ScriptException e) {
            e.printStackTrace();
        }
    }
}

Hello from the Java program

Listing 10-7Passing Parameters from a Java Program to Scripts

程序在String中存储一个脚本,如下所示:

// Store the script in a String
String script = "println(msg)";

在语句中,脚本引擎将执行的脚本是

println(msg)

注意msg是在println()函数调用中使用的变量。脚本没有声明msg变量,也没有给它赋值。如果您试图在不告诉引擎什么是msg变量的情况下执行这个脚本,引擎将抛出一个异常,声明它不理解名为msg的变量的含义。这就是将参数从 Java 程序传递到脚本引擎的概念发挥作用的地方。

可以通过几种方式将参数传递给脚本引擎。最简单的方法是使用脚本引擎的put(String paramName, Object paramValue)方法,它接受两个参数:

  • 第一个参数是参数的名称,它需要与脚本中变量的名称相匹配。

  • 第二个参数是参数的值。

在您的例子中,您希望将一个名为msg的参数传递给脚本引擎,它的值是一个String。调用put()的方法是

// Store the value of the msg parameter in the engine
engine.put("msg", "Hello from Java program");

注意,在调用eval()方法之前,必须先调用引擎的put()方法。在您的例子中,当引擎试图执行print(msg)时,它将使用您传递给引擎的msg参数的值。

大多数脚本引擎允许您使用传递给它的参数名作为脚本中的变量名。当您传递名为msg的参数值并在清单 10-7 的脚本中将它用作变量名时,您看到了这种例子。脚本引擎可能要求在脚本中声明变量,例如,PHP 中的变量名必须以前缀$开头,JRuby 中的全局变量名必须包含前缀$。如果您想将名为msg的参数传递给 JRuby 中的脚本,您的代码如下所示:

// Get the JRuby script engine
ScriptEngineManager manager = new ScriptEngineManager();
ScriptEngine engine = manager.getEngineByName("jruby");
// Must use the $ prefix in JRuby script
String script = "puts($msg)";
// No $ prefix used in passing the msg parameter to the
// JRuby engine
engine.put("msg", "Hello from Java");
// Execute the script
engine.eval(script);

传递给脚本的 Java 对象的属性和方法可以在脚本中访问,就像在 Java 代码中访问一样。不同的脚本语言使用不同的语法来访问脚本中的 Java 对象。例如,您可以在清单 10-7 所示的示例中使用表达式msg.toString(),输出将是相同的。在这种情况下,您正在调用变量msgtoString()方法。将清单 10-7 中赋值给script变量的语句改为如下,并运行程序,程序将产生相同的输出:

String script = "println(msg.toString())";

从脚本向 Java 代码传递参数

脚本引擎可以使其全局范围内的变量对 Java 代码可用。ScriptEngineget(String variableName)方法用于访问 Java 代码中的那些变量。它返回一个 Java Object。全局变量的声明依赖于脚本语言。以下代码片段声明了一个全局变量,并在 Groovy 中为其赋值:

// Declare a variable named year in Groovy
// Note the missing of the 'def' in front of it. If you
// don't prepend 'def', Groovy puts the variable in a
// script-wide global scope.
year = 1969;

清单 10-8 包含了一个程序,展示了如何从 Java 代码访问 Groovy 中的一个全局变量。

// AccessingScriptVariable.java
package com.jdojo.script;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
public class AccessingScriptVariable {
    public static void main(String[] args) {
        // Get the Groovy engine
        ScriptEngineManager manager =
            new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByName(
            "Groovy");
        // Write a script that declares a global variable
        // named year and assign it a value of 1969.
        String script = "year = 1969";
        try {
            // Execute the script
            engine.eval(script);
            // Get the year global variable from the
            // engine
            Object year = engine.get("year");
            // Print the class name and the value of the
            // variable year
            System.out.println("year's class: "  +
                year.getClass().getName());
            System.out.println("year's value: " +
                year);
        } catch (ScriptException e) {
            e.printStackTrace();
        }
    }
}

year's class: java.lang.Integer
year's value: 1969

Listing 10-8Accessing Script Global Variables in Java Code

程序在脚本中声明了一个全局变量year,并给它赋值1969,如下所示:

String script = "year = 1969";

当脚本执行时,引擎将year变量添加到它的状态中。在 Java 代码中,引擎的get()方法用于检索year变量的值,如下所示:

Object year = engine.get("year");

当脚本中声明了year变量时,您没有指定它的数据类型。脚本变量值到适当 Java 对象的转换是自动执行的。在这种情况下,值1969被评估为一个Integer

高级参数传递技术

为了理解参数传递机制的细节,必须清楚地理解三个术语:绑定、范围和上下文。这些术语起初令人困惑。本节使用以下步骤解释参数传递机制:

  • 首先,它定义了这些术语。

  • 其次,它定义了这些术语之间的关系。

  • 第三,它解释了如何在 Java 代码中使用它们。

粘合剂

一个Bindings是一组键-值对,其中所有键必须是非空的、非空字符串。在 Java 代码中,BindingsBindings接口的一个实例。SimpleBindings类是Bindings接口的一个实现。脚本引擎可以提供自己的Bindings接口实现。

Note

如果你熟悉java.util.Map界面,就很容易理解BindingsBindings接口继承自Map<String,Object>接口。因此,Bindings只是一个Map,它的键必须是非空的非空字符串。

清单 10-9 展示了如何使用Bindings。它创建一个SimpleBindings的实例,添加一些键值对,检索键值,删除键值对,等等。Bindings接口的get()方法返回null,如果键不存在或者键存在且其值为null。如果你想测试一个键是否存在,你需要调用它的contains()方法。

// BindingsTest.java
package com.jdojo.script;
import javax.script.Bindings;
import javax.script.SimpleBindings;
public class BindingsTest {
    public static void main(String[] args) {
        // Create a Bindings instance
        Bindings params = new SimpleBindings();
        // Add some key-value pairs
        params.put("msg", "Hello");
        params.put("year", 1969);
        // Get values
        Object msg = params.get("msg");
        Object year = params.get("year");
        System.out.println("msg = " + msg);
        System.out.println("year = " + year);
        // Remove year from Bindings
        params.remove("year");
        year = params.get("year");
        boolean containsYear = params.containsKey("year");
        System.out.println("year = " + year);
        System.out.println("params contains year = " +
            containsYear);
    }
}

msg = Hello
year = 1969
year = null
params contains year = false

Listing 10-9Using Bindings Objects

你不能单独使用一个Bindings。通常,您会使用它将参数从 Java 代码传递到脚本引擎。ScriptEngine接口包含一个返回Bindings接口实例的createBindings()方法。这个方法给脚本引擎一个机会来返回一个Bindings接口的特殊实现的实例。您可以使用如下所示的方法:

// Get the Groovy engine
ScriptEngineManager manager = new ScriptEngineManager();
ScriptEngine engine = manager.getEngineByName(
    "Groovy");
// Do not instantiate SimpleBindings class directly.
// Use the createBindings() method of the engine to create
// a Bindings.
Bindings params = engine.createBindings();
// Work with params as usual

范围

让我们转到下一个术语,即范围。范围用于绑定。绑定的范围决定了它的键值对的可见性。在多个作用域中可以有多个绑定。但是,一个绑定只能出现在一个范围内。如何指定绑定的范围?我很快会谈到这一点。

使用Bindings的作用域可以让您按照层次顺序为脚本引擎定义参数变量。如果在引擎状态下搜索变量名,首先搜索优先级较高的Bindings,然后是优先级较低的Bindings。返回找到的第一个变量的值。Java 脚本 API 定义了两个范围。它们在ScriptContext接口中被定义为两个int常量。他们是

  • ScriptContext.ENGINE_SCOPE

  • ScriptContext.GLOBAL_SCOPE

引擎范围的优先级高于全局范围。如果向两个Bindings添加具有相同键的两个键-值对,一个在引擎范围内,一个在全局范围内,那么每当需要解析与键同名的变量时,都会使用引擎范围内的键-值对。

理解作用域对于一个Bindings的作用是如此重要,以至于我通过另一个类比来解释它。考虑一个有两组变量的 Java 类:一组包含类中的所有实例变量,另一组包含方法中的所有局部变量。这两组变量及其值是两个Bindings。这些Bindings中的变量类型定义了作用域。为了便于讨论,我定义了两个范围:实例范围和本地范围。当执行一个方法时,首先在局部范围Bindings中查找变量名,因为局部变量优先于实例变量。如果在本地作用域Bindings中没有找到变量名,就在实例作用域Bindings中查找。当一个脚本被执行时,Bindings和它们的作用域扮演着相似的角色。

定义脚本上下文

脚本引擎在上下文中执行脚本。您可以将上下文视为脚本执行的环境。Java 宿主应用程序为脚本引擎提供了两样东西:脚本和脚本需要执行的上下文。接口的一个实例代表一个脚本的上下文。SimpleScriptContext类是ScriptContext接口的一个实现。脚本上下文由四部分组成:

  • 一组Bindings,其中每个Bindings与一个不同的作用域相关联

  • 脚本引擎用来读取输入的Reader

  • 脚本引擎用来写输出的一个Writer

  • 脚本引擎用来写入错误输出的错误Writer

上下文中的一组Bindings用于向脚本传递参数。上下文中的读取器和写入器分别控制脚本的输入源和输出目的地。例如,通过将文件编写器设置为编写器,可以将脚本的所有输出发送到文件。

每个脚本引擎都维护一个默认的脚本上下文,用于执行脚本。到目前为止,您已经在没有提供脚本上下文的情况下执行了几个脚本。在这些情况下,脚本引擎使用它们的默认脚本上下文来执行脚本。在这一节中,我将介绍如何单独使用ScriptContext。在下一节中,我将介绍在脚本执行期间如何将一个ScriptContext传递给一个ScriptEngine

您可以使用SimpleScriptContext类创建一个ScriptContext接口的实例:

// Create a script context
ScriptContext ctx = new SimpleScriptContext();

一个SimpleScriptContext类的实例维护两个Bindings实例:一个用于引擎范围,一个用于全局范围。当您创建SimpleScriptContext的实例时,就会创建引擎范围内的Bindings。要使用全局范围Bindings,您需要创建一个Bindings接口的实例。

默认情况下,SimpleScriptContext类将上下文的输入读取器、输出写入器和错误写入器分别初始化为标准输入System.in、标准输出System.out和标准错误输出System.err。您可以使用ScriptContext接口的getReader()getWriter()getErrorWriter()方法分别从ScriptContext中获取阅读器、编写器和错误编写器的引用。还提供了 Setter 方法来设置读取器和编写器。下面的代码片段显示了如何获取阅读器和编写器。它还展示了如何将 writer 设置为FileWriter以将脚本输出写入文件:

// Get the reader and writers from the script context
Reader inputReader = ctx.getReader();
Writer outputWriter = ctx.getWriter();
Writer errWriter = ctx.getErrorWriter();
// Write all script outputs to an out.txt file
Writer fileWriter = new FileWriter("out.txt");
ctx.setWriter(fileWriter);

在创建了SimpleScriptContext之后,您可以开始在引擎范围Bindings中存储键值对,因为当您创建SimpleScriptContext对象时,在引擎范围中创建了一个空的BindingssetAttribute()方法用于向Bindings添加一个键值对。您必须为Bindings提供键名、值和范围。以下代码片段添加了三个键值对:

// Add three key-value pairs to the engine scope bindings
ctx.setAttribute("year", 1969, ScriptContext.ENGINE_SCOPE);
ctx.setAttribute("month", 9, ScriptContext.ENGINE_SCOPE);
ctx.setAttribute("day", 19, ScriptContext.ENGINE_SCOPE);

如果您想在全局范围内将键值对添加到Bindings中,您需要首先创建并设置Bindings,如下所示:

// Add a global scope Bindings to the context
Bindings globalBindings = new SimpleBindings();
ctx.setBindings(globalBindings,
    ScriptContext.GLOBAL_SCOPE);

现在,您可以使用setAttribute()方法在全局范围内向Bindings添加键值对,如下所示:

// Add two key-value pairs to the global scope bindings
ctx.setAttribute("year", 1982,
    ScriptContext.GLOBAL_SCOPE);
ctx.setAttribute("name", "Boni",
    ScriptContext.GLOBAL_SCOPE);

此时,您可以可视化ScriptContext实例的状态,如图 10-1 所示。

img/323070_3_En_10_Fig1_HTML.jpg

图 10-1

SimpleScriptContext 类实例的图示视图

您可以在ScriptContext上执行多项操作。您可以使用setAttribute(String name, Object value, int scope)方法为已存储的密钥设置不同的值。对于指定的键和范围,可以使用removeAttribute(String name, int scope)方法移除键-值对。您可以使用getAttribute(String name, int scope)方法获得指定范围内的键值。

使用ScriptContext可以做的最有趣的事情是检索一个键值,而不用使用它的getAttribute(String name)方法指定它的作用域。一个ScriptContext首先在引擎范围Bindings中搜索关键字。如果在引擎范围内没有找到,则在全局范围内搜索Bindings。如果在这些范围中找到该键,则返回首先找到该键的范围中的相应值。如果两个范围都不包含该键,则返回null

在您的示例中,您已经在引擎范围和全局范围中存储了名为year的键。当首先搜索引擎范围时,下面的代码片段从引擎范围返回关键字year1969getAttribute()方法的返回类型是Object:

// Get the value of the key year without specifying the
// scope. It returns 1969 from the Bindings in the engine
// scope.
int yearValue = (Integer) ctx.getAttribute("year");

您只在全局范围内存储了名为name的键。如果尝试检索其值,将首先搜索引擎范围,这不会返回匹配项。随后,搜索全局范围,并返回值"Boni",如下所示:

// Get the value of the key named name without specifying
// the scope.
// It returns "Boni" from the Bindings in the global scope.
String nameValue = (String) ctx.getAttribute("name");

您还可以检索特定范围内的键值。以下代码片段从引擎范围和全局范围中检索关键字"year"的值:

// Assigns 1969 to engineScopeYear and 1982 to
// globalScopeYear
int engineScopeYear = (Integer) ctx.getAttribute(
    "year", ScriptContext.ENGINE_SCOPE);
int globalScopeYear = (Integer) ctx.getAttribute(
    "year", ScriptContext.GLOBAL_SCOPE);

Note

Java 脚本 API 只定义了两个作用域:引擎和全局。ScriptContext接口的子接口可以定义额外的作用域。ScriptContext接口的getScopes()方法返回一个支持范围的列表作为List<Integer>。请注意,作用域表示为整数。在ScriptContext界面中的两个常量ENGINE_SCOPEGLOBAL_SCOPE分别被赋值为 100 和 200。当在出现在多个范围中的多个Bindings中搜索一个键时,首先搜索具有较小整数值的范围。因为引擎范围的值 100 小于全局范围的值 200,所以当您不指定范围时,首先在引擎范围中搜索一个键。

清单 10-10 展示了如何使用实现ScriptContext接口的类的实例。请注意,您不能在应用程序中单独使用ScriptContext。它由脚本引擎在脚本执行期间使用。最常见的是,你通过一个ScriptEngine和一个ScriptEngineManager间接地操纵一个ScriptContext,这将在下一节详细讨论。

// ScriptContextTest.java
package com.jdojo.script;
import java.util.List;
import javax.script.Bindings;
import javax.script.ScriptContext;
import javax.script.SimpleBindings;
import javax.script.SimpleScriptContext;
import static javax.script.ScriptContext.ENGINE_SCOPE;
import static javax.script.ScriptContext.GLOBAL_SCOPE;
public class ScriptContextTest {
    public static void main(String[] args) {
        // Create a script context
        ScriptContext ctx = new SimpleScriptContext();
        // Get the list of scopes supported by the script
        // context
        List<Integer> scopes = ctx.getScopes();
        System.out.println("Supported Scopes: " + scopes);
        // Add three key-value pairs to the engine scope
        // bindings
        ctx.setAttribute("year", 1969, ENGINE_SCOPE);
        ctx.setAttribute("month", 9, ENGINE_SCOPE);
        ctx.setAttribute("day", 19, ENGINE_SCOPE);
        // Add a global scope Bindings to the context
        Bindings globalBindings = new SimpleBindings();
        ctx.setBindings(globalBindings, GLOBAL_SCOPE);
        // Add two key-value pairs to the global scope
        // bindings
        ctx.setAttribute("year", 1982, GLOBAL_SCOPE);
        ctx.setAttribute("name", "Boni", GLOBAL_SCOPE);
        // Get the value of year without specifying the
        // scope
        int yearValue =
            (Integer) ctx.getAttribute("year");
        System.out.println("yearValue = " + yearValue);
        // Get the value of name
        String nameValue =
            (String) ctx.getAttribute("name");
        System.out.println("nameValue = " + nameValue);
        // Get the value of year from engine  and global
        // scopes
        int engineScopeYear = (Integer) ctx.
            getAttribute("year", ENGINE_SCOPE);
        int globalScopeYear = (Integer) ctx.
            getAttribute("year", GLOBAL_SCOPE);
        System.out.println("engineScopeYear = " +
            engineScopeYear);
        System.out.println("globalScopeYear = " +
            globalScopeYear);
    }
}

Supported Scopes: [100, 200]
yearValue = 1969
nameValue = Boni
engineScopeYear = 1969
globalScopeYear = 1982

Listing 10-10Using an Instance of the ScriptContext Interface

把它们放在一起

在这一节中,我将向您展示Bindings的实例及其作用域、ScriptContextScriptEngineScriptEngineManager和宿主应用程序是如何协同工作的。重点是如何使用一个ScriptEngine和一个ScriptEngineManager在不同的范围内操作存储在Bindings中的键值对。

一个ScriptEngineManager在一个Bindings中维护一组键值对。它允许您使用以下方法处理这些键值对:

  • void put(String key, Object value)

  • Object get(String key)

  • void setBindings(Bindings bindings)

  • Bindings getBindings()

put()方法向Bindings添加一个键值对。get()方法返回指定键的值;如果没有找到密钥,它返回null。使用setBindings()方法可以替换发动机管理器的BindingsgetBindings()方法返回ScriptEngineManagerBindings的引用。

默认情况下,每个ScriptEngine都有一个被称为默认上下文的ScriptContext。回想一下,除了读者和作者,一个ScriptContext有两个Bindings:一个在引擎范围内,一个在全局范围内。当一个ScriptEngine被创建时,它的引擎作用域Bindings为空,它的全局作用域Bindings引用创建它的ScriptEngineManagerBindings

默认情况下,由ScriptEngineManager创建的ScriptEngine的所有实例共享ScriptEngineManagerBindings。在同一个 Java 应用程序中可能有多个ScriptEngineManager实例。在这种情况下,由同一个ScriptEngineManager创建的ScriptEngine的所有实例共享ScriptEngineManagerBindings作为它们默认上下文的全局作用域Bindings

下面的代码片段创建了一个ScriptEngineManager,用于创建ScriptEngine的三个实例:

// Create a ScriptEngineManager
ScriptEngineManager manager = new ScriptEngineManager();
// Create three ScriptEngines using the same
// ScriptEngineManager
ScriptEngine engine1 = manager.getEngineByName(
    "Groovy");
ScriptEngine engine2 = manager.getEngineByName(
    "Groovy");
ScriptEngine engine3 = manager.getEngineByName(
    "Groovy");

现在,让我们给ScriptEngineManagerBindings添加三个键值对,给每个ScriptEngine的引擎范围Bindings添加两个键值对:

// Add three key-value pairs to the Bindings
// of the manager
manager.put("K1", "V1");
manager.put("K2", "V2");
manager.put("K3", "V3");
// Add two key-value pairs to each engine
engine1.put("KE11", "VE11");
engine1.put("KE12", "VE12");
engine2.put("KE21", "VE21");
engine2.put("KE22", "VE22");
engine3.put("KE31", "VE31");
engine3.put("KE32", "VE32");

图 10-2 显示了前一段代码执行后ScriptEngineManager和三个ScriptEngine的状态。从图中可以明显看出,所有ScriptEngine的默认上下文共享ScriptEngineManagerBindings作为它们的全局作用域Bindings

img/323070_3_En_10_Fig2_HTML.jpg

图 10-2

由 ScriptEngineManager 创建的三个 ScriptEngines 的图示视图

ScriptEngineManager中的Bindings可以通过以下方式修改:

  • 通过使用ScriptEngineManagerput()方法

  • 通过使用ScriptEngineManagergetBindings()方法获取Bindings的引用,然后在Bindings上使用put()remove()方法

  • 通过使用getBindings()方法在ScriptEngine的默认上下文的全局范围内获取Bindings的引用,然后在Bindings上使用put()remove()方法

当一个ScriptEngineManager中的Bindings被修改时,由这个ScriptEngineManager创建的所有ScriptEngine的默认上下文中的全局作用域Bindings被修改,因为它们共享同一个Bindings

每个ScriptEngine的默认上下文分别维护一个引擎范围Bindings。要将一个键-值对添加到一个ScriptEngine的引擎作用域Bindings,使用它的put()方法,如下所示:

ScriptEngine engine1 = null; // get an engine
// Add an "engineName" key with its value as "Engine-1"
// to the engine scope Bindings of the default context
// of engine1
engine1.put("engineName", "Engine-1");

ScriptEngineget(String key)方法从其引擎作用域Bindings返回指定的key的值。下面的语句返回"Engine-1",它是engineName键的值:

String eName = (String) engine1.get("engineName");

在默认的ScriptEngine上下文中,获得全局作用域Bindings的键值对需要两个步骤。首先,您需要使用它的getBindings()方法获取全局作用域Bindings的引用,如下所示:

Bindings e1Global =
    engine1.getBindings(ScriptContext.GLOBAL_SCOPE);

现在,您可以使用e1Global引用来修改引擎的全局范围Bindings。下面的语句向e1Global Bindings添加了一个键值对:

e1Global.put("id", 89999);

因为所有的ScriptEngine共享一个ScriptEngine的全局作用域Bindings,这段代码将把键id及其值添加到所有ScriptEngine的默认上下文的全局作用域Bindings中,这些默认上下文是由创建engine1的同一ScriptEngineManager创建的。不建议使用之前的代码修改ScriptEngineManager中的Bindings。您应该改为使用ScriptEngineManager引用来修改Bindings,这使得代码的读者可以更清楚地理解逻辑。

清单 10-11 展示了本节讨论的概念。

ScriptEngineManager
// GlobalBindings.java
package com.jdojo.script;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
public class GlobalBindings {
    public static void main(String[] args) {
        ScriptEngineManager manager =
            new ScriptEngineManager();
        // Add two numbers to the Bindings of the
        // manager - shared by all its engines
        manager.put("n1", 100);
        manager.put("n2", 200);
        // Create two JavaScript engines and add the name
        // of the engine in the engine scope of the default
        // context of the engines
        ScriptEngine engine1 = manager.getEngineByName(
            "Groovy");
        engine1.put("engineName", "Engine-1");
        ScriptEngine engine2 = manager.getEngineByName(
            "Groovy");
        engine2.put("engineName", "Engine-2");
        // Execute a script that adds two numbers and
        // prints the result
        String script = """
            def sum = n1 + n2
            println(engineName + ' - Sum = ' + sum)
        """;
        try {
            // Execute the script in two engines
            engine1.eval(script);
            engine2.eval(script);
            // Now add a different value for n2 for each
            // engine 

            engine1.put("n2", 1000);
            engine2.put("n2", 2000);
            // Execute the script in two engines again
            engine1.eval(script);
            engine2.eval(script);
        } catch (ScriptException e) {
            e.printStackTrace();
        }
    }
}

Engine-1 - Sum = 300
Engine-2 - Sum = 300
Engine-1 - Sum = 1100
Engine-2 - Sum = 2100

Listing 10-11Using Global and Engine Scope Bindings of Engines Created by the Same

A ScriptEngineManager向它的Bindings添加两个键-值对,键为n1n2。创造了两个ScriptEngine;他们在引擎范围Bindings中添加了一个名为engineName的键。当脚本被执行时,脚本中的engineName变量的值从ScriptEngine的引擎范围中被使用。脚本中变量n1n2的值是从ScriptEngine的全局作用域Bindings中获取的。在第一次执行该脚本后,每个ScriptEngine向它们的引擎范围Bindings添加一个名为n2的键,该键具有不同的值。当您第二次执行脚本时,变量n1的值从引擎的全局作用域Bindings中检索,而变量n2的值从引擎作用域Bindings中检索,如输出所示。

由一个ScriptEngineManager创建的所有ScriptEngines共享的全局范围Bindings的故事还没有结束。这是最复杂、最令人困惑的事情!现在重点将放在使用ScriptEngineManager类的setBindings()方法和ScriptEngine接口的效果上。考虑以下代码片段:

// Create a ScriptEngineManager and two ScriptEngines
ScriptEngineManager manager = new ScriptEngineManager();
ScriptEngine engine1 = manager.getEngineByName(
    "Groovy");
ScriptEngine engine2 = manager.getEngineByName(
    "Groovy");
// Add two key-value pairs to the manager
manager.put("n1", 100);
manager.put("n2", 200);

图 10-3 显示了该脚本执行后引擎管理器及其引擎的状态。此时,ScriptEngineManager中只存储了一个Bindings,两个ScriptEngine正在将其作为自己的全局作用域Bindings进行引用。

img/323070_3_En_10_Fig3_HTML.jpg

图 10-3

ScriptEngineManager 和两个 ScriptEngines 的初始状态

让我们创建一个新的Bindings,并使用setBindings()方法将其设置为ScriptEngineManagerBindings,如下所示:

// Create a Bindings, add two key-value pairs to it, and
// set it as the new Bindings for the manager
Bindings newGlobal = new SimpleBindings();
newGlobal.put("n3", 300);
newGlobal.put("n4", 400);
manager.setBindings(newGlobal);

图 10-4 显示了前一段代码执行后ScriptEngineManager和两个ScriptEngine的状态。请注意,ScriptEngineManager有了新的Bindings,而两个ScriptEngine仍然将旧的Bindings称为它们的全球范围Bindings

img/323070_3_En_10_Fig4_HTML.jpg

图 10-4

设置新绑定后 ScriptEngineManager 和两个 ScriptEngines 的状态

此时,对ScriptEngineManagerBindings所做的任何更改都不会反映在两个ScriptEngine的全局作用域Bindings

您仍然可以对两个ScriptEngine共享的Bindings进行更改,两个ScriptEngine都将看到其中一个所做的更改。

让我们创建一个新的ScriptEngine,如图所示:

// Create a new ScriptEngine
ScriptEngine engine3 = manager.getEngineByName(
    "Groovy");

回想一下,ScriptEngine在创建时获得了全局作用域Bindings,BindingsScriptEngineManagerBindings相同。前一条语句执行后,ScriptEngineManager和三个ScriptEngine的状态如图 10-5 所示。

img/323070_3_En_10_Fig5_HTML.jpg

图 10-5

创建第三个 ScriptEngine 后 ScriptEngineManager 和三个 script engine 的状态

这里是对所谓的ScriptEngine s 的全局范围的“全球性”的另一种扭曲。这一次,您将使用一个ScriptEnginesetBindings()方法来设置它的全局范围Bindings:

// Set a new Bindings for the global scope of engine1
Bindings newGlobalEngine1 = new SimpleBindings();
newGlobalEngine1.put("n5", 500);
newGlobalEngine1.put("n6", 600);
engine1.setBindings(newGlobalEngine1,
    ScriptContext.GLOBAL_SCOPE);

图 10-6 显示了前一段代码执行后ScriptEngineManager和三个脚本引擎的状态。

img/323070_3_En_10_Fig6_HTML.jpg

图 10-6

设置新的全局范围绑定后 ScriptEngineManager 和三个 ScriptEngines 的状态

Note

默认情况下,a ScriptEngineManager创建的所有ScriptEngine共享其Bindings作为它们的全局作用域Bindings。如果你使用一个ScriptEnginesetBindings()方法来设置它的全局作用域Bindings,或者如果你使用一个ScriptEngineManagersetBindings()方法来设置它的Bindings,你就打破了“全局”链,如本节所讨论的。为了保持“全局”链的完整性,您应该总是使用ScriptEngineManagerput()方法将键值对添加到它的Bindings中。要从由ScriptEngineManager创建的所有ScriptEngine的全局范围中删除一个键值对,您需要使用ScriptEngineManagergetBindings()方法获取Bindings的引用,并在Bindings上使用remove()方法。

使用自定义脚本上下文

在上一节中,您看到每个ScriptEngine都有一个默认的脚本上下文。ScriptEngineget()put()getBindings()setBindings()方法在默认ScriptContext下运行。当ScriptEngineeval()方法没有指定ScriptContext时,使用引擎的默认上下文。ScriptEngineeval()方法的以下两个版本使用其默认上下文来执行脚本:

  • Object eval(String script)

  • Object eval(Reader reader)

您可以将一个Bindings传递给下面两个版本的eval()方法:

  • Object eval(String script, Bindings bindings)

  • Object eval(Reader reader, Bindings bindings)

这些版本的eval()方法不使用默认的ScriptEngine上下文。他们使用一个新的ScriptContext,其引擎范围Bindings是传递给这些方法的那个,全局范围Bindings与引擎的默认上下文相同。注意,eval()方法的这两个版本保持了ScriptEngine的默认上下文不变。

您可以将一个ScriptContext传递给下面两个版本的eval()方法:

  • Object eval(String script, ScriptContext context)

  • Object eval(Reader reader, ScriptContext context)

这些版本的eval()方法使用指定的上下文来执行脚本。它们保持ScriptEngine的默认上下文不变。

三组eval()方法允许您使用不同的隔离级别执行脚本:

  • 第一组让所有脚本共享默认上下文。

  • 第二组让脚本使用不同的引擎作用域Bindings并共享全局作用域Bindings

  • 第三组让脚本在隔离的ScriptContext中执行。

清单 10-12 展示了如何使用不同版本的eval()方法在不同的隔离级别执行脚本。

// CustomContext.java
package com.jdojo.script;
import javax.script.Bindings;
import javax.script.ScriptContext;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
import javax.script.SimpleScriptContext;
import static javax.script.SimpleScriptContext.
    ENGINE_SCOPE;
import static javax.script.SimpleScriptContext.
    GLOBAL_SCOPE;
public class CustomContext {
    public static void
    main(String[] args) throws ScriptException {
        ScriptEngineManager manager =
            new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByName(
            "Groovy");
        // Add n1 to Bindings of the manager, which will
        // be shared by all engines as their global scope
        // Bindings
        manager.put("n1", 100);
        // Prepare the script
        String script = """
            def sum = n1 + n2
            println(msg + ' n1=' + n1 + ', n2=' + n2 +
                    ', sum=' + sum)
        """;
        // Add n2 to the engine scope of the default
        // context of the engine
        engine.put("n2", 200);
        engine.put("msg", "Using the default context:");
        engine.eval(script);
        // Use a Bindings to execute the script
        Bindings bindings = engine.createBindings();
        bindings.put("n2", 300);
        bindings.put("msg", "Using a Bindings:");
        engine.eval(script, bindings);
        // Use a ScriptContext to execute the script
        ScriptContext ctx = new SimpleScriptContext();
        Bindings ctxGlobalBindings =
            engine.createBindings();
        ctx.setBindings(ctxGlobalBindings, GLOBAL_SCOPE);
        ctx.setAttribute("n1", 400, GLOBAL_SCOPE);
        ctx.setAttribute("n2", 500, ENGINE_SCOPE);
        ctx.setAttribute("msg", "Using a ScriptContext:",
            ENGINE_SCOPE); 

        engine.eval(script, ctx);
        // Execute the script again using the default
        // context to prove that the default context is
        // unaffected.
        engine.eval(script);
    }
}

Using the default context: n1=100, n2=200, sum=300
Using a Bindings: n1=100, n2=300, sum=400
Using a ScriptContext: n1=400, n2=500, sum=900
Using the default context: n1=100, n2=200, sum=300

Listing 10-12Using Different Isolation Levels for Executing Scripts

该程序使用三个变量,称为msgn1n2。它显示存储在msg变量中的值。将n1n2的值相加,并显示总和。该脚本打印出在计算总和时使用了什么值的n1n2n1的值存储在由所有ScriptEngine的默认上下文共享的ScriptEngineManagerBindings中。n2的值存储在默认上下文和自定义上下文的引擎范围中。该脚本使用引擎的默认上下文执行两次,一次在开始,一次在结束,以证明在eval()方法中使用自定义BindingsScriptContext不会影响ScriptEngine的默认上下文中的Bindings。该程序在其main()方法中声明了一个throws子句,以使代码更短。

eval()方法的返回值

ScriptEngineeval()方法返回一个Object,这是脚本中的最后一个值。如果脚本中没有最后一个值,它将返回null。依赖脚本中的最后一个值容易出错,同时也令人困惑。下面的代码片段展示了一些为 Groovy 使用eval()方法返回值的例子。代码中的注释表示从eval()方法返回的值:

Object result = null;
// Assigns 3 to result
result = engine.eval("1 + 2");
// Assigns 7 to result
result = engine.eval("1 + 2; 3 + 4");
// Assigns 6 to result
result = engine.eval("""1 + 2; 3 + 4;
    def v = 5; v = 6""");
// Assigns 5 to result
result = engine.eval("""1 + 2; 3 + 4;
    def v = 5""");
// Assigns null to result
result = engine.eval("println(1 + 2)");

最好不要依赖于eval()方法的返回值。您应该将一个 Java 对象作为参数传递给脚本,并让脚本将脚本的返回值存储在该对象中。在执行了eval()方法之后,您可以查询这个 Java 对象的返回值。

清单 10-13 包含包装整数的Result类的代码。您将向脚本传递一个Result类的对象,脚本将在其中存储返回值。脚本完成后,您可以在 Java 代码中读取存储在Result对象中的整数值。需要将Result声明为公共的,这样脚本引擎就可以访问它。

// Result.java
package com.jdojo.script;
public class Result {
    public int val = -1;
}

Listing 10-13A Result Class That Wraps an Integer

清单 10-14 中的程序展示了如何将一个Result对象传递给一个用值填充Result对象的脚本。该程序在main()方法的声明中包含一个throws子句,以保持代码简短。

// ResultBearingScript.java
package com.jdojo.script;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
public class ResultBearingScript {
    public static void
    main(String[] args) throws ScriptException {
        // Get the Groovy engine
        ScriptEngineManager manager =
            new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByName(
            "Groovy");
        // Pass a Result object to the script. The script
        // will store the result of the script in the
        // result object
        Result result = new Result();
        engine.put("result", result);
        // Store the script in a String
        String script = "3 + 4; result.val = 101";
        // Execute the script, which uses the passed in
        // Result object to return a value
        engine.eval(script);
        // Use the result object to get the returned value
        // from the script
        int returnedValue = result.val; // -> 101
        System.out.println("Returned value is " +
            returnedValue);
    }
}

Returned value is 101

Listing 10-14Collecting the Return Value of a Script in a Result Object

引擎范围绑定的保留键

通常,引擎范围Bindings中的一个键代表一个脚本变量。有些键是保留的,它们有特殊的含义。它们的值可以通过引擎的实现传递给引擎。一个实现可以定义附加的保留密钥。

表 10-1 包含所有保留密钥的列表。这些键在ScriptEngine接口中也被声明为常量。脚本引擎的实现不需要在引擎范围绑定中将所有这些键传递给引擎。作为开发人员,您不应该使用这些键将参数从 Java 应用程序传递到脚本引擎。

表 10-1

引擎范围绑定的保留键

|

钥匙

|

ScriptEngine 接口中的常数

|

键值的含义

|
| --- | --- | --- |
| "javax.script.argv" | ScriptEngine.ARGV | 用来传递一个数组Object来传递一组位置参数。 |
| "javax.script.engine" | ScriptEngine.ENGINE | 脚本引擎的名称。 |
| "javax.script.engine_version" | ScriptEngine.ENGINE_VERSION | 脚本引擎的版本。 |
| "javax.script.filename" | ScriptEngine.FILENAME | 用于传递作为脚本源的文件或资源的名称。 |
| "javax.script.language" | ScriptEngine.LANGUAGE | 脚本引擎支持的语言的名称。 |
| "javax.script.language_version" | ScriptEngine.LANGUAGE_VERSION | 引擎支持的脚本语言版本。 |
| "javax.script.name" | ScriptEngine.NAME | 脚本语言的简称。 |

更改默认脚本上下文

您可以分别使用getContext()setContext()方法来获取和设置ScriptEngine的默认上下文,如下所示:

ScriptEngineManager manager = new ScriptEngineManager();
ScriptEngine engine = manager.getEngineByName(
    "Groovy");
// Get the default context of the ScriptEngine
ScriptContext defaultCtx = engine.getContext();
// Work with defaultCtx here
// Create a new context
ScriptContext ctx = new SimpleScriptContext();
// Configure ctx here
// Set ctx as the new default context for the engine
engine.setContext(ctx);

注意,为一个ScriptEngine设置一个新的默认上下文不会使用ScriptEngineManagerBindings作为它的全局作用域Bindings。如果您希望新的默认上下文使用ScriptEngineManagerBindings,您需要显式设置它,如下所示:

// Create a new context
ScriptContext ctx = new SimpleScriptContext();
// Set the global scope Bindings for ctx the same as the
// Bindings for the manager
ctx.setBindings(manager.getBindings(),
    ScriptContext.GLOBAL_SCOPE);
// Set ctx as the new default context for the engine
engine.setContext(ctx);

将脚本输出发送到文件

您可以自定义脚本执行的输入源、输出目标和错误输出目标。您需要为用于执行脚本的ScriptContext设置适当的读取器和写入器。下面的代码片段将把脚本输出写到当前目录中名为output.txt的文件中:

// Create a FileWriter
FileWriter writer = new FileWriter("output.txt");
// Get the default context of the engine
ScriptContext defaultCtx = engine.getContext();
// Set the output writer for the default context of the
// engine
defaultCtx.setWriter(writer);

该代码为ScriptEngine的默认上下文设置了一个自定义输出编写器,在使用默认上下文的脚本执行过程中将会用到这个编写器。如果您想使用定制的输出编写器来执行特定的脚本,您需要使用一个定制的ScriptContext并设置它的编写器。

Note

ScriptContext设置自定义输出编写器不会影响 Java 应用程序标准输出的目的地。要重定向 Java 应用程序的标准输出,您需要使用System.setOut()方法。

清单 10-15 向您展示了如何将脚本执行的输出写到名为output.txt的文件中。该程序在标准输出中打印输出文件的完整路径。运行该程序时,您可能会得到不同的输出。您需要在文本编辑器中打开输出文件来查看脚本的输出。

// CustomScriptOutput.java
package com.jdojo.script;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import javax.script.ScriptContext;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
public class CustomScriptOutput {
    public static void main(String[] args) {
        // Get the Groovy engine
        ScriptEngineManager manager =
            new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByName(
            "Groovy");
        // Print the absolute path of the output file
        File outputFile = new File("output.txt");
        System.out.println(
            "Script output will be written to "
            + outputFile.getAbsolutePath());
        try (FileWriter writer =
                new FileWriter(outputFile)) {
            // Set a custom output writer for the engine
            ScriptContext defaultCtx =
                engine.getContext();
            defaultCtx.setWriter(writer);
            // Execute a script
            String script =
                "println('Hello custom output writer')";
            engine.eval(script);
        } catch (IOException | ScriptException e) {
            e.printStackTrace();
        }
    }
}

Listing 10-15Writing the Output of Scripts to a File

脚本输出将被写入当前工作目录中的文件output.txt

在脚本中调用过程

脚本语言可以允许创建过程、函数和方法。Java 脚本 API 允许您从 Java 应用程序中调用这样的过程、函数和方法。在本节中,我使用术语“过程”来表示过程、函数和方法。当讨论的上下文需要时,我使用特定的术语。

并非所有脚本引擎都需要支持过程调用。Groovy 引擎支持过程调用。如果有脚本引擎支持,那么脚本引擎类的实现必须实现Invocable接口。在调用过程之前,检查脚本引擎是否实现了Invocable接口是开发人员的责任。调用过程包括四个步骤:

  • 检查脚本引擎是否支持过程调用。

  • 将发动机参考转换为Invocable类型。

  • 评估包含该过程源代码的脚本。

  • 使用Invocable接口的invokeFunction()方法调用过程和函数。使用invokeMethod()方法来调用在脚本语言中创建的对象的方法。

以下代码片段检查脚本引擎实现类是否实现了Invocable接口:

// Get the Groovy engine
ScriptEngineManager manager = new ScriptEngineManager();
ScriptEngine engine = manager.getEngineByName(
    "Groovy");
// Make sure the script engine implements the Invocable
// interface
if (engine instanceof Invocable) {
    System.out.println(
        "Invoking procedures is supported.");
} else {
    System.out.println(
        "Invoking procedures is not supported.");
}

第二步是将引擎引用转换为Invocable接口类型:

Invocable inv = (Invocable) engine;

第三步是评估脚本,因此脚本引擎编译并存储过程的编译形式,供以后调用。以下代码片段执行此步骤:

// Declare a function named add that adds two numbers
String script = "def add(n1, n2) { n1 + n2 }";
// Evaluate the function. Call to eval() does not invoke
// the function. It just compiles it.
engine.eval(script);

最后一步是调用过程或函数:

// Invoke the add function with 30 and 40 as the function's
// arguments. It is as if you called add(30, 40) in the
// script.
Object result = inv.invokeFunction("add", 30, 40);

invokeFunction()的第一个参数是过程或函数的名称。第二个参数是 varargs,用于指定过程或函数的参数。invokeFunction()方法返回过程或函数返回的值。

清单 10-16 显示了如何调用一个函数。它调用用 Groovy 编写的函数。

// InvokeFunction.java
package com.jdojo.script;
import javax.script.Invocable;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
public class InvokeFunction {
    public static void main(String[] args) {
        ScriptEngineManager manager =
            new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByName(
            "Groovy");
        // Make sure the script engine implements the
        // Invocable interface
        if (!(engine instanceof Invocable)) {
            System.out.println(
                "Invoking procedures is not supported.");
            return;
        }
        // Cast the engine reference to the Invocable type
        Invocable inv = (Invocable) engine;
        try {
            String script =
              "def add(n1, n2) { n1 + n2 }";
            // Evaluate the script first
            engine.eval(script);
            // Invoke the add function twice
            Object result1 = inv.invokeFunction(
                "add", 30, 40);
            System.out.println("Result1 = " + result1);
            Object result2 = inv.invokeFunction(
                "add", 10, 20);
            System.out.println("Result2 = " + result2);
        } catch (ScriptException |
                NoSuchMethodException e) {
            e.printStackTrace();
        }
    }
}

Result1 = 70
Result2 = 30

Listing 10-16Invoking a Function Written in Groovy

面向对象或基于对象的脚本语言可以让您定义对象及其方法。您可以使用Invocable接口的invokeMethod()方法调用这些对象的方法,声明如下:

Object invokeMethod(Object objectRef, String name,
    Object... args)

第一个参数是对象的引用,第二个参数是要在对象上调用的方法的名称,第三个参数是 varargs 参数,用于将参数传递给被调用的方法。

清单 10-17 展示了在 Groovy 中创建的对象上的方法调用。注意,该对象是在 Groovy 脚本中创建的。要从 Java 调用对象的方法,需要通过脚本引擎获取对象的引用。该程序评估使用 add()方法创建对象的脚本,并将其引用存储在名为 calculator 的变量中。engine.get("calculator ")方法返回 calculator 对象对 Java 代码的引用。

// InvokeMethod.java
package com.jdojo.script;
import javax.script.Invocable;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
public class InvokeMethod {
    public static void main(String[] args) {
        // Get the Groovy engine
        ScriptEngineManager manager =
            new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByName(
            "Groovy");
        // Make sure the script engine implements the
        // Invocable interface
        if (!(engine instanceof Invocable)) {
            System.out.println(
                "Invoking methods is not supported.");
            return;
        }
        // Cast the engine reference to the Invocable type
        Invocable inv = (Invocable) engine;
        try {
            // Declare a global object with an add() method
            String script = """
                  class Calculator {
                  def add(int n1, int n2){n1 + n2}
                 }
                calculator = new Calculator()
                """;
            // Evaluate the script first
            engine.eval(script);
            // Get the calculator object reference created
            // in the script
            Object calculator = engine.get("calculator");
            // Invoke the add() method on the calculator
            // object
            Object result = inv.invokeMethod(calculator,
                "add", 30, 40);
            System.out.println("Result = " + result);
        } catch (ScriptException |
                NoSuchMethodException e) {
            e.printStackTrace();
        }
    }
}

Result = 70

Listing 10-17Invoking a Method on an Object Created in Groovy JavaScript

Note

使用 Invocable 接口重复执行过程、函数和方法。脚本的评估包含过程、函数和方法,将中间代码存储在引擎中,从而在重复执行时提高性能。

在脚本中实现 Java 接口

Java 脚本 API 允许您用脚本语言实现 Java 接口。Java 接口的方法可以使用顶层过程或对象的实例方法在脚本中实现。用脚本语言实现 Java 接口的优点是,您可以用 Java 代码使用接口的实例,就好像接口是用 Java 实现的一样。您可以将接口的实例作为参数传递给 Java 方法。Invocable接口的getInterface()方法用于获取在脚本中实现的 Java 接口的实例。该方法有两个版本:

  • <T> T getInterface(Class<T> cls)

  • <T> T getInterface(Object obj, Class<T> cls)

第一个版本用于获取 Java 接口的实例,该接口的方法在脚本中作为顶级过程实现。接口类型作为参数传递给该方法。假设你有一个Calculator接口,如清单 10-18 所示,它有两个方法叫做add()subtract()

// Calculator.java
package com.jdojo.script;
public interface Calculator {
    int add (int n1, int n2);
    int subtract (int n1, int n2);
}

Listing 10-18A Calculator Interface

考虑以下两个用 Groovy 编写的顶级函数:

def add(n1, n2) {
    n1 + n2
}
def subtract(n1, n2) {
    n1 -n2
}

这两个函数为Calculator接口的两个方法提供了实现。在 Groovy 脚本引擎编译了这些函数之后,您可以获得一个Calculator接口的实例,如下所示:

// Cast the engine reference to the Invocable type
Invocable inv = (Invocable) engine;
// Get the reference of the Calculator interface
Calculator calc = inv.getInterface(Calculator.class);
if (calc == null) {
    System.err.println(
        "Calculator interface implementation not found.");
} else {
    // Use calc to call add() and subtract() methods
}

您可以添加两个数字,如下所示:

int sum = calc.add(15, 10);

清单 10-19 展示了如何使用 Groovy 中的顶级过程实现 Java 接口。请查阅脚本语言的文档,了解它是如何支持这一功能的。

// UsingInterfaces.java
package com.jdojo.script;
import javax.script.Invocable;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
public class UsingInterfaces {
    public static void main(String[] args) {
        // Get the Groovy engine
        ScriptEngineManager manager =
            new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByName(
            "Groovy");
        // Make sure the script engine implements
        // Invocable interface
        if (!(engine instanceof Invocable)) {
            System.out.println(
                """Interface implementation in script
                   is not supported.""");
            return;
        }
        // Cast the engine reference to the Invocable
        // type
        Invocable inv = (Invocable) engine;
        // Create the script for add() and subtract()
        // functions
        String script = """
            def add(n1, n2) { n1 + n2 }
            def subtract(n1, n2) { n1 - n2 }
        """;
        try { 

            // Compile the script that will be stored in
            // the engine
            engine.eval(script);
            // Get the interface implementation
            Calculator calc = inv.getInterface(
                Calculator.class);
            if (calc == null) {
                System.err.println(
                    """Calculator interface implementation
                       not found.""");
                return;
            }
            int result1 = calc.add(15, 10);
            System.out.println(
                "add(15, 10) = " + result1);
            int result2 = calc.subtract(15, 10);
            System.out.println(
                "subtract(15, 10) = " + result2);
        } catch (ScriptException e) {
            e.printStackTrace();
        }
    }
}

add(15, 10) = 25
subtract(15, 10) = 5 

Listing 10-19Implementing a Java Interface Using Top-Level Functions in a Script

第二个版本的getInterface()方法用于获得一个 Java 接口的实例,该接口的方法被实现为一个对象的实例方法。它的第一个参数是用脚本语言创建的对象的引用。对象的实例方法实现作为第二个参数传入的接口类型。Groovy 中的以下代码创建了一个对象,该对象的实例方法实现了Calculator接口:

    class GCalculator {
      def add(int n1, int n2){n1 + n2}
      def subtract(int n1, int n2){n1 + n2}
    }
    calculator = new GCalculator()

当脚本对象的实例方法实现 Java 接口的方法时,您需要执行一个额外的步骤。在获取接口的实例之前,需要获取脚本对象的引用,如下所示:

// Get the reference of the global script object calc
Object calc = engine.get("calculator");
// Get the implementation of the Calculator interface
Calculator calculator =
    inv.getInterface(calc, Calculator.class);

清单 10-20 展示了如何使用 Groovy 将 Java 接口的方法实现为对象的实例方法。

// ScriptObjectImplInterface.java
package com.jdojo.script;
import javax.script.Invocable;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
public class ScriptObjectImplInterface {
    public static void main(String[] args) {
        // Get the Groovy engine
        ScriptEngineManager manager =
            new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByName(
            "Groovy");
        // Make sure the engine implements the Invocable
        // interface 

        if (!(engine instanceof Invocable)) {
            System.out.println(
                """Interface implementation in script is
                not supported.""");
            return;
        }
        // Cast the engine reference to the Invocable type
        Invocable inv = (Invocable) engine;
        String script = """
          class GCalculator {
            def add(int n1, int n2){n1 + n2}
            def subtract(int n1, int n2){n1 + n2}
          }
          calculator = new GCalculator()
        """;
        try {
            // Compile and store the script in the engine
            engine.eval(script);
            // Get the reference of the global script
            // object calc
            Object calc = engine.get("calculator");
            // Get the implementation of the Calculator
            // interface
            Calculator calculator =
                inv.getInterface(calc, Calculator.class);
            if (calculator == null) {
                System.err.println(
                    """Calculator interface implementation
                    not found.""");
                return;
            }
            int result1 = calculator.add(15, 10);
            System.out.println(
                "add(15, 10) = " + result1);
            int result2 = calculator.subtract(15, 10);
            System.out.println(
                "subtract(15, 10) = " + result2);
        } catch (ScriptException e) {
            e.printStackTrace();
        } 

    }
}

add(15, 10) = 25
subtract(15, 10) = 5

Listing 10-20Implementing Methods of a Java Interface As Instance Methods of an Object in a Script

使用编译的脚本

脚本引擎可以允许编译脚本并重复执行它。执行编译后的脚本可以提高应用程序的性能。脚本引擎可以以 Java 类、Java 类文件的形式或特定于语言的形式编译和存储脚本。

并非所有脚本引擎都需要支持脚本编译。支持脚本编译的脚本引擎必须实现Compilable接口。Groovy 引擎支持脚本编译。以下代码片段检查脚本引擎是否实现了Compilable接口:

// Get the script engine reference
ScriptEngineManager manager = new ScriptEngineManager();
ScriptEngine engine = manager.getEngineByName(
    "YOUR_ENGINE_NAME");
if (engine instanceof Compilable) {
    System.out.println(
        "Script compilation is supported.");
} else {
    System.out.println(
        "Script compilation is not supported.");
}

一旦您知道脚本引擎实现了Compilable接口,您就可以将其引用转换为Compilable类型

// Cast the engine reference to the Compilable type
Compilable comp = (Compilable) engine;

Compilable接口包含两个方法:

  • CompiledScript compile(String script) throws ScriptException

  • CompiledScript compile(Reader script) throws ScriptException

该方法的两个版本仅在脚本源的类型上有所不同。第一个版本接受脚本作为String,第二个版本接受脚本作为Reader

compile()方法返回一个CompiledScript类的对象。CompiledScript是一个抽象类。脚本引擎的提供者提供了这个类的具体实现。一个CompiledScript与创建它的ScriptEngine相关联。CompiledScript类的getEngine()方法返回与其关联的ScriptEngine的引用。

要执行编译后的脚本,您需要调用CompiledScript类的以下eval()方法之一:

  • Object eval() throws ScriptException

  • Object eval(Bindings bindings) throws ScriptException

  • Object eval(ScriptContext context) throws ScriptException

没有任何参数的eval()方法使用脚本引擎的默认脚本上下文来执行编译后的脚本。当你向另外两个版本传递一个Bindings或一个ScriptContext时,它们的工作方式与ScriptEngine接口的eval()方法相同。

清单 10-21 展示了如何编译并执行一个脚本。它使用不同的参数将相同的编译脚本执行两次。

// CompilableTest .java
package com.jdojo.script;
import javax.script.Bindings;
import javax.script.Compilable;
import javax.script.CompiledScript;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
public class CompilableTest {
    public static void main(String[] args) {
        // Get the Groovy engine
        ScriptEngineManager manager =
            new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByName(
            "Groovy");
        if (!(engine instanceof Compilable)) {
            System.out.println(
                "Script compilation not supported.");
            return;
        }
        // Cast the engine reference to the Compilable
        // type
        Compilable comp = (Compilable) engine;
        try {
            // Compile a script
            String script = "println(n1 + n2)";
            CompiledScript cScript = comp.compile(script);
            // Store n1 and n2 script variables in a
            // Bindings
            Bindings scriptParams =
                engine.createBindings();
            scriptParams.put("n1", 2);
            scriptParams.put("n2", 3);
            cScript.eval(scriptParams);
            // Execute the script again with different
            // values for n1 and n2
            scriptParams.put("n1", 9);
            scriptParams.put("n2", 7);
            cScript.eval(scriptParams);
        } catch (ScriptException e) {
            e.printStackTrace();
        }
    }
}
5
16

Listing 10-21Using Compiled Scripts

在脚本语言中使用 Java

脚本语言允许在脚本中使用 Java 类库。每种脚本语言都有自己的使用 Java 类的语法。讨论所有脚本语言的语法是不可能的,也超出了本书的范围。在这一节中,我将讨论在 Groovy 中使用一些 Java 结构的语法。关于 Groovy 的完整报道,请参考网站 www.groovy-lang.org/

声明变量

在脚本语言中声明变量并不一定与 Java 相关。通常,脚本语言允许您在不声明变量的情况下给变量赋值。然后在运行时基于变量存储的值的类型来确定变量的类型。

在 Groovy 中,关键字def用于声明一个变量。如果您决定在变量声明中省略关键字def,那么该变量在整个脚本中都是可访问的,尽管不是在您在脚本中声明的类中,并且在脚本被处理后,可以从 Java 中访问该值。以下代码片段声明了两个变量,并为它们赋值:

// Declare a variable named msg using the def keyword
def msg = "Hello";

// Declare a variable named greeting without using the
// keyword def. We can later use
//     Object greeting = engine.get("greeting");
// in Java to get the value.
greeting = "Hello";

导入 Java 类

Groovy 位于 JVM 之上,所以您可以像导入 Java 类文件一样,将 Java 类从标准库中导入到 Groovy 脚本中。这同样适用于项目中包含的库提供的类以及项目中定义的类:

// A class from the standard library
import java.text.SimpleDateFormat

// A class defined elsewhere in the project
import java17.script.SomeJavaClass

// Some library class. Must be inside the classpath.
import com.foo.superlib.Foo

def obj = new SomeJavaClass(8)
def sdf = new SimpleDateFormat("yyyy-MM-dd")
def foo = new Foo()
...

其他脚本语言定义或者不定义它们自己导入 Java 类的方式。有关详细信息,请参考他们的文档。

实现脚本引擎

实现一个成熟的脚本引擎不是一件简单的任务,它超出了本书的范围。本节旨在为您提供实现脚本引擎所需的设置的简要但完整的概述。在本节中,您将实现一个简单的脚本引擎,称为JKScript引擎。它将使用以下规则计算算术表达式:

  • 它将计算由两个操作数和一个运算符组成的算术表达式。

  • 表达式可能有两个数字文字、两个变量,或者一个数字文字和一个变量作为操作数。数字文字必须是十进制格式。不支持十六进制、八进制和二进制数字文本。

  • 表达式中的算术运算仅限于加、减、乘和除。

  • 它会将+-*/识别为算术运算符。

  • 引擎将返回一个Double对象作为表达式的结果。

  • 可以使用引擎的全局范围或引擎范围绑定将表达式中的操作数传递给引擎。

  • 它应该允许从一个String对象和一个java.io.Reader对象执行脚本。然而,一个Reader应该只有一个表达式作为其内容。

  • 它不会实现InvocableCompilable接口。

使用这些规则,脚本引擎的一些有效表达式如下:

  • 10 + 90

  • 10.7 + 89.0

  • +10 + +90

  • num1 + num2

  • num1 * num2

  • 78.0 / 7.5

脚本 API 使用服务提供者机制来发现脚本引擎。服务类型是javax.script.ScriptEngineFactory接口。您的脚本引擎必须为此服务类型提供实现。你将把你的脚本引擎打包在一个名为jdojo.jkscript的独立模块中,如清单 10-22 中所声明的。

// module-info.java
module jdojo.jkscript {
    requires java.scripting;
    provides javax.script.ScriptEngineFactory
        with com.jdojo.jkscript.JKScriptEngineFactory;
}

Listing 10-22The Declaration of a jdojo.jkscript Module

该模块读取java.scripting模块,因为它需要使用该模块中的类型。该模块提供了javax.script.ScriptEngineFactory服务接口的实现,它是com.jdojo.jkscript.JKScriptEngineFactory类。您不需要导出您的模块的任何包,因为没有其他模块应该直接从该模块访问任何类型。

作为JKScript脚本引擎实现的一部分,你将开发表 10-2 中列出的三个类。在随后的部分中,您将开发这些类。

表 10-2

要为 jscript 脚本引擎开发的类

|

班级

|

描述

|
| --- | --- |
| Expression | Expression类是脚本引擎的核心。它执行解析和评估算术表达式的工作。它在JKScriptEngine类的eval()方法中使用。 |
| JKScriptEngine | 接口的一个实现。它扩展了实现ScriptEngine接口的AbstractScriptEngine类。AbstractScriptEngine类为ScriptEngine接口的eval()方法的几个版本提供了标准实现。您需要实现下面两个版本的eval()方法:Object eval(String, ScriptContext)Object eval(Reader, ScriptContext) |
| JKScriptEngineFactory | 接口的一个实现。这是javax.script.ScriptEngineFactory服务接口的服务提供者。 |

表达式类

Expression类包含解析和评估算术表达式的主要逻辑。清单 10-23 包含了Expression类的完整代码。

// Expression.java
package com.jdojo.jkscript;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.script.ScriptContext;
public class Expression {
    private String exp;
    private ScriptContext context;
    private String op1;
    private char op1Sign = '+';
    private String op2;
    private char op2Sign = '+';
    private char operation;
    private boolean parsed;
    public Expression(String exp, ScriptContext context) {
        if (exp == null || exp.trim().equals("")) {
            throw new IllegalArgumentException(
                this.getErrorString());
        }
        this.exp = exp.trim();
        if (context == null) {
            throw new IllegalArgumentException(
                "ScriptContext cannot be null.");
        }
        this.context = context;
    }
    public String getExpression() {
        return exp;
    }
    public ScriptContext getScriptContext() {
        return context;
    }
    public Double eval() {
        // Parse the expression
        if (!parsed) {
            this.parse();
            this.parsed = true;
        }
        // Extract the values for the operand
        double op1Value = getOperandValue(op1Sign, op1);
        double op2Value = getOperandValue(op2Sign, op2);
        // Evaluate the expression
        Double result = null;
        switch (operation) {
            case '+':
                result = op1Value + op2Value;
                break;
            case '-':
                result = op1Value - op2Value;
                break;
            case '*':
                result = op1Value * op2Value;
                break;
            case '/':
                result = op1Value / op2Value;
                break;
            default:
                throw new RuntimeException(
                    "Invalid operation:" + operation);
        }
        return result;
    }
    private double
    getOperandValue(char sign, String operand) {
        // Check if operand is a double
        double value;
        try {
            value = Double.parseDouble(operand);
            return sign == '-' ? -value : value;
        } catch (NumberFormatException e) {
            // Ignore it. Operand is not in a format that
            // can be converted to a double value.
        }
        // Check if operand is a bind variable
        Object bindValue = context.getAttribute(operand);
        if (bindValue == null) {
            throw new RuntimeException(operand +
                " is not found in the script context.");
        }
        if (bindValue instanceof Number) {
            value = ((Number) bindValue).doubleValue();
            return sign == '-' ? -value : value;
        } else {
            throw new RuntimeException(operand +
                " must be bound to a number.");
        }
    }
    public void parse() {
        // Supported expressions are of the form v1 op v2,
        // where v1 and v2 are variable names or numbers,
        // and op could be +, -, *, or /
        // Prepare the pattern for the expected expression
        String operandSignPattern = "([+-]?)";
        String operandPattern = "([\\p{Alnum}\\p{Sc}_.]+)";
        String whileSpacePattern = "([\\s]*)";
        String operationPattern = "([+*/-])";
        String pattern = "^" + operandSignPattern
                + operandPattern
                + whileSpacePattern + operationPattern
                + whileSpacePattern
                + operandSignPattern + operandPattern
                + "$";
        Pattern p = Pattern.compile(pattern);
        Matcher m = p.matcher(exp);
        if (!m.matches()) {
            // The expression is not in the expected format
            throw new IllegalArgumentException(
                this.getErrorString());
        }
        // Get operand-1
        String temp = m.group(1);
        if (temp != null && !temp.equals("")) {
            this.op1Sign = temp.charAt(0);
        }
        this.op1 = m.group(2);
        // Get operation
        temp = m.group(4);
        if (temp != null && !temp.equals("")) {
            this.operation = temp.charAt(0);
        }
        // Get operand-2
        temp = m.group(6);
        if (temp != null && !temp.equals("")) {
            this.op2Sign = temp.charAt(0);
        }
        this.op2 = m.group(7);
    }
    private String getErrorString() {
        return "Invalid expression[" + exp + "]"
                + "\nSupported expression syntax is: "
                + "op1 operation op2"
                + "\n where op1 and op2 can be a number "
                + " or a bind variable"
                + " , and operation can be"
                + " +, -, *, and /.";
    }
    @Override
    public String toString() {
        return "Expression: " + this.exp + ", op1 Sign = "
                + op1Sign + ", op1 = " + op1
                + ", op2 Sign = " + op2Sign
                + ", op2 = " + op2
                + ", operation = " + operation;
    }
}

Listing 10-23The Expression Class That Parses and Evaluates an Arithmetic Expression

Expression类被设计用来解析和评估以下形式的算术表达式

op1 operation op2

这里,op1op2是两个操作数,可以是十进制格式的数字或变量,operation可以是+-*/

建议使用的Expression类是

Expression exp = new Expression(expression, scriptContext);
Double value = exp.eval();

让我们详细讨论一下Expression类的重要组件。实例变量expcontext分别是表达式和对表达式求值的ScriptContext。它们被传递给这个类的构造函数。

实例变量op1op2分别表示表达式中的第一个和第二个操作数。实例变量op1Signop2Sign分别代表表达式中第一个和第二个操作数的符号,可以是+-。当使用parse()方法解析表达式时,操作数及其符号被填充。

实例变量operation表示要对操作数执行的算术运算(+、-、*或/)。

实例变量parsed用于跟踪表达式是否已经被解析。parse()方法将其设置为true

构造函数接受一个表达式和一个ScriptContext,确保它们不是null,并将它们存储在实例变量中。在将表达式存储到实例变量exp中之前,它会从表达式中删除开头和结尾的空白。

parse()方法将表达式解析成操作数和操作。它使用正则表达式来解析表达式文本。正则表达式要求表达式文本采用以下形式:

  • 第一个操作数的可选符号+-

  • 第一个操作数,可以由字母数字、货币符号、下划线和小数点的组合组成

  • 任何数量的空白

  • 可能是+-*/的操作标志

  • 第二个操作数的可选符号+-

  • 第二个操作数,可以由字母数字、货币符号、下划线和小数点的组合组成

正则表达式([+-]?)将匹配操作数的可选符号。正则表达式([\\pAlnum\\pSc_.]+)会匹配一个操作数,可能是十进制数,也可能是名字。正则表达式([\\s]*)将匹配任意数量的空格。正则表达式([+*/-])将匹配一个操作符。所有正则表达式都用括号括起来形成组,这样就可以捕获表达式的匹配部分。

如果一个表达式匹配正则表达式,parse()方法将匹配存储到各自的实例变量中。

注意,匹配操作数的正则表达式并不完美。它将允许几种无效的情况,比如一个操作数有多个小数点,等等。然而,对于这个演示目的,它将做。

在表达式被解析后,在表达式求值期间使用getOperandValue()方法。如果操作数是一个double数,它通过应用操作数的符号返回值。否则,它会在ScriptContext中查找操作数的名称。如果在ScriptContext中没有找到操作数的名称,它抛出一个RuntimeException。如果在ScriptContext中找到操作数的名称,它将检查该值是否为数字。如果该值是一个数字,则在将符号应用于该值后返回该值;否则抛出一个RuntimeException

getOperandValue()方法不支持十六进制、八进制和二进制格式的操作数。例如,像“0x2A + 0b1011”这样的表达式将不会被视为具有两个带int文字的操作数的表达式。读者可以增强这种方法,以支持十六进制、八进制和二进制格式的数字文字。

eval()方法计算表达式并返回一个double值。首先,如果表达式还没有被解析,它就解析它。注意,多次调用eval()只会解析表达式一次。它获取两个操作数的值,执行运算,并返回表达式的值。

JKScriptEngine 类

清单 10-24 包含了JKScript脚本引擎的实现。它的eval(String, ScriptContext)方法包含主要逻辑:

Expression exp = new Expression(script, context); Object result = exp.eval();

它创建了一个Expression类的对象。它调用评估表达式并返回结果的Expression对象的eval()方法。

eval(ReaderScriptContext)方法从Reader中读取所有行,将它们连接起来,并将结果String传递给eval(String, ScriptContext)方法来计算表达式。注意一个Reader必须只有一个表达式。一个表达式可以拆分成多行。Reader中的空白被忽略。

// JKScriptEngine.java
package com.jdojo.jkscript;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
import javax.script.AbstractScriptEngine;
import javax.script.Bindings;
import javax.script.ScriptContext;
import javax.script.ScriptEngineFactory;
import javax.script.ScriptException;
import javax.script.SimpleBindings;

public class JKScriptEngine extends AbstractScriptEngine {
    private final ScriptEngineFactory factory;
    public JKScriptEngine(ScriptEngineFactory factory) {
        this.factory = factory;
    }
    @Override
    public Object
    eval(String script, ScriptContext context)
    throws ScriptException {
        try {
            Expression exp =
                new Expression(script, context);
            Object result = exp.eval();
            return result;
        } catch (Exception e) {
            throw new ScriptException(e.getMessage());
        }
    }
    @Override
    public Object
    eval(Reader reader, ScriptContext context)
    throws ScriptException {
        // Read all lines from the Reader
        BufferedReader br = new BufferedReader(reader);
        String script = "";
        try {
            String str;
            while ((str = br.readLine()) != null) {
                script = script + str;
            }
        } catch (IOException e) {
            throw new ScriptException(e);
        }
        // Use the String version of eval()
        return eval(script, context);
    }
    @Override
    public Bindings createBindings() {
        return new SimpleBindings();
    }
    @Override
    Public  ScriptEngineFactory getFactory() {
        return factory;
    }
}

Listing 10-24An Implementation of the JKScript Script Engine

JKScriptEngineFactory 类

清单 10-25 包含了JKScript引擎的ScriptEngineFactory接口的实现。它的一些方法返回一个"Not Implemented"字符串,因为你不支持这些方法公开的特性。JKScriptEngineFactory类中的代码是不言自明的。使用ScriptEngineManager可以获得一个JKScript引擎的实例,其名称为jksJKScriptjkscript,如getNames()方法中编码的那样。

// JKScriptEngineFactory.java
package com.jdojo.jkscript;
import java.util.List;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineFactory;
public class JKScriptEngineFactory
        implements ScriptEngineFactory {
    @Override
    public String getEngineName() {
        return "JKScript Engine";
    }
    @Override
    public String getEngineVersion() {
        return "1.0";
    }
    @Override
    public List<String> getExtensions() {
        return List.of("jks");
    }
    @Override
    public List<String> getMimeTypes() {
        return List.of("text/jkscript");
    }
    @Override
    public List<String> getNames() {
        return List.of("jks", "JKScript", "jkscript");
    }
    @Override
    public String getLanguageName() {
        return "JKScript";
    }
    @Override
    public String getLanguageVersion() {
        return "1.0";
    }
    @Override
    public Object getParameter(String key) {
        switch (key) {
            case ScriptEngine.ENGINE:
                return getEngineName();
            case ScriptEngine.ENGINE_VERSION:
                return getEngineVersion();
            case ScriptEngine.NAME:
                return getEngineName();
            case ScriptEngine.LANGUAGE:
                return getLanguageName();
            case ScriptEngine.LANGUAGE_VERSION:
                return getLanguageVersion();
            case "THREADING":
                return "MULTITHREADED";
            default:
                return null;
        }
    }
    @Override
    public String
    getMethodCallSyntax(String obj, String m, String[] p) {
        return "Not implemented";
    }
    @Override
    public String
    getOutputStatement(String toDisplay) {
        return "Not implemented";
    }
    @Override
    public String
    getProgram(String[] statements) {
        return "Not implemented";
    }
    @Override
    public ScriptEngine
    getScriptEngine() {
        return new JKScriptEngine(this);
    }
}

Listing 10-25A ScriptEngineFactory Implementation for the JKScript Script Engine

打包 jscript 文件

要让其他人使用您的 JKScript 引擎,您需要做的就是为jdojo.jkscript模块提供模块化 JAR。

使用 jscript 脚本引擎

是时候测试您的 JKScript 脚本引擎了。第一步也是最重要的一步是将您在上一节中创建的jdojo.jkscript.jar包含到应用程序的模块路径中。之后,使用 JKScript 脚本引擎与使用任何其他脚本引擎没有什么不同。

下面的代码片段使用 JKScript 作为其名称来创建 JKScript 脚本引擎的实例。您也可以使用它的其他名称,jksjkscript:

// Create the JKScript engine
ScriptEngineManager manager = new ScriptEngineManager();
ScriptEngine engine = manager.getEngineByName("JKScript");
if (engine == null) {
    System.out.println(
        "JKScript engine is not available. ");
    System.out.println(
        "Add jkscript.jar to CLASSPATH.");
else {
    // Evaluate your JKScript
}

清单 10-26 包含一个使用 JKScript 脚本引擎评估不同类型表达式的程序。执行存储在String对象和文件中的表达式。一些表达式使用数字文字和一些绑定变量,它们的值在引擎范围和引擎的默认ScriptContext的全局范围中的绑定中传递。注意,这个程序期望在当前目录中有一个名为jkscript.txt的文件,其中包含一个可以被JKScript脚本引擎理解的算术表达式。如果脚本文件不存在,程序将在标准输出中打印一条消息,其中包含预期脚本文件的路径。您可能会在最后一行得到不同的输出。

// JKScriptTest.java
package com.jdojo.script;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.Reader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
public class JKScriptTest {
    public static void
    main(String[] args)
    throws FileNotFoundException, IOException {
        // Create JKScript engine
        ScriptEngineManager manager =
            new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByName(
            "JKScript");
        if (engine == null) {
            System.out.println(
                "JKScript engine is not available. ");
            System.out.println(
                "Add jkscript.jar to CLASSPATH.");
            return;
        }
        // Test scripts as String
        testString(manager, engine);
        // Test scripts as a Reader
        testReader(manager, engine);
    }
    public static void
    testString(ScriptEngineManager manager,
            ScriptEngine engine) {
        try {
            // Use simple expressions with numeric literals
            String script = "12.8 + 15.2";
            Object result = engine.eval(script);
            System.out.println(script + " = " + result);
            script = "-90.0 - -10.5";
            result = engine.eval(script);
            System.out.println(script + " = " + result);
            script = "5 * 12";
            result = engine.eval(script);
            System.out.println(script + " = " + result);
            script = "56.0 / -7.0";
            result = engine.eval(script);
            System.out.println(script + " = " + result);
            // Use global scope bindings variables
            manager.put("num1", 10.0);
            manager.put("num2", 20.0);
            script = "num1 + num2";
            result = engine.eval(script);
            System.out.println(script + " = " + result);
            // Use global and engine scopes bindings.
            // num1 from engine scope and num2 from
            // global scope will be used.
            engine.put("num1", 70.0);
            script = "num1 + num2";
            result = engine.eval(script);
            System.out.println(script + " = " + result);
            // Try mixture of number literal and bindings.
            // num1 from the engine scope bindings will be
            // used
            script = "10 + num1";
            result = engine.eval(script);
            System.out.println(script + " = " + result);
        } catch (ScriptException e) {
            e.printStackTrace();
        }
    }
    public static void
    testReader(ScriptEngineManager manager,
            ScriptEngine engine) {
        try {
            Path scriptPath = Paths.get("jkscript.txt").
                toAbsolutePath();
            if (!Files.exists(scriptPath)) {
                System.out.println(scriptPath +
                    " script file does not exist.");
                return;
            }
            try (Reader reader = Files.
                    newBufferedReader(scriptPath);) {
                Object result = engine.eval(reader);
                System.out.println("Result of " +
                    scriptPath + " = " + result);
            }
        } catch (ScriptException | IOException e) {
            e.printStackTrace();
        }
    }
}

12.8 + 15.2 = 28.0
-90.0 - -10.5 = -79.5
5 * 12 = 60.0
56.0 / -7.0 = -8.0
num1 + num2 = 30.0
num1 + num2 = 90.0
10 + num1 = 80.0
Result of C:\Java9APIsAndModules\jkscript.txt = 88.0

Listing 10-26Using the JKScript Script Engine

Groovy 中的 JavaFX

我们可以使用脚本来加速 JavaFX 的开发。事实上,混合 Java 代码和脚本有助于分离前端和后端逻辑,并且因为脚本比 Java 代码更简洁,所以可以节省一些开发时间。

清单 10-27 包含一个简单的 HelloWorld 风格的 JavaFX 应用程序。

package com.jdojo.groovyfx;

import javax.script.Invocable;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
import javafx.application.Application;
import javafx.stage.Stage;

public class HelloGroovyFX extends Application {
    private Invocable inv;

    public static void main(String[] args) {
        launch(args);
    }

    @Override
    public void init() {
        // Create a script engine manager
        ScriptEngineManager manager =
            new ScriptEngineManager();
        // Obtain a Groovy script engine from the manager
        ScriptEngine engine =
            manager.getEngineByName("Groovy");
        // Store the Groovy script in a String
        String script = """
import javafx.scene.Scene
import javafx.scene.control.Button
import javafx.scene.layout.StackPane
import javafx.beans.property.SimpleStringProperty as SP

def go(def primaryStage) {
  primaryStage.setTitle "Hello World!"
  Button btn = new Button()
  btn.text = "Say 'Hello World'"
  btn.onAction = { def event ->
      println("Hello World!")
  }

  StackPane root = new StackPane()
  root.children.add(btn)
  primaryStage.scene = new Scene(root, 300, 250)
  primaryStage.show()
}
            """;
        try {
            // Execute the script
            engine.eval(script);
            inv = (Invocable) engine;
        } catch (ScriptException e) {
            e.printStackTrace();
        }
    }

    @Override
    public void start(Stage primaryStage) {
        try {
            inv.invokeFunction("go", primaryStage);
        } catch (Exception e) {
            e.printStackTrace();
        }
   }
}

Listing 10-27A JavaFX Application Using a Groovy Script

为此,您必须添加 JavaFX 库。对于 Maven 项目来说,这很容易。只是补充

<dependency>
  <groupId>org.openjfx</groupId>
  <artifactId>javafx-base</artifactId>
  <version>16</version>
</dependency>
<dependency>
  <groupId>org.openjfx</groupId>
  <artifactId>javafx-graphics</artifactId>
  <version>16</version>
</dependency>
<dependency>
  <groupId>org.openjfx</groupId>
  <artifactId>javafx-controls</artifactId>
  <version>16</version>
</dependency>
<dependency>
  <groupId>org.openjfx</groupId>
  <artifactId>javafx-web</artifactId>
  <version>16</version>
</dependency>

在您的pom.xml文件的<dependencies>部分中。

与 Java 相比,Groovy 版本的前端代码要简单一些。在脚本中,您可以使用 Java 类的属性来调用它们的方法。例如,不要用 Java 编写:

btn.setText("Say 'Hello World'");

你可以用 Groovy 写这个:

btn.text = "Say 'Hello World'"

此规则的一个例外是

primaryStage.setTitle "Hello World!"

因为在Stage类中,title字段的类型不同于String

为按钮添加事件处理程序也更容易。您可以使用 Groovy 闭包作为按钮的事件处理程序。请注意,您也可以使用onAction属性来设置事件处理程序,而不是调用Button类的setOnAction()方法。以下代码片段显示了如何为按钮设置ActionEvent处理程序:

  btn.onAction = { def event ->
      println("Hello World!")
  }

图 10-7 显示了正在运行的 JavaFX 应用程序。

img/323070_3_En_10_Fig7_HTML.png

图 10-7

带有 Groovy 脚本的 JavaFX 应用程序

摘要

脚本语言是一种编程语言,它使您能够编写由运行时环境评估(或解释)的脚本,运行时环境称为脚本引擎(或解释器)。脚本是使用脚本语言的语法编写的字符序列,用作由解释器执行的程序的源。Java 脚本 API 允许您执行用任何脚本语言编写的脚本,这些脚本可以从 Java 应用程序编译成 Java 字节码。

使用脚本引擎执行脚本,脚本引擎是ScriptEngine接口的一个实例。ScriptEngine接口的实现者也提供了ScriptEngineFactory接口的实现,其工作是创建脚本引擎的实例并提供关于脚本引擎的细节。ScriptEngineManager类为脚本引擎提供了发现和实例化机制。一个ScriptManager维护一个键值对的映射,作为一个由它创建的所有脚本引擎共享的Bindings接口的实例。

您可以执行包含在StringReader中的脚本。ScriptEngineeval()方法用于执行脚本。您可以使用ScriptContext向脚本传递参数。传递的参数可以是脚本引擎的本地参数,脚本执行的本地参数,或者由ScriptManager创建的所有脚本引擎的全局参数。使用 Java 脚本 API,您还可以执行用脚本语言编写的过程和函数。如果脚本引擎支持,您还可以预编译脚本,并执行从 Java 重复的脚本以获得更好的性能。

您可以使用 Java 脚本 API 实现您的脚本引擎。您需要为ScriptEngineScriptEngineFactory接口提供实现。你需要以某种方式打包你的脚本引擎代码,这样引擎就可以在运行时被ScriptManager发现。

练习

练习 1

什么是脚本语言?

练习 2

哪个 JDK 模块包含脚本 API?

运动 3

简述以下类和接口的使用:ScriptEngineFactoryScriptEngineScriptEngineManagerCompilableInvocableBindingsScriptContextScriptException

演习 4

一个ScriptEngineeval()方法有什么用?

锻炼 5

编写一个程序,在其中使用SimpleScriptContext类创建一个ScriptContext接口的实例。在引擎范围和全局范围中存储一些属性,检索相同的属性,并打印它们的值。

锻炼 6

如何向全局范围和引擎范围添加属性?

锻炼 7

如何将由ScriptEngine执行的脚本输出发送到一个文件中?

运动 8

编写一段代码来检查ScriptEngine是否支持编译脚本。

演习 9

使用java.util.List接口的of()方法创建一个不可修改的两个字符串的列表,并打印列表中的值。使用 Groovy 脚本编写代码。

运动 10

如果您想要推出自己的脚本引擎,那么您必须提供其实现的服务接口的名称是什么?

十一、进程 API

在本章中,您将学习:

  • 什么是进程 API

  • 如何与运行 Java 应用程序的当前进程交互

  • 如何创建本机进程

  • 如何获取有关新进程的信息

  • 如何获取当前进程的信息

  • 如何获取所有系统进程的信息

  • 如何设置创建、查询和管理本机进程的权限

本章中的所有示例程序都是清单 11-1 中声明的jdojo.process模块的成员。

// module-info.java
module jdojo.process {
    exports com.jdojo.process;
}

Listing 11-1The Declaration of a jdojo.process Module

什么是进程 API?

Process API 由允许您在 Java 程序中使用本地进程的类和接口组成。使用 API,您可以

  • 从 Java 代码创建新的本地进程。

  • 获取本机进程的进程句柄,无论它们是由 Java 代码还是其他方式创建的。

  • 销毁正在运行的本机进程。

  • 查询进程的活性及其其他属性。

  • 获取进程的子进程和父进程的列表。

  • 获取本机进程的进程 ID (PID)。

  • 获取新创建进程的输入、输出和错误流。

  • 等待进程终止。

  • 当进程终止时执行任务。

进程 API 很小。它由表 11-1 中列出的类和接口组成。我将在接下来的章节中用例子详细解释这些类和接口。

表 11-1

进程 API 的类和接口

|

类别/接口

|

描述

|
| --- | --- |
| Runtime | 它是一个单例类,其唯一的实例代表了 Java 应用程序的运行时环境。 |
| ProcessBuilder | ProcessBuilder类的一个实例保存了一组进程的属性。调用它的start()方法启动一个本地进程,并返回一个代表本地进程的Process类的实例。可以多次调用它的start()方法;每次,它都使用保存在ProcessBuilder实例中的属性启动一个新的进程。 |
| ProcessBuilder.Redirect | 它是一个静态嵌套类,表示进程输入的源或进程输出的目的。 |
| Process | 它是一个抽象类,其实例表示当前 Java 程序使用ProcessBuilderstart()方法或Runtimeexec()方法启动的本地进程。 |
| ProcessHandle | 它是一个接口,其实例表示本地进程的句柄,无论这些进程是由当前 Java 程序还是由任何其他方式启动的。您可以使用此句柄控制和查询本机进程的状态。 |
| ProcessHandle.Info | ProcessHandle.Info接口的一个实例表示一个进程属性的快照。 |

在 Java 中,您可以启动本地进程,并使用它们的输入、输出和错误流。此外,还可以使用未启动的本地进程,并查询进程的详细信息。对于后者,您使用进程 API 内部的一个名为ProcessHandle的接口。接口的一个实例标识了一个本地进程;它允许您查询进程状态和管理进程。

比较一下Process类和ProcessHandle接口。Process类的实例表示由当前 Java 程序启动的本地进程,而ProcessHandle接口的实例表示由当前 Java 程序或其他方式启动的本地进程。Process类包含一个返回ProcessHandletoHandle()方法。

ProcessHandle.Info接口的一个实例表示一个进程属性的快照。请注意,不同的操作系统实现的进程不同,因此它们的属性也不同。进程的状态可能随时改变,例如,每当进程获得更多的 CPU 时间时,进程使用的 CPU 时间就会增加。为了获得进程的最新信息,您需要在需要的时候使用ProcessHandle接口的info()方法,这将返回一个ProcessHandle.Info接口的新实例。

本章中的所有例子都是在 Ubuntu Linux 上运行的。当您在使用 Windows 或任何其他不同操作系统的计算机上运行这些程序时,您可能会得到不同的输出。

Note

通过调整可执行文件和参数文件路径,CLI 代码片段可以很容易地转换为 Windows 代码片段。

了解运行时环境

每个 Java 应用程序都有一个Runtime类的实例,它允许您查询当前 Java 应用程序运行的运行时环境并与之交互。Runtime类是单例的。您可以使用该类的getRuntime()静态方法获得它的唯一实例:

// Get the instance of the Runtime
Runtime runtime = Runtime.getRuntime();

使用Runtime,可以知道当前 JVM 可以使用的最大内存,JVM 中当前分配的内存,以及 JVM 中的空闲内存。这里有三种方法可以让您以字节为单位查询 JVM 的内存:

  • long maxMemory()

  • long totalMemory()

  • long freeMemory()

JVM 延迟分配内存。maxMemory()方法返回 JVM 可以分配的最大内存量。如果没有最大内存限制,该方法返回Long.MAX_VALUE

totalMemory()方法返回 JVM 当前分配的最大内存。当 JVM 需要更多内存时,它会分配更多内存,而totalMemory()方法将返回当前分配的内存。JVM 可以分配最大内存,最大为由maxMemory()方法返回的内存量。

freeMemory()方法从 JVM 当前分配的内存中返回未使用的内存。你如何知道 JVM 使用的内存?下面的公式将给出 JVM 在特定时间点使用的内存:

Used Memory = Total Memory Free Memory

使用availableProcessors()方法获得 JVM 的可用处理器数量。

使用version()方法获得一个代表 Java 运行时环境版本的Runtime.Version。关于 JDK/JRE 版本化方案的更多细节,请参考Runtime.Version类的 Javadoc。清单 11-2 向您展示了Runtime类在查询 Java 运行时环境中的一些应用。您可能会得到不同的输出。

// QueryingRuntime.java
package com.jdojo.process;
public class QueryingRuntime {
    public static void main(String[] args) {
        // Get the Runtime instance
        Runtime rt = Runtime.getRuntime();
        // Get the JVM memory
        long maxMemory = rt.maxMemory();
        long totalMemory = rt.totalMemory();
        long freeMemory = rt.freeMemory();
        long usedMemory = totalMemory freeMemory;
        System.out.format(
            "Max memory = %d, Total memory = %d,"
            + "Free memory = %d, Used memory = %d.%n",
            maxMemory, totalMemory, freeMemory,
            usedMemory);
        // Print the number of processors available to
        // the JVM
        int processors = rt.availableProcessors();
        System.out.format("Number of processors = %d%n",
            processors);
        // Print the version of the Java runtime
        Runtime.Version version = rt.version();
        System.out.format("Version = %s%n",
            version);
    }
}

Max memory = 3126853632,
    Total memory = 201326592,
    Free memory = 198351728,
    Used memory = 2974864.
Number of processors = 8
Version = 17+01-123

Listing 11-2Querying the Java Runtime Environment

您可以使用Runtime类的gc()方法调用垃圾收集。System.gc()静态方法是Runtime.getRuntime().gc()的方便方法。

Note

方法gc()只是提示操作系统在下一个方便的时间段开始垃圾收集。如果gc()被调用,你不能依赖垃圾收集来立即开始。

您可以使用Runtime类的exit(int status)方法终止 JVM。System.exit()静态方法是Runtime.getRuntime().exit()的一个方便方法。按照惯例,status的非零值表示 JVM 的异常终止。您可以使用Runtime类的halt()方法强制终止 JVM。

您可以使用Runtime类的addShutdownHook(Thread hook)removeShutdownHook(Thread hook)方法添加和移除 JVM 的关闭挂钩。关闭挂钩是一个线程,它被初始化,但没有启动。当线程终止时,JVM 启动注册为关闭挂钩的线程。

使用它的一个exec()重载方法来启动一个本地进程。您应该使用ProcessBuilder类来启动一个本地进程。Runtime类的exec()方法在内部使用了ProcessBuilder类。

当前进程

ProcessHandle接口的current()静态方法返回当前进程的句柄。请注意,此方法返回的当前进程始终是执行代码的 Java 进程:

// Get the handle of the current process
ProcessHandle current = ProcessHandle.current();

一旦获得了当前进程的句柄,就可以使用ProcessHandle接口的方法来获得关于该进程的细节。请参考下一节中关于如何获取当前进程信息的示例。

Note

您不能终止当前进程。试图使用ProcessHandle接口的destroy()destroyForcibly()方法终止当前进程会导致IllegalStateException

查询进程状态

您可以使用ProcessHandle接口中的方法来查询进程的状态。表 11-2 列出了该接口的常用方法,并做了简要说明。请注意,这些方法中有许多会返回快照时为真的进程状态的快照。因为进程是异步创建、运行和销毁的,所以当您以后使用它的属性时,不能保证进程仍然处于相同的状态。

表 11-2

ProcessHandle 接口中的方法

|

方法

|

描述

|
| --- | --- |
| static Stream<ProcessHandle> allProcesses() | 返回操作系统中对当前进程可见的所有进程的快照。 |
| Stream<ProcessHandle> children() | 返回进程的当前直接子进程的快照。使用descendants()方法获得所有级别的子进程列表,例如,子进程、孙进程、曾孙进程等。 |
| static ProcessHandle current() | 为当前进程返回一个ProcessHandle,当前进程是执行这个方法调用的 Java 进程。 |
| Stream<ProcessHandle> descendants() | 返回进程后代的快照。将其与children()方法进行比较,后者只返回进程的直接后代。 |
| boolean destroy() | 请求终止该进程。如果成功请求终止进程,则返回true,否则返回false。能否终止一个进程取决于操作系统的访问控制。 |
| boolean destroyForcibly() | 请求强制终止进程。如果成功请求终止进程,则返回true,否则返回false。终止一个进程会立即强制终止该进程,而正常终止则允许进程干净地关闭。能否终止一个进程取决于操作系统的访问控制。 |
| ProcessHandle.Info info() | 返回进程信息的快照。 |
| boolean isAlive() | 如果此ProcessHandle所代表的进程尚未终止,则返回true,否则返回false。请注意,在您成功请求终止进程后的一段时间内,该方法可能会返回true,因为进程将被异步终止。 |
| static Optional<ProcessHandle> of(long pid) | 为现有的本地进程返回一个Optional<ProcessHandle>。如果具有指定的pid的进程不存在,则返回空的Optional。 |
| CompletableFuture <ProcessHandle> onExit() | 返回一个CompletableFuture <ProcessHandle>来终止进程。您可以使用返回的对象添加一个任务,该任务将在进程终止时执行。在当前进程上调用这个方法会抛出一个IllegalStateException。 |
| Optional<ProcessHandle> parent() | 为父进程返回一个Optional<ProcessHandle>。 |
| long pid() | 返回由操作系统分配的进程的本机进程 ID (PID)。注意,如果进程终止,PID 可以被操作系统重用,因此具有相同 PID 的两个进程句柄可能不代表相同的进程。 |
| boolean supportsNormalTermination() | 如果destroy()的执行正常终止进程,则返回true。 |

表 11-3 列出了ProcessHandle.Info嵌套接口的方法和描述。此接口的实例包含有关进程的快照信息。你可以使用ProcessHandle接口的info()方法或者Process类获得一个ProcessHandle.Info。接口中的所有方法都返回一个Optional

表 11-3

方法。信息界面

|

方法

|

描述

|
| --- | --- |
| Optional<String[]> arguments() | 返回进程的参数。该进程可能会在启动后更改传递给它的原始参数。在这种情况下,此方法返回更改的参数。 |
| Optional<String> command() | 返回进程的可执行路径名。 |
| Optional<String> commandLine() | 这是一种将进程的命令和参数结合起来的便捷方法。如果两个方法都返回非空选项,它通过组合从command()arguments()方法返回的值来返回进程的命令行。 |
| Optional<Instant> startInstant() | 返回进程的开始时间。如果操作系统没有返回开始时间,它返回一个空的Optional。 |
| Optional<Duration> totalCpuDuration() | 返回进程使用的总 CPU 时间。请注意,一个进程可能会运行很长时间,并且可能会占用很少的 CPU 时间。 |
| Optional<String> user() | 返回进程的用户。 |

是时候看看ProcessHandleProcessHandle.Info接口的作用了。清单 11-3 包含一个名为CurrentProcessInfo的类的代码。它的printInfo()方法以一个ProcessHandle作为参数,并打印进程的细节。我们还在其他例子中使用这种方法来打印进程的细节。main()方法获取运行该进程的当前进程的句柄,该进程是一个 Java 进程,并打印其详细信息。您可能会得到不同的输出。当程序在 Linux 上运行时,会生成输出。

// CurrentProcessInfo.java
package com.jdojo.process;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Arrays;
public class CurrentProcessInfo {
    public static void main(String[] args) {
        // Get the handle of the current process
        ProcessHandle current = ProcessHandle.current();
        // Print the process details
        printInfo(current);
    } 

    public static void printInfo(ProcessHandle handle) {
        // Get the process ID
        long pid = handle.pid();
        // Is the process still running
        boolean isAlive = handle.isAlive();
        // Get other process info
        ProcessHandle.Info info = handle.info();
        String command = info.command().orElse("");
        String[] args = info.arguments()
                            .orElse(new String[]{});
        String commandLine = info.commandLine()
            .orElse("");
        ZonedDateTime startTime = info.startInstant()
            .orElse(Instant.now())
            .atZone(ZoneId.systemDefault());
        Duration duration = info.totalCpuDuration()
            .orElse(Duration.ZERO);
        String owner = info.user().orElse("Unknown");
        long childrenCount = handle.children().count();
        // Print the process details
        System.out.printf("PID: %d%n", pid);
        System.out.printf("IsAlive: %b%n", isAlive);
        System.out.printf("Command: %s%n", command);
        System.out.printf("Arguments: %s%n",
            Arrays.toString(args));
        System.out.printf("CommandLine: %s%n",
            commandLine);
        System.out.printf("Start Time: %s%n", startTime);
        System.out.printf("CPU Time: %s%n", duration);
        System.out.printf("Owner: %s%n", owner);
        System.out.printf("Children Count: %d%n",
            childrenCount);
    }
}

PID: 4143
IsAlive: true
Command: /opt/jdk17/bin/java
Arguments: [-Dfile.encoding=UTF-8,
    -classpath,
    [<path-to-project>]/bin,
    -XX:+ShowCodeDetailsInExceptionMessages,
    com.jdojo.process.CurrentProcessInfo]
CommandLine: /opt/openjdk-16.36/bin/java
    -Dfile.encoding=UTF-8
    -classpath [<path-to-project>]/bin
    -XX:+ShowCodeDetailsInExceptionMessages
    com.jdojo.process.CurrentProcessInfo
Start Time: 2021-07-16T14:50:18.870+02:00
    [Europe/Berlin]
CPU Time: PT0.06S
Owner: peter
Children Count: 0

Listing 11-3A CurrentProcessInfo Class That Prints the Details of the Current Process

比较进程

比较两个进程的相等性或有序性是很棘手的。您不能依赖 PID 来实现进程的平等。进程终止后,操作系统重用 PID。您可以检查进程的开始时间以及 PIDs 如果相同,则两个进程可能相同。ProcessHandle接口的默认实现的equals()方法检查两个进程的以下三条信息是否相等:

  • 两个进程的ProcessHandle接口的实现类必须相同。

  • 进程必须有相同的 PID。

  • 进程必须同时启动。

Note

ProcessHandle接口中使用compareTo()方法的默认实现对于排序不是很有用。它比较了两个进程的 PID。

创建进程

您需要使用ProcessBuilder类的一个实例来启动一个新的本地进程。一个ProcessBuilder管理本地进程属性的集合。一旦为进程设置了所有属性,就可以调用它的start()方法来启动一个新的本地进程。存储在ProcessBuilder中的属性将用于启动新进程。您可以多次调用start()方法,使用存储在ProcessBuilder中的属性启动新的进程。start()方法返回代表新的本地进程的Process类的实例。您可以使用以下构造函数之一来创建ProcessBuilder类的实例:

  • ProcessBuilder(String... command)

  • ProcessBuilder(List<String> command)

构造函数允许您指定操作系统程序和参数。假设您想在 Linux 上从/opt/jdk17/bin内部运行java程序,如下所示:

/opt/jdk17/bin/java --version

您将创建一个ProcessBuilder来表示这个命令,如下所示:

ProcessBuilder pb = new ProcessBuilder(
    "/opt/jdk17/bin/java", "--version");

使用ProcessBuilder类的方法,您可以管理进程的以下属性:

  • 一个命令

  • 一个环境

  • 工作目录

  • 标准输入/输出(stdinstdoutstderr)

  • 标准错误流的重定向属性

命令只是代表外部程序及其参数的字符串列表。可以在ProcessBuilder类的构造函数中设置命令。以下方法允许您检索命令字符串并设置更多命令字符串:

  • List<String> command()

  • ProcessBuilder command(String... command)

不带任何参数的command()方法返回已经在ProcessBuilder中设置的命令字符串。带有 varargs 参数的command()方法允许您添加更多的命令字符串。下面的代码片段创建了一个ProcessBuilder来在 Linux 上启动 JVM。它使用command()方法来设置命令属性:

ProcessBuilder pb = new ProcessBuilder()
    .command("/opt/jdk17/bin/java",
        "--module-path",
        "myModulePath",
        "--module",
        "myModule/className");

环境是依赖于系统的键值对的列表。它被初始化为从静态方法System.getEnv()返回的Map<String,String>的副本。您需要使用ProcessBuilder类的environment()方法来获取Map<String,String>并将键值对添加到映射中。下面的代码片段向您展示了如何为ProcessBuilder设置环境属性:

ProcessBuilder pb = new ProcessBuilder("mycommand");
Map<String,String> env = pb.environment();
env.put("arg1", "value1");
env.put("arg2", "value2");

默认情况下,新进程的工作目录是当前 Java 进程的工作目录,通常是由系统属性user.dir命名的目录。ProcessBuilder类中的以下方法允许您获取和设置工作目录:

  • File directory()

  • ProcessBuilder directory(File directory)

下面的代码片段向您展示了如何在 Linux 上将工作目录设置为/home/USER/mydir:

ProcessBuilder pb = new ProcessBuilder("myCommand")
    .directory(new File("/home/USER/mydir"));

ProcessBuilderstart()方法创建的新进程被创建为当前进程的子进程,当前进程是运行代码的 Java 进程。换句话说,当前的 Java 进程是新创建的进程的父进程。新进程不拥有标准 I/O ( stdinstdoutstderr)的终端或控制台。默认情况下,新进程的 I/O 通过管道连接到父进程。您可以选择通过调用一个ProcessBuilderinheritIO()方法将新进程的标准 I/O 设置为与其父进程相同。在ProcessBuilder类中有几个redirectXxx()方法可以为新进程定制标准的 I/O,例如,将标准的错误流设置到一个文件中,这样所有的错误都会被记录到一个文件中。

一旦您配置了进程的所有属性,您就可以调用start()来启动进程:

// Start a new process
Process newProcess = pb.start();

您可以多次调用ProcessBuilder类的start()方法来启动多个具有先前存储在其中的相同属性的进程。这有一个性能优势,您可以创建一个ProcessBuilder实例,并重用它来多次启动相同的进程。

您可以使用Process类的toHandle()方法获得进程的进程句柄:

// Get the process handle
ProcessHandle handle = newProcess.toHandle();

您可以使用进程句柄来销毁进程,等待进程完成,或者查询进程的状态和属性,如其子进程、子进程、父进程、使用的 CPU 时间等。您获得的关于进程的信息以及您对进程的控制取决于操作系统的访问控制。

很难拿出例子来创建可以在所有操作系统上运行的进程。如果您可以运行本书中的其他示例,这意味着您的机器上安装了 JDK17。您可以使用机器上的java程序来启动示例中的其他进程。您可以使用当前进程的 command 属性,即当前运行的java程序,来获取 Java 程序在您的机器上的路径,因此这些示例将在所有平台上工作。

让我们看几个使用 Java 程序创建本地进程的例子。您可以分别使用–version-version选项将 Java 产品版本信息打印到标准输出和标准错误中,如下所示:

/opt/jdk17/bin/java --version
openjdk 17 2021-05-16
OpenJDK Runtime Environment (build 17+1-123)
OpenJDK 64-Bit Server VM (build 17+1-123, mixed mode, sharing)

/opt/jdk17/bin/java -version
openjdk 17 2021-05-16
OpenJDK Runtime Environment (build 17+1-123)
OpenJDK 64-Bit Server VM (build 17+1-123, mixed mode, sharing)

在前面的输出中,您看不到输出在哪里打印的任何区别。两个输出都打印到同一个控制台,因为默认情况下,标准输出和标准错误都映射到控制台。但是,当您尝试在程序中捕获这两个命令的输出时,您会看到不同之处。

清单 11-4 显示了一个运行java –version命令将 Java 产品信息打印到标准输出的程序。

// PipedIO.java
package com.jdojo.process;
import java.io.IOException;
public class PipedIO {
    public static void main(String[] args) {
        // Get the path of the java program that started
        // this program
        String javaPath = ProcessHandle.current()
            .info()
            .command().orElse(null);
        if(javaPath == null) {
            System.out.println(
                "Could not get the java command's path.");
            return;
        }
        // Configure the ProcessBuilder
        ProcessBuilder pb =
            new ProcessBuilder(javaPath,  "--version");
        try {
            // Start a new java process
            Process p = pb.start();
        } catch (IOException e) {
            e.printStackTrace();
        }
    } 

}

Listing 11-4Capturing the Output of a Native Process

当你运行程序ProcessIO类时,它不打印任何东西。产出去了哪里?程序创建了一个新进程,该进程的标准输出通过管道连接到父进程。如果您想要访问输出,您需要从适当的管道读取。当新进程的标准 I/O 通过管道传输到父进程时,您可以使用Process的以下方法来获取新进程的 I/O 流:

  • OutputStream getOutputStream()

  • InputStream getInputStream()

  • InputStream getErrorStream()

getOutputStream()方法返回的OutputStream被连接到新进程的标准输入流。写入此输出流将通过管道传输到新进程的标准输入。

getInputStream()返回的InputStream连接到新进程的标准输出。如果您想要捕获新进程的标准输出,您需要从这个输入流中读取。

getErrorStream()返回的InputStream连接到新进程的标准误差。如果您想要捕获新进程的标准错误,您需要从这个输入流中读取。有时,您希望将输出合并到标准输出,并将标准错误合并到一个目的地。它给出了输出和错误的准确顺序,以便于解决问题。您可以调用ProcessBuilderredirectErrorStream(true)方法,将写入标准错误的数据发送到标准输出。我很快会展示这类例子。

Note

您可以选择将新进程的标准 I/O 重定向到其他目的地,比如文件,在这种情况下,getOutputStream()getInputStream()getErrorStream()方法返回null

清单 11-5 中的程序修复了在PipedIO类中得不到任何输出的问题。它读取并打印写入管道中标准输出流的数据。

// CapturePipedIO.java
package com.jdojo.process;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
public class CapturePipedIO {
    public static void main(String[] args) {
        // Get the path of the java program that started
        // this program
        String javaPath = ProcessHandle.current()
                .info()
                .command().orElse(null);
        if (javaPath == null) {
            System.out.println(
                "Could not get the java command's path.");
            return;
        }
        // Configure the ProcessBuilder
        ProcessBuilder pb =
            new ProcessBuilder(javaPath, "--version");
        try {
            // Start a new java process
            Process p = pb.start();
            // Read and print the standard output stream
            // of the process
            try (BufferedReader input =
                    new BufferedReader(
                        new InputStreamReader(
                            p.getInputStream()))) {
                String line;
                while ((line = input.readLine()) != null) {
                    System.out.println(line);
                }
            } 

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

openjdk 17 2021-05-16
OpenJDK Runtime Environment (build 17+1-123)
OpenJDK 64-Bit Server VM (build 17+1-123, mixed mode, sharing)

Listing 11-5Capturing the Output of a Native Process

如果运行带有-version选项的java命令,输出将被写入标准错误。如果您将清单 11-5 中的选项从–version更改为-version,您将不会再次获得任何输出,因为输出将通过管道传输到标准错误流。有两种方法可以解决这个问题:

  • 在程序中,从ProcessgetErrorStream()方法返回的InputStream读取,而不是从getInputStream()方法返回的InputStream读取。

  • 将错误流重定向到标准输出流,并继续从标准输出中读取。

以下代码片段使用java -version命令创建了一个ProcessBuilder,并在标准输出中重定向了错误流:

// Configure the ProcessBuilder
ProcessBuilder pb =
    new ProcessBuilder(javaPath, "-version")
    .redirectErrorStream(true);

如果您将创建清单 11-5 中的ProcessBuilder的语句改为这个语句,您的程序将运行良好。

新进程也可以继承父进程的标准 I/O。如果要将新进程的所有 I/O 目的地设置为与当前进程相同,请使用ProcessBuilderinheritIO()方法,如下所示:

// Configure the ProcessBuilder inheriting parent's I/O
ProcessBuilder pb =
    new ProcessBuilder(javaPath,  "--version")
    .inheritIO();

如果您更改清单 11-4 中的代码以匹配前面的代码片段,您将看到输出。

ProcessBuilder.Redirect嵌套类表示由ProcessBuilder创建的新进程的输入源和输出目的地。该类定义了以下三个ProcessBuilder .Redirect类型的常量:

  • ProcessBuilder.Redirect DISCARD:丢弃新进程的输出

  • ProcessBuilder.Redirect.INHERIT:表示新进程的输入源或输出目的地将与当前进程相同

  • ProcessBuilder.Redirect.PIPE:表示新进程将通过管道连接到当前进程,这是默认设置

您还可以使用Process.Redirect类的以下方法将新进程的输入和输出重定向到一个文件:

  • ProcessBuilder.Redirect appendTo(File file)

  • ProcessBuilder.Redirect from(File file)

  • ProcessBuilder.Redirect to(File file)

在前面的代码片段中,您看到了如何使用ProcessBuilder类的inheritIO()方法让新进程拥有与当前进程相同的标准 I/O。您可以按如下方式重写代码:

// Configure the ProcessBuilder inheriting parent's I/O
ProcessBuilder pb =
    new ProcessBuilder(javaPath, "--version")
        .redirectInput(ProcessBuilder.Redirect.INHERIT)
        .redirectOutput(ProcessBuilder.Redirect.INHERIT)
        .redirectError(ProcessBuilder.Redirect.INHERIT);

下面的代码片段将新进程的标准输出重定向到当前目录中名为java_product_details.txt的文件:

// Configure the ProcessBuilder
    ProcessBuilder pb =
        new ProcessBuilder(javaPath, "--version")
        .redirectOutput(
            ProcessBuilder.Redirect.to(
                new File("java_product_details.txt")));

让我们看一个复杂的例子,它将探索关于新的本地进程的更多信息。清单 11-6 包含一个名为Job的类的代码。它的main()方法需要两个参数:睡眠间隔和以秒为单位的睡眠持续时间。如果没有通过,该方法将使用 5 秒和 60 秒作为默认值。在第一部分,该方法尝试提取第一个和第二个参数(如果指定)。在第二部分中,它使用 ProcessHandle.current()方法获取执行该方法的当前进程的进程句柄。它读取当前进程的 PID,并打印一条包括 PID、睡眠间隔和睡眠持续时间的消息。最后,它开始一个 for 循环,并在睡眠间隔内保持睡眠,直到达到睡眠持续时间。在循环的每次迭代中,它都会打印一条消息。

// Job.java
package com.jdojo.process;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
 * An instance of this class is used as a job that sleeps
 * at a regular interval up to a maximum duration. The
 * sleep interval in seconds can be specified as the first
 * argument and the sleep duration as the second argument
 * while running this class. The default sleep interval
 * and sleep duration are 5 seconds and 60 seconds,
 * respectively. If these values are less than zero, zero
 * is used instead.
 */
public class Job {
    // The job sleep interval
    public static final long DEFAULT_SLEEP_INTERVAL = 5;
    // The job sleep duration
    public static final long DEFAULT_SLEEP_DURATION = 60;
    public static void main(String[] args) {
        long sleepInterval = DEFAULT_SLEEP_INTERVAL;
        long sleepDuration = DEFAULT_SLEEP_DURATION;
        // Get the passed in sleep interval
        if (args.length >= 1) {
            sleepInterval = parseArg(args[0],
                DEFAULT_SLEEP_INTERVAL);
            if (sleepInterval < 0) {
                sleepInterval = 0;
            }
        }
        // Get the passed in the sleep duration
        if (args.length >= 2) {
            sleepDuration = parseArg(args[1],
                DEFAULT_SLEEP_DURATION);
            if (sleepDuration < 0) {
                sleepDuration = 0;
            }
        }
        long pid = ProcessHandle.current().pid();
        System.out.printf(
            "Job (pid=%d) info: Sleep Interval"
            + "=%d seconds, Sleep Duration=%d "
            + "seconds.%n",
            pid, sleepInterval, sleepDuration);
        for (long sleptFor = 0; sleptFor < sleepDuration;
                sleptFor += sleepInterval) {
            try { 

                System.out.printf(
                    "Job (pid=%d) is going to"
                    + " sleep for %d seconds.%n",
                    pid, sleepInterval);
                // Sleep for the sleep interval
                TimeUnit.SECONDS.sleep(sleepInterval);
            } catch (InterruptedException ex) {
                System.out.printf("Job (pid=%d) was "
                        + "interrupted.%n", pid);
            }
        }
    }
    /**
     * Starts a new JVM to run the Job class.
     *
     * @param sleepInterval The sleep interval when the
     *   Job class is run. It is passed to the JVM as the
     *   first argument.
     * @param sleepDuration The sleep duration for the
     *    Job class. It is passed to the JVM as the
     *    second argument.
     * @return The new process reference of the newly
     * launched JVM or null if the JVM
     * cannot be launched.
     */
    public static Process startProcess(long sleepInterval,
            long sleepDuration) {
        // Store the command to launch a new JVM in a
        // List<String>
        List<String> cmd = new ArrayList<>();
        // Add command components in order
        addJvmPath(cmd);
        addModulePath(cmd);
        addClassPath(cmd);
        addMainClass(cmd);
        // Add arguments to run the class
        cmd.add(String.valueOf(sleepInterval));
        cmd.add(String.valueOf(sleepDuration));
        // Build the process attributes
        ProcessBuilder pb = new ProcessBuilder()
                .command(cmd)
                .inheritIO();
        String commandLine = pb.command()
                .stream() 

                .collect(Collectors.joining(" "));
        System.out.println(
            "Command used:\n" + commandLine);
        // Start the process
        Process p = null;
        try {
            p = pb.start();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return p;
    }
    /**
     * Used to parse the arguments passed to the JVM,
     * which in turn is passed to the main() method.
     *
     * @param valueStr The string value of the argument
     * @param defaultValue The default value of the
     *   argument if the valueStr is not an integer.
     * @return valueStr as a long or the defaultValue if
     * valueStr is not an integer.
     */
    private static long parseArg(String valueStr,
            long defaultValue) {
        long value = defaultValue;
        if (valueStr != null) {
            try {
                value = Long.parseLong(valueStr);
            } catch (NumberFormatException e) {
                // no action needed
            }
        }
        return value;
    }
    /**
     * Adds the JVM path to the command list. It first
     * attempts to use the command attribute of the
     * current process; failing that it relies on the
     * java.home system property.
     *
     * @param cmd The command list
     */ 

    private static void addJvmPath(List<String> cmd) {
        // First try getting the command to run the
        // current JVM
        String jvmPath = ProcessHandle.current()
            .info()
            .command().orElse("");
        if (jvmPath.length() > 0) {
            cmd.add(jvmPath);
        } else {
            // Try composing the JVM path using the
            // java.home system property
            final String FILE_SEPARATOR =
                System.getProperty("file.separator");
            jvmPath = System.getProperty("java.home")
                    + FILE_SEPARATOR + "bin"
                    + FILE_SEPARATOR + "java";
            cmd.add(jvmPath);
        }
    }
    /**
     * Adds a module path to the command list.
     *
     * @param cmd The command list
     */
    private static void addModulePath(List<String> cmd) {
        String modulePath
            = System.getProperty("jdk.module.path");
        if (modulePath != null
                && modulePath.trim().length() > 0) {
            cmd.add("--module-path");
            cmd.add(modulePath);
        }
    } 

    /**
     * Adds class path to the command list.
     *
     * @param cmd The command list
     */
    private static void addClassPath(List<String> cmd) {
        String classPath =
            System.getProperty("java.class.path");
        if (classPath != null
                && classPath.trim().length() > 0) {
            cmd.add("--class-path");
            cmd.add(classPath);
        }
    }
    /**
     * Adds a main class to the command list. Adds
     * module/className or just className depending on
     * whether the Job class was loaded in a named
     * module or unnamed module
     *
     * @param cmd The command list
     */
    private static void addMainClass(List<String> cmd) {
        Class<Job> cls = Job.class;
        String className = cls.getName();
        Module module = cls.getModule();
        if (module.isNamed()) {
            String moduleName = module.getName();
            cmd.add("--module");
            cmd.add(moduleName + "/" + className);
        } else {
            cmd.add(className);
        }
    }
}

Listing 11-6The Declaration of a Class Named Job

Job类包含一个启动新进程的startProcess(long sleepInterval, long sleepDuration)方法。它启动一个以Job类为主类的 JVM。它将休眠间隔和持续时间作为参数传递给 JVM。该方法试图构建一个命令来从JDK_HOME\bin目录启动java命令。如果Job类被加载到一个命名的模块中,它将构建一个类似这样的命令:

JDK_HOME/bin/java --module-path <module-path> \
--module jdojo.process/com.jdojo.process.Job \
<sleepInterval> <sleepDuration>

如果Job类被加载到一个未命名的模块中,它会尝试构建一个如下所示的命令:

JDK_HOME/bin/java \
-class-path <class-path> \
com.jdojo.process.Job \
<sleepInterval> <sleepDuration>

startProcess()方法打印用于启动进程的命令,尝试启动进程,并返回进程引用。

addJvmPath()方法将 JVM 路径添加到命令列表中。它试图获取当前 JVM 进程的命令,以用作新进程的 JVM 路径。如果它不可用,它会尝试从java.home系统属性构建它。

Job类包含几个实用方法,用于组成命令的一部分,并解析传递给main()方法的参数。有关描述,请参考他们的 Javadoc。

如果您想要启动一个应该运行 15 秒并每 5 秒唤醒一次的新进程,您可以使用Job类的startProcess()方法来实现:

// Start a process that runs for 15 seconds
Process p = Job.startProcess(5, 15);

您可以使用您在清单 11-3 中创建的CurrentProcessInfo类的printInfo()方法打印进程细节:

// Get the handle of the current process
ProcessHandle handle = p.toHandle();
// Print the process details
CurrentProcessInfo.printInfo(handle);

当进程终止时,您可以使用ProcessHandleonExit()方法的返回值来运行任务:

CompletableFuture<ProcessHandle> future = handle.onExit();
// Print a message when process terminates
future.thenAccept((ProcessHandle ph) -> {
    System.out.printf(
        "Job (pid=%d) terminated.%n", ph.pid());
});

您可以等待新进程终止,如下所示:

// Wait for the process to terminate
future.get();

在这个例子中,future.get()将返回进程的ProcessHandle。我没有使用返回值,因为我已经在handle变量中有了它。

清单 11-7 包含了一个StartProcessTest类的代码,展示了如何使用Job类创建一个新的进程。在它的main()方法中,它创建一个新进程,打印进程细节,向进程添加一个关闭任务,等待进程终止,然后再次打印进程细节。请注意,该进程运行了 15 秒,但它只使用了 0.359375 秒的 CPU 时间,因为该进程的主线程大部分时间都在休眠。您可能会得到不同的输出。当程序在 Linux 上运行时,会生成输出。

// StartProcessTest.java
package com.jdojo.process;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
public class StartProcessTest {
    public static void main(String[] args) {
        // Start a process that runs for 15 seconds
        Process p = Job.startProcess(5, 15);
        if (p == null) {
            System.out.println(
                "Could not create a new process.");
            return;
        }
        // Get the handle of the current process
        ProcessHandle handle = p.toHandle();
        // Print the process details
        CurrentProcessInfo.printInfo(handle);
        CompletableFuture<ProcessHandle> future =
            handle.onExit();
        // Print a message when process terminates
        future.thenAccept((ProcessHandle ph) -> {
            System.out.printf(
                "Job (pid=%d) terminated.%n",
                ph.pid());
        });
        try {
            // Wait for the process to complete
            future.get();
        } catch (InterruptedException
                | ExecutionException e) {
            e.printStackTrace();
        } 

        // Print process details again
        CurrentProcessInfo.printInfo(handle);
    }
}

Command used:
/opt/jdk17/bin/java
  --class-path /[<path-to-project>]/bin
  com.jdojo.process.Job 5 15
PID: 8701
IsAlive: true
Command: /opt/jdk17/bin/java
Arguments: [
  --class-path,
  /[<path-to-project>]/bin,
  com.jdojo.process.Job,
  5, 15 ]
CommandLine: /opt/jdk17/bin/java
  --class-path /[<path-to-project>]/bin
  com.jdojo.process.Job
  5 15
Start Time: 2021-07-16T18:11:42.510+02:00
  [Europe/Berlin]
CPU Time: PT0.01S
Owner: peter
Children Count: 0
Job (pid=8701) info:
  Sleep Interval=5 seconds, Sleep Duration=15 seconds.
Job (pid=8701) is going to sleep for 5 seconds.
Job (pid=8701) is going to sleep for 5 seconds.
Job (pid=8701) is going to sleep for 5 seconds.
Job (pid=8701) terminated.
PID: 8701
IsAlive: false
Command:
Arguments: []
CommandLine:
Start Time: 2021-07-16T18:11:58.489975569+02:00
  [Europe/Berlin]
CPU Time: PT0S
Owner: Unknown
Children Count: 0

Listing 11-7A StartProcessTest Class That Creates New Processes

获取进程句柄

有几种方法可以获得本机进程的句柄。对于由 Java 代码创建的进程,您可以使用Process类的toHandle()方法获得一个ProcessHandle。本机进程也可以从 JVM 外部创建。ProcessHandle接口包含以下方法来获取本地进程的句柄:

  • static Optional<ProcessHandle> of(long pid)

  • static ProcessHandle current()

  • Optional<ProcessHandle> parent()

  • Stream<ProcessHandle> children()

  • Stream<ProcessHandle> descendants()

  • static Stream<ProcessHandle> allProcesses()

of()静态方法为指定的pid返回一个Optional<ProcessHandle>。如果这个pid没有进程,则返回一个空的Optional。要使用这种方法,您需要知道进程的 PID:

// Get the process handle of the process with the pid
// of 1234
Optional<ProcessHandle> handle = ProcessHandle.of(1234L);

current()静态方法返回当前进程的句柄,它总是执行代码的 Java 进程。你已经在清单 11-3 中看到了这样的例子。

parent()方法返回父进程的句柄。如果该进程没有父进程或者无法检索父进程,它将返回一个空的Optional

children()方法返回该进程所有直接子进程的快照。不能保证此方法返回的进程仍处于活动状态。请注意,不活跃的进程没有子进程。

descendants()方法返回该进程所有直接或间接子进程的快照。

allProcesses()方法返回该进程可见的所有进程的快照。不能保证流在处理时包含操作系统中的所有进程。在拍摄快照后,可能已经终止或创建了进程。下面的代码片段打印了按 PID 排序的所有进程的 PID:

System.out.printf("All processes PIDs:%n");
ProcessHandle.allProcesses()
    .map(ph -> ph.pid())
    .sorted()
    .forEach(System.out::println);

您可以为所有正在运行的进程计算不同类型的统计数据。您还可以在 Java 中创建一个任务管理器,它显示一个 UI,该 UI 显示所有正在运行的进程及其属性。清单 11-8 展示了如何获得运行时间最长的进程的详细信息以及使用 CPU 时间最多的进程。我比较了进程的开始时间以获得运行时间最长的进程,并比较了总的 CPU 持续时间以获得使用 CPU 时间最多的进程。您可能会得到不同的输出。当我在 Linux 上运行这个程序时,我得到了这个输出。

// ProcessStats.java
package com.jdojo.process;
import java.time.Duration;
import java.time.Instant;
public class ProcessStats {
    public static void main(String[] args) {
        System.out.printf("Longest CPU User Process:%n");
        ProcessHandle.allProcesses()
                .max(ProcessStats::compareCpuTime)
                .ifPresent(CurrentProcessInfo::printInfo);
        System.out.printf("%nLongest Running Process:%n");
        ProcessHandle.allProcesses()
                .max(ProcessStats::compareStartTime)
                .ifPresent(CurrentProcessInfo::printInfo);
    } 

    public static int compareCpuTime(ProcessHandle ph1,
            ProcessHandle ph2) {
        return ph1.info()
                .totalCpuDuration()
                .orElse(Duration.ZERO)
                .compareTo(ph2.info()
                        .totalCpuDuration()
                        .orElse(Duration.ZERO));
    }
    public static int
    compareStartTime(ProcessHandle ph1,
            ProcessHandle ph2) {
        return ph1.info()
                .startInstant()
                .orElse(Instant.now())
                .compareTo(ph2.info()
                        .startInstant()
                        .orElse(Instant.now()));
    }
}

Longest CPU User Process:
PID: 2323
IsAlive: true
Command: /usr/lib/tracker/tracker-miner-fs
Arguments: []
CommandLine: /usr/lib/tracker/tracker-miner-fs
Start Time: 2021-07-16T13:43:03.590+02:00[Europe/Berlin]
CPU Time: PT14M35.72S
Owner: peter
Children Count: 0

Longest Running Process:
PID: 9019
IsAlive: true

Command: /opt/openjdk-16.36/bin/java
Arguments: [
  -Dfile.encoding=UTF-8,
  -classpath,
  [...],
  -XX:+ShowCodeDetailsInExceptionMessages,
  com.jdojo.process.ProcessStats]
CommandLine: /opt/jdk17/bin/java
  -Dfile.encoding=UTF-8
  -classpath [...]
  -XX:+ShowCodeDetailsInExceptionMessages
  com.jdojo.process.ProcessStats
Start Time: 2021-07-16T19:02:01.020+02:00[Europe/Berlin]
CPU Time: PT0.3S
Owner: peter
Children Count: 0

Listing 11-8Computing Process Statistics

终止进程

您可以使用ProcessHandle接口的destroy()destroyForcibly()方法和Process类来终止一个进程。如果终止进程的请求成功,两个方法都返回true,否则返回falsedestroy()方法请求正常终止,而destroyForcibly()方法请求强制终止。在发出终止进程的请求后,isAlive()方法可能会返回true一小段时间。

Note

您不能终止当前进程。在当前进程上调用destroy()destroyForcibly()方法会抛出一个IllegalStateException。操作系统访问控制可以防止进程被终止。

进程的正常终止让进程干净地终止。进程的强制终止会立即终止该进程。进程是否正常终止取决于实现。你可以使用ProcessHandle接口的supportsNormalTermination()方法和Process类来检查一个进程是否支持正常终止。如果进程支持正常终止,该方法返回true,否则返回false

调用这些方法之一来终止已经终止的进程不会导致任何操作。当进程终止时,Process类的onExit()返回的CompletableFuture<Process>ProcessHandle接口的onExit()返回的CompletableFuture<ProcessHandle>completed

管理进程权限

当您运行前几节中的示例时,我假设没有安装 Java 安全管理器。如果安装了安全管理器,则需要授予适当的权限来启动、管理和查询本机进程:

  • 如果您正在创建一个新的进程,您需要拥有FilePermission(cmd,"execute")权限,其中cmd是将创建该进程的命令的绝对路径。如果cmd不是绝对路径,你需要有FilePermission("<<ALL FILES>>","execute")权限。

  • 要查询本地进程的状态并使用ProcessHandle接口中的方法销毁进程,应用程序需要拥有RuntimePermission("manageProcess")权限。

清单 11-9 包含一个获取进程计数并创建一个新进程的程序。它在没有安全管理器和有安全管理器的情况下重复这两个任务。

// ManageProcessPermission.java
package com.jdojo.process;
import java.util.concurrent.ExecutionException;
public class ManageProcessPermission {
    public static void main(String[] args) {
        // Get the process count
        long count = ProcessHandle.allProcesses().count();
        System.out.printf("Process Count: %d%n", count);
        // Start a new process
        Process p = Job.startProcess(1, 3);
        try {
            p.toHandle().onExit().get();
        } catch (InterruptedException
                | ExecutionException e) {
            System.out.println(e.getMessage());
        }
        // Install a security manager
        SecurityManager sm = System.getSecurityManager();
        if (sm == null) {
            System.setSecurityManager(
                new SecurityManager());
            System.out.println(
                "A security manager is installed.");
        }
        // Get the process count
        try {
            count = ProcessHandle.allProcesses().count();
            System.out.printf("Process Count: %d%n",
                count);
        } catch (RuntimeException e) {
            System.out.println(
                "Could not get a process count: " +
                e.getMessage());
        }
        // Start a new process
        try {
            p = Job.startProcess(1, 3);
            p.toHandle().onExit().get();
        } catch (InterruptedException
                | ExecutionException
                | RuntimeException e) {
            System.out.println(
                "Could not start a new process: " +
                e.getMessage());
        }
    }
}

Listing 11-9Managing Processes with a Security Manager

假设您没有更改任何 Java 策略文件,尝试使用以下命令运行ManageProcessPermission类:

/opt/jdk17/bin/java \
-Dfile.encoding=UTF-8 \
-classpath /[<path-to-project>]/bin \
-XX:+ShowCodeDetailsInExceptionMessages \
com.jdojo.process.ManageProcessPermission

Process Count: 332
Command used:
/opt/jd17/bin/java
  --class-path [...] com.jdojo.process.Job 1 3
Job (pid=3858) info: Sleep Interval=1 seconds,
  Sleep Duration=3 seconds.
Job (pid=3858) is going to sleep for 1 seconds.
Job (pid=3858) is going to sleep for 1 seconds.
Job (pid=3858) is going to sleep for 1 seconds.
A security manager is installed.
Could not get a process count: access denied
    ("java.lang.RuntimePermission" "manageProcess")
Could not start a new process: access denied
    ("java.lang.RuntimePermission" "manageProcess")

您可能会得到不同的输出。输出表明您能够在安装安全管理器之前获得进程计数并创建一个新进程。安装安全管理器后,Java 运行时在请求进程计数和创建新进程时抛出异常。要解决该问题,您需要授予以下权限:

  • "manageProcess" RuntimePermission,它将允许应用程序查询本机进程并创建一个新进程

  • Java 命令路径上的"execute" FilePermission,它将允许启动 JVM

  • "jdk.module.path"上的"read" PropertyPermission"java.class.path"系统属性,因此Job类可以在构建启动 JVM 的命令行时读取这些属性

清单 11-10 包含一个将这四种权限授予所有代码的脚本。您需要将这个脚本添加到您机器上的JDK_HOME/conf/security/java.policy文件中。Java launcher 的路径是/opt/jdk17/bin/java,在 Linux 上只有在/opt/jdk17目录下安装了 JDK17 才有效。对于所有其他平台和 JDK 安装,修改该路径以指向您机器上正确的 Java 启动器。

grant {
    permission java.lang.RuntimePermission
        "manageProcess";
    permission java.io.FilePermission
        "/opt/jdk17/bin/java", "execute";
    permission java.util.PropertyPermission
        "jdk.module.path", "read";
    permission java.util.PropertyPermission
        "java.class.path", "read";
};

Listing 11-10Addendum to the JDK_HOME/conf/security/java.policy File

如果使用相同的命令再次运行ManageProcessPermission类,应该会得到类似如下的输出:

/opt/jdk17/bin/java \
  -Dfile.encoding=UTF-8 \
  -classpath /[<path-to-project>]/bin \
  -XX:+ShowCodeDetailsInExceptionMessages \
  com.jdojo.process.ManageProcessPermission

Process Count: 330
Command used:
/opt/jdk17/bin/java
  --class-path [...]
  com.jdojo.process.Job 1 3
Job (pid=6093) info: Sleep Interval=1 seconds,
  Sleep Duration=3 seconds.
Job (pid=6093) is going to sleep for 1 seconds.
Job (pid=6093) is going to sleep for 1 seconds.
Job (pid=6093) is going to sleep for 1 seconds.

A security manager is installed.

Process Count: 330
Command used:
/opt/jdk17/bin/java
  --class-path [...]
  com.jdojo.process.Job 1 3
Job (pid=6114) info: Sleep Interval=1 seconds,
  Sleep Duration=3 seconds.
Job (pid=6114) is going to sleep for 1 seconds.
Job (pid=6114) is going to sleep for 1 seconds.
Job (pid=6114) is going to sleep for 1 seconds.

摘要

进程 API 由与本地进程一起工作的类和接口组成。Java SE 从 1.0 版本开始就通过RuntimeProcess类提供了进程 API。它允许您创建新的本机进程,管理它们的 I/O 流,并销毁它们。Java SE 的更高版本改进了 API,增加了一个名为ProcessHandle的接口来表示进程句柄。您可以使用进程句柄来查询和管理本机进程。

以下类和接口组成了进程 API: RuntimeProcessBuilderProcessBuilder.RedirectProcessProcessHandleProcessHandle.Info

Runtime类的exec()方法用于启动一个本地进程。在启动一个进程时,ProcessBuilder类的start()方法比Runtime类的exec()方法更可取。ProcessBuilder.Redirect类的一个实例代表一个进程的输入源或一个进程的输出目的地。

默认情况下,新进程的标准 I/O 通过管道连接到当前进程。您需要读写与管道相关的流,以访问新进程的标准 I/O。您可以选择将新进程的标准 I/O 设置为与当前进程相同,或者将 I/O 重定向到其他源/目标,如文件。

Process类的一个实例代表一个由 Java 程序创建的本地进程。

ProcessHandle接口的一个实例代表一个由 Java 程序或其他方式创建的进程;它是在 Java 9 中添加的,提供了几种查询和管理进程的方法。ProcessHandle.Info接口的一个实例代表一个进程的快照信息;它可以通过使用Process类的info()方法或ProcessHandle接口获得。如果你有一个Process实例,使用它的toHandle()方法得到一个ProcessHandle

ProcessHandle接口的onExit()方法返回一个CompletableFuture<ProcessHandle>来终止进程。您可以使用返回的对象添加一个任务,该任务将在进程终止时执行。请注意,您不能在当前进程中使用此方法。

如果安装了安全管理器,应用程序需要有一个"manageProcess" RuntimePermission来查询和管理本地进程,并在从 Java 代码启动的进程的命令文件上有一个"execute" FilePermission

练习

练习 1

什么是进程 API?

练习 2

Runtime类的实例代表什么?

运动 3

如何获得Runtime类的实例?

演习 4

如何使用ProcessBuilder类?这个类的什么方法被用来启动一个新的本地进程?

锻炼 5

Process类的实例代表什么?

锻炼 6

ProcessHandle接口的实例代表什么?你如何从一个Process那里获得一个ProcessHandle

锻炼 7

如何获得代表正在运行的 Java 程序的当前进程的句柄?

运动 8

ProcessHandle.Info接口的实例代表什么?

演习 9

ProcessBuilder类的start()方法创建的新进程的默认标准 I/O 是什么?

运动 10

可以使用 Process API 终止当前的 Java 程序吗?

十二、打包模块

在本章中,您将学习:

  • 打包 Java 模块的不同格式

  • 对 JAR 格式的增强

  • 多释放罐是什么

  • 如何创建和使用多版本 jar

  • JMOD 格式是什么

  • 如何使用jmod工具处理 JMOD 文件

  • 如何创建、提取和描述 JMOD 文件

  • 如何列出 JMOD 文件的内容

  • 如何在 JMOD 文件中记录模块的哈希以进行依赖验证

一个模块可以打包成不同的格式,用于三个阶段:编译时、链接时和运行时。并非所有阶段都支持所有格式。Java 支持以下格式来打包模块:

  • 展开的目录

  • JAR 格式

  • JMOD 格式

  • 图像格式

JDK9 之前支持展开目录和 JAR 格式。JAR 格式在 JDK9 中得到增强,以支持模块化 JAR 和多版本 JAR。JDK9 引入了两种新的模块打包格式:JMOD 格式和 JIMAGE 格式。在本章中,我将讨论 JAR 格式和 JMOD 格式的增强。第十三章详细介绍了 JIMAGE 格式以及jlink工具。

JAR 格式

在本书中,我们还没有谈到非模块化和模块化的 jar。然而,这两种变体都属于入门风格的书籍,所以如果需要更多关于标准或模块化 jar 的信息,我们要求读者查阅 Oracle 的 Java 文档和命令帮助(输入jar -h)。

在这一章中,我将介绍一个添加到 JAR 格式中的新特性,它被称为多版本 JAR。

什么是多释放罐?

作为一名经验丰富的 Java 开发人员,您必须使用过 Java 库/框架,如 Spring framework、Hibernate 等。你可能在用 Java 17,但是那些库可能还在用 Java 8。为什么库开发者不能使用最新版本来利用 JDK 的新特性?原因之一是并非所有图书馆用户都使用最新的 JDK。更新图书馆以使用新版 JDK 意味着迫使所有图书馆用户迁移到新版 JDK,这在实践中是不可能的。维护和发布针对不同 JDK 的库是打包代码时的另一个痛苦。通常,您会为不同的 JDK 找到一个单独的库 JAR。Java 通过向库开发人员提供一种特殊的打包库代码的方式解决了这个问题——使用一个 JAR 包含多个 JDK 的相同版本的库。这种震击器被称为多释放震击器

一个多版本 JAR (MRJAR)包含一个用于多个 JDK 版本的相同版本的库(提供相同的 API)。也就是说,您可以拥有一个作为 MRJAR 的库,它将为 JDK8 和 JDK17 工作。MRJAR 中的代码将包含在 JDK8 和 JDK17 中编译的类文件。用 JDK17 编译的类可以利用 JDK9 和更高版本提供的 API,而用 JDK8 编译的类可以提供用 JDK8 编写的相同的库 API。

MRJAR 扩展了 JAR 已经存在的目录结构。JAR 包含一个根目录,它的所有内容都驻留在这个根目录中。它包含一个用于存储 JAR 元数据的META-INF目录。通常,JAR 包含一个包含其属性的META-INF/MANIFEST.MF文件。典型 JAR 中的条目如下所示:

- jar-root
  - C1.class
  - C2.class
  - C3.class
  - C4.class
- META-INF
  - MANIFEST.MF

JAR 包含四个类文件和一个MANIFEST.MF文件。MRJAR 扩展了META-INF目录来存储特定于 JDK 版本的类。META-INF目录包含一个versions子目录,其中可能包含许多子目录——每个子目录的名称都与 JDK 主版本相同。例如,对于特定于 JDK17 的类,可能有META-INF/versions/17目录,对于特定于 JDK16 的类,可能有一个名为META-INF/versions/16的目录,等等。典型的 MRJAR 可能包含以下条目:

- jar-root
  - C1.class
  - C2.class
  - C3.class
  - C4.class
- META-INF
  - MANIFEST.MF
  - versions
    - 16
      - C2.class
      - C5.class
    - 17
      - C1.class
      - C2.class
      - C6.class

如果在不支持 MRJAR 的环境中使用这个 Mr JAR,它将被视为一个普通的 JAR——将使用根目录中的内容,而忽略META-INF/versions/17META-INF/versions/16中的所有其他内容。所以,如果这个 MRJAR 与 JDK8 一起使用,那么只会使用四个类:C1C2C3C4

当这个 MRJAR 在 JDK16 中使用时,有五个类在起作用:C1C2C3C4C5。将使用META-INF/versions/9目录中的C2类,而不是根目录中的C2类。在这种情况下,MRJAR 说它有一个新版本的用于 JDK16 的C2类,它覆盖了根目录中用于 JDK8 或更早版本的C2的版本。JDK16 版本还增加了一个名为C5的新类。

基于类似的理由,MRJAR 覆盖了类C1C2,并为 JDK 版本 17 包含了一个名为C6的新类。

针对单个 MRJAR 中的多个 JDK 版本,MRJAR 中的搜索过程不同于常规的 JAR。在 MRJAR 中搜索资源或类文件使用以下规则:

  • JDK 的主要版本是根据使用 MRJAR 的环境来确定的。假设 JDK 的主要版本是N

  • 为了定位名为R的资源或类文件,从版本N的目录开始搜索META-INF/versions目录下的特定于平台的子目录。

  • 如果在子目录N中找到R,则返回。否则,搜索低于N版本的子目录。对于META-INF/versions目录下的所有子目录,该过程继续进行。

  • 当在META-INF/versions/N子目录中没有找到R时,在 MRJAR 的根目录中搜索R

让我们以前面展示的 MRJAR 结构为例。假设程序正在寻找C3.class,JDK 的当前版本是 17。搜索将从META-INF/versions/17开始,在这里没有找到C3.class。搜索在META-INF/versions/16继续,在那里没有找到C3.class。现在搜索继续在根目录中进行,在那里找到了C3.class

再举一个例子,假设你想在 JDK 版本是 17 的时候找到C2.class。搜索从META-INF/versions/17开始,在这里C2.class被找到并返回。

再举一个例子,假设你想在 JDK 版本是 16 的时候找到C2.class。搜索从META-INF/versions/16开始,在这里C2.class被找到并返回。

再举一个例子,假设你想在 JDK 版本是 8 的时候找到C2.class。没有名为META-INF/versions/8的 JDK8 专用目录。因此,搜索从根目录开始,在那里找到并返回C2.class

注意所有处理震击器的工具——如javajavacjavap——都能够使用多释放震击器。处理 jar 的 API 也知道如何处理多版本 jar。

创建多版本 jar

一旦知道了在特定的 JDK 版本上搜索资源或类文件时 MRJAR 中目录的搜索顺序,就很容易理解如何找到类和资源了。JDK 版本特定目录的内容有一些规则。我将在后续章节中描述这些规则。在这一节中,我将重点介绍如何创建 MRJARs。

要运行此示例,您需要在计算机上安装 JDK8 和 JDK17。

我使用 MRJAR 来存储应用程序的 JDK8 和 JDK17 版本。该应用程序由以下两个类组成:

  • com.jdojo.mrjar.Main

  • com.jdojo.mrjar.TimeUtil

Main类创建了一个TimeUtil类的对象,并在其中调用了一个方法。Main类可以作为main类来运行应用程序。TimeUtil类包含一个getLocalDate(Instant now)方法,该方法将一个Instant作为参数,并返回一个LocalDate来解释当前时区的时间。JDK17 在LocalDate类中有一个方法,命名为ofInstant(Instant instant, ZoneId zone)。我们将更新应用程序以使用 JDK17 来利用这种方法,并将保留出于相同目的使用 JDK8 时间 API 的旧应用程序。

本书的源代码包含两个项目。jdk17book目录下的主项目包含一个用于 JDK17 的名为jdojo.mrjar的模块。jdk17book\jdojo.mrjar.jdk8目录包含一个名为jdojo.mrjar.jdk8的项目,该项目包含 JDK8 代码。

清单 12-1 和 12-2 分别包含 JDK8 的TimeUtilMain类的代码。这些项目的源代码很简单,所以我不会提供任何解释。我本可以将TimeUtil类中的getLocalDate()方法变成静态方法。我将它作为一个实例方法保存,所以您可以在输出(稍后讨论)中看到类的哪个版本被实例化了。当您运行Main类时,它打印当前的本地日期,当您运行这个例子时可能会有所不同。

// Main.java
package com.jdojo.mrjar;
import java.time.Instant;
import java.time.LocalDate;
public class Main {
    public static void main(String[] args) {
        System.out.println(
            "Inside JDK 8 version of Main.main()...");
        TimeUtil t = new TimeUtil();
        LocalDate ld = t.getLocalDate(Instant.now());
        System.out.println("Local Date: " + ld);
    }
}

Inside JDK 8 version of Main.main()...
Creating JDK 8 version of TimeUtil...
Local Date: 2021-09-22

Listing 12-2A Main Class for JDK8

// TimeUtil.java
package com.jdojo.mrjar;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
public class TimeUtil {
    public TimeUtil() {
        System.out.println(
            "Creating JDK 8 version of TimeUtil...");
    }
    public LocalDate getLocalDate(Instant now) {
        return now.atZone(ZoneId.systemDefault())
                  .toLocalDate();
    }
}

Listing 12-1A TimeUtil Class for JDK8

我们将把所有的 JDK17 类放在一个名为jdojo.mrjar的模块中,其声明如清单 12-3 所示。清单 12-4 和 12-5 分别包含 JDK17 的TimeUtilMain类的代码。

// Main.java
package com.jdojo.mrjar;
import java.time.Instant;
import java.time.LocalDate;
public class Main {
    public static void main(String[] args) {
        System.out.println(
            "Inside JDK 17 version of Main.main()...");
        TimeUtil t = new TimeUtil();
        LocalDate ld = t.getLocalDate(Instant.now());
        System.out.println("Local Date: " + ld);
    }
}

Inside JDK 17 version of Main.main()...
Creating JDK 17 version of TimeUtil...
Local Date: 2021-09-22

Listing 12-5A Main Class for JDK17

// TimeUtil.java
package com.jdojo.mrjar;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
public class TimeUtil {
    public TimeUtil() {
        System.out.println(
            "Creating JDK 17 version of TimeUtil...");
    }
    public LocalDate getLocalDate(Instant now) {
        return LocalDate.ofInstant(now,
            ZoneId.systemDefault());
    }

Listing 12-4A TimeUtil Class for JDK17

// module-info.java
module jdojo.mrjar {
    exports com.jdojo.mrjar;
}

Listing 12-3A Module Declaration for a Module Named com.jdojo.mrjar

我已经展示了在 JDK8 和 JDK17 上运行Main类时的输出。然而,这个例子的目的不是单独运行这两个类,而是将它们打包在一个 MRJAR 中,并从这个 MRJAR 中运行它们,稍后我将向您展示这一点。

为了处理 MRJARs,jar工具接受一个名为– release的选项。其语法如下:

jar <options> --release N <other-options>

在这里,N是一个 JDK 的主要版本,如 17 为 JDK17。N的值必须大于或等于 9。跟随–release N选项的所有文件都被添加到 MRJAR 的META-INF/versions/N目录中。

下面的命令创建一个名为jdojo.mrjar.jar的 MRJAR,并将其放在C:\jdk17book\mrjars目录中。在运行以下命令之前,确保输出目录mrjars已经存在:

C:\jdk17book>jar --create --file mrjars\jdojo.mrjar.jar ^
    -C jdojo.mrjar.jdk8\build\classes . ^
    --release 17 -C build\modules\jdojo.mrjar .

注意这个命令中–release 17选项的使用。来自build\modules\jdojo.mrjar目录的所有文件都将被添加到 MRJAR 中的META-INF/versions/17目录。来自jdojo.mrjar.jdk8\build\classes目录的所有文件都将被添加到 MRJAR 的根目录中。MRJAR 中的条目将如下所示:

- jar-root
  - com
    - jdojo
      - mrjar
        - Main.class
        - TimeUtil.class
- META-INF
  - MANIFEST.MF
  - versions
    - 17
      - module-info.class
      - com
        - jdojo
          - mrjar
            - Main.class
            - TimeUtil.class

在创建 MRJARs 时,将–verbose选项与jar工具一起使用非常有帮助。该选件打印出许多有助于诊断错误的有用信息。下面是和以前一样的命令,但是带有–verbose选项。输出显示复制了哪些文件及其位置:

C:\jdk17book>jar --create --verbose ^
    --file mrjars\jdojo.mrjar.jar ^
    -C jdojo.mrjar.jdk8\build\classes . ^
    --release 17 -C build\modules\jdojo.mrjar .

added manifest 

added module-info: META-INF/versions/17/module-info.class
adding: com/(in = 0) (out= 0)(stored 0%)
adding: com/jdojo/(in = 0) (out= 0)(stored 0%)
adding: com/jdojo/mrjar/(in = 0) (out= 0)(stored 0%)
adding: com/jdojo/mrjar/Main.class(in = 1098)
    (out= 591)(deflated 46%)
adding: com/jdojo/mrjar/TimeUtil.class(in = 884)
    (out= 503)(deflated 43%)
adding: META-INF/versions/17/(in = 0)
    (out= 0)(stored 0%)
adding: META-INF/versions/17/com/(in = 0)
    (out= 0)(stored 0%)
adding: META-INF/versions/17/com/jdojo/(in = 0)
    (out= 0)(stored 0%)
adding: META-INF/versions/17/com/jdojo/mrjar/(in = 0)
    (out= 0)(stored 0%)
adding: META-INF/versions/17/com/jdojo/mrjar/Main.class
    (in = 1326) (out= 688)(deflated 48%)
adding: META-INF/versions/17/com/jdojo/mrjar/TimeUtil.class
    (in = 814) (out= 470)(deflated 42%)

假设您想要为 JDK 版本 8、16 和 17 创建一个 MRJAR。假设jdojo.mrjar.jdk16\modules\jdojo.mrjar目录包含特定于 JDK16 的类,下面的命令将完成这项工作:

C:\jdk17book>jar --create --verbose ^
    --file mrjars\jdojo.mrjar.jar ^
    -C jdojo.mrjar.jdk8\build\classes . ^
--release 17 -C build\modules\jdojo.mrjar . ^
--release 16 -C jdojo.mrjar.jdk16\modules\jdojo.mrjar .

您可以使用–list选项来验证 MRJAR 中的条目,如下所示:

C:\jdk17book>jar -list --file mrjars\jdojo.mrjar.jar

META-INF/
META-INF/MANIFEST.MF
META-INF/versions/17/module-info.class
com/ 

com/jdojo/
com/jdojo/mrjar/
com/jdojo/mrjar/Main.class
com/jdojo/mrjar/TimeUtil.class
META-INF/versions/17/
META-INF/versions/17/com/
META-INF/versions/17/com/jdojo/
META-INF/versions/17/com/jdojo/mrjar/
META-INF/versions/17/com/jdojo/mrjar/Main.class
META-INF/versions/17/com/jdojo/mrjar/TimeUtil.class

假设您有一个包含 JDK8 的资源和类文件的 JAR,您想通过添加 JDK17 的资源和类文件来更新这个 JAR,使它成为一个 MRJAR。您可以通过使用–update选项更新 JAR 的内容来做到这一点。以下命令创建一个仅包含 JDK8 文件的 JAR:

C:\jdk17book>jar --create --file mrjars\jdojo.mrjar.jar ^
    -C jdojo.mrjar.jdk8\build\classes .

以下命令更新 JAR,使其成为 MRJAR:

C:\jdk17book>jar --update ^
    --file mrjars\com.jdojo.mrjar.jar ^
    --release 17 -C com.jdojo.mrjar.jdk17\build\classes .
C:\jdk17book>jar --update ^
    --file mrjars\jdojo.mrjar.jar ^
    --release 17 -C build\modules\jdojo.mrjar .

看看这个 MRJAR 的运行情况。下面的命令运行com.jdojo.mrjar包中的Main类,将 MRJAR 放在类路径上。JDK8 用于运行该类:

C:\jdk17book>C:\java8\bin\java ^
    -classpath mrjars\jdojo.mrjar.jar ^
    com.jdojo.mrjar.Main

Inside JDK 8 version of Main.main()...
Creating JDK 8 version of TimeUtil...
Local Date: 2021-09-22

输出显示,MainTimeUtil这两个类都是从 MRJAR 的根目录中使用的,因为 JDK8 不支持 MRJAR。下面的命令使用模块路径运行相同的类。JDK17 用于运行命令:

C:\jdk17book>C:\java17\bin\java ^
    --module-path mrjars\jdojo.mrjar.jar ^
    --module jdojo.mrjar/com.jdojo.mrjar.Main

Inside JDK 17 version of Main.main()...
Creating JDK 17 version of TimeUtil...
Local Date: 2021-09-22

输出显示,MainTimeUtil这两个类都是从 MRJAR 的META-INF/versions/17目录中使用的,因为 JDK17 支持 MRJAR,并且 MRJAR 拥有这些类的特定于 JDK17 的版本。

让我们稍微改变一下这个 MRJAR。创建一个具有相同内容的 MRJAR,但是在META-INF/versions/17目录中没有Main.class文件。在真实的场景中,在应用程序的 JDK17 版本中,只有TimeUtil类发生了变化,因此不需要为 JDK17 打包Main类。JDK8 的Main类也可以用在 JDK17 上。以下命令打包了我们上次所做的一切,除了 JDK17 的Main类。产生的 MRJAR 被命名为jdojo.mrjar2.jar

C:\jdk17book>jar --create ^
    --file mrjars\jdojo.mrjar2.jar ^
    -C jdojo.mrjar.jdk8\build\classes . ^
    --release 17 ^
    -C build\modules\jdojo.mrjar ^
    module-info.class ^
    -C build\modules\jdojo.mrjar ^
    com\jdojo\mrjar\TimeUtil.class

您可以使用以下命令验证新 MRJAR 的内容:

C:\jdk17book>jar --list --file mrjars\jdojo.mrjar2.jar

META-INF/
META-INF/MANIFEST.MF
META-INF/versions/17/module-info.class
com/
com/jdojo/
com/jdojo/mrjar/
com/jdojo/mrjar/Main.class
com/jdojo/mrjar/TimeUtil.class
META-INF/versions/17/com/jdojo/mrjar/TimeUtil.class

如果在 JDK8 上运行Main类,将会得到和以前一样的输出。但是,在 JDK17 上运行它会得到不同的输出:

C:\jdk17book>C:\java17\bin\java ^
    --module-path mrjars\jdojo.mrjar2.jar ^
    --module jdojo.mrjar/com.jdojo.mrjar.Main

Inside JDK 8 version of Main.main()...
Creating JDK 17 version of TimeUtil...
Local Date: 2021-09-22

输出显示从 JAR 根目录使用了Main类,而从META-INF/versions/17目录使用了TimeUtil类。请注意,您将获得不同的本地日期值。它在你的机器上打印当前日期。

多版本 jar 的规则

在创建多版本 jar 时,您需要遵循一些规则。如果你犯了一个错误,jar工具将打印错误。有时,错误消息并不直观。正如我所建议的,最好运行带有–verbose选项的jar工具,以获得关于错误的更多细节。

大多数规则基于一个事实:一个 MRJAR 包含一个用于多个 JDK 平台的库(或应用程序)的一个版本的 API。例如,您有一个名为jdojo-lib-1.0.jar的 MRJAR,它可能包含名为jdojo-lib的库的 API 的 1.0 版本,并且该库可能使用来自 JDK8 和 JDK17 的 API。这意味着当这个 MRJAR 用于类路径上的 JDK8、类路径上的 JDK17 或模块路径上的 JDK17 时,它应该提供相同的 API(就公共类型及其公共成员而言)。如果 MRJAR 在 JDK8 和 JDK17 上提供不同的 API,这就不是有效的 MRJAR。以下部分描述了一些规则。

MRJAR 可以是模块化的 JAR,在这种情况下,它可以在根目录、一个或多个版本化的目录或者两者的组合中包含一个模块描述符module-info.class。版本化描述符必须与根模块描述符相同,但有一些例外:

  • 一个版本化描述符可以有不同的不可传递的java.*jdk.*模块的requires语句。

  • 对于非 JDK 模块,不同的模块描述符不能有不同的非传递性requires语句。

  • 版本化描述符可以有不同的uses语句。

这些规则基于这样一个事实,即实现细节的改变是允许的,但 API 本身不允许。允许对非 JDK 模块的requires语句进行更改被认为是 API 中的一项更改——它要求您为不同版本的 JDK 拥有不同的用户定义模块。这就是不允许这样做的原因。

模块化 MRJAR 不需要在根目录中有模块描述符。这就是我们在前一节的例子中所拥有的。我们在根目录中没有模块描述符,但是在META-INF/versions/17目录中有一个。这种安排使得在一个 MRJAR 中包含 JDK8 的非模块化代码和 JDK17 的模块化代码成为可能。

如果在版本化目录中添加一个新的公共类型,而该目录不在根目录中,那么在创建 MRJAR 时会收到一个错误。假设,在我们的例子中,您为 JDK17 添加了一个名为Test的公共类。如果Test类在com.jdojo.mrjar包中,它将被模块导出,并可用于 MRJAR 之外的代码。注意,根目录不包含Test类,所以这个 MRJAR 为 JDK8 和 JDK17 提供了不同的公共 API。在这种情况下,在 JDK17 的com.jdojo.mrjar包中添加一个公共的Test类会在创建 MRJAR 时产生一个错误。

继续同一个例子,假设您将Test类添加到 JDK17 的com.jdojo.test包中。请注意,该模块不导出此包。当您在模块路径上使用这个 MRJAR 时,外部代码将无法访问Test类。在这个意义上,这个 MRJAR 为 JDK8 和 JDK17 提供了相同的公共 API。然而,有一个条件!您还可以将这个 MRJAR 放在 JDK17 中的类路径上,在这种情况下,外部代码可以访问Test类——这违反了模块化封装,也违反了 MRJAR 应该跨 JDK 版本提供相同公共 API 的规则。因此,向 MRJAR 中模块的非导出包添加公共类型也是不允许的。如果您尝试这样做,将会收到类似以下内容的错误消息:

entry: META-INF/versions/17/com/jdojo/test/Test.class,
    contains a new public class not found
    in base entries
invalid multi-release jar file mrjars\jdojo.mrjar.jar
    deleted

有时,有必要为同一个库添加更多类型来支持 JDK 的新版本。必须添加这些类型来支持更新的实现。您可以通过向 MRJAR 中的版本化目录添加包私有类型来实现这一点。在本例中,如果将类设为非公共的,可以为 JDK17 添加Test类。

引导加载程序不支持多版本 jar,例如,使用-Xbootclasspath/a选项指定 MRJARs。对于一个很少需要的特性,支持这一点会使引导加载程序的实现变得复杂。

MRJAR 应该在一个版本化的目录中包含同一个文件的不同版本。如果一个资源或类文件在不同的平台版本中是相同的,那么这样的文件应该被添加到根目录中。目前,jar工具会发出警告,如果它在多个版本目录中看到相同的条目,并且内容相同。

多版本 JAR 和 JAR URL

在 MRJARs 之前,JAR 中的所有资源都位于根目录下。当您从类加载器(ClassLoader.getResource( "com/jdojo/mrjar/TimeUtil.class" ))请求资源时,返回的 URL 如下所示:

jar:file:/C:/jdk17book/mrjars/jdojo.mrjar.jar!
com/jdojo/mrjar/TimeUtil.class

使用 MRJARs,可以从根目录或版本化目录返回资源。如果您正在 JDK17 上查找TimeUtil.class文件,URL 如下:

jar:file:/C:/jdk17book/mrjars/jdojo.mrjar.jar!
/META-INF/versions/17/com/jdojo/mrjar/TimeUtil.class

如果您现有的代码需要特定格式的资源的jar URL,或者您手工编码了一个jar URL,那么您可能会得到令人惊讶的结果。如果您用 MRJARs 重新打包您的 JARs,您需要再次查看您的代码,并修改它以使用 MRJARs。

多版本清单属性

MRJAR 在其MANIFEST.MF文件中包含一个特殊的属性条目:

Multi-Release: true

Multi-Release属性是由 MRJAR 的jar工具添加的。如果这个属性的值是true,这意味着这个 JAR 是一个多版本 JAR。如果它的值是false或者属性缺失,那么它就不是一个多释放的 JAR。该属性被添加到清单文件的主节中。

名为MULTI_RELEASE的常量被添加到Attributes.Name类中,该类位于java.util.jar包中,用来表示清单文件中的属性Multi-Release。因此,Attributes.Name.MULTI_RELEASE常量代表 Java 代码中Multi-Release属性的值。

JMOD 格式

Java 提供了另一种叫做 JMOD 的格式来打包模块。JMOD 文件比 JAR 文件能处理更多的内容类型。JMOD 文件可以打包本机代码、配置文件、本机命令和其他类型的数据。JMOD 格式基于 ZIP 格式,所以您可以使用标准的 ZIP 工具来查看它们的内容。JDK 模块以 JMOD 格式打包,供您在编译时和链接时使用。运行时不支持 JMOD 格式。您可以在JDK_HOME\jmods目录中找到 JMOD 格式的 JDK 模块,其中JDK_HOME是您安装 JDK 的目录。您可以将自己的模块打包成 JMOD 格式。JMOD 格式的文件有一个.jmod扩展名。例如,名为java.base的平台模块已经打包在java.base.jmod文件中。

JMOD 文件可以包含本地代码,在运行时动态提取和链接这些代码有点棘手。这就是 JMOD 文件在编译时和链接时受支持,但在运行时不受支持的原因。

使用 jmod 工具

虽然您可以使用 ZIP 工具来处理 JMOD 文件,但是 JDK 附带了一个特别定制的工具,名为jmod。它位于JDK_HOME\bin目录中。它可以用来创建 JMOD 文件,列出 JMOD 文件的内容,打印模块的描述,以及记录所使用的模块的散列。使用jmod工具的一般语法如下:

jmod <subcommand> <options> <jmod-file>

必须将下列子命令之一与jmod命令一起使用:

  • create

  • extract

  • list

  • describe

  • hash

listdescribe子命令不接受任何选项。<jmod-file>是您正在创建的 JMOD 文件或者您想要描述的现有 JMOD 文件。表 12-1 包含工具支持的选项列表。

表 12-1

jmod 工具的选项列表

|

[计]选项

|

描述

|
| --- | --- |
| –class-path <path> | 指定可以找到要打包的类的类路径。<path>可以是包含应用程序类的 JAR 文件或目录的路径列表。<path>处的内容将被复制到 JMOD 文件中。 |
| –cmds <path> | 指定包含本机命令的目录列表,这些命令需要复制到 JMOD 文件中。 |
| –config <path> | 指定包含要复制到 JMOD 文件的用户可编辑配置文件的目录列表。 |
| –dir <path> | 指定将提取指定 JMOD 文件内容的目标目录。 |
| –do-not-resolve-by-default | 如果使用此选项创建 JMOD 文件,JMOD 文件中包含的模块将从默认的根模块集中排除。要解析这样一个模块,您必须使用–add-modules命令行选项将它添加到默认的根模块集中。 |
| –dry-run | 模拟运行模块散列。使用此选项可以计算和打印散列,但不会将它们记录在 JMOD 文件中。 |
| –exclude <pattern-list> | 排除与提供的逗号分隔模式列表匹配的文件,每个元素使用以下形式之一:<glob-pattern>glob:<glob-pattern>regex:<regex-pattern>。 |
| –hash-modules <regex-pattern> | 计算并记录散列,将打包的模块与匹配给定的<regex-pattern>并直接或间接依赖于它的模块联系起来。散列记录在正在创建的 JMOD 文件中,或者用jmod hash命令指定的模块路径上的 JMOD 文件或模块化 JAR 中。 |
| –help, -h | 打印jmod命令的用法说明和所有选项列表。 |
| –header-files <path> | 将路径列表指定为<path>,本地代码的头文件将被复制到 JMOD 文件中。 |
| –help-extra | 打印关于jmod工具支持的附加选项的帮助。 |
| –legal-notices <path> | 指定要复制到 JMOD 文件的法律声明的位置。 |
| –libs <path> | 指定包含要复制到 JMOD 文件的本地库的目录列表。 |
| –main-class <class-name> | 指定用于运行应用程序的主类名。 |
| –man-pages <path> | 指定手册页的位置。 |
| –module-version <version> | 指定要记录在module-info.class文件中的模块版本。 |
| –module-path <path>, -p<path> | 指定用于查找哈希模块的模块路径。 |
| –target-platform<platform> | <platform><os>-<arch>的形式指定,例如windows-amd64linux-amd64。该选项指定目标操作系统和架构,记录在module-info.class文件的ModuleTarget属性中。 |
| –version | 打印jmod工具的版本。 |
| –warn-if-resolved <reason> | 指定一个提示给jmod工具,如果一个模块被解析,它将发出一个警告,这个模块已经被弃用,被弃用删除,或者正在酝酿。<reason>的值可以是以下三个值之一:deprecateddeprecated-for-removalincubating。 |
| @<filename> | 从指定文件中读取选项。 |

以下章节详细解释了如何使用jmod命令。本章中使用的所有命令都应输入到一行中。有时,为了清晰起见,我在书中用多行展示它们。

您可以使用带有jmod工具的create子命令创建一个 JMOD 文件。JMOD 文件的内容是模块的内容。假设存在以下目录和文件:

C:\jdk17book\jmods
C:\jdk17book\dist\jdojo.javafx.jar

下面的命令在C:\jdk17book\jmods目录中创建一个jdojo.javafx.jmod文件。JMOD 文件的内容来自jdojo.javafx.jar文件:

C:\jdk17book>jmod create ^
    --class-path dist\jdojo.javafx.jar ^
    jmods\jdojo.javafx.jmod

通常,JMOD 文件的内容来自一组包含模块编译代码的目录。以下命令创建一个jdojo.javafx.jmod文件。它的内容来自一个build\modules\jdojo.javafx目录。该命令使用–module-version选项来设置模块版本,该版本将被记录在build\modules\jdojo.javafx目录下的module-info.class文件中。在运行以下命令之前,请确保删除在上一步中创建的 JMOD 文件:

C:\jdk17book>jmod create --module-version 1.0 ^
    --class-path build\modules\jdojo.javafx ^
    jmods\jdojo.javafx.jmod

您可以用这个 JMOD 文件做什么?您可以将它放在模块路径上,以便在编译时使用。您可以使用它和jlink工具来创建一个定制的运行时映像,您可以用它来运行您的应用程序。回想一下,您不能在运行时使用 JMOD 文件。如果您试图通过将 JMOD 文件放在模块路径上来在运行时使用它,您将收到以下错误:

Error occurred during initialization of VM
java.lang.module.ResolutionException:
    JMOD files not supported: jmods\jdojo.javafx.jmod
...
Extracting JMOD File Contents

您可以使用extract子命令提取 JMOD 文件的内容。以下命令将jmods\jdojo.javafx.jmod文件的内容提取到名为extracted的目录中:

C:\jdk17book>jmod extract --dir extracted ^
    jmods\jdojo.javafx.jmod

如果没有–dir选项,JMOD 文件的内容将被提取到当前目录中。

您可以使用带有jmod工具的list子命令来打印 JMOD 文件中所有条目的名称。以下命令列出了您在上一节中创建的jdojo.javafx.jmod文件的内容:

C:\jdk17book>jmod list jmods\jdojo.javafx.jmod

classes/module-info.class
classes/com/jdojo/javafx/BindingTest.class
...
classes/resources/fxml/sayhello.fxml

下面的命令列出了java.base模块的内容,该模块作为一个名为java.base.jmod的 JMOD 文件提供。该命令假设您已经在C:\java17目录中安装了 JDK。输出超过 120 页。显示了部分输出。注意,JMOD 文件在内部将不同类型的内容存储在不同的目录中。

C:\jdk17book>jmod list C:\java17\jmods\java.base.jmod

classes/module-info.class
classes/java/nio/file/WatchEvent.class
classes/java/nio/file/WatchKey.class
bin/java.exe
bin/javaw.exe
native/amd64/jvm.cfg
native/java.dll
conf/net.properties
conf/security/java.policy
conf/security/java.security
...

您可以使用带有jmod工具的describe子命令来描述 JMOD 文件中包含的模块。以下命令描述了包含在jdojo.javafx.jmod文件中的模块:

C:\jdk17book>jmod describe jmods\jdojo.javafx.jmod

jdojo.javafx@1.0
exports com.jdojo.javafx
requires java.base mandated
requires javafx.controls
requires javafx.fxml
contains resources.fxml

您可以使用这个命令描述平台模块。以下命令描述了包含在java.sql.jmod中的模块,假设您在C:\java17目录中安装了 JDK:

C:\jdk17book>jmod describe C:\java17\jmods\java.sql.jmod

java.sql@9.0.1
exports java.sql
exports javax.sql
exports javax.transaction.xa
requires java.base mandated
requires java.logging transitive
requires java.xml transitive
uses java.sql.Driver
platform windows-amd64

您可以使用带有jmod工具的hash子命令来记录 JMOD 文件中包含的模块的module-info.class文件中其他模块的散列。这些散列将在以后用于依赖性验证。假设您在四个 JMOD 文件中有四个模块:

  • jdojo.prime

  • jdojo.prime.faster

  • jdojo.prime.probable

  • jdojo.prime.client

假设您想将这些模块交付给您的客户机,并确保模块代码保持不变。您可以通过在jdojo.prime模块中记录jdojo.prime.fasterjdojo.prime.probablejdojo.prime.client模块的散列来实现这一点。让我们看看如何实现这一点。

为了计算其他模块的哈希值,jmod工具需要找到这些模块。您需要使用–module-path选项来指定其他模块所在的模块路径。您还需要使用–hash-modules选项来指定需要记录其散列的模块所使用的模式列表。

注意当你把一个模块打包成一个模块 JAR 时,你也可以使用–hash-modules–module-path选项和jar工具来记录依赖模块的散列。

使用以下四个命令为四个模块创建 JMOD 文件。注意,我在创建com.jdojo.prime.client.jmod文件时使用了–main-class选项。我在第十三章中讨论jlink工具时再次用到它。如果在运行这些命令时出现“文件已经存在”错误,请从jmods目录中删除现有的 JMOD 文件,然后重新运行该命令:

C:\jdk17book>jmod create --module-version 1.0 ^
    --class-path build\modules\jdojo.prime ^
    jmods\jdojo.prime.jmod
C:\jdk17book>jmod create --module-version 1.0 ^
    --class-path build\modules\jdojo.prime.faster ^
    jmods\jdojo.prime.faster.jmod
C:\jdk17book>jmod create --module-version 1.0 ^
    --class-path build\modules\jdojo.prime.probable ^
    jmods\jdojo.prime.probable.jmod
C:\jdk17book>jmod create --module-version 1.0 ^
    --class-path build\modules\jdojo.prime.client ^
    jmods\jdojo.prime.client.jmod

现在,您已经准备好使用下面的命令来记录名称以jdojo.prime开头的所有模块的散列了:

C:\jdk17book>jmod hash ^
    --module-path jmods ^
    --hash-modules jdojo.prime.? jmods\jdojo.prime.jmod

Hashes are recorded in module jdojo.prime

让我们看看记录在com.jdojo.prime模块中的散列。以下命令打印模块描述以及记录在com.jdojo.prime模块中的散列:

C:\jdk17book>jmod describe jmods\jdojo.prime.jmod

jdojo.prime@1.0
exports com.jdojo.prime
requires java.base mandated
uses com.jdojo.prime.PrimeChecker
provides com.jdojo.prime.PrimeChecker with
    com.jdojo.prime.impl.genericprimechecker
contains com.jdojo.prime.impl
hashes jdojo.prime.client SHA-256
5950...6ce95e9849f520f4b9f54bc520d7969c396dc4f93805121b
hashes jdojo.prime.faster SHA-256
5538...4e264cfa12848be32d3f0b9a5df506aa57ba4443dfcbdc6a
hashes jdojo.prime.probable SHA-256
a1b8...5d62313de97ee285ed845895c8ef3c52b53a16370dd3b2d5

当您使用create子命令创建一个新的 JMOD 文件时,您也可以记录其他模块的散列。假设三个模块jdojo.prime.fasterjdojo.prime.probablejdojo.prime.client存在于模块路径上,您可以使用下面的命令来创建jdojo.prime.jmod文件,该文件也将记录这三个模块的散列:

C:\jdk17book>jmod create --module-version 1.0 ^
    --module-path jmods ^
    --hash-modules jdojo.prime.? ^
    --class-path build\modules\jdojo.prime ^
    jmods\jdojo.prime.jmod

您可以为 JMOD 文件模拟运行散列过程,其中散列将被打印,但不会被记录。在不创建 JMOD 文件的情况下,模拟运行选项对于确保所有设置都是正确的非常有用。以下命令序列将引导您完成该过程。首先,删除您在上一步中创建的jmods\jdojo.prime.jmod文件。

以下命令创建了jmods\jdojo.prime.jmod文件,但没有记录任何其他模块的哈希:

C:\jdk17book>jmod create --module-version 1.0 ^
    --module-path jmods ^
    --class-path build\modules\jdojo.prime ^
    jmods\jdojo.prime.jmod

以下命令模拟运行hash子命令。它计算并打印其他模块的散列,匹配在–hash-modules选项中指定的正则表达式。没有散列将被记录在jmods\jdojo.javafx.jmod文件中:

C:\jdk17book>jmod hash --dry-run ^
    --module-path jmods ^
    --hash-modules jdojo.prime.? ^
    jmods\jdojo.prime.jmod

Dry run:
jdojo.prime
  hashes jdojo.prime.client SHA-256
5950...6ce95e9849f520f4b9f54bc520d7969c396dc4f93805121b
  hashes jdojo.prime.faster SHA-256
5538...4e264cfa12848be32d3f0b9a5df506aa57ba4443dfcbdc6a
  hashes jdojo.prime.probable SHA-256
a1b8...5d62313de97ee285ed845895c8ef3c52b53a16370dd3b2d5

以下命令验证前面的命令没有在 JMOD 文件中记录任何哈希:

C:\jdk17book>jmod describe jmods\jdojo.prime.jmod

jdojo.prime@1.0
exports com.jdojo.prime
requires java.base mandated
uses com.jdojo.prime.PrimeChecker
provides com.jdojo.prime.PrimeChecker with
    com.jdojo.prime.impl.genericprimechecker
contains com.jdojo.prime.impl

当您使用jlink工具创建自定义运行时映像时,您将在第十三章中再次看到 JMOD 文件的运行。

摘要

Java 支持四种格式来打包模块:展开的目录、JAR 文件、JMOD 文件和 JIMAGE 文件。JAR 格式在 JDK9 中得到增强,以支持模块化 JAR 和多版本 JAR。多版本 JAR 允许您针对不同版本的 JDK 打包相同版本的库或应用程序。例如,一个多版本 JAR 可能包含库版本 1.2 的代码,该版本包含 JDK8 和 JDK17 的代码。当在 JDK8 上使用多版本 JAR 时,将使用库代码的 JDK8 版本。在 JDK17 上使用时,将使用 JDK17 版本的库代码。特定于 JDK 版本N的文件存储在多版本 JAR 的META-INF\versions\N目录中。所有 JDK 版本通用的文件存储在根目录中。对于不支持多版本 jar 的环境,此类 jar 被视为常规 jar。在多版本 JAR 中,文件的搜索顺序是不同的——在搜索根目录之前,先搜索以当前平台的主要版本开始的所有版本化目录。

JMOD 文件比 JAR 文件能处理更多的内容类型。它们可以打包本机代码、配置文件、本机命令和其他类型的数据。JDK 模块以 JMOD 格式打包,供您在编译时和链接时使用。运行时不支持 JMOD 格式。您可以使用jmod工具来处理 JMOD 文件。

练习

练习 1

你可以用什么格式来打包你的模块?

练习 2

什么是多释放罐?

运动 3

描述多重释放震击器的结构。

演习 4

当在不理解多发布 JAR 的 JDK 版本(例如 JDK8)上使用多发布 JAR 时会发生什么?

锻炼 5

描述在多版本 JAR 中查找资源时的搜索顺序。

锻炼 6

描述多重释放震击器的局限性。

锻炼 7

多版本 JAR 的META-INF\MANIFEST.MF文件中的属性名是什么?

运动 8

什么是jmod工具,它位于哪里?

演习 9

什么是 JMOD 格式,它比 JAR 格式好在哪里?

运动 10

Java 支持三个阶段:编译时、链接时和运行时。在哪些阶段支持 JMOD 格式?

演习 11

假设您有一个名为jdojo.test.jmod的 JMOD 文件。使用jmod工具编写命令来描述这个 JMOD 文件中存储的模块。

运动 12

JMOD 格式的 JDK 模块在哪里?

十三、自定义运行时映像

在本章中,您将学习:

  • 什么是自定义运行时映像和 JIMAGE 格式

  • 如何使用jlink工具创建自定义运行时映像

  • 如何指定命令名来运行存储在自定义映像中的应用程序

  • 如何通过jlink工具使用插件

什么是自定义运行时映像?

在 JDK9 之前,Java 运行时映像是一个巨大的整体构件——因此增加了下载时间、启动时间和内存占用。单片 JRE 使得在内存很少的设备上使用 Java 成为不可能。如果您将 Java 应用程序部署到云中,您需要为使用的内存付费;最常见的情况是,单片 JRE 会使用比所需更多的内存,从而让您为云服务支付更多的费用。有了 Java,现在可以通过允许您将 JRE 的一个子集打包到一个定制的运行时映像(称为 compact profile )中来减少 JRE 的大小,从而减少运行时内存的占用。

JDK 本身是模块化的,但是您也可以将您的应用程序代码打包成模块,并将所需的 JDK 模块和应用程序模块合并在一起。实现这一点的方法是创建一个自定义运行时,它将包含您的应用程序模块,并且只包含您的应用程序使用的那些 JDK 模块。您还可以在运行时映像中打包本机命令。创建运行时映像的另一个好处是,您只需向应用程序用户提供一个包——运行时映像。他们不再需要下载和安装单独的 JRE 包来运行您的应用程序。

运行时映像以一种称为 JIMAGE 的特殊格式存储,这种格式针对空间和速度进行了优化。只有在运行时才支持 JIMAGE 格式。它是一种容器格式,用于存储和索引 JDK 中的模块、类和资源。从 JIMAGE 文件中搜索和加载类要比从 JAR 和 JMOD 文件中快得多。JIMAGE 格式是 JDK 内部的,开发人员很少需要直接与 JIMAGE 文件交互。

JIMAGE 格式预计会随着时间的推移而显著发展,因此,它的内部结构不会向开发人员公开。JDK 附带了一个名为jimage的工具,可以用来浏览 JIMAGE 文件。我将在本章的单独一节中详细解释这个工具。

Note

您可以使用 jlink 工具创建一个定制的运行时映像,该映像使用一种称为 JIMAGE 的新文件格式来存储模块。Java 附带了 jimage 工具,可以让您探索 JIMAGE 文件的内容。

创建自定义运行时映像

您可以使用jlink工具创建一个定制的特定于平台的运行时映像。运行时映像将包含指定的应用程序模块及其依赖关系,并且只包含所需的平台模块,从而减小运行时映像的大小。这对于运行在具有少量内存的嵌入式设备上的应用程序非常有用。jlink刀具位于JDK_HOME\bin目录中。运行jlink工具的一般语法如下:

jlink <options> --module-path <modulepath> ^
    --add-modules <mods> --output <path>

这里的<options>包括零个或多个jlink选项,如表 13-1 所列。<modulepath>是平台和应用模块所在的模块路径。模块可以位于模块化 jar、展开的目录和 JMOD 文件中。<mods>是要添加到映像的模块列表,由于对其他模块的传递依赖性,这可能会导致添加其他模块。<path>是存储生成的运行时映像的输出目录。

表 13-1

jlink 工具的选项列表

|

[计]选项

|

描述

|
| --- | --- |
| –add-modules <mod>,<mod>... | 指定要解析的根模块列表。所有已解析的模块都将添加到运行时映像中。 |
| –bind-services | 在链接过程中执行完整的服务绑定。如果添加的模块包含uses语句,jlink将扫描模块路径上的所有模块,以将运行时映像中的所有服务提供者模块包含在uses语句中指定的服务中。 |
| -c, –compress | 指定输出<0&#124;1&#124;2>[:filter=<pattern-list>]图像中所有资源的压缩级别。0表示常量字符串共享,1表示 ZIP,2表示两者都有。可以指定一个可选的<pattern-list>过滤器来列出要包含的文件模式。 |
| –disable-plugin <plugin-name> | 禁用指定的插件。 |
| –endian <little&#124;big> | 指定生成的运行时映像的字节顺序。默认值是本机平台的字节顺序。 |
| -h, –help | 打印jlink工具的用法说明和所有选项列表。 |
| –ignore-signing-information | 当签名的模块化 jar 在映像中链接时,禁止出现致命错误。已签名的模块化 jar 的相关文件的签名不会被复制到运行时映像。 |
| –launcher <command>=<module> | 指定模块的启动器命令。<command>是您希望生成的用于启动应用程序的命令的名称,例如runmyapp。该工具将创建一个名为<command>的脚本/批处理文件来运行<module>中记录的主类。 |
| –launcher <command>= <module>/<mainclass> | 指定模块和主类的启动器命令。<command>是您希望生成的用于启动应用程序的命令的名称,例如runmyapp。该工具将创建一个名为<command>的脚本/批处理文件来运行<module>中的<main-class>。 |
| –limit-modules <mod>,<mod> | 将可观察的模块限制在命名模块的传递闭包中,加上主模块(如果指定的话),以及用–add-modules选项指定的任何其他模块。 |
| –list-plugins | 列出可用的插件。 |
| -p, –module-path <modulepath> | 指定将平台和应用程序模块添加到运行时映像的模块路径。 |
| –no-header-files | 排除本机代码的包含头文件。 |
| –no-man-pages | 不包括手册页。 |
| –output <path> | 指定生成的运行时映像的位置。 |
| –save-opts <filename> | 将jlink选项保存在指定文件中。 |
| -G, –strip-debug | 从输出图像中去除调试信息。 |
| –suggest-providers [<service-name>,...] | 如果没有指定服务名,它会建议为添加的模块链接的所有服务的提供者的名称。如果指定一个或多个服务名,它会建议指定服务名的提供者。在创建映像之前可以使用此选项,以了解使用–bind-services选项时将包括哪些服务。 |
| -v, –verbose | 打印详细输出。 |
| –version | 打印jlink工具的版本。 |
| @<filename> | 从指定文件中读取选项。 |

让我们创建一个运行时映像,它包含 prime checker 应用程序的四个模块和所需的平台模块,其中只包含java.base模块。prime checker 应用程序是在第七章中创建的,我在其中解释了如何实现服务。我在本书的源代码中包含了 prime checker 应用程序的源代码。模块有jdojo.primejdojo.prime.fasterjdojo.prime.probablejdojo.prime.client。您可以选择任何其他模块来创建自定义运行时映像。

请注意,以下命令只包括主要检查器应用程序中的三个模块。将添加第四个模块,即jdojo.prime模块,因为这三个模块依赖于jdojo.prime模块。该命令假设您已经以 JMOD 格式打包了所有四个模块,并将它们存储在jmods目录中。JMOD 格式的打包模块在第十二章中介绍。命令后面的文本包含解释。

C:\jdk17book>jlink ^
    --module-path jmods;C:\java17\jmods ^
    --add-modules ^
    jdojo.prime.client,jdojo.prime.faster,
    jdojo.prime.probable ^
    --launcher runprimechecker=
    jdojo.prime.client/com.jdojo.prime.client.Main ^
    --output image\primechecker

(在“,”和“=”后没有换行符和空格。)

在我解释这个命令的所有选项之前,让我们验证一下运行时映像是否创建成功。该命令应该将运行时映像复制到C:\jdk17book\image\primechecker目录。运行以下命令,验证运行时映像是否包含这五个模块:

C:\jdk17book>image\primechecker\bin\java ^
    --list-modules

java.base@9
jdojo.prime@1.0
jdojo.prime.client@1.0
jdojo.prime.faster@1.0
jdojo.prime.probable@1.0

如果您得到与此处所示类似的输出,则运行时映像创建正确。显示在输出中的@符号之后的模块版本号可能会因您而异。

–module-path选项指定了两个目录:jmodsC:\java17\jmods。我将 prime checker 应用程序的四个 JMOD 文件保存在C:\jdk17book\jmods目录中。模块路径中的第一个元素让jlink工具找到所有的应用模块。我将 JDK17 安装在C:\java17目录中,所以模块路径中的第二个元素让工具找到平台模块。如果不指定第二部分,将出现错误:

Error: Module java.base not found,
    required by jdojo.prime.probable

–add-modules选项指定了 prime checker 应用程序的三个模块。您可能想知道为什么我们没有用这个选项指定名为jdojo.prime的第四个模块。这个列表包含根模块,而不仅仅是要包含在运行时映像中的模块。jlink工具将为这些根模块解析所有的依赖关系,并将所有解析的依赖模块包含到运行时映像中。这三个模块依赖于jdojo.prime模块,它将通过在模块路径上定位来解析,因此将包含在运行时映像中。该映像还将包含java.base模块,因为所有应用程序模块都隐式依赖于它。

–output选项指定运行时映像将被复制到的目录。该命令会将运行时映像复制到C:\jdk17book\image\primechecker目录。输出目录包含子目录和一个名为release的文件。release文件包含 JDK 版本和链接到该映像的所有 JDK 和用户模块的列表。表 13-2 包含了每个目录的内容描述。

表 13-2

输出目录中的子目录

|

目录

|

描述

|
| --- | --- |
| bin | 包含可执行文件。在 Windows 上,它还包含动态链接的本地库(.dll文件)。 |
| conf | 包含可编辑的配置文件,如.properties.policy文件。 |
| include | 包含 C/C++头文件。 |
| legal | 包含法律声明。 |
| lib | 包含添加到运行时映像的模块以及其他文件。在 Mac、Linux 和 Solaris 上,它还将包含系统的动态链接库。 |

您在jlink命令中使用了–launcher选项。您指定了runprimechecker作为命令名,jdojo.prime.client作为模块名,com.jdojo.prime.client.Main作为模块中的主类名。–launcher选项使jlink创建一个特定于平台的可执行文件,比如在 Windows 的bin目录中的runprimechecker.bat文件。您可以使用这个可执行文件来运行您的应用程序。文件内容只是运行该模块中主类的包装。您可以使用该文件来运行应用程序:

C:\jdk17book>image\primechecker\bin\
    runprimechecker

Using default service provider:
3 is a prime.
4 is not a prime.
121 is not a prime.
977 is a prime.
Using faster service provider:
3 is a prime.
4 is not a prime.
121 is not a prime.
977 is a prime.
Using probable service provider:
3 is a prime.
4 is not a prime.
121 is not a prime.
977 is a prime.

(“bin”后没有换行符和空格。)

您还可以使用java命令来启动您的应用程序,该命令由jlink工具复制到bin目录中:

C:\jdk17book>image\primechecker\bin\java ^
    --module jdojo.prime.client/com.jdojo.prime.client.Main

这个命令的输出将与前一个命令的输出相同。请注意,您不必指定模块路径。创建运行时映像时,链接器,jlink工具负责管理模块路径。当您运行生成的运行时映像的java命令时,它知道在哪里可以找到模块。

绑定服务

在上一节中,您为主要服务客户端应用程序创建了一个运行时映像。您必须使用–add-modules选项指定您想要包含在映像中的所有服务提供者模块的名称。在这一节中,我将向您展示如何使用–bind-services选项和jlink工具自动绑定服务。这一次,您需要将模块,即jdojo.prime.client模块添加到模块图中。jlink工具会处理剩下的事情。jdojo.prime.client模块读取jdojo.prime模块,因此将前者添加到模块图中也会解析后者。以下命令打印运行时映像的建议服务提供者列表。显示了部分输出:

C:\jdk17book>jlink ^
    --module-path jmods;C:\java17\jmods ^
    --add-modules jdojo.prime.client --suggest-providers

...
jdojo.prime file:///C:/jdk17book/jmods/
        jdojo.prime.jmod
    uses com.jdojo.prime.PrimeChecker
jdojo.prime.client file:///C:/jdk17book/
    jmods/jdojo.prime.client.jmod
jdojo.prime.faster file:///C:/jdk17book/
    jmods/jdojo.prime.faster.jmod
jdojo.prime.probable file:///C:/jdk17book/
    jmods/jdojo.prime.probable.jmod
...
Suggested providers:
  jdojo.prime provides com.jdojo.prime.
      PrimeChecker used by jdojo.prime
  jdojo.prime.faster provides com.jdojo.prime.
      PrimeChecker used by jdojo.prime
  jdojo.prime.probable provides com.jdojo.prime.
      PrimeChecker used by jdojo.prime
...

该命令只将jdojo.prime.client模块指定给–add-modules选项。jdojo.primejava.base模块被解析,因为jdojo.prime.client模块读取它们。针对uses语句扫描所有已解析的模块,随后,针对uses语句中指定的服务扫描模块路径中的所有模块的服务提供者。将打印找到的所有服务提供商。

Note

您可以为–suggest-providers选项指定参数。如果使用不带参数的命令,请确保在命令末尾指定它。否则,–suggest-providers选项之后的选项将被解释为它的参数,您将收到一个错误。

以下命令将com.jdojo.prime.PrimeChecker指定为–suggest-providers选项的服务名,以打印为此服务找到的所有服务提供者:

C:\jdk17book>jlink ^
    --module-path jmods;C:\java17\jmods ^
    --add-modules jdojo.prime.client ^
    --suggest-providers ^
    com.jdojo.prime.PrimeChecker
Suggested providers:
  jdojo.prime provides com.jdojo.prime.
      PrimeChecker used by jdojo.prime
  jdojo.prime.faster provides com.jdojo.prime.
      PrimeChecker used by jdojo.prime
  jdojo.prime.probable provides com.jdojo.prime.
      PrimeChecker used by jdojo.prime

使用与前面描述的相同的逻辑,找到了所有三个服务提供商。让我们创建一个包含所有三个服务提供者的新运行时映像。以下命令可以完成这项工作:

C:\jdk17book>jlink ^
    --module-path jmods;C:\java17\jmods ^
    --add-modules jdojo.prime.client ^
    --launcher runprimechecker=
        jdojo.prime.client/com.jdojo.prime.client.Main ^
    --bind-services
    --output image\primecheckerservice

(在“=”后没有换行符和空格。)

将此命令与上一节中使用的命令进行比较。这一次,您用–add-modules选项只指定了一个模块。也就是说,您不必指定服务提供者模块的名称。您使用了–bind-services选项,所以添加的模块中的所有服务提供者引用都会自动添加到运行时映像中。您指定了一个名为image\T3 的新输出目录。以下命令运行新创建的运行时映像:

C:\jdk17book>image\primecheckerservice\bin\
    runprimechecker

Using default service provider:
3 is a prime.
4 is not a prime.
121 is not a prime.
977 is a prime.
Using faster service provider:
3 is a prime.
4 is not a prime.
121 is not a prime.
977 is a prime. 

Using probable service provider:
3 is a prime.
4 is not a prime.
121 is not a prime.
977 is a prime.

(“bin”后没有换行符和空格。)

输出证明了模块路径中的所有三个 prime checker 服务提供程序都被自动添加到了运行时映像中。

在前面的命令中使用–bind-services选项时,有一个问题。比较一下image\primecheckerimage\primecheckerservice目录的大小,分别是 173MB 和 36MB。您确实使用了一个较短的命令。然而,运行时映像的大小增加了 280。你不想这样。问题在于使用解决所有服务的–bind-services选项,包括java.base模块。除了在jdojo.prime模块中定义的com.jdojo.prime.PrimeChecker服务之外,您不想解析任何其他服务。您可以通过使用–limit-modules选项将可观察模块的范围限制为以下五个模块来实现这一点:

  • java.base

  • jdojo.prime

  • jdojo.prime.faster

  • jdojo.prime.probable

  • jdojo.prime.client

以下命令是前一个命令的修订版。该命令使用–limit-modules。请注意,您没有将jdojo.prime.client模块包含在–list-modules中,因为该模块已经包含在–add-modules中。将它包含在带有–list-modules的模块列表中不会有任何区别。这一次,您的运行时映像将像第一次一样是 36MB。

C:\jdk17book>jlink ^
    --module-path jmods;C:\java17\jmods ^
    --add-modules jdojo.prime.client ^
    --compress 2 ^
    --strip-debug ^
    --launcher runprimechecker=
      jdojo.prime.client/com.jdojo.prime.client.Main ^
    --bind-services ^
    --limit-modules java.base,jdojo.prime,
      jdojo.prime.faster,jdojo.prime.probable ^
--output image\image\primecheckercompactservice

(在“=”或“,”后没有换行符和空格。)

jlink工具使用插件架构来创建运行时映像。它将所有的类、本地库和配置文件收集到一组资源中。它构建了一个转换器管道,这些转换器是被指定为命令行选项的插件。资源被输入管道。管道中的每个转换器对资源进行某种转换,转换后的资源被提供给下一个转换器。最后,jlink将转换后的资源提供给图像生成器。

JDK 发布了带有一些插件的jlink工具。这些插件定义了命令行选项。要使用一个插件,你需要使用命令行选项。您可以运行带有–list-plugins选项的jlink工具来打印所有可用插件的列表及其描述和命令行选项:

C:\jdk17book>jlink --list-plugins

...

下面的命令使用了compressstrip-debug插件。compress插件将压缩图像,这将导致一个较小的图像尺寸。我使用压缩级别 2 来获得最大压缩。strip-debug插件将从 Java 代码中删除调试信息,从而进一步减小图像的大小。

C:\jdk17book>jlink ^
    --module-path jmods;C:\java17\jmods ^
    --add-modules jdojo.prime.client,
      jdojo.prime.faster,jdojo.prime.probable ^
    --compress 2 ^
    --strip-debug ^
    --launcher runprimechecker=
      jdojo.prime.client/com.jdojo.prime.client.Main ^
    --output image\primecheckercompact

(在“=”或“,”后没有换行符和空格。)

输出被复制到image\primecheckercompact目录。新映像的大小是 33MB,而在image\primechecker目录中创建的映像的大小是 36MB。由于你使用了两个插件,这是大约 39%更紧凑的图像。

jimage 工具

Java 运行时在一个 JIMAGE 文件中提供了 JDK 运行时映像。文件名为modules,位于JAVA_HOME\lib,这里的JAVA_HOME可以是你的JDK_HOMEJRE_HOME。JDK9 还附带了一个jimage工具,用于探索 JIMAGE 文件的内容。该工具可以

  • 从 JIMAGE 文件中提取条目

  • 打印存储在 JIMAGE 文件中的内容摘要

  • 打印条目列表,如名称、大小、偏移量等。

  • 验证类文件

jimage刀具存放在JDK_HOME\bin目录中。该命令的一般格式如下:

jimage <subcommand> <options> <jimage-file-list>

这里,<subcommand>是表 13-3 中列出的子命令之一。<options>是表 13-4 中列出的一个或多个选项;<jimage-file-list>是要浏览的以空格分隔的 JIMAGE 文件列表。

表 13-4

与 jimage 工具一起使用的选项列表

|

[计]选项

|

描述

|
| --- | --- |
| –dir <dir-name> | 指定extract子命令的目标目录,JIMAGE 文件中的条目将被提取到该目录。 |
| -h, –help | 打印jimage工具的使用信息。 |
| –include <pattern-list> | 指定用于过滤条目的模式列表。模式列表的值是逗号分隔的元素列表,每个元素都使用以下形式之一:<glob-pattern>``glob:<glob-pattern>``regex:<regex-pattern> |
| –verbose | 与list子命令一起使用时,打印条目细节,如大小、偏移量和压缩级别。 |
| –version | 打印jimage工具的版本信息。 |

表 13-3

与 jimage 工具一起使用的子命令列表

|

子命令

|

描述

|
| --- | --- |
| Extract | 将指定的 JIMAGE 文件中的所有条目提取到当前目录。使用–dir选项为提取的条目指定另一个目录。 |
| Info | 打印指定图像文件头中包含的详细信息。 |
| List | 打印指定的 JIMAGE 文件中所有模块及其条目的列表。使用–verbose选项包含条目的详细信息,如大小、偏移量以及条目是否被压缩。 |
| verify | 打印指定 JIMAGE 文件中未验证为类的.class条目列表。 |

我展示了几个使用jimage命令的例子。示例使用存储在我的计算机上的C:\java17\lib\modules的 JDK 运行时映像。当您运行这些示例时,您需要用您的位置替换这个图像位置。在这些例子中,您还可以使用由jlink工具创建的任何自定义运行时映像。

下面的命令从运行时映像中提取所有条目,并将它们复制到extracted_jdk目录中。该命令需要几秒钟才能完成:

C:\jdk17book>jimage extract ^
    --dir extracted_jdk C:\java17\lib\modules

以下命令将 JDK 运行时映像中扩展名为.png的所有映像条目提取到extracted_images目录中:

C:\jdk17book>jimage extract ^
    --include regex:.+\.png ^
    --dir extracted_images ^
    C:\java17\lib\modules

以下命令列出运行时映像中的所有条目。显示了部分输出:

C:\jdk17book>jimage list C:\java17\lib\modules

jimage: C:\java17\lib\modules
Module: java.activation
    META-INF/mailcap.default
    META-INF/mimetypes.default
...
Module: java.annotations.common
    javax/annotation/Generated.class
...

以下命令列出了运行时映像中的所有条目以及条目的详细信息。注意–verbose选项的使用。显示了部分输出:

C:\jdk17book>jimage list ^
    --verbose ^
    C:\java17\lib\modules

jimage: C:\java17\lib\modules
Module: java.activation
Offset     Size   Compressed Entry
34214466    292            0 META-INF/mailcap.default
34214758    562            0 META-INF/mimetypes.default
...
Module: java.annotations.common
Offset     Size   Compressed Entry
34296622    678            0 javax/annotation/
                                            Generated.class
...

以下命令打印无效的类文件列表。您可能想知道如何使一个类文件无效。通常,您不会有无效的类文件——但是黑客会有!然而,要运行这个例子,我需要一个无效的类文件。我使用了一个简单的想法——获取一个有效的类文件,在文本编辑器中打开它,并随机删除它的部分内容,使它成为一个无效的类文件。我将一个已编译的类文件的内容复制到了Main2.class文件中,并删除了它的一些内容,使它成为一个无效的类。我将Main2.class文件添加到jdojo.prime.client模块中,与Main.class在同一个目录下。对于这个例子,我使用 prime checker 应用程序的前一个命令重新创建了运行时映像。如果使用 JDK 附带的 Java 运行时映像,您将看不到任何输出,因为 JDK 运行时映像中的所有类文件都是有效的。

C:\jdk17book>jimage verify ^
    image\primechecker\lib\modules

jimage: primechecker\lib\modules
Error(s) in Class: /jdojo.prime.client/com/jdojo/prime/
    client/Main2.class

摘要

在 Java 中,运行时映像以一种称为 JIMAGE 的特殊格式存储,这种格式针对空间和速度进行了优化。只有在运行时才支持 JIMAGE 格式。它是一种容器格式,用于存储和索引 JDK 中的模块、类和资源。从 JIMAGE 文件中搜索和加载类要比从 JAR 和 JMOD 文件中快得多。JIMAGE 格式是 JDK 内部的,开发人员很少需要直接与 JIMAGE 文件交互。

JDK 附带了一个名为jlink的工具,它允许您为您的应用程序创建一个 JIMAGE 格式的运行时映像,该映像将包含应用程序模块,并且只包含您的应用程序使用的那些平台模块。jlink工具可以从存储在模块 jar、展开的目录和 JMOD 文件中的模块创建运行时映像。JDK 附带了一个叫做jimage的工具,可以用来探索 JIMAGE 文件的内容。

练习

练习 1

什么是自定义 Java 运行时映像?

练习 2

什么是 JIMAGE 格式?

运动 3

什么是jlink工具?

演习 4

为什么要将–launcher选项与jlink工具一起使用?

锻炼 5

使用或不使用jlink工具的–bind-services选项有什么影响?

锻炼 6

jlink工具有哪些插件?

锻炼 7

如何列出jlink可用的插件?

运动 8

说出两个jlink插件。

演习 9

可以用jlink自定义插件吗?

运动 10

什么是jimage工具?描述jimage工具的以下四个子命令的使用:extractinfolistverify

十四、杂项

在本章中,您将学习:

  • 与前一版相比,章节的选择

  • JDK9 之后的各种增强

删除了以前版本中的章节

如果你将这本书与之前的版本进行比较,即 Java 语言特性,第二版,和Java API,扩展和库,第二版,你可能会错过一些没有进入新版本的章节。这样做的主要原因是我们不希望这本新书太大,它是上述书籍的合并。

问题当然是:你如何决定省略哪些章节?两个第二版书中的章节没有一个是真正过时的,或者是无趣的。决策点不容易找出来,但是作者决定考虑两个方面。首先,如果一个主题太属于开发人员的标准知识,并且关于它的信息可以很容易地在 Oracle 的文档(包括教程)中找到,那么第三版中有一章将被删除。第二,如果一个主题不会在开发人员的日常工作中经常出现,因此属于可能的主题的角落类型,那么新版本中的一章也会被删除。

具体来说,章节选择的基本原理是

  • 没有内部类:非常标准——读者可以很容易地在 Oracle 文档和网上其他地方找到细节。

  • 没有输入/输出,也不处理归档文件:对于服务器端开发来说,这并不重要。应用程序和信息的标准内容可以很容易地在 Oracle 文档和网络上的其他地方找到。

  • 没有垃圾收集,也没有关于堆栈审核的细节:很有趣,但是对于日常工作来说,这样的细节并不重要。介绍性的文章很容易在网上找到。

  • 不收集:重要,但相当标准的东西和许多介绍性文本的主题。

  • 没有模块 API:开发人员很少需要使用它。

  • 无反应流:虽然有点“in”,但它实际上是一个 Java 库主题,在 Java 标准中只有几个螺栓。如果你想了解它,最好参考 reactive streams 库项目。

  • 没有 JDBC:一个连贯的话题,在网上有很好的记录。企业项目开发人员几乎从不直接使用 JDBC。

  • 没有 Java 原生接口:在某种程度上违背了 Java 哲学(编写一次,在任何地方运行),因此这是一个死角。

  • 没有 Swing 就没有 JavaFX:前端开发是一个如此庞大的主题,最好在专业书籍中处理。

更多 JDK17 新奇事物

在这一节中,我将展示一些有用的或有趣的新奇事物,这些新奇事物是在本书之前版本的基础上进入 Java 世界的。或者,在版本号中,它描述了自 JDK9 以来发生的事情。这个列表并不详尽——一些涉及内部的或者在开发人员的日常工作中不太重要的变更被忽略了。

具有自动类型的局部变量

脚本语言,包括那些最终在 JVM 上运行的语言,通常允许简化的未定义的类型变量声明,以简化代码编写。例如,在 Groovy 中,这将是def,如

// This is Groovy
def a = "Hello"
def b = 3
def c = 5.9

在 JavaScript 中,您可以编写

// This is JavaScript
var a = "Hello";
var b = 3;
var c = 5.9;

Java 语言开发人员长期以来坚持为 Java 提供精确类型的变量声明。只有在后来的 Java 版本中,这一点有所放松,并且可以使用var作为局部变量的类型占位符。所以你可以写

// This is Java, inside a method
var a = "Hello";
var b = 3;
var c = 5.9;

必须初始化var变量,不能对类字段使用var,也不能切换值类型:

public class Car {
  private var a = "Hello";
  // <- Won't compile
}

...

var a;
a = 3;
// <- Won't compile

...

var b = 3;
b = 5.9;
// <-Won't compile

对于较长的类型,var局部变量语法非常方便,您也可以将它用于 lambda 参数:

var x = new ArrayList<String>();
   // <-x has type ArrayList

   Function<String,Integer> fsi = (var s) -> s.length();

Caution

不要过度使用var局部变量语法。毕竟,干净的代码编程状态也是关于表达性的,隐藏类型信息使得复杂的代码几乎不可读。

启动单文件源代码程序

对于非常短的程序,只包含一个带有static void main(String[] args)方法的类,您可以绕过编译步骤,直接编写

java HelloTest.java
  # or, if we need args
  java HelloTest.java arg1 arg2 ...

这将执行内存编译,然后运行main()方法。

增强的switch语句

古老可敬的switch声明

int x = ...;
  switch(x) {
    case 1:
    case 2:
      System.out.println("1 or 2");
      break;
    case 3:
      System.out.println("3");
      fbreak;
    default:
      System.out.println("default");
  }

缺少许多其他编程语言都包含的特性:在 Java 中,不能使用switch{ }作为表达式。此外,虽然在某些场景中很有用,但是如果不小心忘记了break,这种失败机制(前面例子中case 1:缺少的break)更容易导致错误。出于这个原因,Java 现在有了一个语法略有不同的新变体,用->代替了case子语句中的::

var a = 5;
switch(a) {
    case 4 -> System.out.println("4");
    default -> System.out.println("default: " + a);
}
var b = switch(a) {
    case 4 -> -1;
    default -> a;
};
var c = switch(a) {
    case 4 -> -1;
    default-> {
        var x9 = a*2;
        yield x9; // goes to c
    }
};

因此,如果没有break,就不再存在失败。事实上,break s 已经过时了,不能用了。使用这种语法,switch可以有一个值,从而被用作表达式。新的yield定义了switch的结果,以防你需要{ ... }模块进行更长时间的计算。

当然,如果不需要新的行为,您仍然可以使用旧的语法。

文本块

在 Java 中,多行字符串总是令人讨厌。大多数开发人员使用类似

  String s = "This is the first line\n" +
      "This is the second line\n" +
      "This is the third line";

输入多行字符串。一个新特性允许更简洁地输入多行字符串:

String s = """
      This is the first line
      This is the second line
      This is the third line
  """;

然而,还有一个问题。如果您将最后一个字符串写入控制台,您将看到如下内容

This is the first line
This is the second line
This is the third line

每行前面有六个空格。

当然,你可以这样写

String s = """
This is the first line
This is the second line
This is the third line
  """;

以避免不必要的压痕。然而,这个解决方案打破了你的源代码的缩进结构。作为补救措施,方法stripIndent()被添加到了String类中:

  String s = """
      This is the first line
      This is the second line
      This is the third line
  """.stripIndent();

输出:

This is the first line
This is the second line
This is the third line

如果结果字符串中不需要换行符,可以使用反斜杠字符对行尾进行转义:

  String s = """
      This is the first line \
      Still inside the same line \
      Still inside the same line
  """;

只要确保每个反斜杠是每个输入行的最后一个字符。

增强型instanceof运算符

通常的instanceof操作符有一个新的样板代码——避免变量,允许立即将有问题的对象赋给正确类型的变量。所以,与其写作

Object s = "Hello";
...
if(s instanceof String) {
    String str = (String) s;
    if(str.equalsIgnoreCase("hello")) {
        System.out.println("Hello String!");
    }
}

你可以更简洁地写

Object s = "Hello";
...
if(s instanceof String str) {
    // use local variable 'str', which
    // is of type String
}
...
if(s instanceof String str &&
        str.equalsIgnoreCase("hello")) {
    System.out.println("Hello String!");
}

值分类:记录

值对象是主要目的是保存一堆值的对象。在传统的 Java 中,您应该编写如下的类

package jdk17;

import java.time.LocalDate;
import java.util.Objects;

public class Person {
    private String firstName;
    private String lastName;
    private LocalDate birthDay;
    private String socialSecurityNumber;

    @Override
    public int hashCode() {
        return Objects.hash(birthDay, firstName, lastName,
            socialSecurityNumber);
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (getClass() != obj.getClass())
            return false;
        Person other = (Person) obj;
        return Objects.equals(birthDay, other.birthDay)
                && Objects.equals(firstName,
                       other.firstName)
                && Objects.equals(lastName, other.lastName)
                && Objects.equals(socialSecurityNumber,
                       other.socialSecurityNumber);
    }

    @Override
    public String toString() {
        return "Person [firstName=" + firstName +
            ", lastName=" + lastName +
            ", birthDay=" + birthDay +
            ", socialSecurityNumber=" +
                    socialSecurityNumber + "]";
    }

    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }

    public LocalDate getBirthDay() {
        return birthDay;
    }

    public void setBirthDay(LocalDate birthDay) {
        this.birthDay = birthDay;
    }

    public String getSocialSecurityNumber() {
        return socialSecurityNumber;
    }

    public void setSocialSecurityNumber(String
          socialSecurityNumber) {
        this.socialSecurityNumber =
            socialSecurityNumber;
    }
}

对于一个值的对象。这个类包含许多样板代码—实际上,这个类包含的所有信息都是由它的字段给出的:

private String firstName;
private String lastName;
private LocalDate birthDay;
private String socialSecurityNumber;

其他一切都是派生的(实际上,我让我的 Eclipse IDE 生成它)。

为了简化这些值对象的使用以及不变性的限制,在 Java 中可以使用记录:

// File Person.java
record Person(
    String firstName,
    String lastName,
    LocalDate birthDay,
    String socialSecurityNumber) {}

就是这样!其他所有东西,getters、equals()hashCode()toString()和一个构造函数,都是自动提供的。没有定义 Setters,因为记录是不可变的。

要使用这样的记录,你只需写

Person p1 = new Person(
    "John",
    "Smith",
    LocalDate.of(1997,Month.DECEMBER,30),
    "000-00-1234");
System.out.println("Name: " + p1.firstName + " " + p1.lastName);

注意,您只是使用点符号来访问成员;没有提供getXXX() getter 方法。

记录声明中的{ }块可用于在构造期间对参数施加约束。所以你可以写

// File Person.java
record Person(
    String firstName,
    String lastName,
    LocalDate birthDay,
    String socialSecurityNumber)
{
    public Person {
      if(lastName == null ||
            "".equals(lastName.trim()))
        throw new IllegalArgumentException(
          "lastName must not be empty");
    }
}

密封类

有时,您希望限制可以从给定基类继承的类的可能集合。添加关键字sealed作为修饰符,并将permits Class1, Class2, ...添加到类声明中,如

// Circle.java
final class Circle extends Shape {
    ...
}

// Rectangle.java
final class Rectangle extends Shape {
    ...
}

// Shape.java
sealed class Shape
     permits Circle, Rectangle {
    // only Circle or Rectangle can
    // inherit from Shape
    ...
}

如果需要库内部的类继承,但不希望用户类从库类继承,通常使用密封类。

摘要

如果与上一版相比,省略章节的基本原理,以及自 JDK10 以来有用或有趣的新奇事物的集合,是本书的结论。

posted @ 2024-08-06 16:34  绝不原创的飞龙  阅读(6)  评论(0编辑  收藏  举报