【Java 并发】【synchronized】【二】synchronized的锁重入、锁消除、锁升级原理?无锁、偏向锁、轻量级锁、自旋、重量级锁

发布时间 2023-04-03 07:34:05作者: 酷酷-

1  前言

这节我们来看看synchronized的锁重入、锁优化、和锁升级的原理。

2  synchronized锁重入

所谓锁重入,就是支持正在持有锁的线程支持再次获取锁,不会出现自己锁死自己的问题。

比如以下的代码:

synchronized(this) {
    synchronized(this){
        synchronized(this){
            synchronized(this){
                synchronized(this){
                    ........
                }
            }
        }
    }
}

可能对应下面的指令:

monitorenter 
    monitorenter
        monitorenter
            monitorenter
                monitorenter
                ......
                monitorexit
            monitorexit
        monitorexit
    monitorexit
monitorexit

回顾之前讲的加锁就是将_count 由 0 设置为1将_owner指向自己,这里的_owner指向加锁的线程。

(1)所以再次重入加锁执行monitorenter指令的时候,发现有人加锁了,同时检查_owner加锁的线程是不是自己的,如果是自己加锁的,只需要将_count 次数加1即可。

(2)同样,在释放锁的时候执行monitorexit指令,首先将_count进行减1,当_count 减少到0的时候表示自己释放了锁,然后将_owner 指向null

 所以,根据上诉锁重入的方式,代码进入了5次synchronized 相当于执行了5次monitorenter加锁,最后_count = 5。当5次monitorexit执行完了之后,_count = 0即释放了锁。

3  synchronized锁消除

锁消除,就是在不存在锁竞争的地方使用了synchronized,jvm会自动帮你优化掉,比如说下面的这段代码......

public void business() {
    // lock对象方法内部创建,线程私有的,根本不会引起竞争
    Object lock = new Object();
    synchronized(lock) {
         i++;
         j++;
         // 其它业务操作
    }
}

上面的这段代码,由于lock对象是线程私有的,多个线程不会共享;像这种情况多线程之间没有竞争,就没必要使用锁了,就有可能被JVM优化成以下的代码:

public void business() {
    i++;
    j++;
    // 其它业务操作
}

4  synchronized锁升级

讲解锁升级之前,我先问个问题,synchronized为什么要设计成可升级的锁呢?我理解的就是希望能尽量花费最小的代价能达到目的。

之前我们说过,Mark Word是一个32bit位的数据结构,最后两位表示的是锁标志位,当Mark Word的锁标志位不同的时候,代表Mark Word 中记录的数据不一样。
(1)比如锁模式标志位是,也就是最后两位是01的时候,表示处于无锁模式或者偏向锁模式。
无锁:如果此时偏向锁标志,倒数第3位,是0,即最后3位是001,表示当前处于无锁模式,此时Mark Word就常规记录对象hashcode、GC年龄信息。
偏向锁:倒数第3位是1,即Mark word最后3位是101,则表示当前处于偏向锁模式,那么Mark Word就记录者获取了偏向锁的线程ID、对象的GC年龄。
(2)轻量级锁:当锁模式标志位是00的时候,表示当前处于轻量级锁模式,此时会生成一个轻量级的锁记录,存放在获取锁的线程栈空间中,Mark Word此时就存储者这个锁记录的地址。
Mark Word存储的地址在哪个线程的栈空间中,就表示哪个线程获取到了轻量级锁。
(3)重量级锁:当锁模式标志位是10的时候,表示当前处于重量级锁模式,此时加锁就不是Mark Word的责任了,需要找monitor锁监视器,这个上一章我们已经讲解monitor加锁的原理了。
此时Mark Word就记录了一下monitor的地址,然后有线程找Mark Word的时候,Mark Word就把monitor地址给它,告诉线程自个根据这个地址找monitor进行加锁。

知道这些以后我们就来看看synchronized锁升级过程中,偏向锁的讲解:

4.1  偏向锁

当有线程第一次进入synchronized的同步代码块之内,发现:

Mark Word的最后三位是101,线程ID为空,表示当前匿名偏向状态,说明锁的这时候竞争不激烈啊。
于是选择代价最小的方式,只在第一次获取偏向锁的时候执行CAS操作(将自己的线程Id通过CAS操作设置到Mark Word中)。
后面如果自己再获取锁的时候,每次检查一下发现自己之前加了偏向锁,就直接执行代码,就不需要再次加锁了......

加了偏向锁的人确实是个自私的人,这家伙用完了锁之后,自己加锁时候修改过的Mark Word信息都不会再改回来了,也就是它不会主动释放锁

其实JVM的设计者也考虑到了,这就涉及到一个重偏向的问题。

4.2  偏向锁之重偏向

举个例子说明一下重偏向咋回事:

线程B去申请加锁,发现是线程A加了偏向锁;这时候回去判断一下线程A是否存活,如果线程A挂了,就可以重新偏向了,重偏向也就是将自己的线程ID设置到Mark Word中。

如果线程A没挂,但是synchronized代码块执行完了,这个时候也可以重新偏向了,将偏向标识指向自己,轮到我了。

如果线程B在申请获取锁的时候,线程A这哥们还没执行完synchronized同步代码块怎么办?这个时候就有锁的竞争了,这就需要将锁升级一下了,线程B就会把锁升级为轻量级锁。

偏向锁为什么要升级为轻量级锁?

下面我们来看下原因,先给看下如下代码块:

// 代码块1
synchronized(this){
  // 业务代码1
}
// 代码块2
synchronized(this){
  // 业务代码2
}
// 代码块3
synchronized(this){
  // 业务代码3
}
// 代码块4
synchronized(this){
  // 业务代码4
}

我们首先要理解的是偏向锁是方便了,当前持有锁对象的线程,再次申请锁的时候,只需要一次比较就可以了,提升自己的吞吐,那么:

假如这个时候有线程A、B、C、D四个线程,线程A先加了偏向锁。之前讲过偏向锁只是在第一次获取锁的时候加锁,后面都是直接操作的不需要加锁。这个时候其它几个线程B、C、D想要加锁,如果线程A连续执行上面4个代码块,那么其他线程看到线程A都在执行synchronized同步代码块,没完没了了,就需要等线程A执行完4个synchronized代码块之后才能获取锁啊,别的线程都只能看线程A一个人自己在那表演了,这样代码就变成串行执行了。

4.3  轻量级锁

轻量级锁模式下,加锁之前会创建一个锁记录,然后将Mark Word中的数据备份到锁记录中(Mark Word存储hashcode、GC年龄等很重要数据,不能丢失了),以便后续恢复Mark Word使用。
这个锁记录放在加锁线程的虚拟机栈中,加锁的过程就是将Mark Word 前面的30位指向锁记录地址。所以mark word的这个地址指向哪个线程的虚拟机栈中,就说明哪个线程获取了轻量级锁。就好比下面的图,线程A获取了轻量级锁,锁记录存在线程A的虚拟机栈中,然后Mark Word的前面30位或者62位存储锁记录的地址。

了解了轻量级加锁的原理之后,我们继续,来讲讲偏向锁升级为轻量级锁的过程:

(1)首先线程A持有偏向锁,然后正在执行synchronized块中的代码
(2)这个时候线程B来竞争锁,发现有人加了偏向锁并且正在执行synchronized块中的代码,为了避免上述说的线程A一直持有锁不释放的情况,需要对锁进行升级,升级为轻量级锁
(3)先将线程A暂停,为线程A创建一个锁记录Lock Record,将Mark Word的数据复制到锁记录中;然后将锁记录放入线程A的虚拟机栈中
(4)然后将Mark Word中的前30位或者62位指向线程A中锁记录的地址,将线程A唤醒,线程A就知道自己持有了轻量级锁

上面偏向锁升级为轻量级锁的过程和原理了解了,那在轻量级锁模式下,多线程是怎么竞争锁和释放锁的?我们来看下:

(1)线程A和线程B同时竞争锁,在轻量级锁模式下,都会创建Lock Record锁记录放入自己的栈帧中
(2)同时执行CAS操作,将Mark Word前30位或者62位设置为自己锁记录的地址,谁设置成功了,锁就获取到锁

上面讲了加锁的过程,轻量级锁的释放很简单,就将自己的Lock Record中的Mark Word备份的数据恢复回去即可,恢复的时候执行的是CAS操作将Mark Word数据恢复成加锁前的样子。

4.4  重量级锁的自旋

先考虑下在轻量级锁模式下获取锁失败的线程应该会怎么样?可能获取锁失败的线程应该会再去尝试吧?或者直接沉睡等待别人释放锁的时候将它唤醒?

两种其实都有可能,但是你觉得哪种花销会更小一点?线程沉睡花费代价更大吧,这涉及到上下文切换,操作系统层次涉及到用户态转内核态,是一个非常重的操作。所以肯定是不会让线程轻易就沉睡的;比如说线程沉睡再唤醒最少需要3000ms的时间,如果某个线程只使用锁150ms的时间就释放了,如果直接采用沉睡方式的话,这个时候synchronized的性能就太差了。所以啊JVM的设计者,设计了一种方案,获取锁失败之后的线程自己先原地等一段时间,然后再去重试获取锁,这种方式就叫做自旋。

但是JVM怎么知道要等多久呢,加入持有锁的那个人一直不释放锁,其他人要一直自旋等待,然后不断重复尝试吗?这样不是非常消耗CPU的资源的吗?这里自旋多少次是有一个限制的,之前我们讲解monitor的底层原理的时候就讲解过了,如果忘记的话可以回去重新看一下。monitor有一个_spinFreq参数表示最大自旋的次数,_spinClock参数表示自旋的间隔时间。所以自旋最多会重试_spinFreq次。每次失败之后等_spinClock的时间过后再去重试,如果尝试_spinFreq次之后都没有成功,那没辙了,只能沉睡了。自旋其实是非常消耗CPU资源的,自旋期间相当于CPU啥也不干,就在那等着的。为了避免自旋时间太长,所以JVM就规定了默认最多自旋10次,10次还获取不到锁,那就直接将线程挂起了,线程就会直接阻塞等待了,这个时候性能就差了。重量级的加锁过程我们前边已经说过了哈,忘记的可以看前面的。

5  小结

总的来说啊,JVM设计的这套synchronized锁升级的原则,主要是为了花费最小的代价能达到加锁的目的。

比如在没有竞争的情况下,进入synchronized的使用使用偏向锁就够了,这样只需要第一次执行CAS操作获取锁,获取了偏向锁之后,后面每次进入synchronized同步代码块就不需要再次加锁了。

然后在存在多个线程竞争锁的时候就不能使用偏向锁了,不能只偏心一个人,它优先获取锁,别人都看它表演,这样是不行的。

于是就升级为轻量级锁,在轻量级锁模式在每次加锁和释放是都需要执行CAS操作,对比偏向锁来说性能低一点的,但是总体还是比较轻量级的。

为了尽量提升线程获取锁的机会,避免线程陷入获取锁失败就立即沉睡的局面(线程沉睡再唤醒涉及上下文切换,用户态内核态切换,是一个非常重的操作,很费时间),所以设计自旋等待;线程每次自旋一段时间之后再去重试获取锁。

当竞争非常激烈,并发很高,或者是synchronized代码块执行耗时比较长,就会积压大量的线程都在自旋,由于自旋是空耗费CPU资源的,也就是CPU在那等着,做不了其他事情,所以在尝试了最大的自旋次数之后;及时释放CPU资源,将线程挂起了。

有理解不对的地方欢迎指正哈,