আপনার মাল্টিথ্রেডেড অ্যাপ্লিকেশনে HashMap ব্যবহার করলে ডেটা করাপশন বা ConcurrentModificationException-এর মতো সমস্যা দেখা দেওয়া খুবই সাধারণ। ConcurrentHashMap জাভার java.util.concurrent প্যাকেজের একটি শক্তিশালী কঙ্কারেন্ট ম্যাপ, যা একাধিক থ্রেড থেকে নিরাপদে পড়া ও লেখার সুযোগ দেয় এবং প্রথাগত Hashtable বা সিঙ্ক্রোনাইজড HashMap-এর তুলনায় অনেক বেশি স্কেলেবল। এই টিউটোরিয়ালে আমরা ConcurrentHashMap-এর পেছনের ধারণা, এর সাধারণ ব্যবহার, ধাপে ধাপে বাস্তবায়ন, একটি পূর্ণাঙ্গ উদাহরণ এবং প্রায়ই যে ভুলগুলো হয় সেগুলোর সমাধান বিস্তারিতভাবে জানব। লেখাটি শেষে আপনি মাল্টিথ্রেডেড পরিবেশে কঙ্কারেন্ট ম্যাপ ঠিকভাবে প্রয়োগ করতে পারবেন।

ভূমিকা

জাভার HashMap অসাধারণ দ্রুতগতির, কিন্তু এটি থ্রেড-সেফ নয়। একাধিক থ্রেড একই সময়ে HashMap-এ মান যোগ করলে বা মুছলে অভ্যন্তরীণ ডেটা স্ট্রাকচার নষ্ট হয়ে যেতে পারে এবং অপ্রত্যাশিত ফলাফল আসতে পারে। Hashtable পুরো ম্যাপকে লক করে থ্রেড-সেফটি নিশ্চিত করে, কিন্তু এতে কঙ্কারেন্সি পুরোপুরি নষ্ট হয় — একটি থ্রেড লিখলেই অন্য সব থ্রেড অপেক্ষা করে। Collections.synchronizedMap() দিয়েও একই সমস্যা: প্রতিটি অপারেশন সম্পূর্ণ ম্যাপকে সিঙ্ক্রোনাইজ করে।

ConcurrentHashMap জাভা ৫ থেকে উপলব্ধ এবং জাভা ৮-তে আরও উন্নত হয়েছে। এটি অভ্যন্তরীণভাবে ম্যাপকে কয়েকটি বাকেট বা সেগমেন্টে ভাগ করে, যাতে একসঙ্গে একাধিক থ্রেড বিভিন্ন সেগমেন্টে নিরাপদে কাজ করতে পারে। সম্পূর্ণ ম্যাপ লক না করে শুধুমাত্র নির্দিষ্ট অংশ লক করায় থ্রেডগুলোর মধ্যে প্রতিযোগিতা (contention) অনেক কমে যায়। এছাড়া putIfAbsent, compute, forEach-এর মতো অ্যাটমিক মেথডগুলোতে এটি lock-free বা lock-striping কৌশল ব্যবহার করে, যা একে উচ্চমানের কঙ্কারেন্ট অ্যাপ্লিকেশনের জন্য আদর্শ করে তোলে।

এই টিউটোরিয়ালে আমরা ধরে নিচ্ছি আপনার জাভা কঙ্কারেন্সির মৌলিক ধারণা (থ্রেড, রানএবল) এবং সাধারণ কালেকশন এপিআই সম্পর্কে ধারণা আছে। জাভা ১১ বা তার পরবর্তী ভার্সন ব্যবহার করলেই ConcurrentHashMap-এর সর্বশেষ সুবিধাগুলো পাবেন, তবে জাভা ৮-তেও প্রধান ফিচারগুলো ঠিকঠাক চলবে।

পূর্বশর্ত

নিচে দেওয়া প্রয়োজনীয় জিনিসগুলো নিশ্চিত করে নিন:

  • জাভা ডেভেলপমেন্ট কিট (JDK) ৮ বা উচ্চতর ইন্সটল করা। টার্মিনালে java -version দিয়ে চেক করুন।
  • পছন্দের একটি IDE (IntelliJ IDEA, Eclipse, বা VS Code) অথবা সাধারণ টেক্সট এডিটর ও কমান্ড লাইন।
  • মৌলিক কোর জাভা জ্ঞান: ক্লাস, মেথড, থ্রেড তৈরি ও চালানো (Thread ক্লাস বা ExecutorService)।
  • জাভা কালেকশন ফ্রেমওয়ার্ক সম্পর্কে পরিচিতি — Map, HashMap, Hashtable-এর সাধারণ ব্যবহার জানা থাকলে সুবিধা হবে।

