Synchronized原理

Java对象头

通常我们的Java对象在内存中都由两部分组成:对象头 + 对象中的成员变量,什么是对象头?
在32位虚拟机中:
普通对象对象头占8字节:
image.png
数组对象对象头占8 + 4字节
image.png
Mark Word:
用于存储对象的运行时信息,包括对象的类型、锁状态、GC 分代年龄等
image.png
Class Word:
指针,指向了这个对象从属的类对象
:::info
💡从上面可以看到,一个对象实际上对象头占用了不少空间,比如说Integer,一个int在内存中占4字节,在32位虚拟机,Integer在内存中占用的空间为8字节(对象头) + 4字节(value)。在内存很敏感的情况下多用基本类型少用包装类型
https://stackoverflow.com/questions/26357186/what-is-in-java-object-header
:::
在64为虚拟机中:
image.png

Java 对象的大小与操作系统的位数有关。在 32 位操作系统中,Java 对象头的大小为 8 字节;在 64 位操作系统中,Java 对象头的大小取决于 JDK 版本。在 JDK 8 中,64 位操作系统的 Java 对象头的大小为 16 字节;在 JDK 11 中,64 位操作系统的 Java 对象头的大小为 12 字节
image.png

Monitor(锁)

monitor通常被翻译为监视器或者操作系统中的管程
每个Java对象都可以关联一个Monitor对象,什么时候关联?当我们调用synchronized关键字给对象尝试加锁时就会尝试将monitor和我们的对象进行关联。monitor对象是操作系统提供的。关联时Java对象的对象头的Mark Word中就被设置指向Monitor对象的指针,对象头变化:
image.png =》image.png
图示:

  • 刚开始 Monitor 中 Owner 为 null
  • 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一 个 Owner
  • 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入 EntryList(等待/阻塞队列,链表结构) BLOCKED 状态
  • Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时是非公平的
  • 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程

image.png

从字节码的角度分析在字节码指令上对monitor锁的体现
static final Object lock = new Object();
static int counter = 0;

public static void main(String[] args) {
    synchronized (lock) {
        counter++;
    }
}

public static void main(java.lang.String[]);
 descriptor: ([Ljava/lang/String;)V
 flags: ACC_PUBLIC, ACC_STATIC
 Code:
 	stack=2, locals=3, args_size=1
 		0: getstatic #2 // <- 拿到lock引用,将来会关联monitor对象
 		3: dup
 		4: astore_1     // 将lock引用存到临时变量slot 1里
 		5: monitorenter // 将 lock对象 MarkWord 置为 Monitor 指针
 		6: getstatic #3 // <- i
 		9: iconst_1     // 准备常数 1
 		10: iadd        // +1
 		11: putstatic #3 // -> i
 		14: aload_1      // 拿到临时变量slot 1里的lock引用
 		15: monitorexit  // 将 lock对象 MarkWord 重置(重置回hashcode分代年龄等), 唤醒 EntryList
 		16: goto 24      // 正常执行时,直接return
 		19: astore_2     // 临界区发生异常e 存入 slot 2 
 		20: aload_1      // 拿到临时变量slot 1里的lock引用
 		21: monitorexit  // 将 lock对象 MarkWord 重置, 唤醒 EntryList
 		22: aload_2      // 加载 slot 2 (e)
 		23: athrow       // throw e
 		24: return
 	Exception table:
 		from   to  target  type
 		 6     16    19    any  (防止出现异常时,加了synchronized锁不能解锁)
 		 19    22    19    any

我们程序中加锁时,每个对象会关联一个Monitor,Monitor是真正的锁,它由操作系统提供,使用成本较高,从Java6开始对synchronized获取锁的方式进行了改进,从直接使用Monitor锁改成了可以使用轻量级锁、偏向锁进行优化…

轻量级锁

轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争,如果有竞争轻量级锁会升级为重量级锁),那么可以 使用轻量级锁来优化,轻量级锁不需要monitor锁,使用线程栈帧中的锁记录充当轻量级锁
轻量级锁对使用者是透明的,即语法仍然是synchronized,调synchronized的时候会优先使用轻量级锁去加锁,如果轻量级锁加锁失败,会用重量级锁加锁
假设有两个方法同步块,利用同一个对象加锁

static final Object obj = new Object();
public static void method1() {
 synchronized( obj ) {
 	// 同步块 A
 	method2();
 }
}
public static void method2() {
 synchronized( obj ) {
 	// 同步块 B
 }
}

上面这段代码在加锁的流程上是怎样的:

  1. 创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word

image.png

  1. 让锁记录中 Object reference 指向锁对象,并尝试用 cas 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录,解锁时恢复回去

image.png

  1. 如果 cas 替换成功,对象头中存储了锁记录地址和状态 00 ,表示由该线程给对象加锁

image.png

  1. 如果 cas 失败,有两种情况
  • 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
  • 如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数,数据不再存储Mark Word,会存null,,表示锁重入的计数

image.png

  1. 当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重 入计数减一

image.png

  1. 当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象头
  • 成功,则解锁成功
  • 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

锁膨胀(亲两级锁升级为重量级锁)

如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁
假设有两个线程Thread-0和Thread-1同时执行下面代码,过程如下:

static Object obj = new Object();
public static void method1() {
 synchronized( obj ) {
 // 同步块
 }
}
  • 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁

image.png

  • 这时 Thread-1 加轻量级锁失败,进入锁膨胀流程
    • 即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址
    • 然后自己进入 Monitor 的 EntryList BLOCKED

image.png

  • 当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程
自旋优化(重量级锁竞争时的优化)

重量级锁竞争的时候,还可以使用自旋来进行优化,自旋即:让线程先不进入阻塞entrylist,而是先进行几次循环,如果在循环过程中持锁的线程释放锁了就可以获取锁不用进入阻塞,避免上下文切换的发生(如果自旋多次仍然拿不到锁就进入阻塞)。

  • 自旋需要使用cpu,适合多核cpu的场景,如果是单核没有意义。
  • 在 Java 6 之后自旋锁是JVM调整为自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
  • Java 7 之后不能控制是否开启自旋功能 ,由JVM底层控制

偏向锁

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作
例如:

static final Object obj = new Object();
public static void m1() {
 synchronized( obj ) {
 	// 同步块 A
 	m2();
 }
}
public static void m2() {
 synchronized( obj ) {
 	// 同步块 B
 	m3();
 }
}
public static void m3() {
 synchronized( obj ) {
     // 同步块 C
 }
}

image.png
Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后加锁的时候发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有
image.png

偏向状态

对象头格式
image.png

  • 一个对象创建时: 如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为 101,这时它的 thread、epoch、age 都为 0
  • 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 - XX:BiasedLockingStartupDelay=0 来禁用延迟,添加 VM 参数 -XX:-UseBiasedLocking 禁用偏向锁,使用场景:明显有线程竞争时,可以禁用偏向锁
  • 如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后 3 位为 001,这时它的 hashcode、 age 都为 0,第一次用到 hashcode 时才会赋值

测试偏向锁:

// 添加虚拟机参数 -XX:BiasedLockingStartupDelay=0 
public static void main(String[] args) throws IOException {
    Dog d = new Dog();
    // 利用 jol 第三方工具来查看对象头信息
    ClassLayout classLayout = ClassLayout.parseInstance(d);
    new Thread(() -> {
        log.debug("synchronized 前");
        System.out.println(classLayout.toPrintableSimple(true));
        synchronized (d) {
            log.debug("synchronized 中");
            System.out.println(classLayout.toPrintableSimple(true));
        }
        log.debug("synchronized 后");
        System.out.println(classLayout.toPrintableSimple(true));
    }, "t1").start();
}

测试结果:

11:08:58.117 c.TestBiased [t1] - synchronized 前
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101 
11:08:58.121 c.TestBiased [t1] - synchronized 中
00000000 00000000 00000000 00000000 00011111 11101011 11010000 00000101 
11:08:58.121 c.TestBiased [t1] - synchronized 后
00000000 00000000 00000000 00000000 00011111 11101011 11010000 00000101

处于偏向锁的对象解锁后,线程 id 仍存储于对象头中,因为偏向锁表示这个锁对象就从属于这个线程,除非其他线程又用这个锁对象才会改变

偏向锁撤销
  1. 调用对象 hashCode

偏向锁的对象头 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被撤销,变成轻量级锁,因为对象头中没地方存hashCode了, 正常状态对象一开始是没有 hashCode 的,第一次调用才生成
轻量级锁会在锁记录中记录 hashCode
重量级锁会在 Monitor 中记录 hashCode

  1. 其它线程使用对象
private static void test2() throws InterruptedException {
Dog d = new Dog();
Thread t1 = new Thread(() -> {
    synchronized (d) {
        log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
    }
    synchronized (TestBiased.class) {
        TestBiased.class.notify();
    }
    
}, "t1");
t1.start();

Thread t2 = new Thread(() -> {
    synchronized (TestBiased.class) {
        try {
            TestBiased.class.wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
    synchronized (d) {
        log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
    }
    log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
}, "t2");
t2.start();
}

输出

[t1] - 00000000 00000000 00000000 00000000 00011111 01000001 00010000 00000101 // 偏向锁
[t2] - 00000000 00000000 00000000 00000000 00011111 01000001 00010000 00000101 // 偏向锁
[t2] - 00000000 00000000 00000000 00000000 00011111 10110101 11110000 01000000 // 轻量级锁
[t2] - 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001 // 不可偏向
  1. 调用 wait/notify
  2. 因为wait/notify机制只有重量级锁才有,所以会把锁升级为重量级锁

锁消除

public void b() throws Exception {
Object o = new Object();
synchronized (o) {
    x++;
}
}

Java运行时有JIT即时编译器,会对反复调用的代码进行优化,上述代码中的o对象不会逃离方法,因此synchronized加锁没有意义,JIT会优化掉synchronized,即锁消除

  • 24
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值