高效并发是 JVM 系列的最后一篇,本篇主要介绍虚拟机如何实现多线程、多线程间如何共享和竞争数据以及共享和竞争数据带来的问题及解决方案。
一. Java 内存模型与线程
让计算机同时执行多个任务,不只是因为处理器的性能更加强大了,更重要是因为计算机的运算速度和它的存储以及通信子系统速度差距太大,大量的时间都花费在磁盘 I/O 、网络通信和数据库访问上。为了不让处理器因为等待其它资源而浪费处理器的资源与时间,我们就必须采用让计算机同时执行多任务的方式去充分利用处理器的性能;同时也是为了应对服务端高并发的需求。而 Java 内存模型的设计和线程的存在正是为了更好、更高效的实现多任务。
1.硬件与效率的一致性
计算机中绝大多数的任务都不可能只靠处理器计算就能完成,处理器至少要和内存交互,如读取数据、存储结果等等,这个 I/O 操作是很难消除的。由于计算器的存储设备和处理器的运算速度有几个量级的差距,所以计算机不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存与处理器之间的缓冲:将运算需要用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存中,这样处理器就无需等待缓慢的内存读写了。
基于高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性。在多处理器中,每个处理器都有自己的高速缓存,而它们又共享同一主内存。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。为了解决一致性的问题,需要各个处理器的访问缓存时都遵循一些协议,在读写时要根据协议来进行操作。
除了增加高速缓存外,为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入的代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果一致,但不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此,如果存在一个计算任务依赖另一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。与处理器的乱象执行优化类似,JIT 编译器中也有类似的指令重排优化。
2.Java 内存模型
Java 虚拟机规范中定义了 Java 内存模型,用来屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。像 C/C++ 这类语言直接使用了物理硬件和操作系统的内存模型,因此会由于不同平台上内存模型的差异,需要针对不同平台来编写代码。
主内存与工作内存
Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中读取变量这样的底层细节。这里说的变量和 Java 代码中的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但不包括变量和方法参数,因为后者是线程私有的,不会被共享。为了获得较好的执行性能,Java 内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制 JIT 编译器进行代码执行顺序这类优化措施。
Java 内存模型规定了所有的变量都存储在主内存,每条线程都有自己单独的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存,线程间变量值的传递均需要通过主内存来完成。
内存间交互操作
关于主内存与工作内存间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的细节,Java 内存模型定义了以下 8 种操作来完成,虚拟机实现时必须保证下面的每一种操作都是原子的、不可再分的。
这 8 种操作分别是:lock(锁定)、unlock(解锁)、read(读取)、load(载入)、use(使用)、assign(赋值)、store(存储)、write(写入)。
对 volatile 型变量的特殊规则
volatile 是 Java 虚拟机提供的最轻量级的同步机制。当一个变量被定义为 volatile 后,它将具备两种特性:
第一是保证此变量对所有线程的可见性,这里的「可见性」是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。普通变量则做不到这一点,需要通过主内存来在线程间传递数据。比如,线程 A 修改了一个普通的变量值,然后向主内存进行回写,另一条线程 B 在 A 线程回写完成之后再从主内存进行读写操作,新变量值才会对线程 B 可见。
第二是禁止指令重排优化。普通变量仅仅会保证方法的执行过程中所有依赖赋值结果的地方能够获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在一个线程的方法执行过程中无法感知到这点,这也就是 Java 内存模型中描述的所谓的「线程内表现为串行的语义」。
对 long 和 double 型变量的特殊规则
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 个特质来建立的。
原子性(Atomicity)
由 Java 内存模型来直接保证原子性变量操作,包括 read、load、assign、use、store 和 write ,我们大致可以认为基本数据类型的访问读写是具备原子性的。如果应用场景需要一个更大范围的原子性保证,Java 内存模型还提供了 lock 和 unlock 操作来满足这种需求,尽管虚拟机未把 lock 和 unlock 操作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐式地使用这两个操作,这两个字节码指令反映到 Java 代码中就是 synchronized 关键字,因此被 synchronize 修饰的方法或代码块之间的操作是具备原子性的。
可见性(Visibility)
可见性是指当一个线程修改了共享变量的值,其它线程能够立即得知这个修改。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是 volatile 变量都是如此,普通变量与 volatile 变量的区别是, volatile 的规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此,可以说 volatile 保证了多线程操作变量的可见性,而普通变量则不能保证这一点。除了 volatile 外,Java 还有两个关键字 synchronized 和 final 。synchronized 同步块的可见性是由「对一个变量执行 unlock 操作前,必须先把此变量同步回主内存中(执行 store、write 操作)」这条规则获得的;final 的可见性是指“:被 final 修饰的字段在构造器中一旦初始化完成,并且构造器没有「this」的引用传递出去,那在其他线程中就能看见 final 字段的值。
有序性(Ordering)
Java 程序中天然的有序性可以总结为:如果在本线程内,所有操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指「线程内表现为串行的语义」,后半句是指「指令重排序」现象和「工作内存和主内存同步延迟」现象。Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 关键字本身就包含了禁止指令重排的语义,而 synchronized 则是由「一个变量在同一时刻只允许一条线程对其进行 lock 操作」这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行的进入。
先行发生原则
如果 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。
3.Java 与线程
谈论 Java 中的并发,通常都是和多线程相关的。这一小节我们就讲讲 Java 线程在虚拟机中的实现。
线程的实现
主流的操作系统都提供了线程实现,Java 语言则提供了在不同硬件和操作系统平台下对线程操作的统一处理,每个已经执行 start() 且还未结束的 Thread 类的实例就代表了一个线程。Thread 类所有关键方法都是 Native 的。Java API 中,一个 Native 方法往往意味着这个方法没有使用或者无法使用平台无关的手段来实现(当然也可能是为了执行效率而使用 Native 方法,不过,通常最高效率的手段就是平台相关的手段)。
实现线程主要有 3 种方式:使用内核线程实现、使用用户线程实现、使用用户线程加轻量级进程混合实现。
Java 线程的实现
Java 线程在 JDK 1.2 之前是基于称为「绿色线程」的用户线程实现的。而在 JDK 1.2 中,线程模型替换为基于操作系统原生线程模型来实现。因此,在目前的 JDK 版本中,操作系统支持怎样的线程模型,在很大程度上决定了 Java 虚拟机的线程是怎样映射的,这点在不同的平台上没有办法达成一致,虚拟机规范中也没有限定 Java 线程需要使用哪种线程模型来实现。线程模型只对线程的并发规模和操作成本产生影响,对 Java 程序的编码和运行过程来说,这些差异都透明的。
4.Java 线程调度
线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,分别是协同式线程调度和抢占式线程调度。
协同式线程调度
如果是使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完之后,要主动通知系统切换到另外一个线程上。协同式多线程的最大好处是实现简单,而且由于线程要把自己的事情做完后才会进行线程切换,切换操作对线程自己是可知的,所有没有线程同步的问题。但是它的坏处也很明显:线程执行时间不可控,甚至如果一个线程编写有问题,一直不告诉操作系统进行线程切换,那么程序就会一直阻塞在那里。很久以前的 Windows 3.x 系统就是使用协同式来实现对进程多任务,相当不稳定,一个进程坚持不让出 CPU 执行时间就可能导致整个系统崩溃。
抢占式线程调度
如果是使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。在这种实现线程调度的方式下,线程的执行实现是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java 使用的线程调度方式就是抢占式的。和前面所说的 Windows 3.x 的例子相对,在 Windows 9x/NT 内核中就是使用抢占式来实现多进程的,当一个进程出了问题,我们还可以使用任务管理器把这个进程「杀掉」,而不至于导致系统崩溃。
5.状态转换
Java 语言定义了 5 种线程状态,在任意一个时间点,一个线程只能有且只有其中一种状态,它们分别是:
- 新建(New):创建后尚未启动的线程处于这种状态;
- 运行(Runnable):Runnable 包括了操作系统线程状态中的 Running 和 Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着 CPU 为它分配执行时间;
- 无限期等待(Waiting):处于这种状态的线程不会被分配 CPU 执行时间,它们要等待被其它线程显式地唤醒;以下三种方法会让线程进入无限期等待状态:
- 没有设置 TimeOut 参数的 Object.wait();
- 没有设置 TimeOut 参数的 Thread.join();
- LockSupport.park()。
- 限期等待(Timed Waiting):处于这种状态的线程也不会被分配 CPU 执行时间,不过无需等待被其它线程显式地唤醒,在一定时间之后它们会由系统自动唤醒;以下方法会让线程进入限期等待状态:
- Thread.sleep();
- 设置了 TimeOut 参数的 Object.wait();
- 设置了 TimeOut 参数的 Thread.join();
- LockSupport.parkNanos();
- LockSupport.parkUntil()。
- 阻塞(Blocked):线程被阻塞了,「阻塞状态」和「等待状态」的区别是:「阻塞状态」在等待着获取一个排他锁,这个事件将在另一个线程放弃这个锁的时候发生;而「等待状态」则是在等待一段时间,或者唤醒动作的发送。在程序等待进入同步区域时,线程将进入这种状态;
- 结束(Terminated):线程已经结束执行。
上述 5 中状态遇到特定事件发生的时候将会互相转换,如下图:
二、线程安全与锁优化
本文的主题是高效并发,但高效的前提是首先要保证并发的正确性和安全性,所以这一小节我们先从如何保证线程并发安全说起。
1.Java 线程安全
那么什么是线程安全呢?可以简单的理解为多线程对同一块内存区域操作时,内存值的变化是可预期的,不会因为多线程对同一块内存区域的操作和访问导致内存中存储的值出现不可控的问题。
Java 语言中的线程安全
如果我们不把线程安全定义成一个非此即彼的概念(要么线程绝对安全,要么线程绝对不安全),那么我们可以根据线程安全的程度由强至弱依次分为如下五档:
- 不可变;
- 绝对线程安全;
- 相对线程安全;
- 线程兼容;
- 线程对立。
线程安全的实现方法
虽然线程安全与否与编码实现有着莫大的关系,但虚拟机提供的同步和锁机制也起到了非常重要的作用。下面我们就来看看虚拟机层面是如何保证线程安全的。
同步互斥
互斥同步是常见的一种并发正确性保障的手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一时间只被一个线程使用。而互斥是实现同步的一种手段。Java 中最基本的互斥同步手段就是 synchronized 关键字,synchronized 关键字在经过编译之后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令,这两个字节码都需要一个 reference 类型的参数来指明要锁定和解锁的对象。如果 Java 程序中的 synchronized 明确指明了对象参数,那就是这个对象的 reference;如果没有,那就根据 synchronized 修饰的是实例方法还是类方法,去取对应的对象实例或 class 对象来作为锁对象。
根据虚拟机规范的要求,在执行 monitorenter 指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,就把锁的计数器加 1;相应的,在执行monitorexit 指令时将锁计数器减 1,当锁计数器为 0 时,锁就被释放。如果获取锁对象失败,当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。
另外要说明的一点是,同步块在已进入的线程执行完之前,会阻塞后面其它线程的进入。由于 Java 线程是映射到操作系统原生线程之上的,如果要阻塞或者唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到内核态,线程状态转换需要耗费很多的处理器时间。对于简单的同步块(如被 synchronized 修饰的 getter() 和 setter() 方法),状态转换消耗的时间可能比用户代码消耗的时间还要长。所以 synchronized 是 Java 中一个重量级的操作,因此我们只有在必要的情况下才应该使用它。当然虚拟机本身也会做相应的优化,比如在操作系统阻塞线程前加入一段自旋等待过程,避免频繁的用户态到内核态的转换过程。这一点我们在介绍锁优化的时候再细聊。
非阻塞同步
互斥同步最大的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也成为阻塞同步。从处理问题的方式上来说,互斥同步是一种悲观的并发策略,认为只要不去做正确的同步措施(例如加锁),就肯定会出问题,无论共享数据是否会出现竞争,它都要进行加锁(当然虚拟机也会优化掉一些不必要的锁)。随着硬件指令集的发展,我们有了另外一个选择:基于冲突检查的乐观并发策略。通俗的说,就是先进行操作,如果没有其他线程竞争,那操作就成功了;如果共享数据有其它线程竞争,产生了冲突,就采取其它的补救措施,这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步。
前面之所以说需要硬件指令集的发展,是因为我们需要操作和冲突检测这两个步骤具备原子性。
这个原子性靠什么来保证呢?如果这里再使用互斥同步来保证原子性就失去意义了,所以我们只能靠硬件来完成这件事,保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成,这类指令常用的有:
- 测试并设置(Test-and-Set)
- 获取并增加(Fetch-and-Increment)
- 交换(Swap)
- 比较并交换(Compare-and-Swap,简称 CAS)
- 加载链接/条件存储(Load-Linked/Store-Conditional,简称 LL/SC)
前三条是之前的处理器指令集里就有的,后两条是新增的。
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 就是用来实现线程本地存储的。
2.锁优化
HotSpot 虚拟机开发团队花费了很大的精力实现了各种锁优化,比如自旋锁与自适应自旋、锁消除、锁粗化、轻量级锁、偏向锁等。
自旋锁与自适应自旋
自旋锁前面我们在聊互斥同步的时候就提到过,互斥同步对性能最大的影响就是阻塞的实现,挂起线程和恢复线程都涉及到了用户态到内核态的转换,这种状态的转换会给系统并发性能带来很大的压力。但是大多数场景下,共享数据的锁定状态只会持续很短的一段时间,为了这短暂的时间去挂起和恢复线程显得不那么划算。如果物理机有一个以上的处理器,能让两个或以上的线程同时并行处理,我们就可以让后面请求锁的那个线程「稍等一下」,但是不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要执行一个空转的循环(自旋),这就是所谓的自旋锁。
自旋等待虽然避免了线程切换的开销,但是它要占用处理器的时间。如果锁被占用的时间很短,那么自旋等待的效果当然很好;反之,如果锁被占用的时间很长,那么自旋的线程就会白白消耗处理器资源,反而形成负优化。所以自旋等待必须有个限度,但是这个限度如果设置一个固定值并不是最有选择,因此虚拟机开发团队设计了自适应自旋锁,让自旋等待的时间不再固定,而是由前一次在同一个锁上自旋的时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行,那么虚拟机就会认为这次自旋也有可能会成功,会将自旋等待的时间延长。如果对于某个锁,自旋等待很少成功获得过,那在以后要获取这个锁的时候就会放弃自旋。有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确。
锁消除
即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁就会进行锁消除。所消除的主要判定依据来源于逃逸分析的数据支持,如果判定一段代码中,堆上的所有数据都不会逃逸出去从而被其它线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就没必要了。
锁粗化
我们在编码时,总是推荐将同步块的作用范围限制到最小,只在共享数据的实际作用域中才进行同步,这样是为了使得需要的同步操作数量尽可能变小,如果存在竞争,那等待锁的线程也能尽快拿到锁。通常,这样做是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中,那即使没有线程竞争,频繁的进行互斥同步也会导致不必要的性能损耗。那加锁出现在循环体中来举例,虚拟机遇到这种情况,就会把加锁同步的范围扩展(粗化)到循环体外,这样只要加锁一次就可以了,这就是锁粗化。
关于轻量级锁和偏向锁这里就不再介绍,如果大家有兴趣可以留言反馈,我在单独发文介绍。