জাভাতে একাধিক থ্রেড যখন একই ভেরিয়েবল নিয়ে কাজ করে, তখন প্রতিটি থ্রেড নিজস্ব স্থানীয় ক্যাশে ভেরিয়েবলের কপি সংরক্ষণ করতে পারে। এর ফলে এক থ্রেডের করা পরিবর্তন অন্য থ্রেড তাৎক্ষণিকভাবে দেখতে পায় না, যা ভুল ফলাফল বা অপ্রত্যাশিত লুপের কারণ হয়। volatile কীওয়ার্ড এই দৃশ্যমানতা (visibility) সমস্যার একটি হালকা ও কার্যকর সমাধান। এটি জাভা মেমোরি মডেলের একটি গুরুত্বপূর্ণ অংশ, যা নিশ্চিত করে কোনো volatile ভেরিয়েবলে লেখা হলে তা সরাসরি মেইন মেমোরিতে যায় এবং পড়ার সময় সর্বদা মেইন মেমোরি থেকে পড়া হয়। এই টিউটোরিয়ালে আমরা volatile-এর প্রয়োজনীয়তা, অভ্যন্তরীণ কার্যপ্রণালী, এবং synchronized থেকে এর পার্থক্য হাতে-কলমে শিখবো।

জাভা মেমোরি মডেল ও volatile-এর ভূমিকা

জাভা মেমোরি মডেল (JMM) নির্ধারণ করে থ্রেড ও মেইন মেমোরির মধ্যে ডেটা কীভাবে আদান-প্রদান হবে। কর্মক্ষমতা বাড়াতে JVM প্রতিটি থ্রেডকে ভেরিয়েবলের একটি লোকাল কপি রাখার অনুমতি দেয়। এতে একটি থ্রেড while(!flag)-এর মতো লুপে আটকে থাকতে পারে, যদি flag-টি অন্য থ্রেড true করে দিলেও লোকাল ক্যাশে পুরোনো মান পড়তে থাকে। এই দৃশ্যমানতা সমস্যার সমাধানই volatile। এটি ঘোষণা করলে জাভা কম্পাইলার ও JVM-কে জানানো হয়: "এই ভেরিয়েবলটি একাধিক থ্রেড থেকে অ্যাক্সেস হবে, কখনো ক্যাশে রেখো না।"

volatile শুধু দৃশ্যমানতাই নিশ্চিত করে না, এটি একটি happens-before সম্পর্কও তৈরি করে। একটি থ্রেডে volatile ভেরিয়েবলে লেখা পরবর্তী কোনো পড়ার আগে অবশ্যই ঘটবে, এবং সেই লেখার আগের সব কোডের প্রভাব পরবর্তী থ্রেডের কাছে দৃশ্যমান হবে। এটি একপ্রকার হালকা সিঙ্ক্রোনাইজেশন, কিন্তু লক নয়। নিচের বিষয়গুলো খেয়াল রাখা জরুরি:

  • volatile শুধুমাত্র রেফারেন্স বা প্রিমিটিভ টাইপের জন্য প্রযোজ্য।
  • volatile কোনো যৌগিক (compound) অপারেশন যেমন i++ কে অ্যাটমিক বানায় না।
  • এটি থ্রেড-লোকাল ক্যাশিং প্রতিরোধ করে এবং রিড/রাইট বাধ্যতামূলকভাবে মেইন মেমোরিতে করে।
  • final ভেরিয়েবলের সাথে volatile ব্যবহার করা অর্থহীন, কারণ final ভেরিয়েবল একবার অ্যাসাইন করার পর অপরিবর্তনীয়।

পূর্বশর্ত

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

  • জাভার মৌলিক সিনট্যাক্স ও ক্লাস তৈরির ক্ষমতা।
  • থ্রেড কী এবং java.lang.Thread ক্লাস ব্যবহার করে কিভাবে নতুন থ্রেড চালু করতে হয়, সে সম্পর্কে পরিচিতি।
  • রেস কন্ডিশন বা প্রতিযোগিতামূলক অবস্থা আসলে কী, তার একটি প্রাথমিক ধারণা।
  • আপনার মেশিনে যেকোনো Java Development Kit (JDK ৮ বা তার উপরের সংস্করণ) এবং একটি কোড এডিটর অথবা IDE ইনস্টল করা থাকতে হবে।
  • কমান্ড লাইন থেকে javacjava কমান্ড চালানোর প্রাথমিক জ্ঞান থাকলে টিউটোরিয়ালটি অনুসরণ করা সহজ হবে।

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

এখন আমরা volatile-এর ব্যবহারিক প্রয়োগ ধাপে ধাপে দেখবো। প্রথমে একটি সাধারণ প্রোগ্রাম লিখবো যেখানে volatile ব্যবহার করা হয়নি, ফলে দৃশ্যমানতা সমস্যা দেখা দেয়। তারপর আমরা সামান্য পরিবর্তন এনে volatile-এর জাদু কাজ করতে দেখবো।

ধাপ ১: volatile ছাড়া একটি ভুল প্রোগ্রাম

