并发编程2:Java 加锁的原理和JVM对锁的优化

为什么要加锁

  • 在多进程的环境下,如果一个资源被多个进程共享,那么对资源的使用往往会表现的随机和无序,这显然是不行的。例如多个线程同时对控制台输出,每个线程都输出的是完整的句子但是多个线程同时同时输出,则输出的内容就会被完全打乱,获取不到本来的信息了。
  • 对于这种共享资源,需要进行同步管理,资源在被一个线程占用时,其他线程只能阻塞等待。
  • Java 的同步就是使用的对象锁机制来实现的,要使用资源则先获取资源对应的锁后才能操作。


一、 Synchronized 关键字的作用是给对象加锁

  1. java 中的多线程同步机制通过对象锁来实现,Synchronized 关键字则是实现对对象加锁来实现对共享资源的互斥访问。
  2. synchronized 关键字实现的是独占锁或者称为排它锁,锁在同一时间只能被一个线程持有。
  3. JVM 的同步是基于进入和退出监视器对象(Monitor 也叫管城对象)来实现的,每个对象实例都有一个 Monitor 对象,和 Java 对象一起创建并一起销毁。
  4. Java 编译器,在编译到带有synchronizedg 关键字的代码块后,会插入 monitorenter 和 monitorexit 指令到字节码中,monitorenter 也就是加锁的入口了,线程会为锁对象关联一个 ObjectMonitor 对象。


二、对象基于 ObjectMonitor 加锁的原理


2.1 对象在内存中的布局

并发编程2:Java 加锁的原理和JVM对锁的优化_第1张图片


2.2 ObjectMonitor 监视器

//结构体如下
ObjectMonitor::ObjectMonitor() {  
    _header       = NULL;  
    _count       = 0;  
    _waiters      = 0,  
    _recursions   = 0;   	 //线程的重入次数
    _object       = NULL;  
    _owner        = NULL;    //标识拥有该monitor的线程
    _WaitSet      = NULL;    //等待线程组成的双向循环链表,_WaitSet是第一个节点
    _WaitSetLock  = 0 ;  
    _Responsible  = NULL ;  
    _succ         = NULL ;  
    _cxq          = NULL ;    //多线程竞争锁进入时的单向链表
    FreeNext      = NULL ;  
    _EntryList    = NULL ;    //_owner从该双向循环链表中唤醒线程结点,_EntryList是第一个节点
    _SpinFreq     = 0 ;  
    _SpinClock    = 0 ;  
    OwnerIsThread = 0 ;  
}  
  1. ObjectMonitor 是 Java 中的一种同步机制,通常被描述为一个对象,和 Java 对象一起创建一同销毁。

  2. 每一个 Java 对象就有一把看不见的锁,称为内部锁或者 Monitor 锁。

  3. ObjectMonitor 象是一个 C++的结构体,用来维护当前持有锁的线程、阻塞等待锁释放的线程链表、调用了 wait 阻塞等待 notify 的线程链表。

  4. 其中有几个关键属性** EntryList、WaitSet、cxq、owner、recursions**

  5. _cxq 竞争列表:单项链表结构,竞争锁失败的线程,会通过 CAS 将包装成 ObjectWaiter 写入到链表头部,同时为了避免 插入和取出元素的竞争,Owner 会从列表尾部取出元素。

  6. EntryList 锁候选者列表:双向链表结构,如果 EntryList 为空 Cxq 不为空,那么线程释放锁的时候,会将 cxq 中的数据移动到 EntryList 中,并制定 EntryList 列表的头结点线程作为 OnDeck 线程。

    1. OnDeck 是可以进行锁竞争的线程,如果线程是 OnDeck 状态,那么可以进行 tryLock 操作,如果失败则重新回到 EntryList 的头部。
    2. 因为 cxq 中的线程可以自旋,所以 OndeckThread 仍然有可能竞争失败。
  7. WaitSet:双向链表结构,保存由于不满足执行条件获取锁后主动释放锁 wait 的线程,在被 notify/notifyAll 后会重新参与锁竞争。

  8. owner:指向持有 ObjectMonitor 对象的线程

  9. recursions:记录当前锁的重入次数

2.3 ObjectMonitor 基本工作机制

并发编程2:Java 加锁的原理和JVM对锁的优化_第2张图片

  1. 所有期待获得锁的线程,在锁已经被其它线程拥有的时候,这些期待获得锁的线程就进入了对象锁的entry set区域。
  2. 所有曾经获得过锁,但是由于其它必要条件不满足而需要wait的时候,线程就进入了对象锁的wait set区域 。
  3. 在wait set区域的线程获得Notify/notifyAll通知的时候,随机的一个Thread(Notify)或者是全部的Thread(NotifyALL)从对象锁的wait set区域进入了entry set中。
  4. 在当前拥有锁的线程释放掉锁的时候,处于该对象锁的entryset区域的线程都会抢占该锁,但是只能有任意的一个Thread能取得该锁,而其他线程依然在entry set中等待下次来抢占到锁之后再执行。

2.4 执行流程图

并发编程2:Java 加锁的原理和JVM对锁的优化_第3张图片

2.5 ObjectMonitor::enter() 加锁的过程

  1. 如果当前线程已经是 owner 则加锁直接成功,只是加锁重入次数recursions+1
  2. 如果当前线程没有被加锁 即 owner 为空,则尝试 CAS 竞争加锁
  3. 如果当前线程已经被锁定,则阻塞进入等待队列EntryList 等待释放后再竞争锁,如果 EntryList 超出阈值线程将会阻塞一直到线程数量减少或被其他线程唤醒。

2.6 ObjectMonitor 竞争锁的过程

  1. 加锁的过程就是多个线程尝试 CAS 操作将 ObjectMonitor 的 owner 设置为自身,并增加重入次数。
  2. 如果当前线程加锁失败,未能获取到锁,则线程会启动自适应自旋,会循环尝试加锁。这是为了避免线程阻塞的开销。
  3. 自旋结束仍未获取到锁,则会被包装成 ObjectWaiter 对象,通过 addwaiter 方法加入到 _cxq 竞争队列的头部
  4. 加入 cxq 队列后,线程仍会再次尝试 CAS 加锁操作,失败后就会被 park 挂起。直到被唤醒重新竞争锁。

2.7 ObjectMonitor::wait() 让出锁

  1. 如果线程执行后判断不满足后续运行条件,会选择调用 wait 进入等待状态
  2. 线程会被封装成 ObjectWaiter 对象,最后会被使用 park 方法挂起。
  3. 调用 wait 第一步会将自身加入到 _waitSet 这个双向链表,后续再调用ObjectMonitor::exit() 来释放锁

2.8 ObjectMonitor::exit() 释放锁的过程

  1. 持有锁的线程执行完 加锁的临界区代码后,会使用ObjectMonitor::exit()来释放锁。
  2. 释放锁会将当前的 _owner 设置为空
  3. 会根据策略,选择将 cxq 队列中的线程移动到 EntryList 队列中唤醒 EntryList 的头部节点 或者直接唤醒 cxq 队列的头部节点让其竞争锁。
  4. 锁被成功释放后,会将栈帧中的 MarkWord 替换回原来的对象头中。

2.9 Object::notify 方法 执行过程

  1. 如果 waitSet 为空,则直接结束
  2. 从 waitSet 头部取出线程节点一个 ObjectWaiter 对象,根据策略 QMode 决定,将线程节点放在哪儿可能放在 cxq 队列头部或者 EntryList 的头部或者尾部,或者被直接唤醒开始竞争锁。
  3. 这样下次锁被释放时,它就能重新参与竞争锁了。


三、 Java 对同步机制的优化

在 jdk1.6 之前,对于并发控制就只有synchronized 这种办法,如果一个线程已经获得锁,另一个线程就只能阻塞进入等待,后续的线程调度就只能由操作系统来控制了。操作系统对线程的调度,需要频繁的上下文切换,所以效率很低。
来到 jdk1.6 JVM 对加锁进行了一系列的优化

3.1 锁的升级机制

并发编程2:Java 加锁的原理和JVM对锁的优化_第4张图片

3.2 32 位 JVM 的 markWord 结构

并发编程2:Java 加锁的原理和JVM对锁的优化_第5张图片

3.3 偏向锁机制

  1. JDK 1.6 默认开启偏向锁,但是在 JDk15 之后就是默认关闭了,因为偏向锁给 JVM 增加了巨大的复杂性。
  2. 未加锁时,锁标志位是 01 并且 markword 中包含 HashCode 值的位置
  3. 施加偏向锁后,markword 中会保存锁的线程 id、epoh 时间戳等信息,同时偏向锁标识变为 1
  4. 开启偏向锁后,进行加锁会判断偏向锁的线程 id 是否和 markword 线程 id 一致,一致则说明加锁成功可以执行临界区代码;
  5. 如果不一致则检查是否已偏向某个线程,未偏向则使用 CAS 加锁;未偏向的情况下加锁失败或者存在偏向但不一致,则说明存在竞争。锁会升级成轻量级锁,或者重新偏向。
  6. 偏向锁只有在出现其他线程竞争时,才会释放,线程不会主动释放偏向锁。
  7. 偏向锁在调用 wait 方法时会直接升级成重量级锁,因为 wait 方法是重量级锁独有的。
  8. hashcode 一般会在第一次调用时填入 markword,如果对象已经计算过 hashcode 那么永远无法进入偏向锁状态。如果已经处于偏向锁状态收到计算 Hashcode 的请求,则会膨胀成为重量级锁,对象头指向重量级锁,重量级锁 ObjectMonitor 类中有字段可以记录未加锁状态的 MarkWord

3.4 轻量级锁

如果竞争不激烈,一次获取锁失败就立即进入阻塞状态,那么可能刚进入阻塞状态就立即被唤醒进行加锁。这就会带来上下文的切换,所以轻量级锁获取锁失败时,会进行一定次数或时间的自旋尝试反复获取锁。如果失败则再进入阻塞。

  • 当发生多个线程竞争时,偏向锁会变为轻量级锁,锁标志位为00
  • 获得锁的线程会先将偏向锁撤销(在安全点),并在栈桢中创建锁记录LockRecord,对象的MarkWord被复制到刚创建的LockRecord,然后CAS尝试将记录LockRecord的owner指向锁对象,再将锁对象的MarkWord指向锁,加锁成功
  • 如果CAS加锁失败,线程会自旋一定次数加锁,再失败则升级为重量级锁

3.5 重量级锁(Synchronize 基于监视器实现的锁机制)

  • 竞争线程激烈,锁则继续膨胀,变为重量级锁,也是互斥锁,锁标志位为10,MarkWord其余内容被替换为一个指向对象锁Monitor的指针

3.6 锁粗化

多次加锁操作在JVM内部也是种消耗,如果多个加锁可以合并为一个锁,就可减少不必要的开销。例如一个方法中将代码分成两个加锁的代码块并且是同一个锁对象,则可以合并为一次加锁过程。

3.7 锁消除

如果涉及变量只是一个线程的栈变量,不是共享变量,编译器会尝试消除锁

3.8 分段锁

分段锁不是真正的某种锁,而是使用锁的一种方式;主要就是将大对象拆成小对象,对大对象的加锁变成了对小对象的加锁,避免锁住整个对象。CurrentHashMap 就是这种操作

你可能感兴趣的:(#,JAVA基础,java,并发编程,ObjectMonitor,轻量级锁,偏向锁,重量级锁,synchronize)