
هل ترغب في إضافة بعض الميزات المفيدة إلى JVM؟ من الناحية النظرية ، يمكن لكل مطور المساهمة في OpenJDK ، ومع ذلك ، في الممارسة العملية ، فإن أي تغييرات غير تافهة على HotSpot ليست موضع ترحيب كبير من الجانب ، وحتى مع دورة الإصدار المختصرة الحالية ، فقد يستغرق الأمر سنوات حتى يرى مستخدمو JDK الميزة الخاصة بك.
ومع ذلك ، في بعض الحالات ، يمكن توسيع وظيفة الجهاز الظاهري دون حتى لمس الرمز الخاص به. واجهة أداة JVM ، واجهة برمجة التطبيقات القياسية للتفاعل مع JVM ، تساعد.
في هذا المقال ، سأعرض بأمثلة ملموسة ما يمكن القيام به ، وأقول ما الذي تغير في جاوة 9 و 11 ، وأحذر بصدق من الصعوبات (المفسد: يجب أن أتعامل مع C ++).
تحدثت أيضا عن هذه المواد على JPoint. إذا كنت تفضل الفيديو ، يمكنك مشاهدة تقرير
الفيديو .
دخول
الشبكة الاجتماعية Odnoklassniki ، حيث أعمل مهندسًا رئيسيًا ، مكتوبة بالكامل تقريبًا بلغة Java. لكن اليوم سوف أخبركم فقط عن جزء آخر ، وهو ليس في Java تمامًا.
كما تعلمون ، فإن المشكلة الأكثر شيوعًا في مطوري Java هي NullPointerException. مرة واحدة ، أثناء الخدمة على البوابة ، صادفت أيضًا NPE في الإنتاج. كان الخطأ مصحوبًا بمثل هذا التتبع المكدس:

بالطبع ، على تتبع المكدس ، يمكنك تتبع المكان الذي حدث فيه الاستثناء حتى سطر معين في الكود. في هذه الحالة فقط ، لم يجعلني أشعر بالتحسن ، لأن NPE هنا يمكن أن يجتمع كثيرًا حيث:

سيكون من الرائع لو أن JVM اقترحت بالضبط أين كان هذا الخطأ ، على سبيل المثال ، مثل هذا:
java.lang.NullPointerException: Called 'getUsers()' method on null object
لكن لسوء الحظ ، لا يحتوي NPE الآن على أي شيء من هذا القبيل. على الرغم من أنهم كانوا يطلبون ذلك لفترة طويلة ، على الأقل مع Java 1.4:
هذا الخطأ كان عمره 16 عامًا. بشكل دوري ، تم فتح المزيد من الأخطاء في هذا الموضوع ، لكن تم إغلاقها دائمًا باسم "لن إصلاح":

هذا لا يحدث في كل مكان.
أخبر Volker Simonis من SAP كيف قاموا بتطبيق هذه الميزة في SAP JVM لفترة طويلة وساعدوها أكثر من مرة. قام موظف SAP آخر
بتقديم خطأ في OpenJDK وتطوع لتنفيذ آلية مماثلة لما هو موجود في SAP JVM. وها ، هذه المرة لم يتم إغلاق الخلل - هناك احتمال أن تدخل هذه الميزة إلى JDK 14.
ولكن متى سيتم إصدار JDK 14 ، ومتى سننتقل إليه؟ ماذا تفعل إذا كنت تريد التحقيق في المشكلة هنا والآن؟
يمكنك ، بالطبع ، الحفاظ على شوكة OpenJDK الخاص بك. ميزة الإبلاغ عن NPE نفسها ليست معقدة للغاية ، فقد كان من الممكن أن ننفذها. ولكن في الوقت نفسه ، ستكون هناك كل مشاكل دعم التجميع الخاص بك. سيكون من الرائع تنفيذ هذه الميزة مرة واحدة ، ثم توصيلها ببساطة بأي إصدار من JVM كمكون إضافي. وهذا ممكن حقا! لدى JVM واجهة برمجة تطبيقات خاصة (تم تطويرها في الأصل لجميع أنواع برامج تصحيح الأخطاء والمقاطعين): واجهة أداة JVM.
الأهم من ذلك ، هذا API هو المعيار. لديه
مواصفات صارمة ، وعند تطبيق ميزة تتوافق معها ، يمكنك التأكد من أنها ستعمل في إصدارات جديدة من JVM.
لاستخدام هذه الواجهة ، تحتاج إلى كتابة برنامج صغير (أو كبير ، حسب مهامك). أصلي: عادة ما يتم كتابته في C أو C ++. يحتوي
jdk/include/jvmti.h
JDK القياسي
jdk/include/jvmti.h
ملف رأس
jdk/include/jvmti.h
الذي تريد تضمينه.
يتم تصنيف البرنامج في مكتبة ديناميكية ، ومتصلة بواسطة المعلمة
-agentpath
أثناء بدء JVM. من المهم عدم الخلط بينه وبين معلمة أخرى مماثلة: -
-javaagent
. في الواقع ، يعتبر عملاء Java حالة خاصة لوكلاء JVM TI. كذلك في النص تحت كلمة "وكيل" المقصود بالضبط الوكيل الأصلي.
من أين تبدأ
دعونا نرى في الممارسة العملية كيفية كتابة أبسط وكيل JVM TI ، وهو نوع من "عالم الترحيب".
#include <jvmti.h> #include <stdio.h> JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM* vm, char* options, void* reserved) { jvmtiEnv* jvmti; vm->GetEnv((void**) &jvmti, JVMTI_VERSION_1_0); char* vm_name = NULL; jvmti->GetSystemProperty("java.vm.name", &vm_name); printf("Agent loaded. JVM name = %s\n", vm_name); fflush(stdout); return 0; }
السطر الأول أضمّن ملف الرأس نفسه. بعد ذلك تأتي الوظيفة الرئيسية التي يجب تنفيذها في الوكيل:
Agent_OnLoad()
. يستدعي الجهاز الظاهري نفسه عندما يقوم العامل بالتمهيد ، ويمرر المؤشر إلى كائن
JavaVM*
.
باستخدامه ، يمكنك الحصول على مؤشر إلى بيئة JVM TI:
jvmtiEnv*
. ومن خلالها ، بدوره ، ندعو بالفعل JVM TI- الوظائف. على سبيل المثال ، باستخدام
GetSystemProperty ، اقرأ قيمة خاصية النظام.
إذا قمت الآن بتشغيل "hello world" ، بتمرير ملف dll
-agentpath
إلى
-agentpath
،
-agentpath
الخط الذي طبعه وكيلنا في وحدة التحكم قبل بدء تشغيل برنامج Java:

إثراء NPE
بما أن hello world ليس المثال الأكثر إثارة للاهتمام ، فلنرجع إلى استثناءاتنا. رمز الوكيل الكامل الذي يكمل تقارير NPE موجود
على GitHub .
هذا ما
Agent_OnLoad()
عليه
Agent_OnLoad()
إذا كنت أريد أن أطلب من الجهاز الظاهري إخطارنا بجميع الاستثناءات:
JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM* vm, char* options, void* reserved) { jvmtiEnv* jvmti; vm->GetEnv((void**) &jvmti, JVMTI_VERSION_1_0); jvmtiCapabilities capabilities = {0}; capabilities.can_generate_exception_events = 1; jvmti->AddCapabilities(&capabilities); jvmtiEventCallbacks callbacks = {0}; callbacks.Exception = ExceptionCallback; jvmti->SetEventCallbacks(&callbacks, sizeof(callbacks)); jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_EXCEPTION, NULL); return 0; }
أولاً ، أطلب من JVM TI القدرة المقابلة (can_generate_exception_events). سنتحدث عن القدرة بشكل منفصل.
الخطوة التالية هي الاشتراك في أحداث الاستثناء. كلما ألقت JVM استثناءات (بغض النظر عما إذا كانت قد تم اكتشافها أم لا) ، فسيتم استدعاء دالة
ExceptionCallback()
.
الخطوة الأخيرة هي استدعاء
SetEventNotificationMode()
لتمكين تسليم الإخطارات.
في ExceptionCallback ، يمرر JVM كل ما نحتاجه للتعامل مع الاستثناءات. void JNICALL ExceptionCallback(jvmtiEnv* jvmti, JNIEnv* env, jthread thread, jmethodID method, jlocation location, jobject exception, jmethodID catch_method, jlocation catch_location) { jclass NullPointerException = env->FindClass("java/lang/NullPointerException"); if (!env->IsInstanceOf(exception, NullPointerException)) { return; } jclass Throwable = env->FindClass("java/lang/Throwable"); jfieldID detailMessage = env->GetFieldID(Throwable, "detailMessage", "Ljava/lang/String;"); if (env->GetObjectField(exception, detailMessage) != NULL) { return; } char buf[32]; sprintf(buf, "at location %id", (int) location); env->SetObjectField(exception, detailMessage, env->NewStringUTF(buf)); }
يوجد هنا كل من كائن سلسلة الرسائل الذي ألقى الاستثناء (مؤشر الترابط) والمكان الذي حدث فيه هذا (الأسلوب ، والموقع) ، وكائن الاستثناء (الاستثناء) ، بل والمكان في التعليمات البرمجية التي تكتشف هذا الاستثناء (catch_method ، catch_location).
ما هو مهم: في هذا رد الاتصال ، بالإضافة إلى المؤشر إلى بيئة JVM TI ، يتم تمرير بيئة JNI (env) أيضًا. هذا يعني أنه يمكننا استخدام جميع وظائف JNI فيه. أي أن JVM TI و JNI يتعايشان تمامًا ، ويكمل كل منهما الآخر.
في بلدي وكيل أستخدمه على حد سواء. على وجه الخصوص ، من خلال JNI ، أتحقق من أن الاستثناء الخاص بي هو من النوع
NullPointerException
، ثم أقوم باستبدال حقل
detailMessage
برسالة خطأ.
نظرًا لأن JVM نفسها تمررنا إلى الموقع - فهرس الرمز البريدي الذي حدث الاستثناء ، عندئذٍ أضع هذا الموقع هنا في الرسالة:

يشير الرقم 66 إلى الفهرس في bytecode حيث حدث هذا الاستثناء. لكن تحليل الرمز الثانوي يدويًا كئيبًا: تحتاج إلى فك تشفير ملف الفصل والبحث عن التعليمة السادسة والستين ومحاولة فهم ما كانت تقوم به ... سيكون من الرائع لو استطاع وكيلنا نفسه إظهار شيء أكثر قابلية للقراءة البشرية.
ومع ذلك ، في هذه الحالة ، فإن JVM TI لديه كل ما تحتاجه. صحيح ، عليك أن تطلب ميزات إضافية من JVM TI: احصل على كود البايت وطريقة البلياردو الثابتة.
jvmtiCapabilities capabilities = {0}; capabilities.can_generate_exception_events = 1; capabilities.can_get_bytecodes = 1; capabilities.can_get_constant_pool = 1; jvmti->AddCapabilities(&capabilities);
سأقوم الآن بتمديد ExceptionCallback: من خلال وظيفة JVM TI ،
GetBytecodes()
سأحصل على النص الأساسي للتحقق من
GetBytecodes()
من خلال فهرس الموقع. فيما يلي إرشادات رمز التبديل الكبيرة: إذا كان هذا وصولًا إلى الصفيف ، فستكون هناك رسالة خطأ واحدة ، إذا كان الوصول إلى الحقل هو رسالة أخرى ، وإذا كانت استدعاء الأسلوب هي الثالثة ، وهكذا.
كود الاستثناء jint bytecode_count; u1* bytecodes; if (jvmti->GetBytecodes(method, &bytecode_count, &bytecodes) != 0) { return; } if (location >= 0 && location < bytecode_count) { const char* message = get_exception_message(bytecodes[location]); if (message != NULL) { ... env->SetObjectField(exception, detailMessage, env->NewStringUTF(buf)); } } jvmti->Deallocate(bytecodes);
يبقى فقط استبدال اسم الحقل أو الطريقة. يمكنك الحصول عليها من
التجمع الثابت ، والذي يتوفر مرة أخرى بفضل JVM TI.
if (jvmti->GetConstantPool(holder, &cpool_count, &cpool_bytes, &cpool) != 0) { return strdup("<unknown>"); }
التالي يأتي بعض الشيء من السحر ، ولكن في الواقع لا يوجد شيء صعب ، فقط وفقًا
لمواصفات تنسيق ملف الفصل ، نقوم بتحليل المجموعة الدائمة ومن هناك نعزل السطر - اسم الطريقة.
تحليل تجمع ثابت u1* ref = get_cpool_at(cpool, get_u2(bytecodes + 1));
نقطة مهمة أخرى: بعض وظائف JVM TI ، على سبيل المثال
GetConstantPool()
أو
GetBytecodes()
، تخصص بنية معينة في الذاكرة الأصلية ، والتي يجب تحريرها عند الانتهاء من العمل معها.
jvmti->Deallocate(cpool);
قم بتشغيل البرنامج المصدر باستخدام العامل الموسع الخاص بنا ، وفيما يلي وصف مختلف تمامًا عن الاستثناء: يشير إلى أننا أطلقنا على الأسلوب longValue () على الكائن الفارغ.

تطبيقات أخرى
بشكل عام ، غالبًا ما يريد المطورون التعامل مع الاستثناءات بطريقتهم الخاصة. على سبيل المثال ، قم بإعادة تشغيل JVM تلقائيًا في حالة
StackOverflowError
.
يمكن فهم هذه الرغبة ، نظرًا لأن
StackOverflowError
هو نفس الخطأ القاتل الذي
OutOfMemoryError
، بعد حدوثه ، لم يعد من الممكن ضمان التشغيل الصحيح للبرنامج. أو ، على سبيل المثال ، أحيانًا لتحليل المشكلة ، أريد تلقي تفريغ مؤشر ترابط أو تفريغ كومة الذاكرة المؤقتة عند حدوث استثناء.

في الإنصاف ، لدى IBM JDK هذه الفرصة خارج الصندوق. لكننا نعرف الآن أنه باستخدام وكيل JVM TI ، يمكنك تطبيق نفس الشيء في HotSpot. يكفي الاشتراك في رد الاتصال الاستثنائي وتحليل الاستثناء. ولكن كيفية إزالة تفريغ الصفحات أو تفريغ كومة الذاكرة المؤقتة من وكيلنا؟ يحتوي JVM TI على كل ما تحتاجه لهذه الحالة:

ليس من المريح جدًا تنفيذ الآلية الكاملة لتجاوز الكومة وإنشاء تفريغ. لكنني سوف أشارك سر كيفية جعلها أسهل وأسرع. صحيح ، لم يعد هذا مدرجًا في JVM TI القياسي ، ولكنه امتداد خاص لـ Hotspot.
تحتاج إلى توصيل ملف الرأس
jmm.h من مصادر HotSpot واستدعاء وظيفة
JVM_GetManagement()
:
#include "jmm.h" JNIEXPORT void* JNICALL JVM_GetManagement(jint version); void JNICALL ExceptionCallback(jvmtiEnv* jvmti, JNIEnv* env, ...) { JmmInterface* jmm = (JmmInterface*) JVM_GetManagement(JMM_VERSION_1_0); jmm->DumpHeap0(env, env->NewStringUTF("dump.hprof"), JNI_FALSE); }
سيقوم بإرجاع مؤشر إلى واجهة إدارة HotSpot ، والتي ستقوم في مكالمة واحدة بإنشاء تفريغ كومة أو تفريغ مؤشر ترابط. يمكن العثور على الرمز الكامل للمثال في
إجابتي على Stack Overflow.
بطبيعة الحال ، لا يمكنك التعامل مع الاستثناءات فقط ، ولكن أيضًا مجموعة من الأحداث المتنوعة الأخرى المرتبطة بعملية JVM: بدء / إيقاف مؤشرات الترابط ، فئات التحميل ، تجميع القمامة ، أساليب التجميع ، طرق الدخول / الخروج ، حتى الوصول إلى أو تعديل مجالات محددة من كائنات Java.
لدي مثال على وكيل
vmtrace آخر يشترك في العديد من أحداث JVM TI القياسية
ويسجلها . إذا قمت بتشغيل برنامج بسيط مع هذا الوكيل ، فسوف أحصل على سجل مفصل ، والذي عند الانتهاء من ذلك ، مع الطوابع الزمنية:

كما ترون ، لطباعة عالم الترحيب ببساطة ، يتم تحميل مئات الفئات ، ويتم إنشاء العشرات ومئات الطرق. يصبح من الواضح لماذا جافا تستغرق وقتا طويلا لتشغيل. استغرق كل شيء عن كل شيء أكثر من مائتي مللي ثانية.
ما يمكن أن تفعله JVM TI
بالإضافة إلى التعامل مع الأحداث ، يحتوي JVM TI على مجموعة من الميزات الأخرى. يمكن تقسيمها إلى مجموعتين.
أحدهما إلزامي ، يجب على أي JVM يدعم JVM TI تنفيذه. يتضمن ذلك عمليات تحليل الأساليب والحقول والتدفقات والقدرة على إضافة فئات جديدة إلى classpath وما إلى ذلك.
هناك ميزات اختيارية تتطلب طلب قدرات أولية. JVM ليس مطلوبًا لدعمهم جميعًا ، ومع ذلك ، يقوم HotSpot بتنفيذ المواصفات بالكامل. تنقسم الميزات الاختيارية إلى مجموعتين فرعيتين: تلك التي لا يمكن توصيلها إلا في بداية JVM (على سبيل المثال ، القدرة على تعيين نقطة توقف أو تحليل المتغيرات المحلية) ، وتلك التي يمكن توصيلها في أي وقت (على وجه الخصوص ، الرمز الفرعي أو التجمع الثابت ، والذي يمكنني المستخدمة أعلاه).

