我们都知道在 Java 中为了保证一些操作的安全性,就会涉及到使用锁,但是你对 Java 的锁了解的有多少呢?Java 都有哪些锁?以及他们是怎么实现的,今天了不起就来说说关于 Java 的锁。
乐观锁
乐观锁(Optimistic Locking)是一种在数据读取时不会阻塞其他读取或写入操作的锁策略,但在更新时会检查在此期间是否有其他操作修改了数据。如果数据已被修改,则更新操作会失败,通常是通过重试或抛出异常来处理。
在 Java 中,乐观锁通常是通过版本号、时间戳或其他状态信息来实现的。以下是乐观锁在 Java 中的一些常见实现方式:
版本号机制:
- 数据表中增加一个“版本号”字段。
- 读取数据时,同时读取版本号。
- 更新数据时,将版本号加1,并带上WHERE子句,确保版本号与读取时的一致。
- 如果更新影响的行数为0,则表示在此期间数据已被其他事务修改。
时间戳机制:
- 类似于版本号,但使用时间戳字段代替。
- 更新时检查时间戳字段,确保它与读取时的时间戳匹配。
CAS (Compare-and-Swap) 操作:
- 是一种原子操作,用于在多线程环境中安全地更新共享变量。
- CAS操作包括三个参数:内存位置(V)、预期原值(A)和新值(B)。
- 如果内存位置V的值与预期原值A匹配,则将V的值更新为新值B。否则,不执行任何操作。
- Java 的 AtomicInteger、AtomicLong 等原子类就使用了CAS操作。
JPA 和 Hibernate 的乐观锁:
- JPA 和 Hibernate 提供了内置的乐观锁支持。
- 在实体类中添加一个版本号或时间戳字段,并使用 @Version 注解标记。
- 当 Hibernate 或 JPA 尝试更新一个实体时,它会自动检查版本号或时间戳字段,以确保数据在此期间没有被其他事务修改。
悲观锁
悲观锁(Pessimistic Locking)是一种在数据处理过程中,总是假设最坏的情况来避免数据并发问题的锁策略。在Java中,悲观锁通常在数据被访问时就立即加锁,以保证在此期间其他任何事务都不能修改这个数据,直到该事务完成为止。
Java中实现悲观锁的常见方式有以下几种:
数据库行级锁和表级锁:
- 行级锁:对正在访问的数据行加锁,防止其他事务修改该行。这是数据库管理系统(DBMS)提供的一种锁机制,可以通过SQL语句来实现。
- 表级锁:对整个表加锁,限制其他事务对该表的并发访问。这种锁的开销较小,但并发性能较低。
Java中的synchronized关键字:
- synchronized是Java语言内建的线程同步机制,它可以用来修饰方法或者以代码块的形式出现。当一个线程进入一个synchronized修饰的方法或代码块时,它会获取一个锁,其他尝试进入该区域的线程将会被阻塞,直到第一个线程释放锁。
ReentrantLock类:
- Java的java.util.concurrent.locks.ReentrantLock类提供了重入锁的实现,这是一种悲观锁。与synchronized相比,ReentrantLock提供了更高的灵活性,比如可以尝试获取锁、定时获取锁以及中断等待锁的线程等。
读写锁(ReadWriteLock):
- java.util.concurrent.locks.ReadWriteLock接口定义了读取和写入锁的规则。虽然它本身不是悲观锁,但其中的写锁部分是一种悲观锁策略。写锁会阻止其他线程进行读和写操作,直到持有锁的线程释放它。
分布式锁:
- 在分布式系统中,悲观锁的概念可以扩展到跨多个进程或机器。常见的实现方式包括使用Redis、Zookeeper等分布式协调服务来实现分布式锁。
在使用悲观锁时,需要注意死锁和性能问题。死锁是指两个或多个线程无限期地等待对方释放资源的情况。性能问题则可能由于锁的粒度过大(如表级锁)导致并发性能下降。
乐观锁与悲观锁的比较:
悲观锁:假设最坏的情况,每次访问数据时都会锁定数据,防止其他事务修改。
乐观锁:假设最好的情况,允许其他事务并发访问数据,但在更新时会检查数据是否被修改。
选择哪种锁策略取决于应用的具体需求和并发场景。使用乐观锁时,需要注意处理更新失败的情况,通常是通过重试、抛出异常或给用户反馈来实现的。
递归锁
Java中的递归锁(ReentrantLock)是java.util.concurrent.locks包下提供的一种可重入的互斥锁,它是悲观锁的一种实现。递归锁允许一个线程多次获取同一个锁,而不会造成死锁,这对于某些需要递归调用或者在一个线程中多次需要获取同一个锁的场景非常有用。
递归锁的几个特性:
可重入性:如果一个线程已经拥有了一个递归锁,那么它可以再次获取该锁而不会阻塞。每次获取锁,都会增加锁的持有计数;每次释放锁,都会减少持有计数。只有当持有计数减少到0时,其他线程才能获取该锁。
公平性:递归锁可以是公平的也可以是非公平的。公平性意味着锁的获取是按照线程请求锁的顺序来的,而非公平性则不保证顺序。公平的递归锁可以减少“线程饥饿”的问题,但可能会降低性能。
既然我们说她是一个悲观锁的实现,那么是不是可以和 synchronized 比较一下,有什么不同呢?
与Java内置的synchronized关键字相比,递归锁提供了更高的灵活性和更好的性能控制。例如,递归锁支持尝试获取锁(tryLock()方法)、定时获取锁(tryLock(long timeout, TimeUnit unit)方法)以及中断等待锁的线程(lockInterruptibly()方法)。
我们看一下递归锁的示例代码:
import java.util.concurrent.locks.ReentrantLock;
public class RecursiveLockExample {
private final ReentrantLock lock = new ReentrantLock();
public void someMethod() {
lock.lock();
try {
// 临界区代码
// ...
someNestedMethod();
// ...
} finally {
lock.unlock();
}
}
private void someNestedMethod() {
lock.lock();
try {
// 嵌套调用中需要同步的代码
// ...
} finally {
lock.unlock();
}
}
}
在上面的示例中,someMethod方法调用了someNestedMethod方法,并且两者都需要获取同一个递归锁。由于ReentrantLock是可重入的,所以这种调用不会造成死锁。
读写锁
Java中的读写锁(ReadWriteLock)是一种允许多个读线程和单个写线程访问共享资源的同步机制。ReadWriteLock接口在java.util.concurrent.locks包中定义,它包含两个锁:一个读锁和一个写锁。
读写锁的特性:
读共享:在没有线程持有写锁时,多个线程可以同时持有读锁来读取共享资源。这可以提高并发性能,因为读操作通常不会修改数据,所以允许多个读线程并发访问是安全的。
写独占:当一个线程持有写锁时,其他线程既不能获取读锁也不能获取写锁。这是为了确保写操作对共享资源的独占访问,从而防止数据不一致。
Java中ReadWriteLock接口的主要实现类是ReentrantReadWriteLock,它提供了可重入的读写锁实现。ReentrantReadWriteLock有两个重要的方法:readLock()和writeLock(),分别用于获取读锁和写锁。
我们看看示例代码:
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteLockExample {
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private int data;
public void readData() {
lock.readLock().lock(); // 获取读锁
try {
// 读取共享资源
System.out.println("Reading data: " + data);
} finally {
lock.readLock().unlock(); // 释放读锁
}
}
public void writeData(int newData) {
lock.writeLock().lock(); // 获取写锁
try {
// 修改共享资源
this.data = newData;
System.out.println("Writing data: " + data);
} finally {
lock.writeLock().unlock(); // 释放写锁
}
}
}
在这个例子中,readData方法使用读锁来读取data字段,而writeData方法使用写锁来修改data字段。当多个线程调用readData时,它们可以同时读取数据而不会相互阻塞,除非有一个线程正在调用writeData并持有写锁。
需要注意的是,ReentrantReadWriteLock还有一个构造方法,它接受一个布尔值参数fair,用于指定锁是否应该是公平的。如果设置为true,则等待时间最长的线程将优先获得锁。但是,公平锁可能会降低性能,因为需要维护一个有序的等待队列。