绕不开的并发编程--synchronized锁优化

279 阅读27分钟

简单介绍

本篇我们将介绍并发编程中会遇见的各种锁包括但不限于:「自旋锁」,「偏向锁」,「轻量级锁」...

接着我们将由浅入深的进入到对锁的学习和探索当中。

Java对象头

new关键字可以创建一个类的实例对象,对象存于内存的堆中,并给其分配一个内存地址,那么是否想过如下问题?

  • 实例对象是以怎样的形态存在内存中的?
  • 一个Object对象在内存中占用多大?
  • 对象中的属性是如何在内存中分配的?

接下来我们就来解决这几个问题。首先先是学习Java对象在内存中的存储形式开始。

对象内存布局

image.png

HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。它们分别存储以下信息:

  • 对象头(object header)

    包括了关于堆对象的布局、类型、GC状态、同步状态和标识哈希码的基本信息。Java对象和vm内部对象都有一个共同的对象头格式。

    • Mark Word(标记字段)

      对象的Mark Word部分在32位JVM4个字节,在64位JVM8个字节,其内容是一系列的标记位,比如轻量级锁的标记位,偏向锁标记位等等。

      Mark Word存储数据细节

      Mark Word在不同的锁状态下存储的内容不同,在32位JVM中是这么存的:

      image.png

      在64位JVM中是这么存的:

      image.png

      虽然它们在不同位数的JVM中长度不一样,但是基本组成内容是一致的。

      • 锁标志位(lock)

        区分锁状态,11时表示对象待GC回收状态, 只有最后2位锁标识(11)有效。

      • biased_lock

        是否偏向锁,由于无锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。

      • 分代年龄(age)

        表示对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代。

      • 对象的hashcode(hash)

        运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。当对象加锁后,计算的结果31位不够表示,在偏向锁,轻量锁,重量锁,hashcode会被转移到Monitor中。

      • 偏向锁的线程ID(JavaThread)

        偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。 在后面的操作中,就无需再进行尝试获取锁的动作。

      • epoch

        偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁。

      • ptr_to_lock_record

        轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争的时,JVM使用原子操作而不是OS互斥。这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的标题字中设置指向锁记录的指针。

      • ptr_to_heavyweight_monitor

        重量级锁状态下,指向对象监视器Monitor的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针。

    • Klass PointerClass对象指针)

      Class对象指针的大小也是4个字节,其指向的位置是对象对应的Class对象(其对应的元数据对象)的内存地址。

      JVM通过这个指针来确定这个对象是哪个类的实例。

    • Array Length

      只有对象是数组才会有这部分

  • 实例数据(Instance Data)

    主要是存放类的数据信息,父类的信息,对象字段属性信息。

    • 对象实际数据

      这里面包括了对象的所有成员变量,其大小由各个成员变量的大小决定,比如:byteboolean是1个字节,shortchar是2个字节,intfloat是4个字节,longdouble是8个字节,reference是4个字节

  • 对齐填充(Padding)

    为了字节对齐,填充的数据,不是必须的。

    • 对齐

      最后一部分是对齐填充的字节,按8个字节填充。

      为什么要对齐数据?

      让字段只出现在同一CPU的缓存行中

      如果字段不是对齐的,那么就有可能出现跨缓存行的字段

      也就是说,该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行。这两种情况对程序的执行效率而言都是不利的。其实对其填充的最终目的是为了计算机高效寻址。

从虚拟机信息中看对象在内存中的构成

我们使用openjdk提供的工具包来获取对象的信息和虚拟机信息。

 <dependency>
   <groupId>org.openjdk.jol</groupId>
   <artifactId>jol-core</artifactId>
   <version>0.8</version>
 </dependency>

我们通过一个简单的Hello类的实例对象来打印对象内部信息:

 public class Hello {
     public static void main(String[] args) {
         Hello hello= new Hello();
         System.out.println(ClassLayout.parseInstance(hello).toPrintable());
     }
 }

打印结果为:

image.png

