【读书笔记】Java多线程编程实战指南(设计模式篇)

第1章 Java多线程编程实战基础

1.1 无处不在的线程

  进程(Process)代表运行中的程序,一个运行的Java程序就是一个进程。从操作系统的角度来看,线程(Thread)是进程中可独立执行的子任务。一个进程可以包含多个线程,同一个进程中的线程共享该进程所申请到的资源,如内存空间和文件句柄等。

  Java程序中任何一段代码总是执行在某个确定的线程中的。JVM启动的时候会创建一个main线程,该线程负责执行Java程序的入口方法(main方法)。

  在多线程编程中,弄清楚一段代码具体是由哪个(或者哪种)线程去负责执行的这点很重要,这关系到性能问题、线程安全问题等。

  Java中的线程可以分为守护线程(Daemon Thread)和用户线程(User Thread)。用户线程会阻止JVM的正常停止(用户线程调用System.exit,而非强行停止JVM,如Linux下kill命令停止Java进程),即JVM正常停止前应用程序中的所有用户线程必须先停止完毕;否则JVM无法停止。而守护线程则不会影响JVM的正常停止,即应用程序中有守护线程在运行也不影响JVM的正常停止。因此,守护线程通常用户执行一些重要性不是很高的任务,例如用于监视其他线程的运行情况。

1.2 线程的创建与运行

  在Java语言中,一个线程就是一个java.lang.Thread类的实例。因此,在Java语言中创建一个线程就是创建一个Thread类的实例,当然这离不开内存的分配。创建一个Thread实例与创建其他类的实例所不同的是,JVM会为一个Thread实例分配两个调用栈(Call Stack)所需的内存空间。这两个调用栈一个用户跟踪Java代码间的调用关系,另一个用户跟踪Java代码对本地代码(即Native代码,通常是C代码)的调用关系。

  一个Thread实例通常对应两个线程。一个是JVM中的线程(或称之为Java线程),另一个是与JVM中的线程相对应的依赖于JVM宿主机(即运行JVM的主机)操作系统的本地(Native)线程。线程启动后,当相应的线程被JVM的线程调度器调度到运行时,相应Thread实例的run方法会被JVM调用。

  Java语言中,子线程是否是一个守护线程取决于其父线程:默认情况下父线程是守护线程则子线程也是守护线程,父线程是用户线程则子线程也是用户线程。当然,父线程在创建子线程后,启动子线程之前可以调用Thread实例的setDaemon方法来修改线程的这一属性。

1.3 线程的状态与上下文切换

  线程的五种状态:新建(New)、运行(Runnable,包含两种状态:Ready、Running)、阻塞(Blocked)、等待(包含两种状态:Waiting、Timed_waiting)、结束(Terminated)。

  多线程环境中,当一个线程的状态由Runnable转换为非Runnable(Blocked、Waiting或者Timed_waiting)时,相应线程的上下文信息(即所谓的Context,包括CPU的寄存器和程序计数器在某一时间点的内容等)需要被保存,以便相应的线程稍后再次进入Runnable状态是能够在之前的执行进度的基础上继续前进。而一个线程的状态由非Runnable进入Runnable状态时可能涉及恢复之前保存的线程上下文信息并在此基础上前进。这个对线程的上下文信息进行保存和恢复的过程就被称为上下文切换

  上下文切换会带来额外的开销,这包括保存和回复线程上下文信息的开销、对线程进行调度的CPU时间开销以及CPU缓存内容失效(即CPU的L1 Cache、L2 Cache等)的开销。

  在Linux平台下使用perf命令、Windows平台使用perfmon来监视Java程序运行过程中的上下文切换情况。

1.4 线程的监视

  JDK自带的工具:jvisualvm,jstack,Java Mission Control(JMC)

