synchronized的锁升级过程(java中的锁)


前言

Java中的如果想实现线程安全,就不得不提一个关键字 synchronized 。今天,我们的主角就是它,先总体介绍一遍。jdk1.5之前,它确实是重量级锁,之后,它便没这么“重”了。synchronized的锁有4种状态:无锁状态、偏向锁状态、自旋锁(轻量级锁状态)、重量级锁状态。锁可以升级,但不能降级,但是偏向锁可以被重置成无锁状态。
本人水平有限,如有误导,欢迎斧正,一起学习,共同进步!


一、基础知识

介绍一下相关的概念,方便读者下面的理解。在java中,线程间的上下文切换,需要映射到内核态中的线程去操作,会消耗资源(下面会详细介绍什么是内核态用户态)。每个对象都有两个池,锁池和等待池。(wait、notify、notifyAll都是Object的方法)。

1、内核态&用户态

现在的操作系统都是分层制的,分为:内核(kernel)、应用程序(app)。app是基于内核之上的。就是说,对于硬件的操作权限,内核的大,应用程序的小。所以一些操作,app必须通过kernel才能访问到一些特定的资源。app运行的状态,称之为用户态。而kernel运行的状态称之为内核态。因为真正控制某个资源,是由内核态的线程来控制的。jvm是工作在用户态的,操作系统kernel是内核态。目前jvm的实现是,一个jvm的线程(用户态线程)1:1对应一个内核态线程。

用户态和内核态的线程对比可以是:
可以是 1:1  jvm的实现。 
可以是 M:1	就是多个用户态的线程对应一个内核态的线程,比如说 fibber(单词可能有问题)之类的
可以是 M:N	就是多个用户态的线程对应多个内核态的线程,比如是 go 语言的协程。(比如说10个用户态对应2个内核态之类的)

现在的synchronized关键字,内部有一个锁升级的过程,偏向锁和轻量级锁都是在用户态下的

2、锁池

假设线程A已经拥有了某个对象的锁(对象,不是类),而其他线程想要调用这个对象的某个synchronized方法(或者synchronized代码块),由于这些线程在进入对象的synchronized方法之前必须先获得该对象的拥有权,但是该对象正在被线程A拥有,所有这些线程就进入了该对象的锁池中。(进入锁池,准备开始竞争这个对象的锁资源)

3、等待池

假设一个线程A调用了某个对象的wait方法,线程A就会释放该对象的锁(因为wait方法必须出现在synchronized中,这样自然在执行wait()之前线程就已经拥有了该对象的锁),同时线程A就进入到了该对象的等待池中。如果另外的一个线程调用了相同对象的notifyAll()方法,那么处于该对象的等待池中的线程就会全部进入到该对象的锁池中,准备争夺锁的拥有权。而如果另外的线程调用了相同对象的notify(),那么仅仅有一个处于该对象的等待池中的线程(随机)会进入该对象的锁池

4、对象头信息

对象的头有4部分: markword(标记字段)、Klass pointer(类型指针)、成员变量、填充字节等4个部分。加起来是64位,8的倍数。

二、偏向锁

1、为什么要引入偏向锁

因为经过hotspot的作者大量的研究发现,大多时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要的代价,为了降低获取锁的代价,才引入的偏向锁。引入偏向锁的目的:为了在无多线程竞争的情况下尽量的减少不必要的轻量级锁执行路线。上面提到了轻量级锁的加锁、解锁过程是需要依赖多次的cas原子指令的,那么偏向锁是如何来减少不必要的cas操作呢?

2、偏向锁的原理和升级的过程

当线程1访问代码块并获取锁对象的时候,会在java对象头和栈帧中记录偏向锁的threadId,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadId和java对象头的threadId是否一致,如果一致(还是线程1获取的锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadId),那么需要查看java对象头中记录的线程1是否存活,若没有存活,那么锁对象重新被置为无锁状态。其他线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻找到该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1不在使用该锁对象,那么将锁对象设置为无锁状态,重新偏向新的线程。

三、自旋锁(轻量级锁)

java线程切换上下文需要消耗大量资源:java线程是映射到操作系统原生线程之上的,如果要阻塞或是唤醒一个线程,就需要操作系统来介入,需要在用户态和内核态之间切换,这种切换会消耗大量的系统资源,因为用户态与核心态之间,都有各自专用的内存空间,专用的寄存器等等,用户切换至内核态需要传递许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。

1、为什么要引入轻量级锁?

轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景,因为阻塞线程需要cpu从用户态转到内核态。代价比较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候干脆就不要阻塞这个线程,让它自旋等待这个锁释放。因为jvm的线程是用户线程,也就是用户态,java的用户态和内核态的线程是1比1的。而从用户态切换到内核态,需要消耗很多资源,因为要记录当前的栈帧信息,变量值等等,而很多时候,用不了那么长时间,假设你切换上下文需要10秒,但是你等1秒他就执行完了,那就没必要来回切换了。

2、轻量级锁的原理和升级的过程

线程1在获取轻量级锁时会把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplaceMarkWord),然后使用cas把对象头的内容替换成线程1存储的锁记录(DisplaceMarkWord)的地址。
升级过程
如果在线程1复制对象头的同时(在线程1cas之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是线程2在cas的过程中,发现线程1已经把对象头换了,线程2的cas失败,那么线程2就会尝试自旋锁来等待线程1释放锁。自旋锁简单来说,就是让线程2在循环中不断的cas;但是如果自旋的时间太长也不行,因为自旋是要消耗cpu的,因此自旋的次数是有限制的,比如说10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3来竞争这个锁对象,那么这时候轻量级所就会膨胀为重量级锁,重量级锁直接把除了拥有锁的线程以外的全部线程都阻塞,防止cpu空转。这样就避免了用户态和内核态的切换的消耗但是线程自旋是需要消耗cpu的,说白了就是让cpu在做无用功,如果一直获取不到锁,那么也不能一直占用cpu做无用功,所以还需要设置一个自旋的最大等待时间,如果到达了最大等待时间后,占用资源的线程还没释放锁,那么等待竞争的线程就会停止自旋进入等待状态。

3、自旋锁

自旋锁的原理非常简单,如果持有锁的线程能在很短的时间内释放资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起的状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免了用户态和内核态的切换的消耗但是线程自旋是需要消耗cpu的,说白了就是让cpu在做无用功,如果一直获取不到锁,那么也不能一直占用cpu做无用功,所以还需要设置一个自旋的最大等待时间,如果到达了最大等待时间后,占用资源的线程还没释放锁,那么等待竞争的线程就会停止自旋进入等待状态

3.1、自旋锁的优点

自旋锁会尽可能的减少线程的阻塞,这对于锁竞争不激烈,且占用锁时间非常短的线程来说性能大幅提高,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作消耗,这些操作会导致线程发生两次上下文切换。但是对于锁竞争激烈,或者持有锁的线程需要长时间的占用锁进行操作,此时就不适用自旋锁了,因为自旋锁在获取锁资源之前,一直在占用cpu做无用功,同时有大量线程在竞争一个锁,会导致锁的时间很长,线程自旋消耗大于线程阻塞挂起操作的消耗,其他需要cpu的线程又不能获取到cpu,造成cpu的浪费。

3.2、自旋锁的阈值

怎么选择线程自旋的时间呢,在jdk5的时候,这个是写死的,在jdk1.6以后,引入了自适应自旋锁。自适应自旋锁意味着自旋时间不是固定的,而是由前一次再同一个锁上的自旋时间以及拥有锁的状态来决定,基本上认为一个线程的上下文切换时间是最佳的一个时间,同时jvm还针对当前cpu做出了较多的优化。怎么做呢?线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么这次自旋也很可能成功,那么他会允许自旋等待持续的次数更多。反之,如果某个锁,很少有能自旋成功的,那么以后竞争这个锁的时候自旋的次数会减少甚至不再自旋,以免浪费cpu资源。

3.3、自旋锁的开启

自旋锁在jdk1.4.2中引入,默认关闭,可以通过 -XX:+UseSpinning来开启;在JDK1.6中默认开启,同时自旋的默认此时为10,可以通过 -XX:PreBlockSpin 来调整。
jdk6中:

  • -XX:+UseSpinning //开启自旋
  • -XX:PreBlockSpin=10 //自旋次数

jdk7: 由jvm控制

4、锁粗话

锁粗话的概念:就是将多个连续的加锁、解锁操作连接在一起。扩展成一个范围更大的锁
什么是锁粗化:我们知道在使用同步锁的时候,需要在同步块的作用范围尽可能的小,仅在共享数据的范围内进行同步,这样做的目的是为了使同步的操作数量尽可能的小,如果存在锁竞争,那么等待锁的线程也能尽快的拿到锁。在大多数的情况下,上述的观点是正确的,但是如果一系列的连续加锁、解锁的过程,可能会导致不必要的性能损耗,所以引入了锁粗话的概念。

四、重量级锁

重量级锁是通过对象内部的monitor实现,其中monitor的本质是依赖于底层操作系统的 Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换。切换成功非常高。
jdk1.6之前:synchronized加锁的时候,会调用ObjectMonitor的enter方法,解锁的时候会调用exit方法。之所以被称为重量级锁,是因为java的线程是映射到原生的操作系统的线程之上的,如果要唤醒或者是要阻塞一个线程就需要操作系统的帮忙,这就要从用户态切换到内核态,因此状态的转换可能比用户代码执行的时间还要长

五、错误的加锁姿势

  1. synchronized (new Object()) 每次调用创建的是不同的锁,相当于无锁
  2. private Integer count;
    synchronized(count) ;
    Integer Boolean在实现上都使用了享元模式,即值再一定范围内,对象是同一个,所以看似是使用了不同的对象,其实使用的是同一个对象,会导致一个锁被多个地方使用。

六、总结

当关闭偏向锁功能,或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁。轻量级锁一般是通过cas操作来实现的。性能提升的依旧是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破了这个依据则除了互斥的开销
外,还有额外的cas操作,因此在多线程竞争的情况下,轻量级所比重量级锁还要慢

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值