synchnized原理及优化
sychronized的使用场景:
# 1. synchronized的特点
- 有序性:禁止指令重排
- 可见性:JMM内存模型
- 原子性:加锁
- 可重入性:synchronized锁计数器
- 不可中断性:锁不可被中断(区别于Lock的tryLock)
- 非公平:Lock可实现公平
# 2. 对象锁monitor机制
# 对象在内存中的存储
【对象头】 对象为8byte
- Mark Word(标记字段):默认存储对象的
HashCode
,分代年龄和锁标志位信息。他会根据对象的状态-复用自己的存储空间,Mark Word中的数据会随着标志位的变化而变化 - Klass Point(类型指针):对象指向它类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
【实例数据】:存放类的数据信息,父类的信息
【对齐填充数据】:虚拟机要求对象起始地址必须是8字节的整数倍
刚开始Monitor中Owner为null
当Thread-2执行
synchronized(obj)
就会成为Monitor的所有者,Owner置为Thread-2,Monitor中只能有一个Owner在Thread-2上锁的过程中,如果Thread-3、Thread-4、Thread-5也来执行
synchronized(obj)
,就会进入EntryList BLOCKEDThread-2执行完同步代码块的内容,然后唤醒EntryList中等待的线程来竞争锁;竞争的时是非公平的,并不是先进入EntryList中得线程先获取锁,取决于操作系统调度器的调度顺序
图中WaitSet中的Thread-0、Thread-1是已经获得锁,但条件不满足进入WAITING状态的线程
注意
synchronized必须是进入同一个对象的monitor才有上述的效果
不加synchronized的对象不会关联监视器,不遵从以上规则
# 3. synchronized底层原理
# synchronized修饰同步代码块
先来看一段简单的代码
为了解到synchronized的底层实现原理,我们来对这段代码进行反编译
先通过cd
命令进入到src下的package目录
先编译javac Test.java
,生成class文件
反编译命令:
javap -c -v Test.class
然后我们来看同步代码块下生成的字节码:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: getstatic #2 // <- lock引用<synchronized开始>
3: dup
4: astore_1 // lock引用,存入局部变量表slot_1
5: monitorenter // 将lock对象 Markward置为 Monitor指针
6: getstatic #3
9: ldc #4
11: invokevirtual #5
14: aload_1
15: monitorexit // 将lock对象MarkWard重置,唤醒EntryList
16: goto 24
19: astore_2
20: aload_1
21: monitorexit // 将lock对象 Markward重置,唤醒 EntryList
22: aload_2
23: athrow
24: return
Exception table: // 异常监测表
from to target type // 监测范围
6 16 19 any
19 22 19 any
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
执行同步代码块后首先要先执行monitorenter
指令,退出的时候执行monitorexit
指令。
通过分析之后可以看出,使用synchronized进行同步,其关键就是要获取对象的监视器monitor
,当线程获取monitor
后才能继续往下执行,否则就只能等待。
而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到monitor
。
上述字节码中包含一个monitorenter
指令以及两个monitorexit
指令。这是因为JVM需要确保获得的锁在正常执行路径、异常执行路径上都能够被解锁。
# synchronized修饰同步方法
当用synchronized标记方法时,字节码中方法的访问标记
flags
多了 ACC_SYNCHRONIZED
。
- 进入该方法时,JVM需要进行
monitorenter
操作 - 退出该方法时,不管是正常返回,还是向调用者抛异常,JVM均需要进行
monitorexit
操作
# monitorenter---monitorexit
关于monitorenter 和 monitorexit 的作用,我们可以抽象地理解为:每个锁对象拥有一个锁计数器和一个 “指向持有该锁的线程” 的指针(对象头)
当执行monitorenter时,如果目标锁对象的计数器为0,那么说明它没有被其他线程所持有。在这个情况下JVM会将该锁对象的持有线程设置为当前线程,并且将其计数器加1
在目标锁对象的计数器不为0的情况下,如果锁对象的持有线程是当前线程,那么JVM可以将其计数器再加1;否则需要等待,直至持有线程释放该锁
当执行monitorexit时,JVM则需将锁对象的计数器减1。当计数器减为0时,便代表该锁已经被释放掉了
# 可重入锁
当执行monitorenter
时,对象的monitor计数器值不为0,但是持有锁的线程恰好是当前线程。此时将monitor计数器值再次+1
,当前线程再次进入同步方法或代码块
之所以采用这种计数器的方式,是为了允许同一个线程重复获取同一把锁
【证明锁的可重入与互斥】
synchronized修饰的test1
方法中调用test2
方法
class Sync implements Runnable {
@Override
public void run() {
test1();
test2();
}
public synchronized void test1() {
if (Thread.currentThread().getName().equals("A")) {
test2();
}
}
public synchronized void test2() {
if (Thread.currentThread().getName().equals("B")) {
System.out.println("B线程进入该同步方法test2()...");
}else {
//此时B线程还没有启动
System.out.println(Thread.currentThread().getName() + "线程--->进入test2()方法");
}
}
}
public class Reentrant {
public static void main(String[] args) throws InterruptedException {
Sync run = new Sync();
new Thread(run,"A").start();
//有时间差,保证A先启动
Thread.sleep(2000);
new Thread(run,"B").start();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
如果一个类中拥有多个synchronized方法,那么这些方法之间的相互调用,不管是直接的还是间接的,都会涉及对同一把锁的重复加锁操作
# 4. JDK1.6后对synchronized的优化
synchronized的操作都是互斥的,Monitor机制是由操作系统来提供的,效率低。
当一个线程拿到了锁资源之后,因为要保证同步,所以其他线程只能等待该线程释放锁,效率自然降低。
优化的思想:让每个线程通过同步代码块时的速度提高
# 锁升级的过程
面:synchronized是个重量级锁,那它的优化有了解嘛?
应:为了减少获得锁和和释放锁带来的性能损耗引入了偏向锁、轻量级锁、重量级锁来进行优化,锁升级的过程如下:
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
首先是一个无锁的状态,当线程进入同步代码块的时候,会检查对象头内和栈帧中的锁记录-是否是-存入当前线程的ID。如果没有,则使用**CAS **进行替换。对象头是由Mark Word和Klass pointer 组成,锁争夺也就是对象头指向的Monitor对象的争夺,一旦有线程持有了这个对象,标志位修改为1,就进入偏向模式,同时会把这个线程的ID记录在对象的Mark Word中。
【偏向锁】
以后该线程进入和退出同步代码块不需要进行CAS 操作来加锁和解锁,只需要判断对象头的Mark word内是否存储指向当前线程的偏向锁。如果有表示已经获得锁,如果没有或者不是,则需要使用CAS进行替换;如果设置成功则当前线程持有偏向锁,反之将偏向锁进行撤销并升级为轻量级锁。
偏向锁不会释放锁(大华被问到过,答错了…)
【轻量级锁加锁过程】
线程在执行同步块之前,JVM会在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头的Mark Word复制到锁记录(Displaced Mark Word)中,然后线程尝试使用CAS 将对象头中的Mark Word替换为指向锁记录的指针。
- 如果成功,当前线程获得锁,
- 反之表示其他线程竞争锁,当前线程便尝试使用自旋来获得锁
【自旋-防止上下文切换】
Linux系统的用户态和内核态的切换很耗资源,其实就是线程的等待唤起过程。
为了减少开销,提高效率,会短暂的自旋防止线程被切换挂起!
自旋,过来的线程在不断自旋,防止线程被挂起。
一旦可以获取资源,就直接尝试成功,
直到超出阈值仍然没获取到,自旋锁的默认大小是10次,自旋都失败了。那就升级为重量级的锁,像1.5的一样,等待唤起咯。
-XX:PreBlockSpin可以修改
【重量级锁】
当前线程获取到锁,其他线程处于阻塞中
# 偏向锁
JDK 1.6 之后默认synchronized
最乐观的锁:进入同步块或同步方法始终是一个线程
在不同时刻时,当出现另一个线程也尝试获取锁,偏向锁会升级为轻量级锁
# 轻量级锁
- 不同时刻有不同的线程获取锁,基本不存在锁竞争
- 同一时刻,如果不同线程尝试获取锁,会将偏向锁自动升级为重量级锁
# 重量级锁
JDK 1.6 之前的锁都是重量级锁,将线程阻塞挂起
锁只有升级过程,没有降级
# 锁粗化
将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成为一个范围更大的锁
比如使用StringBuffer中的apperd方法来添加字符串
sb.append("a");
sb.append("b");
sb.append("c");
2
3
这里每次调用append方法都需要加锁和解锁操作
如果虚拟机检测到有一系列连串对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append
方法时进行加锁,最后一次append
方法结束后进行解锁
# 锁消除
当对象不属于共享资源时,对象内部的同步方法或同步代码块的锁会被自动解除
# 5. 用synchronized还是Lock
- synchronized是关键字,是JVM层面的底层啥都帮我们做了,而Lock是一个接口,是JDK层面的有丰富的API。
- synchronized会自动释放锁,而Lock必须手动释放锁。
- synchronized是不可中断的,Lock可以中断也可以不中断。
- 通过Lock可以知道线程有没有拿到锁,而synchronized不能。
- synchronized能锁住方法和代码块,而Lock只能锁住代码块。
- Lock可以使用读锁提高多线程读效率。
- synchronized是非公平锁,ReentrantLock可以控制是否是公平锁。
两者一个是JDK层面的一个是JVM层面的,我觉得最大的区别其实在,我们是否需要丰富的api,还有一个我们的场景。
比如我现在是滴滴,我早上有打车高峰,我代码使用了大量的synchronized,有什么问题?锁升级过程是不可逆的,过了高峰我们还是重量级的锁,那效率是不是大打折扣了?这个时候你用Lock是不是很好?
场景是一定要考虑的,我现在告诉你哪个好都是扯淡,因为脱离了业务,一切技术讨论都没有了价值。