可以看到,hello对象实例共占据16字节,对象头(object header)占据12字节,其中 mark word占8字节,klass point 占4字节,另外剩余4字节是填充对齐的。

  • 指针压缩

    由于默认开启了指针压缩,所以对象头占了12字节,我们可以通过配置vm参数关闭指针压缩-XX:-UseCompressedOops

    image.png

    如果关闭指针压缩重新打印对象的内存布局,可以发现总SIZE变大了

    对象头所占用的内存大小变为16字节,其中 mark word占8字节,klass point 占8字节,无对齐填充。

    • 开启指针压缩有什么用?

      开启指针压缩可以减少对象的内存使用。

      从两次打印的hello对象布局信息来看,关闭指针压缩时,对象头的SIZE增加了4字节,这里由于hello对象是无属性的

      可以试试增加几个属性字段来看下,这样会明显的发现SIZE增长。因此开启指针压缩,理论上来讲,大约能节省百分之五十的内存。JDK1.8及以后版本已经默认开启指针压缩,无需配置。

  • 数组对象

    我们再看下数组对象的内存布局,看下和普通对象的有什么异同(这里我们开启了指针压缩)

     public class Hello {
         public static void main(String[] args) {
             int[] hello= {1};
             System.out.println(ClassLayout.parseInstance(hello).toPrintable());
         }
     }
    

    打印结果如下:

    image.png

    可以看到这时总SIZE为共24字节,对象头占16字节,其中Mark Work占8字节,Klass Point 占4字节,array length 占4字节。因为里面只有一个 int 类型的1,所以数组对象的实例数据占据4字节,剩余对齐填充占据4字节。

对象年龄最大15岁的原因

对象在Suvivor中每熬过一次MinorGC,年龄就增加1,当它的年龄增加到15岁就会被晋升到老年代中。

Mark Word中可以发现标记对象分代年龄的分配的空间是4bit,而4bit能表示的最大数就是2^4-1 = 15。

什么是Java对象头?

根据上面对象内存模型的介绍我们已经很清楚了,「Java对象头」就是对象在内存中的存储的一块分区,其中对象头包括两个部分Mark WordClass对象指针,如果是数组对象的话还有Array Length

锁的类型

上面我们分析了Java对象头,从对象头的Mark Word中发现了还有锁标志位用于标志不同的锁。所以可以知道对象锁实际上是有不同状态的。

对象锁实际是有不同状态的?

Java对象的锁状态一共有4种,级别从低到高依次为:无锁(01) -> 偏向锁(01) -> 轻量级锁(00) -> 重量级锁(10)。

锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。

这些不同的对象锁的实现都是JDK1.6对synchronized底层做的优化。而1.6之前都是基于monitor机制的重量级锁。

JDK 1.6中默认是开启偏向锁和轻量级锁的,我们也可以通过-XX:-UseBiasedLocking来禁用偏向锁。

要注意锁的升级目的是为了提高锁的获取效率和释放效率

这里我找了「美团技术团队」根据特性将锁进行分组分类,可以说是总结的而非常全面到位了

image.png

本篇将会介绍上面这张图里面的大部分锁。

而一些非锁实现而是锁思想的锁将会在分析它的实现的时候介绍。

为什么需要锁优化?

为什么JDK1.6要引入锁优化?

因为监视器锁(monitor)实现的同步时互斥同步,互斥导致的Java线程的阻塞以及唤醒,都是依靠操作系统来实现的。

详细来说JVMmonitorentermonitorexit字节码就是依赖于底层的操作系统的Mutex Lock来实现的。

使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;

然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)如果每次都调用Mutex Lock那么将严重的影响程序的性能。

接下来我们就来分析synchronized的锁优化。

synchronized的锁优化

JDK1.6引入了什么锁优化技术?

JDK1.6中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销。

这里我们简单的列举一下这几种技术:

  • 锁粗化(Lock Coarsening)

    也就是减少不必要的紧连在一起的unlocklock操作,将多个连续的锁扩展成一个范围更大的锁。

  • 锁消除(Lock Elimination)

    通过运行时JIT编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护

    通过逃逸分析也可以在线程本的Stack上进行对象空间的分配(同时还可以减少Heap上的垃圾收集开销)。

  • 轻量级锁(Lightweight Locking)

    「轻量级锁」这种实现基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorentermonitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。

    当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒(具体处理步骤下面详细讨论)。

  • 偏向锁(Biased Locking)

    为了在无锁竞争的情况下避免在锁获取过程中执行不必要的CAS原子指令,因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟。

  • 适应性自旋(Adaptive Spinning)

    当线程在获取轻量级锁的过程中执行CAS操作失败时,在进入与monitor相关联的操作系统重量级锁(mutex semaphore)前会进入忙等待(Spinning)然后再次尝试

    当尝试一定的次数后如果仍然没有成功则调用与该monitor关联的semaphore(即互斥锁)进入到阻塞状态。

