在本篇文章當中主要跟大家介紹併發的基礎知識,從最基本的問題出發層層深入,幫助大家瞭解併發知識,並且打好併發的基礎!!! ...
前言
在本篇文章當中主要跟大家介紹併發的基礎知識,從最基本的問題出發層層深入,幫助大家瞭解併發知識,並且打好併發的基礎,為後面深入學習併發提供保證。本篇文章的篇章結構如下:
併發的需求
- 我們常用的軟體就可能會有這種需求,對於一種軟體我們可能有多重需求,程式可能一邊在運行一邊在後臺更新,因此在很多情況下對於一個進程或者一個任務來說可能想要同時執行兩個不同的子任務,因此就需要在一個進程當中產生多個子線程,不同的線程執行不同的任務。
- 現在的機器的CPU核心個數一般都有很多個,比如現在一般的電腦都會有4個CPU,而每一個CPU在同一個時刻都可以執行一個任務,因此為了充分利用CPU的計算資源,我們可以讓這多個CPU同時執行不同的任務,讓他們同時工作起來,而不是空閑沒有事可做。
- 還有就是在科學計算和高性能計算領域有這樣的需求,比如矩陣計算,如果一個線程進行計算的話需要很長的時間,那麼我們就可能使用多核的優勢,讓多個CPU同時進行計算,這樣一個計算任務的計算時間就會比之前少很多,比如一個任務單線程的計算時間為24小時,如果我們有24個CPU核心,那麼我們的計算任務可能在1-2小時就計算完成了,可以節約非常多的時間。
併發的基礎概念
在併發當中最常見的兩個概念就是進程和線程了,那什麼是進程和線程呢?
- 進程簡單的說來就是一個程式的執行,比如說你在windows操作系統當中雙擊一個程式,在linux當中在命令行執行一條命令等等,就會產生一個進程,總之進程是一個獨立的主體,他可以被操作系統調度和執行。
- 而線程必須依賴進程執行,只有在進程當中才能產生線程,現在通常會將線程稱為輕量級進程(Light Weight Process)。一個進程可以產生多個線程,二者多個線程之間共用進程當中的某些數據,比如全局數據區的數據,但是線程的本地數據是不進行共用的。
你可能會聽過進程是資源分配的基本單位,這句話是怎麼來的呢?在上面我們已經提到了線程必須依賴於進程而存在,在我們啟動一個程式的時候我們就會開啟一個進程,而這個進程會向操作系統申請資源,比如記憶體,磁碟和CPU等等,這就是為什麼操作系統是申請資源的基本單位。
你可能也聽過線程是操作系統調度的基本單位。那這又是為什麼呢?首先你需要明白CPU是如何工作的,首先需要明白我們的程式會被編譯成一條條的指令,而這些指令會存在在記憶體當中,而CPU會從記憶體當中一一的取出這些指令,然後CPU進行指令的執行,而一個線程通常是執行一個函數,而這個函數也是會被編譯成很多指令,因此這個線程也可以被CPU執行,因為線程可以被操作系統調度,將其放到CPU上進行執行,而且沒有比線程更小的可以被CPU調度的單位了,因此說線程是操作系統調度的基本單位。
Java實現併發
繼承Thread類
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上,最終輸出不同的字元串。
使用匿名內部類實現runnable介面
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
給我們提供的方法去實現多線程,這個點我們在後文當中會進行說明。
理解主線程和Join函數
假如現在我們有一個任務,子線程輸出一下自己的線程的名字,線上程輸出完自己的名字之後,主線程再輸出字元串“線程執行完成”。
在完成上面的任務之前,首先我們需要明白什麼是主線程和子線程,所謂主線程就是在執行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\),當然我們就積分(下圖當中的陰影部分)完全可以根據公式進行求解(如果你不懂積分也沒有關係,下文我們會把這個函數寫出來,不會影響你對併發的理解):
\[\int_0^{10} x^2\mathrm{d}x = \frac{1}{3}x^3+C \]但是我們用程式去求解的時候並不是採用上面的方法,而是使用微元法:
\[\int_0^{10} x^2\mathrm{d}x =\sum_{ i= 0}^{1000000}(i * 0.00001) ^2 * 0.00001 \]下麵我們用一個單線程先寫出求\(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倍,由此可見併發的“威力”。
FutureTask機制
在前文和代碼當中,我們發現不論是我們繼承自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、電腦系統基礎、演算法與數據結構)知識。