טיפים בנושא JNI

‫JNI הוא Java Native Interface. הוא מגדיר דרך לאינטראקציה בין קוד בייט ש-Android מהדר מקוד מנוהל (שנכתב בשפות התכנות Java או Kotlin) לבין קוד מקומי (שנכתב ב-C/C++‎). ‏ JNI הוא ניטרלי לספקים, יש לו תמיכה בטעינת קוד מספריות משותפות דינמיות, ולמרות שהוא מסורבל לפעמים, הוא יעיל למדי.

הערה: מערכת Android מהדרת את Kotlin ל-bytecode שמתאים ל-ART באופן דומה לזה של שפת התכנות Java, ולכן אפשר להשתמש בהנחיות שבדף הזה גם לשפות התכנות Kotlin ו-Java מבחינת ארכיטקטורת JNI והעלויות שקשורות אליה. מידע נוסף זמין במאמר בנושא Kotlin ו-Android.

אם אתם לא מכירים את JNI, כדאי לקרוא את המפרט של Java Native Interface כדי להבין איך הוא פועל ואילו תכונות זמינות בו. חלק מההיבטים של הממשק לא ברורים מיד כשקוראים אותם בפעם הראשונה, ולכן יכול להיות שהקטעים הבאים יהיו שימושיים.

כדי לעיין בהפניות JNI גלובליות ולראות איפה הן נוצרות ונמחקות, אפשר להשתמש בתצוגה JNI heap ב-Memory Profiler ב-Android Studio מגרסה 3.2 ואילך.

טיפים כלליים

כדאי לנסות לצמצם את טביעת הרגל של שכבת ה-JNI. יש כמה מאפיינים שכדאי להביא בחשבון. הפתרון שלכם ל-JNI צריך לעמוד בהנחיות הבאות (לפי סדר חשיבות, מהחשוב ביותר):

  • צמצום המרת המשאבים בשכבת JNI. העברת נתונים בשכבת JNI כרוכה בעלויות לא מבוטלות. כדאי לנסות לתכנן ממשק שמצמצם את כמות הנתונים שצריך להעביר ואת התדירות שבה צריך להעביר נתונים.
  • אם אפשר, מומלץ להימנע מתקשורת אסינכרונית בין קוד שנכתב בשפת תכנות מנוהלת לבין קוד שנכתב ב-C++‎. כך יהיה קל יותר לתחזק את ממשק ה-JNI. בדרך כלל אפשר לפשט עדכונים אסינכרוניים של ממשק המשתמש על ידי שמירת העדכון האסינכרוני באותה שפה של ממשק המשתמש. לדוגמה, במקום להפעיל פונקציית C++‎ משרשור ממשק המשתמש בקוד Java באמצעות JNI, עדיף לבצע קריאה חוזרת בין שני שרשורים בשפת התכנות Java, כאשר אחד מהם מבצע קריאת C++‎ חוסמת ואז מודיע לשרשור ממשק המשתמש כשהקריאה החוסמת מסתיימת.
  • צריך לצמצם למינימום את מספר השרשורים שצריכים לגעת ב-JNI או ש-JNI צריך לגעת בהם. אם אתם צריכים להשתמש במאגרי שרשורים בשפות Java ו-C++, נסו לשמור על תקשורת JNI בין בעלי המאגרים ולא בין שרשורי עובדים בודדים.
  • כדי להקל על שינויים עתידיים, כדאי לשמור את קוד הממשק במספר קטן של מיקומי מקור ב-C++‎ וב-Java שקל לזהות. כדאי להשתמש בספרייה ליצירה אוטומטית של JNI לפי הצורך.

JavaVM ו-JNIEnv

ממשק JNI מגדיר שני מבני נתונים מרכזיים: JavaVM ו-JNIEnv. שני המשתנים האלה הם למעשה מצביעים למצביעים לטבלאות פונקציות. (בגרסת C++, אלה מחלקות עם מצביע לטבלת פונקציות ופונקציית חבר לכל פונקציית JNI שמפנה באופן עקיף דרך הטבלה). ‫JavaVM מספק את הפונקציות של 'ממשק ההפעלה', שמאפשרות ליצור ולמחוק JavaVM. באופן תיאורטי, יכולים להיות כמה מכונות וירטואליות של Java לכל תהליך, אבל ב-Android מותרת רק אחת.

ה-JNIEnv מספק את רוב פונקציות ה-JNI. כל הפונקציות המקוריות מקבלות JNIEnv כארגומנט הראשון, למעט שיטות @CriticalNative. מידע נוסף זמין במאמר בנושא קריאות מקוריות מהירות יותר.

ה-JNIEnv משמש לאחסון מקומי של השרשור. לכן אי אפשר לשתף JNIEnv בין שרשורים. אם אין דרך אחרת לקבל את JNIEnv מקטע קוד, צריך לשתף את JavaVM ולהשתמש ב-GetEnv כדי לגלות את JNIEnv של השרשור. (בהנחה שיש לו כזה, ראו AttachCurrentThread בהמשך).

