mysticalycc的头像-学海无涯,回头是岸
管理员管理员超级版主
简短的个人签名

8个月前
作者:RednaxelaFX 链接:https://www.zhihu.com/question/35777031/answer/64575683 来源:知乎 太长不读(TL;DR)版定性分析: 对于解释器来说,解释器开销主要来自解释器循环(fetch-decode/dispatch-execute循环)中的fetch与decode/dispatch,反而真正用于执行程序逻辑的execute部分并不是大头。每条指令都要经历一轮FDX循环。因而减少指令条数可以导致F与D的开销减少,于是就提升了解释器速度。 基于栈与基于寄存器的指令集,用在解释器里,笼统说有以下对比: 从源码生成代码的难度:基于栈 < 基于寄存器,不过差别不是特别大 表示同样程序逻辑的代码大小(code size):基于栈 基于寄存器 简易实现中数据移动次数(data movement count):基于栈 > 基于寄存器;不过值得一提的是实现时通过栈顶缓存(top-of-stack caching)可以大幅降低基于栈的解释器的数据移动开销,可以让这部分开销跟基于寄存器的在同等水平。请参考另一个回答:寄存器分配问题? - RednaxelaFX 的回答 采用同等优化程度的解释器速度:基于栈 < 基于寄存器 交由同等优化程度的JIT编译器编译后生成的代码速度:基于栈 === 基于寄存器 因而,笼统说可以有以下结论:要追求 尽量实现简单:选择基于栈 传输代码的大小尽量小:选择基于栈 纯解释执行的解释器的速度:选择基于寄存器 带有JIT编译器的执行引擎的速度:随便,两者一样;对简易JIT编译器而言基于栈的指令集可能反而更便于生成更快的代码,而对比较优化的JIT编译器而言输入是基于栈还是基于寄存器都无所谓,经过parse之后就变得完全一样了。 =========================================== JVM的选择 JVM当初设计的时候非常重视代码传输和存储的开销,因为假定的应用场景是诸如手持设备(PDA)、机顶盒之类的嵌入式应用,所以要代码尽量小;外加基于栈的实现更简单(无论是在源码编译器的一侧还是在虚拟机的一侧),而且主要设计者James Gosling的个人经验上也对这种做法非常熟悉(例如他之前实现过PostScript的虚拟机,也是基于栈的指令集),所以就选择了基于栈。 回头看,这个决定也还算OK,可惜的是基于栈的设计并没有让Java的代码传输大小减小多少。 这是因为:Java代码是以Class文件为单位来传输与存储的。Java从设计之初就非要支持分离编译(separate compilation)与按需动态类加载(on-demand dynamic class loading),导致Java的Class文件必须独立的(self-contained)——每个Class文件必须自己携带自己的常量池,其主要信息是字符串与若干其它常量的值,以及用于符号链接的符号引用信息(symbolic reference)。 如果大家关注过Class文件的内容的话,会知道其实通常Class文件里表示程序逻辑的代码部分——“字节码”——只占Class文件大小的小头;而大头都被常量池占了。而且多个Class文件的常量池内容之间常常有重叠,所以当程序涉及多个Class文件时,就容易有冗余信息,不利于减少传输/存储代码的大小。 大家或许还记得Google在Google I/O 2008的 Dalvik VM Internals 演讲里,Dan得意的介绍到Dalvik的Dex格式在未压缩的情况下都比压缩了的JAR文件还小么? (下面数据引用自演示稿第22页) common system libraries (U) 21445320 — 100% (J) 10662048 — 50% (D) 10311972 — 48% web browser app (U) 470312 — 100% (J) 232065 — 49% (D) 209248 — 44% alarm clock app (U) 119200 — 100% (J) 61658 — 52% (D) 53020 — 44% (U) uncompressed jar file (J) compressed jar file (D) uncompressed dex file Dan准确的介绍了Dex体积更小的原因:一个Dex相当于一个或多个JAR包,里面可以包含多个Class文件对应的内容。一个Dex文件里的所有Class都共享同一个常量池,因而不会像Class文件那样在多个常量池之间有冗余。这样Dex文件就等同于在元数据层面上对JAR文件做了压缩,所以前者比后者更小。 但是很明显后来有不少群众不明就里,以为Dalvik的Dex文件更小是跟基于寄存器的选择相关的——其实正好相反,在字节码部分,Dalvik的字节码其实比JVM的字节码更大。只要仔细读了同一个演示稿就会看到 (第37页) The Register Machine 30% fewer instructions 35% fewer code units 35% *more* bytes in the instruction stream but we get to consume two at a time 而其实Java世界里早就发现了这个问题,并且有一个传输/存储格式跟Dex文件应用了类似的压缩思路: pack200 格式。它也是把JAR包里的所有Class文件的常量池汇总为一个,以此压缩掉其中的冗余。以pack200格式打包的Java应用就不会比用Dex格式打包大了。 之前在Oracle的HotSpot JVM组工作时,跟同事们讨论一些与此相关的话题,大家的共识是如果JVM的指令集是在20年后的今天设计的话,很有可能也会跟现在的潮流相似,基于寄存器。不过就算保持现在这样用基于栈的指令集也挺好。 目前Class文件/JAR文件的真正让大家头疼的问题并不是基于栈还是基于寄存器的设计,而是别的地方,例如: Class文件方面: 各种人为的大小限制都跟不上时代了,例如每个方法的字节码最多65535字节; 要生成StackMapTable太闹心; 常量池的组织方式不便于直接从文件映射到内存然后高效执行;可以有更高效的组织方式。 JAR文件方面: 如前文提的,多个Class文件之间的常量池冗余; 缺少带有强语义的描述模块的信息; 等等… 一切都有待未来版本的Java继续改进。 =========================================== Lua的选择 官方版Lua的设计可以从经典文章 The Implementation of Lua 5.0 一窥究竟。 它提到: For ten years (since 1993, when Lua was first released), Lua used a stack-based virtual machine, in various incarnations. Since 2003, with the release of Lua 5.0, Lua uses a register-based virtual machine. 也就是说Lua 5.0之前的Lua其实是用基于栈的指令集,到5.0才改为用基于寄存器的。 接下来: Register-based code avoids several “push” and “pop” instructions that stack-based code needs to move values around the stack. Those instructions are particularly expensive in Lua, because they involve the copy of a tagged value, as discussed in Section 3. So, the register architecture both avoids excessive copying of values and reduces the total number of instructions per function. Davis et al. [6] argue in defense of register-based virtual machines and provide hard data on the improvement of Java bytecode. Some authors also defend register-based virtual machines based on their suitability for on-the-fly compilation (see [24], for instance). 所以Lua 5.0开始改为选用基于寄存器的指令集设计,主要是出于 (1) 减少数据移动次数,降低由数据移动带来的拷贝开销,和 (2) 减少虚拟指令条数 这两点考虑。 从纯解释器的角度看,这两点考虑是非常合理的。 不过如果官方版Lua有JIT编译器的话,它就没必要这么做了——基于栈和基于寄存器的指令集只要经过合理的编译,得到的结果会是一模一样的。 至于LuaJIT, LuaJIT 1.x的字节码设计源于Lua 5.1.x系列,因而也是基于寄存器的; LuaJIT 2.x系列的字节码虽然重新设计了,但应该还是受到之前设计的影响而继续采用了基于寄存器的设计。像Mike Pall那种想榨干一切性能的思路,即便有优化的JIT编译器,也还是想让解释器尽量快的心情也是可以理解的。
8个月前
volatile:从最终[汇编语言]从面来看,volatile使得每次将i进行了修改之后,增加了一个内存屏障lock addl $0x0,(%rsp)保证修改的值必须刷新到[主内存]才能进行[内存屏障]后续的指令操作。但是内存屏障之前的指令并不是原子的 代码例子 [代码] 指令“lock; addl $0,0(%%esp)”表示加锁,把0加到栈顶的[内存单元],该指令操作本身无意义,但这些指令起到内存屏障的作用,让前面的指令执行完成。具有XMM2特征的CPU已有内存屏障指令,就直接使用该指令 volatile方式的[i++],总共是四个步骤: i++实际为load、Increment、store、Memory Barriers 四个操作。 内存屏障是[线程安全]的,但是内存屏障之前的指令并不是.在某一时刻线程1将i的值load取出来,放置到[cpu缓存]中,然后再将此值放置到[寄存器]()A中,然后A中的值自增1(寄存器A中保存的是[中间值],没有直接修改i,因此其他线程并不会获取到这个自增1的值)。如果在此时[线程2]也执行同样的操作,获取值i==10,自增1变为11,然后马上刷入主内存。此时由于线程2修改了i的值,实时的线程1中的i==10的值缓存失效,重新从主内存中读取,变为11。接下来[线程1]恢复。将自增过后的A[寄存器值]11赋值给cpu缓存i。这样就出现了线程安全问题。 [克里斯泡] 既然他从主存中读取了最新的11那什么不会把这个值放到寄存器中加一呢,而是把之前的值直接写会主存? [千帆] 这个涉及到MESI缓存一致性协议,为了保证不同缓存的一致性,一旦某个线程执行了修改(Modify)操作,会立刻使其他层级的缓存失效(invaild),然后立刻从主存中读取最新值~协议就是这样规定的 --- [明明] 寄存器里面的11已经是用缓存中的10自加后的结果了,然后才是缓存中的10失效,重新从内存读取11,这个重新读取的11则会进行下一次自加。不知道我这样的理解是否正确。 --- [明明] 不会自加的,自加这个操作已经完成了,只是cpu缓存的值会更新 --- [明明] 我知道你的疑问在哪里。作者的意思是,一个线程会有3个操作内存:1主存2缓存3寄存器 只有从缓存读到寄存器这一步才会检查10是否无效。一但读入寄存器,就不会进行检查了,即时已经被无效。 --- 突然就懂了! --- [千帆] 你好,我问下,这里说的会立刻使其他层级的缓存失效(invaild),然后立刻从主存中读取最新值,然后会再进行+1计算,写入主存吗,还是只是读取内存的值,不再进行计算了呢 --- [田林轩] 立刻从主存中读取最新值不再进行计算了! --- [黄花小伙子] cpu执行编译后的代码/指令也是自上而下执行有个固定顺序,线程1进行自增完成后发现别的线程已经修改且刷新了值,线程1这时候只能从主存中获取最新值,然后继续后面的回写操作,不可能获取最新值后把这段自增的代码再跑一边的,因为这样从程序逻辑上会有更大问题,比如100个线程一起自增,线程1一直很倒霉,它每次自增完后都发现其他线程刚更新了数据,它就只能重新获取最新数据然后重新跑,在其他线程停止自增之前它永远都结束不了运行 --- [田林轩] 寄存器的值应该也会更新的,不过不影响,如果i已经被一个线程读到栈顶的话(栈是线程私有的),i在寄存器中的值发生变化也无所谓,因为自增操作是在栈上执行完然后再写回寄存器到缓存到内存。栈上的操作不会被改变。
1年前