Java-技术手册第八版-五-

52 阅读1小时+

Java 技术手册第八版(五)

原文:zh.annas-archive.org/md5/450d5a6a158c65e96e7be41e1a8ae3c7

译者:飞龙

协议:CC BY-NC-SA 4.0

第十章:文件处理和 I/O

Java 自第一个版本以来就支持输入/输出(I/O)功能。然而,由于 Java 强烈追求平台独立性,早期版本的 I/O 功能强调可移植性而不是功能性。因此,它们并不总是易于使用。

我们将在本章后面看到原始 API 是如何被补充的——它们现在非常丰富,功能完备,并且非常易于开发。让我们从查看 Java I/O 的原始“经典”方法开始,而更现代的方法则在其之上增加了层次。

经典的 Java I/O

File类是 Java 原始文件 I/O 方式的基石。这种抽象化可以同时表示文件和目录,但在处理过程中有时会显得有些累赘,导致这样的代码:

// Get a file object to represent the user's home directory
var homedir = new File(System.getProperty("user.home"));

// Create an object to represent a config file (should
// already be present in the home directory)
var f = new File(homedir, "app.conf");

// Check the file exists, really is a file, and is readable
if (f.exists() && f.isFile() && f.canRead()) {

  // Create a file object for a new configuration directory
  var configdir = new File(homedir, ".configdir");
  // And create it
  configdir.mkdir();

  // Finally, move the config file to its new home
  f.renameTo(new File(configdir, ".config"));
}

这展示了File类可能具有的一些灵活性,但也展示了抽象化带来的一些问题。它非常通用,因此需要大量的方法来查询File对象,以确定它实际代表什么以及其功能。

文件

File类上有大量的方法,但某些基本功能(特别是直接提供读取文件实际内容的方式)则没有,并且从未直接提供过。以下是File方法的快速总结:

// Permissions management
boolean canX = f.canExecute();
boolean canR = f.canRead();
boolean canW = f.canWrite();

boolean ok;
ok = f.setReadOnly();
ok = f.setExecutable(true);
ok = f.setReadable(true);
ok = f.setWritable(false);

// Different views of the file's name
File absF = f.getAbsoluteFile();
File canF = f.getCanonicalFile();
String absName = f.getAbsolutePath();
String canName = f.getCanonicalPath();
String name = f.getName();
String pName = f.getParent();
URI fileURI = f.toURI(); // Create URI for File path

// File metadata
boolean exists = f.exists();
boolean isAbs = f.isAbsolute();
boolean isDir = f.isDirectory();
boolean isFile = f.isFile();
boolean isHidden = f.isHidden();
long modTime = f.lastModified(); // milliseconds since epoch
boolean updateOK = f.setLastModified(updateTime); // milliseconds
long fileLen = f.length();

// File management operations
boolean renamed = f.renameTo(destFile);
boolean deleted = f.delete();

// Create won't overwrite existing file
boolean createdOK = f.createNewFile();

// Temporary file handling
var tmp = File.createTempFile("my-tmp", ".tmp");
tmp.deleteOnExit();

// Directory handling
boolean createdDir = dir.mkdir(); // Non-recursive create only
String[] fileNames = dir.list();
File[] files = dir.listFiles();

File类还有一些方法并不完全适合于该抽象化。它们主要涉及对文件所在的文件系统进行询问(例如,询问可用的空闲空间):

long free = f.getFreeSpace();
long total = f.getTotalSpace();
long usable = f.getUsableSpace();

File[] roots = File.listRoots(); // all available Filesystem roots

I/O 流

I/O 流抽象化(不要与处理 Java 8 集合 API 时使用的流混淆)已存在于 Java 1.0 中,作为处理来自磁盘或其他源的顺序字节流的一种方式。

这个 API 的核心是一对抽象类,InputStreamOutputStream。它们被广泛使用,事实上,“标准”输入和输出流,即称为System.inSystem.out的流,属于这种类型。它们是System类的公共静态字段,甚至在最简单的程序中经常被使用:

System.out.println("Hello World!");

流的特定子类,包括FileInputStreamFileOutputStream,可以用于操作文件中的单个字节,例如,通过计算 ASCII 97(小写字母a)在文件中出现的次数:

try (var is = new FileInputStream("/Users/ben/cluster.txt")) {
  byte[] buf = new byte[4096];
  int len, count = 0;
  while ((len = is.read(buf)) > 0) {
    for (int i = 0; i < len; i = i + 1) {
      if (buf[i] == 97) {
        count = count + 1;
      }
    }
  }
  System.out.println("'a's seen: "+ count);
} catch (IOException e) {
  e.printStackTrace();
}

处理磁盘数据的这种方法可能缺乏一些灵活性——大多数开发人员都是以字符为单位思考,而不是以字节为单位。为了解决这个问题,这些流通常与更高级别的ReaderWriter类结合使用,提供字符流级别的交互,而不是由InputStreamOutputStream及其子类提供的低级字节流。

读者和写作者

通过转向以字符为基础的抽象层,而不是字节,开发人员面对的是一个更加熟悉的 API,它隐藏了许多字符编码、Unicode 等问题。

ReaderWriter类旨在覆盖字节流类,并消除对 I/O 流的低级处理的需要。它们有几个子类经常用于彼此叠加,例如:

  • FileReader

  • BufferedReader

  • StringReader

  • InputStreamReader

  • FileWriter

  • PrintWriter

  • BufferedWriter

要从文件中读取所有行并将它们打印出来,我们使用一个在FileReader之上叠加的BufferedReader,如下所示:

try (var in = new BufferedReader(new FileReader(filename))) {
  String line;

  while((line = in.readLine()) != null) {
    System.out.println(line);
  }
} catch (IOException e) {
  // Handle FileNotFoundException, etc. here
}

如果我们需要从控制台而不是文件中读取行,则通常会使用InputStreamReader应用于System.in。让我们看一个例子,我们想从控制台读取输入行,但要将以特殊字符开头的输入行视为特殊命令(“元命令”),而不是普通文本。这是许多聊天程序(包括 IRC)的常见特性。我们将使用来自第九章的正则表达式来帮助我们:

// Meta example: "#info username"
var SHELL_META_START = Pattern.compile("^#(\\w+)\\s*(\\w+)?");

try (var console =
      new BufferedReader(new InputStreamReader(System.in))) {
  String line;

  while((line = console.readLine()) != null) {
    // Check for special commands ("metas")
    Matcher m = SHELL_META_START.matcher(line);
    if (m.find()) {
      String metaName = m.group(1);
      String arg = m.group(2);
      doMeta(metaName, arg);
    } else {
      System.out.println(line);
    }
  }
} catch (IOException e) {
  // Handle FileNotFoundException, etc. here
}

要将文本输出到文件,我们可以使用以下代码:

var f = new File(System.getProperty("user.home")
 + File.separator + ".bashrc");
try (var out =
      new PrintWriter(new BufferedWriter(new FileWriter(f)))) {
  out.println("## Automatically generated config file. DO NOT EDIT");
  // ...
} catch (IOException iox) {
  // Handle exceptions
}

这种较旧的 Java I/O 风格还有许多其他偶尔有用的功能。例如,要处理文本文件,FilterInputStream类经常很有用。或者对于希望以类似于经典“管道”I/O 方法进行通信的线程,提供了PipedInputStreamPipedReader及其写入对应物。

到目前为止,我们在本章中一直在使用“try-with-resources”(TWR)这种语言特性。这种语法在“try-with-resources 语句”中简要介绍过,但是结合像 I/O 这样的操作时,它才发挥出最大的潜力,并且它给旧的 I/O 风格带来了新生。

再探讨 try-with-resources

要充分利用 Java 的 I/O 功能,理解何时以及如何使用 TWR 至关重要。只要可能,代码就应该使用 TWR,这一点非常容易理解。

在 TWR 之前,资源必须手动关闭;资源之间复杂的交互导致有漏洞的泄漏资源的错误代码。

实际上,Oracle 的工程师们估计,初始 JDK 6 版本中 60%的资源处理代码是错误的。因此,即使是平台的作者也无法可靠地处理手动资源处理,那么所有新代码肯定都应该使用 TWR。

TWR 的关键在于一个新接口—AutoCloseable。这个接口是Closeable的直接超接口。它标志着一个必须自动关闭的资源,并且编译器将插入特殊的异常处理代码。

在 TWR 资源子句中,只能声明实现了AutoCloseable接口的对象,但开发人员可以声明所需数量的对象:

try (var in = new BufferedReader(
                           new FileReader("profile"));
     var out = new PrintWriter(
                         new BufferedWriter(
                           new FileWriter("profile.bak")))) {
  String line;
  while((line = in.readLine()) != null) {
    out.println(line);
  }
} catch (IOException e) {
  // Handle FileNotFoundException, etc. here
}

这意味着资源自动限定于try块。这些资源(无论是可读还是可写的)会按照打开的相反顺序自动关闭,并且编译器会插入异常处理以考虑资源之间的依赖关系。

TWR 与其他语言和环境中的类似概念相关,例如 C++中的 RAII(资源获取即初始化)。然而,如终结部分所讨论的,TWR 仅限于块范围。这种轻微的限制是因为该功能由 Java 源代码编译器实现 —— 它在退出作用域时自动插入调用资源的close()方法的字节码(无论通过何种方式退出)。

因此,TWR 的整体效果更类似于 C#的using关键字,而不是 C++版本的 RAII。对于 Java 开发人员,理解 TWR 的最佳方式是“正确执行的终结”。正如在“Finalization”中所述,新代码不应直接使用终结机制,而应始终使用 TWR。较旧的代码应尽快重构为使用 TWR,因为它为资源处理代码提供了真正的实际好处。

经典 I/O 存在问题

即使引入了try-with-resources,File类及其相关类在执行标准 I/O 操作时仍存在一些问题。例如:

  • 缺少常见操作的方法。

  • 平台之间并不一致地处理文件名。

  • 没有统一的文件属性模型(例如,建模读写访问)。

  • 难以遍历未知的目录结构

  • 没有平台或操作系统特定的功能。

  • 不支持文件系统的非阻塞操作。

要解决这些缺点,Java 的 I/O 在几个重要版本中逐步演变。随着 Java 7 的发布,这种支持变得真正简单和高效。

现代 Java I/O

Java 7 引入了全新的 I/O API —— 通常称为 NIO.2 —— 几乎完全取代了原始的File方法进行 I/O 操作。

新的类位于java.nio.file包中,对于许多用例来说更加简单。API 由两个主要部分组成。第一个是称为Path的新抽象(可以视为表示文件位置,实际上可能存在也可能不存在)。第二部分是许多处理文件和文件系统的新便利和实用方法。这些方法作为Files类的静态方法提供。

文件

例如,当您使用新的Files功能时,基本的复制操作现在就像这样简单:

var inputFile = new File("input.txt");
try (var in = new FileInputStream(inputFile)) {
  Files.copy(in, Path.of("output.txt"));
} catch(IOException ex) {
  ex.printStackTrace();
}

让我们快速浏览一些Files中的主要方法——它们的大多数操作都是很明显的。在许多情况下,这些方法有返回类型。我们已经省略了处理这些内容,因为它们除了人为示例和复制等效的 C 代码行为外,很少有用:

Path source, target;
Attributes attr;
Charset cs = StandardCharsets.UTF_8;

// Creating files
//
// Example of path --> /home/ben/.profile
// Example of attributes --> rw-rw-rw-
Files.createFile(target, attr);

// Deleting files
Files.delete(target);
boolean deleted = Files.deleteIfExists(target);

// Copying/moving files
Files.copy(source, target);
Files.move(source, target);

// Utility methods to retrieve information
long size = Files.size(target);

FileTime fTime = Files.getLastModifiedTime(target);
System.out.println(fTime.to(TimeUnit.SECONDS));

Map<String, ?> attrs = Files.readAttributes(target, "*");
System.out.println(attrs);

// Methods to deal with file types
boolean isDir = Files.isDirectory(target);
boolean isSym = Files.isSymbolicLink(target);

// Methods to deal with reading and writing
List<String> lines = Files.readAllLines(target, cs);
byte[] b = Files.readAllBytes(target);

var br = Files.newBufferedReader(target, cs);
var bwr = Files.newBufferedWriter(target, cs);

var is = Files.newInputStream(target);
var os = Files.newOutputStream(target);

Files上的一些方法提供了传递可选参数的机会,以提供额外的(可能是实现特定的)操作行为。

这里的一些 API 选择会产生偶尔令人讨厌的行为。例如,默认情况下,复制操作不会覆盖现有文件,因此我们需要指定此行为作为复制选项:

Files.copy(Path.of("input.txt"), Path.of("output.txt"),
           StandardCopyOption.REPLACE_EXISTING);