ההצהרות בשפת C של JNIEnv ו-JavaVM שונות מההצהרות בשפת C++‎. קובץ ה-include‏ "jni.h" מספק typedefs שונים, בהתאם לשאלה אם הוא נכלל ב-C או ב-C++. לכן לא מומלץ לכלול ארגומנטים של JNIEnv בקובצי כותרות שנכללים בשתי השפות. (במילים אחרות: אם קובץ הכותרת דורש #ifdef __cplusplus, יכול להיות שתצטרכו לבצע עבודה נוספת אם משהו בכותרת הזו מתייחס ל-JNIEnv).

שרשורים

כל השרשורים הם שרשורי Linux, שמתוזמנים על ידי ליבת המערכת. בדרך כלל הם מתחילים מקוד מנוהל (באמצעות Thread.start()), אבל אפשר גם ליצור אותם במקום אחר ואז לצרף אותם ל-JavaVM. לדוגמה, אפשר לצרף שרשור שהתחיל עם pthread_create() או std::thread באמצעות הפונקציות AttachCurrentThread() או AttachCurrentThreadAsDaemon(). עד שמצרפים את ה-thread, אין לו JNIEnv, ואי אפשר לבצע קריאות JNI.

בדרך כלל הכי טוב להשתמש ב-Thread.start() כדי ליצור כל שרשור שצריך לקרוא לקוד Java. כך תוכלו לוודא שיש לכם מספיק מקום במחסנית, שאתם נמצאים ב-ThreadGroup הנכון ושאתם משתמשים באותו ClassLoader כמו בקוד Java. בנוסף, קל יותר להגדיר את שם השרשור לצורך ניפוי באגים ב-Java מאשר מקוד מקורי (ראו pthread_setname_np() אם יש לכם pthread_t או pthread_t, וstd::thread::native_handle() אם יש לכם std::thread ואתם רוצים pthread_t).thread_t

צירוף של thread שנוצר באופן מקורי גורם ליצירה של אובייקט java.lang.Thread והוספה שלו ל-thread הראשי ThreadGroup, וכך הוא הופך לגלוי לדיבאגר. התקשרות אל AttachCurrentThread() בשרשור שכבר צורף לא תבצע פעולה.

מערכת Android לא משעה את השרשורים שמריצים קוד מקומי. אם מתבצע איסוף של נתונים מיותרים או אם מאתר הבאגים של JNI הוציא בקשה להשהיה, מערכת Android תשהה את השרשור בפעם הבאה שיתבצעת קריאה ל-JNI.

לפני היציאה, השרשורים שמצורפים באמצעות JNI צריכים לקרוא ל-DetachCurrentThread(). אם קשה לכתוב את הקוד הזה ישירות, ב-Android 2.0 ‏ (Eclair) ואילך אפשר להשתמש ב-pthread_key_create() כדי להגדיר פונקציית הרס שתופעל לפני שהשרשור יוצא, ולקרוא ל-DetachCurrentThread() משם. (משתמשים במפתח הזה עם pthread_setspecific() כדי לאחסן את JNIEnv באחסון מקומי של השרשור. כך הוא יועבר ל-destructor שלכם כארגומנט).

‫jclass,‏ jmethodID ו-jfieldID

אם רוצים לגשת לשדה של אובייקט מקוד מקורי, צריך לעשות את הפעולות הבאות:

  • קבלת הפניה לאובייקט הכיתה עבור הכיתה עם FindClass
  • קבלת מזהה השדה של השדה עם GetFieldID
  • מחליפים את התוכן של השדה במשהו מתאים, כמו GetIntField

באופן דומה, כדי לקרוא לפונקציה, צריך קודם לקבל הפניה לאובייקט של המחלקה ואז מזהה של הפונקציה. המזהים הם לרוב רק מצביעים למבני נתונים פנימיים בזמן ריצה. חיפוש שלהם עשוי לדרוש השוואות של כמה מחרוזות, אבל אחרי שמוצאים אותם, הקריאה בפועל כדי לקבל את השדה או להפעיל את השיטה היא מהירה מאוד.

אם הביצועים חשובים לכם, כדאי לחפש את הערכים פעם אחת ולשמור את התוצאות במטמון בקוד המקורי. מכיוון שיש מגבלה של JavaVM אחד לכל תהליך, הגיוני לאחסן את הנתונים האלה במבנה מקומי סטטי.

ההפניות למחלקות, מזהי השדות ומזהי השיטות תקפים עד שהמחלקות נפרקות. כיתות נפרקות רק אם אפשר לבצע איסוף אשפה לכל הכיתות שמשויכות ל-ClassLoader. זה נדיר, אבל אפשרי ב-Android. עם זאת, חשוב לשים לב שהערך jclass הוא הפניה למחלקה וצריך להגן עליו באמצעות קריאה ל-NewGlobalRef (ראו את הקטע הבא).

אם רוצים לשמור במטמון את המזהים כשכיתה נטענת, ולשמור אותם במטמון מחדש באופן אוטומטי אם הכיתה אי פעם נפרקת ונטענת מחדש, הדרך הנכונה לאתחל את המזהים היא להוסיף קטע קוד שנראה כך לכיתה המתאימה:

Kotlin

companion object {
    /*
     * We use a static class initializer to allow the native code to cache some
     * field offsets. This native function looks up and caches interesting
     * class/field/method IDs. Throws on failure.
     */
    private external fun nativeInit()

    init {
        nativeInit()
    }
}

Java

    /*
     * We use a class initializer to allow the native code to cache some
     * field offsets. This native function looks up and caches interesting
     * class/field/method IDs. Throws on failure.
     */
    private static native void nativeInit();

    static {
        nativeInit();
    }

יוצרים שיטה nativeClassInit בקוד C/C++ שמבצעת את בדיקות המזהים. הקוד יופעל פעם אחת, כשהכיתה תאותחל. אם הכיתה תוסר מהזיכרון ואז תיטען מחדש, היא תופעל שוב.

הפניות מקומיות וגלובליות

כל ארגומנט שמועבר לשיטה מקורית, וכמעט כל אובייקט שמוחזר על ידי פונקציית JNI, הוא 'הפניה מקומית'. המשמעות היא שהיא תקפה למשך הפעולה של השיטה המקורית הנוכחית בשרשור הנוכחי. גם אם האובייקט עצמו ממשיך להתקיים אחרי שהשיטה המקורית מחזירה ערך, ההפניה לא תקינה.

הכלל הזה חל על כל תת-המחלקות של jobject, כולל jclass, jstring ו-jarray. (סביבת זמן הריצה תציג אזהרה לגבי רוב המקרים של שימוש שגוי בהפניות, אם מפעילים בדיקות JNI מורחבות).

הדרך היחידה לקבל הפניות לא מקומיות היא באמצעות הפונקציות NewGlobalRef ו-NewWeakGlobalRef.

אם רוצים לשמור הפניה למשך תקופה ארוכה יותר, צריך להשתמש בהפניה 'גלובלית'. הפונקציה NewGlobalRef מקבלת הפניה מקומית כארגומנט ומחזירה הפניה גלובלית. האסמכתא הגלובלית תהיה בתוקף עד שתתקשרו אל DeleteGlobalRef.

הדפוס הזה משמש בדרך כלל כשמטמון jclass מוחזר מ-FindClass, למשל:

jclass localClass = env->FindClass("MyClass");
jclass globalClass = reinterpret_cast<jclass>(env->NewGlobalRef(localClass));

כל שיטות ה-JNI מקבלות הפניות מקומיות וגלובליות כארגומנטים. יכול להיות שהפניות לאותו אובייקט יקבלו ערכים שונים. לדוגמה, יכול להיות שערכי ההחזרה משיחות רצופות אל NewGlobalRef באותו אובייקט יהיו שונים. כדי לבדוק אם שתי הפניות מתייחסות לאותו אובייקט, צריך להשתמש בפונקציה IsSameObject. לעולם אל תשוו הפניות עם == בקוד מקורי.

אחת התוצאות של זה היא שאסור להניח שהפניות לאובייקטים הן קבועות או ייחודיות בקוד מקורי. הערך שמייצג אובייקט יכול להיות שונה בין הפעלה אחת של שיטה להפעלה הבאה, ויכול להיות שלשני אובייקטים שונים יהיה אותו ערך בקריאות עוקבות. אין להשתמש בערכים jobject כמפתחות.

המתכנתים נדרשים "לא להקצות יתר על המידה" הפניות מקומיות. בפועל, המשמעות היא שאם אתם יוצרים מספר גדול של הפניות מקומיות, למשל כשאתם מריצים מערך של אובייקטים, כדאי לשחרר אותן באופן ידני באמצעות DeleteLocalRef במקום לאפשר ל-JNI לעשות זאת בשבילכם. ההטמעה נדרשת רק כדי לשריין משבצות ל-16 הפניות מקומיות, כך שאם אתם צריכים יותר מזה, אתם צריכים למחוק תוך כדי או להשתמש ב-EnsureLocalCapacity/PushLocalFrame כדי לשריין יותר.

שימו לב ש-jfieldID ו-jmethodID הם סוגים אטומים, לא הפניות לאובייקטים, ואין להעביר אותם אל NewGlobalRef. גם המצביעים לנתונים הגולמיים שמוחזרים על ידי פונקציות כמו GetStringUTFChars ו-GetByteArrayElements הם לא אובייקטים. (יכול להיות שהם יועברו בין השרשורים, והם בתוקף עד לקריאה התואמת של Release).

יש מקרה חריג אחד שחשוב לציין בנפרד. אם מצרפים thread מקורי עם AttachCurrentThread, הקוד שמופעל לא ישחרר אוטומטית הפניות מקומיות עד שה-thread ינותק. תצטרכו למחוק ידנית כל הפניה מקומית שתיצרו. באופן כללי, כל קוד מקורי שיוצר הפניות מקומיות בלולאה כנראה צריך לבצע מחיקה ידנית.

צריך להיזהר כשמשתמשים בהפניות גלובליות. יכול להיות שלא תהיה ברירה אלא להשתמש בהפניות גלובליות, אבל קשה לנפות באגים בהפניות כאלה והן עלולות לגרום לבעיות בזיכרון שקשה לאבחן. אם כל שאר התנאים שווים, פתרון עם פחות הפניות גלובליות הוא כנראה טוב יותר.

מחרוזות UTF-8 ו-UTF-16

שפת התכנות Java משתמשת ב-UTF-16. לנוחותכם, JNI מספקת שיטות שפועלות גם עם UTF-8 שעבר שינוי. הקידוד ששונה שימושי לקוד C כי הוא מקודד את ‎\u0000 כ-‎0xc0 0x80 במקום כ-‎0x00. היתרון בשיטה הזו הוא שאפשר לסמוך על כך שיהיו מחרוזות מסוג C עם סיום באפס, שמתאימות לשימוש עם פונקציות מחרוזת רגילות של libc. החיסרון הוא שאי אפשר להעביר נתוני UTF-8 שרירותיים ל-JNI ולצפות שהם יפעלו בצורה תקינה.

כדי לקבל את הייצוג ב-UTF-16 של String, משתמשים ב-GetStringChars. שימו לב: מחרוזות UTF-16 לא מסתיימות באפס, ומותר להשתמש ב-‎\u0000, לכן צריך לשמור את אורך המחרוזת ואת מצביע jchar.

אל תשכחו Release את המחרוזות שGet. הפונקציות של המחרוזות מחזירות jchar* או jbyte*, שהן מצביעים בסגנון C לנתונים פרימיטיביים ולא הפניות מקומיות. הם תקפים עד לקריאה של Release, כלומר הם לא משוחררים כשהשיטה המקורית מחזירה ערך.

הנתונים שמועברים אל NewStringUTF צריכים להיות בפורמט UTF-8 שעבר שינוי. טעות נפוצה היא קריאת נתוני תווים מקובץ או מזרם רשת והעברתם אל NewStringUTF בלי לסנן אותם. אלא אם אתם יודעים שהנתונים הם בפורמט MUTF-8 תקין (או בפורמט ASCII‏ 7 סיביות, שהוא קבוצת משנה תואמת), אתם צריכים להסיר תווים לא תקינים או להמיר אותם לפורמט MUTF-8 תקין. אם לא, סביר להניח שההמרה ל-UTF-16 תספק תוצאות לא צפויות. ‫CheckJNI – שמופעל כברירת מחדל באמולטורים – סורק מחרוזות ומבטל את המכונה הווירטואלית אם הוא מקבל קלט לא תקין.

לפני Android 8, בדרך כלל היה מהיר יותר לפעול עם מחרוזות UTF-16 כי ב-Android לא נדרשה העתקה ב-GetStringChars, ואילו ב-GetStringUTFChars נדרשה הקצאה והמרה ל-UTF-8. ב-Android 8, הייצוג של String השתנה לשימוש ב-8 ביטים לכל תו במחרוזות ASCII (כדי לחסוך בזיכרון), והמערכת התחילה להשתמש באיסוף אשפה נע. התכונות האלה מצמצמות באופן משמעותי את מספר המקרים שבהם ART יכול לספק מצביע לנתוני String בלי ליצור עותק, גם עבור GetStringCritical. עם זאת, אם רוב המחרוזות שעוברות עיבוד על ידי הקוד הן קצרות, אפשר להימנע מהקצאה ומהסרת הקצאה ברוב המקרים באמצעות מאגר שהוקצה למחסנית ו-GetStringRegion או GetStringUTFRegion. לדוגמה:

    constexpr size_t kStackBufferSize = 64u;
    jchar stack_buffer[kStackBufferSize];
    std::unique_ptr<jchar[]> heap_buffer;
    jchar* buffer = stack_buffer;
    jsize length = env->GetStringLength(str);
    if (length > kStackBufferSize) {
      heap_buffer.reset(new jchar[length]);
      buffer = heap_buffer.get();
    }
    env->GetStringRegion(str, 0, length, buffer);
    process_data(buffer, length);

מערכים פרימיטיביים

‫JNI מספק פונקציות לגישה לתוכן של אובייקטים של מערכים. כשמדובר במערכים של אובייקטים, צריך לגשת לכל רשומה בנפרד, אבל כשמדובר במערכים של פרימיטיבים, אפשר לקרוא ולכתוב ישירות כאילו הם הוגדרו ב-C.

כדי שהממשק יהיה יעיל ככל האפשר בלי להגביל את ההטמעה של המכונה הווירטואלית, משפחת הקריאות Get<PrimitiveType>ArrayElements מאפשרת לסביבת זמן הריצה להחזיר מצביע לרכיבים בפועל, או להקצות זיכרון וליצור עותק. בכל מקרה, מובטח שהמצביע הגולמי שמוחזר יהיה תקף עד להוצאת הקריאה המתאימה ל-Release (מה שאומר שאם הנתונים לא הועתקו, אובייקט המערך יוצמד ולא ניתן יהיה להעביר אותו כחלק מדחיסת הערימה). חובה Release כל מערך שGet. בנוסף, אם הקריאה Get נכשלת, צריך לוודא שהקוד לא מנסה Release מצביע NULL בהמשך.

כדי לקבוע אם הנתונים הועתקו, צריך להעביר מצביע שאינו NULL לארגומנט isCopy. זה לא שימושי ברוב המקרים.

הקריאה Release מקבלת ארגומנט mode שיכול להיות אחד משלושה ערכים. הפעולות שמבוצעות על ידי זמן הריצה תלויות בכך שהוא החזיר מצביע לנתונים בפועל או עותק שלהם:

  • 0
    • בפועל: אובייקט המערך לא מוצמד.
    • העתקה: הנתונים מועתקים בחזרה. המאגר עם העותק משוחרר.
  • JNI_COMMIT
    • בפועל: לא עושה כלום.
    • העתקה: הנתונים מועתקים בחזרה. המאגר עם העותק לא משוחרר.
  • JNI_ABORT
    • בפועל: אובייקט המערך לא מוצמד. פעולות כתיבה קודמות לא מבוטלות.
    • העתקה: המאגר עם העותק משוחרר, וכל השינויים בו אובדים.

אחת הסיבות לבדיקת הדגל isCopy היא לדעת אם צריך להפעיל את Release עם JNI_COMMIT אחרי שמבצעים שינויים במערך. אם אתם מבצעים שינויים ומריצים קוד שמשתמש בתוכן של המערך לסירוגין, יכול להיות שתוכלו לדלג על פעולת ה-commit שלא מבצעת כלום. סיבה אפשרית נוספת לבדיקת הדגל היא טיפול יעיל בJNI_ABORT. לדוגמה, יכול להיות שתרצו לקבל מערך, לשנות אותו במקום, להעביר חלקים לפונקציות אחרות ואז לבטל את השינויים. אם אתם יודעים ש-JNI יוצר בשבילכם עותק חדש, אין צורך ליצור עותק נוסף שאפשר לערוך. אם JNI מעביר לך את המקור, אז אתה צריך ליצור עותק משלך.

טעות נפוצה (שחוזרת על עצמה בקוד לדוגמה) היא ההנחה שאפשר לדלג על הקריאה Release אם *isCopy הוא false. הנחה זו אינה נכונה. אם לא הוקצה מאגר זמני להעתקה, הזיכרון המקורי חייב להיות מוצמד ולא ניתן להעביר אותו על ידי איסוף האשפה.

חשוב גם לזכור שהדגל JNI_COMMIT לא משחרר את המערך, ותצטרכו בסופו של דבר לקרוא שוב ל-Release עם דגל אחר.

שיחות באזור

יש חלופה לשיחות כמו Get<Type>ArrayElements ו-GetStringChars שיכולה להיות מאוד שימושית אם כל מה שרוצים לעשות זה להעתיק נתונים פנימה או החוצה. כמה נקודות שכדאי לזכור:

    jbyte* data = env->GetByteArrayElements(array, NULL);
    if (data != NULL) {
        memcpy(buffer, data, len);
        env->ReleaseByteArrayElements(array, data, JNI_ABORT);
    }

הפעולה הזו מאחזרת את המערך, מעתיקה ממנו את len בייטים הראשונים של הרכיבים ואז משחררת את המערך. בהתאם להטמעה, הקריאה Get תצמיד או תעתיק את תוכן המערך. הקוד מעתיק את הנתונים (אולי בפעם השנייה), ואז קורא ל-Release. במקרה הזה, JNI_ABORT מוודא שלא יהיה סיכוי לעותק שלישי.

אפשר להשיג את אותה התוצאה בצורה פשוטה יותר:

    env->GetByteArrayRegion(array, 0, len, buffer);

יש לזה כמה יתרונות:

  • נדרשת קריאת JNI אחת במקום 2, וכך מצטמצם העומס.
  • לא נדרש הצמדה או עותקים נוספים של נתונים.
  • הפחתת הסיכון לטעות בתכנות – אין סיכון לשכוח לקרוא ל-Release אחרי שמשהו נכשל.

באופן דומה, אפשר להשתמש בקריאה Set<Type>ArrayRegion כדי להעתיק נתונים למערך, ובקריאה GetStringRegion או GetStringUTFRegion כדי להעתיק תווים מתוך String.

חריגים

אסור לקרוא לרוב הפונקציות של JNI בזמן שהחריגה ממתינה. הקוד אמור לזהות את החריגה (באמצעות ערך ההחזרה של הפונקציה, ExceptionCheck או ExceptionOccurred) ולהחזיר, או לנקות את החריגה ולטפל בה.

הפונקציות היחידות של JNI שאפשר להפעיל בזמן שהחריגה ממתינה הן:

  • DeleteGlobalRef
  • DeleteLocalRef
  • DeleteWeakGlobalRef
  • ExceptionCheck
  • ExceptionClear
  • ExceptionDescribe
  • ExceptionOccurred
  • MonitorExit
  • PopLocalFrame
  • PushLocalFrame
  • Release<PrimitiveType>ArrayElements
  • ReleasePrimitiveArrayCritical
  • ReleaseStringChars
  • ReleaseStringCritical
  • ReleaseStringUTFChars

הרבה קריאות ל-JNI יכולות להחזיר חריגה, אבל לעיתים קרובות הן מספקות דרך פשוטה יותר לבדוק אם הפעולה נכשלה. לדוגמה, אם הפונקציה NewString מחזירה ערך שאינו NULL, אין צורך לבדוק אם יש חריגה. עם זאת, אם קוראים לשיטה (באמצעות פונקציה כמו CallObjectMethod), תמיד צריך לבדוק אם יש חריגה, כי ערך ההחזרה לא יהיה תקף אם נוצרה חריגה.

חשוב לדעת: חריגים שנוצרים על ידי קוד מנוהל לא מבטלים את המסגרות של מחסנית מקורית. (ובאופן כללי, לא מומלץ להשתמש בחריגים של C++ ב-Android, ואסור להפעיל אותם מעבר לגבול המעבר של JNI מקוד C++ לקוד מנוהל). ההוראות JNI Throw ו-ThrowNew רק מגדירות מצביע חריגה בשרשור הנוכחי. כשחוזרים לקוד מנוהל מקוד מקורי, החריגה תצוין ותטופל בהתאם.

קוד מקורי יכול 'לתפוס' חריגה על ידי קריאה ל-ExceptionCheck או ל-ExceptionOccurred, ולנקות אותה באמצעות ExceptionClear. כמו תמיד, התעלמות מחריגים בלי לטפל בהם עלולה לגרום לבעיות.

אין פונקציות מובנות לשינוי האובייקט Throwable עצמו, ולכן אם רוצים (למשל) לקבל את מחרוזת החריגה, צריך למצוא את המחלקה Throwable, לחפש את מזהה השיטה של getMessage "()Ljava/lang/String;", להפעיל אותה, ואם התוצאה היא לא NULL, להשתמש ב-GetStringUTFChars כדי לקבל משהו שאפשר להעביר ל-printf(3) או לפונקציה מקבילה.

בדיקה מורחבת

‫JNI לא מבצע הרבה בדיקות שגיאות. שגיאות בדרך כלל גורמות לקריסה. ב-Android יש גם מצב שנקרא CheckJNI, שבו מצביעים על טבלאות של פונקציות JavaVM ו-JNIEnv מוחלפים בטבלאות של פונקציות שמבצעות סדרה מורחבת של בדיקות לפני קריאה להטמעה הרגילה.

הבדיקות הנוספות כוללות:

  • מערכים: ניסיון להקצות מערך בגודל שלילי.
  • מצביעים לא תקינים: העברת jarray/jclass/jobject/jstring לא תקינים לקריאת JNI, או העברת מצביע NULL לקריאת JNI עם ארגומנט שלא יכול להיות NULL.
  • שמות של מחלקות: העברת כל דבר מלבד הסגנון java/lang/String של שם המחלקה לקריאת JNI.
  • שיחות קריטיות: ביצוע שיחת JNI בין פעולת get 'קריטית' לבין פעולת השחרור התואמת שלה.
  • ‫Direct ByteBuffers: העברת ארגומנטים שגויים אל NewDirectByteBuffer.
  • חריגים: ביצוע קריאת JNI בזמן שיש חריגה בהמתנה.
  • ‫JNIEnv*s: שימוש ב-JNIEnv* משרשור שגוי.
  • ‫jfieldIDs: שימוש ב-jfieldID מסוג NULL, או שימוש ב-jfieldID כדי להגדיר שדה לערך מהסוג הלא נכון (לדוגמה, ניסיון להקצות StringBuilder לשדה String), או שימוש ב-jfieldID לשדה סטטי כדי להגדיר שדה מופע או להיפך, או שימוש ב-jfieldID ממחלקה אחת עם מופעים של מחלקה אחרת.
  • ‫jmethodIDs: שימוש בסוג הלא נכון של jmethodID כשמבצעים קריאת Call*Method JNI: סוג החזרה שגוי, אי התאמה בין סטטי ללא סטטי, סוג שגוי ל-'this' (לקריאות לא סטטיות) או מחלקה שגויה (לקריאות סטטיות).
  • קובצי עזר: שימוש ב-DeleteGlobalRef/DeleteLocalRef בסוג הלא נכון של קובץ עזר.
  • מצבי הפצה: העברת מצב הפצה לא תקין לקריאה להפצה (מצב שאינו 0,‏ JNI_ABORT או JNI_COMMIT).
  • בטיחות סוגים: החזרת סוג לא תואם מהשיטה המקורית (לדוגמה, החזרת StringBuilder משיטה שהוגדרה להחזרת String).
  • ‫UTF-8: העברת רצף בייטים לא תקין של UTF-8 שעבר שינוי לקריאת JNI.

(עדיין לא נבדקה הנגישות של שיטות ושדות: הגבלות גישה לא חלות על קוד מקורי).

יש כמה דרכים להפעיל את CheckJNI.

אם אתם משתמשים באמולטור, CheckJNI מופעל כברירת מחדל.

אם יש לכם מכשיר עם הרשאות Root, אתם יכולים להשתמש ברצף הפקודות הבא כדי להפעיל מחדש את סביבת זמן הריצה עם CheckJNI מופעל:

adb shell stop
adb shell setprop dalvik.vm.checkjni true
adb shell start

בכל אחד מהמקרים האלה, כשהסביבה הדינמית תתחיל לפעול, תראו משהו כזה בפלט של logcat:

D AndroidRuntime: CheckJNI is ON

אם יש לכם מכשיר רגיל, אתם יכולים להשתמש בפקודה הבאה:

adb shell setprop debug.checkjni 1

ההגדרה הזו לא תשפיע על אפליקציות שכבר פועלות, אבל בכל אפליקציה שתופעל מעכשיו והלאה, האפשרות CheckJNI תהיה מופעלת. (שינוי הנכס לכל ערך אחר או הפעלה מחדש פשוטה ישביתו שוב את CheckJNI). במקרה כזה, בפעם הבאה שאפליקציה תופעל, תראו משהו כזה בפלט של logcat:

D Late-enabling CheckJNI

אפשר גם להגדיר את המאפיין android:debuggable במניפסט של האפליקציה כדי להפעיל את CheckJNI רק עבור האפליקציה. שימו לב שכלי הבנייה של Android יעשו זאת באופן אוטומטי עבור סוגי בנייה מסוימים.

ספריות מקוריות

אפשר לטעון קוד מקורי מספריות משותפות באמצעות System.loadLibrary הרגיל.

בפועל, בגרסאות ישנות יותר של Android היו באגים ב-PackageManager שגרמו לכך שההתקנה והעדכון של ספריות מקומיות לא היו אמינים. בפרויקט ReLinker מוצעים פתרונות לבעיה הזו ולבעיות אחרות שקשורות לטעינה של ספריות Native.

התקשרות אל System.loadLibrary (או אל ReLinker.loadLibrary) ממאחלת מחלקה סטטית. הארגומנט הוא שם הספרייה "הלא מעוצב", ולכן כדי לטעון את libfubar.so צריך להעביר את "fubar".

אם יש לכם רק מחלקה אחת עם שיטות מובנות, הגיוני שהקריאה ל-System.loadLibrary תהיה בשיטת אתחול סטטית של אותה מחלקה. אחרת, כדאי לבצע את הקריאה מ-Application כדי לוודא שהספרייה תמיד נטענת, ותמיד נטענת מוקדם.

יש שתי דרכים שבהן סביבת זמן הריצה יכולה למצוא את השיטות המקוריות שלכם. אפשר לרשום אותם באופן מפורש ב-RegisterNatives, או לאפשר לסביבת זמן הריצה לחפש אותם באופן דינמי באמצעות dlsym. היתרונות של RegisterNatives הם שמתבצעת בדיקה מראש של קיום הסמלים, ובנוסף אפשר ליצור ספריות משותפות קטנות ומהירות יותר כי לא מייצאים שום דבר מלבד JNI_OnLoad. היתרון של שימוש בזמן הריצה כדי לגלות את הפונקציות הוא שצריך לכתוב קצת פחות קוד.

כדי להשתמש באפליקציה RegisterNatives:

  • צריך לספק פונקציה JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved).
  • ב-JNI_OnLoad, רושמים את כל השיטות המקוריות באמצעות RegisterNatives.
  • מומלץ ליצור סקריפט גרסה או להשתמש ב--fvisibility=hidden כדי לייצא מהספרייה רק את JNI_OnLoad. התוצאה היא קוד מהיר יותר וקטן יותר, ונמנעות התנגשויות פוטנציאליות עם ספריות אחרות שנטענות באפליקציה (אבל אם האפליקציה קורסת בקוד מקורי, נוצרים פחות עקבות מחסנית שימושיים).

