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。多个线程同时执行这些操作时,可能出现以下情况:
- 线程 A 读取了 
count的值(例如 10)。 - 线程 B 也读取了 
count的值(也是 10)。 - 线程 A 将 
count的值加 1,得到 11,然后将 11 写回count。 - 线程 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:更灵活的锁机制
ReentrantLock 是 java.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 变量变为 true。thread2 在等待 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,但需要注意可能导致线程饥饿。 
五、避免死锁:并发编程的雷区
死锁是指两个或多个线程互相等待对方释放资源,导致所有线程都无法继续执行的状态。 避免死锁的关键在于打破死锁发生的四个必要条件:
- 互斥条件: 资源必须处于独占状态,即一次只能被一个线程占用。
 - 请求与保持条件: 线程已经持有一个资源,但又请求新的资源,而且在请求新资源时,不释放已经持有的资源。
 - 不可剥夺条件: 线程已经获得的资源,在未使用完之前,不能被其他线程强行剥夺。
 - 循环等待条件: 多个线程之间形成循环等待资源的关系。
 
预防死锁的常见方法:
- 避免持有锁的同时请求其他锁: 尽量一次性获取所有需要的锁,或者在使用完一个锁之后立即释放它。
 - 按照固定的顺序获取锁: 如果多个线程需要获取多个锁,确保它们按照相同的顺序获取锁,避免循环等待。
 - 使用定时锁: 使用 
ReentrantLock的tryLock(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,形成循环等待,导致死锁。
六、关于线程安全的一些额外建议
除了使用 synchronized 和 ReentrantLock 之外,还有其他一些方法可以提高线程安全性:
- 使用不可变对象: 不可变对象的状态在创建后不能被修改,因此是线程安全的。
 - 使用线程安全的集合: 
java.util.concurrent包提供了许多线程安全的集合类,例如ConcurrentHashMap,CopyOnWriteArrayList等。 - 使用原子类: 
java.util.concurrent.atomic包提供了原子类,例如AtomicInteger,AtomicLong等,用于实现原子操作。 - 避免共享可变状态: 尽量减少线程之间共享的可变状态,或者使用线程封闭技术,将可变状态限制在单个线程内部。
 - 正确使用 volatile 关键字:  
volatile关键字可以保证变量的可见性,但不能保证原子性。 
总结:选择合适的同步机制,避免死锁,提升线程安全
synchronized 和 ReentrantLock 都是 Java 中重要的同步机制。synchronized 简单易用,适合简单的互斥访问场景。ReentrantLock 提供了更丰富的功能,适合复杂的同步场景。在选择同步机制时,需要根据具体的场景和需求进行权衡。  此外,避免死锁是并发编程中必须重视的问题,需要采取有效的预防措施。  理解并运用这些知识,才能编写出高效、可靠的并发程序。