深入JDK 17的语法新特性

5 阅读30分钟

档案类

record 是 JDK 14 引入的关键字,用于声明不可变的数据类。它适用于存储纯粹的值类型数据,如接口传输数据、坐标点和只读的日志记录。与 lombok 相比,record 简化了定义纯粹数据类型的过程。由于 record 类是不可变的,成员变量只能设置一次且无法更改,无需提供显式的 setter() 方法。

相较于以往,在此之前大多数开发者使用Lombok来简化代码,但现在使用了record,你不需要使用任何第三方库,就可以将代码写的更加简洁。在下面的例子中,你可以看到用record代码会多么的简洁。

JDK 8

public class User {
    private String name;
    private String password;
​
    public User() {
​
    }
    
    public User(String name, String password) {
        this.name = name;
        this.password = password;
    }
​
    public String getName() {
        return name;
    }
​
    public void setName(String name) {
        this.name = name;
    }
​
    public String getPassword() {
        return password;
    }
​
    public void setPassword(String password) {
        this.password = password;
    }
}

使用lombok

@AllArgsConstructor
@Data
public class User {
    private String name;
    private String password;
}

使用档案类

public record User(String name,String password) {}

record类特点

record申明的类,具备这些特点:

  1. 它是一个final
  2. 自动实现equalshashCodetoString函数
  3. 成员变量均为public属性

适用场景

使用档案类替代普通DTO

DTO的特点是它不包含任何业务逻辑或行为。
如果DTO的属性在传输过程中发生变化,数据将不再准确。开发者也需要去理解DTO中的数据处理逻辑。

使用record 替代传统类的好处是record 类可以强制开发者不能在DTO中添加逻辑,而传统的方式更多的是一种规范需要开发者自觉遵守。
所以,DTO应该以无法更改的方式创建——它们应该是不可变的。

还有一个好处是如果增加了属性,立刻可以知道哪些代码位置需要修改如下图所示对应使用的地方会报错 image.png

档案类结合Spring Boot开发

我们只需要在record的参数里写上要被注入的bean,这个bean就会自动被注入如下所示

@Service
public record TestServiceImpl2(UserRepository userRepository) implements TestService {

    @Override
    public String test() {
        return userRepository.test();
    }
}

代码上比属性@Autowired注入,甚至构造器注入代码更简洁。

封闭类

封闭类是Java 17中加入的一个新特性。它允许你将一个类或接口的继承限制在一组有限的子类中。

JDK 17之前的Java语言,限制住可扩展性只有两个方法,使用私有类或者 final 修饰符。私有类不是公开接口,只能内部使用;而 final 修饰符彻底放弃了可扩展性。要么全开放,要么全封闭,可扩展性只能在可能性的两个极端。全封闭彻底没有了可扩展性,全开放又面临固有的安全缺陷。

JDK 17之后,有了第三种方法。这个办法,就是使用Java的sealed关键字。使用类修饰符sealed修饰的类是封闭类;使用类修饰符sealed修饰的接口是封闭接口。封闭类和封闭接口限制可以扩展或实现它们的其他类或接口。

封闭类基本使用

声明一个封闭类

封闭类的声明使用 sealed 类修饰符,然后在所有的 extends 和 implements 语句之后,使用 permits 指定允许扩展该封闭类的子类。 比如,使用 sealed 类修饰符,我们可以把形状这个类声明为封闭类。下面的这个例子中,Shape是一个封闭类,可以扩展它的子类只有两个,分别为Circle和Square。也就是说,这里定义的形状这个类,只允许有圆形和正方形两个子类。

public abstract sealed class Shape permits Square, Circle {
    public final String id;

    public Shape(String id) {
        this.id = id;
    }

    public abstract double area();
}

声明一个许可类

许可类的声明需要满足下面的三个条件:

  • 许可类必须和封闭类处于同一模块(module)或者包空间(package)里,也就是说,在编译的时候,封闭类必须可以访问它的许可类;
  • 许可类必须是封闭类的直接扩展类;
  • 许可类必须声明是否继续保持封闭:
    • 许可类可以声明为终极类(final),从而关闭扩展性;
    • 许可类可以声明为封闭类(sealed),从而延续受限制的扩展性;
    • 许可类可以声明为解封类(non-sealed), 从而支持不受限制的扩展性。

比如在下面的例子中,许可类 Circle 是一个解封类;许可类 Square 是一个封闭类;许可类 ColoredSquare 是一个终极类;而 ColoredCircle 既不是封闭类,也不是许可类。