המאחלים הסטטי אמור להיראות כך:

Kotlin

companion object {
    init {
        System.loadLibrary("fubar")
    }
}

Java

static {
    System.loadLibrary("fubar");
}

הפונקציה JNI_OnLoad אמורה להיראות כך אם היא כתובה ב-C++‎:

JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
    JNIEnv* env;
    if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
        return JNI_ERR;
    }

    // Find your class. JNI_OnLoad is called from the correct class loader context for this to work.
    jclass c = env->FindClass("com/example/app/package/MyClass");
    if (c == nullptr) return JNI_ERR;

    // Register your class' native methods.
    static const JNINativeMethod methods[] = {
        {"nativeFoo", "()V", reinterpret_cast<void*>(nativeFoo)},
        {"nativeBar", "(Ljava/lang/String;I)Z", reinterpret_cast<void*>(nativeBar)},
    };
    int rc = env->RegisterNatives(c, methods, sizeof(methods)/sizeof(JNINativeMethod));
    if (rc != JNI_OK) return rc;

    return JNI_VERSION_1_6;
}

כדי להשתמש במקום זאת ב'גילוי' של שיטות מקוריות, צריך לתת להן שם באופן ספציפי (פרטים מופיעים במפרט JNI). המשמעות היא שאם חתימת השיטה שגויה, לא תדעו על כך עד הפעם הראשונה שהשיטה מופעלת בפועל.

