面试题分享之Java并发篇

发布于:2024-05-05 ⋅ 阅读:(23) ⋅ 点赞:(0)

注意:文章若有错误的地方,欢迎评论区里面指正 🍭 

系列文章目录

前言

        今天给小伙伴们分享我整理的关于Java并发的一些常见面试题,这期涉及到线程的一些知识,所以要求小伙伴有一些操作系统的知识,不清楚也不要紧,也不是什么很难的知识点。🌈


一、什么是线程?什么是进程?

  • 线程:线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
  • 进程:进程是操作系统分配资源的基本单位,它是程序在计算机上的一次执行活动。当系统为一个程序分配资源后,该程序就成为一个独立的进程。

👨‍💻面试官追问线程跟进程的区别是什么?

  1. 线程是进程划分成的更小的运行单位。
  2. 独立性:进程是独立的,拥有独立的内存空间和系统资源;而线程是依赖进程的,多个线程共享其所属进程的内存空间和资源。
  3. 开销:进程的创建和销毁开销较大,因为需要为其分配和回收系统资源;而线程的创建和销毁开销较小。
  4. 切换速度:由于线程的上下文信息相对较少,因此线程间的切换速度通常比进程间的切换速度快。
  5. 通信与数据共享:进程间的通信和数据共享相对困难,需要通过特定的机制来实现;而线程间的通信和数据共享相对容易,因为它们共享其所属进程的内存空间和资源。
  6. 并发性:进程和线程都可以实现并发执行,但线程通常用于实现更细粒度的并发操作。在一个多核或多处理器的系统中,多个进程可以并行执行;而在一个进程中,多个线程也可以并行执行(如果处理器支持多线程)。

二、说一下线程的生命周期,它有几种状态

线程的生命周期包含五个阶段,即五种状态,分别是:

  1. 新建状态(New):新创建了一个线程对象,但还没有调用start()方法。在这个阶段,线程只是被分配了必要的资源,并初始化其状态。

  2. 就绪状态(Runnable):线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于“可运行线程池”中,变得可运行,只等待获取CPU的使用权。换句话说,线程已经做好了执行的就绪准备,表示可以运行了,但还不是正在运行的线程。

  3. 运行状态(Running):当就绪的线程被调度并获得CPU资源时,便进入运行状态,开始执行run()方法的线程执行体。在这个阶段,线程正在执行其任务。

  4. 阻塞状态(Blocked):在运行状态的时候,可能因为某些原因导致运行状态的线程变成了阻塞状态。阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。阻塞的情况可能包括:

    • 等待阻塞:运行的线程执行wait()方法,该线程会释放占用的所有资源,JVM会把该线程放入“等待池”中。
    • 其他阻塞:运行的线程执行sleep()或join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。
    • 阻塞于锁:线程试图获取某个锁,但该锁当前被其他线程持有。

    直到线程进入就绪状态,才有机会转到运行状态。

  5. 死亡状态(Dead):当线程退出run()方法时,线程就会自然死亡,处于终止或死亡状态,也就结束了生命周期。

这五个状态构成了线程从创建到消亡的完整生命周期。

三、说一下你对守护线程的了解?

守护线程Daemon线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。这意味着,当一个Java虚拟机中不存在非Daemon线程的时候,Java虚拟机将会退出,因此,在守护线程中执行涉及I/O操作的任务可能会导致数据丢失或其他不可预测的问题。可以通过调用Thread.setDaemon(true)将线程设置为Daemon线程。

👨‍💻面试官追问如何使用守护线程,使用时有什么要注意的

使用方法

  1. 创建线程:首先,你需要创建一个继承自Thread类的新线程或者实现Runnable接口的对象。

  2. 设置守护线程:在调用start()方法之前,通过调用线程的setDaemon(true)方法将其设置为守护线程。

  3. 启动线程:调用线程的start()方法启动线程。

示例

public class DaemonThreadExample extends Thread{
    public DaemonThreadExample() {
        // 默认构造函数
    }
    @Override
    public void run() {
        while (true) {
            // 守护线程执行的代码
            System.out.println("守护线程正在运行....");
            try {
                Thread.sleep(1000); // 暂停一秒
            } catch (InterruptedException e) {
                e.printStackTrace();
                // 如果守护线程被中断,则退出循环
                break;
            }
        }
    }

    public static void main(String[] args) {
        // 创建守护线程对象
        DaemonThreadExample daemonThread = new DaemonThreadExample();

        // 设置为守护线程
        daemonThread.setDaemon(true);

        // 启动守护线程
        daemonThread.start();

        // 主线程执行其他任务,例如休眠一段时间
        try {
            Thread.sleep(5000); // 主线程休眠5秒
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 当主线程结束时,守护线程也会立即停止
        System.out.println("当主线程结束时,守护线程停止.");
    }
}

注意事项

