1. 程式人生 > >Thread 類中的幾個細節(二)

Thread 類中的幾個細節(二)

相關部落格:

Thread 類中的幾個細節(一)

Thread 類中有這樣一個建構函式:

Thread

public Thread(ThreadGroup group,
              Runnable target,
              String name,
              long stackSize)

分配新的 Thread 物件,以便將 target 作為其執行物件,將指定的 name 作為其名稱,作為 group

 所引用的執行緒組的一員,並具有指定的 堆疊大小

除了允許指定執行緒堆疊大小以外,這種構造方法與 Thread(ThreadGroup,Runnable,String) 完全一樣。堆疊大小是虛擬機器要為該執行緒堆疊分配的地址空間的近似位元組數。stackSize 引數(如果有)的作用具有高度的平臺依賴性。

在某些平臺上,指定一個較高的 stackSize 引數值可能使執行緒在丟擲 StackOverflowError 之前達到較大的遞迴深度。同樣,指定一個較低的值將允許較多的執行緒併發地存在,且不會丟擲 

OutOfMemoryError(或其他內部錯誤)。stackSize 引數的值與最大遞迴深度和併發程度之間的關係細節與平臺有關。在某些平臺上,stackSize 引數的值無論如何不會起任何作用。

作為建議,可以讓虛擬機器自由處理 stackSize 引數。如果指定值對於平臺來說過低,則虛擬機器可能使用某些特定於平臺的最小值;如果指定值過高,則虛擬機器可能使用某些特定於平臺的最大值。 同樣,虛擬機器還會視情況自由地舍入指定值(或完全忽略它)。

將 stackSize 引數值指定為零將使這種構造方法與 Thread(ThreadGroup, Runnable, String) 構造方法具有完全相同的作用。

由於這種構造方法的行為具有平臺依賴性,因此在使用它時要非常小心。執行特定計算所必需的執行緒堆疊大小可能會因 JRE 實現的不同而不同。鑑於這種不同,仔細調整堆疊大小引數可能是必需的,而且可能要在支援應用程式執行的 JRE 實現上反覆調整。

實現注意事項:鼓勵 Java 平臺實現者文件化其 stackSize parameter 的實現行為。

 

引數:

group - 執行緒組。

target - 其 run 方法被呼叫的物件。

name - 新執行緒的名稱。

stackSize - 新執行緒的預期堆疊大小,為零時表示忽略該引數。

丟擲:

SecurityException - 如果當前執行緒無法在指定的執行緒組中建立執行緒。

從以下版本開始:

1.4

有一個 stackSize 引數 ,結合 JDK 的描述,stackSize 與平臺是有一定關係的,在某些平臺上,stackSize 引數的值無論如何不會起任何作用。

結合註釋:

 /*
     * The requested stack size for this thread, or 0 if the creator did
     * not specify a stack size.  It is up to the VM to do whatever it
     * likes with this number; some VMs will ignore it.
     */
    private long stackSize;

如果未指定該引數,預設是 0,JVM 會忽略該引數,猜想應該會交由其他 native 方法控制。 

JVM 在執行 Java 程式的時候會把對應的實體記憶體劃分成不同的記憶體區域,每一個區域都存放著不同的資料,也有不同的建立與銷燬時機,有些分割槽會在 JVM 啟動的時候就建立,有些則是在執行時才建立,比如虛擬機器棧,根據虛擬機器規範,JVM 的記憶體結構如下:

程式計數器

無論任何語言,其實最終都是需要由作業系統通過控制匯流排向 CPU 傳送機器指令,Java 也不例外,程式計數器在 JVM 中所起的作用就是用於存放當前執行緒接下來將要執行的位元組碼指令、分支、迴圈、跳轉、異常處理等資訊。在任何時候,一個處理器只執行其中一個執行緒中的指令,為了能夠在 CPU 時間片輪轉切換上下文之後順利回到正確的執行位置,每條執行緒都需要具有一個獨立的程式計數器,各個執行緒之間互相不影響,因此 JVM 將此塊記憶體區域設計成了執行緒私有的。

Java 虛擬機器棧

其與執行緒緊密關聯,與程式計數器記憶體相類似,Java 虛擬機器棧也是執行緒私有的,它的生命週期與執行緒相同,是在 JVM 執行時所建立的,線上程中,方法在執行的時候都會建立一個名為棧幀(stack frame)的資料結構,主要用於存放區域性變量表、操作棧、動態連結、方法出口等資訊,方法的呼叫對應著棧幀在虛擬機器棧中的壓棧和彈棧過程。