כל הקריאות ל-FindClass שמתבצעות מ-JNI_OnLoad יפתרו מחלקות בהקשר של טוען המחלקות ששימש לטעינת הספרייה המשותפת. כשקוראים ל-FindClass מהקשרים אחרים, נעשה שימוש בטוען המחלקות שמשויך לשיטה בחלק העליון של מחסנית Java, או אם אין כזה (כי הקריאה היא משרשור מקורי שצורף זה עתה), נעשה שימוש בטוען המחלקות 'system'. טוען המחלקות של המערכת לא יודע על המחלקות של האפליקציה, ולכן לא תוכלו לחפש את המחלקות שלכם באמצעות FindClass בהקשר הזה. כך JNI_OnLoad הוא מקום נוח לחפש ולאחסן במטמון מחלקות: אחרי שיש לכם jclass הפניה גלובלית תקפה, אתם יכולים להשתמש בה מכל שרשור שמצורף.

שיחות מהירות יותר בפורמט מקורי עם @FastNative ועם @CriticalNative

אפשר להוסיף הערות לשיטות מובנות באמצעות @FastNative או @CriticalNative (אבל לא באמצעות שניהם) כדי להאיץ את המעברים בין קוד מנוהל לקוד מובנה. עם זאת, יש שינויים מסוימים בהתנהגות של ההערות האלה שצריך לשקול היטב לפני השימוש בהן. בהמשך מפורטים השינויים, אבל כדאי לעיין במסמכים כדי לקבל את כל הפרטים.

