【并发】并发锁机制-深入理解synchronized(二)

【并发】并发锁机制-深入理解synchronized(二)

synchronized 高级篇(底层原理)

一、查看synchronized的字节码指令序列

同步方法

同步代码块 

二、Monitor(管程/监视器)

MESA模型

wait()的正确使用姿势

notify() 和 notifyAll() 分别何时使用

关于 wait、notify、notifyAll的问题详解

Java语言的内置管程synchronized

Monitor机制在Java中的实现

图解Java中的Monitor机制

【思考】synchronized加锁加在对象上,锁对象是如何记录锁状态的? 

三、对象的内存布局

【了解】什么是对象头?

【问】new Object() 在对象中占用几个字节???

四、使用JOL工具查看内存布局

导pom依赖

示例代码 

运行结果 

下一节——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()

  1. 所有等待线程拥有相同的等待条件
  2. 所有等待线程被唤醒后,执行相同的操作;
  3. 只需要唤醒一个线程。

关于 wait、notify、notifyAll的问题详解

【面试题】notify() 和 notifyAll()方法的使用和区别_面向架构编程的博客-CSDN博客https://blog.csdn.net/weixin_43715214/article/details/128665586

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;
}

运行结果 

下一节——synchronized底层锁的优化解析

posted @ 2023-01-12 13:05  金鳞踏雨  阅读(33)  评论(0编辑  收藏  举报  来源