Java-学习指南第六版-四-

97 阅读40分钟

Java 学习指南第六版(四)

原文:zh.annas-archive.org/md5/d44128f2f1df4ebf2e9d634772ea8cd1

译者:飞龙

协议:CC BY-NC-SA 4.0

第九章:线程

我们理所当然地认为现代计算机系统可以同时管理许多应用程序和操作系统(OS)任务,并使所有软件看起来同时运行。今天大多数系统都配备了多个处理器或多个核心,有了这些,它们可以实现令人印象深刻的并发度。操作系统仍然在更高层次上调度应用程序,但是它的注意力转向下一个应用程序的速度如此之快,以至于它们也看起来在同时运行。

注意

在编程中,并发操作表示多个通常不相关的任务同时运行。想象一下,一个快餐厨师在烤架上同时准备多份订单。并行操作通常涉及将一个大任务分解为相关的子任务,这些子任务可以并行运行以更快地产生最终结果。我们的厨师可以通过同时在烤架上放置两个肉饼和一些培根来“并行”准备一份双层芝士汉堡。无论哪种情况,程序员通常更广泛地讨论这些任务和子任务同时发生的情况。这并不意味着一切都在完全相同的瞬间开始和停止,但确实意味着这些任务的执行时间是重叠的。

在旧时,操作系统的并发单位是应用程序或进程。对于操作系统来说,一个进程或多或少是一个自行决定要做什么的黑盒子。如果一个应用程序需要更高的并发性,它只能通过运行多个进程并在它们之间进行通信来实现,但这是一种笨重的方法,不太优雅。

后来,操作系统引入了线程的概念。从概念上讲,线程是程序内的控制流。(例如,你可能听说过“执行线程”)线程在应用程序自己的控制下提供了细粒度的并发性。线程已经存在很长时间,但历来使用起来比较棘手。Java 并发工具集解决了多线程应用程序中的常见模式和实践,并将它们提升到了可操作的方法和类的级别。总体来说,这意味着 Java 在更高和更低的层次上都支持线程。

这种广泛的支持使得程序员更容易编写多线程代码,并且使编译器和运行时可以对该代码进行优化。这也意味着 Java 的 API 充分利用了线程,因此在探索 Java 的早期阶段,熟悉这些概念至关重要。并非所有开发人员都需要编写明确使用线程或并发性的应用程序,但大多数人会使用涉及它们的某些功能。

线程在许多 Java API 的设计中起着重要作用,特别是那些涉及客户端应用程序、图形和声音的部分。例如,在我们看 GUI 编程时的第十二章,你会看到组件的paint()方法不是直接由应用程序调用,而是由 Java 运行时系统内的一个单独的绘图线程调用。在任何给定时间,许多这样的后台线程可能会在你的应用程序旁边执行活动,但你仍然会及时更新屏幕。在服务器端,Java 线程同样存在,为每个请求提供服务并运行你的应用程序。了解你的代码如何适应这种环境至关重要。

在本章中,我们将讨论编写显式创建和使用自己线程的应用程序。我们将首先讨论集成到 Java 语言中的低级线程支持,然后讨论java.util.concurrent线程实用工具包。我们还将讨论在 Java 19 中预览的新虚拟线程,项目称为 Project Loom。

引入线程

线程类似于进程或正在运行的程序的概念,不同的是,同一应用程序中的不同线程比同一台机器上运行的不同程序更密切相关,并且共享大部分相同的状态。这有点像许多高尔夫球手同时使用的高尔夫球场。线程协作以共享工作区域。它们轮流等待其他线程。它们可以访问相同的对象,包括其应用程序内的静态和实例变量。但是,线程拥有其自己的局部变量副本,就像球员共享高尔夫球场或高尔夫球车但不共享球棒或球一样。

应用程序中的多个线程面临与球场上高尔夫球手相同的问题,简言之,同步。就像不能同时有两组球员在同一绿地上打球一样,不能有多个线程尝试在没有某种协调的情况下访问相同的变量。否则,某些人可能会受伤。线程可以保留使用对象的权利,直到完成其任务,就像高尔夫聚会在每个球员完成比赛之前独占绿地。更重要的线程可以提高其优先级,断言其“优先通过”的权利。

当然,细节决定成败,长久以来,线程细节使其难以使用。幸运的是,Java 通过直接将一些这些概念集成到语言中,使创建、控制和协调线程变得更简单。

当你第一次使用线程时,很容易会遇到困难。创建一个线程将同时练习你的新 Java 技能。只要记住在运行线程时始终涉及两个主要角色:一个 Java Thread对象代表线程本身,以及一个包含线程将执行的方法的任意目标对象。稍后,我们将看到如何结合这两个角色,但这些方法只是改变了封装,而不是改变了它们的关系。

线程类和可运行接口

在 Java 中,所有的执行都与一个Thread对象相关联,从 JVM 启动的“主”线程开始,用于启动你的应用程序。当你创建java.lang.Thread类的一个实例时,就会诞生一个新的线程。Thread对象表示 Java 解释器中的一个真实线程,并作为控制和协调其执行的句柄。通过它,你可以启动线程,等待它完成,使其休眠一段时间,或者中断其活动。

Thread类的构造函数接受关于线程应该从哪里开始执行的信息。我们想告诉它要运行哪个方法。有很多方法可以做到这一点。经典的方法使用java.lang.Runnable接口来标记包含“可运行”方法的对象。

Runnable定义了一个单一的通用run()方法:

  public interface Runnable {
    abstract public void run();
  }

每个线程都通过执行run()方法来启动其生命周期,该方法位于一个Runnable对象中,这个对象是传递给线程构造函数的“目标对象”。run()方法可以包含任何代码,但必须是公共的,不接受任何参数,没有返回值,并且不会抛出已检查异常。

任何包含适当run()方法的类都可以声明实现Runnable接口。该类的一个实例成为一个可运行对象,可以作为新线程的目标。如果你不想直接将run()方法放在你的对象中(很多时候确实不想这样做),你总是可以创建一个作为你的Runnable的适配器类。适配器的run()方法可以在线程启动后调用任何它想要的方法。稍后我们会展示这些选项的示例。

创建和启动线程

一个新生的线程保持空闲,直到我们通过调用其start()方法来唤醒它。线程随后醒来并继续执行其目标对象的run()方法。start()方法在线程的生命周期中只能调用一次。一旦线程启动,它会继续运行,直到目标对象的run()方法返回或抛出未检查异常。

下面的类Animator实现了一个run()方法来驱动绘图循环。我们可以在游戏中类似地使用它来更新游戏场地:

class Animator implements Runnable {
  boolean animating = true;

  public void run() {
    while (animating) {
      // move active apples one "frame"
      // repaint the field
      // pause
      // ...
    }
  }
}

要使用它,创建一个Thread对象,将一个Animator的实例作为其目标对象传递给它,并调用其start()方法:

    Animator myAnimator = new Animator();
    Thread myThread = new Thread(myAnimator);
    myThread.start();

我们创建了 Animator 类的一个实例,并将其作为参数传递给 myThread 的构造函数。正如在 Figure 9-1 中所示,当我们调用 start() 方法时,myThread 开始执行 Animatorrun() 方法。

ljv6 0901

图 9-1. 作为 Runnable 实现的动画师

让表演开始!

天生的线程

Runnable 接口允许你将任意对象作为线程的目标,就像前面的例子一样。这是 Thread 类最重要的通用用法。在大多数需要使用线程的情况下,你会创建一个类(可能是一个简单的适配器类),该类实现了 Runnable 接口。

另一种创建线程的设计选项是使我们的目标类成为已经可运行的类型的子类。事实证明,Thread 类本身方便地实现了 Runnable 接口;它有自己的 run() 方法,我们可以直接重写它来完成我们的任务:

class Animator extends Thread {
  boolean animating = true;

  public void run() {
    while (animating) {
      // draw Frames
      // do other stuff ...
    }
  }
}

我们的 Animator 类的骨架看起来与之前大致相同,只是现在我们的类是 Thread 的子类。为了配合这个方案,Thread 类的默认构造函数使自己成为默认目标——也就是说,默认情况下,当我们调用 start() 方法时,Thread 执行它自己的 run() 方法,正如 Figure 9-2 中所示。现在我们的子类可以简单地重写 Thread 类中的 run() 方法。(Thread 本身定义了一个空的 run() 方法。)

ljv6 0902

图 9-2. 作为 Thread 子类的动画师

接下来,我们创建了 Animator 的一个实例,并调用了它的 start() 方法(它也继承自 Thread):

    Animator bouncy = new Animator();
    bouncy.start();

扩展 Thread 看起来可能是打包线程及其目标 run() 方法的便利方式。然而,这种方法通常不是最佳设计。如果你扩展 Thread 来实现一个线程,那么你是在说你需要一个新类型的对象,它是 Thread 的一种,公开 Thread 类的所有公共方法。虽然将一个主要关注执行任务的对象变成 Thread 有一种满足感,但实际情况下,你需要创建 Thread 子类的情况应该并不常见。在大多数情况下,更自然的做法是让程序的需求决定类结构,并使用 Runnable 来连接程序的执行和逻辑。

控制线程

现在你已经看到了使用 start() 方法来开始执行一个新线程,让我们来看看在运行时显式控制线程行为的实例方法:

Thread.sleep() 方法

使当前正在执行的线程等待指定的一段时间(多多少少),而不消耗太多(或可能根本没有)CPU 时间。

wait()join() 方法

协调两个或多个线程的执行。在本章后面讨论线程同步时,我们将详细讨论它们。

interrupt()方法

唤醒正在执行sleep()wait()操作中的线程,或者正在长时间 I/O 操作中被阻塞的线程。¹

弃用的方法

我们还应该提到三个已弃用的线程控制方法:stop()suspend()resume()stop()方法与start()方法相辅相成;它销毁线程。start()方法和已弃用的stop()方法只能在线程生命周期中调用一次。相比之下,已弃用的suspend()resume()方法会任意暂停然后重新启动线程的执行。

尽管这些弃用的方法仍然存在于最新版本的 Java 中(而且可能会永远存在),但不应在新代码开发中使用。stop()suspend()方法的问题在于它们以不协调和粗暴的方式控制线程的执行。

您可以创建并监视一些变量作为影响线程执行的更好方式(如果这些变量是boolean类型,您可能会看到它们被称为“标志”)。本书中早期的线程示例在某种程度上使用了这种技术。后续示例将介绍并发类可用的其他一些控制特性。

sleep()方法

程序员通常需要告诉一个线程在一段时间内保持空闲或“睡眠”。例如,您可能需要等待某些外部资源变为可用。即使是我们简单的动画线程在帧之间也会有小的暂停。当一个线程在睡眠或其他方式被某种形式的输入阻塞时,它不会消耗 CPU 时间或与其他线程竞争处理。对于这样的暂停,我们可以调用静态方法Thread.sleep(),它会影响当前执行的线程。调用使得线程空闲指定的毫秒数:

    try {
      // The current thread
      Thread.sleep(1000);
    } catch (InterruptedException e) {
      // someone woke us up prematurely
    }

如果sleep()方法被interrupt()方法中断,可能会抛出InterruptedException异常(详见下文)。正如您在前面的代码中看到的那样,线程可以捕获此异常并利用这个机会执行某些操作——比如检查一个变量来确定线程是否应该退出——然后继续睡眠。

join()wait()notify()方法

如果需要通过等待另一个线程完成其任务来协调线程的活动,可以使用join()方法。调用线程的join()方法会导致该线程阻塞,直到目标线程完成为止。或者,可以将join()方法与等待的毫秒数作为参数一起调用。在这种形式下,调用线程会等待直到目标线程完成或指定的时间段过去。这是一种非常粗糙的线程同步方式。

如果您需要将线程的活动与其他资源协调起来,例如检查文件或网络连接的状态,则可以使用wait()notify()方法。在线程上调用wait()将暂停它,类似于使用join(),但它会保持暂停状态,直到另一个线程通过interrupt()中断它,或者您自己在线程上调用notify()

Java 支持更一般和更强大的机制来协调线程活动,位于java.util.concurrent包中。我们将在本章后面向您展示此包的更多内容。

interrupt() 方法

interrupt() 方法基本上做了它说的事情。它中断了线程的正常执行流程。如果该线程在sleep()wait()或耗时的 I/O 操作中空闲,则会唤醒。当中断线程时,其中断状态标志将被设置。您可以使用isInterrupted()方法测试此标志。您还可以使用一个替代形式isInterrupted(boolean)来指示您是否希望线程在检索当前值后清除其中断状态。

尽管您可能不经常使用interrupt(),但它绝对会派上用场。如果您曾经因桌面应用程序试图连接到服务器或数据库而变得不耐烦,但却失败了,那么您就经历过其中一个可能需要中断的时刻。

让我们通过一个小型图形应用程序模拟这种情况。我们将在屏幕上显示一个标签,并将其移动到每五秒钟的新位置。在那五秒的暂停期间,屏幕上的任何位置的点击都会中断暂停。我们将更改消息,然后再次启动随机移动周期。您可以从ch09/examples/Interruption.java运行完整的示例,但图 9-3 突出了调用interrupt()的流程和效果。

ljv6 0903

图 9-3. 中断线程

重温线程动画

管理动画是图形界面中的常见任务。有时动画是微妙的过渡;其他时候,它们是应用程序的焦点,就像我们的苹果投掷游戏一样。我们将介绍处理动画的两种方式:在sleep()函数旁边使用简单的线程,以及使用计时器。将其中一种选项与某种类型的步进或“下一帧”函数配对是一种流行且易于理解的方法。

你可以使用类似于“创建和启动线程”的线程来生成真实的动画。基本思想是绘制或定位所有动画对象,暂停,将它们移动到它们的下一个位置,然后重复。让我们首先看看如何在没有动画的情况下绘制游戏场地的一些部分。除了现有的树木和篱笆列表外,我们还将为任何活跃的苹果添加一个新的List。您可以从ch09/examples/game文件夹中的编辑器中检索此代码:

// From the Field class...
  protected void paintComponent(Graphics g) {
    g.setColor(fieldColor);
    g.fillRect(0,0, getWidth(), getHeight());
    physicist.draw(g);
    for (Tree t : trees) { t.draw(g); }
    for (Hedge h : hedges) { h.draw(g); }
    physicist.draw(g);
    for (Apple a : apples) { a.draw(g); }
  }

// And from the Apple class...
  public void draw(Graphics g) {
    // Make sure our apple will be red, then paint it!
    g.setColor(Color.RED);
    g.fillOval(x, y, scaledLength, scaledLength);
  }

我们首先绘制背景场地,然后是树木和篱笆,然后是我们的物理学家,最后是任何苹果。最后绘制苹果可以确保它们显示在其他元素的上方。

当您玩游戏时屏幕上发生了什么变化?真正可以移动的只有两个“可移动”物品:我们的物理学家瞄准的苹果,以及被投掷后正在飞行的任何苹果。物理学家响应用户输入(通过移动鼠标或单击按钮)来瞄准,这不需要单独的动画,所以我们将在第十二章中添加这个功能。现在,我们可以专注于处理飞行的苹果。

我们游戏的动画步骤应该根据重力规则移动每个处于活跃状态的苹果。首先,在我们的Apple类中添加一个toss()方法,我们可以使用来自我们物理学家的信息设置苹果的初始条件(因为物理学家目前还没有交互,我们将伪造一些数据)。然后,在step()方法中为给定的苹果进行一次移动:

// File: ch09/examples/game/Apple.java

  // Parameters provided by the physicist
  public void toss(float angle, float velocity) {
    lastStep = System.currentTimeMillis();
    double radians = angle / 180 * Math.PI;
    velocityX = (float)(velocity * Math.cos(radians) / mass);
    // Start negative since "up" means smaller values of y
    velocityY = (float)(-velocity * Math.sin(radians) / mass);
  }

  public void step() {
    // Make sure the apple is still moving
    // using our lastStep tracker as a sentinel
    if (lastStep > 0) {
      // Apply the law of gravity to the apple's vertical position
      long now = System.currentTimeMillis();
      float slice = (now - lastStep) / 1000.0f;
      velocityY = velocityY + (slice * Field.GRAVITY);
      int newX = (int)(centerX + velocityX);
      int newY = (int)(centerY + velocityY);
      setPosition(newX, newY);
    }
  }

我们首先在toss()方法中计算苹果将移动的速度(velocityXvelocityY变量)。在我们的step()方法中,根据这两个速度更新苹果的位置,然后根据重力强度调整垂直速度。这不是很花哨,但它将为苹果产生一个不错的弧线。然后我们将该代码放入一个循环中,该循环将执行更新计算、重绘场地和苹果、暂停并重复:

// File: ch09/examples/game/Field.java

// duration of an animation frame in milliseconds
public static final int STEP = 40;

// ...
// A simple inner class with our run() method
class Animator implements Runnable {
  public void run() {
    // "animating" is a global variable that allows us
    // to stop animating and conserve resources
    // if there are no active apples to move
    while (animating) {
      System.out.println("Stepping " + apples.size() +
          " apples");
      for (Apple a : apples) {
        a.step();
      }
      // Reach back to our outer class instance to repaint
      Field.this.repaint();
      // And get rid of any apples on the ground
      cullFallenApples();
      try {
        Thread.sleep(STEP);
      } catch (InterruptedException ie) {
        System.err.println("Animation interrupted");
        animating = false;
      }
    }
  }
}

