ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JVM跨平台与JIT即时编译:Java如何从字节码到机器码实现越跑越快

JVM跨平台与JIT即时编译:Java如何从字节码到机器码实现越跑越快 2. 从源码到机器码所谓跨平台其实是三层翻译的功劳面试Java岗位的时候十有八九会被问到这个问题JVM为什么能跨平台标准答案谁都能背——Java编译一次到处运行源码编译成字节码字节码在JVM上跑所以跨平台。但真被追问一句字节码到底是什么为什么换台机器还能跑不少人就卡壳了。我第一次面试时也是这样背得滚瓜烂熟结果面试官让我说说JVM和JRE的区别当场语塞。这个问题不是背个概念就能糊弄过去的尤其是后面还跟着一个更难的追问JIT凭什么越跑越快同样是JVM为什么一段代码第一次跑慢、跑久了反而变快这背后其实是JVM设计里最精彩的机制之一。这篇文章不堆理论就用几个具体的例子把JVM跨平台、JIT加速这两件事从头到尾拆开看。先看最基础的问题跨平台到底是怎么实现的。Java源码经过javac编译产出的不是Windows能直接执行的.exe也不是Linux能直接执行的ELF文件而是一种中间产物——.class字节码文件。这个文件里的内容不是给CPU读的而是给JVM读的。JVM本质上就是一台上电之后只认字节码的虚拟计算机它自己定义了一套指令集比如iload、iadd、ireturn这种助记符。只要某个平台上有JVM实现这台JVM能认得这些字节码指令Java程序就能在这个平台上跑起来。举个例子看一段最简单的代码public class AddDemo { public int add(int a, int b) { return a b; } }编译之后用javap -c查看字节码输出是这样public int add(int, int); Code: 0: iload_1 1: iload_2 2: iadd 3: ireturniload_1表示把第一个int类型参数压入栈iload_2把第二个参数压栈iadd把栈顶两个数相加并把结果压回栈ireturn把栈顶的值作为int类型返回值返回。这套指令和你的CPU是不是Intel还是AMD毫无关系也和操作系统是Windows还是Linux毫无关系。无论你在哪台机器上编译这段代码得到的.class文件里的字节码指令都长这样。那么真正干活的是谁是JVM。Windows版的JDK里带了一个Windows专属的JVM实现Linux版的JDK里带了一个Linux专属的JVM实现。这些JVM负责把iadd这种抽象指令最终翻译成当前平台CPU真正认识的原生机器指令。x86有x86的加法指令ARM有ARM的加法指令但JVM把这些细节全部兜住了。这里有个绝佳的类比字节码就像一部电影的剧本JVM就像各个国家的剧团。剧本内容全球统一但中国的剧团用中文演日本的剧团用日文演英国的剧团用英文演。观众听到的语言各不相同但故事结构完全一样。你作为导演只需要写好剧本各剧团自己负责本地化演绎。这就是编译一次到处运行的本质——不是Java代码能跨平台而是JVM这个中间层把平台差异扛了下来。不过这里要澄清一个常被混淆的概念JVM、JRE、JDK三者不是一回事。JVM是虚拟机本体是真正执行字节码的那个进程JRE是Java运行环境包含了JVM实现和Java核心类库你只需要运行别人写好的Java程序装JRE就够了JDK是Java开发工具包除了包含JRE还额外带了javac编译器、javap反汇编工具、jmap排查工具这些开发调试的组件。一句话总结JDK给开发者用JRE给运行者用JVM是这两者里头真正跑字节码的引擎。在C/C的世界里同样的代码在不同平台需要分别编译生成各自平台的机器码。Java用引入一个虚拟层的思路把平台差异集中到了JVM内部解决。这个设计的收益是巨大的——一份产物全平台通用代价也很明显——多了一层翻译所以早期Java被吐槽慢。2. 解释执行的性能账JIT到底在什么时候插手既然JVM是虚拟机最朴素的实现方式就是解释执行——一条一条读取字节码指令每读到一条就翻译成机器指令执行。这种方式的好处是启动快、不需要额外编译时间坏处也显而易见慢。慢在哪我打个比方。一个同声传译收到一句话先解析这句话的语法结构查单词再组织成目标语言说出来。如果每句话都这么干速度必然提不上来。而且更麻烦的是字节码里的很多指令是重复出现的比如循环里那个iadd可能被执行一千万次但解释器每次都把它当第一次见到来翻译没有任何记忆。Sun公司的工程师们当然也清楚这个痛点。1998年前后Sun在HotSpot虚拟机里做了一件改变Java命运的事——引入JITJust-In-Time即时编译编译器。思路很简单既然这套代码热那别一条条解释了直接把整段热代码一次性编译成机器码下次再执行到这就走编译好的快速通道。那JIT怎么判断哪段代码热答案是计数器。HotSpot虚拟机里的每个方法有两套计数器调用计数器和方法体内的回边计数器。调用计数器统计这个方法被调用过多少次回边计数器统计方法里的循环体执行过多少次。默认情况下方法被调用超过一定次数Client模式默认1500次Server模式默认10000次可以理解为频繁使用的阈值就会触发JIT编译。打个比方你读一本外文技术书。第一次读到某个难懂的专业术语你要停下来查词典、理解上下文这就是解释执行。如果这个术语在一本书里反复出现十几二十次你自然就不需要每次都查了扫一眼就知道意思后续阅读速度越来越快。JIT干的就是这件事——把反复出现的术语预编译成自己熟悉的表达方式后面直接跳过翻译过程。不过这里有个很容易误解的细节JIT编译不是把整个class文件一次性全部编译而是按方法为单位哪个方法被判定为热点才编译哪个方法。而且编译时机不是立刻而是进入编译队列后由后台编译线程异步处理。所以越跑越快不是一个突变而是代码运行过程中越来越多热点方法被替换成机器码版本整体执行效率逐步提升。到了JDK 8HotSpot默认开启了分层编译Tiered Compilation把编译分成了好几层第0层是解释执行第1到第3层是C1编译器即时编译响应快但优化相对保守第4层是C2编译器重度优化编译慢但生成的代码质量极高。一个方法从解释执行开始随着调用次数增加可能先升到C1层再进一步升到C2层每一层的执行效率都更高但为了编译付出的时间成本也更大。用一句话总结就是代码越热编译层次越高优化越激进跑得越快。你可以在命令行里看到这个机制的痕迹。执行java -version时HotSpot会打印出当前JVM的工作模式里面带mixed mode字样的就表示混合模式解释执行和JIT编译并存。如果你用-Xint参数强制纯解释模式Java会慢得让人怀疑人生反过来用-Xcomp强制全部编译启动时间又会大幅拉长而且有些方法只被调用一两次编译它们的成本远大于节省的执行时间。所以混合模式是工程上权衡之后的默认最优选择。3. 三个看得见的优化实例拼接、内联和逃逸分析讲原理容易空看例子才过瘾。下面是三个JIT真正在做的优化场景每个都能在你的程序里找到影子。3.1 字符串拼接从笨重到轻快很多Java性能优化的文章都会提到循环里别用拼接字符串要用StringBuilder。这个建议本身没错但很多人不知道为什么甚至有人以为用了StringBuilder就万事大吉。实际上JIT在运行期做的工作远比这个建议要精细。看这段代码public String makeMessage(String name, String level, String action) { return 用户[ name ]执行了[ action ]级别 level; }javac在编译期就会把这个拼接转换成StringBuilder的链式调用大致等价于new StringBuilder() .append(用户[) .append(name) .append(]执行了[) .append(action) .append(]级别) .append(level) .toString();这套转换在编译期就完成了所以字节码层面已经比较正规。但真正的优化在JIT。当这段代码成为热点后C2编译器会做逃逸分析——判断这个StringBuilder对象会不会逃逸出当前方法。如果它只是在一个方法内部被创建、使用、销毁没有任何途径被外部拿到那么C2会把它彻底拆散直接在寄存器或栈上完成所有append操作连对象都不用new更别说垃圾回收了。所以在执行层面一段非常热门的字符串拼接代码真正跑起来的效率可能远超你写下StringBuilder时预期的水平。这也是为什么我们说别在循环里用拼接大量字符串——编译期的StringBuilder优化只是第一步JIT的逃逸分析才是最后的加速器。但循环里每次都新建StringBuilder逃逸分析能不能兜住所有场景不好说老老实实写StringBuilder仍然是最稳妥的。3.2 方法内联把打电话变成直接对话我见过很多同学在代码里写很多小方法比如private int doubleValue(int x) { return x * 2; } public int compute(int[] values) { int sum 0; for (int i 0; i values.length; i) { sum doubleValue(values[i]); } return sum; }方法拆分是个好习惯它让代码清晰、可维护。但每次调用doubleValue都是一次方法调用底层要经历参数传递、栈帧新建、跳转、返回等一连串开销。循环一万次就是一万次打电话。JIT盯上这点很久了。当一个方法满足两个条件——够小字节码小于默认的MaxInlineSize通常是35字节且调用次数够多——C2编译器就会把方法的实现直接粘贴到调用处这个方法调用直接消失变成一段普通的算术计算。代码执行到那里时不再有调用的过程本质上是打通电话线直接喊话。对于compute这个例子JIT会把循环体内对doubleValue的调用替换成values[i] * 2。你写了更清晰的结构化代码JIT帮你把它编译成了更直接高效的机器指令两者兼得。3.3 锁消除原来锁也会被骗走还有一个非常体现JIT聪明程度的优化叫锁消除。前提还是逃逸分析如果判断出一个对象不会逃逸出当前线程那么对它的同步锁操作就没有实际意义——因为别的线程根本不可能访问到它。C2编译器会检测到这种情况然后直接把这把锁去掉。看这段代码public String process(ListString items) { ListString copy new ArrayList(items); synchronized (copy) { copy.replaceAll(String::toUpperCase); } return copy.toString(); }你在这个方法里对copy加了锁。但copy只在当前方法内创建和使用外界拿不到它的引用另一条线程根本不可能来竞争这把锁。JIT做逃逸分析后会发现这个锁是多余的然后把同步指令消除掉。你以为自己在做并发保护其实JIT正在背着你悄悄把锁删了。当然这个优化只适用于锁被证明不可能被共享的情况。一旦对象逃逸出当前线程比如被放进了HashMap、被返回给外部、被传给了其他线程锁消除就失效。所以写代码时别想着反正JIT会帮我优化锁随便加逃逸分析能否成立取决于你的代码是否真的让对象留在了一个安全范围内。这三个例子背后站的是同一个核心机制——运行期监控。解释执行阶段不是白跑的JVM通过零成本的性能采样收集了每个方法的调用频率、对象分配路径、分支走向等大量信息。C2编译器拿到这些真实数据才敢放心大胆地做优化。这也是JIT和传统的编译期优化最大的差异静态编译器只能靠猜测运行时JIT看到了真实世界。4. 跨平台从来不是免调优内存区域与JVM参数实战说完了跨平台和JIT的原理必须泼一盆冷水JVM解决了代码在哪里都能跑的问题但绝没解决跑得好的问题。自己写过Java服务的人都知道一台机器上没调优的JVM和精心调过的JVM性能差距可能是数量级的。而且因为跨平台JVM内存管理的一堆概念在不同操作系统上表现还有细微差异这里容易踩坑。先理清JVM内存区域的划分这是理解一切调优的基础。按线程归属可以分成两大类线程私有区和线程共享区。线程私有的有程序计数器、虚拟机栈、本地方法栈线程共享的有堆和方法区在JDK 8之后称为元空间即Metaspace。程序计数器记录当前线程执行到哪条字节码指令这个区域不会抛OutOfMemoryError虚拟机栈存放每个方法的栈帧方法调用越深、栈帧越大这个区域越紧张最常见的错误就是StackOverflowError堆是对象的家几乎所有Java对象都分配在堆上也是垃圾回收器的主战场一个不留神就OutOfMemoryError: Java heap space元空间存放类的元数据不再受限于物理内存上限之后的默认设置但无限膨胀下去照样把机器内存搞垮。明白了这些区域再看实际问题。热搜词里有个很典型的例子《我的世界》Java版性能受限、存在垃圾回收卡顿。这游戏跑在JVM上玩家建大片红石机器、装载大量区块时堆里对象数量暴涨GC频繁触发就会出现俗称的卡顿。这种场景下你没法改游戏代码但可以改JVM参数让GC策略更适合大堆高吞吐的场景。比如用-Xmx调大最大堆内存用-XX:UseG1GC切换垃圾收集器甚至用-XX:MaxGCPauseMillis给GC设定目标停顿时间。JVM跨平台保证你不管在Windows还是Linux上都能用同一套参数来调整但怎么调需要你自己对应用特性有判断。线上Java服务最常见的故障类型之一就是内存泄露或内存持续增长。排查这个问题的常规链路其实非常固定而且得益于跨平台操作命令在Windows和Linux上几乎一致。第一步用jps找到目标Java进程的PID。jps专门用来列出当前机器上的Java进程比ps过滤更精准。第二步jstat -gc pid 1000 10每秒输出一次GC情况观察堆的各个分代区域变化趋势。如果老年代持续增长且GC回收不掉内存泄露的嫌疑就很大。第三步需要进一步看堆里到底什么对象占了空间用jmap -histo pid按内存占用排序打印出类名。第四步如果要精确定位就得抓堆快照jmap -dump:formatb,fileheap.hprof pid导出堆转储文件然后交给MAT或者VisualVM分析。找到那些仅凭直觉看不到的问题对象比如某个静态Map不断往里塞数据却不清理比如线程池里的异常对象持有外部引用比如Tomcat的类加载器被错误引用导致整个Web应用无法卸载。这个排查链路就是典型的跨平台实战——不管操作系统是什么JVM提供的这一套工具箱都一样。说到Tomcat启动时设置JVM参数也是个经典需求。Linux环境下在catalina.sh文件里找到JAVA_OPTSWindows环境下是catalina.bat加上你需要的参数。一个生产环境常见的配置长这样JAVA_OPTS-Xms4096m -Xmx4096m -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m -XX:UseG1GC -XX:MaxGCPauseMillis200 -Djava.awt.headlesstrue-Xms和-Xmx设置堆的初始大小和最大大小建议两个值设成一样的避免运行期堆容量反复扩容收缩带来不必要的性能损耗。MetaspaceSize设一个初始元空间阈值避免类加载一多就触发Full GC。UseG1GC是G1收集器适合大堆、追求可控停顿时间的场景JDK 9之后它已经是默认收集器但在老版本上需要显式开启。这里要特别提一个容易忽略的细节不同操作系统下JVM对最大堆内存的默认值策略不一样。同样的-Xmx设了4GWindows上可能顺利启动Linux上可能报错原因往往不在JVM自身而在操作系统的内存资源限制。排查这种问题时会看只是第一步能把JVM参数和操作系统行为结合起来看才是真正的内功。回到JIT它不只是后台编译器这么简单。现代JVM的逃逸分析、锁消除、栈上分配这些高级优化全部依赖运行期采集的真实profile数据。C2编译器会基于这些数据做激进的假设比如这个接口在历史运行中只有两个实现类这个分支99%都不会走。一旦运行过程中发现假设不成立JVM也不会硬扛而是触发反优化把已经编译过的机器码废弃回退到解释执行重新收集数据等待下一次重新编译。所以越跑越快是有边际的不是无限加速而是在对当前运行概况反复试探-优化-验证中逼近最优状态。我自己在实战中的一个深刻体会是JVM的优化是用运行时间换编译优化时间。一个长期运行的Java服务比如系统启动后跑了三天三夜JIT已经把核心路径上的所有热点方法都优化到位了此时性能处于巅峰反而是服务刚启动、刚重启后的那十几分钟JIT还没来得及把热点代码全部编译再加上类加载、连接池预热等开销整体表现往往最差。很多运维事故发生在版本发布重启后的窗口期跟这个有直接关系。这也解释了为什么现代微服务架构里大家越来越重视启动加速和预热流量。Spring Cloud的RestTemplate第一次调用慢得像蜗牛本质上就是还没被JIT编译过全走解释执行。有个土办法是服务启动后先放一些热身流量让核心路径的代码先被JIT编译一遍再正式接线上流量。从JVM运行机制来看这是完全合理的实践——本质上是主动把越跑越快的进程往前拨。最后再分享一个小技巧用-XX:PrintCompilation参数启动Java进程可以实时看到JIT编译的记录每行代表一个方法被编译成了本地代码。我发现很多开发者在排查性能问题时第一反应是上各种外部监控工具却忘了JDK自带的这类运行信息。-XX:PrintCompilation输出的内容看起来乱但能直观反映热点方法的编译情况。当你看到某个业务方法被编译成机器码的那一刻才真正理解了什么叫越跑越快。跨平台解决的是能不能跑JIT解决的是越跑越快而你自己对这两者的理解深度决定了你的Java程序上线之后到底能不能稳定发挥。
返回列表