volatile与synchronized实现原理

 

------------------------------------------------------------------
  刚开始认识volatile的时候,觉得对它的一些特性非常迷惑。比如:具有可见性,如果一个线程修改了volatile变量的值,那么其它线程也会发现这一点;同时它又不具有原子性,多个线程对被volatile修饰的int 变量累加会造成相互覆盖。这我就迷糊了:不是一个线程修改了,其它的线程中数据都无效了么,既然会重新读取,为啥最终还会相互覆盖呢?
volatile原理:
  我们知道:如果一个字段被声明成volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。这个就是所谓的“可见性”,就是一个线程修改了,其他线程能知道这个操作,这就是可见性。如何实现的呢?volatile修饰的变量在生成汇编代码的时候,会产生一条lock指令,lock前缀的指令在多核处理器下会引发两件事情:
  1、将当前处理器缓存航的数据写回到系统内存;
  2、这个写回内存的操作会使得在其它cpu里缓存了该内存地址的数据无效;
  这个使得其它cpu里数据无效又是怎么实现的呢?
  cpu处理数据速度是很快的,为了提高处理速度,充分发挥cpu性能,cpu不直接跟内存进行通信,而是先将数据读入cpu高速缓存后再进行操作,但操作完不知道何时回写到内存。如果对声明了volatile的变量进行写操作,jvm就会向处理器发送一条lock前缀指令,将这个变量所在缓存行的数据写回到系统内存。但就算写回到内存,如果其它处理器缓存的还是旧值,再执行计算操作就会有问题。所以多处理器下,为了保证各个处理器的缓存是一致的,就有了一个“缓存一致性协议”,所有硬件厂商都要按照这个标准来生产硬件。具体就是每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存。
注意,如果该数据已经在别的处理器线程被修改过了,只是没有刷新到内存,则这时候是不会重新读数据的,而是等一下直接刷新到内存,这就造成了覆盖的事情发生;别的线程重新读取数据仅仅是在将变量读到了cpu缓存,还没有使用的时候才有的,一旦使用了,即使发现被修改了,也不会重新读取重新计算。具有可见性,而又多线程不安全的问题就是这样产生的。
  该部分可以结合:jvm线程模型jvm8种内存基本操作
synchronized原理:
  synchronized是用java的monitor机制来实现的,就是synchronized代码块或者方法进入及退出的时候会生成monitorenter跟monitorexit两条命令。线程执行到monitorenter时会尝试获取对象所对应的monitor所有权,即尝试获取的对象的锁;monitorexit即为释放锁。
  monitor机制是跟java对象结构相关的。HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头,实例数据跟对齐填充。

从上面的这张图里面可以看出,对象在内存中的结构主要包含以下几个部分:
  • Mark Word(标记字段):对象的Mark Word部分占4个字节,其内容是一系列的标记位,比如轻量级锁的标记位,偏向锁标记位等等。
  • Klass Pointer(Class对象指针):Class对象指针的大小也是4个字节,其指向的位置是对象对应的Class对象(其对应的元数据对象)的内存地址
  • 对象实际数据:这里面包括了对象的所有成员变量,其大小由各个成员变量的大小决定,比如:byte和boolean是1个字节,short和char是2个字节,int和float是4个字节,long和double是8个字节,reference是4个字节
  • 对齐:最后一部分是对齐填充的字节,按8个字节填充。
  • 其实,如果是数组对象,头信息还包括一个Array length的内容,用来记录数组长度。
  我们看这个Mark Word,它包含对象的hashcode,分代年龄跟锁标记位3部分。具体结构如下(32位虚拟机): 
  64位虚拟机下,Mark Word是64bit的,存出结果如下:
  这么复杂的结构,跟synchronized有什么关系呢?
  当然有关系,synchronized就是利用以上结构来实现的,每次就是抢占上边的Mark Word,然后修改里边各个小段的内容;然后,jdk的开发人员经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,那我们老是抢来抢去的岂不是没意义。于是考虑进行优化,也就有了偏向锁,轻量级锁以及重量级锁的概念。然后接下来我们来看这三种锁究竟是怎么一回事儿。
  偏向锁
  简单的讲,就是在锁对象的对象头中有个ThreaddId字段,这个字段如果是空的,
  第一次获取锁的时候,就将自身的ThreadId写入到锁的ThreadId字段内,将锁头内的是否偏向锁的状态位置1.
  这样下次获取锁的时候,直接检查ThreadId是否和自身线程Id一致,如果一致,则认为当前线程已经获取了锁,因此不需再次获取锁,略过了轻量级锁和重量级锁的加锁阶段。提高了效率。
  但是偏向锁也有一个问题,就是当锁有竞争关系的时候,需要解除偏向锁,使锁进入竞争的状态。
 
上图中只讲了偏向锁的释放,其实还涉及偏向锁的抢占,其实就是两个进程对锁的抢占,在synchrnized锁下表现为轻量锁方式进行抢占。
注:也就是说一旦偏向锁冲突,双方都会升级为轻量级锁。(这一点与轻量级->重量级锁不同,那时候失败一方直接升级,成功一方在释放时候notify)
  轻量级锁:
  之后会进入到轻量级锁阶段,两个线程进入锁竞争状态(注,我理解仍然会遵守先来后到原则;注2,的确是的,下图中提到了mark word中的lock record指向堆栈中最近的一个线程的lock record),一个具体例子可以参考synchronized锁机制。
  每一个线程在准备获取共享资源时:
  第一步,检查MarkWord里面是不是放的自己的ThreadId ,如果是,表示当前线程是处于 “偏向锁” ;
  第二步,如果MarkWord不是自己的ThreadId,锁升级,这时候,用CAS来执行切换,新的线程根据MarkWord里面现有的ThreadId,通知之前线程暂停,之前线程将Markword的内容置为空。
  第三步,两个线程都把对象的HashCode复制到自己新建的用于存储锁的记录空间,接着开始通过CAS操作,把共享对象的MarKword的内容修改为自己新建的记录空间的地址的方式竞争MarkWord;
  第四步,第三步中成功执行CAS的获得资源,失败的则进入自旋 第五步,自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于 轻量级锁的状态,如果自旋失败 第六步,进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己;
  重量级锁:
  这个就是我们平常说的synchronized锁,如果抢占不到,则线程阻塞,等待正在执行的线程结束后唤醒自己,然后重新开始竞争。之所以说是重量级,是因为线程阻塞会让出cpu资源,从内核态转换为用户态,然后执行的时候再次转换为内核态,这个过程中,cpu要切换线程,看这个线程上次执行到哪儿了,这次应该从哪儿开始,相关的变量有哪些之类的,这就是所谓的执行上下文,这个上下文的切换对宝贵的cpu资源来说是“无用功”,因为这是在为执行做准备条件,如果cpu大量的时间用在这些“无用功”上,当然也就出活儿少,也就影响执行效率了。
synchronized就是这样,默认开始是偏向锁,有竞争就逐渐升级,最终可能是重量级锁的一个过程。锁定的区域就是对象的Mark Word的内容。
  总结:为什么偏向锁跟轻量级锁相对来说速度快,就是因为这两个没有这个线程切换的过程。偏向锁是直接自己一直在用,相当于没有同步操作,轻量级锁是看了下有人在用,自己觉得他们用的时间应该不长,我就死循环等一下你们吧。大概就是这么个逻辑。当然了,死循环结束,发现你丫还没执行完,没的说,升级成重量级锁吧。
 
posted @ 2018-10-28 11:08  facelessvoidwang  阅读(2833)  评论(0编辑  收藏  举报