我们将在一个简单的线程中使用这个Runnable的实现。我们的Field类将保留一个线程的实例,并包含以下简单的start方法:

// File: ch09/examples/game/Field.java
  Thread animationThread;

  // other state and methods ...

  void startAnimation() {
    animationThread = new Thread(new Animator());
    animationThread.start();
  }

我们将在“事件”中讨论事件;您将使用这些事件来命令启动一个苹果。现在,我们将自动启动一个苹果,如图 9-4 所示。

ljv6 0904

图 9-4:动作中的可抛苹果

它作为静止截图看起来并不起眼,但实际上却令人惊叹。 ;^)

线程的终结

一切美好的事情都有结束的时候。一个线程会一直执行,直到发生以下三种情况之一:

  • 它明确地从其目标run()方法返回。

  • 它遇到了一个未捕获的运行时异常。

  • 讨厌的、已弃用的stop()方法被调用。

如果这些情况都不发生,线程的run()方法永远不会终止会发生什么?即使创建它的代码已经完成,线程仍然可以继续存在。您必须了解线程如何最终终止,否则您的应用程序可能会继续运行未必要地消耗资源,甚至在本应退出时仍保持应用程序的生存。

在许多情况下,你需要在应用程序中执行简单周期任务的后台线程。你可以使用 setDaemon() 方法创建其中一个后台工作者,并将线程标记为守护线程。守护线程可以像其他线程一样终止,但如果启动它们的应用程序正在退出,当没有其他非守护应用程序线程存在时,它们应该被终止和丢弃。² 通常,Java 解释器会继续运行直到所有线程完成。但是当只有守护线程仍然存活时,解释器将退出。

这是一个使用守护线程的“恶魔式”大纲:

class Devil extends Thread {
  Devil() {
    setDaemon(true);
    start();
  }
  public void run() {
    // perform some tasks
  }
}

在这个例子中,Devil 线程在创建时设置了它的守护进程状态。如果我们的应用程序在其他方面完成后仍有任何 Devil 线程存在,运行时系统会为我们终止它们。我们不需要担心清理它们。

关于优雅终止线程的最后一点说明。新的开发人员在第一次使用图形 Swing 组件创建应用程序时经常遇到一个常见问题:他们的应用程序永远不会退出。在一切都完成并且应用程序窗口关闭之后,Java VM 似乎会无限期地挂起。Java 创建一个 UI 线程来处理输入和绘画事件。这个 UI 线程不是守护线程,因此当其他应用程序线程完成时,它不会自动退出。开发人员必须显式调用 System.exit()。如果你想想的话,这是有道理的。因为大多数 GUI 应用程序是事件驱动的并且等待用户输入,否则它们会在启动代码完成后退出。

虚拟线程

在 Java 19 中预览,并在 Java 21 中最终确定,Project Loom³ 为 Java 带来了轻量级虚拟线程。Project Loom 的主要目标之一是改进 Java 中的线程生态系统,使开发人员可以花更少的精力来保持多线程应用程序的稳定性,更多地解决高层次的问题。

预览特性插曲

“在 Java 19 中预览”的意思是什么?从 Java 12 开始,Oracle 开始引入一些语言特性作为预览。这些预览特性有明确的规范并且完全实现,但并不完全成熟。Oracle 可能在未来的版本中做出重大修改。最终,这些特性要么成为 JDK 的永久部分,要么被移除。Oracle 为每个新的 Java 发布版本制作语言更新页面,其中包含对语言最近更改的良好历史以及预览特性概述

因为任何给定的预览特性最终可能被从 Java 中删除,Oracle 要求你在编译或运行使用它的应用程序时包含特殊标志。这个要求是一个小的防护栏,以确保你不会意外使用可能在将来的 Java 版本中不起作用的代码。

配置 IDE 以使用预览特性

如果您使用 IDE 进行演示和练习,可能需要配置它以支持预览功能。例如,IntelliJ IDEA 默认情况下不支持预览功能。您需要在“File → Project Structure”对话框中更改设置,如图 9-5 所示。

ljv6 0905

图 9-5. 在 IntelliJ IDEA 中启用 Java 的预览功能

在 SDK 下拉菜单中选择您想要的 Java 版本后,您可以通过在“Language level”下拉菜单中选择适当选项来启用预览功能支持。(IDEA 列出的特性与版本号旁边的特性不是详尽列表。)设置语言级别后,单击“OK”,IDEA 应该已准备好编译和运行任何具有预览功能的代码。

重命名预览源文件

VirtualDemo 类(ch09/examples/VirtualDemo.java.preview)使用虚拟线程在发出我们最喜欢的“Hello Java”问候之前暂停片刻。在您可以编译或运行它之前,您需要将其重命名。我们在包含代码中的任何预览功能的文件上添加了 .preview 后缀。该后缀阻止像 IntelliJ IDEA 这样的 IDE 在您配置预览支持之前积极地编译它们,就像我们在前一节中提到的那样。

您可以在 IntelliJ IDEA 中使用上下文(右键单击)菜单,在“重构”菜单项下重命名文件。您还可以在 Linux 或 macOS 终端中使用 mv 命令快速重命名文件:

% cd ch09/examples
% mv VirtualDemo.java.preview VirtualDemo.java
% cd ../..

在 Windows 终端或命令提示符中,您可以使用 rename 命令:

C:\> cd ch09\examples
C:\> rename VirtualDemo.java.preview VirtualDemo.java
C:\> cd ..\..

编译具有预览功能的类

Oracle 为使用预览功能编译代码添加了一对命令行选项。例如,如果您尝试使用 Java 19 编译我们的 VirtualDemo 源文件,您可能会看到类似于这样的错误:

% javac --version
javac 19.0.1

% javac VirtualDemo.java
VirtualDemo.java:4: error: startVirtualThread(Runnable)
 is a preview API and is disabled by default.
    Thread thread = Thread.startVirtualThread(runnable);
                          ^
  (use --enable-preview to enable preview APIs)

错误为我们提供了一个关于我们应该如何继续的提示。让我们尝试添加建议的标志并再次编译:

% javac --enable-preview VirtualDemo.java
error: --enable-preview must be used with either -source or --release

糟糕!又是另一个不同的错误。至少它也包含了一些提示。要编译,您需要提供两个标志:--enable-preview,然后是 -source--release。⁴ 编译器使用 -source 来指定适用于正在编译的源代码的语言规则。(编译后的字节码仍针对与您的 JDK 相同版本的 Java。)您可以使用 --release 选项来同时指定源版本和字节码版本。

虽然有许多场景可能需要为旧系统编译,但预览功能是用于当前 JDK 版本的。因此,当我们在书中使用任何预览功能时,我们将使用 --enable-preview--release 并简单地给出与我们的 Java 版本相同的发布版本号。例如,返回到我们的虚拟线程预览功能,我们可以使用 Java 19 来尝试它。我们最终的正确 javac 调用如下所示:

% javac --version
javac 19.0.1

% javac --enable-preview --release 19 VirtualDemo.java
Note: VirtualDemo.java uses preview features of Java SE 19.
Note: Recompile with -Xlint:preview for details.

"notes"这个名词在编译完成后出现,纯粹是提供信息。它们提醒您的代码依赖于未来可能不再可用的不稳定功能。这些注释并非意在劝阻您使用这些功能,但如果您计划与其他用户或开发者共享代码,则需要记住一些额外的兼容性约束。

如果您好奇,您还可以使用注释中提到的-Xlint:preview选项来查看到底是哪些预览代码引起了警告:

src$ javac --enable-preview --release 19 -Xlint:preview VirtualDemo.java
VirtualDemo.java:4: warning: [preview] startVirtualThread(Runnable)
 is a preview API and may be removed in a future release.
    Thread thread = Thread.startVirtualThread(runnable);
                          ^
1 warning

没有什么意外的,但话说回来,这只是一个微小的演示程序。对于更大的程序或团队开发的代码来说,那额外的-Xlint:preview标志就非常方便了。

运行预览类文件

运行包含预览功能的 Java 类也需要--enable-preview标志。如果您尝试像运行任何其他类一样使用 Java 19 运行VirtualDemo,您将收到如下错误:

% java VirtualDemo
Error: LinkageError occurred while loading main class VirtualDemo
        java.lang.UnsupportedClassVersionError: Preview features are not
        enabled for VirtualDemo (class file version 63.65535).
        Try running with '--enable-preview'

再次,您可以使用错误中提到的--enable-preview标志,然后您可以开始:

% java --enable-preview VirtualDemo
Hello virtual thread! ID: 20

如果您想在jshell中尝试预览功能,也可以提供相同的--enable-preview标志:

% jshell --enable-preview

包含该标志将允许 Java 19 的jshell会话使用虚拟线程,就像它允许我们运行上面的演示程序一样。

快速比较

Loom 团队设计了它的虚拟线程,以便在您已经具有一些 Java 线程技能的情况下易于使用。让我们重新设计我们用来测试--enable-preview标志的微不足道的线程示例,以展示两种类型的线程:

public class VirtualDemo2 {
  public static void main(String args[]) throws Exception {
    Runnable runnable = new Runnable() {
      public void run() {
        Thread t = Thread.currentThread();
        System.out.println("Hello thread! " +
            (t.isVirtual() ? "virtual " : "platform ") +
            "ID: " + t.threadId());
      }
    };
    Thread thread1 = new Thread(runnable);
    thread1.start();
    Thread thread2 = Thread.startVirtualThread(runnable);
    thread1.join();
    thread2.join();
  }
}

在这次重新编排中,我们扩展了我们的匿名Runnable内部类,对当前线程进行了一些侦查。我们打印出线程的标识号以及它是否是虚拟线程。但请看启动这两个线程的行代码多么相似(以及简单):它们都接受我们的runnable对象并“适合”在Thread类中。对于已有代码的开发者来说,切换到使用这些虚拟线程应该很简单。编译并运行后的输出如下(当然要使用适当的预览标志):

$ javac --enable-preview --source 19 VirtualDemo2.java
Note: VirtualDemo.java uses preview features of Java SE 19.
Note: Recompile with -Xlint:preview for details.

$ java --enable-preview VirtualDemo2
Hello thread! virtual ID: 21
Hello thread! platform ID: 20

两个线程都如预期地运行。一个线程确实报告自己是虚拟线程。我们使用术语平台来描述另一个线程,因为这是 Oracle 文档称呼它们的方式。平台线程表示与操作系统(平台)提供的本机线程之间的直接一对一关系。另一方面,虚拟线程与来自操作系统的本机线程之间有间接的多对一关系,如图 9-6 所示。

ljv6 0906

图 9-6。平台线程和虚拟线程在映射到本地线程时存在差异

这种分离是虚拟线程的关键设计特征之一。它允许 Java 同时运行许多(许多!)线程,而无需创建和管理相应的本机线程的性能成本。虚拟线程被设计为创建廉价且一旦运行起来非常高效。

同步

每个线程都有自己的思路。通常,线程在不考虑应用程序中其他线程的情况下进行操作。线程可以被时间片,这意味着它们可以按照操作系统的指示以任意的突发方式运行。在多处理器或多核系统上,许多不同的线程甚至可以同时在不同的 CPU 上运行。本节讨论协调两个或多个线程的活动,以便它们可以共同使用相同的变量和方法(而不会发生冲突,就像高尔夫球场上的球员一样)。

Java 提供了一些简单的结构来同步线程的活动。它们都基于监视器的概念,这是一种广泛使用的同步方案。您不必了解监视器的工作细节即可使用它们,但牢记图 9-7 可能会对您有所帮助。

ljv6 0907

图 9-7. 使用监视器同步访问

监视器本质上是一把锁。这把锁附加在一个资源上,许多线程可能需要访问该资源,但只能一次由一个线程访问。这很像带锁的卫生间门;如果门没锁,你可以进去,使用时把门锁上。如果资源未被使用,线程可以获取锁并访问资源。当线程完成时,它释放锁,就像你打开卫生间门留给下一个人(或线程)一样。

然而,如果另一个线程已经获取了资源的锁,则所有其他线程必须等待,直到当前线程完成并释放锁。这就像当你到达时卫生间已被占用一样:你必须等到当前用户完成并解锁门。

Java 让资源访问同步变得相当容易。语言处理设置和获取锁;你只需指定需要同步的资源即可。

方法访问序列化

在 Java 中,同步线程的最常见原因是将它们对资源(如对象或变量)的访问序列化,换句话说,确保一次只有一个线程可以操作该对象。⁵ 在 Java 中,每个类和类的每个实例都有自己的锁。synchronized关键字标记了线程必须在继续之前获取锁的地方。

比如,假设我们实现了一个包含say()方法的SpeechSynthesizer类。我们不希望多个线程同时调用say(),因为我们将无法理解合成器说的内容。因此,我们将say()方法标记为synchronized,这意味着线程必须在发声之前获取SpeechSynthesizer对象上的锁:

class SpeechSynthesizer {
  synchronized void say(String words) {
    // speak the supplied words
  }
}

say()完成后,调用线程释放锁,这允许下一个等待的线程获取锁并运行方法。无论线程是属于SpeechSynthesizer本身还是其他对象,都必须从SpeechSynthesizer实例获取相同的锁。如果say()是类(静态)方法而不是实例方法,我们仍然可以将其标记为synchronized。在这种情况下,因为没有涉及实例对象,所以锁在SpeechSynthesizer类对象本身上。

经常情况下,您希望同步同一类的多个方法,以便一次只有一个方法修改或检查类中的数据。类中的所有静态同步方法都使用相同的类对象锁。同样,类中的所有实例方法都使用相同的实例对象锁。这保证了一次只有一组同步方法在运行。例如,一个SpreadSheet类可能包含表示单元格值的多个实例变量,以及一些操作整行单元格的方法:

class SpreadSheet {
  int cellA1, cellA2, cellA3;

  synchronized int sumRow() {
    return cellA1 + cellA2 + cellA3;
  }

  synchronized void setRow(int a1, int a2, int a3) {
    cellA1 = a1;
    cellA2 = a2;
    cellA3 = a3;
  }
  // other spreadsheet stuff ...
}

方法setRow()sumRow()都访问单元格值。您可以看到,如果一个线程在setRow()中更改变量的值的同时,另一个线程在sumRow()中读取值,可能会出现问题。为了防止这种情况发生,我们将这两个方法都标记为synchronized。当线程遇到同步资源时,只有一个线程运行。如果一个线程在执行setRow()时,另一个线程试图调用sumRow(),那么第二个线程必须等到第一个线程完成setRow()的执行,然后才能运行sumRow()。这种同步允许我们保持SpreadSheet的一致性。最好的部分是,所有这些锁定和等待都由 Java 处理;对程序员来说是不可见的。

除了同步整个方法外,synchronized关键字还可以用在特殊结构中,以保护方法内部的较小代码块。在这种形式下,它还需要一个明确的参数,指定要获取哪个对象的锁:

    synchronized (myObject) {
      // Functionality that needs exclusive access to resources
    }

这个同步块可以出现在任何方法中。当一个线程到达它时,线程必须在继续之前获取myObject上的锁。通过这种方式,我们可以像同一类中的方法那样同步不同类中的方法(或方法的部分)。

这意味着同步实例方法等效于在当前对象上同步其语句的方法:

  synchronized void myMethod () {
    // method body
  }

等价于:

  void myMethod () {
    synchronized (this) {
      // method body
    }
  }

我们可以用经典的“生产者/消费者”场景来演示同步的基本原理。假设我们有一些生产者创建新的资源,而消费者获取并使用这些相同的资源:例如一系列网络爬虫收集在线图片。这里的“生产者”可以是一个或多个线程,实际上加载和解析网页以查找图像及其 URL。我们可以让它将这些 URL 放入一个共享的队列中。“消费者”线程会从队列中获取下一个 URL,并将图像下载到文件系统或数据库中。我们不会在这里尝试进行所有的实际 I/O 操作(有关 URL 和网络的更多信息请参见第十三章),但让我们设置一些生产和消费线程来展示同步如何工作。

同步队列中的 URL

让我们先看看用于存储 URL 的队列。它只是一个列表,我们可以将 URL(作为字符串)追加到末尾并从前面取出。我们将使用LinkedList,类似于我们在第七章中看到的ArrayList。我们需要一个设计用于高效访问和操作的结构:

package ch09.examples;

import java.util.LinkedList;

public class URLQueue {
  LinkedList<String> urlQueue = new LinkedList<>();

  public synchronized void addURL(String url) {
    urlQueue.add(url);
  }

  public synchronized String getURL() {
    if (!urlQueue.isEmpty()) {
      return urlQueue.removeFirst();
    }
    return null;
  }

  public boolean isEmpty() {
    return urlQueue.isEmpty();
  }
}

请注意,并非每个方法都是同步的!任何线程都可以询问队列是否为空,而不会阻塞可能正在添加或删除项目的其他线程。这意味着isEmpty()可能会报告错误的答案 —— 如果不同线程的时间恰好不对。幸运的是,我们的系统有一定的容错性,所以在不锁定队列的情况下检查其大小的效率胜过更完美的知识。⁶

现在我们知道如何存储和检索 URL,我们可以创建生产者和消费者类。生产者将通过循环来模拟网络爬虫,制造假的 URL,并在 URL 前面加上生产者 ID,然后将它们存储在我们的队列中。这是URLProducerrun()方法:

  public void run() {
    for (int i = 1; i <= urlCount; i++) {
      String url = "https://some.url/at/path/" + i;
      queue.addURL(producerID + " " + url);
      System.out.println(producerID + " produced " + url);
      try {
        Thread.sleep(delay.nextInt(500));
      } catch (InterruptedException ie) {
        System.err.println("Producer " + producerID + " interrupted. Quitting.");
        break;
      }
    }
  }