每一個執行緒在建立的時候,JVM 都會為其建立對應的虛擬機器棧,虛擬機器棧的大小可以通過 -xss 來配置,方法的呼叫是棧幀被壓入和彈出的過程。同等的虛擬機器棧如果區域性變量表等佔用記憶體越小則可被壓人的棧幀就會越多,反之則可被壓人的棧幀就會越少,一般將棧幀記憶體的大小稱為寬度,而棧幀的數量則稱為虛擬機器棧的深度。

本地方法棧

Java 中提供了呼叫本地方法的介面(Java Native Interface),也就是 C/C++ 程式,線上程的執行過程中,經常會碰到呼叫 JNI 方法的情況,比如網路通訊、檔案操作的底層,甚至是 String 的 intern 等都是 JNI 方法,JVM 為本地方法所劃分的記憶體區域便是本地方法棧,這塊記憶體區域其自由度非常高,完全靠不同的 JVM 廠商來實現,Java 虛擬機器規範並未給出強制的規定,同樣它也是執行緒私有的記憶體區域。

堆記憶體

堆記憶體是 JVM 中最大的一塊記憶體區域,被所有的執行緒所共享,Java 在執行期間建立的所有物件幾乎都存放在該記憶體區域,該記憶體區域也是垃圾回收器重點照顧的區域,因此有些時候堆記憶體被稱為“GC 堆”。

堆記憶體一般會被細分為新生代和老年代,更細緻的劃分為 Eden 區、From Survivo 區和 To Survivor 區。

方法區

方法區也是被多個執行緒所共享的記憶體區域,他主要用於儲存已經被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器(JIT )編譯後的程式碼等資料,雖然在 Java 虛擬機器規範中,將堆記憶體劃分為堆記憶體的一個邏輯分割槽,但是它還是經常被稱為“非堆”,有時候也被稱為“持久代”,主要是站在垃圾回收器的角度進行劃分,但是這種叫法比較欠妥,在 HotSpot JVM 中,方法區還會被細劃分為持久代和程式碼快取區,程式碼快取區主要用於儲存編譯後的原生代碼(和硬體相關)以及 JIT(Just In Time)編譯器生成的程式碼,當然不同的 JVM 會有不同的實現。

Java 8 元空間

上述內容大致介紹了 JVM 的記憶體劃分,在 JDK 1.8 版本以前的記憶體大概都是這樣劃分的,但是自 JDK 1.8 版本起,JVM 的記憶體區域發生了一些改變,實際上是持久代記憶體被徹底刪除,取而代之的是元空間,下圖是使用分別使用不同版本的jstat命令對比
 JVM 的 GC 記憶體分佈。

JDK 1.7 版本的 jstat:

JDK 1.8 版本的 jstat:

通過對比會發現在 JDK 1.7 版本中存在持久代記憶體區域,而在 JDK 1.8 版本中,該記憶體區域被 Meta Space 取而代之了,元空間同樣是堆記憶體的一部分,JVM 為每個類載入器分配一塊記憶體塊列表,進行線性分配,塊的大小取決於類載入器的型別,sun/反射/代理對應的類載入器塊會小一些,之前的版本會單獨解除安裝回收某個類,而現在則是 GC 過程中發現某個類載入器已經具備回收的條件,則會將整個類載入器相關的元空間全部回收,這樣就可以減少記憶體碎片,節省 GC 掃描和壓縮的時間。

package com.example.threaddesign;

/**
 * @author Dongguabai
 * @date 2018/12/2 20:58
 */
//class 的一些資訊也是放在方法區
public class ThreadTest {

    //存放在方法區
    private static int i = 0;

    //引用地址會放到方法區,具體資料會放到堆
    private byte[] bytes = new byte[1024];

    //JVM 會建立 main 執行緒
    public void main(String[] args) {
        //會為 main 執行緒開闢一個虛擬機器棧

        //告訴 CPU下一步要執行什麼,需要程式計數器

       // m 直接放到區域性變量表
       int m = 1;

       //區域性變量表中會有 arr 的地址,資料還是放到堆中
       int[] arr = new int[1024];
    }

}

演示這樣一段程式碼,一個很簡單的無限遞迴操作,會進行無數個棧操作:

package com.example.threaddesign;

/**
 * @author Dongguabai
 * @date 2018/12/2 20:58
 */
public class ThreadTest {

    private static int COUNT = 0;

    public static void main(String[] args) {
        try {
            add(0);
        } catch (Error error) {
            error.printStackTrace();
            System.out.println(COUNT);
        }
    }

    private static void add(int i) {
        COUNT++;
        add(i + 1);
    }

}

很明顯會出現異常,執行結果:

Exception in thread "main" java.lang.StackOverflowError
	at com.example.threaddesign.ThreadTest.add(ThreadTest.java:14)
	at com.example.threaddesign.ThreadTest.add(ThreadTest.java:14)
	at com.example.threaddesign.ThreadTest.add(ThreadTest.java:14)
	at com.example.threaddesign.ThreadTest.add(ThreadTest.java:14)
	at com.example.threaddesign.ThreadTest.add(ThreadTest.java:14)
	at com.example.threaddesign.ThreadTest.add(ThreadTest.java:14)
	at com.example.threaddesign.ThreadTest.add(ThreadTest.java:14)
	at com.example.threaddesign.ThreadTest.add(ThreadTest.java:14)
...
23542

這是一個 Error 級別的異常,棧溢位,即棧操作了 23542 次。

再回過頭來看 long stackSize 引數:

除了允許指定執行緒堆疊大小以外,這種構造方法與 Thread(ThreadGroup,Runnable,String) 完全一樣。堆疊大小是虛擬機器要為該執行緒堆疊分配的地址空間的近似位元組數。stackSize 引數(如果有)的作用具有高度的平臺依賴性。

在某些平臺上,指定一個較高的 stackSize 引數值可能使執行緒在丟擲 StackOverflowError 之前達到較大的遞迴深度。同樣,指定一個較低的值將允許較多的執行緒併發地存在,且不會丟擲 OutOfMemoryError(或其他內部錯誤)。stackSize 引數的值與最大遞迴深度和併發程度之間的關係細節與平臺有關。在某些平臺上,stackSize 引數的值無論如何不會起任何作用。

作為建議,可以讓虛擬機器自由處理 stackSize 引數。如果指定值對於平臺來說過低,則虛擬機器可能使用某些特定於平臺的最小值;如果指定值過高,則虛擬機器可能使用某些特定於平臺的最大值。 同樣,虛擬機器還會視情況自由地舍入指定值(或完全忽略它)。

將 stackSize 引數值指定為零將使這種構造方法與 Thread(ThreadGroup, Runnable, String) 構造方法具有完全相同的作用。

由於這種構造方法的行為具有平臺依賴性,因此在使用它時要非常小心。執行特定計算所必需的執行緒堆疊大小可能會因 JRE 實現的不同而不同。鑑於這種不同,仔細調整堆疊大小引數可能是必需的,而且可能要在支援應用程式執行的 JRE 實現上反覆調整。

 結合這段描述,我們可以根據指定 stackSize 的大小來控制“最大遞迴深度”,但是由於現在這段測試程式碼是在 main() 方法中執行的,而 main 執行緒是 JVM 建立的,我們無法指定 stackSize 的大小,接下來再這樣測試:

package com.example.threaddesign;

/**
 * @author Dongguabai
 * @date 2018/12/2 20:58
 */
public class ThreadTest {

    private static int COUNT_1 = 0;

    public static void main(String[] args) {
        new Thread(null,()->{
            try {
                add_1(0);
            } catch (Error error) {
                error.printStackTrace();
                System.out.println(Thread.currentThread().getName()+"--->"+COUNT_1);
            }
        },"Thread-1",1<<24).start();

    }

    private static void add_1(int i) {
        COUNT_1++;
        add_1(i + 1);
    }

}

輸出結果:

java.lang.StackOverflowError
	at com.example.threaddesign.ThreadTest.add_1(ThreadTest.java:25)
	at com.example.threaddesign.ThreadTest.add_1(ThreadTest.java:25)
...
Thread-1--->1019821

再更改 stackSize 的大小為 1<<12,執行結果:

java.lang.StackOverflowError
	at com.example.threaddesign.ThreadTest.add_1(ThreadTest.java:25)
	at com.example.threaddesign.ThreadTest.add_1(ThreadTest.java:25)
...
Thread-1--->36810

可以很明顯的看出區別。  

但是要注意的是 JVM 建立的棧的大小事沒變的,也就是說如果一個執行緒在虛擬機器棧中佔用的棧空間過大,那麼相應的可以併發的執行緒的數量就少了。那麼帶著這個猜想再測試一下:

虛擬機器棧記憶體是執行緒私有的,也就是說每一個執行緒都會佔有指定的記憶體大小,我們粗略地認為一個 Java 程序的記憶體大小為:堆記憶體 + 執行緒數量 * 棧記憶體。

不管是 32 位作業系統還是 64 位作業系統,一個程序的最大記憶體是有限制的,比如 32 位的 Windows 作業系統所允許的最大程序記憶體為 2 GB,因此根據上面的公式很容易得出,執行緒數量與棧記憶體的大小是反比關係,那麼執行緒數量與堆記憶體的大小關係呢?當然也是反比關係,只不過堆記憶體是基數,而棧記憶體是係數而已。