【Java并發學習】之線程的同步
前言
在前面一個小節中,我們學習了線程的概念以及在Java中創建任務的方式,并且將任務委托給對應的線程進行執行,本小節我們主要來學習線程之間的關系之一的同步,包含臨界區、臨界資源、線程同步的兩種主要方法
線程的關系
從廣義上來講,線程之間有三種關系
- 沒有關系:多個線程之間相互獨立,既不競爭資源,也沒有任何的合作關系,只是各自完成自己的任務
- 競爭關系:兩個及以上的線程之間存在對某個或者某些資源的競爭
- 合作關系:兩個及以上的線程共同合作,完成某項任務
臨界區及臨界資源
學習線程之間的同步,必不可少會接觸到臨界區以及臨界資源這兩個概念,而線程之間存在競爭關系本質上就是由于臨界資源的存在,而解決的方式就是使得多個線程之間能夠序列化訪問臨界資源
- 臨界資源:臨界資源指的是程序中會被多個線程共享的某個或者某些資源,可以是軟件資源也可以是硬件資源,比如某個變量,某個數組,某個容器,打印機等等
- 臨界區:臨界區指的是訪問臨界資源的代碼,同步操作的主要對象
線程的同步
線程同步是一個非常重要的概念,也是在并發編程中比不可少的關鍵操作,需要進行同步的本質原因在于,資源的有限,由于資源的數量少于線程的數量,于是線程在訪問這些資源的時候需要進行同步處理,如果沒有進行同步處理,或者同步處理時不恰當,輕則會導致數據出錯,重則會出現嚴重的并發問題
首先我們來看下沒有進行同步處理所帶來的后果
情景:假設現在一個公園有三個門,我們需要統計某個時刻公園里的人的總數,由于三個門的統計方式一樣,所以我們可以直接采用相同的三個線程來進行統計即可
/**
* 公園類,包含一個計數器,進入以及離開記錄的操作
*/
class Park{
private static int counter = 0;
public void enter(){
counter++;
}
public void leave(){
counter--;
}
public int getCounter(){
return counter;
}
}
/**
* 公園的進出登記
*/
class DoorWatcher implements Runnable{
private Park park;
public DoorWatcher(Park park) {
this.park = park;
}
@Override
public void run() {
while (true){
park.enter(); // 進入公園
try {
Thread.sleep(1000);// 模式人留在公園中的操作
} catch (InterruptedException e) {
e.printStackTrace();
}
park.leave(); // 離開公園
}
}
}
從上面的操作可以看出,如果程序正常執行,那么每個時刻公園中的人數應該是總體上保持穩定的,畢竟每個人進入公園之后會離開公園
對應的測試類如下
public static void main(String[] args) throws InterruptedException {
Park park = new Park();
// 模擬公園的門的計數器
int doorNumber = 3;
Runnable jobs[] = new Runnable[doorNumber];
for (int i = 0; i < doorNumber; i++){
jobs[i] = new DoorWatcher(park);
}
// 執行對應的任務
ExecutorService executor = Executors.newCachedThreadPool();
for (int i = 0; i < doorNumber; i++) {
executor.submit(jobs[i]);
}
// 定時檢查公園中的人數
while (true){
System.out.println("current number in the park is " + park.getCounter());
Thread.sleep(3000); // 每隔三秒檢查一次
}
}
測試的可能結果
current number in the park is 0
current number in the park is 2
current number in the park is 2
current number in the park is 2
current number in the park is 3
current number in the park is 3
....
current number in the park is 2
current number in the park is 1
current number in the park is 1
....
執行測試代碼之后,可能你會發現實際上程序的運行并不是想象中那樣,而且不同次的運行可能結果還不一樣,出問題的地方在于counter++
以及counter--
這兩個操作,這兩個操作在Java中并不是原子操作,關于原子操作,我們會在后面進行深入的學習,這兩個操作都包含了取出數據,修改數據,寫入數據這三個步驟,而如果沒有進行同步處理,則在進行其中任何一個步驟的時候,當前線程可能被掛起,其他線程對counter進行修改,從而導致了數據的不一致,類似的情況還有很多,這里就不進行具體的分析。
由于出現問題的部分是對變量counter的操作,也就是說,這里的counter就是我們所說到的臨界資源,而對應的enter以及leave方法則是對應的臨界區,或者更詳細的說counter++
,counter--
就是我們所指的臨界區
解決線程同步問題的方法從廣義上來講只有一個,那就是序列化訪問臨界資源,也就是說,同一時刻只允許一個線程來對臨界資源進行操作,這種方式有效地解決了同步問題,而具體的操作就是對臨界區進行加鎖處理
加鎖的原理可以簡單的理解為,某個線程要進入臨界區之間,先申請對應的鎖,如果獲得該鎖,則可以進入,并且將該鎖上鎖,離開臨界區之后就將鎖解開;如果沒能申請到鎖,說明當前時刻臨界資源被其他線程占用,則自己進行阻塞,等待鎖可以使用
同步方法之使用synchronized
synchronized時Java提供的一個重量級鎖,或者稱之為監視器,也稱之為對象鎖,可以用于修飾方法或者代碼塊,默認鎖定的對象是this
,也就是當前對象,也可以顯示指定所要鎖定的對象
修飾方法
class Park{
private static int counter = 0;
public synchronized void enter(){
counter++;
// ...
}
public synchronized void leave(){
counter--;
// ...
}
// ...
}
修飾代碼塊
class Park{
private static int counter = 0;
public void enter(){
synchronized(this){
counter++;
}
// ...
}
public void leave(){
synchronized(this){
counter--;
}
// ...
}
// ...
}
synchronizd的使用比較簡單,只需要在需要進行同步的方法或者代碼塊加上該關鍵字即可,當然,synchronized還有一些比較復雜的原理,這個我們將在后面學習到
同步方法之使用locks
synchronized是在比較舊的JDK中所提供的用于同步的工具,在JDK5之后,還提供了另外的工具用于進行同步,即JUC中的Lock
import java.util.concurrent.locks.ReentrantLock;
class Park{
private static int counter = 0;
// 申請一個鎖
private static Lock lock = new ReentrantLock();
public void enter(){
lock.lock();// 加鎖
try {
counter++;
}finally {
lock.unlock();//解鎖
}
}
public void leave(){
lock.lock();// 加鎖
try {
counter--;
}finally {
lock.unlock();//解鎖
}
}
public int getCounter(){
return counter;
}
}
從上面的代碼中可以看到,使用Lock的操作比較繁瑣,我們需要自己申請鎖,并且在需要加鎖的時候手動加鎖,然后在離開的時候進行解鎖,可能你會注意到使用時的try...finally
代碼塊,強烈建立在使用Lock的時候采用這種方式,因為在進行資源操作的時候,可能會發生異常,采用這種方式可以保證無論在什么時候都能將鎖進行解鎖,還記得finally
的作用嗎?_
Lock的使用雖然比較繁瑣,而且還需要自己手動加鎖、解鎖,但是Lock也有synchronized所不具備的特點,那就是靈活,關于這兩者的具體區別,我們將在后面的內容中學習到
總結
本小節我們主要學習了線程同步的概念,臨界資源、臨界區的概念,沒有加鎖的可能帶來的危害,以及常見的同步方式,synchronized的使用以及Lock使用