并发编程艺术

226 阅读13分钟

并发容器

并发容器可以基本分为三大类,Concurrent*、CopyOnWrite、Blocking。

①Concurrent*:一般使用CAS保证线程安全,比较高效,没有其他并发容器较重的修改开销。但它并不具有遍历的强一致性,也就是说在使用迭代器遍历时,若容器发生修改迭代器仍然可以继续进行。所以我们为Concurrent*提供了快速失败。若迭代器在遍历过程中检测到容器发生修改,则会抛出ConcurrentModificationException异常。

②CopyOnWriteList:它是在增加元素时先使用ReentrantLock加锁,然后将array数组复制一份,在新复制的数组上执行增加元素的操作。完成后再将array指向这个新的数组。那么读操作就不需要加锁,写操作直接换引用,即读操作会完整地读到老数组而非半成品数组,不会引发脏读情况。所以CopyOnWriteArrayList主要作用是保证无锁下的并发遍历!ArrayList有快速失败。

③Blocking:一般使用ReentrantLock(Lock+Condition)实现。首先获取锁保证队列的排他性,然后通过Condition的等待与通知实现消费者与生产者的通信。

还有Collections.synchronizedXXX(new XXX)

Queue

ConcurrentLinkedQueue源码

它有head节点和tail节点构成首尾,节点与节点之间通过next引用关联。

它的入队操作本质上是,当前线程获取Queue的尾结点,然后设置尾结点的next为入队节点。这个设置操作为[casNext将入队节点设置为尾结点]与[casTail更新tail]

  • ①当其他线程在此期间修改了尾节点,当前线程就会失败并重新获取尾结点。
  • ②casTail更新tail时,tail结点距当前尾结点的距离 >= HOPS时,tail才会更新。HOPS默认为1。这样设计减少了更新tail的操作次数,提高入队效率。 出队列时,弹出队首并更新casHead,也是HOPS设计。

BlockingQueue

LinkedBlockingQueue:使用链表实现的有界队列,按照先进先出的原则对元素排序。

LinkedBlockingQueue中维护了一个缓冲链表,然后通过capacity来限定最大容量。LinkedBlockingQueue为写和读各自维护了一个ReentrantLock和Condition,所以生产者和消费者可以并行地操作队列中的数据。

它的入队操作本质上是,先putLock.lockInterruptibly()加锁,然后若还有剩余容量时则入队,若容量已满则进入Condition同步队列中阻塞。最后释放锁。

put():队列若达到最大容量,则入Condition同步队列。

offer():队列若达到最大容量,则直接返回false。

offer(E e,long timeout,TimeUnit unit):使用Condition.awaitNanos(time)来进行一个无限轮询 + 超时释放。

出队和入队同理,也是使用Lock + Condition。

poll():若队列为空,则直接返回null。

take():若队列为空,则进入Condition同步队列阻塞。

ArrayBlockingQueue:使用数组实现的有界队列,按照先进先出的原则对元素排序。第二个构造参数ture可以通过可重入锁实现线程公平。

ArrayBlockingQueue内部维护了一个缓冲数组,所以它初始化时必须传入初始容量。他与LinkedBlockingQueue不同的是,它只是使用了一个ReentrantLock来保证读写并发,所以性能不如LinkedBlockingQueue。但LinkedBlockingQueue可能会发生OOM,因为他是无界的。

PriorityBlockingQueue:支持优先级的无界队列,可使用Comparator对元素排序

DelayQueue:支持延迟获取的无界队列。创建元素时会指定多久才能出队,并且使用PriorityQueue按照出队时间排序。可以用于定时任务Queue。

SynchronousQueue:不存储元素的阻塞队列,每一个put操作必须等到一个take操作,否则不能继续添加元素。它可以实现线程公平,即等待的线程也会根据先进先出来访问队列。所以它可以代替CountdownLatch(1)。

LinkedTransferQueue:使用链表实现的无界队列,额外具有transfer():判断当前是否有消费者正在等待接收元素,若有则生产者会把元素直接传递给消费者;若没有则入队并自旋等待消费者(自旋一定时间会Thread.yield()暂停当前线程)。

LinkedBlockingDeque:使用链表实现的双端队列,额外具有获取、删除队尾元素的方法。可以用在“工作窃取”模式中。

如何自定义一个定时器

首先想到用队列,但是每秒都要扫描队列,性能肯定不好,所以嘞。

使用优先级队列,以最早执行的时间作为score排序。那么我们的线程只需要关注于堆顶元素的执行时间,得到时间差值然后在这个时间段后来执行即可。

预防死锁

死锁:一组相互竞争资源的线程,出现了因被占用资源而相互等待,导致永久阻塞的现象。

从破坏死锁产生的必要条件角度出发来预防死锁:

  • ①互斥:共享资源只能被一个线程占用,锁的目的就是互斥,这个无法破坏。
  • ②占有且等待:当前线程出现等待其他资源时,不会释放已占用的共享资源。

