synchronized 详解

16 阅读10分钟

本文将从 synchronized 的概念、底层实现、性质、锁升级和锁粗化进行介绍。

概念

synchronized 是一个同步关键字,俗称对象锁,避免临界区的竞态条件发生,可以用来修饰方法和代码块。

它是一个悲观锁,是可重入非公平的锁。

synchronized 可以作用在方法或代码块上:

  • synchronized 加在成员方法上,锁的是 this 对象

  • synchronized 加在静态方法上,锁的是类对象

  • synchronized 修饰代码块时,锁的粒度取决于()里面指定的对象。

底层实现

synchronized 锁是存在 Java 对象头的 Mark Word 里的。

对象头

在 Java 虚拟机中,每个对象都有一个对象头。

对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。

Mark Word:用于存储对象的运行时数据和锁相关的信息。默认存储对象的 HashCode、分代年龄和锁标志位信息。在运行期间 Mark Word 里存储的数据会随着锁标志位的变化而变化

Klass Point指向对象所属类的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。它指向对象的类元数据(Class Metadata),包含了类的方法、字段、父类、接口等信息。

四种锁状态对应的的 Mark Word 内容:

Monitor

Monitor 可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个 Java 对象都可以关联一个 Monitor 对象

Monitor 内部有三个属性:

  • Owner:关联获得锁的线程,只能关联一个线程;

  • EntryList(双向链表结构):关联处于阻塞状态的线程;

  • WaitSet:关联处于 Waiting 状态的线程。

当使用 synchronized 给对象上锁之后,该对象的对象头的 Mark Word 中的指针指向 Monitor 对象

线程1获取到锁时,owner 置为线程1,其他线程进入 entrylist 阻塞,线程1执行完后,会把 owner 置为空,并唤醒 EntryList 中等待的线程来竞争锁,竞争是非公平的。

当持有锁的线程调用了 wait() 方法,就会进入 WaitSet 队列,释放 monitor 锁,等待其他线程唤醒,唤醒后添加到 EntryList 当中进行非公平的竞争。

同步代码块是通过 monitorenter 和 monitorexit 来实现,当线程执行到 monitorenter 的时候要先获得 monitor 锁,才能执行后面的方法。当线程执行到 monitorexit 的时候则要释放锁。

同步方法是通过中设置 ACC_SYNCHRONIZED 标志来实现,当线程执行有 ACC_SYNCHRONI 标志的方法,需要获得 monitor 锁,底层还是依靠 monitorenter 和 monitorexit。

monitorenter

当线程执行到 monitorenter 指令时,会尝试去获取对应的 monitor,monitor 维护一个记录着计数器,当对象未被锁定时,该计数器为0,线程进入 monitor 后,计数器会置为1,当同一个线程再次获得该对象的锁的时候,计数器再次自增。其他线程想要获得monitor的时候,只有等计数器为0时才成功。

monitorexit

monitor 的拥有者线程才能执行 monitorexit 指令。线程执行 monitorexit 指令,就会让 monitor 的计数器减一。如果计数器为0,表明该线程不再拥有 monitor。其他线程就允许尝试去获得该 monitor 了。

ACC_SYNCHRONIZED

方法级别的同步是隐式的,作为方法调用的一部分。同步方法的常量池中会有一个ACC_SYNCHRONIZED标志。

当调用一个设置了 ACC_SYNCHRONIZED 标志的方法,执行线程需要先获得 monitor 锁,然后开始执行方法,方法执行之后再释放 monitor 锁,当方法不管是正常 return 还是抛出异常都会释放对应的 monitor 锁。

性质

synchronized 保证了原子性、有序性和可见性。

原子性

synchonized 通过 monitorentermonitorexit 这两个字节码指令保证原子性。

当线程执行到 monitorenter 时要先获得锁,才能执行后面的方法,线程执行到 monitorexit 则会释放锁

线程获取锁之后,其他线程是无法获得锁的,因此可以保证方法和代码块内的操作是原子性的。就算是加锁后 CPU 时间片用完后,由于线程没有释放锁,其他线程即使获取到时间片也无法获取锁,由于 synchronized 是可重入的,当该线程再次获取到时间片时能继续执行没完成的操作。

有序性

由 synchronized 修饰的代码,在同一时间只能被同一线程访问,也就是单线程执行的,因此可以保证有序性。