StandardCopyOption是一个实现CopyOption接口的枚举。这也被LinkOption实现。因此,Files.copy()可以接受任意数量的LinkOptionStandardCopyOption参数。LinkOption用于指定如何处理符号链接(当然,前提是底层操作系统支持符号链接)。

Path

Path是用于在文件系统中定位文件的类型。它表示一个路径,其特点是:

  • 系统相关

  • 分层的

  • 由一系列路径元素组成

  • 假设性的(可能尚不存在,或已被删除)

因此,它与File基本不同。特别是,系统依赖通过Path作为接口而不是类来体现,这使得不同的文件系统提供者可以各自实现Path接口,并提供系统特定的功能,同时保留总体抽象。

Path的元素包括可选的根组件,它标识了此实例所属的文件系统层次结构。请注意,例如,相对Path实例可能没有根组件。除了根之外,所有的Path实例都有零个或多个目录名称和一个名称元素。

名称元素是距离目录层次结构根部最远的元素,并表示文件或目录的名称。Path可以被认为是由特殊分隔符或分隔符连接的路径元素组成。

Path是一个抽象概念;它不一定与任何物理文件路径绑定。这使得我们可以轻松地讨论尚不存在的文件的位置。Path接口提供了用于创建Path实例的静态工厂方法。

注意

当 NIO.2 在 Java 7 中引入时,接口上不支持静态方法,因此引入了Paths类来保存工厂方法。到了 Java 17,推荐使用Path接口的方法,而Paths类可能会在未来被弃用。

Path 提供了两个 of() 方法用于创建 Path 对象。通常版本使用一个或多个 String 实例,并使用默认的文件系统提供者。URI 版本利用了 NIO.2 的能力,可以插入额外提供定制文件系统的提供者。这是一个高级用法,有兴趣的开发人员应该查阅主要文档。让我们看一些如何使用 Path 的简单示例:

var p = Path.of("/Users/ben/cluster.txt");
var p2 = Path.of(new URI("file:///Users/ben/cluster.txt"));
System.out.println(p2.equals(p));

File f = p.toFile();
System.out.println(f.isDirectory());

Path p3 = f.toPath();
System.out.println(p3.equals(p));

该示例还展示了 PathFile 对象之间的简单互操作性。Path 增加了 toFile() 方法,而 File 增加了 toPath() 方法,允许开发人员在两个 API 之间轻松切换,并允许通过简单的方法重构基于 File 的代码内部,改为使用 Path

我们还可以使用 Files 类提供的一些有用的“桥接”方法。例如,通过提供方便的方法来打开 Writer 对象到指定的 Path 位置:

var logFile = Path.of("/tmp/app.log");
try (var writer =
       Files.newBufferedWriter(logFile, StandardCharsets.UTF_8,
                               StandardOpenOption.WRITE,
                               StandardOpenOption.CREATE)) {
  writer.write("Hello World!");
  // ...
} catch (IOException e) {
  // ...
}

我们使用了 StandardOpenOption 枚举,它提供了类似于复制选项的能力,但用于打开新文件的情况。我们同时提供了 WRITECREATE,所以如果文件不存在,它将被创建;否则,我们只是打开它以进行额外的写入操作。

在这个示例用例中,我们已经使用了 Path API 来:

  • 创建对应于新文件的 Path

  • 使用 Files 类来创建这个新文件

  • 打开一个 Writer 到该文件

  • 向该文件写入

  • 在完成时自动关闭它

在我们的下一个示例中,我们将在此基础上操作 JAR 文件,将其作为一个独立的 FileSystem 来修改,直接向 JAR 中添加文件。请记住,JAR 文件实际上只是 ZIP 文件,因此这种技术也适用于 .zip 归档文件:

var tempJar = Path.of("sample.jar");
try (var workingFS =
      FileSystems.newFileSystem(tempJar)) {

  Path pathForFile = workingFS.getPath("/hello.txt");
  Files.write(pathForFile,
              List.of("Hello World!"),
              Charset.defaultCharset(),
              StandardOpenOption.WRITE, StandardOpenOption.CREATE);
}

这显示了我们如何创建一个 FileSystem 对象,以便创建引用 jar 内文件的 Path 对象,通过 getPath() 方法。这使得开发人员基本上可以将 FileSystem 对象视为黑匣子:它们通过服务提供者接口(SPI)机制自动创建。

要查看您的计算机上可用的文件系统,您可以运行类似于以下的代码:

for (FileSystemProvider f : FileSystemProvider.installedProviders()) {
    System.out.println(f.toString());
}

Files 类还提供了处理临时文件和目录的方法,这是一个令人惊讶地常见的用例(也可能是安全漏洞的源头)。例如,让我们看看如何从类路径中加载资源文件,将其复制到新创建的临时目录,然后安全地清理临时文件(使用在线书籍资源中提供的 Reaper 类):

Path tmpdir = Files.createTempDirectory(Path.of("/tmp"), "tmp-test");
try (InputStream in =
      FilesExample.class.getResourceAsStream("/res.txt")) {
    Path copied = tmpdir.resolve("copied-resource.txt");
    Files.copy(in, copied, StandardCopyOption.REPLACE_EXISTING);
    // ... work with the copy
}
// Clean up when done...
Files.walkFileTree(tmpdir, new Reaper());

Java 原始 I/O API 的一个批评是缺乏对本地和高性能 I/O 的支持。在 Java 1.4 中首次添加了解决方案,即 Java 新 I/O (NIO) API,并在后续 Java 版本中进行了改进。

NIO 通道和缓冲区

NIO 缓冲区是高性能 I/O 的低级抽象。它们提供了一个特定原始类型的线性序列元素的容器。我们将在示例中使用ByteBuffer(最常见的情况)。

ByteBuffer

这是一系列字节,可以概念上看作是与byte[]工作的性能关键替代品。为了获得最佳性能,ByteBuffer提供了支持直接处理 JVM 运行平台的本地能力。

这种方法称为直接缓冲区情况,尽可能绕过 Java 堆。直接缓冲区在本机内存中分配,而不是在标准的 Java 堆上,并且不会像常规的在堆 Java 对象那样受垃圾收集的影响。

要获取直接的ByteBuffer,调用allocateDirect()工厂方法。也提供了一个在堆上的版本allocate(),但在实践中这不常用。

获取字节缓冲区的第三种方法是使用wrap()一个现有的byte[] —— 这将提供一个在堆上的缓冲区,用于提供对底层字节的更面向对象的视图:

var b = ByteBuffer.allocateDirect(65536);
var b2 = ByteBuffer.allocate(4096);

byte[] data = {1, 2, 3};
ByteBuffer b3 = ByteBuffer.wrap(data);

字节缓冲区都是关于对字节的低级访问。这意味着开发人员必须手动处理细节 —— 包括处理字节的字节顺序和 Java 整数原始类型的有符号性:

b.order(ByteOrder.BIG_ENDIAN);

int capacity = b.capacity();
int position = b.position();
int limit = b.limit();
int remaining = b.remaining();
boolean more = b.hasRemaining();

要将数据输入或输出到缓冲区,我们有两种操作类型 —— 单值类型,读取或写入单个值,以及批量操作,接受一个byte[]ByteBuffer并作为单个操作操作(可能大量的)值。我们期望从批量操作中获得性能提升:

b.put((byte)42);
b.putChar('x');
b.putInt(0xc001c0de);

b.put(data);
b.put(b2);

double d = b.getDouble();
b.get(data, 0, data.length);

单值形式也支持用于在缓冲区内进行绝对定位的形式:

b.put(0, (byte)9);

缓冲区是内存中的抽象。要影响外部世界(例如文件或网络),我们需要使用java.nio.channels包中的Channel。通道代表可以支持读取或写入操作的实体连接。文件和套接字是通道的常见示例,但我们可以考虑用于低延迟数据处理的自定义实现。

通道在创建时处于打开状态,可以随后关闭。一旦关闭,就不能重新打开。通常通道要么可读要么可写,但不能同时。理解通道的关键在于:

  • 从通道读取将字节放入缓冲区

  • 向通道写入从缓冲区中获取的字节

例如,假设我们有一个大文件,想要以 16M 块进行校验和计算:

FileInputStream fis = getSomeStream();
boolean fileOK = true;

try (FileChannel fchan = fis.getChannel()) {
  var buffy = ByteBuffer.allocateDirect(16 * 1024 * 1024);
  while(fchan.read(buffy) != -1 || buffy.position() > 0 || fileOK) {
    fileOK = computeChecksum(buffy);
    buffy.compact();
  }
} catch (IOException e) {
  System.out.println("Exception in I/O");
}

这将尽可能使用本机 I/O,并避免在 Java 堆上复制大量字节。如果computeChecksum()方法已经实现良好,那么这可能是一个非常高效的实现。

映射的字节缓冲区

这些是一种直接字节缓冲区,包含一个内存映射文件(或其一部分)。它们是从 FileChannel 对象创建的,但请注意,与 MappedByteBuffer 对应的 File 对象在内存映射操作后不能再使用,否则会抛出异常。为了减少这种情况,我们再次使用 try-with-resources,以紧密地限定对象的作用域:

try (var raf =
  new RandomAccessFile(new File("input.txt"), "rw");
     FileChannel fc = raf.getChannel();) {

  MappedByteBuffer mbf =
    fc.map(FileChannel.MapMode.READ_WRITE, 0, fc.size());
  var b = new byte[(int)fc.size()];
  mbf.get(b, 0, b.length);
  for (int i = 0; i < fc.size(); i = i + 1) {
    b[i] = 0; // Won't be written back to the file, we're a copy
  }
  mbf.position(0);
  mbf.put(b); // Zeros the file
}

即使使用缓冲区,Java 对于大型 I/O 操作(例如在文件系统之间传输 10G)也存在一些限制,这些操作在单个线程上同步执行。在 Java 7 之前,这类操作通常通过编写自定义的多线程代码来完成,并管理单独的线程执行后台复制。让我们继续看看 JDK 7 中新增的异步 I/O 特性。

异步 I/O

异步功能的关键在于 Channel 的新子类,它们可以处理需要交给后台线程的 I/O 操作。相同的功能可以应用于大型、长时间运行的操作以及其他几种用例。

在本节中,我们将专注于文件 I/O 的 AsynchronousFileChannel,但还有几个其他异步通道需要了解。我们将在本章末尾查看异步套接字。我们将看到:

  • 用于文件 I/O 的 AsynchronousFileChannel

  • 用于客户端套接字 I/O 的 AsynchronousSocketChannel

  • 用于异步套接字接受传入连接的 AsynchronousServerSocketChannel

与异步通道交互的有两种不同的方式 — Future 风格和回调风格。

基于 Future 的风格

Future 接口的全面讨论将使我们深入了解 Java 并发的细节。然而,对于本章的目的,它可以被视为一个可能已经完成或尚未完成的任务。它有两个关键方法:

isDone()

返回一个布尔值,指示任务是否已完成。

get()

返回结果。如果完成,则立即返回。如果未完成,则阻塞直到完成。

让我们看一个示例程序,异步读取一个大文件(可能达到 100 Mb):

try (var channel =
         AsynchronousFileChannel.open(Path.of("input.txt"))) {
  var buffer = ByteBuffer.allocateDirect(1024 * 1024 * 100);
  Future<Integer> result = channel.read(buffer, 0);

  while(!result.isDone()) {
    // Do some other useful work....
  }

  System.out.println("Bytes read: " + result.get());
}

基于回调的风格

异步 I/O 的回调风格基于 CompletionHandler,它定义了两个方法 completed()failed(),在操作成功或失败时将回调调用。

如果您希望在异步 I/O 中立即收到事件通知,则此风格非常有用 — 例如,如果有大量的 I/O 操作正在进行中,但任何单个操作的失败并不一定是致命的:

byte[] data = {2, 3, 5, 7, 11, 13, 17, 19, 23};
ByteBuffer buffy = ByteBuffer.wrap(data);

CompletionHandler<Integer,Object> h =
  new CompletionHandler<>() {
    public void completed(Integer written, Object o) {
      System.out.println("Bytes written: " + written);
    }

    public void failed(Throwable x, Object o) {
      System.out.println("Asynch write failed: "+ x.getMessage());
    }
  };

