1.基础知识
synchronized关键字最主要有以下3种应用方式,下面分别介绍
修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
2.Synchronized底层实现
2.1 同步代码块
public static void main(String[] args) {
int i = 1;
new SychronizedDemo().add(i);
System.out.println(i);
}
public void add(int i) {
synchronized (this) {
i++;
}
}
两个monitorexit 指令:
第一个monitorexit指令是同步代码块正常释放锁的一个标志;
如果同步代码块中出现Exception或者Error,则会调用第二个monitorexit指令来保证释放锁
2.1.1 monitorenter
- 每个对象维护着一个记录着被锁次数的计数器,对象未被线程所占用时,该计数器为0,线程进入monitor(执行monitorenter指令后),会把计数器设置为1。
- 若线程已拥有monitor的所有权,允许它重入monitor,进入一次次数+1 ;
若其他线程已经占有monitor,当前尝试获取monitor的线程会被阻塞,直到进入次数为变0,才能重新被再次获取。
2.1.2 monitorexit
能执行monitorexit指令的线程,一定是拥有当前对象的monitor所有权的。
当执行monitorexit指令计数器减到为0时,当前线程就不再拥有monitor所有权。其他被阻塞的线程即可再一次去尝试获取这个monitor的所有权。
2.2 同步方法
public class SychronizedDemo {
public static void main(String[] args) {
int i = 1;
new SychronizedDemo().add(i);
System.out.println(i);
}
public synchronized void add(int i) {
System.out.println(i++);
}
}
synchronized方法则会被翻译成普通的方法调用和返回指令如:invokevirtual、areturn指令,在VM字节码层面并没有任何特别的指令来实现被synchronized修饰的方法,而是在Class文件的方法表中将该方法的access_flags字段中的synchronized标志位置1,表示该方法是同步方法并使用调用该方法的对象或该方法所属的Class在JVM的内部对象表示Klass做为锁对象。
3.Synchronized锁升级过程
偏向锁 | 轻量级锁 | 重量级锁 | |
---|---|---|---|
适用场景 | 只有一个线程进入同步块 | 虽然多个线程,但是没有冲突;多条线程进入同步块,但是线程进入时间错开因而并未争抢锁 | 发生了锁争夺的情况:多条线程争夺锁。 |
本质 | 没有同步操作 | CAS操作代替互斥同步 | 互斥同步 |
优点 | 不阻塞,执行效率高 | 自旋不阻塞 | 不会空耗CPU,直接挂起阻塞。 |
缺点 | 适用场景太局限。若竞争产生,会有额外的偏向锁撤销的消耗。 | 长时间获取不到锁空耗CPU | 阻塞,上下文切换。 |
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。
3.1 偏向锁
偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。
所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。下面我们接着了解轻量级锁。
3.1.1 为什么要引入偏向锁?
大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。减少CPU切换,用户态到核心态的切换
3.1.2 偏向锁的升级
【1】当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁;
因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;
【2】如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活, 如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;
【3】如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,
【4】如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
3.2 轻量级锁
倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
3.2.1 为什么引入轻量级锁?
轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。
3.2.2 自旋锁
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失。
3.2.2.1 自适应自旋锁
JDK1.6引入了自适应自旋锁。线程如果上次自旋成功了,那么这次自旋的次数会更加多。因此虚拟机认为既然上次成功了,那么这次
3.2.3 轻量级锁什么时候升级为重量级?
线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;
如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。
但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。
3.3 重量级锁
内置锁在Java中被抽象为监视器锁(monitor)。在JDK 1.6之前,监视器锁可以认为直接对应底层操作系统中的互斥量(mutex)。这种同步方式的成本非常高,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。因此,后来称这种锁为“重量级锁”;
3.4 锁消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。
引用
https://segmentfault.com/a/1190000017255226
http://www.360doc.com/content/20/0602/17/70282097_916085028.shtml
https://blog.csdn.net/javazejian/article/details/72828483
https://blog.csdn.net/suifeng629/article/details/106156599
Synchronized 汇编底层实现:https://zhuanlan.zhihu.com/p/118634086