Java 高併發系列1-開篇
我們都知道在Android開發中, 一個Android程序啓動之後會有一個主線程,也就是UI線程, 而網絡加載數據, 本地文件長時間讀寫,圖片壓縮,等等,很多耗時操作會阻塞UI線程,到時ANR的產生,在Android 3.0 之後便不能在UI線程使用。 由此可見多線程的使用在Android開發中佔地位是多麼重要。
這個系列 我打算通過一個個的例子來說明多線程的基本概念,多線程的使用, 鎖的使用, 併發容器, 線程池的使用,等等。
基本概念
- 1.線程概念
- 2.啓動一個線程
- 3.基本的線程同步
1. 線程概念
提到線程時,不得不提到進程。這裏有兩個問題,
- 第一 什麼是進程, 什麼是線程?
我們首先了解一下什麼是進程。進程是操作系統結構的基礎,是程序在一個數據集合上運行的過程,是系統進行資源分配和調度的基本單位。進程可以被看作程序的實體,同樣,它也是線程的容器。例如Mac 監控活動窗口中一個個的任務,這邊是操作系統的運行單元,進程。 在Android系統中同樣是這樣,通過Android Device Monitor 我們可以看到一個進程列表,裏面就是Android手機中運行的進程。進程就是程序的實體,是受操作系統管理的基本運行單元。這麼說吧, 我們打開的一個又一個App 便是一個又一個應用進程,當然如果某個App做了多進程,該應用便有了兩個進程。
先不說線程是什麼, 這麼說吧,我們使用的QQ瀏覽器,打開一個網頁, 這個網頁打開過程,有的加載文本,有的加載圖片。這些子任務就是線程,是操作系統調度的最小單
元,也叫作輕量級進程。在一個進程中可以創建多個線程,這些線程都擁有各自的計數器、堆棧和局部變
量等屬性,並且能夠訪問共享的內存變量,這也就是我們這個系列要研究的對象。
- 第二 爲什麼要用多線程?
- 充分利用系統資源,提升程序執行效率。就說現在的計算機,動不動就是八核CPU、 16核、32核的 ,如果使用單線程,多浪費資源,這麼想可知,只要任務分配的合理,調度合理。 多個人幹活肯定比單線程效率高。
- 與進程相比,線程創建和切換開銷更小,同時多線程在數據共享方面效率非常高
- 第三 線程的狀態
來一張價值連城的線程狀態圖
簡單說一下,Java線程在運行的聲明週期中可能會處於6種不同的狀態
- New 新創建狀態。線程被創建,還沒有調用 start 方法,在線程運行之前還有一些基礎工作要做。
- Runnable 可運行狀態。一旦調用start方法,線程就處於Runnable狀態。一個可運行的線程可能正在
運行也可能沒有運行,這取決於操作系統給線程提供運行的時間。 - Blocked 阻塞狀態。表示線程被鎖阻塞,它暫時不活動。
- Waiting 等待狀態。線程暫時不活動,並且不運行任何代碼,這消耗最少的資源,直到線程調度器
重新激活它。 - Timed waiting 超時等待狀態。和等待狀態不同的是,它是可以在指定的時間自行返回的
- Terminated 終止狀態。表示當前線程已經執行完畢。導致線程終止有兩種情況:第一種就是run方
法執行完畢正常退出;第二種就是因爲一個沒有捕獲的異常而終止了run方法,導致線程進入終止狀態。
2. 啓動一個線程
- 一種就是 實現Runnable接口
放入Thread 構造函數中, start 便可啓動。 執行的事務便在run方法中執行。 - 另一種便是實現Callable 接口
使用方法和Runnable實現的方式一樣,
兩者的區別就是,後者有返回值,前者沒有返回值。
3. 基本的線程同步
對某個對象加鎖。
public class T {
private int count = 10;
private Object o = new Object();
public void m() {
synchronized(o) { /// 線程需要執行下邊的代碼塊,就先需要獲取o的鎖
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
}
}
如果要執行下邊的代碼,需要先去申請o這個對象, 堆內存中的這個對象,並不是指o 這個引用, 當然不是指,當o這個引用指向其他對象的時候,鎖會變換。
當然如果還沒有釋放o這個鎖,其他線程是沒法獲取到鎖,沒有執行權限,所以這也是互斥鎖。
如果單單是爲了作爲一個鎖而聲明一個對象,就太浪費了。
第二種寫法
public class T {
private int count = 10;
public void m() {
synchronized(this) { // 任何線程執行下邊的代碼塊,需要先獲取this 對象
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
}
}
有人說 synchronized 是鎖定的代碼塊,其實鎖定的是對象。
第三種情況
public class T {
private int count = 10;
public synchronized void m() { //////// 這種加鎖寫法等同於第二種 synchronized(this)
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
}
當synchronized 關鍵字放在了static 靜態方法上時候,
public class T {
private static int count = 10;
public synchronized static void m() { // 這種加鎖方法等同於 synchronized(T.class)
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
public static void mm() {
synchronized(T.class) { // 當然這裏不能使用synchronized(T.this)這種寫法了, 原因很簡單,因爲這是靜態方法,靜態方法調用不需要對象的調用,更不需要使用T.this 這種寫法了。
count --;
}
}
}
再看一下這個程序的輸出
public class T implements Runnable {
private int count = 10;
public /*synchronized*/ void run() {
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
public static void main(String[] args) {
T t = new T();
for(int i=0; i<5; i++) {
new Thread(t, "THREAD" + i).start();
}
}
}
這個程序的執行 結果可能是 9,8,7,6,5 當然執行一兩次是沒有什麼問題的, 如果執行的次數很多,問題就會出現。 結果可能是 7,6,7,7,7
這種奇怪的問題稍微解釋一下,就是這種情況, 五個線程同時執行沒有加鎖的一個代碼塊,執行步驟就是先減,後打印, 當第一個減完,還沒來得及打印時候,第二個線程又減了一次,第二個線程還沒來得及打印的時候,第三個線程又減了一次, 這時候第一個線程拿到cpu執行時間片,打出的結果就是7, 後續結果就是這麼沒有規律的打印了出來。
很顯然並沒有達到我們的預期,這個問題的解決方案就是加鎖,synchronized關鍵字使得整個代碼執行塊具有了原子性。 其他線程只有等待減一併且打印完,釋放了鎖之後,後續線程纔可以繼續拿到鎖,執行後續操作。
原子性可以理解爲不可分割的代碼執行塊。
多線程與數據髒讀
模擬銀行代碼的邏輯,銀行賬戶。
public class Account {
String name;
double balance;
////// 設置銀行賬戶的姓名, 存款
public synchronized void set(String name, double balance) {
this.name = name;
/*
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
*/
this.balance = balance;
}
public /*synchronized*/ double getBalance(String name) {
return this.balance;
}
public static void main(String[] args) {
Account a = new Account();
new Thread(()->a.set("zhangsan", 100.0)).start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(a.getBalance("zhangsan"));
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(a.getBalance("zhangsan"));
}
}
如果在寫的過程中進行讀操作,這時候就會出現數據的髒讀。 當然這時候需要看自己的業務邏輯,
如果允許髒讀,對數據的實時性沒有要求則可以不做處理,僅對寫過程進行加鎖。 如果不允許髒讀,則對讀方法也進行加鎖。
public class T {
synchronized void m1() {
System.out.println("m1 start");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
m2();
}
synchronized void m2() {
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("m2");
}
}
一個同步方法可以調用另外一個同步方法,一個線程已經擁有某個對象的鎖,再次申請的時候仍然會得到該對象的鎖。 會在原來內存中堆內存的鎖上+1
也就是說synchronized獲得的鎖是可重入的。
public class T3 {
synchronized void m() {
System.out.println("m start");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("m end");
}
public static void main(String[] args) {
new TT().m();
}
}
class TT extends T3 {
@Override
synchronized void m() {
System.out.println("child m start");
super.m();
System.out.println("child m end");
}
}
重入鎖的第二種情形
這個例子和上個例子是一樣的
一個同步方法可以調用另外一個同步方法,一個線程已經擁有某個對象的鎖,再次申請的時候仍然會得到該對象的鎖.
也就是說synchronized獲得的鎖是可重入的
這裏是繼承中有可能發生的情形,子類調用父類的同步方法
如果線程執行在有鎖的代碼塊中拋出異常該如何?
看一條程序
public class T {
int count = 0;
synchronized void m() {
System.out.println(Thread.currentThread().getName() + " start");
while(true) {
count ++;
System.out.println(Thread.currentThread().getName() + " count = " + count);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
if(count == 5) {
int i = 1/0; //此處拋出異常,鎖將被釋放,要想不被釋放,可以在這裏進行catch,然後讓循環繼續
}
}
}
public static void main(String[] args) {
T t = new T();
Runnable r = new Runnable() {
@Override
public void run() {
t.m();
}
};
new Thread(r, "t1").start();
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(r, "t2").start();
}
}
在第五秒的時候 t1出現數學算術異常,拋出導致所持有的鎖被釋放, 同時線程t2獲取鎖繼續執行。
注意: m方法內 如果在數據處理邏輯中執行了一半,拋出異常,鎖被釋放,而又沒有對異常之後的數據進行回滾。 同時其他線程拎起這個原來處理過了一半的數據進行操作的話。 結果必定是不準確的,導致的後果也是災難性的。
小節結論:
線程執行中拋出異常鎖會被釋放。 需要添加相關處理邏輯, try-cache
volatile 簡單解釋意思就是 瞬時的,透明的,臨時的, 多個線程可見的。
public class T {
/*volatile*/ boolean running = true; //對比一下有無volatile的情況下,整個程序運行結果的區別
void m() {
System.out.println("m start");
while(running) {
/*
try {
TimeUnit.MILLISECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}*/
}
System.out.println("m end!");
}
public static void main(String[] args) {
T t = new T();
new Thread(t::m, "t1").start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
t.running = false;
}
}
看一下這條程序的運行結果, 一共分兩個線程, 一個是t1, 一個是主線程, t1 線程執行while 循環,主線程 修改running 變量。 嘗試讓t1跳出while 死循環。 結果卻並沒有讓t1跳出死循環。
如果要解釋這個現象, 我們需要簡單的瞭解一下java 的內存模型, 簡稱JMM (java memory model)。
CPU執行區
線程T1 running 線程 T2 running … Tn running
主內存區
running = true ( volatile modify --> notify all thread update )
新的線程執行時,將running 從主內存中拷貝一份到CPU執行區的一個線程緩存區內, 由於CPU一直在執行, 並沒有閒暇時間與主內存中的running 進行同步。 所以線程T1便一直處於死循環中。
另一種情況, 當線程while 的死循環中的睡眠代碼塊 解開之後, CPU便有了與主內存中running 進行了同步, 此時當線程醒來之後 便可以結束了。 ( 具體這是屬於什麼機制 我還不太懂, 需要進一步學習 |汗)
還有一種情況便是,將running 前加上volatile 關鍵字,讓running 的每一次修改便通知執行線程, 從主內存中讀取新的內容,更新緩衝區。
那麼volatile 和synchronized 的區別是什麼呢?
public class T {
volatile int count = 0;
/* synchronized */ void m() {
for(int i=0; i<10000; i++) count++;
}
public static void main(String[] args) {
T t = new T();
List<Thread> threads = new ArrayList<Thread>();
for(int i=0; i<10; i++) {
threads.add(new Thread(t::m, "thread-"+i));
}
threads.forEach((o)->o.start());
threads.forEach((o)->{
try {
o.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println(t.count);
}
}
看這一條程序, 雖然count 變量前加上了volatile 關鍵字,表示該字段的可見性。 但是結果可能是56739, 或者其他 ,但是肯定不會是十萬。
由於使用volatile,將會強制所有線程都去堆內存中讀取running的值
分析過程:
十條線程同時啓動, 同時對主內存中的count 進行了修改操作, 同時從棧中拷貝了一份到自己線程的CPU緩存區內,進行+1 ,完了以後寫回到主內存中 101 , 第二個線程也會把加完的結果101 覆蓋。 第三條線程可能拿到的是101 ,加完的結果是102 ,第四條可能還是覆蓋102, 至此問題便形成。
當然如果把synchronized 註釋放開, 結果便是正確的。
當然如果使用系統提供的AtomicXXX 系列類提供的操作方法 也是可以的,當然這也是最優解。
public class T {
/*volatile*/ //int count = 0;
AtomicInteger count = new AtomicInteger(0);
/*synchronized*/ void m() {
for (int i = 0; i < 10000; i++)
//if count.get() < 1000 當然如果這裏添加了if判斷之後, 這裏就不具備了原子性, 很簡單,因爲判斷過程中會有多個線程同時讀取到一樣的數值,從而造成問題。
////// AtomicXXX 這個東西的出現就是爲了 代替 count++ 操作。 因爲這個操作是原子性的,不可再分的。效率比synchronized高。
/////具體實現方法應該是使用了最底層的方式。 不太懂希望有懂出來說說。
count.incrementAndGet(); //count++
}
public static void main(String[] args) {
T t = new T();
List<Thread> threads = new ArrayList<Thread>();
for (int i = 0; i < 10; i++) {
threads.add(new Thread(t::m, "thread-" + i));
}
threads.forEach((o) -> o.start());
threads.forEach((o) -> {
try {
o.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println(t.count);
}
}
小節結論:
volatile 只保證了可見性,不保證原子性 效率高 。
synchronized 既保證了可見性,又保證了原子性。 效率低
如果程序可以 請使用 AtomicXXX類進行原子操作代替synchronized。
可以閱讀這篇文章進行更深入的理解volatile
http://www.cnblogs.com/nexiyi/p/java_memory_model_and_thread.html
再看一條程序
public class T {
int count = 0;
synchronized void m1() {
//do sth need not sync
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
//業務邏輯中只有下面這句需要sync,這時不應該給整個方法上鎖
count ++;
//do sth need not sync
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
void m2() {
//do sth need not sync
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
//業務邏輯中只有下面這句需要sync,這時不應該給整個方法上鎖
//採用細粒度的鎖,可以使線程爭用時間變短,從而提高效率
synchronized(this) {
count ++;
}
//do sth need not sync
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
小節結論:
給只需要上鎖的部分進行上鎖,以減少線程爭用時間,從而提高效率。
再看一條程序
public class T {
Object o = new Object();
void m() {
synchronized(o) {
while(true) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName());
}
}
}
public static void main(String[] args) {
T t = new T();
//啓動第一個線程
new Thread(t::m, "t1").start();
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
//創建第二個線程
Thread t2 = new Thread(t::m, "t2");
t.o = new Object(); //鎖對象發生改變,所以t2線程得以執行,如果註釋掉這句話,線程2將永遠得不到執行機會
t2.start();
}
}
鎖定某對象o,如果o的屬性發生改變,不影響鎖的使用 但是如果o變成另外一個對象,則鎖定的對象發生改變
小節小結:
鎖定某對象o,對象o是在堆上面的, 並不是棧中對象o的引用。
應該避免將鎖定對象的引用變成另外的對象
還應該避免使用字符串常量來作爲鎖對象,如下 s1 s2 都是字符串變量, m1 m2 鎖定的卻是同一個對象
public class T {
String s1 = "Hello";
String s2 = "Hello";
void m1() {
synchronized(s1) {
}
}
void m2() {
synchronized(s2) {
}
}
}
好了, 囉裏囉嗦,說了一大通,看的雲裏霧裏。 其實我覺得如果能把代碼拿出來 敲一下,跑一跑,應該就會明白使用多線程的妙處。 東西比較多,如果有什麼不對的,請批評指正。 這篇就先說到這裏,下篇我們再見。