在 Java 中,synchronized
关键字是一个非常重要的同步机制,用于确保在多线程环境下,同一时刻只有一个线程可以执行某个方法或代码块。本文将详细介绍 synchronized
关键字的使用、锁的类型、锁的升级过程以及锁的实现原理。
1 synchronized
的基本用法
synchronized
关键字主要有以下三种应用方式:
- 同步方法:为当前对象(
this
)加锁,进入同步代码前要获得当前对象的锁。 - 同步静态方法:为当前类加锁(锁的是
Class
对象),进入同步代码前要获得当前类的锁。 - 同步代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
// 关键字在实例方法上,锁为当前实例
public synchronized void instanceLock() {
// code
}
// 关键字在静态方法上,锁为当前Class对象
public static synchronized void classLock() {
// code
}
// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
Object o = new Object();
synchronized (o) {
// code
}
}
2 锁的四种状态及锁升级
在 JDK 1.6 以前,所有的锁都是“重量级”锁,使用的是操作系统的互斥锁,效率较低。为了减少获得锁和释放锁带来的性能消耗,JDK 1.6 引入了“偏向锁”和“轻量级锁”的概念,对 synchronized
进行了重大升级。
一个对象其实有四种锁状态,它们级别由低到高依次是:
- 无锁状态
- 偏向锁状态
- 轻量级锁状态
- 重量级锁状态
锁会随着竞争情况逐渐升级,但锁降级发生的条件比较苛刻,通常发生在 Stop The World(STW)期间,当 JVM 进入安全点的时候,会检查是否有闲置的锁,然后进行降级。
各种锁的优缺点对比(来自《Java 并发编程的艺术》):
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程使用自旋会消耗 CPU | 追求响应时间。同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗 CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量。同步块执行时间较长 |
3 对象的锁存储位置
每个 Java 对象都有一个对象头(Object Header
),其中包含了 Mark Word
。Mark Word
存储了对象的 hashCode
或锁信息等。
对象头的内容如下表所示:
长度 | 内容 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的 hashCode 或锁信息等 |
32/64bit | Class Metadata Address | 存储到对象类型数据的指针 |
32/64bit | Array length | 数组的长度(如果是数组) |
Mark Word 的具体格式如下:
锁状态 | 29 bit 或 61 bit | 1 bit 是否是偏向锁? | 2 bit 锁标志位 |
---|---|---|---|
无锁 | 0 | 01 | |
偏向锁 | 线程 ID | 1 | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 此时这一位不用于标识偏向锁 | 00 |
重量级锁 | 指向互斥量(重量级锁)的指针 | 此时这一位不用于标识偏向锁 | 10 |
GC 标记 | 此时这一位不用于标识偏向锁 | 11 |
可以看到,当对象状态为偏向锁时,Mark Word
存储的是偏向的线程 ID;当状态为轻量级锁时,Mark Word存储的是指向线程栈中Lock Record
的指针;当状态为重量级锁时,Mark Word
为指向堆中的 monitor
(监视器)对象的指针。
在 Java 中,监视器(monitor)是一种同步工具,用于保护共享数据,避免多线程并发访问导致数据不一致。在 Java 中,每个对象都有一个内置的监视器。
监视器包括两个重要部分,一个是锁,一个是等待/通知机制,后者是通过 Object
类中的wait()
, notify()
, notifyAll()
等方法实现的。
4 偏向锁
偏向锁会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。偏向锁在资源无竞争情况下消除了同步语句,连 CAS 操作都不做了,极大地提高了程序的运行性能。
偏向锁的实现原理:
- 线程在第一次进入同步块时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID。
- 下次该线程进入这个同步块时,会去检查锁的
Mark Word
里面是不是放的自己的线程 ID。 - 如果是,表明该线程已经获得了锁,以后该线程在进入和退出同步块时不需要花费 CAS 操作来加锁和解锁。
- 如果不是,就代表有另一个线程来竞争这个偏向锁。这个时候会尝试使用 CAS 来替换
Mark Word
里面的线程 ID 为新线程的 ID。这个时候要分两种情况:- 成功,表示之前的线程不存在了,
Mark Word
里面的线程 ID 为新线程的 ID,锁不会升级,仍然为偏向锁; - 失败,表示之前的线程仍然存在,那么暂停之前的线程,设置偏向锁标识为
0
,并设置锁标志位为00
,升级为轻量级锁,会按照轻量级锁的方式进行竞争锁。
- 成功,表示之前的线程不存在了,
撤销偏向锁:
-
偏向锁使用了一种等到竞争出现才释放锁的机制。
-
当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
-
撤销偏向锁的过程包括暂停拥有偏向锁的线程,重置偏向锁标识,这个过程开销较大。
-
撤销偏向锁的大概过程:
- 在一个安全点(在这个时间点上没有字节码正在执行)停止拥有锁的线程。
- 遍历线程栈,如果存在锁记录的话,需要修复锁记录和
Mark Word
,使其变成无锁状态。 - 唤醒被停止的线程,将当前锁升级成轻量级锁。
-
如果应用程序里所有的锁通常处于竞争状态,那么偏向锁就会是一种累赘,对于这种情况,我们可以一开始就把偏向锁这个默认功能给关闭:
-XX:UseBiasedLocking=false
5 轻量级锁
多个线程在不同时段获取同一把锁,即不存在锁竞争的情况,也就没有线程阻塞。JVM 采用轻量级锁来避免线程的阻塞与唤醒。
轻量级锁的实现原理:
- JVM 会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,称为
Displaced Mark Word
。如果一个线程获得锁的时候发现是轻量级锁,会把锁的Mark Word
复制到自己的Displaced Mark Word
里面。 - 线程尝试用 CAS 将锁的
Mark Word
替换为指向锁记录的指针。 - 如果成功,当前线程获得锁;如果失败,表示
Mark Word
已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。
自旋:
- 自旋是需要消耗 CPU 的,如果一直获取不到锁,那该线程就一直处在自旋状态,白白浪费 CPU 资源。
- JDK 采用了适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多;如果自旋失败了,则自旋的次数就会减少。
自旋也不是一直进行下去的,如果自旋到一定程度(和 JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。同时这个锁就会升级成重量级锁。
轻量级锁的释放:
- 在释放锁时,当前线程会使用 CAS 操作将
Displaced Mark Word
的内容复制回锁的Mark Word
里面。 - 如果没有发生竞争,这个复制的操作会成功;如果有其他线程因为自旋多次导致轻量级锁升级成了重量级锁,那么 CAS 操作会失败,此时会释放锁并唤醒被阻塞的线程。
6 重量级锁
重量级锁依赖于操作系统的互斥锁(mutex)实现,而操作系统中线程间状态的转换需要相对较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗 CPU。
每一个对象都可以当做一个锁,当多个线程同时请求某个对象锁时,对象锁会设置几种状态用来区分请求的线程:
Contention List
:所有请求锁的线程将被首先放置到该竞争队列;Entry List
:Contention List
中那些有资格成为候选人的线程被移到Entry List
;Wait Set
:那些调用wait
方法被阻塞的线程被放置到Wait Set
;OnDeck
:任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck
;Owner
:获得锁的线程称为Owner
;!Owner
:释放锁的线程。
重量级锁的实现原理:
- 当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个
ObjectWaiter
对象插入到Contention List
队列的队首,然后调用park
方法挂起当前线程。 - 当线程释放锁时,会从
Contention List
或EntryList
中挑选一个线程唤醒,被选中的线程叫做Heir presumptive
即假定继承人,假定继承人被唤醒后会尝试获得锁,但synchronized
是非公平的,所以假定继承人不一定能获得锁。
这是因为对于重量级锁,如果线程尝试获取锁失败,它会直接进入阻塞状态,等待操作系统的调度。
如果线程获得锁后调用Object.wait
方法,则会将线程加入到 WaitSet
中,当被Object.notify
唤醒后,会将线程从 WaitSet
移动到 Contention List
或 EntryList
中去。需要注意的是,当调用一个锁对象的wait
或notify
方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁。
7 锁的升级流程
- 检查
Mark Word
里面是不是放的自己的ThreadId
,如果是,表示当前线程是处于“偏向锁”。 - 如果
Mark Word
不是自己的ThreadId
,锁升级,用 CAS 来执行切换,新的线程根据Mark Word
里面现有的ThreadId
,通知之前线程暂停,之前线程将Mark Word
的内容置为空。 - 两个线程都把锁对象的
HashCode
复制到自己新建的用于存储锁的记录空间,接着开始通过 CAS 操作,把锁对象的Mark Word
的内容修改为自己新建的记录空间的地址的方式竞争Mark Word
。 - 第三步中成功执行 CAS 的获得资源,失败的则进入自旋。
- 自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于轻量级锁的状态,如果自旋失败。
- 进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己。
8 总结
- Java 中的每一个对象都可以作为一个锁,Java 中的锁都是基于对象的。
synchronized
关键字可以用来修饰方法和代码块,它可以保证在同一时刻最多只有一个线程执行该段代码。synchronized
关键字在修饰方法时,锁为当前实例对象;在修饰静态方法时,锁为当前 Class 对象;在修饰代码块时,锁为括号里面的对象。- Java 6 为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。在 Java 6 以前,所有的锁都是“重量级”锁。所以在 Java 6 及其以后,一个对象其实有四种锁状态,它们级别由低到高依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。
- 偏向锁会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。也就是说,偏向锁在资源无竞争情况下消除了同步语句,连 CAS 操作都不做了,提高了程序的运行性能。
- 轻量级锁是通过 CAS 操作和自旋来实现的,如果自旋失败,则会升级为重量级锁。
- 重量级锁依赖于操作系统的互斥量(mutex)实现的,而操作系统中线程间状态的转换需要相对较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗 CPU。
通过理解 synchronized
关键字的工作原理和锁的升级过程,可以更好地掌握 Java 并发编程的核心概念,确保在多线程环境下程序的正确性和性能。