Java并发编程实战笔记

对象的组合


Java监视器模式
  • Java监视器模式会将对象 所有可变状态 封装起来,并由对象自己的内置锁保护,例如Vector和Hashtable使用了监视器模式。

  • 进入和退出同步代码块的字节指令也被称为monitorenter和monitorexit。

构建基础模块


同步容器类
  • 利用Collections.synchronizedXxx将非线程安全的容器类封装为线程安全的容器类。

  • 同步容器类是线程安全的,但在某些复合操作上可能需要额外的客户端加锁来保护。容器上常见的符合操作包括:迭代,跳转(根据当前元素位置找到当前元素的下一个元素),条件运算(例如若没有则添加)。

  • 迭代器与ConcurrentModificationException

    • 线程安全容器类的迭代操作采取的是**“及时失败”**,如果在容器迭代期间被修改,那么会抛出ConcurrentModificationException异常。这种实现方式是将计数器的变化与容器关联起来:如果在迭代期间计数器被修改,那么hasnext或next会抛出ConcurrentModificationException异常。
    • 基于这种设计是为了一种设计上的权衡,从而降低并发修改操作的检测代码对程序性能产生的影响。
    • 可以加锁来完成安全的容器遍历,但迭代期间对容器加锁可能会造成其它线程长时间等待。另一种方式是“克隆”容器,并在副本上进行迭代。克隆期间需要对容器加锁,并且克隆容器存在性能开销。
并发容器
  • ConcurrentHashMap 采用分段锁实现,由16个锁来保护散列桶。ConcurrentHashMap 的迭代器不会抛出ConcurrentModificationException异常,ConcurrentHashMap 的迭代器具有弱一致性,而并非及时失败。遍历元素时可以(但不保证)将容器的修改操作返回给容器。

  • CopyOnWriteArrayList 用于替代同步list,迭代期间不需要对容器进行加锁或复制。容器每次修改时,都会创建一个新的容器副本,从而实现可见性。容器迭代的数据与迭代器创建时的数据一样,每当修改容器时都会复制底层数组,这需要一定的开销。仅当迭代操作远多于修改操作时才应该使用“写入时复制”容器。

  • BlockingQueue

    阻塞队列提供了可阻塞的put和take方法,以及支持定时的offer和poll方法。队列可以是有界的或无界的。

    • LinkedBlockingQueue ArrayBlockingQueue

      类似于LinkedList,ArrayList。但比同步List具有更好的性能。

    • PriorityBlockingQueue

      优先级阻塞队列,可以按照元素自然顺序(如果实现了Comparable方法)来比较元素,或者也可以使用Comparator来比较。

    • SynchronousQueue

      不是一个真正的队列,因为它不会为队列中元素维护存储空间。它维护一组线程,这组线程在等待着把元素加入或移出队列。仅当有足够多的消费者,并且总是有一个消费者准备好交付工作时才适合使用同步队列。

  • Queue

    • ConcurrentLinkedQueue 传统的先进先出队列
    • PriorityQueue 非并发的优先队列
  • Deque和BlockingDeque

    Deque是一个双端队列,实现在队列头和队列尾高效插入和移除元素。实现有ArrayDeque和LinkedBlockingDeque。

同步工具类
  • 闭锁

    闭锁是一种同步工具类,可以延迟线程的进度直到其到达终止状态。闭锁的作用相当于一扇门:在闭锁到达结束状态之前,这扇门是一直关闭的,并且没有任何线程能够通过。闭锁到达结束状态后,这扇门会打开并允许所有线程通过,此后不会再改变状态。闭锁可以用于确保某些活动直到其它活动都完成才继续执行。

    • CountDownLatch

      闭锁状态包含一个计数器,该计数器被初始化为一个正值,表示需要等待的事件数量。countDown方法递减计数器,await方法等待计数器到达0。

  • FutureTask

    FutureTask表示一种可生成结果的计算,计算是通过Callable实现的,相当于一种可生成结果的Runnable。FutureTask有三种状态:等待运行,正在运行,运行完成。运行完成表示所有可能的结束方式,包括正常结束,由于取消而结束和由于异常结束。FutureTask.get会阻塞到任务进入完成状态然后获取结果。

  • 信号量Semaphore

    Semaphore用来控制同时访问某个特定资源或者执行某个特定操作的数量,还可以实现某种资源池或者对容器施加边界。Semaphore初始值为1的话还可以用于互斥体。

    Semaphore使用acquire获取资源,使用release释放资源。若资源不足将会阻塞到有资源可用。

  • 栅栏

    栅栏类似于闭锁,它能阻塞一组线程直到某个时间发生。栅栏与闭锁的关键区别在于所有线程必须同时到达栅栏位置才能继续执行。闭锁用于等待事件,而栅栏用于等待其他线程,实现一些协议。

    • CyclicBarrier

    • Exchanger

      Exchanger是一种两方栅栏,各方在栅栏位置交换数据。当两方进行不对称的操作时,Exchanger会非常有用。例如当一个线程向缓冲池写入数据,而另一个线程向缓冲池读取数据。这些线程可以使用Exchanger来汇合,并将满的缓冲区与空的缓冲区交换。

