Java虚拟机系列七:JMM

866 阅读15分钟

[toc]

一.多核多线程架构

1.1 多核多线程架构

物理上每个cpu核心都是独立的芯片,os会将它作为独立的cpu, 每个cpu中都包含多个可以调度的线程单元,多个线程通过时间片轮转机制调度运行.

image

这里的线程根据线程所在的内存空间(内核空间和用户空间)不同,分为系统线程和用户线程两种,支持多线程的内核就叫做多线程内核,系统线程由系统负责生命周期管理,用户线程由程序负责生命周期管.

系统线程与用户线程通过1对1,1对多,多对多来关联.

Java 中线程采用的是1对1的线程模型,也就是说java种的线程其实就是操作系统中的线程,其线程库和线程模型很大程度上依赖于操作系统(宿主系统)的具体实现,比如在 Windows 中 Java 就是基于 Wind32线程库来管理线程,且 Windows 采用的是一对一的线程模型,linux也是机遇一对一的线程模型.

1.2 多线程调度算法

  • fifo: 先进先出调度,按照任务进入队列顺序执行,执行结束后再发生切换
  • SJF(ShortestJobFirst):最短任务优先,按照任务的耗时长短进行调度,优先调度耗时短的任务,这个算法需要预先知道每个任务的耗时情况,不大现实的
  • RR(Round Robin):时间片轮转算法,给队列中的每个任务一个时间片,第一个任务先执行,时间片到了之后,将此任务放到队列尾部,切换到下个任务执行,这样能解决SJF中耗时长任务饥饿的问题
  • MFQ(Multi-level Feedback Queue): 多级反馈调度算法,将任务分成多个不同level权重的队列里,cpu优先执行高权重的队列,同一level队列按照时间片轮转.高level的队列可以抢占低level队列

1.3 jmm(javaMemoryModel)内存模型 诞生的原因

到这里其实大家还是一脸懵B,我是来看JMM的,你跟我扯半天的多线程有luan用阿,我也很急.

由于cpu的执行速度远远高于内存吞吐速度,一般cpu的速率是ghz,而内存访问速度为mhz,这里面有1000倍的速度差距,为了不白白浪费cpu资源,在cpu跟内存之间设计了l1,l2,l3三级高速缓存(不一定都是三级,看具体硬件),这些高速缓存造价昂贵,速度与cpu相差无几,当cpu需要访问内存数据时,会从l1,l2,l3依次查找,如果没有找到,会先将内存数据拷贝一份到高速缓存中.

多线程内核下,由于每个cpu核心都有自己独立的高速缓存,并且多线程可以并发执行,就会出现各个线程获得的数据不一致的问题,就是缓存一致性问题,也叫做可见性问题.

其次,由于不同的编译器会对指令进行优化,比如java的jit技术会根据指令的热度动态调整指令的顺序,简称指令重排,指令重排仅考虑了单线程的优化效率.当发生指令重排的情况下,在多线程访问时会出现意想不到的情况,比如对象半初始化等,这类问题成为有序性问题.

多个线程对于同一段指令在执行过程中可能会因为调度策略而发生上下文切换,出现方法执行一半的场景,比如商品买卖的扣钱和发货逻辑,需要完整执行代码段而实际没有的情况,称为原子性问题,

为了解决上述可见性,有序性,原子性问题,jmm诞生,jmm是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范.

java中的上下文切换 每个线程在执行方法时,会生成一个栈帧,多个方法的嵌套调用会产生多个栈帧,同时还有程序计数器来表示当前执行的指令位置

二.JMM模型

jmm屏蔽了硬件细节,它规定,所有的java线程都有单独的工作内存,它把线程用到的变量从主存拷贝到了工作内存,并且规定了工作内存与存的同步策略.

image

当线程需要访问某个变量时,首先会从主内存中拷贝到工作内存,在工作内存中完成对变量的修改.当多个线程同时访问变量并且修改内容时,会造成变量数据的不一致.

2.1 线程工作内存与主内存交互

jmm定义了八种基本操作来完成工作内存与主内存的共享变量的同步

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态;
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用;
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中;
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作;
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作;
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用;
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中

