深入Java内存模型

标签: 技术架构 Java jmm | 发表时间:2014-04-07 16:00 | 作者:陈 晓舜
出处:http://www.importnew.com

你可以在网上找到一大堆资料让你了解JMM是什么东西,但大多在你看完后仍然会有很多疑问。happen-before是怎么工作的呢?用volatile会导致缓存的丢弃吗?为什么我们从一开始就需要内存模型?

通过这篇文章,读者可以学习到足以回答以上所有问题的知识。它包含两大部分:第一部分是硬件层次的大体架构,第二部分是深入OpenJdk源代码和实现。因此,即使你没有太深入Java,你可能也会对第一部分感兴趣。

硬件相关的东西

搞硬件的工程师一直在努力地优化他们产品的性能,使我们可以获取更多的代码外的高性能部件。然而,它带来的问题是:当你的代码在运行时,你并不能直观地查看它是运行在什么场景下。有着无数硬件细节被抽象化。而抽象往往意味着容易有 漏洞

处理器缓存

对主存的请求是一项昂贵的操作,即使是在现代机器上,在执行的时候也会花上百纳秒的时间。然后,其他操作的执行时间,不同于主存的访问,其发展就显缓慢。这个问题通常被称为 Memory Wall,而最明显的解决方案就是引入缓存。简单来说,处理器对它经常访问的主存数据保存一份拷贝。你可以在 这里深入阅读不同的缓存架构,我们将会继续另外一个问题:保持缓存最新。

很明显,当我们只有一个执行部件(从现在开始这里指处理器)时是没问题的,但当你拥有多于一个时,事情会变得复杂。

如果 A缓存了某些值,处理器 A怎么知道处理器 B已经修改了它们呢?

或者,更一般地说,你怎么保证 缓存一致性

为了保存内存状态的一致性,处理器需要进行交互。那种交互的规则称之为 缓存一致性协议(cache coherency protocol)

缓存一致性协议

现在有着很多不同的协议,不同的硬件厂商,甚至同一个厂商的不同产品线都会有所不同。尽管有着各种各样的区别,但大部分协议都有着很多共同点,这也是我们需要深入 MESI的原因。然而,它并没有给读者一个所有协议的完整概述。有一些协议(例如 基于目录的)是完全不一样的。我们不准备深入他们。

在MESI中,每一个缓存条目都会属于以下状态之一:

  • 无效(Invalid)缓存不再拥有该条目
  • 独占(Exclusive)这个条目只存在于这个缓存,没有被修改
  • 已修改(Modified)处理器已经修改过这个值,但还没有写回主存或者发送给其他处理器
  • 共享(Shared)多于一个处理器的缓存拥有该条目

状态之间的转换是通过发送特定的协议消息。具体的消息类型关系不大,所以在本文忽略了。你可以通过很多其他的资料去深入了解它们。我会推荐 Memory Barriers: a Hardware View for Software Hackers

讽刺的是:当我们深入时,消息被用于并发修改状态。这是个问题。那么那些讨厌Actor Model的人怎么办?

MESI优化和他们引入的问题

在还没有说到细节时,我们知道消息的传递是需要时间的,它使得状态切换有更多的延迟。重要的是我们也需要意识到某些状态的切换需要特殊的处理,可能会阻塞处理器。这些都将会导致各种各样的稳定性和性能问题。

存储缓存(Store Bufferes)

如果你需要对一个在缓存中的共享的变量进行写入,你需要发送一个失效( Invalidate)消息给它的所有持有者,并且等待它们的确认。处理器在这段时间间隔内会阻塞,这是一个不爽的事情,因为这个时间的要求比普通执行一个指令要长得多。

在现实生活中,缓存条目不只包含一个变量。这个被划分出来的单元是一个缓存链,通常包含多于一个变量,并且很多是64字节大小的。

它会导致有趣的问题,例如 缓存竞争

为了避免这种时间的浪费, Store Bufferes被引入使用。处理器把它想要写入到主存的值写到缓存,然后继续去处理其他事情。当所有 失效确认(Invalidate Acknowledge)都接收到时,数据才会最终被提交。

有人会想到这里有一些隐藏的危险存在。简单的一个就是处理器会尝试从存储缓存(Store buffer)中读取值,但它还没有进行提交。这个的解决方案称为 Store Forwarding,它使得加载的时候,如果存储缓存中存在,则进行返回。

