Java并发编程之synchronized

简介

synchronized 作为 Java 中的一个关键字,保证了多个线程访问同一个段代码时的数据安全。
本篇文章主要介绍其在 JVM 层面的实现原理以及 JDK1.6 之后的几种锁优化策略。

使用方式

普通同步方法

当一个线程要访问一个普通同步方法,访问前需要获取当前 实例对象 的锁,离开该方法时要释放该锁。使用方式如下:

1
2
3
private synchronized void testSynchronizedMethod() {

}

静态同步方法

当一个线程要访问一个静态同步方法,访问前需要获取当前 类对象 的锁,离开该方法时要释放该锁。使用方式如下:

1
2
3
private static synchronized void testSynchronizedStaticMethod() {

}

同步代码块

当一个线程要访问一个同步代码块,访问前需要获取 指定对象 的锁,离开该方法时要释放该锁。使用方式如下:

1
2
3
4
5
6
7
8
9
10
11
private void testSynchronizedBlock() {
// 这里要获取this即当前实例对象的锁
synchronized (this) {

}

// 这里要获取Test类对象的锁
synchronized (Test.class) {

}
}

JVM 层面原理解析

通过 javap 命令可以查看到同步方法是在该方法上添加了 ACC_SYNCHRONIZED 标识,而同步代码块则是添加了 monitorentermonitorexit 两个指令实现的。
monitorenter 指向同步代码块的开始位置,monitorexit 指向同步代码块的结束位置。下面逐步分析实现原理。

oop-klass model

我们都知道创建 Java 对象的时候是保存在堆中的,那么对象在 JVM 中是什么样的结构呢?下面基于 HotSpot 虚拟机来分析一下。

HotSpot 是基于 c++ 实现的,而 c++ 也是面向对象的,那么是否只要创建一个与 Java 对象对应的 c++ 对象就可以了呢?HotSpot 虚拟机并没有这么做,
而是采用了 oop-klass 模型,oop (Ordinary Object Pointer)指的是简单对象指针,而 klass 用来描述对象实例的具体类型。

oop

每创建一个 Java 对象,JVM 就会创建一个对应的 OOP 对象,例如当我们用 new 创建一个 Java 对象实例的时候,JVM 创建一个对应的 instanceOopDesc 对象来表示这个 Java 对象,
当我们用 new 创建一个 Java 数组实例的时候,JVM 创建一个对应的 arrayOopDesc 对象来表示这个数组对象。

在 HotSpot 中,oopDesc 类定义在 oop.hpp 中,instanceOopDesc 定义在 instanceOop.hpp 中,arrayOopDesc 定义在 arrayOop.hpp中。
instanceOopDesc 和 arrayOopDesc 均继承自 oopDesc,我们来看下 oopDesc 的定义:

1
2
3
4
5
6
7
8
9
10
11
12
class oopDesc {
friend class VMStructs;
private:
volatile markOop _mark;
union _metadata {
Klass* _klass;
narrowKlass _compressed_klass;
} _metadata;

// 其它一些field

}

回忆一下之前介绍的 Java 对象在内存中的布局,包含三个部分:对象头、实例数据和对象填充。我们这里看到的 markOop_metadata 就对应对象头中的 Mark Word 和元数据指针。
而实例数据存在其它的各种 field 中。

我们知道对象头中有和锁相关的运行时数据,这些运行时数据是 synchronized 以及其他类型的锁实现的重要基础,而关于锁标记、GC分代等信息均保存在 _mark 中。
_metadata 中包含了普通指针 _klass 和压缩类指针 _compressed_klass,这两个指针都指向下面要说的 instanceKlass 对象,它用来描述对象的具体类型。

klass

JVM在运行时,需要一种用来标识 Java 内部类型的机制。在 HotSpot 中的解决方案是:为每一个已加载的 Java 类创建一个 instanceKlass 对象,用来在 JVM 层表示 Java 类。
其实这个 instanceKlass 也就是我们平常所说的保存在方法区(或元空间)的类的元数据。

总结

当 JVM 加载一个类的时候,在方法区(或元空间)创建一个 instanceKlass 对象来表示该 Java 类。然后当我们通过 new 创建一个对象的时候,在堆中创建一个 instanceOopDesc
对象,包含了对象头和实例数据。对象头又包含 _mark_metadata,实例数据用其它一些 field 保存。
其中 _mark 就是对象头中的 Mark Word,其中存放有一些运行时数据,包括和多线程相关的锁的信息,_metadata 存放的指针,指向对象所属的类 instanceKlass(也就是我们说的类的元数据)。

Java 对象头

  • 第一部分用于存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启指针压缩)中分别为32bit 和 64bit ,官方称之为“Mark Word”。
  • 对象头的另一部分是类型指针。即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果对象是一个 Java 数组,那么在对象头中还必须有一块用于记录数组长度的数据。

markOop.hpp 中我们可以看到:

1
2
3
4
5
6
7
8
enum { age_bits                 = 4,
lock_bits = 2,
biased_lock_bits = 1,
max_hash_bits = BitsPerWord - age_bits - lock_bits - biased_lock_bits,
hash_bits = max_hash_bits > 31 ? 31 : max_hash_bits,
cms_bits = LP64_ONLY(1) NOT_LP64(0),
epoch_bits = 2
};

从上面的枚举定义中可以看出,对象头中主要包含了GC分代年龄、锁状态标记、哈希码、epoch等信息。

对象的状态一共有五种,分别是无锁态、轻量级锁、重量级锁、GC标记和偏向锁。在 32 位的虚拟机中有两个 bits 是用来存储锁的标记为的,但是我们都知道,
两个 bits 最多只能表示四种状态:00、01、10、11,那么第五种状态如何表示呢 ,就要额外依赖 1 bit 的空间,使用 0 和 1 来区分。

markOop.hpp 中我们可以看到对象的相关状态:

1
2
3
4
5
6
enum { locked_value             = 0,    // 轻量级锁:偏向锁状态0  锁状态00  即000
unlocked_value = 1, // 无锁状态:偏向锁状态0 锁状态01 即001
monitor_value = 2, // 重量级锁:偏向锁状态0 锁状态10 即010
marked_value = 3, // gc标记: 偏向锁状态0 锁状态11 即011
biased_lock_pattern = 5 // 偏向锁: 偏向锁状态1 锁状态01 即101
};

  • 当对象状态为偏向锁(biasable)时,Mark Word 存储的是偏向的线程ID
  • 当状态为轻量级锁(lightweight locked)时,Mark Word 存储的是指向线程栈中 Lock Record 的指针
  • 当状态为重量级锁(inflated)时,Mark Word 存储的是指向堆中的 monitor 对象的指针

Monitor 的实现原理

我们前面一直说的都是获取对象的锁,那么这个锁到底是什么,JVM 是这么处理获取锁和释放锁的呢?

管程

我们先来了解一下管程,维基百科的解释如下:

  • 管程 (英语:Monitors,也称为监视器) 是一种程序结构,结构内的多个子程序(对象或模块)形成的多个工作线程互斥访问共享资源。
    这些共享资源一般是硬件设备或一群变量。管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。
    与那些通过修改数据结构实现互斥访问的并发程序设计相比,管程实现很大程度上简化了程序设计。

这么解释还是有点抽象,简单来说,管程是一种封装了互斥量和信号量的机制,为了简化同步调用的过程。

ObjectMonitor

HotSpot 用 ObjectMonitor 来实现管程,这个也就是我们之前所说的锁。在 objectMonitor.hpp 中可以看到:

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

其中有几个关键属性:

  1. _owner:指向持有该 ObjectMonitor 对象的线程
  2. _WaitSet:存放处于wait状态的线程队列
  3. _EntryList:存放处于等待锁block的线程队列
  4. _recursions:锁的重入次数

当多个线程同时访问同步代码的时候,会进入 EntryList,然后当某个线程获取到对象的 monitor 后,将 owner 变量设置为当前线程,同时计数器 count 加 1,即对象获得了锁。
若持有 monitor 的线程调用 wait() 方法,将会释放当前持有的 monitor,owner 变量恢复为 null,count 自减 1,同时该线程进入 WaitSet 集合中等待被唤醒。
若当前线程执行完同步代码也将释放持有的 monitor 并复位变量,以便其它的线程的可以获取该 monitor。

