JVM

深入理解Java虚拟机总结-线程安全与锁优化

Posted by AlstonWilliams on February 17, 2019

注:此文是我在读完周志明老师的深入理解Java虚拟机之后总结的一篇文章,请阅读此书获取更加详细的信息.

什么是线程安全

«Java Concurrency In Practice»的作者Brian Goetz说,"当多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的."

线程安全的实现方法

  • 互斥同步:在Java中,最基本的互斥同步手段就是synchronized关键字,synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象.如果Java程序中的synchronized明确制定了对象参数,那就是这个对象的reference;如果没有明确规定,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象.

  • 非互斥同步:互斥同步尽管保证了线程安全,但是却引入了额外的性能开销(线程挂起和唤醒都需要从用户态向内核态转换),这是典型的悲观锁机制,除了悲观锁,还有一种乐观锁的实现,比如CAS等,这种操作是直接依靠处理器提供的指令来完成的,这类指令常用的有:

    • 测试并设置
    • 获取并增加
    • 交换
    • 比较并交换
    • 加载链接/条件存储

    CAS指令需要有三个操作数,分别是内存位置(在Java中可以简单理解为变量的内存地址,用V表示),旧的预期值(用A表示)和新值(用B表示).CAS指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则它就不执行更新,但是无论是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作.

    Java中,CAS相关的操作一般都是用Unsafe类提供的,但是Unsafe是提供给JDK中的类用的,如果我们用户想用的话,可以通过反射来获取.

  • 无同步方案:如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证安全性,因此会有一些代码天生就是安全的.比如下面将要介绍的这两类:

    • 可重入代码:这种代码叫做纯代码,可以再代码执行的任何时刻中断它,转而去执行另一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误.相对线程安全来说,可重入性是更基本的特性,它可以保证线程安全,即所有的可重入的代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的.

      可重入代码有一些公共的特征,例如不依赖存储在堆上的数据和公用的系统资源,用到的状态量都由参数中传入,不可调用非可重入的方法等.我们可以通过一个简单的原则来判断代码是否具备可重入性:如果一个方法,它的返回结果是可预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的.

    • 线程本地存储:如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行,如果能保证,我们就可以把共享数据的可见性限制在同一个线程之内,这样,无需同步就能保证线程之间不出现数据争用的问题.

锁优化

自旋锁与自适应自旋

自旋锁就是在线程被阻塞之前,先通过一个循环来检测在一段时间内是否能够获取到锁,如果能的话,就减少了一次线程阻塞的开销.

但是,自旋等待并不能代替阻塞,自旋等待本身虽然避免了线程切换的开销,但它要占用处理器时间的,因此,如果所被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长,那么自旋的线程只能白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费.因此,自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了.自旋次数的默认值是10次,用户可以使用参数-XX:PreBlockSpin来更改.

从JDK 1.6中引入了自适应的自旋锁.自适应意味着自选的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定.如果在同一个锁对象上,自选等待刚刚成功获得过锁,而且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环.另外,如果对于某个锁,自选很少成功获得过,那在以后要获取这个锁时,将可能省略掉自旋过程,以避免浪费处理器资源.有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,虚拟机就会变得越来越聪明了.

#### 锁消除

锁消除就是javac在编译时,自动消除掉那些经过它分析不会产生共享数据竞争的锁.

锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量少-只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁.

在大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗.

如果虚拟机检测到有这样的操作,就会把加锁的同步范围扩展到整个操作序列的外部.

轻量级锁

在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志为"01"状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录的空间,用于存储锁对象目前的Mark Word的拷贝,这时候线程堆栈与对象头的状态如下图所示:

如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是说明线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了.如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为"10",Mark Word中存储的就是指向重量级锁的指针,后面等待锁的线程也要进入阻塞状态.

上面描述的是轻量级锁的加锁过程,它的解锁过程也是通过CAS操作来进行的,如果对象的Mark Word仍然指向着现成的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果替换成功,整个同步过程就完成了,如果替换失败,说明有其他线程尝试过获取该所,那就要在释放锁的同时,唤醒被挂起的线程.

轻量级锁能提升程序同步的性能的依据是"对于绝大部分的锁,在整个同步周期内都是不存在竞争的".如果不存在竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争情况下,轻量级锁比传统的重量级锁更慢.