public non-sealed class Circle extends Shape {
    public final double radius;

    public Circle(String id, double radius) {
        super(id);
        this.radius = radius;
    }

    @Override
    public double area() {
        return Math.PI * radius * radius;
    }
}
public sealed class Square extends Shape permits ColoredSquare {
    public final double side;

    public Square(String id, double side) {
        super(id);
        this.side = side;
    }

    @Override
    public double area() {
        return side * side;
    }
}
public final class ColoredSquare extends Square {
    public ColoredSquare(String id, double side) {
        super(id, side);
    }
}

适用场景

  1. 我们需要对继承关系进行限制
  2. 当我们突破限制的时候需要显式的进行,让用户知晓潜在的风险。

instanceof类型匹配

类型匹配这个特性在JDK 16正式发布。

传统代码实现

static boolean isSquare(Shape shape) {
    if (shape instanceof Rectangle) {
        Rectangle rect = (Rectangle) shape;
        return (rect.length == rect.width);
    }
    return (shape instanceof Square);
}

传统的代码把类型判断和类型转换切割成两个部分,增加了错误潜入的机会。比如说,无意地使用了错误的类型。下面例子中的两段代码,就是两个常见的类型转换错误。第一段代码误用了变量类型,第二段代码误用了判断结果。

if (shape instanceof Circle) {
    Rectangle rect = (Rectangle) shape;
        return (rect.length == rect.width);
}

类型判断之后,我们原本就可以开始关注更重要的后续代码逻辑了,但现在不得不停下来编写类型转化代码,或者审视类型转换代码是否恰当。这就影响了生产效率

类型匹配的代码

if (shape instanceof Rectangle rect) {
    return (rect.length == rect.width);
}

这个基本逻辑就是:如果目标变量是一个长方形的实例,那么这个目标变量就会被赋值给一个本地的长方形变量,也就是我们所说的匹配变量;相反,如果目标变量不是一个长方形的实例,那么这个匹配变量就不会被赋值。所以为了简化代码逻辑,减少代码错误,提高生产效率,我们应该优先考虑使用类型匹配,而不是传统的强制类型转换运算符。

switch表达式

switch表达式这个特性在JDK 14正式发布。

案例

用代码计算一个月中有多少天(用Switch语句实现)

传统代码实现方式

public static void main(String[] args) {
    Calendar today = Calendar.getInstance();
    int month = today.get(Calendar.MONTH);
    int year = today.get(Calendar.YEAR);

    int daysInMonth;
    switch (month) {
        case Calendar.JANUARY:
        case Calendar.MARCH:
        case Calendar.MAY:
        case Calendar.JULY:
        case Calendar.AUGUST:
        case Calendar.OCTOBER:
        case Calendar.DECEMBER:
            daysInMonth = 31;
            break;
        case Calendar.APRIL:
        case Calendar.JUNE:
        case Calendar.SEPTEMBER:
        case Calendar.NOVEMBER:
            daysInMonth = 30;
            break;
        case Calendar.FEBRUARY:
            if (((year % 4 == 0) && !(year % 100 == 0))
                    || (year % 400 == 0)) {
                daysInMonth = 29;
            } else {
                daysInMonth = 28;
            }
            break;
        default:
            throw new RuntimeException(
                "Calendar in JDK does not work");
    }

    System.out.println(
        "这个月有" + daysInMonth + "天");
}

上述代码是个正确的代码,但是会有容易犯错的地方

第一个容易犯错的地方,就是在break关键字的使用上。上面的代码里,如果多使用一个break关键字,代码的逻辑就会发生变化;同样的,少使用一个break关键字也会出现问题。所以阅读代码的时候,需要反复地查验break语句的前后语境。毫无疑问,这增加了代码维护的成本,降低了生产效率。

第二个容易犯错的地方,是反复出现的赋值语句。 在上面的代码中,daysInMonth这个本地变量的变量声明和实际赋值是分开的。赋值语句需要反复出现,以适应不同的情景。如果在switch语句里,daysInMonth变量没有被赋值,编译器也不会报错,缺省的或者初始的变量值就会被使用。

使用switch表达式实现

Calendar today = Calendar.getInstance();
int month = today.get(Calendar.MONTH);
int year = today.get(Calendar.YEAR);

int daysInMonth = switch (month) {
    case Calendar.JANUARY,
            Calendar.MARCH,
            Calendar.MAY,
            Calendar.JULY,
            Calendar.AUGUST,
            Calendar.OCTOBER,
            Calendar.DECEMBER -> 31;
    case Calendar.APRIL,
            Calendar.JUNE,
            Calendar.SEPTEMBER,
            Calendar.NOVEMBER -> 30;
    case Calendar.FEBRUARY -> {
        if (((year % 4 == 0) && !(year % 100 == 0))
                || (year % 400 == 0)) {
            yield 29;
        } else {
            yield 28;
        }
    }
    default -> throw new RuntimeException(
            "Calendar in JDK does not work");
};
System.out.println(
        "这个月有" + daysInMonth + "天");

