理解并发编程的基石:为何需要Java内存模型(JMM)
在现代多核处理器架构下,并发编程已成为提升应用性能的核心手段。然而,并发在带来性能红利的同时,也引入了复杂性问题,其中最主要的是内存可见性、原子性和有序性问题。如果没有明确的规范,编译器和处理器为了优化性能,可能会进行指令重排序,或者线程可能将变量保存在本地缓存而非主内存中,导致一个线程的修改对其他线程不可见。这种不确定性使得编写正确、高效的并发程序变得异常困难。Java内存模型(JMM)正是为了解决这些问题而生的,它是一套规范,通过定义程序中各个变量的访问规则,屏蔽了底层各种硬件和操作系统的内存访问差异,为Java开发者提供了一个一致的内存可见性保证。JMM是Java并发编程的基石,理解了JMM,才能真正掌握并发编程的精髓。
Java内存模型(JMM)的核心概念与抽象结构
JMM的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量的底层细节。这里的变量指的是实例字段、静态字段和构成数组对象的元素,但不包括局部变量和方法参数,因为后者是线程私有的,不会被共享。
JMM规定了所有的变量都存储在主内存中。每个线程还有自己的工作内存,工作内存中保存了该线程使用到的变量的主内存副本拷贝。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。这种结构类似于现代计算机的CPU缓存架构,但JMM是一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他硬件和编译器优化。
内存间交互操作
JMM定义了8种操作来完成主内存与工作内存之间的交互,这些操作都是原子的、不可再分的。主要包括:
- lock(锁定):作用于主内存的变量,标识其为线程独占状态。
- unlock(解锁):作用于主内存的变量,释放被锁定的变量。
- read(读取):作用于主内存变量,将变量值从主内存传输到线程的工作内存。
- load(载入):作用于工作内存变量,把read操作得到的值放入工作内存的变量副本中。
- use(使用):作用于工作内存变量,将变量值传递给执行引擎。
- assign(赋值):作用于工作内存变量,把从执行引擎收到的值赋给工作内存的变量。
- store(存储):作用于工作内存变量,将变量值传送到主内存。
- write(写入):作用于主内存变量,把store操作从工作内存得到的值放入主内存变量中。
JMM通过这8种操作以及一系列规则(如不允许read/load或store/write操作单独出现)来保证内存访问的原子性和一致性。
重排序与Happens-Before规则
为了提高性能,编译器和处理器常常会对指令进行重排序。重排序分为三类:编译器优化的重排序、指令级并行的重排序和内存系统的重排序。重排序可能会导致多线程程序出现内存可见性问题。
为了给开发者提供一个强内存模型,同时允许硬件和编译器的优化,JMM引入了Happens-Before规则。Happens-Before关系是JMM最核心的概念,它用于描述两个操作之间的内存可见性。如果操作A Happens-Before 操作B,那么A操作所作的任何操作(包括写内存)对B操作都是可见的。
JMM中一些天然的Happens-Before规则包括:
- 程序顺序规则:一个线程中的每个操作,Happens-Before于该线程中的任意后续操作。
- 监视器锁规则:对一个锁的解锁 Happens-Before 于随后对这个锁的加锁。
- volatile变量规则:对一个volatile域的写 Happens-Before 于任意后续对这个volatile域的读。
- 线程启动规则:Thread对象的start()方法调用 Happens-Before 于此线程的每一个动作。
- 线程终止规则:线程中的所有操作都 Happens-Before 于其他线程检测到该线程已经终止。
- 传递性:如果A Happens-Before B,且B Happens-Before C,那么A Happens-Before C。
volatile关键字的内存语义
volatile是JVM提供的轻量级同步机制。当一个变量被定义为volatile后,它将具备两种特性:第一是保证此变量对所有线程的可见性。这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。第二是禁止指令重排序优化。
volatile的写-读内存语义如下:
- 写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
- 读一个volatile变量时,JMM会使该线程对应的本地内存无效,然后从主内存中读取共享变量。
这意味着,volatile变量的写-读与锁的释放-获取有相同的内存效果。但需要注意的是,volatile的运算在并发下不一定是线程安全的,因为Java里面的运算并非原子操作。例如,`count++`操作即使将count声明为volatile,也不能保证原子性。
锁的内存语义与synchronized的实现
锁是Java并发编程中最主要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。
当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。而当线程获取锁时,JMM会使该线程对应的本地内存无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。这与volatile的写-读内存语义相同。
synchronized关键字在JVM中的实现原理是依赖于操作系统底层的互斥锁(Mutex Lock)来实现的。这种锁称为“重量级锁”。在Java 6之后,为了减少获得锁和释放锁带来的性能开销,引入了“偏向锁”和“轻量级锁”等优化。锁的状态会随着竞争情况逐渐升级,且升级过程不可逆。
final域的内存语义
final域在Java中用于构建不可变对象,而不可变对象是线程安全的。JMM为final域提供了特殊的重排序规则,可以确保在构造函数中对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。同时,初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间也不能重排序。
这些规则保证了在引用变量为任意线程可见之前,其final域已经被正确初始化了。但需要注意的是,如果构造函数内部发生“逸出”(即this引用在构造函数结束前被其他线程使用),那么这种保证可能会被破坏。
JMM在实战中的应用与最佳实践
理解JMM的最终目的是为了编写出正确、高效的并发程序。以下是基于JMM的一些实战应用和最佳实践:
正确使用volatile
volatile适用于以下场景:1. 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。2. 变量不需要与其他的状态变量共同参与不变约束。典型的例子是作为一个状态标志位,例如:
volatile boolean shutdownRequested;public void shutdown() { shutdownRequested = true;}public void doWork() { while (!shutdownRequested) { // 业务逻辑 }}利用Happens-Before规则简化同步
理解Happens-Before规则可以帮助我们避免不必要的同步。例如,如果一个变量的写操作Happens-Before于另一个变量的读操作,那么我们就不需要额外的同步措施。在设计和阅读并发代码时,可以主动运用这些规则来分析线程安全。
安全发布对象
安全地发布一个对象,意味着对象的引用以及对象的状态必须同时对其他线程可见。安全发布常用方式有:
- 在静态初始化函数中初始化一个对象引用。
- 将对象的引用保存到volatile类型的域或者AtomicReference对象中。
- 将对象的引用保存到某个正确构造对象的final类型域中。
- 将对象的引用保存到一个由锁保护的域中。
总结
Java内存模型(JMM)是Java并发编程的核心理论基础,它抽象了线程与主内存之间的关系,定义了共享内存系统中多线程程序读写操作行为的规范。通过理解JMM的核心概念,如主内存与工作内存的交互、重排序、Happens-Before规则,以及volatile、synchronized和final等关键字的内存语义,开发者能够洞察并发程序的内在运行机制,从而编写出线程安全、高效可靠的代码。在实战中,结合JMM的原理来选择和设计同步策略,是解决并发问题的根本之道。随着Java版本的演进,JMM本身也在不断完善,但其核心思想始终是指导我们进行并发编程的明灯。
Java内存模型详解与并发实践
336

被折叠的 条评论
为什么被折叠?



