在 Kotlin CI 测试期间,静态最终变量初始化(在 Java 中)不正确

Static final variable initialization (in Java) incorrect during Kotlin CI Tests

我管理一个开源项目,有一个用户报告了一种情况,我认为根据 Java 在 classes 中静态变量的初始化顺序是不可能的。 static final class 变量的值不正确,显然是由于依赖项的静态方法基于它自己的静态最终变量的不同结果。

我想了解发生了什么,以便找到最佳解决方法。此刻,我很困惑。

问题

我的项目的主要入口点是 class SystemInfo,它具有以下构造函数:

public SystemInfo() {
    if (getCurrentPlatform().equals(PlatformEnum.UNKNOWN)) {
        throw new UnsupportedOperationException(NOT_SUPPORTED + Platform.getOSType());
    }
}

单独运行时,问题不会重现;但是当 运行 作为许多测试的一部分执行更大的构建 (mvn install) 时,它始终是可重现的,暗示问题可能与多线程或多个分支有关 . (澄清一下:我的意思是在两个不同的 classes 中同时初始化静态成员,以及与此过程关联的各种 JVM 内部 locking/synchronization 机制。)

他们收到以下结果:

java.lang.UnsupportedOperationException: Operating system not supported: JNA Platform type 2

此异常意味着当 SystemInfo 实例化开始时有两件事为真:

但是,这种情况应该是不可能的;值 2 将 return WINDOWS,而未知值将 return 不是 2 的值。由于两个变量都是 staticfinal,因此它们绝不能同时存在达到这个状态。

(用户的)MCRE

我试图自己重现这个但失败了,我依赖于用户在他们基于 Kotlin 的 (kotest) 框架中执行测试的报告。

用户的 MCRE 只是调用此构造函数作为大量测试的一部分,运行在 Windows 操作系统上:

public class StorageOnSystemJava {
    public StorageOnSystemJava(SystemInfo info) {
    }
}

class StorageOnSystemJavaTest {
    @Test
    void run() {
        new StorageOnSystemJava(new SystemInfo());
    }
}

底层代码

getCurrentPlatform() 方法只是 return 这个 static final 变量的值。

public static PlatformEnum getCurrentPlatform() {
    return currentPlatform;
}

这是一个 static final 变量,填充为 class 中的第一行(因此它应该是第一个初始化的东西):

private static final PlatformEnum currentPlatform = queryCurrentPlatform();

哪里

private static PlatformEnum queryCurrentPlatform() {
    if (Platform.isWindows()) {
        return WINDOWS;
    } else if (Platform.isLinux()) {
        // other Platform.is*() checks here
    } else {
        return UNKNOWN; // The exception message shows the code reaches this point
    }
}

这意味着在 class 初始化期间,所有 Platform.is*() 检查 return 为假。

但是,如上所述,这不应该发生。这些是对 JNA 的 Platform class 静态方法的调用。第一个检查应该 returned true(如果在构造函数中调用或在实例化后的代码中的任何地方调用)是:

public static final boolean isWindows() {
    return osType == WINDOWS || osType == WINDOWSCE;
}

其中 osType 是这样定义的 static final 变量:

public static final int WINDOWS = 2;

private static final int osType;

static {
    String osName = System.getProperty("os.name");
    if (osName.startsWith("Linux")) {
        // other code
    }
    else if (osName.startsWith("Windows")) {
        osType = WINDOWS; // This is the value being assigned, showing the "2" in the exception
    }
    // other code
}

根据我对初始化顺序的理解,Platform.isWindows()应该总是returntrue(在WindowsOS上)。我不明白从我自己的代码的静态变量初始化调用时它怎么可能 return false 。我已经尝试了静态方法和变量声明后的静态初始化块。

