如何在Java程序中使用泛型

如何在Java程序中使用泛型

泛型可以使你的代码更灵活、更易读,并能帮助你在运行时避免ClassCastExceptions。让我们通过这篇结合Java集合框架的泛型入门指南,开启你的泛型之旅。

Java 5引入的泛型增强了代码的类型安全性并提升了可读性。它能帮助你避免诸如ClassCastException(当尝试将对象强制转换为不兼容类型时引发的异常)这类运行时错误。

本教程将解析泛型概念,通过三个结合Java集合框架的实例演示其应用。同时我们将介绍原始类型(raw types),探讨选择使用原始类型而非泛型的场景及其潜在风险。

Java编程中的泛型

  • 为何使用泛型?
  • 如何利用泛型保障类型安全
  • Java集合框架中的泛型应用
  • Java泛型类型示例
  • 原始类型与泛型对比

为何使用泛型?

泛型在Java集合框架中被广泛用于java.util.List、java.util.Set和java.util.Map等接口。它们也存在于Java其他领域,如java.lang.Class、java.lang.Comparable 和java.lang.ThreadLocal。

在泛型出现前,Java代码常缺乏类型安全保障。以下是非泛型时代Java代码的典型示例:

List integerList = new ArrayList();
integerList.add(1);
integerList.add(2);
integerList.add(3);

for (Object element : integerList) {
    Integer num = (Integer) element; // 必须显式类型转换
    System.out.println(num);
}

这段代码意图存储Integer对象,但没有任何机制阻止你添加其他类型(如字符串):

integerList.add("Hello");

当尝试将String强制转换为Integer时,这段代码会在运行时抛出ClassCastException。

利用泛型保障类型安全

为解决上述问题并避免ClassCastExceptions,我们可以使用泛型指定列表允许存储的对象类型。此时无需手动类型转换,代码更安全且更易理解:

List<Integer> integerList = new ArrayList<>();

integerList.add(1);
integerList.add(2);
integerList.add(3);

for (Integer num : integerList) {
    System.out.println(num);
}

List表示"存储Integer对象的列表"。基于此声明,编译器确保只有Integer对象能被添加至列表,既消除了类型转换需求,也预防了类型错误。

Java集合框架中的泛型

泛型深度集成于Java集合框架,提供编译时类型检查并消除显式类型转换需求。当使用带泛型的集合时,你需指定集合可容纳的元素类型。Java编译器基于此规范确保你不会意外插入不兼容对象,从而减少错误并提升代码可读性。

为演示泛型在Java集合框架中的使用,让我们观察几个实例。

List和ArrayList的泛型应用

前例已简要展示ArrayList的基本用法。现在让我们通过List接口的声明深入理解这一概念:

public interface List<E> extends SequencedCollection<E> { … }

此处声明泛型变量为"E",该变量可被任何对象类型替代。注意变量E代表元素(Element)。

接下来演示如何用具体类型替换变量。下例中将替换为

List<String> list = new ArrayList<>();
list.add("Java");
list.add("Challengers");
// list.add(1); // 此行会导致编译时错误

List声明该列表仅能存储String对象。如代码最后一行所示,尝试添加Integer将引发编译错误。

Set和HashSet的泛型应用

Set接口与List类似:

public interface Set<E> extends Collection<E> { … }

我们将用替换,使集合只能存储Double值:

Set<Double> doubles = new HashSet<>();
doubles.add(1.5);
doubles.add(2.5);
// doubles.add("three"); // 编译时错误

double sum = 0.0;
for (double d : doubles) {
    sum += d;
}

Set确保只有Double值能被添加至集合,防止因错误类型转换引发的运行时错误。

Map和HashMap的泛型应用

我们可以声明任意数量的泛型类型。以键值数据结构Map为例,K代表键(Key),V代表值(Value):

public interface Map<K, V> { … }

现在用String替换K作为键类型,用Integer替换V作为值类型:

Map<String, Integer> map = new HashMap<>();
map.put("Duke", 30);
map.put("Juggy", 25);
// map.put(1, 100); // 此行会导致编译时错误

此例展示将String键映射到Integer值的HashMap。添加Integer类型的键将不被允许并导致编译错误。

泛型命名规范

我们可以在任何类中声明泛型类型。虽然可以使用任意名称,但建议遵循命名规范:

  • E 代表元素(Element)
  • K 代表键(Key)
  • V 代表值(Value)
  • T 代表类型(Type)

应避免使用无意义的"X"、"Y"或"Z"等名称。

Java泛型类型使用示例

现在通过更多示例深入演示Java中泛型类型的声明与使用。

创建通用对象容器

我们可以在自定义类中声明泛型类型,不必局限于集合类型。下例中,Box类通过声明泛型类型E来操作任意元素类型。注意泛型类型E声明于类名之后,随后即可作为属性、构造器、方法参数和返回类型使用:

// 定义带泛型参数E的Box类
public class Box<E> {
    private E content; // 存储E类型对象

    public Box(E content) { this.content = content; }
    public E getContent() { return content; }
    public void setContent(E content) { 
        this.content = content;
    }

    public static void main(String[] args) {
        // 创建存储Integer的Box
        Box<Integer> integerBox = new Box<>(123);
        System.out.println("整数盒内容:" + integerBox.getContent());

        // 创建存储String的Box
        Box<String> stringBox = new Box<>("Hello World");
        stringBox.setContent("Java Challengers");
        System.out.println("字符串盒内容:" + stringBox.getContent());
    }
}

输出结果:

整数盒内容:123
字符串盒内容:Java Challengers

代码要点:

  • Box类使用类型参数E作为容器存储对象的占位符,允许Box处理任意对象类型
  • 构造器初始化Box实例时接受指定类型对象,确保类型安全
  • getContent返回与实例创建时指定的泛型类型匹配的对象,无需类型转换
  • setContent通过类型参数E确保只能设置正确类型的对象
  • main方法创建了存储Integer和String的Box实例
  • 每个Box实例操作特定数据类型,展现泛型在类型安全方面的优势

此例展示了Java泛型的基础实现,演示了如何以类型安全方式创建和操作任意类型对象。

处理多数据类型

我们可以声明多个泛型类型。以下Pair类包含<K, V>泛型值。如需更多泛型参数,可扩展为<K, V, V1, V2, V3>等,代码仍可正常编译。

Pair类示例:

class Pair<K, V> {
    private K key;
    private V value;

    public Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }

    public K getKey() { return key; }
    public V getValue() { return value; }

    public void setKey(K key) { this.key = key; }
    public void setValue(V value) { this.value = value; }
}

public class GenericsDemo {
    public static void main(String[] args) {
        Pair<String, Integer> person = new Pair<>("Duke", 30);

        System.out.println("姓名:" + person.getKey());
        System.out.println("年龄:" + person.getValue());

        person.setValue(31);
        System.out.println("更新后年龄:" + person.getValue());
    }
}

输出结果:

姓名:Duke
年龄:30
更新后年龄:31

代码要点:

  • Pair<K, V>类包含两个类型参数,适用于任意数据类型组合
  • 构造器与方法使用类型参数实现严格类型检查
  • 创建存储String(姓名)和Integer(年龄)的Pair对象
  • 访问器和修改器方法操作Pair数据
  • Pair类可存储管理关联信息而不受特定类型限制,展现泛型的灵活性与强大功能

此例展示泛型如何创建支持多数据类型的可复用类型安全组件,提升代码复用性和可维护性。

让我们再看一个示例。

方法级泛型声明

泛型类型可直接在方法中声明,无需在类级别定义。若某个泛型类型仅用于特定方法,可在方法签名返回类型前声明:

public class GenericMethodDemo {

    // 声明泛型类型<T>并打印指定类型数组
    public static <T> void printArray(T[] array) {
        for (T element : array) {
            System.out.print(element + " ");
        }
        System.out.println();
    }

    public static void main(String[] args) {
        Integer[] intArray = {1, 2, 3, 4};
        printArray(intArray);

        String[] stringArray = {"Java", "Challengers"};
        printArray(stringArray);
    }
}

输出结果:

1 2 3 4
Java Challengers

原始类型与泛型对比

原始类型指未指定类型参数的泛型类或接口名称。在Java 5引入泛型前,原始类型被广泛使用。现今开发者通常仅在与遗留代码兼容或与非泛型API交互时使用原始类型。即使使用泛型,仍需了解如何识别和处理原始类型。

典型原始类型示例——未指定类型参数的List声明:

 List rawList = new ArrayList();

此处List rawList声明了一个未指定泛型参数的列表。rawList可存储任意类型对象(Integer、String、Double等)。由于未指定类型,编译器不会对添加至列表的对象类型进行检查。

使用原始类型的编译警告

Java编译器会对原始类型使用发出警告,提醒开发者可能存在的类型安全隐患。当使用泛型时,编译器会检查集合(如List、Set)中存储的对象类型、方法返回类型和参数是否匹配声明类型,从而预防如ClassCastException的常见错误。

使用原始类型时,由于未指定存储对象类型,编译器无法进行类型检查,因此会发出警告提示你绕过了泛型提供的类型安全机制。

编译警告示例

以下代码演示编译器如何对原始类型发出警告:

List list = new ArrayList(); // 警告:原始使用参数化类'List'
list.add("hello");
list.add(1);

编译时通常会显示:

注意:SomeFile.java使用了未经检查或不安全的操作。
注意:使用-Xlint:unchecked重新编译以获取详细信息。

使用-Xlint:unchecked参数编译将显示更详细警告:

warning: [unchecked] unchecked call to add(E) as a member of the raw type List
    list.add("hello");
            ^
  where E is a type-variable:
    E extends Object declared in interface List

若确信使用原始类型不会引入风险,或处理无法重构的遗留代码,可使用@SuppressWarnings("unchecked")注解抑制警告。但需谨慎使用,避免掩盖真实问题。

使用原始类型的后果

尽管原始类型有助于向后兼容,但存在两大缺陷:类型安全性缺失和维护成本增加。

  • 类型安全性缺失:泛型的核心优势是类型安全,使用原始类型将丧失这一优势。编译器不进行类型正确性检查,可能导致运行时ClassCastException。
  • 维护成本增加:使用原始类型的代码缺乏泛型提供的明确类型信息,维护难度加大,易产生仅在运行时暴露的错误。

类型安全问题示例:使用原始类型List而非泛型List时,编译器允许添加任意类型对象。当从列表检索元素并尝试强制转换为String时,若实际为其他类型将导致运行时错误。

泛型知识要点回顾

泛型以高度灵活性提供类型安全保障。以下回顾关键要点:

泛型是什么?为何使用?

  • code.Java 5引入泛型以提升代码类型安全性和灵活性
  • 主要优势在于帮助避免ClassCastException等运行时错误
  • 泛型广泛应用于Java集合框架,也见于Class、Comparable、ThreadLocal等组件
  • 通过阻止不兼容类型插入实现类型安全

Java集合中的泛型

  • List和ArrayList:List允许指定元素类型E,确保列表类型专一
  • Set和HashSet:Set限定元素为类型E,保持一致性
  • Map和HashMap:Map<K,V>定义键值类型,提升类型安全性和代码清晰度

泛型使用优势

  • 通过阻止不兼容类型插入减少错误
  • 明确类型关联提升代码可读性和可维护性
  • 便于以类型安全方式创建和管理集合等数据结构

AI时代的非人类身份安全

AI时代的非人类身份安全

随着AI在企业中的崛起,攻击面也在不断扩展。了解如何保护非人类身份(Non-Human Identities, NHIs)并防止未经授权的访问。

AI时代的非人类身份安全


非人类身份(NHIs)近期成为焦点并非偶然——随着AI工具和自主代理的快速普及,企业的NHI数量正呈爆炸式增长。这一趋势也引发了关于机器身份与治理的大量研究和讨论。

与系统的普通用户类似,NHI(如AI代理、机器人、脚本和云工作负载)通过密钥(secrets)进行操作。这些凭证赋予其访问敏感系统和数据的权限,可能以多种形式存在,且必须从创建到销毁全程受控。然而,机器无法使用多因素认证或通行密钥(passkeys),而开发者在部署应用时可能生成数百个此类凭证。


AI加速NHI的扩张与风险

企业AI的采用速度惊人,迫使开发者以前所未有的速度推出NHI。AI虽能提升效率,但也带来隐私泄露、密钥暴露和不安全代码等风险。大型语言模型(LLMs)的应用场景令人兴奋,但需谨记:技术引入越多,攻击面越大——尤其是当AI代理被赋予自主权时。


AI代理带来的NHI风险

1. AI代理与密钥泛滥(Secrets Sprawl)

“AI代理”是基于LLM的系统,可自主决策如何完成任务。它们不同于传统的确定性机器人(仅按开发者预设的步骤执行),而是能访问内部数据源、搜索互联网,并代表用户与其他应用交互。

