Skip to content

Latest commit

 

History

History
375 lines (274 loc) · 19.6 KB

Java并发编程实战笔记.md

File metadata and controls

375 lines (274 loc) · 19.6 KB

Java 并发编程实战笔记

第 1 章 简介

1.1 并发简史

1.2 线程的优势

  • 1.2.1 发挥多处理器的强大能力
  • 1.2.2 建模的简单性
  • 1.2.3 异步事件的简化处理
  • 1.2.4 响应更灵敏的用户界面

1.3 线程带来的风险

  • 1.3.1 安全性问题
  • 1.3.2 活跃性问题
  • 1.3.3 性能问题

1.4 线程无处不在

第一部分 基础知识

第 2 章 线程安全性

线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的和可变的状态的访问。对象的状态是指存储在状态变量中的数据。对象的状态可能包括其他依赖对象的域。共享意味着变量可由多个线程同时访问,而可变意味着变量的值在其生命周期内可以发生变化。要使得对象是线程安全的,需要同步机制来协同对对象可变状态的访问。

2.1 什么是线程安全性

当多个线程访问某各类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。

无状态对象一定是线程安全的。

2.2 原子性

  • 竞态条件(Race Condition):由于不恰当的执行时序而出现不正确的结果。
    • 大多数竞态条件的本质:基于一种可能失效的观察结果来做出判断或者执行某个计算。这种类型的竞态条件称为先检查后执行。

2.3 加锁机制

要保证状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。

Java 提供了一种内置的锁机制来支持原子性:synchronized。

每个 Java 对象都可以用作一个实现同步的锁,这些锁被称为内置锁或监视锁。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时会自动释放锁。

某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。

2.4 用锁来保护状态

每个共享的和可变的变量都应该只由一个锁来保护。

对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。

并非所有数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护。

虽然 synchronized 方法可以确保单个操作的原子性,但如果要把多个操作合并为一个复合操作,还是需要额外的加锁机制。

2.5 活跃性与性能

不要盲目地为了性能而牺牲简单性。

当执行时间较长的计算或可能无法快速完成的操作时(如,网络 I/O 或控制台 I/O),一定不要持有锁。

第 3 章 对象的共享

3.1 可见性

  • 在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。
  • 内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果。
  • 加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。
  • volatile 变量是一种稍弱的同步机制。
  • 当把变量声明为 volatile 类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile 变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取 volatile 类型的变量时总会返回最新写入的值。
  • 访问 volatile 变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此 volatile 变量是一种比 synchronized 关键字更轻量级的同步机制。
  • 仅当 volatile 变量能简化代码的实现以及对同步策略的验证时,才应该使用它们。
  • 加锁机制既可以确保可见性又可以确保原子性,而 volatile 变量只能确保可见性。
  • 当且仅当满足以下所有条件时,才应该使用 volatile 变量
    • 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
    • 该变量不会与其他状态变量一起纳入不变性条件中。
    • 在访问变量时不需要加锁。

3.2 发布与逸出

3.3 线程封闭

  • 栈封闭:只能通过局部变量才能访问对象。
  • ThreadLocal 类能使线程中的某个值与保存值得对象关联起来。ThreadLoacl 提供了 get 与 set 等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此 get 宗师返回由当前执行线程在调用 set 时设置的最新值。
  • ThreadLocal 对象通常用于防止对可变的单实例变量或全局变量进行共享。

3.4 不变性

  • 不可变对象一定是线程安全的。
  • 满足以下条件的对象才是可变的:
    • 对象创建以后其状态就不能修改。
    • 对象的所有域都是 final 类型。
    • 对象是正确创建的(在对象的创建期间,this 引用没有逸出)。

3.5 安全发布

  • 在没有额外的同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。
  • 对于可变对象,不仅在发布对象时需要使用同步,并且在每次对象访问时同样需要使用同步来确保后续修改操作的可见性。
  • 在并发程序中使用和共享对象时,可以使用一些实用的策略,包括:
  • 线程封闭。线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。
  • 只读共享。在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问。
  • 线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的共有接口来进行访问而不需要进一步的同步。
  • 保护对象。被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。

第 4 章 对象的组合

4.1 设计线程安全的类

  • 设计线程安全类的过程中,需要包含三个基本要素:
    • 找出构成对象状态的所有变量。
    • 找出约束状态变量的不变性条件。
    • 建立对象状态的并发访问管理策略。
  • 类的不变性条件与后验条件约束力在对象上有哪些状态和状态转换是有效的。

4.2 实例封闭

  • 将数据封装在对象内部,可以将数据的访问限制在对象的方法上,从而更易确保线程在访问数据时总能持有正确的锁。
  • 封闭机制更易于构造线程安全的类,因为当封闭类的状态时,在分析类的线程安全性时就无需检查整个程序。

4.3 线程安全性的委托

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

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

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

4.5 将同步策略文档化

第 5 章 基础构建模块

5.1 同步容器类

  • 包括 Vector 和 Hashtable。
  • 实现线程安全的方式是:将它们的状态封装起来,并对每个公有方法都进行同步,使得每次只有一个线程能访问容器的状态。
  • 同步容器类的问题
    • 迭代,如果有其他线程并发修改容器,会抛出 ConcurrentModificationException
    • 跳转
    • 条件运算
  • 解决方法
    • 在客户端额外加锁来保护复合操作。

5.2 并发容器

  • 包括 ConcurrentHashMap、CopyOnWriteArrayList、CopyOnWriteArraySet。
  • ConcurrentHashMap 用于替代同步 HashMap。
  • ConcurrentHashMap 的实现机制是分段锁。在这种机制中,任意数量的读线程可以并发访问 Map,一定数量的写线程可以并发修改 Map。
  • ConcurrentHashMap 在并发访问环境下实现了更高的吞吐量,而在单线程环境中只损失非常小的性能。
  • ConcurrentHashMap 不同于同步容器,不需要再迭代过程中对容器加锁。
  • CopyOnWriteArrayList 用于替代同步 List。CopyOnWrite 意味着写入时复制,即在每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。写入时复制容器的迭代器保留一个指向底层基础数组的引用,这个数组当前位于迭代器的起始位置,由于它不会被修改,因此在对齐进行同步时只需确保数组内容的可见性。

5.3 BlockingQueue

  • BlockingQueue 提供了可阻塞的 put 和 take 方法,以及支持定时的 offer 和 poll 方法。如果队列满了,那么 put 方法将阻塞直到有空间可用;如果列队为空,那么 take 方法将阻塞到有元素可用。队列可以是有界的或无界的,无界队列永远不会填满,所以无界队列的 put 方法永远不会阻塞。
  • BlockingQueue 支持生产者-消费者模式。
  • BlockingQueue 的实现:LinkedBlockingQueue 和 ArrayBlockingQueue 是 FIFO 队列。PriorityBlockingQueue 是一个按优先级排序的队列。
  • SynchronousQueue 维护一组线程,这些线程在等待着把元素加入或移出队列。因为 SynchronousQueue 没有存储功能,因此 put 和 take 会一直阻塞,直到有另一个线程已经准备好参与到交付过程中。仅当有足够多的消费者,并且总是有一个消费者准备好获取交付的工作时,才适合使用同步队列。

5.4 阻塞方法与中断方法

  • 线程阻塞的原因:等待 I/O 操作结束;等待获得一个锁;等待从 Thread.sleep 方法中唤醒;等待另一个线程的计算结果。
  • Thread 提供了 interrupt 方法,用于中断线程或查询线程是否已经被中断。每个线程都有一个 boolean 型的属性,表示线程的中断状态,当中断线程时将设置这个状态。
  • 中断是一种协作机制,一个线程不能勉强其他线程停止正在执行的操作而去执行其他的操作。
  • InterruptException 处理
    • 传递 InterruptException
    • 恢复中断

5.5 同步工具类

TODO:

  • CountDownLatch
  • FutureTask
  • Semaphore
  • CyclicBarrier

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

第二部分 结构化并发应用程序

第 6 章 任务执行

6.1 在线程中执行任务

  • 在服务器应用中,串行处理机制通畅都无法提高吞吐率或快速响应性。
  • 为每个任务分配一个线程存在缺陷,尤其是当需要创建大量线程时:
    • 线程生命周期的开销非常高。
    • 资源消耗
    • 稳定性

6.2 Executor 框架

  • Executor 基于生产者-消费者模式,提交任务的操作相当于生产者,执行任务的线程则相当于消费者。
  • 如果要在程序中实现一个生产者-消费者设计,那么最简单的方式通常就是使用 Executor。
  • Executors 提供了一些系列静态方法来创建线程池。
    • newFixedThreadPool - 创建一个固定长度的线程池,每当提交一个任务时就创建一个线程,直到达到线程池的最大数量,这时线程池的规模将不再变化。
    • newCachedThreadPool - 创建一个可缓存的线程池,如果线程池规模超过了处理需求时,将回收空闲的线程;而当需求增加时,可以添加新的线程,线程池规模不存在任何限制。
    • newSingleThreadExecutor - 单线程的 Executor。
    • newScheduledThreadPool - 创建一个固定长度的线程池,而且以定时的方式执行任务。
  • Executors 扩展了 ExecutorService 接口,添加了一些用于生命周期管理的方法。
  • ExecutorService 的生命周期有 3 种状态:运行、关闭和终止。
  • shutdown 方法将执行平缓的关闭过程:不再接受新的任务,同时等待已经提交的任务执行完成。shutdownNow 将粗暴的关闭:尝试取消所有运行中的任务,并且不再启动队列中尚未开始执行的任务。
  • Timer 存在缺陷:执行所有定时任务时只会创建一个线程;Timer 并不捕获异常,如果 Timertask 抛出一个未检查的异常将终止定时线程。
  • 应该考虑 ScheduledThreadPoolExecutor 来代替 Timer。

