结合JVM深入谈一谈Synchronized关键字的神奇之处

17 篇文章 0 订阅
8 篇文章 0 订阅

结合JVM深入谈一谈Synchronized关键字的神奇之处

今天就来聊一聊Synchronized这个关键字。一提到Synchronized这个关键字,可能你马上联想到同步、加锁、多线程并发等等这些词语,仿佛感觉Synchronized关键字出镜率很高,感觉哪里都有它的身影。其实,Synchronized在很早的JDK版本中就已经存在了,可以说是元老级别的存在,主要解决的是多线程之间同步的访问资源,被Synchronized关键字修饰的方法或者代码块在任意时刻只能有一个线程执行。

在早期的JDK版本中,Synchronized属于重量级锁,效率十分的低下。为什么呢?因为如果你看过JVM规范,你就会明白,Synchronized的实现同步功能,从JVM角度来讲,是通过**进入和退出Monitor对象来实现方法和同步代码块同步的!**监视器Monitor是依赖于底层操作系统的Mutex互斥锁来实现的,Java的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮助完成,而操作系统实现线程之间的切换需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本比较高。所以说早期的Synchronized锁比较“重”,效率不高。但是也许是官方也意识到了Synchronized锁太过笨重,在JDK 6版本,官方从JVM层面对Synchronized做了非常大的优化,引入了自旋锁、偏向锁、轻量级锁等技术来较少锁操作的开销,使得Synchronized的效率得到了很大的提升,所以现在很多在控制多线程并发方面使用了Synchronized同步关键字。

明白了Synchronized关键字是什么,有什么作用之后,我们就来看看它是怎么使用的。

首先我们要知道Synchronized关键字可以在哪些地方被使用,它主要有3种使用方式:

  • 用来修饰普通方法

    锁是当时实例对象,在进入这个同步方法之前,需要先获得当前对象实例的锁。

    synchronized void function() {...}
    
  • 用来修饰静态方法

    锁是当前类的Class对象,在进入这个静态同步方法之前,需要先获得这个类Class对象的锁

    synchronized static void function() {...}
    
  • 用来修饰同步代码块

    锁是Synchronized括号里面配置的对象,使用this表示锁是当前实例对象,也可以是别的对象,只要保证是唯一的就可以。在进入这个同步代码块之前,需要先获得配置的对象的对应的锁。

    synchronized (this) {...}
    

我们看出来,Synchronized关键字要么实现了方法同步,要么实现了代码块同步,接下来我们就从JVM的角度来看看到底是怎么实现同步的?底层原理是什么?

方法同步

方法级的同步作为方法调用和返回的一部分,无需字节码指令来控制,会被隐式的执行。因为使用了Synchronized关键字的同步方法,在运行时常量池中method_info结构存放着这个方法的相关信息, 在方法调用的时候,会检查方法的access_flags访问标志是否包含ACC_SYNCHRONIZED

  • 如果包含,执行线程将先持有同步锁,然后执行方法,最后在方法完成(无论是正常完成还是非正常完成)时会释放同步锁
  • 在方法执行期间,执行线程持有了同步锁,其它任何线程都无法再获得同一个锁
  • 如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的锁将在异常抛到同步方法之外时自动释放

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SDXVjRWX-1631360767686)(C:\Users\Jian\AppData\Roaming\Typora\typora-user-images\image-20210911100721941.png)]

代码块同步

代码块同步是使用monitorentermonitorexit指令实现的。**monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit指令时插入到方法结束的位置以及发生异常的位置,**JVM要保证每一monitorenter必须有对应的monitorexit指令与之配对。任何一个对象都有一个监视器与之相关联,用来判断对象是否被锁定,当监视器被持有时,对象处于锁定状态。

当一个线程进入同步代码时,它使用monitorenter指令请求进入,如果当前对象的监视器计数器为0,则它被允许进入,若监视器计数器为1,则还需判断持有当前监视器的线程是否为自己(可重入),如果是则可以进入代码块。否则这个线程必须等待,知道对象的监视器计数器为0,才会被允许进入同步代码块。

当线程退出代码块时,需要使用monitorexit声明退出,释放对象的监视器

指令monitorentermonitorexit在执行时,都需要在操作数栈顶压入对象,之后monitorentermonitorexit的锁定和释放都是针对这个对象的监视器进行的

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ftHnXhRD-1631360732898)(C:\Users\Jian\AppData\Roaming\Typora\typora-user-images\image-20210911101116301.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8tYNOuAL-1631360732900)(C:\Users\Jian\AppData\Roaming\Typora\typora-user-images\image-20210911101135923.png)]
二者存在不同,不过本质都是获取对象监视器Monitor来实现同步,你可能会问那这个Monitor对象监视器又是什么东西呢?在JVM中,这个Monitor是基于C++中的ObjectMonitor这个类实现的,每一个对象中都内置了一个ObjectMonitor对象。除此之外wait()/notify()等方法也依赖于这个Monitor对象,这也就是为什么只有在同步代码块或者同步方法中才能调用wait()/notify()等方法。

我们之前提到过由于早期的Synchronized锁太过笨重,在JDK6 之后官方对Synchronized做了很多优化,使得效率得到了很大提升。接下来我们就从JVM底层来看看都做了哪些优化。

对象头

首先我们要明确Synchronized使用的锁的相关信息是存放在Java对象头里面的,确切的说是存放在对象头的运行时元数据(MarkWord)里面的,MarkWord里面存储这对象自身运行时的数据,有hashcode值、GC分代年龄、锁状态、线程目前持有的锁、偏向锁线程ID等等。

优化后的Synchronized锁的分类和锁升级

为了解决加锁和释放锁时效率低的问题,引入了偏向性、轻量级锁。所以说锁主要存在4种状态

