5.基础构建模块

基础构建模块

5.1同步容器类

早期的同步容器类VectorHashtable,以及由Collections.synchronizedXxx等工厂方法创建的同步的封装器类,它们实现线程安全的方式是:将它们的状态封装起来,并对每个公有方法都进行同步,使得每次只有一个线程能访问容器的状态。

5.1.1同步容器类的问题

同步容器类都是线程安全的,但涉及到包括迭代跳转(根据指定顺序找到当前元素的下一个元素)以及条件运算,例如若没有则添加等复合操作时需要额外的客户端加锁来保护。当其他线程并发地修改容器时,可能会表现出意料之外的行为。

public static Object getLast(Vector list) {
	int lastIndex = list.size() - 1;
	return list.get(lastIndex);
}

public static void deleteLast(Vector list) {
	int lastIndex = list.size() - 1;
	list.remove(lastIndex);
}

如果线程A在包含10个元素的Vector上调用getLast,同时线程B在同一个Vector上调用deleteLast

在这里插入图片描述

getLast将抛出ArrayIndexOutOfBoundsException异常。
由于同步容器类要遵守同步策略,即支持客户端加锁,因此可能会创建一些新的操作,只要知道应该使用哪一个锁,那么这些新操作就与容器的其他操作一样都是原子操作。同步容器类通过其自身的锁来保护它的每个方法。通过获得容器类的锁,可以使getLastdeleteLast成为原子操作,并确保Vector的大小在调用sizeget之间不会发生变化:

public static Object getLast(Vector list) {
	synchronized (list) {
		int lastIndex = list.size() - 1;
		return list.get(lastIndex);
	}
}

public static void deleteLast(Vector list) {
	synchronized (list) {
		int lastIndex = list.size() - 1;
		list.remove(lastIndex);
	}
}

在调用size和相应的get之间,Vector的长度可能会发生变化,这种风险在对Vector中的元素进行迭代时仍然会出现:

for (int i = 0; i < vector.size(); i++) {
	doSomething(vector.get(i));
}

这种迭代操作的正确性要依赖于运气,即在调用sizeget之间没有线程会修改Vector。在单线程环境中,这种假设完全成立,但在有其他线程并发地修改Vector时,则可能导致麻烦。与getLast一样,如果在对Vector进行迭代时,另一个线程删除了一个元素,并且这两个操作交替执行,那么这种迭代方法将抛出ArrayIndexOutOfBoundsException异常。
虽然迭代操作可能抛出异常,但并不意味着Vector就不是线程安全的。Vector的状态仍然是有效的,而抛出的异常也与其规范保持一致。然而,像在读取最后一个元素或者迭代等这样的简单操作中抛出异常显然不是人们所期望的。
可以通过在客户端加锁来解决不可靠迭代的问题,但要牺牲一些伸缩性:

// 通过在迭代期间持有Vector的锁,可以防止其他线程在迭代期间修改Vector,然而,
// 这同样会导致其他线程在迭代期间无法访问它,因此降低了并发性
synchronized (vector) {
	for (int i = 0; i < vector.size(); i++) {
		doSomething(vector.get(i));
	}
}
5.1.2迭代器与ConcurrentModificationException

无论直接迭代还是for-each循环,其标准方式都是使用Iterator。然而,如果有其他线程并发地修改容器,那么即使是使用迭代器也无法避免在迭代期间对容器加锁。这些容器所表现出的行为是及时失败的,即当它们发现容器在迭代过程中被修改时,就会抛出一个ConcurrentModificationException异常。
这种及时失败的迭代器只是善意地捕获并发错误,因此只能作为并发问题的预警指示器。它们所采用的方式是,将计数器的变化与容器关联起来:如果在迭代期间计数器被修改,那么hasNextnext将抛出ConcurrentModificationException。然而,这种检查是在没有同步的情况下进行的,因此可能会看到失效的计数值,而迭代器可能并没有意识到已经发生了修改。

List<Widget> widgetList = Collections.synchronizedList(new ArrayList<>());
// ...
// 其他线程并发修改,此时可能抛出ConcurrentModificationException
for (Widget w : widgetList) {
	doSomething(w);
}

然而,有时候并不希望在迭代期间对容器加锁。例如,如果容器的规模很大,或者在每个元素上执行操作的时间很长,那么其他线程将长时间等待。同样,如果直接对容器对象加锁,那么在迭代容器时将持有一个锁,这可能会产生死锁。即使不存在饥饿或者死锁等风险,长时间地对容器加锁也会降低程序的可伸缩性。持有锁的时间越长,那么在锁上的竞争就可能越激烈。如果许多线程都在等待锁被释放,那么将极大地降低吞吐量和CPU的利用率。
如果不希望在迭代期间对容器加锁,那么一种替代方法就是克隆容器,并在副本上进行迭代(在克隆过程中仍然需要对容器加锁)。在克隆容器时存在显著的性能开销。这种方式的好坏取决于多个因素,包括容器的大小,在每个元素上执行的工作,迭代操作相对于容器其他操作的调用频率,以及在响应时间和吞吐量等方面的需求。

5.1.3隐藏迭代器

虽然加锁可以防止迭代器抛出ConcurrentModificationException,但必须要记住在所有对共享容器进行迭代的地方都需要加锁。然而,在某些情况下,迭代器会隐藏起来。

