同步与并发控制

228 阅读21分钟

同步与并发控制

在现代应用程序中,尤其是在需要高性能和响应性的场景下,多线程并发编程已经成为必不可少的一部分。然而,多个线程在并发执行时,可能会涉及到对共享资源的访问,这就引出了同步并发控制的问题。无论是在进行大数据处理,还是在高并发的 Web 服务中,如何确保数据一致性、避免竞争条件、避免死锁并且提高程序的性能,都是我们必须面对的挑战。

同步与并发控制不仅仅是锁和等待那么简单,它涉及到对线程间交互、任务调度和资源管理的精确控制。简单的同步措施可能会导致性能瓶颈,而错误的并发控制则可能会导致程序不稳定,甚至出现严重的线程安全问题。

同步概念

在并发编程中,“同步”是指多个线程在访问共享资源时,为避免出现数据冲突和不一致的情况,需要对资源的访问进行控制。同步的核心目标是保证在同一时刻,只有一个线程能够访问共享资源,从而确保程序的正确性和数据的一致性。

1. 共享资源与竞态条件

当多个线程访问同一个共享资源时,如果不加控制,可能会出现“竞态条件”(Race Condition)的问题。竞态条件是指多个线程在没有适当同步的情况下,执行顺序不确定,导致程序的行为不可预测。例如,两个线程同时更新一个变量的值,由于线程调度的不确定性,可能会导致变量的最终值不符合预期。

2. 为什么需要同步

同步的核心目的是确保在并发执行的环境下,不同线程对共享资源的访问能够做到互斥,从而避免:

  • 数据竞争:多个线程同时读写共享数据,导致数据错误。
  • 状态不一致:多个线程并发修改共享对象的状态,导致程序状态不可预测。
  • 死锁:多个线程相互等待对方释放锁,导致程序无法继续执行。

3. 同步的基本原理

在 Java 中,同步的基本原理是通过“锁”(Lock)来控制对共享资源的访问。通过同步,多个线程在同一时刻只能有一个线程获得对共享资源的访问权限,其它线程必须等待当前线程释放锁后才能继续执行。

4. 同步的两种常见方式

  • 内置锁(Synchronized)
    • Java 提供了 synchronized 关键字来保证同步。它可以用于方法或代码块,使得同一时刻只有一个线程能够执行该方法或代码块。
    • synchronized 通过隐式的锁机制(对象锁)来确保同步,方法执行完毕后,锁会自动释放。
  • 显式锁(Lock)
    • Java 5 引入了 java.util.concurrent.locks 包,提供了更灵活的锁机制,最常用的是 ReentrantLock。显式锁相比内置锁,提供了更多的控制选项,比如定时锁、尝试锁、可中断锁等。

5. 同步的关键问题

  • 可见性问题:多线程程序中,线程对共享变量的修改可能对其它线程不可见,这就需要通过同步来确保对共享变量的修改能够及时地同步到其他线程。
  • 原子性问题:一个操作可能涉及多个步骤,而多线程并发时,这些步骤可能被其他线程打断,导致操作的结果不符合预期。同步保证了操作的原子性,即在同步代码块内的操作不会被打断。
  • 有序性问题:多线程程序中,线程执行的顺序可能是不确定的,导致结果不可预知。同步机制通过控制线程的执行顺序,确保了代码执行的有序性。

6. 同步的开销

虽然同步能够确保数据一致性和程序正确性,但同步也会带来一定的性能开销:

  • 线程竞争:当多个线程争夺同一把锁时,会引起上下文切换,增加 CPU 的负担。
  • 锁的持有时间:长时间持有锁会导致线程阻塞,降低程序的并发度和吞吐量。
  • 死锁:如果多个线程相互等待对方释放锁,可能会造成死锁,导致系统无法继续运行。

同步的基本机制

在多线程并发编程中,同步机制的核心目的是确保多线程环境下共享资源的访问是安全的,防止多个线程同时修改共享数据而导致的不一致性和错误。在 Java 中,提供了多种同步机制来确保线程之间的互斥访问。常见的同步机制包括 synchronized 关键字、显式锁(如 ReentrantLock)和其他同步工具。

