0%

github 加速

1. 并发编程笔记

1.1. 容器加锁注意事项

  • 加锁可以防止容器的迭代溢出.
  • 迭代期间对容器进行加锁会降低程序的可伸缩性. 降低程序性能及cpu使用率.甚至可能造成死锁.取决于容器的规模.
  • 替代方法是克隆容器.(克隆过程中仍然需要加锁,会有额外的性能开销)
  • 使用容器的一些方法时,会隐藏使用迭代器.

1.2. 并发容器

Queue BlockingQueue ConcurrentHashMap ConcurrentMap CopyOnWriteArrayList

PriorityBlockingQueue SynchronousQueue(put take一直阻塞)

1.3. 模式

生产者消费者模式 (如BlockingQueue 所有消费者有一个共享的工作队列)

工作密取(对应双端队列 如BlockingDeque, 每个消费者都有各自的工作队列,如果一个消费者完成了自己双端队列上的全部工作,它可以从其它消费者双端队列上秘密的获取工作,适合执行某个工作时发现有更多的工作.)

1.4. 闭锁

一种同步工具类, 可以延迟线程的进度直到达到终止状态.

  • CountDownLatch

  • FutureTask

    等待执行 正在运行 运行完成

    Future的行为取决于任务的状态, 任务未完成时,会阻塞直到任务到运行完成阶段.

1.5. 信号量

  • 计算信号量用来控制同时访问某个特定资源的操作数量,或执行某个指定操作的数量.
  • Semaphore管理一组虚拟的许可. 许可的初始数量可以通过构造函数指定.在执行操作时首先获取许可(只要还有剩余的许可),并在使用后释放许可.如果没有剩余的许可,acquire将阻塞直到有许可.

1.6. 栅栏

类似与闭锁, 能阻塞一组线程直到某个事件发生.与闭锁的区别在于:

  • 闭锁用于等待事件
  • 栅栏等待其他线程.所有线程必须同时到达栅栏位置(适用于一些迭代算法,如某个步骤中的计算可以并行执行,但必须等到该步骤中的所有计算执行完毕后才能进入下一个步骤).
  • CyclicBarrier Exchanger(交换缓冲区)

2. 结构化并发应用程序

2.1. 任务执行

2.1.1. 无限制创建线程的不足

即为每个任务创建一个线程的做法是不可取的.

  • 线程生命周期的开销非常高

  • 资源消耗

    活跃的线程会消耗系统资源,尤其是内存. 有足够多的线程使CPU处于忙碌状态,创建更多的线程会导致线程竞争CPU, 大量线程被闲置, 将造成性能下降

  • 稳定性 在可创建线程的数量上存在限制,如超出限制, 可能抛出内存溢出.(JVM启动参数,Thread构造函数中请求的栈大小等,取决于平台.)

2.2. 线程池

Executor框架:

Java提供了一种灵活的线程池实现作为Executor框架的一部分.提供了生命周期的支持,统计信息收集,应用管理机制和性能监视等机制.

  • newFixedThreadPool
  • newCachedThreadPool 可缓存的线程池, 回收空闲线程, 线程池的规模不受限制.
  • newSingleThreadExecutor
  • newScheduledThreadPool 以延迟或定时的方式执行任务,类似Timer

基于生产者消费者模式,提交任务的操作相当于生产者,执行任务的线程相当于消费者.

ExecutorService 生命周期: 扩展了Executor 子类.

三种状态: 运行 关闭 已终止.

shutdown方法执行平缓的关闭过程:不再接受新的任务, 同事等待已经提交的任务执行完成.shutdownNow执行粗暴的关闭过程.尝试取消所有运行中的任务.

2.2.1. 延迟任务与周期任务

ScheduledThreadPoolExecutor

  • Timer会引起线程泄漏(发生异常时,会终止整个Timer)
  • Timer 绝对时间的调度机制, ScheduledThreadPoolExecutor 相对时间

2.2.1.1. 携带结果的任务Callable 与 Future

Runnable 与 Callable

  • Runnable不能返回值或者抛出一个受检查的异常
  • Callable相对Runnable是一种更好的抽象, call主入口点将返回值或者抛出异常.

ExecutorService中的所有submit方法(参数为一个Runnable或Callable)返回一个Future, Future用来获取任务的执行结果或者用来取消任务.

CompletionService将Executor和BlockingQueue组合在一起. 将Callable任务提交给它执行, 使用类似队列操作的take和poll等方法来获得已完成的结果.完成时被封装成Future.

3. 取消与关闭

一个可取消的任务必须拥有取消策略:

  • 如何请求取消该任务
  • 任务何时检查是否已经请求了取消
  • 响应取消请求时应该执行哪些操作

