编写优质的并发代码是一件难度极高的事情。Java语言从第一版本开始内置了对多线程的支持,这一点在当年是非常了不起的,但是当我们对并发编程有了更深刻的认识和更多的实践后,实现并发编程就有了更多的方案和更好的选择。本篇Chat为接下来的Java并发编程精华版本,重点知识,如果某个知识点不理解,可以再深入的看本专栏中的其它Blog内容介绍。
- 并发编程的挑战:并行与并发的区别,并发编程的几大挑战。
- JMM内存模型:JMM内存模型是什么样的,如何从底层机制保证Java顶层的并发编程能力
- Java并发机制的底层实现:基于JMM内存模型,底层还做了哪些控制来支撑并发能力
- Java的进程与线程:什么是进程,什么是线程,Java如何使用多线程
- 线程生命周期及状态切换:线程的生命周期是怎样的,如何进行状态切换
- Java线程安全与同步方案:如何对共享资源进行有效的控制,线程如何能安全的访问共享资源
- JUC并发包概述:JUC并发包下有哪些内容,有什么好处
- JUC并发包下原子类:JUC下的原子类重点有哪些,如何发挥作用
- JUC并发包下的锁,虽然在线程安全同步方案中已经提及,这里还是需要体系化的认知一下,锁的种类,作用
- JUC并发包下的工具类,一些我们可能会用到的工具类,了解即可
- JUC并发包下的容器类,并发包下的容器类,重点掌握ConcurrentHashMap和CopyOnWrite
- JUC并发包下的线程池,需要掌握线程池的参数,常用的四种线程池,一些方法的比较,线程池的执行流程
- 死锁问题及解决方案,产生死锁的必要条件,死锁的代码示例,以及如何解决死锁问题
适合人群:不了解Java并发编程的新手,对Java并发编程的实现机制感兴趣的技术人员
本文的全部内容来自我个人在Java并发编程学习过程中整理的博客,是该博客专栏的精华部分。在书写过程中过滤了流程性的上下文,例如部署环境、配置文件、代码示例等,而致力于向读者讲述其中的核心部分,如果读者有意对过程性内容深入探究,可以移步专栏中的其它Blog。
并发编程的挑战
本部分回答以下几个问题,如果能回答正确,则证明本部分掌握好了。
- 什么是并发,并发和并行的区别是什么
- 并发编程有什么挑战、怎么解决
主要围绕两个问题,并发与并行的区别,以及并发编程的挑战和解决方式有哪些。
什么是并发,并发和并行的区别是什么
并发是指两个或多个事件在同一时间间隔内发生,在多道程序环境下,一段时间内宏观上有多个程序在同时执行,而在同一时刻,单处理器环境下实际上只有一个程序在执行,故微观上这些程序还是在分时的交替进行。操作系统的并发是通过分时得以实现的,和串行以及并行的概念区别:
- 串行:顺序做不同事的能力:先洗衣服,洗完后做饭。
- 并发:交替做不同事的能力:一会儿洗衣服,一会儿做饭,交替执行,但快如闪电。洗衣服和做饭的是一个(cpu),在同一个时间段内每个cpu各司其职。并发的实质是一个物理CPU(也可以多个物理CPU) 在若干道程序之间多路复用,并发性是对有限物理资源强制行使多用户共享以提高效率。
- 并行:同时做不同事的能力:左手洗衣服右手做饭,在同一时刻同时做两件事。并行性指两个或两个以上事件或活动在同一时刻发生。在多道程序环境下,并行性使多个程序同一时刻可在不同CPU上同时执行。
并发关注的是资源充分利用(也就是不让cpu闲下来),并行关注的是一个任务被分解给多个执行者同时做,缩短这个任务的完成时间(也就是尽快做完这件事),操作系统的并发性是指计算机系统中同时存在多个运行着的程序,因此它具有处理和调度多个程序同时执行的能力。在操作系统中,引入进程的目的是使程序能并发执行。并行则是同时间同时刻有几个程序同时运行,有几核就就几个程序在并行。单核CPU只能并发多个程序,多核CPU可以并发也可以并行【4核CPU可以并行4个程序,程序大于核心时就需要用到并发性】
并发编程有什么挑战
包括线程轮转执行的上下文切换问题、对同步资源加锁时的死锁问题,整体的资源限制问题
- 上下文切换:CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上 下文切换,减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程。
- 无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。
- CAS算法。Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
- 使用最少线程或线程池。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
- 使用协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换
- 死锁问题:对于共享的资源线程没能安全协同处理,互相持有对方线程的资源,有如下几种避免死锁的机制:
- 避免一个线程同时获取多个锁
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
- 尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制。
- 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况
- 资源限制问题,包括硬件资源限制和软件资源限制问题
- 对于硬件资源限制,可以考虑使用集群并行执行程序。既然单机的资源有限制,那么就让程序在多机上运行
- 对于软件资源限制,可以考虑使用资源池将资源复用。比如使用连接池将数据库和Socket连接复用
关键问题是上下文切换和死锁如何避免。
JMM内存模型
本部分回答以下几个问题,如果能回答正确,则证明本部分掌握好了。
- CPU处理器的内存模型是什么样的
- JMM内存模型的基本构造
- JMM内存操作的整体流程,8个操作过程
- 什么是指令重排,数据依赖,As-if-serial语义指什么
- 什么是Happens-Before先行发生原则,有哪八个原则
接下来我们看这部分的内容。
处理器内存模型
多数运算中,处理器都要和内存进行交互,如读取数据、存储结果等。由于计算机存储设备和处理器运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高缓存Cache来作为内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存中同步回内存,这样处理器就无须等待缓慢的内存读写了。 解决运算冲突却导致了高速缓存的一致性问题。
高缓存Cache引入了缓存的一致性Cache Coherence的问题。在多处理器系统中,每个处理器都有自己的高速缓存,又共享同一主内存,可能导致各自的缓存数据不一致【可见性问题】。需要各处理器访问缓存时遵循一些协议,在读写时根据协议来操作,如MSI,MESI,MOSI,Snapse,Firefly,dRAGON Protocol等。处理器还可能会对输入的代码进行乱序执行优化,之后又将乱序结果重组,保证该结果与顺序执行结果一致,还有指令重排序优化【有序性问题】。
JMM内存模型基本结构
什么是内存模型?内存模型:在特定的操作协议下对特定的内存或高速缓存进行读写访问的过程抽象。Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行(共享变量内存可见),整个通信过程对程序员完全透明,而同步总是显式执行(同步域内有序执行)。
在Java中(JDK1.7),所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享(共享变量代指实例域,静态域和数组元素)。局部变量(Local Variables),方法定义参数和异常处理器参数等不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
JMM,Java Memory Model,用来屏蔽掉各种硬件和操作系统之间的内存访问差异,以实现让Java程序在各平台下都能达到一致的内存访问效果。
在此之前,主流程序语言(如C/C++等)直接使用物理硬件和操作系统的内存模型,因此,会由于不同平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另外一套平台上并发访问却经常出错,因此在某些场景就必须针对不同的平台来编写程序。
JMM主要目标:定义程序中各个共享变量的访问规则,即在虚拟机中将变量存储到内存和从内存取出变量这样的底层细节。
主内存与工作内存
如上图所示,JMM中区分为主内存和工作内存:
- JMM规定所有共享变量均存储在主内存(虚拟机内存的一部分)中。每条线程还有自己的工作内存,类比高速缓存。
- 线程对共享变量的所有操作都在工作内存中,不能直接在主内存中读写操作
- 不同线程之间也不能直接访问对方的工作内存中的共享变量。只能通过主内存来传递变量值
注意:主内存与工作内存和Java内存区域的堆栈方法区等并不是同一个层次的内存划分。工作内存可以理解为本地处理器的高速缓冲区。
内存间交互操作
主内存与工作内存之间具体的交互协议:一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存等的细节。
Java内存模型定义了8种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子操作。对于long 和double类型的变量,store,read,write,load操作在某些平台上允许有例外。
- lock(锁定):作用于主内存的变量,它把一个共享变量标志为一条线程独占的状态。
- unlock(解锁):作用于主内存中的变量,它把一个处于锁定状态的共享变量释放出来,释放后的共享变量才可以被其他线程锁定。
- read(读取):作用于主内存的变量,它把一个共享变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的共享变量值放入工作内存的共享变量副本中。
- use(使用):作用于工作内存的变量,它把工作内存中一个共享变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到共享变量的字节码指令时将执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接受到的值赋给工作内存的共享变量,遇到赋值的字节码时执行。
- store(存储):作用于工作内存的变量,它把工作内存中一个共享变量变量的值传送到主内存中,以便随后的write操作使用。
- write(写入):作用于主内存中的变量,它把store操作从主内存中得到的变量值放入主内存的变量中。
整体执行的流程如下:
以上操作,仅保持顺序执行即可,不用保证连续执行。如可能发生 read a read b load b load a。
交互操作规范
变量操作相关的
- 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况
- 不允许一个线程丢弃它的最近assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存
- 不允许一个线程无原因的(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中,即不能对变量没做任何操作却无原因的同步回主内存
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,就是对一个变量执行use和store之前必须先执行过了load和assign操作
lock操作相关的
- 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁,synchronized实现可重入锁的基石
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值,synchronized实现可见性的基石
- 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量,synchronized实现锁的基石
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store write)synchronized实现可见性的基石
以上可以完全确定Java程序中哪些内存访问操作在并发下是安全的。
指令重排序
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段,也叫做指令重排,指令重排可能导致预期的执行结果不符,所以JMM提供了两种语义解决这个问题:as-if-serial和Happens-Before,JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before关系本质上和as-if-serial语义是一回事
- as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
- as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
- as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度
程序员可以依据Happens-Before原则来进行多线程正确同步下的执行顺序推演。
As-if-serial语义
如果两个操作访问同一个共享变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性
- 单线程或单处理器下,编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
- 多处理器之间或多线程之间的数据依赖性不被编译器和处理器考虑
在并发编程中,数据依赖性其实得不到满足
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序
Happens-Before的语义
两个操作之间具有 happens-before 关系, 并不意味着前一个操作必须要在后一个操作之前执行,happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的保证!
- 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照,happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。
关于顺序执行的三个原则
- 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。(同一个线程中前面的所有写操作对后面的操作可见)
- 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
- 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
关于锁定与读写的两个原则
- 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。(如果线程1解锁了monitor a,接着线程2锁定了a,那么,线程1解锁a之前的写操作都对线程2可见(线程1和线程2可以是同一个线程))
- volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。(如果线程1写入了volatile变量v(临界资源),接着线程2读取了v,那么,线程1写入v及之前的写操作都对线程2可见(线程1和线程2可以是同一个线程))
关于线程的三个原则
- 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。(假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行前对线程B可见。注意:线程B启动之后,线程A在对变量修改线程B未必可见。)
- 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。(线程t1写入的所有变量,在任意其它线程t2调用t1.join(),或者t1.isAlive() 成功返回后,都对t2可见。)
- 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。(线程t1写入的所有变量,调用Thread.interrupt(),被打断的线程t2,可以看到t1的全部操作)(A h-b B , B h-b C 那么可以得到 A h-b C)
以上先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从以上规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。
Java并发机制的底层实现
本部分回答以下几个问题,如果能回答正确,则证明本部分掌握好了。
- 共享变量有哪些,在JDK中的位置,1.7和1.8有什么不同
- volatile关键字底层语义如何保证可见性、有序性,为什么保证不了原子性,需要配合什么实现原子性,CAS实现原子性的问题有哪些
- synchronized关键字底层语义如何保证可见性、有序性以及原子性
- synchronized锁是存储在哪的,对象头的结构是什么样的,锁有哪几种,怎么升级
接下来我们看这部分的内容。
共享变量
什么是共享资源和变量,在JVM模型中来说,就是JVM的堆和⽅法区,这部分内容是所有线程共享的区域:
- 堆是所有线程共享的资源,其中堆是进程中最⼤的⼀块内存,主要⽤于存放新创建的对象 (所有对象都在这⾥分配内存)
- ⽅法区主要⽤于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
堆和⽅法区公有为了保证线程都能共享到堆中创建的对象以及方法区中的内容。共享变量可变可能引发的问题,可以通过Java底层机制解决:
- Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。
- Java语言提供了volatile,在某些情况下比锁要更加方便。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的
- 原子操作,如果操作是原子的,那么每次线程执行的就是不可中断的一组指令,在次过程中当然是不可变的。
我们来看看Java底层如何解决共享资源的同步访问问题。
volatile关键字
volatile是一种修饰共享变量的轻量级同步方法,它可以保证内存可见性、通过禁止指令重排保证执行的有序性,但是它并不能保证原子性,需要搭配原子类使用,或者直接使用synchronized。
保证内存可见性
volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的可见性。它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度其读写内存语义如下:
- volatile写的内存语义如下。当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
- volatile读的内存语义如下。当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量
通过读写内存语义,可以保证主内存和工作内存的值相互立即同步
禁止指令重排
volatile可以通过使用内存屏障禁止指令重排,单线程会禁止数据依赖的指令进行重排,但是对于不存在数据依赖的指令允许重排,只要最后执行结果一致,这在单线程中没有问题,但是多线程中就会有问题,多线程举个例子:
public class Singleton {
private static Singleton instance = null;
private Singleton() { }
public static Singleton getInstance() {
if(instance == null) {
synchronzied(Singleton.class) {
if(instance == null) {
instance = new Singleton(); //非原子操作
}
}
}
return instance;
}
}
看似简单的一段赋值语句:instance= new Singleton(),但是很不幸它并不是一个原子操作,其实际上可以抽象为下面几条JVM指令:
memory =allocate(); //1:分配对象的内存空间
ctorInstance(memory); //2:初始化对象
instance =memory; //3:设置instance指向刚分配的内存地址
上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM是可以针对它们进行指令的优化重排序的,经过重排序后如下:
memory =allocate(); //1:分配对象的内存空间
instance =memory; //3:instance指向刚分配的内存地址,此时对象还未初始化
ctorInstance(memory); //2:初始化对象
可以看到指令重排之后,instance指向分配好的内存放在了前面,而这段内存的初始化被排在了后面。在线程A执行这段赋值语句,在初始化分配对象之前就已经将其赋值给instance引用,恰好另一个线程进入方法判断instance引用不为null,然后就将其返回使用,导致出错,注意这里volatile阻止的并不是 instance = new Singleton(); //非原子操作
的重排序,而是保证了在一个写操作完成之前,不会调用读操作 if(instance == null)
,instance以关键字volatile修饰之后,就会阻止JVM对其相关代码进行指令重排,这样就能够按照既定的顺序指执行。同时存在两个对变量的操作的时候,instance =memory;
就是对volatile变量的写,并且在顺序执行里为第二个动作,第一个动作是ctorInstance(memory)
。
- 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
- 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
- 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序
那么volatile依靠什么实现的禁止指令重排呢?那就是内存屏障
不保证原子性
如果运算操作不是原子操作,导致volatile变量的运算在并发下一样是不安全的。依然没法保证volatile同步的正确性。由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,仍需要加锁synchronized或java.util.concurrent中的原子类来保证原子性(如果把一个事务可看作是一个程序,它要么完整的被执行,要么完全不执行。这种特性就叫原子性):
-
对变量的写入操作不依赖于该变量的当前值(比如a=0;a=a+1的操作,整个流程为a初始化为0,将a的值在0的基础之上加1,然后赋值给a本身,很明显依赖了当前值,即使如果对a的加操作立即对其它线程可见,但是多个线程同时可见,同时更新,会导致在大量循环中的a++达不到预期的值,例如循环100次,值最终更新为75),或者确保只有单一线程修改变量。
-
该变量不会与其他状态变量纳入不变性条件中。(当变量本身是不可变时,volatile能保证安全访问,比如双重判断的单例模式。但一旦其他状态变量参杂进来的时候,并发情况就无法预知,正确性也无法保障)不变式就是a>5,如果a>b,b是个变量,就不能保证了。
也就是在原子操作时volatile并不能百分百保证
synchronized关键字
synchronized,我们谓之锁,主要用来给方法、代码块加锁。当某个方法或者代码块使用synchronized时,那么在同一时刻至多仅有有一个线程在执行该段代码。当有多个线程访问同一对象的加锁方法/代码块时,同一时间只有一个线程在执行,其余线程必须要等待当前线程执行完之后才能执行该代码段。具体表现形式有三种。
- 普通同步方法,锁是当前方法所属实例对象。
- 静态同步方法,锁是当前方法所属类的Class对象。
- 同步方法块,锁是Synchonized括号里配置的对象。
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。在指令层面,同步方法块和同步方法是使用monitorenter和monitorexit指令实现的, JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个 monitor 与之关联,当且一个monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。
我们从锁释放-获取的内存语义与volatile写-读的内存语义可以看出:锁释放与volatile写有相同的内存语义;锁获取与volatile读有相同的内存语义
- 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中
- 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量
synchronized在底层对应的又是什么原理呢,这得从对象头说起。synchronized用的锁是存在Java对象头里的。如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit,在JVM系列的Blog中我们介绍过Java对象头的组成形式:HotSpot虚拟机的对象头包括两部分信息,分别是Mark Word和类型指针(klass pointer),锁就存储在Mark Word中:
各部分的含义如下:
- 锁标志位(lock):区分锁状态,11时表示对象待GC回收状态, 只有最后2位锁标识(11)有效。
- biased_lock:是否偏向锁,由于无锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。
- 分代年龄(age):表示对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代。
- 对象的hashcode(hash):运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。当对象加锁后,计算的结果31位不够表示,在偏向锁,轻量锁,重量锁,hashcode会被转移到Monitor中。
- 偏向锁的线程ID(JavaThread):偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。 在后面的操作中,就无需再进行尝试获取锁的动作。
- epoch:偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁。
- ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争的时,JVM使用原子操作而不是OS互斥。这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的标题字中设置指向锁记录的指针。
- ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针。
当然还有32位虚拟机的布局,该布局组成元素同64位相同,只是占用大小略有不同
锁的升级与对比
Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁,在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率
设置锁的操作
我们给对象设置锁时,使用的方式是CAS(Compare and Swap)比较并设置。用于在硬件层面上提供原子性操作。在 Intel 处理器中,比较并交换通过指令cmpxchg实现。传入两个参数,旧值(期望操作前的值)和新值,执行时会比较旧值是否和给定的数值一致,如果一致则修改为新值,不一致则不修改新值。
偏向锁
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
偏向锁加锁
偏向锁的加锁流程如下,主要就是检测对象头中的偏向锁信息。
- 当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID
- 以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。
- 如果测试成功,表示线程已经获得了锁。
- 如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁)
- 如果已设置,则尝试使用CAS将对象头的偏向锁指向当前线程
- 如果未设置,则使用CAS竞争锁;
以上就是偏向锁的加锁流程。
偏向锁撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)才会执行
- 它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着
- 如果线程不处于活动状态,则将对象头设置成无锁状态,然后重新偏向其它线程
- 如果线程仍然活动着,检查该对象的使用情况
1. 如果仍然需要持有偏向锁,也就是产生了竞争,则偏向锁升级为轻量级锁。
2. 如果不需要持有偏向锁,则重新变为无锁状态,然后重新偏向新的线程,本线程偏向锁撤销。 - 最后唤醒暂停的线程。
偏向锁比轻量锁更容易被终结,轻量锁是在有锁竞争出现时升级为重量锁,而一般偏向锁是在有不同线程申请锁时升级为轻量锁,这也就意味着假如一个对象先被线程1加锁解锁,再被线程2加锁解锁,这过程中没有锁冲突,也一样会发生偏向锁失效,不同的是这回要先退化为无锁的状态,再加轻量锁,如果是线程1持有锁,且2也要争夺偏向锁,则直接到轻量级锁状态
关闭偏向锁
偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0
。如果确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false
,那么程序默认会进入轻量级锁状态
轻量级锁
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。
- 轻量级加锁时,线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁
- 轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁
解锁失败会导致膨胀
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争
三种锁对比
以下是偏向锁、轻量级锁以及重量级锁三者之间的优缺点和使用场景。
我们也可以按照时间线的顺序来看待这三个锁的状态变化
- 成为偏向锁 ,一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操作,并将对象头中的ThreadID改成自己的ID,之后再次访问这个对象时,只需要对比ID,不需要再使用CAS在进行操作。
- 升级为轻量级锁,一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象是偏向状态,这时表明在这个对象上已经存在竞争了,检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程,如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的)。假如一个对象先被线程1加锁解锁,再被线程2加锁解锁,这过程中没有锁冲突,也一样会发生偏向锁失效,不同的是这回要先退化为无锁的状态,再加轻量锁,如果是线程1持有锁,且2也要争夺偏向锁,则直接到轻量级锁状态
- 膨胀为重量级锁,轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。
以上就是锁状态的切换过程
原子操作的实现和问题
在Java中可以通过锁和循环CAS的方式来实现原子操作
使用循环CAS实现原子操作
JVM中的CAS操作正是利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止,以下代码实现了一个基于CAS线程安全的计数器方法safeCount和一个非线程安全的计数器count
package com.company;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadTest {
private AtomicInteger atomicI = new AtomicInteger(0); //安全计数器共享变量atomicI初始化值0
private int a = 0; //安全计数器共享变量a初始化值0
public static void main(String[] args) {
final ThreadTest cas = new ThreadTest();
List<Thread> ts = new ArrayList<>(600);
//开启100个线程,每个线程执行10000次,总计执行一百万次
for (int j = 0; j < 100; j++) {
Thread t = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
cas.count();
cas.safeCount();
}
});
ts.add(t);
}
// 所有线程开始执行
for (Thread t : ts) {
t.start();
}
// 等待所有线程执行完成
for (Thread t : ts) {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("count "+cas.a);
System.out.println("safecount "+cas.atomicI.get());
}
/** * 使用CAS实现线程安全计数器 */
private void safeCount() {
for (;;) {
int i = atomicI.get();
boolean suc = atomicI.compareAndSet(i, ++i);
if (suc) {
break;
}
}
}
/**
* 非线程安全计数器
*/
private void count() {
a++;
}
}
执行结果如下,可以看到安全执行的原子操作刚好符合预期。
count 987753
safecount 1000000
可以看看该方法操作的源码:
/**
* Atomically sets the value to the given updated value
* if the current value {@code ==} the expected value.
*
* @param expect the expected value
* @param update the new value
* @return {@code true} if successful. False return indicates that
* the actual value was not equal to the expected value.
*/
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
继续向下钻取查看:
//预期引用
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
这里还有个比较有意思的是继续钻取查看,发现该类定义的变量为volatile ,就是为了满足共享变量的可见性。
private volatile int value;
CAS的问题
在Java并发包中有一些并发框架也使用了自旋CAS的方式来实现原子操作,CAS虽然很高效地解决了原子操作,但是CAS仍然存在三大问题。ABA问题,循环时间长开销大,以及只能保证一个共享变量的原子操作
- ABA问题。因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。从Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值
- 循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销
- 只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁
虽然有折中解决的办法,例如循环开销大可以使用处理器的指令pause,只能保证一个共享变量原子操作可以考虑把多个共享变量合并成一个共享变量来操作。但最好的解决方式还是使用锁
使用锁机制实现原子操作
锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。有意思的是除了偏向锁,JVM实现锁的方式都用了循环CAS,即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁
JMM实现三大特性
实现并发编程中的三大特性,并发编程才是安全的,原子性、可见性与有序性,那么JMM模型需要补足或者使用哪些特殊机制来满足顺序一致性模型呢?
-
原子性(Atomicity),Java内存模型来直接保证的原子性变量操作包括read、load、use、assign、store和write这六个,我们可以大致的认为基本数据类型的访问读写是具备原子性的(64位的long和64位的double除外)。
- synchronized关键字,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性
- 原子类进行的CAS操作也能满足原子性。
-
可见性(Visibility),可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,有以下三种方式满足可见性:
- volatile关键字,volatile修饰的共享变量保证新值能立即同步到主内存,以及每次使用前立即从主内存刷新。
- synchronized关键字,可见性是由对一个变量执行unlock操作之前,必须先把此变量同步回主内存(执行store、write操作)中这条规则获得的
- final关键字,可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看见final字段的值。
-
有序性(Ordering),Java程序的天然有序性:在本线程内观察,所有操作都是有序的;在一个线程中观察另外一个线程,所有操作都是无序的。(前半句是指线程内表现为串行的语义,后半句是指令重排序现象和工作内存与主内存同步延迟现象)。有以下三种满足有序性
- volatile关键字,volatile本身就包含禁止指令重排序的语义来保证有序性,使用内存屏障,即重排序的指令不能放到内存屏障之前。
- synchronized关键字,因为一个变量在同一时刻只允许一条线程对其进行lock操作。这个规则决定了持有同一个锁的两个同步块只能串行的进入,指令在临界区内可以重排,但不会影响最终执行结果
- 先行发生原则(Happens-Before),如果Java内存模型中所有的有序性都只靠volatile和synchronized来完成,那么有一些操作将会变得很繁琐,在JMM中,如果一个操作执行的结果需要对另一个操作可见,这两个操作需要有一定的执行顺序,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间
Java在底层使用volatile关键字和synchronized关键字来实现这三个特性,之后的高级并发包,也都是基于这两个关键字进行的扩展。
Java的进程与线程
本部分回答以下几个问题,如果能回答正确,则证明本部分掌握好了。
- 进程和线程的区别是什么
- Java多线程的模型是什么样的
- Javad 线程同步和调度
接下来我们看这部分的内容。
进程和线程的区别
什么是进程?进程是程序的⼀次执⾏过程,是系统运⾏程序的基本单位,因此进程是动态的。系统运行和关闭⼀个程序即是⼀个进程从创建,运⾏到消亡的过程。在 Java 中,当我们启动 main 函数时其实就是启动了⼀个 JVM 的进程,⽽ main 函数所在的线程就是这个进程中的⼀个线程,也称主线程
什么是线程?线程与进程相似,但线程是⼀个⽐进程更⼩的执⾏单位。⼀个进程在其执⾏的过程中可以产⽣多个线程。与进程不同的是同类的多个线程共享进程的堆和⽅法区资源,但每个线程有⾃⼰的程序计数器、虚拟机栈和本地⽅法栈,所以系统在产⽣⼀个线程,或是在各个线程之间作切换⼯作时,负担要⽐进程⼩得多,也正因为如此,线程也被称为轻量级进程
二者之间的区别与联系如下:
- 使用定位,进程是分配资源的基本单位,线程是独立运行和独立调度的基本单位,多进程是指操作系统能同时运行多个任务(程序)。多线程是指在同一程序中有多个顺序流在并发执行
- 地址空间和资源:进程间相互独立,同一进程的各线程间共享。某进程内的线程在其它进程不可见,每个进程都有独立的代码和数据空间(进程上下文),进程间的切换会有较大的开销,一个进程包含n个线程;线程没有独立的代码和数据空间,一个进程下的多个线程需要共享进程的资源,线程间切换开销小,比进程切换快的多
- 状态阶段:线程和进程一样分为五个阶段:创建、就绪、运行、阻塞、终止。
- 通信方式:进程间通信IPC(IPC是intent-Process Communication的缩写,含义为进程间通信或者跨进程通信),线程间可以直接读写进程数据段(如全局变量)来进行通信——需要进程同步和互斥手段的辅助,以保证数据的一致性。
对应于我们的JVM模型如下:
启动多少个Java程序,就会创建多少个JVM进程,也称之为JVM实例。而每一个JVM实例都是独立的,它们互不影响。这也是前面所说的一个程序可以被多个进程共用的情况
程序计数器为什么是私有的?
- 字节码解释器通过改变程序计数器来依次读取指令,从⽽实现代码的流程控制,如:顺序执⾏、选择、循环、异常处理。
- 在多线程的情况下,程序计数器⽤于记录当前线程执⾏的位置,从⽽当线程被切换回来的时候能够知道该线程上次运⾏到哪⼉了。需要注意的是,如果执⾏的是 native ⽅法,那么程序计数器记录的是 undefined 地址,只有执⾏的是 Java 代码时程序计数器记录的才是下⼀条指令的地址。
程序计数器私有为了保证线程切换后能恢复到正确的执⾏位置。
虚拟机栈和本地⽅法栈为什么是私有的?
- 虚拟机栈: 每个 Java ⽅法在执⾏的同时会创建⼀个栈帧⽤于存储局部变量表、操作数栈、常量池引⽤等信息。从⽅法调⽤直⾄执⾏完成的过程,就对应着⼀个栈帧在 Java 虚拟机栈中⼊栈和出栈的过程。
- 本地⽅法栈: 和虚拟机栈所发挥的作⽤⾮常相似,区别是: 虚拟机栈为虚拟机执⾏ Java ⽅法(也就是字节码)服务,⽽本地⽅法栈则为虚拟机使⽤到的 Native ⽅法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合⼆为⼀。
虚拟机栈和本地⽅法栈私有为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地⽅法栈是线程私有的。
堆和⽅法区为什么是公有的?
- 堆是所有线程共享的资源,其中堆是进程中最⼤的⼀块内存,主要⽤于存放新创建的对象 (所有对象都在这⾥分配内存)
- ⽅法区主要⽤于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
堆和⽅法区公有为了保证线程都能共享到堆中创建的对象以及方法区中的类型。
Java多线程模型
Java线程模型基于操作系统原生线程模型实现。因此操作系统支持怎么样的线程模型,很大程度上决定了Java虚拟机的线程如何映射,这一点在不同的平台上没有办法达成一致,虚拟机规范中也未限定Java线程需要使用哪种线程模型来实现。线程模型只对线程的并发规模和操作成本产生影响,对于Java程序来说,这些差异是透明的:
也就是一条
- Java线程就映射到一条轻量级进程(Light Weight Process)中,而一条轻量级线程又映射到一条内核线程(Kernel-Level Thread)。
- 我们平时所说的线程,往往就是指轻量级进程(或者通俗来说我们平时新建的java.lang.Thread就是轻量级进程实例的一个"句柄",因为一个java.lang.Thread实例会对应JVM里面的一个JavaThread实例,而JVM里面的JavaThread就应该理解为轻量级进程)。
- 我们在应用程序中创建或者操作的java.lang.Thread实例最终会映射到系统的内核线程
如果我们恶意或者实验性无限创建java.lang.Thread
实例,最终会影响系统的正常运行甚至导致系统崩溃
线程同步与调度
什么是线程同步?其核心就在于一个同。所谓“同”就是协同、协助、配合,也就是按照预定的先后顺序进行运行,即你先,我等, 你做完,我再做。
- 线程同步,就是当线程发出一个功能调用时,在没有得到结果之前,该调用就不会返回,其他线程也不能调用该方法。就一般而言,我们在说同步、异步的时候,特指那些需要其他组件来配合或者需要一定时间来完成的任务。
- 在多线程编程里面,一些较为敏感的数据时不允许被多个线程同时访问的,使用线程同步技术,确保数据在任何时刻最多只有一个线程访问,保证数据和操作的完整性。
线程调度方式包括两种,协同式线程调度和抢占式线程调度
线程调度方式 | 描述 | 优势 | 劣势 |
---|---|---|---|
协同式线程调度 | 线程的执行时间由线程本身控制,执行完毕后主动通知操作系统切换到另一个线程上 | 某个线程如果不让出CPU执行时间可能会导致整个系统崩溃 | 实现简单,没有线程同步的问题 |
抢占式线程调度 | 每个线程由操作系统来分配执行时间,线程的切换不由线程自身决定 | 实现相对复杂,操作系统需要控制线程同步和切换 | 不会出现一个线程阻塞导致系统崩溃的问题 |
Java线程最终会映射为系统内核原生线程,所以Java线程调度最终取决于系操作系统,而目前主流的操作系统内核线程调度基本都是使用抢占式线程调度。
线程生命周期及状态切换
本部分回答以下几个问题,如果能回答正确,则证明本部分掌握好了。
- 线程的生命周期分为几个阶段,如何切换
- 切换过程中涉及的方法比较sleep、wait以及yield
接下来我们看这部分的内容。
线程生命周期及切换方法
Java线程的状态可以从java.lang.Thread
的内部枚举类java.lang.Thread$State
得知:
public enum State {
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED;
}
整个状态如图所示图片来源
想要实现多线程,必须在主线程中创建新的线程对象。Java 语言使用 Thread 类及其子类的对象来表示线程,在它的一个完整的生命周期中通常要经历如下的五种状态
- 新建(NEW): 当一个Thread类或其子类的对象被声明并创建时,新生的线程对象处于新建状态
- 可运行状态(RUNABLE): RUNNABLE状态可以认为包含两个子状态:READY和RUNNING,
- 就绪(READY): 处于新建状态的线程被start()后,将进入线程队列等待CPU时间片,此时它已具备了运行的条件,只是没分配到CPU资源 可能有CPU时间片
- 运行(RUNNING): 当就绪的线程被调度并获得CPU资源时便进入运行状态 有CPU时间片
- 阻塞(BLOCKED锁阻塞): 当一个线程试图获取一个对象锁来访问资源而该对象锁正被别的线程持有时,则该线程进入BLOCKED状态,直到该线程持有对象锁,该线程转为RUNABLE状态, 无CPU时间片
- 无限期等待(WAITING): 当一个线程在等待另一个线程执行一个动作(唤醒)时,该线程处于WAITING状态,该线程不能自动唤醒,必须等待其它线程显式执行唤醒方法notify、notifyAll等
- 有限期等待(TIMED_WAITING):无需等待被显式唤醒,到达设置期限后线程会被JVM自动唤醒
- 终结(TERMINATED): 线程完成了它的全部工作run方法正常结束或线程被提前强制性地中止或出现异常导致结束
其实状态划分有很多种,这里我们就按照Java源代码的枚举状态来判定吧。六种状态的切换状态图如下图所示:
如下的执行流程更加直观一些:
方法比较
包括sleep和yield的区别以及sleep和wait的区别
sleep和yield的区别
yield方法对应了如下操作:先检测当前是否有相同优先级的线程处于同可运行状态,如有,则把 CPU 的占有权交给此线程,否则,继续运行原来的线程。所以yield方法称为“退让”,它把运行机会让给了同等优先级的其他线程,sleep则直接中断当前线程一段时间。
- sleep使线程进入Timed_Waiting状态,yield的线程依然是Runable,sleep使当前线程进入停滞状态,所以执行sleep的线程在指定的时间内肯定不会被执行;yield只是使当前线程重新回到可执行状态,所以执行yield的线程有可能在进入到可执行状态后马上又被执行
- sleep时间可设定,yield不可以,sleep 方法使当前运行中的线程睡眼一段时间,进入不可运行状态,这段时间的长短是由程序设定的,yield 方法使当前线程让出 CPU 占有权,但让出的时间是不可设定的。
- sleep不需要考虑线程优先级,yield需要,sleep 方法允许较低优先级的线程获得运行机会,但 yield方法执行时,当前线程仍处在可运行状态,所以,不可能让出较低优先级的线程些时获得 CPU 占有权
在一个运行系统中,如果较高优先级的线程没有调用 sleep 方法,又没有受到 I\O 阻塞,那么,较低优先级线程只能等待所有较高优先级的线程运行结束,才有机会运行。
sleep和wait的区别
两者的相同点是:
- 都可以使线程切换到TIMED_WAITING状态,它们都是在多线程的环境下,都可以在程序的调用处阻塞指定的毫秒数释放CPU控制权,并返回
- 都可以通过interrupt()方法打断线程的暂停状态 ,从而使线程立刻抛出InterruptedException,捕获并安全结束线程
两者的区别是:
- wait方法必须放在同步块里执行,也就是必须有同步方法修饰
- wait通常被⽤于线程间交互/通信(BLOCKED状态),sleep 通常被⽤于暂停执⾏。
- sleep方法没有释放锁,而wait方法释放了锁,使得其他线程可以使用同步控制块或者方法
- wait⽅法被调⽤后,线程不会⾃动苏醒,需要别的线程调⽤同⼀个对象上的 notify或者notifyAll⽅法,也就是锁的状态切换。
以上就是二者的异同
Java线程安全与同步方案
本部分回答以下几个问题,如果能回答正确,则证明本部分掌握好了。
- 什么是线程安全,如何保证线程安全
- Synchronized和ReentrantLock区别
- 从功能层面上看锁的分类有哪些
- 锁的优化措施有哪些,锁粗化,锁消除,锁升级
- 线程本地存储(Thread Local Storage)的实现原理是什么,ThreadLocal的内存泄露问题
接下来我们看这部分的内容。
线程安全的概念和方案
当多个线程访问一个对象时如果不考虑这些线程在执行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的,但大多数对象都不是线程安全的,我们最终的目的就是使线程安全,其实从并发的角度来讲,按照线程安全的三种策略:
- 第一个部分,阻塞(互斥)同步,我们所讨论的锁也集中在这个部分。
- 第二个部分,非阻塞同步,这个部分也就一种通过CAS进行原子类操作,其实也就是不加锁或者代码实现一些自旋锁。
- 第三个部分,无同步方案,包括可重入代码和线程本地存储(ThreadLocal)
我们使用最多的应该就是虚拟机提供的互斥同步和锁机制,互斥同步是常见的一种并发正确性保障手段。同步是指在多线程并发访问共享数据时,保证共享数据在同一时刻只能被一个线程使用。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。互斥是因,同步是果;互斥是方法,同步是目的
锁的实现方式
我们所说的锁的分类其实应该按照锁的特性和设计来划分,其实我们真正用到的锁也就那么两三种,只不过依据设计方案和性质对其进行了大量的划分。一类是原生语义上的实现
- Synchronized,它是一个:非公平,悲观,独享,互斥,可重入的重量级锁
还有一类是在JUC包下,是API层面上的实现
- ReentrantLock,它是一个:默认非公平但可实现公平的,悲观,独享,互斥,可重入的重量级锁。
- ReentrantReadWriteLocK,它是一个,默认非公平但可实现公平的,悲观,写独享/读共享,读写,可重入的重量级锁
那么我们来详细了解下这几种实现方式。
Synchronized
synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。
- 如果Java程序中的synchronized明确指定了对象参数,那就是这个对象的reference;
- 如果synchronized修饰的是实例方法,去取对应的对象实例
- 如果synchronized修饰的是类方法,去取对应的Class对象来作为锁对象
那么Synchronized实现的锁有什么优缺点呢?
Synchronized优点
在虚拟机规范对monitorenter和monitorexit的行为描述中,有两点是需要特别注意的。
- 首先,synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。 可重⼊锁概念是:⾃⼰可以再次获取⾃⼰的内部锁。⽐如⼀个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重⼊的话,就会造成死锁。同⼀个线程每次获取锁,锁的计数器都⾃增1,所以要等到锁的计数器下降为0时才能释放锁
- 其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入
也就是Synchronized能保证同步块内的内容在多线程下准确执行
Synchronized缺点与优化
其缺点也比较明显,Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。所以synchronized是Java语言中一个重量级的操作,优化方式就是在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁地切入到核心态之中,也就是在直接用重量级锁之前,先让轻量级锁自旋等待下。
ReentrantLock
除了synchronized之外,我们还可以使用java.util.concurrent(下文称JUC)包中的重入锁ReentrantLock
来实现同步,ReentrantLock与synchronized很相似,他们都具备一样的线程重入特性,只是代码写法上有点区别,
- 一个表现为API层面的互斥锁(lock和unlock方法配合try/finally语句块来完成)
- 一个表现为原生语法层面的互斥锁。
相比synchronized,ReentrantLock增加了一些高级功能,主要有以下3项:定时锁等候/等待可中断、可实现公平锁,以及锁可以绑定多个条件
定时锁等候/等待可中断
ReentrantLock获取锁定有四种方式:
- lock(), 如果获取了锁立即返回,如果别的线程持有锁,当前线程则一直处于休眠状态,直到获取锁
- tryLock(), 如果获取了锁立即返回true,如果别的线程正持有锁,立即返回false
- tryLock(long timeout,TimeUnit unit), 如果获取了锁定立即返回true,如果别的线程正持有锁,会等待参数给定的时间,在等待的过程中,如果获取了锁定,就返回true,如果等待超时,返回false;定时锁等候
- lockInterruptibly():如果获取了锁定立即返回,如果没有获取锁定,当前线程处于休眠状态,直到获取锁定,或者当前线程被别的线程中断,中断锁等候
可中断特性对处理执行时间非常长的同步块很有帮助,举例说明,线程A和B都要获取对象O的锁定,假设A获取了对象O锁,B将等待A释放对O的锁定
- 如果使用 synchronized ,如果A不释放,B将一直等下去,不能被中断
- 如果 使用ReentrantLock,如果A不释放,可以使B在等待了足够长的时间以后,中断等待,而干别的事情
所以这个特性还是很重要的。
可实现公平锁
公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized
中的锁是非公平的,ReentrantLock
默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁
锁绑定多个条件
锁绑定多个条件是指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait
和notify
或notifyAll
方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁,而ReentrantLock
则无须这样做,只需要多次调用newCondition
方法即可。
Synchronized和ReentrantLock区别
Synchronized和ReentrantLock有什么区别和联系呢?,可以总结为以下几点:
- 两者默认都是非公平,悲观,独享,互斥,可重入锁
- synchronized 依赖于 JVM ⽽ ReentrantLock 依赖于 API,不但可以通过一些监控工具监控synchronized的锁定,而且在代码执行时出现异常,JVM会自动释放锁定,但是使用Lock则不行,lock是通过代码实现的,要保证锁定一定会被释放,就必须将unLock放到finally{}中
- ReentrantLock 拥有Synchronized相同的并发性和内存语义,此外还多了 可实现选择性通知(锁可以绑定多个条件),定时锁等候/等待可中断,可实现公平锁
需要注意,随着Synchronized的优化,性能已不能作为二者比较的标准。
锁的分类
从功能的角度出发,锁可以按照如下几个维度分类:
锁的优化措施
锁的状态变化分为两种,锁的消除、锁的粗化、内存级别的锁升级以及分段锁的实现。
锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。
锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。
public String concatString(String s1,String s2){
StringBuffer sb=new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
每个StringBuffer.append
方法中都有一个同步块,锁就是sb对象。虚拟机观察变量sb,很快就会发现它的动态作用域被限制在concatString
方法内部。sb的所有引用永远不会逃逸到concatString
方法之外,其他线程无法访问到它
代码中concatString方法中的局部对象sb,就只在该方法内的作用域有效,不同线程同时调用concatString方法时,都会创建不同的sb对象,因此此时的append操作若是使用同步操作,就是白白浪费的系统资源因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。
锁粗化
原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。
大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
for(int i=0;i<size;i++){
synchronized(lock){
}
如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部
synchronized(lock){
for(int i=0;i<size;i++){
}
}
上述代码中,扩展到for循环之外加锁,这样只需要加锁一次就可以了。
锁升级
因为Synchronized太重了,所以在虚拟机层面上进行了优化,偏向锁/轻量级锁/重量级锁这三种锁是指锁的状态,Java 5通过引入锁升级的机制来实现高效Synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。
- 偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
- 轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
- 重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。
其实在BlogJava并发机制的底层实现详细介绍过,这里不再赘述,这里给出简单的状态图:
线程本地存储(Thread Local Storage)
ThreadLocal提供了线程的局部变量,每个线程都可以通过set()和get()来对这个局部变量进行操作,但不会和其他线程的局部变量进行冲突,实现了线程的数据隔离。简要言之:往ThreadLocal中填充的变量属于当前线程,该变量对其他线程而言是隔离的,举个例子
public class MyThreadLocal {
// 采用匿名内部类的方式来重写initialValue方法
private static final ThreadLocal<Object> threadLocal = new ThreadLocal<Object>() {
/**
* ThreadLocal没有被当前线程赋值时或当前线程刚调用remove方法后调用get方法,返回此方法值
*/
@Override
protected Object initialValue() {
System.out.println("调用get方法时,当前线程共享变量没有设置,调用initialValue获取默认值!");
return null;
}
};
// 操纵int类型的任务线程
public static class MyIntegerTask implements Runnable {
private String name;
MyIntegerTask(String name) {
this.name = name;
}
public void run() {
for (int i = 0; i < 5; i++) {
// ThreadLocal.get方法获取线程变量
if (null == MyThreadLocal.threadLocal.get()) {
// ThreadLocal.et方法设置线程变量
MyThreadLocal.threadLocal.set(0);
System.out.println("线程" + name + ": 0");
} else {
int num = (Integer) MyThreadLocal.threadLocal.get();
MyThreadLocal.threadLocal.set(num + 1);
System.out.println("线程" + name + ": " + MyThreadLocal.threadLocal.get());
if (i == 3) {
MyThreadLocal.threadLocal.remove();
}
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
// 操纵string类型的任务线程
public static class MyStringTask implements Runnable {
private String name;
MyStringTask(String name) {
this.name = name;
}
public void run() {
for (int i = 0; i < 5; i++) {
if (null == MyThreadLocal.threadLocal.get()) {
MyThreadLocal.threadLocal.set("a");
System.out.println("线程" + name + ": a");
} else {
String str = (String) MyThreadLocal.threadLocal.get();
MyThreadLocal.threadLocal.set(str + "a");
System.out.println("线程" + name + ": " + MyThreadLocal.threadLocal.get());
}
try {
Thread.sleep(800);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) {
new Thread(new MyIntegerTask("IntegerTask1")).start();
new Thread(new MyStringTask("StringTask1")).start();
}
}
运行结果为:
调用get方法时,当前线程共享变量没有设置,调用initialValue获取默认值!
线程IntegerTask1: 0
调用get方法时,当前线程共享变量没有设置,调用initialValue获取默认值!
线程StringTask1: a
线程StringTask1: aa
线程IntegerTask1: 1
线程StringTask1: aaa
线程IntegerTask1: 2
线程StringTask1: aaaa
线程IntegerTask1: 3
线程StringTask1: aaaaa
调用get方法时,当前线程共享变量没有设置,调用initialValue获取默认值!
线程IntegerTask1: 0
对于多线程资源共享的问题,同步机制采用了以时间换空间的方式,而ThreadLocal采用了以空间换时间的方式。前者仅提供一份变量,让不同的线程排队访问,而后者为每一个线程都提供了一份变量,因此可以同时访问而互不影响
可以通过java.lang.ThreadLocal
类来实现线程本地存储的功能。
- 每一个线程的Thread对象中都有一个
ThreadLocalMap
对象, ThreadLocalMap
对象存储了一组以ThreadLocal.threadLocalHashCode
为键,以本地线程变量为值的K-V值对,
ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。
ThreadLocal 内存泄露问题
ThreadLocalMap 中使⽤的 key 为 ThreadLocal 的弱引⽤,⽽ value 是强引⽤。所以,如果ThreadLocal 没有被外部强引⽤的情况下,在垃圾回收的时候,key 会被清理掉,⽽ value 不会被清理掉。这样⼀来, ThreadLocalMap 中就会出现key为null的Entry。假如我们不做任何措施的话,value 永远⽆法被GC 回收,这个时候就可能会产⽣内存泄露。ThreadLocalMap实现中已经考虑了这种情况,在调⽤ set() 、 get() 、 remove() ⽅法的时候,会清理掉 key 为 null 的记录。使⽤完ThreadLocal ⽅法后 最好⼿动调⽤ remove() ⽅法
JUC并发包概述
本部分回答以下几个问题,如果能回答正确,则证明本部分掌握好了。
- JUC并发包下有哪些内容,作用分别是什么
接下来我们看这部分的内容。JUC并发包提供了一切并发底层原理和实现机制的封装,并且做了大幅度的扩展
JDK并发工具类是JDK1.5引入的一大重要的功能,集中在Java.util.concurrent包下。java.util.concurrent包主要包含了原子类、并发锁、并发集合和队列、线程池、并发工具类
- 原子类,提供了一系列原子操作
- 并发锁,提供了一系列并发锁,主要关注ReentrantLock
- 并发集合和队列,提供了一系列并发集合,主要关注ConcurrentHashMap
- 线程池,提供了线程池进行操作,主要关注线程池参数和常用线程池
- 并发工具类,提供了一些并发工具,都大致了解下即可
接下来会分布进行介绍
JUC并发包下原子类
本部分回答以下几个问题,如果能回答正确,则证明本部分掌握好了。
- 原子类有哪些,简单介绍下AtomicInteger
- CAS操作什么原理?产生的ABA问题是什么?如何解决
接下来我们看这部分的内容。
原子类
atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰,所以,所谓原子类说简单点就是具有原子操作特征的类,原子操作类提供了一些修改数据的方法,这些方法都是原子操作的,在多线程情况下可以确保被修改数据的正确性,我们在前边的Java并发机制底层实现中了解到,通过CAS操作可以实现原子操作,整体分类如下
共有如下几种分类
AtomicInteger:int 类型原子类
AtomicLong:long 类型原子类
AtomicBoolean :boolean类型原子类
AtomicIntegerArray:整形数组原子操作类
AtomicLongArray:长整形数组原子操作类
AtomicReferenceArray :引用类型数组原子操作类
AtomicReference:引用类型原子类
AtomicStampedRerence:原子更新引用类型里的字段原子类 //可以用时间戳解决ABA问题
AtomicMarkableReference :原子更新带有标记位的引用类型
AtomicIntegerFieldUpdater:原子更新整形字段的值
AtomicLongFieldUpdater:原子更新长整形字段的值
AtomicReferenceFieldUpdater :原子更新应用类型字段的值
由于使用上大多类似 ,这里仅以AtomicInteger的常用方法为例进行说明::
public final int get() //获取当前的值
public final int getAndSet(int newValue)//获取当前的值,并设置新的值
public final int getAndIncrement()//获取当前的值,并自增
public final int getAndDecrement() //获取当前的值,并自减
public final int getAndAdd(int delta) //获取当前的值,并加上预期的值
//如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update)
boolean compareAndSet(int expect, int update)
//最终设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。
public final void lazySet(int newValue)
CAS操作
AtomicInteger 类主要利⽤ CAS (compare and swap) + volatile 和 native ⽅法来保证原⼦操作,从⽽避免 synchronized 的⾼开销,执⾏效率⼤为提升。CAS的原理是拿期望的值和原本的⼀个值作⽐较,如果相同则更新成新的值。UnSafe 类的objectFieldOffset() ⽅法是⼀个本地⽅法,这个⽅法是⽤来拿到“原来的值”的内存地址,返回值是valueOffset。另外 value 是⼀个volatile变量,在内存中可⻅,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。我们从getAndAdd其实现的源码中可以看出:
private static final Unsafe unsafe = Unsafe.getUnsafe();
//value属性在AtomicInteger中的偏移量,通过这个偏移量可以快速定位到value字段,这个是实现AtomicInteger的关键
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
private volatile int value; //使用volatile修饰,可以确保value在多线程中的可见性。
可以通过一个方法的源码来看其调用方式:
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2); //可以确保从主内存中获取变量最新的值
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); //CAS自旋等待,多线程情况下安全
return var5;
}
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
getAndAddInt操作相当于线程安全的count++操作
synchronize(lock){
count++;
}
synchronize的方式会导致占时无法获取锁的线程处于阻塞状态,性能比较低。CAS的性能比synchronize要快很多
ABA问题
如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。从Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值
普通情况下ABA问题没有危害,不过可以看一种特殊场景,场景是用链表来实现一个栈,初始化向栈中压入B、A两个元素,栈顶head指向A元素。head(A)->B
Thread thread1 = new Thread(
->{
oldValue = head;
sleep(3秒);
//thread2切换执行
compareAndSet(oldValue, B);
}
);
Thread thread2 = new Thread(
->{
// 弹出A
newHead = head.next;
head.next = null; //即A.next = null;
head = newHead;
// 弹出B
newHead = head.next;
head.next = null; // 即B.next = null;
head = newHead; // 此时head为null
// 压入C
head = C;
// 压入D
D.next = head;
head = D;
// 压入A
A.next = D;
head = A;
}
);
thread1.start();
thread2.start();
- 线程1试图将栈顶换成B,但它获取栈顶的oldValue(head,也就是A)后,被线程2中断了。
- 线程2依次将A、B弹出,然后压入C、D、A。
head(A)->D->C
- 然后换线程1继续运行,线程1执行compareAndSet发现head指向的元素确实与oldValue一致,都是A,所以就将head指向B了。
head(B)
但是,线程2在弹出B的时候,将B的next置为null了,因此在线程1将head指向B后,栈中只剩元素B。但按预期来说,栈中应该放的是B → A → D → C
AtomicStampedRerence
可以解决ABA问题,他内部不仅维护了对象的值,还维护了一个时间戳(我们这里把他称为时间戳,实际上它可以使用任何一个整形来表示状态值),当AtomicStampedRerence
对应的数值被修改时,除了更新数据本身外,还必须要更新时间戳。当AtomicStampedRerence
设置对象值时,对象值及时间戳都必须满足期望值,写入才会成功。因此,即使对象值被反复读写,写回原值,只要时间戳发生变量,就能防止不恰当的写入
JUC并发包下的锁
本部分回答以下几个问题,如果能回答正确,则证明本部分掌握好了。
- JUC下锁的继承关系模式
- AQS框架是什么?解决什么问题
接下来我们看这部分的内容。
JUC下的锁结构
根据锁出现在Java中的时间,Java中的锁,可以分为同步锁和JUC包中的锁,同步锁指的是通过synchronized关键字来进行同步,实现对竞争资源的互斥访问的锁,同步锁的原理是:
- 对于每一个对象,有且仅有一个同步锁,不同的线程能共同访问该同步锁。
- 在同一个时间点,该同步锁能且只能被一个线程获取到。
这样,获取到同步锁的线程就能进行CPU调度,从而在CPU上执行;而没有获取到同步锁的线程,必须进行等待,直到获取到同步锁之后才能继续运行
相比同步锁,JUC包中的锁的功能更加强大,它为锁提供了一个框架,该框架允许更灵活地使用锁。
AQS框架
AbstractQueuedSynchronizer就是被称之为AQS的类,它是一个非常有用的超类,可用来定义锁以及依赖于排队阻塞线程的其他同步器,ReentrantLock,ReentrantReadWriteLock,CountDownLatch,CyclicBarrier和Semaphore等这些类都是基于AQS类实现的
AQS核⼼思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的⼯作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占⽤,那么就需要⼀套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是⽤CLH队列锁实现的,即将暂时获取不到锁的线程加⼊到队列中
AQS使⽤⼀个int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队⼯作。AQS使⽤CAS对该同步状态进⾏原⼦操作实现对其值的修改
/**
* The synchronization state.
*/
private volatile int state;
/**
* Returns the current value of synchronization state.
* This operation has memory semantics of a {@code volatile} read.
* @return current state value
*/
protected final int getState() {
return state;
}
/**
* Sets the value of synchronization state.
* This operation has memory semantics of a {@code volatile} write.
* @param newState the new state value
*/
protected final void setState(int newState) {
state = newState;
}
/**
* Atomically sets synchronization state to the given updated
* value if the current state value equals the expected value.
* This operation has memory semantics of a {@code volatile} read
* and write.
*
* @param expect the expected value
* @param update the new value
* @return {@code true} if successful. False return indicates that the actual
* value was not equal to the expected value.
*/
protected final boolean compareAndSetState(int expect, int update) {
// See below for intrinsics setup to support this
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
AQS 对资源的共享⽅式
AQS定义两种资源共享⽅式
- Exclusive(独占):只有⼀个线程能执⾏,如ReentrantLock。⼜可分为公平锁和⾮公平锁:公平锁:按照线程在队列中的排队顺序,先到者先拿到锁,⾮公平锁:当线程要获取锁时,⽆视队列顺序直接去抢锁,谁抢到就是谁的
- Share(共享):多个线程可同时执⾏,如Semaphore、CountDownLatch、 CyclicBarrier等
- ReentrantReadWriteLock 可以看成是组合式,因为ReentrantReadWriteLock也就是读写锁允许多个线程同时对某⼀资源进⾏读。
不同的⾃定义同步器争⽤共享资源的⽅式也不同。⾃定义同步器在实现时只需要实现共享资源 state的获取与释放⽅式即可,⾄于具体线程等待队列的维护(如获取资源失败⼊队/唤醒出队等),AQS已经在顶层实现好了。
AQS底层使用了模板方法模式
同步器的设计是基于模板⽅法模式的,如果需要⾃定义同步器⼀般的⽅式是这样(模板⽅法模式很经典的⼀个应⽤):
- 使⽤者继承AbstractQueuedSynchronizer并重写指定的⽅法。(这些重写⽅法很简单,⽆⾮是对于共享资源state的获取和释放)
- 将AQS组合在⾃定义同步组件的实现中,并调⽤其模板⽅法,⽽这些模板⽅法会调⽤使⽤者重写的⽅法。这和我们以往通过实现接⼝的⽅式有很⼤区别,这是模板⽅法模式很经典的⼀个运⽤。
AQS使⽤了模板⽅法模式,⾃定义同步器时需要重写下⾯⼏个AQS提供的模板⽅法:
isHeldExclusively()//该线程是否正在独占资源。只有⽤到condition才需要去实现它。
tryAcquire(int)//独占⽅式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int)//独占⽅式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int)//共享⽅式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可⽤资源;正数表示成功,且有剩余资源。
tryReleaseShared(int)//共享⽅式。尝试释放资源,成功则返回true,失败则返回false。
这些⽅法的实现必须是内部线程安全的,并且通常应该简短⽽不是阻塞。AQS类中的其他⽅法都是final ,所以⽆法被其他类使⽤,只有这⼏个⽅法可以被其他类使⽤,例如ReentrantLock实现一个非公平锁
/**
* Creates an instance of {@code ReentrantLock}.
* This is equivalent to using {@code ReentrantLock(false)}.
*/
public ReentrantLock() {
sync = new NonfairSync();
}
非公平锁的底层实现调用并重写了tryAcquire
static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563782823691L;
/**
* Performs lock. Try immediate barge, backing up to normal
* acquire on failure.
*/
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
JUC并发包下的工具类
本部分回答以下几个问题,如果能回答正确,则证明本部分掌握好了。
- JUC下有哪些并发工具类,它们的作用分别是什么
接下来我们看这部分的内容。
JUC并发包下有四个并发工具类,闭锁CountDownlatch、栅栏CyclicBarrier、信号量Semaphore、交换器Exchanger。
- CountDownlatch通常用于主线程等待其他任务线程执行完毕的场景,类似于Join。它的特点在于它可以让主线程一直等待其它几个线程执行完的时候再执行,例如使用三个线程来打印三个List,三个线程任务都完成得时候才允许主线程继续输出Print Task Finish!
- 通过
CountDownLatch countDownLatch = new CountDownLatch(3);
设置主线程的等待个数 - 每个线程执行完一次后等待数减一,
countDownLatch.countDown(); //锁减去1
- 通过
- CyclicBarrier主要阻塞当前线程,等待其他线程(大家无论谁先跑到A点,必须要等其他线程也到达了A点,大家才能继续)。相当于大家都干完了自己手头的活然后在一个临界点等待,集齐后一起发力往下执行。可以看的出,CountDownLatch的作用是允许1或N个线程等待其他线程完成执行;而CyclicBarrier则是允许N个线程相互等待
CyclicBarrier cyclicBarrier = new CyclicBarrier(3);
设置三个线程到达栅栏后统一执行- 每个线程在执行代码里显示的用
cyclicBarrier.await(); //栅栏唤醒,拦住执行线程
,然后等待大家来齐后一起继续执行
- 信号量Semaphore可以用来控制同时访问特定资源的线程数量(比如100个线程只能有10个线程可以获得MySQL连接)。Semaphore也叫信号量,可以用来控制同时访问特定资源的线程数量,通过协调各个线程,以保证合理的使用资源。多个线程在到达Semaphore后获取令牌,其中几个被拿到继续执行,执行完后,释放令牌,其它的线程一起争抢。
- 通过
Semaphore semaphore = new Semaphore(5);
设置信号量的个数,在限制被解除前,一次只能有5个线程能活动,即使开启了20个线程。**信号量维护了一个信号量许可集。 - 每个线程执行时遇到
semaphore.acquire();
表示开启信号量限制,线程可以通过调用acquire()来获取信号量的许可;当信号量中有可用的许可时,线程能获取该许可;否则线程必须等待,直到有可用的许可为止。 - 线程可以通过
semaphore.release();
来释放它所持有的信号量许可
- 通过
- 交换器Exchanger很少用,只适用于两个线程在同步点交换数据的场景,设置一个同步点,在这个同步点多个线程间两两之间线程可以交换彼此的数据
- 通过方法
Exchanger<String> exchanger = new Exchanger<>();
设置一个同步点 - 在同步点设置自己要交换的信息,
String girl = exchanger.exchange("hi girl");
- 打印的时候打印的就是对方线程给出的变量,例如
System.out.println("girl said: " + girl);
打印出的是girl said: hi boy
- 通过方法
其实重点关注下CyclicBarrier和CountDownlatch即可,这两个工具比较常用一些。
JUC并发包下的容器类
本部分回答以下几个问题,如果能回答正确,则证明本部分掌握好了。
- 普通集合类为什么线程不安全?
- ConcurrentHashMap的实现原理和常用方法
- CopyOnWrite类的实现原理和使用方式
接下来我们看这部分的内容。
线程不安全的集合类
如果使用线程不安全的集合极容易出现问题,例如两个线程同时往一个list里添加元素,他们同时判断一个索引上没有值,同时添加,那么实际上只添加了一次,我们举个例子看看:
public class ThreadTest {
public static void main(String[] args) throws InterruptedException {
List<String> list=new ArrayList<>();
for (int i=0;i<500;i++) {
Thread thread=new Thread(()->{
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
list.add(Thread.currentThread().getName()+"线程添加的一个元素");
});
thread.start();
}
Thread.sleep(2000);
System.out.println("tml说在线程不安全条件下,500个线程并发后list只增加了"+list.size()+"个元素");
}
}
打印结果为:
tml说在线程不安全条件下,500个线程并发后list只增加了493个元素
如果我们换成线程安全的Vector:
public class ThreadTest {
public static void main(String[] args) throws InterruptedException {
Vector<String> vector=new Vector<>();
for (int i=0;i<500;i++) {
Thread thread=new Thread(()->{
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
vector.add(Thread.currentThread().getName()+"线程添加的一个元素");
});
thread.start();
}
Thread.sleep(2000);
System.out.println("tml说在线程安全条件下,500个线程并发后vector也增加了"+vector.size()+"个元素");
}
}
返回结果为:
tml说在线程安全条件下,500个线程并发后vector也增加了500个元素
其实不光写会造成问题,在同一时间多个线程无法对同一个List进行读取和增删,否则就会抛出并发异常,因为在读的时候被别人改了
Exception in thread "Thread-403" java.lang.ArrayIndexOutOfBoundsException: 366
at java.util.ArrayList.add(ArrayList.java:463)
at com.company.ThreadTest.lambda$main$0(ThreadTest.java:18)
at java.lang.Thread.run(Thread.java:748)
tml说在线程不安全条件下,500个线程并发后list只增加了491个元素
综合以上考虑,线程安全的实现类有vector,stack,hashtable 为了方便,我们将前面介绍集合类统称为java集合包。java集合包大多是非线程安全的,虽然可以通过Collections工具类中的方法获取java集合包对应的同步类,但是这些同步类的并发效率并不是很高。为了更好的支持高并发任务,Java在JUC包中添加了java集合包中单线程类的对应的支持高并发的类。例如,ArrayList对应的高并发类是CopyOnWriteArrayList,HashMap对应的高并发类是ConcurrentHashMap,等等。
ConcurrentHashMap
ConcurrentHashMap是线程安全且高效的HashMap。一起了解下该容器是如何在保证线程安全的同时又能保证高效的操作。
- 线程不安全的HashMap,在多线程环境下,使用HashMap进行put操作会引起死循环,导致CPU利用率接近100%,所以在并发情况下不能使用HashMap,因为多线程会导致HashMap的Entry链表形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry
- 效率低下的HashTable,HashTable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable的效率非常低下。因为当一个线程访问HashTable的同步方法,其他线程也访问HashTable的同步方法时,会进入阻塞或轮询状态。如线程1使用put进行元素添加,线程2不但不能使用put方法添加元素,也不能使用get方法来获取元素,所以竞争越激烈效率越低
HashTable容器在竞争激烈的并发环境下表现出效率低下的原因是所有访问HashTable的线程都必须竞争同一把锁,假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术。首先将数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问
ConcurrentHashMap结构
ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。
- Segment,一个ConcurrentHashMap里包含一个Segment数组。Segment的结构和HashMap类似,是一种数组和链表结构,【JDK1.7】Segment是一种可重入锁(ReentrantLock),在ConcurrentHashMap里扮演锁的角色
- HashEntry,则用于存储键值对数据。一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得与它对应的Segment锁
整体结构如下,数据结构为:Segment+HashEntry数组+链表
JDK 1.8中ConcurrentHashMap的实现已经摒弃了Segment的概念,而是直接使用Node数组+链表+红黑树(与HashMap的底层实现相同)的数据结构实现,并发控制使用了synchronized和CAS操作。整体就像是优化过且线程安全的HashMap,虽然在JDK 1.8中还能看到Segment的数据结构,但已经简化了其属性,只是为了兼容旧版本
ConcurrentHashMap初始化
ConcurrentHashMap初始化方法是通过initialCapacity、loadFactor和concurrencyLevel等几个参数来初始化segment数组、段偏移量segmentShift、段掩码segmentMask和每个segment里的HashEntry数组来实现的
- initialCapacity,ConcurrentHashMap的初始容量,初始默认为16
- concurrencyLevel/ssize,segments数组的大小,默认为16,最大为65536,concurrencyLevel 表示并发度,默认16。并发度可以理解为程序运行时能够同时更新ConccurentHashMap且不产生锁竞争的最大线程数,实际上就是ConcurrentHashMap中的分段锁个数,即segments数组的长度
- loadFactor, 扩容因子,默认0.75,当一个Segment存储的元素数量大于
threshold
时,该Segment会进行一次扩容 - cap,segment里HashEntry数组的长度,为initialCapacity除以ssize的倍数,如果c大于1,就会取大于等于c的2的N次方值,所以cap不是1就是2的N次方
- threshold,单个segment的容量,值为
threshold = (int)cap*loadFactor
。
那么我们计算的时候可以依据初始值来进行一系列计算。例如initialCapacity为16个元素,负载因子设置为0.75,ssize为16,则c=16/16等于1,则cap为1,也就是每个segment数组长度为1,threshold 容量为(int)1*0.75=0
初始化segments数组
下面为初始化segments数组的源码
if(concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
int sshift = 0;
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
segmentShift = 32 - sshift;
segmentMask = ssize -1;
this.segments = Segment.newArray(ssize);
由上面代码可知,ssize用位运算来计算(ssize <<= 1),所以segments数组的大小取值为2的N次方,即为大于或等于concurrencyLevel的最低的N次方值来作为segment数组的长度。假如concurrencyLevel等于14、15或16,ssize都会等于16,即容器里锁的个数也是16
当然concurrencyLevel最大只能用16位的二进制来表示,即65535,这意味着segments数组的长度最大为65536,对应的二进制为16位
初始化segmentShift和segmentMask
这两个全局变量需要在定位segment的时的散列算法里使用,由初始化segments数组的代码中可知,
- sshift等于ssize从1向左移位的次数,在默认情况下concurrencyLevel等于16,则1需要向左移位移动4次,所以sshift等于4
- 段偏移量segmentShift用于定位参与散列预算的位数,segmentShift = 32 - sshift,所以默认为28.
- segmentMask是散列运算的掩码,segmentMask = ssize -1,即默认为15,掩码的二进制各个位的值都是1。
因为ssize的最大长度为65536,所以segmentShift最大值为16,segmentMask最大值为65535,对应的二进制为16位,每个位都是1。
初始化每个segment
输入参数initialCapacity是ConcurrentHashMap的初始化容量,loadfactor是每个segment的负载因子,在构造方法中需要通过这两个参数来初始化数组中的每个segment
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
int cap = 1;
while (cap < c)
cap <<= 1;
for (int i = 0; i < this.segments.length; ++i)
this.segments[i] = new Segment<K,V>(cap, loadFacotr);
上述代码中的cap是segment里HashEntry数组的长度,它等于initialCapacity除以ssize的倍数,如果c大于1,就会取大于等于c的2的N次方值,所以cap不是1就是2的N次方。segment的容量threshold = (int)cap*loadFactor
,默认情况下initialCapacity
等于16,也就是容纳16个元素,loadFactor(负载因子)等于0.75,通过计算cap=1,threshold=0
定位Segment
ConcurrentHashMap使用分段所Segment来保护不同段的数据,那么在插入和获取元素的时候,必须先通过散列算法定位到某一个Segment。ConcurrentHashMap首先选用Wang/Jenkins hash的变种算法对元素的hashCode进行一次再散列。
private static int hash(int h) {
h += (h << 15) ^ 0xffffcd7d;
h ^= (h >>> 10);
h += (h << 3);
h ^= (h >>> 6);
h += (h << 2) + (h << 14);
return h ^ (h >>> 16);
}
使用再散列算法,目的为了减少散列冲突,使元素能够均有地分步在不同的Segment上,从而提高容器的存取效率。假如散列的质量差到极点,那么所有的元素都在一个Segment中,不仅存取元素缓慢,分段所也会失去意义。在JDK 1.7中ConcurrentHashMap通过以下散列算法定位segment
final Segment<K,V> segmentFor(int hash) {
return segments[(hash >>> segmentShift) & segmentMask];
}
Put/Get/Size操作
由于JDK1.7和1.8的底层实现和方法有所不同,所以我们这里分别介绍下:
Put操作
JDK1.7中的put操作如下
static class Segment<K,V> extends ReentrantLock implements Serializable
从上述Segment的继承体系中可以看出,Segment实现了ReentrantLock,也就带有锁的功能。由于put方法需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量是,必须加锁。
- 对segment加锁,在加锁时,会通过继承ReentrantLock的tryLock()方法尝试获取锁,若获取成功,就直接在相应的位置插入;若已经有线程获取了该Segment的锁,那当前线程会以自旋的方式继续调用tryLock()方法获取锁,超过指定次数就挂起,等待唤醒
- 判断是否需要对Segment里的HashEntry数组进行扩容
- 定位添加元素的位置,然后将其放在HashEntry数组中
其中如果需要扩容该如何扩容呢?
- 在插入元素前会先判断Segment里的HashEntry数组是否超过容量threshold,如果超过了阈值,则对数组进行扩容。
- 建立一个容量是原来两倍的数组,然后将原数组中的元素再散列后插入到新数组中。为了高效,
ConcurrentHashMap
不会对整个容器进行扩容,而是只对某个segment进行扩容
这里的扩容方式与HashMap的扩容方式稍有不同,HashMap是在插入元素之后判断元素是否已经达到容量,如果达到了就进行扩容,但是有可能扩容之后就没有新元素插入,则HashMap就进行了一次无效的扩容。
JDK1.8中的put操作如下
对当前table进行无条件自循环put成功,可以分成以下步骤
- 如果没有初始化就调用initTable()方法来进行初始化过程,若通过散列得到的位置中没有节点,则不加锁直接将节点通过CAS操作插入
- 如果发现该桶中有一个节点,需要扩容则进行扩容
- 如果存在hash冲突,就加锁来保证线程安全,这里存在两种情况:一种是链表形式就直接遍历到尾部插入;另一种是红黑树形式,就按红黑树结构插入。判断依据是如果链表的数量大于阈值,则先转换为红黑树结构,再一次进入循环
如果添加成功,则调用addCount()统计size,并且检查是否需要扩容。
Get操作
JDK1.7中的get操作如下
public V get(Object key) {
int hash = hash(key.hashCode()); //1,先经过一次再散列
return segmentFor(hash).get(key, hash); //2,使用这个散列值通过散列运算定位到Segment,再通过散列算法定位到元素
}
JDK1.8中的get操作如下
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
,
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 1,计算hash值,定位到该table索引位置,如果是首节点符合,则返回
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 2. 如果遇到扩容的时候,会调用标志正在扩容节点Forwarding Node的find方法,查找该节点,若匹配则返回
// 若查找到就返回
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
// 3,若既不是首节点也不是forwarding node,则向下遍历
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
get操作的高效体现在整个get过程不需要加锁,除非读到的值是空才会加锁重读,因为它的get方法里将使用的共享变量都定义成volatile类型。例如用于统计当前Segment大小的count字段和用于存储值的HashEntry的value。定义成volatile的变量,能够在线程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值(由Java内存模型的happen before原则保证),但是只能被单线程写(有一种情况可以被多线程写,就是写入的值不依赖于原值)。在get操作期间,只需要读取共享变量count和value值,所以不需要加锁
Size操作
计算ConcurrentHashMap的元素大小,就必须要统计Segment里的元素的大小后求和。上面说过Segment的全局变量count是一个volatile变量,在并发的场景下,可能会导致计算出来的size值和实际的size值有偏差。因为在计算count值的时候,可能有新数据的插入,导致结果的不准确。那么,最安全的做法就是在统计size的时候把所有Segment的put、remove和clean方法全部锁住,但这种做法显然是非常低效的。
JDK1.7中的size操作如下
JDK 1.7中是如下处理的,先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如果统计的过程中,容器的count发生了变化,则在采用加锁的方式来统计所有Segment的大小。使用modCount变量判断容器是否发生了变化,在put、remove和clean方法里操作元素前都会将变量modCount进行加1操作,那么在统计size前后比较modCount是否发生变化,从而得知容器的大小是否发生变化
try {
for (;;) {
if (retries++ == RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j) ensureSegment(j).lock(); // force creation
}
sum = 0L;
size = 0;
overflow = false;
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) { sum += seg.modCount; int c = seg.count; if (c < 0 || (size += c) < 0)
overflow = true;
} }
if (sum == last) break; // 进行2次统计
last = sum; } }
finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
JDK1.8中的size操作如下
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a; // 变化的数量
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
CopyOnWriteArray写时复制
JUC集合包中的List和Set实现类按照写时复制的实现原理包括: CopyOnWriteArrayList和 CopyOnWriteArraySet。
写时复制原理解决了并发修改异常,每当有写入的时候,这个时候CopyOnWriteArrayList 底层实现添加的原理是先copy出一个容器(可以简称副本),再往新的容器里添加这个新的数据,最后把新的容器的引用地址赋值给了之前那个旧的的容器地址,但是在添加这个数据的期间,其他线程如果要去读取数据,仍然是读取到旧的容器里的数据。
- CopyOnWriteArrayList相当于线程安全的ArrayList,它实现了List接口。CopyOnWriteArrayList是支持高并发的。
- CopyOnWriteArraySet相当于线程安全的HashSet,它继承于AbstractSet类。 内部包含一个
CopyOnWriteArrayList
对象(聚合关系),它是通过CopyOnWriteArrayList实现的
这种方式采用了写时加锁复制,读时读旧容器不需任何处理,这种方式有两个显著的缺点:
- 内存占用问题,很明显,两个数组同时驻扎在内存中,如果实际应用中,数据比较多,而且比较大的情况下,占用内存会比较大,针对这个其实可以用ConcurrentHashMap来代替。
- 数据一致性问题,CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器
基于此我们一般不会大量使用。
JUC并发包下的线程池
本部分回答以下几个问题,如果能回答正确,则证明本部分掌握好了。
- 线程池的参数列表,分别什么含义
- 线程池的常用方法和方法比较
- 线程池的执行流程,任务拒绝策略有哪些
- Java提供了哪些预置线程池
接下来我们看这部分的内容。
线程池定义
线程池的整体定义如下,包含下列的参数列表:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {}
可以看到,其需要如下几个参数:
- corePoolSize(必需):核心线程数。默认情况下,核心线程会一直存活,但是当将
allowCoreThreadTimeout
设置为true时,核心线程也会超时回收。在创建了线程池后,默认情况下,线程池中并没有任何线程,而是等待有任务到来才创建线程去执行任务,除非调用了prestartAllCoreThreads()
或者prestartCoreThread()
方法,从这2个方法的名字就可以看出,是预创建线程的意思,即在没有任务到来之前就创建corePoolSize个线程或者一个线程。默认情况下,在创建了线程池后,线程池中的线程数为0,当有任务来之后,就会创建一个线程去执行任务,当线程池中的线程数目达到corePoolSize后,就会把到达的任务放到缓存队列当中 - maximumPoolSize(必需):线程池所能容纳的最大线程数。当活跃线程数达到该数值后,后续的新任务将会阻塞
- keepAliveTime(必需):表示线程没有任务执行时最多保持多久时间会终止。默认情况下,只有当线程池中的线程数大于corePoolSize时,keepAliveTime才会起作用,直到线程池中的线程数不大于corePoolSize,即当线程池中的线程数大于corePoolSize时,如果一个线程空闲的时间达到keepAliveTime,则会终止,直到线程池中的线程数不超过corePoolSize。但是如果调用了allowCoreThreadTimeOut(boolean)方法,在线程池中的线程数不大于corePoolSize时,keepAliveTime参数也会起作用,直到线程池中的线程数为0;
- unit(必需):指定keepAliveTime参数的时间单位。常用的有:TimeUnit.MILLISECONDS(毫秒)、TimeUnit.SECONDS(秒)、TimeUnit.MINUTES(分)。
- workQueue(必需):任务队列。通过线程池的execute()方法提交的Runnable对象(等待执行的任务)将存储在该参数中。其采用
阻塞队列
实现。ArrayBlockingQueue和PriorityBlockingQueue使用较少,一般使用LinkedBlockingQueue和Synchronous。线程池的排队策略与BlockingQueue有关,关于队列和并发集合在本系列的其它Blog进行介绍 - threadFactory(可选):线程工厂。用于指定为线程池创建新线程的方式。线程工厂指定创建线程的方式,需要实现ThreadFactory接口,并实现newThread(Runnable r)方法。
- handler(可选):拒绝策略。当达到最大线程数时需要执行的饱和策略。
了解了具体参数后我们再看看其中几个参数的可选项。
阻塞队列workQueue选择
任务队列是基于阻塞队列实现的,即采用生产者消费者模式,在Java中需要实现BlockingQueue接口。但Java已经为我们提供了7种阻塞队列的实现:
- ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列(数组结构可配合指针实现一个环形队列)
- LinkedBlockingQueue: 一个由链表结构组成的有界阻塞队列,在未指明容量时,容量默认为Integer.MAX_VALUE
- PriorityBlockingQueue: 一个支持优先级排序的无界阻塞队列,对元素没有要求,可以实现Comparable接口也可以提供Comparator来对队列中的元素进行比较。跟时间没有任何关系,仅仅是按照优先级取任务。
- DelayQueue:类似于PriorityBlockingQueue,是二叉堆实现的无界优先级阻塞队列。要求元素都实现Delayed接口,通过执行时延从队列中提取任务,时间没到任务取不出来。
- SynchronousQueue: 一个不存储元素的阻塞队列,消费者线程调用take()方法的时候就会发生阻塞,直到有一个生产者线程生产了一个元素,消费者线程就可以拿到这个元素并返回;生产者线程调用put()方法的时候也会发生阻塞,直到有一个消费者线程消费了一个元素,生产者才会返回。
- LinkedBlockingDeque: 使用双向队列实现的有界双端阻塞队列。双端意味着可以像普通队列一样FIFO(先进先出),也可以像栈一样FILO(先进后出)。
- LinkedTransferQueue: 它是ConcurrentLinkedQueue、LinkedBlockingQueue和SynchronousQueue的结合体,但是把它用在ThreadPoolExecutor中,和LinkedBlockingQueue行为一致,但是是无界的阻塞队列。
注意有界队列和无界队列的区别:如果使用有界队列,当队列饱和时并超过最大线程数时就会执行拒绝策略;而如果使用无界队列,因为任务队列永远都可以添加任务,所以设置maximumPoolSize没有任何意义
拒绝策略(handler)
当线程池的线程数达到最大线程数时,需要执行拒绝策略。拒绝策略需要实现RejectedExecutionHandler
接口,并实现rejectedExecution(Runnable r, ThreadPoolExecutor executor)
方法。不过Executors框架已经为我们实现了4种拒绝策略:
- AbortPolicy(默认):丢弃任务并抛出RejectedExecutionException异常。
- CallerRunsPolicy:在任务被拒绝添加后,会由调用execute方法的的主线程来执行被拒绝的任务
- DiscardPolicy:丢弃任务,但是不抛出异常。可以配合这种模式进行自定义的处理方式。
- DiscardOldestPolicy:丢弃队列最早的未处理任务,然后重新尝试执行任务。
四种策略都可以进行选择
线程池状态与切换
在ThreadPoolExecutor中定义了一个volatile变量,另外定义了几个static final变量表示线程池的各个状态:
volatile int runState; //runState表示当前线程池的状态,它是一个volatile变量用来保证线程之间的可见性
static final int RUNNING = 0;//当创建线程池后,初始时,线程池处于RUNNING状态
//如果调用了shutdown()方法,则线程池处于SHUTDOWN状态,此时线程池不能够接受新的任务,它会等待所有任务执行完毕
static final int SHUTDOWN = 1;
//如果调用了shutdownNow()方法,则线程池处于STOP状态,此时线程池不能接受新的任务,并且会去尝试终止正在执行的任务
static final int STOP = 2;
//当线程池处于SHUTDOWN或STOP状态,并且所有工作线程已经销毁,任务缓存队列已经清空或执行结束后,线程池被设置为TERMINATED状态
static final int TERMINATED = 3;
线程池常用方法
线程池的使用示例如下:
// 创建线程池
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
// 向线程池提交任务
threadPool.execute(new Runnable() {
@Override
public void run() {
... // 线程执行的任务
}
});
// 关闭线程池
threadPool.shutdown(); // 设置线程池的状态为SHUTDOWN,然后中断所有没有正在执行任务的线程
// 设置线程池的状态为 STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表
threadPool.shutdownNow();
在ThreadPoolExecutor类中有几个非常重要的方法:
- execute()方法实际上是Executor中声明的方法,在ThreadPoolExecutor进行了具体的实现,这个方法是ThreadPoolExecutor的核心方法,通过这个方法可以向线程池提交一个任务,交由线程池去执行
- submit()方法是在ExecutorService中声明的方法,在AbstractExecutorService就已经有了具体的实现,在ThreadPoolExecutor中并没有对其进行重写,这个方法也是用来向线程池提交任务的,但是它和execute()方法不同,它能够返回任务执行的结果,去看submit()方法的实现,会发现它实际上还是调用的execute()方法,只不过它利用了Future来获取任务执行结果
shutdown()和shutdownNow()
是用来关闭线程池的。shutdown不会立即终止线程池,而是要等所有任务缓存队列中的任务都执行完后才终止,但再也不会接受新的任务, shutdownNow立即终止线程池,并尝试打断正在执行的任务,并且清空任务缓存队列,返回尚未执行的任务
还有很多其他的方法:比如:getQueue() 、getPoolSize() 、getActiveCount()、getCompletedTaskCount()
等获取与线程池相关属性的方法
线程池执行策略
描述一下线程池工作的原理,同时对上面的参数有一个更深的了解。其工作原理流程图如下图片来源:
可以简单的总结如下:
- 如果当前线程池中的线程数目小于corePoolSize,则每来一个任务,就会创建一个线程去执行这个任务;
- 如果当前线程池中的线程数目>=corePoolSize,则每来一个任务,会尝试将其添加到任务缓存队列当中,
- 若当前任务数<workQueue容量,添加成功,则该任务会等待空闲线程将其取出去执行;
- 若当前任务数>workQueue容量,添加失败,则会尝试创建新的线程去执行这个任务 - 如果当前线程池中的线程数目没有达到maximumPoolSize,则会创建新线程执行任务,并且根据keepAlive设置的闲置时间会自动销毁
- 如果当前线程池中的线程数目和任务队列都满了,则会采取任务拒绝策略进行处理;
需要注意,如果允许为核心池中的线程设置存活时间,那么核心池中的线程空闲时间超过keepAliveTime,线程也会被终止。
预置线程池
Executors中为我们预置了几种线程池,而让我们不必考虑上述线程池的一些参数,可以理解为一些最佳实践,这里列举一下以及简单介绍下它们的作用,定长线程池(FixedThreadPool), 定时线程池(ScheduledThreadPool ),可缓存线程池(CachedThreadPool)单线程化线程池(SingleThreadExecutor)四种
- 定长线程池,只有核心线程,线程数量固定,执行完立即回收,任务队列为链表结构的有界队列,应用场景主要是控制线程最大并发数,可持续发展,节约资源
- 定时线程池,核心线程数量固定,非核心线程数量无限,执行完闲置10ms后回收或周期性的执行任务,任务队列为延时阻塞队列,应用场景主要是执行定时或周期性的任务,定时周期执行
- 可缓存线程池,特点是无核心线程,非核心线程数量无限,执行完闲置60s后回收,任务队列为不存储元素的阻塞队列,应用场景主要为执行大量、耗时少的任务,短平快,短期大量,60s的缓存时间
- 单线程化线程池, 只有1个核心线程,无非核心线程,执行完立即回收,任务队列为链表结构的有界队列,应用场景为不适合并发但可能引起IO阻塞性及影响UI线程响应的操作,如数据库操作、文件操作等,一次只让一个线程干,安全稳定
以上这些只是Java预置的,但存在一些问题:
- FixedThreadPool 和 SingleThreadPool: 允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM。
- CachedThreadPool 和 ScheduledThreadPool: 允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致OOM
其实还是按照适合自己的场景自定义比较好。
死锁问题及解决方案
本部分回答以下几个问题,如果能回答正确,则证明本部分掌握好了。
- 死锁的问题如何产生,发生条件,死锁的示例
- 死锁如何解决
接下来我们看这部分的内容。
产生死锁的必要条件
所谓死锁,是指 多个进程循环等待它方占有的资源而无限期地僵持下去的局面。很显然,如果没有外力的作用,那麽死锁涉及到的各个进程都将永远处于封锁状态,只要下面四个条件有一个不具备,系统就不会出现死锁。
- 互斥条件,即某个资源在一段时间内只能由一个进程占有,不能同时被两个或两个以上的进程占有。这种独占资源如CD-ROM驱动器,打印机等等,必须在占有该资源的进程主动释放它之后,其它进程才能占有该资源。这是由资源本身的属性所决定的。如独木桥就是一种独占资源,两方的人不能同时过桥。
- 不可抢占条件,进程所获得的资源在未使用完毕之前,资源申请者不能强行地从资源占有者手中夺取资源,而只能由该资源的占有者进程自行释放。如过独木桥的人不能强迫对方后退,也不能非法地将对方推下桥,必须是桥上的人自己过桥后空出桥面(即主动释放占有资源),对方的人才能过桥。
- 占有且申请条件,进程至少已经占有一个资源,但又申请新的资源;由于该资源已被另外进程占有,此时该进程阻塞;但是,它在等待新资源之时,仍继续占用已占有的资源。还以过独木桥为例,甲乙两人在桥上相遇。甲走过一段桥面(即占有了一些资源),还需要走其余的桥面(申请新的资源),但那部分桥面被乙占有(乙走过一段桥面)。甲过不去,前进不能,又不后退;乙也处于同样的状况。
- 循环等待条件,存在一个进程等待序列{P1,P2,…,Pn},其中P1等待P2所占有的某一资源,P2等待P3所占有的资源源,…,而Pn等待P1所占有的的某一资源,形成一个进程循环等待环。就像前面的过独木桥问题,甲等待乙占有的桥面,而乙又等待甲占有的桥面,从而彼此循环等待。
上面我们提到的这四个条件在死锁时会同时发生。也就是说,只要有一个必要条件不满足,则死锁就可以排除,只要破坏这四个必要条件中的任意一个条件,死锁就不会发生。这就为我们解决死锁问题提供了可能。以下是一个死锁的示例:
public class TestMian {
//A、B 表示两把锁
String A = "A";
String B = "B";
public static void main(String[] args) {
TestMian testMian = new TestMian();
new Thread(()->{
try {
testMian.a();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
new Thread(()->{
try {
testMian.b();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
public void a() throws InterruptedException {
//持有锁A后,尝试持有锁B ***********重点**************
synchronized (A){
System.out.println("A");
TimeUnit.SECONDS.sleep(1);
synchronized (B){
System.out.println("B");
}
}
}
public void b() throws InterruptedException {
//持有锁B后,尝试持有锁A ***********重点**************
synchronized (B){
System.out.println("B");
TimeUnit.SECONDS.sleep(1);
synchronized (A){
System.out.println("A");
}
}
}
}
解决死锁问题
有两种策略和几种方式解决死锁的问题,一种是从代码编写上处理,另一种是从资源分配上处理,从代码的逻辑上去确定加锁的顺序和方式,避免死锁可以概括成三种方法:
- 固定加锁的顺序(针对锁顺序死锁)
- 开放调用(针对对象之间协作造成的死锁)
- 使用定时锁 tryLock();使用显式 Lock锁,在获取锁时使用 tryLock()方法。当等待超过时限的时候,tryLock()不会一直等待,而是返回错误信息。使用tryLock()能够有效避免死锁问题。
例如尽量使用ReentrantLock,使用它的锁可中断,定时中断机制。
public class DiffLockWithReentrantLock {
private int amount;
private final Lock lock = new ReentrantLock();
public DiffLockWithReentrantLock(int amount){
this.amount=amount;
}
private void transfer(DiffLockWithReentrantLock target, int transferAmount)
throws InterruptedException {
while (true) {
if (this.lock.tryLock()) {
try {
if (target.lock.tryLock()) {
try {
if(amount< transferAmount){
System.out.println("余额不足!");
}else{
amount=amount-transferAmount;
target.amount=target.amount+transferAmount;
}
break;
} finally {
target.lock.unlock();
}
}
} finally {
this.lock.unlock();
}
}
//随机sleep一定的时间,保证可以释放掉锁
Thread.sleep(1000+new Random(1000L).nextInt(1000));
}
}
}
第二种通过安全序列解决,所谓系统是安全的,是指系统中的所有进程能够按照某一种次序分配资源,并且依次地运行完毕,这种进程序列{P1,P2,…,Pn}就是安全序列。如果存在这样一个安全序列,则系统是安全的;如果系统不存在这样一个安全序列,则系统是不安全的。
安全序列{P1,P2,…,Pn}是这样组成的:若对于每一个进程Pi,它需要的附加资源可以被系统中当前可用资源加上所有进程Pi当前占有资源之和所满足,则{P1,P2,…,Pn}为一个安全序列,这时系统处于安全状态,不会进入死锁状态。
总结
行文至此已洋洋洒洒将近7万字,是我这两周的学习成果没错了,在此过程中学习了大量Blog以及《Java并发编程的艺术》,感觉和17年时候看待并发这件事发生了翻天覆地的变化,可以说透彻并深入的从底层内存模型并发机制到往上的JUC包的使用,整体算有个脉络了,一通百通,不光是Java,在并发编程中学到的一些思想其实可以复用到很多地方,例如Redis的跳表,数据库的排它锁、MVCC机制等。思想是相同的,只是实现不同而已,其实现在还有一些东西不够深入,例如内存屏障和指令级的变化,JUC包下的源码这些都还没研究透彻,之后自己有时间还是想好好研究研究,以上。