synchronized
互斥同步是常见的并发正确性保障方式。
互斥是实现同步的一种手段,临界区、互斥量(Mutex)和信号量(Semaphore)都是主要互斥方式。互斥是因,同步是果。
监视器锁(Monitor 另一个名字叫管程)本质是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如 monitor 可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。
mutex 的工作方式
在 Java 虚拟机 (HotSpot) 中,Monitor 是基于 C++ 实现的,由 ObjectMonitor 实现的, 几个关键属性:
- _owner:指向持有 ObjectMonitor 对象的线程
- _WaitSet:存放处于 wait 状态的线程队列
- _EntryList:存放处于等待锁 block 状态的线程队列
- _recursions:锁的重入次数
- count:用来记录该线程获取锁的次数
ObjectMonitor 中有两个队列,_WaitSet 和 _EntryList,用来保存 ObjectWaiter 对象列表( 每个等待锁的线程都会被封装成 ObjectWaiter 对象),_owner 指向持有 ObjectMonitor 对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的 monitor 后进入 _Owner 区域并把 monitor 中的 owner 变量设置为当前线程同时 monitor 中的计数器 count 加 1。
若线程调用 wait() 方法,将释放当前持有的 monitor,owner 变量恢复为 null,count 自减 1,同时该线程进入 WaitSet 集合中等待被唤醒。若当前线程执行完毕也将释放 monitor(锁)并复位变量的值,以便其他线程进入获取 monitor(锁)。
在 Java 中,最基本的互斥同步手段就是 synchronized
,经过编译之后会在同步块前后分别插入 monitorenter, monitorexit 这两个字节码指令,而这两个字节码指令都需要提供一个 reference 类型的参数来指定要锁定和解锁的对象,具体表现如下所示:
- 在普通同步方法,reference 关联和锁定的是当前方法示例对象;
- 对于静态同步方法,reference 关联和锁定的是当前类的 class 对象;
- 在同步方法块中,reference 关联和锁定的是括号里制定的对象;
Java 对象头
synchronized 用的锁也存在 Java 对象头里,在 JVM 中,对象在内存的布局分为三块区域:对象头、实例数据、对其填充。
- 对象头:MarkWord 和 metadata,也就是图中对象标记和元数据指针;
- 实例对象:存放类的属性数据,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按 4 字节对齐;
- 填充数据:由于虚拟机要求对象起始地址必须是 8 字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;
对象头是 synchronized 实现的关键,使用的锁对象是存储在 Java 对象头里的,jvm 中采用 2 个字宽(一个字宽代表 4 个字节,一个字节 8bit)来存储对象头(如果对象是数组则会分配 3 个字宽,多出来的 1 个字宽记录的是数组长度)。其主要结构是由 Mark Word 和 Class Metadata Address 组成。
Mark word 记录了对象和锁有关的信息,当某个对象被 synchronized 关键字当成同步锁时,那么围绕这个锁的一系列操作都和 Mark word 有关系。
虚拟机位数 | 对象结构 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的 hashCode、锁信息或分代年龄或 GC 标志等信息 |
32/64bit | Class Metadata Address | 类型指针指向对象的类元数据,JVM 通过这个指针确定该对象是哪个类的实例。 |
32/64bit | Array length | 数组的长度(如果当前对象是数组) |
其中 Mark Word 在默认情况下存储着对象的 HashCode、分代年龄、锁标记位等。Mark Word 在不同的锁状态下存储的内容不同,在 32 位 JVM 中默认状态为下:
锁状态 | 25 bit | 4 bit | 1 bit 是否是偏向锁 | 2 bit 锁标志位 |
---|---|---|---|---|
无锁 | 对象 HashCode | 对象分代年龄 | 0 | 01 |
在运行过程中,Mark Word 存储的数据会随着锁标志位的变化而变化,可能出现如下 4 种数据:
锁标志位的表示意义:
- 锁标识 lock=00 表示轻量级锁
- 锁标识 lock=10 表示重量级锁
- 偏向锁标识 biased_lock=1 表示偏向锁
- 偏向锁标识 biased_lock=0 且锁标识=01 表示无锁状态
到目前为止,我们再总结一下前面的内容,synchronized(lock) 中的 lock 可以用 Java 中任何一个对象来表示,而锁标识的存储实际上就是在 lock 这个对象中的对象头内。
Monitor(监视器锁)本质是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。Mutex Lock 的切换需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。所以 synchronized 是 Java 语言中的一个重量级操作。
在 JDK1.6 之前,synchronized 是一个重量级锁,性能比较差。从 JDK1.6 开始,为了减少获得锁和释放锁带来的性能消耗,synchronized 进行了优化,引入了偏向锁和轻量级锁的概念。
所以从 JDK1.6 开始,锁一共会有四种状态,锁的状态根据竞争激烈程度从低到高分别是: 无锁状态->偏向锁状态->轻量级锁状态->重量级锁状态。这几个状态会随着锁竞争的情况逐步升级。为了提高获得锁和释放锁的效率,锁可以升级但是不能降级。
为什么任意一个 Java 对象都能成为锁对象呢?
Java 中的每个对象都派生自 Object 类,而每个 Java Object 在 JVM 内部都有一个 native 的 C++对象 oop/oopDesc 进行对应。其次,线程在获取锁的时候,实际上就是获得一个监视器对象(monitor) ,monitor 可以认为是一个同步对象,所有的 Java 对象是天生携带 monitor。
多个线程访问同步代码块时,相当于去争抢对象监视器修改对象中的锁标识, ObjectMonitor 这个对象和线程争抢锁的逻辑有密切的关系。