并发基础
并发与并行
并发偏重于多个任务交替执行,而多个任务之间有可能还是串行的。而并行是真正意义上的同时执行。
临界区
一次只能被一个线程使用的公共资源。
死锁
多个线程占有资源,相互等待形成一个环。
饥饿
线程因为种种原因无法获得所需要的资源,导致一直无法执行。
活锁
任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试,失败,尝试,失败。
活锁和死锁的区别在于,处于活锁的实体是在不断的改变状态,所谓的”活”,而处于死锁的实体表现为等待;活锁有可能自行解开,死锁则不能。
并行的两个重要定律
Amdahl定律
定义了串行化系统并行化后的加速比计算公式和理论上限。
![](/qrcode.jpg)
加速比=优化前系统耗时/优化后系统耗时
Tn = T1(F + (1-F)/n)
加速比=T1/Tn = 1/(F+(1-F)/n)
T1: 优化前耗时
Tn: 使用n个处理器优化后的耗时
F: 程序中只能串行执行的比例
n: 处理器个数
根据Amdahl定律,使用多核CPU对系统进行优化,优化的效果取决于CPU的数量,以及系统中串行化程序的比例。
CPU数量越多,串行化比例越低,则优化效果越好。
Gustafson定律
同样试图说明处理器个数、串行化比例和加速比之间的关系。
执行时间=a+b
a: 串行时间
b: 并行时间
总的执行时间=a+n*b
n: 处理器个数
串行比例 F=a/(a+b)
加速比 Sn = (a+n*b) / (a+b) = n - F(n-1)
根据Gustafson定律,如果串行化比例很小,并行化比例很大,那么加速比就是处理器的个数。只要不断累加处理器,就能获得更快的速度。
隐蔽的并发错误
没有提示的错误
比如数值溢出
并发下的ArrayList
抛出异常ArrayIndexOutOfBoundsException
多线程访问ArrayList的问题在于:在ArrayList容量快要用完时(只有一个可用空间),如果两个线程同时进入add函数,并且判断认为系统满足继续添加元素而不需要扩容,那么两个线程先后向系统写入自己的数据,必然会有一个线程将数据写到边界外,从而产生ArrayIndexOutOfBoundsException。
List大小变小
可能是保存容器大小的变量被多线程不正常的访问,同时两个线程也对ArrayList中的同一个位置进行赋值导致的。
并发下的HashMap
Map大小变小,元素丢失
和ArrayList一样,保存容器大小的变量被多线程不正常访问,或者多个线程对同一个位置赋值
程序永远无法结束
链表成环
JMM的关键技术点都是围绕着多线程的原子性、可见性、有序性来建立的。
原子性:一个操作是不可中断的。
可见性:一个线程修改了某一个共享变量的值时,其他线程能够立即知道这个修改。
有序性:程序按照代码的先后顺序执行。
有序性问题:线程A的指令执行顺序在线程B看来是没有保证的。指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。
为什么要指令重排?
CPU为了实现流水线操作,提高性能。
比如有A,B,C三个步骤需要用到3个不同的硬件,那么两个线程执行,步骤如下:
线程A: A,B,C
线程B: A,B,C
为了保证流水线不中断,比如数据还没准备好,那么就好后面的操作提到前面,让CPU持续运行,比如:
线程A: A, C, B
线程B: A, C, B
Java虚拟机和执行系统会对指令进行一定的重排,但是指令重排是有原则的,并非所有的指令都可以随便改变执行位置。下面是基本原则:
- 程序顺序原则:一个线程内保证语义的串行性
- Volatile规则:volatile变量的写先于读发生,保证了volatile变量的可见性
- 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前
- 传递性:A先于B, B先于C,那么A必然先于C
- 线程的start()方法先于它的每一个动作
- 线程的所有操作先于线程的终结(Thread.join())
- 线程的中断(interrupt())先于被中断线程的代码
- 对象的构造函数的执行、结束先于finalize()方法
JDK相关
Thread.interrupt()中断线程
Thread.isInterrupted()判断是否被中断
Thread.interrupted()判断是否被中断,并清除当前中断状态
Thread.sleep由于中断会抛出异常,同时会清除中断标识。如果需要标识则要通过Thread.interrupt()重新设置。
Wait, nofity必须在synchronized语句中,因为要调用他们必须先获得目标对象的监视器锁。
线程的suspend和resume方法,如果resume在前面执行,则线程会永久阻塞,但是线程的状态是runnable,是令人迷惑的。
LockSupport.park和LockSupport.unpark则不会有这个问题。LockSupport响应中断,但是不会抛出InterruptedException异常,只会默默往下执行,但是从Thread.interrupted()方法可以获得中断标记。
ConcurrentHashMap: 现成安全的HashMap
CopyOnWriteArrayList: 写时复制,在读多写少的场合,这个List的性能非常好,远远优于Vector
ConcurrentLinkedQueue: 高效的并发队列,使用链表实现,可以看做一个线程安全的LinkedList
BlockingQueue: 接口,JDK内部通过链表,数组等方式实现了这个接口。表示阻塞队列,非常适合作为数据共享的通道
ConcurrentSkipListMap: 跳表的实现。这是一个Map,使用跳表的数据结构进行快速查找
线程安全的HashMap
方法一
使用Collections.synchronizedMap()方法包装HashMap。
它使用委托,将自己所有的Map相关的功能交给传入的HashMap实现,而自己主要负责保证线程安全。
内部所有的操作都会使用内部的mutex进行同步,从而实现线程安全,因此性能并不好。mutex是当前对象,即创建出来的SynchronizedMap。
方法二
使用ConcurrentHashMap
方法三
使用ConcurrentSkipListMap
跳表时间复杂度O(logn)
跳表的本质是同时维护了多个链表,并且链表是分层的。
使用跳表实现Map和使用哈希算法实现Map的一个不同之处就是哈希表不会保存元素的顺序,而跳表内所有的元素都是有序的。
线程安全的List
方法一
使用Collections.synchronizedList()包装List
方法二
使用ConcurrentLinkedQueue
大量使用cas操作链表节点
方法三
使用CopyOnWriteArrayList
漏桶的基本思想:利用一个缓冲区,无论请求速率如何,都现在缓存区保存,然后以固定的流速流出缓存区进行处理。
不论外部请求压力如何,漏桶算法以固定的流速处理数据。漏桶的容积和流出速率是该算法的两个重要参数。
令牌桶算法:在令牌桶算法中,桶中存放的不是请求而是令牌。处理程序只有拿到令牌后才能对请求处理。如果没有令牌,那么处理程序要么丢弃该请求,要么等待。
该算法每个单位时间产生一定量的令牌存入桶中。
ScheduledThreadPoolExecutor如果任务遇到异常,后续所有子任务都会停止调度,因此必须保证异常被及时处理,为周期性的任务的稳定调度提供条件。
线程池
Java Concurrency in Pracetice一书中给出了估算线程池大小的公式:
Ncpu = CPU的数量
Ucpu = 目标CPU的使用率, 0<= Ucpu <= 1
W/C = 等待时间与计算时间的比率
为保持处理器达到期望的使用率,最优线程池的大小等于:
Nthreads = Ncpu * Ucpu * (1 + W/C)
在Java中,可以通过如下代码取得可用的CPU数量:Runtime.getRuntime().availableProcessors()。
保存线程池异常堆栈信息
扩展ThreadPoolExecutor线程池,保存提交任务线程的堆栈信息。
public class TraceThreadPoolExecutor extends ThreadPoolExecutor {
public TraceThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime,
@NotNull TimeUnit unit,
@NotNull BlockingQueue<Runnable> workQueue) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
}
@Override
public void execute(Runnable task) {
super.execute(wrap(task, clientTrace(), Thread.currentThread().getName()));
}
@Override
public Future<?> submit(Runnable task) {
return super.submit(wrap(task, clientTrace(), Thread.currentThread().getName()));
}
private Exception clientTrace() {
return new Exception("Client stack trace");
}
private Runnable wrap(final Runnable task, final Exception clientStack, String clientThreadName) {
return new Runnable() {
@Override
public void run() {
try {
task.run();
} catch (Exception e) {
clientStack.printStackTrace();
throw e;
}
}
};
}
}
测试代码
public static void main(String[] args) {
ThreadPoolExecutor pools = new TraceThreadPoolExecutor(0, Integer.MAX_VALUE, 0L, TimeUnit.SECONDS, new SynchronousQueue<>());
pools.execute(new DivTask());
}
static class DivTask implements Runnable {
@Override
public void run() {
System.out.println(100 / 0);
}
}
异常:
java.lang.Exception: Client stack trace
at xy.art.TraceThreadPoolExecutor.clientTrace(TraceThreadPoolExecutor.java:33)
at xy.art.TraceThreadPoolExecutor.execute(TraceThreadPoolExecutor.java:24)
at xy.art.TraceThreadPoolExecutor.main(TraceThreadPoolExecutor.java:52)
Exception in thread "pool-1-thread-1" java.lang.ArithmeticException: / by zero
at xy.art.TraceThreadPoolExecutor$DivTask.run(TraceThreadPoolExecutor.java:59)
at xy.art.TraceThreadPoolExecutor$1.run(TraceThreadPoolExecutor.java:41)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)
从哪里到线程池调用,从线程池内部调度到异常抛出点。
并行模式与算法
单例模式
单例模式的好处:
- 对于频繁使用的对象,可以省略new操作花费的时间,这对于那些重量级对象而言,是非常可观的一笔系统开销
- 由于new操作的次数减少,因而对系统内存的使用频率也会降低,减轻GC压力
饿汉式:
public class Singleton {
private Singleton() {
System.out.println("Singleton is create");
}
private static Singleton instance = new Singleton();
public static Singleton getInstance() {
return instance;
}
}
优点:性能好,因为getInstance()只是简单地返回instance,没有任何锁操作。
缺点:Singleton实例在什么时候创建是不受控制的。对于静态成员instance,它会在类第一次初始化时被创建,但是初始化时不一定是getInstance()方法第一次被调用的时候。简单来说就是类初始化时机不确定。
懒汉式-synchronized
public class LazySingleton {
private LazySingleton() {
System.out.println("LazySingleton is create");
}
private static LazySingleton instance = null;
public static synchronized LazySingleton getInstance() {
if (instance == null) {
instance = new LazySingleton();
}
return instance;
}
}
懒汉式-双检查校验
public class DoubleCheckLazySingleton {
private DoubleCheckLazySingleton() {
System.out.println("DoubleCheckLazySingleton is create");
}
private static volatile DoubleCheckLazySingleton instance = null;
public static DoubleCheckLazySingleton getInstance() {
if (instance == null) {
synchronized (DoubleCheckLazySingleton.class) {
if (instance == null) {
instance = new DoubleCheckLazySingleton();
}
}
}
return instance;
}
}
早期synchronized开销大
懒汉式-类初始化
public class StaticSingleton {
private StaticSingleton() {
System.out.println("StaticSingleton is create");
}
private static class SingletonHolder {
private static StaticSingleton instance = new StaticSingleton();
}
public static StaticSingleton getInstance() {
return SingletonHolder.instance;
}
}
同时拥有懒汉式和饿汉式的优点。首先getInstance()方法中没有锁,因此在高并发环境下性能优越;其次只有在getInstance()方法第一次被调用时,StaticSingleton的实例才会被创建。因为这种方法巧妙地使用了内部类和类的初始化方式。
内部类SingletonHolder被声明为private,这使得我们不可能在外部访问并初始化它。而我们只可能在getInstance()方法内部对SingletonHolder类进行初始化,利用虚拟机的类初始化机制创建单例。
不变模式
多线程操作同一个对象进行读写操作时,为了保证对象数据的一致性和正确性,有必要对对象进行同步,但是同步操作对系统性能有损耗。为了尽可能去除这些同步操作,提高并行程序性能可以使用一种不可改变的对象,依靠对象的不变性,确保在没有同步操作的多线程环境中依然保持内部状态的一致性和正确性。
不变模式和只读模式是有区别的。不变模式比只读模式有更强的一致性和不变性。对只读属性的对象而言,对象本身不能被其他线程修改,但是对象自身状态却可能自行修改。
不变模式的使用场景需满足以下两个条件:
- 对象被创建后,其内部状态和数据不再发生变化
- 对象需要被共享,被多线程频繁访问
实现不变模式:
- 取出setter方法及所有修改自身属性的方法
- 将所有属性设置为私有,利用final标记,确保不可修改
- 确认没有子类可以重载修改它的行为
- 有一个可以状态完整对象的构造方法
不变模式的应用:String, Boolean等包装类。
生产者-消费者模式
生产者线程负责提交用户请求,消费者线程负责具体处理生产者提交的任务。生产者和消费者之间通过共享内存缓冲区进行通信。
实现方式:
- BlockingQueue
- Disruptor无锁内存队列
Future模式
核心思想是异步调用
Java 8/9/10与并发
Java8的函数式编程
无副作用
函数的副作用是指在函数调用过程中,除了给返回值,还修改了函数外部的状态。
显示函数:与外界交互数据的唯一渠道就是参数和返回值,显示函数不会读取或者修改函数的外部状态
隐式函数:除了参数和返回值,还会读取外部信息或者修改外部信息
声明式
命令式程序设计喜欢大量使用可变对象和指令,习惯创建对象或变量,并且修改他们的值,或者喜欢提供一系列指令。
声明式程序设计所有的指令细节都被程序库封装,只需要提出要求,声明用意,即调用方法。
函数式编程是声明式的编程方式。
不变的对象
在函数式编程中,几乎所有传递的对象都不会被轻易修改。
stream.map等。
易于并行
由于对象处于不变的状态,因此函数式编程更加易于并行。
更少的代码
通常情况下,函数式编程更加简明扼要,Clojure语言(运行于JVM的函数式语言)的爱好者就宣称,使用Clojure可以将Java代码行数减少到原来的十分之一。
函数式编程基础
- FunctionalInterface注解
- 接口默认方法
- lambda表达式
- 方法引用
方法引用
一般来说,如果使用的是静态方法,或者调用目标明确,那么流内的元素会自动作为参数使用。
如果参数引用表示实例方法,并且不存在调用目标,那么流内元素会自动作为调用目标。
可以使用虚拟机参数 -Djdk.internal.lambda.dumpProxyClasses启动带有lambda表达式的程序。该参数会将lambda表达式相关的中间类型进行输出,方便调试和学习。
Java -p -v HelloFunction6$$Lambda$1.class
ConcurrentHashMap的增强
JDK1.8以后,ConcurrentHashMap有了一些API的增强,其中很多接口与lambda表达式有关,这些增强接口大大方便了应用的开发。
forEach对Map数据进行消费
Reduce对Map的数据进行处理的同时会将其转换为另一种形式
computeIfAbsent条件插入
search并发搜索
mappingCount返回Map中的条目总数
newKeySet返回一个线程安全的Set
Jdk9引入了并发编程框架-反应式编程,用于处理异步流中的数据,每当收到数据项,便会对它进行处理。
在反应式编程中,核心的两个组件是Publisher和Subscriber。Publisher将数据发布到流中,Subscriber负责处理这些数据。