Java-并发.03.Synchronized

Synchronized

使用

(1) synchronized 方法:

public class Bank {
public synchronized void transfer() {
while(!resource_is_available) // 进入代码块,此时持有对象锁
wait(); // 放弃锁,并在对象上等待
// 重新获得锁, doSomething...
notifyAll(); // 通知其他等待该对象锁的线程
}
}

(2) synchronized 块:

Object object = new Object();
public void transfer(long userID, double amount) {
synchronized(object) {
// doSomething ..
}
}
  • 每个类对象都有从 Object 继承的”对象锁”, synchronized 方法利用这个对象锁保护方法内的代码片段.
  • 对于同步方法,锁是当前实例对象。
  • 对于静态同步方法,锁是当前对象的 Class 对象。
  • 对于同步方法块,锁是 Synchonized 括号里配置的对象。

实现

  • 在 synchronized 代码块前后增加的 monitorentermonitorexist 两个 JVM 字节码指令,指令的参数是 this 引用。
  • synchronized 关键字起到的作用是设置一个独占访问临界区,在进入这个临界区前要先获取对应的监视器锁,任何 Java 对象都可以成为监视器锁,声明在静态方法上时监视器锁是当前类的 Class 对象,实例方法上是当前实例。
  • synchronized 提供了原子性、可见性和防止重排序的保证。
  • JMM 中定义监视器锁的释放操作 happen-before 于后续的同一个监视器锁获取操作。再结合程序顺序规则就可以形成内存传递可见性保证。

下面以一段 Java 代码为例:

public class TestSynchronize {
private int count;
private void inc() {
synchronized (this) {
count++;
}
}
public static void main(String[] args) {
new TestSynchronize().inc();
}
}

javap 查看 inc() 方法的实现:

private void inc();
descriptor: ()V
flags: ACC_PRIVATE
Code:
stack=3, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter // monitor 1
4: aload_0
5: dup
6: getfield #2 // Field count:I
9: iconst_1
10: iadd
11: putfield #2 // Field count:I
14: aload_1
15: monitorexit // monitor 2
16: goto 24
19: astore_2
20: aload_1
21: monitorexit // monitor 3
22: aload_2
23: athrow
24: return
Exception table:
from to target type
4 16 19 any
19 22 19 any
LineNumberTable:
line 14: 0
line 15: 4

在 synchronized 代码块前后增加的 monitorentermonitorexist 两个 JVM 字节码指令,指令的参数是 this 引用。

hotspot 中对于 monitor_enter 字节码指令的实现(注,不同 JDK 版本代码可能不同):


IRT_ENTRY_NO_ASYNC(void, InterpreterRuntime::monitorenter(JavaThread* thread, BasicObjectLock* elem))
#ifdef ASSERT
 thread->last_frame().interpreter_frame_verify_monitor(elem);
#endif
 if (PrintBiasedLockingStatistics) {
   Atomic::inc(BiasedLocking::slow_path_entry_count_addr());
}
 Handle h_obj(thread, elem->obj());
 assert(Universe::heap()->is_in_reserved_or_null(h_obj()),
        "must be NULL or an object");
// 是否使用偏向锁 JVM 启动时设置的偏向锁-XX:-UseBiasedLocking=false/true
 if (UseBiasedLocking) {
   // Retry fast entry if bias is revoked to avoid unnecessary inflation
   ObjectSynchronizer::fast_enter(h_obj, elem->lock(), true, CHECK);
} else {
// 轻量级锁
   ObjectSynchronizer::slow_enter(h_obj, elem->lock(), CHECK);
}
 assert(Universe::heap()->is_in_reserved_or_null(elem->obj()),
        "must be NULL or an object");
#ifdef ASSERT
 thread->last_frame().interpreter_frame_verify_monitor(elem);
#endif
IRT_END

从上面的 monitorenter 代码可以看到先尝试偏向锁(fast_enter),若失败则改为使用轻量级锁(slow_enter),在轻量级锁的代码中如果也失败,最后兜底方案是膨胀为重量级锁

// slow_enter()函数是轻量级锁的实现,函数最后的inflate()是重量级锁
void ObjectSynchronizer::slow_enter(Handle obj, BasicLock* lock, TRAPS) {
   //获取上锁对象头部标记信息
 markOop mark = obj->mark();
 assert(!mark->has_bias_pattern(), "should not see bias pattern here");
//如果对象处于无锁状态
 if (mark->is_neutral()) {
   //将对象头部保存在lock对象中
   lock->set_displaced_header(mark);
   //通过cmpxchg进入自旋替换对象头为lock对象地址,如果替换成功则直接返回,表明获得了轻量级锁,不然继续自旋
   if (mark == (markOop) Atomic::cmpxchg_ptr(lock, obj()->mark_addr(), mark)) {
     TEVENT (slow_enter: release stacklock) ;
     return ;
  }
   // 否则判断当前对象是否上锁,并且当前线程是否是锁的占有者,如果是markword的指针指向栈帧中的LR,则重入
} else
 if (mark->has_locker() && THREAD->is_lock_owned((address)mark->locker())) {
   assert(lock != mark->locker(), "must not re-lock the same lock");
   assert(lock != (BasicLock*)obj->mark(), "don't relock with same BasicLock");
   lock->set_displaced_header(NULL);
   return;
}

#if 0
 // The following optimization isn't particularly useful.
 if (mark->has_monitor() && mark->monitor()->is_entered(THREAD)) {
   lock->set_displaced_header (NULL) ;
   return ;
}
#endif

 // 代码执行到这里,说明有多个线程竞争轻量级锁,轻量级锁通过`inflate`进行膨胀升级为重量级锁
 lock->set_displaced_header(markOopDesc::unused_mark());
 ObjectSynchronizer::inflate(THREAD, obj())->enter(THREAD);
}

