synchronized
- 类锁:给类的静态方法加上synchronized 关键字进行修饰,
- 锁的是当前类class,一个静态同步方法拿到锁,其他静态同步方法就会等待
- 静态同步方法和普通同步方法间是没有竞争的
- 对象锁:给类的方法加上synchronized 关键字进行修饰
- 锁的是当前对象 this,如果一个对象里有多个synchronized 方法,某个时刻只能有一个线程去调用这个对象其中的一个方法
- 没有加synchronized的方法不受影响
- 如果是不同的对象,锁就不同了,也就不会互相干扰
- 同步代码块锁
- 锁的是 synchronized () 内的对象
- 加锁可以保证线程安全,但是会带来性能的下降,在高并发下,能不加锁就不要加锁,一定要加锁也要使加锁的粒度尽可能的小
为什么每一个对象都可以成为锁
- 在hotsport虚拟机中,monitor采用的是ObjectMonitor实现的,每一个对象天生都带着一个监视器对象,每一个被锁住的对象都会和monitor关联起来
- monitor 的本质是依赖于操作系统的 Mutex Lock 实现,操作系统实现线程的切换需要在用户态和内核态之间切换,成本很高
- ObjectMonitor对象重要的属性
- owner属性记录了持有ObjectMonitor对象的线程id
- count 初始值为0,表示当前锁对象是否被锁定,加锁就加1,释放锁就减1
- recursions 初始值为0,表示重入次数
- entryList 阻塞队列,用于存放阻塞的线程
- waitSet 等待队列,存放等待的线程
synchronized
- 由对象头中的 mark word 根据锁标志位的不同来表示锁的状态
- 在java5之前,只有synchronized ,是操作系统级别的重量级操作,涉及到用户态和内核态的切换,如果锁竞争激烈,性能下降严重
- java的线程是映射到操作系统的原生线程之上的,如果要阻塞或者唤起一个线程就需要操作系统的接入,就需要在用户态和内核态之间切换
- 这种切换是很消耗系统资源的,因为用户态和内核态都有自己专用的内存空间、寄存器等,用户态切换至内核态需要传递很多参数和变量给内核,内核也需要保存好用户态的一些变量,以便内核态调用结束后切换回用户态继续工作
- 所以如果同步代码块中的内容很简单,有可能用户态和内核态之间的切换时间比代码本身的执行时间还长
- 所以 java6之后,通过引入轻量级锁和偏向锁,来减少获得锁和释放锁所带来的性能消耗,从而优化了synchronized
synchronized 的锁升级
- 无锁,对象新建出来,还没有和任何synchronized关联,就是无锁的状态
- 偏向锁:mark word 前54位存储偏向的线程id
- 轻量级锁
- 重量级锁
- 锁升级的过程就是,先cas自旋,实在得不到再阻塞
流程如下:
偏向锁
-
一个 synchronized 方法被一个线程抢到了锁,这个方法所在的对象就会在 mark word 中修改偏向锁的标志位,同时前54位也会用来存储线程指针,也就是偏向线程id
-
偏向模式,如果不存在其他线程竞争,那么持有偏向锁的线程永远不需要进行同步,也就是说,一段同步代码块,如果一直被一个线程多次访问,那么该线程后续的访问会自动获得锁
-
因为HotSpot作者研究发现,多线程的情况下,大多数时候,锁不仅不存在竞争关系,还存在锁由同一个线程多次获得的情况
-
所以只需要锁在第一次被拥有的时候,记录下线程的id,这样偏向线程会一直持有锁,这个线程后续进入和退出听不代码块的时候,不需要再次加锁和释放锁,而是去检查 mark word 中的偏向线程id是不是自己
- 如果是那么锁偏向于当前线程,就不需要再去尝试获得锁了,会直接进入同步块,不需要每次都通过CAS更新对象头,如果自始至终都只有一个线程持有锁,那么偏向锁几乎没由额外的开销,性能极高
- 如果偏向线程id不是当前线程,表示发生了竞争,表示锁已经不是总偏向于一个线程了,这个时候,会尝试使用CAS来更新 mark word 里的线程id为当前线程的id
- 如果CAS竞争成功, mark word 里的线程id 就会替换为当前线程的id,锁也不会升级,仍然是偏向锁,只不过是从一个线程偏向到另一个线程
- 但是如果CAS竞争失败,这个时候就有可能需要 撤销偏向锁,升级为轻量级锁,使线程间公平竞争
-
偏向锁会偏向于第一个访问到锁的线程,且只有偏向锁被其他线程竞争,持有偏向锁的线程才会释放锁,否则线程是不会主动释放锁的,而对于持有偏向锁的线程也就不需要触发同步,就能在没有资源竞争的情况下消除了同步语句
-
jdk6之后,默认就开启了偏向锁
- 但是 HotSpot 虚拟机在启动后有个 4s 的延迟才会对每个新建的对象开启偏向锁模式,这四秒钟之内默认会进入轻量级锁
- 因为 JVM 启动时会进行一系列的复杂活动,比如装载配置,系统类初始化等等。在这个过程中会使用大量synchronized关键字对对象加锁,且这些锁大多数都不是偏向锁。为了减少初始化时间,JVM默认延时加载偏向锁
偏向锁的撤销
- 只有发生竞争时,偏向锁才会释放,原本持有偏向锁的线程才会被撤销
- 撤销需要等待全局安全点,也就是该时间点上没有字节码正在执行,同时检查持有偏向锁的线程是否还在执行
- 如果线程正在执行同步方法,则升级锁
- 其他线程就尝试使用CAS来更新 mark word 里的线程id,从而抢夺锁,偏向锁就会被取消掉并升级为轻量级锁
- 轻量级锁仍然由原本的线程A持有,A会继续执行同步代码,其他正在竞争的线程会进入自旋等待重新获取轻量级锁
- 如果线程执行完了,就会释放锁,并将对象头设置为无锁状态,并撤销偏向锁,被其他线程抢占,重新偏向
java15后会逐步废弃偏向锁
- 在java15之前,偏向锁是默认开启的
- 但是15之后,就默认不在开启了,除非手动开启
轻量级锁
-
关闭偏向锁功能或者多线程竞争偏向锁都会导致偏向锁升级为轻量级锁,升级为轻量锁,会把偏向锁标记改为0,并设置标志位为00
-
对象头 mark word 前62位用来记录线程的id,后两位为锁的标志位 00
-
轻量级锁在没有多线程的竞争下,通过cas来代替重量级锁,较少性能的消耗,能够在线程近乎交替执行同步代码块时提高性能
-
轻量级锁升级过程
- 首先线程A拿到锁,这时候的锁是偏向锁,偏向于A
- 线程B又来抢夺锁,发现锁对象头的 当前线程id 不是自己,线程B就会通过CAS操作去修改线程id希望能获得锁
- 如果B获得成功,也就是A已经执行完了 ,B会把 mark word 当前线程id设置为自己,锁仍然是偏向锁,只是重新偏向于B
- 如果B获取失败,也就是锁仍然被其他线程占用,锁就会升级为轻量锁,轻量级锁仍然由之前持有锁的线程继续持有,B线程会自旋等待获取轻量级锁
轻量级锁的加锁
- JVM会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,称为 Displaced Mark word
- 如果一个线程获得锁时发现是轻量级锁,会把当前锁的 Mark word 复制到自己的 Displaced Mark word 里
- 然后线程尝试用CAS将锁的 Mark word 替换为指向锁记录的指针,
- 如果成功,就代表获取到了锁,
- 如果失败,表示 Mark word 已经被替换为了其他线程的锁记录,说明存在其他线程竞争锁,当前线程就会尝试使用自旋来获取锁
轻量级锁的释放
- 释放锁时,当前线程会使用CAS将 Displaced Mark word 的内容复制到锁的 Mark word 中
- 如果没有发生竞争这个复制操作就会成功,如果有其他线程因为自旋多次导致轻量级锁升级为了重量级锁,那么CAS操作会失败,此时会释放锁并释放被阻塞的线程
轻量级锁和偏向锁的区别:
- 偏向锁是没有竞争关系的,轻量级锁存在锁的竞争,竞争失败,会自旋尝试抢占锁
- 偏向锁只有竞争发生才会释放锁,轻量级锁每次退出同步代码块时都需要释放锁
重量级锁
-
对象头 mark word 前62位用来指向互斥量 (重量级锁) 的指针,后两位为锁的标志位 10
-
当线程自旋达到一定次数,仍然没有获得锁,也就是有大量线程在竞争锁,那么就会升级锁为重量级锁
-
jdk6之前默认是自旋次数达到10次,或者自旋线程数超过cpu核数的一半,都会升级为重量级锁
-
jdk7增加了自适应自旋锁,也就是自旋的次数变的不在固定,
- 通过同一个锁上一次自旋的时间,和拥有锁线程的状态来决定
- 也就是如果线程自旋成功了,那么下次自旋的最大次数就会增加,因为JVM认为上次成功了,那么这次也有很大概率成功
- 反之如果很少会自旋成功,那么下次就会减少自旋的次数甚至不自旋,来避免cpu空转
synchronized
-
对于同步代码块
-
一般情况下,一把锁,会有一个monitorenter指令,和两个monitorexit指令
-
加锁会执行monitorenter
-
释放锁会执行monitorexit,如果产生异常也会执行monitorexit,所以synchronized产生异常也可以释放锁
-
-
对于同步方法
- 会加上 ACC_SYNCHRONIZED 标识,代表这个方法是同步方法
- 如果方法持有ACC_SYNCHRONIZED 标识,执行前就会去获取 monitor ,执行完再释放monitor
-
对于静态同步方法
- 会加上 ACC_SYNCHRONIZED 和 ACC_STATIC 标识,用于区分类锁和对象锁
synchronized 加锁流程
- 当执行monitorenter时,
- 如果锁计数器为0,就说明锁没有被其他线程持有,虚拟机会将当前线程设置为锁的持有线程,并且把锁计数器加1,重入次数加1,然后执行同步代码块的业务代码
- 如果锁计数器不为0
- 且持有线程是当前线程,虚拟机会把锁计数器加1,
- 如果不是,就需要进入当前锁对象的阻塞队列,等待其他线程释放锁
- 当执行monitoreixt时,虚拟机会把锁计数器减1,当锁计数器为0时,会擦除锁的持有线程,这样就释放了锁
锁升级到轻量级锁,重量级锁后,mark word中保存的就分别是线程栈帧里的锁记录指针和重量级锁指针,不在保存hashcode 和GC的年龄,这些信息的去向是:
-
首先,java中一个对象如果计算过一次哈希码,就应该保持这个值不变,除非用户手动重载hashcode方法就可以返回任意值,如果哈希码经常变动,会导致很多依赖哈希码的对象都存在风险
-
绝大多数对象的哈希码都来自于 object 的 hashcode 方法,通过在对象头中存储计算结果来保证第一次计算过后,在次调用hashcode 方法取到的哈希码就永远不会改变
-
无锁状态下,mark word 中可以存储对象的 hash code 值,当对象的 hashcode 方法第一次被调用调用时,JVM就会生成对应的hash code值并存储到 mark word 中
-
对于偏向锁,在线程获取偏向锁时,会使用线程id和epoch 值覆盖 hash code值所在的位置,所以如果一个对象已经计算过了哈希码,这个对象就无法被设置为偏向锁
- 因为如果允许的话,会导致hash code 值和线程id相互覆盖,导致前后调用hashcode 方法的计算结构不一致,所以偏向锁和哈希码不共存
- 所以如果一个对象处于偏向锁状态(锁是偏向锁,但是已经释放过了锁),被调用hashcode方法后,会直接膨胀为轻量级锁
- 如果一个对象处于偏向锁过程中(锁是偏向锁,且没有释放锁),被调用hashcode方法后,会直接膨胀为重量级锁
-
而对于轻量级锁,JVM会在当前线程的栈帧中创建一个锁记录的空间,用于存放锁对象的mark word 拷贝,这个拷贝中就包含了hash code值和GC的年龄,释放锁后会将这些信息写回对象头
-
升级到重量级锁后,mark word 保存了重量级锁的ObjectMonitor 类里有字段记录非加锁状态下的mark word ,锁释放后信息也会被写回到对象头
synchronized 锁的优缺点
- 偏向锁的优点是:加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距,缺点是如果线程间存在竞争,会带来额外的锁撤销的消耗,所以只适用于只有一个线程访问同步块的场景
- 轻量级锁的优点是:即使存在线程的竞争也不会阻塞,提高了程序的响应速度,缺点是始终拿不到锁的线程自旋会消耗cpu,适用于追求响应时间,同步块执行时间很短的场景
- 重量级锁,线程间的竞争不会消耗cpu去自旋,缺点是线程阻塞会导致响应缓慢,适用于追求吞吐量,同步块执行时间较长的情况
锁消除(同步省略)
- 在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断,同步块锁,是否有加锁的必要,如果没有就可以不考虑同步,也就是所谓的锁消除
- 因为加锁的代价是很高的,消除锁可以大大提高并发性和性能,这种情况字节码文件依然会有加锁操作,但是执行的时候会去掉
public static void test4() {
Object obj =new Object();
//例如这里,每个线程进来都会new一个对象,每个线程都有一个锁,没有意义
synchronized (obj){
System.out.println("锁消除案例");
}
}
锁粗化
- 如果对同一个对象执行了连续的加锁和解锁的操作,那么 JIT 会将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁和解锁
static Object lock =new Object();
public static void test5() {
new Thread(()->{
synchronized (lock){
System.out.println("业务1");
}
synchronized (lock){
System.out.println("业务2");
}
synchronized (lock){
System.out.println("业务3");
}
},"锁粗化案例").start();
}