作为异步事件驱动、高性能的NIO框架,Netty代码中大量运用了Java多线程编程技巧。并发编程处理的恰当与否,将直接影响架构的性能。
1、Java内存模型与多线程编程
1.1、硬件的发展和多任务处理
随着硬件,特别是多核处理器的发展和价格的下降,多任务处理已经是所有操作系统必备的一项基本功能。在同一个时刻让计算机做多件事情,不仅是因为处理器的并行计算能力得到了很大提升,还有一个重要的原因是计算机的存储系统、网络通信等IO性能与CPU的计算能力差距太大,导致程序的很大一部分执行时间被浪费在 IO wait上面,CPU的强大运算能力没有得到充分地利用。
Java提供了很多类库和工具用于降低并发编程的门槛,提升开发效率,一些开源的第三方软件也提供了额外的并发编程类库来方便Java开发者,使开发者将重心放在业务逻辑的设计和实现上,而不是处处考虑线程的同步和锁。但是,无论并发类库设计得如何完
美,它都无法完全满足用户的需求。对于一个高级Java程序员来说,如果不懂得Java并发编程的原理,只懂得使用一些简单的并发类库和工具,是无法完全驾驭Java多线程这匹野马的。
1.2、Java内存模型
JVM规范定义了Java内存模型(Java Memory Model)来屏蔽掉各种操作系统、虚拟机实现厂商和硬件的内存访问差异,以确保Java程序在所有操作系统和平台上能够实现次编写、到处运行的效果。
Java内存模型的制定既要严谨,保证语义无歧义,还要尽量制定得宽松一些,允许各硬件和虚拟机实现厂商有足够的灵活性来充分利用硬件的特性提升Java的内存访问性能。
随着JDK的发展,Java的内存模型已经逐渐成熟起来
1.2.1、工作内存和主内存
Java内存模型规定所有的变量都存储在主内存中(JVM内存的一部分),每个线程有自己独立的工作内存,它保存了被该线程使用的变量的主内存复制。线程对这些变量的操作都在自己的工作内存中进行,不能直接操作主内存和其他工作内存中存储的变量或者变量副本。线程间的变量访问需通过主内存来完成,三者的关系如图21-1所示。
1.2.2、Java内存交互协议
Java内存模型定义了8种操作来完成主内存和工作内存的变量访问,具体如下。
- lock:主内存变量,把一个变量标识为某个线程独占的状态。
- unlock:主内存变量,把一个处于锁定状态变量释放出来,被释放后的变量才可以被其他线程锁定。
- read:主内存变量,把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load:工作内存变量,把read读取到的主内存中的变量值放入工作内存的变量副本中。
- use:工作内存变量,把工作内存中变量的值传递给Java虚拟机执行引擎,每当虚拟机遇到一个需要使用到变量值的字节码指令时,将会执行该操作
- assign:工作内存变量,把从执行引擎接收到的变量的值赋值给工作变量,每当虚拟机遇到一个给变量赋值的字节码时,将会执行该操作。
- store:工作内存变量,把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用
- wite:主内存变量,把 store操作从工作内存中得到的变量值放入主内存的变量中。
1.2.3、Java的线程
并发可以通过多种方式来实现,例如:单进程-单线程模型,通过在一台服务器上启动多个进程来实现多任务的并行处理。但是在Java语言中,通常是通过单进程-多线程的模型进行多任务的并发处理。因此,我们有必要熟悉一下Java的线程,大家都知道,线程是比进程更轻量级的调度执行单元,它可以把进程的资源分配和调度执行分开,各个线程可以共享内存、IO等操作系统资源,但是又能够被操作系统发起的内核线程或者进程执行。各线程可以独立地启动、运行和停止,实现任务的解耦。
主流的操作系统提供了线程实现,目前实现线程的方式主要有三种,分别如下。
(1)内核线程(KLT)实现,这种线程由内核来完成线程切换,内核通过线程调度器对线程进行调度,并负责将线程任务映射到不同的处理器上。
(2)用户线程实现(UT),通常情况下,用户线程指的是完全建立在用户空间线程库上的线程,用户线程的创建、启动、运行、销毁和切换完全在用户态中完成,不需要内核的帮助,因此执行性能更高。
(3)混合实现,将内核线程和用户线程混合在一起使用的方式。
由于虚拟机规范并没有强制规定Java的线程必须使用哪种方式实现,因此,不同的操作系统实现的方式也可能存在差异。对于SUN的JDK,在 Windows和 Linux操作系统上采用了内核线程的实现方式,在 Solaris版本的JDK中,提供了一些专有的虚拟机线程参数,用于设置使用哪种线程模型。
2、Netty的并发编程实践
2.1、对共享的可变数据进行正确的同步
关键字synchronized可以保证在同一时刻,只有一个线程可以执行某一个方法或者代码块。同步的作用不仅仅是互斥,它的另一个作用就是共享可变性,当某个线程修改了可变数据并释放锁后,其他线程可以获取被修改变量的最新值。如果没有正确的同步,这种修改对其他线程是不可见的。
下面我们就通过对Netty源码的分析,看看Nety是如何对并发可变数据进行正确同步的。
以 Server Bootstrap为例进行分析,首先看它的 option方法,如图21-2所示。
这个方法的作用是设置 ServerBootstrap的 ServerSocketChannel的 Socket属性,它的属性集定义如下。
由于是非线程安全的 LinkedHashMap,所以当多线程创建、访问和修改 LinkedHashMap时,必须在外部进行必要的同步。
由于 Server Bootstrap是被外部使用者创建和使用的,我们无法保证它的方法和成员变量不被并发访问,因此,作为成员变量的 options必须进行正确地同步。由于考虑到锁的范围需要尽可能的小,我们对传参的 option和 value的合法性判断不需要加锁。因此,代码才对两个判断分支独立加锁,保证锁的范围尽可能的细粒度。
Netty加锁的地方非常多,大家在阅读代码的时候可能会有体会,为什么有的地方要加锁,有的地方却不需要?如果不需要,为什么?当你对锁的使用原理足够理解以后,对于这些锁的使用时机和技巧就会十分清楚了。
2.2、正确使用锁
很多刚接触多线程编程的开发者,虽然意识到了并发访问可变变量需要加锁,但是对于锁的范围、加锁的时机和锁的协同缺乏认识,往往会导致出现一些问题。下面笔者就结合Netty的代码来讲解下这方面的知识。
打开 ForkJoinTask,我们学习一些多线程同步和协作方面的技巧。首先是当条件不满足时阻塞某个任务,直到条件满足后再继续执行,代码如图21-4所示。
重点看框线中的代码,首先通过循环检测的方式对状态变量 status进行判断,当它的状态大于等于0时,执行wait(),阻塞当前的调度线程,直到 status小于0,唤醒所有被阻塞的线程,继续执行。这个方法有以下三个多线程的编程技巧需要说明。(1)wait方法用来使线程等待某个条件,它必须在同步块内部被调用,这个同步块通常会锁定当前对象实例。下面是这个模式的标准使用方式。
(2)始终使用wait循环来调用wait方法,永远不要在循环之外调用wait方法。这样做的原因是尽管并不满足被唤醒条件,但是由于其他线程调用 notifyAllo方法会导致被阻塞线程意外唤醒,此时执行条件并不满足,它将破坏被锁保护的约定关系,导致约束失效,引起意想不到的结果。
(3)唤醒线程,应该使用 notify还是 notifyAll?当你不知道究竟该调用哪个方法时,保守的做法是调用 notify唤醒所有等待的线程。从优化的角度看,如果处于等待的所有线程都在等待同一个条件,而每次只有一个线程可以从这个条件中被唤醒,那么就应该选择调用notify。当多个线程共享同一个变量的时候,每个读或者写数据的操作方法都必须加锁进行同步,如果没有正确的同步,就无法保证一个线程所做的修改被其他线程共享。未能同步共享变量会造成程序的活性失败和安全性失败,这样的失败通常是难以调试和重现的,它们可能间歇性地出问题,可能随着并发的线程个数增加而失败,也可能在不同的虚拟机或者操作系统上存在不同的失败概率。因此,务必要保证锁的正确使用。下面这个案例,就是个典型的错误应用。
2.3、volatile的正确使用
长久以来大家对于 volatile如何正确使用有很多的争议,既便是一些经验丰富的Java设计师,对于 volatile和多线程编程的认识仍然存在误区。其实, volatile的使用非常简单,只要理解了Java的内存模型和多线程编程的基础知识,正确使用 volatile是不存在任何问题的。下面我们结合 Netty的源码,对 volatile的正确使用进行说明。
打开 NioEventLoop的代码,我们来看控制IO操作和其他任务运行比例的 ioRatio,它是int类型的变量,定义如下。
private volatile int ioRatio =50
我们发现,它被定义为 volatile,为什么呢?我们首先对 volatile关键字进行说明,然后再结合 Netty的代码进行分析。
关键字 volatile是Java提供的最轻量级的同步机制,Java内存模型对 volatile专门定义了一些特殊的访问规则。下面我们就看它的规则。
当一个变量被 volatile修饰后,它将具备以下两种特性。
◎线程可见性:当一个线程修改了被 volatile修饰的变量后,无论是否加锁,其他线程都可以立即看到最新的修改,而普通变量却做不到这点。
◎禁止指令重排序优化,普通的变量仅仅保证在该方法的执行过程中所有依赖赋值结果的地方都能获取正确的结果,而不能保证变量赋值操作的顺序与程序代码的执行顺序一致。举个简单的例子说明下指令重排序优化问题,如图21-5所示。
我们预期程序会在3s后停止,但是实际上它会一直执行下去,原因就是虚拟机对代码进行了指令重排序和优化,优化后的指令如下。
重排序后的代码是无法发现stop被主线程修改的,因此无法停止运行。要解决这个问题,只要将stop前增加 volatile修饰符即可。代码修改如图21-6所示。
再次运行,我们发现3s后程序退出,达到了预期效果,使用 volatile解决了如下两个问题。
◎main线程对stop的修改在 workThread线程中可见,也就是说 workThread线程立即看到了其他线程对于stop变量的修改。
◎禁止指令重排序,防止因为重排序导致的并发访问逻辑混乱。一些人认为使用 volatile可以代替传统锁,提升并发性能,这个认识是错误的。 volatile仅仅解决了可见性的问题,但是它并不能保证互斥性,也就是说多个线程并发修改某个变量时,依旧会产生多线程问题。因此,不能靠 volatile来完全替代传统的锁。
根据经验总结, volatile最适合使用的是一个线程写,其他线程读的场合,如果有多个线程并发写操作,仍然需要使用锁或者线程安全的容器或者原子变量来代替讲了 volatile的原理之后,我们继续对Netty的源码做分析。上面讲到了 ioRatio被定义成 volatile,下面看看代码为什么要这样定义。参见如图21-7所示代码。
通过代码分析我们发现,在 NioEventLoop线程中, ioRatio并没有被修改,它是只读操作。既然没有修改,为什么要定义成 volatile呢?继续看代码,我们发现 NioEventLoop提供了重新设置IO执行时间比例的公共方法,接口如图21-8所示。
首先, NioEventLoop线程没有调用该方法,说明调整IO执行时间比例是外部发起的操作,通常是由业务的线程调用该方法,重新设置该参数。这样就形成了一个线程写个线程读。根据前面针对 volatile的应用总结,此时可以使用 volatile来代替传统的synchronized关键字提升并发访问的性能Netty中大量使用了 volatile来修改成员变量,如果理解了 volatile的应用场景,读懂Netty volatile的相关代码还是比较容易的。
2.4、CAS指令和原子类
互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能的额外损耗,因此这种同步被称为阻塞同步,它属于一种悲观的并发策略,我们称之为悲观锁。随着硬件和操作系统指令集的发展和优化,产生了非阻塞同步,被称为乐观锁。简单地说,就是先进行操作,操作完成之后再判断操作是否成功,是否有并发问题,如果有则进行失败补偿,如果没有就算操作成功,这样就从根本上避免了同步锁的弊端。
目前,在Java中应用最广泛的非阻塞同步就是CAS,在IA64、X86指令集中通过cmpxchg指令完成CAS功能,在 sparc-TSO中由case指令完成,在ARM和 PowerPC架构下,需要使用一对 Idex/ strex指令完成。
从JDK1.5以后,可以使用CAS操作,该操作由sun.misc.Unsafe类里的compareAndSwapInt()和compareAndSwapLong()等方法包装提供。通常情况下sun.misc.Unsafe类对于开发者是不可见的,因此,JDK提供了很多CAS包装类简化开发者的使用,如AtomicInteger。
下面,结合Nety的源码,我们对原子类的正确使用进行详细说明。
打开 ChannelOutboundBuffer的代码,看看如何对发送的总字节数进行计数和更新操作,先看定义,如图21-9所示。
首先定义了一个 volatile的变量,它可以保证某个线程对于 totalPendingSize的修改可以被其他线程立即访问,但是,它无法保证多线程并发修改的安全性。紧接着又定义了一个 AtomicIntegerFieldUpdater类型的变量 WTOTAL_PENDING_SIZE_UPDATER,实现
totalPendingSize的原子更新,也就是保证 totalPendingSize的多线程修改并发安全性,我们重点看 AtomicIntegerFieldUpdater的API说明,如图21-10所示。
从API的说明可以看出,它主要用于实现 volatile修饰的int变量的原子更新操作,对于使用者,必须通过类似 compareAndSet或者set或者与这些操作等价的原子操作来保证更新的原子性,否则会导致问题。继续看代码,当执行 write操作外发消息的时候,需要对外发的消息字节数进行统计汇总。由于调用 write操作的既可以是IO线程,也可以是业务的线程,还可能由业务线程池多个工作线程同时执行发送任务,因此,统计操作是多线程并发的,这也就是为什么要将计数器定义成 volatile并使用原子更新类进行原子操作。下面看计数的代码,如图21-11所示。
首先,我们发现计数操作并没有使用锁,而是利用CAS自旋操作,通过 TOTAL_PENDING_SIZE_UPDATER.compareAndSet(this,oldvalue, newWriteBufferSize)来判断本次原子操作是否成功。如果成功则退出循环,代码继续执行;如果失败,说明在本次操作的过程中计数器已经被其他线程更新成功,需要进入循环,首先对 oldValue进行更新,代码如下。
oldvalue = totalPendingsize;
然后重新对更新值进行计算。
newWriteBufferSize = oldvalue + size;
继续循环进行CAS操作,直到成功。它跟 AtomicInteger的 compareAndSet操作类似。
使用Java自带的 Atomic原子类,可以避免同步锁带来的并发访问性能降低的问题,减少犯错的机会。因此,Netty中对于int、long、boolean等成员变量大量使用其原子类,减少了锁的应用,从而降低了频繁使用同步锁带来的性能下降。
2.5、线程安全类的应用
在JDK1.5的发行版本中,Java平台新增了 java.util.concurrent,这个包中提供了一系列的线程安全集合、容器和线程池,利用这些新的线程安全类可以极大地降低Java多线程编程的难度,提升开发效率。
新的并发编程包中的工具可以分为如下4类。
◎线程池 Executor Framework以及定时任务相关的类库,包括 Timer等。
◎并发集合,包括List、 Queue、Map和Set等。
◎新的同步器,例如读写锁 ReadLock等。
◎新的原子包装类,例如 Atomiclnteger等。
在实际编码过程中,我们建议通过使用线程池、Task( Runnable/Callable)、原子类和线程安全容器来代替传统的同步锁、wait和 notify,以提升并发访问的性能、降低多线程编程的难度。
下面,针对新的线程并发包在 Netty中的应用进行分析和说明,以期为大家的学习和应用提供指导。
首先看下线程安全容器在Netty中的应用。 NioEventLoop是Io线程,负责网络读写操作,同时也执行一些非IO的任务。例如事件通知、定时任务执行等,因此,它需要个任务队列来缓存这些Task。它的任务队列定义如图21-12所示。
它是一个 ConcurrentQueue,我们看它的API说明,如图21-13所示。
Doc文档明确说明这个类是线程安全的,因此,对它进行读写操作不需要加锁。下面我们继续看下队列中增加一个任务,如图21-14所示。
读取任务,也不需要加锁,如图21-15所示。
JDK的线程安全容器底层采用了CAS、volatile和 ReadWriteLock实现,相比于传统重量级的同步锁,采用了更轻量、细粒度的锁,因此,性能会更高。合理地应用这些线程安全容器,不仅能提升多线程并发访问的性能,还能降低开发难度。
下面我们看看线程池在Ney中的应用,打开 SingleThreadEventExecutor看它是如何定义和使用线程池的。
首先定义了一个标准的线程池用于执行任务,代码如下。
接着对它赋值并且进行初始化操作,代码如下。
执行任务代码如图21-16所示。
我们发现,实际上执行任务就是先把任务加入到任务队列中,然后判断线程是否已经启动循环执行,如果不是则需要启动线程。启动线程代码如图21-17所示。
实际上就是执行当前线程的run方法,循环从任务队列中获取Task并执行,我们看它的子类 NioEventLoop的run方法就能一目了然,如图21-18所示。
如图21-19中框线内所示,循环从任务队列中获取任务并执行。
Netty对JDK的线程池进行了封装和改造,但是,本质上仍然是利用了线程池和线程安全队列简化了多线程编程。
2.6、读写锁的应用
JDK1.5新的并发编程工具包中新增了读写锁,它是个轻量级、细粒度的锁,合理地使用读写锁,相比于传统的同步锁,可以提升并发访问的性能和吞吐量,在读多写少的场景下,使用同步锁比同步块性能高一大截。
尽管在JDK1.6之后,随着JVM团队对JT即时编译器的不断优化,同步块和读写锁的性能差距缩小了很多,但是,读写锁的应用依然非常广泛。
下面对Netty中的读写锁应用进行分析,让大家掌握读写锁的用法。打开HashedWheelTimer代码,读写锁定义如下。
当新增一个定时任务的时候使用了读锁(如图21-20),用于感知 wheel的变化。由于读锁是共享锁,所以当有多个线程同时调用 newTimeout时,并不会互斥,这样,就提升了并发读的性能。
获取并删除所有过期的任务时,由于要从迭代器中删除任务,所以使用了写锁,如图21-21所示。
现将读写锁的使用场景总结如下。
- 主要用于读多写少的场景,用来替代传统的同步锁,以提升并发访问性能。
- 读写锁是可重入、可降级的,一个线程获取读写锁后,可以继续递归获取;从写锁可以降级为读锁,以便快速释放锁资源。
- ReentrantReadWriteLock支持获取锁的公平策略,在某些特殊的应用场景下,可以提升并发访问的性能,同时兼顾线程等待公平性
- 读写锁支持非阻塞的尝试获取锁,如果获取失败,直接返回 false,而不是同步阻塞。这个功能在一些场景下非常有用。例如多个线程同步读写某个资源,当发生异常或者需要释放资源的时候,由哪个线程释放是个难题。因为某些资源不能重复释放或者重复执行,这样,可以通过 try Lock方法尝试获取锁,如果拿不到,说明已经被其他线程占用,直接退出即可。
- 获取锁之后一定要释放锁,否则会发生锁溢出异常。通常的做法是通过 finally块释放锁。如果是 try Lock,获取锁成功才需要释放锁。
2.7、线程安全性文档说明
当一个类的方法或者成员变量被并发使用的时候,这个类的行为如何,是该类与其客户端程序建立约定的重要组成部分。如果没有在这个类的文档中描述其行为的并发情况,使用这个类的程序员不得不做出某种假设。如果这些假设是错误的,这个程序就缺少必要的同步保护,会导致意想不到的并发问题,这些问题通常都是隐蔽和调试困难的。如果同步过度,会导致意外的性能下降,无论是发生何种情况,缺少线程安全性的说明文档,都会令开发人员非常沮丧,他们会对这些类库的使用小心翼翼,提心吊胆。
在 Netty中,对于一些关键的类库,给出了线程安全性的 API DOC(图21-22),尽管Net!y的线程安全性并不是非常完善,但是,相比于一些做得更糟糕的产品,它还是迈出了重要的一步。
由于 ChannelPipeline的应用非常广泛,因此,在AP中对它的线程安全性进行了详细的说明,这样,开发者在调用 ChannelPipeline的API时,就不用再额外地考虑线程同步和并发问题了。
2.8、不要依赖线程优先级
当有多个线程同时运行的时候,由线程调度器来决定哪些线程运行、哪些等待以及线程切换的时间点,由于各个操作系统的线程调度器实现大相径庭,因此,依赖JDK自带的线程优先级来设置线程优先级策略的方法是错误和非平台可移植的。所以,在任何情况下,程序都不能依赖JDK自带的线程优先级来保证执行顺序、比例和策略。
Netty中默认的线程工厂实现类,开放了包含设置线程优先级字段的构造函数。这是个错误的决定,对于使用者来说,既然JDK类库提供了优先级字段,就会本能地认为它被正确地执行,但实际上JDK的线程优先级是无法跨平台正确运行的。图21-23提供了个线程优先级的反面示例。