1. Java对象头

以 32 位虚拟机为例,普通对象的对象头结构如下,其中的Klass Word为指针,指向对应的Class对象;
在这里插入图片描述
数组对象:
在这里插入图片描述
其中 Mark Word 结构为:

  • 1 01代表偏向状态
  • 00代表轻量级锁,其中的mark word存放的是锁记录
  • 10代表重量级锁,其中的mark word存放的是monitor地址

在这里插入图片描述
所以一个对象的结构如下:
在这里插入图片描述

2. Monitor(锁)

Monitor被翻译为监视器管程

每个java对象都可以关联一个monitor对象,如果使用synchronized给对象上锁(重量级)之后,该对象头的Mark Word中就被设置指向Monitor对象的指针

monitor结构如下:
在这里插入图片描述

  • 刚开始时Monitor中的Owner为null
  • 当Thread-2 执行synchronized(obj){}代码时就会将Monitor的所有者Owner 设置为 Thread-2,上锁成功,Monitor中同一时刻只能有一个Owner
  • 当Thread-2 占据锁时,如果线程Thread-3,Thread-4也来执行synchronized(obj){}代码,就会进入EntryList中变成BLOCKED状态
  • Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争时是非公平的
  • 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲wait-notify 时会分析

注意

  • synchronized 必须是进入同一个对象的 monitor 才有上述的效果
  • 不加 synchronized 的对象不会关联监视器,不遵从以上规则

3.synchronized原理

示例一段代码:

static final Object lock=new Object();
static int counter = 0;
public static void main(String[] args) {
    synchronized (lock) {
        counter++;
    }
}

对应的main方法中的字节码为:

 0 getstatic #2 <com/concurrent/test/Test17.lock>	// 取得lock的引用(synchronized开始了)
 3 dup    											// 复制操作数栈栈顶的值放入栈顶,即复制了一份lock的引用
 4 astore_1											// 操作数栈栈顶的值弹出,即将lock的引用存到局部变量表中
 5 monitorenter										// 将lock对象的Mark Word置为指向Monitor指针
 6 getstatic #3 <com/concurrent/test/Test17.counter>// 以下执行counter++操作
 9 iconst_1
10 iadd
11 putstatic #3 <com/concurrent/test/Test17.counter>
14 aload_1											// 从局部变量表中取得lock的引用,放入操作数栈栈顶
15 monitorexit										// 将lock对象的Mark Word重置,唤醒EntryList
16 goto 24 (+8)										// 下面是异常处理指令,可以看到,如果出现异常,也能自动地释放锁
19 astore_2
20 aload_1
21 monitorexit
22 aload_2
23 athrow
24 return

可以看出在执行同步代码块之前之后都有一个monitor字样,其中前面的是monitorenter,后面的是离开monitorexit,不难想象一个线程也执行同步代码块,首先要获取锁,而获取锁的过程就是monitorenter ,在执行完代码块之后,要释放锁,释放锁就是执行monitorexit指令。

3.1 为什么会有两个monitorexit呢?

这个主要是防止在同步代码块中线程因异常退出,而锁没有得到释放,这必然会造成死锁(等待的线程永远获取不到锁)。因此最后一个monitorexit是保证在异常情况下,锁也可以得到释放,避免死锁。
仅有ACC_SYNCHRONIZED这么一个标志,该标记表明线程进入该方法时,需要monitorenter,退出该方法时需要monitorexit。

3.2 synchronized可重入的原理

重入锁是指一个线程获取到该锁之后,该线程可以继续获得该锁。底层原理维护一个计数器,当线程获取该锁时,计数器加一,再次获得该锁时继续加一,释放锁时,计数器减一,当计数器值为0时,表明该锁未被任何线程所持有,其它线程可以竞争获取锁。

4. 轻量级锁

轻量级锁使用的场景:如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。

轻量级锁对使用者是透明的,即使语法仍然是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和对象引用reference地址
    在这里插入图片描述

  2. 让锁记录中的Object reference指向对象,并且尝试用cas(compare and sweep)替换Object对象的Mark Word ,将Mark Word 的值存入锁记录中
    在这里插入图片描述

  3. 如果cas替换成功,那么对象的对象头储存的就是锁记录的地址和状态00(01代表没有锁住,00代表轻量级锁,见上面),而锁记录中存放的就是对象的相关信息,如下所示
    在这里插入图片描述

  4. 如果CAS失败,有两种情况

    • 如果是其它线程已经持有了该Object的轻量级锁,那么表示有竞争,将进入锁膨胀阶段
    • 如果是自己的线程已经对用一个对象执行了synchronized进行加锁(即自己执行了synchronized锁重入),那么再添加一条 Lock Record 作为重入的计数
      在这里插入图片描述
  5. 当线程退出synchronized代码块的时候,如果获取的是取值为 null 的锁记录 ,表示有重入,这时重置锁记录,表示重入计数减一
    在这里插入图片描述

  6. 当线程退出synchronized代码块的时候,如果获取的锁记录取值不为 null,那么使用cas将Mark Word的值恢复给对象

    • 成功则解锁成功
    • 失败,则说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

