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



我管理一个开源项目,并让一个用户报告一种情况,根据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,而未知将返回除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. CCD_ 22类初始化开始("T是一个类
  3. 初始化器遇到static final currentPlatform变量(类的第一行)
  4. 初始化器调用静态方法queryCurrentPlatform()以获得结果(如果在静态变量声明之后的静态块中分配了值,则结果相同)
  5. Platform.isWindows()静态方法称为
  6. CCD_ 26类被初始化("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语言规范:

  • Java静态类初始化
  • 类中的静态块和静态变量的执行顺序是什么
  • 当类加载到JVM中时,类的不同部分初始化的顺序是什么

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

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

JVM不太可能在这一领域出现错误,因为这会导致重复执行初始化程序,这一点非常明显。

然而,如果出现以下情况,静态最终字段可能会出现变化值:

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

    同一部分,步骤3写道:

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

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

  • 某人(ab)使用反射重新分配静态最终场

  • 类由多个类加载器加载

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

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

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

从你提供的证据来看,不可能说出其中哪一项适用。这就是我建议进一步调查的原因。

免责声明:我写这篇文章是为了回答问题,因为我不知道如何将其融入评论中。如果它对你没有帮助,请告诉我,我会删除它。


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

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

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

  • 无论有多少实例,该字段都只有一个值
  • 一旦指定了值,它就不再更改

所以,我的怀疑不是存在任何线程安全问题(我不认为你是并行运行测试的,所以我想没有两个线程会同时处理这些对象,对吧。

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

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

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

尽管我们在初始化new SomeClass()之前清楚地创建了test文件,但它打印false的原因是字段FILE_EXISTSstatic(因此在所有实例中共享)和final(因此初始化一次,永远持续)。

因此,如果您想知道为什么private static final int osType;在运行mvn install时有一个返回UNKNOWN的值,而在运行单个测试时没有,我只想看看在您的完整测试套件中,哪个测试已经用您意想不到的值初始化了它。

解决方案

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

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

或者,您可能真的需要在生产中使用static final字段,但在每次初始化新测试上下文的测试过程中不需要。如果是这种情况,您应该将测试插件配置为reuseForks=false(这意味着为每个测试类创建一个新的JVM fork,并保证每个测试类将为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>

最新更新