《Java并发编程实践》四(2):并发性能和可伸缩性

使用多线程的主要动机就是提升系统的性能和响应性,充分利用硬件和其他资源的处理能力。

这一章介绍分析、监控和改善并统发系性能的技术。不过,多线程系统极其复杂,提升性能的技术往往会增加安全和活性风险;如果使用不当,改善性能的尝试,往往是解决一个问题的同时,引入另一个问题,得不偿失。因此,在准备对并发系统进行性能优化之前要记住两点:

  • 先保证程序正确,再优化性能,且仅在测试表明需要优化时;
  • 追求极致的性能没有什么意义;

对性能的思考

提升性能意味着用更少的资源做更多的事。资源有多种多样,比如CPU,内存,网络带宽,数据库,磁盘空间等。一个任务的执行速度往往受限于某一种资源,比如CPU、数据库,此时我们称该任务属是CPU-bound,database-bound。

使用多线程目标是提升系统的整体性能,需要认识到:多线程本身会引入一些性能损耗:线程同步(锁、内存同步),上下文切换,线程的创建和销毁,线程的调度。适当的使用多线程,可充分利用资源,补偿掉这些损耗,整体上达到更好的吞吐率和响应性;要是使用不当的话,系统的功能可能比单线程模式更差。

使用并发来提升性能要从两个角度来考虑:

  • 充分利用硬件或外部资源,提搞资源的使用效率;
  • 使用额外资源的能力——当我们配置更多的资源时,系统的性能立即得到提升;

因此我们应当尽量使CPU处于忙碌状态(这里指定:让CPU执行有用的工作),这样增加CPU核能就能提升吞吐量;如果我们无法使CPU保持忙碌,那么增加CPU核不会有任何意义。

性能 VS 可伸缩性(Scalability)

系统的性能能从多个指标进行测量:服务时间、延迟、吞吐率、效率、可伸缩性、容量等。有一些指标(延迟)描述任务执行速度,有一些指标(容量、吞吐量)描述任务执行数量。可伸缩性描述了系统通过增加资源(CPU、内存、带宽)以提升吞吐率或容量的能力。

提升并发系统可伸缩性与传统的性能调优非常不同,后者追求用更少的资源做更多的事,比如将O(n2)复杂度算法替换为O(log2n)复杂度算法。增加系统可伸缩性则尝试将问题解决方案并行化,以实现用更多的资源做更多的事

性能有两个非常不同的方面:速度和容量,而且两者之间通常存在矛盾。一方面,为了追求更好的可伸缩性,最终将增加单个任务的复杂性;另一方面,在单线程下加快任务执行速度的技巧往往妨碍可伸缩性。

大家耳熟能详的三层模型(展示、逻辑、持久)是一个典型的通过牺牲速度来获得可伸缩性的设计。对于一个单独的任务,一个不分层的系统必然有更好的性能,因为减少了不同层之间的数据拷贝和网络延迟,但是当该系统到达容量极限时,不分层的设计很难通过增加资源来增加系统容量。因此一个服务端系统,在速度满足用户需求的前提下,我们更加关注可伸缩性、吞吐量和容量。

性能优化中的权衡

几乎所有的工程决策本质上都是在多个因素之间的做权衡。使用更厚的钢板建造桥梁意味着更好的安全性,付出的代价是更高的建造成本。在一个软件系统中,通常不能获得足够的信息以做出正确的权衡,这是很多优化工作最终被证明是草率的原因之一。

避免草率的性能优化,先使程序正确,再使它更快(如果它不够快)。

性能优化过程中的工程决策同样也需要做权衡,通过牺牲一个指标来换取另一个指标,比如运行时间和内存空间;有时候付出的代价是安全性、代码可读性、可维护性。因此在做决策之前,需要问自己以下问题:

  • 需要优化的是何种指标,更快的真实含义是什么?
  • 在何种情形下新方式真的会更快?高负载下,大数据集下?能否通过测试来支撑你的观点?
  • 上面的情形出现的概率有多高?是否有证据?
  • 新方式是否会被用于其他情形,它是否适用?
  • 是否有隐藏的代价,比如更长的开发周期,更高的维护难度,这种代价是否值得?