אפשר להחיל את ההערה @CriticalNative רק על שיטות מקוריות שלא משתמשות באובייקטים מנוהלים (בפרמטרים או בערכי החזרה, או כ-this מרומז), וההערה הזו משנה את ה-ABI של המעבר ב-JNI. ההטמעה המקורית צריכה להחריג את הפרמטרים JNIEnv ו-jclass מחתימת הפונקציה שלה.

במהלך ביצוע של שיטה @FastNative או @CriticalNative, איסוף האשפה לא יכול להשהות את השרשור לצורך עבודה חיונית, ויכול להיות שהוא ייחסם. אל תשתמשו בהערות האלה לשיטות שפועלות לאורך זמן, כולל שיטות שבדרך כלל פועלות מהר אבל לא מוגבלות. בפרט, הקוד לא צריך לבצע פעולות קלט/פלט משמעותיות או לרכוש נעילות מקומיות שיכולות להיות מוחזקות למשך זמן רב.

ההערות האלה הוטמעו לשימוש המערכת מאז Android 8, והפכו ל-API ציבורי שנבדק על ידי CTS ב-Android 14. האופטימיזציות האלה צפויות לפעול גם במכשירי Android מגרסה 8 עד 13 (אבל ללא הערבויות החזקות של CTS), אבל החיפוש הדינמי של שיטות מקוריות נתמך רק ב-Android מגרסה 12 ואילך. רישום מפורש באמצעות JNI RegisterNatives נדרש באופן מוחלט להפעלה בגרסאות Android מ-8 עד 11. המערכת מתעלמת מההערות האלה ב-Android 7 ומגרסאות קודמות. אי-התאמה של ABI עבור @CriticalNative תוביל להעברה שגויה של ארגומנטים וסביר להניח לקריסות.

