Ever felt like JNI (Java Native Interface) was more of a puzzle box than a useful tool?
Fear not—today we’re diving deep into JNI without the headaches! 🚀
If you’ve ever wanted to:
…then JNI is your secret sauce! But it can also turn your code into a spaghetti monster 🍝 if you’re not careful.
Spotify leverages the Java Native Interface (JNI) to integrate performance-critical audio processing components written in C++
WhatsApp applies image filters and carries our encryption via JNI:
Chrome for Android uses JNI to integrate Blink (the C++ rendering engine).
At NimbleEdge we provide an on-device AI platform capable of running ML models and AI workflows directly on devices unlocking AI native experiences for millions of users across the globe. As these devices are quite resource constrained (compared to their cloud counterparts) our focus is always on optimal performance with efficiency ensuring apps can be natively built with recommendations, reasoning or voice modalities running on device.
In order to achieve this our core platform is written in highly optimized C++ interfacing with the Android layer with the JNI interface - so you can imagine leveraging JNI is critical to our platform for performance and efficiency. In particular with JNI we focus on the following aspects:
Every Java object you touch in native code generates a “local reference.”
Too many references in one function = Crash City 💥.
JNI is essentially C++. You malloc
, you must free
.
One missed free? Memory creeps up like a ghost 👻.
Finding classes, method IDs, exception checks, type conversions… 😫
One small call can explode into 10+ lines of boilerplate.
Java_com_example_whatever_functionName
—ugh.
It’s a leftover from the ‘90s: hard to read, even harder to love!
Sound familiar? Buckle up—let’s fix them! 🤝
RAII stands for “Resource Acquisition Is Initialization.” Translation:
“Create resources in constructors, free them in destructors so you never forget.”
Example: Memory Management:
// This can leak if 'free(data)' is never called
char* data = (char*)malloc(256);
// Freed automatically when out of scope. Zero leaks!
#include <memory>
auto buffer = std::make_unique<char[]>(256);
Result? Your memory management nightmares vanish. Poof! ✨
Converting jstring
↔ char*
in JNI is a chore. You must call GetStringUTFChars()
and then ReleaseStringUTFChars()
—or suffer leaks. A small RAII class solves that:
class JStringHelper {
JNIEnv* env;
jstring javaStr;
const char* nativeStr;
public:
JStringHelper(JNIEnv* e, jstring js)
: env(e), javaStr(js), nativeStr(e->GetStringUTFChars(js, nullptr)) {}
~JStringHelper() {
env->ReleaseStringUTFChars(javaStr, nativeStr);
}
const char* c_str() const { return nativeStr; }
};
Now you can do:
JStringHelper myString(env, jString);
LOGD("Received: %s", myString.c_str());
No leaks—ever. 🥳
You don’t have to write monstrous names like Java_com_example_MyActivity_doStuff
. Use dynamic registration:
// Step 1: In Kotlin
class MyActivity {
external fun doStuff()
}
// Step 2: In C++
static JNINativeMethod methods[] = {
{"doStuff", "()V", (void*)nativeDoStuff}
};
env->RegisterNatives(clazz, methods, 1);
Your C++ function is simply nativeDoStuff()
. A million times cleaner 🧼.
This is the game-changer if your JNI calls are more than one or two lines. A “shadow class” encapsulates all the JNI lookups for a specific Java/Kotlin object. For example, a Restaurant
:
class RestaurantShadow {
static jclass clazz;
jobject restaurantObj;
public:
static void init(JNIEnv* env) {
// find class, store method IDs once
}
RestaurantShadow(JNIEnv* env, jobject obj) {
// keep GlobalRef
}
std::string getName(JNIEnv* env) {
// call getName() on the Restaurant object
}
};
Next time you want the name:
RestaurantShadow restaurant(env, jRestaurantObj);
std::string name = restaurant.getName(env);
No repeating FindClass
or GetMethodID
. So much nicer. 🎉
Wondering if this actually works in a real Android project? Let me show you exactly how these four strategies power my RestaurantSerialization App.
/**
* data class Restaurant(
* val id: String,
* val name: String,
* val address: Address,
* val rating: Double,
* val cuisines: List<String>,
* val phoneNumber: String?,
* val website: String?,
* val openingHours: List<OpeningHour>,
* val menu: List<MenuItem>
* )
*/
class RestaurantShadow {
private:
inline static jclass restaurantClass;
inline static jmethodID getIdMethodId;
inline static jmethodID getNameMethodId;
inline static jmethodID getAddressMethodId;
inline static jmethodID getRatingMethodId;
inline static jmethodID getCuisinesMethodId;
inline static jmethodID getPhoneNumberMethodId;
inline static jmethodID getWebsiteMethodId;
inline static jmethodID getOpeningHoursMethodId;
inline static jmethodID getMenuMethodId;
jobject restaurantObject;
public:
static bool init(JNIEnv* env) {
if (!env) return false;
jclass localClass = env->FindClass("com/voidmemories/restaurant_serializer/Restaurant");
if (!localClass) {
return false;
}
restaurantClass = static_cast<jclass>(env->NewGlobalRef(localClass));
env->DeleteLocalRef(localClass);
if (!restaurantClass) {
return false;
}
// Get method IDs for all getters
getIdMethodId = env->GetMethodID(restaurantClass, "getId", "()Ljava/lang/String;");
getNameMethodId = env->GetMethodID(restaurantClass, "getName", "()Ljava/lang/String;");
getAddressMethodId = env->GetMethodID(restaurantClass, "getAddress", "()Lcom/voidmemories/restaurant_serializer/Address;");
getRatingMethodId = env->GetMethodID(restaurantClass, "getRating", "()D");
getCuisinesMethodId = env->GetMethodID(restaurantClass, "getCuisines", "()Ljava/util/List;");
getPhoneNumberMethodId = env->GetMethodID(restaurantClass, "getPhoneNumber", "()Ljava/lang/String;");
getWebsiteMethodId = env->GetMethodID(restaurantClass, "getWebsite", "()Ljava/lang/String;");
getOpeningHoursMethodId = env->GetMethodID(restaurantClass, "getOpeningHours", "()Ljava/util/List;");
getMenuMethodId = env->GetMethodID(restaurantClass, "getMenu", "()Ljava/util/List;");
if (!getIdMethodId || !getNameMethodId || !getAddressMethodId ||
!getRatingMethodId || !getCuisinesMethodId || !getPhoneNumberMethodId ||
!getWebsiteMethodId || !getOpeningHoursMethodId || !getMenuMethodId) {
return false;
}
return true;
}
RestaurantShadow(JNIEnv* env, jobject obj) {
if (!env || !obj) {
throw std::runtime_error("Invalid constructor arguments for RestaurantShadow.");
}
restaurantObject = env->NewGlobalRef(obj);
}
~RestaurantShadow() {
JNIEnv* env;
int getEnvStatus = globalJvm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6);
if (restaurantObject && getEnvStatus != JNI_EDETACHED && env != nullptr) {
env->DeleteGlobalRef(restaurantObject);
}
}
std::string getId(JNIEnv* env) {
if (!env || !restaurantObject || !getIdMethodId) {
throw std::runtime_error("Invalid state to call getId.");
}
jstring jId = (jstring)env->CallObjectMethod(restaurantObject, getIdMethodId);
if (!jId) {
return std::string();
}
JNIString idStr(env, jId);
return std::string(idStr.c_str());
}
std::string getName(JNIEnv* env) {
if (!env || !restaurantObject || !getNameMethodId) {
throw std::runtime_error("Invalid state to call getName.");
}
jstring jName = (jstring)env->CallObjectMethod(restaurantObject, getNameMethodId);
if (!jName) {
return std::string();
}
JNIString nameStr(env, jName);
return std::string(nameStr.c_str());
}
// Returns a jobject for the Address. The caller can then wrap it in AddressShadow if desired.
jobject getAddress(JNIEnv* env) {
if (!env || !restaurantObject || !getAddressMethodId) {
throw std::runtime_error("Invalid state to call getAddress.");
}
return env->CallObjectMethod(restaurantObject, getAddressMethodId);
}
double getRating(JNIEnv* env) {
if (!env || !restaurantObject || !getRatingMethodId) {
throw std::runtime_error("Invalid state to call getRating.");
}
return env->CallDoubleMethod(restaurantObject, getRatingMethodId);
}
// Returns a jobject reference to a Java List<String> of cuisines
jobject getCuisines(JNIEnv* env) {
if (!env || !restaurantObject || !getCuisinesMethodId) {
throw std::runtime_error("Invalid state to call getCuisines.");
}
return env->CallObjectMethod(restaurantObject, getCuisinesMethodId);
}
std::string getPhoneNumber(JNIEnv* env) {
if (!env || !restaurantObject || !getPhoneNumberMethodId) {
throw std::runtime_error("Invalid state to call getPhoneNumber.");
}
jstring jPhone = (jstring)env->CallObjectMethod(restaurantObject, getPhoneNumberMethodId);
if (!jPhone) {
return std::string();
}
JNIString phoneStr(env, jPhone);
return std::string(phoneStr.c_str());
}
std::string getWebsite(JNIEnv* env) {
if (!env || !restaurantObject || !getWebsiteMethodId) {
throw std::runtime_error("Invalid state to call getWebsite.");
}
jstring jWebsite = (jstring)env->CallObjectMethod(restaurantObject, getWebsiteMethodId);
if (!jWebsite) {
return std::string();
}
JNIString websiteStr(env, jWebsite);
return std::string(websiteStr.c_str());
}
// Returns a jobject reference to Java List<OpeningHour>
jobject getOpeningHours(JNIEnv* env) {
if (!env || !restaurantObject || !getOpeningHoursMethodId) {
throw std::runtime_error("Invalid state to call getOpeningHours.");
}
return env->CallObjectMethod(restaurantObject, getOpeningHoursMethodId);
}
// Returns a jobject reference to Java List<MenuItem>
jobject getMenu(JNIEnv* env) {
if (!env || !restaurantObject || !getMenuMethodId) {
throw std::runtime_error("Invalid state to call getMenu.");
}
return env->CallObjectMethod(restaurantObject, getMenuMethodId);
}
};
getName()
, getAddress()
, getOpeningHours()
.city
and zip
.env->FindClass
and env->GetMethodID
calls into a single file, I group them by data model. That means if I update the Address
structure, I only touch AddressShadow
.init()
method that fetches class references and method IDs only once at startup. After that, the getters are just direct calls—no repeated lookups./**
* @class JNIString
* @brief A helper class that manages the lifetime of a jstring->C string mapping.
*
* Usage:
* JNIString jniString(env, someJString);
* const char* cStr = jniString.c_str();
* // cStr remains valid until jniString goes out of scope
*/
class JNIString {
public:
/**
* Constructs a JNIString from a jstring, acquiring its UTF-8 chars.
* @param env Pointer to the JNI environment.
* @param javaStr jstring to convert to a C-style string.
* @throws std::runtime_error if env or javaStr is null.
*/
JNIString(JNIEnv* env, jstring javaStr)
: env_(env), javaStr_(javaStr), cStr_(nullptr)
{
if (!env_ || !javaStr_) {
throw std::runtime_error("JNIString constructor: env or javaStr is null");
}
cStr_ = env_->GetStringUTFChars(javaStr_, nullptr);
}
/**
* Releases the UTF-8 chars back to the JVM if they were acquired.
*/
~JNIString() {
if (env_ && javaStr_ && cStr_) {
env_->ReleaseStringUTFChars(javaStr_, cStr_);
}
}
/**
* @return A const char* representing the UTF-8 string data.
*/
const char* c_str() const {
return cStr_;
}
private:
JNIEnv* env_;
jstring javaStr_;
const char* cStr_;
};
RestaurantShadow
, I store a global JNI reference to the passed jobject
.ReleaseStringUTFChars()
.One-Liner Getters:
std::string name = restaurantShadow.getName(env);
env->FindClass("...Restaurant")
, then env->GetMethodID("getName")
, then env->CallObjectMethod()
. It’s all under the hood.// Init functions, called only once!!!
static const JNINativeMethod nativeMethods[] = {
{"serializeRestaurant", "(Lcom/voidmemories/restaurant_serializer/Restaurant;)Ljava/lang/String;",
(void *)serializeRestaurant}
};
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void * /*reserved*/) {
globalJvm = vm;
JNIEnv *env = nullptr;
if (vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6) != JNI_OK || !env) {
return -1;
}
AddressShadow::init(env);
MenuItemShadow::init(env);
OpeningHourShadow::init(env);
RestaurantShadow::init(env);
jclass clazz = env->FindClass("com/voidmemories/restaurant_serializer/ExternalFunctions");
if (!clazz) {
return -1;
}
if (env->RegisterNatives(clazz, nativeMethods, sizeof(nativeMethods) / sizeof(nativeMethods[0])) != JNI_OK) {
return -1;
}
return JNI_VERSION_1_6;
}
RestaurantShadow::init(env);
).JNINativeMethod
.Simple Data Classes in DataModels.kt:
data class Restaurant(
val id: String,
val name: String,
val address: Address,
val rating: Double,
val cuisines: List<String>,
val phoneNumber: String?,
val website: String?,
val openingHours: List<OpeningHour>,
val menu: List<MenuItem>
)
data class Address(
val street: String,
val city: String,
val state: String,
val zipCode: String,
val country: String
)
data class OpeningHour(
val dayOfWeek: DayOfWeek,
val openTime: LocalTime,
val closeTime: LocalTime
)
data class MenuItem(
val id: String,
val name: String,
val description: String?,
val price: Double,
val category: String?
)
Restaurant
object in Kotlin.RestaurantShadow
into a JSON string (or however you want to “serialize” it).Inside, you’ll see calls like:
/**
* Utility to get size of a java.util.List
*/
static int getListSize(JNIEnv* env, jobject listObj) {
if (!listObj) return 0;
jclass listClass = env->FindClass("java/util/List");
jmethodID sizeMethod = env->GetMethodID(listClass, "size", "()I");
return env->CallIntMethod(listObj, sizeMethod);
}
/**
* Utility to retrieve an element from a java.util.List by index
*/
static jobject getListElement(JNIEnv* env, jobject listObj, int index) {
jclass listClass = env->FindClass("java/util/List");
jmethodID getMethod = env->GetMethodID(listClass, "get", "(I)Ljava/lang/Object;");
return env->CallObjectMethod(listObj, getMethod, index);
}
/**
* Build a simple JSON from the RestaurantShadow's fields.
* In real projects, you'd likely use a JSON library (cJSON, nlohmann/json, RapidJSON, etc.).
*/
std::string buildJsonFromRestaurant(JNIEnv* env, RestaurantShadow& restShadow) {
// Basic fields
std::string id = restShadow.getId(env);
std::string name = restShadow.getName(env);
double rating = restShadow.getRating(env);
std::string phone = restShadow.getPhoneNumber(env);
std::string website = restShadow.getWebsite(env);
// Address
jobject addressObj = restShadow.getAddress(env);
AddressShadow addrShadow(env, addressObj);
// Cuisines
jobject cuisinesList = restShadow.getCuisines(env);
int cuisinesCount = getListSize(env, cuisinesList);
// OpeningHours
jobject openHoursList = restShadow.getOpeningHours(env);
int openHoursCount = getListSize(env, openHoursList);
// Menu
jobject menuList = restShadow.getMenu(env);
int menuCount = getListSize(env, menuList);
// Manual JSON building
std::ostringstream oss;
oss << "{";
oss << R"("id":")" << id << "\",";
oss << R"("name":")" << name << "\",";
oss << "\"rating\":" << rating << ",";
oss << R"("phoneNumber":")" << phone << "\",";
oss << R"("website":")" << website << "\",";
// Address
oss << "\"address\":{";
oss << R"("street":")" << addrShadow.getStreet(env) << "\",";
oss << R"("city":")" << addrShadow.getCity(env) << "\",";
oss << R"("state":")" << addrShadow.getState(env) << "\",";
oss << R"("zipCode":")" << addrShadow.getZipCode(env) << "\",";
oss << R"("country":")" << addrShadow.getCountry(env) << "\"";
oss << "},";
// Cuisines array
oss << "\"cuisines\":[";
for (int i = 0; i < cuisinesCount; i++) {
jobject elem = getListElement(env, cuisinesList, i);
auto jStr = (jstring)elem; // Because it's List<String>
if (!jStr) continue;
JNIString cStr(env, jStr);
oss << "\"" << cStr.c_str() << "\"";
if (i < cuisinesCount - 1) {
oss << ",";
}
}
oss << "],";
// OpeningHours array
oss << "\"openingHours\":[";
for (int i = 0; i < openHoursCount; i++) {
jobject elem = getListElement(env, openHoursList, i);
OpeningHourShadow ohShadow(env, elem);
oss << "{";
oss << R"("dayOfWeek":")" << ohShadow.getDayOfWeek(env) << "\",";
oss << R"("openTime":")" << ohShadow.getOpenTime(env) << "\",";
oss << R"("closeTime":")" << ohShadow.getCloseTime(env) << "\"";
oss << "}";
if (i < openHoursCount - 1) {
oss << ",";
}
}
oss << "],";
// Menu array
oss << "\"menu\":[";
for (int i = 0; i < menuCount; i++) {
jobject elem = getListElement(env, menuList, i);
MenuItemShadow miShadow(env, elem);
oss << "{";
oss << R"("id":")" << miShadow.getId(env) << "\",";
oss << R"("name":")" << miShadow.getName(env) << "\",";
oss << R"("description":")" << miShadow.getDescription(env) << "\",";
oss << "\"price\":" << miShadow.getPrice(env) << ",";
oss << R"("category":")" << miShadow.getCategory(env) << "\"";
oss << "}";
if (i < menuCount - 1) {
oss << ",";
}
}
oss << "]";
oss << "}"; // end JSON object
return oss.str();
}
Finally, the native method in jni.cpp (line 41) is basically:
jstring serializeRestaurant(JNIEnv *env, jobject thiz, jobject jRestaurant) {
RestaurantShadow restShadow(env, jRestaurant);
std::string json = buildJsonFromRestaurant(env, restShadow);
return env->NewStringUTF(json.c_str());
}
Result?
JNI doesn’t have to be a hair-pulling experience. By:
you can keep your C++ codebase safe, clean, and fun to work with.
Give these ideas a spin in your own projects—or jump into my RestaurantSerialization repo to see them in action. Then watch as your JNI code transforms from nightmare to breeze. 🌈
In the next part i’ll be showing how you can detect memory leaks, local ref leaks in your JNI code
We learned these techniques while working at NimbleEdge, where our iterative approach led us to uncover the best practices for high performance—even when often the JNI documentation was scarce online. We are hoping sharing these learnings will be helpful for others building products leveraging JNI - do reach out to naman.anand@nimbleedgehq.ai if you have any questions or feedback.
Happy coding!
And bon appétit if you’re also into “restaurant” data like me. 😉
In today’s world, users expect AI assistants that not only understand their needs but also respond with a voice that feels natural, personal, and immediate. At NimbleEdge, we rose to this challenge by building an on-device AI assistant powered by a custom implementation of Kokoro TTS—leveraging our platform’s unique ability to translate rich Python workflows into efficient, cross-platform C++ code.
The introduction of Large Language Models (LLMs) and Generative AI (GenAI) has been a major milestone in the field of AI. With AI models encapsulating vast amounts of world knowledge, their ability to reason and understand human language has unlocked unprecedented possibilities
It is a valid question (isn’t it?) that why should we put effort into reducing the size of an SDK, with mobile storage capacities increasing all the time. Surely, how much do a few MBs matter when the device has multiple hundred gigabytes of sto