并发 - 锁机制 (Synchronized vs ReentrantLock)

并发编程 —— 锁机制 (Synchronized vs ReentrantLock)

1. 核心理论:为什么需要锁?

在多线程环境下,如果多个线程同时对一个共享资源 (shared resource)(也叫临界资源,如一个全局变量、一个银行账户对象)进行写操作 (write operation),就会发生数据冲突,导致线程安全问题 (thread safety issue)。例如,两个线程同时对一个变量 i 执行 i++,结果可能只增加了1,而不是2。

锁 (Lock) 就是为了解决这个问题而生的。它是一种同步机制,用于控制多个线程对共享资源的访问。

  • 核心思想: 在某个线程访问共享资源之前,先获取锁;访问结束后,再释放锁。只要这个线程还持有锁,其他任何试图获取该锁的线程都会被阻塞 (blocked),直到锁被释放。这保证了在同一时刻,只有一个线程能操作共享资源。

  • 互斥性 (mutual exclusion): 锁的核心特性是互斥,即排他性。


2. 深度剖析:Synchronized vs ReentrantLock

Synchronized 和 ReentrantLock 是 Java 中最常用的两种锁,理解它们的区别是面试的重中之重。

2.1 Synchronized (内置锁/监视器锁)

  • 本质: 是 JVM 层面实现的关键字 (keyword),而非 API。它通过进入和退出 Monitor (监视器) 对象来实现方法同步和代码块同步。

什么是 Monitor (监视器)?

在 Java 中,每个对象都关联着一个 Monitor。当一个线程执行 synchronized 方法或代码块时,它会尝试获取该对象 Monitor 的所有权。Monitor 确保了在任何给定时间点,只有一个线程可以持有锁并执行临界区代码,从而实现互斥。同时,它也通过 wait(), notify() 和 notifyAll() 方法提供了线程间的协调机制。

  • 用法:

    1. 修饰实例方法: 锁是当前类的实例对象 (instance object) (this)。

    2. 修饰静态方法: 锁是当前类的Class 对象 (Class object) (Xxx.class)。

    3. 修饰代码块: 锁是括号里指定的任何对象 (any object)。

  • 特点:

    • 非公平锁 (non-fair lock): 默认情况下,线程获取锁的顺序是随机的,不保证先到先得。

    • 可重入锁 (reentrant lock): 一个已经持有锁的线程,可以再次成功获取该锁而不会被自己阻塞。这是为了防止死锁 (deadlock)。

  • Synchronized 的原理与“高科技厕所”:

    • 实现: JVM 通过在编译后的字节码中插入 monitorenter (进入监视器) 和 monitorexit (退出监视器) 指令来控制同步。

    • 锁升级 (lock escalation) 与比喻: 为了提高性能,Synchronized 引入了锁升级机制。我们可以把它想象成一个高科技厕所的门锁:

      1. 偏向锁 (biased lock): 你是第一个来上厕所的人。厕所很智能,记住了你的样子(线程ID (thread ID))。你反复进出,门锁都直接为你敞开,效率最高。

      2. 轻量级锁 (lightweight lock): 这时,线程B也想上厕所,发现门偏向于你。他敲门,等你出来后,门锁意识到“不止一个人想用”,于是取消对你的偏爱,升级成一个普通的“门闩”。现在谁想进,都得自己先看看门闩有没有锁上,然后快速地自己锁上(CAS (Compare-And-Swap) 操作)。这个过程很快,不需要管理员。

      3. 重量级锁 (heavyweight lock): 如果很多人(多线程)同时来抢厕所,造成了混乱(CAS自旋失败)。这时,管理员(操作系统 (operating system))就出来了,拿出本子说:“都别抢,来我这排队登记!” 于是所有后来的线程都被安排去排队(进入等待队列 (waiting queue),线程被挂起),只有管理员叫到号的人才能进去。这个过程开销最大,但秩序井然。

      • 代码示例:
      
      
      
      public synchronized void methodA() {
      
          System.out.println("进入 methodA");
      
          methodB(); // 在持有锁的情况下,调用另一个同步方法
      
      }
      
      
      
      public synchronized void methodB() {
      
          System.out.println("进入 methodB");
      
      }
      
      
      
      // 如果锁不是可重入的,线程在执行methodA时调用methodB会死锁。
      
      
      
      
    • 自动释放 (automatic release): JVM 会自动在同步代码块执行完毕或抛出异常时释放锁,无需手动操作,不易出错。

    • 不可中断 (non-interruptible): 一个线程在等待获取锁时,不能被中断。

2.2 ReentrantLock (可重入锁)

  • 本质: 是 JUC 包 (java.util.concurrent.locks) 提供的一个 API 类 (API class),它实现了 Lock 接口。

什么是 JUC?

JUC 是 java.util.concurrent 包 的简称,是 Java 并发编程领域的核心工具包,由并发大师 Doug Lea 设计开发。它提供了比传统 synchronized 和 wait/notify 机制更高级、更灵活、更高效的并发工具,例如:

  • 锁框架 (Locks): 如 ReentrantLock、ReadWriteLock。
  • 原子类 (Atomics): 如 AtomicInteger。
  • 线程池 (Executors): 用于高效管理线程。
  • 并发集合 (Concurrent Collections): 如 ConcurrentHashMap。
  • 同步工具 (Synchronizers): 如 Semaphore, CountDownLatch 等。
  • 用法: 必须手动 (manually) 加锁和释放锁。

    
    
    
    Lock lock = new ReentrantLock();
    
    lock.lock(); // 加锁
    
    
    
    try {
    
        // 临界区代码
    
    } finally {
    
        lock.unlock(); // 必须在 finally 块 (finally block) 中释放锁!
    
    }
    
    
    
    
  • 特点:

    • 可重入锁 (reentrant lock): 和 Synchronized 一样,也是可重入的。

    • 可配置公平性: 可以在构造时选择创建公平锁 (fair lock) (new ReentrantLock(true)) 或非公平锁 (non-fair lock)(默认)。公平锁会按线程请求的顺序分配锁,但性能通常低于非公平锁。

    • 手动释放 (manual release): 必须手动调用 unlock() 释放锁,如果忘记释放,会造成死锁 (deadlock)。

    • 可中断获取 (interruptible acquisition): 提供了 lockInterruptibly() 方法,允许线程在等待锁的过程中响应中断。

    • 可超时获取 (timeout acquisition): 提供了 tryLock() 方法,可以尝试获取锁,如果获取不到立即返回 false,或者在指定时间内获取不到就放弃。

    • 可绑定多个条件: 可以关联多个 Condition (条件) 对象,实现更精确的线程等待和唤醒控制(生产者消费者模式 (producer-consumer pattern))。

  • ReentrantLock 的原理与“手动排队厕所”:

    • 实现: 它的核心是基于 JUC 的 AQS (AbstractQueuedSynchronizer, 抽象队列同步器) 框架。

    • AQS 核心思想与比喻: 我们可以把它想象成一个需要手动操作的、带排队系统的公共厕所。

      1. state (状态) 变量: 就是厕所门上那个“有人/无人”的牌子。state=0 表示“无人”,state=1 表示“有人”。

      2. CAS 操作: 你想上厕所,需要自己伸手去把牌子从“无人”翻成“有人”(CAS尝试修改 state)。如果成功,你就获得了锁。

      3. 等待队列 (waiting queue): 当你伸手时,发现牌子已经是“有人”了(CAS失败)。你不会傻等,而是走到门外的一个排队区域(AQS的等待队列),坐下来玩手机(线程被打包成 Node (节点) 并挂起)。

      4. unlock (解锁) 操作: 里面的人出来后,必须负责任地把牌子从“有人”翻回到“无人”(修改 state 为0),然后他会朝排队区的第一个人喊:“嘿,你可以进来了!”(唤醒 (wake up) 队列中的头节点)。

2.3 对比总结

| 特性 | Synchronized | ReentrantLock |

| :--- | :--- | :--- |

| 本质 | Java 关键字 (keyword),JVM 实现 | JUC 中的 API 类 (API class),基于 AQS |

| 锁释放 | 自动释放 (automatic release) | 必须手动释放 (manual release) |

| 公平性 | 非公平 (non-fair) | 可选公平/非公平 (fair/non-fair) |

| 功能 | 基本功能 (basic functionality) | 功能更丰富 (richer functionality)(可中断 (interruptible)、可超时 (timeout)、多条件 (multiple conditions)) |

| 性能 | JDK 1.6 后有锁升级优化,性能很高 | 理论上更高,尤其在高竞争 (contention) 下 |

如何选择?

  • 优先 Synchronized: 在锁竞争 (lock contention) 不激烈、功能简单的场景下,优先使用 Synchronized。因为它语法简单,且由 JVM 自动管理,不易出错。

  • 使用 ReentrantLock: 当需要更高级的功能,如公平锁、可中断获取、超时获取或多条件等待时,才使用 ReentrantLock。

2.4 Synchronized 如何保证原子性、可见性和有序性?