1.5 原子性、内存可见性和重排序——重新认识synchronized和volatile

  原子(Atomic)操作指相应的操作是单一不可分割的操作。比如count++就不是一个原子操作,这是因为count++司机分解为3个操作:1)读取变量count的当前值,2)拿count的当前值和1做加法操作,3)将增加后的值赋值给count变量。在多线程环境下,非原子操作可能会受到其他线程的干扰。

  synchronized关键字可以实现操作的原子性,其本质是通过该关键字说包括的临界区(Critical Section)的排他性保证在任何一个时刻只有一个线程能够执行临界区中的代码,这使得临界区中的代码代表了一个原子操作。

  synchronized关键字的另外一个作用就是它保证了一个线程执行临界区中的代码时所修改的变量值对于稍后执行该临界区中的代码的线程来说是可见的。这对于保证多线程代码的正确性来说非常重要。

  volatile关键字也能保证内存可见性。即一个线程对一个采用volatile关键字修饰的变量的值得更改对于其他访问该变量的线程而言重视可见的。因此,有人将volatile关键字与synchronized关键字所代表的内部锁做比较,将其称为轻量级的锁,这种称呼其实并不恰当。volatile关键字能保证内存可见性,它并不能像synchronized关键字所代表的内部锁那样能够保证操作的原子性。volatile关键字实现内存可见性的核心机制是当一个线程修改了一个volatile修饰的变量的值时,该值会被写入主内存(RAM)而不仅仅是当前线程所在的CPU的缓冲区,而其他CPU的缓冲区中存储的该变量的值也会因此而失效(从而得以更新为主内存中该变量的相应值)(Java内存模型)。

  volatile关键字的另外一个作用是它禁止了指令重排序(Re-order)。

  与synchronized相比,前者既能保证操作的原子性,又能保证内存的可见性。而volatile仅能保证内存的可见性。但是前者会导致上下文切换,而后者不会。

1.6 线程的优势和风险

多线程编程具有以下优势:

  • 提高系统的吞吐率(Throughput)
  • 提高响应性(Responsiveness)
  • 充分利用多核(Multicore)CPU资源
  • 最小化对系统资源的使用
  • 简化程序的结构

多线程编程的问题与风险:

  • 线程安全(Thread Safe)问题
  • 线程的生命特征(Thread Liveness)问题
  • 上下文切换问题
  • 可靠性

1.7 多线程编程常用术语

  任务、并发、并行、客户端线程、工作者线程、上下文切换、显示锁、线程安全。

第2章 设计模式简介

  设计模式与设计模式之间不是孤立的。在解决实际问题的过程中,我们往往需要综合使用多个设计模式,而不是单靠某个设计模式。

  设计模式是一种可复用的设计方案,它并不是可以直接使用的代码。

  设计模式可以为我们解决设计问题提供可借鉴的成熟解决方案。设计模式是在广泛实践的基础上提炼出来的设计方案。

  设计模式可以作为描述软件架构的一种方式。设计模式构建了开发人员之间阐述和沟通设计方案的词汇。因此它自然可以用来描述软件的架构。

第7章 Producer-Consumer(生产者/消费者)模式

7.1 模式简介

  • 生产者:数据提供方。

  • 消费者:数据加工方。

  • 通道:Channel,可以简单理解为一个队列,对二者进行解耦(Decoupling),避免数据生产者和消费者中处理速率快的一方需要等待处理速率慢的一方。

  Producer-Consumer模式可以看成是设计模式的设计模式。许多设计模式都可以看作是Producer-Consumer模式的一个实例。

7.4 Producer-Consumer模式的评价与实现考量

7.4.1 通道积压

  消费者的处理能力低于生产者的处理能力。

  • 使用有界阻塞队列
  • 使用带流量控制的无界阻塞队列

7.4.2 工作窃取算法

  一个通道实例对应多个队列实例的时候,当一个消费者线程处理完该线程对应的队列中的“产品”时,它可以继续从其他消费者线程对应的队列中取出“产品”进行处理,这样就不会导致该消费者线程闲置,并减轻其他消费者线程的负担。这就是工作窃取算法(Work Stealing)算法的思想。

7.4.3 线程的停止

  可以借助Two-phase Termination模式来先停止producer参与者的工作者线程。当某个服务的所有Producer参与者的工作者线程都停止之后,再停止该服务涉及的Consumer参与者的工作者线程。

7.4.4 高性能高可靠性的Producer-Consumer模式实现

  LMAX Disruptor

7.5 Producer-Consumer模式的可复用实现代码

  java.util.concurrent.ThreadPoolExecutor可以看成是Producer-Consumer模式的可复用实现。