操作的主意事项:

  • read/和load、store/write必须成对出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现;
  • 变量在工作内存中改变了之后必须把该变化同步回主内存;
  • 不允许把没有assign的数据从线程的工作内存同步回主内存中;
  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use、store操作之前,必须先执行assign和load操作;
  • 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁;
  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值;
  • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量;
  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)

这里主意,由于工作内存对应的是高速缓存,与主内存在不同的物理设备上,因此对变量的操作都是先read(读主存变量)再load拷贝到高速缓存,同理,写会操作也是先store(从告诉缓存拷贝到主存)再write(写入变量).

三.JMM解决原子性问题

原子性问题产生的根本原因在于不同的线程执行同一段指令期间上下文切换造成的访问数据不一致问题.JMM通过synchronized关键字来解决原子性问题.

3.1 synchronized 在字节码层面的表示

synchronized (Person.class){
    person = new Person();
    person.name = Thread.currentThread().getName();
}

它对应的字节码指令如下

4 monitorenter
5 aload_0
6 new #2 <com/hch/Person>
9 dup
10 invokespecial #3 <com/hch/Person.<init> : ()V>
13 putfield #4 <com/hch/jmm/TestJmm.person : Lcom/hch/Person;>
16 aload_0
17 getfield #4 <com/hch/jmm/TestJmm.person : Lcom/hch/Person;>
20 invokestatic #5 <java/lang/Thread.currentThread : ()Ljava/lang/Thread;>
23 invokevirtual #6 <java/lang/Thread.getName : ()Ljava/lang/String;>
26 putfield #7 <com/hch/Person.name : Ljava/lang/String;>
29 aload_1
30 monitorexit
31 goto 39 (+8)
34 astore_2
35 aload_1
36 monitorexit

可以看到 synchronized被翻译成monitorenter和monitorexit指令对. monitorenter和monitorexit实际上是对monitor管程模型的操作与访问.在Java内存模型中,synchronized规定,线程在加锁时,先清空工作内存,然后从主内存拷贝变量的副本到工作内存,再执行完代码最后将修改的共享变量的值写回主内存中。

3.2 管程模型

管程模型是一种抽象模型,它为了解决资源的同步访问问题,确保同一时间只能有一个线程访问同一资源.

在java中管程模型的实现是通过在所有对象的对象头中的markword插入锁信息来实现的. 由于所有的对象都有对象头,因此我们可以对任何的对象加锁.

来再看下对象头中的markword结构信息:

jvm_object_markword

对象锁随着线程的竞争激烈程度会逐渐升级,一个线程反复的去获取/释放一个锁,如果这个锁是轻量级锁或者重量级锁,不断的加解锁显然是没有必要的,造成了资源的浪费。因此,从无锁状态,到单线程访问的偏向锁,再到多个线程访问的轻量级锁和重量级锁是性能权衡的结果.

3.2.1 偏向锁

偏向锁是根据这样的假设而出现,即在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争.其目标就是在只有一个线程执行同步代码块时能够提高性能。

当处于偏向状态时,不会通过加锁和解锁来进入同步代码块,而是检测Mark Word里是否存储着指向当前线程的偏向锁.如果线程id一致,则直接进入同步代码块,提高性能.具体说明如下:

  • 当一个对象没有被其他线程访问时,它处于无锁状态,markword中用23位存放hashcode信息,锁标记为记录为01
  • 当有一个线程访问遇到montorenter时,先检查markword是否为01,如果是则检查线程id是否与自己相同,如果是则执行同步代码;否则尝试用CAS获取偏向锁,如果成功,则修改线程id为自身并执行同步代码块,否则表示有竞争,当到达全局安全点时,将偏向锁升级为轻量级锁.
  • 线程不会主动去释放偏向锁.偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程如果已经退出同步代码块(monitorenter与monitorexit同时执行结束)或者线程已经销毁才会释放锁.

全局安全点(safepoint): safepoint代表了一个状态,在该状态下所有线程都是暂停的

3.2.2 批量重偏向与epoch

