Java17-零基础入门手册-七-

113 阅读54分钟

Java17 零基础入门手册(七)

原文:Java 17 for Absolute Beginners

协议:CC BY-NC-SA 4.0

十二、发布-订阅框架

到目前为止,书中解释的所有编程概念都涉及到需要处理的数据。不管数据是以什么形式提供的,到目前为止我们编写的 Java 程序都是获取数据、修改数据并打印出结果,无论是控制台、文件还是其他软件组件。可以说,所有这些组件都在相互通信,并将处理过的数据从一个组件传递到另一个组件。比如图 12-1 ,它抽象地描述了程序中 Java 组件之间的交互。

img/463938_2_En_12_Fig1_HTML.png

图 12-1

程序中 Java 组件之间的交互

每一个箭头上都标有从一个箭头传到另一个箭头的信息类型。在该图中,您可以识别信息进入程序的起点(由Reader读取)和终点(由Printer将信息打印到某个输出组件)。你可以说,Reader提供数据,FilterDocumentCreator是一些内部处理器,处理数据,Printer是数据的消费者。

到目前为止描述的是类似于点对点(p2p)消息模型的东西,它描述了一个消息被发送给一个消费者的概念。p2p 模型特定于一个名为 Java 消息服务(JMS)的 Java API,它支持网络中计算机之间的正式通信,即消息传递。在本章开始的例子中,我们做了一个类比来说明 Java 程序组件之间的通信是以类似的方式工作的。通过考虑链接到消息传递风格通信模型中的所有组件,可以创建实现上图所描述的流程的解决方案设计。

有不止一种通信模型:生产者/消费者、发布/订阅和发送者/接收者,每一种都有其自身的特性, 1 但是本章关注的是**发布/订阅,**因为这是反应式编程所基于的模型。

反应式编程和反应式宣言

反应式编程是一种声明式编程风格,涉及使用数据流和传播变化。反应式编程是用异步数据流编程。Reactive streams 是一项倡议,旨在为具有非阻塞背压的异步流处理提供标准。它们对于解决需要跨线程边界复杂协调的问题非常有用。操作符允许您将数据收集到所需的线程上,并确保线程安全操作,在大多数情况下,不需要过多使用synchronizedvolatile构造。

在版本 8 中引入 Streams API 之后,Java 向反应式编程迈进了一步,但是反应式流直到版本 9 才可用。我们已经在几章前学习了如何使用 streams**(章** 8 ) ,所以我们离目标又近了一步。现在我们所要做的就是了解如何使用反应流来进行一些反应式编程。

使用反应流并不是一个新的想法。反应宣言于 2014 年首次公开, 2 它要求软件以这样的方式开发:系统是响应性的、有弹性的、有弹性的和消息驱动的——简而言之,它们应该是反应性的

这里对这四个术语分别进行了简要说明:

  • 快速响应:应提供快速一致的响应时间。

  • 弹性:出现故障时应保持响应,并能够恢复。

  • 弹性:应保持反应灵敏,能够应对各种工作负载。

  • 消息驱动:应该使用异步消息通信,避免阻塞,必要时施加反压力。

以这种方式设计的系统应该更加灵活、松散耦合和可伸缩,但同时它们应该更容易开发、可修改和更能容忍失败。为了能够完成所有这些,系统需要一个通用的 API 来进行通信。Reactive streams 是一个为异步、非阻塞流处理提供这样一个标准 API 的倡议,它也支持背压。我们一会儿会解释背压的意思。让我们从反应式流处理的基础开始。

任何类型的流处理都涉及数据的生产者、数据的消费者以及它们之间处理数据的组件。显然,数据流的方向是从生产者到消费者。图 12-2 描述了到目前为止所描述的系统的抽象模式。

img/463938_2_En_12_Fig2_HTML.png

图 12-2

生产者/消费者系统

当生产者比消费者更快时,系统可能会陷入困境,因此必须处理无法处理的额外数据。做这件事的方法不止一种:

img/463938_2_En_12_Fig3_HTML.png

图 12-3

反应式生产者/消费者系统

  • 多余的数据被丢弃(这是在网络硬件中完成的)。

  • 生产者被阻止,因此消费者有时间赶上。

  • 数据被缓冲,但是缓冲区是有限的,如果我们有一个快速的生产者和一个慢速的消费者,就有缓冲区溢出的危险。

  • 应用反压力,这包括赋予消费者监管生产者和控制产生多少数据的权力。背压可以被看作是从消费者发送到生产者的信息,让生产者知道它必须降低其数据生产率。记住这一点,我们可以完成前一个图像中的设计,这将产生图 12-3 。

如果生产者、处理器和消费者不同步,通过阻塞直到每个都准备好处理来解决太多数据的问题是不可行的,因为这会将系统转换成同步的系统。丢弃它也不是一个选项,而且缓冲是不可预测的,所以我们留给反应式系统的只有应用非阻塞背压

如果软件的例子对你来说太令人费解,想象一下下面的场景。你有一个叫吉姆的朋友。你还有一桶不同颜色的球。吉姆告诉你把所有的红球都给他。有两种方法可以做到这一点:

  1. 你把所有的红球都捡起来,放到另一个桶里,然后把桶递给吉姆。这是典型的请求-完整响应模型。这是一个异步模型,如果选择红球需要很长时间,Jim 会去做其他事情,而你会进行分类,当你完成时,你只需通知他一桶红球准备好了。它是异步的,因为 Jim 不会被你阻止去整理球,而是去做其他的事情,当球准备好的时候再去拿球。

  2. 你只要从你的桶里一个接一个地拿出红色的球,然后扔向吉姆。在这种情况下,这是你的数据流,或者球流。如果你找到它们并扔出去的速度比吉姆接住它们的速度快,你就有障碍。所以吉姆让你慢下来。这是他在调节球的流动,这相当于现实世界中的背压。

在版本 9 之前,用 Java 编写可以在反应式系统中聚合的应用是不可能的,所以开发人员不得不用外部库来凑合。反应式应用必须根据反应式编程的原理来设计,并使用反应式流来处理数据。反应式编程的标准 API 首先由反应流库描述,该库也可以用于 Java 8。在 Java 9 中,标准 API 被添加到了 JDK 中,下一版本的反应流包含了一组嵌套在org.reactivestreams.FlowAdapters类中的类,它们代表了两个 API(反应流 API 和反应流流 API)中类似组件之间的桥梁。

在图 12-4 中,你可以看到接口是由具有先前定义的角色的组件实现的。

img/463938_2_En_12_Fig4_HTML.png

图 12-4

反应流接口(如 IntelliJ IDEA 所示)

反应流 API 由四个非常简单的接口组成:

  • 接口Publisher<T>公开了一个名为void subscribe(Subscriber<? super T>)的方法,该方法被调用来添加一个Subscriber<T>实例,并产生类型为T的元素,这些元素将被Subscriber<T>使用。Publisher<T>实现的目的是根据从其订户接收到的需求来发布值。

  • 接口Subscriber<T>,消耗来自Publisher<T>的元素,并根据Publisher<T>实例接收的事件类型,公开定义实例具体行为所必须实现的四个方法。

    • void onSubscribe(Subscription)是在订户上调用的第一个方法,这是使用Subscription参数将Publisher<T>链接到Subscriber<T>实例的方法;如果此方法引发异常,则不能保证以下行为。

    • void onNext(T)是由Subscription的下一个项目调用的方法,用于接收数据;如果抛出异常,Subscription可能会被取消。

    • void onError(Throwable)是在Publisher<T>Subscription<T>遇到不可恢复的错误时调用的方法。

    • void onComplete()是当没有更多的数据消耗时调用的方法,因此不会发生额外的Subscriber<T>方法调用。

  • 接口Processor<T,R> extends both Publisher<T> and Subscriber<R>,因为它需要消费数据并产生数据以将其发送到更上游。

  • 接口Subscription的实现链接了Publisher<T>Subscriber<T>,可以通过调用request(long)来设置要生产并发送给消费者的商品数量,从而应用反压力。它还允许取消一个流,通过调用cancel()方法告诉Subscriber<T>停止接收消息。

在 JDK 中,前面列出的所有接口都在java.util.concurrent.Flow类中定义。这个类的名字在本质上是显而易见的,因为前面的接口用于创建流控制的组件,这些组件可以链接在一起创建一个反应式应用。除了这四个接口之外,还有一个 JDK 实现:实现Publisher<T>java.util.concurrent.SubmissionPublisher<T>类,它是生成项目并使用该类中的方法发布项目的子类的方便基础。

Flow接口非常基本,可以在编写反应式应用时使用,但这需要大量的工作。目前,不同的团队有多种实现,为开发反应式应用提供了更实用的方法。使用这些接口的实现,您可以编写反应式应用,而无需编写处理数据的线程的同步逻辑。

下面的列表包含最著名的 reactive streams API 实现(还有更多的实现,因为在大数据世界中,反应式处理不再是奢侈品,而是必需品):

使用 JDK 反应流 API

由于 JDK 为反应式编程提供的接口非常基础,实现起来非常麻烦,但是在这一部分中还是做了尝试。在这一节中,我们将尝试构建一个生成无限数量的整数值的应用。过滤这些值并选择小于 127 的值。对于 98 和 122 之间的偶数,减去 32(基本上是将小写字母转换成大写字母)。然后将它们转换成字符并打印出来。清单 12-1 中描述了最基本的解决方案,没有反应流。

package com.apress.bgn.twelve.dummy;

// some input statements omitted
import java.security.SecureRandom;

public class BasicIntTransformer {
    private static final Logger log = LoggerFactory.getLogger(BasicIntTransformer.class);
    private static final SecureRandom random = new SecureRandom();

    public static void main(String... args) {

        while (true){
            int rndNo = random.nextInt(130);
            if (rndNo < 127) {
                log.info("Initial value: {} ", rndNo);
                if(rndNo % 2 == 0 && rndNo >=98 && rndNo <=122) {
                    rndNo -=32;
                }
                char res = (char) rndNo;
                log.info("Result: {}", res);
            } else {
                log.debug("Number {} discarded.", rndNo);
            }
        }
    }
}

Listing 12-1Generating an Infinite Number of Integers <127

前面代码清单中的每一行代码都有一个目的,一个期望的结果。这种方法被称为命令式编程,因为它顺序执行一系列语句来产生所需的输出。

然而,这不是我们的目标。在本节中,我们将使用 JDK 反应式接口的实现来实现反应式解决方案,因此我们需要以下内容:

  • 利用无限流生成随机整数值的发布器组件。该类应该实现Flow.Publisher<Integer>接口。

  • 一个只选择可以转换成可见字符的整数值的处理器,比方说代码在[0,127]之间的所有字符。该类应该实现Flow.Processor<Integer, Integer>

  • 一种处理器,通过减去 32 来修改接收到的 98 和 122 之间的偶数元素。这个类也应该实现Flow.Processor<Integer, Integer>

  • 一种将整数元素转换成等价字符的处理器。这是一个特殊的类型或处理器,它将一个值映射到另一个类型的另一个值,并且应该实现Flow.Processor<Integer, Character>

  • 将打印从链中最后一个处理器收到的元素的订户。这个类将实现Flow.Subscriber<Character>接口。

让我们从声明Publisher<T>开始,它将环绕一个无限流以产生要消费的值。我们将通过提供一个完整的具体实现来异步提交元素,从而实现Flow.Publisher<Integer>接口。为了在需要时缓冲它们,需要添加大量代码。幸运的是SubmissionPublisher<T>类已经这样做了,所以在我们的类内部,我们将使用一个SubmissionPublisher<Integer>对象。清单 12-2 中描述了发布者的代码。

package com.apress.bgn.twelve.jdkstreams;

import java.util.Random;
import java.util.concurrent.Flow;
import java.util.concurrent.SubmissionPublisher;
import java.util.stream.IntStream;

public class IntPublisher implements Flow.Publisher<Integer> {
    private static final Random random = new Random();
    protected final IntStream intStream;

    public IntPublisher(int limit) {
        intStream = limit == 0 ? IntStream.generate(() -> random.nextInt(150)) :
                IntStream.generate(() -> random.nextInt(150)).limit(30);
    }

    private final SubmissionPublisher<Integer> submissionPublisher = new SubmissionPublisher<>();

    @Override
    public void subscribe(Flow.Subscriber<? super Integer> subscriber) {
        submissionPublisher.subscribe(subscriber);
    }

    public void start() {
        intStream.forEach(element -> {
            submissionPublisher.submit(element);
            sleep();
        });
    }

    private void sleep() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException("could not sleep!");
        }
    }
}

Listing 12-2Publisher Generating an Infinite Number of Integers

img/463938_2_En_12_Figb_HTML.gif注意IntPublisher类的构造函数是如何接受一个参数的。如果在实例化时作为参数提供的值是 0(零),则创建无限流。如果参数值不为 0,则创建一个有限流。如果您希望运行示例而不是强制停止执行,这将非常有用。

正如所料,我们已经为subscribe()方法提供了一个实现,在这种情况下,我们要做的只是将subscriber转发给内部的submissionPublisher。因为我们已经通过包装submissionPublisher创建了我们的 publisher,所以这是必要的,否则我们的流程将不会像预期的那样工作。此外,我们还添加了一个start()方法,该方法从无限IntStream中获取元素,并使用内部submissionPublisher提交它们。

IntStream利用一个Random实例在[0,150]区间生成整数值。选择这个时间间隔是为了让我们看到连接到发布者的第一个Flow.Processor<T,R>实例是如何丢弃大于 127 的值的。为了能够减缓元素提交的速度,我们添加了一个对Thread.sleep(1000)的调用,这基本上保证了每秒一个元素会被向上链转发。

第一个处理器的名称是FilterCharProcessor,它将利用一个内部的SubmissionPublisher<Integer>实例将它处理的元素向前发送到下一个处理器。

抛出的异常也将使用SubmissionPublisher<Integer>转发。处理器既充当发布者,也充当订阅者,因此对onNext(..)方法的实现必须包括对subscription.request(..)的调用,以施加反压力。从本章前面提供的图中,您可以看到处理器基本上是一个允许数据双向流动的组件,它通过实现Publisher<T>Subscriber<T>来实现这一点。

