Java多线程与高并发专题——深入synchronized
类锁、对象锁
synchronized的使用一般就是同步方法和同步代码块。
synchronized的锁是基于对象实现的。
如果使用同步方法
- static:此时使用的是当前类.class作为锁(类锁)
- 非static:此时使用的是当前对象做为锁(对象锁)
synchronized的优化
在JDK1.5的时候,Doug Lee推出了ReentrantLock,lock的性能远高于synchronized,所以JDK团队就在JDK1.6中,对synchronized做了大量的优化。
主要是以下三个方面:
锁消除
在synchronized修饰的代码中,如果不存在操作临界资源的情况,会触发锁消除,你即便写了synchronized,他也不会触发。
public synchronized void method(){
// 没有操作临界资源
// 此时这个方法的synchronized会被消除
}
锁膨胀
如果在一个循环中,频繁的获取和释放锁资源,这样带来的消耗很大,锁膨胀就是将锁的范围扩大,避免频繁的竞争和获取锁资源带来不必要的消耗。
public void method(){
for(int i = 0;i < 999999;i++){
synchronized(对象){
}
}
// 这是上面的代码会触发锁膨胀
// 相当于锁往上扩了一层
synchronized(对象){
for(int i = 0;i < 999999;i++){
}
}
}
锁升级
ReentrantLock的实现,是先基于乐观锁的CAS尝试获取锁资源,如果拿不到锁资源,才会挂起线程。synchronized在JDK1.6之前,完全就是获取不到锁,立即挂起当前线程,所以synchronized性能比较差。synchronized就在JDK1.6做了锁升级的优化。
锁升级的过程从无锁开始,逐步升级为偏向锁、轻量级锁,最后是重量级锁。
-
无锁:对象刚创建时处于无锁状态,没有任何线程竞争。
-
偏向锁:当一个线程访问同步块并获取锁时,对象头中的锁标志位会变为偏向锁,同时记录获得锁的线程 ID。偏向锁假定在接下来的一段时间内,总是这个线程获取锁,从而减少不必要的同步操作。
-
轻量级锁:当有第二个线程来竞争锁时,偏向锁会升级为轻量级锁。此时线程会通过自旋的方式来尝试获取锁,避免直接进入阻塞状态。
-
重量级锁:如果自旋一定次数后还没有获取到锁,或者有多个线程同时竞争,轻量级锁就会升级为重量级锁。重量级锁会导致线程阻塞,需要依靠操作系统的互斥量来实现同步。
无锁、匿名偏向:当前对象没有作为锁存在。
偏向锁:如果当前锁资源,只有一个线程在频繁的获取和释放,那么这个线程过来,只需要判断,当前指向的线程是否是当前线程 。
如果是,直接拿着锁资源走。
如果当前线程不是我,基于CAS的方式,尝试将偏向锁指向当前线程。如果获取不到,触发锁升级,升级为轻量级锁。(偏向锁状态出现了锁竞争的情况)
轻量级锁:会采用自旋锁的方式去频繁的以CAS的形式获取锁资源(采用的是自适应自旋锁)
如果成功获取到,拿着锁资源走
如果自旋了一定次数,没拿到锁资源,锁升级。
重量级锁:就是最传统的synchronized方式,拿不到锁资源,就挂起当前线程。(用户态&内核态的切换)
锁升级的目的是为了在没有激烈竞争时,通过偏向锁和轻量级锁来提高性能,减少线程阻塞和唤醒的开销;而在竞争激烈时,再升级为重量级锁来保证线程安全。
不同级别的锁实现是有区别的。
偏向锁是通过在对象头的标记字段中记录线程 ID 等信息来实现的。
轻量级锁是基于 CAS(Compare and Swap)操作来尝试获取锁,通过在对象头中存储指向线程栈中锁记录的指针等信息来实现。
在升级到重量级锁时,会依赖 ObjectMonitor 来进行阻塞和唤醒线程等操作。
关于锁升级
锁默认情况下,开启了偏向锁延迟。
偏向锁在升级为轻量级锁时,会涉及到偏向锁撤销,需要等到一个安全点(STW),才可以做偏向锁撤销,在明知道有并发情况,就可以选择不开启偏向锁,或者是设置偏向锁延迟开启
因为JVM在启动时,需要加载大量的.class文件到内存中,这个操作会涉及到synchronized的使用,为了避免出现偏向锁撤销操作,JVM启动初期,有一个延迟4s开启偏向锁的操作
如果正常开启偏向锁了,那么不会出现无锁状态,对象会直接变为匿名偏向
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
new Thread(() -> {
synchronized (o){
//t1 - 偏向锁
System.out.println("t1:" + ClassLayout.parseInstance(o).toPrintable());
}
}).start();
//main - 偏向锁 - 轻量级锁CAS - 重量级锁
synchronized (o){
System.out.println("main:" + ClassLayout.parseInstance(o).toPrintable());
}
}
整个锁升级状态的转变:
- 锁的升级:从无锁状态到偏向锁,再到轻量级锁,最后到重量级锁,锁的状态会根据竞争情况逐步升级。锁的升级是为了在不同的竞争程度下优化性能。
- 锁的降级:在 JDK 1.8 中,锁的降级是不可逆的。一旦锁升级为重量级锁,就不会再降级为轻量级锁或偏向锁。
实现原理
其底层实现主要包括两部分:
对象头(Object Header):每个 Java 对象在内存中都包含一个对象头,用于存储对象的运行时数据。
- Mark Word:存储对象的哈希码、GC 分代年龄、锁状态等信息。
- Class Pointer:指向对象的类元数据。
Monitor:Monitor 是每个对象都有的同步机制,负责管理对象的锁。
- Owner:当前持有锁的线程。
- EntryList:等待获取锁的线程队列。
- WaitSet:等待 notify 或 notifyAll 唤醒的线程队列。
MarkWord
为了可以在Java中看到对象头的MarkWord信息,需要导入依赖
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
MarkWord中标记着四种锁的信息:无锁、偏向锁、轻量级锁、重量级锁
- 无锁状态:对象刚创建时,Mark Word 中的锁状态为无锁,Owner 为 null。
- 偏向锁:当一个线程第一次获取锁时,如果锁处于无锁状态,JVM 会尝试将锁升级为偏向锁。偏向锁会将 Mark Word 中的锁状态设置为偏向锁状态,并将当前线程的 ID 存储在 Mark Word 中。如果同一个线程再次获取锁,可以直接使用偏向锁,无需 CAS 操作。
- 轻量级锁:当多个线程竞争同一个锁时,如果锁处于偏向锁状态,JVM 会将锁升级为轻量级锁。轻量级锁使用 CAS 操作尝试获取锁,如果成功,将 Mark Word 中的锁状态设置为轻量级锁状态,并将当前线程的栈帧中的锁记录地址存储在 Mark Word 中。如果 CAS 操作失败,线程会进入自旋状态,继续尝试获取锁。
- 重量级锁:当轻量级锁的自旋次数达到一定阈值,或者锁竞争非常激烈时,JVM 会将锁升级为重量级锁。重量级锁会将线程阻塞,进入 EntryList 阻塞队列,等待锁释放。当持有锁的线程释放锁时,会唤醒 EntryList 中的一个线程去获取锁。
Monitor锁
我们先来看下获取和释放 monitor 锁的时机,每个 Java 对象都可以用作一个实现同步的锁,这个锁也被称为内置锁或 monitor 锁,获得 monitor 锁的唯一途径就是进入由这个锁保护的同步代码块或同步方法,线程在进入被 synchronized 保护的代码块之前,会自动获取锁,并且无论是正常路径退出,还是通过抛出异常退出,在退出的时候都会自动释放锁。
同步代码块
我们可以用 javap -verbose 看使用synchronized后的同步代码块的反汇编内容,synchronized 代码块实际上多了 monitorenter 和 monitorexit 指令。可以把执行 monitorenter 理解为加锁,执行 monitorexit 理解为释放锁,每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为 0,我们来具体看一下 monitorenter 和 monitorexit的含义:
monitorenter
执行 monitorenter 的线程尝试获得 monitor 的所有权,会发生以下这三种情况之一:
- 如果该 monitor 的计数为 0,则线程获得该 monitor 并将其计数设置为 1。然后,该线程就是这个monitor 的所有者。
- 如果线程已经拥有了这个 monitor ,则它将重新进入,并且累加计数。
- 如果其他线程已经拥有了这个 monitor,那个这个线程就会被阻塞,直到这个 monitor 的计数变成为0,代表这个 monitor 已经被释放了,于是当前这个线程就会再次尝试获取这个 monitor。
monitorexit
monitorexit 的作用是将 monitor 的计数器减 1,直到减为 0 为止。代表这个 monitor 已经被释放了,已经没有任何线程拥有它了,也就代表着解锁,所以,其他正在等待这个 monitor 的线程,此
时便可以再次尝试获取这个 monitor 的所有权。
同步方法
同步代码块是使用 monitorenter 和 monitorexit 指令实现的。而对于 synchronized 方法,并不是依靠 monitorenter 和 monitorexit 指令实现的,被 javap 反汇编后可以看到,synchronized 方法和普通方法大部分是一样的,不同在于,这个方法会有一个叫作 ACC_SYNCHRONIZED 的 flag 修饰符,来表明它是同步方法。
当某个线程要访问某个方法的时候,会首先检查方法是否有 ACC_SYNCHRONIZED 标志,如果有则需要先获得 monitor 锁,然后才能开始执行方法,方法执行之后再释放 monitor 锁。其他方面, synchronized 方法和刚才的 synchronized 代码块是很类似的,例如这时如果其他线程来请求执行方法,也会因为无法获得 monitor 锁而被阻塞。
ObjectMonitor
找到ObjectMonitor的两个文件,hpp,cpp
先查看核心属性:OpenJDK Maintenance outage
ObjectMonitor() {
_header = NULL; // header存储着MarkWord
_count = 0; // 竞争锁的线程个数
_waiters = 0, // wait的线程个数
_recursions = 0; // 标识当前synchronized锁重入的次数
_object = NULL;
_owner = NULL; // 持有锁的线程
_WaitSet = NULL; // 保存wait的线程信息,是一个双向链表
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; // 获取锁资源失败后,线程要放到当前的单向链表中
FreeNext = NULL ;
_EntryList = NULL ; // _cxq以及被唤醒的WaitSet中的线程,在一定机制下,会放到EntryList中
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
TryLock
int ObjectMonitor::TryLock (Thread * Self) {
for (;;) {
// 拿到持有锁的线程
void * own = _owner ;
// 如果有线程持有锁,告辞
if (own != NULL) return 0 ;
// 说明没有线程持有锁,own是null,cmpxchg指令就是底层的CAS实现。
if (Atomic::cmpxchg_ptr (Self, &_owner, NULL) == NULL) {
// 成功获取锁资源
return 1 ;
}
// 这里其实重试操作没什么意义,直接返回-1
// 因为到这个地方,就说明是竞争锁失败了,立马重试是没意义的
if (true) return -1 ;
}
}
try_entry
bool ObjectMonitor::try_enter(Thread* THREAD) {
// 在判断_owner是不是当前线程
if (THREAD != _owner) {
// 判断当前持有锁的线程是否是当前线程,说明轻量级锁刚刚升级过来的情况
if (THREAD->is_lock_owned ((address)_owner)) {
_owner = THREAD ;
_recursions = 1 ;
OwnerIsThread = 1 ;
return true;
}
// CAS操作,尝试获取锁资源
if (Atomic::cmpxchg_ptr (THREAD, &_owner, NULL) != NULL) {
// 没拿到锁资源,告辞
return false;
}
// 拿到锁资源
return true;
} else {
// 将_recursions + 1,代表锁重入操作。
_recursions++;
return true;
}
}
enter
(想方设法拿到锁资源,如果没拿到,挂起扔到_cxq单向链表中)
void ATTR ObjectMonitor::enter(TRAPS) {
// 拿到当前线程
Thread * const Self = THREAD ;
void * cur ;
// CAS走你,
cur = Atomic::cmpxchg_ptr (Self, &_owner, NULL) ;
if (cur == NULL) {
// 拿锁成功
return ;
}
// 锁重入操作
if (cur == Self) {
// TODO-FIXME: check for integer overflow! BUGID 6557169.
_recursions ++ ;
return ;
}
//轻量级锁过来的。
if (Self->is_lock_owned ((address)cur)) {
_recursions = 1 ;
_owner = Self ;
OwnerIsThread = 1 ;
return ;
}
// 走到这了,没拿到锁资源,count++
// count就是记录竞争锁资源的个数
Atomic::inc_ptr(&_count);
for (;;) {
jt->set_suspend_equivalent();
// 入队操作,进到cxq中
EnterI (THREAD) ;
if (!ExitSuspendEquivalent(jt)) break ;
_recursions = 0 ;
_succ = NULL ;
exit (false, Self) ;
jt->java_suspend_self();
}
}
// count--
Atomic::dec_ptr(&_count);
}
EnterI
for (;;) {
// 入队
node._next = nxt = _cxq ;
// CAS的方式入队。
if (Atomic::cmpxchg_ptr (&node, &_cxq, nxt) == nxt) break ;
// 重新尝试获取锁资源
if (TryLock (Self) ]]> 0) {
assert (_succ != Self , "invariant") ;
assert (_owner == Self , "invariant") ;
assert (_Responsible != Self , "invariant") ;
return ;
}
}