例如,一个AI采购代理可以分析需求、通过电商平台比价、与AI聊天机器人议价,甚至自主下单。每个安全通信都需要凭证,而这类代理需通过DevOps流程部署,导致更多跨环节的身份验证需求。密钥往往在系统、日志和仓库中意外散落。

企业常为AI代理赋予比传统机器人更广泛的读写、甚至创建和删除权限。由于AI代理的自主性,若权限限制过严,其任务可能受阻;但宽松权限又易导致过度授权。

风险点:任一密钥泄露都可能引发数据泄露或未经授权的交易。需通过最小权限访问、API密钥保护和审计日志来强化NHI治理,并关注密钥存储之外的暴露风险。

2. 孤立的API密钥(Orphaned API Keys)

孤立API密钥指不再与用户账户关联的密钥(如员工离职后未被删除的密钥)。在NHI场景中,密钥的“归属权”模糊(开发者?运维团队?),导致其极易被遗忘却仍有效。

关键问题:谁应对这些密钥引发的安全漏洞负责?

3. 基于提示的架构与敏感数据暴露

AI助手(如ChatGPT、Gemini、GitHub Copilot)依赖提示(prompt)架构,通过上下文、命令和数据与LLM交互。这种模式虽简化了开发,但也可能导致敏感信息(如API密钥)被写入提示或日志。

案例:财务团队用AI聊天机器人处理发票时,若提示中包含API key ABC123,该密钥可能被明文记录。若日志未加密,攻击者可借此入侵发票系统。

防护措施:需阻止开发者及用户将敏感数据嵌入提示或日志,并扫描LLM输出中的异常信息。

4. AI代理与数据收集风险

AI代理常从以下来源收集数据:

  • 云存储(如AWS S3、Google Drive)
  • 企业应用(如Jira、Confluence、Salesforce)
  • 通信系统(如Slack、Microsoft Teams)

风险:若AI代理可访问这些系统中的任何数据,攻击者亦可滥用其NHI权限。需定期轮换所有内部系统的密钥,并清理日志。

5. AI生成代码与嵌入式密钥

GitHub Copilot、Amazon CodeWhisperer等AI编码工具已被超50%的开发者使用。然而,AI生成的代码可能诱导开发者硬编码密钥(如API密钥、数据库凭证)。

案例:开发者要求Copilot生成调用云服务的代码时,可能得到:

import requests  
API_KEY = "sk_live_ABC123XYZ"  
response = requests.get("https://api.example.com/data", headers={"Authorization": f"Bearer {API_KEY}"})  

若匆忙中替换为真实密钥并提交至代码仓库,凭证可能被泄露。

防护:通过预提交钩子(pre-commit hooks)等工具扫描代码,防止密钥泄露。


未来方向:如何保护非人类身份

  1. 发现密钥:自动识别企业环境中的所有AI代理凭证(包括存储库内外)。
  2. 评估风险:明确密钥的用途、访问范围及关联的关键系统。
  3. 动态防护:实时监控提示和日志,防止敏感数据嵌入。

让NHI治理跟上AI速度

AI代理的部署速度与复杂性并存,既带来效率提升,也伴随风险。随着AI普及,保护机器身份已非可选,而是必需。唯有通过系统化的密钥管理、权限控制和持续监测,才能在AI时代实现安全与创新的平衡。


【注】本文译自:
Non-Human Identity Security in the Age of AI

Java Stream API:每个开发者都应该知道的 3 件事

Java Stream API

Java Stream API:每个开发者都应该知道的 3 件事

Java Stream API 通过惰性求值并行处理函数式编程简化了集合处理。使用它可以编写更简洁、高效和可扩展的代码。

时间飞逝!我记得 Java 8 曾经是一个标杆,每个人都把它当作一种全新且革命性的东西来谈论。老实说,它确实是全新且革命性的。但现在,使用 Java 8 的项目可能被称为“遗留”项目。即使 Java 8 本身已经成为遗留版本,它引入的特性仍然具有实际意义。今天,我们来聊聊其中一个特性——Stream API

如果你还不了解,Java Stream API 是一个强大的工具,它允许程序员以函数式编程风格编写 Java 代码。它通过支持过滤、转换和聚合操作,使得集合的处理更加简单。

尽管 Stream API 被广泛使用,但我仍然发现许多开发者对其深层知识的掌握存在不足。在本文中,我将探讨 Stream API 的三个关键方面,这些方面对于深入理解它至关重要:

  1. 惰性求值:帮助我们优化操作链的执行。
  2. 并行流:通过利用多核处理器,深入探讨如何增加数据处理的并行性。
  3. Lambda 变量作用域:了解在使用 Stream 时如何正确地将变量传递给 Lambda。

我希望通过本文,你能更好地理解这些概念。


1. Java Stream API 中的惰性求值

惰性求值是理解如何有效使用流的核心概念。但在深入探讨惰性求值之前,我们先来了解流管道中的两种主要操作类型:中间操作终端操作

  1. 中间操作:中间操作是将输入流转换为另一个流的操作,但不会产生不可变的结果。例如,filter()map()flatMap() 都是中间操作,因为它们接受一个输入流并返回另一个流。这些操作不会立即消耗输入流中的所有元素,而是创建一个包含所需元素的新流。

  2. 终端操作:终端操作是消耗流中元素的操作,它们要么返回一个结果,要么通过副作用修改某些状态。例如,forEach()findFirst()collect() 都是终端操作,因为它们最终会消耗流中的所有元素以产生结果。

什么是惰性求值?它是如何工作的?

在 Stream API 中,惰性求值意味着中间操作不会立即执行,直到我们调用一个终端操作。这意味着我们可以在代码的任何地方定义一个流及其所有操作,但只有在调用终端操作时才会执行。

当我们调用终端操作时,流会逐个处理数据元素,依次应用所有中间操作。这种方法通过避免不必要的计算来优化性能。

让我们通过一个实际例子来看看惰性求值如何影响执行:

import java.util.stream.Stream;

public class LazyEvaluationExample {
    public static void main(String[] args) {
        Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5)
            .filter(num -> {
                System.out.println("Filtering: " + num);
                return num % 2 == 0;
            })
            .map(num -> {
                System.out.println("Mapping: " + num);
                return num * 2;
            });

        System.out.println("Stream pipeline defined, no execution yet.");

        // 终端操作触发执行
        stream.forEach(System.out::println);
    }
}

输出:

Stream pipeline defined, no execution yet.
Filtering: 1
Filtering: 2
Mapping: 2
4
Filtering: 3
Filtering: 4
Mapping: 4
8
Filtering: 5

让我们试着理解为什么会有这样的输出。我们可以看到,filter()map() 操作是惰性的。这意味着即使我们编写了这些代码,它们也不会在调用终端操作 forEach() 之前执行。这解释了为什么我们首先看到输出 Stream pipeline defined, no execution yet

只有当调用 forEach() 终端操作时,流才会开始逐个处理元素。


2. 并行流

Java Stream API 最有用和最强大的功能之一是对并行流的支持。并行性是指通过利用多个 CPU 核心同时处理两个或多个操作的能力。在 Stream API 中,这意味着我们可以同时处理流中的多个元素的中间或终端操作。

这种功能可以显著提高计算密集型任务的性能,但为了更好地理解它以达到最佳效果,我们需要深入了解它。

什么是并行流?

并行流是一种将其元素分成多个块,然后通过不同线程并行处理的流。与普通流(逐个处理元素)不同,并行流在底层使用 ForkJoinPool 来实现并行性。

创建并行流非常简单。你可以使用以下两种方法之一:

  • 对于现有集合,可以使用 parallelStream() 方法。
  • 你可以通过在现有流上调用 parallel() 方法来使其并行。

何时使用并行流?

并行流可以在特定场景中提升性能,但它们并不总是最佳选择。以下是一些关键考虑因素:

  1. 适合的场景

    • 大数据集:当处理大量数据时,并行性效果最好。
    • CPU 密集型任务:对于大量使用 CPU 的计算任务(如数学运算或数据转换),并行流是理想选择。
  2. 避免使用并行流的场景

    • IO 密集型任务:如果你的任务涉及大量读写操作(如磁盘/网络操作),并行流可能不是最佳选择。
    • 小数据集:你需要记住,Java 虚拟机在底层仍然需要管理线程切换等操作。因此,在处理小数据集时,管理线程的开销可能会超过性能提升。

性能对比代码的解释

下面的代码将帮助我们通过求和操作来比较 Java 中顺序流和并行流的性能。我们将运行两个测试:

  • 第一个测试:范围从 1 到 1,000,000。
  • 第二个测试:范围从 1 到 100,000,000。

我们的主要目标是比较顺序流和并行流的处理时间,从而帮助我们理解使用并行流的优缺点。

int rangeLimit = 1_000_000;

long start = System.currentTimeMillis();
LongStream.rangeClosed(1, rangeLimit)
    .reduce(0L, Long::sum);
long end = System.currentTimeMillis();

System.out.println("Sequential Stream Time: " + (end - start) + " ms");

start = System.currentTimeMillis();
LongStream.rangeClosed(1, rangeLimit)
    .parallel()
    .reduce(0L, Long::sum);
end = System.currentTimeMillis();

System.out.println("Parallel Stream Time: " + (end - start) + " ms");

首先,我们创建了两个流:顺序流和并行流。并行流是通过在现有流上调用 .parallel() 方法创建的。两者都包含从 1 到 1,000,000 的数字,使用 LongStream.rangeClosed() 方法生成。

其次,我们对两个流执行了 .reduce(0L, Long::sum) 方法,该方法对输入流中的所有元素求和。由于 reduce 是一个终端操作,流会在调用该方法时立即开始处理。

我们能够测量此操作所花费的时间。这些信息通过 System.currentTimeMillis() 命令记录并存储在变量 startend 中。结果以毫秒为单位打印出来。

让我们执行代码两次,更新 rangeLimit 变量。第一次执行时,将其设置为 1,000,000,如代码所示。第二次执行时,将其设置为 100,000,000。

对于范围从 1 到 1,000,000:

Sequential Stream Time: 9 ms
Parallel Stream Time: 12 ms

我们可以看到,在这种情况下,并行流比顺序流稍慢。这是一个很好的例子,展示了对于像我们示例中使用的小数据集,管理多个线程可能会导致性能损失。

接下来,我们将范围增加到 100,000,000,结果如下:

Sequential Stream Time: 57 ms
Parallel Stream Time: 12 ms

最终,我们可以看到并行流的优势。在这里,并行流明显优于顺序流。较大的数据集能够通过利用多个 CPU 核心来加速计算过程。

重要注意事项:处理大数据集

我们需要记住一点:Java 中的 Long 类型的最大值是 2^63-1。因此,在我们的示例中,当我们测试较大的范围时,求和结果可能会超过此限制,从而导致不正确的结果。

由于本示例的主要目的是展示并行流的行为并比较效率,我们可以忽略结果可能不正确的事实。如果需要精确求和,你可能需要使用更大范围的类型,例如 BigInteger


3. Lambda 中的变量作用域

让我们简单讨论一下 Lambda。Lambda 表达式在 Stream API 中被广泛使用。老实说,我认为有很多开发者只在流中使用 Lambda。因此,我认为在本文中讨论一些与 Lambda 相关的点也是合理的。

我们应该意识到,Lambda 与变量的交互方式有其特殊性,作为 Java 开发者,理解 Lambda 如何捕获和使用变量至关重要。

让我们探讨一下变量作用域在 Lambda 表达式中是如何工作的,以及它与传统方法的区别。

在 Lambda 中捕获变量

假设你在 Lambda 的外部作用域中初始化了一个变量,并计划在 Lambda 函数中使用这个变量。你能这样做吗?

这取决于情况。我们只能使用从外部作用域捕获的变量,前提是它们是 final有效 final 的。那么,“有效 final”是什么意思呢?

简而言之,如果一个变量在初始化后其值从未改变,则它被认为是有效 final 的。因此,要在 Lambda 中使用变量,你有两种方法:

  1. 像往常一样初始化变量,并确保其值在初始化后不会改变。
  2. 在初始化时通过添加 final 关键字使变量成为 final
int factor = 2;  

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

numbers.stream()
    .map(n -> n * factor)  
    .forEach(System.out::println);

在上面的示例中,我们可以看到 factor 变量是有效 final 的,因为我们在初始化后没有更新它。这意味着这个变量可以在我们的 Lambda 中使用。你可以尝试在初始化后重新赋值 factor,看看会发生什么。


结论

Stream API 是一套强大且易于理解的工具,用于处理元素序列。如果正确使用,它可以帮助减少大量不必要的代码,使程序更具可读性,并提高应用程序的性能。但正如我所提到的,正确使用它以从性能和代码简洁性方面获得最佳结果至关重要。


【注】本文译自:Java Stream API: 3 Things Every Developer Should Know About

评估您的数据是否可用于人工智能的三个考虑因素

