第一章 简介 Java并发编程实战 阅读总结

1.1 并发简史

        在早期的计算机中不包含操作系统,它们从头到尾只执行一个程序,并且这个程序能访问计算机中的所有资源。在这种裸机环境中,不仅很难编写和运行程序, 而且每次只能运行一个程序。

        操作系统的出现使得计算机每次能运行多个程序,并且不同的程序都在单独的进程中运行:操作系统为各个独立执行的进程分配各种资源,包括内存,文件句柄以及安全证书等。如果需要的话,在不同的进程之间可以通过一些粗粒度的通信机制来交换数据,包括: 套接字、信号处理器、共享内存、信号量以及文件等。

        之所以在计算机中加入操作系统来实现多个程序的同时执行, 主要是基于以下原因:

        资源利用率。在某些情况下,程序必须等待某个外部操作执行完成,例如输入操作或输出操作等,而在等待时程序无法执行其他任何工作。因此,如果在等待的同时可以运行另一个程序, 那么无疑将提高资源的利用率。
        公平性。不同的用户和程序对于计算机上的资源有着同等的使用权。一种高效的运行方式是通过粗粒度的时间分片(Time Slicing)使这些用户和程序能共享计算机资源,而不是由一个程序从头运行到尾, 然后再启动下一个程序。
        便利性。通常来说,在计算多个任务时,应该编写多个程序,每个程序执行一个任务井在必要时相互通信,这比只编写一个程序来计算所有任务更容易实现。

        在早期的分时系统中, 每个进程相当于一台虚拟的冯·诺依曼计算机,它拥有存储指令和 数据的内存空间,根据机器语言的语义以串行方式执行指令,并通过一组I/O指令与外部设备通 信。对每条被执行的指令, 都有相应的 “下一条指令”,程序中的控制流是按照指令集的规则来确定的。当前, 几乎所有的主流编程语言都遵循这种串行编程模型,并且在这些语言的规范中也 都清晰地定义了在某个动作完成之后需要执行的 “下一个动作”。

        串行编程模型的优势在于其直观性和简单性,因为它模仿了人类的工作方式:每次只做一件事情, 做完之后再做另一件。这些促使进程出现的因素(资源利用率、 公平性以及便利性等)同样也促使着线程的出现。线程允许在同一个进程中同时存在多个程序控制流线程会共享进程范围内的资源, 例如 内存句柄和文件旬柄, 但每个线程都有各自的程序计数器 (Program Counter)、栈以及局部变量等线程还提供了一种直观的分解模式来充分利用多处理器系统中的硬件并行性, 而在同一个程序中的多个线程也可以被同时调度到多个 CPU上运行

        线程也被称为轻量级进程。在大多数现代操作系统中, 都是以线程为基本的调度单位, 而不是进程。如果没有明确的协同机制, 那么线程将彼此独立执行。由于同一个进程中的所有线程都将共享进程的内存地址空间, 因此这些线程都能访问相同的变量并在同一个堆上分配对象, 这就需要实现一种比在进程间共享数据粒度更细的数据共享机制。如果没有明确的同步机制来协同对共享数据的访问, 那么当一个线程正在使用某个变最时, 另一个线程可能同时访问这个变量, 这将造成不可预测的结果。

1.2 线程的优势

        如果使用得当, 线程可以有效地降低程序的开发和维护等成本, 同时提升复杂应用程序的性能。线程能够将大部分的异步工作流转换成串行工作流, 因此能更好地模拟人类的工作方式和交互方式。此外, 线程还可以降低代码的复杂度, 使代码更容易编写、 阅读和维护。
        在 GUI (Graphic User Interface, 图形用户界面)应用程序中, 线程可以提高用户界面的响应灵敏度,而在服务器应用程序中, 可以提升资源利用率以及系统吞吐率。线程还可以简化JVM 的实现, 垃圾收集器通常在一个或多个专门的线程中运行。在许多重要的 Java 应用程序 中, 都在一定程度上用到了线程。

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

        如果设计正确, 多线程程序可以通过提高处理器资源的利用率来提升系统吞吐率。使用多个线程还有助于在单处理器系统上获得更高的吞吐率。 如果程序是单线程的, 那么当程序等待某个同步I/O    操作完成时, 处理器将处于空闲状态。而在多线程程序中, 如果一个线程在等待I/O操作完成, 另一个线程可以继续运行, 使程序能够在I/O阻塞期间继续运行。 (这就好比在等待水烧开的同时看报纸, 而不是等到水烧开之后再开始看报纸)。

