synchronized原理

1,093 阅读6分钟

synchronized锁分为三种,分别是重量级锁,轻量级锁以及偏向锁。在不同的情况下,synchronized使用的锁机制不同。

Java对象头:

以32位虚拟机为例:

普通对象: image.png 数组对象:

image.png 相较于普通对象,数组对象的对象头有96位,其中多出来的32位用来存储数组的长度。

上面我们说过,当获取到锁对象时,锁对象的对象头的MarkWord区域会存储指向Monitor对象的指针,这里我们来看一下Mark Word区域。

image.png 这里我们可以看到,当开启偏向锁时(Biased),相较于普通状态,Mark Word区域有了一些改变,存储hashcode的区域变成了存储thread和epoch,biased_lock由0变成1,当升级为轻量锁时(Lightweight Locked),高30位都改成了存储一个锁记录,同时低2位由01变成00,当膨胀为重量锁后,高30位存储指向关联的Monitor对象的指针,同时低2位由00变为10.

重量级锁:

Monitor:被翻译成管程或者监视器,我们也可以将Monitor看作一个对象,每个对象都可以关联一个Monitor对象,Monitor对象的结构如下图,当synchronized锁住一个锁对象时,该锁对象的对象头的MarkWord区域就会被置为指向Monitor对象的指针。

image.png

  1. 起初Owner为null,当Thread-2执行synchronized拿到锁对象时,锁对象关联的Monitor的Owner属性就会被置为Thread-2,也就是拿到锁对象线程。
  2. 当Thread-2拿到锁对象,执行同步代码块时,如果其他线程执行到synchronized处,那么由于拿到锁的线程没有释放锁,Owner不为null,其他线程就会进入EntryList队列等待,也就是图中的Thread-3,4,5。
  3. 当Thread-2在执行同步代码块时,调用了锁对象的wait方法就会进入到WaitSet区域等待,同时释放锁并唤醒EntryList中等待的线程。
  4. 锁被释放后,处于等待队列中的线程以及新来的线程就会尝试竞争锁,拿到所的线程,将会成为Monitor对象的Owner,其他的线程继续在EntryList队列中等待(Blocked)。
  5. 当其它拿到锁的线程执行同步代码块时,调用锁对象的notify()或者notifyAll方法时,处在WaitSet区域中被唤醒的线程就会进入EntryList队列中等待锁被释放后参与竞争。

image.png

image.png

由上面代码对应的字节码我们可以看到,Java源码被编译成字节码之后,被synchronized包围的同步代码块前后会被加上一个monitorenter指令和monitorexit指令,其作用分别是将锁对象的对象头的MarkWord区域置为指向Monitor的指针以及重置,唤醒EntryList

轻量级锁:

当有多个线程访问共享资源,但是这多个线程访问的时间是错开的时候,我们可以用轻量锁来优化。 轻量锁的使用语法依旧是synchronized关键字。

  1. 首先当某个线程执行方法时,会在虚拟机栈中开辟一个栈桢,同时会在栈桢中创建一个锁记录(Lock Record)对象,所记录对象可以存储锁对象的Mark Word.

image.png 2. 锁记录中的reference指向锁对象,同时尝试使用cas将锁对象的Mark Word值替换为当前栈桢中锁记录的地址,同时将Mark Word值存入锁记录,相当于交换了锁记录的地址和Mark Word的值。

image.png 3. 如果cas操作成功了,锁对象的对象头中存储了所记录的地址以及状态00,则线程加锁成功。

image.png

  1. 如果cas失败了,则有两种情况:
  • 其他线程已经持有了改对象的轻量锁,表明有线程竞争,则进入锁膨胀的过程。
  • 当前线程已经拿到了该对象的轻量锁,本次是锁重入,那么就再添加一条锁记录,用来作为锁重入的计数,该锁记录的reference也指向锁对象。

image.png

  1. 当synchronized进行解锁时,发现有值为null的锁记录,就知道这是一次锁重入,计数减一。
  2. 当发现值不为null的锁记录时,尝试使用cas将Mark Word值恢复给对象头,这个时候也会出现两种情况:
  • 恢复成功,成功释放锁。
  • 恢复失败,发生了锁膨胀,则进入重量级锁的解锁过程。

锁膨胀:

当某个线程尝试加轻量锁失败时,则有可能是另一个线程已经给此对象加了轻量锁,说明发生了竞争,则进入锁膨胀流程。

  1. 当Thread-0已经给对象加了轻量锁时,如果Thread-1尝试给对象加轻量锁

image.png

  1. Thread-1加轻量锁失败,进入锁膨胀过程:
  • 锁对象关联一个Monitor对象,对象头的Mark Word区域置为Monitor对象的地址。
  • 加锁失败的Thread-1进入Monitor的EntryList等待,已经拿到Monitor的Owner置为Thread-0。

image.png

  1. 当Thread-0执行完毕释放锁时,尝试使用cas将Mark Word值恢复给锁对象头,这个时候会失败,进入重量级锁的解锁流程,将Monitor的Owner置为null,同时唤醒EntryList中的等待线程。

偏向锁:

轻量锁在没有其他线程竞争时,每次加锁解索都要进行cas操作,也会损耗一定的资源,所以这里Java6中引入了偏向锁进行优化。当第一次加锁时cas将当前线程id放到锁对象头的MarkWord区域,后续解锁时不会恢复MarkWord区域,只要后续发现线程id是自己的就无需进行cas操作,只要没有其他线程竞争就会一直持有改锁。

image.png 回看我们上面的对象头,对象创建时:

  • 如果开启了偏向锁(默认是开启的),则MarkWord后三位是101,这个时候他的Thread,epoch和age都是0
  • 偏向锁是默认延迟的,不会再程序启动的时候立即开启,如果要避免延迟,可以使用虚拟机参数- XX:BiasedLockingStartupDelay=0来禁用延迟
  • 如果对象创建时没有开启偏向锁,则MarkWord后三位为001,他的hashcode和age都为零,hashcode只有第一次调用时才会赋值。

偏向锁的撤销:

  1. 调用对象的hashcode时偏向锁会被撤销,因为偏向锁的对象头中没有存储hashcode的区域。而轻量级锁则是将hashcode记录在锁记录中,重量级锁则是将hashcode记录在Monitor中。
  2. 当有其他线程参与竞争的时候,偏向锁会升级成轻量级锁
  3. 当锁对象调用wait/notify/notifyAll方法时,偏向锁会膨胀成重量级锁,因为和Monitor里的区域有关。

批量重偏向:

当偏向某一个线程的锁被撤销超过20次之后,JVM会觉得锁偏向了错误的线程,那么在下一次偏向被撤销后会重新偏向新的拿到锁的线程。(这里所说的撤销20次不是非得是同一个锁,也可以是多个锁甚至20个锁)

批量撤销:

当偏向某一个线程的锁被撤销超过40次时,JVM会觉得根本不应该使用偏向锁,那么所有的对象的偏向锁都会被关闭,新创建的对象也不会默认开启偏向锁。