Java的锁机制
Java 中的锁机制主要用于多线程编程中,确保共享资源的同步访问。Java 提供了几种锁机制,包括 synchronized 关键字和 Lock 接口及其实现类。
synchronized 关键字
特点:
-
Java 中最基本的锁机制,隐式地获取和释放锁。
-
自动保证锁的释放,避免了手动释放锁可能导致的问题。
-
支持重入性(线程可以重复获取已经持有的锁),可避免自身死锁。
适用场景:
-
单线程环境下或简单的多线程同步场景。
-
代码块内部需要同步的情况。
-
保证方法或代码块的原子性操作。
synchronized 是 Java 中最基本的锁机制,用于实现方法或代码块的同步。它有两种使用方式:
方法级别的 synchronized:
将 synchronized 关键字放在方法声明中,确保每次只有一个线程可以进入该方法执行。
public synchronized void synchronizedMethod() {
// 同步代码块
}
代码块级别的 synchronized:
将 synchronized 关键字放在代码块中,指定锁对象。
Object lock = new Object();
synchronized(lock) {
// 同步代码块
}
Lock 接口
Lock 接口提供了更灵活的锁定操作,相比于 synchronized,它具有更多的功能和扩展性。Lock 接口的常见实现类包括 ** ReentrantLock 和 ReentrantReadWriteLock.ReadLock/WriteLock** 。
ReentrantLock
特点:
-
显式获取和释放锁,提供更多的灵活性和控制。
-
支持公平锁和非公平锁,默认为非公平锁。
-
支持可中断的锁获取和超时的锁获取。
-
提供条件变量(Condition),可以精确控制线程的等待和唤醒。
适用场景:
-
需要更灵活的锁获取和释放机制,如需要定时、可中断的锁操作。
-
需要实现公平性或非公平性选择的场景。
-
需要使用条件变量进行复杂线程通信的情况。
public class ReentrantLockExample {
private int count = 0;
private Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
ReentrantReadWriteLock
特点:
-
支持读写分离的锁,即可以同时有多个线程获取读锁(共享锁),但只能有一个线程获取写锁(排他锁)。
-
读锁可以共享,适合读多写少的场景,提高并发性能。
-
获取读锁(readLock() 方法)时,会调用 Sync 类中的 tryAcquireShared(int arg) 方法来尝试获取锁。如果当前没有线程持有写锁并且没有线程在等待获取写锁,则获取读锁成功。
-
获取写锁(writeLock() 方法)时,会调用 Sync 类中的 tryAcquire(int arg) 方法来尝试获取锁。只有当没有其他线程持有读锁或写锁时,获取写锁才会成功。
适用场景:
-
数据库的读写分离场景,例如多线程读取共享数据,少量线程修改共享数据。
-
需要提高读操作的并发性能,降低写操作的锁竞争。
public class ReentrantReadWriteLockExample {
private int count = 0;
private ReadWriteLock lock = new ReentrantReadWriteLock();
public void increment() {
lock.writeLock().lock();
try {
count++;
} finally {
lock.writeLock().unlock();
}
}
public int getCount() {
lock.readLock().lock();
try {
return count;
} finally {
lock.readLock().unlock();
}
}
}
性能比较和注意事项
性能比较:
-
synchronized 简单易用,适合低竞争、低并发的场景。
-
ReentrantLock 提供更多高级特性,适合复杂的同步需求和高并发场景。
-
ReentrantReadWriteLock 适合读多写少的场景,提高了读操作的并发性能。
注意事项:
-
避免死锁:使用锁时要注意锁的获取顺序,避免出现循环等待导致死锁。
-
合理释放锁资源:始终在 finally 块中释放锁资源,确保异常情况下能正常释放。
-
锁的粒度:尽量将锁的范围缩小到必要的代码块,减少锁的持有时间,提高并发性能。
读锁跟写锁
读锁(Read Lock)
读锁允许多个线程同时访问共享资源,因为读操作不会修改数据,所以多个线程可以同时读取资源而不会导致数据的不一致。读锁适用于读多写少的场景,可以提高系统的并发性能。
特点:
-
多个线程可以同时获取读锁并访问共享资源。
-
读锁与读锁之间不互斥,允许并发读取。
-
当有线程持有写锁时,其他线程无法获取读锁,保证数据的一致性。
写锁(Write Lock)
写锁用于对共享资源的写操作,保证写操作的原子性和一致性,因此只允许一个线程获取写锁进行写操作。写锁适用于写多读少的场景,避免多个写操作之间的竞争和数据不一致。
特点
-
只允许一个线程获取写锁,执行写操作。
-
写锁与写锁之间互斥,避免多个写操作同时进行。
-
写锁与读锁之间互斥,当有线程持有写锁时,其他线程无法获取读锁,避免读操作读到不一致的数据。
适用场景
读锁适用场景:
-
数据读取操作频繁,写操作较少。
-
多线程可以并发读取数据,提高系统的读取性能。
写锁适用场景:
-
数据写入操作频繁,读操作较少。
-
保证写操作的原子性和一致性,避免数据写入冲突。
在这个示例中,ReadWriteLockExample 类使用了 ReentrantReadWriteLock 来管理共享资源 sharedResource 的读写访问。readData 方法使用读锁进行读操作,允许多个线程同时调用;writeData 方法使用写锁进行写操作,保证了写操作的原子性和一致性。通过这种方式,可以有效地控制对共享资源的并发访问。
public class ReadWriteLockExample {
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private final Lock readLock = lock.readLock();
private final Lock writeLock = lock.writeLock();
private int sharedResource = 0;
public void readData() {
readLock.lock();
try {
// 读取共享资源的操作
System.out.println("Reading data: " + sharedResource);
} finally {
readLock.unlock();
}
}
public void writeData(int newValue) {
writeLock.lock();
try {
// 写入共享资源的操作
sharedResource = newValue;
System.out.println("Writing data: " + newValue);
} finally {
writeLock.unlock();
}
}
public static void main(String[] args) {
ReadWriteLockExample example = new ReadWriteLockExample();
// 读操作示例
Runnable readTask = () -> {
for (int i = 0; i < 5; i++) {
example.readData();
}
};
// 写操作示例
Runnable writeTask = () -> {
for (int i = 0; i < 5; i++) {
example.writeData(i);
}
};
// 创建多个线程进行读写操作
Thread reader1 = new Thread(readTask);
Thread reader2 = new Thread(readTask);
Thread writer1 = new Thread(writeTask);
reader1.start();
reader2.start();
writer1.start();
}
}
公平锁和非公平锁
ReentrantReadWriteLock 支持公平锁和非公平锁。这两种锁的区别在于获取锁的顺序和策略:
非公平锁:
-
默认情况下,ReentrantReadWriteLock 是非公平锁。
-
非公平锁允许当前线程在竞争时通过插队的方式获取锁,即不考虑等待队列中是否有等待的线程,直接尝试获取锁,如果获取不到则进入等待状态。
-
这种方式可能会导致某些线程长时间等待,因为新来的线程有可能会插队成功获取到锁,从而延迟了等待时间较长的线程。
公平锁:
-
通过 ReentrantReadWriteLock 的构造函数可以选择将其配置为公平锁。例如:ReentrantReadWriteLock(true)。
-
公平锁会按照线程的请求顺序获取锁,即先到先得的原则,所有线程都会先加入等待队列,按照FIFO(先进先出)的顺序获取锁。
-
公平锁保证了所有线程公平地竞争锁,避免了饥饿情况的发生,但可能会降低系统的整体性能,因为线程需要更多的上下文切换。
-
在使用 ReentrantReadWriteLock 时,默认情况下是非公平锁,如果需要公平性,可以在构造锁时显式指定为公平锁。选择公平与非公平锁取决于应用的具体场景和性能需求。
乐观锁跟悲观锁
悲观锁(Pessimistic Locking)
-
悲观锁假设在整个数据处理过程中,其他事务可能会对数据进行修改,因此在读取和修改数据时都会进行加锁操作。它的特点包括:
-
加锁机制:在读取数据之前,会先获取数据的排他锁(Exclusive Lock),确保其他事务不能同时修改数据。
-
适用场景:适用于对数据并发修改频率较高的场景,比如数据库系统中的行级锁、表级锁等。例如,在更新某个数据库记录时,悲观锁可以确保在更新期间不会有其他事务修改同一行数据。
-
悲观锁假设数据可能会被其他线程修改,因此在读取数据时会加锁,以防止其他线程修改数据。常见的实现方式包括synchronized关键字和ReentrantLock。
ReentrantLock用来模拟悲观锁,确保在转账操作期间其他线程无法修改余额。lock()和unlock()方法用于手动加锁和释放锁。
在示例中,当一个线程进入transfer(int amount)方法时,它会首先获取锁,执行转账操作,然后释放锁。其他线程在第一个线程释放锁之前无法进入transfer方法,确保了数据操作的原子性和线程安全性。
public class PessimisticLockingExample {
private int balance = 100; // 初始账户余额为100元
private Lock lock = new ReentrantLock(); // 使用可重入锁
public void transfer(int amount) {
lock.lock(); // 加锁
try {
// 模拟业务逻辑
System.out.println("当前余额:" + balance);
System.out.println("尝试转账:" + amount + " 元");
// 模拟转账过程中,其他线程可能已经修改了余额
try {
Thread.sleep(100); // 模拟转账过程中的耗时操作
} catch (InterruptedException e) {
e.printStackTrace();
}
if (balance >= amount) {
balance -= amount;
System.out.println("转账成功!余额:" + balance + " 元");
} else {
System.out.println("转账失败,余额不足!");
}
} finally {
lock.unlock(); // 释放锁
}
}
public static void main(String[] args) {
PessimisticLockingExample account = new PessimisticLockingExample();
// 模拟两个线程同时进行转账操作
Runnable transferTask = () -> {
account.transfer(50);
};
Thread thread1 = new Thread(transferTask);
Thread thread2 = new Thread(transferTask);
thread1.start();
thread2.start();
}
}
乐观锁(Optimistic Locking)
-
乐观锁则假设数据在大多数情况下不会发生冲突,因此不会使用显式的锁来保护数据,在更新操作之前不会加锁。它的特点包括:
-
版本控制:通过记录数据的版本号或时间戳来实现。在读取数据时,同时获取数据的版本信息;在更新数据时,比较当前版本号与读取时的版本号是否一致,若一致则执行更新操作,否则认为数据已被其他事务修改。
-
适用场景:适用于读操作频繁、写操作相对较少的场景,可以减少加锁对系统性能的影响。例如,在处理Web页面的并发访问时,乐观锁可以通过版本号或时间戳来避免数据冲突,提高并发性能。
-
乐观锁假设读取数据时不会修改,只有在更新数据时才会检查是否被其他线程修改过。常见的实现方式包括版本号或者CAS(Compare and Swap)操作。如果并发写入冲突频繁,乐观锁可能会导致大量的重试操作,影响系统性能。
AtomicInteger用来模拟账户余额,初始为100元。 transfer(int amount)方法模拟了转账操作,首先读取当前余额(乐观地假设没有其他线程在转账过程中修改余额),然后尝试更新余额。
compareAndSet(expectedBalance, expectedBalance - amount)方法是乐观锁的关键,它会比较当前值和期望值,如果相等则更新。如果在更新时发现值已经被其他线程修改,则更新失败。
在示例中,两个线程同时进行转账操作,由于乐观锁不会在读取时加锁,因此允许并发的读取操作,只在更新时进行竞争。
public class OptimisticLockingExample {
private AtomicInteger balance = new AtomicInteger(100); // 初始账户余额为100元
public void transfer(int amount) {
int expectedBalance = balance.get(); // 获取当前余额(假设此时没有其他线程修改)
// 模拟业务逻辑
System.out.println("当前余额:" + expectedBalance);
System.out.println("尝试转账:" + amount + " 元");
// 模拟转账过程中,其他线程可能已经修改了余额
try {
Thread.sleep(100); // 模拟转账过程中的耗时操作
} catch (InterruptedException e) {
e.printStackTrace();
}
if (balance.compareAndSet(expectedBalance, expectedBalance - amount)) {
System.out.println("转账成功!余额:" + balance.get() + " 元");
} else {
System.out.println("转账失败,余额不足或账户已被修改!");
}
}
public static void main(String[] args) {
OptimisticLockingExample account = new OptimisticLockingExample();
// 模拟两个线程同时进行转账操作
Runnable transferTask = () -> {
account.transfer(50);
};
Thread thread1 = new Thread(transferTask);
Thread thread2 = new Thread(transferTask);
thread1.start();
thread2.start();
}
}
区别和适用场景
-
加锁方式:悲观锁在读取和修改数据时加锁,乐观锁在更新数据时根据版本或时间戳进行冲突检测。
-
并发控制策略:悲观锁适合高并发写入的场景,能够确保数据的一致性;乐观锁适合读多写少的场景,能够提高系统的并发性能。
-
实现方式:悲观锁需要数据库支持或显式的锁机制;乐观锁通常通过版本控制或时间戳来实现,并不需要显式加锁。
评论( 0 )