
منذ زمن سحيق ، تتمتع Java بواجهة
Map رائعة وتنفيذه ، ولا سيما
HashMap . وبدءًا من Java 5 ، هناك أيضًا
ConcurrentHashMap . النظر في هذين التطبيقين ، وتطورها وما هذا التطور يمكن أن يؤدي إلى المطورين غفلة.
تحذير: تستخدم هذه المقالة الاستشهادات من شفرة مصدر OpenJDK 8 ، الموزعة تحت رخصة جنو العمومية العامة الإصدار 2.
مرات قبل جافا 8
أولئك الذين وجدوا أوقات انتظار طويلة وطويلة ، أول Java 7 ، ثم Java 8 (ليس كما هو الحال الآن ، كل ستة أشهر إصدار جديد) ، يتذكرون أي العمليات مع Map هي الأكثر شعبية. هذا هو:
إذا كنت بحاجة إلى وضع قيمة في المجموعة ، فنحن نستخدم الطريقة الأولى ، وللحصول على القيمة الحالية ، استخدم الطريقة الثانية.
ولكن ماذا لو كان التهيئة البطيئة مطلوبًا؟ ثم ظهر رمز من هذا النوع:
String getOrPut(String key) { String result = map.get(key);
- نحصل على القيمة عن طريق المفتاح
- تحقق مما إذا تم العثور على القيمة المطلوبة
- إذا لم يتم العثور على قيمة ، ثم إنشائها
- إضافة قيمة إلى المجموعة حسب المفتاح
اتضح مرهقة بعض الشيء ، أليس كذلك؟ علاوة على ذلك ، في حالة استخدام HashMap البسيط ، فهذا مجرد رمز غير مريح للقراءة ، لأنه انه ليس الخيوط. ولكن في حالة ConcurrentHashMap ، تنبثق ميزة إضافية: يمكن استدعاء طريقة createValue (2) عدة مرات إذا تمكنت عدة مؤشرات ترابط من التحقق من الحالة (1) قبل أن يكتب أحدها قيمة المجموعة (3). مثل هذا السلوك يمكن أن يؤدي في كثير من الأحيان إلى عواقب غير مرغوب فيها.
قبل Java 8 ، لم يكن هناك ببساطة خيارات أنيقة. إذا كنت بحاجة لتفادي إنشاء قيمة متعددة ، فعليك استخدام أقفال إضافية.
جعل Java 8 الأمور أسهل. يبدو ...
يأتي Java 8 إلينا ...
ما هي الميزة الأكثر توقعًا التي جاءت إلينا باستخدام Java 8؟ هذا صحيح يا لامدا. وليس فقط اللاما ، ولكن دعمهم في جميع مجموعة متنوعة من واجهات برمجة التطبيقات من المكتبة القياسية. لم يتم تجاهل هياكل بيانات الخريطة. على وجه الخصوص ، ظهرت طرق مثل:
نظرًا لهذه الطرق ، من الممكن إعادة كتابة الكود المقدم مسبقًا بشكل أبسط:
String getOrPut(String key) { return map.computeIfAbsent(key, this::createValue); }
من الواضح أن لا أحد سيتخلى عن الفرصة لتبسيط الكود. علاوة على ذلك ، في حالة ConcurrentHashMap ، يتم
تنفيذ طريقة
computeIfAbsent أيضًا ذريًا. أي سيتم استدعاء createValue مرة واحدة وفقط إذا كانت القيمة المطلوبة غائبة.
IDE أيضا لم يمر بها. لذلك تقدم IntelliJ IDEA الاستبدال التلقائي للإصدار القديم بالإصدار الجديد:
من الواضح أن كل من تبسيط التعليمات البرمجية وتلميحات IDE تشجع المطورين على استخدام واجهة برمجة التطبيقات الجديدة هذه. نتيجة لذلك ، بدأ نفس computeIfAbsent في الظهور في العديد من الأماكن في التعليمات البرمجية.
وداعا ...
فجأة!
حتى يحين الوقت لاختبار الحمل التالي. ثم ظهر شيء فظيع:

يعمل التطبيق على الإصدار التالي من Java:
إصدار openjdk "1.8.0_222"
بيئة تشغيل OpenJDK (بناء 1.8.0_222-8u222-b10-1ubuntu1 ~ 18.04.1-b10)
OpenJDK 64-bit Server VM (إنشاء 25.222-b10 ، الوضع المختلط)
بالنسبة لأولئك الذين ليسوا على دراية بهذه الأداة الرائعة مثل
YourKit .
في لقطة الشاشة ، توضح الخطوط العريضة الأفقية تشغيل مؤشرات ترابط التطبيق في الوقت المناسب. اعتمادًا على حالة التيار في لحظة معينة من الزمن ، يتم رسم الشريط باللون المقابل:
- الأصفر - الدفق خامل ، في انتظار العمل ؛
- الأخضر - الخيط قيد التشغيل ، تنفيذ رمز البرنامج ؛
- أحمر - يتم حظر هذا الموضوع من قبل مؤشر ترابط آخر.
وهذا يعني أن كل سلاسل الرسائل تقريبًا (وفي الحقيقة كان هناك أكثر بكثير مما يظهر في لقطة الشاشة) تقريبًا طوال الوقت في حالة محظورة. وللجميع ، القفل في نفس computeIfAbsent من ConcurrentHashMap! وهذا على الرغم من حقيقة أنه نظرًا لخصائص اختبار الحمل المحدد ، لا يمكن تخزين أكثر من 6-8 قيم في هذه المجموعة. أي تقريبا جميع العمليات في مكان معين هي حصرا قراءة القيم الموجودة.
لكن مهلا كيف؟ في الواقع ، حتى في وثائق طريقة الحجب ، يقال فقط في ملحق التحديث:
"إذا لم يكن المفتاح المحدد مرتبطًا بالفعل بقيمة ، فحاول حساب قيمتها باستخدام وظيفة التعيين المعينة وإدخالها في هذه الخريطة ما لم تكن فارغة. يتم تنفيذ استدعاء الأسلوب بالكامل تلقائيًا ، بحيث يتم تطبيق الوظيفة مرة واحدة على الأكثر لكل مفتاح. قد يتم حظر بعض عمليات تحديث المحاولات على هذه الخريطة بواسطة مؤشرات ترابط أخرى أثناء إجراء عملية الحساب ، لذلك يجب أن يكون الحساب قصيرًا وبسيطًا ، ويجب ألا يحاول تحديث أي تعيينات أخرى لهذه الخريطة. "
في الواقع ، كل شيء ليس كذلك. إذا نظرت إلى الكود المصدري لهذه الطريقة ، اتضح أنه يحتوي على كتلتين متزامنتين كثيفتين للغاية:
تنفيذ ConcurrentHashMap.computeIfAbsent public V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction) { if (key == null || mappingFunction == null) throw new NullPointerException(); int h = spread(key.hashCode()); V val = null; int binCount = 0; for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; if (tab == null || (n = tab.length) == 0) tab = initTable(); else if ((f = tabAt(tab, i = (n - 1) & h)) == null) { Node<K,V> r = new ReservationNode<K,V>(); synchronized (r) { if (casTabAt(tab, i, null, r)) { binCount = 1; Node<K,V> node = null; try { if ((val = mappingFunction.apply(key)) != null) node = new Node<K,V>(h, key, val, null); } finally { setTabAt(tab, i, node); } } } if (binCount != 0) break; } else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); else { boolean added = false; synchronized (f) { if (tabAt(tab, i) == f) { if (fh >= 0) { binCount = 1; for (Node<K,V> e = f;; ++binCount) { K ek; V ev; if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek)))) { val = e.val; break; } Node<K,V> pred = e; if ((e = e.next) == null) { if ((val = mappingFunction.apply(key)) != null) { added = true; pred.next = new Node<K,V>(h, key, val, null); } break; } } } else if (f instanceof TreeBin) { binCount = 2; TreeBin<K,V> t = (TreeBin<K,V>)f; TreeNode<K,V> r, p; if ((r = t.root) != null && (p = r.findTreeNode(h, key, null)) != null) val = p.val; else if ((val = mappingFunction.apply(key)) != null) { added = true; t.putTreeVal(h, key, val); } } } } if (binCount != 0) { if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i); if (!added) return val; break; } } } if (val != null) addCount(1L, binCount); return val; }
من المثال أعلاه ، يمكن ملاحظة أن النتيجة يمكن تشكيلها فقط عند ست نقاط ، وجميع هذه الأماكن تقريبًا داخل كتل التزامن. بشكل غير متوقع تماما. علاوة على ذلك ، فإن عملية
الحصول البسيطة لا تحتوي على التزامن على الإطلاق:
تنفيذ ConcurrentHashMap.get public V get(Object key) { Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek; int h = spread(key.hashCode()); if ((tab = table) != null && (n = tab.length) > 0 && (e = tabAt(tab, (n - 1) & h)) != null) { if ((eh = e.hash) == h) { if ((ek = e.key) == key || (ek != null && key.equals(ek))) return e.val; } else if (eh < 0) return (p = e.find(h, key)) != null ? p.val : null; while ((e = e.next) != null) { if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek)))) return e.val; } } return null; }
ماذا تفعل؟ في الواقع ، هناك خياران فقط: إما الرجوع إلى الرمز الأصلي ، أو استخدامه ، ولكن في نسخة معدلة قليلاً:
String getOrPut(String key) { String result = map.get(key); return (result != null) ? result : map.computeIfAbsent(key, this::createValue); }
استنتاج
بشكل عام ، تبين أن هذه النتائج المميتة الناتجة عن إعادة بناء المساكن المبتذلة تبدو غير متوقعة للغاية. تم حفظ الموقف فقط من خلال وجود اختبار الإجهاد ، والتي كشفت بنجاح تدهور.
لحسن الحظ ، تم
إصلاح هذه أحدث إصدارات من Java:
JDK-8161372 .
لذا كن حذرا ، لا تثق في نصائح مغرية وكتابة الاختبارات. خاصة المجهدة.
جافا للجميع!
UPD1: كما أشار بشكل صحيح بواسطة
coldwind ، المشكلة معروفة:
JDK-8161372 . ويبدو أنه تم إصلاحه لـ Java 9. ولكن في وقت نشر المقال في Java 8 و Java 11 وحتى Java 13 ، ظلت هذه الطريقة على حالها.
UPD2: اشتعلت لي
vkovalchuk على الإهمال. في الواقع ، بالنسبة إلى Java 9 والإصدارات الأحدث ، تم إصلاح المشكلة عن طريق إضافة شرط آخر مع إرجاع النتيجة دون حظر:
else if (fh == h
في البداية ، واجهت موقفًا في الإصدار التالي من Java:
إصدار openjdk "1.8.0_222"
بيئة تشغيل OpenJDK (بناء 1.8.0_222-8u222-b10-1ubuntu1 ~ 18.04.1-b10)
OpenJDK 64-bit Server VM (إنشاء 25.222-b10 ، الوضع المختلط)
وعندما نظرت إلى مصادر الإصدارات الأحدث ، فاتني بصراحة هذه السطور ، مما أدى إلى ضلالي.
من أجل العدالة ، قمت بتصحيح النص الرئيسي للمقال.