public class HiddenIterator {
	@GuardedBy("this")
	private final Set<Integer> set = new HashSet<>();

	public synchronized void add(Integer i) {
		set.add(i);
	}
	
	public synchronized void remove(Integer i) {
		set.remove(i);
	}
	
	/**
	 * 可能会抛出ConcurrentModificationException,因为在生成调试消息的过程中,toString对容器进行迭代。当然,
	 * 真正的问题在于HiddenIterator不是线程安全的。在使用println中的set之前必须首先获取HiddenIterator的锁,
	 * 但在调试代码和日志代码中通常会忽视这个要求。
	 */
	public void addTenThings() {
		Random r = new Random();

		for (int i = 0; i < 10; i++) {
			add(r.nextInt());
		}

		System.out.println("DEBUG: added ten elements to " + set);
	}
}

如果状态与保护它的同步代码之间相隔越远,那么开发人员就越容易忘记在访问状态时使用正确的同步
容器的hashCodeequals等方法也会间接地执行迭代操作,当容器作为另一个容器的元素或键值时,就会出现这种情况。同样,containsAllremoveAllretainAll等方法,以及把容器作为参数的构造函数,都会对容器进行迭代。所有这些间接的迭代操作都可能抛出ConcurrentModificationException

5.2并发容器

同步容器在执行每个操作期间都持有一个锁,因此将所有对容器状态的访问都串行化,代价是严重降低并发性能。
另一方面,并发容器时针对多个线程并发访问设计的。例如,ConcurrentHashMap以及CopyOnWriteArrayList等。在新的ConcurrentHashMap接口中增加了对一些常见复合操作的支持,例如若没有则添加、替换以及有条件删除等。
BlockingQueue扩展了Queue,增加了可阻塞的插入和获取等操作。如果队列为空,那么获取元素的操作将一直阻塞,直到队列中出现一个可用的元素。如果队列已满(对于有界队列来说),那么插入元素的操作将一直阻塞,直到队列中出现可用的空间。在生产者/消费者模式中,阻塞队列是非常有用的。

5.2.1ConcurrentHashMap

ConcurrentHashMap也是一个基于散列的Map,但它使用分段锁这种粒度更细的加锁机制来实现更大程度的共享。在这种机制中,任意数量的读取线程可以并发地访问Map,执行读取操作的线程和执行写入操作的线程可以并发地访问Map,并且一定数量的写入线程可以并发地修改Map
另一方面,ConcurrentHashMap提供的迭代器不会抛出ConcurrentModificationException,因此不需要在迭代过程中对容器加锁,其返回的迭代器具有弱一致性,而并非及时失败。弱一致性的迭代器可以容忍并发的修改,当创建迭代器时会遍历已有的元素,并可以(但是不保证)在迭代器被构造后将修改操作反映给容器。
尽管有这些改进,但对于一些需要在整个Map上进行计算的方法,例如sizeisEmpty,这些方法的语义被略微减弱了以反映容器的并发特性。例如,由于size返回的结果在计算时可能已经过期了,它实际上只是一个估计值,因此允许size返回一个近似值而不是一个精确值。
大多数情况下,用ConcurrentHashMap来代替同步Map能进一步提高代码的可伸缩性。只有当应用程序需要加锁Map以进行独占访问时,才应该放弃使用ConcurrentHashMap

5.2.2额外的原子Map操作

由于ConcurrentHashMap不能被加锁来执行独占访问,因此无法使用客户端加锁来创建新的原子操作。但是,一些常见的复合操作,例如若没有则添加等,都已经实现为原子操作并且在ConcurrentHashMap的接口中声明。

5.2.3CopyOnWriteArrayList

CopyOnWriteArrayList用于替代同步List,在某些情况下提供了更好的并发性能,并且在迭代期间不需要对容器进行加锁或复制。
写入时复制(copy-on-write)容器的线程安全性在于,只要正确地发布一个事实不可变的对象,那么在访问该对象时就不再需要进一步的同步。在每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。写入时复制容器的迭代器保留一个指向底层基础数组的引用,这个数组当前位于迭代器的起始位置,由于它不会被修改,因此在对其进行同步时只需确保数组内容的可见性。因此,多个线程可以同时对这个容器进行迭代,而不会彼此干扰或者与修改容器的线程相互干扰。写入时复制容器返回的迭代器不会抛出ConcurrentModificationException,并且返回的元素与迭代器创建时的元素完全一致,而不必考虑之后修改操作所带来的影响。
显然,每当修改容器时都会复制底层数组,这需要一定的开销,特别是当容器的规模较大时。仅当迭代操作远远多于修改操作时,才应该使用写入时复制容器。

5.3阻塞队列和生产者/消费者模式

