Java内存模型及并发三大特性
欢迎关注我的公众号:观知小阁。包含各种类的文章,内容更丰富,更新及时且不迷路。
为什么你的多线程代码总出现诡异问题?可能这就是根源所在
在现代软件开发中,多线程编程已成为提升程序性能的重要手段。
然而,许多开发者在踏入多线程编程领域时,都会遇到各种看似诡异的问题:数据不一致、死锁、结果不可预测等。
这些问题的根源,往往与Java内存模型(Java Memory Model,JMM)密切相关。
1. 什么是Java内存模型?
Java内存模型(JMM)是Java虚拟机规范的重要组成部分,它定义了Java程序中各种变量(线程共享的变量)的访问规则,以及在并发环境下如何保证数据的可见性、有序性和原子性。
JMM是一种抽象规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。它规定了如何做数据同步以及什么时候做数据同步。
有了JMM,Java程序在各种平台下对内存的访问都能保证效果一致。
2. 为什么需要内存模型?
要理解JMM的重要性,我们需要先了解并发编程中的三个核心挑战:
2.1 可见性问题
可见性是指当一个线程修改了共享变量的值后,其他线程能够立即看到这个修改。
在没有适当同步的情况下,一个线程对共享变量的修改可能不会立即对其他线程可见,原因包括:
-
CPU多级缓存的存在:每个CPU核心可能有自己的缓存,线程可能读取到过期的缓存值
-
编译器优化:编译器可能会对代码进行重排序,改变指令的执行顺序
-
处理器优化:现代处理器会采用乱序执行等技术提高性能

2.2 原子性问题
原子性是指一个操作要么完全执行,要么完全不执行,中间状态不会被其他线程看到。
典型的原子性问题就是著名的i++问题。这个操作看似简单,实际上包含三个步骤:
-
从内存读取i的值
-
将值加1
-
将新值写回内存
在没有同步的情况下,两个线程可能同时执行i++,但最终结果可能只增加了1,而不是预期的2。

2.3 有序性问题
有序性指的是程序按照代码的先后顺序执行。为了提高性能,编译器和处理器可能会对指令进行重排序。
在单线程环境下,重排序不会影响最终结果,但在多线程环境下,可能会导致意想不到的问题。
3. JMM的核心概念
3.1 主内存与工作内存
JMM将内存分为两大类:
-
主内存:所有线程共享的内存区域,存储共享变量
-
工作内存:每个线程独有的内存区域,保存该线程使用到的变量的主内存副本
JMM规定:所有线程对变量的操作都必须在工作内存中进行,不能直接读写主内存。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递需要通过主内存来完成。
3.2 Happens-Before原则
Happens-Before是JMM中核心的概念之一,它定义了两个操作之间的偏序关系,从而保证内存可见性。
主要规则包括:
-
程序顺序规则:一个线程内,按照代码顺序,前面的操作Happens-Before于后面的操作
-
监视器锁规则:对一个锁的解锁Happens-Before于后续对这个锁的加锁
-
volatile变量规则:对一个volatile变量的写操作Happens-Before于后续对这个变量的读操作
-
线程启动规则:Thread对象的start()方法调用Happens-Before于该线程内的任何操作
-
线程终止规则:线程中的所有操作Happens-Before于其他线程检测到该线程已经终止
-
传递性:如果A Happens-Before B,且B Happens-Before C,那么A Happens-Before C
4. 如何保证并发安全?
JMM提供了一系列机制来保证并发编程的安全性:
4.1 synchronized关键字
synchronized是Java中最基本的同步机制,它提供了一种互斥锁的功能。
-
原子性:synchronized块中的操作具有原子性,同一时刻只有一个线程可以执行
-
可见性:当线程退出synchronized块时,对共享变量的修改会刷新到主内存
-
有序性:synchronized通过限制线程互斥执行来保证有序性
4.2 volatile关键字
volatile是一种比synchronized更轻量的同步机制。
-
可见性:volatile 变量的修改会立即刷新到主内存,其他线程读取时会直接从主内存获取最新值
-
有序性:volatile 会禁止指令重排序,保证代码执行顺序
-
局限性:volatile 不能保证复合操作的原子性
4.3 final关键字
final关键字也可以用于保证可见性:当一个包含final字段的对象被正确构造后,其他线程可以看到final字段的正确初始化值。
4.4 内存屏障
内存屏障是JMM实现可见性和有序性的底层机制,它是一种特殊的处理器指令,可以阻止指令重排序并强制刷新内存。
主要类型包括:
-
LoadLoad屏障:确保Load1的数据加载在Load2及后续加载操作之前完成
-
StoreStore屏障:确保Store1的数据对其他处理器可见先于Store2及后续存储操作
-
LoadStore屏障:确保Load1的数据加载在Store2及后续存储操作之前完成
-
StoreLoad屏障:确保Store1的数据对其他处理器可见先于Load2及后续加载操作
5. 实战案例:单例模式的双重检查锁定
双重检查锁定是单例模式的一种常见实现方式,但如果不了解JMM,很容易写出错误的代码:
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized(Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton();
}
}
}
return instance;
}
}
这段代码看起来正确,但实际上存在严重问题:指令重排序可能导致其他线程看到未完全初始化的对象。
问题出在instance = new Singleton();这行代码,它实际上包含三个步骤:
-
分配对象内存空间
-
初始化对象
-
将instance引用指向内存地址
编译器或处理器可能将步骤2和3重排序,导致其他线程在第一次检查时看到instance不为null,但实际对象还未初始化完成。
解决方案:使用volatile关键字
public class Singleton {
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
volatile关键字会禁止指令重排序并保证可见性,确保对象完全初始化后,instance引用才被赋值。
6. 总结
Java内存模型是Java并发编程的基石,它通过定义一套规范,使得Java程序在不同平台上都能有一致的行为表现。理解JMM对于编写正确、高效的多线程程序至关重要。
关键要点总结:
-
三大核心:可见性、原子性、有序性是并发编程的三大挑战,JMM提供了解决这些问题的机制
-
内存划分:JMM将内存分为主内存和工作内存,规定线程通过工作内存与主内存交互
-
Happens-Before:理解Happens-Before原则是掌握JMM的关键
-
同步工具:synchronized、volatile、final等关键字是JMM提供给开发者的同步工具
-
实战应用:在实际开发中,要特别注意指令重排序等隐蔽问题,合理使用同步机制
掌握Java内存模型,不仅可以帮助我们避免多线程编程中的各种陷阱,还能写出更高效、更可靠的并发代码。
更多推荐


所有评论(0)