Java内存模型
在并发编程中,多个线程之间采用什么机制通信(信息交换),什么机制进行数据的同步?
为了解决这些疑惑,就很有必要了解java的内存模型,在Java语言中,采用的是共享内存模型来实现多线程之间的信息交换和数据同步的。
线程之间通过共享程序公共的状态,通过读-写内存中公共状态的方式来进行隐式的通信。同步指的是程序在控制多个线程之间执行程序的相对顺序的机制,在共享内存模型中,同步是显式的,程序员必须显式指定某个方法/代码块需要在多线程之间互斥执行。
首先我们先回顾以下java虚拟机的内存结构:
- PC寄存器/程序计数器
严格来说是一个数据结构,用于保存当前正在执行的程序的内存地址,可以简单了理解为当前线程所执行的字节码的行号指示器。当有多个线程交叉执行时,被中断的线程的程序当前执行到哪条内存地址必然要保存下来,以便用于被中断的线程恢复执行时再按照被中断时的指令地址继续执行下去。为了线程切换后能恢复到正确的执行位置,每个线程都需要有一个独立的程序计数器,各个线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存,这在某种程度上有点类似于“ThreadLocal”,是线程安全的。 - Jvm栈Java stack
Java栈是线程私有的,每当创建一个线程,JVM就会为该线程创建对应的java栈,在这个java栈中又会包含多个栈帧(stack frame),这些栈帧是和每个方法关联起来的,每运行一个方法就创建一个栈帧,每个栈帧会包含一些局部变量、操作栈、和方法返回值等信息。由于Java栈是与线程对应起来的,Java栈数据不是线程共有的,所以不需要关心其数据一致性,也不会存在同步锁的问题。
每当一个方法执行完成时,该栈帧就会弹出栈帧的元素作为这个方法的返回值,并且清除这个栈帧,Java栈的栈顶的栈帧就是当前正在执行的活动栈,也就是当前正在执行的方法,PC寄存器也会指向该地址。只有这个活动的栈帧的本地变量可以被操作栈使用,当在这个栈帧中调用另外一个方法时,与之对应的一个新的栈帧被创建,这个新创建的栈帧被放到Java栈的栈顶,变为当前的活动栈。同样现在只有这个栈的本地变量才能被使用,当这个栈帧中所有指令都完成时,这个栈帧被移除Java栈,刚才的那个栈帧变为活动栈帧,前面栈帧的返回值变为这个栈帧的操作栈的一个操作数。
在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。在Hot Spot虚拟机中,可以使用-Xss参数来设置栈的大小。栈的大小直接决定了函数调用的可达深度。 - 堆Heap
Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例。这一点Java虚拟机规范中描述是:所有的对象实例以及数组都要在堆上分配。Java堆是GC管理的主要区域,从内存回收的角度来看,由于现在GC基本都采用分代收集算法,所以Java堆还可以细分为:新生代和老年代;新生代再细致一点有Eden空间、From Survivor空间、To Survivor空间等。
- 方法区Method Area
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息(名称、修饰符等)、常量、静态常量、即时编译器编译后的代码等数据。不像Java堆中其他部分一样会频繁被GC回收,它存储的信息相对比较稳定,在一定条件下会被GC。方法区也属于堆中的一部分,通常所说的永久代Permanet Generation,就是这。可以通过-XX:PermSize指定初始值,-XX:MaxPermSize指定最大值。 - 常量池Constant Pool
常量池属于方法区的一部分,常量池中存储了如字符串、final变量值、类名和方法名常量,在编译期间就被确定,并保存在已编译的.class文件中。一般分为两类:字面量和应用量。字面量就是字符串、final变量等。类名和方法名属于引用量。引用量最常见的是在调用方法的时候,根据方法名找到方法的引用,并以此定为到函数体进行函数代码的执行。引用量包含:类和接口的权限定名、字段的名称和描述符,方法的名称和描述符。 - 本地方法栈Natice Method Stack
本地方法栈和Java栈所发挥的作用非常相似,区别不过是Java栈为JVM执行Java方法服务,而本地方法栈为JVM执行Native方法服务。
主内存与工作内存
Java内存模型的主要目标是定义程序中各个变量的访问规则,即在JVM中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量与Java编程里面的变量有所不同步,它包含了实例字段、静态字段和构成数组对象的元素,但不包含局部变量和方法参数,因为后者是线程私有的,不会共享,当然不存在数据竞争问题(如果局部变量是一个reference引用类型,它引用的对象在Java堆中可被各个线程共享,但是reference引用本身在Java栈的局部变量表中,是线程私有的)。
JMM规定了所有的变量都存储在主内存(Main Memory)中。每个线程还有自己的工作内存(Working Memory),线程的工作内存中保存了该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量(volatile变量仍然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般)。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成。
例如线程A与线程B之间要通信的化,必须要经历2个步骤:
- 线程A把本地内容A中更新过的共享变量刷新到主存中去
- 线程B到主存中去读取线程A之前已经更新过的共享变量
指令重排序 :为了提高性能,编译器和处理器常常会对指令做重排序,也就是在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。这些重排序可能会导致多线程程序出现内存可见性问题。
重排序对多线程的影响,例如:
class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; //1
flag = true; //2
}
public void reader() {
if (flag) { //3
int i = a * a; //4
}
}
}
假设两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法,如果操作1和操作2做了重排序,线程A首先写标记变量flag,随后线程B读这个变量。由于条件为真,线程B读取变量a,此时变量a还没有被线程A写入。
所以可以得出结论,在单线程程序中,对存在控制依赖的操作重排序不会改变执行结果,但在多线程中,对存在控制以来的操作重排序,可能会改变程序的执行结果。
happens-before原则:
如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。也就是说happens-before仅仅要求前一个操作的(执行的结果)对后一个操作可见。
例如:1.程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作
2.监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁
3.volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读
4.传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
需要注意的是,两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行。所以时间上的先后顺序与happens-before原则之间没有太大的关系,所以衡量并发安全问题一切必须以happens-before 原则为准。
JMM的设计思想:程序员希望基于一个强内存模型来编写代码,编译器和处理器希望实现一个弱内存模型利于优化。设计JMM的核心目标就是找到一个平衡点:一方面要为程序员提供足够强的内存可见性的保证,另一方面,对编译器和处理器的限制要尽可能的放松。如此happens-before应运而生。
JMM把happens-before禁止到重排序分为了两类:会改变程序执行结果的重排序,以及不会改变程序执结果的重排序。
JMM对于这两种不同性质的重排序采用了不同的策略:对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序,对于不改变程序结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种重排序)。
JMM其实在遵循一个基本原则:只要不改变程序的执行结果,编译器和处理器想怎么优化都可以。
Java内存模型是围绕着并发编程中原子性、可见性、有序性这三个特征来建立的,那我们依次看一下这三个特征:
原子性(Atomicity):一个操作不能被打断,要么全部执行完毕,要么不执行。在这点上有点类似于事务操作,要么全部执行成功,要么回退到执行该操作之前的状态。
基本数据类型的读写大都是原子操作,但long和double类型的变量是64为的,但是在32为的JVM中会分两次来进行读写,所以在并发访问的时候是非线程安全的。
可见性:一个线程对共享变量做了修改之后,其他线程立即能够看到该变量这种修改
Java内存模型是通过将在工作内存中的变量修改后的值同步到主内存,在读取变量前从主内存刷新最新值到工作内存中,这种依赖主内存的方式来实现可见性的。
无论是普通变量还是volatile变量都是如此,区别在于:volatile的特殊规则保证了volatile变量值修改后的新值立刻同步到主内存,每次使用volatile变量前立即从主内存中刷新,因此volatile保证了多线程之间的操作变量的可见性,而普通变量则不能保证这一点。
除了volatile关键字能实现可见性之外,还有synchronized,Lock,final也是可以的。
使用synchronized关键字,在同步方法/同步块开始时(Monitor Enter),使用共享变量时会从主内存中刷新变量值到工作内存中(即从主内存中读取最新值到线程私有的工作内存中),在同步方法/同步块结束时(Monitor Exit),会将工作内存中的变量值同步到主内存中去(即将线程私有的工作内存中的值写入到主内存进行同步)。
使用Lock接口的最常用的实现ReentrantLock(重入锁)来实现可见性:当我们在方法的开始位置执行lock.lock()方法,这和synchronized开始位置(Monitor Enter)有相同的语义,即使用共享变量时会从主内存中刷新变量值到工作内存中(即从主内存中读取最新值到线程私有的工作内存中),在方法的最后finally块里执行lock.unlock()方法,和synchronized结束位置(Monitor Exit)有相同的语义,即会将工作内存中的变量值同步到主内存中去(即将线程私有的工作内存中的值写入到主内存进行同步)。
final关键字的可见性是指:被final修饰的变量,在构造函数数一旦初始化完成,并且在构造函数中并没有把“this”的引用传递出去(“this”引用逃逸是很危险的,其他的线程很可能通过该引用访问到只“初始化一半”的对象),那么其他线程就可以看到final变量的值。
有序性:对于一个线程的代码而言,我们总是以为代码的执行是从前往后的,依次执行的。这么说不能说完全不对,在单线程程序里,确实会这样执行;但是在多线程并发时,程序的执行就有可能出现乱序。用一句话可以总结为:在本线程内观察,操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行语义(WithIn Thread As-if-Serial Semantics)”,后半句是指“指令重排”现象和“工作内存和主内存同步延迟”现象。
Java提供了两个关键字volatile和synchronized来保证多线程之间操作的有序性,volatile关键字本身通过加入内存屏障来禁止指令的重排序,而synchronized关键字通过一个变量在同一时间只允许有一个线程对其进行加锁的规则来实现,
在单线程程序中,不会发生“指令重排”和“工作内存和主内存同步延迟”现象,只在多线程程序中出现。