Java 高併發系列1-開篇

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瀏覽器,打開一個網頁, 這個網頁打開過程,有的加載文本,有的加載圖片。這些子任務就是線程,是操作系統調度的最小單
元,也叫作輕量級進程。在一個進程中可以創建多個線程,這些線程都擁有各自的計數器、堆棧和局部變
量等屬性,並且能夠訪問共享的內存變量,這也就是我們這個系列要研究的對象。

  • 第二 爲什麼要用多線程?
  1. 充分利用系統資源,提升程序執行效率。就說現在的計算機,動不動就是八核CPU、 16核、32核的 ,如果使用單線程,多浪費資源,這麼想可知,只要任務分配的合理,調度合理。 多個人幹活肯定比單線程效率高。
  2. 與進程相比,線程創建和切換開銷更小,同時多線程在數據共享方面效率非常高
  • 第三 線程的狀態

來一張價值連城的線程狀態圖
在這裏插入圖片描述
簡單說一下,Java線程在運行的聲明週期中可能會處於6種不同的狀態

  1. New 新創建狀態。線程被創建,還沒有調用 start 方法,在線程運行之前還有一些基礎工作要做。
  2. Runnable 可運行狀態。一旦調用start方法,線程就處於Runnable狀態。一個可運行的線程可能正在
    運行也可能沒有運行,這取決於操作系統給線程提供運行的時間。
  3. Blocked 阻塞狀態。表示線程被鎖阻塞,它暫時不活動。
  4. Waiting 等待狀態。線程暫時不活動,並且不運行任何代碼,這消耗最少的資源,直到線程調度器
    重新激活它。
  5. Timed waiting 超時等待狀態。和等待狀態不同的是,它是可以在指定的時間自行返回的
  6. 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) {
			
		}
	}

}

好了, 囉裏囉嗦,說了一大通,看的雲裏霧裏。 其實我覺得如果能把代碼拿出來 敲一下,跑一跑,應該就會明白使用多線程的妙處。 東西比較多,如果有什麼不對的,請批評指正。 這篇就先說到這裏,下篇我們再見。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章