JUC并发编程系列详解篇十一(synchronized底层的锁)

synchronized锁的优化

操作系统分为“用户空间”和“内核空间”,JVM是运行在“用户态”的,jdk1.6之前,在使用synchronized锁时需要调用底层的操作系统实现,其底层monitor会阻塞和唤醒线程,线程的阻塞和唤醒需要CPU从“用户态”转为“内核态”,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,这些操作给系统的并发性能 带来了很大的压力。

同这个时候CPU就需要从“用户态”切向“内核态”,在这个过程中就非常损耗性能而且效率非常低,所以说jdk1.6之前的synchronized是重量级锁。如下图所示:
JUC并发编程系列详解篇十一(synchronized底层的锁)_第1张图片

简单来说在JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)如果每次都调用Mutex Lock那么将严重的影响程序的性能。不过在jdk1.6中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销。

  • 锁粗化(Lock Coarsening):也就是减少不必要的紧连在一起的unlock,lock操作,将多个连续的锁扩展成一个范围更大的锁。
  • 锁消除(Lock Elimination):通过运行时JIT编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,通过逃逸分析也可以在线程本的Stack上进行对象空间的分配(同时还可以减少Heap上的垃圾收集开销)。
  • 轻量级锁(Lightweight Locking):这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒。
  • 偏向锁(Biased Locking):是为了在无锁竞争的情况下避免在锁获取过程中执行不必要的CAS原子指令,因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟。
  • 适应性自旋(Adaptive Spinning):当线程在获取轻量级锁的过程中执行CAS操作失败时,在进入与monitor相关联的操作系统重量级锁(mutex semaphore)前会进入忙等待(Spinning)然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该monitor关联的semaphore(即互斥锁)进入到阻塞状态。

JUC并发编程系列详解篇十一(synchronized底层的锁)_第2张图片

无锁

无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功,其底层是通过CAS实现的。无锁无法全方位代替有锁,但无锁在某些场合下的性能是非常高的。
在这里插入图片描述

偏向锁(无锁 -> 偏向锁)

偏向锁的“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是这个锁会偏向于第一个获得它的线程,会在对象头存储锁偏向的线程ID,以后该线程进入和退出同步块时只需要检查是否为偏向锁、锁标志位以及 ThreadID即可。
在这里插入图片描述

一开始无锁状态,JVM会默认开启“匿名”偏向的一个状态,就是一开始线程还未持有锁的时候,就预先设置一个匿名偏向锁,等一个线程持有锁之后,就会利用CAS操作将线程ID设置到对象的mark word 的高23位上【32位虚拟机】,下次线程若再次争抢锁资源的时,多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,只需要在置换ThreadID的时候依赖一次CAS原子指令即可。如图所示:
JUC并发编程系列详解篇十一(synchronized底层的锁)_第3张图片

偏向锁的获取过程:
首先线程访问同步代码块,会通过检查对象头 Mark Word 的锁标志位判断目前锁的状态,如果是 01,说明就是无锁或者偏向锁,然后再根据是否偏向锁 的标示判断是无锁还是偏向锁,如果是无锁情况下,执行下一步。

线程使用 CAS 操作来尝试对对象加锁,如果使用 CAS 替换 ThreadID 成功,就说明是第一次上锁,那么当前线程就会获得对象的偏向锁,此时会在对象头的 Mark Word 中记录当前线程 ID 和获取锁的时间 epoch 等信息,然后执行同步代码块。

全局安全点(Safe Point):全局安全点的理解会涉及到 C 语言底层的一些知识,这里简单理解 SafePoint 是 Java代码中的一个线程可能暂停执行的位置。

等到下一次线程在进入和退出同步代码块时就不需要进行 CAS 操作进行加锁和解锁,只需要简单判断一下对象头的 Mark Word 中是否存储着指向当前线程的线程ID,判断的标志当然是根据锁的标志位来判断的。如果用流程图来表示的话就是下面这样:
JUC并发编程系列详解篇十一(synchronized底层的锁)_第4张图片
关闭偏向锁:
偏向锁在Java 6 和Java 7 里是默认启用的。由于偏向锁是为了在只有一个线程执行同步块时提高性能,如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

