【读书笔记】《Java并发编程实战》第十一章 性能与可伸缩性

1.对性能的思考

当操作性能由于某种特定的资源而受到限制时,我们通常将该操作称为资源密集型的操作,例如,CPU密集型、数据库密集型等。

尽管使用多个线程的目标是提升整体性能,但与单线程的方法相比,使用多个线程总会引入一些额外的性能开销。造成这些开销的操作包括:线程之间的协调(例如加锁、触发信号以及内存同步等),增加的上下文切换,线程的创建和销毁,以及线程的调度等。

要想通过并发来获得更好的性能,需要努力做好两件事情:更有效地利用现有处理资源,以及在出现新的处理资源时使程序尽可能地利用这些新资源。如果程序是计算密集型的,那么可以通过增加处理器来提高性能。如果程序无法使现有的处理器保持忙碌状态,那么增加再多的处理器也无济于事。

1.1性能与可伸缩性

应用程序的性能可以采用多个指标来衡量,例如服务时间、延迟时间、吞吐率、效率、可伸缩性以及容量等。其中一些指标(服务时间、等待时间)用于衡量程序的“运行速度”,即某个指定的任务单元需要“多快”才能完成。另一些指标(生产量、吞吐量)用于程序的“处理能力”,即在计算资源一定的情况下,能完成“多少”工作。

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

2.什么是Amdahl定律?

Amdahl定律描述的是:在增加计算资源的情况下,程序在理论上能够实现最高加速比。这个最高加速比取决于程序中可并行组件与串行组件所占的比重。

假定F是必须被串行执行的部分,那么根据Amdahl定律,在包含N个处理器的机器中,最高的加速比为:
在这里插入图片描述
当N趋近无穷大时,最大的加速比趋近与1/F。因此,如果程序有50%的计算需要串行执行,那么最高的加速比只能是2(而不管有多少个线程可用);如果在程序中有10%的计算需要串行执行,那么最高的加速比将接近10。Amdahl定律还量化了串行化的效率开销。在拥有10个处理器的,如果程序中有10%的部分需要串行执行,那么最高的加速比为5.3(53%的使用率),在拥有100个处理器的系统中,加速比可以达到9.2(9%的使用率)。即使拥有无限多的CPU,加速比也不可能为10。

//对任务队列的串行访问
public class WorkerThread extends Thread {
	private final BlockingQueue<Runnable> queue;

	public WorkerThread(BlockingQueue<Runnable> queue) {
		this.queue = queue;
	}

	public void run() {
		while (true) {
			try {
				Runnable task = queue.take();
				task.run();
			} catch (InterruptedException e) {
				break;//允许线程退出
			}
		}
	}
}

如上程序,在这个程序中包含了一个串行部分——从队列中获取任务。所有工作者线程都共享同一个工作队列, 因此在对该队列进行并发访问时需要采用某种同步机制来维持队列的完整性。如果通过加锁来保护队列的状态, 那么当一个线程从队列中取出任务时, 其他需要获取下一个任务的线程就必须等待, 这就是任务处理过程中的串行部分。

无论何种共享数据结构,基本上都会在程序中引入一个串行部分

上面示例还忽略了另一种常见的串行操作:对结果进行处理。所有有用的计算都会生成某种结果或者产生某种效应一如果不会,那么可以将它们作为“ 死亡代码” 删除掉。

在所有并发程序中都包含一些串行部分。如果你认为在你程序中不存在串行部分,那么可以再仔细检查一遍。

3.线程引入的开销

单线程程序既不存在线程调度,也不存在同步开销,而且不需要使用锁来保证数据结构的一致性。 在多个线程的调度和协调过程中都需要一定的性能开销: 对于为了提升性能而引入的线程来说,并行带来的性能提升必须超过并发导致的开销。

3.1上下文切换

当可运行的线程数大于CPU的数量时,CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换

3.1.1上下文切换的开销?

上下文切换的开销有:

  1. 上下文切换需要访问操作系统和JVM共享的数据结构,上下文切换(在JVM和操作系统的代码中消耗越多的CPU时钟周期,应用程序的可用CPU时钟周期就越少)。
  2. 它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。
3.1.2上下文切换的影响?

当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起, 并允许它被交换出去。如果线程频繁地发生阻塞,那么它们将无法使用完整的调度时间片。在程序中发生越多的阻塞 (包括阻塞I/0, 等待获取发生竞争的锁,或者在条件变晕上等待),与CPU密集型的程序就会发生越多的上下文切换,从而增加调度开销,并因此而降低吞吐量

3.1.2如何减少上下文切换?

减少上下文切换的方法有无锁并发编程、CAS算法、使用最少的线程和使用协程

  • 无锁并发编程:多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。
  • CAS算法:Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
  • 使用最少的线程:避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
  • 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

3.2内存同步

在synchronized和volatile提供的可见性保证中(后面我会单独整理两篇关于synchronized和volatile内存模型的文章,大家敬请期待)可能会使用一些特殊的指令,即内存屏障(Memory Barrier)。内存屏障可以刷新CPU中的写缓存,使CPU中的写缓存及时的发送到主内存中。

现代的JVM能通过优化一些不会发生竞争的锁(不发生竞争的锁可以不使用同步),从而减少不必要的同步开销。一些不会发生竞争的锁(例如一个锁对象只能由当前线程访问),那么JVM会通过优化去掉这个锁操作

完备的JVM能通过逸出分析(Escape Analysis)会找出不会发布到堆上的本地对象,锁的获取和释放会被优化为最小的次数甚至去掉。

当然即使不逸出分析,也会有锁粒度粗化(lock coarsening)过程,将临近的同步代码块使用同一个锁合并起来。这都减少了同步的开销

3.3阻塞

3.3.1实现线程阻塞的行为有哪些?

当在锁上发生竞争时,竞争失败的线程会被阻塞。

JVM在实现阻塞行为时,会采用以下两种方式之一:

  • 可以采用自旋等待(指通过循环不断地尝试获取锁)
  • 通过操作系统挂起被阻塞的线程
3.3.2自旋等待和挂起线程的效率影响因素?

自旋等待和操作系统挂起线程的效率,取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。如果等待时间较短,则适合采用自旋等待方式,而如果等待时间较长,则适合采用线程挂起方式。

当线程无法获取某个锁或者由于某个条件等待或在I/O操作上阻塞时,需要被挂起,在这个过程中将包含两次额外的上下文切换,以及所有必要的操作系统操作和缓存操作:被阻塞的线程在其执行时间片还未用完之前就被交换出去,而在随后当要获取的锁或者其他资源可用时,又再次被切换回来。(由于锁竞争而导致阻塞时,线程在持有锁时将存在一定的开销:当它释放锁时,必须告诉操作系统恢复运行阻塞的线程。)

4.减少锁的竞争

串行操作会降低可伸缩性,并且上下文切换也会降低性能。在锁上发生竞争将会同时导致这两种问题,因此减少锁的竞争能够提高性能和可伸缩性。

有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,以及每次持有该锁的时间

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

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

下面将介绍一些减少锁竞争的方式

4.1缩小锁的范围(“快进快出”)——减少锁的持有时间

如果将一个“高度竞争”的锁持有过长的时间,那么会限制可伸缩性

缩小锁的范围可以有效的降低竞争的可能性。
 目标:尽可能缩短持有锁的时间
 方法:只对操作共享变量的代码加锁,将一些与锁无关的代码移出同步代码块
 理论:根据Amdahl定律,减少了必须串行执行的部分
 注意:经管缩小同步代码块能提高可伸缩性,但是同步代码块不能太小。当一个同步代码块分解成多个同步代码块时,如果JVM执行锁粒度粗化操作,那么可能会将分解的同步块又重新合并起来。

//将一个锁不必要地持有过长时间
   public class AttributeStore{
   private final Map<String,String> attributes = new HashMap<String,String>();
   	
   	public synchronized boolean userLocationMatches(String name,String regexp){
   			String key = "users"+name+"loaction";
   			String location = attributes.get(key);
   			if(location == null)
   					return false;
   			else
   					return Pattern.matches(regexp,location);
   	}
   }

例如AttributeStore例子,将整个方法synchronized都同步起来,但只有attributes .get方法才真正需要锁。根据Amdahl定律,串行代码总量减少,可以提升可伸缩性。试想,如果userLocationMatches的操作如果持有锁时间为2毫秒,那么吞吐量不会超过每秒500个操作,缩小同步代码块后,若持有锁时间为1毫秒,那么这个锁对应的吞吐量会提高到每秒1000个操作。

对AttributeStore例子进行如下改进:

//减少锁的持有时间
	public class AttributeStore{
		@GuardedBy("this") private final Map<String,String> attributes = new HashMap<String,String>();
		
		public boolean userLocationMatches(String name,String regexp){
				String key = "users"+name+"loaction";
				String location ;
				synchronized(this)
				{
					location = attributes.get(key);
				}
				if(loacation == null)
					return false;
				else
					return Pattern.matches(regexp,location);
		}
	}

4.2减小锁的粒度——降低请求锁的频率

减小锁的粒度来降低请求锁的频率来达到减小锁的竞争的目的。
 目的:降低请求锁的频率
 方法:通过锁分解和锁分段等技术实现
 注意:锁分解和锁分段等技术将采用多个相互独立的锁保护独立的状态变量,这样单个锁的请求频率就会降低。但是,使用的锁越多,那么发生死锁的风险也就越高(例如:锁顺序死锁)。

4.2.1锁分解

如果整个应用程序中只有一个锁,那么,所有同步代码块的执行就会串行执行。由于很多线程将竞争同一个全局锁,因此两个线程同时请求这个锁的概率将剧增,从而导致更严重的竞争。如果将这些锁请求分布到更多的锁上,那么能有效地降低竞争程度。由于等待锁而被阻塞的线程将更少,因此可伸缩性提高。

锁分解:如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁只保护一个变量,从而提高可伸缩性,并最终降低每个锁被请求的频率。

//对锁进行分解
public class ServerStatus {
	public final Set<String> users;
	public final Set<String> queries;

	...
	public void addUser(String u) {
		synchronized (users) {
			users.add(u);
		}
	}

	public void addQuery(String q) {
		synchronized (queries) {
			queries.add(q);
		}
	}
}

如果在锁上存在适中而不是激烈的竞争时,通过将一个锁分解为两个锁,能最大限度地提升性能。如果对竞争不激烈的锁进行分解,那么在性能和吞吐量等方面带来的提升将非常有限。

4.2.2锁分段

锁分段技术是将锁分解技术进一步拓展为对一组独立对象上的锁进行分解。例如ConcurrentHashMap的实现通过一个包含 16个锁的数组,每个锁保护散列桶的1/16,第N个散列桶由第N个锁来保护。锁分段技术可以使ConcurrentHashMap能支持多达16个并发的写入器。

锁分段劣势:与采用单个锁来实现独占访问相比,获取多个锁来实现独占访问更加困难并且开销更高

4.2.3避免热点域

热点域:某个被锁保护的数据却被多个线程经常访问。

如果由你来实现HashMap,你会遇到一个选择:size方法如何计算Map条目的大小?最简单的方法是每次调用的时候数一遍。通常使用的优化方法是在插入和移除的时候更新一个单独的计数器;这会给put和remove方法造成很小的开销,以保证计数器的更新,但是,这会减少size方法的开销,从O(n)减至O(1)。

在单线程或完全同步的实现中,保存一个独立的计数能够很好地提高类似size和isEmpty这样的方法的速度,但是却使改进可伸缩性变得更难了,因为每一个修改map的操作都要更新这个共享的计数器。即使你对每一个哈希链(hash chain)都使用了锁的分离,对计数器独占锁的同步访问还是重新引入了可伸缩性问题。这看起来像是一个性能的优化——缓存size操作的结果——却已经转化为一个可伸缩性问题。这种情况下,计数器被称为热点域(hot field),因为每个变化操作都要访问它。

为避免这个问题,ConcurrentHashMap中的size将对每个分段进行枚举并将每个分段中的元素数量相加,而不是维护一个全局计数。为了避免列举所有元素,ConcurrentHashMap为每一个条目维护一个独立的计数域,同样由分段的锁来维护这个值。

4.3放弃使用独占锁

放弃使用独占锁来降低竞争锁的影响,然后使用友好并发的方式来管理共享状态。例如,使用并发容器、读-写锁、不可变对象以及原子变量

读-写锁:ReadWriteLock(读-写锁)允许多个线程并发执行读操作但是写操作仍然为单线程串行操作:如果多个读取操作都不会修改共享资源,那么这些读取操作可以共同访问该共享资源,但在执行写入操作时必须以独占方式来获取锁。

原子变量类:原子变量类提供了在整数或者对象引用上的细粒度原子操作(因此伸缩性更高),并使用了现代处理器中提供的底层并发原语。

猜你喜欢

转载自blog.csdn.net/Handsome_Le_le/article/details/107886375