阻塞队列提供了可阻塞的puttake方法,以及支持定时的offerpoll方法。如果队列已经满了,那么put方法将阻塞直到有空间可用;如果队列为空,那么take方法将会阻塞直到有元素可用。队列可以是有界的也可以是无界的,无界队列永远都不会充满,因此无界队列上的put方法永远不会阻塞。
阻塞队列支持生产者/消费者这种设计模式。该模式能简化开发过程,因为它消除了生产者类和消费者类之间的代码依赖性,此外,该模式还将生成数据的过程与使用数据的过程解耦开来以简化工作负载的管理,因为这两个过程在处理数据的速率上有所不同
生产者不需要知道消费者的标识或数量,或者它们是否是唯一的生产者,而只需将数据放入队列即可。同样,消费者也不需要知道生产者是谁,或者工作来自何处。BlockingQueue简化了生产者/消费者设计的实现过程,它支持任意数量的生产者和消费者。一种最常见的生产者/消费者设计模式就是线程池与工作队列的组合。另一方面,生产者和消费者的角色是相对的,某种环境中的消费者在另一种不同的环境中可能会成为生产者。

阻塞队列简化了消费者程序的编码,因为take操作会一直阻塞直到有可用的数据。如果生产者不能尽快地产生工作项使消费者保持忙碌,那么消费者就只能一直等待,直到有工作可做。在某些情况下,这种方式是非常合适的(例如,在服务器应用程序中,没有任何客户请求服务),而在其他一些情况下,这也表示需要调整生产者线程数量和消费者线程数量之间的比率,从而实现更高的资源利用率。
如果生产者生成工作的速率比消费者处理工作的速率快,那么工作项会在队列中累积起来,最终耗尽内存。同样,put方法的阻塞特性也极大地简化了生产者的编码。如果使用有界队列,那么当队列充满时,生产者将阻塞并且不能继续生成工作,而消费者就有时间来赶上工作处理进度。
阻塞队列同样提供了一个offer方法,如果数据项不能被添加到队列中,那么将返回一个失败状态。这样就能够创建更多灵活的策略来处理负荷过载的情况,例如减轻负载,将多余的工作项序列化并写入磁盘,减少生产者线程的数量,或者通过某种方式来抑制生产者线程。
在构建高可靠的应用程序时,有界队列是一种强大的资源管理工具:它们能抑制并防止产生过多的工作项,使应用程序在负荷过载的情况下变得更加健壮

另一方面,生产者和消费者的行为仍然会通过共享工作队列间接地耦合在一起。开发人员总会假设消费者处理工作的速率能赶上生产者生成工作项的速率,因此通常不会为工作队列的大小设置边界,但这将导致在之后需要重新设计系统架构。因此,应该尽早地通过阻塞队列在设计中构建资源管理机制,这件事情做得越早,就越容易。在许多情况下,阻塞队列能使这项工作更加简单,如果阻塞队列并不完全符合设计需求,那么还可以通过信号量(Semaphore)来创建其他的阻塞数据结构。

在类库中包含了BlockingQueue的多种实现,其中,LinkedBlockingQueueArrayBlockingQueue是FIFO队列,二者分别与LinkedListArrayList类似,但比同步List拥有更好的并发性能。PriorityBlockingQueue是一个按优先级排序的队列,当希望按照某种顺序而不是FIFO来处理元素时,这个队列将非常有用。正如其他有序的容器一样,PriorityBlockingQueue既可以根据元素的自然顺序来比较元素(如果它们实现了Comparable接口的方法),也可以使用Comparator来比较。
最后一个BlockingQueue实现是SynchronousQueue,实际上它不是一个真正的队列,因为它不会为队列中的元素维护存储空间。与其他队列不同的是,它维护一组线程,这些线程在等待着把元素加入或移出队列。虽然很奇怪,但由于可以直接交付工作,从而降低了将数据从生产者移动到消费者的延迟。直接交付方式还会将更多关于任务状态的信息反馈给生产者。当交付被接受时,它就知道消费者已经得到了任务,而不是简单地把任务放入一个队列。因为SynchronousQueue没有存储功能,因此puttake会一直阻塞,直到有另一个线程已经准备好参与到交付过程中。仅当有足够多的消费者,并且总是有一个消费者准备好获取交付的工作时,才适合使用同步队列

5.3.1示例:桌面搜索

有一种类型的程序适合被分解为生产者和消费者,例如代理程序,它将扫描本地驱动器上的文件并建立索引以便随后进行搜索:

/**
 * 生产者/消费者模式将文件遍历与建立索引等功能分解为独立的操作,可以并发的执行。另一方面,如果一个是I/O密集型,
 * 另一个是CPU密集型,那么并发执行的吞吐率要高于串行执行的吞吐率。如果生产者和消费者的并行度不同,那么将它们紧密
 * 耦合在一起会把整体并行度降低为二者中更小的并行度。
 */
public class FileCrawler implements Runnable {
	private final BlockingQueue<File> fileQueue;
	private final FileFilter fileFilter;
	private final File root;

	// ...

	public void run() {
		try {
			crawl(root);
		} catch (InterruptedException e) {
			Thread.currentThread().interrupt();
		}
	}

	private void crawl(File root) throws InterruptedException {
		File[] entries = root.listFiles(fileFilter);
		if (entries != null) {
			for (File entry : entries) {
				if (entry.isDirectory()) {
					crawl(entry);
				} else if (!alreadyIndexed(entry)) {
					fileQueue.put(entry);
				}
			}
		}
	}
}

public class Indexer implements Runnable {
	private final BlockingQueue<File> queue;

	public Indexer(BlockingQueue<File> queue) {
		this.queue = queue;
	}

	public void run() {
		try {
			while (true) {
				indexFile(queue.take());
			}
		} catch (InterruptedException e) {
			Thread.currentThread().interrupt();
		}
	}
}

