Kotlin CI 测试期间的静态最终变量初始化(Java 中)不正确
Posted
技术标签:
【中文标题】Kotlin CI 测试期间的静态最终变量初始化(Java 中)不正确【英文标题】:Static final variable initialization (in Java) incorrect during Kotlin CI Tests 【发布时间】:2021-09-25 14:57:01 【问题描述】:我管理一个开源项目,并且有一个用户报告了我认为根据 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 以外的值。由于这两个变量都是 static
和 final
,因此它们不应同时达到此状态。
(用户的)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*()
检查都返回错误。
但是,如上所述,这不应该发生。这些是对 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
。我已经尝试了静态方法和紧跟在变量声明之后的静态初始化块。
预期的初始化顺序
-
用户调用
SystemInfo
构造函数
SystemInfo
类初始化开始(“T 是一个类,创建了一个 T 的实例。”)
初始化程序遇到static final currentPlatform
变量(类的第一行)
初始化程序调用静态方法queryCurrentPlatform()
来获取结果(如果在紧跟静态变量声明后的静态块中分配值,则结果相同)
Platform.isWindows()
静态方法被调用
Platform
类已初始化(“T 是一个类,调用了 T 的静态方法。”)
Platform
类在初始化过程中将 osType
值设置为 2
Platform
初始化完成后,静态方法isWindows()
返回true
queryCurrentPlatform()
看到true
结果并设置currentPlatform
变量值(这与预期不同!)
SystemInfo
类初始化完成后,将执行其构造函数,显示冲突值并引发异常。
解决方法
一些变通方法可以解决问题,但我不明白为什么会这样:
在实例化过程中的任何时候(包括构造函数)执行Platform.isWindows()
检查正确返回true
并适当地分配枚举。
currentPlatform
变量的惰性实例化(删除 final
关键字),或忽略枚举并直接调用 JNA 的 Platform
类。
将对static
方法getCurrentPlatform()
的第一次调用移出构造函数。
这些变通方法暗示可能的根本原因与在类初始化期间执行多个类的static
方法有关。具体来说:
Platform.isWindows()
检查显然返回 false
,因为代码到达了 else
块
初始化后(实例化期间),Platform.isWindows()
检查返回true
。 (由于它基于 static final
值,因此不应返回不同的结果。)
研究
我已经彻底查看了多个关于 Java 的教程,清楚地显示了初始化顺序,以及这些其他 SO 问题和链接的 Java 语言规范:
Java static class initialization in what order are static blocks and static variables in a class executed? In what order are the different parts of a class initialized when a class is loaded in the JVM?【问题讨论】:
在类顶部移动静态初始化程序块不起作用? 也许应该先问这个问题,但是...private static final PlatformEnum currentPlatform = queryCurrentPlatform();
与private static final int osType;
在同一个类中并且在其静态初始化块之上?如果是,那么我想知道你是如何设法将除 0 以外的任何内容分配为 osType
首先。
我会通过向所有涉及的类添加调试日志来调查这一点,并查看失败运行产生的日志文件(要将日志输出添加到已编译的类,我会在调试器中执行代码并通过提供在返回 true 之前写入 System.out
的“条件”来滥用条件断点)。这样,很容易验证这是一个多线程问题,还是相互依赖的类在完全初始化之前相互看到,或者完全是其他什么。
(当然,如果故障不是特定于时间的,您可以简单地在调试器中执行测试套件,添加一些断点,然后单步执行代码以查看发生了什么)跨度>
会不会是一些测试改变了os.name
系统属性?
【参考方案1】:
这不是多线程,因为 JVM 会阻止其他线程在类被初始化时访问它。此行为由 Java 语言规范 section 12.4.2 第 2 步规定:
如果
C
的 Class 对象表明其他线程正在对C
进行初始化,则释放LC
并阻塞当前线程,直到通知正在进行的初始化已完成,此时重复此步骤。
JVM 极不可能在这方面出现错误,因为它会导致重复执行初始化程序,这将非常明显。
但是,如果出现以下情况,静态最终字段的值可能会发生变化:
初始化器之间存在循环依赖
同一部分,第 3 步写道:
如果
C
的Class 对象表明当前线程正在对C
进行初始化,那么这一定是对初始化的递归请求。释放LC
,正常完成。
因此,递归初始化可能允许线程在分配静态最终字段之前读取它。只有当类初始化器在初始化器之间创建循环依赖时,才会发生这种情况。
某人 (ab) 使用 反射 重新分配静态最终字段
该类由多个类加载器
加载在这种情况下,每个类都有自己的静态字段副本,并且可能对其进行不同的初始化。
如果该字段是编译时间常数表达式,并且代码是在不同时间编译的
规范要求编译时常量表达式由编译器内联。如果不同的类在不同的时间编译,被内联的值可能不同。 (在您的情况下,表达式不是编译时间常数;我只是为了将来的访问者而提到这种可能性)。
根据您提供的证据,无法确定哪些适用。这就是为什么我建议进一步调查。
【讨论】:
好的,所以在与用户反复讨论之后,他们发现他们在另一个测试中使用了反射。由于您的回答明确将反射列为一种可能性,因此我将其标记为已接受。【参考方案2】:免责声明:我写这篇文章是为了回答,因为我不知道如何使它适合评论。如果对你没有帮助,请告诉我,我会删除它。
让我们从一个小回顾开始,考虑到问题的质量,我相信你已经知道了:
对于一个类来说,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
尽管我们在初始化 new SomeClass()
之前明确创建了 test
文件,但它打印 false
的原因是字段 FILE_EXISTS
是 static
(因此在所有实例之间共享)和 final
(因此初始化一次,永久持续)。
因此,如果您想知道为什么 private static final int osType;
的值在您运行 mvn install
时返回您 UNKNOWN
而不是在您运行单个测试时,我只想看看您的完整测试套件中的哪个测试已经使用您不期望的值对其进行了初始化。
解决方案
对此有两种类型的解决方案,它们取决于您的生产代码。
从功能上讲,您实际上可能需要此字段可能是类实例的final
,而不是static
。
如果是这种情况,您应该将其声明为 final
给类(一旦初始化,它就不会改变,但每个实例仍然有一个不同的值)。
或者,您可能确实需要在生产中将该字段设为static final
,但在测试期间不需要,因为您每次都初始化一个新的测试上下文。如果是这种情况,您应该将您的测试插件配置为 reuseForks
= false (这意味着为每个测试类创建一个新的 JVM 分支,这可以保证您每个测试类都将从您的 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>
【讨论】:
感谢您的详细建议。但是,初始化的“嵌套”性质意味着两个类(我的SystemInfo
和 JNA 的 Platform
)将平等地共享相同的一次性静态最终结果。 SystemInfo
类首先开始初始化,然后Platform
开始和结束,然后SystemInfo
结束。作为测试的一部分,询问是否有任何非 Windows 初始化仍然是一个有用的调试问题。
@DanielWiddis 您是否尝试过在一个分支上运行测试而不重复使用它只是为了排除这个可能的原因?
这是第三方用户在私人仓库中进行测试。他们报告自己执行测试不会重现错误。
@DanielWiddis 我不是那个意思。我的意思是,您是否尝试按照我在上面向您展示的那样配置测试插件并再次运行完整构建(mvn install)?
谢谢。我确实理解这里的意图,并将要求用户提供更多信息。我仍然认为在任何给定的 fork /JVM 实例上,两个初始化的类之间都存在 1:1 的关系。我可以理解是否某些东西首先用未知数初始化了 Platform,但是静态最终 osType
值会被“卡”在不同的值上。我能想到的唯一可能的情况是两个类的初始化阶段的一些非常低级别的竞争条件。以上是关于Kotlin CI 测试期间的静态最终变量初始化(Java 中)不正确的主要内容,如果未能解决你的问题,请参考以下文章