Java并发编程中的锁机制与优化
Java并发编程中的锁机制基础
在Java并发编程中,锁机制是实现线程安全的关键手段。Java提供了多种锁机制,每种锁都有其特点和适用场景。
1. 内置锁(Monitor锁)
Java中的每个对象都可以作为一个锁,这种锁被称为内置锁或Monitor锁。当一个线程进入一个同步方法或同步块时,它会自动获取该对象的内置锁;当方法或块执行完毕,线程会自动释放锁。
public class SynchronizedExample {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
在上述代码中,increment
和getCount
方法都被synchronized
关键字修饰,这意味着当一个线程调用这些方法时,它会获取SynchronizedExample
实例的内置锁。其他线程在该锁被持有期间无法进入这些同步方法,从而保证了count
变量的线程安全。
2. 锁的可重入性
内置锁是可重入的。这意味着同一个线程可以多次获取同一个锁,而不会造成死锁。例如:
public class ReentrantExample {
public synchronized void outerMethod() {
System.out.println("Entering outer method");
innerMethod();
}
public synchronized void innerMethod() {
System.out.println("Entering inner method");
}
}
在ReentrantExample
类中,outerMethod
调用了innerMethod
。由于两个方法都是同步的,并且属于同一个对象实例,同一个线程可以顺利进入innerMethod
,这就是锁的可重入性。
显式锁(Lock接口)
除了内置锁,Java 5.0引入了java.util.concurrent.locks.Lock
接口,它提供了比内置锁更灵活和强大的功能。
1. Lock接口的基本使用
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockExample {
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();
}
}
}
在LockExample
类中,使用ReentrantLock
实现了线程安全的计数器。与内置锁不同,显式锁需要手动调用lock()
方法获取锁,并在finally
块中调用unlock()
方法释放锁,以确保锁一定会被释放。
2. 公平锁与非公平锁
ReentrantLock
默认是非公平锁,它允许新到来的线程在锁可用时立即尝试获取锁,而不考虑等待队列中的其他线程。公平锁则会按照线程等待的顺序来分配锁。
// 创建公平锁
Lock fairLock = new ReentrantLock(true);
// 创建非公平锁
Lock unfairLock = new ReentrantLock();
公平锁虽然保证了线程获取锁的公平性,但由于频繁的线程切换,性能通常比非公平锁低。在大多数情况下,非公平锁更适合高并发场景。
读写锁(ReadWriteLock)
在很多应用场景中,读操作远远多于写操作。Java提供了ReadWriteLock
接口来优化这种场景。
1. ReadWriteLock接口的实现
ReadWriteLock
接口有一个实现类ReentrantReadWriteLock
,它将锁分为读锁和写锁。允许多个线程同时获取读锁,但只允许一个线程获取写锁。
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteLockExample {
private int data = 0;
private ReadWriteLock lock = new ReentrantReadWriteLock();
public void read() {
lock.readLock().lock();
try {
System.out.println("Reading data: " + data);
} finally {
lock.readLock().unlock();
}
}
public void write(int newData) {
lock.writeLock().lock();
try {
data = newData;
System.out.println("Writing data: " + data);
} finally {
lock.writeLock().unlock();
}
}
}
在ReadWriteLockExample
类中,read
方法获取读锁,多个线程可以同时调用read
方法。而write
方法获取写锁,在写锁被持有期间,其他线程无法获取读锁或写锁,从而保证了数据的一致性。
2. 读写锁的应用场景
读写锁适用于数据读取频繁、写入较少的场景,如缓存系统。在缓存系统中,多个线程可能同时读取缓存数据,而只有在数据更新时才需要获取写锁。
锁优化策略
在实际应用中,为了提高并发性能,需要对锁机制进行优化。
1. 减小锁的粒度
减小锁的粒度是指将大的锁分解为多个小的锁,从而降低锁的竞争程度。例如,在一个哈希表中,可以为每个哈希桶分配一个单独的锁。
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class FineGrainedLockingHashMap<K, V> {
private static final int DEFAULT_BUCKET_COUNT = 16;
private final Map<Integer, Lock> bucketLocks = new HashMap<>();
private final Map<K, V> map = new HashMap<>();
public FineGrainedLockingHashMap() {
for (int i = 0; i < DEFAULT_BUCKET_COUNT; i++) {
bucketLocks.put(i, new ReentrantLock());
}
}
private int getBucketIndex(K key) {
return Math.abs(key.hashCode()) % DEFAULT_BUCKET_COUNT;
}
public V get(K key) {
int bucketIndex = getBucketIndex(key);
Lock lock = bucketLocks.get(bucketIndex);
lock.lock();
try {
return map.get(key);
} finally {
lock.unlock();
}
}
public void put(K key, V value) {
int bucketIndex = getBucketIndex(key);
Lock lock = bucketLocks.get(bucketIndex);
lock.lock();
try {
map.put(key, value);
} finally {
lock.unlock();
}
}
}
在FineGrainedLockingHashMap
类中,通过为每个哈希桶分配一个单独的锁,不同哈希桶的操作可以并发进行,从而提高了并发性能。
2. 锁粗化
与减小锁粒度相反,锁粗化是指将多个连续的锁操作合并为一个大的锁操作。当一系列的操作都对同一个对象进行同步时,如果频繁地获取和释放锁,会增加系统开销。此时,可以将这些操作放在一个大的同步块中。
public class LockCoarseningExample {
private final Object lock = new Object();
public void performOperations() {
synchronized (lock) {
operation1();
operation2();
operation3();
}
}
private void operation1() {
// 具体操作1
}
private void operation2() {
// 具体操作2
}
private void operation3() {
// 具体操作3
}
}
在LockCoarseningExample
类中,operation1
、operation2
和operation3
都在同一个同步块中执行,减少了锁的获取和释放次数,提高了性能。
3. 锁消除
锁消除是指在编译时,JVM检测到某些代码块不可能存在竞争条件,从而自动消除这些代码块中的锁。例如:
public class LockEliminationExample {
public void nonConcurrentMethod() {
Object lock = new Object();
synchronized (lock) {
// 这里的操作不会被其他线程访问
int result = 1 + 2;
}
}
}
在nonConcurrentMethod
方法中,lock
对象只在该方法内部使用,不存在线程竞争,JVM在编译时会自动消除同步块中的锁,从而提高性能。
乐观锁与悲观锁
在并发编程中,根据对数据冲突的预测方式不同,锁可以分为乐观锁和悲观锁。
1. 悲观锁
悲观锁认为数据在任何时候都可能被其他线程修改,因此在获取数据时就会先获取锁,以防止其他线程修改数据。内置锁和Lock
接口实现的锁都属于悲观锁。
2. 乐观锁
乐观锁则认为数据一般情况下不会发生冲突,只有在更新数据时才会检查是否有其他线程修改了数据。如果发现数据被修改,则重试操作。乐观锁通常通过版本号或CAS(Compare - And - Swap)操作来实现。
3. CAS操作
CAS是一种乐观锁机制,它包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。只有当V的值等于A时,CAS才会将V的值更新为B,否则不执行任何操作。Java的java.util.concurrent.atomic
包中提供了基于CAS的原子类,如AtomicInteger
。
import java.util.concurrent.atomic.AtomicInteger;
public class CASExample {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
while (true) {
int current = count.get();
int next = current + 1;
if (count.compareAndSet(current, next)) {
break;
}
}
}
public int getCount() {
return count.get();
}
}
在CASExample
类中,increment
方法使用AtomicInteger
的compareAndSet
方法实现了线程安全的计数器。如果compareAndSet
操作失败,说明在获取current
值后,count
的值被其他线程修改了,此时需要重新获取current
值并再次尝试更新。
偏向锁、轻量级锁与重量级锁
在JDK 1.6中,为了提高锁的性能,引入了偏向锁和轻量级锁机制。
1. 偏向锁
偏向锁是为了在无竞争情况下减少锁获取的开销而设计的。当一个线程访问同步块并获取锁时,会在对象头中记录该线程的ID,以后该线程再次进入同步块时,不需要再次获取锁,直接进入即可。只有当有其他线程尝试获取该锁时,偏向锁才会被撤销。
2. 轻量级锁
轻量级锁是在竞争不是很激烈的情况下使用的。当一个线程获取轻量级锁时,它会在栈帧中创建一个锁记录(Lock Record),并将对象头中的Mark Word复制到锁记录中,然后尝试使用CAS操作将对象头中的Mark Word替换为指向锁记录的指针。如果替换成功,该线程就获取了轻量级锁;如果失败,说明有其他线程竞争锁,轻量级锁会膨胀为重量级锁。
3. 重量级锁
重量级锁就是传统的内置锁,它通过操作系统的互斥量来实现线程同步。由于涉及到用户态和内核态的切换,重量级锁的开销较大。当轻量级锁竞争失败时,会升级为重量级锁。
锁的选择与性能分析
在实际应用中,选择合适的锁机制对于提高并发性能至关重要。
1. 根据场景选择锁
- 内置锁:适用于简单的同步场景,代码简洁,自动获取和释放锁。但功能相对有限,如无法实现公平锁。
- 显式锁(Lock接口):提供了更灵活的功能,如可中断的锁获取、公平锁等。适用于对锁功能要求较高的场景。
- 读写锁:适用于读多写少的场景,能显著提高并发性能。
- 乐观锁:适用于冲突较少的场景,通过减少锁的使用来提高性能。
2. 性能分析工具
在优化锁机制时,可以使用一些性能分析工具,如JProfiler、VisualVM等。这些工具可以帮助我们分析线程的运行状态、锁的竞争情况等,从而找出性能瓶颈并进行优化。
例如,使用JProfiler可以查看线程的CPU使用率、锁的持有时间等信息,通过分析这些数据,可以确定是否存在锁竞争过于激烈的情况,并针对性地进行优化,如调整锁的粒度、选择更合适的锁机制等。
死锁问题与避免
死锁是并发编程中常见的问题,它指的是两个或多个线程相互等待对方释放锁,从而导致程序无法继续执行。
1. 死锁示例
public class DeadlockExample {
private static final Object resource1 = new Object();
private static final Object resource2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (resource1) {
System.out.println("Thread 1 acquired resource1");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (resource2) {
System.out.println("Thread 1 acquired resource2");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (resource2) {
System.out.println("Thread 2 acquired resource2");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (resource1) {
System.out.println("Thread 2 acquired resource1");
}
}
});
thread1.start();
thread2.start();
}
}
在DeadlockExample
类中,thread1
先获取resource1
,然后尝试获取resource2
;thread2
先获取resource2
,然后尝试获取resource1
。如果thread1
和thread2
同时运行,就可能导致死锁。
2. 死锁的避免
- 破坏死锁的四个必要条件:死锁的产生需要四个必要条件,即互斥、占有并等待、不可剥夺和循环等待。通过破坏其中任何一个条件,都可以避免死锁。例如,采用资源分配图算法可以破坏循环等待条件。
- 按照相同顺序获取锁:在程序中,所有线程按照相同的顺序获取锁,可以避免循环等待。如在上述例子中,如果
thread1
和thread2
都先获取resource1
,再获取resource2
,就不会发生死锁。
总结
Java并发编程中的锁机制是一个复杂而重要的主题。从基础的内置锁到功能强大的显式锁,从适用于读多写少场景的读写锁到优化性能的乐观锁,以及偏向锁、轻量级锁和重量级锁的不同机制,每种锁都有其特点和适用场景。在实际应用中,我们需要根据具体的业务需求和性能要求,选择合适的锁机制,并通过减小锁粒度、锁粗化、锁消除等优化策略来提高并发性能。同时,要注意避免死锁等问题,确保程序的正确性和稳定性。通过深入理解和合理运用锁机制,我们能够编写出高效、可靠的并发程序。