MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java volatile关键字在多线程中的应用

2024-02-027.2k 阅读

Java 内存模型基础

在深入探讨 volatile 关键字之前,我们需要先了解一些 Java 内存模型(Java Memory Model,JMM)的基础知识。JMM 定义了 Java 程序中各个线程对共享变量的访问规则,以及在多线程环境下如何处理可见性、原子性和有序性问题。

主内存与工作内存

JMM 将内存分为主内存(Main Memory)和工作内存(Working Memory)。主内存是所有线程共享的,存储了共享变量。而每个线程都有自己的工作内存,工作内存中保存了该线程使用到的共享变量的副本。当线程对共享变量进行操作时,它首先从主内存中将变量读取到自己的工作内存中,然后在工作内存中进行操作,操作完成后再将结果写回到主内存。

例如,假设有两个线程 Thread1Thread2 同时访问共享变量 countThread1 从主内存读取 count 到自己的工作内存,进行加 1 操作,然后写回主内存。与此同时,Thread2 也从主内存读取 count 到自己的工作内存(此时 Thread2 读取到的 count 值还是 Thread1 操作前的值),进行自己的操作。这种机制就可能导致数据不一致的问题。

可见性问题

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。在 JMM 中,由于线程操作共享变量是先在工作内存中进行,然后再写回主内存,这就导致了可见性问题。

例如以下代码:

public class VisibilityProblem {
    private static int num = 0;
    public static void main(String[] args) {
        new Thread(() -> {
            while (num == 0) {
                // 线程 1 持续循环,等待 num 不为 0
            }
            System.out.println("线程 1 结束循环,num = " + num);
        }).start();

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

        num = 1;
        System.out.println("主线程修改 num 为 1");
    }
}

在这段代码中,主线程修改了 num 的值为 1,但是线程 1 可能永远不会结束循环。原因是线程 1 在自己的工作内存中保存了 num 的副本,并且在循环中一直使用这个副本进行判断。主线程修改 num 后,线程 1 的工作内存中的 num 副本并没有及时更新,所以线程 1 无法感知到 num 的变化。

原子性问题

原子性是指一个操作是不可中断的,要么全部执行成功,要么全部执行失败。在 Java 中,简单的变量赋值操作(如 int i = 10;)是原子性的,但是像 i++; 这样的复合操作并不是原子性的。

例如,i++ 操作实际上包含了三个步骤:读取 i 的值、对 i 加 1、将结果写回 i。在多线程环境下,如果两个线程同时执行 i++ 操作,就可能出现数据竞争。假设初始 i 的值为 10,线程 A 读取 i 的值为 10,线程 B 也读取 i 的值为 10,然后线程 A 对 i 加 1 并写回,此时 i 的值变为 11。接着线程 B 也对它读取到的 10 进行加 1 并写回,最终 i 的值为 11,而不是预期的 12。

有序性问题

有序性是指程序执行的顺序按照代码的先后顺序执行。然而,在 Java 中,为了提高性能,编译器和处理器可能会对指令进行重排序。重排序可能会导致程序在多线程环境下出现意外的结果。

例如:

public class ReorderingProblem {
    private static int a = 0;
    private static boolean flag = false;

    public static void main(String[] args) {
        new Thread(() -> {
            a = 1;
            flag = true;
        }).start();

        new Thread(() -> {
            while (!flag) {
                // 等待 flag 为 true
            }
            System.out.println(a);
        }).start();
    }
}

在理想情况下,第一个线程先将 a 赋值为 1,然后将 flag 设为 true。第二个线程在 flag 变为 true 后打印 a 的值,应该打印出 1。但是由于指令重排序,第一个线程可能先将 flag 设为 true,然后再将 a 赋值为 1。这样第二个线程在 flag 变为 true 后打印 a 的值,可能就会打印出 0。

volatile 关键字概述

volatile 关键字是 Java 提供的一种轻量级的同步机制,它主要用于解决多线程环境下的可见性问题,在一定程度上也能解决有序性问题,但不能解决原子性问题。

volatile 保证可见性

当一个变量被声明为 volatile 时,任何线程对该变量的修改都会立即同步到主内存,并且其他线程在读取该变量时会直接从主内存中获取最新的值,而不是从自己的工作内存中获取旧的副本。

