簡體   English   中英

在 Kotlin CI 測試期間靜態最終變量初始化(在 Java 中)不正確

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

我管理一個開源項目,有一個用戶報告了一種情況,根據 Java 的類中靜態變量的初始化順序,我認為這是不可能的。 static final類變量的值不正確,顯然是由於依賴項的靜態方法基於其自己的靜態最終變量的不同結果造成的。

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

問題

我的項目的主要入口點是SystemInfo類,它具有以下構造函數:

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

單獨運行時,問題不會重現; 但是當作為正在執行的許多測試的一部分運行時,一個更大的構建 ( mvn install ) 它始終是可重現的,這意味着問題可能與多線程或多個 fork 相關 (澄清:我的意思是同時初始化兩個不同類中的靜態成員,以及與此過程相關的各種 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 (如果在構造函數或實例化后的代碼中的任何地方調用,則返回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 語言規范第 12.4.2 節第 2 步強制要求:

如果C的 Class 對象指示其他線程正在對C進行初始化,則釋放LC並阻塞當前線程,直到通知正在進行的初始化已完成,此時重復此步驟。

JVM 極不可能在這方面存在錯誤,因為它會導致重復執行初始化程序,這將非常明顯。

但是,如果出現以下情況,靜態 final 字段可能會出現變化值:

  • 初始值設定項之間存在循環依賴

    同一部分,第 3 步寫道:

    如果C的 Class 對象指示當前線程正在對C進行初始化,那么這必須是一個遞歸的初始化請求。 釋放LC並正常完成。

    因此,遞歸初始化可能允許線程在分配之前讀取靜態最終字段。 只有當類初始值設定項在初始值設定項之間創建循環依賴時才會發生這種情況。

  • 有人(ab)使用反射重新分配靜態最終字段

  • 該類由多個類加載器加載

    在這種情況下,每個類都有自己的靜態字段副本,並且可能以不同的方式對其進行初始化。

  • 如果字段是編譯時常量表達式,並且代碼是在不同時間編譯的

    規范要求編譯時常量表達式由編譯器內聯。 如果不同的類在不同的時間編譯,被內聯的值可能不同。 (在您的情況下,表達式不是編譯時間常數;我只是為了將來的訪問者才提到這種可能性)。

根據您提供的證據,無法確定其中哪些適用。 這就是為什么我建議進一步調查。

免責聲明:我寫這個作為答案是因為我不知道如何讓它適合評論。 如果它對您沒有幫助,請告訴我,我會刪除它。


讓我們從一個簡短的回顧開始,考慮到問題的質量,我相信你已經知道了:

  • 對於類來說是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_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>

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM