Skip to content

Latest commit

 

History

History
executable file
·
593 lines (436 loc) · 31.3 KB

README.md

File metadata and controls

executable file
·
593 lines (436 loc) · 31.3 KB

JNI Bind

Ubuntu Build

JNI Bind is a new metaprogramming library that provides syntactic sugar for C++ => Java/Kotlin. It is header only and provides sophisticated type conversion with compile time validation of method calls and field accesses.

It requires clang enabled at C++17 or later, is compatible with Android, and is unit / E2E tested on x86/ARM toolchains.

Many features and optimisations are included:

  • Object Management
  • Compile time method name and argument checking
  • Static caching of IDs multi-threading support and compile time signature generation.
  • Classes native construction, argument validation, lifetime support, method and field support.
  • Classloaders native construction, object "buildability".
  • JVM Management single-process multiple JVM lifecycles.
  • Strings easy syntax for inline argument construction.
  • Arrays inline object construction for method arguments, efficient pinning of existing spans.
  • And much more!

For a quick introduction, check out the slides.

Curious to try it? Check out the Godbolt sample!

If you're enjoying JNI Bind, or just want to support it, please consider adding a GitHub ⭐️!

Table of Contents

Quick Intro

JNI is notoriously difficult to use, and numerous libraries exist to try to reduce this complexity. These libraries usually require code generation (or extensive macro use) which leads to brittle implementation, indexes poorly, and still requires domain expertise in JNI (making code difficult to maintain).

JNI Bind is header only (no auto-generation), and it generates robust, easily maintained, and expressive code. It obeys the regular RAII idioms of C++17 and can help separate JNI symbols in compilation. Classes are provided in static constexpr definitions which can be shared across different implementations enabling code re-use.

This is a sample Java class and it's correspondingJNI Bind class definition:

package com.project;
public class Clazz {
 int Foo(float f, String s) {...}
}
#include "jni_bind_release.h"

static constexpr jni::Class kClass {
  "com/project/clazz",
  jni::Method { "Foo", jni::Return<jint>{}, jni::Params<jfloat, jstring>{}
},

jni::LocalObject<kClass> obj { jobject_to_wrap };
obj("Foo", 1.5f, "argString");
// obj("Bar", 1.5, "argString");  // won't compile (good).

There are sample tests which can be a good way to see some example code. Consider starting with with context_test_jni, object_test_helper_jni.h and ContextTest.java.

Installation without Bazel

If you want to jump right in, copy jni_bind_release.h into your own project. The header itself is an automatically generated "flattened" version of the Bazel dependency set, so this documentation is a simpler introduction to JNI Bind than reading the header directly.

You are responsible for ensuring #include <jni.h> compiles when you include JNI Bind.

Installation with Bazel

If you're already using Bazel add the following to your WORKSPACE:

http_archive(
  name = "jni-bind",
  urls = ["https://github.com/google/jni-bind/archive/refs/tags/Release-1.1.2-beta.zip"],
  strip_prefix = "jni-bind-Release-1.1.2-beta",
)

Then include @jni-bind//:jni_bind (not :jni_bind_release) and #include "jni_bind.h".

JNI is sometimes difficult to get working in Bazel, so if you don't have an environment already also add this to your WORKSPACE, and follow the pattern of the BUILD target below.

# Rules Jvm.
RULES_JVM_EXTERNAL_TAG = "4.2"
RULES_JVM_EXTERNAL_SHA = "cd1a77b7b02e8e008439ca76fd34f5b07aecb8c752961f9640dea15e9e5ba1ca"

http_archive(
    name = "rules_jvm_external",
    strip_prefix = "rules_jvm_external-%s" % RULES_JVM_EXTERNAL_TAG,
    sha256 = RULES_JVM_EXTERNAL_SHA,
    url = "https://github.com/bazelbuild/rules_jvm_external/archive/%s.zip" % RULES_JVM_EXTERNAL_TAG,
)
cc_library(
    name = "Foo",
    hdrs = [
        "jni_bind_release.h",
    ],
    srcs = [
        "Foo.cc",
        "@local_jdk//:jni_header",
        "@local_jdk//:jni_md_header-linux",
    ],
    includes = [
        "external/local_jdk/include",
        "external/local_jdk/include/linux",
    ],
)

There are easy to lift samples in javatests/com/jnibind/test/. If you want try building these samples (or to copy the BUILD configuration) you can clone this repo.

cd ~
git clone https://github.com/google/jni-bind.git
cd jni-bind
bazel test  --cxxopt='-std=c++17' --repo_env=CC=clang ...

Usage

JVM Lifecycle

JNI Bind requires some minor bookkeeping in order to ensure acccess to a valid JNIEnv*. To do this, create a jni::JvmRef whose lifetime extends past any JNI Bind call (*a function local static is a reasonable way to do this, although sanitizers will flag this as a memory leak, so all tests explicitly manage the lifetime of the jni::jvmRef).

The simplest way to from the JavaVM* in your JNI_OnLoad call. This object's lifetime must outlive all JNI calls.

JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* pjvm, void* reserved) {
  static auto jvm{std::make_unique<jni::JvmRef<jni::kDefaultJvm>>(pjvm)};
  return JNI_VERSION_1_6;
}

⚠️ If you using another JNI Library initialise JNI Bind after. JNI Bind "attaches" the thread explicitly through JNI, and some libraries behaviour will be conditional on this being unset. If JNI Bind discovers a thread has been attached previously it will not attempt to tear the thread down on teardown.

You can also build a jni::jvmRef from any JNIEnv*.

Classes

Class definitions are the basic mechanism by which you interact with Java through JNI:

static constexpr jni::Class kClass{"com/full/class/name/JavaClassName", jni::Field..., jni::Method... };

jni::Class definitions are static (the class names of any Java object is known in advance). Instances of these classes are created at runtime using jni::LocalObject or jni::GlobalObject.

Local and Global Objects

Local and global objects manage lifetimes of underlying jobjects using the normal RAII mechanism of C++. jni::LocalObject always fall off scope at the conclusion of the surrounding JNI call and are valid only to a single thread, however jni::GlobalObject may be held indefinitely and are thread safe.

jni::LocalObject is built by either wrapping a jobject passed to native JNI from Java, or constructing a new object from native (see Constructors).

When jni::LocalObject or jni::GlobalObject falls off scope, it will unpin the underlying jobject, making it available for garbage collection by the JVM. If you want to to prevent this, call Release(). This is useful to return a jobject back from native, or to simply pass to another native component that isn't JNI Bind aware. Calling methods for a released object is undefined.

