synchronized_1_使用和介绍

134 阅读17分钟

synchronized使用

功能:

synchronized可以作为独占锁,非公平锁,重入锁使用,用于在多线程的场景下,实现线程独占执行代码,防止线程竞争造成程序逻辑错误。

加锁目标

synchronized加锁目标为对象,根据synchronized修饰的目标不同,加锁的目标也不同。

synchronized修饰实例方法:

 public  synchronized void doSomething(){}

当synchronized修饰对象实例方法的时候,此时将会将当前对象实例作为锁对象。

优点:代码简洁,不需要手动指定锁。

缺点:

锁范围过大,synchronized修饰方法通常会造成锁的粒度过粗,通常在一个方法中只有一部分会造成线程竞争,锁住整个方法会导致不必要的阻塞。

锁对象模糊,因为synchronized会默认以当前对象实例作为锁,因此如果多线程调用对象实例中的不同方法,会造成不必要的阻塞,如thread1调用method1,thread2调用method2;这两个线程调用同一个实例的不同方法,相互独立,但是因为锁对象是方法实例,因此如果thread1没有释放锁就会造成thread2阻塞。如:

 public class SynchronizedDemo {
 ​
     public static void main(String[] args) throws InterruptedException {
         SynchronizedDemo demo = new SynchronizedDemo();
         new Thread(()->{
             System.out.println("thread1....");
             demo.doSomething1();
         }).start();
         TimeUnit.SECONDS.sleep(1);
         new Thread(()->{
             System.out.println("thread2....");
             demo.doSomething2();
         }).start();
     }
     public synchronized void doSomething1() {
         System.out.println("doSomething1......");
         TimeUnit.SECONDS.sleep(10);
     }
     public synchronized void doSomething2() {
         System.out.println("doSomething2......");
     }
 }

synchronized修饰静态方法

 public  synchronized static void doSomething(){}

当synchronized修饰静态方法的时候,此时将会将当前类Class作为锁对象。

优缺点和修饰实例方法一样,因为使用都是一个公用锁,而这个公用锁还是Class,它的传播范围比实例对象更广(调用静态方法,可以在任意地方调用),因此它的锁粒度更加粗,更大的几率造成不必要阻塞。

synchronized修饰代码块显示指定锁

 private Object lock = new Object();
 synchronized (lock){
     doSomething...
 }

同步代码块中的lock对象将所谓锁对象

优点:

锁粒度可控,使用synchronized可以控制加锁范围,对不必要加锁的代码可以有效的控制,从而减少锁持有时间,减少其它线程的阻塞时间。

锁目标明确,可以明确的指定加锁目标,可以有效的利用不同的锁实现线程之间的协调,减少多线程对同一个锁的竞争,如ConcurrentHashMap在1.7的实现的Segment锁。

缺点:需要自己维护锁对象,造成一定的内存开销,增加了维护成本,因为是显示锁因此可以在多个synchronized调用,造成死锁的几率大于直接修饰方法。

synchronized锁对象

为什么对象可以作为锁

在Java中锁对象就类似于一个车位外面的门卫,当一个线程获取锁的时候,其实就是在门卫那儿登记当前车位被我占用了,其它线程来获取锁的时候,需要首先检查门卫那儿是否有登记的线程,如果有则说明此时这个车位正在被登记线程使用,其它线程只能等待,因此也说synchronized具备是独占锁。当持有锁线程释放的时候,会去门卫(锁)那儿消除自己登记的信息,从而让出锁,让其它线程能够获取。

锁对象实现原理

在Java synchronized中锁都是以对象的形式出现,要研究锁的持有判断,释放判断首先得确定判断依据,而这个判断依据就是作为锁的对象自带的对象头信息。

对象组成

对象组成:对象由:对象头(MarkWord-8字节,类元信息Klass-4字节),实例数据,对齐填充三部分组成。

  • 对象头-MarkWord(对象标记):MarkWord也叫对象标记,用于存储了对象在运行时候相关状态,如HashCode(无锁状态下),锁状态标志(对象做为锁的情况下),GC分代年龄等,在64位机器下该区域需要占用8字节,在32位机器下该区域需要占用4个字节。
  • 对象头-Klass(类元信息):和它的名字一样,它存储了当前对象基础元数据,包括当前对象类型(数组,实例),如果是数组的话还会存储数组长度。
  • 实例数据:简单来说实例数据,就是对象中声明存储的实例成员和可见的父类成员,例如在Integer中的实例数据主要就是具体的int value。
  • 对齐填充:在JVM规定,每个对象的内存空间大小必须是8的倍数byte,如果对象上面三部分未形成8的倍数,则需要自动填充到8的倍数。如果一个占用大小为16个字节的对象,增加一个成员变量byte类型;此时需要用17字节,但是结果最终会分配24个字节用于存储该对象,这多余的空间就会用对象填充存储。这样做的目的是对齐内存,提高存储效率和寻址速度,是一种空间换时间的做法。

源码:github.com/openjdk/jdk… 里面有对对象头的解释

image-20220514093758494-16524922802301.png

简单对32位机器对象头关于锁的部分整合了一下,64位也只是多了一些没有使用字节和增大了数据存储空间: image-20220511194401978-16522694451051.png

synchronized如何借助对象如何实现加锁释放锁的?

从上面分析对象组成,我们发现synchronized修饰锁对象的时候,有4种锁分别是无锁、偏向锁、轻量级锁、重量级锁。这是从JDK 1.6以后才引入的锁升级策略,在1.6以前synchronized只有重量级锁,这也是当初Java一直受其它语言选手说性能差的诟病之一。

锁对象就是一个监视器,记录了获取锁的线程信息和等待线程信息,在其它线程要获取锁的时候,会首先检查该锁对象是否已经被其它线程持有了。而这个检查和唤醒就是synchronized关键字的底层实现。

synchronized锁升级

什么是锁升级?

JDK在1.6的时候,针对synchronized实现功能引入了锁升级。在1.6之前,synchronized实现只是单一的独占锁,在引入锁升级后synchronized在不改变其功能特性的基础上有多种实现从而提高了性能。锁升级就是一种JVM提供的根据线程竞争强度而选择不同的锁从而实现synchronized功能的一种机制。

为什么要锁升级?

引入锁升级的目的是为了在线程竞争不是那么强烈的时候不使用重量级锁实现,使用其它手段实现synchronized功能,减少上下文切换,提高性能。

什么是上下文切换,为什么上下文会降低性能?

在操作系统中CPU的执行单位是以时间片执行的,当执行完一个时间片后CPU就会变为就绪状态,然后等待下个时间片来执行。

在多核CPU的工作模式下,一个CPU同一时刻只能被一个线程占用,会将一个线程的任务拆分成多个时间片,然后执行任务的时候采取转轮方式,来执行任务,当某个CPU空闲的时候就可以执行该线程的一个时间片任务。

以上是CPU执行任务的原理,那么上下文切换是什么意思呢?

简单来说上下文切换就是CPU执行当前任务还没有执行完成,立马要切换到另外一个任务去执行,等执行完后再回来执行没有执行完的任务,因为要保存未执行完任务的资源如:虚拟内存、栈、局部变量、计数器等信息以便于在回来执行任务的时候能够知道上次未完成任务的状态。这一来一回的切换俗称上下文切换,也就内核切换。

为什么synchronized会造成大量上下文切换?

想象一个场景,8核CPU,现在10个线程执行以下代码:

 synchronized (lock){
 doSomething...
 }

当第一个线程抢占到了锁之后,其它9个线程此时就该阻塞,等待第一个线程释放锁,然后参与锁竞争。

在操作系统层面,假如8个CPU执行这10个线程中的8个线程任务,因为第一个线程占用了锁,其它9个线程都在等待,在CPU层面难道第一个CPU执行任务,其它7个CPU就不干活了吗?不可能的事,这时候其它7个CPU就会保存这个阻塞任务状态后,转而去执行其它任务了,然后当第一个线程释放了锁,其它9个线程又会抢占时间片,最后又只有一个线程能够获取锁,导致其它CPU又保存状态,又去执行其它任务,再回来参与竞争执行。

发现在多线程的情况下,因为是独占的,大多数情况下都是无法顺利执行到任务的,就会频繁的保存状态,切换到其它任务,再重新切换回来,造成大量的上下文切换,从而严重的影响性能。

优化上下文切换的方式:

  • 降低锁的粒度,减少持有锁的时间,一个线程持有锁时间少了,造成其它线程等待的几率也就变小了,从而就降低了上下文切换的概率。
  • 消除锁控制,如多路复用技术,用轮训的方式始终让一个线程执行任务,这样能够有效的利用CPU。
  • 锁分离技术,锁分离技术也是和第一个降低锁粒度一样的道理,将原来的一个锁完成的任务,用多个锁完成,将一个大任务拆分成小任务,从而减少锁持有时间,因为拆分了锁,线程对锁的竞争也进行了分流,原来十个线程竞争一把锁,线程十个线程竞争两把锁,竞争减小了,上下文切换次数也就少了。

