關於Java中尾遞歸的優化


    最近總有人問我,Java SE8里有沒有針對尾調用做優化(這是一種特殊的函數調用)。這個優化和遞歸調用密切相關,而遞歸調用對函數式語言來說尤其重要,因為它們通常都基於遞歸來進行設計編碼。本文會介紹到什么是尾調用,怎樣可以對它進行有效的優化,以及Java 8在這方面是如何做的。

在深入這個話題之前,我們先來了解下什么是尾調用。

什么是尾調用?
尾調用指的是一個方法或者函數的調用在另一個方法或者函數的最后一條指令中進行(為了簡單,后面我就都稱作函數調用了)。Andrew Koenig在他的博客中有關於這個話題的介紹。下面定義了一個foo()函數作為例子:

int foo(int a) {
  a = a + 1;
  return func(a);
}

func()函數的調用是foo()函數的最后一條語句,因此這是一個尾調用。如果在調用了func()之后,foo()函數還執行了別的指令才返回的話,那么func()就不再是一次尾調用了。

你可能在想這些語義有什么意義。如果尾調用不是一個遞歸的函數調用的話,這些概念確實不重要。比如:

int fact(int i, int acc) {
  if ( i == 0 ) 
    return acc;
  else
    return fact( i – 1, acc * i);
}
 
int factorial(int n) {
  if ( n == 0 )
    return 1;
  else
    return fact( n – 1, 1 );
}

 

尾調用的優化
任何的尾調用,不只是尾遞歸,函數調用本身都可以被優化掉,變得跟goto操作一樣。這就意味着,在函數調用前先把棧給設置好,調用完成后再恢復棧的這個操作(分別是prolog和epilog)可以被優化掉。比如說下面的這段代碼:

int func_a(int data) {
  data = do_this(data);
  return do_that(data);
}

函數do_that()是一個尾調用。沒有優化過的匯編代碼看起來大概是這樣的:

...     ! executing inside func_a()
push EIP    ! push current instruction pointer on stack
push data   ! push variable 'data' on the stack
jmp do_this ! call do_this() by jumping to its address
...     ! executing inside do_this()
push EIP    ! push current instruction pointer on stack
push data   ! push variable 'data' on the stack
jmp do_that ! call do_that() by jumping to its address
...     ! executing inside do_that()
pop data    ! prepare to return value of 'data'
pop EIP ! return to do_this()
pop data    ! prepare to return value of 'data'
pop EIP ! return to func_a()
pop data    ! prepare to return value of 'data'
pop EIP ! return to func_a() caller
...

注意到對於數據以及EIP寄存器(用來返回數據並且恢復指令指針),POP指令被連續執行了多次。可以通過一個簡單的JMP指令將一組關聯的epilog和prolog操作(將數據和EIP進行壓棧)優化掉。這是因為dothat()函數會替funca函數執行這段epilog代碼。

這個優化是安全的,因為funca函數在dothat()返回后就不再執行別的指令了。(注意:如果要對dothat()返回的值進行處理的話,就不能執行這個優化,因為dothat()就不再是一個尾調用了)。

  優化前的代碼 優化后的代碼
int func_a(int data) {    
data = do_this(data); push EIP ! prolog
push data ! prolog
jmp do_this ! call
...
pop data ! epilog
pop EIP ! epilog
push EIP ! prolog
push data ! prolog
jmp do_this ! call
...
pop data ! epilog
pop EIP ! epilog
return do_that(data); push EIP ! prolog
push data ! prolog
jmp do_that! call
...
pop data ! epilog
pop EIP ! epilog
jmp do_that ! goto ...
} pop data ! epilog
pop EIP ! epilog

pop data ! epilog
pop EIP ! epilog

 

 

 

 

 

 

 

 

 

 

 

比較一下第三行,你就可以看到節省了多少行機器代碼。當然光這個而言並不能代表這個優化的真正價值。如果和遞歸結合到一起的話,這個優化的價值就顯現出來了。

如果你再看下上面的那個遞歸的階乘函數,你會發現如果使用了這個優化的話,所有原來不斷重復的prolog和epilog匯編代碼現在都消失了。最終把下面的遞歸偽代碼:

call factorial(3)
  call fact(3,1)
    call fact(2,3)
      call fact(1 6)
        call fact(0,6)
        return 6
      return 6
    return 6
  return 6
return 6

變成這個迭代式的偽代碼:

call factorial(3)
  call fact(3,1)
  update variables with (2,3)
  update variables with (1,6)
  update variables with (0,6)
  return 6
return 6

也就是說,它用一個循環替換掉了遞歸,大多數C/C++,Java程序員也正是這么干的。這個優化不止是減少了大量的遞歸函數調用帶來的prolog和epilog,它還極大的減輕了棧的壓力。不作這個優化的話,計算一個很大的數的階乘很可能會讓棧溢出——也就是,用光了所有分配給棧的內存。把代碼優化成循環后就消除了這個問題。由於函數式語言的開發人員經常使用遞歸,所以大多數函數式語言的解釋器都會進行尾調用的優化。

Java是怎么做的?
正如我前面提到的,Java程序員一般都使用循環,而盡量不用遞歸。比如,下面是一個迭代式的階乘的實現:

int factorial(int n) {
   int result = 1;
   for (int t=n; t > 1; t--)
       result *= t;
   return result;
 }

因此對大多數Java開發人員來說,Java不進行尾調用優化並不是什么大問題。不過我猜或許很多Java程序員都沒有聽說過尾調用優化這回事。不過當你在JVM上運行函數式語言的話,這就成為一個問題了。遞歸的代碼在自己語言的解釋器里運行得好好的,可能一到JVM上就突然把棧給用完了。

值得注意的是,這並不是JVM的BUG。這個優化對經常使用遞歸的函數式語言的開發人員來說非常有用。我最近經常和Oracle的Brian Goetz提到這個優化,他總是說這個在JVM的開發計划表上,不過這不是一個優先級特別高的事。就目前來說,你最好自己去做優化,當你使用函數式語言在JVM上進行開發的時候,盡管避免使用過深的遞歸調用。

文章來自於:https://blog.csdn.net/azhegps/article/details/72638906

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM