synchronized关键字(同步锁)
二、synchronized关键字(同步锁)
2.1是什么?有什么用?
synchronized
是 Java 中的一个关键字,翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
在 Java 早期版本中,synchronized
属于 重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock
来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
不过,在 Java 6 之后, synchronized
引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized
锁的效率提升了很多。因此, synchronized
还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized
。
2.2如何使用synchronized?
synchronized
关键字的使用方式主要有下面 3 种:
- 修饰实例方法
- 修饰静态方法
- 修饰代码块
1、修饰实例方法 (锁当前对象实例)
给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁 。
synchronized void method() {
//业务代码
}
2、修饰静态方法(锁当前类)
给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。
这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。
synchronized static void method() {
//业务代码
}
静态 synchronized
方法和非静态 synchronized
方法之间的调用互斥么?不互斥!如果一个线程 A 调用一个实例对象的非静态 synchronized
方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized
方法,是允许的,不会发生互斥现象,因为访问静态 synchronized
方法占用的锁是当前类的锁,而访问非静态synchronized
方法占用的锁是当前实例对象锁。
3、修饰代码块(锁指定对象/类)
对括号里指定的对象/类加锁:
synchronized(object)
表示进入同步代码库前要获得 给定对象的锁。synchronized(类.class)
表示进入同步代码前要获得 给定 Class 的锁
synchronized(this) {
//业务代码
}
总结:
synchronized
关键字加到static
静态方法和synchronized(class)
代码块上都是是给 Class 类上锁;synchronized
关键字加到实例方法上是给对象实例上锁;- 尽量不要使用
synchronized(String a)
因为 JVM 中,字符串常量池具有缓存功能 .
2.3线程安全类
- String
- Integer
- StringBuffer
- Random
- Vector
- Hashtable
- java.util.concurrent 包下的类
这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。也可以理解为
Hashtable table = new Hashtable();
new Thread(()->{
table.put("key", "value1");
}).start();
new Thread(()->{
table.put("key", "value2");
}).start();
- 它们的每个方法是原子的
- 但注意它们多个方法的组合不是原子的
Hashtable table = new Hashtable();
// 线程1,线程2
if( table.get("key") == null) {
table.put("key", value);
}
这个并不是线程安全的
不可变类线程安全性
String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的
采取的是new一个新的对象,并将原来的value指向这个新创建的对象.
2.4锁原理
锁机制的核心基于对象头和 Monitor:
- Java 对象头通过 Mark Word 存储锁的状态(偏向锁、轻量级锁、重量级锁)。
- 每个对象可以关联一个 Monitor,实现线程间的锁竞争与管理。
- synchronized 关键字的实现依赖字节码指令
monitorenter
和monitorexit
。
Java对象头
每个 Java 对象都有一个对象头,包含用于支持锁机制的信息。
普通对象的结构(32 位虚拟机):
- Mark Word (对象头):
- 默认存储对象的哈希值和分代年龄。
- 当对象进入锁状态时,Mark Word 的内容会变化,用于存储锁相关信息(如指向 Monitor 的指针)。
- Class Pointer:
- 指向对象的 Class 元数据。
- 指向对象的 Class 元数据。
Mark Word 的锁标志位:
- 32 位虚拟机:
- 最后两位标志对象的锁状态。
- 偏向锁 (101)、轻量级锁 (00)、重量级锁 (10) 或 无锁 (01)。
- 64 位虚拟机:
- 类似,但结构更复杂,支持更多字段。
- 类似,但结构更复杂,支持更多字段。
Monitor对象(锁原理的关键)
Monitor 是 JVM 实现锁的关键结构,每个 Java 对象可以关联一个 Monitor(存储在堆中)。当一个线程对对象加锁(如进入 synchronized
块)时,会将该对象的 Mark Word 指向一个 Monitor。
Monitor 的结构:
- Owner: 指向当前持有锁的线程(线程独占 Monitor)。
- EntryList: 存储 BLOCKED 状态的线程(双向链表),等待获取锁。
- WaitSet: 存储因调用
wait()
而进入 WAITING 状态的线程(条件不满足时进入)。 - Count: 记录锁的重入次数。
Monitor 工作流程:
加锁:
- 初始时 Monitor 的
Owner
为null
。 - 当线程
T2
执行synchronized(obj)
时,Monitor 的Owner
被设置为T2
。 - 对象头的 Mark Word 指向 Monitor 对象。
- 对象原有的 Mark Word 被存储到线程栈的锁记录中(支持轻量级锁的回退机制)。
竞争锁:
- 如果
T1
也尝试对obj
加锁,但T2
还未释放锁,T1
进入 EntryList(阻塞队列)。
释放锁:
T2
执行完同步代码块后,Monitor 的Owner
被设置为null
。- Monitor 会唤醒 EntryList 中的线程进行竞争(竞争是非公平的)。
wait-notify 机制:
- 线程在调用
wait()
时会从 EntryList 转入 WaitSet。 - 调用
notify()
时线程从 WaitSet 被唤醒,再次进入竞争。
注意:
- synchronized 必须是进入同一个对象的 Monitor 才有上述的效果
- 不加 synchronized 的对象不会关联监视器,不遵从以上规则
字节码
synchronized 的字节码指令:
monitorenter
:指示线程获取锁。monitorexit
:指示线程释放锁。
public static void main(String[] args) {
Object lock = new Object();
synchronized (lock) {
System.out.println("ok");
}
}
2.5synchronized锁升级
升级过程
synchronized 是可重入、不公平的重量级锁,所以可以对其进行优化
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁// 随着竞争的增加,只能锁升级,不能降级
偏向锁(在jdk18中被抛弃)
偏向锁的基本概念和工作原理:
偏向锁是 Java 锁优化机制 的一种,设计目的是 优化只有一个线程多次获取锁的场景,减少无竞争情况下的同步开销。其核心思想是:锁会偏向于第一个获取它的线程,后续该线程再次获取锁时无需进行加锁操作。以下是偏向锁的详细原理:
偏向锁的工作流程:
-
初始状态:
- 对象在创建时,Mark Word 的最后 3 位标记为
101
(表示偏向锁状态)。 - 偏向锁默认是延迟开启的(JDK8 中延迟约 4 秒)。可以通过 JVM 参数
-XX:BiasedLockingStartupDelay=0
禁用延迟。
- 对象在创建时,Mark Word 的最后 3 位标记为
-
第一次加锁:
- 当线程 T1 第一次获取该锁时,通过 CAS 操作将线程 ID 写入 Mark Word,同时保持偏向状态。
- 如果 CAS 成功,后续 T1 再次进入同步块时,只需检查线程 ID,无需加锁或解锁操作(无竞争开销)。
-
锁撤销(Revoke Bias):
-
竞争出现时
(其他线程尝试获取锁),偏向锁会撤销:
- 如果没有竞争,可能触发重偏向,即偏向新的线程。
- 如果竞争严重,会升级为轻量级锁。
- JVM 支持批量撤销,避免重复撤销开销。
-
-
无法偏向的情况:
- 对象调用了
hashCode()
方法,因为偏向锁的 Mark Word 无法存储哈希值。 - 使用了
wait/notify
等需要依赖监视器的操作。 - 偏向锁被 JVM 参数
-XX:-UseBiasedLocking
禁用。
- 对象调用了
轻量级锁
轻量级锁是 Java 锁优化机制 中的一种,设计目标是 减少无竞争情况下的加锁和解锁开销,适用于多线程加锁但加锁时间错开的场景。与重量级锁相比,轻量级锁避免了线程阻塞,提高了性能。
可重入锁:线程可以进入任何一个它已经拥有的锁所同步着的代码块,可重入锁最大的作用是避免死锁
轻量级锁的核心原理:轻量级锁通过在每个线程的栈帧中创建一个 锁记录(Lock Record) 来存储锁对象的相关信息,同时结合 CAS 操作(Compare-And-Swap) 来实现加锁和解锁。
锁重入实例:
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
轻量级锁的加锁流程:
-
创建锁记录(Lock Record):
- 每个线程的栈帧包含一个锁记录结构,记录加锁对象的状态。
-
CAS 操作尝试加锁:
-
让锁记录中的 Object Reference 指向目标对象,并用 CAS 操作替换对象头(Mark Word)内容,将 Mark Word 的原值存入锁记录中。
-
如果 CAS 成功:
- 对象头存储了锁记录地址,状态改为
00
(表示轻量级锁),表示该线程成功获取轻量级锁。
- 对象头存储了锁记录地址,状态改为
-
如果 CAS 失败:
- 无竞争但线程重入: 添加一条新的锁记录,记录重入次数。
- 有竞争: 轻量级锁膨胀为重量级锁。
-
轻量级锁的解锁流程:
- 检查锁记录状态:
- 如果有空的锁记录,表示锁有重入,重置锁记录,重入计数减 1。
- 如果锁记录非空,用 CAS 将 Mark Word 恢复到对象头。
- CAS 成功或失败:
- CAS 成功: 表示解锁成功,轻量级锁结束。
- CAS 失败: 表示锁已经膨胀为重量级锁,进入重量级锁的解锁流程。
锁膨胀(重量级锁)
重量级锁是 Java 中的一种 线程同步机制,用于解决多线程竞争严重时的锁管理问题。当轻量级锁或偏向锁无法满足需求(如存在多个线程同时竞争锁),锁会膨胀为重量级锁。重量级锁通过 Monitor(监视器锁) 实现,依赖于操作系统的 互斥量(Mutex),会导致线程阻塞和上下文切换。
重量级锁的加锁流程
-
轻量级锁膨胀触发:
- 当线程 T1 尝试加轻量级锁时,发现另一个线程 T0 已经持有该锁,CAS 操作失败。
- JVM 判断存在竞争,进入锁膨胀流程,将轻量级锁升级为重量级锁。
-
膨胀为重量级锁:
- JVM 为目标对象分配一个 Monitor 对象。
- 对象头(Mark Word)中存储 Monitor 对象的地址,状态设置为
10
(重量级锁)。
- Monitor 对象包含以下关键字段:
- Owner:记录当前持有锁的线程(如 Thread-0)。
- EntryList:存储尝试获取锁但被阻塞的线程(如 Thread-1)。
-
竞争线程阻塞:
- Thread-1 被加入 Monitor 的 EntryList 中,线程状态变为 BLOCKED,挂起等待锁释放。
重量级锁的解锁流程:
- 持锁线程释放锁:
- 持有锁的线程(Thread-0)退出同步代码块,尝试使用 CAS 操作恢复对象头的 Mark Word。
- 如果 CAS 操作失败(因锁已经膨胀),进入重量级锁解锁流程。
- Monitor 解锁:
- 根据对象头的 Monitor 地址找到对应的 Monitor 对象。
- 将 Monitor 的 Owner 字段设置为
null
。 - 唤醒 EntryList 中等待的线程(如 Thread-1),尝试获取锁。
- 线程重新竞争锁:
- 被唤醒的线程(Thread-1)重新竞争锁,CAS 成功后获取锁,成为新的 Owner。
2.6锁优化
锁优化是 Java 中提升并发性能的重要手段,以下是常见的优化方式:
- 自旋锁:线程在获取锁失败时,通过自旋尝试重新获取锁,适用于短时间锁的竞争,但会占用 CPU 资源。
- 锁消除:通过逃逸分析发现不必要的锁,直接移除同步代码,减少无意义的锁开销。
- 锁粗化:将多次对同一对象的锁操作合并,减少加锁和解锁的次数,提高性能。
- 适应性锁:JVM 根据锁的使用情况动态调整策略(如偏向锁、自旋锁等),优化锁的性能。
自旋锁
**核心思想:**当线程尝试获取锁失败时,线程不会立即进入阻塞状态,而是通过 自旋(循环检查锁的状态)尝试重新获取锁,以避免线程阻塞和上下文切换的开销。
优点:
- 减少线程从用户态到内核态的切换,提高性能。
- 适用于锁持有时间短的场景。
缺点:
- 自旋会占用 CPU 时间,多线程长时间自旋可能造成资源浪费。
- 在单核 CPU 上,自旋毫无意义,因为同一时刻只能运行一个线程。
自旋锁的特点:
- Java 6 引入了自适应自旋锁:
- 如果前一次自旋成功,系统会增加自旋次数。
- 如果多次自旋失败,系统会减少自旋次数甚至直接阻塞线程。
- 在 Java 7 之后,是否开启自旋完全由 JVM 自动控制,开发者无法干预。
锁消除
核心思想:在代码编译时,如果 JVM 通过 逃逸分析 检测到锁对象没有线程安全问题(即不会被其他线程访问到),会将锁直接消除,避免无意义的同步操作。
实现方式:
- 逃逸分析:判断对象是否只在当前线程内使用。
- 如果对象未逃逸,可以将其视为线程私有,不需要加锁。
适用场景:
- 锁用于局部变量且没有被外部线程访问。
- 如常见的
StringBuffer
或StringBuilder
操作中,如果仅在单线程中使用,JVM 会优化去掉同步块。
示例:
public void example() {
StringBuilder sb = new StringBuilder();
sb.append("Hello");
sb.append("World");
}
在这种情况下,JVM 会消除同步锁,优化为无锁操作。
锁粗化
核心思想:将多个连续的加锁和解锁操作合并为一个锁操作,避免频繁的加锁和解锁带来的开销。
优点:
- 减少锁操作的频率,提高性能。
- 适用于多个锁操作针对同一个对象的场景。
实现方式:
- JVM 会在运行时对锁操作进行优化,将锁的作用范围扩大到所有相关操作的外部。
示例: 原始代码:
public void example() {
synchronized (lock) {
doSomething1();
}
synchronized (lock) {
doSomething2();
}
}
锁粗化后:
public void example() {
synchronized (lock) {
doSomething1();
doSomething2();
}
}
2.7活跃性
死锁
死锁:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放,由于线程被无限期地阻塞,因此程序不可能正常终止
Java 死锁产生的四个必要条件:
-
互斥条件,即当资源被一个线程使用(占有)时,别的线程不能使用
-
不可剥夺条件,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放
-
请求和保持条件,即当资源请求者在请求其他的资源的同时保持对原有资源的占有
-
循环等待条件,即存在一个等待循环队列:p1 要 p2 的资源,p2 要 p1 的资源,形成了一个等待环
路
四个条件都成立的时候,便形成死锁。死锁情况下打破上述任何一个条件,便可让死锁消失
定位
定位死锁的方法:
- 使用 jps 定位进程 id,再用 jstack id 定位死锁,找到死锁的线程去查看源码,解决优化
- Linux下可以通过 top 先定位到 CPU 占用高的 Java 进程,再利用 top -Hp 进程id 来定位是哪个线程,最后再用 jstack 的输出来看各个线程栈
- 避免死锁:避免死锁要注意加锁顺序
- 可以使用 jconsole 工具,在 jdk\bin 目录下
活锁
活锁:指的是任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试—失败—尝试—失败的过程
两个线程互相改变对方的结束条件,最后谁也无法结束:
class TestLiveLock {
static volatile int count = 10;
static final Object lock = new Object();
public static void main(String[] args) {
new Thread(() -> {
// 期望减到 0 退出循环
while (count > 0) {
Thread.sleep(200);
count--;
System.out.println("线程一count:" + count);
}
}, "t1").start();
new Thread(() -> {
// 期望超过 20 退出循环
while (count < 20) {
Thread.sleep(200);
count++;
System.out.println("线程二count:"+ count);
}
}, "t2").start();
}
}
饥饿
饥饿:一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束
2.8wait/notify原理
对比 sleep():
- 原理不同:sleep() 方法是属于 Thread 类,是线程用来控制自身流程的,使此线程暂停执行一段时间而把执行机会让给其他线程;wait() 方法属于 Object 类,用于线程间通信
- 对锁的处理机制不同:调用 sleep() 方法的过程中,线程不会释放对象锁,当调用 wait() 方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池(不释放锁其他线程怎么抢占到锁执行唤醒操作),但是都会释放 CPU
- 使用区域不同:wait() 方法必须放在**同步控制方法和同步代码块(先获取锁)**中使用,sleep() 方法则可以放在任何地方使用
底层原理:
- Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
- BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
- BLOCKED 线程会在 Owner 线程释放锁时唤醒
- WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,唤醒后并不意味者立刻获得锁,需要进入 EntryList 重新竞争
需要获取对象锁后才可以调用 锁对象.wait()
,notify 随机唤醒一个线程,notifyAll 唤醒所有线程去竞争 CPU
Object 类 API:
public final void notify():唤醒正在等待对象监视器的单个线程。
public final void notifyAll():唤醒正在等待对象监视器的所有线程。
public final void wait():导致当前线程等待,直到另一个线程调用该对象的 notify() 方法或 notifyAll()方法。
public final native void wait(long timeout):有时限的等待, 到n毫秒后结束等待,或是被唤醒
标签:JUC,加锁,Monitor,synchronized,--,对象,线程,轻量级
From: https://blog.csdn.net/m0_51275144/article/details/144943218