前言:
- 本模块是在下学习Java并发的一些记录和思考,若有不正之处,请多多谅解并欢迎指正。
- 开头会抛出几道常见面试题,引出本篇的内容。
- 每个问题都有属于你的答案。
- 如果你有想法或建议,可以评论或者私信我 : ) wangjie2yd@gmail.com
进程与线程
面试问题
Q : 线程和进程的区别?
Q : 多线程的优缺点?
进程和线程
1.1进程
1.1.1进程的由来
进程的由来涉及到操作系统的发展历史,早期的计算机只能用来解决数学计算问题。因为很多大量的计算通过人力去完成是很耗时间和人力成本的。最初的计算机,只能接受一些特定指令,用户输入一个指令,计算机就做一个操作,假设用户输入指令和读取数据需要10s,计算可能只需要0.01s,计算机绝大多数都处于等待用户输入的状态,显然这样效率很低。
那么能不能把一系列需要输入的指令都提前写好,形成一个清单,然后一次性交给计算机,这样计算机就可以不断读取指令来进行相应的操作,于是,批处理操作系统就诞生了。这样就提高了任务处理的便捷性,减少用户输入指令时间。
但是仍然存在一个问题:数据读取(I/O操作)所需要的CPU资源非常少。大部分工作是分派给DMA(Direct Memory Access)直接内存完成的。在DMA读取数据的时候,CPU是空闲的,只能等待当前的任务读取完数据才能继续执行,这样就白白浪费了CPU资源,于是人们在想,能否让CPU在等待A任务读取数据期间,去执行B任务,当A任务读取完后,暂停B任务,继续执行A任务?
可以打开Windows的任务管理器,复制一个大文件,你会发现,磁盘利用率会持续增大,而CPU的利用率则会稍微增大一些,然后恢复正常,这个变化过程就是CPU给DMA分派任务
这样就有一个新的问题,原来每次都是一个程序在计算机里面运行,也就说内存中始终只有一个程序的运行数据。而如果想要任务A执行I/O操作的时候,让任务B去执行,必然内存中要装入多个程序,那么如何处理呢?多个程序使用的数据如何进行辨别呢?并且当一个程序运行暂停后,后面如何恢复到它之前执行的状态呢?
这个时候人们就发明了进程,用进程来对应一个程序,每个进程对应一定的内存地址空间,并且只能使用它自己的内存空间,各个进程间互不干扰。并且进程保存了程序每个时刻的运行状态,这样就为进程切换提供了可能。当进程暂停时,它会保存当前进程的状态(比如进程标识、进程的使用的资源等),在下一次重新切换回来时,便根据之前保存的状态进行恢复,然后继续执行。
1.1.2并行和并发
进程的出现,使得操作系统的并发成为可能,注意这里说的是 并发 而不是 并行 。这两者在概念上截然不同。
并发: 从宏观上看起来同一时间段,多个任务都在执行,但具体的某一时间点,只有一个任务在使用CPU(针对单核CPU来说),cpu把这个时间段分片给多个任务,由于整个时间段很小,所以我们感觉CPU好像在同时运行这些任务。
并行: 同一时间点,多个任务同时执行,单核CPU无法做到,而多核CPU可以。
1.1.3从应用层面理解进程
进程是程序的一次执行过程,是操作系统分配资源的基本单位。
在现代的操作系统比如 Windows、Linux、UNIX、Mac OS X等,都是支持多任务的操作系统。意味着操作系统可以同时运行多个任务,无论你的CPU是单核单线程还是多核多线程,你都可以一边听歌,一边玩游戏。这个时候至少有2个任务(可以理解为2个进程,但实际可能会多于2个进程,例如Chrome浏览器,你每打开一个标签页,Chrome浏览器应用都会创建一个新的进程)同时在运行。还有很多任务悄悄地在后台同时运行着,只是桌面上没有显示而已。这就是多任务的并发。
当然现在的CPU大多都是多核多线程,有的还支持超线程技术(将一个物理处理器在软件层变成两个逻辑处理器),使一个CPU核心可以并行两个线程,但系统所运行的任务数远远多于CPU的核心数,所以,操作系统也会自动把很多任务轮流调度到每个核心上执行,所以并发和并行在系统运行时是一直存在的。
打开Windows任务管理器,可以看到操作系统上运行的任务,如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bi09BgwJ-1574408321601)
Google Chrome(10),10就代表着这个任务下有10个进程
后台进程(98),代表着有98个后台进程在默默运行着
1.2线程
1.2.1线程的由来
进程的出现,解决了操作系统的并发问题,使得操作系统的性能得到了大大的提升。有新的问题出现了,因为一个进程在一个时间段内只能做一件事情,如果一个进程有多个子任务,只能逐个地去执行这些子任务。比如对于一个监控系统来说,它不仅要把图像数据显示在画面上,还要与服务端进行通信获取图像数据,还要处理人们的交互操作。如果某一个时刻该系统正在与服务器通信获取图像数据,而用户又在监控系统上点击了某个按钮,那么该系统就要等待获取完图像数据之后才能处理用户的操作,如果获取图像数据需要耗费10s,那么用户就只有一直在等待。显然,对于这样的系统,人们是无法满足的。
那么可不可以将这些子任务分开执行呢?即在系统获取图像数据的同时,如果用户点击了某个按钮,则会暂停获取图像数据,而先去响应用户的操作(因为用户的操作往往执行时间很短),在处理完用户操作之后,再继续获取图像数据。人们就发明了线程,让一个线程去执行一个子任务,这样一个进程就包括了多个线程,每个线程负责一个独立的子任务,这样在用户点击按钮的时候,就可以暂停获取图像数据的线程,让UI线程响应用户的操作,响应完之后再切换回来,让获取图像的线程得到CPU资源。从而让用户感觉系统是同时在做多件事情的,满足了用户对实时性的要求。
换句话说,进程让操作系统的并发性成为可能,而线程让进程的内部并发成为可能。但是要注意,一个进程虽然包括多个线程,但是这些线程是共同享有进程占有的资源和地址空间的。进程是操作系统进行资源分配的基本单位,而线程是操作系统进行调度的执行单位。
1.2.2Java中的线程
Java语言内置了多线程支持,一个Java程序实际上是一个JVM进程(也可以称为JVM实例),一般来说名字默认为java.exe或者javaw.exe(windows下可以通过任务管理器查看)。
Java采用的是单线程编程模型,JVM进程用一个主线程来执行main()方法。 main方法所在的主线程只是其中的一个线程,JVM进程在启动时,同时会创建很多其他的线程。
我们可以通过 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 线程和多个其他线程同时运行。
在main()方法内部,我们还可以启动多个自己的线程。这就是多线程的由来。同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈。所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fCIh2tFi-1574408321601)
堆和方法区:堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (所有对象都在这里分配内存)和成员变量,方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
程序计数器:在多线程的情况下,通过线程私有的程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。
虚拟机栈: 每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。
1.3多线程的优缺点
1.3.1多线程的优势
- 发挥多处理器的强大能力,提高资源利用率
当下,单核CPU的通过提高时钟频率来提升性能已经越来越难,既然单核CPU的性能已经很难提升,那不妨尝试通过提升CPU核心的数量,处理器厂商在单个芯片上放置多个处理器核,以横向扩展来提升计算机的整体性能,再往后可能就是增加CPU的数量以及优化CPU之间的协作。
操作系统的基本调度单位是线程,多核处理器的出现,使得同一个程序的多个线程可以被调度到多个 CPU 上同时运行。因此,多线程的程序可以通过提高处理器资源的利用率来提升系统的吞吐率。其实,多线程程序也有助于在单处理器系统上获得更高的吞吐率,如果程序的一个线程在等待 I/O 操作的完成,另一个线程可以继续运行,使程序能够在 I/O 阻塞期间继续运行。(关于阻塞的理解,后边会谈到)
- 解耦程序开发,程序设计更简单
如果在程序中只包含一种类型的任务,那么比包含多种不同类型任务的程序要更容易编写,错误更少,也更容易测试。
在程序中,如果我们为每种类型的任务都分配一个专门的线程,那么可以形成一种串行执行的假象,并将程序的执行逻辑与调度机制的细节,交替执行的操作,异步 I/O 以及资源等待等问题分离开来。通过使用线程,可以将复杂并且异步的工作流进一步分解为一组简单并且同步的工作流,每个工作流在一个单独的线程中运行,并在特定的同步位置进行交互。
Servlet和RMI(Remote Method Invocation) 框架就是一个很好的例子。框架负责解决一些细节问题,包括请求管理、线程创建、负载均衡等,并在正确的时刻将请求分发给正确的应用程序组件(对应的一个具体Servlet)。编写 Servlet 的开发人员不需要了解有多少请求在同一时刻被处理,也不需要了解套接字的输入(出)流是否被阻塞。当调用 Servlet 的 service 方法来响应 Web请求时,可以以同步方式来处理这个请求,就好像它是一个单线程的程序。这种方式简化了组件的开发,大大降低框架学习门槛。
- 异步化事件处理,程序响应更快
同步与异步是关于指令执行顺序的。
同步是指代码调用IO操作时,必须等待IO操作完成才返回的调用方式。
异步是指代码调用IO操作时,不必等IO操作完成就返回的调用方式。
异步则需要多线程,多CPU或者非阻塞IO的支持。
借鉴一个例子,来理解同步和异步:
同步:你妈让你烧壶水,于是你一直在旁边等着水开 这个时候你什么都不能做
异步:还是烧一壶水,你找一个小A来帮你盯着,你就可以去做别的事了
在这个场景下,你是负责处理请求的线程,小A就是一个新的线程来执行烧水的任务
1.3.2多线程带来的风险
- 数据安全性问题
在线程安全性的定义中,最核心的概念就是正确性。当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么这个类就是线程安全的。
// 线程不安全类示例:
@NotThreadSafe
public class UnsafeSequence {
private int value;
/** Returns a unique value. */
public int getNext() {
return value++;
}
}
虽然 递增运算 “value++” 看上去是单个操作,但实际上它包含三个独立的操作:读取 value, 将 value 加 1,并将计算结果写入 value。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Qseg6nfo-1574408321602)
开始value的值为9,A,B 两个线程都执行getNext()方法,预期的返回值应该是11,因为执行了两次++操作。
但是在多线程的环境下,A线程从进程读取 value=9后,发生了线程切换,B线程开始执行,并且也读到了value=9
A线程开始执行,此处value=9已经记录到A线程内部,它把线程内部的9进行+1,变成了10,B线程也进行了同样的操作
此时A线程继续执行,在执行value=10前,进程里堆中的value还是9,执行value=10后,堆中的value就变成10
线程B执行最后的操作,将堆中的value也修改为10,但其实这个时候value已经被A线程修改为10。
这样AB两个线程的getNext()都执行完了,但是堆中的value并不是预期的11,而是10,这就是线程安全问题。
- 活跃性问题
活跃性问题的关注目标在于 某件正确的事情最终会发生,我片面理解为程序会不会卡住,从而无法执行后边的内容,例如你代码中无意造成死循环,从而使循环之后的代码无法得到执行。
线程将带来一些其他活跃性问题包括死锁、活锁和饥饿。这些问题都会让你的程序卡住,无法进行下去。
下面简单描述一下这三个问题,在后边的篇章会有具体的内容。
死锁:你要上厕所,但里面有人,而且把厕所门从里边锁住了,如果他一直不出来,你一直等待,这样就发生死锁了。
活锁:你走在路上,迎面走来一个人,你想给他让路,结果他也想给你让路,你俩都做了这个让路操作后,发现他还是在
你面前,于是你又让路,他的想法也和你一样。于是乎,你俩就处在一直给对方让路的操作中,谁也无法通过,这个就是活锁问题。
饥饿:线程获取到CPU的时间分片才能执行,CPU分配时间分片是随机的,哪个线程抢到哪个就运行,如果这个线程运气比较差,永远抢不到。这个就是饥饿问题。
- 性能问题
性能问题关注的是:正确的事情能够尽快发生。性能问题包括多个方面,例如响应不灵敏,吞吐率过低,资源消耗过高等。在多线程程序中,当线程调度器挂起活跃线程并转而运行另一个线程时,就会频繁出现上下文切换操作(Context Switch),这种操作会导致 CPU 时间更多的花在线程调度上而非线程的运行上。
上下文切换操作
多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。
概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换会这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。
上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。
所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。
Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。
Reference
《Java 并发编程实战》
《Java 编程思想(第4版)》
https://blog.csdn.net/justloveyou_/article/details/53448157
https://snailclimb.gitee.io/javaguide/#/