Synchronized的实现原理:深入理解Java线程同步机制
前言
在多线程编程中,保证线程安全是一个至关重要的问题。Java提供了synchronized关键字来实现线程同步,它是Java中最基本、最常用的同步机制。本文将深入探讨synchronized的实现原理,帮助读者更好地理解其工作机制和使用场景。
1. synchronized的基本用法
在深入原理之前,我们先回顾一下 synchronized 的三种基本用法:
1.1 同步实例方法
public synchronized void method(){// 同步代码
}
1.2 同步静态方法
public static synchronized void staticMethod(){// 同步方法
}
1.3 同步代码块
public void method(){synchronized(this){// 同步代码}
}
或者使用类对象:
public void method(){synchronized(MyClass.class){// 同步代码}
}
2. synchronized 的实现原理
synchronized 实现原理主要基于对象头和 Monitor(监视器)机制。
2.1 对象头与Mark Word
在 Hotspot 虚拟机中,一个Java对象的存储结构,在内存中的存储布局分为 3块区域:
- 对象头(Header)
- 实例数据(Instance Data)
- 对齐填充(Padding)
对象头(Object Header)又包括两部分信息:
- Mark Word:存储对象的哈希码、分代年龄、锁标志位等
- Klass Pointer(32位):指向对象类型数据的指针(指向对象所属类的元数据(Class对象),JVM 通过该指针确定对象是哪个类的实例)
指针压缩(Compressed OOPs)
作用:64位JVM中,通过压缩列表将 Klass Pointer 从8字节压缩为4字节,减少内存占用。
开启方式:默认开启(-XX:+UseCompressedOops),堆内存超过32GB时自动关闭指针压缩,恢复为8字节。
Mark Word 在不同锁状态下的结构如下:
锁状态 | 25bit | 4bit | 1bit(是否偏向锁) | 2bit(锁标志位) |
无锁 | 对象的 hashCode | 对象分代年龄 | 0 | 01 |
偏向锁 | 线程ID | Epoch | 1 | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 00 | ||
重量级锁 | 指向 Monitor 的指针 | 10 | ||
GC标记 | 空 | 11 |
2.2 Monitor (监视器)机制
synchronized 的实现依赖于对象内部的监视器锁(Monitor)。每个Java对象都与一个Monitor相关联,当线程尝试获取对象的锁时,实际上是在尝试获取对象关联的 Monitor 的所有权。
Monitor的主要组成部分:
当一个线程尝试访问被 synchronized 保存的代码块时,其实相当于,线程通过 monitorenter 指令尝试获取 monitor 的所有权:
- 获取 Monitor :线程会首先检查对象的 Mark Word 是否指向当前线程的锁记录(轻量级锁),或是否指向 Monitor 对象(重量级锁)。
- 竞争锁:如果 Monitor 已被其他线程占用,则当前线程会被阻塞,进入 Entry List 队列等待。
- 释放锁:持有锁的线程执行完同步代码块后,会释放 Monitor ,并唤醒 Entry List 中的等待线程重新竞争。
2.3 锁的升级过程
为了减少获得锁和释放锁带来的性能消耗,Java SE 1.6 引入了锁升级机制,锁的状态会随着不同的线程竞争情况,逐渐升级。
无锁 → 偏向锁 → 轻量级锁 → 重量级锁
2.3.1 偏向锁
目的:在无竞争的情况下减少同步开销。
工作原理:
- 当线程第一次访问同步块时,会在对象头和栈帧中记录偏向的线程ID
- 以后该线程进入和退出同步块时不需要进行CAS操作来加锁和解锁
- 如果有其他线程尝试竞争锁,偏向模式宣告结束
2.3.2 轻量级锁
目的:在没有多线程竞争的前提下,减少传统重量级锁的性能消耗
加锁过程:
- 在代码进入同步代码块时,如果同步对象没有被锁定,虚拟机将当前线程的栈帧各种建立一个锁记录(Lock Record)空间
- 将对象头的 Mark Word 复制到锁记录中(DisPlaced Mark Word)
- 尝试使用CAS将对象头的 Mark Word 替换为指向锁记录的指针
- 如果成功,当前线程获得锁;如果失败,表示其他线程竞争锁,当前线程尝试使用自旋来获取锁
解锁过程:
- 使用CAS操作将 DisPlaced Mark Word 替换回对象头
- 如果成功,表示没有竞争发生;如果失败表示存在竞争,锁会膨胀为重量级锁
2.3.3 重量级锁
当轻量级锁竞争激烈时,会升级为重量级锁。此时,未获得锁的线程会被阻塞,等待操作系统调度,涉及到用户态到内核态的切换,性能开销较大
3. synchronized 的底层实现
3.1 字节码层面
从字节码角度看,synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令:
public void test(){synchronized(this){System.out.println("Hello World");}
}
对应的字节码:
0: aload_0
1: dup
2: astore_1
3: monitorenter // 进入同步块
4: getstatic #2
7: ldc #3
9: invokevirtual #4
12: aload_1
13: monitorexit // 正常退出同步块
14: goto 22
17: astore_2
18: aload_1
19: monitorexit // 异常退出同步块
20: aload_2
21: athrow
22: return
可以看到,编译器会自动生成一个异常处理器,确保即使同步块中抛出异常,锁也能被正确释放。
对于同步方法,方法的访问标志中会设置 ACC_SYNCHRONIZED 标志,当方法调用时,调用指令会检查该标志。
3.2 内存语义
synchronized 具有以下内存语义:
- 进入代码块:清空工作内存中的变量副本,从主内存重新加载
- 退出同步块:将工作内存中的修改刷新到主内存
这保证了多线程环境下变量的可见性和有序性。
4. synchronized 的性能问题
- 锁粒度太大:同步范围覆盖过多无关代码,导致线程竞争加剧。
- 锁持有时间过长:同步块中包含耗时操作(如IO、网络请求)。
- 锁竞争激烈:多个线程频繁争抢同一把锁,导致上下文切换频繁。
- 锁升级频繁:大量竞争导致锁从偏向锁升级到重量级锁。
- 死锁:线程互相等待对方释放锁,导致系统停滞。
5. 优化建议
- 减少同步范围:尽量缩小同步代码块的范围;
- 降低锁粒度:将一个大锁拆分为多个小锁;
- 避免嵌套锁:尽量避免在同步块内调用其他同步方法;
- 使用并发容器:优先考虑使用 ConcurrentHashMap 等并发容器;
- 考虑读写锁:读多写少的场景考虑使用 ReadWriteLock;
6. 总结
synchronized 是Java中实现线程同步的重要机制,其底层实现涉及对象头、Monitor、锁升级等多个复杂概念。了解这些原理不仅有助于我们使用 synchronized ,还能在性能调优时做出更合理的选择。
随着Java版本的更新,synchronized 的性能已经得到了大幅提升,在大多数场景下都能提供良好的性能表现。但在高并发场景下,我们仍能需要根据具体情况选择合适的同步策略。