JVM stack depth: JVM internal vs C++ calling through JNI

126 Views Asked by At

Before you read too far, my original thoughts were incorrect. But the investigation is interesting.

Given a simple program in Java to measure available stack depth:

static int maxDepth = 0;

private static void foo(int depth) {
    maxDepth = Math.max(maxDepth, depth);
    foo(depth + 1);
}

public static void main(String[] args) throws Exception {
    try {
        foo(0);
    } catch (Throwable t) {
        System.out.println("Depth=" + maxDepth);
    }
}

I get a max depth of about 20000 using the default 2MB stack of Java 17.

However, calling foo() from C++ using JNI, with a 2MB native stack, results in a max depth of about 400. Can anyone explain the discrepancy? Does the JVM somehow uses larger stack frames in this case, or is the available stack size reduced, or something else?

Our C++ to Java bridge uses a large codegen tool and library because raw JNI calls are incredibly tedious. It is a bit hard to boil down to a simple example, but ultimately the call is made through the JNI Env like:

cls = env->FindClass(className);
mid = mid = env->GetStaticMethodID(cls, "foo", signature);
va_list args;
va_start(args, env);
env->CallStaticVoidMethodV(cls, mid, args);

This is an interesting discussion about the native stack

After building a native standalone application, I determined that the issue was with our larger application code, not what I thought was happening. However, along the way I made some interesting observations:

OBSERVATION 1: The "native stack" as discussed in the link above, used when a JVM calls native code, has absolutely nothing to do with this. The stack in use is the stack created by the native EXE on startup. On Windows/Visual C++ this is set by the "reserve stack size" option in the linker. On Linux/g++ I am unsure of the argument, but it is discussed here. The larger the stack size, the deeper the available recursion depth. This is seen in the test() call.

OBSERVATION 2: As noted by @apangin, the JIT compiler does have an influence on the max stack depth. This influence can be worked around by running the test twice, the second run will use the already-compiled code.

OBSERVATION 3: If the embedded JVM makes a thread, its stack size is equal to the default native stack size (at least on Windows). In other words, increasing the "reserve stack size" on the Windows linker will also change the stack size used by new JVM threads. This is seen in the attached test testThread() call.

SAMPLE CODE: Java

package jvmtest;

public class Test1 {
  private int maxDepth;

  private void foo(int depth) {
    maxDepth = Math.max(maxDepth, depth);
    foo(depth + 1);
  }

  int test() {
    maxDepth = 0;
    try {
      foo(0);
    } catch (Throwable ex) {}
    return maxDepth;
  }

  int testThread() {
    maxDepth = 0;
    Thread t = new Thread(() -> test());
    t.start();
    try {
      t.join();
    } catch (Exception ex) {}
    return maxDepth;
  }

  public static void main(String[] args) throws Exception {
    Test1 t = new Test1();
    System.out.println("max depth=" + t.test());
    System.out.println("max depth=" + t.test());
  }
}

C++

#include <cstdlib>
#include <jni.h>
#include <cstring>
#include <iostream>
#define CLEAR(x) std::memset(&x, 0, sizeof(x))

// Set to your jar location
#define JAR_PATH "f:/temp/scratch/target/test-1.0.0-SNAPSHOT.jar";

int main()
{
  // Create the JVM
  JavaVMInitArgs vm_args;
  CLEAR(vm_args);
  JavaVMOption options[2];
  CLEAR(options);
  options[0].optionString = (char*)"-Djava.class.path=" JAR_PATH;
  vm_args.version = JNI_VERSION_1_6;
  vm_args.options = options;
  vm_args.nOptions = 1;
  JNIEnv* env = nullptr;
  JavaVM* vm = nullptr;
  jint rv = JNI_CreateJavaVM(&vm, (void**)&env, &vm_args);
  if (rv != 0) {
    std::cout << "JNI_CreateJavaVM failed with error " << rv << "\n";
    ::exit(1);
  }

  // Find our test
  jclass clazz = env->FindClass("jvmtest/Test1");
  if (clazz == 0) {
    std::cout << "failed to load class\n";
    ::exit(1);
  }
  jmethodID mid = env->GetMethodID(clazz, "test", "()I");
  jmethodID midThread = env->GetMethodID(clazz, "testThread", "()I");
  jmethodID constructor = env->GetMethodID(clazz, "<init>", "()V");
  if (mid == 0 || constructor == 0) {
    std::cout << "failed to find method\n";
    ::exit(1);
  }

  // Make test instance
  auto instance = env->NewObject(clazz, constructor);
  jint result;
  // Call method using JVM thread's stack
  result = env->CallIntMethod(instance, midThread);
  std::cout << "JVM max depth=" << result << "\n";
  result = env->CallIntMethod(instance, midThread);
  std::cout << "JVM max depth=" << result << "\n";
  // Call method using native stack
  result = env->CallIntMethod(instance, mid);
  std::cout << "native max depth=" << result << "\n";
  result = env->CallIntMethod(instance, mid);
  std::cout << "native max depth=" << result << "\n";

}

When I run the example, my output is

JVM max depth=27172
JVM max depth=62489
native max depth=62477
native max depth=62477

You can see the effects of JIT, as the second call has a deeper stack than the first. You can also see that the stack depth available in a thread spawned by the JVM is the same as the EXE's main() thread.

1

There are 1 best solutions below

0
Wheezil On

The answer is that I was incorrect -- the stack depth available from the native code calling through JNI is the same as the JVM's own stack depth. However, please see my observations in the question description for some details about how this works.