Java并发基础常见面试题总结:线程与进程详解及Java程序员核心知识指南

本文已被收录于(63k+ Star):【Java 学习+面试指南】,此指南涵盖了大部分 Java 程序员需要掌握的核心知识。阅读原文便可获取最新更新!

Java并发基础常见面试题总结:线程与进程详解及Java程序员核心知识指南

这篇文章之前在 Github 上发布过。上次进行问卷调查时,很多人都希望我能制作一个菜单来对文章进行分类。因此,我在公众号又发送了一遍这篇文章。

1.1. 需明确何为进程。

进程是程序的一次执行情况。进程是系统运行程序的基本单位,所以进程具有动态性。系统运行一个程序,就意味着一个进程从开始创建,到进行运行,再到最终消亡的过程。

在 Java 里,当启动 main 函数的时候,也就意味着启动了一个 JVM 的进程。同时,main 函数所在的那个线程,是这个进程当中的一个线程,并且它也被称作主线程。

如下图所示,在 windows 系统中,我们可以通过查看任务管理器来清楚地看到当前正在运行的进程(也就是.exe 文件的运行情况)。

进程示例图片-Windows1.2. 何为线程?

线程和进程有相似之处,然而线程是比进程更小的执行单位。在一个进程执行的过程当中,能够产生多个线程。同类的多个线程与进程不同,它们共享进程的堆和方法区资源。每个线程都有自己的程序计数器、虚拟机栈和本地方法栈。所以,当系统产生一个线程,或者在各个线程之间进行切换工作时,负担比进程小得多。也正因如此,线程被称为轻量级进程。

Java 程序本身就是多线程的。我们能够借助 JMX 去查看一个普通的 Java 程序究竟包含哪些线程,以下是相关代码。

public class MultiThread {
public static void main(String[] args) {
// 获取 Java 线程管理 MXBean
ManagementFactory 获取到线程管理相关的 ThreadMXBean,即 threadMXBean 。
不需要获取同步相关的 monitor 和 synchronizer 信息,只需获取线程以及线程的堆栈信息。
ThreadInfo 数组 threadInfos 等于 threadMXBean 的 dumpAllThreads 方法,该方法会获取所有线程的信息。false, false);
// 遍历线程信息,仅打印线程 ID 和线程名称信息
for对于 threadInfos 中的每个 ThreadInfo threadInfo :
System.out.println("[" + threadInfo.getThreadId() + "] "获取线程名称的方式为 threadInfo.getThreadName()。
}
}
}

上述程序会输出如下内容(输出的内容或许会有所不同,无需过于纠结下面每个线程的具体作用,只需知晓 main 线程会执行 main 方法即可)。

[5] Attach Listener //添加事件
分发处理给 JVM 信号的线程是 Signal Dispatcher 。
调用对象 finalize 方法的是一个线程。
Reference Handler 会清除 reference 线程。
[1] main //main 线程,程序入口

从上面的输出内容能够得知:在一个 Java 程序的运行过程中,main 线程与多个其他线程是同时进行运行的。

2. 请简要描述线程与进程的关系,区别及优缺点?

从 JVM 角度说进程和线程之间的关系

2.1. 图解进程和线程的关系

下图展示的是 Java 内存区域。借助此图,我们从 JVM 的角度来阐述线程与进程之间的关系。倘若你对 Java 内存区域(运行时数据区)这部分知识了解得不够深入,那么可以阅读一下这篇文章:《可能是把 Java 内存区域讲得最清楚的一篇文章》[1]

从上图能够看出,一个进程内可以存在多个线程。多个线程能够共享进程的堆以及方法区(在 JDK1.8 之后是元空间)的资源。然而,每个线程都拥有自己的程序计数器、虚拟机栈和本地方法栈。

总结:线程是从进程划分出来的更小的运行单位。进程和线程有很大不同,一般来说各进程是独立的,然而各线程不一定独立,因为同一进程中的线程很可能会相互产生影响。线程的执行开销较小,但不利于对资源进行管理和保护;而进程则与之相反。

下面是该知识点的扩展内容!

思考这样一个问题:为何程序计数器、虚拟机栈以及本地方法栈是线程私有的呢?为何堆和方法区是线程共享的呢?

2.2. 程序计数器为什么是私有的?

程序计数器主要有下面两个作用:

字节码解释器通过改变程序计数器来依次读取指令,以此实现代码的流程控制。具体包括顺序执行、选择、循环以及异常处理等。

在多线程的情形下,程序计数器的作用是记录当前线程所处的执行位置。这样一来,当线程被切换回来时,就能够知晓该线程上次运行到了什么地方。

需要注意的是,若执行的是 native 方法,程序计数器记录的为 undefined 地址;而只有执行 Java 代码时,程序计数器记录的才是下一条指令的地址。

程序计数器是私有的。这样做主要是为了在进行线程切换之后,能够恢复到正确的执行位置。

2.3. 虚拟机栈和本地方法栈为什么是私有的?

所以,为了确保线程里的局部变量不会被其他线程所访问到。虚拟机栈是线程私有的,本地方法栈也是线程私有的。

2.4. 一句话简单了解堆和方法区

方法区也是所有线程共享的资源,主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

3. 说说并发与并行的区别?4. 为什么要使用多线程呢?

先从总体上来说:

再深入到计算机底层来探讨:

5. 使用多线程可能带来什么问题?

并发编程的目的在于提高程序的执行效率和运行速度。然而,并发编程并非总能提升程序的运行速度,它可能会遭遇诸多问题,例如内存泄漏、上下文切换、死锁以及受硬件和软件资源闲置问题的限制。

6. 说说线程的生命周期和状态?

Java 线程在其运行的生命周期的特定时刻,仅能处于以下 6 种不同状态中的某一种状态。(图源《Java 并发编程艺术》4.1.4 节)

Java 线程的状态

线程在其生命周期内并非固定处于某一种状态,而是会依据代码的执行情况在不同状态之间进行转换。Java 线程的状态变迁情况如下图所示,此图源自《Java 并发编程艺术》的 4.1.4 节。

Java 线程状态变迁

从上图能够得知:线程被创建之后,其会处于 NEW(新建)状态。当调用 start() 方法后,线程开始运行,此时线程处于 READY(可运行)状态。而处于可运行状态的线程,一旦获得了 CPU 时间片(timeslice),就会处于 RUNNING(运行)状态。

操作系统会隐藏 Java 虚拟机(JVM)中的 RUNNABLE 和 RUNNING 状态。它仅能看到 RUNNABLE 状态。(源自:HowToDoInJava[2]:Java Thread Life Cycle and Thread States[3])。因此,Java 系统通常会将这两个状态一起统称为 RUNNABLE(运行中)状态。

RUNNABLE-VS-RUNNING

线程执行 wait()方法后,线程会进入 WAITING(等待)状态。线程进入等待状态后,需依靠其他线程的通知才能返回到运行状态。TIME_WAITING(超时等待)状态,是在等待状态的基础上增加了超时限制。例如,通过 sleep(long millis)方法或 wait(long millis)方法,能够将 Java 线程置于 TIMED WAITING 状态。超时时间到达后,Java 线程会回到 RUNNABLE 状态。线程调用同步方法时,若未获取到锁,就会进入 BLOCKED(阻塞)状态。线程执行 Runnable 的 run()方法之后,会进入 TERMINATED(终止)状态。

7. 什么是上下文切换?

多线程编程中,通常线程的个数比 CPU 核心的个数要多。一个 CPU 核心在任何时刻只能被一个线程使用。为使这些线程都能被有效执行,CPU 采取的策略是为每个线程分配时间片,并以轮转的形式进行。当一个线程的时间片用完后,它就会重新处于就绪状态,将 CPU 让给其他线程使用,这个过程就是一次上下文切换。

概括来说,当前任务在执行完 CPU 时间片后会切换到另一个任务,在此之前它会先保存自身状态。这样,下次再切换回这个任务时,就可以加载该任务的状态。任务从保存状态到再加载状态的这个过程,就是一次上下文切换。

上下文切换一般是计算方面很密集的。这意味着它需要比较可观的处理器时间,在每秒几十次甚至上百次的切换过程中,每次切换都需要纳秒级别的时间。因此,上下文切换对于系统而言意味着会消耗大量的 CPU 时间,实际上,它可能是操作系统中时间消耗最为巨大的操作。

Linux 与其他操作系统相比,包括其他类 Unix 系统,具有很多优点。其中一项是,它在上下文切换和模式切换方面的时间消耗非常少。

8. 什么是线程死锁?如何避免死锁?8.1. 认识线程死锁

多个线程同时处于阻塞状态,其中有的线程或者全部线程都在等待某一资源被释放。因为线程被长时间地阻塞着,所以程序无法正常结束。

如下图所示,线程 A 持有资源 2 。线程 B 持有资源 1 。这两个线程都想申请对方的资源。所以它们会互相等待,进而进入死锁状态。

线程死锁示意图

以下通过一个例子来对线程死锁进行说明,代码模拟了上述图中死锁的情况(此代码来源于《并发编程之美》)。