不可靠的取消操作将把生产者置于阻塞的操作中.

3.1. 中断

每个线程都有一个布尔类型的中断标志.当中断线程时,这个线程的中断状态变为true.在Thread中包含了中断线程及查询线程中断的方法.interrput能中断目标线程.isInterrputed方法能返回目标线程的中断状态.interrupted方法清除当前线程的中断状态,并返回它之前的值.也是清除中断状态的唯一方法.

调用interrupt并不意味者立即停止目标线程正在执行的操作.而只是传递了请求中断的消息,使线程的中断标志位变为true

如Thread.wait sleep join等方法,收到中断请求或者开始执行时发现已经设置好中断标志位时,将抛出InterruptedException.

中断是实现取消的最合理的方式

如果一个线程由于等待某个内置锁而阻塞,那么将无法响应中断.在Lock类中提供的lockInterruptibly方法,允许在等待一个锁的同时仍能响应中断

3.2. 停止基于线程的服务

对于持有线程的服务,只要服务的存在时间大于创建线程的方法的存在时间,就应该提供生命周期方法,如ExecutorService的shutdown方法.

3.3. 处理非正常的线程终止

导致线程提前死亡的主要原因是RuntimeException

线程非正常退出的后果可能是良性的,当一个线程由于未捕获异常退出时,JVM将这个事件报告给应用程序提供的UncaughtExceptionHandler异常处理器.可以使用新的线程替代异常退出的线程.

线程池的使用

3.4. 线程池的创建和销毁

线程池的基本大小, 最大大小,以及存活时间等因素共同负责线程的创建和销毁.

  • 基本大小即线程池的目标大小,在没有任务执行时线程池的大小
  • 最大大小表示可同时活动的线程数量的上限
  • 如某个线程的空闲时间超过了存活时间,该线程会被标记为可回收的.线程池的当前大小超过了基本大小时,这个线程会被终止.

newFixedThreadPool将线程池的基本大小和最大大小设置为参数中指定的值,且创建的线程池不会超时

newCachedThreadPool将线程池的最大大小设置为Interger.MAX_VALUE,基本大小设置为0, 超时时间设置为1min.创建出来的线程池可被无限扩展,当需求降低时(线程的空闲时间增大)会自动收缩.

只有当任务相互独立时,为线程池或工作队列设置界限才是合理的.如果任务之间存在依赖性,那么有界的线程池或队列可能导致线程饥饿死锁问题,此时应使用无界的线程池.

3.5. 饱和策略

有界队列被填满后,饱和策略开始发挥作用.可以通过setRejectedExceptionHandler来修改.

  • 中止策略是默认的饱和策略,抛出未检查的RejectedExecutionException,调用者可捕获该异常,根据需求处理代码
  • 当新提交的任务无法保存到队列中等待执行时,抛弃策略会抛弃该任务
  • 调用者运行策略不会抛弃任务,也不会抛出异常,而是将任务回退给调用者,由调用者所在的线程执行(调用execute函数的线程.)

3.6. 扩展ThreadPoolExecutor

可以在子类中覆写的方法:

beforeExecute afterExecute terminated

在线程池完成关闭操作时调用terminated,即所有任务都已经完成且工作者线程已经关闭后.可以用来释放Executor在生命周期里分配的各种资源,执行发送通知,记录日志等.

无论任务从run中正常返回还是抛出一个异常返回,afterExecute都被调用. (任务在完成后带有一个ERROR,则不会调用afterExecute).

如beforeExecute抛出一个RuntimeException, 也不会调用afterExecute.

4. 活跃性\性能测试 死锁问题

4.1. 锁顺序死锁

如果所有线程以固定顺序获得锁,程序中不会出现锁顺序死锁问题

想验证锁顺序的一致性,需要对程序中的加锁行为进行全局分析.

4.1.1. 动态的锁顺序死锁

如锁参数问题,锁了两个传参问题, 无法保证参数的调用顺序则有可能发生死锁.

可以使用System.identityHashCode方法,比较参数的hashcode值,来保证锁顺序.

但两个对象可能拥有相同的hashcode值,可使用加时赛锁,在获得两个锁之前,首先需要获得这个加时赛锁.从而保证每次只有一个线程以未知的顺序获得这两个锁.

4.1.2. 在协作对象之间发生的死锁

如果在持有锁时调用某个外部方法,将出现活跃性问题.在这个外部方法中可能会获得其他锁,或阻塞时间过长,导致其他线程无法获得当前被持有的锁

4.1.2.1. 开放调用

在调用某个方法时不需要持有锁,该调用被称为开放调用.更易于找出哪些需要获取多个锁的代码路径.

