JAVA 线程安全问题频发?深入理解 synchronized 与 ReentrantLock 差异

JAVA 线程安全问题频发?深入理解 synchronized 与 ReentrantLock 差异

大家好,今天我们来深入探讨 Java 中线程安全问题,并重点比较 synchronized 关键字和 ReentrantLock 类,这两个实现互斥访问的关键机制。线程安全是并发编程中至关重要的一环,稍有不慎就会导致数据损坏、死锁等严重问题。synchronized 作为 Java 内置的同步机制,简单易用,而 ReentrantLock 作为 java.util.concurrent 包提供的锁实现,则提供了更丰富的功能。理解它们之间的差异,有助于我们在不同的场景下选择最合适的同步方案。

一、线程安全问题的根源:共享与竞争

线程安全问题源于多个线程同时访问共享的可变状态。当多个线程尝试修改同一块内存区域时,如果没有适当的同步机制,就可能出现以下问题:

  • 数据竞争 (Data Race): 多个线程并发读写同一变量,导致结果不确定。
  • 竞态条件 (Race Condition): 程序的行为取决于多个线程执行的相对顺序,导致结果不可预测。
  • 内存可见性问题 (Visibility): 一个线程对共享变量的修改,无法及时被其他线程观察到。

代码示例:线程不安全计数器

public class UnsafeCounter {
    private int count = 0;

    public void increment() {
        count++; // 非原子操作:读-修改-写
    }

    public int getCount() {
        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        UnsafeCounter counter = new UnsafeCounter();
        Thread[] threads = new Thread[1000];

        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    counter.increment();
                }
            });
            threads[i].start();
        }

        for (Thread thread : threads) {
            thread.join();
        }

        System.out.println("Final count: " + counter.getCount()); // 结果通常小于 1,000,000
    }
}

在这个例子中,increment() 方法不是线程安全的。count++ 实际上包含了三个操作:读取 count 的值,将值加 1,然后将结果写回 count。多个线程同时执行这些操作时,可能出现以下情况:

  1. 线程 A 读取了 count 的值(例如 10)。
  2. 线程 B 也读取了 count 的值(也是 10)。
  3. 线程 A 将 count 的值加 1,得到 11,然后将 11 写回 count
  4. 线程 B 将 count 的值加 1,得到 11,然后将 11 写回 count

结果是 count 只增加了 1,而不是 2。这就是一个典型的竞态条件。

二、synchronized 关键字:Java 内置的互斥锁

synchronized 关键字是 Java 中最基本的同步机制。它可以用于修饰方法或代码块,保证同一时刻只有一个线程可以执行被 synchronized 修饰的代码。

1. synchronized 的用法:

  • 同步方法: 将整个方法声明为 synchronized。 锁定的是 this 对象(对于实例方法)或 Class 对象(对于静态方法)。

    public synchronized void increment() {
        count++;
    }
  • 同步代码块: 使用 synchronized(object) 来指定锁定的对象。

    public void increment() {
        synchronized (this) {
            count++;
        }
    }

2. synchronized 的原理:

synchronized 基于 Monitor 对象实现。每个对象都关联一个 Monitor,也称为互斥锁或管程。当线程进入 synchronized 代码块或方法时,它尝试获取 Monitor 的所有权。如果 Monitor 没有被其他线程占用,线程成功获取 Monitor 并继续执行。如果 Monitor 已经被其他线程占用,线程会被阻塞,直到持有 Monitor 的线程释放它。

3. synchronized 的特性:

  • 互斥性 (Mutual Exclusion): 同一时刻只有一个线程可以持有 Monitor。
  • 可见性 (Visibility): 线程在释放 Monitor 之前所做的修改,对后续获取 Monitor 的线程是可见的。 这是因为释放锁会强制将工作内存的修改刷新到主内存,而获取锁会强制从主内存重新加载共享变量的值。
  • 可重入性 (Reentrancy): 如果一个线程已经持有 Monitor,它可以再次进入被同一个 Monitor 保护的 synchronized 代码块或方法。这是为了避免自己把自己锁死。

