Java内存模型

458 阅读10分钟

#空杯心态,一切归零#

一:什么是java内存模型

我们通过一张图片来直观的感受下内存模型的抽象概念。

image.png

在java内存模型中,本地内存(在java里面其实就是栈空间)被线程私有,主内存是所有线程共享的。对于主内存中的共享变量,每个线程会获取一个共享变量的副本,也就是在本地内存中缓存了一份数据,在线程内部都是对副本进行操作。只有在需要写回到主存时,才会交由JMM来进行控制。如果不进行JMM控制,可能在竞态条件下会产生线程安全问题

再通过一张图来看下CPU多级缓存和内存的全貌。

image.png

从图中可以看出,L1和L2以及寄存器是CPU私有,L3和主存是多个CPU共享。

补充一点,CPU读取数据时的流程说明:

(1)先读取寄存器的值,如果存在则直接读取

(2)再读取L1,如果存在则先把cache行锁住,把数据读取出来,然后解锁

(3)如果L1没有则读取L2,如果存在则先将L2中的cache行加锁,然后将数据拷贝到L1,再执行读L1的过程,最后解锁

(4)如果L2没有则读取L3,同上先加锁,再往上层依次拷贝、加锁,读取到之后依次解锁

(5)如果L3也没有数据则通知内存控制器占用总线带宽,通知内存加锁,发起内存读请求,等待回应,回应数据保存到L3(如果没有就到L2),再从L3/2到L1,再从L1到CPU,之后解除总线锁定。

回到正题,结合上面的两张图,是否发现结构有些相似。工作内存对应CPU缓存,内存对应L3和RAM,都存在私有和共享。也都采用同样的思想,通过使用缓存提升程序的性能。但是任何事情都有两面性,提升的性能的同时,由此也就衍生出了缓存一致性问题

二:缓存一致性是如何解决的

这里主要引出volatile关键字。它的本质是告诉JVM,禁用缓存。

在CPU层面,最常用到的缓存一致性协议是MESI协议。主要原理是 CPU 通过总线嗅探机制(监听)可以感知数据的变化从而将自己的缓存里的数据失效,缓存行中具体的几种状态如下:

之前是锁总线,其它CPU对内存的读写请求都会被阻塞,直到锁释放,不过实际后来的处理器都采用锁缓存行替代锁总线,因为锁总线的开销比较大,锁总线期间其他CPU没法访问内存。

在JVM层面,JMM通过happen-before规则和内置的八种指令来实现。

happen-before规则:本质上是可见性的问题。就是说前一个线程对共享资源的操作对另一个线程是可见的,后一个线程需要等前一个线程操作结束后,才能执行相关操作。

1、程序的顺序性规则

这条规则是指:在一个线程中,指令按顺序执行,前面的操作 Happens-Before 于后续的任意操作。也就是前面的操作对后面的操作可见。

2、volatile变量规则

这条规则是指:对一个volatile变量的写操作, Happens-Before 于后续对这个变量的读操作。也就是写操作对读操作可见。

3、传递性规则

这条规则是指如果A Happens-Before B,B Happens-Before C,那么A也将Happens-Before C。

4、管程中的锁的规则

这条规则是指:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁操作,即,先解锁才能后加锁。

5、线程的start规则

这条规则是指:如果在线程A中启动线程B,那么该start()操作 Happens-Before 于线程B中的任意操作。

6、线程的join规则

这条规则是指:join进来的线程的所有操作 Happens-Before于当前线程的相关操作。也就是说当前需要等join插队进来的线程执行完成之后,当前线程才能继续执行。

其中的volatile变量规则,其实现也参考了MESI缓存一致性模型,通过状态的变化告诉JVM,当缓存处于失效状态时,需要在使用时从主存中加载最新的值。

另外,稍微补充下JMM提供的8条内置指令

image.png

从主存同步数据到工作内存时,需要借助read和load指令;从工作内存写回主内存时,需要用到store和write指令。其他线程要往主内存写数据时,会对缓存进行加锁,直至锁释放后才能执行相关操作。

volatile有两层含义:除了禁用缓存,还有禁止指令重排。本质是通过内存屏障来实现,为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。一个读的操作为load,写的操作为store。此处就不展开详细描述了。

三:线程安全问题是如何解决的

Synchronized+CAS:在JVM层面进行加锁,解决一致性和原子性问题。

Synchronized现在引入了锁升级的概念,会优先尝试通过CAS操作去获取锁,如果多次尝试后失败,会通过偏向锁->自适应自旋锁->轻量级锁的变化,最后升级成重量级锁。

这里主要讲解下重量级锁,其底层原理是通过ObjectMonitor对象实现。

