4实战java高并发程序设计--4.锁的优化及注意事项

锁是最常用的同步方法之一。在高并发的环境下,激烈的锁竞争会导致程序的性能下降,因此我们有必要讨论一些有关锁的性能问题,以及一些注意事项,比如避免死锁、减小锁粒度、锁分离等

对于单任务或者单线程的应用而言,其主要资源消耗都花在任务本身。它既不需要维护并行数据结构间的一致性状态,也不需要为线程的切换和调度花费时间。但对于多线程应用来说,系统除了处理功能需求外,还需要额外维护多线程环境的特有信息,如线程本身的元数据、线程的调度、线程上下文的切换等。

事实上,在单核CPU上采用并行算法的效率一般要低于原始的串行算法的效率,其根本原因也在于此。因此,并行计算之所以能提高系统的性能,并不是因为它“少干活”了,而是因为并行计算可以更合理地进行任务调度,充分利用各个CPU资源。因此,合理的并发,才能将多核CPU的性能发挥到极致。


4.1 有助于提高锁性能的几点建议

4.1.1 减少锁持有时间

对于使用锁进行并发控制的应用程序而言,在锁竞争过程中,单个线程对锁的持有时间与系统性能有着直接的关系。如果线程持有锁的时间越长,那么相对地,锁的竞争程度也就越激烈。以下面的代码段为例:

在syncMethod()方法中,假设只有mutextMethod()方法是有同步需要的,而othercode1()方法和othercode2()方法并不需要做同步控制。如果othercode1()和othercode2()分别是重量级的方法,则会花费较长的CPU时间。如果在并发量较大时,使用这种对整个方法做同步的方案,则会导致等待线程大量增加。因为一个线程,在进入该方法时获得内部锁,只有在所有任务都执行完后,才会释放锁

一个较为优化的解决方案是,只在必要时进行同步,这样就能明显减少线程持有锁的时间,提高系统的吞吐量

注意:减少锁的持有时间有助于降低锁冲突的可能性,进而提升系统的并发能力。

4.1.2 减小锁粒度

减小锁粒度也是一种削弱多线程锁竞争的有效手段。这种技术典型的使用场景就是ConcurrentHashMap类的实现

对于HashMap来说,最重要的两个方法就是get()和put()。一种最自然的想法就是,对整个HashMap加锁从而得到一个线程安全的对象,但是这样做,加锁粒度太大。对于ConcurrentHashMap类,它内部进一步细分了若干个小的HashMap,称之为段(SEGMENT)。在默认情况下,一个ConcurrentHashMap类可以被细分为16个段

如果需要在ConcurrentHashMap类中增加一个新的表项,并不是将整个HashMap加锁,而是首先根据hashcode得到该表项应该被存放到哪个段中,然后对该段加锁,并完成put()方法操作。在多线程环境中,如果多个线程同时进行put()方法操作,只要被加入的表项不存放在同一个段中,线程间便可以做到真正的并行

由于默认有16个段,因此,如果够幸运的话,ConcurrentHashMap类可以接受16个线程同时插入(如果都插入不同的段中),从而大大提升其吞吐量。下面代码显示了put()方法操作的过程。第5~6行代码根据key获得对应段的序号。接着在第9行得到段,然后将数据插入给定的段中。

但是,减小锁粒度会带来一个新的问题,即当系统需要取得全局锁时,其消耗的资源会比较多。仍然以ConcurrentHashMap类为例,虽然其put()方法很好地分离了锁,但是当试图访问ConcurrentHashMap类的全局信息时,就需要同时取得所有段的锁方能顺利实施。比如ConcurrentHashMap类的size()方法,它将返回ConcurrentHashMap类的有效表项的数量,即ConcurrentHashMap类的全部有效表项之和。要获取这个信息需要取得所有子段的锁,因此,其size()方法的部分代码如下:

可以看到在计算总数时,先要获得所有段的锁再求和。但是,ConcurrentHashMap类的size()方法并不总是这样执行的,事实上,size()方法会先使用无锁的方式求和,如果失败才会尝试这种加锁的方法。但不管怎么说,在高并发场合ConcurrentHashMap类的size()方法的性能依然要差于同步的HashMap。

因此,只有在类似于size()方法获取全局信息的方法调用并不频繁时,这种减小锁粒度的方法才能在真正意义上提高系统的吞吐量。

