জাভা অ্যাপ্লিকেশনে মেমরি লিক একটি নীরব সমস্যা—কোড কম্পাইল হয়, রান করে, কিন্তু ধীরে ধীরে মেমরি খেয়ে শেষমেশ OutOfMemoryError ছুঁড়ে দেয়। গার্বেজ কালেক্টর (GC) থাকা সত্ত্বেও অবাঞ্ছিত অবজেক্ট রেফারেন্স ধরে রাখলে এই লিক তৈরি হয়, যা প্রোডাকশন সিস্টেমকে অকার্যকর করে তোলে। এই টিউটোরিয়ালে আমরা জাভার মেমরি লিক চেনার উপায়, বাস্তব উদাহরণের মাধ্যমে লিক তৈরি ও ধরার কৌশল, এবং এমন কোড লেখা শিখবো যা লিক প্রতিরোধ করে। পুরো আলোচনা বাংলায়, সাথে পূর্ণাঙ্গ রান-যোগ্য জাভা কোড।

মেমরি লিক কী এবং কেন গুরুত্বপূর্ণ

জাভার গার্বেজ কালেক্টর স্বয়ংক্রিয়ভাবে অব্যবহৃত অবজেক্টের মেমরি পুনরুদ্ধার করলেও, প্রোগ্রামার ভুলে কিছু রেফারেন্স ধরে রাখলে সেই অবজেক্ট আর GC-তে অপসারিত হয় না—এটাই মেমরি লিক। একটি static লিস্টে প্রতিনিয়ত অবজেক্ট জমা করা, লিসেনার বা কলব্যাক রেজিস্টার করে মুছে না ফেলা, কিংবা থ্রেড-লোকাল ভ্যারিয়েবল ঠিকমতো পরিষ্কার না করলে লিক তৈরি হয়। এর ফলাফল: অ্যাপ্লিকেশন ধীর হয়ে যায়, GC-তে অতিরিক্ত সময় ব্যয় হয়, এবং একপর্যায়ে java.lang.OutOfMemoryError: Java heap space দিয়ে ক্র্যাশ করে। তাই লিক চিহ্নিত করা ও প্রতিরোধ করা জাভা ডেভেলপারের অপরিহার্য স্কিল।

মেমরি লিকের প্রভাব শুধু ক্র্যাশেই সীমাবদ্ধ নয়। দীর্ঘদিন চলা সার্ভার অ্যাপ্লিকেশনে লিক জমা হয়ে রেসপন্স টাইম বাড়িয়ে দেয়, ক্যাশ হিট রেশিও কমায়, এবং স্কেলিং ব্যাহত করে। জাভার শক্তিশালী প্রোফাইলিং টুল (যেমন VisualVM, JProfiler, Eclipse MAT) ব্যবহার করে হিপ ডাম্প বিশ্লেষণ করলে আমরা লিকের উৎস খুঁজে পেতে পারি। প্রথমেই বুঝতে হবে কিভাবে একটি নিরীহ সংকলন (collection) বা ইনার ক্লাস ভুল রেফারেন্স ধরে রাখে।

পূর্বশর্ত

এই টিউটোরিয়াল অনুসরণ করতে আপনার প্রয়োজন:

  • JDK 8 বা তার পরে ইন্সটল করা (ওপেন সোর্স বা অরাকল জাভা)।
  • যেকোনো আইডিই যেমন IntelliJ IDEA, Eclipse, বা VS Code with Java extensions।
  • জাভা কোর ধারণা—ক্লাস, অবজেক্ট, স্ট্যাটিক মেম্বার, গার্বেজ কালেকশন-এর বেসিক।
  • অপশনাল: VisualVM (JDK-এর সাথে আসা jvisualvm) অথবা Eclipse Memory Analyzer (MAT) হিপ ডাম্প দেখার জন্য।

আপনি যদি টুলস না ব্যবহার করতে চান, তবুও কনসোল আউটপুট এবং jmap কমান্ড দিয়ে লিক বোঝা যাবে। টিউটোরিয়াল জুড়ে সব কোড কপি-পেস্ট করে সরাসরি রান করা যাবে।

ধাপে ধাপে বাস্তবায়ন

ধাপ ১: একটি সাধারণ মেমরি লিক তৈরি