代码示例:使用 synchronized 修复计数器

public class SafeCounterWithSynchronized {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        SafeCounterWithSynchronized counter = new SafeCounterWithSynchronized();
        Thread[] threads = new Thread[1000];

        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    counter.increment();
                }
            });
            threads[i].start();
        }

        for (Thread thread : threads) {
            thread.join();
        }

        System.out.println("Final count: " + counter.getCount()); // 结果总是 1,000,000
    }
}

在这个例子中,increment() 方法被声明为 synchronized,这意味着同一时刻只有一个线程可以执行 count++ 操作,从而避免了数据竞争。

三、ReentrantLock:更灵活的锁机制

ReentrantLockjava.util.concurrent.locks 包提供的锁实现,它实现了 Lock 接口。与 synchronized 相比,ReentrantLock 提供了更丰富的功能,例如:

  • 公平锁和非公平锁: synchronized 通常是非公平锁(但也可能在特定 JVM 实现中表现出近似公平性),而 ReentrantLock 可以选择公平锁或非公平锁。公平锁保证等待时间最长的线程优先获取锁。
  • 可中断性: 获取 ReentrantLock 的线程可以被中断,而 synchronized 不支持中断。
  • 定时锁: 尝试在指定时间内获取锁,如果超时则放弃。
  • 条件变量: ReentrantLock 可以关联多个条件变量 (Condition),用于实现更复杂的线程同步。

1. ReentrantLock 的用法:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class SafeCounterWithReentrantLock {
    private int count = 0;
    private final Lock lock = new ReentrantLock(); // 创建一个 ReentrantLock 实例

    public void increment() {
        lock.lock(); // 获取锁
        try {
            count++;
        } finally {
            lock.unlock(); // 释放锁 (必须在 finally 块中释放)
        }
    }

    public int getCount() {
        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        SafeCounterWithReentrantLock counter = new SafeCounterWithReentrantLock();
        Thread[] threads = new Thread[1000];

        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    counter.increment();
                }
            });
            threads[i].start();
        }

        for (Thread thread : threads) {
            thread.join();
        }

        System.out.println("Final count: " + counter.getCount()); // 结果总是 1,000,000
    }
}

注意: 使用 ReentrantLock 时,必须finally 块中释放锁,以确保即使发生异常也能释放锁,防止死锁。

2. 公平锁与非公平锁:

  • 公平锁: 按照线程请求锁的顺序授予锁。 可以防止线程饥饿,但性能相对较低。

    Lock fairLock = new ReentrantLock(true); // 创建一个公平锁
  • 非公平锁: 允许线程 "插队",即即使有其他线程在等待锁,如果当前线程尝试获取锁时锁是空闲的,它可以直接获取锁。 非公平锁通常具有更好的性能,但可能导致线程饥饿。

    Lock unfairLock = new ReentrantLock(false); // 创建一个非公平锁 (默认)

3. 可中断性:

ReentrantLock 提供了 lockInterruptibly() 方法,允许线程在等待锁的过程中被中断。

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class InterruptibleLockExample {
    private static final Lock lock = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            try {
                lock.lockInterruptibly(); // 尝试获取锁,可以被中断
                try {
                    System.out.println("Thread 1 acquired the lock.");
                    Thread.sleep(5000); // 模拟持有锁一段时间
                } finally {
                    lock.unlock();
                    System.out.println("Thread 1 released the lock.");
                }
            } catch (InterruptedException e) {
                System.out.println("Thread 1 was interrupted while waiting for the lock.");
            }
        });

        Thread thread2 = new Thread(() -> {
            try {
                Thread.sleep(100); // 确保 thread1 先获取锁
                System.out.println("Thread 2 is trying to acquire the lock.");
                lock.lockInterruptibly(); // 尝试获取锁,可以被中断
                try {
                    System.out.println("Thread 2 acquired the lock.");
                } finally {
                    lock.unlock();
                    System.out.println("Thread 2 released the lock.");
                }
            } catch (InterruptedException e) {
                System.out.println("Thread 2 was interrupted while waiting for the lock.");
            }
        });

        thread1.start();
        thread2.start();

        Thread.sleep(2000); // 等待一段时间
        thread2.interrupt(); // 中断 thread2

        thread1.join();
        thread2.join();

        System.out.println("Main thread finished.");
    }
}

