并发编程:线程

一、什么是线程和进程

何为进程

进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。

在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。

如下图所示,在 Windows 中通过查看任务管理器的方式,我们就可以清楚看到 Windows 当前运行的进程(.exe 文件的运行)。

何为线程

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

Java 程序天生就是多线程程序,我们可以通过 JMX 来看看一个普通的 Java 程序有哪些线程,代码如下。

public class MultiThread {
	public static void main(String[] args) {
		// 获取 Java 线程管理 MXBean
	ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
		// 不需要获取同步的 monitor 和 synchronizer 信息,仅获取线程和线程堆栈信息
		ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
		// 遍历线程信息,仅打印线程 ID 和线程名称信息
		for (ThreadInfo threadInfo : threadInfos) {
			System.out.println("[" + threadInfo.getThreadId() + "] " + threadInfo.getThreadName());
		}
	}
}

上述程序输出如下(输出内容可能不同,不用太纠结下面每个线程的作用,只用知道 main 线程执行 main 方法即可):

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

从上面的输出内容可以看出:一个 Java 程序的运行是 main 线程和多个其他线程同时运行

 

二、Java 线程和操作系统的线程有啥区别?

JDK 1.2 之前,Java 线程是基于绿色线程(Green Threads)实现的,这是一种用户级线程(用户线程),也就是说 JVM 自己模拟了多线程的运行,而不依赖于操作系统。由于绿色线程和原生线程比起来在使用时有一些限制(比如绿色线程不能直接使用操作系统提供的功能如异步 I/O、只能在一个内核线程上运行无法利用多核),在 JDK 1.2 及以后,Java 线程改为基于原生线程(Native Threads)实现,也就是说 JVM 直接使用操作系统原生的内核级线程(内核线程)来实现 Java 线程,由操作系统内核进行线程的调度和管理。

我们上面提到了用户线程和内核线程,考虑到很多读者不太了解二者的区别,这里简单介绍一下:

  • 用户线程:由用户空间程序管理和调度的线程,运行在用户空间(专门给应用程序使用)。
  • 内核线程:由操作系统内核管理和调度的线程,运行在内核空间(只有内核程序可以访问)。

顺便简单总结一下用户线程和内核线程的区别和特点:用户线程创建和切换成本低,但不可以利用多核。内核态线程,创建和切换成本高,可以利用多核。

一句话概括 Java 线程和操作系统线程的关系:现在的 Java 线程的本质其实就是操作系统的线程

线程模型是用户线程和内核线程之间的关联方式,常见的线程模型有这三种:

  1. 一对一(一个用户线程对应一个内核线程)
  2. 多对一(多个用户线程映射到一个内核线程)
  3. 多对多(多个用户线程映射到多个内核线程)

在 Windows 和 Linux 等主流操作系统中,Java 线程采用的是一对一的线程模型,也就是一个 Java 线程对应一个系统内核线程。 

三、线程与进程的关系,区别及优缺点?

下图是 Java 内存区域,通过下图我们从 JVM 的角度来说一下线程和进程之间的关系。

从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的方法区 (JDK1.8 之后的元空间)资源,但是每个线程有自己的程序计数器虚拟机栈本地方法栈

总结: 线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。

下面来思考这样一个问题:为什么程序计数器虚拟机栈本地方法栈是线程私有的呢?为什么堆和方法区是线程共享的呢?

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

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

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

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

所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置

虚拟机栈和本地方法栈为什么是私有的?
  • 虚拟机栈: 每个 Java 方法在执行之前会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
  • 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。

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

堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

四、如何创建线程?

一般来说,创建线程有很多种方式,例如继承Thread类、实现Runnable接口、实现Callable接口、使用线程池、使用CompletableFuture类等等。

不过,这些方式其实并没有真正创建出线程。准确点来说,这些都属于是在 Java 代码中使用多线程的方法。

严格来说,Java 就只有一种方式可以创建线程,那就是通过new Thread().start()创建。不管是哪种方式,最终还是依赖于new Thread().start()

