synchronized的实现原理

锁的内存语义

线程释获取锁时

JMM会把对应的本地内存置为无效,从而使得被监视器保护的临界区域代码必须从主内存中读取共享变量。

也就是说锁内的临界区变量,是绝对不会走CPU缓存的,都是从主内存中重新获取的"新鲜"数据。

线程释放锁时

JMM会把该线程对应的本地变量内存中的共享变量刷新到主内存中。(但这里不能保证刷新到主内存的数据对其他线程的可见性)

案例解释内存语义

加voliate解决可见性

来看下面这个例子:

图片1.png

我们为了让子线程可以及时看到ready变量的修改,我们将read以volatile来修饰,这样就可以成功跳出无限循环。

加打印语句解决可见性

当我们将程序做如下改造

我们发现,当我们在while循环中加了一个打印语句,依然可以跳出我们的无限循环。这是巧合么?

图片2.png

可以看到线程同样可以终止,为何?我们观察System.out.println的实现

图片3.png

在打印语句中,我们加了锁的关键字。结合我们之前说的锁的内存语句,我们知道在加入锁的时候,整个线程的本地内存置为无效。也就是说ready此时也需要从主内存中再次进行获取,因此也保证了ready的可见性。

synchronized的实现原理

同步代码块的MonitorEnter和MonitorExit

Synchronized在JVM里的实现都是基于进入和退出Monitor对象来实现方法同步和代码块同步。虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。

对同步块,MonitoEnter指令插入在同步代码块的开始位置,当代码块执行到该指令时,将会尝试获取该对象Monitor的所有权,即尝试获得该对象的锁。而MonitorExit指令则插入在方法结束处和异常处。JVM保证每个MonitorEnter必须存在对应的MonitorExit。

同步方法的ACC_SYNCHRONIZED标识符

对同步方法,从同步方法反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来实现。相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标识符。

JVM就是根据该标识符来实现方法同步的。当方法被调用时,调用指令将会检查方法的ACC_SYBCHRONIZED访问标志是否被设置。如果被设置了,执行线程将先获取monitor,获取成功之后才能执行方法体。方法执行完毕后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。

对象头中的锁状态

synchronized使用的锁是放在Java的对象头里的。

图片4.png

具体位置是对象头中的MarkWord,MarkWord里面数据是存储对象HashCode等信息。

图片5.png

但是随着对象的运行改变而发生变化,不同的锁状态对应着不同的记录存储方式。

图片6.png

什么是自旋锁

原理

自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放资源,那么这些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态。他们只需要等一等(自旋),等待有锁的线程释放完锁后即可立即获取锁,这样就必避免了用户线程和内核的切换的消耗。

但是线程自旋式需要消耗CPU的,说白了就是让CPU做无用功,线程能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间。

如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其他争用锁的线程在做大等待时间内还是获取不到锁。这时争用线程会停止自旋进入阻塞状态。

自旋锁的优缺点

自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁的时间非常短的代码块来说,性能提升是巨大的。因为自旋锁的消耗会小于线程阻塞挂起操作的消耗。

但是如果锁的竞争激烈(自旋一直不会成功,反而会占用大量CPU资源),或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合用自旋锁了。因为自旋锁在获取锁之前一直都占用CPU做无用功。线程自旋的消耗大于线程阻塞挂起操作的消耗。其它需要CPU的线程又不能获取到CPU,造成CPU的浪费。

自旋锁的时间阈值

自旋锁的目的是为了占着CPU的资源不释放,等到获取到锁立即进行处理。但是如何选择自旋锁执行时间的呢?如果自旋时间太长,会有大量的线程处于自旋状态占用CPU资源,进而会影响整体系统的性能。因此自旋次数很重要。

JVM对于自旋锁次数的选择,JDK1.5默认为10次,在1.6之后引入了适应性自旋锁。适应性自旋锁意味着自旋时间不再是固定的了,而是由前一次在同一个锁的自旋时间以及锁的拥有者的状态来决定,基本上是一个线程上下文切换的最佳时间。

synchronized锁的优化

在jdk1.5之前,synchronized是一个非常笨重的锁。要知道锁的取得(假如只考虑重量级MutexLock)是需要操作系统调用的,从用户态进入内核态,开销很大。相当于进行一次耗时严重的上下文切换。因此我们的JDK开发人员,为了避免直接加上一把重量级锁,而做出了相当多的优化。一起来看看把。

锁粗化

原则上为了提高运行效率,锁的范围应该尽量小,减少同步的代码,但是这不是绝对的原则,试想有一个循环,循环里面是一些敏感操作,有的人就在循环里面写上了synchronized关键字。synchronized是可重入锁确实没错不过效率也许会很低,因为其频繁地拿锁释放锁。这是非常消耗资源的。于是针对这种情况也许虚拟机发现了之后会适当扩大加锁的范围(所以叫锁粗化)以避免频繁的拿锁释放锁的过程。

比如像这样的代码:

synchronized{
做一些事情
}
synchronized{
做另外一些事情
}

就会被粗化成:

synchronized{
做一些事情
做另外一些事情
}

锁消除

通过逃逸分析发现其实根本就没有别的线程产生竞争的可能(别的线程没有临界量的引用),或者同步块内进行的是原子操作,而“自作多情”地给自己加上了锁。有可能虚拟机会直接去掉这个锁。

锁膨胀

在我们的JDK1.5之前,我们只要加了synchronized关键字,你就是一个重量级的大胖子锁。JDK开发人员尝试,让锁不要一开始就是重量级锁,而是将锁划分成为了多个状态,由一个瘦子慢慢变成胖子。

 

锁膨胀的过程

一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,目的是为了提高获得锁和释放锁的效率。

偏向锁

偏向的背景

引入背景:大多数情况下,锁不仅不存在多线程竞争,而且总是由一个线程多次获得。为了让线程获得锁的代价更低而引入了偏向锁,减少不必要的CAS操作。

偏向锁,顾名思义,它会偏向于第一个访问锁的线程。如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的。这种情况下就会给线程加一个偏向锁。如果运行过程中,遇到其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它,并将当前锁对象进阶为轻量级锁。

偏向锁的获取过程

  1. 访问Mark Word中偏向锁的标识是否设置成01(偏向锁的标志位为01),确认为可偏向状态。
  2. 如果是可偏向状态,则测试线程ID是否指向当前线程。如果是,则执行同步代码。

偏向锁的撤销

接着上面的流程,如果线程ID并未指向当前线程,则通过CAS操作竞争锁,如果竞争成功,则将线程锁对象头的MarkWord中的线程ID设置为当前线程ID。然后执行同步代码。若竞争失败,则表示当前锁已经被其他线程所持有。那么当持有锁的线程到达了安全点时,会进行一次STOP THE WORD,此时会撤销锁的偏向锁状态,并将锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块。

偏向锁的使用流程图

图片7.png

偏向锁的适用场景

始终只有一个线程在执行同步块。在它没有执行完释放之前,没有其它线程去执行同步块(也就是没有竞争的情况下,降低对加了锁的单线程的资源消耗)。

一旦竞争开始就升级为轻量级锁。升级为轻量级锁的时候需要撤销偏向锁。撤销偏向锁会导致stop the word操作。

诶?就算STW,不也就一次的事儿么,但其实我们要多想想。当锁内的代码执行完毕后,我们下一个线程再次抢到锁的时候又会进行一次偏向锁升级为轻量级锁的过程。那么又会在撤销偏向锁时导致STW。频繁的进行这种操作就会导致性能严重损耗。因此,在JVM调优中,就可以根据我们的判断,在并发度较高的环境中禁用掉偏向锁状态。

轻量级锁

轻量级锁是由偏向锁升级来的。偏向锁运行在一个线程进入同步块的情况下。当第一个线程加入并争用锁的时候,偏向锁就会升级为轻量级锁。所谓的轻量级锁其实就是在两个锁之间发生竞争关系是。占有的线程继续执行,而竞争的锁不直接放弃CPU执行权,而是占着CPU做自旋操作。当积累到一定次数依旧无法抢到锁后升级为重量级锁即synchronized锁。

轻量级锁的加锁过程

  1. JVM会先在当前线程的栈帧中创建用于存储锁记录的空间(LockRecord)
  2. 将对象头中的Mark Word复制到锁记录中,称为Displaced Mark Word
  3. 线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针
  4. 如果替换成功,表示当前线程获得轻量级锁,如果失败,表示存在其他线程竞争锁,那么当前线程会尝试使用CAS来获取锁, 当自旋超过指定次数(可以自定义)时仍然无法获得锁,此时锁会膨胀升级为重量级锁

轻量级锁解锁

注意:此处的解锁并不是偏向锁的撤销。此时的解锁就是退出锁状态了。

当同步块中的代码执行完毕后,尝试CAS操作把当前栈帧的Displaced Mark Word替换回Mark Word中

如果成功,表示没有竞争发生。(如果有其他锁CAS抢锁,那么肯定不会替换成功。相比于其他线程无时无刻不停地CAS,本线程再次竞争一定是最慢的)

如果失败,表示当前锁存在竞争,锁会膨胀成重量级锁

重量级锁

这个锁就是已经重复无数遍了。重量级锁依赖对象内部的monitor锁来实现,而monitor又依赖操作系统的MutexLock(互斥锁)。

阻塞或者唤醒一个线程,都需要通过操作系统来实现,也就是相当于从用户态转化到内核态,而转化状态是需要消耗时间的,这个上下文切换的时间相对于CPU的执行来说是相当漫长的。

锁升级总结与比较

图片8.png

首先它们的关系是:最高效的是偏向锁,尽量使用偏向锁,如果不能(发生了竞争)就膨胀为轻量级锁,最后是重量级锁

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
`synchronized` 是 Java 中用于实现线程同步的关键字,它可以用来修饰代码块或方法。当某个线程执行一个被 `synchronized` 修饰的代码块或方法时,它会尝试获取对象的锁(monitor),如果锁没有被其他线程占用,则该线程会获取到锁,并继续执行代码块或方法;如果锁已经被其他线程占用,则该线程会被阻塞,直到获取到锁为止。 `synchronized` 的实现原理可以分为以下几个步骤: 1. 当一个线程尝试获取某个对象的锁时,它会先检查锁是否被其他线程占用。 2. 如果锁未被占用,则该线程会获取到锁,并继续执行代码块或方法。 3. 如果锁已经被其他线程占用,则该线程被阻塞,并被放入对象的等待队列中。 4. 当锁被释放时,等待队列中的线程会被唤醒,然后再次尝试获取锁。 5. 如果此时有多个线程都被唤醒,则它们会竞争获取锁,只有一个线程能够获取到锁,其他线程仍然被阻塞。 在 `synchronized` 中,锁是以对象为单位的。每个对象都有一个与之关联的锁,称为对象的监视器(monitor)。当一个线程进入某个对象的 `synchronized` 代码块或方法时,它会尝试获取该对象的监视器。如果监视器未被其他线程占用,则该线程获取到监视器并继续执行代码块或方法;如果监视器已经被其他线程占用,则该线程被阻塞,直到获取到监视器为止。当代码块或方法执行完毕后,该线程会释放监视器。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

大将黄猿

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值