重度竞争的情况下,多线程尝试获得锁时就需要等到安全点时将偏向锁撤销为无锁状态或升级为轻量级/重量级锁.导致性能下降。因此,JVM中增加了一种批量重偏向/撤销的机制.

epoch是一个标志位,存在于对象与class中,当不一致时,偏向锁批量失效.

具体操作如下:

  • jvm在每个对象的class中记录了初始epoch值,
  • jvm为每个class维护了一个偏向锁撤销计数器,阈值是20
  • 如果CAS成功,新线程成功获取偏向锁;如果失败,对象撤销偏向锁,计数器+1(说明有线程切换),升级为轻量锁.
  • 达到计数器阈值时,说明竞争非常激烈(或者异常),在到达安全点时,会修改class的epoch值+1,扫描所有持有class实例的线程栈,根据线程栈的信息判断出该线程是否锁定了该对象,将新的epoch的值赋给被锁定的对象中.
  • 退出安全点后,再次发生对象的锁竞争时,对于对象中epoch值与class中epoch值不相同的对象可以进行偏向锁申请.

3.2.3 轻量锁 (lockRecoder)

轻量锁的开销大于偏向锁,它需要在线程的栈帧的附加信息中增加一个lockRecoder记录.lockRecoder中存放了对象的markword的拷贝.当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁.

轻量级锁的获取需要通过CAS操作来完成,CAS是一种乐观锁的实现.java中它结合volatile的可见性,通过对比原值与副本是否相等来判断数据是否发声变化,如果未发生变化,则修改原值为新值,否则放弃本次操作重试.

虚拟机将使用CAS操作尝试将对象的markword更新为指向lockRecoder的指针,并将lockRecoder里的owner指针指向对象的markword.

若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁

3.2.3 重量级锁 (monitor对象)

markword中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态.

image

重量级锁使用monitor对象存储锁信息,它包含一个waitlist,一个entrylist,以及当前锁拥有者owner.

新的线程遇到monitorenter指令首先进入entrylist,如果调用了wait方法(wait方法会释放资源,即会导致当前线程的工作副本被清空),则进入waitlist,如果被notify唤醒,则重新加入entrylist.

四.JMM解决可见性问题(volatile)

volatile关键字的作用:

  • 在汇编代码中插入lock指令,该指令会锁定jmm的缓存总线,导致其他线程在需要读取或者回写数据时需要等待总线解除锁定.
  • 在有volatile关键字修饰的变量的指令前/后插入内存屏障指令.

4.1可见性问题产生的原因

在多线程环境下,让所有的线程都能获取共享变量的最新值的问题叫做可见性问题.由于每个线程都有自己的工作内存,当多线程同时访问同一变量时,就会出现变量值不一致的情况.

4.2 volatile如何保证可见性

java通过volatile关键字解决可见性问题.

被volatile修饰的变量,其汇编代码中会插入lock指令,由于lock指令引起缓存失效从而导致线程需要重新从主存去获取变量数据造成性能损失,因此不是所有的操作下对于volatile的变量的指令都会加lock..

虽然lock会导致线程工作内存中的变量缓存失效,但是它并不会使得其他线程去从主内存load新的数据.结合CAS操作才能解决这个问题.

五.JMM解决指令重排(有序性)问题(volatile)

5.1 指令重排是什么?

编译器和执行器在一定条件下会对指令做一些优化来提升运行速度,这些优化包括调整指令的顺序,也叫指令重排,在多线程情况下,指令重排会引起不确定性问题,比如对象半初始化问题.并不是所有的指令都可以重排,具体满足重排的规则不继续深入.

5.2 volatile如何禁止指令重排?

volatile修饰的变量在读写时,编译器会在指令前/后插入内存屏障指令(简单来说就是标记位),遇到标记位不发生指令重排.

插入规则如下:

  • 在volatile写操作的前面插入一个StoreStore屏障。
  • 在volatile写操作的后面插入一个StoreLoad屏障。
  • 在volatile读操作的前面插入一个LoadLoad屏障。
  • 在volatile读操作的后面插入一个LoadStore屏障

屏蔽规则:

  • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。