饭饭TXT > 学习管理 > 《实战Java高并发程序设计(出书版)》作者:葛一鸣/郭超【完结】 > 实战Java高并发程序设计.txt

第4章 锁的优化及注意事项.3

作者:葛一鸣/郭超 当前章节:16214 字 更新时间:2026-6-23 07:00

01 public class AtomicIntegerArrayDemo { 02 static AtomicIntegerArray arr = new AtomicIntegerArray(10); 03 public static class AddThread implements Runnable{ 04 public void run(){ 05 for(int k=0;k<10000;k++) 06 arr.getAndIncrement(k%arr.length()); 07 } 08 } 09 public static void main(String[] args) throws InterruptedException { 10 Thread[] ts=new Thread[10]; 11 for(int k=0;k<10;k++){ 12 ts[k]=new Thread(new AddThread()); 13 } 14 for(int k=0;k<10;k++){ts[k].start();} 15 for(int k=0;k<10;k++){ts[k].join();} 16 System.out.println(arr); 17 } 18 }

上述代码第2行,申明了一个内含10个元素的数组。第3行定义的线程对数组内10个元素进行累加操作,每个元素各加1000次。第11行,开启10个这样的线程。因此,可以预测,如果线程安全,数组内10个元素的值必然都是10000。反之,如果线程不安全,则部分或者全部数值会小于10000。

程序的输出结果如下:

[10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000]

这说明AtomicIntegerArray确实合理地保证了数组的线程安全性。

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

有时候,由于初期考虑不周,或者后期的需求变化,一些普通变量可能也会有线程安全的需求。如果改动不大,我们可以简单地修改程序中每一个使用或者读取这个变量的地方。但显然,这样并不符合软件设计中的一条重要原则——开闭原则。也就是系统对功能的增加应该是开放的,而对修改应该是相对保守的。而且,如果系统里使用到这个变量的地方特别多,一个一个修改也是一件令人厌烦的事情(况且很多使用场景下可能只是只读的,并无线程安全的强烈要求,完全可以保持原样)。

如果你有这种困扰,在这里根本不需要担心,因为在原子包里还有一个实用的工具类AtomicIntegerFieldUpdater。它可以让你在不改动(或者极少改动)原有代码的基础上,让普通的变量也享受CAS操作带来的线程安全性,这样你可以修改极少的代码,来获得线程安全的保证。这听起来是不是让人很激动呢?

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

现在来思考这么一个场景。假设某地要进行一次选举。现在模拟这个投票场景,如果选民投了候选人一票,就记为1,否则记为0。最终的选票显然就是所有数据的简单求和。

01 public class AtomicIntegerFieldUpdaterDemo { 02 public static class Candidate{ 03 int id; 04 volatile int score; 05 } 06 public final static AtomicIntegerFieldUpdater<Candidate> scoreUpdater 07 = AtomicIntegerFieldUpdater.newUpdater(Candidate.class, "score"); 08 //检查Updater是否工作正确 09 public static AtomicInteger allScore=new AtomicInteger(0); 10 public static void main(String[] args) throws InterruptedException { 11 final Candidate stu=new Candidate(); 12 Thread[] t=new Thread[10000]; 13 for(int i = 0 ; i < 10000 ; i++) { 14 t[i]=new Thread() { 15 public void run() { 16 if(Math.random()>0.4){ 17 scoreUpdater.incrementAndGet(stu); 18 allScore.incrementAndGet(); 19 } 20 } 21 }; 22 t[i].start(); 23 } 24 for(int i = 0 ; i < 10000 ; i++) { t[i].join();} 25 System.out.println("score="+stu.score); 26 System.out.println("allScore="+allScore); 27 } 28 }

上述代码模拟了这个计票场景,候选人的得票数量记录在Candidate.score中。注意,它是一个普通的volatile变量。而volatile变量并不是线程安全的。第6~7行定义了AtomicIntegerFieldUpdater实例,用来对Candidate.score进行写入。而后续的allScore我们用来检查AtomicIntegerFieldUpdater的正确性。如果AtomicIntegerFieldUpdater真的保证了线程安全,那么最终Candidate.score和allScore的值必然是相等的。否则,就说明AtomicIntegerFieldUpdater根本没有确保线程安全的写入。第12~21行模拟了计票过程,这里假设有大约60%的人投赞成票,并且投票是随机进行的。第17行使用Updater修改Candidate.score(这里应该是线程安全的),第18行使用AtomicInteger计数,作为参考基准。

大家如果运行这段程序,不难发现,最终的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的内部存储,如下:

private final AtomicReferenceArray<AtomicReferenceArray<E>> buckets;

变量buckets存放所有的内部元素。从定义上看,它是一个保存着数组的数组,也就是通常的二维数组。特别之处在于这些数组都是使用CAS的原子数组。为什么使用二维数组去实现一个一维的Vector呢?这是为了将来Vector进行动态扩展时可以更加方便。我们知道,AtomicReferenceArray内部使用Object[]来进行实际数据的存储,这使得动态空间增加特别的麻烦,因此使用二维数组的好处就是为了将来可以方便地增加新的元素。

此外,为了更有序的读写数组,定义一个称为Descriptor的元素。它的作用是使用CAS操作写入新数据。

01 static class Descriptor<E> { 02 public int size; 03 volatile WriteDescriptor<E> writeop; 04 public Descriptor(int size, WriteDescriptor<E> writeop) { 05 this.size = size; 06 this.writeop = writeop; 07 } 08 public void completeWrite() { 09 WriteDescriptor<E> tmpOp = writeop; 10 if (tmpOp != null) { 11 tmpOp.doIt(); 12 writeop = null; // this is safe since all write to writeop use 13 // null as r_value. 14 } 15 } 16 } 17 18 static class WriteDescriptor<E> { 19 public E oldV; 20 public E newV; 21 public AtomicReferenceArray<E> addr; 22 public int addr_ind; 23 24 public WriteDescriptor(AtomicReferenceArray<E> addr, int addr_ind, 25 E oldV, E newV) { 26 this.addr = addr; 27 this.addr_ind = addr_ind; 28 this.oldV = oldV; 29 this.newV = newV; 30 } 31 32 public void doIt() { 33 addr.compareAndSet(addr_ind, oldV, newV); 34 } 35 }

上述代码第4行定义的Descriptor构造函数接收两个参数,第一个为整个Vector的长度,第2个为一个writer。最终,写入数据是通过writer进行的(通过completeWrite()方法)。

第24行,WriteDescriptor的构造函数接收四个参数。第一个参数addr表示要修改的原子数组,第二个参数为要写入的数组索引位置,第三个oldV为期望值,第四个newV为需要写入的值。

在构造LockFreeVector时,显然需要将buckets和descriptor进行初始化。

public LockFreeVector() { buckets = new AtomicReferenceArray<AtomicReferenceArray<E>>(N_BUCKET); buckets.set(0, new AtomicReferenceArray<E>(FIRST_BUCKET_SIZE)); descriptor = new AtomicReference<Descriptor<E>>(new Descriptor<E>(0, null)); }

在这里N_BUCKET为30,也就是说这个buckets里面可以存放一共30个数组(由于数组无法动态增长,因此数组总数也就不能超过30个)。并且将第一个数组的大小FIRST_BUCKET_SIZE设为8。到这里,大家可能会有一个疑问,如果每个数组8个元素,一共30个数组,那岂不是一共只能存放240个元素吗?

如果大家了解JDK内的Vector实现,应该知道,Vector在进行空间增长时,默认情况下,每次都会将总容量翻倍。因此,这里也借鉴类似的思想,每次空间扩张,新的数组的大小为原来的两倍(即每次空间扩展都启用一个新的数组),因此,第一个数组为8,第二个就是16,第三个就是32。依此类推,因此30个数组可以支持的总元素达到。

这数值已经超过了2^33,即在80亿以上。因此,可以满足一般的应用。

当有元素需要加入LockFreeVector时,使用一个名为push_back()的方法,将元素压入Vector最后一个位置。这个操作显然就是LockFreeVector的最为核心的方法,也是最能体现CAS使用特点的方法,它的实现如下:

01 public void push_back(E e) { 02 Descriptor<E> desc; 03 Descriptor<E> newd; 04 do { 05 desc = descriptor.get(); 06 desc.completeWrite(); 07 08 int pos = desc.size + FIRST_BUCKET_SIZE; 09 int zeroNumPos = Integer.numberOfLeadingZeros(pos); 10 int bucketInd = zeroNumFirst - zeroNumPos; 11 if (buckets.get(bucketInd) == null) { 12 int newLen = 2 * buckets.get(bucketInd - 1).length(); 13 if (debug) 14 System.out.println("New Length is:" + newLen); 15 buckets.compareAndSet(bucketInd, null, 16 new AtomicReferenceArray<E>(newLen)); 17 } 18 19 int idx = (0x80000000>>>zeroNumPos) ^ pos; 20 newd = new Descriptor<E>(desc.size + 1, new WriteDescriptor<E>( 21 buckets.get(bucketInd), idx, null, e)); 22 } while (!descriptor.compareAndSet(desc, newd)); 23 descriptor.get().completeWrite(); 24 }

可以看到,这个方法主体部分是一个do-while循环,用来不断尝试对descriptor的设置。也就是通过CAS保证了descriptor的一致性和安全性。在第23行,使用descriptor将数据真正地写入数组中。这个descriptor写入的数据由第20~21行构造的WriteDescriptor决定。

在循环最开始(第5行),使用descriptor先将数据写入数组,是为了防止上一个线程设置完descriptor后(第22行),还没来得及执行第23行的写入,因此,做一次预防性的操作。

因为限制要将元素e压入Vector,因此,我们必须首先知道这个e应该放在哪个位置。由于目前使用了二维数组,因此我们自然需要知道e所在哪个数组(buckets中的下标位置)和数组中的下标。

第8~10行通过当前Vector的大小(desc.size),计算新的元素应该落入哪个数组。这里使用了位运算进行计算。

之前说过,LockFreeVector每次都会成倍的扩容。它的第1个数组长度为8,第2个就是16,第3个就是32,依此类推。它们的二进制表示如下。

00000000 00000000 00000000 00001000:第一个数组大小,28个前导零。

00000000 00000000 00000000 00010000:第二个数组大小,27个前导零。

00000000 00000000 00000000 00100000:第三个数组大小,26个前导零。

00000000 00000000 00000000 01000000:第四个数组大小,25个前导零。

它们之和就是整个LockFreeVector的总大小,因此,如果每一个数组都恰好填满,那么总大小应该类似如下的数值(以4个数组填满为例)。

00000000 00000000 00000000 01111000:4个数组都恰好填满时的大小。

导致这个数字进位的最小条件,就是加上二进制的1000。而这个数字正好是8(FIRST_BUCKET_SIZE就是8)。这就是第8行代码的意义。它可以使得数组大小发生一次二进制的进位(如果不进位说明还在第一个数组中),进位后前导零的数量就会发生变化。而元素所在的数组,和pos(第8行定义的变量)的前导零直接相关。每进行一次数组扩容,它的前导零就会减1。如果从来没有扩容过,它的前导零就是28个。以后,逐级减1。这就是第9行获得pos前导零的原因。第10行,通过pos的前导零可以立即定位使用哪个数组(也就是得到了bucketInd的值)。

第11行,判断这个数组是否存在。如果不存在,则创建这个数组,大小为前一个数组的两倍,并把它设置到buckets中。

接着再看一下元素没有恰好填满的情况。

00000000 00000000 00000000 00001000:第一个数组大小,28个前导零。

00000000 00000000 00000000 00010000:第二个数组大小,27个前导零。

00000000 00000000 00000000 00100000:第三个数组大小,26个前导零。

00000000 00000000 00000000 00000001:第四个数组大小,只有一个元素。

那么总大小如下。

00000000 00000000 00000000 00111001:元素总个数。

总个数加上二进制1000后,得到:

00000000 00000000 00000000 01000001

显然,通过前导零可以定位到第4个数组。而剩余位,显然就表示元素在当前数组内的偏移量(也就是数组下标)。根据这个理论,我们就可以通过pos计算这个元素应该放在给定数组的哪个位置。通过第19行代码,获得pos的除了第一位数字1以外的其他位的数值。因此,pos的前导零可以表示元素所在的数组,而pos的后面几位,则表示元素所在这个数组中的位置。由此,第19行代码就取得了元素的所在位置idx。

到此,我们就已经得到新元素位置的全部信息,剩下的就是将这些信息传递给Descriptor让它在给定的位置把元素e安置上去即可。这里,就通过CAS操作,保证写入正确性。

下面来看一下get()操作的实现:

1 @Override 2 public E get(int index) { 3 int pos = index + FIRST_BUCKET_SIZE; 4 int zeroNumPos = Integer.numberOfLeadingZeros(pos); 5 int bucketInd = zeroNumFirst - zeroNumPos; 6 int idx = (0x80000000>>>zeroNumPos) ^ pos; 7 return buckets.get(bucketInd).get(idx); 8 }

在get()的实现中,第3~6行使用了相同的算法获得所需元素的数组以及数组中的索引下标。这里简单地通过buckets定位到对应的元素即可。

这样,对于Vector来说两个重要的方法就已经实现了。其他方法也是非常类似的,这里就不再详细讨论了。

4.4.9 让线程之间互相帮助:细看SynchronousQueue的实现

在对线程池的介绍中,提到了一个非常特殊的等待队列SynchronousQueue。SynchronousQueue的容量为0,任何一个对SynchronousQueue的写需要等待一个对SynchronousQueue的读,反之亦然。因此,SynchronousQueue与其说是一个队列,不如说是一个数据交换通道。那SynchronousQueue的奇妙功能是如何实现的呢?

既然我打算在这一节中介绍它,那么SynchronousQueue就和无锁的操作脱离不了关系。实际上SynchronousQueue内部也正是大量使用了无锁工具。

对SynchronousQueue来说,它将put()和take()两个功能截然不同的操作抽象为一个共通的方法Transferer.transfer()。从字面上看,这就是数据传递的意思。它的完整签名如下:

Object transfer(Object e, boolean timed, long nanos)

当参数e为非空时,表示当前操作传递给一个消费者,如果为空,则表示当前操作需要请求一个数据。timed参数决定是否存在timeout时间,nanos决定了timeout的时长。如果返回值非空,则表示数据已经接受或者正常提供,如果为空,则表示失败(超时或者中断)。

SynchronousQueue内部会维护一个线程等待队列。等待队列中会保存等待线程以及相关数据的信息。比如,生产者将数据放入SynchronousQueue时,如果没有消费者接收,那么数据本身和线程对象都会打包在队列中等待(因为SynchronousQueue容积为0,没有数据可以正常放入)。

Transferer.transfer()函数的实现是SynchronousQueue的核心,它大体上分为三个步骤:

如果等待队列为空,或者队列中节点的类型和本次操作是一致的,那么将当前操作压入队列等待。比如,等待队列中是读线程等待,本次操作也是读,因此这两个读都需要等待。进入等待队列的线程可能会被挂起,它们会等待一个“匹配”操作。

如果等待队列中的元素和本次操作是互补的(比如等待操作是读,而本次操作是写),那么就插入一个“完成”状态的节点,并且让他“匹配”到一个等待节点上。接着弹出这两个节点,并且使得对应的两个线程继续执行。

如果线程发现等待队列的节点就是“完成”节点,那么帮助这个节点完成任务。其流程和步骤2是一致的。

步骤1的实现如下(代码参考JDK 7u60):

01 SNode h = head; 02 if (h == null || h.mode == mode) { // 如果队列为空,或者模式相同 03 if (timed && nanos <= 0) { // 不进行等待 04 if (h != null && h.isCancelled()) 05 casHead(h, h.next); // 处理取消行为 06 else 07 return null; 08 } else if (casHead(h, s = snode(s, e, h, mode))) { 09 SNode m = awaitFulfill(s, timed, nanos); //等待,直到有匹配操作出现 10 if (m == s) { // 等待被取消 11 clean(s); 12 return null; 13 } 14 if ((h = head) != null && h.next == s) 15 casHead(h, s.next); // 帮助s的 fulfiller 16 return (mode == REQUEST) ? m.item : s.item; 17 } 18 }

上述代码中,第1行SNode表示等待队列中的节点。内部封装了当前线程、next节点、匹配节点、数据内容等信息。第2行,判断当前等待队列为空,或者队列中元素的模式与本次操作相同(比如,都是读操作,那么都必须要等待)。第8行,生成一个新的节点并置于队列头部,这个节点就代表当前线程。如果入队成功,则执行第9行awaitFulfill()函数。该函数会进行自旋等待,并最终挂起当前线程。直到一个与之对应的操作产生,将其唤醒。线程被唤醒后(表示已经读取到数据或者自己产生的数据已经被别的线程读取),在第14~15行尝试帮助对应的线程完成两个头部节点的出队操作(这仅仅是友情帮助)。并在最后,返回读取或者写入的数据(第16行)。

步骤2的实现如下:

01 } else if (!isFulfilling(h.mode)) { //是否处于fulfill状态 02 if (h.isCancelled()) // 如果以前取消了 03 casHead(h, h.next); // 弹出并重试 04 else if (casHead(h, s=snode(s, e, h, FULFILLING|mode))) { 05 for (;;) { // 一直循环直到匹配(match)或者没有等待者了 06 SNode m = s.next; // m 是 s的匹配者(match) 07 if (m == null) { // 已经没有等待者了 08 casHead(s, null); // 弹出fulfill节点 09 s = null; // 下一次使用新的节点 10 break; // 重新开始主循环 11 } 12 SNode mn = m.next; 13 if (m.tryMatch(s)) { 14 casHead(s, mn); // 弹出s 和 m 15 return (mode == REQUEST) ? m.item : s.item; 16 } else // match 失败 17 s.casNext(m, mn); // 帮助删除节点 18 } 19 } 20 }

上述代码中,首先判断头部节点是否处于fulfill模式。如果是,则需要进入步骤3。否则,将视自己为对应的fulfill线程。第4行,生成一个SNode元素,设置为fulfill模式并将其压入队列头部。接着,设置m(原始的队列头部)为s的匹配节点(第13行),这个tryMatch()操作将会激活一个等待线程,并将m传递给那个线程。如果设置成功,则表示数据投递完成,将s和m两个节点弹出即可(第14行)。如果tryMatch()失败,则表示已经有其他线程帮我完成了操作,那么简单得删除m节点即可(第17行),因为这个节点的数据已经被投递,不需要再次处理,然后,再次跳转到第5行的循环体,进行下一个等待线程的匹配和数据投递,直到队列中没有等待线程为止。

步骤3的实现(如果线程在执行时,发现头部元素恰好是fulfill模式,它就会帮助这个fulfill节点尽快被执行):

} else { // 帮助一个 fulfiller SNode m = h.next; // m 是 h的 match if (m == null) // 没有等待者 casHead(h, null); // 弹出fulfill节点 else { SNode mn = m.next; if (m.tryMatch(h)) // 尝试 match casHead(h, mn); // 弹出 h 和 m else // match失败 h.casNext(m, mn); // 帮助删除节点 } }

上述代码的执行原理和步骤2是完全一致的。唯一的不同是步骤3不会返回,因为步骤3所进行的工作是帮助其他线程尽快投递它们的数据,而自己并没有完成对应的操作。因此,线程进入步骤3后,再次进入大循环体(代码中没有给出),从步骤1开始重新判断条件和投递数据。

从整个数据投递的过程中可以看到,在SynchronousQueue中,参与工作的所有线程不仅仅是竞争资源的关系。更重要的是,它们彼此之间还会互相帮助。在一个线程内部,可能会帮助其他线程完成它们的工作。这种模式可以更大程度上减少饥饿的可能,提高系统整体的并行度。

4.5 有关死锁的问题

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

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

用来描述死锁问题的一个有名的场景是“哲学家就餐”问题。哲学家就餐问题可以这样表述,假设有五位哲学家围坐在一张圆形餐桌旁,做以下两件事情之一:吃饭,或者思考。吃东西的时候,他们就停止思考,思考的时候也停止吃东西。餐桌中间有一大碗意大利面,每两个哲学家之间有一只餐叉。因为用一只餐叉很难吃到意大利面,所以假设哲学家必须用两只餐叉吃东西。他们只能使用自己左右手边的那两只餐叉。哲学家就餐问题有时也用米饭和筷子而不是意大利面和餐叉来描述,因为很明显,吃米饭必须用两根筷子。

哲学家从来不交谈,这就很危险,可能产生死锁,每个哲学家都拿着左手的餐叉,永远都在等右边的餐叉(或者相反)。如图4.3所示,显示了这种情况。

图4-3 哲学家就餐问题

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

下面让我们用一个简单的例子来模拟这个过程:

01 public class DeadLock extends Thread { 02 protected Object tool; 03 static Object fork1 = new Object(); 04 static Object fork2 = new Object(); 05 06 public DeadLock(Object obj) { 07 this.tool = obj; 08 if (tool == fork1) { 09 this.setName("哲学家A"); 10 } 11 if (tool == fork2) { 12 this.setName("哲学家B"); 13 } 14 } 15 16 @Override 17 public void run() { 18 if (tool == fork1) { 19 synchronized (fork1) { 20 try { 21 Thread.sleep(500); 22 } catch (Exception e) { 23 e.printStackTrace(); 24 } 25 synchronized (fork2) { 26 System.out.println("哲学家A开始吃饭了"); 27 } 28 } 29 30 } 31 if (tool == fork2) { 32 synchronized (fork2) { 33 try { 34 Thread.sleep(500); 35 } catch (Exception e) { 36 e.printStackTrace(); 37 } 38 synchronized (fork1) { 39 System.out.println("哲学家B开始吃饭了"); 40 } 41 } 42 43 } 44 } 45 46 public static void main(String[] args) throws InterruptedException { 47 DeadLock 哲学家A = new DeadLock(fork1); 48 DeadLock 哲学家B = new DeadLock(fork2); 49 哲学家A.start(); 50 哲学家B.start(); 51 Thread.sleep(1000); 52 } 53 }

上述代码模拟了两个哲学家互相等待对方的叉子。哲学家A先占用叉子1,哲学家B占用叉子2,接着他们就相互等待,都没有办法同时获得两个叉子用餐。

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

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

C:\Users\Administrator>jps 8404 944 3992 DeadLock 3260 Jps //使用jstack查看进程内所有的线程堆栈 C:\Users\Administrator>jstack 3992 //省略部分输出,只列出当前与死锁有关的线程 "哲学家B" #9 prio=5 os_prio=0 tid=0x01ccf400 nid=0xb70 waiting for monitor entry [0x1597f000] java.lang.Thread.State: BLOCKED (on object monitor) at geym.conc.ch4.deadlock.DeadLock.run(DeadLock.java:42) - waiting to lock <0x046b3430> (a java.lang.Object) - locked <0x046b3438> (a java.lang.Object) "哲学家A" #8 prio=5 os_prio=0 tid=0x01ccec00 nid=0x1064 waiting for monitor entry [0x160ff000] java.lang.Thread.State: BLOCKED (on object monitor) at geym.conc.ch4.deadlock.DeadLock.run(DeadLock.java:29) - waiting to lock <0x046b3438> (a java.lang.Object) - locked <0x046b3430> (a java.lang.Object) //自动找到了一个死锁,确认死锁的存在 Found one Java-level deadlock: ============================= "哲学家B": waiting to lock monitor 0x15b5bd6c (object 0x046b3430, a java.lang.Object), which is held by "哲学家A" "哲学家A": waiting to lock monitor 0x01c1705c (object 0x046b3438, a java.lang.Object), which is held by "哲学 ?B" Java stack information for the threads listed above: =================================================== //哲学家A占用了0x046b3430,等待0x046b3438,哲学家B正好相反,因此产生死锁 "哲学家B": at geym.conc.ch4.deadlock.DeadLock.run(DeadLock.java:42) - waiting to lock <0x046b3430> (a java.lang.Object) - locked <0x046b3438> (a java.lang.Object) "哲学家A": at geym.conc.ch4.deadlock.DeadLock.run(DeadLock.java:29) - waiting to lock <0x046b3438> (a java.lang.Object) - locked <0x046b3430> (a java.lang.Object) Found 1 deadlock.

目录
设置
设置
阅读主题
字体风格
雅黑 宋体 楷书 卡通
字体大小
适中 偏大 超大
保存设置
恢复默认
手机
手机阅读
扫码获取链接,使用浏览器打开
书架同步,随时随地,手机阅读
首 页 < 上一章 章节列表 下一章 > 尾 页