আপনার প্রোজেক্টে আলাদা কোনো ডিপেন্ডেন্সি লাগবে না; ConcurrentHashMap জাভা স্ট্যান্ডার্ড লাইব্রেরির অংশ।

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

১. অবজেক্ট তৈরি ও সাধারণ অপারেশন

প্রথমেই ConcurrentHashMap-এর একটি ইন্সট্যান্স তৈরি করে সাধারণ তথ্য রাখা ও পড়া শিখুন। কোডের মাধ্যমে আমরা একটি ম্যাপ তৈরি করব যেখানে স্ট্রিং-কি এবং পূর্ণসংখ্যা-ভ্যালু থাকবে।

import java.util.concurrent.ConcurrentHashMap;

public class BasicOps {
    public static void main(String[] args) {
        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

        // মান বসানো
        map.put("Apple", 10);
        map.put("Banana", 20);
        map.put("Cherry", 30);

        // মান পড়া
        System.out.println("Apple-এর পরিমাণ: " + map.get("Apple"));
        System.out.println("Banana-এর পরিমাণ: " + map.get("Banana"));

        // একটি কি-ভ্যালু জোড়া শুধু অনুপস্থিত থাকলে যুক্ত করা
        map.putIfAbsent("Date", 40);
        map.putIfAbsent("Apple", 100);  // Apple আগে থেকেই আছে, যোগ হবে না

        System.out.println("পুরো ম্যাপ: " + map);
    }
}

এই উদাহরণে আমরা সাধারণ putget অপারেশন দেখেছি। putIfAbsent একটি অ্যাটমিক মেথড, যা চেক করে দেখে নির্দিষ্ট কি-টি ম্যাপে না থাকলেই কেবল নতুন ভ্যালু যোগ করে। মাল্টিথ্রেডেড পরিবেশে এটি অতিরিক্ত সিঙ্ক্রোনাইজেশন ছাড়াই if(!map.containsKey(key)) map.put(key, value);-এর সমতুল্য কাজ নিরাপদে সম্পন্ন করে।

২. অ্যাটমিক কম্পাউন্ড অপারেশন

বাস্তবে অনেক সময় আমাদের ভ্যালু আপডেট করতে পূর্বের ভ্যালুর ওপর নির্ভর করতে হয়, যেমন একটি কাউন্টার বাড়ানো। ConcurrentHashMap-এর অ্যাটমিক মেথডগুলো ছাড়া এটি থ্রেড-সেফ হবে না। নিচের কোড compute মেথড ব্যবহার করে একটি কী-র ভ্যালু নিরাপদে বাড়ায়।

ConcurrentHashMap<String, Integer> scores = new ConcurrentHashMap<>();
scores.put("Player1", 100);

// থ্রেড-সেফভাবে স্কোর 50 বাড়ানো
scores.compute("Player1", (key, oldValue) -> oldValue + 50);

// কি না থাকলে নতুন ভ্যালু সেট করা, থাকলে কিছুই নয়
scores.computeIfAbsent("Player2", key -> 200);

System.out.println("Player1: " + scores.get("Player1")); // 150
System.out.println("Player2: " + scores.get("Player2")); // 200

এখানে compute মেথডটি ল্যাম্বডা এক্সপ্রেশন নেয় এবং পুরো প্রক্রিয়াটি অ্যাটমিক — অর্থাৎ ভ্যালু পড়া ও আপডেটের মধ্যে অন্য কোন থ্রেড হস্তক্ষেপ করতে পারে না। computeIfAbsent একইভাবে শুধুমাত্র অনুপস্থিত কী-র জন্য একটি নতুন ভ্যালু গণনা করে যোগ করে। এই মেথডগুলো ম্যানুয়াল get-চেক-put প্যাটার্নের চেয়ে অনেক নিরাপদ ও কর্মক্ষম।

৩. বাল্ক ও ইটারেশন অপারেশন

বড় ম্যাপে সব এন্ট্রির ওপর কোন কাজ করতে forEach মেথড ব্যবহার করা হয়। এটি ইটারেশনের সময় ConcurrentModificationException ছোড়ে না এবং ম্যাপের একটা মোমেন্ট স্ন্যাপশট নিয়ে চলে।

ConcurrentHashMap<String, Integer> inventory = new ConcurrentHashMap<>();
inventory.put("Pen", 50);
inventory.put("Notebook", 30);
inventory.put("Eraser", 20);

// ১০-এর বেশি পরিমাণ থাকা আইটেম প্রিন্ট
inventory.forEach(1, (key, value) -> {
    if (value > 10) {
        System.out.println(key + " -> " + value);
    }
});

