多线程学习-锁升级

前言

本篇文章主要学习synchronized关键字在JDK1.6引入的偏向锁轻量级锁,并围绕synchronized关键字的锁的升级进行展开讨论。本篇文章讨论的锁是通过synchronized加的锁,是不同于java.util.concurrent.locks.Lock的另外一种加锁机制,后续文中提及,均指synchronized关键字的锁。

参考资料:《Java并发编程的艺术》

正文

一. 锁的使用

synchronized可以用于修饰普通方法静态方法代码块,访问被synchronized关键字修饰的内容需要先获取锁,获取的这个锁具体是什么,这里暂不讨论,下面先举例子来看一下synchronized关键字如何使用。

public class SynchronizedLearn {

    //修饰普通方法
    public synchronized void normalSyncMethod() {
        ......
    }

    //修饰静态方法
    public static synchronized void staticSyncMethod() {
        ......
    }

    //修饰代码块
    public void syncCodeBlock() {
        synchronized (SynchronizedLearn.class) {
            ......
        }
    }

}

上述例子中,使用synchronized关键字修饰代码块时,传入了SynchronizedLearn类的类对象,实际上,synchronized关键字无论是修饰方法还是修饰代码块,均需要传入一个对象,我们这里可以将传入的这个对象理解为,只不过在修饰方法时,会隐式地传入对象作为锁,规则如下。

  • 修饰普通方法时,隐式传入的对象为持有普通方法的实例对象本身;
  • 修饰静态方法时,隐式传入的对象为持有静态方法的类的类对象。

我们称由synchronized关键字修饰的方法为同步方法,结合上述规则,对由synchronized关键字修饰的同步方法的访问有如下注意点。

1. 实例对象的所有普通同步方法同一时刻只能由一个线程访问

给出一个例子如下所示。

public class SynchronizedLearn {

    public synchronized void normalSyncMethod1() {
        ......
    }

    public synchronized void normalSyncMethod2() {
        ......
    }

}

某时刻线程A和线程B都持有SynchronizedLearn的同一个实例synchronizedLearn,并且线程A成功调用实例synchronizedLearnnormalSyncMethod1()方法,此时在线程A执行完normalSyncMethod1()方法以前,线程B都无法访问normalSyncMethod1()normalSyncMethod2()方法。

2. 类的所有静态同步方法同一时刻只能由一个线程访问

给出一个例子如下所示。

public class SynchronizedLearn {

    public static synchronized void staticSyncMethod1() {
        ......
    }

    public static synchronized void staticSyncMethod2() {
        ......
    }

}

某时刻线程A成功调用SynchronizedLearn类的staticSyncMethod1()方法,此时在线程A执行完staticSyncMethod1()方法以前,线程B都无法访问staticSyncMethod1()staticSyncMethod2()方法。

3. 类的静态同步方法和类实例的普通同步方法同一时刻可以由不同线程访问

给出一个例子如下所示。

public class SynchronizedLearn {

    public synchronized void normalSyncMethod() {
        ......
    }

    public static synchronized void staticSyncMethod() {
        ......
    }

}

某时刻线程A持有SynchronizedLearn的实例synchronizedLearn并调用了其normalSyncMethod()方法,无论线程A是否执行完normalSyncMethod()方法,线程B都可以访问SynchronizedLearn类的staticSyncMethod()方法。

二. 锁是什么

上一小节中提到:synchronized关键字无论是修饰方法还是修饰代码块,均需要传入一个对象,这个对象可以理解为锁。在JVM中通过monitorentermonitorexit指令来保证同步代码块的线程安全(同步方法是另外一种方式,由具体虚拟机决定其实现,这里不做讨论),monitorenter指令是在编译后插入到同步代码块的起始位置,monitorexit指令是在编译后插入到同步代码块的结束位置,当线程执行到monitorenter指令时,会尝试获取通过synchronized关键字传入的对象相关联的Monitor对象的所有权,即获取锁,当线程执行到monitorexit指令时,会放弃Monitor对象的所有权,即释放锁。

上面提到的Monitor是一种同步机制,一般由一个对象来实现,称之为Monitor对象,在Java中每一个Java对象都与一个Monitor对象相关联,具体的关联关系可以理解为共生共灭。在HotSpot虚拟机中,Monitor对象是ObjectMonitor对象,获取synchronized关键字传入的对象实际上就是获取和该对象关联的ObjectMonitor,其由c++实现,如下所示。

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,
    _recursions   = 0; //重入次数
    _object       = NULL;
    _owner        = NULL; //表示当前持有Monitor的线程
    _WaitSet      = NULL; //等待队列
    _WaitSetLock  = 0;
    _Responsible  = NULL;
    _succ         = NULL;
    _cxq          = NULL;
    FreeNext      = NULL; 
    _EntryList    = NULL; //同步队列
    _SpinFreq     = 0;
    _SpinClock    = 0;
    OwnerIsThread = 0;
}

ObjectMonitor对象中维护了两个队列,分别是_EntryList_WaitSet,其中_EntryList是同步队列,每一个想要获取ObjectMonitor对象所有权的线程会进入该队列,如果获取ObjectMonitor对象所有权失败,则阻塞在该队列中,直到成功获取到ObjectMonitor对象所有权才会从_EntryList中移除,而_WaitSet是等待队列,当持有ObjectMonitor对象所有权的线程调用和ObjectMonitor对象关联的Java对象的wait()方法时,就会释放ObjectMonitor对象的所有权并进入_WaitSet队列中等待。那么到这里可以发现,Monitor对象和队列同步器AbstractQueuedSynchronizer很像,所以不太严谨的将synchronized的锁机制与java.util.concurrent.locks.Lock的锁机制进行一个比对:Java中的所有对象都是一把锁,可以和ReentrantLock这样的锁相对应,而每个Java对象与一个Monitor对象相关联,可以和ReentrantLock这样的锁的自定义同步器相对应。

三. 锁的升级

JDK1.6引入了偏向锁轻量级锁,用于解决synchronized关键字太过重量的问题。已知synchronized关键字修饰方法或者代码块时会传入对象作为锁,实际上synchronized关键字的锁存在于传入对象的对象头中,对于非数组类型的对象的对象头占用2个字宽,32/64位虚拟机中每字宽占用大小为32/64 bits,如下表所示。

长度 内容项 说明
32/64 bits Mark Word 存储对象hashCode或锁信息
32/64 bits Class Metadata Address 存储指向对象类型数据的指针

上表中的Mark Word在无锁状态下存储的数据为对象hashCode,在偏向锁轻量级锁重量级锁状态下存储的数据为锁信息,如下所示。

多线程学习-锁升级_第1张图片

在不通过设置JVM参数主动关闭偏向锁的情况下偏向锁默认开启,并随着竞争的逐渐激烈,锁会逐步升级为轻量级锁重量级锁,在第二小节中提及的ObjectMonitor对象,实际就是锁膨胀为重量级锁之后会使用到的数据。下面将对偏向锁,轻量级锁和重量级锁及其锁升级过程进行分析。

1. 偏向锁

通常,锁被认定为不会存在多线程竞争,并且锁会被同一线程重复获取,这种情况下,锁会偏向某一个线程,即偏向锁。下面分别对偏向锁的获取偏向锁的重偏向偏向锁的撤销进行分析。

1)偏向锁的获取和重偏向
一开始,锁对象Mark Word中的是否可偏向标志位为1,锁标志位为01,且线程ID为空,这表示锁可偏向且未偏向。如果线程判断锁是可偏向且未偏向状态,那么线程会基于CAS将锁对象Mark Word的线程ID字段设置为当前线程ID,如果设置成功,表示成功获取到偏向锁,如果设置失败,表示出现了竞争,需要执行偏向锁的撤销逻辑。

如果锁对象Mark Word中的是否可偏向标志位为1,锁标志位为01,且线程ID不为空,此时获取锁的线程需要将锁对象Mark WordEpoch字段值与锁对象所属类的类信息中的Epoch字段值进行比较,如果相等,则锁是可偏向且已偏向状态,如果不相等,则锁是可偏向且重偏向状态,根据不同的状态获取偏向锁的逻辑如下所示。

  • 如果是可偏向且已偏向状态,则判断锁对象Mark Word的线程ID字段是否和当前线程的线程ID相等,若相等,表示当前线程是锁偏向的线程,当前线程可以成功获取偏向锁,若不相等,表示发生了锁竞争,此时需要执行偏向锁的撤销逻辑;
  • 如果是可偏向且重偏向状态,表示偏向锁可以被线程获取(等同于可偏向且未偏向状态),则当前线程可以以CAS方式来竞争获取偏向锁。

因为偏向锁不会有主动释放锁这个操作,所以某线程获取到偏向锁并执行完同步代码后,尽管这个线程不再需要偏向锁,但是锁对象Mark Word的线程ID字段依旧为这个线程的ID,所以偏向锁会使用锁对象Mark Word中的Epoch字段实现重偏向逻辑,即使得出现上述情况时,另外的线程依旧可以获取到偏向锁。Eopch字段的实现逻辑如下所示。

  • 偏向锁的锁对象Mark Word中会存储一份Epoch值,同时在偏向锁对象所属类的类信息中也会存储一份Epoch值;
  • 每到达全局安全点(该时间点没有正在执行的字节码),类信息中的Epoch值会加1,得到Epoch_new,然后遍历类的所有实例对象,并判断这些对象是否还作为偏向锁被某个线程持有,如果是,则将Epoch_new赋值给对象Mark WordEpoch字段;
  • 当出现锁对象Mark Word中的线程ID字段值不会空时,就将Mark WordEpoch字段值与类信息中的Epoch字段值进行比较,如果相等,表明该锁还被某线程持有,如果不相等,则表明锁未被持有,该锁可以重新偏向某个线程。

3)偏向锁撤销
偏向锁的撤销,需要等到全局安全点,然后根据锁对象Mark Word中的线程ID找到被偏向的线程并暂停,再然后判断被偏向线程是否执行完同步代码,最后根据判断结果执行不同的偏向锁撤销逻辑,如下所示。

  • 如果被偏向线程已经执行完同步代码,则锁会从偏向锁状态被置为无锁状态,此时锁对象Mark Word中的是否可偏向字段为0,表示不可偏向,此时的锁对象是不可偏向状态,后续如果需要获取该锁,直接以轻量级锁的方式获取;
  • 如果线程还未执行完同步代码,则偏向锁会直接升级为轻量级锁。

最后会唤醒在全局安全点被暂停的线程。

2. 轻量级锁

在偏向锁状态下,如果发生锁竞争,则偏向锁会升级为轻量级锁,或者通过设置JVM参数禁用偏向锁,获取锁时会直接获取轻量级锁。

1)轻量级锁的获取
线程执行同步代码前,若锁对象为无锁状态,则会在当前线程栈帧中创建用于存储锁记录(Lock Record)的内存空间,然后将锁对象的Mark Word复制到当前线程栈帧的锁记录中,称复制到锁记录中的Mark WordDisplaced Mark Word,同时锁记录中的owner指针会指向锁对象,再然后基于CAS方式将锁对象的Mark Word替换为指向当前线程栈帧锁记录的指针,如果替换成功,表示成功获取到轻量级锁,如果替换失败,则表示发生了竞争,当前线程进入自旋状态尝试获取锁。

锁记录的结构如下所示。

多线程学习-锁升级_第2张图片

2)轻量级锁的重入
当线程要获取锁时,如果锁对象已经是轻量级锁,此时判断锁对象头的指向线程栈帧锁记录的指针是否指向当前线程栈帧锁记录,如果不是,则表示其它线程持有该轻量级锁,则当前线程进入自旋状态尝试获取锁,如果是,则表示当前线程持有该轻量级锁,此时是锁重入

锁重入时,持有锁的线程会在线程栈帧又创建存储锁记录的内存空间,同时Displaced Mark Word为空,owner指针指向锁对象。每重入一次,就会创建一次Displaced Mark Word为空的锁记录内存空间,如下所示。

多线程学习-锁升级_第3张图片

3)轻量级锁的升级
线程在获取轻量级锁时,有两种情况会获取失败并进入自旋获取锁的状态,如下所示。

  • 线程将锁对象的Mark Word复制到线程栈帧锁记录后,如果基于CAS方式将锁对象的Mark Word替换为指向当前线程栈帧锁记录的指针失败,此时会进入自旋获取锁的状态;
  • 线程获取锁时,如果锁对象是轻量级锁状态,但已经被其它线程持有,此时会进入自旋获取锁的状态。

因为线程自旋获取锁会消耗CPU资源,所以不会让自旋获取锁的线程一直自旋,当自旋达到一定次数时,会判断自旋获取锁失败,此时会让轻量级锁升级为重量级锁,重量级锁的锁对象Mark Word会变成指向锁对象关联的ObjectMonitor对象的指针,锁标志位会置为10,同时阻塞自旋获取锁失败的线程。

4)轻量级锁的释放
持有轻量级锁的线程退出同步代码时会释放轻量级锁,会基于CAS方式将线程栈帧锁记录的Displaced Mark Word替换回到锁对象的Mark Word中,如果替换成功,表明成功释放轻量级锁,如果替换失败,表明发生了锁竞争且锁已经升级为重量级锁,此时线程释放锁并唤醒在重量级锁上等待的线程。

3. 重量级锁

当锁从轻量级锁状态升级为重量级锁状态后,此时锁是一种悲观锁,所有竞争获取锁失败的线程会被阻塞,直到持有锁的线程释放锁则会又加入对锁的竞争中。

重量级锁的锁对象的Mark Word为指向锁对象关联的ObjectMonitor对象的指针,重量级锁的同步语义也是依赖ObjectMonitor对象来实现,该部分内容在第二节中已经进行了分析,这里不再重复讨论。

总结

synchronized关键字的锁有四种状态:无锁状态偏向锁状态轻量级锁状态重量级锁状态,这四种状态会随着竞争的逐渐激烈而依次升级。使用偏向锁是为了降低锁总是会被同一线程重复获取场景下获取锁的开销,使用轻量级锁是为了让竞争获取锁的线程不被阻塞从而避免线程用户态和内核态之间切换的消耗,而当锁升级到重量级锁后,此时悲观地认为锁资源竞争很激烈,每个竞争锁资源失败的线程直接被阻塞从而避免自旋获取锁时对CPU资源的消耗

你可能感兴趣的