並發編程中的逃離「996icu」——this引用逃逸
- 2019 年 10 月 8 日
- 筆記
題外話: 最近看到知乎上的騎車逆行被抓的小哥因生活壓力過大而心理崩潰,看到996icu的發起者備受關注,深切體會到了工薪族的不容易。生活不易,但需自我調節,願大家都保持一個好的心態,勇敢向前。共勉~ —— 23號老闆
0
1
什麼是this逃逸
並發編程實踐中,this引用逃逸("this"escape)是在構造器構造還未徹底完成前(即實例初始化階段還未完成),將自身this引用向外拋出並被其他執行緒複製(訪問)了該引用,可能會問到該還未被初始化的變數,甚至可能會造成更大嚴重的問題(如危及到執行緒安全)。
因為其他執行緒有可能通過這個逸出的引用訪問到「初始化了一半」的對象(partially-constructed object)。這樣就會出現某些執行緒中看到該對象的狀態是沒初始化完的狀態,而在另外一些執行緒看到的卻是已經初始化完的狀態,
這種不一致性是不確定的,程式也會因此而產生一些無法預知的並發錯誤。
0
2
程式碼示例
示例1:
public class ThisEscape { //final常量會保證在構造器內完成初始化(但是僅限於未發生this逃逸的情況下,具體可以看多執行緒對final保證可見性的實現) final int i; //儘管實例變數有初始值,但是還實例化完成 int j = 0; static ThisEscape obj; public ThisEscape() { i=1; j=1; //將this逃逸拋出給執行緒B obj = new ThisEscape(); } public static void main(String[] args) { //執行緒A:模擬構造器中this逃逸,將未構造完全對象引用拋出 /*Thread threadA = new Thread(new Runnable() { @Override public void run() { //obj = new ThisEscape(); } });*/ //執行緒B:讀取對象引用,訪問i/j變數 Thread threadB = new Thread(new Runnable() { @Override public void run() { //可能會發生初始化失敗的情況解釋:實例變數i的初始化被重排序到構造器外,此時1還未被初始化 ThisEscape objB = obj; try { System.out.println(objB.j); } catch (NullPointerException e) { System.out.println("發生空指針錯誤:普通變數j未被初始化"); } try { System.out.println(objB.i); } catch (NullPointerException e) { System.out.println("發生空指針錯誤:final變數i未被初始化"); } } }); //threadA.start(); threadB.start(); } }
輸出結果:
發生空指針錯誤:普通變數j未被初始化 發生空指針錯誤:final變數i未被初始化
這說明ThisEscape還未完成實例化,構造還未徹底結束。
示例2:
public class ThisEscape { public final int id; public final String name; public ThisEscape(EventSource<EventListener> source) { id = 1; source.registerListener(new EventListener() { //內部類是可以直接訪問外部類的成員變數的(外部類引用this被內部類獲取了) public void onEvent(Object obj) { System.out.println("id: "+ThisEscape.this.id); System.out.println("name: "+ThisEscape.this.name); } }); name = "flysqrlboy"; } }
ThisEscape在構造函數中引入了一個內部類EventListener,而內部類會自動的持有其外部類(這裡是ThisEscape)的this引用。
source.registerListener會將內部類發布出去,從而ThisEscape.this引用也隨著內部類被發布了出去。
但此時ThisEscape對象還沒有構造完成,id已被賦值為1,但name還沒被賦值,仍然為null。
簡單來說,就是在一個類的構造器創建了一個內部類(內部類本身是擁有對外部類的所有成員的訪問權的),此時外部類的成員變數還沒初始化完成。但是,同時這個內部類被其他執行緒獲取到,並且調用了內部類可以訪問到外部類還沒來得及初始化的成員變數的方法。
示例3:
public class EventSource<T> { private final List<T> eventListeners ; public EventSource() { eventListeners = new ArrayList<T>() ; } public synchronized void registerListener(T eventListener) { //數組持有傳入對象的引用 this.eventListeners.add(eventListener); this.notifyAll(); } public synchronized List<T> retrieveListeners() throws InterruptedException { //獲取持有對象引用的數組 List<T> dest = null; if(eventListeners.size() <= 0 ) { this.wait(); } dest = new ArrayList<T>(eventListeners.size()); //這裡為什麼要創建新數組,好處在哪裡 dest.addAll(eventListeners); return dest; } }
把內部類對象發布出去的source.registerListener語句沒什麼特殊的(發布其實就是讓別的類有機會持有這個內部類的引用),registerListener方法只是往list中添加一個EventListener元素而已。這樣,其他持有EventSource對象的執行緒從而持有EventListener對象,便可以訪問ThisEscape的內部狀態了(id和name)。
示例4:
public class ListenerRunnable implements Runnable { private EventSource<EventListener> source; public ListenerRunnable(EventSource<EventListener> source) { this.source = source; } public void run() { List<EventListener> listeners = null; try { listeners = this.source.retrieveListeners(); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } for(EventListener listener : listeners) { listener.onEvent(new Object()); //執行內部類獲取外部類的成員變數的方法 } } }
只要執行緒得到持有內部類引用的數組,就可以使用內部類獲取外部類的有可能未初始化的成員變數。
示例5:
public class ThisEscapeTest { public static void main(String[] args) { EventSource<EventListener> source = new EventSource<EventListener>(); ListenerRunnable listRun = new ListenerRunnable(source); Thread thread = new Thread(listRun); thread.start(); ThisEscape escape1 = new ThisEscape(source); } }
啟動了一個ListenerRunnable 執行緒,用於監視ThisEscape的內部狀態。主執行緒緊接著調用ThisEscape的構造函數,新建一個ThisEscape對象。
在ThisEscape構造函數中,如果在source.registerListener語句之後,name="flysqrlboy"賦值語句之前正好發生上下文切換,ListenerRunnable 執行緒就有可能看到了還沒初始化完的ThisEscape對象,即id為1,但是name仍然為null。
示例6:
另外一種就是在構造函數中啟動新的執行緒的時候,容易發生This逃逸。
public class ThreadThisEscape { //成員變數xxx public ThisEscape() { new Thread(new EscapeRunnable()).start(); //使用未初始化的成員變數 // 初始化成員變數 } private class EscapeRunnable implements Runnable { @Override public void run() { //使用成員變數 // ThreadThisEscape.this就可以引用外圍類對象, 但是此時外圍類對象可能還沒有構造完成, 即發生了外圍類的this引用的逃逸 } } }
示例7:
public class ThisEscape { //final常量會保證在構造器內完成初始化(但是僅限於未發送this逃逸的情況下) final int i; //儘管實例變數有初始值,但是還實例化完成 int j = 0; static ThisEscape obj; public ThisEscape() { i=1; j=1; //obj = new ThisEscape(); } public static void main(String[] args) { //執行緒A:模擬構造器中this逃逸,將未構造完全對象引用拋出 Thread threadA = new Thread(new Runnable() { @Override public void run() { //構造初始化中...執行緒B可能獲取到還未被初始化完成的變數 //類似於this逃逸,但並不定發生 obj = new ThisEscape(); } }); //執行緒B:讀取對象引用,訪問i/j變數 Thread threadB = new Thread(new Runnable() { @Override public void run() { //可能會發生初始化失敗的情況解釋:實例變數i的初始化被重排序到構造器外,此時1還未被初始化 ThisEscape objB = obj; try { System.out.println(objB.j); } catch (NullPointerException e) { System.out.println("發生空指針錯誤:普通變數j未被初始化"); } try { System.out.println(objB.i); } catch (NullPointerException e) { System.out.println("發生空指針錯誤:final變數i未被初始化"); } } }); threadA.start(); threadB.start(); } }
利用執行緒A模擬this逃逸,但不一定會發生,執行緒A模擬構造器正在構造…而執行緒B嘗試訪問變數,這是因為
(1)由於JVM的指令重排序存在,實例變數i的初始化被安排到構造器外(final可見性保證是final變數規定在構造器中完成的);
(2)類似於this逃逸,執行緒A中構造器構造還未完全完成。
0
3
如何避免
因此,什麼情況下會this逃逸?
(1)在構造器中很明顯地拋出this引用提供其他執行緒使用(如上述的明顯將this拋出)。
(2)在構造器中內部類使用外部類情況:內部類訪問外部類是沒有任何條件的,也不要任何代價,也就造成了當外部類還未初始化完成的時候,內部類就嘗試獲取為初始化完成的變數。
那麼,如何避免this逃逸呢?
導致的this引用逸出需要滿足兩個條件:
1、在構造函數中創建內部類(EventListener)
2、是在構造函數中就把這個內部類給發布了出去(source.registerListener)。
因此,我們要防止這一類this引用逸出的方法就是避免讓這兩個條件同時出現。也就是說,如果要在構造函數中創建內部類,那麼就不能在構造函數中把他發布了,應該在構造函數外發布,即等構造函數執行完初始化工作,再發布內部類。
根據不同的場景,解決如下:
1、單獨編寫一個啟動執行緒的方法,不要在構造器中啟動執行緒,嘗試在外部啟動。
2、使用一個私有的構造函數進行初始化和一個公共的工廠方法進行發布。
3、將事件監聽放置於構造器外,比如new Object()的時候就啟動事件監聽,但是在構造器內不能使用事件監聽,那可以在static{}中加事件監聽,這樣就跟構造器解耦了。
0
4
補充知識點
class Glyph { void draw() { //沒有執行 System.out.println("Glyph.draw()"); } Glyph() { //3,默認調用 System.out.println("Glyph() before draw()"); draw(); //父類構造器作為子類構造器執行前的默認執行,此時父構造器內執行的方法是子類的重寫方法。 System.out.println("Glyph() after draw()"); } } class RoundGlyph extends Glyph { private int radius = 1; //5,初始化變數 RoundGlyph(int r) {//2,首先調用父類構造器(並且默認是無參構造器) radius = r; //6,賦值執行 System.out.println("RoundGlyph.RoundGlyph(). radius = " + radius); } void draw() { //4,在父構造器被調用,此時該類(子類)還沒被初始化,所以實例變數的值為默認值。 System.out.println("RoundGlyph.draw(). radius = " + radius); } } public class PolyConstructors { public static void main(String[] args) { new RoundGlyph(5);//1,首先執行 } }
輸出:
Glyph() before draw() RoundGlyph.draw(). radius = 0 //未被初始化 Glyph() after draw() RoundGlyph.RoundGlyph(). radius = 5
原因——Java中構造函數的調用順序:
(1)在其他任何事物發生之前,將分配給對象的存儲空間初始化成二進位0;
(2)調用基類構造函數。從根開始遞歸下去,因為多態性此時調用子類覆蓋後的draw()方法(要在調用RoundGlyph構造函數之前調用),由於步驟1的緣故,我們此時會發現radius的值為0;
(3)按聲明順序調用成員的初始化方法;
(4)最後調用子類的構造函數。
0
5
小結
this引用逃逸問題實則是Java多執行緒編程中需要注意的問題,引起逃逸的原因無非就是在多執行緒的編程中「濫用」引用(往往涉及構造器中顯式或隱式地濫用this引用),在使用到this引用的時候需要特別注意!