JAVA SE 實戰篇 C1 多執行緒程式設計基礎

雫#1999發表於2020-11-30

P1 關於多執行緒程式設計的幾個知識點

1 程式

(1) 程式是動態的

程式與程式之間存在密切的關係,程式是靜態的,存放在磁碟中,一旦寫好就不會再發生變化,而程式指的是程式從磁碟載入到記憶體中,並享受CPU服務的動態過程

作業系統需要“建立”程式,為其分配一定的資源才能存在,程式存在期間,需要作業系統對其進行管理,程式結束後,需要作業系統對其佔用的資源進行回收,並“銷燬”這個程式,對程式的所有操作,都是要消耗計算機資源的,至少是時間資源

(2) 每個程式有一套獨立的資料

如果在一臺機器上同時執行兩個qq賬戶,這時就有了兩個不同的程式,這兩個程式各自執行各自的,而且每一個程式隨時會進入執行狀態,又會從執行狀態中退出,下一次有進入執行狀態,這需要程式能夠在暫時退出執行狀態時,記住當前執行的所有詳細狀況,以便下一次能成功執行

每個程式都需要記載大量的資料,程式的管理不但要消耗計算機時間資源,還要消耗計算機記憶體資源

(3) 程式是計算機資源的競爭者

某次程式可能需要計算機的各種資源,如:輸入,輸出,檔案資源,,磁碟資源等等

程式向作業系統申請這些資源,對於計算機資源的多樣性和程式對資源申請的多樣性,如果不加以嚴格的管理,可能會導致嚴重的後果

(4) 程式的管理與排程

在這裡插入圖片描述
對於上圖,強調以下幾點:
1,程式(執行緒)被建立後,不是立刻進入執行態,先進入就緒態
2,阻塞態的程式(執行緒)在喚醒前,沒有資格競爭CPU
3,阻塞態的程式(執行緒)必須由其它程式(執行緒)喚醒
4,被喚醒的程式(執行緒)不是立刻進入執行態,而是進入就緒態

2 保護臨界資源

(1) 原語

由高階程式設計語言編寫的源程式的一條語句,編譯成機器語言後可能對應多條語句,而在多道程式並行環境中,這些多條語句的執行可能隨時被中斷

但是在實際程式設計需求中,有些語句的執行是不希望被打斷的,必須完全執行,為了滿足這樣的要求,計算機系統提出了“原語”

原語:一條或多條語句,對於它們的執行不會被中斷

對於程式(執行緒)的建立,排程,阻塞,喚醒的操作實質上都是原語,作為原語,應該做到:程式碼儘可能少,儘可能不要出現長迴圈,尤其不能出現遞迴呼叫和I/O操作

(2) 臨界資源

有些情況下,需要兩個或多個程式之間的互相配合,共同完成某一程式設計任務,它們之間通過“共享資料”的方式建立聯絡,對於這種存在著關聯關係的多個程式,如果不仔細處理其中的邏輯關係,就可能造成程式的失敗

臨界資源指的是程式中的某些程式碼段,在程式的程式碼中,與“共享資料”操作有關的程式碼往往被稱為臨界資源

(3) 鎖

對於臨界資源,通過加鎖,阻止不該進入的程式

對於鎖的的幾點宣告:
1,鎖必須是所有相關程式共享的,即大家都知道這個鎖的存在
2,多個相關程式檢查/開啟/關閉的應該是同一把鎖
3,程式(執行緒)若是遇到鎖,一定先檢視鎖的狀態

若鎖的狀態是開啟的,先關閉鎖,再進入臨界資源
若鎖的狀態是關閉的,則阻塞自己,將自己放入該鎖的阻塞態佇列

檢視鎖和關閉是一個原語,不會被打斷,在Java中,對鎖的檢視,開鎖和關鎖操作,都是由JVM實現的

(4) 程式與執行緒

執行緒是輕量級的程式

執行緒是由程式建立的,但執行緒不再申請另外的計算機資源,即執行緒不需要像程式那樣有龐大的資源表,也不需要像程式那樣對資源進行嚴格的管理

執行緒所使用的資源都是程式申請的,多個執行緒的狀態切換比程式更簡單,更省時,執行緒也可以生成新的執行緒(子執行緒)

P2 建立多執行緒

1 繼承Thread類

執行緒類:

package com.mec.thread;

public class MyThread extends java.lang.Thread {
	
	private String threadName;
	
	public MyThread(String threadName) {
		this.threadName = threadName;
	}
	
