欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

Java 中的并发编程》,第 1 章 - 引言 - 读者笔记

最编程 2024-07-16 16:02:05
...
【直播预告】程序员逆袭 CEO 分几步?

1.1 并发简史

    资源利用率:在某些情况下,程序必须等待某个外部操作执行完成,例如输入操作或输出操作等,而在等待时程序无法执行其他任何工作。因此,如果在等待的同时可以运行另一个程序,那么无疑将提高资源的利用率。
    公平性:不同的用户和程序对于计算机上的资源有着同等的使用权。一种高效的运行方式是通过粗颗粒度的时间分片(Time Slicing)使这些用户的程序能共享计算机资源,而不是由一个程序从头运行到尾,然后再启用下一个程序。

    便利性:通常来说,在计算多个任务时,应该编写多个程序,每个程序执行一个任务并在必要时互相通信,这比只编写一个程序来计算所有任务更容易实现。

1.2 线程的优势

    如果使用得当,线程可以有效地降低程序的开发和维护成本,同时提升复杂应用程序的性能。线程能够将大部分的异步工作流转换成串行工作流,因此能更好地模拟人类的工作方式和交互方式。此外,线程还可以降低代码的复杂度,使代码更容易编写、阅读和维护。

    在GUI(Graphic User Interface,用户图形界面)应用程序中,线程可以提高用户界面的响应灵敏度,而在服务器应用程序中,可以提升资源利用率以及系统吞吐率。线程还可以简化JVM的实现,垃圾收集器通常在一个或多个专门的线程中运行。在许多重要的Java应用程序中,都在一定程度上用到了线程。

1.2.1 发挥多处理器的强大能力

    过去,多处理器系统的非常昂贵和稀少的。但现在,多处理器系统日益普及,并且价格也不断地降低,即使在低端服务器和中端桌面系统,通常也会采用多个处理器。

    由于基本的调度单位是线程,因此如果在系统中只有一个线程,那么最多同时只能在一个处理器上运行。在双处理器系统上,单线程的程序只能使用一半的CPU次元,而在拥有100个处理器的系统上,将有99%的资源无法使用。另一方面,多线程程序可以同时在多个处理器上执行。如果设计正确,多线程程序可以通过提高处理器资源的利用率来提升系统的吞吐率。

    使用多个线程还有助于在单处理器系统上获得更高的吞吐率。如果程序是单线程的,那么当程序等待某个同步I/O操作完成时,处理器将处于空闲状态。而在多线程程序中,如果一个线程在等待I/O操作完成,另一个线程可以继续运行,使程序能够在I/O阻塞期间继续运行。

1.2.2 建模的简单性

    通常,当只需要执行一种类型的任务时,在时间管理方面比执行多种类型的任务要简单。当只有一种类型的任务需要完成时,只需要埋头工作,知道完成所有任务,你不需要花任何经理来琢磨下一步该做什么。

    例如Servlet和RMI(Remote Method Invocation,远程方法调用)。框架负责解决一些细节问题,例如请求管理、线程创建、负载平衡,并在正确的时刻将请求分发给正确的应用程序组件。编写Servlet的开发人员不需要了解有多少请求在同一时刻被处理,也不需要了解套接字的输入流或者输出流是否被阻塞。当调用Servlet的servlet方法来响应Web请求时,可以以同步方式来处理这个请求,就好像它是一个单线程程序。这种方式可以简化组件的开发,并缩短掌握这种框架的学习时间。

1.2.3 异步事件的简化处理

    服务器应用程序接受来自多个远程客户端的套接字连接请求时,如果为每个链接都分配其各自的线程并使用同步I/O,那么就会降低这类程序的开发难度。

    如果某个应用程序对套接字执行读取操作而此时还没有数据到来,那么这个读取将一直阻塞,直到有数据到达。在单线程程序中,这不仅意味着在处理请求的过程中将停顿,而且还意味着在这个线程被阻塞期间,对所有请求的处理都将停顿。为了避免这个问题,单线程服务器应用程序必须使用非阻塞I/O,这种I/O的复杂性要远远高于同步I/O,并且很容易出错。然而,如果每个请求都拥有自己的处理线程,那么在处理某个请求时发生的阻塞将不会影响其他请求的处理。

1.2.4 响应更灵敏的用户界面

    在现代GUI应用程序中,例如AWT和Swing等工具,都采用一个事件分发线程(Event Dispatch Thread, EDT)来代替主事件循环。当某个用户界面事件发生时,在事件线程中将调用应用程序的事件处理器。由于大多数GUI框架都是单线程子系统,因此到目前为止仍然在主事件循环,但它现在处理GUI工具的控制下并在其自己的线程中运行,而不是在应用程序的控制下。

1.3 线程带来的风险

    Java对线程的支持其实是一把双刃剑。虽然Java提供了相应的语言和库,以及一种明确的跨平台内存模型,这些工具简化了并发应用程序的开发,但同时也提高了对开发人员的技术要求,因为在更多的程序中会使用线程。

1.3.1 安全性问题

    线程安全性可能是非常复杂的,在没有充足同步的情况下,多个线程中操作执行顺序是不可预测的,甚至会产生奇怪的结果。

// 线程不安全
public class UnsafeSequence {
	private int value;
	
	// 返回一个唯一的数值
	public int getNext() {
		return value++;
	}
}

    在上面的代码中,UnsafeSequence类中将产生一个整数值序列,该序列中的每个值都是唯一的。在这个类中简要地说明了多个线程之间的交替操作将如何导致不可预料的结果。在单线程环境中,这个类能正确的工作,但在多线程环境中则不能。

    UnsafeSequence的问题在于,如果执行的时机不对,那么两个线程在调用getNext时会得到相同的值。虽然递增运算看上去是单个操作,但事实上它包含三个独立的操作:读取value,将value加1,并将计算结果写入value。由于运行时可能将多个线程之间的操作交替执行,因此这两个线程可能同时执行读取操作,从而使他们得到相同的值,并都将这个值加1。结果就是,在不同线程的调用中返回了相同的数值。

    UnsafeSequence类中说明的是一种常见的并发安全问题,成为竞态条件(Race Condition)。在多线程环境下,getValue是否会返回唯一的值,要取决于运行时对线程中操作的交替执行方式,这并不是我们希望看到的。

    由于多个线程要共享相同的内存地址空间,并且是并发运行,因此它们可能会访问或修改其它线程正在使用的变量。当然,这是一种极大的便利,因为这种方式比较其他线程间的通信机制更容易实现数据共享。但它同样也带来了巨大的风险:线程会由于无法预料的数据变化而发生错误。当多个线程同时访问和修改相同的变量时,将会在串行编程模型中引入非串行因素,而这种非串行性是很难分析的。要使多线程程序的行为可预测,必须对共享变量的访问操作进行协同,这样才不会在线程之间发生彼此干扰。

    通过将getNext修改为一个同步方法,可以修改UnsafeSequence中的错误:

// 线程安全
public class UnsafeSequence {
	private int value;
	
	// 返回一个唯一的数值
	public synchronized int getNext() {
		return value++;
	}
}

    如果没有同步,那么无论是编译器、硬件还是运行时,都可以随意安排操作的执行时间和顺序。虽然这些技术有助于实现更优秀的性能,并且通常也是值得采用的方法,但它们也为开发人员带来了负担,因为开发人员必须找出这些数据在哪些位置被多个线程共享,只有这样才能使这些优化措施不破坏线程安全性。

1.3.2 活跃性问题

    在开发代码时,一定要注意线程安全性是不可破坏的。安全性不仅对于多线程程序很重要,对于单线程程序同样重要。此外,线程还会导致一些在单线程程序中不会出现的问题,例如活跃性问题。

    安全性的含义是“永远不发生糟糕的事情”,而活跃性则关注与另一个目标,即“某件正确的事情最终会发生”。当某个操作无法继续执行下去时,就会发生活跃性问题。在串行程序中,活跃性问题的形式之一就是无意中造成无限循环,从而使循环之后的代码无法得到执行。线程将带来一些其他的活跃性问题。包括死锁、饥饿、以及活锁。与大多数并发性错误一样,导致活跃性问题的错误同样是难以分析的,因为它们依赖于不同 线程的事件发生时序,因此在开发或者测试中并不总是能够重现。

1.3.3 性能问题

    与活跃性问题密切相关的是性能问题。活跃性意味着某件正确的事情最终会发生,但却不够好,因为我们通常希望正确的事情尽快发生。性能问题包括多个方面,例如服务时间过长,响应不灵敏,吞吐率过低,资源消耗过高,或者可伸缩性较低等。与安全性和活跃性一样,在多线程程序中不仅存在于单线程程序相同的性能问题,而且还存在由于使用线程而引入的其他性能问题。

    在设计良好的并发应用程序中,线程能提升程序的性能,但无论如何,线程总会带来某种程度的运行时开销。在多线程程序中,当线程调度器挂起活跃线程并转而运行另外一个线程时,就会频繁的出现上下文切换操作(Context Switch),这种操作将带来极大的开销:保存和恢复执行上下文,丢失局部性,并且CPU时间将更多地花在线程调度而不是运行上。当线程共享数据时,必须使用同步机制,而这些机制往往会抑制某些编译器优化,使内存缓存区中的数据无效,以及增加共享内存的总线的同步流量。所有这些因素都将带来额外的性能开销。

1.4 线程无处不在

    每个Java应用程序都会使用线程。当JVM启动时,它将为JVM的内部任务创建后台线程,并创建一个主线程来运行main方法。AWT和Swing的用户界面框架将创建线程来管理用户界面事件。Timer将创建线程来执行延迟任务。一些组件框架,例如Servlet和RMI,都会创建线程池并调用这些线程中的方法。

    如果要使用这些功能。那么就必须熟悉并发性和线程安全性,因为这些框架将创建线程并且在这些线程中调用程序中的代码。虽然将并发性认为是一种“可选的”或者“高级的”语言功能固然理想,但现实的情况是,几乎所有的Java应用程序都是多线程的,因此在使用这些框架时仍然需要对应用程序状态的访问进行协同。

    当某个框架在应用程序中引入并发性时,通常不可能将并发性仅局限于框架代码,因为框架本身会回调(Callback)应用程序的代码,而这些代码将访问应用程序的状态。同样,对线程安全性的需求也不能局限于被调用的代码,而是要延伸到需要访问这些代码所访问的程序状态的所有代码路径。因此,对线程安全性的需求将在程序中蔓延开来。

框架通过在框架线程中调用应用程序代码将并发性引入到程序中。在代码中将不可避免地访问应用程序状态,因此所有访问这些状态的代码路径都必须是线程安全的。

推荐阅读