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

时间:2022-09-11 16:58:06

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

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

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

一、锁的内存结构

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

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

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

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

下面首先先介绍各种级别的锁及应用场景,然后介绍除了锁级别之外的其余优化策略。

二、锁的级别

1. 偏向锁

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

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

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

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

3. 轻量级锁

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

为什么升级为轻量锁时要把对象头里的Mark Word复制到线程栈的锁记录中呢?因为在申请对象锁时需要以该值作为CAS的比较条件,同时在升级到重量级锁的时候,能通过这个比较判定是否子持有锁的过程中此锁被其他线程申请了(如果被其他线程申请了,则在释放锁的时候要唤醒被挂起的线程)。

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

关于什么是自适应后面再讲,但这里的尝试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),其中不仅描述了获得轻量级锁的过程,也描述了轻量级锁撤销的过程。

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

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

4. 重量级锁

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

5.各锁级别的适用场景

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

三、锁的其他优化机制

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就会把整个循环都放在一个同步块下执行。