锁机制-使用锁带来的一些问题
首先强调一点:所有锁(包括内置锁和高级锁)都是有性能消耗的,在高并发的情况下,使用锁可能比线程本身的消耗要大,由于锁机制带来的上下文切换,资源同步等消耗,所以如果可能,在任何情况下都应该少使用锁,如果不可避免,采用非阻塞算法是一个不错的解决方案。
内部锁
Java语言通过synchronized关键字来保证原子性。这是因为每一个Object都有一个隐含的锁,这个也称作监视器对象。在进入synchronized之前自动获取此内部锁,而一旦离开此方式(不管通过和中方式离开此方法)都会自动释放锁。显然这是一个独占锁,每个锁请求之间是互斥的。相对于前面介绍的众多高级锁(Lock/ReadWriteLock等),synchronized的代价都比后者要高。但是synchronized的语法比较简单,而且也比较容易使用和理解,不容易写法上的错误。而我们知道Lock一旦调用了lock()方法获取到锁而未正确释放的话很有可能就死锁了。所以Lock的释放操作总是跟在finally代码块里面,这在代码结构上也是一次调整和冗余。另外前面介绍中说过Lock的实现已经将硬件资源用到了极致,所以未来可优化的空间不大,除非硬件有了更高的性能。但是synchronized只是规范的一种实现,这在不同的平台不同的硬件还有很高的提升空间,未来Java在锁上的优化也会主要在这上面。
性能
由于锁总是带了性能影响,所以是否使用锁和使用锁的场合就变得尤为重要。如果在一个高并发的Web请求中使用了强制的独占锁,那么就可以发现Web的吞吐量将急剧下降。
为了利用并发来提高性能,出发点就是:更有效的利用现有的资源,同时让程序尽可能的开拓更多可用的资源。这意味着机器尽可能的处于忙碌的状态,通常意义是说CPU忙于计算,而不是等待。当然CPU要做有用的事情,而不是进行无谓的循环。当然在实践中通常会预留一些资源出来以便应急特殊情况,这在以后的线程池并发中可以看到很多例子。
线程阻塞
锁机制的实现通常需要操作系统提供支持,显然这会增加开销。当锁竞争的时候,失败的线程必然会发生阻塞。JVM既能自旋等待(不断尝试,知道成功,很多CAS就是这样实现的),也能够在操作系统中挂起阻塞的线程,直到超时或者被唤醒。通常情况下这取决于上下文切换的开销以及与获取锁需要等待的时间二者之间的关系。自旋等待适合于比较短的等待,而挂起线程比较适合那些比较耗时的等待。
挂起一个线程可能是因为无法获取到锁,或者需要某个特定的条件,或者耗时的I/O操作。挂起一个线程需要两次额外的上下文切换以及操作系统、缓存等多资源的配合:如果线程被提前换出,那么一旦拿到锁或者条件满足,那么又需要将线程换回执行队列,这对线程而言,两次上下文切换可能比较耗时。
如何减少上下文切换
- 无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。
- CAS算法。Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
- 使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
- 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
锁竞争
影响锁竞争的条件有两个
- 持有锁的时间
- 请求锁的频率
显然当这两者都很小的时候,锁竞争不会成为主要的瓶颈,但是如果使用不当,导致二者都比较大,那么CPU可能不能有效的处理任务,导致任务大量堆积
所以减少锁竞争的方法有以下三个:
- 减少持有锁的时间
- 减少请求锁的频率
- 使用共享锁代替独占锁
死锁
- 如果一个线程永远不释放另一个线程所需要的资源就会导致死锁,这有两种情况,
- 线程A永远不释放锁,导致B永远拿不到锁,所以线程B死掉
- 线程A需要线程B持有的锁,线程B拥有线程A需要的锁,导致线程AB互相等待
- 还有一种情况发生死锁,如果一个线程永远不能被调度,那么等待此线程结果的线程可能就死掉了,这种情况叫做饥饿死锁,比如说在非公平锁中,如果某些线程非常活跃,在高并发的情况下这类线程总能拿到锁,那么活跃度低的线程可能永远拿不到锁,这样就发生了饥饿死
死锁产生的4个必要条件
- 互斥条件:进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一进程所占用。
- 请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。
- 环路等待条件:在发生死锁时,必然存在一个进程--资源的环形链。
预防死锁的解决方案是:
- 尽可能的按照锁的规范使用锁,另外请求锁的粒度要小
- 在高级锁里面使用tryLock或者定时机制,(指定获取锁超时的时间,如果时间到了还没获取到就放弃)
- 以确定的顺序获得锁,针对两个特定的锁,开发者可以尝试按照锁对象的hashCode值大小的顺序,分别获得两个锁,这样锁总是会以特定的顺序获得锁,那么死锁也不会发生。问题变得更加复杂一些,如果此时有多个线程,都在竞争不同的锁,简单按照锁对象的hashCode进行排序(单纯按照hashCode顺序排序会出现“环路等待”),可能就无法满足要求了,这个时候开发者可以使用银行家算法,所有的锁都按照特定的顺序获取,同样可以防止死锁
银行家算法
- 银行家算法:首先需要定义状态和安全状态的概念。系统的状态是当前给进程分配的资源情况。因此,状态包含两个向量Resource(系统中每种资源的总量)和Available(未分配给进程的每种资源的总量)及两个矩阵Claim(表示进程对资源的需求)和Allocation(表示当前分配给进程的资源)。安全状态是指至少有一个资源分配序列不会导致死锁。当进程请求一组资源时,假设同意该请求,从而改变了系统的状态,然后确定其结果是否还处于安全状态。如果是,同意这个请求;如果不是,阻塞该进程知道同意该请求后系统状态仍然是安全的。
死锁恢复
- 利用抢占恢复
- 杀死进程
活锁
线程总是尝试某项操作却总是失败的情况这种情况下线程没有被阻塞,但是任务不能被执行。
- 比如在一个死循环里总是尝试做某件事,结果却总是失败,线程将永远无法跳出这个循环
- 在一个队列中,每次从队列头取出一个任务来执行,每次都失败,然后将任务放入队列头,继续失败
- 在碰撞协议情况下,线程优先度低的线程得不到执行,也会导致活锁发生。