1:进程
1-1:什么是进程
进程是程序的一次执行过程,进程是程序运行和资源分配的基本单位。
1-2:进程的状态
多进程和多线程都分为五个阶段
- 创建
- 就绪
- 运行
- 阻塞
- 死亡
1-2-1:进程的状态变迁
1-3:什么是 PCB
存放进程的管理和控制信息的数据结构称为进程控制块。它是进程管理和控制的最重要的数据结构,每一个进程均有一个 PCB,在创建进程时,建立 PCB,伴随进程运行的全过程,直到进程撤消而撤消。
1-3-1:PCB 具体包含什么信息呢?
- 进程标识符 name
- 进程当前状态 status
- 进程相应的程序和数据地址
- 进程资源清单
- 进程优先级 priority
- CPU 现场保护区 cpustatus
- 进程同步与通信机制
- 进程所在队列 PCB 的连接字
- 与进程有关的其他信息
1-3-2:多个 PCB 是如何组织的呢?
- 线性表方式
线性表方式将所有进程控制块的首地址连续存储在一整块内存空间中,操作系统每次查找目标进程时,只需要遍历这张表,就可以找到相应的进程控制块,进而控制目标进程。 - 索引表方式
索引表方式可以看做是线性表方式的“改进版”,它根据当前各个进程不同的执行状态,分别建立就绪索引表、阻塞索引表等。 - 链接表方式
链接表方式会将处于相同执行状态的进程控制块通过指针串联成一个链表,从而分别组建成就绪链表、阻塞链表等。
1-4:进程的控制
对进程在生命周期中各种状态之间的转换进行有效的控制,它具有创建新进程、撤销已有进程、实现进程状态转换等功能。在操作系统中,一般把进程控制用的程序段称为原语,原语的特点是执行期间不允许中断,它是一个不可分割的基本单位。
- 创建原语:一个进程可以使用创建原语创建一个新的进程,前者称为父进程,后者称为子进程,子进程又可以创建新的子进程,构成新的子进程,构成新的父子关系。建立进程控制块 PCB:先申请一个空闲的 PCB 区域,将有关信息填入 PCB,置该进程为就绪状态,最后将它插入到就绪状态队列中去。
- 撤销原语:找到要被撤销的进程 PCB,将它从所在队列中消去。
- 阻塞原语:把进程运行状态转换为阻塞状态。首先应中断 CPU 执行,把 CPU 的当前状态保存到 PCB 的现场信息中,把它插入到该事件的等待队列中去。
- 唤醒原语:进程因为等待时间的发生而处于等待状态,当等待事件完成后,就用唤醒原语将其转换为就绪状态。具体操作过程:在等待队列中找到该进程,置该进程的当前状态为就绪状态,然后将它从等待队列中撤去并插入到就绪队列中排队,等待调度执行。
1-5:进程间通信
- 管道/匿名管道(Pipes) :用于具有亲缘关系的父子进程间或者兄弟进程之间的通信。
- 有名管道(Names Pipes) : 匿名管道由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了有名管道。有名管道严格遵循先进先出(first in first out)。有名管道以磁盘文件的方式存在,可以实现本机任意两个进程通信。
- 信号(Signal) :信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
- 消息队列(Message Queuing) :消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识。管道和消息队列的通信数据都是先进先出的原则。
与管道(无名管道:只存在于内存中的文件;命名管道:存在于实际的磁盘介质或者文件系统)不同的是消息队列存放在内核中,只有在内核重启(即,操作系统重启)或者显示地删除一个消息队列时,该消息队列才会被真正的删除。
消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取.比 FIFO 更有优势。消息队列克服了信号承载信息量少,管道只能承载无格式字 节流以及缓冲区大小受限等缺。 - 信号量(Semaphores) :信号量是一个计数器,用于多进程对共享数据的访问,信号量的意图在于进程间同步。这种通信方式主要用于解决与同步相关的问题并避免竞争条件。
- 共享内存(Shared memory) :使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。可以说这是最有用的进程间通信方式。
- 套接字(Sockets) : 此方法主要用于在客户端和服务器之间通过网络进行通信。套接字是支持 TCP/IP 的网络通信的基本操作单元,可以看做是不同主机之间的进程进行双向通信的端点,简单的说就是通信的两方的一种约定,用套接字中的相关函数来完成通信过程。
1-6:进程的内存结构
- 文本段:包含了进程运行的程序机器语言指令。文本段具有只读属性,以防止进程通过错误指针意外修改自身指令。因为多个进程可同时运行同一程序,所以又将文本段设为可共享,这样,一份程序代码的拷贝可以映射到所有这些进程的虚拟地址空间中。
- 初始化数据段:包含显示初始化的全局变量和静态变量。当程序加载到内存时,从可执行文件中读取这些变量的值。
- 未初始化数据段:包含了未进行显示初始化的全局变量和静态变量。程序启动之前,系统将本段内所有内存初始化为 0。出于历史原因,此段常被称为 BSS 段,这源于老版本的汇编语言助记符“block started by symbol”。将经过初始化的全局变量和静态变量与未初始化的全局变量和静态变量分开存放,其主要原因在于程序在磁盘上存储时,没有必要为未经初始化的变量分配存储空间。相反,可执行文件只需记录未初始化数据段的位置及所需大小,直到运行时再由程序加载器来分配空间。
- 栈(stack):是一个动态增长和收缩的段,有栈帧(stack frames)组成。系统会为每个当前调用的函数分配一个栈帧。栈帧中存储了函数的局部变量(所谓自动变量)、实参和返回值。
- 堆(heap):是可在运行时(为变量)动态进行内存分配的一块区域。堆顶端称为 program break。
2:线程
2-1:线程概念
多进程中每个进程拥有自己独立的代码和数据空间(进程上下文),而多线程共享代码和数据空间,每个线程有独立的运行栈和程序计数器 (PC)。共享变量使线程之间的通信比进程之间的通信更有效,更容易。此外,在某些操作系统中,线程更“轻量级”, 创建,撤销一个线程比启动新线程开销更小。
线程是进程的一个实体,是 CPU 调度和分派的基本单位,是比程序更小的能独立运行的基本单位。同一进程中的多个线程之间可以并发执行。
2-1-1:为什么使用线程
线程的创建和切换开销更小,因为线程共享代码段、数据段等内存空间。
2-1-2:线程的优缺点
优点:
- 创建和切换开销更小
- 线程间方便的通信机制
缺点:
- 调度时, 要保存线程状态,频繁调度, 需要占用大量的机时;
- 程序设计上容易出错(线程同步问题)。
2-2:三种线程的实现方式
- 实现 Runnable 接口
- 继承 Thread 接口
- 实现 Callable 接口
2-3:用户线程如何理解?
不需要内核支持而在用户程序中实现的线程,其不依赖于操作系统核心,应用进程利用线程库提供创建、同步、调度和管理线程的函数来控制用户线程。
不需要用户态/核心态切换,速度快,操作系统内核不知道多线程的存在,因此一个线程阻塞将使得整个进程(包括它的所有线程)阻塞。由于这里的处理器时间片分配是以进程为基本单位,所以每个线程执行的时间相对减少。
2-3-1:用户线程存在什么优势和缺陷?
优点:
- 线程的调度不需要内核直接参与,控制简单。
- 可以在不支持线程的操作系统中实现。
- 创建和销毁线程、线程切换代价等线程管理的代价比内核线程少得多。
- 允许每个进程定制自己的调度算法,线程管理比较灵活。
- 线程程能够利用的表空间和堆栈空间比内核级线多。
- 同一进程中只能同时有一个线程在运行,如果有一个线程使用了系统调用而阻塞,那么整个进程都会被挂起。另外,页面失效也会产生同样的问题。
缺点:
- 资源调度按照进程进行,多个处理机下,同一个进程中的线程只能在同一个处理机下分时复用。
2-4:内核线程如何理解?
由操作系统内核创建和撤销。内核维护进程及线程的上下文信息以及线程切换。一个内核线程由于 I/O 操作而阻塞,不会影响其它线程的运行。
2-4-1:内核线程存在什么优势和缺陷
优点:
- 当有多个处理机时,一个进程的多个线程可以同时执行。
缺点:
- 即使 CPU 在同一个进程的多个线程之间切换,也需要陷入内核,因此其速度和效率不如用户级线程。
2-5:用户线程和内核线程的对应关系
- 内核支持:用户级线程可在一个不支持线程的 OS 中实现;内核支持线程则需要得到 OS 内核的支持。亦即内核支持线程是 OS 内核可感知的,而用户级线程是 OS 内核不可感知的。
- 处理器分配:在多处理机环境下,对用户级线程而言,内核一次只为一个进程分配一个处理器,进程无法享用多处理机带来的好处;在设置有内核支持线程时,内核可调度一个应用中的多个线程同时在多个处理器上并行运行,提高程序的执行速度和效率。
- 调度和线程执行时间:设置有内核支持线程的系统,其调度方式和算法与进程的调度十分相似,只不过调度单位是线程;对只设置了用户级线程的系统,调度的单位仍为进程。
- 用户级线程执行系统调用指令时将导致其所属进程被中断,而内核支持线程执行系统调用指令时,只导致该线程被中断。
- 在只有用户级线程的系统内,CPU 调度还是以进程为单位,处于运行状态的进程中的多个线程,由用户程序控制线程的轮换运行;在有内核支持线程的系统内,CPU 调度则以线程为单位,由 OS 的线程调度程序负责线程的调度。
2-6:轻量级进程如何理解
轻量级进程(LWP)是建立在内核之上并由内核支持的用户线程,它是内核线程的高度抽象,每一个轻量级进程都与一个特定的内核线程关联。内核线程只能由内核管理并像普通进程一样被调度。
2-6-1: LWP 与用户线程的对应关系
LWP 与普通进程的区别也在于它只有一个最小的执行上下文和调度程序所需的统计信息,而这也是它之所以被称为轻量级的原因。而和线程相比,LWP 可以共享进程空间,像进程一眼调度。
2-7:线程同步
- 锁和同步
- 等待/通知机制:notify(),wait()
- 信号量:volatile
- 管道
- 其他
- join()
- sleep()
- ThreadLocal
- InheritableThreadLocal
2-7-1:wait/notify 机制
- wait():Object 类的静态方法,释放当前线程占有的机锁,进入阻塞状态。
- notify()/notifyAll():唤醒一个/全部等待该锁的线程,然后继续执行,执行完成后释放锁。
2-7-2:管道通信
- PipedInputStream 和 PipedOutputStream
- PipedReader 和 PipedWriter
3:线程与进程的区别
3-1:进程切换与线程切换的区别
进程切换与线程切换的一个最主要区别就在于进程切换涉及到虚拟地址空间的切换而线程切换则不会。因为每个进程都有自己的虚拟地址空间,而线程是共享所在进程的虚拟地址空间的,因此同一个进程中的线程进行线程切换时不涉及虚拟地址空间的转换。
3-2:为什么进程花销比线程花销大
进程都有自己的虚拟地址空间,把虚拟地址转换为物理地址需要查找页表,页表查找是一个很慢的过程,因此通常使用 Cache 来缓存常用的地址映射,这样可以加速页表查找,这个 cache 就是 TLB,Translation Lookaside Buffer,我们不需要关心这个名字只需要知道 TLB 本质上就是一个 cache,是用来加速页表查找的。
由于每个进程都有自己的虚拟地址空间,那么显然每个进程都有自己的页表,那么当进程切换后页表也要进行切换,页表切换后 TLB 就失效了,cache 失效导致命中率降低,那么虚拟地址转换为物理地址就会变慢,表现出来的就是程序运行会变慢,而线程切换则不会导致 TLB 失效,因为线程线程无需切换地址空间,因此我们通常说线程切换要比较进程切换块,原因就在这里。
4. 上下文切换
4-1:什么是上下文切换?
上下文切换指的是内核(操作系统的核心)在 CPU 上对进程或者线程进行切换。上下文切换过程中的信息被保存在进程控制块(PCB-Process Control Block)中。PCB 又被称作切换帧(SwitchFrame)。上下文切换的信息会一直被保存在 CPU 的内存中,直到被再次使用。
4-2:CPU 上下文切换
就是先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。
而这些保存下来的上下文,会存储在系统内核中,并在任务重新调度执行时再次加载进来。这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。
4-3:进程的上下文
进程是由内核来管理和调度的,进程的切换只能发生在内核态。所以,进程的上下文不仅包括了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的状态。
因此,进程的上下文切换就比系统调用时多了一步:在保存内核态资源(当前进程的内核状态和 CPU 寄存器)之前,需要先把该进程的用户态资源(虚拟内存、栈等)保存下来;而加载了下一进程的内核态后,还需要刷新进程的虚拟内存和用户栈。
4-3-1:发生进程上下文切换有哪些场景?
- 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,就会被系统挂起,切换到其它正在等待 CPU 的进程运行。
- 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行。
- 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度。
- 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行
- 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序。
4-4:线程上下文
- 当进程只有一个线程时,可以认为进程就等于线程。
- 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源。这些资源在上下文切换时是不需要修改的。
- 另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。
5:调度
5-1:什么是调度
当计算机系统处于就绪状态的用户进程数多于 CPU 数时,就会产生多个进程或线程同时竞争 CPU 的结果。假设现在只有一个 CPU 可用,那么操作系统就必须选择下一个要运行的进程。完成这种选择工作的这一部分称为调度程序,该程序使用的算法称为调度算法。
5-2:什么时候会发生调度
- 正在执行的进程运行完毕。
- 正在执行的进程由于某种错误而终止。
- 时间片用完,即有一个进程从运行状态变为就绪状态。
- 正在执行的进程调用阻塞原语将自己阻塞起来,即一个进程从运行状态进入阻塞状态。
- 创建了新的进程,即有一个新的进程进入就绪队列。
- 正在执行的进程调用了唤醒原语操作激活了等待资源的进程,即一个等待状态的进程变为就绪状态。
5-3:调度原则
- 调度算法
- 执行模型:程序在 CPU 突发和 I/O 中交替
- 调度算法的评价指标
- 人们要求“更快”的服务
- 调度算法的要求(均衡、折中)
5-4:进程调度算法
- 先来先服务调度算法(FCSFS):从就绪队列选取最先进入该队列的进程。
- 短作业(进程)优先调度算法(SPN):从就绪队列中选出一个估计运行时间最短的进程。
- 最高响应比优先(HRRN):根据
响应比 = (等待时间+要求服务时间)/ 要求服务时间
计算出响应比。响应比高的算法会先执行。我们称之为「高响应比优先」。 - 时间片轮转法(RR):将所有的就绪进程按先来先服务的原则排成一个队列,每次调度时,把 CPU 分配给队首进程,并令其执行一个时间片。时间片的大小从几 ms 到几百 ms。当执行的时间片用完时,由一个计时器发出时钟中断请求,调度程序便据此信号来停止该进程的执行,并将它送往就绪队列的末尾;然后,再把处理机分配给就绪队列中新的队首进程,同时也让它执行一个时间片。这样就可以保证就绪队列中的所有进程在一给定的时间内均能获得一时间片的处理机执行时间。换言之,系统能在给定的时间内响应所有用户的请求。
- 优先级调度算法(HPF):在进程等待队列中选择优先级最高的来执行。
- 多级反馈队列调度算法:将时间片轮转与优先级调度相结合,把进程按优先级分成不同的队列,先按优先级调度,优先级相同的,按时间片轮转,轮转后未完成的进程降低优先级。
6:互斥与同步
6-1:互斥概念
互斥亦称间接制约关系。当一个进程进入临界区使用临界资源时,另一个进程必须等待, 当占用临界资源的进程退出临界区后,另一进程才允许去访问此临界资源。
临界资源:一次仅允许一个进程使用的资源
6-1-1:临界区
对临界资源的访问,必须互斥地进行,在每个进程中,访问临界资源的那段代码称为临界区。为了保证临界资源的正确使用,可以把临界资源的访问过程分成四个部分:
- 进入区:为了进入临界区使用临界资源,在进入区要检查可否进入临界区,如果可以进入临界区,则应设置正在访问临界区的标志,以阻止其他进程同时进入临界区。
- 临界区:进程中访问临界资源的那段代码,又称临界段。
- 退出区:将正在访问临界区的标志清除。
- 剩余区:代码中的其余部分。
6-2:同步概念
同步亦称直接制约关系,它是指为完成某种任务而建立的两个或多个进程,这些进程因为需要在某些位置上协调它们的工作次序而等待、传递信息所产生的制约关系。进程间的直接制约关系就是源于它们之间的相互合作。
6-2-1:同步的原理
当一个线程访问临界区时,判断是否有其他线程进入临界区,如果没有它才可以进入临界区,如果已经有线程进入临界区它会被同步挂起,直到进入的线程离开。
6-2-1:线程同步的方式
- 同步方法
- 同步代码块
- volatile
- 重入锁(Lock 类)
- ThreadLock
- 阻塞队列
- 原子变量
6-2-2:同步方法和同步代码块的区别是什么
- 同步方法通过 ACC_SYNCHRONIZED 关键字隐式的对方法进行加锁。当线程要执行的方法被标注上 ACC_SYNCHRONIZED 时,需要先获得锁才能执行该方法。
- 同步代码块通过 monitorenter 和 monitorexit 执行来进行加锁。当线程执行到 monitorenter 的时候要先获得所锁,才能执行后面的方法。当线程执行到 monitorexit 的时候则要释放锁。
每个对象自身维护这一个被加锁次数的计数器,当计数器数字为 0 时表示可以被任意线程获得锁。当计数器不为 0 时,只有获得锁的线程才能再次获得锁。即可重入锁。
6-2-3:在监视器内部,是如何做线程同步的?
- 偏向锁
- 轻量级锁
- 重量级锁
6-3:同步与互斥的区别
- 互斥:是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。
- 同步:是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。在大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的。少数情况是指可以允许多个访问者同时访问资源。
7:生产者与消费者问题(同步问题)
- wait()/notify()
- 可重入锁 ReentrantLock - await()/signal()
- 阻塞队列 BlockingQueue
- 信号量
- 管道
8:并发级别(并行和并发)
8-1:并行与并发概念
- 并行:同一时间段多个线程同时执行。
- 并发:同一时间间隔内多个线程交替执行。
9:并发特性
- 阻塞:必须等待其他线程执行完成才能执行。
- 无饥饿:所以线程按抵达顺序依次执行。
- 无障碍:多个线程同时执行,如果发现自己的数据被破坏,对修改进行回滚。
- 无锁:多个线程同时修改,但是只有一个线程能在有限步内修改成功走出临界区。
- 无等待:所有线程都必须在有限步内完成操作。
10:多线程
10-1:为什么要使⽤多线程呢
减少程序响应时间,更快的处理事务。
10-1-1:使⽤多线程可能带来什么问题?
- 线程过多:频繁的上下文切换会影响性能。
- 数据竞争:多个线程读写统一数据时,需要同步。
- 死锁:复数线程互相占有其他线程需要的资源。
- 饿死:一个或多个线程永远没有机会执行。
- 伪共享:多个线程的读写映射到一个同一缓存时,频繁的读写会导致不停的更新缓存,导致性能降低。
10-2:多线程公共用一个数据注意什么
- 原子性
- 可加性
- 有序性
10-2-1:如何确保 N 个线程可以访问 N 个资源同时又不导致死锁?
- 所有资源空闲时才能申请锁
- 所有线程顺序获取锁
10-2-2:单 cpu 上多线程效率和单线程比如何
并发编程的目的是为了让程序运行的更快,但是,并不是启动更多的线程就能让程序最大限度地并发运行。并且,在进行并发编程时,如果希望通过多线程执行任务让程序运行的更快,将会面临非常多的挑战,比如上下文切换问题、死锁等。所以单 cpu 多线程不一定比单线程快,但是也不一定慢。
11:线程的基本操作
11-1:说说 sleep() 方法和 wait() 方法区别和共同点?
sleep():方法是线程类(Thread)的静态方法,让调用线程进入睡眠状态,让出执行机会给其他线程,等到休眠时间结束后,线程进入就绪状态和其他线程一起竞争 cpu 的执行时间。因为 sleep() 是 static 静态的方法,他不能改变对象的机锁,当一个 synchronized 块中调用了 sleep () 方法,线程虽然进入休眠,但是对象的机锁没有被释放,其他线程依然无法访问这个对象。
wait ():wait () 是 Object 类的方法,当一个线程执行到 wait 方法时,它就进入到一个和该对象相关的等待池,同时释放对象的机锁,使得其他线程能够访问,可以通过 notify,notifyAll 方法来唤醒等待的线程。
11-2:yield join notify notifyAll
- yield():Thread 类方法,让出时间片
- sleep():Thread 类方法,线程休眠
- join():执行子线程
- wait():Object 类静态方法,当前线程释放锁,进入等待池
- notify()/notifyAll():唤醒等待池中的一个/所有线程,同其他线程随机竞争锁
11-3:为什么我们调⽤ start() 方法时会执⾏ run() 方法,为什么我们不能直接调⽤ run() 方法
start()方法会创建一个新线程,run()方法只是一个普通方法,直接调用相当于在当前线程调用一个方法。
11-4:中断线程方法(36 期)
interrupt()
11-5:用户线程和守护线程以及两者区别
守护线程都是为 JVM 中所有非守护线程的运行提供便利服务:
- 只要当前 JVM 实例中尚存在任何一个用户线程没有结束,守护线程就全部工作。
- 只有当最后一个用户线程结束时,守护线程随着 JVM 一同结束工作。
- 如果 User Thread 已经全部退出运行了,只剩下 Daemon Thread 存在了,虚拟机也就退出了。
12:创建线程
12-1:创建线程的方式
- 实现 Runnable 接口,重写 run 方法
- 继承 Thread 接口,重写 run 方法
- 实现 Callable 接口,重写 call 方法
12-1-1:实现 Runnable 接⼝和 Callable 接⼝的区别
Runnable 接口没有返回值,Callable 接口有返回值,可以更方便的进行异常处理等工作。
12-1-2:实现 Runnable 接口比继承 Thread 类所具有的优势
实际使用两者都是一样的,只是一个是继承一个是实现,只有这两方面的区别。
12-2:创建线程的对比
采用实现 Runnable、Callable 接口的方式创见多线程时,优势是:
- 线程类只是实现了 Runnable 接口或 Callable 接口,还可以继承其他类。在这种方式下,多个线程可以共享同一个 target 对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将 CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。
劣势是:
- 编程稍微复杂,如果要访问当前线程,则必须使用 Thread.currentThread()方法。
使用继承 Thread 类的方式创建多线程时优势是:
- 编写简单,如果需要访问当前线程,则无需使用 Thread.currentThread()方法,直接使用 this 即可获得当前线程。
劣势是:
- 线程类已经继承了 Thread 类,所以不能再继承其他父类。
13:线程安全
13-1:什么是线程安全
多线程执行环境下,保证对共享资源的操作能够得到正确执行。
13-1-1:举例说明线程不安全
i++,i–。
14:synchronized 关键字
14-1:synchronized 关键字
- 同步方法
- 同步代码块
14-2:JDK1.6 优化有哪些?
- 偏向锁
- 轻量级锁
- 锁消除
- 锁加粗
14-3:底层原理
对于同步方法,JVM 采用 ACC_SYNCHRONIZED 标记符来实现同步。 对于同步代码块。JVM 采用 monitorenter、monitorexit 两个指令来实现同步。
14-4:synchronized 的优势
- 实现简单,自动获取锁,释放锁。
14-5:synchronized 锁的膨胀(升级)过程
- 默认为偏向锁状态,通过比较 mark word 来获取锁。
- 如果有其余线程竞争偏向锁,偏向锁会被安全的撤销为无锁状态。
- 无锁状态时,会通过自旋锁的方式来获取锁,这个等级是轻量级锁。
- 轻量级锁的状态下,有其他线程同时访问锁,会升级为重量级锁。重量级锁使用阻塞来达成同步。
14-6:使用 Synchronized 关键字需要注意什么
- synchronized 可能会出现死锁。
- synchronized 的作用范围应适当调整。
- synchronized 的锁对象不能为空。
14-7:synchronized 在内存层面,是如何实现加锁和释放锁的?
- 偏向锁会在对象头的 mark word 区域存放线程 id,如果 id 相同表示只有一个线程访问,如果有其余线程竞争,就会进化为轻量级锁。
- 轻量级锁状态下 mark word 区域会存放指向栈中锁记录的指针。
- 重量级锁会存放指向互斥量的指针。
15:volatile 关键字
- 直接读写主存数据,实现可见性。
- 禁止指令重排,实现有序性。
15-1:基本原理
通过内存屏障,确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面。即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。
也是通过内存屏障,实现和主存数据的同步,保证了可见性。
15-1-1:缓存一致性协议
为了缓解 CPU 和内存之间速度的不匹配问题,引入了多级缓存概念。而缓存一致性就是为了保证多级缓存中的数据一致性。
15-2:为什么要是用 volatile 关键字
volatile 是比 synchronized 更轻量的同步方法,同时 synchonized 只能保证一定的有序性,而 volatile 通过禁止指令重排实现了更严格的有序性。volatile 也能实现变量的修改可见性。
15-3:volatile 的作用
- 可见性
- 有序性
15-4:原子性
volatile 虽然直接对主存数据进行读写,但是它是不能保证原子性的。
15-4-1:volatile 为什么不保证原子性吗
因为在 volatile 读取数据进行中间计算时,如果有其余线程修改主存的值,第一个线程的计算结果还是不受影响的。
15-4-2:为什么其他线程能感知到变量更新
每个 CPU 不断嗅探总线上传播的数据来检查自己缓存值是否过期了,如果处理器发现自己的缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从内存中把数据读取到处理器缓存中。
15-5:举例说一下指令重排
例如单例模式中使用的 volatile 就是为了有序性,正常一个对象的创建的过程是:1.分配内存空间–>2.初始化零值–>3.设置对象头。指令重排可能就是变成 1–>3–>2。
这种情况下如果有其他线程获取单例就可能出现错误。
15-6:happen-before 原则是什么
对于两个操作 A 和 B,这两个操作可以在不同的线程中执行。如果 A Happens-Before B,那么可以保证,当 A 操作执行完后,A 操作的执行结果对 B 操作是可见的。
15-7:volatile 的典型应用场景
- 信号:用于保证其他线程读取最新数据
- 双重校验锁
16:synchronized 与其他的区别
16-1:谈谈 synchronized 和 ReentrantLock 的区别
- synchronized 是关键字,reentrantlock 是类。
- synchronized 自动释放锁,reentrantlock 手动释放。
- synchronized 可重入,非公平,不可中断,reentrantlock 可重入,公平,可中断。
16-2:synchronized 关键字和 volatile 关键字的区别
- synchronized 修饰方法和代码块,volatile 修饰变量。
- synchronzied 保证原子性,可见性,有序性,volatile 保证可见性,有序性。
17:线程池–死锁
17-1:使⽤线程池的好处
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要等待线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
17-2:线程池增长策略
核心线程已满就加入等待队列,等待队列已满就加入线程,达到最大线程数就按照拒绝策略拒绝。
17-3:线程池拒绝策略
- AbortPolicy:丢弃任务并抛出 RejectedExecutionException 异常 (默认)。
- DiscardPolicy:也是丢弃任务,但是不抛出异常。
- DiscardOldestPolicy:丢弃队列最前面的任务,执行后面的任务。
- CallerRunsPolicy:由调用线程处理该任务。
17-4:线程池参数
源码如下,一共有 7 个参数:
- corePoolSize:核心线程数量
- maximumPoolSize:最大线程数量
- keepAliveTime:当线程数大于核心线程数时,多余的空闲线程在终止之前等待新任务的最长时间
- unit: keepAliveTime 参数的时间单位
- workQueue:工作队列
- threadFactory:创建线程使用的工厂
- handler:拒绝策略
1 | public ThreadPoolExecutor(int corePoolSize, |
17-5:线程池处理流程
17-6:设计线程池
- IO 密集型:2n
- CPU 密集型:n+1
17-7:线程池中的线程出现异常
- 使用 execute 执行会抛出堆栈异常,使用 submit 执行,会返回异常,可以使用 Future.get()获取。
- 不会影响线程池里的其他线程执行。
- 线程池会移除错误线程,创建新的线程放入线程池中。
17-8:线程池的风险
- 死锁
- 资源不足
- 并发错误
- 线程泄露
- 请求过载
17-9:死锁
17-9-1:什么是线程死锁
死锁是指两个或两个以上的进程(线程)在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程(线程)称为死锁进程(线程)。
17-9-2:产生死锁的条件
- 互斥条件:进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一进程所占用。
- 请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。
- 环路等待条件:在发生死锁时,必然存在一个进程–资源的环形链。
17-9-3:如何解决线程死锁问题
- 资源一次性分配:一次性分配所有资源,这样就不会再有请求了(破坏请求条件)。
- 只要有一个资源得不到分配,也不给这个进程分配其他的资源(破坏请求保持条件)。
- 可剥夺资源:即当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源(破坏不可剥夺条件)。
- 资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件)。
17-10:线程池单例问题
可以通过常见的懒汉式单例创建线程池
1 | import java.util.concurrent.Executors; |
18:锁
18-1:锁
Java 中通过锁实现多线程处理业务时的线程同步和互斥。
18-2:乐观锁与悲观锁
- 乐观锁:每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在提交更新的时候会判断一下在此期间别人有没有去更新这个数据。
- 悲观锁:每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻止,直到这个锁被释放。
18-2-1:两种锁的使用场景
- 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
- 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
18-2-2:乐观锁常见的两种实现方式
- 直接比较当前值和预期值是否相同,相同则继续处理,但是可能会出现 ABA 问题。
- 通过版本号或者时间戳进行判断。
18-2-3:乐观锁的缺点
- ABA 问题。CAS 需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是 A,后来变成了 B,然后又变成了 A,那么 CAS 进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA 问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
JDK 从 1.5 开始提供了 AtomicStampedReference 类来解决 ABA 问题,具体操作封装在 compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。 - 循环时间长开销大。CAS 操作如果长时间不成功,会导致其一直自旋,给 CPU 带来非常大的开销。
- 只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS 能够保证原子操作,但是对多个共享变量操作时,CAS 是无法保证操作的原子性的。
Java 从 1.5 开始 JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行 CAS 操作。
18-3:自旋锁
自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
18-3-1:自旋锁的优缺点
缺点:
- 如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗 CPU。使用不当会造成 CPU 使用率极高。
- 上面 Java 实现的自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在“线程饥饿”问题。
优点:
- 不会进行线程切换,不会使线程进行阻塞状态,减少了上下文切换的损失。
18-3-2:自旋锁的升级——自适应自旋
自旋锁在 Java1.6 中改为默认开启,并引入了自适应的自旋锁。
自适应意味着自旋的次数不在固定,而是由前一次在同一个锁上的自旋时间和锁的拥有者的状态共同决定。
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很可能再次成功的,进而它将会允许线程自旋相对更长的时间。
如果对于某个锁,线程很少成功获得过,则会相应减少自旋的时间甚至直接进入阻塞的状态,避免浪费处理器资源。
18-3-3:自旋锁使用场景
在多核/多 CPU 的系统上,特别是大量的线程只会短时间的持有锁的时候,在使线程上下文切换上浪费大量的时间,也许会显著降低程序的运行性能。使用自旋锁,线程可以充分利用调度程序分配的时间片(不需要进入阻塞状态), 以达到更高的处理能力和吞吐量。
18-4:可重入锁(递归锁)
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者 class),不会因为之前已经获取过还没释放而阻塞。
18-4-1:可重入锁使用场景
- 如果发现该操作已经在执行中则不再执行(有状态执行):例如定时任务/页面交互,避免多次执行。
- 如果发现该操作已经在执行,等待一个一个执行(同步执行,类似 synchronized)。
- 如果发现该操作已经在执行,则尝试等待一段时间,等待超时则不执行(尝试等待执行)。
- 如果发现该操作已经在执行,等待执行。这时可中断正在进行的操作立刻释放锁继续下一操作。
18-4-2:可重入锁如果加了两把,但是只释放了一把会出现什么问题?
程序卡死,线程不能出来,也就是说我们申请了几把锁,就需要释放几把锁。
18-4-3:如果只加了一把锁,释放两次会出现什么问题?
会报错,java.lang.IllegalMonitorStateException。
18-5:读写锁
通过 ReentrantReadWriteLock 类来实现。为了提高性能, Java 提供了读写锁,在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,在一定程度上提高了程序的执行效率。读写锁分为读锁和写锁,多个读锁不互斥,读锁与写锁互斥,这是由 jvm 自己控制的。
18-6:公平锁与非公平锁
公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。
非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。
18-6-1:公平锁与非公平锁优缺点
优点: 非公平锁的性能高于公平锁。公平锁中会从队列首位唤醒线程,而非公平锁可能会直接获取锁,避免上下文切换带来的消耗。
缺点: 公平锁有可能造成线程饥饿(某个线程很长一段时间获取不到锁)。
18-6-2:公平锁与非公平锁使用场景
非公平锁能用于提升效率,但是对于执行时间较长的程序,节省的时间忽略不计,但是会造成线程饥饿。
18-7:共享锁
即读锁,多个线程可以同时读取内容。
18-7-1:共享锁使用场景
限制公共资源访问人数,例如登陆人数,数据库连接池。
18-8:独占锁
即写锁,只有一个线程可以进行读写,其他线程都会被排斥。
18-9:重量级锁
通过阻塞实现同步。
18-9-1:重量级锁使用场景
多线程同时并发的情况下保证数据同步。
18-10:轻量级锁
通过乐观锁的 CAS 操作保证数据同步。
18-10-1:轻量级锁优缺点
在并发线程较少时保证数据同步。
18-11:偏向锁
保存当前线程 id,比较 id 是否相同。
18-11-1:偏向锁优缺点
节约资源,提高执行效率。
18-12:分段锁
JDK1.7 中 ConcurrentHashMap 原理:它内部细分了若干个小的 HashMap,称之为段(Segment)。默认情况下一个 ConcurrentHashMap 被进一步细分为 16 个段,既就是锁的并发度。如果需要在 ConcurrentHashMap 添加一项 key-value,并不是将整个 HashMap 加锁,而是首先根据 hashcode 得到该 key-value 应该存放在哪个段中,然后对该段加锁,并完成 put 操作。在多线程环境中,如果多个线程同时进行 put 操作,只要被加入的 key-value 不存放在同一个段中,则线程间可以做到真正的并行。
JDK1.8 中 ConcurrentHashMap 直接对数组中的节点加锁。
18-13:互斥锁
与悲观锁,写锁同义,只有一个线程能够访问。
18-14:同步锁
同步锁与互斥锁同义,表示并发执行的多个线程,在同一时间内只允许一个线程访问共享数据。
18-15:死锁
死锁是一种现象:如线程 A 持有资源 x,线程 B 持有资源 y,线程 A 等待线程 B 释放资源 y,线程 B 等待线程 A 释放资源 x,两个线程都不释放自己持有的资源,则两个线程都获取不到对方的资源,就会造成死锁。
Java 中的死锁不能自行打破,所以线程死锁后,线程不能进行响应。所以一定要注意程序的并发场景,避免造成死锁。
18-16:锁粗化
锁粗化是一种优化技术: 如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作都是出现在循环体体之中,就算真的没有线程竞争,频繁地进行互斥同步操作将会导致不必要的性能损耗,所以就采取了一种方案:把加锁的范围扩展(粗化)到整个操作序列的外部,这样加锁解锁的频率就会大大降低,从而减少了性能损耗。
18-17:锁消除
锁消除是一种优化技术:就是把锁干掉。当 Java 虚拟机运行时发现有些共享数据不会被线程竞争时就可以进行锁消除。
那如何判断共享数据不会被线程竞争?
利用逃逸分析技术:分析对象的作用域,如果对象在 A 方法中定义后,被作为参数传递到 B 方法中,则称为方法逃逸;如果被其他线程访问,则称为线程逃逸。
在堆上的某个数据不会逃逸出去被其他线程访问到,就可以把它当作栈上数据对待,认为它是线程私有的,同步加锁就不需要了。
18-18:提高锁性能的方法
- 减少锁持有时间
- 降低锁粒度
- 用读写分离锁替换独占锁
- 锁分离
- 锁粗化
19:ThreadLocal
19-1:什么是 ThreadLocal,优势在哪里
ThreadLocal 是线程本地存储,在每个线程中都创建了一个 ThreadLocalMap 对象,每个线程可以访问自己内部 ThreadLocalMap 对象内的 value。
Synchronized 用于线程间的数据共享,而 ThreadLocal 则用于线程间的数据隔离。
19-2:ThreadLocal 的实现原理
每个线程内部都有一个 Map,Map 里面存储线程本地对象(key)和线程的变量副本(value)。Thread 内部的 Map 是由 ThreadLocal 维护的,由 ThreadLocal 负责向 map 获取和设置线程的变量值。
19-3:ThreadLocal 内存泄露问题
所有 Entry 对象都被 ThreadLocalMap 类的实例化对象 threadLocals 持有,当 ThreadLocal 对象不再使用时,ThreadLocal 对象在栈中的引用就会被回收,一旦没有任何引用指向 ThreadLocal 对象,Entry 只持有弱引用的 key 就会自动在下一次 YGC 时被回收,而此时持有强引用的 Entry 对象并不会被回收。
简而言之: threadLocals 对象中的 entry 对象不在使用后,没有及时 remove 该 entry 对象 ,然而程序自身也无法通过垃圾回收机制自动清除,从而导致内存泄漏。
19-3-1:ThreadLocal 如何防止内存泄漏?
只要在使用完 ThreadLocal 对象后,调用其 remove 方法删除对应的 Entry,即可从根本解决问题。
20:无锁-CAS、Atomic
20-1:CAS
20-1-1:CAS 使用时存在的问题以及解决方案
- 问题:ABA 问题,即将 A 改为 B,再改为 A,如果直接通过原值比较替换,就丢失了替换 B 的过程。
- 解决方案:版本号或者时间戳。
20-2:原子类原理
CAS 即乐观锁。
20-2-1:为什么要使用原子类
不会阻塞线程从而不会带来 CPU 上下文切换的性能开销。
20-2-2:原子类的作用?
和锁相同,保证并发情况下的线程安全。
20-3:基本数据类型原子类的优势
不需要加锁就能实现线程安全。
20-4: unsafe 类
Java 无法直接访问底层操作系统,而是通过本地(native)方法来访问。不过尽管如此,JVM 还是开了一个后门,JDK 中有一个类 Unsafe,底层是使用 C/C++写的,它提供了硬件级别的原子操作。Unsafe 为我们提供了访问底层的机制,这种机制仅供 java 核心类库使用,而不应该被普通用户使用。
UnSafe 的功能主要有:(1)实例化一个类;(2)修改私有字段的值;(3)抛出 checked 异常;(4)使用堆外内存;(5)CAS 操作;(6)阻塞/唤醒线程;(7)内存屏障
20-4-1:为什么是 unsafe
直接操纵内存,意味着实例化出来的对象不会受到 JVM 的管理,不会被 GC,需要手动进行回收,容易出现内存泄露的问题。
20-4-2:Unsafe 的实例怎么获取?
如果你尝试创建 Unsafe 类的实例,基于以下两种原因是不被允许的。
- Unsafe 类的构造函数是私有的;
- 虽然它有静态的 getUnsafe()方法,但是如果你尝试调用 Unsafe.getUnsafe(),会得到一个 SecutiryException。这个类只有被 JDK 信任的类实例化。
但是可以通过反射获取
1 | Field f = Unsafe.class.getDeclaredField("theUnsafe"); //Internal reference |
20-4-3:讲一讲 Unsafe 中的 CAS 操作?
1 | public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5); |
以 compareAndSwapInt 为例,在对象 var1 指定偏移量 var2 处读取一个 int 值 real,如果 var4=real,则用 var5 更新这个 real,并返回 true,否则返回 false。
20-5:unsafe 的阻塞/唤醒操作?
LockSupport 类中的 park 与 unpark 方法对 unsafe 中的 park 与 unpark 方法做了封装,LockSupport 类中有各种版本 pack 方法,但最终都调用了 Unsafe.park()方法。
21:AQS(队列同步器)
队列同步器 AbstractQueuedSynchronizer(以下简称同步器),是用来构建锁或者其他同步组件的基础框架,它使用了一个 int 成员变量表示同步状态,通过内置的 FIFO 队列来完成资源获取线程的排队工作,并发包的作者(Doug Lea)期望它能够成为实现大部分同步需求的基础。
21-1:对 AQS 原理分析
AQS 核心思想是,如果被请求的共享资源空闲,那么就将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定状态;如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是 CLH 队列的变体实现的,将暂时获取不到锁的线程加入到队列中。
21-2:AQS 对资源的共享⽅式
- Exclusive(独占):只有一个线程能只执行,又分为公平锁和非公平锁。
- Share(共享):多个线程可同时执行。
21-3:AQS 组件
- Semaphore(信号量)-允许多个线程同时访问: synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。
- CountDownLatch (倒计时器): CountDownLatch 是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。
- CyclicBarrier(循环栅栏): CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。CyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用 await()方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。
22:并发容器
22-1:JDK 提供的并发容器总结
- ConcurrentHashMap
- CopyOnWriteArrayList
- CopyOnWriteArraySet
- ConcurrentLinkedQueue
- BlockingQueue
- ConcurrentSkipListMap
- Vector
- Collections 工具类
22-2:CopyOnWriteArrayList 是如何做到的?
CopyOnWrite 容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行 Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对 CopyOnWrite 容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以 CopyOnWrite 容器也是一种读写分离的思想,读和写不同的容器。
22-3:BlockingQueue
22-3-1:什么是阻塞队列
阻塞队列:当线程队列是空时,从队列中获取元素的操作将会被阻塞;当线程队列是满时,往队列里添加元素的操作将会被阻塞。
22-3-2:阻塞队列的常用类
- ArrayBlockQueue:基于数组的阻塞队列实现,内部为定长数组,生产者和消费者共用同一个锁对象。
- LinkedBlockingQueue:基于链表的阻塞队列实现,对于生产者和消费者分别采用独立的锁控制同步,二者可以并行执行。
24-3-3:手写堵塞队列
25:快速失败与安全失败
- 快速失败(fail-fast)
在使用迭代器对集合对象进行遍历的时候,如果 A 线程正在对集合进行遍历,此时 B 线程对集合进行修改(增加、删除、修改),或者 A 线程在遍历过程中对集合进行修改,都会导致 A 线程抛出 ConcurrentModificationException 异常。
原因是迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变 modCount 的值。
每当迭代器使用 hashNext()/next() 遍历下一个元素之前,都会检测 modCount 变量是否为 expectedModCount 值,是的话就返回遍历;否则抛出异常,终止遍历。 - 安全失败(fail-safe)
采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。
由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,故不会抛 ConcurrentModificationException 异常