文章首发于微信公众号:BaronTalk,欢迎关注!git
高效并发是 JVM 系列的最后一篇,本篇主要介绍虚拟机如何实现多线程、多线程间如何共享和竞争数据以及共享和竞争数据带来的问题及解决方案。github
让计算机同时执行多个任务,不仅是由于处理器的性能更增强大了,更重要是由于计算机的运算速度和它的存储以及通讯子系统速度差距太大,大量的时间都花费在磁盘 I/O 、网络通讯和数据库访问上。为了避免让处理器由于等待其它资源而浪费处理器的资源与时间,咱们就必须采用让计算机同时执行多任务的方式去充分利用处理器的性能;同时也是为了应对服务端高并发的需求。而 Java 内存模型的设计和线程的存在正是为了更好、更高效的实现多任务。数据库
计算机中绝大多数的任务都不可能只靠处理器计算就能完成,处理器至少要和内存交互,如读取数据、存储结果等等,这个 I/O 操做是很难消除的。因为计算器的存储设备和处理器的运算速度有几个量级的差距,因此计算机不得不加入一层读写速度尽量接近处理器运算速度的高速缓存来做为内存与处理器之间的缓冲:将运算须要用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存中,这样处理器就无需等待缓慢的内存读写了。数组
基于高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,可是也为计算机系统带来更高的复杂度,由于它引入了一个新的问题:缓存一致性。在多处理器中,每一个处理器都有本身的高速缓存,而它们又共享同一主内存。当多个处理器的运算任务都涉及同一块主内存区域时,将可能致使各自的缓存数据不一致。为了解决一致性的问题,须要各个处理器的访问缓存时都遵循一些协议,在读写时要根据协议来进行操做。缓存
除了增长高速缓存外,为了使处理器内部的运算单元能尽可能被充分利用,处理器可能会对输入的代码进行乱序执行优化,处理器会在计算以后将乱序执行的结果重组,保证该结果与顺序执行的结果一致,但不保证程序中各个语句计算的前后顺序与输入代码中的顺序一致,所以,若是存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的前后顺序来保证。与处理器的乱象执行优化相似,JIT 编译器中也有相似的指令重排优化。安全
Java 虚拟机规范中定义了 Java 内存模型,用来屏蔽各类硬件和操做系统的内存访问差别,以实现让 Java 程序在各类平台下都能达到一致的内存访问效果。像 C/C++ 这类语言直接使用了物理硬件和操做系统的内存模型,所以会因为不一样平台上内存模型的差别,须要针对不一样平台来编写代码。微信
Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中读取变量这样的底层细节。这里说的变量和 Java 代码中的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但不包括变量和方法参数,由于后者是线程私有的,不会被共享。为了得到较好的执行性能,Java 内存模型并无限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制 JIT 编译器进行代码执行顺序这类优化措施。网络
Java 内存模型规定了全部的变量都存储在主内存,每条线程都有本身单独的工做内存,线程的工做内存中保存了被该线程使用到的变量的主内存的副本拷贝,线程对变量的全部操做都必须在工做内存中进行,而不能直接读写主内存,线程间变量值的传递均须要经过主内存来完成。多线程
关于主内存与工做内存间具体的交互协议,即一个变量如何从主内存拷贝到工做内存、如何从工做内存同步回主内存之类的细节,Java 内存模型定义了如下 8 种操做来完成,虚拟机实现时必须保证下面的每一种操做都是原子的、不可再分的。并发
这 8 种操做分别是:lock(锁定)、unlock(解锁)、read(读取)、load(载入)、use(使用)、assign(赋值)、store(存储)、write(写入)。
volatile 是 Java 虚拟机提供的最轻量级的同步机制。当一个变量被定义为 volatile 后,它将具有两种特性:
第一是保证此变量对全部线程的可见性,这里的「可见性」是指当一条线程修改了这个变量的值,新值对于其余线程来讲是能够当即得知的。普通变量则作不到这一点,须要经过主内存来在线程间传递数据。好比,线程 A 修改了一个普通的变量值,而后向主内存进行回写,另外一条线程 B 在 A 线程回写完成以后再从主内存进行读写操做,新变量值才会对线程 B 可见。
第二是禁止指令重排优化。普通变量仅仅会保证方法的执行过程当中全部依赖赋值结果的地方 可以获取到正确的结果,而不能保证变量赋值操做的顺序与程序代码中的执行顺序一致。由于在一个线程的方法执行过程当中没法感知到这点,这也就是 Java 内存模型中描述的所谓的「线程内表现为串行的语义」。
Java 内存模型要求 lock、unlock、read、load、assign、use、store、writer 这 8 个操做都具备原子性,但对于 64 位数据类型(long 和 double),在模型中特别定义了一条相对宽松的规定:容许虚拟机将没有被 volatile 修饰的 64 位数据的读写操做划分为两次 32 位的操做来进行,即容许虚拟机实现选择能够不保证 64 位数据类型的 load、store、read 和 write 这 4 个操做的原子性。这点就是所谓的 long 和 double 的非原子协定。
若是有多个线程共享一个未声明为 volatile 的 long 或 double 类型的变量,而且同时对它们进行读取和修改操做,那么某些线程可能会读取到一个错误的值。好在这种状况很是罕见,主流商业虚拟机中也都把对 long 和 double 的操做视为原子性,所以在实际开发中无需使用 volatile 来修饰变量。
Java 内存模型是围绕着在并发过程当中如何处理原子性、可见性和有序性 3 个特质来创建的。
若是 Java 内存模型中全部的有序性都仅仅靠 volatile 和 synchronized 来保证,那么有一些操做就会变得很繁琐,可是咱们在编写 Java 并发代码的时候并无感受到这一点,这是由于 Java 语言中有一个「先行发生」(happens-before)原则。这个原则很是重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,咱们能够经过几条规则一揽子解决并发环境下两个操做之间是否可能存在冲突的全部问题。
先行发生是 Java 内存模型中定义的两项操做之间的偏序关系,若是说操做 A 先行发生于操做 B,其实就是说在发生操做 B 以前,操做 A 产生的影响能被操做 B 观察到,「影响」包括修改了内存中共享变量的值、发送了消息、调用了方法等。
Java 内存模型下有一些自然的先行发生关系,这些先行发生关系无需任何同步器协助就已存在,能够在编码中直接使用。若是两个操做之间的关系不在此列,而且没法从下列规则推导出来,它们就没有顺序性保障,虚拟机就能够随意的对它们进行重排序。
程序次序规则:在一个线程内,按照程序代码顺序,写在前面的代码先行发生写在后面的代码。准确的讲,应该是控制流顺序而不是程序代码顺序,由于要考虑分支、循环等结构;
管程锁定规则:一个 unlock 操做先行发生于后面对于同一个锁的 lock 操做;
volatile 变量规则:对一个 volatile 变量的写操做先行发生于后面对这个变量的读操做,理解了这个原则咱们就能理解为何 DCL 单例模式中为何要用 volatile 来标识实例对象了;
线程启动规则:线程的 start() 方法先行发生于此线程的全部其它动做;
线程终止规则:线程中全部的操做都先行发生于对此线程的终止检测;
程序中断规则:对线程 interrupt() 的调用先行发生于被中断线程的代码检测到中断时间的发生;
对象终结规则:一个对象的初始化完成先行发生于它的 finalize() 的开始;
传递性:操做 A 先行发生于 B,B 先行发生于 C,那么 A 就先行发生于 C。
谈论 Java 中的并发,一般都是和多线程相关的。这一小节咱们就讲讲 Java 线程在虚拟机中的实现。
主流的操做系统都提供了线程实现,Java 语言则提供了在不一样硬件和操做系统平台下对线程操做的统一处理,每一个已经执行 start() 且还未结束的 Thread 类的实例就表明了一个线程。Thread 类全部关键方法都是 Native 的。Java API 中,一个 Native 方法每每意味着这个方法没有使用或者没法使用平台无关的手段来实现(固然也多是为了执行效率而使用 Native 方法,不过,一般最高效率的手段就是平台相关的手段)。
实现线程主要有 3 种方式:使用内核线程实现、使用用户线程实现、使用用户线程加轻量级进程混合实现。
Java 线程在 JDK 1.2 以前是基于称为「绿色线程」的用户线程实现的。而在 JDK 1.2 中,线程模型替换为基于操做系统原生线程模型来实现。所以,在目前的 JDK 版本中,操做系统支持怎样的线程模型,在很大程度上决定了 Java 虚拟机的线程是怎样映射的,这点在不一样的平台上没有办法达成一致,虚拟机规范中也没有限定 Java 线程须要使用哪一种线程模型来实现。线程模型只对线程的并发规模和操做成本产生影响,对 Java 程序的编码和运行过程来讲,这些差别都透明的。
线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,分别是协同式线程调度和抢占式线程调度。
若是是使用协同式调度的多线程系统,线程的执行时间由线程自己来控制,线程把本身的工做执行完以后,要主动通知系统切换到另一个线程上。协同式多线程的最大好处是实现简单,并且因为线程要把本身的事情作完后才会进行线程切换,切换操做对线程本身是可知的,全部没有线程同步的问题。可是它的坏处也很明显:线程执行时间不可控,甚至若是一个线程编写有问题,一直不告诉操做系统进行线程切换,那么程序就会一直阻塞在那里。好久之前的 Windows 3.x 系统就是使用协同式来实现对进程多任务,至关不稳定,一个进程坚持不让出 CPU 执行时间就可能致使整个系统崩溃。
若是是使用抢占式调度的多线程系统,那么每一个线程将由系统来分配执行时间,线程的切换不禁线程自己来决定。在这种实现线程调度的方式下,线程的执行实现是系统可控的,也不会有一个线程致使整个进程阻塞的问题,Java 使用的线程调度方式就是抢占式的。和前面所说的 Windows 3.x 的例子相对,在 Windows 9x/NT 内核中就是使用抢占式来实现多进程的,当一个进程出了问题,咱们还可使用任务管理器把这个进程「杀掉」,而不至于致使系统崩溃。
Java 语言定义了 5 种线程状态,在任意一个时间点,一个线程只能有且只有其中一种状态,它们分别是:
上述 5 中状态遇到特定事件发生的时候将会互相转换,以下图:
本文的主题是高效并发,但高效的前提是首先要保证并发的正确性和安全性,因此这一小节咱们先从如何保证线程并发安全提及。
那么什么是线程安全呢?能够简单的理解为多线程对同一块内存区域操做时,内存值的变化是可预期的,不会由于多线程对同一块内存区域的操做和访问致使内存中存储的值出现不可控的问题。
若是咱们不把线程安全定义成一个非此即彼的概念(要么线程绝对安全,要么线程绝对不安全),那么咱们能够根据线程安全的程度由强至弱依次分为以下五档:
不可变;
绝对线程安全;
相对线程安全;
线程兼容;
线程对立。
虽然线程安全与否与编码实现有着莫大的关系,但虚拟机提供的同步和锁机制也起到了很是重要的做用。下面咱们就来看看虚拟机层面是如何保证线程安全的。
互斥同步是常见的一种并发正确性保障的手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一时间只被一个线程使用。而互斥是实现同步的一种手段。Java 中最基本的互斥同步手段就是 synchronized 关键字,synchronized 关键字在通过编译以后,会在同步块的先后分别造成 monitorenter 和 monitorexit 这两个字节码指令,这两个字节码都须要一个 reference 类型的参数来指明要锁定和解锁的对象。若是 Java 程序中的 synchronized 明确指明了对象参数,那就是这个对象的 reference;若是没有,那就根据 synchronized 修饰的是实例方法仍是类方法,去取对应的对象实例或 class 对象来做为锁对象。
根据虚拟机规范的要求,在执行 monitorenter 指令时,首先要尝试获取对象的锁。若是这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,就把锁的计数器加 1;相应的,在执行monitorexit 指令时将锁计数器减 1,当锁计数器为 0 时,锁就被释放。若是获取锁对象失败,当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。
另外要说明的一点是,同步块在已进入的线程执行完以前,会阻塞后面其它线程的进入。因为 Java 线程是映射到操做系统原生线程之上的,若是要阻塞或者唤醒一个线程,都须要操做系统来帮忙完成,这就须要从用户态转换到内核态,线程状态转换须要耗费不少的处理器时间。对于简单的同步块(如被 synchronized 修饰的 getter() 和 setter() 方法),状态转换消耗的时间可能比用户代码消耗的时间还要长。因此 synchronized 是 Java 中一个重量级的操做,所以咱们只有在必要的状况下才应该使用它。固然虚拟机自己也会作相应的优化,好比在操做系统阻塞线程前加入一段自旋等待过程,避免频繁的用户态到内核态的转换过程。这一点咱们在介绍锁优化的时候再细聊。
非阻塞同步
互斥同步最大的问题就是进行线程阻塞和唤醒所带来的性能问题,所以这种同步也成为阻塞同步。从处理问题的方式上来讲,互斥同步是一种悲观的并发策略,认为只要不去作正确的同步措施(例如加锁),就确定会出问题,不管共享数据是否会出现竞争,它都要进行加锁(固然虚拟机也会优化掉一些没必要要的锁)。随着硬件指令集的发展,咱们有了另一个选择:基于冲突检查的乐观并发策略。通俗的说,就是先进行操做,若是没有其余线程竞争,那操做就成功了;若是共享数据有其它线程竞争,产生了冲突,就采起其它的补救措施,这种乐观的并发策略的许多实现都不须要把线程挂起,所以这种同步操做称为非阻塞同步。
前面之因此说须要硬件指令集的发展,是由于咱们须要操做和冲突检测这两个步骤具有原子性。
这个原子性靠什么来保证呢?若是这里再使用互斥同步来保证原子性就失去意义了,因此咱们只能靠硬件来完成这件事,保证一个从语义上看起来须要屡次操做的行为只经过一条处理器指令就能完成,这类指令经常使用的有:
前三条是以前的处理器指令集里就有的,后两条是新增的。
CAS 指令须要 3 个操做数,分别是内存位置(在 Java 中能够简单理解为变量的内存地址,用 V 表示)、旧的预期值(用 A 表示)和新值(用 B 表示)。CAS 执行指令时,当且仅当 V 符合旧预期值 A 时,处理器用新值 B 更新 V 的值,不然他就不执行更新,可是不管是否更新了 V 的值,都会返回 V 的旧值,上述的处理过程是一个原子操做。
在 JDK 1.5 以后,Java 程序中才可使用 CAS 操做,该操做由 sun.misc.Unsafe 类里的 compareAndSwapInt() 和 compareAndSwapLong() 等几个方法包装提供,虚拟机在内部对这些方法作了特殊处理,即时编译出来的结果就是一条平台相关的处理器 CAS 指令,没有方法的调用过程,或者能够认为是无条件内联进去了。
因为 Unsafe 类不是提供给用户程序调用的类,所以若是不用反射,咱们只能经过其余的 Java API 来间接使用,好比 J.U.C 包里的整数原子类,其中的 compareAndSet() 和 getAndIncrement() 等方法都使用了 Unsafe 类的 CAS 操做。
尽管 CAS 看起来很美,可是这种操做却没法覆盖互斥同步的全部场景,而且 CAS 从语义上来讲并非完美的。若是一个变量 V 初次读取的时候是 A 值,而且在准备赋值的时候检查它仍然是 A 值,那咱们就能说它的值没有被其余线程修改过吗?若是在这段时间内曾经被改成了 B,后来又被改回为 A,那 CAS 操做就会认为它历来没有被改变过。这个漏洞称为 CAS 操做的「ABA」问题。
为了解决「ABA」问题,J.U.C 包提供了一个带有标记的原子引用类 AtomicStamoedReference
,它能够经过控制变量值的版原本保证 CAS 的正确性。不过这个类比较「鸡肋」,大部分状况下 ABA 问题不会影响程序并发的正确性,若是须要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。
无同步方案
要保证线程安全不必定要进行同步,若是一个方法原本就不涉及共享数据,那它天然无需任何同步措施,所以会有一些代码天生就是线程安全的,其中就包括下面要说的可重入代码和线程本地存储。
可重入代码(Reentrant Code):也叫纯代码,能够在代码执行的任什么时候候中断它,转而去执行另外一端代码(包括递归调用本身),而在从新得到控制权后,原来的程序不会出现任何错误。可重入代码有一些共同特征,例如不依赖存储在堆上的数据和公用的系统资源,用到的状态量都由参数传入、不调用非可重入的方法等。若是一个方法的返回结果能够预测,只要输入相同,就能返回相同的输出,那它就是可重入代码,固然也就是线程安全的。
线程本地存储(Thread Local Storage):也就是说这个数据是线程独有的,ThreadLocal 就是用来实现线程本地存储的。
HotSpot 虚拟机开发团队花费了很大的精力实现了各类锁优化,好比自旋锁与自适应自旋、锁消除、锁粗化、轻量级锁、偏向锁等。
自旋锁前面咱们在聊互斥同步的时候就提到过,互斥同步对性能最大的影响就是阻塞的实现,挂起线程和恢复线程都涉及到了用户态到内核态的转换,这种状态的转换会给系统并发性能带来很大的压力。可是大多数场景下,共享数据的锁定状态只会持续很短的一段时间,为了这短暂的时间去挂起和恢复线程显得不那么划算。若是物理机有一个以上的处理器,能让两个或以上的线程同时并行处理,咱们就可让后面请求锁的那个线程「稍等一下」,可是不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,咱们只须要执行一个空转的循环(自旋),这就是所谓的自旋锁。
自旋等待虽然避免了线程切换的开销,可是它要占用处理器的时间。若是锁被占用的时间很短,那么自旋等待的效果固然很好;反之,若是锁被占用的时间很长,那么自旋的线程就会白白消耗处理器资源,反而造成负优化。因此自旋等待必须有个限度,可是这个限度若是设置一个固定值并非最有选择,所以虚拟机开发团队设计了自适应自旋锁,让自旋等待的时间再也不固定,而是由前一次在同一个锁上自旋的时间及锁的拥有者的状态来决定。若是在同一个锁对象上,自旋等待刚刚成功得到过锁,而且持有锁的线程正在运行,那么虚拟机就会认为此次自旋也有可能会成功,会将自旋等待的时间延长。若是对于某个锁,自旋等待不多成功得到过,那在之后要获取这个锁的时候就会放弃自旋。有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的情况预测就会愈来愈准确。
即时编译器在运行时,对一些代码上要求同步,可是被检测到不可能存在共享数据竞争的锁就会进行锁消除。所消除的主要断定依据来源于逃逸分析的数据支持,若是断定一段代码中,堆上的全部数据都不会逃逸出去从而被其它线程访问到,那就能够把它们当作栈上数据对待,认为它们是线程私有的,同步加锁天然就不必了。
咱们在编码时,老是推荐将同步块的做用范围限制到最小,只在共享数据的实际做用域中才进行同步,这样是为了使得须要的同步操做数量尽量变小,若是存在竞争,那等待锁的线程也能尽快拿到锁。一般,这样作是正确的,可是若是一系列的连续操做都对同一个对象反复加锁和解锁,甚至加锁操做是出如今循环体中,那即便没有线程竞争,频繁的进行互斥同步也会致使没必要要的性能损耗。那加锁出如今循环体中来举例,虚拟机遇到这种状况,就会把加锁同步的范围扩展(粗化)到循环体外,这样只要加锁一次就能够了,这就是锁粗化。
关于轻量级锁和偏向锁这里就再也不介绍,若是你们有兴趣能够留言反馈,我在单独发文介绍。
至此,整个 JVM 系列就更新完了,这个系列的文章基本上都是由个人读书笔记整理而成,但愿能对你们有帮助。因为篇幅限制,加上本人水平有限,书中精华未能一一呈现。想进一步 Java 虚拟机的同窗推荐去阅读周志明老师的原著。
参考资料:
若是你喜欢个人文章,就关注下个人公众号 BaronTalk 、 知乎专栏 或者在 GitHub 上添个 Star 吧!
- 微信公众号:BaronTalk
- 知乎专栏:zhuanlan.zhihu.com/baron
- GitHub:github.com/BaronZ88