كيف لا القمامة في جافا

هناك اعتقاد خاطئ شائع بأنه إذا كنت لا تحب مجموعة البيانات المهملة ، فأنت بحاجة إلى الكتابة ليس في Java ، ولكن في C / C ++. على مدار السنوات الثلاث الماضية ، كنت أكتب شفرة جافا منخفضة زمنياً لتداول العملات ، وكان عليّ تجنب إنشاء أشياء غير ضرورية بكل طريقة. كنتيجة لذلك ، قمت بصياغة بعض القواعد البسيطة ، وهي كيفية تقليل التخصيصات في Java ، إن لم يكن الصفر ، ثم إلى حد أدنى معقول ، دون اللجوء إلى الإدارة اليدوية للذاكرة. ربما سيكون من المفيد أيضًا لشخص من المجتمع.


لماذا تجنب القمامة على الإطلاق


حول ما هو GC وكيفية تكوينها قيل وكتب الكثير. ولكن في نهاية المطاف ، بغض النظر عن كيفية إعداد GC ، فإن الكود الذي سوف يعمل بشكل مثالي. هناك دائما مفاضلة بين الإنتاجية والكمون. يصبح من المستحيل تحسين أحدهما دون تفاقم الآخر. وكقاعدة عامة ، يتم قياس النفقات العامة لـ GC من خلال دراسة السجلات - يمكنك أن تفهم منها في لحظات كانت هناك توقف مؤقت وكم من الوقت استغرقوه. ومع ذلك ، لا تحتوي سجلات GC على كافة المعلومات حول هذا الحمل. يتم وضع الكائن الذي تم إنشاؤه بواسطة الخيط تلقائيًا في ذاكرة التخزين المؤقت L1 الخاصة بنواة المعالج التي يعمل عليها الخيط. هذا يؤدي إلى ازدحام البيانات الأخرى التي يحتمل أن تكون مفيدة. مع وجود عدد كبير من التخصيصات ، يمكن أيضًا إخراج البيانات المفيدة من ذاكرة التخزين المؤقت L3. في المرة التالية التي يصل فيها مؤشر الترابط إلى هذه البيانات ، ستحدث ذاكرة التخزين المؤقت المفقودة ، مما سيؤدي إلى تأخير في تنفيذ البرنامج. علاوة على ذلك ، نظرًا لأن ذاكرة التخزين المؤقت L3 شائعة في جميع النوى داخل نفس المعالج ، فإن دفق البيانات المهملة سيدفع البيانات وغيرها من مؤشرات الترابط / التطبيقات من ذاكرة التخزين المؤقت L3 ، وسيواجهون بالفعل أخطاء تخزينية إضافية باهظة الثمن ، حتى إذا كانت مكتوبة بلغة C فارغة ولا تخلق القمامة. لا توجد إعدادات ، ولن تساعد أدوات تجميع البيانات المهملة (لا C4 ولا ZGC) في التغلب على هذه المشكلة. الطريقة الوحيدة لتحسين الموقف ككل هي عدم إنشاء كائنات غير ضرورية دون داعٍ. لا تحتوي Java ، على عكس C ++ ، على ترسانة غنية من آليات العمل باستخدام الذاكرة ، ولكن مع ذلك ، هناك عدد من الطرق لتقليل التخصيصات. سيتم مناقشتها.


انحدار غنائي

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


باستخدام أنواع بدائية


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


مثال 1. الحسابات التقليدية


دعنا نقول أن لدينا وظيفة منتظمة تحسب شيئًا ما.


 Integer getValue(Integer a, Integer b, Integer c) { return (a + b) / c; } 

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


 int getValue(int a, int b, int c) { return (a + b) / c; } 

مثال 2. Lambdas


في بعض الأحيان يتم إنشاء الأشياء دون علمنا. على سبيل المثال ، إذا مررنا أنواع بدائية إلى حيث يتوقع أنواع الكائنات. يحدث هذا غالبًا عند استخدام تعبيرات lambda.
تخيل أن لدينا هذا الكود:


 void calculate(Consumer<Integer> calculator) { int x = System.currentTimeMillis(); calculator.accept(x); } 

