Java21手册(九):其他重要功能的更新

avatar
@比心

9.1 新的API

9.1.1 伪随机数生成器接口

Java的伪随机数生成器(PRNG)最常用的类是java.util.Random,Random类有两个子类:第一个是java.util.concurrent.ThreadLocalRandom,用于多线程场景,标准的用法是ThreadLocalRandom.current().nextX();第二个是java.security.SecureRandom,用于加密服务。除了Random类外,另一个生成器是java.util.SplittableRandom,使用split()方法创建新实例用于多线程,很适合在Stream并行编程中使用。

新的伪随机数生成器接口API,为现有的实现类提供了一系列统一接口,并在具体的算法实现上提供了更多选择,同时在编程上提高了便利性。完整的接口类图如下:

  • RandomGenerator是最基础的伪随机数生成器接口,提供了命名为ints、longs、doubles、nextBoolean、nextInt、nextLong、nextDouble和nextFloat的方法,以及它们当前的所有参数变体。

  • SplittableRandomGenerator提供了命名为split和splits的方法,允许开发者通过现有的RandomGenerator生成一个新的RandomGenerator,通常会产生统计上独立的结果。

  • JumpableRandomGenerator提供了命名为jump和jumps的方法,允许用户向前跳过一定数量的随机数抽取。

  • LeapableRandomGenerator提供了命名为leap和leaps的方法,允许用户向前跳过大量的随机数抽取。

  • ArbitrarilyJumpableRandomGenerator给LeapableRandomGenerator增加了可以任意指定跳跃距离的jump和jumps的方法。

Java基于LXM算法和其他PRNG算法提供了更多实现,可以通过RandomGeneratorFactory查看全部PRNG实现类:

使用新API,我们可以通过RandomGenerator.getDefault方法返回一个默认的PRNG实现类,也可以通过指定算法名称来创建实现类:

RandomGenerator generator = RandomGenerator.getDefault();
System.out.println(generator.getClass().getSimpleName());


输出:
L32X64MixRandom


RandomGenerator generator = RandomGenerator.of("L128X256MixRandom");

不同于之前的Random类,新的实现类都不是线程安全的。但由于Random在并发场景下执行效率过低,实际我们在服务中也不会在多线程中共享Random实例,在子线程中使用随机数还是要通过new Random()来新建,因此区别不大。如果在新开子线程中需要用到随机数,建议还是使用SplittableRandomGenerator来实现:

sourceGenerator.splits(20).forEach((splitGenerator) -> {
    executorService.submit(() -> {
        numbers.add(splitGenerator.nextInt(10));
    });
}

新的PRNG实现性能要优于Random,使用Benchmark测试,吞吐量可以达到Random的两倍以上。

@Benchmark
    public void testRandom() {
        RandomGenerator random = new Random();
        random.nextInt();
        random.nextBoolean();
        random.nextDouble();
        random.nextLong();
    }


    @Benchmark
    public void testRandomDefault() {
        RandomGenerator random = RandomGenerator.getDefault();
        random.nextInt();
        random.nextBoolean();
        random.nextDouble();
        random.nextLong();
    }


输出结果:
Benchmark                                    Mode  Cnt         Score   Error  Units
RandomGeneratorBenchmark.testRandom         thrpt    2 9182013.655          ops/s
RandomGeneratorBenchmark.testRandomDefault  thrpt    2 24315699.844          ops/s

新的伪随机数生成器接口对比原来的实现类,在语法、性能、维护性等各方面都有很明显的优势,建议各位开发者在遇到随机数场景时使用新接口来实现。

9.1.2 外部函数和内存API

在Java中调用外部函数和使用堆外内存,并不是java开发者很常用的功能,但却是作为一门语言很必要的能力。新的外部函数和内存API(FFM API)在Java21中还是一个Preview特性,这个API大大降低了编程难度,FFM API的代码在java.lang.foreign 这个包下。我不会花太多时间详细描述这个特性,如果遇到这样的场景你可以找到专门的文档来学习,这里给一个官方介绍的例子来让你有一个初步印象:

// 1. Find foreign function on the C library path
Linker linker          = Linker.nativeLinker();
SymbolLookup stdlib    = linker.defaultLookup();
MethodHandle radixsort = linker.downcallHandle(stdlib.find("radixsort"), ...);
// 2. Allocate on-heap memory to store four strings
String[] javaStrings = { "mouse", "cat", "dog", "car" };
// 3. Use try-with-resources to manage the lifetime of off-heap memory
try (Arena offHeap = Arena.ofConfined()) {
    // 4. Allocate a region of off-heap memory to store four pointers
    MemorySegment pointers
    = offHeap.allocateArray(ValueLayout.ADDRESS, javaStrings.length);
    // 5. Copy the strings from on-heap to off-heap
    for (int i = 0; i < javaStrings.length; i++) {
        MemorySegment cString = offHeap.allocateUtf8String(javaStrings[i]);
        pointers.setAtIndex(ValueLayout.ADDRESS, i, cString);
    }
    // 6. Sort the off-heap data by calling the foreign function
    radixsort.invoke(pointers, javaStrings.length, MemorySegment.NULL, '\0');
    // 7. Copy the (reordered) strings from off-heap to on-heap
    for (int i = 0; i < javaStrings.length; i++) {
        MemorySegment cString = pointers.getAtIndex(ValueLayout.ADDRESS, i);
        javaStrings[i] = cString.getUtf8String(0);
    }
} // 8. All off-heap memory is deallocated here
assert Arrays.equals(javaStrings,
                     new String[] {"car", "cat", "dog", "mouse"});  // true

9.1.3 反序列化过滤器

提到序列化,笔者印象最深的还是《Effective Java》第12章序列化的观点:Prefer alternatives to Java serialization(优先选择 Java 序列化的替代方案),原因在于反序列化作为一种创建Java对象的方式,实际上打破了由构造函数等语言层面构建的限制和封装,黑客可以通过调用ObjectInputStream类上的readObject方法,来实例化类路径上几乎任何类型的对象,只要该类型实现Serializable接口。不使用任何gadget,就可以通过导致需要很长时间反序列化的短字节流,进行反序列化操作,轻松地发起拒绝服务攻击。这种流被称为反序列化炸弹(deserialization bombs)[Svoboda16]。下面是Wouter Coekaerts的一个例子,它只使用HashSet和字符串[Coekaerts15]:

// Deserialization bomb - deserializing this stream takes forever
static byte[] bomb() {
    Set<Object> root = new HashSet<>();
    Set<Object> s1 = root;
    Set<Object> s2 = new HashSet<>();
    for (int i = 0; i < 100; i++) {
        Set<Object> t1 = new HashSet<>();
        Set<Object> t2 = new HashSet<>();
        t1.add("foo"); // Make t1 unequal to t2
        s1.add(t1); s1.add(t2);
        s2.add(t1); s2.add(t2);
        s1 = t1;
        s2 = t2;
    }
    return serialize(root); // Method omitted for brevity
}

对象图由201个HashSet实例组成,每个实例包含3个或更少的对象引用。整个流的长度为5744字节,但是在完成反序列化之前,太阳都已经燃尽了。原因是反序列化HashSet实例需要计算其元素的哈希码。root实例的2个元素本身就是包含2个HashSet元素的HashSet,每个HashSet元素包含2个HashSet元素,以此类推,深度为100。因此,反序列化set会导致hashCode方法被调用超过2100次。除了反序列化会持续很长时间之外,反序列化器没有任何错误的迹象。生成的对象很少,并且堆栈深度是有界的。

为了可以在一定程度上解决这个问题,Java9引入了反序列化过滤器机制,通过jdk.serialFilter属性设置全局的可序列化类名模式,例如:

java -Djdk.serialFilter="example.*;java.base/*;!*" ...

这段参数的意思是,允许 example.* 包下的class和java.base模块下的class被反序列化,其他class反序列化都会被拒绝。

过滤器可以基于以下四个设置来检查字节流是否符合约定,不符合则会拒绝反序列化:

  • maxdepth=value — 最大深度
  • maxrefs=value — 最大内部引用数
  • maxbytes=value — 最大字节数
  • maxarray=value — 最大数组长度

Java17通过增加上下文感知反序列化过滤器接口,可动态设置ObjectInputFilter,进一步细化了反序列化的控制颗粒度,下面的代码定义了一个在当前线程中会过滤任何反序列化操作的过滤器:

使用方式如下:

// 动态设置反序列化过滤器factory
var filterInThread = new FilterInThread();
ObjectInputFilter.Config.setSerialFilterFactory(filterInThread);


// 允许 example.* 包下的class和java.base模块下的class被反序列化,其他class反序列化都会被拒绝
var filter = ObjectInputFilter.Config.createFilter("example.*;java.base/*;!*");
filterInThread.doWithSerialFilter(filter, () -> {
    byte[] bytes = ...;
    var o = deserializeObject(bytes);
});

尽管反序列化过滤器在一定程度上提高了Java序列化编程的安全性,但这个特性看起来还并不是那么智能和自动化,需要开发者有意识地按照这种方式设置过滤器以避免反序列化问题。虽然Java序列化在应用场景上依然十分广泛,但作为服务端开发者,我们会更多选择基于反射而非java序列化的框架,例如使用Jackson做json序列化的web框架,以及自定义二进制协议的RPC框架等。Java反序列化过滤器的特性,会在一些特定场景下起到很大作用,但在对外提供服务的业务后端开发中,我们还是应该尽量避免暴露用java反序列化方式创建对象的接口。

9.1.4 EdDSA加密算法

Edwards-Curve Digital Signature Algorithm(EdDSA) 相比JDK默认的加密算法,有更好的安全性和性能表现,同时也是TLS 1.3中三种可选组件之一。为了支持EdDSA,Java引入了一些新的类,下面是使用EdDSA进行加解密的例子:

KeyPairGenerator keyGen = KeyPairGenerator.getInstance("EC");
keyGen.initialize(new ECGenParameterSpec("secp256r1"), new SecureRandom());
KeyPair pair = keyGen.generateKeyPair();
PrivateKey priv = pair.getPrivate();
PublicKey pub = pair.getPublic();


/*
 * Create a Signature object and initialize it with the private key
 */
Signature ecdsa = Signature.getInstance("SHA256withECDSA");
ecdsa.initSign(priv);
String str = "This is string to sign";
byte[] strByte = str.getBytes(StandardCharsets.UTF_8);
ecdsa.update(strByte);
byte[] realSig = ecdsa.sign();
System.out.println("Signature: " + new BigInteger(1, realSig).toString(16));


/*
 * Verify the signature
 */
Signature sig = Signature.getInstance("SHA256withECDSA");
sig.initVerify(pub);
sig.update(strByte);
System.out.println(sig.verify(realSig));

9.1.5 System.Logger日志API

Java定义了一个新的日志API:System.Logger,作为java日志功能的统一接口,通过System.getLogger(loggerName) 或System.getLogger(loggerName, bundle)来创建,通过System.LoggerFinder来获取Logger的实现类,如果找不到则默认会使用java.util.logging.Logger。实例代码如下:

其中JULWrapper即使用java.util.logging.Logger实现日志打印的包装类。

由于System.Logger是基于LoggerFinder来确定logger最终实现的,新的API可以很好的跟Log4j、SLF4J等流行的日志框架做集成,例如集成Log4j,在项目中引入依赖:

<dependencies>
  <dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-core</artifactId>            
    <version>2.17.0</version>
  </dependency>
  <dependency>
    <groupId>org.apache.logging.log4j</groupId>    
    <artifactId>log4j-jpl</artifactId>
    <version>2.17.0</version>
  </dependency>
</dependencies>

再次执行上面的程序,输出如下:

18:33:01.549 [main] INFO com.java21.DefaultLogExample - Hello, World!
18:33:03.531 [main] INFO com.java21.DefaultLogExample - Log4jSystemLogger

同理,SLF4J也可以用相同的方式集成:

<dependencies>
  <dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-simple</artifactId>               
    <version>2.0.0-alpha5</version>
  </dependency>
  <dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-jdk-platform-logging</artifactId> 
    <version>2.0.0-alpha5</version>
  </dependency>
</dependencies>

System.Logger相比java默认的Logger无疑有了很大进步,但对比主流日志框架似乎并没有突出的优势,兼容多个日志系统的特性,可以作为我们需要考虑未来可能替换日志工具类时的一种选择。当然,如果我们有不依赖外部日志包的需求,需要自己定义日志实现时,使用System.Logger无疑是最佳选择。

9.1.6 StackWalker堆

栈扫描器

StackWalker是一个可遍历当前调用堆栈信息API,区别于Throwable::getStackTrace和Thread::getStackTrace方法,StackWalker不会直接返回完整的堆栈,而是允许延迟加载和过滤堆栈帧,支持在满足给定条件的帧处停止的短遍历,也支持遍历整个堆栈的长遍历。因此在只需要部分堆栈信息的场景里,例如获取方法调用信息,StackWalker提供了性能更优的解决方案。

下面这个例子,我们使用StackWalker的遍历堆栈方法walk,来打印目标方法的调用类和方法信息:

package chapter9;


public class StackWalkerDemo {


    public static void main(String[] args) {
        func1();
        func2();
    }
    public static void inv() {
        StackWalker.StackFrame frame = StackWalker.getInstance().walk(stackFrameStream ->
                                                                      stackFrameStream.filter(f -> !f.getClassName().equals("org.example.Main") ||
                                                                                              !f.getMethodName().equals("inv")).findFirst().get());
        System.out.println(frame.getClassName() + ":" + frame.getMethodName());
    }
    public static void func1() {
        inv();
    }
    public static void func2() {
        inv();
    }
}
程序输出如下:
chapter9.StackWalkerDemo:inv
chapter9.StackWalkerDemo:inv

9.1.7 snippet 代码片段标签

@snippet是JavaDoc的一个新增的标签,用于写代码片段,相比于之前的@code,@snippet提供了更加丰富的功能。snippet标签支持两种模式:inline snippets,类似code标签,代码片段直接写在标签内部;external snippets,代码片段可以在其他源文件里。

inline snippets的例子:

/**
 * The following code shows how to use {@code Optional.isPresent}:
 * {@snippet :
 * if (v.isPresent()) {
 *     System.out.println("v: " + v.get());
 * }
 * }
 */

snippet代码块在IDE中是可以代码补全的,通过截图可以更清晰地看到:

External snippets相对来说配置起来要复杂一些,需要创建非法包名路径来存放snippet代码源文件,或者在编译期设置源文件路径,个人认为仅用来写注释的话不够直观,因此不在此介绍。

由于出色的可读性和代码补全能力,snippet标签从使用体验上要远胜过code、pre标签,因此建议开发者在新的代码注释块中使用。Java中新增的API,例如 StructuredTaskScope、ScopedValue 等,注释中的代码范例也都使用snippet来编写完成。

9.2 重要的性能优化

9.2.2 Method

Handles 增强与反射的重新实现

MethodHandles(方法句柄)是 Java 核心库中的一个类,位于 java.lang.invoke 包中,它提供了一种在运行时处理方法调用的灵活方式,可以绕过传统的 Java 反射机制,并提供更高效的方法调用。

MethodHandles 类提供了许多静态方法,用于创建、转换和操作方法句柄(MethodHandle)。方法句柄是对方法的类型和调用目标的引用,类似于函数指针。与传统的 Java 方法调用相比,方法句柄的执行速度更快,因为它们是直接的、低级别的方法调用,无需进行动态绑定和方法解析。

Java9进一步强化了MethodHandles以及其相关类,通过引入新的MethodHandle组合器和查找细化,简化了常用操作,并实现更好的编译器优化。具体包含:

  • 提供了更多语句的组合器,提供了循环、try/finally语句的抽象:
MethodHandle loop(MethodHandle[]... clauses);
MethodHandle tryFinally(MethodHandle target, MethodHandle cleanup);
  • 提供了更好的参数处理,包括参数展开、参数收集和参数折叠:
MethodHandle asSpreader(int pos, Class<?> arrayType, int arrayLength);
MethodHandle asCollector(int pos, Class<?> arrayType, int arrayLength);
MethodHandle foldArguments(MethodHandle target, int pos, MethodHandle combiner);
  • 句柄查询能力加强,支持继承接口的方法查询以及类查询:
Class<?> findClass(String targetName);
Class<?> accessClass(Class<?> targetClass);

更详细的使用说明,读者可以查阅javadoc。

9.2.2 Class-Data Sharing 启动优化

Application Class-Data Sharing(应用程序类数据共享)是java中的一个新特性,它允许在不同的JVM实例之间共享和重用已经加载的类数据,以加速应用程序的启动时间和减少内存使用。

在传统的JVM启动过程中,每个应用程序都会独立加载和解析类文件,并为每个类分配内存空间。这意味着在多个JVM实例中运行相同的应用程序时,会重复进行类加载和内存分配,浪费了时间和资源。

Application Class-Data Sharing通过创建并使用Dynamic CDS Archives(动态CDS归档)来解决这个问题。Dynamic CDS Archives是一种包含已加载类的内存映像的文件,可以在不同的JVM实例之间共享。这些归档文件包含类的字节码、符号引用、静态变量等信息。

使用Application Class-Data Sharing的步骤如下:

  1. 创建Dynamic CDS Archives:在第一个JVM实例上,使用-XX:DumpLoadedClassList参数运行应用程序,该参数会生成一个包含已加载类列表的文件。然后使用-XX:SharedArchiveFile参数指定生成的Dynamic CDS Archives文件。
  2. 应用Dynamic CDS Archives:在后续的JVM实例上,使用-XX:SharedArchiveFile参数指定之前生成的Dynamic CDS Archives文件的路径。JVM会加载Dynamic CDS Archives中的类数据,而不需要重新解析和分配内存。

通过使用Application Class-Data Sharing和Dynamic CDS Archives,可以实现以下好处:

  1. 加速应用程序启动时间:由于类数据已经预加载并共享,后续的JVM实例可以更快地启动,避免了重复的类加载和解析过程。
  2. 减少内存使用:共享的类数据可以被多个JVM实例重用,减少了内存占用量。
  3. 改善性能:应用程序运行时,JVM可以更快速地访问和执行共享的类数据,提高了程序的执行性能。

我们java服务端的应用,在大部分情况下都是在服务器部署单进程,不同进程之间通过容器隔离,所以多进程共享类文件对我们实际的作用不是很大。另一方面,对于java应用的启动速度影响最大的是服务框架的初始化时间,class加载在程序启动中的时间占比,实测下来其实微乎其微,因此这个特性可能并不会让大部分场景受益。

9.2.3 弹性metaspace内存管理

Java16开始使用Buddy memory allocation算法来管理metaspace,作用是可以及时将未使用的metaspace 内存返回给操作系统,减少了metaspace占用空间,并简化了metaspace的实现代码,以降低维护成本。

关于这个算法的更详细描述,可以参考:cr.openjdk.org/~stuefe/JEP…

9.2.4 优化域名解析

Java18定义了一套新的域名解析SPI,替换java.net.InetAddress所使用的操作系统默认域名解析器。SPI定义在java.net.spi 包中,新定义的类包括:

  • InetAddressResolverProvider:一个抽象类,定义了由 java.util.ServiceLoader 定位的服务。InetAddressResolverProvider 本质上是一个解析器的工厂,实例化的解析器将被设置为系统范围的解析器,并且 InetAddress 将把所有查找请求委托给该解析器。

  • InetAddressResolver:一个接口,定义了基本的正向和反向查找操作的方法。可以从 InetAddressResolverProvider 的实例中获取该接口的实例。

  • InetAddressResolver.LookupPolicy:一个类的实例描述了解析请求的特性,包括请求的地址类型和返回地址的顺序。

  • InetAddressResolverProvider.Configuration:一个接口,描述了平台内置的解析操作配置,它提供对本地主机名和内置解析器的访问。自定义解析器提供者可以使用它来引导解析器的构建或者实现将解析请求部分委托给操作系统的本地解析器。

新的SPI可以让java域名解析在集成新的网络协议、自定义和测试方面有更好的兼容性和控制,同时也避免了由操作系统调用引起的虚拟线程阻塞,一定程度上提升了性能。

9.3 重要特性的废弃

9.3.1 废弃Finalization

Finalization对标C++中的析构函数,即Object中的finalize()方法,是从java1.0开始就引入的功能,现已标记为deprecated,并会在未来删除。关于Finalization的问题在《Effective Java》一书有过详细的阐述,在很多Java编码标准和规范中也明确规定禁止使用Finalization。

9.3.2 废弃偏向锁

偏向锁是JVM的优化技术,用于减少使用monitor的锁竞争,代价是在发生争用时需要执行昂贵的撤销操作。早期的java程序使用的集合如HashTable、Vector大量使用到同步,由于当今的程序更多使用非同步集合,以及在并发场景中会更多使用性能更好的并发数据结构,偏向锁优化产生的收益已经微乎其微,相反根据相关资料,围绕线程池队列和工作线程构建的应用程序通常在禁用偏向锁定时性能更好。由于这些原因,偏向锁在JVM中默认已经关闭并标记为deprecated。

9.3.3 废弃Nashorn JavaScript引擎

Nashorn JavaScript引擎是java8引入的一个重要特性,符合ECMAScript-262 5.1标准,然而由于ECMAScript迅猛发展,java开发者越来越难以维护引擎的更新,因此将这一特性从java中移除。

image.png