接下来我们来详细的解释各种锁

自旋锁

什么是自旋锁?

我们知道只有获取了锁的线程才能访问临界区资源,并且同一时刻只有一个线程可以获取到锁。那么没有获取到锁的线程该怎么办?

一般有两种方式:

  • 线程自己阻塞(BLOCK),等待重新调度请求,这个就是「互斥锁」。
  • 没有获取到锁的线程就一直循环等待判断该资源是否已经释放锁,线程不需要阻塞(BLOCK),这个就是「自旋锁」。

如果用一句话来概括互斥锁和自旋锁:互斥锁是睡等,自旋锁是忙等。

早在JDK1.4.2就引入了自旋锁对synchronized关键字进行了优化,我们可以用-XX:+UseSpinning来开启自旋锁。在JDK1.6中则是将自旋锁设置为默认开启。

image.png

于是这种采用循环加锁->等待的机制被称为「自旋锁」(spinlock)

为什么需要自旋锁?

在没有加入锁优化时,synchronized是一个非常“胖大”的家伙。这一点可以在「为什么需要锁优化?」已经解释。

同时HotSpot团队注意到在很多情况下,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和回复阻塞线程并不值得。

在如今多处理器环境下,完全可以让另一个没有获取到锁的线程在门外等待一会(自旋),但不放弃CPU的执行时间。等待持有锁的线程有可能很快就会释放锁。

如果线程请求获取监视器锁失败,并不立刻阻塞线程,而是让线程执行一个忙循环(自旋)。如果持有锁的线程能在短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞状态了,这样就避免了用户进程和内核切换的消耗。

自旋的消耗会小于上面所说的这种线程阻塞挂起再唤醒的消耗,后者的操作会导致线程发生两次上下文切换。

自旋实现的原理

image.png

自旋之后再次尝试获取锁。如果获取锁失败,这个过程会循环一定次数,超过某个阀值,如果还是获取不到锁,才阻塞线程。

自旋可以通过**-XX:+UserSpinning参数来开启,自旋的次数通过-XX:PreBlockSpin**来更改(默认是10)。

自旋锁的实现原理使CASAtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。

image.png

自旋锁的实现

我们用Java代码来实现一个简单的自旋锁:

 public class SpinLock {
 ​
     private AtomicBoolean available = new AtomicBoolean(false);
 ​
     public void lock(){
 ​
         // 循环检测尝试获取锁
         while (!tryLock()){
             // doSomething...
         }
 ​
     }
 ​
     public boolean tryLock(){
         // 尝试获取锁,成功返回true,失败返回false
         return available.compareAndSet(false,true);
     }
 ​
     public void unLock(){
         if(!available.compareAndSet(true,false)){
             throw new RuntimeException("释放锁失败");
         }
     }
 ​
 }

这种简单的自旋锁有一个问题:无法保证多线程竞争的公平性。

对于上面的SpinLock,当多个线程想要获取锁时,谁先将available设置为false谁就能最先获得锁,这可能会造成某些线程一直都未获取到锁造成「线程饥饿」。

一般我们会用排队的方式解决这样的问题,我们把这种锁叫做「排队自旋锁」(QueuedSpinlock)。

计算机科学家们使用了各种方式来实现排队自旋锁,如TicketLockMCSLockCLHLock...(但这些不是本章的重点,有兴趣的同学可以自行检索)

自旋锁的问题

自旋虽然避免了线程切换的损耗,但是需要占用处理器时间

自旋的效果取决于锁被占用的时间,如果锁被占用的时间很短,自旋等待的效果就会很好。

但是如果持有锁的线程需要长时间占用锁执行同步块,这个时候就不适合使用自旋锁了。这种情况下自旋只会白白消耗处理器资源,带来性能上的损耗。

