乐观VS悲观
乐观锁(Optimistic Lock)
概念:乐观锁是一种偏向于乐观的策略,假设并发访问冲突的概率较低,因此在访问共享资源时不会加锁,而是在更新数据时检查是否有其他线程对数据进行了修改。乐观锁一般使用版本号或时间戳等机制来检测数据是否被修改,如果检测到冲突,乐观锁会回滚操作或者重新尝试更新操作
通俗说法:大姨A和大姨B去同一个菜市场买同一种菜。这个市场的摊位没有上锁,大姨A和大姨B都可以随时进去挑菜,她们在进摊位前记下摊位上的菜有多少,操作结束时检查菜的数量,如果发现不一致就重试
在Java中实现乐观锁:
- 使用
java.util.concurrent.atomic
包中的原子类,例如AtomicInteger
、AtomicLong
等 - 使用版本号机制,即每次更新数据时检查版本号,如果版本号匹配则更新成功,否则重试
AtomicInteger是java.util.concurrent.atomic包提供的原子类,利用CPU提供的CAS操作来保证原子性
// 使用 AtomicInteger 实现乐观锁
import java.util.concurrent.atomic.AtomicInteger;
public class VC {
private final AtomicInteger value = new AtomicInteger(0);
public void increment() {
int oldValue;
int newValue;
do {
oldValue = value.get();
newValue = oldValue + 1;
} while (!value.compareAndSet(oldValue, newValue));
}
}
在MySQL中实现乐观锁::
假设有一个包含版本号字段version
的表example_table
-- 假设要更新的数据行的id为1,当前版本号为5
UPDATE example_table
SET column1 = value1, version = version + 1
WHERE id = 1 AND version = 5;
-- 检查更新是否成功
SELECT ROW_COUNT();
如果ROW_COUNT()
返回0,表示版本号不匹配,需要重新读取数据并重试更新。
悲观锁(Pessimistic Lock)
概念:悲观锁是一种偏向于保守的策略:假设在并发访问中会经常发生冲突,因此在访问共享资源之前,会将资源加锁以防止其他线程修改数据。悲观锁适用于对数据修改频率较高或者并发冲突概率较大的情况。当一个线程获取到悲观锁后,其他线程需要等待锁释放才能继续执行
通俗说法:大姨A和大姨B都要去同一个菜市场买同一种菜。假设这个市场只有一个摊位卖这种菜,每次只能让一个人进入摊位买菜。大姨A一开始就锁上门,只允许自己挑菜,防止大姨B同时挑菜
在Java中实现悲观锁:
synchronized
关键字:用于同步方法或代码块,使得同一时间只有一个线程能够执行被同步的方法或代码块。ReentrantLock
类:比synchronized
更加灵活,提供了更多的控制和功能,例如可以尝试加锁、可中断的锁请求等
import java.util.concurrent.locks.ReentrantLock;
public class VC {
private final ReentrantLock lock = new ReentrantLock();
public void criticalSection() {
lock.lock();
try {
// 关键代码块
System.out.println("买菜");
} finally {
lock.unlock();
}
}
}
在MySQL中实现悲观锁:
悲观锁在数据库中通常通过锁定数据行来实现,即在读取数据时锁定行,防止其他事务修改数据
- 共享锁(S锁/读锁):允许多个事务读取数据,但不允许修改数据
- 排他锁(X锁/写锁):不允许其他事务读取或修改数据
使用SELECT ... FOR UPDATE
语句可以实现行级排他锁,使用SELECT ... LOCK IN SHARE MODE
语句可以实现行级共享锁
-- 使用排他锁
START TRANSACTION;
SELECT * FROM table_name WHERE condition FOR UPDATE;
-- 进行数据操作
COMMIT;
-- 使用共享锁
START TRANSACTION;
SELECT * FROM table_name WHERE condition LOCK IN SHARE MODE;
-- 进行数据操作
COMMIT;
概念锁
可重入锁(Reentrant Lock)
概念:可重入锁是一种允许同一个线程多次获取同一个锁而不会发生死锁的锁机制。如果一个线程已经持有一个锁,它可以在不被阻塞的情况下再次获取该锁。可重入锁在一些场景中很有用,比如在递归调用或在同一个方法内多次调用需要同步的方法
通俗说法:大姨A和大姨B去同一个菜市场买同一种菜。摊位的门是锁住的,只有一个人能持有这把锁。大姨A进摊位买菜时锁上了门,开始挑选各种菜。由于摊位的门是可重入的,大姨A可以在挑选一种菜后,继续去隔壁挑选其他菜,大姨B想进来,必须等A买完全部菜才可
在Java中实现可重入锁:
ReentrantLock
类如其名,是典型的可重入锁实现。(synchronized
关键字也是一种隐式的可重入锁。)
import java.util.concurrent.locks.ReentrantLock;
public class VC {
private final ReentrantLock lock = new ReentrantLock();
public void methodA() {
lock.lock();
try {
System.out.print(Thread.currentThread().getName() + " 买菜,");
methodB(); // 同一个线程可以再次进入methodB
System.out.println(" 买完了");
} finally {
lock.unlock();
}
}
public void methodB() {
lock.lock();
try {
System.out.print(Thread.currentThread().getName() + " 去隔壁买水果");
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
VC example = new VC();
Runnable task = () -> {
example.methodA();
};
Thread t1 = new Thread(task, "大姨A");
Thread t2 = new Thread(task, "大姨B");
t1.start();
t2.start();
}
}
公平锁(Fair Lock)
概念:公平锁是一种确保锁获取顺序公平的同步机制,它按照请求锁的顺序授予锁,从而避免某些线程长期得不到锁的情况。通常使用一个先进先出(FIFO)的队列来管理等待锁的线程。每个请求锁的线程都会被放入队列中,按照其到达的顺序获取锁,避免线程饥饿现象
通俗说法:大姨A和大姨B去同一个菜市场买同一种菜。市场规定,每个人按顺序排队进入摊位买菜,谁先到谁先买,大姨A和大姨B按顺序排队买菜,确保每个人都有公平的买菜机会
在Java中实现公平锁:
ReentrantLock
既是可重入锁,也支持公平锁和非公平锁两种模式。默认情况下,ReentrantLock
是非公平锁。可以通过在构造函数中传递true
参数来创建一个公平锁。
import java.util.concurrent.locks.ReentrantLock;
public class VC {
private final ReentrantLock lock = new ReentrantLock(true); // 创建一个公平锁
public void buy(String threadName) {
lock.lock();
try {
System.out.print(threadName + "买菜,");
Thread.sleep(10);
System.out.println("买完了");
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
VC example = new VC();
Runnable task = () -> {
for (int i = 0; i < 5; i++) {
example.buy(Thread.currentThread().getName());
}
};
Thread t1 = new Thread(task, "大姨A");
Thread t2 = new Thread(task, "大姨B");
t1.start();
t2.start();
}
}
自旋锁(Spin Lock)
概念: 自旋锁不会放弃 CPU 时间片,当遇到阻塞时,通过自旋等待线程释放锁,它会不停地尝试获取锁,如果失败就再次尝试,直到成功为止,节省了线程状态切换带来的开销。但自旋会占用CPU,在条件长时间不满足的情况下,线程会一直占用 CPU 进行无用的检查
通俗说法:大姨A和大姨B去同一个菜市场买同一种菜。大姨A在大姨B前面先买菜,但大姨B不会走去别处,而是原地等待大姨A买完菜轮到自己。
Java中的自旋锁
AtomicInteger
的实现源码,为保证自己的原子性操作,加入了自旋锁
从上述代码我们可以看到
getIntVolatile
读取对象o
中偏移量为offset
的字段的当前值。weakCompareAndSetInt
方法尝试将当前值更新为v + delta
。如果更新失败(表示在读取值和尝试更新之间该值被其他线程修改了),则开始while
自旋,重复读取和更新操作,直到成功为止。
Synchronized的变脸(无锁->偏向锁->轻量级锁->重量级锁)
Mark Word
在讲解以下三种锁前,必须先介绍下Mark Word
在Java虚拟机(JVM)中,每个对象头部(Object Header)都有一个称为“Mark Word”的区域,用于存储与对象相关的状态信息。
Mark Word的结构和用途
Mark Word的结构依赖于JVM的实现和对象的状态(以无锁状态为例,从左到右解释):
25bit + 4bit + 1bit + 2bit = 32bit
- 对象Hash Code :通过
Object.hashCode()
方法生成的哈希值。 - 对象分代年龄:对象在堆中的年龄,用于分代垃圾收集。
- 锁信息:用于同步锁机制,包括偏向锁、轻量级锁和重量级锁。
- 是否偏向锁:第一层区分:区别无锁状态和偏向锁状态。
- 锁标志位:第二层区分:偏向锁升级后,区别轻量级锁状态和重量级锁状态
不同状态下的Mark Word
根据对象的不同状态,MarkWord的内容会有所不同,具体表现在bit存储的内容(从上到下解释):
- 无锁状态(Unlocked) :存储对象的哈希码、分代年龄等信息。
- 偏向锁状态(Biased Locking) :存储偏向线程的ID、Epoch、分代年龄等信息。
- 轻量级锁状态(Lightweight Locking) : 存储指向栈中锁记录(Lock Record)的指针。
- 重量级锁状态(Heavyweight Locking) :存储指向重量级锁(Monitor)的指针。
- GC标记状态: 存储垃圾收集相关的信息。
重量级锁(Heavyweight Lock)
概念:当锁竞争激烈或轻量级锁(或偏向锁)升级时,JVM会使用重量级锁。重量级锁通过 JVM 级别的 Monitor 实现,涉及线程的阻塞和唤醒。但与轻量级锁不同,重量级锁会导致线程上下文切换,开销较大。
通俗说法:N个大姨去同一个菜市场买同一种菜。因为人太多,所以市场部门实行排队 + 预约制(Monitor)来保证买菜的正常有序进行
下图是 Synchronized 中重量级锁关键的 Monitor 工作原理
- Owner:存储当前获取锁的线程,只用一个线程可以获取
- Entry List:存储等待获取锁的线程队列,当一个线程尝试获取锁但锁已被其他线程持有时,该线程会被放入Entry List中。
- Wait Set:关联调用了wait方法的线程
- 假设此时线程A进入,此时无任何线程占有此锁,即Monitor中的Owner为空,Owner为线程A
- 之后线程B和线程C进入,但此时Monitor中的Owner为线程A,所以只能前往EntryList等待
- 线程A在执行任务时等待某个条件执行,调用了
lock.wait()
,进入Wait Set并释放锁。 - 因为锁的释放,Owner 再次为空,假设此时线程C竞选成功,Owner便存储为线程C
JVM重量级锁参数
- 启用/禁用自适应自旋:
-XX:+UseSpinning
:启用自适应自旋(默认启用)。-XX:-UseSpinning
:禁用自适应自旋。
- 自适应自旋次数:
-XX:PreBlockSpin=<n>
:指定重量级锁尝试自旋的次数,默认值为10。
轻量级锁(Lightweight Locking)
重量级锁虽然很好地保证了锁只有一个线程持有,但大多数情况下,并没有这么多线程前来竞争。Monitor的存在会带来一些不必要的线程上下文切换,成本高,性能低。因此,引入了轻量级锁。
概念:轻量级锁用于减少重量级锁的性能开销,特别是减少在无竞争情况下的锁开销。它使用了CAS(Compare-And-Swap)操作来实现锁的获取和释放。
通俗说法:大部分情况下只有大姨A一个人来菜市场买菜,老板和大姨交换(CAS)名片,下次再来只需展示便可以证明,不需要再进行繁琐的步骤
-
当线程进入同步块时,JVM会在当前线程的栈帧中创建一个名为锁记录(Lock Record)的区域,用于存储锁对象的Mark Word的副本
-
然后,JVM尝试使用CAS操作将对象头中的Mark Word替换为指向锁记录的指针。如果CAS操作成功,线程获取轻量级锁,并将对象头中的Mark Word更新为指向锁记录的指针。
-
如果此时线程中有方法再次调用此锁,会被归类为同一线程,不会出现锁竞争状态,会在锁记录区域存入一个为null的值,但因为是null,CAS判断结果为不需要再交换,所以保持原样
-
如果CAS操作失败,表示锁已经被其他线程持有,JVM会尝试使用自旋锁(即让线程忙等待一段时间),如果自旋失败,锁会升级为重量级锁,也就是使用回 Monitor 来存储
提问:为什么要用CAS这么复杂,直接记录不好吗?
- CAS操作可以保证object和Thread-0发生变化,并且保证操作为原子性操作
- 如果CAS操作失败,说明锁可能已经升级为重量级锁,需要进行相应的处理。
JVM轻量级锁参数
- 启用/禁用轻量级锁:
-XX:+UseBiasedLocking
:启用轻量级锁(默认启用)。-XX:-UseBiasedLocking
:禁用轻量级锁。
- 旋转次数:
-XX:PreBlockSpin=<n>
:指定轻量级锁尝试自旋的次数,默认值为10。
偏向锁(Biased Lock)
轻量级锁已经减少了开销并提升了性能,保证在只有单一线程使用的情况下锁操作顺利执行。那么,为什么还需要进一步优化呢?这是因为还可以进一步减少CAS(Compare-And-Swap)操作的次数,从而提升性能。为此,研发人员引入了一种称为偏向锁的机制。
概念:偏向锁的目的是在无竞争的情况下,通过消除不必要的轻量级锁操作,实现更高效的锁获取和释放。如果一个线程多次获取同一把锁,那么这把锁会偏向于该线程,避免每次获取锁时的CAS操作。
通俗说法:大部分情况下,只有大姨A一个人来菜市场买菜。老板给了大姨A一个临时挂牌(表示线程ID),无需每次都检查确认,只需看到她的挂牌就能让她进来买菜。
在偏向锁的机制中,前两步操作与轻量级锁相同,但当同一线程的不同方法再次调用此锁时,锁的Mark Word会记录该线程的ID。判断是否为同一线程时,无需进行CAS操作来获取锁,仅需检查对象头中的标志位是否偏向当前线程ID。
当另一个线程尝试获取一个偏向锁时,JVM会进行以下操作:
- 暂停偏向锁持有线程:为了避免竞争,JVM会暂停当前持有偏向锁的线程。
- 检查锁对象状态:JVM检查锁对象的Mark Word,确认是否需要撤销偏向锁。
- 撤销偏向锁:如果需要,JVM会撤销偏向锁,将其Mark Word重置为无锁状态或其他状态。
- 升级为轻量级锁:JVM尝试使用CAS操作将锁升级为轻量级锁。如果成功,新的线程会持有轻量级锁,并继续执行同步块。
JVM偏向锁参数
- 启用/禁用偏向锁:
-XX:+UseBiasedLocking
:启用偏向锁(默认启用)。-XX:-UseBiasedLocking
:禁用偏向锁。
- 偏向锁延迟:
-XX:BiasedLockingStartupDelay=<n>
:指定JVM启动后偏向锁的延迟时间(单位为毫秒),默认值为4000毫秒。