解决:使用另外一个角色一次性申请当前角色所需要的所有资源,若有资源无法申请成功则全部失败;若申请成功,当前角色执行完操作后,另外角色释放资源。

  • ③不可抢占:当前线程占用的资源不能被其他线程强行抢占。

解决:【RE:使用Lock解决,梦幻联动】

  • ④循环等待:线程互相等待双方已占有的资源。

解决:对多个共享资源进行排序,然后按顺序申请资源

Executor框架

Java把线程的工作单元与执行机制分离。Runnable与Callable为工作单元,Executor提供执行机制。Java的多线程程序把应用分解为若干个任务,然后Executor就把这些任务交给线程,操作系统再把这些线程交给CPU处理。

FutureTask:

FutureTask继承了Runnable接口,所以它可以交给ExecutorService.submit()执行。返回一个FutureTask异步结果,调用FutureTask.get()阻塞获取结果、调用FutureTask.canal()放弃执行未启动的任务,若此时FutureTask已启动则无作用。

CompletableFuture:

它相当于承担了线程池的作用。实现了Future接口和CompletionStage接口,不能被当做任务执行,支持异步回调函数式接口(不阻塞)

它的底层是创建一个ForkJoinPool线程池,传入Supplier对象,封装成AsyncSupply并放入线程池中。然后线程池中的线程会阻塞等待Supplier对象的get(),就相当于是等待FutureTask回调。主线程中使用CompletableFuture.runAsync(Function,myThreadPool)时,会调用thenApply(Function f)方法将传入函数式接口封装成对象并压入栈中。异步线程执行时,函数式接口被弹栈并执行。

在Executor框架中,ExecutorService用于执行任务,ThreadPoolExecutor、ScheduledThreadPoolExecutor是ExecutorService的具体实现类。ExecutorService可以执行Runnable、Callable、FutureTask三种任务,Runnable可以与待返回结果一起被包装成Callable,Callable与FutureTask被执行会返回FutureTask异步计算结果。

创建线程的四种方式

继承Thread类创建线程

Thread t = Thread(Runnable),传入任务创建线程

Thread t = Thread(callable),传入任务创建线程

线程池创建线程。

ThreadPoolExecutor:线程池的实现类

FixedThreadPool:核心线程数与最大线程池为根据传入参数设置,使用LinkedBlockingQueue作为工作队列。

SingleThreadExecutor:使用单个worker线程的Executor,核心线程数与最大线程池为1,使用LinkedBlockingQueue作为工作队列。

CachedThreadPool:核心线程数为0,但最大工作线程数无界,keepAliveTime为60秒,使用SynchronousQueue作为工作队列。

ScheduledThreadPoolExecutor extends ThreadPoolExecutor: 在指定延迟时间执行任务或定期执行ScheduledFutureTask任务,返回ScheduledFuture。

ScheduledThreadPoolExecutor使用DelayQeue作为工作队列,DelayQueue封装了一个PriorityQueue,会对其中的ScheduledFuture根据时间与序号进行排序。当任务到期时就弹出任务执行,然后更新时间并放入队尾。因为我的调度线程池仅仅用于心跳,所以我把心跳任务设置为单例,然后使用ScheduledThreadPoolExecutor.schedule(Runnable,time,timeUnit)执行心跳调度。

ForkJoinPool

WorkStealingPool:工作窃取线程池,内部会构建ForkJoinPool,把大任务切分为一个个小任务,CPU并行执行一个个小任务,然后合并结果。它的底层重写了compute()方法,通过递归把大任务分成了若干个小任务,并调用fork()方法分别执行。

线程池:

new ThreadPoolExecutor 七大参数:

①corePoolSize:线程池中的核心线程数。但不会一开始就初始化,而是等任务到来时才会被初始化,之后变一直维持最小线程数为corePoolSize。

②maximumPoolSize:线程池中可以容纳的的最大线程数,即核心线程与加班线程的最大总数。

③ keepAliveTime:线程池中的加班线程空闲后的最大存活时间。

④ unit:加班线程最大存活时间的单位。

⑤ workQueue:存放等待执行的任务的阻塞队列。

⑥threadFactory:创建线程所使用的工厂。

⑦ handler:拒绝策略。当使用的线程数达到最大线程数,且工作队列也满了,再有任务提交时就会触发拒绝策略。线程池提供了以下四种拒绝策略。

  • CallerRunsPolicy:提交任务的线程自己去执行该任务
  • AbortPolicy:默认的拒绝策略,抛出RejectedExecutionException异常
  • DiscardPolicy:直接丢弃任务
  • DiscardOldestPolicy:丢弃最早进入工作队列的任务

工作原理:

线程池和一般意义上的池化资源不同。一般的池化资源是我们需要资源的使用就调用acquire()申请资源,用完之后就调用release()释放资源。而线程池采用了生产者-消费者模式。使用线程池的一方是生产者,线程池本身是消费者。在线程池的内部维护类一个工作队列和一些工作线程,用户调用execute()方法来提交Runnable任务,execute()方法仅仅是把任务加入到工作队列中,线程池中的工作线程会消费工作队列中的任务。当一个任务到来时,线程池会使用核心线程池执行任务,如果核心线程池都在工作,就把任务放入工作队列中等待执行。如果工作队列也满了,线程池就会创建加班线程。如果核心线程与加班线程到达最大线程数,并且任务队列仍然是满的,就会触发拒绝策略。

线程池大小的选择策略:

对于执行比较慢、I/O操作较多的任务,操作系统的大部分时间用于I/O交互,而线程在进行I/O操作时不会占用CPU,所以我们需要更多的线程数来提高CPU利用率,而不需要太大的任务队列(CPU* 2)

对于吞吐量较大的CPU计算型任务,本质上要提高CPU的利用率,减少线程的I/O消耗。所以线程数不宜过多(减少线程上下文切换的开销),但需要较长的任务队列来做缓存(CPU+1,1是为了防止线程偶发的缺页中断或其他原因带来的暂停)

使用线程池要注意的点

Executors提供的线程池都是使用无界工作队列,容易导致OOM

默认的拒绝策略不会被编译器捕获,所以要自定义拒绝策略(搭配降级)

声明线程后立即调用prestartAllCoreThreads()方法,可以立即启动所有核心线程。

调用allowCoreThreadTimeOut(true)方法,使得线程池在空闲时也会回收核心线程。

弹性线程池

对于执行时间比较长的任务,我们希望优先开启更多的线程,避免任务进入阻塞队列一直等待,即把任务队列作为一个后备方案。

解决:线程扩容是发生在任务队列满了之后的,所以我们可以重写任务队列的offer(),造成任务队列已满的假象。如果这样,触发拒绝策略时任务队列其实是假满,所以我们可以在拒绝策略中把任务塞入任务队列。(github.com/y645194203/…

Java8的Stream,背后是公用一个ForkJoinPool,最大线程数是CPU-1。所以当我们使用Stream处理IO密集型操作时,可以重写他的ForkJoinPool。

并发工具类

【CountDownLatch】:CountDownLatch底层维护了一个计数器。

CountDownLatch的构造器接收int表示等待几个线程。当调用CountDownLatch.await()时,当前线程阻塞,等待其他线程。其他线程调用countDown()使得计数器减一。当减为0时,在await()阻塞的线程被唤醒。

【CyclicBarrier】:

使得一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障时,屏障才会打开并放行所有线程。每个线程调用await()方法时会被阻塞在屏障,并告诉屏障我到达了,屏障计数器减一。当减为0时全部放行。(用于多线程计算数据。我的项目并用不上)

【Fork/Join工作窃取】:

使用ForkJoinPool执行ForkJoin任务,把大任务分隔成一个个的小任务放到双端队列中,然后启动几个线程从队列中获取任务执行,执行完成后的结果统一放入一个队列中,再启动一个单独的线程合并数据。(我的项目用的Queue,应该用Deque)

【原子操作原理-CAS】

多处理器实现原子操作:

①使用总线锁保证原子性:多个处理器同时从各自的缓存中读取变量,分别进行操作后分别写入系统内存,造成数据不一致的线性。所以使用总线锁,锁定CPU与内存之间的通信,处理器在操作共享变量时会发出一个LOCK#信号,其他处理器的请求就会被阻塞,实现了处理器独占共享资源。

②使用缓存锁定保证原子性:处理器执行锁操作写回内存时,会直接修改内部的内存地址,通过缓存一致性机制保证操作的原子性。 Java保证原子性操作:使用CAS(compareAndSet(int expect,int update)),循环进行CAS操作直至返回成功。 原理:在修改之前拿一份真实值快照放到线程内存中的volatile变量中,在更新操作前要判断当前值是否等于真实快照,若等于则表示修改过程中没有其他线程修改此值,才会进行修改操作。volatile读+volatile写保证了在CAS操作前后的指令禁止重排序,而且写完后会把本地内存中的数据立即刷入共享内存,保证CAS的有效性。 CAS操作的问题: ①ABA问题:在CAS检查值是否变化时,值发生了ABA变化,但无法检测到。可以使用版本号解决。JDK的AtomicStampedReference.compareAndSet()方法会检查当前引用与当前标志是否发生变化,若全部相当才会进行操作。

②循环时间长,CPU开销大

③无法保证多个变量的原子性操作。使用AtomicReference保证引用对象的原子操作,那么把共享变量封装到引用对象中即可。 原子类:底层使用unSafe的CAS方法判断并更新。