設計模式 – 單例模式之多線程調試與破壞單例

  • 2019 年 10 月 10 日
  • 筆記

前言

在之前的 設計模式 – 單例模式(詳解)看看和你理解的是否一樣? 一文中,我們提到了通過Idea 開發工具進行多線程調試、單例模式的暴力破壞的問題;由於篇幅原因,現在單獨開一篇文章進行演示:線程不安全的單例在多線程情況下為何被創建多個、如何破壞單例。

如果還不知道如何使用IDEA工具進行線程模式的調試,請先閱讀我之前發的一篇文章: 你不知道的 IDEA Debug調試小技巧

一、線程不安全的單例在多線程情況下為何被創建多個

首先回顧簡單線程不安全的懶漢式單例的代碼以及測試程序代碼:

/**   * @author eamon.zhang   * @date 2019-09-30 上午10:55   */  public class LazySimpleSingleton {      private LazySimpleSingleton(){}      private static LazySimpleSingleton instance = null;        public static LazySimpleSingleton getInstance(){          if (instance == null) {              instance = new LazySimpleSingleton();          }          return instance;      }  }    // 測試程序  @Test  public void test() {      try {          ConcurrentExecutor.execute(() -> {              LazySimpleSingleton instance = LazySimpleSingleton.getInstance();              System.out.println(Thread.currentThread().getName() + " : " + instance);          }, 2, 2);      } catch (Exception e) {          e.printStackTrace();      }  }

對於這個單例,我們毫無疑問認為它是線程不安全的,至於為什麼,接下來使用IDEA工具的線程debug模式來直觀的找出答案。

在關鍵代碼上打斷點

  1. 單例類LazySimpleSingletonif (instance == null) 處:

  1. 測試類,多線程入口調用getInstance()處:

開始調試

  1. 啟動 debug ,我們可以在調試窗口找到我們啟動的線程:

  1. pool-1-thread-1 線程單步執行到if (instance == null) 斷點處,觀察instance值為null

  1. pool-1-thread-1執行到instance = new LazySimpleSingleton();處等待初始化:

  1. 切換線程 pool-1-thread-2 同樣單步執行到 if (instance == null) 斷點處,此時觀察instance值也為null(這就是我們常說的兩個線程同時執行到斷代碼處):

  1. 同樣將pool-1-thread-2執行到instance = new LazySimpleSingleton();處等待初始化:

  1. 顯然,這兩個線程都滿足if (instance == null) 的條件,都應該到對應的代碼塊中執行實例化操作,那麼這兩個線程就會分別初始化:

線程 pool-1-thread-1 實例化後:

切換線程 pool-1-thread-2 觀察 instance 值已經被初始化了,但是,線程pool-1-thread-2 還是會被實例化一遍:

線程pool-1-thread-2實例化後:

大家是否一目了然了呢?

  1. 將兩個線程執行完,看控制台:

大家可以看到,雖然輸出打印的對象是同一個,但是,確實是創建了兩遍,只不過 pool-1-thread-2 實例化後將 pool-1-thread-1實例化的對象值給覆蓋了。

當我將線程pool-1-thread-1和線程pool-1-thread-2同時執行到instance = new LazySimpleSingleton();處然後先讓pool-1-thread-1執行完打印後,再將pool-1-thread-2執行實例化操作,就會看到打印的對象會是不一樣的了:

這就是通過線程調試模式手動控制線程執行順序來模擬還原多線程環境下,線程不安全的情況。


二、改進線程不安全的單例

我們明白了線程不安全的原因是兩個線程同時拿到的instance資源都為null,從而都進行實例化。那麼有沒有什麼方法能解決呢?當然有,給 getInstance()加 上 synchronized 關鍵字,使這個方法變成線程同步方法:

public class LazySimpleSingleton {      private LazySimpleSingleton(){}      private static LazySimpleSingleton instance = null;        public synchronized static LazySimpleSingleton getInstance(){          if (instance == null) {              instance = new LazySimpleSingleton();          }          return instance;      }  }

當我們將其中一個線程執行並調用 getInstance()方法時,另一個線程在調用 getInstance()方法,線程的狀態由 RUNNING 變成了MONITOR,出現阻塞。直到第一個線程執行完,第二個線程才恢復 RUNNING 狀態繼續調用 getInstance() 方法

這就解決了之前所說的線程安全問題,但是這樣子在線程數量比較多情況下,如果 CPU分配壓力上升,會導致大批量線程出現阻塞,從而導致程序運行性能大幅下降;為了解決線程安全和程序性能問題,於是乎有了我們的雙重檢查式的單例。這裡就不再多說了。


三、破壞單例

一般情況下,我們創建使用餓漢式單例或雙重檢查的懶漢式單例是沒有問題的,但是在一定情況下,會發生單例被破壞。

反射破壞單例

實際情況下,公司一個程序員寫了一個單例,但是另外一個程序員,可能比較牛 X,寫代碼風格有點不一樣,他通過反射來調用別人寫的接口,這就會出現此單例並非彼單例的情況。這就破壞了單例。

演示

在我們寫單例的時候,大家有沒有注意到私有的構造方法前面的修飾符僅為 private,如果我們使用反射來調用其構造方法,然後,再調用 getInstance()方法,應該就會有兩個不同的實例。

我們以前面說單例的文章中的 LazyInnerClassSingleton為例,編寫反射調用測試代碼:

@Test  public void testReflex() {      try {          // 很無聊的情況下,進行破壞          Class<LazyInnerClassSingleton> clazz = LazyInnerClassSingleton.class;          // 通過反射拿到私有的構造方法          Constructor<LazyInnerClassSingleton> c = clazz.getDeclaredConstructor(null);          // 設置訪問屬性,強制訪問          c.setAccessible(true);            // 暴力初始化兩次,這就相當於調用了兩次構造方法          LazyInnerClassSingleton o1 = c.newInstance();          LazyInnerClassSingleton o2 = c.newInstance();          // 只要 o1和o2 地址不相等,就可以說明這是兩個不同的對象,也就是違背了單例模式的初衷          System.out.println(o1 == o2);      } catch (Exception e) {          e.printStackTrace();      }    }

運行結果如下:

顯然,是創建了兩個不同的實例。現在,我們在其構造方法中做一些限制,一旦出現多次重複創建,則直接拋出異常。來看優化後的代碼:

public class LazyInnerClassSingleton {        private LazyInnerClassSingleton() {          if(LazyHolder.INSTANCE != null){              throw new RuntimeException("不允許創建多個實例");          }      }        // 注意關鍵字final,保證方法不被重寫和重載      public static final LazyInnerClassSingleton getInstance() {          return LazyHolder.INSTANCE;      }        private static class LazyHolder {          // 注意 final 關鍵字(保證不被修改)          private static final LazyInnerClassSingleton INSTANCE = new LazyInnerClassSingleton();      }  }

再次調用:

至此,就避免了單例被反射破壞的問題。

序列化破壞單例

另外一種情況,可能會遇到,我們需要將對象序列化到磁盤,下次使用時再從磁盤反序列化回來,反序列化的對象會被重新分配內存,那如果序列化的對象為單例,則就違背了單例模式的初衷。這也相當於破壞了單例。

演示

我們還是以LazyInnerClassSingleton為例,將LazyInnerClassSingleton 實現 Serializable 接口;

然後編寫測試代碼:

/**   * @author eamon.zhang   * @date 2019-10-08 下午3:06   */  public class SerializableTest {      public static void main(String[] args) {          LazyInnerClassSingleton s1 = null;          LazyInnerClassSingleton s2 = LazyInnerClassSingleton.getInstance();            FileOutputStream fos = null;          try {              fos = new FileOutputStream("LazyInnerClassSingleton.obj");              ObjectOutputStream oos = new ObjectOutputStream(fos);              oos.writeObject(s2);              oos.flush();              oos.close();                FileInputStream fis = new FileInputStream("LazyInnerClassSingleton.obj");              ObjectInputStream ois = new ObjectInputStream(fis);              s1 = (LazyInnerClassSingleton)ois.readObject();              ois.close();                System.out.println(s1);              System.out.println(s2);          } catch (Exception e) {              e.printStackTrace();          }      }  }

執行測試代碼:

可以看到,結果為兩個不同的對象。這同樣違背了單例模式的初衷。那麼我們如何保證序列化的情況也能實現單例呢?其實也很簡單,使用 readResolve() 方法即可:

public class LazyInnerClassSingleton implements Serializable {        private LazyInnerClassSingleton() {          if (LazyHolder.INSTANCE != null) {              throw new RuntimeException("不允許創建多個實例");          }      }        // 注意關鍵字final,保證方法不被重寫和重載      public static final LazyInnerClassSingleton getInstance() {          return LazyHolder.INSTANCE;      }        private static class LazyHolder {          // 注意 final 關鍵字(保證不被修改)          private static final LazyInnerClassSingleton INSTANCE = new LazyInnerClassSingleton();      }        // 解決反序列化對象不一致問題      private Object readResolve() {          return LazyHolder.INSTANCE;      }  }

大家肯定會問,why?

為了一探究竟,我們來看一下 JDK 源碼,我們進入 ObjectInputStream 類的 readObject()方法:

public final Object readObject() throws IOException, ClassNotFoundException {          if (this.enableOverride) {              return this.readObjectOverride();          } else {              int outerHandle = this.passHandle;                Object var4;              try {                  Object obj = this.readObject0(false);                  this.handles.markDependency(outerHandle, this.passHandle);                  ClassNotFoundException ex = this.handles.lookupException(this.passHandle);                  if (ex != null) {                      throw ex;                  }                    if (this.depth == 0L) {                      this.vlist.doCallbacks();                      this.freeze();                  }                    var4 = obj;              } finally {                  this.passHandle = outerHandle;                  if (this.closed && this.depth == 0L) {                      this.clear();                  }                }                return var4;          }      }

我們發現:readObject 中又調用了我們重寫的 readObject0()方法,進入 readObject0()方法:

private Object readObject0(boolean unshared) throws IOException {          ...          try {              switch(tc) {              ...              case 115:                  var4 = this.checkResolve(this.readOrdinaryObject(unshared));                  return var4;              ...          } finally {              --this.depth;              this.bin.setBlockDataMode(oldMode);          }            return var4;      }

我們看到代碼中調用了 ObjectInputStreamreadOrdinaryObject() 方法,我們繼續進入看源碼:

private Object readOrdinaryObject(boolean unshared) throws IOException {          ...              if (cl != String.class && cl != Class.class && cl != ObjectStreamClass.class) {                  Object obj;                  try {                      obj = desc.isInstantiable() ? desc.newInstance() : null;                  } catch (Exception var7) {                      throw (IOException)(new InvalidClassException(desc.forClass().getName(), "unable to create instance")).initCause(var7);                  }            ...            }      }

發現調用了 ObjectStreamClassisInstantiable()方法,而 isInstantiable()裏面的代碼如下:

boolean isInstantiable() {      this.requireInitialized();      return this.cons != null;  }

代碼非常簡單,就是判斷一下構造方法是否為空,構造方法不為空就返回 true,也就是說,只要有無參構造方法就會實例化;這時候,其實還沒有找到為什麼加上readResolve()方法就避免了單例被破壞的真正原因,我們再次回到ObjectInputStreamreadOrdinaryObject()方法繼續往下看可以找到如下代碼:

private Object readOrdinaryObject(boolean unshared) throws IOException {      ...      if (obj != null && this.handles.lookupException(this.passHandle) == null && desc.hasReadResolveMethod()) {          Object rep = desc.invokeReadResolve(obj);          if (unshared && rep.getClass().isArray()) {              rep = cloneArray(rep);          }            if (rep != obj) {              if (rep != null) {                  if (rep.getClass().isArray()) {                      this.filterCheck(rep.getClass(), Array.getLength(rep));                  } else {                      this.filterCheck(rep.getClass(), -1);                  }              }                obj = rep;              this.handles.setObject(this.passHandle, rep);          }      }      ...  }

判斷無參構造方法是否存在之後,又調用了 hasReadResolveMethod()方法:

boolean hasReadResolveMethod() {      this.requireInitialized();      return this.readResolveMethod != null;  }

邏輯非常簡單,就是判斷readResolveMethod 是否為空,不為空就返回 true。那麼 readResolveMethod是在哪裡賦值的呢? 通過全局查找找到了賦值代碼在私有方法 ObjectStreamClass()方法中給 readResolveMethod 進行賦值,來看代碼:

 ObjectStreamClass.this.readResolveMethod = ObjectStreamClass.getInheritableMethod(cl, "readResolve", (Class[])null, Object.class);

代碼的邏輯其實就是通過反射找到一個無參的 readResolve()方法,並且保存下來,現在再回到 ObjectInputStreamreadOrdinaryObject() 方法繼續往下看,如果readResolve()存在則調用 invokeReadResolve()方法:

Object invokeReadResolve(Object obj) throws IOException, UnsupportedOperationException {      this.requireInitialized();      if (this.readResolveMethod != null) {          try {              return this.readResolveMethod.invoke(obj, (Object[])null);          } catch (InvocationTargetException var4) {              Throwable th = var4.getTargetException();              if (th instanceof ObjectStreamException) {                  throw (ObjectStreamException)th;              } else {                  throwMiscException(th);                  throw new InternalError(th);              }          } catch (IllegalAccessException var5) {              throw new InternalError(var5);          }      } else {          throw new UnsupportedOperationException();      }  }

我們可以看到在 invokeReadResolve()方法中用反射調用了 readResolveMethod() 方法。 通過JDK源碼分析我們可以看出,雖然,增加 readResolve()方法返回實例,解決了單例被破壞的問題。但是,我們通過分析源碼以及調試,我們可以看到實際上實例化了兩 次,只不過新創建的對象沒有被返回而已.

那如果,創建對象的動作發生頻率增大,就 意味着內存分配開銷也就隨之增大;為了解決這個問題,我們推薦使用註冊式單例。

為何建議使用註冊式(枚舉式)單例

我們在前文中說到了,我們極力推薦使用枚舉類型的單例;接下來我們分析一下原因:

使用 Java 反編譯工具 Jad(自行下載),解壓後,使用命令行調用:

./jad ~/IdeaProjects/own/java-advanced/01.DesignPatterns/design-patterns/build/classes/java/main/com/eamon/javadesignpatterns/singleton/enums/EnumSingleton.class

會在當前目錄生成一個 EnumSingleton.jad文件,我們使用 vscode 打開這個文件查看:

public final class EnumSingleton extends Enum  {        public static EnumSingleton[] values()      {          return (EnumSingleton[])$VALUES.clone();      }        public static EnumSingleton valueOf(String name)      {          return (EnumSingleton)Enum.valueOf(com/eamon/javadesignpatterns/singleton/enums/EnumSingleton, name);      }        private EnumSingleton(String s, int i)      {          super(s, i);          instance = new EnumResource();      }        public Object getInstance()      {          return instance;      }        public static final EnumSingleton INSTANCE;      private Object instance;      private static final EnumSingleton $VALUES[];        static      {          INSTANCE = new EnumSingleton("INSTANCE", 0);          $VALUES = (new EnumSingleton[] {              INSTANCE          });      }  }

請注意這段代碼:

static  {      INSTANCE = new EnumSingleton("INSTANCE", 0);      $VALUES = (new EnumSingleton[] {          INSTANCE      });  }

原來枚舉類單例在靜態代碼塊中就給INSTANCE 賦了值,是餓漢式單例的實現方式。那麼同樣的,我們能否通過反射和序列化方式進行破壞呢?

先分析通過序列化方式:

我們還是回到JDK源碼:在 ObjectInputStreamreadObject0()方法中有如下代碼:

 private Object readObject0(boolean unshared) throws IOException {      ...          case 126:              var4 = this.checkResolve(this.readEnum(unshared));      ...        return var4;  }

我們看到 readObject0()中調用了readEnum()方法,跟進該方法:

private Enum<?> readEnum(boolean unshared) throws IOException {      if (this.bin.readByte() != 126) {          throw new InternalError();      } else {          ObjectStreamClass desc = this.readClassDesc(false);          if (!desc.isEnum()) {              throw new InvalidClassException("non-enum class: " + desc);          } else {              int enumHandle = this.handles.assign(unshared ? unsharedMarker : null);              ClassNotFoundException resolveEx = desc.getResolveException();              if (resolveEx != null) {                  this.handles.markException(enumHandle, resolveEx);              }                String name = this.readString(false);              Enum<?> result = null;              Class<?> cl = desc.forClass();              if (cl != null) {                  try {                      Enum<?> en = Enum.valueOf(cl, name);                      result = en;                  } catch (IllegalArgumentException var9) {                      throw (IOException)(new InvalidObjectException("enum constant " + name + " does not exist in " + cl)).initCause(var9);                  }                    if (!unshared) {                      this.handles.setObject(enumHandle, result);                  }              }                this.handles.finish(enumHandle);              this.passHandle = enumHandle;              return result;          }      }  }

我們發現枚舉類型其實通過類名和 Class 對象類找到一個唯一的枚舉對象。因此,枚舉對象不可能被類加載器加載多次。

那麼是否可以通過反射進行破壞呢?我們先來執行以下反射破壞枚舉類的測試代碼:

@Test  public void testEnum(){      try {          // 很無聊的情況下,進行破壞          Class<EnumSingleton> clazz = EnumSingleton.class;          // 通過反射拿到私有的構造方法          Constructor<EnumSingleton> c = clazz.getDeclaredConstructor(null);          // 設置訪問屬性,強制訪問          c.setAccessible(true);            // 暴力初始化兩次,這就相當於調用了兩次構造方法          EnumSingleton o1 = c.newInstance();          EnumSingleton o2 = c.newInstance();          // 只要 o1和o2 地址不相等,就可以說明這是兩個不同的對象,也就是違背了單例模式的初衷          System.out.println(o1 == o2);      } catch (Exception e) {          e.printStackTrace();      }  }

執行結果:

報的是 java.lang.NoSuchMethodException 異常,意思是沒找到無參的構造方法。

那麼我們來看一下 java.lang.Enum 的源碼,我們發現它只有一個protected的構造方法:

protected Enum(String name, int ordinal) {      this.name = name;      this.ordinal = ordinal;  }

那我們來做一個這樣的測試:

@Test  public void testEnum1() {      try {          Class clazz = EnumSingleton.class;          Constructor c = clazz.getDeclaredConstructor(String.class, int.class);          c.setAccessible(true);          EnumSingleton enumSingleton = (EnumSingleton) c.newInstance("Eamon", 666);      } catch (Exception e) {          e.printStackTrace();      }  }

發現控制台輸出如下錯誤:

意思就是不能用反射來創建枚舉類型。至於為什麼,我們還是來看 JDK 源碼,進入ConstructornewInstance()方法中:

    public T newInstance(Object... initargs) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException {          if (!this.override) {              Class<?> caller = Reflection.getCallerClass();              this.checkAccess(caller, this.clazz, this.clazz, this.modifiers);          }            if ((this.clazz.getModifiers() & 16384) != 0) {              throw new IllegalArgumentException("Cannot reflectively create enum objects");          } else {              ConstructorAccessor ca = this.constructorAccessor;              if (ca == null) {                  ca = this.acquireConstructorAccessor();              }                T inst = ca.newInstance(initargs);              return inst;          }      }

原來,在源碼中對枚舉類型進行了強制性的判斷(16384代表枚舉類型),如果是枚舉類型,直接拋異常。到此為止也就說明了為什麼《Effective Java》推薦使用枚舉來實現單例的原因: JDK 枚舉的語法特殊性,以及反射也為枚舉保駕護航,讓枚舉式單例成為一種比較優雅的實現。


本文中所涉及的源碼可在 github 上找到,相關的測試代碼在 test 包下:https://github.com/eamonzzz/java-advanced