"The JVM doesn't support tail-call optimization, so I predict lots of exploding stacks"
Anyone who says this either (1) doesn't understand tail-call optimization, or (2) doesn't understand the JVM, or (3) both.
I'll start with the definition of tail-calls from Wikipedia (if you don't like Wikipedia, here's an alternative):
In computer science, a tail call is a subroutine call that happens inside another procedure as its final action; it may produce a return value which is then immediately returned by the calling procedure
In the code below, the call to bar()
is the tail call of foo()
:
private void foo() {
// do something
bar()
}
Tail call optimization happens when the language implementation, seeing a tail call, doesn't use normal method invocation (which creates a stack frame), but instead creates a branch. This is an optimization because a stack frame requires memory, and it requires CPU cycles to push information (such as the return address) onto the frame, and because the call/return pair is assumed to require more CPU cycles than an unconditional jump.
TCO is often applied to recursion, but that's not its only use. Nor is it applicable to all recursions. The simple recursive code to compute a factorial, for example, cannot be tail-call optimized, because the last thing that happens in the function is a multiplication operation.
public static int fact(int n) {
if (n <= 1) return 1;
else return n * fact(n - 1);
}
In order to implement tail call optimization, you need two things:
- A platform that supports branching in addition to subtroutine calls.
- A static analyzer that can determine whether tail call optimization is possible.
That's it. As I've noted elsewhere, the JVM (like any other Turing-complete architecture) has a goto. It happens to have an unconditional goto, but the functionality could easily be implemented using a conditional branch.
The static analysis piece is what's tricky. Within a single function, it's no problem. For example, here's a tail-recursive Scala function to sum the values in a List
:
def sum(acc:Int, list:List[Int]) : Int = {
if (list.isEmpty) acc
else sum(acc + list.head, list.tail)
}
This function turns into the following bytecode:
public int sum(int, scala.collection.immutable.List);
Code:
0: aload_2
1: invokevirtual #63; //Method scala/collection/immutable/List.isEmpty:()Z
4: ifeq 9
7: iload_1
8: ireturn
9: iload_1
10: aload_2
11: invokevirtual #67; //Method scala/collection/immutable/List.head:()Ljava/lang/Object;
14: invokestatic #73; //Method scala/runtime/BoxesRunTime.unboxToInt:(Ljava/lang/Object;)I
17: iadd
18: aload_2
19: invokevirtual #76; //Method scala/collection/immutable/List.tail:()Ljava/lang/Object;
22: checkcast #59; //class scala/collection/immutable/List
25: astore_2
26: istore_1
27: goto 0
Note the goto 0
at the end. By comparison, an equivalent Java function (which must use an Iterator
to imitate the behavior of breaking a Scala list into head and tail) turns into the following bytecode. Note that the last two operations are now an invoke, followed by an explicit return of the value produced by that recursive invocation.
public static int sum(int, java.util.Iterator);
Code:
0: aload_1
1: invokeinterface #64, 1; //InterfaceMethod java/util/Iterator.hasNext:()Z
6: ifne 11
9: iload_0
10: ireturn
11: iload_0
12: aload_1
13: invokeinterface #70, 1; //InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
18: checkcast #25; //class java/lang/Integer
21: invokevirtual #74; //Method java/lang/Integer.intValue:()I
24: iadd
25: aload_1
26: invokestatic #43; //Method sum:(ILjava/util/Iterator;)I
29: ireturn
Tail call optimization of a single function is trivial: the compiler can see that there is no code that uses the result of the call, so it can replace the invoke with a goto
.
Where life gets tricky is if you have multiple methods. The JVM's branching instructions, unlike those of a general-purpose processor such as the 80x86, are confined to a single method. It's still relatively straightforward if you have private methods: the compiler is free to inline those methods as appropriate, so can optimize tail calls (if you're wondering how this might work, consider a common method that uses a switch
to control behavior). You can even extend this technique to multiple public methods in the same class: the compiler inlines the method bodies, provides public bridge methods, and internal calls turn into jumps.
But, this model breaks down when you consider public methods in different classes, particularly in light of interfaces and classloaders. The source-level compiler simply does not have enough knowledge to implement tail-call optimizations. However, unlike "bare-metal" implementations, the *JVM( does have the information to do this, in the form of the Hotspot compiler (at least, the ex-Sun compiler does). I don't know whether it actually performs tail-call optimizations, and suspect not, but it could.
Which brings me to the second part of your question, which I'll rephrase as "should we care?"
Clearly, if your language uses recursion as its sole primitive for iteration, you care. But, languages that need this feature can implement it; the only issue is whether a compiler for said language can produce a class that can call and be called by an arbitrary Java class.
Outside of that case, I'm going to invite downvotes by saying that it's irrelevant. Most of the recursive code that I've seen (and I've worked with a lot of graph projects) is not tail-call optimizable. Like the simple factorial, it uses recursion to build state, and the tail operation is combination.
For code that is tail-call optimizable, it's often straightforward to translate that code into an iterable form. For example, that sum()
function that I showed earlier can be generalized as foldLeft()
. If you look at the source, you'll see that it's actually implemented as an iterative operation. Jörg W Mittag had an example of a state machine implemented via function calls; there are lots of efficient (and maintainable) state machine implementations that do not rely on function calls being translated into jumps.
I'll finish with something completely different. If you Google your way from footnotes in the SICP, you might end up here. I personally find that a much more interesting place than having my compiler replace JSR
by JUMP
.
GOTO
, the JVM doesn't. And x86 isn't used as an interop platform. The JVM doesn't haveGOTO
and one of the main reasons for choosing the Java Platform is interop. If you want to implement TCO on the JVM, you have to do something to the stack. Manage it yourself (i.e. don't use the JVM call stack at all), use trampolines, use exceptions asGOTO
, something like that. In all of those cases, you become incompatible with the JVM call stack. It's impossible to be stack-compatible with Java, have TCO, and high performance. You have to sacrifice one of those three.