Java并发基础常见面试题总结:线程与进程详解及Java程序员核心知识指南
本文已被收录于(63k+ Star):【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 的一种普通方法调用,并且是在主线程中执行的。