// ...

public static void startIndexing(File[] roots) {
	BlockingQueue<File> queue = new LinkedBlockingQueue<>(BOUND);
	FileFilter filter = new FileFilter() {
		public boolean accept(File file) {
			return true;
		}
	};

	for (File root : roots) {
		new Thread(new FileCrawler(queue, filter, root)).start();
	}

	for (int i = 0; i < N_CONSUMERS; i++) {
		new Thread(new Indexer(queue)).start();
	}
}
5.3.2串行线程封闭

java.util.concurrent中实现的各种阻塞队列都包含了足够的内部同步机制,从而安全地将对象从生产者线程发布到消费者线程。
对于可变对象,生产者/消费者这种设计与阻塞队列一起,促进了串行线程封闭,从而将对象所有权从生产者交付给消费者。线程封闭对象只能由单个线程拥有,但可以通过安全地发布该对象来转移所有权。在转移所有权后,也只有另一个线程能获得这个对象的访问权限,并且发布对象的线程不会再访问它。这种安全的发布确保了对象状态对于新的所有者来说是可见的,并且由于最初的所有者不会再访问它,因此对象将被封闭在新的线程中。新的所有者线程可以对该对象做任意修改,因为它具有独占的访问权。
对象池利用串行线程封闭,将对象借给一个请求线程。只要对象池包含足够的内部同步来安全地发布池中的对象,并且只要客户代码本身不会发布池中的对象,或者在将对象返回给对象池后就不再使用它,那么就可以安全地在线程之间传递所有权。
也可以使用其他发布机制来传递可变对象的所有权,但必须确保只有一个线程能接受被转移的对象。阻塞队列简化了这项工作。除此之外,还可以通过ConcurrentMap的原子方法remove或者AtomicReference的原子方法compareAndSet来完成这项工作。

5.3.3双端队列与工作密取

双端队列适用于另一种相关模式,即工作密取,在该设计中,每个消费者都有各自的双端队列。如果一个消费者完成了自己双端队列中的全部工作,那么它可以从其他消费者双端队列末尾秘密地获取工作。密取工作模式比传统的生产者/消费者模式具有更高的可伸缩性,这是因为工作者线程不会在单个共享的任务队列上发生竞争。在大多数时候,它们都只是访问自己的双端队列,从而极大地减少了竞争。当工作者线程需要访问另一个队列时,它会从队列的尾部而不是从头部获取工作,因此进一步降低了队列上的竞争程度。
工作密取非常适用于既是消费者也是生产者问题:当执行某个工作时可能导致出现更多的工作。例如,在网页爬虫程序中处理一个页面时,通常会发现有更多的页面需要处理。当一个工作线程找到新的任务单元时,它会将其放到自己队列的末尾(或者在工作共享设计模式中,放入其他工作者线程的队列中)。当双端队列为空时,它会在另一个线程的队列队尾查找新的任务,从而确保每个线程都保持忙碌状态

5.4阻塞方法与中断方法

线程可能会阻塞或暂停执行,原因有多种:等待I/O操作结束,等待获得一个锁,等待从Thread.sleep方法中醒来,或是等待另一个线程的计算结果。当线程阻塞时,它通常被挂起,并处于某种阻塞状态(BLOCKEDWAITINGTIMED_WAITING)。阻塞操作与执行时间很长的普通操作的差别在于,被阻塞的线程必须等待某个不受它控制的事件发生后才能继续执行,例如等待I/O操作完成,等待某个锁变成可用,或者等待外部计算的结束。当某个外部事件发生时,线程被置回RUNNABLE状态,并可以再次被调度执行
BlockingQueueputtake等方法会抛出受检查异常InterruptedException,这与类库中其他一些方法的做法相同,例如Thread.sleep。当某方法抛出InterruptedException时,表示该方法是一个阻塞方法,如果这个方法被中断,那么它将努力提前结束阻塞状态。
Thread提供了interrupt方法,用于中断线程或者查询线程是否已经被中断。每个线程都有一个布尔类型的属性,表示线程的中断状态,当中断线程时将设置这个状态。
中断是一种协作机制。一个线程不能强制其他线程停止正在执行的操作而去执行其他的操作。另一方面,方法对中断请求的响应度越高,就越容易及时取消那些执行时间很长的操作。
当在代码中调用了一个将抛出InterruptedException异常的方法时,自己的方法也就变成了一个阻塞方法,并且必须要处理对中断的响应。对于库代码来说,有两种基本选择:

  • 传递InterruptedException。避开这个异常通常是最明智的策略,只需把InterruptedException传递给方法的调用者。传递InterruptedException的方法包括,根本不捕获该异常,或者捕获该异常,然后在执行某种简单的清理工作后再次抛出这个异常。
    恢复中断。有时候不能抛出InterruptedException,例如当代码是Runnable的一部分时。在这些情况下,必须捕获InterruptedException,并通过调用当前线程上的interrput方法恢复中断状态,这样在调用栈中更高层的代码将看到引发了一个中断:
public class TaskRunnable implements Runnable {
	BlockingQueue<Task> queue;

	// ...

	public void run() {
		try {
			processTask(queue.take());
		} catch (InterruptedException e) {
			// 恢复被中断的状态
			Thread.currentThread().interrupt();
		}
	}
}

然而在出现InterruptedException不应该做的事情是,捕获它但不做出任何响应。这将使调用栈上更高层的代码无法对中断采取处理措施,因为线程被中断的证据已经丢失。只有在一种特殊的情况中才能屏蔽中断,即对Thread进行扩展,并且能控制调用栈上所有更高层的代码。

5.5同步工具类

同步工具类可以是任何一个对象,只要它根据其自身的状态来协调线程的控制流。阻塞队列可以作为同步工具类,其他类型的同步工具还包括信号量(Semaphore)、栅栏(Barrier)以及闭锁(Latch)。

5.5.1闭锁

闭锁可以延迟线程的进度直到其到达终止状态。闭锁的作用相当于一扇门:在闭锁到达结束状态之前,这扇门一直是关闭的,并且没有任何线程能通过,当到达结束状态时,这扇门会打开并允许所有的线程通过。当闭锁到达结束状态后,将不会再改变状态,因此这扇门将永远保持打开状态。闭锁可以用来确保某些活动直到其他活动都完成后才继续执行,例如:

  • 确保某个计算在其需要的所有资源都被初始化之后才继续执行。二元闭锁(包括两个状态)可以用来表示资源R已经被初始化,而所有需要R的操作都必须先在这个闭锁上等待。
  • 确保某个服务在其依赖的所有其他服务都已经启动之后才启动。每个服务都有一个相关的二元闭锁。当启动服务S时,将首先在S依赖的其他服务的闭锁上等待,在所有依赖的服务都启动后会释放闭锁S,这样其他依赖S的服务才能继续执行。
  • 等待直到某个操作的所有参与者(例如,在多玩家游戏中的所有玩家)都就绪再继续执行。在这种情况下,当所有玩家都准备就绪时,闭锁将到达结束状态。

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

/**
 * 起始门将使得主线程能够同时释放所有工作线程,而结束门则使主线程能够等待最后一个线程执行完成,
 * 而不是顺序地等待每个线程执行完成。
 */
public class TestHarness {
    public long timeTasks(int nThreads, final Runnable task) throws InterruptedException {
        // 两个闭锁,分别表示起始门和结束门,起始门计数器的初始化为1,而结束门计数器的初始化为
        // 工作线程的数量
        final CountDownLatch startGate = new CountDownLatch(1);
        final CountDownLatch endGate = new CountDownLatch(nThreads);

        for (int i = 0; i < nThreads; i++) {
            Thread t = new Thread(() -> {
                try {
                    // 每个工作线程首先要做的就是在起始门上等待,从而确保所有线程都就绪后才开始执行
                    startGate.await();

                    try {
                        task.run();
                    } finally {
                        // 任务执行完毕,结束门计数值递减
                        endGate.countDown();
                    }
                } catch (InterruptedException ignored) {
                }
            });

            t.start();
        }

        long start = System.nanoTime();
        // 起始门计数值归0,所有线程开始运行
        startGate.countDown();
        // 等待所有线程运行完毕之后,结束门计数值归0,主线程结束等待,从而可以统计出所消耗的时间
        endGate.await();
        long end = System.nanoTime();
        return end - start;
    }
}
5.5.2FutureTask

FutureTask也可以用做闭锁,其表示的计算时通过Callable来实现的,相当于一种可生成结果的Runnable,并且可以处于以下3种状态:

  • 等待运行。
  • 正在运行。
  • 运行完成。

运行完成表示计算的所有可能结束方式,包括正常结束、由于取消而结束和由于异常而结束等。当FutureTask进入完成状态后,它会永远停止在这个状态上。

Future.get的行为取决于任务的状态。如果任务已经完成,那么get会立即返回结果,否则get将阻塞直到任务进入完成状态,然后返回结果或者抛出异常。FutureTask将计算结果从执行计算的线程传递到获取这个结果的线程,而FutureTask的规范确保了这种传递过程能实现结果的安全发布。
FutureTaskExecutor框架中表示异步任务,此外还可以用来表示一些时间较长的计算,这些计算可以在使用计算结果之前启动。

/**
 * 使用FutureTask来执行一个高开销的计算,并且计算结果将在稍后使用。
 */
public class Preloader {
    private final FutureTask<ProductInfo> future = new FutureTask<>(new Callable<>() {
        @Override
        public ProductInfo call() throws DataLoadException {
            return loadProductInfo();
        }
    });
    private final Thread thread = new Thread(future);

	/**
	 * 在构造函数或静态初始化方法中启动线程并不是一种好方法,因此提供一个
	 * start方法来启动线程。
	 */
    public void start() {
        thread.start();
    }

    ProductInfo loadProductInfo() throws DataLoadException {
        return null;
    }
	
	/**
	 * 当程序随后需要ProductInfo时,可以调用get方法,如果数据已经加载,那么将返回这些数据,
	 * 否则将等待加载完成后再返回。
	 */
    public ProductInfo get() throws DataLoadException, InterruptedException {
        try {
            return future.get();
        } catch (ExecutionException e) {
            Throwable cause = e.getCause();
			
			// 首先检查已知的受检查异常,并重新抛出,剩下的是未检查异常
            if (cause instanceof DataLoadException) {
                throw (DataLoadException) cause;
            } else {
                throw LaunderThrowable.launderThrowable(cause);
            }
        }
    }

    interface ProductInfo {
    }
}

class DataLoadException extends Exception {
}

Callable表示的任务可以抛出受检查的或未受检查的异常,并且任何代码都可能抛出一个Error。无论任务代码抛出什么异常,都会被封装到一个ExecutionException中,并在Future.get中被重新抛出。这将使调用get的代码变得复杂,因为它不仅需要处理可能出现的ExecutionException(以及未检查的CancellationException),而且还由于ExecutionException是作为一个Throwable类返回的,因此处理起来并不容易。

Preloader中,当get方法抛出ExecutionException时,可能是以下三种情况之一:

  • Callable抛出的受检查异常。
  • RuntimeException
  • Error

必须对每种情况进行单独处理,为了后续方便使用,因此提取出来进行封装,从而处理复杂的异常逻辑:

public class LaunderThrowable {
    public static RuntimeException launderThrowable(Throwable t) {
        if (t instanceof RuntimeException)
            return (RuntimeException) t;
        else if (t instanceof Error)
            throw (Error) t;
        else
            throw new IllegalStateException("Not unchecked", t);
    }
5.5.3信号量

信号量用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量,并且还可以用来实现某种资源池,或者对容器施加边界
Semaphore中管理着一组虚拟的许可(permit),其初始数量可通过构造函数来指定。在执行操作时可以首先获得许可(只要还有剩余),并在使用以后释放。如果没有许可,那么acquire将阻塞直到有许可(或者直到被中断或者操作超时)。release方法将返回一个许可给信号量。在这种实现中不包含真正的许可对象,并且Semaphore也不会将许可与线程关联起来,因此在一个线程中获得的许可可以在另一个线程中释放。可以将acquire操作视为是消费一个许可,而release操作是创建一个许可,Semaphore并不受限于它在创建时的初始许可数量。
信号量的一种简化形式是二值信号量,即初始值为1的Semaphore。二值信号量可以用作互斥体(mutex),并具备不可重入的加锁语义:谁拥有这个唯一的许可,谁就拥有了互斥锁。

Semaphore可以用于实现资源池,例如数据库连接池。可以构造一个固定长度的资源池,当池为空时,请求资源将会失败,但真正希望看到的行为是阻塞而不是失败,并且当池非空时解除阻塞。如果将Semaphore的计数值初始化为池的大小,并在从池中获取一个资源之前首先调用acquire方法获取一个许可,在将资源返回给池之后调用release释放许可,那么acquire将一直阻塞直到资源池不为空(在构造阻塞对象池时,一种更简单的方法是使用BlockingQueue来保存池的资源)。
同样,也可以使用Semaphore将任何一种容器变成有界阻塞容器:

/**
 * 底层的Set实现并不知道关于边界的任何信息
 */
public class BoundedHashSet<T> {
    private final Set<T> set;
    private final Semaphore sem;

    public BoundedHashSet(int bound) {
        this.set = Collections.synchronizedSet(new HashSet<>());
        // 信号量的计数值会初始化为容器容量的最大值
        this.sem = new Semaphore(bound);
    }

	/**
	 * add操作在向底层容器中添加一个元素之前,首先要获取一个许可。如果add操作没有添加任何元素,
	 * 那么会立刻释放许可。
	 */
    public boolean add(T o) throws InterruptedException {
        sem.acquire();
        boolean wasAdded = false;

        try {
            wasAdded = set.add(o);
            return wasAdded;
        } finally {
            if (!wasAdded) {
                sem.release();
            }
        }
    }

	/**
	 * remove操作释放一个许可,将更多的元素能够添加到容器中。
	 */
    public boolean remove(Object o) {
        boolean wasRemoved = set.remove(o);

        if (wasRemoved) {
            sem.release();
        }

        return wasRemoved;
    }
}
5.5.4栅栏

栅栏(Barrier)类似于闭锁,它能阻塞一组线程直到某个事件发生。栅栏与闭锁的关键区别在于,所有线程必须同时到达栅栏位置,才能继续执行。闭锁用于等待事件,而栅栏用于等待其他线程。栅栏多用于实现一些协议。
CyclicBarrier可以使一定数量的参与方反复地在栅栏位置汇集,它在并行迭代算法中非常有用:这种算法通常将一个问题拆分成一系列相互独立的子问题。当线程到达栅栏位置时将调用await方法,这个方法将阻塞直到所有线程都到达栅栏位置。如果所有线程都到达了栅栏位置,那么栅栏将打开,此时所有线程都被释放,而栅栏将被重置以便下次使用。如果对await的调用超时,或者await阻塞的线程被中断,那么栅栏就被认为是打破了,所有阻塞的await调用都将终止并抛出BrokenBarrierException。如果成功地通过栅栏,那么await将为每个线程返回一个唯一的到达索引号,可以利用这些索引来选举产生一个领导线程,并在下一次迭代中由该领导线程执行一些特殊的工作。CyclicBarrier还可以将一个栅栏操作传递给构造函数,这是一个Runnable,当成功跨过栅栏时(在一个子任务线程中)执行它,但在阻塞线程被释放之前是不能执行的。

在模拟程序中通常需要使用栅栏,例如某个步骤中的计算可以并行执行,但必须等到该步骤中的所有计算都执行完毕才能进入下一个步骤。

/**
 * 通过栅栏来计算细胞的自动化模拟。在把模拟过程并行化时,为每个元素(例如一个细胞)分配一个独立的线程是不现实的,
 * 因为这将产生过多的线程,而在协调这些线程上导致的开销将降低计算性能。合理的做法是,将问题分解成一定数量的子问题,
 * 为每个子问题分配一个线程来进行求解,之后再将所有的结果合并起来。
 */
public class CellularAutomata {
    private final Board mainBoard;
    private final CyclicBarrier barrier;
    private final Worker[] workers;

    public CellularAutomata(Board board) {
        this.mainBoard = board;
        // 获取可用CPU核心数量,从而将问题分解为对应数量的子问题
        int count = Runtime.getRuntime().availableProcessors();
        this.barrier = new CyclicBarrier(count, mainBoard::commitNewValues);
        this.workers = new Worker[count];
		
		// 将每个子问题分配给一个线程
        for (int i = 0; i < count; i++) {
            workers[i] = new Worker(mainBoard.getSubBoard(count, i));
        }
    }

    private class Worker implements Runnable {
        private final Board board;

        public Worker(Board board) {
            this.board = board;
        }

        public void run() {
            while (!board.hasConverged()) {
                for (int x = 0; x < board.getMaxX(); x++) {
                    for (int y = 0; y < board.getMaxY(); y++) {
                        board.setNewValue(x, y, computeValue(x, y));
                    }
                }

                try {
                    barrier.await();
                } catch (InterruptedException | BrokenBarrierException ex) {
                    return;
                }
            }
        }

        private int computeValue(int x, int y) {
            // Compute the new value that goes in (x,y)
            return 0;
        }
    }

    public void start() {
    	// 在每个步骤中,工作线程都为各自子问题中的所有细胞计算新值。当所有工作线程都到达栅栏时,
    	// 栅栏会把这些新值提交给数据模型。在栅栏的操作执行完以后,工作线程将开始下一步的计算,
    	// 包括调用isDone方法来判断是否需要进行下一次迭代
        for (Worker worker : workers) {
            new Thread(worker).start();
        }

        mainBoard.waitForConvergence();
    }

    interface Board {
        int getMaxX();

        int getMaxY();

        int getValue(int x, int y);

        int setNewValue(int x, int y, int value);

        void commitNewValues();

        boolean hasConverged();

        void waitForConvergence();

        Board getSubBoard(int numPartitions, int index);
    }
}

另一种形式的栅栏是Exchanger,它是一种两方栅栏,各方在栅栏位置上交换数据。当两方执行不对称的操作时,Exchanger会非常有用,例如,当一个线程向缓冲区写入数据,而另一个线程从缓冲区中读取数据。这些线程可以使用Exchanger来汇合,并将满的缓冲区与空的缓冲区交换。当两个线程通过Exchanger交换对象时,这种交换就把这两个对象安全地发布给另一方。
数据交换的时机取决于应用程序的响应需求。最简单的方案是,当缓冲区被填满时,由填充任务进行交换,当缓冲区为空时,由清空任务进行交换。这样会把需要交换的次数降至最低,但如果新数据的到达率不可预测,那么一些数据的处理过程就将延迟。另一个方法是,不仅当缓冲区填满时进行交换,并且当缓冲被填充到一定程度并保持一定时间后,也进行交换。

5.6构建高效且可伸缩的结果缓存

几乎所有的服务器应用程序都会使用某种形式的缓存。重用之前的计算结果能降低延迟,提高吞吐量,但却需要消耗更多的内存。
缓存看上去都非常简单,然而,简单的缓存可能会将性能瓶颈转变成可伸缩性瓶颈,即使缓存是用于提升单线程的性能。
接下来将开发一个高效且可伸缩的缓存,用于改进一个高计算开销的函数。首先从简单的HashMap开始:

public interface Computable <A, V> {
    V compute(A arg) throws InterruptedException;
}

public class ExpensiveFunction implements Computable<String, BigInteger> {
    public BigInteger compute(String arg) {
        // 在经过长时间的计算后
        return new BigInteger(arg);
    }
}

/**
 * 使用HashMap来保存之前计算的结果
 */
public class Memoizer1 <A, V> implements Computable<A, V> {
    @GuardedBy("this")
    private final Map<A, V> cache = new HashMap<>();
    private final Computable<A, V> c;

    public Memoizer1(Computable<A, V> c) {
        this.c = c;
    }

    public synchronized V compute(A arg) throws InterruptedException {
        V result = cache.get(arg);
        if (result == null) {
            result = c.compute(arg);
            cache.put(arg, result);
        }
        return result;
    }
}

HashMap不是线程安全的,因此要确保两个线程不会同时访问它,这里采用了一种保守的方法,即对整个compute方法进行同步。这种方法能确保线程安全性,但会带来一个明显的可伸缩性问题:每次只有一个线程能够执行该方法。如果另一个线程正在计算结果,那么其他调用该方法的线程可能被阻塞很长时间。如果有多个线程在排队等待还未计算出的结果,那么该方法的计算时间可能比没有记忆操作的计算时间更长。

在这里插入图片描述

/**
 * 用ConcurrentHashMap代替HashMap来改进Memoizer1中糟糕的并发行为
 */
public class Memoizer2<A, V> implements Computable<A, V> {
    private final Map<A, V> cache = new ConcurrentHashMap<>();
    private final Computable<A, V> c;

    public Memoizer2(Computable<A, V> c) {
        this.c = c;
    }

    public V compute(A arg) throws InterruptedException {
        V result = cache.get(arg);
        if (result == null) {
            result = c.compute(arg);
            cache.put(arg, result);
        }
        return result;
    }
}

Memoizer2有着更好的并发行为,多线程可以并发地使用它。但它的问题在于如果某个线程启动了一个开销很大的计算,而其他线程并不知道这个计算正在进行,那么很可能会重复这个计算。

在这里插入图片描述

因此希望通过某种方法来表达这种情况,这样当另一个线程启动了一个开销很大的计算时,它能够知道最高效的方法是等待线程X计算结束,然后再去查询缓存的结果是多少。
目前为止,已经知道有一个类能基本实现这个功能:FutureTaskFutureTask表示一个计算的过程,这个过程可能已经计算完成,也可能正在进行。如果有结果可用,那么FutureTask.get将立即返回结果,否则它会一直阻塞,直到结果计算出来再将其返回。

public class Memoizer3<A, V> implements Computable<A, V> {
    private final Map<A, Future<V>> cache = new ConcurrentHashMap<>();
    private final Computable<A, V> c;

    public Memoizer3(Computable<A, V> c) {
        this.c = c;
    }

    public V compute(final A arg) throws InterruptedException {
        Future<V> f = cache.get(arg);
        if (f == null) {
            Callable<V> eval = new Callable<>() {
                public V call() throws InterruptedException {
                    return c.compute(arg);
                }
            };
            FutureTask<V> ft = new FutureTask<>(eval);
            f = ft;
            cache.put(arg, ft);
            // 在这里将调用c.compute
            ft.run();
        }
        try {
            return f.get();
        } catch (ExecutionException e) {
            throw LaunderThrowable.launderThrowable(e.getCause());
        }
    }
}

Memoizer3表现出了非常好的并发性(基本上是源于ConcurrentHashMap高效的并发性),若结果已经计算出来,那么将立即返回。如果其他线程正在计算该结果,那么新到的线程将一直等待这个结果被计算出来。它只有一个缺陷,即仍然存在两个线程计算出相同值的漏洞。原因在于,复合操作是在底层的Map对象上执行的,而这个对象无法通过加锁来确保原子性。

在这里插入图片描述

public class Memoizer<A, V>implements Computable<A, V> {
    private final ConcurrentMap<A, Future<V>> cache = new ConcurrentHashMap<>();
    private final Computable<A, V> c;

    public Memoizer(Computable<A, V> c) {
        this.c = c;
    }

    public V compute(final A arg) throws InterruptedException {
        while (true) {
            Future<V> f = cache.get(arg);
            if (f == null) {
                Callable<V> eval = new Callable<>() {
                    public V call() throws InterruptedException {
                        return c.compute(arg);
                    }
                };
                FutureTask<V> ft = new FutureTask<>(eval);
                f = cache.putIfAbsent(arg, ft);
                if (f == null) {
                    f = ft;
                    ft.run();
                }
            }
            try {
                return f.get();
            } catch (CancellationException e) {
            	// 当缓存的是Future而不是值时,将导致缓存污染问题:如果某个计算被取消或者失败,那么在计算这个
            	// 结果时将指明计算过程被取消或者失败。为了避免这种情况,如果发现计算被取消,那么将把Future
            	// 从缓存中移除。如果检测到RuntimeException,那么也会移除Future,这样将来的计算才可能成功。
                cache.remove(arg, f);
            } catch (ExecutionException e) {
                throw LaunderThrowable.launderThrowable(e.getCause());
            }
        }
    }
}

Memoizer同样没有解决缓存逾期的问题,但它可以通过使用FutureTask的子类来解决,在子类中为每个结果指定一个逾期时间,并定期扫描缓存中逾期的元素。同样,它也没有解决缓存清理的问题,即移除旧的计算结果以便为新的计算结果腾出空间,从而使缓存不会消耗过多的内存。

并发技巧清单

  • 可变状态是至关重要的。所有的并发问题都可以归结为如何协调对并发状态的访问。可变状态越少,就越容易确保线程安全性
  • 尽量将域声明为final类型,除非需要它们是可变的
  • 不可变对象一定是线程安全的。不可变对象能极大地降低并发编程的复杂性。它们更为简单而且安全,可以任意共享而无须使用加锁或保护性复制等机制。
  • 封装有助于管理复杂性。在编写线程安全的程序时,虽然可以将所有数据都保存在全局变量中,但为什么要这样做。将数据封装在对象中,更易于维持不变性条件:将同步机制封装在对象中,更易于遵循同步策略。
  • 用锁来保护每个可变变量
  • 当保护同一个不变性条件中的所有变量时,要使用同一个锁
  • 在执行复合操作期间,要持有锁
  • 如果从多个线程中访问同一个可变变量时没有同步机制,那么程序会出现问题
    不要故作聪明地推断出不需要使用同步
  • 在设计过程中考虑线程安全,或者在文档中明确地指出它不是线程安全的
  • 将同步策略文档化
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值