【JUC基础】17. 并发编程常见问题

2024-01-25 11:08:56 浏览数 (2)

1、前言

多线程固然可以提升系统的吞吐量,也可以最大化利用系统资源,提升相应速度。但同时也提高了编程的复杂性,也提升了程序调试的门槛。今天就来汇总一些常见的并发编程中的问题。

2、上下文切换问题

2.1、什么是上下文切换

上下文切换是指在多任务环境下,从一个任务(线程或进程)切换到另一个任务时,保存当前任务的状态(上下文)并加载下一个任务的状态的过程。在操作系统中,上下文切换是实现多任务调度的重要机制之一。当系统中存在多个任务需要并发执行时,操作系统通过快速地切换任务的上下文来实现任务的交替执行,以使每个任务都能得到充分的执行时间。

2.2、上下文切换过程

当一个任务被切换出去时,操作系统会保存当前任务的上下文信息,包括寄存器的值、堆栈指针和程序计数器等。然后,操作系统会加载下一个任务的上下文信息,并将控制权转移到该任务中,使其继续执行。这个过程涉及到保存和恢复大量的寄存器状态以及修改内核数据结构,因此,上下文切换是一个相对耗时的操作。

2.3、上下文切换的原因

上下文切换的主要原因包括:

  1. 时间片轮转:操作系统采用时间片轮转调度算法,每个任务被分配一段时间片进行执行,当时间片用完后,任务被切换出去,切换到下一个任务。
  2. 中断处理:当硬件设备发生中断请求时,当前任务会被中断,操作系统需要立即处理中断请求,因此会发生上下文切换。
  3. 等待事件:当任务需要等待某些事件的发生,如等待用户输入、等待IO操作完成等,任务会被阻塞,操作系统会切换到另一个可执行的任务。

2.4、上下文切换的开销和影响

上下文切换虽然是操作系统实现并发的重要机制,但是它也带来了一些开销和影响:

  1. 时间开销:上下文切换需要保存和恢复大量的上下文信息,涉及到寄存器状态的保存和恢复,以及内核数据结构的修改,因此会消耗一定的处理器时间。
  2. 系统资源消耗:上下文切换涉及到内核数据结构的修改和维护,会占用一定的系统资源,包括内存、处理器等。
  3. 性能下降:频繁的上下文切换会导致系统性能下降,特别是在任务数量较多、切换频率较高的情况下。

正因为上下文切换也会有资源的开销,因此多线程开发中并不是线程数量开得越多越好。

2.5、注意事项和改进策略

当涉及到上下文切换时,以下是一些需要注意的事项和改进策略,并通过Java代码示例进行说明:

  • 减少线程数量:

上下文切换的主要开销来自于保存和恢复线程的上下文信息,因此减少线程数量可以减少上下文切换的次数。

代码语言:javascript复制
ExecutorService executor = Executors.newFixedThreadPool(4); // 使用固定大小的线程池
  • 避免过度线程同步:

过度的线程同步可能导致线程频繁地进入和退出临界区,增加了上下文切换的频率。避免不必要的锁和同步机制。

代码语言:javascript复制
AtomicInteger counter = new AtomicInteger(0); // 使用原子操作类避免锁竞争
  • 使用非阻塞算法:

非阻塞算法可以减少对共享资源的竞争,避免线程因为等待资源而阻塞,从而减少上下文切换的次数。

代码语言:javascript复制
ConcurrentLinkedQueue<Integer> queue = new ConcurrentLinkedQueue<>(); // 使用非阻塞队列
  • 优化任务调度:

合理的任务调度策略可以减少上下文切换的次数。例如,将相互依赖的任务放在同一个线程中执行,减少线程间的切换。

代码语言:javascript复制
ForkJoinPool pool = new ForkJoinPool(); // 使用ForkJoinPool进行任务调度
  • 异步编程模型:

使用异步编程模型可以减少线程的阻塞和等待,从而减少上下文切换的发生。

代码语言:javascript复制
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> computeResult()); // 使用CompletableFuture实现异步编程

通过合理的线程池配置、避免过度同步、使用非阻塞算法、优化任务调度和采用异步编程模型,可以降低上下文切换的频率和开销,提高并发程序的性能和效率。但需要注意,在实际开发中,需要根据具体情况选择适当的策略,并进行性能测试和调优以获得最佳的结果。

3、死锁问题

3.1、什么是死锁

死锁是并发编程中常见的问题,指两个或多个线程彼此持有对方所需的资源,并且由于无法继续执行而相互等待的状态。这导致这些线程无法继续执行下去,从而陷入无限等待的状态,进而影响程序的性能和可靠性。