1. synchronized 关键字

synchronized 是 Java 中最常见的同步机制,它通过加锁来保证同一时刻只有一个线程能够访问某个方法或代码块。synchronized 有两种常见的使用方式:

  • 方法同步
public synchronized void method() {
    // 临界区代码
}
    • 使用 synchronized 修饰方法时,该方法在同一时刻只能被一个线程执行。若其他线程尝试访问该方法,将被阻塞,直到当前线程执行完毕并释放锁。
    • 如果 synchronized 用在实例方法上,它会对当前对象实例加锁。若是静态方法,则对类本身加锁。
  • 代码块同步
public void method() {
    synchronized (this) {
        // 临界区代码
    }
}
    • 通过 synchronized 关键字修饰代码块,只对代码块中的临界区进行加锁。这样做比修饰整个方法更具灵活性,可以更细粒度地控制同步的范围,减少锁的持有时间。
    • 在代码块中传递的锁对象通常是某个共享资源、类本身(用于类锁)或其他对象引用。使用合适的锁对象可以减少锁冲突,提高并发性。

2. 内置锁(隐式锁)

Java 中的 synchronized 实际上是通过内置锁(也称为隐式锁)来实现的。每个对象实例在内部都会有一个与之关联的锁,当线程访问同步方法或代码块时,它会自动请求获取对象的锁。

  • 对象锁:当 synchronized 修饰实例方法时,线程会获取当前对象的锁。
  • 类锁:当 synchronized 修饰静态方法时,线程会获取当前类的锁。

当某个线程已经获得了某个对象的锁,其他线程在尝试访问该对象的同步代码时会被阻塞,直到锁被释放。

3. 显式锁(ReentrantLock)

Java 5 引入的 java.util.concurrent.locks 包提供了显式锁,ReentrantLock 是其中最常用的锁类型。与 synchronized 不同,显式锁提供了更多的控制选项和灵活性。

  • 重入锁ReentrantLock 是可重入的,即同一线程可以多次获取同一把锁而不会被阻塞。与 synchronized 不同的是,显式锁允许更细粒度的控制,例如尝试获取锁(tryLock())、定时锁(lockInterruptibly())等。
  • 显式锁的基本使用
ReentrantLock lock = new ReentrantLock();

lock.lock(); // 获取锁
try {
    // 临界区代码
} finally {
    lock.unlock(); // 释放锁
}
    • lock.lock() 获取锁,lock.unlock() 释放锁。必须确保在 finally 块中调用 unlock(),以防程序在执行过程中抛出异常,从而导致锁未被释放。
    • ReentrantLock 还提供了 tryLock()lockInterruptibly() 等方法,允许线程在等待锁时能够响应中断或尝试获取锁而不阻塞。

4. 死锁的避免

当多个线程互相等待对方持有的锁时,就会导致死锁。Java 中的同步机制需要特别注意避免死锁。常见的死锁避免策略包括:

  • 锁的顺序:确保多个线程获取锁时按照相同的顺序进行,避免因相互等待而形成死锁。例如:
synchronized (lock1) {
    synchronized (lock2) {
        // 处理
    }
}
  • 尝试锁(TryLock) :使用 ReentrantLocktryLock() 方法,允许线程在无法立即获取锁时继续尝试而不是无限等待。

5. volatile 关键字

volatile 关键字保证了一个变量在多线程环境下的可见性。当一个线程修改了 volatile 变量的值,其他线程可以立刻看到该值的变化。volatile 并不能保证变量的原子性,但它确保了对共享变量的修改对其他线程是立即可见的。

private volatile boolean flag = false;

6. wait() notify() notifyAll()

这些方法属于 Java 的 条件同步,它们通常与 synchronized 关键字一起使用,用于线程间的通信。它们通常用于实现线程间的协作:

  • wait():让当前线程进入等待状态,直到被其他线程通知。
  • notify():唤醒一个等待的线程。
  • notifyAll():唤醒所有等待的线程。

例如,生产者消费者问题中,生产者线程生产数据,消费者线程消费数据,当没有数据时,消费者线程可以调用 wait() 进入等待,生产者线程在生产数据后调用 notify() 唤醒等待的消费者线程。