注意:所谓减小锁粒度,就是指缩小锁定对象的范围,从而降低锁冲突的可能性,进而提高系统的并发能力。

4.1.3 用读写分离锁来替换独占锁

如果说减小锁粒度是通过分割数据结构实现的,那么读写分离锁则是对系统功能点的分割

注意:在读多写少的场合使用读写锁可以有效提升系统的并发能力。

4.1.4 锁分离

如果将读写锁的思想进一步延伸,就是锁分离。读写锁根据读写操作功能上的不同,进行了有效的锁分离。依据应用程序的功能特点,使用类似的分离思想,也可以对独占锁进行分离。一个典型的案例就是java.util.concurrent.LinkedBlockingQueue的实现(我们在之前已经讨论了它的近亲ArrayBlockingQueue的内部实现)。在LinkedBlockingQueue的实现中,take()函数和put()函数分别实现了从队列中取得数据和往队列中增加数据的功能。虽然两个函数都对当前队列进行了修改操作,但由于LinkedBlockingQueue是基于链表的,因此两个操作分别作用于队列的前端和尾端,从理论上说,两者并不冲突。

通过takeLock和putLock两把锁,LinkedBlockingQueue实现了取数据和写数据的分离,使两者在真正意义上成为可并发的操作。

4.1.5 锁粗化

通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。只有这样,等待在这个锁上的其他线程才能尽早地获得资源执行任务。但是,凡事都有一个度,如果对同一个锁不停地进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化

为此,虚拟机在遇到一连串连续地对同一个锁不断进行请求和释放的操作时,便会把所有的锁操作整合成对锁的一次请求,从而减少对锁的请求同步的次数,这个操作叫作锁的粗化.

在开发过程中,大家也应该有意识地在合理的场合进行锁的粗化,尤其当在循环内请求锁时。以下是一个循环内请求锁的例子,在这种情况下,意味着每次循环都有申请锁和释放锁的操作。但在这种情况下,显然是没有必要的

注意:性能优化就是根据运行时的真实情况对各个资源点进行权衡折中的过程。锁粗化的思想和减少锁持有时间是相反的,但在不同的场合,它们的效果并不相同,因此要根据实际情况进行权衡。


4.2 Java虚拟机对锁优化所做的努力

4.2.1 锁偏向

锁偏向是一种针对加锁操作的优化手段。它的核心思想是:如果一个线程获得了锁,那么锁就进入偏向模式。当这个线程再次请求锁时,无须再做任何同步操作。这样就节省了大量有关锁申请的操作,从而提高了程序性能。因此,对于几乎没有锁竞争的场合,偏向锁有比较好的优化效果,因为连续多次极有可能是同一个线程请求相同的锁。而对于锁竞争比较激烈的场合,其效果不佳。因为在竞争激烈的场合,最有可能的情况是每次都是不同的线程来请求相同的锁。这样偏向模式会失效,因此还不如不启用偏向锁。使用Java虚拟机参数-XX:+UseBiasedLocking可以开启偏向锁。

4.2.2 轻量级锁(说的啥玩意)

如果偏向锁失败,那么虚拟机并不会立即挂起线程,它还会使用一种称为轻量级锁的优化手段。轻量级锁的操作也很方便,它只是简单地将对象头部作为指针指向持有锁的线程堆栈的内部,来判断一个线程是否持有对象锁。如果线程获得轻量级锁成功,则可以顺利进入临界区。如果轻量级锁加锁失败,则表示其他线程抢先争夺到了锁,那么当前线程的锁请求就会膨胀为重量级锁。

4.2.3 自旋锁

锁膨胀后,为了避免线程真实地在操作系统层面挂起,虚拟机还会做最后的努力—自旋锁。当前线程暂时无法获得锁,而且什么时候可以获得锁是一个未知数,也许在几个CPU时钟周期后就可以得到锁。如果这样,简单粗暴地挂起线程可能是一种得不偿失的操作。系统会假设在不久的将来,线程可以得到这把锁。因此,虚拟机会让当前线程做几个空循环(这也是自旋的含义),在经过若干次循环后,如果可以得到锁,那么就顺利进入临界区。如果还不能获得锁,才会真的将线程在操作系统层面挂起。

4.2.4 锁消除

锁消除是一种更彻底的锁优化。Java虚拟机在JIT编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁。通过锁消除,可以节省毫无意义的请求锁时间

