【并发】并发锁机制-深入理解synchronized(二)
synchronized 高级篇(底层原理)
synchronized是JVM内置锁,基于Monitor机制实现。
这个Monitor就是管程的意思,它可以控制线程,让其陷入等待,或者将其唤醒!
synchronized 依赖底层操作系统的互斥原语Mutex(互斥量),它是一个重量级锁,性能较低。
因为,有使用到操作系统底层的原语Mutex,我们只能通过系统调用来使用它!所以,CPU要从用户态到内核态,它是一个很重的操作!
不过,在JVM内置锁在1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、自适应自旋(Adaptive Spinning)等技术来减少锁操作的开销,内置锁的并发性能已经基本与Lock持平。
根据一些测试报告,在数据量不是很大的情况下,synchronized的性能大约只比ReentrantLock 差10%-20%!
一、查看synchronized的字节码指令序列
Java虚拟机通过一个同步结构支持方法和方法中的指令序列的同步:monitor
同步方法是通过方法中的access_flags(访问标志位)中设置ACC_SYNCHRONIZED标志来实现。
同步代码块是通过 monitorenter 和 monitorexit 来实现。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。
同步方法
private static int counter = 0;
public synchronized static void increment() {
counter++;
}
public synchronized static void decrement() {
counter--;
}
这里的synchronized加在方法上面,所以方法内部的指令没有发生变化!仅仅是加了一个标志位!
这边显示的是0x0029,其实是0x0001 + 0x0008 + 0x0020
同步代码块
private static String lock = "";
public static void increment() {
synchronized (lock) {
counter++;
}
}
public static void decrement() {
synchronized (lock) {
counter--;
}
}
这里方法内部的指令发生的改变!
【问】为什么monitorexit指令有2次?
第一个monitorexit指令是同步代码块正常释放锁的一个标志
如果同步代码块中出现Exception或者Error,则会调用第二个monitorexit指令来保证释放锁
二、Monitor(管程/监视器)
Monitor在操作系统中就是管程,而在Java中,我们通常称它为监视器!
管程是指管理共享变量以及对共享变量操作的过程,让它们支持并发。
在Java 1.5之前,Java语言提供的唯一并发语言就是管程,Java 1.5之后提供的SDK并发包也是以管程为基础的!例如:JUC
synchronized关键字和wait()、notify()、notifyAll()这三个方法是Java中实现管程技术的组成部分。
MESA模型
在管程的发展史上,先后出现过三种不同的管程模型——Hasen模型、Hoare模型和MESA模型。
现在正在广泛使用的是MESA模型,介绍如下:
入口只允许一个线程通过,其余的现在入口等待队列中等待!这样子设计可以解决互斥的问题!
进去之后,里面还提供了条件变量,每个条件变量都对应有一个等待队列!
条件变量和其等待队列的作用是解决线程之间的同步问题!条件队列里面存的东西,可以理解为“被wait()” 的线程。
wait()的正确使用姿势
对于MESA管程来说,有一个编程范式:
while(条件不满足) {
wait();
}
唤醒的时间和获取到锁继续执行的时间是不一致的,被唤醒的线程再次执行时可能条件又不满足了,所以循环检验条件。MESA模型的wait()方法还有一个超时参数,为了避免线程进入等待队列永久阻塞。
我们可以看看Object类里面的对于 wait() 方法的注解描述:
确实需要将其放在循环里面。
notify() 和 notifyAll() 分别何时使用
满足以下三个条件时,可以使用notify(),其余情况尽量使用notifyAll():
- 所有等待线程拥有相同的等待条件;
- 所有等待线程被唤醒后,执行相同的操作;
- 只需要唤醒一个线程。
Java语言的内置管程synchronized
Java 参考了 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简。MESA 模型中,条件变量可以有多个,Java 语言内置的管程里只有一个条件变量。模型如下图所示。
Monitor机制在Java中的实现
java.lang.Object 类定义了 wait(),notify(),notifyAll() 方法,这些方法的具体实现,依赖于 ObjectMonitor 实现,这是 JVM 内部基于 C++ 实现的一套机制。
ObjectMonitor其主要数据结构如下(hotspot源码ObjectMonitor.hpp):
ObjectMonitor() {
_header = NULL; //对象头 markOop
_count = 0;
_waiters = 0,
_recursions = 0; // 锁的重入次数
_object = NULL; //存储锁对象
_owner = NULL; // 标识拥有该monitor的线程(当前获取锁的线程)
_WaitSet = NULL; // 等待线程(调用wait)组成的双向循环链表,_WaitSet是第一个节点
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; //多线程竞争锁会先存到这个单向链表中 (FILO栈结构)
FreeNext = NULL ;
_EntryList = NULL ; //存放在进入或重新进入时被阻塞(blocked)的线程 (也是存竞争锁失败的线程)
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
图解Java中的Monitor机制
在获取锁时,是将当前线程插入到cxq的头部,而释放锁时,默认策略(QMode=0)是:如果EntryList为空,则将cxq中的元素按原有顺序插入到EntryList,并唤醒第一个线程,也就是当EntryList为空时,是后来的线程先获取锁。_EntryList不为空,直接从_EntryList中唤醒线程。
【思考】synchronized加锁加在对象上,锁对象是如何记录锁状态的?
三、对象的内存布局
Hotspot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
- 对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象才有)等。
- 实例数据:存放类的属性数据信息,包括父类的属性信息;
- 对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
什么是对象头?
HotSpot虚拟机的对象头包括:
- Mark Word
用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称它为“Mark Word”。
- Klass Pointer
对象头的另外一部分是klass类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 32位4字节,64位开启指针压缩或最大堆内存
- 数组长度(只有数组对象有)
如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度。 4字节