على الرغم من أن المتغير x هو بدائي ، سيتم إنشاء كائن من النوع Integer ، والذي سيتم تمريره إلى الآلة الحاسبة. لتجنب ذلك ، استخدم IntConsumer بدلاً من Consumer<Integer> :


 void calculate(IntConsumer calculator) { int x = System.currentTimeMillis(); calculator.accept(x); } 

لن يؤدي هذا الرمز إلى إنشاء كائن إضافي. يحتوي Java.util.function على مجموعة كاملة من الواجهات القياسية المعدلة لاستخدام أنواع بدائية: DoubleSupplier ، LongFunction ، إلخ. حسنًا ، إذا كان هناك شيء مفقود ، فيمكنك دائمًا إضافة الواجهة المطلوبة مع العناصر الأولية. على سبيل المثال ، بدلاً من BiConsumer<Integer, Double> يمكنك استخدام واجهة محلية الصنع.


 interface IntDoubleConsumer { void accept(int x, double y); } 

مثال 3. المجموعات


قد يكون استخدام نوع بدائي أمرًا صعبًا لأن متغيرًا من هذا النوع موجود في مجموعة. افترض أن لدينا بعض List<Integer> ونريد معرفة الأرقام الموجودة فيها وحساب عدد مرات تكرار كل رقم. لهذا ، نستخدم HashMap<Integer, Integer> . الرمز يشبه هذا:


 List<Integer> numbers = new ArrayList<>(); // fill numbers somehow Map<Integer, Integer> counters = new HashMap<>(); for (Integer x : numbers) { counters.compute(x, (k, v) -> v == null ? 1 : v + 1); } 

هذا الرمز سيء بعدة طرق دفعة واحدة. أولاً ، يستخدم بنية بيانات وسيطة ، والتي من المحتمل أن تتم بدونها. حسنًا ، حسنًا ، للبساطة ، نفترض أن هذه القائمة ستكون مطلوبة في وقت لاحق ، أي لا يمكنك إزالته بالكامل. ثانيا ، Integer استخدام كائن Integer في كلا المكانين بدلا من int البدائية. ثالثا ، هناك العديد من التخصيصات في طريقة compute . رابعا ، يتم تخصيص التكرار. من المحتمل أن يصبح هذا التخصيص مضمّنًا ، لكن مع ذلك. كيفية تحويل هذا الرمز إلى رمز خالية من القمامة؟ تحتاج فقط إلى استخدام المجموعة على البدائل من بعض مكتبة الطرف الثالث. هناك عدد من المكتبات التي تحتوي على هذه المجموعات. يستخدم الجزء التالي من التعليمات البرمجية مكتبة agrona .


 IntArrayList numbers = new IntArrayList(); // fill numbers somehow Int2IntCounterMap counters = new Int2IntCounterMap(0); for (int i = 0; i < numbers.size(); i++) { counters.incrementAndGet(numbers.getInt(i)); } 