修改前面的可见性问题代码如下:

public class VolatileVisibility {
    private static volatile int num = 0;
    public static void main(String[] args) {
        new Thread(() -> {
            while (num == 0) {
                // 线程 1 持续循环,等待 num 不为 0
            }
            System.out.println("线程 1 结束循环,num = " + num);
        }).start();

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

        num = 1;
        System.out.println("主线程修改 num 为 1");
    }
}

在这段代码中,num 被声明为 volatile。当主线程修改 num 的值为 1 时,这个修改会立即同步到主内存,并且线程 1 在循环中会从主内存获取最新的 num 值,从而能够感知到 num 的变化,结束循环。

volatile 防止指令重排序

volatile 关键字还能在一定程度上防止指令重排序。对于 volatile 变量的写操作,编译器和处理器会确保在写操作之前的所有操作都已经执行完毕,并且不会将写操作之后的指令重排序到写操作之前。对于 volatile 变量的读操作,编译器和处理器会确保在读操作之后的所有操作都不会重排序到读操作之前。

例如,在前面的指令重排序问题代码中,如果将 flag 声明为 volatile

public class VolatileReordering {
    private static int a = 0;
    private static volatile boolean flag = false;

    public static void main(String[] args) {
        new Thread(() -> {
            a = 1;
            flag = true;
        }).start();

        new Thread(() -> {
            while (!flag) {
                // 等待 flag 为 true
            }
            System.out.println(a);
        }).start();
    }
}

由于 flagvolatile,第一个线程在将 flag 设为 true 之前,会确保 a = 1 已经执行完毕,不会发生指令重排序,从而第二个线程在 flag 变为 true 后能正确打印出 a 的值 1。

volatile 与原子性

虽然 volatile 关键字能保证可见性和一定程度的有序性,但它不能保证原子性。

例如,对于 volatile 修饰的变量 countcount++ 这样的操作仍然不是原子性的。因为 count++ 包含了读取、加 1 和写回三个步骤,即使 countvolatile,在多线程环境下,仍然可能出现数据竞争。

以下代码演示了 volatile 不能保证原子性:

public class VolatileAtomicityProblem {
    private static volatile int count = 0;

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

    public static void main(String[] args) {
        Thread[] threads = new Thread[1000];
        for (int i = 0; i < 1000; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    increment();
                }
            });
            threads[i].start();
        }

        for (Thread thread : threads) {
            try {
                thread.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        System.out.println("最终 count 的值:" + count);
    }
}

在这段代码中,创建了 1000 个线程,每个线程对 count 进行 1000 次 count++ 操作。如果 count++ 是原子性的,最终 count 的值应该是 1000000。但实际上,由于 count++ 不是原子性的,最终 count 的值会小于 1000000。

如果需要保证原子性,可以使用 java.util.concurrent.atomic 包下的原子类,如 AtomicInteger

修改上述代码如下:

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicitySolution {
    private static AtomicInteger count = new AtomicInteger(0);

    public static void increment() {
        count.incrementAndGet();
    }

    public static void main(String[] args) {
        Thread[] threads = new Thread[1000];
        for (int i = 0; i < 1000; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    increment();
                }
            });
            threads[i].start();
        }

        for (Thread thread : threads) {
            try {
                thread.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        System.out.println("最终 count 的值:" + count.get());
    }
}

在这段代码中,使用 AtomicInteger 类的 incrementAndGet 方法保证了 count 自增操作的原子性,最终 count 的值会是 1000000。

volatile 的使用场景

状态标记

在多线程编程中,经常会使用一个变量来标记某个状态,例如线程是否应该停止。使用 volatile 修饰这个状态标记变量,可以保证其他线程能够及时感知到状态的变化。

以下是一个简单的示例:

public class ThreadStopExample {
    private static volatile boolean stop = false;

