十九 . 一个线程两次调用start()方法会出现什么情况?线程的生命周期和状态转移。
线程是Java并发的基础元素。理解,操纵线程是必备技能。
19.1 典型回答
Java线程是不允许启动2次的,第二次调用必然会抛出illegalThreadStateException,运行时异常,多次调用start()被认为编程错误。
19.1.1 线程生命周期:
线程状态被明确定义在其公告内部枚举类型java.lang.Thread.State中:
分别是:
1. 新建(new):
表示线程被创建出来还没有真正启动的状态,可以认为是一个Java内部状态。
2. 就绪(runnable):
表示该线程已经在JVM中执行,当然由于执行需要计算资源,它可能是正在运行,也可能是还在等待系统分配给它CPU片段,在就绪队列里面排队。
3. 阻塞(blocked)
状态和讲的同步相关,阻塞表示线程在等待Monitor lock 。
如:
线程试图通过synchronized去获取某个锁,但是其他线程已经独占了,就会导致阻塞问题,使得线程处于阻塞状态。
4. 等待(waiting)
表示正在等待其他线程采取某些操作,
场景:类似生产者消费者模式,发现任务条件并没有满足,会让当前消费者线程等待(wait),另外的生产者线程去准备任务数据,然后通过类似notify等动作,通知消费者继续工作。
Thread.join()也会令线程进入等待状态。
5. 计时等待(timed_wait)
进入条件和等待状态类似,但是调用的是存在超时条件方法,比如wait或join等方法的指定超时版本。
6. 终止(terminated):
不管是意外退出还是正常执行结束,线程已经完成使命,终止运行。
当我们进行第二次调用start()方法的时候,线程可能处于终止或者其他(非new)状态,但是无论如何,都是不可以再次启动的。
19.1.2 计时等待详细解释:
当一个线程等待其他线程完成某个任务,并设置了超时时间,可以使用timed_wait方法。
public class TimedWaitExample { private boolean isTaskComplete = false; public synchronized void waitForTask() { try { // 设置等待超时时间为5秒 wait(5000); if (!isTaskComplete) { System.out.println("任务未能在指定时间内完成,继续执行其他操作"); } } catch (InterruptedException e) { e.printStackTrace(); } } public synchronized void completeTask() { // 模拟任务完成 try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } isTaskComplete = true; notifyAll(); } public static void main(String[] args) { TimedWaitExample example = new TimedWaitExample(); // 创建等待任务的线程 Thread waitThread = new Thread(() -> { example.waitForTask(); }); // 创建完成任务的线程 Thread completeThread = new Thread(() -> { example.completeTask(); }); // 启动线程 waitThread.start(); completeThread.start(); try { // 等待两个线程执行完成 waitThread.join(); completeThread.join(); } catch (InterruptedException e) { e.printStackTrace(); } } }
TimedWaitExample类包含了一个waitForTask方法和一个completeTask方法。在waitForTask方法中,调用了带有超时参数的wait方法,等待任务完成或超过5秒的时间限制。在completeTask方法中,模拟了任务的完成,并通过notifyAll方法唤醒处于等待状态的线程。
在main方法中,创建了一个等待任务的线程和一个完成任务的线程,并启动它们。然后,使用join方法等待两个线程执行完成。
如果完成任务的线程在5秒内完成了任务,则等待线程会被唤醒并继续执行。如果任务未能在指定时间内完成,等待线程会输出一条提示信息并继续执行其他操作。
例子展示了如何使用timed_wait方法来实现线程等待超时的功能。
19.2 深入扩展考察
面试热身题,进行对基本状态的简单的流转进行介绍,对线程进行理解是对我们日常开发和诊断分析有很大的帮助,都是必备的基础。
作为突破口,进行从各个不同的角度考察你对线程的掌握。
19.2.1 线程是什么?
操作系统的角度,我们可以简单的认为,线程是系统调度的最小单元,一个进程可以包含多个线程,作为任务真正运作者,有自己的栈(Stack),寄存器(Register),本地存储(Thread Local)等,但是会和进程内其他线程共享描述符,虚拟地址空间等。
具体实现中,线程分为内核线程,用户线程,Java的线程实现其实是与虚拟机相关的。
对于我们最熟悉的JDk,线程也进行了一个演进过程,基本上在Java 1.2 后,JDK已经抛弃了Green Thread,也就是用户调度的线程,现在的模型是一对一映射到操作系统内核线程。
19.2.2 Green Thread详细解释:
Green Thread模型的设计初衷是为了使Java程序能够在不依赖底层操作系统的情况下运行,并具备跨平台的能力。在Green Thread模型中,Java虚拟机自己实现了对线程的调度和管理,而不是依赖于底层操作系统的线程支持。
然而,由于Green Thread模型没有直接与操作系统内核进行交互,因此导致了一些限制和问题:
1.无法充分利用多核处理器的性能:由于Green Thread模型的线程调度和管理是由Java虚拟机自己完成的,它无法直接利用多核处理器的并行计算能力。在这种模型下,即使在具有多个物理核心的处理器上运行Java程序,所有的线程仍然只能通过单个物理核心来执行,不能实现真正的并行计算。
2.无法与底层操作系统进行充分的集成:Green Thread模型无法直接与底层操作系统进行交互,因此无法充分利用操作系统提供的各种线程调度算法和特性。
3.对资源的占用较大:由于Green Thread模型需要自己实现线程调度和管理,它需要占用较多的内存资源。每个Green Thread都需要分配一定的堆栈空间,而且它们的调度算法和状态维护也需要一定的计算和存储开销。
为了克服这些限制和问题,从Java 1.2版本开始,JDK采用了一对一映射到操作系统内核线程的模型,也称为"native thread"模型。这种模型能够更好地利用多核处理器的性能,并与底层操作系统进行充分的集成,提供更高效和可靠的线程支持。
Green Thread模型在提供跨平台能力方面具有优势,但无法充分利用多核处理器并与底层操作系统进行充分的集成。因此,JDK在Java 1.2之后放弃了Green Thread模型,转而使用一对一映射到操作系统内核线程的模型,以提供更强大和高效的线程支持。
二十. Java程序产生死锁的情况以及如何进行定位,修复?
20.1 典型回答
死锁是一种特定的程序状态,在实体之间,由于循环依赖导致彼此一直处于等待中,没有任何个体可以继续前进。
死锁不仅仅是在线程之间会发生,存在资源独占的进程之间同样也可能出现死锁。
通常来说,我们大多是聚焦在多线程场景中的死锁,指两个或多个线程之间,由于互相持有对方需要的锁,
20.1.1 定位死锁
定位死锁最常见的方式就是利用jstack工具获取线程栈,然后定位互相之间的依赖关系,进而找到死锁,如果是明细的死锁,我们可以通过jstack进行定位。
程序运行发生死锁后,绝大多数情况,无法在线进行解决,只能进行重启,修正程序本身的问题。
20.1.1 .1 详细解释:
当程序发生死锁时,通常情况下无法立即在运行时解决它,因为死锁是由于线程之间的资源竞争导致的相互等待,形成了一个循环依赖的状态。
死锁通常发生在多个线程同时请求一组共享资源,并且每个线程都持有一部分资源并等待其他线程释放它们所需的资源。当发生这种情况时,没有任何一个线程能够继续执行下去,它们被阻塞在等待资源释放的状态中,从而导致程序无法继续正常执行。
解决死锁问题需要针对程序本身进行修正,以消除或避免死锁的产生。以下是一些常见的方法:
1.分析和检测死锁:使用工具或技术来分析和检测死锁。例如,通过线程转储分析工具(如jstack)、死锁检测工具(如Java自带的jconsole、VisualVM或第三方工具)来查看线程的状态和死锁信息。
2.梳理锁的获取顺序:确保线程获取锁的顺序是一致的,避免出现循环依赖的情况。例如,如果线程A先获取锁1,再获取锁2,那么其他线程也应该按照相同的顺序获取这两个锁。这种预防措施可以减少死锁的发生。
3.避免长时间持有锁:尽量减少在持有锁的情况下进行耗时的操作,比如I/O操作或者远程调用。可以使用异步操作或者将操作拆分为更小的单元,以便在持有锁期间减少执行时间。
4.使用超时机制:在获取锁时设置一个超时时间,在等待超过一定时间后放弃获取锁并采取相应的处理策略。这可以避免线程无限期地等待锁而导致死锁。
死锁恢复策略:当检测到死锁时,程序可以采取恢复策略,例如释放已经获得的锁并回退一些操作,然后重新尝试执行。这个策略需要根据具体的业务场景来设计和实现。
尽管有以上的方法来预防和解决死锁问题,但有时候死锁发生的原因非常复杂,可能需要对程序进行彻底的重构才能解决。在这种情况下,重新启动程序是一种常见的解决方法,因为它可以清除死锁并重新开始执行。
当程序发生死锁时,无法在线进行解决的大多数情况下需要重启程序,并通过修正程序本身的问题来避免死锁的再次发生。这涉及到分析和检测死锁、优化锁的获取顺序、避免长时间持有锁、使用超时机制以及实施死锁恢复策略等方法。
20.1.2 如何在编程中尽量预防死锁?
20.1.2.1 小结 产生死锁的原因
1.互斥条件,类似Java中Monitor 都是独占的,要么是我用,要么是你用。
2.互斥条件是长期持有的,在使用结束前,自己不会释放,也不能被其他线程抢占。
3. 循环依赖关系,两个或者多个个体之间出现了锁的链条环。
借此我们分析避免死锁的思路和方法。
20.1.2.2 第一种方法-避免使用多个锁
尽可能避免使用多个锁,并且只有需要的时候我们才持有锁,嵌套的sychronized或者lock非常容易出问题。
20.1.2.2.1 详细解释通过例子:
假设我们有一个银行账户类,包含了账户余额和充值、提现等方法。为了避免多个线程同时访问同一个账户造成数据不一致,我们使用 synchronized 关键字加锁来处理并发访问问题。
如果在方法中有多重嵌套的 synchronized 代码块,就容易出现死锁的情况。例如,我们可能会这样实现账户转账的方法:
public void transfer (Account target,int amount) { synchronized (this) { synchronized(target){ this.withdraw(amount); target.deposit(amount); } } }
代码中,我们先锁定当前账户对象 this,以及另一个账户对象 target。如果两个线程同时调用 transfer 方法,且交换了账户参数,就可能出现死锁的情况。例如,线程A锁住了账户A,线程B锁住了账户B,然后线程A需要再锁住账户B才能继续执行,而线程B需要再锁住账户A才能继续执行,形成了互相等待的死锁状态。
20.1.2.2.2 解决方案:使用单一锁或者基于信号量的机制
可以使用银行账户类的静态对象作为锁,以确保所有线程都共享同一个锁对象
public class Account { private static Object lock = new Object(); // 静态锁对象 private int balance; public void transfer(Account target, int amount) { synchronized (lock) { // 使用静态锁对象加锁 this.withdraw(amount); target.deposit(amount); } } // 省略withdraw和deposit方法 }
这样,不论是哪个账户对象,都可以使用同一个静态锁对象来进行加锁,避免了多重嵌套的 synchronized 代码块。同时,只有在真正需要锁定资源的时候才会获取锁。这种方式可以很好地避免死锁问题,并提高了系统的并发性能。
20.1.2.2.2.1 使用单一锁或者基于信号量的机制可以解决同时持有多个锁所带来的死锁问题的分析。
1.避免循环依赖:当存在多个锁时,如果线程按照不同的顺序获取这些锁,就可能出现循环依赖的情况,导致死锁。而使用单一锁或者共享的锁对象,所有线程都按照相同的顺序获取锁,从而避免了循环依赖。
2.保持顺序性:使用单一锁或者共享的锁对象,可以确保在访问共享资源时只有一个线程获得锁,其他线程需要等待。这样就保证了对共享资源的访问是有序的,避免了竞争和冲突。
3.减少锁的数量:使用单一锁或者共享的锁对象,可以降低锁的数量,从而减少了系统中锁冲突的可能性。当只有一个锁时,线程之间的互斥性也更容易管理和控制。
使用单一锁或者共享的锁对象可以简化锁管理,避免死锁,并提高并发性能。通过确保所有线程按照相同的顺序获取锁,并在需要时才持有锁,可以更好地控制对共享资源的访问,保持程序的正确性和一致性。
20.1.2.2.3 使用静态锁对象注意点
private static Object lock = new Object(); 这行代码定义了一个静态锁对象 lock。静态意味着该对象是与类相关联的,而不是与实例对象相关联的。这意味着无论创建了多少个类的实例,它们都会共享同一个静态锁对象。
在多线程编程中,使用一个静态锁对象可以保证多个线程之间的同步和互斥。通过加锁和释放锁来管理对共享资源的访问,可以防止多个线程同时修改或读取共享资源而引发数据不一致的问题。
在上述的示例中,lock 对象被用作一个通用锁,用于同步银行账户的转账操作。这样,无论是哪个账户对象调用 transfer() 方法,它们都会用到同一个静态锁对象。使用静态锁对象可以确保不同的线程在执行转账操作时按顺序获取锁,从而避免死锁问题,并保持对共享资源的正确访问。
需要注意的是,在使用静态锁对象时,要确保所有相关的线程都使用同一个锁对象进行加锁操作,以避免相互竞争不同的锁对象导致的问题。
竞争不同的锁对象的例子:
private static Object lock1 = new Object(); private static Object lock2 = new Object(); public void thread1() { synchronized (lock1) { // 访问共享资源 } } public void thread2() { synchronized (lock2) { // 访问共享资源 } }
thread1() 方法使用 lock1 进行加锁,而 thread2() 方法使用 lock2 进行加锁。这种情况下,如果线程T1先获取到了 lock1,而线程T2先获取到了 lock2,那么它们之间就没有达到同步和互斥的效果。因为它们竞争的是不同的锁对象,不会相互阻塞等待,而是各自执行。
解决方案:
private static Object lock = new Object(); public void thread1() { synchronized (lock) { // 访问共享资源 } } public void thread2() { synchronized (lock) { // 访问共享资源 } }
thread1()
和 thread2()
方法都使用同一个静态锁对象 lock
进行加锁,确保了它们之间的同步和互斥效果。这样,当一个线程获取到了 lock
对象的锁时,另一个线程就必须等待,直到第一个线程释放了锁。
20.1.2.3 第二种方法-必须使用多个锁的时候,尽可能设计好锁的获取顺序。
如果必须使用多个锁,尽量设计好锁的获取顺序。
比较著名的就是银行家算法:
银行家算法是一种用于避免死锁的资源分配算法,它通过预先检查每个进程对资源的最大需求量以及系统当前可用资源量来进行资源分配。它的目标是确保资源分配是安全的,即不会发生死锁。
银行家算法的基本思想是,系统首先假设每个进程都能获得其最大需求量的所有资源,然后尝试执行进程,直到无法满足某个进程的资源需求。如果所有进程都能成功执行完毕,则认为资源分配是安全的。否则,就认为存在潜在的死锁。
下面是银行家算法的执行步骤:
初始化工作:
- 设置一个布尔型数组
finish[]
,用于标记每个进程是否执行完毕。初始时,所有进程都被标记为未执行完毕。 - 设置一个整型数组
work[]
,用于表示系统当前可用的资源数。初始时,work[]
的值等于系统当前可用资源量。
执行算法:
- 遍历每个进程,如果该进程未执行完毕且其所需资源量小于等于
work[]
,则认为可以执行该进程。
- 如果执行该进程后,系统能够满足所有进程的资源需求,则认为资源分配是安全的。
- 如果执行该进程后,系统无法满足某些进程的资源需求,则认为该资源分配是不安全的,可能会发生死锁。
如果某个进程执行完毕,则将该进程标记为已执行完毕,并将其已释放的资源加回到work[]
中。 - 重复以上步骤,直到所有进程都执行完毕或者不存在可以执行的进程。
20.1.2.3.1 银行家算法例子:
假定有五个进程{P0,P1,P2,P3,P4}和三类资源{A,B,C},各种资源的数量分别为10、5、7。在T0时刻的资源分配情况如下:
选择:p1-p3-p4-p2-p0的顺序
20.1.2.4 第三种方法-使用带超时的方法
在Java中使用带超时的方法避免锁时,可以使用tryLock(long timeout, TimeUnit unit)
方法来实现。这个方法尝试在指定的超时时间内获取锁并立即返回结果。如果无法获取锁,则会等待一段时间,直到超过指定的超时时间后返回失败。
举例子进行展示了如何在Java中使用带超时的方法避免锁:
import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class TimeoutLockExample { // 创建一个可重入锁对象 private static Lock lock = new ReentrantLock(); public static void doSomething() { boolean acquired = false; try { // 尝试获取锁,设置超时时间为5秒 acquired = lock.tryLock(5, TimeUnit.SECONDS); if (acquired) { // 在此处执行需要加锁的操作 System.out.println("执行需要加锁的操作"); } else { // 超时未能获取锁的处理逻辑 System.out.println("获取锁超时"); } } catch (InterruptedException e) { e.printStackTrace(); } finally { if (acquired) { // 释放锁 lock.unlock(); } } } public static void main(String[] args) { // 创建并启动线程 Thread thread = new Thread(() -> doSomething()); thread.start(); } }
我们使用tryLock(5, TimeUnit.SECONDS)方法尝试在5秒内获取锁。如果获取到锁,就可以执行需要加锁的操作;如果超过5秒仍未获取到锁,则会打印"获取锁超时"。
在使用tryLock()方法时,需要考虑逻辑的正确性和线程安全。当无法获取锁时,可以根据具体需求进行后续处理,例如等待一段时间后再次尝试或者放弃执行相关操作。
20.1.2.5 排查小技巧
20.1.2.5.1.当是死循环引起的其他线程阻塞,会导致cpu飙升,可以先看下cpu的使用率。
定位:比如Linux上,可以使用top命令配合grep Java之类,找到忙的pid;然后,转换成16进
制,就是jstack输出中的格式;再定位代码。
20.1.2.5.2.死循环死锁可以认为是自旋锁死锁的一种,其他线程因为等待不到具体的信号提示。导致线程一直饥饿。
这种情况下可以查看线程 cpu 使用情况,排查出使用 cpu 时间片最高的线程,再打出该线程
的堆栈信息,排查代码。
20.1.2.5.3. 基于互斥量的锁如果发生死锁往往 cpu 使用率较低,实践中也可以从这一方面进行排查。
20.1.2.5.3.1 详细解释-基于互斥量的锁
当使用互斥量的锁时,死锁是一个常见的问题。死锁指的是多个线程相互等待对方释放资源而无法继续执行的状态,从而导致程序无法正常运行。
在某些情况下,死锁可能会导致 CPU 使用率较低。这是因为在发生死锁时,线程会进入等待状态,不再进行任何工作。因此,CPU 不会被用于执行其他任务,而多个线程处于阻塞状态。
例子:
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class DeadlockExample { private static Lock lock1 = new ReentrantLock(); private static Lock lock2 = new ReentrantLock(); public static void main(String[] args) { Thread thread1 = new Thread(() -> { synchronized (lock1) { System.out.println("Thread 1: Holding lock 1..."); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (lock2) { System.out.println("Thread 1: Holding lock 1 and lock 2..."); } } }); Thread thread2 = new Thread(() -> { synchronized (lock2) { System.out.println("Thread 2: Holding lock 2..."); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (lock1) { System.out.println("Thread 2: Holding lock 2 and lock 1..."); } } }); thread1.start(); thread2.start(); } }
thread1持有lock1并尝试获取lock2,而thread2持有lock2并尝试获取lock1。这种情况下,两个线程相互等待对方释放资源,导致死锁的发生。
当程序运行时,在发生死锁后,CPU 使用率可能较低,因为两个线程都无法继续执行,只是在互相等待。通过观察 CPU 使用率可以初步判断是否存在死锁的可能性。
在实践中,如果发现 CPU 使用率较低,且程序存在使用互斥量的锁进行资源竞争的情况,可以考虑死锁的排查。可以使用各种工具和方法来分析线程状态、资源使用情况和线程堆栈信息,以确定是否发生了死锁,并找出导致死锁的原因,进而调整代码逻辑和资源管理策略以避免死锁的发生