一、乐观锁和悲观锁
1、乐观锁(Optimistic Locking)--读多写少
乐观锁:假设多个线程之间的冲突很少发生,因此不加锁直接访问共享资源,然后在更新时检查是否发生冲突。
乐观锁的实现
(1)版本号(Versioning)
在数据实体中添加一个版本字段(如version
),每次修改数据时,不仅更新业务字段,还递增版本号。当一个线程尝试更新数据时,它会先读取当前版本号,然后执行业务逻辑并计算新的数据状态,最后在更新数据时附带当前读取到的版本号。如果在提交更新时发现数据库中当前版本号与之前读取时不一致(表明有其他线程在此期间进行了更新),则更新操作失败,通常会抛出异常,如OptimisticLockingFailureException
。在Java中,可以使用JPA或Hibernate等ORM框架提供的@Version
注解来自动管理版本字段。
(2)CAS(Compare-And-Swap)
Java的java.util.concurrent.atomic
包提供了诸如AtomicInteger
、AtomicLong
、AtomicReference
等原子类,它们利用了CPU级别的CAS指令来实现无锁的原子更新。在更新操作中,CAS会比较当前值与预期值,只有两者相等时才会更新值,否则不会修改。这种机制可以在不使用锁的情况下实现乐观并发控制,线程在更新时检查值是否被其他线程改变,如果没有改变则更新,否则可以采取重试或其他策略。
(3)StampedLock的乐观读模式
java.util.concurrent.locks.StampedLock
提供了一种读写锁的变体,其中包含了乐观读(optimistic read)模式。在乐观读模式下,线程获取一个所谓的“乐观读取戳”(stamp),而不是实际的锁。在读取数据后,线程可以使用这个戳来验证数据在读取期间是否被其他线程写入,如果没有则认为读取有效,否则需要重新读取。
2、悲观锁(Pessimistic Locking)--写多读少或并发写
悲观锁:默认加锁,假设会有并发冲突发生,因此在访问共享资源之前先获取锁。
悲观锁的实现
(1)synchronized
关键字
Java中的synchronized
关键字用于声明方法或代码块为同步的,它隐式地获取对象锁。当一个线程进入synchronized
代码块或方法时,会锁定对象,其他试图访问同一对象锁的线程将被阻塞,直到持有锁的线程退出同步区域并释放锁。这种锁定机制假定并发环境下存在大量冲突,因此在访问数据前就进行锁定,确保数据操作的独占性。
(2)ReentrantLock
ReentrantLock
是java.util.concurrent
包提供的可重入、互斥锁实现。与synchronized
相比,它提供了更精细的锁定控制,如公平锁与非公平锁的选择、可中断的锁请求、以及条件队列(Condition)支持。使用ReentrantLock
时,线程必须显式地调用lock()
方法获取锁和unlock()
方法释放锁。在锁定期间,其他线程的锁请求将被阻塞,直到锁被释放,这符合悲观锁的策略。
(3)数据库事务中的悲观锁
在使用JDBC或ORM框架(如JPA、Hibernate)操作数据库时,可以通过设置事务隔离级别或使用特定的查询语句来实现悲观锁。例如,使用SELECT ... FOR UPDATE
语句在查询时立即锁定所选行,阻止其他事务对该行的修改,直到当前事务结束。
二、公平锁和非公平锁
1、公平锁(fair Lock)--FIFO
公平锁:在锁的分配上遵循“先来后到”的原则。当锁可用时,优先分配给等待时间最长的线程,从而减少了线程饥饿的可能性。公平锁保证了所有线程按照申请锁的顺序获得锁,类似于队列中的FIFO(先进先出)规则。
公平锁的实现
(1)ReentrantLock
的公平模式:ReentrantLock
是 Java 中提供的一种可重入锁,通过 ReentrantLock(boolean fair)
构造函数可以创建公平锁。公平模式会按照先来后到的顺序分配锁,但由于公平锁需要维护一个有序队列,因此可能会带来一些性能损失。
(2)synchronized
关键字:synchronized
关键字所获得的锁默认是公平锁,即按照先后顺序获取锁。但是,synchronized
无法设置为非公平锁模式。
//公平锁
ReentrantLock fairLock = new ReentrantLock(true);
2、非公平锁(Nonfair Lock)--性能较高
非公平锁:锁的分配不考虑线程的等待时间,即使新到达的线程也可能“插队”获得锁,优先级高于已经在队列中等待的线程。非公平锁在尝试获取锁时,首先会尝试通过CAS(Compare-And-Swap)操作直接获取锁,如果失败(即锁已被其他线程持有),才会加入等待队列。这种直接尝试获取锁的方式可能导致已等待的线程长时间得不到锁,形成“饥饿”现象,但在许多并发场景下,尤其是锁竞争不激烈或锁持有时间较短的情况下,非公平锁的性能优势更为明显。
非公平锁的实现
// 默认构造函数创建非公平锁
ReentrantLock nonfairLock = new ReentrantLock();
三、互斥锁/读写锁
1、互斥锁(Mutex Lock)--同步保障
互斥锁:确保任何时候只有一个线程访问被保护的资源,无论该线程是进行读操作还是写操作。互斥锁提供了最强的同步保障,防止了数据竞争。synchronized
关键字和 ReentrantLock
在未指定为读写锁时即为互斥锁。
互斥锁的实现
(1)synchronized
关键字
synchronized
关键字可以直接作用于方法或代码块,提供一种内置的互斥锁机制。当一个线程进入synchronized
区域时,它会获得对象锁,其他试图进入该区域的线程将被阻塞,直到持有锁的线程退出同步块或方法并释放锁。
public class Example {
public synchronized void synchronizedMethod() {
// 受互斥锁保护的代码
}
public void synchronizedBlock() {
synchronized (this) {
// 受互斥锁保护的代码
}
}
}
(2)ReentrantLock
ReentrantLock
类提供了一个可重入的互斥锁,它比synchronized
关键字更灵活,支持公平锁和非公平锁的选择、可中断的锁请求以及更复杂的条件队列(Condition)功能。使用ReentrantLock
需要显式地调用lock()
和unlock()
方法来获取和释放锁。
import java.util.concurrent.locks.ReentrantLock;
class Example {
private final ReentrantLock lock = new ReentrantLock();
public void lockedMethod() {
lock.lock();
try {
// 受互斥锁保护的代码
} finally {
lock.unlock();
}
}
}
2、读写锁(Read-Write Lock)--多线程读/单线程写
读写锁:它分为读锁(共享锁)和写锁(独占锁)。读锁允许多个读线程并发访问,而写锁在同一时间只允许一个写线程访问。读写锁提高了在读多写少场景下的并发性能,因为它允许并发读,但会限制并发写和读写之间的并行,读写锁确保了读读不互斥、读写互斥、写写互斥的特性。
读写锁的实现
import java.util.concurrent.locks.ReentrantReadWriteLock;
// 创建读写锁实例
ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
// 获取读锁和写锁
ReentrantReadWriteLock.ReadLock readerLock = rwl.readLock();
ReentrantReadWriteLock.WriteLock writerLock = rwl.writeLock();
// 使用读锁进行读操作
readerLock.lock();
try {
// 读取共享资源的代码
} finally {
readerLock.unlock();
}
// 使用写锁进行写操作
writerLock.lock();
try {
// 更新共享资源的代码
} finally {
writerLock.unlock();
}
四、独占锁/共享锁(参考读写锁)
独占锁(也称写锁):一次只能由一个线程持有,其他试图获取该锁的线程将被阻塞。独占锁确保任何时候只有一个线程访问被保护的资源,适用于需要对资源进行排他性写入或修改的场景。ReentrantLock
在没有指定为读写锁时默认表现为独占锁。
共享锁(也称读锁):允许多个线程同时持有,通常用于读取操作。当一个线程持有共享锁时,其他线程也可以同时获取该锁进行读取,但任何写操作都会被阻塞,直到所有共享锁都被释放。ReentrantReadWriteLock
的读锁就是一个典型的共享锁。
五、可重入锁/不可重入锁
1、可重入锁(Reentrant Lock)--不会死锁
可重入锁:允许同一个线程在已经持有该锁的情况下,再次对其加锁而不被阻塞。例如,如果一个线程持有一个可重入锁进入某个方法,在该方法内部又调用了另一个也需要同样锁的方法,那么由于锁的可重入性,使得线程在递归调用或嵌套调用时,不会因再次请求相同的锁而导致死锁。Java 的 ReentrantLock
和 synchronized
关键字实现的锁都是可重入的。
可重入锁的实现
(1)ReentrantLock
ReentrantLock
是Java并发包提供的一个可重入、互斥的锁实现。它支持公平锁和非公平锁模式,并提供了比synchronized
关键字更丰富的功能,如可中断锁请求、条件变量等。ReentrantLock
内部维护一个线程持有计数器,当线程首次获得锁时,计数器加1;当线程释放锁时,计数器减1。只有当计数器为0时,锁才真正被释放,其他线程才能获得锁。因此,一个线程可以多次获得同一个ReentrantLock
实例,而不会相互阻塞。
(2)synchronized
Java语言内置的关键字synchronized
修饰的方法或代码块也是可重入的。当一个线程进入synchronized
方法或代码块时,会在当前线程的栈帧中记录锁信息。后续递归或嵌套的synchronized
代码块如果使用的是同一锁对象,那么由于锁识别到是由当前线程持有,所以允许再次进入,不会造成自我阻塞。
2、不可重入锁(Non-Reentrant Lock)--可能会死锁不建议使用
不可重入锁:一个线程一旦获得了锁,就不能再重新获得该锁,即使是在同一方法或者不同层次的嵌套调用中。如果尝试重入,将会导致死锁。Java标准库中没有直接提供不可重入锁,但在特定场景下,开发者可能需要自行实现或使用第三方库提供的不可重入锁。
六、分段锁(Segment Locking)
分段锁:一种将锁分成多个段的锁机制,每个段都有自己的锁。这样,不同的线程可以同时访问不同段的共享资源,从而提高并发性能。Java中的 ConcurrentHashMap 就是使用分段锁来实现高并发访问的。
分段锁的实现
(1)在Java 7及更早版本的ConcurrentHashMap
中,分段锁的具体实现如下:
Segment数组:CHM内部维护了一个Segment
数组,每个Segment
代表哈希表的一部分,相当于一个独立的、线程安全的小型哈希表。每个Segment
都继承自ReentrantLock
,即每个分段都是一个可重入锁。
哈希桶分布:插入到CHM中的键值对会被分散到各个Segment
中。通过散列函数和Segment数量对键的哈希值进行二次哈希,确定键值对应该落入哪个Segment
。
并发访问:当多个线程同时访问CHM时,只要它们操作的是不同的Segment
,就能实现并发执行,互不干扰。如果多个线程访问同一个Segment
,则需要竞争对应的Segment
锁,遵循锁的互斥原则,确保数据安全性。
操作方法:CHM的所有操作(如put()
, get()
, remove()
等)都通过定位到对应的Segment
,并在其上进行相应操作。操作过程中,首先尝试获取对应的Segment
锁,成功后再进行数据操作,操作完成后释放锁。
(2)Java 8对ConcurrentHashMap
的实现进行了重大革新,不再使用分段锁,而是采用了以下设计:
Node结构:使用Node
节点代替原有的Entry
,每个节点包含键值对和指向下一个节点的引用。节点之间通过链表或树形结构连接,形成链表桶或红黑树桶。
CAS操作:对Node
的更新操作(如插入、删除、替换等)大量使用了CAS(Compare-And-Swap)原子操作,以实现无锁化更新。这减少了对锁的依赖,提升了并发性能。
细粒度锁:虽然不再有明确的分段锁概念,但Java 8的CHM在某些操作(如扩容、树化等复杂操作)时仍会使用锁。不过,这些锁通常是针对单个桶(即一个链表或红黑树的头节点)进行的,具有比分段锁更细的粒度,进一步减少了锁竞争。
扩容机制:扩容不再是原来的“rehash all”,而是采用了一种更高效的方式,只迁移部分桶的数据,且迁移过程中允许其他线程并发访问和更新。
七、自旋锁
自旋锁:让等待锁的线程在原地循环(自旋)一段时间,而不是立即挂起,直到锁被释放。自旋期间,线程持续检查锁是否可用,如果在很短的时间内锁被释放,那么线程就可以立即获得锁并继续执行,避免了线程上下文切换的开销。自旋锁适用于锁持有时间非常短的场景。Java中虽没有直接提供自旋锁,但 ReentrantLock
可以通过设置参数启用自旋等待,另外,java.util.concurrent.atomic
包中的原子类(如AtomicInteger
)在一定程度上实现了自旋锁的效果。
自旋锁的实现
(1)使用AtomicInteger
等原子类
原子类如AtomicInteger
、AtomicBoolean
等提供了原子性的CAS(Compare-And-Swap)操作,可以用来实现自旋锁。
import java.util.concurrent.atomic.AtomicInteger;
//state被初始化为0,表示锁未被持有。当线程尝试获取锁时,通过compareAndSet方法尝试将状态从0改为1。
//如果当前状态已经是1(即锁已被其他线程持有),该方法返回false,线程将继续循环(自旋)尝试。
//一旦状态为0且成功设置为1,线程就获得了锁。解锁时,只需将状态重置为0。
public class SpinLock {
private AtomicInteger state = new AtomicInteger(0);
public void lock() {
while (!state.compareAndSet(0, 1)) {
// do nothing or yield to reduce CPU usage
}
}
public void unlock() {
state.set(0);
}
}
(2)使用locks下的park/unpark
java.util.concurrent.locks.LockSupport
类提供了低级别的线程阻塞和唤醒支持,可以用来实现自旋锁。这种方法结合了自旋和线程阻塞,当自旋一定次数后仍未获得锁,线程会进入阻塞状态,以减少不必要的CPU消耗。
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.LockSupport;
//owner字段表示当前持有锁的线程,count表示锁的重入次数。线程在尝试获取锁时,如果发现锁未被持有或自己已经持有锁(重入),则直接更新状态并返回。
//否则,线程会进行一定次数的自旋,若仍未能获得锁,则调用LockSupport.park使当前线程进入阻塞状态。
//解锁时,递减重入计数,当计数为0时,将owner设为null,并通过LockSupport.unpark唤醒等待的线程。
public class SpinLockWithPark {
private Thread owner = null;
private int count = 0;
public void lock() {
Thread currentThread = Thread.currentThread();
while (true) {
if (owner == null) {
// 初次获取锁
if (Thread.currentThread().equals(owner)) {
count++;
return;
}
} else if (currentThread.equals(owner)) {
// 同一线程重入
count++;
return;
} else {
// 自旋一定次数后,尝试park
for (int i = 0; i < SPIN_LIMIT; i++) {
if (owner == null) {
break;
}
}
LockSupport.park(this);
}
}
}
public void unlock() {
if (Thread.currentThread().equals(owner)) {
count--;
if (count == 0) {
owner = null;
LockSupport.unpark(Thread.currentThread());
}
}
}
}
八、锁升级
偏向锁/轻量级锁/重量级锁
这是Java虚拟机(JVM)对s ynchronized
关键字底层实现的三种锁升级状态,属于锁的优化技术:
偏向锁(Biased Locking):假设大多数情况下,锁会被同一线程多次获得,因此引入偏向锁,使锁偏向于第一个获得它的线程。后续该线程再次请求锁时,无需进行任何同步操作即可直接获得,大大降低了锁的开销。
轻量级锁(Lightweight Locking):当偏向锁失败(即有其他线程竞争锁)时,JVM会尝试升级为轻量级锁。轻量级锁依赖CAS操作尝试快速获取锁,避免了重量级锁带来的系统调用和线程上下文切换的开销。
重量级锁(Heavyweight Locking):当轻量级锁也失败(即CAS操作持续失败,意味着存在多个线程竞争)时,锁升级为重量级锁。此时,JVM会为锁分配内核对象(如Monitor),涉及线程上下文切换和操作系统层面的同步操作,开销较大,但能保证严格的互斥性和可见性。