为什么我们要推荐看起来有些保守的优化方式?因为在并发编程中,极致地追求性能可能是产生并发bug最多的一个原因。同步锁较慢的错误认知导致程序员采用一些看起来很巧妙,实际很危险的优化措施(double-check lock模式),并一再成为程序员违反同步原则的借口。并发bug非常难以追踪和修复,因此,任何引入并发bug的风险都应当被仔细评估。如果以牺牲安全性的代价来换取性能,最终将一无所获。

在并发系统中,程序员凭直觉判断的性能瓶颈点往往是错误,任何性能优化工作需要有明确的性能要求,并准备好相关的测试程序。在性能优化完成后,再次测试以证明该优化是否达到目的。性能优化的往往以安全风险和可维护性为代价,我们不想为不需要的性能付出代价,更不想付出代价却一无所获。

Amdahl定律

有些问题只要有更多资源就能解决得越快,比如收割庄稼,更多的农夫参与必然完成得更快。而有些问题从本质上就是顺序的,曾加资源毫无意义。如果我们使用多线程的动机是利用多核CPU的处理能力,那么就要求问题能够被分解为可并行的多个子问题。

多数的并发程序都和农业工作有很大共同点,有一些可并行部分,也有一些不可并行的部分。Amdahl定律描述了程序在理论上可以通过增加资源加速的倍率限制:

Speedup <= 1/(F+(1-F)/N)

  • F是必须串行计算的比例;
  • N是CPU核数;
  • Speedup是加速因子。

当N接近无穷大,Speedup的极值是1/F。对于一个10%串行工作的问题,10个CPU核能加速5.3倍,100个CPU核也只能加速9.2倍。

看一段并发执行任务的代码,并假设任务之间没有任何依赖关系:

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; /* Allow thread to exit */
			}
		}
	}
}

从表面上看,任务的执行是完全并发的,但实际上包含一个串行执行的部分:从任务队列获取任务。BlockingQueue包含同步机制来保护数据,当一个工作线程从队列中获取任务时,另一个想要执行相同操作的工作线程必须等待。所以执行任务的时间,不仅包括task.run消耗的时间,还包括queue.take消耗的时间,而后者对所有的任务是串行的。

上面的示例还有意忽视了另外一个串行操作,就是对任务执行结果的处理。一组并发的任务,要么任务执行产生某种副作用(写数据库、写日志文件、影响全局状态);要么需要将任务执行返回的结果合并起来;无论何种方式,基本都会包含串行操作。

所有的并发程序都包含某种串行操作,如果你认为没有,请再想想

优化串行操作

Amdahl定律告诉我们,并发程序的可伸缩性与串行部分的耗时比例成反比,因此优化串行部分能显著提升系统的并发性能和可伸缩性。在上面的例子中,将queue的类型从synchronized LinkedList替换为LinkedBlockingQueue能不同程度地提升程序的可伸缩性。

定性地运用Amdahl定律

准确地测量程序中串行部分运行时间是比较困难的,而且随着并发度的提高,该部分的执行时间也会发生变化。比如synchronized LinkedList在大量线程下(同时受cpu核数影响)的锁竞争概率加大,从而增加了访问延时。

即使不能准确测量,定性地运用Amdahl定律也是很价值的,就像上面的例子一样。在评价一个程序的可伸缩性时,我们可以做个假想:**如果它运行在一个有几百个处理器的机器上,哪些并发限制将会出现?**这会有助于我们洞察哪些优化是真正有意义的,哪些优化价值不大。

多线程的性能代价

相比单线程序,多线程本身会引入一些成本,包括线程调度、锁等待、内存同步等,这些都是要消耗CPU周期的;多线程所带来的的性能增益要能覆盖这些成本才有意义。

上下文切换

在线程调度中,不断地发生上下文切换:一个线程让出CPU,另外一个线程占据CPU。上下文切换需要保存前一个线程的运行状态,同时载入下一个线程的运行状态。

上下文切换是有代价的,它涉及以下两个部分:

  • OS和JVM的相关数据状态的操作,OS、JVM和应用程序线程使用的是相同的处理器,前者消耗了的时间多了,后者所能消耗的时间必然就少了;
  • 新线程所需的数据肯定没有在处理器缓存中,这会导致一波缓存未命中,进一步消耗CPU;

将新调度的线程载入缓存需要一定时间,这个时间是计入线程的运行时间片内的;为了保证线程有足够的时间来执行业务逻辑,现代操作系统会给被调度线程保证一个最小的运行时间片(无论有多少线程正在等待调度)。

