Java中的synchronized的底层实现原理以及锁升级优化详解

Java中的加锁大致分为两种:通过synchronized关键字修饰和通过Lock的实现类在代码逻辑中显示加锁,如ReentrantLock。本文主要讲解synchronized关键字实现锁的底层原理以及JDK对于synchronized做出的锁升级优化!

1 syncronized基础知识

  synchronized 块是Java 提供的一种原子性内置锁,Java中的每个对象都可以把它当作一个同步锁来使用,这些Java内置的使用者看不到的锁被称为内部锁,也叫作监视器锁。
  线程的执行代码在进入synchronized 代码块前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步块内调用了该内置锁资源的wait 系列方法时释放该内置锁。内置锁是排它锁,也就是当一个线程获取这个锁后, 其他线程必须等待该线程释放锁后才能获取该锁。
  另外,由于Java 中的线程是与操作系统的原生线程一一对应的,所以当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,而synchronized 的使用就会导致上下文切换。
  synchronized锁,具有三种表现形式:

  1. 对于普通同步方法,锁是当前实例对象
  2. 对于静态同步方法,锁是当前类的Class的对象
  3. 对于同步方法块为syncronized括号的配置对象。

1.1 对象头

  synchronized作为锁是和Java对象头是密不可分的。如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。关于Java对象的构成,可以看这篇文章:Java对象的内存布局和访问定位详解
  在32位虚拟机中,1字宽等于4字节,即32bit;而在64位虚拟机中,1字宽等于8字节,即64bit,如下表:

长度 内容 说明
32/64bit Mark Word 存储对象的hashCode或锁信息等
32/64bit Class Metadata Address 存储到对象类型数据的指针
32/64bit Array Length 数组的长度(仅当当前对象为数组时存在)

  Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄、偏向锁标记、锁标记位。
  在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。32位JVM在不同状态下mark word的组成如下表:

状态 Mark Word(32 bit)
无锁 hash:25 age:4 biased_lock:1 lock:2
偏向锁 thread Id:23 epoch:2 age:4 biased_lock:1 lock:2
轻量级锁 Lock record address:30(指向栈中锁记录的指针) lock:2
重量级锁 Monitor address:30 (指向监视器对象/monitor的指针) lock:2
GC标记 30,空,只在Mark Sweep GC中用到,其他时刻无效 lock:2
  在64位虚拟机下,Mark Word是64bit大小的,不同状态下mark word的组成如下表:
状态 Mark Word(64 bit)
无锁 unused:25 hash:31 unused:1 age:4 biased_lock:1 lock:2
偏向锁 thread Id:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2
轻量级锁 Lock record address:62(指向栈中锁记录的指针) lock:2
重量级锁 Monitor address:62 (指向监视器对象/monitor的指针) lock:2
GC标记 62,空,只在Mark Sweep GC中用到,其他时刻无效 lock:2

  下面来解释各种存放的数据的含义:
  unused:就是表示没有使用的区域,在64位虚拟机的对象头中会出现。
  hash:对象的hashcode,如果对象没有重写hashcode()方法,那么通过System.identityHashCode()方法获取。采用延迟加载技术,不会主动计算,但是一旦生成了hashcode,JVM会将其记录在markword中;
  age:4位的Java对象GC年龄。对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15。最大值也是15。
  biased_lock:1位的偏向锁标记,为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。
  lock:2位的锁状态标记位,其中无锁和偏向锁的锁标志位都是01,只是在前面的1 bit的biased_lock区分了这是无锁状态还是偏向锁状态。lock和biased_lock共同表示对象处于什么状态

