## 目錄 * [1. JUC概述及回顧](#1-juc概述及回顧) * [1.1. JUC是什麼?](#11-juc是什麼) * [1.2. 進程和線程](#12-進程和線程) * [1.3. 並行和併發](#13-並行和併發) * [1.4. wait/sleep的區別](#14-waitsle ...
目錄
JavaEE_JUC
JavaEE_JUC
1. JUC概述及回顧
1.1. JUC是什麼?
在 Java 5.0 提供了 java.util.concurrent
(簡稱JUC)包,在此包中增加了在併發編程中很常用的工具類。此包包括了幾個小的、已標準化的可擴展框架,並提供一些功能實用的類,沒有這些類,一些功能會很難實現或實現起來冗長乏味。
參照JDK文檔:
1.2. 進程和線程
進程:進程是一個具有一定獨立功能的程式關於某個數據集合的一次運行活動。它是操作系統動態執行的基本單元,在傳統的操作系統中,進程既是基本的分配單元,也是基本的執行單元。
線程:通常在一個進程中可以包含若幹個線程,當然一個進程中至少有一個線程,不然沒有存在的意義。線程可以利用進程所擁有的資源,在引入線程的操作系統中,通常都是把進程作為分配資源的基本單位,而把線程作為獨立運行和獨立調度的基本單位,由於線程比進程更小,基本上不擁有系統資源,故對它的調度所付出的開銷就會小得多,能更高效的提高系統多個程式間併發執行的程度。
生活實例:
使用QQ,查看進程一定有一個QQ.exe的進程,我可以用qq和A文字聊天,和B視頻聊天,給C傳文件,給D發一段語言,QQ支持錄入信息的搜索。
大四的時候寫論文,用word寫論文,同時用QQ音樂放音樂,同時用QQ聊天,多個進程。
word如沒有保存,停電關機,再通電後打開word可以恢復之前未保存的文檔,word也會檢查你的拼寫,兩個線程:容災備份,語法檢查
1.3. 並行和併發
併發:同一時刻多個線程在訪問同一個資源,多個線程對一個點
例子:小米9今天上午10點,限量搶購
春運搶票
電商秒殺...
並行:多項工作一起執行,之後再彙總
例子:泡速食麵,電水壺燒水,一邊撕調料倒入桶中
1.4. wait/sleep的區別
功能都是當前線程暫停,有什麼區別?
wait:放開手去睡,放開手裡的鎖
sleep:握緊手去睡,醒了手裡還有鎖
wait是Object的方法,sleep是thread的方法
1.5. 創建線程回顧
創建線程常用三種方式:
- 繼承Thread:java是單繼承,資源寶貴,要用介面方式
- 實現Runable介面
- 實現Callable介面(後面講)
- 使用線程池(後面講)
繼承Thread抽象類:
public class MyThread extends Thread
new MyThread().start();
實現Runnable介面的方式:
- 新建類實現runnable介面。這種方法會新增類,有更好的方法
class MyRunnable implements Runnable//新建類實現runnable介面
new Thread(new MyRunnable(), name).start // 使用Rannable實現類創建進程,name是線程名
- 匿名內部類。
new Thread(new Runnable() {
@Override
public void run() {
// 調用資源方法,完成業務邏輯
}
}, "your thread name").start();
1.6. lambda表達式
之前說了Runnable介面的兩種實現方式,其實還有第三種:
-
創建類實現Runnable介面
-
編寫匿名內部類實現Runnable介面
-
lambda表達式:這種方法代碼更簡潔精煉
new Thread(() -> {
}, "your thread name").start();
1.6.1. 什麼是lambda
Lambda 是一個匿名函數,我們可以把 Lambda表達式理解為是一段可以傳遞的代碼(將代碼像數據一樣進行傳遞)。可以寫出更簡潔、更靈活的代碼。作為一種更緊湊的代碼風格,使Java的語言表達能力得到了提升。
Lambda 表達式在Java 語言中引入了一個新的語法元素和操作符。這個操作符為 “->” , 該操作符被稱為 Lambda 操作符或剪頭操作符。它將 Lambda 分為兩個部分:
-
左側:指定了 Lambda 表達式需要的所有參數
-
右側:指定了 Lambda 體,即 Lambda 表達式要執行的功能
1.6.2. 案例
在一個方法中調用介面中的方法:傳統寫法
interface Foo {
public int add(int x, int y);
}
public class LambdaDemo {
public static void main(String[] args) {
Foo foo = new Foo() {
@Override
public int add(int x, int y) {
return x + y;
}
};
System.out.println(foo.add(10, 20));
}
}
接下來,要用lambda表達式改造。其實是改造main方法
public static void main(String[] args) {
Foo foo = (int x, int y)->{
return x + y;
};
System.out.println(foo.add(10, 20));
}
改造口訣:
拷貝小括弧(),寫死右箭頭->,落地大括弧{...}
思考:如果Foo介面有多個方法,還能使用lambda表達式嗎?
1.6.3. 函數式介面
lambda表達式,必須是函數式介面,必須只有一個抽象方法,如果介面只有一個方法java預設它為函數式介面。
為了正確使用Lambda表達式,需要給介面加個註解:@FunctionalInterface。如有兩個方法,立刻報錯。
Runnable介面為什麼可以用lambda表達式?
@FunctionalInterface
public interface Runnable {
/**
* When an object implementing interface <code>Runnable</code> is used
* to create a thread, starting the thread causes the object's
* <code>run</code> method to be called in that separately executing
* thread.
* <p>
* The general contract of the method <code>run</code> is that it may
* take any action whatsoever.
*
* @see java.lang.Thread#run()
*/
public abstract void run();
}
發現Runnable介面上有一個註解:@FunctionalInterface
並且該介面只有一個方法:run()方法
其實,函數式介面必須只有一個方法,這個描述並不准確,它還允許有default方法和靜態方法。
例如,在Foo介面中,又添加了sub方法和mul方法:
interface Foo {
public int add(int x, int y); // 抽象方法
default int sub(int x, int y){ // default方法
return x - y;
}
public static int mul(int x, int y){ // 靜態方法
return x * y;
}
}
public class LambdaDemo {
public static void main(String[] args) {
Foo foo = (int x, int y)->{ // lambda表達式實現抽象方法
return x + y;
};
System.out.println(foo.add(10, 20)); // 調用抽象方法
System.out.println(foo.sub(30, 15)); // 調用default方法
System.out.println(Foo.mul(10, 50)); // 通過Foo調用靜態方法
}
}
1.6.4. 小結
lambda表達式實現介面的前提是
有且只有一個抽象方法,可以選擇@FunctionalInterface註解增強函數式介面定義
改造口訣
拷貝小括弧(形參列表),寫死右箭頭 ->,落地大括弧 {方法實現}
1.7. synchronized回顧
多線程編程:
口訣一:線程 操作 資源類
實現步驟:
-
創建資源類
-
資源類里創建同步方法、同步代碼塊
-
多線程調用
例子:賣票程式
創建工程,並添加了一個SaleTicket.java
內容如下:
class Ticket {
private Integer number = 20;
public synchronized void sale(){
if (number <= 0) {
System.out.println("票已售罄!!!");
return;
}
try {
System.out.println(Thread.currentThread().getName() + "開始買票,當前票數:" + number);
Thread.sleep(200);
System.out.println(Thread.currentThread().getName() + "買票結束,剩餘票數:" + --number);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
// 在main方法中創建多線程方法,測試賣票業務
public class SaleTicket {
public static void main(String[] args) {
Ticket ticket = new Ticket();
new Thread(() -> {
for (int i = 0; i < 30; i++) {
ticket.sale();
}
}, "AAA").start();
new Thread(() -> {
for (int i = 0; i < 30; i++) {
ticket.sale();
}
}, "BBB").start();
new Thread(() -> {
for (int i = 0; i < 30; i++) {
ticket.sale();
}
}, "CCC").start();
}
}
1.8. synchronized的8鎖問題
看下麵這段兒代碼,回答後面的8個問題:
class Phone {
public synchronized void sendSMS() throws Exception {
//TimeUnit.SECONDS.sleep(4);
System.out.println("------sendSMS");
}
public synchronized void sendEmail() throws Exception {
System.out.println("------sendEmail");
}
public void getHello() {
System.out.println("------getHello");
}
}
public class Lock_8 {
public static void main(String[] args) throws Exception {
Phone phone = new Phone();
Phone phone2 = new Phone();
new Thread(() -> {
try {
phone.sendSMS();
} catch (Exception e) {
e.printStackTrace();
}
}, "AA").start();
Thread.sleep(100);
new Thread(() -> {
try {
phone.sendEmail();
//phone.getHello();
//phone2.sendEmail();
} catch (Exception e) {
e.printStackTrace();
}
}, "BB").start();
}
}
多線程的8個問題:
-
標準訪問,先列印簡訊還是郵件
同一個鎖
-
停4秒在簡訊方法內,先列印簡訊還是郵件
同一個鎖
-
普通的hello方法,是先打簡訊還是hello
hello無鎖:無需爭搶,直接執行
-
現在有兩部手機,先列印簡訊還是郵件
非同一個鎖
-
兩個靜態同步方法,1部手機,先列印簡訊還是郵件
同一個鎖
-
兩個靜態同步方法,2部手機,先列印簡訊還是郵件
同一個鎖
-
1個靜態同步方法,1個普通同步方法,1部手機,先列印簡訊還是郵件
非同一個鎖
-
1個靜態同步方法,1個普通同步方法,2部手機,先列印簡訊還是郵件
非同一個鎖
總結:
synchronized實現同步的基礎:Java中的每一個對象都可以作為鎖。具體表現為以下3種形式:
-
對於普通同步方法,鎖是當前實例對象。
-
對於靜態同步方法,鎖是當前類的Class對象。
-
對於同步方法塊,鎖是Synchonized括弧里配置的對象
當一個線程試圖訪問同步代碼塊時,它首先必須得到鎖,退出或拋出異常時必須釋放鎖。
也就是說:
如果一個實例對象的非靜態同步方法獲取鎖後,該實例對象的其他非靜態同步方法必須等待獲取鎖的方法釋放鎖後才能獲取鎖;可是不同實例對象的非靜態同步方法因為用的是不同對象的鎖,所以毋須等待其他實例對象的非靜態同步方法釋放鎖,就可以獲取自己的鎖。
所有的靜態同步方法用的是同一把鎖——類對象本身。不管是不是同一個實例對象,只要是一個類的對象,一旦一個靜態同步方法獲取鎖之後,其他對象的靜態同步方法,都必須等待該方法釋放鎖之後,才能獲取鎖。
而靜態同步方法(Class對象鎖)與非靜態同步方法(實例對象鎖)之間是不會有競態條件的。
2. Lock鎖
首先看一下JUC的重磅武器——鎖(Lock)
相比同步鎖,JUC包中的Lock鎖的功能更加強大,它提供了各種各樣的鎖(公平鎖,非公平鎖,共用鎖,獨占鎖……),所以使用起來很靈活。
翻譯過來就是:
鎖實現提供了比使用同步方法和語句可以獲得的更廣泛的鎖操作。它們允許更靈活的結構,可能具有非常不同的屬性,並且可能支持多個關聯的條件對象。
Lock是一個介面,這裡主要有三個實現:ReentrantLock、ReentrantReadWriteLock.ReadLock、ReentrantReadWriteLock.WriteLock
2.1. ReentrantLock可重入鎖
ReentrantLock使用方式參照官方文檔:
使用ReentrantLock改造賣票程式:只需改造sale()方法
class Ticket{
private Integer number = 20;
private ReentrantLock lock = new ReentrantLock();
public void sale(){
lock.lock();
if (number <= 0) {
System.out.println("票已售罄!");
lock.unlock();
return;
}
try {
Thread.sleep(200);
number--;
System.out.println(Thread.currentThread().getName() + "買票成功,當前剩餘:" + number);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
2.1.1. 測試可重入性
可重入鎖又名遞歸鎖,是指在同一個線程在外層方法獲取鎖的時候,再進入該線程的內層方法會自動獲取鎖。Java中ReentrantLock和synchronized都是可重入鎖,可重入鎖的一個優點是可一定程度避免死鎖。
例如下列偽代碼:
class A{
public synchronized void aa(){
......
bb();
......
}
public synchronized void bb(){
......
}
}
A a = new A();
a.aa();
A類中有兩個普通同步方法,都需要對象a的鎖。如果是不可重入鎖的話,aa方法首先獲取到鎖,aa方法在執行的過程中需要調用bb方法,此時鎖被aa方法占有,bb方法無法獲取到鎖,這樣就會導致bb方法無法執行,aa方法也無法執行,出現了死鎖情況。可重入鎖可避免這種死鎖的發生。
class Ticket{
private Integer number = 20;
private ReentrantLock lock = new ReentrantLock();
public void sale(){
lock.lock();
if (number <= 0) {
System.out.println("票已售罄!");
lock.unlock();
return;
}
try {
Thread.sleep(200);
number--;
System.out.println(Thread.currentThread().getName() + "買票成功,當前剩餘:" + number);
// 調用check方法測試鎖的可重入性
this.check();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
/**
* 為了測試可重入鎖,添加檢查餘票方法
*/
public void check(){
lock.lock();
System.out.println("檢查餘票。。。。");
lock.unlock();
}
}
可以發現程式可以正常執行。。。說明該鎖確實可重入。
AAA買票成功,當前剩餘:19
檢查餘票。。。。
AAA買票成功,當前剩餘:18
檢查餘票。。。。
AAA買票成功,當前剩餘:17
檢查餘票。。。。
AAA買票成功,當前剩餘:16
檢查餘票。。。。
AAA買票成功,當前剩餘:15
檢查餘票。。。。
AAA買票成功,當前剩餘:14
檢查餘票。。。。
AAA買票成功,當前剩餘:13
檢查餘票。。。。
BBB買票成功,當前剩餘:12
檢查餘票。。。。
BBB買票成功,當前剩餘:11
檢查餘票。。。。
BBB買票成功,當前剩餘:10
。。。。。。
2.1.2. 測試公平鎖
ReentrantLock還可以實現公平鎖。所謂公平鎖,也就是在鎖上等待時間最長的線程優先獲得鎖的使用權。通俗的理解就是誰排隊時間最長誰先執行獲取鎖。
private ReentrantLock lock = new ReentrantLock(true);
測試結果:
AAA買票成功,當前剩餘:19
檢查餘票。。。。
BBB買票成功,當前剩餘:18
檢查餘票。。。。
CCC買票成功,當前剩餘:17
檢查餘票。。。。
AAA買票成功,當前剩餘:16
檢查餘票。。。。
BBB買票成功,當前剩餘:15
檢查餘票。。。。
CCC買票成功,當前剩餘:14
。。。。。。
可以看到ABC三個線程是按順序買票成功的。
2.1.3. 限時等待
這個是什麼意思呢?也就是通過我們的tryLock方法來實現,可以選擇傳入時間參數,表示等待指定的時間,無參則表示立即返回鎖申請的結果:true表示獲取鎖成功,false表示獲取鎖失敗。我們可以將這種方法用來解決死鎖問題。
2.1.4. ReentrantLock和synchronized區別
(1)synchronized是獨占鎖,加鎖和解鎖的過程自動進行,易於操作,但不夠靈活。ReentrantLock也是獨占鎖,加鎖和解鎖的過程需要手動進行,不易操作,但非常靈活。
(2)synchronized可重入,因為加鎖和解鎖自動進行,不必擔心最後是否釋放鎖;ReentrantLock也可重入,但加鎖和解鎖需要手動進行,且次數需一樣,否則其他線程無法獲得鎖。
(3)synchronized不可響應中斷,一個線程獲取不到鎖就一直等著;ReentrantLock可以響應中斷。
2.2. ReentrantReadWriteLock讀寫鎖
在併發場景中用於解決線程安全的問題,我們幾乎會高頻率的使用到獨占式鎖,通常使用java提供的關鍵字synchronized或者concurrents包中實現了Lock介面的ReentrantLock。它們都是獨占式獲取鎖,也就是在同一時刻只有一個線程能夠獲取鎖。而在一些業務場景中,大部分只是讀數據,寫數據很少,如果僅僅是讀數據的話並不會影響數據正確性(出現臟讀),而如果在這種業務場景下,依然使用獨占鎖的話,很顯然這將是出現性能瓶頸的地方。針對這種讀多寫少的情況,java還提供了另外一個實現Lock介面的ReentrantReadWriteLock(讀寫鎖)。讀寫鎖允許同一時刻被多個讀線程訪問,但是在寫線程訪問時,所有的讀線程和其他的寫線程都會被阻塞。
讀寫鎖的特點:
-
寫寫不可併發
-
讀寫不可併發
-
讀讀可以併發
2.2.1. 重寫讀寫問題
接下來以緩存為例用代碼演示讀寫鎖,重現問題:
class MyCache{
private volatile Map<String, String> cache= new HashMap<>();
public void put(String key, String value){
try {
System.out.println(Thread.currentThread().getName() + " 開始寫入!");
Thread.sleep(300);
cache.put(key, value);
System.out.println(Thread.currentThread().getName() + " 寫入成功!");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
}
}
public void get(String key){
try {
System.out.println(Thread.currentThread().getName() + " 開始讀出!");
Thread.sleep(300);
String value = cache.get(key);
System.out.println(Thread.currentThread().getName() + " 讀出成功!" + value);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
}
}
}
public class ReentrantReadWriteLockDemo {
public static void main(String[] args) {
MyCache cache = new MyCache();
for (int i = 1; i <= 5; i++) {
String num = String.valueOf(i);
// 開啟5個寫線程
new Thread(()->{
cache.put(num, num);
}, num).start();
}
for (int i = 1; i <= 5; i++) {
String num = String.valueOf(i);
// 開啟5個讀線程
new Thread(()->{
cache.get(num);
}, num).start();
}
}
}
列印結果:多執行幾次,有很大概率不會出現問題
2.2.2. 讀寫鎖的使用
改造MyCache,加入讀寫鎖:
class MyCache{
private volatile Map<String, String> cache= new HashMap<>();
// 加入讀寫鎖
ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
public void put(String key, String value){
// 加寫鎖
rwl.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " 開始寫入!");
Thread.sleep(500);
cache.put(key, value);
System.out.println(Thread.currentThread().getName() + " 寫入成功!");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 釋放寫鎖
rwl.writeLock().unlock();
}
}
public void get(String key){
// 加入讀鎖
rwl.readLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " 開始讀出!");
Thread.sleep(500);
String value = cache.get(key);
System.out.println(Thread.currentThread().getName() + " 讀出成功!" + value);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 釋放讀鎖
rwl.readLock().unlock();
}
}
}
2.2.3. 鎖降級
什麼是鎖降級,鎖降級就是從寫鎖降級成為讀鎖。在當前線程擁有寫鎖的情況下,再次獲取到讀鎖,隨後釋放寫鎖的過程就是鎖降級。這裡可以舉個例子:
public void test(){
rwlock.writeLock().lock();
System.out.println("獲取到寫鎖。。。。");
rwlock.readLock().lock();
System.out.println("獲取到讀鎖----------");
rwlock.writeLock().unlock();
System.out.println("釋放寫鎖==============");
rwlock.readLock().unlock();
System.out.println("釋放讀鎖++++++++++++++++");
}
列印效果:
2.2.4. 讀寫鎖總結
-
支持公平/非公平策略
-
支持可重入
-
同一讀線程在獲取了讀鎖後還可以獲取讀鎖
-
同一寫線程在獲取了寫鎖之後既可以再次獲取寫鎖又可以獲取讀鎖
-
-
支持鎖降級,不支持鎖升級
-
讀寫鎖如果使用不當,很容易產生“饑餓”問題:
在讀線程非常多,寫線程很少的情況下,很容易導致寫線程“饑餓”,雖然使用“公平”策略可以一定程度上緩解這個問題,但是“公平”策略是以犧牲系統吞吐量為代價的。 -
Condition條件支持
寫鎖可以通過newCondition()
方法獲取Condition對象。但是讀鎖是沒法獲取Condition對象,讀鎖調用newCondition()
方法會直接拋出UnsupportedOperationException
。
3. 線程間通信
面試題:兩個線程列印
兩個線程,一個線程列印1-52,另一個列印字母A-Z列印順序為12A34B...5152Z,要求用線程間通信
3.1. 回顧線程通信
先來簡單案例:
兩個線程操作一個初始值為0的變數,實現一個線程對變數增加1,一個線程對變數減少1,交替10輪。
線程間通信模型:
-
生產者+消費者
-
通知等待喚醒機制
多線程編程:口訣二:
-
判斷
-
幹活
-
去通知
代碼實現:
class ShareDataOne {
private Integer number = 0;
/**
* 增加1
*/
public synchronized void increment() throws InterruptedException {
// 1. 判斷
if (number != 0) {
this.wait();
}
// 2. 幹活
number++;
System.out.println(Thread.currentThread().getName() + ": " + number);
// 3. 通知
this.notifyAll();
}
/**
* 減少1
*/
public synchronized void decrement() throws InterruptedException {
// 1. 判斷
if (number != 1) {
this.wait();
}
// 2. 幹活
number--;
System.out.println(Thread.currentThread().getName() + ": " + number);
// 3. 通知
this.notifyAll();
}
}
/**
* 現在兩個線程,
* 可以操作初始值為零的一個變數,
* 實現一個線程對該變數加1,一個線程對該變數減1,
* 交替,來10輪。
*
* 筆記:Java裡面如何進行工程級別的多線程編寫
* 1 多線程變成模板(套路)-----上
* 1.1 線程 操作 資源類
* 1.2 高內聚 低耦合
* 2 多線程變成模板(套路)-----中
* 2.1 判斷
* 2.2 幹活
* 2.3 通知
*/
public class NotifyWaitDemo {
public static void main(String[] args) {
ShareDataOne shareDataOne = new ShareDataOne();
new Thread(()->{
for (int i = 0; i < 10; i++) {
try {
shareDataOne.increment();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "AAA").start();
new Thread(()->{
for (int i = 0; i < 10; i++) {
try {
shareDataOne.decrement();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "BBB").start();
}
}
部分列印結果:AAA和BBB交互執行,執行結果是1 0 1 0... 一共10輪
AAA: 1
BBB: 0
AAA: 1
BBB: 0
AAA: 1
BBB: 0
AAA: 1
BBB: 0
。。。。
如果換成4個線程會怎樣?
改造mian方法,加入CCC和DDD兩個線程:
public static void main(String[] args) {
ShareDataOne shareDataOne = new ShareDataOne();
new Thread(()->{
for (int i = 0; i < 10; i++) {
try {
shareDataOne.increment();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "AAA").start();
new Thread(()->{
for (int i = 0; i < 10; i++) {
try {
shareDataOne.decrement();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "BBB").start();
new Thread(()->{
for (int i = 0; i < 10; i++) {
try {
shareDataOne.increment();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "CCC").start();
new Thread(()->{
for (int i = 0; i < 10; i++) {
try {
shareDataOne.decrement();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "DDD").start();
}
列印結果,依然會有概率是,10101010...。
但是,多執行幾次,也會出現錯亂的現象:
AAA: 1
BBB: 0
CCC: 1
AAA: 2
CCC: 3
BBB: 2
CCC: 3
DDD: 2
AAA: 3
DDD: 2
CCC: 3
BBB: 2
3.2. 虛假喚醒
換成4個線程會導致錯誤,虛假喚醒
原因:在java多線程判斷時,不能用if,程式出事出在了判斷上面。
註意,消費者被喚醒後是從wait()方法(被阻塞的地方)後面執行,而不是重新從同步塊開頭。
如下圖: 出現-1的情況分析!
解決虛假喚醒:查看API,java.lang.Object的wait方法
中斷和虛假喚醒是可能產生的,所以要用loop迴圈,if只判斷一次,while是只要喚醒就要拉回來再判斷一次。
if換成while
class ShareDataOne {
private Integer number = 0;
/**
* 增加1
*/
public synchronized void increment() throws InterruptedException {
// 1. 判斷
while (number != 0) {
this.wait();
}
// 2. 幹活
number++;
System.out.println(Thread.currentThread().getName() + ": " + number);
// 3. 通知
this.notifyAll();
}
/**
* 減少1
*/
public synchronized void decrement() throws InterruptedException {
// 1. 判斷
while (number != 1) {
this.wait();
}
// 2. 幹活
number--;
System.out.println(Thread.currentThread().getName() + ": " + number);
// 3. 通知
this.notifyAll();
}
}
/**
* 現在兩個線程,
* 可以操作初始值為零的一個變數,
* 實現一個線程對該變數加1,一個線程對該變數減1,
* 交替,來10輪。
*
* 筆記:Java裡面如何進行工程級別的多線程編寫
* 1 多線程編程模板(套路)-----上
* 1.1 線程 操作 資源類
* 1.2 高內聚 低耦合
* 2 多線程編程模板(套路)-----中
* 2.1 判斷
* 2.2 幹活
* 2.3 去通知
* 3 多線程編程模板(套路)-----下
* 防止虛假喚醒(while)
*/
public class NotifyWaitDemo {
public static void main(String[] args) {
ShareDataOne shareDataOne = new ShareDataOne();
new Thread(()->{
for (int i = 0; i < 10; i++) {
try {
shareDataOne.increment();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "AAA").start();
new Thread(()->{
for (int i = 0; i < 10; i++) {
try {
shareDataOne.decrement();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "BBB").start();
new Thread(()->{
for (int i = 0; i < 10; i++) {
try {
shareDataOne.increment();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "CCC").start();
new Thread(()->{
for (int i = 0; i < 10; i++) {
try {
shareDataOne.decrement();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "DDD").start();
}
}
再次測試,完美解決
3.3. 線程通信(Condition)
對標synchronized:
Condition:查看API,java.util.concurrent.locks
並提供了實現案例:
使用Condition實現線程通信,改造之前的代碼(只需要改造ShareDataOne):刪掉increment和decrement方法的synchronized
class ShareDataOne {
private Integer number = 0;
final Lock lock = new ReentrantLock(); // 初始化lock鎖
final Condition condition = lock.newCondition(); // 初始化condition對象
/**
* 增加1
*/
public void increment() throws InterruptedException {
lock.lock(); // 加鎖
try {
// 1. 判斷
while (number != 0) {
// this.wait();
condition.await();
}
// 2. 幹活
number++;
System.out.println(Thread.currentThread().getName() + ": " + number);
// 3. 通知
// this.notifyAll();
condition.signalAll();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
/**
* 減少1
*/
public void decrement() throws InterruptedException {
lock.lock();
try {
// 1. 判斷
while (number != 1) {
// this.wait();
condition.await();
}
// 2. 幹活
number--;
System.out.println(Thread.currentThread().getName() + ": " + number);
// 3. 通知
//this.notifyAll();
condition.signalAll();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
3.4. 定製化調用通信
案例:
多線程之間按順序調用,實現A->B->C。三個線程啟動,要求如下:
AA列印5次,BB列印10次,CC列印15次
接著
AA列印5次,BB列印10次,CC列印15次
。。。列印10輪
分析實現方式:
-
有一個鎖Lock,3把鑰匙Condition
-
有順序通知(切換線程),需要有標識位
-
判斷標誌位
-
輸出線程名 + 內容
-
修改標識符,通知下一個
具體實現:
內容:
class ShareDataTwo {
private Integer flag = 1; // 線程標識位,通過它區分線程切換
private final Lock lock = new ReentrantLock();
private final Condition condition1 = lock.newCondition();
private final Condition condition2 = lock.newCondition();
private final Condition condition3 = lock.newCondition();
public void print5() {
lock.lock();
try {
while (flag != 1) {
condition1.await();
}
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + "\t" + (i + 1));
}
flag = 2;
condition2.signal();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public void print10() {
lock.lock();
try {
while (flag != 2) {
condition2.await();
}
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName() + "\t" + (i + 1));
}
flag = 3;
condition3.signal();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public void print15() {
lock.lock();
try {
while (flag != 3) {
condition3.await();
}
for (int i = 0; i < 15; i++) {
System.out.println(Thread.currentThread().getName() + "\t" + (i + 1));
}
flag = 1;
condition1.signal();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
/**
* 多線程之間按順序調用,實現A->B->C
* 三個線程啟動,要求如下:
* AA列印5次,BB列印10次,CC列印15次
* 接著
* AA列印5次,BB列印10次,CC列印15次
* ......來10輪
*/
public class ThreadOrderAccess {
public static void main(String[] args) {
ShareDataTwo sdt = new ShareDataTwo();
new Thread(()->{
for (int i = 0; i < 10; i++) {
sdt.print5();
}
}, "AAA").start();
new Thread(()->{
for (int i = 0; i < 10; i++) {
sdt.print10();
}
}, "BBB").start();
new Thread(()->{
for (int i = 0; i < 10; i++) {
sdt.print15();
}
}, "CCC").start();
}
}
4. 併發容器類
面試題:
請舉例說明集合類是不安全的。
4.1. 重現線程不安全:List
首先以List作為演示對象,創建多個線程對List介面的常用實現類ArrayList進行add操作。
內容:
public class NotSafeDemo {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
for (int i = 0; i < 20; i++) {
new Thread(()->{
list.add(UUID.randomUUID().toString().substring(0, 8));
System.out.println(list);
}, String.valueOf(i)).start();
}
}
}
測試結果:
出現了線程不安全錯誤
ArrayList在多個線程同時對其進行修改的時候,就會拋出java.util.ConcurrentModificationException異常(併發修改異常),因為ArrayList的add及其他方法都是線程不安全的,有源碼佐證:
解決方案:
List介面有很多實現類,除了常用的ArrayList之外,還有Vector和SynchronizedList。
他們都有synchronized關鍵字,說明都是線程安全的。
改用Vector或者synchronizedList試試:
public static void main(String[] args) {
//List<String> list = new Vector<>();
List<String> list = Collections.synchronizedList(new ArrayList<>());
for (int i = 0; i < 200; i++) {
new Thread(()->{
list.add(UUID.randomUUID().toString().substring(0, 8));
System.out.println(list);
}, String.valueOf(i)).start();
}
}
即可解決!
Vector和Synchronized的缺點:
vector:記憶體消耗比較大,適合一次增量比較大的情況
SynchronizedList:迭代器涉及的代碼沒有加上線程同步代碼
Vector:讀取加鎖!
public synchronized ListIterator<E> listIterator() {
return new ListItr(0);
}
synchronizedList: 讀取數據:讀取數據沒有加鎖!
public ListIterator<E> listIterator() {
return list.listIterator(); // Must be manually synched by user
}
4.2. CopyOnWrite容器
什麼是CopyOnWrite容器
CopyOnWrite容器(簡稱COW容器)即寫時複製的容器。通俗的理解是當我們往一個容器添加元素的時候,不直接往當前容器添加,而是先將當前容器進行Copy,複製出一個新的容器,然後新的容器里添加元素,添加完元素之後,再將原容器的引用指向新的容器。這樣做的好處是我們可以對CopyOnWrite容器進行併發的讀,而不需要加鎖,因為當前容器不會添加任何元素。所以CopyOnWrite容器也是一種讀寫分離的思想,讀和寫不同的容器。
從JDK1.5開始Java併發包里提供了兩個使用CopyOnWrite機制實現的併發容器,它們是CopyOnWriteArrayList和CopyOnWriteArraySet。
先看看CopyOnWriteArrayList類:發現它的本質就是數組
再來看看CopyOnWriteArrayList的add方法:發現該方法是線程安全的
使用CopyOnWriteArrayList改造main方法:
public static void main(String[] args) {
//List<String> list = new Vector<>();
//List<String> list = Collections.synchronizedList(new ArrayList<>());
List<String> list = new CopyOnWriteArrayList<>();
for (int i = 0; i < 200; i++) {
new Thread(()->{
list.add(UUID.randomUUID().toString().substring(0, 8));
System.out.println(list);
}, String.valueOf(i)).start();
}
}
CopyOnWrite併發容器用於讀多寫少的併發場景。比如:白名單,黑名單。假如我們有一個搜索網站,用戶在這個網站的搜索框中,輸入關鍵字搜索內容,但是某些關鍵字不允許被搜索。這些不能被搜索的關鍵字會被放在一個黑名單當中,黑名單一定周期才會更新一次。
缺點:
-
記憶體占用問題。 寫的時候會創建新對象添加到新容器里,而舊容器的對象還在使用,所以有兩份對象記憶體。通過壓縮容器中的元素的方法來減少大對象的記憶體消耗,比如,如果元素全是10進位的數字,可以考慮把它壓縮成36進位或64進位。或者不使用CopyOnWrite容器,而使用其他的併發容器,如ConcurrentHashMap。
-
數據一致性問題。 CopyOnWrite容器只能保證數據的最終一致性,不能保證數據的實時一致性。所以如果你希望寫入的的數據,馬上能讀到,請不要使用CopyOnWrite容器。
4.3. 擴展類比:Set和Map
HashSet和HashMap也都是線程不安全的,類似於ArrayList,也可以通過代碼證明。
private static void notSafeMap() {
Map<String, String> map = new HashMap<>();
for (int i = 0; i < 30; i++) {
new Thread(()->{
map.put(String.valueOf(Thread.currentThread().getName()), UUID.randomUUID().toString().substring(0, 8));
System.out.println(map);
}, String.valueOf(i)).start();
}
}
private static void notSafeSet() {
Set<String> set = new HashSet<>();
for (int i = 0; i < 30; i++) {
new Thread(()->{
set.add(UUID.randomUUID().toString().substring(0, 8));
System.out.println(set);
}, String.valueOf(i)).start();
}
}
都會報:ConcurrentModificationException異常信息。
Collections提供了方法synchronizedList保證list是同步線程安全的,Set和Map呢?
JUC提供的CopyOnWrite容器實現類有:CopyOnWriteArrayList和CopyOnWriteArraySet。
有沒有Map的實現:
最終實現:
public class NotSafeDemo {
public static void main(String[] args) {
notSafeList();
notSafeSet();
notSafeMap();
}
private static void notSafeMap() {
//Map<String, String> map = new HashMap<>();
//Map<String, String> map = Collections.synchronizedMap(new HashMap<>());
Map<String, String> map = new ConcurrentHashMap<>();
for (int i = 0; i < 30; i++) {
new Thread(()->{
map.put(String.valueOf(Thread.currentThread().getName()), UUID.randomUUID().toString().substring(0, 8));
System.out.println(map);
}, String.valueOf(i)).start();
}
}
private static void notSafeSet() {
//Set<String> set = new HashSet<>();
Set<String> set = new CopyOnWriteArraySet<>();
for (int i = 0; i < 30; i++) {
new Thread(()->{
set.add(UUID.randomUUID().toString().substring(0, 8));
System.out.println(set);
}, String.valueOf(i)).start();
}
}
private static void notSafeList() {
//List<String> list = new Vector<>();
//List<String> list = Collections.synchronizedList(new ArrayList<>());
List<String> list = new CopyOnWriteArrayList<>();
for (int i = 0; i < 20; i++) {
new Thread(()->{
list.add(UUID.randomUUID().toString().substring(0, 8));
System.out.println(list);
}, String.valueOf(i)).start();
}
}
}
擴展:HashSet底層數據結構是什麼?HashMap ?
但HashSet的add是放一個值,而HashMap是放K、V鍵值對
5. JUC強大的輔助類
JUC的多線程輔助類非常多,這裡我們介紹三個:
-
CountDownLatch(倒計數器)
-
CyclicBarrier(迴圈柵欄)
-
Semaphore(信號量)
5.1. CountDownLatch
CountDownLatch是一個非常實用的多線程式控制制工具類,應用非常廣泛。
例如:在手機上安裝一個應用程式,假如需要5個子進程檢查服務授權,那麼主進程會維護一個計數器,初始計數就是5。用戶每同意一個授權該計數器減1,當計數減為0時,主進程才啟動,否則就只有阻塞等待了。
CountDownLatch中count down是倒數的意思,latch則是門閂的含義。整體含義可以理解為倒數的門栓,似乎有一點“三二一,芝麻開門”的感覺。CountDownLatch的作用也是如此。
常用的就下麵幾個方法:
new CountDownLatch(int count) //實例化一個倒計數器,count指定初始計數
countDown() // 每調用一次,計數減一
await() //等待,當計數減到0時,阻塞線程(可以是一個,也可以是多個)並行執行
案例:6個同學陸續離開教室後值班同學才可以關門。
public class CountDownLatchDemo {
/**
* main方法也是一個進程,在這裡是主進程,即上鎖的同學
*
* @param args
*/
public static void main(String[] args) throws InterruptedException {
// 初始化計數器,初始計數為6
CountDownLatch countDownLatch = new CountDownLatch(6);
for (int i = 0; i < 6; i++) {
new Thread(()->{
try {
// 每個同學墨跡幾秒鐘
TimeUnit.SECONDS.sleep(new Random().nextInt(5));
System.out.println(Thread.currentThread().getName() + " 同學出門了");
// 調用countDown()計算減1
countDownLatch.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, String.valueOf(i)).start();
}
// 調用計算器的await方法,等待6位同學都出來
countDownLatch.await();
System.out.println("值班同學鎖門了");
}
}
列印結果:
同學3 出來了
同學1 出來了
同學0 出來了
同學2 出來了
同學5 出來了
同學4 出來了
值班同學鎖門了
面試:CountDownLatch 與 join 方法的區別
調用一個子線程的 join()方法後,該線程會一直被阻塞直到該線程運行完畢。而 CountDownLatch 則使用計數器允許子線程運行完畢或者運行中時候遞減計數,也就是 CountDownLatch 可以在子線程運行任何時候讓 await 方法返回而不一定必須等到線程結束;另外使用線程池來管理線程時候一般都是直接添加 Runnable 到線程池這時候就沒有辦法在調用線程的 join 方法了,countDownLatch 相比 Join 方法讓我們對線程同步有更靈活的控制。
練習:秦滅六國,一統華夏。(模仿課堂案例,練習枚舉類的使用)
5.2. CyclicBarrier
從字面上的意思可以知道,這個類的中文意思是“迴圈柵欄”。大概的意思就是一個可迴圈利用的屏障。該命令只在每個屏障點運行一次。若在所有參與線程之前更新共用狀態,此屏障操作很有用
常用方法:
-
CyclicBarrier(int parties, Runnable barrierAction) 創建一個CyclicBarrier實例,parties指定參與相互等待的線程數,barrierAction一個可選的Runnable命令,該命令只在每個屏障點運行一次,可以在執行後續業務之前共用狀態。該操作由最後一個進入屏障點的線程執行。
-
CyclicBarrier(int parties) 創建一個CyclicBarrier實例,parties指定參與相互等待的線程數。
-
await() 該方法被調用時表示當前線程已經到達屏障點,當前線程阻塞進入休眠狀態,直到所有線程都到達屏障點,當前線程才會被喚醒。
案例:組隊打boss過關卡游戲。
public class CyclicBarrierDemo {
public static void main(String[] args) {
CyclicBarrier cyclicBarrier = new CyclicBarrier(3, () -> {
System.out.println(Thread.currentThread().getName() + " 過關了");
});
for (int i = 0; i < 3; i++) {
new Thread(()->{
try {
System.out.println(Thread.currentThread().getName() + " 開始第一關");
TimeUnit.SECONDS.sleep(new Random().nextInt(4));
System.out.println(Thread.