第三章 JDK并发包
3.1 多线程的团队协作:同步控制
3.1.1 synchronized的功能扩展:重入锁
- 重入锁可以完全替代synchronized关键字。jdk5前版本重入锁性能远高于synchronized,jdk6开始,两者的性能差异并不是很大。
- 开发人员需要手动指定何时加锁、何时释放锁。如果一个线程多次获得锁,那么在释放锁的时候,也必须释放相同次数。如果释放次数过多,则会抛出异常,如果释放少,则会导致临界资源没有释放其他线程无法进入临界区。
- 中断响应
- synchronized来说,如果一个线程在等待锁,那么结果只有两种情况,要么它获得这把锁继续执行,要么它就保持等待。
- 重入锁可以在等待锁的过程中,取消对锁的请求。被中断的线程会放弃任务直接退出,释放资源。EX:参考73页
- 锁申请等待限时
- 可以使用tryLock进行限时等待锁。
- 公平锁
- 通过ReentrantLock的构造函数,传入true表示公平锁。公平锁的一大特点就是不会产生饥饿现象。如果使用synchroinzed则产生的锁为非公平锁。公平锁需要维护一个有序队列,因此实现成本高,性能也相对非常低下,因此默认情况下锁都是非公平的。
- 方法:
- lock:获得锁,如果锁已经被占用,则等待
- lockInterruptibly:获得锁,但优先响应中断
- tryLock:无等待尝试获得锁,成功获得锁返回true,失败返回false
- tryLock(long time,TimeUnit unit):在给定时间内尝试获得锁
- unlock:释放锁
- 在重入锁的实现中,主要包含了三个要素:
- 原子状态:使用CAS操作来存储当前锁的状态,判断锁是否已经被别的线程持有
- 等待队列:所有没有请求到锁的线程,进入等待队列进行等待。
- 阻塞原语:park()和unpark(),用来挂起和恢复线程。
3.1.2 重入锁的好搭档:Condition条件
同wait和notify的作用大致相同,配合重入锁使用。
- 方法:
- await()方法会使当前线程等待,同时释放当前锁,当其他线程中使用signal()或者signalAll()方法时,线程会重新获得锁并继续执行。或者当线程被中断时,也能跳出等待。
- awaitUninterruptibly()方法与await()方法基本相同,但是它并不会在等待过程中响应中断。
- signal()方法用于唤醒一个在等待中的线程。相对的signalAll()方法会唤醒所有在等待中的线程。
3.1.3 允许多个线程同时访问:信号量(Semaphore)
- 广义上说,信号量是对锁的扩展。
- 内部锁synchronized和重入锁,一次都只允许一个线程访问一个资源,而信号量却可以指定多个线程,同时访问某一个资源。
- 方法:
- acquire()获取一个许可,无法获得则等待,直到有一个线程释放一个许可或者当前线程被中断。
- acquireUninterruptibly()和acquire()类似,只是不响应中断。
- tryAcquire()尝试获得一个许可,不等待
- release()线程访问资源结束后,释放一个许可,以使其他等待许可的线程可以进行资源访问。
3.1.4 ReadWriteLock读写锁
读读不互斥、读写互斥、写写互斥
3.1.5 倒计时:CountDownLatch
3.1.6 循环栅栏:CyclicBarrier
- 与CountDownLatch类似,但比其更加强大。可以实现循环阻塞,如等待10个线程后执行,再继续等待10个线程再执行。EX:参考90页
- CyclicBarrier.await()方法会抛出两个异常,一个中断异常,一个BrokenBarrierException,破损异常,表示CyclicBarrier已经破损了,可能系统已经没有办法等待所有线程到齐了。如果继续等待可能就是徒劳的,因此可以据此结束等待处理等。
3.1.7 线程阻塞工具类:LockSupport
- 与Thread.suspend相比,它祢补了由于resume在前发生,导致线程无法继续执行的情况。与Object.wait()相比,它不需要先获取某个对象锁,也不会抛出InterruptedExcepton
- 方法:
- 静态方法park阻塞当前线程,parkNanos、parkUntil实现一个限时阻塞。
- 使用了类似信号量的机制,它为每一个线程准备了一个许可,如果许可可用,则park会立即返回,并且消费这个许可,如果许可不可用,则阻塞。而unpark则使得一个许可变为可用,但同信号量不同的是,许可不能累加,不可能拥有超过一个许可。
- park挂起也不会产生想suspend那样还是一个Runnable状态,它会是WAITTING状态。同时park可以传入阻塞对象,这个阻塞对象会出现在线程Dump中,可以便于分析异常。如LockSupport.park(this)
- park还支持中断影响,但和其他接收中断的函数不一样,其不会抛出InterruptedException异常,只会默默的返回,但可以从Thread.interrupted等方法获得中断标记。
3.2 线程复用:线程池
3.2.1 什么是线程池
创建线程变成了从线程池中获取空闲线程,销毁线程变成了向线程池归还线程。
3.2.2 不要重复发明轮子:JDK对线程池的支持
- Executor框架
- ThreadPoolExecutor表示一个线程池。Executors扮演着线程池工厂的角色。
- newFixedThreadPool()方法:该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有空闲线程时,则新提交的任务立即执行,如果没有则在一个队列中等待。
- newSingleThreadExecutor()方法:该方法返回一个只有一个线程的线程池。若处于非空闲状态,新提交的任务则在队列中等待。
- newCacheThreadPool()方法:返回一个可根据实际情况调整线程数的线程池。线程池的数量不确定,如果有空闲可以复用,则优先使用复用线程。如果不存在空闲线程,则创建一个新的线程处理任务,处理完成后返回线程池中等待复用。
- newSingleThreadScheduleExecutor()方法:返回一个ScheduledExecutorService对象,线程池大小为1。其在ExecutorService之上扩展了给定时间执行某任务的功能。如在某个固定的延时之后执行,或者周期性执行某个任务。
- newScheduledThreadPool()方法:返回一个ScheduledExecutorService对象,但该线程池可以指定线程数量。
- 固定大小的线程池: newFixedThreadPool
- 计划任务
- newScheduledThreadPool,返回一个ScheduledExecutorService对象。如果调度的任务产生了异常,则会导致后续的执行都会被中断,因此必须处理好异常的捕获。
- schedule()方法:在给定的时间,对任务进行一次调度。不会周期性调度
- scheduleAtFixedRate()方法:任务调度频率一定,以上一个任务开始时间为起始时间,之后period时间后调度下一次任务。如果任务的执行时间超过了定时调度的时间,则下一个任务会在上一个任务结束后立即调用。
- scheduleWithFixedDelay()方法:任务调度时以上一次执行结束为起始时间的,之后经过delay时间执行任务。
3.2.3 刨根究底:核心线程池的内部实现
上面的各种类型线程池,都是通过ThreadPoolExecutor实现。
- ThreadPoolExecutor构造函数参数含义:
- corePoolSize:指定了线程池中的线程数量
- maximumPoolSize:指定了线程池中的最大线程数量
- keepAliveTime:当线程池线程数量超过corePoolSize时,多余的空闲线程的存活时间。即,超过corePoolSize的空闲线程,在多长时间内会被销毁
- unit:keepAliveTime的单位
- workQueue:任务队列,被提交但尚未被执行的任务
- threadFactory:线程工厂,用于创建线程,一般用默认即可
- handler:拒绝策略。当任务太多来不及处理,如何拒绝任务
- workQueue说明:是一个BlockingQueue接口对象,存放Runnable对象。有以下几种BolckingQueue:
1. 直接提交的队列
- 由SynchronousQueue对象提供。一个特殊的BlockingQueue,没有容量,每一个插入操作都要等待一个相应的删除操作,反之,每一个删除操作都需要等待一个插入操作。如果使用该队列,则任务不会被真实保存,而总是将任务提交给线程执行。如果没有空闲线程,则创建新线程,如果数量达到了最大值,则执行拒绝策略,因此其需要设置很大的maximumPoolSize值,否则很容易执行拒绝策略。
2. 有界的任务队列
- 由ArrayBlockingQueue实现。构造函数必须带一个容量参数,表示该队列的最大容量。如果实际线程数小于corePoolSize则创建线程执行,如果超出,则进入等待队列,如果等待队列满,且当前线程数小于maximumPoolSize则继续创建新线程执行任务,如果超出maximumPoolSize则执行拒绝策略。因此,有界队列必须在队列满时,才会将线程数提升大于corePoolSize,即确保了核心线程数维持在corePoolSize之下。
3. 无界的任务队列
- 由LinkedBlockingQueue实现。除非系统资源耗尽,否则无界的任务队列不存在任务入队失败的情况。当有新任务来时,如果线程数小于corePoolSize,则创建新线程,如果大于corePoolSize则在队列中等待,如果创建和处理任务的速度差异很大,则等待队列会不断增加,知道内存耗尽。
4. 优先的任务队列
- 由PriorityBlockingQueue实现。一个特殊的无界任务队列,可以控制任务执行的先后顺序。
- newFixedThreadPool采用的是corePoolSize=maxmimuPoolSize的LinkedBlockingQueue无界任务队列,因此会存在资源耗尽的问题。
- newSingleThreadExecutor是上一种的一种退化,即corePoolSize=maxmimuPoolSize=1
- newCacheThreadPool返回corePoolSize为0,maxmimuPoolSize无穷大的线程池,在没任务时,线程池内无线程,当任务提交,如果有空闲则立即执行,如果没有空闲则提交到SynchronousQueue队列,因此又会创建新线程执行任务,当任务执行完毕,由于corePoolSize为0,因此空闲线程又会在指定时间内(60秒)被回收。
3.2.4 超负载了怎么办:拒绝策略
- 通常由于压力太大引起,线程池中的线程已经用完,等待队列也满了。
- JDK内置提供的四种拒绝策略:
- AbortPolicy策略:
- 直接抛出异常,阻止系统正常运行
- CallerRunsPolicy策略:
- 只要线程池未关闭,该策略直接在调用者线程中,运行当前被丢弃的任务。这样不会真正的丢弃任务,但是,任务提交线程的性能极有可能会急剧下降
- DiscardOledestPolicy
- 丢弃最老的一个请求,也就是即将被执行的一个任务,并尝试再次提交当前任务。
- DiscardPolicy
- 默默地丢弃无法处理的任务,不予任何处理。如果允许任务丢失,这可能是最好的一种策略。
- 如无法满足需求,可以实现RejectedExecutionHandler接口,实现自己的拒绝策略
3.2.5 自定义线程创建:ThreadFactory
- ThreadFactory是一个接口,只有一个方法,用来创建线程。
- newThread(Runnable run)
- 当线程池需要创建新线程时,就调用该方法。
3.2.6 我的应用我做主:扩展线程池
- ThreadPoolExecutor提供了beforeExecute、afterExecute、terminated三个接口对线程池进行控制。
- shutdown是一个比较安全的方法不会暴力的关闭线程池,其会等待所有任务执行完毕后关闭线程池,但它并不会等待所有线程执行完成后再返回,简单理解则为发送了一个关闭的信号而已。但其执行后线程池就不会再接收其他新的任务了。
3.2.7 合理的选择:优化线程池线程数量
- Ncp=CPU的数量
- Ucpu=目标CPU的使用率,
- 0<=Ucpu<=1
- W/C=等待时间与计算时间的比率
- 为了保持处理器达到期望的使用率,最优的池的大小等于:
- Nthreads=Ncpu * Ucpu * (1+ W/C)
3.2.8 堆栈去哪里了:在线程池中寻找堆栈
- 放弃submit方法,该用execute方法,如果需要查看任务在哪里提交,则需要扩展ThreadPoolExecutor。EX:参考115页
3.2.9 分而治之:Fork/Join框架
- fork方法产生一个子线程,如果过多的fork则会导致线程太多,反而导致性能下降,ForkJoinPool线程池,对于fork方法并不急于开启线程,而是交给ForkJoinPool线程池处理,以节省系统资源。
- 由于线程池的优化,提交的任务跟线程数量并不是一对一关系。在绝大多数情况下,一个物理线程实际上需要处理多个逻辑任务。因此实际中可能会存在A线程任务已经完成了,但B线程还没,此时A就会帮助B,当线程尝试去帮助其他线程时,总是从底部开始拿数据。因为这种行为有利于避免数据竞争。
- ForkJoinTask就是支持fork分解和join等待的任务,ForkJoinTask两个重要子类:RecursiveAction和RecursiveTask,分别表示没有返回值和可以携带返回值的任务。
- ForkJoin线程池使用一个无锁的栈来管理空闲线程池。
- 注意:如果任务层次很深,一直得不到返回,则有两种可能:
- 系统内的线程数量越积越多,导致性能严重下降
- 函数的调用层次变得很深,最终导致栈溢出
3.3 不要重复发明轮子:JDK的并发容器
3.3.1 超好用的工具类:并发集合简介
- ConcurrentHashMap:高效的并发HashMap
- CopyOnWriteArrayList:适合读多写少,性能非常好,远高于Vector
- ConcurrentLinekdQueue:高效并发队列,用链表实现,相当于安全的LinkedList
- BlockingQueue:接口,内部通过链表、数组等方式实现,表示阻塞队列,非常适合作为数据共享的通道
- ConcurrentSkipListMap:跳表实现,Map,可以进行快速的查找
- 通过Collections包装
3.3.2 线程安全的HashMap
通过Collections包装的SynchronizedMap性能并不高,其通过synchroinzed进行同步,无论写入还是读取都需要获得锁
3.3.3 有关List的线程安全
3.3.4 高效读写的队列:深度剖析ConcurrentLinkedQueue
- 在高并发环境中性能最好的队列。
- Node操作使用了CAS
- 内部有两个重要的字段,head和tail,分别表示链表的头和尾部,都是Node类型,对于head来说,永远不会为null,并且通过head以及succ后继方法一定能完整地遍历整个链表。但tail并不一定总表示链表尾,其更新不是及时的,可能会产生拖延的现象。
- 添加元素方法offset没有任何锁操作,线程安全完全有cas操作和队列的算法来保证。
3.3.5 高效读取:不变模式下的CopyOnWriteArrayList
- 修改时,对副本进行修改,再替换原来的值,这样可以保证写操作不影响读。
- 读取代码没有任何同步控制和锁操作,理由就是内部数组array不会发生修改,只会被另一个array替换,因此可以保证数据安全。
3.3.6 数据共享通道:BlockingQueue
- BlockingQueue是为了解决线程A能够通知线程B,但又不知道线程B的存在的问题,松耦合,便于程序的升级。很好的解耦生产者和消费者
- ArrayBlockingQueu数组实现,有界队列,创建时指定最大容量;LinkedBlockingQueue,链表实现,无界队列,内部元素可以动态增加,不会因为初始容量很大,而一口气吃掉大半内存。
- BlockingQueue让服务线程队列为空时,进行等待,当有新消息进入队列时,自动将线程唤醒。
- 添加元素方法:
- offset如果队列已满,则立即返回false
- put如果队列已满,则等待,直到有空闲的位置
- 获取元素方法:
- poll如果队列为空,则返回null
- take如果队列为空,则等待
- put和take是体现Blocking的关键
3.3.7 随机数据结构:跳表(SkipList)
- 一种快速查找元素的数据结构,有点类似平衡树,区别在于:对平衡树的插入和删除往往很有可能导致平衡树进行一次全局的调整,跳表只需对整个数据结构的局部进行操作即可。
- 跳表的查询时间复杂度为O(log n)
- 跳表的另一个特点就是随机算法,跳表的本质是同时维护了多个链表,并且链表是分层的。最底层的链表维护了跳表内的所有元素,每上面一层都是下一层的子集。插入哪些层是完全随机的,如果运气不好,性能可能会很糟糕,但实际工作中,它的表现非常好。是一种空间换时间的算法。
- 跳表内的所有元素都是排序的。查找时从顶层开始找,如果发现元素大于当前链表中的取值,则会跳到下一层继续查找。即跳跃式搜索。
- 使用跳表实现Map和使用哈希实现Map的不同是,哈希不保存元素的顺序,而跳表的元素是排序的。
- 跳表内部数据结构:
- Node:一个Node就是一个节点,里面包含了两个重要元素Key和Value,每个Node还会指向下一个Node,即next。Node的操作都是CAS的
- Index:表示索引,内部包装了Node,同时增加了向下的引用和向右的引用,整个跳表就是根据Index进行全网组织。
- HeadIndex:用于记录当前处于哪一层。表示链表头部的第一个Index,其继承自Index,多了level字段。