synchronized详解
1. 简介
- 解决多线程访问共享资源的同步性问题
synchronized
关键字保证被其修饰的方法或者代码块在任意时刻只能有一个线程在执行- 在 Java 早期版本中,
synchronized
属于 重量级锁
- 因为
监视器锁【monitor】
依赖操作系统的Mutex Lock
,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。 Java 6
之后,在JVM层面对 synchronized
进行优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁
2. 用法
2.1 修饰实例方法
synchronized void func(){
}
2.2 修饰静态方法
synchronized static void func(){
}
2.3 修饰代码块
synchronized(this){
}
synchronized(object){
}
synchronized(类.class){
}
2.4 注意
- 由于JVM中的字符串常量池具有缓存功能,故应该不使用
synchronized(String a)
3. 双重检验锁实现单例模式
public class Singleton{
private volatile static Singleton singleton;
private Singleton{}
public static Singleton getSingleton(){
if(singletom == null){
synchronized(Singleton.class){
if(singleton == null){
this.singleton = new Singleton();
}
}
}
return singleton;
}
}
注意:
- 若不使用
volatile
,则有可能导致对象尚未初始化就被使用的情况发生 - 使用
volatile
关键字可以禁止 JVM 的指令重排,保证在多线程下的线程安全
4. 构造方法不能使用synchronized修饰
- 构造方法本身即线程安全【JVM提供保证】,不存在同步构造方法的说法
5. sychronized的底层实现
- 该关键字的底层原理属于 JVM 层面
- 以下两种情况的本质都是对 对象监视器 monitor 的获取
5.1 同步代码块的情况
synchronized(this){
}
synchronized(object){
}
synchronized(类.class){
}
- 使用的是
monitorenter
和 monitorexit
指令 - 当执行monitorenter指令时,线程试图获取
对象监视器monitor
的持有权 - 在执行
monitorenter
时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1 - 在执行
monitorexit
指令后,将锁计数器设为 0,表明锁被释放 - 对象监视器monitor 是基于 C++ 【ObjectMonitor】实现的,每个对象内置一个ObjectMonitor对象
5.2 同步方法的情况
synchronized void func(){
}
synchronized static void func(){
}
synchronized
修饰的方法并没有 monitorenter
指令和 monitorexit
指令,取得代之的确实是 ACC_SYNCHRONIZED
标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
6. JDK1.6 对synchronized的优化
6.1 概述
- 引入
偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化
等技术来减少锁的操作 - 锁主要存在四种状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。随着锁竞争的激烈程度逐渐升级,且不可降级。
6.2 对象头
- 在HotSpot虚拟机中, 对象在内存中的布局分为三块区域: 对象头, 实例数据和对齐填充.
- 对象头中包含两部分: MarkWord 和 类型指针.
- MarkWord:用于存储对象自身的运行时数据, 如HashCode, GC分代年龄, 锁状态标志, 线程持有的锁, 偏向线程ID等等。占用内存大小与虚拟机位长一致(32位JVM -> MarkWord是32位, 64位JVM->MarkWord是64位)
- 类型指针:指向对象的类元数据, 虚拟机通过这个指针确定该对象是哪个类的实例.
- 如果是数组对象的话, 对象头还有一部分是存储数组的长度.
- 多线程下synchronized的加锁就是对同一个对象的对象头中的MarkWord中的变量进行CAS操作.
长度 | 内容 | 说明 |
---|
32/64 bit | MarkWord | 存储对象的hashCode或锁等信息 |
32/64bit | Class Metadata Address | 存储对象类型数据的指针 |
32/64bit | Array Length | 数组长度【如果当前对象是数组】 |
6.3 优化后锁的升级过程
6.3.1 无锁状态
25bit | 4bit | 1bit【是否偏向锁】 | 2bit【锁标志位】 |
---|
对象的hashCode | 对象分代年龄 | 0 | 01 |
当没有线性竞争锁时,锁对象处于无锁状态
6.3.2 偏向锁状态
23bit | 2bit | 4bit | 1bit | 2bit |
---|
线程ID | epoch | 对象分代年龄 | 1 | 01 |
- 当只有一个线程竞争锁的时候,锁升级为偏向锁状态。
- 适用于只有一个线程访问同步块的情况
- 当一个线程访问同步块并获取锁时, 会在锁对象的对象头和栈帧中的锁记录里存储锁偏向的线程ID
- 以后该线程进入和退出同步块时不需要进行CAS操作来加锁和解锁, 只需要简单的测试一下锁对象的对象头的MarkWord里是否存储着指向当前线程的偏向锁(线程ID是当前线程)
- 如果测试成功, 表示线程已经获得了锁; 如果测试失败, 则需要再测试一下MarkWord中偏向锁的标识是否设置成1(表示当前是偏向锁)
- 如果没有设置, 则使用CAS竞争锁, 如果设置了, 则尝试使用CAS将锁对象的对象头的偏向锁指向当前线程.
- 偏向锁使用了一种等到竞争出现才释放锁的机制, 所以当其他线程尝试竞争偏向锁时, 持有偏向锁的线程才会释放锁. 偏向锁的撤销需要等到全局安全点(在这个时间点上没有正在执行的字节码). 首先会暂停持有偏向锁的线程, 然后检查持有偏向锁的线程是否存活, 如果线程不处于活动状态, 则将锁对象的对象头设置为无锁状态; 如果线程仍然活着, 则锁对象的对象头中的MarkWord和栈中的锁记录要么重新偏向于其它线程要么恢复到无锁状态, 最后唤醒暂停的线程(释放偏向锁的线程).
- 偏向锁在Java6及更高版本中是默认启用的, 但是它在程序启动几秒钟后才激活. 可以使用-XX:BiasedLockingStartupDelay=0来关闭偏向锁的启动延迟, 也可以使用-XX:-UseBiasedLocking=false来关闭偏向锁, 那么程序会直接进入轻量级锁状态
6.3.3 轻量级锁
30bit | 2bit【锁标志位】 |
---|
指向栈中锁记录指针 | 00 |
- 当出现两个线程来竞争锁,则偏向锁会膨胀为轻量级锁
- 线程在执行同步块之前, JVM会先在当前线程的栈帧中创建用户存储锁记录的空间, 并将对象头中的MarkWord复制到锁记录中
- 然后线程尝试使用CAS将对象头中的MarkWord替换为指向锁记录的指针. 如果成功, 当前线程获得锁; 如果失败, 表示其它线程竞争锁, 当前线程便尝试使用自旋来获取锁, 之后再来的线程, 发现是轻量级锁, 就开始进行自旋.
- 轻量级锁解锁时, 会使用原子的CAS操作将当前线程的锁记录替换回到对象头, 如果成功, 表示没有竞争发生; 如果失败, 表示当前锁存在竞争, 锁就会膨胀成重量级锁
- 总结一下加锁解锁过程, 有线程A和线程B来竞争对象c的锁(如: synchronized©{} ), 这时线程A和线程B同时将对象c的MarkWord复制到自己的锁记录中, 两者竞争去获取锁, 假设线程A成功获取锁, 并将对象c的对象头中的线程ID(MarkWord中)修改为指向自己的锁记录的指针, 这时线程B仍旧通过CAS去获取对象c的锁, 因为对象c的MarkWord中的内容已经被线程A改了, 所以获取失败. 此时为了提高获取锁的效率, 线程B会循环去获取锁, 这个循环是有次数限制的, 如果在循环结束之前CAS操作成功, 那么线程B就获取到锁, 如果循环结束依然获取不到锁, 则获取锁失败, 对象c的MarkWord中的记录会被修改为重量级锁, 然后线程B就会被挂起, 之后有线程C来获取锁时, 看到对象c的MarkWord中的是重量级锁的指针, 说明竞争激烈, 直接挂起.
- 解锁时, 线程A尝试使用CAS将对象c的MarkWord改回自己栈中复制的那个MarkWord, 因为对象c中的MarkWord已经被指向为重量级锁了, 所以CAS失败. 线程A会释放锁并唤起等待的线程, 进行新一轮的竞争.
6.3.4 重量级锁
30bit | 2bit |
---|
指向互斥量【重量级锁】的指针 | 10 |
6.4 锁比较
锁 | 优点 | 缺点 | 适用场景 |
---|
偏向锁 | 加锁和解锁不需要额外的消耗, 和执行非同步代码方法的性能相差无几. | 如果线程间存在锁竞争, 会带来额外的锁撤销的消耗. | 适用于只有一个线程访问的同步场景 |
轻量级锁 | 竞争的线程不会阻塞, 提高了程序的响应速度 | 如果始终得不到锁竞争的线程, 使用自旋会消耗CPU | 追求响应时间, 同步快执行速度非常快 |
重量级锁 | 线程竞争不使用自旋, 不会消耗CPU | 线程堵塞, 响应时间缓慢 | 追求吞吐量, 同步快执行时间速度较长 |