评估您的数据是否可用于人工智能的三个考虑因素

​ 多数组织正在人工智能和生成性人工智能的炒作中迷失方向。在许多情况下,他们并没有准备好人工智能项目所需的数据基础。三分之一的高管认为,只有不到50%的组织有了人工智能所需的数据,而多数组织并未准备好。因此,在开展人工智能项目之前,奠定正确的基础至关重要。在评估准备情况时,主要考虑因素如下:

  • 可用性:您的数据在哪里?
  • 类目:您将如何记录和协调您的数据?
  • 质量:优质数据是人工智能项目成功的关键。

​ 人工智能存在“垃圾进,垃圾出”的问题:如果您输入的数据质量差、不准确或无关紧要,那么输出也会如此。这些项目涉及的工作量和费用都非常高,风险也很大,因此从错误的数据开始是不可取的。

数据对人工智能的重要性

​ 数据是人工智能的基本要素;它是基于数据进行训练的,然后为特定目的处理数据。当您计划使用人工智能解决问题时——即使是使用现有的大型语言模型,如ChatGPT这样的生成性人工智能工具——您也需要为其提供业务的正确上下文(即优质数据),以便根据您的业务上下文定制答案(例如,用于检索增强生成)。而并不只是简单地将数据塞到模型中。

​ 如果您正在构建新模型,您必须知道将使用什么数据进行训练和验证。这些数据需要进行分离,以便您可以在一个数据集上进行训练,然后在不同的数据集上进行验证,来确定模型是否有效。

建立正确数据基础的挑战

​ 对于许多公司来说,知道数据在哪里以及数据的可用性是第一项重大挑战。如果您对自己的数据有一定的了解——数据的存在情况、数据所在的系统、数据的规则等——这已经是一个良好的起点。然而,事实是,许多公司并没有达到这种理解水平。

​ 数据并不总是随时可用;它可能分散在许多系统和信息孤岛中。尤其是大型公司,往往拥有非常复杂的数据环境。他们没有一个单一的、经过整理的数据库,所有模型所需的数据都整齐地组织在行和列中,可以直接检索和使用。

​ 另一个挑战是数据不仅存在于许多不同的系统中,而且格式各异。存在SQL数据库、NoSQL数据库、图数据库、数据湖,有时数据只能通过专有应用程序API访问。还有结构化数据和非结构化数据。一些数据存放在文件中,可能还有一些来自工厂传感器的实时数据,等等。根据您所在的行业,数据可能来自不同系统和格式的众多来源。协调这些数据是困难的;大多数组织没有相应的工具或系统来统一维护。

​ 即使您能够找到数据并将其转换为业务理解的统一格式(规范模型),您还需要考虑数据质量。数据是杂乱的;粗略看似乎没有问题,但仔细观察时,数据中会出现错误和重复,因为您是从多个系统中获取数据,不一致是不可避免的。您不能用低质量的训练数据来训练人工智能模型,然后期待高质量的结果。

如何奠定正确的基础:成功的三个步骤

​ 人工智能项目基础的第一块砖是了解您的数据。您必须能够清晰地表达业务正在捕获什么数据,这些数据存放在哪些系统中,数据的物理实现与业务的逻辑定义有何不同,以及业务规则是什么……

​ 接下来,您必须能够评估您的数据。就是要问:“对我的业务来说,什么是优质数据?”您需要定义优质数据的标准,并制定验证和清洗数据的规则,以及维护数据质量的策略。

​ 如果您能够从异构系统中获取数据并将其转换为规范模型,并对其进行整理以提高质量,您仍然需要关注可扩展性。这是第三个基础步骤。许多模型需要大量数据进行训练;您还需要大量数据用于检索增强生成,这是提高生成性人工智能模型性能的一种技术,它使用未包含在训练模型中的外部信息。所有这些数据都是不断变化和发展的。

​ 您需要一种方法来创建合适的数据管道,以适应您可能输入的数据的负载和体积。最初,您可能会被弄得不知所措,忙于寻找数据来源、清洗数据等,以至于没有充分考虑到对于不断演变的数据进行扩展将面临的挑战。因此,您必须考虑使用哪个平台来构建该项目,以便该平台能够扩展到您将引入的数据量。

为可信数据创造环境

​ 在进行人工智能项目时,将数据视为事后考虑因素必然会导致糟糕的商业结果。任何认真对待通过开发和使用人工智能来建立和维持商业优势的人都必须首先关注数据。主要问题在于:整理和准备用于商业目的数据具有相当的复杂性和挑战性,首当其冲的是时间因素。也就是说不给您范错的时间;最起码您要有一个帮助您维护高质量数据的平台和方法。了解和评估您的数据,然后规划可扩展性,您就会朝着更好的商业结果迈出一步。


【注】本文译自:https://sdtimes.com/ai/three-considerations-to-assess-your-datas-readiness-for-ai

使用Lambda表达式和接口的简单Java 8 Predicate示例

大量的Java编程涉及到对真或假值的评估,从条件语句到迭代循环。当您使用JDK的Streams API和Lambda函数时,可以使用备受欢迎的Java Predicate接口来简化布尔条件的评估。

也被称为Java 8 Predicate(源自引入函数式编程的JDK版本),这个简单的接口定义了五个方法,尽管只有Java Predicate的test方法在Stream或Lambda调用中被评估。

img

图1:Java 8 Predicate接口的五个方法的JavaDoc列表

传统的Java 8 Predicate示例:

尽管Java 8的Predicate是一个函数式接口,但开发人员仍可以以传统方式使用它。下面是一个Java Predicate示例,它简单地创建了一个扩展Predicate接口的新类,并在其主方法中使用Predicate的单独类:

import java.util.function.*;
public class Java8PredicateTutorial {  
  public static void main(String args[]) {
    PredicateExample example = new PredicateExample();
    System.out.printf("Gretzky's number is even: %s", example.test(99));
    boolean value = example.test(66);
    System.out.printf("nLemieux's number is even: %s ", value);  
  }   
}
class PredicateExample implements Predicate<Integer> {
  public boolean test(Integer x) {
    if (x%2==0){
      return true;
    } else {
    return false;
    }
  }
}

img

图2:如何编译和运行Java 8 Predicate示例

图2展示了编译和执行这个Predicate接口教程时的结果。

Java Predicate作为内部类:

如果您是一个喜欢内部类的开发人员,您可以对此进行一些简化,减少示例的冗长性。然而,这个Java 8 Predicate示例并不完全符合函数式编程的要求。

import java.util.function.*;
public class Java8PredicateTutorial {
  public static void main(String args[]) {
    Predicate predicateExample = new Predicate<Integer>() {
    public boolean test(Integer x) {
        return (x % 2 == 0);
        }
    };
    System.out.printf("Gretzky's number is even: %s", predicateExample.test(99));
    System.out.printf("nLemieux's number is even: %s ", predicateExample.test(66));
    }
}

Java Predicate lambda 示例

当然,如果您正在学习Java 8的Predicate接口,您很可能对如何在Lambda函数中使用它感兴趣。

Lambda表达式的目标是减少Java代码的冗长性,特别是在需要覆盖只有一个功能方法的接口的情况下。以下是使用Lambda表达式创建Java Predicate的代码示例:

Predicate<Integer> lambdaPredicate = (Integer x) -> (x % 2 == 0);

与传统的接口创建方法相比,毋庸置疑,Lambda表达式更加简洁。

以下是完整的使用Lambda表达式实现的Java Predicate示例:

import java.util.function.*;
public class Java8PredicateTutorial {
  public static void main(String args[]) {             
    /* Java predicate lambda example */
    Predicate<Integer> lambdaPredicate = (Integer x) -> (x % 2 == 0);             
    System.out.printf("Gretzky's number is even: %s", lambdaPredicate.test(99));
    System.out.printf("nLemieux's number is even: %s ", lambdaPredicate.test(66));
  }   
}

Java Predicate 和 lambda 流

自从JDK 8发布以来,函数式表达式已经在Java API中广泛应用。Streams API广泛使用Lambda表达式和Java Predicate,其中过滤表达式(filter expression)就是其中之一。下面是一个使用Lambda表达式、Stream和Predicate的示例,从一个Integer对象的列表中提取出所有的偶数:

import java.util.function.*;
import java.util.*;
import java.util.stream.*;
public class LambdaPredicateStreamExample {    
  public static void main(String args[]) {          
    List<Integer> jerseys = Arrays.asList(99, 66, 88, 16);
    /* Java predicate and lambda stream example usage */
    List<Integer> evenNumbers =
          jerseys.stream()
              .filter( x -> ((x%2)==0))
                  .collect(Collectors.toList());          
    /* The following line prints: [66, 88, 16] 8 */
    System.out.println(evenNumbers);
  }
}

正如您所看到的,Java的Lambda函数、流(Streams)和Predicate接口的组合使用可以创建非常紧凑的代码,既强大又易于阅读。


【注】本文译自:
Simple Java 8 Predicate example with lambda expressions and interfaces

Java开发中不要使用受检异常

简介

Types of Java Exception

Java是唯一(主流)实现了受检异常概念的编程语言。一开始,受检异常就是争议的焦点。在当时被视为一种创新概念(Java于1996年推出),如今却被视不良实践。

本文要讨论Java中非受检异常和受检异常的动机以及它们优缺点。与大多数关注这个主题的人不同,我希望提供一个平衡的观点,而不仅仅是对受检异常概念的批评。

我们先深入探讨Java中受检异常和非受检异常的动机。Java之父詹姆斯·高斯林对这个话题有何看法?接下来,我们要看一下Java中异常的工作原理以及受检异常存在的问题。我们还将讨论在何时应该使用哪种类型的异常。最后,我们将提供一些常见的解决方法,例如使用Lombok的@SneakyThrows注解。

Java和其他编程语言中异常的历史

在软件开发中,异常处理可以追溯到20世纪60年代LISP的引入。通过异常,我们可以解决在程序错误处理过程中可能遇到的几个问题。

异常的主要思想是将正常的控制流与错误处理分离。让我们看一个不使用异常的例子:

public void handleBookingWithoutExceptions(String customer, String hotel) {

 if (isValidHotel(hotel)) {

  int hotelId = getHotelId(hotel);

  if (sendBookingToHotel(customer, hotelId)) {

   int bookingId = updateDatabase(customer, hotel);

   if (bookingId > 0) {

    if (sendConfirmationMail(customer, hotel, bookingId)) {

     logger.log(Level.INFO, "Booking confirmed");

    } else {

     logger.log(Level.INFO, "Mail failed");

    }

   } else {

    logger.log(Level.INFO, "Database couldn't be updated");

   }

  } else {

   logger.log(Level.INFO, "Request to hotel failed");

  }

 } else {

  logger.log(Level.INFO, "Invalid data");

 }

}

程序的逻辑只占据了大约5行代码,其余的代码则是用于错误处理。这样,代码不再关注主要的流程,而是被错误检查所淹没。

如果我们的编程语言没有异常机制,我们只能依赖函数的返回值。让我们使用异常来重写我们的函数:

public void handleBookingWithExceptions(String customer, String hotel) {

 try {

  validateHotel(hotel);

  sendBookingToHotel(customer, getHotelId(hotel));

  int bookingId = updateDatabase(customer, hotel);

  sendConfirmationMail(customer, hotel, bookingId);

  logger.log(Level.INFO, "Booking confirmed");

 } catch(Exception e) {

  logger.log(Level.INFO, e.getMessage());

 }

}

采用这种方法,我们不需要检查返回值,而是将控制流转移到catch块中。这样的代码更易读。我们有两个独立的流程: 正常流程和错误处理流程。

除了可读性之外,异常还解决了"半谓词问题"(semipredicate problem)。简而言之,半谓词问题发生在表示错误(或不存在值)的返回值成为有效返回值的情况下。让我们看几个示例来说明这个问题:

示例:

int index = "Hello World".indexOf("World");

int value = Integer.parseInt("123");

int freeSeats = getNumberOfAvailableSeatsOfFlight();

indexOf() 方法如果未找到子字符串,将返回 -1。当然,-1 绝对不可能是一个有效的索引,所以这里没有问题。然而,parseInt() 方法的所有可能返回值都是有效的整数。这意味着我们没有一个特殊的返回值来表示错误。最后一个方法 getNumberOfAvailableSeatsOfFlight() 可能会导致隐藏的问题。我们可以将 -1 定义为错误或没有可用信息的返回值。乍看起来这似乎是合理的。然而,后来可能发现负数表示等待名单上的人数。异常机制能更优雅地解决这个问题。

Java中异常的工作方式

在讨论是否使用受检异常之前,让我们简要回顾一下Java中异常的工作方式。下图显示了异常的类层次结构:

java-exception

RuntimeException继承自Exception,而Error继承自Throwable。RuntimeException和Error被称为非受检异常,意味着它们不需要由调用代码处理(即它们不需要被“检查”)。所有其他继承自Throwable(通常通过Exception)的类都是受检异常,这意味着编译器期望调用代码处理它们(即它们必须被“检查”)。

所有继承自Throwable的异常,无论是受检的还是非受检的,都可以在catch块中捕获。

最后,值得注意的是,受检异常和非受检异常的概念是Java编译器的特性。JVM本身并不知道这个区别,所有的异常都是非受检的。这就是为什么其他JVM语言不需要实现这个特性的原因。

在我们开始讨论是否使用受检异常之前,让我们简要回顾一下这两种异常类型之间的区别。

受检异常

受检异常需要被try-catch块包围,或者调用方法需要在其签名中声明异常。由于Scanner类的构造函数抛出一个FileNotFoundException异常,这是一个受检异常,所以下面的代码无法编译:

public void readFile(String filename) {

 Scanner scanner = new Scanner(new File(filename));

}

我们会得到一个编译错误:

Unhandled exception: java.io.FileNotFoundException

我们有两种选项来解决这个问题。我们可以将异常添加到方法的签名中:

public void readFile(String filename) throws FileNotFoundException {

 Scanner scanner = new Scanner(new File(filename));

}

或者我们可以使用try-catch块在现场处理异常:

public void readFile(String filename) {

 try {

  Scanner scanner = new Scanner(new File(filename));

 } catch (FileNotFoundException e) {

  // handle exception

 }

}

非受检异常

对于非受检异常,我们不需要做任何处理。由Integer.parseInt引发的NumberFormatException是一个运行时异常,所以下面的代码可以编译通过:

public int readNumber(String number) {

 return Integer.parseInt(callEndpoint(number));

}

然而,我们仍然可以选择处理异常,因此以下代码也可以编译通过:

public int readNumber(String number) {

 try {

  return Integer.parseInt(callEndpoint(number));

 } catch (NumberFormatException e) {

  // handle exception

  return 0;

 }

}

为什么我们要使用受检异常?

如果我们想了解受检异常背后的动机,我们需要看一下Java的历史。该语言的创建是以强调健壮性和网络功能为重点的。

最好用Java创始人詹姆斯·高斯林(James Gosling)自己的一句话来表达:“你不能无意地说,‘我不在乎。’你必须明确地说,‘我不在乎。’”这句话摘自一篇与詹姆斯·高斯林进行的有趣的采访,在采访中他详细讨论了受检异常。

在《编程之父》这本书中,詹姆斯也谈到了异常。他说:“人们往往忽略了检查返回代码。”

这再次强调了受检异常的动机。通常情况下,当错误是由于编程错误或错误的输入时,应该使用非受检异常。如果在编写代码时程序员无法做任何处理,应该使用受检异常。后一种情况的一个很好的例子是网络问题。开发人员无法解决这个问题,但程序应该适当地处理这种情况,可以是终止程序、重试操作或简单地显示错误消息。

受检异常存在的问题

了解了受检异常和非受检异常背后的动机,我们再来看看受异常在代码库中可能引入的一些问题。

受检异常不适应规模化

一个主要反对受异常的观点是代码的可扩展性和可维护性。当一个方法的异常列表发生变化时,会打破调用链中从调用方法开始一直到最终实现try-catch来处理异常的方法的所有方法调用。举个例子,假设我们调用一个名为libraryMethod()的方法,它是外部库的一部分:

public void retrieveContent() throws IOException {

 libraryMethod();

}

在这里,方法libraryMethod()本身来自一个依赖项,例如,一个处理对外部系统进行REST调用的库。其实现可能如下所示:

public void libraryMethod() throws IOException {

 // some code

}

在将来,我们决定使用库的新版本,甚至用另一个库替换它。尽管功能相似,但新库中的方法会抛出两个异常:

public void otherSdkCall() throws IOException, MalformedURLException {

 // call method from SDK

}

由于有两个受检异常,我们的方法声明也需要更改:

public void retrieveContent() throws IOException, MalformedURLException {

 sdkCall();

}

对于小型代码库来说,这可能不是一个大问题,但对于大型代码库来说,这将需要进行相当多的重构。当然,我们也可以直接在方法内部处理异常:

public void retrieveContent() throws IOException {

 try {

  otherSdkCall();

 } catch (MalformedURLException e) {

  // do something with the exception

 }

}

使用这种方法,我们在代码库中引入了一种不一致性,因为我们立即处理了一个异常,而推迟了另一个异常的处理。

异常传播

一个与可扩展性非常相似的论点是受检异常如何在调用堆栈中传播。如果我们遵循“尽早抛出,尽晚捕获”的原则,我们需要在每个调用方法上添加一个throws子句(a):

异常传播

相反,非受检异常(b)只需要在实际发生异常的地方声明一次,并在我们希望处理异常的地方再次声明。它们会在调用堆栈中自动传播,直到达到实际处理异常的位置。

不必要的依赖关系

受检异常还会引入与非受检异常不必要的依赖关系。让我们再次看看在场景(a)中我们在三个不同的位置添加了IOException。如果methodA()、methodB()和methodC()位于不同的类中,那么所有相关类都将对异常类有一个依赖关系。如果我们使用了非受检异常,我们只需要在methodA()和methodC()中有这个依赖关系。甚至methodB()所在的类或模块都不需要知道异常的存在。

让我们用一个例子来说明这个想法。假设你从度假回家。你在酒店前台退房,乘坐公共汽车去火车站,然后换乘一次火车,在回到家乡后,你又乘坐另一辆公共汽车从车站回家。回到家后,你意识到你把手机忘在了酒店里。在你开始整理行李之前,你进入了“异常”流程,乘坐公共汽车和火车回到酒店取手机。在这种情况下,你按照之前相反的顺序做了所有的事情(就像在Java中发生异常时向上移动堆栈跟踪一样),直到你到达酒店。显然,公共汽车司机和火车操作员不需要知道“异常”,他们只需要按照他们的工作进行。只有在前台,也就是“回家”流程的起点,我们需要询问是否有人找到了手机。

糟糕的编码实践

当然,作为专业的软件开发人员,我们绝不能在良好的编码实践上选择方便。然而,当涉及到受检异常时,往往会诱使我们快速引入以下三种模式。通常的想法是以后再处理。我们都知道这样的结果。另一个常见的说法是“我想为正常流程编写代码,不想被异常打扰”。我经常见到以下三种模式。

第一种模式是捕获所有异常(catch-all exception):

public void retrieveInteger(String endpoint) {

 try {

  URL url = new URL(endpoint);

  int result = Integer.parseInt(callEndpoint(endpoint));

 } catch (Exception e) {

  // do something with the exception

 }

}

我们只是捕获所有可能的异常,而不是单独处理不同的异常:

public void retrieveInteger(String endpoint) {

 try {

  URL url = new URL(endpoint);

  int result = Integer.parseInt(callEndpoint(endpoint));

 } catch (MalformedURLException e) {

  // do something with the exception

 } catch (NumberFormatException e) {

  // do something with the exception

 }

}

当然,在一般情况下,这并不一定是一种糟糕的实践。如果我们只想记录异常,或者在Spring Boot的@ExceptionHandler中作为最后的安全机制,这是一种适当的做法。

第二种模式是空的catch块:

public void myMethod() {

 try {

  URL url = new URL("malformed url");

 } catch (MalformedURLException e) {}

}

这种方法显然绕过了受检异常的整个概念。它完全隐藏了异常,使我们的程序在没有提供任何关于发生了什么的信息的情况下继续执行。

第三种模式是简单地打印堆栈跟踪并继续执行,就好像什么都没有发生一样:

public void consumeAndForgetAllExceptions(){

 try {

  // some code that can throw an exception

 } catch (Exception ex){

  ex.printStacktrace();

 }

}

为了满足方法签名而添加额外的代码

有时我们可以确定除非出现编程错误,否则不会抛出异常。让我们考虑以下示例:

public void readFromUrl(String endpoint) {

 try {

  URL url = new URL(endpoint);

 } catch (MalformedURLException e) {

  // do something with the exception

 }

}

MalformedURLException是一个受检异常,当给定的字符串不符合有效的URL格式时,会抛出该异常。需要注意的重要事项是,如果URL格式不正确,就会抛出异常,这并不意味着URL实际上存在并且可以访问。

即使我们在之前验证了格式:

public void readFromUrl(@ValidUrl String endpoint)

或者我们已经将其硬编码:

public static final String endpoint = "http://www.example.com";

编译器仍然强制我们处理异常。我们需要写两行“无用”的代码,只是因为有一个受检异常。

如果我们无法编写代码来触发某个异常的抛出,就无法对其进行测试,因此测试覆盖率将会降低。

有趣的是,当我们想将字符串解析为整数时,并不强制我们处理异常:

Integer.parseInt("123");

parseInt方法在提供的字符串不是有效整数时会抛出NumberFormatException,这是一个非受检异常。

Lambda表达式和异常

受检异常并不总是与Lambda表达式很好地配合使用。让我们来看一个例子:

public class CheckedExceptions {

 public static String readFirstLine(String filename) throws FileNotFoundException {

  Scanner scanner = new Scanner(new File(filename));

  return scanner.next();

 }

 public void readFile() {

  List<String> fileNames = new ArrayList<>();

  List<String> lines = fileNames.stream().map(CheckedExceptions::readFirstLine).toList();

 }

}

由于我们的readFirstLine()方法抛出了一个受检异常,所以会导致编译错误:

Unhandled exception: java.io.FileNotFoundException in line 8.

如果我们尝试使用try-catch块来修正代码:

public void readFile() {

 List<String> fileNames = new ArrayList<>();

 try {

  List<String> lines = fileNames.stream()

    .map(CheckedExceptions::readFirstLine)

    .toList();

 } catch (FileNotFoundException e) {

   // handle exception

 }

}

我们仍然会得到一个编译错误,因为我们无法在lambda内部将受检异常传播到外部。我们必须在lambda表达式内部处理异常并抛出一个运行时异常:

public void readFile() {

 List<String> lines = fileNames.stream()

  .map(filename -> {

   try{

    return readFirstLine(filename);

   } catch(FileNotFoundException e) {

    throw new RuntimeException("File not found", e);

   }

  }).toList();

}

不幸的是,如果静态方法引用抛出受检异常,这种方式将变得不可行。或者,我们可以让lambda表达式返回一个错误消息,然后将其添加到结果中:

public void readFile() {

 List<String> lines = fileNames.stream()

  .map(filename -> {

   try{

    return readFirstLine(filename);

   } catch(FileNotFoundException e) {

    return "default value";

   }

  }).toList();

}

然而,代码看起来仍然有些杂乱。

我们可以在lambda内部传递一个非受检异常,并在调用方法中捕获它:

public class UncheckedExceptions {

 public static int parseValue(String input) throws NumberFormatException {

  return Integer.parseInt(input);

 }

 public void readNumber() {

  try {

   List<String> values = new ArrayList<>();

   List<Integers> numbers = values.stream()

       .map(UncheckedExceptions::parseValue)

       .toList();

  } catch(NumberFormatException e) {

   // handle exception

  }

 }

}

在这里,我们需要注意之前使用受检异常和使用非受检异常的例子之间的一个关键区别。对于非受检异常,流的处理将继续到下一个元素,而对于受检异常,处理将结束,并且不会处理更多的元素。显然,我们想要哪种行为取决于我们的用例。

处理受检异常的替代方法

将受检异常包装为非受检异常

我们可以通过将受检异常包装为非受检异常来避免在调用堆栈中的所有方法中添加throws子句。而不是让我们的方法抛出一个受检异常:

public void myMethod() throws IOException{}

我们可以将其包装在一个非受检异常中:

public void myMethod(){

 try {

  // some logic

 } catch(IOException e) {

  throw new MyUnchckedException("A problem occurred", e);

 }

}

理想情况下,我们应用异常链。这样可以确保原始异常不会被隐藏。我们可以在第5行看到异常链的应用,原始异常作为参数传递给新的异常。这种技术在早期版本的Java中几乎适用于所有核心Java异常。

异常链是许多流行框架(如Spring或Hibernate)中常见的一种方法。这两个框架从受检异常转向非受检异常,并将不属于框架的受检异常包装在自己的运行时异常中。一个很好的例子是Spring的JDBC模板,它将所有与JDBC相关的异常转换为Spring框架的非受检异常。

Lombok @SneakyThrows

Project Lombok为我们提供了一个注解,可以消除异常链的需要。而不是在我们的方法中添加throws子句:

public void beSneaky() throws MalformedURLException {

 URL url = new URL("http://test.example.org");

}

我们可以添加@SneakyThrows 注解,这样我们的代码就可以编译通过:

@SneakyThrows

public void beSneaky() {

 URL url = new URL("http://test.example.org");

}

然而,重要的是要理解,@SneakyThrows并不会使MalformedURLException的行为完全像运行时异常一样。我们将无法再捕获它,并且以下代码将无法编译:

public void callSneaky() {

 try {

  beSneaky();

 } catch (MalformedURLException e) {

  // handle exception

 }

}

由于@SneakyThrows移除了异常,而MalformedURLException仍然被视为受检异常,因此我们将在第4行得到编译器错误:

Exception 'java.net.MalformedURLException' is never thrown in the corresponding try block

性能

在我的研究过程中,我遇到了一些关于异常性能的讨论。在受检异常和非受检异常之间是否存在性能差异?实际上,它们之间没有性能差异。这是一个在编译时决定的特性。

然而,是否在异常中包含完整的堆栈跟踪会导致显着的性能差异:

public class MyException extends RuntimeException {

 public MyException(String message, boolean includeStacktrace) {

  super(message, null, !includeStacktrace, includeStacktrace);

 }

}

在这里,我们在自定义异常的构造函数中添加了一个标志。该标志指定是否要包含完整的堆栈跟踪。在抛出异常的情况下,构建堆栈跟踪会导致程序变慢。因此,如果性能至关重要,则应排除堆栈跟踪。

一些指南

如何处理软件中的异常是我们工作的一部分,它高度依赖于具体的用例。在我们结束讨论之前,这里有三个高级指南,我相信它们(几乎)总是正确的。

  • 如果不是编程错误,或者程序可以执行一些有用的恢复操作,请使用受检异常。
  • 如果是编程错误,或者程序无法进行任何恢复操作,请使用运行时异常。
  • 避免空的catch块。

结论

本文深入探讨了Java中的异常。我们讲了为什么要引入异常到语言中,何时应该使用受检异常和非受检异常。我们还讨论了受检异常的缺点以及为什么它们现在被认为是不良实践 – 尽管也有一些例外情况。


【注】本文译自: Don’t Use Checked Exceptions (reflectoring.io)

2023全栈开发人员职业路线图

0. 全栈开发人员职业路线图

全栈开发人员

全栈开发人员是IT行业中薪资最高的职业之一。

如果您想成为一名全栈开发人员,以下是2023年全栈开发人员路线图上的十一个步骤:

  1. 掌握敏捷开发和Scrum
  2. 学习浏览器技术,如HTML和CSS
  3. 熟练掌握JavaScript或TypeScript
  4. 了解Git及其CI/CD生态系统
  5. 具备移动应用程序开发能力
  6. 使用RESTful API交换JSON数据
  7. 使用SQL管理超大型数据库
  8. 掌握中间层技术
  9. 学习用于云原生配置的YAML语言
  10. 使用Rust或C++与底层技术打交道
  11. 致力于12因素应用程序开发

1. 敏捷开发

所有全栈开发人员都具备敏捷性这一特质。

每个技术组织都知道,条件变化太快,无法提前数月进行适当的计划。这就是为什么每个全栈开发人员都必须具备敏捷性,并理解快速“响应变化比遵循计划更重要”这一点。

这是敏捷开发的一个原则。其他三个是什么呢?

  • 个人与互动高于流程和工具
  • 能工作的软件高于详尽的文档
  • 与客户的协作高于合同的谈判

无论您使用哪种软件堆栈,精通哪些开发工具或部署到哪个云平台,如果您不是一名敏捷的全栈开发人员,这些都是无关紧要的。

阅读敏捷宣言并将其12个敏捷软件开发原则铭记于心。

在学习过程中,学习敏捷框架,如Scrum或Kanban。

Scrum指南只有13页长。阅读它以便了解敏捷软件开发的全部内容。

一名全栈开发人员必须拥抱敏捷开发的理念,并精通敏捷框架,如Scrum。

2. 需要具备HTML和CSS的核心能力

HTML和CSS是网站开发的基石。

一名全栈开发人员可能不会花费大量时间开发网站的落地页面,但需要深入了解HTML,以便:

  • 修复网站错误
  • 更新WordPress模板
  • 浏览PHP代码片段
  • 修复响应式网站
  • 进行SEO优化

一名全栈开发人员需要了解和掌握的第一种编程语言是HTML。如果没有HTML知识,您不可能成为一名全栈开发人员。

全栈开发人员需要了解HTML和CSS,才能使用响应式Web框架,如Bootstrap。

3. 需要熟练掌握JavaScript

想成为一名全栈开发人员吗?那么您必须掌握JavaScript或TypeScript中的一种。

JavaScript是Web浏览器的四种W3C标准编程语言之一,也是唯一一种可以对WebAssembly组件进行基于浏览器的调用的语言。

此外,JavaScript在服务器端也得到了广泛的支持,如Node.js,因此当需要与数据库或消息队列集成时,可以轻松地将基于浏览器的JavaScript技能转移到后端。

全栈开发人员必须在前端和后端都具备能力。了解JavaScript可以让全栈开发人员进入前后端两个领域。

4. 掌握Git

全栈开发人员编写的所有代码都必须存储在某个地方。

如今,绝大多数代码都存储在基于Git的存储库中,如GitHub、GitLab或BitBucket。

全栈开发人员需要知道如何提交代码、合并分支、变基历史和压缩提交。

Git是一项必要的全栈开发人员技能。

对于全栈开发人员来说,Git和了解基于Git的SaaS提供商,如GitHub和GitLab,是必需的。

5. 移动应用程序开发

全栈开发人员需要了解移动应用程序开发。

如果您想成为一名全栈开发人员,您需要知道如何使用以下语言开发iPhone和Android设备的应用程序:

  • iPhone使用Swift
  • Android使用Kotlin
  • 两者都可以使用React Native

您还需要了解发布到Apple Store或Google Play Store的复杂性。

如果您不知道如何将移动应用程序分发给客户,那么开发移动应用程序就没有意义了。

6. 使用JSON构建RESTful API

客户端和服务器之间发生的绝大部分通信都是通过交换JSON数据的RESTful API进行的。全栈开发人员需要了解两者。

全栈开发人员需要知道:

  • 如何构建RESTful API网关以供客户端访问
  • 如何从客户端应用程序连接到RESTful API
  • 如何通过认证和加密保护RESTful API
  • 如何创建可靠的可扩展的RESTful API

作为学习RESTful API的一部分,还要学习如何将JSON存储在NoSQL数据库中。NoSQL数据库是Facebook和Twitter实现大规模扩展的方式,它们大大简化了RESTful数据持久性。

开始学习RESTful API的旅程,可以阅读Roy Fielding关于RESTful API是什么以及为什么互联网需要它们的2001年论文。这是一篇值得阅读的论文。

RESTful API是将客户端和服务器端应用程序集成的关键。

7. SQL和关系型数据库技术

NoSQL数据库很重要,但关系型数据库更重要。

全栈开发人员需要了解结构化查询语言(SQL)的基础知识,以便处理存储数十亿行和数TB数据的大型企业级关系型数据库。

SQL和关系型数据库技术的知识对于全栈开发人员非常重要,以便:

  • 管理、修改和查询大型数据库系统
  • 为外部工具、客户端和API提供后端集成
  • 调整和优化数据库性能
  • 在出现问题时解决生产问题

8. 中间层技术

全栈开发人员需要知道如何将客户端层和后端数据库层连接起来。

  • 如果需要与应用程序服务器和云API交互,则Java非常适合。
  • 如果您的堆栈包括机器学习或人工智能,则Python非常适合。
  • 如果要使用跨越堆栈多个层的单一语言,则JavaScript非常适合。

全栈开发人员需要了解中间层编程语言,以及与该语言堆栈相关的中间层技术的知识。

例如,一个以Java为重点的全栈开发人员还应该对Java中间层技术有深入的了解,例如:

  • Tomcat
  • WebSphere
  • Kafka
  • Jenkins
  • CouchDB
  • Hadoop

中间层对于全栈开发人员来说非常重要。

全栈开发人员应该了解n层软件堆栈的工作原理。

9. 使用YAML进入云原生

YAML是云原生技术的标准“应用程序配置”语言。

  • 想要使用Terraform进行基础设施即代码管理吗?
  • 想要使用GitHub Actions进行持续集成和部署吗?
  • 想要使用Docker和Kubernetes部署应用程序吗?
  • 想要在AWS上配置网络网关吗?

您可以使用YAML对它们进行配置。YAML是全栈开发人员的必备技能。

YAML是云原生部署和软件配置的关键。

10. 靠近硬件

不是所有开发人员都需要“靠近硬件”。

但如果您需要,您需要了解编程语言,如Rust、Go或C。

这些语言使全栈开发人员可以为以下组件编写代码:

  • 操作系统,如Linux和Windows
  • 制造和汽车领域中使用的嵌入式系统
  • 云计算中使用的虚拟化程序
  • 密码和安全组件

了解类似Rust或C++的语言,可以让全栈开发人员编写编译为二进制代码的代码,而不需要像Java和Python一样运行在抽象层之上。这就是为什么这些语言被称为“靠近硬件”的原因。

11. 致力于12个要素

“12因素应用程序”原则描述了开发云原生应用程序的最佳实践。

学习如何创建12因素应用程序,并承诺按照这些原则编写您的应用程序。

任何部署为微服务、Lambda过程或无服务器函数的应用程序都必须符合12因素要求。

了解这12个因素,并承诺遵守它们。

如果您掌握了所有这些技能,您将完成2023年全栈开发人员的路线图,并将在IT领域获得可观的职业生涯。

对于云原生开发,全栈开发人员必须致力于12因素应用程序。


【注】本文译自 2023 full-stack developer roadmap

JUnit 5 参数化测试

JUnit 5

JUnit 5参数化测试

目录

  • 设置

  • 我们的第一个参数化测试

  • 参数来源

    • @ValueSource
    • @NullSource & @EmptySource
    • @MethodSource
    • @CsvSource
    • @CsvFileSource
    • @EnumSource
    • @ArgumentsSource
    • 参数转换
    • 参数聚合
  • 奖励

  • 总结

如果您正在阅读这篇文章,说明您已经熟悉了JUnit。让我为您概括一下JUnit——在软件开发中,我们开发人员编写的代码可能是设计一个人的个人资料这样简单,也可能是在银行系统中进行付款这样复杂。在开发这些功能时,我们倾向于编写单元测试。顾名思义,单元测试的主要目的是确保代码的小、单独部分按预期功能工作。如果单元测试执行失败,这意味着该功能无法按预期工作。编写单元测试的一种工具是JUnit。这些单元测试程序很小,但是非常强大,并且可以快速执行。如果您想了解更多关于JUnit 5(也称为JUnit Jupiter)的信息,请查看这篇JUnit5的文章。现在我们已经了解了JUnit,接下来让我们聚焦于JUnit 5中的参数化测试。参数化测试可以解决在为任何新/旧功能开发测试框架时遇到的最常见问题。

  • 编写针对每个可能输入的测试用例变得更加容易。
  • 单个测试用例可以接受多个输入来测试源代码,有助于减少代码重复。
  • 通过使用多个输入运行单个测试用例,我们可以确信已涵盖所有可能的场景,并维护更好的代码覆盖率。

开发团队通过利用方法和类来创建可重用且松散耦合的源代码。传递给代码的参数会影响其功能。例如,计算器类中的sum方法可以处理整数和浮点数值。JUnit 5引入了执行参数化测试的能力,可以使用单个测试用例测试源代码,该测试用例可以接受不同的输入。这样可以更有效地进行测试,因为在旧版本的JUnit中,必须为每种输入类型创建单独的测试用例,从而导致大量的代码重复。

示例代码

本文附带有在 GitHub上 的一个可工作的示例代码。

设置

就像疯狂泰坦灭霸喜欢访问力量一样,您可以使用以下Maven依赖项来访问JUnit5中参数化测试的力量:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-params</artifactId>
    <version>5.9.2</version>
    <scope>test</scope>
</dependency>

让我们来写些代码,好吗?

我们的第一个参数化测试

现在,我想向您介绍一个新的注解 @ParameterizedTest。顾名思义,它告诉JUnit引擎使用不同的输入值运行此测试。

import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

public class ValueSourceTest {

    @ParameterizedTest
    @ValueSource(ints = { 2, 4 })
    void checkEvenNumber(int number) {
        assertEquals(0, number % 2,
         "Supplied number is not an even number");
    }
}

在上面的示例中,注解@ValueSource为 checkEvenNumber() 方法提供了多个输入。假设我们使用JUnit4编写相同的代码,即使它们的结果(断言)完全相同,我们也必须编写2个测试用例来覆盖输入2和4。

当我们执行 ValueSourceTest 时,我们会看到什么:

ValueSourceTest

|_ checkEvenNumber

|_ [1] 2

|_ [2] 4

这意味着 checkEvenNumber() 方法将使用2个输入值执行。

在下一节中,让我们学习一下JUnit5框架提供的各种参数来源。

参数来源

JUnit5提供了许多参数来源注释。下面的章节将简要概述其中一些注释并提供示例。

