从jvm代码优化角度谈撸代码

1.前言

以编译程序执行本地代码,比解释执行更快,除虚拟机解释执行字节码额外消耗时间的原因之外,另一个很重要原因就是虚拟机设计团队几乎把对代码的所有优化措施都集中在即时编译器中。一般即时编译器生成的本地代码比javac产生的字节码更优秀。

HotSpot虚拟机在即时编译器中采用的优化技术:https://wiki.openjdk.java.net/display/HotSpot/PerformanceTacticIndex

下面谈一下jit编译优化的常见手段。

2.方法内联

编译器最总要的优化是方法内联。遵循面向对象方式编写的代码,通常会有很多get,set方法。

优化前代码:
static class B{
int value;
final int get(){
return value;
}
}

public void foo(){
y = b.get();
z = b.get();
sum = y + z;
}

内联是最重要的优化措施,原因有二。一,降低调用的开销(如建立栈帧等);二,方法内联为其他优化措施建立良好基础,方法内联膨胀后,可在更大范围采用后续优化手段。

内联后代码:
public void foo(){
y = b.value;
z = b.value;
sum = y + z;
}

第二步优化:冗余访问消除
y和z的表达式一样,所以可以不必去访问对象b,替换为z=y。如果把b.value视为表达式,则该优化也可以理解为公共表达式消除。

冗余存储消除后代码:
public void foo(){
y = b.value;
z = y;
sum = y + z;
}

第三步优化:复写传播
z与y的值一样,不必使用额外的变量,用y代替z

复写传播优化后:
public void foo(){
y = b.value;
y = y;
sum = y + y;
}

第四步优化:无用代码消除

public void foo(){
y = b.value;
sum = y + y;
}

经过优化以后,作用一样,但是省略了很多代码(从字节码,机器码指令上的差距更大),执行效率更高。

由上面的例子,我们可以看到,方法内联是很多优化手段的基础。方法是否内联,取决于方法是否够热(jvm根据内部方法判断,如是否调用频繁),以及大小。只有方法够热,并且字节码小于325字节,或者方法小于35字节时才能内联。

由此我们可以和平时撸代码的一个小经验印证起来,多写小方法,把职责完整的逻辑段抽取出来,成为独立的小方法。小方法好处有几个:
一 逻辑简单,职责单一,与设计原则的单一性对应;
二 抽出去小方法,类似的,职责上接近的,可以当成下一步抽象优化的基础;
三 小方法更容易满足内联条件。

3.逃逸分析

逃逸分析与方法内联相似,也是其他优化手段的基础。
逃逸分析的行为就是分心对象动态作用域,当一个对象不会逃逸到方法或者线程之外,也就是其他方法或者线程无法通过任何路径访问到该对象时,则可进行一些高效优化。

栈上分配:堆中分配的对象,无论是验证,整理或者清理都需耗费时间,如果确定对象不会逃逸,则可在栈上分配内存,对象所在的内存就可以随栈帧出栈而销毁。在一般应用中,不会逃逸的局部对象比例很大,栈上分配可减少垃圾回收压力。

同步消除:线程同步耗时很大,不会逃逸的对象,读写不会有竞争,相应的同步措施可以消除。

标量替换:标量是指无法再分解的数据。java虚拟机中的原始数据类型都可以称为标量。如果可以继续分解,则称为聚合量,如对象。如果把一个java对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型,来访问,叫做标量替换。如果逃逸分析证明一个对象不会被外部访问,并且可以拆解,那么程序真正执行的时候将很可能不创建这个对象,改为直接创建该对象的成员变量来代替。进而直接在栈上分配和读写,为后续优化打基础。

栈上存储的数据,很大概率会被虚拟机分配到物理机的高速寄存器中。

可以看到,逃逸分析也是jit优化代码的重要措施,那撸代码时有啥常识可以印证?
答案:广义的迪米特法则。控制对象之间的信息流量,流向,影响。内部实现和外部接口隔离。严格控制对象的作用域,访问权限等,使对象,方法的影响最小,不但优雅,而且更合编译器口味。(全局变量自然不满足逃逸分析)

4. 再谈对象作用域

前面都在聊jit,我们回到编译方式执行程序的场景(代码不够热,解释器执行)。看看下面这段代码。

public static void main (String[] args)(){
{
byte[] a = new byte[64 * 1024 * 1024];
}
System.gc();
}

jvm 加上运行参数”-verbose:gc”,查看运行日志

[GC 66846K->65888K(125632K), 0.0009397 secs]
[Full GC 65888K->65746K(125632K), 0.0051574 secs]

内存居然没被回收。改一下代码
public static void main (String[] args)(){
{
byte[] a = new byte[64 * 1024 * 1024];
}
int b = 0;
System.gc();
}

再执行一次,回收了。。。
[GC 66401K->65778K(125632K), 0.0035471 secs]
[Full GC 65778K->218K(125632K), 0.0140596 secs]

原因在于局部变量表。局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在第一段代码中,虽然变量离开了作用域,但是在此之后,没有其他局部变量表的读写,GC Roots一部分的局部变量表仍然保存着对它的关联。正常情况下没影响,但如果遇到一些特殊情况(对象占用内存大,此方法的栈帧长时间不能被回收,方法调用次数达不到jit编译条件),则会采用手动set为null的方式(用来代替那句b = 0,把局部变量表清空)。

然而有一个问题,解释执行和jit编译后执行是完全不同的代码,这里用来救命的 set null,在jit编译后,很大可能会当成无用代码消除,jit编译后,第一段代码可以正常释放内存。更优雅的方式应当还是严格控制对象作用域,减少对set null的依赖。

5 总结

写小方法
遵循广义迪米特法则
严格控制对象作用域

6 参考

深入理解java虚拟机
java性能权威指南

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值