并发锁机制之深入理解Synchronized & 锁膨胀 & 锁撤销

一、Java共享内存模型带来的线程安全问题

思考: 两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?

public class SyncDemo {
   
    private static int counter = 0;

    public static void increment() {
        counter++;
    }

    public static void decrement() {
        counter--;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                increment();
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                decrement();
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        //思考: counter=?
        log.info("{}", counter);
    }
}    

问题分析:
以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作。

我们可以查看 i++和 i–(i 为静态变量)的 JVM 字节码指令 ( 可以在idea中安装一个jclasslib插件)

i++的JVM 字节码指令:

getstatic i // 获取静态变量i的值 
iconst_1 // 将int常量1压入操作数栈
iadd // 自增 

i- -的JVM 字节码指令:

getstatic i // 获取静态变量i的值 
iconst_1 // 将int常量1压入操作数栈
isub // 自减 

如果是单线程以上代码是顺序执行(不会交错)没有问题。

但多线程下代码可能交错运行:
在这里插入图片描述

1.1 临界区( Critical Section)

一个程序运行多个线程本身是没有问题的,问题出在多个线程访问共享资源。

  • 多个线程读共享资源其实也没有问题
  • 在多个线程对共享资源读写操作时发生指令交错,就会出现问题

一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区,其共享资源为临界资源。

//临界资源
private static int counter = 0;

public static void increment() { //临界区
    counter++;
}

