JAVA多線程學習筆記—— 線程的簡介與入門
理論知識
爲什麼會出現多線程
多線程出現的主要原因
- 科學技術的發展。計算機從早期的巨型機到微型機,從早期的單核CPU到現在的多核CPU,從單核CPU的僞多線程到現在多核CPU的真正意義上的多線程,以及取決於決定性因素的CPU處理能力與程序運行的高度不匹配都是促使多線程出現的原因之一,
- 貪婪之心。人是串行化的動物(神童,天才,超能力者除外),一次只能做一件事,當然,只要給與足夠的時間,同時交給你的任務總是能夠採用
串行
化的方式執行完,不知道這是不是996的由來。人雖然不能,但是計算機可以,計算機並行
執行的能力相較於人類同時處理多種事情導致的上下文切換
,更能保證正確性。 - 充分利用資源。我們都知道CPU的運行速度是很快的,計算機在執行非CPU型任務,比如:讀取文件,寫數據到數據庫,此時CPU是空閒的,但是卻會因爲程序是串行化(非多線程程序)的執行,而導致CPU得不到很好的利用,其實,此時CPU本可以做其他事情的,如繼續讀取其他的指令進行執行。這樣也能充分的利用CPU資源,高效的完成任務。
串行與並行
在上面我們提到了串行
,並行
的概,那麼什麼是串行
,什麼是並行
。在程序的世界裏,串行和並行主要是指程序任務的執行方式。
串行
多個任務時,各個任務按順序執行,完成一個之後才能執行下一個。
並行
多個任務時,多個任務可以同時執行
舉個簡單的例子用來理解上面的概念:
每個人或多或少都有過去窗口打飯的經歷,那麼當只有一個窗口的時候,如果你想喫飯怎麼辦呢,只好排隊對吧,因爲窗口只有一個,那麼當你前面的的人在沒有打飯完成之前,你都只好等着,等你前面的人依次排隊把飯打完才能輪到你打飯對吧,這就是串行,排隊式,一個接一個的執行,只有前一個執行完成才能輪到你的執行。打飯打了很多天了,結果每天都會因爲有很多人因爲等的時間比出去找個新地方喫飯的時間要長而選擇離開,而你是最能忍的,你覺得還可以接受,主要是飯菜可口,但是食堂老闆不接受啊,這跑的可都是錢呀,他進行調研之後仔細一算,決定再開一個窗口,來提高食堂供應飯菜的速度,這樣同一時間內就能提供兩份飯菜了,從前丟失的顧客又再次投入了老闆的懷抱,老闆開心的笑了。其實這裏的增設窗口就是並行的處理了顧客等待時間的問題,提供了一種能力,一種能夠同時供應兩個用戶的就餐能力。
針對上面的問題,我們會在下面通過實戰模擬來體會一下串行和並行。
Java中的多線程
很多人在使用java中的多線程的時候總是搞不清楚某些概念,而導致使用不好多線程。首先要能夠區分的概念我認爲是線程對象
和線程
的概念,相信來學線程的,一定是對java基礎還是有一定了解的,我們常常一說線程就會說Thread
,很多人就會認爲Thread
就是一個線程,真的是這樣嗎?顯然不是這樣,這是一個認知的誤區,那麼我們來分析下到底什麼是線程
,什麼是線程對象
線程對象
線程對象,顧名思義——持有線程的對象。那麼在JAVA中,是誰呢,沒錯,就是我們的Thread對象,無論你是直接new出來的Thread對象,還是其子類的實例化,只要在沒有執行它的
start()
方法,那麼這個對象就可以稱之爲線程對象,它只是持有這個線程的引用,並沒有通過你的new方法創建了一個線程。
線程
如果仔細理解了上面線程對象的概念,那麼就不難理解這裏的線程的概念,只有當我們顯示的調用了這個
線程對象
的start()
方法時,才能稱之爲真正的創建了一個線程,那麼這個線程是怎麼創建的呢,這就和操作系統有關了,JAVA會通過調用本地方法
start0()
來再操作系統中開闢線程的生存空間,從而創建一個我們理解意義上的線程。
如果上述概念已經澄清了,那麼我們就可以學習JAVA中線程的實現方式了。
實現方式
- 聲明一個類繼承自
Thread
,並重寫其run()
方法 - 聲明一個類實現
Runnable
接口,實現其run()
方法,將其作爲Thread
的參數來創建線程
實戰
將會上面描述的窗口打飯進行描述,用於達成以下目的
- 理解串行和並行
- 理解線程對象和線程
- 使用線程
體會線程
下面我們創建一個線程對象,然後啓動這個線程,查看執行結果
創建線程方式一:
聲明一個類繼承自Thread
,並重寫其run()
方法
public class TaskThread extends Thread {
// run()方法是線程的主要業務執行單元
@Override
public void run() {
System.out.println("我是TaskThread,我的任務就是執行一系列的任務");
}
public static void main(String[] args) {
/*======================================繼承方式創建一個線程=========================*/
/**
* 這裏僅是聲明瞭一個線程對象,並沒有實際的創建出一個線程
*/
TaskThread taskThread = new TaskThread();
/**
* 這裏是將線程對象變成一個線程的方法,如果註釋這行,啓動程序的時候講不會打印`我是一個線程,
* 新創建的哦`這句話。由於線程的執行需要CPU的調度,而不是調用的start方法後就立即執行
*/
taskThread.start();
/*======================================內部類方式創建========================*/
// 採用內部類方式創建,如果不重寫run方法,則什麼也不會輸出
Thread thread = new Thread() {
@Override
public void run() {
System.out.println("我是一個線程,新創建的哦。");
}
};
thread.start();
System.out.println("main方法執行結束");
}
}
創建線程方式二:
聲明一個類實現Runnable
接口,實現其run()
方法,將其作爲Thread
的參數來創建線程
public class TaskRunnable implements Runnable {
@Override
public void run() {
System.out.println("我是通過Runnable方式創建的線程哦");
}
public static void main(String[] args) {
// 創建一個線程對象,使用實現了Runnable接口的對象作爲參數
Thread thread = new Thread(new TaskRunnable());
// 線程對象啓動,成爲一個線程
thread.start();
System.out.println("main方法結束了哦");
}
}
一個窗口點餐(串行)
code清單1
/**
* @ClassName SingleWindowOrderFood
* @Description 單個窗口點餐
* @Author Administrator
* @Date 2019/10/11 22:56
* @Version 1.0
*/
public class SingleWindowOrderFood {
public static void main(String[] args) {
System.out.println("到飯點了,大家可以開始排隊點餐了");
long start = System.currentTimeMillis();
// 窗口一: 處理50人的用餐
for (int i = 0; i < 50; i++) {
try {
// 這裏模擬一個人點餐需要1秒,那麼50個人點餐就需要50s
Thread.sleep(1_000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("一個窗口,每人耗時1秒,50人點餐,執行總共耗時:" + ((System.currentTimeMillis() - start) / 1000) +"s");
}
}
通過運行上述的代碼,我們可以發現,程序一共跑了50s
可能有人會寫出下面的代碼,心想:不就是兩個窗口嗎,我寫兩個循環不就完了,分別處理25個客戶請求:
public class DoubleWindowsOrderFood {
public static void main(String[] args) {
System.out.println("到飯點了,大家可以開始排隊點餐了,今天兩個窗口哦");
long start = System.currentTimeMillis();
// 窗口一: 處理25人的用餐
System.out.println("窗口一點餐開始");
for (int i = 0; i < 25; i++) {
try {
// 這裏模擬一個人點餐需要1秒,那麼25個人點餐就需要25s
Thread.sleep(1_000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("窗口一點餐結束");
System.out.println("窗口二點餐開始");
// 窗口二: 處理25人的用餐
for (int i = 0; i < 25; i++) {
try {
// 這裏模擬一個人點餐需要1秒,那麼25個人點餐就需要25s
Thread.sleep(1_000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("窗口二點餐結束");
System.out.println("兩個窗口,每人耗時1秒,50人點餐,執行總共耗時:" + ((System.currentTimeMillis() - start) / 1000) + "s");
}
}
執行結果:
其實這個的執行結果和上面單個窗口的執行結果是一樣的,那麼我們來分析一下爲什麼?很簡單,程序是串行化的,也就是一行一行執行的,並沒有做到並行化執行,他不會從窗口一就直接跳到窗口二的代碼去,只能一行一行的執行,一行一行的出結果,所以這裏執行的結果都是一樣的,都會耗費50s
並行化改造
站在生活的角度,兩個窗口肯定是同時提供服務的,而不是必須等一個執行完了,纔開始第二個,我們需要真正意義上的貼近生活的設計,那麼線程就是用來幹這個事的,下面我們對代碼進行改造一下。
public class OrderFoodConcurrency {
public static void main(String[] args) {
System.out.println("到飯點了,大家可以開始排隊點餐了,今天兩個窗口哦,這裏並行點餐哦");
Thread windowOne = new Thread(){
@Override
public void run() {
long start = System.currentTimeMillis();
System.out.println("窗口一開始點餐了");
// 窗口一: 處理25人的用餐
for (int i = 0; i < 25; i++) {
try {
// 這裏模擬一個人點餐需要1秒,那麼25個人點餐就需要25s
Thread.sleep(1_000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("窗口一點餐耗時:" + ((System.currentTimeMillis() - start) / 1000) + "s");
}
};
windowOne.start();
Thread windowTwo = new Thread(){
@Override
public void run() {
long start = System.currentTimeMillis();
System.out.println("窗口二開始點餐了");
// 窗口二: 處理25人的用餐
for (int i = 0; i < 25; i++) {
try {
// 這裏模擬一個人點餐需要1秒,那麼25個人點餐就需要25s
Thread.sleep(1_000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("窗口二點餐耗時:" + ((System.currentTimeMillis() - start) / 1000) + "s");
}
};
windowTwo.start();
try {
// 這裏讓main方法睡眠30s,主要是爲了驗證整個程序的執行時間,當然在後面會有來統計線程
// 執行時間的方法,這裏就不做講解了,其實main方法也是執行在一個線程中的,名字就叫main線程,這點 // 我們會在後面解析,因爲窗口一和窗口二同時執行,那麼最少需要25秒的時間才能完成兩個線程的執行, // 這裏我們睡眠30s,足矣,不然main線程啓動完兩個線程後直接掛掉了,無法監視時間
Thread.sleep(30_000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("所有窗口按照預期都執行完了點餐");
}
}
執行結果:
分析
由於窗口一和窗口二是並行執行的,所以理論上兩個線程完美狀態是共耗時25s