典型的死锁场景通常涉及以下条件的交叉发生:

  1. 互斥条件:至少有一个资源被视为临界资源,一次只能被一个线程占用。
  2. 请求与保持条件:线程在持有至少一个资源的同时,又请求其他资源。
  3. 不可剥夺条件:已分配的资源不能被其他线程强行夺走。
  4. 循环等待条件:存在一组线程,每个线程都在等待下一个线程所持有的资源。

3.2、死锁示例

代码语言:javascript复制
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 acquired lock1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    System.out.println("Thread 1 acquired lock2");
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (lock2) {
                System.out.println("Thread 2 acquired lock2");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock1) {
                    System.out.println("Thread 2 acquired lock1");
                }
            }
        });

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

在上述代码中,两个线程分别尝试获取lock1和lock2的锁,并且获取锁的顺序相反。如果运行该代码,将会导致死锁,因为线程1持有lock1并等待获取lock2,而线程2持有lock2并等待获取lock1,双方相互等待,无法继续执行。

3.3、改进策略

  1. 避免循环等待: 确保线程在获取资源时按照相同的顺序获取,或者使用资源分级,避免循环等待的发生。
  2. 加锁顺序一致性: 线程在获取多个锁时,始终按照相同的顺序获取,避免不同线程之间的锁顺序冲突。
  3. 使用并发库提供的工具:JUC(Java Util Concurrent)包中提供了一些工具类来帮助我们避免死锁的发生,如使用Lock接口及其实现类ReentrantLock代替synchronized关键字进行显式锁定,或者使用java.util.concurrent包中的并发容器来避免手动管理锁。

代码改进:

代码语言:javascript复制
public class DeadlockExample {
    private static final Lock lock1 = new ReentrantLock();
    private static final Lock lock2 = new ReentrantLock();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            lock1.lock();
            try {
                System.out.println("Thread 1 acquired lock1");
                Thread.sleep(100);
                lock2.lock();
                try {
                    System.out.println("Thread 1 acquired lock2");
                } finally {
                    lock2.unlock();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock1.unlock();
            }
        });

        Thread thread2 = new Thread(() -> {
            lock2.lock();
            try {
                System.out.println("Thread 2 acquired lock2");
                Thread.sleep(100);
                lock1.lock();
                try {
                    System.out.println("Thread 2 acquired lock1");
                } finally {
                    lock1.unlock();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock2.unlock();
            }
        });

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

在改进的代码中,我们使用了ReentrantLock来代替synchronized关键字进行显式锁定。这样,我们可以通过调用lock()和unlock()方法来手动管理锁的获取和释放,从而避免死锁的发生。

此外,还有其他一些预防死锁的策略,如:

  1. 资源分配策略: 确保每个线程在请求资源时,能够立即得到所需的资源,而不是无限等待。
  2. 超时策略: 设置一个超时时间,在获取锁或资源的过程中如果超过了该时间仍然无法获取,就放弃并尝试其他方式。
  3. 死锁检测: 可以使用工具来检测死锁的发生,如使用jstack命令查看线程的堆栈信息。

4、竞态条件

竞态条件是指多个线程对共享资源进行操作时,执行的结果依赖于线程执行顺序或时间差的现象。这可能导致不确定的结果或数据一致性问题。

代码语言:javascript复制
public class RaceConditionExample {
    private int count;

    public void increment() {
        count  ;
    }
}

解决方式:使用Synchronized或ReenterLock。

代码语言:javascript复制
public class RaceConditionExample {
    private int count;

    public synchronized void increment() {
        count  ;
    }
}

5、内存可见性

多个线程访问共享变量时,可能会出现内存可见性问题,即一个线程对变量的修改对其他线程不可见。

代码语言:javascript复制
public class VisibilityExample {
    // 解决方法: 使用`volatile`关键字修饰共享变量,保证其对所有线程的可见性。
    // 或者使用`synchronized`关键字或`Lock`接口来确保线程间的同步和数据可见性。
    private  boolean flag = false;

    public void updateFlag() {
        flag = true;
    }

    public void printFlag() {
        while (!flag) {
            // 等待flag变为true
        }
        System.out.println("Flag is true");
    }
}

6、小结

总之,在并发编程中,需要小心处理常见的问题,包括上下文切换的影响、竞态条件、死锁、内存可见性、阻塞和等待以及资源泄漏等。通过合理的同步机制、线程间通信和资源管理,可以提高程序的性能、稳定性和可维护性。同时,通过合理的代码设计和遵循最佳实践。

0 人点赞