forEach-এর প্রথম আর্গুমেন্ট parallelismThreshold নির্দেশ করে কতগুলো এন্ট্রির পর প্যারালাল থ্রেড ব্যবহার করা হবে। এখানে 1 দেওয়ায় এটি ইচ্ছা করলে একাধিক থ্রেডে কাজ ভাগ করে নিতে পারে (যদিও ছোট ম্যাপে খুব একটা প্রভাব পড়বে না)। ইটারেশন চলাকালে ম্যাপে নতুন এন্ট্রি যুক্ত হলে তা ইটারেটরে প্রতিফলিত নাও হতে পারে, কিন্তু কোনো ব্যতিক্রম ঘটবে না — এটি ConcurrentHashMap-এর একটি গুরুত্বপূর্ণ সুবিধা।

৪. উন্নত অ্যাটমিক অপারেশন: merge

যখন কী ভিত্তিক ভ্যালুর সাথে কিছু সংযুক্ত করতে হয় (যেমন স্ট্রিং জোড়া বা সংখ্যা যোগ), তখন merge অত্যন্ত কাজের। এটি নির্দিষ্ট কী থাকলে রিম্যাপিং ফাংশন চালায়, না থাকলে ডিফল্ট ভ্যালু বসায়, এবং এই পুরো প্রক্রিয়াটি অ্যাটমিক।

ConcurrentHashMap<String, String> logs = new ConcurrentHashMap<>();
logs.merge("errors", "FileNotFound", (oldVal, newVal) -> oldVal + ", " + newVal);
logs.merge("errors", "NullPointer", (oldVal, newVal) -> oldVal + ", " + newVal);
System.out.println(logs.get("errors")); // FileNotFound, NullPointer

এই কোডে প্রথম merge-এ errors কী ছিল না, তাই FileNotFound সরাসরি বসে গেছে। দ্বিতীয় কলের সময় কী বিদ্যমান ছিল, তাই রিম্যাপার পুরনো ভ্যালুর সাথে নতুন ভ্যালু জুড়ে দিয়েছে। একাধিক থ্রেড একই কী-তে merge কল করলেও ডেটা কখনো হারিয়ে যাবে না বা ওভাররাইট হবে না।

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

এবার আমরা একটি পূর্ণাঙ্গ রানযোগ্য প্রোগ্রাম লিখব, যা বাস্তব মাল্টিথ্রেডেড পরিস্থিতি অনুকরণ করে। প্রোগ্রামটি পাঁচটি থ্রেড ব্যবহার করে একটি ConcurrentHashMap-এ সমকালীনভাবে পণ্যের স্টক আপডেট করবে এবং শেষে চূড়ান্ত স্টক দেখাবে।

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class InventoryManager {
    private final ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>();

    // স্টকে পণ্য যোগ করা (বাড়ানো)
    public void addStock(String product, int quantity) {
        stock.merge(product, quantity, Integer::sum);
    }

    // স্টক থেকে পণ্য তোলা (কমানো)
    public void removeStock(String product, int quantity) {
        stock.computeIfPresent(product, (key, existing) -> {
            int updated = existing - quantity;
            return updated < 0 ? 0 : updated; // নেগেটিভ হতে দিচ্ছি না
        });
    }

    public void printStock() {
        stock.forEach((product, qty) -> System.out.println(product + ": " + qty));
    }

    public static void main(String[] args) throws InterruptedException {
        InventoryManager manager = new InventoryManager();
        ExecutorService executor = Executors.newFixedThreadPool(5);

        // থ্রেড 1 ও 2: আপেলের স্টকে যোগ করছে
        executor.submit(() -> {
            for (int i = 0; i < 100; i++) {
                manager.addStock("Apple", 1);
            }
        });
        executor.submit(() -> {
            for (int i = 0; i < 100; i++) {
                manager.addStock("Apple", 1);
            }
        });

        // থ্রেড 3: কলার স্টক যোগ ও কিছু তোলা
        executor.submit(() -> {
            for (int i = 0; i < 50; i++) {
                manager.addStock("Banana", 2);
            }
            for (int i = 0; i < 20; i++) {
                manager.removeStock("Banana", 3);
            }
        });

        // থ্রেড 4: আপেলের স্টক কমানো
        executor.submit(() -> {
            for (int i = 0; i < 150; i++) {
                manager.removeStock("Apple", 1);
            }
        });

        // থ্রেড 5: নতুন পণ্য আম যোগ করা
        executor.submit(() -> {
            for (int i = 0; i < 30; i++) {
                manager.addStock("Mango", 5);
            }
        });

        executor.shutdown();
        executor.awaitTermination(5, TimeUnit.SECONDS);
        System.out.println("চূড়ান্ত স্টক:");
        manager.printStock();
    }
}

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

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