构建高效可伸缩的缓存

​ 利用FutureTask和ConcurrentHashMap实现一个高效缓存

/**
* 用于将某个计算结果放入到缓存中,如果缓存中有数据则直接取出。
* 使用FutureTask进行异步计算,可同时进行多个计算需求。
*
* @Date 2020/5/25
* @author varg
*/
public class Memoizer<A,V> implements Computable<A,V>{
    private final ConcurrentMap<A,Future<V>> cache = new ConcurrentHashMap<>();
    private final Computable<A,V> c;
    
    public Memoizer(Computable<A,V> c){this.c = c};
    
    public V compute(final A arg) throws InterruptedException {
        while(true){
            Future<V> f = cache.get(arg);
            if(f == null){
                Callable<V> eval = new Callable<>() throws InterruptedException {
                    public V call(){
                        return c.compute(arg);
                    }
                }
                FutureTask<V> ft = new FutureTask<>(eval);
                f = cache.putIfAbsent(arg,ft);
                // 如果缓存中没有进行过该计算,则开始计算
                if(f == null){
                    f = ft;
                    ft.run();
                }
            }
            try{
                return f.get();
            } cache (CancallationException e){
                cache.remove(arg,f);
            }
        }
    }
}

任务执行


在生产环境中,为每一个任务创建一个线程存在缺陷:

  • 线程生命周期的开销非常高
  • 资源消耗
  • 稳定性
初探线程池
线程池的创建:Executors中的静态工厂方法

创建线程池后,可以将任务提交给线程池,通过Executors.execute()来执行任务

  • newFixedThreadPool

    固定数量线程的线程池,每当提交一个任务就会创建一个线程,当线程数量到达最大时不再创建。如果某个线程发生未预期的Exception而结束,那么线程池会补充一个线程。

  • newCachedThreadPool

    可缓存的线程池,线程池的当前规模超过处理需求时会回收空闲的线程。需求增加时会增加新的线程,线程池的规模不存在限制。

  • newSingleThreadPool

    单线程的Executors,如果该线程因为异常结束会补充一个线程。该线程池能确保任务按照在队列中的固定顺序来执行(如FIFO,LIFO,优先级)

  • newScheduledThreadPool

    固定长度的线程池,且该线程池以延迟或定时的方式来执行任务。

线程池的生命周期

线程池有3种状态:运行,关闭,终止。

线程池的关闭:在关闭后提交的任务都会交给Rejected Execution Handler来处理,它会抛弃任务并且使得execute抛出一个未受检查的RejectedExecutionException异常。关闭后进入终止状态,可以通过awaitTermination来等待线程池到达终止状态,或者通过isTerminated来轮询是否终止。通常调用awaitTermination后会立即调用shutdown来等待线程池关闭。

  • shutdown

    shutdown会平缓的关闭线程池,不再接收新的任务,同时等待已经提交的任务执行完成-包括那些还未开始执行的任务。

  • shutdownNow

    粗暴的关闭线程池:它将尝试取消所有运行中的任务,并且不再启动队列中未开始执行的任务。

携带结果的Callable和Future

Executor使用Runnable作为其基本任务表示,Runnable不能返回任务的执行结果。Callable是一种更好的抽象,它认为主入口点(call)会返回一个值或抛出一个异常。要使用Callable来表示无返回值的任务可以使用Callable。Future表示一个任务的生命周期,并提供了相应的方法来判断是否完成或取消,以及获取任务的结果和取消任务。

ExecutorService中的所有submit方法都返回一个Future,从而将一个Runnable或Callable任务提交给线程池。亦可以显式的使用FutureTask来提交任务。

