继上一本《深入理解Java虚拟机》之后,学习计划里的另一本书《Java并发编程实战》现在开始学习,并记录学习笔记。
第一章主要内容是介绍 并发 的简介、发展、特点。
编写正确的程序很难,而编写正确的并发程序则难上加难。与串行程序相比,在并发程序中存在更多容易出错的地方。那么,为什么还要编写并发程序?线程是Java语言中不可或缺的重要功能,它们能使复杂的异步代码变得更简单,从而极大地简化了负责系统的开发。此外,要想充分发挥多处理器系统的强大计算能力,最简单的方式就是使用线程。随着处理器数量的持续增长,如何高效地使用并发正变得越来越重要。
1.1 并发简史
随着计算机的发展,在计算机中加入操作系统来实现多个程序的同时执行,主要是基于以下原因:
- 资源利用率:在某些情况下,程序必须等待某个外部操作执行完成,而在等待时程序无法执行其他任何工作。因此,在等待的同时可以运行另一个程序,这无疑将提高资源的利用率。
- 公平性:不同用户和程序对于计算机上的资源有着同等的使用权。一种高效的运行方式是通过粗粒度的时间分片(Time Slicing)使这些用户和程序能共享计算机资源,而不是由一个程序从头运行到尾,再启动下一个程序。
- 便利性:在计算多个任务时,应该编写多个程序,每个程序执行一个任务并在必要时相互通信,这比只编写一个程序来计算所有任务更容易实现。
在早期的分时系统中,每个进程相当于一台虚拟的冯`诺依曼计算机,根据机器语言的语义以串行的方式执行指令,对每条执行的指令,都有相应的“下一条指令”,程序中的控制流是按照指令集的规则来确定的。
线程允许在同一个进程中同时存在多个程序控制流。线程会共享进程范围内的资源,例如内存句柄和文件句柄,但每个线程都有各自的程序计数器(Program Counter)、栈以及局部变量等。
1.2 线程的优势
如果使用得当,线程可以有效地降低程序的开发和维护等成本,同时提升复杂应用程序的性能。
1.2.1 发挥多处理器的强大能力
多处理器系统的日益普及,是线程发展的重要因素。由于基本的调度单位是线程,因此如果在程序中只有一个线程,那么最多同时只能在一个处理器上运行。
使用多个线程还有助于在单处理器系统上获得更高的吞吐率。
1.2.2 建模的简单性
如果需要完成多种类型的任务,那么需要管理不同任务之间的优先级和执行时间,并在任务之间进行切换,这将带来额外的开销,对于线程同样如此。
我们可以通过一些现有的框架来实现多线程,例如 Servlet 和 RMI(Remote Method Invocation)。框架负责解决一些细节问题,例如请求管理、线程创建、负载平衡,并在正确的时刻将请求分发给正确的应用程序组件。
1.2.3 异步事件的简化处理
服务器应用程序在接受来自多个远程客户端的套接字连接请求时,如果为每个连接都分配其各自的线程并且使用同步 I/O ,那么就会降低这类程序的开发难度。
早期的操作系统通常会将进程中可创建的线程数量限制在一个较低的阈值内,线程创建数量受 操作系统 和 JVM 参数设置影响。
Java 类库需要获得一组实现非阻塞 I/O 的包(java.nio)。
1.2.4 响应更灵敏的用户界面
如果某事件线程中的任务需要很长的执行时间,例如对一个大型文档进行拼写检查,或者从网络上获取一个资源,那界面的响应灵敏度就会降低。如果用用在执行这类任务时触发了某个动作,那么必须等待很长时间才能获得响应,因为事件线程要先执行玩该任务。更糟糕的是,不仅仅界面失去了响应,而且即使在界面上包含了“取消”按钮,也无法取消这个长时间执行的任务,因为事件线程只有在执行完该任务后才能响应“取消”按钮的地址事件。然而,如果将这个长时间运行的任务放在一个单独的线程中运行,那么事件线程就能及时地处理界面事件,从而使用户具有更高的灵敏度。
1.3 线程带来的风险
Java 对线程的支持其实是一把双刃剑。当线程还是一项鲜为人知的技术时,并发性是一个“高深地”主题,但现在,主流开发人员都必须了解线程方面的内容。
1.3.1 安全性问题
线程安全性可能是非常复杂的,在没有充足同步的情况下,多个线程中的操作执行顺序是不可预测的,甚至会产生奇怪的结果。看下面的例子,在单线程可以正确工作,但在多线程环境下则不能。
public class UnsafeSequence { 【冷漠脸--非最优方式】
private int value; public int getValue() {
return value++; //返回当前数组,并自加
}
}
UnsafeSequence 的问题在于,如果执行时机不对,那么两个线程的调用getValue 时会得到相同的值。
实际上, value++ 包含了三个独立的操作:读取value,将value+1,并将计算结果写入 value。
由于运行时可能将多个线程之间的操作交替执行,因此这两个线程可能同时执行读操作,从而使他们得到相同的值,并且都自加1。
UnsafeSequence 类中说明的一种常见的并发安全问题,称为 竞态条件(Race Condition)。由于多个线程要共享相同的内存地址空间,并且是并发运行,因此他们可能会访问或修改其他线程正在使用的变量。当然,这是一种极大的便利,因为这种方式比其他 线程间通信机制更容易实现数据共享。但它同样也带来了巨大的风险:线程会由于无法预料的数据变化而发生错误。
对此,我们可以将 getValue() 方法修改为一个同步方法。
public class UnsafeSequence {
private int value; public synchronized int getValue() { //同步方法
return value++;
}
}
1.3.2 活跃性问题
安全性不仅对于多线程很重要,对于单线程程序同样重要。线程还会导致一些在单线程程序中不会出现的问题,例如活跃性问题。
安全性的含义是“永远不会发生糟糕的事情”,而活跃性则关注于另一个目标,即“某件正确的事情最终会发生”。
当某个操作无法继续执行下去时,就会发生活跃性问题。在串行程序中,活跃性问题的形式之一就是无意中造成的无限循环,从而使循环之后的代码无法得到执行。
1.3.3 性能问题
性能问题包括多个方面,例如服务时间过长,响应不灵敏,吞吐率过低,资源消耗过高,或者可伸缩性较低等。与安全性和活跃性一样,在多线程程序中不仅崔仔与单线程相同的性能问题,而且还存在由于使用线程而引入的其他性能问题。
在涉及良好的并发应用程序中,线程能提高程序的性能,但无论如何,线程总会带来某种程序的运行时开销。在多线程程序中,当线程调度器临时挂起活跃线程并转而运行另一个线程时,就会频繁地出现上下文切换操作(Context Switch),这种操作将带来极大 的开销:保存和恢复执行上下文,丢失局部性,并且CPU 时间将更多地花在线程调度而不是线程运行上。当线程共享数据时,必须使用同步机制,而这些机制往往会抑制某些编译器优化,使内存缓存区中的数据无效,以及增加共享内存总线的同步流量。
1.4 线程无处不在
即使在程序中没有显式地创建线程,但在框架中仍可能会创建线程,因此在这些线程中调用的代码同样必须是线程安全的。
每个 Java 应用程序都会使用线程。当JVM 启动时,它将为 JVM 内部任务(例如,垃圾收集、终结操作等)创建后台线程,并创建一个主线程来运行 main 方法。AWT(Abstract Window Toolkit)和Swing 的用户界面框架将创建线程来管理用户界面事件。Timer 将创建线程来执行延迟任务。一些组件框架,例如 Servlet 和 RMI,都会创建线程池并调用这些线程中的方法。