  1. 设置守护线程的时机:必须在调用线程的start()方法之前调用setDaemon(true)方法将其设置为守护线程。如果在调用start()方法之后调用setDaemon(true),则会抛出IllegalThreadStateException

  2. 守护线程与前台线程:守护线程主要是为前台线程服务的。当所有的前台线程都结束时,JVM会立即停止,此时守护线程也会被强制终止。因此,守护线程不应该执行任何重要的或必须完成的任务。

  3. 避免在守护线程中执行I/O操作:由于守护线程的生命周期是不确定的,可能在任何时候被终止,因此在守护线程中执行I/O操作可能会导致数据丢失或文件损坏等问题。

  4. 线程池中的守护线程:如果你在使用线程池(如ExecutorService),并希望线程池中的线程是守护线程,那么你需要确保在调用Executors的工厂方法创建线程池时,传入的线程工厂(ThreadFactory)创建的线程是守护线程。但是,Java的ExecutorService默认并不支持直接设置守护线程,因为线程池通常用于执行重要的后台任务,这些任务应该由前台线程来执行。

  5. 不要依赖守护线程完成关键任务:由于守护线程的生命周期受前台线程的控制,因此不应该依赖守护线程来完成关键任务或需要持久运行的任务。这些任务应该由前台线程来执行。

守护线程在Java编程中有多种应用场景,这些场景通常涉及需要在后台运行的任务,以支持其他线程或执行特定的服务,比如:日志记录、定时任务、数据统计、垃圾回收等。

给大家写一个日志记录的场景:

import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class LogRecorder {

    private final ScheduledExecutorService scheduler;
    private final BufferedWriter logWriter;

    public LogRecorder(String logFilePath) throws IOException {
        // 创建一个单线程的守护线程池
        scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
            Thread thread = new Thread(r);
            // 设置为守护线程
            thread.setDaemon(true);
            return thread;
        });

        // 初始化日志文件的写入器
        logWriter = new BufferedWriter(new FileWriter(logFilePath, true));
    }

    // 启动日志记录任务
    public void startLogging() {
        // 每隔一段时间记录一条日志(这里假设为每5秒)
        scheduler.scheduleAtFixedRate(() -> {
            try {
                // 模拟生成一条日志
                String logMessage = "日志信息: " + System.currentTimeMillis();
                logWriter.write(logMessage);
                logWriter.newLine();
                logWriter.flush();
                System.out.println(logMessage);
            } catch (IOException e) {
                e.printStackTrace();
                // 可以在这里处理异常,例如重新打开文件或记录错误日志
            }
        }, 0, 5, TimeUnit.SECONDS);
    }

    // 停止日志记录任务并关闭文件写入器
    public void stopLogging() throws IOException {
        scheduler.shutdown(); // 停止任务调度
        logWriter.close(); // 关闭文件写入器
    }

    public static void main(String[] args) throws IOException {
        // 假设日志文件路径为"logs/application.log"
        String logFilePath = "文件地址/xxx.log";
        LogRecorder logRecorder = new LogRecorder(logFilePath);

        // 启动日志记录任务
        logRecorder.startLogging();

        // 模拟主线程执行一些任务
        try {
            Thread.sleep(30000); // 主线程休眠30秒
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 停止日志记录任务并关闭文件写入器
        logRecorder.stopLogging();

        // 主线程结束,由于守护线程的存在,JVM不会立即关闭
        // 但由于我们调用了scheduler.shutdown(),守护线程中的任务将不再执行
        System.out.println(" 主线程结束,停止写入日志.");
    }
}

四、使用多线程可能带来什么问题

在进行并发编程时,如果希望通过多线程执行任务让程序运行得更快,会面临非常多的挑战,比如:

  • 上下文切换的问题:频繁的上下文切换会影响多线程的执行速度。
  • 死锁的问题
  • 受限于硬件和软件的资源限制问题:在进行并发编程时,程序的执行速度受限于计算机的硬件或软件资源。

👨‍💻面试官追问既然你提到了锁,那么死锁产生的必要条件是什么?

  • 互斥条件(Mutual Exclusion):至少有一个资源必须处于非共享状态,即一次只能被一个进程或线程占用。
  • 请求与保持条件(Hold and Wait):进程或线程至少需要持有一个资源,并且在等待其他资源时不释放已占有的资源。
  • 不可剥夺条件(No Preemption):已分配给进程或线程的资源不能被强制性地剥夺,只能由持有资源的进程或线程主动释放。
  • 循环等待条件(Circular Wait):存在一个进程或线程的资源申请序列,使得每个进程或线程都在等待下一个进程或线程所持有的资源。