প্রথমে আমরা একটি স্ট্যাটিক ArrayList-এ অবজেক্ট জমা করে লিক তৈরি করবো। প্রতিটি অবজেক্ট কিছু পরিমাণ মেমরি নেবে, কিন্তু রেফারেন্স static হওয়ায় GC কখনোই সেটা পরিষ্কার করবে না।

import java.util.ArrayList;
import java.util.List;

public class LeakyCache {
    private static final List<byte[]> leakyContainer = new ArrayList<>();

    public void addData() {
        // প্রতি কলায় প্রায় ১ মেগাবাইট মেমরি যোগ হচ্ছে
        leakyContainer.add(new byte[1024 * 1024]);
    }

    public static void main(String[] args) throws InterruptedException {
        LeakyCache cache = new LeakyCache();
        while (true) {
            cache.addData();
            System.out.println("যোগ করা হয়েছে, বর্তমান সাইজ: " + leakyContainer.size());
            Thread.sleep(50); // ধীরগতিতে বাড়তে দিচ্ছি
        }
    }
}

প্রথমেই LeakyCache ক্লাসে একটি static List<byte[]> ডিক্লেয়ার করা হয়েছে। addData() মেথড প্রতিবার ১ মেগাবাইটের একটি বাইট অ্যারে যুক্ত করে। main-এ অসীম লুপের মাধ্যমে ক্রমাগত ডেটা জমা হচ্ছে এবং কখনোই মুছে ফেলা হচ্ছে না। কিছুক্ষণ রান করালেই OutOfMemoryError দেখা যাবে, কারণ স্ট্যাটিক লিস্ট সব রেফারেন্স ধরে রেখেছে।

ধাপ ২: লিক সনাক্তকরণ (প্রোফাইলিং টুল বা jmap)