	//Thread類中必須覆蓋run()方法
	//run()方法體中的內容是執行緒將執行的程式碼
	@Override
	public void run() {
		for(int i = 0; i < 100; i++) {
			System.out.println(this.threadName + ":" + i);
		}	
	}
	
}

測試類:

package com.mec.thread.test;

import com.mec.thread.MyThread;

public class Test {

	public static void main(String[] args) {
		
		MyThread thread1 = new MyThread("執行緒1");
		MyThread thread2 = new MyThread("執行緒2");
		MyThread thread3 = new MyThread("執行緒3");
		
		
		//start()用來建立一個新執行緒,將該建立的執行緒放入就緒態
		//這個執行緒等待JVM的執行緒排程器,被JVM排程到執行態方可執行
		thread1.start();
		thread2.start();
		thread3.start();	
	}

}

執行結果1:
在這裡插入圖片描述
執行結果2:
在這裡插入圖片描述
對比這兩次執行結果發現各個執行緒執行的狀況是不確定的,這與當前時刻作業系統的狀態有關,每一個執行緒都是獨立執行的

在向測試類中加一行輸出語句:
在這裡插入圖片描述
執行結果1:
在這裡插入圖片描述
執行結果2:
在這裡插入圖片描述
對於輸出“"main()函式所在主執行緒執行結束”這句話,絕大多數都是輸出在第一行

上述的結果再一次說明了start()方法的作用是建立執行緒,而不是執行執行緒,start()建立一個執行緒後,將其放入就緒態,等待作業系統排程

2 實現Runnable介面

執行緒類:

package com.mec.thread;

public class MyThread2 implements Runnable {
	
	//count被static修飾,只有一份
	//在這個程式中,用兩個執行緒來更改同一個count的值
	private static int count;
	private String threadName;
	
	public MyThread2(String threadName) {
		this.threadName = threadName;
	}	
	
	

	@Override
	public void run() {
		
		for(int i = 0; i < 100; i++) {
			
			MyThread2.count += 5;
			for(int j = 0; j < 10000; j++) {
			}
			MyThread2.count -= 4;		
			System.out.println(this.threadName + ":" + MyThread2.count);
		}
	}
	
}

測試類:

package com.mec.thread.test;

import com.mec.thread.MyThread2;

public class Test2 {

	public static void main(String[] args) {
				
		//通過Runnable介面建立的執行緒類沒有start()方法,
		//必須先生成一個物件
		//再使用Thread類的方法,將先前的物件作為一個引數傳入
		//才可以使用start()方法
		MyThread2 myThread1 = new MyThread2("執行緒1");
		MyThread2 myThread2 = new MyThread2("執行緒2");
		
		
		Thread thread1 = new Thread(myThread1);
		thread1.start();
		new Thread(myThread2).start();;

	}

}

執行結果1:
在這裡插入圖片描述
在這裡插入圖片描述
執行結果2:
在這裡插入圖片描述
在這裡插入圖片描述
上述執行緒類中run()方法中的執行緒體目的是讓n個執行緒獨立各自執行100次,其中對static修飾的count進行操作,理想情況下執行緒交替執行,那麼count最後的結果是100n,且輸出是有順序的,從1到100n

但是這僅僅是理想情況下,真實執行的多執行緒不可能這麼簡單,考慮假設執行緒1開始第一次執行,對count+5後,此時count的值是5,進入迴圈時,迴圈一段時間後,執行緒1的時間片用盡,進入就緒態,執行緒2被排程到執行態,開始執行程式碼,對於此時的count,它直接+5,此時count就成了10,接下來如此往復,執行緒2也可能在時間片用完後被調入就緒態,這樣對於共享的資料count就徹底亂套了

3 使用extends繼承Thread類和實現Runnable介面的區別

雖然extends Thread簡明扼要,但是更推薦使用實現Runnable介面的方式來建立執行緒類,其原因有:

1,extends只能單繼承,而介面可以多實現
2,Runnable介面很乾淨,裡面只有一個run()方法

P3 鎖

對於上面count的輸出混亂的結果不是我們想要的,想要count有序的輸出資料,這時就需要給臨界資源(涉及到count的程式碼段)加鎖

根據鎖的定義:
1,鎖必須是所有相關程式共享的,即大家都知道這個鎖的存在
2,多個相關程式檢查/開啟/關閉的應該是同一把鎖
3,程式(執行緒)若是遇到鎖,一定先檢視鎖的狀態

