80b0797c-a0af-40c0-a0ac-d3822be6d38a.jpg

synchornized

每个java对象都可以用做一个实现同步的锁, 这些锁被称为内置锁(Intrinsic Lock)或者监视器锁(Monitor Lock). 线程在进入同步代码块之前会自动获得锁, 并且在退出同步代码块时自动释放锁.
获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法.

如果synchronized所修饰的是静态方法, 则其所用的锁为Class对象 如果synchronized所修饰的是非静态方法, 则其所用的锁为方法调用所在的对象

在使用 Class 对象 作为内置锁的时候,要注意由于 Class 对象只有一个,不像对象实例一样,可以存在很多个,线程去争夺不同实例对象的锁时,是互不影响的。但是用Class 对象作为锁后,所有争夺锁的线程都是会互相竞争。

注:synchronized关键字不能继承。
对于父类中用synchronized 修饰的方法,子类在覆盖该方法时,默认情况下不是同步的,必须显式的使用 synchronized 关键字修饰才行, 当然子类也可以直接调用父类的方法, 这样就间接实现了同步。

wait

阻塞当前线程,释放锁,进入 wait_set 队列。wait 方法是会响应中断的。这里的响应中断的条件也是获取锁后才能响应。

notify、notifyAll

从 wait_set 队列移除,进入 sync_queue 队列,竞争锁。

以上方法都有一个前提,那就是调用这些方法的线程必须获取了锁。

ObjectMonitor

在 java 中每一个对象其实对应的都有一个 ObjectMonitor 对象,这个对象的引用存放在对象头里面。就是说每一个对象的对象头里面都有一个 ObjectMonitor 对象的引用。
对象头中存储了该对象的metadata, 除了该对象的锁信息,还包括指向该对象对应的类的指针,对象的hashcode, GC分代年龄等,在对象头这个寸土寸金的地方,根据锁状态的不同,有些内存是大家公用的,在不同的锁状态下,存储不同的信息,而对象头中存储锁信息的那部分字段,我们称作Mark Word, 这个我们就不展开了讲了。我们只需要知道: 锁信息存储在对象头的> Mark Word中 在synchronized锁中,这个存储在对象头的Mark Word中的锁信息是一个指针,它指向一个monitor对象(也称为管程或监视器锁)的起始地址。这样,我们就通过对象头,将每一个对象与一个monitor关联了起来,它们的关系如下图所示:

image.png

  1. ObjectMonitor() {
  2. _header = NULL;
  3. _count = 0;
  4. _waiters = 0,
  5. _recursions = 0;
  6. _object = NULL;
  7. _owner = NULL;
  8. _WaitSet = NULL;
  9. _WaitSetLock = 0 ;
  10. _Responsible = NULL ;
  11. _succ = NULL ;
  12. _cxq = NULL ;
  13. FreeNext = NULL ;
  14. _EntryList = NULL ;
  15. _SpinFreq = 0 ;
  16. _SpinClock = 0 ;
  17. OwnerIsThread = 0 ;
  18. _previous_owner_tid = 0;
  19. }

上面这些字段中,我们只需要重点关注三个字段:

  • _owner : 当前拥有该 ObjectMonitor 的线程
  • _EntryList: 当前等待锁的集合
  • _WaitSet: 调用了Object.wait()方法而进入等待状态的线程的集合,即我们上一篇一直提到的wait set

在java中,每一个等待锁的线程都会被封装成ObjectWaiter对象,当多个线程同时访问一段同步代码时,首先会被扔进 _EntryList 集合中,如果其中的某个线程获得了monitor对象,他将成为_owner,如果在它成为_owner之后又调用了wait方法,则他将释放获得的monitor对象,进入 _WaitSet集合中等待被唤醒。
image.png

2 几种锁

Java 6 为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁“。在Java 6 以前,所有的锁都是”重量级“锁。所以在Java 6 及其以后,一个对象其实有四种锁状态,它们级别由低到高依次是:

  1. 无锁状态
  2. 偏向锁状态
  3. 轻量级锁状态
  4. 重量级锁状态

无锁就是没有对资源进行锁定,任何线程都可以尝试去修改它,无锁在这里不再细讲。
几种锁会随着竞争情况逐渐升级,锁的升级很容易发生,但是锁降级发生的条件会比较苛刻,锁降级发生在Stop The World期间,当JVM进入安全点的时候,会检查是否有闲置的锁,然后进行降级。