CompletionService: Executor与BlockingQueue

如果我们向线程池中提交了一组任务,并且希望计算完成后获取结果。那么可以保留每个任务的Future,然后使用get方法来等待结果。这种方式虽然可行,但比较繁琐。

CompletionService将Executor与BlockingQueue功能融合在一起。可以将Callable任务提交给它执行,然后使用类似于队列操作的take和poll方法来获得已完成的结果。

线程池的配置

线程池大小配置:线程池的理想大小取决于被提交任务的类型以及所部署系统的特性。应该通过某种配置机制或者通过Runtime.avaliableProcessors来动态计算。

ThreadPoolExecutor
public ThreadPoolExecutor(int coreSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)

线程池的基本大小(coreSize),最大大小(maximumPoolSize)以及存活时间等因素共同负责线程的创建与销毁。基本大小指线程池的目标大小,即在没有任务执行时线程池的大小,并且只有在工作队列满了的情况下才会创建超出这个数量的线程。线程池的最大大小表示可同时活动的线程数量的上限。如果某个线程的空闲时间超过了存活时间,那么将会被标记为可回收的,并且当当前线程池的当前大小超过了基本大小时,这个线程将被终止。

newFixedThreadPool工厂方法将线程池的基本大小和最大大小设置为参数中指定的值,而且创建的线程池不会超时。newCachedThreadPool工厂方法将线程池的最大大小设置为Integer.MAX_VALUE,而将基本大小设置为0,并将超时时间设置为1分钟,这种方法创建出来的线程池可以被无限扩展,并且当需求降低时会自动收缩。

ThreadPoolExecutor允许提供一个BlockingQueue来保存等待执行的任务。基本的任务队列有3种:无界队列,有界队列和同步移交(Synchronous Handoff)。newFixedThreadPool和newSingleThreadPool使用无界的LinkedBlockingQueue。

饱和策略

有界队列被填满后,包和策略开始发挥作用。ThreadPoolExecutor的饱和策略可以通过调用setRejectedExecutionHandler来修改。JDK提供了几种不同的RejectedExecutionHandler实现,每种实现都包含有不同的饱和策略:

  • AbortPolicy

    中止策略是默认的饱和策略,该策略将抛出一个未检查的RejectedExecutionException。

  • CallerRunsPolicy

    该策略不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,从而降低新任务的流量。它不会在线程池的某个线程中执行新提交的任务,而是在一个调用了execute的线程中执行该任务。当线程池中所有的线程都被占用,且工作队列被占满后,下一个任务会在调用execute的主线程中执行。由于主线程执行执行任务需要一定的时间,因此主线程在这段时间不能提交新的任务,使得工作者线程有时间来处理其它任务。

  • DiscardPolicy

    抛弃策略将会悄悄抛弃该任务。

  • DiscardOldestPolicy

    抛弃最旧的策略则会抛弃下一个将被执行的任务,然后尝试重新提交该任务(如果该队列是一个优先队列,则下一个等待的优先级最高的任务将被抛弃。因此最好不要将该策略与优先级队列一起使用)。

    /**
     * 创建一个固定大小的线程池,并采用有界队列及调用者运行包和策略。
     * @author varg
     * @date 2020/5/26
     */
    ThreadPoolExecutor executor 
        = new ThreadPoolExecutor(N_THREADS,N_THREADS,
             0L,TimeUnit.MILLSECONDS,
             new LinkedBlockingQueue<Runnable>(CAPACITY));
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallRunsPolicy());
    
线程工厂

每当线程池需要创建一个线程时,都是通过线程工厂方法创建的。默认线程工厂方法将创建一个新的,非守护的线程,并且不包含特殊的配置信息。通过指定一个线程工厂方法,可以定制线程池的配置信息。在ThreadFactory中仅有一个newThread方法,每当线程池需要创建一个线程时都会调用该方法。

public interface ThreadFactory{
	Thread newThread(Runnable r);
}

如果在应用程序中需要利用安全策略来控制对某些特殊代码库的访问权限,那么可以通过Executor中的privilegedThreadFactory来定制自己的线程工厂。通过这种方式创建出来的线程将与创建privilegedThreadFactory的线程拥有相同的访问权限、AccessControlContext和contextClassLoader。如果不适用privilegedThreadFactory的话,那么线程池创建的线程将从调用exexute或submit的客户端程序继承访问权限。

活跃性、性能

死锁

使用加锁机制来确保线程安全,但在某些情况下可能发生死锁问题。

死锁的四个必要条件:互斥、不剥夺、请求和保持、环路等待

  • 线程饥饿死锁

    线程池中任务等待一些必须由池中其它任务才能提供的资源或条件,但其它任务由于线程池已满无法启动线程从而造成线程饥饿死锁。

  • 锁顺序死锁

    两个线程由于试图以不同的顺序来获得相同的锁,互不退让,造成死锁。

  • 资源死锁

    线程在相同的资源集合上等待时,也会发生死锁。

死锁避免与诊断
  • 支持定时的锁

    在Lock类中的定时tryLock方法可以支持定时的获取锁操作,如果在规定时间内没有获取到锁,则会返回一个失败信息。

  • 通过转储信息来分析死锁

其它活跃性危险
  • 饥饿

    线程由于无法访问它所需要的资源而不能继续执行时,就发生了"饥饿"。引起饥饿最常见的就是CPU时钟周期。如果在Java应用程序中对线程的优先级使用不当,或者在持有锁时进行一些无法结束的结构,那么其它线程也可能死锁,因为需要这个锁的线程将无法得到它。

    在Thread中定义的线程优先级只是作为线程调度的参考,JVM将优先级映射到操作系统的调度优先级。这种映射与平台相关,因此相同的优先级可能被映射成不同系统的不同优先级,因此不建议改变线程的优先级。

  • 活锁

    活锁是另一种形式的活跃性问题,该问题不会阻塞线程,但也不能继续执行,因为线程将不断地执行重复的操作,而且总会失败。例如对向行驶的两个人,他们相遇了,都阻碍了对方的道路。他们彼此都让出自己的路,然而又在另一条路上相遇了。因此他们就这样重复的避让下去。

    解决活锁问题可以在重试机制中引入随机性。

性能考量
锁竞争失败

当在锁上发生竞争时,竞争失败的线程肯定会阻塞。JVM在实现阻塞行为时,可以采取自旋等待(Soin-Waiting,通过不断地尝试获取锁,直到成功)。亦可以直接挂起该线程。如果等待时间较短,适合采取自旋等待方式,否则挂起线程将会更为合适。

减少锁的竞争

有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,持有锁的时间。

  • 缩小锁的范围

    降低发生竞争可能性的一种有效方式就是尽可能缩短锁的持有时间。例如,可以将一些与锁的无关代码移出同步代码块,尤其是那些开销较大的操作,以及可能被阻塞的操作,例如I/O操作。

  • 减小锁的粒度

    可以通过减小锁的持有时间的方式是降低线程请求锁的频率。这可以通过锁分解和锁分段实现。在这些技术中使用多个相互独立的锁来保护独立的状态变量,从而改变这些变量之前由单个锁保护的情况。这些技术可以减小锁的操作粒度,并能实现更好的可伸缩性。然而,使用的锁越多,发生死锁的风险也就越高。

    • 锁分解

      将多个独立的状态变量由不同的锁来保护,减小锁的操作粒度。

    • 锁分段

      在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况称为锁分段。例如,在ConcurrentHashMap实现中,使用了一个包含16个锁的数组,每个锁保护散列桶的1/16,其中第N个散列桶由(N Mod 16)个锁来保护。假设散列函数具有合理的分部性,那么这大约能将锁的请求减少到原来的1/16。

      锁分段的一个劣势在于与采取单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。通常,在执行一个操作时最多需要获取一个锁,但某些情况下需要加锁整个容器,例如当容器扩容时重新进行散列计算时,就需要获取分段锁集合中的所有锁。

      如果程序采用锁分段技术,那么一定要表现出在锁上的竞争频率要明显高于在被锁保护的数据上的竞争频率。

一些替代独占锁的方法

第三种降低竞争锁的影响就是放弃使用独占锁,从而有助于使用一种友好并发的方式来管理共享状态。例如,使用并发容器、读-写锁、不可变对象及原子变量。

ReadWriteLock实现了在多个读取操作以及单个写入操作情况下的加锁规则:多个读取操作不会修改共享资源,那么这些读取操作可以同时访问该资源,但在写入操作时需要以独占的方式来获取锁。

原子变量提供了在整数或者对象引用上的细粒度原子操作,并使用了现代处理器中提供的底层并发原语(例如比较并交换CAS)。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值