【Java】Java性能及垃圾回收

【以下内容出自《Java编程思想》一书】

1、性能

Java语言特别强调准确性,但可靠的行为要以性能作为代价。这一特点反映在自动收集垃圾、严格的运行期检查、完整的字节码检查以及保守的运行期同步等等方面。对于一个解释型的虚拟机来说,由于目前有大量平台可供选择,所以进一步阻碍了性能的发挥。

下面是各种运算的执行时间,结果为实际时间除以“本地赋值”的时间。

  • 本地赋值 i=n; time=1.0
  • 实例赋值 this.i=n; time=1.2
  • int增值 i++; time=1.5
  • byte增值 b++; time=2.0
  • short增值 s++; time=2.0
  • float增值 f++; time=2.0
  • double增值 d++; time=2.0
  • 空循环 while(true) n++; time=2.0
  • 三元表达式 (x<0) ? -x : x; time=2.2
  • 算术调用 Math.abs(x); time=2.5
  • 数组赋值 a[0]=n; time=2.7
  • long增值 l++; time=3.5
  • 方法调用 function(); time=5.9
  • throw或catch异常 try{throw e;}或catch{e}; time=320
  • 同步方法调用 syncMethod(); time=570
  • 新建对象 new Object(); time=980
  • 新建数组 new int[10]; time=3100
    从上面的结果中可以看出,新建对象和数组会造成最沉重的开销,同步会造成比较沉重的开销,而一次不同步的方法调用会造成适度的开销。

下面是关于性能的一些建议:

  1. 将接口修改成抽象类,因为接口的多个继承会妨碍性能的优化。
  2. 将非本地或数组循环变量修改成本地循环变量,因为一次实例整数赋值的时间是本地整数赋值时间的1.2倍,但数组赋值的时间是本地整数赋值的2.7倍。
  3. 将链接列表修改成保存丢弃的链接项目,或将列表替换成一个循环数组,因为每新建一个对象,都相当于本地赋值980次。
  4. 将2的任意幂次修改成移位运算,因为可以使用更快的硬件指令。
  5. 字串的开销:字串连接运算符+看似简单,但实际需要消耗大量系统资源。编译器可高效地连接字串,但变量字串确要求可观的处理事件。例如,假设s和t是字串变量,System.out.println("heading"+s+"trailer"+t);,这个语句要求新建一个StringBuffer,追加自变量,然后用toString()将结果转换会一个字串。因此,无论磁盘空间还是处理器事件,都会受到严重消耗。若准备追加多个字串,则可考虑直接使用一个字串缓冲,特别是能在一个循环里重复利用它的时候。通过在每个循环里禁止新建一个字串缓冲,可节省980单位的对象创建时间。利用subString以及其它字串方法,可进一步地改善性能。如果可行,字符数组的速度甚至能够更快。也要注意由于同步的关系,所以StringTokenizer会造成较大的开销。
  6. 同步:在JDK解释器中,调用同步方法通常会比调用不同步方法慢10倍。经JIT编译器处理后,这一性能上的差距提升到50到100倍。所以要尽可能避免使用同步方法,若不能避免,方法的同步也要比代码块的同步稍快一些。
  7. 重复利用对象:要花很长时间来新建一个对象,因此,最明智的做法是保存和更新老对象的字段,而不是创建一个新对象。例如,不要在自己的paint()方法中新建一个Font对象,相反,应将其声明成实例对象,再初始化一次,在这以后,可在paint()里需要的时候随时更新。
  8. 异常:只有在不正常的情况下,才应放弃异常处理模块。什么才叫不正常呢?这通常是指程序遇到了问题,而这一般是不愿见到的,所以性能不再成为优先考虑的目标。进行优化时,将小的try-catch块合并到一起,由于这些块将代码分割成小的、各自独立的片段,所以会妨碍编译器进行优化。另一方面,若果份热衷于删除异常处理模块,也可能造成代码健壮程度的下降。
  9. 散列处理:首先,HashTable类需要类型转换以及特别消耗系统资源的同步处理,约570单位的赋值时间。其次,早期的JDK库不能自动决定最佳的表格尺寸。最后,散列函数应针对实际使用项(key)的特征设计。考虑到所有这些原因,我们可特别设计一个散了类,令其与特定的应用程序配合,从而改善常规散列表的性能。HashMap具有更大的灵活性,而且不会自动同步。
  10. 方法内嵌:只有在方法属于final、private或static的情况下,Java编译器才能内嵌这个方法。而且某些情况下,还要求他绝对不可以有局部变量。若代码花大量时间调用一个不含上述任何属性的方法,那么请考虑为其编写一个final版本。
  11. IO:应尽可能使用缓冲,否则,最终也许就是一次仅输入输出一个字节的恶果。IO类采用了大量的同步措施,所以若使用像readFully()这样的一个“大批量”调用,然后由自己解释数据,就可以获得更佳的性能。ReaderWriter类已针对性能进行了优化。
  12. 类型转换和实例:类型转换会耗去2到200个单位的赋值时间。开销更大的甚至要求继承结构,其它高代价的操作会损失和恢复更低层结构的能力。
  13. 图形:利用剪切技术,减少在repaint()中的工作量。倍增缓冲区,提高接收速度。同时利用图形压缩技术,缩短下载时间。请急着使用最贴切的命令,例如,为根据一系列点画一个多边形,和drawLine()相比,drawPolygon()的速度要快得多,如必须花一条单像素粗细的直线,drawLine(x,y,x,y)的速度比fillRect(x,y,1,1)快。
  14. 使用API类:尽量使用来自Java API的类,因为它们本身已针对机器的性能进行了优化。这是用Java难于达到的,比如在复制任意长度的一个数组时,arrayCopy()比使用循环的速度快得多。
  15. 替换API类:有些时候,API类提供了比我们希望更多的功能,相应的执行时间也会增加。因此,可定做特别的版本,让它做更少的事情,但可更快地运行。例如,假定一个应用程序需要一个容器来保存大量数组,为加快执行速度,可将原来的Vector替换成更快的动态对象数组。
  16. 将重复的常数计算移至关键循环之外,比如计算固定长度缓冲区的buffer.length
  17. static final常数有助于编译器优化程序。
  18. 实现固定长度的循环。
  19. 使用javac的优化选项-O。它通过内嵌static、final以及private方法,从而优化编译过的代码。注意类的长度可能会增加,JIT编译器会动态加速代码。
  20. 尽可能地将计数减至0,这使用了一个特殊的JVM字节码。