可见性

可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立刻看到修改的值

JMM 规定了所有变量都存储在主内存中,每条线程有自己的工作内存,修改变量时会先拷贝一份数据到工作内存,在工作内存操作完后再同步回主内存。

被 synchronized 修饰的代码,在解锁之前,会先把这个变量同步到主内存中

synchronized 锁升级

整体的锁状态升级流程如下:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

  • 当 JVM 启动后,一个共享资源对象直到有线程第一个访问时,这段时间内是处于无锁状态;

  • 当一个共享资源首次被某个线程访问时,锁就会从无锁状态升级到偏向锁状态;

  • 当存在两个线程竞争时,偏向锁会升级成轻量级锁,此时,JVM 会使用 CAS 自旋操作来尝试获取锁。

  • 当出现三个以上线程竞争自旋超过一定次数时,轻量级锁会升级为重量级锁

无锁

无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。上面我们介绍的 CAS 原理及应用即是无锁的实现。

偏向锁

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。

在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能

初始状态下,一个线程获取锁,JVM 会在对象头中记录该线程 ID,并将对象头标记为偏向锁状态

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁

  1. 当其他线程尝试获取偏向锁时,JVM 首先会检查该锁对象的头部,发现它处于偏向状态并记录了持有锁的线程 ID。

  2. 暂停持有偏向锁的线程(如果它正在运行),这被称为安全点(Safe Point)。

  3. 如果持有锁的线程此时还在同步代码块里,偏向锁会被撤销,升级为轻量级锁;如果持有锁的线程此时不在同步代码块里,会先撤销偏向锁,然后把偏向锁分给新来的线程

JDK15 中已废弃偏向锁

轻量级锁

是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时轻量级锁升级为重量级锁

重量级锁

升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

synchroniezd 锁优化

自旋锁

其他线程获取到锁时,未获得锁的线程不是阻塞,而是自旋,时刻等待锁释放。

自旋锁和阻塞锁的区别是:

  1. 自旋锁在等待过程中没有放弃 CPU 时间,而是一直自旋,时刻检查共享资源是否可以被访问

  2. 阻塞锁放弃了 CPU 时间,进入等待区等待被唤醒线程的阻塞和唤醒需要 CPU 从用户态转为核心态,造成性能消耗。

当线程数很多的时候,大量线程都使用自旋锁会降低性能,自旋锁适合线程竞争不激烈,且持有锁的时间短的场景。

锁消除

如果 synchronized 锁住的对象被 JIT 编译器通过逃逸分析判断只能被一个线程访问,JIT 编译器就会取消当前锁。

如:

public void f() {
    Object lock = new Object();
    synchronized(lock) {
        System.out.println(123);
    }
}

上述代码中锁对象 lock 的生命周期只在 f() 中,lock 不会被其他线程获取到,所以会被优化成:

public void f() {
    Object lock = new Object();
    System.out.println(123);
}

锁粗化

当 JIT 发现一系列连续的操作都对同一个对象反复加锁和解锁,或加锁操作出现在循环体内的时候,会将锁范围扩大(粗化)到整个操作序列以外。

如:

for (int i = 0; i < 100; i++) {
    synchronized(this) {
        // ...
    }
}

会被优化成:

synchronized(this) {
    for (int i = 0; i < 100; i++) {
        // ...
    }
}

synchronized 和 volatile 区别

  • volatile 关键字是线程同步的轻量级实现,所以 volatile 性能比 synchronized 关键字要好

  • volatile 关键字只能用于变量,synchronized 关键字可以修饰方法以及代码块。

  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。

  • volatile 关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性

synchronized 和 Lock 对比

  • synchronized 是 Java 的内置关键字,是 JVM 层面的,而 Lock 是个接口

  • synchronized 在使用过后,会自动释放锁,而 Lock 需要手动上锁和释放锁

  • synchronized 无法判断锁的状态,而 Lock 可以通过 tryLock()、isLocked()判断锁的状态。

  • synchronized非公平锁,而 Lock 可以通过构造函数指定公平锁或非公平锁

  • synchronized 锁粒度较粗,只能锁住整个方法或代码块,而 Lock 可以细粒度控制锁的范围,比如锁住某个对象的部分属性。

一般情况下建议使用 synchronized,在需要更高级的锁控制时可以考虑使用 Lock。