Fork me on GitHub

synchronized详解

一、synchronized的使用

1.synchronized的形式

Java中每一个对象都可以作为锁,具体表现为以下三种形式:

  1. 对于普通同步方法,锁是当前实例对象;
  2. 对于静态同步方法,锁是当前类的class对象;
  3. 对于同步方法块,锁是括号里面的对象。

2.synchronized使用的注意点

  • 锁对象不能为空(因为Synchronized的锁信息是记录在对象头中的)
  • 作用域不宜过大
  • 避免死锁
    例如如下两个代码块,就很容易造成死锁:
1
2
3
4
5
synchronized(lock1){
synchronized(lock2) {
...
}
}
1
2
3
4
5
synchronized(lock2){
synchronized(lock1) {
...
}
}

3.synchronized的缺陷

  • 效率低:锁只有在持有锁的线程完成任务或者出现异常时才会释放,其他试图获得锁的线程只能等待; 试图锁时也不能设定超时; 不能中断一个正在试图获得锁的线程
  • 不够灵活(读写锁更灵活)
  • 无法知道是否成功获得锁

4.synchronized和Lock的区别

  • synchronized依赖于JVM实现,lock依赖于JDK实现,相当于操作系统实现和用户自己敲代码实现的区别;synchronized 在成功完成功能或者抛出异常时,虚拟机会自动释放线程占有的锁;而Lock对象在发生异常时,如果没有主动调用unLock()方法去释放锁,则锁对象会一直持有,因此使用Lock时需要在finally块中释放锁;

  • Lock更加灵活:Lock接口锁可以通过多种方法来尝试获取锁包括立即返回是否成功的tryLock(),以及一直尝试获取的lock()方法和尝试等待指定时间长度获取的方法,相对灵活了许多比synchronized;

  • Lock更加高效:在读多,写少的高并发情况下,我们用ReentrantReadWriteLock分别获取读锁和写锁来提高系统的性能,因为读锁是共享锁,即可以同时有多个线程读取共享资源,而写锁则保证了对共享资源的修改只能是单线程的。

5.如何选择Lock和synchronized关键字

  • 当可以使用java.util.concurrent包下的工具完成任务时,就不要使用Lock和synchronized,避免造成出错;
  • 当synchronized可以满足业务需求时,优先使用synchronized
  • 当需要用到一些Lock才能实现的功能,如Condition、tryLock()等时,使用Lock。

二、synchronized的性质

1.可重入性

  • 一个锁能够能够支持一个线程对资源的重复加锁,就是可重入锁。
  • 可重入性的好处:避免死锁、提高封装性。

2.不可中断性

一旦这个锁被一个线程获得了,其他线程只能等待或者阻塞,知道持有锁的线程执行完任务或者出现异常释放锁。


三、synchronized的实现原理

1.synchronized同步的实现原理

  • 从JVM规范中可以看到Synchonized在JVM里的实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorenter和monitorexit指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明。但是,方法的同步同样可以使用这两个指令来实现。
  • monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。
  • 任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter
    指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

2.Monitor对象

  • 什么是Monitor对象

可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。

  • Monitor对象是如何与被锁住的对象关联的

Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

  • Monitor对象的结构

    |Owner |
    | ———— |
    |EntryQ |
    |RcThis |
    |Nest |
    |HashCode |
    |Candidate |

    • Owner:初始时为NULL表示当前没有任何线程拥有该monitorrecord,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;

    • EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitorrecord失败的线程。

    • RcThis:表示blocked或waiting在该monitorrecord上的所有线程的个数。

    • Nest:用来实现重入锁的计数。

    • HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GCage)。

    • Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁。

3.Java对象头

synchronized用的锁是存在Java对象头里的

  • 对象头的长度

如果对象是数组类型,则虚拟机用3个字长(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字长等于4字节,即32bit:

  • 对象头中存储的数据

Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。其中Klass Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

  • Mark Word的存储结构

Mark Word用于存储对象自身的运行时数据,如HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等。它是实现轻量级锁和偏向锁的关键。
Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。32位JVM的Mark Word的默认存储结构如下:

  • Mark Word状态的变化

对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化,变化状态如下:


四、synchronized中锁的升级

Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。

锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

1.整个synchronized锁流程如下:

  1. 检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁
  2. 如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
  3. 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
  4. 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁
  5. 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
  6. 如果自旋成功则依然处于轻量级状态。
  7. 如果自旋失败,则升级为重量级锁。

2.偏向锁

  • 什么是偏向锁

    偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要同步。

  • 为什么引入偏向锁

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

  • 偏向锁的获取

  1. 当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。
  2. 如果测试成功,表示线程已经获得了锁。
  3. 如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
  • 偏向锁的撤销

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

  1. 它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,
  2. 如果线程不处于活动状态,则将对象头设置成无锁状态;
  3. 如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

线程1演示了偏向锁初始化的流程,线程2演示了偏向锁撤销的流程。

  • 偏向锁的释放
  1. 偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活
  2. 如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。
  3. 如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

3.轻量级锁

  • 轻量级锁的加锁

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

  • 轻量级锁的解锁

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

两个线程同时争夺锁,导致锁膨胀的流程。

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

三种锁的对比

-------------本文结束感谢您的阅读-------------