消费者类类似,唯一的区别是从队列中取出 URL。它将取出一个 URL,加上消费者 ID,并在生产者完成生成并且队列为空时重新开始:

  public void run() {
    while (keepWorking || !queue.isEmpty()) {
      String url = queue.getURL();
      if (url != null) {
        System.out.println(consumerID + " consumed " + url);
      } else {
        System.out.println(consumerID + " skipped empty queue");
      }
      try {
        Thread.sleep(delay.nextInt(1000));
      } catch (InterruptedException ie) {
        System.err.println("Consumer " + consumerID +
            " interrupted. Quitting.");
        break;
      }
    }
  }

我们可以从很小的数字开始运行我们的模拟:两个生产者和两个消费者。每个生产者只会创建三个 URL:

public class URLDemo {
  public static void main(String args[]) {
    URLQueue queue = new URLQueue();
    URLProducer p1 = new URLProducer("P1", 3, queue);
    URLProducer p2 = new URLProducer("P2", 3, queue);
    URLConsumer c1 = new URLConsumer("C1", queue);
    URLConsumer c2 = new URLConsumer("C2", queue);
    System.out.println("Starting...");
    Thread tp1 = new Thread(p1);
    tp1.start();
    Thread tp2 = new Thread(p2);
    tp2.start();
    Thread tc1 = new Thread(c1);
    tc1.start();
    Thread tc2 = new Thread(c2);
    tc2.start();
    try {
      // Wait for the producers to finish creating urls
      tp1.join();
      tp2.join();
    } catch (InterruptedException ie) {
      System.err.println("Interrupted waiting for producers to finish");
    }
    c1.setKeepWorking(false);
    c2.setKeepWorking(false);
    try {
      // Now wait for the workers to clean out the queue
      tc1.join();
      tc2.join();
    } catch (InterruptedException ie) {
      System.err.println("Interrupted waiting for consumers to finish");
    }
    System.out.println("Done");
  }
}

即使涉及到这些小数字,您仍然可以看到使用多个线程来完成工作的影响:

Starting...
C1 skipped empty queue
C2 skipped empty queue
P2 produced https://some.url/at/path/1
P1 produced https://some.url/at/path/1
P1 produced https://some.url/at/path/2
P2 produced https://some.url/at/path/2
C2 consumed P2 https://some.url/at/path/1
P2 produced https://some.url/at/path/3
P1 produced https://some.url/at/path/3
C1 consumed P1 https://some.url/at/path/1
C1 consumed P1 https://some.url/at/path/2
C2 consumed P2 https://some.url/at/path/2
C1 consumed P2 https://some.url/at/path/3
C1 consumed P1 https://some.url/at/path/3
Done

线程不会完美地轮流执行,但每个线程都至少会有一些工作时间。消费者不会锁定到特定的生产者。我们的想法是有效利用有限的资源。生产者可以继续添加任务,而不必担心每个任务需要多长时间或分配给谁。消费者反过来可以获取任务,而不必担心其他消费者。如果一个消费者得到一个简单的任务并在其他消费者之前完成,它可以立即返回并获取一个新任务。

尝试自己运行这个示例,并增加一些这些数字。当有数百个 URL 时会发生什么?当有数百个生产者或消费者时会发生什么?在规模上,这种类型的多任务处理几乎是必需的。你不会找到一个不使用线程来管理其后台工作的大型程序。即使你的应用程序很小,Java 自己的图形包 Swing 也需要一个单独的线程来保持 UI 的响应和正确性。

同步虚拟线程

那虚拟线程呢?它们有相同的并发问题吗?大多数是的。虽然轻量级,虚拟线程仍代表标准的“执行线程”概念。它们仍然可以以混乱的方式相互中断,并且仍必须协调对共享资源的访问。但幸运的是,Project Loom 的设计目标拯救了我们。我们可以使用虚拟线程重用所有同步技巧。事实上,要使我们的 URL 生产和消费演示变为虚拟线程,我们只需要替换 main() 方法中启动线程的代码块:

// file: URLDemo2.java
    System.out.println("Starting virtual threads...");
    // Convert these two-step lines:
    //Thread tp1 = new Thread(p1);
    //tp1.start();

    // To these simpler, create-and-start lines:
    Thread vp1 = Thread.startVirtualThread(p1);
    Thread vp2 = Thread.startVirtualThread(p2);
    Thread vc1 = Thread.startVirtualThread(c1);
    Thread vc2 = Thread.startVirtualThread(c2);

虚拟线程遵守我们 URLQueue 方法中的 synchronized 关键字,并理解 join() 调用,就像平台线程一样。如果你编译并运行 URLDemo2.java(不要忘记可能需要启用预览功能),你将看到与之前相同的输出,当然,会有一些来自随机暂停的小变化。

我们说虚拟线程 大多数情况 下与平台线程具有相同的并发问题。我们之所以这样说是因为创建和运行虚拟线程比管理一组平台线程要便宜得多,因此你不会压垮操作系统。 (请记住,每个平台线程都映射到一个本地线程。)你不需要对虚拟线程进行池化——你只需要创建更多。

从多个线程访问类和实例变量

SpreadSheet 示例中,我们通过一个同步方法保护对一组实例变量的访问,以避免一个线程在另一个线程读取其他变量时改变其中一个变量,以保持它们协调。

但是对于个别变量类型呢?它们需要同步吗?通常不需要。在 Java 中,几乎所有对基本类型和对象引用类型的操作都是 原子的:即 JVM 在一步中处理它们,没有两个线程会碰撞。这可以防止线程在其他线程访问它们时查看引用。

警告

注意——JVM 规范不保证会原子地处理 doublelong 原始类型。这两种类型都表示 64 位值。问题在于 JVM 的堆栈工作方式。你应该通过访问器方法同步访问你的 doublelong 实例变量,或者使用原子包装类,我们将在“并发工具”中描述。

调度和优先级

Java 对线程如何调度几乎没有任何保证。几乎所有的线程调度都留给了 Java 实现和在一定程度上的应用程序。Java 的设计者可以规定一个调度算法,但单一的算法并不适合 Java 可以扮演的所有角色。相反,Java 的设计者让您编写能够在任何调度算法下都能正常工作的健壮代码,并且让实现调整算法以达到最佳适配。

Java 语言规范中的优先级规则被精心措辞为线程调度的一般指南。你应该能够在统计意义上依赖这种行为,但编写依赖调度器非常具体特性的代码并不是一个好主意。相反,请使用本章描述的控制和同步工具来协调您的线程。⁷

每个线程都有一个优先级。一般来说,只要高于当前线程优先级的线程变为可运行状态(启动、停止休眠或被通知),它将抢占低优先级线程并开始执行。在某些系统上,具有相同优先级的线程按轮转法调度,这意味着一旦线程开始运行,它将一直运行,直到执行以下操作之一:

  • 通过调用Thread.sleep()wait()来休眠

  • 等待锁以运行同步方法

  • 阻塞 I/O 操作,例如在read()accept()调用中

  • 通过调用yield()显式地让出控制权

  • 通过完成其目标方法来终止⁸

此情况类似于 Figure 9-8。

ljv6 0908

图 9-8. 优先级抢占式轮转调度

您可以使用setPriority()方法在平台线程上设置优先级,并且可以使用配套的getPriority()调用查看线程的当前优先级。优先级必须落在Thread类常量MIN_PRIORITYMAX_PRIORITY定义的范围内。默认优先级存储在常量NORM_PRIORITY中。

注意

虚拟线程都以NORM_PRIORITY运行。如果在虚拟线程上调用setPriority(),则传递的新优先级将被简单忽略。

线程状态

在其生命周期的任何时刻,线程处于五种一般状态之一。您可以使用Thread类的getState()方法来查询它们:

NEW

线程已创建但尚未启动。

RUNNABLE

线程处于其正常的活动状态,即使它在执行 I/O 操作中被阻塞,例如读取或写入文件或网络连接。

BLOCKED

线程被阻塞,等待进入同步方法或代码块。这包括在调用notify()后被唤醒并试图在wait()后重新获取其锁的时候。

WAITING, TIMED_WAITING

线程正在等待另一个线程通过调用wait()join()。在TIMED_WAITING情况下,调用具有超时。

TERMINATED

线程由于返回、异常或停止而完成。

您可以使用以下代码片段显示当前线程组中所有平台线程的状态:

    Thread [] threads = new Thread [ 64 ]; // max threads to show
    int num = Thread.enumerate(threads);
    for(int i = 0; i < num; i++)
       System.out.println(threads[i] +":"+ threads[i].getState());

Thread.enumerate()调用将填充我们的threads数组,直到其长度。你可能不会在一般编程中使用这个方法,但它对于实验和学习 Java 线程非常有趣和有用。

时间分片

除了优先级排序外,所有现代系统(除了一些嵌入式和“微”Java 环境)都实现了线程时间分片。在时间分片系统中,线程处理被切割,以便每个线程在上下文切换到下一个线程之前运行一小段时间,如图 9-9 所示。

ljv6 0909

图 9-9. 优先级抢占时间分片调度

在此方案中,高优先级线程仍然可以抢占低优先级线程。添加时间分片会混合处理相同优先级的线程;在多处理器机器上,线程甚至可以同时运行。这可能会改变不正确使用线程和同步的应用程序的行为。

严格来说,因为 Java 不保证时间分片,你不应该编写依赖这种调度类型的代码;你编写的任何软件都应该在轮转调度下正常运行。如果你想知道你的 Java 版本的行为,可以尝试以下实验:

public class Thready {
  public static void main(String args []) {
    new Thread(new ShowThread("Foo")).start();
    new Thread(new ShowThread("Bar")).start();
  }

  static class ShowThread implements Runnable {
    String message;

    ShowThread(String message) {
      this.message = message;
    }
    public void run() {
      while (true)
        System.out.println(message);
    }
  }
}

运行此示例时,您将看到您的 Java 实现如何进行调度。Thready类启动两个ShowThread对象。ShowThread是一个进入无限循环⁹(通常不好的形式,但对于此演示很有用)并打印其消息的线程。因为我们没有为任何线程指定优先级,它们都继承了它们创建者的优先级,所以它们具有相同的优先级。在轮转方案下,只应该打印FooBar不应该出现。在时间分片实现中,您偶尔会看到FooBar消息交替出现。

ch09/examples文件夹还包含一个VirtualThready示例,如果您想看看虚拟线程的行为。它们使用“工作窃取”调度程序运行。(请随意深入研究官方 Oracle 文档,了解该算法在分支/合并框架中的实现。)我们必须向虚拟线程版本添加一些join()调用。与平台线程不同,虚拟线程在没有这些显式的等待请求时不会使 JVM“保持唤醒”状态。

优先级

线程优先级是 JVM 在竞争线程之间分配时间的一般指导原则。不幸的是,Java 平台线程以复杂的方式映射到本机线程,以至于你不能依赖优先级的确切含义。相反,把它们视为 JVM 的一个提示。

让我们来调整我们线程的优先级:

class Thready2 {
  public static void main(String args []) {
    Thread foo = new ShowThread("Foo");
    foo.setPriority(Thread.MIN_PRIORITY);
    Thread bar = new ShowThread("Bar");
    bar.setPriority(Thread.MAX_PRIORITY);

    foo.start();
    bar.start();
  }
}

您可能期望通过这对我们的Thready2类的更改,Bar线程将完全接管。如果您在旧版 Solaris 实现的 Java 5.0 上运行此代码,确实会发生这种情况。但对于大多数现代版本的 Java 并非如此。同样地,如果将优先级更改为除最小和最大之外的值,则可能根本看不到任何区别。优先级和性能的微妙之处与 Java 线程和优先级如何映射到操作系统中的实际线程有关。因此,通常应保留调整线程优先级的权利供系统和框架开发使用。

线程性能

线程的使用决定了几个 Java 包的形式和功能。

同步的成本

获取锁来同步线程需要时间,即使没有竞争。在旧版 Java 实现中,这段时间可能是显著的。使用较新的 JVM,这几乎可以忽略不计。然而,不必要的低级别同步仍然可能通过阻塞线程来减慢应用程序。为了避免这种惩罚,两个重要的 API,Java 集合框架和 Swing API,特别设计为让开发人员控制同步。

java.util 集合框架用更全面功能的未同步类型(ListMap)替代了早期简单的 Java 聚合类型,即VectorHashtable。集合框架反而让应用程序代码来同步访问集合时必要的部分,并提供特殊的“快速失败”功能以帮助检测并发访问并抛出异常。它还提供同步“包装器”,可以以旧式风格提供安全访问。作为 java.util.concurrent 包的一部分,特殊的支持并发访问的 MapQueue 集合的实现进一步允许高度并发的访问,而无需用户同步。

Java Swing API 采用了一种不同的方法来提供速度和安全性。Swing 使用单个线程来修改其组件,有一个例外:事件分发线程,也称为事件队列。Swing 通过强制一个超级线程控制 GUI 来解决性能问题和任何事件顺序问题。应用程序通过简单的接口间接地访问事件分发线程,通过将命令推送到队列中来实现。我们将在第十二章中详细了解如何做到这一点。

线程资源消耗

Java 中的一个基本模式是启动多个线程来处理异步外部资源,比如套接字连接。为了最大效率,Web 开发人员可能会诱惑创建每个客户端连接的线程。当每个客户端有自己的线程时,I/O 操作可以根据需要阻塞和恢复。但尽管对于给定客户端的吞吐量而言这可能是高效的,但这是一种非常低效的服务器资源利用方式。

线程会消耗内存;每个线程都有自己的“栈”用于本地变量,并在运行线程之间切换时(称为上下文切换)会增加 CPU 开销。线程相对较轻量级。在大型服务器上可以运行数百或数千个线程是可能的。但在某一点之后,管理现有线程的成本开始超过启动更多线程的好处。为每个客户端创建一个线程并不总是可扩展的选择。

另一种方法是创建“线程池”,在这里固定数量的线程从队列中拉取任务,并在完成后返回以继续工作。这种线程的重复使用使得系统具有良好的可伸缩性,但在 Java 服务器上高效实现这一点通常较为困难。Java 的基本 I/O(如套接字)并不完全支持非阻塞操作。java.nio 包,即 New I/O(或简称 NIO),具有异步 I/O 通道。通道可以执行非阻塞读写操作。它们还具有测试流准备好传输数据的能力。线程可以异步关闭通道,使交互更加优雅。我们将在接下来讨论与文件和网络连接工作的章节中讨论 NIO。

Java 提供了线程池和作业“执行器”服务作为 java.util.concurrent 包的一部分。这意味着你不必自己编写这些功能。在讨论 Java 的并发工具时,我们将对它们进行总结。

虚拟线程性能

Project Loom 的目标是提高线程性能——尤其是当涉及数千甚至数百万个线程时。在平台线程与虚拟线程上运行 run() 方法的速度没有任何差异。然而,创建和管理这些线程的速度更快。

让我们再次看看我们的URLDemo类。而不是总共四个线程,我们将这个数字提升到数千个。我们将取消生产者,并预先填充队列以便我们可以专注于我们的新消费者。我们将创建只需消耗一个 URL 的消费者——在获取另一个 URL 之前没有随机的、人为的延迟。这种行为模仿了虚拟线程的一个真实用例:一个单一的服务器在短时间内处理数百万个小请求。我们还将修改我们的打印语句,以便在里程碑出现而不是在每个 URL 被消耗后出现。我们的新URLDemo3将接受两个可选的命令行参数:要创建的 URL 数量(默认为 100,000)以及是否使用平台线程还是虚拟线程(默认为平台),这样我们可以比较性能的差异。

查看ch09/examples文件夹中URLConsumer3的源代码,以查看我们为这个新变体所做的调整。然后让我们仔细看看main()方法中的处理循环,看看它如何处理新的消费者。这里是相关部分:

    // Create and populate our shared queue object
    URLQueue queue = new URLQueue();
    for (int u = 1; u <= count; u++) {
      queue.addURL("http://some.url/path/" + u);
    }

    // Now the fun begins! Make one consumer for every URL
    for (int c = 0; c < count; c++) {
      URLConsumer3 consumer = new URLConsumer3("C" + c, queue);
      if (useVirtual) {
        Thread.startVirtualThread(consumer);
      } else {
        new Thread(consumer).start();
      }
    }

这段代码并不尝试重用消费者。顺便说一句,在现实世界中不重用线程有一些有效的原因。例如,你必须在使用之间手动清理一些共享数据。忘记了这一点的“行政琐事”,你可能会泄露敏感信息。(如果你在处理银行交易,你不会想意外使用之前的账号。)你可以通过假设一个单一线程会完成所有工作然后终止来简化你的代码。无论你是否使用虚拟线程,这一点都是正确的。

我们在中等的 Linux 桌面系统上使用 1,000,000 个 URL 测试了这个版本。平台线程在近一分钟内清除了队列(根据粗略的time实用程序为 58.661 秒)。效果不错!另一方面,虚拟线程在不到2 秒(1.867 秒)的时间内清除了队列。测试一个里程碑 URL 以打印是微不足道的。拖慢速度的不是每个消费者所做的任务。平台线程的真正瓶颈是数千次请求操作系统获取新的、昂贵的资源。Project Loom 消除了许多这种开销。使用虚拟线程并不保证更好的性能,但在这种情况下,它肯定会有所好处!