লিক ধরতে আমরা JDK-এর বিল্ট-ইন jvisualvm অথবা jmap ব্যবহার করতে পারি। উপরের প্রোগ্রাম রান করিয়ে তারপরে নিচের ধাপগুলো অনুসরণ করুন:

  1. টার্মিনালে jps কমান্ড দিয়ে চলমান Java প্রসেসের পিআইডি (PID) বের করুন।
  2. jmap -histo:live <PID> | head -20 চালিয়ে লাইভ অবজেক্টের হিস্টোগ্রাম দেখুন। আপনি দেখবেন [B (byte array) টাইপের ইন্সট্যান্স অস্বাভাবিকভাবে বেড়ে চলেছে।
  3. হিপ ডাম্প নিতে jmap -dump:live,format=b,file=leak.hprof <PID> ব্যবহার করুন এবং Eclipse MAT দিয়ে .hprof ফাইল ওপেন করে “Leak Suspects” রিপোর্ট দেখুন—এটি সরাসরি leakyContainer-কে লিকের উৎস দেখাবে।

এই টুল-নির্ভর অংশ কোডের বাইরে, কিন্তু এগুলো আপনাকে প্রোডাকশন ডিবাগিং-এ দক্ষ করে তুলবে।

ধাপ ৩: লিক সমাধান – রেফারেন্স ক্লিনআপ

লিক এড়ানোর সহজ উপায় হলো ডেটা ব্যবহার শেষে রেফারেন্স সরিয়ে দেওয়া। স্ট্যাটিক ক্যাশের ক্ষেত্রে আমরা WeakHashMap ব্যবহার করতে পারি, যেখানে কী (key) যদি আর স্ট্রং রেফারেন্স না থাকে, তাহলে এন্ট্রি GC করে ফেলে।

import java.util.Map;
import java.util.WeakHashMap;

public class SafeCache {
    private static final Map<String, byte[]> cache = new WeakHashMap<>();

    public byte[] getData(String key) {
        return cache.get(key);
    }

    public void cacheData(String key) {
        // ক্যাশে কেবল তখনই যোগ হয় যদি রেফারেন্স অন্য কোথাও থাকে
        cache.put(new String(key), new byte[1024 * 1024]);
    }
}

এখানে WeakHashMap ব্যবহার করায় কী অবজেক্টের বাইরের কোনো স্ট্রং রেফারেন্স না থাকলে জিসি পুরো এন্ট্রি সরিয়ে ফেলবে। তাই ভলাটাইল ডেটা জমেও মেমরি ফুরাবে না। বাস্তব প্রোজেক্টে Caffeine বা Guava ক্যাশের মতো লাইব্রেরি ব্যবহার করা আরও নির্ভরযোগ্য, যেখানে আকার ও মেয়াদ নিয়ন্ত্রণ করা যায়।

সম্পূর্ণ উদাহরণ

নিচে একটি পূর্ণাঙ্গ জাভা প্রোগ্রাম দেওয়া হলো যা ইচ্ছাকৃতভাবে লিক তৈরি করে, তারপর একটি নির্দিষ্ট সময় পর পরিষ্কার করার ঠিক পদ্ধতি দেখায়। এটি main মেথডের মাধ্যমে প্রথমে লিক তৈরি করবে এবং পরে ব্যবহারকারী কন্ট্রোল চাপলে পরিষ্কার করবে—যাতে লিক ও সমাধানের তুলনা হয়।

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class MemoryLeakDemo {
    // লিকি কন্টেইনার – স্ট্যাটিক এবং কখনো খালি হয় না (প্রাথমিক অবস্থা)
    private static final List<byte[]> leakyList = new ArrayList<>();
    // অটো-ক্লিনিং কন্টেইনার – নির্দিষ্ট চেকের মাধ্যমে পুরনো ডেটা সরায়
    private static final List<byte[]> managedList = new ArrayList<>();
    private static final int MAX_MANAGED_SIZE = 20;

    public static void main(String[] args) throws InterruptedException {
        System.out.println("=== মেমরি লিক ডেমো শুরু ===");

        // ১. লিকি অংশ – প্রতি সেকেন্ডে বাইট অ্যারে যোগ
        Thread leakThread = new Thread(() -> {
            try {
                while (true) {
                    leakyList.add(new byte[512 * 1024]); // 0.5 MB
                    System.out.println("[লিক] বর্তমান আকার: " + leakyList.size() + " (~" + (leakyList.size() * 0.5) + " MB)");
                    Thread.sleep(1000);
                }
            } catch (OutOfMemoryError e) {
                System.out.println("[লিক] OutOfMemoryError! লিকি লিস্ট আর বাড়াতে পারেনি।");
            } catch (InterruptedException ignored) {}
        });

        // ২. পরিচালিত অংশ – পুরনো এন্ট্রি মুছে ফেলে
        Thread managedThread = new Thread(() -> {
            try {
                while (true) {
                    managedList.add(new byte[512 * 1024]);
                    // আকার সীমা ছাড়ালে প্রথম কয়েকটি এলিমেন্ট সরান
                    if (managedList.size() > MAX_MANAGED_SIZE) {
                        Iterator<byte[]> it = managedList.iterator();
                        int removed = 0;
                        while (it.hasNext() && removed < 5) {
                            it.next();
                            it.remove();
                            removed++;
                        }
                    }
                    System.out.println("[পরিচালিত] বর্তমান আকার: " + managedList.size());
                    Thread.sleep(1000);
                }
            } catch (OutOfMemoryError e) {
                System.out.println("[পরিচালিত] OutOfMemoryError! (সাধারণত হবে না)");
            } catch (InterruptedException ignored) {}
        });

        leakThread.start();
        managedThread.start();

        // ১৫ সেকেন্ড পর লিক থ্রেড বন্ধ (যদি ক্র্যাশ না হয়)
        Thread.sleep(15000);
        leakThread.interrupt();
        managedThread.interrupt();

        System.out.println("\n=== ডেমো শেষ – প্রোফাইলার দিয়ে পর্যবেক্ষণ করুন ===");
    }
}

এখানে দুটি সমান্তরাল থ্রেড কাজ করে: leakThread অবিরত ডেটা জমা করে এবং কখনো মুছে না, ফলে কয়েক সেকেন্ডেই হিপ ভরে যায় এবং OutOfMemoryError হয়। অন্যদিকে managedThread একটি তালিকায় যোগ করলেও যখন আকার ২০ ছাড়ায় তখন পুরনো এন্ট্রি ইটারেটর দিয়ে সরিয়ে দেয়, তাই মেমরি স্থিতিশীল থাকে। এটি লিক বনাম নিয়ন্ত্রিত মেমরি ব্যবহারের সরাসরি উদাহরণ।

সাধারণ সমস্যা ও সমাধান

মেমরি লিক প্রায়ই কিছু সাধারণ ভুলের কারণে হয়। নিচে সেসব সমস্যা ও সমাধান দেওয়া হলো:

  • স্ট্যাটিক কালেকশনে ভুল রেফারেন্স: static লিস্ট বা ম্যাপে অবজেক্ট জমিয়ে পরে আর মুছে ফেলা হয় না।
    সমাধান: WeakHashMap বা SoftReference ব্যবহার করুন; অথবা ScheduledExecutorService দিয়ে মেয়াদী ক্লিনআপ চালান। Guava-র CacheBuilder আকার ও সময়ভিত্তিক ইভিকশন দেয়।

  • শ্রোতা (Listener) নিবন্ধন বাতিল না করা: UI বা ইভেন্ট-চালিত অ্যাপে অ্যাক্টিভিটি/কম্পোনেন্ট ডেস্ট্রয়ের সময় লিসেনার সরাতে ভুলে গেলে বড় অবজেক্ট লিক হয়।
    সমাধান: registerListener-এর সাথে স্পষ্ট unregisterListener কল নিশ্চিত করুন; WeakReference দিয়ে লিসেনার ধরে রাখুন।

  • অভ্যন্তরীণ ক্লাসের অজান্তে রেফারেন্স: নন-স্ট্যাটিক ইনার ক্লাস তার বাইরের ক্লাসের ইন্সট্যান্স ধরে রাখে, যা লাইফটাইম বাড়িয়ে দেয়।
    সমাধান: ইনার ক্লাস static বানান; অথবা ল্যাম্বডা/মেথড রেফারেন্স ব্যবহার করুন।

  • থ্রেড-লোকাল অপব্যবহার: ThreadLocal ভ্যারিয়েবল যদি থ্রেডপুলে ব্যবহৃত হয় এবং থ্রেড শেষে remove() না করা হয়, তাহলে মান আটকে থাকে।
    সমাধান: try-finally ব্লকে ThreadLocal.remove() কল বাধ্যতামূলক করুন।

  • বন্ধ না করা রিসোর্স: JDBC কানেকশন, স্ট্রিম, ফাইল হ্যান্ডেল close না করলে জিসি দেরি করে, এবং নেটিভ মেমরি লিক হয়।
    সমাধান: ট্রাই-উইথ-রিসোর্স (try-with-resources) ব্যবহার করুন; Cleaner বা finalize এর ওপর নির্ভর করবেন না।

  • বড় স্ট্যাটিক ক্যাশ: অনেক ডেটা ইন-মেমরি ক্যাশ করলে সেটা স্থায়ী হয়ে যায়।
    সমাধান: Caffeine বা Ehcache দিয়ে সাইজ, TTL (time-to-live) নির্ধারণ করুন।

প্রতিটি সমাধানেই মুলমন্ত্র হল “যে রেফারেন্স প্রয়োজন নেই, তাকে শক্তিশালী রেফারেন্স হিসাবে রাখাও যাবে না”। প্রোফাইলিং টুল আর হিপ ডাম্প বিশ্লেষণ আপনার বেস্ট ফ্রেন্ড।

সারসংক্ষেপ

জাভার গার্বেজ কালেক্টর শক্তিশালী হলেও, ডেভেলপারের অসাবধানতায় মেমরি লিক এড়ানো অসম্ভব কিছু নয়। এই টিউটোরিয়ালে আমরা মেমরি লিকের ধারণা, এর ভয়াবহতা, স্ট্যাটিক কালেকশনের মাধ্যমে লিক তৈরি, এবং প্রোফাইলার দিয়ে সনাক্তকরণ শিখেছি। পূর্ণাঙ্গ উদাহরণে দেখানো হয়েছে কীভাবে একটি লিকি লিস্ট অ্যাপ্লিকেশনকে কয়েক সেকেন্ডে শেষ করে দেয়, আর একটি ছোট্ট ক্লিনআপ লজিক মেমরি স্থিতিশীল রাখে। সাধারণ ভুল ও তাদের সমাধান জেনে আপনি এখন নিজের কোডবেসকে লিক-মুক্ত রাখতে আরও দক্ষ। টর্চার টেস্ট হিসেবে নিজের প্রোজেক্টে jmap বা VisualVM চালিয়ে দেখুন—অনাকাঙ্ক্ষিত রেফারেন্স চিহ্নিত করে ঠিক করে ফেলুন।

Share