biased_lock 偏向锁标识位 lock 锁标识位 状态
0 01 未锁定
1 01 偏向锁
00 轻量级锁
10 重量级锁
11 GC标记

  thread Id:持有偏向锁的线程ID。
  epoch:的偏向锁的时间戳。
  Lock record address:轻量级锁状态下,指向栈中锁记录的指针。
  Monitor address:重量级锁状态下,指向对象监视器Monitor的指针。

  从上面的内容可以看出来,“锁”这个东西,可能是个锁记录+对象头里的引用指针(判断线程是否拥有锁时将线程的锁记录地址和对象头里的指针地址比较),也可能是对象头里的线程ID(判断线程是否拥有锁时将线程的ID和对象头里存储的线程ID比较),也可能是对象头里的Monitor指针和Monitor对象(重量级锁)。
  JDK1.6及之后synchronized锁可以分为偏向锁、轻量级锁、重量级锁,并且前两种锁和Monitor是没关系的,这当然得益于JDK1.6对synchronized的优化,因此,人们常说的synchronized底层是使用Monitor来实现的这句话是不准确的。

1.2 Monitor

什么是Monitor?

  1. Monitor是一种用来实现同步的工具,又称为对象监视器/管程。
  2. 与每个java对象相关联,即每个java对象都有一个Monitor与之对应
  3. Monitor是实现Sychronized(内置锁)的基础,是shcronized作为重量级锁使用的对象(这源于JDK1.6的优化)。

  在重量级锁阶段,线程在获取锁的时候,实际上就是获得一个监视器对象 (monitor) ,monitor可以认为是一个同步对象,所有的Java对象是天生携带一个monitor。因此任何对象都可以作为锁。多个线程访问同步代码块时,相当于去争抢对象监视器对象、修改对象中的锁标识。更多Monitor详解在下面重量级锁那部分!

2 synchronized块 底层原理

案例:

public class SyncBlock {
    static int i;

    public static void main(String[] args) {
        synchronized (SyncBlock.class) {
            i++;
        }
    }
}

  使用javap -v反编译后得到字节码如下:
在这里插入图片描述
  如果没有synchronized块,而是普通块:

public class NoSyncBlock {
    static int i;

    public static void main(String[] args) {
        {
            i++;
        }
    }
}

  那么就不会产生monitor相关字节码:
在这里插入图片描述
  从上面的字节码中可知同步语句块的实现使用的是monitorenter 和 monitorexit指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置。
  当执行monitorenter指令时,当前线程将试图获取锁对象对应的Monitor的持有权,当锁对象的 Monitor的计数器为0,那线程可以成功取得 Monitor,并将计数器值设置为 1,取锁成功。
  如果当前线程已经拥有锁对象的 Monitor的持有权,那它可以重入这个 Monitor (关于重入性稍后会分析),重入时计数器的值也会加 1。
  倘若其他线程已经拥有Monitor的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将尝试释放锁并设置Monitor的计数器值减一直到为0 ,其他线程将有机会持有 Monitor。
  值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器表,这个异常处理器表声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。
  当然在JDK1.6及之后的JDK版本中,对上面这些步骤进行了优化升级,得到了更好的性能,上面的步骤只有重量级锁时才会使用到。

3 synchronized方法 底层原理

public class SyncMethod {
    static int i;
    public static void main(String[] args) {}
    synchronized void sync() {
        i++;
    }
}

  方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。
  当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后在方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。下面我们看看字节码层面如何实现:
在这里插入图片描述
  从字节码中可以看出,synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。在JDK1.6之前的同步块和同步方法两个同步方式实际都是通过获取Monitor和释放Monitor来实现同步的,但在JDK1.6时,规则改变了,下面来看看JDK1.6的锁升级!

  在JDK1.6之前的同步块和同步方法两个同步方式实际都是通过获取Monitor和释放Monitor来实现同步的,其实wait、notiy和notifyAll等方法也是依赖于Monitor对象的内部方法来完成的,这也就是为什么需要在同步方法或者同步代码块中调用的原因(需要先获取对象的锁,才能执行),否则会抛出java.lang.IllegalMonitorStateException的异常。
  在JDK1.6之前,synchronized属于重量级锁,效率低下,因为Monitor是依赖于底层的操作系统的互斥原语mutex来实现,JDK1.6之前实际上加锁时会调用Monitor的enter方法,解锁时会调用Monitor的exit方法,由于java的线程是映射到操作系统的原生线程之上的,如果要操作Monitor对象,都需要操作系统来帮忙完成,这会导致线程在“用户态和内核态”两个态之间来回切换,这个状态之间的转换需要相对比较长的时间,对性能有较大影响。
  庆幸的是在JDK1.6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了,JDK1.6之后,为了减少获得锁和释放锁所带来的性能消耗,为了减少这种重量级锁的使用,引入了轻量级锁和偏向锁,这两个锁可以不依赖Monitor的操作。

