常见的多线程面试题

2023年 8月 23日 70.6k 0

一. 重排序有哪些分类?如何避免?

重排序类型:

  • 编译器重排序。
    对于没有先后依赖关系的语句,编译器可以重新调整语句的执行顺序。
  • CPU指令重排序。
    在指令级别,让没有依赖关系的多条指令并行。
  • CPU内存重排序。
    CPU有自己的缓存,指令的执行顺序和写入主内存的顺序不完全一致
  • 为了禁止编译器重排序和 CPU 重排序,在编译器和 CPU 层面都有对应的指令,也就是内存屏障(Memory Barrier)。这也正是JMM和happen-before规则的底层实现原理。

    编译器的内存屏障,只是为了告诉编译器不要对指令进行重排序。当编译完成之后,这种内存屏障就消失了,CPU并不会感知到编译器中内存屏障的存在。

    而CPU的内存屏障是CPU提供的指令,可以由开发者显示调用。

    happen-before规则总结

  • 单线程中的每个操作,happen-before于该线程中任意后续操作。
  • 对volatile变量的写,happen-before于后续对这个变量的读。
  • 对synchronized的解锁,happen-before于后续对这个锁的加锁。
  • 对final变量的写,happen-before于final域对象的读,happen-before于后续对final变量的
    读。
    四个基本规则再加上happen-before的传递性,就构成JMM对开发者的整个承诺。在这个承诺以外
    的部分,程序都可能被重排序,都需要开发者小心地处理内存可见性问题。
  • JMM对编译器和CPU 来说,volatile 变量不能重排序;非 volatile 变量可以任意重排序。
    volatile的三重功效:64位写入的原子性、内存可见性和禁止重排序。

    1692681907323.png

    二、如何停止一个正在运行的线程

    在 Java 中,停止线程是一个需要谨慎处理的操作,因为突然中断线程可能会导致资源泄露、数据不一致等问题。从 Java 11 开始,一些线程停止方法已被标记为不推荐使用,因为它们可能引发不可预测的结果。以下是一些停止线程的方法:

  • 使用标志位: 这是一种较为安全和常用的停止线程的方法。在线程的 run 方法中使用一个标志位(例如,布尔型变量)来指示线程是否应该继续运行。当你希望停止线程时,将标志位设置为 false,线程会在下一个循环迭代中退出。

    public class MyRunnable implements Runnable {
        private volatile boolean running = true;
    
        public void run() {
            while (running) {
                // 线程的工作逻辑
            }
        }
    
        public void stop() {
            running = false;
        }
    }
    
  • 使用 Thread.interrupt() 方法: interrupt() 方法是一种中断线程的方式,它会在线程中设置中断标志位。线程可以周期性地检查中断标志位并作出相应的处理。

    public class MyRunnable implements Runnable {
        public void run() {
            while (!Thread.currentThread().isInterrupted()) {
                // 线程的工作逻辑
            }
        }
    }
    
    // 在其他线程中调用以下代码来中断线程
    threadToStop.interrupt();
    

    需要注意的是,interrupt() 方法并不会直接停止线程,而是在线程中设置了中断标志位,线程在适当的时候可以根据标志位自行退出。

  • 使用 Thread.stop()(不推荐): stop() 方法可以立即终止一个线程,但这个方法已被标记为不推荐使用,因为它可能会引发线程之间的数据不一致和资源泄露等问题。

    // 不推荐使用
    threadToStop.stop();
    
  • 使用 ExecutorService 和 Future: 如果你使用线程池(ExecutorService),可以通过调用 shutdown()shutdownNow() 方法来停止线程池中的所有线程。还可以通过 Future 对象的 cancel() 方法来中断正在执行的任务。

    ExecutorService executorService = Executors.newFixedThreadPool(5);
    Future future = executorService.submit(() -> {
        // 线程任务逻辑
    });
    
    // 取消任务
    future.cancel(true);
    
    // 停止线程池
    executorService.shutdown();
    
  • 三、notify()和notifyAll()有什么区别?

    notify()notifyAll() 是 Java 中用于多线程通信的方法,它们都是在对象级别上进行调用的,用于唤醒等待中的线程。主要的区别在于唤醒的目标线程不同。

  • notify(): notify() 方法用于唤醒等待在该对象上的一个随机线程。如果有多个线程在调用对象的 wait() 方法等待该对象的通知,那么只有一个线程会被唤醒。哪个线程会被唤醒是不确定的,取决于 JVM 的调度策略。

    synchronized (lock) {
        lock.notify(); // 唤醒一个等待线程
    }
    
  • notifyAll(): notifyAll() 方法用于唤醒等待在该对象上的所有线程。当有多个线程等待时,调用 notifyAll() 方法会唤醒所有等待线程,让它们有机会争夺对象的锁。

    synchronized (lock) {
        lock.notifyAll(); // 唤醒所有等待线程
    }
    
  • 通常情况下,推荐使用 notifyAll() 来确保所有等待线程都有机会获取到对象的锁并进行相应的操作。这可以避免因为使用 notify() 而导致某些线程一直没有机会被唤醒,从而造成死锁或饥饿等问题。

    需要注意的是,wait()notify()notifyAll() 都必须在同步代码块中(使用对象的监视器锁)调用,否则会抛出 IllegalMonitorStateException 异常。此外,等待线程和通知线程之间的竞争条件需要进行适当的同步来确保线程安全。

    3.1 使用 notify() 为什么会造成死锁或饥饿等问题

    使用 notify() 方法可能会导致死锁或饥饿问题,主要是因为在多线程环境下,对于等待线程的唤醒和竞争锁的获取存在一些潜在的风险,如果不谨慎处理,就会导致这些问题。

  • 死锁: 当使用 notify() 唤醒等待线程时,如果被唤醒的线程试图获取某个其他资源的锁,而该资源又正好被其他线程占用,就可能发生死锁。例如,线程 A 等待对象 A 的锁,线程 B 等待对象 B 的锁,线程 A 被唤醒并获取了对象 A 的锁,然后线程 B 被唤醒并获取了对象 B 的锁,这时候两个线程互相等待对方释放资源,造成死锁。
  • 饥饿: 如果使用 notify() 来唤醒等待线程,但唤醒的是相同类型的线程,那么可能会导致某些线程始终无法获得执行的机会,从而导致饥饿问题。例如,一个线程 A 被唤醒后,获取了锁并完成了任务,然后线程 B 被唤醒,又获取了锁并完成了任务,依此类推。如果线程 A、B、C 等的数量很大,而每次只能唤醒一个线程,那么某些线程可能会一直无法被唤醒,造成饥饿。
  • 为了避免这些问题,通常建议使用 notifyAll() 方法来唤醒所有等待线程。这样做的好处是,每个等待线程都有机会争夺锁,从而避免了某些线程一直无法被唤醒的情况。但要注意,过度使用 notifyAll() 也可能会引起性能问题,因为会增加竞争和上下文切换的开销。

    要安全地使用 notify()notifyAll(),必须在合适的同步代码块中使用,并确保等待线程和唤醒线程之间的同步关系正确。合理的设计和同步机制可以有效地避免死锁和饥饿问题。

    3.2 举个简单易懂的例子说明死锁

    以下是一个简单的例子,说明死锁的情况:

    假设有两个线程,Thread A 和 Thread B,它们同时访问两个共享资源,Resource 1 和 Resource 2。每个线程都需要同时获取这两个资源才能继续执行。如果 Thread A 先获取了 Resource 1,而 Thread B 同时获取了 Resource 2,那么它们会陷入相互等待的状态,无法继续执行下去,从而导致死锁。

    public class DeadlockExample {
        private static Object resource1 = new Object();
        private static Object resource2 = new Object();
    
        public static void main(String[] args) {
            Thread threadA = new Thread(() -> {
                synchronized (resource1) {
                    System.out.println("Thread A: Holding resource 1...");
    
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                    }
    
                    System.out.println("Thread A: Waiting for resource 2...");
                    synchronized (resource2) {
                        System.out.println("Thread A: Holding resource 1 and resource 2...");
                    }
                }
            });
    
            Thread threadB = new Thread(() -> {
                synchronized (resource2) {
                    System.out.println("Thread B: Holding resource 2...");
    
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                    }
    
                    System.out.println("Thread B: Waiting for resource 1...");
                    synchronized (resource1) {
                        System.out.println("Thread B: Holding resource 1 and resource 2...");
                    }
                }
            });
    
            threadA.start();
            threadB.start();
        }
    }
    

    在这个例子中,Thread A 和 Thread B 分别尝试获取 Resource 1 和 Resource 2 的锁。由于它们获取锁的顺序不同,当 Thread A 获取了 Resource 1 的锁,同时 Thread B 获取了 Resource 2 的锁,它们会相互等待对方释放资源,从而形成了死锁。

    为了避免死锁,通常可以通过按照相同的顺序获取锁,使用超时机制来放弃获取锁,或者使用更复杂的同步策略来解决资源竞争问题。

    3.3 为什么还要有notify()

    notify() 是对notifyAll()的一个优化,但它有很精确的应用场景,并且要求正确使用。不然可能导致死锁。正确的场景应该是 WaitSet中等待的是相同的条件,唤醒任一个都能正确处理接下来的事项,如果唤醒的线程无法正确处理,务必确保继续notify()下一个线程,并且自身需要重新回到WaitSet中.

    四、sleep()和wait() 有什么区别?

    sleep()wait() 都是 Java 中用于控制线程的方法,但它们的用途和行为有很大的区别。

  • sleep() 方法:

    • sleep()Thread 类的一个静态方法,用于让当前线程暂停一段时间(以毫秒为单位)。
    • 在调用 sleep() 期间,线程仍然持有之前获得的锁,不会释放锁。其他线程无法获得这个锁,即使它们正在等待。
    • sleep() 方法可能会抛出 InterruptedException,表示在线程休眠期间被中断。
    javaCopy code
    try {
        Thread.sleep(1000); // 休眠 1 秒
    } catch (InterruptedException e) {
        // 处理中断异常
    }
    
  • wait() 方法:

    • wait()Object 类的方法,用于在对象上等待,并且会释放对象的锁。它必须在同步块中调用,即在使用对象的监视器锁(synchronized)的范围内。
    • 调用 wait() 会使线程进入等待状态,直到其他线程在相同的对象上调用了 notify()notifyAll() 方法来唤醒等待的线程。
    • wait() 方法可以接受一个超时参数,如果等待时间超过指定的超时时间,线程会自动唤醒。
    javaCopy code
    synchronized (lock) {
        try {
            lock.wait(); // 在 lock 对象上等待
        } catch (InterruptedException e) {
            // 处理中断异常
        }
    }
    
  • 生活场景举例:

    想象你正在一家繁忙的餐厅就餐,你点了食物,但由于餐厅很忙,需要一些时间才能准备好你的食物。在这个场景中,你是等待服务的线程,餐厅员工是服务线程,而你的食物是共享的资源。
    使用 sleep()
    在这个场景中,你可能决定等待一段时间,给餐厅足够的时间准备食物,然后再来取食物。
    同步等与异步等通知
    使用 wait()notify()
    另一方面,如果餐厅员工已经准备好了你的食物,他们可以通过声音或其他方式通知你。在这个场景中,餐厅员工就像是其他线程通过 notify() 唤醒等待线程,而你则是等待线程,等待食物准备好。
    总结:同步等(不释放锁),异步等通知(释放锁)。

    五、volatile 是什么?可以保证有序性吗?

    volatile 是 Java 中的一个关键字,它主要用于修饰变量,用于确保多线程环境下对变量的访问具有一定的可见性和有序性。虽然 volatile 可以保证可见性,但不能完全保证有序性。

    volatile 的特性:

  • 可见性(Visibility): 当一个线程修改了一个被 volatile 修饰的变量的值,其他线程可以立即看到这个修改。这是因为 volatile 变量的修改会立即刷新到主内存,并且其他线程在读取该变量时会从主内存中获取最新值。
  • 有序性(Ordering): volatile 可以确保变量的读写操作是按照顺序进行的,即禁止指令重排序。然而,它并不能解决所有的有序性问题,因为它仅限于单个变量的操作。在涉及多个变量之间的复合操作时,volatile 无法提供足够的保证。
  • 尽管 volatile 可以保证变量的读写操作按照顺序进行,但volatile 不是原子性操作,它并不能解决所有的并发问题,特别是复合操作和复杂的同步需求。例如,volatile 无法保证一组相关变量之间的一致性,也不能代替锁来实现复杂的线程同步。

    如果需要更强大的原子性、有序性和可见性保证,你可能需要使用其他同步工具,如 synchronized 块、Lock 接口、Atomic 类等,以根据具体情况来解决并发问题。

    请举例说明为什么 volatile 无法保证一组相关变量之间的一致性

    当涉及一组相关变量的复合操作时,volatile 无法保证这些变量之间的一致性。这是因为 volatile 能确保单个变量的读写操作的可见性和顺序性,但不能保证复合操作的原子性和正确性。

    以下是一个例子,说明 volatile 无法保证一组相关变量之间的一致性:

    javaCopy code
    public class VolatileConsistencyExample {
        private volatile int x = 0;
        private volatile int y = 0;
    
        public void write() {
            x = 1;
            y = 2;
        }
    
        public void read() {
            if (y == 2 && x == 0) {
                System.out.println("Inconsistent state detected!");
            }
        }
    
        public static void main(String[] args) {
            VolatileConsistencyExample example = new VolatileConsistencyExample();
    
            Thread writerThread = new Thread(() -> {
                example.write();
            });
    
            Thread readerThread = new Thread(() -> {
                example.read();
            });
    
            writerThread.start();
            readerThread.start();
        }
    }
    

    在这个例子中,有两个变量 xy,分别被 volatile 修饰。write() 方法修改了这两个变量的值,而 read() 方法检查了这两个变量的值。根据预期,如果 x 的值是 1,y 的值是 2,那么就是一个一致的状态。

    然而,由于 volatile 无法保证复合操作的原子性,read() 方法可能会检查到 y 的值为 2,但同时 x 的值仍然是 0,尽管在逻辑上应该是一个不一致的状态。这是因为 volatile 仅能保证单个变量的读写操作是按顺序进行的,但无法确保多个变量之间的操作是原子性的。

    要解决这类问题,通常需要使用更强大的同步机制,如锁或者 Atomic 类,来确保复合操作的原子性和正确性。

    六、Thread 类中的start() 和 run() 方法有什么区别?

    • 使用 start() 方法启动线程会创建一个新的系统线程,并在后台执行 run() 方法中的代码,实现多线程的并行处理。
    • 直接调用 run() 方法只是普通的方法调用,不会创建新的线程,只是在当前线程中执行,不会实现多线程并行处理。

    七、为什么wait, notify 和 notifyAll这些方法不在thread类里面?

    wait()notify()notifyAll() 这些方法没有直接放在 Thread 类中,而是放在 Object 类中,是因为它们与线程的等待和通知机制紧密相关,更适合与对象的锁机制一起使用。这种设计使得任何对象都可以作为同步监视器,从而在多线程编程中更加灵活和通用。

    主要的原因包括:

  • 等待和通知是对象级别的操作: 等待和通知操作是针对对象的,而不是线程。每个对象都有自己的等待队列和通知机制,所以将这些操作放在 Object 类中更为合适。
  • 多个线程可以等待同一个对象: 多个线程可以等待同一个对象的状态变化,例如一个共享资源的状态改变。如果这些方法放在 Thread 类中,就会对具体线程造成限制,而将它们放在 Object 类中可以方便地用于多线程环境。
  • 相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论