为什么不应该将每个Scala实例变量都设为惰性初始化的变量?
为什么不应该将每个Scala实例变量都设置为延迟初始化?这个问题的出现的原因是,延迟初始化可能会导致维护性和效率方面的问题。解决方法是在分布式处理和集中式处理之间进行合理的任务分配,以避免延迟初始化带来的问题。
首先,我们应该讨论的是lazy val(Scala的“常量”),而不是延迟变量(我不认为它们存在)。
延迟初始化的效率问题:非延迟初始化的好处是你可以控制初始化发生的位置。想象一下,一个fork-join类型的框架,在工作线程中生成一些对象,然后将它们交给中央处理。使用急切求值,初始化是在工作线程上完成的。而使用延迟求值,初始化是在主线程上完成的,有可能造成瓶颈。
延迟初始化的维护性问题:如果所有的值都是延迟初始化的,并且程序出错了,你得到的堆栈跟踪将在一个完全不同的上下文中,与实例的初始化无关,可能在另一个线程中。
此外,几乎肯定会有与语言实现相关的成本(我看到有人发表了一个例子),但我觉得自己不够胜任来讨论这些成本。
难道在工作线程中进行生成和处理,然后再将它们返回给主线程不是更合理吗?或者,如果需要对象交互,让工作线程将它们的对象返回给另一个将进行处理的工作线程,而不是主线程,这样主线程就不必担心管理大部分工作线程,只需管理中间管理线程。
这正是我所说的。更详细地说,许多算法在分布式处理和集中处理之间都有一个分布式处理元素和一个集中处理元素,否则就不会有阿姆达尔定律 :)。一个微不足道的例子是Mergesort的合并步骤。当然,这听起来很明显,但当你潜意识里只有一个解决问题的方法时,这是一个实际会掉进的陷阱。
为什么不应该将每个Scala实例变量都定义为延迟初始化的问题?
首先,如果延迟初始化的lazy val在初始化过程中出现错误(例如访问不存在的外部资源),则只有在第一次访问该val时才会注意到错误,而使用普通val时,会在对象构造时立即注意到错误。你还可以在lazy val中出现循环依赖,这将导致类根本无法工作(一个可怕的NullPointerException),但你只会在第一次访问其中一个相关的lazy val时才会发现这个问题。
因此,lazy val使程序变得不确定,这总是一件坏事。
其次,延迟初始化的lazy val会带来运行时开销。延迟初始化的lazy val当前是通过一个私有位掩码(int)在使用lazy val的类中实现的(每个lazy val一个位,所以如果你有超过32个lazy val,就会有两个位掩码等)。
为了确保lazy val初始化器仅运行一次,当字段初始化时会进行同步写入位掩码,并且每次访问字段时会进行volatile读取。现在,在x86体系结构上,volatile读取非常廉价,但是volatile写入可能非常昂贵。
据我所知,正在努力优化这个问题,以在未来的Scala版本中进行优化,但是与直接访问val相比,检查字段是否初始化的开销总是存在的。例如,lazy val访问的额外代码可能会阻止方法被内联。
当然,对于非常小的类来说,位掩码的内存开销也可能是相关的。
但即使你没有任何性能问题,弄清楚val相互依赖的顺序并根据这个顺序进行排序并使用普通的val也是很好的做法。
下面是一个代码示例,说明了如果使用lazy val可能会出现的不确定性:
class Test {
lazy val x:Int = y
lazy val y:Int = x
}
你可以创建一个此类的实例而没有任何问题,但是一旦你访问x或y,就会得到StackOverflow错误。当然,这只是一个人为的例子。在现实世界中,你可能会遇到更长和更复杂的依赖循环。
下面是使用:javap命令在Scala控制台中进行的会话,用于说明延迟初始化的lazy val的运行时开销。首先是普通的val:
scala> class Test { val x = 0 }
defined class Test
scala> :javap -c Test
Compiled from "
public class Test extends java.lang.Object implements scala.ScalaObject{
public int x();
Code:
0: aload_0
1: getfield #11; //Field x:I
4: ireturn
public Test();
Code:
0: aload_0
1: invokespecial #17; //Method java/lang/Object."
4: aload_0
5: iconst_0
6: putfield #11; //Field x:I
9: return
}
现在是延迟初始化的lazy val:
scala> :javap -c Test
Compiled from "
public class Test extends java.lang.Object implements scala.ScalaObject{
public volatile int bitmap$0;
public int x();
Code:
0: aload_0
1: getfield #12; //Field bitmap$0:I
4: iconst_1
5: iand
6: iconst_0
7: if_icmpne 45
10: aload_0
11: dup
12: astore_1
13: monitorenter
14: aload_0
15: getfield #12; //Field bitmap$0:I
18: iconst_1
19: iand
20: iconst_0
21: if_icmpne 39
24: aload_0
25: iconst_0
26: putfield #14; //Field x:I
29: aload_0
30: aload_0
31: getfield #12; //Field bitmap$0:I
34: iconst_1
35: ior
36: putfield #12; //Field bitmap$0:I
39: getstatic #20; //Field scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit;
42: pop
43: aload_1
44: monitorexit
45: aload_0
46: getfield #14; //Field x:I
49: ireturn
50: aload_1
51: monitorexit
52: athrow
Exception table:
from to target type
14 45 50 any
public Test();
Code:
0: aload_0
1: invokespecial #26; //Method java/lang/Object."
4: return
}
正如你所看到的,普通val的访问器非常简短,并且肯定会被内联,而延迟初始化的lazy val的访问器相当复杂(对于并发性来说最重要的是涉及synchronized块的monitorenter/monitorexit指令)。你还可以看到编译器生成的额外字段。
在文章中,给出了将Scala实例变量定义为延迟初始化的lazy val的问题以及解决方法。这些原因包括:延迟初始化会使程序变得不确定,可能会导致循环依赖和NullPointerException;延迟初始化的lazy val会带来运行时开销,并且可能会影响性能和内存占用。
因此,建议根据val的相互依赖关系来排序它们,并使用普通的val来实现更可靠和高效的代码。