2.0 Java类型系统演进的性能影响

超越Java 17:Valhalla项目

NOTE

如果启用了 -XX:+UseCompressedOops,klass 指针将被压缩。

使用Java对象布局(JOL)分析对象内存布局

Java Object Layout (JOL)4 是理解对象内存分配的强大工具。它能清晰展示每个对象相关的开销,包括对象头(object header)和字段。以下是如何使用JOL分析8字节数组布局的示例:

import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;
import static java.lang.System.out;
 
public class ArrayLayout {
    public static void main(String[] args) throws Exception {
        out.println(VM.current().details());
        byte[] ba = new byte[8];
        out.println(ClassLayout.parseInstance(ba).toPrintable());
    }
}

关于如何打印内部细节的更多信息,请参考以下两个jol-core文件5

  • org/openjdk/jol/info/ClassLayout.java
  • org/openjdk/jol/info/ClassData.java

为简化起见,我们在64位硬件、64位操作系统、64位Java虚拟机上运行此代码,并启用 -XX:+UseCompressedOops。(自Java 6 Update 23起,此选项默认启用。如果不确定哪些选项默认启用,可使用 -XX:+PrintFlagsFinal 选项检查)。以下是JOL的输出:

# Running 64-bit HotSpot VM.
# Using compressed oop with 3-bit shift.
# Using compressed klass with 3-bit shift.
# WARNING | Compressed references base/shifts are guessed by the experiment!
# WARNING | Therefore, computed addresses are just guesses, and ARE NOT RELIABLE.
# WARNING | Make sure to attach Serviceability Agent to get the reliable addresses.
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]

4 OpenJDK. “Code Tools: jol.” https://openjdk.org/projects/code-tools/jol/。 5 Maven Repository. “Java Object Layout: Core.” https://mvnrepository.com/artifact/org.openjdk.jol/jol-core。

