1. synchronized 的使用方式

1.1 第一种使用方式是使用 synchronized 关键字修饰同步代码块

例如 synchronized (tasks) {},其中,小括号里的对象是可以是任意的对象。我们并不是对这个 task 对象加锁,只是让它来维护秩序。这个人是谁其实并无所谓。但是,对于不同对象的同步控制,一定要选用两个线程都持有的对象才行。否则各自使用不同的对象,相当于聘用了两个看门人,各看各的门,毫无瓜葛。那么原本想要串行执行的代码仍旧会并行执行。

1.2 第二种使用方式是使用 synchronized 关键字修饰实例方法

  1. public synchronized void eat(){}

你看,这里没有锁对象,是如何加锁的呢?其实同步方法的锁对象就是 this,即当前对象。这和下面代码把方法中代码全部用 synchronized(this) 括起来的效果是一样的:

public void eat(){
    synchronized(this){
        .......
    .......
    }
}

1.3 第三种使用方式是使用 synchronized 关键字修饰静态方法

如果 synchroinized 修饰的是静态方法,那么锁对象就是静态方法所在的 Class 类文件。这时候,只要是这个类产生的对象,在调用这个静态方法时都会产生互斥。

所以如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象。因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

1.4 哪种使用方式比较好?

使用同步块是更好的选择,因为它不会锁住整个对象(当然你也可以让它锁住整个对象)。同步方法会锁住整个对象,哪怕这个类中有多个不相关联的同步块,这通常会导致他们停止执行并需要等待获得这个对象上的锁。

  • 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,monitorenter 指向同步代码块的开始位置,monitorexit 指向同步代码块的结束位置。
  • 同步方法依靠的是方法修饰符上的 ACC_SYNCHRONIZED 标志隐式实现。

2. synchronized 的实现原理

2.1 synchronized 作用

  1. synchronized 可以保证在同一时刻,只有一个线程可以执行某一个方法或者代码块
  2. synchronized 也可以确保可见性,在一个线程执行完 synchronized 代码后,当某个线程修改了可变数据并释放锁后,其它线程可以获取被修改变量的最新值。
  3. 有效解决重排序问题

因此,synchronized 可以解决原子性、可见性、有序性。

2.2 Java 对象在内存中的结构

在 HotSpot 虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(对象的信息)和对齐填充(仅仅起着占位符的作用)。要理解锁机制,就必须先了解 HotSpot 虚拟机的对象头。

对象头分为两个部分:

  • 第一部分是为 Mark Word(对象标记),用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32 个比特和 64 个比特。这部分是实现轻量级锁和偏向锁的关键。
  • 第二部分用于存储 Klass Pointer(元数据指针),这是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

如果是数组对象,还会有一个额外的部分用于存储数组长度(因为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小,但是从数组的元数据中无法确定数组的大小)。

synchronized 关键字 - 图1
Mark Word会随着程序的运行发生变化,变化状态如下(32位虚拟机):
synchronized 关键字 - 图2
synchronized 的对象锁,锁标识位为10,其中指针指向的是 monitor 对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联,monitor 对象存在于每个对象的对象头中。

2.3 Monitor 机制

2.3.1 Monitor 是什么?

监视器锁(Monitor 另一个名字叫管程)本质是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如 monitor 可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。

2.3.2 Monitor 基本组成

Monitor 机制需要几个元素来进行配合,分别是:

  1. 临界区
  2. Monitor 对象及锁
  3. 条件变量以及定义在 Monitor 对象上的 wait、signal 操作

Monitor 对象是用来阻塞无法进入临界区的线程的,这个 Monitor 对象内部会有相应的数据结构,例如列表,来保存被阻塞的线程。同时由于 Monitor 机制本质上是基于 mutex 这种同步原语的,所以 Monitor 对象还必须维护一个基于 mutex 的锁。

条件变量是用于在适当的时候能够阻塞或唤醒线程,这个条件可以来自程序代码的逻辑,也可以来自 Monitor 对象的内部。由于 Monitor 对象内部采用了数据结构来保存被阻塞的队列,因此它必须对外提供两个 API 来阻塞或唤醒线程,分别是 wait() 和 notify()。

2.3.3 Java 中的 Monitor

在使用 synchronized 关键字的时候,往往需要指定一个对象与之关联。如果 synchronized 修饰的是静态代码块,那么关联的对象就是我们指定的那个对象;如果 synchronized 修饰的是实例方法,那么其关联的对象就是当前对象 this;如果 synchronized 修饰的是静态方法,那么其关联的对象是当前类 this.class。

总之,synchronized 需要关联一个对象,而这个对象就是 Monitor 对象。在 Monitor 机制中,Monitor 对象用于维护 mutex 以及定义 wait、signal、notify 等 API 来管理线程的阻塞和唤醒。

在 Java 虚拟机 (HotSpot) 中,Monitor 是基于 C++ 实现的,由 ObjectMonitor 实现的,它实现了 java.lang.Object 类定义的 wait()、notify()、notifyAll() 几个关键方法。ObjectMonitor 有几个关键属性:

  • _owner:指向持有 ObjectMonitor 对象的线程。
  • _WaitSet:存放处于 wait 状态的线程队列。
  • _EntryList:存放处于等待锁 block 状态的线程队列。
  • _recursions:锁的重入次数。
  • count:用来记录该线程获取锁的次数。

synchronized 关键字 - 图3
ObjectMonitor 中有两个队列,_WaitSet_EntryList,用来保存 ObjectWaiter 对象列表(每个等待锁的线程都会被封装成 ObjectWaiter 对象),_owner指向持有 ObjectMonitor 对象的线程。

  • 当多个线程同时访问一段同步代码时,首先会进入_EntryList集合,当线程获取到对象的 monitor 后进入_Owner区域并把 monitor 中的 owner 变量设置为当前线程同时 monitor 中的计数器 count 加 1。
  • 若线程调用 wait() 方法,将释放当前持有的 monitor,owner 变量恢复为 null,count 自减 1,同时该线程进入 _WaitSet 集合中等待被唤醒,_EntryList中的其他线程在这个时候就有机会获得锁。当条件变量成立时,_WaitSet 中被阻塞的线程也可以重新进入_EntryList去竞争锁。
  • 若当前线程执行完毕也将释放 monitor 并复位变量的值,以便其他线程进入获取 monitor。

由此看来,Monitor 存在于每个 Java 对象的对象头中(存储的指针的指向),synchronized 锁便是通过这种方式获取锁的,也是为什么 Java 中任意对象可以作为锁的原因,同时也是 notify/notifyAll/wait 等方法存在于顶级对象 Object 中的原因。

2.3.4 synchronized 效率低的原因

synchronized 通过 Monitor 来实现线程同步,Monitor 是依赖于底层操作系统的 Mutex Lock(互斥锁)来实现的线程同步。而操作系统实现线程之间的切换就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。

因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为『重量级锁』。JDK1.6 中对 Synchronized 做的种种优化,其核心都是为了减少这种重量级锁的使用。在 JDK1.6 中,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了『轻量级锁』和『偏向锁』。

3. JDK 1.6 对 synchronized 的优化

高效并发是从 JDK 1.5 升级到 JDK 1.6 后一项重要的改进项,HotSpot 虚拟机开发团队在这个版本上花费了大量的资源去实现各种锁优化技术,如适应性自旋锁消除锁膨胀轻量级锁偏向锁等,这些技术都是为了在线程之间更高效地共享数据及解决竞争问题,从而提高程序的执行效率。

3.1 锁消除

如果 JVM 检测到不可能存在共享数据竞争,这时 JVM 会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。
synchronized 关键字 - 图4

3.2 锁粗化

如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。虚拟机将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
synchronized 关键字 - 图5

3.3 偏向锁

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。

当一个线程访问同步代码块并获取锁时,会首先在 MarkWord 里存储锁偏向的线程 ID。在线程进入和退出同步块时不再向轻量级锁那样通过 CAS 操作来加锁和解锁,而是检测 MarkWord 里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在没有多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 MarkWord 中的 ThreadID 的时候依赖一次 CAS 原子指令即可。

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放偏向锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

3.4 轻量级锁

  1. 当锁是偏向锁的时候,被另外的线程访问,虚拟机会在当前线程的栈帧中建立一个名为锁记录的空间,用于存储锁对象目前的 MarkWord 的拷贝。
  2. 然后,虚拟机使用 CAS 操作尝试把对象的 MarkWord 更新为指向锁记录的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象 MarkWord 的锁标志位转变为“00”,表示此对象处于轻量级锁状态。
  3. 如果这个更新操作失败了,虚拟机首先检查对象的 MarkWord 是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那就直接进入同步块执行即可。如果不是,说明这个锁对象已经被其他线程抢占了,继续锁升级。

若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数(自旋锁默认大小是 10 次),或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

对于轻量级锁,其性能提升的依据是『对于绝大部分的锁,在整个生命周期内都是不会存在竞争的』,如果打破这个依据则除了互斥的开销外,还有额外的 CAS 操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。

3.5 自旋锁和自适应自旋锁

在轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。现在绝大多数的个人电脑和服务器都是多路(核)处理器系统,如果物理机器有一个以上的处理器或者处理器核心,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁

自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,这就会带来性能的浪费。自旋锁的实现原理同样也是 CAS

所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要获取这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

4. 锁的四种状态

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率
synchronized 关键字 - 图6

轻量级锁的实现方式为自旋锁。

5. 偏向锁、轻量级锁、重量级锁的对比

  1. 偏向锁通过对比 MarkWord 中的 线程ID 解决加锁问题,避免执行循环 CAS 操作。偏向锁适用于只有一个线程访问同步块的场景。
  2. 轻量级锁是通过用 CAS 操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。轻量级锁适合追求响应时间,锁占用时间很短的场景。
  3. 重量级锁是将除了拥有锁的线程以外的线程都阻塞。重量级锁适合追求吞吐量,锁占用时间较长的场景。

synchronized 关键字 - 图7


参考

  1. 不可不说的Java“锁”事