👨‍💻面试官继续追问那你说说Java多线程避免死锁有什么办法?

  • 按顺序获取锁:当多个线程需要获取多个锁时,为了避免死锁,可以约定一个获取锁的顺序,并且所有线程都按照这个顺序来获取锁。这样可以确保锁是以一致的顺序被请求和释放的。
  • 避免嵌套锁:尽量减少锁的嵌套使用,避免在持有锁的情况下再申请其他锁。如果必须使用多个锁,尽量保证锁的获取顺序一致,以避免死锁。
  • 使用定时锁或tryLock():Java提供了定时锁的机制,即在尝试获取锁的时候设定一个等待的时间。如果在这个时间内未能获取到锁,就主动放弃。另外,可以使用tryLock()方法来尝试获取锁,如果获取失败则不会阻塞,可以继续执行其他逻辑或等待一段时间后重新尝试。
  • 使用并发工具类:Java中的并发工具类,如java.util.concurrent包下的类,提供了许多高级并发工具,如SemaphoreCountDownLatchCyclicBarrier等,这些工具可以帮助简化多线程编程,并减少死锁的风险。
  • 避免线程持有锁的时间过长:当一个线程持有一个锁并长时间不释放时,会阻塞其他线程的访问,并增加死锁的概率。因此,需要尽量缩短线程持有锁的时间,及时释放锁,以便其他线程能够及时获取锁并继续工作。
  • 仔细设计资源申请顺序:在设计多线程程序时,要仔细考虑资源申请的顺序。如果多个线程都需要获取同一组资源,可以考虑引入一个资源分配器,通过分配器来按照一定的策略来分配资源,避免资源的竞争。
  • 死锁检测和恢复:虽然预防死锁是最好的策略,但有时死锁仍然可能发生。在这种情况下,可以使用死锁检测算法来及时发现死锁,并采取必要的措施进行恢复,如终止一个或多个进程或线程,或者回滚到某个一致的状态。

👨‍💻面试官继续追问:你能写一个Java死锁的案例吗?

当两个或多个线程无限期地等待一个资源,而这些资源又被其他线程持有时,就会发生死锁。

public class DeadlockExample {
/*
这个死锁大概思路:
1、线程1拿到lock1休眠5s
2、线程1休眠后,线程2拿到lock2
3、线程1休眠结束后。尝试拿lock2,但是lock2被线程2占有
4、同理,线程2休眠结束后,尝试拿lock1,但是lock1又被线程1占有
因此,造成了死锁
*/

    public static void main(String[] args) {
        Object lock1 = new Object();
        Object lock2 = new Object();
        new Thread(() -> {
            synchronized (lock1){
                System.out.println(Thread.currentThread().getName()+"已经获得a锁");
                try {
                    Thread.sleep(5);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName()+"睡眠5ms结束");
                synchronized (lock2) {
                    System.out.println(Thread.currentThread().getName()+"已经获得b锁");
                }
            }
        },"线程1").start();
        new Thread(() -> {
            synchronized (lock2) {
                System.out.println(Thread.currentThread().getName() + "已经获得b锁");
                try {
                    Thread.sleep(5);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName()+"睡眠5ms结束");
                synchronized (lock1) {
                    System.out.println(Thread.currentThread().getName() + "已经获得a锁");
                }
            }
        },"线程2").start();
    }
}

五、说一说sleep()、wait()、join()、yield()的区别

在说这几个方法区别之前,先给大家说一下什么锁池等待池

1.锁池

所有需要竞争同步锁的线程都会放在锁池中,比如当前对象的锁已经被其中一个线程得到,则其他线程需要在这个锁池进行等待,当前面的线程释放同步锁后锁池中的线程去竞争同步锁,当某个线程得到后会进入就绪队列进行等待cpu资源分配。

2.等待池

当我们调用wait()方法后,线程会放到等待池当中,等待池的线程是不会去竞争同步锁。只有调用notify()notifyAll()后等待池的线程才会开始去竞争锁,notify()是随机从等待池选出一个线程放到锁池,而notifyAll()是将等待池的所以线程放到锁池当中。

sleep跟wait的区别

  1. sleep方法是Thread类的静态方法,wait是Object类的本地方法
  2. sleep方法不会释放锁,但是wait会释放锁,而且会加入到等待队列中