锁升级后是否还能降级?

synchronized在锁升级到重量级锁之后,如果锁还在使用是无法进行降级的,会一直保持重量级锁,但是当没有线程竞争锁后,锁就会变为无锁状态,此时再获取锁会直接使用轻量级锁,去除了偏向锁,这也就是为什么会有个无锁状态的原因。

ps:因为锁已经升级为重量级锁后,说明该处肯定是发生了强烈的竞争,那么下次还是会大概率发生竞争,因此没有必要再引入偏向锁了,但是因为没有竞争还加锁又会导致不必要的性能消耗,因此引入了无锁机制。

实验参考:blog.csdn.net/salerzhang/…

baijiahao.baidu.com/s?id=169345…

锁升级大致流程和原理

锁升级大致流程

image-20220516195059639-16527018612131.png

锁升级大致流程如上图所示,当对象创建的时候,可能会是无锁状态或者偏向锁状态,这取决于对象创建的时候是否开启了偏向锁,在JVM中有个默认配置,会在4秒后启动偏向锁;注意无锁是不会升级到偏向锁的,其实按我的理解无锁和偏向锁都是一个概念,都可以理解为偏向锁,只是无锁=不可偏向的偏向锁,偏向锁=可偏向的偏向锁。在无锁状态下有线程获取锁,会直接升级到轻量级锁,偏向锁要升级到轻量级锁也是要先撤销到无锁状态才会升级到轻量级锁;当使用轻量级锁都无法竞争到锁的时候,就会自然而然的升级到重量级锁。

几种锁介绍和对比

偏向锁和无锁

偏向锁是一种只有存在竞争的情况下才会释放锁的非常轻量级的锁,它能够保证在只有一个线程获取锁的情况下,能够以非常少的代价实现获取锁。适用于只有一个线程进入同步代码块。ps:这里值得注意的是,如果是线程串行获取偏向锁,那么还得判断上一次获取偏向锁的线程是否已经终止了,如果没有终止则当前获取锁会升级成轻量级锁获取锁

无锁是一种已经出现了竞争后或者该类不适合作为偏向锁的情况下的一种锁,它不具备锁的功能,只是一个状态,只要存在线程获取该锁会立马进入轻量级锁的逻辑竞争锁。

为什么会有无锁这个状态?

这个在上面锁降级处就有相关说明,这个状态两层意思:

1、没有开启偏向锁,那么需要获取该锁对象则直接进入轻量级锁竞争逻辑;

2、该对象锁发生过多线程竞争,下次还大概率会产生竞争,这时候已经不适合再使用偏向锁了。

偏向锁实现原理

偏向锁获取:

在查看对象头可以看到:

 JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)

当锁对象处于偏向锁的状态的时候,会存储一个持有偏向锁的线程ID,当线程第一次获取锁的时候,会判断偏向锁状态是否是1,如果是1说明是可偏向状态,并判断线程ID是否为空,如果为空当前偏向锁的处于一个匿名偏向状态,将当前线程ID使用CAS设置进锁对象头中;当这个线程下次再获取该偏向锁的时候,只需要简单比对一下当前对象头中的线程ID是否获取锁操作的线程即可,如果是则获取锁成功。

偏向锁撤销:

偏向锁是不会由持有线程主动撤销的,当一个线程获取偏向锁后并释放后,偏向锁对象中仍然保存了上一次获取偏向锁的线程ID。

新线程要获取已经释放了的偏向锁,首先需要判断偏向锁对象中保存的线程是否还存活,如果已经终止了直接使用CAS替换成当前线程ID,获取锁成功;如果还存活,则等待全局安全点撤销偏向锁变为无锁状态并升级为轻量级锁参与竞争锁;在线程存活的情况下还有一种重偏向机制,能够让线程在对偏向锁在撤销到了一定次数的时候,重新获取到偏向锁,这种情况比较特殊,需要针对一个类创建的锁对象并频繁撤销这些锁对象。

以上的全局安全点问题也是偏向锁不能滥用的一个原因,全局安全点在垃圾回收的地方也会出现相关运用,简单来说就是JVM在特定位置插入的一个标记,如果线程执行到这里此处安全点正在被其它线程执行,则需要等待,是不是感觉又有点像在竞争锁阻塞的问题了。这也就说明了偏向锁的大量撤销会造成一定的阻塞,影响性能。