我们最先看到的变化是多情景的合并。也就是说,一个case语句,可以处理多个情景。 这些情景,使用逗号分隔开来,共享一个代码块。而传统的switch代码,一个case语句只能处理一种情景。

下一个变化,是一个新的情景操作符,“->”,它是一个箭头标识符。这个符号使用在case语句里,一般化的形式是“case L ->” 。这里的L,就是要匹配的一个或者多个情景。如果目标变量和情景匹配,那么就执行操作符右边的表达式或者代码块。如果要匹配的情景有两个或者两个以上,就要使用逗号“,”用分隔符把它们分割开来。

下一个我们看到的变化,是箭头标识符右侧的数值。这个数值,代表的就是该匹配情景下,switch表达式的数值。 需要注意的是,箭头标识符右侧可以是表达式、代码块或者异常抛出语句,而不能是其他的形式。 如果只需要一个语句,这个语句也要以代码块的形式呈现出来。如下所示:

case Calendar.JANUARY,
        Calendar.MARCH,
        Calendar.MAY,
        Calendar.JULY,
        Calendar.AUGUST,
        Calendar.OCTOBER,
        Calendar.DECEMBER -> {
    //处理自己的逻辑
    yield 31;
}

箭头标识符右侧需要一个表达switch表达式的数值,这是一个很强的约束。如果一个语句破坏了这个需要,它就不能出现在switch表达式里。比如,下面的代码里的return语句,意图退出该方法,而没有表达这个switch表达式的数值。这段代码就不能通过编译器的审查 image.png

下一个我们看到的变化,是出现了一个新的关键字“yield” 。大多数情况下,switch 表达式箭头标识符的右侧是一个数值或者是一个表达式。 如果需要一个或者多个语句,我们就要使用代码块的形式。这时候,我们就需要引入一个新的 yield 语句来产生一个值,这个值就成为这个封闭代码块代表的数值。 为了便于理解,我们可以把yield语句产生的值看成是switch表达式的返回值。所以,yield只能用在switch 表达式里,而不能用在switch语句里。

最后一个我们能够看到的变化,在switch表达式里,所有的情景都要列举出来,不能多、也不能少(这也就是我们常说的穷举)。
比如说,在上面的例子里,如果没有最后的default情景分支,编译器就会报错。它会使得switch表达式的代码更加健壮,大幅度降低维护成本。

总结

  • break语句只能出现在switch语句里,不能出现在switch表达式里;
  • yield语句只能出现在switch表达式里,不能出现在switch语句里;
  • switch表达式需要穷举出所有的情景,而switch语句不需要情景穷举;
  • 使用冒号标识符的swtich形式,支持情景间的fall-through;而使用箭头标识符的swtich形式不支持fall-through;
  • 使用箭头标识符的swtich形式,一个case语句支持多个情景;而使用冒号标识符的swtich形式不支持多情景的case语句。

模块化

Java平台模块系统是在JDK 9正式发布的。 模块系统通过requires和exports关键字,对自身所依赖(requires)的模块和自身暴露(exports)出去的内容(package)进行了声明。本模块只能使用其他模块暴露(exports)出来的内容(package),其他模块也只能使用本模块暴露(exports)出去的内容(package)。

为什么Java需要模块化

精简jvm加载的class类,提升加载速度

java8中有个非常重要的包rt.jar,里面涵盖了java提供的类文件,在程序员运行java程序时jvm会加载rt.jar。这里有个问题是rt.jar中的某些文件我们是不会使用的,比如使用java开发服务器端程序的时候通常用不到图形化界面的库java.awt),这就造成了内存的浪费。

java9中将rt.jar分成了不同的模块,一个模块下可以包含多个包,模块之间存在着依赖关系,其中java.base模块是基础模块,不依赖其他模块。上面提到的java.awt被放到了其他模块下,这样在不使用这个模块的时候就无需让jvm加载,减少内存浪费。让jvm加载程序的必要模块,并非全部模块,达到了瘦身效果。 java%E6%A8%A1%E5%9D%97%E5%8C%96.png

对包更精细的控制,提高安全

Java包之间的关系,要么全开放,要么全封闭这么简单。这种情况带来的后果会让本来只应该由某个公开接口独立使用的代码变得所有人都可以使用了。

