在本篇文章當中主要跟大家介紹並行的基礎知識,從最基本的問題出發層層深入,幫助大家瞭解並行知識,並且打好並行的基礎,為後面深入學習並行提供保證。本篇文章的篇章結構如下:
在並行當中最常見的兩個概念就是程序和執行緒了,那什麼是程序和執行緒呢?
你可能會聽過程序是資源分配的基本單位,這句話是怎麼來的呢?在上面我們已經提到了執行緒必須依賴於程序而存在,在我們啟動一個程式的時候我們就會開啟一個程序,而這個程序會向作業系統申請資源,比如記憶體,磁碟和CPU等等,這就是為什麼作業系統是申請資源的基本單位。
你可能也聽過執行緒是作業系統排程的基本單位。那這又是為什麼呢?首先你需要明白CPU是如何工作的,首先需要明白我們的程式會被編譯成一條條的指令,而這些指令會存在在記憶體當中,而CPU會從記憶體當中一一的取出這些指令,然後CPU進行指令的執行,而一個執行緒通常是執行一個函數,而這個函數也是會被編譯成很多指令,因此這個執行緒也可以被CPU執行,因為執行緒可以被作業系統排程,將其放到CPU上進行執行,而且沒有比執行緒更小的可以被CPU排程的單位了,因此說執行緒是作業系統排程的基本單位。
public class ConcurrencyMethod1 extends Thread {
@Override
public void run() {
// Thread.currentThread().getName() 得到當前正在執行的執行緒的名字
System.out.println(Thread.currentThread().getName());
}
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
// 新開啟一個執行緒
ConcurrencyMethod1 t = new ConcurrencyMethod1();
t.start();// 啟動這個執行緒
}
}
}
// 某次執行輸出的結果(輸出的順序不一定)
Thread-0
Thread-4
Thread-1
Thread-2
Thread-3
上面程式碼當中不同的執行緒需要得到CPU資源,在CPU當中被執行,而這些執行緒需要被作業系統排程,然後由作業系統放到不同的CPU上,最終輸出不同的字串。
public class ConcurrencyMethod2 extends Thread {
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
// Thread.currentThread().getName() 得到當前正在執行的執行緒的名字
System.out.println(Thread.currentThread().getName());
}
});
thread.start();
}
}
}
// 某次執行輸出的結果(輸出的順序不一定)
Thread-0
Thread-1
Thread-2
Thread-4
Thread-3
當然你也可以採用Lambda函數去實現:
public class ConcurrencyMethod3 {
public static void main(String[] args) {
for (int i=0; i < 5; i++) {
Thread thread = new Thread(() -> {
System.out.println(Thread.currentThread().getName());
});
thread.start();
}
}
}
// 輸出結果
Thread-0
Thread-1
Thread-2
Thread-4
Thread-3
其實還有一種JDK
給我們提供的方法去實現多執行緒,這個點我們在後文當中會進行說明。
假如現在我們有一個任務,子執行緒輸出一下自己的執行緒的名字,線上程輸出完自己的名字之後,主執行緒再輸出字串「執行緒執行完成」。
在完成上面的任務之前,首先我們需要明白什麼是主執行緒和子執行緒,所謂主執行緒就是在執行Java程式的時候不是通過new Thread
操作這樣顯示的建立的執行緒。比如在我們的非並行的程式當中,執行程式的執行緒就是主執行緒。
public class MainThread {
public static void main(String[] args) {
System.out.println("我是主執行緒");
}
}
比如在上面的程式碼當中執行語句System.out.println("我是主執行緒");
的執行緒就是主執行緒。
public class MainThread {
public static void main(String[] args) {
// 下面這段程式碼是由主執行緒執行的
// 主執行緒通過下面這段程式碼建立一個子執行緒
Thread thread = new Thread(() -> {
System.out.println("我是主執行緒建立的子執行緒");
});
// 這句程式碼也是主執行緒執行的
// 主要意義就是主執行緒啟動子執行緒
thread.start();
System.out.println("我是主執行緒");
}
}
現在我們再來看一下我們之前的任務:
假如現在我們有一個任務,子執行緒輸出一下自己的執行緒的名字,線上程輸出完自己的名字之後,主執行緒再輸出字串「執行緒執行完成」。
上面的任務很明確就是主執行緒在執行輸出自己執行緒的名字的語句之前,必須等待子執行緒執行完成,而在Java
執行緒當中給我提供了一種方式,幫助我們實現這一點,可以保證主執行緒的某段程式碼可以在子執行緒執行完成之後再執行。
public class MainThread {
public static void main(String[] args) throws InterruptedException {
// 下面這段程式碼是由主執行緒執行的
// 主執行緒通過下面這段程式碼建立一個子執行緒
Thread thread = new Thread(() -> {
System.out.println(Thread.currentThread().getName());
});
// 這句程式碼也是主執行緒執行的
// 主要意義就是主執行緒啟動子執行緒
thread.start();
// 這句程式碼的含義就是阻塞主執行緒
// 直到 thread 的 run 函數執行完成
thread.join();
System.out.println(Thread.currentThread().getName());
}
}
// 輸出結果
Thread-0
main
上面程式碼的執行流程大致如下圖所示:
我們需要知道的一點是thread.join()
這條語句是主執行緒執行的,它的主要功能就是等待執行緒thread
執行完成,只有thread
執行完成之後主執行緒才會繼續執行thread.join()
後面的語句。
接下來我們用一個例子去具體體會並行帶來的效果提升。我們的這個例子就是求函數的積分,我們的函數為最簡單的二次函數\(x^2\),當然我們就積分(下圖當中的陰影部分)完全可以根據公式進行求解(如果你不懂積分也沒有關係,下文我們會把這個函數寫出來,不會影響你對並行的理解):
但是我們用程式去求解的時候並不是採用上面的方法,而是使用微元法:
下面我們用一個單執行緒先寫出求\(x^2\)積分的程式碼:
public class X2 {
public static double x2integral(double a, double b) {
double delta = 0.001;
return x2integral(a, b, delta);
}
/**
* 這個函數是計算 x^2 a 到 b 位置的積分
* @param a 計算積分的起始位置
* @param b 計算積分的最終位置
* @param delta 表示微元法的微元間隔
* @return x^2 a 到 b 位置的積分結果
*/
public static double x2integral(double a, double b, double delta) {
double sum = 0;
while (a <= b) {
sum += delta * Math.pow(a, 2);
a += delta;
}
return sum;
}
public static void main(String[] args) {
// 這個輸出的結果為 0.3333333832358528
// 這個函數計算的是 x^2 0到1之間的積分
System.out.println(x2integral(0, 1, 0.0000001));
}
}
上面程式碼當中的函數x2integral
主要是用於計算區間\([a, b]\)之間的二次函數\(x^2\)的積分結果,我們現在來看一下如果我們想計算區間[0, 10000]
之間的積分結果且delta = 0.000001
需要多長時間,其中delta
表示每一個微元之間的距離。
public static void main(String[] args) {
long start = System.currentTimeMillis();
System.out.println(x2integral(0, 10000, 0.000001));
long end = System.currentTimeMillis();
System.out.println(end - start);
}
從上面的結果來看計算區間[0, 10000]
之間的積分結果且delta = 0.000001
,現在假設我們使用8
個執行緒來做這件事,我們該如何去規劃呢?
因為我們是採用8
個執行緒來做這件事兒,因此我們可以將這個區間分成8
段,每個執行緒去執行一小段,最終我們將每一個小段的結果加起來就行,整個過程大致如下。
首先我們先定義一個繼承Thread
的類(因為我們要進行多執行緒計算,所以要繼承這個類)去計算區間[a, b]
之間的函數\(x^2\)的積分:
class ThreadX2 extends Thread {
private double a;
private double b;
private double sum = 0;
private double delta = 0.000001;
public double getSum() {
return sum;
}
public void setSum(double sum) {
this.sum = sum;
}
public double getDelta() {
return delta;
}
public void setDelta(double delta) {
this.delta = delta;
}
/**
* 重寫函數 run
* 計算區間 [a, b] 之間二次函數的積分
*/
@Override
public void run() {
while (a <= b) {
sum += delta * Math.pow(a, 2);
a += delta;
}
}
public double getA() {
return a;
}
public void setA(double a) {
this.a = a;
}
public double getB() {
return b;
}
public void setB(double b) {
this.b = b;
}
}
我們最終開啟8個執行緒的程式碼如下所示:
public static void main(String[] args) throws InterruptedException {
// 單執行緒測試計算時間
System.out.println("單執行緒");
long start = System.currentTimeMillis();
ThreadX2 x2 = new ThreadX2();
x2.setA(0);
x2.setB(1250 * 8);
x2.start();
x2.join();
System.out.println(x2.getSum());
long end = System.currentTimeMillis();
System.out.println("花費時間為:" + (end - start));
System.out.println("多執行緒");
// 多執行緒測試計算時間
start = System.currentTimeMillis();
ThreadX2[] threads = new ThreadX2[8];
for (int i = 0; i < 8; i++) {
threads[i] = new ThreadX2();
threads[i].setA(i * 1250);
threads[i].setB((i + 1) * 1250);
}
// 這裡要等待每一個執行緒執行完成
// 因為只有執行完成才能得到計算的結果
for (ThreadX2 thread : threads) {
thread.start();
}
for (ThreadX2 thread : threads) {
thread.join();
}
end = System.currentTimeMillis();
System.out.println("花費時間為:" + (end - start));
double ans = 0;
for (ThreadX2 thread : threads) {
ans += thread.getSum();
}
System.out.println(ans);
}
// 輸出結果
單執行緒
3.333332302493948E11
花費時間為:14527
多執行緒
花費時間為:2734
3.333332303236695E11
單執行緒 | 多執行緒(8個執行緒) | |
---|---|---|
計算結果 | 3.333332302493948E11 | 3.333332303236695E11 |
執行時間 | 14527 | 2734 |
從上面的結果來看,當我們使用多個執行緒執行的時候花費的時間比單執行緒少的多,幾乎減少了7倍,由此可見並行的「威力」。
在前文和程式碼當中,我們發現不論是我們繼承自Thread
類或者寫匿名內部內我們都沒有返回值,我們的返回值都是void
,那麼如果我們想要我們的run
函數有返回值怎麼辦呢?JDK
為我們提供了一個機制,可以讓執行緒執行我們指定函數並且帶有返回值,我們來看下面的程式碼:
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class FT {
public static void main(String[] args) throws ExecutionException, InterruptedException {
FutureTask<Integer> task = new FutureTask<>(new MyCallable());
Thread thread = new Thread(task);
thread.start();
// get 函數如果結果沒有計算出來
// 主執行緒會在這裡阻塞,如果計算
// 出來了將返回結果
Integer integer = task.get();
System.out.println(integer);
}
}
class MyCallable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
System.out.println("執行緒正在執行");
return 101;
}
}
// 輸出結果
執行緒正在執行
101
從上面的繼承結構我們可以看出FutureTask
實現了Runnable
介面,而上面的程式碼當中我們最終會將一個FutureTask
作為引數傳入到Thread
類當中,因此執行緒最終會執行FutureTask
當中的run
方法,而我們也給FutureTask
傳入了一個Callable
介面實現類物件,那麼我們就可以在FutureTask
當中的run
方法執行我們傳給FutureTask
的Callable
介面中實現的call
方法,然後將call
方法的返回值儲存下來,當我們使用FutureTask
的get
函數去取結果的時候就將call
函數的返回結果返回回來,在瞭解這個過程之後你應該可以理解上面程式碼當中FutureTask
的使用方式了。
需要注意的一點是,如果我們在呼叫get
函數的時候call
函數還沒有執行完成,get
函數會阻塞呼叫get
函數的執行緒,關於這裡面的實現還是比較複雜,我們在之後的文章當中會繼續討論,大家現在只需要在邏輯上理解上面使用FutureTask
的使用過程就行。
在本篇文章當中主要給大家介紹了一些並行的需求和基礎概念,並且使用了一個求積分的例子帶大家切身體會並行帶來的效果提升,並且給大家介紹了在Java當中3中實現並行的方式,並且給大家梳理了一下FutureTask
的方法的大致工作過程,幫助大家更好的理解FutureTask
的使用方式。除此之外給大家介紹了join函數,大家需要好好去理解這一點,仔細去了解join函數到底是阻塞哪個執行緒,這個是很容搞錯的地方。
以上就是本文所有的內容了,希望大家有所收穫,我是LeHung,我們下期再見!!!(記得點贊收藏哦!)
更多精彩內容合集可存取專案:https://github.com/Chang-LeHung/CSCore
關注公眾號:一無是處的研究僧,瞭解更多計算機(Java、Python、計算機系統基礎、演演算法與資料結構)知識。