原文来自李林峰的《多线程并发编程在 Netty 中的应用分析》
Java内存模型
工作内存和主内存
Java 内存模型规定所有的变量都存储在主内存中(JVM 内存的一部分),每
个线程有自己独立的工作内存,它保存了被该线程使用的变量的主内存拷贝,线
程对这些变量的操作都在自己的工作内存中进行,不能直接操作主内存和其它工
作内存中存储的变量或者变量副本,线程间的变量访问需通过主内存来完成,三
者的关系如下图所示:
Java内存交互协议
JAVA 内存模型定义了八种操作来完成主内存和工作内存的变量访问,具体如下:
- lock:主内存变量,把一个变量标识为某个线程独占的状态;
- unlock:主内存变量,把一个处于锁定状态变量释放出来,被释放后的变量才可以被其它线程锁定;
- read:主内存变量,把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用;
- load:工作内存变量,把 read 读取到的主内存中的变量值放入工作内存的变量拷贝中;
- use:工作内存变量,把工作内存中变量的值传递给 java 虚拟机执行引擎,每当虚拟机遇到一个需要使用到变量值的字节码指令时将会执行该操作;
- assign:工作内存变量,把从执行引擎接收到的变量的值赋值给工作变量,每当虚拟机遇到一个给变量赋值的字节码时将会执行该操作;
- store:工作内存变量,把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用;
- write:主内存变量,把 store 操作从工作内存中得到的变量值放入主内存的变量中。
Java的线程
主流的操作系统都提供了线程实现,目前实现线程的方式主要有三种,分别是:
- 内核线程(KLT)实现,这种线程由内核来完成线程切换,内核通过线程调度器对线程进行调度,并负责将线程任务映射到不同的处理器上;
- 用户线程实现(UT),通常情况下,用户线程指的是完全建立在用户空间线程库上的线程,用户线程的创建、启动、运行、销毁和切换完全在用户态中完成,不需要内核的帮助,因此执行性能更高;
- 混合实现:将内核线程和用户线程混合在一起使用的方式。
由于虚拟机规范并没有强制规定 JAVA 的线程必须使用哪种方式实现,因此,不同的操作系统实现的方式也可能存在差异。对于 SUN 的 JDK,在 Windows 和Linux 操作系统上采用了内核线程的实现方式,在 Solaris 版本的 JDK 中,提供了一些专有的虚拟机线程参数,用于设置使用哪种线程模型。
Netty的并发编程分析
正确的使用锁
在更细粒度的范围加锁;
正确的选择synchronized和ReentrantLock用于加锁;
volatile的正确使用
关键字 volatile 是 JAVA 提供的最轻量级的同步机制,JAVA 内存模型对volatile 专门定义了一些特殊的访问规则,下面我们就看下它的规则:
当一个变量被 volatile 修饰后,它将具备两种特性:
- 线程可见性:当一个线程修改了被 volatile 修饰的变量后,无论是否加锁,其它线程都可以立即看到最新的修改,而普通变量却做不到这点;
- 禁止指令重排序优化,普通的变量仅仅保证在该方法的执行过程中所有依赖赋值结果的地方都能获取正确的结果,而不能保证变量赋值操作的顺序与程序代码的执行顺序一致。举个简单的例子说明下指令重排序优化问题:
我们预期程序会在 3S 后停止,但是实际上它会一直执行下去,原因就是虚拟机对代码进行了指令重排序和优化,优化后的指令如下:
if (!stop)
While(true)
......
重排序后的代码是无法发现 stop 被主线程修改的,因此无法停止运行。如果要解决这个问题,只要将 stop 前增加 volatile 修饰符即可,代码修改如下:
再次运行,我们发现 3S 后程序退出,达到了预期效果,使用 volatile 解决了如下两个问题:
- main 线程对 stop 的修改在 workThread 线程中可见,也就是说 workThread线程立即看到了其它线程对于 stop 变量的修改;
- 禁止指令重排序,防止因为重排序导致的并发访问逻辑混乱。
一些人错误的认为使用 volatile 可以代替传统锁,提升并发性能,这个认识是错误的,volatile 仅仅解决了可见性的问题,但是它并不能保证互斥性,也就是说多个线程并发修改某个变量时,依旧会产生多线程问题。因此,不能靠volatile 来完全替代传的锁。
根据经验总结,volatile 最适合使用的地方是一个线程写、其它线程读的场合,如果有多个线程并发写操作,仍然需要使用锁或者线程安全的容器或者原子变量来代替。
CAS指令和原子类
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 等大量使用其原子类,减少了锁的应用,降低了频繁使用同步锁带来的性能下降。
线程安全类
在 JDK1.5 的发行版本中,Java 平台新增了 java.util.concurrent,这个包中提供了一系列的线程安全集合、容器和线程池,利用这些新的线程安全类可以极大的降低 Java 多线程编程的难度,提升开发效率。
新的并发编程包中的工具可以分为如下四类:
- 线程池 Executor Framework 以及定时任务相关的类库,包括 Timer 等;
- 并发集合,包括 List、Queue、Map 和 Set 等;
- 新的同步器,例如读写锁 ReadWriteLock 等;
- 新的原子包装类,例如 AtomicInteger 等。
在实际编码过程中,我们建议通过使用线程池、Task(Runnable/Callable)、原子类和线程安全容器来代替传统的同步锁、wait 和 notify,提升并发访问的性能、降低多线程编程的难度。
并发集合:
对于List,Queue,Map,Set 选用其线程安全的实现:
List | CopyOnWriteArrayList ; Vector |
---|---|
Set | CopyOnWriteArraySet |
Map | ConcurrentHashMap |
Queue | ConcurrentLinkedQueue ; BlockingQueue |
详见:高并发下的Java数据结构(List、Set、Map、Queue)
线程池:
深入理解Java线程池:ThreadPoolExecutor
读写锁的应用
JDK1.5 新的并发编程工具包中新增了读写锁,它是个轻量级、细粒度的锁,合理的使用读写锁,相比于传统的同步锁,可以提升并发访问的性能和吞吐量,在读多写少的场景下,使用同步锁比同步块性能高一大截。尽管 JDK1.6 之后,随着 JVM 团队对 JIT 即使编译器的不断优化,同步块和读写锁的性能差距缩小了很多;但是,读写锁的应用依然非常广泛,例如,JDK的线程安全 List CopyOnWriteArrayList 就是基于读写锁实现的,代码如下:
下面,我们对 Netty 中的读写锁应用进行分析,让大家掌握读写锁的用法,
打开 HashedWheelTimer 代码,读写锁定义如下:
当新增一个定时任务的时候使用了读锁,用于感知 wheel 的变化,由于读锁是共享锁,所以当有多个线程同时调用 newTimeout 的时候,并不会互斥,这样,就提升了并发读的性能。
获取并删除所有过期的任务时,由于要从迭代器中删除任务,所以使用了写锁:
读写锁的使用总结:
- 主要用于读多写少的场景,用来替代传统的同步锁,以提升并发访问性能;
- 读写锁是可重入、可降级的,一个线程获取读写锁后,可以继续递归获取;从写锁可以降级为读锁,以便快速释放锁资源;
- ReentrantReadWriteLock 支持获取锁的公平策略,在某些特殊的应用场景下,可以提升并发访问的性能,同时兼顾线程等待公平性;
- 读写锁支持非阻塞的尝试获取锁,如果获取失败,直接返回 false,而不是同步阻塞,这个功能在一些场景下非常有用。例如多个线程同步读写某个资源,当发生异常或者需要释放资源的时候,由哪个线程释放是个挑战,因为某些资源不能重复释放或者重复执行,这样,可以通过 tryLock 方法尝试获取锁,如果拿不到,说明已经被其它线程占用,直接退出即可;
- 获取锁之后一定要释放锁,否则会发生锁溢出异常。通常的做法是通过 finally 块释放锁。如果是 tryLock,获取锁成功才需要释放锁。
不要使用线程优先级
当有多个线程同时运行的时候,由线程调度器来决定哪些线程运行、哪些等待以及线程切换的时间点,由于各个操作系统的线程调度器实现大相径庭,因此,依赖 JDK 自带的线程优先级来设置线程优先级策略的方法是错误和非平台可移植的。所以,在任何情况下,你的程序都不能依赖 JDK 自带的线程优先级来保证执行顺序、比例和策略。
Netty 中默认的线程工厂实现类,开放了包含设置线程优先级字段的构造函数,这个是个错误的决定,对于使用者来说,既然提供了优先级字段,它就本能的认为它可以正确并且到处运行,但实际上由于它基于 JDK 默认的线程优先级实现,因此无法满足使用者的需求。