1. 重量级锁
monitor 监视器锁本质上是依赖操作系统的 Mutex Lock 互斥量 来实现的,我们一般称之为重量级锁。因为 OS 实现线程间的切换需要从用户态转换到核心态,这个转换过程成本较高,耗时相对较长,因此 synchronized 效率会比较低。
在膨胀为重量锁的时候若没有获取到锁,不是立马就阻塞未获取到锁的线程,因其是非公平锁,首先会去尝试加锁,不管前面是否有线程等待(如果是公平锁的话就会判断是否有线程等待,有的话则直接入队睡眠),如果加锁失败,synchronized还会采用自旋的方式去获取锁,JDK1.6之前是默认自旋10次后睡眠,而优化之后引入了适应性自旋,即JVM会根据各种情况动态改变自旋次数:
1 如果平均负载小于CPU则一直自旋
2 如果有超过(CPU/2)个线程正在自旋,则后来线程直接阻塞
3 如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞
4 如果CPU处于节电模式则停止自旋自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差)
5 自旋时会适当放弃线程优先级之间的差异
2. 轻量级锁
轻量级锁加锁有两个来源,一个是从无锁状态来的,一个是偏向锁膨胀成轻量级锁的。
轻量级锁,其性能提升的依据是对于绝大部分的锁,在整个生命周期内都是不会存在竞争的,如果没有竞争,轻量级锁就可以使用 CAS 操作避免互斥量的开销,从而提升效率。 如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。
- 轻量级锁的加锁过程:
- 线程在进入到同步代码块的时候,JVM 会先在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象当前 Mark Word 的拷贝(官方称为 Displaced
- Mark Word),owner 指针指向对象的 Mark Word。此时堆栈与对象头的状态如图所示:image.png
- JVM 使用 CAS 操作尝试将对象头中的 Mark Word 更新为指向 Lock Record 的指针。如果更新成功,则执行步骤3;更新失败,则执行步骤4
- 如果更新成功,那么这个线程就拥有了该对象的锁,对象的 Mark Word 的锁状态为轻量级锁(标志位转变为’00’)。此时线程堆栈与对象头的状态如图所示:
- 如果更新失败,JVM 首先检查对象的 Mark Word 是否指向当前线程的栈帧如果是,就说明当前线程已经拥有了该对象的锁,那就可以直接进入同步代码块继续执行如果不是,就说明这个锁对象已经被其他的线程抢占了,当前线程会尝试自旋一定次数来获取锁。如果自旋一定次数 CAS 操作仍没有成功,那么轻量级锁就要升级为重量级锁(锁的标志位转变为’10’),Mark Word 中存储的就是指向重量级锁的指针,后面等待锁的线程也就进入阻塞状态?
- 轻量级锁的解锁过程:
- 通过 CAS 操作用线程中复制的 Displaced Mark Word 中的数据替换对象当前的 Mark Word
- 如果替换成功,整个同步过程就完成了
- 如果替换失败,说明有其他线程尝试过获取该锁,那就在释放锁的同时,唤醒被挂起的线程
2.1 lock record 到底写入了什么?
3. 偏向锁
依据:对于绝大部分锁,在整个同步周期内不仅不存在竞争,而且总由同一线程多次获得。 在一些情况下总是同一线程多次获得锁,此时第二次再重新做CAS修改对象头中的Mark Word这样的操作,有些多余。所以就有了偏向锁,只需要检查是否为偏向锁、锁标识为以及ThreadID即可,只要是同一线程就不再修改对象头。其目的为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。
- 偏向锁枷锁过程:
- 检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识位为01;
- 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤(5),否则执行步骤(3);
- 如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行线程(4);
- 通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;
- 执行同步代码块
- 偏向锁释放过程:
线程进入同步块时,如果此同步对象没有被锁定(即锁标志位为01,是否为偏向锁为0),虚拟机在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的一个Mark Word的copy
虚拟机使用CAS操作,尝试将Mark World更新为指向Lock Record的指针,如果更新成功,那么线程拥有了该对象的锁,并且将锁标志位置位00
4 锁膨胀过程
升级过程可简单的理解为:
偏向锁 -> 轻量级锁 -> 重量级锁
当一个线程第一次获取锁后再去拿锁就是偏向锁,如果有别的线程和当前线程交替执行就膨胀为轻量级锁,如果发生竞争就会膨胀为重量级锁
1、整个膨胀过程在自旋下完成;
2、mark->has_monitor()方法判断当前是否为重量级锁,即Mark Word的锁标识位为 10,如果当前状态为重量级锁,执行步骤(3),否则执行步骤(4);
3、mark->monitor()方法获取指向ObjectMonitor的指针,并返回,说明膨胀过程已经完成;
4、如果当前锁处于膨胀中,说明该锁正在被其它线程执行膨胀操作,则当前线程就进行自旋等待锁膨胀完成,这里需要注意一点,虽然是自旋操作,但不会一直占用cpu资源,每隔一段时间会通过os::NakedYield方法放弃cpu资源,或通过park方法挂起;如果其他线程完成锁的膨胀操作,则退出自旋并返回;
5、如果当前是轻量级锁状态,即锁标识位为 00,膨胀过程如下:
- 通过omAlloc方法,获取一个可用的ObjectMonitor monitor,并重置monitor数据;
- 通过CAS尝试将Mark Word设置为markOopDesc:INFLATING,标识当前锁正在膨胀中,如果CAS失败,说明同一时刻其它线程已经将Mark Word设置为markOopDesc:INFLATING,当前线程进行自旋等待膨胀完成;
- 如果CAS成功,设置monitor的各个字段:_header、_owner和_object等,并返回;
6、如果是无锁,重置监视器值;
参考 https://www.cnblogs.com/yuhangwang/p/11295940.html
4 其他优化
- 自旋锁:互斥同步时,挂起和恢复线程都需要切换到内核态完成,这对性能并发带来了不少的压力。同时在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段较短的时间而去挂起和恢复线程并不值得。那么如果有多个线程同时并行执行,可以让后面请求锁的线程通过自旋(CPU忙循环执行空指令)的方式稍等一会儿,看看持有锁的线程是否会很快的释放锁,这样就不需要放弃 CPU 的执行时间了。
- 适应性自旋:线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
- 锁消除虚拟机即时编译器(JIT)运行时,依据逃逸分析的数据检测到不可能存在竞争的锁,就自动将该锁消除)。锁消除的依据是逃逸分析的数据支持。如果判断一段代码中,堆上的数据不会逃逸出去从而被其他线程访问到,则可以把他们当做栈上的数据对待,认为它们是线程私有的,不必要加锁。如下所示,在 StringBuffer.append() 方法中有一个同步代码块,锁就是sb对象,但 sb 的所有引用不会逃逸到 concatString() 方法外部,其他线程无法访问它。因此这里有锁,但是在即时编译之后,会被安全的消除掉,忽略掉同步而直接执行了。
- 锁粗化锁粗化就是 JVM 检测到一串零碎的操作都对同一个对象加锁,则会把加锁同步的范围粗化到整个操作序列的外部。以上述 concatString() 方法为例,内部的 StringBuffer.append() 每次都会加锁,将会锁粗化,在第一次 append() 前至 最后一个 append() 后只需要加一次锁就可以了
5 注意事项
- jdk8偏向锁默认是开启的,不过jvm启动后有4秒钟的延迟,所以在这4秒钟内对家加锁都直接是轻量级锁,可用-xx:biasedlockingstartupdelay=0 关闭该特性
- 测试用的jdk是64位的,所以获取对象头的时候是用unsafe.getlong,来获取对象头markword的8个字节,如果你是32位则用unsafe.getint替换即可
- hashcode方法会对偏向锁造成影响(这里的hashcode特指identity hashcode,如果锁对象重载过hashcode方法则不会影响)