public static void decrement() {//临界区
    counter--;

1.2 竞态条件( Race Condition )

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件。

为了避免临界区的竞态条件发生,有多种手段可以达到目的:

  • 阻塞式的解决方案:synchronized,Lock
  • 非阻塞式的解决方案:原子变量

注意:

虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:
互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点

二、synchronized的使用

synchronized 同步块是 Java 提供的一种原子性内置锁,Java 中的每个对象都可以把它当作一个同步锁来使用,这些 Java 内置的使用者看不到的锁被称为内置锁,也叫作监视器锁。

2.1 加锁方式

在这里插入图片描述

2.2 解决之前的共享问题

方式一:

public static synchronized void increment() {
    counter++;
}

public static synchronized void decrement() {
    counter--;

方式二:

private static String lock = "";

public static void increment() {
    synchronized (lock){
        counter++;
    }
}

public static void decrement() {
    synchronized (lock) {
        counter--;
    }
}    

synchronized 实际是用对象锁保证了临界区内代码的原子性。
在这里插入图片描述

三、synchronized底层原理

synchronized是JVM内置锁,基于Monitor机制实现依赖底层操作系统的互斥原语Mutex(互斥量),它是一个重量级锁,性能较低。当然,JVM内置锁在1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、自适应自旋(Adaptive Spinning)等技术来减少锁操作的开销,内置锁的并发性能已经基本与Lock持平。

The Java® Language Specification
Each object is associated with a monitor (§17.1), which is used by synchronized methods (§8.4.3) and the synchronized statement (§14.19) to provide control over concurrent access to state by multiple threads (§17 (Threads and Locks)).
The Java® Virtual Machine Specification
The Java Virtual Machine supports synchronization of both methods and sequences of instructions within a method by a single synchronization construct: the monitor.

Java虚拟机通过一个同步结构支持方法和方法中的指令序列的同步:monitor。

同步方法是通过方法中的access_flags中设置ACC_SYNCHRONIZED标志来实现;

同步代码块是通过monitorentermonitorexit来实现。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。

3.1 查看synchronized的字节码指令序列

在这里插入图片描述
Method access and property flags:
在这里插入图片描述
在这里插入图片描述

3.2 Monitor(管程/监视器)

Monitor,直译为“监视器”,而操作系统领域一般翻译为“管程”。管程是指管理共享变量以及对共享变量操作的过程,让它们支持并发。在Java 1.5之前,Java语言提供的唯一并发语言就是管程,Java 1.5之后提供的SDK并发包也是以管程为基础的。除了Java之外,C/C++、C#等高级语言也都是支持管程的。synchronized关键字和wait()、notify()、notifyAll()这三个方法是Java中实现管程技术的组成部分。

3.3 MESA模型

在管程的发展史上,先后出现过三种不同的管程模型,分别是Hasen模型、Hoare模型和MESA模型。现在正在广泛使用的是MESA模型。下面我们便介绍MESA模型:

在这里插入图片描述
管程中引入了条件变量的概念,而且每个条件变量都对应有一个等待队列。条件变量和等待队列的作用是解决线程之间的同步问题。

3.3.1 wait()的正确使用姿势

对于MESA管程来说,有一个编程范式:

while(条件不满足) {
  wait();
}

唤醒的时间和获取到锁继续执行的时间是不一致的,被唤醒的线程再次执行时可能条件又不满足了,所以循环检验条件。MESA模型的wait()方法还有一个超时参数,为了避免线程进入等待队列永久阻塞。

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

满足以下三个条件时,可以使用notify(),其余情况尽量使用notifyAll():

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

3.4 Java语言的内置管程synchronized

Java 参考了 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简。MESA 模型中,条件变量可以有多个,Java 语言内置的管程里只有一个条件变量。模型如下图所示。
在这里插入图片描述

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

在这里插入图片描述
在获取锁时,是将当前线程插入到cxq的头部(cxq是一个栈结构);

而释放锁时,默认策略(QMode=0)是:如果EntryList为空,则将cxq中的元素按原有顺序插入到EntryList,并唤醒第一个线程,也就是当EntryList为空时,是后来的线程先获取锁。_EntryList不为空,直接从_EntryList中唤醒线程。

调用wait方法之后,就会将线程放入到_waitSet中,同时该线程会释放锁。而当我们再次调用notify或者notifyAll方法的时候就会根据策略,将_waitSet中的线程翻到_cxq或者_EntryList中,继续等待获取锁资源。

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

四、对象的内存布局

Hotspot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
在这里插入图片描述

  • 对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象才有)等。
  • 实例数据:存放类的属性数据信息,包括父类的属性信息;
  • 对齐填充:由于虚拟机要求对象起始地址必须是8字节的整数倍填充数据不是必须存在的,仅仅是为了字节对齐(为了实现指针压缩技术)。

4.1 对象头详解

HotSpot虚拟机的对象头包括:

Mark Word
用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称它为“Mark Word”。

Klass Pointer
对象头的另外一部分是klass类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 32位4字节,64位开启指针压缩或最大堆内存<32g时4字节,否则8字节。jdk1.8默认开启指针压缩后为4字节,当在JVM参数中关闭指针压缩(-XX:-UseCompressedOops)后,长度为8字节。

数组长度(只有数组对象有)
如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度。 4字节

在这里插入图片描述

4.2 使用JOL工具查看内存布局

给大家推荐一个可以查看普通java对象的内部布局工具JOL(JAVA OBJECT LAYOUT),使用此工具可以查看new出来的一个java对象的内部布局,以及一个普通的java对象占用多少字节。

引入maven依赖:

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.10</version>
</dependency>

测试:

public class Test1 {
    public static void main(String[] args) {
        Object obj = new Object();
        //查看对象内部信息
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());
    }
}

1、利用jol查看64位系统java对象(空对象),默认开启指针压缩,总大小显示16字节,前12字节为对象头
在这里插入图片描述

  • OFFSET:偏移地址,单位字节;
  • SIZE:占用的内存大小,单位为字节;
  • TYPE DESCRIPTION:类型描述,其中object header为对象头;
  • VALUE:对应内存中当前存储的值,二进制32位;

2、关闭指针压缩后,对象头为16字节:-XX:-UseCompressedOops
在这里插入图片描述
思考: 下面例子中obj对象占多少个字节?

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

8 + 4 + 8 + 4 = 24
markword + classpoint + 一个long类型 + 4个对齐字节


回到之前的问题: synchronized加锁加在对象上,对象是如何记录锁状态的?

锁状态被记录在每个对象的对象头的Mark Word中。

五、Mark Word是如何记录锁状态的

Hotspot通过markOop类型实现Mark Word,具体实现位于markOop.hpp文件中。由于对象需要存储的运行时数据很多,考虑到虚拟机的内存使用,markOop被设计成一个非固定的数据结构,以便在极小的空间存储尽量多的数据,根据对象的状态复用自己的存储空间。

简单点理解就是:MarkWord 结构搞得这么复杂,是因为需要节省内存,让同一个内存区域在不同阶段有不同的用处。

5.1 Mark Word的结构

//  32 bits:
//  --------
//             hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)
//             JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)
//             size:32 ------------------------------------------>| (CMS free block)
//             PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
//  64 bits:
//  --------
//  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
//  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
//  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
//  size:64 ----------------------------------------------------->| (CMS free block)
//
//  unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)
//  JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)
//  narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
//  unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)

。。。。。。
//    [JavaThread* | epoch | age | 1 | 01]       lock is biased toward given thread
//    [0           | epoch | age | 1 | 01]       lock is anonymously biased
//
//  - the two lock bits are used to describe three states: locked/unlocked and monitor.
//
//    [ptr             | 00]  locked             ptr points to real header on stack
//    [header      | 0 | 01]  unlocked           regular object header
//    [ptr             | 10]  monitor            inflated lock (header is wapped out)
//    [ptr             | 11]  marked             used by markSweep to mark an object
  • hash: 保存对象的哈希码。运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。
  • age: 保存对象的分代年龄。表示对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代。
  • biased_lock: 偏向锁标识位。由于无锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。
  • lock: 锁状态标识位。区分锁状态,比如11时表示对象待GC回收状态, 只有最后2位锁标识(11)有效。
  • JavaThread*: 保存持有偏向锁的线程ID。偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。在后面的操作中,就无需再进行尝试获取锁的动作。这个线程ID并不是JVM分配的线程ID号,和Java Thread中的ID是两个概念。
  • epoch: 保存偏向时间戳。偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁。

32位JVM下的对象结构描述:
在这里插入图片描述
64位JVM下的对象结构描述:
在这里插入图片描述

  • ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争时,JVM使用原子操作而不是OS互斥,这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的MarkWord中设置指向锁记录的指针。
  • ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针

5.2 Mark Word中锁标记枚举

enum { 
	locked_value             = 0,    //00 轻量级锁 
	unlocked_value           = 1,   //001 无锁
	monitor_value            = 2,   //10 监视器锁,也叫膨胀锁,也叫重量级锁
	marked_value             = 3,   //11 GC标记
	biased_lock_pattern      = 5    //101 偏向锁
}

更直观的理解方式:
在这里插入图片描述

六、利用JOL工具跟踪锁标记变化

6.1 偏向锁

偏向锁是一种针对加锁操作的优化手段,经过研究发现, 在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了消除数据在无竞争情况下锁重入(CAS操作)的开销而引入偏向锁。对于没有锁竞争的场合,偏向锁有很好的优化效果。

/***StringBuffer内部同步***/
public synchronized int length() { 
   return count; 
} 

//System.out.println 无意识的使用锁 
public void println(String x) { 
  synchronized (this) {
     print(x); newLine(); 
  } 
}

当JVM启用了偏向锁模式(jdk6默认开启),新创建对象的Mark Word中的Thread Id为0,说明此时处于可偏向但未偏向任何线程,也叫做匿名偏向状态(anonymously biased)

偏向锁延迟偏向

偏向锁模式存在偏向锁延迟机制:HotSpot 虚拟机在启动后有个 4s 的延迟才会对每个新建的对象开启偏向锁模式。JVM启动时会进行一系列的复杂活动,比如装载配置,系统类初始化等等。在这个过程中会使用大量synchronized关键字对对象加锁,且这些锁大多数都不是偏向锁(为了减少偏向锁升级的时间)。为了减少初始化时间,JVM默认延时加载偏向锁。