并发工具

到目前为止,在这一章中,我们演示了如何使用 Java 语言基元创建和同步线程。java.util.concurrent包和子包在此基础上构建,添加了重要的线程实用程序,并通过提供标准实现来编码一些常见的设计模式。这些领域的通用性大致按照以下顺序排列:

线程感知的集合实现

java.util.concurrent包在第七章中通过几种特定线程模型的实现增强了 Java 集合 API。这些包括Queue接口的定时等待和阻塞实现,以及非阻塞、并发访问优化的QueueMap接口实现。该包还为极其高效的“几乎总是读取”情况添加了“写时复制”ListSet实现。这些听起来可能很复杂,但它们很好地涵盖了一些常见情况。

Executors

Executor们运行任务,包括Runnable们,并将线程创建和池化的概念从用户抽象出来(这意味着你不需要自己编写)。Executors旨在作为一个高级别的替代品,用于创建新线程以处理一系列作业。与Executor一起,CallableFuture接口允许管理、返回值和异常处理。

低级别同步构造

java.util.concurrent.locks包包含一组类,包括LockCondition,这些类与 Java 语言级别的同步原语类似,并将它们提升到具体 API 的级别。例如,LockSupport辅助类包括两种方法,park()unpark(),它们替代了Thread类中已弃用的suspend()resume()方法。锁包还添加了非排他读/写锁的概念,允许在同步数据访问中实现更大的并发性。

高级别同步构造

这包括CyclicBarrierCountDownLatchSemaphoreExchanger类。这些类实现了从其他语言和系统中借鉴的常见同步模式,并可以作为新高级工具的基础。

原子操作(听起来非常像詹姆斯·邦德,是吧?)

java.util.concurrent.atomic包提供了对原始类型和引用执行原子“全有或全无”操作的包装器和实用程序。这包括简单组合原子操作,如在设置值之前测试值,并在一个操作中获取和增加数字。

除了 Java VM 对atomic操作包的优化外,所有这些实用程序都是用纯 Java 实现的,基于标准的 Java 语言同步构造。这意味着它们从某种意义上说只是便利工具,并没有真正为语言添加新的功能。它们的主要作用是在 Java 线程编程中提供标准模式和习惯用法,使其更安全和高效。一个很好的例子是Executor实用程序,它允许用户在预定义的线程模型中管理一组任务,而无需深入创建线程。这样的高级 API 不仅简化了编码,还允许更大程度的优化常见情况。

升级我们的队列演示

许多内建到 Java 中的并发特性在大型、更复杂的项目中会更加有用。但我们可以通过使用java.util.concurrent包中的线程安全的ConcurrentLinkedQueue类来升级我们那个简陋的 URL 处理演示。我们可以对其类型进行参数化,并完全摒弃我们自定义的URLQueue类:

// Directory: ch09/examples
// in URLDemo4.java
    ConcurrentLinkedQueue<String> queue =
        new ConcurrentLinkedQueue<>();

// in URLProducer4.java, just "add" instead of "addURL"
    queue.add(producerID + " " + url);

// in URLConsumer4.java, "poll" rather than "getURL"
    String url = queue.poll();
    // ...

我们需要稍微调整消费者和生产者的代码,但只是一点点,并且主要是为了使用普通队列操作的名称addpoll,而不是我们自定义的、以 URL 为中心的方法名称。但我们根本不需要担心URLQueue类。有时候你会需要自定义数据结构,因为现实世界是混乱的。但如果你能使用其中一个内置的同步存储选项,你就知道你得到了可以安全在多线程应用中使用的健壮存储和访问。

另一个考虑的升级是原子便利类。您可能还记得我们的消费者类有一个布尔标志,可以设置为 false 以结束消费者的处理循环。由于我们可以合理地假设多个线程可能访问我们的消费者,我们可以将该标志重新制作为AtomicBoolean类的实例,以确保战斗中的线程不能摧毁我们可怜的标志。(当然,我们可以使我们的访问方法synchronized,但我们想要突出显示 JDK 中已经存在的一些选项。)以下是URLConsumer4的有趣部分的简要概述:

  AtomicBoolean keepWorking;
  //...

  public void run() {
    while (keepWorking.get() || !queue.isEmpty()) {
      String url = queue.poll();
      //...
    }
  }

  public void setKeepWorking(boolean newState) {
    keepWorking.set(newState);
  }

使用AtomicBoolean需要更多的打字工作——调用设置/获取方法而不是简单的赋值或比较——但您可以得到所有您希望的安全处理。当您在各处具有复杂的多线程逻辑时,您可能会进行自己的状态管理。然而,在没有太多其他需要同步的代码的情况下,这些便利类确实非常方便。

结构化并发

除了虚拟线程为高并发应用带来的令人印象深刻的改进之外,Project Loom 还为 Java 引入了结构化并发。您可能听说过在线程世界中的“并行编程”。当您可以将一个较大的问题分解为可以分别解决且同时解决的更小问题(并行处理)时,您可以选择追求并行编程解决方案(明白了吧?)。

将大任务分解成子任务的这个概念与我们使用生产者和消费者的演示有许多相似之处,但这两种类型的问题并不完全相同。一个重大的区别在于如何处理错误。例如,如果我们在URLDemo类中创建消费者失败,我们可以简单地创建另一个并继续进行。但如果并行计算中的一个子任务失败,如何恢复就不那么明显了。应该取消所有其他子任务吗?如果其中一些已经完成了怎么办?如果我们想取消更大的“父”任务怎么办?

Java 19 引入了一个孵化器特性,StructuredTaskScope类,用于更好地封装子任务的工作。 (如果您把像虚拟线程这样的预览功能称为“β”增强功能,那么孵化器特性将是“α”增强功能。)您可以在JEP 428中了解其设计目标和实现细节。虽然本书不会介绍结构化并发性或执行器,但重要的是要知道 Java 为开发人员在处理并行和并发应用程序时提供了许多工具。事实上,Java 在这个领域为开发人员提供的支持正是为什么它仍然是生产后端的流行工具。

那么多要处理的线程

虽然本章我们不会深入研究并发包,但如果并发对您有趣或在您遇到的问题类型中证明有用,我们希望您知道接下来可以深入了解的地方。正如我们在“同步 URL 队列”中所注明的,《Java 并发编程实践》(jcip.net) by Brian Goetz 是实现真实世界多线程项目所必需的阅读材料。我们也要向 Doug Lea 致敬,他是*《Java 并发编程》(Addison-Wesley)*的作者,领导了添加这些包到 Java 中的团队,并且在创建它们方面负有重大责任。

除了线程之外,Java 对基本文件输入和输出(I/O)的本机支持在生产应用程序中占据了重要位置。在下一章中,我们将查看典型 I/O 的主要类。

复习问题

  1. 什么是线程?

  2. 如果要求线程“轮流”调用方法(意味着不能同时执行该方法的两个线程以避免损坏共享数据),可以向方法添加什么关键字?

  3. 哪些标志允许您编译包含预览功能代码的 Java 程序?

  4. 哪些标志允许您运行包含预览功能代码的 Java 程序?

  5. 一个本机线程支持多少平台线程?

  6. 一个本机线程支持多少虚拟线程?

  7. 语句x = x + 1;对变量x来说是原子操作吗?

  8. 哪个包包含流行集合类(如QueueMap)的线程安全版本?

代码练习

  1. 让我们建立一个时钟!使用一个JLabel和一个Thread(或者可以理解为一个指针和一个线程),制作一个小型图形时钟应用程序。Clock.java文件在ch09/exercises文件夹中包含一个骨架应用程序,它会在窗口中显示一个简单的JLabel对象。我们增加了标签字体的大小以提高可读性。您的时钟应该至少显示小时、分钟和秒。创建一个线程,让它睡眠一秒钟,然后增加时钟的显示。可以回顾一下“格式化日期和时间”中的日期和时间格式化示例。

  2. ch09/exercises/game 文件夹中的苹果投掷游戏目前使用平台线程,按照“重访线程动画”中的讨论,启动游戏时苹果会被抛出。苹果不会撞到任何东西,但会像被投掷一样弧线移动。我们将在第十二章中使这个动画更有趣和更具互动性。

    一旦你掌握了预期动画的感觉,将平台线程转换为虚拟线程。编译你的新版本并验证它仍然按预期工作。(请记住,根据你的 Java 版本,你可能需要使用额外的预览标志进行编译和运行。)

¹ 从历史上看,interrupt() 在所有 Java 实现中并不一致。

² “守护进程”一词(在 Unix 圈子里通常读作 day-mun)灵感来自Maxwell's demon,指的是希腊词汇中对较低神灵的称呼,而非恶意精灵。

³ 许多 Java 增强功能最初都是带有“Loom”之类时髦名称的工作进展。

⁴ 不幸的是,这些选项上的单破折号和双破折号前缀并非打字错误。命令行参数有着相当悠久的历史,Java 及其工具足够古老,以至于继承了一些遗留模式,同时仍需适应现代方法。大多数选项支持任意前缀,但偶尔需要遵循看似未写下的规则。犹豫不决时,像javac这样的工具支持另一选项:-help(或 --help)。提供该参数将打印出简明的选项列表和相关细节。

⁵ 不要混淆此上下文中的“序列化”术语与 Java 中的“对象序列化”,后者是一种使对象持久化的机制。然而,基础含义(依次放置一件事物)是相同的。在对象序列化的情况下,对象的数据按照一定顺序逐字节布局。对于线程而言,每个线程依次访问同步资源。

⁶ 即使能够容忍对象状态的轻微差异,现代多核系统在没有完美应用知识的情况下可能会造成严重破坏。而实现完美是困难的!如果你打算在现实世界中使用线程,Java 并发编程实战(由 Brian Goetz 等人编著,Addison-Wesley 出版社)是必读之作。