并发控制的基本工具

在 Java 的并发编程中,除了使用传统的同步机制(如 synchronized 和锁)来确保线程安全,还可以利用一些并发控制工具来管理线程之间的协作与资源共享。这些工具通常可以帮助开发者更高效、灵活地控制线程的执行,避免常见的并发问题如死锁、竞态条件和线程间的资源竞争。Java 提供了许多并发控制工具,主要集中在 java.util.concurrent 包下。以下是一些常用的并发控制工具:

1. CountDownLatch

CountDownLatch 是一个允许一个或多个线程等待直到其他线程完成一些操作后再继续执行的工具。它通过计数器来控制线程的执行顺序。

  • 使用场景:用于等待多个线程执行完毕后再进行后续处理。例如,在一个任务执行之前,等待所有初始化操作完成。
  • 工作原理CountDownLatch 初始化时设置一个计数器(例如,N),每次调用 countDown() 方法,计数器减 1。线程通过 await() 方法阻塞自己,直到计数器减到 0,所有线程才能继续执行。
  • 代码示例
import java.util.concurrent.CountDownLatch;

public class CountDownLatchDemo {
    public static void main(String[] args) throws InterruptedException {
        int threadCount = 3;
        CountDownLatch latch = new CountDownLatch(threadCount);
        
        for (int i = 0; i < threadCount; i++) {
            new Thread(new Worker(latch)).start();
        }
        
        latch.await();  // 主线程等待,直到计数器为 0
        System.out.println("All workers finished.");
    }

    static class Worker implements Runnable {
        private CountDownLatch latch;

        Worker(CountDownLatch latch) {
            this.latch = latch;
        }

        @Override
        public void run() {
            try {
                // 模拟工作
                Thread.sleep(1000);
                System.out.println(Thread.currentThread().getName() + " finished.");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                latch.countDown();  // 完成任务后,计数器减 1
            }
        }
    }
}

2. CyclicBarrier

CyclicBarrier 允许一组线程相互等待,直到所有线程都到达某个公共屏障点(即执行到 await())。与 CountDownLatch 不同的是,CyclicBarrier 在所有线程都通过屏障后,会重置计数器,从而可以重复使用。

  • 使用场景:用于处理需要一组线程协作执行的场景,比如多线程并行计算,需要等待所有线程完成某一步再进入下一步。
  • 工作原理:每个线程在调用 await() 后被阻塞,直到所有线程都到达屏障点。如果所有线程都到达了屏障点,计数器被重置,所有线程继续执行。
  • 代码示例
import java.util.concurrent.CyclicBarrier;

public class CyclicBarrierDemo {
    public static void main(String[] args) throws InterruptedException {
        int threadCount = 3;
        CyclicBarrier barrier = new CyclicBarrier(threadCount, new Runnable() {
            @Override
            public void run() {
                System.out.println("All threads reached the barrier.");
            }
        });

        for (int i = 0; i < threadCount; i++) {
            new Thread(new Worker(barrier)).start();
        }
    }

    static class Worker implements Runnable {
        private CyclicBarrier barrier;

        Worker(CyclicBarrier barrier) {
            this.barrier = barrier;
        }

        @Override
        public void run() {
            try {
                Thread.sleep(1000);  // 模拟工作
                System.out.println(Thread.currentThread().getName() + " is ready.");
                barrier.await();  // 等待其他线程
            } catch (Exception e) {
                Thread.currentThread().interrupt();
            }
        }
    }
}

3. Semaphore

Semaphore 是一个控制同时访问特定资源的线程数量的工具。它通过一个计数器来控制对资源的访问。Semaphore 常用于限流、并发资源池等场景。

  • 使用场景:用于限制某个资源的并发访问量,例如限制对数据库的连接数,限制并发执行的线程数。
  • 工作原理Semaphore 初始化时设置一个计数器(如 N),每次线程请求资源时调用 acquire() 方法,计数器减 1。当计数器为 0 时,线程会被阻塞。线程使用完资源后调用 release() 方法,计数器加 1。
  • 代码示例
import java.util.concurrent.Semaphore;

