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. שימוש באופטימיזציות האלה ללא רישום מפורש ב-JNIRegisterNatives
עלול לגרום לקריסות ב-Android 8 עד 11. FindClass
זריקותClassNotFoundException
לצורך תאימות לאחור, מערכת Android מחזירה את השגיאה
ClassNotFoundException
במקוםNoClassDefFoundError
כשלא נמצאת מחלקה על ידיFindClass
. ההתנהגות הזו עקבית עם Java reflection APIClass.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;
).
- בחיפוש שיטות עצלניות, אם לא מצהירים על פונקציות C++ עם
שימוש ב-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
). בהתאם לאופן שבו מיושמת גישה ישירה למאגר בייטים, הגישה לנתונים מקוד מנוהל יכולה להיות איטית מאוד.
הבחירה באיזה מהם להשתמש תלויה בשני גורמים:
- האם רוב הגישות לנתונים יתבצעו מקוד שנכתב ב-Java או ב-C/C++?
- אם הנתונים מועברים בסופו של דבר ל-API של מערכת, באיזה פורמט הם צריכים להיות? (לדוגמה, אם הנתונים מועברים בסופו של דבר לפונקציה שמקבלת byte[], יכול להיות שלא כדאי לבצע עיבוד ישיר של
ByteBuffer
).
אם אין מנצח ברור, משתמשים במאגר בייטים ישיר. התמיכה בהם מוטמעת ישירות ב-JNI, והביצועים צפויים להשתפר בגרסאות עתידיות.