sleep就是把cpu执行资格执行权释放出去,不在运行此线程,当定时时间结束后再取回cpu资源,参与cpu的调度,获取到cpu资源后就可以继续运行了。而如果sleep时该线程有锁,那么sleep也不会释放这个,而是把锁带着进入了冻结状态,也就是说其他需要这个锁的线程根本不可能获取到这把锁。也就是无法执行程序。如果在睡眠期间其他线程调用了这个线程的interrupt方法,那么这个线程也会抛出interruptexception异常返回,这和wait是一样的。

        3.sleep方法不依赖于同步器synchronized,但是wait需要依赖synchronized关键字。

        4.sleep不需要被唤醒,但是wait需要(不指定时间需要被别人中断)。

        5.sleep一般用于当前线程休眠,或者轮询暂停操作,wait则多用于多线程之间的通信。

        6.sleep会让出CPU执行时间并且强制上下文切换,而wait不一定,wait后还是有机会重新争夺锁继续执行的。

 yield跟join的区别

yield()执行后线程直接进入就绪状态,马上释放cpu的执行权,但是依旧保留了cpu的执行资格,所以有可能cpu下次进行线程调度还会让这个线程获取到执行权继续执行

join()执行后线程进入阻塞状态,例如在线程B中调用线程A的join(),那么线程B会进入到阻塞队列,直到线程A结束或中断线程

给大家举一个简单的例子:t1线程睡4秒,然后执行,之后又调用了join()使主线程进入阻塞,直到t1线程执行完之后主线程才会执行。(注意:是主线程进入阻塞而不是t1阻塞

public static void main(String[] args) throws InterruptedException {
       Thread t1 =  new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(4000);
                }catch (InterruptedException e){
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName()+"执行了。。。");
            }
        });
       t1.start();
       t1.join();
       System.out.println(Thread.currentThread().getName()+"执行了。。。");
    }

/*打印结果:
Thread-0执行了。。。 (先执行)
main执行了。。。 (后执行)
*/

六、知道线程中的 run() 和 start() 有什么区别吗?

  1. 功能

    • run(): 这是Thread类中的一个方法,用于定义线程要执行的任务。当你直接调用一个线程的run()方法时(例如myThread.run()),它会在当前线程(通常是主线程)中执行,而不是在新的线程中。这意味着它不会启动一个新线程。
    • start(): 这是Thread类中的另一个方法,用于启动一个新线程来执行run()方法中的代码。当你调用start()方法时(例如myThread.start()),Java会创建一个新的线程,并在该线程中调用run()方法。这意味着run()方法中的代码会在新的线程中执行。
  2. 执行上下文

    • 直接调用run():代码在当前线程(通常是主线程)的上下文中执行。
    • 调用start():Java会创建一个新的线程,并在该线程的上下文中执行run()方法中的代码。
  3. 返回值

    • run(): 它没有返回值(即返回类型为void)。
    • start(): 它也没有返回值(返回类型为void),但它启动了一个新线程。
  4. 异常处理

    • 如果你在run()方法中抛出一个未检查的异常(例如RuntimeException),并且你没有在该方法中捕获它,那么它会在当前线程中直接抛出,并且可能会导致程序崩溃(除非有其他地方的代码捕获了该异常)。
    • 如果你在start()方法中抛出一个异常,那么它实际上是在调用start()的线程中抛出的,而不是在新创建的线程中。这是因为start()方法是在当前线程中调用的,而新线程是在start()方法内部创建的。
  5. 线程状态

    • 当线程首次被创建时,它的状态是NEW
    • 当你调用start()方法时,线程的状态变为RUNNABLE(或BLOCKEDWAITINGTIMED_WAITING等,具体取决于线程的行为)。
    • 如果你直接调用run()方法而不是start()方法,线程将不会被创建为单独的线程,并且它的状态仍然是NEW(尽管这在实际中并不常见,因为通常你会在创建线程后立即调用start())。

总结:你应该总是使用start()方法来启动一个新线程,而不是直接调用run()方法。

七、说了这么多,Java程序中如何保证多线程的安全

  • 原子性:在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么都执行,要么都不执行。可以用Java提供了java.util.concurrent.atomic包下的原子类,如AtomicIntegerAtomicLong等,这些类中的方法都是线程安全,或者java.util.concurrent.locks 包下的 Lock 接口提供了比 synchronized 更灵活的锁机制,包括可重入锁、读写锁、定时锁
  • 可见性:Java提供了volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值

另外,通过synchronizedLock也能够保证可见性,synchronizedLock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

  • 有序性:在Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是只有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

总结

这期的面试题需要大家多理解多记多背,先理解在背。好了,今天的分享就到这,喜欢的小伙伴记得三连欧😘

参考文章:并发编程&JVM_ΘLLΘ的博客-CSDN博客


网站公告

今日签到

点亮在社区的每一天
去签到