验证:

@Slf4j
public class LockEscalationDemo{
    public static void main(String[] args) throws InterruptedException {
        log.debug(ClassLayout.parseInstance(new Object()).toPrintable());
        Thread.sleep(4000);
        log.debug(ClassLayout.parseInstance(new Object()).toPrintable());
    }
}   

在这里插入图片描述
刚开始的时候可以看到是无锁状态001 ,4s后默认会开启偏向锁,查看4s后锁状态确实修改为了101,证明了偏向锁是JVM启动后4s才会生效。但是我们是否会好奇,4s后也没有其他线程来竞争锁,为什么4s后锁就变成了偏向锁呢?

其实我们是可以通过JVM参数来指定偏向锁的延迟时间的。

//关闭延迟开启偏向锁
-XX:BiasedLockingStartupDelay=0
//禁止偏向锁
-XX:-UseBiasedLocking 
//启用偏向锁

我们现在将偏向锁延迟时间设置为0,再来测试一下如下代码:

@Slf4j
public class LockEscalationDemo {

    public static void main(String[] args) throws InterruptedException {
        Object obj = new Object();
        log.debug(ClassLayout.parseInstance(obj).toPrintable());
        // 加锁
        new Thread(()-> {
            synchronized (obj) {
                log.debug(ClassLayout.parseInstance(obj).toPrintable());
            }
        }, "thread-a").start();
    }
}

在这里插入图片描述
当关闭偏向锁延迟后,对象被创建后就会处于匿名偏向状态。之后,如果有线程来竞争并获得这把锁之后,就会将偏向锁对应的线程id从0修改为其线程id,此时处于真正的偏向锁状态。

现在我们也就能够理解为什么要有偏向锁延迟了,为了提升性能,因为一开始没必要让创建的对象就直接升级为偏向锁,这样可以减少启动时间。这样创建对象的锁状态就是无锁,如果后面没有锁竞争,对象状态就一直是无锁状态。

偏向锁状态跟踪

思考:如果对象调用了hashCode,还会开启偏向锁模式吗?

@Slf4j
public class LockEscalationDemo6 {

    public static void main(String[] args) throws InterruptedException {
        Thread.sleep(5000);
        Object obj = new Object();
        log.debug(ClassLayout.parseInstance(obj).toPrintable());
        obj.hashCode();
        log.debug(ClassLayout.parseInstance(obj).toPrintable());
        new Thread(()-> {
            synchronized (obj) {
                log.debug(ClassLayout.parseInstance(obj).toPrintable());
            }
        }, "thread-a").start();
    }
}

在这里插入图片描述
我们发现,5s延迟后创建对象一开始锁状态是匿名偏向,这个没问题!但是当我们调用hashcode方法之后,发现此时锁状态竟然撤销为无锁状态了。这是为什么呢?我们先来看看对象头中是如何存储锁状态的:
在这里插入图片描述
可以看到,当处于偏向锁状态的时候,是没有存储对象的hashCode的。所以此时如果调用对象的hashCode方法,就不得不将偏性锁撤销到无锁状态(因为无锁状态中有地方记录了hashCode)去获取hashCode。

调用锁对象的obj.hashCode()或System.identityHashCode(obj)方法会导致该对象的偏向锁被撤销。因为对于一个对象,其HashCode只会生成一次并保存,偏向锁是没有地方保存hashcode的。

那为什么当我们再次加锁的时候,锁直接升级为了轻量级锁呢?此时只有一个线程竞争,不应该升级为轻量级锁吗?
因为之前已经将锁撤销为无锁状态,已经存储了hashCode的值。所以后面升级锁的时候也要存储这个hachCode值,轻量级锁中hashCode存储在锁记录中,重量级锁中hashCode存储在monitor中。


注意,如果我们在延迟偏向锁延迟期间创建完对象,此时锁状态是无锁状态,但是此时也是没有记录hashCode的,只有当我们显示调用hashCode方法之后,才会将hashCode记录在对象头中:

Object obj = new Object();
log.debug(ClassLayout.parseInstance(obj).toPrintable());
obj.hashCode();
log.debug(ClassLayout.parseInstance(obj).toPrintable());

在这里插入图片描述

偏向锁撤销之调用对象HashCode

调用锁对象的obj.hashCode()或System.identityHashCode(obj)方法会导致该对象的偏向锁被撤销。因为对于一个对象,其HashCode只会生成一次并保存,偏向锁是没有地方保存hashcode的。

  • 轻量级锁会在锁记录中记录 hashCode
  • 重量级锁会在 Monitor 中记录 hashCode

当对象处于可偏向(也就是线程ID为0)和已偏向的状态下,调用HashCode计算将会使对象再也无法偏向:

  • 当对象可偏向时,MarkWord将变成未锁定状态,并只能升级成轻量锁;
  • 当对象正处于偏向锁时,调用HashCode将使偏向锁强制升级成重量锁。

在这里插入图片描述

@Slf4j
public class LockEscalationDemo7 {

    public static void main(String[] args) throws InterruptedException {
        Thread.sleep(5000);
        Object obj = new Object();
        log.debug(ClassLayout.parseInstance(obj).toPrintable());
        new Thread(()-> {
            synchronized (obj) {
                log.debug(ClassLayout.parseInstance(obj).toPrintable());
                obj.hashCode();
                log.debug(ClassLayout.parseInstance(obj).toPrintable());
            }
        }, "thread-a").start();
    }
}

在这里插入图片描述

偏向锁撤销之调用wait/notify

偏向锁状态执行obj.notify() 会升级为轻量级锁。
调用obj.wait(timeout) 会升级为重量级锁,因为wait方法是基于monitor机制实现的。

obj.notify()测试:

@Slf4j
public class LockEscalationDemo8 {