如果锁竞争激烈,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗。这种情况下我们需要关闭自旋锁。

JDK1.6不仅将自旋锁变成默认开启,而且在JDK1.6还引入了自适应自旋锁用于解决上面的问题。

自适应自旋锁

自适应意味着什么?

自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

  • 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它允许自旋等待等待持续相对更长的时间。
  • 如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

自适应自旋有什么用?

自适应自旋解决的是「锁竞争时间不确定」的问题

JVM很难感知到确切的锁竞争时间,而交给用户分析就违反了JVM的设计初衷。

自适应自旋假定不同线程持有同一个锁对象的时间基本相当,竞争程度趋于稳定。因此,可以根据上一次自旋的时间与结果调整下一次自旋的时间。

偏向锁

为什么会有偏向锁?

大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,同一个线程反复获取锁,即便是按照轻量级锁的方式获取锁(CAS),也是有一定代价的,如何让这个代价更小一点呢?

这个时候我们就搬出来偏向锁来解决这个问题了。

什么是偏向锁?

偏向锁对象对应的Mark Word信息:

bit fields是否偏向锁锁标志位
threadIdepoch101

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。

「偏向」的实际含义是,偏向锁「假定」将来只有第一个申请锁的线程会使用锁(不会有任何线程再来申请锁)

因此,只需要再锁对象的Mark WordCAS记录owner(该线程的ID),如果记录成功,则偏向锁获取成功,记录对象的锁状态为偏向锁(最后3位设置为101),以后当线程等于owner就可以零成本的直接获取锁。

  • 什么是epoch?

    可以理解成是第几代偏向锁。

    • epoch有什么用?

      • 如果有线程请求偏向锁,需要判断Mark Word最后三位是否为101,是否指向的是当前线程的地址,并且判断epoch值和锁对象的类中的epoch值是否相同。如果都满足,那么说明当前线程持有该偏向锁,就可以直接返回。

      • 偏向锁在有竞争的时候是要执行撤销操作的,其实就是要升级成轻量级锁。

        而当一类对象撤销的次数过多,比如有个 Hello 类的对象作为偏向锁,经常被撤销,次数到了一定阈值(XX:BiasedLockingBulkRebiasThreshold,默认为 20 )就会把当代的偏向锁废弃,把类的 epoch 加一。

        所以当类对象和锁对象的 epoch 值不等的时候,当前线程可以将该锁重偏向至自己,因为前一代偏向锁已经废弃了。

偏向锁适应的并发场景

偏向锁适应无实际锁竞争,且将来只有第一个申请锁的线程会使用锁的场景。

偏向锁的升级(锁膨胀)

在多线程环境下,不可能只是同一个线程一直获取这个锁,如果出现了多个线程竞争的情况,也就有了偏向锁升级成轻量级锁的过程。

image.png

偏向锁无法使用自旋锁优化,一旦有其他线程申请锁,就破坏了偏向锁的「假定」。那么偏向锁将很快膨胀为轻量级锁。

偏向锁是有缺点的,它比较容易失效膨胀,一旦失效后就需要进行「锁撤销」的操作,接着我们就来讲讲锁撤销。

锁撤销

什么是锁撤销?

承接上面的偏向锁膨胀升级成轻量级锁的场景。

由于偏向锁失效了,那么接下来就得把该锁撤销,锁撤销的开销花费还是挺大的,大概过程如下:

  • 在一个安全点停止拥有锁的线程
  • 遍历线程栈,如果存在锁记录的话,需要修复锁记录和MarkWord,使其成为无锁状态
  • 唤醒当前线程,将当前锁升级成轻量级锁(这一点将在「轻量级锁」中详细介绍)

所以,如果某些同步代码块大多数情况下都是有两个及以上的线程竞争的话,那么偏向锁就会是一种累赘,对于这种情况,我们可以一开始就把偏向锁这个默认功能给关闭

可以使用参数-XX:-UseBiasedLocking禁止偏向锁优化(默认打开)

轻量级锁

什么是轻量级锁?

轻量级锁对象对应的Mark Word信息:

bit fields锁标志位
指向栈中锁记录的指针(ptr_to_lock_record)00