处理器必须订阅发布者,并且当发布者subscribe(..)方法被调用时,将导致onSubscribe(Flow.Subscription subscription)方法被调用。订阅必须存储在本地,以便可以用来施加反压力。但是在接受订阅时,我们必须确保该字段尚未初始化,因为根据 reactive streams 规范,一个发布者只能有一个订阅者,否则结果是不可预测的。如果有新的订阅到达,必须取消,这是通过打电话cancel()完成的。清单 12-3 中描述了处理器的完整代码。

package com.apress.bgn.twelve.jdkstreams;

import java.util.concurrent.Flow;
import java.util.concurrent.SubmissionPublisher;
// some input statements omitted

public class FilterCharProcessor implements Flow.Processor<Integer, Integer> {
    private static final Logger log = LoggerFactory.getLogger(FilterCharProcessor.class);

    private final SubmissionPublisher<Integer> submissionPublisher = new SubmissionPublisher<>();
    private Flow.Subscription subscription;
    @Override
    public void subscribe(Flow.Subscriber<? super Integer> subscriber) {
        submissionPublisher.subscribe(subscriber);
    }
    @Override
    public void onSubscribe(Flow.Subscription subscription) {
        if (this.subscription == null) {
            this.subscription = subscription;
            // apply back pressure - request one element
            this.subscription.request(1);
        } else {
            subscription.cancel();
        }
    }
    @Override
    public void onNext(Integer element) {

        if (element >=0 && element < 127){
            submit(element);
        } else {
            log.debug("Element {} discarded.", element);
        }
        subscription.request(1);
    }
    @Override
    public void onError(Throwable throwable) {
        submissionPublisher.closeExceptionally(throwable);
    }

    @Override
    public void onComplete() {
        submissionPublisher.close();
    }
    protected void submit(Integer element){
        submissionPublisher.submit(element);
    }
}

Listing 12-3Flow.Processor<T,R> Implementation FilterCharProcessor<Integer,Integer> That Filters Integers > 127

这个处理器非常专用,一个处理流程通常需要不止一个。在这个场景中,我们需要几个,因为除了onNext(..)方法之外,实现的其余部分主要是样板代码,允许处理器在我们设计的流程中链接在一起,将这些代码包装在一个AbstractProcessor中会更实用,这个解决方案需要的所有处理器都可以扩展这个AbstractProcessor

由于流程中的最后一个处理器需要将接收到的Integer值转换为Character,因此该实现的返回类型将保持通用。清单 12-4 中描述了代码。

package com.apress.bgn.twelve.jdkstreams;

import java.util.concurrent.Flow;
import java.util.concurrent.SubmissionPublisher;

public abstract class AbstractProcessor<T> implements Flow.Processor<Integer, T> {
    protected final SubmissionPublisher<T> submissionPublisher = new SubmissionPublisher<>();
    protected Flow.Subscription subscription;

    @Override
    public void subscribe(Flow.Subscriber<? super T> subscriber) {
        submissionPublisher.subscribe(subscriber);
    }

    @Override
    public void onSubscribe(Flow.Subscription subscription) {
        if (this.subscription == null) {
            this.subscription = subscription;
            // apply back pressure - ask one or more than one
            this.subscription.request(1);
        } else {
            // avoid more than one Publisher sending elements to this Subscriber
            // do not accept other subscriptions
            subscription.cancel();
        }
    }

    @Override
    public void onError(Throwable throwable) {
        submissionPublisher.closeExceptionally(throwable);
    }

    @Override
    public void onComplete() {
        submissionPublisher.close();
    }

    protected void submit(T element) {

        submissionPublisher.submit(element);
    }
}

Listing 12-4AbstractProcessor<Integer,T> Implementation

这也简化了FilterCharProcessor<Integer, Integer>和其他处理器的实现。清单 12-5 中描述了FilterCharProcessor<Integer, Integer>的简化实现。

package com.apress.bgn.twelve.jdkstreams;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class FilterCharProcessor extends AbstractProcessor<Integer> {
    private static final Logger log = LoggerFactory.getLogger(FilterCharProcessor.class);

    @Override
    public void onNext(Integer element) {
        if (element >= 0 && element < 127) {
            submit(element);
        } else {
            log.debug("Element {} discarded.", element);
        }
        subscription.request(1);
    }
}

Listing 12-5FilterCharProcessor

Extending AbstractProcessor<Integer>

我们有一个出版商和一个处理器,那么现在呢?当然,我们把它们联系起来。清单 12-6 中的点(..))取代了本节中尚未建立的所有相互连接的处理器和用户。

package com.apress.bgn.twelve.jdkstreams;

public class ReactiveDemo {
    public static void main(String... args) {
        IntPublisher publisher = new IntPublisher(0);
        FilterCharProcessor filterCharProcessor = new FilterCharProcessor();

        publisher.subscribe(filterCharProcessor);
        // ..
        publisher.start();
    }
}

Listing 12-6Executing a Reactive Flow

下一个处理器实现是通过减去 32 将较小的字母转换成较大的字母。通过扩展AbstractProcessor<Integer, T>也可以很容易地实现,清单 12-7 中描述了该实现。

package com.apress.bgn.twelve.jdkstreams;

public class TransformerProcessor extends AbstractProcessor<Integer>{
    @Override
    public void onNext(Integer element) {
        if(element % 2 == 0 && element >=98 && element <=122) {
            element -=32;
        }
        submit(element);
        subscription.request(1);
    }
}

Listing 12-7The TransformerProcessor

Implementation

要在流程中插入这个处理器,我们只需要实例化它,调用filterCharProcessor.subscribe(..)并提供这个实例作为参数。清单 12-8 展示了创建我们的反应流的下一步。

package com.apress.bgn.twelve.jdkstreams;

public class ReactiveDemo {
    public static void main(String... args) {
        IntPublisher publisher = new IntPublisher(0);
        FilterCharProcessor filterCharProcessor = new FilterCharProcessor();
        TransformerProcessor transformerProcessor = new TransformerProcessor();

        publisher.subscribe(filterCharProcessor);
        filterCharProcessor.subscribe(transformerProcessor);
        // ..
        publisher.start();
    }
}

Listing 12-8A TransformerProcessor

Instance Being Added to a Reactive Flow

下一个要实现的是我们这个解决方案需要的最终处理器,它将一个Integer值转换成一个String值。为了尽可能保持实现的声明性,将向处理器提供映射函数作为参数。代码如清单 12-9 所示。

package com.apress.bgn.twelve.jdkstreams;

import java.util.function.Function;

public class MappingProcessor extends AbstractProcessor<Character> {
    private final Function<Integer, Character> function;

    public MappingProcessor(Function<Integer, Character> function) {
        this.function = function;
    }
    @Override
    public void onNext(Integer element) {
        submit(function.apply(element));
        subscription.request(1);
    }
}

Listing 12-9The MappingProcessor

Implementation

在清单 12-10 中,您可以看到一个MappingProcessor实例被添加到反应流中。

package com.apress.bgn.twelve.jdkstreams;

public class ReactiveDemo {
    public static void main(String... args) {
       IntPublisher publisher = new IntPublisher();
        FilterCharProcessor filterCharProcessor = new FilterCharProcessor();
        TransformerProcessor transformerProcessor = new TransformerProcessor();
        MappingProcessor mappingProcessor =
                        new MappingProcessor(element -> (char) element.intValue());
        publisher.subscribe(filterCharProcessor);
        filterCharProcessor.subscribe(transformerProcessor);
        transformerProcessor.subscribe(mappingProcessor);
        //...
        publisher.start();
    }
}

Listing 12-10A MappingProcessor

Instance Being Added to a Reactive Flow

这个流程的最后一个组成部分是订户。订户是流中最重要的组件;在添加订阅者并创建一个Subscription实例之前,实际上什么都不会发生。我们的订户实现了Flow.Subscriber<Character>,,它的大部分与我们在AbstractProcessor<T>中隔离的代码相同,这看起来可能有点多余,但也使事情变得非常简单。清单 12-11 描述了Subscriber的实现。

package com.apress.bgn.twelve.jdkstreams;
// some import statements omitted
import java.util.concurrent.Flow;

public class CharPrinter implements Flow.Subscriber<Character> {
    private static final Logger log = LoggerFactory.getLogger(CharPrinter.class);
    private Flow.Subscription subscription;

    @Override
    public void onSubscribe(Flow.Subscription subscription) {
        if (this.subscription == null) {
            this.subscription = subscription;
            this.subscription.request(1);
        } else {
            subscription.cancel();
        }
    }

    @Override
    public void onNext(Character element) {
        log.info("Result: {}", element);

        subscription.request(1);
    }

    @Override
    public void onError(Throwable throwable) {

        log.error("Something went wrong.", throwable);
    }

    @Override
    public void onComplete() {
        log.info("Printing complete.");
    }
}

Listing 12-11Subscriber<Character> Implementation

使用这个订户类,现在可以完成如清单 12-12 所示的流程。

package com.apress.bgn.twelve.jdkstreams;

public class ReactiveDemo {
    public static void main(String... args) {
        IntPublisher publisher = new IntPublisher(0);
        FilterCharProcessor filterCharProcessor = new FilterCharProcessor();
        TransformerProcessor transformerProcessor = new TransformerProcessor();
        MappingProcessor mappingProcessor = new MappingProcessor(element -> (char) element.intValue());
        CharPrinter charPrinter = new CharPrinter();

        publisher.subscribe(filterCharProcessor);
        filterCharProcessor.subscribe(transformerProcessor);
        transformerProcessor.subscribe(mappingProcessor);
        mappingProcessor.subscribe(charPrinter);
        publisher.start();
    }
}

Listing 12-12Reactive Pipeline Complete Implementation

如果subscribe(..)方法能够返回调用者实例,以便我们能够链接subscribe(..)调用,那就太好了,但是我们使用的是提供给我们的东西。当前面的代码运行时,类似于清单 12-13 中描述的日志被打印在控制台中:

...
INFO  c.a.b.t.j.CharPrinter - Result: .
INFO  c.a.b.t.j.CharPrinter - Result: ,
INFO  c.a.b.t.j.CharPrinter - Result: A
DEBUG c.a.b.t.j.FilterCharProcessor - Element 147 discarded.
DEBUG c.a.b.t.j.FilterCharProcessor - Element 127 discarded.
INFO  c.a.b.t.j.CharPrinter - Result: E
INFO  c.a.b.t.j.CharPrinter - Result: Z
...

Listing 12-13Console Output of a Reactive Flow Being Executed

前面的例子使用一个无限的IntStream来生成要发布、处理和消费的元素。这导致执行程序永远运行,所以你必须手动停止它。另一个结果是onComplete()方法永远不会被调用。如果我们想要使用它,我们必须确保被发布的条目数量是有限的,但是用一个不同于 0(零)的值初始化IntPublisher

还有一点要提的是,背压处理更多的是在概念上做的。Flow API 没有提供任何机制来通知背压或处理背压。所以subscription.request(1)只是确保当onNext(..)被调用时,元素生产率减少到 1。基于用户的微调,可以设计各种策略来处理背压,但是很难在一个非常简单的例子中显示类似的东西,这个例子不涉及两个微服务相互反应性地交互。

在 JDK 中,对反应流的支持非常少,即使在 2021 年 9 月 14 日发布的版本 17 中也是如此。预计在未来的版本中会添加更多有用的类,但显然 Oracle 专注于其他方面,如重组模块结构和决定如何更好地利用 JDK 赚钱。这就是为什么本章的最后一节介绍了一个用 Project Reactor 库完成的反应式编程的简短示例。

反应流技术兼容性套件

当构建使用反应式流的应用时,很多事情可能会出错。为了确保事情按预期进行, Reactive Streams 技术兼容性工具包项目,也称为 TCK、 3 是编写测试非常有用的库。这个库包含一些类,可以用来根据反应流规范测试反应实现。TCK 旨在验证 JDK java.util.concurrent.Flow类中包含的接口,出于某种原因,创建该库的团队决定使用 TestNG 作为测试库。

img/463938_2_En_12_Figc_HTML.jpg在版本 1.0.3 中修改了 TCK,以验证包含在反应流 API 中的接口。

等等,什么?你可能会惊呼。

那么如何用它来验证 JDK java.util.concurrent.Flow 类中包含的接口呢?

耐心等待年轻的学徒,一切都会在适当的时候得到解释。

TCK 包含四个类,必须实现它们来提供它们的Flow.Publisher<T>Flow.Subscriber<T>Flow.Processor<T,R>实现,以便测试工具进行验证。这四个类别是:

  • org.reactivestreams.tck.PublisherVerification<T>用于测试Publisher<T>的实现

  • org.reactivestreams.tck.SubscriberWhiteboxVerification<T>用于白盒测试Subscriber<T>实现和Subscription实例

  • org.reactivestreams.tck.SubscriberBlackboxVerification<T>用于黑盒测试Subscriber<T>实现和Subscription实例

  • org.reactivestreams.tck.IdentityProcessorVerification<T>用于测试Processor<T,R>的实现

为了使每个测试的目的变得明显,库测试方法的名称遵循这种模式:TYPE_spec#_DESC其中TYPErequired, optional, stochastic,untested,中的一个,表示被测试规则的重要性。spec</emphasis></emphasis>#中的散列符号代表规则号,第一个是 1 代表Publisher<T>实例,2 代表Subscriber<T>实例。DESC是对测试目的的简短说明。

让我们看看如何测试我们之前定义的IntPublisherPublisherVerification<T>类需要实现两个测试方法:一个测试发出大量元素的工作中的Publisher<T>(createPublisher(..)方法)实例,另一个测试“失败的”Publisher<T>(createFailedPublisher(..))实例,该实例无法初始化它需要发出元素的连接。

createPublisher(..)测试的实例是通过传递一个不同于 0(零)的参数创建的,因此IntPublisher实例发出一组有限的元素,测试执行也是有限的。

清单 12-14 中描述了PublisherVerification<Integer>的实现。

package com.apress.bgn.twelve.jdkstreams;

