不定期补充、修正、更新;欢迎大家讨论和指正
本文只涉及到线程基本概念和线程安全问题,因为字数过多,篇幅太长,阅读不易,关于线程活性故障、线程通信、线程池的知识点会在下篇涉及
参考资料
以下均为视频,参考的文章会在摘要后贴上链接
黑马【多线程】知识
黑马程序员全面深入学习java并发编程,java基础进阶必学教程
Java多线程实战精讲-带你一次搞明白Java多线程高并发
B站最详细JAVA高并发多线程VIP课程–圣思园
尚硅谷_Java零基础教程-java入门必备-适合初学者的全套完整版教程(宋红康主讲)
如果学习过操作系统这门课对进程线程应该不陌生,这里简单理解下概念就行
进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础,打开任务管理器就可以看到一个一个的进程;进程可以认为是运行的程序,程序是放在磁盘的数据,是死的;程序放在内存让CPU处理后就变成了进程。
线程(thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
以浏览器为例,浏览器自身是一个进程,标签页就是一个线程(实际上目前大多浏览器标签页也是进程来实现)
为什么使用线程,我们知道OS给进程分配资源不是进程结束才释放资源,而是这个进程用一会,然后又将资源给另一个进程,即利用时间轮片算法进行并发,这样可以很好地提高资源利用率(比如A处理花3秒,等待2秒,B处理2秒,等待1秒,如果是串行总共要花费3+2+2+1=8秒,而如果在A等待这段时间把资源给B,这样最理想就只用花费3+1=4秒,)
但是进程切换时资源开销还是比较大的(需要保存当前的状态起来,以便能够进行恢复先前状态)于是就发明出线程,线程本质上就是一个函数/方法,但是有自己的线程栈,可以视为弱化的进程,我们编程都知道函数之间调用并不会花费什么资源。
OS中,电脑刚启动会创建一个主进程,它会创建子进程,子进程自己又创建子进程,子子孙孙无穷匮也,什么桌面、任务管理器、QQ等应用程序都是其直接或间接产生的。Java中,JVM启动会创建主线程,即运行main方法的线程。
由于计算机中CPU的高速运算,并行和并发容易混淆,单核的情况下,我们以为听歌和上网是同时运行,其实是OS采用时间轮片算法进行并发,让我们以为宏观上是同时运行,微观上是A进程用一会资源马上就将资源给B进程
并行是严格意义上的同一时刻同时进行,这就要求CPU是多核或多线程
在OS层面,当线程(进程)被创建并启动以后,它既不是一启动就进入了执行状态,也不是一直处于执行状态。在线程的生命周期中,它要经过新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)和死亡(Dead)5种状态。尤其是当线程启动以后,它不可能一直"霸占"着CPU独自运行,所以CPU需要在多条线程之间切换,于是线程状态也会多次在运行、阻塞之间切换。(进程也同理)
- 新建状态(New):当线程对象对创建后,即进入了新建状态,如:Thread t = new MyThread();
- 就绪状态(Runnable):当调用线程对象的start()方法,线程即进入就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,随时等待CPU调度执行,并不是说执行了start()此线程立即就会执行;
- 运行状态(Running):当CPU开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态。注:就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中;
- 阻塞状态(Blocked):处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到>- 就绪状态,才 有机会再次被CPU调用以进入到运行状态。根据阻塞产生的原因不同,阻塞状态又可以分为三种:
1.等待阻塞:运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态;
2.同步阻塞 – 线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态;
3.其他阻塞 – 通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。- 死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。
摘自线程的生命周期及五种基本状态
实际上Java线程的状态和以上略有点出入,Thread类的状态枚举类如下
- 初始(NEW):新创建了一个线程对象,但还没有调用start()方法。
- 运行(RUNNABLE):Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。
线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)。- 阻塞(BLOCKED):表示线程阻塞于锁。
- 等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
- 超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定的时间后自行返回。
- 终止(TERMINATED):表示该线程已经执行完毕。
在Java创建线程有三种方式:继承Thread类、实现Runable接口、实现Callable接口配合使用FutureTask类
创建Thread线程的三种方式、代码、使用场景及比较
下面为常用API,没什么难度就不演示了
实际开发中对线程的应用大多都是多线程编程,多线程编程有诸多好处
A coin Has two sides,多线程编程的风险也很明显,以下面卖票为例,共享变量用static修饰),
很容易找到数据出错的问题,原因就在于线程资源切换时而没有作出相应保护
以上仅仅是线程安全问题,多线程编程总的来说有以下问题
本文主要关心的是线程安全问题和线程活性故障
多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的。
多线程编程有三要素,满足这三个要素就可以保证线程安全,分别是原子性、可见性、有序性
从广义上说,Java平台提供的线程同步机制包括锁、volatile关键字、final关键字、static关键字、和一些相关的API
我们着重关注锁,因为锁可以直接保证线程安全三要素,volatile关键字虽然也可用于同步机制,但其不保证原子性,只保证可见性和有序性,之后对它单独另讲,static关键字、final关键字不用多说了。
Java提供的锁机制有synchronized关键字和JUC(JUC是 Java 5.0 提供的 java.util.concurrent包,在此包中增加了在并发编程中很常用的工具类)提供的Lock接口及实现类
关于锁机制还有许多概念需要了解,稍安勿躁
参考文章:
Java中的锁
java 锁(二):乐观锁VS悲观锁
Java中常用的锁机制
关于内部锁与显示锁
认真的讲一讲:自旋锁到底是什么
synchronized是Java提供最早的锁机制,它运行在JVM层面上,当使用synchronized 关键字后,代表这个方法加锁,相当于不管哪一个线程(例如线程A),运行到这个方法时,都要检查有没有其它线程B(或者C、 D等)正在用这个方法(或者该类的其他同步方法),有的话要等正在使用synchronized方法的线程B(或者C 、D)运行完这个方法后再运行此线程A,没有的话,锁定调用者,然后直接运行。它包括两种用法:synchronized 方法和 synchronized 块。
synchronized的用法很简单,还是以卖票为例,将需要同步的代码放到synchronized块中
synchronized块需要传入同步监视器,也就是俗称的锁,任何一个类的对象都可以充当锁,也可以使用类来充当锁如Object.class,但是线程间必须共用一把锁
自行试验,试验时可能会遇到只有一个线程输出的情况,这是因为在一个时间轮片内就执行完了,可以把数据弄大些或者加上sleep()方法。同时这里也可以看出synchronized是非公平锁
另一种是直接在需要同步的方法上加上synchronized关键字,其同步监视器是隐式声明,默认是this,显然的这种形式的粒度大,比较少用
注意如果线程是以继承Thread的方式创建的话,这种形式是不起同步作用的,因为两个线程都是new出来的,this也就不一样。如果非要使用就将同步的方法设为静态static的
如果是以Runnable接口创建线程的话的话,两个线程共用ticket2对象,而this都是ticket2,所以就能够成功同步
实际开发大多情况下也是使用Runnable接口来创建线程,一是可以数据共享,不用像继承那样给数据加static,二就是避免现在这种情况
以上试验了synchronized的原子性,关于可见性和有序性将会volatile中演示,下面演示synchronized另一个特性——可重入性,即一个线程能否多次获得同一个锁的能力
如果是不可重入锁,显然当run()方法拿到锁后,m1()是无法拿到锁的,就会进入阻塞状态,既然拿到了就是可重入锁了
注意可重入是针对一个线程来讲的,多个线程时锁肯定时互斥的
synchronized是Java关键字,关键字的功能只能由底层来实现,在使用过程中我们并没有看到显示加锁和解锁的过程,所以我们有必要查看字节码文件,我们创建一个简单的方法
在终端下使用 [javap -v 类名] 查看该类的字节码文件(路径是生成target文件下,而不是类源码所在的路径下),可以看到synchronized在JVM层面使用的是monitor字样的指令,加锁的过程是monitorenter,解锁则是monitorexit
这里出现了两次monitorexit,主要是防止在同步代码块中线程因异常退出,而锁没有得到释放,这必然会造成死锁(等待的线程永远获取不到锁)。因此最后一个monitorexit是保证在异常情况下,锁也可以得到释放,避免死锁。
往下看就是其维护的异常表,异常表中第一行监控着4-14行的指令,也就是同步的起始位置到锁的释放,如果此时出现异常就会跳转到17行的指令;第二行监控的是17到20行的指令,主要监控的就是因为异常尝试第二次解锁的指令,如果还出现异常依然跳转到17行的指令,总之解锁不了就循环往复,保证锁能得到释放
如果synchronized作用于方法上,那它的字节码是怎么样的呢,可以看到方法多了ACC_SYNCHRONIZED标志,该标记表明线程进入该方法时,需要monitorenter,退出该方法时需要monitorexit
使用synchronized时需要传入同步监视器,字节码里也看到了monitor,那么这个monitor究竟是什么东西?
monitor,即可以翻译为监视器,而也可以叫做管程,本身是操作系统的一个机制,是将共享变量及对共享变量能够进行的所有操作集中在一个模块中,简单来说就是放入同步代码块中。
在Java虚拟机(HotSpot)中,Monitor是基于C++实现的,由ObjectMonitor实现的,其主要数据结构如下:
ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL;
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ;
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor中有几个关键属性:
_owner:指向持有ObjectMonitor对象的线程
_WaitSet:存放处于wait状态的线程队列
_EntryList:存放处于等待锁block状态的线程队列
_recursions:锁的重入次数
_count:用来记录该线程获取锁的次数
当多个线程同时访问一段同步代码时,首先会进入_EntryList队列中,当某个线程获取到对象的monitor后进入_Owner区域并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器_count加1。即获得对象锁。
若持有monitor的线程调用wait()方法,将释放当前持有的monitor,_owner变量恢复为null,_count自减1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示
monitor的基本工作流程就是这样,现在仍然有个问题,ObjectMonitor只是一个类,那么真正的锁在哪?还记得我们在synchronized中可以传入任意对象作为锁吗,所以这些对象既是一个对象也是一个锁。
因为在Java中任意对象都可以用作锁,因此必定要有一个映射关系,存储该对象以及其对应的锁信息(即上面ObjectMonitor的结构)。
一种很直观的方法是,用一个全局map,来存储这个映射关系,但这样会有一些问题:需要对map做线程安全保障,不同的synchronized之间会相互影响,性能差;另外当同步对象较多时,该map可能会占用比较多的内存。所以最好的办法是将这个映射关系存储在对象头中,因为对象头本身也有一些hashcode、GC相关的数据,所以如果能将锁信息与这些信息共存在对象头中就好了。
对象头属于JVM的知识,我们需要了解一下。
我们知道对象创建后存储在JVM的堆空间内,在HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)(其中对象头又分为Mark Word和Klass Word两个字段)、实例数据(Instance Data)和对齐填充(Padding),如果对象是数组类型对象头还会有Length字段
Mark Word主要用来表示对象的线程锁状态,这里以32位虚拟机的存储结构为例,对象作为不同锁时Mark Word具体的含义也不同,如下
我们着重关注无锁和重量级锁的字段含义(其他锁很快就会讲到)
无锁也就是对象正常的情况,其锁标志位为01
当这个对象被当作锁(此时这个对象既是对象也是个锁,其HashCode会存储在另一个地方),前30位设置为指向重量级锁的指针,锁标志位也设置为10
关于底层原理了解的不深,所以这块可能讲得不太好,如果有错误和疏漏请指正
参考资料
java对象在堆内存中的结构
jvm-monitor原理
在jdk1.6之前,只有重量锁,synchronized底层是由c++提供的ObjectMonitor来维护。ObjectMonitor 帮synchronized封装了阻塞队列、同步队列,加锁,释放锁等复杂流程,其更底层调用操作系统的函数来实现线程同步,以及线程切换等操作。同时在一些并发不高,或者甚至没有并发的场景下,这些操作很浪费系统资源。所以,JVM对此进行了优化,出现了偏向锁和轻量级锁。
这部分可能稍微难懂些,建议大家先看看下面的视频
黑马程序员全面深入学习java并发编程,java基础进阶必学教程——P78
目前来说,锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁,,按照量级从轻到重分则是:无锁、偏向锁、轻量级锁、重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。
JDK 1.6中默认是开启偏向锁和轻量级锁的,我们也可以通过-XX:-UseBiasedLocking来禁用偏向锁。
锁的状态在对象头的Mark Word字段中有着不同的表示,如下
轻量级锁
如果一个临界区虽然有很多线程访问,但多线程访问的时间大多是相互错开的,也就是锁的竞争压力低,如果使用重量级锁无疑是浪费资源和效率的,这时我们可以使用轻量级锁来优化。轻量级锁和后面讲的偏向锁的使用方法和之前并无差别,一样是synchronized的用法,对用户是透明的,只不过底层实现有区别。
以下为轻量级锁加锁和解锁具体流程
在线程进入同步块的时候,如果同步对象锁状态为偏向状态(就是锁标志位为“01”状态,是否为偏向锁标志位为“1”,因为轻量级锁是由偏向锁升级而来的),JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。官方称之为 Displaced Mark Word(所以这里我们认为Lock Record和 Displaced Mark Word其实是同一个概念)。这时候线程堆栈与对象头的状态如图所示:
拷贝成功后,JVM将使用CAS操作(volatile中会详细讲)尝试将对象头的Mark Word更新为指向该线程Lock Record地址的指针,并将Lock Record里的owner指针指向对象头的Mark Word。如果更新成功,则执行步骤(3),否则执行步骤(4)。
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如下所示:
如果这个更新操作失败了,这时会有两种情况,一种是锁重入了,另一种是确实有线程在竞争锁。JVM首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,现在是重入状态,那么设置Lock Record第一部分为null,起到了一个重入计数器的作用。下图为重入三次时的Lock Record示意图,左边为锁对象,右边为当前线程的栈帧,重入之后然后结束。接着就可以直接进入同步块继续执行。
如果不是说明这个锁对象已经被其他线程抢占了,说明此时有多个线程竞争锁,那么它就会自旋等待锁,一定次数后仍未获得锁对象,说明发生了竞争,需要膨胀为重量级锁。
自旋等待仍得不到锁的线程会为锁对象申请Monitor锁,让锁对象指向重量锁的地址,而自己进入Monitor中的EntryList阻塞(得不到就毁掉[doge])
此时获得锁的线程结束任务,需要解锁,该线程会通过CAS操作尝试把线程中复制的Lock Record对象替换当前的Mark Word,如果替换成功,整个同步过程就完成了。如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),因为锁对象的Mark Word已经指向重量级锁地址了嘛,这时该线程就会通过地址找到Monitor,在那里释放锁的同时,唤醒被挂起的线程。
完整的流程图如下:
轻量级锁涉及到一个自旋的问题,而自旋操作是会消耗CPU资源的。为了避免无用的自旋,当锁资源存在线程竞争时,轻量级锁就会升级为重量级锁来避免其他线程无用的自旋操作。所以这就引出了轻量级锁的一个缺点:如果始终无法获得锁资源,线程就会自旋消耗CPU资源。
但是轻量级锁相对于重量级锁的一个有点就是:因为线程在竞争资源时采用的是自旋,而不是阻塞,也就避免了线程的切换带来的时间消耗,提高了程序的响应速度。
偏向锁
当一个线程反复的去获取或释放一个锁,如果这个锁是轻量级锁或者重量级锁,不断的加解锁显然是没有必要的,造成了资源的浪费。于是引入了偏向锁。
偏向锁顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁
偏向锁获取过程如下:
偏向锁的释放
偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。
偏向锁的撤销,需要等待全局安全点safepoint,它会首先暂停拥有偏向锁的线程A,然后判断这个线程A,此时有两种情况:
批量重偏向
为什么有批量重偏向
当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时将偏向锁撤销为无锁状态或升级为轻量级/重量级锁。这个过程是要消耗一定的成本的,所以如果说运行时的场景本身存在多线程竞争的,那偏向锁的存在不仅不能提高性能,而且会导致性能下降。因此,JVM中增加了一种批量重偏向/撤销的机制。
批量重偏向的原理
最后总结:
参考文章:
彻底搞懂Java中的偏向锁,轻量级锁,重量级锁
Java的对象头和对象组成详解
轻量级锁加解锁过程详解
synchronized虽然很好保证了线程安全,但作为Java早期的锁机制难免有局限性和缺点,不适合后面高并发的场景。
于是就诞生了另一种锁机制——Lock,Lock接口及实现类是Java 5之后的 java.util.concurrent包所提供的工具,弥补了synchronized的一些缺点(synchronized是6之后才进行优化的)
参考文章:synchronized的缺陷,Lock的诞生
locks包下的类结构图如下
上面的结构图中,ReentrantLock是Lock接口的唯一实现类,但实际并不是,IDEA中Ctrl+H查看类实现树,Lock接口还有几个实现类,只不过这些实现类是作为其他类的内部类实现接口的。从这也看出了ReentrantLock的特殊,我们主要使用的也是ReentrantLock。
Lock接口提供的方法如下,这些功能足够我们使用了,ReentrantLock的方法就不看了
ReentrantLock使用也很简单,在需要同步的代码前调用lock()加锁,后加上unlock()解锁即可。同样的,锁也要是同一把,这里将锁设为static
为了确保不会因为异常、提前返回等原因没有解锁,建议将lock()放在try代码块中,unlock()方法放到finally块中。
成功同步,这里还可以看到输出结果是两线程交替执行,这是因为前面构造器中传入参数true后使用公平锁的原因,默认是false即非公平锁
非公平锁的情况,公平锁的目的时为了解决线程饥饿问题,但这样会使得并发的效率变低,实际上较少使用公平锁
锁机制可以保证线程安全三要素,所以synchronized和Lock都可以很好地保证线程安全
synchronized和Lock(主要是ReentrantLock)共同点:
不同点:
在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而竞争资源非常激烈是(既有大量线程同时竞争),此时lock的性能要远远优于synchronized,实际生产看需求使用。
volatile关键字(adj. 易变的;无定性的;)是Java语言提供了一种轻量的同步机制(其实早在上世纪70年代就被C语言用来处理MMIO(Memory-mapped I/O)带来的问题,不是Java首创的)用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
我们先来看多线程中没有任何保障产生的可见性问题
设置共享变量flag为false,在重写的run()内将flag设为true,为了让试验更加顺利在前面睡眠1秒再改变flag
创建一个子线程执行run()方法,主线程则在死循环中判断flag的值,如果flag为true就循环输出信息
结果如下,尽管子线程在后面将共享变量flag变为true,但是因为不可见性,主线程得不到子线程修改后的值,于是什么都没输出,至于为什么会发生这样的问题,我们需要先了解JMM
JMM(Java Memory Model,Java内存模型)是Java用于屏蔽掉各种硬件和操作系统的内存访问差异和规定内存访问规则,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。
不想看文字的可以看看下面的视频
Java高并发编程精髓Java内存模型JMM详解全集
特别注意:JVM内存模型和Java内存模型不是一回事,我们知道JVM的内存模型比如堆栈、方法区、常量池等等,
JMM的目的是为了解决Java多线程对共享数据的读写一致性问题,通过Happens-Before语义(后面讲)定义了Java程序对数据的访问规则,修正之前由于读写冲突导致的Cache数据不一致的问题。具体到Hotspot VM的实现,主要是由OrderAccess类定义的一些列的读写屏障来实现JMM的语义。
JMM大概的结构如下
JMM定义了8种操作(原子操作),虚拟机实现时保证这8中操作均为原子操作,以下为8中操作的介绍以及执行顺序:
Java内存模型还规定了执行上述8种基本操作时必须满足如下规则:
不允许read和load、store和write操作之一单独出现(即不允许一个变量从主存读取了但是工作内存不接受,或者从工作内存发起会写了但是主存不接受的情况),以上两个操作必须按顺序执行,但没有保证必须连续执行,也就是说,read与load之间、store与write之间是可插入其他指令的。
不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
一个新的变量只能从主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
一个变量在同一个时刻只允许一条线程对其执行lock操作,但lock操作可以被同一个条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
如果一个变量实现没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
对一个变量执行unlock操作之前,必须先把此变量同步回主内存(执行store和write操作)。
参考文章:
Java内存模型(JMM)总结
Java内存结构(JVM)、Java内存模型(JMM)、Java对象模型区别
了解JMM后,我们就知道为什么会有这样的问题(画图太麻烦,这里找到一张差不多的图)
两种锁都让主线程成功读取到修改后的flag并输出内容
另一种方法就是volatile关键字,为共享变量加上volatile关键字后,同样解决了不可见性(记得把锁去掉再试验)
继续看JMM,添加了volatile修饰之后,总线中会通过缓存一致性协议和lock指令让主线程处于监听状态,一直嗅探共享变量是否被改变,子线程修改flag后会立即同步回主内存,这时候会通知主线程将缓存行状态改为I(无效状态),即变量副本失效了,需要重新从主内存读取,这样就可以确保可见性了。如下图所示:
编译器和处理器会在不改变程序执行结果的前提下,为了提高执行效率,会对既定的代码执行顺序进行指令重排序
为什么指令重排序可以提高性能?
简单地说,每一个指令都会包含多个步骤,每个步骤可能使用不同的硬件。因此,流水线技术产生了,它的原理是指令1还没有执行完,就可以开始执行指令2,而不用等到指令1执行结束之后再执行指令2,这样就大大提高了效率。
但是,流水线技术最害怕中断,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断。指令重排就是减少中断的一种技术。
我们分析一下下面这个代码的执行情况:
a = b + c;
d = e - f ;
先加载b、c(注意,即有可能先加载b,也有可能先加载c),但是在执行add(b,c)的时候,需要等待b、c装载结束才能继续执行,也就是增加了停顿,那么后面的指令也会依次有停顿,这降低了计算机的执行效率。
为了减少这个停顿,我们可以先加载e和f,然后再去加载add(b,c),这样做对程序(串行)是没有影响的,但却减少了停顿。既然add(b,c)需要停顿,那还不如去做一些有意义的事情。
综上所述,指令重排对于提高CPU处理性能十分必要。虽然由此带来了乱序的问题,但是这点牺牲是值得的。
参考文章:重排序与happens-before
指令重排一般分为以下三种:
指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。
看下面的例子,创建两个线程,当两线程执行完后打印x,y的结果
稍微思考一下就知道x,y的值只有三种情况
事实真的如此吗,我们可以创建个死循环,打印所有结果,完整代码如下
public class OrderingDemo {
private volatile static int x = 0 ,y = 0;
private static int a = 0 ,b = 0;
public static void main(String[] args) {
int count = 0;
while (true) {
count++;
a = 0; b = 0; x = 0; y = 0;
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
a = 1;
x = b;
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
y = a;
}
});
thread1.start();
thread2.start();
//join()是十分必要的,还记得它得作用吗,它能别的线程插队,别的线程执行完后才继续本线程的执行
//这里就是确保thread1和thread2执行完后,main才执行
//因为main作为线程也同样竞争资源,如果main先于两个子线程执行完,那毫无疑问x,y输出的都0,这样就达不到我们需要的效果
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(count + ":x=" + x + ", y=" + y);
if (x==0&&y==0)//当找到x=0,y=0的情况就退出,也可以替换查找其他的情况
break;
}
}
}
先修改break语句,查找正常情况
执行到19次的时候就找到了x=1,y=0的情况,同时上面也输出了x=0,y=1的情况
查找x=1,y=1的情况时,发现了一个尴尬事情,因为线程执行太快,根本来不及切换就执行完了,所以这种情况甚至比x=0,y=0的情况还难出现,不过好在还是出现了
最后是正常情况下不会出现的x=0,y=0;但事实上出现了
如果程序执行顺序和代码编写顺序一致的话是不可能出现这种情况的,出现这种情况只有可能是x=b(y=a)先于a=1(b=1)执行了,看来底层确实对指令进行重排序了
对于单线程重排序是没有任何问题的,但是多线程就会产生很大的问题
我们可以使用锁来禁止指令重排序保障有序性
经过后面学习发现锁是不能禁止指令重排序的,但是可以保证有序性
Java synchronized 能防止指令重排序吗?为何双重校验单例模式要加上 volatile?
这里找了20万次没找到,原来十几万次就找到了,就不继续进行下去了,关于使用ReentrantLock有兴趣的自己试验
另一种方法则是使用volatile关键字修饰变量
也试了20多万次没出结果
有人看到这可能觉得有点小崩溃,多线程真是一个大坑,原子性容易理解,可见性看了JMM模型也能接受,但是有序性。。现在代码执行顺序甚至和程序编写顺序不一样!难道还得了解底层是如何实现指令重排序才能写出没有问题的代码吗?那这样程序员不仅要多掉十几根头发,而且并发编程的效率严重降低。
当然前辈们肯定提出了解决方法,他们提出了happens-before的概念,只要我们编写的程序符合其规则,就可以忽略底层指令重排序的细节,感谢他们帮我们掉的头发,如下
happens-before的概念最初由Leslie Lamport在其一篇影响深远的论文(《Time,Clocks and the Ordering of Events in a Distributed System》)中提出,有兴趣的可以google一下。JSR-133使用happens-before的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以是在不同线程之间。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。具体的定义为:
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
这条是JMM对程序员的承诺。从程序员的角度来说,可以这样理解happens-before关系:如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的保证!- 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
这条是JMM对编译器和处理器重排序的约束原则。正如前面所言,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。参考文章:java内存模型以及happens-before规则
happens-before具体规则如下:
程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
如果线程1释放了某个锁,后续线程2请求了这个锁,那么线程1解锁前的写操作都对线程2可见。
volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读
传递性:如果A happens-before B,且B happens-before C,那么A happens-before C
start规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作
join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。
对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。
参考文章:happens-before是什么?JMM最最核心的概念,看完你就懂了
特别注意volatile是不保障原子性的,我们让线程循环对count进行自增操作,最后看看count的值是什么,可以事先猜想,不出意外的话count最后应该是20000(循环次数大些,不然可能一个线程能在其时间片内执行完,看不出效果)
然而结果并不如我们所意,这里可以看出两个问题:自增操作不是一条语句执行完的、volatile确实保障不了原子性
关于自增操作,如果进行字节码反编译,可以看到是由多条汇编指令构成(图网上找的),所以也不难理解为什么输出结果不是20000
显然原子性问题可以用锁来保障,但既然volatile已经保障可见性和有序性,那么我们能不能和另一个单独保障原子性的机制一起使用来解决上面的问题呢,下面讲的Atomic类就可以实现
Atomic类是JUC下atomic包提供的一系列工具类。这些类可以保证多线程环境下,当某个线程在执行atomic的方法时,不会被其他线程打断,而别的线程就像自旋锁一样,一直等到该方法执行完成,才由JVM从等待队列中选择一个线程执行。Atomic类在软件层面上是非阻塞的,它的原子性其实是在硬件层面上借助相关的指令来保证的。
这些原子类可以分为四组
分组 | 原子类 |
---|---|
基础数据型 | AtomicInteger,AtomicLong,AtomicBoolean |
数组型 | AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray |
字段更新型 | AtomicLongFieldUpdater,AtomicIntegerFieldUpdater,AtomicReferenceFieldUpdater |
引用型 | AtomicReference,AtomicStampedReference,AtomicMarkableReference |
这些原子类大同小异,我们以AtomicInteger为例,看看常用的方法
public AtomicInteger(int initialValue),构造器设置初始值
get() ,获取当前的值
getAndAdd(int newValue) ,增加指定的数据,返回变化前的数据
getAndDecrement() ,减少1,返回减少前的数据 ,功能上等价于n = n –
getAndIncrement() ,增加1,返回增加前的数据 ,等价n = n ++
getAndSet(int newValue), 设置指定的数据,返回设置前的数据
addAndGet(int newValue),增加指定的数据后返回增加后的数据
decrementAndGet() ,减少1,返回减少后的值,等价 n = --n
incrementAndGet() ,增加1,返回增加后的值,等价n = ++n
lazySet(int newValue) ,仅当get时才会set
compareAndSet(int expectedValue, int newValue) ,很重要的方法,跟expectedValue作对比,如果值一致就设置新值并返回true
知道了方法的简单使用,我们将其运用到上面的例子,虽然getAndIncrement() 和 incrementAndGet()都能直接完成任务,但我还是先用compareAndSet()来试验
compareAndSet()底层大致思路是从内存中获取共享变量的最新值(所以必须确保可见性和有序性),与参数expectedValu对比,如果一致就设置新值并返回true,反之亦然,估计还是有点懵逼,没关系看下面的代码
设置个死循环,变量n用于获取共享变量的最新值,变量m将n加一,下面的判断语句就是重点
比如现在n获取的值是10,假设现在途中资源被其他线程抢了,count的值变为了15,当进行compareAndSet()时,会从内存获取count最新值,那么n的值肯定过期的,count和n不一致,所以compareAndSet()设置新值失败返回false,并继续循环;
如果执行地很顺利,那么n的值和count获取的最新值一致,就设置新值将m赋给count并返回true,同时退出循环
那么这段代码的含义就很清楚了,就是循环直到成功设置新值,以很巧妙的思想保障了原子性
如果还是不明白也没关系,待回会在CAS中详细讲
输出结果如下
为什么要先讲compareAndSet(),因为有关数据变换的方法底层都是compareAndSet()或类似功能的方法实现,所以这些方法的思想是核心
拿getAndIncrement()为例查看源码
跟我们上面写的代码思想如出一辙,就是循环直到成功设置新值
底层还是compareAndSet()
现在我们就直接使用getAndIncrement()或incrementAndGet()了,如果不将结果返回给变量这两个方法效果是一样的
输出结果如下
细心的朋友会发现,AtomicInteger类的共享变量没有使用volatile关键字修饰,原子性是保障了,那可见性和有序性呢,AtomicInteger类全部囊括了?
没错,我们查看AtomicInteger内的源码,它的值就被volatile关键字修饰了
AtomicInteger的基本使用和作用就是这样,其他原子类的操作也差不多,自行了解
CAS(Compare And Swap/Set,比较和替换/设置),连名字都和compareAndSet()一样,CAS不仅是原子类的底层实现,也是乐观锁的原理。
在大多数处理器的指令中,都会实现 CAS 相关的指令,这一条指令就可以完成“比较并交换”的操作,也正是由于这是一条(而不是多条)CPU 指令,所以 CAS 相关的指令是具备原子性的,这个组合操作在执行期间不会被打断,这样就能保证并发安全。由于这个原子性是由 CPU 保证的,所以无需我们程序员来操心。
CAS 有三个操作数:内存值 V、预期值 A、要修改的值 B。CAS 最核心的思路就是,仅当预期值 A 和当前的内存值 V 相同时,才将内存值修改为 B。
我们对此展开描述一下:CAS 会提前假定当前内存值 V 应该等于值 A,而值 A 往往是之前读取到当时的内存值 V。在执行 CAS 时,如果发现当前的内存值 V 恰好是值 A 的话,那 CAS 就会把内存值 V 改成值 B,而值 B 往往是在拿到值 A 后,在值 A 的基础上经过计算而得到的。如果执行 CAS 时发现此时内存值 V 不等于值 A,则说明在刚才计算 B 的期间内,内存值已经被其他线程修改过了,那么本次 CAS 就不应该再修改了,可以避免多人同时修改导致出错。这就是 CAS 的主要思路和流程。可以结合我们上面使用compareAndSet()的例子,就很容易理解CAS的流程了
利用 CAS 实现的无锁算法,就像我们谈判的时候,用一种非常乐观的方式去协商,彼此之间很友好,这次没谈成,还可以重试。CAS 的思路和之前的互斥锁是两种完全不同的思路,如果是互斥锁,不存在协商机制,大家都会尝试抢占资源,如果抢到了,在操作完成前,会把这个资源牢牢的攥在自己的手里。当然,利用 CAS 和利用互斥锁,都可以保证并发安全,它们是实现同一目标的不同手段。
参考文章:你知道什么是 CAS 吗?
CAS的优点:
缺点
总的来说,对于资源竞争较少的情况,使用锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
对于资源竞争严重的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于锁。所以根据这些特性对于不同场景做出相应权衡
ABA问题是指在CAS操作中带来的潜在问题,我们知道对于一个要更新的变量A,我们提供一个它的旧值a和新值B ,如果变量A的值等于旧值那么更新B值成功, 否则失败。那么如果另一个线程已经修改过变量A后,又把值改回旧值a,这时该线程会作出如何反应,因为变量A现在确实等于旧值,尽管实际上被修改过了一次,该线程也会修改成功,这就是ABA问题,名字取得就很灵性:A->B->A
通俗来说就是你看到一杯水把它喝了,然后又把它接满,这时水的主人回来了,她并不知道这水有没有被动过
似乎ABA问题看起来不严重,有些场景是会造成严重的影响,我在知乎找到了一个例子,如下
小明在提款机,提取了50元,因为提款机问题,有两个线程,同时把余额从100变为50
线程1(提款机):获取当前值100,期望更新为50,
线程2(提款机):获取当前值100,期望更新为50,
线程1成功执行,线程2某种原因block了,这时,某人给小明汇款50
线程3(默认):获取当前值50,期望更新为100,
这时候线程3成功执行,余额变为100,
线程2从Block中恢复,获取到的也是100,compare之后,继续更新余额为50!!!
此时可以看到,实际余额应该为100(100-50+50),但是实际上变为了50(100-50+50-50)这就是ABA带来的问题
什么是ABA问题?
ABA问题的根本在于CAS在修改变量的时候,无法记录变量的状态,比如修改的次数,是否修改过这个变量。这样就很容易在一个线程将A修改成B时,另一个线程又会把B修改成A,造成CAS多次执行的问题。
ABA问题的解决办法可以在变量前面追加时间戳(版本号),每次变量更新时把时间戳(版本号)加1,那么A-B-A就会变成1A-2B-3A。
Atomic包里就提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果都相等,则以原子方式将该引用和标志的值设为给定的更新值。用法跟之前没什么区别,只不过多了两个时间戳参数
平行宇宙的另一个线程1比较机灵,买好可乐后在瓶底下做好了记号
public static void main(String[] args) {
//AtomicReference cola = new AtomicReference<>("未开的可口可乐");
AtomicStampedReference<String> cola = new AtomicStampedReference<>("未开的可口可乐",0);
Thread thread0 = new Thread(new Runnable() {
@Override
public void run() {
try {
sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
if(cola.compareAndSet("未开的可口可乐","喝完的可口可乐",0,1)) {
System.out.println(currentThread().getName()+":看看瓶底做的记号");
System.out.println(currentThread().getName()+":睡了一会,起来快乐");
} else{
System.out.println(currentThread().getName()+":看看瓶底做的记号");
System.out.println(currentThread().getName()+":嗯?谁换了我的可乐,算了凑合喝吧。。。艹! 谁放洁厕灵在里面");
}
}
});
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
cola.compareAndSet("未开的可口可乐","未开的百事可乐",0,1);
System.out.println(currentThread().getName()+":百事可乐yyds,看我偷偷把标签换了");
cola.compareAndSet("未开的百事可乐","未开的可口可乐",1,2);
}
});
thread0.start();
thread1.start();
}
有了记号(时间戳),线程1免受一次喝百事可乐的灾难(无意冒犯,我两个都喝)
ThreadLocal早在JDK 1.2的版本中就存在了,有些地方叫做线程本地变量,也有些地方叫做线程本地存储,ThreadLocal为解决多线程程序的并发问题提供了一种新的思路,它通过为每个线程提供一个独立的变量副本解决了变量并发访问的冲突问题。在很多情况下,ThreadLocal比直接使用synchronized同步机制解决线程安全问题更简单,更方便,且结果程序拥有更高的并发性。需要注意的是ThreadLocal设计的初衷是为了能够在当前线程中有属于自己的变量,并不是为了解决并发或者共享变量的问题。
我找到了一个专门讲ThreadLocal的网站(连域名都是threadlocal),就没必要在这里学习了
一针见血 ThreadLocal