锁是一种通用的技术方案,synchronized 是 Java 内建的同步机制,所以也有人称其为 Intrinsic Locking,它提供了互斥的语义和可见性,当一个线程已经获取当前锁时,其他试图获取的线程只能等待或者阻塞在那里。
在 Java 5 以前,synchronized 是仅有的同步手段。在代码中,synchronized 可以用来修饰方法(包括实例方法和静态方法)也可以使用在特定的代码块上。Java 编译器会在 synchronized 修饰的方法或代码块前后自动添加加锁 lock() 和解锁 unlock() 操作,以保证加锁和解锁一定是成对出现的。
字节码指令
那 synchronized 里的加锁 lock() 和解锁 unlock() 锁定的对象在哪里呢?实际上,当修饰静态方法的时候,锁定的是当前类的 Class 对象;当修饰非静态方法的时候,锁定的是当前实例对象 this。如果采用同步代码块,锁定的则是同步代码块中对象。注意,当采用同步代码块时,作为锁句柄的变量通常采用 private final 修饰,因为锁句柄变量的值一旦改变,会导致执行同一个同步块的多个线程实际上使用了不同的锁,从而导致竞态。
// 静态方法,锁为当前类Class对象
public static synchronized void method1() {
//......
}
// 实例方法,锁为当前实例对象
public synchronized void method2() {
//......
}
// 关键字在代码块上,锁为括号里面的对象
public void method3() {
Object o = new Object();
synchronized (o) {
//......
}
}
通过反编译看下具体字节码的实现:
javac -encoding UTF-8 SyncTest.java
javap -v SyncTest.class
1. 同步方法
当 synchronized 修饰同步方法时,会添加一个 ACC_SYNCHRONIZED 的访问标志。JVM 使用该访问标志来区分一个方法是否是同步方法。当方法调用时,调用指令将会检查该方法是否被设置 ACC_SYNCHRONIZED 访问标志。如果设置了该标志,执行线程将先持有 Monitor 对象,然后再执行方法。在该方法运行期间,其它线程将无法获取到该 Mointor 对象,当方法执行完成后,再释放该 Monitor 对象。
2. 同步代码块
当 synchronized 在修饰同步代码块时,是通过 monitorenter 和 monitorexit 字节码指令来实现同步的。如果使用 monitorenter 进入时 monitor 为 0,表示该线程可以持有 monitor 后续代码,并将 monitor 加 1,如果当前线程已经持有了 monitor,那么 monitor 继续加 1;如果 monitor 非 0,其他线程就会进入阻塞状态。并且当同步代码块执行异常时,会自动执行 monitorexit 指令避免 monitor 未释放。
Monitor 实现
JVM 底层是通过进入和退出监视锁(Monitor)来实现 synchronized 同步的,monitor 是每个对象与生俱来的一个隐藏字段,与对象实例一起创建、销毁。使用 synchronized 时,JVM 会根据 synchronized 的当前使用环境找到对应对象的 monitor,再根据 monitor 的状态进行加、解锁的判断。
1. ObjectMonitor
Monitor 由 ObjectMonitor 实现,而 ObjectMonitor 是由 C++ 的 ObjectMonitor.hpp 文件实现:
ObjectMonitor() {
_header = NULL;
_count = 0; // 记录个数
_waiters = 0,
_recursions = 0; // 记录重入次数
_object = NULL;
_owner = NULL; // 标识拥有该锁的对象
_WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0;
_Responsible = NULL; //
_succ = NULL;
_cxq = NULL; // 如果有多个线程争抢锁会先存到这个列表中
FreeNext = NULL;
_EntryList = NULL; // 争抢锁失败进入等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0;
_SpinClock = 0;
OwnerIsThread = 0;
}
当多个线程同时访问一段同步代码时,多个线程会先被存放在 ContentionList 和 _EntryList 集合中,处于 block 状态的线程,都会被加入到该列表。接下来当线程获取到对象的 Monitor 时,Monitor 是依靠底层操作系统的 Mutex Lock 来实现互斥的,线程申请 Mutex 成功,则持有该 Mutex,此时其它线程将无法获取到该 Mutex,竞争失败的线程会再次进入 ContentionList 被挂起。
如果线程调用 wait() 方法,就会释放当前持有的 Mutex,并且该线程会进入 WaitSet 集合中,等待下一次被唤醒。如果当前线程顺利执行完方法,也将释放 Mutex。
线程尝试获取 Monitor 的所有权,获取失败说明 Monitor 被其他线程占用,则将线程加入到 ContentionList 中,等待其他线程释放 Monitor ,当其他线程释放 Monitor 后,若刚好有线程来获取 Monitor 的所有权,那么系统会将 Monitor 的所有权给这个线程,所以 synchronized 是非公平锁。
2. 源码分析
synchronized 的行为是 JVM runtime 的一部分,所以我们需要先找到 Runtime 相关的功能实现。通过在代码中查询类似 “monitor_enter” 或 “Monitor Enter” 很直观的就可以定位到:
- sharedRuntime.cpp/hpp,它是解释器和编译器运行时的基类。
- synchronizer.cpp/hpp,JVM 同步相关的各种基础逻辑。
在 sharedRuntime.cpp 中,下面代码体现了 synchronized 的主要逻辑。
Handle h_obj(THREAD, obj);
if (UseBiasedLocking) {
// Retry fast entry if bias is revoked to avoid unnecessary inflation
ObjectSynchronizer::fast_enter(h_obj, lock, true, CHECK);
} else {
ObjectSynchronizer::slow_enter(h_obj, lock, CHECK);
}
其实现可以简单进行分解:
UseBiasedLocking 是一个检查,因为在 JVM 启动时,我们可以指定是否开启偏向锁。偏向锁的撤销操作(revoke)是比较重的行为,只有当存在较多不会真正竞争的 synchronized 块儿时才能体现优点
fast_enter 是完整锁获取路径(包含锁升级过程)。slow_enter 则是绕过偏向锁,直接进入轻量级锁获取逻辑
下面分析一下完整锁获取路径的 fast_enter 方法是如何实现的:
void ObjectSynchronizer::fast_enter(Handle obj, BasicLock* lock,
bool attempt_rebias, TRAPS) {
if (UseBiasedLocking) {
if (!SafepointSynchronize::is_at_safepoint()) {
BiasedLocking::Condition cond = BiasedLocking::revoke_and_rebias(obj, attempt_rebias, THREAD);
if (cond == BiasedLocking::BIAS_REVOKED_AND_REBIASED) {
return;
}
} else {
assert(!attempt_rebias, "can not rebias toward VM thread");
BiasedLocking::revoke_at_safepoint(obj);
}
assert(!obj->mark()->has_bias_pattern(), "biases should be revoked by now");
}
// 获取偏向锁失败
slow_enter(obj, lock, THREAD);
}
BiasedLocking 定义了偏向锁的相关操作,revoke_and_rebias 是获取偏向锁的入口方法,revoke_at_safepoint 则定义了当检测到安全点时的处理逻辑。
如果获取偏向锁失败,则进入 slow_enter。
这个方法里面同样检查是否开启了偏斜锁,但从代码路径来看,如果关闭了偏斜锁是不会进入这个方法的,所以算是个额外的保障性检查吧。
顺着锁升降级的过程分析下去,偏斜锁到轻量级锁的过程是如何实现的呢?我们来看看 slow_enter 方法到底做了些什么。
void ObjectSynchronizer::slow_enter(Handle obj, BasicLock* lock, TRAPS) {
markOop mark = obj->mark();
if (mark->is_neutral()) {
// 将目前的Mark Word复制到Displaced Header上
lock->set_displaced_header(mark);
// 利用CAS设置对象的Mark Word
if (mark == obj()->cas_set_mark((markOop) lock, mark)) {
TEVENT(slow_enter: release stacklock);
return;
}
// 检查存在竞争
} else if (mark->has_locker() &&
THREAD->is_lock_owned((address)mark->locker())) {
// 清除
lock->set_displaced_header(NULL);
return;
}
// 重置Displaced Header
lock->set_displaced_header(markOopDesc::unused_mark());
// 锁膨胀
ObjectSynchronizer::inflate(THREAD, obj(), inflate_cause_monitor_enter)->enter(THREAD);
}
设置 Displaced Header,然后利用 cas_set_mark 设置对象 Mark Word,成功则获取到轻量级锁。
否则 Displaced Header,然后进入锁膨胀阶段,具体实现在 inflate 方法入参中的 enter 方法中。
下面分析下 ObjectMonitor 的 enter 方法:
可以看到重点就是通过 CAS 把 ObjectMonitor 中的 _owner 设置为当前线程,设置成功就表示获取锁成功。然后通过 recursions 的自增来表示重入。如果 CAS 失败的话,会执行自适应自旋,如果还不行就要阻塞。
锁优化
从执行成本的角度看,持有锁是一个重量级的操作。因为在主流 Java 虚拟机实现中,Java 的线程是映射到操作系统的原生内核线程之上的,Java 线程的阻塞以及唤醒都是依靠操作系统来完成的。
在 JDK 6 以后,JVM 对此进行了大刀阔斧地改进,并提供了三种不同的 Monitor 实现,即:偏向锁(Biased Locking)、轻量级锁和重量级锁,大大改进了 synchronized 的性能。
1. 自旋锁与自适应自旋
互斥同步对性能最大的影响是阻塞的实现,因为线程的挂起和恢复操作都需要转入内核态中完成,这些操作给 Java 虚拟机的并发性能带来了很大的压力。但很多时候共享数据的锁定状态只会持续很短一段时间,为了这段时间去挂起和恢复线程并不值得。
因此我们可以让后面请求锁的那个线程稍等一会,但不放弃处理器的执行时间,看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的 自旋锁。
自旋锁在 JDK 1.4.2 中就已经引入,只不过默认是关闭的,可以使用 -XX:+UseSpinning 参数来开启,在 JDK 6 已改为默认开启了。自旋等待不能代替阻塞,虽然它避免了线程切换的开销,但是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,导致性能浪费。因此自旋等待的时间必须有一定限度,如果自旋超过了限定次数仍然没有成功获得锁,就应当使用传统方式挂起线程。自旋次数的默认值是十次,用户也可以使用参数 -XX:PreBlockSpin 来自行更改。
在 JDK 6 进一步引入了自适应的自旋,这意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间。相反,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。
自旋状态还带来另外一个副作用,那便是不公平的锁机制。处于阻塞状态的线程,并没有办法立刻竞争被释放的锁。然而,处于自旋状态的线程,则很有可能优先获得这把锁。
2. 锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持。如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行。使用 -XX:+EliminateLocks 参数可以打开锁消除。
3. 锁粗化
原则上,我们在编写代码时,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快地拿到锁。但如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
4. 轻量级锁
轻量级锁的【轻量级】是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为【重量级锁】。不过,轻量级锁并不是用来代替重量级锁的,它设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗(阻塞、唤醒)。比如,当多个线程在不同的时间段请求同一把锁,也就是说没有锁竞争。
轻量级锁考虑的是竞争锁对象的线程不多,持有锁时间也不长的场景。因为阻塞线程需要 CPU 从用户态切换到内核态,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失,所以干脆不阻塞这个线程,让它自旋一段时间等待锁释放。
当前线程持有的锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。轻量级锁的获取主要有两种情况:① 当关闭偏向锁功能时;② 多个线程竞争偏向锁导致偏向锁升级为轻量级锁。
4.1 Mark Word
HotSpot 虚拟机的对象头(Object Header)分为两部分,第一部分用于存储对象自身的运行时数据,如哈希码、GC 分代年龄等。这部分数据的长度在 32 位和 64 位的 Java 虚拟机中分别占用 32 个或 64 个比特,官方称它为 Mark Word。这部分是实现偏向锁和轻量级锁的关键。另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有一个额外的部分用于存储数组长度。
由于对象头是与对象自身定义的数据无关的额外存储成本,考虑到 Java 虚拟机的空间使用效率,Mark Word 被设计成一个非固定的动态数据结构,以便在极小的空间内存储尽量多的信息。它会根据对象的状态复用自己的存储空间。对象除了未被锁定的正常状态外,还有轻量级锁定、重量级锁定、GC标记、可偏向等几种不同状态,下图展示了 32 位虚拟机下 Mark Word 的存储内容:
4.2 轻量级锁的加锁过程
在代码即将进入同步块的时候,如果此同步对象未被锁定(锁标志位为 01 状态),虚拟机首先将在当前线程的当前栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝(官方称这份拷贝为 Displaced Mark Word),此时线程堆栈与对象头的状态如下图所示:
然后,虚拟机将使用 CAS 操作尝试把对象的 Mark Word 更新为指向 Lock Record 的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象 Mark Word 的锁标志位将转变为 00,表示此对象处于轻量级锁定状态。这时候线程堆栈与对象头的状态如下图所示:
如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是则说明当前线程已经拥有了这个对象的锁,直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。
如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就必须要膨胀为重量级锁,锁标志的状态值变为 10,此时 Mark Word 中存储的就是指向重量级锁的指针,后面等待锁的线程也必须进入阻塞状态。
4.3 轻量级锁的解锁过程
轻量级锁的解锁过程也同样是通过 CAS 操作来进行的,如果对象的 Mark Word 仍然指向线程的锁记录,那就用 CAS 操作把对象当前的 Mark Word 和线程中复制的 Displaced Mark Word 替换回来。假如能够成功替换,那整个同步过程就顺利完成了;如果替换失败则说明有其他线程尝试过获取该锁,这把锁已经被膨胀为重量级锁了,此时 Java 虚拟机会进入重量级锁的释放过程,唤醒因竞争该锁而被阻塞了的线程。
轻量级锁提升程序同步性能的依据是:对于绝大部分的锁,在整个同步周期内都是不存在竞争的。如果没有竞争的话,轻量级锁便通过 CAS 操作成功避免了使用互斥量的开销;如果确实存在锁竞争,除了互斥量的本身开销外还额外发生了 CAS 操作的开销。因此在有竞争的情况下,轻量级锁反而比传统的重量级锁更慢。
5. 偏向锁
偏向锁的目的是消除数据在无竞争情况下的同步,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用 CAS 操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连 CAS 操作都不去做了。
偏向锁中【偏】的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。因为在大多数情况下,锁总是由同一线程多次获得,因而不存在多线程竞争。所以偏向锁的目标就是在只有一个线程执行同步代码块时,降低获取锁带来的性能消耗,提高性能。
5.1 偏向锁的加锁过程
假设当前虚拟机启用了偏向锁(-XX:+UseBiasedLocking,JDK 6 默认开启)。当锁对象第一次被线程获取时,虚拟机会把对象头中的标志位设置为01、把偏向模式设置为1,表示进入偏向模式。同时使用 CAS 操作把获取到这个锁的线程 ID 记录在对象的 Mark Word 中。如果 CAS 操作成功,则持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作,例如加锁、解锁及对 Mark Word 的更新操作等。
具体就是每当有线程请求这把锁时,Java 虚拟机会判断锁对象的对象头中的标志位是否为 01,偏向模式是否为 1,是否包含当前线程 ID,以及 epoch 值是否和锁对象的类的 epoch 值相同。如果都满足,则表示当前线程持有该偏向锁,可以直接返回。
这里的 epoch 值是一个什么概念呢?
如果请求加锁的线程和锁对象中已标记的线程 ID 不匹配且 epoch 值相等时,Java 虚拟机需要撤销该偏向锁。这个撤销过程需要持有偏向锁的线程到达安全点后,再将偏向锁替换成轻量级锁。如果某类锁对象的总撤销数超过了阈值(对应 Java 虚拟机参数 -XX:BiasedLockingBulkRebiasThreshold,默认为 20)那么 Java 虚拟机会宣布这个类的偏向锁失效。
具体的做法便是在每个类中维护一个 epoch 值,你可以理解为第几代偏向锁。当设置偏向锁时,Java 虚拟机需要将该 epoch 值复制到锁对象的 Mark Word 中。
在宣布某个类的偏向锁失效时,Java 虚拟机实则将该类的 epoch 值加 1,表示之前那一代的偏向锁已经失效。而新设置的偏向锁则需要复制新的 epoch 值。为了保证当前持有偏向锁并且已加锁的线程不会因此丢锁,Java 虚拟机需要遍历所有线程的 Java 栈,找出该类已加锁的实例,并且将它们标记字段中的 epoch 值加 1。该操作需要所有线程处于安全点状态。
如果总撤销数超过另一个阈值(对应 Java 虚拟机参数 -XX:BiasedLockingBulkRevokeThreshold,默认值为 40),那么 Java 虚拟机会认为这个类已经不再适合偏向锁。此时,Java 虚拟机会撤销该类实例的偏向锁,并且在之后的加锁过程中直接为该类实例设置轻量级锁。
5.2 偏向锁的撤销过程
一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为 0),撤销后锁标志位恢复到未锁定(标志位为 01)或轻量级锁定(标志位为 00)的状态,后续的同步操作就按照上面介绍的轻量级锁那样去执行。偏向锁、轻量级锁的状态转化及对象 Mark Word 的关系如下图所示:
5.3 哈希码影响
当对象进入偏向状态的时候,Mark Word 大部分的空间都用于存储持有锁的线程 ID 了,这部分空间占用了原有存储对象哈希码的位置,那原来对象的哈希码怎么办呢?
在 Java 语言里面一个对象如果计算过哈希码,就应该一直保持该值不变,否则很多依赖对象哈希码的 API 都可能存在出错风险。而作为绝大多数对象哈希码来源的 Object::hashCode() 方法,返回的是对象的一致性哈希码(Identity HashCode),这个值是能强制保证不变的,它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变。
因此当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。在重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的 ObjectMonitor 类里有字段可以记录非加锁状态下的 Mark Word,其中自然可以存储原来的哈希码。
如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。在具体问题具体分析的前提下,有时候使用参数 -XX:-UseBiasedLocking 来禁止偏向锁优化反而可以提升性能。
6. 锁升级总结
优点 | 缺点 | 适用场景 | |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步代码块的场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程,适用自旋会消耗CPU | 追求响应时间 同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量 同步块执行速度较长 |