本系列文章經補充和完善,已修訂整理成書《Java編程的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連接 html
在以前的章節中,咱們都是假設程序中只有一條執行流,程序從main方法的第一條語句逐條執行直到結束。從本節開始,咱們討論併發,在程序中建立線程來啓動多條執行流,併發和線程是一個複雜的話題,本節,咱們先來討論Java中線程的一些基本概念。java
線程表示一條單獨的執行流,它有本身的程序執行計數器,有本身的棧。下面,咱們經過建立線程來對線程創建一個直觀感覺,在Java中建立線程有兩種方式,一種是繼承Thread,另一種是實現Runnable接口,咱們先來看第一種。git
Java中java.lang.Thread這個類表示線程,一個類能夠繼承Thread並重寫其run方法來實現一個線程,以下所示:程序員
public class HelloThread extends Thread {
@Override
public void run() {
System.out.println("hello");
}
}
複製代碼
HelloThread這個類繼承了Thread,並重寫了run方法。run方法的方法簽名是固定的,public,沒有參數,沒有返回值,不能拋出受檢異常。run方法相似於單線程程序中的main方法,線程從run方法的第一條語句開始執行直到結束。github
定義了這個類不表明代碼就會開始執行,線程須要被啓動,啓動須要先建立一個HelloThread對象,而後調用Thread的start方法,以下所示:編程
public static void main(String[] args) {
Thread thread = new HelloThread();
thread.start();
}
複製代碼
咱們在main方法中建立了一個線程對象,並調用了其start方法,調用start方法後,HelloThread的run方法就會開始執行,屏幕輸出:swift
hello
複製代碼
爲何調用的是start,執行的倒是run方法呢?start表示啓動該線程,使其成爲一條單獨的執行流,背後,操做系統會分配線程相關的資源,每一個線程會有單獨的程序執行計數器和棧,操做系統會把這個線程做爲一個獨立的個體進行調度,分配時間片讓它執行,執行的起點就是run方法。緩存
若是不調用start,而直接調用run方法呢?屏幕的輸出並不會發生變化,但並不會啓動一條單獨的執行流,run方法的代碼依然是在main線程中執行的,run方法只是main方法調用的一個普通方法。bash
怎麼確認代碼是在哪一個線程中執行的呢?Thread有一個靜態方法currentThread,返回當前執行的線程對象:服務器
public static native Thread currentThread();
複製代碼
每一個Thread都有一個id和name:
public long getId() public final String getName() 複製代碼
這樣,咱們就能夠判斷代碼是在哪一個線程中執行的,咱們在HelloThead的run方法中加一些代碼:
@Override
public void run() {
System.out.println("thread name: "+ Thread.currentThread().getName());
System.out.println("hello");
}
複製代碼
若是在main方法中經過start方法啓動線程,程序輸出爲:
thread name: Thread-0
hello
複製代碼
若是在main方法中直接調用run方法,程序輸出爲:
thread name: main
hello
複製代碼
調用start後,就有了兩條執行流,新的一條執行run方法,舊的一條繼續執行main方法,兩條執行流併發執行,操做系統負責調度,在單CPU的機器上,同一時刻只能有一個線程在執行,在多CPU的機器上,同一時刻能夠有多個線程同時執行,但操做系統給咱們屏蔽了這種差別,給程序員的感受就是多個線程併發執行,但哪條語句先執行哪條後執行是不必定的。當全部線程都執行完畢的時候,程序退出。
經過繼承Thread來實現線程雖然比較簡單,但咱們知道,Java中只支持單繼承,每一個類最多隻能有一個父類,若是類已經有父類了,就不能再繼承Thread,這時,能夠經過實現java.lang.Runnable接口來實現線程。
Runnable接口的定義很簡單,只有一個run方法,以下所示:
public interface Runnable {
public abstract void run();
}
複製代碼
一個類能夠實現該接口,並實現run方法,以下所示:
public class HelloRunnable implements Runnable {
@Override
public void run() {
System.out.println("hello");
}
}
複製代碼
僅僅實現Runnable是不夠的,要啓動線程,仍是要建立一個Thread對象,但傳遞一個Runnable對象,以下所示:
public static void main(String[] args) {
Thread helloThread = new Thread(new HelloRunnable());
helloThread.start();
}
複製代碼
不管是經過繼承Thead仍是實現Runnable接口來實現線程,啓動線程都是調用Thread對象的start方法。
前面咱們提到,每一個線程都有一個id和name,id是一個遞增的整數,每建立一個線程就加一,name的默認值是"Thread-"後跟一個編號,name能夠在Thread的構造方法中進行指定,也能夠經過setName方法進行設置,給Thread設置一個友好的名字,能夠方便調試。
線程有一個優先級的概念,在Java中,優先級從1到10,默認爲5,相關方法是:
public final void setPriority(int newPriority) public final int getPriority() 複製代碼
這個優先級會被映射到操做系統中線程的優先級,不過,由於操做系統各不相同,不必定都是10個優先級,Java中不一樣的優先級可能會被映射到操做系統中相同的優先級,另外,優先級對操做系統而言更多的是一種建議和提示,而非強制,簡單的說,在編程中,不要過於依賴優先級。
線程有一個狀態的概念,Thread有一個方法用於獲取線程的狀態:
public State getState() 複製代碼
返回值類型爲Thread.State,它是一個枚舉類型,有以下值:
public enum State {
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED;
}
複製代碼
關於這些狀態,咱們簡單解釋下:
Thread還有一個方法,返回線程是否活着:
public final native boolean isAlive() 複製代碼
線程被啓動後,run方法運行結束前,返回值都是true。
Thread有一個是否daemo線程的屬性,相關方法是:
public final void setDaemon(boolean on) public final boolean isDaemon() 複製代碼
前面咱們提到,啓動線程會啓動一條單獨的執行流,整個程序只有在全部線程都結束的時候才退出,但daemo線程是例外,當整個程序中剩下的都是daemo線程的時候,程序就會退出。
daemo線程有什麼用呢?它通常是其餘線程的輔助線程,在它輔助的主線程退出的時候,它就沒有存在的意義了。在咱們運行一個即便最簡單的"hello world"類型的程序時,實際上,Java也會建立多個線程,除了main線程外,至少還有一個負責垃圾回收的線程,這個線程就是daemo線程,在main線程結束的時候,垃圾回收線程也會退出。
Thread有一個靜態的sleep方法,調用該方法會讓當前線程睡眠指定的時間,單位是毫秒:
public static native void sleep(long millis) throws InterruptedException;
複製代碼
睡眠期間,該線程會讓出CPU,但睡眠的時間不必定是確切的給定毫秒數,可能有必定的誤差,誤差與系統定時器和操做系統調度器的準確度和精度有關。
睡眠期間,線程能夠被中斷,若是被中斷,sleep會拋出InterruptedException,關於中斷以及中斷處理,咱們後續章節再介紹。
Thread還有一個讓出CPU的方法:
public static native void yield();
複製代碼
這也是一個靜態方法,調用該方法,是告訴操做系統的調度器,我如今不着急佔用CPU,你能夠先讓其餘線程運行。不過,這對調度器也僅僅是建議,調度器如何處理是不必定的,它可能徹底忽略該調用。
在前面HelloThread的例子中,HelloThread沒執行完,main線程可能就執行完了,Thread有一個join方法,可讓調用join的線程等待該線程結束,join方法的聲明爲:
public final void join() throws InterruptedException 複製代碼
在等待線程結束的過程當中,這個等待可能被中斷,若是被中斷,會拋出InterruptedException。
join方法還有一個變體,能夠限定等待的最長時間,單位爲毫秒,若是爲0,表示無期限等待:
public final synchronized void join(long millis) throws InterruptedException 複製代碼
在前面的HelloThread示例中,若是但願main線程在子線程結束後再退出,main方法能夠改成:
public static void main(String[] args) throws InterruptedException {
Thread thread = new HelloThread();
thread.start();
thread.join();
}
複製代碼
Thread類中還有一些看上去能夠控制線程生命週期的方法,如:
public final void stop() public final void suspend() public final void resume() 複製代碼
這些方法由於各類緣由已被標記爲了過期,咱們不該該在程序中使用它們。
前面咱們提到,每一個線程表示一條單獨的執行流,有本身的程序計數器,有本身的棧,但線程之間能夠共享內存,它們能夠訪問和操做相同的對象。咱們看個例子,代碼以下:
public class ShareMemoryDemo {
private static int shared = 0;
private static void incrShared(){
shared ++;
}
static class ChildThread extends Thread {
List<String> list;
public ChildThread(List<String> list) {
this.list = list;
}
@Override
public void run() {
incrShared();
list.add(Thread.currentThread().getName());
}
}
public static void main(String[] args) throws InterruptedException {
List<String> list = new ArrayList<String>();
Thread t1 = new ChildThread(list);
Thread t2 = new ChildThread(list);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(shared);
System.out.println(list);
}
}
複製代碼
在代碼中,定義了一個靜態變量shared和靜態內部類ChildThread,在main方法中,建立並啓動了兩個ChildThread對象,傳遞了相同的list對象,ChildThread的run方法訪問了共享的變量shared和list,main方法最後輸出了共享的shared和list的值,大部分狀況下,會輸出指望的值:
2
[Thread-0, Thread-1]
複製代碼
經過這個例子,咱們想強調說明執行流、內存和程序代碼之間的關係。
當多條執行流能夠操做相同的變量時,可能會出現一些意料以外的結果,咱們來看下。
所謂競態條件(race condition)是指,當多個線程訪問和操做同一個對象時,最終執行結果與執行時序有關,可能正確也可能不正確,咱們看一個例子:
public class CounterThread extends Thread {
private static int counter = 0;
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
counter++;
}
}
public static void main(String[] args) throws InterruptedException {
int num = 1000;
Thread[] threads = new Thread[num];
for (int i = 0; i < num; i++) {
threads[i] = new CounterThread();
threads[i].start();
}
for (int i = 0; i < num; i++) {
threads[i].join();
}
System.out.println(counter);
}
}
複製代碼
這段代碼容易理解,有一個共享靜態變量counter,初始值爲0,在main方法中建立了1000個線程,每一個線程對counter循環加1000次,main線程等待全部線程結束後輸出counter的值。
指望的結果是100萬,但實際執行,發現每次輸出的結果都不同,通常都不是100萬,常常是99萬多。爲何會這樣呢?由於counter++這個操做不是原子操做,它分爲三個步驟:
兩個線程可能同時執行第一步,取到了相同的counter值,好比都取到了100,第一個線程執行完後counter變爲101,而第二個線程執行完後仍是101,最終的結果就與指望不符。
怎麼解決這個問題呢?有多種方法:
關於這些方法,咱們在後續章節再介紹。
多個線程能夠共享訪問和操做相同的變量,但一個線程對一個共享變量的修改,另外一個線程不必定立刻就能看到,甚至永遠也看不到,這可能有悖直覺,咱們來看一個例子。
public class VisibilityDemo {
private static boolean shutdown = false;
static class HelloThread extends Thread {
@Override
public void run() {
while(!shutdown){
// do nothing
}
System.out.println("exit hello");
}
}
public static void main(String[] args) throws InterruptedException {
new HelloThread().start();
Thread.sleep(1000);
shutdown = true;
System.out.println("exit main");
}
}
複製代碼
在這個程序中,有一個共享的boolean變量shutdown,初始爲false,HelloThread在shutdown不爲true的狀況下一直死循環,當shutdown爲true時退出並輸出"exit hello",main線程啓動HelloThread後睡了一會,而後設置shutdown爲true,最後輸出"exit main"。
指望的結果是兩個線程都退出,但實際執行,極可能會發現HelloThread永遠都不會退出,也就是說,在HelloThread執行流看來,shutdown永遠爲false,即便main線程已經更改成了true。
這是怎麼回事呢?這就是內存可見性問題。在計算機系統中,除了內存,數據還會被緩存在CPU的寄存器以及各級緩存中,當訪問一個變量時,可能直接從寄存器或CPU緩存中獲取,而不必定到內存中去取,當修改一個變量時,也多是先寫到緩存中,而稍後纔會同步更新到內存中。在單線程的程序中,這通常不是個問題,但在多線程的程序中,尤爲是在有多CPU的狀況下,這就是個嚴重的問題。一個線程對內存的修改,另外一個線程看不到,一是修改沒有及時同步到內存,二是另外一個線程根本就沒從內存讀。
怎麼解決這個問題呢?有多種方法:
關於這些方法,咱們在後續章節再介紹。
爲何要建立單獨的執行流?或者說線程有什麼優勢呢?至少有如下幾點:
關於線程,咱們須要知道,它是有成本的。建立線程須要消耗操做系統的資源,操做系統會爲每一個線程建立必要的數據結構、棧、程序計數器等,建立也須要必定的時間。
此外,線程調度和切換也是有成本的,當有當量可運行線程的時候,操做系統會忙於調度,爲一個線程分配一段時間,執行完後,再讓另外一個線程執行,一個線程被切換出去後,操做系統須要保存它的當前上下文狀態到內存,上下文狀態包括當前CPU寄存器的值、程序計數器的值等,而一個線程被切換回來後,操做系統須要恢復它原來的上下文狀態,整個過程被稱爲上下文切換,這個切換不只耗時,並且使CPU中的不少緩存失效,是有成本的。
固然,這些成本是相對而言的,若是線程中實際執行的事情比較多,這些成本是能夠接受的,但若是隻是執行本節示例中的counter++,那相對成本就過高了。
另外,若是執行的任務都是CPU密集型的,即主要消耗的都是CPU,那建立超過CPU數量的線程就是沒有必要的,並不會加快程序的執行。
本節,咱們介紹了Java中線程的一些基本概念,包括如何建立線程,線程的一些基本屬性和方法,多個線程能夠共享內存,但共享內存也有兩個重要問題,一個是競態條件,另外一個是內存可見性,最後,咱們討論了線程的一些優勢和成本。
針對共享內存的兩個問題,下一節,咱們討論Java的一個解決方案 - synchronized關鍵字。
(與其餘章節同樣,本節全部代碼位於 github.com/swiftma/pro…)
未完待續,查看最新文章,敬請關注微信公衆號「老馬說編程」(掃描下方二維碼),深刻淺出,老馬和你一塊兒探索Java編程及計算機技術的本質。用心原創,保留全部版權。