互联网的快速发展,Java开发的过程或多或少会需要进行并发编程,也会遇到一些并发编程带来的各种bug。下面从并发编程的理论、并发工具类、并发设计模式、并发模型案例,记录一下自己的学习历程。
1.并发编程理论
1.1 可见性、原子性、有序性
并发编程的来源于缓存导致的可见性问题,线程切换带来的原子性问题,编译优化带来的有序性问题,也就是并发编程需要遵循的三个原则。
可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到
原子性:一个或者多个操作在 CPU 执行的过程中不被中断的特性
有序性:程序按照代码的先后顺序执行
1.2 Java内存模型
Java语言规范引入了Java内存模型,通过定义多项规则对编译器和处理器进行限制,主要是针对可见性和有序性。主要是通过volatile、synchronized 和 final 三个关键字,以及Happens-Before 规则。
(1)锁,锁操作是具备happens-before关系的,解锁操作happens-before之后对同一把锁的加锁操作。实际上,在解锁的时候,JVM需要强制刷新缓存,使得当前线程所修改的内存对其他线程可见。
(2)volatile字段,volatile字段可以看成是一种不保证原子性的同步但保证可见性的特性,其性能往往是优于锁操作的。但是,频繁地访问 volatile字段也会出现因为不断地强制刷新缓存而影响程序的性能的问题。
(3)final修饰符,final修饰的实例字段则是涉及到新建对象的发布问题。当一个对象包含final修饰的实例字段时,其他线程能够看到已经初始化的final实例字段,这是安全的。
Happpens-Before规则:
(1)程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
(2)管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而"后面"是指时间上的先后顺序。
(3)volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的"后面"同样是指时间上的先后顺序。
(4)线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
(5)线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
(6)线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
(7)对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
1.3 互斥锁
互斥:同一个时刻只有一个线程在运行。锁是一种通用的技术方案,Java 语言提供的 synchronized 关键字,就是锁的一种实现。synchronized 关键字可以用来修饰方法,也可以用来修饰代码块。
class X {
// 修饰非静态方法
synchronized void foo() {
// 临界区
}
// 修饰静态方法
synchronized static void bar() {
// 临界区
}
// 修饰代码块
Object obj = new Object();
void baz() {
synchronized(obj) {
// 临界区
}
}
}
当修饰静态方法的时候,锁定的是当前类的 Class 对象;当修饰非静态方法的时候,锁定的是当前实例对象 this。锁的本质是在锁定对象的头部字段写入锁定状态和线程信息,所以锁定的对象需要是一个不变的对象。
当用一把锁锁住多个资源,性能太差,会造成几个操作都是串行。所以可以用多把锁分别锁不同的资源,不同的操作可以并行操作。用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁。当然这样又会造成死锁的情况出现。要避免死锁就需要分析死锁发生的条件,有个叫 Coffman 的牛人早就总结过了,只有以下这四个条件都发生时才会出现死锁:
(1)互斥,共享资源 X 和 Y 只能被一个线程占用;
(2)占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
(3)不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
(4)循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
1.4 用“等待-通知”机制优化循环等待
在 Java 语言里,等待 - 通知机制可以有多种实现方式,比如 Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll() 这三个方法就能轻松实现。如果 synchronized 锁定的是 this,那么对应的一定是 this.wait()、this.notify()、this.notifyAll();如果 synchronized 锁定的是 target,那么对应的一定是 target.wait()、target.notify()、target.notifyAll() 。notify() 是会随机地通知等待队列中的一个线程,而 notifyAll() 会通知等待队列中的所有线程。有个阿姆达尔(Amdahl)定律,代表了处理器并行运算之后效率提升的能力,它正好可以解决这个问题,具体公式如下:
公式里的 n 可以理解为 CPU 的核数,p 可以理解为并行百分比。
1.5 Java线程
线程在sleep期间被打断了,抛出一个InterruptedException异常,try catch捕捉此异常,应该重置一下中断标示,因为抛出异常后,中断标示会自动清除掉!
Thread th = Thread.currentThread();
while(true) {
if(th.isInterrupted()) {
break;
}
// 省略业务代码无数
try {
Thread.sleep(100);
}catch (InterruptedException e){
Thread.currentThread().interrupt();
e.printStackTrace();
}
}
CPU 密集型计算:理论上“线程的数量 =CPU 核数”就是最合适的。不过在工程上,线程的数量一般会设置为“CPU 核数 +1”,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证 CPU 的利用率。
I/O 密集型:最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]
Java不支持尾递归,尽量不要使用递归。
2.并发工具类
3.并发设计模式
每个Thread线程内部都有一个Map,Map里面存储线程本地对象(key)和线程的变量副本(value),但是,Thread内部的Map是由ThreadLocal维护的,由ThreadLocal负责向map获取和设置线程的变量值。
4.并发编程案例
4.1 高性能限流器Guava RateLimiter
令牌桶算法是定时向令牌桶发送令牌,请求能够从令牌桶中拿到令牌,然后才能通过限流器;而漏桶算法里,请求就像水一样注入漏桶,漏桶会按照一定的速率自动将水漏掉,只有漏桶里还能注入水的时候,请求才能通过限流器。Guava RateLimiter采用令牌桶算法。
4.2 高性能网络应用框架Netty
Netty 是一个款优秀的网络编程框架,性能非常好,为了实现高性能的目标,Netty 做了很多优化,例如优化了 ByteBuffer、支持零拷贝等等,和并发编程相关的就是它的线程模型。
4.3 高性能队列Disruptor
Disruptor 是一款高性能的有界内存队列,基于Disruptor开发的系统单线程能支撑每秒600万订单。原因如下:
(1)内存分配更加合理,使用 RingBuffer 数据结构,数组元素在初始化时一次性全部创建,提升缓存命中率;对象循环利用,避免频繁 GC。
(2)能够避免伪共享,提升缓存利用率。
(3)采用无锁算法,避免频繁加锁、解锁的性能消耗。
(4)支持批量消费,消费者可以无锁方式消费多个消息。
Disruptor详细介绍:高性能队列Disruptor 很详细。
4.4 高性能数据库连接池HiKariCP
HiKariCP 号称是业界跑得最快的数据库连接池,主要是有两个特殊的数据结构:FastList、ConcurrentBag。
1.FastList将remove(Object element) 方法的查找顺序变成了逆序查找;get(int index) 方法没有对 index 参数进行越界检查,HiKariCP 能保证不会越界
2.ConcurrentBag 通过 ThreadLocal 做一次预分配,避免直接竞争共享资源,非常适合池化资源的分配
4.5 Actor模型
Actor模型基于消息机制,可以实现并发编程和分布式编程。
4.6 软件事务内存
MVCC(全称是 Multi-Version Concurrency Control),也就是多版本并发控制,具体代表是:Multiverse