synchronized原理深度剖析

作者 : 松哥资源 本文共2757个字,预计阅读时间需要7分钟 发布时间: 2017-03-7 共930人阅读




示例标题

sync是jvm的内置锁,底层是通过对象监视器(ObjectMonitor)来实现。而对象监视器的底层实现是通过cas+自旋或者操作系统的互斥量来实现的。通过javap -c 命令可以查看到sync方法前后有成对的monitorenter/monitorexit指令。

ObjectMonitor的结构

cas: compare and swap ,比对并交换。
这个操作是cpu指令级的功能,可以保证原子性。
这个操作需要三个参数:1.数据存放的地址Addr  2.原来的值A  3.期望改变后的值B。
只有当Addr数据为A的时候才会成功把值更改为B

CAS的三大问题

  • ABA问题

    cas的ABA问题: 描述: 第一个线程取到了变量 x 的值 A,然后巴拉巴拉干别的事,总之就是只拿到了变量 x 的值 A。
    这段时间内第二个线程也取到了变量 x 的值 A,然后把变量 x 的值改为 B,然后巴拉巴拉干别的事,
    最后又把变量 x 的值变为 A (相当于还原了)。在这之后第一个线程终于进行了变量 x 的操作,但是此时变量 x 的值还是 A,
    所以 compareAndSet 操作是成功。 这在某些场景是不允许的
    
    一个不恰当的例子:公司有一笔专款专用的钱100万,在公司真正支出这笔钱之前,公司财务挪用这笔钱借给了自己的某个朋友,
    然后这个朋友归还了回来。归还之后公司正常使用了这笔钱,表面上对公司没有任何影响,但挪用公款这个行为是违法不允许的。
    
    如何解决这个问题:增加一个版本号。 Java中的AtomicStampedReference通过版本号解决了这个问题。
    
  • 循环时间长开销大

    一般cas都会配合一个循环来使用,所以如果cas长时间不成功,是很消耗cpu的。所以一般还要配合LockSurport.park使用
    
  • 只能保证一个变量的原子操作

    可以把多个变量封装在一个对象里,然后通过AtomicReference来进行操作
    

在jvm早期中,sync是通过操作系统的互斥量来实现的,这就会涉及到操作系统的用户态和内核态的切换,这是非常消耗系统资源的操作。所以后来引入了轻量级锁,即通过cas+自旋的方式来实现。

那么执行同步代码块的线程是如何进行同步的呢?它们总有一个共同的存储锁信息的地方吧? 是的,那就是对象的mark word(上图ObjectMonitor中的_header字段)。

对象的mark word在什么地方呢?
在对象的对象头里。这里扩展下对象在jvm中的内存结构:

对象结构

我们重点关注下对象头中的mark word,那么mark word的结构又是怎样的呢?32位jvm中如下:
mark word结构

线程就是通过对象的mark word来进行同步的。

在上图可以看到mark word中记录了对象锁的状态,分为无锁、偏向锁、轻量级锁、重量级锁。这几种状态的转变规则为无锁->偏向锁->轻量级锁->重量级锁,这个规则称之为锁的升级。注意:锁只能升级不能降级。但偏向锁可以降级为无锁,其他的不行。

锁的升级过程

见图:

sync锁的升级过程

这张图用文字描述清楚感觉太费劲,文字还多,说不定吃力不讨好,所以这个图要慢慢品,细细品,多次品。品的时候建议假设有多个线程在竞争锁。

品的时候有个难点可能是偏向锁的撤销那,偏向锁的撤销是在有锁竞争的情况下才会撤销。为什么呢 ?我们优先要明白偏向锁的设计目的是什么? 因为可能存在这种情况:这个同步代码块只有一个线程在访问。如果没有偏向锁,那么根据上图这个线程会多次进行cas操作,这个显然是没有必要的,于是为了优化就设计了偏向锁。使得在只有一个线程访问同步代码块的时候,只需要比对之前偏向的线程是不是自己,如果是,则获得锁。

注意

sync的对象不要使用String Integer Long等基础对象,因为这些对象存在缓存原因,可能锁到其他线程用的对象。

sync和ReentrantLock的区别

  1. 两者都是可重入的(sync的重入次数保存在哪里的呢?)
  2. sync是基于jvm实现的,是隐式锁,而ReentrantLock是基于java api实现的,是显式锁。
  3. ReentrantLock 比 synchronized 更加灵活
    1. 可以试探性获得锁(tryLock)
    2. 等待可中断;(acquireInterruptibly)
    3. 可实现公平锁,sync是非公平的
    4. 可实现选择性通知,也就是可以创建多个条件队列
    5. 性能上已经不是一个选择因素了。sync做了各种优化(偏向锁、自旋锁、锁粗化、锁消除等等)

扩展

矛盾1

A: 重量级锁中的阻塞(挂起线程/恢复线程): 需要转入内核态中完成,有很大的性能影响。

B: 锁大多数情况都是在很短的时间执行完成。

解决方案: 引入轻量锁(通过自旋来完成锁竞争)。

矛盾2

A: 轻量级锁中的自旋: 占用CPU时间,增加CPU的消耗(因此在多核处理器上优势更明显)。

B: 如果某锁始终是被长期占用,导致自旋如果没有把握好,白白浪费CPU资源。

解决方案: JDK5中引入默认自旋次数为10(用户可以通过-XX:PreBlockSpin进行修改), JDK6中更是引入了自适应自旋(简单来说如果自旋成功概率高,就会允许等待更长的时间(如100次自旋),如果失败率很高,那很有可能就不做自旋,直接升级为重量级锁,实际场景中,HotSpot认为最佳时间应该是一个线程上下文切换的时间,而是否自旋以及自旋次数更是与对CPUs的负载、CPUs是否处于节电模式等息息相关的)。

矛盾3

A: 项目中代码块中可能绝大情况下都是多线程访问。

B: 每次都是先偏向锁然后过渡到轻量锁,而偏向锁能用到的又很少。

解决方案: 可以使用-XX:-UseBiasedLocking=false禁用偏向锁。

矛盾4

A: 代码中JDK原生或其他的工具方法中带有大量的加锁。

B: 实际过程中,很有可能很多加锁是无效的(如局部变量作为锁,由于每次都是新对象新锁,所以没有意义)。

解决方法: 引入锁削除(虚拟机即时编译器(JIT)运行时,依据逃逸分析的数据检测到不可能存在竞争的锁,就自动将该锁消除)。

矛盾5

A: 为了让锁颗粒度更小,或者原生方法中带有锁,很有可能在一个频繁执行(如循环)中对同一对象加锁。

B: 由于在频繁的执行中,反复的加锁和解锁,这种频繁的锁竞争带来很大的性能损耗。

解决方法: 引入锁扩大/锁膨胀(会自动将锁的范围拓展到操作序列(如循环)外, 可以理解为将一些反复的锁合为一个锁放在它们外部)。



  • 随机毒鸡汤:世界上本没有贫穷,富的人多了,我就变成了贫穷。


文章来源于justsoso.fun: synchronized原理深度剖析





发表回复