对并发熟悉吗?说说Synchronized及实现原理

Synchronized的基本使用

Synchronized是Java中解决并发问题的一种最常用的方法,也是最简单的一种方法。

Synchronized的作用主要有三个:

  • 确保线程互斥的访问同步代码
  • 保证共享变量的修改能够及时可见
  • 有效解决重排序问题

从语法上讲,Synchronized总共有三种用法:

  • 修饰普通方法(实例对象锁)
  • 修饰静态方法(类锁)
  • 修饰代码块(普通对象的锁)

Synchronized 原理

每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态。

使用synchronized修饰代码块进行并发处理就是通过monitorenter和monitorexit指令来完成的。

线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

  • 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
  • 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1。
  • 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

执行monitorexit的线程必须是objectref所对应的monitor的所有者。

  • 指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

使用Synchronized关键词修饰的方法,该种同步并没有通过指令monitorenter和monitorexit来完成(与使用代码块的不同,理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。

JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。

在方法执行期间,其他任何线程都无法再获得同一个monitor对象。其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。

Synchronized锁升级的过程和原理

对象的内存布局

Java中一切皆对象。

在Hotspot 虚拟机中,对象在内存中的存储布局,可以分为三个区域:对象头、实例数据、对齐填充(保证8个字节的倍数)。

对象头分为对象标记(markOop)和类元信息(klassOop)。类元信息存储的是指向该对象类元数据(klass)的首地址,4个字节。

对象标记(markOop)是我们重点要介绍的,它存储对象本身运行时的数据,如哈希码、GC标记、锁信息、线程关联等(64位JVM占8个字节,32位JVM占4个字节),称为"Mark Word",存储格式非规定与具体JVM实现有关。

32位存储格式:

64位存储格式:

synchronized 锁升级

Java SE 1.6 对synchronized 进行了各种优化之后,引入了偏向锁和轻量级锁,减少了获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁。

在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

偏向锁

大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,所以为了让线程获得锁的代价更低而引入了偏向锁。

当一个线程访问加了同步锁的代码块时,会在对象头中存储当前线程的 ID,后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的线程ID。如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了。

偏向锁的获取

① 获取锁对象头中的对象标记,判断当前对象是否处于可偏向状态(即当前没有对象获得偏向锁)。

② 如果是可偏向状态(状态码:0),则通过CAS原子操作,把当前线程的ID写入到对象标记,如果CAS成功,表示获得偏向锁成功,会将偏向锁标记设置为1,且将当前线程的ID写入对爱那个标记;如果CAS失败则说明当前有其他线程获得了偏向锁,同时也说明当前环境存在锁竞争,这时候就需要将已获得偏向锁的线程中的偏向锁撤销掉,并升级为轻量级锁(偏向锁的撤销,需要等待全局安全点,即在这个时间点上没有正在执行的字节码)。

③ 如果当前线程是已偏向状态(状态码:1),需要检查对象头中的ThreadID是否和自己相等,如果相等则不需要再次获得锁,可以直接执行同步代码块,如果不相等,说明当前偏向的是其他线程,需要撤销偏向锁并升级到轻量级锁。

偏向锁的撤销

偏向锁的撤销并不是把对象恢复到无锁可偏向状态(偏向锁并不存在锁释放的概念),而是在获取偏向锁的过程中,发现CAS失败也就是存在线程竞争时,直接把被偏向的锁对象升级到被加了轻量级锁的状态。对原持有偏向锁的线程进行撤销时,原获得偏向锁的线程有两种情况:

  • 原获得偏向锁的线程如果已经退出了临界区,也就是同步代码块执行完了,那么这个时候会把对象头设置成无锁状态,同时正在争抢锁的线程可以基于 CAS 重新偏向当前线程。
  • 如果原获得偏向锁的线程的同步代码块还没执行完,处于临界区之内,这个时候会把原获得偏向锁的线程升级为轻量级锁后继续执行同步代码块。

偏向锁的注意事项

偏向锁在Java SE 1.6和Java SE 1.7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下都处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:- UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
如果我们的应用中大多数情况存在线程竞争,那么建议是关闭偏向锁,因为开启反而会因为偏向锁撤销操作而引起更多的资源消耗。

轻量级锁

轻量级锁,一般用于两个线程在交替使用锁的时候,由于没有同时抢锁,属于一种比较和谐的状态,就可以使用轻量级锁。

轻量级锁加锁

线程在执行同步代码块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用 CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

轻量级锁解锁

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

自旋锁

轻量级锁在加锁过程中,用到了自旋锁。所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。

为什么要采用自旋等待呢?

因为绝大多数情况下线程获得锁和释放锁的过程都是非常短暂的,自旋一定次数之后极有可能碰到获得锁的线程释放锁,所以,轻量级锁适用于那些同步代码块执行很快的场景,这样,线程原地等待很短的时间就能够获得锁了。

注意:锁在原地循环等待的时候,是会消耗CPU资源的。所以自旋必须要有一定的条件控制,否则如果一个线程执行同步代码块的时间很长,那么等待锁的线程会不断的循环反而会消耗CPU资源。默认情况下锁自旋的次数是 10 次,可以使用-XX:PreBlockSpin参数来设置自旋锁等待的次数。

自适应自旋

在 JDK1.7 开始,引入了自适应自旋锁,修改自旋锁次数的JVM参数被取消,虚拟机不再支持由用户配置自旋锁次数,而是由虚拟机自动调整

自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

重量级锁

当轻量级锁膨胀到重量级锁之后,意味着线程只能被挂起阻塞来等待唤醒了。每一个对象中都有一个Monitor监视器,而Monitor依赖操作系统的 MutexLock(互斥锁)来实现的, 线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能。

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。而且当一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。我们可以简单的理解为,在加重量级锁的时候会执行monitorenter指令,解锁时会执行monitorexit指令。

锁的优缺点对比

preview

总结

Synchronized是Java并发编程中最常用的用于保证线程安全的方式,其使用相对也比较简单。但是如果能够深入了解其原理,对监视器锁等底层知识有所了解,一方面可以帮助我们正确的使用Synchronized关键字,另一方面也能够帮助我们更好的理解并发编程机制,有助我们在不同的情况下选择更优的并发策略来完成任务。对平时遇到的各种并发问题,也能够从容的应对。

synchronized可以解决并发编程中的三大问题:原子性,可见性和有序性,虽然JDK对其做了优化,有些时候并不那么重了,但是在某些场景中,我们可以使用volatile关键字代替synchronized,如果volatile变量修饰符使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。

 

参考:

 

posted @ 2021-12-04 13:10  残城碎梦  阅读(48)  评论(0编辑  收藏  举报