Java并发编程中的锁机制:synchronized与Lock详解

在现代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)。每个对象都与一个监视器关联,当线程进入同步代码块前需要成功获得监视器,退出时(正常或异常)释放监视器。在字节码层面,同步代码块通过monitorentermonitorexit指令实现;同步方法则通过方法上的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来精细控制等待/唤醒。

    • 读写锁场景(ReentrantReadWriteLockStampedLock)。

六、高级锁: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) {} }
}

避免死锁的策略:

  1. 避免嵌套锁:尽量不持有一个锁时再去获取另一个锁。

  2. 统一锁顺序:所有线程按相同的顺序获取锁。

  3. 使用tryLock超时:获取失败时释放已有锁,回退重试。

  4. 使用open-close调用:减少锁的持有范围,避免长时间占锁。

八、最佳实践总结

  1. 最小化锁的作用范围 :只在必要的代码段加锁,避免Synchronized方法包含耗时IO操作。

  2. 使用finally释放锁 :对显式锁,必须在finally中unlock(),防止异常导致锁泄漏。

  3. 避免锁上调用外部方法:外部方法可能又去拿其他锁,或者执行耗时操作,增加死锁风险。

  4. 优先使用并发容器 :如ConcurrentHashMapCopyOnWriteArrayList等,它们内部已经实现了高效的同步策略,减少手动锁的需求。

  5. 考虑使用原子类 :对于简单的计数器,AtomicIntegerAtomicLong基于CAS无锁操作,性能更好。

  6. 要理解锁的可见性语义:不仅保证互斥,还保证释放锁前写的内容对后续获取锁的线程可见。

九、完整示例:模拟售票系统

下面是一个综合示例,使用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锁机制,并在项目中写出高效、安全的并发代码。如果你有任何疑问或建议,欢迎在评论区留言讨论!

相关推荐
SamDeepThinking1 小时前
所有的框架源码,最怕的就是被debug
java·后端·程序员
道剑剑非道1 小时前
FFmpeg + Qt 实现摄像头采集与 MP3 背景音乐 RTSP 推流
开发语言·qt·ffmpeg
吴声子夜歌1 小时前
Java——字符编码
java·字符编码·char
冷小鱼1 小时前
多线程编程深度解析:Java与Python框架实战指南
java·开发语言·python·多线程
武帝为此1 小时前
【C语言进程与线程】
c语言·开发语言
fox_lht1 小时前
第十一章 错误处理
开发语言·后端·rust
叼烟扛炮1 小时前
C++ 知识点12 构造函数
开发语言·c++·算法·构造函数
Byte Wizard1 小时前
C语言指针深入浅出4
c语言·开发语言
java1234_小锋2 小时前
Spring AI 2.0 开发Java Agent智能体 - 结构化输出
java·人工智能·spring