系列文章目录
面试题分享之Java集合篇(三)
面试题分享之Java集合篇(二)
面试题分享之Java基础篇(三)
前言
今天给小伙伴们分享我整理的关于Java并发的一些常见面试题,这期涉及到线程的一些知识,所以要求小伙伴有一些操作系统的知识,不清楚也不要紧,也不是什么很难的知识点。🌈
一、什么是线程?什么是进程?
- 线程:线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
- 进程:进程是操作系统分配资源的基本单位,它是程序在计算机上的一次执行活动。当系统为一个程序分配资源后,该程序就成为一个独立的进程。
👨💻面试官追问:线程跟进程的区别是什么?
- 线程是进程划分成的更小的运行单位。
- 独立性:进程是独立的,拥有独立的内存空间和系统资源;而线程是依赖进程的,多个线程共享其所属进程的内存空间和资源。
- 开销:进程的创建和销毁开销较大,因为需要为其分配和回收系统资源;而线程的创建和销毁开销较小。
- 切换速度:由于线程的上下文信息相对较少,因此线程间的切换速度通常比进程间的切换速度快。
- 通信与数据共享:进程间的通信和数据共享相对困难,需要通过特定的机制来实现;而线程间的通信和数据共享相对容易,因为它们共享其所属进程的内存空间和资源。
- 并发性:进程和线程都可以实现并发执行,但线程通常用于实现更细粒度的并发操作。在一个多核或多处理器的系统中,多个进程可以并行执行;而在一个进程中,多个线程也可以并行执行(如果处理器支持多线程)。
二、说一下线程的生命周期,它有几种状态
线程的生命周期包含五个阶段,即五种状态,分别是:
-
新建状态(New):新创建了一个线程对象,但还没有调用start()方法。在这个阶段,线程只是被分配了必要的资源,并初始化其状态。
-
就绪状态(Runnable):线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于“可运行线程池”中,变得可运行,只等待获取CPU的使用权。换句话说,线程已经做好了执行的就绪准备,表示可以运行了,但还不是正在运行的线程。
-
运行状态(Running):当就绪的线程被调度并获得CPU资源时,便进入运行状态,开始执行run()方法的线程执行体。在这个阶段,线程正在执行其任务。
-
阻塞状态(Blocked):在运行状态的时候,可能因为某些原因导致运行状态的线程变成了阻塞状态。阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。阻塞的情况可能包括:
- 等待阻塞:运行的线程执行wait()方法,该线程会释放占用的所有资源,JVM会把该线程放入“等待池”中。
- 其他阻塞:运行的线程执行sleep()或join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。
- 阻塞于锁:线程试图获取某个锁,但该锁当前被其他线程持有。
直到线程进入就绪状态,才有机会转到运行状态。
-
死亡状态(Dead):当线程退出run()方法时,线程就会自然死亡,处于终止或死亡状态,也就结束了生命周期。
这五个状态构成了线程从创建到消亡的完整生命周期。
三、说一下你对守护线程的了解?
守护线程:即Daemon
线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。这意味着,当一个Java虚拟机中不存在非Daemon线程的时候,Java虚拟机将会退出,因此,在守护线程中执行涉及I/O操作的任务可能会导致数据丢失或其他不可预测的问题。可以通过调用Thread.setDaemon(true)
将线程设置为Daemon
线程。
👨💻面试官追问:如何使用守护线程,使用时有什么要注意的
使用方法
-
创建线程:首先,你需要创建一个继承自
Thread
类的新线程或者实现Runnable
接口的对象。 -
设置守护线程:在调用
start()
方法之前,通过调用线程的setDaemon(true)
方法将其设置为守护线程。 -
启动线程:调用线程的
start()
方法启动线程。
示例
public class DaemonThreadExample extends Thread{public DaemonThreadExample() {// 默认构造函数}@Overridepublic 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("当主线程结束时,守护线程停止.");}
}
注意事项
设置守护线程的时机:必须在调用线程的
start()
方法之前调用setDaemon(true)
方法将其设置为守护线程。如果在调用start()
方法之后调用setDaemon(true)
,则会抛出IllegalThreadStateException
。守护线程与前台线程:守护线程主要是为前台线程服务的。当所有的前台线程都结束时,JVM会立即停止,此时守护线程也会被强制终止。因此,守护线程不应该执行任何重要的或必须完成的任务。
避免在守护线程中执行I/O操作:由于守护线程的生命周期是不确定的,可能在任何时候被终止,因此在守护线程中执行I/O操作可能会导致数据丢失或文件损坏等问题。
线程池中的守护线程:如果你在使用线程池(如
ExecutorService
),并希望线程池中的线程是守护线程,那么你需要确保在调用Executors
的工厂方法创建线程池时,传入的线程工厂(ThreadFactory
)创建的线程是守护线程。但是,Java的ExecutorService
默认并不支持直接设置守护线程,因为线程池通常用于执行重要的后台任务,这些任务应该由前台线程来执行。不要依赖守护线程完成关键任务:由于守护线程的生命周期受前台线程的控制,因此不应该依赖守护线程来完成关键任务或需要持久运行的任务。这些任务应该由前台线程来执行。
守护线程在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
包下的类,提供了许多高级并发工具,如Semaphore
、CountDownLatch
、CyclicBarrier
等,这些工具可以帮助简化多线程编程,并减少死锁的风险。- 避免线程持有锁的时间过长:当一个线程持有一个锁并长时间不释放时,会阻塞其他线程的访问,并增加死锁的概率。因此,需要尽量缩短线程持有锁的时间,及时释放锁,以便其他线程能够及时获取锁并继续工作。
- 仔细设计资源申请顺序:在设计多线程程序时,要仔细考虑资源申请的顺序。如果多个线程都需要获取同一组资源,可以考虑引入一个资源分配器,通过分配器来按照一定的策略来分配资源,避免资源的竞争。
- 死锁检测和恢复:虽然预防死锁是最好的策略,但有时死锁仍然可能发生。在这种情况下,可以使用死锁检测算法来及时发现死锁,并采取必要的措施进行恢复,如终止一个或多个进程或线程,或者回滚到某个一致的状态。
👨💻面试官继续追问:你能写一个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的区别:
- sleep方法是Thread类的静态方法,wait是Object类的本地方法
- 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() {@Overridepublic 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() 有什么区别吗?
功能:
run()
: 这是Thread
类中的一个方法,用于定义线程要执行的任务。当你直接调用一个线程的run()
方法时(例如myThread.run()
),它会在当前线程(通常是主线程)中执行,而不是在新的线程中。这意味着它不会启动一个新线程。start()
: 这是Thread
类中的另一个方法,用于启动一个新线程来执行run()
方法中的代码。当你调用start()
方法时(例如myThread.start()
),Java会创建一个新的线程,并在该线程中调用run()
方法。这意味着run()
方法中的代码会在新的线程中执行。执行上下文:
- 直接调用
run()
:代码在当前线程(通常是主线程)的上下文中执行。- 调用
start()
:Java会创建一个新的线程,并在该线程的上下文中执行run()
方法中的代码。返回值:
run()
: 它没有返回值(即返回类型为void
)。start()
: 它也没有返回值(返回类型为void
),但它启动了一个新线程。异常处理:
- 如果你在
run()
方法中抛出一个未检查的异常(例如RuntimeException
),并且你没有在该方法中捕获它,那么它会在当前线程中直接抛出,并且可能会导致程序崩溃(除非有其他地方的代码捕获了该异常)。- 如果你在
start()
方法中抛出一个异常,那么它实际上是在调用start()
的线程中抛出的,而不是在新创建的线程中。这是因为start()
方法是在当前线程中调用的,而新线程是在start()
方法内部创建的。线程状态:
- 当线程首次被创建时,它的状态是
NEW
。- 当你调用
start()
方法时,线程的状态变为RUNNABLE
(或BLOCKED
、WAITING
、TIMED_WAITING
等,具体取决于线程的行为)。- 如果你直接调用
run()
方法而不是start()
方法,线程将不会被创建为单独的线程,并且它的状态仍然是NEW
(尽管这在实际中并不常见,因为通常你会在创建线程后立即调用start()
)。总结:你应该总是使用
start()
方法来启动一个新线程,而不是直接调用run()
方法。
七、说了这么多,Java程序中如何保证多线程的安全
- 原子性:在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么都执行,要么都不执行。可以用Java提供了
java.util.concurrent.atomic
包下的原子类,如AtomicInteger
、AtomicLong
等,这些类中的方法都是线程安全,或者java.util.concurrent.locks
包下的Lock
接口提供了比synchronized
更灵活的锁机制,包括可重入锁、读写锁、定时锁等 - 可见性:Java提供了volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值
另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
- 有序性:在Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是只有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
总结
这期的面试题需要大家多理解多记多背,先理解在背。好了,今天的分享就到这,喜欢的小伙伴记得三连欧😘
参考文章:并发编程&JVM_ΘLLΘ的博客-CSDN博客