قد تلاحظ أن قائمة الميزات مشابهة جدًا لقدرات مصحح الأخطاء. في الواقع ، فإن مصحح أخطاء Java ليس أكثر من حالة خاصة من وكيل JVM TI ، الذي يستفيد من كل هذه القدرات ويطلب جميع القدرات.
يتم فصل الإمكانات عن تلك التي يمكن تمكينها في أي وقت ، وتلك التي تكون فقط في وقت التمهيد ، عن قصد. ليست كل الميزات مجانية ، وبعضها يحمل النفقات العامة.
إذا كان كل شيء واضحًا مع النفقات العامة المباشرة المصاحبة لاستخدام الميزة ، فهناك حتى أقل وضوحًا غير مباشر يظهر حتى إذا كنت لا تستخدم هذه الميزة ، ولكن ببساطة من خلال القدرات التي تعلن أنها ستكون مطلوبة في وقت ما في المستقبل. ويرجع ذلك إلى حقيقة أن الجهاز الظاهري يمكنه تجميع الشفرة بشكل مختلف أو إضافة اختبارات إضافية إلى وقت التشغيل.
على سبيل المثال ، تؤدي القدرة المدروسة بالفعل للاشتراك في الاستثناءات (can_generate_exception_events) إلى حقيقة أن جميع الاستثناءات التي يتم طرحها سيتم بطيئًا. من حيث المبدأ ، هذا ليس مخيفًا جدًا ، لأن الاستثناءات شيء نادر في برنامج Java جيد.
الوضع مع المتغيرات المحلية هو أسوأ قليلا. بالنسبة إلى can_access_local_variables ، التي تسمح لك بالحصول على قيم المتغيرات المحلية في أي وقت ، تحتاج إلى تعطيل بعض التحسينات المهمة. على وجه الخصوص ، تتوقف Escape Analysis تمامًا عن العمل ، مما يمكن أن يؤدي إلى زيادة ملحوظة: اعتمادًا على التطبيق ، 5-10٪.
ومن هنا الاستنتاج: إذا قمت بتشغيل Java مع تشغيل عامل التصحيح ، حتى دون استخدامه ، سيتم تشغيل التطبيقات بشكل أبطأ. على أي حال ، إن تضمين عامل تصحيح في الإنتاج ليس فكرة جيدة.
هناك عدد من الميزات ، على سبيل المثال ، تعيين نقطة توقف أو تتبع جميع المدخلات / المخرجات من إحدى الطرق ، تحمل تكاليف أكثر خطورة بكثير. على وجه الخصوص ، تعمل بعض أحداث JVM TI (FieldAccess و MethodEntry / Exit) فقط في المترجم الفوري.
وكيل واحد هو جيد ، واثنين هو أفضل
يمكنك توصيل عدة عوامل بعملية واحدة عن طريق تحديد العديد من معلمات
-agentpath
. سيكون لكل شخص بيئة JVM TI الخاصة به. هذا يعني أنه يمكن للجميع الاشتراك في قدراتهم واعتراض أحداثهم بشكل مستقل.
وإذا اشترك وكيلان في حدث Breakpoint ، وفي أحدهما تم تعيين نقطة توقف في بعض الطرق ، فعند تنفيذ هذه الطريقة ، هل سيتلقى الوكيل الثاني الحدث؟
في الواقع ، لا يمكن أن يحدث مثل هذا الموقف (على الأقل في HotSpot JVM). لأن هناك بعض القدرات التي يمكن أن يمتلكها أحد الوكلاء فقط في أي وقت محدد. وتشمل هذه breakpoint_events على وجه الخصوص. لذلك ، إذا طلب العميل الثاني نفس الإمكانية ، فسيتلقى خطأً في الاستجابة.
هذا استنتاج مهم: يجب على الوكيل التحقق دائمًا من نتيجة طلب الإمكانيات ، حتى إذا كنت تعمل على HotSpot وتعلم أن جميعها متوفرة. لا تذكر مواصفات JVM TI شيئًا عن القدرات الحصرية ، ولكن HotSpot لديه ميزة تنفيذ من هذا القبيل.
صحيح ، العزلة وكيل لا يعمل دائما تماما. خلال تطوير برنامج
async-profiler ، صادفت هذه المشكلة: عندما يكون لدينا وكيلان ويطلب أحدهما إنشاء أحداث تجميع الطريقة ، يتلقى جميع الوكلاء هذه الأحداث. بالطبع ، قمت بتقديم
خطأ ، لكن يجب أن تضع في اعتبارك أن الأحداث التي لا تتوقعها قد تحدث في وكيلك.
الاستخدام في البرنامج العادي
قد يبدو JVM TI شيئًا محددًا جدًا لمصححات الأخطاء والمقاطعين ، لكن يمكن استخدامه أيضًا في برنامج Java عادي. النظر في مثال.
نموذج البرمجة التفاعلية واسع الانتشار الآن عندما يكون كل شيء غير متزامن ، ولكن هناك مشكلة في هذا النموذج.
public class TaskRunner { private static void good() { CompletableFuture.runAsync(new AsyncTask(GOOD)); } private static void bad() { CompletableFuture.runAsync(new AsyncTask(BAD)); } public static void main(String[] args) throws Exception { good(); bad(); Thread.sleep(200); } }
أركض مهمتين غير متزامنتين تختلفان فقط في المعلمات. وإذا حدث خطأ ما ، يتم رفع استثناء:

من تتبع المكدس ، من غير الواضح تمامًا أي من هذه المهام تسببت في المشكلة. لأن الاستثناء يحدث في سلسلة رسائل مختلفة تمامًا ، حيث لا يوجد لدينا سياق. كيف نفهم في أي مهمة؟
كأحد الحلول ، يمكنك إضافة معلومات حول المكان الذي أنشأناه فيه إلى مُنشئ مهمتنا غير المتزامنة:
public AsyncTask(String arg) { this.arg = arg; this.location = getLocation(); }
وهذا هو ، تذكر الموقع - مكان محدد في الكود ، وصولاً إلى السطر من حيث تم استدعاء المنشئ. وفي حالة استثناء التعهد بذلك:
try { int n = Integer.parseInt(arg); } catch (Throwable e) { System.err.println("ParseTask failed at " + location); e.printStackTrace(); }
الآن ، عند حدوث استثناء ، سنرى أن هذا حدث على السطر 14 في TaskRunner (حيث يتم إنشاء المهمة باستخدام معلمة BAD):

ولكن كيف تحصل على المكان في الكود الذي يتم استدعاء المنشئ منه؟ قبل الإصدار 9 من Java ، كانت هناك الطريقة القانونية الوحيدة للقيام بذلك: الحصول على تتبع مكدس ، وتخطي بعض الإطارات غير الملائمة ، وسيكون المكان الذي يطلق عليه الكود لدينا أقل قليلاً من المكدس.
String getLocation() { StackTraceElement caller = Thread.currentThread().getStackTrace()[3]; return caller.getFileName() + ':' + caller.getLineNumber(); }
ولكن هناك مشكلة. الحصول على StackTrace الكامل بطيء جدًا. لدي
تقرير كامل مكرس لهذا.
لن تكون هذه مشكلة كبيرة إذا حدث ذلك نادرًا. ولكن ، على سبيل المثال ، لدينا خدمة ويب - الواجهة الأمامية التي تقبل طلبات HTTP. هذا تطبيق رائع ، ملايين الأسطر من التعليمات البرمجية. وللاطلاع على أخطاء العرض ، نستخدم آلية مماثلة: في مكونات العرض ، نتذكر المكان الذي تم إنشاؤه فيه. لدينا الملايين من هذه المكونات ، لذلك فإن الحصول على جميع آثار المكدس يستغرق وقتًا ملموسًا لبدء التطبيق ، وليس دقيقة واحدة فقط. لذلك ، تم تعطيل هذه الميزة مسبقًا في الإنتاج ، على الرغم من أن تحليل المشكلات ضروري في الإنتاج.
قدم Java 9 طريقة جديدة لتجاوز مجموعات البيانات: StackWalker ، والتي من خلال واجهة برمجة تطبيقات Stream يمكن أن تفعل كل هذا بالكسل ، عند الطلب. وهذا يعني أنه يمكننا تخطي العدد الصحيح من الإطارات والحصول على إطار واحد فقط يهمنا.
String getLocation() { return StackWalker.getInstance().walk(s -> { StackWalker.StackFrame frame = s.skip(3).findFirst().get(); return frame.getFileName() + ':' + frame.getLineNumber(); }); }
إنه يعمل بشكل أفضل قليلاً من الحصول على تتبع مكدس كامل ، ولكن ليس بترتيب من الحجم أو حتى عدة مرات. في حالتنا ، اتضح أنه أسرع بنحو مرة ونصف:

هناك
مشكلة معروفة في التنفيذ الأمثل لـ StackWalker ، وعلى الأرجح أنه سيتم إصلاحه في JDK 13. لكن مرة أخرى ، ما الذي يجب أن نفعله الآن في Java 8 ، حيث StackWalker ليس بطيئًا حتى؟
وتأتي JVM TI للإنقاذ مرة أخرى. هناك وظيفة
GetStackTrace()
يمكنها القيام بكل ما تحتاجه: الحصول على جزء من تتبع مكدس بطول معين ، بدءًا من الإطار المحدد ، ولا تفعل شيئًا أكثر.
GetStackTrace(jthread thread, jint start_depth, jint max_frame_count, jvmtiFrameInfo* frame_buffer, jint* count_ptr)
يبقى سؤال واحد فقط: كيفية استدعاء وظيفة JVM TI من برنامج Java الخاص بنا؟ تمامًا مثل أي طريقة أصلية أخرى: قم بتحميل المكتبة الأصلية باستخدام
System.loadLibrary()
، حيث سيكون تطبيق JNI لأسلوبنا.
public class StackFrame { public static native String getLocation(int depth); static { System.loadLibrary("stackframe"); } }
يمكن الحصول على مؤشر إلى بيئة JVM TI ليس فقط من Agent_OnLoad () ، ولكن أيضًا أثناء تشغيل البرنامج ، ولمتابعة استخدامه من خلال طرق JNI الأصلية العادية:
JNIEXPORT jstring JNICALL Java_StackFrame_getLocation(JNIEnv* env, jclass unused, jint depth) { jvmtiFrameInfo frame; jint count; jvmti->GetStackTrace(NULL, depth, 1, &frame, &count);
هذا النهج أسرع بالفعل عدة مرات وسمح لنا بحفظ عدة دقائق من بدء التطبيق:
صحيح ، مع تحديث JDK التالي ، لقد فوجئنا بحقيقة أن التطبيق بدأ فجأة في البدء ببطء شديد. أدى التحقيق إلى مكتبة أصلية لتلقي آثار المكدس. فهم ، لقد توصلنا إلى استنتاج مفاده أن الخطأ لم يظهر في مكاننا ، ولكن في JDK. بدءًا من JDK 8u112 ، أصبحت جميع وظائف JVM TI التي تعمل مع الأساليب (GetMethodName و GetMethodDeclaringClass وما إلى ذلك) بطيئة للغاية.لقد بدأت بعض الأخطاء ، وقمت بإجراء القليل من البحث ، واكتشفت قصة مضحكة: أضافت بعض وظائف JVM TI اختبارات تصحيح الأخطاء ، لكنني لم ألاحظ أنها استُدعيت من رمز الإنتاج أيضًا. لم يتم العثور على سيناريو الاستخدام هذا ، لأنه لم يكن موجودًا في التعليمات البرمجية المصدر في C ++ ، ولكن في الملفjvmtiEnter.xsl .تخيل: أثناء تجميع HotSpot ، يتم إنشاء جزء من الكود المصدري سريعًا من خلال تحويل XSLT. هذه هي الطريقة التي عادت بها المؤسسة إلى HotSpot.ماذا يمكن أن يكون الحل؟ فقط لا تستدعي هذه الوظائف كثيرًا ، حاول تخزين النتائج مؤقتًا. وهذا هو ، إذا تم تلقي بعض المعلومات jmethodID ، تذكرها محليا في وكيلك. بتطبيق هذا التخزين المؤقت على مستوى الوكلاء ، عدنا الأداء إلى المستوى السابق.اتصال ديناميكي
كمثال سابق ، لقد أوضحت أنه يمكن استخدام JVM TI مباشرة من كود جافا باستخدام طرق أصلية عادية تستخدم System.loadLibrary
.بالإضافة إلى ذلك ، لقد رأينا بالفعل كيفية توصيل وكلاء JVM TI من خلال -agentpath
بدء تشغيل JVM.وهناك طريقة ثالثة أخرى: إرفاق ديناميكي.ما هي الفكرة؟ إذا قمت بتشغيل التطبيق ولم تعتقد أنه في المستقبل ستحتاج إلى نوع من الميزات ، أو إذا كنت بحاجة فجأة للتحقيق في خلل في الإنتاج ، فيمكنك تنزيل وكيل JVM TI في وقت التشغيل مباشرةً.بدءًا من JDK 9 ، أصبح هذا ممكنًا مباشرةً من سطر الأوامر باستخدام الأداة المساعدة jcmd: jcmd <pid> JVMTI.agent_load /path/to/agent.so [arguments]
وللإصدارات الأقدم من JDK ، يمكنك استخدام أداة jattach الخاصة بي . على سبيل المثال ، يمكن لـ async-profiler الاتصال أثناء التشغيل بالتطبيقات التي تعمل بدون أي وسيطات JVM إضافية ، ويرجع الفضل في ذلك جزئيًا إلى jattach.من أجل استخدام إمكانية الاتصال الديناميكي في وكيل JVM TI ، تحتاج ، بالإضافة Agent_OnLoad()
إلى ذلك ، إلى تنفيذ وظيفة مماثلة Agent_OnAttach()
. الفرق الوحيد: Agent_OnAttach()
لا يمكنك استخدام تلك القدرات المتوفرة فقط في وقت التمهيد من الوكيل.من المهم أن تتذكر أنه يمكنك توصيل المكتبة نفسها بشكل ديناميكي عدة مرات ، لذلك Agent_OnAttach()
يمكن استدعاءها مرارًا وتكرارًا.سأظهر بالقدوة. سيكون IntelliJ IDEA في دور الإنتاج: هذا هو أيضًا تطبيق Java ، مما يعني أنه يمكننا أيضًا الاتصال به أثناء التنقل والقيام بشيء ما.سوف نعثر على معرف عملية IDEA الخاص بنا ، ثم مع أداة jattach سوف نقوم بتوصيل مكتبة patcher.dll TI JVM بهذه العملية:jattach 8648 load patcher.dll true
وفي الحال ، غيّر لون القائمة إلى اللون الأحمر:
ماذا يفعل هذا العامل؟ يبحث عن كل كائنات Java للفئة المعينة ( javax.swing.AbstractButton
) والمكالمات من خلال طريقة JNI setBackground()
. يمكن رؤية الرمز الكامل هنا .ما الجديد في Java 9
يوجد JVM TI لفترة طويلة ، وعلى الرغم من الأخطاء الموجودة ، يوجد بالفعل واجهة برمجة تطبيقات مصححة راسخة لم تتغير منذ وقت طويل. ظهرت الابتكارات المهمة الأولى في Java 9.كما تعلمون ، جلب Java 9 للمطورين الألم والمعاناة المرتبطة بالوحدات النمطية. بادئ ذي بدء ، أصبح من الصعب استخدام "أسرار" JDK ، والتي بدونها ، من حيث المبدأ ، لا يمكن أن تفعل.على سبيل المثال ، في JDK لا توجد طريقة قانونية لمسح Direct ByteBuffer. فقط من خلال واجهة برمجة تطبيقات خاصة:
قل ، في Cassandra ، لا يوجد مكان بدون هذه الميزة ، لأن جميع أعمال DBMS مبنية على العمل مع MappedByteBuffer ، وإذا لم تقم بمسحها يدويًا ، فسوف يتعطل JVM بسرعة.إذا حاولت تشغيل نفس الكود على JDK 9 ، فستحصل على IllegalAccessError:
الموقف مع Reflection هو نفسه: أصبح من الصعب الوصول إلى الحقول الخاصة.على سبيل المثال ، لا تتوفر جميع عمليات الملفات من Linux في Java. لذلك ، بالنسبة للميزات الخاصة بنظام Linux ، قام المبرمجون باسترداد java.io.FileDescriptor
واصف ملف النظام من كائن من الكائن من خلال الانعكاس واستخدام JNI الذي يطلق عليه بعض وظائف النظام عليه. والآن ، إذا قمت بتشغيله على JDK 9 ، فسترى اللعنات في السجلات:
بالطبع ، هناك علامات JVM التي تفتح الوحدات الخاصة اللازمة وتتيح لك استخدام الفصول الخاصة والتأمل. ولكن عليك تسجيل جميع الحزم التي تنوي استخدامها يدويًا. على سبيل المثال، فقط لتشغيل كاساندرا في جاوة 11 من الضروري أن يصف مثل هذا الشعار: --add-exports java.base/jdk.internal.misc=ALL-UNNAMED --add-exports java.base/jdk.internal.ref=ALL-UNNAMED --add-exports java.base/sun.nio.ch=ALL-UNNAMED --add-exports java.management.rmi/com.sun.jmx.remote.internal.rmi=ALL-UNNAMED --add-exports java.rmi/sun.rmi.registry=ALL-UNNAMED --add-exports java.rmi/sun.rmi.server=ALL-UNNAMED --add-exports java.sql/java.sql=ALL-UNNAMED --add-opens java.base/java.lang.module=ALL-UNNAMED --add-opens java.base/jdk.internal.loader=ALL-UNNAMED --add-opens java.base/jdk.internal.ref=ALL-UNNAMED --add-opens java.base/jdk.internal.reflect=ALL-UNNAMED --add-opens java.base/jdk.internal.math=ALL-UNNAMED --add-opens java.base/jdk.internal.module=ALL-UNNAMED --add-opens java.base/jdk.internal.util.jar=ALL-UNNAMED --add-opens jdk.management/com.sun.management.internal=ALL-UNNAMED
ومع ذلك ، جنبا إلى جنب مع وحدات ، ظهرت وظائف JVM TI للعمل معهم:- GetAllModules
- AddModuleExports
- AddModuleOpens
- ور. د.
عند النظر إلى هذه القائمة ، يقترح الحل نفسه: يمكنك الانتظار حتى يتم تحميل JVM ، والحصول على قائمة بجميع الوحدات ، وتجاوز جميع الحزم ، وفتح كل شيء للجميع والاستمتاع به.هنا هو نفس المثال مع Direct ByteBuffer: public static void main(String[] args) { ByteBuffer buf = ByteBuffer.allocateDirect(1024); ((sun.nio.ch.DirectBuffer) buf).cleaner().clean(); System.out.println("Buffer cleaned"); }
إذا قمنا بتشغيله بدون عملاء ، فنحن نتوقع الحصول على IllegalAccessError. وإذا قمت بإضافة عامل مضاد للكتل كتبه لي إلى agentpath ، فإن المثال سيعمل دون أخطاء. نفس الشيء مع التفكير.ما الجديد في Java 11
ظهر ابتكار آخر في Java 11. إنه واحد فقط ، لكن ما! لقد ظهرت إمكانية إنشاء تشكيلات خفيفة للمخصصات: تمت إضافة حدث جديد SampledObjectAlloc
، يمكنك الاشتراك فيه ، بحيث تأتي إعلامات انتقائية حول التخصيصات.سيتم نقل كل ما هو مطلوب لمزيد من التحليل إلى رد الاتصال: مؤشر الترابط الذي يخصص ، الكائن المحدد نفسه ، فئته ، حجمه. هناك طريقة أخرى SetHeapSampingInterval
تتمثل في تغيير معدل تكرار هذه الإخطارات.
لماذا هذا مطلوب؟ كان التخصيص الجانبي في وقت سابق في جميع الملفات التعريفية الشائعة ، ولكنه كان يعمل من خلال الأجهزة ، وهو محفوف بنفقات علوية عالية. كانت أداة التنميط المنخفضة فقط هي Java Flight Recorder.إن فكرة الطريقة الجديدة هي عدم تخصيص جميع التخصيصات ، ولكن فقط بعضها ، بعبارة أخرى ، لأخذ العينات.في الحالة الأسرع والأكثر تكرارًا ، يحدث التخصيص داخل مؤشر ترابط تخصيص مؤشر الترابط المحلي ببساطة عن طريق زيادة المؤشر. ومع تضمين أخذ العينات ، تتم إضافة حدود افتراضية إلى TLAB المقابلة لتردد أخذ العينات. بمجرد أن يتجاوز التخصيص التالي هذا الحد ، يتم إرسال حدث حول تخصيص الكائن.
في بعض الحالات ، يتم تخصيص الكائنات الكبيرة التي لا تنسجم مع TLAB مباشرة في الكومة. تسير هذه الكائنات أيضًا على مسار التخصيص البطيء خلال وقت تشغيل JVM ويتم أيضًا أخذ عينات منها.نظرًا لحقيقة أن أخذ العينات يتم الآن فقط لبعض الكائنات ، فإن الحمل يكون مقبولًا بالفعل للإنتاج - في معظم الحالات يكون أقل من 5٪.ومن المثير للاهتمام ، أن هذه الميزة كانت منذ زمن طويل ، منذ وقت JDK 7 ، المصممة خصيصًا لمسجلات الطيران. ولكن من خلال واجهة برمجة تطبيقات Hotspot API الخاصة ، استخدم هذا التطبيق async-profiler أيضًا. والآن ، بدءًا من JDK 11 ، أصبحت واجهة برمجة التطبيقات هذه متاحة للعامة ، ودخلت في JVM TI ، ويمكن للمعلنين الآخرين استخدامها. على وجه الخصوص ، YourKit يعرف بالفعل كيف. وكيفية استخدام واجهة برمجة التطبيقات هذه ، يمكنك أن ترى في المثال المنشور في مستودعنا.باستخدام ملف التعريف هذا ، يمكنك إنشاء مخططات تخصيص جميلة. شاهد الأشياء التي تبرز ، وعددها يبرز ، والأهم من ذلك ، أين.
استنتاج
JVM TI هي طريقة رائعة للتفاعل مع جهاز افتراضي.يمكن تشغيل المكونات الإضافية المكتوبة بلغة C أو C ++ في بداية JVM أو يمكن توصيلها ديناميكيًا بشكل مباشر أثناء تشغيل التطبيق. بالإضافة إلى ذلك ، يمكن للتطبيق نفسه استخدام وظائف JVM TI من خلال الأساليب الأصلية.يتم نشر جميع الأمثلة الموضحة في مستودعنا على جيثب . استخدام ودراسة وطرح الأسئلة.