预期的初始化顺序

  1. 用户调用 SystemInfo 构造函数
  2. SystemInfo class 初始化开始(“T 是一个 class 并且创建了 T 的一个实例。”)
  3. 初始值设定项遇到static final currentPlatform变量(class的第一行)
  4. 初始化器调用静态方法queryCurrentPlatform()获得结果(如果在静态变量声明后立即在静态块中赋值,结果相同)
  5. 调用了Platform.isWindows()静态方法
  6. Platform class 已初始化(“T 是一个 class 并且调用了 T 的静态方法。”)
  7. Platform class 将 osType 值设置为 2 作为初始化的一部分
  8. Platform初始化完成后,静态方法isWindows()returnstrue
  9. queryCurrentPlatform() 看到 true 结果并设置 currentPlatform 变量值(这没有按预期发生!
  10. SystemInfoclass初始化完成后,其构造函数执行,显示冲突值并抛出异常。

解决方法

一些解决方法可以解决问题,但我不明白为什么会这样:

这些解决方法意味着 一个可能的根本原因与在 class 初始化 期间执行多个 class 的 static 方法有关。具体来说:

研究

我已经仔细阅读了多个关于 Java 的教程,清楚地显示了初始化顺序,以及这些其他 SO 问题和链接的 Java 语言规范:

免责声明: 我写这个作为答案,因为我不知道如何让它适合评论。如果对您没有帮助,请告诉我,我会删除它。


让我们从一个小的回顾开始,考虑到问题的质量,我相信你已经知道了:

  • static 到 class 的字段意味着它对任何实例只存在一次。无论您创建多少个 class 实例,该字段将始终指向相同的内存地址。
  • 字段final表示一旦初始化,其值就不能再改变。

因此,当您将这两个混合到一个 static final 字段中时,这意味着:

  • 无论有多少个实例,该字段只有一个值
  • 一旦赋值,就不再改变

所以,我怀疑不存在任何线程安全问题(我不认为你运行并行测试,所以我猜没有两个线程会同时工作这些对象,对吗?),而是 您的测试套件之前的测试以不同方式初始化变量,并且由于它们 运行 进入同一个 JVM,它们不再更改它们的值.

以这个非常简单的测试为例。

我有一个非常基本的 class:

public final class SomeClass {

    private static final boolean FILE_EXISTS;

    static {
        FILE_EXISTS = new File("test").exists();
    }

    public SomeClass() {
        System.out.println("File exists? " + FILE_EXISTS);
    }

}

上面的 class 只是有一个 static final boolean 说明工作目录中是否存在名为 test 的特定文件。 如您所见,该字段被初始化一次 (final),并且每个实例都相同。

现在,让我们运行进行这两个非常简单的测试:

@Test
public void test_some_class() throws IOException {
    System.out.println("Running test_some_class");
    File testFile = new File("test");
    if (testFile.exists()) {
        System.out.println("Deleting file: " + testFile.delete());
    } else {
        System.out.println("Could create the file test: " + testFile.createNewFile());
    }
    SomeClass instance1 = new SomeClass();
}

@Test
public void other_test_some_class() {
    System.out.println("Running other_test_some_class");
    SomeClass instance2 = new SomeClass();
}

在第一个测试中,我检查文件 test 是否存在。如果它确实存在,我会删除它。否则,我会创建它。 然后,我将初始化一个 new SomeClass().

第二次测试,我简单的初始化了一个new SomeClass().

这是我测试的输出 运行 在一起:

Running other_test_some_class //<-- JUnit picks the second test to start
File exists? false //<-- The constructor of SomeClass() prints the static final variable: file doesn't exist
Running test_some_class //<-- JUnit continues running the first test
Could create the file test: true //<-- it is able to create the file
File exists? false //<-- yet, the initializer of new SomeClass() still prints false

它打印false的原因,即使我们在初始化new SomeClass()之前明确创建了test文件,是因为字段FILE_EXISTSstatic(因此在所有实例之间共享)和 final(因此初始化一次,永远持续)。

所以如果你想知道为什么 private static final int osType; 有一个值 returns 你 UNKNOWN 当你 运行 mvn install 但当你 运行 单个测试,我只想看看在您的完整测试套件中,哪个测试已经使用您不期望的值对其进行了初始化。

解决方案

有两种解决方案,具体取决于您的生产代码。

从功能上讲,您实际上可能需要此字段 final 到 class 的实例,而不是 static。 如果是这种情况,您应该将它声明为 final 到 class(一旦初始化,它就不会改变,但每个实例仍然有一个不同的值)。

或者,您可能确实需要该字段在生产中为 static final,但在测试期间不需要,因为您每次都初始化一个新的测试上下文。如果是这种情况,您应该将测试插件配置为 reuseForks = false(这意味着为每个测试 class 创建一个新的 JVM 分支,并保证每个测试 class 将开始对您的 static final 领域有一个新的记忆):

        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>${maven.surefire.plugin.version}</version>
            <configuration>
                <forkCount>1</forkCount>
                <reuseForks>false</reuseForks>
            </configuration>
        </plugin>

它不是多线程,因为 JVM 会阻止其他线程在 class 初始化时访问它。 Java 语言规范 section 12.4.2 第 2 步:

强制执行此行为

If the Class object for C indicates that initialization is in progress for C by some other thread, then release LC and block the current thread until informed that the in-progress initialization has completed, at which time repeat this step.

JVM 极不可能在这方面有错误,因为它会导致重复执行初始化程序,这是非常明显的。

但是,在以下情况下,静态 final 字段的值可能会发生变化:

  • 初始化器之间存在循环依赖

    同一部分,第 3 步写道:

    If the Class object for C indicates that initialization is in progress for C by the current thread, then this must be a recursive request for initialization. Release LC and complete normally.

    因此,递归初始化可能允许线程在分配之前读取静态最终字段。这只有在 class 个初始化器在初始化器之间创建循环依赖时才会发生。

  • 有人 (ab) 使用 反射 重新分配静态最终字段

  • class 由 多个 class 加载器加载

    在这种情况下,每个 class 都有自己的静态字段副本,并且可以不同地初始化它。

  • 如果该字段是编译时间常量表达式,并且代码是在不同时间

    规范要求编译器内联编译时常量表达式。如果不同的 classes 在不同的时间被编译,被内联的值可能是不同的。 (在你的情况下,表达式不是编译时间常量;我只是为了将来的访问者而提到这种可能性)。

根据您提供的证据,无法判断哪一个适用。这就是为什么我建议进一步调查。