在程序中尽量使用开放调用,与那些在持有锁时调用外部方法的程序相比,更易于对依赖于开放调用的程序进行死锁分析.

4.1.3. 资源死锁

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

  • 如数据库连接池,每个资源池有多个连接,在发生死锁时不仅需要N个循环等待的线程,且需要大量不恰当的时序

4.1.3.1. 线程饥饿死锁

单线程Exector中,一个任务提交另一个任务.第一个任务将永远等待下去,并使得另一个任务以及这个Executor上执行的所有任务都停止执行.

有界线程池/资源池与相互依赖的任务不能一起使用

4.2. 死锁的避免和诊断

4.3. 定时锁

tryLock,可指定一个超时时限,在等待超过该时间后返回一个失败信息.同时获取两个锁时才有效,如果在嵌套的方法调用中请求多个锁,也无法释放超时锁.

4.3.1. 线程转储信息

UNIX上 kill -3

显示的Lock类上获得的信息比在内置锁上获得的信息精确度低.内置锁与获得它们所在的线程栈帧是相关联的.显示的Lock只与获得它的线程相关.

4.4. 其他活跃性危险

饥饿\丢失信号\活锁

要避免使用线程优先级,会增加平台依赖性,并可能导致活跃性问题.

在Thread API中定义的线程优先级只是作为线程调度的参考.定义了10个优先级.JVM根据需要将他们映射到操作系统的调度优先级.这种映射与特定平台相关.在某个操作系统中,两个不同的JAVA优先级可能被映射到同一优先级.在另一个操作系统中可能被映射到另一个不同的优先级.

4.4.1.1. 糟糕的响应性

GUI程序中计算密集型的后台任务(可适当降低优先级)和不良的锁管理.

4.4.1.2. 活锁

通常发生在处理事务消息的程序中.如果不能成功的处理某个消息,消息处理机制将回滚整个事物.将重新放到队列的开头.由于这条消息又被放回队列开头,因此处理器将被反复调用,并返回相同的结果.错误的将不可修复的错误作为可修复的错误.

当多个相互协作的线程都对彼此进行响应从而修改各自的状态,使得任何一个线程都无法继续执行时,将发生上述问题.

在重试机制中引入随机性,使得相互协作的线程发生时间随机不撞车.

4.5. 性能与可伸缩性

执行过程中串行部分所占的比例影响计算资源的可加速比.

降低锁的竞争程度:

  • 减少锁的持有时间
  • 降低锁的请求频率
  • 使用带有协调机制的独占锁,这些机制允许更高的并发性

尽管缩小同步代码块能提高可伸缩性,但同步代码块也不能过小.当把一个同步代码块分解为多个时,反而会对性能产生负面影响.

4.5.1. 减小锁的粒度

采用多个相互独立的锁保护独立的状态变量,从而改变这些变量在之前由单个锁保护的情况,降低锁的请求频率.

将锁分解技术进一步扩展为一组独立对象上的锁进行分解,称为锁分段

ConcurrentHashMap对于大多数读操作并不会加锁,并且在写入操作以及其他一些需要锁的读操作中使用了锁分段技术.

4.5.2. 避免热点域

一些常见的优化措施,如将一些反复计算的结果缓存起来,都会引入一些热点域,而限制可伸缩性.

原子变量提供了一种方式降低更新热点域,如静态计数器\序列发生器\链表中头节点的引用.

4.5.3. 并发程序中避免使用对象池

通常对象分配操作的开销比同步的开销更低.

4.6. 并发程序的测试

4.6.1. 安全性测试

要最大程度的检测出一些对执行时序敏感的数据竞争,测试中的线程数量应该多于CPU数量,在任意时刻都会有一些线程在运行,而另一些被交换出去,从而可以监测线程间交替行为的可预测性

4.6.1.1. 产生更多的交替操作,可提高发现错误的概率

处理器的数量小于活跃线程的数量.

在访问共享状态的操作中,使用Thread.yield产生更多的上下文切换或睡眠时间较短的sleep.

5. 显示锁

ReentrantLock

显示锁提供了一种无条件的\可轮询的\定时的以及可中断的锁获取操作.

内置锁在功能上存在局限性: 无法中断一个正在等待获取锁的线程.无法在请求获取一个锁时无限的等待下去.

显示锁必须在finally中释放lock.

5.1. 轮询锁与定时锁

tryLock 可避免死锁的发生.

释放已经获得的锁,重新获取所有锁.(或将这个失败记录,或采取其他措施)

如使用tryLock获取两个锁,如不能同时获得,回退重试尝试,在休眠时间包括固定部分和随机部分,从而降低活锁的发生性.如在指定时间不能获得所需要的锁,最终返回一个失败的状态.

定时锁可用tryLock实现,超过timeout失败.