第 7 章 取消与关闭

Java 没有提供任何机制来安全地终止线程。但它提供了中断,这是一种写作机制,能够使一个线程终止另一个线程的当前工作。 interrupt 并不会真正地中断一个正在运行的线程,而只是发出中断请求,然后由线程在下一个合适的时刻中断自己。

7.1 任务取消

  • 响应中断(处理 InterruptedException)的两种策略:
    • 传递异常
    • 恢复中断状态

第 8 章 线程池的使用

第 9 章 图形用户界面应用程序

Swing 什么鬼?略

第三部分 活跃性、性能与测试

第 10 章 避免活跃性危险

  • 什么是死锁 - 多个线程为了竞争资源,相互阻塞。
  • 如何避免死锁 - 定时锁,为锁指定超时时间。

第 11 章 性能与可伸缩性

  • 线程引入的开销
    • 上下文切换
    • 内存同步
    • 阻塞
  • 影响在锁上发生竞争的可能性:锁的请求频率;每次持有该锁的时间。
  • 降低锁的竞争强度的方法:
    • 减少锁的持有时间。
    • 降低锁的请求频率。
    • 使用带有协调机制的独占锁。
  • 减小锁的范围可以减少锁的持有时间。例:本来同步整个方法的,改为同步某个代码段。
  • 减少锁的粒度可以降低线程请求锁的频率。可以通过锁分解和锁分段等技术来实现,在这些技术中将采用多个相互独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。这些技术能减少锁操作的粒度,并能实现更高的可伸缩性,然而,使用的锁越多,那么发生死锁的风险也就越高。
  • 替代独占锁的方法:并发容器、读写锁、原子操作、不可变对象。

第 12 章 并发程序的测试

12.1 正确性测试

  • 12.1.1 基本的单元测试
  • 12.1.2 对阻塞操作的测试
  • 12.1.3 安全性测试
  • 12.1.4 资源管理的测试
  • 12.1.5 使用回调
  • 12.1.6 产生更多的交替操作

12.2 性能测试

  • 12.2.1 在 PutTakeTest 中增加计时功能
  • 12.2.2 多种算法的比较
  • 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 章 显式锁

Lock 原型:

public interface Lock {
    void lock();
    void lockInterruptibly() throws InterruptedException;
    boolean tryLock();
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    void unlock();
    Condition newCondition();
}
  • ReentrantLock 实现了 Lock 接口,并提供了与 synchronized 相同的互斥性和内存可见性。
  • 轮询锁和定时锁时由 tryLock 方法实现的。
  • synchronized vs. ReentrantLock
  • ReentrantLock 的优点:可定时、可轮询、可中断、公平队列。
  • 读写锁的应用场景;一个资源可以被多个读操作访问,或者被一个写操作访问,但两者不能同时进行。

第 14 章 构建自定义的同步工具

第 15 章 原子变量与非阻塞同步机制

  • 锁的劣势
  • 使用锁在挂起和恢复线程等过程中存在着很大的开销,并且通常存在着较长时间的中断。
  • 当一个线程在等待锁时,它不能做任何其他事。
  • CAS
    • CAS 包含了 3 个操作数——需要读写的内存位置 V、进行比较的值 A 和将要写入的新值 B。当且仅当 V 的值等于 A 时,CAS 才会通过原子方式用新值 B 来更新 V 的值,否则不会执行任何操作。无论位置 V 的值是否等于 A,都将返回 V 原有的值。
    • CAS 的含义是:我认为 V 的值应该为 A,如果是,那么将 V 的值更新为 B,否则不修改并告诉 V 的值实际为多少。
    • CAS 的主要缺点是,它将使调用者处理竞争问题,而在锁中能自动处理竞争问题。
  • 原子变量类
    • 原子变量比锁的粒度更细,量级更轻,并且对于在多处理器系统上实现高性能的并发代码来说非常关键。
  • 非阻塞算法
    • 一个线程的失败或挂起不会导致其他线程也失败或挂起,那么这种算法就称为非阻塞算法。

第 16 章 Java 内存模型

  • 重排序
  • Happens-Before