[Java併發] 1. 線程同步(同步器)

[Java併發] 1. 線程同步(同步器)

一、synchronized關鍵字

對於synchronized的理解:
由於java的每個對象都有一個內置鎖,當用此關鍵字修飾方法時,內置鎖會保護整個方法。在調用該方法前,需要獲得內置鎖,否則就處於阻塞狀態。synchronized關鍵字也可以修飾靜態方法,此時如果調用該靜態方法,將會鎖住整個類。

1. 給對象加鎖
1.1 new一個對象作爲鎖

synchronized(Object)對括號內的對象加鎖,任何線程要執行synchronized代碼塊中的代碼,都必須要先拿到該對象的鎖,當代碼塊執行完畢時,鎖就會釋放,被其他線程獲取。

public class T {
    private int count = 10;
    private final Object lock = new Object();	// 鎖對象    
    public void m() {
        synchronized (lock) { // 任何線程要執行下面的代碼,都必須先拿到lock鎖,鎖信息記錄在堆內存對象中的,不是在棧引用中
            count--;
            System.out.println(Thread.currentThread().getName() + " count = " + count);
        }
        // 當上述synchronized代碼塊執行完畢後,鎖就會被釋放,然後被其他線程獲取
    }   
}

注意:synchronized(lock)是鎖住堆內存中的lock指向的Object對象,而引用變量lock是位於棧內存中的。

1.2 直接鎖定自身對象

在1.1中,每次使用鎖都新建一個毫無其他功能的鎖對象比較麻煩,因此我們可以直接對this對象加鎖,即synchronized(this)

public class T {
    private int count = 10;
        public void m() {
        synchronized (this) { // 任何線程要執行下面的代碼,必須先拿到this鎖
            // synchronized鎖定的不是代碼塊,而是this對象
            count--;
            System.out.println(Thread.currentThread().getName() + " count = " + count);
        }
    }
}
1.3 synchronized修飾方法

若整個方法內所有代碼都被synchronized修飾,則可以使synchronized關鍵字修飾整個方法。

public class T {
    private int count = 10;
    public synchronized void m() { // 等同於synchronized(this),鎖定當前堆內存對象 
        count--;
        System.out.println(Thread.currentThread().getName() + " count = " + count);
    }    
}	

1.4 synchronized鎖定靜態方法

類中靜態方法和靜態屬性屬性不需要new一個對象就可以訪問,沒有new出來,就沒有this引用的存在,所以當鎖定一個靜態方法時,相當於鎖定的是當前類的class對象。

public class T {
    private static int count = 10;
    public static synchronized void m() {//等同於synchronized(T.class) 
        count--;
        System.out.println(Thread.currentThread().getName() + " count = " + count);
    }    
    // 上邊m()方法與下邊mm()方法等價
    public static synchronized void mm() {
        synchronized (T.class) { 
            // 這裏不能使用synchronized(this),因爲靜態方法不需要實例對象即可訪問
        	count--;
            System.out.println(Thread.currentThread().getName() + " count = " + count);
        }
    }   
}
1.5. synchronized鎖住線程的run方法
public class T implements Runnable{
    private int count = 10;    
    @Override
    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).start();	//這裏new的所有線程的鎖住的是同一個上邊的t對象
        }
    }
}

run方法不加synchronized:因爲不保證原子性,每個線程在執行count--和輸出操作之間,可能有別的線程來執行count--,導致前後數據不一致。
加上synchronized關鍵字:相當於是一個原子操作,一個run方法執行完畢釋放了鎖,下一個線程才能拿到鎖執行run方法。

2. synchronized對方法加鎖, 同步方法和非同步方法是否可以同時調用
public class T {