ConcurrentHashMap ব্যবহারকারী ডেভেলপারদের কিছু সাধারণ সমস্যা ও তাদের সমাধান নিচে তালিকাবদ্ধ করা হলো:

  • null কী বা ভ্যালু অনুমোদিত নয়: ConcurrentHashMap-এ null কী এবং null ভ্যালু রাখা যায় না। map.put(null, value) বা map.put(key, null) কল করলে NullPointerException ছুড়বে। সমাধান: ডেটা প্রবেশের আগে null চেক করে নিন অথবা প্রয়োজনে একটি বিশেষ সেন্টিনেল মান (যেমন Optional বা কাস্টম ডিফল্ট) ব্যবহার করুন।
  • size() মেথড আনুমানিক মান দিতে পারে: ম্যাপের সাইযটি কস্টলি অপারেশন, তাই এটি int ফেরত দেয় ঠিকই, কিন্তু উচ্চ কঙ্কারেন্সিতে এটি সম্পূর্ণ নির্ভুল নাও হতে পারে। আপনার অ্যাপ্লিকেশনে নিখুঁত সাইজ চাইলে mappingCount() মেথড ব্যবহার করুন, যা long রিটার্ন করে এবং তাত্ত্বিকভাবে আরও নির্ভুল।
  • ইটারেশন ও আপডেট একসাথে করা: forEach বা ইটারেটর ব্যবহারের সময় ম্যাপে আপডেট করলে ইটারেশন ফলাফলে সেটি নাও আসতে পারে। এটি ত্রুটি নয়, বরং ডিজাইন করা ডিল। সম্পূর্ণরূপে নির্দিষ্ট স্ন্যাপশট চাইলে প্রথমে entrySet().toArray() বা অন্য কপি তৈরি করে নিন।
  • কম্পাউন্ড অপারেশনে সরাসরি getput ব্যবহার: if(map.get(key) == null) map.put(key, value); প্যাটার্নটি থ্রেড-সেফ নয়। অ্যাটমিক মেথড যেমন putIfAbsent, compute, merge সব সময় ব্যবহার করুন।
  • পারফরম্যান্স টিউনিং: প্রাথমিক ক্যাপাসিটি ও লোড ফ্যাক্টর ConcurrentHashMap-এর কন্সট্রাক্টরে যথাযথভাবে সেট করুন। অতিরিক্ত ছোট ক্যাপাসিটি দিলে রিসাইজিং ঘটতে পারে, যা কঙ্কারেন্ট পরিবেশে পারফরম্যান্স কমায়। আনুমানিক এন্ট্রির সংখ্যা জানলে new ConcurrentHashMap<>(expectedSize) ব্যবহার করাই উত্তম।

সারসংক্ষেপ

এই টিউটোরিয়ালে আমরা জাভার ConcurrentHashMap-এর প্রয়োজনীয়তা, অভ্যন্তরীণ নকশা এবং ব্যবহারিক প্রয়োগ বিস্তারিতভাবে শিখেছি। আমরা দেখেছি কীভাবে এটি HashMap-এর থ্রেড-সেফটি সমস্যা ও Hashtable-এর পারফরম্যান্স সীমাবদ্ধতা দূর করে। putIfAbsent, compute, merge, forEach ইত্যাদি অ্যাটমিক মেথড ব্যবহার করে আমরা জটিল মাল্টিথ্রেডেড লজিক খুব সহজে বাস্তবায়ন করতে পারি।

একটি পূর্ণাঙ্গ মাল্টিথ্রেডেড স্টক ম্যানেজার উদাহরণের মাধ্যমে আমরা বাস্তব জগতের একটি সাধারণ সমস্যার সমাধান দেখিয়েছি, যেখানে কোনো ম্যানুয়াল সিঙ্ক্রোনাইজেশন ছাড়াই ডেটা ইন্টেগ্রিটি বজায় থেকেছে। সবশেষে, সাধারণ ভুলগুলো চিহ্নিত করে সেগুলোর সমাধান দিয়েছি, যাতে প্রোডাকশন কোডে প্রায়োগিক সমস্যায় না পড়তে হয়।

এখন আপনি নিশ্চয়ই বুঝতে পেরেছেন যে কঙ্কারেন্ট পরিবেশে ConcurrentHashMap আপনার প্রথম পছন্দ হওয়া উচিত। এর ওপর ভিত্তি করে আপনি আরও জটিল কঙ্কারেন্ট ডেটা স্ট্রাকচার তৈরি করতে পারেন এবং জাভা কঙ্কারেন্সির গভীরে প্রবেশ করতে পারেন।

Share