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

ভূমিকা

জাভার প্রতিটি অবজেক্টের সাথেই একটি লুকানো তালা জুড়ে থাকে, যাকে আমরা বলি ইন্ট্রিনসিক লক বা মনিটর। যখন কোনো থ্রেড synchronized কোডে প্রবেশ করে, তখন সেই থ্রেড অবজেক্টের তালাটি ধরে ফেলে এবং আর কোনো থ্রেড একই তালার সুরক্ষিত অংশে ঢুকতে পারে না। এই সহজ প্রকৌশলই জাভার কনকারেন্সির ভিত। কিন্তু ভুল জায়গায় synchronized ব্যবহারে ডেডলক এবং পারফরম্যান্স হ্রাসের ঝুঁকি থেকেই যায়। এখানে আমরা synchronized-এর পেছনের যুক্তি, এর সিনট্যাক্স, এবং প্র্যাকটিক্যাল দিক নিয়ে বিস্তারিত আলোচনা করব।

উদাহরণ দিয়ে বুঝতে পারা সহজ। ধরুন একটি ব্যাংক অ্যাকাউন্টের ব্যালেন্স আপডেট হচ্ছে একসাথে একাধিক থ্রেড থেকে। সুরক্ষা ছাড়া শেষ ব্যালেন্স অপ্রত্যাশিত হয়ে যায়। synchronized ঠিক এই জায়গায় এসে নিশ্চিত করে যে একবারে একটি থ্রেডই সেই সংবেদনশীল কোড চালাবে। নিচের ধাপে আমরা ধীরে ধীরে পুরো প্রক্রিয়াটি খুলে বলব।

পূর্বশর্ত

এই টিউটোরিয়াল বোঝার জন্য তোমার কিছু ধারণা আগে পরিষ্কার থাকা দরকার:

  • জাভা বেসিক: ক্লাস, অবজেক্ট, মেথড, এবং স্ট্যাটিক মেম্বার কীভাবে কাজ করে।
  • থ্রেড তৈরি ও চালানো: Thread ক্লাস বা Runnable ইন্টারফেস থেকে থ্রেড বানিয়ে start() করতে পারা।
  • রেস কন্ডিশনের ধারণা: একই ডেটা একসাথে মডিফাই করলে কেন ভুল হয়, সেটি অন্তত উপলব্ধি থাকা।
  • আইডিই বা টার্মিনাল: যে কোনো জাভা এনভায়রনমেন্ট (IntelliJ, Eclipse, বা কমান্ড লাইন) যেখানে উদাহরণ রান করানো যায়।

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

synchronized-এর ধাপে ধাপে প্রয়োগ

প্রথমে আমরা এমন একটি কাউন্টার তৈরি করব যেটি একসাথে একাধিক থ্রেড বৃদ্ধি করলে ভুল রেজাল্ট দেয়। তারপর আমরা সেই সমস্যাকে synchronized দিয়ে সারিয়ে তুলব।

১. অরক্ষিত কাউন্টার তৈরি

class UnsafeCounter {
    private int count = 0;

    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

এখানে increment() মেথড থ্রেড-সেফ নয়। count++ একটি যৌগিক কাজ—পড়া, বৃদ্ধি করা, এবং লেখা—তিনটি ধাপ একসাথে নেওয়ার সুযোগ না থাকায় একাধিক থ্রেড একই মান ঢুকিয়ে দিতে পারে।

২. রেস কন্ডিশন ডেমোনস্ট্রেট করা

public class RaceConditionDemo {
    public static void main(String[] args) throws InterruptedException {
        UnsafeCounter counter = new UnsafeCounter();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10_000; i++) counter.increment();
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10_000; i++) counter.increment();
        });
        t1.start(); t2.start();
        t1.join(); t2.join();
        System.out.println("চূড়ান্ত গণনা: " + counter.getCount());
    }
}

প্রত্যাশিত ফলাফল 20000 হলেও প্রায় প্রতিবারই কম বা ভিন্ন সংখ্যা প্রিন্ট হবে। কারণ দুই থ্রেড একই সময়ে count ভ্যারিয়েবলের উপর না-চাওয়া ইন্টারলিভিং তৈরি করছে।