4 JDK1.6 synchronized优化(升级)和对比

  JDK1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在JDK 1.6中,锁对象一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。
  锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

4.1 偏向锁

  HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
  偏向锁是在单线程执行代码块时使用的机制,如果在多线程并发的环境下(即线程A尚未执行完同步代码块,线程B发起了申请锁的申请),则一定会转化为轻量级锁或者重量级锁。
  引入偏向锁主要目的是:为了在没有多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。因为轻量级锁、重量级锁的加锁、解锁操作是需要依赖多次CAS原子指令的,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗也必须小于节省下来的CAS原子指令的性能消耗)。

4.1.1 偏向锁的获取

  1. 检测锁对象的MarkWord是否为可偏向状态,即是否为偏向锁标识1,锁标识位为01;
  2. 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,表示线程A已经获得了这个偏向锁,虚拟机不再进行任何同步操作,不需要进行CAS操作来加锁和解锁,提高性能,执行步骤(6);否则执行步骤(3);
  3. 如果测试线程ID不为当前线程ID,则通过CAS操作竞争锁,重偏向,(这里的CAS操作是有可能成功的,因为获取偏向锁的线程不会自动释放偏向锁,也就是即使最开始的线程执行完毕了同步的内容,其保存在MarkWOrd中的线程ID不会撤销),竞争成功,则将Mark Word的线程ID替换为当前线程ID,执行步骤(6);否则执行线程(4);
  4. 通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到线程达全局安全点,最开始获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程获取轻量级锁继续往下执行步骤(6);
  5. 若锁对象的MarkWord为不可偏向状态,则直接构造轻量级锁,然后继续往下执行步骤(6);
  6. 执行同步代码。

4.1.2 偏向锁的释放

  偏向锁的释放采用只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争,然后其他线程使用CAS替换掉原来的线程的ThreadID。

4.1.3 偏向锁的撤销(升级)

  偏向锁释放失败,则进入偏向锁的撤销(升级)。

  1. 对原持有偏向锁的线程进行撤销时,需要等待持有锁的线程到达全局安全点。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,然后有一下两种情况:
  2. 如果原获得偏向锁的线程如果已经退出了临界区,也就是同步代码块执行完了,不存在锁竞争,那么这个时候争抢锁的线程可以基于 CAS 重新偏向当前线程,此过程称为重偏向。
  3. 如果原获得偏向锁的线程的同步代码块还没执行完,处于临界区之内,存在锁竞争,这个时候会把原获得偏向锁的线程升级为轻量级锁(标志位为“00”),并将指向当前线程的锁记录地址的指针放入对象头Mark Word,后唤醒持有锁的当前线程,进入轻量级锁的竞争模式;此处将当前线程挂起再恢复的过程中并没有发生锁的转移,仍然在当前线程手中,只是穿插了个“将对象头中的线程ID变更为指向锁记录地址的指针” 这么个事。

4.1.4 偏向锁的关闭

  偏向锁在JDK1.6之后是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

4.2 轻量级锁

  轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。
  相比于重量级锁,轻量级锁可以减少传统的重量级锁使用Monitor而导致操作系统Mute互斥量产生的性能消耗。因为监视器锁Monitor的是依赖于底层的操作系统的Mutex来实现的,操作Monitor会导致线程在用户态和核心态的转换,这个成本非常高,状态之间的转换需要相对比较长的时间。
  当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁。

4.2.1 轻量级锁加锁

  1. 在代码进入同步块的时候,首先获取锁对象的Mark Word。

  2. 判断此锁对象是否是无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),如果是,则虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,并存储锁对象无锁状态的Mark Word的拷贝,我们称Lock Record中存储对象mark word的字段叫Displaced Mark Word,然后执行步骤(3);如果不是,则说明是加锁状态,则执行步骤(4)。
    在这里插入图片描述

  3. 然后,虚拟机将使用CAS操作尝试将对象头中的Mark Word更新为指向Lock Record即线程栈中锁记录的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后2bit)将转变为“00”,即表示此对象处于轻量级锁定状态,继续向下执行同步代码;如果CAS失败了则会执行方法(4)。
    在这里插入图片描述

  4. 继续检查对象的Mark Word的是否指向当前线程栈,如果是说明当前线程已经拥有了这个对象的锁,说明是重入,执行重入逻辑,继续向下执行同步代码;否则说明这个锁对象已经被其他线程抢占了,执行步骤(5);

  5. 如果不是重入,对于抢锁失败的线程,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的循环第(3)步重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果最终还是失败则锁会升级至重量级锁。锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量/monitor)的指针,此状态下,后面等待锁的线程也要进入阻塞状态。

4.2.2 轻量级锁解锁

  执行完同步代码块代码,退出同步代码块,使用CAS开始轻量级锁解锁,线程会使用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果对象的Mark Word仍然指向着线程的锁记录,就么可能就替换成功,整个同步过程就算完成了,成功释放锁;如果不满足,则释放锁,唤醒被挂起阻塞的线程,开始重量级锁的竞争。
  注:当超过自旋阈值,竞争的线程就会把锁对象Mark Word指向重量级锁,导致Mark Word中的值发生了变化,当原持有轻量级锁的线程执行完毕,尝试通过CAS释放锁时,因为Mark Word已经指向重锁,不再是指向当前线程Lock Record的指针,于是解锁失败,这时原持有轻量级锁的线程就会知道锁已经升级为重量级锁。

4.2.3 锁重入

synchronized(obj){
    synchronized(obj){
        synchronized(obj){
        }
    }
}

  假设锁的状态是轻量级锁,则线程栈中会包含3个指向当前锁对象的Lock Record。其中栈中最高位的Lock Record为第一次获取锁时分配的,其中Displaced Mark word为锁对象加锁前的Mark Word,而之后的锁重入,则会在线程栈中新分配一个Displaced Mark word为null的Lock Record,用来重入计数。如下图:
在这里插入图片描述
  每次重入锁时会将Lock Record放在最后,每次释放锁的时候则会从低到高遍历栈的Lock Record删除对应的Lock Record。 这就是轻量级锁的实现逻辑,相对于偏向锁来说,逻辑会稍微简单一些。

4.2.4 自旋锁

  自旋锁是在JDK1.4.2的时候引入的。所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。轻量级锁的竞争就是采用的自旋锁机制。
  注意,锁在原地循环的时候,是会消耗cpu的,就相当于在执行一个啥也没有的for循环。所以,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短很短的时间就能够获得锁了。经验表明,大部分同步代码块执行的时间都是很短很短的,也正是基于这个原因,才有了轻量级锁这么个东西。
自旋锁的一些问题:
  如果同步代码块执行的很慢,需要消耗大量的时间,那么这个时侯,其他线程在原地等待空消耗cpu,这会让人很难受。
  本来一个线程把锁释放之后,当前线程是能够获得锁的,但是假如这个时候有好几个线程都在竞争这个锁的话,那么有可能当前线程会获取不到锁,还得原地等待继续空循环消耗cup,甚至有可能一直获取不到锁。
  基于这个问题,我们必须给线程空循环设置一个次数,当线程超过了这个次数,我们就认为,继续使用自旋锁就不适合了,此时锁会再次膨胀,升级为重量级锁。
  默认情况下,自旋的次数为10次,用户可以通过-XX:PreBlockSpin来进行更改。

4.2.5 自适应自旋锁

  所谓自适应自旋锁就是线程空循环等待的自旋次数并非是固定的,而是会动态着根据实际情况来改变自旋等待的次数。
