概念
临界区
临界区指的是一个访问共用资源(例如:共用设备或是共用存储器)的程序片段,而这些共用资源又无法同时被多个线程访问的特性。当有线程进入临界区段时,其他线程或是进程必须等待(例如:bounded waiting等待法),有一些同步的机制必须在临界区段的进入点与离开点实现,以确保这些共用资源是被互斥获得使用。只能被单一线程访问的设备,例如:打印机。
互斥量
互斥量是一个可以处于两态之一的变量:解锁和加锁。
这样,只需要一个二进制位表示它,不过实际上,常常使用一个整型量,0表示解锁,而其他所有的值则表示加锁。互斥量使用两个过程。当一个线程(或进程)需要访问临界区时,它调用mutex_lock
。如果该互斥量当前是解锁的(即临界区可用),此调用成功,调用线程可以自由进入该临界区。
另一方面,如果该互斥量已经加锁,调用线程被阻塞,直到在临界区中的线程完成并调用mutex_unlock
。如果多个线程被阻塞在该互斥量上,将随机选择一个线程并允许它获得锁。
管程
管程(Monitors
,也称为监视器) 是一种程序结构,结构内的多个子程序(对象或模块)形成的多个工作线程互斥访问共享资源。这些共享资源一般是硬件设备或一群变数。
管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。与那些通过修改数据结构实现互斥访问的并发程序设计相比,管程实现很大程度上简化了程序设计。
系统中的各种硬件资源和软件资源,均可用数据结构抽象地描述其资源特性,即用少量信息和对资源所执行的操作来表征该资源,而忽略了它们的内部结构和实现细节。
利用共享数据结构抽象地表示系统中的共享资源,而把对该共享数据结构实施的操作定义为一组过程。
信号量
信号量(Semaphore
),有时被称为信号灯,是在多线程环境下使用的一种设施,是可以用来保证两个或多个关键代码段不被并发调用。
在进入一个关键代码段之前,线程必须获取一个信号量;一旦该关键代码段完成了,那么该线程必须释放信号量。其它想进入该关键代码段的线程必须等待直到第一个线程释放信号量。为了完成这个过程,需要创建一个信号量VI
,然后将Acquire Semaphore VI
以及Release Semaphore VI
分别放置在每个关键代码段的首末端。确认这些信号量VI引用的是初始创建的信号量。
CAS操作(Compare-and-swap)
CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
重排序
编译器和处理器”为了提高性能,而在程序执行时会对程序进行的重排序。它的出现是为了提高程序的并发度,从而提高性能!但是对于多线程程序,重排序可能会导致程序执行的结果不是我们需要的结果!
重排序分为”编译器”和”处理器”两个方面,而”处理器”重排序又包括”指令级重排序”和”内存的重排序”。
线程与内存交互操作
所有的变量(实例字段,静态字段,构成数组对象的 元素,不包括局部变量和方法参数)都存储在主内存中,每个线程有自己的工作内存,线程的工作内存保存被线程使用到变量的主内存副本拷贝。线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存的变量。不同线程之间也不能直接访问对方工作内存中的变量,线程间变量值的传递通过主内存来完成。
Java内存模型定义了八种指令操作
lock(锁定)
作用于主内存的变量,它把一个变量标识为一个线程独占的状态。
unlock(解锁)
作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read(读取)
作用于主内存的变量,它把一个变量的值从主内存传送到线程中的工作内存,以便随后的load
动作使用。
load(载入)
作用于工作内存的变量,它把read
操作从主内存中得到的变量值放入工作内存的变量副本中。
use(使用)
作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎。
assign(赋值)
作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存中的变量。
store(存储)
作用于工作内存的变量,它把工作内存中的一个变量的值传送到主内存中,以便随后的write
操作。
write(写入)
作用于主内存的变量,它把store
操作从工作内存中得到的变量的值写入主内存的变量中。
volatile关键字作用
- 保证了新值能立即存储到主内存,每次使用前立即从主内存中刷新。
- 禁止指令重排序优化。
volatile
关键字不能保证在多线程环境下对共享数据的操作的正确性(保证可见性)。可以使用在自己状态改变之后需要立即通知所有线程的情况下。
Java线程的实现方式
内核线程(Kernal Thread)
内核线程(Kernel Thread,KLT)就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操作调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型。
轻量级进程要消耗一定的内核资源(如内核线程的栈空间),而且系统调用的代价相对较高,因此一个系统支持轻量级进程的数量是有限的。
轻量级用户进程(Light Weight Process)
广义上来讲,一个线程只要不是内核线程,那就可以认为是用户线程(User Thread,UT),而狭义的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到线程存在的实现,用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也可以支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。
这种进程与用户线程之间1:N
的关系称为一对多的线程模型。(Windows和Linux使用的是这种方式)
使用用户线程的优势在于不需要系统内核的支援,劣势在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理,因而使用用户线程实现的程序一般都比较复杂,现在使用用户线程的程序越来越少了。
用户线程/混合线程(User Thread)
既存在用户线程,又存在轻量级进程。用户线程还是完全建立在用户空间中,而操作系统所支持的轻量级进程则作为用户线程和内核线程之间的桥梁。这种混合模式下,用户线程与轻量级进程的数量比是不定的,是M:N
的关系。许多Unix系列的系统,都提供了M:N
的线程模型实现。
线程调度有两种方式
协同式
线程的执行时间由线程本身来控制,线程任务执行完成之后主动通知系统切换到另一个线程去执行。(不推荐)
优点
实现简单,线程切换操作对线程本身是可知的,不存在线程同步问题。
缺点
线程执行时间不可控制,如果线程长时间执行不让出CPU执行时间可能导致系统崩溃。
抢占式
每个线程的执行时间有操作系统来分配,操作系统给每个线程分配执行的时间片,抢到时间片的线程执行,时间片用完之后重新抢占执行时间,线程的切换不由线程本身来决定。(Java使用的线程调度方式就是抢占式调度)
优点
线程执行时间可控制,不会因为一个线程阻塞问题导致系统崩溃。
Java中线程状态的调度关系
同步原理
JVM规范规定JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。
代码块同步是使用monitorenter
和monitorexit
指令实现,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明,但是方法的同步同样可以使用这两个指令来实现。monitorenter
指令是在编译后插入到同步代码块的开始位置,而monitorexit
是插入到方法结束处和异常处,JVM要保证每个monitorenter
必须有对应的monitorexit
与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。
线程执行到monitorenter
指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。
Java对象头
锁存在Java对象头里。如果对象是数组类型,则虚拟机用3个Word(字宽)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,一字宽等于四字节,即32bit。
长度 | 内容 | 说明
—|—-|—
32/64bit | Mark Word | 存储对象的hashCode或锁信息等。
32/64bit | Class Metadata Address | 存储到对象类型数据的指针
32/64bit | Array length | 数组的长度(如果当前对象是数组)
Java对象头里的Mark Word里默认存储对象的HashCode
,分代年龄和锁标记位。
32位JVM的Mark Word的默认存储结构如下。
状态 | 25 bit | 4bit | 1bit(是否是偏向锁) | 2bit(锁标志位)
—|——–|——|————–|—–
无锁状态 | 对象的hashCode | 对象分代年龄 | 0 | 01
在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
Mark Word可能变化为存储以下4种数据。
:合并单元格
锁状态 | 25 bit | 4bit | 1bit(是否是偏向锁) | 2bit(锁标志位)
—-|——–|——|——|—–
轻量级锁 | 指向栈中锁记录的指针(30bit) | | | 00
重量级锁 | 指向互斥量(重量级锁)的指针(30bit) | | | 10
GC标记 | 空 | 空 | 空 | 11
偏向锁 | 线程ID(23bit),Epoch(2bit) | 对象分代年龄 | 1 | 01
在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下。
锁状态 | 25bit | 31bit | 1bit(cms_free) | 4bit(分代年龄) | 1bit(偏向锁) | 2bit(锁标志位)
—-|——-|——-|—————-|————|———–|———–
无锁 | unused | hashCode | - | - | 0 | 01
偏向锁 | ThreadID(54bit) Epoch(2bit) = 25bit+31bit | | - | - | 1 | 01
锁的升级
Java SE1.6为了减少获得锁和释放锁所带来的性能消耗,引入了“偏向锁”和“轻量级锁”,所以在Java SE1.6里锁一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率,下文会详细分析。
锁的升级不是需要同步块执行完成的。持有锁的线程在执行完同步块的时候检查下锁是否升级了,如果升级了就唤醒等待的线程重新竞争。
偏向锁
Hotspot的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而只需简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行,那么这个时间点有可能是未进入同步代码快,也有可能是退出的同步代码块时候),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态,如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
下图中的线程1演示了偏向锁初始化的流程,线程2演示了偏向锁撤销的流程。
viso附件
关闭偏向锁
偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟-XX:BiasedLockingStartupDelay = 0
。如果你确定自己应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁-XX:-UseBiasedLocking=false
,那么默认会进入轻量级锁状态。
轻量级锁
加锁
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。(轻量级锁就是Sync和lock)
解锁
轻量级解锁时,会使用原子的CAS操作来将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
下图是两个线程同时争夺锁,导致锁膨胀的流程图。
viso附件
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
锁的优缺点对比
锁 | 优点 | 缺点 | 适用场景 | |
---|---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 | 适用于只有一个线程访问同步块场景。 | |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度。 | 如果始终得不到锁竞争的线程使用自旋会消耗CPU。 | 追求响应时间。同步块执行速度非常快。 | |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU。 | 线程阻塞,响应时间缓慢。 | 追求吞吐量。同步块执行速度较长。 |