單例模式
目錄
探究與結論
基本解釋
單例模式(Singleton Pattern)是 Java 中最簡單的設計模式之一。這種類型的設計模式屬於創建型模式,它提供了一種創建對象的最佳方式。
這種模式涉及到一個單一的類,該類負責創建自己的對象,同時確保只有單個對象被創建。這個類提供了一種訪問其唯一的對象的方式,可以直接訪問,不需要實例化該類的對象。
必要條件
- 1、單例類只能有一個實例。
- 2、單例類必須自己創建自己的唯一實例。
- 3、單例類必須給所有其他對象提供這一實例。
基本目標
設計模式比較常見的就是讓你手寫一個單例模式或者設計模式項目中的使用。
單例模式的優點
對於頻繁使用的對象,可以省略創建對象所花費的時間,這對於那些重量級對象而言,是非常可觀的一筆系統開銷; 由於 new 操作的次數減少,因而對系統內存的使用頻率也會降低,這將減輕 GC 壓力,縮短 GC 停頓時間;避免對資源的多重佔用 。
單例模式的缺點
沒有接口,不能繼承,與單一職責原則衝突,一個類應該只關心內部邏輯,而不關心外面怎麼樣來實例化。
單線程變爲多線程時
單例模式在單線程下一般分爲懶漢模式,和餓漢模式,總體來說,懶漢模式的優點可以突出的顯現;但是當變成多線程時,餓漢模式可以很好的避免安全隱患,而懶漢模式則不可以。
//餓漢式
public class Singleton {
private static Singleton instance = new Singleton();
private Singleton (){}
public static Singleton getInstance() {
return instance;
}
}
//懶漢模式
public class Singleton {
private static Singleton instance;
private Singleton (){}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
舉例說明
這裏通過hashcode驗證,忘記hashcode的同學可以看一下關於hash的相關知識
https://blog.csdn.net/weixin_43914278/article/details/104398493
class MySingleton {
private static MySingleton instance = null;
private MySingleton(){}
public static MySingleton getInstance() {
try {
if(instance != null){//懶漢式
}else{
//創建實例之前可能會有一些準備性的耗時工作
Thread.sleep(100);
instance = new MySingleton();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
return instance;
}
}
public class MyThread extends Thread{
@Override
public void run() {
System.out.println(MySingleton.getInstance().hashCode());
}
public static void main(String[] args) {
MyThread[] thread = new MyThread[100];
for(int i = 0 ; i < 100 ; i++){
thread[i] = new MyThread();
}
for (int j = 0; j < 100; j++) {
thread[j].start();
}
}
}
那麼我們就會引入改進方法——雙重校驗鎖實現單例模式。
爲什麼是雙重校驗鎖實現單例模式呢?
是一個重點知識敲黑板,第一層爲了提高效率,思想:優化思想,提升執行效率(速度和開銷),第二次實際上纔是真正的實現單例模式,實際上變成普通的一個懶漢模式+synchronzied關鍵字,多了一個同步鎖。
第一次校驗:也就是第一個if(uniqueInstance==null),這個是爲了代碼提高代碼執行效率,由於單例模式只要一次創建實例即可,所以當創建了一個實例之後,再次調用getUniqueInstance方法就不必要進入同步代碼塊,不用競爭鎖。直接返回前面創建的實例即可。說白了假設第一次不檢驗,看似問題也不大,但是其實這裏所用到的思想就如我們在學習hashmap時爲什麼需要先比較hashcode再比較equals方法,就一句話誰快選誰,這裏看似多判斷了一次,然而synchronzied同步鎖會大大削減效率,開銷很大,所以我們就任性地先比較一次,這樣如果運氣好的話可以通過if語句,跳過synchronized這個步驟。
第二次校驗:也就是第二個if(uniqueInstance==null),這個校驗是防止二次創建實例,假如有一種情況,當uniqueInstance還未被創建時,線程t1調用getUniqueInstance方法,由於第一次判斷if(uniqueInstance==null),此時線程t1準備繼續執行,但是由於資源被線程t2搶佔了,此時t2頁調用getUniqueInstance方法,同樣的,由於singleton並沒有實例化,t2同樣可以通過第一個if,然後繼續往下執行,同步代碼塊,第二個if也通過,然後t2線程創建了一個實例singleton。此時t2線程完成任務,資源又回到t1線程,t1此時也進入同步代碼塊,如果沒有這個第二個if,那麼,t1就也會創建一個singleton實例,那麼,就會出現創建多個實例的情況,但是加上第二個if,就可以完全避免這個多線程導致多次創建實例的問題。
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() { };//
public static Singleton getUniqueInstance() {
if (uniqueInstance == null) { //目的:提高效率
//剛開始所有進入這行代碼的線程,uniqueInstance對象都是null
//可能是第一個進去的線程,這時候uniqueInstance對象都是null
//也可能是第一個線程之後的線程進入並執行
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
volatile 關鍵字修飾
前驅知識點
synchronized對象進行加鎖操作,會造成線程執行代碼互斥
理解多個線程執行同一行代碼
三個線程搶佔資源,出現問題
package test_26;
public class MyThread implements Runnable{
private int num=10;
@Override
public void run() {
// TODO 自動生成的方法存根
for(int i=0;i<500;i++) {
if(this.num>=0) {
System.out.println(Thread.currentThread().getName()+(this.num--));
}
}
}
public static void main(String[] args) {
MyThread myThread=new MyThread();
Thread thread1=new Thread(myThread,"a");
Thread thread2=new Thread(myThread,"b");
Thread thread3=new Thread(myThread,"c");
thread1.start();
thread2.start();
thread3.start();
}
}
對象的實例化是做了個什麼事情
1.從方法去中找該類的信息
如果沒找到?觸發類的加載(類的加載器:)
做月餅-找模子模型:
舉個例子類似於要做一個月餅,你是不是首先需要找月餅模子,從方法中找該類的信息就是你到處找一個月餅模子,如果你要是自己找不到,就需要有人幫你找,類的加載器就是到處給你找月餅模子的部門
2.對象最終放在堆上
3.計算對象的大小(屬性(包括父類))
4.開闢的空間初始化爲0X0(屬性的默認值都是0)
5.屬性的初始化
static與private的作用
一個類中如果有成員變量或者方法被static關鍵字修飾,那麼該成員變量或方法將獨立於該類的任何對象。它不依賴類特定的實例,被類的所有實例共享,只要這個類被加載,爲了避免單例的類被頻繁創建對象,我們可以用private的構造函數來確保單例類無法被外部實例化。
實例分析
uniqueInstance 採用 volatile 關鍵字修飾也是很有必要的, uniqueInstance = new Singleton();
public class Singleton {
private volatile static Singleton uniqueInstance;//1
private Singleton() { };//2
public static Singleton getUniqueInstance() {
if (uniqueInstance == null) { //目的:提高效率
//剛開始所有進入這行代碼的線程,uniqueInstance對象都是null
//可能是第一個進去的線程,這時候uniqueInstance對象都是null
//也可能是第一個線程之後的線程進入並執行
synchronized (Singleton.class) {
//嘗試獲取同一個對象鎖的線程,嘗試獲取鎖,獲取不到就阻塞
//鎖住類名的class(這裏用到了反射的知識)
if (uniqueInstance == null) {
//初始化操作,使用volatile關鍵字禁止指令重排序
uniqueInstance = new Singleton(); //3
}
}
}
return uniqueInstance;
}
}
這段代碼其實是分爲三步執行:
Ⅰ、給 uniqueInstance分配內存
Ⅱ、調用 Singleton 的構造函數來初始化成員變量
Ⅲ、將uniqueInstance對象指向分配的內存空間(執行完這步uniqueInstance 就爲非 null 了), 但是在 JVM 的即時編譯器中存在指令重排序的優化。也就是說上面的第二步和第三步的順序是不能保證的,最終的執行順序可能是 1-2-3 也可能是 1-3-2。如果是後者,則在 3 執行完畢、2 未執行之前,被線程二搶佔了,這時 instance 已經是非 null 了(但卻沒有初始化),所以線程二會直接返回 instance,然後使用,然後順理成章地報錯。
例如,線程 T1 執行了 1 和 3,此時 T2 調用 getUniqueInstance() 後發現 uniqueInstance 不爲空,因此返回 uniqueInstance,但此時 uniqueInstance 還未被 初始化。使用 volatile 可以禁止 JVM 的指令重排,保證在多線程環境下也能正常運行。
例如現在有2個線程A,B,線程A在執行 uniqueInstance = new Singleton(); 代碼時,B線程進來,而此時A執行了 1和3,沒有執行2,此時B線程判斷s不爲null 直接返回一個未初始化的對象,就會出現問題
關於多線程的分析圖
實際應用,還是通過hashcode
package test_26;
class MySingleton {
//使用volatile關鍵字保其可見性,防止重排序
volatile private static MySingleton instance = null;
private MySingleton(){}
public static MySingleton getInstance() {
try {
if(instance != null){//懶漢式
}else{
//創建實例之前可能會有一些準備性的耗時工作
Thread.sleep(300);
synchronized (MySingleton.class) {
if(instance == null){//二次檢查
instance = new MySingleton();
}
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
return instance;
}
}
public class MyThread extends Thread{
@Override
public void run() {
System.out.println(MySingleton.getInstance().hashCode());
}
public static void main(String[] args) {
MyThread[] thread = new MyThread[10];
for(int i = 0 ; i < 10 ; i++){
thread[i] = new MyThread();
}
for (int j = 0; j < 10; j++) {
thread[j].start();
}
}
}
初步結論
volatile的可見性: 一個線程在進行寫操作的同時, 可以被其他正在進行讀操作的線程立即看到。
volatile的禁止指令重排序:防止初始化對象與對象賦值給引用的順序發生顛倒的錯誤。
關於volatile這裏介紹的相對比較少,詳細看學姐的博客,正所謂眼前好景道不得,學姐題詩在上頭,直接拿來主義了:https://blog.csdn.net/asdx1020/article/details/104443565
嚴謹探究
第一:保證此變量對所有線程的可見性,這裏的"可見性"是指 : 當一條線程修改了這個變量的值,新值對於其他線程來說是可以立即得知的。而普通變量做不到這一點,普通變量的值在線程間傳遞均需要通過主內存來完成。例如:線程A修改一個普通變量的值,然後向主內存進行回寫,另外一條線程B在線程A回寫完成之後再從主內存進行讀取操作,新值纔會對線程B可見。volatile變量在各個線程中是一致的,但是volatile變量的運算,在併發下一樣是不安全的。原因在於Java裏面的運算並非原子操作。
package test_229;
public class Main {
public static volatile int num = 0;
public static void increase() {
num++;
}
public static void main(String[] args) {
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 100; j++) {
increase();
}
}
});
threads[i].start();
}
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(num);
}
}
這段代碼你運行幾遍,會有不同的答案,輸出值可能是正確的1000,可能900,可能995,……這些都運行出來過 ,問題就在於爲什麼呢》》》實際上num++等同於num = num+1。看到這裏,聰明的你熟悉多線程立刻眼前一亮。
volatile關鍵字保證了num的值在取值時是正確的,但是在執行num+1的時候,其他線程可能已經把num值增大了,這樣在+1後會把較小的數值同步回主內存之中。
由於volatile關鍵字只保證可見性,在不符合以下兩條規則的運算場景中,我們仍然需要通過加鎖(synchronized或者
lock)來保證原子性。
第二:使用volatile變量的語義是禁止指令重排序。普通的變量僅僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變量賦值操作的順序和程序代碼中執行的順序一致。
volatile關鍵字禁止指令重排序有兩層意思:
1)當程序執行到volatile變量的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對
後面的操作可見;在其後面的操作肯定還沒有進行;
2)在進行指令優化時,不能將在對volatile變量訪問的語句放在其後面執行,也不能把volatile變量後面的語句
放到其前面執行。
volatile應用環境
1. 運算結果並不依賴變量的當前值,或者能夠確保只有單一的線程修改變量的值
2. 變量不需要與其他的狀態變量共同參與不變約束