@ValueSource

@ValueSource是一个简单的参数源,可以接受单个字面值数组。@ValueSource支持的字面值类型有short、byte、int、long、float、double、char、boolean、String和Class。

@ParameterizedTest
@ValueSource(strings = { "a1", "b2" })
void checkAlphanumeric(String word) {
    assertTrue(StringUtils.isAlphanumeric(word),
             "Supplied word is not alpha-numeric");
}

@NullSource & @EmptySource

假设我们需要验证用户是否已经提供了所有必填字段(例如在登录函数中需要提供用户名和密码)。我们使用注解来检查提供的字段是否为 null,空字符串或空格。

  • 在单元测试中使用 @NullSource 和 @EmptySource 可以帮助我们提供带有 null、空字符串和空格的数据源,并验证源代码的行为。
@ParameterizedTest
@NullSource
void checkNull(String value) {
    assertEquals(null, value);
}

@ParameterizedTest
@EmptySource
void checkEmpty(String value) {
    assertEquals("", value);
}
  • 我们还可以使用 @NullAndEmptySource 注解来组合传递 null 和空输入。
@ParameterizedTest
@NullAndEmptySource
void checkNullAndEmpty(String value) {
    assertTrue(value == null || value.isEmpty());
}
  • 另一个传递 null、空字符串和空格输入值的技巧是结合使用 @NullAndEmptySource 注解,以覆盖所有可能的负面情况。该注解允许我们从一个或多个测试类的工厂方法中加载输入,并生成一个参数流。
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = { " ", " " })
void checkNullEmptyAndBlank(String value) {
    assertTrue(value == null || value.isBlank());
}

@MethodSource

该注解允许我们从一个或多个测试类的工厂方法中加载输入,并生成一个参数流。

  • 显式方法源 – 测试将尝试加载提供的方法。
// Note: The test will try to load the supplied method
@ParameterizedTest
@MethodSource("checkExplicitMethodSourceArgs")
void checkExplicitMethodSource(String word) {
assertTrue(StringUtils.isAlphanumeric(word),
"Supplied word is not alpha-numeric");
}

static Stream<String> checkExplicitMethodSourceArgs() {
return Stream.of("a1",
"b2");
}
  • 隐式方法源 – 测试将搜索与测试类匹配的源方法。
// Note: The test will search for the source method
// that matches the test-case method name
@ParameterizedTest
@MethodSource
void checkImplicitMethodSource(String word) {
    assertTrue(StringUtils.isAlphanumeric(word),
"Supplied word is not alpha-numeric");
}

static Stream<String> checkImplicitMethodSource() {
return Stream.of("a1",
"b2");
}
  • 多参数方法源 – 我们必须将输入作为参数流传递。测试将按照索引顺序加载参数。
// Note: The test will automatically map arguments based on the index
@ParameterizedTest
@MethodSource
void checkMultiArgumentsMethodSource(int number, String expected) {
    assertEquals(StringUtils.equals(expected, "even") ? 0 : 1, number % 2);
}

static Stream<Arguments> checkMultiArgumentsMethodSource() {
    return Stream.of(Arguments.of(2, "even"),
     Arguments.of(3, "odd"));
}
  • 外部方法源 – 测试将尝试加载外部方法。
// Note: The test will try to load the external method
@ParameterizedTest
@MethodSource(
"source.method.ExternalMethodSource#checkExternalMethodSourceArgs")
void checkExternalMethodSource(String word) {
    assertTrue(StringUtils.isAlphanumeric(word),
"Supplied word is not alpha-numeric");
}
// Note: The test will try to load the external method@ParameterizedTest@MethodSource("source.method.ExternalMethodSource#checkExternalMethodSourceArgs")void checkExternalMethodSource(String word) {    assertTrue(StringUtils.isAlphanumeric(word),"Supplied word is not alpha-numeric");}

package source.method;
import java.util.stream.Stream;

public class ExternalMethodSource {
    static Stream<String> checkExternalMethodSourceArgs() {
        return Stream.of("a1",
         "b2");
    }
}

@CsvSource

该注解允许我们将参数列表作为逗号分隔的值(即 CSV 字符串字面量)传递,每个 CSV 记录都会导致执行一次参数化测试。它还支持使用 useHeadersInDisplayName属性跳过 CSV 标头。

@ParameterizedTest
@CsvSource({ "2, even",
"3, odd"})
void checkCsvSource(int number, String expected) {
    assertEquals(StringUtils.equals(expected, "even")
     ? 0 : 1, number % 2);
}

@CsvFileSource

该注解允许我们使用类路径或本地文件系统中的逗号分隔值(CSV)文件。与 @CsvSource 类似,每个 CSV 记录都会导致执行一次参数化测试。它还支持各种其他属性 -numLinesToSkip、useHeadersInDisplayName、lineSeparator、delimiterString等。

示例 1: 基本实现

@ParameterizedTest
@CsvFileSource(
files = "src/test/resources/csv-file-source.csv",
numLinesToSkip = 1)
void checkCsvFileSource(int number, String expected) {
    assertEquals(StringUtils.equals(expected, "even")
                 ? 0 : 1, number % 2);
}

src/test/resources/csv-file-source.csv

NUMBER, ODD_EVEN

2, even

3, odd

示例2:使用属性

@ParameterizedTest
@CsvFileSource(
    files = "src/test/resources/csv-file-source_attributes.csv",
    delimiterString = "|",
    lineSeparator = "||",
    numLinesToSkip = 1)
void checkCsvFileSourceAttributes(int number, String expected) {
    assertEquals(StringUtils.equals(expected, "even")
? 0 : 1, number % 2);
}

src/test/resources/csv-file-source_attributes.csv

|| NUMBER | ODD_EVEN ||

|| 2 | even ||

|| 3 | odd ||

@EnumSource

该注解提供了一种方便的方法来使用枚举常量作为测试用例参数。支持的属性包括:

  • value – 枚举类类型,例如 ChronoUnit.class
package java.time.temporal;

public enum ChronoUnit implements TemporalUnit {
    SECONDS("Seconds", Duration.ofSeconds(1)),
    MINUTES("Minutes", Duration.ofSeconds(60)),
HOURS("Hours", Duration.ofSeconds(3600)),
    DAYS("Days", Duration.ofSeconds(86400)),
    //12 other units
}

ChronoUnit 是一个包含标准日期周期单位的枚举类型。

@ParameterizedTest
@EnumSource(ChronoUnit.class)
void checkEnumSourceValue(ChronoUnit unit) {
assertNotNull(unit);
}

在此示例中,@EnumSource 将传递所有16个 ChronoUnit 枚举值作为参数。

  • names – 枚举常量的名称或选择名称的正则表达式,例如 DAYS 或 ^.*DAYS$
@ParameterizedTest
@EnumSource(names = { "DAYS", "HOURS" })
void checkEnumSourceNames(ChronoUnit unit) {
    assertNotNull(unit);
}

@ArgumentsSource

该注解提供了一个自定义的可重用ArgumentsProvider。ArgumentsProvider的实现必须是外部类或静态嵌套类。

  • 外部参数提供程序
public class ArgumentsSourceTest {

    @ParameterizedTest
    @ArgumentsSource(ExternalArgumentsProvider.class)
    void checkExternalArgumentsSource(int number, String expected) {
        assertEquals(StringUtils.equals(expected, "even")
                    ? 0 : 1, number % 2,
                    "Supplied number " + number +
                    " is not an " + expected + " number");
    }
}

public class ExternalArgumentsProvider implements ArgumentsProvider {

    @Override
    public Stream<? extends Arguments> provideArguments(
        ExtensionContext context) throws Exception {

        return Stream.of(Arguments.of(2, "even"),
             Arguments.of(3, "odd"));
    }
}
  • 静态嵌套参数提供程序
public class ArgumentsSourceTest {

    @ParameterizedTest
    @ArgumentsSource(NestedArgumentsProvider.class)
    void checkNestedArgumentsSource(int number, String expected) {
        assertEquals(StringUtils.equals(expected, "even")
? 0 : 1, number % 2,
                 "Supplied number " + number +
                    " is not an " + expected + " number");
    }

    static class NestedArgumentsProvider implements ArgumentsProvider {

        @Override
        public Stream<? extends Arguments> provideArguments(
            ExtensionContext context) throws Exception {

            return Stream.of(Arguments.of(2, "even"),
     Arguments.of(3, "odd"));
        }
    }
}

参数转换

首先,想象一下如果没有参数转换,我们将不得不自己处理参数数据类型的问题。

源方法: Calculator 类

public int sum(int a, int b) {
    return a + b;
}

测试用例:

@ParameterizedTest
@CsvSource({ "10, 5, 15" })
void calculateSum(String num1, String num2, String expected) {
    int actual = calculator.sum(Integer.parseInt(num1),
                                Integer.parseInt(num2));
    assertEquals(Integer.parseInt(expected), actual);
}

如果我们有String参数,而我们正在测试的源方法接受Integers,则在调用源方法之前,我们需要负责进行此转换。

JUnit5 提供了不同的参数转换方式

  • 扩展原始类型转换
@ParameterizedTest
@ValueSource(ints = { 2, 4 })
void checkWideningArgumentConversion(long number) {
    assertEquals(0, number % 2);
}

使用 @ValueSource(ints = { 1, 2, 3 }) 进行参数化测试时,可以声明接受 int、long、float 或 double 类型的参数。

  • 隐式转换
@ParameterizedTest
@ValueSource(strings = "DAYS")
void checkImplicitArgumentConversion(ChronoUnit argument) {
    assertNotNull(argument.name());
}

JUnit5提供了几个内置的隐式类型转换器。转换取决于声明的方法参数类型。例如,用@ValueSource(strings = "DAYS")注释的参数化测试会隐式转换为类型ChronoUnit的参数。

  • 回退字符串到对象的转换
@ParameterizedTest
@ValueSource(strings = { "Name1", "Name2" })
void checkImplicitFallbackArgumentConversion(Person person) {
    assertNotNull(person.getName());
}

public class Person {
    private String name;
    public Person(String name) {
        this.name = name;
    }
    //Getters & Setters
}

JUnit5提供了一个回退机制,用于自动将字符串转换为给定目标类型,如果目标类型声明了一个适用的工厂方法或工厂构造函数。例如,用@ValueSource(strings = { "Name1", "Name2" })注释的参数化测试可以声明接受一个类型为Person的参数,其中包含一个名为name且类型为string的单个字段。

  • 显式转换
@ParameterizedTest
@ValueSource(ints = { 100 })
void checkExplicitArgumentConversion(
    @ConvertWith(StringSimpleArgumentConverter.class) String argument) {
    assertEquals("100", argument);
}

public class StringSimpleArgumentConverter extends SimpleArgumentConverter {

    @Override
    protected Object convert(Object source, Class<?> targetType)
        throws ArgumentConversionException {
        return String.valueOf(source);
    }
}

如果由于某种原因,您不想使用隐式参数转换,则可以使用@ConvertWith注释来定义自己的参数转换器。例如,用@ValueSource(ints = { 100 })注释的参数化测试可以声明接受一个类型为String的参数,使用
StringSimpleArgumentConverter.class将整数转换为字符串类型。

参数聚合

@ArgumentsAccessor

默认情况下,提供给@ParameterizedTest方法的每个参数对应于一个方法参数。因此,当提供大量参数的参数源可以导致大型方法签名时,我们可以使用ArgumentsAccessor而不是声明多个参数。类型转换支持如上面的隐式转换所述。

@ParameterizedTest
@CsvSource({ "John, 20",
         "Harry, 30" })
void checkArgumentsAccessor(ArgumentsAccessor arguments) {
    Person person = new Person(arguments.getString(0),
                             arguments.getInteger(1));
    assertTrue(person.getAge() > 19, person.getName() + " is a teenager");
}

自定义聚合器

我们看到ArgumentsAccessor可以直接访问@ParameterizedTest方法的参数。如果我们想在多个测试中声明相同的ArgumentsAccessor怎么办?JUnit5通过提供自定义可重用的聚合器来解决此问题。

  • @AggregateWith
@ParameterizedTest
@CsvSource({ "John, 20",
             "Harry, 30" })
void checkArgumentsAggregator(
    @AggregateWith(PersonArgumentsAggregator.class) Person person) {
    assertTrue(person.getAge() > 19, person.getName() + " is a teenager");
}

public class PersonArgumentsAggregator implements ArgumentsAggregator {

    @Override
    public Object aggregateArguments(ArgumentsAccessor arguments,
        ParameterContext context) throws ArgumentsAggregationException {

        return new Person(arguments.getString(0),
arguments.getInteger(1));
    }
}

实现ArgumentsAggregator接口并通过@AggregateWith注释在@ParameterizedTest方法中注册它。当我们执行测试时,它会将聚合结果作为对应测试的参数提供。ArgumentsAggregator的实现可以是外部类或静态嵌套类。

