你好呀,我是歪歪。
前几天在网上冲浪的时候,看到知乎上的这个话题:

一瞬间,一次历史悠久但是记忆深刻的代码调试经历,“刷”的一下,就在我的脑海中蹦出来了。
虽然最终定位到的原因令人无语,对于日常编码也没啥帮助,但是真的是:

情景再现
我记得当时我是学习 ConcurrentLinkedQueue (下文用 CLQ 代替)的这个玩意,为了比较深入的掌握这个玩意,我肯定是要 Debug 跟踪一下源码的。
问题就出现在 Debug 的时候,现象非常诡异,听我细细道来。
首先,我当时的 Demo 极其简单,就这么两行代码:

new 一个 CLQ 对象,然后调用 offer 方法筛一个对象进去。
完事了。
这么简单的代码能搞出什么牛逼的玩意呢?
首先,我带你看看 CLQ 的数据结构。
CLQ 是由一个个 Node 组成的链式结构。

new CLQ 的时候通过 new Node() 构造出一个特殊的“dummy node”,翻译过来大家一般叫它“哑元节点”。
然后将头指针 head 和尾指针 tail 都指向这个哑元节点。
那这个 Node 长啥样呢?
Node 里面有一个 item(放的是存储的对象),还有一个 next 节点(指向的是当前 Node 的下一个节点):

从数据结构来看,也知道这是一个单向链表了。
当时为了学它,我想通过日志的方式直接输出链表结构,这应该是最简单的演示方式了。
毕竟 Java 程序员,就靠日志活着了。
所以我当时自定义了一个 WhyConcurrentLinkedQueue(下文简写为 WhyCLQ)。
这个 WhyCLQ 是怎么来的呢?
非常简单,我直接把 JDK 源码中的 CLQ 复制出来一份,改名为 WhyCLQ 就完事了。

然后搞个测试用例跑跑:

非常 nice,没有任何毛病。
我们现在可以任意的在代码中增加输出日志了。
比如,我想要看 WhyCLQ 这个链式结构到底是怎么样的。
我们可以在自定义的 CLQ 里面加一个打印链表结构的方法:
public void printWhyCLQ() {
StringBuilder sb = new StringBuilder();
for (Node<E> p = first(); p != null; p = succ(p)) {
E item = p.item;
sb.append(item).append("->");
}
System.out.println("链表item对象指向 =" + sb);
}
然后在每次 offer 方法新增完成后,调用一下 printWhyCLQ 方法,输出当前的链式结构:

其他的地方类似,只要你觉得源码看起来有点绕的地方,你就可以加输出语句,哪怕一行代码就配上一行输出语句也没问题。
甚至,你还能“客制化”源码,但是这不是本文的重点,我就不展开了。
通过复制源码的方式自定义一个 JDK 源码中的类,然后加上大量的输出语句,有时候也会对源码进行各种改装,是我常用的一个学习小技巧,分享给你,不用客气。
当你被一步步 debug 带晕的时候,你可以试一试这种方式,先整体再局部。
好,到这里就算是铺垫完成了。
我们回到最开始的这两行代码:

按照我们的理解,第一次 offer 之后,对应的链表画个简图应该是这样的:

但是最后的输出是这样的:

为什么输出的日志不是 null->@4629104a 呢?
因为我们自定义的 printWhyCLQ 这个方法里面会调用 first 方法,获取真正的头节点,即 item 不为 null 的节点:

也就是我框起来的地方:first 方法中的 updateHead(h, p) 方法,会去修改头结点。
然后,我还想在第一次 offer 的时候,详细的输出头结点的信息,所以加了这几行输出语句:

直接把程序跑起来,对应的效果是这样的:

但是,当我在这个分支入口,打上断点,用 debug 模式进行调试的时候:

运行结果是这样的:

空指针异常!!!???
为了让你有更加直观的感受,我给你上个动图。
首先,是直接把程序运行起来的动图:

这是 Debug 运行时的动图:

如果前面的文字你没看懂,不重要,你只需要记住下面这个现象:
同样的程序,当你直接运行,就能正常结束,当你用 Debug 模式运行的时候,就会抛出空指针异常。
来,如果是你遇到这个问题,你会怎么办?
当年我还是一个萌新菜鸟的时候,遇到这个问题,直接就懵逼了啊,百思不得其解,感觉编程的大厦正在摇摇欲坠。
这真的就很诡异啊!