    public static void main(String[] args) {
        new Thread(() -> {
            while (!stop) {
                System.out.println("线程正在运行...");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("线程停止运行");
        }).start();

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

        stop = true;
        System.out.println("主线程设置停止标记");
    }
}

在这段代码中,stop 变量被声明为 volatile。主线程在 5 秒后将 stop 设置为 true,工作线程能够及时感知到这个变化并停止循环。

单例模式中的双重检查锁定(DCL)

在单例模式中,双重检查锁定(Double-Checked Locking,DCL)是一种常用的实现方式,用于在多线程环境下延迟初始化单例实例,同时保证线程安全。在 DCL 中,volatile 关键字起着关键作用。

以下是使用 DCL 实现单例模式的代码:

public class Singleton {
    private static volatile Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

在这段代码中,instance 被声明为 volatile。这是因为在 instance = new Singleton(); 这行代码中,实际上包含了三个步骤:

  1. 分配内存空间给 Singleton 对象。
  2. 初始化 Singleton 对象。
  3. instance 指向分配的内存空间。

由于指令重排序,步骤 2 和步骤 3 可能会重排序。如果没有 volatile,当一个线程执行到 if (instance == null) 时,可能会因为指令重排序,在 Singleton 对象还未初始化完成时,就看到 instance 已经不为 null,从而返回一个未初始化完全的 Singleton 对象。而使用 volatile 可以防止这种指令重排序,保证 instance 在被赋值之前,其初始化操作已经完成。

volatile 与 synchronized 的比较

功能

  • volatile:主要用于解决多线程环境下的可见性问题,在一定程度上防止指令重排序,但不能保证原子性。
  • synchronized:不仅能保证可见性,还能保证原子性和有序性。synchronized 通过锁机制,确保同一时间只有一个线程能够访问被同步的代码块或方法,从而保证了原子性。同时,由于锁的存在,也解决了可见性和有序性问题。

性能

  • volatile:是一种轻量级的同步机制,因为它不需要像 synchronized 那样进行线程上下文切换和锁竞争,所以性能开销较小。适用于对性能要求较高,且只需要保证可见性的场景。
  • synchronized:由于涉及到锁的获取和释放,以及可能的线程阻塞和唤醒,性能开销相对较大。适用于需要保证原子性、可见性和有序性,且对性能要求不是特别苛刻的场景。

使用场景

  • volatile:常用于状态标记变量、单例模式中的双重检查锁定等场景,这些场景只需要保证可见性和一定程度的有序性。
  • synchronized:适用于对共享资源的读写操作都需要保证线程安全的场景,例如多个线程同时对一个对象的属性进行修改和读取。

例如,在一个简单的计数器场景中,如果只需要保证不同线程能够及时看到计数器的最新值,而不需要保证每次计数操作的原子性,可以使用 volatile

public class VolatileCounter {
    private static volatile int count = 0;

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

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

但如果需要保证每次计数操作的原子性,就需要使用 synchronized

public class SynchronizedCounter {
    private static int count = 0;

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

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

总结 volatile 在多线程中的应用要点

  1. 可见性保证volatile 关键字确保对 volatile 变量的修改对其他线程立即可见,通过强制线程从主内存读取变量值,而不是使用工作内存中的副本。这解决了多线程环境下共享变量的可见性问题,避免了线程因使用旧的变量副本而导致的逻辑错误。
  2. 有序性保证:它能够防止指令重排序,对于 volatile 变量的写操作,其之前的所有操作都先于写操作完成且不会被重排序到写操作之后;对于读操作,其后的操作不会被重排序到读操作之前。这在一些对操作顺序有要求的多线程场景中非常重要。
  3. 原子性局限:需要明确 volatile 不能保证原子性,像 count++ 这样的复合操作在多线程环境下仍然会出现数据竞争。如果需要原子性操作,应使用 java.util.concurrent.atomic 包中的原子类。
  4. 使用场景:常用于状态标记变量,如线程是否停止的标记;在单例模式的双重检查锁定中,volatile 确保单例实例的正确初始化和线程安全。
  5. synchronized 对比volatile 是轻量级同步机制,性能开销小,主要解决可见性和部分有序性问题;而 synchronized 能保证原子性、可见性和有序性,但性能开销较大。根据具体场景需求,合理选择使用 volatilesynchronized 来实现多线程编程中的线程安全。

在多线程编程中,正确理解和使用 volatile 关键字对于编写高效、线程安全的代码至关重要。通过结合 volatile 与其他同步机制(如 synchronized、原子类等),可以满足各种复杂的多线程场景需求。