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

Java内存模型中的原子性与可见性

2022-04-097.6k 阅读

Java内存模型简介

在深入探讨Java内存模型(Java Memory Model,JMM)中的原子性与可见性之前,我们先来了解一下JMM的基本概念。JMM是Java虚拟机规范中定义的一种抽象的内存模型,它定义了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节。

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中。每个线程还有自己的工作内存(Working Memory),线程的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

原子性(Atomicity)

原子性的定义

原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败,有着“原子不可分”的特点。在单线程环境下,我们可以认为所有操作都是原子性的。然而,在多线程环境中,情况就变得复杂起来。

例如,对于一个简单的变量赋值操作 int i = 10;,在单线程中它无疑是原子性的。但在多线程环境下,这个操作实际上包含了两个步骤:从主内存读取值到工作内存,然后将值写入工作内存。如果两个线程同时对这个变量进行操作,就可能出现数据不一致的问题。

非原子性操作的问题

考虑以下代码示例:

public class AtomicityProblem {
    private static int count = 0;

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

    public static void main(String[] args) throws InterruptedException {
        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) {
            thread.join();
        }

        System.out.println("Expected count: 1000000, Actual count: " + count);
    }
}

在上述代码中,我们期望 count 最终的值为 1000 * 1000 = 1000000。然而,由于 count++ 操作并非原子性的,它实际上包含了读取 count 的值、增加 1、再写回 count 这三个步骤。在多线程环境下,多个线程可能同时读取到相同的 count 值,然后分别进行自增操作,最终导致结果小于预期值。

实现原子性的方式

  1. 使用 synchronized 关键字synchronized 关键字可以保证在同一时刻,只有一个线程能够进入被 synchronized 修饰的代码块或方法,从而实现对共享资源的原子性操作。

修改上述代码如下:

public class AtomicitySynchronized {
    private static int count = 0;

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

    public static void main(String[] args) throws InterruptedException {
        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) {
            thread.join();
        }

        System.out.println("Expected count: 1000000, Actual count: " + count);
    }
}

在这个版本中,increment 方法被 synchronized 修饰,保证了同一时间只有一个线程能够执行 count++ 操作,从而确保了原子性,最终 count 的值将是预期的 1000000

  1. 使用原子类:Java并发包(java.util.concurrent.atomic)中提供了一系列原子类,如 AtomicIntegerAtomicLong 等。这些原子类利用了处理器提供的原子操作指令,在硬件层面保证了操作的原子性。

以下是使用 AtomicInteger 改写的代码:

import java.util.concurrent.atomic.AtomicInteger;

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

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

    public static void main(String[] args) throws InterruptedException {
        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) {
            thread.join();
        }

        System.out.println("Expected count: 1000000, Actual count: " + count.get());
    }
}

AtomicIntegerincrementAndGet 方法是原子性的,能够确保在多线程环境下 count 的正确自增,最终得到预期的结果。

可见性(Visibility)

可见性的定义

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。在Java内存模型中,由于线程对变量的操作是在自己的工作内存中进行,然后再同步回主内存,这就可能导致其他线程不能及时看到变量的最新值。

可见性问题的产生

考虑以下代码示例:

public class VisibilityProblem {
    private static boolean stop = false;

    public static void main(String[] args) {
        Thread taskThread = new Thread(() -> {
            while (!stop) {
                // 线程持续执行任务
            }
            System.out.println("Task thread stopped.");
        });

        taskThread.start();

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

        stop = true;
        System.out.println("Main thread set stop to true.");
    }
}

在上述代码中,主线程启动了一个任务线程,任务线程在 while 循环中不断检查 stop 变量。主线程在休眠 1 秒后将 stop 设置为 true。然而,由于 stop 变量的修改可能不会立即同步到任务线程的工作内存中,任务线程可能会一直循环下去,无法感知到 stop 的变化。

实现可见性的方式

  1. 使用 volatile 关键字volatile 关键字可以保证变量的可见性。当一个变量被声明为 volatile 时,对它的写操作会立即同步到主内存,而读操作会直接从主内存中读取,从而确保其他线程能够及时看到变量的最新值。

修改上述代码如下:

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

    public static void main(String[] args) {
        Thread taskThread = new Thread(() -> {
            while (!stop) {
                // 线程持续执行任务
            }
            System.out.println("Task thread stopped.");
        });

        taskThread.start();

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

        stop = true;
        System.out.println("Main thread set stop to true.");
    }
}

在这个版本中,stop 变量被声明为 volatile,当主线程修改 stoptrue 后,任务线程能够立即感知到这个变化,从而退出循环。

  1. 使用 synchronized 关键字:除了保证原子性,synchronized 关键字也能保证可见性。当一个线程进入 synchronized 块时,它会先从主内存中读取共享变量的值,而当线程退出 synchronized 块时,会将共享变量的值刷新回主内存。

以下是使用 synchronized 实现可见性的示例:

public class VisibilitySynchronized {
    private static boolean stop = false;

    public static synchronized void setStop() {
        stop = true;
    }

    public static synchronized boolean getStop() {
        return stop;
    }

    public static void main(String[] args) {
        Thread taskThread = new Thread(() -> {
            while (!getStop()) {
                // 线程持续执行任务
            }
            System.out.println("Task thread stopped.");
        });

        taskThread.start();

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

        setStop();
        System.out.println("Main thread set stop to true.");
    }
}