举个例子,为了优化我们的系统我们会将经常访问但不经常变化的对象存储在 Redis 中,以减少数据库查询次数,这个时候通常我们会将这个通用逻辑放到通用逻辑层,此时service层在对数据进行增删改查的时候我们是希望开发者只调用通用逻辑层的代码,但是又因为数据持久层的类需要被通用逻辑层引用所以我们不得不公开数据持久层的类,这就导致了有可能数据持久层类被service层调用。就可能会造成大量的请求转移到了数据库上,数据库的负载会显著增加。导致数据库服务器的性能下降,甚至可能引发数据库崩溃,尤其是在高并发场景下。

JDK 9之前的Java语言没有描述和定义包之间的依赖关系,也没有描述和定义基于包的依赖关系的访问控制规则。 这是一个缺失的访问控制。
这种缺失的关系,可能会带来严重的后果。

就是因为更精细的控制也提高了我们程序的规范化,比如我们提倡的面向接口编程,要求在controller中只能注入service层的接口,而不能直接注入其实现类,但是这个要求只是个规范,无法强制约束,Java9以前,我们仍然可以在直接注入service层的实现类,代码仍然可以照常运行,只是没那么规范而已。但是在Java9以后,我们可以在service的模块中只exports出接口,这样controller就无法直接注入实现类,在编译期就会报错,实现了强约束。

模块化的使用

模块声明

通过在一个模块的源代码文件package的根部,添加文件module-info.java来声明该文件夹及其子文件夹为一个模块。每个模块声明以关键字 module 开头,紧接着一个专属的模块名称,以及括在括号中的模块主体内容,该文件语法如下:

module moduleName {
    ...
}

exports 及 exports…to

exports 及 exports…to。 exports 模块指令指定模块的一个包,其 public 类型应可供所有其他模块的代码访问。通过 exports…to 指令,您可以在逗号分隔的列表中指定哪个模块或模块的代码可以访问导出的包,这就是所谓的限定导出。

module moduleA {
    exports org.example.moduleA;
    exports org.example.moduleA to moduleB;
}

requires

requires。 requires 模块指令指定此模块依赖于另一个模块,此关系称为模块依赖关系。每个模块必须明确地指出其依赖项。当模块 A requires 模块 B 时,模块 A 称为读取模块 B,模块 B 则是给模块 A 读取。如需指定对其他模块的依赖性,使用 requires,如下所示:

module moduleA {
    requires moduleB;
}

uses

使用 uses 关键字,我们可以指定我们的模块需要或使用某些服务。这个服务通常是一个接口或抽象类。它不应该是一个实现类。
uses只能从模块自己的包中或者requiresrequires static以及requires transitive传递过来的接口或者抽象类。

module moduleB {
    requires moduleA;
    uses org.example.service.TestService;
}

provides…with

provides…with 模块指令指定模块提供服务实施,使模块成为服务提供者。指令的 provides 部分指定模块的 uses 指令指令中列出的接口或 abstract 类;指令的 with 部分指定 implements 接口或 extends abstract类的服务提供类的名称。 假设有一个名为 com.example.service 的服务接口:

package com.example.service;

public interface TestService {
    void test();
}

现在,我们有一个模块 com.example.service.impl,它提供了 TestService 接口的一个实现:

package com.example.service.impl;

import com.example.service.TestService;

public class TestServiceImpl implements TestService {
    @Override
    public void test() {
        System.out.println("Hello");
    }
}

module-info.java 文件中使用 provides 关键字声明;
provides com.example.service.TestService 表示该模块提供了 TestService 接口的实现。with com.example.service.impl.TestServiceImpl 则表示使用 TestServiceImpl 类作为该服务的具体实现。

这样,当其他模块需要使用 TestService 服务时,Java 运行时环境会根据模块描述文件中的信息将提供服务的模块与使用服务的模块联系起来,并将服务的实现注入到相应的地方。

module com.example.provider {
    provides com.example.service.TestService
        with com.example.service.impl.TestServiceImpl;
}

open、opens 及 opens…to

模块中open(开放)的包(或者整个open的模块) ,这种类型的模块允许其他java程序深度反射的操作,所谓深度反射意味着其他java可以完全的反射利用这个module里面的类,包括里面的private类和方法。如果有必要,还可以指定开放给哪一个或者一些module。

如果一个module-info中有如下的声明,open声明在整个module上面,那么这个模块是开放的:

open module Demo {
}

如果module-info中有如下定义,则指定的包是开放的:

module Demo {
   // opens声明在包上面
   opens org.demo.test;
   // opens给example,除此之外的包不能使用它。
   opens org.demo.test to example;
}

私有接口方法

从Java8开始,允许在interface里面添加默认方法,这里一个小问题,如果一个default方法体很大怎么办,拆到另外的类去写吗?实在有些不太合理,所以在JDK 17里面,如果一个default方法体很大,那么可以通过新增接口私有方法来进行一个合理的拆分了。

public interface MyInterface {
    //定义私有方法
    private void m1() {
        System.out.println("123");
    }
    
    //default中调用
    default void m2() {
        m1();
    }
}

集合工厂方法

List,Set 和 Map 接口中,新的静态工厂方法可以创建不可变集合

List.of() 方法用于创建不可变的列表对象。例如

List<String> list = List.of("apple", "banana", "orange");

在这个示例中,我们使用 List.of() 方法创建一个包含三个元素的不可变列表。

Set.of() 方法用于创建不可变的集合对象。例如:

Set<String> set = Set.of("apple", "banana", "orange");

在这个示例中,我们使用 Set.of() 方法创建一个包含三个元素的不可变集合。

Map.of() 方法用于创建不可变的键值对映射。例如:

Map<String, Integer> map = Map.of("apple", 1, "banana", 2, "orange", 3);
Map<String, Integer> map = Map.ofEntries(
        entry("apple", 1),
        entry("banana", 2),
        entry("orange", 3)
);

这些集合工厂方法的特点是它们创建的集合对象都是不可变的,即无法修改集合中的元素。这种不可变性有助于编写更加健壮和可靠的代码,并提供更好的线程安全性。 需要注意的是,这些集合工厂方法创建的集合对象是不可变的,因此不能对其进行添加、删除或修改元素的操作。如果需要对集合进行修改操作,仍然可以使用传统的构造函数或其他方法来创建可变的集合对象。

与asList的区别

看到这里,可能有的人会问了,之前不是对于集合有asXxx这样的方便方法了么?他们有啥区别吗?

  1. Java 9中推出List.of创建的是不可变集合,而Arrays.asList是可变集合
  2. List.ofArrays.asList都不允许add和remove元素,但Arrays.asList可以调用set更改值,而List.of不可以,会报java.lang.UnsupportedOperationException异常
  3. List.of中不允许有null值,Arrays.asList中可以有null值

Stream API 改进

增加takeWhile, dropWhile, ofNullable, iterate以及toList的API

// takeWhile 顺序返回符合条件的值,直到条件不符合时即终止继续判断,
// 此外toList方法的加入,也大大减少了节省了代码量,免去了调用collect(Collectors::toList)方法了
List<Integer> list = Stream.of(2,2,3,4,5,6,7,8,9,10)
        .takeWhile(i->(i%2==0)).toList(); // 返回2, 2

// dropWhile 顺序去掉符合条件的值,直到条件不符合时即终止继续判断
List<Integer> list1 = Stream.of(2,2,3,4,5,6,7,8,9,10)
        .dropWhile(i->(i%2==0)).toList(); //返回3, 4, 5, 6, 7, 8, 9, 10

// ofNullable,支持传入空流,如果不使用ofNullable且传入一个空流,那么将会抛NPE
var nullStreamCount = Stream.ofNullable(null).count(); //返回0

// 以下两行都将输出0到9
Stream.iterate(0, n -> n < 10, n -> n + 1).forEach(x -> System.out.println(x));
Stream.iterate(0, n -> n + 1).limit(10).forEach(x -> System.out.println(x));

Stream.toList()和Collectors.toList()的区别

通过查看Stream.toList()的源码:

default List<T> toList() {
    return (List<T>) Collections.unmodifiableList(new ArrayList<>(Arrays.asList(this.toArray())));
}

我们可以发现,它所创建的是一个unmodifiableList不可变的List。 而使用Stream.collect(Collectors.toList())创建出来的则是一个普通的List,是可以做增删改操作的。

那么如果用Collectors也要创建不可变的List要怎么写呢?其实也很简单,只需要调用Collectors.toUnmodifiableList()就可以了。所以与本文开头等价代码替换可以这样写:

List<String> result = list.stream()
    .filter(e -> e.contains("didispace.com"))
    .filter(e -> e.length() > 17)
    .collect(Collectors.toUnmodifiableList());

String API 改进

  • strip方法,可以去除首尾空格,与之前的trim的区别是还可以去除unicode编码的空白字符,例如:
    char c = '\u2000';//Unicdoe空白字符
    String str = c + "abc" + c;
    System.out.println(str.strip());
    System.out.println(str.trim());
    
    System.out.println(str.stripLeading());//去除前面的空格
    System.out.println(str.stripTrailing());//去除后面的空格
    
    image.png
  • isBlank方法,判断字符串长度是否为0,或者是否是空格,制表符等其他空白字符(不用在引入第三方库就可以实现字符串判空了)
    String str = " ";
    System.out.println(str.isBlank());
    
  • repeat:重复生成字符串
    String str = "Java ";  
    // 重复3次
    String repeatedStr = str.repeat(3);
    System.out.println(repeatedStr);  // 输出: Java Java Java 
    
  • lines:能根据一段字符串中的终止符提取出行为单位的流
    String multiLineString = "line1\nline2\nline3";
    multiLineString.lines()
            .forEach(System.out::println);
    
  • indent:给字符串做缩进,接受一个int型的输入
  • transform:接受一个转换函数,实现字符串的转换
    String str= "hello".transform((s)->{ return s+":你好";});
    

