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

Java内存模型对并发编程的影响

2021-10-133.8k 阅读

Java内存模型基础

在深入探讨Java内存模型(Java Memory Model,JMM)对并发编程的影响之前,我们先来了解一下JMM的基本概念和架构。

主内存与工作内存

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中。这里的变量涵盖了实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,不会存在竞争问题。

每个线程都有自己独立的工作内存(Working Memory),线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

例如,考虑以下简单的Java代码:

public class MemoryModelExample {
    private static int number = 0;

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            number = 1;
            System.out.println("Thread1 set number to " + number);
        });

        Thread thread2 = new Thread(() -> {
            int localNumber = number;
            System.out.println("Thread2 read number as " + localNumber);
        });

        thread1.start();
        thread2.start();
    }
}

在上述代码中,number变量存储在主内存中。thread1线程在其工作内存中对number进行赋值操作,然后将修改后的值刷新回主内存。thread2线程从主内存读取number的值到自己的工作内存。然而,由于并发执行的不确定性,thread2可能在thread1将修改后的值刷新回主内存之前就读取了number,导致输出结果可能不符合预期。

内存交互操作

JMM定义了8种操作来完成主内存与工作内存之间的交互:

  1. lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占的状态。
  2. unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  3. read(读取):作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  4. load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  5. use(使用):作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
  6. assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  7. store(存储):作用于工作内存的变量,把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
  8. write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

这些操作必须遵循一定的规则,例如,不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存;不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存等。

Java内存模型与可见性

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。在Java并发编程中,可见性问题是一个常见且容易被忽视的问题,而Java内存模型对可见性有着重要的影响。

可见性问题产生的原因

由于每个线程都有自己的工作内存,线程对共享变量的修改首先在自己的工作内存中进行,然后才会同步回主内存。而其他线程从主内存读取共享变量的值到自己的工作内存也需要一定的时间。这就导致了在某些情况下,一个线程对共享变量的修改,其他线程可能无法及时感知到。

例如,考虑以下代码:

public class VisibilityExample {
    private static boolean flag = false;

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            while (!flag) {
                // 线程1在等待flag变为true
            }
            System.out.println("Thread1 exited loop");
        });

        Thread thread2 = new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            flag = true;
            System.out.println("Thread2 set flag to true");
        });

        thread1.start();
        thread2.start();
    }
}

在上述代码中,thread1线程在一个循环中等待flag变为truethread2线程在睡眠1秒后将flag设置为true。然而,由于可见性问题,thread1可能永远不会退出循环。这是因为thread2flag的修改可能没有及时同步到主内存,或者thread1没有及时从主内存中读取到flag的最新值。

volatile关键字与可见性

为了解决可见性问题,Java提供了volatile关键字。当一个变量被声明为volatile时,它会具有以下特性:

  1. 保证可见性:对一个volatile变量的写操作,会立即同步到主内存中;对一个volatile变量的读操作,会直接从主内存中读取最新的值。
  2. 禁止指令重排序volatile变量会禁止指令重排序优化,确保在volatile变量写操作之前的所有写操作都已经同步到主内存,在volatile变量读操作之后的所有读操作都能读取到最新的值。

下面我们通过修改前面的代码来展示volatile的作用:

public class VolatileVisibilityExample {
    private static volatile boolean flag = false;

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            while (!flag) {
                // 线程1在等待flag变为true
            }
            System.out.println("Thread1 exited loop");
        });

        Thread thread2 = new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            flag = true;
            System.out.println("Thread2 set flag to true");
        });

        thread1.start();
        thread2.start();
    }
}

在上述代码中,将flag声明为volatile后,thread2flag的修改会立即同步到主内存,thread1也能及时从主内存中读取到flag的最新值,从而thread1能够正常退出循环。

Java内存模型与原子性

原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败,不存在中间状态。在Java并发编程中,原子性问题也是一个关键问题,而Java内存模型对原子性的实现有着重要的影响。

原子性问题产生的原因

在多线程环境下,由于线程的交替执行,一些看似简单的操作可能会被中断,导致出现原子性问题。例如,对于简单的变量赋值操作i = i + 1,在字节码层面,它实际上包含了读取变量值、增加1、写入变量值等多个操作。在多线程环境下,如果两个线程同时执行这个操作,就可能出现数据竞争,导致结果不符合预期。

考虑以下代码:

public class AtomicityExample {
    private static int counter = 0;

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

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

        System.out.println("Final counter value: " + counter);
    }
}

在上述代码中,我们启动了10个线程,每个线程对counter变量进行1000次自增操作。理论上,最终的counter值应该是10000。然而,由于counter++操作不是原子性的,实际运行结果往往小于10000。

synchronized关键字与原子性

为了解决原子性问题,Java提供了synchronized关键字。当一个方法或代码块被synchronized修饰时,同一时刻只有一个线程能够进入该方法或代码块,从而保证了操作的原子性。

我们可以通过修改上述代码来展示synchronized的作用:

public class SynchronizedAtomicityExample {
    private static int counter = 0;

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

