引言

JVM中定义了Java内存模型(Java Memory Model,JMM),深入了解JMM中的主内存和工作内存,了解并发时每个线程是如何操作主内存中的共享变量的,了解JMM是如何保证并发时的原子性、可见性、一致性的,这样我们在开发过程中才可以避免出现并发问题。

JMM 的重要性

随着多核处理器的普及,多线程编程成为提升程序性能的重要手段。然而,多线程并发访问共享数据时会引发一系列复杂的问题,如可见性问题、原子性问题和有序性问题。这些问题如果得不到妥善解决,将导致程序出现难以调试和重现的错误。JMM 正是为了解决这些问题而诞生的,它提供了一套规则和机制,明确了在多线程环境下,线程如何与主内存进行数据交互,以及对共享变量的访问顺序和可见性等关键问题,从而为多线程编程提供了坚实的基础。

JMM 的基本概念

主内存与工作内存

在 JMM 的架构中,内存被分为主内存(Main Memory)和工作内存(Working Memory)。主内存是所有线程共享的内存区域,存储了共享变量等数据。而每个线程都拥有自己独立的工作内存,线程对共享变量的操作并非直接在主内存中进行,而是先将共享变量从主内存拷贝到自己的工作内存中,然后在工作内存中对变量进行读取和修改等操作,最终再将修改后的值刷新回主内存。例如,当一个线程要读取某个共享变量的值时,它会首先从主内存将该变量加载到自己的工作内存中的副本,之后的操作都基于这个副本进行。这种设计虽然提高了线程访问内存的效率,但也引入了数据一致性的问题,因为不同线程的工作内存中的数据副本可能不一致。

原子性、可见性与有序性

  1. 原子性(Atomicity):原子性是指一个操作是不可中断的,要么全部执行成功,要么全部不执行。在 JMM 中,对于基本数据类型的变量的读取和赋值操作(除 long 和 double 外)通常是原子性的,但对于复合操作,如 i++(它实际上包含读取 i 的值、加 1 和写回新值三个步骤)则不是原子性的。为了保证复杂操作的原子性,Java 提供了 synchronized 关键字等机制,被 synchronized 修饰的代码块在同一时刻只能被一个线程执行,从而保证了代码块内操作的原子性。

  1. 可见性(Visibility):可见性是指当一个线程修改了共享变量的值后,其他线程能够立即得知这个修改。由于线程操作的是工作内存中的变量副本,当一个线程修改了工作内存中的变量后,如果没有及时将修改刷新回主内存,其他线程就无法看到这个变化。JMM 通过一些规则来保证可见性,例如,对 volatile 修饰的变量,当一个线程修改了它的值,会立即将修改刷新回主内存,并且其他线程在读取该变量时,会强制从主内存重新获取最新的值,从而保证了可见性。

  1. 有序性(Ordering):有序性是指程序执行的顺序按照代码的先后顺序执行。然而,在实际运行中,为了提高性能,编译器和处理器可能会对指令进行重排序。虽然重排序在单线程环境下不会影响程序的正确性,但在多线程环境下可能会导致问题。JMM 通过 happens - before 原则来保证一定的有序性。happens - before 原则定义了一些操作之间的先后顺序关系,例如,如果一个操作 A happens - before 另一个操作 B,那么 A 的执行结果对 B 可见,并且 A 的执行顺序排在 B 之前。例如,一个线程对一个变量的写操作 happens - before 另一个线程对该变量的读操作,这样就能保证读操作能获取到正确的值。

JMM 的工作原理

内存交互操作

JMM 定义了一系列的内存交互操作来规范线程与主内存之间的数据传输。这些操作包括 lock(锁定)、unlock(解锁)、read(读取)、load(载入)、use(使用)、assign(赋值)、store(存储)和 write(写入)。例如,当线程要读取一个共享变量时,首先会执行 read 操作从主内存读取变量的值,然后通过 load 操作将读取的值存入工作内存中对应的变量副本;当线程要修改共享变量的值时,先对工作内存中的变量副本执行 assign 操作进行赋值,之后通过 store 操作将修改后的值存储到主内存,最后执行 write 操作将值写入主内存中对应的变量。这些操作必须按照一定的顺序执行,并且不同的操作之间存在着特定的依赖关系,以确保内存操作的正确性和一致性。

lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。

unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用

load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。

assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。

write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

happens - before 原则