	public synchronized void m1() {
		System.out.println(Thread.currentThread().getName() + "m1 start...");
		try {
			Thread.sleep(10000);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		System.out.println(Thread.currentThread().getName() + "m1 end...");
	}
	
	public void m2() {
		try {
			Thread.sleep(5000);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		System.out.println(Thread.currentThread().getName() + "m2 ...");
	}
	
	public static void main(String[] args) {
		T t = new T();
		
		new Thread(() -> t.m1(), "t1").start();//Java8中的lamba表達式
		new Thread(() -> t.m2(), "t2").start();
		
//		new Thread(t::m1, "t1").start(); //更簡潔的寫法
//		new Thread(t::m2, "t2").start();

		/**
		new Thread(new Runnable() {  //最原始的寫法
			@Override
			public void run() {
				t.m1();
			}
		}).start();

		new Thread(new Runnable() {
			@Override
			public void run() {
				t.m2();
			}
		}).start();
		 */
	}
}

同步方法與非同步方法是可以同時調用的。只有synchronized修飾的方法在運行過程中才需要申請鎖,普通方法是不需要申請的。在同步方法m1()執行的同時,非同步方法m2()也在執行

3. 髒讀問題

業務代碼中,對業務寫方法加鎖,而對業務讀方法不加鎖,容易產生髒讀問題(dirty read)。
髒讀,不可重複讀,幻讀

import java.util.concurrent.TimeUnit;
public class Account {
	String name;
	double balance;//賬戶餘額爲成員變量 默認爲0.0
	
	public synchronized void set(String name, double balance) {//寫操作
		this.name = name;
		//下面這段是爲了放大在this.name = name與this.balance = balance的執行間可能有別的業務代碼執行的情形,比如getbalance(),因爲這裏它是非鎖定方法仍然可以訪問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("張三", 100.0)).start();
		
		try {
			TimeUnit.SECONDS.sleep(1);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		
		System.out.println(a.getBalance("zhangsan"));//0.0
		
		try {
			TimeUnit.SECONDS.sleep(2);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		
		System.out.println(a.getBalance("zhangsan"));//100.0
	}
}

set方法在初始化時會休眠2s,我們調用set方法後1s讀取餘額,顯示爲0,再過2s後餘額才變爲100.0,因此允不允許髒讀?要根據實際業務場景斟酌使用

4. synchronized是可重入鎖
4.1 一個同步方法可以調用另一個同步方法

一個同步方法可以調用另外一個同步方法:若一個線程已搶到某對象的鎖,再申請時仍然會得到該對象的鎖。因爲這是在同一個線程以內,無非就是給鎖上的數字加一(同一線程,同一把鎖)

import java.util.concurrent.TimeUnit;
public class T implements Runnable {
	@Override
	public synchronized void run() {
		System.out.println("m1 start...");
		try {
			TimeUnit.SECONDS.sleep(1);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		m2();// 在同步方法m1()中調用同步方法m2(),不會發生死鎖,因爲這是在同一線程內的調用
		System.out.println("m1 end");
	}
	
	synchronized void m2() {
		System.out.println("m2 start");
		try {
			TimeUnit.SECONDS.sleep(2);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		System.out.println("m2 end");
	}

	public static void main(String[] args) {
		T t = new T();
		new Thread(t::run).start();
	}
}

程序輸出如下,沒有發生死鎖,且m1()方法會等待m2()方法結束後繼續運行,說明這是函數調用,而非線程並行。

m1 start 
m2 start
m2 end
m1 end
4.2 子類的同步方法可以調用父類的同步方法

子類的同步方法可以調用父類的同步方法也不會發生死鎖,兩個方法鎖住的this指向的都是同一個子類對象。

import java.util.concurrent.TimeUnit;
public class T {
	// 父類同步方法
    synchronized void m2() {
        System.out.println("father method start");
        System.out.println("father method lock:" + this);
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("father method end");
    }
}

class TT extends T {
    // 子類同步方法
    @Override
    synchronized void m1() {
        System.out.println("child method start");
        System.out.println("child method lock:" + this);
        super.m();
        System.out.println("child method end");
    }

    public static void main(String[] args) {
        TT tt = new TT();
        new Thread(tt::m1).start();
    }
}

程序輸出結果如下,沒有發生死鎖,且m1()方法會等待m2()方法結束後繼續運行,說明這是函數調用,而非線程並行; 另外也可以看到父子的同步方法持有的是同一把鎖。

child method start
child method lock:thread01.TT@2dd5c6ac
father method start
father method lock:thread01.TT@2dd5c6ac
father method end
child method end
5. 出現異常默認情況鎖會被釋放

synchronized修飾的代碼塊中出現異常,線程進行異常處理後會馬上釋放鎖(與ReentrantLock正相反).

import java.util.concurrent.TimeUnit;
public class T {
    int i = 0;
    // 同步方法,計數到5拋出異常
    synchronized void m() {
        System.out.println(Thread.currentThread().getName() + " start");
        while (true) {
            i++;
            System.out.println(Thread.currentThread().getName() + ": " + i);
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }			
            // 計數到5拋出異常
            if (i == 5) {
                int error = 1 / 0;
            }
        }
    }

    public static void main(String[] args) {
        T t = new T();
        new Thread(t::m, "線程1").start();
        new Thread(t::m, "線程2").start();
    }
}

程序執行結果:線程1拋出異常後馬上釋放鎖,鎖被線程2搶到並開始執行。

解決方法: 使用try-catch捕獲異常。

try{
    if (i == 5) {
        int error = 1 / 0;
    }
}catch(Exception e){
    System.out.println("除法溢出");
}

6. 引用變量指向對象的改變對鎖的影響

synchronized鎖住的是堆中o對象的實例,而不是o對象的引用,synchronized是針對堆中o對象的實例進行計數。

  1. 若在程序運行過程中,,引用o指向對象的屬性發生改變,鎖狀態不變。
  2. 若在程序運行過程中,引用o指向的對象發生改變,則鎖狀態改變,原本搶到的鎖作廢,線程會去搶新鎖。因此實際編程中常將鎖對象的引用用final修飾,保證其指向的鎖對象不發生改變。(final修飾引用時,該引用所指向的屬性可以改變,但該引用不能再指向其他對象)
public class T {
    Object o = new Object();

    // 該方法鎖住的o對象引用沒有被設爲final
    void m() {
        synchronized (o) {
            while (true) {
                System.out.println(Thread.currentThread().getName() + "正在運行");
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) {
        T t = new T();
        new Thread(t::m, "線程1").start();

        // 在這裏讓程序睡一會兒,保證兩個線程得到的o對象不同
        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        Thread thread2 = new Thread(t::m, "線程2");

        // 改變鎖引用,使得線程2也有機會運行,否則一直都是線程1運行
        t.o = new Object();
        thread2.start();
    }
}

程序輸出如下,看到主線程睡了3秒之後,線程1和線程2交替運行,他們各自搶到了不同的鎖

線程1正在運行
線程1正在運行
線程1正在運行
線程2正在運行
線程1正在運行
線程2正在運行
線程1正在運行
線程2正在運行
...

如果沒有改變鎖引用,將會一直是線程1在運行。

7. 不要將字符串常量作爲鎖定對象

因爲字符串常量池的存在,兩個不同的字符串引用可能指向同一字符串對象。

public class T {

    // 兩個字符串常量,作爲兩同步方法的鎖
    String s1 = "Hello";
    String s2 = "Hello";

    // 同步m1方法以s1爲鎖
    void m1() {
        synchronized (s1) {
            while (true) {
                System.out.println(Thread.currentThread().getName() + ":m1 is running");
            }
        }
    }

    // 同步m2方法以s2爲鎖
    void m2() {
        synchronized (s2) {
            while (true) {
                System.out.println(Thread.currentThread().getName() + ":m1 is running");
            }
        }
    }

    public static void main(String[] args) {
        T t = new T();
		
        // 輸出兩個鎖的哈希碼
        System.out.println(t.s1.hashCode());
        System.out.println(t.s2.hashCode());

        new Thread(t::m1, "線程1").start();
        new Thread(t::m2, "線程2").start();
    }
}

程序執行結果如下,實際上m1m2其實鎖定的是同一對象,即兩個字符串常量指向的是同一對象,有一個線程永遠得不到鎖。

69609650
69609650
線程1:m1 is running
線程1:m1 is running
線程1:m1 is running
線程1:m1 is running
線程1:m1 is running
線程1:m1 is running 

這種情況還會發生比較詭異的現象,比如你用到一個類庫,在該類庫中代碼鎖定了字符串 “Hello”,但是你看不到源碼,然後你在自己代碼中也鎖定了 “Hello”, 這時就會發生非常詭異的死鎖阻塞,因爲你的程序和使用到的類庫不經意間使用了同一把鎖。

8. 同步代碼中的語句越少越好
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
 * synchronized的優化
 * 同步代碼中的語句越少越好
 * 比較m1和m2
 */
public class T {
	
	int count = 0;
	
	synchronized void m1() {
		// do something need not sync
		try {
			TimeUnit.SECONDS.sleep(2);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		// 業務邏輯中只有下面這句需要sync,這時不應該給整個方法上鎖
		count ++;
		try {
			TimeUnit.SECONDS.sleep(2);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}
	
    void m2() {
		// do something need not sync
		try {
			TimeUnit.SECONDS.sleep(2);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		// 業務邏輯中只有下面這句需要sync,這時不應該給整個方法上鎖
		// 採用細粒度的鎖, 可以使用線程爭用時間變短,從而提高效率
		synchronized(this) {
			count ++;
		}
		// do something need not sync
		try {
			TimeUnit.SECONDS.sleep(2);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}
	
	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::m1, "thread-" + i));
		}
		
		threads.forEach((o) -> o.start());
		
		threads.forEach((o) -> {
			try {
				o.join();
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		});
		
		System.out.println(t.count);
	}
}

二、volatile關鍵字

volatile關鍵字, 是一個變量在多個線程間可見

1. volatile的可見性

volatile關鍵字向編譯器聲明該變量是易變的,每次對volatile關鍵字的修改會通知給所有相關線程.

  1. 在Java內存模型JMM中,所有對象以及信息都存放在主內存中(包含堆,棧),而每個線程在CPU中都有自己的獨立空間,存儲了需要用到的變量的副本。
  2. 線程對共享變量的操作,都會先在自己CPU中的工作內存中進行,然後再同步給主內存。若不加volatile關鍵字修飾,每個線程都有可能直接從自己CPU中的工作內存讀取內存,這樣如果另一個線程修改了原變量,該線程卻未必知道,從而引起同步問題;而加以volatile關鍵字修飾後,每個線程對該變量進行修改後都會馬上通知給所有線程。

下面的程序中,running變量存在於主內存的t對象中,當線程t1開通的時候, 會把running值從內存中讀到t1線程的工作區,在運行中直接使用這個copy,並不會每次都去讀取內存,這樣, 當主線程修改running的值後,t1線程感知不到, 所以不會停止運行。

使用volatile, 將會強制所有線程都去對內存中讀取running的值, 緩存過期通知

import java.util.concurrent.TimeUnit;
public class T {
	/**
	 * https://www.cnblogs.com/Mushrooms/p/5151593.html
	 *
	 * 補充內容:分享點兒知識,內容就是CPU內部的寄存器。就這個程序來說,有兩個線程。一個是主線程,
	 * 一個是自己啓動的線程。當自己啓動的線程運行時,running這個變量的值會被CPU把值從內存中讀到
	 * CPU中的寄存器(即CPU中的cache)中。爲什麼這麼做呢?因爲CPU的速度要比內存的速度快,內存的速
	 * 度比硬盤快。所以要把running中的數據copy一份到內存中處理。但是,沒有加volatile關鍵字的變
	 * 量running,當主線程已經把running改爲false,自己啓動的線程依然不能停下來。因爲它讀的是CPU
	 * 中running。主線程改的內存中的running。兩個線程讀寫的變量的存儲位置不同。
	 *
	 * 而volatile關鍵字就是爲了解決這個問題而出現的。其作用是,當主線程對內存中的變量running修改
	 * 後,就會通知CPU中的變量running,你那個值已經不是最新的了。這時候,自己啓動的線程會重新讀一
	 * 遍內存中的running變量。
	 */

    volatile boolean running = true; //對比一下有無volatile的情況下,整個程序運行結果的區別
    
    void m() {
        System.out.println("m start");
        while (running) { 
            //死循環。只有running=false時,才能執行後面的語句
        }
        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();
        }
        // 將running變量設爲false,觀察線程是否被終止
        t.running = false;
    }
}

運行結果表明,如果不對running變量加以volatile修飾,則對running``變量的修改不能終止子線程,說明在主線程中對running`的修改對子線程不可見.

但是如果在while死循環體中加入一些語句或sleep一段時間之後,可見性問題可能會消失,這是因爲加入語句後,CPU就可能會出現空閒,並同步主內存中的內容到工作內存,但這是不確定的,因此在這種情況下還是儘量要加上volatile

2. volatile不保證原子性

volatile只能保證可見性,但不能保證原子性。 即只會在讀變量的操作進行檢查,不會檢查寫回變量的時候之前讀入變量的值是否已經被修改。

volatile不能解決多個線程同時修改一個變量帶來的線程安全問題, 也就是說volatile不能代替synchronized

/*10個線程分別執行10000次count++,count是對象t的成員變量,按理來說最終count=100000,
  但是最終每次執行結果都不一樣,count一直小於100000,說明volatile不具備原子性*/
import java.util.ArrayList;
import java.util.List;
public class T {
	volatile int count = 0;
	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 {
				//join()方法阻塞調用此方法的線程,直到線程t完成,此線程再繼續。通常用於在main()主線程內,等待其它線程完成再結束main()主線程。
				o.join();//相當於在main線程中同步o線程,o執行完了,main線程纔有執行的機會
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		});		
		System.out.println(t.count);
	}
}
使用synchronized保證原子性和可見性

使用synchronized解決,輸出count爲10000。

int count = 0;
synchronized void m() {  //m方法加了synchronized修飾,保證了原子性和可見性
    for (int i=0; i<10000; i++) {
        count ++ ;
    }
}
更高效:使用AtomicXXX類

AtomXXX類本身方法都是原子性的, 但不能保證多方法連續調用的原子性。

import java.util.concurrent.atomic.AtomicInteger;

	AtomicInteger count = new AtomicInteger(0);
	
	/*synchronized*/ void m() { //不需要加鎖了
		for (int i = 0; i < 10000; i++) {
			// 如果加上了if (count.get() < 1000)
			// 則在for循環兩條語句中間,即這個位置是沒有原子性的
			count.incrementAndGet(); // 具備原子性,用來替換count++;
		}
	}
3. volatile與synchronized關鍵字區別
  • volatile 只能保證可見性,效率高
  • synchronized 既保證可見性,有保證原子性,效率低
4. 面試題:監控容器內元素個數

題目:寫兩個線程,線程1添加10個元素到容器中,線程2實時監控元素的個數,當容器中元素個數達到5時,線程2給出提示並立即結束

思路:容器選用ArrayList<Object>,調用其add()方法添加元素,調用size()方法得到容器中元素個數。

方法1 volatile

線程2一直輪詢,將容器設爲volatile保證線程間可見性,使線程2可以收到通知。

public class MyContainer {
	// 主要容器,設爲volatile保證線程間可見性
    private volatile List<Object> list = new ArrayList<>();	
    public void add(Object ele) {
        list.add(ele);
    }
    public int size() {
        return list.size();
    }

    public static void main(String[] args) {
        MyContainer container = new MyContainer();
        // 線程1,每隔一秒向容器中添加一個元素
        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                container.add(new Object());
                //這個部分可能被線程2搶佔
                System.out.println("add " + i);
                // 每隔一秒添加一個元素
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                
            }
        }, "線程1").start();
        // 線程2,輪詢容器內元素個數
        new Thread(() -> {
            while (true) {
                if (container.size() == 5) {
                	//這個部分可能被線程1搶佔
                    break;
                }
            }
            System.out.println("監測到容器長度爲5,線程2立即退出");
        }, "線程2").start();
    }
}

評價:

  • 不夠精確: 若當container.size == 5還未執行break時,被其他線程搶佔;或container.add()之後還未打印,就被線程2搶佔並判斷到container.size == 5並退出了。
  • 損耗性能: 線程2一直在走while(true)循環,浪費性能。我們避免用到死循環。
方法2 wait/notifyAll

使用wait/notify機制,當線程1寫入5個元素後通知線程2。

① 運用這種方法,必須保證t2先執行,先讓t2監聽纔可以
② wait會釋放鎖, 而notify與sleep不會釋放鎖
③ 因此notify之後,t1必須釋放鎖, t2退出後,也必須notify, 通知t1繼續執行

鎖的轉移過程:

  • 先啓動線程2並使主線程睡2秒以確保線程2先搶到鎖。
  • 線程2搶到鎖後調用wait(),讓其釋放鎖並阻塞,以確保線程1獲得鎖。
  • 線程1搶到鎖後開始向容器內添加元素。當線程1添加了5個元素後調用notify()通知線程2並調用wait()釋放鎖並阻塞,以確保線程2獲得鎖。
  • 線程2搶到鎖後輸出語句並退出,退出之前調用notify()喚醒線程1,因爲線程2退出後會釋放鎖,因此這時不用調用wait()釋放鎖。
public class MyContainer {
    // 主要容器,因爲只有線程1對其進行修改和查詢操作,所以不用加volatile關鍵字
    private List<Object> list = new ArrayList<>();
    public void add(Object ele) {
        list.add(ele);
    }
    public int size() {
        return list.size();
    }

    public static void main(String[] args) {
        MyContainer container = new MyContainer();
        
        final Object lock = new Object();    // 鎖對象
        
        // 線程2先啓動並進入wait狀態,等待被線程1喚醒
        new Thread(() -> {
            synchronized (lock) {
                System.out.println("線程2啓動");
                if (container.size() != 5) {
                    try {
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println("監測到容器長度爲5,線程2立即退出");
                // 線程1喚醒線程2後立刻睡眠了,因此線程2退出前要再次喚醒線程1
                lock.notify();
                System.out.println("線程2結束");
            }
        }, "線程2").start();

        // 主線程睡2秒鐘再創建線程1,確保線程2先得到鎖
		try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 線程1,每隔一秒向容器中添加一個元素
        new Thread(() -> {
            synchronized (lock) {
                for (int i = 0; i < 10; i++) {
                    container.add(new Object());
                    System.out.println("add " + i);
                    // 當容器中元素個數達到5時,喚醒線程2並退出線程1
                    if (container.size() == 5) {
                        lock.notify();
                        // notify()方法不會釋放鎖,因此即使通知了線程2,也不能讓線程2立刻執行
                        // 所以要先將線程1 wait()住,讓其釋放鎖給線程2,等待線程2退出前再通知喚醒線程1
                        try {
                            lock.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    // 每隔一秒添加一個元素
                    try {
                        TimeUnit.SECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }, "線程1").start();
    }
}

評價:當不涉及同步,只涉及線程通信的時候,用synchronized+wait/notify機制就顯得太重了,實際編程中常用封裝層次更深的類庫實現線程間通信.

方法3 CountDownLatch

使用門閂鎖CountDownLatch類鎖住線程2,並等待線程1撤去門閂釋放線程2。

public class MyContainer {

    // 主要容器,因爲門閂鎖只是一種同步方式,不保證可見性,因此需要用volatile修飾
    private volatile List<Object> list = new ArrayList<>();
    public void add(Object ele) {
        list.add(ele);
    }
    public int size() {
        return list.size();
    }

    public static void main(String[] args) {
        MyContainer container = new MyContainer();

        // 門閂鎖,構造函數中傳入門閂數,使用其countDown()方法撤掉一條門閂
        // 當門閂數爲0時,門會打開,兩個線程都會被執行
        CountDownLatch latch = new CountDownLatch(1);

        // 線程2先啓動並調用await()讓其被門閂鎖鎖住
        new Thread(() -> {
            System.out.println("線程2啓動");
            if (container.size() != 5) {
                try {
                    // 讓線程被門閂鎖鎖住,等待門閂的開放,而不是進入等待隊列
                    latch.await();
                    // 可以指定等待時間
					// latch.await(5000, TimeUnit.MILLISECONDS)
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("監測到容器長度爲5,線程2立即退出");
        }, "線程2").start();

        // 主線程睡2秒鐘再創建線程1,確保線程2先得到鎖
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 線程1,每隔一秒向容器中添加一個元素
        new Thread(() -> {
            System.out.println("線程1 啓動");
            for (int i = 0; i < 10; i++) {
                container.add(new Object());
                System.out.println("add " + i);
                // 當容器中元素個數達到5時,撤去一個門閂,打開門閂鎖,兩個線程都會被執行
                if (container.size() == 5) {
                    latch.countDown();
                }
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "線程1").start();
    }
}

評價:

  • 門閂鎖不涉及鎖定,當count的值爲5時線程1並不會停止運行。
  • 使用Latch(門閂)的awaitcountdown方法代替wait, notify來進行通知,通信方式簡單, 同時可以指定等待時間。
  • 當不涉及同步,只有涉及線程通信的時候,用synchronized + wait/notify就太重了,這時應該考慮使用CountDownLatch/cyclicbarrier/semaphore

門閂鎖CountDownLatch在框架中使用的非常廣泛,如在Spring框架中,要先實例化所有PropertiesService對象後才能實例化Bean對象。因此我們給初始化Bean對象的線程上一個兩道門閂的門閂鎖,初始化完畢所有Properties對象後撤去一道門閂,初始化完畢所有Service對象後再撤去一道門閂,兩道門閂撤去後,門閂鎖打開,創建Bean的線程開始執行。

三、ReentrantLock可重入鎖

1. ReentrantLock替代synchronized

ReentrantLock可以完全替代synchronized,提供了一種更靈活的鎖。
ReenTrantLock必須手動釋放鎖,爲防止發生異常,必須將同步代碼用try包裹起來,在finally代碼塊中釋放鎖。

public class T {
    ReentrantLock lock = new ReentrantLock();
    // 使用ReentrantLock的寫法
    private void m1() {
        // 嘗試獲得鎖
        lock.lock(); //等於synchronized(this)
        try {
            System.out.println(Thread.currentThread().getName());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock(); 
        }
    }

    // 使用synchronized的寫法
    private synchronized void m2() {
        System.out.println(Thread.currentThread().getName());
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        T t = new T();
        new Thread(t::m1, "t1").start(); 
        new Thread(t::m2, "t2").start(); 
    }
}
2. ReentrantLock獲取鎖的方法
2.1 嘗試鎖tryLock()
  • 使用tryLock()方法可以嘗試獲得鎖,返回一個boolean值,指示是否獲得鎖。

  • 可以給tryLock方法傳入阻塞時長,當超出阻塞時長時,線程退出阻塞狀態轉而執行其他操作。

public class T {
    ReentrantLock lock = new ReentrantLock();

    void m() {
        boolean isLocked = false;        // 記錄是否得到鎖

        // 改變下面兩個量的大小關係,觀察輸出
        int synTime = 4;   	 // 同步操作耗時
        int waitTime = 2;    // 獲取鎖的等待時間

        try {
            isLocked = lock.tryLock(waitTime, TimeUnit.SECONDS);    // 線程在這裏阻塞waitTime秒,嘗試獲取鎖
            if (isLocked) {
                // 若waitTime秒內得到鎖,則執行同步操作
                for (int i = 1; i <= synTime; i++) {
                    TimeUnit.SECONDS.sleep(1);
                    System.out.println(Thread.currentThread().getName() + "持有鎖,執行同步操作");
                }
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            // 使用tryLock()方法,嘗試解除標記時,一定要先判斷當前線程是否持有鎖
            if (isLocked) {
                lock.unlock();
            }
        }
        // 執行非同步操作
        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, "線程1").start();
        new Thread(t::m, "線程2").start();
    }
}
  1. 設置同步操作耗時4秒,獲取鎖的等待時間爲2秒,輸出結果顯示線程2在阻塞時間內沒能搶到鎖,直接執行非阻塞方法。

    線程1持有鎖,執行同步操作
    線程1持有鎖,執行同步操作
    線程2沒持有鎖,執行非同步操作
    線程1持有鎖,執行同步操作
    線程2沒持有鎖,執行非同步操作
    線程1持有鎖,執行同步操作
    線程2沒持有鎖,執行非同步操作
    線程1沒持有鎖,執行非同步操作
    線程2沒持有鎖,執行非同步操作
    線程1沒持有鎖,執行非同步操作
    ...
    
  2. 設置同步操作耗時4秒,獲取鎖的等待時間爲5秒,輸出結果顯示線程2在阻塞時間內成功搶到鎖,先執行完同步方法才執行非同步方法。

    線程1持有鎖,執行同步操作
    線程1持有鎖,執行同步操作
    線程1持有鎖,執行同步操作
    線程1持有鎖,執行同步操作
    線程2持有鎖,執行同步操作
    線程1沒持有鎖,執行非同步操作
    線程2持有鎖,執行同步操作
    線程1沒持有鎖,執行非同步操作
    線程2持有鎖,執行同步操作
    線程1沒持有鎖,執行非同步操作
    線程2持有鎖,執行同步操作
    線程1沒持有鎖,執行非同步操作
    線程2沒持有鎖,執行非同步操作
    ....
    
2.2 可中斷鎖lockInterruptibly()

使用lockInterruptibly()以一種可被中斷的方式獲取鎖。獲取不到鎖時線程進入阻塞狀態,但這種阻塞狀態可以被中斷。調用被阻塞線程的interrupt()方法可以中斷該線程的阻塞狀態,並拋出InterruptedException異常。

interrupt()方法只能中斷線程的阻塞狀態.若某線程已經得到鎖或根本沒去嘗試獲得鎖,則該線程當前沒有處於阻塞狀態,因此不能被interrupt()方法中斷.

public static void main(String[] args) {
    ReentrantLock lock = new ReentrantLock();

    // 線程1一直佔用着lock鎖
    new Thread(() -> {
        lock.lock();
        try {
            System.out.println("線程1啓動");
            TimeUnit.SECONDS.sleep(Integer.MAX_VALUE);// 線程一直佔用鎖
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }, "線程1").start();

    // 線程2搶不到lock鎖,若不被中斷則一直被阻塞
    Thread t2 = new Thread(() -> {
        try {
            lock.lockInterruptibly();  // 嘗試獲取鎖,若獲取不到鎖則一直阻塞
            System.out.println("線程2啓動");
        } catch (InterruptedException e) {
            System.out.println("線程2阻塞過程中被中斷");
        } finally {
            if (lock.isLocked()) {
                try {
                    lock.unlock(); // 沒有鎖定進行unlock就會拋出IllegalMonitorStateException異常
                } catch (Exception e) {
                }
            }
        }
    }, "線程2");
    t2.start();

    // 4秒後中斷線程2
    try {
        TimeUnit.SECONDS.sleep(4);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    t2.interrupt();//告訴t2別等了,拋出異常
}

輸出:

線程1啓動
線程2阻塞過程中被中斷

並不是所有處於阻塞狀態的線程都可以被interrupt()方法中斷,要看該線程處於具體的哪種阻塞狀態。阻塞狀態包括普通阻塞、等待隊列、鎖池隊列。

  • 普通阻塞: 調用sleep()方法的線程處於普通阻塞,調用其interrupt()方法可以中斷其阻塞狀態並拋出InterruptedException異常。
  • 等待隊列: 調用鎖的wait()方法將持有當前鎖的線程轉入等待隊列,這種阻塞狀態只能由鎖對象的notify()方法喚醒,而不能被線程的interrupt()方法中斷。
  • 鎖池隊列: 嘗試獲取鎖但沒能成功搶到鎖的線程會進入鎖池隊列:
    • 爭搶synchronized鎖的線程的阻塞狀態不能被中斷。
    • 使用ReentrantLock的lock()方法爭搶鎖的線程的阻塞狀態不能被中斷。
    • 使用ReentrantLocktryLock()lockInterruptibly()方法爭搶鎖的線程的阻塞狀態可以被中斷。

關於interrupted()方法的使用,可以查看這篇文章Java中interrupt的使用,總結來說,就是interrupt()方法不能打斷線程,但是會給該線程發送一個interrupt信號,讓該線程自己決定如何處理該信號,但有一種特殊情況:若該線程正處於阻塞狀態,調用其interrupt()方法會拋出InterruptedException.

2.3 公平鎖

公平鎖:誰等的時間長,誰獲得鎖

在初始化ReentrantLock時給其fair參數傳入true,可以指定該鎖爲公平鎖。默認的synchronized爲非公平鎖。

CPU默認的進程調度是不公平的,也就是說,CPU不能保證等待時間較長的線程先被執行。但公平鎖可以保證等待時間較長的線程先被執行。

public class T implements Runnable {
    private static ReentrantLock lock = new ReentrantLock(true);// 指定鎖爲公平鎖

    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            lock.lock();
            try {
                System.out.println(Thread.currentThread().getName() + "持有鎖");
            } finally {
                lock.unlock(); 
            }
        }
    }

    public static void main(String[] args) {
        T t = new T();
        new Thread(t, "線程1").start();
        new Thread(t, "線程2").start();
    }
}

程序輸出發現兩個線程嚴格交替執行。

四、ThreadLocal 線程局部變量

/*ThreadLocal是使用空間換時間,synchronized是使用時間換空間。
* 比如在Hibernate中的session就存在於ThreadLocal中,避免Synchronized的使用
* 線程局部變量屬於每個線程都有自己的,線程間不共享,互不影響*/
public class ThreadLocalTest {
    static ThreadLocal<Person> tL = new ThreadLocal<>(); //每個線程的tL互不影響
 
    public static void main(String[] args) {
        new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(tL.get());
        }).start();
 
        new Thread(()-> {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            tL.set(new Person());
        }).start();
    }
 
    static class Person {
        String name = "zhangsan";
    }
}

第二個線程設置了值,但是第一個線程get得到的是null,說明線程局部變量是互不影響的。

整理自視頻:馬士兵老師java多線程高併發編程

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