import org.reactivestreams.FlowAdapters;
import org.reactivestreams.Publisher;
import org.reactivestreams.tck.PublisherVerification;
import org.reactivestreams.tck.TestEnvironment;
import java.util.concurrent.Flow;
// other import statements omitted

public class IntPublisherTest extends PublisherVerification<Integer> {
    private static final Logger log = LoggerFactory.getLogger(IntPublisherTest.class);

    public IntPublisherTest() {
        super(new TestEnvironment(300));
    }

    @Override
    public Publisher<Integer> createPublisher(final long elements) {
        return FlowAdapters.toPublisher(new IntPublisher(30) {
            @Override
            public void subscribe(Flow.Subscriber<? super Integer> subscriber) {
                intStream.forEach(subscriber::onNext);
                subscriber.onComplete();
            }
        });
    }

    @Override
    public Publisher<Integer> createFailedPublisher() {
        return FlowAdapters.toPublisher(new IntPublisher(0) {
            @Override
            public void subscribe(Flow.Subscriber<? super Integer> subscriber) {
                subscriber.onError(new RuntimeException("There be dragons! (this is a failed publisher)"));
            }
        });
    }
}

Listing 12-14TestNG Test Class for Testing a IntPublisher Instance

关于前面的测试类,应该提到的另一件事是,由于实现是为使用反应流 API 而设计的,所以它不能用于测试基于 JDK 的IntPublisher。然而,前面提到过,在版本 1.0.3 中,反应流 API 增加了一组类,用作反应流和 JDK 反应流 API 之间的桥梁。因此,IntPublisher必须作为一个参数提供给FlowAdapters.toPublisher(..)方法,该方法将它转换成一个等价的org.reactivestreams.Publisher,以便IntPublisherTest可以测试。

由于特定于您正在构建的应用的设计决策,一个Publisher<T>实现可能无法通过所有的测试。在我们的例子中,IntPublisher的实现非常简单,当运行createPublisher(..)方法时,在所有执行的测试中,通过的并不多,大多数都被忽略了,如图 12-5 所示。

img/463938_2_En_12_Fig5_HTML.png

图 12-5

TestNG 反应式发布器

测试没有通过或被忽略的原因是我们的实现没有实现那些特定测试所针对的行为(例如,maySupportMultiSubscribemaySignalLessThanRequestedAndTerminateSubscriptionmustSignalOnMethodsSequentially)。

我们还可以通过扩展前面提到的测试类来测试我们在上一节中定义的处理器和订阅者,但是我们将把它作为一个练习留给您,因为在这一章中我们还想介绍一件更有趣的事情。

使用 Project Reactor

如前所述,JDK 对反应式编程的支持非常少。发布者、处理者和订阅者应该异步运行,所有这些行为都必须由开发人员来实现,这可能有点麻烦。目前,JDK 唯一适合的是在所有其他已经存在的实现之间提供一个公共接口。它们有很多,为更专业的反应组件和实用方法提供了更多有用的类,以便更容易地创建和连接它们。作为 Spring 爱好者,我个人最喜欢的一个是 Project Reactor ,也是 Spring 开发团队最喜欢的一个。

Project Reactor 是第一批用于反应式编程的库之一,它的类为构建反应式应用提供了一个非阻塞的稳定基础和高效的需求管理。它适用于 Java 8,但是为 JDK9+反应流类提供了适配器类。

Project reactor 适用于微服务应用,并提供了比 JDK 更多的类,旨在使编程反应式应用更实用。Project reactor 提供了两个主要的发布器实现:reactor.core.publisher.Mono<T>,它是一个反应式流发布器,仅限于发布零个或一个元素,以及reactor.core.publisher.Flux<T>,它是一个反应式流发布器,具有基本的流操作符。

使用 Project React 的优点是我们有更多的类和方法可以使用,有静态工厂可以用来创建发布者,操作可以更容易地链接起来。

项目反应堆小组不喜欢Processor,这个名字,所以中间组件被命名为操作员

如果你查阅官方文档,你很可能会遇到图 12-6 中的模式。 4

img/463938_2_En_12_Fig6_HTML.jpg

图 12-6

项目反应堆通量发布器实施

这是一个关于Flux<T> publisher 如何工作的抽象模式。Flux<T>发出元素,可以抛出异常,并在没有更多元素要发布时完成,与之前解释的行为相同,Project Reactor 团队只是找到了一种更漂亮的方式来绘制它。

