基本概念
什么是临界资源和临界区?
临界资源
临界资源是指在多线程或多进程应用程序中,多个线程或进程需要访问并修改的共享资源,例如共享内存、文件、数据库连接等。这些资源通常是有限的,多个线程或进程试图同时访问它们可能导致数据不一致或竞态条件的问题。因此,必须采取措施来保护这些临界资源,以确保它们在任何给定时刻只能由一个线程或进程访问,从而避免数据损坏或不一致性。
临界区
临界区是指多线程或多进程应用程序中,访问临界资源的那部分代码或代码段。在临界区内,线程或进程对临界资源进行操作。为了确保临界资源的安全访问,必须采用同步机制,如互斥锁、信号量或条件变量等,来控制只有一个线程可以进入临界区,其他线程必须等待。这样可以防止竞态条件和数据损坏。
线程和进程的区别?
定义
- 进程:进程是操作系统中的一个独立的执行单元,拥有独立的内存空间、程序计数器、寄存器等资源。每个进程都运行在自己的地址空间中,相互之间不直接共享内存,通信需要使用特殊的机制,如管道、套接字等。
- 线程:线程是进程内的一个小的执行单元,多个线程共享同一个进程的内存和资源。线程之间可以更容易地共享数据和通信,因为它们处于同一地址空间内。
资源开销
- 进程:每个进程都有独立的内存空间和系统资源,创建和销毁进程的开销相对较大。
- 线程:线程共享同一进程的内存和资源,因此创建和销毁线程的开销较小。
通信和同步
- 进程:进程之间的通信通常需要使用更复杂的机制,如进程间通信(IPC)。
- 线程:线程之间可以更容易地共享数据,但也需要进行同步来避免竞态条件(Race Condition)和其他并发问题。
并发性
- 进程:多个进程可以并行执行,因为它们在不同的地址空间中。
- 线程:多个线程在同一进程内并发执行,可以更高效地共享数据和资源。
故障隔离
- 进程:由于进程之间有独立的内存空间,一个进程的崩溃通常不会影响其他进程。
- 线程:一个线程的错误可能会导致整个进程崩溃,因为它们共享同一地址空间。
什么是死锁,死锁的四个条件?
死锁(Deadlock)是指两个或多个进程或线程在竞争资源时,因彼此之间的互斥和等待而陷入无限等待的状态,导致它们都无法继续执行下去。死锁是一种程序设计或系统管理的错误,它会导致应用程序无响应或进程挂起,需要手动干预才能解决。
死锁通常具备以下四个必要条件,也被称为死锁条件:
- 互斥条件
- 至少有一个资源是独占的,即一次只能被一个进程或线程占用。如果多个进程或线程同时需要访问这个资源,就会出现互斥条件。
- 请求与保持条件
- 进程或线程在持有至少一个资源的同时,又请求其他资源,但无法立即获得所需资源。这就会导致持有资源的进程或线程等待其他资源的释放,形成循环等待。
- 不可剥夺条件
- 资源不能被强制性地从一个进程或线程中抢占,只能由占用它的进程或线程主动释放。
- 循环等待条件
- 多个进程或线程之间形成一个资源的循环等待链,每个进程或线程都在等待下一个进程或线程所持有的资源,最终导致所有进程或线程都无法继续执行。
要解决死锁问题,需要破坏其中任何一个必要条件,以防止死锁的发生。
什么是线程饥饿现象?
线程饥饿(Thread Starvation)是指在多线程应用程序中,某些线程由于竞争有限的资源而无法获得执行的机会,导致它们长时间处于等待状态,无法完成其工作的现象。线程饥饿通常是由于不公平的资源分配、锁竞争、线程优先级设置不合理等因素引起的。
产生线程饥饿现象有几个原因:
- 锁竞争:如果多个线程竞争获取某个锁,并且锁的分配不公平,某些线程可能会一直无法获取锁,导致线程饥饿。这种情况下,一些线程可能会等待很长时间,无法进入临界区执行。
- 线程优先级不均衡:如果线程的优先级设置不合理,高优先级线程可能会抢占资源,导致低优先级线程无法获得执行机会。这可能会导致低优先级线程长时间等待,无法执行。
- 资源瓶颈:当多个线程竞争有限的系统资源,如CPU时间、内存等时,某些线程可能会长时间等待资源的释放,导致线程饥饿。这种情况下,系统资源的不合理分配可能导致线程无法平等地访问资源。
- 死锁:死锁是线程饥饿的一种极端情况,其中多个线程彼此等待对方释放资源,导致所有线程都无法继续执行。
如何理解线程的同步与异步、阻塞与非阻塞?
同步(Synchronous)与异步(Asynchronous)
- 同步:在同步编程中,任务按照顺序执行,一个任务完成后,下一个任务才会开始执行。这意味着任务之间的执行是相互依赖的,后一个任务通常会等待前一个任务完成才能执行。
- 异步:在异步编程中,任务可以并行执行,一个任务不必等待另一个任务完成就能继续执行。任务之间通常通过回调函数、事件处理或者异步操作来实现协作。异步编程有助于提高程序的响应性和效率。
阻塞(Blocking)与非阻塞(Non-blocking)
- 阻塞:在阻塞模式下,当一个任务执行时,如果它需要等待某个资源或事件完成,它会阻止其他任务的执行,直到该资源或事件可用。这意味着任务会停滞在等待状态,无法执行其他工作。
- 非阻塞:在非阻塞模式下,任务可以继续执行,即使它需要等待某些资源或事件。任务会定期查询资源或事件的状态,如果资源不可用,它可以执行其他工作而不会停滞。
这些概念通常可以组合在一起,产生四种不同的情况:
- 同步阻塞:任务按顺序执行,并在等待资源时阻塞。
- 同步非阻塞:任务按顺序执行,但在等待资源时定期查询,可以执行其他任务。
- 异步阻塞:任务可以并行执行,但在等待异步操作完成时阻塞。
- 异步非阻塞:任务可以并行执行,并且在等待异步操作时不会阻塞,可以继续执行其他任务。
什么是上下文切换?
多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式(程序计数器)。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。
概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。
上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。
如何理解并发编程中的三个概念:原子性,可见性,有序性?
在并发编程中,有三个重要的概念:原子性、可见性和有序性,通常被称为 Java 内存模型(Java Memory Model,JMM)的三大特性。
- **原子性(Atomicity):**原子性指的是操作不可分割,要么完全执行,要么完全不执行。一个原子操作是一个不可再分的操作单元,要么全部成功执行,要么全部失败,不会出现部分执行的情况。它用于保护共享资源,确保多个线程不会同时修改共享资源导致数据不一致的问题。
- **可见性(Visibility):**可见性指的是一个线程对共享变量的修改对其他线程是可见的。当一个线程修改共享变量时,其他线程应该能够立即看到这个修改,而不是看到过期的或缓存的值。
- 有序性(Ordering): 有序性指的是指令在程序中的执行顺序要符合程序的原有顺序。在并发编程中,由于编译器和处理器的优化,指令的执行顺序可能与代码中的顺序不一致。有序性问题可能导致程序出现意外的行为,如重排序可能导致线程死锁或无法结束。
多线程
创建线程有哪几种方式,如何实现?
- 继承Thread类创建线程类
- 定义Thread类的子类,并重写该类的run方法,该run方法的方法体就代表了线程要完成的任务。因此把run()方法称为执行体。
- 创建Thread子类的实例,即创建了线程对象。
- 调用线程对象的start()方法来启动该线程。
- 通过Runnable接口创建线程类
- 定义runnable接口的实现类,并重写该接口的run()方法,该run()方法的方法体同样是该线程的线程执行体。
- 创建 Runnable实现类的实例,并依此实例作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象。
- 调用线程对象的start()方法来启动该线程。
- 通过Callable和Future创建线程
- 创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,并且有返回值。
- 创建Callable实现类的实例,使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。
- 使用FutureTask对象作为Thread对象的target创建并启动新线程。
- 调用FutureTask对象的get()方法来获得子线程执行结束后的返回值。
- 通过线程池创建线程
- 调用Executors.newFixedThreadPool方法创建线程池。
- Runnable的匿名内部类创建线程。
- 结束要调用shutdown关闭线程池。
采用实现Runnable接口的方式创建线程的优缺点
优点
- 简单:实现Runnable接口相对简单,只需要实现run()方法,不需要继承特定的基类。
- 更灵活:由于Java支持多重接口实现,你可以同时实现其他接口,使你的类更加灵活。
缺点
- 不能返回结果:Runnable接口的run()方法没有返回值,因此无法获得线程的执行结果。
- 不支持异常抛出:run()方法不能抛出受检查异常,这可能会导致异常处理的不便。
- 相对较低的可扩展性:如果你需要更高级的线程控制,如取消、获取执行结果等,就需要额外的编程工作。
采用实现 Callable接口的方式创建线程的优缺点
优点
- 支持返回结果:Callable接口的call()方法可以返回执行结果,这对于需要获取线程执行结果的情况非常有用。
- 支持异常抛出:call()方法可以抛出受检查异常,更容易进行异常处理。
- 更灵活的线程控制:Executor框架支持Callable,可以更灵活地控制线程的执行、取消等。
缺点
- 复杂性:实现Callable接口相对于Runnable来说更复杂,需要实现call()方法,而且需要处理可能抛出的异常。
- 不能直接用于Thread类:Callable不能直接传递给Thread的构造函数,需要与Executor框架一起使用,稍微增加了一些复杂性。
采用继承Thread类的方式创建线程的优缺点
优点
- 简单直观:继承Thread类创建线程相对来说更直观,因为你只需扩展Thread类并覆盖其run()方法来定义线程的执行逻辑。
- 易于使用:对于一些简单的多线程任务,继承Thread类可能是最简单的方法,无需创建额外的接口实现或传递任务对象。
- 适用于独立线程:如果你要创建的线程是一个独立的实体,不需要和其他线程共享状态或资源,那么继承Thread类可能是一个合适的选择。
缺点
- 不支持多重继承:Java不支持多重继承,这意味着如果你已经继承了Thread类,就无法再继承其他类。这可能限制了你的设计灵活性。
- 耦合性高:使用继承Thread类创建线程可能会导致较高的耦合性,因为线程的执行逻辑与线程本身紧密绑定在一起,不容易进行解耦和重用。
- 不支持返回结果:与实现Callable接口不同,继承Thread的方式通常不支持直接返回线程执行结果。如果需要获取线程的执行结果,可能需要使用其他方式,如共享变量或回调函数。
Runnable 和 Callable 有什么区别
Runnable
- Runnable 接口的
run()
方法没有返回值,因此无法返回执行结果。 run()
方法不能抛出受检查的异常(checked exception),只能抛出未受检查的异常(unchecked exception)。- 通常通过创建实现 Runnable 接口的类,然后将其传递给线程对象的构造函数来创建新线程,并通过
start()
方法启动线程。
Callable
- Callable 接口的
call()
方法可以返回一个值,它支持泛型,可以指定返回类型。 call()
方法可以抛出受检查的异常,因此可以更灵活地处理异常情况。- 通常通过创建实现
Callable
接口的类,然后将其传递给线程池的执行方法(如ExecutorService.submit()
)来创建并发任务,并获取任务的执行结果。
Runnable 用于表示没有返回值的并发任务,而 Callable 用于表示有返回值的并发任务,并且 Callable 允许更灵活地处理异常。
sleep和wait的区别
sleep()
方法:
sleep()
是一个线程类(Thread类)的静态方法,用于使当前线程进入指定的时间段的休眠状态。sleep()
不会释放线程持有的锁。sleep()
方法的调用不需要在同步块(synchronized)中,可以在任何地方使用。sleep()
接受一个以毫秒为单位的参数,指定线程休眠的时间。
wait()
方法:
wait()
是一个Object
类的实例方法,用于让当前线程进入等待状态,同时释放对象的锁,以便其他线程可以访问该对象。wait()
方法通常在同步块内部调用,因为它需要在持有锁的对象上等待。wait()
方法可以指定一个可选的超时时间,或者不指定,如果不指定超时时间,线程将一直等待直到其他线程通过notify
或notifyAll()
来唤醒它。
本质区别
Thread.sleep()
只会让出CPU ,不会导致锁行为的改变Object.wait()
不仅让出CPU , 还会释放已经占有的同步资源锁。
notify()和 notifyAll()有什么区别
notify()
和 notifyAll()
都是Java中用于线程间通信的方法,它们的主要区别在于唤醒等待线程的方式和数量。
notify()
方法
notify()
用于唤醒一个处于等待状态的线程。- 当调用
notify()
方法时,系统会选择其中一个处于等待状态的线程唤醒,但具体唤醒哪个线程是不确定的,取决于调度器的策略。
notifyAll()
方法
notifyAll()
用于唤醒所有处于等待状态的线程。- 当调用
notifyAll()
方法时,会唤醒所有等待的线程,这样它们都有机会去争夺锁。
使用 notify()
和 notifyAll()
时需要注意以下几点:
notify()
通常用于更细粒度的通知,只唤醒一个等待线程,可以提高性能,但可能需要更复杂的代码来确保正确性。notifyAll()
通常用于更安全的通知,确保所有等待线程都被唤醒,但可能会导致竞争和性能问题,因为多个线程会争夺锁。- 在使用
notify()
或notifyAll()
时,必须在同步块内部调用,即在持有锁的情况下调用这些方法,否则会抛出IllegalMonitorStateException
异常。 - 为了避免竞争条件和死锁,通常建议在调用
notify()
或notifyAll()
后立即释放锁,以便等待线程能够竞争锁并继续执行。
线程的 run()和 start()有什么区别?为什么不能直接调用 run() 方法?
run()
方法:
run()
方法是在实现了Runnable
接口的类中定义的。- 当你调用
run()
方法时,它会在当前线程中执行,并不会创建新的线程。 - 直接调用
run()
方法会使程序按照顺序执行,而不会实现多线程的并发执行。
start()
方法:
start()
方法是在Thread
类中定义的。- 当你调用
start()
方法时,它会创建一个新的线程,并在新线程中执行run()
方法。 - 使用
start()
方法启动线程才能实现多线程并发执行,多个线程可以同时运行。
run()
方法是在本线程里的,只是线程里的一个函数,而不是多线程的。 如果直接调用run()
,其实就相当于是调用了一个普通函数而已,直接调用run()方法必须等待run()方法执行完毕才能执行下面的代码,所以执行路径还是只有一条,根本就没有线程的特征,所以在多线程执行时要使用start()方法而不是run()方法。
线程的生命周期和状态
线程的生命周期包括以下状态:
- 新建(New):线程被创建但尚未启动执行。在这个状态下,线程对象已经被创建,但还没有调用其start()方法。
- 运行(Runnable):线程正在执行或者等待CPU时间片来执行。线程可以从新建状态或者阻塞状态进入运行状态。
- 阻塞(Blocked):线程因为某些原因被暂时挂起,无法继续执行。这些原因可能包括等待某个资源的释放、等待用户输入等。线程在阻塞状态下会等待条件满足,一旦条件满足,线程会重新进入运行状态。
- 等待(Waiting):线程在等待某个条件的发生,但等待时不消耗CPU时间。线程可以被唤醒并进入运行状态。
- 超时等待(Timed Waiting):与等待状态类似,但线程在等待一段特定的时间后会自动转为运行状态,无需外部唤醒。
- 终止(Terminated):线程执行完毕或者异常终止后进入终止状态。一旦线程终止,它就不能再次进入运行状态。
状态图的转换关系如下:
- 得到一个线程类,new出一个实例线程就进入new状态(新建状态)。
- 调用start方法就进入Runnable(可运行状态)
- 如果此状态被操作系统选中并获得时间片就进入Running状态
- 如果Running状态的线程的时间片用完或者调用yield方法就可能回到Runnable状态
- 处于Running状态的线程如果在进入同步代码块/方法就会进入Blocked状态(阻塞状态),锁被其它线程占有,这个时候被操作系统挂起。得到锁后会回到Running状态。
- 处于Running状态的线程如果调用了wait/join/LockSupport.park()就会进入等待池(无限期等待状态), 如果没有被唤醒或等待的线程没有结束,那么将一直等待。
- 处于Running状态的线程如果调用了sleep(睡眠时间)/wait(等待时间)/join(等待时间)/ LockSupport.parkNanos(等待时间)/LockSupport.parkUntil(等待时间)方法之后进入限时等待状态,等待时间结束后自动回到原来的状态。
- 处于Running状态的线程方法执行完毕或者异常退出就会进入死亡状态。
如何停止一个正在运行的线程
使用标志来请求停止
在你的线程的run()方法中,使用一个标志变量来指示线程是否应该停止。当需要停止线程时,设置该标志为true,并在线程的执行逻辑中检查该标志,如果标志为true,则退出线程的run()方法。这个方法是比较安全和可控制的线程终止方式。
public class MyThread extends Thread {
private volatile boolean stopRequested = false;
public void run() {
while (!stopRequested) {
// 执行线程的工作
}
}
public void stopThread() {
stopRequested = true;
}
}
使用interrupt()方法
Java提供了interrupt()
方法来请求线程中断。当一个线程被中断时,它会收到一个InterruptedException
,你可以捕获这个异常并在适当的地方停止线程的执行。
public class MyThread extends Thread {
public void run() {
while (!Thread.currentThread().isInterrupted()) {
// 执行线程的工作
}
}
}
在其他地方,你可以调用线程的interrupt()方法来请求线程中断
myThread.interrupt(); // 请求线程中断
使用Executor框架
使用Executor框架创建和管理线程可以更好地控制线程的生命周期。Executor框架提供了方法来提交任务、取消任务等,以更安全和灵活地停止线程。
ExecutorService executor = Executors.newFixedThreadPool(1);
Future<?> future = executor.submit(() -> {
while (!Thread.currentThread().isInterrupted()) {
// 执行线程的工作
}
});
// 请求线程中断
future.cancel(true);
**使用 Thread.stop() **
在Java中,通常不建议使用Thread.stop()方法来停止线程,因为它已经被标记为废弃(deprecated),而且存在潜在的风险。
为什么wait, notify 和 notifyAll这些方法不在thread类里面?
Java中的wait
、notify
和notifyAll
方法不在Thread类中,而是在Object类中定义的,主要是因为它们是与线程之间的协作和锁定相关的机制,而不是与线程自身的控制相关。
在Java中,每个对象都有一个关联的锁(也称为监视器锁或内部锁),wait
、notify
和notifyAll
方法是用于协调线程之间对这些锁的访问的。
Java中interrupted() 和 isInterrupted()方法的区别?
interrupted():
- 它用于检查当前线程的中断状态,并且在检查的同时会清除当前线程的中断状态(将中断状态重置为false)。
- 如果调用
interrupted()
方法的线程的中断状态为true,表示线程曾经被中断过(中断状态被设置过),则返回true,否则返回false。
isInterrupted():
- 它用于检查指定线程的中断状态,但不会清除中断状态。
- 如果调用
isInterrupted()
方法的线程的中断状态为true,表示线程曾经被中断过(中断状态被设置过),则返回true,否则返回false。
Thread类中的yield()方法有什么作用?
Thread.yield()
方法用于提示线程调度器将当前线程从运行状态切换到就绪状态,以便给其他具有相同或更高优先级的线程更多的执行机会。它的作用是让出当前线程的 CPU 执行时间片,使得其他线程有更多的机会运行。
yield()
方法的主要作用是:
- 协助线程调度:
yield()
提供了一种协助线程调度的机制。它告诉线程调度器,当前线程愿意让出一部分执行时间,以便其他具有相同或更高优先级的线程有机会运行。 - 减小线程间的竞争:在某些情况下,当多个线程竞争某些资源或锁时,通过在适当的地方调用
yield()
,可以减少线程之间的竞争,提高程序的性能。
需要注意的是,yield()
方法并不能保证当前线程会立即切换到就绪状态,因为线程调度是由操作系统和 JVM 控制的,而且 yield()
方法的调用会告诉调度器当前线程愿意让出时间片,但不是强制性的。
使用 yield()
方法通常需要慎重考虑,因为它的过度使用可能会导致线程切换频繁,降低程序的性能。通常情况下,不需要显式地调用 yield()
方法,因为线程调度器通常能够合理分配 CPU 时间片,但在某些特定的场景中,它可能会有用。
线程安全需要保证几个基本特征?
线程安全是指在多线程环境下,对共享资源或数据的访问和操作不会导致数据不一致、不可预测的结果或其他并发问题。为了保证线程安全,需要确保以下几个基本特征:
原子性(Atomicity)
原子性指的是一个操作是不可分割的,要么全部执行成功,要么全部不执行。如果一个操作是原子的,那么多个线程同时执行这个操作不会导致数据不一致。Java提供了java.util.concurrent.atomic
包中的原子类,如AtomicInteger
和AtomicLong
,来实现原子操作。
可见性(Visibility)
可见性指的是当一个线程修改了共享数据的值时,其他线程能够立即看到这个修改。为了确保可见性,通常需要使用volatile
关键字来修饰共享变量,或者使用锁机制来同步访问共享数据。
有序性(Ordering)
有序性指的是线程执行操作的顺序要与程序中的指令顺序一致。在多线程环境中,指令重排序可能会导致意外的结果。为了确保有序性,可以使用volatile
关键字、synchronized
关键字或其他同步机制。
线程之间有几种通讯方式?
线程之间的通信有两种方式:共享变量和消息传递。
共享变量
线程之间可以通过共享变量来进行通信。多个线程可以读写共享变量,并通过共享变量传递信息。然而,要确保对共享变量的访问是线程安全的,通常需要使用同步机制(如synchronized
关键字、锁等)来保护共享变量。
消息传递
在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。在 Java 中典型的消息传递方式,就是 wait()
和 notify()
,或者 BlockingQueue
。
什么是Daemon线程?它有什么意义?
Daemon线程,也叫守护线程,是指在程序运行的时候在后台提供一种通用服务的线程,并且这个线程并不属于程序中不可或缺的部分。它的主要特点是当所有的非守护线程都执行完毕时,JVM会自动退出,而不管守护线程是否还在运行。它具有如下意义:
- 后台任务执行:守护线程通常用于执行后台任务和服务,这些任务在应用程序运行期间需要进行,但不需要等待它们完成。例如,垃圾回收器就是一个典型的守护线程。
- 不阻止程序退出:守护线程不会阻止JVM的退出。当所有非守护线程都执行完毕,JVM会自动退出,不会等待守护线程完成。这在一些需要在应用程序退出时进行清理工作的情况下很有用。
- 作为辅助线程:守护线程通常用于辅助性质的工作,不是应用程序的主要任务。它们会在后台默默地执行,不干扰用户线程的正常工作。
- 设置守护状态:可以使用
setDaemon(boolean on)
方法将线程设置为守护线程。默认情况下,线程是非守护的。在启动线程之前设置守护状态是有效的。 - JVM退出时的清理工作:守护线程通常用于执行一些在应用程序退出时需要的清理工作,例如关闭文件、释放资源、断开网络连接等。
需要注意的是,守护线程并不适用于所有情况。它们在执行后台任务时非常有用,但要小心确保它们不会在应用程序退出前被强制终止,否则可能导致资源泄漏或数据不一致。