额外福利

由于您已经阅读完文章,我想给您一个额外的福利 – 如果您正在使用像Fluent assertions for java这样的断言框架,则可以将
java.util.function.Consumer作为参数传递,其中包含断言本身。

@ParameterizedTest
@MethodSource("checkNumberArgs")
void checkNumber(int number, Consumer<Integer> consumer) {
    consumer.accept(number);    
}

static Stream<Arguments> checkNumberArgs() {    
    Consumer<Integer> evenConsumer =
            i -> Assertions.assertThat(i % 2).isZero();
    Consumer<Integer> oddConsumer =
            i -> Assertions.assertThat(i % 2).isEqualTo(1);

    return Stream.of(Arguments.of(2, evenConsumer),
         Arguments.of(3, oddConsumer));
}

总结

JUnit5的参数化测试功能通过消除重复测试用例的需要,提供多次使用不同输入运行相同测试的能力,实现了高效的测试。这不仅为开发团队节省了时间和精力,而且还增加了测试过程的覆盖范围和有效性。此外,该功能允许对源代码进行更全面的测试,因为可以使用更广泛的输入进行测试,从而增加了识别任何潜在的错误或问题的机会。总体而言,JUnit5的参数化测试是提高代码质量和可靠性的有价值的工具。


【注】本文译自: JUnit 5 Parameterized Tests (reflectoring.io)

Java最佳实践

img

计算机编程中,最佳实践是许多开发人员遵循的一组非正式规则,以提高软件质量、可读性和可维护性。在应用程序长时间保持使用的情况下,最佳实践尤其有益,这样它最初是由一个团队开发的,然后由不同的人组成的维护团队进行维护。

本教程将提供Java最佳实践的概述,以及每个条目的解释,包括Java编程的顶级最佳实践列表中的每一项。

Java编程最佳实践概览

虽然Java最佳实践的完整列表可能很长,但对于那些正在努力提高代码质量的编码人员来说,有几个被认为是一个很好的起点,包括使用适当的命名规范、使类成员私有化、避免使用空的catch块、避免内存泄漏以及正确地注释代码块:

  • 使用适当的命名规范
  • 类成员设置为私有
  • 在长数字文字中使用下划线
  • 避免空的catch
  • 使用StringBuilderStringBuffer进行字符串连接
  • 避免冗余初始化
  • 使用增强型for循环代替带计数器的for循环
  • 正确处理空指针异常
  • FloatDouble:哪一个是正确的选择?
  • 使用单引号和双引号
  • 避免内存泄漏
  • 返回空集合而不是返回Null元素
  • 高效使用字符串
  • 避免创建不必要的对象
  • 正确注释代码

Java中的类成员应该是私有的

在Java中,类的成员越不可访问,越好!第一步是使用private访问修饰符。目标是促进理想的封装,这是面向对象编程(OOP)的基本概念之一。太多时候,新的开发人员没有正确地为类分配访问修饰符,或者倾向于将它们设置为public以使事情更容易。

考虑以下字段被设置为public的类:

public class BandMember {
  public String name;
  public String instrument;
}

在这里,类的封装性被破坏了,因为任何人都可以直接更改这些值,如下所示:

BandMember billy = new BandMember();
billy.name = "George";
billy.instrument = "drums";

使用private访问修饰符与类成员一起可以将字段隐藏起来,防止用户通过setter方法之外的方式更改数据:

public class BandMember {
  private String name;
  private String instrument;

  public void setName(String name) {
    this.name = name;
  }
  public void setInstrument(String instrument)
    this.instrument = instrument;
  }
}

setter方法中也是放置验证代码和/或管理任务(如增加计数器)的理想位置。

在长数字文字中使用下划线

得益于Java 7的更新,开发人员现在可以在长数字字面量中使用下划线(_),以提高可读性。以下是在允许下划线之前一些长数字字面量的示例:

int minUploadSize = 05437326;
long debitBalance = 5000000000000000L;
float pi = 3.141592653589F;

我想您会同意下划线使值更易读:

int minUploadSize = 05_437_326;
long debitBalance = 5_000_000_000_000_000L;
float pi = 3.141_592_653_589F;

避免空的Catch块

在Java中,把catch块留空是非常不好的习惯,有两个原因:一是它可能导致程序默默地失败,二是程序可能会继续运行而不会发生任何异常。这两种结果都会使调试变得非常困难

考虑以下程序,它从命令行参数中计算两个数字的和:

public class Sum {
  public static void main(String[] args) {
    int a = 0;
    int b = 0;

    try {
      a = Integer.parseInt(args[0]);
      b = Integer.parseInt(args[1]);
    } catch (NumberFormatException ex) {
    }

    int sum = a + b;

    System.out.println(a + " + " + b + " = " + sum);
  }
}

Java的parseInt()方法会抛出NumberFormatException异常,需要开发人员在其调用周围使用try/catch块来捕获异常。不幸的是,这位开发人员选择忽略抛出的异常!因此,传入无效参数,例如“45y”,将导致关联的变量被赋为其类型的默认值,对于int类型来说是0

img

通常,在捕获异常时,程序员应采取以下三个行动中的一个或多个:

  1. 开发人员最起码应该通知用户异常情况,要么让他们重新输入无效的值,要么让他们知道程序必须提前终止。
  2. 使用 JDK LoggingLog4J 记录异常日志。
  3. 将异常封装并作为一个新的、更适合应用程序的异常重新抛出。

以下是重新编写后的Sum应用程序,用于通知用户输入无效并因此终止程序:

public class Sum {
  public static void main(String[] args) {
    int a = 0;
    int b = 0;

    try {
      a = Integer.parseInt(args[0]);
    } catch (NumberFormatException ex) {
      System.out.println(args[0] + " is not a number. Aborting...");
      return;
    }

    try {
      b = Integer.parseInt(args[1]);
    } catch (NumberFormatException ex) {
      System.out.println(args[1] + " is not a number. Aborting...");
      return;
    }

    int sum = a + b;

    System.out.println(a + " + " + b + " = " + sum);
  }
}

以下是我们观察到的结果:

img

使用StringBuilder或StringBuffer进行字符串拼接

“+” 运算符是在 Java 中快速和简便地组合字符串的方法。在 Hibernate 和 JPA 时代之前,对象是手动持久化的,通过从头开始构建 SQL INSERT 语句来实现!以下是一个存储一些用户数据的示例:

String sql = "Insert Into Users (name, age)";
       sql += " values ('" + user.getName();
       sql += "', '" + user.getage();
       sql += "')";

很遗憾,当像上面那样连接多个字符串时,Java编译器必须创建多个中间字符串对象,然后将它们合并成最终连接的字符串。

相反,我们应该使用StringBuilderStringBuffer类。两者都包含函数,可以连接字符串而无需创建中间String对象,从而节省处理时间和不必要的内存使用。

以前的代码可以使用 StringBuilder 重写,如下所示:

StringBuilder sqlSb = new StringBuilder("Insert Into Users (name, age)");
sqlSb.append(" values ('").append(user.getName());
sqlSb.append("', '").append(user.getage());
sqlSb.append("')");
String sqlSb = sqlSb.toString();

这对开发者来说可能要费点事儿,但是这样做非常值得!

StringBuffer 与 StringBuilder

虽然StringBufferStringBuilder类都比“+”运算符更可取,但它们并不相同。StringBuilderStringBuffer更快,但不是线程安全的。因此,在非多线程环境中进行字符串操作时,应使用StringBuilder;否则,请使用StringBuffer类。

避免冗余初始化

尽管某些语言如TypeScript强烈建议在声明时初始化变量,但在Java中并非总是必要的,因为它在声明时将默认初始化值(如0falsenull)分配给变量。

因此,Java的最佳实践是要知道成员变量的默认初始化值,除非您想将它们设置为除默认值以外的其他值,否则不要显式初始化变量。

以下是一个计算从11000的自然数之和的短程序。请注意,只有部分变量被初始化:

class VariableInitializationExample {
  public static void main(String[] args) {

    // automatically set to 0
    int sum;
    final numberOfIterations = 1000;

    // Set the loop counter to 1
    for (int i = 1; i &= numberOfIterations; ++i) {
      sum += i;
    }

    System.out.println("Sum = " + sum);
  }
}

使用增强型for循环代替需要计数器的for循环

尽管 for 循环在某些情况下很有用,但是计数器变量可能会引起错误。例如,计数器变量可能会在稍后的代码中被无意中更改。即使从 1 而不是从 0 开始索引,也可能导致意外行为。出于这些原因,for-each 循环(也称为增强型 for 循环)可能是更好的选择。

考虑以下代码:

String[] names = {"Rob", "John", "George", "Steve"};
for (int i = 0; i < names.length; i++) {
  System.out.println(names[i]);
}

在这里,变量i既是循环计数器,又是数组names的索引。尽管这个循环只是打印每个名称,但如果下面有修改i的代码,就会变得棘手。我们可以通过使用下面所示的for-each循环轻松避开整个问题:

for (String name : names) {
  System.out.println(name);
}

使用增强的for循环,出错的机会要少得多!

合理处理空指针异常

空指针异常在Java中是一个非常常见的问题,可能是由于其面向对象的设计所致。当您试图在 Null 对象引用上调用方法时,就会发生 Null Pointer 异常。这通常发生在在类实例化之前调用实例方法的情况下,如下例所示:

Office office;

// later in the code...
Employee[] employees = office.getEmployees();

虽然你无法完全消除 Null Pointer Exceptions,但有方法可以将其最小化。一种方法是在调用对象的方法之前检查对象是否为 Null。以下是使用三元运算符的示例:

Office office;

// later in the code...
Employee[] employees = office == null ? 0 : office.getEmployees();

你可能还想抛出自己的异常:

Office office;
Employee[] employees;

// later in the code...
if (office == null) {
  throw new CustomApplicationException("Office can't be null!");
} else {
  employees = office.getEmployees();
}

Float或Double:应该使用哪个?

浮点数和双精度数是相似的类型,因此许多开发人员不确定该选择哪种类型。两者都处理浮点数,但具有非常不同的特性。例如,float的大小为32位,而double分配了64位的内存空间,因此double可以处理比float大得多的小数。然后有一个精度问题:float只能容纳7位精度。极小的指数大小意味着一些位是不可避免的丢失的。相比之下,double为指数分配了更多的位数,允许它处理高达15位精度。

因此,当速度比准确性更重要时,通常建议使用float。尽管大多数程序不涉及大量计算,但在数学密集型应用中,精度差异可能非常显著。当需要的小数位数已知时,float也是一个不错的选择。当精度非常重要时,double应该是你的首选。只需记住,Java强制使用double作为处理浮点数的默认数据类型,因此您可能需要附加字母"f"来明确表示float,例如,1.2f

单引号和双引号在字符串连接中的使用

在Java中,双引号(“)用于保存字符串,单引号用于字符(由char类型表示)。当我们尝试使用+连接运算符连接字符时,可能会出现问题。问题在于使用+连接字符会将char的值转换为ascii,从而产生数字输出。以下是一些示例代码,以说明这一点:

char a, b, c;
a = 'a';
b = 'b';
c = 'c';

str = a + b + c; // not "abc", but 294!

就像字符串连接最好使用StringBuilderStringBuffer类一样,字符连接也是如此!在上面的代码中,变量abc可以通过以下方式组合成一个字符串:

new StringBuilder().append(a).append(b).append(c).toString()

我们可以在下面观察到期望的结果:

img

避免Java中的内存泄漏

在Java中,开发人员并没有太多关于内存管理的控制权,因为Java通过垃圾回收自动地进行内存管理。尽管如此,有一些Java最佳实践可以帮助开发人员避免内存泄漏,例如:

  • 避免创建不必要的对象。
  • 避免使用"+"运算符进行字符串连接。
  • 避免在会话中存储大量数据。
  • 在会话不再使用时,及时让会话超时。
  • 避免使用静态对象,因为它们在整个应用程序的生命周期内存在。
  • 在与数据库交互时,不要忘记在finally块中关闭ResultSetStatementsConnection对象。

返回空集合而不是null引用。

你知道吗,null引用经常被称为软件开发中最严重的错误吗?1965年,Tony Hoare在设计面向对象语言(OOP)的引用的第一个全面类型系统时发明了null引用。后来在2009年的一次会议上,Hoare为自己的发明道歉,承认他的创造“导致了无数的错误、漏洞和系统崩溃,在过去四十年中可能造成了数十亿美元的痛苦和损失。”

在Java中,通常最好返回空值而不是null,特别是当返回集合、可枚举对象或对象时更为重要。尽管你自己的代码可能会处理返回的null值,但其他开发人员可能会忘记编写空值检查,甚至没有意识到null是可能的返回值!

以下是一些Java代码,它以ArrayList的形式获取库存中的书籍列表。但是,如果列表为空,则返回一个空列表:

private final List<Book> booksInStock = ...

public List<Book> getInStockBooks() {
  return booksInStock.isEmpty() ? Collections.emptyList() : new ArrayList<>(booksInStock);
}

这使得方法的调用者可以在不必首先检查null引用的情况下迭代列表:

(Book book: getInStockBooks()) {
 // do something with books
}

Java 中字符串的高效使用

我们已经讨论了使用 + 连接操作符可能产生的副作用,但还有其他一些方法可以更有效地使用字符串,以避免浪费内存和处理器周期。例如,在实例化 String 对象时,通常最好直接创建 String 而不是使用构造函数。原因是什么?使用直接创建 String 比使用构造函数更快(更不用说更少的代码!)。

这里是在Java中创建字符串的两种等效方式:直接创建和使用构造函数:

// directly
String str = "abc";
// using a constructor
char data[] = {'a', 'b', 'c'};
String str = new String(data);

虽然两种方法都是等效的,但直接创建字符串的方式更好。

Java中不必要的对象创建

你知道吗?在Java中,对象的创建是消耗内存最多的操作之一。因此,在没有充分理由的情况下,最好避免创建对象,并仅在绝对必要时才这样做。

那么,如何将这个实践起来呢?具有讽刺意味的是,像我们上面看到的直接创建字符串就是避免不必要创建对象的一种方式!

以下是一个更复杂的示例:

以下是一个Person类的例子,它包括一个isBabyBoomer()方法,用于判断此人是否属于“婴儿潮”年龄段,出生于1946年至1964年之间:

public class Person {
  private final Date birthDate;

  public boolean isBabyBoomer() {
    // Unnecessary allocation of expensive object!
    Calendar gmtCal =
        Calendar.getInstance(TimeZone.getTimeZone("GMT"));
    gmtCal.set(1946, Calendar.JANUARY, 1, 0, 0, 0);
    Date boomStart = gmtCal.getTime();
    gmtCal.set(1965, Calendar.JANUARY, 1, 0, 0, 0);
    Date boomEnd = gmtCal.getTime();

    return birthDate.compareTo(boomStart) >= 0 &&
           birthDate.compareTo(boomEnd)   <  0;
  }
}

isBabyBoomer()方法每次被调用都会创建一个新的CalendarTimeZone和两个Date实例,这是不必要的。纠正这种低效的方法之一是使用静态初始化器,以便只在初始化时创建CalendarTimeZoneDate对象,而不是每次调用isBabyBoomer()方法。

class Person {
  private final Date birthDate;

  // The starting and ending dates of the baby boom.
  private static final Date BOOM_START;
  private static final Date BOOM_END;

  static {
    Calendar gmtCal =
      Calendar.getInstance(TimeZone.getTimeZone("GMT"));
    gmtCal.set(1946, Calendar.JANUARY, 1, 0, 0, 0);
    BOOM_START = gmtCal.getTime();
    gmtCal.set(1965, Calendar.JANUARY, 1, 0, 0, 0);
    BOOM_END = gmtCal.getTime();
  }

  public boolean isBabyBoomer() {
    return birthDate.compareTo(BOOM_START) >= 0 &&
       birthDate.compareTo(BOOM_END)       <  0;
  }
}

Java中适当的注释

清晰简洁的注释在阅读其他开发人员的代码时非常有用。以下是写出高质量注释的几个指南:

  1. 注释不应该重复代码。
  2. 好的注释不能弥补代码不清晰的问题。
  3. 如果您无法编写清晰的注释,则代码可能存在问题。
  4. 在注释中解释不符合惯用方式的代码。
  5. 在最有用的地方包含指向外部参考文献的链接。
  6. 在修复错误时添加注释。
  7. 使用注释标记未完成的实现,通常使用标记“TODO:”开头。

总结

在本文中,我们了解了15个Java最佳实践,并探讨了类成员封装、在冗长的数字字面值中使用下划线、避免空catch块、正确完成字符串连接、如何避免冗余初始化以及使用增强的for循环。


【注】本文译自: Java Best Practices | Developer.com

重新学习Java线程原语

Synchronized曾经是一个革命性的技术,在当前仍然有重要的用途。但是,现在是时候转向更新的Java线程原语,同时重新考虑我们的核心逻辑。

自从Java第一个测试版以来,我就一直在使用它。从那时起,线程就是我最喜欢的特性之一。Java是第一种在编程语言本身中引入线程支持的语言。那是一个具有争议的决定。在过去的十年中,每种编程语言都竞相引入async/await,甚至Java也有一些第三方支持……但是Java选择了引入更优越的虚拟线程(Loom项目)。本文并不讨论这个问题。

我觉得这很好,证明了Java的核心实力。Java不仅仅是一种语言,还是一种文化。这种文化注重深思熟虑的变革,而不是盲目跟随时尚潮流。

在本文中,我想重新探讨Java中的线程编程旧方法。我习惯使用synchronized、wait、notify等技术。但是, “然而,这些方法已经不再是Java中线程处理的最佳方式。 我也是问题的一部分。我还是习惯于使用这些技术,发现很难适应自Java 5以来就存在的一些API。这是一种习惯的力量。 虽然可以讨论许多处理线程的出色API,但我想在这里专注讨论锁,因为它们是基础但极为重要的。

Synchronized 与 ReentrantLock

我犹豫放弃使用 synchronized 的原因是,并没有更好的替代方案。现在弃用 synchronized 的主要原因是,它可能会在 Loom 中触发线程固定,这并不理想。JDK 21 可能会修复这个问题(当 Loom 正式发布时),但还有一些理由弃用它。

synchronized 的直接替代品是 ReentrantLock。不幸的是,ReentrantLock 相比 synchronized 很少有优势,因此迁移的好处最多是存疑的。事实上,它有一个主要的缺点。为了了解这一点,让我们看一个例子。下面是我们如何使用 synchronized:

synchronized(LOCK) {
    // safe code
}

LOCK.lock();
try {
    // safe code
} finally {
    LOCK.unlock();
}

ReentrantLock 的第一个缺点是冗长。我们需要try块,因为如果在块内部发生异常,锁将保持。而 synchronized 则会自动处理异常。

有些人会使用 AutoClosable 对锁进行封装,大概是这样的:

public class ClosableLock implements AutoCloseable {
   private final ReentrantLock lock;

   public ClosableLock() {
       this.lock = new ReentrantLock();
   }

   public ClosableLock(boolean fair) {
       this.lock = new ReentrantLock(fair);
   }

   @Override
   public void close() throws Exception {
       lock.unlock();
   }

   public ClosableLock lock() {
       lock.lock();
       return this;
   }

   public ClosableLock lockInterruptibly() throws InterruptedException {
       lock.lock();
       return this;
   }

   public void unlock() {
       lock.unlock();
   }
}

注意,我没有实现 Lock 接口,这本来是最理想的。这是因为 lock 方法返回了可自动关闭的实现,而不是 void。

一旦我们这样做了,我们就可以编写更简洁的代码,比如这样:

try(LOCK.lock()) {
 // safe code
}

我喜欢代码更简洁的写法,但是这个方法存在一些问题,因为 try-with-resource 语句是用于清理资源的,而我们正在重复使用锁对象。虽然调用了 close 方法,但是我们会再次在同一个对象上调用它。我认为,将 try-with-resource 语法扩展到支持锁接口可能是个好主意。但在此之前,这个技巧可能不值得采用。

ReentrantLock 的优势

使用ReentrantLock的最大原因是Loom支持。其他的优点也不错,但没有一个是“杀手级功能”。

我们可以在方法之间使用它,而不是在一个连续的代码块中使用。但是这可能不是一个好主意,因为你希望尽量减少锁定区域,并且失败可能会成为一个问题。我不认为这个特性是一个优点。

ReentrantLock提供了公平锁(fairness)的选项。这意味着它会先服务于最先停在锁上的线程。我试图想到一个现实而简单的使用案例,但却无从下手。如果您正在编写一个复杂的调度程序,并且有许多线程不断地排队等待资源,您可能会发现一个线程由于其他线程不断到来而被“饥饿”。但是,这种情况可能更适合使用并发包中的其他选项。也许我漏掉了什么……

lockInterruptibly() 方法允许我们在线程等待锁时中断它。这是一个有趣的特性,但是很难找到一个真正实际应用场景。如果你编写的代码需要非常快速响应中断,你需要使用 lockInterruptibly() API 来获得这种能力。但是,你通常在 lock()方法内部花费多长时间呢?

这种情况可能只在极端情况下才会有影响,大多数人在编写高级多线程代码时可能不会遇到这种情况。

ReadWriteReentrantLock

更好的方法是使用ReadWriteReentrantLock。大多数资源都遵循频繁读取、少量写入的原则。由于读取变量是线程安全的,除非正在写入变量,否则没有必要加锁。这意味着我们可以将读取操作进行极致优化,同时稍微降低写操作的速度。

假设这是你的使用情况,你可以创建更快的代码。使用读写锁时,我们有两个锁,一个读锁,如下图所示。它允许多个线程通过,实际上是“自由竞争”的。

img

一旦我们需要写入变量,我们需要获得写锁,如下图所示。我们尝试请求写锁,但仍有线程从变量中读取,因此我们必须等待。

img

一旦所有线程完成读取,所有读取操作都会阻塞,写入操作只能由一个线程执行,如下图所示。一旦释放写锁,我们将回到第一张图中的“自由竞争”状态。

img

这是一种强大的模式,我们可以利用它使集合变得更快。一个典型的同步列表非常慢。它同步所有的操作,包括读和写。我们有一个CopyOnWriteArrayList,它对于读取操作非常快,但是任何写入操作都很慢。

如果可以避免从方法中返回迭代器,你可以封装列表操作并使用这个API。例如,在以下代码中,我们将名字列表暴露为只读,但是当需要添加名字时,我们使用写锁。这可以轻松超过synchronized列表的性能:

private final ReadWriteLock LOCK = new ReentrantReadWriteLock();
private Collection<String> listOfNames = new ArrayList<>();
public void addName(String name) {
   LOCK.writeLock().lock();
   try {
       listOfNames.add(name);
   } finally {
       LOCK.writeLock().unlock();
   }
}
public boolean isInList(String name) {
   LOCK.readLock().lock();
   try {
       return listOfNames.contains(name);
   } finally {
       LOCK.readLock().unlock();
   }
}

这个方案可行,因为synchronized是可重入的。我们已经持有锁,所以从methodA()进入methodB()不会阻塞。这在使用ReentrantLock时也同样适用,只要我们使用相同的锁或相同的synchronized对象。

StampedLock返回一个戳记(stamp),我们用它来释放锁。因此,它有一些限制,但它仍然非常快和强大。它也包括一个读写戳记,我们可以用它来保护共享资源。但ReadWriteReentrantLock不同的是,它允许我们升级锁。为什么需要这样做呢?

看一下之前的addName()方法…如果我用"Shai"两次调用它会怎样?

是的,我可以使用Set…但是为了这个练习的目的,让我们假设我们需要一个列表…我可以使用ReadWriteReentrantLock编写那个逻辑:

public void addName(String name) {
   LOCK.writeLock().lock();
   try {
       if(!listOfNames.contains(name)) {
           listOfNames.add(name);
       }
   } finally {
       LOCK.writeLock().unlock();
   }
}

这很糟糕。我“付出”写锁只是为了在某些情况下检查contains()(假设有很多重复项)。我们可以在获取写锁之前调用isInList(name)。然后我们会:

  • 获取读锁
  • 释放读锁
  • 获取写锁
  • 释放写锁

在两种情况下,我们可能会排队, 这样可能会增加额外的麻烦,不一定值得。

有了StampedLock,我们可以将读锁更新为写锁,并在需要的情况下立即进行更改,例如:

public void addName(String name) {
   long stamp = LOCK.readLock();
   try {
       if(!listOfNames.contains(name)) {
           long writeLock = LOCK.tryConvertToWriteLock(stamp);
           if(writeLock == 0) {
               throw new IllegalStateException();
           }
           listOfNames.add(name);
       }
   } finally {
       LOCK.unlock(stamp);
   }
}

这是针对这些情况的一个强大的优化。

终论

我经常不假思索地使用 synchronized 集合,这有时可能是合理的,但对于大多数情况来说,这可能是次优的。通过花费一点时间研究与线程相关的原语,我们可以显著提高性能。特别是在处理 Loom 时,其中底层争用更为敏感。想象一下在 100 万并发线程上扩展读取操作的情况…在这些情况下,减少锁争用的重要性要大得多。

你可能会想,为什么 synchronized 集合不能使用 ReadWriteReentrantLock 或者是 StampedLock 呢?

这是一个问题,因为API的可见接口范围非常大,很难针对通用用例进行优化。这就是控制低级原语的地方,可以使高吞吐量和阻塞代码之间的差异。


【注】本文译自: Relearning Java Thread Primitives – DZone