对象锁的升级

上面的代码看到了 monitor_enter 的大致流程:偏向锁(fast_enter)-》轻量级锁(slow_enter)-》重量级锁,前两者的实现都使用了 Java 对象的 Mark Word 来实现。

Java SE1.6 为了减少获得锁和释放锁所带来的性能消耗,引入了“偏向锁”和“轻量级锁”,
所以在 Java SE1.6里 对象锁 一共有四种状态,无锁状态偏向锁状态轻量级锁状态重量级锁状态
它会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。

随着锁的升级, Java 对象头里的 Mark Word 存储的内容也会变化。

回顾 Java 对象的内存结构:
在 64 位 JVM 环境, Java 对象对象头中,前 64bit 是”Mark Word”, 存储了 hashCode, 锁信息, 以及分代信息等,

MarkWord 低 2 位是锁标记, 加上 1 位的偏向锁标记,一共对应 4 种锁:

../_images/Advanced-Java.Object.MarkWord64.png
图来自 https://juejin.cn/post/6844904114061590535#heading-5

无锁

无锁状态下, 对象 Mark Word 锁标志也是 01(同偏向锁一样);

偏向锁

在无锁状态下,当有线程尝试获取对象锁(前提是 JVM 开启了支持偏向锁),那么会通过 CAS 操作,将对象头的 Mark Word 替换为自己的线程 ID,并把 Mark Word 锁标志置为101,这时候对象锁进入偏向锁

偏向锁状态下,线程尝试进入同步块的过程如下:

  • 检查 Mark Word 中的 ThreadID 是否等于自己的,如果是则直接进入同步块;
  • 如果不等于自己的 ThreadID,获取偏向锁失败

所以,对于很少出现竞争的同步块,偏向锁的效率很高(只是简单检查)