//结构体如下
ObjectMonitor::ObjectMonitor() {  
  _header       = NULL;  
  _count       = 0;  
  _waiters      = 0,  
  _recursions   = 0;       //线程的重入次数
  _object       = NULL;  
  _owner        = NULL;    //标识拥有该monitor的线程
  _WaitSet      = NULL;    //等待线程组成的双向循环链表,_WaitSet是第一个节点
  _WaitSetLock  = 0 ;  
  _Responsible  = NULL ;  
  _succ         = NULL ;  
  _cxq          = NULL ;    //多线程竞争锁进入时的单向链表
  FreeNext      = NULL ;  
  _EntryList    = NULL ;    //_owner从该双向循环链表中唤醒线程结点,_EntryList是第一个节点
  _SpinFreq     = 0 ;  
  _SpinClock    = 0 ;  
  OwnerIsThread = 0 ;  
}

对象持有一个owner属性,一个entrylist和waitset。等待获取锁的对象会被封装成ObjectWait对象进入entrylist。synchronized因为是互斥锁,只能有一个线程持有当前锁资源,底层有一个owner属性。如果owner为null,那么其他线程就可以通过CAS的方式来将owner修改为当前线程,表示这个属性是当前持有锁的线程。其他线程获取失败后,会再尝试几次,没拿到然后就会进入entrylist。如果在获取到锁的线程调用了wait方法,会进入到waitset。注意:wait方法会释放锁,而sleep方法不会释放锁。

看了HenryChenV对Synchronized的分析,对Synchronized的印象又有了更进一步的改观。

image.png

在对象刚分配时,如果对象类型是可偏向的,会变成左上角的匿名偏向状态,否则就会变成右上角的无锁状态。

当偏向某个线程时,就会变成左下角的偏向某个线程的状态,就算解锁了,也一直保持这个状态,所以从MarkWord是无法看出偏向锁是否已经解锁了的。如果发生了重偏向,会再次变为匿名偏向状态,变成左上角的偏向某个线程的状态,可以重新偏向其他线程。

在左下角的偏向状态下,另一个线程synchronized了这个对象,如果当前锁已经释放了,就会发生偏向锁撤销,变为无锁状态,也就是右上角的状态。

当遇到其他线程访问时,变成轻量级锁状态,在改变MarkWord中锁状态位的同时,MarkWord也会指向轻量级锁的LockRecord,无锁状态的MarkWord会存储到LockRecord的displaced_header中。

如果当前锁还在使用,当前锁就会升级为轻量级锁,这个会将偏向锁以及重入的偏向锁都该为轻量级锁,并将无锁的MarkWord存到最早的一个LockRecord的displaced_header中,其他LockRecord的displaced_header设置为空。这样当最早的一个LockRecord释放后,就可以用这个displaced_header还原MarkWord到无锁状态了。

image.png

这个时候的撤销和升级为轻量级锁的操作如果是针对当前线程的操作,不需要等到安全点,如果需要跨线程操作,就必须要等到安全点,相对低效。这也是常常被人提的偏向锁的最大缺点。

如果轻量级锁状态下存在竞争,那么会膨胀为重量级锁,这时MarkWord会变为重量级锁状态,每个对象会对应一个OjbectMonitor对象,无锁的MarkWord会存放到ObjectMonitor的header中。ObjectMonitor这个对象有很重的操作逻辑,wait、notify、notifyAll等操作都由它完成。

image.png

为什么偏向锁在变为轻量级锁或者重量级锁前需要偏向锁撤销呢?因为偏向锁状态下没有持有hashcode,升级后hashCode和垃圾回收信息依然不能丢,这个结构只有无锁的MarkWord才有,所以需要先变为无锁的MarkWord,再将无锁的MarkWord放到轻量级锁或重量级锁中。基于相同的原因,当计算hashCode时,如果是偏向锁状态,也会发生偏向锁撤销。

顺便说下,JUC工具包下基于Lock实现的一系列工具类也可以实现Synchronized的功能。

volatile:禁用缓存,解决可见性问题。

四:程序开发中的一点小窍门

其实总的说来,不管是软件层面还是硬件层面,为了支持更好的性能,缓存和指令优化都不可避免。我们能做的,就是告诉JMM让它在安全性和性能上做折中取舍。

final:对程序中的一些常量,使用final修饰,告诉JVM,这是一个不可变类型,不用优化。

volatile:对前后有依赖关系的逻辑,使用volatile修饰,告诉JVM,此处不用重排序。

其他发现后再补充......

五:写在最后

作为一个老后端程序员,第一次在平台上写技术类的文章,心中有些忐忑,也很惶恐。之前的底子也不怎么扎实,对一些技术也都掌握得不怎么成体系,很多时候,都会陷入一个误区。以为平时看到的文章或者听过的视频,自己都掌握了。但是真需要好好表达出来时候,却是又有些为难。以我自己为例,今日的这篇小文章,差不多耗费了整个下午的时光,删了写,写了删,遇到模棱两可的,再去翻阅资料。知易行难,还好,一切都有个开端,希望通过持续通过这种方式,记录自己对技术的感悟,温故而知新吧。

此处如果有小伙伴有补充的话,也欢迎在评论区留言。欢迎大家指摘,一起共勉。