第二个陷阱是:保存什么时候会完成,这个并没有任何保证。考虑一下下面的代码:

  void executedOnCpu0() {
    value = 10;
    finished = true;
}

void executedOnCpu1() {
    while(!finished);
    assert value == 10;
}

试想一下开始执行时, CPU 0保存着 finishedExclusive状态,而 value并没有保存在它的缓存中。(例如, Invalid)。在这种情况下, value会比 finished更迟地抛弃存储缓存。完全有可能 CPU 1读取 finished的值为 true,而 value的值不等于 10

这种在可识别的行为中发生的变化称为 重排序(reordings)。注意,这不意味着你的指令的位置被恶意(或者好意)地更改。

它只是意味着其他的CPU会读到跟程序中写入的顺序不一样的结果。

无效队列

执行失效也不是一个简单的操作,它需要处理器去处理。另外,存储缓存(Store Buffers)并不是无穷大的,所以处理器有时需要等待失效确认的返回。这两个操作都会使得性能大幅降低。为了应付这种情况,引入了失效队列。它们的约定如下:

  • 对于所有的收到的 Invalidate请求, Invalidate Acknowlege消息必须立刻发送
  • Invalidate并不真正执行,而是被放在一个特殊的队列中,在方便的时候才会去执行。
  • 处理器不会发送任何消息给所处理的缓存条目,直到它处理 Invalidate

也正是那些优化的情况会导致这种跟直觉不符的结果。让我们看回代码,假设 CPU 1存有 Exclusive状态的 value。这里有一张图表,表示其中一种可能的执行情况:

同步是很简单容易,不是吗?问题在于steps (4) – (6)。当 CPU 1在(4)接收到 Invalidate时,它只是把它进行排列,并没有执行。 CPU 1在(6)得到 Read Response,而对应的 Read在(2)之前就被发送。尽管这样,我们也没有使 value失效,所以造成了assertion的失败。如果那个操作早点执行就好了。但,唉,这该死的优化搞坏了所有事情!但从另一方面考虑,它给予了我们重要的性能优化。

那些硬件工程师没办法提前知道的是:什么时候优化是允许的,而什么时候并不允许。这也是他们为什么把这个问题留给我们。它同时也给予我们一些小东西,标志着:“单独使用它很危险!用这个!”

硬件内存模型

软件工程师在出发和巨龙搏斗时被授予的魔法剑并不是真正的剑。同样,那些搞硬件的家伙给我们的是写好的规则。他们描述着:当这个(或其他)处理器执行指令时,处理器能够看见什么值。我们能够像符咒一样把他们分类成Memory Barriers。对于我们的MESI例子,它描述如下:

Store Memory Barrier(a.k.a. ST, SMB, smp_wmb)是一条告诉处理器在执行这之后的指令之前,应用所有已经在存储缓存(store buffer)中的保存的指令。

Load Memory Barrier (a.k.a. LD, RMB, smp_rmb)是一条告诉处理器在执行任何的加载前,先应用所有已经在失效队列中的失效操作的指令。

因此,这两个方法可以防止我们之前遇到的两种情况。我们应该使用它:

  void executedOnCpu0() {
    value = 10;
    storeMemoryBarrier(); // Mighty Spell!
    finished = true;
}
void executedOnCpu1() {
    while(!finished);
    loadMemoryBarrier(); // I am a Wizard!
    assert value == 10;
}

哈!我们现在安全了。是时候来写一些高性能并且正常的并发代码了!

啊,等等。它甚至不能编译,显示找不到方法。真糟糕。

Write Once @ Run Anywhere

一次编写,处处运行

上面的那些缓存一致性协议,memory barriers,内存清除(dropped caches)和类似的东西看起来都是恶心的平台相关的东西。Java开发人员不应该关心这些东西。毕竟Java内存模型没有重排序的概念。

如果你没有完全理解上面的最后一段,你不应该继续往下阅读了。一个好的建议是先去学习一下JMM相关的知识。一个很好的入门教程应该是这篇 FAQ

