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

Dan*_*dis 7 java static-initialization order-of-execution

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

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

问题

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

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

单独运行时,问题不会重现;但是当作为正在执行的许多测试的一部分运行时,一个更大的构建 ( 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());
    }
}
Run Code Online (Sandbox Code Playgroud)

底层代码

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

public static PlatformEnum getCurrentPlatform() {
    return currentPlatform;
}
Run Code Online (Sandbox Code Playgroud)

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

private static final PlatformEnum currentPlatform = queryCurrentPlatform();
Run Code Online (Sandbox Code Playgroud)

在哪里

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
    }
}
Run Code Online (Sandbox Code Playgroud)

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

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

public static final boolean isWindows() {
    return osType == WINDOWS || osType == WINDOWSCE;
}
Run Code Online (Sandbox Code Playgroud)

定义osTypestatic 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
}
Run Code Online (Sandbox Code Playgroud)

根据我对初始化顺序的理解,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 语言规范:

mer*_*ike 5

它不是多线程,因为 JVM 在初始化类时会阻止其他线程访问该类。此行为是 Java 语言规范第 12.4.2 节第 2 步规定的:

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

JVM 在该区域出现 bug 的可能性极小,因为它会导致重复执行初始化程序,这一点非常明显。

但是,如果出现以下情况,静态最终字段可能会显示出变化的值:

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

    同一部分,步骤 3 写道:

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

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

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

  • 该类由多个类加载器加载

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

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

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

从你提供的证据来看,不可能说出哪一个适用。这就是为什么我建议进一步调查。