文章目录
2、常用锁
在 Java 编程中,锁(Locks)是用于控制对共享资源(如对象、变量、文件等)的访问,以避免并发线程引发的竞争条件和数据不一致问题。Java 提供了多种锁机制,以下是主要类型的锁及其用途:
Java 提供的多种锁机制适用于不同的并发控制需求和场景。以下是各种锁机制的使用场景和优缺点:
2.1. 内置锁(Synchronized)
使用场景:
- 简单的同步需求: 适用于简单的并发控制,如同步方法或代码块,控制对共享资源的访问。
- 小型项目: 适合小型项目或简单的并发需求,不需要复杂的锁控制。
优点:
- 使用简单,语法直接。
- 内置在 Java 语言中,无需额外的库。
缺点:
- 粒度较粗,可能导致性能瓶颈。
- 无法中断等待的线程。
- 不支持尝试获取锁和超时获取锁的功能。
2.1.1 内置锁示例
内置锁(synchronized
)是 Java 提供的最简单的锁机制,主要用于方法级别和代码块级别的同步。以下是几个示例:
2.1.1.1. 同步方法
使用 synchronized
关键字可以对整个方法进行同步,确保同一时间只有一个线程能够执行该方法
public class SynchronizedMethodExample {
private int counter = 0;
// 同步方法
public synchronized void increment() {
counter++;
}
public synchronized int getCounter() {
return counter;
}
public static void main(String[] args) {
final SynchronizedMethodExample example = new SynchronizedMethodExample();
// 创建多个线程来调用同步方法
Thread t1 = new Thread((new Runnable() {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
example.increment();
}
}
}));
Thread t2 = new Thread((new Runnable() {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
example.increment();
}
}
}));
t1.start();
t2.start();
// 等待线程结束
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 输出计数器的值
System.out.println("Final Counter Value: " + example.getCounter());
}
}
2.1.1.2. 同步代码块
使用 synchronized
关键字可以对特定的代码块进行同步,指定锁对象来控制同步范围。
public class SynchronizedBlockExample {
private final Object lock = new Object();
private int counter = 0;
// 同步代码块
public void increment() {
synchronized (lock) {
counter++;
}
}
public int getCounter() {
synchronized (lock) {
return counter;
}
}
public static void main(String[] args) {
final SynchronizedBlockExample example = new SynchronizedBlockExample();
// 创建多个线程来调用同步代码块
Thread t1 = new Thread((new Runnable() {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
example.increment();
}
}
}));
Thread t2 = new Thread((new Runnable() {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
example.increment();
}
}
}));
t1.start();
t2.start();
// 等待线程结束
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 输出计数器的值
System.out.println("Final Counter Value: " + example.getCounter());
}
}
2.1.1.3 解释
- 同步方法:
- 使用
synchronized
关键字直接在方法声明中,整个方法体是同步的。 - 当一个线程调用
increment
方法时,其他线程必须等待该方法执行完毕后才能调用该方法或其他同步方法。
- 使用
- 同步代码块:
- 使用
synchronized
关键字和一个锁对象(如lock
)。 - 仅在同步代码块内的代码会受到锁的保护,而其他代码不受影响。
- 提供了更细粒度的控制,可以提高性能。
- 使用
通过这两种方式,可以确保对共享资源(如 counter
变量)的访问是线程安全的,避免并发修改带来的数据不一致问题。
2.2. 显式锁(Explicit Locks)
使用场景:
- 复杂的同步需求: 适用于复杂的并发控制,如需要实现公平锁、公平队列或中断锁等。
- 大规模项目: 适合需要高度自定义和灵活控制并发的场景。
优点:
- 支持可中断锁获取、尝试获取锁和超时获取锁。
- 提供了更多控制,如公平性设置。
- 可以实现更细粒度的锁定,提高并发性能。
缺点:
- 使用较复杂,需要显式地获取和释放锁。
- 容易出现死锁,如果不小心使用会导致问题。
示例:ReentrantLock
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockExample {
private final Lock lock = new ReentrantLock();
public void someMethod() {
lock.lock();
try {
// critical section
} finally {
lock.unlock();
}
}
}
2.3 读写锁(ReadWriteLock)
使用场景:
- 读多写少的场景: 适用于读操作频繁而写操作较少的场景,如缓存系统、配置读取等。
优点:
- 读锁可以同时被多个线程持有,提高读操作的并发性。
- 写锁是独占的,保证写操作的原子性。
缺点:
- 写操作时,所有读操作会被阻塞。
- 实现较为复杂。
示例:ReentrantReadWriteLock
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteLockExample {
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
public void readMethod() {
rwLock.readLock().lock();
try {
// read critical section
} finally {
rwLock.readLock().unlock();
}
}
public void writeMethod() {
rwLock.writeLock().lock();
try {
// write critical section
} finally {
rwLock.writeLock().unlock();
}
}
}
2.4. 自旋锁(SpinLock)
自旋锁(SpinLock)是一种忙等待锁机制,线程在等待锁时不会进入阻塞状态,而是不断循环检查锁的状态,直到获得锁。这种机制避免了线程上下文切换的开销,适用于锁持有时间短、线程数量少的高频临界区场景。
使用场景
自旋锁适用于以下场景:
- 锁持有时间短:自旋锁适合那些持锁时间非常短的场景,如果锁持有时间长,自旋等待会浪费 CPU 资源。
- 低线程竞争:适用于线程数量较少、锁竞争不激烈的场景,高线程竞争下自旋锁可能导致性能下降。
- 高性能要求:自旋锁在避免线程阻塞和上下文切换方面表现出色,非常适合性能要求高的场景。
自旋锁示例
以下是一个简单的 Java 自旋锁实现示例:
import java.util.concurrent.atomic.AtomicBoolean;
public class SpinLock {
private AtomicBoolean lock = new AtomicBoolean(false);
public void lock() {
while (!lock.compareAndSet(false, true)) {
// 自旋等待
}
}
public void unlock() {
lock.set(false);
}
public static void main(String[] args) {
final SpinLock spinLock = new SpinLock();
Runnable task = (new Runnable() {
@Override
public void run() {
spinLock.lock();
try {
// 临界区
System.out.println(Thread.currentThread().getName() + " acquired the lock");
} finally {
spinLock.unlock();
System.out.println(Thread.currentThread().getName() + " released the lock");
}
}
});
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
2.5. 悲观锁(Pessimistic Locking)和乐观锁(Optimistic Locking)
- 悲观锁: 采用较重的锁机制,认为每次访问数据时都会发生冲突,因此会对数据进行锁定。
- 乐观锁: 采用较轻的锁机制,认为冲突不会频繁发生,在操作前不锁定数据,而是在提交时检查冲突。
乐观锁通常通过版本号或时间戳机制实现。
2.5.1. 悲观锁(Pessimistic Locking)
悲观锁(Pessimistic Locking)是一种在并发控制中假设冲突是常态,因此在访问资源时会先进行加锁操作,以防止其他线程或事务对资源进行修改。悲观锁通常用于需要严格数据一致性保障的场景,例如数据库系统中。
使用场景
悲观锁适用于以下场景:
- 高冲突场景:数据更新频繁,多个事务或线程可能会同时修改同一数据,使用悲观锁可以确保数据的一致性。
- 严格一致性要求:应用程序对数据一致性要求非常高,不能接受任何数据不一致的情况,例如金融系统中的交易操作。
- 长时间持有锁:当业务逻辑需要长时间持有锁且过程中需要进行多次操作时,悲观锁可以防止其他事务的干扰。
悲观锁示例
以下是一个在 Java 中使用悲观锁的示例,模拟数据库操作。假设我们有一个银行账户,我们希望在取款和存款操作时确保线程安全性。
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public class PessimisticLockingExample {
private static final String DB_URL = "jdbc:mysql://localhost:3306/testdb";
private static final String USER = "username";
private static final String PASS = "password";
public static void main(String[] args) {
final PessimisticLockingExample example = new PessimisticLockingExample();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
example.withdraw(1, 100);
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
example.withdraw(1, 200);
}
});
t1.start();
t2.start();
}
public void withdraw(int accountId, int amount) {
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
conn = DriverManager.getConnection(DB_URL, USER, PASS);
conn.setAutoCommit(false);
// 悲观锁:锁定账户记录,防止其他事务修改
String selectSQL = "SELECT balance FROM accounts WHERE id = ? FOR UPDATE";
pstmt = conn.prepareStatement(selectSQL);
pstmt.setInt(1, accountId);
rs = pstmt.executeQuery();
if (rs.next()) {
int balance = rs.getInt("balance");
if (balance >= amount) {
String updateSQL = "UPDATE accounts SET balance = balance - ? WHERE id = ?";
pstmt = conn.prepareStatement(updateSQL);
pstmt.setInt(1, amount);
pstmt.setInt(2, accountId);
pstmt.executeUpdate();
System.out.println("Withdraw successful, new balance: " + (balance - amount));
} else {
System.out.println("Insufficient funds");
}
}
conn.commit();
} catch (SQLException e) {
e.printStackTrace();
try {
if (conn != null) {
conn.rollback();
}
} catch (SQLException ex) {
ex.printStackTrace();
}
} finally {
try {
if (rs != null) rs.close();
if (pstmt != null) pstmt.close();
if (conn != null) conn.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
解释
- 数据库连接:
- 使用 JDBC 连接到 MySQL 数据库,并在操作之前禁用自动提交 (
conn.setAutoCommit(false)
) 以便手动控制事务。
- 使用 JDBC 连接到 MySQL 数据库,并在操作之前禁用自动提交 (
- 悲观锁:
SELECT balance FROM accounts WHERE id = ? FOR UPDATE
:使用FOR UPDATE
语句对账户记录进行加锁,防止其他事务在当前事务完成之前对该记录进行修改。FOR UPDATE
确保在读取账户余额后,其他事务无法更新同一账户记录,直到当前事务提交或回滚。
- 业务逻辑:
- 查询账户余额,如果余额充足则执行扣款操作。
- 如果余额不足,输出提示信息。
- 事务控制:
- 成功执行后提交事务 (
conn.commit()
),确保变更持久化。 - 出现异常时回滚事务 (
conn.rollback()
),保证数据一致性。
- 成功执行后提交事务 (
总结
通过使用悲观锁,可以确保在高冲突和高一致性要求的场景中,数据操作的安全性和一致性。虽然悲观锁可能导致锁竞争和性能下降,但在需要严格数据保护的应用中,它是一种有效的并发控制机制。
2.5.2. 乐观锁(Optimistic Locking)
使用场景:
- 无冲突操作: 适用于冲突较少的场景,如版本控制系统、数据库事务等。
- 高性能需求: 适合需要高并发和高性能的场景。
优点:
- 不需要加锁,可以提高性能。
- 冲突检测机制减少了锁竞争,提高了并发性。
缺点:
- 需要冲突检测和重试机制,增加了实现复杂度。
- 在冲突频繁的场景下性能较差。
示例:使用 java.util.concurrent.atomic
包中的类
public class OptimisticLockingExample {
private AtomicInteger value = new AtomicInteger(0);
public void increment() {
int oldValue, newValue;
do {
oldValue = value.get();
newValue = oldValue + 1;
} while (!value.compareAndSet(oldValue, newValue));
}
public int getValue() {
return value.get();
}
public static void main(String[] args) {
OptimisticLockingExample optimisticLocking = new OptimisticLockingExample();
for (int i = 0; i < 10; i++) {
optimisticLocking.increment();
}
System.out.println(optimisticLocking.getValue());
}
}
解释
- AtomicBoolean:
- 自旋锁使用
AtomicBoolean
来表示锁状态,初始值为false
表示锁未被占用。 compareAndSet(false, true)
方法尝试将lock
从false
设置为true
,如果成功则表示获得了锁。
- 自旋锁使用
- lock 方法:
- 使用
while
循环和compareAndSet
方法实现自旋等待。 - 当锁状态为
false
时,线程将锁状态设置为true
并退出循环。 - 如果锁状态为
true
,线程将继续自旋等待。
- 使用
- unlock 方法:
- 使用
set(false)
方法将锁状态重置为false
,释放锁。
- 使用
- 示例使用:
- 创建两个线程
t1
和t2
,它们分别尝试获取锁并进入临界区。 - 获取锁后,线程打印锁定信息,然后在
finally
块中释放锁。
- 创建两个线程
自旋锁的注意事项
- 忙等待:
- 自旋锁会不断检查锁的状态,如果锁持有时间过长,忙等待会浪费大量 CPU 资源。
- 因此,自旋锁适用于锁持有时间非常短的场景。
- 线程数限制:
- 自旋锁在高线程竞争的场景下表现不佳,因为大量线程会进行忙等待,导致性能下降。
- 公平性:
- 自旋锁通常是非公平的,无法保证等待线程的先后顺序。
适用场景总结
自旋锁非常适合以下场景:
- 临界区代码执行速度极快,锁持有时间短。
- 线程数较少,锁竞争不激烈。
- 高性能要求,需要避免线程上下文切换的开销。
对于其他场景,例如锁持有时间长或线程数量多,推荐使用其他锁机制,如内置锁(synchronized
)或显式锁(如 ReentrantLock
),以避免自旋锁带来的性能问题。
2.6. StampedLock
StampedLock
是 Java 8 引入的一种高级锁机制,提供了对传统锁(如 ReentrantLock
和 ReadWriteLock
)的替代方案。它支持三种模式的锁:写锁、悲观读锁和乐观读锁。StampedLock
设计的目的是在高并发场景下提高性能,特别是读多写少的场景。
2.6.1 使用场景
StampedLock
适用于以下场景:
- 读多写少:主要用于读操作非常频繁,而写操作相对较少的场景。在这种场景下,
StampedLock
的乐观读锁可以显著提升并发性能。 - 需要减少锁竞争:通过乐观读锁减少读操作之间的锁竞争,提高并发性。
- 高性能要求:对性能有较高要求,需要尽量减少线程阻塞和上下文切换开销的场景。
2.6.2 示例
下面是一个使用 StampedLock
的示例,模拟一个简单的点(Point)的坐标操作。
示例:使用 StampedLock
保护坐标操作
import java.util.concurrent.locks.StampedLock;
public class StampedLockExample {
private double x, y;
private final StampedLock lock = new StampedLock();
// 写锁:更新坐标
public void move(double deltaX, double deltaY) {
long stamp = lock.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
lock.unlockWrite(stamp);
}
}
// 悲观读锁:读取坐标
public double[] getCoordinates() {
long stamp = lock.readLock();
try {
return new double[] { x, y };
} finally {
lock.unlockRead(stamp);
}
}
// 乐观读锁:尝试读取坐标
public double distanceFromOrigin() {
long stamp = lock.tryOptimisticRead();
double currentX = x, currentY = y;
// 校验乐观读锁是否有效
if (!lock.validate(stamp)) {
stamp = lock.readLock();
try {
currentX = x;
currentY = y;
} finally {
lock.unlockRead(stamp);
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
public static void main(String[] args) {
StampedLockExample point = new StampedLockExample();
point.move(1.0, 1.0);
System.out.println("Coordinates: " + java.util.Arrays.toString(point.getCoordinates()));
System.out.println("Distance from origin: " + point.distanceFromOrigin());
Thread t1 = new Thread(() -> {
point.move(2.0, 2.0);
});
Thread t2 = new Thread(() -> {
System.out.println("Distance from origin (from thread): " + point.distanceFromOrigin());
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Final Coordinates: " + java.util.Arrays.toString(point.getCoordinates()));
}
}
2.6.3 解释
- 写锁:
long stamp = lock.writeLock();
获取写锁,stamp
是锁的票据,用于解锁。try
块中更新坐标值x
和y
。finally
块中调用lock.unlockWrite(stamp);
释放写锁。
- 悲观读锁:
long stamp = lock.readLock();
获取悲观读锁。try
块中读取坐标值并返回。finally
块中调用lock.unlockRead(stamp);
释放读锁。
- 乐观读锁:
long stamp = lock.tryOptimisticRead();
尝试获取乐观读锁。- 读取坐标值
x
和y
。 if (!lock.validate(stamp)) { ... }
校验乐观读锁是否有效。如果无效,获取悲观读锁重新读取坐标。
2.6.4 总结
StampedLock
提供了三种锁模式,可以在不同场景下使用:
- 写锁:独占锁,用于写操作,确保数据一致性。
- 悲观读锁:与传统的读锁类似,多个读线程可以同时获取,但写线程会阻塞。
- 乐观读锁:轻量级锁,可以在大多数情况下无锁地进行读操作,但在检测到数据修改时会回退到悲观读锁。
通过使用 StampedLock
,可以在高并发、读多写少的场景下显著提高性能和吞吐量。