synchronized 详解
java对象
对象头
Mark Word(标记字段):默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
Klass Point(类型指针):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
实例数据
这部分主要是存放类的数据信息,父类的信息。
对其填充
由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐。
Tip:不知道大家有没有被问过一个空对象占多少个字节?就是8个字节,是因为对齐填充的关系哈,不到8个字节对其填充会帮我们自动补齐。
Monitor详解
当Thread-2调用synchronized (obj),底层其实都是对Monitor做操作,Monitor是操作系统的,对象头的makeWord指向对象的Monitor,会将Monitor的Owner指向Thread-2
当在thread-2执行过程中,thread-1,thread-3调用synchronized (同一对象时),发现Monitor Owner已经有线程指向了,则会放到EntryList等待队列,线程状态变为阻塞
当线程2执行完同步代码块内容,然后唤醒EntryList等待的线程来竞争锁,竞争时是非公平的
synchronized字节码详解
下面我们将从字节码层面解析synchronized底层monitor实现
public class synchronizedTest { static final Object lock =new Object(); static int count =0; public static void main(String[] args) { synchronized(lock) { count++; } }}
public static void main(java.lang.String[]); Code: 0: getstatic #2// lock引用, synchronized开始 3: dup // 复制一份 4: astore_1 // 临时存储 5: monitorenter //将lock对象MakeWord指向monitor 6: getstatic #3 // Field count:I 9: iconst_1 // 准备常量 10: iadd // count+ 11: putstatic #3 // Field count:I 14: aload_1 // lock引用 15: monitorexit //退出synchronized块 16: goto 24 //跳到24行执行 19: astore_2 20: aload_1 21: monitorexit 22: aload_2 23: athrow 24: return Exception table: from to target type 6 16 19 any 19 22 19 any
下面from6 to16表示6-16行代码发生异常时,执行target第19行
synchronized优化原理
因为如果每次调用synchronized都直接调用底层monitor会对程序性能有影响,java6开始对synchronized做了大的优化,对获取锁的方式做出了改进,下面我们来讲解其进化的原理
轻量级锁
当执行synchronized时,创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个所锁记录的结构,内部可以存储锁定对象的Mark Word
让锁记录中Object reference指向锁对象,并尝试使用cas替换Object的Mark Word,将Mark Word的值存入锁记录
如果cas替换成功,对象头中存储了锁记录地址和状态00,表示由该线程加锁成功
如果cas失败,有两种情况
如果是其他线程持有了该Object的轻量级锁,这时候表明有竞争,进入锁膨胀过程
如果是自己执行了synchronized锁重入,那么再添加一条Lock Record作为重入的计数
当退出synchronized代码快(解锁时)如果有取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
当退出synchronized代码快(解锁时)锁记录不为null,这时使用cas将Mark Word的值恢复给对象头
成功,则解锁成功
失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
注:cas原理可在后面内容中查看
锁膨胀
当Thread-1进行轻量级加锁时,Thread-0已经对该对象加了轻量级锁,这时Thread-1会进入锁膨胀流程
为Object对象申请Monitor锁,让Object指向重量级锁地址
然后自己进入Monitor的EntryList BLOCKED
当Thread-0退出同步块解锁时,使用cas将Make Word的值恢复给对象头,失败。这时会进入重量级锁流程,即按照Monitor地址找到Monitor对象,设置Owner为null,唤醒EntryList中BLOCKED线程
自旋优化
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
自旋重试成功的情况
自旋重试失败的情况
在java6之后自旋锁是自适应的,比如刚刚的依次自旋操作时成功的,那么认为这次自旋成功的可能性会高,就会多自旋几次,反之,就少自旋甚至不自旋
自旋会占用CPU时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势
旋锁的默认大小是10次,-XX:PreBlockSpin可以修改。
自旋都失败了,那就升级为重量级的锁
偏向锁
轻量级锁在没有竞争(就自己这个线程),每次重入仍然需要执行CAS操作。
java6中引入了偏向锁来做进一步优化,只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归该线程所有
public class PianXiangTest { static final Object obj = new Object(); public static void m1() { synchronized (obj) { m2(); } } public static void m2() { synchronized (obj) { m3(); } } public static void m3() { synchronized (obj) { } }}
偏向状态
回忆一下对象头格式
一个对象创建时:
如果开启了偏向锁(默认开启),那么对象创建后,makedown值最后3位为101,这时它的thread epoch age都为0
偏向锁是默认延迟的,不会在程序启动时立即生效,如果想避免演出,可以加VM参数 -xx:BiasedLockingStartupDelay=0来禁用延迟
如果没有开启偏向锁,那么对象创建后,markward即最后3位为001,这时它的hashcode,age都为0,第一次使用hashcode时才会赋值
偏向锁撤销
调用对象的hashcode
调用了对象的hashCode,但偏向锁的对象MarkWord中存储的是线程id,如果调用hashCode会导致偏向锁被撤销
轻量级锁会在锁记录中记录hasCode
重量级锁则会在Monitor中记录hashCode
其他线程使用对象
批量重偏向
如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程T1的对象仍然有机会重新偏向T2,重偏向会重置对象的Thread ID
当撤销偏向锁阈值超过20次后,jvm就会觉得,我是不是偏向错了呢,于是会再给这些对象加锁时重新偏向至加锁线程
批量撤销
当撤销偏向锁阈值超过40次后,jvm就会觉得,自己确实偏向错了,根本就不该偏向,于是整个累的所有对象都会变为不可偏向的,新建的对象也 是不可偏向的
锁消除
锁消除是Java虚拟机在JIT编译是,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。
public class SuoXiaoChu { public static String getString(String s1, String s2) { StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); return sb.toString(); } public static void main(String[] args) { long tsStart = System.currentTimeMillis(); for (int i = 0; i < 1000000; i++) { getString("TestLockEliminate ", "Suffix"); } System.out.println("一共耗费:" + (System.currentTimeMillis() - tsStart) + " ms"); }}
getString()方法中的StringBuffer数以函数内部的局部变量,进作用于方法内部,不可能逃逸出该方法,因此他就不可能被多个线程同时访问,也就没有资源的竞争,但是StringBuffer的append操作却需要执行同步操作:
@Override public synchronized StringBuffer append(String str) { toStringCache = null; super.append(str); return this; }
逃逸分析和锁消除分别可以使用参数-XX:+DoEscapeAnalysis和-XX:+EliminateLocks(锁消除必须在-server模式下)开启。使用如下参数运行上面的程序:
-XX:-DoEscapeAnalysis -XX:-EliminateLocks
-XX:+DoEscapeAnalysis -XX:+EliminateLocks
锁粗化
原则上,我们都知道在加同步锁的时候,尽可能的将同步块的作用范围限制在尽量小的范围,比如下面这两种情况:
package com.util.xgb;public class SynchronizedTest {private int count;public void test() { System.out.println("test"); int a = 0; synchronized (this) { count++; }}}
ackage com.util.xgb;public class SynchronizedTest {private int count;public void test() { synchronized (this) { System.out.println("test"); int a = 0; count++; }}}
很明显,第一种实现方式好于第二种,它并不会将对非共享数据的操作划分到同步代码块中,使得同步需要的操作数量更少,在存在锁竞争的情况下,也可以使得等待锁的线程尽快的拿到锁。
对于大多数情况,这种思想是完全正确的,但是如果存在一连串的操作都对同一个对象进行反复的加锁和解锁,甚至加锁的操作出现在循环体中,那么即使没有线程竞争共享资源,频繁的进行加锁操作也会导致性能损耗。
锁粗化的思想就是扩大加锁范围,避免反复的加锁和解锁。这里还是拿 StringBuffer 举例,如下所示
public String test(String str){ int i = 0; StringBuffer sb = new StringBuffer(): while(i < 100){ sb.append(str); i++; } return sb.toString():}
在这种情况下,JVM 会检测到这样一连串的操作都对同一个对象加锁(while 循环内 100 次执行 append,没有锁粗化的就要进行 100 次加锁/解锁),此时 JVM 就会将加锁的范围粗化到这一连串的操作的外部(比如 while 虚幻体外),使得这一连串操作只需要加一次锁即可。
总结
锁的升级方向
无锁-》偏向锁-》轻量级锁-》重量级锁
Tip:切记这个升级过程是不可逆的
用synchronized还是Lock呢
我们先看看他们的区别:
synchronized是关键字,是JVM层面的底层啥都帮我们做了,而Lock是一个接口,是JDK层面的有丰富的API。
synchronized会自动释放锁,而Lock必须手动释放锁。
synchronized是不可中断的,Lock可以中断也可以不中断。
通过Lock可以知道线程有没有拿到锁,而synchronized不能。
synchronized能锁住方法和代码块,而Lock只能锁住代码块。
Lock可以使用读锁提高多线程读效率。
synchronized是非公平锁,ReentrantLock可以控制是否是公平锁。
两者一个是JDK层面的一个是JVM层面的,我觉得最大的区别其实在,我们是否需要丰富的api,还有一个我们的场景。
比如我现在是滴滴,我早上有打车高峰,我代码使用了大量的synchronized,有什么问题?锁升级过程是不可逆的,过了高峰我们还是重量级的锁,那效率是不是大打折扣了?这个时候你用Lock是不是很好?
场景是一定要考虑的,我现在告诉你哪个好都是扯淡,因为脱离了业务,一切技术讨论都没有了价值。