public class DeadLockDemo {
private static Object resource1 = new Object();//资源 1
private static Object resource2 = new Object();//资源 2

public static void main(String[] args) {
new Thread(() -> {
synchronized (resource1) {
System.out 会打印出当前正在执行的线程。Thread.currentThread() 用于获取当前线程的引用。通过这样的方式,可以获取到关于当前线程的相关信息并进行输出或后续的处理。"get resource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource2");
synchronized (resource2) {
System.out.println(Thread.currentThread().getName()); // 获取当前线程的名称并输出"get resource2");
}
}
}, "线程 1").start();

new Thread(() -> {
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource1");
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
}
}
}, "线程 2").start();
}
}

Output

main 线程获取 resource1
Thread 获取了 resource2,其中涉及到线程 2、5 和 main
Thread[线程 1,5,main]处于等待状态以获取资源 2
Thread 在等待获取资源 1,其线程编号为 2、5 且属于 main 线程

线程 A 借助 synchronized (resource1) 获取到了 resource1 的监视器锁。接着,它通过 Thread.sleep(1000) 使自身休眠 1 秒。这样做的目的是让线程 B 有机会得以执行,之后线程 B 能够获取到 resource2 的监视器锁。线程 A 休眠结束后开始企图请求获取对方的资源,线程 B 休眠结束后也开始企图请求获取对方的资源,接着这两个线程就陷入了互相等待的状态,从而产生了死锁。上述例子符合产生死锁的四个必要条件。

学过操作系统的朋友都知道产生死锁必须具备以下四个条件:

互斥条件:该资源任意一个时刻只由一个线程占用。

一个进程在请求资源时被阻塞,那么它会将已经获得的资源保持在手中而不释放。

条件二:只有当线程自己将资源使用完毕之后,才会释放该资源。

若干进程之间存在一种关系,这种关系是头尾相接的循环等待资源的关系。

8.2. 如何避免线程死锁?

我们只要破坏产生死锁的四个条件中的其中一个就可以了。

破坏互斥条件

我们无法破坏这个条件,因为我们使用锁的目的就是让他们互斥,而临界资源需要互斥访问。

破坏请求与保持条件

一次性申请所有的资源。

破坏不剥夺条件

线程占用部分资源后,若进一步申请其他资源且申请不到,便可主动释放其所占有的资源。

破坏循环等待条件

通过按顺序申请资源来进行预防。先按照某一特定顺序申请资源,而在释放资源时则按照相反的顺序进行释放。以此来破坏循环等待条件。

我们对线程 2 的代码修改成下面这样就不会产生死锁了。

        new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
}
}
}, "线程 2").start();

Output

Thread[线程 1,5,main]get resource1
Thread[线程 1,5,main]waiting get resource2
main 线程获取 resource2
Thread 中的线程 2、5 和 main 获取了 resource1
Thread 中的线程 2、5 和 main 在等待获取资源 2。
Thread[线程 2,5,main]get resource2

程序执行完毕,退出代码为 0。

我们分析一下上面的代码为什么避免了死锁的发生?

线程 1 首先获取到了 resource1 的监视器锁,此时线程 2 无法获取。接着线程 1 去获取 resource2 的监视器锁,能够获取到。之后线程 1 释放了对 resource1 和 resource2 的监视器锁的占用,线程 2 就可以获取并执行了。这样就破坏了破坏循环等待条件,因此避免了死锁。

10. 为何我们在调用 start() 方法时会执行 run() 方法?为何我们不能直接调用 run() 方法呢?

这是一个非常经典的 java 多线程面试问题,在面试中时常会被问到。它很简单,然而很多人却答不上来。

创建一个 Thread,此时线程处于新建状态;调用 start() 方法后,会启动一个线程,使线程进入就绪状态,一旦分配到时间片就能开始运行;start() 会执行线程的相关准备工作,接着会自动执行 run() 方法的内容,这就是真正的多线程工作。直接执行 run()方法,会将 run 方法视为在 main 线程下的普通方法来执行,不会在某个线程中执行它,因此这并非多线程工作。

总结:要启动线程并让线程进入就绪状态,需调用 start 方法。而 run 方法只是对 thread 的一种普通方法调用,并且是在主线程中执行的。

你可能想看:
« 上一篇
下一篇 »

相关推荐

java中什么是冒泡,冒泡算法

2026年09月06日

26阅读

java中冒号,java双冒号

2026年09月06日

23阅读

java中的->,java中的是什么意思

2026年09月06日

24阅读

java中的super用法,Java中super的说明

2026年09月06日

27阅读

java中反射的应用,java中反射的应用场景

2026年09月06日

23阅读

java中开关的定义,Java关键字有哪些

2026年09月06日

24阅读