সরাসরি পর্বে যান
পর্ব ৪৪ · ৫৯-এর মধ্যে · মডিউল ৬

JVM মেমরি আর garbage collection — heap, GC root, G1, ZGC ও memory leak

JVM Memory & Garbage Collection — Heap Layout, GC Roots, Generational Collectors, G1, ZGC and Memory Leaks

Taught in Bangla, this lesson explains JVM memory and garbage collection: runtime data areas like heap, metaspace and thread stacks, object layout and size, reachability from GC roots, mark-sweep, compacting and generational collection, choosing Serial, Parallel, G1, ZGC or Shenandoah, OutOfMemoryError versus StackOverflowError, memory leaks, and measuring with jcmd, heap dumps and JFR.

এই পর্বে যা শিখবেন

  • JVM-এর runtime data area-গুলো — কোন ডেটা কোথায় থাকে, আর কোনটি ভরলে কোন error আসে
  • একটি object-এর ভেতরের গঠন — header, field, padding — আর কেন int[] এত মিতব্যয়ী
  • Reachability, mark-sweep, mark-compact, copying আর generational GC ধাপে ধাপে
  • Serial, Parallel, G1, ZGC, Shenandoah — কোনটি কখন, আর কোন flag দিয়ে
  • GC-ভাষায় memory leak চেনা, ঠেকানো আর jcmd, JFR, heap dump দিয়ে খুঁজে বের করা

১ · new আছে, free নেই — তাহলে মেমরি ফেরত দেয় কে?

পর্ব ০৫-এ আপনি একটি মানসিক ছবি পেয়েছিলেন: local ভ্যারিয়েবল থাকে stack-এ, new দিয়ে তৈরি object থাকে heap-এ, আর কোনো reference দিয়ে পৌঁছানো যায় না এমন object একসময় মুছে যায়। এই পর্বে সেই “একসময়” আর “মুছে যায়”-এর ভেতরে ঢুকব।

প্রশ্নটা সহজ শোনায়, কিন্তু উত্তর গভীর। একটি অনলাইন শপের সার্ভার দিনে কয়েক কোটি object তৈরি করতে পারে — প্রতিটি অনুরোধে request object, JSON-এর String, অস্থায়ী list. প্রোগ্রামার একটিও মোছেন না, তবু মেমরি ফুরোয় না — JVM-এর ভেতরে একটি পরিচ্ছন্নতা-কর্মী অবিরাম কাজ করে। তাকে বুঝলে তিনটি বাস্তব সমস্যা সামলাতে পারবেন: memory leak, OutOfMemoryError, আর হঠাৎ লম্বা pause.

একটি ব্যস্ত রেস্টুরেন্টের কথা ভাবুন। ওয়েটার টেবিল পরিষ্কার করেন, কিন্তু কোন টেবিল? যে টেবিলে কেউ বসে নেই এবং কেউ দাবি করে রাখেনি। চেয়ারে একটি ব্যাগ রাখা থাকলে, মালিক সারাদিন না ফিরলেও, ওয়েটার টেবিলটি ছোঁবেন না। Garbage collector ঠিক এই নিয়মে চলে: সে “কে আর দরকারি নয়” জানে না — জানে শুধু “কার দিকে এখনো কেউ ইঙ্গিত করছে”। চেয়ারের ওপর ফেলে রাখা ব্যাগটি হলো ভুলে যাওয়া reference — Java-র memory leak-এর মূল রূপ।

মূল ধারণা

GC “অপ্রয়োজনীয়” object মোছে না — মোছে “অপৌঁছনীয়” object. কোনটি দরকারি, তা শুধু আপনি জানেন। কোনটি পৌঁছানো যায়, তা JVM হিসাব করতে পারে। এই দুইয়ের ফাঁকেই সব leak লুকিয়ে থাকে।

Garbage collection-এর জন্ম Java-র অনেক আগে। ১৯৫৯ সালের দিকে MIT-তে John McCarthy Lisp ভাষার জন্য এটি উদ্ভাবন করেন, আর ১৯৬০ সালে প্রকাশিত Lisp-এর বিখ্যাত প্রবন্ধে বর্ণনা দেন। একই বছর George Collins প্রস্তাব করেন reference counting — প্রতিটি object কতজন দেখছে, গুনে রাখা। ১৯৭০ সালে C. J. Cheney দেখান কীভাবে জীবিত object কপি করে এক ধাপে heap গুছিয়ে ফেলা যায়। আজকের JVM-এর প্রতিটি collector এই তিন চিন্তার উত্তরসূরি।

২ · JVM-এর মেমরির মানচিত্র — runtime data area

একটি Java প্রোগ্রাম চালালে অপারেটিং সিস্টেম দেখে একটিমাত্র process. কিন্তু সেই process-এর ভেতরে JVM মেমরিকে কয়েকটি আলাদা কাজে ভাগ করে নেয়। কোনটি কোথায়, জানা থাকলে কোনো error দেখেই বুঝবেন কোন ঘর ভরেছে।

একটি JVM process-এর ভেতরে

Heap is shared; stacks belong to threads; metaspace and code cache live outside the heap

  1. Youngeden + S0 + S1নতুন object
  2. Oldtenuredদীর্ঘজীবী object
  3. Metaspacenativeclass-এর তথ্য
  4. Thread stackপ্রতি threadframe, local
ছবি ৪২.১ — প্রথম দুটি মিলে heap (-Xmx এদের সীমা); বাকিগুলো heap-এর বাইরে।
অঞ্চলকী থাকেকারভরলে যা দেখবেন
Heap — young ও oldসব object আর array; JDK 7 থেকে static field-এর মানও (class-এর Class object-এর সাথে)সব thread মিলে একটিOutOfMemoryError: Java heap space
Metaspaceclass-এর গঠন, method-এর bytecode, constant poolসব threadOutOfMemoryError: Metaspace
Thread stackপ্রতিটি method কলের frame: local ভ্যারিয়েবল, operand stackপ্রতিটি thread-এর নিজেরStackOverflowError
PC registerএখন কোন bytecode নির্দেশ চলছেপ্রতিটি thread-এর নিজের—
Code cacheJIT-এর বানানো machine codeসব threadসতর্কবার্তা, JIT থেমে যায়, প্রোগ্রাম ধীর
Direct memoryByteBuffer.allocateDirect, NIO-র bufferসব threadOutOfMemoryError: Cannot reserve … direct buffer memory

পর্ব ১৮-এ বলা হয়েছিল, static field “class-এর সাথে একটিই কপি”। ভেতরের সত্যটা এই: class-এর কাঠামো (কোন কোন field, কোন method) থাকে metaspace-এ, কিন্তু static field-এর মান JDK 7 থেকে থাকে heap-এ। তাই একটি static list-এ ঢোকানো object সাধারণ heap object — আর সেই list একটি GC root থেকে পৌঁছানো যায়, যতদিন class লোড করা আছে। Leak-এর আলোচনায় এই তথ্যটি ফিরে আসবে।

-Xmx2g দিলে process ২ GB-তেই থামে না। Heap-এর বাইরে আছে metaspace, প্রতিটি thread-এর stack (ডিফল্ট সাধারণত ১ MB করে, ৬৪-bit Linux-এ), code cache, direct buffer আর GC-র নিজের হিসাবের তথ্য। Docker container-এর মেমরি সীমা ঠিক -Xmx-এর সমান দিলে OS process-টিকে মেরে ফেলতে পারে, Java কোনো error ছাপার সুযোগই পায় না। সাধারণ নিয়ম: heap-কে container-এর মেমরির মোটামুটি ৫০–৭৫% দিন। (পর্ব ৪২-এর virtual thread এখানে আলাদা: তাদের stack প্রয়োজন মতো heap-এই রাখা হয়, তাই লাখো virtual thread সম্ভব।)

৩ · একটি object আসলে কত বড়?

“একটি Integer কত বাইট?” — int ৪ বাইট, তাই অনেকে বলেন ৪. আসলে সাধারণ ৬৪-bit HotSpot-এ এটি প্রায় ১৬ বাইট। বাকি ১২ বাইট কোথায় যায়? প্রতিটি heap object-এর তিনটি অংশ থাকে:

🏷️ Header

Object header — দুটি অংশ। Mark word (৮ বাইট): identity hash, GC-র দেওয়া বয়স, lock-এর অবস্থা। Class pointer (সাধারণত ৪ বাইট): “আমি কোন class-এর object”.

সাধারণত ১২ বাইট

📦 Field

আপনার ঘোষণা করা field: int ৪, long/double ৮, reference সাধারণত ৪ বাইট। Array হলে বাড়তি ৪ বাইটে থাকে তার দৈর্ঘ্য।

আপনার ডেটা

🧩 Padding

HotSpot প্রতিটি object-এর আকার ৮-এর গুণিতকে গোল করে, যাতে ঠিকানা হিসাব সহজ ও দ্রুত হয়। ফাঁকা বাইটগুলো অপচয়।

৮-এর গুণিতক

“সাধারণত ৪ বাইটের reference” এসেছে compressed oops থেকে: heap ৩২ GB-এর কম হলে HotSpot ৬৪-bit ঠিকানাকে ৩২ bit-এ চেপে রাখে, যা ডিফল্টভাবে চালু থাকে। নিচের হিসাবগুলো এই সাধারণ অবস্থার জন্য; JVM, version আর flag বদলালে সংখ্যা বদলায়।

যা রাখছেনহিসাবআনুমানিক মোট
int (local বা field)শুধু মান৪ বাইট
new Object()১২ header + ৪ padding১৬ বাইট
Integer১২ header + ৪ int১৬ বাইট
Long১২ + ৮ = ২০ → গোল করে২৪ বাইট
int[1000]১৬ (header + দৈর্ঘ্য) + ৪,০০০≈ ৪ KB
Integer[1000], সব আলাদা মান৪,০১৬ (reference-এর array) + ১,০০০ × ১৬≈ ২০ KB

শেষ দুই সারি তুলনা করুন — একই ১,০০০টি সংখ্যা, প্রায় পাঁচ গুণ মেমরি। আর শুধু আকার নয়: int[]-এর সংখ্যাগুলো পাশাপাশি থাকে, CPU এক টানে cache-এ তোলে। Integer[]-এ থাকে শুধু reference, আসল object ছড়িয়ে থাকে heap-এর নানা জায়গায় — প্রতিটি পড়তে আলাদা লাফ। List<Integer>-এ দশ লাখ সংখ্যা রাখা তাই int[]-এর চেয়ে অনেক ভারী, আর GC-কে দশ লাখ বাড়তি object গুনতে হয়। পর্ব ০৬-এর autoboxing নিঃশব্দে এই খরচ তৈরি করে (−১২৮ থেকে ১২৭-এর Integer cache ছাড়া)। এজন্যই Stream API-তে IntStream আছে, আর OpenJDK-র Project Valhalla header-হীন “value” object আনার চেষ্টা করছে।