7.6 Java标准库实例

  Java标准库中的类java.io.PipedOutStream和java.io.PipedInputStream允许一个线程以I/O的形式输出数据给另外一个线程。

  • 生产者:java.io.PipedOutStream
  • 消费者:java.io.PipedInputStream
  • Channel参与者:java.io.PipedOutStream内部维护的缓冲区

第9章 Thread Pool(线程池)模式

9.1 模式简介

  一个系统中的线程相对于其所要处理的任务而言,总是一种非常有限的资源。线程不仅在其执行任务时需要消耗CPU时间和内存等资源,线程对象(Thread实例)本身以及线程所需要的调用栈(Call Stack)也占用内存。并且Java中创建一个线程往往意味着JVM会创建相应的依赖于宿主机操作系统的本地线程(Native Thread)。因此,为每个或者每一批任务创建一个线程以对其进行执行,通常是一种奢侈而不现实的事情。比较常见的一种做法是复用一定数量的线程,由这些线程去执行不断产生的任务。绝大多数的Web服务器就是采用这种方法。例如Tomcat服务器复用一定数量的线程用于处理其接收到的请求。

  Thread Pool模式的核心思想是使用队列对待处理的任务进行缓存,并复用一定数量的工作者线程去取队列中的任务进行执行。Thread Pool模式的本质是使用极其有限的资源去处理相对无限的任务。

  JDK1.5引入的标准库类java.util.concurrent.ThreadPoolExecutor就是Thread Pool模式的一个实现。

9.4 Thread Pool模式的评价与实现考量

  • 节约线程这种有限而昂贵的资源
  • 抵消线程创建的开销,提高响应性
  • 封装了工作者线程的生命周期管理
  • 减少销毁线程的开销

9.4.1 工作队列的选择

  • 无界队列(Unbounded Queue):可能导致系统的不稳定,适合在任务占用的内存空间以及其他稀缺资源比较少的情况下使用
  • 直接交接队列(SynchronousQueue):使用SynchronousQueue作为工作队列,工作队列本身并不限制待执行的任务的数量。但此时需要限定线程池的最大大小为一个合理的有限值,而不是Integer.MAX_VALUE,否则可能导致线程池的工作者线程的数量一直增加到系统资源所无法承受为止。
  • 有界队列(Bounded Queue):有界队列适合在提交给线程池执行的各个任务之间是相互独立(而非有依赖关系)的情况下使用。

9.4.2 线程池大小调校

  合理的线程池大小取决于该线程池所要处理的任务的特性系统资源状况以及任务所使用的稀缺资源状况

  系统资源状况:系统CPU个数、JVM堆内存的大小。系统CPU个数可以通过调用java.lang.Runtime类的availableProcessors方法获取JVM宿主机CPU个数。假如使用Ncpu表示系统的CPU个数。

  任务的特性:任务是CPU密集型、I/O密集型,还是混合型。CPU密集型:Ncpu+1;I/O密集型:大于Ncpu的数字,比如2*Ncpu。另外,对于I/O密集型任务我们需要注意I/O操作会引起上下文切换,这就意味着进行I/O操作的线程越多,由I/O操作引起的上下文切换也就越多。因此,对于I/O密集型任务不妨将相应的线程池的核心线程池大小(Core Pool Size)设置为1,并将其最大线程池大小(Maximum Pool Size)设置为2*Ncpu。混合型任务:将任务进行相应的分解。

实际上,商用软件往往会规定某个软件在其运行过程中对CPU的使用率不能超过某个限定值(如75%)。

S=Ncpu*Ucpu*(1+WT/ST)

S:线程池合理大小,Ncpu:CPU个数,Ucpu:目标CPU使用率,WT为任务执行线程进行等待的时间,ST为任务执行线程使用CPU进行计算的时间。其中,WT和ST这个时间值可以借助工具(如jvisualvm)计算出对应的值。

另外,任务执行过程中使用到的一些稀缺资源,如数据库连接,也会对线程池的合理大小产生影响。

总而言之,设置线程池的合理大小不是一件能精确做到的事情。重要的是线程池的大小要可以配置,并且其配置值要考虑到系统可用CPU资源以及其他稀缺资源等因素。