5. 锁膨胀(锁升级)

如果在尝试加轻量级锁的过程中,cas操作无法成功,这是有一种情况就是其它线程已经为这个对象加上了轻量级锁,这是就要进行锁膨胀,将轻量级锁变成重量级锁。

static object obj = new Object();
public static void method1() {
	synchronized (obj) {
		// 同步块
	}
}
  1. 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
    在这里插入图片描述

  2. 这时 Thread-1 加轻量级锁失败(交换失败),进入锁膨胀流程

    • 即为Object对象申请monitor锁,让object指向重量级锁地址
    • 然后自己进入monitor的entryList BOLCKED
      在这里插入图片描述
  3. 当Thread-0 退出synchronized同步块时,使用cas将Mark Word的值恢复给对象头,失败,那么会进入重量级锁的解锁过程,即按照Monitor的地址找到Monitor对象,将Owner设置为null,唤醒EntryList 中的Thread-1线程。

锁的升级的目的:锁升级是为了减低了锁带来的性能消耗。在 Java 6 之后优化 synchronized 的实现方式,使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,从而减低了锁带来的性能消耗。

6. 自旋优化(反复尝试获取锁)

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即在自旋的时候持锁的线程释放了锁),那么当前线程就可以不用进行上下文切换就获得了锁(即避免上下文切换带来的开销)

  1. 自旋成功的情况:
    在这里插入图片描述

  2. 自旋失败的情况:
    在这里插入图片描述

  3. 自旋注意点:

    • 在java6之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能
    • 自旋会占用CPU时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势
    • Java7之后不能控制是否开启自旋功能

7. 偏向锁

在轻量级的锁中,如果同一个线程对同一个对象进行重入锁时,也需要执行CAS操作,有点浪费时间和性能;
那么java6开始引入了偏向锁来做进一步优化,只有第一次使用CAS时将对象的Mark Word头设置为入锁线程ID,之后这个线程再进行重入锁时,发现线程ID是自己的,那么就不用再进行CAS了。以后只要不发生竞争,这个对象就归线程所有

在这里插入图片描述

7.1 偏向状态

在这里插入图片描述
一个对象的创建过程

  1. 如果开启了偏向锁(默认是开启的),那么对象刚创建之后,Mark Word 值为0x05即最后三位的值101,这时它的Thread,epoch,age都是0,在加锁的时候进行设置这些的值。

  2. 偏向锁默认是延迟的,不会在程序启动的时候立刻生效,如果想避免延迟,可以添加虚拟机参数来禁用延迟:-XX:BiasedLockingStartupDelay=0来禁用延迟

  3. 注意:处于偏向锁的对象解锁后,线程 id 仍存储于对象头中

  4. 测试偏向过程:(三次都是101偏向)
    在这里插入图片描述

  5. 测试禁用:在上面测试代码运行时在添加VM参数-xx:-UseBiasedLocking禁用偏向锁;(开始是001正常,然后是00轻量级锁,最后又变成001正常)
    在这里插入图片描述

7.2 撤销偏向锁:

7.2.1 hashcode撤销;

当调用对象的hashcode方法的时候就会撤销这个对象的偏向锁;

因为正常情况下,对象的hash码只有在用的时候才会产生,没有用之前就都是0,第一次调用了对象的hashcode方法时,才会给对象产生hash码,并且填充到对象头的markword中;而如果处于偏向状态,只能存一个线程id,再想存对象的hashcode码就存不下了

  • 轻量级锁会在锁记录中记录hashcode
  • 重量级锁会在monitor中记录hashcode
    在这里插入图片描述
7.2.2 其他线程使用对象

当有其他线程使用偏向锁对象时,会将偏向锁升级为轻量级锁
在这里插入图片描述
结果:
在这里插入图片描述

7.2.3 调用wait/notify

因为只有重量级锁才有wait/nofity机制

7.3 批量重偏向

如果对象被多个线程访问,但是没有竞争,这时候偏向了线程一的对象又有机会重新偏向线程二,即可以不用升级为轻量级锁,重偏向或重置对象的ThreadID;

可这和我们之前做的实验矛盾了呀,其实要实现重新偏向是要有条件的:就是超过20对象对同一个线程如线程一撤销偏向时,那么第20个及以后的对象才可以将撤销对线程一的偏向这个动作变为将第20个及以后的对象偏向线程二。

7.4 批量撤销

当撤销偏向锁阈值超过40次后,jvm会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的

8. 锁消除

看一段代码:代码中有2个方法,都是对x进行++操作,一个对局部对象加了锁,一个就是正常的方法:
在这里插入图片描述
执行查看结果:
在这里插入图片描述

8.1 原因:

因为jvm中有JIT(即时编译器),会对java字节码进一步优化,比如上述代码中对++操作重复执行变成热点代码,JIT就会过来优化,JIT看到o对象是一个局部变量所以加锁操作并不会被共享,JIT就会把这个加锁操作优化掉,执行时是没有加锁操作的

8.2 关闭锁消除

在这里插入图片描述

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