在现代Java开发中,并发编程是绕不开的核心话题。无论是高并发服务器、大数据处理,还是普通的Web应用,多线程的使用都能极大提升系统性能。然而,线程间的资源竞争也带来了数据不一致、死锁、活锁等问题。为了解决这些隐患,Java提供了多种锁机制来保证共享数据的安全访问。本文将深入剖析Java中最常用的两类锁------synchronized关键字和Lock接口(以ReentrantLock为代表),从用法、原理到性能对比,辅以完整代码示例,帮助你在实际项目中做出正确的技术选型。
一、为什么需要锁?
假设我们有一个银行账户类,包含余额和取款方法。如果不加任何同步控制,多个线程同时取款会导致余额计算错误:
java
public class BankAccount {
private int balance = 1000;
public void withdraw(int amount) {
if (balance >= amount) {
// 模拟其他耗时操作
try { Thread.sleep(10); } catch (InterruptedException e) {}
balance -= amount;
}
}
public int getBalance() {
return balance;
}
public static void main(String[] args) throws InterruptedException {
BankAccount account = new BankAccount();
Runnable task = () -> account.withdraw(500);
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println("最终余额:" + account.getBalance()); // 可能为500或0
}
}
上述代码中,两个线程各取500元,最终余额应为0,但由于balance >= amount判断和实际扣除动作不是原子操作,可能出现两个线程都通过判断,然后各自扣减,导致余额变成负数或错误数值。这就是典型的竞态条件。锁的作用就是将这些非原子操作变为原子操作,保证同一时刻只有一个线程能修改共享变量。
二、synchronized内置锁
synchronized是Java语言内置的同步机制,使用简单,无需手动释放锁。它可以修饰实例方法、静态方法和代码块。
1. 修饰实例方法
锁住当前实例对象(this),同一时刻同一个对象的多个同步方法互斥。
java
public class SynchronizedCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
2. 修饰静态方法
锁住当前类的Class对象,同一类的所有静态同步方法互斥。
java
public class SynchronizedStaticCounter {
private static int count = 0;
public static synchronized void increment() {
count++;
}
public static synchronized int getCount() {
return count;
}
}
3. 同步代码块
可以更精细地控制锁的范围,减少锁持有的时间,提升并发性。需要显式指定锁对象。
java
public class BankAccountSync {
private int balance = 1000;
private final Object lock = new Object(); // 专用锁对象
public void withdraw(int amount) {
synchronized (lock) {
if (balance >= amount) {
balance -= amount;
}
}
}
}
4. synchronized底层原理
synchronized依赖于JVM的对象监视器(Monitor)。每个对象都与一个监视器关联,当线程进入同步代码块前需要成功获得监视器,退出时(正常或异常)释放监视器。在字节码层面,同步代码块通过monitorenter和monitorexit指令实现;同步方法则通过方法上的ACC_SYNCHRONIZED标志来标识,JVM会隐式执行监视器的进入和退出。
从JDK 1.6开始,JVM对synchronized进行了大量优化,引入了偏向锁、轻量级锁和重量级锁,以及自旋自适应等技术。初始时锁处于偏向锁状态(只有一个线程竞争),当有第二个线程竞争时升级为轻量级锁(CAS实现),竞争激烈时升级为重量级锁(操作系统互斥量)。因此,现代Java中的synchronized性能已经不亚于ReentrantLock,且使用更简洁。
三、Lock显式锁
JDK 1.5引入了java.util.concurrent.locks.Lock接口,提供了比synchronized更灵活的锁操作。最常用的实现是ReentrantLock。
1. Lock接口核心方法
void lock():获取锁,如果锁被占用则阻塞直到获得。boolean tryLock():尝试获取锁,立即返回成功/失败。boolean tryLock(long time, TimeUnit unit):带超时时间的尝试。void unlock():释放锁,必须在finally块中执行。Condition newCondition():获取条件对象,实现等待/通知。
2. ReentrantLock基本使用
java
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockCounter {
private int count = 0;
private final 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();
}
}
}
3. 可中断锁
synchronized无法响应中断(线程阻塞在同步锁上时,调用interrupt()无效)。而ReentrantLock.lockInterruptibly()支持中断响应:
java
public void doSomething() throws InterruptedException {
lock.lockInterruptibly();
try {
// 临界区代码
} finally {
lock.unlock();
}
}
当另一个线程调用当前线程的interrupt()时,lockInterruptibly()会抛出InterruptedException,使线程有机会处理中断。
4. 尝试非阻塞获取锁
使用tryLock()可以实现非阻塞逻辑,避免死锁:
java
public boolean tryTransfer(ReentrantLockCounter from, ReentrantLockCounter to, int amount) {
if (from.lock.tryLock()) {
try {
if (to.lock.tryLock()) {
try {
if (from.getCount() >= amount) {
from.count -= amount;
to.count += amount;
return true;
}
} finally {
to.lock.unlock();
}
}
} finally {
from.lock.unlock();
}
}
return false;
}
5. 公平锁与非公平锁
ReentrantLock构造函数可指定公平性。公平锁:线程按请求顺序获取锁,避免饥饿;非公平锁:允许插队,提高吞吐量。默认非公平。
java
// 公平锁
Lock fairLock = new ReentrantLock(true);
// 非公平锁(默认)
Lock unfairLock = new ReentrantLock();
6. 可重入性
synchronized和ReentrantLock通过锁计数器实现可重入。线程首次获取锁时计数器置1,后续每次重入计数器递增,释放时递减至0才真正释放锁资源。
java
public class ReentrantExample {
private final Lock lock = new ReentrantLock();
public void outer() {
lock.lock();
try {
inner(); // 嵌套调用仍可获取锁
} finally {
lock.unlock();
}
}
private void inner() {
lock.lock();
try {
System.out.println("Critical section");
} finally {
lock.unlock();
}
}
}
四、读写锁:ReentrantReadWriteLock
当读操作远多于写操作时,使用独占锁会严重限制并发性。读写锁允许多个线程同时读,但写操作互斥且与读互斥。ReadWriteLock接口及实现ReentrantReadWriteLock提供了这一能力。
java
public class ReadWriteMap<K,V> {
private final Map<K,V> map = new HashMap<>();
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
public V get(K key) {
readLock.lock();
try {
return map.get(key);
} finally {
readLock.unlock();
}
}
public void put(K key, V value) {
writeLock.lock();
try {
map.put(key, value);
} finally {
writeLock.unlock();
}
}
}
五、性能对比与适用场景
1. 性能
-
JDK 1.6以前,
synchronized性能较差,ReentrantLock优势明显。 -
现代JVM(尤其是1.8+),
synchronized经过偏向锁、轻量级锁优化,在低/中度竞争下性能接近甚至超过ReentrantLock。但在极高竞争且需要公平锁时,ReentrantLock更可控。
2. 功能差异
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 锁获取方式 | JVM自动管理 | 手动lock/unlock |
| 可中断性 | 不支持 | lockInterruptibly() |
| 公平锁 | 非公平 | 可配置公平/非公平 |
| 条件变量 | 单一wait/notify | 多Condition支持 |
3. 选择建议
-
优先使用
synchronized:代码简洁,JVM会持续优化,且不易忘记释放锁。如果一个锁仅用于保护少量代码,且没有高级需求(如超时、中断),用synchronized。 -
使用
ReentrantLock的场景:-
需要可中断的锁获取。
-
需要非阻塞的
tryLock()或带超时的尝试。 -
需要公平锁来避免线程饥饿。
-
需要多个
Condition来精细控制等待/唤醒。 -
读写锁场景(
ReentrantReadWriteLock或StampedLock)。
-
六、高级锁:StampedLock(Java 8+)
StampedLock提供三种模式:写锁、读锁(悲观读)和乐观读。乐观读不加锁,通过版本号(stamp)验证数据一致性,适合读多写少的极致优化。
java
public class Point {
private double x, y;
private final StampedLock sl = new StampedLock();
public void move(double dx, double dy) {
long stamp = sl.writeLock();
try {
x += dx;
y += dy;
} finally {
sl.unlockWrite(stamp);
}
}
public double distance() {
long stamp = sl.tryOptimisticRead();
double currentX = x, currentY = y;
if (!sl.validate(stamp)) {
stamp = sl.readLock();
try {
currentX = x;
currentY = y;
} finally {
sl.unlockRead(stamp);
}
}
return Math.sqrt(currentX*currentX + currentY*currentY);
}
}
StampedLock不支持可重入,且写锁与悲观读锁都不支持条件变量。它的性能极高,适用于短小的临界区,但使用复杂度较高。
七、死锁与避免策略
锁虽然能保证线程安全,但不当使用会导致死锁------两个线程互相等待对方释放锁,永远阻塞。经典例子:
java
public class DeadlockDemo {
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (lockA) {
sleep(100);
synchronized (lockB) {
System.out.println("t1 done");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (lockB) {
sleep(100);
synchronized (lockA) {
System.out.println("t2 done");
}
}
});
t1.start(); t2.start();
}
static void sleep(int ms) { try { Thread.sleep(ms); } catch(InterruptedException e) {} }
}
避免死锁的策略:
-
避免嵌套锁:尽量不持有一个锁时再去获取另一个锁。
-
统一锁顺序:所有线程按相同的顺序获取锁。
-
使用
tryLock超时:获取失败时释放已有锁,回退重试。 -
使用
open-close调用:减少锁的持有范围,避免长时间占锁。
八、最佳实践总结
-
最小化锁的作用范围 :只在必要的代码段加锁,避免
Synchronized方法包含耗时IO操作。 -
使用
finally释放锁 :对显式锁,必须在finally中unlock(),防止异常导致锁泄漏。 -
避免锁上调用外部方法:外部方法可能又去拿其他锁,或者执行耗时操作,增加死锁风险。
-
优先使用并发容器 :如
ConcurrentHashMap、CopyOnWriteArrayList等,它们内部已经实现了高效的同步策略,减少手动锁的需求。 -
考虑使用原子类 :对于简单的计数器,
AtomicInteger、AtomicLong基于CAS无锁操作,性能更好。 -
要理解锁的可见性语义:不仅保证互斥,还保证释放锁前写的内容对后续获取锁的线程可见。
九、完整示例:模拟售票系统
下面是一个综合示例,使用ReentrantLock模拟100张票的售票系统,有10个窗口(线程)同时售票,带尝试超时和重试机制。
java
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;
public class TicketSystem {
private int tickets = 100;
private final ReentrantLock lock = new ReentrantLock(true); // 公平锁
public boolean sellTicket(String windowName) {
boolean acquired = false;
try {
// 尝试获取锁,最多等待200毫秒
acquired = lock.tryLock(200, TimeUnit.MILLISECONDS);
if (!acquired) {
System.out.println(windowName + " 获取锁超时,放弃购票");
return false;
}
if (tickets > 0) {
tickets--;
System.out.println(windowName + " 售出1张票,剩余" + tickets);
return true;
} else {
System.out.println(windowName + " 票已售罄");
return false;
}
} catch (InterruptedException e) {
System.out.println(windowName + " 被中断");
return false;
} finally {
if (acquired) {
lock.unlock();
}
}
}
public static void main(String[] args) throws InterruptedException {
TicketSystem system = new TicketSystem();
Runnable task = () -> {
String name = Thread.currentThread().getName();
for (int i = 0; i < 15; i++) {
boolean success = system.sellTicket(name);
if (!success && i > 5) break; // 多次失败后退出
try { Thread.sleep(10); } catch (InterruptedException e) {}
}
};
for (int i = 1; i <= 10; i++) {
new Thread(task, "窗口-" + i).start();
}
}
}
该示例展示了tryLock超时、锁释放的规范性以及公平锁的排队效果,适合实际项目参考。
十、结语
Java的锁机制从内置synchronized到功能丰富的Lock接口,再到高性能的StampedLock,为并发编程提供了层层递进的工具。掌握它们的关键不在于记住多少API,而在于理解锁的本质------保证临界区互斥与内存可见性 。在实际开发中,先问自己:是否真的需要手动锁?能否用并发容器或原子类代替?如果必须使用锁,优先选择synchronized,只有在需要高级特性时才切换到ReentrantLock或读写锁。同时,务必注意锁的粒度、顺序和超时处理,避免死锁和性能瓶颈。
希望本文的详实讲解和代码示例能够帮助你深入理解Java锁机制,并在项目中写出高效、安全的并发代码。如果你有任何疑问或建议,欢迎在评论区留言讨论!