প্রথমে আমরা একটি শেয়ার্ড বুলিয়ান ফ্ল্যাগ তৈরি করি, যা একটি থ্রেড false থেকে true করবে এবং আরেকটি থ্রেড সেই পরিবর্তনের জন্য অপেক্ষা করবে।

public class VisibilityProblem {
    // volatile ব্যবহার করা হয়নি!
    private boolean flag = false;

    public void writer() {
        System.out.println("লেখক থ্রেড: ফ্ল্যাগ পরিবর্তনের আগে কাজ করছে...");
        flag = true;
        System.out.println("লেখক থ্রেড: ফ্ল্যাগ true করে দিয়েছে।");
    }

    public void reader() {
        System.out.println("পাঠক থ্রেড: ফ্ল্যাগ true-এর অপেক্ষায়...");
        while (!flag) {
            // স্পিন লুপ - এখানে flag-এর লোকাল কপি পড়তে পারে।
        }
        System.out.println("পাঠক থ্রেড: ফ্ল্যাগ true হয়েছে! মূল কাজ শুরু।");
    }

    public static void main(String[] args) throws InterruptedException {
        VisibilityProblem demo = new VisibilityProblem();
        Thread t1 = new Thread(demo::reader);
        Thread t2 = new Thread(demo::writer);

        t1.start();
        Thread.sleep(100); // লেখক থ্রেড শুরু হওয়ার আগে একটু সময় দেওয়া
        t2.start();

        t1.join();
        t2.join();
    }
}

উপরের কোডে reader থ্রেড while (!flag) লুপের ভিতরে আটকে থাকতে পারে, এমনকি writer থ্রেড flag = true করার পরেও। এর কারণ JVM পারফরম্যান্সের জন্য flag-এর মান reader-এর লোকাল ক্যাশে সংরক্ষণ করে রেখেছে, মেইন মেমোরিতে পরিবর্তন দেখছে না। এতে প্রোগ্রামটি কখনই শেষ নাও হতে পারে।

ধাপ ২: volatile ব্যবহার করে সমস্যার সমাধান

উপরের কোডটির একটিমাত্র পরিবর্তনেই আমরা সমস্যা সমাধান করবো — শুধু flag ভেরিয়েবলটিকে volatile হিসেবে ঘোষণা করব।

public class VolatileSolution {
    // volatile সংযোজন!
    private volatile boolean flag = false;

    // বাকি কোড সম্পূর্ণ অপরিবর্তিত
    public void writer() {
        System.out.println("লেখক থ্রেড: ফ্ল্যাগ পরিবর্তনের আগে কাজ করছে...");
        flag = true;
        System.out.println("লেখক থ্রেড: ফ্ল্যাগ true করে দিয়েছে।");
    }

    public void reader() {
        System.out.println("পাঠক থ্রেড: ফ্ল্যাগ true-এর অপেক্ষায়...");
        while (!flag) {
            // flag সরাসরি মেইন মেমোরি থেকে পড়া হবে।
        }
        System.out.println("পাঠক থ্রেড: ফ্ল্যাগ true হয়েছে! মূল কাজ শুরু।");
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileSolution demo = new VolatileSolution();
        Thread t1 = new Thread(demo::reader);
        Thread t2 = new Thread(demo::writer);

        t1.start();
        Thread.sleep(100);
        t2.start();

        t1.join();
        t2.join();
    }
}

এখন flag-কে volatile ঘোষণা করায়, reader থ্রেড প্রতিবার লুপের শর্ত পরীক্ষার সময় মেইন মেমোরি থেকে flag-এর সর্বশেষ মান পড়তে বাধ্য হয়। writer থ্রেড flag = true করার সাথে সাথেই সেই মান মেইন মেমোরিতে লেখা হয়। ফলে reader থ্রেড তাৎক্ষণিক পরিবর্তন দেখতে পায় এবং লুপ থেকে বেরিয়ে এসে "মূল কাজ শুরু" বার্তাটি প্রিন্ট করে।

ধাপ ৩: volatile এবং অ্যাটমিসিটি — একটি সতর্কতা

volatile ভেরিয়েবলের উপর count++-এর মতো অপারেশন নিরাপদ নয়। এটি রিড-মডিফাই-রাইট ধাপের একটি যৌগিক কাজ, যা দুটি থ্রেড একই সাথে করলে একটি আপডেট হারিয়ে যেতে পারে। নিচের উদাহরণে আমরা একটি কাউন্টার ব্যবহার করছি যা volatile হওয়া সত্ত্বেও ভুল ফলাফল দেবে।

public class VolatileNonAtomic {
    private volatile int counter = 0;

    public void increment() {
        // এটি অ্যাটমিক নয়: counter = counter + 1
        counter++;
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileNonAtomic demo = new VolatileNonAtomic();
        Thread[] threads = new Thread[1000];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    demo.increment();
                }
            });
            threads[i].start();
        }
        for (Thread t : threads) t.join();
        // প্রত্যাশিত 10,00,000 কিন্তু প্রায় সব সময় কম হবে।
        System.out.println("কাউন্টারের সর্বশেষ মান: " + demo.counter);
    }
}

