深入理解Thread建構函式

禿桔子發表於2019-06-13

 

 上一篇快速認識執行緒

 本文參考汪文君著:Java高併發程式設計詳解。

1、執行緒的命名

在構造現成的時候可以為執行緒起一個名字。但是我們如果不給執行緒起名字,那執行緒會有一個怎樣的命名呢?

這裡我們看一下Thread的原始碼:

 public Thread(ThreadGroup group, Runnable target) {
        init(group, target, "Thread-" + nextThreadNum(), 0);
    }

    /**
     * Allocates a new {@code Thread} object. This constructor has the same
     * effect as {@linkplain #Thread(ThreadGroup,Runnable,String) Thread}
     * {@code (null, null, name)}.
     *
     * @param   name
     *          the name of the new thread
     */
    public Thread(String name) {
        init(null, null, name, 0);
    }

    /**
     * Allocates a new {@code Thread} object. This constructor has the same
     * effect as {@linkplain #Thread(ThreadGroup,Runnable,String) Thread}
     * {@code (group, null, name)}.
     *
     * @param  group
     *         the thread group. If {@code null} and there is a security
     *         manager, the group is determined by {@linkplain
     *         SecurityManager#getThreadGroup SecurityManager.getThreadGroup()}.
     *         If there is not a security manager or {@code
     *         SecurityManager.getThreadGroup()} returns {@code null}, the group
     *         is set to the current thread's thread group.
     *
     * @param  name
     *         the name of the new thread
     *
     * @throws  SecurityException
     *          if the current thread cannot create a thread in the specified
     *          thread group
     */
    public Thread(ThreadGroup group, String name) {
        init(group, null, name, 0);
    }

如果沒有為執行緒起名字,那麼執行緒將會以“Thread-”作為字首與一個自增數字進行組合,這個自增數字在整個JVM程式中將會不斷自增:

如果我們執行以下程式碼:

import java.util.stream.IntStream;

public class Test {
    public static void main(String[] args) {
        IntStream.range(0,5).boxed()
        .map(
                i->new Thread(
                        ()->System.out.println(
                                Thread.currentThread().getName()
                                )
                        )
                ).forEach(Thread::start);
    }
}

這裡使用無參的建構函式建立了5個執行緒,並且分別輸出了各自的名字:

其實Thread同樣提供了這樣的建構函式。如下

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

下面是實現程式碼:

import java.util.stream.IntStream;


public class Test2 {
    private final static String PREFIX="ALEX-";
    public static void main(String[] args) {
        IntStream.range(0,5).mapToObj(Test2::createTHREAD).forEach(Thread::start);
    }
    private static Thread createTHREAD(final int intName) {
        return new Thread(()->System.out.println(Thread.currentThread().getName()),PREFIX+intName);
    }
}

執行效果:

需要注意的是,不論你使用的是預設的命名還是特殊的名字,線上程啟動之後還有一個機會可以對其進行修改,一旦執行緒啟動,名字將不再被修改,下面是setName原始碼:

public final synchronized void setName(String name) {
        checkAccess();
        if (name == null) {
            throw new NullPointerException("name cannot be null");
        }

        this.name = name;
        if (threadStatus != 0) {
            setNativeName(name);
        }
    }

2、執行緒的父子關係

Thread的所有建構函式,最終都會呼叫一個init,我們擷取程式碼片段對其分析,不難發現新建立的任何一個執行緒都會有一個父執行緒:

 private void init(ThreadGroup g, Runnable target, String name,
                      long stackSize, AccessControlContext acc,
                      boolean inheritThreadLocals) {
        if (name == null) {
            throw new NullPointerException("name cannot be null");
        }

        this.name = name;

        Thread parent = currentThread();//在這裡獲取當前執行緒作為父執行緒
        SecurityManager security = System.getSecurityManager();
        if (g == null) {
            /* Determine if it's an applet or not */

            /* If there is a security manager, ask the security manager
               what to do. */
            if (security != null) {
                g = security.getThreadGroup();
            }

            /* If the security doesn't have a strong opinion of the matter
               use the parent thread group. */
            if (g == null) {
                g = parent.getThreadGroup();
            }
        }

        /* checkAccess regardless of whether or not threadgroup is
           explicitly passed in. */
        g.checkAccess();

        /*
         * Do we have the required permissions?
         */
        if (security != null) {
            if (isCCLOverridden(getClass())) {
                security.checkPermission(SUBCLASS_IMPLEMENTATION_PERMISSION);
            }
        }

        g.addUnstarted();

        this.group = g;
        this.daemon = parent.isDaemon();
        this.priority = parent.getPriority();
        if (security == null || isCCLOverridden(parent.getClass()))
            this.contextClassLoader = parent.getContextClassLoader();
        else
            this.contextClassLoader = parent.contextClassLoader;
        this.inheritedAccessControlContext =
                acc != null ? acc : AccessController.getContext();
        this.target = target;
        setPriority(priority);
        if (inheritThreadLocals && parent.inheritableThreadLocals != null)
            this.inheritableThreadLocals =
                ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
        /* Stash the specified stack size in case the VM cares */
        this.stackSize = stackSize;

        /* Set thread ID */
        tid = nextThreadID();
    }