    public static void main(String[] args) throws InterruptedException {
        Thread.sleep(5000);
        Object obj = new Object();
        log.debug("匿名偏向状态 =====================" + "\n" + ClassLayout.parseInstance(obj).toPrintable());
        new Thread(()-> {
            synchronized (obj) {
                log.debug("【" + Thread.currentThread().getName() + "】" + "获取锁执行中。。。\n" + ClassLayout.parseInstance(obj).toPrintable());
                try {
                    obj.notify();
                    log.debug("【" + Thread.currentThread().getName() + "】"  + "调用notify 之后。。。\n" + ClassLayout.parseInstance(obj).toPrintable());
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }, "thread-a").start();
    }
}

在这里插入图片描述

obj.wait(timeout) 测试:

@Slf4j
public class LockEscalationDemo9 {

    public static void main(String[] args) throws InterruptedException {
        Thread.sleep(5000);
        Object obj = new Object();
        log.debug("匿名偏向状态 =====================" + "\n" + ClassLayout.parseInstance(obj).toPrintable());
        new Thread(()-> {
            synchronized (obj) {
                log.debug("【" + Thread.currentThread().getName() + "】" + "获取锁执行中。。。\n" + ClassLayout.parseInstance(obj).toPrintable());
                try {
                    obj.wait();
                    log.debug("【" + Thread.currentThread().getName() + "】"  + "调用wait 之后。。。\n" + ClassLayout.parseInstance(obj).toPrintable());
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }, "thread-a").start();


        new Thread(()-> {
            synchronized (obj) {
                log.debug("【" + Thread.currentThread().getName() + "】" + "获取锁执行中。。。\n" + ClassLayout.parseInstance(obj).toPrintable());
                try {
                    obj.notify();
                    log.debug("【" + Thread.currentThread().getName() + "】"  + "调用wait() 之后。。。\n" + ClassLayout.parseInstance(obj).toPrintable());
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }, "thread-b").start();
    }
}

在这里插入图片描述

注意: 偏向锁解锁之后不会变成无锁状态,如果线程1获取了锁资源之后升级为偏向锁,此时线程2来竞争的时候,如果线程1恰好释放了锁资源,那么此时这个锁对象的装填依然是偏向锁!即线程1释放锁之后这个对象的锁状态依然是偏向锁状态。(只有很长时间没有竞争的时候,GC线程才会将这个锁撤销为无锁)

6.2 轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段,此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间多个线程长时间访问同一把锁代码块中的代码执行很快的话,cas比较容易成功,那么即使有多个线程也不会立刻升级为重量级锁。只有经过多次自旋之后都无法获取都锁资源,才会膨胀为重量级锁。轻量级锁适合线程交替执行。)的场合,就会导致轻量级锁膨胀为重量级锁。

轻量级锁相较于重量级锁的优点:轻量级锁只会在对象的markword中CAS修改锁记录,无需切换到内核态去阻塞线程。

偏向锁升级轻量级锁

模拟两个线程轻微竞争场景:

@Slf4j
public class LockEscalationDemo10 {

    public static void main(String[] args) throws InterruptedException {
        //HotSpot 虚拟机在启动后有个 4s 的延迟才会对每个新建的对象开启偏向锁模式
        Thread.sleep(5000);
        Object obj = new Object();
        log.debug("匿名偏向状态 =====================" + "\n" + ClassLayout.parseInstance(obj).toPrintable());
        new Thread(() -> {
            synchronized (obj) {
                log.debug(Thread.currentThread().getName() + "获取锁执行中。。。\n"
                        + ClassLayout.parseInstance(obj).toPrintable());
            }
        }, "thread-a").start();

        //控制线程竞争时机,否则可能直接升级为重量级锁...
        Thread.sleep(1);

        new Thread(() -> {
            synchronized (obj) {
                log.debug(Thread.currentThread().getName() + "获取锁执行中。。。\n"
                        + ClassLayout.parseInstance(obj).toPrintable());
            }
        }, "thread-b").start();

        // 睡眠5s后
        Thread.sleep(5000);
        log.debug(Thread.currentThread().getName() + ClassLayout.parseInstance(obj).toPrintable());
    }
}

在这里插入图片描述
注意:上面的测试代码并不是每次都能成功,因为每次的竞争都是不一样的。

这里需要注意,锁是可以撤销成无锁状态的,但是锁是不能降级的!比如无法从轻量级锁降级为偏向锁。只有等到锁竞争结束后,GC线程会将其撤销为无锁状态

轻量级锁竞争过程分析

轻量级锁有一个锁记录(锁记录存储在markword中),每一个线程都有一个线程栈,获取锁成功的线程会将这个markwork复制到自己的线程栈中(即锁记录指针指向线程栈。

此时如果有另一个线程也来竞争锁资源,此时因为锁记录指针已经指向了某个线程栈中,所以当前线程是无法获取到锁资源的。除非第一个线程将锁释放(断开指针),此时锁会撤销为无锁状态,会将锁记录指针设置为NULL。

这时候第二个线程再来竞争锁资源的话,就能够使用CAS(NULL, thread2-线程栈地址)将锁记录由NULL修改为自己的线程栈地址。这样线程2就竞争到了锁资源。

所以说,轻量级锁的切换,中间必须要先消除到无锁状态!否则CAS操作是永远无法成功的,因为CAS的时候期待的锁记录是NULL。

因为不同的线程无法知道其他线程的线程栈地址,所以CAS的时候只能用NULL去比较,这也是为什么轻量级锁竞争必须要先撤销到无锁状态的原因。
在这里插入图片描述

轻量级锁递归锁定(锁重入分析)

在这里插入图片描述
第一次升级为轻量级锁后,该线程会将锁对象的markword复制一份到自己的线程栈中,将其保存成Lock Record锁记录中的displaced word。并且将锁对象markword中的ptr_to_lock_record指针指向当前Lock Record中的obj。

如果再次重入,就不需要再去复制一次markword了,只需要再创建一个Lock Record锁记录,然后将器displaced word内容设置为NULL,然后将锁对象中的markword中的ptr_to_lock_record指向当前Lock Record的obj(锁对象markword中的ptr_to_lock_record指针要先断开第一个Lock Record中的obj指针,然后连接到第二个Lock Record中的obj)。

当释放锁的时候,Lock Record会出栈,并将锁对象markword中的ptr_to_lock_record指针指向下一个Lock Record中的obj。直到最后一次的时候,锁对象markword中的ptr_to_lock_record指针不再指向任何锁记录,并且会将displaced word还原到锁对象中的markword去。

6.3 轻量级锁膨胀为重量级锁

@Slf4j
public class LockEscalationDemo11 {
    public static void main(String[] args) throws InterruptedException {

        log.debug(ClassLayout.parseInstance(new Object()).toPrintable());
        //HotSpot 虚拟机在启动后有个 4s 的延迟才会对每个新建的对象开启偏向锁模式
        Thread.sleep(5000);
        Object obj = new Object();

        new Thread(() -> {
            log.debug(Thread.currentThread().getName()+"开始执行。。。\n"
                    +ClassLayout.parseInstance(obj).toPrintable());
            synchronized (obj){
                log.debug(Thread.currentThread().getName()+"获取锁执行中。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
            }
            log.debug(Thread.currentThread().getName()+"释放锁。。。\n"
                    +ClassLayout.parseInstance(obj).toPrintable());
        },"thread1").start();

        new Thread(() -> {
            log.debug(Thread.currentThread().getName()+"开始执行。。。\n"
                    +ClassLayout.parseInstance(obj).toPrintable());
            synchronized (obj){
                log.debug(Thread.currentThread().getName()+"获取锁执行中。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
            }
            log.debug(Thread.currentThread().getName()+"释放锁。。。\n"
                    +ClassLayout.parseInstance(obj).toPrintable());
        },"thread2").start();

        Thread.sleep(5000);
        log.debug(ClassLayout.parseInstance(obj).toPrintable());
    }
}

在这里插入图片描述

注意:轻量级锁和重量级锁拷贝锁对象的markword的原因除了做CAS操作之外,还是为了还原锁状态!比如无锁状态下可能是有hashcode值的,那么锁撤销之后必须回复。

我们知道synchronized是可重入的,第一次获取到锁之后会复制一份markwork,后面重入的时候不会再复制了,而是会向栈中增加一个NULL。

思考:重量级锁释放之后变为无锁,此时有新的线程来调用同步块,会获取什么锁?

七、总结

锁对象状态转换

在这里插入图片描述

加锁、撤销锁关系(仔细研究!!!)

在这里插入图片描述
1、偏向锁解锁后依然是偏向锁

2、偏向锁撤销后有可能是无锁、轻量级锁、重量级锁,不同的情况撤销后结果不同(参考上图)

3、轻量级锁是如何存储hashCode的?会在线程栈中创建一个锁记录,然后将无锁状态下的markword拷贝一份,无锁的markword中存有hashCode。并且这个markword还要用于锁撤销后的还原,如果轻量级锁解锁为无锁状态,直接将拷贝的markword CAS修改到锁对象的markword中即可。

4、重量级锁撤销之后是无锁状态,撤销锁之后会清除创建的monitor对象并修改markword,这个过程需要一段时间。Monitor对象是通过GC来清除的。GC清除掉monitor对象之后,就会撤销为无锁状态。

5、无锁状态可以直接升级为重量级锁,当竞争激烈的时候,CAS失败导致升级为轻量级锁失败,会直接升级为重量级锁。

后面会有源码验证上面的说法,大家可以关注一下后面的文章。

  • 3
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值