1 synchronized (lock) {…}

推薦使用物件鎖,給臨界資源上鎖:

package com.mec.thread;

public class MyThread2 implements Runnable {
	
	//count被static修飾,只有一份
	//在這個程式中,用兩個執行緒來更改同一個count的值
	private static int count;
	private String threadName;
	
	//定義單獨一份的物件鎖
	private static Object lock;
	
	//初始化一個物件鎖
	static {
		lock = new Object();
	}
	
	public MyThread2(String threadName) {
		this.threadName = threadName;
	}	
	
	

	@Override
	public void run() {
		for(int i = 0; i < 100; i++) {
			//將與共享資料有關的臨界資源上鎖
			synchronized (lock) {
				MyThread2.count += 5;
				for(int j = 0; j < 10000; j++) {
				}
				MyThread2.count -= 4;
				
				System.out.println(this.threadName + ":" + MyThread2.count);	
			}	
		}	
	}
	
	
}

執行結果:
在這裡插入圖片描述
在對共享資料的臨界資源加鎖後,假設一開始執行緒1執行,執行緒1遇到lock,先檢查lock此刻的狀態,此時lock是開啟的,執行緒1先對lock加鎖,接著執行臨界資源中的程式碼,如果此時執行緒1被排程到了就緒態,執行緒2開始執行,執行緒2遇到lock,檢查到現在lock的狀態是關閉的,執行緒2自己阻塞自己,作業系統將執行緒2放入到lock的阻塞佇列中,此時執行緒2只能等待被喚醒,它無法競爭CPU,接著執行緒1繼續執行臨界資源中的程式碼,直到遇到synchronized (lock) { 的右花括號,此時執行緒1順利的執行完了臨界資源中的程式碼,且不會被其它的執行緒打斷,執行緒1將lock開啟,並喚醒該鎖上阻塞佇列的所有執行緒,將它們排程到就緒態,等待執行,自此count的輸出就會變得有順序

2 Thread.sleep(x)

對於上述的測試結果,可以看到一個執行緒可能執行很多次才輪到另一個執行緒,因為在整個執行緒體中,只有for(int i = 0; i < 100; i++)中進行i < 100和i++的操作時,才可能會被其它的執行緒打斷,其它的所有程式都被lock包住了,如果想更有順序的輪換執行緒執行,可以:

@Override
	public void run() {
		
		for(int i = 0; i < 100; i++) {
			try {
				Thread.sleep(10);
			} catch (InterruptedException e) {
			}
			//將與共享資料有關的臨界資源上鎖
			synchronized (lock) {
				MyThread2.count += 5;
				for(int j = 0; j < 10000; j++) {
				}
				MyThread2.count -= 4;
				
				System.out.println(this.threadName + ":" + MyThread2.count);	
			}
			
		}
		
	}

sleep(10),會讓該執行緒空等10ms,這段時間,如果該執行緒的時間片用盡,就會有別的執行緒從就緒態被排程到執行態執行程式:

在這裡插入圖片描述

P4 volatile關鍵字

1 計算機儲存體系簡介

計算機儲存體系從外到內分為5層:
1,海量外存,儲存空間最大,速度最慢
2,外存,儲存空間大,速度較慢
3,記憶體,儲存空間不是很大,速度較快
4,快取記憶體,儲存空間很小,速度很快
5,暫存器,儲存空間最小,速度最快

我們所編寫的程式,變數,陣列的本質就是記憶體空間,對變數,陣列元素的訪問本質上就是對記憶體的訪問

for(int i = 0; i < 10000; i++) {
	...
}

對於迴圈的條件和步長i,會在程式中頻繁被訪問,如果每次都要從記憶體中訪問i進行判斷,從記憶體中取出i,對i+1後,再將其寫入記憶體,程式的執行效率會因為記憶體訪問速度較慢,而變得低效

2 變數的暫存器優化

很多編譯軟體都會對“要頻繁訪問記憶體的變數”,進行暫存器優化

即對於這個變數,編譯系統第一次從記憶體中訪問它時,用一個暫存器儲存它的值,之後對它的讀和寫都在暫存器中完成,由於暫存器的高速,整個程式的速度就會提升

但這意味著,如果存在兩個執行緒,它們對同一變數進行操作,但該變數被編譯系統暫存器優化後,兩個執行緒表面上是對同一執行緒進行訪問,但是卻是對各自的暫存器中的值進行訪問,即就算某個執行緒更改了“同一個變數”的值,其本質只是操作自己的暫存器的值,並沒有影響到記憶體,從而使得兩個執行緒並沒有真正聯絡在一起

3 private volatile static int count

對於先前的count,雖然程式測試的結果表明他沒有被暫存器優化,但如果將迴圈增大,可能會出現上述的問題,所以對與共享資料,一般都加上volatile,避免暫存器優化有實用意義的共享資料導致多執行緒沒有聯絡在一起

但使用volatile也是有代價的,volatile拒絕暫存器優化,堅持從記憶體中讀寫,也必然會降低速度,對volatile的使用不能氾濫

P5 使用Java多執行緒完成生產者,消費者問題

1 生產者,消費者問題

考慮這樣一個場景:兩個執行緒,一個執行緒負責生產資料,一個執行緒負責消耗資料,如果生產者沒有生產出資料,消費者自然沒法消費,但如果生產者已經生產出一個資料了,而消費者尚未消耗該資料,那麼生產者應該等待消費者消耗完該資料,再生產資料

其本質就是兩個執行緒的同步問題,做到兩個執行緒交替執行即可

2 wait()和notify()

wait()方法的本質是讓執行這個方法的執行緒進入阻塞態

notify()方法用於喚醒處在阻塞態的相關執行緒

3 實現過程

對於生產者,需要設定一個表示“之前生產的資料是否已經被消耗”的標誌

對於消費者,需要設定一個表示“是否有資料可供消費”的標誌

對於生產者和消費者,需要設定一個共同的物件鎖

需要設定一個共享的資料,即生產者生產的資料消費者消耗的資料,這兩個是同一個

通過建立一個父類,由兩個子類繼承來完成:

package com.mec.thread;

public class ProducerCustomer {
	
	//是否有資料以供消費
	public static boolean hasValue = false;
	
	//是否以消費
	public static boolean isConsume = true;
	
	//物件鎖
	public static Object lock = new Object();
	
	//共享資料
	public volatile static int data;

}

生產者類:

package com.mec.thread;

import java.util.Random;

public class Producer extends ProducerCustomer implements Runnable {
	
	private Random random;
	private String threadName;
	private Thread thisThread;
	
	public Producer() {
		random = new Random();
		threadName = "生產者";
		thisThread = new Thread(this,threadName);
	}
	
	
	public void startProducer(){
		thisThread.start();
		System.out.println("執行緒" + threadName + "建立");
	}


	@Override
	public void run() {
			
		while(true) {	
			synchronized (lock) {
				//如果已消耗,生產一個資料,將已消耗標誌改為false
				//將是否有資料標誌改為true,喚醒阻塞的消費者
				if(isConsume) {
					data = random.nextInt(1000);
					System.out.println(threadName + "生產了一個資料" + data);
					isConsume = false;
					hasValue = true;
					lock.notify();
				} else {
					//如果未消耗,阻塞自己,等待被喚醒
					try {
						lock.wait();
					} catch (InterruptedException e) {
						e.printStackTrace();
					}
				}
			}	
		}		
	}
	
	
	
}

消費者類:

package com.mec.thread;

public class Customer extends ProducerCustomer implements Runnable {
	
	
	private String threadName;
	private Thread thisThread;
	
	public Customer() {
		threadName = "消費者";
		thisThread = new Thread(this,threadName);
	}
	
	
	public void startCustomer(){
		thisThread.start();
		System.out.println("執行緒" + threadName + "建立");
	}

	@Override
	public void run() {
		
		while(true) {
			synchronized (lock) {
				//如果有資料未消耗,消耗該資料,將已消耗標誌改為true
				//將是否有資料改為false,喚醒阻塞的生產者
				if(hasValue) {
					System.out.println(threadName + "消耗了一個資料" + data);
					isConsume = true;
					hasValue = false;
					lock.notify();					
				} else {
					//如果已經消耗,則阻塞自己,等待被喚醒
					try {
						lock.wait();
					} catch (InterruptedException e) {
						e.printStackTrace();
					}
				}
			}
		}
	}

	
}

測試類:

package com.mec.thread.test;

import com.mec.thread.Customer;
import com.mec.thread.Producer;

public class TestPC {

	public static void main(String[] args) {
		
		Producer producer = new Producer();
		Customer customer = new Customer();
		
		producer.startProducer();
		customer.startCustomer();

	}

}

執行結果:
在這裡插入圖片描述

相關文章