Header নিয়ে একটি নতুন খবর (পর্ব ২৩-এ ইঙ্গিত দেওয়া হয়েছিল): Java 24-এ পরীক্ষামূলকভাবে Compact Object Headers (JEP 450) এসেছে, যা চালু করলে header ১২ থেকে ৮ বাইটে নামে। ছোট object-ভরা অ্যাপে এতে heap উল্লেখযোগ্যভাবে কমতে পারে। পরের সংস্করণে (Java 25, JEP 519) এটি পরীক্ষামূলক তকমা হারিয়েছে, তবে এখনো নিজে চালু করতে হয়। নিজের object-এর আসল গঠন দেখতে OpenJDK-র JOL (Java Object Layout) টুল ব্যবহার করা যায় — এটি আলাদা library, তাই এই পাতার রানারে চলবে না।

যে object heap-এ যায়ই না — escape analysis

JIT-এর C2 compiler (পর্ব ০৫) একটি বিশ্লেষণ করে, যার নাম escape analysis: method-এর ভেতরে তৈরি object কি বাইরে “পালায়” — field-এ রাখা হয়, ফেরত দেওয়া হয়, অন্য method-এ যায়? না পালালে C2 object বানানোই বাদ দিতে পারে, তার field-গুলোকে সাধারণ local মানের মতো register-এ রেখে — scalar replacement. ফলে লুপের ভেতরের new Point(x, y) গরম কোডে হয়তো একটিও heap allocation করে না। এটি JIT-এর সিদ্ধান্ত, নিশ্চয়তা নয় — তবে এজন্যই “ছোট object এড়াতে কোড জটিল করুন” পরামর্শ আধুনিক Java-তে প্রায়ই অপ্রয়োজনীয়।

৪ · কে জীবিত? — GC root থেকে mark

প্রতিটি tracing collector-এর প্রথম কাজ একই: জীবিত object খুঁজে চিহ্ন দেওয়া। শুরু হয় GC root থেকে — যেসব reference নিশ্চিতভাবে প্রোগ্রামের হাতে আছে:

  • চলমান প্রতিটি thread-এর stack-এর local ভ্যারিয়েবল আর parameter — এই মুহূর্তে চলা প্রতিটি method যা ধরে আছে।
  • লোড করা class-এর static field — class বেঁচে থাকলে তারাও।
  • জীবিত thread object নিজেই, আর synchronized দিয়ে lock করা object.
  • JNI reference — native (C/C++) কোড যে Java object ধরে রেখেছে, আর JVM-এর নিজের কিছু ভেতরের reference.

এই root থেকে reference ধরে ধরে যতদূর যাওয়া যায়, সবাই reachable. বাকিরা garbage — একে অপরকে দেখালেও। নিচের stepper-এ একটি ছোট heap-এ mark phase চালিয়ে দেখুন। লাল বর্ডার মানে “চিহ্নিত, জীবিত”:

root: cartmain()-এর local
root: CATALOGstatic field
Cart#1→ Item#2
Item#2→ Product#3
Order#4→ Receipt#5
Product#3CATALOG-ও দেখায়
Receipt#5→ Order#4
Temp#6কেউ দেখায় না
GC-র হিসাবের খাতা
চিহ্নিত (জীবিত)
বাকি কাজের তালিকা
অচিহ্নিত
  1. শুরু — শুধু root চিহ্নিত। Heap-এ ছয়টি object. GC এখনো জানে না কে জীবিত। হাতে দুটি root: main()-এর local cart, আর static field CATALOG. দুটিকে একটি কাজের তালিকায় রাখা হলো।
  2. cart ধরে এগোনো। cart দেখায় Cart#1 — চিহ্ন দেওয়া হলো। Cart#1-এর field দেখায় Item#2 — সেটিও চিহ্নিত, আর তালিকায় উঠল তার field দেখার জন্য। GC শুধু reference field পড়ে; int field তার কাছে অর্থহীন।
  3. Product#3 — দুই পথে পৌঁছানো যায়। Item#2 দেখায় Product#3; চিহ্নিত হলো। এরপর CATALOG root-ও Product#3 দেখায় — কিন্তু সেটি আগেই চিহ্নিত, তাই আর ভেতরে ঢোকা হয় না। এই “আগে দেখেছি” চিহ্নই চক্রাকার reference-এ GC-কে অনন্ত লুপ থেকে বাঁচায়।
  4. Mark শেষ — তিনজন অচিহ্নিত। কাজের তালিকা খালি। Order#4 আর Receipt#5 একে অপরকে দেখায়, কিন্তু কোনো root থেকে তাদের কাছে পথ নেই। Temp#6-কে কেউই দেখায় না। তিনজনই garbage. Reference counting এই চক্র ধরতে পারত না — Order#4-এর গণনা ১, Receipt#5-এরও ১. Tracing GC-র কাছে চক্র কোনো সমস্যাই নয়।
  5. Sweep বা compact — জায়গা ফেরত। অচিহ্নিত তিনটি object-এর জায়গা এখন খালি বলে গণ্য। কীভাবে ফেরত নেওয়া হবে, তা অ্যালগরিদমের ওপর নির্ভর করে — নিচে দেখুন।

জায়গা ফেরত নেওয়ার তিন কৌশল

🧹 Mark-sweep

Mark-sweep: চিহ্ন দেওয়ার পর পুরো heap ঘুরে অচিহ্নিত জায়গাগুলো একটি “খালি তালিকায়” লেখে। দ্রুত, কিন্তু heap ঝাঁঝরা হয় (fragmentation) — মোট খালি ১০০ MB থাকলেও একটানা ২ MB না-ও মিলতে পারে।

সরায় না

🗜️ Mark-compact

Mark-compact: জীবিত object-গুলোকে heap-এর এক প্রান্তে ঠেলে গুছিয়ে রাখে, আর সব reference নতুন ঠিকানায় বদলে দেয়। Fragmentation নেই, কিন্তু সরানো-বদলানো ব্যয়বহুল।

গুছিয়ে রাখে

📋 Copying

Copying: মেমরিকে দুই ভাগ করে; জীবিতদের এক ভাগ থেকে অন্য ভাগে কপি করে, পুরোনো ভাগটি পুরোটাই খালি। খরচ শুধু জীবিতদের সংখ্যার ওপর — garbage যত বেশি, তত সস্তা। দাম: একটি ভাগ সবসময় ফাঁকা রাখতে হয়।

শুধু জীবিতদের ছোঁয়

Copying-এর একটি চমৎকার পার্শ্বফল আছে: কপির পর খালি জায়গা একটানা। তাই নতুন object দিতে JVM-কে শুধু একটি pointer সামনে ঠেলতে হয় — “bump the pointer”. আরও দ্রুত করতে প্রতিটি thread পায় eden-এর একটি নিজস্ব টুকরো, যার নাম TLAB (thread-local allocation buffer); সেখানে lock ছাড়াই object তৈরি হয়। এজন্য Java-তে new সাধারণত C-র malloc-এর চেয়েও সস্তা। দামটা দিতে হয় পরে, GC-র সময়।

৫ · বেশিরভাগ object অল্প বয়সে মরে — generational GC

Copying সস্তা যখন জীবিত কম। আর বাস্তব প্রোগ্রামে ঠিক তাই ঘটে। একটি HTTP অনুরোধের জন্য তৈরি String, list, DTO — অনুরোধ শেষ হতেই সব garbage. অন্যদিকে cache, connection pool, Spring-এর bean বেঁচে থাকে ঘণ্টার পর ঘণ্টা। এই পর্যবেক্ষণের নাম generational hypothesis: বেশিরভাগ object খুব অল্প বয়সেই মারা যায়; যারা বেঁচে যায়, তারা প্রায়ই অনেকদিন বাঁচে।

তাই HotSpot heap-কে দুই প্রজন্মে ভাগ করে। Young অংশে আবার তিনটি ঘর: eden (যেখানে নতুন object জন্মায়) আর দুটি ছোট survivor space, S0 ও S1. Young অংশ ঘন ঘন, দ্রুত পরিষ্কার হয় copying দিয়ে — একে বলে minor GC. Old অংশ পরিষ্কার হয় কদাচিৎ। প্রতিবার বেঁচে গেলে object-এর বয়স এক বাড়ে (mark word-এ লেখা থাকে); বয়স একটি সীমা পেরোলে সে old অংশে উঠে যায় — promotion. ধাপে ধাপে দেখুন:

Heap-এর অবস্থা (A¹ মানে A, বয়স ১)
Eden
Survivor S0
Survivor S1
Old
  1. জন্ম eden-এ। একটি অনুরোধ এলে পাঁচটি object তৈরি হলো: A (একজন ব্যবহারকারীর session), আর B, C, D, E (অনুরোধের অস্থায়ী জিনিস)। সবাই eden-এ, TLAB-এর কারণে খুব দ্রুত। Eden প্রায় ভরে গেল।
  2. Minor GC #1. Eden-এ জায়গা নেই, তাই minor GC. GC root থেকে mark করে দেখা গেল শুধু A আর C জীবিত (C এখনো একটি চলমান কাজে লাগছে)। এই দুজনকে S0-তে কপি করা হলো, বয়স ১. B, D, E-কে ছোঁয়াই হলো না — পুরো eden একবারে খালি ঘোষণা। পাঁচজনের মধ্যে খরচ হলো শুধু দুজনের কপি।
  3. নতুন object, আবার eden-এ। F, G, H, I, J জন্মাল eden-এ; S0-তে A¹ আর C¹ অপেক্ষায়।
  4. Minor GC #2 — S0 থেকে S1-এ। আবার eden ভরল। এবার জীবিত শুধু A (C-র কাজ শেষ) আর নতুন F. দুজনকে কপি করা হলো অন্য survivor, S1-এ: A-র বয়স ২, F-এর ১. Eden আর S0 দুটোই পুরো খালি। প্রতি minor GC-তে S0 আর S1 ভূমিকা অদলবদল করে — একটি সবসময় ফাঁকা থাকে, copying-এর নিয়ম মেনে।
  5. Promotion — old অংশে। A বারবার বেঁচে যাচ্ছে, তাকে বারবার কপি করা অপচয়। বয়স একটি সীমা পেরোলে A-কে old অংশে উঠিয়ে দেওয়া হয়। HotSpot-এ এই সীমা সর্বোচ্চ ১৫ (-XX:MaxTenuringThreshold), কারণ mark word-এ বয়স রাখার জন্য মাত্র ৪ bit; JVM দরকার মনে করলে আগেও promote করে, আর survivor-এ জায়গা না থাকলে সরাসরি।
  6. Old ভরে আসা — বড় পরিষ্কার। Old অংশ ধীরে ভরে। ভরে এলে collector অনুযায়ী আলাদা কাজ: Parallel থামিয়ে পুরো old পরিষ্কার করে (major GC); G1 প্রোগ্রামের পাশাপাশি old-এ mark চালায়, তারপর “mixed” GC-তে young-এর সাথে কিছু old অঞ্চলও পরিষ্কার করে। সব চেষ্টা ব্যর্থ হলে আসে পুরো heap থামিয়ে পরিষ্কার — full GC, যা সবচেয়ে লম্বা pause.