但应该会有更抽象层次的重排序。你应该会有兴趣看看JMM是怎么映射到硬件模型的。让我们从一个简单的类开始:( github

有许多不同的场景供我们去了解究竟发生了什么: PrintAssembly很有趣,可以看到编译器正在做什么,而不用再去问别人, 疑惑地告诉你缓存被丢序了等等。我决定深入看看OpernJDK的C1(client编译器)。由于client编译器很少用在真实的应用中,作为教学用是一个好选择。

我使用的是jdk8,版本号为 933:4f8fa4724c14。在其他版本有可能会不一样。

如果你以前从来就没有深入过OpenJDK的源码(或者你有),你很难找你很感兴趣的地方在哪里。一个缩小查找范围的方法是取得你感兴趣的字节码指令,大概看一下它。好的,我们就这样做:

  $ javac TestSubject.java && javap -c TestSubject
void executedOnCpu0();
  Code:
     0: aload_0          // Push this to the stack
     1: bipush        10 // Push 10 to the stack
     3: putfield      #2 // Assign 10 to the second field(value) of this
     6: aload_0          // Push this to the stack
     7: iconst_1         // Push 1 to the stack
     8: putfield      #3 // Assign 1 to the third field(finished) of this
    11: return

void executedOnCpu1();
  Code:
     0: aload_0          // Push this to the stack
     1: getfield      #3 // Load the third field of this(finished) and push it to the stack
     4: ifne          10 // If the top of the stack is not zero, go to label 10
     7: goto          0  // One more iteration of the loop
    10: getstatic     #4 // Get the static system field $assertionsDisabled:Z
    13: ifne          33 // If the assertions are disabled, go to label 33(the end)
    16: aload_0          // Push this to the stack
    17: getfield      #2 // Load the second field of this(value) and push it to the stack
    20: bipush        10 // Push 10 to the stack
    22: if_icmpeq     33 // If the top two elements of the stack are equal, go to label 33(the end)
    25: new           #5 // Create a new java/lang/AssertionError
    28: dup              // Duplicate the top of the stack
    29: invokespecial #6 // Invoke the constructor (the  method)
    32: athrow           // Throw what we have at the top of the stack (an AssertionError)
    33: return

你不应该单单看了字节码就开始猜想你程序的执行(或者底层操作)。当JIT编译器编译它时,代码会跟现在看到的基本两样。

我们做这个的目的就是为了了解它们是为谁工作的。

这些有两个有趣的事情:

  1. 许多人都会忘记:断言在默认情况下是关闭的。用 -ea来启用他们。
  2. 我们查找的名字: getfieldputfield

深入剖析

我们看到,用于加载和保存 volatile和普通属性的指令是一样的。所以,一个好办法就是找到编译器是在哪里知道一个属性是否是 volatile。随便看了一下,我们的目光停留在 share/vm/ci/ciField.hpp文件。有趣的方法是

  bool is_volatile    () { return flags().is_volatile(); }

所以,我们现在的任务就是找到处理加载和保存属性的方法,并且通过结果分析调用上面这个方法的代码层次关系。Client编译器在 Low-Level Intermediate Representation(LIR)层上处理它们,代码在文件: share/vm/c1/c1_LIRGenerator.cpp

C1中间展示

我们由保存开始。我们深入的方法是 void LIRGenerator::do_StoreField(StoreField* x),它在 1658:1751行。我们看到的第一个显眼的操作是:

  if (is_volatile && os::is_MP()) {
  __ membar_release();
}

很好,一个memory barrier!两个下划线是一个宏,可以被展开为 gen()->lir()->,另外,被调用的方法定义在 share/vm/c1/c1_LIR.hpp:

  void membar_release()                          { append(new LIR_Op0(lir_membar_release)); }

所以,发生的事情就是我们对我们的展示添加多了一个操作 ir_membar_release

被调用的方法有着平台相关的实现。给x86( cpu/x86/vm/c1\_LIRGenerator\_x86.cpp)的很简单:对于64位的属性,我们尝试一些技巧性的方法来保证写入的原子性。因为 标准写着。这有点过时,但会在 Java9的时候更新。最后一个我们需要看的是在方法最后的又一个memory barrier。

  if (is_volatile && os::is_MP()) {
  __ membar();
}

void membar()                                  { append(new LIR_Op0(lir_membar)); }

这就是保存的处理。

加载在源代码稍微底层点,几乎没有包含任何新东西。它同样有一些技巧性的方法来处理 longdouble的原子性,在加载完成后会添加 lir_membar_acquire

注意,我故意省略一些跟这关联的东西,例如:GC相关的指令。

Memory Barrier类型和抽象层次(Abstraction Levels)

这个时候,你肯定会想 releaseacquire memory barriers是什么东西,因为我们到现在都还没介绍。这完全是因为我们所看到的 storeload memory barriers是在MESI模型中的操作,然后我们现在正在看的是在其上几层的抽层层次(或者任何其他的内存一致性协议)。在这一层,我们有不同的术语。

考虑到我们有两种类型的操作, LoadStore,我们拥有四种组合: LoadLoadLoadStoreStoreLoadStoreStore。因此也很方便的得到四种相同名称的memory barriers。

如果我们有一个 XY memory barrier,它表示所有的在barrier前的X操作必须比在barrier后的任意Y操作提前完成它们的操作。

例如,所有的在 StoreStore barrier前的 Store操作必须比barrier后的任意 Store操作早完成。 JSR-133是关于这个主题的一本好书。

有些人会疑惑,认为memory barriers接收一个变量作为参数,然后阻止跨进程间对该变量的重排序。

Memory barriers只能用在一个线程内。恰当地组合使用它们,你可以保证其他线程在加载这些值的时候看到一致的情况。一般地说,JMM的所有抽象都是由正确的组合memory barriers来实现的。

还有一些 AcquireRelease语义。一个拥有 release语义的 write操作要求所有在它之前的内存操作都必须在它执行前完成。而 read-acquire操作是相反的情况。

有人会发现 Release Memory Barrier可以通过 LoadStore|StoreStore的结合来实现,而 Acquire Memory BarrierLoadStore|LoadLoad。StoreLoad就是我们上面看到的lir_membar。

生成汇编代码

现在我们已经找到了IR和它的memory barriers,我们可以深入本地实现层了。所有的处理都在 share/vm/c1/c1_LIRAssembler.cpp文件内:

  case lir_membar_release:
  membar_release();
  break;

memory barriers是平台相关的,对于x86平台,我们看 cpu/x86/vm/c1_LIRAssembler_x86.cpp文件。我们发现x86在内存模型架构上相当严格,因此大部分的memory barriers都是没有处理的。

  void LIR_Assembler::membar_acquire() {
  // No x86 machines currently require load fences
  // __ load_fence();
}

void LIR_Assembler::membar_release() {
  // No x86 machines currently require store fences
  // __ store_fence();
}

然而这并不是所有:

  void LIR_Assembler::membar() {
   // QQQ sparc TSO uses this,
   __ membar( Assembler::Membar_mask_bits(Assembler::StoreLoad));
 }

(我们深入 cpu/x86/vm/assembler_x86.hpp)

  // Serializes memory and blows flags
void membar(Membar_mask_bits order_constraint) {
  if (os::is_MP()) {
    // We only have to handle StoreLoad
    if (order_constraint & StoreLoad) {
      // All usable chips support "locked" instructions which suffice
      // as barriers, and are much faster than the alternative of
      // using cpuid instruction. We use here a locked add [esp],0.
      // This is conveniently otherwise a no-op except for blowing
      // flags.
      // Any change to this code may need to revisit other places in
      // the code where this idiom is used, in particular the
      // orderAccess code.
      lock();
      addl(Address(rsp, 0), 0);// Assert the lock# signal here
    }
  }
}

因此,对于每一个 volatile写入,我们必须使用代价较大的形式为 lock addl $0x0,(%rsp)StoreLoad barrier。它强制要求我们去执行所有的挂起的保存,并且有效地保证其他线程可以很快地看到最新的值。而对于volatile读取,我们没有使用其他的barriers。然而我们不能想着[volatile读取跟普通的读取是一样简单的](http://brooker.co.za/blog/2012/09/10/volatile.html)。

我们应该清楚虽然barriers没有生成汇编代码,但它仍然存在在IR中的。如果他们被可以修改代码的组件(这里指编译器)忽略,那将会是一个类似的bug。

完整性检查

虽然通过查看OpenJDK源代码来学习是很好的,所有真正的科学家都这样,并且测试他们的理论。我们还是不要搞特例,同样来学习吧。

Java并发很有趣

好消息是我们不需要再重造轮子,因为已经有 jcstress工具来长时间执行代码,并且把输出完全聚合起来。它同样帮我们做了很多没什么意义工作,包括那些我们根本没意识到我们必须要去做的。

与其同时, jcstress已经拥有了我们需要的充分的 测试

  static class State {
    int x;
    int y; // acq/rel var
}

@Override
public void actor1(State s, IntResult2 r) {
    s.x = 1;
    s.x = 2;
    s.y = 1;
    s.x = 3;
}

@Override
public void actor2(State s, IntResult2 r) {
    r.r1 = s.y;
    r.r2 = s.x;
}

我们有一个线程,用来执行保存,而另外一个执行读取,然后会输出相应的状态。框架已经帮我们聚合了需要的结果,这些结果满足一定的 规则。我们对由第二个线程得到的两个可能出现的结果感兴趣: [1,0][1,1]。在这两种情况中,它加载了y == 1,但看不到写入给x的值,或者加载了一个并不是y写入时的最新值。根据我们的理论,这种情况的出现原因在于没有volatile标识符。让我们看一下:

  $ java -jar tests-all/target/jcstress.jar -v -t ".*UnfencedAcquireReleaseTest.*"
...

Observed state  Occurrence      Expectation       Interpretation
----------------------------------------------------------------------------------------------------------------
 [0, 0]          32725135        ACCEPTABLE       Before observing releasing write to, any value is OK for $x.
 [0, 1]             15           ACCEPTABLE       Before observing releasing write to, any value is OK for $x.
 [0, 2]             36           ACCEPTABLE       Before observing releasing write to, any value is OK for $x.
 [0, 3]           10902          ACCEPTABLE       Before observing releasing write to, any value is OK for $x.
 [1, 0]           65960    ACCEPTABLE_INTERESTING Can read the default or old value for $x after $y is observed.
 [1, 3]          50929785        ACCEPTABLE       Can see a released value of $x if $y is observed.
 [1, 2]             7            ACCEPTABLE       Can see a released value of $x if $y is observed.

因此,83731840中有65960次 (≈ 0.07%),第二个线程得到了 y == 1 && x == 0,这也证明了重排序是确实有运行的。

PrintAssembly的乐趣

我们需要检查的第二件事情是:我们是否正确地预测生成的汇编代码。因此,我们添加了很多所需代码的调用,为了方便结果演示,取消了inlining,开启了断言(assertoins),并且跑在client模式下。

  $ java -client -ea -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:MaxInlineSize=0 TestSubject
...
  # {method} 'executedOnCpu0' '()V' in 'TestSubject'
...
  0x00007f6d1d07405c: movl   $0xa,0xc(%rsi)
  0x00007f6d1d074063: movb   $0x1,0x10(%rsi)
  0x00007f6d1d074067: lock addl $0x0,(%rsp)     ;*putfield finished
                                                ; - TestSubject::executedOnCpu0@8 (line 15)
...
  # {method} 'executedOnCpu1' '()V' in 'TestSubject'
...
  0x00007f6d1d061126: movzbl 0x10(%rbx),%r11d   ;*getfield finished
                                                ; - TestSubject::executedOnCpu1@1 (line 19)
  0x00007f6d1d06112b: test   %r11d,%r11d
...

啊,就跟预想的一样!是时候完成了。

让我来提醒你那些你现在应该可以回答的问题:

它是怎么实现的?

使用volatile会导致缓存被丢弃吗?

为什么我们一开始就需要内存模型?

你觉得你可以回答这些?欢迎留言!

P.S 这是我早前的 俄语博文的翻译,感谢 Aleksey Shipilёv

相关文章

相关 [java 内存 模型] 推荐:

深入Java内存模型

- - ImportNew
你可以在网上找到一大堆资料让你了解JMM是什么东西,但大多在你看完后仍然会有很多疑问. happen-before是怎么工作的呢. 用volatile会导致缓存的丢弃吗. 为什么我们从一开始就需要内存模型. 通过这篇文章,读者可以学习到足以回答以上所有问题的知识. 它包含两大部分:第一部分是硬件层次的大体架构,第二部分是深入OpenJdk源代码和实现.

Java 内存模型 JMM

- - 码蜂笔记
JMM,Java Memory Model,Java 内存模型. 什么是内存模型,要他何用. 假定一个线程为变量var赋值: var = 3;,内存模型要回答的问题是:在什么条件下,读取变量var的线程可以看到 3这个值. 如果缺少了同步,线程可能无法看到其他线程操作的结果. 导致这种情况的原因可以有:编译器生成指令的次序可以不同于源代码的“显然”版本,编译器还会把变量存储在寄存器而不是内存中;处理器可以乱序或并行执行指令;缓存会改变写入提交到主存得到变量的次序;存储在处理器本地缓存中的变量对其他处理器不可见 等等.

Java 多线程内存模型

- - ITeye博客
Java 多线程内存模型.       Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果. 在此之前,主流程序怨言(如C/C++等)直接使用物理硬件(或者说操作系统的内存模型),因此,会由于不同的平台上内存模型差异,导致程序在一套平台上并发完成正常,而在另一套平台上并发访问却经常出错,因此经常需要针对不同的平台来编写程序.

Java运行时的内存模型

- - CSDN博客编程语言推荐文章
每个线程单独的数据区(线程间不共享). 每个线程都有一片单独的内存区域,这里面包含:程序计数器(program counter register),JVM栈和本地方法栈(Native Method Stack). 当一个新的线程被创建的时候,这片内存就已经被分配出来了. 程序计数器:为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间的计数器互不影响,独立存储.

Java内存模型修订了!

- - 互联网 - ITeye博客
传统的Java内存模型涵盖了很多Java语言的语义保证. 在这篇文章中,我们将重点介绍其中的几个语义,以更深入地了解他们. 对于本文中描述的语义,我们还将尝试体会对现有Java内存模型更新的动机. 本文中与JMM未来更新相关的讨论,将被称为JMM9. 现有的Java内存模型,如JSR133(以下称为JMM-JSR133)中所定义的,为共享内存指定了一致性模型,并且有助于为开发者提供与JMM-JSR133表述一致的定义.

文章: 深入理解Java内存模型(五)——锁

- - InfoQ cn
锁的释放-获取建立的happens before 关系. 锁是java并发编程中最重要的同步机制. 锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息. 深度剖析WebKit渲染机制:Chromium项目Committer确认QCon北京2013. QCon北京自动化运维专题:腾讯海量SNS社区网站高效运维探索.

JAVA内存释放

- - Java - 编程语言 - ITeye博客
(问题一:什么叫垃圾回收机制. ) 垃圾回收是一种动态存储管理技术,它自动地释放不再被程序引用的对象,按照特定的垃圾收集算法来实现资源自动回收的功能. 当一个对象不再被引用的时候,内存回收它占领的空间,以便空间被后来的新对象使用,以免造成内存泄露. (问题二:java的垃圾回收有什么特点. ) JAVA语言不允许程序员直接控制内存空间的使用.

Java 堆内存(Heap)

- - ITeye博客
        堆(Heap)又被称为:优先队列(Priority Queue),是计算机科学中一类特殊的数据结构的统称. 堆通常是一个可以被看做一棵树的数组对象. 在队列中,调度程序反复提取队列中第一个作业并运行,因而实际情况中某些时间较短的任务将等待很长时间才能结束,或者某些不短小,但具有重要性的作业,同样应当具有优先权.

java内存泄漏

- - 编程语言 - ITeye博客
不论哪种语言的内存分配方式,都需要返回所分配内存的真实地址,也就是返回一个指针到内存块的首地址. Java中对象是采用new或者反射的方法创建的,这些对象的创建都是在堆(Heap)中分配的,所有对象的回收都是由Java虚拟机通过垃圾回收机制完成的. GC为了能够正确释放对象,会监控每个对象的运行状况,对他们的申请、引用、被引用、赋值等状况进行监控,Java会使用有向图的方法进行管理内存,实时监控对象是否可以达到,如果不可到达,则就将其回收,这样也可以消除引用循环的问题.

Java内存之"栈"与"堆"

- - ITeye博客
        昨天中午,发了一篇 equals和==区别的博文,晚上再看时有几位大牛指出了其中的一些错误,很感谢他们的留言,一句简简单单的留言给了我对这些错误知识点改正的机会. 或许这就是从事互联网行业所提倡的互帮互助的精神吧,因为有分享,有交流,互联网才会发展的如此迅猛. 大牛提的一个观点很好,好的东西可以拿出来分享,错的东西却可能带给别人错误的理解,这一点我确实得向看了我写了一些bug博客的人道个歉.