JAVA并发

一.线程安全性

线程安全是建立在对于对象状态访问操作进行管理,特别是对共享的与可变的状态的访问

解释下上面的话:

  • 对象状态:从某种意义上来说,对象状态是指存储在状态变量的数据
  • 共享的:变量可以由多个线程同时访问
  • 可变的:变量的值在其生命周期内可以发生变化

一个对象是否需要线程安全,取决于它是否被多个线程访问,同时线程安全需要采用同步机制来协同对对象可变状态的访问

Java中的同步机制有如下

  • Synchronized关键字,提供独占的加锁方式
  • volatile关键字
  • 显示锁
  • 原子变量

编写并发程序正确的编程方法:首先使代码正确运行,然后再提高代码速度

1.什么是线程安全性

定义如下:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的

无状态对象是线程安全的,不存在上面说的对于变量的访问

2.原子性

原子是世界上的最小单位,具有不可分割性。比如 a=0;(a非long和double类型) 这个操作是不可分割的,那么我们说这个操作时原子操作。再比如:a++; 这个操作实际是a = a + 1;是可分割的,所以他不是一个原子操作。非原子操作都会存在线程安全问题,需要我们使用同步技术(sychronized)来让它变成一个原子操作。一个操作是原子操作,那么我们称它具有原子性。

当两个线程在没有同步情况下对一个计数器执行递增操作时会发生读取数据不准确情况

在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,称为竞态条件

竞态条件

当某个计算的正确性取决与多个线程交替执行的时序时,那么就会发生竞态条件

那么要避免竞态条件,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完全前或者后读取和修改状态,而不是在修改状态过程中

这里我们的选择是通过线程安全类(Atomic包)来解决这个问题

Atomic包

Java从JDK1.5开始提供了java.util.concurrent.atomic包,atomic包中包含了一些原子变量类,用与实现数值与对象引用上的原子状态转换,方便程序员在多线程环境下,无锁的进行原子操作。

Atomic包里一共有12个类,四种原子更新方式,分别是原子更新基本类型,原子更新数组,原子更新引用和原子更新字段

更新基本类型

  • AtomicBoolean:原子更新布尔类型。
  • AtomicInteger:原子更新整型。
  • AtomicLong:原子更新长整型。

原子更新数组

  • AtomicIntegerArray:原子更新整型数组里的元素。
  • AtomicLongArray:原子更新长整型数组里的元素。
  • AtomicReferenceArray:原子更新引用类型数组里的元素。

原子更新引用

  • AtomicReference:原子更新引用类型。
  • AtomicReferenceFieldUpdater:原子更新引用类型里的字段。
  • AtomicMarkableReference:原子更新带有标记位的引用类型。可以原子的更新一个布尔类型的标记位和引用类型。构造方法是AtomicMarkableReference(V initialRef, boolean initialMark)

原子更新字段

  • AtomicIntegerFieldUpdater:原子更新整型的字段的更新器。
  • AtomicLongFieldUpdater:原子更新长整型字段的更新器。
  • AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于原子的更数据和数据的版本号,可以解决使用CAS进行原子更新时,可能出现的ABA问题。

通过线程安全类可以保证竞态条件下变量的正确性

实际情况中,应尽可能地使用现有的线程安全对象来管理类的状态。

3.加锁机制

如果两个线程安全的类之间存在关联性,那么在并发情况下仍然存在竞态条件的可能性。

为了解决这个问题,Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)包含两个部分:1.作为锁的对象引用,2.作为由这个锁保护的代码块

synchronized (lock){ }

每个Java对象都可以用作一个实现同步的锁,这些锁被称为内部锁或监视器锁,内置锁相对于一个互斥体,所以最多只有一个线程能持有这种锁。

线程进入同步代码块之前会自动获得锁,退出同步代码块时自动释放锁,获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或者方法。

内置锁是可以重用的,重用的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程,当同一个线程再次获取这个锁时,计数值降递增,当线程退出同步代码块时计数值会递减,当计数值为0时,这个锁将释放

4.用锁来保护状态

对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要用同一个锁来保护

synchronized方法可以确保单个操作的原子性,但是如果把多个操作合并为一个复合操作,还需要额外的加锁机制

5.活跃性与性能

尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去

判断同步代码块的合理大小,需要在各种设计需求间进行权衡,包括安全性,简单性与性能

二.对象的共享

同步除了上述提到的原子性,其实还有一个很重要的方法:内存可见性,确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化

1.可见性

为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制

没有同步的情况下,编译器,处理器以及运行是等都可能对操作的执行顺序进行一些意想不到的调整,而这种不确定的执行顺序会导致我们产生的结果可能是不正确的数据,这种数据称之为失效数据

为了避免失效数据,我们通过同步的方式就可以解决這方面的问题

除了前面提到的同步代码块(Synchronized Block)方式来同步,JAVA语言还提供了一种稍弱的同步机制,为volatile变量

volatile变量

volatile修饰的成员变量在每次被线程访问时,都强迫从共享内存中读该成员变量的值,当成员变量发生变化时,都强迫线程将变化值写到共享内存,这样在任务时刻,两个不同线程总是看到某个成员变量的同一个值

从内存可见性角度看:写入volatile变量相对于退出同步代码块,读取volatile变量相对于进入同步代码块

2.发布与逸出

发布:使对象能够在当前作用域之外的代码中使用 逸出:不应该发布的对象被发布

发布对象最简单的方法就是将对象的引用保存到一个公有的静态变量中,以便任何类和线程都可以看到该对象

public static Set knownSecrets; public void initialize(){ knownSecrets = new HashSet(); }

如果将一个Secret对象添加到集合中,则会间接发布了Secret对象对象

当从一个非私有方法返回一个引用时候,同样会发布返回的对象,如下

private String[] states; public String[] getStates(){ return states; }

而这种发布方式就会出现问题,因为任何调用者都可以获取这个数组,并且对这个数组进行相关修改,所以这个states已经逸出了它所在的作用域,本来私有的变量已经被发布了。

为了避免产生逸出问题,我们需要封装:封装能够使得对程序的正确性进行分析变得可能,并使得无意中破坏设计约束条件变的更难。

3.线程封闭

如果仅在单线程内访问数据,就不需要同步,这种技术被称为线程封闭。

线程封闭JAVA中常用的地方有Swing可视化组件,JDBC等

线程封闭方式如下

  • Ad-hoc线程封闭

  • 栈封闭

    栈封闭其实就是通过局部变量来实现的,局部变量的固有属性就是在封闭的执行线程中,所以当多线程跑的时候,每个线程都会有独立的局部变量

  • ThreadLocal类

    ThreadLocal类能使线程中的某个值与保存值的对象关联起来,ThreadLocal提供get与set等访问接口活方法,这些方法为每个使用该变量的线程保存一份独立的副本,因此get总是返回由当前执行线程在调用的set时设置的最新值。

    使用如下

    private static ThreadLocal connectionHolder = new ThreadLoacl(){ public Connection initialValue(){ return DriverMananager.getConnection(DB_URL); } } public static Connection getConnection(){ return connectionHolder.get(); }

4.不变性

前面提到的原子性可见性相关,其实都是与多线程试图同时访问同一个可变状态相关,如果对象的状态不会发生改变,那么这些问题与复杂性也就自然消失了

不可变对象一定是线程安全的

不变性并不等于将对象中的所有域都声明为final类型

只有满足以下条件时,对象才是不可变的:

  • 对象创建以后其状态就不能修改
  • 对象的所有域都是final类型
  • 对象是正确创建的
5.安全发布

三.对象的组合

我们并不希望每次内存访问都分析确保程序是线程安全的,而是希望将一些现有的安全组件组合为更大规模的组件或程序

1.设计线程安全类

设计安全类过程中,需要包含以下三个基本要素:

  • 找出构成对象状态的所有变量
  • 找出约束状态变量的不变性条件
  • 建立对象并发访问管理策略

对于一个类来说他的对象变量受很多条件的约束,比如后验条件(大于多少,小于多少),先验条件(非空队列删除元素),不正确的操作就会导致变量无效的情况,如果某些状态是无效的,那么必须对底层的状态变量进行封装。

2.实例封闭

封装简化了线程安全类的实现过程,它提供了一种实例封闭机制,当一个对象被封装到另一个对象中时,能访问被封装对象的所有代码路径都是已知的,所以更易于对代码进行分析,通过将封闭机制与合适的加锁策略结合起来,可以确保以线程安全的方式来使用非线程安全的对象

如下

public class PersonSet{ private final Set mySet = new HashSet(); public synchronized void addPerson(Person p){ mySet.add(p); } public synchronized boolean containsPerson(Person p){ return mySet.contains(p); }

这里HashSet并非线程安全的,但由于mySet是私有的并且不会逸出,因此相关操作都是在PersonSet中的,并且PersonSet状态由内置锁保护,因此PersonSet是一个线程安全类

实例封闭是构建线程安全类的一个简单方式

从线程封闭原则及其逻辑推论可以得出Java监视器模式,遵循Java监视器模式的对象会把对象的所有可变状态都封装起来,并由对象自己的内置锁保护,许多类都使用了这种模式,如Vector和Hashtable

3.线程安全性的委托

如果类中的各个组件都是已经线程安全的,那么这个类是否就是线程安全的呢?实际则是需要通过实际情况判断的,某些情况下是线程安全的,某些情况下,则不一定

当某个类包含复合操作的时候,仅靠委托并不足以实现线程安全性,这种情况下,需要提供自己的加锁机制保证安全性

4.现有安全类添加功能

当一个类中有若没有则添加的需求

则需要客户端加锁

public class ListHelper{ public List list = Collections.synchronizedList(new ArrayList()); public boolean putIfAbsent(E x){ synchronized (list){ boolean absent = !list.contains(x); if (absent) list.add(x); return absent; } } }

当这样其实是脆弱的,因为我们添加了很多和业务代码并不相关的代码,所以其实我们有一个更好的的方法:组合,这个我们在开始也提到了

接下来就是完成上述要求的使用组合的一个方式

public class ImprovedList implements List{ private final List list; public ImprovedList(List list){ this.list = list; } public synchronized boolean putIfAbsent(T x){ boolean contains = list.contains(x); if (contains) list.add(x); return !contains; } }

ImprovedList通过自身的内置锁增加了一层额外锁,并不关心底层list是否是线程安全的,即使List不是线程安全的或者修改了它的加锁实现,但ImprovedList提供了一致的加锁机制来实现线程安全性。

四.基础构建模块

1.同步容器类

同步容器将所有对容器状态的访问都串行化,以实现他们的线程安全性,这种方法代价是严重降低并发性,当多个线程竞争容器的锁时,吞吐量将严重降低。

同步容器类包括:Vector与Hashtable,还有Collections提供封装的类,这些类实现线程安全的方式是:将它们的状态封装起来,并对每个公有方法进行同步,使得每次只有一个线程能访问容器的状态。

存在的问题

复合操作:迭代与条件运算会出现同步问题(并发情况下)

对于条件运算我们可以采取加锁方式来解决

但是对于迭代来说,如果容器规模很大,那么线程等待持有时间就会很长,降低了可伸缩性,可以降低吞吐量与CPU的利用率,如果不希望加锁方式,可以采用"克隆"容器,在副本中进行迭代。

另外注意隐藏的迭代器

除了显示的迭代器,toString,hashCode,equals会间接执行迭代操作 同样containAll,removeAll,retainAll等方法也会对容器进行迭代

2.并发容器

并发容器是针对于多个线程并发访问设计的,使用并发容器代替同步容器,可以极高地提高伸缩性并降低风险。

ConcurrentHashMap

ConcurrentHashMap使用了分段锁,一种粒度更细的加锁机制来实现更大程度的共享,这种机制中,任意数量的读取线程可以并发访问Map,执行读取操作的线程和执行写入操作的线程可以并非访问Map,并且一定数量的写入线程可以并发的修改Map。

在并发环境下将实现更高的吞吐量,而在单线程环境中只损失非常小的性能,所以ConcurrentHashMap对比于Hashtable来说有着更多的优势,所以大多数情况下可以用ConcurrentHashMap代替同步Map,除去当应用需要加锁Map进行独占访问的情况下。

CopyOnWriteArrayList

CopyOnWriteArrayList,使用了"写入时复制",即只要正确地发布一个事实不可改变对象,那么在访问该对象时就不再需要进一步的同步,每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。

CopyOnWriteArrayList用来替代同步的List

3.阻塞队列

BlockingQueue扩展了Queue,增加了可阻塞的插入和获取等操作

BlockingQueue的实现有

  • LinkedBlockingQueue FIFO队列,与LinkedList类似
  • ArrayBlockingQueue FIFO队列,与ArrayList类似
  • PriorityBlockingQueue 优先级排序队列
  • SynchronousQueue 并非真正的队列,不会为队列中的元素维护存储空间,内部维护一组线程,这些线程等待把元素加入或者移除队列

生产者-消费者模式可以基于阻塞队列构建,生产者将数据放入队列,消费者从队列中获取数据,进行处理。

双端队列

Deque与BlockingDeque是对Queue与BlockingQueue扩展,可以实现双端高效插入与移除

4.阻塞方法与中断方法
5.同步工具类

阻塞队列是一种独特的类:它们不仅能作为保存对象的容器,还能协调生产者和消费者等线程之间的控制流,因为take和put方法将阻塞,直到队列达到期望的状态

同步工具类可以是任何一个对象,只要它根据其自身的状态来协调线程的控制流,阻塞队列可以作为同步工具类,当然还有其他可以作为同步工具类的类型,接下来分别介绍

闭锁

闭锁可以延迟线程的进度直到其到达终止状态,闭锁可以用来确保某些活动直到其他活动都完成后才继续执行

CountDownLatch是一种灵活的闭锁实现,它可以使一个或者多个线程等待一组事件的发生,CountDownLatc内包含一个计数器,初始化为一个正数,表示需要等待的事件数量,countDown方法递减计数器,表示一个事件已经发生了,而await方法等待计数器值达到零,这表示所有等待事件都已经发生,如果计数器值非零,则会一直阻塞。

FutureTask也可以作为闭锁,FutureTask表示的计算通过Callable实现,相当于一种可以生成结果的Runnable,并且可以处于以下3种状态:等待运行,正在运行,运行完成,FutureTask.get的行为取决于任务的状态,如果任务完成,那么get可以立刻获取结果,如果没有,则get将阻塞直到任务进入完成状态,然后返回结果或者抛出异常。

信号量

计数信号量用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量,计数信号量还可以用来实现某种资源池,或者对容器施加边界

Semaphore中管理着一组虚拟的许可,许可的初始数量可通过构造函数来指定,在执行操作时候可以首先获得许可(acquire),并且在使用以后释放许可(release),如果没有许可,那么acquire方法将租塞到有许可。

栅栏

栅栏类似于闭锁,它能阻塞一组线程直到某个事件发生。栅栏与闭锁的关键区别在于,所有线程必须同事到栅栏位置,才可以继续执行,闭锁用于等待事件,而栅栏用于等待其他线程。

CyclicBarrier可以使一定数量的参与反复的在栅栏位置汇集,它在并行迭代算法中非常有用:这种算法通常将一个问题拆分成一系列相互独立的子问题,当线程到达栅栏位置将调用await方法,这个方法将阻塞直到所有线程都到达栅栏位置,如果所有线程都到达栅栏位置,那么栅栏将打开,此时所有线程都被释放,而栅栏将被重置以便下次使用。

五.任务执行

大多数并发应用都是围绕"任务执行"来构造的:任务通常是一些抽象的且离散的工作单元。通常把应用程序的工作分解到多个任务中,可以简化程序的组织结构,提供一种自然的事务边界来优化错误恢复过程,以及提供一种自然的并行工作结构来提升并发性。

1.在线程中执行任务

最简单的策略就是在单个线程中串行执行各项任务,但是一般串行机制通常都无法提供高吞吐量或快速响应

为了解决这个问题可以用显示为任务创建线程,在正常负载情况下,为每个任务分配一个线程提升串行执行的性能,这种方法可以同时带来更快的响应性和更高的吞吐量

但是这种无限制创建线程是有不足的,如下