上面还说到了一个重偏向机制,重偏向主要是在一个类生成的多个偏向锁在已释放偏向锁线程未终止的情况下,另外一个线程频繁的对这些偏向锁对象执行撤销操作,达到一定阈值,默认是20会重新回到偏向锁逻辑逻辑,不再执行撤销逻辑,毕竟撤销要等待safepoint。

大致加锁流程如下:

image-20220521161326565.png

ps:后面会单独分析偏向锁

轻量级锁

轻量级锁适用于多线程交替抢占锁持有锁时间很短的场景,或者说竞争非常小的情况。轻量级锁相较于偏向锁支持多线程获取锁,相较于重量级锁获取锁的成本更低,性能也更好。

轻量级锁实现原理之所以支持多线程是因为在获取锁的时候新增了CAS+自旋逻辑,线程要获取轻量级锁的时候,首先会将锁对象的markwod拷贝到线程栈帧的锁记录(Lock Record)中,然后通过CAS将锁对象的轻量锁引用指向当前锁记录。

上面提到了原理是CAS+自旋逻辑,自旋就是反复设置持有线程信息,这个持有线程信息是什么呢?这个数据对比偏向锁又一定差异,偏向锁存储了线程ID,而轻量级锁加锁成功存储的是一个指向获取锁线程栈帧中的锁记录(Lock Record)该记录会存储锁对象的markword,在释放轻量级锁的时候,会通过CAS将这些数据重新设置进锁对象头中,如果释放锁CAS失败,说明持有锁期间被其它线程竞争了,有线程等待,需要释放锁并唤醒其它线程。

ps:自旋次数在1.6是可以设置的,但是1.7以后改成了自适应自旋,再设置会报错

image-20220528110200388.png

重量级锁

适用场景,竞争大,多个线程同时竞争锁对象。

重量级锁是利用底层操作系统的mutex lock和底层API实现的,因为mutex lock会让线程休眠,导致了上下文切换,性能开销比较大,因此叫做重量级锁;依赖于监视器ObjectMonitor。

重量级锁有个ObjectMonitor指针,指向了一个ObjectMonitor,该对象有一些重要属性。

 ObjectMonitor() {
  _owner        = NULL;// 当前锁的持有者线程
  _count        = 0; // 线程获取锁的重入次数
  _WaitSet      = NULL;// 对锁对象调用了wait方法被阻塞的线程存放的set集合
  _EntryList    = NULL ;// 当其它线程持有锁时,线程获取锁阻塞存放的集合
 }

看到ObjectMonitor的相关属性应该能够很容易推断出,synchronized和锁对象的工作流程了。

加锁流程:

image-20220508112830850-16519805128251.png

ps:从上面流程图,调用wait会将线程加入到_WaitSet集合中,这也是为什么调用wait和notify的时候必须要先获取锁的原因,如果不获取锁对象,调用wait的线程就不知道存哪儿。

释放锁流程:

image-20220508113816016.png

synchronized用对象作为锁目标,使用monitorenter和monitorexit指令控制锁的自动获取和释放,降低了维护锁的成本。通过控制Java对象的mark指向的ObjectMonitor对象的属性来实现了锁持有者,锁重入,锁阻塞等待等判断,从而实现了线程的获取和释放,唤醒等操作。

总结

偏向锁适用于只有一个线程访问同步代码块,在无竞争情况下适用,但是发生了竞争会导致撤销偏向锁,造成等待全局安全点,因此偏向锁一般情况下升级后不会再使用。

无锁出现在竞争之后或者没有开启偏向锁的情况下,该状态是偏向锁的反面对应了不可偏向,一旦有线程获取无锁状态的锁对象,会进入获取轻量级锁的逻辑,这个状态主要就是用来区分偏向锁的,因此也可以说它和偏向锁是同一种。

轻量级锁使用竞争小,持锁时间短的情况下使用,采用CAS+自旋竞争锁,因为要主动撤销锁,因此采用拷贝锁对象markword的方式,在释放的时候判断是否已经升级了锁。

重量级锁借助了监视器ObjectMonitor对象,来维护持有锁和获取锁信息,功能更加强大,但也因为在竞争锁失败的时候要阻塞,需要用到操作系统底层指令,导致上下文切换,因此性能比上面的锁低,上面的锁都是JVM层面操作完成。