⁷ 《Java Threads》(由 Scott Oaks 和 Henry Wong 编著,O'Reilly 出版社)详细讨论了同步、调度和其他与线程相关的问题。

⁸ 从技术上讲,线程也可以通过已废弃的 stop() 调用来终止,但正如我们在本章开头提到的那样,这种做法有很多问题。

⁹ 当你看腻了飞来飞去的Foos时,可以按 Control-C 退出演示。

第十章:文件输入和输出

将数据存储在文件中并在以后检索是桌面和企业应用程序至关重要的功能。在本章中,我们将介绍java.iojava.nio包中一些最受欢迎的类。这些包为基本输入和输出(I/O)提供了丰富的工具集,并为 Java 中所有文件和网络通信的框架提供支持。图 10-1 展示了java.io包的广度。

我们首先来看看java.io中的流类,这些类是基本InputStreamOutputStreamReaderWriter类的子类。然后我们将检查File类,并讨论如何使用java.io中的类来读取和写入文件。我们还快速浏览一下数据压缩和序列化。在此过程中,我们介绍了java.nio包。这个“新”I/O 包(或 NIO)增加了专门用于构建高性能服务的重要功能。NIO 主要关注使用缓冲区(你可以在其中存储数据以更有效地利用其他资源)和通道(你可以高效地将数据放入其中,其他程序同样高效地从中读取数据)。在某些情况下,NIO 还提供了更好的 API,可以替代一些java.io功能。¹

ljv6 1001

图 10-1. java.io类层次结构

Java 中大多数 I/O 操作都是基于流的。在概念上,表示一种数据的流动,其中一个写入器位于一端,一个读取器位于另一端。当你使用java.io包执行终端输入和输出、读取或写入文件或通过 Java 网络套接字进行通信时(更多关于网络的内容请参阅第十三章),你将使用各种类型的流。当我们研究 NIO 包时,我们将发现一个类似的概念叫做通道。两者的主要区别在于流是围绕字节或字符而设计的,而通道则是围绕包含这些数据类型的“缓冲区”而设计的。缓冲区通常是用于数据的快速临时存储,从而更容易优化吞吐量。它们都大致完成相同的工作。让我们从流开始。以下是最受欢迎的流类的快速概述:

InputStreamOutputStream

抽象类定义了读取或写入无结构字节序列的基本功能。Java 中的所有其他字节流都建立在基本InputStreamOutputStream之上。

ReaderWriter

抽象类定义了读取或写入字符数据序列的基本功能,支持 Unicode。Java 中的所有其他字符流都建立在ReaderWriter之上。

InputStreamReaderOutputStreamWriter

通过按照特定字符编码方案(如 ASCII 或 Unicode)进行转换,将字节流和字符流进行桥接的类。请记住:在 Unicode 中,一个字符不一定是一个字节!

DataInputStreamDataOutputStream

专门的流过滤器增加了读写多字节数据类型(如数值原始数据和String对象)的能力,以标准化格式进行。

ObjectInputStreamObjectOutputStream

专门的流过滤器,能够写入整组序列化的 Java 对象并重新构造它们。

BufferedInputStreamBufferedOutputStreamBufferedReaderBufferedWriter

专门的流过滤器增加了缓冲以提高效率。在真实的 I/O 操作中,几乎总是会使用缓冲。

PrintStreamPrintWriter

简化文本打印的专门流。

PipedInputStreamPipedOutputStreamPipedReaderPipedWriter

在应用程序内移动数据的配对类。写入PipedOutputStreamPipedWriter的数据将从其对应的PipedInputStreamPipedReader中读取。

FileInputStreamFileOutputStreamFileReaderFileWriter

实现了从本地文件系统读取和写入文件的InputStreamOutputStreamReaderWriter

Java 中的流是单向的。java.io输入和输出类只代表简单流的两端。对于双向对话,您将使用每种类型的一个流。

InputStreamOutputStream,如图 10-2 所示,是定义所有字节流的最底层接口的抽象类。它们包含用于读取或写入无结构的字节级数据的方法。由于这些类是抽象的,你不能创建通用的输入或输出流。

ljv6 1002

图 10-2. 基本输入和输出流功能

Java 为诸如从文件读取和写入或与网络连接通信等活动实现了这些类的子类。由于所有字节流都继承自InputStreamOutputStream的结构,因此可以互换使用各种类型的字节流。还可以在基本流周围堆叠或包装特定类型的专门流,以添加缓冲、过滤、压缩或处理更高级别数据类型等功能。

ReaderWriterInputStreamOutputStream非常相似,不同之处在于它们处理的是字符而不是字节。作为真正的字符流,这些类可以正确处理 Unicode 字符,而字节流则并非总是如此。通常需要在这些字符流和物理设备(如磁盘和网络)的字节流之间进行桥接。InputStreamReaderOutputStreamWriter是特殊的类,它们使用像 ASCII 或 UTF-8 这样的字符编码方案来在字符流和字节流之间进行转换。

本节描述了几种流类型,但不包括FileInputStreamFileOutputStreamFileReaderFileWriter。我们将在下一节中讨论文件流,在那里我们将涵盖如何在 Java 中访问文件系统。

基本输入/输出

InputStream对象的典型示例是 Java 应用程序的标准输入。与 C 语言中的stdin或 C++中的cin类似,这是命令行(非 GUI)程序的输入来源。它是来自环境的输入流,通常是一个终端窗口或可能是另一个命令的输出。java.lang.System类是系统相关资源的通用存储库,在静态变量System.in中提供了对标准输入流的引用。它还在outerr变量中分别提供了标准输出流标准错误流。²以下示例显示了它们之间的对应关系:

    InputStream stdin = System.in;
    OutputStream stdout = System.out;
    OutputStream stderr = System.err;

这段代码隐藏了System.outSystem.err不仅仅是OutputStream对象,而是更专门和有用的PrintStream对象的事实。我们稍后会在“PrintWriter and PrintStream”中解释这些内容,但目前我们可以将outerr引用为OutputStream对象,因为它们都是从OutputStream派生出来的。

您可以使用InputStreamread()方法从标准输入一次读取一个字节。如果您仔细查看在线文档,您会发现基础InputStream类的read()方法是一个抽象方法。System.in背后是InputStream的特定实现,它提供了read()方法的实际实现:

    try {
      int val = System.in.read();
    } catch (IOException e) {
      // ...
    }

尽管我们说read()方法读取字节值,但示例中的返回类型是int而不是byte。这是因为 Java 中基本输入流的read()方法使用了从 C 语言继承过来的约定,用特殊值指示流的结束。字节值在 0 到 255 之间返回,并且特殊值-1用于指示已到达流的结尾。在使用简单的read()方法时,您可以测试这种条件。然后,如果需要,可以将值转换为字节。以下示例从输入流中读取每个字节并打印其值:

    try {
      int val;
      while((val=System.in.read()) != -1) {
        System.out.println((byte)val);
      }
    } catch (IOException e) {
      // Oops. Handle the error or print an error message
    }

如我们在示例中所示,read() 方法也可能抛出 IOException,如果在底层流源中读取时出现错误。IOException 的各种子类可能表示源(如文件或网络连接)发生了错误。此外,读取比单个字节更复杂数据类型的高级流可能会抛出 EOFException(“文件结尾”),这表明流的意外或过早结束。

read() 的重载形式会将字节数组填充为可能的最大数据,并返回读取的字节数:

    byte [] buff = new byte [1024];
    int got = System.in.read(buff);

理论上,我们还可以使用 available() 方法在给定时间内检查 InputStream 上可用于读取的字节数。有了这些信息,我们可以创建一个恰好大小的数组:

    int waiting = System.in.available();
    if (waiting > 0) {
      byte [] data = new byte [ waiting ];
      System.in.read(data);
      // ...
    }

但是,这种技术的可靠性取决于底层流实现是否能够检测到可以检索多少数据。它通常适用于文件,但不应该依赖于所有类型的流。

这些 read() 方法会阻塞,直到读取到至少一些数据(至少一个字节)。一般来说,您必须检查返回的值,以确定您读取了多少数据,并且是否需要继续读取。 (我们在本章后面将介绍非阻塞 I/O。)InputStreamskip() 方法提供了一种跳过一定数量字节的方法。根据流的实现方式,跳过字节可能比读取它们更有效率。

close() 方法关闭流并释放任何关联的系统资源。在使用完流后记得关闭大多数类型的流对性能很重要。在某些情况下,当对象被垃圾回收时,流可能会自动关闭,但依赖这种行为并不是一个好主意。try-with-resources 功能在 “try with Resources” 中讨论,可以更容易地自动关闭流和其他可关闭实体。我们将在 “File Streams” 中看到一些示例。接口 java.io.Closeable 标识了所有可以关闭的流、通道和相关实用类。

字符流

在早期的 Java 版本中,一些 InputStreamOutputStream 类型包含了用于读取和写入字符串的方法,但大多数情况下它们是通过天真地假设 16 位 Unicode 字符等同于流中的 8 位字节来操作的。这对于拉丁-1(ISO 8859-1)字符有效,但对于与不同语言一起使用的其他编码的世界则不适用。

java.io ReaderWriter字符流类被引入为仅处理字符数据的流。当您使用这些类时,您仅考虑字符和字符串数据。您允许底层实现处理字节到特定字符编码的转换。正如您将看到的,有一些ReaderWriter的直接实现,例如用于读取和写入文件的实现。

更一般地说,两个特殊类InputStreamReaderOutputStreamWriter弥合了字符流和字节流之间的差距。它们分别是ReaderWriter,可以包装在任何底层字节流周围,使其成为字符流。编码方案在字节(可能以表示多字节字符的组形式出现)和 Java 的双字节字符之间进行转换。编码方案可以在InputStreamReaderOutputStreamWriter的构造函数中通过名称指定。为方便起见,默认构造函数使用系统的默认编码方案。

让我们看看如何使用读取器和java.text.NumberFormat类从命令行中的用户检索数字输入。我们假设来自System.in的字节使用系统的默认编码方案:

// file: ch10/examples/ParseKeyboard.java

    try {
      InputStream in = System.in;
      InputStreamReader charsIn = new InputStreamReader(in);
      BufferedReader bufferedCharsIn = new BufferedReader(charsIn);

      String line = bufferedCharsIn.readLine();
      int i = NumberFormat.getInstance().parse(line).intValue();
      // ...
    } catch (IOException e) {
      // ...
    } catch (ParseException pe) {
      // ...
    }

首先,我们在System.in周围包装一个InputStreamReader。该读取器使用默认编码方案将System.in的传入字节转换为字符。然后,我们在InputStreamReader周围包装一个BufferedReaderBufferedReader添加了readLine()方法,我们可以使用该方法将一整行文本(最多到达平台特定的行终止符字符组合)读入String中。然后,使用第八章中描述的技术将字符串解析为整数。自己试试看。提示时,尝试提供不同的输入。如果输入“0”会发生什么?如果只输入您的名字会发生什么?

我们刚刚采取了面向字节的输入流System.in,并安全地将其转换为Reader以读取字符。如果我们希望使用与系统默认值不同的编码,则可以在InputStreamReader的构造函数中指定它,如下所示:

    InputStreamReader reader = new InputStreamReader(System.in, "UTF-8");

对于从读取器读取的每个字符,InputStreamReader读取一个或多个字节,并执行必要的 Unicode 转换。

当我们讨论java.nio.charset包时,我们将回到字符编码的主题“新 I/O 文件 API”,该包允许您查找和使用编码器和解码器。InputStreamReaderOutputStreamWriter都可以接受Charset编解码器对象以及字符编码名称。

流包装器

如果您想要做的不仅仅是读取和写入字节或字符序列怎么办?我们可以使用 过滤流,它是 InputStreamOutputStreamReaderWriter 的一种类型,它包装另一个流并添加新功能。过滤流将目标流作为其构造函数的参数,并进行一些额外的处理,然后将调用委托给目标。例如,我们可以构造一个 BufferedInputStream 来包装系统标准输入:

    InputStream bufferedIn = new BufferedInputStream(System.in);

BufferedInputStream 预先读取并缓冲一定量的数据。它在底层流周围包装了一个额外的功能层。Figure 10-3 显示了 DataInputStream 的这种排列方式,它可以读取更高级别的数据类型,如 Java 的基本类型和字符串。

ljv6 1003

图 10-3. 层叠流

正如您从前面的代码片段中看到的那样,BufferedInputStream 过滤器是 InputStream 的一种类型。因为过滤流本身是基本流类型的子类,所以它们可以作为其他过滤流的构造参数。这使得可以将过滤流层叠在一起,以提供不同的功能组合。例如,我们可以首先用 BufferedInputStream 包装我们的 System.in 来获得输入缓冲,然后再用 DataInputStream 包装 BufferedInputStream 来读取带缓冲区的特殊数据类型。

Java 提供了用于创建新类型过滤流的基类:FilterInputStreamFilterOutputStreamFilterReaderFilterWriter。这些超类通过将它们所有的方法调用委托给它们的底层流来提供过滤的基本机制。要创建自己的过滤流,可以扩展这些类并重写各种方法以添加所需的额外处理。

数据流

DataInputStreamDataOutputStream 是过滤流,允许您读取或写入字符串(而不是单个字符)和由多个字节组成的原始数据类型。DataInputStreamDataOutputStream 分别实现了 DataInputDataOutput 接口。这些接口定义了用于读取或写入字符串以及所有 Java 原始类型的方法,包括数字和布尔值。DataOutputStream 对这些值进行编码,以便在任何机器上正确读取,然后将它们写入其底层的字节流。DataInputStream 从其底层字节流中获取编码的数据并将其解码为原始类型和值。

您可以从 InputStream 构造一个 DataInputStream,然后使用诸如 readDouble() 这样的方法来读取原始数据类型:

    DataInputStream dis = new DataInputStream(System.in);
    double d = dis.readDouble();

此片段将标准输入流包装在 DataInputStream 中,并使用它来读取一个 double 值。readDouble() 方法从流中读取字节,并从中构造一个 double 值。DataInputStream 方法期望数字数据类型的字节采用网络字节顺序,这是一种标准,指定多字节值的高阶字节先发送(也称为大端序;参见“字节顺序”)。

DataOutputStream 类提供了与 DataInputStream 的读取方法对应的写入方法。我们输入片段的补充如下:

    double d = 3.1415926;
    DataOutputStream dos = new DataOutputStream(System.out);
    dos.writeDouble(d);
警告

DataOutputStreamDataInputStream 处理二进制数据,而不是人类可读的文本。通常,您会使用 DataInputStream 来读取由 DataOutputStream 生成的内容。这些过滤流非常适合直接处理像图像文件之类的内容。

DataInputStreamDataOutputStreamreadUTF()writeUTF() 方法使用 UTF-8 字符编码读取和写入 Java String,该编码使用 Unicode 字符。正如在第八章中讨论的那样,UTF-8 是一种广泛使用的 ASCII 兼容的 Unicode 字符编码。并非所有编码都保证能够保存所有 Unicode 字符,但 UTF-8 可以。您还可以通过将 UTF-8 指定为编码名称,将其与 ReaderWriter 流一起使用。

缓冲流

BufferedInputStreamBufferedOutputStreamBufferedReaderBufferedWriter 类在流路径中添加了一个指定大小的数据缓冲区。缓冲区可以通过减少与 read()write() 方法调用相对应的物理读取或写入操作次数来提高效率,如图 10-4 所示。

ljv6 1004

图 10-4. 使用缓冲区和不使用缓冲区读取数据

您可以通过适当的输入或输出流和缓冲区大小创建一个缓冲流。(您也可以将另一个流包装在缓冲流中,以便它从缓冲中获益。)以下是一个简单的缓冲输入流示例:

    BufferedInputStream bis = new BufferedInputStream(myInputStream, 32768);
    // bis will store up to 32K of data from myInputStream at a time
    // we can then read from bis at any time
    byte b = bis.read();

在这个例子中,我们指定了一个 32 KB 的缓冲区大小。如果在构造函数中没有指定缓冲区的大小,Java 会为我们创建一个合理大小的缓冲区(当前默认为 8 KB)。在我们第一次调用 read() 方法时,bis 会尝试用数据填充整个 32 KB 的缓冲区(如果数据可用)。之后,对 read() 的调用会从缓冲区中检索数据,并在必要时重新填充缓冲区。

BufferedOutputStream 的工作方式类似。调用 write() 方法将数据存储在缓冲区中;只有当缓冲区填满时,数据才实际写入到底层流中。你也可以使用 flush() 方法随时将 BufferedOutputStream 的内容写出。flush() 方法实际上是 OutputStream 类本身的方法。它允许你确保所有底层流中的数据已保存或发送。

BufferedReaderBufferedWriter类的工作方式与它们的基于字节的对应类相同,不同之处在于它们操作的是字符而不是字节。

PrintWriter 和 PrintStream

另一个有用的包装类是java.io.PrintWriter。这个类提供了一系列重载的print()方法,将它们的参数转换为字符串并将它们推送到流中。一组补充的println()便捷方法在字符串末尾添加了一个新行。对于格式化文本输出,printf()和完全相同的format()方法允许您向流中写入 C printf风格的格式化文本。

PrintWriter是一个不同寻常的字符流,因为它可以包装OutputStream或另一个WriterPrintWriter是传统的PrintStream字节流的更强大的大哥。System.outSystem.err流都是PrintStream对象,这一点在本书中已经多次见识过:

    System.out.print("Hello, world...\n");
    System.out.println("Hello, world...");
    System.out.printf("The answer is %d\n", 17);
    System.out.println(3.14);

创建PrintWriter对象时,可以在构造函数中传递一个额外的布尔值,指定是否“自动刷新”。如果这个值为truePrintWriter在每次发送换行符时会自动执行flush()操作,刷新底层的OutputStreamWriter

    // Stream automatically flushes after a newline.
    PrintWriter pw = new PrintWriter(myOutputStream, true);
    pw.println("Hello!");

当您将此技术与缓冲输出流一起使用时,它就像一个终端,逐行输出数据。

PrintStreamPrintWriter相比常规字符流的另一个重大优势在于,它们可以屏蔽底层流抛出的异常。与其他流类的方法不同,PrintWriterPrintStream的方法不会抛出IOException。相反,它们提供了一个方法来显式检查错误(如果需要的话)。这使得打印文本的常见操作变得更加容易。您可以使用checkError()方法来检查错误:

    System.out.println(reallyLongString);
    if (System.out.checkError()) {
      // uh oh
    }

PrintStreamPrintWriter的这个特性意味着您通常可以将文本输出到各种目标,而无需将每个打印语句都包装在try块中。但如果您正在写入重要信息并希望确保没有任何错误发生,它仍然会让您访问到发生的任何错误。

java.io.File 类

打印输出的一个流行目标是文件。java.io.File类封装了关于文件或目录的信息访问。您可以使用File获取文件的属性信息,列出目录中的条目,并执行基本的文件系统操作,比如删除文件或创建新目录。虽然File对象处理这些“元”操作,但它不提供读写文件数据的 API;您需要使用文件流来完成这些操作。

文件构造函数

您可以从String路径名创建File的实例:

    File fooFile = new File("/tmp/foo.txt");
    File barDir = new File("/tmp/bar");

您还可以使用以 JVM 当前工作目录为起点的相对路径来创建文件:

    File f = new File("foo");

您可以通过读取System属性列表中的user.dir属性来确定当前工作目录:

    System.getProperty("user.dir"); // e.g.,"/Users/pat"

File 构造函数的重载版本允许你将目录路径和文件名指定为单独的 String 对象:

    File fooFile = new File("/tmp", "foo.txt");

还有另一种变化,你可以使用 File 对象指定目录,用 String 指定文件名:

    File tmpDir = new File("/tmp"); // File for directory /tmp
    File fooFile = new File (tmpDir, "foo.txt");

这些 File 构造函数实际上都不创建文件或目录,并且为不存在的文件创建 File 对象不会报错。File 对象只是文件或目录的句柄,你可能希望读取、写入或测试其属性。例如,你可以使用 exists() 实例方法来了解文件或目录是否存在。许多应用程序在保存文件之前执行此测试。如果所选文件不存在,太好了!应用程序可以安全地保存其数据。如果文件已经存在,则通常会收到一个覆盖警告,以确保你确实要替换旧文件。

路径本地化

在 Java 中,路径名应遵循本地文件系统的约定。Windows 文件系统使用具有驱动器号的不同(顶级目录)(例如,“C:”)和反斜线 (),而不是 Linux 和 macOS 系统中使用的单个根和正斜线 (/) 路径分隔符。

Java 尝试弥补这种差异。例如,在 Windows 平台上,它接受斜线或反斜线的路径。然而,在 macOS 和 Linux 上,它只接受斜线。

你最好确保你遵循主机文件系统的文件名约定。如果你的应用程序有一个 GUI,它可以根据用户的请求打开和保存文件,那么你应该能够使用 Swing 的 JFileChooser 类来处理这个功能。这个类封装了一个图形文件选择对话框。JFileChooser 的方法会为你处理系统相关的文件名特性。

如果你的应用程序需要代表自己处理文件,那么事情就会变得有点复杂。File 类包含一些 static 变量,以使这项任务更加简单。File.separator 定义了一个 String,指定了本地主机上的文件分隔符(例如,在 Unix 和 macOS 系统上是 /,在 Windows 系统上是 \);File.separatorChar 提供了相同的信息,但以一个 char 的形式提供。

你可以以几种方式使用这些与系统相关的信息。可能最简单的本地化路径名的方法是选择一个你在内部使用的约定,比如正斜线 (/),然后使用 String 的替换方法来替换本地化的分隔符字符:

    // we'll use forward slash as our standard
    String path = "mail/2023/june";
    path = path.replace('/', File.separatorChar);
    File mailbox = new File(path);

或者,你可以使用路径名的组件并在需要时构建本地路径名:

    String [] path = { "mail", "2004", "june", "merle" };

    StringBuffer sb = new StringBuffer(path[0]);
    for (int i=1; i< path.length; i++) {
      sb.append(File.separator + path[i]);
    }
    File mailbox = new File(sb.toString());
注意

请记住,在 Java 中,当反斜杠字符 (\) 在源代码中用作 String 时,Java 会将其解释为转义字符。要获得一个字面上的反斜杠,你必须使用双反斜杠:\\

为了解决具有多个“根目录”(例如,在 Windows 上是C:\)的文件系统的问题,File类提供了静态方法listRoots(),它返回一个File对象数组,对应于文件系统根目录。你可以在jshell中尝试这个:

jshell> import java.io.File;

// On a Linux box:
jshell> File.listRoots()
$2 ==> File[1] { / }

// On Windows:
jshell> File.listRoots()
$3 ==> File[2] { C:\, D:\ }

同样,在 GUI 应用程序中,图形文件选择对话框通常会屏蔽您免受这个问题的影响。

文件操作

一旦我们有了一个File对象,我们就可以使用它对其表示的文件或目录执行许多标准操作。几个方法让我们向File询问问题。例如,如果File表示一个普通文件,则isFile()返回true,而如果它是一个目录,则isDirectory()返回trueisAbsolute()指示File是否封装了绝对或相对路径规范。相对路径是相对于应用程序的工作目录的。绝对路径是一个系统相关的概念,表示该路径不与工作目录或当前驱动器绑定。在 Unix 和 macOS 中,绝对路径以斜杠开头:/Users/pat/foo.txt。在 Windows 中,它是包括驱动器号的完整路径:C:\Users\pat\foo.txt(而且,再次强调,如果系统中有多个驱动器,则它可以位于与工作目录不同的驱动器上)。

通过getName()getPath()getAbsolutePath()getParent()方法可以获得路径名的各个组成部分。getName()方法返回一个没有任何目录信息的文件名的String。如果File具有绝对路径规范,则getAbsolutePath()返回该路径。否则,它会返回相对路径附加到当前工作目录(尝试将其转换为绝对路径)。getParent()方法返回文件或目录的父目录。

getPath()getAbsolutePath()返回的字符串可能不遵循与底层文件系统相同的大小写约定。你可以通过使用getCanonicalPath()方法来检索文件系统自己的(或“规范的”)版本的文件路径。例如,在 Windows 中,你可以创建一个File对象,它的getAbsolutePath()C:\Autoexec.bat,但它的getCanonicalPath()C:\AUTOEXEC.BAT;两者实际上指向同一个文件。这对于比较文件名或向用户显示文件名很有用。

你可以使用lastModified()setLastModified()方法获取或设置文件或目录的修改时间。该值是一个long,表示自 Unix 纪元(Unix 中的“第一个”日期的名称:1970 年 1 月 1 日 00:00:00 GMT)以来的毫秒数。我们还可以使用length()方法获取文件的大小,以字节为单位。

这里有一段打印文件信息的代码片段:

    File fooFile = new File("/tmp/foo.txt");

    String type = fooFile.isFile() ? "File " : "Directory ";
    String name = fooFile.getName();
    long len = fooFile.length();
    System.out.println(type + name + ", " + len + " bytes ");

如果File对象对应的是一个目录,我们可以使用list()方法或listFiles()方法列出目录中的文件:

    File tmpDir = new File("/tmp");
    String [] fileNames = tmpDir.list();
    File [] files = tmpDir.listFiles();

list()返回一个String对象数组,其中包含文件名。listFiles()返回一个File对象数组。请注意,在任何情况下文件都不保证以任何形式(例如按字母顺序)排序。您可以使用集合 API 按字母顺序对字符串进行排序,如下所示:

    List list = Arrays.asList(fileNames);
    Collections.sort(list);

如果File引用不存在的目录,我们可以使用mkdir()mkdirs()创建目录。mkdir()方法最多创建单个目录级别,因此路径中的任何中间目录都必须已经存在。mkdirs()创建必要的所有目录级别以创建File规范的完整路径。在任何情况下,如果无法创建目录,则该方法返回false。使用renameTo()重命名文件或目录,使用delete()删除文件或目录。

虽然可以使用File对象创建目录,但通常不使用File来创建文件;这通常是在使用FileOutputStreamFileWriter写入数据时隐含完成的,稍后我们会讨论。例外是createNewFile()方法,您可以使用它在File位置创建一个新的零长度文件。

从文件系统的所有其他文件创建操作方面来看,createNewFile()操作是原子的³。Java 从createNewFile()返回一个布尔值,告诉您文件是否已创建。以这种方式创建新文件在您还使用deleteOnExit()的情况下特别有用,后者标记文件在 Java 虚拟机退出时自动删除。此组合允许您保护资源或创建一次只能在单个实例中运行的应用程序。

File类本身相关的另一种文件创建方法是静态方法createTempFile(),它使用自动生成的唯一名称在指定位置创建文件。通常与deleteOnExit()结合使用createTempFile()。网络应用程序经常使用这种组合来创建临时文件,用于存储请求或构建响应。

toURL()方法将文件路径转换为file: URL 对象。URL 是一种抽象,允许您指向网络上任何类型的对象。将File引用转换为 URL 可能对与处理 URL 的更一般实用程序保持一致性有用。例如,Java 的 NIO 使用 URL 引用直接在 Java 代码中实现的新类型的文件系统。

表 10-1 总结了File类提供的方法。

表 10-1. 文件方法

方法返回类型描述
canExecute()boolean文件是否可执行?
canRead()boolean文件(或目录)是否可读?
canWrite()boolean文件(或目录)是否可写?
createNewFile()boolean创建一个新文件。
createTempFile (String pfx, Stringsfx)File静态方法,在默认临时文件目录中创建一个带有指定前缀和后缀的新文件。
delete()boolean删除文件(或目录)。
deleteOnExit()VoidJava 运行时系统在退出时删除文件。
exists()boolean文件(或目录)是否存在?
getAbsolutePath()String返回文件(或目录)的绝对路径。
getCanonicalPath()String返回文件(或目录)的绝对路径,大小写正确,并且解析了相对元素。
getFreeSpace()long获取包含此路径的分区上未分配空间的字节数,如果路径无效则返回 0。
getName()String返回文件(或目录)的名称。
getParent()String返回文件(或目录)的父目录名称。
getPath()String返回文件(或目录)的路径。(不要与toPath()混淆。)
getTotalSpace()long获取包含文件路径的分区的大小(以字节为单位),如果路径无效则返回 0。
getUseableSpace()long获取包含此路径的分区上用户可访问的未分配空间的字节数,如果路径无效则返回 0。此方法试图考虑用户的写权限。
isAbsolute()boolean文件名(或目录名)是否是绝对的?
isDirectory()boolean该项是否为目录?
isFile()boolean该项是否为文件?
isHidden()boolean该项是否隐藏?(依赖于系统。)
lastModified()long返回文件(或目录)的最后修改时间。
length()long返回文件的长度。
list()String []返回目录中文件的列表。
listFiles()File[]返回目录内容作为File对象数组。
listRoots()File[]返回根文件系统的数组,如果有的话(例如,C:/,D:/)。
mkdir()boolean创建目录。
mkdirs()boolean创建路径中的所有目录。
renameTo(File dest )boolean重命名文件(或目录)。
setExecutable()boolean设置文件的执行权限。
setLastModified()boolean设置文件(或目录)的最后修改时间。
setReadable()boolean设置文件的读权限。
setReadOnly()boolean设置文件为只读状态。
setWriteable()boolean设置文件的写权限。
toPath()java.nio.file.Path将文件转换为 NIO 文件路径。(不要与getPath()混淆。)
toURL()java.net.URL生成文件(或目录)的 URL 对象。

File Streams

你可能已经对文件听得耳朵生茧了,但我们甚至还没有写一个字节呢!现在,让我们开始享受乐趣吧。Java 提供了两种基本流用于从文件中读取和写入:FileInputStreamFileOutputStream。这些流提供了基本的字节导向 InputStreamOutputStream 功能,用于读取和写入文件。它们可以与前面描述的过滤流结合使用,以与其他流通信方式相同的方式处理文件。

可以从 String 路径名或 File 对象创建 FileInputStream

    FileInputStream in = new FileInputStream("/etc/motd");

创建 FileInputStream 时,Java 运行时系统尝试打开指定的文件。因此,如果指定的文件不存在,FileInputStream 构造函数可能会抛出 FileNotFoundException,或者在发生其他 I/O 错误时抛出 IOException。你必须在代码中捕获这些异常。在可能的情况下,习惯上使用 try-with-resources 结构来自动关闭文件是一个好习惯:

  try (FileInputStream fin = new FileInputStream("/etc/motd") ) {
    // ....
    // fin will be closed automatically if needed
    // upon exiting the try clause.
  }

当你首次创建流时,它的 available() 方法和 File 对象的 length() 方法应该返回相同的值。

要将文件中的字符作为 Reader 读取,可以将 InputStreamReader 包装在 FileInputStream 周围。你也可以使用提供的便利类 FileReaderFileReader 实际上只是一个带有一些默认值的 InputStreamReader 包装在 FileInputStream 中。

下面的类 ListIt 是一个小型实用程序,将文件或目录的内容打印到标准输出:

//file: ch10/examples/ListIt.java
import java.io.*;

class ListIt {
  public static void main (String args[]) throws Exception {
    File file =  new File(args[0]);

    if (!file.exists() || !file.canRead()) {
      System.out.println("Can't read " + file);
      return;
    }

    if (file.isDirectory()) {
      String [] files = file.list();
      for (String file : files)
        System.out.println(file);
    } else {
      try {
        Reader ir = new InputStreamReader(
            new FileInputStream(file) );

        BufferedReader in = new BufferedReader(ir);
        String line;
        while ((line = in.readLine()) != null)
          System.out.println(line);
      }
      catch (FileNotFoundException e) {
          System.out.println("File Disappeared");
      }
    }
  }
}

ListIt 从其第一个命令行参数构造一个 File 对象,并测试该 File 是否存在且可读。如果 File 是一个目录,ListIt 输出目录中文件的名称。否则,ListIt 按行读取并输出文件内容。试试看!你能在 ListIt.java 上使用 ListIt 吗?

对于写入文件,可以从 String 路径名或 File 对象创建 FileOutputStream。然而,与 FileInputStream 不同的是,FileOutputStream 构造函数不会抛出 FileNotFoundException。如果指定的文件不存在,FileOutputStream 将创建文件。FileOutputStream 构造函数可能会在发生其他 I/O 错误时抛出 IOException,因此仍然需要处理此异常。

如果指定的文件存在,FileOutputStream 将打开它进行写入。随后调用 write() 方法时,新数据将覆盖文件的当前内容。如果需要向现有文件追加数据,可以使用一个接受布尔型 append 标志的构造函数形式:

    FileOutputStream fooOut =
        new FileOutputStream(fooFile); // overwrite fooFile
    FileOutputStream pwdOut =
        new FileOutputStream("/etc/passwd", true); // append

另一种向文件追加数据的方式是使用 RandomAccessFile,我们将稍后讨论。

与读取一样,如果要向文件写入字符(而不是字节),可以在 FileOutputStream 周围包装一个 OutputStreamWriter。如果要使用默认的字符编码方案,可以使用 FileWriter 类,这是一个方便的选择。

下面的代码从标准输入读取一行数据,并将其写入文件 /tmp/foo.txt

    String s = new BufferedReader(
        new InputStreamReader(System.in) ).readLine();
    File out = new File("/tmp/foo.txt");
    FileWriter fw = new FileWriter (out);
    PrintWriter pw = new PrintWriter(fw);
    pw.println(s);
    pw.close();

注意我们如何将 FileWriter 包装在 PrintWriter 中以便写入数据。此外,作为一个良好的文件系统使用者,在完成操作后调用 close() 方法。在这里,关闭 PrintWriter 也会关闭底层的 Writer

RandomAccessFile

java.io.RandomAccessFile 类提供了在文件中任意位置读取和写入数据的能力。RandomAccessFile 实现了 DataInputDataOutput 接口,因此你可以像使用 DataInputStreamDataOutputStream 一样在文件中任意位置读取和写入字符串和基本类型数据。但是,因为这个类提供对文件数据的随机访问而不是顺序访问,所以它不是 InputStreamOutputStream 的子类。

可以根据 String 路径名或 File 对象创建 RandomAccessFile。构造函数还接受第二个 String 参数,指定文件的模式。使用字符串 "r" 表示只读文件,使用 "rw" 表示读/写文件:

    try {
      RandomAccessFile users = new RandomAccessFile("Users", "rw")
    } catch (IOException e) { ... }

当以只读模式创建 RandomAccessFile 时,Java 尝试打开指定的文件。如果文件不存在,RandomAccessFile 会抛出 IOException。然而,如果以读/写模式创建 RandomAccessFile,如果文件不存在,对象会创建该文件。构造函数仍然可能因为其他 I/O 错误而抛出 IOException,因此你仍然需要处理这个异常。

创建了 RandomAccessFile 后,你可以调用任何常规的读取和写入方法,就像使用 DataInputStreamDataOutputStream 一样。如果尝试向只读文件写入数据,写入方法会抛出 IOException

RandomAccessFile 的特殊之处在于 seek() 方法。这个方法接受一个 long 值,并将其用于设置文件中的读写位置。你可以使用 getFilePointer() 方法来获取当前位置。如果需要向文件末尾追加数据,可以使用 length() 确定位置,然后 seek() 到该位置。你可以写入或定位到文件末尾以外的位置,但不能从文件末尾以外读取。如果尝试这样做,read() 方法会抛出 EOFException 异常。

下面是一个简单数据库写入数据的示例:

    users.seek(userNum * RECORDSIZE);
    users.writeUTF(userName);
    users.writeInt(userID);

在这段代码中,我们假设 userNameString 长度以及其后的任何数据都适合指定的记录大小内。

新 I/O 文件 API

现在我们将注意力从原始的“经典”Java 文件 API 转向 NIO 文件 API。正如我们前面提到的,NIO 文件 API 可以被视为经典 API 的替代或补充。新 API 将 Java 移向更高性能和更灵活的 I/O 风格,支持可选择的和异步可中断的通道(后面将详细讨论选择和使用通道)。在处理文件时,新 API 的优势在于在 Java 中提供了更完整的文件系统抽象。

除了更好地支持现有的、真实世界中的文件系统类型——包括新的和受欢迎的复制和移动文件、管理链接以及获取详细文件属性如所有者和权限的能力——NIO 允许您直接在 Java 中实现全新类型的文件系统。最好的例子是 ZIP 文件系统提供者。您可以将 ZIP 归档文件“挂载”为文件系统。您可以使用标准 API 直接在归档文件中处理文件,就像处理任何其他文件系统一样。

NIO 文件包还提供了一些工具,这些工具多年来可以节省 Java 开发人员大量重复的代码,包括目录树变更监视、文件系统遍历、文件名“匹配”(使用通配符匹配文件名的行话)以及直接将整个文件读取到内存的便利方法。

我们将在本节介绍基本的 NIO 文件 API,并在章节末回到缓冲区和通道的主题。特别是,我们将讨论ByteChannelFileChannel,您可以将其视为用于读取和写入文件和其他类型数据的备选、基于缓冲区的流。

FileSystem 和 Path

java.nio.file 包中有三个主要角色:

FileSystem

Path 是底层存储机制并且作为Path对象的工厂。

FileSystems

FileSystem 对象的工厂。

Path

文件系统中文件或目录的位置。

Files

一个实用类,包含一组丰富的静态方法,用于操作 Path 对象以执行与经典 API 类似的所有基本文件操作。

FileSystems(复数形式)类是我们的起点。让我们创建几个文件系统:

    // The default host computer filesystem
    FileSystem fs = FileSystems.getDefault();

    // A custom filesystem for ZIP files, no special properties
    Map<String,String> props = new HashMap<>();
    URI zipURI = URI.create("jar:file:/Users/pat/tmp/MyArchive.zip");
    FileSystem zipfs = FileSystems.newFileSystem(zipURI, props);

正如本代码片段所示,我们请求默认的文件系统来在主机环境中操作文件。我们还使用FileSystems类来构建另一个FileSystem,通过一个统一资源标识符(或 URI,类似于 URL 的特殊标识符),该标识符引用自定义文件系统类型。我们使用jar:file作为我们的 URI 协议,以指示我们正在处理 JAR 或 ZIP 文件。

FileSystem 实现了 Closeable,当关闭一个 FileSystem 时,所有与其关联的打开文件通道和其他流对象也将被关闭。在那时尝试读取或写入这些通道将抛出异常。请注意,默认文件系统(与主机计算机关联)无法关闭。

一旦有了FileSystem,就可以将其用作代表文件或目录的Path对象的工厂。您可以使用字符串表示法获取Path,就像经典的File类一样。随后,您可以使用Files实用程序的方法创建、读取、写入或删除该项:

    Path fooPath = fs.getPath("/tmp/foo.txt");
    OutputStream out = Files.newOutputStream(fooPath);

此示例打开一个OutputStream以写入文件foo.txt。默认情况下,如果文件不存在,它将被创建;如果文件已存在,则在写入新数据之前将其截断(设置为零长度)—但您可以使用选项更改这些结果。我们将在下一节中详细讨论Files方法。

Path类实现了java.lang.Iterable接口,可用于迭代其字面路径组件,例如前面片段中的斜杠分隔的tmpfoo.txt。(如果要遍历路径以查找其他文件或目录,则可能更感兴趣的是我们稍后将讨论的DirectoryStreamFileVisitor。)Path还实现了java.nio.file.Watchable接口,允许对其进行监视以进行更改。

Path具有方便的方法来解析相对于文件或目录的路径:

    Path patPath =  fs.getPath("/User/pat/");

    Path patTmp = patPath.resolve("tmp"); // "/User/pat/tmp"

    // Same as above, using a Path
    Path tmpPath = fs.getPath("tmp");
    Path patTmp = patPath.resolve(tmpPath); // "/User/pat/tmp"

    // Resolving a given absolute path against any path just yields given path
    Path absPath = patPath.resolve("/tmp"); // "/tmp"

    // Resolve sibling to Pat (same parent)
    Path danPath = patPath.resolveSibling("dan"); // "/Users/dan"

在此片段中,我们展示了Path方法resolve()resolveSibling()用于查找相对于给定Path对象的文件或目录。resolve()方法通常用于将相对路径附加到表示目录的现有Path。如果提供给resolve()方法的参数是绝对路径,则仅会生成绝对路径(它的工作方式类似于 Unix 或 DOS 的cd命令)。resolveSibling()方法的工作方式相同,但是它相对于目标Path的父级;此方法对于描述move()操作的目标非常有用。

经典文件路径和返回

为了连接经典和新 API,分别在java.io.Filejava.nio.file.Path中提供了相应的toPath()toFile()方法,以将其转换为另一种形式。当然,从File生成的Path类型只能是默认主机文件系统中表示文件和目录的路径:

    Path tmpPath = fs.getPath("/tmp");
    File file = tmpPath.toFile();
    File tmpFile = new File("/tmp");
    Path path = tmpFile.toPath();

NIO 文件操作

一旦有了Path,我们可以使用Files实用程序的静态方法对其进行操作,以将路径创建为文件或目录,读取和写入它,并查询和设置其属性。我们将列出大部分方法,然后在进一步讨论一些更重要的方法。

表 10-2 总结了java.nio.file.Files类的这些方法。由于Files类处理所有类型的文件操作,因此它包含大量方法。为了使表格更易读,我们省略了相同方法的重载形式(接受不同类型参数的方法),并将对应及相关类型的方法组合在一起。

表 10-2. NIO Files 方法

方法返回类型描述
copy()long 或 Path将流复制到文件路径、文件路径到流,或路径到路径。返回复制的字节数或目标 Path。如果目标文件存在,可以选择替换(默认为存在时操作失败)。复制目录将在目标处生成空目录(不复制内容)。复制符号链接会复制链接文件的数据(产生常规文件复制)。
createDirectory(), createDirectories()Path创建单个目录或指定路径中的所有目录。如果目录已存在,createDirectory() 会抛出异常。createDirectories() 则会忽略已存在的目录,仅在需要时创建。
createFile()Path创建一个空文件。此操作是原子性的,只有在文件不存在时才会成功。(此属性可用于创建标志文件以保护资源等。)
createLink(), createSymbolicLink(), isSymbolicLink(), readSymbolicLink(), createLink()boolean 或 Path创建硬链接或符号链接,检测文件是否为符号链接,或读取符号链接指向的目标文件。符号链接是指引用其他文件的文件。普通(“硬”)链接是文件的低级镜像,其中两个文件名指向相同的底层数据。如果不确定使用哪种,建议使用符号链接。
createTempDirectory(), createTempFile()Path创建一个带有指定前缀的临时目录或文件,确保名称唯一。可选择将其放置在系统默认的临时目录中。
delete(), deleteIfExists()void删除文件或空目录。deleteIfExists() 如果文件不存在则不会抛出异常。
exists(), notExists()boolean判断文件是否存在(notExists() 返回其相反值)。可选择是否跟踪链接(默认是跟踪)。
getAttribute(), set​Attri⁠bute(), getFile​Attri⁠buteView(), readAttributes()Object, MapFileAttributeView获取或设置特定于文件系统的文件属性,如访问和更新时间、详细权限和所有者信息,使用实现特定的名称。
getFileStore()FileStore获取表示路径所在文件系统上的设备、卷或其他类型分区的 FileStore 对象。
getLastModifiedTime(), setLastModifiedTime()FileTimePath获取或设置文件或目录的最后修改时间。
getOwner(), setOwner()UserPrincipal获取或设置代表文件所有者的 UserPrincipal 对象。使用 toString()getName() 获取用户名的字符串表示形式。
getPosixFile​Permis⁠sions(), setPosixFilePermissions()SetPath获取或设置路径的完整 POSIX 用户-组-其他样式读写权限,作为 PosixFile​Per⁠mission 枚举值的集合。
isDirectory(), isExecutable(), isHidden(), isReadable(), isRegularFile(), isWritable()boolean测试文件特性,如路径是否为目录和其他基本属性。
isSameFile()boolean检查两个路径是否引用同一个文件(即使路径不完全相同也可能为真)。
move()Path通过重命名或复制移动文件或目录,可选择是否替换现有目标。通常使用重命名,但如果需要在文件存储或文件系统之间复制文件以移动文件,则必须进行复制。仅当简单重命名可能或目录为空时,才能使用此方法移动目录。如果目录移动需要跨文件存储或文件系统复制文件,则方法会抛出 IOException。(在这种情况下,您必须自行复制文件。参见 walkFileTree()。)
newBufferedReader(), newBufferedWriter()BufferedReaderBufferedWriter通过 BufferedReader 打开文件进行读取,或通过 BufferedWriter 创建并打开文件进行写入。在两种情况下都要指定字符编码。
newByteChannel()SeekableByteChannel创建一个新文件或打开一个现有文件作为可寻址的字节通道。(请参见本章后面有关 NIO 的完整讨论。)考虑使用 FileChannel.open() 作为替代方案。
newDirectoryStream()DirectoryStream返回用于遍历目录层次结构的 DirectoryStream。可选择提供 glob 模式或过滤器对象以匹配文件。
newInputStream(), newOutputStream()InputStreamOutputStream通过 InputStream 打开文件进行读取,或通过 OutputStream 创建并打开文件进行写入。可选择指定输出流的文件截断;如果要覆盖写入,则默认为截断现有文件。
probeContentType()String如果能够通过安装的 FileTypeDetector 服务确定文件的 MIME 类型,则返回该类型;否则返回 null
readAllBytes(), readAllLines()byte[] 或 List<String>使用指定的字符编码从文件中读取所有数据为字节数组或所有字符作为字符串列表。
size()long获取指定路径文件的字节大小。
walkFileTree()PathFileVisitor 应用于指定的目录树,可选择是否跟随链接以及遍历的最大深度。
write()Path将字节数组或字符串集合(使用指定的字符编码)写入到指定路径的文件中,并关闭文件,可选择追加和截断行为。默认情况下是截断并写入数据。

使用这些方法,我们可以获取给定文件的输入或输出流,或者使用缓冲读写器和写入器。我们还可以将路径创建为文件和目录,并遍历文件层次结构。我们将在下一节讨论目录操作。

作为提醒,Pathresolve()resolveSibling()方法对于构建copy()move()操作的目标非常有用:

    // Move the file /tmp/foo.txt to /tmp/bar.txt
    Path foo = fs.getPath("/tmp/foo.txt");
    Files.move(foo, foo.resolveSibling("bar.txt"));

为了快速读取和写入文件内容而不使用流,我们可以使用各种readAll…​write方法,在单个操作中移动字节数组或字符串进出文件:

    // Read and write collection of String (e.g., lines of text)
    Charset asciiCharset = Charset.forName("US-ASCII");
    List<String> csvData = Files.readAllLines(csvPath, asciiCharset);
    Files.write(newCSVPath, csvData, asciiCharset);

    // Read and write bytes
    byte [] data = Files.readAllBytes(dataPath);
    Files.write(newDataPath, data);

这些对于容易适应内存的文件非常方便。

NIO 包

让我们回到java.nio包,完善我们关于核心 Java I/O 的讨论。NIO 的一个方面就是简单地更新和增强经典的java.io包的功能。实际上,许多通用的 NIO 功能确实与现有的 API 重叠。然而,NIO 首先引入是为了解决大系统的可伸缩性问题,特别是在网络应用中。接下来的几节概述了 NIO 的基本要素。

异步 I/O

大多数对 NIO 包的需求驱动力来自于希望在 Java 中添加非阻塞可选择的 I/O。在 NIO 出现之前,Java 中的大多数读写操作都绑定到线程,并被迫阻塞不确定的时间。尽管某些 API(如套接字,我们将在“套接字”中看到)提供了特定的方法来限制 I/O 调用的持续时间,但这只是一种弥补缺乏更一般机制的权宜之计。在许多语言中,即使没有线程,也可以通过将 I/O 流设置为非阻塞模式并测试它们是否准备好发送或接收数据来高效地进行 I/O。在非阻塞模式下,读取或写入只完成可以立即执行的工作——填充或清空缓冲区然后返回。结合测试准备就绪的能力,这使得单线程应用程序可以高效地连续服务许多通道。主线程“选择”一个准备好的通道,与之协作直到它阻塞,然后转移到另一个通道。在单处理器系统上,这与使用多线程基本上是等效的。

除了非阻塞和可选择的 I/O 外,NIO 包还能够异步关闭和中断 I/O 操作。正如在第九章讨论的那样,在 NIO 出现之前,没有可靠的方法来停止或唤醒在 I/O 操作中阻塞的线程。使用 NIO 后,被阻塞在 I/O 操作中的线程总是在被中断或另一个线程关闭通道时唤醒。此外,如果在线程阻塞在 NIO 操作时中断该线程,其通道将自动关闭。(因为线程中断而关闭通道可能看起来过于严格,但通常这样做是正确的。保持通道打开可能导致意外行为或使通道受到不必要的操纵。)

性能

I/O 通道设计围绕缓冲区的概念,这是一种专门用于通信任务的复杂数组形式。NIO 包支持直接缓冲区的概念 —— 这些缓冲区在主机操作系统中维护它们的内存而不是在 Java 虚拟机内部。因为所有真实的 I/O 操作最终都必须通过在主机操作系统中维护缓冲区空间来工作,使用直接缓冲区可以使许多操作更有效率。在两个外部端点之间传输的数据可以在不先复制到 Java 中再返回的情况下进行转移。

映射和锁定文件

NIO 提供了两个在java.io中找不到的通用文件相关功能:内存映射文件和文件锁定。内存映射文件表现得好像它的所有内容都在内存中的数组中而不是在磁盘上。内存映射文件超出了本章的范围,但如果你处理大量数据并且偶尔需要非常快的读/写访问,请在线查阅MappedByteBuffer文档

文件锁支持文件区域上的共享和排他锁 —— 对于多个应用程序的并发访问非常有用。我们将在“文件锁定”中讨论文件锁定。

通道

虽然java.io处理流,java.nio处理通道。通道是通信的端点。虽然实际上通道与流类似,但通道的基本概念同时更抽象和更原始。java.io中的流根据读取或写入字节的方法定义,而基本通道接口并不涉及通信的方式。它只是具有打开或关闭的概念,通过isOpen()close()方法支持。然后为文件、网络套接字或任意设备的通道实现添加自己的操作方法,如读取、写入或传输数据。NIO 提供以下通道:

  • FileChannel

  • Pipe.SinkChannel, Pipe.SourceChannel

  • SocketChannel, ServerSocketChannel, DatagramChannel

我们将在“文件通道”中涵盖FileChannel及其异步姊妹AsynchronousFileChannel。(异步版本通过线程池缓冲其所有操作,并通过异步 API 报告结果。)Pipe通道只是java.io Pipe工具的通道等价物。套接字和数据报通道参与 Java 的网络世界,我们将在第十三章中进行讨论。与网络相关的通道也有异步版本:AsynchronousSocketChannel, AsynchronousServerSocketChannelAsynchronousDatagramChannel

所有这些基本通道都实现了ByteChannel接口,该接口设计用于具有读取和写入方法(如 I/O 流)的通道。然而,ByteChannel读取和写入ByteBuffer,而不是简单的字节数组。

除了这些通道实现外,您还可以使用java.io I/O 流和读写器与通道进行桥接,以实现互操作性。然而,如果混合使用这些功能,您可能无法获得 NIO 提供的全部好处和性能。

缓冲区

大多数java.iojava.net包的实用程序操作的是字节数组。NIO 包的对应工具是围绕ByteBuffer(对于文本,还有一个基于字符的缓冲区CharBuffer)构建的。字节数组很简单,为什么需要缓冲区?它们具有几个目的:

  • 它们规范了缓冲数据的使用模式,提供了诸如只读缓冲区之类的功能,并跟踪大缓冲区空间内的读/写位置和限制。它们还提供了类似于java.io​.BufferedInputStream的标记/重置功能。

  • 它们提供了用于处理表示原始类型的原始数据的附加 API。您可以创建“查看”您的字节数据为一系列较大原始类型(如shortintfloat)的缓冲区。最通用的数据缓冲区类型ByteBuffer包括让您像DataInputStreamDataOutputStream对流所做的那样读取和写入所有原始类型的方法。

  • 它们抽象了数据的底层存储,允许 Java 优化吞吐量。具体而言,缓冲区可以分配为直接缓冲区,这些直接缓冲区使用主机操作系统的本机缓冲区,而不是 Java 内存中的数组。与缓冲区一起工作的 NIOChannel工具可以自动识别直接缓冲区,并尝试优化与它们的交互。例如,从文件通道读取到 Java 字节数组通常需要 Java 将数据复制为从主机操作系统到 Java 内存的读取。使用直接缓冲区,数据可以保留在主机操作系统中,超出 Java 正常内存空间,直到需要为止。

缓冲区操作

基本java.nio.Buffer类有点像具有状态的数组。它不指定它保存的元素类型(由子类型决定),但它确实定义了所有数据缓冲区通用的功能。缓冲区具有固定大小,称为容量。尽管所有标准缓冲区都提供对其内容的“随机访问”,但缓冲区通常期望按顺序读取和写入,因此缓冲区维护下一个元素被读取或写入的位置的概念。除了位置,缓冲区还可以维护另外两个状态信息:限制,在读模式下通常表示可用数据,在写模式下表示文件的容量;以及一个标记,可用于记住未来回忆的早期位置。

Buffer的实现添加了特定的类型化获取和放置方法,用于读取和写入缓冲区内容。例如,ByteBuffer是字节的缓冲区,它有get()put()方法用于读取和写入字节及字节数组(以及许多其他有用的方法,稍后我们将讨论)。从Buffer获取或放置数据会改变位置标记,因此Buffer类似于流一样跟踪其内容。试图读取或写入超出限制标记的数据会生成BufferUnderflowExceptionBufferOverflowException异常。

标记、位置、限制和容量的值始终遵守以下公式:

    mark <= position <= limit <= capacity

Buffer的读写位置始终在标记(mark)和限制(limit)之间,其中标记作为下界,限制作为上界。容量表示缓冲区空间的物理范围。

您可以使用position()limit()方法显式设置位置和限制标记。提供了几个便利方法用于常见的使用模式。reset()方法将位置重置为标记。如果未设置标记,则会抛出InvalidMarkException异常。clear()方法将位置重置为0,并将限制设置为容量,准备好接收新数据(标记被丢弃)。请注意,clear()方法实际上并不对缓冲区中的数据执行任何操作;它只是改变位置标记。

flip()方法用于常见模式,将数据写入缓冲区,然后再读取出来。flip方法将当前位置设置为限制,并将当前位置重置为0(任何标记都被丢弃),这样就不需要跟踪读取了多少数据。另一个方法rewind()简单地将位置重置为0,但保持限制不变。您可以使用它再次写入相同大小的数据。以下是使用这些方法从一个通道读取数据并写入两个通道的代码片段:

    ByteBuffer buff = ...
    while (inChannel.read(buff) > 0) { // position = ?
      buff.flip();    // limit = position; position = 0;
      outChannel.write(buff);
      buff.rewind();  // position = 0
      outChannel2.write(buff);
      buff.clear();   // position = 0; limit = capacity
    }

第一次看可能会让人困惑,因为在这里,从Channel读取实际上是向Buffer写入,反之亦然。因为此示例将所有可用数据写入限制,所以在这种情况下,flip()rewind()具有相同的效果。

缓冲区类型

各种缓冲区实现添加了用于读写特定数据类型的获取和放置方法。每个 Java 原始类型都有一个关联的缓冲区类型:ByteBufferCharBufferShortBufferIntBufferLongBufferFloatBufferDoubleBuffer。每个类型都提供了用于读取和写入其类型及其类型数组的获取和放置方法。其中,ByteBuffer是最灵活的,因为它具有所有缓冲区中最细粒度的"get"和"put"方法,用于读写除byte外的所有其他数据类型。以下是一些ByteBuffer的方法:

    byte get()
    char getChar()
    short getShort()
    int getInt()
    long getLong()
    float getFloat()
    double getDouble()

    void put(byte b)
    void put(ByteBuffer src)
    void put(byte[] src, int offset, int length)
    void put(byte[] src)
    void putChar(char value)
    void putShort(short value)
    void putInt(int value)
    void putLong(long value)
    void putFloat(float value)
    void putDouble(double value)

所有标准缓冲区也支持随机访问。对于ByteBuffer的上述每种方法,还有一个带索引的额外形式,例如:

    getLong(int index)
    putLong(int index, long value)

但这还不是全部!ByteBuffer还可以提供它自己的“视图”,作为任何粗粒度类型。例如,你可以用asShortBuffer()方法从ByteBuffer获取一个ShortBuffer视图。ShortBuffer视图是由ByteBuffer支持的,这意味着它们在相同的数据上工作,对其中一个的更改会影响另一个。视图缓冲区的范围从ByteBuffer的当前位置开始,其容量是剩余字节数除以新类型的大小。 (例如,每个short占两个字节,每个float占四个字节,每个longdouble占八个字节。)视图缓冲区对于在ByteBuffer内读取和写入大块连续类型数据非常方便。

CharBuffer也很有趣,主要是因为它们与String的集成。CharBufferString都实现了java.lang.CharSequence接口。这个接口提供了标准的charAt()length()方法。Java 的许多其他部分(比如java.util.regex包)允许你可以互换地使用CharBufferString。在这种情况下,CharBuffer就像一个可修改的String,具有用户可配置的逻辑起始和结束位置。

字节顺序

因为我们正在讨论大于一个字节的类型的读写,所以问题就来了:多字节值(比如shortint)的字节写入顺序是什么?在这个世界上有两个派别:大端序小端序。[⁵] 大端序意味着最重要的字节首先出现;小端序则相反。如果你要写入某些本地应用程序消费的二进制数据,这一点非常重要。兼容 Intel 的计算机使用小端序,许多运行 Unix 的工作站使用大端序。ByteOrder类封装了这个选择。你可以通过ByteBuffer order()方法指定要使用的字节顺序,使用标识符ByteOrder.BIG_ENDIANByteOrder.LITTLE_ENDIAN,例如:

    byteArray.order(ByteOrder.BIG_ENDIAN);

你可以使用静态方法ByteOrder.nativeOrder()获取你的平台的本地字节顺序。我们知道你很好奇:

jshell> import java.nio.ByteOrder;

jshell> ByteOrder.nativeOrder()
$4 ==> LITTLE_ENDIAN

我们在一台装有英特尔芯片的 Linux 桌面上运行了这个程序。你也可以在自己的系统上试试看!

分配缓冲区

你可以通过显式分配使用allocate()或者通过包装现有的普通 Java 数组类型来创建缓冲区。每种缓冲区类型都有一个静态的allocate()方法,接受一个容量(大小),以及一个wrap()方法,接受一个现有的数组:

    CharBuffer cbuf = CharBuffer.allocate(64*1024);
    ByteBuffer bbuf = ByteBuffer.wrap(someExistingArray);

直接缓冲区的分配方式与allocateDirect()方法相同:

    ByteBuffer bbuf2 = ByteBuffer.allocateDirect(64*1024);

正如我们之前描述的那样,直接缓冲区可以使用操作系统的内存结构,这些结构针对某些类型的 I/O 操作进行了优化。这样做的权衡是,分配直接缓冲区比普通缓冲区的操作稍慢且更重量级,因此您应该尽量将它们用于长期缓冲区。

字符编码器和解码器

字符编码器和解码器将字符转换为原始字节,反之亦然,将 Unicode 标准映射到特定的编码方案。在 Java 中,编码器和解码器早已存在,供 ReaderWriter 流使用,并在 String 类的处理字节数组的方法中使用。然而,早期并没有用于显式处理编码的 API;您只需按名称将编码器和解码器引用到需要的地方作为 Stringjava.nio.charset 包使用 Charset 类正式化了 Unicode 字符集编码的概念。

Charset 类是一个 Charset 实例的工厂,它们知道如何将字符缓冲区编码为字节缓冲区,并解码字节缓冲区为字符缓冲区。您可以使用静态 Charset.forName() 方法按名称查找字符集并在转换中使用它:

    Charset charset = Charset.forName("US-ASCII");
    CharBuffer charBuff = charset.decode(byteBuff);  // to ascii
    ByteBuffer byteBuff = charset.encode(charBuff);  // and back

您还可以使用静态 Charset.isSupported() 方法测试编码是否可用。

以下字符集是保证提供的:

  • US-ASCII

  • ISO-8859-1

  • UTF-8

  • UTF-16BE(大端序)

  • UTF-16LE(小端序)

  • UTF-16

您可以使用静态 availableCharsets() 方法列出平台上提供的所有编码器:

    Map map = Charset.availableCharsets();
    Iterator it = map.keySet().iterator();
    while (it.hasNext())
      System.out.println(it.next());

availableCharsets() 的结果是一个映射,因为字符集可能具有“别名”并且可能出现在多个名称下。

除了 java.nio 包的面向缓冲区的类之外,java.io 包的 InputStreamReaderOutputStreamWriter 桥接类也与 Charset 一起工作。您可以指定编码作为 Charset 对象或名称。

CharsetEncoder 和 CharsetDecoder

通过使用 Charset newEncoder()newDecoder() 方法创建 CharsetEncoderCharsetDecoder(一个编解码器),您可以更加控制编码和解码过程。在前面的片段中,我们假设所有数据都在单个缓冲区中可用。然而,更常见的情况是我们可能需要按块处理数据。编码器/解码器 API 通过提供更一般的 encode()decode() 方法来允许这样做,这些方法接受一个标志,指定是否期望更多数据。编解码器需要知道这一点,因为在数据耗尽时可能会中断多字节字符转换。如果它知道还有更多数据要到来,它不会因为这种不完整的转换而抛出错误。

在以下片段中,我们使用解码器从 ByteBuffer bbuff 中读取并将字符数据累积到 CharBuffer cbuff 中:

    CharsetDecoder decoder = Charset.forName("US-ASCII").newDecoder();

    boolean done = false;
    while (!done) {
      bbuff.clear();
      done = (in.read(bbuff) == -1);
      bbuff.flip();
      decoder.decode(bbuff, cbuff, done);
    }
    cbuff.flip();
    // use cbuff. . .

在这里,我们在in通道上寻找输入结束条件来设置done标志。注意,我们利用ByteBufferflip()方法来设置数据读取的限制并重置位置,以便一步完成解码操作。在遇到问题时,encode()decode()方法都会返回一个结果对象CoderResult,它可以确定编码的进度。CoderResultisError()isUnderflow()isOverflow()方法分别指定编码停止的原因:错误、输入缓冲区中字节不足或输出缓冲区已满。

FileChannel

现在我们已经介绍了通道和缓冲区的基础知识,是时候看一看真正的通道类型了。FileChannel是 NIO 中java.io.RandomAccessFile的等效物,但它除了性能优化外,还提供了几个增强功能。特别是,如果需要使用文件锁定、内存映射文件访问或高度优化的文件和网络通道之间的数据传输,可以使用FileChannel代替简单的java.io文件流。这些都是相当高级的用例,但如果你从事后端工作或处理大量数据,它们肯定会派上用场。

使用静态的FileChannel.open()方法可以为Path创建一个FileChannel

    FileSystem fs = FileSystems.getDefault();
    Path p = fs.getPath("/tmp/foo.txt");

    // Open default for reading
    try (FileChannel channel = FileChannel.open(p)) {
      // read from the channel ...
    }

    // Open with options for writing
    import static java.nio.file.StandardOpenOption.*;

    try (FileChannel channel =
        FileChannel.open(p, WRITE, APPEND, ...) ) {
      // append to foo.txt if it already exists,
      // otherwise, create it and start writing ...
    }

默认情况下,open()创建一个文件的只读通道。我们可以通过传递额外的选项来打开写入或追加通道,并控制其他更高级的特性,如前面示例的第二部分所示。表格 10-3 总结了这些选项。

表格 10-3. java.nio.file.StandardOpenOption

OptionDescription
APPEND打开文件以进行写入;所有写操作定位于文件末尾。
CREATEWRITE一起使用,打开文件并在需要时创建它。
CREATE_NEWWRITE一起使用,原子性地创建文件;如果文件已存在,则操作失败。
DELETE_ON_CLOSE尝试在关闭文件时或在虚拟机退出时删除文件(如果文件已打开)。
READ, WRITE以只读或只写(默认为只读)模式打开文件。使用两者可进行读写操作。
SPARSE在创建新文件时使用;请求文件是稀疏的。在支持的文件系统上,稀疏文件可以处理非常大且大部分为空的文件,而不会为空部分分配太多实际存储空间。
SYNC, DSYNC在可能的情况下,保证写操作阻塞,直到所有数据写入存储介质。SYNC会对所有文件更改(包括数据和元数据(属性))执行此操作,而DSYNC仅对文件的数据内容添加此要求。
TRUNCATE_EXISTING对现有文件使用WRITE;在打开文件时将文件长度设为零。

FileChannel也可以通过经典的FileInputStreamFileOutputStreamRandomAccessFile构造:

    FileChannel readOnlyFc = new FileInputStream("file.txt")
        .getChannel();
    FileChannel readWriteFc = new RandomAccessFile("file.txt", "rw")
        .getChannel();

从这些文件输入流和输出流创建的FileChannel分别是只读或只写的。要获取读/写FileChannel,必须像前面的示例一样使用读/写选项构造RandomAccessFile

使用FileChannel就像使用RandomAccessFile一样,但它使用ByteBuffer而不是字节数组:

    ByteBuffer bbuf = ByteBuffer.allocate(...);
    bbuf.clear();
    readOnlyFc.position(index);
    readOnlyFc.read(bbuf);
    bbuf.flip();
    readWriteFc.write(bbuf);

您可以通过设置缓冲区的位置和限制标记或使用另一种接受缓冲区起始位置和长度的读/写形式来控制读取和写入的数据量。您还可以通过提供索引与读写方法来随机读写到某个位置:

    readWriteFc.read(bbuf, index)
    readWriteFc.write(bbuf, index2);

在每种情况下,实际读取或写入的字节数取决于几个因素。该操作试图读取或写入缓冲区的限制,绝大多数情况下,这就是本地文件访问的情况。该操作仅保证阻塞,直到至少处理了一个字节。无论发生什么,返回处理的字节数并相应更新缓冲区位置,准备重复操作直到完成(如果需要)。这是使用缓冲区的便利之一;它们可以为您管理计数。与标准流一样,通道的read()方法在达到输入结束时返回-1

使用size()方法始终可以获取文件的大小。如果您写入超出文件末尾,文件大小可能会更改。反之,您可以使用truncate()方法将文件截断为指定的长度。

并发访问

FileChannel可安全地供多个线程使用,并保证在同一虚拟机中的通道之间对数据的一致视图。但是,除非指定了SYNCDSYNC选项,否则通道不保证写入的传播速度。如果您只偶尔需要确保数据在继续之前是安全的,可以使用force()方法将更改刷新到磁盘。此方法接受一个布尔参数,指示是否必须包括文件元数据,包括时间戳和权限。某些系统跟踪文件的读取以及写入,因此,如果将标志设置为false,表示您不关心立即同步该元数据,则可以节省大量更新。

与所有Channel一样,任何线程都可以关闭FileChannel。一旦关闭,所有通道的读/写和位置相关方法都会抛出ClosedChannelException

文件锁定

通过lock()方法,FileChannel支持对文件区域进行独占锁定和共享锁定。

    FileLock bigLock = fileChannel.lock();

    int start = 0, len = fileChannel2.size();
    FileLock readLock = fileChannel2.lock(start, len, true);

锁可以是共享的或独占的。独占 锁可阻止其他人在指定文件或文件区域上获取任何类型的锁。共享 锁允许其他人获取重叠的共享锁,但不允许获取独占锁。这些分别用作写锁和读锁。在写入时,您不希望其他人能够写入直到您完成,但在读取时,您只需要阻止其他人写入,而不是阻止其他人读取。

在前面的例子中,lock() 方法没有参数尝试获取整个文件的独占锁。第二种形式接受起始和长度参数,以及指示锁定是共享(true)还是独占(false)的标志。lock() 方法返回的 FileLock 对象可用于释放锁定:

    bigLock.release();
警告

文件锁仅能保证是协作的。它们仅在所有线程都遵守它们时起作用;它们不一定能阻止非协作线程读取或写入已锁定的文件。一般而言,保证锁被遵守的唯一方法是双方尝试获取锁,并仅在尝试成功后继续。

此外,某些系统上未实现共享锁定,此时所有请求的锁都将是独占的。您可以使用 isShared() 方法测试锁是否为共享的。

FileChannel 锁定持有直到通道关闭或中断,因此在 try-with-resources 语句中执行锁定将有助于更可靠地释放锁定:

  try (FileChannel channel = FileChannel.open(p, WRITE) ) {
    channel.lock();
    // ...
  }

FileChannel 示例

让我们看一些通道和缓冲区的具体用法。我们将创建一个小型文本文件,其中包含我们的程序访问该文件的次数计数。然后,我们将打开文件,读取当前计数,增加该计数,然后将计数重新写入(实际上是覆盖)文件。您可以在 ch10/examples 文件夹中的 AccessNIO.java 文件中尝试下面片段的完整版本。

注意

您完全可以使用 java.io 中的标准 I/O 类来处理此项目。NIO 套件并非旨在完全替换旧类,而是在不破坏依赖这些类的代码的情况下添加缺失的功能。如果您觉得 NIO 有点复杂或密集,可以在需要使用文件锁定或操作元数据等缺失功能时再考虑使用。

我们的第一个任务是查看我们的计数文件是否存在(本例中为 access.txt,但名称是任意的)。如果不存在,我们需要创建它(并将内部访问计数器设置为 1)。我们可以使用 Path 对象和 Files 静态帮助方法来开始:

public class AccessNIO {
  String accessFileName = "access.txt";
  Path   accessFilePath = Path.of(accessFileName);
  int    accessCount = 0;
  FileChannel accessChannel;

  public AccessNIO() {
    // ...
    boolean initial = !Files.exists(accessFilePath);
    accessChannel = FileChannel.open(accessFilePath, CREATE, READ, WRITE);
    // ...
  }
}

如果文件尚不存在,我们可以写入一个初始消息(“此文件已访问 0 次。”),然后倒回到新文件的开头。这样我们就有了从一开始文件就存在的基准:

    if (initial) {
      String msg = buildMessage(); // helper for consistency
      accessChannel.write(ByteBuffer.wrap(msg.getBytes()));
      accessChannel.position(0);
    }

如果文件已经存在,我们需要确保能够从中读取并向其中写入。我们可以通过构造函数中创建的 accessChannel 对象收集这些信息。当然,我们可以添加其他测试和更详细的错误消息,但这些最小的检查非常有用:

  public boolean isReady() {
    return (accessChannel != null && accessChannel.isOpen());
  }

现在我们来到我们的主要用例。文件已经存在并且具有一些内容。我们拥有我们想做的一切适当的权限。我们将以读/写模式打开文件,并将其内容读入字符串中:

    int fsize = (int)accessChannel.size();
    // Give ourselves extra room in case the count
    // goes over a digit boundary (9 -> 10, 99 -> 100, etc.)
    ByteBuffer in = ByteBuffer.allocate(fsize + 2);
    accessChannel.read(in);
    String current = new String(in.array());

我们希望文件本身是人类可读的,因此我们不会利用 FileChannel 读写二进制数据的能力。我们可以利用我们对单行文本结构的了解来解析我们的访问计数:

    int countStart = 28;
    // We know where the count number starts, so get
    // everything from that position to the next space
    String rawCount = current.substring(countStart,
        current.indexOf(" ", countStart));
    accessCount = Integer.parseInt(rawCount) + 1;

最后,我们可以重置位置并用新的更新行覆盖前一行。请注意,我们还截断文件以保存消息的末尾。我们留出了额外的空间以容纳更大的数字,但我们不希望实际文件中存在多余的空间:

    String msg = buildMessage();
    accessChannel.position(0);
    accessChannel.write(ByteBuffer.wrap(msg.getBytes()));
    accessChannel.truncate(accessChannel.position());
    accessChannel.close();

尝试多次编译和运行此示例。计数是否按预期增加?如果您在另一个程序(如文本编辑器)中打开文件会发生什么?不幸的是,Java NIO 只是 感觉 像魔术。使用任何其他程序访问文件不一定会按照我们小例子的规则更改其内容。

wrap() 完成

几乎任何准备发布的应用程序都需要处理文件 I/O。Java 在高效处理本地文件方面提供了强大的支持,包括对文件和目录的元数据访问。Java 在处理文本文件时提供了多种字符编码选项,显示了其广泛兼容性的承诺。Java 在处理非本地文件方面也是众所周知的。我们将在第十三章中探讨网络 I/O 和 web 资源。

复习问题

  1. 如何检查给定文件是否已经存在?

  2. 如果必须使用旧的编码方案(例如 ISO 8859)处理遗留文本文件,您如何设置读取器以正确将其内容转换为类似 UTF-8 的内容?

  3. 哪个包中的类最适合非阻塞文件 I/O?

  4. 当你需要解析诸如 JPEG 压缩图像之类的二进制文件时,你可能会使用哪种类型的输入流?

  5. System 类中有哪三个标准文本流?

  6. 绝对路径从根目录开始(例如 / 或 *C:*)。相对路径从哪里开始?更具体地说,相对路径相对于什么?

  7. 如何从现有的 FileInputStream 获取 NIO 通道?

代码练习

对于这些练习,一个骨架Count.java文件位于ch10/exercises文件夹中,但可以随意从自己的类开始。我们在一个项目上进行迭代,因此您可以将第一个练习的解决方案作为第二个的起点,依此类推。因为测试程序需要在命令行上提供不同的文件,所以您可能会发现从终端或命令窗口运行此程序更容易。您当然也可以使用 IDE 中的终端选项卡:

  1. 使用java.io包的类,创建一个小程序,将打印出命令行中指定的文件的大小。例如:

    C:\> java Count ../examples/ListIt.java
    Analyzing ListIt.java
      Size: 1011 bytes
    

    如果没有给出文件参数,则向System.err打印错误消息。

  2. 扩展上一个练习,打开给定的文件并计算行数。(对于这些简单的练习,可以假设正在分析的文件是文本文件。)如果您想要练习一些来自第八章的工具,可以根据空白拆分每一行,并在输出中包含单词计数。(您可以使用正则表达式在更复杂的模式上拆分单词,如标点符号,但这不是必需的。)

    C:\> java Count ../examples/ListIt.java
    Analyzing ListIt.java
      Size: 1011 bytes
      Lines: 36
      Words: 178
    

    与之前一样,如果没有给出文件参数,则向System.err打印错误消息。

  3. 将您之前的解决方案转换为使用 NIO 类,如PathFiles,而不是读取器。您可以使用java.niojava.nio.file包中的任何部分。当然,你几乎肯定还需要“旧”I/O 中的java.io.IOException类。

高级练习

  1. 接受第二个命令行,其中包含统计日志文件的名称。而不是将各种计数打印回终端,而是追加一行,其中包含当前时间戳、文件名和其三个计数。该行的确切格式并不重要,但应该看起来像这样:

    2023-02-02 08:14:25 Count1.java  36  147  1002
    

    您可以使用 NIO 或旧的 I/O(OIO?)解决方案中的任何一个作为起点。如果选择 NIO 版本,请尝试使用ByteBufferFileChannel进行写入。

    如果只提供一个命令行参数,则恢复以前将统计信息打印到屏幕上的方式。如果没有提供参数,或者第二个参数不可写,则向System.err打印错误信息。

    运行此版本几次,对几个文件进行测试。检查您的日志,确保每个新结果都正确追加到日志文件的末尾,而不是覆盖它。

¹ 虽然 NIO 是在 Java 1.4 中引入的——因此不再很新了——但它比原始的基本包要新,而且这个名称已经固定下来了。

² 标准错误(stderr)通常是保留给与命令行应用程序的用户显示有关的错误相关文本消息的流。它与标准输出(stdout)不同,后者通常被重定向到日志文件或另一个应用程序,并且不被用户看到。

³ 这个术语来源于线程的世界,意味着同样的事情:原子文件创建不会被其他线程中断。

⁴ 在面向对象编程中,工厂这个术语通常指静态帮助器,可以构造和定制某些对象。工厂(或工厂方法)类似于构造函数,但额外的定制可以为新对象添加细节,这些细节可能在构造函数中很难(或不可能)指定。

大端序小端序这两个术语源自乔纳森·斯威夫特的小说格列佛游记,在小说中它们分别指代利利普特人的两个阵营:一个从大头吃蛋,一个从小头吃蛋。