একটি সূক্ষ্ম প্রশ্ন: minor GC শুধু young অংশ দেখে, কিন্তু কোনো old object যদি একটি young object দেখায়? সেজন্য JVM old থেকে young-এ যাওয়া reference-এর হিসাব রাখে (card table, G1-এ remembered set) — প্রতিটি reference লেখার সময় একটি ছোট “write barrier” কোড চলে। এটাই generational GC-র লুকানো খরচ।

Stop-the-world — কেন সব thread থামাতে হয়

GC যখন object সরায়, প্রোগ্রামের thread যদি একই সময়ে পুরোনো ঠিকানায় লেখে — বিপর্যয়। সবচেয়ে সহজ সমাধান: সব Java thread একটি নিরাপদ বিন্দুতে (safepoint) থামিয়ে কাজ সেরে নেওয়া। এই বিরতিই stop-the-world pause. Young অংশ ছোট হলে minor GC-র pause সাধারণত কয়েক মিলিসেকেন্ড। কিন্তু কয়েক GB-এর old অংশ থামিয়ে compact করলে pause এক সেকেন্ড ছাড়াতে পারে। আধুনিক collector-গুলোর পুরো গল্প হলো এই কাজের যতটা সম্ভব প্রোগ্রামের পাশাপাশি (concurrent) করে ফেলা।

৬ · HotSpot-এর collector-গুলো — কোনটি কখন

প্রতিটি collector তিনটি জিনিসের মধ্যে একটি ভারসাম্য বেছে নেয়: throughput (মোট CPU সময়ের কত ভাগ আপনার কাজে গেল), latency (একটি pause সর্বোচ্চ কত লম্বা), আর footprint (কত বাড়তি মেমরি লাগে)। তিনটিই একসাথে সেরা পাওয়া যায় না।

Collectorflagমূল চিন্তাভালো যেখানে
Serial-XX:+UseSerialGCএক thread, সব কাজ থামিয়েছোট heap, ১টি CPU-র container, ছোট টুল
Parallel-XX:+UseParallelGCঅনেক thread মিলে, থামিয়ে; সর্বোচ্চ throughputরাতের batch job, রিপোর্ট তৈরি — pause চলে, মোট সময় জরুরি
G1-XX:+UseG1GCregion-ভিত্তিক; pause-এর লক্ষ্য (ডিফল্ট ২০০ ms)বেশিরভাগ সার্ভার অ্যাপ; Java 9 থেকে ডিফল্ট
ZGC-XX:+UseZGCপ্রায় সব কাজ concurrent; pause সাধারণত ১ ms-এর নিচেবড় heap, কঠোর latency লক্ষ্য
Shenandoah-XX:+UseShenandoahGCconcurrent compaction; pause heap-এর আকারের ওপর প্রায় নির্ভর করে নাZGC-র মতোই; সব JDK build-এ থাকে না

কয়েকটি তথ্য সাবধানে মনে রাখুন। G1 Java 9 থেকে ডিফল্ট (JEP 248) — তবে JVM যদি দেখে মেশিনটি খুব ছোট (যেমন ১টি মাত্র CPU, বা প্রায় ১.৭৫ GB-এর কম মেমরি), তাহলে নিজে থেকে Serial বেছে নেয়; ছোট container-এ এটা প্রায়ই ঘটে। ZGC Java 11-এ পরীক্ষামূলক হিসেবে আসে, Java 15-এ production-এ ব্যবহারের উপযোগী ঘোষিত হয় (JEP 377). Java 21-এ generational ZGC এসেছে (JEP 439), কিন্তু Java 21-এ সেটি চালু করতে দুটি flag লাগে: -XX:+UseZGC -XX:+ZGenerational. Java 23-এ generational মোডই ডিফল্ট হয় (JEP 474), আর Java 24-এ পুরোনো non-generational মোড সরিয়ে দেওয়া হয় (JEP 490) — তখন -XX:+ZGenerational flag-টিরও আর দরকার নেই। Shenandoah Red Hat-এর তৈরি, Java 15-এ production-ready (JEP 379); বেশিরভাগ OpenJDK build-এ (যেমন Temurin, Corretto) থাকে, কিন্তু Oracle-এর নিজস্ব JDK build-এ রাখা হয়নি।

G1 ভেতরে — অঞ্চল আর “আগে garbage”

G1 heap-কে একটানা young আর old-এ ভাগ করে না। পুরো heap সমান আকারের অনেক region-এ ভাগ করা (heap-এর আকার অনুযায়ী সাধারণত ১ থেকে ৩২ MB), আর প্রতিটি region যেকোনো সময় eden, survivor বা old-এর ভূমিকা নিতে পারে। অর্ধেক region-এর চেয়ে বড় object (যেমন বিশাল array) পায় এক বা একাধিক একটানা “humongous” region. নিচের নকশায় E = eden, S = survivor, O = old, H = humongous, · = খালি:

১২টি region-এর heap (বন্ধনীতে old region-এ garbage-এর ভাগ)
region ১–৬
region ৭–১২
pause
  1. ছড়ানো region. Eden, survivor আর old region heap জুড়ে ছড়িয়ে আছে — পাশাপাশি হওয়ার দরকার নেই। Region ৬ একটি humongous object-এর।
  2. Young GC — evacuation. Eden region ভরে গেলে G1 থামিয়ে young GC চালায়: সব eden আর survivor region থেকে জীবিত object কপি করে নতুন survivor region-এ (বয়স্করা old region-এ)। পুরোনো eden region-গুলো এখন পুরো খালি (·)। এটি সেই copying, শুধু region ধরে।
  3. Concurrent marking. Old region-এর মোট ভরাট একটি সীমা ছাড়ালে G1 প্রোগ্রামের পাশাপাশি পুরো heap-এ mark চালায়। ফলাফল: প্রতিটি old region-এ কত ভাগ garbage, তার হিসাব।
  4. Mixed GC — garbage-first. এবার young GC-র সাথে কিছু old region-ও পরিষ্কার হয়। কোনগুলো? যেগুলোতে garbage সবচেয়ে বেশি — region ৪ (৯০%) আর ৮ (৮৫%) — কারণ সেখানে অল্প জীবিত কপি করে অনেক জায়গা মেলে। ২০% বা ১০% garbage-এর region আপাতত থাক। নামের “Garbage-First” এখান থেকেই। Pause-এর লক্ষ্য (-XX:MaxGCPauseMillis) মেনে G1 ঠিক করে একবারে কয়টি region নেবে।

বাস্তব সিদ্ধান্ত — একটি payment API-র জন্য G1 না ZGC?

ধরুন আপনার দল একটি payment API চালায়: মোবাইল ওয়ালেট থেকে টাকা পাঠানোর অনুরোধ আসে, চুক্তি অনুযায়ী ৯৯% উত্তর ১০০ ms-এর মধ্যে দিতে হবে (p99 latency). Heap ৮ GB. কেউ বলল, “ZGC দিলেই pause শেষ।” সিদ্ধান্তের পথটা এমন হওয়া উচিত:

  1. আগে মাপুন: ডিফল্ট G1-এ বাস্তবের মতো লোড দিয়ে -Xlog:gc চালান। সবচেয়ে লম্বা pause ৩০ ms আর বাজেট ১০০ ms হলে G1-ই যথেষ্ট।
  2. দোষী GC কি না মেলান: ধীর অনুরোধের সময়ের সাথে GC pause-এর সময় মেলান — কারণটা ডেটাবেস বা অন্য সার্ভিসও হতে পারে।
  3. দরকার হলে ZGC পরীক্ষা: একই লোডে -XX:+UseZGC -XX:+ZGenerational (Java 21) চালান; pause প্রায় মুছে যাবে, তবে CPU আর মেমরি একটু বাড়তে পারে।
  4. কারণসহ লিখে রাখুন কোন flag, কোন পরিমাপের ভিত্তিতে — যাতে ছয় মাস পরে কেউ “এই flag কেন?” প্রশ্নের উত্তর পায়।

প্রাথমিক flag আর GC log

# heap-এর শুরু ও সর্বোচ্চ আকার (সার্ভারে প্রায়ই দুটো সমান রাখা হয়)
java -Xms2g -Xmx2g -jar shop.jar

# container-এ: নির্দিষ্ট সংখ্যার বদলে container-এর মেমরির শতাংশ
java -XX:MaxRAMPercentage=75 -jar shop.jar

# collector বাছাই আর G1-এর pause লক্ষ্য
java -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -jar shop.jar
java -XX:+UseZGC -XX:+ZGenerational -jar shop.jar      # Java 21

# GC log (unified logging, Java 9 থেকে) — ফাইলে
java -Xlog:gc*:file=gc.log -jar shop.jar

# OutOfMemoryError হলে স্বয়ংক্রিয় heap dump
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/dumps -jar shop.jar

ফ্ল্যাগ না দিলে JVM নিজেই হিসাব করে: সর্বোচ্চ heap সাধারণত মেশিনের (বা container-এর) মেমরির এক-চতুর্থাংশ। Container awareness JDK 10 থেকে ডিফল্টভাবে চালু (পরে Java 8u191-এও আনা হয়েছে) — অর্থাৎ JVM Docker বা Kubernetes-এর CPU ও মেমরি সীমা পড়ে, পুরো host মেশিনের নয়। আগের JVM পুরো host-এর মেমরি দেখে বিশাল heap ঠিক করত, আর ছোট container-এ OS তাকে মেরে ফেলত।

