写点什么

实操 | 剖析 Java16 新语法特性

用户头像
九叔
关注
发布于: 2021 年 03 月 21 日
实操 | 剖析 Java16 新语法特性

前言

3 月 16 日,Java 社区正式发布了 Java16 的 GA 版本。这意味着大家欠 Oracle 的技术债开始变得越来越多。在半年前的剖析Java15新语法特性一文中,我为大家详细介绍了 Java15 的新语法全貌;而本文,我会紧接上一篇博文的内容,继续为大家剖析 Java16 带给我们的改变。

引入 Memory Access API(孵化中)

在实际的开发过程中,绝大多数的开发人员基本都不会直接与堆外内存打交道,但这并不代表你从未接触过堆外内存,像大家经常使用的诸如:RocketMQ、MapDB 等中间件产品底层实现都是基于堆外存储的,换句话说,我们几乎每天都在间接与堆外内存打交道。那么究竟为什么需要使用到堆外内存呢?简单来说,主要是出于以下 3 个方面的考虑:

  • 减少 GC 次数和降低 Stop-the-world 时间;

  • 可以扩展和使用更大的内存空间;

  • 可以省去物理内存和堆内存之间的数据复制步骤。


在 Java14 之前,如果开发人员想要操作堆外内存,通常的做法就是使用 ByteBuffer 或者 Unsafe,甚至是 JNI 等方式,但无论使用哪一种方式,均无法同时有效解决安全性和高效性等 2 个问题,并且,堆外内存的释放也是一个令人头痛的问题。以 DirectByteBuffer 为例,该对象仅仅只是一个引用,其背后还关联着一大段堆外内存,由于 DirectByteBuffer 对象实例仍然是存储在堆空间内,只有当 DirectByteBuffer 对象被 GC 回收时,其背后的堆外内存才会被进一步释放。如图 1 所示。

图1 持有off-heap引用的“冰山对象”

在此大家需要注意,程序中通过 ByteBuffer.allocateDirect()方法来申请物理内存资源所耗费的成本远远高于直接在 on-heap 中的操作,而且实际开发过程中还需要考虑数据结构如何设计、序列化/反序列化如何支撑等诸多难题,所以与其使用语法层面的 API 倒不如直接使用 MapDB 等开源产品来得更实惠。


如今,在堆外内存领域,我们似乎又多了一个选择,从 Java14 开始,Java 的设计者们在语法层面为大家带来了崭新的 Memory Access API,极大程度上简化了开发难度,并得以有效的解决了安全性和高效性等 2 个核心问题。示例 1-1:

// 获取内存访问var句柄var handle = MemoryHandles.varHandle(char.class,        ByteOrder.nativeOrder());// 申请200字节的堆外内存try (MemorySegment segment = MemorySegment.allocateNative(200)) {    for (int i = 0; i < 25; i++) {        handle.set(segment, i << 2, (char) (i + 1 + 64));        System.out.println(handle.get(segment, i << 2));    }}
复制代码

关于堆外内存段的释放,Memory Access API 提供有显式和隐式 2 种方式,开发人员除了可以在程序中通过 MemorySegment 的 close()方法来显式释放所申请的内存资源外,还可以注册 Cleaner 清理器来实现资源的隐式释放,后者会在 GC 确定目标内存段不再可访问时,释放与之关联的堆外内存资源。

支持 AVX 指令的向量计算 API(孵化中)

AVX(Advanced Vector Extensions,高级向量扩展)实际上是 x86-64 处理器上的一套 SIMD(Single Instruction Multiple Data,单指令多数据流)指令集,相对于 SISD(Single instruction, Single dat,单指令流但数据流)而言,SIMD 非常适用于 CPU 密集型场景,因为向量计算允许在同一个 CPU 时钟周期内对多组数据批量进行数据运算,执行性能非常高效,甚至从某种程度上来看,向量运算似乎更像是一种并行任务,而非像标量计算那样,在同一个 CPU 时钟周期内仅允许执行一组数据运算,存在严重的执行效率低下问题。如图 2 所示。

图2 向量计算与标量计算

随着 Java16 的正式来临,开发人员可以在程序中使用 Vector API 来实现各种复杂的向量计算,由 JIT 编译器 Server Compiler(C2)在运行期将其编译为对应的底层 AVX 指令执行。当然,在讲解如何使用 Vector API 之前,我们首先来看一个简单的标量计算程序。示例 1-2:

void scalarComputation() {    var a = new float[10000000];    var b = new float[10000000];    // 省略数组a和b的赋值操作    var c = new float[10000000];    for (int i = 0; i < a.length; i++) {        c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f;    }}
复制代码

在上述程序示例中,循环体内每次只能执行一组浮点运算,总共需要执行约 1000 万次才能够获得最终的运算结果,可想而知,这样的执行效率必然低效。值得庆幸的是,从 Java6 的时代开始,Java 的设计者们就在 HotSpot 虚拟机中引入了一种被称之为 SuperWord 的自动向量优化算法,该算法缺省会将循环体内的标量计算自动优化为向量计算,以此来提升数据运算时的执行效率。当然,我们可以通过虚拟机参数-XX:-UseSuperWord来显式关闭这项优化(从实际测试结果来看,如果不开启自动向量优化,存在约 20%~22%之间的性能下降)。


在此大家需要注意,尽管 HotSpot 缺省支持自动向量优化,但局限性仍然非常明显,首先,JIT 编译器 Server Compiler(C2)仅仅只会对循环体内的代码块做向量优化,并且这样的优化也是极不可靠的;其次,对于一些复杂的向量运算,SuperWord 则显得无能为力。因此,在一些特定场景下(比如:机器学习,线性代数,密码学等),建议大家还是尽可能使用 Java16 为大家提供的 Vector API 来实现复杂的向量计算。示例 1-3:

// 定义256bit的向量浮点运算static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_256;void vectorComputation(float[] a, float[] b, float[] c) {    var i = 0;    var upperBound = SPECIES.loopBound(a.length);    for (; i < upperBound; i += SPECIES.length()) {        var va = FloatVector.fromArray(SPECIES, a, i);        var vb = FloatVector.fromArray(SPECIES, b, i);        var vc = va.mul(va).                add(vb.mul(vb)).                neg();        vc.intoArray(c, i);    }    for (; i < a.length; i++) {        c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f;    }}
复制代码

值得注意的是,Vector API 包含在 jdk.incubator.vector 模块中,程序中如果需要使用 Vector API 则需要在 module-info.java 文件中引入该模块。示例 1-4:

module java16.test{    requires jdk.incubator.vector;}
复制代码

值类型使用警告(正式)

早在 Java9 版本时,Java 的设计者们就对 @Deprecated 注解进行了一次升级,增加了 since 和 forRemoval 等 2 个新元素。其中,since 元素用于指定标记了 @Deprecated 注解的 API 被弃用时的版本,而 forRemoval 则进一步明确了 API 标记 @Deprecated 注解时的语义,如果forRemoval=true时,则表示该 API 在未来版本中肯定会被删除,开发人员应该使用新的 API 进行替代,不再容易产生歧义(Java9 之前,标记 @Deprecated 注解的 API,语义上存在多种可能性,比如:存在使用风险、可能在未来存在兼容性错误、可能在未来版本中被删除,以及应该使用更好的替代方案等)。


仔细观察原始类型的包装类(比如:java.lang.Integer、java.lang.Double),不难发现,其构造函数上都已经标记有@Deprecated(since="9", forRemoval = true)注解,这就意味着其构造函数在将来会被删除,不应该在程序中继续使用诸如new Integer();这样的编码方式(建议使用Integer a = 10;或者Integer.valueOf()函数),如果继续使用,编译期将会产生'Integer(int)' is deprecated and marked for removal 告警。并且,值得注意的是,这些包装类型已经被指定为同 java.util.Optional 和 java.time.LocalDateTime 一样的值类型。


其次,如果继续在 Synchronized 同步块中使用值类型,将会在编译期和运行期产生警告,甚至是异常。在此大家需要注意,就算编译期和运行期没有产生警告和异常,也不建议在 Synchronized 同步块中使用值类型,举个自增的例子。示例 1-5:

public void inc(Integer count) {    for (int i = 0; i < 10; i++) {        new Thread(() -> {            synchronized (count) {                count++;            }        }).start();    }}
复制代码

当执行上述程序示例时,最终的输出结果一定会与你的期望产生差异,这是许多新人经常犯错的一个点,因为在并发环境下,Integer 对象根本无法通过 Synchronized 来保证线程安全,这是因为每次的count++操作,所产生的 hashcode 均不同,简而言之,每次加锁都锁在了不同的对象上。因此,如果希望在实际的开发过程中保证其原子性,应该使用 AtomicInteger。

进一步强化的 instanceof 运算符(正式)

早期,如果我们需要在程序中对某个引用类型进行强制类型转换,通常情况下,为了避免在转换过程中出现类型不匹配的转换错误,我们往往会使用 instanceof 运算符验证目标对象是否是特定类型的一个实例,只有当表达式条件成立时,才允许进行强制类型转换,示例 1-6:

if (obj instanceof String) {    String temp = (String) obj;} else {    //...}
复制代码

上述程序示例中,整个转换过程总共需要经历 2 个步骤,首先需要使用 instanceof 关键字来匹配目标对象,当条件成立后,再使用一个新的局部变量来接收强转后的值。平心而论,这样的写法着实有点冗余,因此,从 Java14 开始,Java 的设计者们开始对 instanceof 运算符进行了进一步的升级强化,为开发人员提供了模式匹配功能,以便于简化样板代码的编写。示例 1-7:

if (obj instanceof String str) {    //TODO 变量str的作用域仅限if代码块} else {    //...}
复制代码

很明显,使用 instanceof 的模式匹配功能后,弱化了原本代码样板化严重的问题。instanceof 关键字的右边允许开发人员直接声明变量,当满足条件后,编译器会隐式强转并为其赋值。

数据载体支持(正式)

实际上,这项语法特性也是为了给大家“减负”使用的,目的就是为了减少样板代码的编写。在实际开发过程中,我们往往会在业务代码中定义非常多的 POJO 类,比如:Controller 和 Service 之间的 DTO 对象、持久层的 PO 对象等。但是这类 POJO 类型本身并不会处理复杂的业务逻辑,也不会包含任何行为,其作用纯粹就是作为一个数据载体,以便于数据的传输或访问处理;但我们仍然每天都需要不胜其烦的编写一大堆样板代码,比如:setter、getter、hashCode,以及 equals 等。在此大家需要注意,如果一个 POJO 类中存在较多的字段,会严重影响其维护性和可读性,示例 1-8:

public class UserEntity {    private int id;    private String account, pwd;
@Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof UserEntity)) return false; UserEntity that = (UserEntity) o; return getId() == that.getId() && Objects.equals(getAccount(), that.getAccount()) && Objects.equals(getPwd(), that.getPwd()); }
@Override public int hashCode() { return Objects.hash(getId(), getAccount(), getPwd()); } //省略setter/getter方法}
复制代码

为了减少样板代码的编写,Java 的设计者们为开发人员提供了 record 关键字,专用于定义不可变的数据载体类,示例 1-9:

public record UserEntity(int id, String account, String pwd) {}
复制代码

上述程序示例中,我们仅通过一行代码即可完成一个 POJO 类的定义,大幅减少了样板代码的编写。通过 record 关键字定义的 POJO 类在对其进行反编译后我们不难发现,编译器在编译时,仍然会将其编译为一个标准的 Java 类型,相当于隐式帮我们实现了 Lombok 的功能示例 1-10:

public final class UserEntity extends java.lang.Record {    private final int id;    private final java.lang.String account;    private final java.lang.String pwd;
public UserEntity(int id, java.lang.String account, java.lang.String pwd) { /* compiled code */ } public final java.lang.String toString() { /* compiled code */ } public final int hashCode() { /* compiled code */ } public final boolean equals(java.lang.Object o) { /* compiled code */ } public int id() { /* compiled code */ } public java.lang.String account() { /* compiled code */ } public java.lang.String pwd() { /* compiled code */ }}
复制代码

上述程序示例中,所有的字段都被声明为了 final,这也说明,record 类是专用于定义不可变数据。在此大家需要注意,在使用 record 类时,有几点是必须注意的,如下所示:

  • record 类中不允许定义实例字段,但允许定义静态字段;

  • record 类中允许定义静态方法和实例方法,以便于实现一些特定的数据加工任务;

  • record 类中允许定义构造函数。


或许大家存在疑问,早期我们在定义 POJO 类时,如果遇到有很多可选参数时,往往会采用重载构造函数的方式来解决,但如果使用 record 类后,我们应该如何解决这个问题?实际上,既然 record 类允许我们定义构造函数,那这就意味着同样可以通过相似的技术手段来解决共性问题,但实际开发过程中,我更建议大家使用 Builder 模式,示例 1-11:

public record UserEntity(int id, String account, String pwd) {    private UserEntity(Builder builder) {        this(builder.id, builder.account, builder.pwd);    }
static class Builder { int id; String account, pwd;
Builder(String account, String pwd) { this.account = account; this.pwd = pwd; } Builder id(int id) { this.id = id; return this; } public UserEntity build() { return new UserEntity(this); } }
public static void main(String[] args) { var userEntity = new UserEntity.Builder("gxl", "123456").id(100).build(); System.out.println(userEntity.toString()); }}
复制代码

扩展限制支持(预览)

在谈扩展限制之前,我们首先回顾下 Java 访问修饰符是如何控制资源的访问权限的。当资源被声明为 public 时,意味着资源对所有类可见;当资源被声明为 protected 时,意味着仅同包类,或派生类可见;当资源被声明为 default 时,意味着仅同包类可见;而当资源被声明为 private 时,仅同类可见。



在某些情况下,如果我们希望所定义的超类具备扩展限制时,通常会采用如下 2 种方式:

  • 将超类定义为 final;

  • 将超类的构造函数声明为 default。



如果我们将超类定义为 final 后,那么任何类都不允许对其进行继承,也就是说,超类将不提供任何扩展支持,很明显,这样的做法显然无法有效支撑某些特定场景(比如:我们希望超类仅允许被固定的几个子类扩展)。而如果将超类的构造函数声明为 default 时,尽管在一定范围内可以限制超类的扩展权限(同包类可见),但如果其它包中的子类也需要对其进行扩展时则显得无能为力。因此,在 Java15 诞生之前,仅凭现有的技术手段均无法有效的为开发人员提供一种声明式的方式来合理限制超类对子类的扩展能力。



值得庆幸的是,从 Java15 开始,为开发人员在语法层面上提供了 sealed 关键字来支持这项特性,示例 1-12:

public sealed interface Animal permits Tiger, Lion {}final class Tiger implements Animal {}final class Lion implements Animal {}
复制代码

上述程序示例中,我们通过 sealed 关键字定义了一个具备扩展限制的超类,并在 classname 之后使用 permits 关键字来限制其扩展范围;也就是说,只有在限制范围内的固定子类才允许对超类进行扩展,否则编译器将会出现编译错误。当然,permits 关键字并非是强制的,当我们所定义的子类为嵌套内部类时,编译器会在编译时进行自动类型推断,示例 1-13:

public sealed interface Animal{}final class Tiger implements Animal {}final class Lion implements Animal {}
复制代码

相信大家也看见了,示例 1-11 和 1-12 中,子类均使用了 final 关键字来进行修饰,这是为何?实际上,这与 sealed 类的约束有关。我们使用 sealed 的初衷是希望对超类的扩展做出限制,在开发过程中不允许任何类都对其进行继承或实现,因此对于 sealed 类的子类而言,就需要追加 3 个约束。首先是超类和子类必须被在同包内,如果要解除这个限制,就必须被包含在同一模块中,示例 1-14:

module name {}
复制代码

其次,permits 关键字包含的子类都必须显示 extends 或者 implements。最后,子类都必须定义一个特定的修饰符来描述是否延续超类的扩展限制;也就是说,子类也可以使用 sealed、permits 关键字来定义它的下级派生,并且也可以使用 final 关键字来禁止所有子类继承于它,甚至还可以使用 non-sealed 关键字来解除所有限制(如果这么做,扩展限制将失去意义)。具体怎么定义,还需要结合具体的业务场景而定,示例 1-15:

public sealed interface Animal{}
/** * 子类也可以定义sealed来延续超类的扩展限制 */sealed class Tiger implements Animal permits NortheastTiger {}final class NortheastTiger extends Tiger{}
/** * non-sealed解除所有限制 */non-sealed class Lion implements Animal {}class PumaConcolor extends Lion{}
复制代码


====== END ======



至此,本文内容全部结束。如果在阅读过程中有任何疑问,欢迎在评论区留言参与讨论。



推荐文章:

码字不易,欢迎转发

发布于: 2021 年 03 月 21 日阅读数: 224
用户头像

九叔

关注

阿里巴巴 高级技术专家 2020.03.25 加入

GIAC全球互联网架构大会讲师,《超大流量分布式系统架构解决方案》、《人人都是架构师》、《Java虚拟机精讲》书籍作者

评论 (1 条评论)

发布
用户头像
牛人
2021 年 03 月 22 日 06:50
回复
没有更多了
实操 | 剖析 Java16 新语法特性