synchronized实现原理及其优化-(自旋锁,偏向锁,轻量锁,重量锁)

1.synchronized概述:

  synchronized修饰的方法或代码块相当于并发中的临界区,即在同一时刻jvm只允许一个线程进入执行。synchronized是通过锁机制实现同一时刻只允许一个线程来访问共享资源的。另外synchronized锁机制还可以保证线程并发运行的原子性,有序性,可见性。

2.synchronized的原理:

  我们先通过反编译下面的代码来看看Synchronized是如何实现对代码进行同步的:

  步骤:首先找到存放java文件的目录,在地址栏输入cmd进入命令行,然后执行javac test.java命令,形成class文件,接着执行javap -v test.class进行反编译。

【代码示例】:同步方法

 1 class thread  extends Thread{
 2     Object obj=new Object();
 3     @Override
 4     public synchronized void run() {
 5         System.out.println("run...");
 6     }
 7 }
 8 public class test {
 9     public static void main(String[] args) {
10         new thread().start();
11     }
12 }

反编译结果:

  从反编译的结果来看,Synchronized同步方法相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法是否设置访问标志 ACC_SYNCHRONIZED ,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 

 【代码演示】:同步代码块。

 1 class thread  extends Thread{
 2     Object obj=new Object();
 3     @Override
 4     public  void run() {
 5         synchronized(obj){
 6             System.out.println("run...");
 7         }
 8     }
 9 }
10 public class test {
11     public static void main(String[] args) {
12         new thread().start();
13     }
14 }

反编译结果: 

 关于这三条指令的作用,我们直接参考JVM规范中描述:

monitorenter :

  每个对象有一个监视器锁(monitor),当monitor被占用时该对象就会处于锁定状态。线程执行monitorenter指令时尝试获取monitor的所有权,如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。

如果线程已经占有该monitor,只是重新进入,则将monitor的进入数加1.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

monitorexit: 

  执行monitorexit的线程必须是monitor对应的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。 

  通过这两段描述,我们应该能很清楚的看出Synchronized同步块的实现原理,不过还有两点需要我们注意下,首先synchronized同步块对同一条线程来说是可重入的,不会出现自己将自己锁死的问题,但同步块在已进入程序执行完之前,是会阻塞后面其他线程的进入。通过上图我们也可知道Synchronized同步块的语义底层其实就是通过一个monitor的对象来完成,而我们前面学习的wait/notify等方法的调用也依赖于monitor对象,这也就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

  至于为什么Synchronized同步块要使用两个monitorexit指令?因为如果只使用一个,当线程在运行的过程中发生异常而无法释放锁时,就会造成死锁现象,因此另一个monitorexit指令的作用就是在线程发生异常时释放锁的。

 3.Synchronized的优化

  现在我们应该知道,Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”。

  在jdk1.5之前,只有synchronized重量级锁,实现需要借助操作系统,是比较消耗性能的操作,在1.6之中为了提高性能,便对synchronized锁进行了优化,实现了各种锁优化技术,如:适应性自旋,锁消除,锁粗化,轻量级锁,偏向锁。

  为了更好的掌握这几种锁,首先我们先学习一下Java对象的内存布局。

4.Java对象的内存布局

 

 

 上图就是Java对象内存布局中包含三大块:

对象头区域:
HotSpot虚拟机的对象头包括两部分信息:

  1.markword:第一部分markword,用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称它为“MarkWord”。

  2.Class:对象头的另外一部分是Class类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例.

  3.数组长度(只有数组对象有):如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度。如果不是数组,不存在数组长度的对象头信息。

实例数据
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。

对齐填充
第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

  通过上诉内容我们也可知道Synchronized锁就在Java的对象头中。

下面这个是32位的Mark Word的默认结构:

锁状态

25 bit

4bit

1bit

2bit

23bit

2bit

是否是偏向锁

锁标志位

轻量级锁

指向栈中锁记录的指针

00

重量级锁

指向互斥量(重量级锁)的指针

10

GC标记

11

偏向锁

线程ID

Epoch

对象分代年龄

1

01

无锁

对象的hashCode

对象分代年龄

0

01

从图中我们可以知道,锁的状态有四种:无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态。这几种状态是随着线程竞争情况逐渐升级的,锁可以升级但不允许降级,目的是提高获得锁和释放锁的效率。

5.轻量级锁

  轻量级锁是jdk1.6中加入的新型锁机制,它名字中的“轻量级”是相对于使用操作系统互斥量来实现的重量级锁而言的。首先需要强调的是,轻量级锁并不是来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统重量级锁使用操作系统互斥量产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。

  在轻量级锁的执行过程上,在代码进入同步块的时候,如果此同步对象没有被锁定,也就是说此时对象的锁标记位为“01”状态,那么虚拟机首先将在线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀),这时候线程堆栈与对象头的状态如图1。

  然后虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位将转变位00,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图2。

 

  如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行;否则说明这个锁已经被其他线程抢占了,由于有多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。

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

  如果线程之间不存在锁的竞争,与重量级锁相比,轻量级锁避免使用了互斥信号量,只使用了简单的CAS操作,但如果存在锁竞争,轻量级锁除了使用互斥信号量,还要额外发生CAS操作,因此在有竞争的情况下,轻量级锁会比重量级锁开销更大。

 