الكائنات التي يتم إنشاؤها هنا هي مجموعتان int[] ، توجدان داخل هذه المجموعات. يمكن إعادة استخدام كلا المجموعتين من خلال استدعاء طريقة clear() عليها. باستخدام المجموعات على البدائل ، لم نعقِّد الكود الخاص بنا (بل قمنا بتبسيطه عن طريق إزالة طريقة حساب مع لامدا معقدة داخلها) وتلقينا العلاوات الإضافية التالية مقارنة باستخدام المجموعات القياسية:


  1. الغياب التام تقريبا للمخصصات. إذا تم إعادة استخدام المجموعات ، فلن يكون هناك أي تخصيصات على الإطلاق.
  2. توفير كبير في الذاكرة (يستغرق IntArrayList مساحة أقل بنحو خمس مرات من ArrayList<Integer> . كما ذكرنا سابقًا ، نحن نهتم بالاستخدام الاقتصادي لذاكرة التخزين المؤقت للمعالج وليس ذاكرة الوصول العشوائي.
  3. الوصول التسلسلي إلى الذاكرة. لقد كُتب الكثير حول موضوع أهمية هذا الأمر ، لذلك لن أتوقف عند هذا الحد. إليكم مقالتان : مارتن طومسون وأولريش دريبر .

تعليق صغير آخر حول المجموعات. قد يتضح أن المجموعة تحتوي على قيم من أنواع مختلفة ، وبالتالي لا يمكن استبدالها بمجموعة بدائية. في رأيي ، هذه علامة على سوء تصميم بنية البيانات أو الخوارزمية ككل. على الأرجح في هذه الحالة ، فإن تخصيص كائنات إضافية ليس هو المشكلة الرئيسية.


كائنات قابلة للتغيير


لكن ماذا لو لم يكن بالإمكان الاستغناء عن البدائيين؟ على سبيل المثال ، في حالة أن الطريقة التي نحتاجها يجب أن تُرجع عدة قيم. الجواب بسيط - استخدام كائنات قابلة للتغيير.


استطراد صغير

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


كيف تبدو في الممارسة العملية؟ لنفترض أننا بحاجة إلى حساب الباقي والباقي من القسمة. ولهذا نستخدم الكود التالي.


 class IntPair { int x; int y; } IntPair divide(int value, int divisor) { IntPair result = new IntPair(); result.x = value / divisor; result.y = value % divisor; return result; } 

كيف يمكن للمرء التخلص من التخصيص في هذه الحالة؟ هذا صحيح ، مرر IntPair كحجة واكتب النتيجة هناك. في هذه الحالة ، تحتاج إلى كتابة javadoc مفصلة ، والأفضل من ذلك ، استخدام نوع من الاصطلاح لأسماء المتغيرات ، حيث تتم كتابة النتيجة. على سبيل المثال ، يمكن أن يبدأوا بالبادئة. ستبدو التعليمات البرمجية الخالية من البيانات المهملة في هذه الحالة كما يلي:


 void divide(int value, int divisor, IntPair outResult) { outResult.x = value / divisor; outResult.y = value % divisor; } 

أريد أن أشير إلى أن طريقة divide يجب ألا تحفظ رابطًا لإقرانه في أي مكان أو تمريره إلى طرق يمكنها القيام بذلك ، وإلا فقد نواجه مشكلات كبيرة. كما نرى ، فإن الكائنات القابلة للتغيير يصعب استخدامها أكثر من الأنواع البدائية ، لذلك إذا كنت تستطيع استخدام البدائل ، فمن الأفضل أن تفعل ذلك. في الواقع ، في مثالنا ، نقلنا مشكلة التخصيص من داخل طريقة الفجوة إلى الخارج. في جميع الأماكن التي نسميها هذه الطريقة ، سنحتاج إلى بعض دمية IntPair ، والتي IntPair إليها. غالبًا ما يكفي لتخزين هذه الدمية في الحقل final للكائن ، حيث نسميها طريقة divide . دعني أعطيك مثالًا بعيد المنال: لنفترض أن برنامجنا لا يتعامل إلا مع تلقي دفق من الأرقام عبر الشبكة ، ويقسمها ، ويرسل النتيجة إلى نفس المقبس.


 class SocketListener { private final IntPair pair = new IntPair(); private final BufferedReader in; private final PrintWriter out; SocketListener(final Socket socket) throws IOException { in = new BufferedReader(new InputStreamReader(socket.getInputStream())); out = new PrintWriter(socket.getOutputStream(), true); } void listenSocket() throws IOException { while (true) { int value = in.read(); int divisor = in.read(); divide(value, divisor, pair); out.print(pair.x); out.print(pair.y); } } } 

للإيجاز ، لم أكتب رمز "إضافي" لمعالجة الأخطاء وإنهاء البرنامج الصحيح ، إلخ. الفكرة الرئيسية لهذا الجزء من الكود هي أن كائن IntPair الذي IntPair يتم إنشاؤه مرة واحدة ويتم تخزينه في الحقل final .


تجمعات الكائنات


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


  • يمكننا أن ننسى إعادة الكائن إلى التجمع ، ثم سيتم إنشاء البيانات المهملة ("تسرب الذاكرة"). هذه مشكلة صغيرة - سينخفض ​​الأداء بشكل طفيف ، لكن GC سوف تنجح وسيستمر البرنامج في العمل.
  • يمكننا إعادة الكائن إلى التجمع ، ولكن احفظ الرابط إليه في مكان ما. بعد ذلك سيحصل شخص آخر على الكائن من التجمع ، وفي هذه المرحلة في برنامجنا سيكون هناك بالفعل رابطان لنفس الكائن. هذه مشكلة كلاسيكية بعد الاستخدام. من الصعب أن تظهر لأول مرة بسبب بخلاف C ++ ، لن يتعطل البرنامج وسيواصل العمل بشكل غير صحيح .

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


 public interface Storage<T> { T get(); void dispose(T object); } class IntPair implements AutoCloseable { private static final Storage<IntPair> STORAGE = new StorageImpl(IntPair::new); int x; int y; private IntPair() {} public static IntPair create() { return STORAGE.get(); } @Override public void close() { STORAGE.dispose(this); } } 

قد تبدو طريقة القسمة كما يلي:


 IntPair divide(int value, int divisor) { IntPair result = IntPair.create(); result.x = value / divisor; result.y = value % divisor; return result; } 

وطريقة listenSocket مثل هذا:


 void listenSocket() throws IOException { while (true) { int value = in.read(); int divisor = in.read(); try (IntPair pair = divide(value, divisor)) { out.print(pair.x); out.print(pair.y); } } } 

في IDE ، يمكنك عادةً تكوين تسليط الضوء على جميع الحالات عند استخدام كائنات قابلة AutoCloseable خارج كتلة موارد التجريب. لكن هذا ليس خيارًا مطلقًا ، لأنه تسليط الضوء في IDE يمكن فقط أن تكون متوقفة. لذلك ، هناك طريقة أخرى لضمان عودة الكائن إلى التجمع - انعكاس التحكم. سأقدم مثالا:


 class IntPair implements AutoCloseable { private static final Storage<IntPair> STORAGE = new StorageImpl(IntPair::new); int x; int y; private IntPair() {} private static void apply(Consumer<IntPair> consumer) { try(IntPair pair = STORAGE.get()) { consumer.accept(pair); } } @Override public void close() { STORAGE.dispose(this); } } 

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


من الواضح ، إذا لم نخزن كائنات عامة في التجمع ، ولكن بعض كائنات المكتبة التي لا تطبق AutoCloseable ، فلن يعمل خيار try-with-resources أيضًا.


مشكلة إضافية هنا هي multithreading. يجب أن يكون تنفيذ تجمع الكائنات سريعًا للغاية ، وهو أمر يصعب تحقيقه. تجمع بطيء يمكن أن تلحق الضرر بالأداء أكثر مما تنفع. بدوره ، يتم تخصيص كائنات جديدة في TLAB بسرعة كبيرة وأسرع بكثير من malloc في C. تعد كتابة تجمع كائنات سريع موضوعًا منفصلًا لا أريد تطويره الآن. لا أستطيع إلا أن أقول أنني لم أر أي تطبيقات جيدة "جاهزة".


بدلا من الاستنتاج


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


تحديث:


نعم ، لقد تذكرت طريقة أخرى لأولئك الذين لا يخافون من التحولات bitwise: تعبئة عدة أنواع بدائية صغيرة في واحد كبير. لنفترض أننا بحاجة إلى إعادة اثنين من int . في هذه الحالة بالذات ، لا يمكنك استخدام كائن IntPair ، ولكن يمكنك إرجاع واحد long ، أول 4 بايتات التي تتوافق مع int الأول ، والثاني 4 بايت إلى الثانية. قد يبدو الرمز كما يلي:


 long combine(int left, int right) { return ((long)left << Integer.SIZE) | (long)right & 0xFFFFFFFFL; } int getLeft(long value) { return (int)(value >>> Integer.SIZE); } int getRight(long value) { return (int)value; } long divide(int value, int divisor) { int x = value / divisor; int y = value % divisor; return combine(left, right); } void listenSocket() throws IOException { while (true) { int value = in.read(); int divisor = in.read(); long xy = divide(value, divisor); out.print(getLeft(xy)); out.print(getRight(xy)); } } 

بالطبع ، يجب اختبار هذه الأساليب بشكل كامل ، لأنه من السهل جدًا تدوينها. ولكن بعد ذلك فقط استخدامه.

Source: https://habr.com/ru/post/ar436024/


All Articles