在前面我們介紹的一些內容中,我們的程序都是一條執行流,一步一步的執行。但其實這種程序對我們計算機的資源的使用上是低效的。例如:我們有一個用於計算的程序,主程序計算數據,在計算的過程中每得到一個結果就需要將其保存到外部磁盤上,那麼難道我們的主程序每次都要停止等待CPU將結果保存到磁盤之後,再繼續完成計算工作嗎?要知道磁盤的速度可是巨慢的(相對內存而言),我們如果能分一個線程去完成磁盤的寫入工作,主線程還是繼續計算的話,是不是效率更高了呢?其實,併發就是這樣的一種思想,使用時間片分發給各個線程CPU的使用時間,給人感覺好像程序在同時做多個事情一樣,這樣做的好處主要在於它能夠對我們整個的計算機資源有一個充分的利用,在多個線程競爭計算機資源不衝突的前提下,充分的利用我們的資源。本篇文章首先來介紹併發的最基本的內容—–線程。主要涉及以下一些內容:
- 定義線程的兩種不同的方法及它們之間的區別
- 線程的幾種不同的狀態及其區別
- Thread類中的一些線程屬性和方法
- 多線程遇到的幾個典型的問題
一、創建一個線程
首先我們看創建一個線程的第一種方式,繼承Thread類並重寫其run方法。
public class MyThread extends Thread {
@Override
public void run(){
System.out.println("this is mythread");
}
}
現在我們來看看在主程序中如何啓動我們自定義的線程:
public static void main(String[] args) {
Thread myThread = new MyThread();
myThread.start();
}
我們首先構建一個Thread實例,調用其start方法,調用該方法會爲線程分配其所必須的堆棧資源,計數器,時間片等,並在該方法的結束時刻調用我們重寫的run方法,完成線程的啓動。
但是在Java中類是單繼承的,也就是如果某個類已經有了父類,那麼它就不能被定義成線程類。當然,Java中也提供了第二種方法來定義一個線程類,這種方式實際上更加的接近本質一些。通過繼承接口Runnable並在其內部重寫一個run方法。
public class MyThread implements Runnable{
@Override
public void run(){
System.out.println("this is mythread");
}
}
啓動線程的方式和上一種略微有點不同,但是本質上都是一樣的。
public static void main(String[] args) {
Thread myThread = new Thread(new MyThread());
myThread.start();
}
這裏我們利用Thread的一個構造函數,傳入一個實現了Runnable接口的參數。下面我們看看這個構造函數的具體實現:
public Thread(Runnable target) {
init(null, target, "Thread-" + nextThreadNum(), 0);
}
調用init方法對線程的一些狀態優先級等做一個初始化的操作,我們順便看看使用第一種方式創建線程實例的那個無參的構造函數:
public Thread() {
init(null, null, "Thread-" + nextThreadNum(), 0);
}
可以看到,兩個構造函數的內部調用的是同一個方法,只是傳入的參數不同而已。所以他們之間的區別就在於初始化的時候這個Runnable參數是否爲空,當然這個參數的用處在run方法中也可以看出來:
@Override
public void run() {
if (target != null) {
target.run();
}
}
如果我們使用第二種方式構建Thread實例,那麼此處的target肯定不會是null,自然會調用我們重寫的run方法。如果使用的是第一種方式構建的Thread實例,那麼就不會調用上述的run方法,而是調用的我們重寫的Thread的run方法,所以從本質上看,兩種方式的底層處理都是一樣的。
這就是創建一個線程類並啓動該線程的兩種不同的方式,表面上略有不同,但是實際上都是一樣的調用init方法完成初始化。對於啓動線程的start方法的源碼,由於調用本地native方法,暫時並不易解釋,有興趣的可以使用jvm指令查看本地方法的實現以瞭解整個線程從分配資源到調用run方法啓動的全過程。
二、線程的多種狀態
線程是有狀態的,它會因爲得不到鎖而阻塞處於BLOCKED狀態,會因爲條件不足而等待處於WAITING狀態等。Thread中有一個枚舉類型囊括了所有的線程狀態:
public enum State {
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED;
}
NEW狀態表示線程剛剛被定義,還未實際獲得資源以啓動,也就是還未調用start方法。
RUNNABLE表示線程當前處於運行狀態,當然也有可能由於時間片使用完了而等待CPU重新的調度。
BLOCKED表示線程在競爭某個鎖失敗時被置於阻塞狀態
WAITING和TIMED_WAITING表示線程在運行中由於缺少某個條件而不得不被置於條件等待隊列等待需要的條件或資源。
TERMINATED表示線程運行結束,當線程的run方法結束之後,該線程就會是TERMINATED狀態。
我們可以調用Thread的getState方法返回當前線程的狀態:
/*定義一個線程類*/
public class MyThread implements Runnable{
@Override
public void run(){
System.out.println("myThread's state is : "+Thread.currentThread().getState());
}
}
/*啓動線程*/
public static void main(String[] args) throws InterruptedException {
Thread myThread = new Thread(new MyThread());
myThread.start();
Thread.sleep(1000);
System.out.println("myThread's state is : "+myThread.getState());
}
我們兩次輸出myThread線程的當前狀態,在run方法中輸出結果顯示該線程狀態爲RUNNABLE,當該run方法執行結束時候,我們又一次輸出該線程的當前狀態,結果顯示該線程處於TERMINATED。至於更加複雜的線程狀態,我們將在後續的文章中逐漸進行介紹。
三、Thread類中的其他一些常用屬性及方法
以上我們介紹了創建線程的兩種不同的方式以及線程的幾種不同狀態,有關於線程信息屬性的一些方法還沒有介紹。本小節將來簡單介紹下線程所具有的基本的一些屬性以及一些常用的方法。
首先每個線程都有一個id和一個name屬性,id是一個遞增的整數,每創建一個線程該id就會加一,該id的初始值是10,每創建一個線程就會往上加一。所以該id也間接的告訴了我們當前線程在所有線程中的位置。name屬性往往是以“Thread-”+編號作爲某個具體線程的name值。例如:
public static void main(String[] args){
for (int i=0;i<10;i++){
Thread myThread = new Thread(new MyThread());
myThread.start();
System.out.println(myThread.getName());
}
}
輸出結果:
除此之外,Thread中還有一個屬性daemon,它是一個boolean類型的變量,該變量指示了當前線程是否是一個守護線程。守護線程主要用於輔助主線程完成工作,如果主線程執行結束,那麼它的守護線程也會跟着結束。例如:我們的main程序在執行的時候,始終有一個垃圾回收線程作爲守護線程輔助一些對象的回收工作,當main程序執行結束時,守護線程也將退出內存。關於守護線程有幾個方法:
public final boolean isDaemon() :判斷當前線程是否是守護線程
public final void setDaemon(boolean on):設置當前線程是否作爲守護線程
還有一個方法較爲常見,join。該方法可以讓一個線程等待另一個線程執行結束之後再繼續工作。例如:
public class MyThread implements Runnable{
@Override
public void run(){
System.out.println("myThread is running");
}
}
public static void main(String[] args) {
Thread myThread = new Thread(new MyThread());
myThread.start();
//主線程等待myThread線程執行結束
myThread.join();
System.out.println("waiting myThread done....");
}
輸出結果:
有人可能會疑問,我們使用多線程不就是爲了充分利用計算機資源,使其同時執行多個任務,爲什麼又要讓一個線程等待另一個線程呢?其實某些時候,主線程需要拿到所有分支線程計算的結果再一次進行計算,各個分支線程的進度各有快慢,主線程唯有等待他們全部執行結束之後才能繼續。此時就需要使用join方法了,所以說每一個方法的存在都有其可應用的場景。至於這個join的源代碼也是很有研究價值的,我們將在後續的文章中對其源代碼的實現進行進一步的學習。
還有一些屬性和方法,限於篇幅,本文不再繼續學習,大家可以自行查看源碼進行學習。下面我們看看多線程之後可能會遇到的幾個經典的問題。
四、多線程遇到的幾個典型的問題
第一個可能遇到的問題是,競態條件。也就是說,當多個線程同時訪問操作同一個對象的時候,最終的結果可能正確也可能不正確,具體的執行情況和線程實際的執行時序有關。
例如:
/*我們定義一個線程*/
public class MyThread implements Runnable{
public static int count;
@Override
public void run(){
try {
Thread.currentThread().sleep((int)(Math.random()*100));
count++;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
/*main方法中啓動多個線程*/
public static void main(String[] args){
Thread[] threads = new Thread[100];
for (int i=0;i<100;i++){
threads[i] = new Thread(new MyThread());
threads[i].start();
}
for (int j =0;j<100;j++){
threads[j].join();
}
System.out.println(MyThread.count);
}
首先在我們自定義的線程類中,有一個static公共變量,而我們的run方法主要就做兩個事情,隨機睡一會和count增一。再來看main函數,首先定義了一百個線程並逐個啓動,然後主線程等待所有的子線程完成之後輸出count的值。
按照我們一般的思維,這一百個線程,每個線程都是爲count加一,最後的輸出結果應該是100纔對。但是實際上我們多次運行該程序得到的結果都是不一樣的,但幾乎都是小於100的。
爲什麼會出現這樣的情況呢?主要原因還是在於爲count加一這個操作,它並非是原子操作,也就是說想要爲count加一需要經過起碼兩個步驟:
- 取count的當前值
- 爲count加一
因爲每個線程都是隨機睡了一會,有可能兩個線程同時醒來,都獲取到當前的count的值,又同時爲其加一,這樣就導致兩個不同的線程卻只爲count增加了一次值。這種情況在多線程的前提下,發生的概率就更大了,所以這也是爲什麼我們得到的結果始終小於100但又每次都不同的原因。
第二個問題是,內存的可見性問題。就是說,如果兩個線程共享了同一個參數,其中一個線程對共享參數的修改而另一個線程並不會立馬能夠看到。原因是這些修改會被暫存在CPU緩存中,而沒有立馬寫回內存。例如:
public class MyThread extends Thread{
public static boolean flag = false;
@Override
public void run(){
while(!flag){
//just running
}
System.out.println("my thread has finished ");
}
}
public static void main(String[] args) throws InterruptedException {
Thread myThread = new MyThread();
myThread.start();
Thread.sleep(1000);
MyThread.flag = true;
System.out.println("main thread has finished");
}
首先我們定義一個線程類,該線程類中有一個靜態共享變量flag,run方法做的事情很簡單,死循環的做一些事情,等待外部線程更改flag的值,使其退出循環。而main方法首先啓動一個線程,然後修改共享變量flag的值,按照常理線程myThread在main線程修改flag變量的值之後將退出循環,打印退出信息。但是實際的輸出結果爲:
main線程已經結束了,而整個程序並沒有結束,線程myThread的結束信息也沒有被打印,這就說明myThread線程還困在while循環中,但是實際上主線程已經將flag的值修改了,只是myThread無法看見。這是什麼原因呢?
我們知道,每個線程都有一些緩存,往往爲了效率,對一個變量值的修改並不會立馬寫會內存,而是注入緩存中,等到一定的時候才寫回內存,而當別的線程來修改這些共享的變量的時候,他們是從內存進行讀取的,修改後可能也沒有及時的寫回內存中,這就很容易導致其他線程根本就看不到你所做的修改。這就是典型的內存可見性問題。
本小節簡單的介紹了多線程的兩個典型的問題,解決辦法其實有多種,我們將在下篇文章中涉及。
以上的本篇內容主要介紹了線程的基本概念,如何創建一個線程,如何啓動一個線程,還有與線程相關的一些基本的屬性和方法,總結不到之處,望大家指出,相互學習。下篇文章將介紹一個用於解決多線程併發問題的關鍵字synchronized。