synchronized锁的升级(偏向锁、轻量级锁及重量级锁)

1,072 阅读6分钟

java同步锁前置知识点

  1. 编码中如果使用锁可以使用synchronized关键字,对方法、代码块进行同步加锁
  2. Synchronized同步锁是jvm内置的隐式锁(相对Lock,隐式加锁与释放)
  3. Synchronized同步锁的实现依赖于操作系统,获取锁与释放锁进行系统调用,会引起用户态与内核态切换
  4. jdk1.5之前加锁只能使用synchronized,1.6引入Lock同步锁(请求锁基于java实现,显式加锁与释放、性能更优)
  5. jdk1.6对于Synchronzied同步锁提出了偏向锁、轻量级锁、重量级锁的概念(其实是对synchronized的性能优化,尽可能减少锁竞争带来的上下文切换)
  6. 无论是使用synchronized还是Lock,线程上下文切换都是无法避免的
  7. Lock相对synchronized的性能优化的其中一点是:在线程阻塞的时候,Lock获取锁不会导致用户态与内核态的切换,而synchronized会(看第3点)。但是线程阻塞都会导致上下文切换(看第6点)
  8. java线程的阻塞与唤醒依赖操作系统调用,导致用户态与内核态切换 本文主要关注synchronized锁的升级。

synchronized同步锁

java对象头

每个java对象都有一个对象头,对象头由类型指针和标记字段组成。
在64位虚拟机中,未开启压缩指针,标记字段占64位,类型指针占64位,共计16个字节。
锁类型信息为标记字段的最后2位:00表示轻量级锁,01表示无锁或偏向锁,10表示重量级锁;如果倒数第3位为1表示这个类的偏向锁启用,为0表示类的偏向锁被禁用。如下图,图片来源wiki:wiki.openjdk.java.net/display/Hot…

image.png 左侧一列表示偏向锁启用(方框1),右侧一列表示偏向锁禁用(方框3)。1和3都表示无锁的初始状态,如果启用偏向锁,锁升级的步骤应该是1->2->4->5,如果禁用偏向锁,锁升级步骤是3->4->5。
左侧一列表示偏向锁启用(方框1),右侧一列表示偏向锁禁用(方框3)。1和3都表示无锁的初始状态,如果启用偏向锁,锁升级的步骤应该是1->2->4->5,如果禁用偏向锁,锁升级步骤是3->4->5。

image.png
关于偏向锁还有另外几个参数:

image.png
注意BiasedLockingStartupDelay参数,默认值4000ms,表示虚拟机启动的延迟4s才会使用偏向锁(先使用轻量级锁)。

偏向锁

偏向锁处理的场景是大部分时间只有同一条线程在请求锁,没有多线程竞争锁的情况。看对象头图的红框2,有个thread ID字段:当第一次线程加锁的时候,jvm通过cas将当前线程地址设置到thread ID标记位,最后3位是101。下次同一线程再获取锁的时候只用检查最后3位是否为101,是否为当前线程,epoch是否和锁对象的类的epoch相等(wiki上说没有再次cas设置是为了针对现在多处理器上的cas操作的优化)。

偏向锁优化带来的性能提升指的是避免了获取锁进行系统调用导致的用户态和内核态的切换,因为都是同一条线程获取锁,没有必要每次获取锁的时候都要进行系统调用。

如果当前线程获取锁的时候(无锁状态下)线程ID与当前线程不匹配,会将偏向锁撤销,重新偏向当前线程,如果次数达到BiasedLockingBulkRebiasThreshold的值,默认20次,当前类的偏向锁失效,影响就是epoch的值变动,加锁类的epoch值加1,后续锁对象会重新copy类的epoch值到图中的epoch标记位。如果总撤销次数达到BiasedLockingBulkRevokeThreshold的值(默认40次),就禁用当前类的偏向锁了,就是对象头右侧列了,加锁直接从轻量锁开始了(锁升级了)。

偏向锁的撤销是个很麻烦的过程,需要所有线程达到安全点(发生STW),遍历所有线程的线程栈检查是否持有锁对象,避免丢锁,还有就是对epoch的处理。

如果存在多线程竞争,那偏向锁就要升级了,升级到轻量级锁。

轻量级锁

轻量级锁处理的场景是在不同的时间段有不同的线程请求锁(线程交替执行)。即使同一时间段,存在多条线程竞争锁,获取到锁的线程持有锁的时间也特别短,很快就释放锁了。

线程加锁的时候,判断不是重量级锁,就会在当前线程栈内开辟一个空间,作为锁记录,将锁对象头的标记字段复制过来(复制过来是做一个记录,因为后面要把锁对象头的标记字段的值替换为刚才复制这个标记字段的空间地址,就像对象头那个图片中的pointer to lock record部分,至于最后2位,因为是内存对齐的缘故,所以是00)。然后基于CAS操作将复制这个锁记录的地址设置为锁对象头的标记位的值,如果成功就是获取到锁了。如果加锁的时候判断不是重量级锁,最后两位也不是01(从偏向锁或无锁状态过来的),那就说明已经有线程持有了,如果是当前线程在使用(需要重入),那就设置一个0,这里是个栈结构,直接压入一个0即可。最后释放锁的时候,出栈,最后一个元素记录的就是锁对象原来的标记字段的值,再通过CAS设置到锁对象头即可。

注意在获取锁的时候,cas失败,当前线程会自旋一会,达到一定次数,升级到重量级锁,当前线程也会阻塞。

重量级锁

重量级就是我们平常说的加的同步锁,也就是java基础的锁实现,获取锁与释放锁的时候都要进行系统调用,从而导致上下文切换。

关于自旋锁

关于自旋锁,我查阅相关资料,主要有两种说明:
1、是轻量级锁竞争失败,不会立即膨胀为重量级而是先自旋一定次数尝试获取锁;
2、是重量级锁竞争失败也不会立即阻塞,也是自旋一定次数(这里涉及到一个自调整算法)。
关于这个说明,还是要看jvm的源码实现才能确认,这是jdk8的实现:hg.openjdk.java.net/jdk8/jdk8/h…

打印偏向锁的参数

如下:
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintBiasedLockingStatistics
我在main方法循环获取同一把锁,打印结果如下:

    public static void main(String[] args) {
        int num = 0;
        for (int i = 0; i < 1_000_000000; i++) {
            synchronized (lock) {
                num++;
            }
        }
    }

image.png