Java内存模型-JMM

248 阅读11分钟

计算机原理

现代CPU指令速度远超内存存取的速度,所以在现代计算机模型当中引入存取速度接近CPU的高速缓存,来当作CPU和内存之间的缓冲。

  1. 将运算所需要的数据复制到内存
  2. 将缓存中的数据读取到CPU进行计算
  3. 将CPU计算结果存到缓存
  4. 将缓存的内容写回内存

计算机物理模型带来的问题

缓冲一致性问题

虽然缓存很好的解决来高速CPU和龟速内存之间的缓冲问题,但是同时技术的发展往往会带来更新更难的问题,添加了更高的复杂度 -- 缓冲一致性问题。

多个CPU的系统当中,每个CPU维护自己的缓冲,共享一块大的主内存。

当多个CPU涉及的计算数据交叉的时候就会遇到缓冲不一致的问题。

现代的计算机采用写缓冲区的方式来暂时存放内存向CPU缓冲写的数据。写缓冲区可以保证流水线指令的执行,可以避免CPU停下来等待缓慢存入内存的过程。同时采用批处理的方式,以及合并缓冲区对同一块内存区域的多次写,来减少资源的损耗。

看上去很美好,用了类似异步的思想,还能合并多次写入操作,实际上会带来一个比较难解决的问题,那就是CPU对于内存的读写指令的顺序与实际内存的读写顺序不一定一致。

伪共享

CacheLine 是CPU Cache的最小单位,CPU取的时候不是按照字节取,而是直接一个一个CacheLine拿。那么当多个CPU同时对同一块CacheLine内的内存中的不同变量进行修改时,无疑会影响彼此的性能。

可以通过数据填充,这样子空间换时间的方式进行解决,即单个数据填满整个cacheLine。

Java内存模型(JMM)

主内存 所有线程共享的内存区域 线程内存 线程自己私有的内存区域 主内存和线程内存通过save和load操作进行交互 所有提到的内存区域并不是真实存在的,而是虚拟存在的,就和虚拟机的运行时数据区一样。

JMM带来的问题

可见性问题

有甲乙两个线程都在执行相同的操作:从主内存读取变量a=0到工作内存,然后加一之后写回工作内存。 当甲执行完操作a++之后,a=1其实并没有立马被flush到主内存,那么这个操作就是对于乙线程不可见。

在多线程的环境下,如果某个线程首次读取共享变量,则首先到主内存中获 取该变量,然后存入工作内存中,以后只需要在工作内存中读取该变量即可。同 样如果对该变量执行了修改的操作,则先将新值写入工作内存中,然后再刷新至 主内存中。但是什么时候最新的值会被刷新至主内存中是不太确定,一般来说会 很快,但具体时间不知。

这个问题可以用volatile关键词修饰变量来解决

竞争问题

还是上面甲乙线程的场景,如果甲乙线程最理想的情况下,串行执行,那么最后的结果是a=2 但是如果甲乙的都将a=0复制到来工作内存,在自己的工作内存内完成++操作,那么就会遇到存在两个a=1要被flush到主内存的情况。

这个问题可以用synchronized代码块解决

重排序

编译器和处理器为了提高执行的效率会对指令进行重排序

  1. 编译器优化的重排序 -- 不改变语义的情况下就可以进行重排序
  2. 指令级并行的重排序 -- 不存在数据依赖性下就可以进行重排序
  3. 内存系统的重排序 -- 因为缓冲的存在,所以会让读写指令看起来乱序

数据依赖性

如果两个操作访问同一个变量,并且两个操作当中存在一个写操作,那么这两个操作就存在数据依赖性

as-if-serial

不管怎么重排序,程序单线程下的执行结果不能变。

所以如果存在数据依赖性就不会进行重排序

注意点在于,这里的数据依赖性是单个处理器或者单个线程内的两个操作的数据依赖性,而并不是多个处理器和多个线程的数据依赖性。

控制依赖性

举个例子

int a = 0;
boolean flag = false;

public void init() {
	a = 1; // 1
    flag = true; // 2
}

public void use() {
	if (flag) { // 3
    	int i = a * a; // 4
    }
}

上述代码中,操作3控制着操作4,如果init()操作和user()操作的顺序发生变化,那么结果就不对了,这就是控制依赖

继续观察代码,可以发现单线程下操作1和操作2并无数据依赖,所以可以进行重排序,操作3和操作4并无数据依赖,所以可以进行重排序。

当存在控制依赖的时候,会影响指令的并行度,为此编译器和处理器会采取猜测的方式进行克服,即执行use的处理器可以提前读取a的值进行计算,将结果临时存在重排序缓冲当中,如果条件为true,就从缓冲中写入。这一步实际上就已经执行了重排序。

因为单线程下,并不会改变结果,所以允许重排序

但多线程下,线程a执行init,线程b执行use,线程b执行到4的时候不一定对线程a的1操作可见

情况:操作1和操作2被重排序,这是允许的。操作3和操作4被重排序,这也是允许的。线程a标记flag=true,线程b去读取了a的值,而此时线程a并没有写入a

所以多线程下,控制重排序会造成结果不一致

内存屏障 Memory Barrier

编译器会在生成指令序列的时候在适当的位置插入内存屏障来禁止特定的指令重排序。

不管什么指令都不能和Memory Barrier进行指令重排序,同时还能强制刷出缓冲数据到内存

  • LoadLoad Load1 LoadLoad Load2 确保load1的操作之前与Load2以及之后的装载指令
  • StoreStore Store1 StoreStore Store2 确保Store1存放的数据对之后的可见,刷新到内存,之前与其他存储指令
  • LoadStore Load1 LoadStore Store1 确保load1的操作之前与Store1以及之后的装载指令
  • StoreLoad Store1 StoreLoad Load1 确保屏障之前的所有装载和存储指令运行完之后,再运行屏障后的

happens-before 保证多线程下结果正确

  1. 程序顺序规则 一个线程的每一个操作happens-before后续操作
  2. 监视器锁原则 对于一个锁的解锁happens-before对这个锁的加锁
  3. volatile变量原则 对于一个volatitle的写happens-before对这个变量的读
  4. 传递性 A happens-before B B happens-before C A happens-before C
  5. start原则 A启动线程B,那么线程B的start happens-before 后续操作
  6. join原则 A执行线程B的join方法并成功返回,那么线程B的所有操作happens-before于线程Ajoin的返回
  7. interrupt原则 中断操作 happens-before 中断检测代码

volatile详解

可见性 对于一个volatile变量的读,总是能看到最近的操作

原子性 对于单个volatile变量的读写保证原子,多个不保证

volatile的内存语义

每当写一个volatile变量,JMM会把线程对应的本地内存的共享变量更新到主内存中去

每当读一个volatile变量,JMM会把工作内存的共享变量无效,然后重新从主内存读取共享变量

volatile为什么不能保证安全性

同时有两个线程进行i++操作,当一个线程完成读取操作,将i读取到CPU之后,时间片用完,交出控制权给另外线程,因为并没有写回内存,所以缓冲没有失效,另外的线程获取i,写入缓存,拿到CPU进行计算,写入内存i=1,之后时间回到来第一个线程上,第一个线程对CPU内的i进行累加,所以结果还是i=1

volatile的规则

当第二个操作是volatile写的时候,无论第一个操作是什么,都不能进行重排序 当第一个操作是volatile读的时候,无论第二个操作是什么,都不能进行重排序 当第一个曹祖是volatile读的时候,第二个操作是volatile读的时候,不能进行重排序

volatile写

StoreStore

volatile写

StoreLoad

volatile读

volatile读

LoadLoad

LoadStore

synchronized

锁的内存语义

释放锁的时候,会把工作内存的共享变量刷到主内存

获得锁的时候,会把工作内存的共享变量设置为无效,从主内存中获取共享变量

实现原理

Synchronized 在 JVM 里的实现都是基于进入和退出 Monitor 对象来实现方法 同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的 MonitorEnter 和 MonitorExit 指令来实现。

对同步块,MonitorEnter 指令插入在同步代码块的开始位置,当代码执行到 该指令时,将会尝试获取该对象 Monitor 的所有权,即尝试获得该对象的锁,而 monitorExit 指令则插入在方法结束处和异常处,JVM 保证每个 MonitorEnter 必须 有对应的 MonitorExit。

对同步方法,从同步方法反编译的结果来看,方法的同步并没有通过指令 monitorenter 和 monitorexit 来实现,相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符。JVM 就是根据该标示符来实现方法的同步的:当方法被调用时,调用指令将 会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线 程将先获取 monitor,获取成功之后才能执行方法体,方法执行完后再释放 monitor。在方法执行期间,其他任何线程都无法再获得同一个 monitor 对象

锁的状态

  1. 无锁状态
  2. 偏向锁
  3. 轻量锁
  4. 重量锁

锁可以升级但不能降级,目的是为了提高获得锁和 释放锁的效率。

偏向锁

偏向锁的获得

  1. 访问Mark Word偏向锁的标识是否被设置为1,锁的标志位是否是01,是否是可偏向状态
  2. 如果是可偏向状态,测试线程ID是否指向当前的线程,如果是的话进入5,不是进入3
  3. 如果ID未指向当前线程,就通过CAS进行尝试设置成自己,成功就执行5,CAS失败就进入4
  4. CAS获取锁失败,说明存在竞争,到达安全点之后,持有锁的线程会被挂起一会,将偏向锁升级为轻量锁
  5. 执行同步代码

偏向锁的释放

线程不会主动释放偏向锁,只有等到安全点的时候,判断是否需要升级到轻量锁还是释放锁

轻量锁

轻量锁的加锁过程

同步对象无锁,并且不可偏向的时候,虚拟机会在当前线程的栈帧建立一个锁记录的空间,用于存储锁对象目前的markWord的拷贝,官方称之为 Displaced Mark Word。 拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock record 里的 owner 指针指向 object mark word。如果更 新成功,则执行步骤 4,否则执行步骤 5。 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态 如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前 线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进 入同步块继续执行。否则说明多个线程竞争锁,当竞争线程尝试占用轻量级锁失 败多次之后,轻量级锁就会膨胀为重量级锁,重量级线程指针指向竞争线程,竞 争线程也会阻塞,等待轻量级线程释放锁后唤醒他。锁标志的状态值变为“10”, Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也 要进入阻塞状态。

重量锁