其大概原理是这样的:
  假如一个线程1刚刚成功获得一个锁,当它把锁释放了之后,线程2获得该锁,并且线程2在运行的过程中,此时线程1又想来获得该锁了,但线程2还没有释放该锁,所以线程1只能自旋等待,但是虚拟机认为,由于线程1刚刚获得过该锁,那么虚拟机觉得线程1这次自旋也是很有可能能够再次成功获得该锁的,所以会延长线程1自旋的次数。
  另外,如果对于某一个锁,一个线程自旋之后,很少成功获得该锁,那么以后这个线程要获取该锁时,是有可能直接忽略掉自旋过程,直接升级为重量级锁的,以免空循环等待浪费资源。
  轻量级锁也被称为非阻塞同步、乐观锁,因为这个过程并没有把线程阻塞挂起,而是让线程空循环等待,串行执行。

4.3 重量级锁

  在JDK1.6之前,Synchronized是通过对象内部的一个叫做 监视器锁(Monitor)来实现的,synchronized属于重量级锁,效率低下,。因为Monitor是依赖于底层的操作系统的互斥原语mutex来实现,JDK1.6之前实际上加锁时会调用Monitor的enter方法,解锁时会调用Monitor的exit方法,由于java的线程是映射到操作系统的原生线程之上的,如果要操作Monitor对象,都需要操作系统来帮忙完成,这会导致线程在“用户态和内核态”两个态之间来回切换,这个状态之间的转换需要相对比较长的时间,对性能有较大影响。
  庆幸的是在JDK1.6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了,JDK1.6之后,为了减少获得锁和释放锁所带来的性能消耗,为了减少这种重量级锁的使用,引入了轻量级锁和偏向锁,这两个锁可以不依赖Monitor的操作。

4.3.1 Monitor详解

  当轻量级锁升级为重量级锁之后,它的Markword部分数据大体如下,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。
  每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。
  在Hospot JDK(大部分人使用的JDK)中,monitor对象的具体实现的代码是没有开源的,因为Hospot将底层的调用C++的native方法的具体实现屏蔽了,而monitor对象正是由C++来实现的,但是这部分源码在openjdk上开源了,我们可以在openjdk中查看源码。
  在HotSpot虚拟机中,最终采用ObjectMonitor类实现monitor。ObjectMonitor所在目录为:https://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/d975dfffada6/src/share/vm/runtime。在下面可以找到如下两个文件:
在这里插入图片描述
  其中.hpp是c++的头文件,主要定义一些变量,其具体的实现是以cpp中,下面一起来看看objectMonitor.hpp的源码
  首先是ObjectWaiter,ObjectWaiter 顾名思义对象等待者,其实就是对等待锁的线程的封装。

class ObjectWaiter : public StackObj {
 public:
  enum TStates { TS_UNDEF, TS_READY, TS_RUN, TS_WAIT, TS_ENTER, TS_CXQ } ;
  enum Sorted  { PREPEND, APPEND, SORTED } ;
// ObjectWaiter 类似一个双向链表
  ObjectWaiter * volatile _next;  // 上一个 ObjectWaiter
  ObjectWaiter * volatile _prev;  // 下一个 ObjectWaiter
  Thread*       _thread;          // 线程
  jlong         _notifier_tid;
  ParkEvent *   _event;
  volatile int  _notified ;
  volatile TStates TState ;
  Sorted        _Sorted ;           // List placement disposition
  bool          _active ;           // Contention monitoring is enabled
 public:
  ObjectWaiter(Thread* thread);

  void wait_reenter_begin(ObjectMonitor *mon);
  void wait_reenter_end(ObjectMonitor *mon);
};

  接下来就是ObjectMonitor,来看看初始化的方法的源码:

ObjectMonitor() {
   //成员变量简单的初始化
    _header       = NULL;  //markOop对象头,重量级锁储存锁对象头信息的地方
    _count        = 0;
    _waiters      = 0,  //等待线程数
    _recursions   = 0;   //锁的重入次数,作用于可重入锁
    _object       = NULL; //监视器锁寄生的对象。锁不是平白出现的,而是寄托存储于对象中。
    _owner        = NULL; //指向持有ObjectMonitor对象的线程地址
    _WaitSet      = NULL; //处于wait状态的线程,会被包装成ObjectWaiter,加入到_WaitSet集合(调用wait方法)
    _WaitSetLock  = 0 ;  // 保护等待队列,作用于自旋锁。
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;  //阻塞在EntryList上的最近可达的的线程列表
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block阻塞状态的线程,会被包装成ObjectWaiter,加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
OwnerIsThread = 0 ;
    _previous_owner_tid = 0;// 监视器前一个拥有者线程的ID
  }

  ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装成ObjectWaiter对象),_owner则指向持有ObjectMonitor对象的线程。

