Synchronized原理

Synchronized原理

1、Synchronized使用

//synchronized代码块
synchronized (SynchronizedTest.class){
    System.out.println("进入同步代码块");
}
//synchronized代码方法
public synchronized void testFun(){
    System.out.println("进入同步方法");
}

字节码:

public class SynchronizedTest {
    public static void main(String[] args) {
        synchronized (SynchronizedTest.class){
            System.out.println("进入同步代码块");
        }
    }
}
Code:
  stack=2, locals=3, args_size=1
     0: ldc           #2                  // class jiang/JVM/SynchronizedTest
     2: dup
     3: astore_1
     4: monitorenter
     5: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
     8: ldc           #4                  // String 进入同步代码块
    10: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
    13: aload_1
    14: monitorexit
    15: goto          23
    18: astore_2
    19: aload_1
    20: monitorexit
    21: aload_2
    22: athrow
    23: return

我们发现有两条陌生的指令:monitorentermonitorexit

Javac在编译时,会生成对应的monitorentermonitorexit指令分别对应synchronized同步块的进入和退出

其中,我们可以发现有两个monitorexit,这是因为:

为了保证抛异常的情况下也能释放锁,所以java为同步代码块添加了一个隐式的try-finally,在finally中会调用monitorexit命令释放锁。

对于synchronized方法而言,javac为其生成了一个ACC_SYNCHRONIZED关键字,在JVM进行方法调用时,发现调用的方法被ACC_SYNCHRONIZED修饰,则会先尝试获得锁。

我们发现在字节码中出现了两次monitorexit。这是在两个不同的代码路径上执行一次。

  • 第一个指令用于synchronized块的正常退出。
  • 为了保证抛出异常的情况下也能释放锁,所以Javac为同步代码块添加一个隐式try-finally,在finally中会调用monitorexit命令释放锁。可以看到,在第一个monitorexit之后有一个goto 23,说明,直接跳到23行指令,因此有两个monitorexit并没有执行
monitorenter

每一个对象都会和一个监视器锁monitor关联。监视器被占用时会被锁住,其他线程无法来获取该monitor。当JVM执行某个线程的某个方法内部的monitorenter时,它会尝试去获取当前对象对应的monitor的所有权。过程如下:

  1. 若monitor的进入数为0,线程可以进入monitor,并将monitor的进入树置为1.当前线程成为monitor的ower(所有者)
  2. 若线程已拥有monitor的所有权,允许它重入monitor,则进入monitor的进入数加1
  3. 若其他线程已经占有monitor的所有权,那么当前尝试获取monitor的所有权的线程会被阻塞,直到monitor的进入数变为0,才能重新尝试获取monitor的所有权。

synchronized的锁对象会关联一个monitor,这个monitor不是我们主动创建的,是JVM的线程执行到这个同步代码块,发现锁对象没有monitor就会创建monitor,monitor内部有两个重要的成员变量owner:拥有这把锁的线程,recursions会记录线程拥有锁的次数,当一个线程拥有monitor后其他线程只能等待

monitorexit

  1. 能执行monitorexit指令的线程一定是拥有当前对象的monitor的所有权的线程。

  2. 执行monitorexit时会将monitor的进入数减1。当monitor的进入数减为0时,当前线程退出monitor,不再拥有monitor的所有权,此时其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。

monitorexit释放锁。monitorexit插入在方法结束处和异常处,JVM保证每个monitorenter必须有对应的monitorexit。

2、Java对象头

synchronized用的锁是存放在Java对象头里的。如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头。如果对象是非数组类型,则用2字宽存储对象头。

在32位虚拟机中,1字宽等于4字节,即32bit。

普通对象:

image-20210520131637139

数组对象:

image-20210520131541197

Mark Word:存储对象的hashCode或一些锁信息

Klass Word:存储到对象类型数据的指针

array length:数组的长度(如果当前对象是数组)

具体的MarkWord信息和状态变化:

image-20210520132016211

image-20210520132049817

3、监视器锁monitor

无论是同步代码方法,同步代码块,都依赖于一个monitor监视器锁。

monitor源码

下面是HotSpot虚拟机中的monitor源码:

ObjectMonitor()
{
    _header=NULL;
    _count=0;
    _waiters=0;
    _recursions=0;//线程的重入次数
    _object=NULL;//存储该monitor的对象
    owner=NULL;//标识拥有该monitor的线程
    _WaitSet=NULL;//处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock=0;
    _Responsible=NULL;
    _succ=NULL;
    _cxq=NULL;//多线程竞争锁时的单向列表
    FreeNext=NULL;
    _EntryList=NULL;//处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq=0;
    _SpinClock=0;
    OwnerIsThread=0

在源码中,我们要注意一些重要的数据结构:

  1. _owner

    初始化时为NULL。当有线程占有该monitor时,owner标记为线程的唯一标识。当线程释放monitor时,owner又恢复为NULL。owner是一个临时资源,JVM是通过CAS操作来保证其线程安全的。

  2. _cxq :竞争队列

    所有请求锁的线程首先会被放在这个队列中(单向链接)。_cxq是一个临时资源, JVM通过CAS原子指令来修改cxq队列。

  3. _EntryList

    存放处于等待锁block状态的线程队列

  4. _WaitSet

    存放处于wait状态的线程队列,即调用wait()方法的线程

monitor使用
  1. 刚开始Monitor时Owner为null

image-20210520132558728

  1. 当Thread-2执行synchronized(obj)就会将monitor的所有者owner置为Thread-2,monitor只能有一个owner。

    MarkWord中的信息会按照不同的锁进行更改。如果是重量级锁,会指向Monitor对象

image-20210520133209752

  1. 在Thread-2上锁的过程中,如果Thread-3,Thread-4,Thread-5也来执行synchronized(obj),就会进入EntryList BLOCKED

    image-20210520133724097

  2. Thread-2执行完同步代码块的内容,然后唤醒EntryList中等待的线程来竞争锁。这个竞争是非公平的。

    image-20210520133922131

4、锁升级

Java SE1.6为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁,在JavaSE1.6中,锁一共有四种状态,级别从低到高依次是:无锁状态,偏向锁、轻量级锁、重量级锁。这几个状态会随着竞争情况逐渐升级。所可以升级但不能降级,意味着偏向锁升级为轻量级锁之后不能降级为偏向锁。(锁降级发生在读写锁上)。

1、轻量级锁

轻量级锁的使用场景:如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。

轻量级锁对使用者是透明的,语法依旧是synchronized。

轻量级锁流程
static final Object obj = new Object();

public static void method1() {
     synchronized( obj ) {
         // 同步块 A
         method2();
     }
}
public static void method2() {
     synchronized( obj ) {
         // 同步块 B
     }
}
  1. 首先判断obj的对象是否处于无锁的状态。如果是,创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁对象的Mark Word

    Lock Record对象中,有两个结构,Displaced Mark Word记录锁对象的MarkWord,Object reference指向锁对象。比如上面程序中的obj锁对象。

image-20210520143416139

  1. 让锁记录中Object reference指向锁对象后,并尝试用CAS替换obj对象锁的Mark Word。将MarkWord的值存入锁记录。

    CAS如何替换:

    根据对象的MarkWord最后两位是否是01,如果是表示此时无锁,可以成功。如果其他线程将它修改为00,那么CAS操作失败。

image-20210520144450659

  1. 如果CAS替换成功,对象头中存储了锁记录地址和状态00,表示由该线程给对象加锁。

image-20210520144617367

  1. 如果CAS失败,可能由两种情况。
    • 如果是其他线程已经持有了该obj的轻量级锁,这时表明有竞争,进入锁膨胀过程。
    • 如果是自己执行了synchronized锁重入。那么在添加一条Lock Record作为重入的计数。

image-20210520145725597

  1. 当退出synchronized代码块(解锁时),如果由取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减1。

image-20210520150020755

  1. 当退出synchronized代码块(解锁时),如果由取值为null的锁记录,这是使用CAS将MarkWord的值给对象头。

    如果成功:则解锁成功

    如果失败:说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程。

轻量级锁锁膨胀

在解锁时,如果CAS操作失败,表示当前线程执行同步代码块时,有其他线程也在访问,当前的锁是被竞争。那么轻量级锁会膨胀为重量级锁。

image-20210520151755439

  1. 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁

image-20210520152533571

  1. 这时Thread-1加轻量级锁,进入锁膨胀流程

    Thread-1为obj对象申请一个Monitor锁,让Obj的MarkWord指向重量级锁地址。原来是指向锁记录中的Displaced Mark Word。最后两位是01。

    然后Thread-1进入Monitor的EntyList BLOCKED。

    并且要改成obj中Displaced Mark Word的最后两位为10,表示是重量级锁。

image-20210520152838701

  1. 对于Thread-0来说,执行完同步代码块后。使用CAS将Mark Word的值恢复给Obj锁对象的对象头。但是失败,因为此时最后两位已经被更改为10。说明此时已经是重量级锁,需要按照重量级锁的解锁流程来解锁。
自旋

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。

2、偏向锁

HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是有同一线程多次获得。为了让线程获得锁的代价更低而引入了偏向锁。

使用jar包可以查看对象头MarkWord信息。

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.14</version>
</dependency>

如何使用:

public class test {
    public static void main(String[] args) {
        System.out.println(ClassLayout.parseInstance(test.class).toPrintable());
    }
}

image-20210520165144296

偏向锁的特点
  • 如果开启了偏向锁(默认开启),那么对象创建后,markword值的最后三位是001,这是它的thread、epoch、age都为0。
  • 偏向锁是默认延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加VM参数-XX:BiasedLockingStartupDelay=0来禁用延迟。
在jvm添加上参数
-XX:BiasedLockingStartupDelay=0

image-20210520165301145

  • 如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后 3 位为 001,这时它的 hashcode、age 都为 0,第一次用到 hashcode 时才会赋值

  • 添加 VM 参数 -XX:-UseBiasedLocking 禁用偏向锁

public static void main(String[] args) {
    Object o = new Object();
    synchronized (o){
        //添加上偏向锁之后
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
    }
    System.out.println(ClassLayout.parseInstance(o).toPrintable());
}

可以看到两次打印的对象头都是相等的

image-20210520165615643

偏向锁流程
  1. 当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID

  2. 以后该线程在进入和退出同步块时不需要进入CAS操作来加锁和解锁,只需要简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。

    • 如果测试成功,表示线程已经获得了锁。

    • 如果测试失败,则需要在测试一下Mark Word中偏向锁的标识是否设置成1(表示当前时偏向锁),如果没有设置,则使用CAS竞争锁。如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

image-20210520154833068

偏向锁的撤销
  1. 调用对象的 hashCode

调用了对象的 hashCode,但此时偏向锁的对象 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被撤销。

  1. 其他线程使用锁对象

    当有多个线程竞争时,会撤销偏向锁。改为轻量级锁。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值