可中断的锁,Lock.lockInterruptibly(替代lock)可在获取锁的同时保持对中断的响应.

非公平锁的性能要高于公平锁的性能,显示锁和内置锁都没有保证锁是公平的(显示锁可设置公平锁)

5.2. 内置锁与显示锁的选择

显示锁vs内置锁:

  • 显示锁提供了一种无条件的\可轮询的\定时的以及可中断的锁获取操作.
  • 显示锁如未被正确使用,在finally中释放锁,可能引起灾难性后果
  • 内置锁在线程转储过程中可给出线程栈帧.并能监测和识别发生死锁的线程.
  • java的高级版本中已经对显示锁支持更多的调试信息.可通过管理接口进行注册等.

在内置锁不能满足要求时,才可以考虑使用显示锁

5.3. 读写锁

一个资源可以被多个读操作访问,或者被一个写操作访问,但两者不能同时进行

6. 构建自定义的同步工具

条件队列使构建高效以及高响应性的状态依赖性变得更容易.但同时也很容易被不正确的使用.

wait notify notifyAll方法

在编译器或系统平台上可能没有遵循正确使用条件队列的规则,尽量避免使用条件队列.

6.1. 使用条件队列

条件谓词: 使某个操作成为状态依赖操作的前提条件

在条件等待中存在一条重要的三元关系:

加锁\wait方法\条件谓词

锁对象和条件队列对象(调用wait和notify所在的对象)必须是同一对象,锁保护者条件谓词的状态变量.

wait方法将释放锁,阻塞当前线程,并等待直到超时,然后线程被中断或者通过一个通知被唤醒.在唤醒进程后,wait在返回前还要重新获取锁,当线程从wait方法中被唤醒时,它在重新请求锁时不具有任何特殊的优先级,要去其他尝试进入同步代码块的线程一起竞争.

6.1.1. wait过早唤醒

wait方法的返回并不一定意味这线程正在等待的条件谓词已经成真.

在发出通知的线程调用notifyAll时,条件谓词变为真,但该等待线程在重新获取锁时条件谓词可能已经变成假的了.或者并不知道另一个线程为什么调用notify或notifyAll,也许是因为与同一队列相关的另一个条件谓词变成了真.

一个条件队列可能有多个条件谓词相关.

基于上述条件,每次从wait线程中唤醒时,都必须再次测试条件谓词.需要在while循环中调用wait,每次迭代都检测条件谓词.

当使用条件等待时(wait)

  • 通常都有一个条件谓词
  • 在调用wait之前测试条件谓词,并且从wait返回时再次进行测试
  • 在循环中调用wait
  • 确保使用与条件队列相关的锁来保护构成条件谓词的各个状态变量
  • 当使用wait\notify\notifyAll等方法时,一定要持有与条件队列相关的锁
  • 在检查条件谓词之后以及开始执行相应的操作之前,不要释放锁

6.1.2. 丢失信号

在wait没有检查条件谓词时,会出现这种情况

6.1.3. 通知 notify

每当在等待一个条件时,一定要确保在条件谓词变为真时通过某种方式发出通知.

  • notify
    JVM从条件队列上等待的多条线程中选择其中一个唤醒
  • notifyAll
    唤醒所有…

发出通知的线程应尽快释放锁,以使wait的线程重新获得锁.

只有同时满足以下两个条件时,才能用单一的notify而不是notifyAll:

  • 所有等待线程的类型都相同.只有一个条件谓词与条件队列相关,并且每个线程在从wait返回后都执行相同的操作
  • 单进单出. 在条件变量上的每次通知,最多只能唤醒一个线程来执行.

6.2. 显示的Condition对象

内置锁和内置条件队列存在一些缺陷,每个内置锁都只能有一个相关联的条件队列.

在Condition中,await signal sinalAll

Condition比内置条件队列提供了更丰富的功能:

  • 在每个锁上可存在多个等待
  • 条件等待可以是可中断的或不可中断的
  • 基于时限的等待
  • 公平的或非公平的队列操作

对每个Lock,可有任意数量的Condition对象.一次sinal会唤醒所有在该lock上等待的线程.

即在每个锁上等待多个线程集.

要实现一个依赖状态的类,如果没有满足依赖状态的前提条件,这个类的方法必须阻塞,最好的方式是基于现有的类库来构建,如Semaphore BlockingQueue 或CountDonwLatch.如现有类库不能提供足够功能,可使用内置的条件队列\显示的Condition对象或AQS来构建自己的同步器.

7. 原子变量与非阻塞同步机制

volatile变量的使用,不能用于构建原子的符合操作.不能实现计数器或互斥体.

计数操作目前为止只能用锁的方式实现.