לשיטות שחשובות לביצועים וצריכות את ההערות האלה, מומלץ מאוד לרשום את השיטות באופן מפורש ב-JNI RegisterNatives במקום להסתמך על 'גילוי' של שיטות מקוריות שמבוסס על השם. כדי להגיע לביצועים אופטימליים של הפעלת האפליקציה, מומלץ לכלול את המתקשרים של שיטות @FastNative או @CriticalNative בפרופיל הבסיסי. מגרסה Android 12 ואילך, קריאה לשיטה מובנית משיטה מנוהלת שעברה קומפילציה היא כמעט זולה כמו קריאה לא מוטמעת ב-C/C++, כל עוד כל הארגומנטים מתאימים לאוגרים (לדוגמה, עד 8 ארגומנטים של מספרים שלמים ועד 8 ארגומנטים של מספרים ממשיים ב-arm64).@CriticalNative

לפעמים עדיף לפצל שיטה מובנית לשתי שיטות: שיטה מהירה מאוד שיכולה להיכשל, ושיטה אחרת שמטפלת במקרים איטיים. לדוגמה:

Kotlin

fun writeInt(nativeHandle: Long, value: Int) {
    // A fast buffered write with a `@CriticalNative` method should succeed most of the time.
    if (!nativeTryBufferedWriteInt(nativeHandle, value)) {
        // If the buffered write failed, we need to use the slow path that can perform
        // significant I/O and can even throw an `IOException`.
        nativeWriteInt(nativeHandle, value)
    }
}

@CriticalNative
external fun nativeTryBufferedWriteInt(nativeHandle: Long, value: Int): Boolean

external fun nativeWriteInt(nativeHandle: Long, value: Int)

Java

void writeInt(long nativeHandle, int value) {
    // A fast buffered write with a `@CriticalNative` method should succeed most of the time.
    if (!nativeTryBufferedWriteInt(nativeHandle, value)) {
        // If the buffered write failed, we need to use the slow path that can perform
        // significant I/O and can even throw an `IOException`.
        nativeWriteInt(nativeHandle, value);
    }
}

@CriticalNative
static native boolean nativeTryBufferedWriteInt(long nativeHandle, int value);

static native void nativeWriteInt(long nativeHandle, int value);

שיקולים לגבי 64 ביט

כדי לתמוך בארכיטקטורות שמשתמשות במצביעים של 64 ביט, צריך להשתמש בשדה long במקום בשדה int כשמאחסנים מצביע למבנה מקומי בשדה Java.

תכונות שלא נתמכות או תאימות לאחור

כל התכונות של JNI 1.6 נתמכות, למעט התכונה הבאה:

  • התכונה DefineClass לא מיושמת. ‫Android לא משתמש בבייטקודים של Java או בקובצי מחלקה, ולכן העברת נתוני מחלקה בינאריים לא עובדת.

