1 线程安全与同步机制
线程安全是指在多线程环境下,多个线程对共享资源进行访问时,不会发生数据不一致或竞争条件的现象。为了保证线程安全,通常需要通过同步机制来控制对共享资源的访问,确保在任何时刻只有一个线程能访问该资源。
1.1 线程安全的定义
线程安全指的是在多线程并发执行的情况下,多个线程对同一共享资源的访问,不会导致数据不一致、竞态条件等问题。线程安全的关键点在于:
共享资源的可见性:当一个线程修改共享资源时,其他线程能够看到最新的修改。
原子性:对共享资源的操作是不可分割的,要么完全执行,要么完全不执行,不会被其他线程干扰。
有序性:程序中的操作顺序按照预期执行,不会因为线程调度等因素导致不确定的执行顺序。
1.2 常见的线程安全问题
竞态条件(Race Condition):当多个线程同时访问共享资源时,由于线程的执行顺序不可预测,可能会导致数据的不可预知结果。竞态条件通常发生在多个线程尝试同时修改共享变量时。
死锁(Deadlock):多个线程因互相等待对方释放资源,造成永远无法执行的状态。
资源争用(Resource Contention):多个线程同时争夺有限的资源,导致性能问题,甚至可能导致系统崩溃。
2 synchronized 关键字
synchronized 是 Java 中最常用的同步机制。它通过对方法或代码块加锁,保证在同一时刻只有一个线程可以执行被 synchronized 修饰的代码,从而确保对共享资源的操作是安全的。
2.1 synchronized 的基本使用
2.1.1 修饰实例方法
public synchronized void increment() {
counter++;
}
当 synchronized 修饰实例方法时,锁的是当前实例对象的 monitor。同一个对象的多个线程调用该方法时,会被串行化执行。
2.1.2 修饰静态方法
public synchronized static void staticMethod() {
// 静态方法内部的同步代码
}
当 synchronized 修饰静态方法时,锁的是当前类的 Class 对象。即使是不同实例对象的线程,都会被这个类的 Class 对象锁住,保证同一时刻只有一个线程可以执行该类的静态方法。
2.1.3 修饰代码块
public void increment() {
synchronized(this) {
counter++;
}
}
同步代码块相比于修饰整个方法,能够提高性能,因为它只对代码块中需要同步的部分加锁,而不是整个方法。这样做可以减少锁的粒度,提高程序的执行效率。
2.2 synchronized 的工作原理
每个对象有一个与之关联的锁(称为 monitor 锁)。
线程在执行
synchronized方法时,必须首先获取该方法所属对象的锁。如果锁已经被其他线程占用,当前线程会阻塞,直到锁可用。当线程执行完同步方法或代码块后,会释放锁,允许其他线程获取锁。
2.3 synchronized 的限制
性能开销:每次加锁和解锁都会产生性能开销,尤其在高并发的情况下,锁竞争会显著影响性能。
死锁:如果多个线程相互持有对方所需要的锁,就会发生死锁,导致程序无法继续执行。
可扩展性差:
synchronized无法灵活控制锁的粒度和共享资源的访问,限制了并发程序的可扩展性。
3 显式锁:ReentrantLock
在 Java 5 之后,java.util.concurrent.locks 包提供了 ReentrantLock,它是显式的锁,具有比 synchronized 更强大的功能。ReentrantLock 可以手动控制锁的获取和释放,避免了 synchronized 的一些局限性。
3.1 ReentrantLock 的基本用法
ReentrantLock lock = new ReentrantLock();
public void increment() {
lock.lock(); // 获取锁
try {
counter++;
} finally {
lock.unlock(); // 确保释放锁
}
}
ReentrantLock 的基本用法与 synchronized 类似,但它提供了更多的灵活性:
lock():手动获取锁。
unlock():手动释放锁,通常放在
finally块中,确保锁的释放。
3.2 ReentrantLock 的优点
可中断锁:
ReentrantLock支持中断,可以在获取锁时响应线程的中断操作。尝试锁:可以使用
tryLock()方法尝试获取锁,如果锁不可用,可以选择继续执行或等待。公平锁与非公平锁:可以通过构造方法来选择使用公平锁(
new ReentrantLock(true)),公平锁保证按照线程请求锁的顺序来获取锁,避免线程饿死。
3.3 ReentrantLock 的缺点
性能开销:虽然
ReentrantLock提供了更灵活的控制,但它的性能开销较synchronized高,尤其在高并发情况下,锁的竞争更加激烈。使用复杂性:
ReentrantLock需要显式的获取和释放锁,比synchronized语法更复杂,容易造成忘记释放锁的情况,进而引发死锁。
4 死锁与避免死锁
死锁是并发编程中的一个经典问题,它指的是多个线程因相互等待对方释放资源而导致永远无法执行的状态。死锁的典型场景发生在多个线程需要获取多个锁时。
4.1 死锁的必要条件
死锁发生需要同时满足以下四个条件:
互斥条件:每个资源要么已分配给某个线程,要么是可用的。
占有并等待:一个线程已经持有至少一个资源,并且在等待获取其他线程持有的资源。
不可剥夺:线程持有的资源在未使用完之前,不能被其他线程抢占。
循环等待:一组线程形成一个环形等待,每个线程都在等待下一个线程释放自己所需要的资源。
4.2 死锁的预防与避免
避免嵌套锁:避免多个线程在不同的顺序中请求锁,减少死锁的发生概率。
锁的顺序:为所有的锁定义一个固定的顺序,所有线程按照相同的顺序请求锁。这样可以防止死锁。
尝试锁:使用
tryLock()方法,允许线程在无法获得锁时返回,从而避免一直等待。设置锁超时:为每个锁设置超时时间,如果在超时之前未能获取到锁,线程会放弃请求并处理超时情况。
4.3 死锁检测
如果能够动态检测程序中的死锁,可能会减少死锁带来的影响。可以通过 JDK 中的 ThreadMXBean 来检查死锁情况:
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
long[] deadlockedThreads = threadMXBean.findDeadlockedThreads();
if (deadlockedThreads != null) {
// 处理死锁
}
5 原子操作与 CAS(Compare and Swap)
原子操作是指操作不可分割的操作,要么完全执行,要么完全不执行。在多线程环境中,原子操作是确保线程安全的基础。CAS 是一种常用的无锁原子操作。
5.1 CAS 的基本原理
CAS 是一种比较和交换的机制,基本原理如下:
读取内存中的值。
将内存中的值与预期值进行比较。
如果值相等,则将新值写入内存;如果不相等,则返回失败。
public boolean compareAndSet(expectedValue, newValue) {
if (currentValue == expectedValue) {
currentValue = newValue;
return true;
}
return false;
}
CAS 机制允许我们在没有锁的情况下执行原子操作,从而提高程序的性能。
5.2 CAS 的缺点
ABA 问题:CAS 操作会比较当前值与预期值是否相等,但如果值从
A变为B,再变回A,CAS 会误以为值没有变化。这是所谓的 ABA 问题。解决方法:使用版本号(如
AtomicStampedReference)来避免 ABA 问题。
自旋开销:如果多个线程频繁争夺同一个资源,CAS 操作会导致反复自旋,从而产生性能开销。
5.3 Java 原子类
Java 提供了 java.util.concurrent.atomic 包下的原子类,如 AtomicInteger、AtomicLong、AtomicReference,这些类通过底层的 CAS 操作实现无锁的线程安全。
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet(); // 原子性地递增
counter.compareAndSet(5, 10); // 如果当前值是 5,则将其更新为 10
这些原子类提供了原子操作,避免了使用显式锁的开销,适用于高并发场景。
6 总结
线程安全:通过同步机制(如
synchronized、ReentrantLock等)保证多线程环境下的安全。synchronized:用于保证对共享资源的访问是安全的,但它可能导致性能开销和死锁问题。
ReentrantLock:比
synchronized更灵活,支持中断、尝试锁、可重入等特性,但使用复杂。死锁:死锁会导致程序无法继续执行,需要通过锁顺序、超时等方法避免。
CAS:无锁的原子操作,通过比较和交换的方式实现线程安全,避免了显式锁的开销。