4.3.2 monitor获取和释放

  对于重量级锁,获取锁的过程实际上就是获取monitor的过程,释放锁的过程实际上就是释放monitor的过程。monitor的竞争获取是在ObjectMonitor的enter方法中,而释放则是在exit方法中。

  1. 通过CAS尝试把monitor的_owner字段设置为当前线程;
  2. 如果设置之前的_owner指向当前线程,说明当前线程再次进入monitor,即重入锁,执行_recursions ++ ,记录重入的次数;
  3. 如果之前的_owner指向的地址在当前线程中,这种描述有点拗口,换一种说法:之前_owner指向的BasicLock在当前线程栈上,说明当前线程是第一次进入该monitor,设置_recursions为1,_owner为当前线程,该线程成功获得锁并返回;
  4. 如果获取锁失败,则当前线程包装为ObjectWaiter加入_EntryList,等待锁的释放;
  5. 等待时,将会首先通过自旋尝试获取锁, 如果还是没有获取到锁,则通过park将当前线程挂起,等待被唤醒。这里的park方法是objectMonitor.cpp中的方法,并不是java中的park方法。
  6. 当某个持有锁的线程执行完同步代码块时,会进行锁的释放,给其它线程机会执行同步代码,在HotSpot中,通过退出monitor的方式实现锁的释放,并唤醒被阻塞的线程(唤醒操作最终由unpark完成),具体实现入口位于ObjectMonitor的exit方法中。
  7. 被唤醒的线程,继续执行monitor的竞争;

总结:
  当多个线程同时访问一段同步代码时,没有获得monitor的对象会进入_EntryList 集合,所谓的获取monitor简单所就是把monitor中的owner变量设置为当前线程同时monitor中的_recursions加1,若线程调用wait()方法,将释放当前持有的monitor,owner变量恢复为null,_recursions减一,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor并_recursions减一,以便其他线程进入获取monitor。如下图所示:
在这里插入图片描述
  其实Java中的wait、notiy和notifyAll等方法也是依赖于ObjectMonitor对象的内部方法来完成的,这也就是为什么需要在同步方法或者同步代码块中调用的原因(需要先获取对象的锁,才能执行),否则会抛出java.lang.IllegalMonitorStateException的异常。

4.3.3 对象信息存放在哪里

  升级重量级锁时,锁对象的markword存储的是对应ObjectMonitor的指针,但是依然持有 原始的对象头分代年龄 hash 是否偏向的信息。它是储存在ObjectMonitor类中有一个markOop类型的_header成员变量中,而这个值,就是在锁膨胀的过程中复制过来的。

4.4 总体过程和优缺点对比

偏向锁:
  优点:加锁和解锁不需要额外消耗,和执行非同步方法相比,仅存在纳秒级的差距
  缺点:如果线程间存在竞争,会带来额外开销(偏向锁的撤销)
  适用场景: 适用于只有一个线程访问同步块的场景
轻量锁:
  优点: 竞争的线程不会造成阻塞,提高了程序的响应速度
  缺点: 如果始终得不到锁,使用自旋会消耗CPU
  适用场景: 追求相应实践,同步块执行速度非常快
重量锁:
  优点: 线程竞争不使用自旋,不会消耗CPU
  缺点: 线程阻塞,响应时间缓慢
  适用场景: 追求吞吐量,同步块执行速度较慢

总体过程如下:
  当关闭了偏向锁的设置,那么就会走右边的流程;反之则走左边的流程。
在这里插入图片描述

参考
openJDK8
《Java并发编程之美》
《Java并发编程的艺术》
《深入理解Java虚拟机》
《实战高并发编程》

发布了72 篇原创文章 · 获赞 135 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/weixin_43767015/article/details/105544786