当你直接运行程序,会拿到一个预期的结果。
但是试图通过 Debug 模式去观察这个程序的时候,这个程序就会抛出异常。
这很难不让人想起“量子力学”中的光的双缝干涉试验啊。
观测手段触发了光的粒子状态,所以没有干涉条纹。
如果不观测,光就是波的形态,出现了干涉条纹。
如果你不知道我在说什么,一点也不重要。
但是你知道我在说什么,你就知道,歪师傅这个程序的现象,用“量子力学”来形容是多么的贴切。
我甚至还怀疑过是质子,一定是质子在搞事情。

当时,我是怎么解决这个问题的呢?
没有解决。
当年经验浅薄,现象又太过诡异导致我不知道应该怎么去解决,而且最重要的是并没有影响我理解 CLQ 这个玩意。
是的,感谢我当时还记得主要目标是去学习 CLQ,而不是去研究这个诡异的现象。
偶遇真相
我忘了隔了多长时间,只记得是一个麦子黄了的季节,我在这个链接中偶遇到了真相:
https://stackoverflow.com/questions/55889152/why-my-object-has-been-changed-by-intellij-ideas-debugger-soundlessly

这个哥们遇到的问题和我一模一样,但是这个问题下面只有一个回答:

这个回答给出的解决方案
最后的解决方案就是关闭 IDEA 的这两个配置,他们默认是开启的:

当关闭这两个配置后,我的程序在 Debug 的时候也正常了。
为什么呢?
因为 IDEA 在 Debug 模式下会主动的帮我们调用一次 toString 方法。
而在 CLQ 的 toString 方法里面,会去调用 first 方法:

前面我说了:first 方法中的 updateHead(h, p) 方法,会去修改头结点。

之前我给的简图是这样的:

由于 Debug 会调用 toString 方法,从而触发了 first 方法,进而导致了头结点不是 null,而是这个 obj 了:

再到 this.head.next 这里获取头结点的 next 的时候,由于 next 并不存在,值为 null:

所以 this.head.next.item 抛出了空指针异常。
没有什么玄学,我们要相信科学。
但是,这个真相确实有点坑。

IDEA 图啥?
那么问题就来了。
为什么 IDEA 要在 Debug 的时候默认调用一下 toString 方法呢?
我用 HashMap 举例,给你上个对比图你就知道它想要干啥了。
这是默认配置的情况:

可以直观的看到 map 中 key 和 value 的情况
当我们取消前面说的配置:

再次 Debug 的时候,看到的就是这样的:

而且可以看到,toString 方法是可以点击的。
当你点击之后,就变成了这样:

这么一对比,就很直观了。
你说 IDEA 图啥?
还不就说图用户调试起来的时候,看起来更加直观嘛,确实是一片好心。
谁能想到你 toString 方法中还能藏着一些逻辑呢。
这波我站 IDEA。
又学到一个埋坑的小技巧
通过前面的介绍,我仿佛又掌握了一个埋坑的小技巧。
我给你演示一下。
首先我定义一个 why 的类:

这个类的 toSting 方法中有 age++ 这样的操作。
当你直接运行这个程序的时候,运行结果为 18:

但是,当你 Debug 的时候:

age 就变成 19 了。
而且是看一次,就涨一岁,这你受得了吗:

如果代码再复杂一点,找问题都让你焦头烂额了。
谁能想到 IDEA 在你 Debug 的时候帮你调用了 toString,谁又能想到 toString 方法中还有逻辑呢?
如果 toString 方法中的逻辑,和前面说的 CLQ 一样,会影响到你要寻找的答案...
这一套丝滑小连招下来,你就玩去吧。
一个埋坑的小技巧,没到血海深仇,不要轻易使用。
最后,你说上帝在编程的时候,会不会也是埋了这样的一个坑。
当我们直接运行“光”这个方法的时候,光就是波的形态。
但是当我们使用通过观察手段去 Debug “光”这个方法到底是怎么运行的时候,上帝他老人家就会在“光的 toStirng 方法”中主动调用一个让光变成粒子的逻辑。
所以,我们的任何观测手段都会触发这个“光的 toStirng 方法”,导致光的出现了粒子状态,在光的双缝干涉试验直接中,就没有出现干涉条纹。
从编程角度,看量子力学,有点意思。
