Java多线程学习笔记
1.基本概念
程序:
用某种编程语言(java、python等)编写,能够完成一定任务或者功能的代码集合,是指令和数据的有序集合,是一段静态代码。
进程:
进程是程序的一次执行过程,是系统运行程序的基本单位
线程:
线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。
并发和并行的区别:
- 并发: 同一时间段,多个任务都在执行 (单位时间内不一定同时执行);
- 并行: 单位时间内,多个任务同时执行。
为什么使用多线程实现并发:
多进程方式确实可以实现并发,但使用多线程,有以下几个好处:
- 进程间的通信比较复杂,而线程间的通信比较简单,通常情况下,我们需要使用共享资源,这些资源在线程间的通信比较容易。
- 进程是重量级的,而线程是轻量级的,故多线程方式的系统开销更小。
进程与线程的区别:
进程是一个独立的运行环境,而线程是在进程中执行的一个任务。他们两个本质的区别是是否单独占有内存地址空间及其它系统资源(比如I/O):
进程单独占有一定的内存地址空间,所以进程间存在内存隔离,数据是分开的,数据共享复杂但是同步简单,各个进程之间互不干扰;而线程共享所属进程占有的内存地址空间和资源,数据共享简单,但是同步复杂。
进程单独占有一定的内存地址空间,一个进程出现问题不会影响其他进程,不影响主程序的稳定性,可靠性高;一个线程崩溃可能影响整个程序的稳定性,可靠性较低。
进程单独占有一定的内存地址空间,进程的创建和销毁不仅需要保存寄存器和栈信息,还需要资源的分配回收以及页调度,开销较大;线程只需要保存寄存器和栈信息,开销较小。
另外一个重要区别是,进程是操作系统进行资源分配的基本单位,而线程是操作系统进行调度的基本单位,即CPU分配时间的单位 。
从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的堆和*方法区 (JDK1.8 之后的元空间)**资源,但是每个线程有自己的**程序计数器、*虚拟机栈 和 本地方法栈。
总结: 线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。
Java线程状态切换
Java线程模型定义了 6 种状态,在任意一个时间点,一个线程有且只有其中一个状态:
新建(New)
:新建的Thread,尚未开始。运行(Runable)
:包含操作系统线程状态中的Running、Ready,也就是处于正在执行或正在等待CPU分配时间的状态。无限期等待(Waiting)
:处于这种状态的线程不会被分配CPU时间,等待其他线程唤醒。限期等待(Timed Waiting)
:处于这种状态的线程不会被分配CPU时间,在一定时间后会由系统自动唤醒。阻塞(Blocked)
:在等待获得排他锁。结束(Terminated)
:已终止的线程。
线程死锁
线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
产生死锁必须具备以下四个条件:
- 互斥条件:该资源任意一个时刻只由一个线程占用。
- 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
2.Volatile
计算机内存模型
计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,势必涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存。当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。举个简单的例子,比如下面的这段代码:
1 | i = i + 1; |
当线程执行这个语句时,会先从主存当中读取
i
的值,然后复制一份到高速缓存当中,然后 CPU 执行指令对i
进行加1操作,然后将数据写入高速缓存,最后将高速缓存中i
最新的值刷新到主存当中。
这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了。在多核 CPU 中,每条线程可能运行于不同的 CPU 中,因此 每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。比如同时有两个线程执行这段代码,假如初始时i
的值为0
,那么我们希望两个线程执行完之后i的值变为2。但是事实会是这样吗?
可能出现这种情况:初始时,两个线程分别读取i
的值存入各自所在的 CPU 的高速缓存当中,然后 线程1 进行加1操作,然后把i
的最新值1写入到内存。此时线程2的高速缓存当中i
的值还是0,进行加1操作之后,i
的值为1,然后线程2把i的值写入内存。最终结果i
的值是1,而不是2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。
为了解决缓存不一致性问题,通常来说有以下两种解决方法:
- 通过在总线加
LOCK#
锁的方式 - 通过 缓存一致性协议
这两种方式都是硬件层面上提供的方式。
在早期的 CPU 当中,是通过在总线上加LOCK#
锁的形式来解决缓存不一致的问题。因为 CPU 和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他 CPU 对其他部件访问(如内存),从而使得只能有一个 CPU 能使用这个变量的内存。比如上面例子中 如果一个线程在执行 i = i +1
,如果在执行这段代码的过程中,在总线上发出了LCOK#
锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。但是上面的方式会有一个问题,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。
所以就出现了缓存一致性协议。最出名的就是 Intel 的MESI协议
,MESI协议
保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
Java内存模型
在Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM
)来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。那么Java内存模型规定了程序中变量的访问规则,往大一点说是定义了程序执行的次序。注意,为了获得较好的执行性能,Java内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在java内存模型中,也会存在缓存一致性问题和指令重排序的问题。
Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。
在Java中,执行下面这个语句:
1 | i = 10; |
执行线程必须先在自己的工作线程中对变量i
所在的缓存行进行赋值操作,然后再写入主存当中。而不是直接将数值10
写入主存当中。那么Java语言本身对 原子性、可见性以及有序性提供了哪些保证呢?
原子性
即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。上面一句话虽然看起来简单,但是理解起来并不是那么容易。看下面一个例子,请分析以下哪些操作是原子性操作:
1 | x = 10; //语句1 |
咋一看,有些朋友可能会说上面的4个语句中的操作都是原子性操作。其实只有语句1
是原子性操作,其他三个语句都不是原子性操作。
语句1
是直接将数值10
赋值给x
,也就是说线程执行这个语句的会直接将数值10
写入到工作内存中。语句2
实际上包含2个操作,它先要去读取x
的值,再将x
的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。- 同样的,
x++
和x = x+1
包括3个操作:读取x
的值,进行加1
操作,写入新的值。
也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。不过这里有一点需要注意:在32位平台下,对64位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。但是好像在最新的JDK中,JVM已经保证对64位数据的读取和赋值也是原子性操作了。
从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronize
d和Lock
来实现。由于synchronized
和Lock
能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
对于可见性,Java提供了volatile
关键字来保证可见性。当一个共享变量被volatile
修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
另外,通过synchronized
和Lock
也能够保证可见性,synchronized
和Lock
能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
有序性
即程序执行的顺序按照代码的先后顺序执行。
指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。
在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
在Java里面,可以通过volatile
关键字来保证一定的“有序性”(具体原理在下一节讲述)。另外可以通过synchronized
和Lock
来保证有序性,很显然,synchronized
和Lock
保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before
原则,若线程 A 和线程 B 满足 happens-before 关系,则线程 A 执行操作的结果对线程 B 是可见的。如果两个操作的执行次序无法从happens-before
原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
下面就来具体介绍下happens-before
原则(先行发生原则):
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
对于程序次序规则来说,我的理解就是一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。
第二条规则也比较容易理解,也就是说无论在单线程中还是多线程中,同一个锁如果出于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行lock操作。
第三条规则是一条比较重要的规则,也是后文将要重点讲述的内容。直观地解释就是,如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。
第四条规则实际上就是体现happens-before
原则具备传递性。
深入剖析Volatile关键字
Volatile 的语义
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile
修饰之后,那么就具备了两层语义:
- 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的
- 禁止进行指令重排序
先看一段代码,假如线程1先执行,线程2后执行:
1 | //线程1 |
这段代码是很典型的一段代码,很多人在中断线程时可能都会采用这种标记办法。但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。
下面解释一下这段代码为何有可能导致无法中断线程。在前面已经解释过,每个线程在运行过程中都有自己的工作内存,那么线程1
在运行的时候,会将stop
变量的值拷贝一份放在自己的工作内存当中。
那么当线程2
更改了stop
变量的值之后,但是还没来得及写入主存当中,线程2
转去做其他事情了,那么线程1
由于不知道线程2
对stop
变量的更改,因此还会一直循环下去。但是用volatile
修饰之后就变得不一样了:
- 使用
volatile
关键字会强制将修改的值立即写入主存; - 使用
volatile
关键字的话,当线程2
进行修改时,会导致线程1
的工作内存中缓存变量stop
的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效); - 由于
线程1
的工作内存中缓存变量stop
的缓存行无效,所以线程1
再次读取变量stop
的值时会去主存读取。 - 那么在
线程2
修改stop
值时(当然这里包括2个操作,修改线程2工作内存中的值,然后将修改后的值写入内存),会使得线程1
的工作内存中缓存变量stop
的缓存行无效,然后线程1
读取时,发现自己的缓存行无效,它会等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值。
那么线程1读取到的就是最新的正确的值。
Volatile与原子性
从上面知道volatile
关键字保证了操作的可见性,但是volatile
能保证对变量的操作是原子性吗?
下面看一个例子:
1 | public class Test { |
大家想一下这段程序的输出结果是多少?也许有些朋友认为是10000。但是事实上运行它会发现每次运行结果都不一致,都是一个小于10000的数字。可能有的朋友就会有疑问,不对啊,上面是对变量inc
进行自增操作,由于volatile
保证了可见性,那么在每个线程中对inc
自增完之后,在其他线程中都能看到修改后的值啊,所以有10个线程分别进行了1000次操作,那么最终inc
的值应该是1000*10=10000
。
这里面就有一个误区了,volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。
在前面已经提到过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:
1 | 假如某个时刻变量inc的值为10, |
解释到这里,可能有朋友会有疑问,不对啊,前面不是保证一个变量在修改volatile变量时,会让缓存行无效吗?然后其他线程去读就会读到新的值,对,这个没错。这个就是上面的happens-before
规则中的volatile
变量规则,但是要注意,线程1对变量进行读取操作之后,被阻塞了的话,并没有对inc值进行修改。然后虽然volatile能保证线程2对变量inc的值读取是从内存中读取的,但是线程1没有进行修改,所以线程2根本就不会看到修改的值。
根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。解决的方法也就是对提供原子性的自增操作即可。
在Java 1.5
的java.util.concurrent.atomic
包下提供了一些原子操作类,即对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。atomic
是利用CAS来实现原子性操作的(Compare And Swap
),CAS实际上是利用处理器提供的 CMPXCHG
指令实现的,而处理器执行 CMPXCHG
指令是一个原子性操作。
Volatile与有序性
在前面提到volatile
关键字能禁止指令重排序,所以volatile
能在一定程度上保证有序性。volatile
关键字禁止指令重排序有两层意思:
- 当程序执行到
volatile
变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见,在其后面的操作肯定还没有进行; - 在进行指令优化时,不能将在对
volatile
变量访问的语句放在其后面执行,也不能把volatile
变量后面的语句放到其前面执行。
可能上面说的比较绕,举个简单的例子:
1 | //x、y为非volatile变量 |
由于flag变量为volatile
变量,那么在进行指令重排序的过程的时候,不会将语句3
放到语句1
、语句2
前面,也不会讲语句3
放到语句4
、语句5
后面。但是要注意语句1
和语句2
的顺序、语句4
和语句5
的顺序是不作任何保证的。
并且volatile
关键字能保证,执行到语句3
时,语句1
和语句2
必定是执行完毕了的,且语句1
和语句2
的执行结果对语句3
、语句4
、语句5
是可见的。
Volatile的原理和实现机制
前面讲述了源于volatile关键字的一些使用,下面我们来探讨一下volatile到底如何保证可见性和禁止指令重排序的。下面这段话摘自《深入理解Java虚拟机》:
观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令
lock前缀指令实际上相当于一个 内存屏障(也成内存栅栏),内存屏障会提供3个功能:
- 它 确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
- 它会 强制将对缓存的修改操作立即写入主存;
- 如果是写操作,它会导致其他CPU中对应的缓存行无效。
3.Synchronized
基础
在多线程并发编程中 Synchronized
一直是元老级角色,很多人都会称呼它为重量级锁,但是随着 Java SE1.6
对 Synchronized
进行了各种优化,引入了 偏向锁 和 轻量级锁。所以在 Java SE1.6 里锁一共有四种状态,无锁状态
,偏向锁状态
,轻量级锁状态
和重量级锁状态
,它会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁,但是偏向锁状态可以被重置为无锁状态(锁撤销)。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
锁状态 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁、解锁无额外消耗,和非同步方式近似 | 如果竞争线程多,会有额外锁撤销的消耗 | 基本没有线程竞争的场景 |
轻量级锁 | 竞争线程不会阻塞,使用自旋等待 | 如果长时间不能获取锁,会消耗CPU | 少量线程竞争,且线程持有锁时间不长 |
重量级锁 | 竞争线程被阻塞,减少CPU空转 | 线程阻塞,响应时间长 | 很多线程竞争,锁持有时间长 |
Java中的每一个对象都可以作为锁。
- 对于同步方法,锁是当前实例对象。
- 对于静态同步方法,锁是当前对象的Class对象。
- 对于同步方法块,锁是
Synchonized
括号里配置的对象。
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。
锁的升级
目前锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。
偏向锁
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得。偏向锁是为了在只有一个线程执行同步块时提高性能。
轻量级锁
这里解释下其中几个重要的步骤:
- 复制 Mark Word 到锁记录:拷贝对象头中的 Mark Word 到锁记录中。
- 更新 Mark Word 指针:拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 指针,并将 Lock Record 里的 owner 指针指向对象的 Mark Word。
重量级锁
在重量级锁的状态下, JVM 基于进入和退出 Monitor
对象来实现方法同步和代码块同步,Monitor
的引用存储在对象头中。
Monitor
本身是依赖与操作系统的互斥锁(mutex lock)实现的。由于 JVM 线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一条线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此这种转换需要耗费很多的 CPU 时间。
锁粗化
同步块的作用范围应该尽可能小,仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,缩短阻塞时间,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
但是加锁解锁也需要消耗资源,如果存在一系列的连续加锁解锁操作,可能会导致不必要的性能损耗。 锁粗化就是 JVM 将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。
锁消除
Java 虚拟机在 JIT 编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间
Synchronized vs ReentrantLock
- synchronized 是 Java 关键字,ReentrantLock 是基于 AQS 的 API 层面的互斥锁
- ReentrantLock 设置等待超时时间
- ReentrantLock 可进行公平锁与非公平锁设置
- ReentrantLock 可绑定多个 Condition
- synchronized 不需要手动释放锁
- synchronized 可以修饰方法、代码块
待续~
参考资料