When possible try to avoid using raw jobject. Managing lifetimes with regular JNI is difficult, e.g. jobject can mean either local or global object (the former will be automatically unpinned at the end of the JNI call, but the latter won't and must be deleted exactly once).

Because jobject does not uniquely identify its underlying storage, it is presumed to always be local. If you want to build a global, you must use either jni::PromoteToGlobal or jni::AdoptGlobal. e.g.

jobject obj1, obj2, obj3;
jni::LocalObject local_obj {obj1}; // Fine, given local semantics.
// jni::GlobalObject global_obj {obj2}; // Illegal, obj2 could be local or global.
jni::GlobalObject global_obj_1 {PromoteToGlobal{}, obj2}; // obj2 will be promoted.
jni::GlobalObject global_obj_2 {AdoptGlobal{}, obj3}; // obj3 will *not* be promoted.

When using a jobject you may add NewRef{} which creates a new local reference, or AdoptLocal{} which takes full ownership.

Because JNI objects passed to native should never be deleted, NewRef is used by default (so that LocalObject may always call delete). A non-deleting FastLocal that does not delete may be added in the future. In general you shouldn't need to worry about this.

Sample C++, Sample Java

Methods

A jni::Method is described as a method name, a jni::Return, and an optional jni::Params containing a variadic pack of zero values of the desired type. If a class definition contains jni::Methods in its definition then corresponding objects constructed will be imbued with operator(). Using this operator with the corresponding method name will invoke the corresponding method.

static constexpr jni::Class kClass {
   "com/project/kClass",
   jni::Method{"intMethod", jni::Return{jint{}},
   jni::Method {"floatMethod", jni::Return{jfloat{}}, jni::Params{ jint{}, jfloat{} }},
   jni::Method {"classMethod", jni::Return{kClassFromOtherHeader} }
};

jni::LocalObject<kClass> runtime_object{jobj};
int int_val = runtime_object("intMethod");
float float_val = runtime_object("floatMethod");
jni::LocalObject<kClassFromOtherHeader> class_val { runtime_object("classMethod") };

Methods will follow the rules laid out in Type Conversion Rules. Invalid method names won't compile, and jmethodIDs are cached on your behalf. Method lookups are compile time, there is no hash lookup cost.

Sample C++, Sample Java

Fields

A jni::Field is described as a field name and a zero value of the underlying type. If a class definition contains jni::Fields in its definition then corresponding objects constructed will be imbued with operator[]. Using this operator with the corresponding field name will provide a proxy object with two methods Get and Set.

static constexpr jni::Class kClass {
  "kClassName",
  jni::Field {"intField", jint{} },
};

jni::LocalObject<kClass> runtime_object{jobj};
runtime_object["intField"].Set(5);
runtime_object["intField"].Get(); // = 5;

Accessing and setting fields will follow the rules laid out in Type Conversion Rules. Accessing invalid field names won't compile, and jfieldIDs are cached on your behalf.

Sample C++, Sample Java

Constructors

If you want to create a new Java object from native code, you can define a jni::Constructor, or use the default constructor. If you omit a constructor the default constructor is called, but if any are specified you must explicitly define a no argument constructor.

static constexpr jni::Class kSomeClass{...};

static constexpr jni::Class kClass {
   "com/google/Class",
   jni::Constructor{jint{}, kSomeClass},
   jni::Constructor{jint{}},
   jni::Constructor{},
};

jni::LocalObject<kClass> obj1{123, jni::LocalObject<kSomeClass>{}};
jni::LocalObject<kClass> obj2{123};
jni::LocalObject<kClass> obj3{};  // Compiles only because of jni::Constructor{}.

Constructors follow the arguments rules laid out in Type Conversion Rules.

Sample C++.

Type Conversion Rules

All JNI types have corresponding JNI Bind types. These types are used differently depending on their context. Sometimes multiple types are valid as an argument, and you can use them interchangeably, however types are strictly enforced, so you can't pass arguments that might otherwise implictly cast.

JNI C API Jni Bind Declaration Types valid when used as arg Return Type
void
jboolean jboolean jboolean, bool jint
jint jint jint, int jint
jfloat jfloat, float jfloat jfloat
jdouble jdouble, double jdouble jdouble
jlong jlong, long jlong jlong
jobject jni::Class kClass jobject, jni::LocalObject, jni::GlobalObject1 jni::LocalObject
jstring jstring jstring, jni::LocalString, jni::GlobalString, jni::LocalString
char*, std::string
jarray jni::Array jarray, jni::LocalArray, , jlongArray jni::LocalArray
jbooleanArray, jbyteArray, jcharArray,
jfloatArray, jintArray
jobjectarray jni::Array<jobject, kClass> jarray, jni::LocalArray<jobject, kClass>, jni::LocalArray

More conversions will be added later and this table will be updated as they are. In particular wchar views into jstrings will offer a zero copy view into java strings. Also, jarray will support std::span views for its underlying types.

Advanced Usage

Strings

Strings are slightly atypical as they are a regular Java class (java/lang/String) but have a separate JNI type, jstring. They therefore have two separate JNI Bind types: jni::LocalString and jni::GlobalString.

Unlike jni::LocalObject, you must explicitly pin the underlying string data to access it (arrays also follow this paradigm). To "view" into the string, you must call Pin() which returns a jni::UtfStringView. You can call ToString() to get a std::string_view into the string.

jni::LocalString new_string{"TestString"};             // builds a new java/lang/String instance.
jni::UtfStringView utf_string_view = new_string.Pin();
std::string_view jni_string_view = utf_string_view.ToString();

jni::UtfStringView will immediately pin memory associated with the jstring, and release on leaving scope. This will always make an expensive copy, as strings are natively represented in Java as Unicode (C++20 will offer a compatible std::string_view but C++17 does not).

Sample C++, Sample Java

Forward Class Declarations

Sometimes you need to reference a class that doesn't yet have a corresponding JNI Bind definition (possibly due to a circular dependency or self reference). This can be obviated by defining the class inline.

using ::jni::Class;
using ::jni::Constructor;
using ::jni::Return;
using ::jni::Method;
using ::jni::Params;

static constexpr Class kClass {
  "com/project/kClass",
  Method{"returnsNothing", Return<void>{}, Params{Class{"kClass2"}},
  Method{"returnsKClass", Return{Class{"com/project/kClass"}},           // self referential
  Method{"returnsKClass2", Return{Class{"com/project/kClass2"}},         // undefined
};

static constexpr Class kClass2 {
  "com/project/kClass2",
  Constructor{ Class{"com/project/kClass2"} },  // self referential
  Method{"Foo", Return<void>{}}
};

LocalObject<kClass> obj1{};
obj1("returnsNothing", LocalObject<kClass2>{});        // correctly forces kClass2 for arg
LocalObject<kClass> obj2{ obj1("returnsKClass") };     // correctly forces kClass for return
LocalObject shallow_obj{} = obj1("returnsKClass");     // returns unusable but name safe kClass
// shallow_obj("Foo");                                 // this won't compile as it's only a forward decl
LocalObject<kClass> rich_obj{std::move(shallow_obj)};  // promotes the object to a usable version
LocalObject<kClass2> { obj1("returnsKClass2") };       // materialised from a temporary shallow rvalue

Note, if you use the output of a forward declaration, it will result in a shallow object. You can use this object in calls to other methods or constructors and they will validate as expected.

Sample: proxy_test.cc.

Multhreading

When using multi-threaded code in native, it's important to remember the difference between maintaining thread safety in your native code vs your Java code. JNI Bind only handles thread safety of your native handles and class ids. e.g. you might safely pass a jobject from one native thread to another (i.e. you managed to get the handle correctly to the other thread), however, your Java code may not be expecting calls from multiple threads.

To pass a jobject from one thread to another you must use jni::GlobalObject (using jni::LocalObject is undefined).

Upon spinning a new native thread (that isn't the main thread), you must declare a jni::ThreadGuard to explicitly announce to JNI the existence of this thread. It's permissible to to have nested jni::ThreadGuards.

Sample jvm_test.cc.

Caution

A note for Android multi-threaded use.

On Android JVMs, you must take an additional step to enable multi-threading. You must invoke JvmRef::SetFallbackClassLoaderFromJObject on your JvmRef object. You should call it with any jobject that has been built in Android with a default classloader (if you don't know what that is, you're probably using it).

This is necessary because new threads spun up on Android do not have a primordial loader like they do on vanilla, non-Android JVMs. When you call this function, you will capture the classloader that has loaded the jobject and save it to load classes later.

Sample thread_test_jni.cc.

Overloads

Methods can be overloaded just like in regular Java by declaring a name, and then a variadic pack of jni::Overload. Overloaded methods are invoked like regular methods. JNI Bind will correctly differentiate between functions that differ only by type, including functions that take different class types.

static constexpr Class kClass{
    "com/google/SupportsStrings",
    Method{
        "Foo",
        Overload{jni::Return<void>{}, Params{jint{}}},
        Overload{jni::Return<void>{}, Params{jstring{}}},
        Overload{jni::Return<void>{}, Params{jstring{}, jstring{}}},
    }
};

LocalObject<kClass> obj{};
obj("Foo", 1);
obj("Foo", "arg");
obj("Foo", "arg", jstring{nullptr});

Sample method_test_jni.cc, MethodTest.java.

Class Loaders

Class loaders are a powerful but complex tool that enable loading class definitions at runtime from classes not present in the primordial loader. Their scope is well in excess of this documentation, but you will need a solid understanding of their usage to deploy them successfully (check out the reference links).

To define a class loaded class, you must first define a ClassLoader:

static constexpr ClassLoader kTestClassLoader{
    kDefaultClassLoader, SupportedClassSet{kClassLoaderHelperClass}};

The first argument is the "parent" loader, which can be either kDefaultClassLoader, kNullClassLoader or a different ClassLoader instance you have defined. I recommend reading Baeldung's "Class loaders in Java", but, a rough explanation is that the DefaultLoader supports everything, the NullLoader will support nothing, and a custom loader will support its own classes. When building classes using a loader, your hierarchy will be searched from base to parent searching for the first viable loader.

The second argument is the SupportedClassSet, a set of all classes this loader will directly support building. It is acceptable to support building the same class at multiple layers in your loader hierarchy.

To build objects with a ClassLoader you create a LocalClassLoader or GlobalClassLoader instance. Unfortunately, these currently must be constructed in Java like in the sample (this is deficiency of JNI Bind, feel free to file a bug if you actually need this from native).

LocalClassLoader<kTestClassLoader> class_loader{jclass_loader_obj};
jni::LocalObject<kClassLoaderHelperClass, kTestClassLoader> = class_loader.BuildLocalObject<kClassLoaderHelperClass>();

⚠️ You must add the kTestClassLoader above, JNI Bind incorrectly does not enforce this, but it will be a compilation error in the future, and failing to do so may cause crashes.

Classloaders support building their own instances of classes, and if they can't build it themselves, they will defer to their parents. JNI Bind will validate any class you attempt to build by traversing its hierarchy and resolving to the base most loader available (or the primordial loader if no custom loader is found). Note that the constructor set of the class will be respected, so pass these arguments to Build[Local|Global]Object as if you constructed the object directly.

You can now use this object as usual. This additional decoration may seem excessive, however, this is actually required, because objects of different class loaders are not interchangeable, and they require completely independent class and method lookups.

Sample class_loader_test_jni.cc, ClassLoaderTest.java.

Intro to classloaders, Oracle's intro to classloaders, Baeldung's "Class loaders in Java".

Statics

Statics are declared as an instance of jni::Static which is constructed with jni::Method and jni::Field instances (in that order). Unlike regular objects, you make the static invocation with StaticRef.

static constexpr Class kSomeClass{"com/google/SomeClass"};

static constexpr Class kClass{
  "com/google/HasStaticMethods",
  Static {
    Method { "staticTakesInt", Return{}, Params<int>{}  },
    Method { "staticTakesFloat", Return{}, Params<float>{} },
    Field { "staticLongField", jlong{} },
    Field { "staticObjectField", kSomeClass },
  },
  // Some other field (comes after).
  Field { "Foo", jint{}, }
};

StaticRef<kClass>{}("staticTakesInt", 123);
StaticRef<kClass>{}("staticTakesFloat", 123.f);
StaticRef<kClass>{}["staticLongField"].Set(123);
StaticRef<kClass>{}["staticObjectField"].Set(LocalObject<kSomeClass>{});

Statics will follow the rules laid out in Type Conversion Rules. Invalid static method names won't compile, and jmethodIDs are cached on your behalf. Static method lookups are compile time, there is no hash lookup cost.

Sample static_test_jni.cc, StaticTest.java.

Some classes expose a builder style pattern like so:

  // Class:
  public static class Builder {
    private int valOne = 0;
    private int valTwo = 0;

    public Builder() {}
    public SomeObj build() { return new SomeObj(valOne, valTwo); }

    public Builder setOne(int val) {
      valOne = val;
      return this;
    }

    public Builder setTwo(int val) {
      valTwo = val;
      return this;
    }
  }

  // Usage:
  SomeObj b = new Builder().setOne(123).setTwo(456);

In these circumstances, it is not viable to simply use forward declarations, because the return value will lack the function declarations needed to continue building. To circumvent this, you can use Self, which will return a fully decorated class object with its own class definition.

constexpr Class kBuilder {
    "com/jnibind/test/BuilderTest$Builder",

    Constructor<>{},

    Method{"setOne", Return{Self{}}, Params<int>{}},
    Method{"setTwo", Return{Self{}}, Params<int>{}},
    // FAILS:
    // Method{"setTwo", Return{Class{"com/jnibind/test/BuilderTest$Builder"}}, Params<int>{}},
    Method{"build", Return{kObjectTestHelperClass}},
};

 LocalObject<kBuilder>{}("setOne", 111)("setTwo", 222)("build")

The commented line above would fail to compile at the invocation, because the return value of setTwo is a shallow copy. Self preserves the methods and fields so the returned LocalObject "just works".

Sample: BuilderTest.java, builder_test_jni.cc.

Arrays

Java arrays can be defined in JNI Bind and offer similar mechanics as you would expect with other types.

jni:Array<type, possible_class_definition> provides the static definition, which is referenced by LocalArray<type, possible_class_definition> which provides Get/Set methods, or ArrayView via Pin(bool copy_on_completion=true). ArrayView has a ptr() method which itself can be used to modify underlying values (to be copied back out when ArrayView falls off scope).

LocalArray<jint> arr{3}; // {0, 0, 0}
// LocalArray<jint> int_arr{1,2,3}; // coming soon
LocalArray<jint> another_valid_arr {some_jarray_val};

{
  // Value is written when it falls off scope
  ArrayView view = arr.Pin(/*copy_on_completion=true*/);
  view.ptr()[0] = 123;
}

// You can also use helper methods Set/Get.
arr.Get(0); // 123
arr.Set(1, 456);

If copy_on_completion is false, values will not be copied back when the scope of ArrayView falls off scope (otherwise it will). This can be used as an optimisation when you only intend to read from the array.

Arrays can be used in conjunction with fields and methods as you would expect:

  static constexpr Class kClass{
    "com/google/SupportsStrings",
    Method{ "Foo", Array{int{}} },
    Field { "intArrayField", Array<int>{} },
    // , Field { "intArrayArrayField", Array{ Array { int{} } }  // coming soon
};

LocalObject<kClass> obj{};
LocalArray<int> arr = obj["intArrayField"];

Arrays can be used in conjunction with primitive types or classes, but if they are used with classes they will always consist of LocalObjects (reasoning about a LocalArray with a GlobalObject is too confusing).

static constexpr Class kClass{
    "com/google/SupportsStrings",
    Field { "objectArrayField", Array<jobject, kClass>{} }
};

LocalArray<jint> int_arr{1,2,3};

// Arrays work just like any other type in JNI Bind.
obj("Foo", 1);
obj("Foo", "arg");
obj("Foo", "arg", jstring{nullptr});

LocalArray has two constructors, one for construction from an existing jarray object (similar to LocalObject) and another that builds a new array full of zero initialised objects. For primitives, simply indicate the size, for object arrays, provide a default object that will be used to fill the array.

    LocalArray<int> local_int_array{3};
    LocalArray<jobject, kClass> local_obj_array{5, LocalObject<kClass>{}};

Arrays of arrays, while legal, are not currently supported. They will be supported in the future.

Sample local_array.h, array_test_jni.cc, ArrayTest.java.

Upcoming Features

Feature requests are welcome! Some upcoming features are:

  • Statics
  • Tighter array type validation (arrays are overly permissive for arguments)
  • Better error messages
  • Link time symbol validation in Bazel
  • Unit Testing Support (enabling unit testing of JNI interfaces)
  • Callback syntactic sugar
  • Per JNI call lambda invocations (e.g. per JNI call logging, perf tracing)
  • Auto generated interfaces (with pre-loaded scrapes of Java libraries)
  • And more!

License

The JNI Bind library is licensed under the terms of the Apache license. See LICENSE for more information.

Footnotes

  1. Note, if you pass a jni::LocalObject or jni::GlobalObjectas an rvalue, it will release the underlying jobject (mimicking the same rules used for const& in C++).