文章目录
主内存与工作内存
在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步 。
Java虚拟机规范定义了一种Java内存模型(Java Memory Model,JMM)来屏蔽各种硬件和操作系统的内容访问差异,以实现让java程序在各种平台下都能达到一致的内存访问效果 。
Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化 的方法。具体来说,这些方法包括 volatile
、synchronized
和 final
三个关键字,以及六项 Happens-Before 规则
在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享 ("共享变量"这个术语代指实例域,静态域和数组元素)。局部变量,方法定义参数和异常处理器参数不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
Java内存模型规定了所有的变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件时提到的主内存名字一样,两者也可以类比,但物理上它仅是虚拟机内存的一部分)。每条线程 还有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本(如"假设线程中访问一个10MB大小的对象,也会把 这10MB的内存复制一份出来吗?",事实上并不会如此,这个对象的引用、对象中某个在线程访问到的字段是有可能被复制的,但不会有虚拟机把整个对象复制一次。),
线程对变量的所有操作(读取、赋值等)都必须在工作内存 中进行,而不能直接读写主内存中的数据(根据《Java虚拟机规范》的约定,volatile变量依然有工作内存的拷贝,但是由于它特殊的操作顺序 性规定(后文会讲到),所以看起来如同直接在主内存中读写访问一般,因此这里的描述对于volatile 也并不存在例外。)。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
java内存模型主要体现在三个方面:
- 原子性---保证指令不会受到线程上下文切换的影响
- 可见性---保证指令不会受 cpu 缓存的影响
- 重排序---保证指令不会受 cpu 指令并行优化的影响
那么,关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存呢?
内存间交互操作
关于主内存与工作内存之间具体的交互协议, 即一个变量如何从主内存拷贝到工作内存、 如何从工作内存同步回主内存这一类的实现细节, Java内存模型中定义了以下8种操作来完成。 Java虚拟机实现时必须保证下面提及的每一种操作都是原子的、 不可再分的。
- lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态
- unlock(解锁) :作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
- read(读取) :作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
- load(载入) :作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
- use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量
- store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作
- write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中
同步规则分析:
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或者assign)的变量。即就是对一个变量实施use和store操作之前,必须先自行assign和load操作
- 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新执行load或assign操作初始化变量的值。
- 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)
这8种内存访问操作以及上述规则限定, 再加上专门针对volatile的一些特殊规定, 就已经能准确地描述出Java程序中哪些内存访问操作在并发下才是安全的。 这种定义相当严谨, 但也是极为烦琐, 实践起来更是无比麻烦。
Java内存模型是围绕着在并发过程中如何处理原子性、 可见性和有序性这三个特征来建立的, 我们逐个来看一下哪些操作实现了这三个特性。
原子性(Atomicity)
由Java内存模型来直接保证的原子性变量操作包括read、 load、 assign、 use、 store和write这六个,我们大致可以认为, 基本数据类型的访问、 读写都是具备原子性的
如果应用场景需要一个更大范围的原子性保证(经常会遇到) , Java内存模型还提供了lock和unlock操作来满足这种需求, 尽管虚拟机未把lock和unlock操作直接开放给用户使用, 但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作。 这两个字节码指令反映到Java代码中就是同步块------synchronized关键字, 因此在synchronized块之间的操作也具备原子性。
可见性(Visibility)
可见性就是指当一个线程修改了共享变量的值时, 其他线程能够立即得知这个修改。
Java内存模型是通过在变量修改后将新值同步回主内存, 在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的, 无论是普通变量还是volatile
变量都是如此。 普通变量与volatile
变量的区别是, volatile
的特殊规则保证了新值能立即同步到主内存, 以及每次使用前立即从主内存刷新。 因此我们可以说volatile
保证了多线程操作时变量的可见性, 而普通变量则不能保证这一点。
java
@Slf4j(topic = "c.Visible")
public class Visible {
// static boolean runCondition = true;
static boolean runCondition = true;
public static void main(String[] args) {
Thread t = new Thread(() -> {
while (true) {
// System.out.println(runCondition);
if (!runCondition) {
log.debug("子线程while循环结束");
break;
}
}
});
t.start();
Sleeper.sleep(1);
runCondition = false;
Sleeper.sleep(1);
log.debug("结束。。。");
}
}
运行上面代码,子线程并不能看到runCondition
值得修改。
volatile实现可见性
java
@Slf4j(topic = "c.Visible")
public class Visible {
static volatile boolean runCondition = true;
// static boolean runCondition = true;
public static void main(String[] args) {
Thread t = new Thread(() -> {
while (true) {
// System.out.println(runCondition);
if (!runCondition) {
break;
}
}
});
t.start();
Sleeper.sleep(1);
runCondition = false;
log.debug("结束。。。");
}
}
除了volatile
之外,synchronized
关键字能实现可见性,synchronized
同步块的可见性是由"对一个变量执行unlock操作之前, 必须先把此变量同步回主内存中(执行store、 write操作) "这条规则获得的。 (以保证任一时刻只有一个线程能访问共享资源,并在其释放锁之前将修改的变量刷新到内存中 )。
顺序性(Ordering)
Java程序的有序性可以总结为一句话: 如果在本线程内观察, 所有的操作都是有序的; 如果在一个线程中观察另一个线程,所有的操作都是无序的。 前半句是指"线程内似表现为串行的语义(as-if-serial)"(Within-Thread As-If-Serial Semantics ) , 后半句是指"指令重排序"现象和"工作内存与主内存同步延迟"现象。
Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性, volatile关键字本身就包含了禁止指令重排序的语义, 而synchronized则是由"一个变量在同一个时刻只允许一条线程对其进行lock操作"这条规则获得的, 这个规则决定了持有同一个锁的两个同步块只能串行地进入。
指令重排
Java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。指令重排序的作用是什么?JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。
从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序:
java中如何禁止指令重排呢?volatile和synchronized都可以实现
as-if-serial
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
Happens-Before 原则
如果Java内存模型中所有的有序性都仅靠volatile
和synchronized
来完成, 那么有很多操作都将会变得非常啰嗦, 但是我们在编写Java并发代码的时候并没有察觉到这一点, 这是因为Java语言中有一个"先行发生"(Happens-Before) 的原则。 这个原则非常重要, 它是判断数据是否存在竞争, 线程是否安全的非常有用的手段。 依赖这个原则, 我们可以通过几条简单规则一揽子解决并发环境下两个操作之间是否可能存在冲突的所有问题, 而不需要陷入Java内存模型苦涩难懂的定义之中。
"先行发生"原则指的是什么? 先行发生是Java内存模型中定义的两项操作之间的偏序关系, 比如说操作A先行发生于操作B, 其实就是说在发生操作B之前, 操作A产生的影响能被操作B观察到, "影响"包括修改了内存中共享变量的值、 发送了消息、 调用了方法等。
下面是Java内存模型下一些"天然的"先行发生关系, 这些先行发生关系无须任何同步器协助就已经存在, 可以在编码中直接使用。 如果两个操作之间的关系不在此列, 并且无法从下列规则推导出来, 则它们就没有顺序性保障, 虚拟机可以对它们随意地进行重排序。
程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作先行 发生于书写在后面的操作。(代码顺序)
管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这 里必须强调的是"同一个锁",而"后面"是指时间上的先后。(同一把锁,加锁必须在解锁之后)
volatile变量规则(Volatile Variable Rule) :对一个volatile变量的写操作先行发生于后面对这个变量 的读操作,这里的"后面"同样是指时间上的先后。(volatile变量在每次被线程访问时,都强迫从主内存中读该变量的
值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。 )
线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。(如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见 )
java
public class ThreadStartRule {
static int x = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
System.out.println(x); // 打印的是10
}, "t2");
x = 10;
t1.start();
}
}
线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程 的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生。
对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize()方法的开始。
传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出 操作A先行发生于操作C的结论。
volatile内存语义
volatile
是Java虚拟机提供的轻量级的同步机制。volatile
关键字有如下两个作用
- 保证被
volatile
修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile
修饰共享变量的值,新值总是可以被其他线程立即得知。 - 禁止指令重排序优化。
内存屏障
先了解一个概念,内存屏障(MemoryBarrier)。
Intel硬件提供了一系列的内存屏障,主要有:
- lfence,是一种Load Barrier 读屏障
- sfence, 是一种Store Barrier 写屏障
- mfence, 是一种全能型的屏障,具备ifence和sfence的能力
- Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD,ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR,SBB, SUB, XOR, XADD, and XCHG等指令
不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。 JVM中提供了四类内存屏障指令:
内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。
volatile如何保证可见性
volatile实现可见性的两个重要点:
- 对 volatile 变量的写指令后会加入写屏障
- 对 volatile 变量的读指令前会加入读屏障
但volatile并不能保证操作的原子性(如下面race++操作并不是原子性的,最终得到的结果是不确定的)
java
public class VolatileTest {
public static volatile int race = 0;
public static void increase() {
race++;
}
private static final int THREAD_COUNT = 20;
public static void main(String[] args) {
Thread[] threads = new Thread[THREAD_COUNT];
for (int i = 0; i < THREAD_COUNT; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
increase();
}
});
threads[i].start();
}
while (Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(race);
}
}
使用volatile变量的第二个语义是禁止指令重排序优化 ,普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在同一个线程的方法执行过程中无法感知到这点,这就是Java内存模型中描述的 所谓"线程内表现为串行的语义"(Within-Thread As-If-Serial Semantics)。
volatile如何保证有序性
volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象。
下图是JMM针对编译器制定的volatile重排序规则表。
从上表可以看出:
- 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
- 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
- 当第一个操作是volatile写,第二个操作是volatile读或写时,不能重排序。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
volatile写
上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。
下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图
上图中StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。
这里比较有意思的是,volatile写后面的StoreLoad屏障。此屏障的作用是避免volatile写与 后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面 是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)。为了保证能正确 实现volatile的内存语义,JMM在采取了保守策略:在每个volatile写的后面,或者在每个volatile 读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑,JMM最终选择了在每个 volatile写的后面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个 写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里可以看到JMM 在实现上的一个特点:首先确保正确性,然后再去追求执行效率(读多写少)。
volatile读
下图是在保守策略下,volatile读插入内存屏障后生成的指令序列示意图
上图中LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
DCL作用举例
DCL,及双重检查加锁(Double-checked Locking),经常用在创建单例对象方面,看下面具体代码
java
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
上面的instance
变量中是没有使用volatile
变量进行修饰的。在并发的情况下,new Singletone()
就会出现问题。
正常来说,new操作会出现以下3个步骤
- 分配一块内存M
- 在内存M上初始化Singleton对象
- 将M的地址赋值给
instance
变量
但经过编译器进行重排后,有可能出现以下执行顺序
- 分配一块内存M
- 将M的地址赋值给
instance
变量 - 最后在内存M上初始化Singleton对象
重排序后会出现什么问题?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。