【并发】并发锁机制-深入理解synchronized(二)
【思考】synchronized加锁加在对象上,锁对象是如何记录锁状态的?
【并发】并发锁机制-深入理解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():
- 所有等待线程拥有相同的等待条件;
- 所有等待线程被唤醒后,执行相同的操作;
- 只需要唤醒一个线程。
关于 wait、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;
}
其中涉及到3种链表——cxq、WaitSet、EntryList
但是从队列中挑选一个线程进行唤醒,如何挑选?有没有什么原则?
这一方面比较复杂!具体看下文!!!
图解Java中的Monitor机制
首先,所有线程去竞争锁,竞争失败的线程会进入cxq(FILO)里面;
然后,持有锁的线程,执行后续逻辑,如果有 wait 方法,进入等待队列waitSet;
接下来,可能继续被唤醒,可能会进入cxq队列(栈),也可能进入EntryList(这要看具体的策略!)
最后,再次竞争锁的时候,可能从cxq中获取,也可能从EntryList中获取!
默认策略
如果 EntryList为空,则将 cxq中的元素按原有顺序插入到EntryList,并唤醒第一个线程,也就是当EntryList为空时,是后来的线程先获取锁。(非公平!)
如果 EntryList不为空,直接从 EntryList 中唤醒线程。
【思考】synchronized加锁加在对象上,锁对象是如何记录锁状态的?
锁状态是被记录在每个对象的对象头(Mark Word)中!具体看下文!
三、对象的内存布局
Hotspot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和 对齐填充(Padding)
- 对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象才有)等。
- 实例数据:存放类的属性数据信息,包括父类的属性信息;
- 对齐填充:由于虚拟机要求 对象起始地址 必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
【了解】什么是对象头?
对象头是对象中最复杂的部分!HotSpot虚拟机的对象头包括:3个部分
(1)Mark Word
用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称它为“Mark Word”。
图解32位的JVM的存储情况
图解64位的JVM的存储情况
虽然它们在不同位数的JVM中长度不一样,但是基本组成内容是一致的。
- 锁标志位(lock):区分锁状态,11时表示对象待GC回收状态, 只有最后2位锁标识(11)有效。
- biased_lock:是否偏向锁,由于无锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。
- 分代年龄(age):表示对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代。最大是15,所以是四位!
- 对象的hashcode(hash):运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。当对象加锁后,计算的结果31位不够表示,在偏向锁,轻量锁,重量锁,hashcode会被转移到Monitor中。
- 偏向锁的线程ID(JavaThread):偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。 在后面的操作中,就无需再进行尝试获取锁的动作。
- epoch:偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁
- ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争的时,JVM使用原子操作而不是OS互斥。这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的标题字中设置指向锁记录的指针。
- ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针。
简单的来说就是:
enum {
locked_value = 0, //00 轻量级锁
unlocked_value = 1, //001 无锁
monitor_value = 2, //10 监视器锁,也叫膨胀锁,也叫重量级锁
marked_value = 3, //11 GC标记
biased_lock_pattern = 5 //101 偏向锁
}
图示如下:
(2)Klass Pointer
对象头的另外一部分是klass类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。默认开启压缩指针,4个字节(未开启是8个)
(3)数组长度(只有数组对象有)
如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度。 4字节
【问】new Object() 在对象中占用几个字节???
JDK8是默认开启压缩指针,如果该对象是数组,则对象头是16个字节;反之,只是一个普通对象,则对象头是12个字节
但是请注意,对于 new Object() 来说,首先它不是数组,则对象头为12个字节,其次它是一个空对象,则它的实例数据为0,但是一个对象所占的字节必须是8个字节的整数倍,所以对齐填充位要补4个字节!加起来一共是16个字节!
我们可以用JOL来佐证!详情如下:
public class ObjectTest {
public static void main(String[] args) throws InterruptedException {
Object obj = new Object();
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}
四、使用JOL工具查看内存布局
查看普通 java对象的内部布局工具JOL(JAVA OBJECT LAYOUT),使用此工具可以查看 new 出来的一个 java对象的内部布局,以及一个普通的java对象占用多少字节。
导pom依赖
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
示例代码
public class ObjectTest {
public static void main(String[] args) throws InterruptedException {
Object obj = new Test();
//查看对象内部信息
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}
class Test{
private boolean flag;
private long p;
}