偏向锁不会立刻撤销,而是等到下次全局安全点(safe point,当前 JVM 没有正在执行的字节码),JVM 会检查持有偏向锁的线程是否还活着,

  • 如果否,会设置 Mark Word 为无锁状态,随后偏向新的线程;
  • 如果是,已持有偏向锁的线程继续执行,竞争失败的线程开始自旋+CAS(轻量级锁

可以看到偏向锁是一直等到有竞争才会释放,偏向锁的撤销需要等到下个 safe point。

是否要用偏向锁,取决于:

  1. 偏向锁在 JDK 6及以后的 JVM 里是默认启用的。可以通过 JVM 参数关闭偏向锁:-XX:-UseBiasedLocking=false
  2. 如果对象已经调用过 hashCode 方法,Mark Word 中会存储 hashcode 的值,这种对象就无法转换为偏向锁了,而是直接从无锁转换为轻量级锁,但这对轻量锁和重量所没关系,因为这两种锁会各自保存 HashCode(前者会保存在栈帧中的 Lock Record,后者保存在 Monitor 对象中)@ref: Advanced-Java.05.对象内存结构

偏向锁中的 epoch 解析: 偏向锁里面的epoch作用_Java多线程-IT乾坤技术博客

轻量级锁

上面提到,如果线程 A 已经持有了偏向锁,线程 B 尝试解锁的时候会使用 CAS,也即进入了轻量级锁,轻量级锁的实现是自旋+CAS,

  • 竞争线程会在自己的栈帧中创建 Lock Record(锁记录),并将对象头中的 Mark Word 复制到 Lock Record
  • 竞争线程使用 CAS,尝试将对象头的 Mark Word 替换为指向 Lock Record 的指针
  • CAS 失败则自旋获取锁,自旋超过一定次数,对象锁升级为重量级锁

上面的自旋次数默认是10次,可以使用-XX:PreBlockSpin 来更改,自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。

@doubt: 当是偏向锁时,另一个线程 B 尝试 CAS,CAS 的 exceptVal 是“无锁状态下的 MarkWord”?那么在 B 中如何获取“无锁状态下的 MarkWord”?

重量级锁

对象锁升级为重量级锁:Mark Word 的锁标记设置为 00,Mark Word 中指向 Lock Record 的指针被替换为指向重量级锁的指针,

../_images/Java-并发.03.Synchronized-2023-05-28-1.png

图片来自 Synchronization - Synchronization - OpenJDK Wiki

重量级锁解析

上面提到了轻量级锁在自旋失败后,会膨胀为重量级锁,重量级锁是由 ObjectMonitor 对象实现的,每个 Java 对象升级到重量级锁后,Mark Word 内会存储指向 ObjectMonitor 对象的指针。

先看 ObjectMonitor 的主要成员:

ObjectMonitor() {  
_recursions = 0; // 线程重入次数
_owner = NULL; // 指向拥有该 monitor 的线程
_WaitSet = NULL; // 等待线程 双向循环链表_WaitSet 指向第一个节点
_EntryList = NULL ; // _owner 从该双向循环链表中唤醒线程,

}
  • _recursions:如果线程 a 已经持有 monitor 锁的情况下,再次加锁,recursions+1(类似 ReentrantLock 的 State 计数器)
  • _owner:初始时为 NULL。当有线程占有该 monitor 时 owner 标记为该线程的 ID。当线程释放 monitor 时 owner 恢复为 NULL。_owner 是一个临界资源 JVM 是通过 CAS 操作来保证其线程安全的(类似 ReentrantLock 的 exclusiveOwnerThread)
  • _EntryList: 竞争队列,因抢锁失败的线程会进入这个队列(类似 ReentratLock 的“同步队列”)
  • _WaitSet: 等待队列,因调用 wait 方法而被阻塞的线程会被放在该队列中( 类似 Condition 对象的“等待队列”)

【图】 Monitor 模型:

  1. 抢锁的线程先进入 EntrySet
  2. EntrySet 队列中的线程尝试 acquire() 锁,成功后改写 Owner 为线程自己,
  3. 持有锁的线程调用了 wait(),会释放锁并进入 WaitSet
  4. WaitSet 中的线程,被其他线程 notify()唤醒后,会尝试 acquire()
  5. 持有锁的线程释放锁
    Monitor模型

↑ 只有 acquire 成功的线程是活动的(深色表示),其他的线程都是等待状态(浅色)

ObjectMonitor 的 C++代码实现参考 @ref: synchronized 实现原理 | 小米信息部技术团队

➤ 回到开头的例子:

public class Bank {
public synchronized void transfer() { // (1)
while(!resource_is_available){
wait(); // (2)
}
notifyAll(); // (3)
}
}
  • 执行到(1)进入 sync 代码块,当前线程 A 获得了对象锁;
  • 执行(2)wait 后,线程会释放锁,进入 waitSet 队列,并调用 park 让出 CPU
  • 另一个线程 B 再尝试获取锁,如果成功且调用了 (3)notifyAll,A 线程被唤醒,然后尝试抢锁;
  • 假设有线程 C 尝试获取锁失败,C 线程会 park,然后进入 cxq 队列,当有线程释放锁时,会尝试从 cxq 中删除线程 C 的节点,然后把 C 加入 entrySet,C 再尝试抢锁

需要注意的:

  • 调用 wait 后,线程会放弃锁,其他线程可以尝试抢锁;
  • waitSet 是调用 wait 的线程的队列,cxq 是抢锁失败的线程的队列
  • notify:先 wait 的线程,先被唤醒;
  • notifyAll:最后 wait 的线程,先被唤醒;
  • wait 可以响应 InterruptedException 异常

@ref:: http://lovestblog.cn/blog/2016/03/27/object-wait-notify/

对象锁的优化-消除&粗化

锁粗化:如果在代码中,连续对同一个对象加锁,JIT(不确定)会把这块代码加入到一个同步块中;

锁消除:是虚拟机另外一种锁的优化,这种优化更彻底,Java 虚拟机在 JIT 编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下 StringBuffer 的 append 是一个同步方法,但是在 add 方法中的 StringBuffer 属于一个局部变量,并且不会被其他线程所使用,因此 StringBuffer 不可能存在共享资源竞争的情景,JVM 会自动将其锁消除。

// 参考自 https://blog.csdn.net/javazejian/article/details/72828483
public class StringBufferRemoveSync {

public void add(String str1, String str2) {
//StringBuffer是线程安全,由于sb只会在append方法中使用,不可能被其他线程引用
//因此sb属于不可能共享的资源,JVM会自动消除内部的锁
StringBuffer sb = new StringBuffer();
sb.append(str1).append(str2);
}

public static void main(String[] args) {
StringBufferRemoveSync rmsync = new StringBufferRemoveSync();
for (int i = 0; i < 10000000; i++) {
rmsync.add("abc", "123");
}
}
}

需要注意的是,锁操作的 happens-before 规则的关键字是同一把锁。也就意味着,如果编译器能够(通过逃逸分析)证明某把锁仅被同一线程持有,那么它可以移除相应的加锁解锁操作。因此也就不再强制刷新缓存。举个例子,即时编译后的 synchronized (new Object()) {},可能等同于空操作,而不会强制刷新缓存。