2、垃圾收集

  • C++将自己的主要精力放在编译期间“静态”发生的所有事情上,所以程序的运行期版本非常短小和快速。C++也直接建立在C模型的基础上(主要为了向后兼容),但有时仅仅由于它在C中能按特定的方式工作,所以也是 C++中最方便的一种方法。最重要的一种情况是CC++对内存的管理方式,它是某些人觉得Java速度肯定慢的重要依据。
  • Java中,所有对象都必须在内存堆”里创建。而在C++中,对象是在堆栈中创建的,这样可达到更快的速度。因为当我们进入一个特定的作用域时,堆栈指针会向下移动一个单位,为那个作用域内创建的以堆栈为基础的所有对象分配存储空间,而当我们离开作用域的时候(调用完毕所有局部构建器后),堆栈指针会向上移动一个单位。然而,在C++里创建“内存堆”(Heap)对象通常会慢得多,因为它建立在C的内存堆基础上。这种内存堆实际是一个大的内存池,要求必须进行再循环(再生)。在C++里调用delete以后,释放的内存会在堆里留下一个洞,所以再调用new的时候,存储分配机制必须进行某种形式的搜索,使对象的存储与堆内任何现成的洞相配,否则就会很快用光堆的存储空间。之所以内存堆的分配会在C++里对性能造成如此重大的性能影响,对可用内存的搜索正是一个重要的原因。所以创建基于堆栈的对象要快得多。
  • 同样地,由于C++如此多的工作都在编译期间进行,所以必须考虑这方面的因素。但在Java的某些地方,事情的发生却要显得“动态”得多,它会改变模型。创建对象的时候,垃圾收集器的使用对于提高对象创建的速度产生了显著的影响。从表面上看,这种说法似乎有些奇怪,存储空间的释放会对存储空间的分配造成影响,但它正是JVM采取的重要手段之一,这意味着在Java中为堆对象分配存储空间几乎能达到与C++中在堆栈里创建存储空间一样快的速度。
  • 可将C++的堆(以及更慢的Java堆)想象成一个庭院,每个对象都拥有自己的一块地皮。在以后的某个时间,这种“不动产”会被抛弃,而且必须再生。但在某些JVM里,Java堆的工作方式却是颇有不同的。它更像一条传送带,每次分配了一个新对象后,都会朝前移动。这意味着对象存储空间的分配可以达到非常快的速度。“堆指针”简单地向前移至处女地,所以它与C++的堆栈分配方式几乎是完全相同的(当然,在数据记录上会多花一些开销,但要比搜索存储空间快多了)。
  • 现在,大家可能注意到了堆事实并非一条传送带。如按那种方式对待它,最终就要求进行大量的页交换(这对性能的发挥会产生巨大干扰),这样终究会用光内存,出现内存分页错误。所以这儿必须采取一个技巧,那就是著名的“垃圾收集器”。它在收集“垃圾”的同时,也负责压缩堆里的所有对象,将“堆指针”移至尽可能靠近传送带开头的地方,远离发生(内存)分页错误的地点。垃圾收集器会重新安排所有东西,使其成为一个高速、无限自由的堆模型,同时游刃有余地分配存储空间。
  • 为真正掌握它的工作原理,我们首先需要理解不同垃圾收集器(GC)采取的工作方案。一种简单、但速度较慢的GC技术是引用计数。这意味着每个对象都包含了一个引用计数器。每当一个句柄同一个对象连接起来时,引用计数器就会增值。每当一个句柄超出自己的作用域,或者设为null时,引用计数就会减值。这样一来,只要程序处于运行状态,就需要连续进行引用计数管理,尽管这种管理本身的开销比较少。垃圾收集器会在整个对象列表中移动巡视,一旦它发现其中一个引用计数成为0,就释放它占据的存储空间。但这样做也有一个缺点,若对象相互之间进行循环引用,那么即使引用计数不是0,仍有可能属于应收掉的“垃圾”。为了找出这种自引用的组,要求垃圾收集器进行大量额外的工作。引用计数属于垃圾收集的一种类型,但它看起来并不适合在所有JVM方案中采用。
  • 在速度更快的方案里,垃圾收集并不建立在引用计数的基础上。相反,它们基于这样一个原理,所有非死锁的对象最终都肯定能回溯至一个句柄,该句柄要么存在于堆栈中,要么存在于静态存储空间。这个回溯链可能经历了几层对象。所以,如果从堆栈和静态存储区域开始,并经历所有句柄,就能找出所有活动的对象。对于自己找到的每个句柄,都必须跟踪到它指向的那个对象,然后跟随那个对象中的所有句柄,“跟踪追击”到它们指向的对象…等等,直到遍历了从堆栈或静态存储区域中的句柄发起的整个链接网路为止。中途移经的每个对象都必须仍处于活动状态。注意对于那些特殊的自引用组,并不会出现前述的问题。由于它们根本找不到,所以会自动当作垃圾处理。
  • 在这里阐述的方法中,JVM采用一种“自适应”的垃圾收集方案。对于它找到的那些活动对象,具体采取的操作取决于当前正在使用的是什么变体。其中一个变体是“停止和复制”。这意味着由于一些不久之后就会非常明显的原因,程序首先会停止运行(并非一种后台收集方案)。随后,已找到的每个活动对象都会从一个内存堆复制到另一个,留下所有的垃圾。除此以外,随着对象复制到新堆,它们会一个接一个地聚焦在一起。这样可使新堆显得更加紧凑(并使新的存储区域可以简单地抽离末尾,就象前面讲述的那样)。当然,将一个对象从一处挪到另一处时,指向那个对象的所有句柄(引用)都必须改变。对于那些通过跟踪内存堆的对象而获得的句柄,以及那些静态存储区域,都可以立即改变。但在“遍历”过程中,还有可能遇到指向这个对象的其他句柄。一旦发现这个问题,就当即进行修正(可想象一个散列表将老地址映射成新地址)。
  • 有两方面的问题使复制收集器显得效率低下。第一个问题是我们拥有两个堆,所有内存都在这两个独立的堆内来回移动,要求付出的管理量是实际需要的两倍。为解决这个问题,有些JVM根据需要分配内存堆,并将一个堆简单地复制到另一个。第二个问题是复制。随着程序变得越来越“健壮”,它几乎不产生或产生很少的垃圾。尽管如此,一个副本收集器仍会将所有内存从一处复制到另一处,这显得非常浪费。为避免这个问题,有些JVM能侦测是否没有产生新的垃圾,并随即改换另一种方案(这便是“自适应”的缘由)。
  • 另一种方案叫作“标记和清除”,Sun公司的JVM一直采用的都是这种方案。对于常规性的应用,标记和清除显得非常慢,但一旦知道自己不产生垃圾,或者只产生很少的垃圾,它的速度就会非常快。标记和清除采用相同的逻辑,从堆栈和静态存储区域开始,并跟踪所有句柄,寻找活动对象。然而,每次发现一个活动对象的时候,就会设置一个标记,为那个对象作上“记号”。但此时尚不收集那个对象。只有在标记过程结束,清除过程才正式开始。在清除过程中,死锁的对象会被释放,然而,不会进行任何形式的复制,所以假若收集器决定压缩一个断续的内存堆,它通过移动周围的对象来实现。
  • “停止和复制”向我们表明这种类型的垃圾收集并不是在后台进行的,相反,一旦发生垃圾收集,程序就会停止运行。在Sun公司的文档库中,可发现许多地方都将垃圾收集定义成一种低优先级的后台进程,但它只是一种理论上的实验,实际根本不能工作。在实际应用中,Sun的垃圾收集器会在内存减少时运行。除此以外,“标记和清除”也要求程序停止运行。正如早先指出的那样,在这里介绍的JVM中,内存是按大块分配的。若分配一个大块头对象,它会获得自己的内存块。严格的“停止和复制”要求在释放旧堆之前,将每个活动的对象从源堆复制到一个新堆,此时会涉及大量的内存转换工作。通过内存块,垃圾收集器通常可利用死块复制对象,就像它进行收集时那样。每个块都有一个生成计数,用于跟踪它是否依然“存活”。通常,只有自上次垃圾收集以来创建的块才会得到压缩,对于其他所有块,如果已从其他某些地方进行了引用,那么生成计数都会溢出。这是许多短期的、临时的对象经常遇到的情况。会周期性地进行一次完整清除工作,大块头的对象仍未复制(只是让它们的生成计数溢出),而那些包含了小对象的块会进行复制和压缩。JVM会监视垃圾收集器的效率,如果由于所有对象都属于长期对象,造成垃圾收集成为浪费时间的一个过程,就会切换到“标记和清除”方案。类似地,JVM会跟踪监视成功的“标记与清除”工作,若内存堆变得越来越“散乱”,就会换回“停止和复制”方案。“自定义”的说法就是从这种行为来的,我们将其最后总结为,“根据情况,自动转换停止和复制/标记和清除这两种模式”。
  • JVM还采用了其他许多加速方案。其中一个特别重要的涉及装载器以及JIT编译器。若必须装载一个类(通
    常是我们首次想创建那个类的一个对象时),会找到.class文件,并将那个类的字节码送入内存。此时,一个方法是用JIT编译所有代码,但这样做有两方面的缺点,它会花更多的时间,若与程序的运行时间综合考虑,编译时间还有可能更长;而且它增大了执行文件的长度(字节码比扩展过的JIT代码精简得多),这有可能造成内存页交换,从而显著放慢一个程序的执行速度。另一种替代办法是,除非确有必要,否则不经JIT编译。这样一来,那些根本不会执行的代码就可能永远得不到JIT的编译。
  • 由于JVM对浏览器来说是外置的,大家可能希望在使用浏览器的时候从一些JVM的速度提高中获得好处。但非常不幸,JVM目前不能与不同的浏览器进行沟通。为发挥一种特定JVM的潜力,要么使用内建了那种JVM的浏览器,要么只有运行独立的Java应用程序。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值