JMM
Java内存模型描述了Java程序中各种变量(共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取这些变量的底层细节。
- 主存:所有共享变量都保存在主存中。
- 工作内存:每个线程都有自己独立的工作内存,里面保存该线程使用到的变量的副本。
两条规定:
- 线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接从主存中读写。
- 不同线程的工作内存之间无法直接相互访问,线程之间的变量传递,必须通过主存来完成。
在开始并发编程时,我们需要思考两个关键的问题:1.线程之间如何通信?2.线程之间如何同步?
线程通信
在命令式编程中,线程之间有两种通信方式:
- 共享内存:线程之间存在公共状态,线程间通过独写内存中的公共状态来隐式进行通信。
- 消息传递:线程之间没有公共状态,需要发送消息来进行显式通信。
线程同步
同步是指程序用于控制线程发生相对顺序执行的机制。在共享内存模型里,程序员需要给代码加上制定的互斥操作来显式进行;在消息传递模型中,通信是对程序员透明的,是隐式进行的。
可见性
所有的实例域、静态域、数组元素是储存在堆中的,线程之间可以共享,可以将它们称为“共享变量”,他们可能会在并发编程时出现“可见性”问题;而局部变量、方法参数、异常处理参数不会在线程之间共享,不受内存模型的影响。
假如一个变量被多个线程使用到,那么这个共享变量会在多个线程的工作内存中都存在副本。
当一个共享变量被一个线程修改,能够及时被其他线程看到,这叫做可见性。
要实现可见性,需要保证两点:
- 共享变量被修改后,能及时刷新到主存中去。
- 其他线程能及时将主存中更新的信息刷新到自己的工作内存中。
重排序
as-if-serial语义:
- 无论怎么重排序,程序执行的结果必须是与未排序情况下一致的。(Java保证在单线程情况下遵循词语义)
- 多线程中程序交错执行时,重排序可能会导致内存可见性问题。
数据依赖性
如果两个操作访问同一个变量,而且这两个操作中有一个为写操作,那么这两个操作之间就存在了数据依赖性。数据依赖性存在以下三种情况:
操作 | 示例 |
---|---|
先写,后读 | a=1;b=a; |
先写,后写 | a=1;a=2; |
先读,后写 | b=a;a=1; |
不难发现,上面的三种情况,只要重排序其指令,结果都会产生变化。
所以编译器和处理器在进行重排序时,必须遵守数据依赖性。不能对存在数据依赖性的两个操作进行重排序。
控制依赖性
看下面一段代码
if(flag){ //操作1
int num=a+b; //操作2
}
复制代码
可以看到,操作1和操作2并不存在数据依赖,但是存在控制依赖。当代码中出现控制依赖时,会影响程序的并行度。因此,编译器和处理器会采用一种“猜测执行”来克服控制依赖性对并行度的影响(并行是为了效率和性能)处理器可能提前执行操作2,将a+b计算出来,并放置到一个叫“重排序缓存”的缓存中,假如得知操作1中的flag为真,再将结果写入num中。
在单线程程序中,对存在控制依赖关系的重排序,不会影响结果;不过在多线程中,可能会影响到结果。
指令重排序:
实际执行的代码顺序和程序员书写的顺序是不一样的,编译器或处理器为了提高性能,在不影响程序结果的前提下,会进行执行顺序的优化。
- 编译器优化重排序(编译器)
- 指令级并行重排序(处理器)
- 内存系统重排序(处理器)
导致不可见的原因:
- 线程的交叉执行(原子性问题)
- 重排序结合线程交叉执行(原子性问题)
- 共享变量更新后的值,没有在工作内存和主存之间得到及时的更新。(可见性问题)
synchronized实现可见性
原子性:
通过互斥锁来实现。
可见性:
- 线程解锁前,必须把共享变量的最新值刷新到主存中去。
- 线程加锁时,会清空当前工作内存中共享变量的值,从主存中重新独取最新的值。
流程:
- 获得互斥锁
- 清空工作内存
- 从主存拷贝共享变量的最新副本到工作内存
- 执行代码
- 将更改后的共享变量的值刷新到主存
- 释放互斥锁
volatile实现可见性
能够保证volatile变量的可见性,但是不能保证volatile变量复合操作的原子性。 volatile
通过加入内存屏障和禁止指令重排序来实现可见性的。对volatile
变量执行写操作时,会在写入后加一条store
的屏障指令;对volatile
变量执行读操作时,会在读操作前加入一条load
屏障指令。
处理器级别的重排序与内存屏障指令
由于处理器的速度很快,为了避免处理器停顿下来等待内存(内存肯定跟不上处理器的速度)而产生的延迟,现代的处理器使用缓存区来临时保存处理器向内存写入的数据,然后再提供给内存,以保证连续不断地高效运行。
虽然缓存区存在诸多好处,但是它是仅对处理器可见的。这将会产生一个重要的问题:处理器堆内存的独写操作的顺序,可能与内存中实际发生读写操作顺序不一致。(因为现代的处理器大都允许使用重排序)
所以,为了保证可见性,Java编译器会在生成指令序列时,插入内存屏障来禁止特定的处理器进行重排序。
线程写入volatile
变量的过程:
- 改变线程工作内存中
volatile
变量副本的值 - 将改变后的副本的值从工作内存刷新到主存
线程读volatile
变量的过程:
- 从主存中独取
volatile
变量的最新的值到工作内存中。 - 从工作内存中独取变量的副本
volatile
不能保证volatile
变量符合操作的原子性:
举一个例子:
public class VolatileDemo{
private volatile int num=0;
public int getNumber(){
return this.num;
}
public void increase(){
this.num++;
}
public static void main(String[] args){
final VolatileDemo v=new VolatileDemo();
for(int i;i<500;i++){
new Thread(new Runnable(){
public void run(){
v.increase();
}
}).start();
}
//主线程主动让出资源让500个子线程运行。这个‘1’指的是主线程
while(Thread.activeCount()>1){
Thread.yield();
}
System.out.println(v.getNumber());
}
}
复制代码
这个程序是开启500个线程,每个线程执行一次increase()
操作,给变量num
加一。运行这个程序多次,发现并不是每次输出结果都是500。
发生了什么问题?
因为this.num++
这条语句,其实是三步操作,不具备原子性。假设一个运行场景:
- 此时num=1
- 线程A读取num为1,A工作内存中num=1
- 线程B独取num为1,B工作内存中num=1
- 线程B进行加1操作,写入B工作内存,B工作内存中num=2,更新到主存,主存中num=2.
- 线程A进行加1操作,写入A工作内存,A工作内存中num=2, 更新到主存,主存中num=2.
可见,进行了两次加1操作,但是主存中的num只增加了1。怎么解决呢?我们要保证num自增操作的原子性。
volatile注意事项
- 对变量的写入操作不能依赖当前值。
- 该变量没有包含在具有其他变量的不变式中。
两者比较
- volatile 不需要加锁,比synchronized更轻量级,不会阻塞线程。
- 从可见性角度讲,volatile读相当于加锁,写相当于解锁。(前文提到的屏障指令)
- synchronized可以保证原子性和可见性,volatile只保证了可见性。