在JDK 1.6之后引入了轻量级锁,需要注意的是轻量级锁并不是替代重量级锁的,而是对在大多数情况下同步块并不会有竞争出现提出的一种优化。

轻量级锁的目标就是减少无实际竞争情况下,使用重量级锁产生的性能消耗。

轻量级锁是相对于重量级锁而言的。使用轻量级锁时,不需要申请互斥量(mutex),仅仅将Mark Word中部分字节CAS更新指向线程栈中的Lock Record,如果更新成功,则轻量级锁获取成功,记录锁状态为轻量级锁;否则,说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁。

轻量级锁适应的并发场景

轻量级锁适应无实际锁竞争,多个线程交替使用锁;允许短时间的锁竞争。

轻量级锁的升级

轻量级锁天然是瞄准不存在锁竞争的场景,如果存在锁竞争但不激烈,仍然可以用自旋锁优化,自旋失败后再膨胀成重量级锁

轻量级锁加锁

轻量级加锁过程

这个过程也可以叫做偏向锁撤销后升级成轻量级锁

  • 线程在自己的栈帧中创建锁记录LockRecord

    image.png

    • 什么是锁记录?

      在线程执行同步块之前,JVM会先在当前线程的栈帧中创建一个名为锁记录(Lock Record)的空间。

      Lock Record中包含一个_displaced_header属性,用于存储锁对象目前的Mark Word的拷贝(JVM会将对象头中的Mark Word拷贝到锁记录中)

  • 将锁对象的对象头中的MarkWord赋值到线程的刚刚创建的锁记录中

    image.png

    这个复制过来的记录叫做 Displaced Mark Word。具体来讲,是将 mark word 放到锁记录的 _displaced_header 属性中。

    (上图所示的对象没有被锁定,锁标志位为01状态。)

  • 将锁记录中的Owner指针指向锁对象

    image.png

  • 虚拟机使用CAS操作将锁对象的对象头的MarkWord更新为指向锁记录的指针,并且更新锁标志位为00

    image.png

    虚拟机使用CAS操作将锁对象对象头的Mark Word更新为指向Lock Record的指针。

    • 如果更新成功了,那么这个线程就有用了该对象的锁,并且对象Mark Word的锁标志位更新为00,即表示此对象处于轻量级锁定状态。
    • 如果这个更新操作失败,JVM会检查当前的Mark Word中是否存在指向当前线程的栈帧的指针

      如果有,说明该锁已经被获取,可以直接调用。

      如果没有,则说明该锁被其他线程抢占了,如果有两条以上的线程竞争同一个锁,那轻量级锁就不再有效,直接膨胀为重量级锁,没有获得锁的线程会被阻塞。此时,锁的标志位为10Mark Word中存储的时指向重量级锁的指针。

    注意到这里栈帧中的锁记录存储的锁对象加轻量锁之前的状态,

轻量级锁解锁

轻量级锁解锁过程

解锁的思路是使用 CAS 操作把当前线程的栈帧中的 Displaced Mark Word 替换回锁对象中去,如果替换成功,则解锁成功。

如果失败,表示有其他线程尝试过获取该锁,则要在释放锁的同时唤醒被挂起的线程。

image.png

锁消除

什么是锁消除?

「锁消除」是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除

(不会被其它线程访问的线程没有加锁的必要,于是加锁的的步骤会被消除)

锁消除的主要判定依据来源于逃逸分析的数据支持,意思就是:

JVM会判断在一段程序中的同步明显不会逃逸出去从而被其他线程访问到,那JVM就把它们当作栈上数据对待,认为这些数据是线程独有的,不需要加同步。此时就会进行锁消除。

当然在实际开发中,我们很清楚的知道哪些是线程独有的,不需要加同步锁。

但是在Java API中有很多方法都是加了同步的,那么此时JVM会判断这段代码是否需要加锁。如果数据并不会逃逸,则会进行锁消除。

  • 比如如下操作

     public static String testLockElimination(String s1, String s2, String s3) {
         String s = s1 + s2 + s3;
         return s;
     }
    

    在操作String类型数据时,由于String是一个不可变类,对字符串的连接操作总是通过生成的新的String对象来进行的。因此**Javac编译器会对String连接做自动优化。**

    在JDK 1.5之前会使用StringBuffer对象的连续append()操作,在JDK 1.5及以后的版本中,会转化为StringBuidler对象的连续append()操作。

    使用javap编译结果:

     public static java.lang.String testLockElimination(java.lang.String, java.lang.String, java.lang.String);
         descriptor: (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
         flags: ACC_PUBLIC, ACC_STATIC
         Code:
           stack=2, locals=4, args_size=3
              0: new           #6                  // class java/lang/StringBuilder
              3: dup
              4: invokespecial #7                  // Method java/lang/StringBuilder."<init>":()V
              7: aload_0
              8: invokevirtual #12                 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
             11: aload_1
             12: invokevirtual #12                 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
             15: aload_2
             16: invokevirtual #12                 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
             19: invokevirtual #13                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
             22: astore_3
             23: aload_3
             24: areturn
           LineNumberTable:
             line 25: 0
             line 26: 23
     }
    

    众所周知,StringBuilder不是安全同步的,但是在上述代码中,JVM判断该段代码并不会逃逸,则将该代码带默认为线程独有的资源,并不需要同步,所以执行了锁消除操作。

    (还有Vector中的各种操作也可实现锁消除。在没有逃逸出数据安全防卫内)

锁粗化

什么是锁粗化?

原则上,我们都知道在加同步锁时,尽可能的将同步块的作用范围限制到尽量小的范围(只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小。在存在锁同步竞争中,也可以使得等待锁的线程尽早的拿到锁)。

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

  • 比如下面的操作

     public static String testLockCoarsening(String s1, String s2, String s3) {
         StringBuffer sb = new StringBuffer();
         sb.append(s1);
         sb.append(s2);
         sb.append(s3);
         return sb.toString();
     }  
    

    在上述的连续append()操作中就属于这类情况。JVM会检测到这样一连串的操作都是对同一个对象加锁,那么**JVM会将加锁同步的范围扩展(粗化)到整个一系列操作的外部,使整个一连串的append()操作只需要加锁一次就可以了。**

重量级锁

什么是重量级锁?

重量级锁对象对应的Mark Word信息:

bit fields锁标志位
指向互斥锁(重量级锁)的指针(ptr_to_heavyweight_monitor)10

内置锁在Java中被抽象为监视器锁(monitor)。

在JDK 1.6之前,监视器锁可以认为直接对应底层操作系统中的互斥量(mutex)。

这种同步方式的成本非常高,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。因此,后来称这种锁为「重量级锁」。

当重量级锁已被获取时,其它线程要获取该锁都会进入阻塞状态。

重量级锁还负责实现了Semaphore(信号量)的功能,也就是说它至少包含一个竞争锁的队列,和一个信号阻塞队列(wait队列),前者负责做互斥,后一个用于做线程同步。

我们提到很多次重量级锁的开销了,那么重量级锁到底有什么开销呢?

重量级锁的开销包括:

  • 系统调用引起的内核态与用户态切换

    当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗CPU。

    但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。

  • 线程阻塞造成的线程切换

    等待获取重量级锁的线程要先进入阻塞状态,这个时候CPU时间片被释放,并分配给其它线程,当重量级锁被释放后该线程又要去竞争,竞争成功CPU时间片要分配给它执行任务,这个过程涉及到了几次线程切换,线程切换是需要开销的。

  • ...

重量级锁的适用场景

追求吞吐量,且锁占用时间较长。

使用Synchronized还有哪些需要注意的?

  • 锁对象不能为空,因为锁的信息都保存在对象头里

  • 作用域不宜过大,影响程序执行的速度,控制范围过大,编写代码也容易出错

  • 避免死锁

  • 在能选择的情况下,既不要用Lock也不要用synchronized关键字

    而是用java.util.concurrent包中的各种各样的类

    如果不用该包下的类,在满足业务的情况下,可以使用synchronized关键,因为代码量少,避免出错

小结

本篇我们针对synchronized关键字底层对锁的优化,依次介绍了自旋锁优化,偏向锁,轻量级锁等等锁优化机制。

相信通过本篇的学习大家可以对并发编程中的锁有一个比较全面的了解。

本章参考: