Java的多线程是一把双刃剑,使用好它可使咱们的程序更高效,可是出现并发问题时,咱们的程序将会变得很是糟糕。并发编程中须要注意三方面的问题,分别是安全性、活跃性和性能问题。html
咱们常常说这个方法是线程安全的、这个类是线程安全的,那么到底该怎么理解线程安全呢?java
要给线程安全性定一个很是明确的定义是比较复杂的。越正式的定义越复杂,也就越难理解。可是无论怎样,在线程安全性定义中,最核心的概念仍是正确性,能够简单的理解为程序按照咱们指望的执行。
正确性的含义是:某个类的行为与其规范彻底一致。线程的安全性就能够理解为:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。算法
咱们要想编写出线程安全的程序,就须要避免出现并发问题的三个主要源头:原子性问题、可见性问题和有序性问题。(前面的文章介绍了规避这三个问题的方法)固然也不是全部的代码都须要分析这三个问题,只有存在共享数据而且该数据会发生变化,即有多个线程会同时读写同一个数据时,咱们才须要同步对共享变量的操做以保证线程安全性。数据库
这也暗示了,若是不共享数据或者共享数据状态不发生变化,那么也能够保证线程安全性。编程
综上,咱们能够总结出设计线程安全的程序能够从如下三个方面入手:数组
咱们前面介绍过使用Java中主要的同步机制synchronized关键字来协同线程对变量的访问,synchronized提供的是一种独占的加锁方式。同步机制除了synchronized内置锁方案,还包括volatile类型变量,显式锁(Explicit Lock)以及原子变量。而基于一二点的技术方案有线程本地存储(Thread Local Storage, LTS)、不变模型等(后面会介绍)。缓存
当多个线程同时访问一个数据,而且至少有一个线程会写这个数据时,若是咱们不采用任何 同步机制协同这些线程对变量的访问,那么就会致使并发问题。这种状况咱们叫作数据竞争(Data Race)。安全
例以下面的例子就会发生数据竞争。性能优化
public class Test { private long count = 0; void add10K() { int idx = 0; while(idx++ < 10000) { count += 1; } } }
当多个线程调用add10K()
时,就会发生数据竞争。可是咱们下面使用synchronized同步机制就能够来防止数据竞争。服务器
public class Test { private long count = 0; synchronized long get(){ return count; } synchronized void set(long v){ count = v; } void add10K() { int idx = 0; while(idx++ < 10000) { set(get()+1); } } }
可是此时的add10K()
方法并非线程安全的。
假设count=0, 当两个线程同时执行get()方法后,get()方法会返回相同的值0,两个线程执行get()+1操做,结果都是1,以后两个线程再将结果1写入了内存。原本指望的是2,可是结果倒是1。(至于为何会同时?我当初脑壳被“阻塞”好一下子才反应过来,哈哈,╮(~▽~)╭,看来不能熬夜写博客。由于若是实参须要计算那么会先被计算,而后做为函数调用的参数传入。这里get()会先被调用,等其返回了才会调用set(),因此一个线程调用完了get()后,另外一个线程能够立刻获取锁调用get()。这也就会形成两个线程会获得相同的值。)
这种状况,咱们称为竞态条件(Race Condition)。竞态条件,是指程序的执行结果依赖线程执行的顺序 。
上面的例子中,若是两个线程彻底同时执行,那么结果是1;若是两个线程是先后执行,那么结果就是2。在并发环境里,线程的执行顺序是不肯定的,若是程序存在竞态条件问题,那么就意味着程序执行的结果是不肯定的,而执行结果不肯定就是一个大问题。
咱们前面讲并发bug源头时,也介绍过竞态条件。因为不恰当的执行时序而致使的不正确的结果。要避免竞态条件问题,就必须在某个线程修改该变量时,经过某种方式防止其余线程使用这个变量,从而确保其余线程只能在修改操做完成以前或者以后读取和修改状态,而不是在修改状态的过程当中。
解决这个例子的竞态条件问题,咱们能够介绍过的加锁机制来保证:其余线程只能在修改操做完成以前或者以后读取和修改状态,而不是在修改状态的过程当中。
public class Test { private long count = 0; synchronized long get(){ return count; } synchronized void set(long v){ count = v; } void add10K() { int idx = 0; while(idx++ < 10000) { synchronized(this){ set(get()+1); } } } }
因此面对数据竞争和竞态条件咱们可使用加锁机制来保证线程的安全性!
安全性的含义是“永远不发生糟糕的事情”,而活跃性则关注另一个目标,即“某件正确的事情最终会发生”。 当某个操做没法继续执行下去时,就会发生活跃性问题。
在串行程序中,活跃性问题的形式之一即是无心中形成的无限循环。从而使循环以后的代码没法被执行。而线程将会带来其余的一些活跃性问题,例如咱们前面所讲的死锁,以及咱们下面将要介绍的饥饿和活锁。
饥饿(Starvation)指的是线程没法访问到所须要的资源而没法执行下去的状况。
引起饥饿最多见的资源即是CPU时钟周期。若是Java应用程序中对线程的优先级使用不当,或者在持有锁时执行一些没法结束的结构(例如无限循环或者无限制地等待某个资源),那么也可能致使饥饿,由于其余须要这个锁的线程没法获得它。
一般,咱们尽可能不要改变线程的优先级,在大部分并发应用程序中,可使用默认的线程优先级。只要改变了线程的优先级,程序的行为就将与平台相关,而且可能致使发生饥饿问题的风险(例如优先级高的线程会一直获取资源,而低优先级的线程则将一直没法获取到资源)。
当某个程序会在一些奇怪的地方调用Thread.sleep
或Thread.yield
,那是这个程序在试图克服优先级调整问题或响应性问题,并试图让低优先级的线程执行更多的时间。
饥饿问题的实质能够用孔子老人家说过的一句话来总结:不患寡而患不均。
解决饥饿问题,有如下三种方案:
这三个方案中,方案一和方案三的适用场景比较有限,由于不少场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。因此,方案二的适用场景会多一点。在并发编程里,咱们可使用公平锁来公平的分配资源。所谓公平锁,是一种FIFO方案,线程的等待是有顺序的,排在等待队列前面的线程会优先得到资源。
活锁(Livelock)是另外一种形式的活跃性问题,它和死锁很类似,可是它却不会阻塞线程。活锁尽管不会阻塞线程,但也不能继续执行,由于线程将不断重复执行相同的操做,并且总会失败。
活锁一般发生在处理事务消息的应用程序中:如何不能成功地处理某个消息,那么消息处理机制将回滚整个事务,并将它从新放置到队列的开头。若是消息处理器在处理某种特定的消息时存在错误并致使它失败,那么每当这个消息从队列中取出并传递到存在错误的处理器时,都会发生事务回滚。因为这个消息又被放到队列开头,所以处理器将被反复调用,并返回相同的处理结果。(有时候也被称为毒药消息,Poison Message。)虽然处理消息的线程没有被阻塞,但也没法执行下去。这种形式的活锁,一般由过分的错误恢复代码形成,由于它错误地将不可修复的错误做为可修复的错误。
当多个相互协做的线程都对彼此进行响应从而修改各自的状态,并使得任何一个线程都没法继续执行时,就发生了活锁。 这就比如两个过于礼貌的人在半路上相遇,为了避免相撞,他们彼此都给对方让路,结果致使他们又相撞。他们如此反复下一,便形成了活锁问题。
解决这种活锁问题,咱们在重试机制中引入随机性。即,让他们在谦让时尝试等待一个随机的时间。如此,他们便不会相撞而顺序通行。咱们在以太网协议的二进制指数退避算法中,也能够看到引入随机性下降冲突和反复失败的好处。在并发应用程序中,经过等待随机长度的时间和回退能够有效避免活锁的发生。
与活跃性问题密切相关的是性能问题。活跃性意味着某件正确的事情最终会发生,但却不够好,由于咱们一般但愿正确事情尽快发生。性能问题包括多个方面,例如服务时间过长,响应不灵敏,吞吐量太低,资源消耗太高,或者可伸缩性下降等。与活跃性和安全性同样,在多线程程序中不只存在与单线程程序相同的性能问题,并且还存在因为实现线程而引入的其余性能问题。
咱们使用多线程的目的是提高程序的总体性能,可是与单线程的方法相比,使用多个线程总会引入一些额外的性能开销。形成这些开销的操做包括:线程之间的协调(如加锁、内存同步等),增长上下文切换,线程的建立和销毁,以及线程的调度等。若是咱们多度地使用线程,那么这些开销可能超过因为提升吞吐量、响应性或者计算能力所带来的性能提高。另外一方面,一个并发设计很糟糕的程序,其性能甚至比完成相同功能的串行程序性能还要低。
想要经过并发来得到更好的性能就须要作到:更有效地利用现有处理资源,以及在出现新的处理资源时使程序尽量地利用这些新资源。
下面咱们将介绍如何评估性能、分析多线程带来的额外开销以及如何减小这些开销。
应用程序的性能能够采用多个指标来衡量,例如服务时间、延迟时间、吞吐量、效率、可伸缩性以及容量等。其中一些指标(服务时间、等待时间)用于衡量程序的“运行速度”,即某个指定的任务单元须要“多快”才能处理完成。另外一些指标(生产量、吞吐量)用于程序的“处理能力”,即在计算资源必定的状况下,能完成“多少”工做。
可伸缩性指的是:当增长计算资源(例如CPU、内存、存储容量或者I/O带宽)时,程序的吞吐量或者处理能力相应地增长。在对可伸缩性调优时,目的是将设法将问题的计算并行化,从而可以利用更多的计算资源来完成更多的任务。而咱们传统的对性能调优,目的是用更小的代价完成相同的工做,例如经过缓存来重用以前的计算结果。
大多数的并发程序都是由一系列的并行工做和串行工做组成。
Amdahl定律描述的是:在增长计算资源的状况下,程序在理论上可以实现最高加速比,这个值取决于程序中可并行组件与串行组件所占比重。简单点说,Amdahl定律表明了处理器并行运算以后效率提高的能力。
假定F是必须被串行执行的部分,那么根据Amdahl定律,在包含N个处理器的机器中,最高加速比为:
\[Speedup <= \frac{1}{F+\frac{(1-F)}{N}}\]
当N趋近于无穷大时,最高加速比趋近于\(\frac{1}{F}\) 。所以,若是程序有50%的计算须要串行执行,那么最高加速比只能是2,而无论有多个线程可用。不管咱们采用什么技术,最高也就只能提高2倍的性能。
Amdahl定律量化了串行化的效率开销。在拥有10个处理器的系统中,若是程序中有10%的部分须要串行执行,那么最高加速比为5.3(53%的使用率),在拥有100个处理器的系统中,加速比能够达到9.2(92%的使用率)。可是拥有无限多的处理器,加速比也不会到达10。
若是能准确估计出执行过程当中穿行部分所占的比例,那么Amdahl定律就能够量化当有更多计算资源可用时的加速比。
在多个线程的调度和协调过程当中都须要必定的性能开销。因此咱们要保证,并行带来的性能提高必须超过并发致使的开销,否则这就是一个失败的并发设计。下面介绍并发带来的开销。
上下文切换
若是主线程是惟一的线程,那么它基本上不会被调度出去。若是可运行的线程数目大于CPU的数量,那么操做系统最终会将某个正在运行的线程调度出来,从而使其余线程可以使用CPU。这将致使一次上下文切换,在这个过程当中,将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文。
切换上下文须要必定的开销,而在线程调度过程当中须要访问由操做系统和JVM共享的数据结构。上下文切换的开销不止包含JVM和操做系统的开销。当一个新的线程被切换进来时,它所须要的数据可能不在当前处理器的本地缓存中,所以上下文切换将致使一些缓存缺失(丢失局部性),于是线程在首次调度运行时会更加缓慢。
调度器会为每一个可运行的线程分配一个最小执行时间,即便有许多其余的线程正在等待执行:这是为了将上下文切换的开销分摊到更多不会中断的执行时间上,从而提升总体的吞吐量(以损失响应性为代价)。
当线程被频繁的阻塞时,也可能会致使上下文切换,从而增长调度开销,下降吞吐量。由于,当线程因为没有竞争到锁而被阻塞时,JVM一般会将这个线程挂起,并容许它被交换出去。
上下文切换的实际开销会随着平台的不一样而变化,按照经验来看:在大多数通用的处理器上,上下文切换的开销至关于5000~10000个时钟周期,也就是几微秒。
内存同步
同步操做的性能开销包括多个方面。在synchronized和volatile提供的可见性保证中可能会使用一些特殊指令,即内存栅栏(也就是咱们前面文章介绍过的内存屏障)。内存栅栏能够刷新缓存,使缓存无效,刷新硬件的写缓冲,以及中止执行管道。内存栅栏可能一样会对性能带来间接的影响,由于它们将抑制一些编译器优化操做。在内存栅栏中,大多数的操做都是不能被重排序的。
在评估同步操做带来的性能影响时,须要区分有竞争的同步和无竞争的同步。现代的JVM能够优化一些不会发生竞争的锁,从而减小没必要要的同步开销。
synchronized(new Object()){...}
JVM会经过逃逸分析优化掉以上的加锁。
因此,咱们应该将优化重点放在那些发生锁竞争的地方。
某个线程的同步可能会影响其余线程的性能。同步会增长共享内存总线上的通讯量,总线的带宽是有限的,而且全部的处理器都将共享这条总线。若是有多个线程竞争同步带宽,那么全部使用了同步的线程都会受到影响。
阻塞
非竞争的同步能够彻底在JVM中处理,而竞争的同步可能须要操做系统的介入,从而增长系统的开销。在锁上发生竞争时,竞争失败的线程会被阻塞。JVM在实现阻塞行为时,能够采用自旋等待(Spin-Waitiin,指经过循环不断地尝试获取锁,直到成功)或者经过操做系统挂起被阻塞的线程。这两种方式的效率高低,取决于上下文切换的开销以及在成功获取锁以前须要等待的时间。若是等待时间短,就采用自旋等待方式;若是等待时间长,则适合采用线程挂起的方式。JVM会分析历史等待时间作选择,不过,大多数JVM在等待锁时都只是将线程挂起。
线程被阻塞挂起时,会包含两次的上下文切换,以及全部必要的操做系统操做和缓存操做。
串行操做会下降可伸缩性,而且上下文切换也会下降性能。当在锁上发生竞争时会同时致使这两种问题,所以减小锁的竞争可以提升性能和可伸缩性。
在对某个独占锁保护的资源进行访问时,将采用串行方式——每次只有一个线程能访问它。若是在锁上发生竞争,那么将限制代码的可伸缩性。
在并发程序中,对可伸缩性的最主要的威胁就是独占方式的资源锁。
有两个因素将影响在锁上发生竞争的可能性:锁的请求频率和每次持有该锁的时间。(Little定律)
若是两者的乘积很小,那么大多数获取锁的操做都不会发生竞争,所以在该锁上的竞争不会对可伸缩性形成严重影响。
下面介绍下降锁的竞争程度的方案。
缩小锁的范围
下降发生竞争的可能性的一种有效方式就是尽量缩短锁的持有时间。例如,能够将一些与锁无关的代码移除代码块,尤为是那些开销较大的操做,以及可能被阻塞的操做(I/O操做)。
尽管缩小同步代码块能提升可伸缩性,但同步代码块也不能过小,由于会有一些复合操做须要以原子操做的方式进行,这时就必须在同一同步块中。
减少锁的粒度
另外一种减小锁的持有时间的方式即是下降线程请求锁的频率(从而减少发生竞争的可能性)。这能够经过锁分解和锁分段等技术来实现,这些技术中将采用多个相互独立的锁来保护相互独立的状态变量,从而改变这些变量在以前由单个锁来保护的状况。这些技术能缩小锁操做的粒度,并能实现更高的可伸缩性。可是须要注意,使用的锁越多,也就越容易发生死锁。
若是一个锁须要保护多个相互独立的状态变量,那么能够将这个锁分解为多个锁,而且每一个锁只保护一个变量,从而提升可伸缩性,并最终下降每一个锁被请求的频率。
例如,以下的程序咱们即可以进行锁分解。(例子来自《Java并发编程实践》)
@ThreadSafe // 该注解表示该类是线程安全的 public class ServerStatus { // @GuardedBy(xxx)表示该状态变量是由xxx锁保护 @GuardedBy("this") public final Set<String> users; @GuardedBy("this") public final Set<String> queries; public ServerStatusBeforeSplit() { users = new HashSet<String>(); queries = new HashSet<String>(); } 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); } }
以上程序表示的是某个数据库服务器的部分监视接口,该数据库维护了当前已经登陆的用户以及正在执行的请求。当一个用户登陆、注销、开始查询或者结束查询时,都会调用相应的add或者remove方法来更新ServerStatus对象。这两种类型信息是彻底独立的,所以,咱们能够尝试用锁分解来提高该程序的性能。
@ThreadSafe public class ServerStatus{ @GuardedBy("users") public final Set<String> users; @GuardedBy("queries") public final Set<String> queries; public ServerStatusAfterSplit() { users = new HashSet<String>(); queries = new HashSet<String>(); } public void addUser(String u) { synchronized (users) { users.add(u); } } public void addQuery(String q) { synchronized (queries) { queries.add(q); } } public void removeUser(String u) { synchronized (users) { users.remove(u); } } public void removeQuery(String q) { synchronized (users) { queries.remove(q); } } }
咱们将原来的ServerStatus分解,使用新的细粒度锁来同步对状态变量的维护。减小了锁的竞争,提高了性能。
把一个竞争激烈的锁分解为两个锁时,这两个锁可能都存在激烈的竞争。在上面的锁分解例子中,并不能进一步对锁进行分解。
在某些状况下,能够将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种状况被称为锁分段。
例如,ConcurrentHashMap
的实现中使用了一个包含16个锁的数组,每一个锁保护全部散列桶的\(\frac{1}{16}\) ,其中第N个散列桶由第(N mod 16)个锁来保护。
假设散列函数具备合理的分布性,而且关键字可以实现均匀分布,那么这大约能把对于锁的请求减小到原来的\(\frac{1}{16}\) 。正是由于这项技术,使用ConcurrentHashMap能够支持多大16个并发的写入器。
锁分段的一个劣势在于:须要获取多个锁来实现独占访问将更加困难且开销更高。例如当ConcurrentHashMap须要扩展映射范围,以及从新计算键值的散列值须要分不到更大的桶集合中时,就须要获取全部分段锁。
下面的代码展现了在基于散列的Map中使用锁分段的技术。它拥有N_LOCKS个锁,而且每一个锁保护散列桶的一个子集。大多数方法都只须要得到一个锁,如get(),而有些方法则须要获取到全部的锁,但不要求同时得到,如clear()。(例子来自《Java并发编程实践》)
@ThreadSafe public class StripedMap { // Synchronization policy: buckets[n] guarded by locks[n%N_LOCKS] private static final int N_LOCKS = 16; private final Node[] buckets; private final Object[] locks; private static class Node { Node next; Object key; Object value; } public StripedMap(int numBuckets) { buckets = new Node[numBuckets]; locks = new Object[N_LOCKS]; for (int i = 0; i < N_LOCKS; i++) locks[i] = new Object(); } private final int hash(Object key) { return Math.abs(key.hashCode() % buckets.length); } public Object get(Object key) { int hash = hash(key); synchronized (locks[hash % N_LOCKS]) { for (Node m = buckets[hash]; m != null; m = m.next) if (m.key.equals(key)) return m.value; } return null; } public void clear() { for (int i = 0; i < buckets.length; i++) { synchronized (locks[i % N_LOCKS]) { buckets[i] = null; } } } }
除了缩小锁的范围、减小请求锁的粒度,还有第三种下降锁的影响的技术就是放弃使用独占锁。
使用一些无锁的算法或者数据结构来管理共享状态。例如,使用并发容器、读-写锁、不可变对象以及原子变量。
后面也会陆续介绍这些方案。
结合咱们前面讲的并发知识,咱们如今能够从微观和宏观来理解并发编程。在微观上,设计并发程序时咱们要考虑到原子性、可见性和有序性问题。跳出微观,从宏观上来看,咱们设计程序,要考虑到到线程的安全性、活跃性以及性能问题。咱们在作性能优化的前提是要保证线程安全性,若是会优化后出现并发问题,那么结果将会与咱们的预期背道而驰。
参考: [1]极客时间专栏王宝令《Java并发编程实战》 [2]Brian Goetz.Tim Peierls. et al.Java并发编程实战[M].北京:机械工业出版社,2016