public class SemaphoreDemo {
    public static void main(String[] args) throws InterruptedException {
        Semaphore semaphore = new Semaphore(2);  // 限制同时最多 2 个线程执行

        for (int i = 0; i < 5; i++) {
            new Thread(new Worker(semaphore)).start();
        }
    }

    static class Worker implements Runnable {
        private Semaphore semaphore;

        Worker(Semaphore semaphore) {
            this.semaphore = semaphore;
        }

        @Override
        public void run() {
            try {
                semaphore.acquire();  // 获取许可
                System.out.println(Thread.currentThread().getName() + " is working.");
                Thread.sleep(1000);  // 模拟工作
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                semaphore.release();  // 释放许可
                System.out.println(Thread.currentThread().getName() + " has finished.");
            }
        }
    }
}

4. Exchanger

Exchanger 是一个用于线程间交换数据的同步点。它允许两个线程在特定的点交换对象。每个线程都在 exchange() 方法上阻塞,直到另一线程到达该点并交换数据。

  • 使用场景:用于线程之间的协作与数据交换,常用于两阶段的任务协作,如并行计算中的数据传输。
  • 工作原理:两个线程通过 exchange() 方法交换数据。线程 A 调用 exchange() 后阻塞,直到线程 B 也调用了 exchange(),然后两者交换数据并继续执行。
  • 代码示例
import java.util.concurrent.Exchanger;

public class ExchangerDemo {
    public static void main(String[] args) throws InterruptedException {
        Exchanger<String> exchanger = new Exchanger<>();

        new Thread(new Producer(exchanger)).start();
        new Thread(new Consumer(exchanger)).start();
    }

    static class Producer implements Runnable {
        private Exchanger<String> exchanger;

        Producer(Exchanger<String> exchanger) {
            this.exchanger = exchanger;
        }