כדי להבטיח תאימות לאחור עם גרסאות ישנות יותר של Android, חשוב לשים לב לנקודות הבאות:

  • חיפוש דינמי של פונקציות מקוריות

    עד Android 2.0 ‏ (Eclair), התו '$' לא הומר בצורה תקינה ל-‎ "_00024" במהלך חיפושים של שמות שיטות. כדי לעקוף את הבעיה הזו, צריך להשתמש ברישום מפורש או להעביר את השיטות המקוריות מחוץ למחלקות הפנימיות.

  • ניתוק שרשורים

    עד Android 2.0 ‏ (Eclair), לא הייתה אפשרות להשתמש בפונקציית pthread_key_create destructor כדי להימנע מהבדיקה 'צריך לנתק את השרשור לפני היציאה'. (סביבת זמן הריצה משתמשת גם בפונקציית pthread key destructor, כך שיהיה מירוץ כדי לראות איזו פונקציה נקראת ראשונה).

  • הפניות גלובליות חלשות

    עד Android 2.2 ‏ (Froyo), לא בוצעו הפניות גלובליות חלשות. גרסאות ישנות יותר ידחו בתוקף ניסיונות להשתמש בהן. אפשר להשתמש בקבועי הגרסה של פלטפורמת Android כדי לבדוק אם יש תמיכה.

    עד Android 4.0 (Ice Cream Sandwich), אפשר היה להעביר הפניות גלובליות חלשות רק אל NewLocalRef, NewGlobalRef ו-DeleteWeakGlobalRef. (במפרט מומלץ מאוד למתכנתים ליצור הפניות קשיחות למשתנים גלובליים חלשים לפני שעושים איתם משהו, כך שלא אמורה להיות כאן מגבלה כלשהי).

    מגרסה Android 4.0 (Ice Cream Sandwich) ואילך, אפשר להשתמש בהפניות גלובליות חלשות כמו בכל הפניה אחרת של JNI.

  • הפניות מקומיות

    עד Android 4.0‏ (Ice Cream Sandwich), הפניות המקומיות היו למעשה מצביעים ישירים. ב-Ice Cream Sandwich נוספה ההפניה העקיפה שנדרשת לתמיכה במנגנוני איסוף אשפה טובים יותר, אבל המשמעות היא שהרבה באגים ב-JNI לא ניתנים לזיהוי בגרסאות ישנות יותר. פרטים נוספים זמינים במאמר שינויים בהפניות מקומיות של JNI ב-ICS.

    בגרסאות Android שקדמו ל-Android 8.0, מספר ההפניות המקומיות מוגבל למגבלה ספציפית לגרסה. החל מ-Android 8.0, ‫Android תומך בהפניות מקומיות ללא הגבלה.

  • קביעת סוג ההפניה באמצעות GetObjectRefType

    עד Android 4.0 ‏ (Ice Cream Sandwich), כתוצאה מהשימוש במצביעים ישירים (ראו למעלה), לא היה אפשר להטמיע את GetObjectRefType בצורה נכונה. במקום זאת, השתמשנו בהיוריסטיקה שחיפשה בטבלה של משתנים גלובליים חלשים, בארגומנטים, בטבלה של משתנים מקומיים ובטבלה של משתנים גלובליים, לפי הסדר הזה. בפעם הראשונה שהיא תמצא את המצביע הישיר שלכם, היא תדווח שההפניה שלכם היא מהסוג שהיא בודקת. המשמעות היא, לדוגמה, שאם קראתם ל-GetObjectRefType ב-jclass גלובלי שהיה זהה ל-jclass שהועבר כארגומנט משתמע לשיטה סטטית מקורית, קיבלתם JNILocalRefType במקום JNIGlobalRefType.

  • @FastNative ו-@CriticalNative

    עד Android 7, המערכת התעלמה מהערות האופטימיזציה האלה. אי ההתאמה של ה-ABI במקרה של @CriticalNative תוביל להעברה שגויה של ארגומנטים וכנראה לקריסות.

    החיפוש הדינמי של פונקציות מקוריות לשיטות @FastNative ו-@CriticalNative לא הוטמע ב-Android מגרסה 8 עד 10, והוא מכיל באגים ידועים ב-Android 11. שימוש באופטימיזציות האלה ללא רישום מפורש ב-JNI RegisterNatives עלול לגרום לקריסות ב-Android 8 עד 11.

  • FindClass זריקות ClassNotFoundException

    לצורך תאימות לאחור, מערכת Android מחזירה את השגיאה ClassNotFoundException במקום NoClassDefFoundError כשלא נמצאת מחלקה על ידי FindClass. ההתנהגות הזו עקבית עם Java reflection API‏ Class.forName(name).

שאלות נפוצות: למה מופיעה הודעת השגיאה UnsatisfiedLinkError?

כשעובדים על קוד מקורי, לא נדיר לראות כשל כזה:

java.lang.UnsatisfiedLinkError: Library foo not found

במקרים מסוימים, המשמעות היא בדיוק מה שכתוב – הספרייה לא נמצאה. במקרים אחרים, הספרייה קיימת אבל לא ניתן לפתוח אותה באמצעות dlopen(3), ופרטי הכשל מופיעים בהודעת הפרטים של החריגה.

סיבות נפוצות להופעת חריגים מסוג 'לא נמצאה ספריה':

  • הספרייה לא קיימת או שאין לאפליקציה גישה אליה. כדי לבדוק אם היא קיימת ומה ההרשאות שלה, צריך להשתמש ב-adb shell ls -l <path>.
  • הספרייה לא נוצרה באמצעות NDK. הדבר עלול לגרום לתלות בפונקציות או בספריות שלא קיימות במכשיר.

דוגמה נוספת לכישלונות מסוג UnsatisfiedLinkError:

java.lang.UnsatisfiedLinkError: myfunc
        at Foo.myfunc(Native Method)
        at Foo.main(Foo.java:10)

ב-logcat, תראו:

W/dalvikvm(  880): No implementation found for native LFoo;.myfunc ()V

