Why is the max recursion depth I can reach non-deterministic?
Asked Answered
P

4

33

I decided to try a few experiments to see what I could discover about the size of stack frames, and how far through the stack the currently executing code was. There are two interesting questions we might investigate here:

  1. How many levels deep into the stack is the current code?
  2. How many levels of recursion can the current method reach before it hits a StackOverflowError?

Stack depth of currently executing code

Here's the best I could come up with for this:

public static int levelsDeep() {
    try {
        throw new SomeKindOfException();
    } catch (SomeKindOfException e) {
        return e.getStackTrace().length;
    }
}

This seems a bit hacky. It generates and catches an exception, and then looks to see what the length of the stack trace is.

Unfortunately it also seems to have a fatal limitation, which is that the maximum length of the stack trace returned is 1024. Anything beyond that gets axed, so the maximum that this method can return is 1024.

Question:

Is there a better way of doing this that isn't so hacky and doesn't have this limitation?

For what it's worth, my guess is that there isn't: Throwable.getStackTraceDepth() is a native call, which suggests (but doesn't prove) that it can't be done in pure Java.

Determining how much more recursion depth we have left

The number of levels we can reach will be determined by (a) size of stack frame, and (b) amount of stack left. Let's not worry about size of stack frame, and just see how many levels we can reach before we hit a StackOverflowError.

Here's my code for doing this:

public static int stackLeft() {
    try {
        return 1+stackLeft();
    } catch (StackOverflowError e) {
        return 0;
    }
}

It does its job admirably, even if it's linear in the amount of stack remaining. But here is the very, very weird part. On 64-bit Java 7 (OpenJDK 1.7.0_65), the results are perfectly consistent: 9,923, on my machine (Ubuntu 14.04 64-bit). But Oracle's Java 8 (1.8.0_25) gives me non-deterministic results: I get a recorded depth of anywhere between about 18,500 and 20,700.

Now why on earth would it be non-deterministic? There's supposed to be a fixed stack size, isn't there? And all of the code looks deterministic to me.

I wondered whether it was something weird with the error trapping, so I tried this instead:

public static long badSum(int n) {
    if (n==0)
        return 0;
    else
        return 1+badSum(n-1);
}

Clearly this will either return the input it was given, or overflow.

Again, the results I get are non-deterministic on Java 8. If I call badSum(14500), it will give me a StackOverflowError about half the time, and return 14500 the other half. but on Java 7 OpenJDK, it's consistent: badSum(9160) completes fine, and badSum(9161) overflows.

Question:

Why is the maximum recursion depth non-deterministic on Oracle's Java 8? And why is it deterministic on OpenJDK 7?
Paeon answered 20/11, 2014 at 15:56 Comment(3)
Related discussion for Java 7: #5166253 I know you are asking of Java 8, but just for the sake of lost visitors.Bernadette
Just an observation: -Xint doesn't make it deterministic, so JIT compilation kicking in at some point doesn't seem to be a possibility. When repeatedly probing the max stack depth on the same VM instance (on the same main thread), I do get a constant max value; next run of the VM (same args ofc) I do get a slightly different max. (Oracle JDK 1.8.0_25)Misprision
Any chance of persuading @BrianGoetz to comment on the non-determinism introduced in Java 8? Does the new JVM have ASLR, Brian?Paeon
I
16

The observed behavior is affected by the HotSpot optimizer, however it is not the only cause. When I run the following code

public static void main(String[] argv) {
    System.out.println(System.getProperty("java.version"));
    System.out.println(countDepth());
    System.out.println(countDepth());
    System.out.println(countDepth());
    System.out.println(countDepth());
    System.out.println(countDepth());
    System.out.println(countDepth());
    System.out.println(countDepth());
}
static int countDepth() {
    try { return 1+countDepth(); }
    catch(StackOverflowError err) { return 0; }
}

with JIT enabled, I get results like:

> f:\Software\jdk1.8.0_40beta02\bin\java -Xss68k -server -cp build\classes X
1.8.0_40-ea
2097
4195
4195
4195
12587
12587
12587

> f:\Software\jdk1.8.0_40beta02\bin\java -Xss68k -server -cp build\classes X
1.8.0_40-ea
2095
4193
4193
4193
12579
12579
12579

> f:\Software\jdk1.8.0_40beta02\bin\java -Xss68k -server -cp build\classes X
1.8.0_40-ea
2087
4177
4177
12529
12529
12529
12529

Here, the effect of the JIT is clearly visible, obviously the optimized code needs less stack space, and it’s shown that tiered compilation is enabled (indeed, using -XX:-TieredCompilation shows a single jump if the program runs long enough).

In contrast, with disabled JIT I get the following results:

> f:\Software\jdk1.8.0_40beta02\bin\java -Xss68k -server -Xint -cp build\classes X
1.8.0_40-ea
2104
2104
2104
2104
2104
2104
2104

> f:\Software\jdk1.8.0_40beta02\bin\java -Xss68k -server -Xint -cp build\classes X
1.8.0_40-ea
2076
2076
2076
2076
2076
2076
2076

> f:\Software\jdk1.8.0_40beta02\bin\java -Xss68k -server -Xint -cp build\classes X
1.8.0_40-ea
2105
2105
2105
2105
2105
2105
2105

The values still vary, but not within the single runtime thread and with a lesser magnitude.

So, there is a (rather small) difference that becomes much larger if the optimizer can reduce the stack space required per method invocation, e.g. due to inlining.

What can cause such a difference? I don’t know how this JVM does it but one scenario could be that the way a stack limit is enforced requires a certain alignment of the stack end address (e.g. matching memory page sizes) while the memory allocation returns memory with a start address that has a weaker alignment guaranty. Combine such a scenario with ASLR and there might be always a difference, within the size of the alignment requirement.

Infamy answered 20/11, 2014 at 19:22 Comment(2)
Yes, I wondered about ASLR. I can't find anything anywhere that says it's not in Java 7 but is in Java 8, though.Paeon
ASLR is mainly implemented by the operating system. So I would expect the start address of the stack to be different in Java 7 as well. So there has to be a change regarding size or end address. It could be possible that there is a size randomization added in Java 8 to make StackOverflows intentionally less predictable to make exploiting bugs harder.Infamy
P
1
Why is the maximum recursion depth non-deterministic on Oracle's Java 8? And why is it deterministic on OpenJDK 7?

About that, possibly relates to changes in garbage collection. Java can choose a different mode for gc each time. http://vaskoz.wordpress.com/2013/08/23/java-8-garbage-collectors/

Peer answered 20/11, 2014 at 16:12 Comment(2)
Where is the relevance in the linked blog post?Misprision
Lists the different gcs in java 8Tatum
B
1

It's deprecated, but you could try Thread.countStackFrames() like

public static int levelsDeep() {
    return Thread.currentThread().countStackFrames();       
}

Per the Javadoc,

Deprecated. The definition of this call depends on suspend(), which is deprecated. Further, the results of this call were never well-defined.

Counts the number of stack frames in this thread. The thread must be suspended.

As for why you observe non-deterministic behaviour, I can only assume it is some combination of the JIT and garbage collector.

Behind answered 20/11, 2014 at 16:14 Comment(1)
But why would the garbage collector affect the amount of stack space on the current thread? Isn't it run on its own thread? I did wonder about this, so I coded it to avoid any object creation.Paeon
C
1

You don't need to catch the exception, just create one like this:

new Throwable().getStackTrace()

Or:

Thread.currentThread().getStackTrace()

It's still hacky as the result is JVM implementation specific. And JVM may decide to trim the result for better performance.

Cherin answered 19/12, 2014 at 9:33 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.