在这个示例中,通过 synchronized 修饰的 setStopgetStop 方法,保证了 stop 变量的可见性。当主线程调用 setStop 方法修改 stop 后,任务线程调用 getStop 方法能够获取到最新的值。

原子性与可见性的关系

原子性和可见性是两个不同但又紧密相关的概念。原子性主要关注操作的完整性,而可见性主要关注变量值的及时更新。

在多线程编程中,仅仅保证原子性并不一定能保证可见性。例如,使用 synchronized 保证了原子性的操作,如果没有及时将修改后的值同步到主内存,其他线程仍然可能看不到最新的值。同样,仅仅保证可见性也不能保证原子性。例如,volatile 变量保证了可见性,但对于像 count++ 这样的复合操作,它并不能保证原子性。

在实际应用中,我们需要根据具体的需求来选择合适的机制来同时保证原子性和可见性。如果只是简单的变量读取和写入,并且不涉及复合操作,使用 volatile 可以保证可见性;如果涉及到复合操作,如 count++,则需要使用 synchronized 或原子类来保证原子性和可见性。

深入理解底层原理

  1. volatile 的底层原理:在硬件层面,volatile 关键字会通过内存屏障(Memory Barrier)来实现可见性。内存屏障是一种CPU指令,它可以阻止指令重排序,并确保在屏障之前的写操作都同步到主内存,在屏障之后的读操作都从主内存读取最新值。不同的CPU架构对内存屏障的实现方式可能略有不同,但总体目的都是为了保证内存操作的顺序性和可见性。

在Java中,当一个变量被声明为 volatile 时,JVM会在生成的字节码中插入特定的内存屏障指令。例如,在写操作后插入写屏障,在读操作前插入读屏障。这样,就确保了对 volatile 变量的写操作会立即对其他线程可见。

  1. 原子类的底层原理:Java并发包中的原子类,如 AtomicInteger,是基于CAS(Compare - and - Swap)操作实现的。CAS是一种乐观锁机制,它包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。当且仅当V的值等于A时,CAS才会将V的值更新为B,否则不会执行任何操作。这种操作是由处理器提供的原子指令实现的,从而保证了操作的原子性。

例如,AtomicIntegerincrementAndGet 方法内部就是通过CAS操作来实现自增的。它会不断尝试将当前值加 1,如果在尝试过程中发现当前值已经被其他线程修改,就重新获取当前值并再次尝试,直到成功为止。

  1. synchronized 的底层原理synchronized 关键字在底层是通过Monitor机制实现的。Monitor是一种同步工具,也可以理解为一种同步锁。当一个线程进入 synchronized 块时,它会尝试获取Monitor的所有权。如果Monitor已经被其他线程持有,该线程会进入等待队列,直到Monitor的所有者释放它。

在JVM层面,synchronized 修饰的方法会在字节码中增加 ACC_SYNCHRONIZED 标志,当线程调用该方法时,会自动获取Monitor锁。对于 synchronized 块,JVM会通过 monitorentermonitorexit 指令来实现加锁和解锁操作。

实际应用场景

  1. 计数器应用:在多线程环境下统计访问次数、任务完成数量等场景中,需要保证计数器的原子性和可见性。可以使用 AtomicInteger 来实现,例如网站的访问量统计,多个请求线程同时对访问量进行累加,AtomicInteger 能够确保数据的准确性和及时性。

  2. 线程间通信:在生产者 - 消费者模型中,生产者线程和消费者线程之间需要共享一些状态变量,如缓冲区是否已满、是否有新数据等。这些变量需要保证可见性,以便生产者和消费者能够及时感知对方的操作。可以使用 volatile 关键字来声明这些变量,确保状态的及时更新。

  3. 资源竞争控制:在多线程访问共享资源时,如数据库连接池、文件读写等场景,需要保证对共享资源的操作是原子性的,以避免数据冲突。可以使用 synchronized 关键字来同步对共享资源的访问,确保同一时间只有一个线程能够操作资源。

总结与注意事项

  1. 总结:原子性和可见性是Java内存模型中非常重要的概念,在多线程编程中起着关键作用。理解并正确应用原子性和可见性机制,能够有效避免多线程编程中的数据不一致和并发错误。
  2. 注意事项
    • 在使用 volatile 关键字时,要注意它只能保证变量的可见性,不能保证复合操作的原子性。因此,对于涉及到多个操作的场景,如 count++,不能仅仅依赖 volatile
    • synchronized 关键字虽然功能强大,能够同时保证原子性和可见性,但由于它是一种独占锁,会带来一定的性能开销。在高并发场景下,需要谨慎使用,或者考虑使用更细粒度的锁机制。
    • 原子类虽然在性能上优于 synchronized,但它们的功能相对单一,主要用于简单的原子操作。在涉及到复杂逻辑的同步控制时,可能还需要结合其他同步机制。

通过深入理解Java内存模型中的原子性与可见性,并合理运用相关机制,我们能够编写出更加健壮、高效的多线程程序。在实际开发中,需要根据具体的业务需求和场景,选择最合适的同步方式,以达到性能和正确性的平衡。