关于锁降级有两点说明: 1.不同于大部分文章说锁不能降级,实际上HotSpot JVM 是支持锁降级的,文末有链接。 2.上面提到的Stop The World期间,以及安全点,这些知识是属于JVM的知识范畴,本文不做细讲。

下面分别介绍这几种锁以及它们之间的升级。

2.1 Java对象头

前面我们提到,Java的锁都是基于对象的。首先我们来看看一个对象的“锁”的信息是存放在什么地方的。
每个Java对象都有对象头。如果是非数组类型,则用2个字宽来存储对象头,如果是数组,则会用3个字宽来存储对象头。在32位处理器中,一个字宽是32位;在64位虚拟机中,一个字宽是64位。对象头的内容如下表:

长度 内容 说明
32/64bit Mark Word 存储对象的hashCode或锁信息等
32/64bit Class Metadata Address 存储到对象类型数据的指针
32/64bit Array length 数组的长度(如果是数组)

我们主要来看看Mark Word的格式:

锁状态 29 bit 或 61 bit 1 bit 是否是偏向锁? 2 bit 锁标志位
无锁 0 01
偏向锁 线程ID 1 01
轻量级锁 指向栈中锁记录的指针 此时这一位不用于标识偏向锁 00
重量级锁 指向互斥量(重量级锁)的指针 此时这一位不用于标识偏向锁 10
GC标记 此时这一位不用于标识偏向锁 11

可以看到,当对象状态为偏向锁时,Mark Word存储的是偏向的线程ID;当状态为轻量级锁时,Mark Word存储的是指向线程栈中Lock Record的指针;当状态为重量级锁时,Mark Word为指向堆中的monitor对象的指针。

2.2 偏向锁

Hotspot的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,于是引入了偏向锁。
偏向锁会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。也就是说,偏向锁在资源无竞争情况下消除了同步语句,连CAS操作都不做了,提高了程序的运行性能。

大白话就是对锁置个变量,如果发现为true,代表资源无竞争,则无需再走各种加锁/解锁流程。如果为false,代表存在其他线程竞争资源,那么就会走后面的流程。

实现原理

一个线程在第一次进入同步块时,会在对象头和栈帧中的锁记录里存储锁的偏向的线程ID。当下次该线程进入这个同步块时,会去检查锁的Mark Word里面是不是放的自己的线程ID。
如果是,表明该线程已经获得了锁,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁 ;如果不是,就代表有另一个线程来竞争这个偏向锁。这个时候会尝试使用CAS来替换Mark Word里面的线程ID为新线程的ID,这个时候要分两种情况:

  • 成功,表示之前的线程不存在了, Mark Word里面的线程ID为新线程的ID,锁不会升级,仍然为偏向锁;
  • 失败,表示之前的线程仍然存在,那么暂停之前的线程,设置偏向锁标识为0,并设置锁标志位为00,升级为轻量级锁,会按照轻量级锁的方式进行竞争锁。

    CAS: Compare and Swap 比较并设置。用于在硬件层面上提供原子性操作。在 Intel 处理器中,比较并交换通过指令cmpxchg实现。 比较是否和给定的数值一致,如果一致则修改,不一致则不修改。

线程竞争偏向锁的过程如下:
image.png
图中涉及到了lock record指针指向当前堆栈中的最近一个lock record,是轻量级锁按照先来先服务的模式进行了轻量级锁的加锁。

撤销偏向锁

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时, 持有偏向锁的线程才会释放锁。
偏向锁升级成轻量级锁时,会暂停拥有偏向锁的线程,重置偏向锁标识,这个过程看起来容易,实则开销还是很大的,大概的过程如下:

  1. 在一个安全点(在这个时间点上没有字节码正在执行)停止拥有锁的线程。
  2. 遍历线程栈,如果存在锁记录的话,需要修复锁记录和Mark Word,使其变成无锁状态。
  3. 唤醒被停止的线程,将当前锁升级成轻量级锁。

所以,如果应用程序里所有的锁通常处于竞争状态,那么偏向锁就会是一种累赘,对于这种情况,我们可以一开始就把偏向锁这个默认功能给关闭:

  1. -XX:UseBiasedLocking=false

下面这个经典的图总结了偏向锁的获得和撤销:
image.png

2.3 轻量级锁

多个线程在不同时段获取同一把锁,即不存在锁竞争的情况,也就没有线程阻塞。针对这种情况,JVM采用轻量级锁来避免线程的阻塞与唤醒。

轻量级锁的加锁

JVM会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,我们称为Displaced Mark Word。如果一个线程获得锁的时候发现是轻量级锁,会把锁的Mark Word复制到自己的Displaced Mark Word里面。
然后线程尝试用CAS将锁的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示Mark Word已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。

自旋:不断尝试去获取锁,一般用循环来实现。

自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。
但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
自旋也不是一直进行下去的,如果自旋到一定程度(和JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。同时这个锁就会升级成重量级锁
轻量级锁的释放:
在释放锁时,当前线程会使用CAS操作将Displaced Mark Word的内容复制回锁的Mark Word里面。如果没有发生竞争,那么这个复制的操作会成功。如果有其他线程因为自旋多次导致轻量级锁升级成了重量级锁,那么CAS操作会失败,此时会释放锁并唤醒被阻塞的线程。
一张图说明加锁和释放锁的过程:
image.png

2.4 重量级锁

重量级锁依赖于操作系统的互斥量(mutex) 实现的,而操作系统中线程间状态的转换需要相对比较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗CPU。
前面说到,每一个对象都可以当做一个锁,当多个线程同时请求某个对象锁时,对象锁会设置几种状态用来区分请求的线程:

  1. Contention List:所有请求锁的线程将被首先放置到该竞争队列
  2. Entry ListContention List中那些有资格成为候选人的线程被移到Entry List
  3. Wait Set:那些调用wait方法被阻塞的线程被放置到Wait Set
  4. OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck
  5. Owner:获得锁的线程称为Owner
  6. !Owner:释放锁的线程

当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个ObjectWaiter对象插入到Contention List的队列的队首,然后调用park函数挂起当前线程。
当线程释放锁时,会从Contention List或EntryList中挑选一个线程唤醒,被选中的线程叫做Heir presumptive即假定继承人,假定继承人被唤醒后会尝试获得锁,但synchronized是非公平的,所以假定继承人不一定能获得锁。这是因为对于重量级锁,线程先自旋尝试获得锁,这样做的目的是为了减少执行操作系统同步操作带来的开销。如果自旋不成功再进入等待队列。这对那些已经在等待队列中的线程来说,稍微显得不公平,还有一个不公平的地方是自旋线程可能会抢占了Ready线程的锁。
如果线程获得锁后调用Object.wait方法,则会将线程加入到WaitSet中,当被Object.notify唤醒后,会将线程从WaitSet移动到Contention List或EntryList中去。需要注意的是,当调用一个锁对象的waitnotify方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁

2.5 总结锁的升级流程

每一个线程在准备获取共享资源时: 第一步,检查MarkWord里面是不是放的自己的ThreadId ,如果是,表示当前线程是处于 “偏向锁” 。
第二步,如果MarkWord不是自己的ThreadId,锁升级,这时候,用CAS来执行切换,新的线程根据MarkWord里面现有的ThreadId,通知之前线程暂停,之前线程将Markword的内容置为空。
第三步,两个线程都把锁对象的HashCode复制到自己新建的用于存储锁的记录空间,接着开始通过CAS操作, 把锁对象的MarKword的内容修改为自己新建的记录空间的地址的方式竞争MarkWord。
第四步,第三步中成功执行CAS的获得资源,失败的则进入自旋 。
第五步,自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于 轻量级锁的状态,如果自旋失败 。
第六步,进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己。

2.6 各种锁的优缺点对比

下表来自《Java并发编程的艺术》:

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 适用于只有一个线程访问同步块场景。
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度。 如果始终得不到锁竞争的线程使用自旋会消耗CPU。 追求响应时间。同步块执行速度非常快。
重量级锁 线程竞争不使用自旋,不会消耗CPU。 线程阻塞,响应时间缓慢。 追求吞吐量。同步块执行时间较长。

参考文章

多线程系列文章目录
9 synchronized与锁
死磕Synchronized底层实现—概论 #12
重量级锁、自旋锁、轻量级锁、偏向锁、悲观、乐观锁等各种锁