Synchronized的使用
我们在日常开发中使用synchronized关键字主要为下面三种情形,修饰实例方法、修饰静态方法、修饰代码块。
1. 修饰实例方法
- 加锁对象: 当前实例对象 (
this
)。 - 场景: 适用于需要保证同一实例对象的多个线程在访问某些方法时,能够保持同步,防止数据竞争。
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instance = new SynchronizedObjectLock();
@Override
public void run() {
method();
}
public synchronized void method() {
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}
public static void main(String[] args) {
Thread t1 = new Thread(instance);
Thread t2 = new Thread(instance);
t1.start();
t2.start();
}
}
输出
我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束
解释:
-
代码中创建了一个静态的
SynchronizedObjectLock
实例instance
,并且两个线程t1
和t2
都是基于这个instance
实例创建的。 -
当线程
t1
或t2
调用method()
方法时,由于该方法是synchronized
的,线程在执行这个方法时必须先获取instance
对象的锁。 -
只有当一个线程获得了
instance
对象的锁后,才能进入method()
方法执行。而在这期间,其他试图调用method()
方法的线程将被阻塞,直到锁被释放。在这个示例中,
synchronized
关键字锁定的是当前实例对象instance
,保证同一时刻只有一个线程能够执行method()
方法,确保了线程安全。
2.修饰静态方法
- 加锁对象: 当前类的
Class
对象。 - 场景: 适用于需要保证对类级别的共享资源进行同步操作时,同步静态方法可以确保同一时间只有一个线程可以访问这些静态方法,无论多少个实例存在。
我们先看下面这个代码
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instance1 = new SynchronizedObjectLock();
static SynchronizedObjectLock instance2 = new SynchronizedObjectLock();
@Override
public void run() {
method();
}
// synchronized用在普通方法上,默认的锁就是this,当前实例
public synchronized void method() {
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}
public static void main(String[] args) {
// t1和t2对应的this是两个不同的实例,所以代码不会串行
Thread t1 = new Thread(instance1);
Thread t2 = new Thread(instance2);
t1.start();
t2.start();
}
}
输出:
我是线程Thread-0
我是线程Thread-1
Thread-1结束
Thread-0结束
因为这两个线程锁定的是不同的对象,所以 t1
和 t2
不会互相等待锁的释放,也就是说它们不会因为等待锁而串行执行。这两个线程可以并行执行,各自运行自己的 method()
方法,因此不会出现串行的效果。
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instance1 = new SynchronizedObjectLock();
static SynchronizedObjectLock instance2 = new SynchronizedObjectLock();
@Override
public void run() {
method();
}
// synchronized用在静态方法上,默认的锁就是当前所在的Class类,所以无论是哪个线程访问它,需要的锁都只有一把
public static synchronized void method() {
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}
public static void main(String[] args) {
Thread t1 = new Thread(instance1);
Thread t2 = new Thread(instance2);
t1.start();
t2.start();
}
}
输出:
我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束
解释:
由于 method()
是静态方法,而 synchronized
修饰静态方法时锁定的是类的 Class
对象,因此无论 t1
调用 instance1.method()
还是 t2
调用 instance2.method()
,它们都需要获得 SynchronizedObjectLock.class
这个锁才能继续执行。
3. 修饰代码块
- 加锁对象: 可以是任意对象,由程序员指定。
- 场景: 当需要细粒度地控制锁的范围,或者希望使用不同的锁来同步不同的资源时,可以使用
synchronized
代码块,指定锁对象来保护特定代码段的执行。
public class Singleton {
private static volatile Singleton singleton = null;
public Singleton() {
}
Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
这是我们熟悉的双检锁模式,就是在代码块上使用synchronized
加锁。
细粒度控制锁的范围:
- 在这个单例模式中,并不是整个
getInstance()
方法都需要同步锁,只在singleton
为null
时,才需要通过同步代码块确保只有一个线程能够初始化该对象。 - 通过
synchronized
代码块,仅在需要时(即singleton == null
的时候)进行加锁,可以减少不必要的同步开销,从而提高程序的性能。
原理分析
我们创建下面代码:
public class SynchronizedDemo {
Object object = new Object();
public void method1() {
synchronized (object) {
}
method2();
}
private static void method2() {
}
}
执行javac命令编译生成class文件
javac SynchronizedDemo.java
使用javap命令反编译查看.class文件的信息
javap -verbose SynchronizedDemo.class
得到:
我们只需要关注框里的monitorenter
和monitorexit
即可
monitorenter
是用来获取一个对象的监视器锁的。当一个线程进入一个同步块或者方法时,它必须先获得该对象的监视器锁,这样可以确保同步块中的代码是线程安全的。
Monitorenter
和Monitorexit
指令分别会让锁计数器加1或者减1。
详细解释:
- 锁计数器的初始状态为 0:
- 当一个对象的 monitor 计数器为 0 时,意味着该对象的锁没有被任何线程持有。
- 如果线程执行 monitorenter,此时会立即获取锁,并将计数器加 1,表示该线程拥有了锁。
- 重入锁:
- 如果一个线程已经持有某个对象的锁,并再次进入同一个对象的 synchronized 方法或代码块时,JVM 允许线程重入该锁。每次重入时,锁的计数器都会递增。例如,线程第一次获取锁时计数器为 1,重入一次则计数器变为 2。
- 当线程退出同步代码块时,monitorexit 会将锁的计数器减 1,直到计数器降为 0,表示锁已被完全释放,其他线程才可以尝试获取该锁。
- 锁已被其他线程占用时:
- 如果一个线程尝试获取某个对象的锁,但该锁已被其他线程持有(即计数器大于 0),该线程会进入等待状态,直到持有锁的线程释放锁。
- 当锁持有线程通过执行 monitorexit 指令释放锁后,等待的线程会尝试重新获取锁。
可重入性
由上面我们可以发现Synchronized关键字具有可重入性。那么什么是可重入性呢?
- 可重入性,又称为递归锁,指的是同一个线程在持有某个锁时,可以再次进入该锁所保护的同步代码块或方法,而不会被阻塞。这种特性确保了线程可以在调用自己或者递归调用的过程中,继续进入同步代码,而**不造成死锁。 **
- 而
Synchronized
的重入性是通过每个锁对象(即监视器)的计数器和持有锁的线程来实现的。当一个线程第一次获得锁时,JVM 会记录锁的持有者并将计数器设置为 1。之后,当该线程再次进入同步代码块时,计数器递增。每次线程离开同步块,计数器递减。只有当计数器归零时,锁才真正被释放,允许其他线程获取该锁。
看下面的例子:
public class ReentrantLockExample {
public synchronized void methodA() {
System.out.println("methodA");
methodB();
}
public synchronized void methodB() {
System.out.println("methodB");
}
public static void main(String[] args) {
ReentrantLockExample example = new ReentrantLockExample();
example.methodA(); // 调用 methodA,会进入 methodB
}
}
- 当
methodA()
被调用时,线程首先获取ReentrantLockExample
对象的锁(修饰实例方法),锁计数器加 1。monitor计数器+1 -> 1 - 由于
methodA()
内部调用了methodB()
,此时线程仍然持有该锁,再次进入同步方法methodB()
,锁计数器加 1。monitor计数器+1 -> 2 - 当
methodB()
执行完毕后,锁计数器递减到 1。monitor计数器-1 -> 1 - 当
methodA()
执行完毕,锁计数器递减到 0,锁被释放。monitor计数器-1 -> 0
这就是Synchronized的重入性,Synchronized
关键字的重入性是通过 JVM 的 监视器锁(Monitor) 来实现的。当一个线程持有对象锁时,锁的计数器递增,每次线程退出同步代码块时计数器递减。只有当计数器降为 0 时,锁才被完全释放。这种机制允许同一线程多次进入同步代码块,避免了死锁,确保了同步操作的正确性。
可见性
我们知道Volatile关键字通过内存屏障来实现可见性**,而synchronized也是通过内存屏障保证可见性**的。
volatile
关键字在 Java 中通过以下机制来实现变量的可见性:
内存屏障:volatile
修饰的变量在读写时会插入特定的内存屏障,保证不同线程之间对该变量的可见性。内存屏障确保了对 volatile
变量的读写操作不会被重排序,并且强制刷新缓存中的数据。
- 写屏障:当一个线程写入 volatile 变量时,写操作后的指令会插入写屏障,确保这个写操作对其他线程立即可见。写屏障会将线程工作内存中的值刷新到主内存。
- 读屏障:当一个线程读取 volatile 变量时,读操作前会插入读屏障,确保在读取操作前,从主内存获取最新的值,而不是使用工作内存中的缓存值。
而synchronized中的monitorenter指令具有读屏障的作用,即执行monitorenter指令后每次读取数据时候会从主内存中读取最新的数据。
同样monitorexit具有写屏障的作用,当一个线程释放锁时,它会强制将线程工作内存中的共享变量值刷新到主内存。这意味着在 synchronized
块内对共享变量的修改在释放锁后对其他线程可见。
这就保证了:
- 在一个线程释放锁之前,它对共享变量的所有修改对其他线程是可见的。
- 另一个线程获取同一个锁时,它能够看到主内存中最新的共享变量状态。
非公平性
Synchronized
的锁依赖于底层操作系统的线程调度器。线程调度器会根据系统的负载、线程的优先级等因素来决定哪个线程可以继续执行。当多个线程争夺锁时,操作系统调度器会随机挑选一个等待的线程来执行,而不是按照线程请求锁的顺序来公平调度。这种机制的随机性导致了 Synchronized
的非公平性。
公平锁虽然可以保证线程按照先到先得的原则获取锁,但在实际应用中,这种机制容易造成锁饥饿问题。由于某些低优先级线程可能长时间得不到锁,系统吞吐量反而会下降。非公平锁则允许新来的线程快速获得锁资源,从而提高系统的整体性能。非公平性使得锁更灵活,有助于避免线程长时间等待。非公平锁可以提高锁的吞吐量。
锁升级
在jdk1.5(包含)版本之前,Synchronized是重量级锁。后来在jdk1.6版本中就引入了“偏向锁”和“轻量级锁”,通过锁的升级来解决不同并发场景下的性能问题。为什么呢?
原因:
重量级锁的实现涉及操作系统级别的线程管理,它通过操作系统的互斥量(mutex)来控制线程的阻塞和唤醒。而使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行。当线程竞争锁时,如果锁已经被其他线程占有,当前线程就会进入阻塞状态,此时操作系统会将线程挂起,直到锁被释放。
这个过程涉及到两方面的性能损耗:
- 线程挂起和恢复的开销:线程被挂起和恢复时,操作系统会进行线程调度,并在**内核态和用户态之间进行切换。**这种切换会带来较大的性能开销,尤其是在高并发的情况下,频繁的挂起和恢复操作会严重影响程序的性能。
- 上下文切换:每次线程被挂起或唤醒时,操作系统都需要保存和恢复线程的执行上下文(如寄存器状态、内存栈等)。上下文切换的频率越高,性能损耗越大。
在jdk1.6中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销。下面我们来看看。
1.自旋锁与自适应自旋锁
1.1自旋锁
为了避免线程在锁竞争时被频繁挂起和唤醒,JVM 引入了自旋锁。在轻量级锁竞争失败时,线程不会立刻被挂起,而是会短暂地自旋(不断循环等待一段时间),看锁是否很快被释放。
- 自旋的核心思想是:如果线程竞争锁的时间非常短,与其直接阻塞线程并进行上下文切换,不如让线程等待(自旋)一段时间,可能锁很快就被释放了,这样可以节省线程挂起、恢复的成本。
- 线程自旋有时间限制,如果超过一定次数还没有成功获取锁,自旋会失败,线程进入阻塞状态。
适用场景:自旋锁适合锁竞争时间非常短的场景,能减少线程的阻塞和上下文切换开销。
1.2适应性自旋锁
自旋锁的自旋次数最初是固定的,而 适应性自旋锁 会根据前一次自旋获取锁的时间来动态调整自旋次数。
- 如果某线程在前几次自旋时很快获得锁,那么 JVM 会推测当前的竞争情况较轻,增加下次的自旋时间。
- 如果多次自旋都失败,JVM 会减少自旋时间或者直接放弃自旋进入阻塞。
适用场景:适应性自旋锁通过动态调整自旋次数,适应不同的竞争环境,提高了性能的灵活性。
2.锁消除
JVM 在 JIT 编译阶段进行代码分析时,如果发现某些同步块在多线程环境中没有实际的锁竞争(比如局部变量对象没有逃逸到线程之外),则会自动移除 synchronized
所产生的锁,直接优化掉不必要的加锁操作。
- 通过逃逸分析(Escape Analysis),JVM 可以判断对象是否只在当前线程中使用,如果是,那么加锁就没有意义,JVM 会将加锁操作优化掉。
适用场景:锁消除适合那些没有实际线程安全风险的场景,JVM 通过优化移除了不必要的锁。
3.锁粗化
将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
4.锁升级
上面知道重量级锁状态时的synchronized
在释放和获取锁的时候都会从用户态切换为内核态,切换时比较消耗效率的。引入无锁、偏向锁、轻量级锁就是为了减少用户态到内核态的状态切换。提高synchronized 的性能。
锁升级过程为:无锁->偏向锁->轻量级锁->重量级锁 (注意不可逆)
下面我们先来看java对象的组成,帮助我们理解锁升级的过程。
对象的组成结构
在 Java 虚拟机中,Java 对象的内存结构主要由以下三部分组成:
1. 对象头(Object Header)
对象头是对象在内存中的第一部分,用来存储对象的元数据。它通常包括以下两个部分:
- Mark Word:用于存储对象的运行时状态信息,具体内容取决于对象的锁状态。它可能包括对象的哈希码、GC 分代年龄、锁标志位、偏向锁的线程 ID 等信息。Mark Word 的长度通常是 32 位或 64 位,取决于 JVM 的具体实现和运行环境。
- 在未锁定状态下,Mark Word 中包含对象的哈希码和 GC 的年龄信息。
- 在偏向锁状态下,Mark Word 记录偏向锁的线程 ID。
- 在轻量级锁状态下,Mark Word 记录指向线程栈中锁记录的指针。
- 在重量级锁状态下,Mark Word 记录指向重量级锁的指针。
- Class Pointer:存储指向对象所属类的元数据指针,指向方法区中的类元数据。通过这个指针,JVM 可以确定对象的类型信息和方法。
- 数组长度(仅针对数组对象):如果是数组对象,额外会有一部分空间存储数组的长度。
2. 实例数据(Instance Data)
实例数据是对象真正存储其字段(成员变量)的部分。它包含了类中定义的所有实例变量,包括从父类继承下来的字段。字段的顺序和内存布局受以下因素影响:
- 变量在类中的声明顺序。
- 数据类型:JVM 会对对象进行字段的紧凑布局,以减少内存浪费。
- 基本类型字段(如
int
、long
)会根据字节大小进行对齐。 - 引用类型字段(如
String
或其他对象的引用)存储的是对该对象的引用地址。
- 基本类型字段(如
3. 对齐填充(Padding)
为了满足 JVM 内存管理的要求,特别是为了对象大小满足 8 字节的对齐要求,可能会在对象的末尾填充一些字节。填充字节不包含任何有意义的数据,它们只是为了保证内存对齐,提高内存访问效率。
1. 无锁状态
初始时,JVM 中的对象没有加锁,处于无锁状态。这意味着当前没有线程在尝试访问该对象的同步方法或代码块。
- 无锁状态的特征:对象的对象头(Mark Word)中记录了对象的默认信息,如哈希码、GC标记等。
- 适用场景:没有线程竞争的场景下,不需要加锁。
2. 偏向锁
当一个线程第一次进入代码块时,JVM 会尝试为其分配偏向锁。偏向锁的机制是锁会偏向于第一次获取锁的线程,从而避免后续该线程再次加锁和解锁的开销。
- 偏向锁的实现:线程第一次进入时,JVM 会将线程 ID 记录在对象头的 Mark Word 中。之后该线程再次进入时,只需要简单检查对象头中的线程 ID 是否为自己,不需要进行复杂的加锁操作。
- 撤销偏向锁:如果出现其他线程尝试获取该锁,偏向锁会被撤销,进入锁升级的下一阶段(轻量级锁)。撤销偏向锁会带来一定的开销,因为需要暂停线程并重新分配锁。
- 适用场景:单线程访问的场景下,偏向锁能够极大地提升性能。主要应对同一个线程反复获取锁和释放锁。
3. 轻量级锁
当偏向锁被撤销,或者当多个线程几乎同时进入代码块,JVM 会将锁升级为轻量级锁。轻量级锁通过 CAS(Compare-And-Swap)操作进行竞争,不涉及操作系统层面的线程挂起和唤醒。
- 轻量级锁的实现:当线程尝试获取轻量级锁时,JVM 会将对象头中的锁记录复制到线程的栈帧中,并通过 CAS 操作尝试将对象头指向线程的栈帧。如果 CAS 成功,线程获得锁;否则,表示有竞争存在,可能需要进一步升级锁。
- 锁竞争激烈时:当多个线程频繁争抢锁,CAS 操作的失败率上升,轻量级锁会消耗较多 CPU 资源,因此在竞争激烈时锁会升级为重量级锁。
- 适用场景:少量线程竞争的场景。轻量级锁的优势是它避免了线程阻塞,只用用户态的自旋等待来获取锁。
4. 重量级锁
当锁竞争非常激烈时,JVM 会升级到重量级锁。重量级锁通过操作系统的 monitor 来管理,线程进入等待状态,直到锁被释放。
- 重量级锁的实现:重量级锁会让竞争失败的线程进入阻塞状态。阻塞线程需要操作系统层面的线程调度,涉及内核态和用户态的切换,这个过程非常耗时。
- 重量级锁的代价:线程的挂起和唤醒涉及系统调用,且需要在内核态和用户态之间切换,消耗大量系统资源,因此在高竞争环境下重量级锁的性能较低。
- 适用场景:线程竞争非常激烈的场景。虽然重量级锁性能较低,但它能够保证线程的调度和安全性,避免自旋锁在高竞争时带来的 CPU 资源浪费。
synchronized VS Lock
1.synchronized的特点
- 简单易用 :synchronized 是 Java 提供的关键字,使用方式简单,通过 method 或者 block 来实现。
- 内置锁:synchronized 使用的是 JVM 实现的内置锁,JVM 会自动进行锁优化,如偏向锁、轻量级锁和重量级锁的升级过程。
- 自动释放锁:synchronized 的锁是自动释放的,锁定的代码块或方法一旦执行完毕,JVM 会自动释放锁,避免锁的忘记释放问题。
- 非公平锁:synchronized 默认是非公平锁,多个线程争抢锁时没有明确的顺序,可能导致线程“饥饿”。
- 无条件阻塞:当一个线程获取不到锁时,synchronized 会直接进入阻塞状态,没有尝试获取锁的机制。
2.Lock的特点
- 灵活性高:Lock 是 Java java.util.concurrent.locks 包中的接口,提供了更丰富的锁控制功能,如 ReentrantLock。
- 可响应中断:Lock 提供的 lockInterruptibly() 方法允许线程在等待锁的过程中响应中断,而 synchronized 不具备这一特性。
- 尝试获取锁:Lock 提供 tryLock() 方法,允许线程在指定时间内尝试获取锁,不会无限期等待,适合超时控制的场景。
- 公平锁:ReentrantLock 支持公平锁机制,可以设置锁的公平性,确保线程按照请求锁的顺序来获取锁,避免线程饥饿问题。
- 必须手动释放锁:Lock 必须在获取锁后手动释放锁,通过 unlock() 方法实现。如果忘记释放锁,可能导致死锁或资源得不到释放,容易引发错误。
- 性能:Lock 通常在高并发场景下的性能比 synchronized 更好,尤其是在锁竞争激烈的情况下。
3. 如何选择
适合使用 synchronized 的场景:
- 简单同步需求:当代码中对并发控制的需求较简单时,synchronized 更加直观且便于使用。
- 锁争用不激烈:如果锁的竞争不激烈,synchronized 的性能表现也不错,JVM 的锁优化策略可以有效减少锁竞争带来的开销。
- 对锁的控制需求不高:如果不需要中断响应、不需要超时等待,synchronized 是很好的选择。
适合使用 Lock 的场景:
- 复杂的并发控制:需要更多灵活控制时,如需要可中断锁、超时获取锁或公平锁等,Lock 提供了更丰富的功能。
- 高并发、竞争激烈:当锁的争用非常激烈时,Lock 的性能比 synchronized 更优,特别是在复杂的并发场景中。
- 需要锁的公平性:在某些情况下,必须确保线程按照请求的顺序获取锁,避免线程饥饿问题,此时可以使用 Lock 的公平锁功能。
4. 总结
synchronized
:简单易用,适合简单的并发控制和低锁竞争场景,性能一般,但 JVM 进行了多种优化,通常可以满足大部分情况。Lock
:灵活性更高,适合复杂的并发控制需求和高并发场景,但使用起来更复杂,需要显式释放锁,并且在忘记释放锁时可能会导致问题。- 还是需要结合具体的实际业务分析采用哪个锁。synchronized锁升级是不可逆的,比如在具有高峰的场景下大量使用了synchronized锁,过了高峰还是重量级锁,那么程序效率就会受到大大影响。
参考:
https://mp.weixin.qq.com/s/2ka1cDTRyjsAGk_-ii4ngw
https://pdai.tech/md/java/thread/java-thread-x-key-synchronized.html