1. 程式人生 > >深度遞迴必須知道的尾呼叫(Lambda)

深度遞迴必須知道的尾呼叫(Lambda)

引導語

本文從一個遞迴棧溢位說起,像大家介紹一下如何使用尾呼叫解決這個問題,以及尾呼叫的原理,最後還提供一個解決方案的工具類,大家可以在工作中放心用起來。

遞迴-發現棧溢位

現在我們有個需求,需要計算任意值階乘的結果,階乘我們用 n!表示,它的計算公式是:n! = 123……(n-1)n,比如說 3 的階乘就是 123。

對於這個問題,我們首先想到的應該就是遞迴,我們立馬寫了一個簡單的遞迴程式碼:

// 階乘計算
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)));
}

遞迴程式碼很簡單,我們寫了一個簡單的測試,如下:

 @Test
 public void testRecursion() {
   log.info("計算 10 的階乘,結果為{}",recursion(1, 10, BigDecimal.ONE));
 }

執行結果很快就出來了,結果為:3628800,是正確的。

因為需求是能夠計算任意值,接著我們把 10 換成 9000,來計算一下 9000 的階乘,可這時卻突然報錯了,報錯的資訊如下:

StackOverflowError 是棧溢位的異常,jvm 給棧分配的大小是固定的,方法本身的定義、入參、方法裡的區域性變數這些都會佔記憶體,隨著遞迴不斷進行,遞迴的方法就會越來越多,每個方法都能從棧中得到記憶體,漸漸的,棧的記憶體就不夠了,報了這個異常。

我們首先想到的辦法是如何讓棧的記憶體大一點呢?JVM 有個引數叫做 -Xss,這個引數就規定了要分配給棧多少大小的記憶體,於是我們在 idea 裡面配置一下 Xss 的引數,配置如下:

圖中我們給棧分配 200k 大小記憶體,再次執行仍然報錯,說明我們分配的棧還是太小了,於是我們修改 Xss 值到 100M 試一下,配置如下:

再次執行,成功了,執行結果如下:

雖然通過修改棧的大小暫時解決了這個問題,但這種解決方案在線上是完全行不通的,主要問題如下:

  1. 我們不可能修改線上棧的大小,一般來說,線上棧的大小一般都是 256k,不可能為了一個遞迴程式把棧大小修改成很大。

  2. 因為我們需要計算任意值的階乘,所以棧的大小是動態的,即使我們修改成 100m 的話,也難以保證遞迴時一定不會超出棧的深度。

那該怎麼辦呢,有木有其他辦法可以解決這個問題呢?在想其他辦法之前,我們先思考下問題的根源在那裡。

每次遞迴時,棧都會給遞迴的方法分配記憶體,遞迴深度越深,方法就會越多,記憶體分配就會越多,而且遞迴執行的時候,是遞迴到最後一層的時候,遞迴才會真正執行,也就是說在沒有遞迴到最後一層時,所有被分配的遞迴方法都無法執行,所有棧記憶體也都無法被釋放,這樣就導致棧的記憶體很快被消耗完,我們畫一個圖簡單釋義一下:

我們知道了問題根源後,突然發現有一種技術很適合解決這種問題:尾呼叫。

尾呼叫

尾呼叫主要是用來解決遞迴時,棧溢位的問題,不需要任何改造,只需要在程式碼的最後一行返回無任何計算的遞迴程式碼,編譯器就會自動進行優化,比如之前寫的遞迴程式碼,我們修改成如下即可:

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 的方法分為兩種,懶方法和急方法,網上通俗的說明是懶方法是不會執行的,只有急方法才會執行,本文用到的特性就是懶方法不執行,懶方法不執行的潛在含義是:方法只是申明出來了,棧不會給方法分配記憶體,如果用到遞迴上,那麼不管遞迴多少次,棧只會給每個遞迴遞迴分配一個 Lambda 包裝的遞迴方法宣告變數而已,並不會給遞迴方法分配記憶體。

我們畫一張圖釋義一下:

接著我們程式碼實現以下:

  1. 首先我們實現了一個尾呼叫的介面,方便大家使用:
// 尾呼叫的介面,定義了是否完成,執行等方法
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();
  }
}
  1. 接著實現了利用這個介面實現 9k 的階乘,程式碼如下:
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));
}
  1. 最後我們寫了一個測試方法,我們把棧的大小設定成 200k,測試程式碼如下:
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 尾呼叫的機制卻可以完美解決這個問題,所以趕緊用起來吧。

部落格主頁

新課:面試官系統精講Java原始碼及大廠真題

新課:跟我一起學 D