从低到高:

​ 无锁状态==》偏向锁状态==》轻量级锁状态==》重量级锁状态

随着线程竞争获取锁激烈程度逐渐升级,但是不能降级

  • 偏向锁

    初次执行到synchronized代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),如果是则正常往下执行。**由于之前没有释放锁,这里也就不需要重新加锁。**如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。

    偏向锁使用了一种等待竞争出现才释放锁的机制,所以当其它线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销需要等待全局安全点,即在这个时间点上没有正在执行的字节码

  • 轻量级锁

    一旦有第二个线程加入锁竞争偏向锁就会升级为轻量级锁,也叫自旋锁。这里要明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。

    线程在执行同步代码块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中。在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。

    长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。

  • 重量级锁

    轻量级锁解锁时,会使用原子的CAS操作将指向锁记录的指针替换回到对象头。如果成功,则表示没有竞争发生,如果失败,表示当前锁存在竞争,轻量级锁会膨胀成重量级锁。

    当其它线程在竞争锁时,发现MarkWord中的锁标记已经改成重量级锁了,就明白现在锁竞争很激烈,就没必要CAS自旋,白白浪费CPU了。就不会再进行自旋而是阻塞直接挂起。

Synchronized同步锁从早期的效率低,到后来的优化,可以说是元老级别的“锁”了。其实Java中还存在另一把“锁”与之相媲美,那就是ReentrantLock可重入锁。接下来我们就来谈一谈ReentrantLock可重入锁以及它们二者之间的区别。

之前我们说的Synchronized是依赖于JVM实现的,是JVM层面的锁,并且它的优化也是从JVM底层进行优化的。而ReentrantLock可重入锁是API层面的,ReentrantLock这个类实现了Lock这个接口,需要我们使用lock()和unlock()方法手动的进行加锁和释放锁。

Synchronized锁和ReentrantLock都是可重入锁,“可重入锁” 指的是同一线程可以多次同一把锁。但是相比synchronized,ReentrantLock增加了一些其他高级功能

  • 等待可中断

    ReentrantLock提供了一种能够中断等待锁的线程机制,调用lock.lockInterruptibly()方法可以让正在等待的线程放弃等待,去做其他事情。

  • 可实现公平锁

    我们要知道,Synchronized锁是非公平锁,也就是说一个线程抢到了锁,在释放锁之后,如果需要的话它还可以继续争抢锁,如果一个线程恰好每次都获取锁成功,那么就会导致线程迟迟得不到执行。而ReentrantLock可以实现公平锁,所谓公平锁,就是说不可能让一个线程一直抢锁成功,而是先等待的线程先获得锁,实现公平轮流加锁。ReentrantLock默认使用的是非公平锁,但是在构造方法中可以指定是否使用公平锁。实际上公平锁是比非公平锁多维护了一个队列,先来后到,每次都是从队列中拿出线程去获取锁,不像非公平那样,每次谁抢到算谁的。

  • 可实现选择性通知

    Synchronized锁有对应的wait()、notify()方法实现线程之间的等待通知机制,同样的,ReentrantLock也应该有一套对应的等待通知机制,await()和signal()方法。这一对方法依赖于Condition接口和newCondition()方法,Condition对象是由Lock对象调用newCondition()方法创建出来的,它有很好的灵活性,在一个Lock对象中可以创建多个Condition对象,线程对象可以注册在指定的Condition中,从而可以有选择性的通知指定的线程。而Synchronized关键字的notify()、notifyAll()方法通知线程时,被通知的线程是由JVM选择的,要么通知一个,要么全部通知,不能像ReentrantLock那样可选择的通知一部分线程。

不知你有没有注意到,Synchronized可以修改方法,也可以修饰代码块,那变量呢?谁来修饰?实际上还有一个关键字volatile,相对于Synchronized这个重量级锁,volatile是JVM提供的轻量级同步机制

至此~ 我们从JVM角度深入了解了一下Synchronized的使用以及它的底层原理,同时还对比了Synchronized于ReentrantLock以及volatile之间的区别。ReentrantLock和volatile深入解析留给后续的文章。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
1. synchronized关键字在使用层面的理解 synchronized关键字Java中用来实现线程同步的关键字,可以修饰方法和代码块。当线程访问被synchronized修饰的方法或代码块时,需要获取对象的锁,如果该锁已被其他线程获取,则该线程会进入阻塞状态,直到获取到锁为止。synchronized关键字可以保证同一时刻只有一个线程能够访问被锁定的方法或代码块,从而避免了多线程并发访问时的数据竞争和一致性问题。 2. synchronized关键字在字节码中的体现 在Java代码编译成字节码后,synchronized关键字会被编译成monitorenter和monitorexit指令来实现。monitorenter指令对应获取锁操作,monitorexit指令对应释放锁操作。 3. synchronized关键字JVM中的实现 在JVM中,每个对象都有一个监视器(monitor),用来实现对象锁。当一个线程获取对象锁后,就进入了对象的监视器中,其他线程只能等待该线程释放锁后再去竞争锁。 synchronized关键字的实现涉及到对象头中的标志位,包括锁标志位和重量级锁标志位等。当一个线程获取锁后,锁标志位被设置为1,其他线程再去获取锁时,会进入自旋等待或者阻塞等待状态,直到锁标志位被设置为0,即锁被释放后才能获取锁。 4. synchronized关键字在硬件方面的实现 在硬件层面,锁的实现需要通过CPU指令和总线锁来实现。当一个线程获取锁时,CPU会向总线发送一个锁请求信号,其他CPU收到该信号后会进入自旋等待状态,直到锁被释放后才能获取锁。总线锁可以保证多个CPU之间的原子操作,从而保证锁的正确性和一致性。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值