Synchronized原理
Synchronized原理
1、Synchronized使用
1 |
|
字节码:
1 |
|
1 |
|
我们发现有两条陌生的指令:monitorenter
和monitorexit
Javac在编译时,会生成对应的monitorenter
和monitorexit
指令分别对应synchronized
同步块的进入和退出
其中,我们可以发现有两个monitorexit
,这是因为:
为了保证抛异常的情况下也能释放锁,所以java为同步代码块添加了一个隐式的try-finally,在finally中会调用monitorexit
命令释放锁。
对于synchronized
方法而言,javac
为其生成了一个ACC_SYNCHRONIZED
关键字,在JVM进行方法调用时,发现调用的方法被ACC_SYNCHRONIZED
修饰,则会先尝试获得锁。
我们发现在字节码中出现了两次monitorexit
。这是在两个不同的代码路径上执行一次。
- 第一个指令用于synchronized块的正常退出。
- 为了保证抛出异常的情况下也能释放锁,所以Javac为同步代码块添加一个隐式try-finally,在finally中会调用
monitorexit
命令释放锁。可以看到,在第一个monitorexit
之后有一个goto 23,说明,直接跳到23行指令,因此有两个monitorexit并没有执行
。
==monitorenter==
每一个对象都会和一个监视器锁monitor
关联。监视器被占用时会被锁住,其他线程无法来获取该monitor。当JVM执行某个线程的某个方法内部的monitorenter时,它会尝试去获取当前对象对应的monitor的所有权。过程如下:
- 若monitor的进入数为0,线程可以进入monitor,并将monitor的进入树置为1.当前线程成为monitor的ower(所有者)
- 若线程已拥有monitor的所有权,允许它重入monitor,则进入monitor的进入数加1
- 若其他线程已经占有monitor的所有权,那么当前尝试获取monitor的所有权的线程会被阻塞,直到monitor的进入数变为0,才能重新尝试获取monitor的所有权。
synchronized的锁对象会关联一个monitor,这个monitor不是我们主动创建的,是JVM的线程执行到这个同步代码块,发现锁对象没有monitor就会创建monitor,monitor内部有两个重要的成员变量owner:拥有这把锁的线程,recursions会记录线程拥有锁的次数,当一个线程拥有monitor后其他线程只能等待
==monitorexit==
能执行monitorexit指令的线程一定是拥有当前对象的monitor的所有权的线程。
执行monitorexit时会将monitor的进入数减1。当monitor的进入数减为0时,当前线程退出monitor,不再拥有monitor的所有权,此时其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。
monitorexit释放锁。monitorexit插入在方法结束处和异常处,JVM保证每个monitorenter必须有对应的monitorexit。
2、Java对象头
synchronized用的锁是存放在Java对象头里的。如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头。如果对象是非数组类型,则用2字宽存储对象头。
在32位虚拟机中,1字宽等于4字节,即32bit。
普通对象:
数组对象:
Mark Word:存储对象的hashCode或一些锁信息
Klass Word:存储到对象类型数据的指针
array length:数组的长度(如果当前对象是数组)
具体的MarkWord信息和状态变化:
3、监视器锁monitor
无论是同步代码方法,同步代码块,都依赖于一个monitor监视器锁。
monitor源码
下面是HotSpot虚拟机中的monitor源码:
1 |
|
在源码中,我们要注意一些重要的数据结构:
_owner
初始化时为NULL。当有线程占有该monitor时,owner标记为线程的唯一标识。当线程释放monitor时,owner又恢复为NULL。owner是一个临时资源,JVM是通过CAS操作来保证其线程安全的。
_cxq :竞争队列
所有请求锁的线程首先会被放在这个队列中(单向链接)。_cxq是一个临时资源, JVM通过CAS原子指令来修改cxq队列。
_EntryList
存放处于等待锁block状态的线程队列
_WaitSet
存放处于wait状态的线程队列,即调用wait()方法的线程
monitor使用
- 刚开始Monitor时Owner为null
当Thread-2执行synchronized(obj)就会将monitor的所有者owner置为Thread-2,monitor只能有一个owner。
MarkWord中的信息会按照不同的锁进行更改。如果是重量级锁,会指向Monitor对象
在Thread-2上锁的过程中,如果Thread-3,Thread-4,Thread-5也来执行synchronized(obj),就会进入EntryList BLOCKED
Thread-2执行完同步代码块的内容,然后唤醒EntryList中等待的线程来竞争锁。这个竞争是非公平的。
4、锁升级
Java SE1.6为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁,在JavaSE1.6中,锁一共有四种状态,级别从低到高依次是:无锁状态,偏向锁、轻量级锁、重量级锁。这几个状态会随着竞争情况逐渐升级。所可以升级但不能降级,意味着偏向锁升级为轻量级锁之后不能降级为偏向锁。(锁降级发生在读写锁上)。
1、轻量级锁
轻量级锁的使用场景:如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
轻量级锁对使用者是透明的,语法依旧是synchronized。
轻量级锁流程
1 |
|
首先判断obj的对象是否处于无锁的状态。如果是,创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,==内部可以存储锁对象的Mark Word==
Lock Record
对象中,有两个结构,Displaced Mark Word
记录锁对象的MarkWord,Object reference
指向锁对象。比如上面程序中的obj锁对象。
让锁记录中
Object reference
指向锁对象后,并尝试用CAS替换obj对象锁的Mark Word
。将MarkWord的值存入锁记录。CAS如何替换:
根据对象的
MarkWord
最后两位是否是01,如果是表示此时无锁,可以成功。如果其他线程将它修改为00,那么CAS操作失败。
- 如果CAS替换成功,对象头中存储了锁记录地址和状态00,表示由该线程给对象加锁。
- 如果CAS失败,可能由两种情况。
- 如果是其他线程已经持有了该obj的轻量级锁,这时表明有竞争,进入锁膨胀过程。
- 如果是自己执行了synchronized锁重入。那么在添加一条Lock Record作为重入的计数。
- 当退出synchronized代码块(解锁时),如果由取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减1。
当退出synchronized代码块(解锁时),如果由取值为null的锁记录,这是使用CAS将MarkWord的值给对象头。
如果成功:则解锁成功
如果失败:说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程。
轻量级锁锁膨胀
在解锁时,如果CAS操作失败,表示当前线程执行同步代码块时,有其他线程也在访问,当前的锁是被竞争。那么轻量级锁会膨胀为重量级锁。
- 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
这时Thread-1加轻量级锁,进入锁膨胀流程
Thread-1为obj对象申请一个Monitor锁,让Obj的
MarkWord
指向重量级锁地址。原来是指向锁记录中的Displaced Mark Word
。最后两位是01。然后Thread-1进入Monitor的EntyList BLOCKED。
并且要改成obj中
Displaced Mark Word
的最后两位为10,表示是重量级锁。
- 对于Thread-0来说,执行完同步代码块后。使用CAS将Mark Word的值恢复给Obj锁对象的对象头。但是失败,因为此时最后两位已经被更改为10。说明此时已经是重量级锁,需要按照重量级锁的解锁流程来解锁。
自旋
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
2、偏向锁
HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是有同一线程多次获得。为了让线程获得锁的代价更低而引入了偏向锁。
使用jar包可以查看对象头MarkWord信息。
1 |
|
如何使用:
1 |
|
偏向锁的特点
- 如果开启了偏向锁(默认开启),那么对象创建后,markword值的最后三位是001,这是它的thread、epoch、age都为0。
- 偏向锁是默认延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加VM参数
-XX:BiasedLockingStartupDelay=0
来禁用延迟。
1 |
|
如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后 3 位为 001,这时它的 hashcode、age 都为 0,第一次用到 hashcode 时才会赋值
添加 VM 参数
-XX:-UseBiasedLocking
禁用偏向锁
1 |
|
可以看到两次打印的对象头都是相等的
偏向锁流程
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID。
以后该线程在进入和退出同步块时不需要进入CAS操作来加锁和解锁,只需要简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。
如果测试成功,表示线程已经获得了锁。
如果测试失败,则需要在测试一下Mark Word中偏向锁的标识是否设置成1(表示当前时偏向锁),如果没有设置,则使用CAS竞争锁。如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁的撤销
- 调用对象的 hashCode
调用了对象的 hashCode,但此时偏向锁的对象 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被撤销。
其他线程使用锁对象
当有多个线程竞争时,会撤销偏向锁。改为轻量级锁。