1.2.2 建模的简单性

        通常, 当只需要执行一种类型的任务(例如修改 12 个错误)时, 在时间管理方面比执行多种类型的任务(例如, 修复错误、面试系统管理员的接任者、 完成团队的绩效考核, 以及为下个星期的报告做幻灯片) 要简单。 当只有一种类型的任务需要完成时, 只需埋头工作, 直到完成所有的任务(或者你已经精疲力尽), 你不需要花任何精力来琢磨下一步该做什么。而另一方面, 如果需要完成多种类型的任务, 那么需要管理不同任务之间的优先级和执行时间, 并在任务之间进行切换, 这将带来额外的开销。
        对于软件来说同样如此:如果在程序中只包含一种类型的任务, 那么比包含多种不同类型任务的程序要更易于编写, 错误更少, 也更容易测试。 如果为模型中每种类型的任务都分配一个专门的线程, 那么可以形成一种串行执行的假象, 并将程序的执行逻辑与调度机制的细节, 交替执行的操作, 异步I/O以及资源等待等问题分离开来。通过使用线程,可以将复杂并且异步的工作流进一步分解为一组简单并且同步的工作流, 每个工作流在一个单独的线程中运行令并在特定的同步位置进行交互

1.2.3异步事件的简化处理

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

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

        早期的操作系统通常会将进程中可创建的线程数量限制在一个较低的阈值内,大约在数百个(甚至更少)左右。因此,操作系统提供了一些高效的方法来实现多路I/O 例如Unix的select和poll等系统调用,要调用这些方法,Java类库需要获得一组实现非阻塞I/O的包(java. nio)。然而,在现代操作系统中,线程数量已得到极大的提升, 这使得在某些平台上,即使有更多的客户端,为每个客户端分配一个线程也是可行的., 并且很容易出错。然而,如果每个请求都拥有自己的处理线程,那么在处理某个请求时发生的阻塞将不会影响其他请求的处理。

1.3 线程带来的风险

        Java对线程的支持其实是一把双刃剑。虽然Java提供了相应的语言和库,以及一种明确的跨平台内存模型(该内存模型实现了在Java中开发 “编写一次,随处运行” 的并发应用程序), 这些工具简化了井发应用程序的开发,但同时也提高了对开发人员的技术要求,因为在更多  程序中会使用线程。 当线程还是一项鲜为人知的技术时, 井发性是一个 “ 高深的 ” 主题,但现在, 主流开发人员都必须了解线程方面的内容。    

1.3.1 安全性问题

        线程安全性可能是非常复杂的, 在没有充足同步的情况下, 多个线程中的操作执行顺序是 不可预测的, 甚至会产生奇怪的结果。 在程序清单1-1的UnsafeSequence类中将产生一个整数值序列, 该序列中的每个值都是唯一的。 在这个类中简要地说明了多个线程之间的交替操作 将如何导致不可预料的结果。 在单线程环境中, 这个类能正确地工作, 但在多线程环境中则不能。        

@NotThreadSafe
public class UnsafeSequence {
    private int value;
    /*返回一个唯一的数值*/
    public int getNext(){
        return value++;
    }
}
        Unsafe Sequence的问题在于,如果执行时机不对,那么两个线程在调用getNext时会得到相同的值。在图1-1中给出了这种错误情况。虽然递增运算someVariable++看上去是单个操作,但事实上它包含三个独立的操作:读取value,将value加1,并将计算结果写人value。由于运行时可能将多个线程之间的操作交替执行,因此这两个线程可能同时执行读操作,从而使它们得到相同的值,并都将这个值加1。结果就是,在不同线程的调用中返回了相同的数值。


        在UnsafeSequence类中说明的是一种常见的并发安全问题,称为竞态条件(RaceCondition)。在多线程环境下,getValue是否会返回唯一的值, 要取决于运行时对线程中操作的交替执行方式, 这并不是我们希望看到的情况。
        由于多个线程要共享相同的内存地址空间, 并且是并发运行, 因此它们可能会访问或修改其他线程正在使用的变量。 当然, 这是一种极大的便利, 因为这种方式比其他线程间通信机制更容易实现数据共享。 但它同样也带来了巨大的风险:线程会由于无法预料的数据变化而发生 错误。 当多个线程同时访问和修改相同的变量时, 将会在串行编程模型中引入非串行因素, 而 这种非串行性是很难分析的。 要使多线程程序的行为可以预测, 必须对共享变量的访问操作进行协同, 这样才不会在线程之间发生彼此干扰。幸运的是,Java提供了各种同步机制来协同这种访问。
        通过将getNext修改为一个同步方法, 可以修复UnsafeSequence中的错误, 如程序清单1-2中的Sequence, 这个类可以防止图1-1中错误的交替执行情况。(第2章和第3章将进一步分析这个类的工作原理。)


@ThreadSafe
public class Sequence {
    @GuardedBy("this") private int value;
    
    public synchronized int getNext(){
        return value++;
    }
}

        如果没有同步, 那么无论是编译器、 硬件还是运行时, 都可以随意安排操作的执行时间和顺序, 例如对寄存器或者处理器中的变量进行缓存, 而这些被缓存的变量对于其他线程来说是暂时(甚至永久)不可见的。 虽然这些技术有助于实现更优的性能, 并且通常也是值得采用的方法, 但它们也为开发人员带来了负担, 因为开发人员必须找出这些数据在哪些位置被多个线程共享, 只有这样才能使这些优化措施不破坏线程安全性。(第16章将详细介绍NM实现了 哪些顺序保证, 以及同步将如何影响这些保证, 但如果遵循第2章和第3章给出的指导原则, 那么就可以绕开这些底层细节问题。)

1.3.2 活跃性问题

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

        安全性的含义是“永远不发生糟糕的事情”,而活跃性则关注于另一个目标,即“某件正确的事情最终会发生”。当某个操作无法继续执行下去时,就会发生活跃性问题。在串行程序中,活跃性问题的形式之一就是无意中造成的无限循环,从而使循环之后的代码无法得到执行。线程将带来其他一些活跃性问题。例如,如果线程A在等待线程B释放其持有的资源,而 线程B永远都不释放该资源,那么A就会永久地等待下去。第10章将介绍各种形式的活跃性问题,以及如何避免这些问题,包括死锁(10.1节),饥饿(10.3.1节),以及活锁(10.3.3 节)。与大多数并发性错误一样,导致活跃性问题的错误同样是难以分析的,因为它们依赖于不同线程的事件发生时序,因此在开发或者测试中井不总是能够重现。

1.3.3 性能问题

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

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

1.4 线程无处不在

        即使在程序中没有显式地创建线程,但在框架中仍可能会创建线程,因此在这些线程中调用的代码同样必须是线程安全的。这将给开发人员在设计和实现上带来沉重负担,因为开发线程安全的类比开发非线程安全的类要更加谨慎和细致。

        每个 Java 应用程序都会使用线程。当JVM启动时,它将为JVM的内部任务(例如,垃圾收集、终结操作等)创建后台线程, 并创建一个主线程来运行 main 方法。AWT (Abstract Window Toolkit, 抽象窗口工具库) 和 Swing 的用户界面框架将创建线程来管理用户界面事件。 Timer 将创建线程来执行延迟任务。一些组件框架,例如 Servlet 和 RMI, 都会创建线程池并调用这些线程中的方法。

        如果要使用这些功能, 那么就必须熟悉并发性和线程安全性, 因为这些框架将创建线程并且在这些线程中调用程序中的代码。 虽然将并发性认为是一种 “可选的” 或者 “高级的” 语言功能固然理想,但现实情况是, 几乎所有的 Java 应用程序都是多线程的, 因此在使用这些框架时仍然需要对应用程序状态的访问进行协同。
        当某个框架在应用程序中引入并发性时, 通常不可能将并发性仅局限于框架代码, 因为框 架本身会回调 (Callback) 应用程序的代码, 而这些代码将访问应用程序的状态。同样, 对线 程安全性的需求也不能局限于被调用的代码, 而是要延伸到需要访问这些代码所访问的程序状态的所有代码路径。 因此, 对线程安全性的需求将在程序中蔓延开来。


猜你喜欢

转载自blog.csdn.net/weixin_40243947/article/details/80626243