本文從一個遞歸棧溢出提及,像你們介紹一下如何使用尾調用解決這個問題,以及尾調用的原理,最後還提供一個解決方案的工具類,你們能夠在工做中放心用起來。java
如今咱們有個需求,須要計算任意值階乘的結果,階乘咱們用 n!表示,它的計算公式是:n! = 123……(n-1)n,好比說 3 的階乘就是 123。面試
對於這個問題,咱們首先想到的應該就是遞歸,咱們立馬寫了一個簡單的遞歸代碼:app
// 階乘計算 public static String recursion(long begin, long end, BigDecimal total) { // begin 每次計算時都會遞增,當 begin 和 end 相等時,計算結束,返回最終值 if (begin == end) { return total.toString(); } // recursion 第三個參數表示當前階乘的結果 return recursion(++begin, end, total.multiply(new BigDecimal(begin))); }
遞歸代碼很簡單,咱們寫了一個簡單的測試,以下:jvm
@Test public void testRecursion() { log.info("計算 10 的階乘,結果爲{}",recursion(1, 10, BigDecimal.ONE)); }
運行結果很快就出來了,結果爲:3628800,是正確的。ide
由於需求是可以計算任意值,接着咱們把 10 換成 9000,來計算一下 9000 的階乘,可這時卻忽然報錯了,報錯的信息以下:
StackOverflowError 是棧溢出的異常,jvm 給棧分配的大小是固定的,方法自己的定義、入參、方法裏的局部變量這些都會佔內存,隨着遞歸不斷進行,遞歸的方法就會愈來愈多,每一個方法都能從棧中獲得內存,漸漸的,棧的內存就不夠了,報了這個異常。工具
咱們首先想到的辦法是如何讓棧的內存大一點呢?JVM 有個參數叫作 -Xss,這個參數就規定了要分配給棧多少大小的內存,因而咱們在 idea 裏面配置一下 Xss 的參數,配置以下:
圖中咱們給棧分配 200k 大小內存,再次運行仍然報錯,說明咱們分配的棧仍是過小了,因而咱們修改 Xss 值到 100M 試一下,配置以下:
再次運行,成功了,運行結果以下:
雖然經過修改棧的大小暫時解決了這個問題,但這種解決方案在線上是徹底行不通的,主要問題以下:測試
咱們不可能修改線上棧的大小,通常來講,線上棧的大小通常都是 256k,不可能爲了一個遞歸程序把棧大小修改爲很大。優化
由於咱們須要計算任意值的階乘,因此棧的大小是動態的,即便咱們修改爲 100m 的話,也難以保證遞歸時必定不會超出棧的深度。this
那該怎麼辦呢,有木有其餘辦法能夠解決這個問題呢?在想其餘辦法以前,咱們先思考下問題的根源在那裏。idea
每次遞歸時,棧都會給遞歸的方法分配內存,遞歸深度越深,方法就會越多,內存分配就會越多,並且遞歸執行的時候,是遞歸到最後一層的時候,遞歸纔會真正執行,也就是說在沒有遞歸到最後一層時,全部被分配的遞歸方法都沒法執行,全部棧內存也都沒法被釋放,這樣就致使棧的內存很快被消耗完,咱們畫一個圖簡單釋義一下:
咱們知道了問題根源後,忽然發現有一種技術很適合解決這種問題:尾調用。
尾調用主要是用來解決遞歸時,棧溢出的問題,不須要任何改造,只須要在代碼的最後一行返回無任何計算的遞歸代碼,編譯器就會自動進行優化,好比以前寫的遞歸代碼,咱們修改爲以下便可:
public static BigDecimal recursion1(long begin, long end, BigDecimal total) { if (begin == end) { return total; } ++begin; total = total.multiply(new BigDecimal(begin)); return recursion1(begin, end, total);//在方法的最後直接返回,叫作尾調用 }
上面代碼方法的最後一行直接返回遞歸的代碼,而且沒有任何計算邏輯,這樣子編譯器會自動識別,並解決棧溢出的問題。
但 Java 是不支持的,只有 C 語言才支持!!!
但咱們立馬又想到了 Java 8 中的新技術能夠解決這個問題:Lambda。
首先咱們必須先介紹一下 Lambda 的特性,Lambda 的方法分爲兩種,懶方法和急方法,網上通俗的說明是懶方法是不會執行的,只有急方法纔會執行,本文用到的特性就是懶方法不執行,懶方法不執行的潛在含義是:方法只是申明出來了,棧不會給方法分配內存,若是用到遞歸上,那麼無論遞歸多少次,棧只會給每一個遞歸遞歸分配一個 Lambda 包裝的遞歸方法聲明變量而已,並不會給遞歸方法分配內存。
咱們畫一張圖釋義一下:
接着咱們代碼實現如下:
// 尾調用的接口,定義了是否完成,執行等方法 public interface TailRecursion<T> { TailRecursion<T> apply(); default Boolean isComplete() { return Boolean.FALSE; } default T getResult() { throw new RuntimeException("遞歸尚未結束,暫時得不到結果"); } default T invoke() { return Stream.iterate(this, TailRecursion::apply) .filter(TailRecursion::isComplete) .findFirst() .get()//執行急方法 .getResult(); } }
public class TestDTO { private Long begin; private Long end; private BigDecimal total; } public static TailRecursion<BigDecimal> recursion1(TestDTO testDTO) { // 若是已經遞歸到最後一個數字了,結束遞歸,返回 testDTO.getTotal() 值 if (testDTO.getBegin().equals(testDTO.getEnd())) { return TailRecursionCall.done(testDTO.getTotal()); } testDTO.setBegin(1+testDTO.getBegin()); // 計算本次遞歸的值 testDTO.setTotal(testDTO.getTotal().multiply(new BigDecimal(testDTO.getBegin()))); // 這裏是最大的不一樣,這裏每次調用遞歸方法時,使用的是 Lambda 的方式,這樣只是初始化了一個 Lambda 變量而已,recursion1 方法的內存是不會分配的 return TailRecursionCall.call(()->recursion1(testDTO)); }
public void testRecursion1(){ TestDTO testDTO = new TestDTO(); testDTO.setBegin(1L); testDTO.setEnd(9000L); testDTO.setTotal(BigDecimal.ONE); log.info("計算 9k 的階乘,結果爲{}",recursion1(testDTO).invoke()); }
最終運行的結果以下:
從運行結果能夠看出,雖然棧的大小隻有 200k,但利用 Lambda 懶加載的特性,卻能輕鬆的執行 9000 次遞歸。
咱們寫遞歸的時候,最擔憂的就是遞歸深度過深,致使棧溢出,而使用 Lambda 尾調用的機制卻能夠完美解決這個問題,因此趕忙用起來吧。