Sivan
Sivan
Published on 2021-02-22 / 143 Visits
0
0

Java 并发编程

Java 并发编程

三、对象的共享

3.1 可见性

多线程之间同一个对象,不同状态的可见性。通常无法确保读线程能够及时看到其他线程所做的修改。

  • 失效数据

    无法保证可见性时,读线程查看数据时,可能会读取到已经失效的数据(即早已被其他写线程修改)

  • 非原子的64位操作

    JVM为了兼容32位CPU,将long、double类型变量拆分为两个32位操作。如果对long、double类型进行共享,会导致某个值的高32位与低32位不一致。除非使用volatile关键字修饰或加锁保护。

  • 加锁与可见性

    加锁可以保证共享变量在同一个锁上的线程间的可见性。加锁包含互斥、内存可见性俩部分,保证操作的原子性。

  • Volatile变量

    volatile关键字只能保证变量可见性。仅满足以下条件时才应该使用:
    对变量的写入操作不依赖变量当前的值,或者你能确保当前只有一个线程更新变量的值。
    该变量不会与其他变量一起纳入不变性条件。(volatile关键字无法保证复合状态的原子性,需要加锁保证复合变量可见性及原子性)
    在访问变量时不需要加锁。

3.2 发布与逸出

发布(Publish): 对象能够在当前作用域范围之外的代码中使用。
逸出(Escape): 不应该‘发布’的对象被‘发布‘。(发布内部状态可能会破坏封装性,难以维持不变性条件;对象构造完成前就发布,会破坏线程安全性;)

3.3 线程封闭

当共享可变数据时,通常需要使用同步。若仅在单线程内访问数据,就不需要同步,这种技术称为“线程封闭”。

  • Ad-hoc线程封闭
    维护线程封闭的职责完全由程序实现来承担。(将对象封闭到目标线程上)

  • 栈封闭
    将基本类型变量、对象引用封闭到方法的局部变量中,此时基本变量、对象都被封闭在执行线程栈中。

  • ThreadLocal
    将全局变量、单实例变量封闭到ThreadLocal中。

3.4 不变性

如果某个对象在创建完成后其状态不能被修改,那么就称为“不可变对象”。不可变对象的固有属性之一就是线程安全。

当满足以下条件,对象才是不可变的:

  • 对象创建以后其状态就不能修改

  • 对象的所有域都是final类型

  • 对象是正确创建的(创建时this引用没有逸出)

  • 3.4.1 Final域

    final类型的域是不能被修改的;在JVM内存模型中,final域能确保初始化过程的安全性,从而可以不受限制的访问不可变对象,并无需同步的访问这些共享对象。
    除非需要某个域是可变的,否则建议声明为final域。

    示例: P41 使用volatile发布不可变对象

3.5 安全发布

  • 3.5.2 不可变对象与初始化安全性
    Java内存模型对不可变对象的共享提供了特殊的初始化安全性保证。任何线程都可以不需要额外同步情况下安全的访问不可变对象。
    这种保证还延伸到被正确创建的对象中的所有final域,除非final域指向可变对象。

  • 3.5.3 安全发布的常用模式

    安全的发布对象,需要保证对象的引用及状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式安全发布:
    - 在静态初始化函数中初始化一个对象引用。(静态初始化器由JVM在类的初始化阶段执行,由于JVM内部存在同步机制,因此此方式初始化的任何对象都可以被安全发布)
    - 将对象保存到volatile类型的域、或AtomicReference对象中。
    - 将对象的引用保存到某个正确构造对象的final类型的域中。
    - 将对象引用保存到一个由锁保护的域中。(线程安全容器即满足此条件)

  • 3.5.4 事实不可变对象

    若对象实际是可变的,但在安全发布后不会再改变其状态的,称为事实不可变对象。
    
  • 3.5.5 可变对象

    可变对象必须通过安全发布,并且必须是线程安全或由锁保护的。
    
  • 3.5.6 安全的共享对象
    对象的发布需求取决于它的可变性:
    - 不可变对象可以由任意方式发布
    - 事实不可变对象必须通过安全方式发布
    - 可变对象必须通过安全发布,并且必须是线程安全或者由锁保护

    在并发程序中使用和共享对象时,可以使用一些策略:
    - 线程封闭。 线程封闭的对象只能由一个线程拥有,对象被封闭在该线程内,并且只能由这个线程修改。
    - 只读共享。 在没有额外的同步情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。
    - 线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象安全的公有接口访问。
    - 保护对象。 被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布并由某个锁保护的对象。

四、对象的组合

4.1 设计线程安全的类

设计线程安全的类需要包含三个基本要素:

  • 找出构成对象状态的所有变量
  • 找出约束状态变量的不变性条件
  • 建立对象状态的并发访问管理策略
  • 4.1.1 收集同步需求

    不可变条件* 用于判断状态有效还是无效。
    后验条件 判断状态的迁移是否有效。

    不可变条件和后验条件在状态及状态转换上添加了各种约束,因此需要同步和封装。
    要满足状态变量的有效值或状态转换上的各种约束,就需要借助原子性和封装性。

  • 4.1.2 依赖状态的操作

    如果在某个操作中包含有基于状态的先验条件,那么这个操作称为依赖状态的操作。

  • 4.1.3 状态的所有权

    对象封装它所拥有的状态,对它封装的状态拥有所有权。

    容器类通常是“所有权分离”,容器拥有其自身的状态,而客户代码则拥有容器中各个对象的状态。

4.2 实例封闭

将一个对象封装到另一个对象内,通过将封闭机制与合适的加锁策略结合起来,可以确保以线程安全的形式使用非线程安全对象,称为“实例封闭机制”。

将数据封装在对象内部,可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时持有正确的锁。

实例封闭使得不同的状态变量可以由不同的锁来保护。

Collections.synchronizedList 工厂方法的返回值就是一个使用实例封闭的装饰器模式的同步容器。

  • 4.2.1 Java 监视器模式

    遵循Java监视器模式的对象会把对象的所有状态都封装起来,并由对象自己的内置锁保护。(每个对象都是一个监视者..)

4.3 线程安全性的委托

在某些情况下,通过多个线程安全类组合而成的类也是线程安全的。

  • 4.3.1 委托单个状态变量

    当普通对象A的状态变量B是线程安全的,普通对象A对状态变量B没有额外的有效性约束,A将线程安全性委托给B来保证,此时对象A也是线程安全的。

  • 4.3.2 独立的状态变量

    当普通对象A有多个线程安全的状态变量时,只要线程变量是相互独立的,没有额外的不变性条件时,普通对象A也是线程安全的。

  • 4.3.3 当委托失效时

    如果一个类是由多个独立且线程安全的变量组成,并且在所有的操作中都不包含无效状态转换,那么可以将线程安全性委托给底层的状态变量。

  • 4.3.4 发布底层的状态变量

    如果一个状态变量是线程安全的,并且没有任何不变性条件约束它的值,在变量的操作上也不存在有效性约束,那么就可以安全的发布这个变量。

4.4 对现有的线程安全类中添加功能

  • 4.4.1 客户端加锁机制

    将扩展的同步方法放到辅助类中,需要与所扩展对象的状态使用同一个锁。

  • 4.4.2 组合

    通过组合模式与委托模式,可以使用扩展类自身的锁。与客户端加锁机制相比拥有更好的健壮性。

4.5 为线程安全的设计编写文档

五、基础构建模块

5.1 同步容器类

  • 5.1.1 同步容器类的问题

    同步容器类是线程安全的,但某些客户端可能会产生复合操作,需要额外加锁保护。

    • 迭代
    • 跳转(根据指定顺序找到当前位置的下一个)
    • 条件运算
  • 5.1.2 迭代器和ConcurrentModificationException

    对于容器类无论是直接迭代还是使用for-each迭代,使用的都是Iterator。如果有其他线程并发的修改容器,那么将会触发fail-fast机制,抛出ConcurrentModificationException。

    避免此问题的方式就是在迭代时加锁,但这不是一个好主意,会降低程序的可伸缩性,持有锁的时间越长,锁竞争越激烈,会导致大量线程等待,极大的降低吞吐量和CPU利用率。

    若不希望在迭代时加锁,那么可以使用“克隆”容器,并在副本上进行迭代。

  • 5.1.3 隐藏起来的迭代器

    虽然可以在迭代时加锁,防止抛出ConcurrentModificationException,但必须在所有进行迭代的位置进行加锁。
    许多容器的内部方法都会隐藏的调用迭代,例如 toString、hashCode、equals、containsAll、removeAll、retainAll等,以及把容器作为参数的构造函数。

5.2 并发容器

  • 5.2.1 ConcurrentHashMap
  • 5.2.2 额外的原子操作
  • 5.2.3 CopyOnWriteArrayList

5.3 阻塞队列和生产者消费者模式

阻塞队列简化了生产者和消费者的编码,take操作会一直阻塞消费者,直到有数据消费;put操作在队列满时一直阻塞生产者,直到队列可容纳更多数据。

BlockingQueue有多种实现 LinkedBlockingQueue、ArrayBlockingQueue都是FIFO队列,PriorityBlockingQueue是按照优先级排序的队列(通过元素的Comparable或Comparator)。

SynchronousQueue是特殊的队列,内部维护一组线程,put和take会一直阻塞,直到有另一个线程已经准备好参与到交付过程中。仅当有足够多的消费者,并且总是有一个消费者准备好获取交付时才适合使用SynchronousQueue。

  • 5.3.2 串行线程封闭

    java.util.concurrent中实现的各种队列都包含了足够的内部同步机制,从而安全的将对象从生产者线程发布到消费者线程。
    生产者-消费者线程与阻塞队列一起,促进了串行线程封闭,从而将对象所有权从生产者交付到消费者。

  • 5.3.3 双端队列与工作密取

    Deque是一种双端队列,实现了队头队尾的高效插入和移除。

    工作密取区别于生产者-消费者模式,其每个消费者都有各自的双端队列,如果一个消费者完成了自己的双端队列中的任务,那么它可以从其他消费者双端队列的末尾秘密的获取工作,从而确保每个线程都保持忙碌。

5.4 阻塞方法与中断方法

当一个方法抛出InterruptException异常时,表示其是一个阻塞方法。
Thread类的interrupt方法,用于中断方法或查询线程是否已经被中断。

5.5 同步工具类

只要一个类根据它自身的状态来协调线程的控制流,那么就可以称为同步工具类。

常见的同步工具类有:阻塞容器、信号量(Semaphore)、栅栏(Barrier)、闭锁(Latch)

  • 5.5.1 闭锁

    闭锁可以延迟线程的进度,直到其达到终止状态。闭锁可以看作一扇门,在闭锁达到终止状态前,门是关闭的;当闭锁达到终止状态时,门将永远打开,不再关闭。

    CountDownLatch包含一个计数器,该计时器被初始化为一个正值,表示需要等待的事件数量。countDown方法将递减计数器,await方法将一直阻塞并等待计数器归零。

    闭锁可以用于确保某些活动等待其他事件执行完之后再启动:

    • 确保某个计算在资源被初始化之后再继续执行。

    • 确保某个服务在其依赖的服务启动完之后再启动。

    • 等待某个操作的所有参与者都就绪再继续执行。

  • 5.5.2 FutureTask

    FutureTask是Future的实现类,通过执行Callable完成计算。Future.get 方法在已完成时直接返回,未完成时阻塞等待结果。

    需要注意的是,Callable中的任务可能会抛出任意的异常,而它们都会被Future包装成ExecutionException,这些原因可能会导致异常处理起来非常麻烦。

  • 5.5.3 信号量

    Semaphore中管理一组虚拟许可(permit),可以通过构造函数设置初始许可数量,在执行操作前,需要先获取许可(若还有剩余的许可),并在使用后释放许可。acquire方法将阻塞到获取一个许可,release方法将释放一个许可。

  • 5.5.4 栅栏

    栅栏类似一个闭锁,它能阻塞一组线程直到某个事件发生。所有线程必须都到达栅栏后才能继续执行任务。所有线程到达栅栏时都需要调用await,这个方法将阻塞到所有线程都到达栅栏。如果调用await的线程等待超时、被中断,那么栅栏被认为是打破了,所有在await阻塞的线程都将终止并抛出BrokenBarrierException。

5.6 构建高效可伸缩的结果缓存

P86 并发优化的缓存策略

六、任务执行

6.1 线程中执行任务

  • 6.1.1 串行执行任务

    吞吐率低、响应时间慢

  • 6.1.2 为每个任务创建线程

    响应性高、并行效率高;线程生命周期开销高(CPU)、资源消耗高(内存)、稳定性低(操作系统对于线程数量的限制)

6.2 Executor框架

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

  • 6.2.2 执行策略

    通过将任务的提交与执行解耦开,从而无须太大的困难就可以为某种类型的任务指定和修改执行策略。执行策略中定义了任务执行的“what、where、when、how”等方面。

    • 在什么线程中执行任务?
    • 任务按照什么顺序执行任务?
    • 有多少任务并发执行?
    • 队列中有多少任务在等待执行?
    • 如果系统过载拒绝一个任务,那么应该选择哪一个?如何通知应用程序有任务被拒绝?
    • 在执行一个任务前后应该进行哪些动作?

    通过将任务提交与执行策略分离开,有助于在部署阶段选择与可用硬件资源最佳的执行策略。

  • 6.2.3 线程池

    • Fixed 固定长度的线程池。每当提交一个任务就创建一个线程,直到达到线程池最大容量。
    • Cached 可缓存的线程池。如果线程池的线程数量多于任务,那么将会回收线程(超过60s未使用
    • 的线程将会被删除);如果任务需求增加时,将会创建新的线程。线程池线程数量没有任何限制。
    • Single 单线程的线程池。能保证任务的执行顺序。若线程因为异常导致退出,将会创建新的线程代替。
    • Scheduled 任固定长度的线程池。允许以延迟、定时的方式执行任务。
  • 6.2.4 Executor的生命周期

    • 运行 Executor启动后进入运行状态,允许接受任务及调度线程。
    • 关闭 通过shutdown方法进入关闭状态,不接受新任务,等待已经提交的任务执行完毕(包括正在运行的和在队列中的)。
    • 已终止 所有任务执行完毕后转入终止状态。可以通过awaitTermination等待终止。
  • 6.2.5 延迟任务和周期任务

    DelayQueue为ScheduledThreadPool提供调度功能。可以通过其实现自己的调度服务。

6.3 找出可利用的并行性

对于一项大的任务,可以将其中的部分逻辑处理为并行任务,加速应用程序的处理速度。例如数据库处理。

  • 6.3.4 在异构任务并行化中存在的局限

    异构任务的大小不固定,可能最终导致并行任务的时间差异较大,导致异构任务虽然并发,但总体性能没有提升。

    只有当大量相互独立的同构任务可以并发处理时,才能体现并行的性能提升。

  • 6.3.5 CompletionService

    CompletionService可以接受一组计算任务,并通过take、poll等方法获取已完成的结果。

  • 6.3.7 为任务设置时限

    有时在某个任务无法在指定时间内完成,那么就不再需要它的结果。
    可以给Future.get方法指定时间,并在处理TimeoutException时通过Future.cancel方法取消任务,

七、关闭与取消

7.1 任务取消

如果外部代码能在某个任务正常完成之前将其置入“完成”状态,那么就可以称为这个任务是可取消的。取消一般出于以下原因:

  • 用户请求的取消 用户通过“取消”按钮取消任务
  • 有时间限制的操作 任务时长超出指定时间限制
  • 应用程序事件 程序内其他任务发起的取消
  • 错误 任务执行遇到错误时
  • 关闭 程序被强制关闭时

一个可取消的任务必须拥有取消策略,这个策略详细的定义取消操作的How、When、What,即其他代码如何(How)取消该任务,任务在何时(When)检查取消请求,以及在响应取消请求时该执行哪些(What)操作。

  • 7.1.1 中断

    中断线程是实现取消的最合理的方式。调用interrupt(中断)不会立即停止线程,只是传递了中断的消息。

    注意⚠️:在使用interrupted时,它将清除当前线程的中断状态。如果在调用interrupted额时返回了true,除非你想屏蔽这个中断,否则必须向外抛出InterruptException或再次调用interrupt恢复中断。

  • 7.1.2 中断策略

    如同任务有取消策略一样,线程也应该有中断策略,其规定线程如何解释某个中断请求,当发生中断时如应该做哪些工作。

  • 7.1.3 响应中断

    当收到InterruptException时,只有实现了中断策略的代码才可以屏蔽中断请求。否则需要传递异常或恢复中断状态。

    有时中断可以用于获得线程的注意,例如一个ThreadPoolExecutor拥有的工作线程检测到中断时,它会检查线程池是否正在关闭,若是,它将进行清理工作,否则它可能新建一个线程将线程池恢复到合理的规模。

  • 7.1.5 通过Future取消

    Future.cancel 可以用于取消一个线程,它接受一个参数mayInterruptIfRunning
    ,表示是否允许中断正在运行的线程,为false时允许当前运行的线程执行完毕。

  • 7.1.6 处理不可中断的阻塞

    并不是所有的阻塞方法都会抛出InterruptException来响应中断请求,那么我们需要了解线程阻塞的原因,并用类似中断的方式停止线程:

    • Java.io中的同步 Socket I/O:其read、write等方法都不会响应中断,但可以通过关闭底层套接字,使调用read、write的方法抛出SocketException中断线程。
    • Java.io中的同步I/O:大多数标准的Channel都实现了InterruptibleChannel,当中断一个在InterruptibleChannel上等待的线程,将抛出CloseByInterruptException并关闭链路(这也会使其他在链路上等待的线程同样会抛出CloseByInterruptException)。当关闭一个InterruptibleChannel时,将导致所有在链路上操作阻塞的线程抛出AsynchronousCloseException.
    • Selector的异步I/O:若一个线程阻塞在Selector.select方法上,那么调用close或wakeup方法会使线程抛出CloseSelectorException。
    • 获取某个锁:若一个线程由于等待某个内置锁而阻塞,那么将无法响应中断。

    可以将不可中断的阻塞对象封装在自定义的Thread子类中,并重写其interrupt方法。

  • 7.1.7 采用newTaskFor来封装非标准的取消

7.2 停止基于线程的服务

  • 7.2.2 关闭ExecutorService

    shutdown或shutdownNow

  • 7.2.3 “毒丸”对象

    “毒丸”对象是一种关闭生产者-消费者服务的方式。“毒丸”是指一个放在队列中的对象,表示:“得到这个对象,立即停止”

    只有生产者和消费者数量都确定的情况下,才可以使用毒丸对象,并且需要一个无界队列,毒丸对象才能可靠的工作。

  • 7.2.5 shutdownNow的局限性

    shutdownNow在终止时,会返回一组已提交但未执行的任务,但无法返回正在运行的任务,而这通常是比较需要的功能。通过拓展线程池,我们可以获取正在运行的任务,但我们无法得知哪些已经工作完成。

7.3 处理非正常终止的线程

可以通过Thread.setUncaughtExceptionHandler为每个线程设置一个未捕获异常处理器。线程池可以通过ThreadFactory设置。

标准线程池允许在发生未捕获异常时结束线程,但在使用try-finally接收通知,因此当线程结束时,会有新的线程代替它。若未提供UncaughtExceptionHandler或其他故障通知,那么任务将悄悄的失败。若你希望任务发生异常时的到通知,并执行一些特定的恢复操作,那么可以将任务封装在能捕获异常的Runnable或Callable中,或者重写ThreadPoolExecutor中的afterExecute方法。

但是!只有通过execute提交的任务才能将它抛出的异常交给UncaughtExceptionHandler处理,submit方法提交的任务中抛出的异常将会在Future.get中包装在ExecutionException内重新抛出。

7.4 JVM关闭

  • 7.4.1 关闭钩子

    Runtime.getRuntime().addShutdownHook(new Thread(() -> {
        // todo something
    }));
    
  • 7.4.2 守护线程

  • 7.4.3 终结器

八、线程池的使用

8.1 在任务与执行策略之间的隐性耦合

线程池可以将任务的提交和执行策略解耦开来。但并不是所有的场景的任务都能适用所有的执行策略,当以下场景时,任务与执行策略存在隐性耦合:

  • 依赖性任务:若提交给线程池的任务是有相互依赖性的,那么必须谨慎的调整它们的执行策略,避免导致死锁。(任务必须搭配特殊的执行策略)
  • 使用线程封闭机制的任务:在单线程的线程池不存在并发,所以其中的任务可以放宽对线程安全的要求,使得在该线程池中执行的任务不需要同步。(不能将任务提交到多线程的池中)
  • 对响应时间敏感的任务:将一个运行时间较长的任务提交到单线程池中、将多个运行时间长的任务提交到少量线程的池中,都将影响服务的响应性。(特定任务需要特定的执行策略处理)
  • 使用ThreadLocal的任务:线程池中的线程会被重用,保存在ThreadLocal中的数据可能会被共享到多个任务中;当线程抛出异常,线程池补充一个新的线程时,那么在ThreadLocal中的数据会丢失。只有ThreadLocal受限于当前任务的生命周期时,才有意义。
  • 8.1.1 线程饥饿死锁

    线程池中的任务如果依赖于其他任务,那么就可能产生死锁。在单线程的线程池中,一个任务向同一个线程池提交一个任务,并等待这个任务的结果,那么通常会引发死锁。在多线程的线程池中,若所有运行中的线程都在等待在工作的队列中的任务而阻塞,那么也会死锁。称为线程饥饿死锁。

  • 8.1.2 运行时间较长的任务

8.2 设置线程池的大小

计算密集型的线程池建议为 Ncpu + 1(即使线程因为各种原因暂停,“额外”的线程也能及时补上,不浪费cpu时钟周期)。
IO密集型建议为更大的线程数。

计算公式:
Ncpu = CPU数量
Ucpu = CPU利用率指标,取[0,1]之间
W/C= 等待时间与计算时间的比率

要得到期望的使用率,线程池的最优大小为:
Nthreads = Ncpu * Ucpu * (1 + W/C)

CPU并不是唯一影响线程池大小的资源,还包括内存、文件句柄、套接字句柄和数据库连接等,计算这些资源对线程池大小的方式更容易:

资源可用总量 / 资源需求量 = 线程池大小的上限

8.3 配置ThreadPoolExecutor

public ThreadPoolExecutor(int corePoolSize,   // 线程池基本大小
                          int maximumPoolSize,// 线程池最大大小
                          long keepAliveTime, // 空闲线程存活时间
                          TimeUnit unit,      // 空闲时间单位
                          BlockingQueue<Runnable> workQueue, // 任务队列
                          ThreadFactory threadFactory,       // 线程工厂 创建自定义线程
                          RejectedExecutionHandler handler)  // 任务拒绝策略
  • 8.3.1 线程的创建与销毁

    线程的存活创建与销毁和线程池的基本大小、最大大小、存活时间相关。
    基本大小是指没有任务执行时,线程池的最小线程数。(可以通过prestartAllCoreThreads方法在没有任务时也立即启动一组线程)
    最大大小是指可同时活动的线程数量上限,只有工作队列满时,才会创建超出基本大小的线程。
    存活时间是指当线程的空闲时间超出存活时间时,将会被标记为可回收的,并且当线程池的大小超出基本大小时,该线程将被终止。(可以通过allowCoreThreadTimeOut方法允许核心线程超时,若设置为ture,当无任务时,核心线程超出空闲时间,也会被终止)

  • 8.3.2 管理任务队列

    阻塞队列有 ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue、SynchronousQueue

    线程池中使用队列的优缺点:

    • 无界队列:可以接收瞬间的大量任务,但当任务持续快速的到达容易导致OOM异常
    • 有界队列:有助于避免资源耗尽,需要搭配拒绝策略,会降低线程池吞吐量
    • 同步移交队列:任务到达时直接交给线程处理,避免任务排队,需要搭配无界的线程池或拒绝策略
  • 8.3.3 饱和策略(拒绝策略)

    当有界队列被填满后,拒绝策略开始生效(提交到已关闭的线程池也会生效)。终止策略有:

    • AbortPolicy 终止策略:抛出未受检异常RejectExecutionException。
    • Discard 抛弃策略:丢弃当前提交的任务。
    • Discard-Oldest 抛弃最旧策略:丢弃一个即将被执行的任务,当使用优先级队列时,将会丢弃优先级最高的任务。
    • Caller-Runs 调用者运行策略:将任务退回到调用者执行。

    Caller-Runs在WebServer中可以达到性能平缓下降的目的。当线程池已满时,任务由主线程运行,此时将会阻塞一段时间,因此在这段时间,主线程将不能提交任务。在运行期间主线程不会带哦用accept,因此请求将被保存在TCP层的队列中,如果持续过载,TCP将会发现他的请求队列填满,并开始丢弃请求。当服务器过载时,这种过载情况会逐渐蔓延--从线程池到工作队列到应用程序到TCP层,最终到客户端,实现一种平缓的性能降低。

    使用Caller-Runs饱和策略:

    new ThreadPoolExecutor(N_THREAD, N_THREAD,
        0L, TimeUnit.MINUTES,
        new LinkedBlockingDeque(CAPACITY),
        new ThreadPoolExecutor.CallerRunsPolicy())
    

    若使用无界队列,且没有饱和策略,则可以使用Semaphore控制任务提交速率,当信号量消耗完毕后任务的提交将会被阻塞,达到同样的平缓性能降低策略:

    public class BoundedExecutor {
        private final ExecutorService executor;
        private final Semaphore semaphore;
    
        public BoundedExecutor(ExecutorService executor, int bound) {
            this.executor = executor; // 使用无界队列
            this.semaphore = new Semaphore(bound);  // bound为线程池大小+可排队任务数(自定义)
        }
    
        public void submitTask(final Runnable command) {
            try {
                semaphore.acquire();
                executor.execute(new Runnable() {
                    @Override
                    public void run() {
                        try {
                            command.run();
                        }finally {
                            semaphore.release();
                        }
                    }
                });
            } catch (InterruptedException e) {
                semaphore.release();
            }
        }
    }
    
  • 8.3.4 线程工厂

    线程工厂提供了自定义创建线程的能力,可以通过其设置线程的名字、UncaughtExceptionHandler以及其他自定义的信息(日志、运行信息等)

    public class MyThreadFactory implements ThreadFactory{
    	private final String poolName;
    	
    	public Thread newThread(Runnable runnable){
    		return new MyThread(runnable, poolName)
    	}
    }
    

    若应用程序需要利用安全策略控制对特殊代码库的访问权限,可以通过Executors中的privilegedThreadFactory来定制自己的线程工厂。这种方式创建的线程将与创建privilegedThreadFactory的线程拥有相同的访问权限、AccessControlContext和contextClassLoader.

  • 8.3.5 调用构造函数后再修改线程池配置

    通过构造方法ThreadPoolExecutor对象之后,仍可以通过其内部方法修改基本大小、空闲超时、拒绝策略等。

    同时,若你不想线程池被修改,那么可以使用Executors提供的unconfigurableExecutorService方法,该方法将包装一个ExecutorService,使其不能被配置。

8.4 扩展ThreadPoolExecutor

十、避免活跃性危险

活跃性危险是指死锁、活锁、丢失信号等。

10.1 死锁

线程A持有锁L等待锁M,线程B持有锁M等待锁L,构成一个最简单的死锁。

  • 10.1.1 锁顺序死锁

    顺序死锁:两个线程尝试以不同的顺序获得两个相同的锁。

    如果所有线程以固定顺序来获取锁,那么将不会有顺序死锁问题。

  • 10.1.2 动态锁顺序死锁

    若锁对象由外部参数决定,那么在运行中即使按照固定顺序获得锁,依然有可能因为外部输入顺序的问题导致锁顺序死锁。

    public void transferMoney(Account formAcc, Account toAcc, Decimal amount){
    	synchronized(formAcc){
    		synchronized(toAcc){
    			// do something
    		}
    	}
    }
    

    A:transferMoney(A, B, 100)
    B:transferMoney(B, A, 10)

    可以通过Account构造一个唯一的、不可变的、可比较的值。例如Account的Id,或该对象的hash。改造后的transferMoney如下:

    public void transferMoney(Account formAcc, Account toAcc, Decimal amount){
    	if (formAcc.hashCode() < toAcc.hashCode()){
           	synchronized(formAcc){
    			synchronized(toAcc){
    				// do something
    			}
    		}
           } else {
    		synchronized(toAcc){
    			synchronized(formAcc){
    				// do something
    			}
    		}
    	}
    
    }
    

    此时可以保证锁的执行顺序。

  • 10.1.3 协作对象之间发生死锁

    通常获取多个锁的操作并不像上节的例子那么简单,两个锁的操作都在同一个方法内,通常锁操作可能在两个或多个类内部进行。

    如果在持有锁时调用某个外部方法,那么将出现活跃性问题。在这个外部方法中可能会获取其他锁(可能会产生死锁),或者阻塞时间过长,导致其他线程无法及时获取当前被持有的锁。

  • 10.1.4 开放调用

    如果在调用某个方法时不需要获取锁,那么就称为开放调用。

    在应用程序中尽量使用开发调用(缩小锁的范围)。与持有锁时调用外部方法相比,开放调用更容易进行死锁分析。

    使用开放调用有时可能会导致同步方法丢失原子性,需要一些额外的协议来防止其他线程进入临界区(避免了死锁, 但增加了复杂度)

  • 10.1.5 资源死锁

    当多个线程在相同的资源集合上等待时也有可能发生死锁。
    资源集合越大,发生资源死锁的可能性就越小。

10.2 死锁的避免与诊断

尽量减少潜在的加锁交互数量,将获取锁时需要遵循的协议写入正式文档。

  • 10.2.1 支持定时的锁

    显示使用Lock中的tryLock功能,可以避免死锁,当长时间未获得锁时,将返回超时失败的信息。

  • 10.2.2 通过线程转储分析死锁

    内部锁与它们所在的线程栈帧相关联,而显式的Lock只与获得它的线程相关联。

10.3 其他活跃性危险

  • 10.3.1 饥饿

    当线程无法访问它所需要的资源而不能继续执行时,就发生了“饥饿”。引发饥饿最常见的资源就是CPU时钟周期。如果在Java引用程序中对线程的优先级使用不当,或者在持有锁时执行一些无法结束的结构(无限循环、无限等待资源),那么也可能导致饥饿,因为其他线程需要这个锁,但无法得到。

    要避免使用线程优先级,因为这将增加平台依赖性,并可能导致活跃性问题。在大多数并发应用程序中,都可以使用默认的线程优先级。

  • 10.3.2 糟糕的响应性

  • 10.3.3 活锁

    活锁(LiveLock)是另一种形式的活跃性问题,尽管不会阻塞线程,但也不能继续执行,因为线程将不断重复执行相同的操作,并且总是失败。

    当多个相互协作的线程都对彼此进行响应,从而修改各自的状态,并使得任何一个线程都无法继续执行的时候,就发生了活锁。这就像两个非常礼貌的人半路上面对面相遇,他们都让出对方的路,然后又在另一边相遇,如此反复的避让下去。

    要解决活锁问题需要在重试机制中加入随机性,在并发应用程序中,通过等待随机长度的时间和回退机制可以有效避免活锁的发生

十一、性能和可伸缩性

线程的主要目的是提高程序的运行性能,线程可以是程序更加充分的发挥系统的可用处理能力,从而提高系统的资源利用率。线程还可以使程序执行任务时立即开始处理新的任务,从而提高系统的响应性。

11.1 对性能的思考

提升性能意味着用更少的资源做更多的事情。

“资源”的含义很广,例如CPU时钟周期、内存、网络、I/O带宽、数据库请求、磁盘空间以及其他资源。
当操作性能受某种资源限制时,称为资源密集型操作。例如CPU密集型、I/O密集型、数据库密集型。
线程会带入额外的性能开销:线程之间的协调(加锁、触发信号、内存同步)、增加上下文切换、线程的创建与销毁、线程的调度。如果过度的使用线程,那么这些开销会导致性能甚至比串行程序的性能还差。

如果程序是计算密集型的,那么可以通过增加处理器提高性能。通过应用程序分解到多个线程上执行,使得每个处理器都执行一些工作,从而使CPU都保持忙碌状态。

  • 11.1.1 性能和可伸缩性

    可伸缩性是指:当增加计算资源时(CPU、内存、存储容量、I/O带宽),程序的吞吐量或处理能力能相应的增加。

    应用程序的新能可以通过多个指标来衡量,服务时间、等待时间用于衡量程序的“运行速度”,即某个任务单元“多快”能处理完成。生产量、吞吐率用于衡量程序的“处理能力”,即在计算资源一定的情况下,能完成“多少”工作。

    性能的“多快”和“多少“是完全独立的,有时候甚至是互相矛盾的。要实现更高的可伸缩性或硬件利用率,通常会增加各个任务所要处理的工作量,例如把任务分解为多个”流水线“子任务。

    对于服务器应用程序而言,”多少“--可伸缩性、吞吐量、生产量往往比“多快”更重要。

  • 11.1.2 评估各种性能权衡因素

    避免不成熟的优化,首先使应用程序正确,然后再提高运行速度。

    大多数性能决策都有多个变量,并且依赖于运行环境,想要“更快”之前,先问自己一些问题:

    • 更快的含义是什么
    • 该方法在什么条件下运行的更快?在低负载还是高负载的情况下?大数据集还是小数据集?能否通过测试结果来验证?
    • 这些条件在运行环境中发生的频率?能否通过测试结果来验证?
    • 在其他不同条件的环境中是否能使用这里的代码?
    • 在实现这种性能提升时需要付出哪些隐含的代价?例如开发风险、维护开销?这种权衡是否合适?
      以测试为基准,不要猜测。对于并发采取谨慎的措施。

11.2 Amdahl定律

Amdahl定律描述的是:在增加计算资源的清咖滚下,程序在理论上能够实现最高加速比,这个值取决与程序中可并行组建与串行组建所占的比重。假定F是必须被串行执行的部分,那么根据Amdahl定律,在包含N个处理器的机器中,最高的加速比为:
Speedup <= 1 / (F + (1 - F) / N)

当N趋近与无穷大时,最大加速比趋近与1 / F。因此在10个处理器系统中,如果程序有10%需要串行执行,那么最高加速比为5.3(53%)。及时串行部分占比很小也会极大的限制增加计算资源时能够提升的吞吐率。

所有并发程序都包含一些串行部分。例如从共享队列中取出任务可能会阻塞、计算任务结果(保存或写入到日志)都是串行部分。

  • 11.2.1 各种框架中隐藏的串行部分

    ConcurrentLinkedQueue比SynchronizedList封装的LinkedList并发性能要好...

  • 11.2.2 Amdahl定律的应用

    如果能准确的估计出执行过程中串行部分所占的比例,那么Amdahl定律就能量化有更多资源可用时的极速比。虽然直接测量串行部分的比例非常困难,但即使不进行测试,Amdahl定律也是有用的。

11.3 线程引入的开销

单线程不存在线程带调度、也不存在同步开销、也不需要使用锁来保证数据结构的一致性。多线程调度、协调都需要一定的性能开销,但对于提升性能而引入的线程而言,并行带来的性能提升必须超过并发导致的开销。

  • 11.3.1 上下文切换

    切换上下文需要一定的开销,而在线程调度的过程中需要访问由操作系统和JVM共享的数据结构。应用程序、操作系统、JVM都使用同一组CPU,在JVM和操作系统中消耗越多CPU时钟周期,应用程序可用的时钟周期就越少。
    当一个线程被切换时,它所需要的数据可能不再当前处理器的本地缓存中,因此上下文切换将导致一些缓存确实,因而线程在首次调度的运行时会更加缓慢。

    UNIX系统的vmstat和Windows的perform工具都能报告上下文切换次数以及在内核中执行时间所占比例。

  • 11.3.2 内存同步

    同步操作的性能开销包括多个方面,在synchronized和volatile提供的可见性保证中可能会使用一些特殊指令,即内存栅栏(Memory Barrier)。内存栅栏可以刷新缓存,使缓存无效,刷新硬件的写缓冲,及停止执行管道。内存栅栏可能同样会对性能带来间接的影响,因为它们将一些编译器优化,在内存栅栏中,大多数操作是不能被重排序的。

    在评估同步操作带来的性能影响时,区分有竞争的同步和无竞争的同步非常重要。synchronized机制对无竞争的同步进行了优化,一个“快速通道(Fast-Path)”的非竞争同步消耗20~250个时钟周期,虽然开销不为0,但对应用程序的影响已经微乎其微。

    现代JVM能通过一些优化去掉一些不会发生竞争的锁,减少不必要的同步开销。称为锁消除(Lock Elison)优化。
    更完备的JVM能通过逸出分析(Escape Analysis)找出不会发布到堆的本地对象引用,从而使其内部状态不会逸出,取消其对锁的获取操作。
    即使没有逸出分析,编译器也可以执行锁粒度粗化(Lock Coarsening)操作,将临近的同步代码块用同一个锁合并起来。

  • 11.3.3 阻塞

    当在锁上发生竞争时,竞争失败的线程肯定会阻塞。JVM在实现阻塞行为时可以采用自旋等待(Spin-Waiting, 指通过循环不断地尝试获取锁,直到成功)或者通过操作系统挂起被阻塞的线程。这两种操作的效率取决与上下文切换的开销以及获取锁的等待时长。若等待时间短,则适合采用自旋等待的方式,若等待时间长,则适合操作系统挂起的方式。有些JVM将根据历史的等待时间的分析数据进行选择,但大多数JVM等待锁时都只是挂起线程。

    当线程在某个资源(锁、条件、I/O操作等)上阻塞时,将需要挂起,在这个过程需要两次额外的上下文切换,以及其所需要的操作系统操作和缓存操作:被阻塞的线程在其执行时间片还未结束时被交换出去,而随后有可用资源后,又再次被切换回来。

11.4 减少锁竞争

在并发程序中,对可伸缩性最主要的威胁就是独占方式的资源锁。

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

注:这是Little定律的必然结论,也是排队理论的一个推论 —— “在一个稳定的系统中,顾客的平均数量等于他们的平均到达率乘以在系统中平均停留时间“。
如果二者乘积很小,那么大多数获取锁的操作都不会发生竞争,因此在该锁上的竞争就不会对可伸缩性造成严重的影响。反之,若锁的请求量很高,那么获取该锁的线程将被阻塞,极端情况下,即使认购呀大量工作等待完成,处理器也会被闲置。

有三种降低锁竞争的方式:

  • 减少持有锁的时间。

  • 降低锁的请求频率。

  • 使用带有协调机制的独占锁,这些机制允许更高的并发性。

  • 11.4.1 缩小锁的范围(快进快出)

    降低发生竞争可能性的一种有效方式就是尽可能缩短锁的持有时间。将一些与锁无关的代码移出同步代码块。

  • 11.4.2 减小锁的粒度

    另一种减少锁的持有时间的方式是降低线程请求锁的频率。可以通过锁分解和锁分段等技术来实现,这些技术将采用多个相互独立的锁来保护独立的状态变量。

    如果锁上的竞争是适中的而不是激烈的,可以讲一个锁分解为两个,将其转变为非竞争锁,从而有效的提高性能和可伸缩性。如果对竞争激烈的锁进行分解,那么在性能和吞吐量上带来的提升非常有限,但也会提高性能下降的拐点(随着竞争愈发激烈,性能逐渐下降的拐点)。

  • 11.4.3 锁分段

    虽然通过锁分解技术可以提高一部分可伸缩性,但在一个拥有多个处理器的系统中,仍然无法给可伸缩性,带来极大的提高。
    某些情况下可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,称为锁分段技术。锁分段的一个劣势在于,与采用单个锁来实现独占相比,需要获取多个锁来实现独占访问,更加困难并且开销更高。通常执行一个操作最多只需要获取一个锁,但在某些情况下需要加锁整个容器。例如ConcurrentHashMap扩展映射范围,以及重新计算键值的散列值要分布到更大的桶集合中时,就需要获取分段所有集合中的锁(获取内置锁的集合,唯一方式是遍历)。

  • 11.4.4 避免热点域

    当每个操作都请求多个变量时,锁的粒度很难降低。这是在性能与可伸缩性之间相互制衡的另一个方面,一些常见的优化措施,例如将一些反复计算的结果缓存起来,会引入“热点域(Hot Field)”,这些热点域将会影响可伸缩性。

  • 11.4.5 一些替代独占锁的方法

    使用并发容器、读写锁、不可变对象和原子变量替代独占锁,可以提高可伸缩性(能降低独占锁的开销但并不能完全消除)。

  • 11.4.6 监测CPU的利用率

    CPU利用不充足的几种原因:

    • 负载不充足 客户端达不到足够的负载。
    • I/O密集
    • 外部限制 应用程序的外部依赖,例如数据库或Web服务。
    • 锁竞争 程序中锁竞争激烈,通常可以通过线程转储发现。

    如果CPU已经忙碌,那么可以使用监视工具来判断是否能通过增加CPU提升程序的性能。

  • 11.4.9 向对象池说不

    为了让对象能被循环使用,而不是被垃圾回收并在需要时重新分配,早期的开发人员使用对象池技术。

    但在并发程序中,对象池需要额外同步来协调对象池的访问,会带来额外的阻塞开销。

11.5 比较Map的性能

ConcurrentHashMap默认情况下在16线程时到达性能顶峰。

11.6 减少上下文切换的开销

十二、并发程序的测试

12.1 正确性测试

  • 12.1.1 基本单元测试

  • 12.1.2 对阻塞操作的测试

  • 12.1.3 安全性测试

  • 12.1.4 资源管理的测试

  • 12.1.5 使用回调

    通过自定义线程池记录线程运行日志

  • 12.1.6 产生更多交替操作

    通过AOP工具向目标代码添加更多上下文切换,若某些操作过程中存在时序关系,可以通过随机的Thread.yield暴露问题。

12.2 性能测试

  • 12.2.1 计时
  • 12.2.3 响应性衡量

12.3 避免性能测试的陷阱

有些场景可能会带来性能测试的误差

  • 12.3.1 垃圾回收

  • 12.3.2 动态编译

  • 12.3.3 对代码路径的不真实采样

    动态编译器会根据收集的信息对已经编译的代码进行优化,容易导致编译器对测试用例进行了不同的优化方式,而真实环境却另有不同。

  • 12.3.4 不真实的竞争程度

  • 12.3.5 无用代码的消除

    避免编译器对无用代码进行消除,使得测试结果呈现出执行速度提高的假象。

12.4 其他测试的方法

  • 12.4.1 代码审查
  • 12.4.2 静态分析工具
  • 12.4.3 面向切面的测试技术
  • 12.4.4 分析与监测工具

十三、显式锁

13.1 Lock与ReentrantLock

Lock接口

void lock();
void lockInterruptibly();
void boolean tryLock();
void boolean tryLock(Long timeout, TimeUnit unit);
void unlock();
Condition newCondition();
  • 13.1.1 轮询锁与定时锁

    可定时与可轮询的锁获取模式是由tryLock方法实现的,它又更完善的错误恢复机制。

  • 13.1.2 可中断锁

    lockInterruptibly方法获取可以响应中断机制的锁。

  • 13.1.3 非块结构的加锁

13.2 性能考虑因素

Java5中的内置锁比ReentrantLock性能要差很多,但Java6中的内置锁做了优化,其性能与ReentrantLock非常接近。

13.3 公平性

ReentrantLock构造函数中提供了两种公平性选择,非公平的锁、公平锁。

公平锁上,线程将按照它们发出请求的顺序来获得锁,但非公平锁上则允许“插队”(当一个线程请求非公平锁时,若发出请求时该锁变为可用,那么将直接获得锁。即使是公平锁,可轮询的tryLock依然会“插队”)

激烈竞争下,非公平锁性能要高于公平锁两个数量级,原因是:在恢复一个被挂起的线程与该线程真正开始运行之间存在着严重的延迟。

13.4 synchronized和ReentrantLock之间进行选择

书中建议优先使用synchronized,当synchronized无法满足时才应该使用ReentrantLock。

13.5 读写锁

ReentrantReadWriteLock在公平锁中,等待时间长的线程优先获得锁。如果这个线程由读线程持有,而另一个线程请求写入锁,那么其他读线程都不能获取读取锁,直到写入线程释放写入锁。在非公平锁中,线程的访问术训是不确定的,写线程可以降级为读线程。

十四、构建自定义的同步工具

介绍实现状态依赖性的各种选择,以及在使用平台提供的状态依赖性机制时需要遵守的各项规则。

14.1 状态依赖性管理

在单线程中调用一个方法,如果某个基于状态的前提条件未得到满足,那么这个条件将永远无法成真。若在多线程中,更好的方式是提前等待前提条件,依赖状态的操作一直阻塞到继续执行,要更为方便和不容易出错。

  • 14.1.1 将前提条件的失败传递给调用者

    在一个自定义的同步工具中包含阻塞队列,其take、put方法都进行了同步,以确保对缓存状态的独占访问。(阻塞队列的前提条件是:方法不能从空队列中获取元素、也不能向满队列添加元素)

    当调用者接收到前提条件导致的异常时,可以通过自旋、休眠的方式进行重试。客户代码必须要在二者之间选择:容忍自旋导致的CPU时钟浪费,或容忍休眠导致的响应性降低。(另一种方式是调用Thread.yield,让出当前的处理器。)

  • 14.1.2 通过轮询和休眠实现简单阻塞

  • 14.1.3 条件队列

    “条件队列”这个名字源于:它使得一组线程能够通过某种方式来等待特定的条件变成真的。传统队列的元素是一个个数据,而条件队列中的元素是一个个等待相关条件的线程。
    每个Java对象都可以作为一个条件队列,并且Object中的wait、notify、notifyAll构成了内部条件队列的API。对象的内置锁与其内部的条件队列相互关联,要调用对象X中条件队列的任意方法,必须持有对象X的锁。因为“等待由状态构成的条件”与“维护状态一致性”两种机制必须绑定:只有能对状态进行检查时,才能在某个条件上等待,并且只有能修改状态时,才能从条件等待中释放另一个线程。

    wait时自动释放锁意味着“我去休息了,但当某件特定事情发生时叫醒我”,而notify就是“特定事情”发生了。

14.2 使用条件队列

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

  • 14.2.1 条件谓词

    想要正确使用条件队列,关键是找出对象在哪个条件谓词上等待。

    条件谓词是指是某个操作成为状态依赖操作的前提条件。例如在有界缓存中,只有当缓存不为空时,take才能执行,否则必须等待,对take方法而言,它的条件谓词就是“缓存不为空”,take方法在执行之前必须首先测试该条件谓词。

    在条件等待中存在一种重要的三元关系,包括加锁、wait方法和一个条件谓词。在条件谓词中包含多个由同一个锁保护的状态变量,因此在测试条件谓词之前必须持有这个锁。

    每一次wait调用都会隐式地与特定的条件谓词关联起来,当调用某个特定条件谓词的wait时,调用者必须已经持有与条件队列相关的锁,并且这个锁必须保护着构成条件谓词的状态变量。

  • 14.2.2 过早唤醒

    内置条件队列可以与多个条件谓词一起使用,当一个线程由于调用notifyAll而醒来时,并不意味该线程正在等待的条件谓词已经变成真了(由于与多个条件谓词共享)。

    当执行控制重新进入调用wait的代码时,他已经获取了条件队列相关联的锁,但是可能在发出通知的线程调用notifyAll时,条件谓词可能已经变成真,但重新获取锁时又变为假。在线程被唤醒再到wait重新获取锁的这段时间里,可能有其他线程已经修改了对象的状态。

    当使用条件等待时(Object.wait或Condition.await):

    • 通常都有一个条件谓词 —— 包括一些对象状态的测试,线程在执行前必须先通过测试。
    • 在调用wait之前测试条件谓词,并且从wait中返回时再次测试。
    • 在一个循环中调用wait。
    • 确保使用与条件队列相关的锁来保护构成条件谓词的各个状态变量。
    • 当调用wait、notify或notifyAll等方法时,一定要持有与条件队列相关的锁。
    • 在检查条件谓词之后以及开始执行相应的操作之前,不要释放锁。
  • 14.2.3 丢失的信号

    线程等待一个已经为真的条件,但在开始等待前没有检查条件谓词,线程等待一个已经发生过的事件。就像你使用公司的微波炉加热午餐,你回到座位玩手机,当微波炉铃响之后,你没听到,你可能会等待很久。

  • 14.2.4 通知

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

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

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

    在使用条件通知或者单次通知时,一些约束条件使得子类化过程更加复杂。要想支持子类化,那么设计师类时需要保证:如果实施子类化时违背了条件通知或单次通知的某个需求,那么在子类中可以增加合适的通知机制来代表基类。

    对于状态依赖的类,要么将其等待和通知等协议完全向子类公开,要么完全组织子类参与到等待和通知等过程中。

    另一种选择是禁止子类化或隐藏条件队列、锁、状态变量等。

  • 14.2.7 封装条件队列

    通常建议将条件队列封装起来,因此除了使用条件队列的类,就不能在其他地方访问,否则调用者可能会采用一种违背设计的方式来使用条件队列。

    不幸的是,“封装条件队列”这个建议,与线程安全类常用的"使用内置锁保护自身对象状态"建议并不一致。

  • 14.2.8 入口协议与出口协议

    对于每个依赖状态的操作,以及每个修改其他操作依赖状态的操作,都应该定义一个入口协议和出口协议。入口协议就是该操作的条件谓词,出口协议则包括,检查被该操作修改的所有状态变量,并确认它们时候使某个其他条件谓词变为真,如果是则通知相关条件队列。

14.3 显式的Condition对象

一个Condition可以和一个Lock关联在一起,就像一个条件队列和一个内置锁相关联一样。在Condition中与wait、notify、notifyAll相对应的方法分别是await、signal、signalAll,由于Condition继承了Object因此也有wait、notify、notifyAll方法,但一定要确保使用了正确的await和signal。

与内置锁和条件队列一样,使用显式的Lock和Condition时,也必须满足锁、条件谓词和条件变量之间的三元关系。在条件谓词中包含的变量必须由Lock来保护,并且在检查条件谓词以及调用await和signal时,必须持有Lock对象

14.5 AbstractQueuedSynchronizer

在基于AQS构建的同步器类中,最基本的操作包括各种形式的获取操作和释放操作。获取操作是一种依赖状态的操作,并且通常会阻塞。当使用锁或信号量时,“获取”操作的含义就很直观,即获取的是锁或者许可,并且调用这可能会一直等待直到同步器类处于可被获取的状态。在CountDownLatch中,“获取“操作意味着”等待并直到闭锁到达结束状态“,而在FutureTask中,则意味着”等待并直到任务已经完成“。

如果一个类想称为状态依赖的类,那么它必须拥有一些状态。AQS负责管理同步器中的状态,他管理了一个整数状态信息,可以通过getState、setState、compareAndSetState等方法进行操作。这个整数可以表示任意状态。例如,ReentrantLock中表示所有者线程重复获取该锁的次数,Semaphore用它表示剩余的许可数量,FutureTask用它表示任务的状态。

如果同步器支持独占获取操作,那么需要实现一些保护方法tryAcquire、tryRelease和isHeldExclusively等,对于支持共享获取的同步器应该实现tryAcquireShared和tryReleaseShared等方法。

14.6 java.util.concurrent同步器类中的AQS

  • 14.6.1 ReentrantLock

    只支持独占的获取操作,因此实现了tryAcquire、tryRelease和isHeldExclusively,

  • 14.6.2 Semaphore和CountDownLatch

    tryAcquireShared方法首先计算剩余许可数量,如果没有足够的许可,将会返回一个值表示操作失败。如果还有剩余的许可那么会通过compareAndSetState方法降低许可计数。

  • 14.6.3 FutureTask

  • 14.6.4 ReentrantReadWriteLock

    使用一个16位的状态表示写入锁的计数,并使用另一个16为的状态表示读取锁的计数。在读取锁上的操作将使用共享的获取方法和释放方法,在写入锁上的操作将使用独占的获取方法和释放方法。

    AQS在内部维护了一个等待线程队列,其记录了某个线程是独占还是共享访问。在ReentrantReadWriteLock中,如果位于头部的线程执行写入操作,那么线程会得到这个锁,如果位于头部的线程执行读取访问,那么队列中在第一个写入线程之前的所有线程都将获得这个锁。

十五、原子变量与非阻塞同步机制

15.1 锁的劣势

许多现代的JVM都对非竞争锁获取和锁释放等操作进行了极大的优化,但如果有多个线程同时请求锁,那么需要借助操作系统的功能。那么一些线程将被挂起并且在稍后运行。当线程恢复时,必须等待其他线程执行完它们的时间片后才能被调度执行。在挂起和恢复的过程中存在很大的开销,以及长时间的中断。

当一个线程在等待锁时,它不能做其他任何事情。

15.2 硬件对并发的支持

独占锁是一种悲观的技术,它假设最坏的情况,只有确保其他线程不干扰,才能执行下去。
比较并交换是一种乐观的技术,这种方法需要借助冲突检查机制来判断更新的过程中是否存在来自其他线程的干扰,如果存在这个操作将失败,并且可以重试。

  • 15.2.1 比较并交换

    CAS包含三个操作数,需要读写的内存位置、进行比较的值A、拟写入的新值。

    当多个线程尝试使用CAS同时更新一个变量时,只有一个线程能更新变量的值其他都将失败,失败的线程不会被挂起,而是被告知在竞争中失败,并且可以再次尝试。

  • 15.2.2 非阻塞的计数器

    CAS的主要缺点是,它让调用者处理竞争问题(重试、回滚、放弃),而锁可以自动处理竞争问题。

  • 15.2.3 JVM对CAS的支持

    在支持CAS的平台,JVM将CAS操作编译为相应的多条机器指令。在不支持CAS的瓶体啊,JVM将使用自旋锁。

15.3 原子变量类

原子变量比锁的粒度更细,量级更轻,并且对于在多个处理器系统上实现高性能的并发代码是非常关键的。

  • 15.3.1 原子变量是一种“更好的volatile”

  • 15.3.2 性能比较:锁与原子变量

    中低程度的竞争下原子变量能提供更高的可伸缩性,而在高强度的竞争下,锁能够更有效地避免竞争。

15.4 非阻塞算法

如果某种算法中,一个线程的失败或挂起不会导致其他线程失败或挂起,那么就称为非阻塞算法。如果算法中每个步骤都存在某个线程能够执行下去,那么也称为无锁算法。如果算法中仅将CAS用于协调线程之间的操作,并且能够正确实现,那么它即是非阻塞算法,也是无锁算法。

  • 15.4.1 非阻塞的栈

    非阻塞算法通常比基于锁的算法更为复杂。

  • 15.4.2 非阻塞的链表

    原书记录了一种巧妙的方式,解决同时更新两个原子变量并保证安全的方法。

  • 15.4.3 原子的域更新器

    通过反射来更新值的原子域(字段)更新器

  • 15.4.4 ABA问题

    如果CAS中V的值首先由A变为B,再从B变成A,可能就会出现异常情况,CAS无法得知当前V是否发生过改变。

    ABA问题可以使用AtomicStampedReference以及AtomicMarkableReference支持原子的条件更新。AtomicStampedReference将更新一个“对象-引用”二元组,通过在引用上加上“版本号”,从而避免ABA问题。

    AtomicMarkableReference将更新一个“对象引用-布尔值”二元组。

十六、Java内存模型


Comment