从一行注释看:为什么要注重对底层知识的理解
自从厌倦于CRUD,
我常在寻找新的方向。
每当被问起项目,
我便发觉自己总是增删查改,无一可讲。
我所追寻的程序员的技术,究竟在何方?苦觅二十载(别问哪来的二十,问就是加班),所得之物,其一,即今所欲论者:对底层知识的理解。
底层知识重要?何以论之?先上代码:
妇孺皆知,变量名应该阐述其含义,图方便而随意使用 a, b, c 这种变量名,可读性极差,容易出错。而上面这段代码,恰巧是用 a 和 c 作为变量名。遂拍案而起:此垃圾代码,谁写之?
打,打扰了.....
其实变量名并不是越长越好,而是在阐述清晰的情况下尽可能简单,有时简单的变量名反而会有更好的可读性。曾经就看过一个认为变量名越长、越详细就越好的朋友,他将一个查询方法命名为 getUserInfomationAccordingToUserId,相比之下,getUserById 这个更加简洁的方法名可能反而更合适,阐明了作用,又更加简洁。
曾几何时看过几点关于命名的建议:
- 全局变量尽量意义明确,使用全名而不是缩写,而局部变量可以适当使用缩写。因为全局变量(或类成员变量)的生命周期比较长,通常伴随着整个程序(或类)的生命周期,可以被来自当前类、其他类,甚至别人负责模块的代码引用,这个范围内存在大量的变量名,若存在模糊的变量或方法名,很容易引起误解,严重影响代码可读性。而局部变量相对来说生命周期很短(一个方法或代码块),阅读者需要同时记住的变量相对少很多,缩写对可读性影响也较小。另外有些约定俗成的、或者本就没有什么业务含义的场景,简单的命名减小了直观的代码量,反而便于阅读,比如循环中常用的i、j、k,上面的数组 a 和集合 c 等……
- 使变量的生命周期尽可能最短。如果一个变量是局部变量,那么它在方法快结束后,我们就不再需要记住它,而如果它是全局变量,我们就需要一直记住它,因为指不定什么时候就又会被使用到,增加了阅读代码的负担。全局变量可以被各处引用也表明它指不定什么时候就被(其他地方)修改了,既线程不安全,也影响可读性。
- 使得变量的定义尽量靠近第一次使用它的地方。早早地定义了变量,而又没有马上使用,这样就是使得它的生命周期变长了,不符合第 2 点建议。而定义到第一次使用之间的代码也可能误用了这个变量,导致真正要使用的时候已经不是期望的初始值了,从而发生不合预期的错误。
所以上面的代码因为变量都是局部变量,适当地使用了简洁的变量名,有理有据,绝不是我畏惧权威……
然而,再看看这段从HashMap 中截取的源码:
这里定义了一个局部变量 tab ,给它赋上 table ,而 table 就是 HashMap 用来保存元素(实现哈希表)的数组,之后就使用这个 tab 而不是 table 来进行操作。但 tab 和 table 对变量含义的描述清晰程度基本相似,而长度上前者不过比后者少了两个字母而已, table 这个变量名其实已经足够简洁了,如此再特意定义了一个局部变量 Node tab 不是多此一举吗?仅仅为了之后的使用能少写两个字母?
曾经的我只能用这个解释来勉强说服自己,直到那个下午……可怕的黑影划破长空,浓浓的黑雾笼罩着整个世界。然而即使是这样绝望的现实面前,永不言弃的人们依然决定要奋战到最后一刻。就连一旁打了一下午游戏的舍友,也不知道是喝到了什么鸡汤,突然痛改前非、大彻大悟,觉醒了他作为当代大学生本该有的使命感,下定决定要学习十分钟。他转头找我时,我正和邪神加坦杰厄打得不可开交,正此胶着之际,我本该目不斜视。但转念想到救人一命,胜造七级浮屠,我按下了空格键暂停。
好家伙,这货正在看 java.lang.String 的源码,让我解释一段代码的含义:
在茫茫代码中,我一眼看到了那行注释:
/* avoid getfield opcode */
一瞬间,色彩绚烂的满屏代码在我眼前暗淡无光,我读懂了她,读懂了她那简短的话语中温存的万水千山……
首先简单回顾一下 JVM 的内存模型:
JVM 内存模型中的 Java 虚拟机栈,其元素称为栈帧,每个栈帧都对应一个正在执行的 Java 方法,即每次调用一个 Java 方法,JVM 都会生成与之对应的栈帧并压入栈顶,而一个 Java 方法返回时则会将对应的栈帧弹出,因此栈顶的栈帧总是对应着正在执行的 Java 方法。
栈帧包含操作数栈和局部变量表,其作用都是为方法的执行提供临时的数据存储。操作数栈,顾名思义,就是存储计算过程中的操作数的“栈”,例如计算 1 + 2 时,需要先将 1 和 2 推入栈顶,然后通过加法指令计算两者的和并将结果也压入栈顶;局部变量表同样顾名思义,是存储方法中所定义的局部变量的“表”。
Java 之所以跨平台,是因为它编译后生成的 class 文件是平台无关的,而 class 文件中的指令称为字节码,它们指示了虚拟机要进行的操作(比如把某个数放入操作数栈)。而 getfield 和 load 就是其中的一个和一种指令。getfield 用于获取一个对象的成员变量,把成员变量的值(或者引用)放入操作数栈顶,load(系列)则是将局部变量表的一个元素放入操作数栈。
我们通过代码示例来看看它们的具体区别:
getfield 指令(参考):
public class GetField implements Instruction { private int operand; @Override public void readOperands(BytecodeReader reader) throws IOException { operand = reader.readUint16(); } @Override public void execute(Frame frame) { ClassInfo currentClass = frame.getMethod().getClassInfo(); RTConstantPool pool = currentClass.getConstantPool(); FieldRef fieldRef = pool.getFieldRef(operand); Field field = FieldResolver.resolve(fieldRef, false); if (field == null) { throw new NoSuchFieldError(fieldRef.toString()); } ObjectRef ref = frame.getOperandStack().popRef(); if (ref == null) { throw new NullPointerException(); } switch (TypeDesc.of(field.getDescriptor())) { case BOOLEAN : case BYTE : case CHAR : case SHORT : case INT : frame.getOperandStack().pushInt(ref.getFields().getInt(field.getSlotId())); break; case LONG : frame.getOperandStack().pushLong(ref.getFields().getLong(field.getSlotId())); break; case FLOAT : frame.getOperandStack().pushFloat(ref.getFields().getFloat(field.getSlotId())); break; case DOUBLE : frame.getOperandStack().pushDouble(ref.getFields().getDouble(field.getSlotId())); break; case REF : case ARRAY : frame.getOperandStack().pushRef(ref.getFields().getRef(field.getSlotId())); break; default: throw new RuntimeException("非法类型描述符:" + field.getDescriptor()); } } }
- 通过当前指令的操作数,从当前执行方法所属类的运行时常量池中,获取目标字段的符号引用
- 解析字段符号引用,获取直接引用(类似根据全类名、字段名等 String 类型数据通过反射的方式在类继承体系中查找对应的 Class 对象和 Field 对象)。如果字段所属的类还没有解析(即没有转换为直接引用),需要先解析(也就是常说的类加载过程中的"解析")。同样如果字段没有被解析过,也需要先解析字段符号引用为直接引用。一个符号引用的解析过程只执行一次。
- 将操作数栈最上面的对象引用弹出
- 根据字段的直接引用,从弹出的对象中获取目标字段值并压入操作数栈。
- 这个过程中包含一系列判断。例如拿到的对象引用是不是 null ?当前方法有没有权限访问该字段(public、protected 等)?
LOAD 系列(参考):
public static class ALOAD extends XLOAD { @Override public void execute(Frame frame) { frame.getOperandStack().pushRef(frame.getLocalVarsTable().getRef(operand)); } @Override protected int readOperand(BytecodeReader reader) throws IOException { return reader.readUint8(); } }
- 将局部变量表中一个元素压入栈(元素序号为常数或由当前字节码的操作数指定)
- 没了
其实 getfield 字节码需要用到一个栈顶的对象引用作为操作数栈,所以如果将要执行这条指令的时候,如果目标对象没在栈顶,需要先通过一个 aload 指令把对象压入栈顶。总的来说,getfield字节码所进行的操作比 load 系列要多很多,自然也就造成了速度慢很多。
回到之前 HashMap 的例子,每次直接用 table 这个成员变量的话,都要执行 getfield,而使用局部变量 tab,则生成的字节码是 aload,这样可以减少获取 table 数组时的需要执行的操作。更通俗地说,这是把堆上的数据(对象在堆上)缓存在栈(局部变量表)中。一般来说,即使是整个 getfield 指令,计算机也能很快执行,但是 table 变量在这个方法中多次被使用(而且是在循环里),在 HashMap 的很多其他方法也被使用,而 HashMap 又是 Java 中经常使用到的类,使得这小小的优化也是必要的。这就是那句注释“避免 getfield 字节码”的意义。
由于之前对 JVM 有过一些学习,所以看到这一行注释时,曾经对 HashMap 定义局部变量的疑惑瞬间解开了。理解底层知识的目的,除了应对面试,还有什么?我一直在寻找答案,而这就是我所找到的答案之一。它不能直接实现什么功能,但是当你习得这些知识,你看问题的视野会变宽(如果没了解过字节码,或许没办法从字节码的角度去理解这行注释),当你再去学东西、看文章的时候,也许会觉得某些以前不理解的东西,都变得理所应当,当时的疑惑不解,逐渐成了英雄所见。
其真wu码邪?其真不知码也。
其实报错的代码,不是不爱你,是你不够懂她。
你不懂她,她就会任性;你越懂她,她就越温柔。
舍友抓住我的手臂:你又看懂了什么,快给我讲讲。
我嘴角微扬:年轻人,你没有必要去问一个你根本不会理解的问题,你听懂了吗?你先看看《深入理解Java虚拟机》吧,当你明白了舍身取义的道理,自然也就懂了……
说罢拂衣去,深藏功与名。
#java求职##学习路径##Java##笔记#后记:
大三准备春招,整理自己简历时,发现能写的项目都是基于 SSM 的增删查改项目,实在没有什么亮点。学完 Springboot 后更是觉得自己做的事情毫无技术含量,十分茫然,也不知道接下来应该学什么。那时总听说应该学好底层知识,我又急需一个“有亮点”的项目来装饰简历,刚好在学校图书馆看到了《自己动手写 Java 虚拟机》,便在寒假花了半个多月断网日夜研究,按照书完成了一个 demo,还是有些收获,最后也大言不惭地把“实现简单 Java 虚拟机”、“熟悉 JVM class 文件等底层原理”写到了简历上,实际面试也确实在这个项目上问得比较多(我之前发的面经帖里的项目部分就是指这个)。但真正“醍醐灌顶”般体会到底层知识的好处,却是在这里写到的经历,以至于当时激动地记录了下来。工作一年多以后再来看自己当初写的 demo,想着找时间重写一下,把过程中的体会记录一下,算是温故知新。当然如果有感兴趣的人,或者也和当初的我一样正迷茫的人,如果能给这些人一些启发也不错。因此重写到一半时,把之前记录的这一特殊经历也重新整理了一下发出来,对 JVM 感兴趣的同学可以看看我公众号“米来的岁岁年”里的文章(不过还只写完一半),当然还是更建议直接读一读上面提到的那些书。最后,希望我的经历能给你带来一些正面的影响。