通过了解JMM模型,可以了解多线程下出现安全问题的原因,并给出解决方案。
文章目录
1. JMM模型基础
Java 线程之间的通信由 Java 内存模型(本文简称为 JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。
从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地缓存(local memory),本地内存中存储了该线程以读 / 写共享变量的副本。
本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。
1.1. JMM下的线程通讯实例
线程A、B通讯需要两步:
线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去;
线程 B 到主内存中去读取线程 A 之前已更新过的共享变量;
从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 java 程序员提供内存可见性保证。
1.2. Happens-Before 语义
JMM本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,涉及的相关方法包括:
- volatile、synchronized 和 final 三个关键字
- Happens-Before 规则
从 JDK5 开始,java 使用新的 JSR -133 内存模型。JSR-133 提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。
happens-before表示前面一个操作的结果对后续操作是可见的。
Happens-Before 规则
规则 | 表述 |
---|---|
1. 程序的顺序性规则 |
程序前面对某个变量的修改一定是对后续操作可见的。
|
2. volatile 变量规则 |
对一个 volatile 变量的写操作相对于后续对这个变量的读操作可见
|
3. 传递性 |
如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。
|
4. 管程中锁的规则 |
锁的解锁 Happens-Before 于后续对这个锁的加锁,即解锁前的操作对后续加锁都是可加见的
|
5. 线程 start() 规则 |
指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程A在启动子线程 B 前的操作。
|
6. 线程 join() 规则 |
(A调用了子线程 B 的 join() )主线程 A 等待子线程 B 完成,当子线程 B 完成后,主线程能够看到子线程(对共享变量)的操作。
|
7. 线程中断规则 |
对线程interrupt()方法的调用 先发生于 被中断线程检测到中断事件的发生
|
Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。
看一个栗子
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
// 这里x会是多少呢?
//如果在 1.5 以上的版本上运行,x 就是等于 42。
//如果低于1.5 可能是0 可能是42
}
}
}
规则 1 : “x=42” 先于 “v=true” 操作;
规则 2 : 当“v=true”时,后续会读到v的值;
规则 3 : 如果线程B 读到了“v=true”,那么线程 A 设置的“x=42”对线程 B 也是可见的。
2. volatile:可见性与防止指令重排
volatile 关键字最原始的意义就是禁用 CPU 缓存。
例如,我们声明 volatile int x = 0,它表达的是:告诉编译器,对这个变量的读写,不能使用 CPU 缓存,必须从内存中读取或者写入。
详见之前的文章
java并发关键字:volatile深入浅出:可见性、防止指令重排
3. 管程 与 线程同步
管程是一种通用的同步原语,synchronized 是 Java 里对管程的实现。
看一个栗子:
synchronized (this) { //此处自动加锁
// x是共享变量,初始值=10
if (this.x < 12) {
this.x = 12;
}
} //此处自动解锁
管程中锁的规则可以这样理解:假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 12(执行完自动释放锁),线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x==12。
详见之前的文章
关键字: synchronized详解
4. final域 与 指令重排
对于基础类型
看一个栗子:
public class FinalDemo {
private int a; //普通域
private final int b; //final域
private static FinalDemo finalDemo;
public FinalDemo() {
a = 1; // 1. 写普通域
b = 2; // 2. 写final域
}
public static void writer() {
finalDemo = new FinalDemo();
}
public static void reader() {
FinalDemo demo = finalDemo; // 3.读对象引用
int a = demo.a; //4.读普通域
int b = demo.b; //5.读final域
}
}
看下final的规则:
禁止对final域的写重排序到构造函数之外,具体的:
编译器会在final域写之后,构造函数return之前,插入一个storestore屏障。这个屏障可以禁止处理器把final域的写重排序到构造函数之外。
假设线程A执行writer,线程B执行reader:可能发生的情况是:
普通变量 a 可能“溢出”:(因为可能会被重排序到构造函数之外,此时线程B就有可能读到的是普通变量a初始化之前的值(零值))。
根据重排序规则,final域变量 b 不会被重排序,会在构造函数中赋值,这样线程B就能够读到初始化后的值。
对于引用类型
看一个栗子:
public class FinalReferenceDemo {
final int[] arrays;
private FinalReferenceDemo finalReferenceDemo;
public FinalReferenceDemo() {
arrays = new int[1]; //操作1
arrays[0] = 1; //操作2
}
public void writerOne() {
finalReferenceDemo = new FinalReferenceDemo(); //操作3
}
public void writerTwo() {
arrays[0] = 2; //4
}
public void reader() {
if (finalReferenceDemo != null) { //5
int temp = finalReferenceDemo.arrays[0]; //6
}
}
}
final域的引用变量增加了一些约束:
在构造函数内对一个final修饰的对象的成员域写入(操作1,2),与随后在构造函数之外:把这个被构造的对象引用赋给一个引用变量(操作3),这两个操作是不能被重排序的。
假设:线程A执行wirterOne方法,执行完后线程B执行writerTwo方法,然后线程C执行reader方法。
由于对final域的写禁止重排序到构造方法外,因此1和3不能被重排序。由于一个final域的引用对象的成员域写入不能与随后将这个被构造出来的对象赋给引用变量重排序,因此2和3不能重排序。
不可变的对象一定是线程安全的
不可变(Immutable)的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。
不可变的类型:
- final 关键字修饰的基本数据类型
- String
- 枚举类型
- Number 部分子类,如 Long 和 Double等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。但同为 Number 的原子类 AtomicInteger和 AtomicLong 则是可变的。
参考:
https://pdai.tech/md/java/jvm/java-jvm-jmm.html
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/65375.html