  • 线程生命开销非常高
  • 资源消耗
  • 稳定性

在一定范围内,增加线程可以提高吞吐率,但是超过这个范围,再创建线程则会降低程序的执行速度,并且如果过多的创建,那么整个应用程序会面临崩溃的风险

2.Executor框架

鉴于上面两种任务执行方式的不足,我们可以采用线程池,线程池简化了线程的管理工作,并且java.util.concurrent提供了一种灵活的线程池实现作为Executor框架的一部分

虽然Executor是简单的接口,但它却为灵活且强大的异步任务执行框架提供了基础,该框架能支持多种不同类型的任务执行策略,它提供一直标准的方法将任务提交与执行过程解耦开来,并且Runnable来表示任务,Executor的实现还提供了对生命周期的支持,以及统计信息收集,应用程序管理机制和性能监视等机制。

类库提供了一个灵活的线程池以及一些有用的默认配置,可以通过调用Executors中的静态工厂方法之一来创建一个线程池:

newFixedThreadPool 创建固定长度的线程池

newCachedThreadPool 创建可缓存的线程池

newSingleThradExecutor 单线程的Executor

newScheduledThreadPool 创建固定长度线程池,并且以延迟或定时的方式来执行任务

关于Executor的生命周期

JVM只有在所有(非守护)线程全部中止后才会退出,因此无法正确关闭Executor,那么JVM将无法结束。

为了解决Executor生命周期问题,Executor扩展了ExecutorService接口

ExecutorService生命周期3种状态,运行,关闭与已终止,ExecutorService在初始创建处于运行状态,shutdown方法将执行平缓的关闭过程,不在接受新的任务,同时等待已经提交的任务执行完成,shutdownNow方法将执行粗暴的关闭过程,取消运行中任务,不在启动队列中尚未执行的任务。

用线程池来执行延迟任务与周期任务

Timer类负责管理延迟任务以及周期任务,但是Timer存在一些缺陷,如下:

Timer在执行所有定时任务时只会创建一个线程,如果某个任务执行时间过长,那么将破坏其他TimeTask的精确性。

如果TimeTask抛出一个未检查异常,Timer线程并不捕获异常,也不会恢复线程的执行,会错误的认为整个Timer都被取消了,从而终止定时线程。

所以我们可以用ScheduledThreadPoolExecutor来带他Timer

3.找出可利用的并行性

我们以一个例子来说明,怎么去找可利用的并行性,从而使我们的程序的并行性更好

我们的需求是:实现浏览器程序中的页面渲染功能,它的作用是将HTML页面绘制到图像缓存中,为了简便,假设HTML页面仅包含标签文本,以及预定义大小的图片与URL

1.用并行替代串行

图像下载过程大部分时间都是等待I/O的操作执行完成,所以我们可以先加载文本标签,然后等到图片下载后在加载图片,这样将问题分解为多个独立的任务并发执行,能够获得更高的CPU利用率和响应敏捷度。

如下

public class SingleThreadRenderer{ void renderPage(CharSequence source){ renderText(source); List imageData = new ArrayList(); for(ImageInfo imageInfo : scanForImageInfo(source)) imageData.add(imageInfo.downloadImage()); for(ImageData data:imageData) renderImage(data); } }

2.使用携带结果的任务Callable与Future

Executor框架使用Runnable作为基本的任务表示形式,Runnable是一种有很大局限的抽象,虽然run能写入到日志文件或者将结果放入某个共享的数据结构,但它不能返回一个值或者抛出一个受检查的异常。

实际上很多任务都存在延迟的计算,对于这些功能,Callable是更好的选择,因为Callable可以返回一个值,并可能抛出一个异常

Future则表示一个任务的生命周期,get方法取决于任务的状态(尚未开始,正在运行,已经完成)

  • 如果已经完成,那么get会立刻返回或者抛出一个Exception,
  • 如果任务没有完成,那么get将阻塞并直到任务完成
  • 如果任务抛出异常,那么get方法将该异常封装为ExcecutionException并重新抛出,可以通过getCause来获得被封装的初始异常
  • 如果任务被取消,那么get方法将抛出CancellationException

创建Future来描述任务有很多方式,如下

  • ExceutorService中的所有submit方法都将返回一个Future,从而将一个Runnable或者Callable提交给Exector,并得到一个Future用来获得任务的执行结果或者取消任务
  • 显示为某个指定的Runable或者Callable实例化一个FutureTask
  • 从Java6开始,ExceutorService实现可以改写AbstractExceutorService中的newTaskFor方法,从而根据已提交的Runnable或Callable来控制Future的实例化过程

public class FutureRender{ private final ExecutorService executor = ...; void renderPage(CharSequence source){ final List imageInfos = scanForImageInfo(source); Callable> task = new Callable>(){ public List call(){ List result = new ArrayList(); for(ImageInfo imageInfo : imageInfos){ result.add(imageInfo.downloadImage()); } return result; } } Future> future = executor.submit(task); renderText(sourece); try{ List imageData = future.get(); for (IamgeData data ; iamgeData){ renderIamge(data) } }catch(InterruptedException e){ Thread.currentThread().interrupt(); future.cancel(true); }catch(EcecutionException e){ throw launderThrowable(e.getCause()); } } }

但是这里存在的缺陷是用户要等到所以图像都下载完成后才显示,所以还有优化的控件存在

3.使用完成服务(CompletionService)

CompletionService将Executor和BlockingQueue的功能融合在一起,可以将Callable任务提交由他来执行,然后使用类似队列操作的take和poll方法来获得已完成的结果,而这些结果会在完成时封装为Future。

public class FutureRender{ private final ExecutorService executor = ...; void renderPage(CharSequence source){ final List imageInfos = scanForImageInfo(source); CompletionService(executor); for(fianl ImageInfo info: imageInfos){ completionService.submit(new Callable(){ public ImageData call(){ return imageInfo.downloadImage(); } }); } renderText(sourece); try{ for (int t =0 , n = imageInfos.size() ; t < n ; t++){ Future f = completionService.take(); ImageData data = f.get(); renderIamge(data) } }catch(InterruptedException e){ Thread.currentThread().interrupt(); }catch(EcecutionException e){ throw launderThrowable(e.getCause()); } } }

4.为任务设置时限

在支持时间限制的Future.get中支持这种需求:当结果可用时则立即返回,如果在指定时限内没有计算出结果,那么抛出TimeOutExection,就可以通过Future来取消任务

Page renderPageWithAd() throws InterruptedExection{ long endNanos = System.nanoTime() - TIME_BUDGET; Future f = exec.subimt(new FetchAdTask()); Page page = renderPageBody(); Ad ad; try{ long timeLeft = endNanos - System.nanoTime(); ad = f.get(timeLeft, NANOSECONDS) }catch(ExecutionException e){ ad = DEFAULT_AD; }catch(TimeoutExection e){ ad = DEFAULT_AD; f.cancel(true); } page.setAd(ad); return page; }

六.取消与关闭

1.任务取消

任务取消有很多原因,如下

  • 用户请求取消
  • 有时间限制的操作
  • 应用程序事件
  • 错误
  • 关闭

然而,Java中并没有一种安全的抢占式方法来停止线程。

一种解决方案,是设置某个"已请求取消"标志位,如果设置这个标识位,半任务提前结束,为了可靠的工作,标志位必须为volatile类型

然而在租塞队列中,并且租塞的情况下,可能那个标识位永远都不会执行到

一些特殊租塞库的方法支持中断。

每个线程都有一个boolean类型的中断状态,在线程中断时候会被设置为true。

对于中断的正确理解是:它并不会真正的中断一个正在运行的线程,而是发出中断请求,然后线程在下一个合适时刻中断自己,中断的处理在InterruptedException。

怎么响应中断?

  • 传递异常(可能在执行某个特定任务的清除操作之后),从而使你的方法也成为可中断的阻塞方法

    BlockingQueue queue; ... public Task getNextTask() throws InterruptedException{ return queue.take(); }

  • 恢复中断状态,从而是调用栈中的上层代码能够对其处理

    public class TaskRunner implements Runnable { private BlockingQueue queue; public TaskRunner(BlockingQueue queue) { this.queue = queue; } public void run() { try { while (true) { Task task = queue.take(10, TimeUnit.SECONDS); task.execute(); } } catch (InterruptedException e) { // Restore the interrupted status Thread.currentThread().interrupt(); } } }

处理不可中断的阻塞

并非所有的可阻塞方法或者阻塞机制都能响应中断,对于那些由于执行不可中断操作而被阻塞的线程,可以使用类似于中断的手段来停止这些线程

  • Java.io包中的同步SocketI/O 关闭底层套接字,可以使得由于执行read或write等方法而被阻塞的线程抛出一个SocketException。
  • Java.io包中的同步I/O 中断一个正在InterruptibleChannel上等待的线程时,将抛出ClosedByInterruptException并关闭链路
  • Selector的异步I/O 如果一个线程在调用Selector.select方法时阻塞了,那么调用close或wakeup方法会使线程抛出ClosedSelectorException并提前返回。
  • 获取某个锁 Lock类提供了LockInterruptibly方法,该方法允许在等待一个锁同时响应中断请求。

通过Future实现取消

Future拥有一个cancel方法,该方法带有一个boolean类型到底参数mayInterruptIfRunning,表示取消操作是否成功,如果mayInterruptIfRunning为true并且任务当前正在某个线程中运行,那么这个线程能被中断,如果这个参数为false,那么意味着"若任务还没有启动,就不要运行它",这种方式应该用于那些不处理中断的任务。

当Future.get抛出InterruptedException或TimeoutException时,如果你知道不再需要结果,那么就可以调用Future.cancel来取消任务。

newTaskFor是Java 6在ThreadPoolExecutor中新增的功能,当把一个Callable提交给ExcutorService时,submit方法会返回一个Future,我们可以通过这个Future来取消任务,newTaskFor是一个改成方法,它将创建Future来代表任务,newTaskFor还能返回一个RunnableFuture接口,该接口扩展Future和Runnable(并由FutureTask实现)

2.停止基于线程的服务

应用程序可以拥有服务,服务也可以拥有工作线程,但应用程序不能拥有工作线程,因此应用程序不能直接停止工作线程,相反,服务应该提供生命周期方法来关闭它自己及它所拥有的线程。这样,当应用程序关闭该服务时,服务就可以关闭所有的线程了。

关闭ExecutorService

ExecutorService提供了两种关闭方法shutdown正常关闭,shutdownNow强行关闭。

两者差别在于各自的安全性和响应性:强行关闭速度快,但风险也更大,正常关闭虽然速度慢,但更安全。

"毒丸"对象

"毒丸"是指一个放在队列上的对象,其含义是:"当得到这个对象时,立即停止"。在FIFO(先进先出)队列中,"毒丸"对象将确保消费者在关闭前首先完成队列中的所有工作,在提交"毒丸"对象之前提交的所有工作都会被处理,而生产者在提交了"毒丸"对象后,将不会再提交任何工作。

3.处理非正常的线程终止

线程非正常退出的后果可能是良性的,也可能是恶性的,这要取决于线程在应用程序中的作用。

可以通过未捕获异常处理,通过UncaughtExceptionHandler来检测出某个线程由于未捕获异常而终结的情况,从而进行处理。

4.JVM关闭

JVM即可以正常关闭也可以强行关闭,

正常关闭的触发方式有多种

  • 最后一个"正常(非守护)"线程结束时
  • 调用System.exit时
  • 通过其他特定于平台的方法关闭时(例如发送了SIGINT信号或者键入Ctrl-C)

强行关闭方式如下:

  • 调用Runtime.halt
  • 在操作系统中"杀死"JVM进程(例如发送SIGKILL)来强行关闭JVM。

关闭钩子

正常关闭中,JVM首先调用所有已注册的关闭钩子,关闭钩子是指通过Runtime.addShutdownHook注册的但尚未开始的线程,在关闭应用程序线程时,如果有(守护或非守护)线程仍然在运行,那么这些线程接下来将与关闭进程并发执行,当所有关闭钩子都执行结束时,如果runFinalizersOnExit为true,那么JVM将运行终结器,然后再停止。

关闭钩子应该是线程安全的。

守护线程

线程分为两种,普通线程与守护线程,除了主线程外其他都是守护线程。

守护线程一般用来执行一些辅助工作,而且这个线程并不阻碍JVM的关闭

普通线程与阻塞线程之间的差异仅在于当线程退出时发生的操作,当JVM中所有的线程都是守护线程的时候,JVM就可以退出了(如果User Thread全部撤离,那么Daemon Thread也就没啥线程好服务的了,所以虚拟机也就退出了);如果还有一个或以上的非守护线程则不会退出。(以上是针对正常退出,调用System.exit则必定会退出)。

我们尽量可能少的使用守护线程,很少有操作能够在不进行清理的情况下被安全的抛弃。

终接器

垃圾回收器定义了finalize方法对对象进行特殊处理:在回收器释放它们后,调用他们的finalize方法,从而保证一些持久化的资源被释放。

七.线程池的使用

1.在任务与执行策略之间的隐性耦合

Executor框架可以将任务的提交与任务的执行策略解耦开来,虽然Executor框架为定制和修改执行策略都提供相当大的灵活性,但并非所有的任务都适用所有的执行策略,有些任务需要明确指定执行策略,如:

  • 依赖性任务
  • 使用线程封闭机制的任务
  • 对响应时间敏感的任务
  • 使用ThreadLocal的任务

只有当任务都是同一类型的并且相互独立时,线程池的性能才能达到最佳

线程饥饿死锁

在线程池中,如果任务依赖于其他任务,那么可能产生死锁

如果一个任务将另一个任务提交到同一个Executor,并等待这个被提交任务的结果,那么通常会引发死锁,第二个任务停留在工作队列中,并等待第一个任务完成,而第一个任务又无法完成,因为它在等待第二个任务完成,这种现象被称为线程饥饿死锁。

运行时间较长的任务

如果任务阻塞的时间过长,线程池的响应性也会变得糟糕,甚至还会增加执行时间较短任务的服务时间。

2.设置线程池的大小

设置线程池的大小,只需要避免"过大"和"过小"这两种极端情况即可。

在拥有N个处理器的系统上,当线程池的大小为N+1时,通常能实现最优的利用率。

3.配置ThreadPoolExecutor

线程的创建与销毁

线程池的基本大小,最大大小,存活时间等因素共同负责线程的创建与销毁。

newFixedThreadPool工厂方法将线程池的基本大小和最大大小设置为参数中指定的值,而且创建的线程池不会超时。 newCachedThreadPool工厂方法将线程池的最大大小设置为Interger.MAX_VALUE,而将基本大小设置为零,并将超时时间设置为1分钟,这种方法创建出来的线程池可以被无限扩展。并当需求降低时候自动收缩。 其他形式的线程池可以通过显示的ThreadPoolExecutor构造函数来构造。

管理队列任务

ThreadPoolExecutor允许提供一个BlockingQueue来保存等待执行的任务,排列方法有三种:无界队列,有界队列,同步移交,队列的选择与其他的配置参数有关,例如线程池的大小等。

无界队列: newFixedThreadPool和newSingleThreadExecutor在默认情况下使用一个无界的LinkedBlockingQueue。如果所有工作者线程都处于忙碌状态,那么任务将在队列等候,如果任务持续快速的到达,并且超过了线程池的处理速度,那么队列会无限制地增加。

有界队列: ArrayBlockingQueue,有界的LinkedBlockingQueue,PriorityBlockingQueue,有界队列有助于避免资源耗尽的情况发生,但是也会面临队列填满后,新的任务该怎么办?

同步移交: 对于非常大的或者无界的线程池,可以通过使用SynchronousQueue来避免任务队列,SynchronousQueue不是一个真正的队列,而是一种在线程之间进行移交的机制,在newCachedThreadPool中就使用了SynchronousQueue。

只有在任务相互独立时,为线程池或工作队列设置界限才是合理的,如果任务之间存在依赖性,那么有界的线程池或队列就可能导致线程饥饿死锁问题,此时应该使用无界的线程池,例如newCachedThreadPool。

饱和策略

前面提到有界队列填满后,新的任务改怎么办?这时候饱和策略就开始发货作用了,ThreadPoolExecutor的饱和策略可以通过调用setRejectedExecutionHandler来修改,JDK提供几种不同的RejectedExecutionHandler实现,每种实现都包含不同的饱和策略:AbortPolicy,CallerRunsPolicy,DiscardPolicy和DiscardOldestPolicy。

简单介绍下:

AbortPolicy(中止策略)是默认的饱和策略,该策略将抛出未检查的RejectedExecutionException。调用者可以捕获这个异常,然后根据需求编写自己的处理代码,当新的任务无法保存到队列中等待执行时,DiscardPolicy(抛弃策略)会悄悄抛弃该任务,DiscardOldestPolicy(抛弃最旧策略)则会抛弃下一个将被执行的任务,然后尝试重新提交新的任务。

CallerRunsPolicy(调用者运行策略)既不会抛弃任务也不会抛弃异常,而是将某些任务回退给调用者,从而降低新任务的流量。

4.扩展ThreadPoolExecutor

ThreadPoolExecutor是可以扩展的,它提供了几个可以在子类中改写的方法:beforeExecute,afterExecute和terminated,这结果方法可以用于扩展ThreadPoolExecutor的行为。

在执行任务的线程中将调用beforeExecute和afterExecute方法,在线程池关闭时调用terminated方法。

线程工厂

每当线程池需要创建一个线程时,都是通过线程工厂方法来完成的,通过指定一个线程工厂方法,可以定制线程池的配置信息。

在调用构造函数后再定制ThreadPoolExecutor

调用完ThreadPoolExecutor的构造函数后,仍然可以通过设置函数来修改传递给构造函数的参数,在Executors中包含一个unconfigurableExecutorService工厂方法,这个方法对现有的ExecutorService进行包装,使其只暴露出ExecutorService的方法,因此不能对它进行配置。从而防止执行策略被修改。

八.避免活跃性危险

我们使用加锁机制可以用来确保线程安全,但如果过度使用加锁,则可能导致锁顺序死锁,同样使用线程池和信号量来限制对资源的使用,会导致资源死锁等问题。

需要注意的是:JAVA应用程序无法从死锁中恢复过来,所以需要在设计时候就排除可能导致死锁的条件

1.死锁

产生死锁的原因:每个人都拥有其他人需要的资源,同时又等待其他人已经拥有的资源,并且每个人在获得所有需要的资源之前都不会放弃已拥有的资源。

另外数据库服务器是不会发生死锁的情况,当检测到一组事务发生了死锁时,将选择一个牺牲者并放弃这个事务,当然JVM在解决死锁问题方面没有数据库那么强大。

顺序死锁

发生死锁代码如下

public class LeftRightDeadLock{

   private final Object left = new Object();
   private final Object right = new Objetct();
   
   public void leftRight(){
        synchronized (left){
            synchronized (right){
                doSomething();
            }
        }
   }