关于这个问题的详细分析可以查看这篇文章:https://mp.weixin.qq.com/s/NspUsyhEmKnJ-4OprRFp9g

五、线程的生命周期和状态

Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:

  • NEW: 初始状态,线程被创建出来但没有被调用 start()
  • RUNNABLE: 运行状态,线程被调用了 start()等待运行的状态。
  • BLOCKED:阻塞状态,需要等待锁释放。
  • WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
  • TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
  • TERMINATED:终止状态,表示该线程已经运行完毕。

线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。

Java 线程状态变迁图

由上图可以看出:线程创建之后它将处于 NEW(新建) 状态,调用 start() 方法后开始运行,线程这时候处于 READY(可运行) 状态。可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 RUNNING(运行) 状态。

 在操作系统层面,线程有 READY 和 RUNNING 状态;而在 JVM 层面,只能看到 RUNNABLE 状态(图源:HowToDoInJavaopen in new windowJava Thread Life Cycle and Thread Statesopen in new window),所以 Java 系统一般将这两个状态统称为 RUNNABLE(运行中) 状态 。

为什么 JVM 没有区分这两种状态呢? (摘自:Java 线程运行怎么有第六种状态? - Dawell 的回答open in new window ) 现在的时分(time-sharing)多任务(multi-task)操作系统架构通常都是用所谓的“时间分片(time quantum or time slice)”方式进行抢占式(preemptive)轮转调度(round-robin 式)。这个时间分片通常是很小的,一个线程一次最多只能在 CPU 上运行比如 10-20ms 的时间(此时处于 running 状态),也即大概只有 0.01 秒这一量级,时间片用后就要被切换下来放入调度队列的末尾等待再次调度。(也即回到 ready 状态)。线程切换的如此之快,区分这两种状态就没什么意义了

 

  • 当线程执行 wait()方法之后,线程进入 WAITING(等待) 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态。
  • TIMED_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,比如通过 sleep(long millis)方法或 wait(long millis)方法可以将线程置于 TIMED_WAITING 状态。当超时时间结束后,线程将会返回到 RUNNABLE 状态。
  • 当线程进入 synchronized 方法/块或者调用 wait 后(被 notify)重新进入 synchronized 方法/块,但是锁被其它线程占有,这个时候线程就会进入 BLOCKED(阻塞) 状态。
  • 线程在执行完了 run()方法之后将会进入到 TERMINATED(终止) 状态。

六、什么是线程上下文切换?

线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说到过的程序计数器,栈信息等。当出现如下情况的时候,线程会从占用 CPU 状态中退出。

  • 主动让出 CPU,比如调用了 sleep(), wait() 等。
  • 时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。
  • 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
  • 被终止或结束运行

这其中前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换

上下文切换是现代操作系统的基本功能,因其每次需要保存信息恢复信息,这将会占用 CPU,内存等系统资源进行处理,也就意味着效率会有一定损耗,如果频繁切换就会造成整体效率低下。

七、Thread#sleep() 方法和 Object#wait() 方法对比

共同点:两者都可以暂停线程的执行。

区别

  • sleep() 方法没有释放锁,而 wait() 方法释放了锁
  • wait() 通常被用于线程间交互/通信,sleep()通常被用于暂停执行。
  • wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。sleep()方法执行完成后,线程会自动苏醒,或者也可以使用 wait(long timeout) 超时后线程会自动苏醒。
  • sleep()Thread 类的静态本地方法,wait() 则是 Object 类的本地方法。为什么这样设计呢?下一个问题就会聊到。

八、为什么 wait() 方法不定义在 Thread 中?

wait() 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象(Object)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(Object)而非当前的线程(Thread)。

类似的问题:为什么 sleep() 方法定义在 Thread 中?

因为 sleep() 是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。

九、可以直接调用 Thread 类的 run 方法吗?

这是另一个非常经典的 Java 多线程面试问题,而且在面试中会经常被问到。很简单,但是很多人都会答不上来!

new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

总结:调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。

  • 21
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值