死磕Synchronized

前言

今天开始来写有关Java多线程的知识,此次要介绍的是synchronized关键字,咱们都知道它能够用来保证线程互斥地访问同步代码,也就是咱们常说的加锁,那么问题来了:什么是锁?锁到底长啥样?
在开始正文以前颇有必要先来了解一下锁的概念,一旦搞清楚这些概念,后面不少问题其实也就迎刃而解。segmentfault

什么是锁?

其实“锁”自己是个对象,synchronized这个关键字并非“锁”。
从语法上讲,Java中的每一个对象均可以看作一把锁,在HotSpot JVM实现中,锁有个专门的名字:监视器(Monitor)
Monitor对象存在于每一个Java对象的对象头中,这也是为何Java中任意对象能够做为锁的缘由,有关Monitor后续会详细介绍,有了这些概念看下面这张图应该就容易多了。数据结构

同步原理

当一个线程访问同步代码块时,首先是须要获得锁才能执行同步代码,当退出或者抛出异常时必需要释放锁,那么它是如何来实现这个机制的呢?咱们先看一段简单的代码:多线程

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("Method 1 start");
        }
    }
}

查看反编译后结果:架构

线程执行monitorenter指令时尝试获取monitor的全部权,过程以下:并发

  • 若是monitor的进入数为0,则该线程进入monitor,而后将进入数设置为1,该线程即为monitor的全部者;
  • 若是线程已经占有该monitor,只是从新进入,则进入monitor的进入数加1;
  • 若是其余线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再从新尝试获取monitor的全部权;

线程执行monitorexit指令用来释放monitor,执行该指令的线程必须是monitor的全部者。指令执行时,monitor的进入数减1,若是减1后进入数为0,那线程退出monitor,再也不是这个monitor的全部者。其余被这个monitor阻塞的线程能够尝试去获取这个 monitor 的全部权。布局

注意:monitorexit指令出现了两次,第1次为正常释放monitor;第2次为发生异常时释放monitor。 性能

经过上面的描述,咱们应该能很清楚的看出Synchronized的语义底层是经过一个monitor的对象来完成。优化

两个指令的执行是JVM经过调用操做系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待从新调度,会致使“用户态和内核态”两个态之间来回切换,对性能有较大影响。

对象头

上面讲到Monitor存在于每一个Java对象的对象头中,接下来就来具体看看对象头是个什么东西。this

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。以下图所示:spa

对象头主要包括两部分数据:Mark Word(标记字段)和 Class Pointer(类型指针)

  • Class Pointer 是对象指向它的类元数据的指针,虚拟机经过这个指针来肯定这个对象是哪一个类的实例
  • Mark Word 用于存储对象自身的运行时数据,好比哈希码、锁状态标识、GC年龄等信息。

Java对象头具体结构描述以下:

锁也能够分为无锁、偏向锁、轻量级锁和重量级锁4种状态,每种都会有对应的标志位,(后续介绍)

当一个线程获取到锁以后,在锁的对象头里面会有一个指向线程栈中锁记录(Lock Record)的指针。当咱们判断线程是否拥有锁时只要将线程的锁记录地址和对象头里的指针地址进行比较就行。那么这个Lock Record究竟是啥?

Lock Record(锁记录)

在线程进入同步代码块的时候,若是此同步对象没有被锁定,即它的锁标志位是01,则虚拟机首先在当前线程的栈中建立咱们称之为“锁记录(Lock Record)”的空间,用于存储锁对象的Mark Word的拷贝

Lock Record是线程私有的数据结构,每个线程都有一个可用Lock Record列表。每个被锁住的对象Mark Word都会和一个Lock Record关联(对象头的MarkWord中的Lock Word指向Lock Record的起始地址),同时Lock Record中有一个Owner字段存放拥有该锁的线程的惟一标识,表示该锁被这个线程占用

以下图所示为Lock Record的内部结构:

锁的优化

从JDK6开始,就对synchronized的实现机制进行了较大调整,包括使用JDK5引进的CAS自旋以外,还增长了自适应的CAS自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,锁能够从偏向锁升级到轻量级锁,再升级到重量级锁。可是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级

自旋锁

阻塞或唤醒一个Java线程须要操做系统切换CPU状态来完成,这种状态转换须要耗费处理器时间。在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。

所谓自旋锁,就是指当一个线程尝试获取某个锁时, 若是该锁已被其余线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。

图片3

自旋锁自己是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。若是锁被占用的时间很短,自旋等待的效果就会很是好。反之,若是锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。因此,自旋等待的时间必需要有必定的限度,若是自旋超过了限定次数(默认是10次,可使用-XX:PreBlockSpin来更改)没有成功得到锁,就应当挂起线程。

自旋锁的实现原理一样也是CAS,AtomicInteger中调用unsafe进行自增操做的源码中的do-while循环就是一个自旋操做,若是修改数值失败则经过循环来执行自旋,直至修改为功,若是对CAS不了解能够参考一文完全搞懂CAS

图片4

自适应自旋锁

JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数再也不是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。那它如何进行适应性自旋呢?

线程若是自旋成功了,那么下次自旋的次数会更加多,由于虚拟机认为既然上次成功了,那么这次自旋也颇有可能会再次成功,那么它就会容许自旋等待持续的次数更多。反之, 若是对于某个锁,不多有自旋可以成功,那么在之后要或者这个锁的时候自旋的次数会减小甚至省略掉自旋过程,以避免浪费处理器资源。

锁消除

在有些状况下,JVM检测到不可能存在共享数据竞争,这时会对这些同步锁进行锁消除

好比下面这个例子:

public void vectorTest(){
    Vector<String> vector = new Vector<String>();
    for(int i = 0 ; i < 10 ; i++){
        vector.add(i + "");
    }
    System.out.println(vector);
}

在运行这段代码时,JVM能够明显检测到变量vector没有逃逸出方法vectorTest()以外,因此JVM能够大胆地将vector内部的加锁操做消除。

锁粗化

若是一系列的连续加锁解锁操做,可能会致使没必要要的性能损耗,锁粗化就是将多个连续的加锁、解锁操做链接在一块儿,扩展成一个范围更大的锁

好比上面那个例子,vector每次add的时候都须要加锁操做,JVM检测到对同一个对象(vector)连续加锁、解锁操做,会合并一个更大范围的加锁、解锁操做,即加锁解锁操做会移到for循环以外。

偏向锁

在大多数状况下,锁老是由同一线程屡次得到,不存在多线程竞争,因此出现了偏向锁。其目标就是在只有一个线程执行同步代码块时可以提升性能。

偏向锁是在单线程执行代码块时使用的机制,若是在多线程并发的环境下(即线程A还没有执行完同步代码块,线程B发起了申请锁的申请),则必定会转化为轻量级锁或者重量级锁。

引入偏向锁是为了在无多线程竞争的状况下尽可能减小没必要要的轻量级锁执行路径,由于轻量级锁的获取及释放依赖屡次CAS原子指令,而偏向锁只须要在比较ThreadID的时候依赖一次CAS原子指令便可。

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,之后该线程进入和退出同步块时不须要花费CAS操做来争夺锁资源,只须要检查是否为偏向锁、锁标识为以及ThreadID便可

处理流程以下:

  • 检测对象头的Mark Word字段判断是否为偏向锁状态
  • 若为偏向锁状态,则判断线程ID是否为当前线程ID,若是是则执行同步代码块
  • 若是线程ID不为当前线程ID,则经过CAS操做竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID
  • 经过CAS竞争锁失败,证实当前存在多线程竞争状况,得到偏向锁的线程被挂起,偏向锁升级为轻量级锁

偏向锁只有遇到其余线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁

偏向锁在JDK 6及之后的JVM里是默认启用的。能够经过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭以后程序默认会进入轻量级锁状态。

轻量级锁

当多个线程竞争偏向锁时就会升级为轻量级锁,其余线程会经过自旋的形式尝试获取锁,不会阻塞,从而提升性能,其具体步骤以下:

(1)在线程进入同步块时,若是同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中创建一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。此时线程堆栈与对象头的状态以下图所示:

(2)拷贝对象头中的Mark Word复制到锁记录(Lock Record)中。

(3)拷贝成功后,虚拟机将使用CAS操做尝试将对象Mark Word中的Lock Word更新为指向当前线程Lock Record的指针,并将Lock record里的owner指针指向object mark word

(4)若是这个更新动做成功了,那么当前线程就拥有了该对象的锁,此时将对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态。

(5)若是这个更新操做失败了,虚拟机首先会检查对象Mark Word中的Lock Word是否指向当前线程的栈帧,若是是,就说明当前线程已经拥有了这个对象的锁,那就能够直接进入同步块继续执行。不然说明多个线程竞争锁,进入自旋状态,若自旋结束时仍未得到锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,当前线程以及后面等待锁的线程也要进入阻塞状态。

对于轻量级锁,其性能提高的依据是 “对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,若是打破这个依据则除了互斥的开销外,还有额外的CAS操做, 所以在有多线程竞争的状况下,轻量级锁比重量级锁更慢

轻量级锁所适应的场景是线程交替执行同步块的状况,当屡次CAS自旋仍未得到锁时,锁就会升级为重量级锁。

重量级锁

Synchronized是经过对象内部的一个叫作 监视器锁(Monitor)来实现的可是监视器锁本质又是依赖于底层的操做系统的Mutex Lock来实现的。而操做系统实现线程之间的切换这就须要从用户态转换到核心态,这个成本很是高,状态之间的转换须要相对比较长的时间,这就是为何Synchronized效率低的缘由。所以,这种依赖于操做系统Mutex Lock所实现的锁咱们称之为 “重量级锁”

锁的优劣

各类锁并非相互代替的,而是在不一样场景下的不一样选择,绝对不是说重量级锁就是不合适的。

图片5

  • 若是是单线程使用,那偏向锁毫无疑问代价最小,而且它就能解决问题,连CAS都不用作,仅仅在内存中比较下对象头就能够了;
  • 若是出现了其余线程竞争,则偏向锁就会升级为轻量级锁;
  • 若是其余线程经过必定次数的CAS尝试没有成功,则进入重量级锁;

其它

看了上面以后也能够解决另外其它问题,好比:

为何notify/notifyAll/wait等方法要定义在Object类中?

就是由于每一个对象都是一把锁,每把锁(对象)均可以调用wait方法来改变当前对象头里的指针,所以定义在Object类里是最合适的。

那为何wait方法必须在同步代码块(synchronized修饰)里面使用?

wait方法用来挂起当前线程并释放持有的锁,你要先用synchronized加锁以后才能去释放锁,notify/notifyAll/wait等方法也会使用到Monitor锁对象,所以wait等方法须要在同步代码块中使用。

总结

有关synchronized总算介绍完了,若是有哪里不对的地方请帮忙指正,后续的话估计会写篇Java Lock的文章,谢谢!

参考

啃碎并发(七):深刻分析Synchronized原理 猿码架构
Synchronized锁定的是什么

相关文章
相关标签/搜索