এখানে counter ভেরিয়েবলটি volatile হলেও, একাধিক থ্রেড একই সময়ে counter++ পড়া-বাড়ানো-লেখার ধাপগুলো একসাথে করতে পারে। তাই কিছু আপডেট নিখোঁজ হয়। এর সমাধান হিসেবে synchronized ব্লক অথবা AtomicInteger ক্লাস ব্যবহার করা উচিত। volatile কেবল তখনই উপযুক্ত যখন একাধিক থ্রেড থেকে একটি ভেরিয়েবলে শুধুমাত্র সরাসরি ভ্যালু অ্যাসাইনমেন্ট হয়, এবং জটিল স্টেট-ভিত্তিক পরিবর্তন নয়।

সম্পূর্ণ উদাহরণ: প্রডিউসার-কনজিউমার সিগনালিং

এবার আমরা একটি বাস্তবসম্মত উদাহরণ দেখব যেখানে volatile একটি শাটডাউন ফ্ল্যাগ হিসেবে কাজ করে, একাধিক থ্রেডকে নিরাপদে থামিয়ে দিতে।

public class ProducerConsumerWithVolatile {
    // শাটডাউন সিগনালের জন্য volatile ফ্ল্যাগ
    private volatile boolean running = true;

    public void producer() {
        int item = 0;
        System.out.println("প্রডিউসার শুরু হয়েছে।");
        while (running) {
            // আইটেম প্রস্তুত করা হচ্ছে
            System.out.println("আইটেম তৈরি: " + (++item));
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        System.out.println("প্রডিউসার বন্ধ হয়েছে। মোট আইটেম: " + item);
    }

    public void consumer() {
        System.out.println("কনজিউমার শুরু হয়েছে এবং ৫ সেকেন্ড পর শাটডাউনের অনুরোধ করবে।");
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        System.out.println("কনজিউমার: শাটডাউন সিগনাল পাঠানো হচ্ছে...");
        running = false; // volatile লেখা, সাথে সাথে প্রডিউসার থ্রেড দৃশ্যমান হবে
    }

    public static void main(String[] args) throws InterruptedException {
        ProducerConsumerWithVolatile app = new ProducerConsumerWithVolatile();
        Thread producerThread = new Thread(app::producer);
        Thread consumerThread = new Thread(app::consumer);

        producerThread.start();
        consumerThread.start();

        producerThread.join();
        consumerThread.join();
        System.out.println("সমস্ত থ্রেড নিরাপদে বন্ধ হয়েছে।");
    }
}

এই সম্পূর্ণ উদাহরণে running ভেরিয়েবল volatile হিসেবে চিহ্নিত। কনজিউমার থ্রেড ৫ সেকেন্ড পর running = false করার সাথে সাথেই প্রডিউসার থ্রেড তার while (running) লুপের শর্তে সেই পরিবর্তন দেখতে পায় এবং সুন্দরভাবে থেমে যায়। এটি volatile-এর সবচেয়ে সাধারণ ও নিরাপদ ব্যবহার — একটি সরল স্ট্যাটাস ফ্ল্যাগ।

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

volatile ব্যবহারের সময় কিছু সাধারণ সমস্যার মুখোমুখি হওয়া স্বাভাবিক। এগুলো চিহ্নিত করে সমাধানের পথ জানা থাকলে ডিবাগিং সহজ হবে।

  • volatile ভেরিয়েবলে ++ অপারেশন: ইতিমধ্যেই দেখানো হয়েছে যে এটি অ্যাটমিক নয়। সমাধান: AtomicInteger, AtomicLong, অথবা synchronized ব্যবহার করুন।
  • অপ্রয়োজনীয় volatile: প্রোগ্রামের একটি মাত্র থ্রেড যদি কোনো ভেরিয়েবল ব্যবহার করে, তবে volatile কেবল পারফরম্যান্স ওভারহেড যোগ করে। সমাধান: শুধুমাত্র শেয়ার্ড, মিউটেবল স্টেটের জন্য ব্যবহার করুন।
  • জটিল রেফারেন্স টাইপে volatile: volatile কোনো অবজেক্টের রেফারেন্সকে দৃশ্যমান করে, কিন্তু অবজেক্টের অভ্যন্তরীণ ফিল্ডগুলোকে দৃশ্যমান করে না (যতক্ষণ না সেগুলোও volatile হয় বা সঠিক happens-before সম্পর্ক থাকে)। সমাধান: ইমিউটেবল অবজেক্ট ব্যবহার করুন বা পুরো স্টেট অ্যাক্সেস সিঙ্ক্রোনাইজ করুন।
  • পারফরম্যান্স ডিগ্রেডেশন: অতিরিক্ত volatile রিড/রাইট CPU ক্যাশে মিস বাড়াতে পারে। সাধারণত এটি নগণ্য, কিন্তু টাইট লুপে সতর্ক থাকুন। সমাধান: লুপের ভিতরে যদি বারবার volatile ভেরিয়েবল পড়া হয় এবং সেটি অপরিবর্তিত থাকে, তাহলে একটি লোকাল নন-ভোলাটাইল ভেরিয়েবলে কপি করে সেটি পড়ুন।

সারসংক্ষেপ

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

Share