[B object internals:
OFFSET SIZE TYPE DESCRIPTION     VALUE
   0     4       (object header) 01 00 00 00 (00000001 ...) (1)
   4     4       (object header) 00 00 00 00 (00000000 ...) (0)
   8     4       (object header) 48 68 00 00 (01001000 ...) (26696)
  12     4       (object header) 08 00 00 00 (00001000 ...) (8)
  16     8 byte  [B.<elements>     N/A
Instance size: 24 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

解析输出,我们可以看出几个部分:JVM配置、字节数组内部结构、标记字(mark word)、klass、数组长度、数组元素以及可能的对齐填充(padding)。

现在分段解析输出。

第1段:前三行

# Running 64-bit HotSpot VM.
# Using compressed oop with 3-bit shift.
# Using compressed klass with 3-bit shift.

这些行确认我们在64位Java虚拟机上,启用了压缩普通对象指针(oops),并且klass也被压缩。klass指针是JVM的关键组件,因为它访问对象类的元数据,例如其方法、字段和超类。此元数据对于许多操作(如方法调用和字段访问)是必需的。

压缩oops6 和klass指针用于减少64位JVM上的内存使用。对于拥有大量对象或类的应用程序,使用它们可以显著提升性能。

6 https://wiki.openjdk.org/display/HotSpot/CompressedOops

第2段:字节数组内部结构

[B object internals:
OFFSET SIZE TYPE DESCRIPTION     VALUE
   0     4       (object header) 01 00 00 00 (00000001 ...) (1)
   4     4       (object header) 00 00 00 00 (00000000 ...) (0)

输出还显示了字节数组的内部结构。回顾图2.3(Java对象头),我们可以看出标题后的两行代表标记字。JVM使用标记字进行对象同步和垃圾回收。

第3段:klass

OFFSET SIZE TYPE DESCRIPTION     VALUE
   8     4       (object header) 48 68 00 00 (01001000 ...) (26696)

klass部分只显示4个字节,因为当启用压缩oops时,压缩klass也会自动启用。可以尝试使用以下选项禁用压缩klass:-XX:-UseCompressedClassPointers

第4段:数组长度

OFFSET SIZE TYPE DESCRIPTION     VALUE
  12     4       (object header) 08 00 00 00 (00001000 ...) (8)

数组长度部分显示值为8,说明这是一个8字节数组。该值是头部的数组长度字段。

第5段:数组元素

OFFSET SIZE TYPE DESCRIPTION     VALUE
  16     8 byte  [B.<elements>   N/A

数组元素部分显示主体/字段 array.elements。由于这是一个8字节数组,所以在输出中大小显示为8字节。数组元素存储在连续的内存块中,这可以改善缓存局部性和性能。

理解数组对象的组成让我们能够意识到涉及的开销。对于大小为1到8字节的数组,我们需要24字节(根据元素不同,所需填充可能在0到7字节之间):

Instance size: 24 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

实例大小和空间损失被计算出来,以便更好地理解对象的内存使用情况。

现在,我们来看一个更复杂的例子:MorningPeople 类的数组。首先,考虑该类的可变版本:

public class MorningPeopleArray {
    public static void main(String[] args) throws Exception {
        out.println(VM.current().details());
 
        // 创建一个 MorningPeople 对象数组
        MorningPeople[] mornpplarray = new MorningPeople[8];
 
        // 打印 MorningPeople 类的布局
        out.println(ClassLayout.parseClass(MorningPeople.class).toPrintable());
 
        // 打印 mornpplarray 实例的布局
        out.println(ClassLayout.parseInstance(mornpplarray).toPrintable());
    }
}
 
class MorningPeople {
    String name;
    Boolean type;
 
    public MorningPeople(String name, Boolean type) {
        this.name = name;
        this.type = type;
    }
}

输出结果如下:

# Running 64-bit HotSpot VM.
# Using compressed oop with 3-bit shift.
# Using compressed klass with 3-bit shift.
# WARNING | Compressed references base/shifts are guessed by the experiment!
# WARNING | Therefore, computed addresses are just guesses, and ARE NOT RELIABLE.
# WARNING | Make sure to attach Serviceability Agent to get the reliable addresses.
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
 
MorningPeople object internals:
 OFFSET  SIZE   TYPE               DESCRIPTION                VALUE
      0    12                      (object header)            N/A
     12     4   java.lang.String   MorningPeople.name         N/A
     16     4   java.lang.Boolean  MorningPeople.type         N/A
     20     4                      (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
 
[LMorningPeople; object internals:
 OFFSET  SIZE   TYPE              DESCRIPTION               VALUE
      0     4                     (object header)           (1)
      4     4                     (object header)           (0)
      8     4                     (object header)           (13389376)
     12     4                     (object header)           (8)
     16    32   MorningPeople     MorningPeople;.<elements> N/A
Instance size: 48 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

小型不可变对象的低效性

考虑不可变的 MorningPeople 类:

// 通过声明为 final 使其不可变
final class MorningPeople {
    private final String name;
    private final Boolean type;
 
    public MorningPeople(String name, Boolean type) {
        this.name = name;
        this.type = type;
    }
    // name 和 type 的 getter 方法
    public String getName() {
         return name;
    }
    public Boolean getType() {
        return type;
    }
}

在这个版本的 MorningPeople 类中,nametype 字段是私有且 final 的,这意味着它们在构造函数中初始化后无法修改。该类还被声明为 final,因此不能被继承。没有 setter 方法,因此 MorningPeople 对象的状态在创建后无法改变。这使得 MorningPeople 类不可变。

如果编译并通过 JOL 运行此代码,内部结构仍然与可变版本相同。这个结果突出了一个重要点:当前的 Java 类型系统对于某些类型的数据是低效的。这种低效性在处理大量小型不可变数据对象时最为明显。在这种情况下,内存开销变得显著,并且引用局部性问题也随之出现。每个 MorningPeople 对象在内存中都是一个独立的实体,即使后者是不可变的。对这些对象的引用散落在堆中。这种分散的布局会因硬件缓存利用率不佳而对 Java 程序的性能产生不利影响。

值类的出现:对内存管理的影响

Valhalla 项目计划引入的值类将改变 JVM 处理内存管理的方式。通过在某些情况下减少对对象标识(object identity)的需求,这些类有望减轻垃圾回收器的工作负担,从而减少暂停次数并提高 GC 周期效率。这一变更还旨在利用值类型的紧凑布局来优化存储和检索操作,尤其是针对数组。然而,通往这

将这些新类集成起来的道路带来了挑战,特别是在管理值类型和引用类型的共存方面。JVM必须调整其GC策略并优化缓存使用,以有效处理这种新的混合模型。

值类代表了与传统类结构的根本转变。它们缺乏标识,因此与常规意义上的对象不同。它们被提议为不可变的,这意味着它们不能被同步或在创建后被修改——这些属性对于维护状态一致性至关重要。在当前的Java类型系统中,要创建像MorningPeople示例这样的不可变类,通常需要将类声明为final(防止扩展),将所有字段设为private(禁止直接访问),省略setter方法,将所有可变字段设为final(只允许一次性赋值),通过构造函数初始化所有字段,并确保对任何可变组件的独占访问。

值类设想了一个简化的过程,并通过消除对象头的需要,实现类似原始类型的内联存储,从而承诺更节省内存的存储方法。这带来了几个关键好处:

  • 减少内存占用:内联存储显著减少了所需的内存占用。
  • 改善引用局部性:内联存储确保值类的所有字段在内存中彼此靠近。这种改善的引用局部性可以通过优化CPU缓存使用,显著提高程序的性能。
  • 多才多艺的字段类型:预计值类支持任何类型的字段,包括原始类型、引用类型和嵌套的值类。这种拥有用户定义的原始类型的灵活性,允许更富表现力和更高效的代码。

重新定义带原始支持的通配符泛型

回顾我们在第1章中对泛型的讨论,我们使用StringBoolean作为KV的类型参数,检查了泛型类型FreshmenAdmissions<K, V>。然而,在当前的Java中,泛型仅限于引用类型。如果需要原始类型,则通过自动装箱与它们的包装类一起使用,如第1章的代码片段所示:

applicationStatus.admissionInformation("Monica", true);

在Project Valhalla下,Java类型系统的演进旨在包括对原始类型的泛型支持,并最终将此支持扩展到值类型。考虑在新语境下FreshmenAdmissions类的潜在定义:

public class FreshmenAdmissions<K, any V> {
    K key;
    V boolornumvalue;
 
    public void admissionInformation(K name, V value) {
        key = name;
        boolornumvalue = value;
    }
}

这里,any关键字引入了一个重大转变,允许泛型直接接受原始类型,而无需装箱。例如,在修改后的FreshmenAdmissions<K, any V>类中,V现在可以直接是原始类型1。为了演示这一增强,考虑使用原始类型boolean而不是包装类BooleanTestGenerics类:

public class TestGenerics {
    public static void main(String[] args) {
        FreshmenAdmissions<String, boolean> applicationStatus = new FreshmenAdmissions<>();
        applicationStatus.admissionInformation("Monica", true);
    }
}

这个例子说明了在泛型中直接使用原始类型如何简化代码并提高性能,这是Project Valhalla在Java类型系统方面的进步的一个关键目标。

探索Project Valhalla的当前状态

类和类型

Project Valhalla向Java类层次结构引入了几个新颖的概念,如内联类(值类)、原始类和增强的泛型。上一节讨论的值类具有一个独特的特征:当使用==运算符比较两个值类的实例时,它检查的是内容相等性而不是实例标识(因为值类没有标识)。此外,不能对值类进行同步。JVM可以利用这些功能,通过避免不必要的堆分配来节省内存。

原始类是一种特殊的值类。它们不是引用类型,这意味着它们不能为null。相反,如果你不给它们赋值,它们的值将默认为零。原始类直接存储在内存中,而不是需要单独的堆分配。因此,原始类的数组不需要为每个元素指向堆的不同部分,这使得程序运行得更快、更高效。必要时,JVM可以将原始类“装箱”为值类,反之亦然2

NOTE

要了解Project Valhalla的最新进展并跟踪其持续进步,你可以利用多种资源。OpenJDK的Project Valhalla邮件列表3是一个很好的起点。此外,你可以导出GitHub仓库4以获得深入见解并访问源代码。如需动手实践,最新的早期访问可下载二进制文件5提供了一种实验OpenJDK原型的实用方式,允许你探索许多这些新特性。

值得注意的是,Project Valhalla还为JVM中的八种基本类型引入了原始类:bytecharshortintlongbooleanfloatdouble。这使得类型系统更优雅、更容易理解,因为不再需要将这些类型与其他类型区别对待。为基本类型引入这些原始类是Project Valhalla优化Java性能和内存管理工作的重要方面,使该语言在各种应用中更高效。

Project Valhalla正在努力改进泛型,使它们能更好地与值类配合使用。目标是帮助通用API在与值类一起使用时表现更好。

内存访问性能与效率

Project Valhalla的主要目标之一是提高内存访问性能和效率。通过允许值类和原始类直接存储在内存中,并通过改进泛型使其更好地与这些类型配合使用,Project Valhalla旨在减少内存使用并提高Java应用的性能。这对列表或数组尤其有利,因为它们可以将值直接存储在内存块中,而不需要单独的堆指针。

早期访问版本:推进Project Valhalla的概念

Project Valhalla JEP 401:值类和对象6的早期访问版本标志着实现该项目雄心壮志目标的重要一步。虽然前面的部分讨论了值类的理论框架,但早期访问版本通过具体的实现和功能将这些概念具体化。主要亮点包括:

  • 值对象的实现:根据Java语言规范对值对象7的要求,此版本在Java中引入了值对象,强调其无标识行为,以及在局部变量或方法参数中进行内联、无分配编码的能力。
  • 增强功能与实验性特性:EA版本试验了扁平化字段和数组等功能,开发者可以使用.ref后缀来引用原始类的相应引用类型。这种方法旨在提供无空值原始值类型,可能改变Java在内存管理和数据表示方面的策略。
  • 用于实验的命令行选项:开发者可以使用诸如-XDenablePrimitiveClasses-XX:+EnablePrimitiveClasses之类的选项解锁对原始类的实验性支持,而其他选项如-XX:InlineFieldMaxFlatSize=n-XX:FlatArrayElementMaxSize=n允许开发者为扁平化字段和数组组件设置限制。
  • HotSpot优化:HotSpot的C2编译器中针对值对象的有效处理进行了重大优化,旨在减少堆分配的需求。

凭借这些增强功能,Project Valhalla走出了理论领域,进入了更具体的形式,展示了其特性的实际含义和潜力。当开发者探索这些特性时,需要注意兼容性影响。将标识类重构为值类可能会影响现有代码。此外,值类现在可以声明为记录并实现可序列化,从而扩展其功能。核心反射也已更新以支持这些新修饰符。最后,开发者必须理解,此EA版本中由javac生成的值类文件是专门为此版本量身定制的。它们可能与其他JVM或第三方工具不兼容,这表明这些特性的实验性质。

使用场景案例:将理论付诸实践

在高性能计算和数据处理应用中,值类可以提供巨大的好处。例如,处理大量不可变对象的金融模拟可以利用值类的内存效率和速度,实现更高效的处理8。增强的泛型使API设计者能够创建更通用且更简单的API,促进更广泛的应用性和更简便的维护。

并发应用可以显著受益于值类固有的线程安全性。值类的不可变性与线程安全性一致,减少了多线程环境中与同步相关的复杂性。

与其他语言的比较

Java即将推出的值类(作为Project Valhalla的一部分)旨在优化内存使用和性能,类似于C#的值类型。然而,Java的值类预计在方法和接口方面提供增强的功能,这与C#使用struct的更传统方法不同。虽然C#的struct提供了高效的内存管理,但当它们用作引用类型时,会面临与装箱相关的性能开销。Java的方法则侧重于提供更优化、更灵活的替代方案。

与 C# 使用 struct 的传统方法不同,Java 采用了基于方法和接口的路径。尽管 C# 的 struct 能提供高效的内存管理,但当它们作为引用类型使用时,会因装箱而产生性能开销。Java 的策略重点在于避免此类开销并提升运行时效率,尤其是在集合和数组中。8

Kotlin 的 data 类提供了与 Java record 类似的函数式便利,能自动生成 equals()hashCode() 等常用方法,但并未针对内存优化进行定制。相反,Kotlin 的 inline 类才是优化内存的选择。Inline 类通过在编译时将值直接嵌入代码中来避免开销,这与 Java 在 Valhalla 项目中提出的值类型目标相似,都是为了降低运行时成本。9

Scala 的 case 类在表示不可变数据结构方面非常高效,促进了函数式编程实践。它们原生提供了模式匹配、equals()hashCode()copy() 方法。但与 Java 预期的值类型不同,Scala 的 case 类并非专门针对内存优化或内联存储。其优势在于促进不可变、易于模式匹配的数据建模,而非值类型所擅长的底层内存管理或性能优化。10

结论

在本章中,我们探讨了 Java 类型系统的重大进展,直至 Valhalla 项目所带来的革新。从原始类型和引用类型的基础使用,到值类型和增强泛型的雄心勃勃引入,Java 的演进体现了对效率和性能的一贯追求。

Valhalla 项目尤其标志着这一旅程中的重要节点,它提出的概念有望进一步优化 Java 的能力。这些改变对于 Java 新手和有经验的开发者都至关重要,提供了更高效、更有效的编码工具。展望未来,Valhalla 项目为 Java 在编程世界中的持续相关性奠定了基础——它代表了在保持语言核心优势的同时实现现代化的承诺。


Footnotes

  1. Nicolai Parlog. “Java’s Evolution into 2022: The State of the Four Big Initiatives.” Java Magazine, February 18, 2022. https://blogs.oracle.com/javamagazine/post/java-jdk-18-evolution-valhalla-panama-loom-amber.

  2. JEP 401: Value Classes and Objects (Preview). https://openjdk.org/jeps/401.

  3. https://mail.openjdk.org/mailman/listinfo/valhalla-dev

  4. https://github.com/openjdk/valhalla/tree/lworld

  5. https://jdk.java.net/valhalla/

  6. https://openjdk.org/jeps/401

  7. https://cr.openjdk.org/~dlsmith/jep8277163/jep8277163-20220830/specs/value-objects-jls.html#jls-8.1.1.5

  8. https://openjdk.java.net/projects/valhalla/ 2

  9. https://kotlinlang.org/docs/reference/data-classes.html

  10. https://docs.scala-lang.org/tour/case-classes.html