关于 epoch
偏向锁的对象头中有一个被称为 epoch 的值,它作为偏差有效性的时间戳。

轻量级锁(偏向锁 -> 轻量锁)

当线程交替执行同步代码块时,且竞争不激烈的情况下,偏向锁就会升级为轻量级锁。在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。
在这里插入图片描述

其目标就是在只有一个线程执行同步代码块时能够提高性能。当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。

在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。

引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。

撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
JUC并发编程系列详解篇十一(synchronized底层的锁)_第5张图片
轻量级锁是指当前锁是偏向锁的时候,资源被另外的线程所访问,那么偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能,下面是详细的获取过程。
轻量级锁加锁过程:

  • 紧接着上一步,如果 CAS 操作替换 ThreadID 没有获取成功,执行下一步
  • 如果使用 CAS 操作替换 ThreadID 失败(这时候就切换到另外一个线程的角度)说明该资源已被同步访问过,这时候就会执行锁的撤销操作,撤销偏向锁,然后等原持有偏向锁的线程到达全局安全点(SafePoint)时,会暂停原持有偏向锁的线程,然后会检查原持有偏向锁的状态,如果已经退出同步,就会唤醒持有偏向锁的线程,执行下一步
  • 检查对象头中的 Mark Word 记录的是否是当前线程 ID,如果是,执行同步代码,如果不是,执行偏向锁获取流程 的第2步。

JUC并发编程系列详解篇十一(synchronized底层的锁)_第6张图片

自旋锁

在很多场景下,共享资源的锁定状态只会持续很短的一段时间,为了这段时间阻塞和唤醒线程并不值得。

如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。

为了让线程等待,我们只需让线程执行一个忙循环(自旋) , 这就是自旋锁。

当一个线程t1、t2同事争抢同一把锁时,假如t1线程先抢到锁,锁不会立马升级成重量级锁,此时t2线程会自旋几次(默认自旋次数是10次,可以使用参数-XX : PreBlockSpin来更改),若t2自旋超过了最大自旋次数,那么t2就会当使用传统的方式去挂起线程了,锁也升级为重量级锁了。

自旋的等待不能代替阻塞,暂且不说对处理器数量的要求必须要两个核,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,如果锁被占用的时间很长,那自旋的线程只会消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。

自旋锁在jdk1.4中就已经引入,只不过默认是关闭的,可以使用-XX:+UseSpinning参数来开启,在jdk1.6之后自旋锁就已经默认是打开状态了。

自适应自旋锁

在JDK 1.6中引入了自适应自旋锁。这就意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋 时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到100此循环。相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,JVM对程序的锁的状态预测会越来越准确,JVM也会越来越聪明。

重量级锁

升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
在这里插入图片描述

重量级锁的获取流程:

  1. 接着上面偏向锁的获取过程,由偏向锁升级为轻量级锁,执行下一步。
  2. 会在原持有偏向锁的线程的栈中分配锁记录,将对象头中的 Mark Word 拷贝到原持有偏向锁线程的记录中,然后原持有偏向锁的线程获得轻量级锁,然后唤醒原持有偏向锁的线程,从安全点处继续执行,执行完毕后,执行下一步,当前线程执行第4步
  3. 执行完毕后,开始轻量级解锁操作,解锁需要判断两个条件:1、拷贝在当前线程锁记录的 Mark Word 信息是否与对象头中的 Mark Word 一致。2、判断对象头中的 Mark Word 中锁记录指针是否指向当前栈中记录的指针。
  4. 在当前线程的栈中分配锁记录,拷贝对象头中的 MarkWord 到当前线程的锁记录中,执行 CAS 加锁操作,会把对象头 Mark Word 中锁记录指针指向当前线程锁记录,如果成功,获取轻量级锁,执行同步代码,然后执行第3步,如果不成功,执行下一步。
  5. 当前线程没有使用 CAS 成功获取锁,就会自旋一会儿,再次尝试获取,如果在多次自旋到达上限后还没有获取到锁,那么轻量级锁就会升级为 重量级锁