happens - before 原则是 JMM 中保证有序性的核心机制。除了前面提到的线程对同一变量的写操作 happens - before 读操作外,还有其他一些规则。例如,程序顺序规则,即一个线程内,按照代码顺序,前面的操作 happens - before 后面的操作;监视器锁规则,对一个锁的解锁操作 happens - before 后续对这个锁的加锁操作;volatile 变量规则,对一个 volatile 变量的写操作 happens - before 后续对这个 volatile 变量的读操作等。通过这些规则,JMM 能够在一定程度上保证多线程程序执行的有序性,尽管存在指令重排序等优化手段,但只要遵循 happens - before 原则,就能保证程序的正确性。

volatile 关键字的作用

volatile 关键字在 JMM 中具有特殊的语义。它保证了变量的可见性,如前所述,当一个变量被 volatile 修饰后,任何线程对它的修改会立即刷新到主内存,并且其他线程在读取该变量时会强制从主内存获取最新值。同时,volatile 关键字也在一定程度上保证了有序性。由于 volatile 变量的写操作 happens - before 后续对它的读操作,这就限制了编译器和处理器对 volatile 变量相关操作的重排序。例如,在一个多线程环境中,有一个共享的布尔变量 flag 被 volatile 修饰,当一个线程将 flag 设置为 true 后,其他线程能够立即看到这个变化,并且不会因为指令重排序而导致其他线程读取到旧的值。

synchronized 关键字的作用

synchronized 关键字用于实现线程同步,它提供了一种互斥访问的机制。当一个线程进入被 synchronized 修饰的代码块或方法时,它会获取对应的锁,在持有锁期间,其他线程无法进入该代码块或方法。这保证了同一时刻只有一个线程能够执行被 synchronized 保护的代码,从而解决了原子性问题。同时,synchronized 还保证了可见性,当线程释放锁时,会将工作内存中的数据刷新回主内存,而其他线程在获取锁时,会从主内存重新读取数据,确保了数据的一致性。例如,在一个多线程同时对一个共享资源进行修改的场景中,使用 synchronized 关键字可以保证每个线程对共享资源的操作是原子性的,并且其他线程能够看到最新的修改结果。

JMM 与多线程编程实践

线程安全的实现

在多线程编程中,实现线程安全是至关重要的。基于 JMM 的规则和机制,开发者可以采用多种方式来确保线程安全。除了前面提到的使用 volatile 和 synchronized 关键字外,还可以使用 java.util.concurrent 包下提供的并发工具类,如 ConcurrentHashMap、CopyOnWriteArrayList 等。这些类内部通过合理地运用 JMM 的特性,实现了高效且线程安全的数据结构和算法。例如,ConcurrentHashMap 采用了分段锁的机制,允许多个线程同时对不同的段进行操作,提高了并发访问的性能,同时保证了数据的一致性。

常见的多线程问题及解决方案

  1. 死锁(Deadlock):死锁是多线程编程中常见的问题之一,当两个或多个线程互相持有对方需要的资源,并且都在等待对方释放资源时,就会发生死锁。例如,线程 A 持有资源 1,等待获取资源 2,而线程 B 持有资源 2,等待获取资源 1,这样就形成了死锁。为了避免死锁,开发者需要合理地设计线程获取资源的顺序,避免循环等待。同时,可以使用一些工具来检测死锁,如 Java 自带的 jstack 命令,它可以打印出线程的堆栈信息,帮助开发者分析是否存在死锁以及死锁发生的位置。

  1. 活锁(Livelock):活锁与死锁类似,但线程并没有阻塞,而是在不断地尝试执行,但始终无法取得进展。例如,两个线程都在尝试避让对方,不断地改变自己的状态,但最终都无法完成任务。解决活锁的方法通常是引入随机的等待时间,避免线程之间的持续冲突。

  1. 饥饿(Starvation):饥饿是指某个线程因为无法获取到资源而长时间无法执行。例如,当一个线程优先级较低,而高优先级的线程持续占用资源时,低优先级线程就可能会发生饥饿。为了解决饥饿问题,可以合理地设置线程优先级,并且避免高优先级线程长时间占用资源。

总结

Java 内存模型(JMM)是 Java 多线程编程的核心基础,它通过定义内存结构、内存交互操作、happens - before 原则等机制,有效地解决了多线程编程中原子性、可见性和有序性等关键问题,为开发者编写线程安全的 Java 程序提供了有力的保障。

文章作者: Z
本文链接:
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 微博客
并发编程
喜欢就支持一下吧