9.4.3 线程池监控

线程池大小、工作队列的容量、线程空闲时间限制这些线程池的属性虽然我们可以通过配置的方式进行指定(而不是在代码中硬编码),但是所指定的值是否恰当就需要通过监控来判断。ThreadPoolExecutor类提供了对线程进行监控的相关方法。

9.4.4 线程泄露

线程泄露(Thread Leak)指线程池中的工作者线程意外中止,使得线程池中实际可用的工作者线程变少。

9.4.5 可靠性与线程池饱和处理策略

从线程池的客户端代码的角度来看,其为了提高计算的可靠性,必须考虑如何应对这种任务提交被拒绝的情形,即线程池饱和处理策略。

如果使用ThreadPoolExecutor.CallerRunsPolicy作为线程池饱和处理策略,需要注意它可能会引起线程安全问题。

实现类 所实现的处理策略
ThreadPoolExecutor.AbortPolicy 直接抛出异常
ThreadPoolExecutor.DiscardPolicy 丢弃当前被拒绝的任务(而不抛出任何异常)
ThreadPoolExecutor.DiscardOldestPolicy 将缓冲区中最老的任务丢弃,然后重新尝试接纳被拒绝的任务
ThreadPoolExecutor.CallerRunsPolicy 在客户端线程中执行被拒绝的任务

也可以采用其他支持补救的线程池饱和处理策略,比如人工重试的补救方法。但是如果采用无界队列作为线程池的工作队列,那么上述的线程池饱和处理策略就不适用了,因为这种情况下提交给线程池的任务总是可以被放入工作队列而不会被拒绝(除非ThreadPoolEXecutor实例已经被关闭)。

9.4.6 死锁

如果线程池中执行的任务在其执行过程中又会向同一个线程池提交另外一个任务,而前一个任务的执行结束又依赖于后一个任务的执行结果,那么当线程池中所有的线程都处于这种等待其他任务的处理结果,而这些线程所等待的任务仍然还在工作列队中的时候,由于线程池已经没有可以对工作队列汇中的任务进行处理的工作者线程,这种等待就会一直持续下去而形成死锁(DeadLock)。

因此,适合提交给同一个线程池实例执行的任务是相互独立的任务,而不是彼此有依赖关系的任务。

要执行彼此有依赖关系的任务可以考虑将不同类型的任务交给不同的线程池实例执行,或者对负责任务执行的线程池实例可以进行如下配置。

  • 配置1:设置线程池的最大大小为一个有限值,而不是默认值Integer.MAX_VALUE。
  • 配置2:使用SynchronousQueue作为工作队列。
  • 配置3:使用ThreadPoolExecutor.CallerRunsPolicy作为线程池饱和处理策略。

9.4.7 线程池空闲线程清理

线程池中长期处于空闲状态(即没有在执行任务)的工作者线程会浪费宝贵的线程资源。因此,清理一部分这样的线程可以节约有限的资源。ThreadPoolExecutor支持将其核心工作者线程以外的空闲线程进行清理。创建ThreadPoolExecutor实例的,我们可以在其构造器的第3、4个参数中指定一个空闲持续时间。

9.5 Thread Pool模式的可复用实现代码

Java标准库类java.util.concurrent.ThreadPoolExecutor就是Thread Pool模式的一个可复用的实现。利用ThreadPoolExecutor实现Thread Pool模式,应用代码只需要完成以下几件事情。

  1. 【必需】创建一个ThreadPoolExecutor实例。根据应用程序的需要,创建ThreadPoolExecutor实例时指定一个合适的线程池饱和处理策略。
  2. 【必需】创建Runnable实例用于表示待执行的任务,并调用ThreadPoolExecutor实例的submit方法提交任务。
  3. 【可选】使用ThreadPoolExecutor实例的submit方法返回值获取相应任务的执行结果。

9.6 Java标准库实例

Java Swing中类javax.swing.SwingWorker可用于执行耗时较长的任务。该类使用了Thread Pool模式。SwingWorker内部维护了一个线程池(ThreadPoolExecutor实例),该线程池包含了若干个工作者线程用于执行提交给SwingWorker的任务。

谢谢你请我吃糖果!
0%