微信公众号搜"智元新知"关注
微信扫一扫可直接关注哦!

在 Kotlin CI 测试期间静态最终变量初始化在 Java 中不正确 问题用户的MCRE底层代码预期的初始化顺序解决方法研究

如何解决在 Kotlin CI 测试期间静态最终变量初始化在 Java 中不正确 问题用户的MCRE底层代码预期的初始化顺序解决方法研究

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

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

问题

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

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

自行运行时,问题不会重现;但是当作为正在执行的大型构建 (mvn install) 的许多测试的一部分运行时,它始终是可重现的,暗示该问题可能与多线程或多个分叉有关。 (澄清一下:我的意思是同时初始化两个不同类中的静态成员,以及与此过程相关的各种 JVM 内部锁定/同步机制。)

他们收到以下结果:

java.lang.UnsupportedOperationException:不支持操作系统:JNA 平台类型 2

这个异常意味着当 SystemInfo 实例化开始时有两件事是正确的:

  • getCurrentPlatform() 的结果是枚举值 PlatformEnum.UNKNowN
  • Platform.getoSType() 的结果是 2

不过,这种情况应该是不可能的;值 2 将返回 WINDOWS,而 unkNown 将返回一个非 2 的值。由于两个变量都是 staticfinal,因此它们不应同时达到此状态。

用户的)MCRE

我曾尝试自行重现此问题但失败了,我依赖于在基于 Kotlin(kotest)的框架中执行测试的用户的报告。

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

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

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

底层代码

getCurrentPlatform() 方法仅返回此 static final 变量的值。

public static PlatformEnum getCurrentPlatform() {
    return currentPlatform;
}

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

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
    }
}

这意味着在类初始化期间,所有 Platform.is*() 检查都返回 false。

但是,如上所述,这不应该发生。这些是对 JNA 的 Platform 类静态方法调用。第一个检查应该返回 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() 应该总是返回 true(在 Windows 操作系统上)。我不明白从我自己的代码的静态变量初始化调用时它怎么可能返回 false。我已经尝试了静态方法和紧跟在变量声明之后的静态初始化块。

预期的初始化顺序

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

解决方法

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

  • 在实例化过程(包括构造函数)期间随时执行 Platform.isWindows() 检查正确返回 true 并适当分配枚举。

    • 包括 currentPlatform 变量的延迟实例化(删除 final 关键字),或忽略枚举并直接调用 JNA 的 Platform 类。
  • 将对 static 方法 getCurrentPlatform() 的第一次调用移出构造函数

这些变通办法意味着一个可能的根本原因与在类初始化期间执行多个类的 static 方法有关。具体:

  • 在初始化期间,Platform.isWindows() 检查显然返回 false,因为代码到达了 else
  • 初始化后(在实例化期间),Platform.isWindows() 检查返回 true。 (由于它基于 static final 值,因此不应返回不同的结果。)

研究

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

解决方法

这不是多线程,因为 JVM 会在类初始化时阻止其他线程访问该类。此行为由 Java 语言规范 section 12.4.2 步骤 2 强制要求:

如果 C 的 Class 对象指示其他线程正在进行 C 的初始化,则释放 LC 并阻塞当前线程,直到通知正在进行初始化已完成,此时重复此步骤。

JVM 在这方面存在 bug 的可能性极小,因为它会导致重复执行初始化程序,这将非常明显。

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

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

    同一部分,第 3 步写道:

    如果 C 的 Class 对象指示当前线程正在进行 C 的初始化,那么这一定是一个递归的初始化请求。释放 LC 并正常完成。

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

  • 某人 (ab) 使用 reflection 重新分配静态 final 字段

  • 该类由多个类加载器

    加载

    在这种情况下,每个类都有自己的静态字段副本,并且可能以不同的方式对其进行初始化。

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

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

根据您提供的证据,无法确定哪些适用。这就是我建议进一步调查的原因。

,

免责声明:我写这个是为了回答,因为我不知道如何使它适合评论。如果它没有帮助你让我知道,我会删除它。


让我们先简要回顾一下问题的质量,我相信您已经知道了:

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

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

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

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

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

我有一个非常基础的课程:

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);
    }

}

上面的类只有一个 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 之前明确创建了 test 文件,但它打印 new SomeClass() 的原因是字段 FILE_EXISTSstatic (因此在所有实例之间共享)和 final(因此初始化一次,永远持续)。

因此,如果您想知道为什么 private static final int osType; 的值在您运行 UNKNOWN 时返回 mvn install 而不是在您运行单个测试时,我只是看看是什么测试,在您的完整测试套件已经使用您不期望的值对其进行了初始化。

解决方案

对此有两种类型的解决方案,它们取决于您的生产代码。

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

或者,您可能真的需要在生产中将该字段设为 static final,但在测试期间不需要,因为每次都初始化一个新的测试上下文。如果是这种情况,您应该将您的测试插件配置为 reuseForks = false(这意味着为每个测试类创建一个新的 JVM fork,这保证您每个测试类都将从您的 {{ 1}} 个字段):

static final

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。