侧边栏壁纸
  • 累计撰写 98 篇文章
  • 累计创建 20 个标签
  • 累计收到 3 条评论

Synchronized原理探究

林贤钦
2020-05-24 / 0 评论 / 12 点赞 / 719 阅读 / 0 字
温馨提示:
本文最后更新于 2020-07-15,若内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

Synchronized原理探究

我们通常会谈到 synchronized 这个关键字。它翻译成中⽂就是“同步”的意思。

1、 基本使用

使⽤ synchronized 关键字来给⼀段代码或⼀个⽅法上锁有以下三种形式

  1. 普通方法 :锁对象是this,所谓的方法锁(本质上属于对象锁)

    // 关键字在实例⽅法上,锁为当前实例
    public synchronized void instanceLock() {
     	// code
    }
    
  2. 同步静态方法:锁对象是当前类的Class对象,即(XXX.class),所谓的类锁

    // 关键字在静态⽅法上,锁为当前Class对象
    public static synchronized void classLock() {
     	// code
    }
    
  3. 同步代码块(方法中):锁对象是synchronized(obj)的对象,所谓的对象锁

    // 关键字在代码块上,锁为括号⾥⾯的对象
    Object obj = new Object();
    public void blockLock() {
         synchronized (obj) {
        	 // code
         }
    }
    

在上⾯的例⼦中,如果 synchronized 关键字在⽅法上,那临界区就是整个⽅法内部。⽽如果是使⽤synchronized代码块,那临界区就指的是代码块内部的区域。

“临界区”的概念

每个进程中访问临界资源的那段代码称为临界区(Critical Section)(临界资源是一次仅允许一个进程使用的共享资源)。每次只准许一个进程进入临界区,进入后不允许其他进程进入。

下⾯这两个写法其实是等价的作⽤

// 关键字在实例⽅法上,锁为当前实例
public synchronized void instanceLock() {
 	// code
}
// 关键字在代码块上,锁为括号⾥⾯的对象
public void blockLock() {
     synchronized (this) {
     // code
     }
}

同理,下⾯这两个⽅法也应该是等价的:

// 关键字在静态⽅法上,锁为当前Class对象
public static synchronized void classLock() {
 	// code
}
// 关键字在代码块上,锁为括号⾥⾯的对象
public void blockLock() {
     synchronized (this.getClass()) {
     // code
     }
}

Synchronized的作用主要有三个

  1. 原子性:确保线程互斥的访问同步代码;
  2. 可见性:保证共享变量的修改能够及时可见
  3. 有序性:有效解决重排序问题

在了解synchronized的原理前,我觉得应该先了解Java的对象头,因为Java的锁都是基于对象的。

2 、Java对象头

每个Java对象都有对象头。如果是⾮数组类型,则⽤2个字宽来存储对象头,如果是数组,则会⽤3个字宽来存储对象头。

在32位处理器中,⼀个字宽是32位;在64位虚拟机中,⼀个字宽是64位。

对象头的内容如下表:

⻓度内容说明
32/64bitMark Word存储对象的hashCode或锁信息等
32/64bitClass Metadata Address存储到对象类型数据的指针
32/64bitArray length数组的⻓度(如果是数组)

我们主要来看看Mark Word的格式:

锁状态29 bit61 bit1 bit 是否是偏向锁?2 bit 锁标志位
⽆锁 001
偏向锁线程ID101
轻量级锁指向栈中锁记录的指针此时这⼀位不⽤于
标识偏向锁
00
重量级锁指向互斥量(重量级锁)
的指针
此时这⼀位不⽤于
标识偏向锁
10
GC标 此时这⼀位不⽤于
标识偏向锁
11

可以看到,当对象状态为偏向锁时, Mark Word 存储的是偏向的线程ID;当状态为轻量级锁时, Mark Word 存储的是指向线程栈中 Lock Record 的指针;当状态为重量级锁时, Mark Word 为指向堆中的monitor对象的指针。

3、Monitor原理

Monitor 被翻译为监视器管程

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

  • 刚开始 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 状态的线程,比如调用了wait方法

注意:

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

3、synchronized 原理

写一段测试代码

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