    public static void main(String[] args) {
        Thread[] threads = new Thread[10];
        for (int i = 0; i < 10; 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("Final counter value: " + counter);
    }
}

在上述代码中,我们将counter++操作封装在一个synchronized方法increment中。这样,同一时刻只有一个线程能够执行increment方法,从而保证了counter++操作的原子性,最终counter的值为10000。

原子类与原子性

除了synchronized关键字,Java还提供了一系列原子类,如AtomicIntegerAtomicLong等,这些原子类提供了原子性的操作方法,能够更高效地解决原子性问题。

下面我们使用AtomicInteger来修改前面的代码:

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicClassAtomicityExample {
    private static AtomicInteger counter = new AtomicInteger(0);

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

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

        System.out.println("Final counter value: " + counter.get());
    }
}

在上述代码中,我们使用AtomicIntegerincrementAndGet方法来对counter进行自增操作。incrementAndGet方法是原子性的,因此能够保证最终counter的值为10000。

Java内存模型与有序性

有序性是指程序执行的顺序按照代码的先后顺序执行。然而,在实际执行过程中,为了提高性能,编译器和处理器可能会对指令进行重排序。在单线程环境下,指令重排序不会影响程序的正确性,但在多线程环境下,指令重排序可能会导致程序出现错误。Java内存模型对有序性有着重要的影响。

指令重排序

指令重排序是指编译器和处理器为了优化程序性能,在不改变程序语义的前提下,对指令的执行顺序进行重新排序。指令重排序可以分为以下三种类型:

  1. 编译器优化重排序:编译器在编译代码时,会根据一定的规则对指令进行重排序,以提高程序的执行效率。
  2. 指令级并行重排序:现代处理器采用了指令级并行技术(ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

例如,考虑以下代码:

public class ReorderingExample {
    private static int a = 0;
    private static int b = 0;

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            a = 1;
            b = 2;
        });

        Thread thread2 = new Thread(() -> {
            if (b == 2) {
                System.out.println("a = " + a);
            }
        });

        thread1.start();
        thread2.start();
    }
}

在上述代码中,thread1先对a赋值为1,然后对b赋值为2。thread2b等于2时输出a的值。在单线程环境下,a的值肯定为1。然而,由于指令重排序,thread1可能会先执行b = 2,然后再执行a = 1。这样,当thread2执行时,b可能已经为2,但a可能还未被赋值为1,从而导致输出结果不符合预期。

happens - before原则

为了保证多线程程序的有序性,Java内存模型定义了happens - before原则。happens - before原则定义了一些规则,只要满足这些规则,就可以保证在多线程环境下程序的执行结果是可预测的。happens - before原则包括以下规则:

  1. 程序顺序规则:一个线程中的每个操作,happens - before于该线程中的任意后续操作。
  2. 监视器锁规则:对一个锁的解锁,happens - before于随后对这个锁的加锁。
  3. volatile变量规则:对一个volatile域的写,happens - before于任意后续对这个volatile域的读。
  4. 传递性:如果A happens - before B,且B happens - before C,那么A happens - before C。

例如,在使用synchronized关键字时,就满足监视器锁规则。在使用volatile关键字时,就满足volatile变量规则。这些规则保证了在多线程环境下,程序的执行顺序是可预测的。

Java内存模型在并发工具中的应用

Java内存模型的特性在各种并发工具中得到了广泛的应用,下面我们以ConcurrentHashMap为例来进行分析。

ConcurrentHashMap中的可见性与原子性

ConcurrentHashMap是Java提供的线程安全的哈希表。在ConcurrentHashMap中,通过使用volatile关键字和一些原子操作来保证数据的可见性和原子性。

例如,ConcurrentHashMap中的Node节点的valuenext字段被声明为volatile,这保证了在多线程环境下,一个线程对Node节点的修改能够及时被其他线程感知到。同时,ConcurrentHashMap在进行一些更新操作时,会使用原子类Unsafe提供的原子操作,如compareAndSwapObject等,来保证操作的原子性。

ConcurrentHashMap中的有序性

ConcurrentHashMap中,通过遵循happens - before原则来保证操作的有序性。例如,在进行插入操作时,会先对相应的桶进行加锁,然后进行插入操作,最后解锁。这满足了监视器锁规则,保证了在多线程环境下插入操作的有序性。

总结与实践建议

通过对Java内存模型对并发编程影响的深入探讨,我们了解了可见性、原子性和有序性在并发编程中的重要性,以及Java内存模型是如何通过volatile关键字、synchronized关键字、原子类和happens - before原则等来保证这些特性的。

在实际开发中,为了编写高效、正确的并发程序,我们应该遵循以下实践建议:

  1. 合理使用volatile关键字:当需要保证变量的可见性,且该变量的操作不需要保证原子性时,可以使用volatile关键字。
  2. 谨慎使用synchronized关键字:当需要保证操作的原子性和可见性,且对性能要求不是特别高时,可以使用synchronized关键字。但需要注意避免死锁等问题。
  3. 优先使用原子类:当需要保证操作的原子性,且对性能要求较高时,可以优先使用原子类。
  4. 遵循happens - before原则:在编写多线程代码时,要遵循happens - before原则,确保程序的执行顺序是可预测的。

总之,深入理解Java内存模型对并发编程的影响,是编写高效、可靠的并发程序的关键。