৩. synchronized মেথড ব্যবহার করে সমাধান

class SafeCounter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }
}

increment()-কে synchronized ঘোষণা করার ফলে পুরো মেথডটি সেই অবজেক্টের (this) লক ধরে চলে। এখন একবারে কেবল একটি থ্রেডই increment() বা getCount() চালাতে পারবে। এটি সবচেয়ে সহজ ব্যবহার।

৪. synchronized ব্লক ব্যবহার

মেথড পুরোটা লক করার বদলে আমরা শুধু সংবেদনশীল অংশকে লক করতে পারি, বিশেষ করে যখন ক্লাসের নিজস্ব লক ব্যবহার করতে চাই বা অন্য কোনো অবজেক্টের লক ধার করতে চাই।

class BlockSafeCounter {
    private int count = 0;
    private final Object lock = new Object();

    public void increment() {
        synchronized(lock) {
            count++;
        }
    }

    public int getCount() {
        synchronized(lock) {
            return count;
        }
    }
}

এখানে আমরা একটি আলাদা lock অবজেক্ট তৈরি করেছি, যাতে this-এর চেয়ে সূক্ষ্ম নিয়ন্ত্রণ পাওয়া যায়। এই প্যাটার্ন জটিল ক্লাসে অনেক বেশি নমনীয়।

৫. স্ট্যাটিক synchronized (ক্লাস-লেভেল লক)

যখন পুরো ক্লাসের জন্য লক চাই, তখন স্ট্যাটিক মেথডে synchronized লাগানো হয়। এটি ক্লাসের Class অবজেক্টের লক অর্জন করে।

class GlobalCounter {
    private static int count = 0;

    public static synchronized void increment() {
        count++;
    }
}

স্ট্যাটিক synchronized মেথড এবং ইন্সট্যান্স synchronized মেথড ভিন্ন লক ধরে; ইন্সট্যান্স মেথড ধরে অবজেক্টের লক, আর স্ট্যাটিক ধরে GlobalCounter.class-এর লক। ফলে দুটি আলাদা থ্রেড একেবারে সমান্তরালে চলতে পারে যদি একটি ইন্সট্যান্স মেথড আরেকটি স্ট্যাটিক মেথডে কাজ করে।

উপরের ধাপগুলো আয়ত্ত করলেই synchronized-এর কোর প্র্যাকটিস পরিষ্কার হয়ে যায়। এখন আমরা পুরো বিষয়টিকে একটি বাস্তবসম্মত ব্যাংক অ্যাকাউন্ট উদাহরণের মাধ্যমে দেখব।

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

নিচের প্রোগ্রামটি একটি BankAccount ক্লাস তৈরি করে, যেখানে depositwithdraw মেথড synchronized। তারপর একই অ্যাকাউন্টে একাধিক থ্রেড লেনদেন করে এবং আমরা চূড়ান্ত ব্যালেন্স যাচাই করি।

class BankAccount {
    private int balance;

    public BankAccount(int initialBalance) {
        this.balance = initialBalance;
    }

    public synchronized void deposit(int amount) {
        balance += amount;
    }

    public synchronized void withdraw(int amount) {
        if (balance >= amount) {
            balance -= amount;
        } else {
            System.out.println(Thread.currentThread().getName() + " এর অপর্যাপ্ত টাকা।");
        }
    }

    public synchronized int getBalance() {
        return balance;
    }
}

public class BankTransactionDemo {
    public static void main(String[] args) throws InterruptedException {
        BankAccount account = new BankAccount(1000);
        Runnable depositTask = () -> {
            for (int i = 0; i < 100; i++) account.deposit(10);
        };
        Runnable withdrawTask = () -> {
            for (int i = 0; i < 100; i++) account.withdraw(5);
        };

        Thread t1 = new Thread(depositTask, "ডিপোজিট-থ্রেড");
        Thread t2 = new Thread(withdrawTask, "উইথড্র-থ্রেড");
        Thread t3 = new Thread(depositTask, "আরেক-ডিপোজিট");

        t1.start(); t2.start(); t3.start();
        t1.join(); t2.join(); t3.join();

        System.out.println("চূড়ান্ত ব্যালেন্স: " + account.getBalance());
    }
}