המשמעות היא שסביבת זמן הריצה ניסתה למצוא שיטה תואמת, אבל לא הצליחה. בין הסיבות הנפוצות לכך:

  • הספרייה לא נטענת. בודקים את הפלט של Logcat כדי לראות אם יש הודעות לגבי טעינת הספריות.
  • השיטה לא נמצאת בגלל חוסר התאמה בשם או בחתימה. הסיבות הנפוצות לכך הן:
    • בחיפוש שיטות עצלניות, אם לא מצהירים על פונקציות C++‎ עם extern "C" ועם הגדרת הראות המתאימה (JNIEXPORT). שימו לב: לפני Ice Cream Sandwich, מאקרו JNIEXPORT היה שגוי, ולכן שימוש ב-GCC חדש עם jni.h ישן לא יפעל. אפשר להשתמש ב-arm-eabi-nm כדי לראות את הסמלים כמו שהם מופיעים בספרייה. אם הם נראים מעוותים (משהו כמו _Z15Java_Foo_myfuncP7_JNIEnvP7_jclass במקום Java_Foo_myfunc), או אם סוג הסמל הוא 't' באותיות קטנות במקום 'T' באותיות גדולות, צריך לשנות את ההצהרה.
    • במקרה של רישום מפורש, יכולות להיות שגיאות קלות בהזנת חתימת השיטה. חשוב לוודא שהערך שמועבר לקריאת הרישום תואם לחתימה בקובץ היומן. חשוב לזכור ש-B הוא byte ו-Z הוא boolean. רכיבי שם המחלקה בחתימות מתחילים ב-'L', מסתיימים ב-';', משתמשים ב-'/' כדי להפריד בין שמות החבילות והמחלקות, ומשתמשים ב-'$' כדי להפריד בין שמות המחלקות הפנימיות (לדוגמה, Ljava/util/Map$Entry;).

שימוש ב-javah כדי ליצור באופן אוטומטי כותרות JNI יכול לעזור למנוע בעיות מסוימות.

שאלות נפוצות: למה FindClass לא מצא את הכיתה שלי?

(רוב העצות האלה רלוונטיות גם למקרים שבהם לא נמצאו שיטות עם GetMethodID או GetStaticMethodID, או שדות עם GetFieldID או GetStaticFieldID).

חשוב לוודא שמחרוזת שם המחלקה בפורמט הנכון. שמות של מחלקות JNI מתחילים בשם החבילה ומופרדים באמצעות לוכסנים, כמו java/lang/String. אם מחפשים מחלקה של מערך, צריך להתחיל עם המספר המתאים של סוגריים מרובעים, וגם להוסיף את המחלקה בין 'L' לבין ';'. לכן, מערך חד-ממדי של String יהיה [Ljava/lang/String;. אם מחפשים מחלקה פנימית, צריך להשתמש ב-'$' ולא ב-'.'. באופן כללי, שימוש ב-javap בקובץ ‎ .class הוא דרך טובה לגלות את השם הפנימי של המחלקה.

אם מפעילים כיווץ קוד, חשוב להגדיר איזה קוד לשמור. חשוב להגדיר כללי שמירה נכונים, כי אחרת כלי הכיווץ של הקוד עלול להסיר מחלקות, שיטות או שדות שמשמשים רק מ-JNI.

אם שם הכיתה נראה נכון, יכול להיות שנתקלת בבעיה בטוען הכיתות. ‫FindClass רוצה להתחיל את החיפוש בכיתה בטוען הכיתות שמשויך לקוד שלך. הוא בודק את מחסנית הקריאות, שנראית בערך כך:

    Foo.myfunc(Native Method)
    Foo.main(Foo.java:10)

ה-method העליון הוא Foo.myfunc. ‫FindClass מוצא את האובייקט ClassLoader שמשויך למחלקה Foo ומשתמש בו.

בדרך כלל זה מה שרוצים. אתם עלולים להסתבך אם תיצרו בעצמכם שרשור (למשל, על ידי קריאה ל-pthread_create ואז צירוף שלו באמצעות AttachCurrentThread). במקרה כזה לא יהיו פריימים של מחסנית מהאפליקציה שלכם. אם קוראים ל-FindClass מהשרשור הזה, ה-JavaVM יתחיל בטוען המחלקות 'system' במקום בטוען המחלקות שמשויך לאפליקציה, ולכן ניסיונות למצוא מחלקות ספציפיות לאפליקציה ייכשלו.

יש כמה דרכים לעקוף את הבעיה:

  • מבצעים את החיפושים של FindClass פעם אחת, ב-JNI_OnLoad, ומאחסנים במטמון את ההפניות למחלקות לשימוש מאוחר יותר. כל קריאה ל-FindClass שמתבצעת כחלק מההרצה של JNI_OnLoad תשתמש בטוען המחלקות שמשויך לפונקציה שקראה ל-System.loadLibrary (זהו כלל מיוחד שנועד להקל על אתחול הספריות). אם קוד האפליקציה טוען את הספרייה, FindClass ייעשה שימוש בטוען המחלקות הנכון.
  • מעבירים מופע של המחלקה לפונקציות שזקוקות לה, על ידי הצהרה על שיטה מקורית לקבלת ארגומנט Class, ואז מעבירים את Foo.class.
  • שומרים במטמון הפניה לאובייקט ClassLoader במקום נוח, ומבצעים קריאות ישירות ל-loadClass. הפעולה הזו דורשת מאמץ מסוים.

שאלות נפוצות: איך משתפים נתונים גולמיים עם קוד מקורי?

יכול להיות שתצטרכו לגשת למאגר גדול של נתונים גולמיים מקוד מנוהל ומקוד מקורי. דוגמאות נפוצות לכך הן מניפולציה של מפות סיביות או דגימות של צלילים. יש שתי גישות בסיסיות.

אפשר לאחסן את הנתונים ב-byte[]. כך אפשר לגשת אליהם במהירות רבה מקוד מנוהל. עם זאת, בצד המקורי, לא מובטח שתהיה לכם גישה לנתונים בלי שתצטרכו להעתיק אותם. ביישומים מסוימים, הפונקציות GetByteArrayElements ו-GetPrimitiveArrayCritical יחזירו מצביעים בפועל לנתונים הגולמיים ב-managed heap, אבל ביישומים אחרים הן יקצו מאגר ב-native heap ויעתיקו את הנתונים.

אפשרות אחרת היא לאחסן את הנתונים במאגר בייטים ישיר. אפשר ליצור אותם באמצעות java.nio.ByteBuffer.allocateDirect או הפונקציה NewDirectByteBuffer של JNI. בניגוד למאגרי בייטים רגילים, האחסון לא מוקצה בערימה המנוהלת, ותמיד אפשר לגשת אליו ישירות מקוד מקורי (מקבלים את הכתובת באמצעות GetDirectBufferAddress). בהתאם לאופן שבו מיושמת גישה ישירה למאגר בייטים, הגישה לנתונים מקוד מנוהל יכולה להיות איטית מאוד.

הבחירה באיזה מהם להשתמש תלויה בשני גורמים:

  1. האם רוב הגישות לנתונים יתבצעו מקוד שנכתב ב-Java או ב-C/C++?
  2. אם הנתונים מועברים בסופו של דבר ל-API של מערכת, באיזה פורמט הם צריכים להיות? (לדוגמה, אם הנתונים מועברים בסופו של דבר לפונקציה שמקבלת byte[], יכול להיות שלא כדאי לבצע עיבוד ישיר של ByteBuffer).

אם אין מנצח ברור, משתמשים במאגר בייטים ישיר. התמיכה בהם מוטמעת ישירות ב-JNI, והביצועים צפויים להשתפר בגרסאות עתידיות.