在这个例子中,thread2 在等待锁的过程中被中断,会抛出 InterruptedException 异常。

4. 定时锁:

ReentrantLock 提供了 tryLock(long timeout, TimeUnit unit) 方法,允许线程在指定时间内尝试获取锁。如果超时则返回 false,否则返回 true

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class TimedLockExample {
    private static final Lock lock = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            try {
                lock.lock();
                try {
                    System.out.println("Thread 1 acquired the lock.");
                    Thread.sleep(5000); // 模拟持有锁一段时间
                } finally {
                    lock.unlock();
                    System.out.println("Thread 1 released the lock.");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        Thread thread2 = new Thread(() -> {
            try {
                Thread.sleep(100); // 确保 thread1 先获取锁
                System.out.println("Thread 2 is trying to acquire the lock with a timeout.");
                if (lock.tryLock(2, TimeUnit.SECONDS)) { // 尝试在 2 秒内获取锁
                    try {
                        System.out.println("Thread 2 acquired the lock.");
                    } finally {
                        lock.unlock();
                        System.out.println("Thread 2 released the lock.");
                    }
                } else {
                    System.out.println("Thread 2 failed to acquire the lock within the timeout.");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println("Main thread finished.");
    }
}

在这个例子中,thread2 尝试在 2 秒内获取锁。如果 thread1 在 2 秒内没有释放锁,thread2 将放弃获取锁。

5. 条件变量 (Condition):

ReentrantLock 提供了 Condition 接口,用于实现线程间的协作。Condition 类似于 Object 类的 wait(), notify(), notifyAll() 方法,但提供了更细粒度的控制。

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class ConditionExample {
    private static final Lock lock = new ReentrantLock();
    private static final Condition condition = lock.newCondition();
    private static boolean ready = false;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            lock.lock();
            try {
                while (!ready) {
                    System.out.println("Thread 1 is waiting for the condition.");
                    condition.await(); // 释放锁并等待信号
                }
                System.out.println("Thread 1 received the signal and is now executing.");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        });

        Thread thread2 = new Thread(() -> {
            lock.lock();
            try {
                Thread.sleep(2000);
                ready = true;
                System.out.println("Thread 2 is signaling the condition.");
                condition.signal(); // 发送信号,唤醒一个等待的线程
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        });

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println("Main thread finished.");
    }
}

在这个例子中,thread1 等待 ready 变量变为 truethread2 在等待 2 秒后将 ready 变量设置为 true 并发送信号,唤醒 thread1

四、synchronized vs. ReentrantLock:选择的艺术

特性 synchronized ReentrantLock
实现方式 JVM 内置关键字 Java 类库
灵活性 相对较低 较高
公平性 通常是非公平的 (但也可能在特定 JVM 实现中表现出近似公平性) 可以选择公平锁或非公平锁
可中断性 不支持 支持
定时锁 不支持 支持
条件变量 隐式 (通过 wait(), notify(), notifyAll()) 显式 (通过 Condition 接口)
性能 在 JDK 1.6 之后,性能差距缩小,甚至在某些情况下优于 ReentrantLock 在高并发场景下,公平锁的性能可能较低。非公平锁的性能通常比 synchronized 略好,尤其是在竞争激烈的情况下
易用性 简单易用 需要手动获取和释放锁,更容易出错
异常处理 自动释放锁 (在方法或代码块执行完毕或抛出异常时) 需要在 finally 块中手动释放锁

选择建议:

  • 简单场景: 如果只需要简单的互斥访问,并且对锁的公平性、可中断性、定时锁等没有特殊要求,synchronized 是一个不错的选择,因为它更简单易用。
  • 复杂场景: 如果需要更灵活的锁机制,例如公平锁、可中断性、定时锁、条件变量等,ReentrantLock 是更好的选择。
  • 性能敏感场景: 在高并发、竞争激烈的场景下,可以考虑使用非公平的 ReentrantLock,但需要注意可能导致线程饥饿。

五、避免死锁:并发编程的雷区

死锁是指两个或多个线程互相等待对方释放资源,导致所有线程都无法继续执行的状态。 避免死锁的关键在于打破死锁发生的四个必要条件:

  1. 互斥条件: 资源必须处于独占状态,即一次只能被一个线程占用。
  2. 请求与保持条件: 线程已经持有一个资源,但又请求新的资源,而且在请求新资源时,不释放已经持有的资源。
  3. 不可剥夺条件: 线程已经获得的资源,在未使用完之前,不能被其他线程强行剥夺。
  4. 循环等待条件: 多个线程之间形成循环等待资源的关系。

预防死锁的常见方法:

  • 避免持有锁的同时请求其他锁: 尽量一次性获取所有需要的锁,或者在使用完一个锁之后立即释放它。
  • 按照固定的顺序获取锁: 如果多个线程需要获取多个锁,确保它们按照相同的顺序获取锁,避免循环等待。
  • 使用定时锁: 使用 ReentrantLocktryLock(long timeout, TimeUnit unit) 方法,在指定时间内尝试获取锁,如果超时则放弃,避免无限期等待。
  • 死锁检测与恢复: 某些系统提供了死锁检测机制,可以检测到死锁并采取措施进行恢复,例如回滚事务或重启线程。

代码示例:演示死锁

public class DeadlockExample {
    private static final Object lock1 = new Object();
    private static final Object lock2 = new Object();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("Thread 1: Holding lock1...");
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("Thread 1: Waiting for lock2...");
                synchronized (lock2) {
                    System.out.println("Thread 1: Acquired lock2.");
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (lock2) {
                System.out.println("Thread 2: Holding lock2...");
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("Thread 2: Waiting for lock1...");
                synchronized (lock1) {
                    System.out.println("Thread 2: Acquired lock1.");
                }
            }
        });

        thread1.start();
        thread2.start();
    }
}

在这个例子中,thread1 持有 lock1,等待 lock2,而 thread2 持有 lock2,等待 lock1,形成循环等待,导致死锁。

六、关于线程安全的一些额外建议

除了使用 synchronizedReentrantLock 之外,还有其他一些方法可以提高线程安全性:

  • 使用不可变对象: 不可变对象的状态在创建后不能被修改,因此是线程安全的。
  • 使用线程安全的集合: java.util.concurrent 包提供了许多线程安全的集合类,例如 ConcurrentHashMap, CopyOnWriteArrayList 等。
  • 使用原子类: java.util.concurrent.atomic 包提供了原子类,例如 AtomicInteger, AtomicLong 等,用于实现原子操作。
  • 避免共享可变状态: 尽量减少线程之间共享的可变状态,或者使用线程封闭技术,将可变状态限制在单个线程内部。
  • 正确使用 volatile 关键字: volatile 关键字可以保证变量的可见性,但不能保证原子性。

总结:选择合适的同步机制,避免死锁,提升线程安全

synchronizedReentrantLock 都是 Java 中重要的同步机制。synchronized 简单易用,适合简单的互斥访问场景。ReentrantLock 提供了更丰富的功能,适合复杂的同步场景。在选择同步机制时,需要根据具体的场景和需求进行权衡。 此外,避免死锁是并发编程中必须重视的问题,需要采取有效的预防措施。 理解并运用这些知识,才能编写出高效、可靠的并发程序。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注