-Xlog:gc হলো Java 9-এ আসা unified logging-এর অংশ, যা পুরোনো -XX:+PrintGCDetails ধরনের flag-এর জায়গা নিয়েছে। একটি ছোট প্রোগ্রাম -Xmx64m -Xlog:gc দিয়ে চালালে এমন লাইন আসে:

একটি নমুনা — সংখ্যা আপনার মেশিনে আলাদা হবে

[0.008s][info][gc] Using G1
[0.069s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 7M->1M(34M) 2.720ms
[0.084s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 18M->2M(34M) 1.425ms
[0.091s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 19M->2M(34M) 1.900ms
[0.097s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 19M->3M(34M) 2.994ms
[0.104s][info][gc] GC(4) Pause Young (Normal) (G1 Evacuation Pause) 20M->4M(34M) 1.801ms
[0.111s][info][gc] GC(5) Pause Young (Normal) (G1 Evacuation Pause) 21M->5M(43M) 2.019ms

পড়তে শিখুন: বন্ধনীর প্রথম অংশ JVM চালুর পর কত সেকেন্ড; GC(0) হলো প্রথম GC; Pause Young (Normal) মানে young GC; (G1 Evacuation Pause) কারণ — eden ভরেছে; তীরচিহ্নের দুই পাশে GC-র আগে আর পরে heap-এ ব্যবহৃত মেমরি, বন্ধনীতে মোট heap; শেষে pause কত মিলিসেকেন্ড। খেয়াল করুন, GC-র পরের সংখ্যাটি ১M থেকে ৫M-এ উঠছে — কারণ এই নমুনা-প্রোগ্রাম ইচ্ছা করে কিছু array একটি list-এ রেখে দেয়। বাস্তব অ্যাপে GC-র পরের সংখ্যা যদি ঘণ্টার পর ঘণ্টা এভাবে বাড়তেই থাকে, কখনো আগের জায়গায় না ফেরে — সেটি leak-এর প্রথম লক্ষণ। আর heap-এর মোট আকার (৩৪M থেকে ৪৩M) বদলেছে, কারণ -Xms আর -Xmx সমান না দিলে JVM প্রয়োজন মতো heap বাড়ায়।

৭ · OutOfMemoryError আর StackOverflowError — কোন ঘর ভরেছে?

OutOfMemoryError একটি Error, Exception নয় (পর্ব ২৯) — সাধারণ কোডে এটি ধরে চালিয়ে যাওয়ার চেষ্টা করা উচিত নয়, কারণ তখন JVM-এর অবস্থা অনিশ্চিত। কিন্তু বার্তার শেষ অংশটি খুব কাজের: কোন ঘর ভরেছে, তা বলে দেয়।

বার্তামানেপ্রথম সন্দেহ
Java heap spaceheap-এ নতুন object-এর জায়গা নেই, GC-র পরেওleak, অথবা -Xmx সত্যিই কম
GC overhead limit exceeded(মূলত Parallel GC-তে) প্রায় সব সময় GC-তে যাচ্ছে, জায়গা মিলছে সামান্যপ্রায় ভরা heap, প্রায়ই leak
Metaspaceclass-এর তথ্যের জায়গা শেষবারবার redeploy-এ class loader leak, অগণিত তৈরি করা class
unable to create native thread: possibly out of memory or process/resource limits reachedOS আর thread বানাতে দিচ্ছে নাসীমাহীন thread তৈরি; pool ব্যবহার করুন (পর্ব ৪১)
Cannot reserve … bytes of direct buffer memoryNIO-র heap-এর বাইরের buffer-এর সীমা (-XX:MaxDirectMemorySize) শেষবড় direct buffer ফেরত না দেওয়া

নিচে একটি আসল trace. প্রোগ্রামটির loadAllImages() method একটি list-এ একের পর এক ১ MB-র byte[] রাখে, কখনো থামে না — যেন “সব ছবি একবারে মেমরিতে লোড করো” ধরনের বাগ। চালানো হয়েছে -Xmx32m দিয়ে, অনলাইন রানারে, যেখানে ফাইলের নাম prog.java:

একটি নমুনা — সংখ্যা আপনার মেশিনে আলাদা হবে

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at Main.loadAllImages(prog.java:6)
	at Main.main(prog.java:9)

Trace-এর শেষ লাইনটি দেখায় কোথায় শেষ allocation ব্যর্থ হলো — কিন্তু সেটি দোষী না-ও হতে পারে! যে লাইন শেষ খড়টি রাখল, উট তার জন্য ভাঙেনি; ভেঙেছে আগের হাজার খড়ে। আসল দোষী খুঁজতে দেখতে হয় heap-এ কী জমেছে — সেজন্য heap dump (অংশ ৯)।

Stack ভরলে — StackOverflowError

প্রতিটি thread-এর stack-এর আকার নির্দিষ্ট (-Xss, ৬৪-bit Linux-এ ডিফল্ট সাধারণত ১ MB). প্রতিটি method কল একটি frame যোগ করে; শেষ না হওয়া বা খুব গভীর recursion stack ভরিয়ে দেয়, আর আসে StackOverflowError. কত গভীরে গিয়ে ভরবে, তা frame-এর আকার, JIT আর -Xss-এর ওপর নির্ভর করে — তাই নিচের কোড গভীরতার সংখ্যা ছাপে না, শুধু নিশ্চিত তথ্যগুলো ছাপে:

class Main {
    static int depth = 0;
    static long sumTo(long n) {                 // প্রতিটি কলে stack-এ নতুন frame
        depth++;
        return n == 0 ? 0 : n + sumTo(n - 1);
    }
    static long sumLoop(long n) {               // একটিই frame, যত বড় n হোক
        long s = 0;
        for (long i = 1; i <= n; i++) s += i;
        return s;
    }
    public static void main(String[] args) {
        System.out.println("sumTo(1000) = " + sumTo(1_000));
        depth = 0;
        try {
            sumTo(10_000_000);
            System.out.println("শেষ হয়েছে");
        } catch (StackOverflowError e) {
            System.out.println("ধরা পড়ল: " + e.getClass().getName());
            System.out.println("১ কোটি frame-এর আগেই থেমেছে? " + (depth < 10_000_000));
        }
        System.out.println("লুপ দিয়ে sum(1..1 কোটি) = " + sumLoop(10_000_000));
    }
}

আউটপুট চার লাইন: sumTo(1000) = 500500, ধরা পড়ল: java.lang.StackOverflowError, ১ কোটি frame-এর আগেই থেমেছে? true আর লুপ দিয়ে sum(1..1 কোটি) = 50000005000000. ১,০০০ গভীরতা stack-এ আরামে ধরে। কিন্তু এক কোটি গভীরতার জন্য এক কোটি frame লাগত — ১ MB-এর stack তার অনেক আগেই ভরে যায়। ঠিক কত গভীরে, তা প্রতিবার একটু আলাদা হতে পারে, তাই সংখ্যাটি ছাপা হয়নি। StackOverflowError ধরা পড়ার সাথে সাথে সব frame সরে যায় (stack unwinding), তাই পরের লাইন স্বাভাবিকভাবে চলে, আর লুপের সংস্করণ একটিমাত্র frame-এ একই হিসাব করে ফেলে। HotSpot tail call-কে লুপে বদলায় না, তাই গভীর recursion-এর জায়গায় লুপ বা নিজের Deque ব্যবহার করুন। এখানে শুধু দেখানোর জন্য Error ধরা হয়েছে — বাস্তব কোডে StackOverflowError ধরে চালিয়ে যাওয়া সমাধান নয়।

৮ · GC থাকলেও memory leak — চেনা পাঁচটি রূপ

C-তে leak মানে free ভুলে যাওয়া। Java-তে leak মানে reference ভুলে যাওয়া: object আর দরকার নেই, কিন্তু কোনো GC root থেকে এখনো পৌঁছানো যায়। GC নিয়ম মেনেই তাকে রেখে দেয়। একটি বাস্তব গল্প দিয়ে দেখি — একটি ই-কমার্স সার্ভার, যার মেমরি প্রতিদিন একটু একটু বাড়ে, আর প্রতি সপ্তাহে একবার রিস্টার্ট লাগে:

1static Map<String, Session> SESSIONS = new HashMap<>();2void login(User u)  { SESSIONS.put(newId(), new Session(u)); }3void logout(String id) { /* remove করতে ভুলে গেছি */ }4// ... এক সপ্তাহ: ১০ লাখ login, ১০ লাখ logout5// minor GC, mixed GC ... চলছে6SESSIONS.remove(id);  // + মেয়াদ ও সীমা
অবস্থা (সবুজ লাইনটি এইমাত্র চলেছে)
SESSIONS.size()
সত্যিই সক্রিয় ব্যবহারকারী
পুরোনো Session কি reachable?
heap-এর প্রবণতা
  1. একটি static map. Class লোড হওয়ার সাথে সাথে SESSIONS তৈরি। static field একটি GC root থেকে পৌঁছানো যায়, আর class বেঁচে থাকে প্রায় অ্যাপের পুরো জীবন। তাই এই map-এ যা ঢুকবে, তা ততক্ষণ reachable — যতক্ষণ না কেউ বের করে।
  2. Login — সব ঠিক। একজন ব্যবহারকারী ঢুকলেন, একটি Session তৈরি হয়ে map-এ গেল। এখানে reachable থাকাই উচিত — তিনি এখনো কেনাকাটা করছেন। তার ভেতরের User, cart — পুরো গাছটাই root থেকে পৌঁছানো যায়।
  3. Logout — বাগটা এখানে। ব্যবহারকারী বেরিয়ে গেলেন, কিন্তু logout map থেকে entry সরায় না। প্রোগ্রামারের চোখে Session এখন অপ্রয়োজনীয়। GC-র চোখে? root → SESSIONS → Session — পথ আছে, তাই জীবিত। GC-র কোনো দোষ নেই; সে নিয়ম মেনেছে।
  4. এক সপ্তাহ পর। দশ লাখ login, দশ লাখ logout — map-এ দশ লাখ entry, অথচ সক্রিয় মাত্র প্রায় ৮০০ জন। প্রতিটি Session কয়েকটি minor GC-তে বেঁচে গিয়ে old অংশে promote হয়েছে। Old অংশ ধীরে ধীরে ভরছে, mixed GC ঘন ঘন হচ্ছে, আর প্রতিবার খুব কম জায়গা মিলছে।
  5. OutOfMemoryError. একসময় GC সব চেষ্টা করেও নতুন object-এর জায়গা পায় না। শুক্রবার রাতের ফ্ল্যাশ সেলে, যখন লোড সবচেয়ে বেশি, সার্ভার পড়ে যায়। Trace-এর শেষ লাইন হয়তো দেখায় নির্দোষ কোনো JSON তৈরির লাইন।
  6. সমাধান — তিন স্তরে। (১) logout-এ SESSIONS.remove(id). (২) কিন্তু অনেকে logout না করেই ব্রাউজার বন্ধ করেন — তাই নিষ্ক্রিয়তার মেয়াদ, যেমন ৩০ মিনিট পর বাতিল। (৩) নিরাপত্তার জাল হিসেবে সর্বোচ্চ সংখ্যার সীমা। বাস্তবে এই কাজের জন্য Spring Session বা Caffeine-এর মতো পরীক্ষিত library ব্যবহার হয়। এখন প্রতিটি GC-র পর heap আগের জায়গায় ফেরে — সুস্থ “করাত-দাঁত” আকৃতি।

পাঁচটি চেনা রূপ

  1. বাড়তেই থাকা static collection — উপরের session map, বা “পরে কাজে লাগবে” ভেবে সব ইভেন্ট জমানো একটি static List.
  2. সরানো হয়নি এমন listener — একটি দীর্ঘজীবী object-এ (যেমন একটি event bus) listener নিবন্ধন করলেন, কিন্তু কাজ শেষে সরালেন না। Event bus listener-কে ধরে রাখে, আর listener ধরে রাখে তার পুরো object.
  3. Eviction ছাড়া cache — HashMap-কে cache বানালে সেটি শুধু বাড়ে। সমাধান সীমা (পর্ব ৩১-এর LRU cache) বা মেয়াদ।
  4. Thread pool-এ ThreadLocal — প্রতিটি thread-এর নিজস্ব একটি মান রাখার ব্যবস্থা। Thread pool-এর thread কখনো মরে না (পর্ব ৪১), তাই remove() না করলে মান থেকে যায় — আর পরের অনুরোধে অন্য ব্যবহারকারীর ডেটা ফাঁসও হতে পারে। finally-তে remove() ডাকুন।
  5. Inner class-এর লুকানো reference — পর্ব ২৭-এ দেখেছেন, non-static inner class বা anonymous class-এর object বাইরের object-এর একটি লুকানো reference (enclosing instance) রাখে। একটি ছোট callback দীর্ঘদিন বেঁচে থাকলে পুরো বড় বাইরের object-ও বাঁচে। দরকার না হলে static nested class ব্যবহার করুন।

এবার থাম্বনেইল cache-এর একটি ছোট মডেল চালিয়ে দেখুন। একটি অনলাইন শপে এক লাখ আলাদা পণ্যের ছবি চাওয়া হলো। একটি cache সীমাহীন HashMap, অন্যটি LinkedHashMap দিয়ে ১০০টির সীমা দেওয়া LRU. মেমরির সংখ্যা নয় — শুধু entry গোনা হচ্ছে, যা সবসময় একই আসে:

import java.util.*;
class Main {
    // ভুল: কখনো কিছু সরায় না — প্রতিটি thumbnail চিরকাল থেকে যায়
    static final Map<Integer, String> leaky = new HashMap<>();
    // ঠিক: সর্বোচ্চ ১০০টি, সবচেয়ে পুরোনো ব্যবহারেরটি বাদ (LRU)
    static final Map<Integer, String> lru = new LinkedHashMap<>(16, 0.75f, true) {
        @Override
        protected boolean removeEldestEntry(Map.Entry<Integer, String> eldest) {
            return size() > 100;
        }
    };
    public static void main(String[] args) {
        for (int id = 1; id <= 100_000; id++) {      // ১ লাখ আলাদা পণ্যের ছবি চাওয়া হলো
            String thumb = "thumb-" + id;
            leaky.put(id, thumb);
            lru.put(id, thumb);
            if (id == 1_000 || id == 10_000 || id == 100_000)
                System.out.println(id + " অনুরোধ পর: leaky=" + leaky.size() + ", lru=" + lru.size());
        }
        System.out.println("lru-তে সাম্প্রতিক 99999? " + lru.containsKey(99_999));
        System.out.println("lru-তে পুরোনো 5? " + lru.containsKey(5));
    }
}

আউটপুট পাঁচ লাইন: 1000 অনুরোধ পর: leaky=1000, lru=100, 10000 অনুরোধ পর: leaky=10000, lru=100, 100000 অনুরোধ পর: leaky=100000, lru=100, তারপর lru-তে সাম্প্রতিক 99999? true আর lru-তে পুরোনো 5? false. leaky অনুরোধের সাথে সমান তালে বাড়ে — এক লাখ অনুরোধে এক লাখ String আর এক লাখ entry object, সবই static field থেকে reachable, তাই GC একটিও ছুঁতে পারে না। বাস্তবে প্রতিটি thumbnail কয়েক KB-এর byte[] হলে এটাই কয়েক দিনে OutOfMemoryError. lru কখনো ১০০ ছাড়ায় না: LinkedHashMap-এর constructor-এর তৃতীয় argument true মানে “ব্যবহারের ক্রমে সাজাও”, আর প্রতিটি put-এর পর removeEldestEntry true দিলে সবচেয়ে পুরোনো ব্যবহারের entry বাদ যায় (পর্ব ৩১)। বাদ যাওয়া entry আর কোনো root থেকে পৌঁছানো যায় না — তাদের জায়গা পরের GC ফেরত নেবে। (নিজের প্রজেক্টে main class সাধারণত public class Main; এই পাতার রানারের জন্য public বাদ।)

চার রকম reference — strong, soft, weak, phantom

সাধারণ ভ্যারিয়েবল একটি strong reference: যতক্ষণ আছে, object বাঁচবে। java.lang.ref প্যাকেজ আরও তিন রকম দুর্বল reference দেয়, যা GC-কে বলে “এই object-এর জন্য আমাকে গোনার দরকার নেই”:

ধরনGC কখন মুছতে পারেব্যবহার
Strongকখনো না, যতক্ষণ reference আছেসাধারণ সব কোড
SoftReferenceমেমরির টান পড়লে; OutOfMemoryError-এর আগে অবশ্যইমেমরি-সংবেদনশীল cache (তবে আচরণ অনুমান করা কঠিন)
WeakReferenceপরের GC-তেই, যদি শুধু weak reference বাকি থাকেWeakHashMap, object-এর সাথে বাড়তি তথ্য জোড়া
PhantomReferenceobject আগেই unreachable; শুধু “মুছে গেছে” খবর পাওয়ার জন্যপরিষ্কারের কাজ — Cleaner-এর ভিত্তি

WeakHashMap-এর key-র বাইরের সব strong reference হারালে entry একসময় নিজে থেকে মুছে যায়। কিন্তু “একসময়” মানে GC যখন চালাবে — তাই এই পাতায় এর চালানো উদাহরণ নেই, আউটপুট প্রতিবার আলাদা হতে পারত। String literal বা ছোট Integer-কে key বানালে সেগুলো pool/cache থেকে strong-ভাবে ধরা থাকে, entry কখনো মোছে না। বেশিরভাগ cache-এর জন্য স্পষ্ট সীমা বা মেয়াদ বেশি নির্ভরযোগ্য।

finalize() ব্যবহার করবেন না। এটি Java 9 থেকে deprecated, আর Java 18-এ মুছে ফেলার জন্য চিহ্নিত (JEP 421). কখন চলবে বা আদৌ চলবে কি না, নিশ্চিত নয়; object-কে “পুনর্জীবিত” করা যায়; GC ধীর করে। ফাইল বা সংযোগ বন্ধ করতে try-with-resources (পর্ব ২৯)। শেষ নিরাপত্তা-জাল হিসেবে Java 9-এর Cleaner (পর্ব ১৬-এ উল্লেখ করা হয়েছিল) — যা PhantomReference দিয়ে তৈরি, আর নিজের object-এর reference রাখে না বলে পুনর্জীবনের ঝুঁকি নেই।

C/C++/Python জানলে

C-তে malloc/free হাতে মেলাতে হয়; ভুলে use-after-free বা double free (C dynamic memory পর্ব)। C++ RAII আর unique_ptr/shared_ptr দিয়ে scope শেষ হতেই নিশ্চিতভাবে মুছে দেয় — Java-তে এমন নিশ্চিত মুহূর্ত নেই, তাই রিসোর্সের জন্য try-with-resources (C++ RAII পর্ব)। CPython মূলত reference counting করে — গণনা শূন্য হলেই সঙ্গে সঙ্গে মোছে — আর চক্রের জন্য আলাদা একটি generational cycle collector চালায় (Python কীভাবে চলে)। JVM-এ কোনো গণনা নেই; পুরোটাই tracing.

৯ · অনুমান নয়, মাপুন — jcmd, jstat, heap dump, JFR

মেমরির সমস্যায় সবচেয়ে বড় ভুল হলো না মেপে flag বদলানো। JDK-র সাথেই চমৎকার টুল আসে; চলমান JVM-এর process id (pid) দিয়ে এগুলো চালানো যায়, অ্যাপ বন্ধ না করেই:

# চলমান সব JVM আর তাদের pid
jcmd

# heap-এর এখনকার অবস্থা, আর কোন class-এর কতগুলো object
jcmd 4321 GC.heap_info
jcmd 4321 GC.class_histogram

# প্রতি ১ সেকেন্ডে eden / survivor / old কত ভরা, কতবার GC
jstat -gcutil 4321 1000

# পুরো heap-এর ছবি ফাইলে (heap dump) — বড় heap-এ সময় নেয়, pause হয়
jcmd 4321 GC.heap_dump /tmp/shop.hprof

# Java Flight Recorder: ৬০ সেকেন্ডের রেকর্ডিং, খুব কম খরচে
jcmd 4321 JFR.start duration=60s filename=/tmp/shop.jfr
টুলকী দেখায়কখন
jcmdএক টুলে অনেক কাজ: heap তথ্য, histogram, dump, JFR, thread dumpপ্রথম পছন্দ
jstatসময়ের সাথে প্রতিটি অঞ্চলের ভরাট আর GC-র সংখ্যাপ্রবণতা দেখতে
Heap dump + Eclipse MAT / VisualVMকোন object কত মেমরি ধরে রেখেছে, আর কোন root থেকে পথleak-এর দোষী খুঁজতে
JFR + JDK Mission Controlallocation, GC pause, lock, CPU — সব সময়ের সাথেproduction-এ চালু রেখেও

Leak খোঁজার পরীক্ষিত পদ্ধতি: কিছুক্ষণ ব্যবধানে দুটি heap dump বা class histogram নিন, তুলনা করুন কোন class-এর object সংখ্যা শুধু বাড়ছে। তারপর MAT-এ সেই object-এর “path to GC roots” দেখুন — উপরের উদাহরণে পথটি সরাসরি দেখাবে SESSIONS নামের static field. JFR আগে আলাদা বাণিজ্যিক বৈশিষ্ট্য ছিল; JDK 11-এ OpenJDK-তে উন্মুক্ত হয়েছে (JEP 328), আর এর খরচ এত কম যে অনেক দল production-এ সবসময় চালু রাখে।

টিউনিংয়ের নিয়ম

আগে সংখ্যা, তারপর বদল। সমস্যাটি সংখ্যায় লিখুন (“p99 ৩০০ ms, লক্ষ্য ১০০ ms”), মেপে নিশ্চিত হোন দোষী GC, আগে allocation কমান (অপ্রয়োজনীয় boxing, বিশাল অস্থায়ী list), তারপর একবারে একটি flag বদলে একই লোডে তুলনা করুন। নতুন JDK-তে উঠলে পুরোনো টিউনিং মুছে আবার মাপুন — ডিফল্টগুলো প্রায়ই আগের হাতে-বসানো flag-এর চেয়ে ভালো।

পরীক্ষা ও ইন্টারভিউতে
  • Heap আর stack-এর পার্থক্য? — heap-এ সব object, সব thread মিলে একটি, GC পরিষ্কার করে; stack প্রতিটি thread-এর নিজের, method-এর frame আর local রাখে, method শেষ হলেই frame সরে।
  • Minor আর full GC? — minor GC শুধু young অংশ (eden + survivor) পরিষ্কার করে, ঘন ঘন ও দ্রুত; full GC পুরো heap থামিয়ে পরিষ্কার করে, বিরল ও লম্বা।
  • Java-তে কি memory leak হয়? — হ্যাঁ: অপ্রয়োজনীয় object কোনো GC root থেকে পৌঁছানো গেলে (যেমন static collection-এ) GC তাকে মুছতে পারে না।
  • System.gc() কি GC চালায়? — শুধু অনুরোধ; JVM উপেক্ষা করতে পারে (-XX:+DisableExplicitGC), আর ডাকলে প্রায়ই অপ্রয়োজনীয় full GC হয়।
  • PermGen আর Metaspace? — Java 8-এ PermGen বাদ দিয়ে class-এর তথ্য রাখা হয় heap-এর বাইরের Metaspace-এ (JEP 122), যা প্রয়োজন মতো বাড়ে।

১০ · AI-এর যুগে — JVM flag আর “GC-র দোষ” যাচাই

মেমরি বা গতির সমস্যায় AI-কে জিজ্ঞেস করলে সে প্রায়ই একগাদা JVM flag দেয় — দেখতে পেশাদার, কিন্তু অনেকগুলো পুরোনো, কিছু পরস্পরবিরোধী, আর প্রায় কোনোটাই আপনার অ্যাপ মেপে দেওয়া নয়। AI আপনার GC log দেখেনি; সে দেখেছে ইন্টারনেটের হাজারো পুরোনো টিউনিং ব্লগ।

AI যা বলতে পারেসমস্যা
“-Xmx ১৬ GB করে দিন”Leak থাকলে শুধু দেরিতে ভাঙবে; বড় heap-এ pause লম্বা হতে পারে; container-এর সীমা ছাড়ালে OS process মেরে ফেলবে
“লুপের পর System.gc() ডাকুন”সাধারণত পুরো heap-এর GC চাপিয়ে দেয় — দ্রুত হওয়ার বদলে লম্বা pause
“static Map দিয়ে cache বানিয়ে নিন”সীমা আর মেয়াদ নেই — ঠিক অংশ ৮-এর leak
“-XX:+UseConcMarkSweepGC, -XX:MaxPermSize দিন”CMS Java 14-এ মুছে ফেলা, PermGen Java 8-এ; নতুন JDK-তে এমন অচেনা flag দিলে JVM চালুই না-ও হতে পারে
“অ্যাপ ধীর — নিশ্চয়ই GC-র দোষ”মাপ ছাড়া দাবি; কারণ হতে পারে ডেটাবেস, lock বা নেটওয়ার্ক
“Java 21-এ শুধু -XX:+UseZGC দিলেই generational ZGC”Java 21-এ বাড়তি -XX:+ZGenerational লাগে; ডিফল্ট হয় Java 23-এ

AI-কে অনুমান করতে না দিয়ে তথ্য দিন আর প্রমাণ চান:

আমার Java 21 (HotSpot) Spring Boot অ্যাপ Docker container-এ চলে, মেমরি সীমা 2 GB, 2টি CPU. সমস্যা: কয়েক ঘণ্টা পর p99 latency 80 ms থেকে 400 ms হয়ে যায়, আর একদিন পর OutOfMemoryError: Java heap space. নিচে আমার -Xlog:gc log-এর অংশ আর jcmd GC.class_histogram-এর প্রথম ২০ লাইন দিলাম। শর্ত: ১. আগে বলো log দেখে এটা leak, নাকি শুধু heap ছোট — কোন লাইনের কোন সংখ্যা দেখে বলছ। ২. কোনো JVM flag সুপারিশ করলে প্রতিটির পাশে লেখো সেটি কী করে, Java 21-এ আছে কি না, আর কোন পরিমাপ দিয়ে আমি তার ফল যাচাই করব। ৩. System.gc() বা বিশাল -Xmx সুপারিশ করবে না। ৪. leak সন্দেহ হলে heap dump নিয়ে কোন class-এর "path to GC roots" দেখতে হবে, বলো। ৫. নিশ্চিত না হলে "নিশ্চিত নই" লেখো।

AI-এর মেমরি-পরামর্শ হাতে পেলে — চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১John McCarthy কেন Lisp-এর জন্য garbage collection উদ্ভাবন করলেন, আর প্রথম GC দেখতে কেমন ছিল?

১৯৫০-এর দশকের শেষে MIT-তে John McCarthy কৃত্রিম বুদ্ধিমত্তার গবেষণার জন্য একটি নতুন ভাষা বানাচ্ছিলেন — Lisp. এই ভাষার মূল ডেটা হলো list, আর list তৈরি হয় ছোট ছোট “cons cell” জুড়ে, প্রতিটিতে দুটি pointer. একটি প্রোগ্রাম চলার সময় অগণিত list তৈরি হয়, ভাঙে, অংশবিশেষ ভাগাভাগি হয় — একটি sublist হয়তো তিনটি বড় list-এর অংশ।

এমন জগতে প্রোগ্রামারকে দিয়ে “এই cell আর লাগবে না” বলানো প্রায় অসম্ভব। কারণ একটি cell কতগুলো জায়গা থেকে দেখা হচ্ছে, তা এক জায়গায় বসে জানা যায় না। McCarthy সমাধান দিলেন উল্টো দিক থেকে: প্রোগ্রামার কিছুই মুছবেন না। খালি cell-এর তালিকা ফুরিয়ে গেলে সিস্টেম নিজে প্রোগ্রামের হাতে থাকা ভ্যারিয়েবল থেকে শুরু করে সব পৌঁছানো যায় এমন cell চিহ্নিত করবে, তারপর পুরো মেমরি ঘুরে বাকিগুলো খালি তালিকায় ফেরত দেবে। এটাই mark-sweep — ১৯৬০ সালের এপ্রিলে Communications of the ACM-এ প্রকাশিত তাঁর Lisp-বিষয়ক বিখ্যাত প্রবন্ধে বর্ণিত। প্রবন্ধে McCarthy একে “reclamation” বলেছিলেন, যদিও পরে তিনি স্মরণ করেছেন যে দলের ভেতরে তাঁরা আগেই একে “garbage collection” ডাকতেন।

প্রথম দিকের এই GC-তে পুরো প্রোগ্রাম থেমে যেত, আর ধীর মেশিনে সেই বিরতি লক্ষণীয় ছিল। কয়েক দশক ধরে GC-কে তাই “একাডেমিক ভাষার বিলাসিতা” মনে করা হতো। ১৯৭০-এর দশকে Cheney-র copying অ্যালগরিদম, ১৯৮০-এর দশকে generational collection, ১৯৯০-এর দশকে concurrent collector — ধাপে ধাপে খরচ কমেছে। Java ১৯৯৫ সালে GC-কে মূলধারার শিল্প-ভাষায় নিয়ে আসে, তারপর C#, Go, JavaScript, Kotlin — আজ বেশিরভাগ জনপ্রিয় ভাষাই GC-নির্ভর।

লক্ষ করুন, McCarthy-র মূল অন্তর্দৃষ্টি আজও অপরিবর্তিত: “কোনটা garbage” প্রশ্নের উত্তর কঠিন, কিন্তু “কোনটা পৌঁছানো যায়” প্রশ্নের উত্তর যান্ত্রিকভাবে বের করা যায়। ZGC-র colored pointer বা G1-এর region — সবই সেই প্রথম mark phase-এর আধুনিক রূপ।

মূল উপলব্ধি: একটি কঠিন প্রশ্নকে সমতুল্য কিন্তু হিসাবযোগ্য প্রশ্নে বদলে নেওয়াই ভালো প্রকৌশল। GC “দরকার” বোঝে না, “পৌঁছানো” বোঝে — আর এই ব্যবধানটুকু সামলানোর দায়িত্ব এখনো প্রোগ্রামারের।

প্র ০২“বেশিরভাগ object অল্প বয়সে মরে” — এই generational hypothesis কোথা থেকে এলো, আর এটি কি সবসময় সত্য?

১৯৮৩ সালে MIT-র Henry Lieberman ও Carl Hewitt একটি প্রবন্ধ প্রকাশ করেন — “A Real-Time Garbage Collector Based on the Lifetimes of Objects”. তাঁদের যুক্তি: object-এর আয়ু সমান নয়; নতুন object-দের মধ্যে মৃত্যুহার অনেক বেশি। তাই সব object-কে প্রতিবার একইভাবে পরীক্ষা করা অপচয় — বয়স অনুযায়ী ভাগ করে নতুনদের বেশি ঘন ঘন দেখা উচিত।

পরের বছর, ১৯৮৪ সালে, UC Berkeley-র David Ungar “Generation Scavenging” নামে একটি ব্যবহারিক কৌশল প্রকাশ করেন, Berkeley Smalltalk-এর জন্য। নতুন object জন্মায় একটি ছোট অঞ্চলে; সেটি ভরলে শুধু জীবিতদের কপি করা হয়; কয়েকবার বেঁচে যাওয়া object-কে পুরোনো অঞ্চলে পাঠানো হয়। আজকের HotSpot-এর eden, survivor আর promotion প্রায় হুবহু এই নকশা। মজার যোগসূত্র: এই Ungar-ই পরে Randall Smith-এর সাথে Self ভাষা বানান, যার গবেষণা থেকে HotSpot JVM-এর জন্ম (পর্ব ০৫-এর ভাবনার প্রশ্ন দেখুন)।

এই অনুমানকে বলা হয় “weak generational hypothesis” — শুধু বলে বেশিরভাগ object অল্প বয়সে মরে। বাস্তবে Java সার্ভারে এটি চমৎকারভাবে মেলে: প্রতিটি HTTP অনুরোধের জন্য তৈরি শত শত object অনুরোধ শেষেই মৃত।

কিন্তু সবসময় নয়। একটি বড় in-memory cache, যা ঘণ্টায় ঘণ্টায় নতুন ডেটা দিয়ে ভরে আর পুরোনো সরায় — সেখানে object-রা “মাঝবয়সে” মরে: young-এ বেঁচে গিয়ে old-এ উঠে তারপর মরে, যা generational GC-র সবচেয়ে খারাপ অবস্থা (old অংশ ঘন ঘন ভরে)। এজন্যই ZGC বহু বছর generational ছাড়াই চলেছিল, আর পরে generational হলো (Java 21) যখন দেখা গেল বেশিরভাগ অ্যাপে এতে CPU ও মেমরি দুটোই বাঁচে।

মূল উপলব্ধি: প্রতিটি দ্রুত অ্যালগরিদম কোনো না কোনো পরিসংখ্যানগত অনুমানের ওপর দাঁড়িয়ে। অনুমানটা জানলে বোঝা যায় কোন workload-এ সেটি ভেঙে পড়বে — আর তখন কী মাপতে হবে।

প্র ০৩System.gc() কেন শুধু একটি অনুরোধ, আর বাস্তব কোডে এটি ডাকা প্রায়ই ক্ষতিকর কেন?

Java-র documentation-এ System.gc()-এর বর্ণনা সাবধানী: এটি JVM-কে অব্যবহৃত object-এর জায়গা ফেরত নেওয়ার চেষ্টা করতে “পরামর্শ” দেয়; কতটা জায়গা মিলবে, কখন, বা আদৌ কিছু হবে কি না — কোনো নিশ্চয়তা নেই। JVM চাইলে এটি পুরোপুরি উপেক্ষা করতে পারে — HotSpot-এ -XX:+DisableExplicitGC দিলে ঠিক তাই হয়।

কেন এমন নকশা? কারণ GC কখন চালানো ভালো, তা JVM আপনার চেয়ে ভালো জানে — কোন অঞ্চল কতটা ভরা, কতগুলো object অল্পবয়সী, শেষ GC কবে হয়েছিল। একটি নির্দিষ্ট সময়ে GC চালানোর অধিকার প্রোগ্রামারকে দিলে collector-এর সব হিসাব ভেস্তে যেতে পারে।

আর ক্ষতিটা বাস্তব। HotSpot-এ ডিফল্টভাবে System.gc() সাধারণত একটি full GC চালায় — পুরো heap, young ও old, প্রোগ্রাম থামিয়ে। যে কাজ একটি ছোট minor GC-তে হতো, তার জন্য পুরো heap ঘাঁটা হয়। আরও খারাপ: অনেক object অকালে পুরোনো অংশে উঠে যেতে পারে। একটি লুপের ভেতরে প্রতি অনুরোধে System.gc() ডাকা — AI-লেখা কোডে এমন দেখা যায় — সার্ভারকে প্রায় অচল করতে পারে। G1-এ -XX:+ExplicitGCInvokesConcurrent দিয়ে একে concurrent চক্রে বদলানো যায়, কিন্তু সেটা উপসর্গের চিকিৎসা।

একটি সূক্ষ্ম উদাহরণ হলো direct memory: heap-এর বাইরের একটি buffer-এর মেমরি মুক্ত হয় তখনই, যখন তাকে ধরে রাখা ছোট Java object-টি GC-তে মোছে। তাই direct memory-র সীমায় পৌঁছালে JDK নিজেই ভেতরে System.gc() ডেকে অব্যবহৃত buffer মুক্ত করার চেষ্টা করে। তখন DisableExplicitGC দেওয়া থাকলে উল্টো direct memory-র OutOfMemoryError দেখা দিতে পারে। অর্থাৎ এই flag-ও না বুঝে দেওয়া বিপজ্জনক।

তাহলে কখনোই নয়? Benchmark বা পরীক্ষার কোডে, যেখানে দুটি মাপের মাঝে heap পরিষ্কার রাখতে চান, কেউ কেউ এটি ব্যবহার করেন — সেখানেও JMH-এর মতো টুল ভালো। Production কোডে System.gc() দেখলে সাধারণত সেটি কোনো গভীর সমস্যা (leak বা ভুল আকারের heap) ঢাকার চেষ্টা।

মূল উপলব্ধি: যে সিস্টেমের কাছে বেশি তথ্য আছে, সিদ্ধান্ত তার হাতে থাকা ভালো। প্রোগ্রামারের কাজ GC-কে আদেশ দেওয়া নয়, কম garbage তৈরি করা আর অপ্রয়োজনীয় reference ছেড়ে দেওয়া।

প্র ০৪Low-latency ট্রেডিং আর “GC pause”-এর গল্প — ZGC ও Shenandoah কেন এক মিলিসেকেন্ডের নিচে pause-এর লক্ষ্য নেয়?

শেয়ার বা মুদ্রা বাজারের স্বয়ংক্রিয় ট্রেডিং সিস্টেমে প্রতিটি অর্ডারের উত্তর আসে মাইক্রোসেকেন্ড থেকে কয়েক মিলিসেকেন্ডে। সেখানে ৫০ ms-এর একটি GC pause মানে প্রতিযোগীর চেয়ে হাজারগুণ দেরি — বা ভুল দামে লেনদেন। তবু এই শিল্পে Java বহুল ব্যবহৃত, কারণ এতে দ্রুত নিরাপদ কোড লেখা যায়। তাহলে pause সামলানো হতো কীভাবে?

প্রথম কৌশল ছিল garbage তৈরিই না করা। লন্ডনের LMAX এক্সচেঞ্জ ২০১০-এর দশকের শুরুতে Disruptor নামের একটি Java library উন্মুক্ত করে: আগে থেকে বানানো object-এর একটি ring buffer, যা বারবার পুনর্ব্যবহার হয়, ফলে চলার সময় প্রায় কোনো নতুন allocation নেই। কিছু দল দিনের শুরুতে বিশাল heap দিয়ে চালু করত যাতে বাজার খোলা থাকার সময় GC প্রায় না হয়, রাতে রিস্টার্ট। কার্যকর, কিন্তু এমন Java লেখা কঠিন আর অস্বাভাবিক।

দ্বিতীয় কৌশল ছিল ভিন্ন JVM. Azul Systems-এর বাণিজ্যিক JVM-এর C4 collector (২০১১ সালে প্রবন্ধ প্রকাশিত) object সরানোসহ প্রায় সব কাজ প্রোগ্রামের পাশাপাশি করে দেখায় যে বড় heap-এও pause ছোট রাখা সম্ভব।

OpenJDK-তে সেই চিন্তা আসে দুটি প্রকল্পে। Red Hat-এর Shenandoah concurrent compaction করে, যাতে pause heap-এর আকারের ওপর প্রায় নির্ভর না করে। Oracle-এর ZGC colored pointer আর load barrier ব্যবহার করে: প্রতিটি reference পড়ার সময় একটি ছোট যাচাই, object সরে গিয়ে থাকলে সেখানেই ঠিকানা ঠিক। Java 16-এ thread stack-এর কাজও concurrent হওয়ার পর (JEP 376) ZGC-র pause সাধারণত এক মিলিসেকেন্ডের নিচে নামে। লক্ষ্যটা “শূন্য pause” নয় — safepoint-এ থামা, root স্ক্যানের শুরু — কিছু ছোট বিরতি থাকেই। আর OS-এর scheduling বা মেশিনের অন্য কাজও দেরি আনতে পারে, যা GC-র নিয়ন্ত্রণের বাইরে।

দাম কী? প্রতিটি reference পড়ায় barrier-এর খরচ, collector thread-এর বাড়তি CPU, আর concurrent কাজের সময় নতুন allocation-এর জন্য heap-এ বাড়তি জায়গা। Allocation-এর হার খুব বেশি হলে collector পিছিয়ে পড়তে পারে, তখন thread-কে allocation-এ অপেক্ষা করতে হয় (allocation stall).

মূল উপলব্ধি: latency-র সমস্যার দুটি সমাধান — কম garbage তৈরি করা, আর এমন collector যা কাজ সময়ের মধ্যে ছড়িয়ে দেয়। দুটোরই দাম আছে; কোনটি নেবেন, তা নির্ভর করে আপনার latency বাজেট কত কঠোর তার ওপর।

প্র ০৫Rust GC ছাড়াই মেমরি-নিরাপত্তা দেয় — তাহলে Java কেন এখনো GC রাখে? দুই পথের বিনিময় কী?

মেমরি সামলানোর তিনটি বড় পথ আছে। C/C++: প্রোগ্রামার নিজে (C++-এ RAII আর smart pointer অনেকটা সাহায্য করে)। Java, Go, C#: garbage collector. আর Rust (১.০ প্রকাশিত ২০১৫ সালে): ownership.

Rust-এ প্রতিটি মানের ঠিক একজন মালিক থাকে। মালিকের scope শেষ হলেই মান মুছে যায় — compile-এর সময়ই নির্ধারিত, চলার সময় কোনো collector নেই। অন্যরা মান “ধার” (borrow) নিতে পারে, কিন্তু borrow checker নামের compiler-এর অংশ নিশ্চিত করে যে কোনো ধার মালিকের চেয়ে বেশিদিন বাঁচে না, আর একই সময়ে একাধিক জায়গা থেকে বদলানো যায় না। ফলে use-after-free, double free, এমনকি data race-এর বড় অংশ compile-এর সময়েই ধরা পড়ে। Pause নেই, বাড়তি heap-ও নেই।

এর একটি পরিচিত বাস্তব উদাহরণ: Discord ২০২০ সালে এক ব্লগে লিখেছিল, Go-তে লেখা তাদের একটি সার্ভিসে GC-র কারণে নিয়মিত latency spike হচ্ছিল; Rust-এ নতুন করে লেখার পর সেই spike চলে যায়। এমন ক্ষেত্রে — কঠোর latency, অল্প মেমরি, embedded যন্ত্র, OS kernel, ব্রাউজার ইঞ্জিন — Rust-এর পথ আকর্ষণীয়।

কিন্তু দাম আছে। Ownership-এর নিয়ম শেখা কঠিন; graph, doubly linked list বা পারস্পরিক reference-ওয়ালা object-এর মতো কাঠামো Rust-এ লিখতে বাড়তি কৌশল লাগে (Rc, RefCell — যা আসলে runtime reference counting). বড় ব্যবসায়িক অ্যাপে, যেখানে দল বড়, বদল ঘন ঘন, আর ১০ ms pause কারো ক্ষতি করে না — সেখানে GC প্রোগ্রামারের সময় বাঁচায়, আর সময়ই সবচেয়ে দামি সম্পদ। তাছাড়া GC-র একটি কম আলোচিত সুবিধা আছে: compaction. দীর্ঘদিন চলা সার্ভারে heap গুছিয়ে রাখা যায়, fragmentation জমে না; malloc-ভিত্তিক সিস্টেমে এটি একটি বাস্তব সমস্যা।

Java নিজেও দাঁড়িয়ে নেই। ZGC-র sub-millisecond pause GC-র সবচেয়ে বড় অভিযোগ অনেকটা কমিয়েছে, আর Project Valhalla header-হীন value object এনে মেমরির খরচ কমানোর চেষ্টা করছে।

মূল উপলব্ধি: মেমরি-নিরাপত্তার দাম কেউ না কেউ দেয় — Rust-এ প্রোগ্রামার, compile-এর সময়; Java-তে মেশিন, চলার সময়। কোন দাম সস্তা, তা নির্ভর করে আপনার সমস্যা, দল আর সময়সীমার ওপর।

অনুশীলন

এগুলোর বেশিরভাগে কোড লিখতে হবে না — হাতে-কলমে দেখা আর ভাবাই আসল কাজ।

  1. Reachability আঁকুন: কাগজে আঁকুন — static List<Order> history-তে Order#1; main()-এর local o2 দেখায় Order#2; Order#2 দেখায় Customer#3; Customer#4 আর Customer#5 একে অপরকে দেখায়; Order#1 দেখায় Customer#4. GC চললে কোন object-গুলো মুছবে?

    Root দুটি: static field history আর local o2. history → Order#1 → Customer#4 → Customer#5 (চক্রের মাধ্যমে)। o2 → Order#2 → Customer#3. অর্থাৎ পাঁচটিই reachable, কিছুই মুছবে না। চক্রটি এখানে বাঁচে, কারণ Order#1 থেকে তার কাছে পথ আছে। যদি history.clear() করা হয়, তাহলে Order#1, Customer#4, Customer#5 — তিনটিই একসাথে garbage, চক্র থাকা সত্ত্বেও।

  2. নিজের JVM জিজ্ঞেস করুন: নিজের কম্পিউটারে java -XX:+PrintCommandLineFlags -version চালান। কোন GC আর কত সর্বোচ্চ heap বেছে নেওয়া হয়েছে? তারপর যেকোনো ছোট প্রোগ্রাম -Xlog:gc দিয়ে চালিয়ে প্রথম লাইনটি পড়ুন।

    সাধারণ ল্যাপটপে দেখবেন -XX:+UseG1GC আর -XX:MaxHeapSize=... — সংখ্যাটি মোটামুটি আপনার RAM-এর এক-চতুর্থাংশ (যেমন ১৬ GB RAM-এ প্রায় ৪ GB). -Xlog:gc-এর প্রথম লাইন সাধারণত Using G1. যদি ১ CPU বা খুব কম মেমরির VM বা container-এ চালান, দেখবেন -XX:+UseSerialGC — JVM ছোট মেশিন দেখে নিজেই Serial বেছে নিয়েছে। সংখ্যা মেশিনভেদে আলাদা, তাই এখানে নির্দিষ্ট মান দেওয়া হলো না।

  3. Leak খুঁজুন: একটি ক্লাসে আছে static Map<Long, byte[]> reportCache = new HashMap<>(); — প্রতিটি রিপোর্ট অনুরোধে reportCache.put(System.nanoTime(), pdfBytes). কোথায় সমস্যা, আর আপনি কীভাবে ঠিক করবেন?

    দুটি সমস্যা। (১) key প্রতিবার নতুন (nanoTime), তাই একই রিপোর্ট আবার চাইলেও cache-এ কখনো মিলবে না — cache কোনো কাজই করছে না, শুধু জমাচ্ছে। (২) কোনো সীমা বা মেয়াদ নেই, আর static হওয়ায় সব PDF চিরকাল reachable — নিশ্চিত OutOfMemoryError: Java heap space. সমাধান: key হোক রিপোর্টের পরিচয় (যেমন রিপোর্টের ধরন + তারিখ), আর cache হোক সীমাবদ্ধ — removeEldestEntry-ওয়ালা LinkedHashMap (পর্ব ৩১) বা মেয়াদসহ কোনো পরীক্ষিত cache library. আগে ভাবুন, cache আদৌ দরকার কি না।

  4. Collector বাছাই: তিনটি পরিস্থিতিতে কোন collector দিয়ে শুরু করবেন, আর কেন? (ক) রাতে চলা একটি batch job, যা ৫০ লাখ লেনদেনের রিপোর্ট বানায়; (খ) একটি সাধারণ REST API, heap ৪ GB; (গ) ৬৪ GB heap-এর একটি রিয়েল-টাইম বিডিং সার্ভিস, প্রতিটি উত্তর ১০ ms-এর মধ্যে।

    (ক) Parallel — কেউ উত্তরের অপেক্ষায় নেই, তাই pause চলে; মোট কাজ দ্রুত শেষ হওয়াই লক্ষ্য। (খ) ডিফল্ট G1 — বেশিরভাগ API-তে ভারসাম্য যথেষ্ট; GC log মেপে সমস্যা দেখলে তবেই বদলান। (গ) ZGC (Java 21-এ -XX:+ZGenerational সহ) বা Shenandoah — বিশাল heap-এ G1-এর mixed GC pause ১০ ms-এর বাজেট ছাড়াতে পারে; বাড়তি CPU-মেমরির জায়গা রাখুন। তিনটিতেই একটি কথা: সিদ্ধান্তটি প্রথম অনুমান — বাস্তব লোডে মেপে নিশ্চিত করুন।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমHotSpot-এর generational heap-এ একটি দীর্ঘজীবী object-এর যাত্রা সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 🐣 new — eden-এ (TLAB-এ) জন্ম
  2. 🧹 প্রথম minor GC-তে বেঁচে survivor space-এ কপি, বয়স ১
  3. 🔁 পরের minor GC-গুলোতে S0 ↔ S1 অদলবদল, বয়স বাড়ে
  4. 🏛️ বয়স সীমা পেরিয়ে old অংশে promotion
  5. ✂️ কোনো GC root থেকে আর পৌঁছানো যায় না — unreachable
  6. ♻️ old অংশের পরিষ্কারে (mixed/major GC) জায়গা ফেরত

Java-তে কোন object garbage collection-এর যোগ্য হয়?

  • যেটি প্রোগ্রামে আর দরকার নেই
  • যার দিকে reference-এর গণনা শূন্য
  • যার কাছে কোনো GC root থেকে reference ধরে পৌঁছানো যায় না
  • যেটি ১৫টি GC পেরিয়েছে

HotSpot-এ young generation কোন কোন অংশ নিয়ে গঠিত?

  • Eden আর metaspace
  • Eden আর দুটি survivor space (S0, S1)
  • Eden আর tenured অংশ
  • Thread stack আর code cache

java.lang.OutOfMemoryError: Metaspace দেখলে সবচেয়ে সম্ভাব্য কারণ কোনটি?

  • খুব গভীর recursion
  • একটি static list-এ অসংখ্য object জমা
  • অতিরিক্ত platform thread তৈরি
  • বারবার class লোড হচ্ছে কিন্তু পুরোনো class loader মুক্ত হচ্ছে না

G1 collector-এর নামের “Garbage-First” অংশের অর্থ কী?

  • যে region-এ garbage সবচেয়ে বেশি, সেগুলো আগে পরিষ্কার করা
  • শুধু নতুন object আগে মোছা
  • প্রতিটি allocation-এর আগে GC চালানো
  • এটি Java-র প্রথম garbage collector

Java 21-এ generational ZGC চালু করতে কোন flag-গুলো লাগে?

  • শুধু -XX:+UseZGC
  • -XX:+UseG1GC -XX:+ZGenerational
  • -XX:+UseZGC -XX:+ZGenerational
  • -XX:+UseShenandoahGC

সাধারণ ৬৪-bit HotSpot-এ (compressed oops চালু) একটি Integer object আনুমানিক কত বাইট নেয়?

  • ৪ বাইট
  • ১৬ বাইট
  • ৮ বাইট
  • ৬৪ বাইট

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. GC মোছে অপৌঁছনীয় object — GC root (stack-এর local, static field, জীবিত thread, JNI) থেকে reference ধরে যাদের কাছে যাওয়া যায় না; চক্র কোনো বাধা নয়।
  2. Heap = young (eden + S0 + S1) + old; metaspace, thread stack, code cache, direct memory heap-এর বাইরে — -Xmx শুধু heap-এর সীমা।
  3. বেশিরভাগ object অল্প বয়সে মরে: minor GC জীবিতদের কপি করে, বয়স বাড়ায়, শেষে promotion; full GC সবচেয়ে লম্বা pause.
  4. Parallel = throughput, G1 = ডিফল্ট ভারসাম্য, ZGC/Shenandoah = ছোট pause (Java 21-এ generational ZGC-র জন্য -XX:+ZGenerational).
  5. Java-তে leak = ভুলে যাওয়া reference: সীমাহীন static collection, listener, cache, ThreadLocal, inner class. আগে মাপুন — -Xlog:gc, jcmd, heap dump, JFR.

পরের পর্বে: এই পর্বে দেখলেন class-এর তথ্য metaspace-এ থাকে। পরের পর্বে সেই তথ্য চলমান প্রোগ্রাম থেকেই পড়বেন — reflection দিয়ে একটি class-এর field আর method খুঁজে বের করা, আর annotation দিয়ে কোডে এমন তথ্য জুড়ে দেওয়া, যা Spring বা JUnit-এর মতো framework পড়ে কাজ করে।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান