Java的多线程机制系列:(三)synchronized的同步原理

synchronized关键字是JDK5之实现锁(包括互斥性和可见性)的惟一途径(volatile关键字能保证可见性,但不能保证互斥性,详细参见后文关于vloatile的详述章节),其在字节码上编译为monitorenter和monitorexit这样的JVM层次的原语(原语的意思是这个命令是原子执行的,中间不可中断,详细可查阅原语的概念,这里monitorenter和monitorexit是原语对,代表它们之间的代码段是原子执行的,因此保证了锁机制中的互斥性。若是反编译会发现同步函数的前面加上了monitorenter命令,而在其结束处加上monitorexit命令),JVM经过调用操做系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待从新调度,也就是如前面“用户态和内核态”章节所说的,在两个态之间来回切换,对性能有较大影响。 html

JDK5引入了现代操做系统新增长的CAS原子操做(JDK5中并无对synchronized关键字作优化,而是体如今J.U.C中,因此在该版本concurrent包有更好的性能),从JDK6开始,就对synchronized的实现机制进行了较大调整,包括使用JDK5引进的CAS自旋以外,还增长了自适应的CAS自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略(后面详述)。因为此关键字的优化使得性能极大提升,同时语义清晰、操做简单、无需手动关闭,因此java专家组推荐在容许的状况下尽可能使用此关键字,同时在性能上此关键字还有优化的空间。java

在《Java的多线程机制系列:(一)总述及基础概念》中曾经提到,锁机制有两种特性:互斥性和可见性。synchronized的互斥性经过在同一时间只容许一个线程持有某个对象锁来实现(这种串行也保证了指令有序性,即“一个unlock操做先行发生(happen-before)于后面对同一个锁的lock操做”);可见性被关注较少,其是经过Java内存模型中的“对一个变量unlock操做以前,必需要同步到主内存中;若是对一个变量进行lock操做,则将会清空工做内存中此变量的值,在执行引擎使用此变量前,须要从新从主内存中load操做或assign操做初始化变量值”来保证的。关于内存模型的简单介绍及指令重排序参见“《Java的多线程机制系列:(四)不得不提的volatile及指令重排序(happen-before)》”。数组

1、锁的内存结构

锁在内存上体现为何样的形式?前面说了锁是一个逻辑抽象,实际上是一种机制。在Java内存模型里在不一样机制下对应不一样的数据结构。每一个对象都有个长度2个字宽的对象头(在32位虚拟机里,1字宽是4个字节,64位虚拟机里,1字宽是8个字节。若是是数组对象,则对象头是3个字宽,其中第三个字存储数组的长度),这里面存储了对象的hashcode或锁信息,官方称它为“Mark Word”,以下图: 安全

2_thumb[2]

 

对象头的最后两位存储了锁的标志位,01是初始状态,未加锁,其对象头里存储的是对象自己的哈希码,随着锁级别的不一样,对象头里存储不一样的内容。偏向锁存储的是当前占用此对象的线程ID;而轻量级则存储指向线程栈中锁记录的指针。从这里咱们能够看到,“锁”这个东西,多是个锁记录+对象头里的引用指针(判断线程是否拥有锁时将线程的锁记录地址和对象头里的指针地址比较),也多是对象头里的线程ID(判断线程是否拥有锁时将线程的ID和对象头里存储的线程ID比较)。网络

在代码进入同步块的时候,若是此同步对象没有被锁定,即它的锁标志位是01,则虚拟机首先在当前线程的栈中建立咱们称之为“锁记录”的空间,用于存储锁对象的Mark Word的拷贝,官方把这个拷贝称为Displaced Mark Word。整个Mark Word及其拷贝相当重要,后面在介绍各个级别的锁的时候会详细叙述。数据结构

下面首先先介绍各类级别的锁及应用场景,而后介绍除了锁级别以外的其他优化策略。多线程

 

2、锁的级别 

1. 偏向锁

这是JDK6中的重要引进,由于hotspot做者通过研究实践发现,在大多数状况下,锁不只不存在多线程竞争,并且老是由同一线程屡次得到,为了让线程得到锁的代价更低,引进了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,之后该线程进入和退出同步块时不须要花费CAS操做来争夺锁资源。当一个线程但愿得到对象锁时,首先搜索下对象头里是否存储着当前线程的ID,若是是则直接使用(因为仅仅是查询比较、不须要写,因此不须要同步机制,只须要在将对象头设置为线程ID这个事是须要同步的,这使用CAS来实现:假设两个线程A和B来查看对象头的时候,都是无锁状态,那么线程A给对象头赋A的ID,CAS成功,此时若线程B再来更新对象头时,发现对象头的值已经不等于其以前读取的值了,就会更新失败,因此这能保证“将对象头设置为线程ID”是同步的),若是设置了则代表此对象已被别的线程锁定,则尝试发起替换对象头中的线程ID为本身的CAS请求,此时就进入偏量锁撤销、升级为轻量级锁的环节。 并发

偏向锁是等到有竞争资源时才释放的(这也是基于HotSpot做者发现同步代码段每每是被同一个线程使用的缘由),线程发起了替换对象头中的线程ID为自身的CAS请求,则持有锁的线程在安全的位置(无字节码正在执行)看拥有此偏向锁的线程是否还活着,若是不是活着,则置为无锁状态,以容许其他线程竞争。若是是活的,则挂起此线程,并将指向当前线程的锁记录地址的指针放入对象头,升级为轻量级锁,而后恢复持有锁的线程,进入轻量级锁的竞争模式。注意,这里将当前线程挂起再恢复的过程当中并无发生锁的转移,仍然在当前线程手中,只是穿插了个“将对象头中的线程ID变动为指向锁记录地址的指针”这么个事。app

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

在JDK5中偏向锁默认是关闭的,而到了JDK6中偏向锁已经默认开启。若是并发数较大同时同步代码块执行时间较长,则被多个线程同时访问的几率就很大,就可使用参数-XX:-UseBiasedLocking来禁止偏向锁(但这是个JVM参数,不能针对某个对象锁来单独设置)。

 

3. 轻量级锁

若是进入了轻量级锁的模式(不管是由偏向锁升级来的,仍是关闭了偏向锁直接进入轻量级锁),则每次线程想进入同步代码块的时候,都得经过CAS尝试将对象头中的锁指针替换为自身栈中的记录,若是没有成功,则进入了自适应的自旋。这个自适应自旋结束时尚未得到锁,则升级为重量锁。以下图(本图引自网络,因为是别的做者所画,这里注明出处,来源于淘宝工程师方腾飞的聊聊并发(二)——Java SE1.6中的Synchronized)。

为何升级为轻量锁时要把对象头里的Mark Word复制到线程栈的锁记录中呢?由于在申请对象锁时须要以该值做为CAS的比较条件,同时在升级到重量级锁的时候,能经过这个比较断定是否子持有锁的过程当中此锁被其余线程申请了(若是被其余线程申请了,则在释放锁的时候要唤醒被挂起的线程)。

关于什么是自适应后面再讲,但这里的尝试CAS没有成功有必定的混淆性,不少文章包括书籍都没有把这里说清楚,我以为有必要专门指出来。

为何会尝试CAS不成功以及什么状况下会不成功?

CAS自己是不带锁机制的,其是经过比较而来。假设以下场景:线程A和线程B都在对象头里的锁标识为无锁状态进入,那么如线程A先更新对象头为其锁记录指针成功以后,线程B再用CAS去更新,就会发现此时的对象头已经不是其操做前的对象HashCode了,因此CAS会失败。也就是说,只有两个线程并发申请锁的时候会发生CAS失败。

而后线程B进行CAS自旋,(后面这部分的逻辑我因为没有深刻研究JVM,也没有看到有资料介绍,而是根据CAS的概念推理出来,可能会不正确,若是谁有准确答案,望告知),等待对象头的锁标识从新变回无锁状态或对象头内容等于对象HashCode(由于这是线程B作CAS操做前的值),这也就意味着线程A执行结束(参见后面轻量级锁的撤销,只有线程A执行完毕撤销锁了才会重置对象头),此时线程B的CAS操做终于成功了,因而线程B得到了锁以及执行同步代码的权限。若是线程A的执行时间较长,线程B通过若干次CAS时钟没有成功,则锁膨胀为重量级锁,即线程B被挂起阻塞、等待从新调度。

轻量级锁的解锁过程也是经过CAS来操做。因为持有锁线程的锁记录里头存储着Displaced Mark Word,当线程执行完同步代码块后,将对象头里的锁记录指针所指向的地址和本身的锁记录地址相比较,若是相等则将对象头的内容替换为Displanced Mark Word,并将对象的标识重置为无锁状态。

下图来自这个地址Java轻量级锁原理详解(Lightweight Locking),其中不只描述了得到轻量级锁的过程,也描述了轻量级锁撤销的过程。

 

有一点令我不明白的是:大多文章和书籍都说,这里的CAS替换存在失败可能,即“若是对象头里的锁记录指针所指向的地址不等于本身的锁记录地址(为了后面描述方便,咱们暂将这个比较操做称为步骤Compare),则代表曾经有线程尝试过申请该锁,则须要在释放锁的同时,唤醒被挂起的线程”,咱们来考虑两个时间段:在对象锁为无锁状态时,线程B和线程A同时申请锁,在线程A成功获取的状况下,线程B要么是对象锁释放后CAS成功、要么是被挂起但此时对象头的内容始终保持是线程A的锁记录指针,步骤Compare不会失败;另一个时间段是:在线程A成功获取锁以后,即此时对象头已是轻量级锁状态时,线程B再发起锁申请,则因为状态不对,线程B立刻就进入挂起阻塞状态,不存在修改对象头的可能,步骤Compare也不会失败。那么到底是什么状况下会存在步骤Compare失败?还望知道的人告知。

 

4. 重量级锁

前面已经提到过,重量级锁就已经到了在操做系统级别了,调用的是互斥mutex命令,这也意味着若是线程没有获取到锁,则被挂起阻塞,等待从新调度,须要较频繁的内核态与用户态的切换,开销较大。

 

5.各锁级别的适用场景

各类锁并非相互代替的,而是在不一样场景下的不一样选择,绝对不是说重量级锁就是不合适的。每种锁是只能升级,不能降级,即由偏向锁->轻量级锁->重量级锁,而这个过程就是开销逐渐加大的过程。若是是单线程使用,那偏向锁毫无疑问代价最小,而且它就能解决问题,连CAS都不用作,仅仅在内存中比较下对象头就能够了;若是出现了其余线程竞争则偏向锁就会升级为轻量级锁,若是其余线程经过必定次数的CAS尝试没有成功则进入重量级锁,在这种状况下进入同步代码块就要作偏向锁创建、偏向锁撤销、轻量级锁创建、升级到重量级锁,最终仍是得靠重量级锁来解决问题,那这样的代价就比直接用重量级锁要大很多了。因此使用哪一种技术,必定要看其所处的环境及场景,在绝大多数的状况下,偏向锁是有效的,这是基于HotSpot做者发现的“大多数锁只会由同一线程并发申请”的经验规律。

 

3、锁的其余优化机制

1.自适应的CAS自旋

自旋的概念就是在一个无限循环中不断地去作CAS,直到成功为止,好比申请锁的过程。自旋不会使当前线程挂起、调度,省去了这部分时间,但它仍是会不断占据CPU时间的,若是持有锁的线程执行时间较长,这个自旋的持续时间就很长,对性能就会形成较明显的影响(咱们平时写个死循环就知道,机器立刻CPU使用率就很高),因此须要必定的保护机制,使CAS自旋必定次数以后,就再也不尝试了,如轻量级锁的CAS尝试多次不成以后就会升级为重量级锁。

那么自旋尝试多久合适?在JDK5中是尝试10次,JDK6引入了自适应的概念,即根据前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,若是上次通过必定尝试就成功了,则推断此次相应次数甚至更长一些的次数也极可能会成功,若是上次等了好久也没成功,则推断此次也极可能不成功,不多的CAS自旋就会放弃。这是颇有道理的,上次很快成功,说明同步代码块执行地很快、耗时不多,值得等一等;若是上次等好久也没成功,则其同步代码块执行比较耗时,如较长时间的IO操做,则此次也不必等了。随着时间的推移,经验逐渐累计,这样自适应的CAS自旋就愈来愈准确,应该说每段同步代码块的第一次并发执行会尝试多一些,后面的就会比较和实际匹配了。

 

2. 锁消除

虚拟机在运行时,有一些代码虽然要求同步,加了synchronized,但被检测到不可能存在共享数据的竞争,因此就把锁去除。举个简单例子,下面这个类是个累加器,i++方法不是原子的,因此须要用synchronized修饰,这没有问题

    private class Accumulator{
        private int val=0;
        public synchronized void increase(){
            val++;
        }
        public int getVal(){
            return val;
        }
    }

但使用累加器的方式是这样的,以下面代码

public class ClearLockDemo {
    
    public void execute(){
        Accumulator aor=new Accumulator();
        for(int i=0;i<100;i++){
            aor.increase();
        }
        int result=aor.getVal();
    }
}

虽说Accumulator的increase方法是线程不安全的,但在上面的execute方法中,建立了方法内的局部对象,也就是说是在单线程下循环运行,不存在多线程并发的问题,此时JVM就会据此判断从而优化,消除掉在increase执行前的锁判断,以提升效率。与此相似的还有StringBuffer的append方法,JDK提供的这个方法用synchronized修饰来保证线程安全,但若是是在方法内建立StringBuffer对象并append,则会锁消除。

 

3. 锁粗化

原则上咱们用synchronized修饰的代码块应该尽可能小,以减小同步代码执行时间,但若是在一个线程中针对同一个对象锁有较多连续的同步代码块,那么再每次进同步代码块都争取锁就会带来没必要要的效率损失,因此JVM在这种状况下会进行锁粗化。最多见的场景是循环里面调用方法,仍然是上面的ClearLockDemo的execute方法为例,假如说须要启用同步,那么在每一个循环体中都争夺锁、释放锁没有任何意义,JVM就会把整个循环都放在一个同步块下执行。

相关文章
相关标签/搜索