用javap反编译成对应的字节码

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引用 (synchronized开始)
 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 // <- lock引用
 15: monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList
 16: goto 24
 19: astore_2 // e -> slot 2 
 20: aload_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
 19 22 19 any
 LineNumberTable:
 line 8: 0
 line 9: 6
 line 10: 14
 line 11: 24
 LocalVariableTable:
 Start Length Slot Name Signature
 0 25 0 args [Ljava/lang/String;
 StackMapTable: number_of_entries = 2
 frame_type = 255 /* full_frame */
 offset_delta = 19
 locals = [ class "[Ljava/lang/String;", class java/lang/Object ]
 stack = [ class java/lang/Throwable ]
 frame_type = 250 /* chop */
 offset_delta = 4
注意:  方法级别的 synchronized 不会在字节码指令中有所体现          

可以看到在同步块的入口位置和退出位置分别插入monitorenter和monitorexit字节码指令,这段指令,在这个时间,只要synchronized没有执行monitorexit解锁,其他线程就无法执行

并且synchronized是支持锁重入的,在锁期间,每调用一个synchronized的代码块,锁计数器就会加一,直到锁计数器等于0才解锁

3.1、轻量级锁

多个线程在不同时段获取同⼀把锁,即不存在锁竞争的情况,也就没有线程阻塞。 针对这种情况,JVM采⽤轻量级锁来避免线程的阻塞与唤醒。

加锁和释放锁的过程

  1. 创建锁记录(Lock Record)对象,每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word
  2. 让锁记录中 Object reference 指向锁对象,并尝试用 cas 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录
  3. 如果 cas 替换成功,对象头中存储了 锁记录地址和状态 00 ,表示由该线程给对象加锁
  4. 如果 cas 失败,有两种情况
    • 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
    • 如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数
  5. 当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一

3.2、锁膨胀

如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

  • 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁

  • 这时 Thread-1 加轻量级锁失败,进入锁膨胀流程

    1. 即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址

    2. 然后自己进入 Monitor 的 EntryList BLOCKED

    3. 当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败。

      这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED

3.3、自旋优化

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。

  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。

  • 在 Java 6 之后自旋锁是自适应的

    比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。

  • Java 7 之后不能控制是否开启自旋功能

3.4、偏向锁

Hotspot的作者经过以往的研究发现⼤多数情况下锁不仅不存在多线程竞争,⽽且总是由同⼀线程多次获得,于是引⼊了偏向锁。

偏向锁会偏向于第⼀个访问锁的线程,如果在接下来的运⾏过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。

也就是说,偏向锁在资源⽆竞争情况下消除了同步语句,连CAS操作都不做了,提⾼了程序的运⾏性能。

实现原理

  • ⼀个线程在第⼀次进⼊同步块时,会在对象头和栈帧中的锁记录⾥存储锁的偏向的线程ID。当下次该线程进⼊这个同步块时,会去检查锁的Mark Word⾥⾯是不是放的⾃⼰的线程ID。

  • 如果是,表明该线程已经获得了锁,以后该线程在进⼊和退出同步块时不需要花费CAS操作来加锁和解锁 ;

  • 如果不是,就代表有另⼀个线程来竞争这个偏向锁。这个时候会尝试使⽤CAS来替换Mark Word⾥⾯的线程ID为新线程的ID

    这个时候要分两种情况:

    1. 成功,表示之前的线程不存在了, Mark Word⾥⾯的线程ID为新线程的ID,锁不会升级,仍然为偏向锁;
    2. 失败,表示之前的线程仍然存在,那么暂停之前的线程,设置偏向锁标识为0,并设置锁标志位为00,升级为轻量级锁,会按照轻量级锁的⽅式进⾏竞争 锁。

CAS: Compare and Swap

⽐较并设置。⽤于在硬件层⾯上提供原⼦性操作。在 Intel 处理器中,⽐较并交换通过指令cmpxchg实现。 ⽐较是否和给定的数值⼀致,如果⼀致则修改,不⼀致则不修改。

撤销 - 调用对象 hashCode

调用了对象的 hashCode,但偏向锁的对象 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被撤销

轻量级锁会在锁记录中记录 hashCode

重量级锁会在 Monitor 中记录 hashCode

撤销 - 其它线程使用对象

当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁

撤销 - 调用 wait/notify

Object d = new Object();
Thread t1 = new Thread(() -> {
    synchronized (d) {
        try {
            d.wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}, "t1");
t1.start();
new Thread(() -> {
    try {
        Thread.sleep(6000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    synchronized (d) {
        d.notify();
    }
}, "t2").start();

批量重偏向

如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID

当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程

批量撤销

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

3.5、重量级锁

重量级锁依赖于操作系统的互斥量(mutex) 实现的,⽽操作系统中线程间状态的转换需要相对⽐较⻓的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗CPU。

当调⽤⼀个锁对象的 wait 或 notify ⽅法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁。

3.6、总结锁的升级流程

每⼀个线程在准备获取共享资源时:
  1. 检查MarkWord⾥⾯是不是放的⾃⼰的ThreadId ,如果是,表示当前线程是处于 “偏向锁” 。
  2. 如果MarkWord不是⾃⼰的ThreadId,锁升级,这时候,⽤CAS来执⾏切 换,新的线程根据MarkWord⾥⾯现有的ThreadId,通知之前线程暂停,之前线程将Markword的内容置为空。
  3. 两个线程都把锁对象的HashCode复制到⾃⼰新建的⽤于存储锁的记录空 间,接着开始通过CAS操作, 把锁对象的MarKword的内容修改为⾃⼰新建的记录空间的地址的⽅式竞争MarkWord。
  4. 第三步中成功执⾏CAS的获得资源,失败的则进⼊⾃旋 。
  5. ⾃旋的线程在⾃旋过程中,成功获得资源(即之前获的资源的线程执⾏完成并释放了共享资源),则整个状态依然处于 轻量级锁的状态,如果⾃旋失败 。
  6. 进⼊重量级锁的状态,这个时候,⾃旋的线程进⾏阻塞,等待之前线程执 ⾏完成并唤醒⾃⼰

3.7、各种锁的优缺点对⽐

优点缺点适⽤场景
偏向锁加锁和解锁不需要额外的
消耗,和执⾏⾮同步⽅法
⽐仅存在纳秒级的差距。
如果线程间存在锁竞争,
会带来额外的锁撤销的消耗。
适⽤于只有⼀个线程
访问同步块场景。
轻量级锁竞争的线程不会阻塞,
提高了程序的响应速度
如果线程得不到锁,
竞争线程使用自旋会消耗CPU
追求响应速度,同步
块执行非常快
重量级锁线程竞争不使用自旋,
不会消耗CPU
线程阻塞,响应时间缓慢追求吞吐量,同步代
码块执行速度比较长

4、常见问题

4.1、synchronized的可重入怎么实现。

每个锁关联一个线程持有者和一个计数器。当计数器为0时表示该锁没有被任何线程持有,那么任何线程都都可能获得该锁而调用相应方法。当一个线程请求成功后,JVM会记下持有锁的线程,并将计数器计为1。此时其他线程请求该锁,则必须等待。而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增。当线程退出一个synchronized方法/块时,计数器会递减,如果计数器为0则释放该锁。

4.2、synchronized和lock的用法区别

synchronized:在需要同步的对象中加入此控制,synchronized可以加在方法上,也可以加在特定代码块中zd,括号中表示需要锁的对象。

lock:需要显示指定起始位置和终止位置。一般使用ReentrantLock类做为锁,专多个线程中必须要使用一个ReentrantLock类做为对象才能保证锁的生效。且在加锁和解锁处需要通过lock()和unlock()显示指出。所以一般会在finally块中写unlock()以防死锁。

ReentrantLock比synchronized的扩展性体现在几点上:

(1)ReentrantLock可以对获取锁的等待时间进行设置,这样就避免了死锁

(2)ReentrantLock可以获取各种锁的信息

(3)ReentrantLock可以灵活地实现多路通知

4.3、synchronized和lock用途区别

synchronized原语和ReentrantLock在一般情况下没有什么区别,但是在非常复杂的同步应用中,请考虑使用ReentrantLock,特别是遇到下面需求的时候。

  • 某个线程在等待一个锁的控制权的这段时间需属要中断
  • 需要分开处理一些wait-notify,ReentrantLock里面的Condition应用,能够控制notify哪个线程
  • 具有公平锁功能,每个到来的线程都将排队等候
12

评论区