1. 线程基础知识
1.1 线程和进程的区别
- 进程是资源分配的最小单位,一个应用至少有一个进程,进程之间有着相互独立的内存空间。一个进程挂了不影响另一个进程。线程是程序执行的最小单位,即资源调度的最小单位;一个进程一般由多个线程来执行任务,这多个线程之间共享一个相同的地址空间。进程的创建和切换比线程的创建和切换的成本开销要小得多。
- 通信便利程度:线程之间的通信更方便,同一进程下的线程共享全局变量、静态变量等数据,而进程之间的通信需要以通信的方式(IPC)进行,不过如何处理好同步与互斥是编写多线程程序的难点;
1.2 并行与并发的区别
目标都是最大化CPU的使用率
并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。所以无论从微观还是从宏观来看,二者都是一起执行的。
并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。
1.2 线程的同步互斥
线程同步是指线程之间采用某种方式相互制约建立预期的执行顺序或联系的一种方式。
线程互斥是指对于共享的进程系统资源,在各单个线程访问时的排它性。
1.3 上下文切换(Context switch)
上下文是线程或进程在任何一时刻的运行环境信息,包括程序运行到哪一行,各个相关变量的信息等,这些信息存储在内存中。上下文切换是指CPU(中央处理单元)从一个进程或线程到另一个进程或线程的切换过程中,对进程或线程的运行环境的处理过程。其中,进程上下文切换的开销比线程切换大的多,一般为几十甚至上百毫秒,线程的切换开销大概在5-10毫秒。
上下文切换可以更详细地描述为内核(即操作系统的核心)对CPU上的进程(包括线程)执行以下活动:
1. 暂停当前进程或线程(以下简称线程)的处理,并将该进程的CPU状态(即上下文)存储在内存中的某个地方。
2. 从内存中获取下一个进程的上下文,并在CPU的寄存器中恢复它。
3. 返回到程序计数器指示的位置(即返回到进程被中断的代码行)以恢复进程。
上下文切换是多任务操作系统的一个基本特性。在多任务操作系统中,多个进程似乎同时在一个CPU上执行,彼此之间互不干扰。这种并发的错觉是通过快速连续发生的上下文切换(每秒数十次或数百次)来实现的。这些上下文切换发生的原因是进程自愿放弃它们在CPU中的时间,或者是调度器在进程耗尽其CPU时间片时进行切换的结果。
上下文切换通常是计算密集型的。就CPU时间而言,上下文切换对系统来说是一个巨大的成本,实际上,它可能是操作系统上成本最高的操作。因此,操作系统设计中的一个主要焦点是尽可能地避免不必要的上下文切换。与其他操作系统(包括一些其他类unix系统)相比,Linux的众多优势之一是它的上下文切换和模式切换成本极低。
2. 并发三大特性
并发编程Bug的源头:可见性、原子性和有序性问题
2.1 可见性
当一个线程修改了共享变量的值,其他线程能够看到修改的值。 Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方法来实现可见性的。
如何保证可见性
- 通过 volatile 关键字保证可见性(本质是通过内存屏障保证)。
- 通过 内存屏障保证可见性。
- 通过 synchronized 关键字保证可见性(本质是通过内存屏障保证)。
- 通过 Lock保证可见性(本质是通过内存屏障保证)。
- 通过 final 关键字保证可见性,如Integer(本质是通过新建变量必须刷新读的原则)。
- 通过Thread.yild()方法让出CPU(本质是通过上下文切换保证)
- 通过Thread.sleep()(本质是通过内存屏障保证)。
- 通过while循环多次延长程序执行时间,使得工作内存中的缓存失效。
保证可见性的方式很多,但有些方式是不可靠的,为保证程序的健壮性,我们一般使用volatile和JAVA的锁机制来保证可见性,这是一种兜底的做法。
2.2 有序性
即程序执行的顺序按照代码的先后顺序执行。 JVM 存在指令重排,所以存在有序性问题。
如何保证有序性
- 通过 volatile 关键字保证可见性。
- 通过 内存屏障保证可见性。
- 通过 synchronized关键字保证有序性。
- 通过 Lock保证有序性。
保证有序性同样本质上只有两种方式,一种是通过内存屏障禁止jvm指令的重排序,一种是加锁,确保线程逻辑执行的有序性,这样,即使单个线程内部发生了指令重排序,但有单线程顺序一致性的保证,可保证单线程执行结果不会被改变,配合锁机制,则多线程执行结果也不会被改变。
2.3 原子性
一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。 在Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作(64位处理器)。 不采取任何的原子性保障措施的自增操作并不是原子性的。
如何保证原子性
- 通过 synchronized 关键字保证原子性。
- 通过 Lock保证原子性。
- 通过 CAS保证原子性。
保证原子性说白了就是两种方式,通过CAS(Lock的本质是CAS)和锁机制保证的
3 JMM定义
Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),它是一种抽象的规范、规则。不仅定义了多线程之间数据交互的模型,还规定了多线程读写数据必须遵守的操作流程、原则。各个操作系统、硬件都遵循这个规范,可以屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果, JMM是围绕原子性、有序性、可见性展开的。
3.1 JMM内存模型
多线程之间并不能直接做信息交互,而是通过主内存这个交互介质来实现数据信息交互的。一个线程读取数据时,最开始一定先从多线程共享的主内存中读取到本地内存中作为一个副本,在本地内存中完成数据修改后回写到主内存中。但出于对读写性能的考虑,线程并非每次读写都从主内存中读取最新值,每次修改后也不一定会立马刷到主内存中,因此,在多核CPU下就产生了并发数据可见性的问题(单核CPU下不存在数据可见性问题)。
3.2 内存交互操作
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:
- lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
- unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
- write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
- use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
- 如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
- 不允许read和load、store和write操作之一单独出现
- 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
- 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
- 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。
4. volatile的内存语义
4.1 volatile的特性
- 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
- 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性(基于这点,我们通过会认为volatile不具备原子性)。volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具有原子性。
- 有序性:对volatile修饰的变量的读写操作前后加上各种特定的内存屏障来禁止指令重排序来保障有序性。
在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许volatile变量与普通变量重排序。为了提供一种比锁更轻量级的线程之间通信的机制, JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。
4.2 volatile写-读的内存语义
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
- 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。
4.3 volatile可见性实现原理
JMM内存交互层面实现
volatile修饰的变量的read、load、use操作和assign、store、write必须是连续的,即修改后必须立即同步回主内存,使用时必须从主内存刷新,由此保证volatile变量操作对多线程的可见性。
代码层面实现
volatile保证可见性的原理是使用了内存屏障storeload,storeload里面调了fence方法,但在X86 操作系统中,fence里面使用lock指令代替了fence。
硬件层面实现
通过lock前缀指令,会锁定变量缓存行区域并写回主内存,这个操作称为“缓存锁定”,缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据。一个处理器的缓存回写到内存会导致其他处理器的缓存无效。
lock前缀指令的作用
1. 确保后续指令执行的原子性。在Pentium及之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很大。在新的处理器中,Intel使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低lock前缀指令的执行开销。
2. LOCK前缀指令具有类似于内存屏障的功能,禁止该指令与前面和后面的读写指令重排序。
3. LOCK前缀指令会 等待它之前所有的指令完成、并且所有缓冲的写操作写回内存 (也就是将store buffer中的内容写入内存)之后才开始执行,并且根据缓存一致性协议,刷新store buffer的操作会导致其他cache中的副本失效。
指令重排序
Java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等, 那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。
指令重排序的意义: JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。 在编译器与CPU处理器中都能执行指令重排优化操作
JVM层面的内存屏障
在JSR规范中定义了4种内存屏障:
LoadLoad屏障 :(指令Load1; LoadLoad; Load2),在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
LoadStore屏障 :(指令Load1; LoadStore; Store2),在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreStore屏障 :(指令Store1; StoreStore; Store2),在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
StoreLoad屏障 :(指令Store1; StoreLoad; Load2), 在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。 它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
由于x86只有store load可能会重排序,所以只有JSR的StoreLoad屏障对应它的mfence或lock前缀指令,其他屏障对应空操作
硬件层内存屏障
硬件层提供了一系列的内存屏障 memory barrier / memory fence(Intel的提法)来提供一致性的能
力。拿X86平台来说,有几种主要的内存屏障:
- lfence,是一种Load Barrier 读屏障
- sfence, 是一种Store Barrier 写屏障
- mfence, 是一种全能型的屏障,具备lfence和sfence的能力
- Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR,BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。
内存屏障有两个能力:
1. 阻止屏障两边的指令重排序
2. 刷新处理器缓存/冲刷处理器缓存
对Load Barrier来说,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据;对Store Barrier来说,在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存。
Lock前缀实现了类似的能力,它先对总线和缓存加锁,然后执行后面的指令,最后释放锁后会把高速缓存中的数据刷新回主内存。在Lock锁住总线的时候,其他CPU的读写请求都会被阻塞,直到锁释放。
不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。
JMM内存屏障插入策略
1. 在每个volatile写操作的前面插入一个StoreStore屏障
2. 在每个volatile写操作的后面插入一个StoreLoad屏障
3. 在每个volatile读操作的后面插入一个LoadLoad屏障
4. 在每个volatile读操作的后面插入一个LoadStore屏障
x86处理器不会对读-读、读-写和写-写操作做重排序, 会省略掉这3种操作类型对应的内存屏障。仅会对写-读操作做重排序,所以volatile写-读操作只需要在volatile写后插入StoreLoad屏障