        @Override
        public void run() {
            try {
                String data = "Hello from Producer";
                System.out.println("Producer is sending: " + data);
                exchanger.exchange(data);  // 交换数据
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }

    static class Consumer implements Runnable {
        private Exchanger<String> exchanger;

        Consumer(Exchanger<String> exchanger) {
            this.exchanger = exchanger;
        }

        @Override
        public void run() {
            try {
                String data = exchanger.exchange(null);  // 接收数据
                System.out.println("Consumer received: " + data);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }
}

死锁与解决策略

1. 死锁的概念

在并发编程中,死锁是指两个或多个线程在执行过程中,因为争夺资源而造成的一种相互等待的状态,导致线程永远无法继续执行。死锁是并发编程中的一种常见问题,可能会导致程序无法响应、资源浪费、系统性能下降等问题。死锁发生的条件通常被称为死锁四大必要条件,它们是:

  • 互斥条件:至少有一个资源是处于非共享模式,即每次只有一个线程能够使用该资源。
  • 占有且等待条件:线程已经持有至少一个资源,但又等待其他线程释放它所需要的资源。
  • 不剥夺条件:已经分配给线程的资源,在没有使用完成之前,不能被其他线程强制剥夺。
  • 循环等待条件:存在一个线程集合 {T1, T2, ..., Tn},其中每个线程 Ti 都在等待 Ti+1 的资源,且 Tn 又在等待 T1 的资源。

当上述四个条件同时满足时,就会发生死锁。

2. 死锁的表现

死锁的典型表现是程序中的某些线程永远无法执行下去,它们相互等待对方释放资源,导致整个程序或部分系统卡住。

  • 程序执行时间不确定,线程未能及时完成任务。
  • 系统资源被消耗殆尽,但没有线程能够正常进行任务。
  • 程序无法正常响应用户输入,或者变得非常缓慢。

3. 死锁的解决策略

解决死锁问题的核心思想是尽量避免死锁发生,或者在死锁发生时能够检测到并进行处理。以下是几种常见的死锁解决策略:

1. 避免死锁(避免策略)

避免死锁发生是最理想的解决策略。常见的避免策略有:

  • 资源分配策略:要求所有线程在获取多个资源时,按照一定的顺序来请求资源。如果所有线程都遵循相同的资源请求顺序,那么就不会发生循环等待。
    • :在获取资源时,按照资源编号的顺序来申请,先申请编号较小的资源,再申请编号较大的资源,这样避免了循环等待的情况。
  • 请求和分配算法:如银行家算法,在资源分配时对每个线程的请求进行安全性检查。只有当资源的分配不会导致系统进入不安全状态时,才允许分配。
    • 银行家算法是基于“安全序列”的思想来确保系统不会进入死锁状态。即在分配资源前,判断系统是否处于“安全状态”,如果处于不安全状态,则不分配资源。
2. 检测和恢复(检测策略)

如果程序已经发生死锁,可以通过死锁检测算法来检测死锁,并在发生死锁时进行恢复。常见的检测策略包括:

  • 资源分配图:通过构建一个资源分配图,来表示线程与资源之间的关系。如果图中存在环路,则表明发生了死锁。
    • 通过定期检查系统中的资源分配图,可以检测出死锁的发生。
  • 线程挂起与回滚:通过定期检查线程的状态,如果检测到死锁,则中断其中一个或多个线程,让它们回滚到之前的状态,从而解除死锁。
    • 如果线程中断导致了资源不完整的使用,可以让中断的线程重新尝试获取资源或恢复到合适的状态。
3. 避免使用锁的嵌套(锁顺序)

避免在一个线程中嵌套多个锁是防止死锁的一种有效方法。可以通过以下方式避免锁的嵌套:

  • 锁顺序:线程在获取多个锁时,必须按照相同的顺序请求锁。例如,如果线程 A 要请求锁1和锁2,那么线程 B 也应按照相同的顺序请求锁1和锁2。这样可以避免不同线程在不同的顺序下获取锁,导致死锁。
  • 减少锁的使用:尽量减少对多个锁的需求,避免多个线程竞争多个资源的情况。
4. 超时机制

为每个资源请求设置超时机制,使得线程在等待锁时,如果长时间没有获得锁,就放弃等待,避免死锁的发生。

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

public class DeadlockAvoidanceDemo {
    private Lock lock1 = new ReentrantLock();
    private Lock lock2 = new ReentrantLock();

    public void method1() throws InterruptedException {
        if (lock1.tryLock() && lock2.tryLock()) {
            try {
                // 执行操作
                System.out.println("Thread 1 acquired both locks.");
            } finally {
                lock1.unlock();
                lock2.unlock();
            }
        } else {
            System.out.println("Thread 1 could not acquire both locks, retrying.");
        }
    }

    public void method2() throws InterruptedException {
        if (lock1.tryLock() && lock2.tryLock()) {
            try {
                // 执行操作
                System.out.println("Thread 2 acquired both locks.");
            } finally {
                lock1.unlock();
                lock2.unlock();
            }
        } else {
            System.out.println("Thread 2 could not acquire both locks, retrying.");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        DeadlockAvoidanceDemo Demo = new DeadlockAvoidanceDemo();
        Demo.method1();
        Demo.method2();
    }
}
5. 使用死锁检测工具

现代的Java虚拟机(JVM)提供了死锁检测工具,可以帮助开发者检测并调试死锁。jstackVisualVM 和一些专门的性能监控工具可以帮助开发者查看程序中线程的状态,分析死锁发生的根本原因。

线程安全与共享资源

1. 线程安全的概念

在并发编程中,线程安全是指在多线程环境下,多个线程同时访问同一个资源时,不会引发数据不一致或不可预期的行为。换句话说,线程安全的代码在并发执行时能够保证结果的正确性和一致性。

线程安全通常包括以下几个方面:

  • 数据一致性:即使多个线程并发访问同一数据,数据也始终保持一致。
  • 避免竞争条件:确保多个线程在访问共享资源时不会发生冲突或错误的读写操作。
  • 可见性保证:确保一个线程对共享变量的修改能够及时被其他线程看到。

2. 共享资源的概念

在多线程编程中,多个线程可能需要访问相同的资源或数据,这时称之为共享资源。共享资源可以是变量、数据结构、文件、数据库连接等。在并发环境下,多个线程同时访问这些共享资源可能会导致不可预测的结果,特别是在没有适当的同步机制时。

共享资源的常见问题包括:

  • 数据竞争(Data Race) :当两个或多个线程并发地访问共享资源,并且至少有一个线程在写入时,可能会发生数据竞争,导致数据不一致或错误。
  • 原子性问题:当一个操作被多个线程共享且执行的过程中被中断时,会导致操作的中间状态出现错误。例如,在银行账户的存款和取款操作中,如果没有同步机制,多个线程同时修改账户余额,可能会导致不一致的余额。

3. 线程安全的实现方式

要保证线程安全,可以采取以下几种方式来实现:

1. 同步(Synchronization)

通过在方法或代码块中使用sychronized关键字来实现线程安全,确保每次只有一个线程能访问共享资源。常见的同步方式有:

  • 同步方法:将整个方法声明为 synchronized,这样每次只有一个线程能够进入该方法,防止多线程同时操作共享资源。
public synchronized void increment() {
    this.count++;
}
  • 同步代码块:在方法内部使用synchronized关键字包裹共享资源的访问部分,限制临界区的代码,减少性能损耗。
public void increment() {
    synchronized (this) {
        this.count++;
    }
}
2. 显式锁(Lock)

相比synchronized,显式锁(如ReentrantLock)提供了更多的控制功能,例如公平性、锁的中断、定时锁等。使用显式锁时,必须手动释放锁(即调用lock.unlock())。

  • ReentrantLock 示例
private final Lock lock = new ReentrantLock();

public void increment() {
    lock.lock();
    try {
        this.count++;
    } finally {
        lock.unlock();
    }
}
3. 原子操作(Atomic Operations)

Java提供了原子类(如AtomicIntegerAtomicLong等),它们通过CAS(Compare-And-Swap)操作保证对共享变量的操作是原子的,从而避免了线程间的冲突。

  • AtomicInteger 示例
private AtomicInteger count = new AtomicInteger(0);

public void increment() {
    count.incrementAndGet(); // 原子性递增
}
4. 不可变对象

通过设计不可变对象,保证对象的状态在创建后不能被改变,从而避免了多线程并发修改带来的问题。不可变对象的状态一旦初始化后便不能修改,因此是线程安全的。

  • 不可变对象示例
public final class Person {
    private final String name;
    private final int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
}

4. 共享资源的并发控制

对于多个线程访问共享资源的情况,除了线程安全性之外,还需要考虑并发控制。并发控制不仅仅是防止数据错误,还涉及到如何高效地管理并发资源,确保线程安全的同时,不影响程序的性能。

1. 读写锁(Read-Write Lock)

ReadWriteLock 提供了比互斥锁更高效的机制。当多个线程只需要读取共享资源时,可以允许多个线程同时访问;但如果有线程正在修改资源,其他线程将无法访问资源。通过分离读写操作,ReadWriteLock 提供了更高的并发性。

  • ReadWriteLock 示例
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();

public void read() {
    rwLock.readLock().lock();
    try {
        // 读取共享资源
    } finally {
        rwLock.readLock().unlock();
    }
}

public void write() {
    rwLock.writeLock().lock();
    try {
        // 写入共享资源
    } finally {
        rwLock.writeLock().unlock();
    }
}
2. 线程局部变量(ThreadLocal)

ThreadLocal 提供了一种避免共享资源冲突的机制。它为每个线程提供了一个独立的变量副本,每个线程在访问该变量时只能看到自己副本的值,从而避免了共享资源的竞争条件。

  • ThreadLocal 示例
private static ThreadLocal<Integer> threadLocalCount = ThreadLocal.withInitial(() -> 0);

public void increment() {
    threadLocalCount.set(threadLocalCount.get() + 1);
}

5. 线程安全的集合类

Java提供了线程安全的集合类,位于java.util.concurrent包中。这些集合类通过内建的同步机制或高效的并发控制,确保在多线程环境下对集合操作的线程安全性。

常见的线程安全集合类包括:

  • ConcurrentHashMap:一个高效的线程安全的哈希映射表,支持高并发的读写操作。
  • CopyOnWriteArrayList:适用于读多写少的场景,每次修改都会创建一个新的副本,保证了线程的安全性。
  • BlockingQueue:一种线程安全的队列接口,提供了线程安全的入队和出队操作,常用于生产者-消费者模型中。