try (var channel =
       AsynchronousFileChannel.open(Path.of("primes.txt"),
          StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {

  channel.write(buffy, 0, null, h);

  // Give the CompletionHandler time to run before foreground exit
  Thread.sleep(1000);
}

AsynchronousFileChannel 对象与后台线程池相关联,因此 I/O 操作可以继续进行,而原始线程可以继续进行其他任务。

注意

CompletionHandler 接口有两个抽象方法,而不是一个,所以它不可以是 lambda 表达式的目标类型,遗憾地。

默认情况下,这使用由运行时提供的托管线程池。如果需要,可以创建一个由应用程序管理的线程池(通过AsynchronousFileChannel.open()的重载形式),但这很少是必要的。

最后,为了完整起见,让我们简要介绍一下 NIO 对多路复用 I/O 的支持。这使得单个线程能够管理多个通道,并检查这些通道以查看哪些已准备好进行读取或写入。支持这些功能的类位于java.nio.channels包中,包括SelectableChannelSelector

当编写需要高可伸缩性的高级应用程序时,这些非阻塞的多路复用技术可以非常有用,但是在本书的讨论范围之外。通常情况下,非阻塞 API 应仅用于高级用例,当高性能或其他非功能性要求要求时。

监控服务和目录搜索

我们将考虑的最后一类异步服务是监视目录或访问目录(或树)。观察服务通过观察目录中发生的一切来操作,例如文件的创建或修改:

try {
  var watcher = FileSystems.getDefault().newWatchService();

  var dir = FileSystems.getDefault().getPath("/home/ben");
  dir.register(watcher,
                StandardWatchEventKinds.ENTRY_CREATE,
                StandardWatchEventKinds.ENTRY_MODIFY,
                StandardWatchEventKinds.ENTRY_DELETE);

  while(!shutdown) {
    WatchKey key = watcher.take();
    for (WatchEvent<?> event: key.pollEvents()) {
      Object o = event.context();
      if (o instanceof Path) {
        System.out.println("Path altered: "+ o);
      }
    }
    key.reset();
  }
}

相比之下,目录流提供了对单个目录中当前所有文件的视图。例如,要列出所有 Java 源文件及其大小(以字节为单位),我们可以使用如下代码:

try(DirectoryStream<Path> stream =
    Files.newDirectoryStream(Path.of("/opt/projects"), "*.java")) {
  for (Path p : stream) {
    System.out.println(p +": "+ Files.size(p));
  }
}

此 API 的一个缺点是,它仅根据通配符语法返回匹配的元素,有时不够灵活。我们可以通过使用Files.find()Files.walk()方法进一步处理通过目录的递归遍历获取的每个元素:

var homeDir = Path.of("/Users/ben/projects/");
Files.find(homeDir, 255,
  (p, attrs) -> p.toString().endsWith(".java"))
     .forEach(q -> {System.out.println(q.normalize());});

还可以进一步构建基于java.nio.file中的FileVisitor接口的高级解决方案,但这要求开发人员实现接口的所有四个方法,而不仅仅是像这里所做的单个 lambda 表达式。

在本章的最后一部分,我们将讨论 Java 的网络支持和使其成为可能的核心 JDK 类。

网络

Java 平台提供了对大量标准网络协议的访问,这使得编写简单的网络应用程序非常容易。Java 网络支持的核心位于java.net包中,通过javax.net(特别是javax.net.ssl)提供了额外的可扩展性,所有这些都在模块java.base中。

构建应用程序最简单的协议之一是超文本传输协议(HTTP),这是 Web 的基本通信协议。

HTTP

HTTP 是 Java 支持的最常见和流行的高级网络协议。它是一个非常简单的协议,在标准 TCP/IP 协议栈的基础上实现。它可以运行在任何网络端口上,但通常在加密的 TLS(称为 HTTPS)上使用端口 443 或者在未加密时使用端口 80。如今,应尽可能在所有地方默认使用 HTTPS。

Java 有两个单独的处理 HTTP 的 API —— 其中一个可以追溯到平台早期的日子,另一个是全面支持的 Java 11 新 API。

为了完整起见,让我们快速看一下旧 API。在这个 API 中,URL 是关键类 —— 它支持 http://ftp://file://https:// 形式的 URL。它非常易于使用,Java HTTP 支持的最简单示例是下载特定 URL:

var url = new URL("http://www.google.com/");
try (InputStream in = url.openStream()) {
  Files.copy(in, Path.of("output.txt"));
} catch(IOException ex) {
  ex.printStackTrace();
}

对于更低级别的控制,包括请求和响应的元数据,我们可以使用 URLConnection 来实现类似以下的操作:

try {
  URLConnection conn = url.openConnection();

  String type = conn.getContentType();
  String encoding = conn.getContentEncoding();
  Date lastModified = new Date(conn.getLastModified());
  int len = conn.getContentLength();
  InputStream in = conn.getInputStream();
} catch (IOException e) {
  // Handle exception
}

HTTP 定义了“请求方法”,即客户端可以在远程资源上执行的操作。这些方法包括 GET、POST、HEAD、PUT、DELETE、OPTIONS 和 TRACE。

每个方法有稍微不同的用法,例如:

  • GET 应仅用于检索文档,并且永远不应执行任何副作用。

  • HEAD 与 GET 类似,但不返回主体 —— 如果程序想通过标头快速检查 URL 是否已更改,则此方法很有用。

  • 当我们需要将数据发送到服务器进行处理时使用 POST。

默认情况下,Java 使用 GET 方法,但它提供了一种使用其他方法构建更复杂应用程序的方式;然而,这样做比较复杂。在下一个示例中,我们使用 Postman 提供的 echo 函数来返回我们发布的数据的视图:

var url = new URL("https://postman-echo.com/post");
var encodedData = URLEncoder.encode("q=java", "ASCII");
var contentType = "application/x-www-form-urlencoded";

var conn = (HttpURLConnection) url.openConnection();
conn.setInstanceFollowRedirects(false);
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", contentType );
conn.setRequestProperty("Content-Length",
  String.valueOf(encodedData.length()));

conn.setDoOutput(true);
OutputStream os = conn.getOutputStream();
os.write( encodedData.getBytes() );

int response = conn.getResponseCode();
if (response == HttpURLConnection.HTTP_MOVED_PERM
    || response == HttpURLConnection.HTTP_MOVED_TEMP) {
  System.out.println("Moved to: "+ conn.getHeaderField("Location"));
} else {
  try (InputStream in = conn.getInputStream()) {
    Files.copy(in, Path.of("postman.txt"),
                StandardCopyOption.REPLACE_EXISTING);
  }
}

注意,我们需要将查询参数发送到请求体中并在发送前进行编码。我们还需要禁用 HTTP 重定向的跟随,并手动处理来自服务器的任何重定向。这是由于 HttpURLConnection 类在处理 POST 请求的重定向时存在限制。

旧 API 明显显露其年龄,事实上仅实现了 HTTP 标准的 1.0 版本,非常低效且被认为是过时的。作为替代,现代 Java 程序可以使用新 API,这是为了支持新的 HTTP/2 协议而添加的结果。自 Java 11 开始,它已经作为 java.net.http 模块的完全支持部分提供。让我们看一个使用新 API 的简单示例:

        var client = HttpClient.newBuilder().build();
        var uri = new URI("https://www.oreilly.com");
        var request = HttpRequest.newBuilder(uri).build();

        var response = client.send(request,
                ofString(Charset.defaultCharset()));
        var body = response.body();
        System.out.println(body);

请注意,该 API 设计为可扩展,例如通过 HttpResponse.BodySubscriber 接口来实现自定义处理。该接口还可以无缝隐藏 HTTP/2 和旧版 HTTP/1.1 协议之间的差异,这意味着 Java 应用程序能够在 Web 服务器采用新版本时进行优雅的迁移。

让我们继续看网络堆栈中更底层的下一层,传输控制协议(TCP)。

TCP

TCP 是互联网上可靠网络传输的基础。它确保网页和其他互联网流量以完整和可理解的状态传输。从网络理论的角度来看,使 TCP 能够作为互联网流量的“可靠性层”的协议特性包括:

基于连接

数据属于单个逻辑流(即连接)。

保证有序交付

直到数据包到达为止,数据包将被重新发送。

经过错误检查

网络传输引起的损坏将被自动检测和修复。

TCP 是双向(或双向)通信通道,并使用特殊的编号方案(TCP 序列号)来确保通信流的两侧保持同步。为了支持同一网络主机上的多个不同服务,TCP 使用端口号来标识服务,并确保发送到一个端口的流量不会进入另一个端口。

在 Java 中,TCP 由SocketServerSocket类表示。它们用于提供能力,分别作为连接的客户端和服务器端—这意味着 Java 既可用于连接到网络服务,也可作为实现新服务的语言。

注意

Java 的原始套接字支持在 Java 13 中重新实现,没有 API 更改。经典套接字 API 现在与更现代的 NIO 基础结构共享代码,并且将继续有效地运行到未来。

举例来说,让我们考虑重新实现 HTTP 1.1。这是一个相对简单的基于文本的协议。我们需要实现连接的双方,所以让我们从基于 TCP 套接字的 HTTP 客户端开始。为了完成这个任务,我们实际上需要实现 HTTP 协议的细节,但我们有一个优势,那就是我们可以完全控制 TCP 套接字。

我们将需要从客户端套接字读取和写入,并且我们将按照 HTTP 标准(称为 RFC 2616,并使用显式的行结束语法)构造实际的请求行。生成的客户端代码将类似于以下内容:

var hostname = "www.example.com";
int port = 80;
var filename = "/index.xhtml";

try (var sock = new Socket(hostname, port);
  var from = new BufferedReader(
      new InputStreamReader(sock.getInputStream()));
  var to = new PrintWriter(
      new OutputStreamWriter(sock.getOutputStream())); ) {

  // The HTTP protocol
  to.print("GET " + filename +
    " HTTP/1.1\r\nHost: "+ hostname +"\r\n\r\n");
  to.flush();

  for (String l = null; (l = from.readLine()) != null; )
    System.out.println(l);
}

在服务器端,我们需要接收可能的多个传入连接。为了处理这一点,我们将启动一个主服务器循环,然后使用accept()从操作系统获取新连接。然后快速将新连接传递给一个单独的处理程序类,以便主服务器循环可以继续监听新连接。这段代码比客户端情况复杂一些:

// Handler class
private static class HttpHandler implements Runnable {
  private final Socket sock;
  HttpHandler(Socket client) { this.sock = client; }

  public void run() {
    try (var in =
           new BufferedReader(
             new InputStreamReader(sock.getInputStream()));
         var out =
           new PrintWriter(
             new OutputStreamWriter(sock.getOutputStream())); ) {
      out.print("HTTP/1.0 200\r\nContent-Type: text/plain\r\n\r\n");
      String line;
      while((line = in.readLine()) != null) {
        if (line.length() == 0) break;
        out.println(line);
      }
    } catch(Exception e) {
      // Handle exception
    }
  }
}

// Main server loop
public static void main(String[] args) {
  try {
    var port = Integer.parseInt(args[0]);

    ServerSocket ss = new ServerSocket(port);
    while (true) {
      Socket client = ss.accept();
      var handler = new HTTPHandler(client);
      new Thread(handler).start();
    }
  } catch (Exception e) {
    // Handle exception
  }
}

当设计一个应用程序通过 TCP 进行通信的协议时,有一个简单而深刻的网络架构原则,即著名的波斯特尔定律(以互联网之父之一乔恩·波斯特尔命名),你应该始终牢记。它有时被表述为:“在发送时要严格,接收时要宽松。”这个简单的原则意味着即使在网络系统中存在相当不完美的实现,通信仍然可以广泛可能。

波斯特尔定律与协议应尽可能简单的一般原则结合起来(有时被称为 KISS 原则),将使得开发者实现基于 TCP 的通信任务比起其他方式要容易得多。

TCP 下面是互联网的通用运输协议——互联网协议(IP)本身。

IP

IP,作为“最低公共分母”传输,为实际从点 A 到点 B 传输字节的物理网络技术提供了一个有用的抽象。

不同于 TCP,IP 数据包的传递并不保证,可能会被沿途任何过载系统丢弃。IP 数据包确实有一个目的地,但通常没有路由数据——这是沿途(可能是多个不同的)物理传输的责任来实际交付数据。

在 Java 中可以创建基于单个 IP 数据包(或带有 UDP 头部而不是 TCP 的数据包)的“数据报”服务,但这通常只有在需要极低延迟的应用程序时才需要。Java 使用类DatagramSocket来实现此功能,尽管少数开发人员可能永远不需要深入到这个网络栈的层次。

最后,值得注意的是,当前在互联网上使用的寻址方案正在飞行中发生一些变化。目前主导的 IP 版本是 IPv4,其拥有 32 位的可能网络地址空间。现在这个空间已经非常紧张,已经部署了各种缓解技术来处理这种枯竭。

下一个 IP 版本(IPv6)正在逐步推出,但尚未完全被接受,也尚未取代 IPv4,尽管向它成为标准的稳步进展正在持续。截至目前,IPv6 流量约占互联网流量的 35%,并且稳步增长。在未来的 10 年内,IPv6 有可能在流量量上超过 IPv4,并且低级网络将需要适应这个根本性的新版本。

然而,对于 Java 程序员来说,好消息是该语言和平台多年来一直在为 IPv6 和其引入的变化提供良好支持。与其他许多语言相比,IPv4 到 IPv6 的过渡可能对 Java 应用程序来说会更加平稳和少问题。

摘要

在这一章中,我们介绍了 Java SDK 提供的文件处理、I/O 和网络功能。然而,并不是所有这些功能都被同等频繁地使用。核心的文件处理类(特别是Path和 NIO.2 的其他部分)被 Java 开发者经常使用,而更高级的功能则较少遇到。

网络库的情况有所不同。了解这些功能很好,但它们相当基础。在实践中,通常会使用第三方提供的更高级别的库(例如 Netty)。唯一的例外是,Java 开发者可以相对经常遇到的低级别 JDK 网络库是java.net.http中的新 HTTP 库。

现在让我们来了解一些 Java 的关键动态特性——类加载和反射——这些强大的技术允许代码在运行时以编译时未知的方式被发现、加载和执行。

第十一章:类加载、反射和方法句柄

在第三章中,我们遇到了 Java 的Class对象,一种表示运行中 Java 进程中的活动类型的方式。在本章中,我们将在此基础上讨论 Java 环境如何加载和使新类型可用。在本章的下半部分,我们将介绍 Java 的内省能力——原始的反射 API 和较新的方法句柄功能。

类文件、类对象和元数据

类文件,正如我们在第一章中所看到的,是将 Java 源文件(或者,可能是其他语言)编译成 JVM 使用的中间形式的结果。这些是不打算供人类阅读的二进制文件。

这些类文件的运行时表示是包含元数据的类对象,该元数据表示创建该类文件的 Java 类型。

类对象示例

在 Java 中,您可以通过几种方式获取类对象。最简单的方法是:

Class<?> myClass = getClass();

这会返回调用它的实例的类对象。然而,正如我们从调查Object的公共方法所知道的那样,Object上的getClass()方法是公共的,所以我们也可以获取任意对象o的类:

Class<?> c = o.getClass();

已知类型的类对象也可以写为“类文字”:

// Express a class literal as a type name followed by ".class"
c = String.class; // Same as "a string".getClass()
c = byte[].class; // Type of byte arrays

对于原始类型和void,我们还有表示为字面量的类对象:

// Obtain a Class object for primitive types with various
// predefined constants
c = Void.TYPE; // The special "no-return-value" type
c = Byte.TYPE; // Class object that represents a byte
c = Integer.TYPE; // Class object that represents an int
c = Double.TYPE; // etc.; see also Short, Character, Long, Float

还有可能直接在原始类型上使用.class语法,像这样:

c = int.class; // Same as Integer.TYPE

.class.TYPE之间的关系可以通过一些简单的测试看出:

// outputs true
System.out.printf("%b%n", Integer.TYPE == int.class);

// outputs false
System.out.printf("%b%n", Integer.class == int.class);

// outputs false
System.out.printf("%b%n", Integer.class == Integer.TYPE);

请注意,包装器类型(Integer等)具有.TYPE属性,但一般类不具备。此外,所有这些仅适用于在编译时已知的类型;对于未知类型,我们将不得不使用更复杂的方法。

类对象和元数据

类对象包含有关给定类型的元数据。这包括在所讨论的类上定义的方法、字段、构造函数等。程序员可以访问此元数据来调查类,即使在加载类时对该类一无所知也可以。

例如,我们可以在类文件中找到所有已弃用的方法(它们将用@Deprecated注解标记):

Class<?> clz =  ... // Get class from somewhere, e.g. loaded from disk
for (Method m : clz.getMethods()) {
  for (Annotation a : m.getAnnotations()) {
    if (a.annotationType() == Deprecated.class) {
      System.out.println(m.getName());
    }
  }
}

我们还可以找到一对类文件的公共祖先类。当这两个类都由同一个类加载器加载时,这种简单的形式将起作用:

public static Class<?> commonAncestor(Class<?> cl1, Class<?> cl2) {
  if (cl1 == null || cl2 == null) return null;
  if (cl1.equals(cl2)) return cl1;
  if (cl1.isPrimitive() || cl2.isPrimitive()) return null;

  List<Class<?>> ancestors = new ArrayList<>();
  Class<?> c = cl1;
  while (!c.equals(Object.class)) {
    if (c.equals(cl2)) return c;
    ancestors.add(c);
    c = c.getSuperclass();
  }
  c = cl2;
  while (!c.equals(Object.class)) {
    for (Class<?> k : ancestors) {
      if (c.equals(k)) return c;
    }
    c = c.getSuperclass();
  }

  return Object.class;
}

如果类文件要合法并且能够被 JVM 加载,它们必须符合非常具体的布局。类文件的各个部分依次是:

  • 魔数(所有以十六进制的四个字节CA FE BA BE开头的类文件)

  • 使用的类文件标准的版本

  • 此类的常量池

  • 访问标志(abstractpublic等)

  • 此类的名称

  • 继承信息(例如,超类的名称)

  • 实现的接口

  • 字段

  • 方法

  • 属性

类文件是一种简单的二进制格式,但它并不是人类可读的。相反,应该使用类似javap(参见第十三章)的工具来理解其内容。

类文件中最常用的部分之一是常量池,其中包含类需要引用的所有方法、类、字段和常量的表示(无论它们是在本类中还是在其他类中)。它的设计使得字节码可以简单地通过索引号引用常量池条目,从而节省字节码表示中的空间。

多个不同的 Java 版本创建了不同的类文件版本。然而,Java 的向后兼容规则之一是,更新版本的 JVM(和工具)始终可以使用旧的类文件。

让我们看看类加载过程如何将磁盘上的一组字节转换为一个新的类对象。

类加载的阶段

类加载是将新类型添加到正在运行的 JVM 进程中的过程。这是新代码可以进入系统的唯一方式,也是将数据转换为代码的 Java 平台的唯一方式。类加载过程包括多个阶段,让我们逐个审视它们。

加载

类加载过程从加载字节数组开始。通常情况下,这些字节数组是从文件系统读取的,但也可以从 URL 或其他位置读取(通常表示为 Path 对象)。

ClassLoader::defineClass() 方法负责将类文件(表示为字节数组)转换为类对象。这是一个受保护的方法,因此在没有子类化的情况下是不可访问的。

defineClass() 的第一个任务是加载。这会生成一个类对象的骨架,对应于您尝试加载的类。在这个阶段,对类执行了一些基本检查(例如,检查常量池中的常量以确保它们是自洽的)。

然而,加载本身并不会产生完整的类对象,该类也还不能被使用。相反,在加载后,必须链接该类。这一步骤分解为多个子阶段:

  • 验证

  • 准备和解析

  • 初始化

验证

验证确认类文件符合预期,并且不会尝试违反 JVM 的安全模型(详情见“安全编程与类加载”)。

JVM 字节码设计使得它可以(大部分)静态检查。这样做的效果是减慢类加载过程但加快运行时(因为可以省略一些检查)。

验证步骤旨在防止 JVM 执行可能导致崩溃或将其置于未定义和未经测试状态的字节码,从而可能使其易受恶意代码攻击的字节码。字节码验证是防范恶意手工制作的 Java 字节码和不受信任的 Java 编译器可能输出无效字节码的防御措施。

注意

默认方法机制通过类加载工作。在加载接口的实现时,会检查类文件是否包含默认方法的实现。如果存在这些方法,类加载将正常继续。如果某些方法缺失,则会补丁化以添加缺失方法的默认实现。

准备和解析

验证成功后,类准备好供使用。会为类分配内存,并为类中的静态变量做好初始化准备。

在这个阶段,变量尚未初始化,并且新类中没有执行任何字节码。在运行任何代码之前,JVM 检查新类文件引用的每种类型是否已知于运行时。如果类型不被认识,则可能需要加载它们——这可能会重新启动类加载过程,因为 JVM 加载新类型。

这种加载和发现的过程可以迭代执行,直到达到一组稳定的类型。这称为最初加载的类型的“传递闭包”¹。

让我们通过检查 java.lang.Object 的依赖关系的示例来快速看一下。 图 11-1 展示了 Object 的简化依赖图。它仅显示了 Object 在其公共 API 中可见的直接依赖项,以及这些依赖项的直接 API 可见依赖项的依赖项。此外,还以非常简化的形式显示了 Class 对反射子系统的依赖,以及 PrintStreamPrintWriter 对 I/O 子系统的依赖。

在 图 11-1 中,我们可以看到 Object 的传递闭包的一部分。

JN7 1101

图 11-1. 类型的传递闭包

初始化

解析完成后,JVM 最终可以初始化类。静态变量可以初始化,并且会运行静态初始化块。

这是 JVM 首次执行新加载类的字节码。静态块完成后,类就完全加载并且准备就绪。

安全编程和类加载

Java 程序可以从各种来源动态加载 Java 类,包括来自不受信任的来源,例如通过不安全网络到达的网站。能够创建和使用这些动态代码源是 Java 的一个伟大优势和特性之一。为了成功地使其工作,Java 非常重视一种安全架构,允许不受信任的代码安全地运行,而无需担心对主机系统的损害。

Java 的类加载子系统是许多安全特性的实现地方。类加载架构安全方面的核心思想是,将新的可执行代码引入进程的唯一方法是通过类。

这提供了一个“关键点” —— 创建新类的唯一方法是使用ClassLoader从字节流加载类。通过集中精力使类加载安全,我们可以限制需要保护的攻击面。

JVM 设计中非常有帮助的一个方面是它是一个堆栈机器,因此所有操作都在堆栈上进行,而不是在寄存器中。在方法的每个点上可以推断堆栈状态,并且可以用于确保字节码不会试图违反安全模型。

JVM 实施的一些安全检查包括:

  • 类的所有字节码都具有有效的参数。

  • 所有方法调用时,参数的数量和静态类型必须正确。

  • 字节码永远不会尝试下溢或溢出 JVM 堆栈。

  • 局部变量在初始化之前不被使用。

  • 变量只能被分配合适类型的值。

  • 必须尊重字段、方法和类访问控制修饰符。

  • 没有不安全的强制转换(例如,尝试将int转换为指针)。

  • 所有的分支指令都指向同一个方法内的合法点。

最重要的是处理内存和指针的方法。在汇编语言和 C/C++中,整数和指针是可互换的,因此整数可以用作内存地址。我们可以这样在汇编中编写它:

mov eax, [STAT] ; Move 4 bytes from addr STAT into eax

Java 安全体系结构的最低层涉及 Java 虚拟机的设计及其执行的字节码。JVM 不允许任何形式的直接访问底层系统的内存地址,这可以防止 Java 代码干扰本机硬件和操作系统。JVM 上的这些故意限制反映在 Java 语言本身上,它不支持指针或指针算术。

语言和 JVM 都不允许将整数强制转换为对象引用或反之,并且绝对没有任何方式可以获取对象在内存中的地址。没有这些功能,恶意代码根本无法立足。

从第二章回忆起,Java 有两种类型的值 —— 原始类型和对象引用。这些是唯一可以放入变量中的东西。注意“对象内容”不能放入变量中。Java 没有 C 中的struct的等价物,而且始终具有按值传递的语义。对于引用类型,传递的是引用的副本 —— 这是一个值。

在 JVM 中,引用被表示为指针,但字节码不直接操作它们。事实上,字节码没有“访问位置 X 的内存”的操作码。

相反,我们只能访问字段和方法;字节码不能调用任意内存位置。这意味着 JVM 始终知道代码和数据的区别。反过来,这可以防止一系列栈溢出和其他攻击。

应用类加载

要应用类加载知识,充分理解java.lang.ClassLoader是很重要的。

这是一个抽象类,是完全功能的,并且没有抽象方法。abstract修饰符只存在以确保用户必须子类化ClassLoader才能使用它。

除了上述的defineClass()方法之外,我们还可以通过一个公共的loadClass()方法加载类。这通常由URLClassLoader子类使用,它可以从 URL 或文件路径加载类。

我们可以使用URLClassLoader从本地磁盘加载类,就像这样:

var current = new File( "." ).getCanonicalPath();
var urls = new URL[] {new URL("file://"+ current + "/")};
try (URLClassLoader loader = new URLClassLoader(urls)) {
  Class<?> clz = loader.loadClass("com.example.DFACaller");
  System.out.println(clz.getName());
}

loadClass()的参数是类文件的二进制名称。注意,为了使URLClassLoader正确找到类,它们需要在文件系统上的预期位置上。在这个例子中,类com.example.DFACaller需要在工作目录相对路径com/example/DFACaller.class中找到。

或者,Class提供了Class.forName(),一个静态方法,可以加载类,这些类存在于类路径上,但尚未被引用。

该方法接受一个完全限定的类名。例如:

Class<?> jdbcClz = Class.forName("oracle.jdbc.driver.OracleDriver");

如果找不到类,则会抛出ClassNotFoundException。正如示例所示,在较早的 Java 数据库连接(JDBC)的版本中通常使用它来确保加载正确的驱动程序,同时避免对驱动程序类的直接import依赖。随着 JDBC 4.0 的到来,这个初始化步骤不再需要了。

Class.forName()有一个替代的、三参数形式,有时与替代的类加载器一起使用:

Class.forName(String name, boolean inited, ClassLoader classloader);

有一系列ClassLoader的子类处理类加载的各种特殊情况——它们适应类加载器的层次结构。

类加载器层次结构

JVM 有一个类加载器的层次结构;系统中的每个类加载器(除了初始的“引导”类加载器)都有一个可以委托的父加载器。

注意

Java 9 中模块的引入影响了类加载的细节操作方式。特别是,加载 JRE 类的类加载器现在是模块化类加载器

惯例是类加载器将请求其父加载器来解析和加载一个类,如果只有父类加载器无法遵守,它将自己执行这个任务。一些常见的类加载器显示在图 11-2 中。

JN7 1102

图 11-2. 类加载器层次结构

引导类加载器

这是任何 JVM 进程中出现的第一个类加载器,仅用于加载核心系统类。在较早的文本中,它有时被称为原始类加载器,但现代用法更倾向于使用引导名称。

出于性能原因,引导类加载器不执行验证,并依赖于引导类路径的安全性。由引导类加载器加载的类型隐含地被授予所有安全权限,因此这组模块尽可能地保持受限。

平台类加载器

这个类加载器层次结构的这个级别最初被用作扩展类加载器,但这种机制现在已被移除。

在其新角色中,这个类加载器(其父类加载器为引导类加载器)现在被称为平台类加载器。它可以通过方法ClassLoader::getPlatformClassLoader获得,并且从 Java 9 版开始出现在(和被)Java 规范中。它从基本系统中加载剩余的模块(相当于旧版 Java 8 及更早版本中使用的rt.jar)。

在新的 Java 模块化实现中,启动 Java 进程需要的代码大大减少;因此,尽可能多的 JDK 代码(现在表示为模块)已从引导加载器的范围移出,并移到了平台加载器中。

应用程序类加载器

历史上,有时被称为系统类加载器,但这是一个不好的名字,因为它并不加载系统(引导加载器和平台类加载器负责)。相反,它是从模块路径或类路径加载应用程序代码的类加载器。它是最常遇到的类加载器,并且其父加载器是平台类加载器。

在执行类加载时,应用程序类加载器首先搜索模块路径上的命名模块(任何三个内置类加载器中已知的模块)。如果请求的类在已知的某个这些类加载器的模块中找到,则该类加载器将加载该类。如果在任何已知的命名模块中找不到该类,则应用程序类加载器委托给其父加载器(平台类加载器)。如果父加载器未能找到该类,则应用程序类加载器搜索类路径。如果在类路径上找到该类,则作为应用程序类加载器的未命名模块的成员加载。

应用程序类加载器被广泛使用,但许多高级 Java 框架需要主类加载器无法提供的功能。相反,需要对标准类加载器进行扩展。这形成了“自定义类加载”的基础,依赖于实现ClassLoader的新子类。

自定义类加载器

在执行类加载时,迟早我们必须将数据转换为代码。如前所述,defineClass()(实际上是一组相关方法)负责将byte[]转换为类对象。

这个方法通常由子类调用,例如,这个简单的自定义类加载器从磁盘上的文件创建一个类对象:

public static class DiskLoader extends ClassLoader {
  public DiskLoader() {
    super(DiskLoader.class.getClassLoader());
  }

  public Class<?> loadFromDisk(String clzPath) throws IOException {
    byte[] b = Files.readAllBytes(Paths.get(clzPath));

    return defineClass(null, b, 0, b.length);
  }
}

注意,在前面的例子中,我们不需要将类文件放在磁盘上的“正确”位置,就像我们在URLClassLoader示例中所做的那样。

我们需要提供一个类加载器来作为任何自定义类加载器的父加载器。在这个例子中,我们提供了加载DiskLoader类的类加载器(通常是应用程序类加载器)。

自定义类加载是 Java EE 和高级 SE 环境中非常常见的技术,它为 Java 平台提供了非常复杂的能力。我们将在本章后面看到自定义类加载的示例。

动态类加载的一个缺点是,当与动态加载的类对象一起工作时,通常对该类的信息很少或没有。为了有效地使用这个类,因此我们将不得不使用一组称为反射的动态编程技术。

反射

反射是在运行时检查、操作和修改对象的能力。这包括修改它们的结构和行为,甚至自我修改。

警告

Java 模块系统对平台上的反射工作引入了重大变化。重要的是,在理解模块如何工作及两种能力如何交互之后,重新阅读本节。有关模块如何限制反射的详细信息,请参见“开放模块”。

反射能够在编译时甚至不知道类型和方法名称的情况下工作。它使用类对象提供的基本元数据,并且可以从类对象中发现方法或字段名称,然后获取表示方法或字段的对象。

实例也可以通过反射方式构造(使用Class::newInstance()或另一个构造函数)。通过一个反射构造的对象和一个Method对象,我们可以在先前未知类型的对象上调用任何方法。

这使得反射成为一种非常强大的技术,因此重要的是要理解何时应该使用它,以及何时它过于复杂。

何时使用反射

许多,如果不是大多数,Java 框架在某种程度上使用反射。编写足够灵活以处理运行时未知代码的架构通常需要反射。例如,插件架构、调试器、代码浏览器和类似读取-评估-打印循环(REPL)的环境通常是在反射之上实现的。

反射在测试中也被广泛使用(例如,通过 JUnit 和 TestNG 库),以及用于模拟对象的创建。如果你使用过任何一种 Java 框架,你几乎肯定在使用反射代码,即使你没有意识到它。

要在自己的代码中开始使用反射 API,最重要的是意识到它是关于访问几乎不知道信息的对象,并且由于这一点,交互可能会很繁琐。

如果对动态加载的类有一些静态信息(例如,加载的所有类都实现了已知接口),这可以极大地简化与类的交互,并减少反射操作的负担。

常见的错误是尝试创建一个反射框架,试图涵盖所有可能的情况,而不是只处理与问题域直接相关的情况。

如何使用反射

任何反射操作的第一步是获取代表要操作的类型的Class对象。从这个对象可以访问并应用于未知类型的实例的其他对象,如字段、方法或构造函数。

如果我们已经有一个未知类型的实例,可以通过Object::getClass()方法检索其类。或者,也可以通过类加载的静态Class.forName()方法在“应用类加载”中进行Class对象的查找:

var clzForInstance = "Hi".getClass();
var clzForName = Class.forName("java.lang.String");

一旦我们有一个Class对象的实例,下一个合理的步骤就是通过反射调用方法。Method对象是 Reflection API 提供的一些最常用的对象之一。我们将详细讨论它们——ConstructorField对象在许多方面都很相似。

方法对象

类对象包含每个类上的Method对象。这些在类加载后延迟创建,因此它们在 IDE 的调试器中并不立即可见。

Class上的方法允许我们检索(并在必要时惰性初始化)这些Method对象:

var clz = Class.forName("java.lang.String");

// Returns list of all publicly visible methods on clz
var publicMethods = clz.getMethods();

// Returns named method from clz, or throws
var toString = clz.getMethod("toString", new Class[] {});

getMethod()的第二个参数接受一个Class对象数组,表示方法的参数,以区分方法重写。

此处演示的代码将仅列出并查找Class对象上的公共方法。有一些类似getDeclaredMethod形式的替代方法,可以访问受保护和私有方法。我们很快将有更多关于使用这些机制来绕过 Java 访问模型的内容。

像任何良好的 Java 对象一样,Method提供了所有关于方法的相关信息的访问器。让我们看看我们可以检索到的关于方法的最关键的元数据:

var clz = Class.forName("java.lang.String");
var toString = clz.getMethod("toString", new Class[] {});

// The method's name
String name = toString.getName();

// Generic type information for the method
TypeVariable[] typeParams = toString.getTypeParameters();

// List of method annotations with RUNTIME retention
Annotation[] ann = toString.getAnnotations();

// List of checked exception types declared by method
Class[] exceptions = toString.getExceptionTypes();

// List of Parameter objects for callling the method
Parameter[] params = toString.getParameters();

// List of just the `Class` for each parameter to the method
Class[] paramTypes = toString.getParameterTypes();

// Class of the method's return type
Class ret = toString.getReturnType();

我们可以通过调用访问器方法来探索Method对象的元数据,但远远最大的用例是反射调用Method

这些对象所代表的方法可以通过反射使用Method上的invoke()方法执行。

String对象上调用hashCode()的示例如下:

Object rcvr = "a";
try {
  Class<?>[] argTypes = new Class[] { };
  Object[] args = null;

  Method meth = rcvr.getClass().getMethod("hashCode", argTypes);
  Object ret = meth.invoke(rcvr, args);
  System.out.println(ret);

} catch (IllegalArgumentException | NoSuchMethodException |
         SecurityException e) {
  e.printStackTrace();
} catch (IllegalAccessException | InvocationTargetException x) {
  x.printStackTrace();
}

请注意,rcvr的静态类型声明为Object。在反射调用期间没有使用静态类型信息。invoke()方法还返回Object,因此hashCode()的实际返回类型已经自动装箱为Integer

这种自动装箱是 Reflection 的一个方面,您可以看到 API 的一些轻微笨拙之处——我们将在即将到来的部分讨论。

使用反射创建实例

如果您想创建Class对象的新实例,您会发现方法查找并不起作用。我们的构造函数没有那些 API 能够找到的名称。

在没有参数的构造函数的最简单情况下,可以通过Class对象获取助手:

Class<?> clz = ... // Get some class object
Object rcvr = clz.getDeclaredConstructor().newInstance();

对于接受参数的构造函数,Class 类有像 getConstructor 这样的方法,允许找到你需要的覆盖方法。虽然它们返回一个单独的 Constructor 类型,但使用它们与我们已经看到的与 Method 对象交互非常相似。

让我们看一个扩展示例,并看看如何将反射与自定义类加载结合起来,以检查磁盘上的类文件是否有任何已过时的方法(这些方法应标记为 @Deprecated):

public class CustomClassloadingExamples {
    public static class DiskLoader extends ClassLoader {

        public DiskLoader() {
            super(DiskLoader.class.getClassLoader());
        }

        public Class<?> loadFromDisk(String clzName)
          throws IOException {
            byte[] b = Files.readAllBytes(Paths.get(clzName));

            return defineClass(null, b, 0, b.length);
        }
    }

    public void findDeprecatedMethods(Class<?> clz) {
        for (Method m : clz.getMethods()) {
            for (Annotation a : m.getAnnotations()) {
                if (a.annotationType() == Deprecated.class) {
                    System.out.println(m.getName());
                }
            }
        }
    }

    public static void main(String[] args)
      throws IOException, ClassNotFoundException {
        var rfx = new CustomClassloadingExamples();

        if (args.length > 0) {
            DiskLoader dlr = new DiskLoader();
            Class<?> clzToTest = dlr.loadFromDisk(args[0]);
            rfx.findDeprecatedMethods(clzToTest);
        }
    }
}

这展示了反射技术的一些强大之处,但是使用 API 也会带来一些问题。

反射的问题

Java 的反射 API 常常是处理动态加载代码的唯一途径,但 API 中的一些恼人之处可能会使其处理起来稍显麻烦:

  • 大量使用 Object[] 来表示调用参数和其他实例。

  • 还有在讨论类型时使用 Class[] 的情况。

  • 方法可以根据名称重载,因此我们需要一个类型数组来区分方法。

  • 表示原始类型可能会有问题——我们必须手动装箱和拆箱。

void 是一个特殊的问题——有一个 void.class,但它的使用不一致。Java 实际上不知道 void 是否是一种类型,反射 API 中的某些方法使用 null 而不是它。

这很麻烦,可能会出错——特别是,Java 数组语法的轻微冗长可能会导致错误。

另一个问题是对非 public 方法的处理。如前所述,我们不能使用 getMethod(),必须使用 getDeclaredMethod() 来获取非 public 方法的引用。此外,要调用非 public 方法,我们必须重写 Java 访问控制子系统,调用 setAccessible() 以允许其执行:

public class MyCache {
  private void flush() {
    // Flush the cache...
  }
}

Class<?> clz = MyCache.class;
try {
  Object rcvr = clz.newInstance();
  Class<?>[] argTypes = new Class[] { };
  Object[] args = null;

  Method meth = clz.getDeclaredMethod("flush", argTypes);
  meth.setAccessible(true);
  meth.invoke(rcvr, args);
} catch (IllegalArgumentException | NoSuchMethodException |
         InstantiationException | SecurityException e) {
  e.printStackTrace();
} catch (IllegalAccessException | InvocationTargetException x) {
  x.printStackTrace();
}

因为反射总是涉及未知信息,我们只能接受一些冗长。这是使用动态运行时反射调用的代价。

动态代理

Java 反射故事的最后一部分是创建动态代理。这些是类(扩展自 java.lang.reflect.Proxy),实现多个接口。实现类在运行时动态构造,并将所有调用转发到一个调用处理器对象:

InvocationHandler handler = (proxy, method, args) -> {
    String name = method.getName();
    System.out.println("Called as: "+ name);
    return switch (name) {
        case "isOpen" -> Boolean.TRUE;
        case "close" -> null;
        default -> null;
    };
};

Channel c = (Channel) Proxy.newProxyInstance(
        Channel.class.getClassLoader(),
        new Class[] { Channel.class },
        handler);
System.out.println("Open? "+ c.isOpen());
c.close();

代理可以用作测试的替代对象(尤其是在测试模拟方法中)。

另一个用例是提供接口的部分实现,或者装饰或以其他方式控制委托的某些方面:

public class RememberingList implements InvocationHandler {
  private final List<String> proxied = new ArrayList<>();

  @Override
  public Object invoke(Object proxy, Method method, Object[] args)
                         throws Throwable {
    String name = method.getName();
    switch (name) {
      case "clear":
        return null;
      case "remove":
      case "removeAll":
        return false;
    }

    return method.invoke(proxied, args);
  }
}

RememberingList hList = new RememberingList();

var l = (List<String>) Proxy.newProxyInstance(
                                List.class.getClassLoader(),
                                new Class[] { List.class },
                                hList);
l.add("cat");
l.add("bunny");
l.clear();
System.out.println(l);

代理是许多 Java 框架中广泛使用的一种强大而灵活的能力。

方法句柄

在 Java 7 中,引入了一种全新的用于内省和方法访问的机制。最初设计用于动态语言,在运行时可能需要参与方法分派决策。为了在 JVM 级别支持这一点,引入了新的invokedynamic字节码。这个字节码在 Java 7 本身中没有使用,但随着 Java 8 的到来,它在 lambda 表达式和 Nashorn JavaScript 实现中得到了广泛应用。

即使没有invokedynamic,新的方法句柄 API 在许多方面与反射 API 相比具有相似的功能强大性,并且即使是独立使用时也可能更加清晰和概念上更简单。可以将其视为以更安全、更现代的方式完成的反射。

MethodType

在反射中,方法签名表示为Class[],这相当繁琐。相比之下,方法句柄依赖于MethodType对象。这是一种类型安全且面向对象的方法,用于表示方法的类型签名。

它们包括返回类型和参数类型,但不包括方法的接收器类型或名称。名称不存在,因为这允许将任何具有正确签名的方法绑定到任何名称(根据 lambda 表达式的函数接口行为)。

方法的类型签名表示为MethodType的不可变实例,可以通过工厂方法MethodType.methodType()获得。methodType()的第零个参数是方法的返回类型,其后是方法参数的类型。

例如:

// Matching method type for toString()
MethodType m2Str = MethodType.methodType(String.class);

// Matching method type for Integer.parseInt()
MethodType mtParseInt =
  MethodType.methodType(Integer.class, String.class);

// Matching method type for defineClass() from ClassLoader
MethodType mtdefClz = MethodType.methodType(Class.class, String.class,
                                            byte[].class, int.class,
                                            int.class);

这个单一的谜题部分提供了比反射更显著的增益,因为它显著地简化了方法签名的表示和讨论。下一步是获得方法的句柄。这是通过查找过程实现的。

方法查找

方法查找查询是在定义方法的类上执行的,并且依赖于执行它们的上下文:

// String.toString only has return type with no parameter
MethodType mtToString = MethodType.methodType(String.class);

try {
  Lookup l = MethodHandles.lookup();
  MethodHandle mh = l.findVirtual(String.class, "toString",
                                  mtToString);
  System.out.println(mh);
} catch (NoSuchMethodException | IllegalAccessException e) {
  e.printStackTrace();
}

我们始终需要调用MethodHandles.lookup()—这会给我们一个基于当前执行方法的查找上下文对象。

查找对象上有几种方法(全部以find开头)声明用于方法解析。这些包括findVirtual()findConstructor()findStatic()

反射和方法句柄 API 之间的一个重大区别是访问控制。一个Lookup对象只会返回在创建查找的上下文中可访问的方法,并且没有办法绕过这一点(没有类似于反射中的setAccessible()的黑客方法)。

例如,我们可以看到,当我们尝试从通用查找上下文查找受保护的ClassLoader::defineClass()方法时,由于受保护的方法不可访问,我们未能解析它,导致IllegalAccessException的抛出:

public static void lookupDefineClass(Lookup l) {
  MethodType mt = MethodType.methodType(Class.class, String.class,
                                        byte[].class, int.class,
                                        int.class);

  try {
    MethodHandle mh =
      l.findVirtual(ClassLoader.class, "defineClass", mt);
    System.out.println(mh);
  } catch (NoSuchMethodException | IllegalAccessException e) {
    e.printStackTrace();
  }
}

Lookup l = MethodHandles.lookup();
lookupDefineClass(l);

因此,方法句柄始终符合安全管理器的要求,即使相应的反射代码没有这样做。它们在构造查找上下文的地方进行访问检查——查找对象不会返回对其没有适当访问权限的方法句柄。

查找对象或从其派生的方法句柄可以返回到其他上下文,包括那些不再可能访问该方法的上下文。在这些情况下,句柄仍然是可执行的——访问控制在查找时检查,正如我们在此示例中所见:

public class SneakyLoader extends ClassLoader {
  public SneakyLoader() {
    super(SneakyLoader.class.getClassLoader());
  }

  public Lookup getLookup() {
    return MethodHandles.lookup();
  }
}

SneakyLoader snLdr = new SneakyLoader();
l = snLdr.getLookup();
lookupDefineClass(l);

使用Lookup对象,我们能够生成对任何我们可以访问的方法的方法句柄。我们还可以生成一种访问可能没有给予访问权限的字段的方式。Lookup上的findGetter()findSetter()方法生成可以根据需要读取或更新字段的方法句柄。

调用方法句柄

方法句柄代表调用方法的能力。它们是强类型的,并尽可能类型安全。所有实例都是java.lang.invoke.MethodHandle的某个子类,这是 JVM 需要特殊处理的类。

调用方法句柄有两种方式——invoke()invokeExact()。这两种方法都将接收器和调用参数作为参数。invokeExact()尝试直接调用方法句柄本身,而invoke()则在需要时调整调用参数。

一般而言,如果必要,invoke()执行asType()转换——这将根据以下规则转换参数:

  • 如果需要,原始参数将被装箱。

  • 如果需要,装箱的原始类型将被取消装箱。

  • 如果需要,原始类型将被扩展。

  • 返回类型为void的情况将会根据预期返回的是基本类型还是引用类型而转换为 0 或null

  • null值将被传递,而不考虑静态类型。

有了这些潜在的转换,调用看起来像这样:

Object rcvr = "a";
try {
  MethodType mt = MethodType.methodType(int.class);
  MethodHandles.Lookup l = MethodHandles.lookup();
  MethodHandle mh = l.findVirtual(rcvr.getClass(), "hashCode", mt);

  int ret;
  try {
    ret = (int)mh.invoke(rcvr);
    System.out.println(ret);
  } catch (Throwable t) {
    t.printStackTrace();
  }
} catch (IllegalArgumentException |
  NoSuchMethodException | SecurityException e) {
  e.printStackTrace();
} catch (IllegalAccessException x) {
  x.printStackTrace();
}

方法句柄提供了一种更清晰和更连贯的方式来访问与反射相同的动态编程能力。此外,它们设计用于与 JVM 的低级执行模型良好配合,因此比反射提供的性能要好得多。

¹ 正如在第六章中,我们从称为图论的数学分支借用了表达式传递闭包

第十二章:Java 平台模块

在本章中,我们将对 Java 平台模块系统(JPMS)进行基本介绍。然而,这是一个大而复杂的主题——有兴趣的读者可能需要更深入的参考资料,如《Java 9 Modularity》(O'Reilly)的 Sander Mak 和 Paul Bakker。

模块,作为一个相对先进的特性,主要用于打包和部署整个应用程序及其依赖项。它们被添加到平台大约在 Java 第一个版本之后的 20 年左右,因此可以看作是与语言语法的其他部分正交的。

Java 强烈推广向后兼容性,在这里也发挥了作用,因为非模块化应用必须继续运行。这导致 Java 平台的架构师和管理者采取了一种务实的观点,认为团队采用模块化的必要性。

没有必要切换到模块。

从未有过必要切换到模块。

Java 9 及以后的版本支持传统的 JAR 文件在传统的类路径上,通过未命名模块的概念,可能会一直持续到宇宙的热死亡。

是否开始使用模块完全取决于您自己。

Mark Reinhold

oreil.ly/4RjDH

由于模块的先进性质,本章假定您熟悉现代 Java 构建工具,如 Gradle 或 Maven。

如果您是 Java 的新手,可以安全地忽略对这些工具的引用,只需阅读本章节,以获取对 JPMS 的首次高级概述。新的 Java 程序员在学习如何编写 Java 程序时,不需要完全理解这个主题。

为什么要使用模块?

添加模块到 Java 平台的几个主要动机包括:

  • 强封装

  • 明确定义的接口

  • 明确的依赖关系

这些都是语言(和应用程序设计)级别的,并且它们与新平台级别的能力承诺相结合:

  • 可扩展的开发

  • 改进的性能(特别是启动时间)和减少的占用空间

  • 减少攻击面和提高安全性

  • 可演化的内部

封装点是由于原始语言规范仅支持私有、公共、受保护和包私有的可见性级别。没有办法以更精细的方式控制访问,以表达诸如以下概念:

  • 只有指定的包可作为 API 访问——其他包是内部的,可能无法访问

  • 某些包可以通过此包列表访问,但不包括其他包

  • 定义严格的导出机制

当设计更大型的 Java 系统时,缺乏这些及相关能力一直是一个重大缺点。而且,没有适当的保护机制,JDK 内部的演进将非常困难——因为没有任何东西阻止用户应用程序直接访问实现类。

模块系统试图一次性解决所有这些问题,并提供适用于 JDK 和用户应用程序的解决方案。

将 JDK 模块化

随着模块系统的推出,Java 8 随附的单片式 JDK 成为首个目标,熟悉的 rt.jar 被分解为模块。

Java 8 已经开始模块化工作,通过提供称为紧凑配置的功能,整理代码并使其可以以减少的运行时占用空间进行部署。

java.base 是代表 Java 应用程序启动实际所需的最小模块。它包含核心包,例如:

java.io
java.lang
java.math
java.net
java.nio
java.security
java.text
java.time
java.util
javax.crypto
javax.net
javax.security

以及一些子包和非导出实现包,例如 sun.text.resources。可以通过这个简单的程序看到 Java 8 和模块化 Java 之间编译行为的一些差异,它扩展了包含在 java.base 中的内部公共类:

import java.util.Arrays;
import sun.text.resources.FormatData;

public final class FormatStealer extends FormatData {
    public static void main(String[] args) {
        FormatStealer fs = new FormatStealer();
        fs.run();
    }

    private void run() {
        String[] s = (String[]) handleGetObject("japanese.Eras");
        System.out.println(Arrays.toString(s));

        Object[][] contents = getContents();
        Object[] eraData = contents[14];
        Object[] eras = (Object[])eraData[1];
        System.out.println(Arrays.toString(eras));
    }
}

在 Java 11 上尝试编译代码会产生以下错误消息:

$ javac javanut8/ch12/FormatStealer.java
javanut8/ch12/FormatStealer.java:4:
        error: package sun.text.resources is not visible
import sun.text.resources.FormatData;
               ^
  (package sun.text.resources is declared in module
        java.base, which does not export it to the unnamed module)
javanut8/ch12/FormatStealer.java:14: error: cannot find symbol
        String[] s = (String[]) handleGetObject("japanese.Eras");
                                ^
  symbol:   method handleGetObject(String)
  location: class FormatStealer
javanut8/ch12/FormatStealer.java:17: error: cannot find symbol
        Object[][] contents = getContents();
                              ^
  symbol:   method getContents()
  location: class FormatStealer
3 errors

在模块化的 Java 中,即使是公共的类也不能访问,除非它们被定义的模块明确导出。我们可以使用 --add-exports 开关临时强制编译器使用内部包(基本上是重新确认旧的访问规则),像这样:

$ javac --add-exports java.base/sun.text.resources=ALL-UNNAMED \
        javanut8/ch12/FormatStealer.java
javanut8/ch12/FormatStealer.java:5:
        warning: FormatData is internal proprietary API and may be
        removed in a future release
import sun.text.resources.FormatData;
                         ^
javanut8/ch12/FormatStealer.java:7:
        warning: FormatData is internal proprietary API and may be
        removed in a future release
public final class FormatStealer extends FormatData {
                                         ^
2 warnings

我们需要指定导出被授予给未命名模块,因为我们正在编译我们的类独立地,而不是作为模块的一部分。编译器会警告我们正在使用内部 API,并且这可能会在将来的 Java 版本中中断。在 Java 11 下编译和运行时,这将产生一个日本时代列表,如下所示:

[, Meiji, Taisho, Showa, Heisei, Reiwa]
[, Meiji, Taisho, Showa, Heisei, Reiwa]

然而,如果我们尝试在 Java 17 下运行,那么结果就会有所不同:

$ java javanut8.ch12.FormatStealer

Error: LinkageError occurred while loading main class
        javanut8.ch12.FormatStealer

java.lang.IllegalAccessError: superclass access check failed:

class javanut8.ch12.FormatStealer (in unnamed module @0x647c3190)
        cannot access class sun.text.resources.FormatData (in module
        java.base) because module java.base does not export
        sun.text.resources to unnamed module @0x647c3190

这是因为 Java 17 现在在强化内部封装时执行了额外的检查。为了使程序运行,我们还需要添加 --add-exports 运行时标志:

$ java --add-exports java.base/sun.text.resources=ALL-UNNAMED \
        javanut8.ch12.FormatStealer
[, Meiji, Taisho, Showa, Heisei, Reiwa]
[, Meiji, Taisho, Showa, Heisei, Reiwa]

虽然 java.base 是应用程序启动所需的绝对运行时最小值,但在编译时,我们希望可见平台尽可能接近旧版(Java 8)的体验。

这意味着我们使用了一个更大的模块集,包含在一个总称模块下,即 java.se。该模块有一个依赖关系图,如 图 12-1 所示。

JN7 1201

图 12-1. java.se 的模块依赖关系图

这几乎包含了大多数 Java 开发人员期望可用的所有类和包。

然而,一个重要的例外是,定义 CORBA 和 Java EE API(现在称为 Jakarta EE)的 Java 8 包已被移除,并且不包含在 java.se 中。这意味着任何依赖于这些 API 的项目在 Java 11 及以后版本上默认情况下将无法编译,并且必须使用特殊的构建配置,显式依赖于提供这些 API 的外部库。

由于 JDK 的模块化,编译可见性也发生了变化,模块系统还旨在允许开发人员对其自己的代码进行模块化。

撰写您自己的模块

在本节中,我们将讨论启动编写模块化 Java 应用程序所需的基本概念。

基本模块语法

模块化的关键在于新文件module-info.java,它包含了一个模块的描述。这被称为模块描述符

模块在文件系统上的正确布局方式如下:

  • 在项目的源根目录(src)下面,需要有一个与模块同名的目录(moduledir)。

  • moduledir目录中,module-info.java位于与包开始的相同级别。

模块信息编译为二进制格式,module-info.class,其中包含模块运行时链接和运行应用程序时使用的元数据。让我们看一个module-info.java的简单示例:

module httpchecker {
    requires java.net.http;

    exports httpchecker.main;
}

这引入了一些新语法:moduleexportsrequires,但实际上这些并不是完全的关键字。正如 Java 语言规范 SE 11 中所述:

还有十个字符序列是受限关键字:openmodulerequirestransitiveexportsopenstousesprovideswith。这些字符序列仅在它们作为ModuleDeclarationModuleDirective产生式中的终端出现时被标记为关键字。

这意味着这些受限关键字只能出现在模块元数据中,并且由javac编译为二进制格式。主要受限关键字的含义是:

module

开始模块的元数据声明

requires

列出此模块依赖的模块

exports

声明导出哪些包作为 API

剩余的(与模块相关的)受限关键字将在本章的其余部分介绍。

注意

在 Java 17 中,受限关键字的概念得到了显著扩展,因此描述更长,更不清晰。在这里,我们使用旧规范,因为它特指模块系统,更适合我们的目的。

在我们的示例中,这意味着我们声明了一个模块httpchecker,它依赖于 Java 11 中标准化的模块java.net.http(以及对java.base的隐式依赖)。该模块导出一个单独的包httpchecker.main,这是此模块中唯一可以在编译时从其他模块访问的包。

构建一个简单的模块化应用程序

例如,让我们构建一个简单的工具,检查网站是否已经使用了 HTTP/2,使用我们在第十章中遇到的 API:

import static java.net.http.HttpResponse.BodyHandlers.ofString;

public final class HTTP2Checker {
    public static void main(String[] args) throws Exception {
        if (args.length == 0) {
            System.err.println("Provide URLS to check");
        }
        for (final var location : args) {
            var client = HttpClient.newBuilder().build();
            var uri = new URI(location);
            var req = HttpRequest.newBuilder(uri).build();

            var response = client.send(req,
                    ofString(Charset.defaultCharset()));
            System.out.println(location +": "+ response.version());
        }
    }
}

这依赖于两个模块——java.net.http和无处不在的java.base。应用程序的模块文件非常简单:

module http2checker {
    requires java.net.http;
    exports httpchecker.main;
}

假设一个简单的标准模块布局,可以像这样进行编译:

$ javac -d out/httpchecker \
        httpchecker/httpchecker/main/HTTP2Checker.java \
        httpchecker/module-info.java

这将在*out/*目录中创建一个已编译的模块。为了使用,它需要被打包为一个 JAR 文件:

$ jar --create --file httpchecker.jar \
        --main-class httpchecker.main.HTTP2Checker \
        -C out/httpchecker .

--create开关告诉jar创建一个新的 jar,其中将包含目录中包含的类。命令末尾的最后一个.是强制性的,并表示应将所有类文件(相对于使用-C指定的路径)打包到 jar 中。

我们使用--main-class开关来设置模块的入口点—​即,在我们将模块用作应用程序时要执行的类。让我们看看它的工作原理:

$ java -jar httpchecker.jar http://www.google.com
http://www.google.com: HTTP_1_1
$ java -jar httpchecker.jar https://www.google.com
https://www.google.com: HTTP_2

这表明,撰写时,谷歌的网站使用 HTTP/2 通过 HTTPS 提供其主页,但仍然使用 HTTP/1.1 提供遗留的未加密 HTTP 服务。

现在我们已经看到了如何编译和运行一个简单的模块化应用程序,让我们了解一些构建和运行全尺寸应用程序所需的核心模块化功能。

模块路径

许多 Java 开发人员熟悉类路径的概念。在使用模块化 Java 应用程序时,我们需要使用模块路径。这是一种新的模块概念,尽可能替换类路径。

模块携带关于其导出和依赖项的元数据—​它们不仅仅是类型的长列表。这意味着可以轻松构建模块依赖关系图,并且模块解析可以有效进行。

尚未模块化的代码仍然放置在类路径上。此代码加载到未命名模块中,该模块是特殊的,并且可以读取从java.se可达的所有其他模块。当将类放置在类路径上时,将自动使用未命名模块。

这为采用模块化 Java 运行时提供了迁移路径,而无需迁移到完全模块化的应用程序路径。然而,它确实有两个主要缺点:在应用程序完全迁移之前,模块的任何好处都不可用,并且必须手动维护类路径的自一致性,直到模块化完成。

自动模块

模块系统的约束之一是我们不能从命名模块引用类路径上的 JAR。这是一个安全特性—​模块系统的设计者希望模块依赖关系图利用完整的元数据,并且能够依赖于该元数据的完整性。

然而,可能会有时候模块化的代码需要引用尚未模块化的包。解决此问题的方法是将未修改的 JAR 直接放置在模块路径上(并从类路径中删除它)。像这样放置在模块路径上的 JAR 成为自动模块

它具有以下功能:

  • 模块名称源自 JAR 名称(或从MANIFEST.MF中读取)

  • 导出每个包

  • 需要所有其他模块(包括未命名模块)

这是另一个旨在缓解和帮助迁移的功能,但通过使用自动模块仍然会放弃一些安全性。

开放模块

如前所述,仅仅将一个方法标记为public不再保证该元素在任何地方都可访问。相反,现在访问性也取决于定义模块是否导出包含该元素的包。模块设计中的另一个重要问题是使用反射访问类。

反射是一个广泛而通用的机制,初看起来很难看出它如何与 JPMS 的强封装目标相 reconciled。更糟糕的是,Java 生态系统中许多重要的库和框架都依赖于反射(例如单元测试、依赖注入等),如果没有反射的解决方案,模块对于任何真实应用都将不可能采用。

提供的解决方案是双重的。首先,一个模块可以声明自己是一个open模块,像这样:

open module jin8 {
    exports jin8.api;
}

这种声明具有以下效果:

  • 模块中的所有包都可以通过反射访问

  • 编译时访问提供给非导出包。

这意味着配置在编译时的行为就像一个标准模块一样。总体意图是提供与现有代码和框架的简单兼容性,并减少迁移的痛苦。对于开放模块,先前能够通过反射访问代码的期望得到恢复。此外,允许访问private和其他通常不允许访问的方法的setAccessible() hack 也被保留了。

通过opens限制关键字还提供了对反射访问的更细粒度控制。这不会创建一个开放模块,而是通过显式声明某些包可以通过反射访问来选择性地开放特定包:

module ojin8 {
    exports ojin8.api;
    opens ojin8.domain;
}

当你需要为一个模块感知的对象-关系映射(ORM)系统提供完整的反射访问以获取核心域类型时,这种用法可能非常有用。

可以进一步限制对特定客户端包的反射访问,使用to关键字来实现。在可行的情况下,这可以作为一个良好的设计原则,但当然这种技术在像 ORM 这样的通用框架中效果不佳。

注意

类似地,还可以限制包的导出仅针对特定外部包。然而,此功能主要是为了帮助 JDK 本身的模块化而添加的,对用户模块的适用性有限。

不仅如此,还可以同时导出和开放一个包,但这不被推荐——在迁移期间,最好将对包的访问限制在编译时或反射访问中,而不是同时两者。

如果现在一个模块内包含一个需要反射访问的包,则平台提供了一些开关作为过渡期的应急措施。

特别是,java 选项 --add-opens module/package=ALL-UNNAMED 可以用来打开模块的特定包,以便对类路径中所有代码进行反射访问,覆盖模块系统的行为。对于已经是模块化的代码,它也可以用来允许对特定模块进行反射访问。

当您正在迁移到模块化的 Java 时,任何反射访问另一个模块的内部代码的代码应该首先使用该开关运行,直到情况得到解决。

与反射访问的这个问题相关(也是它的特殊情况)是框架广泛使用内部平台 API 的问题。这通常被描述为 “Unsafe 问题”,我们将在本章末尾遇到它。

提供服务

模块系统包含了 服务 机制,以缓解封装的高级形式的另一个问题。这个问题可以通过考虑一个熟悉的代码片段来简单地解释:

import com.example.Service;

Service s = new ServiceImpl();

即使 Service 存在于一个已导出的 API 包中,这行代码也不会编译通过,除非包含 ServiceImpl 的包也被导出。我们需要的是一种机制,允许对实现服务类的类进行细粒度的访问,而无需导入整个包。例如,我们可以这样写:

module jin8 {
    exports jin8.api;
    requires othermodule.services;

    provides services.Service with jin8.services.ServiceImpl;
}

现在 ServiceImpl 类在编译时作为 Service 接口的实现可访问。请注意,services 包必须包含在另一个模块中,这是当前模块所需要的,以便此提供可以工作。

多版本 JAR

为了解释多版本 JAR 解决的问题,让我们考虑一个简单的例子:找到当前执行进程的进程 ID(PID)(即,执行我们代码的 JVM)。

注意

我们不使用之前的 HTTP/2 示例,因为 Java 8 没有 HTTP/2 API,所以我们需要做大量的工作(基本上是完整的后向兼容!)来为 8 提供相同的功能。

这可能看起来是一个简单的任务,但在 Java 8 上,这需要大量的样板代码:

public class GetPID {
    public static long getPid() {
        // This rather clunky call uses JMX to return the name that
        // represents the currently running JVM. This name is in the
        // format <pid>@<hostname>—on OpenJDK and Oracle VMs only—there
        // is no guaranteed portable solution for this on Java 8
        final String jvmName = 
            ManagementFactory.getRuntimeMXBean().getName();
        final int index = jvmName.indexOf('@');
        if (index < 1)
            return -1;

        try {
            return Long.parseLong(jvmName.substring(0, index));
        } catch (NumberFormatException nfe) {
            return -1;
        }
    }
}

如我们所见,这一点远非我们所希望的那么简单。更糟糕的是,它在所有 Java 8 实现中都没有标准的支持方式。幸运的是,从 Java 11 开始,我们可以使用新的 ProcessHandle API,如下所示:

public class GetPID {
    public static long getPid() {
        // Use new Java 9 Process API...
        ProcessHandle processHandle = ProcessHandle.current();
        return processHandle.getPid();
    }
}

现在这使用了一个标准的 API,但它引发了一个基本问题:开发人员如何编写能够在所有当前 Java 版本上运行的代码?

我们想要的是正确构建和运行一个项目在多个 Java 版本上。我们想依赖于仅在较新版本中可用的库类,但仍然可以通过使用一些代码 “shims” 在较早版本上运行。最终结果必须是一个单独的 JAR,并且我们不需要项目切换到多模块格式——事实上,这个 JAR 必须作为自动模块工作。

让我们来看一个例子项目,它必须在 Java 8 和 Java 11 或更高版本中都能正确运行。主要代码库是用 Java 8 构建的,Java 11 的部分必须用 Java 11 构建。为了防止编译失败,这部分构建必须与主代码库隔离,尽管它可以依赖于 Java 8 构建的构件。

为了保持构建配置的简单性,此功能是通过 JAR 文件中的 MANIFEST.MF 条目来控制的:

Multi-Release: True

变体代码(即后续版本的代码)然后存储在 META-INF 的特殊目录中。在我们的案例中,这是 META-INF/versions/11

对于实现此功能的 Java 运行时,版本特定目录中的任何类都会覆盖内容根目录中的版本。另一方面,对于 Java 8 及更早版本,MANIFEST.MF 条目和 versions/ 目录都会被忽略,只会找到内容根目录中的类。

转换为多版本 JAR

要开始将您的软件部署为多版本 JAR,请按照以下概要进行操作:

  1. 隔离依赖于 JDK 版本的特定代码。

  2. 如果可能,将该代码放入一个或一组包中。

  3. 使版本 8 项目能够干净地构建

  4. 为补充类创建一个新的独立项目。

  5. 为新项目设置一个单一的依赖项(版本 8 构件)。

对于 Gradle,您还可以使用 source set 的概念,并使用不同(较新)的编译器编译 v11 代码。然后可以使用类似以下的段落将其构建为 JAR 文件:

jar {
  into('META-INF/versions/11') {
     from sourceSets.java11.output
  }

  manifest.attributes(
     'Multi-Release': 'true'
  )
}

对于 Maven,当前最简单的方法是使用 Maven 依赖插件,并在单独的 generate-resources 阶段将模块化类添加到整体 JAR 中。

迁移到模块

许多 Java 开发人员面临一个问题,即是否以及何时应该将他们的应用程序迁移到使用模块。

提示

模块应该成为所有新开发的应用程序的默认选项,特别是那些采用微服务架构的应用。

许多应用程序根本不需要迁移。然而,将现有代码库模块化确实值得,因为更好的封装和整体架构上的好处会在长期内显现出来——可以更快地引入新开发人员,并提供更清晰的结构,易于理解和维护。

在考虑迁移现有应用程序(特别是单体设计)时,可以使用以下路线图:

  1. 首先将应用程序运行时升级到 Java 17(最初从类路径运行)。

  2. 确定已模块化的任何应用程序依赖项,并将这些依赖项迁移到模块中。

  3. 将所有非模块化的依赖项保留为自动模块。

  4. 引入一个单一的 单体模块,包含所有应用程序代码。

此时,一个最小化的模块化应用程序应该准备好进行生产部署。在此阶段,该模块通常会是一个open模块。下一步是架构重构;在此阶段,应用程序可以根据需要拆分为单独的模块。

一旦应用程序代码在模块中运行,限制通过 opens 对您的代码的反射访问可能是有意义的。此访问可以限制在特定模块(如 ORM 或依赖注入模块)作为删除任何不必要访问的第一步。

对于 Maven 用户来说,值得记住 Maven 不是一个模块系统,但它确实有依赖项——并且(与 JPMS 的依赖项不同)它们是有版本的。在撰写本文时,Maven 工具仍在不断发展以完全与 JPMS 集成(许多插件在此时尚未跟进)。然而,正在出现一些关于模块化 Maven 项目的一般指导原则,特别是:

  • 目标是为每个 Maven POM 文件生成一个模块。

  • 不要在 Maven 项目未准备好(或没有立即需要)之前进行模块化。

  • 请记住,在 Java 11+ 运行时上运行不需要在 Java 11+ 工具链上构建。

最后一点表明,将 Maven 项目迁移到 Java 8 项目并确保这些 Maven 构件能够在 Java 11(或 17)运行时上作为自动模块清洁地部署,是迁移 Maven 项目的一种路径。只有在第一步正常工作后,才应进行全面的模块化。

有一些很好的工具支持可帮助进行模块化过程。Java 8 及更高版本附带了jdeps工具(参见 Chapter 13),用于确定您的代码依赖于哪些包和模块。这对于从 Java 8 迁移和在重新架构时使用jdeps非常有帮助。

自定义运行时映像

JPMS 的关键目标之一是应用程序可能不需要 Java 8 传统单片式运行时中的每个类,并且可以管理较小的模块子集。这些应用程序在启动时间和内存开销方面的足迹可以更小。这可以进一步进行:如果不需要所有类,那么为什么不与仅包含必要内容的减小、自定义运行时映像一起提供应用程序呢?

为了演示这个想法,让我们将 HTTP/2 检查器打包成一个独立的工具,并使用jlink工具(自 Java 9 起成为平台的一部分)来实现这一点:

$ jlink --module-path httpchecker.jar:$JAVA_HOME/jmods \
      --add-modules httpchecker,jdk.crypto.ec \
      --launcher http2chk=httpchecker \
      --output http2chk-image

注意,这假设 JAR 文件 httpchecker.jar 已创建了一个主类(即入口点)。结果是一个输出目录 http2chk-image,大小约为 39M,远小于完整映像。这也说明,因为该工具使用新的 HTTP 模块,在使用 HTTPS 连接时需要安全、加密等库。

在自定义镜像目录中,我们可以直接运行http2chk工具,并查看它在机器没有所需版本的java时也可以工作:

$ java -version
java version "1.8.0_144"
Java(TM) SE Runtime Environment (build 1.8.0_144-b01)
Java HotSpot(TM) 64-Bit Server VM (build 25.144-b01, mixed mode)
$ ./bin/http2chk https://www.google.com
https://www.google.com: HTTP_2

自定义运行时镜像的部署仍然是一种相当新的工具,但它具有减少代码占用空间并帮助 Java 在微服务时代保持竞争力的巨大潜力。未来,jlink甚至可以与新的编译方法结合使用,包括提前(AOT)编译器。

模块化的问题

尽管模块系统是 Java 9 的旗舰功能,并且已经花费了大量的工程时间来开发它,但它也不是没有问题。这或许是不可避免的——这一功能从根本上改变了 Java 应用程序的架构和交付方式。当试图为 Java 的成熟生态系统提供后向兼容时,模块几乎不可能避免遇到一些问题。

Unsafe及其相关问题

sun.misc.Unsafe是一个在 Java 世界中被广泛使用和受欢迎的类,特别是在框架编写者和其他实现者中。然而,它是一个内部实现类,并不是 Java 平台标准 API 的一部分(正如包名所清楚表明的)。类名也很明显地表明这实际上不是为 Java 应用程序使用而设计的。

Unsafe是一个不受支持的内部 API,因此可能会在任何新的 Java 版本中被撤回或修改,而不考虑对用户应用程序的影响。任何使用它的代码技术上都直接耦合于 HotSpot JVM,并且可能是非标准的,并且可能无法在其他实现上运行。

虽然Unsafe并非以任何方式成为 Java SE 的官方部分,但已成为事实上的标准,并成为几乎每个主要框架实现的关键部分之一。随着版本的不断更新,它已经演变成为一种非标准但必要功能的垃圾桶。这些功能的混合使用形成了一个真正的混合包,每种能力提供的安全程度各不相同。Unsafe的示例用途包括:

  • 快速序列化/反序列化

  • 线程安全的 64 位大小的本地内存访问(例如,堆外内存)

  • 原子内存操作(例如,比较并交换)

  • 快速字段/内存访问

  • 多操作系统替代 JNI

  • 访问具有 volatile 语义的数组项(参见 第六章)

核心问题在于许多框架和库在没有某些Unsafe功能的替代方案的情况下无法迁移到模块化的 JDK。这反过来影响了使用 Java 生态系统中各种框架的每个应用程序。

为解决这个问题,Oracle 为一些所需功能创建了新的受支持的 API,并将无法及时封装到模块中的 API 分离出来,jdk.unsupported。这清楚地表明这不是一个受支持的 API,开发人员使用它需自担风险。这使得Unsafe获得了临时许可证(严格限制时间),同时鼓励库和框架开发人员转移到新的 API。

替代 API 的一个示例是 VarHandles。这些扩展了Method Handles概念(来自第十一章)并添加了新功能,例如 Java 11 的并发障碍模式。这些,连同对 JMM 的一些适度更新,旨在生成用于访问新低级处理器功能的标准 API,而不允许开发人员完全访问危险功能,就像Unsafe中发现的一样。

关于Unsafe和相关低级平台技术的更多详细信息可在Optimizing Java(O’Reilly)中找到。

缺乏版本控制

Java 17 的 JPMS 标准不包括依赖项的版本控制。

注意

这是一个有意的设计决定,旨在降低交付系统的复杂性,并不排除未来模块可能包含有版本的依赖项的可能性。

当前情况需要外部工具来处理模块依赖项的版本控制。在 Maven 的情况下,这将在项目对象模型(POM)中完成。这种方法的一个优点是下载和管理版本也在构建工具的本地存储库中处理。

但是,无论如何,简单的事实是,依赖版本信息必须存储在模块之外,并且不构成 JAR 工件的一部分。

无法摆脱——这相当丑陋,但与从类路径中推导出依赖项的情况相比,情况并不更糟。

采用率缓慢

随着 Java 9 的发布,Java 发布模型在根本上发生了变化。 Java 8 和 9 使用了“关键发布”模型——其中一个关键(或标志性)功能,如 Java 8 的 lambda 或 Java 9 的模块——基本上定义了发布,因此交付日期取决于功能何时完成。

这种模型的问题在于,由于不确定版本何时发布,可能会导致效率低下。特别是,只是错过发布的一个小功能将不得不等待很长时间才能等到下一个主要发布版。因此,从 Java 10 开始,采用了一种新的发布模型,引入了严格的基于时间的版本控制

这意味着:

  • Java 现在被分类为功能发布,每六个月定期发生一次。

  • 功能在基本上完成之前不会合并到平台中。

  • 主线仓库始终处于可发布状态。

这些发布仅适用于六个月,过后将不再得到支持。Oracle 将某些版本指定为长期支持(LTS)版本,用户可以从 Oracle 获取扩展的付费支持。

这些 LTS 版本最初的发布节奏是三年,但预计在撰写本文时将改为两年。这意味着 Oracle 的 LTS 版本目前是 8(事后添加的)、11 和 17;预计下一个版本将在 2023 年 9 月发布 Java 21。

除了 Oracle 外,OpenJDK 的版本还可以从包括 Amazon、Azul、Eclipse Adoptium、IBM、Microsoft、Red Hat 和 SAP 在内的其他供应商获取。这些供应商提供多种获取 JDK 更新(包括安全更新)的方式,且零成本。

还有多种付费支持模型从上述供应商提供。

欲深入了解此主题,请参阅指南:“Java Is Still Free” by the Java Champions community,这是 Java 行业中独立的 Java 领袖组成的团体。

尽管 Java 社区普遍对新的更快发布周期持积极态度,但 Java 9 及以上版本的采纳率远低于以往版本。这可能是因为团队希望有更长的支持周期,而不是仅在六个月后升级到每个特性发布。实际上,只有 LTS 版本才看到广泛采用,甚至与 Java 8 的快速普及相比也较慢。

从 Java 8 升级到 11(或 17)也不是一种插拔替换(与 7 到 8、6 到 7 相比)。即使最终用户应用程序不利用模块,模块子系统也从根本上改变了 Java 平台的许多方面。

在 Java 11 发布四年后,它似乎终于超过了 Java 8,现在更多工作负载在 Java 11 上运行而不是 8。Java 17 的采用速度以及 Java 21 的影响有待观察(假设 21 确实是下一个 LTS 版本)。

摘要

Java 9 首次引入的模块化特性旨在一次解决多个问题。通过拒绝访问内部实现,已经实现了更短的启动时间、更小的内存占用和减少的复杂性目标。长期目标是实现更好的应用架构,并开始思考编译和部署的新方法,这些目标仍在进行中。

然而,事实是,截至 Java 17 发布时,很少有团队和项目全面转向模块化世界。这是可以预料的,因为模块化是一个长期的项目,回报缓慢,并依赖于生态系统内部的网络效应来实现全部收益。

新应用程序在一开始时应考虑以模块化方式构建,但 Java 生态系统内平台模块性的整体故事仍处于初期阶段。