JUC多并发编程 Synchronized与锁升级
锁规范:
- 高并发时,同步调用应该去考量锁的性能消耗。能用无锁数据结构,就不要用锁,能锁区块,就不要锁整个方法体,能用对象锁,就不要用类锁。
- 尽可能使加锁的代码块工作量尽可能的少,避免在锁代码中调用 RPC 方法
synchronized 和 内核转换:
- synchronized 锁: 由对象头中的 Mark Word 根据锁标志位的不同而被复用及锁升级策略
- Java 的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要用户态和核心态之间切换,这种切换会消耗大量的系统资源,因为用户态和核心态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些存储器、变量等,以便内核态调用结束后切换回用户态继续工作
- 在 Java 早期版本, synchronized 属于重量级锁,效率低下,因为监视器锁(monitor) 是依赖于底层操作系统 Mutex Lock(系统互斥量) 来实现的,挂起线程和恢复线程都需要转入内核态完成。阻塞和唤醒一个 Java 线程需要操作系统切换 CPU 状态来完成,这种状态切换需要耗费处理器时间。如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行时间还长。时间成本较高
Monitor(监视器锁):
- JVM 中同步就是基于进入和退出管程(Monitor) 对象实现的, 每个对象实例都会有一个 Monitor,Monitor 可以和对象一起创建、销毁、Monitor 是由 ObjectMonitor 实现,而 ObjectMonitor 是由 C++ 的 ObjectMonitor.hpp 文件实现
Mutex Lock:
- Monitor 是 JVM 底层实现的,底层代码是 C++, 本质是依赖于底层操作系统的 Mutex Lock 实现, 操作系统实现线程之间的切换需要从用户态到内核态的准换,状态转换需要耗费很多的处理器时间成本非常高。所以 synchronized 是 Java 语言中的一个重量级操作
Monitor 与 Java 对象以及线程如何关联:
- 如果一个 Java 对象被某线程锁住,则该 java 对象的 Mark Word 字段中 LockWord 指向 monitor 的起始地址
- Monitor的 Owner 字段会存放拥有相关对象锁的线程ID
Synchronized锁种类和升级
多线程访问情况:
- 只有一个线程来访问,有且唯一 Only One
- 有多个线程(2个线程 A、B 来交替访问)
- 竞争激烈, 更多个线程来访问
锁指向:
- synchronized 用的锁存在 Java 对象头里的 MarkWord 中,锁升级功能主要依赖 MarkWord 中锁标志位和释放偏向锁标志位
- 偏向锁: MarkWord 存储的是偏向的线程ID
- 轻量锁: MarkWord 存储的是指向线程栈中的 Lock Record 的指针
- 重量所: MarkWord 存储的是指向堆中的 monitor 对象的指针
无锁:
- 初始状态,一个对象被实例化后,如果还没有任何线程竞争锁,那么它就为无锁状态(001)
- 当且仅有 hashcode 被调用后, 才会记录 hashcode 编码
import org.openjdk.jol.info.ClassLayout;
public class SynchronizedUpDemo {
public static void main(String[] args) {
// new 一个对象,如果不调用 hashcode,不会记录 hashcode 编码
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
System.out.println("10进制:" +o.hashCode());
System.out.println("16进制:" +Integer.toHexString(o.hashCode()));
System.out.println("2进制:" +Integer.toBinaryString(o.hashCode()));
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
偏向锁:
- 当线程 A 第一次竞争到锁时, 通过操作修改 Mark Word 的偏向锁线程ID、偏向模式。
- 如果不存在其他线程竞争,那么持有偏向锁的线程将永远不需要进行同步
- 避免频繁出现用户态到内核态的切换
- 当一段同步代码一直被一个线程多次访问,由于只有一个线程那么该线程在后续访问时便会自动获得锁
import java.util.concurrent.locks.ReentrantLock;
class Ticket{
private int number = 50;
ReentrantLock lock = new ReentrantLock(false);
public void sale() {
lock.lock();
try {
if(number > 0) {
System.out.println(Thread.currentThread().getName() + "卖出第:\t" + (number--) + "\t 还剩下:" + number );
}
}finally {
lock.unlock();
}
}
}
public class SaleTicketDemo {
public static void main(String[] args) {
Ticket ticket = new Ticket();
new Thread(()-> {for(int i = 0; i < 55; i++) ticket.sale(); },"a").start();
new Thread(()-> {for(int i = 0; i < 55; i++) ticket.sale(); },"b").start();
new Thread(()-> {for(int i = 0; i < 55; i++) ticket.sale(); },"c").start();
}
}
理论落地:
只需要在锁第一次被拥有的时候,记录偏向线程ID,这样偏向线程就一直持有锁(后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接会去检查锁的 MarkWord 里面是不是放的自己线程ID)
- 如果相等,表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了,直到竞争发生才释放锁,以后每次同步,检查锁的偏向线程ID和当前线程ID 是否一致,如果一致直接进入同步。无需每次加锁解锁都去 CAS 更新对象头。如果自始至终使用锁的线程只有一个,很明显偏向锁没有额外开销,性能极高
- 如果不等,表示发生了竞争,锁已经不是总是偏向一个线程了,这个时候会尝试使用 CAS 来替代 MarkWord 里面的线程 ID为线程的ID
- 竞争成功,表示之前的线程不存在了,MarkWord 里面的线程 ID 为新线程的 ID,锁不会升级,仍然为偏向锁
- 竞争失败,这时候可能需要升级变为轻量级锁,才能保证线程公平竞争锁
JVM 命令:
- 使用 Java -XX:+PrintFlagsInitial | grep BiasedLock*, 查看默认参数
- 实际上偏向锁在 JDK1.6 之后默认开启的,但是启动有延迟
- 开启偏向锁: -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0, 关闭偏向锁则默认直接会进入轻量级锁状态
偏向锁示例代码:
import org.openjdk.jol.info.ClassLayout;
import java.util.concurrent.TimeUnit;
public class SynchronizedUpDemo {
public static void main(String[] args) {
// 通过睡眠,达到延迟秒数后,可进入偏向锁
try{ TimeUnit.SECONDS.sleep(5); } catch (InterruptedException e) { e.printStackTrace(); }
Object o = new Object();
// 锁状态是101 可偏向锁的状态,可是由于 o 对象未使用 synchronized, 所以 线程 ID 是空的,其余数据跟上述无锁状态一样
System.out.println(ClassLayout.parseInstance(o).toPrintable());
new Thread(()-> {
synchronized (o) {
// 关闭偏向锁,会跳级进入轻量级锁,需要编辑延迟参数 -XX:BiasedLockingStartupDelay=0
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
},"t1").start();
}
}
偏向锁的撤销:
- 当有另外线程逐步来竞争锁的时候,就不会再使用偏向锁了,要升级未轻量级锁
- 竞争线程尝试 CAS 更新对象头失败,会等待到全局安全点(此时不会执行任何代码)撤销偏向锁
- 第一个线程正在执行 synchronized 方法(处于同步块),它还没有执行完,其他线程来抢夺,该偏向锁会被取消掉并出现锁升级。此时轻量级锁由原持有锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁
- 第一个线程执行完成 synchronized 方法(退出同步块), 则将对象头设置成无锁状态并撤销偏向锁,重新偏向
轻量级锁
- 多线程竞争,但是任意时刻最多只有一个线程竞争,即不存在锁竞争太过激烈的情况,也没有线程阻塞
- 有线程参与锁的竞争,但是获取锁的冲突时间极短(本质就是自旋锁 CAS)
- 在进程近乎交替执行同步块时提升性能,在没有多线程竞争的前提下,通过 CAS 减少重量级锁的使用操作系统互斥量产生的性能消耗
- 升级时机: 当关闭偏向锁功能或多线程竞争偏向锁会导致偏向锁升级为轻量级锁
轻量级锁的加锁:
- JVM 会为每个线程在当前线程帧中创建用于存储锁记录的空间,官方称为 Displaced Mark Word。弱一个线程获得锁时发现是轻量级锁,会把锁的 MarkWord 复制到自己的 Displaced Mark Word 里面,然后线程尝试用 CAS 将锁的 MarkWord 替换为指向锁记录的锁指针。如果成功,当前线程获得锁,如果失败,表示 Mark Word 已经被替换成了其他线程的锁记录,说明在与其他线程竞争锁,当前线程尝试使用自旋来获取锁
轻量级锁的释放:
- 在释放锁时,当前线程会使用 CAS 操作将 Displaced Mark Word 的内容入复制回锁的 Mark Word 里面。如果没有发生竞争,那么这个复制的操作会成功。如果有其他线程因为自旋次数导致轻量级锁升级重量级锁,那么 CAS 操作会失败,此时会释放锁并唤醒被阻塞的线程。
代码示例:
- 关闭 偏向锁,可以直接进入轻量级锁(-XX:-UseBiasedLocking)
import org.openjdk.jol.info.ClassLayout;
public class SynchronizedUpDemo {
public static void main(String[] args) {
Object o = new Object();
new Thread(()-> {
synchronized (o) {
// 需要在 VM 配置-XX:-UseBiasedLocking
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
},"t1").start();
}
}
自旋的次数和程度:
java6之前:
- 默认启用,默认情况下自旋次数是 10次 使用 -XX:PreBlockSpin=10 来修改
- 自旋的线程数超过了 CPU 核数的一半
java6之后:
- 线程如果自旋成功了,那下次自旋的最大次数会增加,因为 JVM 认为既然上次成功了,那么这一次也很大概率会成功。反之,如果很少会自旋成功,那么下次会减少自旋的次数甚至不自旋,避免 CPU 空转
偏向锁和轻量锁的区别:
- 争夺轻量级锁失败时,自旋尝试抢占锁
- 轻量级锁每次退出同步块都需要释放锁,而偏向锁是在竞争发生时才释放锁
重锁
- 有大量的线程参与锁的竞争,冲突性很高
- 锁标志位:指向互斥量(重量级锁)的指针
重量级锁原理:
- Java 中 synchronized 的重量级锁,是基于进入和退出 Monitor对象实现的。在编译时会将同步块开始位置插入 monitor enter 指令,在结束位置插入 monitor exit 指令
- 当线程执行到 monitor enter 指令时,会尝试获取对象对应的 Monitor 所有权, 如果获取到了,即获取到了锁,会在 Monitor 的 owner 中存放当前线程的 ID,这样它将处于锁定状态,除非退出同步块,否则其他线程无法获取到这个 Monitor
import org.openjdk.jol.info.ClassLayout;
import java.util.concurrent.TimeUnit;
public class SynchronizedUpDemo {
public static void main(String[] args) {
Object o = new Object();
new Thread(()-> {
synchronized (o) {
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
},"t1").start();
new Thread(()-> {
synchronized (o) {
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
},"t2").start();
}
}
锁升级总结
锁升级和HashCode关系:
- 在无锁状态下,Mark Word 中可以存储对象的 identity hash code 值。当对象的 hashCode() 方法第一次被调用时,JVM 会生成对应的 identity hash code 值并将该值存储在 Mark Word中
- 对于偏向锁,在线程获取偏向锁时,会用 Thread ID 和 epoch 值覆盖 identity hash code 所在的位置。如果一个对象的 hashCode() 方法已经被调用过一次之后,这个对象不能设置偏向锁,升级为轻量级锁。因为如果可以的化,那 Mark Word 中的 identity hash code 必然会被偏向线程 ID 覆盖,这就会造成同一个对象前后两次调用 hashCode() 方法得到的结果不一致,如果在偏向锁状态下收到计算其一致性哈希码请求时,它的偏向状态会立即撤销,并且锁会膨胀为重量级锁
- 升级为轻量级锁时, JVM 会在当前线程的栈帧中创建一个锁记录(Lock Record)空间,用于存储锁对象的 Mark Word 拷贝,该拷贝中可以包含 identity hash code, 所以轻量级锁可以和 identity hash code 共存,哈希码和GC年龄自然保存在此,释放锁后会将这些信息写回到对象头
- 升级为重量锁后,Mark Word 保存的重量级锁指针,代表重量级锁的 ObjectMonitor 类里有字段记录非加锁状态下的 Mark Word, 锁释放后也会将信息写回对象头
import org.openjdk.jol.info.ClassLayout;
import java.util.concurrent.TimeUnit;
public class SynchronizedUpDemo {
public static void main(String[] args) {
try{ TimeUnit.SECONDS.sleep(5); } catch (InterruptedException e) { e.printStackTrace(); }
Object o = new Object();
System.out.println("本应该有偏向锁");
System.out.println(ClassLayout.parseInstance(o).toPrintable());
// o.hashCode();
// synchronized (o) {
// System.out.println("本应该有偏向锁,由于 hashcode 一致性, 转化为轻量级锁");
// System.out.println(ClassLayout.parseInstance(o).toPrintable());
// }
synchronized (o) {
o.hashCode();
System.out.println("在处于偏向锁过程中,由于 hashcode 一致性, 膨胀为重量锁");
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}
锁的优缺点对比:
锁 | 优点 | 缺点 | 使用场景 |
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁的竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的相应速度 | 如果始终得不到锁竞争线程,使用自旋会消耗 CPU | 追求响应时间,同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗 CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度较长 |
synchronized 在修饰方法和代码块在字节码上实现方式有很大差异,但是内部实现还是基于对象头的 MarkWord来实现的。JDK 1.6 之前 synchronized 使用的重量级锁,JDK 1.6 之后进行了优化,拥有了 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁的升级过程,而不是无论什么情况都使用重量级锁。
JIT 编译器对锁的优化:
JIT(Just In Time Compiler, 一般翻译为即时编译器)
锁消除:
public class LockClearDemo {
static Object objectLock = new Object();
public void m1() {
// synchronized (objectLock) {
// System.out.println("hello -- LockClearDemo");
// }
// 锁消除问题,JIT 编译器会无视它,synchronized (o),每次 new 出来的,不存在了,非正常的
Object o = new Object();
synchronized (o) {
System.out.println("hello -- LockClearDemo\t" + o.hashCode() + "\t" + objectLock.hashCode());
}
}
public static void main(String[] args) {
LockClearDemo lockClearDemo = new LockClearDemo();
for (int i = 1; i <= 10; i++) {
new Thread(()-> {
lockClearDemo.m1();
},String.valueOf(i)).start();
}
}
}
锁粗化:
public class LockBigDemo {
static Object objectLock = new Object();
public static void main(String[] args) {
new Thread(()-> {
synchronized (objectLock){
System.out.println("1111");
}
synchronized (objectLock){
System.out.println("2222");
}
synchronized (objectLock){
System.out.println("3333");
}
synchronized (objectLock){
System.out.println("4444");
}
// 假如方法中首尾相连,前后相邻都是同一个锁对象,那 JIT 编译器就会把这个 synchronized 块合并成一个大块
// 加粗加大范围,一次申请锁使用即可,避免此次申请和释放锁,提升性能
synchronized (objectLock){
System.out.println("1111");
System.out.println("2222");
System.out.println("3333");
System.out.println("4444");
}
},"t1").start();
}
}