上面的程式碼中的currentThread()是獲取當前執行緒,線上程的生命週期中,執行緒的最初狀態為NEW,沒有執行start方法之前,他只能算是一個Thread的例項,並不意味著一個新的執行緒被建立,因此currentThread()代表的將會是建立它的那個執行緒,因此我們可以得出以下結論:

  1. 一個執行緒的建立肯定是由另一個執行緒完成的
  2. 被建立執行緒的父執行緒是建立它的執行緒

我們都知道main函式所在的執行緒是由JVM建立的,也就是main執行緒,那就意味著我們前面建立的所有執行緒,其父執行緒都是main執行緒。

3、Thread與ThreadGroup

在Thread的建構函式中,可以顯式地指定執行緒的Group,也就是ThreadGroup。

在Thread的原始碼中,我們擷取片段。

 SecurityManager security = System.getSecurityManager();
        if (g == null) {
            /* Determine if it's an applet or not */

            /* If there is a security manager, ask the security manager
               what to do. */
            if (security != null) {
                g = security.getThreadGroup();
            }

            /* If the security doesn't have a strong opinion of the matter
               use the parent thread group. */
            if (g == null) {
                g = parent.getThreadGroup();
            }
        }

通過對原始碼的分析,我們不難看出,如果沒指定一個執行緒組,那麼子執行緒將會被加入到父執行緒所在的執行緒組,下面寫一個簡單的程式碼來測試一下:

package concurrent.chapter02;

public class ThreadConstruction {
    public static void main(String[] args) {
        Thread t1 = new Thread("t1");
        ThreadGroup group = new ThreadGroup("TestGroup");
        Thread t2 = new Thread(group,"t2");
        ThreadGroup mainThreadGroup = Thread.currentThread().getThreadGroup();
        System.out.println("Main thread belong group:"+mainThreadGroup.getName());
        System.out.println("t1 and main belong the same group:"+(mainThreadGroup==t1.getThreadGroup()));
        System.out.println("t2 thread group not belong main group:"+(mainThreadGroup==t2.getThreadGroup()));
        System.out.println("t2 thread group belong main TestGroup:"+(group==t2.getThreadGroup()));
    }
}

執行結果如下所示:

通過上面的例子,我們不難分析出以下結論:

main 執行緒所在的ThreadGroup稱為main

構造一個執行緒的時候如果沒有顯示地指定ThreadGroup,那麼它將會和父執行緒擁有同樣的優先順序,同樣的daemon。

 

在這裡補充一下Thread和Runnable的關係。

Thread負責執行緒本身的職責和控制,而runnable負責邏輯執行單元的部分。

4、Thread與JVM虛擬機器棧

stacksize

在Thread的建構函式中,可發現有一個特殊的引數,stackSize,這個引數的作用是什麼呢?

一般情況下,建立執行緒的時候不會手動指定棧記憶體的地址空間位元組陣列,統一通過xss引數進行設定即可,一般來說stacksize越大,代表正線上程內方法呼叫遞迴的深度就越深,stacksize越小代表著建立的執行緒數量越多,當然這個引數對平臺的依賴性比較高,比如不同的作業系統,不同的硬體。

在有些平臺下,越高的stack設定,可以允許的遞迴深度就越多;反之,越少的stack設定,遞迴深度越淺。

JVM記憶體結構

雖然stacksize在構造時無需手動指定,但是我們會發現執行緒和棧記憶體的關係非常密切,想要了解他們之間到底有什麼必然聯絡,就需要了解JVM的記憶體分佈機制。

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

1、程式計數器

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

2、Java虛擬機器棧

這裡需要重點介紹記憶體,因為與執行緒緊密關聯,與程式計數器記憶體相類似,Java虛擬機器棧也是執行緒私有的,他的生命週期與執行緒相同,是在JVM執行時所建立的,線上程中,方法在執行的時候都會建立一個名為stack frame的資料結構,主要用於存放區域性變數表、操作棧、動態連結,方法出口等資訊。

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

3、本地方法棧