锁优化技术

事实上,只有在 JDK1.6 之前,synchronized 的实现才会直接调用 ObjectMonitor 的 enter 和 exit,是使用操作系统互斥量(mutex)来实现的传统锁,如果要阻塞或唤醒一个线程就需要操作系统的帮忙,
这就要从用户态转换到核心态,因此状态转换需要花费很多的处理器时间,所以这种锁被称之为重量级锁。

高效并发是从 JDK1.5 到 JDK1.6 的一个重要改进,HotSpot 虚拟机开发团队在这个版本中花费了很大的精力去对 Java 中的锁进行优化,如适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等。
这些技术都是为了在线程之间更高效的共享数据,以及解决竞争问题。

偏向锁

研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块的时候,通过 CAS 修改 Mark Word 的 ThreadID 为当前线程,
如果修改成功,则获得锁,那么以后线程再次进入和退出同步块时,就不需要使用 CAS 来获取锁,只是简单的测试一个对象头中的 Mark Word 字段中是否存储着指向当前线程的偏向锁;
如果使用 CAS 设置失败时,说明存在锁的竞争,那么将执行偏向锁的撤销操作 (revoke bias),将偏向锁升级为轻量级锁。

撤销偏向的操作需要在全局安全点执行。我们假设线程A曾经拥有锁(不确定是否释放锁), 线程B来竞争锁对象,如果当线程A不在拥有锁时或者死亡时,线程B直接去尝试获得锁(根据是否允许重偏向,
获得偏向锁或者轻量级锁);如果线程A仍然拥有锁,那么锁升级为轻量级锁,线程B自旋请求获得锁。

轻量级锁

在进入同步块前,在栈空间中创建用于存储锁记录的空间,并将对象头的 Mark Word 拷贝到锁记录中,官方称之为 Displaced Mark Word。然后线程尝试用 CAS 将对象头的 Mark Word 替换为指向锁记录的指针。
如果成功,当前线程获得锁;如果失败,先检查对象头的 Mark Word 是否指向当前线程栈桢的锁记录,如果指向则说明当前线程已经获得了这个对象的锁,否则说明有其它线程竞争锁,当前线程便尝试使用自旋来获取锁。
当竞争线程的自旋次数 达到界限值,轻量级锁将会膨胀为重量级锁。

轻量级锁解锁时,会使用 CAS 操作, 将 Displaced Mark Word 替换到对象头,如果成功,则表示没有竞争发生。如果失败, 表示当前锁存在锁竞争,其它竞争线程已经升级了轻量级锁,
也就是将 Mark Word 中关于锁的部分更新为指向 monitor 的指针了。当前解锁线程按重量级锁解锁流程处理。

自旋锁

获取锁失败的时候,通过操作系统挂起当前线程后,拥有锁的线程马上就释放了锁并唤醒了刚才挂起的那个线程,这种情况下还不如让线程在获取锁失败的时候一直在那自旋获取,虽然占用一定的 CPU 处理时间,但是远比线程的挂起、唤醒消耗小。

锁消除

虚拟机通过逃逸分析判断某个基于锁的操作根本不会存在竞争(例如局部变量),虚拟机会直接去除掉这个加锁操作。

锁粗化

通常来说,编写同步代码时,需要将同步的代码控制到一个比较小的范围,但是如果在一段代码中,频繁的基于同一个对象加锁解锁,还不如扩大加锁的范围,减少加锁解锁的次数。

总结

  • 偏向锁:只需要一次 CAS 修改 Mark Word 的操作,不需要额外的消耗,但是如果存在线程竞争,则会带来额外的锁撤销的消耗,因此适用于只有一个线程访问的情况。
  • 轻量级锁:竞争锁的线程通过自旋获取锁不会阻塞,提高了程序的响应速度,但是对于始终获取不到锁的线程,则会因为自旋消耗 CPU,因此适用于追求响应时间,同步块执行速度块,且锁竞争不激烈的情况。
  • 重量级锁:线程竞争不使用自旋不消耗 CPU,但是会因为线程阻塞导致响应时间缓慢,因此适用于追求吞吐量,同步块执行速度慢,竞争线程激烈的情况。