目錄
一、單例模式
1.1 單例模式的概念
1.2 單例模式的實現(餓漢模式)
1.3 懶漢模式——單線程
1.4 懶漢模式——多線程
1.4.1 給內層上鎖
1.4.2 給外層上鎖
1.5 雙重檢查鎖DCL(Double Check Lock)
二、阻塞隊列
2.1 阻塞隊列的概念
2.2 生產者消費者模型
2.3 使用JDK的類創建阻塞隊列
2.4 阻塞隊列的應用場景
2.4.1 解耦
2.4.2 削峯填谷
2.4.3 異步操作
2.5 模擬實現
2.6 生產者消費者代碼實現
一、單例模式
1.1 單例模式的概念
單例模式是最常考的設計模式之一
設計模式是什麼?
設計模式是一套被反覆使用的、多數人知曉的、經過分類編目的、代碼設計經驗的總結。使用設計模式是為了重用代碼、讓代碼更容易被他人理解、保證代碼可靠性。
設計模式就好比下棋時的棋譜,針對對方的走法,我們有一些前人總結的套路,按照特定套路來走就不會吃虧
在軟件開發中同樣也是,我們會遇到很多常見的“問題場景”,針對這些問題場景,大佬們總結了一些固定套路,按照這個套路來實現代碼,通常也不會吃虧
我們這裏要學習的單例模式,顧名思義就是保證某個類在程序中只存在唯一一份實例對象,而不會創建出多個實例。
那我們該如何保證多個程序員合作寫代碼的時候不會創建新的實例對象呢?
❌:我們不能靠口頭約束,讓大家不要new對象,這樣是十分不靠譜的。
✅:所以我們選擇通過語言自身的語法約束,通過限制一個類只能被實例化一個對象,把限制過程交給程序,按照程序的邏輯執行,只要程序代碼可以保證是單例,那麼執行後一定是個單例。
1.2 單例模式的實現(餓漢模式)
想要實現單例類,只需要定義一個static修飾的變量,就可以保證這個變量全局唯一
public class SingletonHungry {
// 定義一個類的成員變量,用static修飾,保證全局唯一
private static SingletonHungry instance = new SingletonHungry();
}
private:防止外部對這個變量修改
static:修飾過後,保證全局唯一
instance實例對象此時已經全局唯一了
new SingletonHungry()類被加載到JVM中的時候,就會實例化這個變量
public class Demo_601 {
public static void main(String[] args) {
// 獲取實例
SingletonHungry instance1 = new SingletonHungry();
System.out.println(instance1.getInstance());
// 獲取實例
SingletonHungry instance2 = new SingletonHungry();
System.out.println(instance2.getInstance());
// 獲取實例
SingletonHungry instance3 = new SingletonHungry();
System.out.println(instance3.getInstance());
}
我們獲取三次對象,打印對象顯示,三個對象都是相同的,我們確實實現了單例模式
但是我們觀察,此時獲取對象還是依靠new SingletonHungry() 這樣的方法,雖然此時返回的是同一個對象,已經實現了單例,但是代碼書寫有歧義
所以我們可以創建一個方法來獲取該對象
public class SingletonHungry {
// 定義一個類的成員變量,用static修飾,保證全局唯一
private static SingletonHungry instance = new SingletonHungry();
/**
* 提供一個公開的方法返回instance對象
*/
public static SingletonHungry getInstance () {
// 返回全局唯一的對象
return instance;
}
}
代碼中我們創建了一個getInstance()方法,返回值為該對象
public class Demo_601 {
public static void main(String[] args) {
// 獲取第一個實例
SingletonHungry instance1 = SingletonHungry.getInstance();
System.out.println(instance1);
// 獲取第二個實例
SingletonHungry instance2 = SingletonHungry.getInstance();
System.out.println(instance2);
// 獲取第三個實例
SingletonHungry instance3 = SingletonHungry.getInstance();
System.out.println(instance3);
}
}
我們通過調用這個方法來獲取單例對象
打印對象,仍然是同一個對象,而且我們消除了new的歧義
我們現在希望用户只能通過getInstance方法獲得對象,而不能使用new構造方法,我們通過構造方法私有化來實現
public class SingletonHungry {
// 定義一個類的成員變量,用static修飾,保證全局唯一
private static SingletonHungry instance = new SingletonHungry();
// 構造方法私有化
private SingletonHungry() {}
/**
* 提供一個公開的方法返回instance對象
*/
public static SingletonHungry getInstance () {
// 返回全局唯一的對象
return instance;
}
}
我們通過private SingletonHungry() {}實現了構造方法私有化
此時再使用new構造方法則報錯
private static SingletonHungry instance = new SingletonHungry();
從代碼中我們能看見,這個類在加載的時候就已經完成了對象的初始化,這種創建方式被稱為“餓漢模式”,就像一個飢餓的人,非常迫切
但是我們在程序啓動時候需要加載很多類,有些單例類不需要在啓動的時候就進行使用。
那為了節省計算機資源,加速程序的啓動,我們就可以讓單例類在用到的時候再進行初始化,而不是在一開始就初始化,也就是new sigletonHungry()操作
1.3 懶漢模式——單線程
public class SingletonLazy {
// 定義一個全局的變量
private static SingletonLazy instance = null;
// 構造方法私有化
private SingletonLazy() {
}
//對外提供一個獲取對象的方法
public static SingletonLazy getInstance() {
// 判斷一個對象是不是已經創建過
if (instance == null) {
// 創建對象
instance = new SingletonLazy();
}
// 返回對象
return instance;
}
}
懶漢模式中,我們沒有直接初始化單例對象,當用户調用getInstance方法的時候,我們先判斷當前對象有沒有被初始化,如果沒有我們再進行new的初始化操作,如果已經被初始化,則直接返回。
public class Demo_602 {
public static void main(String[] args) {
// 第一次獲取
SingletonLazy instance1 = SingletonLazy.getInstance();
System.out.println(instance1);
// 第二次獲取
SingletonLazy instance2 = SingletonLazy.getInstance();
System.out.println(instance2);
// 第三次獲取
SingletonLazy instance3 = SingletonLazy.getInstance();
System.out.println(instance3);
}
}
我們來用getInstance方法來獲取單例對象
結果顯示是同一個對象
這是在單線程的情況下的結果,我們現在使用多線程環境,再來測試一下
1.4 懶漢模式——多線程
public class Demo_603 {
public static void main(String[] args) {
// 創建10個線程
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(() -> {
// 獲取單例對象
SingletonLazy instance = SingletonLazy.getInstance();
// 打印結象結果
System.out.println(instance);
});
// 啓動線程
thread.start();
}
}
}
我們創建10個線程,每個線程中都獲取單例對象,並且打印,觀察結果
結果顯示出現問題,10個打印結果出現了不一致的情況,此時單例模式就不滿足單例了
我們分析一下問題,很容易就能發現是new單例對象的時候出現了問題,其實就是因為多線程的時候我們沒有保證原子性,進行了多次初始化
那我們嘗試加synchronized解決問題
1.4.1 給內層上鎖
public static SingletonLazy getInstance() {
// 第一次判斷是否需要加鎖
// 判斷一個對象是不是已經創建過
if (instance == null) {
// 創建對象
synchronized (SingletonLazy.class) {
instance = new SingletonLazy();
}
}
// 返回對象
return instance;
}
我們在內層new對象操作代碼上鎖,重新運行
我們觀察到還是出現問題,這是因為線程的隨機調度,if語句沒有上鎖,會出現多個線程認為instance==null,此時有多少個線程判斷instance==null,就會重複new出來多少個不同的對象
1.4.2 給外層上鎖
public static SingletonLazy getInstance() {
// 第一次判斷是否需要加鎖
synchronized (SingletonLazy.class) {
// 判斷一個對象是不是已經創建過
if (instance == null) {
// 創建對象
instance = new SingletonLazy();
}
}
// 返回對象
return instance;
}
當我們給外層上鎖之後,就不會有多個線程同時Load到instance的值,也就避免了多個線程判斷instance==null這樣的情況,從而避免了初始化多個對象
重複運行結果顯示正確
1.5 雙重檢查鎖DCL(Double Check Lock)
按照我們之前的寫法,仍然會出現一個問題
- 當第一個線程進行這個方法,如果變量沒有初始化,則獲取鎖進行初始化操作,此時單例對象被第一個線程創建完成
- 後面的線程之後永遠不會執行new對象這個操作了
- 那對於後面的線程,synchronized還有必要存在嗎?
此時其實第一個線程把對象創建好後,synchronized就沒有必須要存在了,從第二個線程開始,加鎖解鎖操作都是無效操作了
public class SingletonDCL {
// 定義一個全局的變量
private static volatile SingletonDCL instance = null;
// 構造方法私有化
private SingletonDCL() {
}
// 對外提供一個獲取對象的方法
public static SingletonDCL getInstance() {
// 第一次判斷是否需要加鎖
if (instance == null) {
// 獲取鎖
synchronized (SingletonDCL.class) {
// 判斷一個對象是不是已經創建過
if (instance == null) {
// 創建對象
instance = new SingletonDCL();
}
}
}
// 返回對象
return instance;
}
}
那麼我們在加鎖之前先判斷一下,if(instance==null),節省資源,如果已經實例化後就直接返回,如果沒有實例化就獲取鎖,同時鎖內部代碼我們也會再次判斷if(instance==null),也避免了多次初始化的情況。
同時因為涉及到多個線程修改共享變量,我們也給共享變量加鎖volatile關鍵字,避免指令重排序的情況
指令重排序問題
已知new操作具體步驟為
- 在內存中申請一片空間
- 初始化對象的屬性(賦初值)
- 把對象在內存中的首地址賦值給對象的引用
那麼13指令是強相關的,2指令並不強相關
正常執行順序:1 2 3
指令重排序的順序:1 3 2
如果按照1 3 2這樣的順序,此時instance拿到的就是一個沒有初始化的對象,那麼在使用對象的時候就可能會出現問題,為了避免這個問題,我們加鎖volatile關鍵字。
二、阻塞隊列
2.1 阻塞隊列的概念
阻塞隊列是一種特殊的隊列,也遵守“先進先出”的原則
阻塞隊列同時是一種線程安全的數據結構,具有一下特性
當隊列滿的時候,繼續入隊列就會阻塞,直到有其他線程從隊列中取走元素
當隊列空的時候,繼續出隊列也會阻塞,直到有其他線程往隊列中插入元素
簡單來説就是有空位才入隊,有元素才出隊
2.2 生產者消費者模型
這是一個典型的阻塞隊列應用場景,一種典型的開發模型。
場景中,生產者負責生產資源,向隊列中放入元素;消費者負責消費資源,從隊列中取走元素
當隊列滿後,生產者則阻塞等待,直到有空位後繼續生產資源
當隊列空時,消費者則阻塞等待,直到有資源後繼續消費資源
2.3 使用JDK的類創建阻塞隊列
在Java標準庫中內置了阻塞隊列,如果我們需要在⼀些程序中使⽤阻塞隊列,直接使⽤標準庫中的即可
- BlockingQueue是⼀個接⼝.真正實現的類是LinkedBlockingQueue
- put⽅法⽤於阻塞式的⼊隊列,take⽤於阻塞式的出隊列
- BlockingQueue也有offer,poll,peek等⽅法,但是這些⽅法不帶有阻塞特性
public class Demo_701 {
public static void main(String[] args) throws InterruptedException {
// 創建一個阻塞隊列
BlockingQueue queue = new LinkedBlockingQueue<>(3);
// 向阻塞隊列中添加元素
queue.put(1);
queue.put(2);
queue.put(3);
System.out.println("已經添加了3個元素");
queue.put(4); // 開始阻塞
System.out.println("已經添加了4個元素"); // 這句打印不出來
}
}
我們的阻塞隊列大小為3,觀察到添加三個元素之後,不再往下進行執行,這是因為隊列已滿,執行到queue.put(4); 就阻塞了
public class Demo_701 {
public static void main(String[] args) throws InterruptedException {
// 創建一個阻塞隊列
BlockingQueue queue = new LinkedBlockingQueue<>(3);
// 向阻塞隊列中添加元素
queue.put(1);
queue.put(2);
queue.put(3);
System.out.println("已經添加了3個元素");
System.out.println(queue.take());
System.out.println(queue.take());
System.out.println(queue.take());
System.out.println("已經取出了3個元素");
System.out.println(queue.take());
System.out.println("已經取出了4個元素");
}
}
我們再演示取出元素,隊列中有1,2,3這三個元素,我們依次取出後,再進行第四次取出,此時不再往下執行,因為隊列已經為空,System.out.println(queue.take());阻塞等待
2.4 阻塞隊列的應用場景
2.4.1 解耦
在這個模型中,服務器A和服務器B要時刻互相感知,調用過程中雙方都要直到對方需要的參數和調用凡事,此時如果再加入一個服務器節點C,則要直接對接另外的服務器,ABC調用鏈路中,如果其中一個出了問題,則影響整個業務的執行
此時我們可以設置一個阻塞隊列為消息隊列作為中轉站
- 消息隊列可以接受服務器A的消息並保存,如果隊列有空位則入隊,隊滿則阻塞等待
- 服務器B可以從消息隊列中獲取消息並處理,如果隊不為空則獲取,隊空則阻塞等待
- 如果要添加服務器節點,通過消息隊列可以做到解耦,讓服務器C和消息隊列對接即可
2.4.2 削峯填谷
平時業務程序很難應對流量大幅增長的情況,當流量暴增是,程序會申請很多線程,各種資源。導致最後服務器資源被耗盡崩盤
這樣服務器A請求,服務器B處理請求的模型,如果流量暴增,請求暴增,服務器無法承受最終資源被耗盡
如果我們用消息隊列進行緩衝,將請求入隊,服務器B按照自己的節奏處理請求,就可以保證服務器資源正常分配
2.4.3 異步操作
採用消息隊列我們可以實現異步操作,讓效率更高
同步操作時,一方發出請求後,會死等,等到迴應後再進行下一步操作
異步操作時,一方發出請求後,就去完成其他業務,等另一方響應後再通知
2.5 模擬實現
public class MyBlockingQueue {
// 定義一個數組來存放數據,具體的容量由構造方法中的參數決定
private Integer[] elementData;
// 定義頭尾下標
private volatile int head = 0;
private volatile int tail = 0;
// 定義數組中元素的個數
private volatile int size = 0;
// 構造
public MyBlockingQueue(int capacity) {
if (capacity <= 0) {
throw new RuntimeException("隊列容量必須大於0.");
}
elementData = new Integer[capacity];
}
// 插入數據的方法
public void put(Integer value) throws InterruptedException {
synchronized (this) {
// 判斷隊列是否已滿
while (size >= elementData.length) {
// 阻塞隊列在隊列滿的時候就應該阻塞等待
// 等待
this.wait();
}
// 插入數據的過程
// 在隊列尾部插入元素
elementData[tail] = value;
// 移動隊尾下標
tail++;
// 處理隊尾下標
if (tail >= elementData.length) {
tail = 0; // 回到數據頭
}
// 修改size的值
size++;
// 喚醒阻塞線程
this.notifyAll();
}
}
// 獲取數據的方法
public synchronized Integer take() throws InterruptedException {
// 判斷隊列是否為空
while (size == 0) {
this.wait();
}
// 出隊的過程
// 獲取要出隊的元素
Integer value = elementData[head];
// 移動隊頭下標
head++;
// 處理隊頭下標
if (head >= elementData.length) {
head = 0; // 回來數據頭
}
// 處理數組中的元素個數
size--;
// 喚醒阻塞等待的線程
this.notifyAll();
// 返回元素
return value;
}
}
與我們之前實現的隊列不同的是,在插入數據和取出數據時,我們需要判斷當前隊列是否滿或空,進行阻塞等待wait(),並且當我們插入數據或取出數據後進行線程喚醒notifyAll(),並且因為方法修改了多個變量,我們要將方法上鎖,並且加上volatile關鍵字。
測試
public class Demo_702 {
public static void main(String[] args) throws InterruptedException {
// 創建阻塞隊列
MyBlockingQueue queue = new MyBlockingQueue(3);
// 入隊元素
queue.put(1);
queue.put(2);
queue.put(3);
System.out.println("已經入隊了3個元素...");
queue.put(4);
System.out.println("已經入隊了4個元素...");
}
}
public class Demo_702 {
public static void main(String[] args) throws InterruptedException {
// 創建阻塞隊列
MyBlockingQueue queue = new MyBlockingQueue(3);
// 入隊元素
queue.put(1);
queue.put(2);
queue.put(3);
System.out.println("已經入隊了3個元素...");
System.out.println(queue.take());
System.out.println(queue.take());
System.out.println(queue.take());
System.out.println("已經取出了3個元素...");
System.out.println(queue.take());
System.out.println("已經取出了4個元素...");
}
}
正常運行,結果正確
2.6 生產者消費者代碼實現
public class Demo_703 {
public static void main(String[] args) {
// 定義一個阻塞隊列, 交易場所
MyBlockingQueue queue = new MyBlockingQueue(100);
// 創建生產者線程
Thread producer = new Thread(() -> {
int num = 0;
// 使用循環不停的向隊列中添加元素,直到隊列容量佔滿
while (true) {
try {
// 添加元素
queue.put(num);
System.out.println("生產了元素:" + num);
num++;
// 休眠一會
TimeUnit.MILLISECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
// 啓動
producer.start();
// 定義一個消費者線程
Thread consumer = new Thread(() -> {
// 不斷的從隊列中取出元素
while (true) {
try {
// 取元素
Integer value = queue.take();
System.out.println("消費了元素:" + value);
// 休眠一會
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
// 啓動消費者線程
consumer.start();
}
}
代碼中設置阻塞隊列的大小為100,設置生產者和消費者兩個線程,可以觀察到,生產者生成導致隊列滿後就開始等待消費者消費,消費一個元素後又生成一個元素