    public void rightLeft(){
        synchronized (right){
            synchronized (left){
                doSomething();
            }
        }
   }
}

leftRight与rightLeft这两个方法分别获得left锁和right锁。

如果一个线程调用leftRight,另一个线程调用rightLeft,并且这两个线程的操作是交错执行的时候,则会发生死锁。

如果所有线程都以固定的顺序来获得锁,那么在程序中就不会出现锁顺序死锁问题。

动态的顺序死锁

发生死锁代码如下

public void transferMoney(Account fromAccount,Account toAccount,DollarAmount amount) throws InsufficientFundsException{
    synchronized(fromAccount){
        synchronized(toAccount){
            doSomething();
        }
    }
}

当两个线程调用如下代码时候

A:transferMoney(myAccount,yourAccount,10);
B:transferMoney(yourAccount,myAccount,10);

这个方式发生死锁其实和上面发生死锁的原因是一致的。

但是这里还有一个方法来避免动态的顺序死锁,制定锁的顺序时候,可以使用System.identityHashCode方法,该方法返回由Object.hashCode返回的值,极少数情况下,两个对象可能拥有相同的hashCode,所有使用System.identityHashCode方法还是可以有效的避免死锁问题。

协作对象之间发生死锁

如果在持有锁的时调用某个外部方法,那么将出现活跃性问题,在这个外部方法中可能获取其他锁(这可能会产生死锁),或者阻塞时间过长,导致其他线程无法及时获得当前被持有的锁。

资源死锁

和前面情况一样,当线程在相同资源集合上等待时,也会发生死锁。

2.死锁的避免与诊断

设计时必须考虑锁的顺序:

  • 尽量减少潜在的加锁交互数量
  • 将获取锁时需要遵循的协议写入正式文档并始终遵循这些协议

支持定时锁

有一项技术可以检测死锁和从死锁中恢复过来,即显示使用Lock类中的定时tryLock功能来带他内置锁机制,当使用内置锁时,只要没有获得锁,就会一直等待下去,而显示锁则可以指定一个超时时限,在等待超过该时间后tryLock会返回一个失败信息。

通过线程转储信息来分析死锁

JVM可以通过线程转储来识别死锁的发生,线程转储包括各种运行中的线程的栈追踪信息。

3.其他活跃性危险

饥饿

当线程由于无法访问它所需要的资源而不能继续执行时,就发生了"饥饿",引发饥饿的最常见资源就是CPU时钟周期。

如果Java应用程序中对线程的优先级使用不当,或者在持有锁时执行一些无法结束的结构,那么可能导致饥饿,因为其他需要这个锁的线程将无法得到它。

通常,尽量不要改变线程的优先级。

糟糕的响应性

不良的锁管理也可能导致糟糕的响应性,如果某个线程长时间占有一个锁,而其他想要访问这个容器的线程就必须要等待很长时间。

活锁

九.性能与可伸缩性

1.对性能的思考

提升性能意味着用更少的资源做更多的事情,

尽管使用多线程并发的目的是提升性能,但是使用多线程也会引起额外的性能开销,如果并发设计很糟糕,性能可能还不如串行的程序。

想要通过并发获得更好的性能,需要努力做好两件事情:更有效利用现有处理资源,在出现新的处理资源时使程序尽可能地利用这些新资源。

性能与可伸缩性

应用程序性能可以采用多个指标来衡量,其中一些指标(服务时间,等待时间)用于衡量程序的"运行速度"(多快),另一些指标(生产量,吞吐量)用于程序的"处理能力"(多少)。

可伸缩性指的是:当增加计算资源时(例如CPU,内存,存储容量或I/O带宽),程序的吞吐量或者处理能力能相应地增加。

性能这两个特性"多快","多少"是完全独立的,有时候甚至是相互矛盾的。

评估各种性能权衡因素

避免不成熟优化,首先程序正确,然后提高运行速度-如果它运行得不够快。

2.线程引入开销

多个线程调度和协调过程中都需要一定的性能开销,对于为了提升性能而引入的线程来说,并行带来的性能提升必须超过并发导致的开销。

上下文切换

如果可运行的线程数大于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,使其他线程能够使用CPU,这将导致上下文切换,这个过程中将保存当前运行线程执行上下文,并将新调度进来的线程执行上下文设置为当前上下文,切换上下文需要一定的开销。

内存同步

阻塞

3.减小锁的竞争

并发程序中,对伸缩性最主要威胁就是独占方式的资源锁

有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,以及每次持有锁的时间,如果两者的乘积很小,那么大多数获得锁的操作请求都不会发生竞争。

有3种方式可以降低锁的竞争程度:

  • 减少锁的持有时间。
  • 降低锁的请求频率。
  • 使用带有协调机制的独占锁,这些机制允许更高的并发性。

缩小锁的范围

减少锁的粒度

多个需要保护的相互独立的状态变量,尽量不要用一个锁,用多个锁保护每个变量

锁分段

ConcurrentHashMap

一些替代独占锁的方法

放弃使用独占锁,使用一种友好并发的方式来管理共享状态,例如:并发容器,读写锁,不可变对象以及原子变量。

监测CPU的利用率

如果CPU并没有得到充分利用,需要找出原因,一般有以下几种

  • 负载不充足
  • I/O密集
  • 外部限制
  • 锁竞争

对对象池说不

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容