1.尽量使用final修饰符。
带有final修饰符的类是不可派生的。在JAVA核心API中,有许多应用final的例子,例如java.lang.String。为String类指定final防止了使用者覆盖length()方法。另外,如果一个类是final的,则该类所有方法都是final的。java编译器会寻找机会内联(inline)所有的final方法(这和具体的编译器实现有关)。此举能够使性能平均提高50%。
2.尽量重用对象。
特别是String对象的使用中,出现字符串连接情况时应使用StringBuffer代替,由于系统不仅要花时间生成对象,以后可能还需要花时间对这些对象进行垃圾回收和处理。因此生成过多的对象将会给程序的性能带来很大的影响。
3.尽量使用局部变量。
调用方法时传递的参数以及在调用中创建的临时变量都保存在栈(Stack)中,速度较快。其他变量,如静态变量,实例变量等,都在堆(Heap)中创建,速度较慢。
4.不要重复初始化变量。
默认情况下,调用类的构造函数时,java会把变量初始化成确定的值,所有的对象被设置成null,整数变量设置成0,float和double变量设置成0.0,逻辑值设置成false。当一个类从另一个类派生时,这一点尤其应该注意,因为用new关键字创建一个对象时,构造函数链中的所有构造函数都会被自动调用。
这里有个注意,给成员变量设置初始值但需要调用其他方法的时候,最好放在一个方法比如initXXX()中,因为直接调用某方法赋值可能会因为类尚未初始化而抛空指针异常,public int state = this.getState();
5.在java+Oracle的应用系统开发中,java中内嵌的SQL语言应尽量使用大写形式,以减少Oracle解析器的解析负担。
6.java编程过程中,进行数据库连接,I/O流操作,在使用完毕后,及时关闭以释放资源。因为对这些大对象的操作会造成系统大的开销。
7.过分的创建对象会消耗系统的大量内存,严重时,会导致内存泄漏,因此,保证过期的对象的及时回收具有重要意义。
JVM的GC并非十分智能,因此建议在对象使用完毕后,手动设置成null。
8.在使用同步机制时,应尽量使用方法同步代替代码块同步。
9.尽量减少对变量的重复计算。
比如
for(int i=0;i<list.size();i++)
应修改为
for(int i=0,len=list.size();i<len;i++)
10.采用在需要的时候才开始创建的策略。
例如:
String str="abc";
if(i==1){ list.add(str);}
应修改为:
if(i==1){String str="abc"; list.add(str);}
11.慎用异常,异常对性能不利。
抛出异常首先要创建一个新的对象。Throwable接口的构造函数调用名为fillInStackTrace()的本地方法,fillInStackTrace()方法检查栈,收集调用跟踪信息。只要有异常被抛出,VM就必须调整调用栈,因为在处理过程中创建了一个新的对象。
异常只能用于错误处理,不应该用来控制程序流程。
12.不要在循环中使用Try/Catch语句,应把Try/Catch放在循环最外层。
Error是获取系统错误的类,或者说是虚拟机错误的类。不是所有的错误Exception都能获取到的,虚拟机报错Exception就获取不到,必须用Error获取。
13.通过StringBuffer的构造函数来设定他的初始化容量,可以明显提升性能。
StringBuffer的默认容量为16,当StringBuffer的容量达到最大容量时,她会将自身容量增加到当前的2倍+2,也就是2*n+2。无论何时,只要StringBuffer到达她的最大容量,她就不得不创建一个新的对象数组,然后复制旧的对象数组,这会浪费很多时间。所以给StringBuffer设置一个合理的初始化容量值,是很有必要的!
14.合理使用java.util.Vector。
Vector与StringBuffer类似,每次扩展容量时,所有现有元素都要赋值到新的存储空间中。Vector的默认存储能力为10个元素,扩容加倍。
vector.add(index,obj) 这个方法可以将元素obj插入到index位置,但index以及之后的元素依次都要向下移动一个位置(将其索引加 1)。 除非必要,否则对性能不利。
同样规则适用于remove(int index)方法,移除此向量中指定位置的元素。将所有后续元素左移(将其索引减 1)。返回此向量中移除的元素。所以删除vector最后一个元素要比删除第1个元素开销低很多。删除所有元素最好用removeAllElements()方法。
如果要删除vector里的一个元素可以使用 vector.remove(obj);而不必自己检索元素位置,再删除,如int index = indexOf(obj);vector.remove(index);
15.当复制大量数据时,使用System.arraycopy();
16.代码重构,增加代码的可读性。
17.不用new关键字创建对象的实例。
用new关键词创建类的实例时,构造函数链中的所有构造函数都会被自动调用。但如果一个对象实现了Cloneable接口,我们可以调用她的clone()方法。clone()方法不会调用任何类构造函数。
Factory模式
public static Credit getNewCredit()
{
return new Credit();
}
改进后的代码使用clone()方法,
private static Credit BaseCredit = new Credit();
public static Credit getNewCredit()
{
return (Credit)BaseCredit.clone();
}
18.乘除法如果可以使用位移,应尽量使用位移,但最好加上注释,因为位移操作不直观,难于理解
19.不要将数组声明为:public static final
20.HaspMap的遍历。
1
2
3
4
5
6
Map<String, String[]> paraMap = new HashMap<String, String[]>();
for( Entry<String, String[]> entry : paraMap.entrySet() )
{
String appFieldDefId = entry.getKey();
String[] values = entry.getValue();
}
利用散列值取出相应的Entry做比较得到结果,取得entry的值之后直接取key和value。
21.array(数组)和ArrayList的使用。
array 数组效率最高,但容量固定,无法动态改变,ArrayList容量可以动态增长,但牺牲了效率。
22.单线程应尽量使用 HashMap, ArrayList,除非必要,否则不推荐使用HashTable,Vector,她们使用了同步机制,而降低了性能。
23.StringBuffer,StringBuilder的区别在于:java.lang.StringBuffer 线程安全的可变字符序列。一个类似于String的字符串缓冲区,但不能修改。StringBuilder与该类相比,通常应该优先使用StringBuilder类,因为她支持所有相同的操作,但由于她不执行同步,所以速度更快。为了获得更好的性能,在构造StringBuffer或StringBuilder时应尽量指定她的容量。当然如果不超过16个字符时就不用了。
相同情况下,使用StringBuilder比使用StringBuffer仅能获得10%~15%的性能提升,但却要冒多线程不安全的风险。综合考虑还是建议使用StringBuffer。
24.尽量使用基本数据类型代替对象。
25.用简单的数值计算代替复杂的函数计算,比如查表方式解决三角函数问题。
26.使用具体类比使用接口效率高,但结构弹性降低了,但现代IDE都可以解决这个问题。
27.考虑使用静态方法
如果你没有必要去访问对象的外部,那么就使你的方法成为静态方法。她会被更快地调用,因为她不需要一个虚拟函数导向表。这同事也是一个很好的实践,因为她告诉你如何区分方法的性质,调用这个方法不会改变对象的状态。
28.应尽可能避免使用内在的GET,SET方法。
android编程中,虚方法的调用会产生很多代价,比实例属性查询的代价还要多。我们应该在外包调用的时候才使用get,set方法,但在内部调用的时候,应该直接调用。
29.避免枚举,浮点数的使用。
30.二维数组比一维数组占用更多的内存空间,大概是10倍计算。
31.SQLite数据库读取整张表的全部数据很快,但有条件的查询就要耗时30-50MS,大家做这方面的时候要注意,尽量少用,尤其是嵌套查找!
4. 使用懒加载
懒加载 : 当要用的时候才创建该对象。
String prefix = "gebi";
if ("laowang".equals(name)) {
list.add(prefix + name);
}
替换为:
if("laowang".equals(name)) {
String prefix = "gebi";
list.add(prefix + name);
}
7.循环内尽量避免创建对象的引用。
尤其是循环量大的时候。
while (i<1000) {
Object object = new Object();
}
建议修改为:
Object object = null;
while (i<1000) {
object = new Object();
每次new Object()的时候,Object对象引用指向Object对象。
当循环次数多的时候,如第一种,JVM会创建1000个对象的引用,而第二种内存中只有一份Object对象引用。这样就大大节省了内存空间了。
8.不要随意使用static变量。
当对象被声明为static的变量所引用时,此时,Java垃圾回收器不会清理这个对象所占用的堆内存。静态变量所占用的堆内存直到该变量所在类所在程序结束才被释放。 即静态变量生命周期=类生命周期。
9.不要创建一些不使用的对象,不要导入一些不使用的类。
10.使用带缓冲的I/O流:带缓冲的I/O流可以极大提高I/O效率。BufferedWriter, BufferedReader, BufferedInputStream, BufferedOutputStream。
11.包装类数据转换为字符串使用: toString
Integer i = 1;
包装类数据转换为字符串方法速度排名 :
i.toString > String.valueOf(i) > "" + i
12.Map遍历效率 : entrySet > keySet
//entrySet()
for (Entry<String, String> entry : map.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
System.out.println(key + " : " + value);
}
//上下对比
//keySet()
for (String key : map.keySet()) {
String value = map.get(key);
System.out.println(key + " : " + value);
}
13.关于Iterator与forEach()的集合遍历舍取。
算法导论上说:算法是为了提高空间效率和时间效率。但往往时间和空间不能并存。
时间效率:Iterator > forEach()
代码可读性 : forEach() > Iterator
//Iterator
Set
//forEach()
for (Entry<String, String> entry : map.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
System.out.println(key + " : " + value);
}
个人认为:当处理大数据时推荐使用Iterator遍历集合。
但处理小数据的话,为了可读性和后期维护还是使用forEach()。
两者结合使用,都应该掌握。
1、及时清除不再使用的对象,设为null
2、尽可能使用final,static等关键字
3、尽可能使用buffered对象
如何优化代码使JAVA源文件及编译后CLASS文件更小
1 尽量使用继承,继承的方法越多,你要写的代码量也就越少
2 打开JAVA编译器的优化选项: javac -O 这个选项将删除掉CLASS文件中的行号,并能把
一些private, static,final的小段方法申明为inline方法调用
3 把公用的代码提取出来 Util
4 不要初始化很大的数组,尽管初始化一个数组在JAVA代码中只是一行的代码量,但编译后的代码是一行代码插入一个数组的元素,所以如果你有大量的数据需要存在数组中的话,可以先把这些数据放在String中,然后在运行期把字符串解析到数组中
5 日期类型的对象会占用很大的空间,如果你要存储大量的日期对象,可以考虑把它存储为
long型,然后在使用的时候转换为Date类型
6 类名,方法名和变量名尽量使用简短的名字,可以考虑使用Hashjava, Jobe, Obfuscate and Jshrink等工具自动完成这个工作
7 将static final类型的变量定义到Interface中去
首先接口是一种高度抽象的”模版”,,而接口中的属性也就是’模版’的成员,就应当是所有实现”模版”的实现类的共有特性,所以它是public static的 ,是所有实现类共有的 .假如可以是非static的话,因一个类可以继承多个接口,出现重名的变量,如何区分呢?
其次,接口中如果可能定义非final的变量的话,而方法又都是abstract的,这就自相矛盾了,有可变成员变量但对应的方法却无法操作这些变量,虽然可以直接修改这些静态成员变量的值,但所有实现类对应的值都被修改了,这跟抽象类有何区别? 又接口是一种更高层面的抽象,是一种规范、功能定义的声明,所有可变的东西都应该归属到实现类中,这样接口才能起到标准化、规范化的作用。所以接口中的属性必然是final的。
最后,接口只是对事物的属性和行为更高层次的抽象 。对修改关闭,对扩展(不同的实现implements)开放,接口是对开闭原则(Open-Closed Principle )的一种体现。
8 算术运算 能用左移/右移的运算就不要用*和/运算,相同的运算不要运算多次
9 不要两次初始化变量
Java通过调用独特的类构造器默认地初始化变量为一个已知的值。所有的对象被设置成null,integers (byte, short, int, long)被设置成0,float和double设置成0.0,Boolean变量设置成false。这对那些扩展自其它类的类尤其重要,这跟使用一个新的关键词创建一个对象时所有一连串的构造器被自动调用一样。
10 在任何可能的地方让类为Final
标记为final的类不能被扩展。在《核心Java API》中有大量这个技术的例子,诸如java.lang.String。将String类标记为final阻止了开发者创建他们自己实现的长度方法。
更深入点说,如果类是final的,所有类的方法也是final的。Java编译器可能会内联所有的方法(这依赖于编译器的实现)。在我的测试里,我已经看到性能平均增加了50%。
11 异常在需要抛出的地方抛出,try catch能整合就整合
12
try {
some.method1(); // Difficult for javac
} catch( method1Exception e ) { // and the JVM runtime
// Handle exception 1 // to optimize this
} // code
try {
some.method2();
} catch( method2Exception e ) {
// Handle exception 2
}
try {
some.method3();
} catch( method3Exception e ) {
// Handle exception 3
}
try {
some.method1(); // Easier to optimize
some.method2();
some.method3();
} catch( method1Exception e ) {
// Handle exception 1
} catch( method2Exception e ) {
// Handle exception 2
} catch( method3Exception e ) {
// Handle exception 3
}
更容易被编译器优化
10. For循环的优化
Replace…
for( int i = 0; i < collection.size(); i++ ) {
...
}
with…
for( int i = 0, n = collection.size(); i < n; i++ ) {
...
}
本文中,作者(Eva Andreasson)首先介绍了不同种类的编译器,并对客户端编译,服务器端编译器和多层编译的运行性能进行了对比。然后,在文章的最后介绍了几种常见的JVM优化方法,如死代码消除,代码嵌入以及循环体优化。
Java最引以为豪的特性“平*立性”正是源于Java编译器。软件开发人员尽其所能写出最好的java应用程序,紧接着后台运行的编译器产生高效的基于目标平台的可执行代码。不同的编译器适用于不同的应用需求,因而也就产生不同的优化结果。因此,如果你能更好的理解编译器的工作原理、了解更多种类的编译器,那么你就能更好的优化你的Java程序。
本篇文章突出强调和解释了各种Java虚拟机编译器之间的不同。同时,我也会探讨一些及时编译器(JIT)常用的优化方案。
什么是编译器?
简单来说,编译器就是以某种编程语言程序作为输入,然后以另一种可执行语言程序作为输出。Javac是最常见的一种编译器。它存在于所有的JDK里面。Javac 以java代码作为输出,将其转换成JVM可执行的代码—字节码。这些字节码存储在以.class结尾的文件中,并在java程序启动时装载到java运行时环境。
字节码并不能直接被CPU读取,它还需要被翻译成当前平台所能理解的机器指令语言。JVM中还有另一个编译器负责将字节码翻译成目标平台可执行的指令。一些JVM编译器需要经过几个等级的字节码代码阶段。例如,一个编译器在将字节码翻译成机器指令之前可能还需要经历几种不同形式的中间阶段。
从平台不可知论的角度出发,我们希望我们的代码能够尽可能的与平台无关。
为了达到这个目的,我们在最后一个等级的翻译—从最低的字节码表示到真正的机器代码—才真正将可执行代码与一个特定平台的体系结构绑定。从最高的等级来划分,我们可以将编译器分为静态编译器和动态编译器。 我们可以根据我们的目标执行环境、我们渴望的优化结果、以及我们需要满足的资源限制条件来选择合适的编译器。在上一篇文章中我们简单的讨论了一下静态编译器和动态编译器,在接下来的部分我们将更加深入的解释它们。
静态编译 VS 动态编译
我们前面提到的javac就是一个静态编译的例子。对于静态编译器,输入代码被解释一次,输出即为程序将来被执行的形式。除非你更新源代码并(通过编译器)重新编译,否则程序的执行结果将永远不会改变:这是因为输入是一个静态的输入并且编译器是一个静态的编译器。
通过静态编译,下面的程序:
复制代码 代码如下:
staticint add7(int x ){ return x+7;}
将会转换成类似下面的字节码:
复制代码 代码如下:
iload0 bipush 7 iadd ireturn
动态编译器动态的将一种语言编译成另外一种语言,所谓动态的是指在程序运行的时候进行编译—边运行边编译!动态编译和优化的好处就是可以处理应用程序加载时的一些变化。Java 运行时常常运行在不可预知甚至变化的环境上,因此动态编译非常适用于Java 运行时。大部分的JVM 使用动态编译器,如JIT编译器。值得注意的是,动态编译和代码优化需要使用一些额外的数据结构、线程以及CPU资源。越高级的优化器或字节码上下文分析器,消耗越多的资源。但是这些花销相对于显著的性能提升来说是微不足道的。
JVM种类以及Java的平*立性
所有JVM的实现都有一个共同的特点就是将字节码编译成机器指令。一些JVM在加载应用程序时对代码进行解释,并通过性能计数器来找出“热”代码;另一些JVM则通过编译来实现。编译的主要问题是集中需要大量的资源,但是它也能带来更好的性能优化。
如果你是一个java新手,JVM的错综复杂肯定会搞得你晕头转向。但好消息是你并不需要将它搞得特别清楚!JVM将管理代码的编译和优化,你并不需要为机器指令以及采取什么样的方式写代码才能最佳的匹配程序运行平台的体系结构而操心。
从java字节码到可执行
一旦将你的java代码编译成字节码,接下来的一步就是将字节码指令翻译成机器代码。这一步可以通过解释器来实现,也可以通过编译器来实现。
解释
解释是编译字节码最简单的方式。解释器以查表的形式找到每条字节码指令对应的硬件指令,然后将它发送给CPU执行。
你可以将解释器想象成查字典:每一个特定的单词(字节码指令),都有一个具体的翻译(机器代码指令)与之对应。因为解释器每读一条指令就会马上执行该指令,所以该方式无法对一组指令集进行优化。同时每调用一个字节码都要马上对其进行解释,因此解释器运行速度是相当慢得。解释器以一种非常准确的方式来执行代码,但是由于没有对输出的指令集进行优化,因此它对目标平台的处理器来说可能不是最优的结果。
编译
编译器则是将所有将要执行的代码全部装载到运行时。这样当它翻译字节码时,就可以参考全部或部分的运行时上下文。它做出的决定都是基于对代码图分析的结果。如比较不同的执行分支以及参考运行时上下文数据。
在将字节码序列被翻译成机器代码指令集后,就可以基于这个机器代码指令集进行优化。优化过的指令集存储在一个叫代码缓冲区的结构中。当再次执行这些字节码时,就可以直接从这个代码缓冲区中取得优化过的代码并执行。在有些情况下编译器并不使用优化器来进行代码优化,而是使用一种新的优化序列—“性能计数”。
使用代码缓存器的优点是结果集指令可以被立即执行而不再需要重新解释或编译!
这可以大大的降低执行时间,尤其是对一个方法被多次调用的java应用程序。
优化
通过动态编译的引入,我们就有机会来插入性能计数器。例如,编译器插入性能计数器,每次字节码块(对应某个具体的方法)被调用时对应的计数器就加一。编译器通过这些计数器找到“热块”,从而就能确定哪些代码块的优化能对应用程序带来最大的性能提升。运行时性能分析数据能够帮助编译器在联机状态下得到更多的优化决策,从而更进一步提升代码执行效率。因为得到越多越精确的代码性能分析数据,我们就可以找到更多的可优化点从而做出更好的优化决定,例如:怎样更好的序列话指令、是否用更有效率的指令集来替代原有指令集,以及是否消除冗余的操作等。
例如
考虑下面的java代码
复制代码 代码如下:
staticint add7(int x ){ return x+7;}
Javac 将静态的将它翻译成如下字节码:
复制代码 代码如下:
iload0
bipush 7
iadd
ireturn
当该方法被调用时,该字节码将被动态的编译成机器指令。当性能计数器(如果存在)达到指定的阀值时,该方法就可能被优化。优化后的结果可能类似下面的机器指令集:
复制代码 代码如下:
lea rax,[rdx+7] ret
不同的编译器适用于不同的应用
不同的应用程序拥有不同的需求。企业服务器端应用通常需要长时间运行,所以通常希望对其进行更多的性能优化;而客户端小程序可能希望更快的响应时间和更少的资源消耗。下面让我们一起讨论三种不同的编译器以及他们的优缺点。
客户端编译器(Client-side compilers)
C1是一种大家熟知的优化编译器。当启动JVM时,添加-client参数即可启动该编译器。通过它的名字我们即可发现C1是一种客户端编译器。它非常适用于那种系统可用资源很少或要求能快速启动的客户端应用程序。C1通过使用性能计数器来进行代码优化。这是一种方式简单,且对源代码干预较少的优化方式。
服务器端编译器(Server-side compilers)
对于那种长时间运行的应用程序(例如服务器端企业级应用程序),使用客户端编译器可能远远不能够满足需求。这时我们应该选择类似C2这样的服务器端编译器。通过在JVM启动行中加入 –server 即可启动该优化器。因为大部分的服务器端应用程序通常都是长时间运行的,与那些短时间运行、轻量级的客户端应用相比,通过使用C2编译器,你将能够收集到更多的性能优化数据。因此你也将能够应用更高级的优化技术和算法。
提示:预热你的服务端编译器
对于服务器端的部署,编译器可能需要一些时间来优化那些“热点”代码。所以服务器端的部署常常需要一个“加热”阶段。所以当对服务器端的部署进行性能测量时,务必确保你的应用程序已经达到了稳定状态!给予编译器充足的时间进行编译将会给你的应用带来很多好处。
服务器端编译器相比客户端编译器来说能够得到更多的性能调优数据,这样就可以进行更复杂的分支分析,从而找到性能更优的优化路径。拥有越多的性能分析数据就能得到更优的应用程序分析结果。当然,进行大量的性能分析也就需要更多的编译器资源。如JVM若使用C2编译器,那么它将需要使用更多的CPU周期,更大的代码缓存区等等。
多层编译
多层编译混合了客户端编译和服务器端编译。Azul第一个在他的Zing JVM中实现了多层编译。最近,这项技术已经被Oracle Java Hotspot JVM采用(Java SE7 之后)。多层编译综合了客户端和服务器端编译器的优点。客户端编译器在以下两种情况表现得比较活跃:应用启动时;当性能计数器达到较低级别的阈值时进行性能优化。客户端编译器也会插入性能计数器以及准备指令集以备接下来的高级优化—服务器端编译器—使用。多层编译是一种资源利用率很高的性能分析方式。因为它可以在低影响编译器活动时收集数据,而这些数据可以在后面更高级的优化中继续使用。这种方式与使用解释性代码分析计数器相比可以提供更多的信息。
图1所描述的是解释器、客户端编译、服务器端编译、多层编译的性能比较。X轴是执行时间(时间单位),Y轴是性能(单位时间内的操作数)
图1.编译器性能比较
相对于纯解释性代码,使用客户端编译器可以带来5到10倍的性能提升。获得性能提升的多少取决于编译器的效率、可用的优化器种类以及应用程序的设计与目标平台的吻合程度。但对应程序开发人员来讲最后一条往往可以忽略。
相对于客户端编译器,服务器端编译器往往能带来30%到50%的性能提升。在大多数情况下,性能的提升往往是以资源的损耗为代价的。
多层编译综合了两种编译器的优点。客户端编译有更短的启动时间以及可以进行快速优化;服务器端编译则可以在接下来的执行过程中进行更高级的优化操作。
一些常见的编译器优化
到目前为止,我们已经讨论了优化代码的意义以及怎样、何时JVM会进行代码优化。接下来我将以介绍一些编译器实际用到的优化方式来结束本文。JVM优化实际发生在字节码阶段(或者更底层的语言表示阶段),但是这里将使用java语言来说明这些优化方式。我们不可能在本节覆盖所有的JVM优化方式;当然啦,我希望通过这些介绍能激发你去学习数以百计的更高级的优化方式的兴趣并在编译器技术方面有所创新。
死代码消除
死代码消除,顾名思义就是消除那些永远不会被执行到的代码—即“死”代码。
如果编译器在运行过程中发现一些多余指令,它将会将这些指令从执行指令集里面移除。例如,在列表1里面,其中一个变量在对其进行赋值操作后永远不会被用到,所有在执行阶段可以完全地忽略该赋值语句。对应到字节码级别的操作即是,永远不需要将该变量值加载到寄存器中。不用加载意味着消耗更少的cpu时间,因此也就能加快代码执行,最终导致应用程序加快—如果该加载代码每秒被调用好多次,那优化效果将更明显。
列表1 用java 代码列举了一个对永远不会被使用的变量赋值的例子。
列表1. 死代码
复制代码 代码如下:
int timeToScaleMyApp(boolean endlessOfResources){
int reArchitect =24;
int patchByClustering =15;
int useZing =2;
if(endlessOfResources)
return reArchitect + useZing;
else
return useZing;
}
在字节码阶段,如果一个变量被加载但是永远不会被使用,编译器可以检测到并消除掉这些死代码,如列表2所示。如果永远不执行该加载操作则可以节约cpu时间从而改进程序的执行速度。
列表2. 优化后的代码
复制代码 代码如下:
int timeToScaleMyApp(boolean endlessOfResources){
int reArchitect =24; //unnecessary operation removed here…
int useZing =2;
if(endlessOfResources)
return reArchitect + useZing;
else
return useZing;
}
冗余消除是一种类似移除重复指令来改进应用性能的优化方式。
很多优化尝试着消除机器指令级别的跳转指令(如 x86体系结构中得JMP). 跳转指令将改变指令指针寄存器,从而转移程序执行流。这种跳转指令相对其他ASSEMBLY指令来说是一种很耗资源的命令。这就是为什么我们要减少或消除这种指令。代码嵌入就是一种很实用、很有名的消除转移指令的优化方式。因为执行跳转指令代价很高,所以将一些被频繁调用的小方法嵌入到函数体内将会带来很多益处。列表3-5证明了内嵌的好处。
列表3. 调用方法
复制代码 代码如下:
int whenToEvaluateZing(int y){ return daysLeft(y)+ daysLeft(0)+ daysLeft(y+1);}
列表4. 被调用方法
复制代码 代码如下:
int daysLeft(int x){ if(x ==0) return0; else return x -1;}
列表5. 内嵌方法
复制代码 代码如下:
int whenToEvaluateZing(int y){
int temp =0;
if(y ==0)
temp +=0;
else
temp += y -1;
if(0==0)
temp +=0;
else
temp +=0-1;
if(y+1==0)
temp +=0;
else
temp +=(y +1)-1;
return temp;
}
在列表3-5中我们可以看到,一个小方法在另一个方法体内被调用了三次,而我们想说明的是:将被调用方法直接内嵌到代码中所花费的代价将小于执行三次跳转指令所花费的代价。
内嵌一个不常被调用的方法可能并不会带来太大的不同,但是如果内嵌一个所谓的“热”方法(经常被调用的方法)则可以带来很多的性能提升。内嵌后的代码常常还可以进行更进一步的优化,如列表6所示。
列表6. 代码内嵌后,更进一步的优化实现
复制代码 代码如下:
int whenToEvaluateZing(int y){ if(y ==0)return y; elseif(y ==-1)return y -1; elsereturn y + y -1;}
循环优化
循环优化在降低执行循环体所带来的额外消耗方面起着很重要的作用。这里的额外消耗指的是昂贵的跳转、大量的条件检测,非优化管道(即,一系列无实际操作、消耗额外cpu周期的指令集)。这里有很多种循环优化,接下来列举一些比较流行的循环优化:
循环体合并:当两个相邻的循环体执行相同次数的循环时,编译器将试图合并这两个循环体。如果两个循环体相互之间是完全独立的,则它们还可以被同时执行(并行)。
反演循环: 最基本的,你用一个do-while循环来替代一个while循环。这个do-while循环被放置在一个if语句中。这个替换将减少两次跳转操作;但增加了条件判断,因此增加了代码量。这种优化是以适当的增加资源消耗换来更有效的代码的很棒的例子—编译器对花费和收益进行衡量,在运行时动态的做出决定。
重组循环体: 重组循环体,使整个循环体能全部的存储在缓存器中。
展开循环体: 减少循环条件的检测次数和跳转次数。你可以把这想象成将几次迭代“内嵌”执行,而不必进行条件检测。循环体展开也会带来一定的风险,因为它可能因为影响流水线和大量的冗余指令提取而降低性能。再一次,是否展开循环体由编译器在运行时决定,如果能带来更大的性能提升则值得展开。
以上就是对编译器在字节码级别(或更低级别)如何改进应用程序在目标平台执行性能的一个概述。我们所讨论的都是些常见、流行的优化方式。由于篇幅有限我们只举了一些简单的例子。我们的目的是希望通过上面简单的讨论来激起你深入研究优化的兴趣。
结论:反思点和重点
根据不同的目的,选择不同的编译器。
1.解释器是将字节码翻译成机器指令的最简单形式。它的实现基于一个指令查询表。
2.编译器可以基于性能计数器进行优化,但是需要消耗一些额外的资源(代码缓存,优化线程等)。
3.客户端编译器相对于解释器可以带来5到10倍的性能提升。
4.服务器端编译器相对于客户端编译器来说可以带来30%到50%的性能提升,但需要消耗更多的资源。
5.多层编译则综合了两者的优点。使用客户端编译来获取更快的响应速度,接着使用服务器端编译器来优化那些被频繁调用的代码。
这里有很多种可能的代码优化方式。编译器的一个重要工作就是分析所有可能的优化方式,然后对各种优化方式所付出的代价与最终得到的机器指令带来的性能提升进行权衡。
Java应用程序是运行在JVM上的,但是你对JVM技术了解吗?这篇文章(这个系列的第一部分)讲述了经典Java虚拟机是怎么样工作的,例如:Java一次编写的利弊,跨平台引擎,垃圾回收基础知识,经典的GC算法和编译优化。之后的文章会讲JVM性能优化,包括最新的JVM设计——支持当今高并发Java应用的性能和扩展。
如果你是一个开发人员,你肯定遇到过这样的特殊感觉,你突然灵光一现,所有的思路连接起来了,你能以一个新的视角来回想起你以前的想法。我个人很喜欢学习新知识带来的这种感觉。我已经有过很多次这样的经历了,在我使用JVM技术工作时,特别是使用垃圾回收和JVM性能优化时。在这个新的Java世界中,我希望和你分享我的这些启发。希望你能像我写这篇文章一样兴奋的去了解JVM的性能。
这个系列文章,是为所有有兴趣去学习更多JVM底层知识,和JVM实际做了什么的Java开发人员所写的。在更高层次,我将讨论垃圾回收和在不影响应用运行的情况下,对空闲内存安全和速度上的无止境追求。你将学到JVM的关键部分:垃圾回收和GC算法,编译优化,和一些常用的优化。我同样会讨论为什么Java标记这样难,提供建议什么时候应该考虑测试性能。最后,我将讲一些JVM和GC的新的创新,包括Azul’s Zing JVM, IBM JVM, 和Oracle’s Garbage First (G1) 垃圾回收中的重点。
我希望你读完这个系列时对Java可扩展性限制的特点有更深的了解,同样的这样限制是如何强制我们以最优的方式创建一个Java部署。希望你会有一种豁然开朗的感受,并且能激发了一些好的Java灵感:停止接受那些限制,并去改变它!如果你现在还不是一个开源工作者,这个系列或许会鼓励你往这方面发展。
JVM性能和“一次编译,到处运行”的挑战
我有新的消息告诉那些固执的认为Java平台本质上是缓慢的人。当Java刚刚做为企业级应用的时候,JVM被诟病的Java性能问题已经是十几年前的事了,但这个结论,现在已经过时了。这是真的,如果你现在在不同的开发平台上运行简单静态和确定的任务时,你将很可能发现使用机器优化过的代码比使用任何虚拟环境执行的要好,在相同的JVM下。但是,Java的性能在过去10年有了非常大的提升。Java产业的市场需求和增长,导致了少量的垃圾回收算法、新的编译创新、和大量的启发式方法和优化,这些使JVM技术得到了进步。我将在以后的章节中介绍一些。
JVM的技术之美,同样是它最大的挑战:没有什么可以被认为是“一次编译,到处运行”的应用。不是优化一个用例,一个应用,一个特定的用户负载,JVM不断的跟踪Java应用现在在做什么,并进行相应的优化。这种动态的运行导致了一系列动态的问题。当设计创新时(至少不是在我们向生产环境要性能时),致力于JVM的开发者不会依赖静态编译和可预测的分配率。
JVM性能的事业
在我早期的工作中我意识到垃圾回收是非常难“解决”的,我一直着迷于JVMs和中间件技术。我对JVMs的热情开始于我在JRockit团队中时,编码一种新的方法用于自学,自己调试垃圾回收算法(参考 Resources)。这个项目(转变为JRockit一个实验性的特点,并成为Deterministic Garbage Collection算法的基础)开启了我JVM技术的旅程。我已经在BEA系统、Intel、Sun和Oracle(因为Oracle收购BEA系统,所以被Oracle短暂的工作过)工作过。之后我加入了在Azul Systems的团队去管理Zing JVM,现在我为Cloudera工作。
机器优化的代码可能会实现较好的性能(但这是以牺牲灵活性来做代价的),但对于动态装载和功能快速变化的企业应用这并不是一个权衡选择它的理由。大多数的企业为了Java的优点,更愿意去牺牲机器优化代码带来的勉强完美的性能。
1.易于编码和功能开发(意义是更短的时间去回应市场)
2.得到知识渊博的的程序员
3.用Java APIs和标准库更快速的开发
4.可移植性——不用为新的平台去重新写Java应用
从Java代码到字节码
做为一个Java程序员,你可能对编码、编译和执行Java应用很熟悉。例子:我们假设你有一个程序(MyApp.java),现在你想让它运行。去执行这个程序你需要先用javac(JDK内置的静态Java语言到字节码编译器)编译。基于Java代码,javac生成相应的可执行字节码,并保存在相同名字的class文件:MyApp.class中。在把Java代码编译成字节码后,你可以通过java命令(通过命令行或startup脚本,使用不使用startup选项都可以)来启动可执行的class文件,从而运行你的应用。这样你的class被加载到运行时(意味着Java虚拟机的运行),程序开始执行。
这就是表面上每一个应用执行的场景,但是现在我们来探究下当你执行java命令时究竟发生了什么。Java虚拟机是什么?大多数开发人员通过持续调试来与JVM交互——aka selecting 和value-assigning启动选项能让你的Java程序跑的更快,同时避免了臭名昭著的”out of memory”错误。但是,你是否曾经想过,为什么我们起初需要一个JVM来运行Java应用呢?
什么是Java虚拟机?
简单的说,一个JVM是一个软件模块,用于执行Java应用字节码并且把字节码转化到硬件,操作系统特殊指令。通过这样做,JVM允许Java程序在第一次编写后可以在不同的环境中执行,并不需要更改原始的代码。Java的可移植性是通往企业应用语言的关键:开发者并不需要为不同平台重写应用代码,因为JVM负责翻译和平台优化。
一个JVM基本上是一个虚拟的执行环境,作为一个字节码指令机器,而用于分配执行任务和执行内存操作通过与底层的交互。
一个JVM同样为运行的Java应用照看动态资源管理。这就意味着它掌握分配和释放内存,在每个平台上保持一致的线程模型,在应用执行的地方用一种适于CPU架构的方式组织可执行的指令。JVM把开发人员从跟踪对象当中的引用,和它们需要在系统中存在多长时间中解放出来。同样的它不用我们管理何时去释放内存——一个像C语言那样的非动态语言的痛点。
你可以把JVM当做是一个专门为Java运行的操作系统;它的工作是为Java应用管理运行环境。一个JVM基本上是一个虚拟的通过与底层的交互的执行环境,作为一个字节码指令机器,而用于分配执行任务和执行内存操作。
JVM组件概述
有很多写JVM内部和性能优化的文章。作为这个系列的基础,我将会总结概述下JVM组件。这个简短的阅览会为刚接触JVM的开发者有特殊的帮助,会让你更想了解之后更深入的讨论。
从一种语言到另一种——关于Java编译器
编译器是把一种语言输入,然后输出另一种可执行的语句。Java编译器有两个主要任务:
1. 让Java语言更加轻便,不用在第一次写的时候固定在特定的平台;
2. 确保对特定的平台产生有效的可执行的代码。
编译器可以是静态也可以是动态。一个静态编译的例子是javac。它把Java代码当做输入,并转化为字节码(一种在Java虚拟机执行的语言)。静态编译器一次解释输入的代码,输出可执行的形式,这个是在程序执行时将被用到。因为输入是静态的,你将总能看到结果相同。只有当你修改原始代码并重新编译时,你才能看到不同的输出。
动态编译器,例如Just-In-Time (JIT)编译器,把一种语言动态的转化为另一种,这意味着它们做这些时把代码被执行。JIT编译器让你收集或创建运行数据分析(通过插入性能计数的方式),用编译器决定,用手边的环境数据。动态的编译器可以在编译成语言的过程之中,实现更好的指令序列,把一系列的指令替换成更有效的,甚至消除多余的操作。随着时间的增长你将收集更多的代码配制数据,做更多更好的编译决定;整个过程就是我们通常称为的代码优化和重编译。
动态编译给了你可以根据行为去调整动态的变化的优势,或随着应用装载次数的增加催生的新的优化。这就是为什么动态编译器非常适合Java运行。值得注意的是,动态编译器请求外部数据结构,线程资源,CPU周期分析和优化。越深层次的优化,你将需要越多的资源。然而在大多数环境中,顶层对执行性能的提升帮助非常小——比你纯粹的解释要快5到10倍的性能。
分配会导致垃圾回收
分配在每一个线程基于每个“Java进程分配内存地址空间”,或者叫Java堆,或者直接叫堆。在Java世界中单线程分配在客户端应用程序中很常见。然而,单线程分配在企业应用和工作装载服务端变的没有任何益处,因为它并没有使用现在多核环境的并行优势。
并行应用设计同样迫使JVM保证在同一时间,多线程不会分配同一个地址空间。你可以通过在整个分配空间中放把锁来控制。但这种技术(通常叫做堆锁)很消耗性能,持有或排队线程会影响资源利用和应用优化的性能。多核系统好的一面是,它们创造了一个需求,为各种各样的新的方法在资源分配的同时去阻止单线程的瓶颈,和序列化。
一个常用的方法是把堆分成几部分,在对应用来说每个合式分区大小的地方——显然它们需要调优,分配率和对象大小对不同应用来说有显著的变化,同样线程的数量也不同。线程本地分配缓存(Thread Local Allocation Buffer,简写:TLAB),或者有时,线程本地空间(Thread Local Area,简写:TLA),是一个专门的分区,在其中线程不用声明一个全堆锁就可以*分配。当区域满的时候,堆就满了,表示堆上的空闲空间不够用来放对象的,需要分配空间。当堆满的时候,垃圾回收就会开始。
碎片
使用TLABs捕获异常,是把堆碎片化来降低内存效率。如果一个应用在要分配对象时正巧不能增加或者不能完全分配一个TLAB空间,这将会有空间太小而不能生成新对象的风险。这样的空闲空间被当做“碎片”。如果应用程序一直保持对象的引用,然后再用剩下的空间分配,最后这些空间会在很长一段时间内空闲。
碎片就是当碎片被分散在堆中的时候——通过一小段不用的内存空间来浪费堆空间。为你的应用分配 “错误的”TLAB空间(关于对象的大小、混合对象的大小和引用持有率)是导致堆内碎片增多的原因。在随着应用的运行,碎片的数量会增加在堆中占有的空间。碎片导致性能下降,系统不能给新应用分配足够的线程和对象。垃圾回收器在随后会很难阻止out-of-memory异常。
TLAB浪费在工作中产生。一种方法可以完全或暂时避免碎片,那就是在每次基础操作时优化TLAB空间。这种方法典型的作法是应用只要有分配行为,就需要重新调优。通过复杂的JVM算法可以实现,另一种方法是组织堆分区实现更有效的内存分配。例如,JVM可以实现free-lists,它是连接起一串特定大小的空闲内存块。一个连续的空闲内存块和另一个相同大小的连续内存块相连,这样会创建少量的链表,每个都有自己的边界。在有些情况下free-lists导致更好的合适内存分配。线程可以对象分配在一个差不多大小的块中,这样比你只依靠固定大小的TLAB,潜在的产生少的碎片。
GC琐事
有一些早期的垃圾收集器拥有多个老年代,但是当超过两个老年代的时候会导致开销超过价值。另一种优化分配减少碎片的方法,就是创造所谓的新生代,这是一个专门用于分配新对象的专用堆空间。剩余的堆会成为所谓的老年代。老年代是用来分配长时间存在的对象的,被假定会存在很长时间的对象包括不被垃圾收集的对象或者大对象。为了更好的理解这种分配的方法,我们需要讲一些垃圾收集的知识。
垃圾回收和应用性能
垃圾回收是JVM的垃圾回收器去释放没有引用的被占据的堆内存。当第一次触发垃圾收集时,所有的对象引用还被保存着,被以前的引用占据的空间被释放或重新分配。当所有可回收的内存被收集后,空间等待被抓取和再次分配给新对象。
垃圾回收器永远都不能重声明一个引用对象,这样做会破坏JVM的标准规范。这个规则的异常是一个可以捕获的soft或weak引用 ,如果垃圾收集器将要将近耗尽内存。我强烈推荐你尽量避免weak引用,然而,因为Java规范的模糊导致了错误的解释和使用的错误。更何况,Java是被设计为动态内存管理,因为你不需要考虑什么时候和什么地方释放内存。
垃圾收集器的一个挑战是在分配内存时,需要尽量不影响运行着的应用。如果你不尽量垃圾收集,你的应用将耗近内存;如果你收集的太频繁,你将损失吞吐量和响应时间,这将对运行的应用产生坏的影响。
GC算法
有许多不同的垃圾回收算法。稍后,在这个系列里将深入讨论几点。在最高层,垃圾收集两个最主要的方法是引用计数和跟踪收集器。
引用计数收集器会跟踪一个对象指向多少个引用。当一个对象的引用为0时,内存将被立即回收,这是这种方法的优点之一。引用计数方法的难点在于环形数据结构和保持所有的引用即时更新。
跟踪收集器对仍在引用的对象标记,用已经标记的对象,反复的跟随和标记所有的引用对象。当所有的仍然引用的对象被标记为“live”时,所有的不被标记的空间将被回收。这种方法管理环形数据结构,但是在很多情况下收集器应该等待直到所有标记完成,在重新回收不被引用的内存之前。
有不种的途径来被上面的方法。最著名的算法是 marking 或copying 算法, parallel 或 concurrent算法。我将在稍后的文章中讨论这些。
通常来说垃圾回收的意义是致力于在堆中给新对象和老对象分配地址空间。其中“老对象”是指在许多垃圾回收后幸存的对象。用新生代来给新对象分配,老年代给老对象,这样能通过快速回收占据内存的短时间对象来减少碎片,同样通过把长时间存在的对象聚合在一起,并把它们放到老年代地址空间中。所有这些在长时间对象和保存堆内存不碎片化之间减少了碎片。新生代的一个积极作用是延迟了需要花费更大代价回收老年代对象的时间,你可以为短暂的对象重复利用相同的空间。(老空间的收集会花费更多,是因为长时间存在的对象们,会包含更多的引用,需要更多的遍历。)
最后值的一提的算法是compaction,这是管理内存碎片的方法。Compaction基本来说就是把对象移动到一起,从来释放更大的连续内存空间。如果你熟悉磁盘碎片和处理它的工具,你会发现compaction跟它很像,不同的是这个运行在Java堆内存中。我将在系列中详细讨论compaction。
总结:回顾和重点
JVM允许可移植(一次编程,到处运行)和动态的内存管理,所有Java平台的主要特性,都是它受欢迎和提高生产力的原因。
在第一篇JVM性能优化系统的文章中我解释了一个编译器怎么把字节码转化为目标平台的指令语言的,并帮助动态的优化Java程序的执行。不同的应用需要不同的编译器。
我同样简述了内存分配和垃圾收集,和这些怎么与Java应用性能相关的。基本上,你越快的填满堆和频繁的触发垃圾收集,Java应用的占有率越高。垃圾收集器的一个挑战是在分配内存时,需要尽量不影响运行着的应用,但要在应用耗尽内存之前。在以后的文章中我们会更详细的讨论传统的和新的垃圾回收和JVM性能优化。
Java平台的垃圾收集机制显著提高了开发者的效率,但是一个实现糟糕的垃圾收集器可能过多地消耗应用程序的资源。在Java虚拟机性能优化系列的第三部分,Eva Andreasson向Java初学者介绍了Java平台的内存模型和垃圾收集机制。她解释了为什么碎片化(而不是垃圾收集)是Java应用程序性能的主要问题所在,以及为什么分代垃圾收集和压缩是目前处理Java应用程序碎片化的主要办法(但不是最有新意的)。
垃圾收集(GC)的目的是释放那些不再被任何活动对象引用的Java对象所占用的内存,它是Java虚拟机动态内存管理机制的核心部分。在一个典型的垃圾收集周期里,所有仍然被引用的对象(因此是可达的)都将被保留,而那些不再被引用的对象将被释放、其所占用的空间将被回收用来分配给新的对象。
为了理解垃圾收集机制和各种垃圾收集算法,首先需要知道关于Java平台内存模型的一些知识。
垃圾收集和Java平台内存模型
当用命令行启动一个Java程序并指定启动参数-Xmx时(例如:java -Xmx:2g MyApp),指定大小的内存就分配给了Java进程,这就是所谓的Java堆。这个专用的内存地址空间用于存储Java程序(有时是JVM)所创建的对象。随着应用程序运行并不断为新对象分配内存,Java堆(即专门的内存地址空间)就会慢慢被填满。
最终Java堆会被填满,也就是说内存分配线程找不到一块足够大的连续空间为新对象分配内存,这时JVM决定要通知垃圾收集器并启动垃圾收集。垃圾收集也可以通过在程序中调用System.gc()来触发,但使用System.gc()并不能确保垃圾收集一定被执行。在任何一次垃圾收集之前,垃圾收集机制都会首先判断执行垃圾收集是否安全,当应用程序的所有活动线程都处于安全点时就可以开始执行一次垃圾收集。例如:当正在为对象分配内存时就不能执行垃圾收集,或者是正在优化CPU指令时也不能执行垃圾收集,因为这样很可能会丢失上下文从而搞错最终结果。
垃圾收集器不能回收任何一个有活动引用的对象,那将破坏Java虚拟机规范。也无需立即回收死对象,因为死对象最终还是会被后续的垃圾收集所回收。尽管有很多种垃圾收集的实现方法,但以上两点对所有垃圾收集实现都是相同的。垃圾收集真正的挑战在于如何识别对象是否存活以及如何在尽量不影响应用程序的情况下回收内存,因此垃圾收集器的目标有以下两个:
1.迅速释放没有引用的内存以满足应用程序的内存分配需要从而避免内存溢出。
2.回收内存时对正在运行的应用程序性能(延迟和吞吐量)的影响最小化。
两类垃圾收集
在本系列的第一篇中,我介绍了两种垃圾收集的方法,即引用计数和跟踪收集。接下来我们进一步探讨这两种方法,并介绍一些在生产环境中使用的跟踪收集算法。
引用计数收集器
引用计数收集器记录了指向每个Java对象的引用数,一旦指向某个对象的引用数为0,那么就可以立即回收该对象。这种即时性是引用计数收集器的主要优点,而且维护那些没有引用指向的内存几乎没有开销,不过为每个对象记录最新的引用数却是代价高昂的。
引用计数收集器的主要难点在于如何保证引用计数的准确性,另外一个众所周知的难点是如何处理循环引用的情况。如果两个对象彼此引用,而且没有被其他活动对象所引用,那么这两个对象的内存永远都不会被回收,因为指向这两个对象的引用数都不为0。对循环引用结构的内存回收需要major analysis(译者注:Java堆上的全局分析),这将增加算法的复杂性,从而也给应用程序带来额外的开销。
跟踪收集器
跟踪收集器基于这样的假设:所有的活动对象都可以通过一个已知的初始活动对象集合的迭代引用(引用以及引用的引用)找到。可以通过分析寄存器、全局对象和栈帧来确定初始活动对象集合(也被称为根对象)。确定了初始对象集合后,跟踪收集器顺着这些对象的引用关系依次将引用所指向的对象标注为活动对象,就这样已知的活动对象集合不断扩大。这一过程持续进行直到所有被引用的对象都被标注为活动对象,而那些没有被标注过的对象的内存就被回收。
跟踪收集器不同于引用计数收集器主要在于它可以处理循环引用结构。多数的跟踪收集器都是在标记阶段发现那些循环引用结构中的无引用对象。
跟踪收集器是动态语言中最常用的内存管理方式,也是目前Java中最常见的方式,同时在生产环境中也被验证了很多年。下面我将从实现跟踪收集的一些算法开始介绍跟踪收集器。
跟踪收集算法
复制垃圾收集器和标记-清除垃圾收集器并不是什么新东西,但它们仍然是目前实现跟踪收集的两种最常见算法。
复制垃圾收集器
传统的复制垃圾收集器使用堆中的两个地址空间(即from空间和to空间),当执行垃圾收集时from空间的活动对象被复制到to空间,当from空间的所有活动对象都被移出(译者注:复制到to空间或者老年代)后,就可以回收整个from空间了,当再次开始分配空间时将首先使用to空间(译者注:即上一轮的to空间作为新一轮的from空间)。
在该算法的早期实现中,from空间和to空间不断变换位置,也就是说当to空间满了,触发了垃圾收集,to空间就成为了from空间,如图1所示。
图1 传统的复制垃圾收集顺序
最新的复制算法允许堆内任意地址空间作为to空间和from空间。这样它们不需要彼此交换位置,而只是逻辑上变换了位置。
复制收集器的优点是在to空间被复制的对象紧凑排列,完全没有碎片。而碎片化正是其他垃圾收集器所面临的一个共同问题,也是我之后主要讨论的问题。
复制收集器的缺陷
通常来说复制收集器是stop-the-world的,也就是说只要垃圾收集在进行,应用程序就无法执行。对于这种实现来说,你需要复制的东西越多,对应用程序性能的影响就越大。对于那些响应时间敏感的应用来说这是个缺点。使用复制收集器时,你还要考虑最坏的场景(即from空间中的所有对象都是活动对象),这时你需要为移动这些活动对象准备足够大的空间,因此to空间必须大到可以装下from空间的所有对象。由于这个限制,复制算法的内存利用率稍有不足(译者注:在最坏的情况下to空间需要和from空间大小相同,所以只有50%的利用率)。
标记-清除收集器
部署在企业生产环境上的大多数商业JVM采用的都是标记-清除(或者叫标记)收集器,因为它没有复制垃圾收集器对应用程序性能的影响问题。其中最有名的标记收集器包括CMS、G1、GenPar和DeterministicGC。
标记-清除收集器跟踪对象引用,并且用标志位将每个找到的对象标记为live。这个标志位通常对应堆上的一个地址或是一组地址。例如:活动位可以是对象头的一个位(译者注:bit)或是一个位向量、一个位图。
在标记完成之后就进入了清除阶段。清除阶段通常都会再次遍历堆(不仅是标记为live的对象,而是整个堆),用来定位那些没有标记的连续内存地址空间(没有被标记的内存就是空闲并可回收的),然后收集器将它们整理为空闲列表。垃圾收集器可以有多个空闲列表(通常按照内存块的大小划分),有些JVM(例如:JRockit Real Time)的收集器甚至基于应用程序的性能分析和对象大小的统计结果来动态划分空闲列表。
清除阶段过后,应用程序就可以再次分配内存了。从空闲列表中为新对象分配内存时,新分配的内存块需要符合新对象的大小,或是线程的平均对象大小,或是应用程序的TLAB大小。为新对象找到大小合适的内存块有助于优化内存和减少碎片。
标记-清除收集器的缺陷
标记阶段的执行时间依赖于堆中活动对象的数量,而清除阶段的执行时间依赖于堆的大小。因此对于堆设置较大并且堆中活动对象较多的情况,标记-清除算法会有一定的暂停时间。
对于内存消耗很大的应用程序来说,你可以调整垃圾收集参数以适应各种应用程序的场景和需要。在很多情况下,这种调整至少推迟了标记阶段/清除阶段给应用程序或服务协议SLA(SLA这里指应用程序要达到的响应时间)带来的风险。但是调优仅仅对特定的负载和内存分配率有效,负载变化或是应用程序本身的修改都需要重新调优。
标记-清除收集器的实现
至少有两种已经在商业上验证的方法来实现标记-清除垃圾收集。一种是并行垃圾收集,另一种是并发(或者多数时间是并发)垃圾收集。
并行收集器
并行收集是指资源被垃圾收集线程并行使用。大多数并行收集的商业实现都是stop-the-world收集器,即所有的应用程序线程都暂停直到完成一次垃圾收集,因为垃圾收集器可以高效地使用资源,所以通常会在吞吐量的基准测试中得到高分,如SPECjbb。如果吞吐量对你的应用程序至关重要,那么并行垃圾收集器是一个很好的选择。
并行收集的主要代价(特别是对于生产环境)是应用程序线程在垃圾收集期间无法正常工作,就像复制收集器一样。因此那些对于响应时间敏感的应用程序使用并行收集器会有很大的影响。特别是在堆空间中有很多复杂的活动对象结构时,有很多的对象引用需要跟踪。(还记得吗标记-清除收集器回收内存的时间取决于跟踪活动对象集合的时间加上遍历整个堆的时间)对于并行方法来说,整个垃圾收集时间应用程序都会暂停。
并发收集器
并发垃圾收集器更适合那些对响应时间敏感的应用程序。并发意味着垃圾收集线程和应用程序线程并发执行。垃圾收集线程并不独占所有资源,因此需要决定何时开始一次垃圾收集,需要有足够的时间跟踪活动对象集合并在应用程序内存溢出前回收内存。如果垃圾收集没有及时完成,应用程序就会抛出内存溢出错误,另一方面又不希望垃圾收集执行时间太长因为那样会消耗应用程序的资源进而影响吞吐量。保持这种平衡是需要技巧的,因此在确定开始垃圾收集的时机以及选择垃圾收集优化的时机时都使用了启发式算法。
另一个难点在于确定何时可以安全执行一些操作(需要完整准确的堆快照的操作),例如:需要知道何时标记阶段完成,这样就可以进入清理阶段。对于stop-the-world的并行收集器来说这不成问题,因为世界已经暂停了(译者注:应用程序线程暂停,垃圾收集线程独占资源)。但对于并发收集器而言,从标记阶段立刻切换到清理阶段可能不安全。如果应用程序线程修改了一段内存,而这段内存已经被垃圾收集器跟踪并标注过了,这就可能产生了新的没有标注的引用。在一些并发收集实现中,这会使应用程序陷入长时间重复标注的循环,当应用程序需要这段内存时也无法获得空闲内存。
通过到目前为止的讨论我们知道有很多的垃圾收集器和垃圾收集算法,分别适合特定的应用程序类型和不同的负载。不仅是不同的算法,还有不同的算法实现。所以在指定垃圾收集器钱最好了解应用程序的需求以及自身特点。接下来我们将介绍Java平台内存模型的一些陷阱,这里陷阱的意思是,在动态变化的生产环境中Java程序员容易做出的一些使得应用程序性能变得更差的假设。
为什么调优无法代替垃圾收集
多数的Java程序员都知道如果要优化Java程序可以有很多选择。若干个可选的JVM、垃圾收集器和性能调优参数让开发者花费大量的时间在无休无尽的性能调优方面。这使有些人因此得出结论:垃圾收集是糟糕的,通过调优使垃圾收集较少发生或者持续时间较短是一个很好的变通办法,不过这样做是有风险的。
考虑一下针对具体应用程序的调优,多数的调优参数(例如内存分配率、对象大小、响应时间)都是基于当前测试的数据量对应用程序的内存分配率(译者注:或者其他参数)调整。最终可能造成以下两个结果:
1.在测试中通过的用例在生产环境中失败。
2.数据量的变化或者应用程序的变化要求重新调优。
调优是需要反复的,特别是并发垃圾收集器可能需要很多调优(尤其在生产环境中)。需要启发式方法来满足应用程序的需要。为了要满足最坏的情况,调优的结果可能是一个非常死板的配置,这也导致了大量的资源浪费。这种调优方法是一种堂吉诃德式的探索。事实上,你越是优化垃圾收集器来匹配特定的负载,越是远离了Java运行时的动态特性。毕竟有多少应用程序的负载是稳定的呢,你所预期的负载的可靠性又有多高呢?
那么如果你不将注意力放在调优上,能够做些什么来防止内存溢出错误和提高响应时间呢?首要的事情就是找到影响Java应用程序性能的主要因素。
碎片化
影响Java应用程序性能的因素不是垃圾收集器,而是碎片化以及垃圾收集器如何处理碎片化。所谓碎片化是这样一种状态:堆空间中有空闲可用的空间,但并没有足够大的连续内存空间,以至于无法为新对象分配内存。正如在第一篇中提到的,内存碎片要么是堆中残留的一段空间TLAB,要么是在长期存活对象中间被释放的小对象所占用的空间。
随着时间的推移和应用程序的运行,这些碎片就会遍布在堆中。在某些情况下,使用了静态化调优的参数可能会更糟,因为这些参数无法满足应用程序的动态需要。应用程序无法有效利用这些碎片化的空间。如果不做任何事情,那么将导致接连不断的垃圾收集,垃圾收集器尝试释放内存分配给新对象。在最坏的情况下,即使是接连不断的垃圾收集也无法释放更多的内存(碎片太多),然后JVM不得不抛出内存溢出的错误。你可以通过重启应用程序来解决碎片化,这样Java堆就有连续的内存空间可以分配给新对象。重启程序导致宕机,而且一段时间后Java堆将再次充满碎片,不得不再次重启。
内存溢出错误会挂起进程,日志显示垃圾收集器正在超负荷工作,这些都显示垃圾收集正试图释放内存,也表明堆中碎片很多。一些程序员会试图通过再次优化垃圾收集器来解决碎片化问题。但我认为应该寻找更有新意的办法解决这个问题。接下来的部分将重点讨论解决碎片化的两个办法:分代垃圾收集和压缩。
分代垃圾收集
你可能听过这样的理论:在生产环境中绝大多数对象的存活时间都很短。分代垃圾收集正是由这一理论衍生出的一种垃圾收集策略。在分代垃圾收集中,我们将堆分为不同的空间(或者叫做代),每个空间中保存着不同年龄的对象,所谓对象的年龄就是对象存活的垃圾收集周期数(也就是该对象多少个垃圾收集周期后仍然被引用)。
当新生代没有剩余空间可分配时,新生代的活动对象会被移动到老年代中(通常只有两个代。译者注:只有满足一定年龄的对象才会被移动到老年代),分代垃圾收集常常使用单向的复制收集器,一些更现代的JVM新生代中使用的是并行收集器,当然也可以为新生代和老年代分别实现不同的垃圾收集算法。如果你使用并行收集器或复制收集器,那么你的新生代收集器就是一个stop-the-world的收集器(参见之前的解释)。
老年代分配给那些从新生代移出的对象,这些对象要么是被引用很长一段时间,要么是被一些新生代中对象集合所引用。偶尔也有大对象直接被分配到了老年代,因为移动大对象的成本相对较高。
分代垃圾收集技术
在分代垃圾收集中,老年代运行垃圾收集的频率较低,而在新生代运行垃圾收集的频率较高,而我们也希望在新生代中垃圾收集周期更短。在极少的情况下,新生代的垃圾收集可能会比老年代的垃圾收集更频繁。如果你将新生代设置的太大时并且应用程序中的多数对象都存活较长时间,这种情况就可能会发生。在这种情况下,如果老年代设置的太小以至于无法容纳所有的长时间存活的对象,老年代的垃圾收集也会挣扎于释放空间给那些被移动进来的对象。不过通常来说分代垃圾收集可以使应用程序获得更好的性能。
划分出新生代的另一个好处是某种程度上解决了碎片化问题,或者说将最坏的情况推迟了。那些存活时间短的小对象本来可能产生碎片化问题,但都在新生代的垃圾收集中被清理了。由于存活时间长的对象被移到老年代时被更紧凑的分配空间,老年代也更加紧凑了。随着时间推移(如果你的应用运行时间足够长),老年代也会产生碎片化,这时需要运行一次或是几次完全垃圾收集,同时JVM也有可能抛出内存溢出错误。但是划分出新生代推迟了出现最坏情况的时间,这对于很多应用程序来说已经足够了。对于多数应用程序而言,它的确降低了stop-the-world垃圾收集的频率和内存溢出错误的机会。
优化分代垃圾收集
正如之前提到的,使用分代垃圾收集带来了重复的调优工作,例如调整新生代大小、提升率等。我无法针对具体应用运行时来强调怎样做取舍:选择固定的大小固然可以优化应用程序,但同时也减少了垃圾收集器应对动态变化的能力,而变化是不可避免的。
对于新生代首要原则就是在确保stop-the-world垃圾收集期间延迟时间前提下尽可能的加大,同时也要为那些长期存活的对象在堆中保留足够大的空间。下面是在调整分代垃圾收集器时要考虑的一些额外因素:
1.新生代中多数都是stop-the-world垃圾收集器,新生代设置的越大,相应的暂停时间就越长。因此对于那些受垃圾收集暂停时间影响大的应用程序来说,要仔细考虑将新生代设置为多大合适。
2.可以在不同的代上使用不同的垃圾收集算法。例如在新生代中使用并行垃圾收集,在老年代中使用并发垃圾收集。
3.当发现频繁的提升(译者注:从新生代移动到老年代)失败时说明老年代中碎片太多了,也就是说老年代中没有足够的空间来存放从新生代移出的对象。这时你可以调整一下提升率(即调整提升的年龄),或者确保老年代中的垃圾收集算法会进行压缩(将在下一段讨论)并调整压缩以适应应用程序的负载。也可以增加堆大小和各个代大小,但是这样更会进一步延长老年代上的暂停时间。要知道碎片化是无法避免的。
4.分代垃圾收集最适合这样的应用程序,他们有很多存活时间很短的小对象,很多对象在第一轮垃圾收集周期就被回收了。对于这种应用程序分代垃圾收集可以很好的减少碎片化,并将碎片化产生影响的时机推迟。
压缩
尽管分代垃圾收集延迟了出现碎片化和内存溢出错误的时间,然而压缩才是真正解决碎片化问题的唯一办法。压缩是指通过移动对象来释放连续内存块的垃圾收集策略,这样通过压缩为创建新对象释放了足够大的空间。
移动对象并更新对象引用是stop-the-world操作,会带来一定的消耗(有一种情况例外,将在本系列的下一篇中讨论)。存活的对象越多,压缩造成的暂停时间就越长。在剩余空间很少并且碎片化严重的情况下(这通常是因为程序运行了很长的时间),压缩存活对象较多的区域可能会有几秒种的暂停时间,而当接近内存溢出时,压缩整个堆甚至会花上几十秒的时间。
压缩的暂停时间取决于需要移动的内存大小和需要更新的引用数量。统计分析表明堆越大,需要移动的活动对象和更新的引用数量就越多。每移动1GB到2GB活动对象的暂停时间大约是1秒钟,对于4GB大小的堆很可能有25%的活动对象,因此偶尔会有大约1秒的暂停。
压缩和应用程序内存墙
应用程序内存墙是指在垃圾收集产生的暂停(例如:压缩)前可以设置的堆大小。根据系统和应用的不同,大多数的Java应用程序内存墙都在4GB到20GB之间。这也是多数的企业应用都是部署在多个较小的JVM上,而不是少数较大的JVM上的原因。让我们考虑一下这个问题:有多少现代企业的Java应用程序设计、部署是根据JVM的压缩限制来定义的。在这种情况下,为了绕过整理堆碎片的暂停时间,我们接受了更耗费管理成本的多个实例部署方案。考虑到现在硬件的大容量存储能力和企业级Java应用对增加内存的需求,这就有点奇怪了。为什么为每个实例只设置了几个GB的内存。并发压缩将会打破内存墙,这也是我下一篇文章的主题。
总结
本文是一篇关于垃圾收集的介绍性文章,帮助你了解有关垃圾收集的概念和机制,并希望能够促使你进一步阅读相关文章。这里讨论的很多东西都已经存在了很久,在下一篇中将介绍一些新的概念。例如并发压缩,目前是由Azul‘s Zing JVM实现的。它是一项新兴的垃圾收集技术,甚至尝试重新定义Java内存模型,特别是在今天内存和处理能力都不断提高的情况下。
以下是我总结出的一些关于垃圾收集的要点:
1.不同的垃圾收集算法和实现适应不同的应用程序需要,跟踪垃圾收集器是商业Java虚拟机中使用的最多的垃圾收集器。
2.并行垃圾收集在执行垃圾收集时并行使用所有资源。它通常是一个stop-the-world垃圾收集器,因此有更高的吞吐量,但是应用程序的工作线程必须等待垃圾收集线程完成,这对应用程序的响应时间有一定影响。
3.并发垃圾收集在执行收集时,应用程序工作线程仍然在运行。并发垃圾收集器需要在应用程序需要内存之前完成垃圾收集。
4.分代垃圾收集有助于延迟碎片化,但无法消除碎片化。分代垃圾收集将堆分为两个空间,其中一个空间存放新对象,另一个空间存放老对象。分代垃圾收集适合有很多存活时间很短的小对象的应用程序。
5.压缩是解决碎片化的唯一方法。多数的垃圾收集器都是以stop-the-world的方式执行压缩的,程序运行时间越久,对象引用越是复杂,对象的大小越是分布不均匀都将导致压缩时间延长。堆的大小也会影响压缩时间,因为可能有更多的活动对象和引用需要更新。
6.调优有助于延迟内存溢出错误。但是过度调优的结果是僵化的配置。在通过试错的方式开始调优之前,要确保清楚生产环境的负载、应用程序的对象类型以及对象引用的特性。过于僵化的配置很可能无法应付动态负载,因此在设置非动态值时一定要了解这样做的后果。
深入探讨C4(Concurrent Continuously Compacting Collector)垃圾收集算法