6.偏向锁  

   Java偏向锁是在jdk1.6中引入的,它的目的是消除数据无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除连CAS都不做。偏向锁,顾名思义,它会偏向于第一个访问它的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用锁的情况,则持有偏向锁的线程将永远是不需要在进行同步。如果运行过程中,遇到其它线程抢占资源,则持有偏向锁的线程会被挂起,jvm会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。

6.1由于偏向锁会转换成轻量级锁,那么许多人可能就会疑惑为什么不直接使用轻量级锁呢?

  引入偏向级锁是为了减少在无多线程竞争的情况下,尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换线程时,使用CAS操作把获取到这个锁的线程线程的ID记录在对象的Mark Word之中,从而减少性能消耗,不过遇到多线程竞争的情况时就必须撤销偏向锁。另外一个原因就是,在HotSpot虚拟机中,大多时候是不存在锁竞争的,常常是一个线程多次获取同一个锁,因此直接使用轻量级锁会增加很多不必要的消耗,所以可以才引入了偏向锁。

6.2偏向锁的升级过程

  假设当前虚拟机启用了偏向锁(启用参数 -XX:+UseBiasedLocking,这就是jdk1.6的默认值),那么,当锁对象第一次被线程获取时,虚拟机将会把对象头中的标志位设位01,即偏向模式,同时使用CAS操作把获取到这个锁的线程线程的ID记录在对象的Mark Word之中,由于偏向锁不会主动释放锁,所以持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不在进行任何同步操作。但当有另外的线程去尝试获取这个锁时,就需要查看锁对象头中记录的那个线程是否还存活,如果没有存活,那么锁对象就会被置为无锁状态,且这时候其他线程是可以竞争该锁,如果获取成功该锁,该锁就又被设为偏向锁;如果对象头中记录的那个线程仍存活,那就立即查找该线程的栈帧信息,判断是否还需要此锁,如果不需要,那么该锁对象就会被置为无锁状态,且偏向其他新的线程,如果还需要此锁,那么就先暂停当前线程,撤销掉偏向锁,升级为轻量级锁(00)的状态。

  偏向锁可以提高带有同步但无竞争的程序性能。但是如果程序中大多数锁总是被多个不同的线程访问,那么偏向锁模式就是多余的。

  

重量级锁、轻量级锁和偏向锁之间转换

 

7.自旋锁

  自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。

  但是线程自旋是需要消耗cup的,说白了就是让cup在做无用功,如果一直获取不到锁,那线程也不能一直占用cup自旋做无用功,所以需要设定一个自旋等待的最大时间。

  如果持有锁的线程执行的时间超过自旋等待的最大时间仍没有释放锁,这时其他争用线程会停止自旋进入阻塞状态。

优缺点:

  自旋锁尽可能的减少了线程的阻塞,这对于锁竞争不激烈,且占用锁时间非常短的代码块来说性能大幅度的提升了,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换!但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用cpu做无用功,会白白浪费CPU资源。同时如果有大量线程在竞争一个锁,会导致获取锁的时间很长,这时候线程自旋的消耗就大于线程阻塞挂起操作的消耗,同时其它需要cup的线程也因为不能获取到cpu,而造成cpu的浪费,这种情况下也不适合使用自旋锁;

  自旋锁的目的是为了占着CPU的资源不释放,等到获取到锁立即进行处理。但是如何去选择自旋的执行时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用CPU资源,进而会影响整体系统的性能。因此自旋的周期选的额外重要!

JVM对于自旋周期的选择,在jdk1.5时规定,当自旋操作超过了默认的限定次数10次,仍然没有获取到锁,那就应该使用传统的方式去挂起线程(当然用户可也以可使用参数-XX:PreBlockSpin来更改)。在1.6时便引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不在是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间,同时JVM还针对当前CPU的负荷情况做了较多的优化

  1. 如果平均负载小于CPUs则一直自旋

  2. 如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞

  3. 如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞

  4. 如果CPU处于节电模式则停止自旋

  5. 自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差)

  6. 自旋时会适当放弃线程优先级之间的差异

8.各种锁的使用场景

 

  偏向锁:通常只有一个线程访问临界区。

 

  轻量级锁:可以有多个线程交替进入临界区,在竞争不激烈的时候,稍微自旋就能获得锁。

 

  重量级锁:线程间出现了激烈的竞争就需要使用重量级锁,此时未获取到锁的线程会进入阻塞队列,需要操作系统介入。

  jvm设置偏向锁和轻量级锁,就是为了避免阻塞,避免操作系统的介入。

 

9.总结 

  本文重点介绍了JDk中采用轻量级锁和偏向锁等对Synchronized的优化,但是这两种锁也不是完全没缺点的,比如竞争比较激烈的时候,不但无法提升效率,反而会降低效率,因为多了一个锁升级的过程,这个时候就需要通过-XX:-UseBiasedLocking来禁用偏向锁。下面是这几种锁的对比:

优点

缺点

适用场景

偏向锁

加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。

如果线程间存在锁竞争,会带来额外的锁撤销的消耗。

适用于只有一个线程访问同步块场景。

轻量级锁

竞争的线程不会阻塞,提高了程序的响应速度。

如果始终得不到锁竞争的线程使用自旋会消耗CPU。

追求响应时间。

同步块执行速度非常快。

重量级锁

线程竞争不使用自旋,不会消耗CPU。

线程阻塞,响应时间缓慢。

追求吞吐量。

同步块执行速度较长。

 

posted @ 2020-03-18 18:03  小L要努力吖  阅读(4713)  评论(0编辑  收藏  举报