উপরের প্রোগ্রামে আমরা একাউন্টকে synchronized মেথড দিয়ে সুরক্ষিত করেছি, তাই একই সময়ে একাধিক থ্রেড deposit বা withdraw কল করলে ভুল আপডেট এড়ানো যাবে। প্রতিটি থ্রেডের লেনদেন শেষে কাঙ্ক্ষিত ফল পাওয়া যায়। বাস্তব অ্যাপ্লিকেশনে আমরা এভাবেই থ্রেড-সেফ ব্যাংকিং অপারেশন লিখি।

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

Synchronized ব্যবহারে নিচের ভুলগুলো প্রায়ই দেখা যায়:

  • মেথডের উপর অতিরিক্ত synchronized লাগানো: পুরো মেথড লক করলে প্রয়োজন ছাড়াই পারফরম্যান্স কমতে পারে। পরিবর্তে শুধু ক্রিটিক্যাল সেকশনে synchronized ব্লক ব্যবহার করো।
  • কলিং ক্রমে ডেডলক: একটি থ্রেড যেখানে A লক নিয়ে B-এর অপেক্ষা করে, অন্যটি B নিয়ে A-এর অপেক্ষা করলে ডেডলক হয়। সবসময় একই অর্ডারে লক নেওয়ার নীতি মেনে চলো।
  • স্ট্রিং অবজেক্ট বা কনস্ট্যান্টকে লক হিসেবে ব্যবহার: স্ট্রিং পুলের কারণে একই স্ট্রিং লিটারেলে ভিন্ন ভিন্ন অংশ অপ্রত্যাশিতভাবে একই লক শেয়ার করতে পারে। সবসময় private final Object lock = new Object()-এর মতো আলাদা লক ব্যবহার করো।
  • রিড-অনলি অপারেশনেও synchronized দেওয়া: যদি কোনো মেথড শুধু ফাইনাল ফিল্ড পড়ে বা ইমিউটেবল ডেটা ফেরত দেয়, তাহলে synchronized না লাগানোই উচিত।
  • কনকারেন্ট কালেকশন ব্যবহার না করা: Hashtable বা Collections.synchronizedList()-এর মতো পুরনো টুলের বদলে ConcurrentHashMap, CopyOnWriteArrayList ব্যবহারে স্কেলেবিলিটি ভালো হয়।

এছাড়াও মনে রাখতে হবে synchronized শুধুমাত্র একই জেভিএম-এ কাজ করে। ডিস্ট্রিবিউটেড সিস্টেমে ডাটাবেজ লক বা ডিস্ট্রিবিউটেড লকের দরকার হবে।

সারসংক্ষেপ

  • জাভা synchronized কিওয়ার্ড ইন্ট্রিনসিক লক ব্যবহার করে একবারে একটি মাত্র থ্রেডকে কোড ব্লকে প্রবেশ করতে দেয়।
  • synchronized মেথড this লক করে, আর synchronized ব্লক নির্দিষ্ট অবজেক্ট লক করে। স্ট্যাটিক মেথড ক্লাস অবজেক্ট (ClassName.class) লক করে।
  • সাধারণ মিউটেবল স্টেট অ্যাকসেসকে নিরাপদ করতে synchronized অত্যন্ত কার্যকর, তবে অতি ব্যবহার পারফরম্যান্স কমাতে পারে।
  • ডেডলক এড়াতে লকের ক্রম ঠিক রাখা এবং অপ্রয়োজনীয় লকিং বাদ দেওয়া জরুরি।
  • কনকারেন্ট ইউটিলিটি (যেমন java.util.concurrent প্যাকেজ) অনেক সময় synchronized-এর চেয়ে উন্নত সমাধান হতে পারে, তাই বাস্তব প্রকল্পে এই টুলটিও বিবেচনায় রেখো।

এই জ্ঞান নিয়ে তুমি মাল্টিথ্রেডেড জাভা কোডে আত্মবিশ্বাসের সাথে সিঙ্ক্রোনাইজ করতে পারবে। প্র্যাকটিস করতে থাকো, আর কোনো বিভ্রান্তি এলে এই টিউটোরিয়ালে ফিরে এসো।

Share