使用流程图表示,如下图所示:
JUC并发编程系列详解篇十一(synchronized底层的锁)_第7张图片

锁消除

锁消除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。

public class SynchRemoveDemo {
    public static void main(String[] args) {
      stringContact("AA", "BB", "CC");
    }
    public static String stringContact(String s1, String s2, String s3) {
        StringBuffer sb = new StringBuffer();
        return sb.append(s1).append(s2).append(s3).toString();
    }
}
//StringBuffer 源代码中append()方法源码
@Override
public synchronized StringBuffer append(String str) {
   toStringCache = null;
   super.append(str);
   return this;
}

JVM字节码分析:
JUC并发编程系列详解篇十一(synchronized底层的锁)_第8张图片

StringBuffer的append()是一个同步方法,锁就是this也就是sb对象。虚拟机发现它的动态作用域被限制在stringContact()方法内部。

也就是说, sb对象的引用永远不会“逃逸”到stringContact()方法之外,其他线程无法访问到它,因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。

“对象的逃逸分析”:就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。JVM通过逃逸分析确定该对象不会被外部访问。如果不会逃逸可以将该对象优先在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。

锁粗化

JVM会探测到一连串细小的操作都使用同一个对象加锁,将同步代码块的范围放大,放到这串操作的外面,这样只需要加一次锁即可。
可以通过下面的例子来看一下:

public class SynchDemo {
    public static void main(String[] args) {
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < 50; i++) {
            sb.append("AA");
        }
        System.out.println(sb.toString());
    }
}

//StringBuffer 源代码中append()方法源码
@Override
public synchronized StringBuffer append(String str) {
   toStringCache = null;
   super.append(str);
   return this;
}

JVM字节码代码分析:
JUC并发编程系列详解篇十一(synchronized底层的锁)_第9张图片
StringBuffer的append()是一个同步方法,通过上面的代码可以看出,每次循环都要给append()方法加锁,这时系统会通过判断将其修改为下面这种,直接将原append()方法的synchronized的锁给去掉直接加在了for循环外。

public class SynchDemo {
    public static void main(String[] args) {
        StringBuffer sb = new StringBuffer();
        synchronized(sb){
            for (int i = 0; i < 50; i++) {
            sb.append("AA");
          }
        }
        System.out.println(sb.toString());
    }
}

//StringBuffer 源代码中append()方法源码
@Override
public StringBuffer append(String str) {
   toStringCache = null;
   super.append(str);
   return this;
}

锁的优缺点对比

JUC并发编程系列详解篇十一(synchronized底层的锁)_第10张图片

参考文章

  • https://www.pdai.tech/md/java/thread/java-thread-x-key-synchronized.html
  • 微信公众号(得物技术) :精选文章|深入理解synchronzied底层原理
  • 微信公众号(石衫的架构笔记):不懂什么是java的锁?看看这篇你就懂了!
  • https://blog.csdn.net/a745233700/article/details/119923661
  • 《深入理解Java虚拟机》+《Java并发编程的艺术》
  • https://juejin.im/post/5ae6dc04f265da0ba351d3ff
  • https://www.cnblogs.com/javaminer/p/3889023.html
  • https://www.jianshu.com/p/dab7745c0954
  • https://www.cnblogs.com/wuchaodzxx/p/6867546.html
  • https://www.cnblogs.com/xyabk/p/10901291.html
  • https://www.jianshu.com/p/64240319ed60

你可能感兴趣的:(java基础,Java高级特性,并发编程,jvm,java,面试)