说到这里,细心的读者可能会产生疑问,如果不存在竞争,为什么程序员还要加上锁呢?这是因为在Java软件开发过程中,我们必然会使用一些JDK的内置API,比如StringBuffer、Vector等。你在使用这些类的时候,也许根本不会考虑这些对象到底内部是如何实现的。比如,你很有可能在一个不可能存在并发竞争的场合使用Vector。而众所周知,Vector内部使用了synchronized请求锁,比如下面的代码:

注意上述代码中的Vector,由于变量v只在createStrings()函数中使用,因此它只是一个单纯的局部变量。局部变量是在线程栈上分配的,属于线程私有的数据,因此不可能被其他线程访问。在这种情况下,Vector内部所有加锁同步都是没有必要的。如果虚拟机检测到这种情况,就会将这些无用的锁操作去除。

锁消除涉及的一项关键技术为逃逸分析。所谓逃逸分析就是观察某一个变量是否会逃出某一个作用域。在本例中,变量v显然没有逃出createStrings()函数之外。以此为基础,虚拟机才可以大胆地将变量v内部的加锁操作去除。如果createStrings()函数返回的不是String数组,而是变量v本身,那么就认为变量v逃逸出了当前函数,也就是说变量v有可能被其他线程访问。如果是这样,虚拟机就不能消除变量v中的锁操作。

逃逸分析必须在-server模式下进行,可以使用-XX:+DoEscapeAnalysis参数打开逃逸分析。使用-XX:+EliminateLocks参数可以打开锁消除。


4.3 人手一支笔:ThreadLocal

除了控制资源的访问外,我们还可以通过增加资源来保证所有对象的线程安全。比如,让100个人填写个人信息表,如果只有一支笔,那么大家就得挨个填写,对于管理人员来说,必须保证大家不会去哄抢这仅存的一支笔,否则,谁也填不完。从另外一个角度出发,我们可以准备100支笔,人手一支,那么所有人很快就能完成表格的填写工作。如果说锁使用的是第一种思路,那么ThreadLocal使用的就是第二种思路.

4.3.1 ThreadLocal的简单使用

从ThreadLocal的名字上可以看到,这是一个线程的局部变量。也就是说,只有当前线程可以访问。既然是只有当前线程可以访问的数据,自然是线程安全的。

4.3.2 ThreadLocal的实现原理

ThreadLocal如何保证这些对象只被当前线程访问呢?下面让我们一起深入ThreadLocal的内部实现。我们需要关注的自然是ThreadLocal的set()方法和get()方法。先从set()方法说起:

而设置到ThreadLocal中的数据,也正是写入了threadLocals的这个Map。其中,key为ThreadLocal当前对象,value就是我们需要的值。而threadLocals本身就保存了当前自己所在线程的所有“局部变量”,也就是一个ThreadLocal变量的集合

     在ThreadLocal类中有一个ThreadLocalMap, 用于存放每一个线程的变量副本,Map中元素的key为线程对象,value为对应线程的变量副本。

    另外,说ThreadLocal使得各线程能够保持各自独立的一个对象,并不是通过ThreadLocal.set()来实现的,而是通过每个线程中的new 对象 的操作来创建的对象,每个线程创建一个,不是什么对象的拷贝或副本。通过ThreadLocal.set()将这个新创建的对象的引用保存到各线程的自己的一个map中,每个线程都有这样一个map,执行ThreadLocal.get()时,各线程从自己的map中取出放进去的对象,因此取出来的是各自自己线程中的对象,ThreadLocal实例是作为map的key来使用的。 

  如果ThreadLocal.set()进去的东西本来就是多个线程共享的同一个对象,那么多个线程的ThreadLocal.get()取得的还是这个共享对象本身,还是有并发访问问题。

在了解了ThreadLocal的内部实现后,我们自然会引出一个问题:那就是这些变量是维护在Thread类内部的(ThreadLocalMap定义所在类),这也意味着只要线程不退出,对象的引用将一直存在。当线程退出时,Thread类会进行一些清理工作,其中就包括清理ThreadLocalMap.

因此,使用线程池就意味着当前线程未必会退出(比如固定大小的线程池,线程总是存在)。如果这样,将一些大的对象设置到ThreadLocal中(它实际保存在线程持有的threadLocals Map内),可能会使系统出现内存泄漏的可能(这里我的意思是:你设置了对象到ThreadLocal中,但是不清理它,在你使用几次后,这个对象也不再有用了,但是它却无法被回收)。

此时,如果你希望及时回收对象,最好使用ThreadLocal.remove()方法将这个变量移除。就像我们习惯性地关闭数据库连接一样。如果你确实不需要这个对象了,就应该告诉虚拟机,请把它回收,防止内存泄漏。

另外一种有趣的情况是JDK也可能允许你像释放普通变量一样释放ThreadLocal。比如,我们有时候为了加速垃圾回收,会特意写出类似obj=null的代码。如果这么做,那么obj所指向的对象就会更容易地被垃圾回收器发现,从而加速回收。同理,如果对于ThreadLocal的变量,我们也手动将其设置为null,比如tl=null,那么这个ThreadLocal对应的所有线程的局部变量都有可能被回收。这里面的奥秘是什么呢?先来看一个简单的例子。

ThreadLocalMap的实现使用了弱引用。弱引用是比强引用弱得多的引用。Java虚拟机在垃圾回收时,如果发现弱引用,就会立即回收.


4.4 无锁

无锁的策略使用一种叫作比较交换(CAS,Compare And Swap)的技术来鉴别线程冲突,一旦检测到冲突产生,就重试当前操作直到没有冲突为止

4.4.1 与众不同的并发策略:比较交换

与锁相比,使用比较交换会使程序看起来更加复杂一些,但由于其非阻塞性,它对死锁问题天生免疫,并且线程间的相互影响也远远比基于锁的方式要小。更为重要的是,使用无锁的方式完全没有锁竞争带来的系统开销,也没有线程间频繁调度带来的开销,因此,它要比基于锁的方式拥有更优越的性能。\

CAS算法的过程是:它包含三个参数CAS(V,E,N),其中V表示要更新的变量,E表示预期值,N表示新值。仅当V值等于E值时,才会将V的值设为N,如果V值和E值不同,说明已经有其他线程做了更新,则当前线程什么都不做。最后,CAS返回当前V的真实值。CAS操作是抱着乐观的态度进行的,它总是认为自己可以成功完成操作。当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。基于这样的原理,CAS操作即使没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理。

简单地说,CAS需要你额外给出一个期望值,也就是你认为这个变量现在应该是什么样子的。如果变量不是你想象的那样,则说明它已经被别人修改过了。你就重新读取,再次尝试修改就好了。

4.4.2 无锁的线程安全整数:AtomicInteger

为了让Java程序员能够受益于CAS等CPU指令,JDK并发包中有一个atomic包,里面实现了一些直接使用CAS操作的线程安全的类型。

其中,最常用的一个类就是AtomicInteger,可以把它看作一个整数。与Integer不同,它是可变的,并且是线程安全的。对其进行修改等任何操作都是用CAS指令进行的。这里简单列举一下AtomicInteger的一些主要方法,对于其他原子类,操作也是非常类似的。

第6行的AtomicInteger.incrementAndGet()方法会使用CAS操作将自己加1,同时也会返回当前值(这里忽略了当前值)。执行这段代码,你会看到程序输出了100 000。这说明程序正常执行,没有错误。如果不是线程安全,那么i的值应该会小于100 000才对。

使用AtomicInteger会比使用锁具有更好的性能

这里让人印象深刻的应该是incrementAndGet()方法的第2行for循环吧!如果你是初次看到这样的代码,可能会觉得很奇怪,为什么连设置一个值那么简单的操作都需要一个死循环呢?原因就是:CAS操作未必是成功的,因此对于不成功的情况,我们就需要不断地进行尝试。第3行的get()取得当前值,接着加1后得到新值next。这里,我们就得到了CAS必需的两个参数:期望值及新值。使用compareAndSet()方法将新值next写入,成功的条件是在写入的时刻,当前的值应该要等于刚刚取得的current。如果不是这样,则说明AtomicInteger的值在第3行到第5行代码之间又被其他线程修改过了。当前线程看到的状态就是一个过期状态。因此,compareAndSet返回失败,需要进行下一次重试,直到成功。

和AtomicInteger类似的类还有:AtomicLong用来代表long型数据;AtomicBoolean表示boolean型数据;AtomicReference表示对象引用。

4.4.3 Java中的指针:Unsafe类

如果你对技术有追求,应该还会特别在意incrementAndGet() 方法中compareAndSet()方法的实现。现在,就让我们进一步看一下它吧!

这里就可以看到,虽然Java抛弃了指针,但是在关键时刻,类似指针的技术还是必不可少的。这里底层的Unsafe类实现就是最好的例子。但是很不幸,JDK的开发人员并不希望大家使用这个类。获得Unsafe类实例的方法是调动其工厂方法getUnsafe(),但是它的实现却是这样的:

注意加粗部分的代码,它会检查调用getUnsafe()函数的类,如果这个类的ClassLoader不为null,就直接抛出异常,拒绝工作。因此,这也使得我们自己的应用程序无法直接使用Unsafe类。它是一个在JDK内部使用的专属类。

4.4.4 无锁的对象引用:AtomicReference

之前我们说过,线程判断被修改对象是否可以正确写入的条件是对象的当前值和期望值是否一致。这个逻辑从一般意义上来说是正确的。但有可能出现一个小小的例外,就是当你获得对象当前数据后,在准备修改为新值前,对象的值被其他线程连续修改了两次,而经过这两次修改后,对象的值又恢复为旧值。这样,当前线程就无法正确判断这个对象究竟是否被修改过,图4.2显示了这种情况。

一般来说,发生这种情况的概率很小,即使发生了,可能也不是什么大问题。比如,我们只是简单地要做一个数值加法,即使在取得期望值后,这个数字被不断地修改,只要它最终改回了我的期望值,我的加法计算就不会出错。也就是说,当你修改的对象没有过程的状态信息时,所有的信息都只保存于对象的数值本身。

但是,在现实中,还可能存在另外一种场景,就是我们是否能修改对象的值,不仅取决于当前值,还和对象的过程变化有关,这时,AtomicReference就无能为力了。

打一个比方,有一家蛋糕店,为了挽留客户,决定为贵宾卡里余额小于20元的客户一次性赠送20元,刺激客户充值和消费,但条件是,每一位客户只能被赠送一次。现在,我们就来模拟这个场景,为了演示AtomicReference,我在这里使用AtomicReference实现这个功能。首先,我们模拟客户账户余额。定义客户账户余额:

如果在赠予金额到账的同时,客户进行了一次消费,使得总金额又小于20元,并且正好累计消费了20元。使得消费、赠予后的金额等于消费前、赠予前的金额,那么后台的赠予进程就会误以为这个账户还没有赠予,所以,存在被多次赠予的可能。模拟这个消费线程:

虽然这种情况出现的概率不大,但是依然是有可能出现的。因此,当业务上确实可能出现这种情况时,我们也必须多加防范。JDK也已经为我们考虑到了这种情况,使用AtomicStampedReference就可以很好地解决这个问题。

4.4.5 带有时间戳的对象引用:AtomicStampedReference

AtomicReference无法解决上述问题的根本原因是,对象在修改过程中丢失了状态信息,对象值本身与状态被画上了等号。因此,我们只要能够记录对象在修改过程中的状态值,就可以很好地解决对象被反复修改导致线程无法正确判断对象状态的问题。AtomicStampedReference正是这么做的。它内部不仅维护了对象值,还维护了一个时间戳(我这里把它称为时间戳,实际上它可以使任何一个整数来表示状态值)。当AtomicStampedReference对应的数值被修改时,除了更新数据本身外,还必须要更新时间戳。当AtomicStampedReference设置对象值时,对象值及时间戳都必须满足期望值,写入才会成功。因此,即使对象值被反复读写,写回原值,只要时间戳发生变化,就能防止不恰当的写入

有了AtomicStampedReference这个法宝,我们再也不用担心对象被写坏啦!现在,就让我们使用AtomicStampedReference来修正那个贵宾卡充值的问题。

在第2行中,我们使用AtomicStampedReference代替原来的AtomicReference。第6行获得账户的时间戳,后续的赠予操作以这个时间戳为依据。如果赠予成功(第13行),则修改时间戳,使得系统不可能发生二次赠予的情况。消费线程也是类似的,每次操作都使时间戳加1(第36行),使之不可能重复。

4.4.6 数组也能无锁:AtomicIntegerArray

除提供基本数据类型以外,JDK还为我们准备了数组等复合结构。当前可用的原子数组有:AtomicIntegerArray、AtomicLongArray和AtomicReferenceArray,分别表示整数数组、long型数组和普通的对象数组。

AtomicIntegerArray本质上是对int[]类型的封装,使用Unsafe类通过CAS的方式控制int[]在多线程下的安全性。它提供了以下几个核心API

4.4.7 让普通变量也享受原子操作:AtomicIntegerFieldUpdater

有时候,由于初期考虑不周,或者后期的需求变化,一些普通变量可能也会有线程安全的需求。如果改动不大,则可以简单地修改程序中每一个使用或者读取这个变量的地方。但显然,这样并不符合软件设计中的一条重要原则—开闭原则。也就是系统对功能的增加应该是开放的,而对修改应该是相对保守的。而且,如果系统里使用到这个变量的地方特别多,一个一个修改也是一件令人厌烦的事情(况且很多使用场景下可能是只读的,并无线程安全的强烈要求,完全可以保持原样)。如果你有这种困扰,在这里根本不需要担心,因为在原子包里还有一个实用的工具类AtomicIntegerFieldUpdater。它可以让你在不改动(或者极少改动)原有代码的基础上,让普通的变量也享受CAS操作带来的线程安全性,这样你可以通过修改极少的代码来获得线程安全的保证。这听起来是不是让人很激动呢?

根据数据类型不同,Updater有三种,分别是AtomicIntegerFieldUpdater、AtomicLong-FieldUpdater和AtomicReferenceFieldUpdater。顾名思义,它们分别可以对int、long和普通对象进行CAS修改。

大家如果运行这段程序,不难发现,最终的Candidate.score总是和allScore绝对相等。这说明AtomicIntegerFieldUpdater很好地保证了Candidate.score的线程安全。虽然AtomicIntegerFieldUpdater很好用,但是还是有几个注意事项。

第一,Updater只能修改它可见范围内的变量,因为Updater使用反射得到这个变量。如果变量不可见,就会出错。比如score声明为private,就是不可行的。

第二,为了确保变量被正确的读取,它必须是volatile类型的。如果我们原有代码中未声明这个类型,那么简单地声明一下就行,这不会引起什么问题。

第三,由于CAS操作会通过对象实例中的偏移量直接进行赋值,因此,它不支持static字段(Unsafe.objectFieldOffset()方法不支持静态变量)。

通过AtomicIntegerFieldUpdater,我们可以更加随心所欲地对系统关键数据进行线程安全的保护。

4.4.8 挑战无锁算法:无锁的Vector实现

这里向大家介绍一种使用无锁方式实现的Vector。通过这个案例,我们可以更加深刻地认识无锁的算法,同时也可以学习一下有关Vector实现的细节和算法技巧(本例讲述的无锁Vector来自amino并发包)。

我们将这个无锁的Vector称为LockFreeVector。它的特点是可以根据需求动态扩展其内部空间。在这里,我们使用二维数组来表示LockFreeVector的内部存储。


4.5 有关死锁的问题

在学习了无锁之后,让我们重新回到锁的世界吧!在众多的应用程序中,使用锁的情况一般要多于无锁。因为对于应用来说,如果业务逻辑很复杂,会极大增加无锁的编程难度但如果使用锁,我们就不得不对一个新的问题引起重视—死锁。

什么是死锁呢?通俗地说,死锁就是两个或者多个线程相互占用对方需要的资源,而都不进行释放,导致彼此之间相互等待对方释放资源,产生了无限制等待的现象。死锁一旦发生,如果没有外力介入,这种等待将永远存在,从而对程序产生严重的影响。

最简单的情况就是只有两个哲学家,假设是A和B,桌面也只有两个叉子。A左手拿着其中一只叉子,B也一样。这样他们的右手等待对方的叉子,并且这种等待会一直持续,从而导致程序永远无法正常执行。

在实际环境中,遇到了这种情况,通常的表现就是相关的进程不再工作,并且CPU占用率为0(因为死锁的线程不占用CPU),不过这种表面现象只能用来猜测问题。如果想要确认问题,还需要使用JDK提供的一套专业工具。

首先,我们可以使用jps命令得到Java进程的进程ID,接着使用jstack命令得到线程的线程堆栈

如果想避免死锁,除使用无锁的函数之外,还有一种有效的做法是使用第3章介绍的重入锁,通过重入锁的中断或者限时等待可以有效规避死锁带来的问题。大家可以回顾一下相关内容。

发布了33 篇原创文章 · 获赞 1 · 访问量 5516

猜你喜欢

转载自blog.csdn.net/ashylya/article/details/104357050