終於,有大佬通過JS代碼講清楚了Java中的逃逸分析...

作者:一葉知秋
來源:https://muyinchen.github.io/

終於,有大佬通過JS代碼講清楚了Java中的逃逸分析...

說到域,假如給小學生中學生或者沒有學過編程語言的人理解,更好說明其本質,就是作用範圍,而這也就是上下文的意思,無須去理解的那麼抽象。接下來就從函數講起

這裡拿js的閉包來說,不解釋那麼多,貼幾段代碼的,變量的作用域無非就是兩種:
全局變量和局部變量。

Javascript語言的特殊之處,就在於函數內部可以直接讀取全局變量。

Js代碼

 var n=999;
  function f1(){
    alert(n);
  }
  f1(); // 999

另一方面,在函數外部自然無法讀取函數內的局部變量。

Js代碼

function f1(){
    var n=999;
  }
  alert(n); // error

這裡有一個地方需要注意,函數內部聲明變量的時候,一定要使用var命令。如果不用的話,你實際上聲明瞭一個全局變量!

Js代碼

function f1(){
    n=999;
  }
  f1();
  alert(n); // 999

如何從外部讀取局部變量?

出於種種原因,我們有時候需要得到函數內的局部變量。但是,前面已經說過了,正常情況下,這是辦不到的,只有通過變通方法才能實現。

那就是在函數的內部,再定義一個函數。

Js代碼

function f1(){
    n=999;
    function f2(){
      alert(n); // 999
    }
  }

在上面的代碼中,函數f2就被包括在函數f1內部,這時f1內部的所有局部變量,對f2都是可見的。但是反過來就不行,f2內部的局部變量,對f1 就是不可見的。這就是Javascript語言特有的“鏈式作用域”結構(chain scope),子對象會一級一級地向上尋找所有父對象的變量。所以,父對象的所有變量,對子對象都是可見的,反之則不成立。

既然f2可以讀取f1中的局部變量,那麼只要把f2作為返回值,我們不就可以在f1外部讀取它的內部變量了嗎!

Js代碼


 function f1(){
    n=999;
    function f2(){
      alert(n);
    }
    return f2;
  }
  var result=f1();
  result(); // 999

上面例子中的f2函數,就是閉包,其實就是兩個容器,大的套個小的,有點類似套娃,再往大自然延伸一點:

f1函數就是河,而f2函數就是河裡的魚,魚的新陳代謝的產物就是其返回的結果,同樣是不是類似於我們和空氣的關係,吸進去的是氧氣,返回的結果的是二氧化碳

同樣可以聯繫下Java中的類的函數,是不是也是一個道理,這就是函數的上下文

# 由域聯繫到的逃逸分析

接著由這個可以聯想到代碼中的逃逸分析,其實本就不是什麼高深的東西,和現實聯想下一個本應被銷燬的東西沒有被處理掉,會造成多大的浪費,比如地雷,戰時有很大作用,和平時期,假如在居民區範圍,那就有很大的問題;又比如犯人,一個越了域(用這個字覺得更形象)的恐怖分子,會造成多大的恐慌和社會問題,所以說逃逸分析,分析的就是作用域。

先拿Java來說,逃逸常見分三種情況:

  • 給全局變量賦值,發生逃逸;
  • 方法的返回值,會發生逃逸
  • 實例的引用傳遞,也會發生逃逸

這裡又引出了一個優化的問題,Java對象總是在堆中分配的,因此,Java對象的創建和回收對系統的開銷也是很大的,而Java被詬病的一個地方認為其性能慢的一個原因就是Java不支持運行時棧分配對象,沒有像c++裡面的struct結構或者是c#裡的值對象。jdk6中的swing呢剛才和性能消耗的瓶頸就是由於發生逃逸造成的,棧內置保存了對象的指針,當對象不再被引用後,需要依靠GC來遍歷引用樹並回收內存,如果對象的數量比較多,就會給GC帶來比較大的壓力,也就間接影響了應用的性能,所以,減少臨時對象在堆內分配的數量,無疑是最有效的優化方法。

再次回到域這個話題,在Java應用裡我們很容易想到,一般在方法體內,我們聲明瞭一個局部變量,且該變量的方法執行生命週期內未發生逃逸,因為在方法體內未將引用暴露給外面,按照JVM內存分配機制,首先會在堆裡new出變量類的實例,然後將返回的對象指針壓入調用棧,再繼續執行,這是jvm未優化之前的走方式。

通過逃逸分析,我們可以對jvm的這個過程進行優化,即對棧的重新分配,首先需要分析並找到這期間為發生逃逸的變量,將變量類的實例化直接在棧裡發生,即不需要經過堆,分配完成後,繼續在棧內執行,最後線程結束,棧空間被回收,同樣,局部變量也會跟著棧消失,優化後和優化前的區別就是減少了臨時對象在堆內的分配數量(即未逃逸的對象無須堆內創建)。

逃逸分析不能在靜態編譯時進行,必須在JIT裡完成,因為我們在正常的代碼過程中運行時會通過動態代理改變一個類的行為,這時就無法得志類已經變化。

舉個例子,比如一個方法的返回值是void,方法內部有對象的創建,如:


 public void use_a()
{ A a=new A();
//a.xx();
...
a=null
}

從上面代碼可以看出,a這個局部對象,沒有返回,沒有賦值全局變量等操作,所以,其是沒有發生逃逸的,由於整個生命週期都在一個方法體內,這樣的對象就可以在運行時棧進行分配和銷燬。

JIT在編譯時,假如能分析出這種代碼,那麼非逃逸的對象的創建和回收就可以在棧上進行,從而大大提高Java的運行性能。

往往我們會碰到這個情況,就是方法內調用另一個方法,有點類似js的閉包了吧,這時就要進行內聯分析,因為往往一些對象在被調用過程中創建並返回給調用,比如上面的a.xx(),假如有返回值賦值給另外的局部變量的過程(這下應該好理解這個概念了吧),在調用過程中使用完就被銷燬回收了,其實都清楚程序的執行過程是自上而下的,其實函數的調用也無非是把一段代碼嵌入到另一段代碼裡順序執行處理而已,說白了兩個方法內聯成一個方法體,而這個也是我們平常方法重構的一個過程,這種原來通過返回傳遞的對象就變成了方法內的局部對象,也就變成了非逃逸對象了,這樣,這些對象就可以在同一個棧上進行分配了。

Java7已經開始支持對象的棧分配和逃逸分析,這樣除了上述的優化外還會帶來 同步消除和矢量替代,關於這兩個可以查閱相應資料

這裡對方法逃逸再貼兩段代碼,方便大家加深認識:

 

 public static StringBuffer craeteStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}

StringBuffer sb是一個方法內部變量,上述代碼中直接將sb返回,這樣這個StringBuffer有可能被其他方法所改變,這樣它的作用域就不只是在方法內部,雖然它是一個局部變量,稱其逃逸到了方法外部。

甚至還有可能被外部線程訪問到,譬如賦值給類變量或可以在其他線程中訪問的實例變量,稱為線程逃逸。

上述代碼如果想要StringBuffer sb不逃出方法,可以這樣寫:


 public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}

不直接返回 StringBuffer,那麼StringBuffer將不會逃逸出方法。

同樣,我們分析下js中閉包在此的行為:

最大用處有兩個,一個是前面提到的可以讀取函數內部的變量,另一個就是讓這些變量的值始終保持在內存中。

Js代碼

function f1(){
 var n=999;
 nAdd=function(){n+=1}
 function f2(){
   alert(n);
 }
 return f2;
}
var result=f1();
result(); // 999
nAdd();
result(); // 1000

在這段代碼中,result實際上就是閉包f2函數。它一共運行了兩次,第一次的值是999,第二次的值是1000。這證明了,函數f1中的局部變量n一直保存在內存中,並沒有在f1調用後被自動清除。


為什麼會這樣呢?原因就在於f1是f2的父函數,==而f2被賦給了一個全局變量,這導致f2始終在內存中,而f2的存在依賴於f1,因此f1也始終在內存中,不會在調用結束後,被垃圾回收機制(garbage collection)回收。

這段代碼中另一個值得注意的地方,就是“nAdd=function(){n+=1}”這一行,首先在nAdd前面沒有使用var關鍵字,因此nAdd是一個全局變量,而不是局部變量。

其次,nAdd的值是一個匿名函數(anonymousfunction),而這個

匿名函數本身也是一個閉包,所以nAdd相當於是一個setter,可以在函數外部對函數內部的局部變量進行操作。

# 使用閉包的注意點

由於閉包會使得函數中的變量都被保存在內存中,內存消耗很大,所以不能濫用閉包,否則會造成網頁的性能問題,在IE中可能導致內存洩露。解決方法是,在退出函數之前,將不使用的局部變量全部刪除。


分享到:


相關文章: