第三章 Java程序优化
本章主要介绍在代码层优化Java应用程序。与设计优化相比,程序级别的优化更具技巧性。高效而精炼的代码,正确的函数使用方法和优良的软件开发习惯也对应用程序的整体性能有着决定性的影响。可以说,代码层次的优化是每个程序员的必修课,它也自始至终贯穿整个软件的编码过程。
本章主要涉及到的知识点有:
□Java语言中的字符串优化,如何更高效地使用字符串;
□Vector,ArrayList等核心数据结构优化方法介绍;
□在Java语言中使用NIO提高I/O性能,摆脱最大堆束缚
□Java中的引用类型和使用方法
□一些有助于提高系统性能的技巧集锦
3.1 字符串优化处理
字符串是软件开发中最为重要的对象之一。通常,字符串对象或者其等价对象( 如char数组),在内存中总是占据了最大的空间块。因此如何高效地处理字符串,必将是提高系统性能的关键所在。
3.1.1 String对象及其特点
String对象是Java语言中重要的数据类型,但它并不是Java的基本数据类型。在C语言中,对字符串的处理通常的做法是使用char数组,但这种方式的弊端是显而易见的,数组本身无法封装字符串操作所需要的基本方法。而在Java语言中,String对象可以认为是char数组的延伸和进一步封装。
在Java语言中, Java的设计者对String对象进行了大量的优化,其主要表现在一下3个方面,同时这也是String对象的3个基本特点:
□不变性;
□针对常量池的优化;
□类的final定义。
1.不变性
不变性是指String对象一旦生成,则不能再对它进行改变。String的这个特性可以泛化成不变(immutable)模式,即一个对象的状态在对象创建之后就不再发生变化,不变模式的主要作用在于当一个对象需要被多线程共享,并且访问频繁时,可以省略同步和锁等待的时间,从而大幅提高系统性能。
2.针对常量池的优化
当两个String对象拥有相同的值时,它们只引用常量池中的同一个拷贝。当同一个字符串反复出现时,这个技术可以大幅度节省内存空间。
String str1 = "abc";
String str2 = "abc";
String str3 = new String("abc");
System.out.println(str1 == str2);// true
System.out.println(str1 == str3);// false
System.out.println(str1 == str3.intern());// true
以上代码显示str1和str2引用了相同的地址,但是srt3却重新开辟了内存空间。但即便如此,str3在常量池中的位置和str1是一样的,也就是说,虽然str3单独占了堆空间,但是它所指向的实体和str1完全一样。以上示例代码中,最后一行使用intern()方法,该方法返回了String对象在常量池中的引用。
3.类的final定义
final类型定义也是String对象的重要特点。作为final类的String对象在系统中不可能有任何子类,这是对系统安全性的保护。同时,对于JDK1.5版本之前的环境中,使用final定义,有助于帮助虚拟机寻找机会,内联所有的final方法,从来提高系统效率。但这种优化方法在JDK1.5以后,效果并不理想。
3.1.2 subString()方法的内存泄漏(1.7已修复, 略过)
3.1.2 字符串分割和查找
字符串分割和查找也是字符串处理最常用的方法之一。字符串分割将一个原始字符串根据某个分隔符切割成一组小字符串。
String val = "a;b,c:d";
System.out.println(Arrays.toString(val.split("[,|;|:]")));// [a, b, c, d]
以上代码便将原始字符串分割成四个字母, 并且它是支持正则表达式的。但是,就简单的字符串分割功能而言,它的性能表现却不尽人意。
1.最原始的字符串分割
使用一下代码生成一个String对象,并存放在orgStr变量中。
public static String getOrgStr() {
String orgStr = null;
StringBuffer sb = new StringBuffer();
for(int i=0; i<1000; i++) {
sb.append("i");
sb.append(";");
}
return sb.toString();
}
public static void splitStr(String str) {
long begin = System.currentTimeMillis();
for(int i=0; i<10000; i++) {
str.split(";");
}
System.out.println(System.currentTimeMillis() - begin);
}
2 使用更高效的StringTokenizer类分割字符串
StringTokenizer类是JDK中提供的专门用来处理字符串分割子串的工具类。
public static void splitByStringTokenizer(String str) {
long begin = System.currentTimeMillis();
int count = 0;
StringTokenizer st = new StringTokenizer(str, ";");
for(int i=0; i<10000; i++) {
while (st.hasMoreTokens()) {
count++;
st.nextToken();
}
}
System.out.println(count);
System.out.println(System.currentTimeMillis() - begin);
}
5.高效的chartAt方法
书中提及到的是尽量用chartAt来替换startWith和endWith,但是经过测试,发现问题不大, 并且考虑到代码的可读性, 个人建议使用startWith和endWith.
public static void main(String[] args) {
long begin = System.currentTimeMillis();
String str = "asdadasjdlakjdoqiue";
for(int i=0; i<10000000; i++) {
// startAndEndWith(str);// 7
chartAt(str);// 6
}
System.out.println("startAndEndWith: " + (System.currentTimeMillis() - begin));
}
public static void startAndEndWith(String str) {
str.startsWith("abc");
str.endsWith("abc");
}
public static void chartAt(String str) {
int len = str.length();
boolean isS = str.charAt(0) == 'a' && str.charAt(1) == 'b' && str.charAt(2) == 'c';
boolean isE = str.charAt(len - 3) == 'a' && str.charAt(len - 2) == 'b'
&& str.charAt(len - 1) == 'c';
}
3.1.4 StringBuffer和StringBuilder
由于String对象是不可变对象,因此,在需要对字符串进行修改操作为,String总是会生成新的对象,所以,其性能比较差。因此,JDK专门提供了创建和修改字符串的工具,这就是StringBuffer和StringBuilder类。
3构建超大的String对象
由以上两小节可知,在代码实现中直接对String对象做的累加操作会在编译时被优化,因此性能比理论上要好很多。但是,仍然建议在代码实现中,显示地使用StringBuffer和StringBuilder对象来提升程序性能,而不是太过于依赖编译器对程序进行优化。
public static void main(String[] args) {
long begin = System.currentTimeMillis();
// stringPlus();// 20898ms
// stringConcat();// 6783ms
stringBuilderAppend();// 31ms
stringBufferAppend();// 20ms
System.out.println("time is: " + (System.currentTimeMillis() - begin));
}
public static void stringPlus(){
String str = "";
for (int i=0; i<100000; i++) {
str = str + i;
}
}
public static void stringConcat(){
String str = "";
for (int i=0; i<100000; i++) {
str = str.concat(String.valueOf(i));
}
}
public static void stringBuilderAppend(){
StringBuilder sb = new StringBuilder();
for (int i=0; i<100000; i++) {
sb.append(i);
}
}
public static void stringBufferAppend(){
StringBuffer sb = new StringBuffer();
for (int i=0; i<100000; i++) {
sb.append(i);
}
}
由上代码可以得知,在需要对字符串高频率修改的时候,推荐使用sb, 原因是1代码的可读性,2是代码的性能有量级的提升。
4.StringBuffer和StringBuilder的选择
StringBuffer和StringBuilder是一对孪生兄弟,他们都实现了AbstractStringBuilder抽象类,拥有几乎相同的对外接口,两者最大的不同在于StringBuffer对几乎所有的方法都做了同步,而StringBuilder并没有做同步,所以在性能上来说, StringBuilder好一些,但是它的缺点就是无法保证线程安全,不能在多线程系统中使用。
△ 注意:在无需考虑线程安全的情况下使用性能较好的StringBuffer,但若系统有线程安全要求,则只能选择StringBuilder。
5.容量参数
无论是StringBuffer或者StringBuilder,在初始化的时候都可以设置一个容量参数,在不指定容量参数的情况下, 默认是16个字节。
/**
* Constructs a string builder with no characters in it and an
* initial capacity of 16 characters.
*/
public StringBuilder() {
super(16);
}
/**
* Creates an AbstractStringBuilder of the specified capacity.
*/
AbstractStringBuilder(int capacity) {
value = new char[capacity];
}
因此参数制定了它的初始大小,在追加字符串时,如果需要容量超过了实际的char数据长度,则需要进行扩容。
private int newCapacity(int minCapacity) {
// overflow-conscious code
int newCapacity = (value.length << 1) + 2;// 长度翻倍
if (newCapacity - minCapacity < 0) {
newCapacity = minCapacity;
}
return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0)
? hugeCapacity(minCapacity)
: newCapacity;
}
private void ensureCapacityInternal(int minimumCapacity) {
// overflow-conscious code
if (minimumCapacity - value.length > 0) {
value = Arrays.copyOf(value,
newCapacity(minimumCapacity));
}
}
可以看到,当追加字符串的时候,如果发现长度超过初始值,变会进行扩容,同时新建一个数组把之前的字符数组放进这个新的数组里面去,因此对于大对象的扩容会涉及大量的内存复制操作。所以,如果能够预先评估StringBuilder的大小,能够有效地节省这些操作,从而提高系统的性能。
3.2 核心数据结构
为方便开发人员进行程序开发,JDK提供了一组主要的数据结构实现,如List,Map,Set等常用结构。这些数据结构都继承自java.util.Collection接口,并位于java.util包内。
3.2.1 List接口
List是重要的数据结构之一。ArrayList,LinkedList和Vector这三种List均来自AbstractList的实现。而AbstractList直接实现了List接口,并扩展自AbstractCollcetion。在这三种不同的实现中,ArrayList和Vector使用了数据实现,可以认为,ArrayList,LinkedList和Vector封装了对内部数据的操作。
△ 注意:ArrayList和Vector几乎使用了相同的算法,他们唯一的区别可以认为是对多线程的支持。ArrayList没有任何一个方法做线程同步,因此不是线程安全的。Vector中绝大部分方法都做了线程同步,是一种线程安全的实现。
1增加元素到列表尾端
在ArrayList中增加元素的代码如下
public boolean add(E e) {
ensureCapacityInternal(size + 1); // 确保内部数组有足够的空间,性能取决于该方法
elementData[size++] = e;// 将元素加入到数组的末尾,完成添加
return true;
}
ensureCapacityInternal的代码实现如下:
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);// 扩容到以前的1.5倍
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
可以看到,只要当前的容量足够大,add操作的效率是很高的。
只有当ArrayList对用量的需求超过当前数组的大小时,才需要进行扩容。扩容过程中,会进行大量的数据复制操作。而数组复制时,最终会调用System.arraycopy(),因此,add()操作的效率还是相当高的。
2.增加元素到列表任意位置
由于ArrayList时基于数组实现的,而数组是一块连续的内存空间,所以在ArrayList中间插入元素的时候,必然导致该元素后面的所有元素都需要重新排列,因为,其效率相对对比较低。代码如下:
public void add(int index, E element) {
rangeCheckForAdd(index);
ensureCapacityInternal(size + 1); // Increments modCount!!
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
elementData[index] = element;
size++;
}
大量的数组重组操作会导致性能低下。并且,插入元素越靠前,数组重组的开销也越大。尽可能地将元素插入到List的尾端附近,有助于提高该方法的性能。
而LinkedList此时就显示出了优势,它并不会因为插入的位置靠前还是靠后导致插入的方法的性能降低。
3.删除任意位置元素
对ArrayList来说,remove和add方法是雷同的。在任意位置移除元素后,都要进行数组重组。而在LinkedList的实现中,首先要通过循环找到要删除的元素。JDK对其进行了简单的优化,如果要删除的位置处于list的前端,则从前往后找,如果要删除的位置处于list的后端,那么就从后往前找。但是如果删除的位置处理list的中段,就只能遍历一半的数组了,在元素很多的情况下,是比较费时间的。
Node<E> node(int index) {
// assert isElementIndex(index);
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
4.容量参数
ArrayList的初始容量是10,由于它自动扩容导致数组重新排列的特性,当检测到添加的元素不够时必然会扩容导致性能问题,如果是要新建一个大容量的数组并且知道数组的长度时,最好是指定这个容量参数。
3.2.2 Map接口
Map接口是一种非常常用的数据结构。主要的实现类有HashMap,HashTable,TreeMap和LinkedHashMap,在HashTable中,还有properties类的实现。
这里首先关注的是HashMap和HashTable。首先HashTable的大部分方法做了同步,而HashMap没有,因此HashMap不是线程安全的。其实HashTable不允许key或者value使用null值,但是HashMap是可以的。第二,在内部算法上,它们对key的hash算法和hash值到内存索引的映射算法不同。两者性能相差不大。
由于HashMap使用的广泛性,现以HashMap为例,阐述它的实现机理。
1.HashMap的实现原理
简单地说,HashMap就是将key做hash算法,然后将hash值映射到内存地址,直接取得key所对应的数据。在HashMap中,底层数据结构使用的是数组,所谓的内存地址即数组的下标索引。HashMap的高性能需要保证以下几点:
□hash算法必须是高效的;
□hash值到内存地址(数组索引)的算法是快速的;
□根据内存地址(数组索引)可以直接取得对应的值。
首先来看第一点,hash算法的高效性。在HashMap中,hash算法有关的代码如下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
当取得key的hash值后,需要通过hash得到内存地址
2.hash冲突
如果需要存放在HashMap中的两个元素,经过hash计算后,发现对应在内存中的同一个地址。需要处理好这个问题,就需要深入去研究。
查看HashMap中的源码,发现是它的基本数据结构是Node。Node中的hash值是通过hash算法得到的,目的是均匀放置,那如果hashcode()方法有一个糟糕的实现,比如:
public int hashCode() {
return 1;
}
那么这两个Node都放置到了同一个数组(数组Node)的同一个位置。但是HashMap有自己的解决hash冲突的方法,因为它是第一个数组Node,当同时存在多个元素的hash计算的值指向同一个地址的时候,HashMap还是会将这两个Node放在同一个位置,但是Node它有自身的值,还有next值,put的时候会按照顺序排列
图中紫色区域是内存地址, 绿色区域就是Node,当有hash冲突值,多个值就会保存在node中。
在get(key)的时候,如果通过get方法获取到的Node只有一个值,那么直接返回,如果获取到的Node有多个值,那么就开始遍历去比较key是否相等,如果不相等就继续遍历,知道返回对应key的值为止。基于HashMap的这种实现机制,只要HashCode和hash方式实现的足够好,能够尽可能滴减少冲突的产生,那么对HashMap的操作几乎等于与数组的随机访问机制,具有很好的性能。但是如果hashCode和hash方法实现较差,在大量冲突产生的情况下,HashMap事实上流退化为几个链表,对HashMap的操作等价于遍历链表,此时性能很差。
3.容量参数
除hashCode()的实现外,影响HashMap的性能的还有它的容量参数。和ArrayList和Vector一样,这种基于数组的结构,不可避免地需要在数组空间不足时,进行扩容。而数组的重组相对而言较为耗时,因此对其做一定的了解有助于优化HashMap的性能。
/**
* Constructs an empty <tt>HashMap</tt> with the default initial capacity
* (16) and the default load factor (0.75).
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
查看源码得知,HashMap提供了两个参数(构造器方法里面),initialCapacity指定了HashMap的初始容量,loadFactor指定了负载因子。负载因子又叫做填充比,它是介于0~1之间的浮点数,它决定了HashMap在扩容之前,其内部数组的填充。
负载因子 = 元素个数/内部数组总大小
4.LinkedHashMap,有序的HashMap
总体来说,HashMap的性能表现非常不错,因此得到了广泛的应用。但是它的一个功能缺点就是它的无序性,被存入到HashMap中的元素,在遍历的时候,它的输出是无序的。
LinkedHashMap继承自HashMap,它具备了HashMap的高性能,但是它又在HashMap的基础上,在内部增加了一个链表,用于存放元素的顺序。LinkedHashMap可以提供两种类型的顺序,一直元素插入的顺序,而是最近访问的顺序(每当使用get()方法访问某一个元素时,该元素变被移动到链表的尾端)。可以通过以下构造函数指定排序行为:
/**
* Constructs an empty insertion-ordered <tt>LinkedHashMap</tt> instance
* with the specified initial capacity and load factor.
*
* @param initialCapacity the initial capacity
* @param loadFactor the load factor
* @throws IllegalArgumentException if the initial capacity is negative
* or the load factor is nonpositive
*/
public LinkedHashMap(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor);
accessOrder = false;// 默认按照插入顺序排列
}
public static void linkedHashMap() {
Map<String, String> map = new LinkedHashMap<>(16, 0.75F, true);
map.put("1", "aa");
map.put("2", "bb");
map.put("3", "cc");
map.put("4", "dd");
map.get("3");
map.forEach((k, v) -> {
System.out.println(v); // aa, bb, dd, cc
});
}
在内部实现中,LinkedHashMap通过继承HashMap.Entry类,实现了LinkedHashMap.Entry,增加了before和after属性以记录某一表项的前驱和后驱,并构成循环列表。
5.TreeMap-另一种Map实现
HashMap通过hash算法可以最快地进行put和get操作。TreeMap则提供了一种完全不同的Map实现。从功能上将,TreeMap有着比HashMap更强大的功能,它实现了SortedMap接口, 这意味着它可以对元素进行排序。然而TreeMap的性能却略微低于HashMap。但是如果在开发中,需要对元素进行排序,那么使用HashMap便无法实现这种功能。
TreeMap的内部实现是基于红黑树的。红黑树是一种平衡查找树,它的统计性能要优于平衡二叉树(AVL树)。它具有良好的最坏情况运行时间,可以在O(log n)时间内做查找,插入和删除,n表示树中元素的数目。红黑树的算法有些复杂。
3.2.3 Set接口
Set接口并没有在Collection接口之上增加额外的操作,Set集合中的元素时不能重复的。其中最为重要的是HashSet,LinkedHashSet,TreeSet的实现。如果想深入了解这些类是如何实现的,必须先回顾之前Map接口的详细介绍。因为这些Set的实现都是对应Map的一种封装而已。
3.4 引用类型
在java中提供了4个级别的引用,强引用,软引用,弱引用和虚引用。在这四个引用级别中,只有强引用FinaleReference类是包内课件的,其他3中引用均为public,可以在应用程序中直接使用。
3.4.1 强引用
Java中的引用,有点像C++的指针。通过引用,可以对堆中的对象进行操作。在某函数中,当创建了一个对象,该对象分配在堆中,通过这个对象的引用才能对这个对象操作:
StringBuffer sb = new StringBuffer("Hello World");
假设以上代码是在函数体内运行的,那么局部变量sb将被分配在栈中,而对象StringBuffer实例被分配在堆中。局部变量sb指向StringBuffer实例所在的堆空间,通过sb可以操作该实例,难么sb就是StringBuffer的引用。
StringBuffer st1 = sb;
sb所指向的对象也将被str1指向,通过在局部栈空间上回分配空间存放str1,此时该StringBuffer实例就有两个引用。对引用的"=="操作用于表示两个操作所指向的堆空间是否相同,不表示两个操作数所指向的对象是否相等。
如上例中的两个引用,都是强引用,强引用具备以下的特点:
□强引用可以直接访问目标对象;
□强引用所指向的对象在任何时候都不会被系统收回。JVM宁愿抛出OOM异常,也不回收强引用所指向的对象。
□强引用可导致内存泄漏
3.4.2 软引用
软引用是除了强引用外,最强的引用类型。可以通过java.lang.ref.SoftReference使用软引用。一个持有软引用的对象,不会被JVM很快回收,JVM会根据当前堆得使用情况来判断何时回收。当堆使用频率临近阈值时,才会去回收软引用的对象。只要有足够的内存,软引用便可能在内存中存活相当长的时间。因此,软引用可以用于实现对内存敏感的Cache。