java+向前进一_Java 线程基础
前言
線程并發系列文章:
熟練掌握線程原理與使用是程序員進階的必經之路,網上很多關于Java線程的知識,比如多線程之間變量的可見性、操作的原子性,進而擴展出的Volatile、鎖(CAS/Synchronized/Lock)、信號量等知識。有些文章只說籠統的概念、有些文章深入底層源碼令人迷失其中、有些文章只說了其中某個點沒有提及內在的聯系。
基于以上原因,本系列文章嘗試由淺入深、系統性地分析、總結Java線程相關知識,算是加深印象、夯實基礎,也算是拋磚引玉。若是相關文章對各位看官有所幫助,幸甚至哉。
通過本篇文章,你將了解到:
1、進程與線程區別
2、開啟/停止線程
3、線程的交互
1、進程與線程區別
程序與進程
平時所說的編寫一個程序/軟件,比如編寫好一個APK,這個APK可以直接傳送給另一個設備安裝,這時候我們說發送給你一個程序/軟件,是個靜態的單個文件/多個文件的集合。
當安裝好APK之后,運行該APK,該程序就被CPU執行了,這時候我們稱這個進程在運行了。因此進程是程序的動態表現,也是CPU執行時間段的描述。
image.png
當然,程序與進程也不是一一對應關系,也就是說一個程序里可以fork()多個進程來執行任務。
進程與線程
CPU調度執行程序之前,需要準備好一些數據,如程序所在的內存區域,程序需要訪問的外設資源等,程序運行過程中產生的一些中間變量需要臨時存儲在寄存器等。這些與進程本身關聯的東西稱之為進程上下文。
由此引發的問題:CPU在切換進程的過程中勢必涉及到上下文的切換,切換的過程會占用CPU時間。
image.png
通俗點理解就是:進程1先被CPU調度執行,執行了一段時間后調度進程2執行,此時上下文就會切換成與進程2相關的。
再考慮另一種情形:一個程序里實現了A、B兩個有關聯的功能,兩者在不同的進程實現,A進程需要與B進程交互,該過程就是個IPC(進程間通信)。我們知道,IPC需要共享內存或者陷入內核調用,這些操作代價比較大。
Android 進程間通信系列文章請移步:Android IPC 看了都懂系列
隨著計算機硬件越來越強大,CPU頻率越來越高,甚至還發展出多個CPU。為了充分利用CPU,線程應運而生。
進程被分為更小的粒度,原本一個進程要執行A、B、C三個任務,現在將這三個任務分別放在三個線程里執行。
image.png
可以看出,CPU調度的基本單位就是線程。
進程與線程關系
1、進程與線程均是CPU執行時間段的描述。
2、進程是資源分配的基本單位,線程是CPU調度的基本單位。
3、一個進程里至少有一個線程。
4、同一進程里的各個線程可以共享變量,它們之間的通信稱之為線程間通信。
5、線程可以看作粒度更小的進程。
線程的優勢
1、開啟新線程遠比開啟新進程節約資源,并且更快速。
2、線程間通信比IPC簡單、快捷易于理解。
3、符合POSIX規范的線程可以跨平臺移植。
2、開啟/停止線程
既然線程如此重要,那么來看看Java中如何開啟與停止線程。
開啟線程
查看Thread.java源碼可知,Thread實現了Runnable接口,因此需要重寫Runnable方法:run()。
#Thread.java
@Override
public void run() {
if (target != null) {
target.run();
}
}
而線程開啟后執行任務的方法即是run()。
該方法里先判斷target是否不為空,若是則執行target.run()。
#Thread.java
/* What will be run. */
private Runnable target;
target為Runnable類型,該引用可以通過Thread構造方法賦值。
由此看就比較明顯了,要線程實現任務,要么直接重寫run()方法,要么傳入Runnable引用。
繼承Thread
聲明MyThread繼承自Thread,并重寫run()方法
static class MyThread extends Thread {
@Override
public void run() {
System.out.println("thread running by extends...");
}
}
private static void startThreadByExtends() {
MyThread t2 = new MyThread();
t2.start();
}
生成Thread引用后,調用start()方法開啟線程。
實現Runnable
先構造Runnable,再將Runnable引用傳遞給Thread。
private static void startThreadByImplements() {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("thread running by implements...");
}
};
Thread t1 = new Thread(runnable);
t1.start();
}
生成Thread引用后,調用start()方法開啟線程。
停止線程
線程開啟后,被CPU調度后執行run()方法,該方法執行完畢線程正常退出。當然也可以在run()方法執行途中退出該方法(設置標記位,滿足條件即退出),該線程也將停止。若是run()方法里正在Thread.sleep(xx)、Object.wait()等方法,可以使用interrupt()方法中斷線程。
private static void stopThread() {
MyThread t2 = new MyThread();
t2.start();
//中斷線程
t2.interrupt();
//已廢棄
t2.stop();
}
3、線程的交互
硬件層面
先來看看CPU和主存的交互:
image.png
CPU運算速度遠遠高于訪問主存的速度,也就是說,當CPU需要計算如下表達式:
int a = a + 1;
首先從主存里拿到a的值,訪問主存的過程中CPU是等待狀態,當從主存拿到a的值后才進行運算。這個過程顯然很浪費CPU的時間,因此在主存與CPU之間增加了高速緩存,顧名思義,當拿到a的值后,放到高速緩存,下次再次訪問a的時候先去看看緩存里是否有,有的話直接拿到放到寄存器里,最后按照一定的規則將改變后的a的值刷新到主存里。
訪問速度:寄存器-->高速緩存-->主存,CPU在尋找值的時候先找寄存器,再到高速緩存,最后到主存。
你可能已經發現問題了,如下代碼:
int a = 1;
int a++;
線程A、線程B分別執行上述代碼,假設線程A被CPU1調度,線程B被CPU2調度。線程A、B分別執行a = 1,此時CPU1、CPU2的高速緩存分別存放著a=1,當線程A執行a++時發現高速緩存有值于是直接拿出來計算,結果是:a=2。
當線程B執行時同樣的從高速緩存獲取值來計算,結果是:a=2。
最后高速緩存將修改后的值回寫的主存,結果是a=2。
這樣的結果不是我們愿意看到的,CPU針對此種情況設計了一套同步高速緩存+主存的機制:MESI(緩存一致性協議)
該協議約定了各個CPU的高速緩存間與主存的配合,盡量保證緩存數據是一致的。但是由于StoreBuffer/InvalidateQueue的存在,還需要配合Volatile使用。
有關Volatile詳細解析請移步:真正理解Java Volatile的妙用
軟件層面
由于寄存器、高速緩存的存在,讓我們有種感覺:每個線程都擁有自己的本地內存。
實際上,JVM設計了JMM(Java Memory Model Java內存模型):
image.png
本地內存是個虛擬概念,如下代碼:
static Integer integer = new Integer(0);
public static void main(String args[]) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
integer = 5;
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
integer = 6;
}
});
t1.start();
t2.start();
}
integer 在主存中只有一份,可能還存在于寄存器、高速緩存等地方,這些地方對應的是本地內存。而不是每個線程又重新復制了一份數據。
再看看一段代碼:
static boolean flag = false;
static int a = 0;
public static void main(String args[]) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
a = 1; //1
flag = true; //2
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
if (flag) { //3
a = 2; //4
}
}
});
t1.start();
t2.start();
}
若是線程1先執行完,線程2再執行,結果沒問題。若是兩個線程同時執行,由于//1 //2之間沒有依賴關系,編譯器/處理器 可能會對//1 //2交換位置,這就是指令重排。如此之后,有可能執行順序是:2->3->4->1,還有可能是其它順序,最終的結果是不可控的。
線程交互的核心
從上述的軟件層面、硬件層面分析可知,線程1、線程2、線程3各自的本地內存對其它線程是不可見的;多個線程寫入主存時可能會存在臟數據;指令重排導致結果不可控。
多線程交互需要解決上述三個問題,這三個問題也是線程并發的核心:
1、可見性
2、原子性
3、有序性
上述三者既是并發核心,也是基礎,只有滿足了三者,線程并發的共享變量結果才是可控的。
我們熟知的鎖、Volatile等是針對三者中的某個或者全部提出的解決方案。
互斥與同步
互斥的由來
要滿足并發的三個條件,想想該怎么做呢?
先來看看原子性,既然多線程同時訪問共享變量容易出問題,那么想到的是大家排隊來訪問它,當其中一個線程(A)在訪問時,其它線程不能訪問,并排隊等待A線程執行完畢后,等待中的線程再次嘗試訪問共享變量,我們把操作共享變量的代碼所在的區域稱為臨界區,共享變量稱為臨界資源。
//臨界區
{
a = 5;
b = 6;
c = a;
}
如上面的代碼,多個線程不能同時訪問臨界區。
這種訪問方式稱為:互斥。
也就是說多個線程互斥地訪問臨界區可以實現操作的原子性。
同步的由來
臨界區內的操作的共享變量在不同的線程可能有不一樣的處理,如下代碼:
//偽代碼
int a = 0;
//線程1執行
private void add() {
while(true) {
if (a < 10)
a++;
}
}
//線程2執行
private void sub() {
while(true) {
if (a > 0)
a--;
}
}
線程1、線程2都對變量a進行了操作,兩者都依賴a的值做一些操作。
線程1判斷如果a<10,則a需要自增;線程2判斷如果a>0,則a需要自減。
線程1、線程2分別不斷地去檢查a的值看是否滿足條件再做進一步操作,這么做沒問題,但是效率太低。如果線程1、線程2檢查到不滿足條件先停下來等待,當滿足條件時由對方通知自己,這樣子就不用傻乎乎地每次跑去問a是多少了,極大提升了效率。
因此,交互變成這樣子:
//偽代碼
int a = 0;
//線程1執行
private void add() {
while(true) {
if (a < 10)
a++;
else
//等待,并通知線程2
}
}
//線程2執行
private void sub() {
while(true) {
if (a > 0)
a--;
else
//等待,并通知線程1
}
}
這么說流程有點枯燥,我們用個小比喻類比一下:
用小明表示線程1、小剛表示線程2,小明要發一批集裝箱,先把箱子拿到庫房外的空地上,空地面積有限,最多只能放10個箱子,等待小剛過來拿貨。
1、剛開始小剛發現空地沒貨,于是等待小明通知。小明發現沒貨,開始放貨。
2、小明發現空地上還可以放箱子,于是繼續放。
3、小明發現箱子已經放了10個,空地占滿了,于是就休息下來不再放了,并打電話告訴小剛,我的貨夠了,你快點過來拿貨吧。
4、小剛收到通知后,過來拿貨,一直拿,當發現貨拿完之后,就不再拿了,并打電話告訴小明,貨拿完了,你快放貨吧。
于是整個流程簡述:小明放了10個箱子就等待小剛拿,小剛拿完之后通知小明繼續放。值得注意的是:上述是批量放了箱子,再批量拿箱子,并沒有拿一個放一個。關于這個問題,后面細說
又因為小明、小剛都依賴于箱子的個數做事,通過上面對互斥的分析,我們知道需要將這部分操作包裹在臨界區里進行互斥訪問。
我們把上面的交互過程稱之為:同步
同步與互斥關系
可以看出,同步是在互斥的基礎上增加了等待-通知機制,實現了對互斥資源的有序訪問,因此同步本身已經實現了互斥。
同步是種復雜的互斥
互斥是種特殊的同步
解釋了互斥、同步概念,那么該這么實現呢?
接下來系列文章將重點分析系統提供的機制是如何實現可見性、原子性、有序性的以及互斥、同步與三者的關系。
下篇文章:聊聊Unsafe的作用及其用法。
您若喜歡,請點贊、關注,您的鼓勵是我前進的動力
持續更新中,和我一起步步為營系統、深入學習Android
總結
以上是生活随笔為你收集整理的java+向前进一_Java 线程基础的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Jsoup 爬虫之百度贴吧
- 下一篇: Java面试全集