JAVA

memory visibility & volatile

neal89 2025. 3. 9. 12:28

🔹 What is Memory Visibility?

Memory visibility refers to how changes made by one thread become visible to other threads in a multi-threaded environment. In Java, each thread may have its own local cache of variables, which can lead to unexpected behavior when multiple threads access and modify shared data.

💡 The Problem: Cached Variables

  • CPU caches improve performance by storing frequently used variables.
  • Each thread may use its own copy of a variable instead of the latest value from main memory.
  • Changes made by one thread may not be visible to others due to caching.

🚀 Example 1: Memory Visibility Issue Without volatile

❌ Expected Behavior

  1. The main thread starts a worker thread.
  2. The worker thread runs a loop while isRunning is true.
  3. After 1 second, the main thread sets isRunning = false.
  4. The worker thread should stop when it sees isRunning is false.
public class VisibilityIssue {
    public static void main(String[] args) {
        Task task = new Task();
        Thread worker = new Thread(task, "WorkerThread");
        worker.start();

        try {
            Thread.sleep(1000); // Let the worker thread run for 1 second
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Main thread: Changing isRunning to false...");
        task.isRunning = false; // Attempt to stop the worker thread
    }

    static class Task implements Runnable {
        boolean isRunning = true; // Shared variable (no volatile)

        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName() + " started...");
            while (isRunning) { // The thread should stop when isRunning becomes false
            }
            System.out.println(Thread.currentThread().getName() + " stopped.");
        }
    }
}

🔹 Expected Output

WorkerThread started...
Main thread: Changing isRunning to false...
WorkerThread stopped.

🔴 Actual Output (Problem)

WorkerThread started...
Main thread: Changing isRunning to false...
  • The worker thread does not stop because it never sees the updated value of isRunning.
  • The worker thread continues to use the cached value (true) in its CPU instead of fetching the updated value (false) from memory.

🚀 Example 2: Fixing Memory Visibility with volatile

To fix this issue, we use the volatile keyword, which ensures:

  • All reads and writes go directly to main memory.
  • All threads see the most up-to-date value.
public class VisibilityFixed {
    public static void main(String[] args) {
        Task task = new Task();
        Thread worker = new Thread(task, "WorkerThread");
        worker.start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Main thread: Changing isRunning to false...");
        task.isRunning = false; // Now the change is visible to all threads
    }

    static class Task implements Runnable {
        volatile boolean isRunning = true; // Using volatile ensures visibility

        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName() + " started...");
            while (isRunning) {
            }
            System.out.println(Thread.currentThread().getName() + " stopped.");
        }
    }
}

✅ Corrected Output

WorkerThread started...
Main thread: Changing isRunning to false...
WorkerThread stopped.

Why Does volatile Fix the Issue?

  • volatile ensures that every read of isRunning happens from main memory, not cache.
  • When isRunning is updated in the main thread, the change is immediately visible to the worker thread.

🚀 Example 3: Real-Time Counter Without volatile (Incorrect)

public class CounterIssue {
    public static void main(String[] args) {
        CounterTask task = new CounterTask();
        Thread worker = new Thread(task, "WorkerThread");
        worker.start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Main thread: Changing active to false...");
        task.active = false;
        System.out.println("Final counter value: " + task.counter);
    }

    static class CounterTask implements Runnable {
        boolean active = true;
        long counter = 0;

        @Override
        public void run() {
            while (active) {
                counter++; // Updating shared variable
            }
            System.out.println("WorkerThread stopped. Final counter: " + counter);
        }
    }
}

🔹 Issue

Even though active is false in the main thread, the worker thread continues running due to caching.

 


🚀 Example 4: Fixing with volatile (Still Has an Issue with Atomicity)

import java.util.concurrent.atomic.AtomicLong;

public class CounterFixed {
    public static void main(String[] args) {
        CounterTask task = new CounterTask();
        Thread worker = new Thread(task, "WorkerThread");
        worker.start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Main thread: Changing active to false...");
        task.active = false;
        System.out.println("Final counter value: " + task.counter);
    }

    static class CounterTask implements Runnable {
        volatile boolean active = true;
        // ❌ Incorrect: volatile does not guarantee atomicity for increments
        // volatile long counter = 0; 
        // ✅ Correct: Use AtomicLong for atomic operations
        AtomicLong counter = new AtomicLong(0);

        @Override
        public void run() {
            while (active) {
                counter.incrementAndGet(); // Ensures atomicity
            }
            System.out.println("WorkerThread stopped. Final counter: " + counter.get());
        }
    }
}

⚠️ Important Note: volatile Does NOT Guarantee Atomicity

  • volatile long counter = 0; ensures visibility but not atomicity.
  • Why? The counter++ operation consists of three steps:
    1. Read the current value.
    2. Increment the value.
    3. Write the new value back.
  • If multiple threads execute counter++, race conditions can still occur, leading to lost updates.
  • Solution: Use AtomicLong instead, which provides atomic operations like incrementAndGet().

✅ Corrected Output

Now, the counter increments correctly without race conditions.


🔹 When to Use volatile vs. Atomic Variables?

Use Case volatile Atomic Variables (AtomicLong, AtomicInteger)

Thread visibility ✅ Ensures visibility across threads ✅ Ensures visibility
Atomic operations (++, --, +=, -=) Not safe! Multiple threads may overwrite each other's updates Safe! Provides atomicity
Performance impact ⚡ Faster (no locking) 🐢 Slightly slower (ensures atomicity)
Use for flags (boolean) ✅ Yes, perfect for simple flags 🚫 Not needed
Use for counters (long, int) ❌ No, use AtomicLong or synchronization ✅ Yes, prevents race conditions

 


🔹 Key Takeaways

Concept Without volatile With volatile

Thread visibility Changes may not be seen by other threads Changes are immediately visible
Caching issue Threads may use stale data from cache Always reads/writes to main memory
Performance Faster (due to caching) Slightly slower (but safer for visibility)
Atomicity ❌ No ❌ No (Still needs synchronized for atomic operations)

 

'JAVA' 카테고리의 다른 글

LockSupport & ReentrantLock  (0) 2025.03.10
synchronized  (0) 2025.03.10
interrupt() in java threads  (0) 2025.03.09
join() in java thread  (0) 2025.03.09
Why Can't run() Throw Checked Exceptions?  (0) 2025.03.08