Mono实现的绘图非常相似(参见 http://projectreactor.io/docs/core/release/api/reactor/core/publisher/Mono.html )。

但是让我们把它放在一边,看几个代码示例。使用该类中的多个实用方法创建Flux<T>实例非常容易。在开始发布元素之前,让我们设计一个除了打印值之外什么也不做的普通订阅者,因为我们将需要它来确保我们的Flux<T>发布器工作。

要使用 Project Reactor API 编写订阅者,您有多种选择。您可以直接实现org.reactivestreams.Subscriber<T>,如清单 12-15 所示。

package com.apress.bgn.twelve.reactor;

import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
// other import statements omitted

public class GenericSubscriber<T> implements Subscriber<T> {
    private static final Logger log = LoggerFactory.getLogger(GenericSubscriber.class);
    private Subscription subscription;
    @Override
    public void onSubscribe(Subscription subscription) {
        if (this.subscription == null) {
            this.subscription = subscription;
            this.subscription.request(1);
        } else {
            subscription.cancel();
        }
    }
    @Override
    public void onNext(T element) {
        log.info("consumed {} ", element);
        subscription.request(1);
    }
    @Override
    public void onError(Throwable t) {
        log.error("Unexpected issue!", t);
    }
    @Override
    public void onComplete() {
        log.info("All done!");
    }
}

Listing 12-15org.reactivestreams.Subscriber<T> Implementation

为了避免用样板代码实现那么多方法,还可以选择实现reactor.core.CoreSubscriber<T>,它是订阅者的 reactor 基本接口,或者更好,通过扩展提供基本订阅者功能的reactor.core.publisher.BaseSubscriber<T>类。订户典型方法的行为可以通过覆盖具有相同名称但前缀为hook的方法来修改。在清单 12-16 中,您可以看到使用 project reactor 编写订阅者是多么容易。

package com.apress.bgn.twelve.reactor;

import reactor.core.publisher.BaseSubscriber;
// other import statements omitted

public class GenericSubscriber<T> extends BaseSubscriber<T> {
    private static final Logger log = LoggerFactory.getLogger(GenericSubscriber.class);

    @Override
    protected void hookOnNext(T value) {
        log.info("consumed {} ", value);
        super.hookOnNext(value);
    }

    @Override
    protected void hookOnComplete() {
        log.info("call done.");
        super.hookOnComplete();
    }
}

Listing 12-16reactor.core.publisher.BaseSubscriber<T> Extension

*哒哒!*现在我们有了一个 subscriber 类,所以让我们创建一个反应式发布器来服务来自无限整数流的整数,以使用这个类的一个实例。实现如清单 12-17 所示。

package com.apress.bgn.twelve.reactor;

import reactor.core.publisher.Flux;
import java.util.Random;
import java.util.stream.Stream;

public class ReactorDemo {

    private static final Random random = new Random();

    public static void main(String... args) {
        Flux<Integer> intFlux = Flux.fromStream(
                Stream.generate(() -> random.nextInt(150))
        );
        intFlux.subscribe(new GenericSubscriber<>());
    }
}

Listing 12-17Creating a Reactive Publisher Using Project Reactor’s Flux<T>

如果您运行前面的代码,您将看到所有生成的整数值都由订阅者打印出来。可以从多个来源创建一个Flux<T>,包括数组和其他发布者。对于特殊情况,为了避免返回空值,可以通过调用empty()方法创建一个空的Flux<T>

String[] names = {"Joy", "John", "Anemona", "Takeshi"};
Flux.fromArray(names).subscribe(new GenericSubscriber<>());

Flux<Integer> intFlux = Flux.empty();
intFlux.subscribe(new GenericSubscriber<>());

我认为最棒的方法叫做just(..),,它同时适用于通量和单声道。它接受一个或多个值,并根据被调用的类型返回一个发布者、Flux<T>Mono<T>

Flux<String> dummyStr = Flux.just("one", "two", "three");
Flux<Integer> dummyInt = Flux.just(1,2,3);

Mono<Integer> one = Mono.just(1);
Mono<String> empty = Mono.empty();

您可能会发现另一个有用的方法是concat(..),它允许您连接两个Flux<T>实例。

String[] names = {"Joy", "John", "Anemona", "Takeshi"};
Flux<String> namesFlux = Flux.fromArray(names);

String[] names2 = {"Hanna", "Eugen", "Anthony", "David"};
Flux<String> names2Flux = Flux.fromArray(names2);
Flux<String> combined = Flux.concat(namesFlux, names2Flux);
combined.subscribe(new GenericSubscriber<>());

你可能会喜欢的另一件事是:还记得如何使用Thread.sleep(1000)调用来降低IntPublisher类的速度吗?使用Flux<T>,你不需要这样做,因为有两个实用方法结合起来会导致相同的行为。

Flux<Integer> infiniteFlux = Flux.fromStream(
        Stream.generate(() -> random.nextInt(150))
    );

Flux<Long> delay = Flux.interval(Duration.ofSeconds(1));
Flux<Integer> delayedInfiniteFlux = infiniteFlux.zipWith(delay, (s,l) -> s);
delayedInfiniteFlux.subscribe(new GenericSubscriber<>());

interval(..)方法创建一个发布器,它发出从 0 开始的长值,并在全局计时器上以指定的时间间隔递增;它接收 Duration 类型的参数,在前面的示例中使用了秒。zipWith(..)方法压缩作为参数接收的Flux<T>实例。zip操作是一个特定的流操作,它翻译为两个发布者发出一个元素并使用java.util.function.BiFunction<T, U, R>组合这些元素。在我们的例子中,该函数只是丢弃第二个元素,并返回调用流中被第二个流生成的秒数减慢的元素。

project reactor 提供的组件的优点是,它们返回的对象类型大多与被调用的对象类型相同,这意味着它们可以很容易地被链接起来。

可以用 reactor API 编写一段与之前实现的基于 JDK 的实现等价的反应式代码,如清单 12-18 所示。

Flux<Integer> infiniteFlux = Flux.fromStream(
        Stream.generate(() -> random.nextInt(150))
    );

Flux<Long> delay = Flux.interval(Duration.ofSeconds(1));
Flux<Integer> delayedInfiniteFlux = infiniteFlux.zipWith(delay, (s, l) -> s);

delayedInfiniteFlux
    .filter(element -> (element >= 0 && element < 127))
    .map(item -> {
        if (item % 2 == 0 && item >= 98 && item <= 122) {
            item -= 32;
        }
        return item;
    })
.map(element -> (char) element.intValue())
.subscribe(new GenericSubscriber<>());

Listing 12-18Writing a Reactive Pipeline Using Project Reactor

您所记得的 Stream API 中的大多数函数都是为了在 project Reactor 中被动使用而实现的,所以如果前面的代码看起来很熟悉,这就是原因。

关于 Project Reactor API,如果你需要一个反应库,你可以首先考虑这个。你可以在 http://projectreactor.io/docs/core/milestone/reference/ 找到官方文档,相当不错,例子也很多。如果 Oracle 决定提供自己的丰富 API 来使用反应式流编程反应式应用,他们可能会有点太晚了。

摘要

反应式编程不是一个简单的话题,但它似乎是编程的未来。这本书将需要进入真正先进的主题,以显示反应式解决方案的真正力量。作为一本完全面向 Java 初学者的书,这不是一个适合它的主题。然而,读完这本书后,如果你有兴趣了解更多关于构建反应式应用的知识,Apress 在 2021 年 1 月出版的Pro Spring MVC with web flux5这本书有几个很棒的章节是关于用 Spring 和 Project Reactor 构建反应式应用的。

你必须记住的是,对于非反应性的实现,反应性的实现是毫无用处的。设计和使用电抗组件和非电抗组件是没有用的,因为实际上你可能会引入故障点并减慢速度。例如,如果您使用的是 Oracle 数据库,那么定义一个使用反应式流返回元素的存储库类是没有意义的,因为 Oracle 数据库不支持反应式访问。您只是添加了一个增加额外实现的反应层,因为在这种情况下没有真正的好处。但是如果您选择的数据库是 MongoDB,您可以放心地使用反应式编程,因为 MongoDB 数据库支持反应式访问。此外,如果您正在构建一个具有 ReactJS 或 angular 接口的 web 应用,那么您可以设计控制器类,以反应性地提供要由接口显示的数据。

本章的内容可以概括如下:

  • 解释了反应式编程

  • 解释了反应流的行为

  • 涵盖了 JDK 反应流支持

  • 介绍了如何使用 Reactive Streams 技术兼容性套件测试您的反应式解决方案

  • 提供了用于构建反应应用的项目反应器组件的小介绍

Footnotes 1

如果您对通信模型更感兴趣,您可以在 web 上搜索企业集成模式。

  2

参见《无功之人》,《无功宣言》, https://www.reactivemanifesto.org ,2021 年 10 月 15 日访问。

  3

参见 GitHub 官方回购,“反应流”, https://github.com/reactive-streams/reactive-streams-jvm/tree/master/tck ,2021 年 10 月 15 日访问。

  4

图片来源:Project Reactor,“公共 API JavaDoc”, http://projectreactor.io/docs/core/release/api/reactor/core/publisher/Flux.html ,2021 年 10 月 15 日访问。

  5

Marten Deinum 和 Iuliana Cosmina,Pro Spring MVC with web flux(New York:a press,2021), https://www.apress.com/us/book/9781484256657 ,2021 年 10 月 15 日访问。

 

十三、垃圾收集

当执行 Java 代码时,从内存中反复创建、使用和丢弃对象。丢弃未使用的 Java 对象的过程被称为内存管理,但通常被称为垃圾收集(GC) 。在章节 5 **,**中提到了垃圾收集,因为需要它来解释原语和引用类型之间的区别,但是在这一章中,我们将深入 JVM 的内部来解决运行中的 Java 应用的另一个秘密。

当 Java 垃圾收集器正常工作时,内存在新对象创建之前就被清理了,不会填满,所以你可以说分配给程序的内存被回收了。低复杂度的程序,就像我们到目前为止所写的,不需要那么多内存就能运行,但是取决于它们的设计(还记得递归吗?)它们最终可能会使用比可用内存更多的内存。在 Java 中,垃圾收集器是自动运行的。在 C/C++等更低级的语言中,没有自动内存管理,开发人员负责编写代码来按需分配内存,并在不再需要时释放内存。尽管自动内存管理看起来很实用,但是如果管理不当,垃圾收集器会是一个问题。本章提供了足够的关于垃圾收集器的信息,以确保它被明智地使用,并且当问题出现时,至少您将有一个好的地方开始修复它们。

虽然将介绍一些调优垃圾收集器的方法,但是请记住,垃圾收集调优不是必需的;应该以这样的方式编写程序:它只创建执行其功能所需的对象,正确管理引用,应该在应用投入生产之前估计服务器运行应用的内存容量,并且应该在此之前知道并配置应用所需的最大内存量。如果分配给一个 Java 程序的内存不够用,通常是实现中有什么地方烂了。

垃圾收集基础知识

Java 自动垃圾收集是 Java 编程语言的主要特性之一。正如本书开头所提到的,JVM 是一种用于执行 Java 程序的虚拟机。Java 程序使用 JVM 在其上运行的系统资源,因此它必须有一种方法来安全地释放这些资源。这项工作由垃圾收集器完成。

为了理解垃圾收集器的位置,我们必须看一看 JVM 架构。

Oracle Hotspot JVM 架构

多年来,一些大公司已经开发了他们自己的 JVM 版本(例如 IBM ),现在 Java 正在进入模块时代和快速交付风格,越来越多的公司将会维护特定版本的 JDK/JVM(例如 Azul、Amazon Coretto、GraalVM ),因为对于具有遗留依赖性的大型应用来说,迁移到 9+是很困难的。

另一个重要的经济因素是,在两年宽限期后,所有 LTS 版本的 Java 支持将于 2019 年 1 月支付,因此公司最终将不得不为运行其基于 Java 的软件的 JDK 付费。学习编码或构建小项目的开发人员可以在个人电脑上使用甲骨文官方 JDK,但要在服务器上运行他们的软件,访问成熟的 JMC 等企业功能,并使该软件盈利需要付费订阅。

目前,Oracle 的 HotSpot 仍然是许多应用使用的最常见的 JVM。谈到垃圾收集,这个 JVM 提供了一套成熟的垃圾收集选项。其架构的抽象表示如图 13-1 所示。

img/463938_2_En_13_Fig1_HTML.jpg

图 13-1

Oracle HotSpot JVM 架构(抽象表示)

内存区域由垃圾收集器管理,并被分成多个区域。对象在这些区域之间移动,直到被丢弃。图 13-2 中描述的区域是针对旧式垃圾收集器和新式垃圾收集器的,这两种类型的垃圾收集器可能会遵循 JDK 当前使用的默认垃圾收集器的模型,G1GC,它是在 JDK 8 中引入的。

img/463938_2_En_13_Fig2_HTML.jpg

图 13-2

堆结构

G1GC 是为拥有大量资源的机器设计的下一代垃圾收集器,这就是它对堆进行分区的方法不同的原因。它的堆被划分成一组大小相等的堆区域,每个堆区域都是一个连续的虚拟内存范围。某些区域集被分配了与旧收集器相同的角色(eden、survivor、old ),但是它们没有固定的大小。这为内存使用提供了更大的灵活性。在下一节中,您可以阅读更多关于不同类型的垃圾收集器的内容,因为现在的重点仍然是堆内存及其被命名为的区域。

当一个应用运行时,它创建的对象被存储在年轻一代区域中。当一个对象被创建时,它在这一代的一个被命名为伊甸园空间的细分中开始它的生命。当 eden 空间被填满时,这触发了一个次要垃圾收集(次要 GC 运行),它清除这个区域中未被引用的对象,并将被引用的对象移动到第一个幸存者空间(S0) 。下一次 eden 空间被填满时,另一个小的 GC 运行被触发,它再次删除未被引用的对象,被引用的对象被移动到下一个残存空间(S1)

S0 中的对象已经在那里运行了一次较小的 GC,因此它们的年龄会增加。他们也被转移到 S1,所以 S0 和伊甸园可以被清理。

在下一次次要的 GC 运行时,再次执行该操作,但是这次引用的对象被保存到空的 S0 中。来自 S1 的旧对象增加了它们的年龄,也移到了 S0,所以 S1 和伊甸园可以被清理。

在幸存者空间中的对象达到某个年龄(特定于每种垃圾收集器的值)后,在较小的 GC 运行期间,它们被移动到****旧代空间**。**

**前述步骤在图像 13-3 中描述,对象o1o2被老化,直到它们被移动到旧生成区域。

img/463938_2_En_13_Fig3_HTML.jpg

图 13-3

小 GC 运行在年轻一代的空间

少量的 GC 收集将会发生,直到旧的层代空间被填满。这时会触发主垃圾收集(主垃圾收集运行),这将删除未引用的对象并压缩内存,四处移动对象,这样剩下的空内存就是一个大的压缩空间。

次要垃圾收集事件是一个停止世界的事件。这个进程基本上接管了应用的运行并暂停了它的执行,因此它可以释放内存。由于年轻一代的空间非常小(您将在下一节看到这一点),应用暂停通常可以忽略不计。如果在一次小规模的 GC 运行之后,没有内存可以从新生成区域回收,就会触发一次大规模的 GC 运行。

永久生成区域是为 JVM 元数据(如类和方法)保留的。这个区域也经常被清理,以删除应用中不再使用的类。当堆中没有更多的对象时,这个区域的清理被触发。

刚刚描述的垃圾收集过程是特定于分代垃圾收集器的,比如 G1GC。在 JDK 8 之前,垃圾收集是使用旧的垃圾收集器完成的,它使用一种叫做并发标记清除的算法。这种垃圾收集器与标记已用和未用内存区域的应用并行运行。然后,它会删除未引用的对象,并通过移动对象将内存压缩到一个连续的区域中。这一过程非常低效和耗时。随着越来越多的对象被创建,垃圾收集需要越来越多的时间来执行,但是由于大多数对象的寿命都很短,这实际上并不是问题。所以 CMS 垃圾收集器暂时还可以。

G1GC 有一个类似的方法,但是在标记阶段结束后,G1 将注意力集中在大部分是空的区域,以尽可能多地回收未使用的内存。这就是为什么这个垃圾收集器也被命名为垃圾优先。G1 还使用暂停预测模型,根据为应用设置的暂停时间来决定可以处理多少内存区域。来自已处理区域的对象被复制到堆的单个区域,从而同时实现了内存压缩。此外,G1GC 没有固定大小的 eden 和 survivor 空间,它在每次运行较小的 GC 后决定它们的大小。

有多少垃圾收集工?

垃圾收集器 Oracle HotSpot JVM 提供了以下类型的垃圾收集器:

  • 串行收集器:所有垃圾收集事件在一个线程中串行进行。内存压缩发生在每次垃圾收集之后。

*** 并行收集器:多线程用于少量垃圾收集。单个线程用于主要的垃圾收集和旧代压缩。

***   **CMS(并发标记清除** **)** :多线程用于少量垃圾收集,使用与并行 GC 相同的算法。主要的垃圾收集也是多线程的,但是 CMS 与应用进程同时运行,以最小化 stop world 事件。不进行内存压缩。这种类型的垃圾收集器适用于需要较短垃圾收集暂停时间的应用,并且在应用运行时能够与垃圾收集器共享处理器资源。这是默认的垃圾收集器,直到 Java 8 引入了默认的 G1。

*   **G1(垃圾优先** **)** :在 Oracle JDK 7 中引入,update 4 旨在永久取代 CMS GC,适用于可以与 CMS 收集器并发运行、需要内存压缩、需要更可预测的 GC 暂停持续时间且不需要大得多的堆的应用。垃圾优先(G1)收集器是一个服务器风格的垃圾收集器,目标是具有大内存的多处理器机器,但考虑到大多数笔记本电脑现在至少有 8 个内核和 16GB RAM,它也非常适合它们。G1 具有并发(与应用线程一起运行,例如细化、标记、清理)和并行(多线程,例如停止运行)两个阶段。完全垃圾收集仍然是单线程的,但是如果调整得当,您的应用应该可以避免完全垃圾收集。

*   Z 垃圾收集器:Z 垃圾收集器(ZGC)是 Java 11 中引入的一个可伸缩的低延迟垃圾收集器。ZGC 可以同时执行所有开销较大的工作,不会停止应用线程的执行超过 10 毫秒,因此非常适合要求低延迟和/或使用非常大的堆(数万亿字节)的应用

*   Shenandoah 垃圾收集器 : Shenandoah 是 Java 12 中引入的低暂停时间垃圾收集器,它通过与正在运行的 Java 程序并发执行更多垃圾收集工作来减少 GC 暂停时间。Shenandoah 并发执行大部分 GC 工作,包括并发压缩,这意味着它的暂停时间不再与堆的大小成正比。

*   **Epsilon 无操作收集器**:在 Java 11 中引入,这种类型的收集器实际上是一个虚拟 GC,它不回收或清理内存。当堆满时,JVM 就会关闭。这种类型的收集器可用于性能测试、内存分配分析、VM 接口测试,以及寿命极短的作业和应用,这些作业和应用在内存使用方面非常有限,开发人员必须尽可能准确地估计应用内存占用。

![img/463938_2_En_13_Figa_HTML.jpg](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/ce43e078984547f7901ebee3d342713e~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1771920935&x-signature=7mmyrSxEu0Hcf9Dr86lfGQM60C0%3D)并发标记清除垃圾收集器已从 JDK 中移除,并且不再识别`-XX:+UseConcMarkSweepGC` VM 选项。**** 

****我们已经列出了垃圾收集器的类型,但是我们如何知道本地 JVM 使用的是哪一种呢?方法不止一个。最简单的方法是在用main(..)方法运行一个简单的类时,添加-verbose:gc作为 VM 选项。

使用没有任何其他配置的 Java 17 JDK,会显示以下输出:

[0.011s][info][gc] Using G1

很明显,默认情况下,使用 G1 垃圾收集器。为了显示这个垃圾收集器的更多细节,在运行 Java 类时,可以将-Xlog:gc* 1 添加到 VM 参数中。对于只包含一个System.out.println语句的简单类com.apress.bgn.thirteen.ShowGCDemo,当使用前面提到的两个 VM 选项执行该类时,清单 13-1 中显示的输出会打印在控制台中。

[0.010s][info][gc] Using G1
[0.012s][info][gc,init] Version: 17+35-2724 (release)
[0.012s][info][gc,init] CPUs: 8 total, 8 available
[0.012s][info][gc,init] Memory: 16384M
[0.012s][info][gc,init] Large Page Support: Disabled
[0.012s][info][gc,init] NUMA Support: Disabled
[0.012s][info][gc,init] Compressed Oops: Enabled (Zero based)
[0.012s][info][gc,init] Heap Region Size: 2M
[0.012s][info][gc,init] Heap Min Capacity: 8M
[0.012s][info][gc,init] Heap Initial Capacity: 256M
[0.012s][info][gc,init] Heap Max Capacity: 4G
[0.012s][info][gc,init] Pre-touch: Disabled
[0.012s][info][gc,init] Parallel Workers: 8
[0.012s][info][gc,init] Concurrent Workers: 2
[0.012s][info][gc,init] Concurrent Refinement Workers: 8
[0.012s][info][gc,init] Periodic GC: Disabled
[0.012s][info][gc,metaspace] CDS archive(s) mapped at: [0x0000000800000000-0x0000000800bd0000-0x0000000800bd0000), size 12386304, SharedBaseAddress: 0x0000000800000000, ArchiveRelocationMode: 0.
[0.012s][info][gc,metaspace] Compressed class space mapped at: 0x0000000800c00000-0x0000000840c00000, reserved size: 1073741824
[0.012s][info][gc,metaspace] Narrow klass base: 0x0000000800000000, Narrow klass shift: 0, Narrow klass range: 0x100000000
Hey ma' look the GC!
[0.123s][info][gc,heap,exit] Heap
[0.123s][info][gc,heap,exit]  garbage-first heap   total 266240K, used 6098K [0x0000000700000000, 0x0000000800000000)
[0.123s][info][gc,heap,exit]   region size 2048K, 3 young (6144K), 0 survivors (0K)
[0.123s][info][gc,heap,exit]  Metaspace       used 397K, committed 576K, reserved 1056768K
[0.123s][info][gc,heap,exit]   class space    used 20K, committed 128K, reserved 1048576K

Listing 13-1Showing G1GC details Using -verbose:gc -Xlog:gc* as VM Arguments When Running ShowGCDemo

我们可以看到堆的最大大小(4G)、内存区域大小(2M)以及每一代的大小和占用情况。

章节 5 中,引入了java -XX:+PrintFlagsFinal -version命令来显示所有的 JVM 标志。过滤由“GC”和“NewSize”返回的结果显示所有 GC 特定的标志及其值。有不少,如清单 13-2 所示。

$ java -XX:+PrintFlagsFinal -version | grep 'GC\|NewSize'
    uintx AdaptiveSizeMajorGCDecayTimeScale   = 10           {product} {default}
     uint ConcGCThreads                       = 2            {product} {ergonomic}
     bool DisableExplicitGC                   = false        {product} {default}
     bool ExplicitGCInvokesConcurrent         = false        {product} {default}
    uintx G1MixedGCCountTarget                = 8            {product} {default}
    uintx G1PeriodicGCInterval                = 0            {manageable} {default}
     bool G1PeriodicGCInvokesConcurrent       = true         {product} {default}
   double G1PeriodicGCSystemLoadThreshold     = 0.000000     {manageable} {default}
    uintx GCDrainStackTargetSize              = 64           {product} {ergonomic}
    uintx GCHeapFreeLimit                     = 2            {product} {default}
    uintx GCLockerEdenExpansionPercent        = 5            {product} {default}
    uintx GCPauseIntervalMillis               = 201          {product} {default}
    uintx GCTimeLimit                         = 98           {product} {default}
    uintx GCTimeRatio                         = 12           {product} {default}
     bool HeapDumpAfterFullGC                 = false        {manageable} {default}
     bool HeapDumpBeforeFullGC                = false        {manageable} {default}
   size_t HeapSizePerGCThread                 = 43620760     {product} {default}
    uintx MaxGCMinorPauseMillis               = 18446744..   {product} {default}
    uintx MaxGCPauseMillis                    = 200          {product} {default}
   size_t MaxNewSize                          = 2575302656   {product} {ergonomic}
   size_t NewSize                             = 1363144      {product} {default}
   size_t NewSizeThreadIncrease               = 5320         {pd product} {default}
      int ParGCArrayScanChunk                 = 50           {product} {default}
    uintx ParallelGCBufferWastePct            = 10           {product} {default}
     uint ParallelGCThreads                   = 8            {product} {default}
     bool PrintGC                             = false        {product} {default}
     bool PrintGCDetails                      = false        {product} {default}
     bool ScavengeBeforeFullGC                = false        {product} {default}
     bool UseAdaptiveSizeDecayMajorGCCost     = true         {product} {default}
     bool UseAdaptiveSizePolicyWithSystemGC   = false        {product} {default}
     bool UseDynamicNumberOfGCThreads         = true         {product} {default}
     bool UseG1GC                          = true        {product}                                                          {ergonomic}
     bool UseGCOverheadLimit                  = true         {product} {default}
     bool UseMaximumCompactionOnSystemGC      = true         {product} {default}
     bool UseParallelGC                       = false        {product} {default}
     bool UseSerialGC                         = false        {product} {default}
     bool UseShenandoahGC                     = false        {product} {default}
     bool UseZGC                              = false        {product} {default}

Listing 13-2Showing G1GC Flags Using java -XX:+PrintFlagsFinal -version | grep 'GC\|NewSize'

默认情况下,UseG1GC设置为 true,这意味着当 JVM 用于执行 Java 应用时,将使用 G1 垃圾收集器。新尺寸过滤器挑选具有与年轻代尺寸相关的值的标志。当运行一个应用来定制 GC 行为或在日志中显示额外的细节时,所有这些标志都可以用作由-XX:+处理的 VM 选项。例如,我们可以通过使用特定的 VM 选项来指示 JVM 使用前面列出的任何垃圾收集器:

  • -XX:+UseSerialGC要使用串行 GC,在这种情况下添加-verbose:gc -Xlog:gc*作为 VM 选项也会产生清单 13-3 中的输出(注意缺少并行、并发工作器和不同的堆结构)。

  • -XX:+UseParallelGC要使用并行 GC,在这种情况下添加-verbose:gc -Xlog:gc*作为 VM 选项也会产生清单 13-4 中的输出(注意并行工作器和不同的堆结构)。

[0.013s][info][gc] Using Serial
[0.013s][info][gc,init] Version: 17+35-2724 (release)
[0.013s][info][gc,init] CPUs: 8 total, 8 available
[0.013s][info][gc,init] Memory: 16384M
[0.013s][info][gc,init] Large Page Support: Disabled
[0.013s][info][gc,init] NUMA Support: Disabled
[0.013s][info][gc,init] Compressed Oops: Enabled (Zero based)
[0.013s][info][gc,init] Heap Min Capacity: 8M
[0.013s][info][gc,init] Heap Initial Capacity: 256M
[0.013s][info][gc,init] Heap Max Capacity: 4G
[0.013s][info][gc,init] Pre-touch: Disabled
[0.014s][info][gc,metaspace] CDS archive(s) mapped at: [0x0000000800000000-0x0000000800bd0000-0x0000000800bd0000), size 12386304, SharedBaseAddress: 0x0000000800000000, ArchiveRelocationMode: 0.
[0.014s][info][gc,metaspace] Compressed class space mapped at: 0x0000000800c00000-0x0000000840c00000, reserved size: 1073741824
[0.014s][info][gc,metaspace] Narrow klass base: 0x0000000800000000, Narrow klass shift: 0, Narrow klass range: 0x100000000
Hey ma' look the GC!
[0.180s][info][gc,heap,exit] Heap
[0.180s][info][gc,heap,exit]  def new generation   total 78656K, used 9946K [0x0000000700000000, 0x0000000705550000, 0x0000000755550000)
[0.180s][info][gc,heap,exit]   eden space 69952K,  14% used [0x0000000700000000, 0x00000007009b6a70, 0x0000000704450000)
[0.180s][info][gc,heap,exit]   from space 8704K,   0% used [0x0000000704450000, 0x0000000704450000, 0x0000000704cd0000)
[0.180s][info][gc,heap,exit]   to   space 8704K,   0% used [0x0000000704cd0000, 0x0000000704cd0000, 0x0000000705550000)
[0.180s][info][gc,heap,exit]  tenured generation   total 174784K, used 0K [0x0000000755550000, 0x0000000760000000, 0x0000000800000000)
[0.180s][info][gc,heap,exit]    the space 174784K,   0% used [0x0000000755550000, 0x0000000755550000, 0x0000000755550200, 0x0000000760000000)
[0.180s][info][gc,heap,exit]  Metaspace       used 774K, committed 960K, reserved 1056768K
[0.180s][info][gc,heap,exit]   class space    used 67K, committed 192K, reserved 1048576K

Listing 13-3Showing Serial GC Details

  • 默认的垃圾收集器已经涵盖了这一点。

  • -XX:+UseShenandoahGC使用 Shenandoah GC。虽然这个标志存在,但是 Oracle 选择不构建 Shenandoah,但是它可以在 Shenandoah 官方文档中列出的各种 OpenJDK 构建中使用: https://wiki.openjdk.java.net/display/shenandoah/Main#Main-JDKSupport

  • -XX:+UseZGC为了使用 ZGC,在这种情况下,添加-verbose:gc -Xlog:gc*作为 VM 选项也会产生清单 13-5 中的输出(注意 GC 和运行时工作器以及不同的堆结构)。

[0.016s][info][gc] Using Parallel
[0.018s][info][gc,init] Version: 17+35-2724 (release)
[0.018s][info][gc,init] CPUs: 8 total, 8 available
[0.018s][info][gc,init] Memory: 16384M
[0.018s][info][gc,init] Large Page Support: Disabled
[0.018s][info][gc,init] NUMA Support: Disabled
[0.018s][info][gc,init] Compressed Oops: Enabled (Zero based)
[0.018s][info][gc,init] Alignments: Space 512K, Generation 512K, Heap 2M
[0.018s][info][gc,init] Heap Min Capacity: 8M
[0.018s][info][gc,init] Heap Initial Capacity: 256M
[0.018s][info][gc,init] Heap Max Capacity: 4G
[0.018s][info][gc,init] Pre-touch: Disabled
[0.018s][info][gc,init] Parallel Workers: 8
[0.018s][info][gc,metaspace] CDS archive(s) mapped at: [0x0000000800000000-0x0000000800bd0000-0x0000000800bd0000), size 12386304, SharedBaseAddress: 0x0000000800000000, ArchiveRelocationMode: 0.
[0.018s][info][gc,metaspace] Compressed class space mapped at: 0x0000000800c00000-0x0000000840c00000, reserved size: 1073741824
[0.018s][info][gc,metaspace] Narrow klass base: 0x0000000800000000, Narrow klass shift: 0, Narrow klass range: 0x100000000
Hey ma' look the GC!
[0.187s][info][gc,heap,exit] Heap
[0.187s][info][gc,heap,exit]  PSYoungGen      total 76288K, used 9337K [0x00000007aab00000, 0x00000007b0000000, 0x0000000800000000)
[0.187s][info][gc,heap,exit]   eden space 65536K, 14% used [0x00000007aab00000,0x00000007ab41e680,0x00000007aeb00000)
[0.187s][info][gc,heap,exit]   from space 10752K, 0% used [0x00000007af580000,0x00000007af580000,0x00000007b0000000)
[0.187s][info][gc,heap,exit]   to   space 10752K, 0% used [0x00000007aeb00000,0x00000007aeb00000,0x00000007af580000)
[0.187s][info][gc,heap,exit]  ParOldGen       total 175104K, used 0K [0x0000000700000000, 0x000000070ab00000, 0x00000007aab00000)
[0.187s][info][gc,heap,exit]   object space 175104K, 0% used [0x0000000700000000,0x0000000700000000,0x000000070ab00000)
[0.187s][info][gc,heap,exit]  Metaspace       used 746K, committed 896K, reserved 1056768K
[0.187s][info][gc,heap,exit]   class space    used 65K, committed 128K, reserved 1048576K

Listing 13-4Showing Parallel GC Details

  • -XX:+UseEpsilonGC,无操作垃圾收集器。如果在控制台中,您会看到一条消息,要求您也在启用 Epsilon 垃圾收集器的选项前添加-XX:+UnlockExperimentalVMOptions,请这样做。这个 VM 选项是解锁实验性特性所必需的,在本书写作的时候,这个垃圾收集器还是一个实验性的特性。添加-verbose:gc -Xlog:gc*作为 VM 选项也会产生清单 13-6 中的输出(注意缺少任何 workers 和 TLAB 选项)。
[0.031s][info][gc,init] Initializing The Z Garbage Collector
[0.031s][info][gc,init] Version: 17+35-2724 (release)
[0.031s][info][gc,init] NUMA Support: Disabled
[0.031s][info][gc,init] CPUs: 8 total, 8 available
[0.031s][info][gc,init] Memory: 16384M
[0.031s][info][gc,init] Large Page Support: Disabled
[0.031s][info][gc,init] GC Workers: 2 (dynamic)
[0.031s][info][gc,init] Address Space Type: Contiguous/Unrestricted/Complete
[0.031s][info][gc,init] Address Space Size: 65536M x 3 = 196608M
[0.032s][info][gc,init] Min Capacity: 8M
[0.032s][info][gc,init] Initial Capacity: 256M
[0.032s][info][gc,init] Max Capacity: 4096M
[0.032s][info][gc,init] Medium Page Size: 32M
[0.032s][info][gc,init] Pre-touch: Disabled
[0.032s][info][gc,init] Uncommit: Enabled
[0.032s][info][gc,init] Uncommit Delay: 300s
[0.032s][info][gc,init] Runtime Workers: 5
[0.032s][info][gc     ] Using The Z Garbage Collector
[0.033s][info][gc,metaspace] CDS archive(s) mapped at: [0x0000000800000000-0x0000000800ba4000-0x0000000800ba4000), size 12206080, SharedBaseAddress: 0x0000000800000000, ArchiveRelocationMode: 0.
[0.033s][info][gc,metaspace] Compressed class space mapped at: 0x0000000800c00000-0x0000000840c00000, reserved size: 1073741824
[0.033s][info][gc,metaspace] Narrow klass base: 0x0000000800000000, Narrow klass shift: 0, Narrow klass range: 0x100000000
Hey ma' look the GC!
[0.283s][info][gc,heap,exit] Heap
[0.283s][info][gc,heap,exit]  ZHeap           used 10M, capacity 256M, max capacity 4096M
[0.283s][info][gc,heap,exit]  Metaspace       used 754K, committed 896K, reserved 1056768K
[0.283s][info][gc,heap,exit]   class space    used 66K, committed 128K, reserved 1048576K

Listing 13-5Showing ZGC Details

[0.012s][info][gc] Using Epsilon
[0.012s][info][gc,init] Version: 17+35-2724 (release)
[0.012s][info][gc,init] CPUs: 8 total, 8 available
[0.012s][info][gc,init] Memory: 16384M
[0.012s][info][gc,init] Large Page Support: Disabled
[0.012s][info][gc,init] NUMA Support: Disabled
[0.012s][info][gc,init] Compressed Oops: Enabled (Zero based)
[0.012s][info][gc,init] Heap Min Capacity: 6656K
[0.012s][info][gc,init] Heap Initial Capacity: 256M
[0.012s][info][gc,init] Heap Max Capacity: 4G
[0.012s][info][gc,init] Pre-touch: Disabled
[0.012s][warning][gc,init] Consider setting -Xms equal to -Xmx to avoid resizing hiccups
[0.012s][warning][gc,init] Consider enabling -XX:+AlwaysPreTouch to avoid memory commit hiccups
[0.012s][info   ][gc,init] TLAB Size Max: 4M
[0.012s][info   ][gc,init] TLAB Size Elasticity: 1.10x
[0.012s][info   ][gc,init] TLAB Size Decay Time: 1000ms
[0.013s][info   ][gc,metaspace] CDS archive(s) mapped at: [0x0000000800000000-0x0000000800bd0000-0x0000000800bd0000), size 12386304, SharedBaseAddress: 0x0000000800000000, ArchiveRelocationMode: 0.
[0.013s][info   ][gc,metaspace] Compressed class space mapped at: 0x0000000800c00000-0x0000000840c00000, reserved size: 1073741824
[0.013s][info   ][gc,metaspace] Narrow klass base: 0x0000000800000000, Narrow klass shift: 0, Narrow klass range: 0x100000000
Hey ma' look the GC!
[0.179s][info   ][gc,heap,exit] Heap
[0.179s][info   ][gc,heap,exit] Epsilon Heap
[0.179s][info   ][gc,heap,exit] Allocation space:
[0.179s][info   ][gc,heap,exit]  space 262144K,   1% used [0x0000000700000000, 0x00000007003364a0, 0x0000000710000000)
[0.180s][info   ][gc,heap,exit]  Metaspace       used 751K, committed 896K, reserved 1056768K
[0.180s][info   ][gc,heap,exit]   class space    used 65K, committed 128K, reserved 1048576K
[0.180s][info   ][gc          ] Heap: 4096M reserved, 256M (6.25%) committed, 3289K (0.08%) used
[0.180s][info   ][gc,metaspace] Metaspace: 1032M reserved, 896K (0.08%) committed, 752K (0.07%) used

Listing 13-6Showing Epsilon GC Details

正如您所看到的,为这些垃圾收集器打印的数据有共同的元素,比如堆的大小,在应用开始时总是 256M,在我的系统上最大大小为 4GB。伊甸园和年轻一代之间也有所不同,G1 只为年轻一代使用 4096K,而 CMS 需要 78656K。(更多)

这里最有趣的是 Epislon 垃圾收集器,因为正如预期的那样,它没有将堆分成生成区域,因为这种类型的垃圾收集器根本不执行垃圾收集。 TLAB线程本地分配缓冲区的缩写,是存储对象的内存区域。只有较大的对象存储在 TLABs 之外。TLABs 在每个线程单独执行期间动态调整大小。因此,如果一个线程分配了大量内存,那么它从堆中获得的新 TLABs 的大小将会增加。可以使用 VM -XX:MinTLABSize选项来控制 TLAB 的最小大小。

对于我们使用前面的 VM 选项运行的小型空类,这个输出实际上并不相关,但是您可以在运行下一节的代码时使用这些选项,因为此时这里打印的统计数据具有一定的相关性。

此外,还有一个名为-XX:+PrintCommandLineFlags的 VM 选项,当运行一个类来描述垃圾收集器的配置时,可以使用这个选项,比如它使用的线程数量、堆大小等等。这些选项如清单 13-7 所示。

-XX:ConcGCThreads=2
-XX:G1ConcRefinementThreads=8
-XX:GCDrainStackTargetSize=64
-XX:InitialHeapSize=268435456
-XX:MarkStackSize=4194304
-XX:MaxHeapSize=4294967296
-XX:MinHeapSize=6815736
-XX:+PrintCommandLineFlags
-XX:ReservedCodeCacheSize=251658240
-XX:+SegmentedCodeCache
-XX:+UseCompressedClassPointers
-XX:+UseCompressedOops
-XX:+UseG1GC

Listing 13-7G1GC VM Options

这些 VM 选项中的大多数都有明显的名字,允许开发者自己推断它们的用途;对于那些不知道的人,有一个来自 Oracle 的官方文档。如果你曾经需要剖析 Oracle 内存管理,这篇文章非常适合这个: https://www.oracle.com/java/technologies/javase/javase-core-technologies-apis.html

从代码中使用 GC

对于大多数应用来说,垃圾收集并不是开发人员必须真正考虑的事情。JVM 不时地启动一个 GC 线程,它通常在不妨碍应用执行的情况下完成工作。对于不仅仅想掌握 Java 基本技能的开发人员来说,理解 Java 垃圾收集的工作原理以及如何对其进行调优是必须的。关于 Java 垃圾收集,开发人员必须接受的第一件事是它不能在运行时被控制。正如您将在下一节中看到的,有一种方法可以建议 JVM 进行一些内存清理,但是不能保证内存清理真的会被执行。当一个对象被丢弃时,唯一能做的事情就是指定一些要运行的代码。

使用finalize()方法

在本书的开始,提到了每个 Java 类都自动是 JDK java.lang.Object类的子类。这个类是 JDK 层次结构的根,也是应用中所有类的根。它提供了许多有用的方法,可以扩展或覆盖这些方法来实现特定于子类的行为。前面已经提到了equals()hashcode()toString()。在 Java 9 中不赞成使用finalize()方法,但是为了向后兼容,它还没有从 JDK 中删除。终结机制有些问题。终结可能会导致性能问题、死锁和挂起。终结器中的错误会导致资源泄漏,如果不再需要,也没有办法取消终结。

由于一些开发人员可能最终会使用早期版本的 JDK 来处理 Java 项目,所以知道这个方法的存在是有好处的,以防您可能需要它,或者只是知道在哪里可以找到奇怪的 bug。

当代码中不再有对该对象的任何引用时,垃圾回收器将调用此方法。在我们继续之前,看一下清单 13-8 中的代码。

package com.apress.bgn.thirteen;

import com.apress.bgn.thirteen.util.NameGenerator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.time.LocalDate;
import java.util.Random;

public class InfiniteSingerGenerator {
    private static final Logger log = LoggerFactory.getLogger(InfiniteSingerGenerator.class);
    private static NameGenerator nameGenerator = new NameGenerator();
    private static final Random random = new Random();

    public static void main(String... args) {
        while (true) {
            genSinger();
        }
    }

    private static void genSinger() {
        Singer s = new Singer(nameGenerator.genName(), random.nextDouble(), LocalDate.now());
        log.info("JVM created: {}", s.getName());
    }
}

Listing 13-8Class Generating an Infinite Number of Singer Instances

即使不知道NameGeneratorSinger类是什么样子,前面代码执行的动作也应该是显而易见的。main 方法在无限循环中调用genSinger()方法。这意味着创建了无限的Singer实例。那么会发生什么呢?代码会运行吗?多久?如果你能在心里回答这些问题,我在这里的工作就完成了;你现在可以不看这本书了。☺

5 中有一些数字代表一个小程序的内存内容。图 13-4 展示了在前一个程序执行期间 Java 堆和栈内存的样子。

img/463938_2_En_13_Fig4_HTML.jpg

图 13-4

在执行InfiniteSingerGenerator类期间的 Java 栈和堆内存

由于显而易见的原因,只显示了一个genSinger()调用和一个Singer实例。正如你所看到的,当调用main(..)方法时,静态实例的引用被创建,这将与程序相关,直到它的执行结束。然后,genSinger()方法被调用。这些方法中的每一个都有自己的栈,其中保存了对在该方法上下文中创建的对象的引用,在本例中是Singer实例。该引用仅用于打印在该方法主体中创建的 Singer 实例的名称。则该方法存在,不返回引用。这意味着创建的实例不再是必需的,因为它被创建来仅在该方法的上下文中使用。当 genSinger()方法的执行结束时,对Singer的引用将从栈中丢弃。Singer实例仍然存在于堆内存中,但是不能再从程序中访问,因此它不再是必需的。现在,它只是保持一个内存块被自己的内容和对其他实例的引用占用,在这种情况下,是一个String、一个Double和一个LocalDate

考虑到genString()被调用了无数次(在图中我们用(*n)来表示),更多的Singer实例将被创建,它们将保持内存被占用,程序将无法在某个时候创建其他实例,因为没有更多的可用内存了。

这就是垃圾收集器发挥作用的地方。不再被程序引用的Singer实例被认为是垃圾,(现在你知道这个名字是从哪里来的了):它们不再是必需的,可以安全地清理内存。垃圾收集器是一个清理线程,它与主执行线程并行运行,并不时地开始删除堆内存中未被引用的对象。因为finalize()方法仍然可用,我们将为Singer类型覆盖它以打印日志消息,所以当垃圾收集器销毁实例时,我们可以直接在控制台中看到,因为在此之前会调用finalize()方法。清单 13-9 中的代码片段描述了我们的Singer实例。

package com.apress.bgn.thirteen;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.time.LocalDate;
import java.util.Objects;

public class Singer {
    private static final Logger log = LoggerFactory.getLogger(Singer.class);
    private static final long serialVersionUID = 42L;
    private final long birthtime;

    private String name;

    private Double rating;

    private LocalDate birthDate;

    public Singer(String name, Double rating, LocalDate birthDate) {
        this.name = name;
        this.rating = rating;
        this.birthDate = birthDate;
        this.birthtime = System.nanoTime();
    }

    // some code omittted

    @Override
    protected void finalize() throws Throwable {
        try {
            long deathtime = System.nanoTime();
            long lifespan = (deathtime - birthtime) / 1_000_000_000;
            log.info("GC Destroyed: {} after {} seconds", name, lifespan);

        } finally {
            super.finalize();
        }
    }
}

Listing 13-9The Singer Class with the Overriden finalize() Method

添加字段birthtime只是为了能够计算调用实例的构造函数和垃圾收集器调用finalize()方法之间经过的时间。由于时间是以纳秒为单位计算的,所以我们用 10 9 除以这个差值来得到以秒为单位的时间。

本节中使用的代码示例让垃圾收集器做了很多工作,因为每个被创建的Singer实例在被丢弃之前都很少被使用。如果您运行该代码,您将在控制台中看到许多日志消息:首先是许多关于正在创建的对象的消息,如果您稍待片刻,也会出现关于对象正在被丢弃的消息。所有输出都定向到一个文件,因为 IntelliJ IDEA 控制台基于一个缓冲区,该缓冲区会不时重置以防止编辑器崩溃。您必须手动停止程序,因为 while(true)永远不会结束,因为它的条件永远不会计算为false。当你停止程序后,你会在下面的位置看到一个日志文件:/chapter13/out/gc.log。如果没有,请修改该类的 IntelliJ IDEA 启动器,并添加以下 VM 选项:

-Dlogback.configurationFile=chapter13/src/main/resources/logback.xml并再次运行。

gc.log的内容看起来应该很像清单 13-10 中描述的片段:

INFO  c.a.b.t.InfiniteSingerGenerator - JVM created: Acnefqlspvwekzq
INFO  c.a.b.t.InfiniteSingerGenerator - JVM created: izyfkluhimlpkt
INFO  c.a.b.t.InfiniteSingerGenerator - JVM created: Tcyrpvgyfbpobym
INFO  c.a.b.t.InfiniteSingerGenerator - JVM created: Akmvyeazowdavpy
INFO  c.a.b.t.Singer - GC Destroyed: Kjidllzezjjdjge after 1 seconds
INFO  c.a.b.t.InfiniteSingerGenerator - JVM created: Llsghambpgetl c
INFO  c.a.b.t.Singer - GC Destroyed: Bffmcezvrzflhlh after 1 seconds
INFO  c.a.b.t.InfiniteSingerGenerator - JVM created: Pg vjmfwzhujzv
INFO  c.a.b.t.Singer - GC Destroyed: wrlaqutybuzvsj after 1 seconds
INFO  c.a.b.t.InfiniteSingerGenerator - JVM created: Kdzlsyiteskleka
INFO  c.a.b.t.Singer - GC Destroyed: Lqzdgeqqguitbgg after 1 seconds
INFO  c.a.b.t.Singer - GC Destroyed: Ddpzqlbiryelzvr after 1 seconds
INFO  c.a.b.t.Singer - GC Destroyed: Ozkzfubi  vpmj  after 1 seconds
INFO  c.a.b.t.InfiniteSingerGenerator - JVM created: Uegz isigjcrlfj
...

Listing 13-10The gc.log File Showing the finalize() Method

in Class Singer Being Called

当你有了这个文件,你可以打开它并开始分析它的内容,但是因为 IntelliJ 可能不会打开这么大的文件,试着用一个专门的文本编辑器打开它,比如 Notepad++或者 Sublime。或者,如果您使用 Unix/Linux 操作系统,只需打开您的控制台并使用 grep 命令,如下所示:

grep -a 'seconds' gc.log

这将显示调用finalize()方法时打印的所有日志条目。然后,您可以选择一个实例的名称,并执行如下操作:

$ grep -a 'Lybhpococssuoz' gc.log
INFO c.a.b.c.Main - JVM created: Lybhpococssuoz
INFO c.a.b.c.Singer - GC Destroyed: Lybhpococssuoz after 7 seconds

正如您所看到的,从堆中删除一个Singer实例所需的时间各不相同,这是因为 GC 是随机调用的;开发者对此没有控制权。有一种方法可以明确地请求进行垃圾收集——嗯,有两种方法。可以拨打:System.gc()或者

Runtime.getRuntime().gc().

System.gc()反正叫Runtime.getRuntime().gc()

但是,这并不意味着 GC 会立即开始清理内存;这更像是建议 JVM 努力回收未使用的对象和未使用的内存,因为它们是需要的。

现在,回到finalize()方法。提到过在 Java 9 中被标记为不推荐使用。此方法旨在由处理存储在堆外的资源的类重写。这里最明显的例子是 I/O 处理类,用于将资源作为文件或 URL 和数据库读取。当一个对象不再能被正在运行的应用的任何活动线程访问时,JVM 将调用finalize(),以确保那些资源被释放并可供其他外部和不相关的程序使用。

在 Windows 上旧版本的 Apache Tomcat(一个基于 Java 的 web 服务器)中,有一个与资源释放有关的错误。当服务器崩溃或被强制停止时,它无法再次启动,因为它的一些日志文件处理程序没有正确释放,新的服务器实例无法访问它们以开始写入新的日志条目。(这是我很久很久以前在 Windows 上使用 Apache Tomcat 时的个人经验。)

随着 JDK 1.7 中java.lang.AutoCloseable接口的引入,finalize()方法变得越来越少使用。前面已经提到了这种方法的一些问题,但是下面的列表给出了更多的上下文:

  • JVM 不能保证哪个线程将为任何给定的对象调用这个方法,因此任何可以访问它的线程都可以调用它,我们可能会在仍然需要该对象时释放资源。该方法是公共的,因此可以在代码中显式调用它,即使它应该只由 GC 线程调用。

  • 如果自定义实现不正确,抛出异常,或者没有正确释放资源,会发生什么?

  • JVM 应该只调用一次finalize()方法,但这不能保证。

  • 另一个缺点是finalize()调用不会被自动链接,所以finalize()方法的自定义实现必须总是显式调用超类的finalize()方法。

  • 之前提到的另一个问题是:一旦调用了finalize(),就没有办法停止方法的执行或撤销其效果,所以基本上只剩下对一个不再存在的对象的引用。

现在您可能已经发现,在实现这种方法时,开发人员有很大的自由,这意味着有很大的空间发生错误。

这就是为什么 Java 中的终结机制是有缺陷的,并且在 JDK 9 中被否决以阻止它的使用。不当的finalize()实施可能导致:

  • 内存泄漏(内存内容不会被丢弃)

  • 死锁(资源被两个进程阻塞)

  • 挂起(进程处于等待状态,无法退出)

为了有助于内存管理,Java 9 中引入了java.lang.ref.Cleaner类。在此之前,我必须向您展示如何通过编程来检查您的内存状态。

堆内存统计信息

当程序运行时,试图与 JVM 内部交互时,Runtime类非常有用。正如本章前面提到的,可以调用它的gc()方法来建议 JVM 应该清理内存,几章前我们使用了这个类中的方法来从 Java 代码中启动进程。这个类中有三个方法对于查看分配给 Java 程序的内存的状态很有用:

  • runtime.maxMemory()返回 JVM 在需要时试图为其堆使用的最大内存量。此方法返回的值因机器而异,并且被隐式设置为机器上现有 RAM 总内存的四分之一,除非设置了,否则它是通过使用以下 JVM 选项-Xmx后跟内存量来显式设置的(例如,-Xmx8G将允许 JVM 使用最大 8 GB 的内存)。

  • runtime.totalMemory()返回 JVM 的内存总量。该方法返回的值也因机器而异,并且是依赖于实现的,除非通过使用下面的 JVM 选项-Xms后跟内存量来显式设置(例如,-Xms1G将告诉 JVM 其堆内存的初始大小应该是 1 GB 内存)。

  • runtime.freeMemory()返回 Java 虚拟机空闲内存量的近似值。使用runtime.totalMemory()runtime.freeMemory()方法,我们可以写一些代码来检查在程序执行的不同时刻我们的内存被占用了多少。为此,创建了一个名为MemAudit的类,该类将使用当前的记录器来打印内存值。这个类的实现如清单 13-11 所示。

package com.apress.bgn.thirteen.util;

import org.slf4j.Logger;

public class MemAudit {
    private static final long MEGABYTE = 1024L * 1024L;
    private static final Runtime runtime = Runtime.getRuntime();

    public static void printBusyMemory(Logger log) {
        long memory = runtime.totalMemory() - runtime.freeMemory();
        log.info("Occupied memory: {} MB", (memory / MEGABYTE));
    }
    public static void printTotalMemory(Logger log) {
        log.info("Total Program memory: {} MB", (runtime.totalMemory()/MEGABYTE));
        log.info("Max Program memory: {} MB", (runtime.maxMemory()/MEGABYTE));
    }
}

Listing 13-11The MemAudit Class

Shown Memory Statistics During the Execution of a Java Application

这个类的方法将在我们的程序执行期间被调用,如清单 13-12 所示。

package com.apress.bgn.thirteen;

// some imports omitted
import static com.apress.bgn.thirteen.MemAudit.*;

public class MemAuditDemo {
    private static final Logger log = LoggerFactory.getLogger(MemAuditDemo.class);
    private static NameGenerator nameGenerator = new NameGenerator();
    private static final Random random = new Random();

    public static void main(String... args) {
        printTotalMemory(log);
        int count =0;
        while (true) {
            genSinger();
            count++;
            if (count % 1000 == 0) {
                printBusyMemory(log);
            }
        }
    }
    private static void genSinger() {
        Singer s = new Singer(nameGenerator.genName(), random.nextDouble(), LocalDate.now());
        log.info("JVM created: {}", s.getName());
    }
}

Listing 13-12The MemAuditDemo Class

Using the Class in Listing 13-11 to Print Memory Statistics in the Console

删除旧的日志文件后,我们应该运行这个类,并让它运行一段时间。因为不可能再看到输出,所以这个命令

grep -a 'memory' gc.log

对于提取包含“memory”一词的所有行非常有用,结果应该与清单 13-13 中的结果非常相似。

$  grep -a 'memory' gc.log
INFO  c.a.b.t.MemAuditDemo - Total Program memory: 260 MB
INFO  c.a.b.t.MemAuditDemo - Max Program memory: 4096 MB
INFO  c.a.b.t.MemAuditDemo - Occupied memory: 21 MB
INFO  c.a.b.t.MemAuditDemo - Occupied memory: 7 MB
INFO  c.a.b.t.MemAuditDemo - Occupied memory: 12 MB
...
INFO  c.a.b.t.MemAuditDemo - Occupied memory: 98 MB
INFO  c.a.b.t.MemAuditDemo - Occupied memory: 104 MB
...

Listing 13-13Memory Statistics Printed By Methods in the MemAudit Class

During Java Application Execution

最大内存是 4096MB,这意味着我的机器总共有 16 GB 的 RAM,占用的内存非常少,甚至不到 JVM 最初使用的 260MB。如果我们希望看到真实内存被占用,我们可以修改genSinger()方法来返回创建的引用,并将它们添加到一个列表中。因为在主类中引用了Singer实例,所以内存不再被清空。前述修改如清单 13-14 所示。

import com.apress.bgn.thirteen.util.NameGenerator;
// some import statements omitted
import java.util.ArrayList;
import java.util.List;
import static com.apress.bgn.thirteen.util.MemAudit.*;

public class MemoryConsumptionDemo {
    private static final Logger log = LoggerFactory.getLogger(MemoryConsumptionDemo.class);
    private static NameGenerator nameGenerator = new NameGenerator();
    private static final Random random = new Random();

    public static void main(String... args) {
        printTotalMemory(log);
        List<Singer> singers = new ArrayList<>();
        for (int i = 0; i < 1_000_000; ++i) {
            singers.add(genSinger());
            if (i % 1000 == 0) {
                printBusyMemory(log);
            }
        }
    }
    private static Singer genSinger() {
        Singer s = new Singer(nameGenerator.genName(), random.nextDouble(), LocalDate.now());
        log.info("JVM created: {}", s.getName());
        return s;
    }
}

Listing 13-14Saving the Singer Instances

to a List to Avoid Them Being Collected by the GC and the Memory Cleared

运行前面的程序后,我们实际上可以看到正在使用的内存逐渐增加。查看一下被 grep 神奇过滤的日志,我们会发现程序一直占用内存直到结束,因为引用现在保存到了List<Singer>实例中,如清单 13-15 所示。

$ grep -a 'memory' gc.log
INFO  c.a.b.t.MemoryConsumptionDemo - Total Program memory: 260 MB
INFO  c.a.b.t.MemoryConsumptionDemo - Max Program memory: 4096 MB
INFO  c.a.b.t.MemoryConsumptionDemo - Occupied memory: 14 MB
INFO  c.a.b.t.MemoryConsumptionDemo - Occupied memory: 17 MB
INFO  c.a.b.t.MemoryConsumptionDemo - Occupied memory: 19 MB
INFO  c.a.b.t.MemoryConsumptionDemo - Occupied memory: 22 MB
...
INFO  c.a.b.t.MemoryConsumptionDemo - Occupied memory: 99 MB
INFO  c.a.b.t.MemoryConsumptionDemo - Occupied memory: 101 MB
INFO  c.a.b.t.MemoryConsumptionDemo - Occupied memory: 104 MB
...
INFO  c.a.b.t.MemoryConsumptionDemo - Occupied memory: 474 MB
INFO  c.a.b.t.MemoryConsumptionDemo - Occupied memory: 477 MB

Listing 13-15Memory Statistics Printed By Methods in the MemAudit Class

During a Java Application Execution Where Instances Are Saved to a List<Singer>

当我们每 1000 步打印一次被占用的内存时,我们可以得出结论,1000 个Singer实例大约占用 2 MB。前面的代码不再使用无限循环来生成实例;如果出现这种情况,程序会在某个时候突然崩溃,抛出以下异常:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at chapter.thirteen/com.apress.bgn.thirteen.MemoryConsumptionDemo
        .genSinger(MemoryConsumptionDemo.java:64)
    at chapter.thirteen/com.apress.bgn.thirteen.MemoryConsumptionDemo
        .main(MemoryConsumptionDemo.java:55)

还记得runtime.maxMemory()返回的值吗?在我的机器上,它是 4096MB。如果我在控制台中查看,就在刚才描述的异常之前,我会看到以下内容:

INFO c.a.b.c.MemoryConsumptionDemo - Occupied memory: 4094 MB
INFO c.a.b.c.MemoryConsumptionDemo - Occupied memory: 4094 MB
INFO c.a.b.c.MemoryConsumptionDemo - Occupied memory: 4095 MB
INFO c.a.b.c.MemoryConsumptionDemo - Occupied memory: 4095 MB
INFO c.a.b.c.MemoryConsumptionDemo - Occupied memory: 4095 MB

因此 JVM 努力创建另一个 Singer 实例,但是没有剩余的内存。在异常之前打印的最后一个值是4095MB,比允许 JVM 使用的最大内存量4096MB少 1 MB。所以可怜的 JVM 崩溃了,因为没有更多的堆内存可用。如果一个程序以这样的方式结束,问题总是出在解决方案的设计上。JVM 的总内存和最大内存的值也会影响 GC 的行为。前面介绍的-Xms-Xmx非常重要,因为它们决定了堆内存的初始大小和最大大小。正确配置它们可以提高性能,但当值不合适时,它们会产生负面影响。例如,不要将堆的初始大小设置得太小,因为如果没有足够的空间来容纳应用创建的所有对象,JVM 就必须分配更多的内存,基本上就是在程序执行期间重复地重建堆。因此,如果在应用运行期间发生几次这种情况,总的时间消耗将会受到影响。堆的最大大小非常重要:分配太少应用会崩溃,分配太多可能会阻碍其他程序运行。决定这些值通常是通过反复实验完成的,从 JDK 11 号开始,新的 Epsilon 垃圾收集器在这方面非常方便。

如果你想了解更多关于 GC 调优的知识,通常最好的文档是官方的( https://docs.oracle.com/en/java/javase/17/gctuning )。

既然您已经知道了 GC 会带来什么,那么让我们来看看定制其行为的其他方法,这样可以避免问题。

使用清洁剂

因为需要确保向后兼容性,所以还不清楚finalize()方法何时会从 JDK 中移除。如果需要,可以开发类来实现java.lang.AutoCloseable并为close()方法提供一个实现,并确保在try-with-resources语句中使用你的对象。如果你想避免实现这个接口,还有一个方法:使用一个java.lang.ref.Cleaner对象。这个类可以被实例化,当对象被垃圾收集器丢弃时,对象可以和要执行的动作一起注册到这个类中。使用一个Cleaner实例,前面的代码可以如清单 13-16 所示编写:

package com.apress.bgn.thirteen.cleaner;
// some import statements omitted
import java.lang.ref.Cleaner;

public class CleanerDemo {
    private static final Logger log = LoggerFactory.getLogger(CleanerDemo.class);
    public static final Cleaner cleaner = Cleaner.create();
    private static NameGenerator nameGenerator = new NameGenerator();

    public static void main(String... args) {
        printTotalMemory(log);
        int count = 0;
        for (int i = 0; i < 100_000; ++i) {
            genActor();
            count++;
            if (count % 1000 == 0) {
                printBusyMemory(log);
                System.gc();
            }
        }

        //filling memory with arrays of String to force GC to clean up Actor objects

        for (int i = 1; i <= 10_000; i++) {
            String[] s = new String[10_000];
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
            }
        }

    }

    private static Cleaner.Cleanable genActor() {
        Actor a = new Actor(nameGenerator.genName(), LocalDate.now());
        log.info("JVM created: {}", a.getName());
        Cleaner.Cleanable handle = cleaner.register(a, new ActorRunnable(a.getName(), log));
        return handle;
    }

    static class ActorRunnable implements Runnable {
        private final String actorName;
        private final Logger log;

        public ActorRunnable(String actorName, Logger log) {
            this.actorName = actorName;
            this.log = log;
        }

        @Override
        public void run() {
            log.info("GC Destroyed: {} ", actorName);
        }
    }
}

Listing 13-16Using a Cleaner Instance

因为我们想让你更容易浏览代码,因为所有这些源代码都是同一个项目的一部分,所以我们在这里使用一个类来模拟一个Actor而不是一个Singer——但是不用担心,实现是非常相似的。Cleaner实例有一个名为register(..)的方法,该方法被调用来注册当对象被清理时要执行的动作。要执行的动作被指定为一个Runnable实例,并且决定通过实现它来创建一个类,在这个例子中是ActorRunnable,这样我们可以将要销毁的对象的名称保存到一个字段中,而不需要实际保存对要销毁的对象的引用,否则在程序执行期间 GC 将不会使用Cleaner.Cleanable句柄,因为该对象看起来好像仍然有对它的引用。通过调用clean()方法,cleaner.register(..)方法返回一个类型为Cleaner.Cleanable的实例,该实例可用于显式执行操作。当不再使用对象时,从内存中删除该对象,JVM 就会调用这个方法。如果您运行前面的代码,打印的日志看起来将与清单 13-17 中的非常相似。

INFO  c.a.b.t.c.CleanerDemo - Total Program memory: 260 MB
INFO  c.a.b.t.c.CleanerDemo - Max Program memory: 4096 MB
INFO  c.a.b.t.c.CleanerDemo - JVM created: Nuyktryvtkewiwd
INFO  c.a.b.t.c.CleanerDemo - JVM created: Brqivlsbvmteihz
INFO  c.a.b.t.c.CleanerDemo - JVM created: Qzvopg ophjcyho
...
INFO  c.a.b.t.c.CleanerDemo - Occupied memory: 17 MB
INFO  c.a.b.t.c.CleanerDemo - JVM created: Jrliwbjadztvwdm
INFO  c.a.b.t.c.CleanerDemo - JVM created: Evdteelpzinfcfh
INFO  c.a.b.t.c.CleanerDemo - JVM created: Hozfatszogfvzfz
...
INFO  c.a.b.t.c.CleanerDemo - GC Destroyed: Giqojswtuqzs s
INFO  c.a.b.t.c.CleanerDemo - GC Destroyed: Lzdjorokvyzwdu
INFO  c.a.b.t.c.CleanerDemo - JVM created: Igmzjiypo ttkzw
INFO  c.a.b.t.c.CleanerDemo - JVM created: Ljmksqzhzzhuzwl
INFO  c.a.b.t.c.CleanerDemo - GC Destroyed: Fny tnsffvyuisp
INFO  c.a.b.t.c.CleanerDemo - GC Destroyed: Qzillviekynpkec
...

Listing 13-17Log Printed By an Execution Using a Cleaner Instance to Free Up Memory

因此获得了与使用finalize()相同的结果,但是没有实现一个不赞成使用的方法。

img/463938_2_En_13_Figc_HTML.jpg作为一个很好的实践,如果你正在使用 Java 9+编写你的应用,避免使用finalize(),因为这种方法很明显正在被移除。使用Cleaner,在升级您的应用正在使用的 Java 版本时,您可能会少一些麻烦。

防止 GC 删除对象

在前两节中,我们重点关注了适合垃圾收集的对象。在应用中,有些对象在程序运行时不应该被丢弃,因为它们是需要的。在我们的类中,只有在执行结束时才被丢弃的最明显的引用是静态字段,它们是最终的,所以不能被重新初始化。

private static final Logger log = LoggerFactory.getLogger(CleanerDemo.class);
public static final Cleaner cleaner = Cleaner.create();
private static NameGenerator nameGenerator = new NameGenerator();
private static final Random random = new Random();

然而,这些静态值的问题是它们占用了内存。如果您的应用使用一个大的Map<K,V>包含一个字典,而这个字典在应用启动时并不需要,该怎么办?要解决这个问题,进入Singleton设计模式。Singleton模式是对一个类的特定设计,确保该类在程序执行期间只被实例化一次。这是通过隐藏构造函数(将其声明为私有)并声明类类型的静态引用和返回它的静态方法来实现的。根据Singleton模式编写类的方法不止一种,但是清单 13-18 中描述了最常用的方法。

package com.apress.bgn.thirteen;
// some import statements omitted
import java.util.HashMap;
import java.util.Map;

public final class SingletonDictionary {
    private static final Logger log = LoggerFactory.getLogger(SingletonDictionary.class);
    private Map<String, String> dictionary = new HashMap<>();

    private static final SingletonDictionary instance = new SingletonDictionary();

    private SingletonDictionary() {
        // init dictionary
        log.info("Starting to create dictionary: {}", System.currentTimeMillis());
        final NameGenerator keyGen = new NameGenerator(20);
        final NameGenerator valGen = new NameGenerator(200);
        for (int i = 0; i < 100_000; ++i) {
            dictionary.put(keyGen.genName(), valGen.genName());
        }
        log.info("Done creating dictionary: {}", System.currentTimeMillis());
    }

    public synchronized static SingletonDictionary getInstance(){
        return instance;
    }
}

Listing 13-18SingletonDictionary Class

在前面的代码中,我们模拟了一个包含 100,000 个条目的字典,所有条目都是由修改后的NameGenerator类生成的。创建实例时,日志消息被打印在构造函数中,这一点非常明显。关于Singleton模式,你必须记住四件事:

  • 构造函数必须是私有的,因为它不应该在类外被调用

  • 该类必须包含对其类型的对象的静态引用,该对象可以通过调用私有构造函数就地初始化

  • 必须定义一个方法来检索这个实例,所以它必须是静态的

  • 检索静态实例的方法也必须是同步的,这样就不会有两个线程同时调用它并获得对实例的访问,因为单例模式的核心思想是在程序执行期间只允许类被实例化一次,并确保不允许并发访问,因为这可能会导致意外的行为。有多种方式来初始化和使用单例,请随意做自己的研究。

在单例类中,创建了一个对实例的静态引用,这个静态引用防止垃圾收集器在程序执行期间清理这个实例。这是因为静态引用是一个类变量,而类是最后被 GC 删除的,在程序执行的最后。为了测试这一点,我们将编写一个主类,声明一个Cleaner实例,并为SingletonDictionary实例注册一个Cleanable。main 方法将创建大量的String数组来填充内存,试图说服 GC 删除SingletonDictionary实例,我们甚至将自己对它的引用设置为null,如清单 13-19 所示。

package com.apress.bgn.thirteen;
// import statements omitted

public class SingletonDictionaryDemo {
    public static final Cleaner cleaner = Cleaner.create();
    private static final Logger log = LoggerFactory.getLogger(SingletonDictionaryDemo.class);

    public static void main(String... args) {
        log.info("Testing SingletonDictionary...");
        //filling memory with arrays of String to force GC
        for (int i = 1; i <= 10_000; i++) {
            String[] s = new String[10_000];
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
            }
        }
        SingletonDictionary singletonDictionary = SingletonDictionary.getInstance();

        cleaner.register(singletonDictionary, ()-> {
            log.info("Cleaned up the dictionary!");
        });
        // we delete the reference
        singletonDictionary = null;

        //filling memory with arrays of String to force GC
        for (int i = 1; i <= 10_000; i++) {
            String[] s = new String[10_000];
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
            }
        }
        log.info("DONE.");
    }
}

Listing 13-19SingletonDictionaryDemo Class

如果我们运行前面的代码并期望看到“清理了字典!”控制台里的信息,我们的期待是徒劳的。在程序结束之前,SingletonDictionary中的静态引用不允许 GC 接触该对象。我们在类SingletonDictionary中的静态引用也被称为强引用,因为它防止对象从内存中被丢弃。

使用弱引用

显然,如果有强引用,我们也应该能够使用弱引用,对于我们实际上想要清理的对象,对吗?没错。

在 Java 中,有三个类可以用来保存对一个对象的引用,该对象不会保护该对象免于垃圾收集。这对于太大的对象很有用,并且将它们保存在内存中效率很低。对于这种类型的对象,重新初始化所耗费的时间是值得的,因为将它们保存在内存中会降低应用的整体性能。

这三个类别是:

  • java.lang.ref.SoftReference<T>:这种类型的引用所引用的对象由垃圾收集器根据内存需求自行清除。软引用最常用于实现对内存敏感的缓存。

  • java.lang.ref.WeakReference<T>:由这种类型的引用所引用的对象并不妨碍它们的被引用对象被终结化、终结化,然后被回收。弱引用最常用于实现规范化映射。规范化映射指的是容器,弱引用可以保存在容器中,并且可以被其他对象访问,但是它们到容器的链接不会阻止它们被收集。

  • java.lang.ref.PhantomReference<T>:由这些类型的引用所引用的对象在收集器确定它们的引用对象可能被回收后被排队。幻像引用最常用于计划事后清理操作。

我们的SingletonDictionary包含一个Map<K,V>实际上是存储在内存中的大对象。这个映射可以包装在一个WeakReference中,因为弱引用最常用于实现规范化映射。我们可以写一些逻辑,当字典实例被访问时,如果它不存在,它应该被重新初始化。因为我们需要访问地图,所以除了将Map<K,V>包装成一个 WeakReference 之外,实现会有一些变化。清单 13-20 中描述了名为WeakDictionary,的新类。

package com.apress.bgn.thirteen.util;
// other import statements omitted
import java.lang.ref.WeakReference;

public class WeakDictionary {
    private static final Logger log = LoggerFactory.getLogger(WeakDictionary.class);
    private static WeakDictionary instance = new WeakDictionary();
    private static Cleaner cleaner;
    private WeakReference<Map<Integer, String>> dictionary;

    private WeakDictionary() {
        cleaner = Cleaner.create();
        dictionary = new WeakReference<>(initDictionary());
    }

    public synchronized String getExplanationFor(Integer key) {
        Map<Integer, String> dict = dictionary.get();
        if (dict == null) {
            dict = initDictionary();
            dictionary = new WeakReference<>(dict);
            return dict.get(key);
        } else {
            return dict.get(key);
        }
    }

    public WeakReference<Map<Integer, String>> getDictionary() {
        return dictionary;
    }

    public synchronized static WeakDictionary getInstance() {
        return instance;
    }

    private Map<Integer, String> initDictionary() {
        final Map<Integer, String> dict = new HashMap<>();
        log.info("Starting to create dictionary: {}", System.currentTimeMillis());
        final NameGenerator valGen = new NameGenerator(200);
        for (int i = 0; i < 100_000; ++i) {
            dict.put(i, valGen.genName());
        }
        log.info("Done creating dictionary: {}", System.currentTimeMillis());
        cleaner.register(dict, ()-> log.info("Cleaned up the dictionary!"));
        return dict;
    }
}

Listing 13-20WeakDictionary Class

getExplanationFor(..)用于访问地图并获取与某个键对应的值。然而,在此之前,我们必须检查Map<K,V>是否还在。这是通过在类型为WeakReference<Map<Integer, String>>的字典引用上调用get()方法来完成的。如果 GC 没有收集映射,则提取并返回密钥;否则,Map<K,V>被重新初始化,弱引用被重新创建。这里也使用了Cleaner实例,并为Map<K,V>注册了一个Cleanable,因此我们可以看到正在收集的地图。那么我们如何测试这个呢?与我们测试SingletonDictionary的方式类似。WeakDictionaryDemo类并没有那么大的不同。代码如清单 13-21 所示。

package com.apress.bgn.thirteen;

import com.apress.bgn.thirteen.util.WeakDictionary;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class WeakDictionaryDemo {
    private static final Logger log = LoggerFactory.getLogger(WeakDictionaryDemo.class);

    public static void main(String... args) {
        log.info("Testing WeakDictionaryDemo...");
        //filling memory with arrays of String to force GC
        for (int i = 1; i <= 10_000; i++) {
            String[] s = new String[10_000];
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
            }
        }
        WeakDictionary weakDictionary = WeakDictionary.getInstance();

        //filling memory with arrays of String to force GC
        for (int i = 1; i <= 10_000; i++) {
            String[] s = new String[10_000];
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
            }
        }
        log.info("Getting val for 3 =  {}", weakDictionary.getExplanationFor(3));
        log.info("DONE.");
    }
}

Listing 13-21WeakDictionaryDemo Class

在检索到WeakDictionary引用后,创建了许多String数组来强制 GC 从内存中删除地图。之后,我们尝试访问有问题的地图。有用吗?

INFO  c.a.b.t.WeakDictionaryDemo - Testing WeakDictionaryDemo...
INFO  c.a.b.t.u.WeakDictionary - Starting to create dictionary: 1629635325234
INFO  c.a.b.t.u.WeakDictionary - Done creating dictionary: 1629635325485
INFO  c.a.b.t.u.WeakDictionary - Cleaned up the dictionary!
INFO  c.a.b.t.u.WeakDictionary - Starting to create dictionary: 1629635337852
INFO  c.a.b.t.u.WeakDictionary - Done creating dictionary: 1629635338093
INFO  c.a.b.t.WeakDictionaryDemo - Getting val for 3 =  Lqcnaowqotkzlhckqepogpjdlgkjzenyzzoaunebjsc z nervebnbc yjjlmuqkjaemmbtjbqzstjsssrwubwvfeoqfynyisba zclhf   lep fdbsnm cagubzodfpkepblslpypjwsybmwgptyznuymzgcdhkfydtibkjwgojjalctkrloatluakwwzppledhzdi
INFO  c.a.b.t.WeakDictionaryDemo - DONE.

Listing 13-22WeakDictionaryDemo Log

前面的日志证明了这一点,不仅如此,我们还可以看到 GC 丢弃了 map,然后在需要时重新初始化。这就是软引用力量。

垃圾收集过程是不确定的,因为它不能从代码中得到很好的控制。Java 程序不能告诉它开始、暂停或停止,但是通过使用适当的 VM 选项,我们可以控制它所拥有的资源。使用正确的实现,从代码中我们可以告诉它收集什么或不收集什么,大多数时候这就足够了。 2

垃圾收集异常和原因

前面提到过,如果对象不能从内存中丢弃,将会抛出类型为OutOfMemoryError的异常。我不确定你是否注意到了,但是OutOfMemoryError实际上并没有扩展java.lang.Exception,所以称它为异常是错误的。第 5 中提到了异常类的层次结构。在那个层次结构中,有一个名为java.lang.Error的类,它实现了java.lang.Throwable,,它提到了当出现程序无法恢复的严重问题时,程序会抛出这些类型的对象。这里描述了java.lang.OutOfMemoryError的完整层级。

java.lang.Object
    java.lang.Throwable
        java.lang.Error
            java.lang.VirtualMachineError
                java.lang.OutOfMemoryError

实际上是那些你不希望在你的程序运行时抛出的丑陋的东西之一,因为这意味着你的程序实际上不再运行了。它没有运行的原因是因为它没有剩余的内存来存储正在创建的新对象。

当内存管理出错时,JVM 会抛出这个错误。尽管最常见的原因是堆内存耗尽,但还有其他原因。当分配给 JVM 的堆内存耗尽时,错误消息如下:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

但是您可能会看到另一条消息:

Exception in thread "main" java.lang.OutOfMemoryError: GC Overhead Limit Exceeded

这条消息仍然与堆大小有关。当程序的数据刚好适合堆的大小时,就会抛出这个错误,所以堆几乎满了,这允许 GC 运行,但是因为它不能赎回任何内存,所以 GC 一直运行,实际上阻碍了应用的正常执行。当 GC 花费 98%的执行时间而应用花费另外 2%的时间时,该消息被添加到错误中。

当 GC 由于某种原因无法正常工作时,这两个是您将会看到的最常见的错误消息。完整的列表可以在 https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/memleaks002.html 找到,但是由于大多数 GC 问题都与堆大小有关,G1GC 主要抛出 Java 堆空间消息的错误。

摘要

这一节是本书的结尾。谈到 Java 生态系统,互联网上有大量的书籍和教程。这本书只是触及了表面,给你作为 Java 开发人员的一个好的起点,整个团队都希望它能满足你的需求,并激发你的好奇心,以获得更多的资金。请记住,无论应用的范围如何,都没有万能的解决方案来确保内存始终得到正确的管理。如果您遇到麻烦,试验总是为您的 JVM 确定合适的收集器的一个步骤。

本章涵盖了以下主题:

  • 什么是垃圾收集以及涉及的步骤

  • 堆内存是如何构造的

  • Oracle HotSpot JVM 中有多少种垃圾收集器,我们如何在它们之间切换

  • 如何列出所有 GC 标志并将它们用作 VM 选项

  • 如何使用虚拟机选项查看垃圾收集器配置和统计信息

  • 如何使用 finalize 和Cleaner查看正在进行的垃圾收集

  • 如何阻止垃圾收集器收集重要对象

  • 如何使用软引用创建易于收集的对象

Footnotes 1

此 VM 选项取代了不推荐使用的-XX:+printgdetails。

  2

如果您想了解更多关于 GC 的细节,请参阅 Oracle,“G1 垃圾收集器入门”, https://www.oracle.com/technetwork/tutorials/tutorials-1876574.html ,访问日期:2021 年 10 月 15 日。

 

******