String字符串的变化

写程序的时候会经常用到String字符串,在以前的版本中String内部使用了char数组存储,对于使用英语的人来说,一个字符用一个字节就能存储,使用char存储字符会浪费一半的内存空间,因此在jdk9中将String内部的char数组改成了byte数组,这样就节省了一半的内存占用。

char c = 'a';//2个字节
byte b = 97;//1个字节

我们可以看一下源码: image.png image.png String中增加了下面2个成员变量

  • COMPACT_STRINGS:判断是否压缩,默认是true,若为false,则不压缩,使用UTF16编码。
  • coder用来区分使用的字符编码,分别为LATIN1(值为0)和UTF16(值为1)。

byte数组如何存储中文呢?通过源码(StringUTF16类中的toBytes方法)得知,在使用中文字符串时,1个中文会被存储到byte数组中的两个元素上,即存储1个中文,byte数组长度为2,存储2个中文,byte数组长度为4。

以如下代码为例进行分析
String str = "好" 好对应的Unicode码二进制为0101100101111101,分别取出高8位和低8位,放入到byte数组中{01011001,01111101},这样就利用byte数组的2个元素保存了1个中文。 String%E5%AD%98%E5%82%A8%E4%B8%AD%E6%96%87%E5%88%86%E6%9E%90.png 当字符串中存储了中英混合的内容时,1个英文字符会占用2个byte数组位置,例如下面代码底层byte数组的长度为14:

String str = "土豆hello";

在获取字符串长度时,若存储的内容存在中文,是不能直接获取byte数组的长度作为字符串长度的,String源码中有向右移动1位的操作(即除以2),这样才能获取正确的字符串长度。(这里是底层源码帮我们做好的事情,所以我们只需要知道就行)

改进的try with resource

java7中新增了try with resource语法用来自动关闭资源文件,在IO流和jdbc部分使用的比较多。使用方式是将需要自动关闭的资源对象的创建放到try后面的小括号中,在jdk9中我们可以将这些资源对象的创建代码放到小括号外面,然后将需要关闭的对象名放到try后面的小括号中即可,示例:

/*
    改进了try-with-resources语句,可以在try外进行初始化,在括号内填写引用名,即可实现资源自动关闭
 */
