synchronized与锁
Java 中的锁都是基于对象的,每一个对象都可以作为一个锁。
synchronized 关键字
我们可以使用 synchronized
为一段代码或一个方法加上锁。静态方法上加的是类锁。
主要有以下形式。
// 关键字在实例方法上,锁为当前实例
public synchronized void instanceLock() {
// code
}
// 关键字在静态方法上,锁为当前Class对象
public static synchronized void classLock() {
// code
}
// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
Object o = new Object();
synchronized (o) {
// code
}
}
临界区内的代码同一时间只能由一个获得锁的线程执行。
synchronized
下面写法是等价的。
// 关键字在实例方法上,锁为当前实例
public synchronized void instanceLock() {
// code
}
// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
synchronized (this) {
// code
}
}
静态方法同理,使用的类锁(class对象)
// 关键字在静态方法上,锁为当前Class对象
public static synchronized void classLock() {
// code
}
// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
synchronized (this.getClass()) {
// code
}
}
锁分类
Java6 为了减少获得锁和释放锁带来的性能损耗,引入了 偏向锁 和 轻量锁。Java6 以前,所有的锁都是重量级锁。Java6 后,一个对象有四种锁状态,由低到高分别为:
- 无锁
- 偏向锁
- 轻量锁
- 重量锁
无锁是没有对资源进行锁定,任何线程都可以尝试修改它。
几种锁会随着竞争逐渐升级,锁容易升级,但降锁条件比较苛刻。锁降级发生在 Stop The World
期间,当 JVM 进入安全点时,会检查是否有闲置的锁,然后进行降级。
Java 对象头
Java 的锁都是基于对象的,我们看一下 对象的 锁 信息放在哪。
每个 Java 对象都有对象头。如果是非数组类型,用2个字宽存储对象头,如果是数组,3个字宽存储对象头。
在32位处理器中,一个字宽是32位;在64位虚拟机中,一个字宽是64位。
长度 | 内容 | 说明 |
---|---|---|
32/64 bit | Mark Word | 对象的hashCode或锁信息 |
32/64 bit | Class Metadata Address | 对象类型数据指针 |
32/64 bit | Array length | 数组长度 |
Mark Word的格式
锁 | 29/61 bit | 1bit(是否为偏向锁) | 2bit(锁标志) |
---|---|---|---|
无锁 | 0 | 01 | |
偏向锁 | 线程ID | 1 | 01 |
轻量锁 | 指向栈中锁记录的指针 | 00 | |
重量级锁 | 指向互斥量(重量级锁)的指针 | 10 | |
GC | 11 |
当对象状态为偏向锁时,Mark Word 存储的是偏向的线程ID;
为轻量级锁时,
Mark Word
存储的是指向线程栈中Lock Record
的指针;为重量级锁时,
Mark Word
为指向堆中 monitor 对象的指针
偏向锁
Hotspot的作者经过以往的研究发现大多数情况下大多数情况下 锁不仅不存在多线程竞争,而且总是由同一线程多次获得,于是引入了偏向锁。
偏向锁会偏向于第一个获得访问锁的线程,如果接下来,该锁没有被其他线程访问,则持有该锁的线程将永远不需要触发同步。
也就是说,偏向锁在资源无竞争情况下消除了同步语句,连CAS操作都不做了,提高了程序的运行性能。
实现原理
一个线程在第一次进入同步块时,会在对象头和栈帧中的锁记录存储锁偏向的线程ID,当下次该线程再进入整个同步块,会检查锁的 Mark Word 放的是不是当前线程 ID。
如果是,表明线程已经获得了锁,线程进入和退出同步块时不需要花费 CAS 操作来加/解锁。
如果不是,就代表有另一个线程来竞争这个偏向锁。这时会尝试用CAS来替换 Mark Word里的线程ID为新线程的ID,这时有两种情况:
- 成功,表示之前的线程不在了,Mark Word 里的线程ID更新为新线程的ID,锁依然为偏向锁。
- 失败,表示之前的线程依然存在,那么暂停之前的线程,设置偏向锁标识为0,并设置锁标志位为00,升级为轻量级锁,会按照轻量级锁的方式进行竞争锁。
撤销偏向锁
偏向锁升级为轻量锁时,会暂停拥有偏向锁的线程,重置偏向锁标志位。
- 在一个安全点停止拥有锁的线程
- 遍历线程栈,如果存在锁记录的话,需要修复锁记录和Mark Word,使其变成无锁状态
- 唤醒被停止的线程,将当前锁升级成轻量级锁。
如果应用程序里的锁通常处于竞争态,偏向锁是一种累赘,这时可以把偏向锁概念关闭
-XX:UseBiasedLocking=false
轻量级锁
加锁
JVM 为每个线程再当前线程的栈帧中创建用于存储锁记录的空间,我们称为 Displaced Mark Word。
如果一个线程获得锁时发现是轻量级锁,会把锁的 Mark Word 复制到自己的 Displaced Mark Word中。
然后线程尝试 CAS 将锁的**Mark Word
替换为指向锁记录指针**。
- 如果成功,当前线程获得锁
- 如果失败,表示
Mark Word
已经被替换成了其他线程的锁记录,说明与其他线程竞争锁,当前线程尝试使用自旋获取锁。
自旋:不断尝试获取锁,一般使用循环实现
自旋消耗CPU资源,如果一直获取不到,线程一直处在自旋状态,白白浪费CPU。JDK采取的方式,适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
当自旋到一定程度,依然没有获取倒锁,即自旋失败了,这个线程会被阻塞,同时这个锁会升级为重量级锁。
释放
释放锁时,当前线程会使用 CAS 将Displaced Mark Word的内容复制回锁的Mark Word
里,如果无竞争,那么复制会成功。如果有其他线程因为多次自旋导致锁升级为重量级锁,CAS会失败,此时锁被释放并唤醒被阻塞的线程。
重量级锁
重量级锁依赖于操作系统的互斥量(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 或 Entry List 中挑选一个线程唤醒, 被选中的线程叫做 Heir presumptive
即假定继承人,但 synchronized
是非公平的,假定继承人不一定能获取到锁。因为对于重量级锁,线程先尝试自旋获取锁,这样会减少操作系统同步操作带来的开销。如果自旋不成功再进入等待队列。
如果线程获得锁后调用 Object.wait
方法,则会将线程加入到 Wait Set
中,当被 Object.notify
唤醒后,会将线程从 Wait Set
移动到Contention List
或 Entry List
中。
当调用一个锁对象的 wait
或 notify
时,如果锁时偏向锁或轻量锁则先升级为重量锁。
总结
锁升级流程
偏向锁 -> 轻量级锁
线程检查 markword 锁标记,如果是偏向锁,并且 锁对象的线程ID为自己,获取锁
如果不是自己,执行CAS 替换操作,替换失败,说明有其他线程竞争锁,此时锁升级为轻量级锁,暂停获得锁的线程
轻量级锁 -> 重量级锁
- 线程将锁对象的 markword复制到自己线程栈的所记录
- CAS 替换锁对象的 markdord 为当前线程栈的地址
- 替换成功说明获取到锁
- 替换失败,自旋一段时间,如果还是未获取到,升级为重量级锁,自旋线程进入阻塞,等待被唤醒。
锁对比
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 | 适用于只有一个线程访问同步块场景。 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度。 | 如果始终得不到锁竞争的线程使用自旋会消耗CPU。 | 追求响应时间。同步块执行速度非常快。 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU。 | 线程阻塞,响应时间缓慢。 | 追求吞吐量。同步块执行时间较长。 |