Java多线程编程详解–[0]
参考书籍:
《Java并发编程实战》
《Java并发编程实战》
本文是关于以上两本书的读书笔记以及一些个人思考。
[0] 关于并发与多线程的简介
编写正确的程序很难,而编写正确的并发程序则难上加难。与串行程序相比,在并发程序中存在更多容易出错的地方。那么,为什么还要编写并发程序呢?线程是Java语言中不可或缺的重要功能,它们能使复杂的异步代码变得更简单,从而极大地简化了复杂系统的开发。此外,要想充分发挥多处理系统的强大计算能力,最简单的方式就是使用线程。而随着处理器数量的持续增加,如何高效地使用并发正变得越来越重要。
[0] [0] 并发的历史
在早期的计算机中不包含操作系统,它们从头到尾只执行一个程序,并且这个程序能访问你计算机中的所有资源。在这种裸机环境下,不仅很难编写和运行程序,而且每次只能运行一个程序,这对于昂贵并且稀有的计算机资源来说是一种浪费。
操作系统的出现使得计算机每次能运行多个程序,并且不同的程序都在单独的进程中运行:操作系统为各个独立的进程分配各种资源,包括内存,文件句柄以及安全证书等。如果需要的话,在不同的进程之间可以通过一些粗粒度的通信机制来交换数据,包括:套接字、信号处理器、共享内存、信号量以及文件等。
名词解释:
- 句柄:句柄是操作系统在生成对象时分配给对象的唯一标识。通过句柄可以获取操作系统提供的服务。句柄不同于指针,如果你得到一个人对象的指针,那你就可以在此对象上为所欲为了,但操作系统不会给与用户这么大的权限,所以,系统不会给你指针,而是一个加以限制的、用于跟踪对象的指针的标识——句柄!系统使用句柄向外提供服务就相对安全了。
- 粗粒度:粗粒度和细粒度是一个相对的概念,其区别主要用于重用的目的。像类的设计,为了尽可能重用,所以采用细粒度,将一个复杂的类(粗粒度)拆分成高度重用的职责清晰的类(细粒度)。对于数据库的设计,原则是尽可能减少表的数量以及标语表之间的连接,能够设计成一个表就不要细分,所以考虑粗粒度的设计方式。
之所以在计算机中加入操作系统来实现多个程序的同时进行,主要原因如下:
- 资源利用率:在某些情况下,程序必须等待某种外部操作执行完成,例如输入输出操作等,而在等待时程序无法执行其他工作,这种频繁地等待就会极大的降低资源利用率,这时候就需要并发操作的出现。
- 公平性:不同用户和程序对于计算机上的资源有同等的使用权。一种高效的运行方式是通过粗粒度的时间分片(Time Slicing)使这些用户和程序能共享计算机资源,而不是一个任务从头执行到尾,然后再启动下一个程序。
- 便利性:通常来说,在计算多个任务时,应该编写多个程序,每个程序执行一个任务,并在必要的时候进行通信,这比只编写一个程序来计算所有的任务要更容易实现。
[0] [1] 线程的优势
首先介绍一下什么是进程与线程,同时进程与线程又有什么区别?
进程:通常来说,我们进程认为是程序的一次执行。每个进程都由自己独立的一块内存空间,一个进程可以有多个线程。
线程:线程也被称为轻量级进程。在大多操作系统中,都是以线程为基本的调度单位,而不是进程。如果没有明确的协同机制,那么线程将彼此独立执行。由于同一个进程中的所有线程都将共享进程的内存地址空间,因此,这些线程都能访问相同的变量并在同一个堆上分配对象,这就需要实现一种比在进程间共享数据粒度更细的数据共享机制。如果没有明确的同步机制来协同对共享数据的访问,那么当一个线程正在使用某个变量时,另一个线程可能同时访问这个变量,这将造成不可预测的后果。
简单来说,进程是操作系统分配资源的基本单位,一个进程可以包含多个线程,线程是CPU调度的基本单位。
如果使用得当的话,线程可以有效地降低程序地开发和维护成本,同时提升复杂应用程序地性能。线程能够将大部分的异步工作流转化成串行工作流,因此能更好地模拟人类的工作方式和交互方式。此外,线程还可以降低代码的复杂度,使代码更容易编写、阅读和维护。可以说,在许多重要的Java应用程序中都一定程度上用到了线程。
名词解释:
- 异步:计算机操作系统的异步性是指在多道程序环境下,允许多个程序并发执行,但由于资源有限,进程的执行不是一贯到底。而是走走停停,以不可预知的速度向前推进,这就是进程的异步性。
线程的优势也体现在以下几个方面:
- 发挥多处理器的强大能力:一方面,多线程可以更好地利用到CPU资源,提高其资源利用率,同时提升系统吞吐率。
- 建模的简单性:如果程序中只包含一种类型的任务,那么比包含多种不同类型的任务的程序要更容易编写,错误更少,也更容易测试。我们可以通过一些现有的框架来实现上述目标,例如Servlet和RMI(Remote Method Invocation,远程方法调用)。框架负责解决一些细节问题,例如请求管理、线程创建、负载平衡,并在正确的时刻将请求分发给正确的应用程序组件。这种方式可以简化组建的开发,并缩短掌握这种框架的时间。
- 异步事件的简化处理:服务器应用程序在接收来自多个客户端的套接字请求时,如果为每个连接都分配其各自的线程程并使用I/O,那么就会降低这类程序的开发难度。如果某个应用程序对套接字执行操作而此时还没有数据到来,那么这个读操作就会一直阻塞,直到有数据到来。在单线程应用程序中,这不仅意味着处理请求的过程将停顿,还意味着这个线程在被阻塞期间,对所有的请求都将停顿。为了解决这个问题,单线程服务器应用程序必须使用非阻塞I/O。这种I/O的复杂性要远远高于同步I/O。并且很容易出错,因此多线程是有必要的。
- **响应更加灵敏的用户界面:**传统的GUI应用程序都是单线程的,然而如果主时间循环中调用的代码需要很长时间 才能执行完成,那么用户界面就会“冻结”,直到代码执行完成。这是因为只有当执行权返回主事件循环后,才能处理后续的用户界面。然而,如果将这个长时间运行的任务放在一个单独的线程中运行,那么事件线程就能及时处理界面事件,从而使用户界面具有更高的灵敏度。
[0] [2] 线程带来的风险
虽然Java提供了相应的语言和库,以及一种明确的跨平台内存模型(对该内存模型实现了在Java中开发“编写一次,随处运行”的并发应用程序),这些工具简化了并发应用程序的开发,但同时也提高了对开发人员的技术要求。在这种情况下,多线程可能会带来以下几种风险:
-
安全性问::线程安全性可能是非常复杂的,在没有充分同步的情况下,多个线程中的操作执行顺序是不可预测的,甚至可能产生奇怪的结果。例如:
public class UnsafeSequence{ private int value; /**返回一个唯一的数值。*/ public int getNext(){ return value++; } }
在单线程中,这个类能正确地工作,但在多线程环境中则不能。问题就在于,如果执行时机不对,那么两个线程在调用getNext()时会得到相同的值。
上图给出了不同线程之间地一种交替执行情况。在图中,执行时序按照从左往右地顺序递增,每行表示一个线程的动作。在图中这种情况下,两个线程交替执行,进而得到了相同的value,其最终返回的值也一样,而这与我们所需要的返回唯一的值矛盾了,进而会带来一些危险。以上的UnsafeSequence类中说明的是一种常见的并发安全问题,称为竞态条件(Race Condition)。在多线程环境下,getValue()是否会返回唯一的值,要取决于运行时对线程中操作的交替执行方式,这不是我们希望看见的情况。
由于多线程要共享内存地址空间,并且是并发运行,因此它们可能会访问或修改其他线程正在使用的变量。当然,这时一种极大的便利,因为这种方式比其他线程间通信机制更容易实现数据共享。
但它同样也带来了巨大的风险:线程会由于无法预料的数据变化而发生错误。当多个线程同时访问和修改相同的变量时,将会在串行编程模型中引入非串行因素,而这种非串行因素时很难分析的。要使多线程程序的行为可以预测,必须对共享变量的访问操作进行协同,这样才不会在线程之间发生彼此干扰。幸运的是,Java提供了各种同步机制来协同这种访问。
我们可以将getNext()修改为一个同步方法,可以修复UnsafeSequence中的错误,如下述程序中的Sequence,这个类可以防止错误的交替执行。
public class Sequence{ private int Value; public synchronized int getNext(){ return Value++; } }
-
活跃性问题:在开发过程中,一定要注意线程安全性不可破坏。安全性不仅对多线程程序很重要,对于单线程程序也很重要。此外,线程还会导致一些在单线程中不会出现的问题,例如:活跃性问题。
安全性的含义是“永远不发生糟糕的事情”,而活跃性则关注另一个问题,即“某件正确的事最终会发生”。当某个操作无法继续执行下去时,就会发生活跃性问题。在串行程序中,活跃性问题的形式之一就是无意间造成的无限循环,从而使循环后面的代码无法执行。而对于并发编程,这个问题也时长出现,例如:死锁、饥饿、以及活锁等。与大多数并发性错误一样,导致活跃性问题的错误同样难以发现,因为它们依赖于不同线程之间时间发生时序,因此在开发或测试过程中并不总是能够重现。
-
性能问题:与活跃性问题密切相关的就是性能问题。活跃性意味着某件正确的事最终会发生,但却不够好,因为,我们通常希望正确的事尽快发生。性能问题包括多个方面,例如如武器时间过长,响应不灵敏,吞吐率过低,资源消耗过高,或者可伸缩性较低等。
在多线程程序中不仅存在与单线程程序相同的性能问题,而且还存在由于使用线程而引入的其他性能问题。在设计良好的并发程序中,线程能够提升程序的性能,但无论如何线程总会带来某种程度的运行开销。关于这个问题我们可以在下一节中展开分析。
[0] [3] 并发带来的挑战
并发编程的目的是让程序运行得更快,但是,并不是启动更多的线程就能让程序最大限度地并发执行。在进行并发编程时,如果希望通过多执行任务让程序运行得更快,会面临非常多的挑战,比如:上下文切换的问题、死锁的问题以及受限于硬件和软件的资源限制问题,接下来我们会介绍几种并发编程的挑战以及解决方案。
-
上下文切换:即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停切换线程执行,让我们感觉到多个线程是同时执行的,时间片一般是既是毫秒。
CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便于下次切换会这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。
第一个问题:多线程一定快吗?
答案当然是否定的,我们可以看如下的代码:
package concurrencyProgramming; public class ConcurrencyTest { private static final long count = 50001; public static void main(String[] args) throws InterruptedException { concurrency(); seria(); } //并发执行耗时 private static void concurrency() throws InterruptedException { long start = System.nanoTime(); Thread thread = new Thread(new Runnable() { public void run() { int a = 0; for (long i = 0; i < count; i++) { a += 5; } } }); thread.start(); int b = 0; for (long i = 0; i < count; i++) { b--; } long time = System.nanoTime() - start; thread.join(); System.out.println("concurrency:" + time + "ns,b=" + b); } //串行执行耗时 private static void seria() { long start = System.nanoTime(); int a = 0; for (long i = 0; i < count; i++) { a += 5; } int b = 0; for (long i = 0; i < count; i++) { b--; } long time = System.nanoTime() - start; System.out.println("serial:" + time + "ns,b=" + b + ",a=" + a); } }
我们把测试结果的单位换算成ms,整理成如下的表格:
循环次数 并发执行耗时/ms 串行执行耗时/ms 并发与串行的耗时比 1万 0.524 0.181 2.895 5万 0.890 0.962 0.925 10万 1.322 1.843 0.717 1百万 3.136 4.530 0.692 1千万 4.846 7.961 0.609 1亿 28.866 53.757 0.537 从表中可以发现,当并发执行操作不超过5万次时,速度反而会比串行执行累计操作要慢。这是因为线程有创建和上下文切换的开销。
第二个问题:如何减少上下文切换?
减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程。
- 无锁并发编程:多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。
- CAS(Compare And Swap)算法:Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
- 使用最少线程:避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量的线程都处于等待状态。
- 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
-
死锁:锁是非常有用的工具,运用场景非常广泛,因为它使用起来非常简单,而且易于理解。但同时它也会带来一些困扰,那就是死锁,一旦产生死锁,就会造成系统功能不可用。让我们看如下代码:
package concurrencyProgramming; public class DeadLockDemo { private static String A = "A"; private static String B = "B"; public static void main(String[] args) { new DeadLockDemo().deadLock(); } private void deadLock() { Thread t1 = new Thread(new Runnable() { public void run() { synchronized (A) { try { Thread.currentThread().sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (B) { System.out.println("1"); } } } }); Thread t2 = new Thread(new Runnable() { public void run() { synchronized (B) { synchronized (A) { System.out.println("2"); } } } }); t1.start(); t2.start(); } }
这段代码只是演示死锁的场景,在现实中你可能不会写出这样的代码。但是,在一些更为复杂的场景中,你可能会遇到这样的问题,比如t1拿到锁之后,因为一些异常情况没有释放锁(死循环)。又或者时t1拿到一个数据库锁,释放锁的时候抛出异常,没释放掉。
一旦出现死锁,业务是可感知的,因为不能继续提供服务了。而对于多线程中的死锁问题,其形成的四个必要条件如下:
- 互斥条件:任意时刻一个资源只能给一个线程使用,其他线程若申请一个资源,而该资源被占有时,则申请者等待资源被占有者释放。
- 不可剥夺条件:进程锁获得的资源在为使用完毕前,不被其他任何进程强行剥夺,而只能由获得该资源的进程资源释放。
- 请求和保持条件:进程每次申请它所需要的一部分资源,在申请新的资源的同时,继续占有已分配的资源。
- 循环等待条件:若干进程之间形成一种头尾相连的循环等待条件。
而要预防死锁,只需要破坏以上任意一种条件,具体方法如下:
- 避免一个线程同时获取多个锁
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
- 尝试使用定时锁,使用lock.tryLock(timeout)来代替使用内部锁机制。
- 对于数据库锁,加锁1和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。
避免死锁的方法:
- 一次封锁法:每个进程(事务)将所有要使用的数据全部加锁,否则,就不能继续执行。
- 顺序封锁法:预先对数据对象规定一个封锁顺序,所有进程(事务)都按这个顺序加锁。
- 银行家算法:保证进程处于安全进程序列。
有助于最大限度地降低死锁的方法:
- 按同一顺序访问对象
- 避免事务中的用户交互
- 保持事务简短并在一个批处理中
- 使用低隔离等级
-
资源限制的挑战:
第一个问题:什么是资源限制?
资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源。例如,服务器带宽只有2Mb/s,某个资源的下载速度是1Mb/s,系统启动10个线程下载资源,下载速度也不会变成10Mb/s,所以在进行并发编程时,要考虑这些资源的限制。硬件资源限制有带宽的上传/下载速度、硬盘读写速度和CPU的处理速度。软件资源限制有数据库的连接数和socket连接数等。
第二问题:资源限制引发的问题?
在并发编程中,将代码执行速度加快的原则将代码中串行执行的部分变成并发执行,但是如果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这时候程序不仅不会加快,反而会更慢,因为增加了上下文切换和资源调度的时间。例如,之前看到一段程序使用多线程在办公网并发地加载和处理数据时,导致CPU利用率达到100%,几个小时都不能运行完成任务,后来修改成单线程,一个小时就执行完成。
第三个问题:如何解决资源限制的问题?
对于硬件资源限制,可以考虑使用集群并执行程序。既然单机的资源有限制,那么就让程序在多机上运行。比如使用ODPS(Open Data Processing Service)、Hadoop或者自己搭建服务器集群,不同的机器处理不同的数据。可以通过“数据ID%机器数”,计算得到一个机器编号,然后由对应编号的机器处理这笔数据。
对于软件资源的限制,可以考虑使用资源池将资源复用。比如使用连接池将数据库和Socket连接复用,或者在调用对方webservice接口获取数据时,只建立一个连接。
在第四个问题:如何在资源限制情况下进行并发编程?
如何在资源限制的情况下,让程序执行得更快呢?方法就是,根据不同的资源限制调整程序得并发度,比如下载文件程序依赖于两个资源——带宽和硬盘读写速度。有数据库操作时,涉及数据库连接数,如果SQL语句执行非常快,而线程的数量比数据库连接大得多,则某些线程会被阻塞,等待数据库连接数大很多,则某些线程会被阻塞,等待数据库连接。
[0] [4] 线程无处不在
即使在程序中没有显示的创建线程,但在框架中仍可能会创建线程,因此在这些线程中调用的代码同样必须是线程安全的。这将给开发人员在设计和实现上带来沉重负担,因为开发线程安全的类比开发非线程安全的类要更加谨慎和细致。
每个Java应用程序都会使用线程。
- 当JVM启动时,它将为JVM的内部任务(例如,垃圾收集、终结操作等)创建后台线程,并创建一个主线程来运行main方法。
- AWT(Abstract Window Toolkit,抽象窗口工具库)和Swing的用户界面框架将创建线程来给管理用户界面事件。Timer将创建线程来执行延迟任务。
- 一些组件框架,例如Servlet和RMI,都会创建线程池并调用这些线程中的方法。
如果要使用这些功能,那么就必须熟悉并发性和线程安全性,因为这些框架将创建线程并且在这些线程中调用程序中的代码。虽然我们将并发性认为是一种“可选的”或者“高级的”语言功能,但是现实情况是,几乎所有的Java应用程序都是多线程的,因此在使用这些框架时任然需要对应用程序状态的访问进行协同。
当某个框架在应用程序中引入并发性时,通常不可能将并发性仅局限于框架代码,因为框架本身会回调(Callback)应用程序的代码,而这些代码将访问应用程序的状态。同样,对线程安全性的需求也不能局限于被调用的代码,而是要延伸到需要访问这些代码所访问的程序状态的所有代码路径。因此,对线程安全性的需求将在程序中蔓延开来。
什么是回调机制(Call back)?
回调是一种使下层模块/库可以调用或执行上层模块定义的代码的机制。上层模块所定义的、被(下层模块)调用或动态绑定的代码,则被称为回调函数(简称回调、callback)。
在软件设计中,分层设计(Layered designs)是软件开发时常用的策略,例如管理信息系统中常用的逻辑三层结构——表现层、业务逻辑层和数据访问层。
我们可以简单地将代码分为两层,应用程序称为上层模块,而被应用程序依赖的库,称为下层模块/基础设施,如JDK(和第三方包)。
分层架构遵循一条规则:
上层模块依赖于下层模块。下层模块不可能依赖上层模块。
回调机制有两种使用场景:
- 框架中:下层模块中的框架,需要上层的应用程序提供具体的策略。
- 通知机制中:通过调用上层模块/应用程序的函数,将下层知道的数据,通过参数传递给应用程序处理。
框架通过在框架线程中调用应用程序代码将并发性引入到程序到程序中。在代码中将不可避免地访问应用程序状态,因此所有访问这些代码状态地代码路径都必须是线程安全的。
下面给出的模块都将在应用程序之外的线程中调用应用程序的代码。尽管线程安全性可能源自这些模块,但却不会止步于它们,而是会延续到整个应用程序。
-
Timer:Timer类的作用是使任务在稍后的时刻运行,或者运行一次,或者周期性地运行。引入Time可能会使串行程序变得复杂,因为TimeTask将在Timer管理的线程中执行,而不是由应用程序来管理。如果某个TimerTask访问了应用程序中其他线程访问的数据,那么不仅TimerTask需要以线程安全的方式来访问数据,其他类也必须采用线程安全的方式来访问该数据。通常要实现这个目标,最简单的方式是确保TimerTask访问的对象是线程安全的,从而就能把线程安全性封装在共享对象内部。
-
Servlet和JavaServer Page(JSP):Servlet框架用于部署网页应用程序以及分发来自HTTP客户端的请求。到达服务器的请求可能会通过一个过滤器链被分发到正确的Servlet或JSP。每个Servlet都表示一个程序逻辑组件,在高吞吐率的网站中,多个哭护短可能同时请求同一个Servlet的服务。在Servlet规范中,Servlet同样需要满足被多个线程同时滴哦用,换句话说,Servlet需要是线程安全的。
即使可以确保每次只有一个 线程调用某个Servlet,但在构建网页应用程序时仍然必须注意线程安全性。Servlet通常会访问与其他Servlet共享的信息,例如应用程序中的对象(这些对象保存在ServletContext中)或者会话中的对象(这些对象保存在每个客户端的HttpSession中)。当一个Servlet访问在多个Servlet或者请求中共享这些对象。Servlet和JSP,以及在ServletContext和HttpSession等容器中保存的Servlet过滤器和对象等,都必须是线程安全的。
-
远程方法调用(Remote Method Invocation,RMI):RMI使代码能够调用在其他JVM中运行的对象。当通过RMI调用某个远程方法时,传递给方法的参数必须被打包(也称为列集[Marshaled])到一个字节流中,通过网络传输给远程JVM,然后由远程JVM拆包(或者称为散集[Unmarshaled])并传递给远程方法。
远程对象必须注意两个线程安全性问题:正确地协同在多个对象共享地状态,以及对远程对象本身状态地访问(由于同一个对象可能会在多个线程中被同时访问)。与Servlet相同,RMI对象应该做好被多个线程同时调用地准备,并且必须确保它们自身地线程安全性。
-
Swing和AWT:GUI应用程序地一个固有属性是异步性。用户在任意时刻选择一个菜单项或者按下一个按钮,应用程序就会及时响应,即使应用程序当时正在执行其他的任务。Swing和AWT很好地解决了这个问题,它们创建了一个单独的线程来处理用户触发的事件,并对呈现给用户的图形界面进行更新。这也需要线程安全。