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的内存模型已经逐渐成熟起来。
工作内存和主内存
Java内存模型规定所有的变量都存储在主内存中(JVM内存的一部分),每个线程有自己独立的工作内存,它保存了被该线程使用的变量的主内存拷贝,线程对这些变量的操作都在自己的工作内存中进行,不能直接操作主内存和其它工作内存中存储的变量或者变量副本,线程间的变量访问需通过主内存来完成,三者的关系如下图所示:
图1.2.1 JAVA内存访问模型
内存交互协议
JAVA内存模型定义了八种操作来完成主内存和工作内存的变量访问,具体如下:
- lock:主内存变量,把一个变量标识为某个线程独占的状态;
- unlock:主内存变量,把一个处于锁定状态变量释放出来,被释放后的变量才可以被其它线程锁定;
- read:主内存变量,把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用;
- load:工作内存变量,把read读取到的主内存中的变量值放入工作内存的变量拷贝中;
- use:工作内存变量,把工作内存中变量的值传递给java虚拟机执行引擎,每当虚拟机遇到一个需要使用到变量值的字节码指令时将会执行该操作;
- assign:工作内存变量,把从执行引擎接收到的变量的值赋值给工作变量,每当虚拟机遇到一个给变量赋值的字节码时将会执行该操作;
- store:工作内存变量,把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用;
- write:主内存变量,把store操作从工作内存中得到的变量值放入主内存的变量中。
JAVA的线程
并发的实现可以通过多种方式来实现,例如:单进程-单线程模型,通过在一台服务器上启多个进程实现多任务的并行处理。但是在JAVA语言中,通过是通过单进程-多线程的模型进行多任务的并发处理。因此,我们有必要熟悉一下JAVA的线程。
大家都知道,线程是比进程更轻量级的调度执行单元,它可以把进程的资源分配和调度执行分开,各个线程可以共享内存、IO等操作系统资源,但是又能够被操作系统发的内核线程或者进程执行。各线程可以独立的启动、运行和停止,实现任务的解耦。
主流的操作系统都提供了线程实现,目前实现线程的方式主要有三种,分别是:
- 内核线程(KLT)实现,这种线程由内核来完成线程切换,内核通过线程调度器对线程进行调度,并负责将线程任务映射到不同的处理器上;
- 用户线程实现(UT),通常情况下,用户线程指的是完全建立在用户空间线程库上的线程,用户线程的创建、启动、运行、销毁和切换完全在用户态中完成,不需要内核的帮助,因此执行性能更高;
- 混合实现:将内核线程和用户线程混合在一起使用的方式。
由于虚拟机规范并没有强制规定JAVA的线程必须使用哪种方式实现,因此,不同的操作系统实现的方式也可能存在差异。对于SUN的JDK,在Windows和Linux操作系统上采用了内核线程的实现方式,在Solaris版本的JDK中,提供了一些专有的虚拟机线程参数,用于设置使用哪种线程模型。
2. Netty的并发编程分析
在Java技术领域,网络通信和多线程并发编程是相对较高级和难掌握的领域,作为高性能的NIO通信框架,线程模型对Netty的性能影响非常大,Netty的高性能是建立在灵活和高效的并发编程基础之上。
通过学习Netty的多线程并发编程技巧,对于我们掌握并在实践中灵活应用Java多线程编程来提升系统性能带来很大的帮助。
2.1. 对共享的可变数据进行正确的同步
关键字synchronized 可以保证在同一时刻,只有一个线程可以执行某一个方法或者代码块。同步的作用不仅仅是互斥,它的另一个作用就是共享可变性,当某个线程修改了可变数据并释放锁后,其它的线程可以获取被修改变量的最新值。如果没有正确的同步,这种修改对其它线程是不可见的。
下面我们就通过对Netty的源码进行分析,看看Netty是如何对并发可变数据进行正确同步的。
以ServerBootstrap为例进行分析,首先看它的option方法:
这个方法的作用是设置ServerBootstrap的ServerSocketChannel的Socket属性,它的属性集定义如下:
由于是非线程安全的LinkedHashMap,所以如果多线程创建、访问和修改LinkedHashMap时,必须在外部进行必要的同步,LinkedHashMap的API DOC对于线程安全的说明如下:
由于ServerBootstrap是被使用者创建和使用的,我们无法保证它的方法和成员变量不被并发访问,因此,作为成员变量的options必须进行正确的同步。由于考虑到锁的范围需要尽可能的小,我们对传参的option和value的合法性判断不需要加锁。因此,代码才对两个判断分支独立加锁,保证锁的范围尽可能的细粒度。
Netty加锁的地方非常多,大家在阅读代码的时候可以仔细体会下,为什么有的地方要加锁,有的地方有不需要?如果不需要,为什么?当你对锁的真谛理解以后,对于这些锁的使用时机和技巧理解起来就非常容易了。
2.2. 正确的使用锁
对于很多刚接触多线程编程的开发者,意识到了并发访问可变变量需要加锁,但是对于锁的范围、加锁的时机和锁的协同缺乏认识,往往会导致一些问题,下面我就结合Netty的代码来讲解下这方面的知识。
打开ForkJoinTask,我们学习一些多线程同步和协作方面的技巧,先看下当条件不满足时阻塞某个任务,直到条件满足后再继续执行,代码如下:
重点看下红框中的代码,首先通过循环检测的方式对状态变量status进行判断,当它的状态大于等于0时,执行wait(),阻塞当前的调度线程,直到status小于0,唤醒所有被阻塞的线程,继续执行。这个方法有三个多线程的编程技巧需要说明:
- wait方法别用来使线程等待某个条件,它必须在同步块内部被调用,这个同步块通常会锁定当前对象实例。下面是这个模式的标准使用方式:
synchronized(this)
{
While(condition)
Object.wait;
......
}
- 始终使用wait循环来调用wait方法,永远不要在循环之外调用wait方法。原因是尽管条件并不满足被唤醒条件,但是由于其它线程意外调用notifyAll()方法会导致被阻塞线程意外唤醒,此时执行条件并不满足,它将破坏被锁保护的约定关系,导致约束失效,引起意想不到的结果;
- 唤醒线程,应该使用notify还是notifyAll,当你不知道究竟该调用哪个方法时,保守的做法是调用notifyAll唤醒所有等待的线程。从优化的角度看,如果处于等待的所有线程都在等待同一个条件,而每次只有一个线程可以从这个条件中被唤醒,那么就应该选择调用notify。
当多个线程共享同一个变量的时候,每个读或者写数据的操作方法都必须加锁进行同步,如果没有正确的同步,就无法保证一个线程所做的修改被其它线程可见。未能同步共享变量会造成程序的活性失败和安全性失败,这样的失败通常是难以调试和重现的,它们可能间歇性的出问题,也可能随着并发的线程个数而失败,也可能在不同的虚拟机或者操作系统上存在不同的失败概率。因此,我们务必要保证锁的正确使用。下面这个案例,就是个典型的错误应用:
int size = 0;
public synchronized void increase()
{
size++;
}
public int current()
{
Return size;
}
2.3. volatile的正确使用
在实际工作中,我发现即便是一些经验丰富的JAVA设计师,对于volatile和多线程编程的认识仍然存在误区。其实,volatile的使用非常简单,只要理解了JAVA的内存模型和多线程编程基础知识,正确使用volatile是不存在任何问题的,下面我们结合Netty的源码,对volatile的正确使用进行说明。
打开NioEventLoop的代码,我们来看下控制IO操作和其它任务运行比例的ioRatio,它是int类型的变量,定义如下:
我们发现,它被定义为volatile,为什么呢?首先对volatile关键字进行说明,然后再结合Netty的代码进行分析。
关键字volatile是JAVA提供的最轻量级的同步机制,JAVA内存模型对volatile专门定义了一些特殊的访问规则,下面我们就看下它的规则:
当一个变量被volatile修饰后,它将具备两种特性:
1. 线程可见性:当一个线程修改了被volatile修饰的变量后,无论是否加锁,其它线程都可以立即看到最新的修改,而普通变量却做不到这点;
2. 禁止指令重排序优化,普通的变量仅仅保证在该方法的执行过程中所有依赖赋值结果的地方都能获取正确的结果,而不能保证变量赋值操作的顺序与程序代码的执行顺序一致。举个简单的例子说明下指令重排序优化问题:
我们预期程序会在3S后停止,但是实际上它会一直执行下去,原因就是虚拟机对代码进行了指令重排序和优化,优化后的指令如下:
if (!stop)
While(true)
......
重排序后的代码是无法发现stop被主线程修改的,因此无法停止运行。如果要解决这个问题,只要将stop前增加volatile修饰符即可,代码修改如下:
再次运行,我们发现3S后程序退出,达到了预期效果,使用volatile解决了如下两个问题:
- main线程对stop的修改在workThread线程中可见,也就是说workThread线程立即看到了其它线程对于stop变量的修改;
- 禁止指令重排序,防止因为重排序导致的并发访问逻辑混乱。
一些人错误的认为使用volatile可以代替传统锁,提升并发性能,这个认识是错误的,volatile仅仅解决了可见性的问题,但是它并不能保证互斥性,也就是说多个线程并发修改某个变量时,依旧会产生多线程问题。因此,不能靠volatile来完全替代传的锁。
根据经验总结,volatile最适合使用的地方是一个线程写、其它线程读的场合,如果有多个线程并发写操作,仍然需要使用锁或者线程安全的容器或者原子变量来代替。
讲了volatile的原理之后,我们继续对Netty的源码做分析,上面我们说到了ioRatio被定义成volatile,下面看看代码为啥这样定义:
通过代码分析我们发现,在NioEventLoop线程中,ioRatio并没有被修改,它是只读操作。那既然没有修改,为啥要定义成volatile呢?我们继续看代码,我们发现NioEventLoop提供了重新设置IO执行时间比例的公共方法,接口如下:
首先,NioEventLoop线程没有调用该方法,说明调整IO执行时间比例是外部发起的操作,通常是由业务的线程调用该方法,重新设置该参数。这样就形成了一个线程写、一个线程读,根据前面针对volatile的应用总结,此时可以使用volatile来代替传统的synchronized关键字提升并发访问的性能。
Netty中大量使用了volatile来修改成员变量,如果理解了volatile的应用场景,读懂Netty volatile的相关代码还是比较容易的。
2.4. CAS指令和原子类
互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步被称为阻塞同步,它属于一种悲观的并发策略,我们称之为悲观锁。随着硬件和操作系统指令集的发展和优化,产生了非阻塞同步,被称为乐观锁。简单的说就是先进行操作,操作完成之后再判断下看看操作是否成功,是否有并发问题,如果有进行失败补偿,如果没有就算操作成功,这样就从根本上避免了同步锁的弊端。
目前,在JAVA中应用最广泛的非阻塞同步就是CAS,在IA64、X86指令集中通过cmpxchg指令完成CAS功能,在sparc-TSO中由case指令完成,在ARM和PowerPC架构下,需要使用一对Idrex/strex指令完成。
从JDK1.5以后,可以使用CAS操作,该操作由sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等方法包装提供。通常情况下sun.misc.Unsafe类对于开发者是不可见的,因此,JDK提供了很多CAS包装类简化开发者的使用,例如AtomicInteger等。
下面,结合Netty的源码,我们对于原子类的正确使用进行详细说明:
我们打开ChannelOutboundBuffer的代码,看看如何对发送的总字节数进行计数和更新操作,先看定义:
首先定义了一个volatile的变量,它可以保证某个线程对于totalPendingSize的修改可以被其它线程立即访问到,但是,它无法保证多线程并发修改的安全性。紧接着又定义了一个AtomicIntegerFieldUpdater类型的变量WTOTAL_PENDING_SIZE_UPDATER,实现totalPendingSize的原子更新,也就是保证totalPendingSize的多线程修改并发安全性,我们重点看下AtomicIntegerFieldUpdater的API说明:
从API的说明我们可以看出来,它主要用于实现volatile修饰的int变量的原子更新操作,对于使用者,必须通过类似compareAndSet或者set或者与这些操作等价的原子操作来保证更新的原子性,否则会导致问题。
我们继续看代码,当执行write操作外发消息的时候,需要对外发的消息字节数进行统计汇总,由于调用write操作的既可以是IO线程,也可以是业务的线程,也可能由业务线程池多个工作线程同时执行发送任务,因此,统计操作是多线程并发的,这也就是为什么要将计数器定义成volatile并使用原子更新类进行原子操作,下面,我们看下计数的代码:
首先,我们发现计数操作并没有实现锁,而是使用了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多线程编程的难度,提升开发效率。
新的并发编程包中的工具可以分为如下四类:
- 线程池Executor Framework以及定时任务相关的类库,包括Timer等;
- 并发集合,包括List、Queue、Map和Set等;
- 新的同步器,例如读写锁ReadWriteLock等;
- 新的原子包装类,例如AtomicInteger等。
在实际编码过程中,我们建议通过使用线程池、Task(Runnable/Callable)、原子类和线程安全容器来代替传统的同步锁、wait和notify,提升并发访问的性能、降低多线程编程的难度。
下面,我们针对新的线程并发包在Netty中的应用进行分析和说明,以期为大家的应用提供指导。
首先,我们看下线程安全容器在Netty中的应用,NioEventLoop是IO线程,负责网络读写操作,它同时也执行一些非IO的任务,例如事件通知、定时任务执行等,因此,它需要一个任务队列来缓存这些Task,它的任务队列定义如下:
它是一个ConcurrentLinkedQueue,我们看下它的API 说明:
DOC文档明确说明这个类是线程安全的,因此,对它进行读写操作不需要加锁,下面我们继续看下队列中增加一个任务:
读取任务,也不需要加锁:
JDK的线程安全容器底层采用了CAS、volatile和ReadWriteLock实现,相比于传统重量级的同步锁,采用了更轻量、细粒度的锁,因此,性能会更高。采用这些线程安全容器,不仅仅能提升多线程并发访问的性能,还能降低开发难度。
下面我们看看线程池在Netty中的应用,打开SingleThreadEventExecutor看下它是如何定义和使用线程池的:
首先定义了一个标准的线程池用于执行任务:
接着对它赋值并且进行初始化操作:
执行任务:
我们发现,实际上是执行任务就是先把任务加入到任务队列中,然后判断线程是否已经启动循环执行,如果不是需要启动线程,启动线程代码如下:
实际上就是执行当前线程的run方法,循环从任务队列中获取Task并执行,我们看下它的子类NioEventLoop的run方法就能一目了然:
如红框中所示,循环从任务队列中获取任务并执行:
Netty对JDK的线程池进行了封装和改造,但是,本质上仍然是利用了线程池和线程安全队列简化了多线程编程。
2.6. 读写锁的应用
JDK1.5新的并发编程工具包中新增了读写锁,它是个轻量级、细粒度的锁,合理的使用读写锁,相比于传统的同步锁,可以提升并发访问的性能和吞吐量,在读多写少的场景下,使用同步锁比同步块性能高一大截。
尽管JDK1.6之后,随着JVM团队对JIT即使编译器的不断优化,同步块和读写锁的性能差距缩小了很多;但是,读写锁的应用依然非常广泛,例如,JDK的线程安全List CopyOnWriteArrayList就是基于读写锁实现的,代码如下:
下面,我们对Netty中的读写锁应用进行分析,让大家掌握读写锁的用法,打开HashedWheelTimer代码,读写锁定义如下:
当新增一个定时任务的时候使用了读锁,用于同步wheel的变化,由于读锁是
共享锁,所以当有多个线程同时调用newTimeout的时候,并不会互斥,这样,就提升了并发读的性能。
获取并删除所有过期的任务时,由于要从迭代器中删除任务,所以使用了写锁:
读写锁的使用总结:
- 主要用于读多写少的场景,用来替代传统的同步锁,以提升并发访问性能;
- 读写锁是可重入、可降级的,一个线程获取读写锁后,可以继续递归获取;从写锁可以降级为读锁,以便快速释放锁资源;
- ReentrantReadWriteLock支持获取锁的公平策略,在某些特殊的应用场景下,可以提升并发访问的性能,同时兼顾线程等待公平性;
- 读写锁支持非阻塞的尝试获取锁,如果获取失败,直接返回false,而不是同步阻塞,这个功能在一些场景下非常有用。例如多个线程同步读写某个资源,当发生异常或者需要释放资源的时候,由哪个线程释放是个挑战,因为某些资源不能重复释放或者重复执行,这样,可以通过tryLock方法尝试获取锁,如果拿不到,说明已经被其它线程占用,直接退出即可;
- 获取锁之后一定要释放锁,否则会发生锁溢出异常。通常的做法是通过finally块释放锁。如果是tryLock,获取锁成功才需要释放锁。
2.7. 线程安全性的文档说明
当一个类的方法或者成员变量被并发使用的时候,这个类的行为如何,是该类与其客户端程序建立约定的重要组成部分。如果没有在这个类的文档中描述其行为的并发情况,使用这个类的程序员不得不做出某种假设。如果这些假设是错误的,这个程序就缺少必要的同步保护,会导致意想不到的并发问题,这些问题通常都是隐蔽和调试困难的。如果同步过度,会导致意外的性能下降,无论是发生何种情况,缺少线程安全性的说明文档,都会令开发人员非常沮丧,他们会对这些类库的使用小心翼翼,提心吊胆。
在Netty中,对于一些关键的类库,给出了线程安全习惯的API DOC,尽管Netty的线程安全性并不是非常完善,但是,相比于一些做的更糟糕的产品,它还是迈出了重要一步。
由于ChannelPipeline的应用非常广泛,因此,在API中对它的线程安全性进行了详细的说明,这样,开发者在调用ChannelPipeline的API时,就不要再额外的考虑线程同步和并发问题。
3. 附录
3.1. 总结
本文并不试图面面俱到的涵盖所有JAVA多线程编程的知识点,如果读者希望了解更多的多线程编程知识,建议阅读 Joshua Bloch的《Java Concurrency in Practive》。
事实上,多线程编程作为一个难点,也是阻拦读者深入理解Netty架构和源码的一个拦路虎,如果你的基本功不够扎实,对形形色色的多线程编程技术无法透彻理解,就很难说能够真正理解Netty。当你带着这些疑问去定位Netty深层次问题的时候,也是困难重重。
简言之,如果你不能够精通JAVA的多线程编程,对于很多开源框架的深入理解都会非常困难。
本文通过对Netty中主要使用的多线程编程技术进行归纳、汇总和分析,以期让读者更快的熟悉多线程编程的常用知识点和技巧,加深大家对Netty的理解。