可重入

可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁,这样的锁就叫做可重入锁。

与可重入锁相反,不可重入锁不可递归调用,递归调用就发生死锁。

隐式锁(即synchronized关键字使用的锁)默认是可重入锁,显式锁(即Lock)也有ReentrantLock这样的可重入锁。

可重入锁的工作原理很简单,就是用一个计数器来记录锁被获取的次数,获取锁一次计数器+1,释放锁一次计数器-1,当计数器为0时,表示锁可用。

  • synchronized解决脏读问题。对同一个变量的set/get方法,都要加synchronized关键字,这样,get和set才能顺序执行,从而保证读取到的都是最新值。
  • synchronized锁可重入,在一个synchronized方法/块的内部调用本类的其他synchronized方法/块时,是可以得到锁的;当存在父子类继承时,子类可以通过可重入锁调用父类的同步方法。
  • 出现异常,synchronized锁自动释放。
  • 同步不可继承。需要在子类继承的方法上加上synchronized关键字,才能让子类方法具有同步效果。
  • 耗时长的方法不适合用synchronized方法,可以改用synchronized方法块。
  • 同步方法块锁定的是当前对象。
  • synchronized(非this对象)的同步代码块和synchronized(this对象)的同步方法,可以异步执行。

synchronized的实现原理

monitor对象

monitor监视器源码是C++写的,在虚拟机的ObjectMonitor.hpp⽂件中。

  1. ObjectMonitor() {
  2. _header = NULL;
  3. _count = 0;
  4. _waiters = 0,
  5. _recursions = 0; // 线程重⼊次数
  6. _object = NULL; // 存储Monitor对象
  7. _owner = NULL; // 持有当前线程的owner
  8. _WaitSet = NULL; // wait状态的线程列表
  9. _WaitSetLock = 0 ;
  10. _Responsible = NULL ;
  11. _succ = NULL ;
  12. _cxq = NULL ; // 单向列表
  13. FreeNext = NULL ;
  14. _EntryList = NULL ; // 处于等待锁状态block状态的线程列表
  15. _SpinFreq = 0 ;
  16. _SpinClock = 0 ;
  17. OwnerIsThread = 0 ;
  18. _previous_owner_tid = 0;
  19. }

每个对象有一个监视器锁(monitor)。

无论是修饰方法的字节码指令ACC_SYNCHRONIZED还是修饰代码块的monitorenter/monitorexit,最终都是在操作monitor对象。

synchronized底层的源码就是引⼊了ObjectMonitor

加锁

monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程:

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

释放锁

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

通过这两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。 :::info synchronized是java提供的原子性内置锁,也被称为监视器锁。使用synchronized之后,会在编译之后在同步的代码块前后加上monitorentermonitorexit字节码指令,他依赖操作系统底层互斥锁实现。他的作用主要就是实现原子性操作和解决共享变量的内存可见性问题。

执行monitorenter指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器+1。此时其他竞争锁的线程则会进入等待队列中。

执行monitorexit指令时则会把计数器-1,当计数器值为0时,则锁释放,处于等待队列中的线程再继续竞争锁。 :::

如果再深入到源码来说,synchronized实际上有两个队列waitSet和entryList。

  • 当多个线程进入同步代码块时,首先进入entryList
  • 有一个线程获取到monitor锁后,就赋值给当前线程,并且计数器+1
  • 如果线程调用wait方法,将释放锁,当前线程置为null,计数器-1,同时进入waitSet等待被唤醒,调用notify或者notifyAll之后又会进入entryList竞争锁
  • 如果线程执行完毕,同样释放锁,计数器-1,当前线程置为null

synchronized是排它锁,当一个线程获得锁之后,其他线程必须等待该线程释放锁后才能获得锁,而且由于Java中的线程和操作系统原生线程是一一对应的,线程被阻塞或者唤醒时时会从用户态切换到内核态,这种转换非常消耗性能。

从内存语义来说,加锁的过程会清除工作内存中的共享变量,再从主内存读取,而释放锁的过程则是将工作内存中的共享变量写回主内存。

锁的优化

从JDK1.6版本之后,synchronized本身也在不断优化锁的机制,有些情况下他并不是一个很重量级的锁了。优化机制包括自适应锁、自旋锁、锁消除、锁粗化、轻量级锁和偏向锁。

锁的状态从低到高依次为无锁->偏向锁->轻量级锁->重量级锁,升级的过程就是从低到高,降级在一定条件也是有可能发生的。

自旋锁:由于大部分时候,锁被占用的时间很短,共享变量的锁定时间也很短,所有没有必要挂起线程,用户态和内核态的来回上下文切换严重影响性能。自旋的概念就是让线程执行一个忙循环,可以理解为就是啥也不干,防止从用户态转入内核态,自旋锁可以通过设置-XX:+UseSpining来开启,自旋的默认次数是10次,可以使用-XX:PreBlockSpin设置。

自适应锁:自适应锁就是自适应的自旋锁,自旋的时间不是固定时间,而是由前一次在同一个锁上的自旋时间和锁的持有者状态来决定。

锁消除:锁消除指的是JVM检测到一些同步的代码块,完全不存在数据竞争的场景,也就是不需要加锁,就会进行锁消除。

锁粗化:锁粗化指的是有很多操作都是对同一个对象进行加锁,就会把锁的同步范围扩展到整个操作序列之外。

偏向锁:当线程访问同步块获取锁时,会在对象头和栈帧中的锁记录里存储偏向锁的线程ID,之后这个线程再次进入同步块时都不需要CAS来加锁和解锁了,偏向锁会永远偏向第一个获得锁的线程,如果后续没有其他线程获得过这个锁,持有锁的线程就永远不需要进行同步,反之,当有其他线程竞争偏向锁时,持有偏向锁的线程就会释放偏向锁。可以用过设置-XX:+UseBiasedLocking开启偏向锁。

轻量级锁:JVM的对象的对象头中包含有一些锁的标志位,代码进入同步块的时候,JVM将会使用CAS方式来尝试获取锁,如果更新成功则会把对象头中的状态位标记为轻量级锁,如果更新失败,当前线程就尝试自旋来获得锁。

锁升级的过程

简单点说,偏向锁就是通过对象头的偏向线程ID来对比,甚至都不需要CAS了,而轻量级锁主要就是通过CAS修改对象头锁记录和自旋来实现,重量级锁则是除了拥有锁的线程其他全部阻塞。
image.png

参考文章

深入分析Synchronized原理

JAVA锁的膨胀过程和优化

一些题目