public class TryWithResource {
    public static void main(String[] args) throws FileNotFoundException {
        //jdk8以前
        try (FileInputStream fileInputStream = new FileInputStream("");
             FileOutputStream fileOutputStream = new FileOutputStream("")) {

        } catch (IOException e) {
            e.printStackTrace();
        }

        //jdk9
        FileInputStream fis = new FileInputStream("");
        FileOutputStream fos = new FileOutputStream("");
        //多资源用分号隔开
        try (fis; fos) {
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

根据上述代码可以看出将所有的文件实例化的操作放在try的小括号中会使我们的代码看起来非常的臃肿,JDK 9中的这个小改变可以让我们的代码更加的优雅。

不能使用下划线命名变量

下面语句在jdk9之前可以正常编译通过,但是在jdk9(含)之后编译报错,在后面的版本中会将下划线作为关键字来使用

String _ = "tudou";

JDK 9之前: image.png 这个时候IDEA 已经提示了在JDK 9就不能使用了

局部变量类型推断

在jdk10以前声明变量的时候,我们会像下面这样:

String oldName = "jack";
int oldAge = 10;
long oldMoney = 88888888L;
Object oldObj = new Object();

上面我们声明的时候使用了4种不同类型的变量,在jdk10中前面的类型都可以使用var来代替,JVM会自动推断该变量是什么类型的,例如可以这样写:

var newName = "jack";
var newAge = 10;
var newMoney = 88888888L;
var newObj = new Object();

注意:

当然这个var的使用是有限制的,仅适用于局部变量,增强for循环的索引,以及普通for循环的本地变量;它不能使用于方法形参,构造方法形参,方法返回类型等。

空指针异常

String s = null;
String s1 = s.toLowerCase();

上述代码是一个会造成空指针异常的代码

JDK 8版本下运行

image.png

JDK 17版本下运行

image.png 对比一下,我们可以看到,JDK 17的异常信息里,包含了调用者(REPL.JShellJShell11.a)和被调用者(String.equals(Object))的信息;而JDK 8里,调用者的信息需要从调用堆栈里寻找,而且没有被调用者的信息。

这是空指针异常的一个小的改进。它简化了问题排查的流程,提高了问题排查的效率。

文字块

文字块这个特性,首先在JDK 13中以预览版的形式发布。在JDK 14中,改进的文字块再次以预览版的形式发布。最后,文字块在JDK 15正式发布。

文字块的概念很简单,它是一个由多行文字构成的字符串。

多行字符串案例

构造一个简单的表示"Hello,World!"的HTML字符串

JDK 8中的呈现形式

String stringBlock =
        "<!DOCTYPE html>\n" +
                "<html>\n" +
                "    <body>\n" +
                "        <h1>"Hello World!"</h1>\n" +
        "    </body>\n" +
        "</html>\n";

在JDK 8中我们要构造一个简单的表示"Hello,World!"的HTML字符串,就需要处理好文本对齐、换行字符、连接符以及双引号的转义字符。这就使得这段代码既不美观、也不简约,一点都不自然

这样的字符串不好写,不好看,也不好读。更糟糕的是,我们有时候需要从别的地方拷贝一段 HTML 或者 SQL 语句,然后再转换成类似于上面的字符串。是不是出力多,收效少,需要特别的耐心。

JDK 17中的呈现形式

String textBlock = """
        <!DOCTYPE html>
        <html>
            <body>
                <h1>"Hello World!"</h1>
            </body>
        </html>
        """;

对比一下阅读案例里的代码,我们可以看到,下面的这些特殊的字符从这个表达式里消失了:

  1. 换行字符(\n)没有出现在文字块里;
  2. 连接字符(+)没有出现在文字块里;
  3. 双引号没有使用转义字符(\)。

由于文字块不再需要特殊字符、开始分隔符和结束分隔符这些格式安排,我们几乎就可以直接拷贝、粘贴看到的文字,而不再需要特殊的处理了。同样地,你在代码里看到的文字块是什么样子,它实际要表达的文字就是什么样子的。这也就是说,“所见即所得”。

文本块的使用

首先是文字块的组成部分:
文字块由零个或多个内容字符组成,从开始分隔符开始,到结束分隔符结束。开始分隔符是由三个双引号字符 (“”“) ,后面跟着的零个或多个空格,以及行结束符组成的序列。结束分隔符是一个由三个双引号字符 (”“”)组成的序列。

需要注意的是,开始分隔符必须单独成行;三个双引号字符后面的空格和换行符都属于开始分隔符。 所以,一个文字块至少有两行代码。即使是一个空字符,结束分隔符也不能和开始分隔符放在同一行代码里。

jshell> String s = """""""
|  错误:
|  文本块起始分隔符序列非法,缺少行终止符
|  String s = """""""
|                ^

1. 文本块的编译过程

像传统的字符串一样,文字块是字符串的一种常量表达式。不同于传统字符串的是,在编译期,文字块要顺序通过如下三个不同的编译步骤:

  1. 为了降低不同平台间换行符的表达差异,编译器把文字内容里的换行符统一转换成 LF(\u000A);
  2. 为了能够处理Java源代码里的缩进空格,要删除所有文字内容行和结束分隔符共享的前导空格,以及所有文字内容行的尾部空格;
  3. 最后处理转义字符,这样开发人员编写的转义序列就不会在第一步和第二步被修改或删除。
    看一个例子
String stringBlock =
        "<!DOCTYPE html>\n" +
                "<html>\n" +
                "    <body>\n" +
                "        <h1>"Hello World!"</h1>\n" +
                "    </body>\n" +
                "</html>\n";

String textBlock = """
        <!DOCTYPE html>
        <html>
            <body>
                <h1>"Hello World!"</h1>
            </body>
        </html>
        """;

System.out.println(stringBlock.equals(textBlock));
System.out.println(stringBlock == textBlock);

发现使用传统方式声明的字符串和使用文字块声明的字符串,它们的内容是一样的,而且指向的是同一个对象。

这也就说明了,文字块是在编译期处理的,并且在编译期被转换成了常量字符串,然后就被当作常规的字符串了。所以,如果文字块代表的内容,和传统字符串代表的内容一样,那么这两个常量字符串变量就指向同一内存地址,代表同一个对象。
我们可以看一下编译后的结果

String stringBlock = "<!DOCTYPE html>\n<html>\n    <body>\n        <h1>"Hello World!"</h1>\n    </body>\n</html>\n";
String textBlock = "<!DOCTYPE html>\n<html>\n    <body>\n        <h1>"Hello World!"</h1>\n    </body>\n</html>\n";

虽然表达形式不同,但是编译后的结果都是一样的,即文字块就是字符串。既然是字符串,就能够使用字符串支持的各种API和操作方法。

int stringSize = """
<!DOCTYPE html>
<html>
    <body>
        <h1>"Hello World!"</h1>
    </body>
</html>
""".length();
System.out.println(stringSize);

2. 文本块的空格处理

上文编译过程的第二个步骤中有说 **要删除所有文字内容行和结束分隔符共享的前导空格,以及所有文字内容行的尾部空格;**如下所示

String textBlock = """
........<!DOCTYPE html>
........<html>
........    <body>!!!
........        <h1>"Hello World!"</h1>
........    </body>
........</html>
........""";

.就代表着共享的前导空格,!就代表尾部空格。
需要注意的是结束分隔符除了用来结束文字块之外,还参与界定共享的前导空格
所以如果你想要整个文本块添加缩进可以将结束分隔符前移如下:

String textBlock = """
......  <!DOCTYPE html>
......  <html>
......      <body>!!!
......          <h1>"Hello World!"</h1>
......      </body>
......  </html>
......""";

这样这个字符串的整体就添加了缩进打印如下图所示: image.png

需要注意的是如果结束分隔符放在文本内容左对齐位置的右侧这时候,结束分隔符的左侧,除了共享的前导空格之外,还有多余的空格。这些多余的空格,就成了文字内容行的尾部空格,它们在编译期会被删除掉。

........<!DOCTYPE html>
........<html>
........    <body>
........        <h1>"Hello World!"</h1>!!!!
........    </body>
........</html>
........!!!!""";

总结

文字块是 Java 语言中一种新的文字。 字符串能够出现的任何地方,也都可以用文字块表示。但是,文字块提供了更好的表现力和更少的复杂性。 文字块“所见即所得”的表现形式,使得使用复杂字符串的代码更加清晰,便于编辑,也便于阅读。这是一个能够降低代码错误,提高生产效率的改进。

JShell

Java Shell工具(JShell)是用于学习Java编程语言和Java代码原型的交互式工具。它在输入声明,语句和表达式时对它们进行评估,并立即显示结果。该工具从命令行运行。

jshell 支持 Java 类、接口、enum,可以定义变量、方法,可以执行表达式、语句

JShell的基本使用

  1. JShell的启动
    终端输入jshell

    MacBook-Pro:example tudou$ jshell
     |  欢迎使用 JShell -- 版本 21.0.1
     |  要大致了解该版本, 请键入: /help intro
    
  2. JShell停止
    终端输入/exit

    jshell> /exit
    |  再见
    
  3. 创建类并调用类中的实例方法

    jshell> public class Demo {
      ...>     public int add(int a, int b){
      ...>         return a + b;
      ...>     }
      ...> }
    |  已创建 类 Demo
    
    jshell> Demo demo = new Demo()
    demo ==> Demo@66a29884
    
    jshell> demo.add(10,20)
    $3 ==> 30
    
    
  4. 声明变量、创建方法、调用方法

    jshell> int a = 10
    a ==> 10
    
    jshell> int b = 20
    b ==> 20
    
    jshell> int add(int a, int b){
       ...>         return a + b;
       ...>     }
    |  已创建 方法 add(int,int)
    
    jshell> add(a,b)
    $7 ==> 30
    

总结

使用 JShell 工具,我们可以一次输入一个程序元素,立即看到执行结果,并根据需要进行调整。 而一个完整的Java应用程序开发步骤通常纷繁复杂,一般流程如下:

  1. 首先写一个完整的应用程序。
  2. 编译该应用程序并修复各种BUG和优化调优等,使其编译通过。
  3. 启动运行程序。
  4. 运行并发现其存在的问题。
  5. 编辑应用程序。
  6. 重复上面这个过程直至最后的交付。

JShell 则可以帮助我们在开发程序时调试代码并轻松地进行各方面的探索。可以测试单个语句,也可以测试不同的方法体,并在JShell Session 中调试不熟悉的 API。但是JShell的设计并不是为了取代IDE。JShell 在处理简单的小逻辑,验证简单的小问题时,比IDE更有效率。在开发程序的过程中,可以将代码片段粘贴到 JShell Session 中进行试运行,测试通过后,再将 JShell 中的代码片段粘贴回编辑器或 IDE 中,从而做到快速调试,快速发现问题等,这样一来就节省了不少的开发时间,在一定意义上来讲,就是缩短了程序的开发周期。