如果一个线程由于锁竞争被阻塞,JVM就让它让出CPU。如果一个线程频繁地被阻塞,它每次占据CPU后运行的时间经常地小于被分配的时间片,这样一来就会发生更多的下文切换,降低处理器的吞吐率。

一次上下文的消耗随不同的平台而不同,一般需要5000~10000个CPU周期,或者说几个微秒。Unix系统下的vmstat命令(windows下的perfmon工具)能够查看上下文切换的系统报告,如果内核消耗的CPU时间较高(超过10%),意味着存在非常频繁的调度,原因大多是IO阻塞或锁竞争。

内存同步

线程同步会引入若干性能代价,第一个代价是可见性保证:synchronized锁、volatile关键字引入一个特殊的JVM指令:memory barriers,该指令将缓存刷入主内存、使缓存无效,清空硬件缓存。memory barriers还有间接的性能影响:阻止编译器进行指令重排优化。

一个线程的同步对其他线程的性能也会产生一定影响,因为同步需要集中读写内存,从而占据内存总线,导致其他需要使用总线的线程必须等待。

锁竞争

竞争同步和非竞争同步

线程同步肯定要付出一些代价,付出代价的大小,取决于同步是否发生竞争。打个比方,线程执行每次执行一段代码都需要加锁,但每次加锁都非常顺利,没有其他线程与之竞争,这就是所谓“非金竞争同步”。对于非竞争同步(volatile总是非竞争同步),JVM做了足够优化,使其可以在20到250个CPU周期内完成。虽然非竞争同步不是零代价,但是对程序性能的影响是及其微小的,冒着安全性的风险来减少非竞争同步是不明智的。

非竞争同步能够被JVM完全处理,而竞争同步则需要OS参与;锁竞争失败的线程会被阻塞,JVM有两种方式实现阻塞,一种是spin-waiting,不断检查锁状态直至成功,另一种通知操作系统挂起线程。哪种方式更有效,取决于上下文消耗和等待锁的时间,JVM可依据历史数据来动态决策,不过绝大多数锁都是将线程挂起。

这样每次线程阻塞,引入了两次额外的上下文切换:一次是线程挂起时,一次是线程恢复时;锁竞争引起的阻塞,也会使得持有锁的线程增加了些许开销:它在释放锁的时候需要通知被阻塞的线程。

可以打一个比方,线程竞争锁类似汽车通过路口,如果遇到绿灯,汽车的前进状态只会收到很小的影响(不要加速,脚放在刹车上);如果遭遇红灯,那汽车就需要完全停下来,等待绿灯重新启动。

减少锁竞争

我们知道,串行化影响可伸缩性,上下文切换影响性能,而锁竞争既会导致串行化也会增加上下文切换,因此减少锁竞争能同时提升程序的性能和可伸缩性。

排他锁保护的资源,同一时刻只允许一个线程访问,是对程序可伸缩性的主要威胁。有两个因素影响锁竞争的概率:程序获取锁的频率,线程持有锁的时长; 如果这两个因素的乘积足够小,那么大多数获取锁的操作不会遭遇竞争。反正,如果一个被频繁请求的锁,且被持有很长时间,那么其他线程极有可能被阻塞。

据以上分析,有三种途径来减少锁竞争:

  • 减少线程持有锁的时间;
  • 减少获取锁的频率;
  • 将排他锁替换为其他并发性更好的机制。

减小锁区域

减少锁持有时间的有效手段是减小锁覆盖的代码区域,我们可以将不需要锁保护的代码移出同步代码块,尤其是那些耗时的或可能发生阻塞的操作。缩小同步代码块,自然就减少了串行操作的比例,依据Amadahl定律,能够提高程序的可伸缩性。

请看以下示例代码,实现了一个地址匹配谓词逻辑,它的实现方式是先查看地址信息是否被缓存,否则通过正则表达式来匹配:

@ThreadSafe
public class AttributeStore {
	@GuardedBy("this") private final Map<String, String> attributes = new HashMap<String, String>();
	
	public synchronized boolean userLocationMatches(String name, String regexp) {
		String key = "users." + name + ".location";
		String location = attributes.get(key);
		if (location == null)
			return false;
		else
			return Pattern.matches(regexp, location);
	}
}

上面这个程序,真正需要锁保护的是attributes变量,可以缩小同步代码块的范围:

public boolean userLocationMatches(String name, String regexp) {
	String key = "users." + name + ".location";
	String location;
	synchronized(this) {
		location = attributes.get(key);
	}
	if (location == null)
		return false;
	else
		return Pattern.matches(regexp, location);
}

由于AttributeStore只包含一个状态变量,因此可以使用”委托线程安全“的策略,使用一个线程安全的Map类型:Hashtable、synchronizedMap或ConcurrentHashMap来存储状态。使用”委托线程安全“而不是显示的锁,可进一步缩小锁范围,同时提高代码的可维护性。

虽然原则上,我们应该尽量缩小锁的范围,但是只有将耗时的操作移出同步块才有意义,比如userLocationMatches方法内的Pattern.matches是一个潜在的耗时操作,否则的话可能会得不偿失(没有提高可伸缩性,却损害代码可维护性)。

减小锁粒度

如果对象在一些互相独立的状态变量,那么比起用同一个锁来保护所有变量,用多个锁来分别保护它们,能够提高程序的可伸缩性;因为不同锁保护的代码块之间是可以并发的。有两种减少锁粒度的技术:锁分拆(lock splitting)和锁分离(lock striping)。

要理解这两个技术,可以反方向想象一下,假设程序的所有状态都用一把锁来保护会怎么样,那么线程之间锁竞争会极为频繁,可伸缩性会非常差。实际上我们的程序里面有大量的锁,有效地降减少了锁竞争。

大多数情况下,我们以类为粒度来进行加锁保护,不过如果对象包含互相独立的一状态,那么锁粒度可进一步降低,下面ServerStatus是数据库用来记录当前登录用户和当前执行的查询语句的类:

@ThreadSafe
public class ServerStatus {
	@GuardedBy("this") public final Set<String> users;
	@GuardedBy("this") public final Set<String> queries;
	
	public synchronized void addUser(String u) { users.add(u); }
	public synchronized void addQuery(String q) { queries.add(q); }
	
	public synchronized void removeUser(String u) {
		users.remove(u);
	}
	public synchronized void removeQuery(String q) {
		queries.remove(q);
	}
}

由于users和queries两个状态是互相独立的,所以可以用不同的锁来保护:

@ThreadSafe
public class ServerStatus {
	@GuardedBy("users") public final Set<String> users;
	@GuardedBy("queries") 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);
		}
	}
	// remove methods similarly refactored to use split locks
}

ServerStatus展示的是锁分拆技术:一方面,不同的锁之间没有竞争;另一方面,分离后的锁与原单个锁相比,一般来说保护的代码块更小,持有时间更短;因此锁拆分技术能有效提高程序的可伸缩性。不过多个锁也增加了编码难度,增加了死锁风险。

锁分离(Lock Striping)

锁拆分技术将一个高度竞争的锁进行拆分,结果可能偶个高度竞争的锁,只不过每个锁同步的代码块更小,他对程序可伸缩性的改善很有限(比如增加一倍)。

锁拆分技术可以扩展为锁分离技术,对数据状态进行分区加锁,以期望能够将竞争锁变为非竞争锁。ConcurrentHashMap使用了该技术,它内部包含16个锁,每个锁保护1/16的哈希桶。当map的key均匀地分布于所有的桶时,并发的map操作大概率不会发生竞争。

锁分离技术的缺点是,使用排他锁的难度会加大,ConcurrentHashMap在需要扩展桶空间时,需要对整个map加锁,它的实现方案是获取全部16个锁以实现排他锁。

Java 1.8的ConcurrentHashMap已经实现了无锁化。

避免热点字段

锁分拆和锁分离技术之所有以能凑效,是因为不同操作访问不同的状态字段或状态的不同部分。如果存在某个状态字段是每个操作都需要访问的,那么就无法使用锁分拆(分离)技术了。

以维护ConcurrentHashMap的size属性为例,要想快速的获取map的元素个数,可以增加一个size字段是符合常理的,为了线程安全size字段的读写需要进行同步。由于所有的读写操作都可能修改size,那么这些操作内部也就包含了一个无法分离的同步操作,妨碍了程序的可伸缩性。

map的size属性就是一个典型的热点字段,它是提升代码可伸缩性的一个障碍。因此ConcurrentHashMap并没有维护一个size字段,它的size()方法实际是遍历元素并计数,效率很低(设计者并不希望开发者频繁调用它),这是一种设计权衡。

使用非排他锁

另一种避免锁竞争的方式是使用排他锁之外的同步手段,有以下几种:

  • ReadWriteLock
    允许一个或多个写线程共享锁,以读取数据状态,但是写线程必须获取排他锁,以修改数据状态。对于读多写少的数据状态,ReadWriteLock能提程序的高并发度;
  • Atomic变量
    Atomic变量能够实现对整形或引用类型的并发的原子操作,提供了一种比较廉价的方式来来维护热点字段,比如统计计数、序号产生器;对于独立的状态字段(和其他状态没有约束关系),那么使用Atomic变量能提高并发度。
  • 非可变对象
    是非可变对象能够完全避免同步。

拒绝对象池

在早期版本的JVM中,对象分配和垃圾收集都很慢,但当前的JVM(1.5以后)分配一个对象只需要大致10条机器指令,比c语言的malloc还快。因此纯粹为了避免内存分配而自建对象池是没有必要的,只会适得其反。

首先,对象池并不好管理,容易发生的问题包括:

  • 线程将对象返回给对象池后仍然使用它;
  • 对象返回个给对象池时并没有重置
  • 对象池的大小并不好确定,小了没作用,大了影响垃圾收集;
  • 在并发系统中,对象池相当于一个热点状态,容器发生线程竞争(jvm分配对象时优先使用线程指定内存区,避免竞争)。

总之一句话,JVM对内存对象的分配和回收肯定做得比你好,不要瞎折腾。

JVM的锁优化技术

JVM能够将不可能发生竞争的同步代价完全优化掉,比如以下代码:

synchronized (new Object()) {
	// do something
}

JVM还能进行更加复杂的逃逸分析,来确定对象未被发布,是thread-local的。比如下面的代码,Vector执行了三次add和一次toString(都是同步的):

public String getStoogeNames() {
	List<String> stooges = new Vector<String>();
	stooges.add("Moe");
	stooges.add("Larry");
	stooges.add("Curly");
	return stooges.toString();
}

一个聪明的JVM能够发现Vector对象是栈封闭的,所以四次synchronize都是没有必要的,全部会被优化掉。

再看以下代码:

public void getStoogeNames(Vector stooges) {
	stooges.add("Moe");
	stooges.add("Larry");
	stooges.add("Curly");
}

虽然Vector不是栈封闭的,JVM也能进行叫做”锁粗化“的优化措施,将三次同步合并为一次同步。

不要过于担忧非竞争同步的性能开销,它本身的速度足够快,JVM还能做进一步的优化。应该将优化精力放在竞争频繁发生的地方。

总结

使用多线程的主要动机之一就是充分利用多核CPU的处理能力,在谈论一个并发程序的性能时,我们更加关注吞吐率和可伸缩性。Amdahl定律告诉我们,可伸缩性是由程序必须串行执行的操作决定的,因此减少串行操作的时间比例能够改善程序可伸缩性。而锁竞争是串行操作的主要来源,因此我们应该尽量减少锁竞争。减少每个线程持有排他锁的时间,能有效减少锁竞争,具体技术包括:锁拆分、锁分离、使用排他锁之外的同步技术(读写锁、volatile、Atomic变量、不可变对象等)。

我们可以监测CPU的运行情况,来评估系统的并发性能,,Unix系统的vmstat、mpstat工具,Windows系统上的perfmon工具能够告诉你每个CPU的使用情况。

首先,我们应该观测CPU的多个核的使用情况是否对称,如果某些核使用率很高,而某些核使用率很低,那么程序的并发性肯定有问题。

如果CPU使用率不高,可能有以下原因:

  • 负载不足:尝试增加测试负载,并关注CPU使用率、响应时间;
  • IO-bound:通过iostat或perform工具确认,程序被磁盘、网络等IO操作阻塞;
  • 依赖外部因素:是否依赖外部Web服务或数据库,需要测量这些服务的响应时间;
  • 锁竞争:通过随机地dump程序的线程状态,可以定位出那些导致线程竞争的热点锁;

如果压测时CPU负载较高,且总是存在一些runable状态线程等待调度,那么就有可能通过增加CPU来提升性能。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值