synchronized 关键字之所以能成为保证并发安全的重要手段,就是因为它能同时保证原子性、可见性、有序性三大特性。

  1. 如何保证原子性 (Atomicity)

    • synchronized 通过互斥来实现原子性。当一个线程进入一个 synchronized 修饰的代码块或方法时,它会获取一个锁(也称为监视器锁或 Monitor)。在它释放锁之前,其他任何尝试进入同一个锁保护的代码块的线程都会被阻塞。

    • 这就保证了在同一时刻,只有一个线程能执行被 synchronized 保护的代码块,使得这个代码块内的所有操作对于其他线程来说,要么都执行完了,要么都还没开始,形成了一个不可分割的原子操作。

  2. 如何保证可见性 (Visibility)

    • synchronized 的可见性是由 Java 内存模型(JMM)中的 happens-before 规则来保证的。JMM 规定:

      • 线程加锁时 (enter):会清空当前线程工作内存中共享变量的值,强制从主内存中重新读取最新值。

      • 线程解锁时 (exit):必须将当前线程工作内存中对共享变量的修改刷新到主内存中。

    • 这“一进一出”的操作,确保了一个线程在释放锁之前对共享变量的修改,对下一个获取到同一个锁的线程是完全可见的。

  3. 如何保证有序性 (Ordering)

    • synchronized 同样可以保证有序性。因为一个线程在 synchronized 块内部的所有操作,在 JMM 的视角下,对于另一个随后获取同一个锁的线程来说,是完全可见且有序的。

    • 更直接地说,由于每次只有一个线程能执行同步代码块,这实际上就等同于将多线程的并发执行,在同步代码块的范围内,强制变成了串行执行。既然是串行执行,自然就不会存在指令重排序导致的多线程问题。


3. 生活中的例子与代码示例

  • 生活比喻: 想象一个会议室(共享资源 (shared resource)),多个团队(线程 (thread))都想使用它。

    • Synchronized: 就像会议室的门是一个声控自动门。第一个团队进去后,喊一声“开会!”,门自动锁上(获取锁 (acquire lock))。外面的人怎么喊,门都不会开。会议结束后,团队成员一出门,门自动就解锁了(释放锁 (release lock))。整个过程由门禁系统自动完成,非常省心。但如果里面的人开会时间特别长,外面等待的团队只能死等,不能说“我们不等了,先去干点别的”(不可中断 (non-interruptible))。

    • ReentrantLock: 就像会议室的门是一个需要刷员工卡的手动门禁。你需要主动刷卡开门进去(lock()),并且走的时候,必须记得在内部按一下“出门”按钮,把门打开(unlock())。如果你忘了按,下个团队就永远进不来了(死锁 (deadlock))。但这个门禁系统功能更强大:你可以尝试刷卡(tryLock),可以按刷卡顺序排队(公平锁 (fair lock)),也可以在排队时随时放弃去干别的事(可中断 (interruptible))。

  • 核心代码示例: 模拟多线程同时取钱




package com.study.concurrency;



import java.util.concurrent.locks.Lock;

import java.util.concurrent.locks.ReentrantLock;



// 共享资源:银行账户

class UnsafeAccount {



    private double balance;



    public UnsafeAccount(double balance) {

        this.balance = balance;

    }



    // 未加锁的取钱方法,会产生线程安全问题

    public void withdraw(double amount) {



        if (this.balance >= amount) {

            System.out.println(Thread.currentThread().getName() + " 准备取款: " + amount);



            try {

                Thread.sleep(100); // 模拟网络延迟,放大问题

            } catch (InterruptedException e) {

                e.printStackTrace();

            }



            this.balance -= amount;

            System.out.println(Thread.currentThread().getName() + " 取款成功,余额: " + this.balance);

        } else {

            System.out.println(Thread.currentThread().getName() + " 尝试取款失败,余额不足。");

        }

    }

}



// 使用 Synchronized 保证安全的账户

class SynchronizedAccount extends UnsafeAccount {



    public SynchronizedAccount(double balance) { super(balance); }



    // 使用 synchronized 修饰方法,锁是 this 对象

    @Override

    public synchronized void withdraw(double amount) {

        super.withdraw(amount);

    }

}



// 使用 ReentrantLock 保证安全的账户

class ReentrantLockAccount extends UnsafeAccount {



    private final Lock lock = new ReentrantLock();



    public ReentrantLockAccount(double balance) { super(balance); }



    @Override

    public void withdraw(double amount) {



        lock.lock(); // 手动加锁

        try {

            super.withdraw(amount); // 调用父类不安全的方法,但此时被锁保护

        } finally {

            lock.unlock(); // 必须在 finally 中手动解锁

        }

    }

}



public class LockComparison {



    public static void main(String[] args) throws InterruptedException {



        // 为了演示,我们让两个线程取同一个账户的钱

        UnsafeAccount account = new ReentrantLockAccount(1000); // 你可以换成 SynchronizedAccount 或 UnsafeAccount 观察不同结果



        Runnable task = () -> account.withdraw(800);

        Thread t1 = new Thread(task, "线程A");

        Thread t2 = new Thread(task, "线程B");



        t1.start();

        t2.start();



        t1.join();

        t2.join();



        System.out.println("最终结果:两个线程都尝试取款800元...");



        // 如果是 UnsafeAccount,你会看到两个线程都提示“准备取款”,最终余额变为负数或非预期值。

        // 如果是 SynchronizedAccount 或 ReentrantLockAccount,你会看到一个线程成功,另一个失败,结果正确。

    }

}



posted @ 2026-01-21 16:04  我是刘瘦瘦  阅读(73)  评论(0)    收藏  举报