Java中提供了呼叫本地方法的介面(java Native Interface),也就是可執行程式,線上程的執行過程中,經常會碰到呼叫JNI方法,JVM為本地方法所劃分的記憶體區域便是本地方法棧,這塊記憶體區域其自由度非常高,完全靠不同的JVM廠商來實現,Java虛擬機器規範並未給出強制的規定,同樣他也是執行緒私有的記憶體區域。

4、堆記憶體

堆記憶體是JVM中最大的一塊記憶體區域,被所有執行緒所共享,Java在執行期間創造的所有物件幾乎都放在該記憶體區域,該記憶體區域也是垃圾回收器重點照顧的區域,因此有時候堆記憶體被稱為“GC堆”。堆記憶體一般會被細分為新生代和老年代,更細緻的劃分為Eden區,FromSurvivor區和To Survivor區。

5、方法區

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

6、Java 8 元空間

上述內容大致的介紹了JVM的記憶體劃分,在JDK1.8版本以前的記憶體大致都是這樣劃分的,但是從JDK1.8來,JVM的記憶體區域發生了一些改變,實際上是持久代記憶體被徹底刪除,取而代之的是元空間。

 

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

不管是32位作業系統還是64位作業系統,一個程式最大記憶體是有限制的。簡單來說 執行緒的數量和虛擬機器棧的大小成反比。

5、守護執行緒

守護執行緒是一類比較特殊的執行緒,一般用於處理一些後臺的工作,比如JDK的垃圾回收執行緒。

JVM在什麼情況下會退出。

在正常情況下,JVM中若沒有一個非守護執行緒,則JVM的程式會退出。

這和作業系統的執行緒概念如出一轍。

什麼是守護執行緒?我們看下下面的程式碼:

public class DaemonThread {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(()-> {
            while(true) {
                try {
                    Thread.sleep(1);
                }catch(Exception e) {
                    e.printStackTrace();
                }
            }
        });
        thread.start();
        Thread.sleep(2_000L);
        System.out.println("Main thread finished lifestyle");
    }
}

執行這段程式碼之後,我們會發現,JVM永遠不會結束。

package concurrent.chapter02;

public class DaemonThread {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(()-> {
            while(true) {
                try {
                    Thread.sleep(1);
                }catch(Exception e) {
                    e.printStackTrace();
                }
            }
        });
        thread.setDaemon(true);
        thread.start();
        Thread.sleep(2_000L);
        System.out.println("Main thread finished lifestyle");
    }
}

我們加了個thread.setDaemon(true)之後,程式就在main結束後正常推出了。

注意:

設定守護執行緒的方法很簡單,呼叫setDaemon方法即可,true代表守護執行緒,false代表正常執行緒。

執行緒是否為守護執行緒和他的父執行緒有很大的關係,如果父執行緒是正常的執行緒,則子執行緒也是正常執行緒,反之亦然,如果你想要修改他的特性則可藉助setDaemon方法。isDaemon方法可以判斷該執行緒是不是守護執行緒。

另外要注意的是,setDaemon方法旨線上程啟動之前才能生效,如果一個執行緒已經死亡,那麼再設定setDaemon就會丟擲IllegalThreadStateException異常。

守護執行緒的作用:

在瞭解了什麼是守護執行緒以及如何建立守護執行緒之後,我們來討論一下為什麼要有守護執行緒,以及何時使用守護執行緒。

通過上面的分析,如果一個JVM程式中沒有一個非守護執行緒,那麼JVM就會退出,就是說守護執行緒具備自動結束生命週期的特性,而非守護執行緒則不具備這個特點,試想一下弱國JVM程式的垃圾回收執行緒是非守護執行緒,如果main執行緒完成了工作,則JVM無法退出,因為垃圾回收執行緒還在正常的工作。再比如有一個簡單的遊戲程式,其中有一個執行緒正在與伺服器不斷地互動以獲得玩家最新的金幣,武器資訊,若希望在退出遊戲客戶端的時候,這些資料的同步工作也能夠立即結束等等。

守護執行緒經常用作與執行一些後臺任務,因此有時稱他為後臺執行緒,當你希望關閉某些執行緒的時候,這些資料同步的工作也能夠立即結束,等等。

守護執行緒經常用作執行一些後臺任務,因此有時它也被稱為後臺執行緒,當你希望關閉這些執行緒的時候,或者退出JVM程式的時候,一些執行緒能夠自動關閉,此時就可以考慮用守護執行緒為你完成這樣的工作。

總結:

學習了Thread的建構函式,能夠理解執行緒與JVM記憶體模型的關係,還明白了什麼是守護執行緒。

 

相關文章