第3章 Java程序优化

3.1 字符串优化处理

3.1.1 String对象及其特点。

在C语言中,对字符串的处理最通常的做法是使用char数组,但这种方式的弊端是数组本身无法封装字符串操作所需的基本方法;
在Java语言中,String对象可以认为是char数组的延伸和进一步封装。基本实现主要有3部分组成:char数组,offset偏移量,count长度;
(1)char数组:表示String的内容,它是String对象所表示字符串的超集;
(2)offset偏移量和count长度:String的真实内容需要有这两个值在char数组中进行定位和截取;

String对象3个基本特点:
(1)不变性:是指String对象一旦生成,则不能再对它进行改变。String的这个特性可以泛化成不变模式,即一个对象的状态在对象被创建之后就不再发生变化。不变模式的主要作用在于当一个对象需要被多线程共享,并且频繁访问时,可以省略同步和锁等待的时间,从而大幅提高系统性能。
(2)针对常量池优化:当两个String对象拥有相同的值时,它们只引用常量池中的同一个拷贝。当同一个字符串反复出现时,这个技术可以大幅节省内存空间。
(3)类的final定义:作为final类的String对象在系统中不可能有任何子类,这是对系统安全的防护。同时,对于JDK 1.5之前,使用final定义,有助于帮助虚拟机寻找机会,内联所有的final方法,从而提高效率。但这种优化方法在JDK 1.5之后,效果并不明显。

3.1.2 subString的内存泄漏

subString方法源码如下

public String substring(int beginIndex, int endIndex) {
        if (beginIndex < 0) {
            throw new StringIndexOutOfBoundsException(beginIndex);
        }
        if (endIndex > value.length) {
            throw new StringIndexOutOfBoundsException(endIndex);
        }
        int subLen = endIndex - beginIndex;
        if (subLen < 0) {
            throw new StringIndexOutOfBoundsException(subLen);
        }
        return ((beginIndex == 0) && (endIndex == value.length)) ? this : new String(value, beginIndex, subLen);
}
// 内存泄漏是因为调用了该构造方法,这个问题JDK 1.7中已修复
String (char value[], int offset, int count) {
    this.value = value;
    this.offset = offset;
    this.count = count;
}

以上是一个包作用域的构造方法,其目的是为了能高效且快速地共享String内的char数组对象。但在这种通过偏移量来截取字符串的方法中,String的原始内容value数组被复制到新的子字符串中。设想,如果原始字符串特别大,截取的字符串长度特别小,那么截取的子字符串中包含了原生字符串的所有内容,并占据了相应的内存空间,而仅仅通过偏移量和长度来决定实际值。这种算法提高了运算速度却浪费大量空间。【String的这个构造函数使用了以空间换时间的策略,浪费了内存空间,却提高了字符串的生成速度】

JDK 1.7中已把该问题修复,源码如下:

public String(char value[], int offset, int count) {
        if (offset < 0) {
            throw new StringIndexOutOfBoundsException(offset);
        }
        if (count < 0) {
            throw new StringIndexOutOfBoundsException(count);
        }
        // Note: offset or count might be near -1>>>1.
        if (offset > value.length - count) {
            throw new StringIndexOutOfBoundsException(offset + count);
        }
        this.value = Arrays.copyOfRange(value, offset, offset+count);
}

3.1.3 字符串分割和查找

split()方法:支持正则表达式,恰当地使用,可以起到事半功倍。但是,就简单的字符串分割而言,它的性能表现却不尽人意,因此,在性能敏感的系统中频繁使用这个方法是不可取的。

StringTokenizer类:是JDK中提供的专门用来处理字符串分割子串的工具类,性能明显高于split()方法;使用方式如下:

StringTokenizer st = new StringTokenizer(orgStr, ";");
for (int i = 0; i < 10000; i ++) {
    while (st.hasMoreTokens()) {
        st.nextToken();
    }
    st = new StringTokenizer(orgStr, ";");
}

更优化的字符串分割方式:其性能远远超过split()和StringTokenizer,适合高频率调用。

String tmp = orgStr;
for (int i = 0; i < 10000; i++) {
    while (true) {
        String splitStr = null;
        int j = tmp.indexOf(";");
        if (j < 0) break;
        splitStr = tmp.substring(0, j);
        tmp = tmp.substring(j+1);
    }
    tmp = orgStr;
}

高效率的charAt()方法:它的功能和indexOf()正好相反,但是它的效率却和indexOf()一样高。判断字符串orgStr是否以“abc”开始和结束,单纯使用charAt()方法更高效:

int len = orgStr.length();
if (orgStr.charAt(0) == 'a' && orgStr.charAt(1) == 'b' && orgStr.charAt(2) == 'c');
if (orgStr.charAt(len-1) == 'a' && orgStr.charAt(len-2) == 'b' && orgStr.charAt(len-3) == 'c');

3.1.4 StringBuffer和StringBuilder

String常量的累加
对于静态字符串的连接操作,Java在编译时会进行彻底的优化,将多个连接操作的字符串在编译时合成一个单独的长字符串。

String变量的累加
对于变量字符串的累加,Java也做了相应的编译优化,使用了StringBuilder对象来实现字符串的累加。

在代码实现中直接对String对象做的累加操作会在编译时被优化,因此其性能比理论值好很多,但是仍建议在代码实现中,显示地使用StringBuilder或者StringBuffer对象来提升性能,而不是依靠编译器对程序进行优化。

for (int i = 0; i < 10000; i++) {
    str = str + i;
}

编译器优化后,进行反编译后的代码:

for (int i = 0; i < 10000; i++) {
    str = (new StringBuilder(String.valueOf(str))).append(i).toString();
}

以上反编译代码,虽然String的加法运行被编译成StringBuilder的实现,但在这种情况下,编译器并没有做出足够聪明的判断,每次循环都生成了新的StringBuilder实例从而大大降低了系统性能。

在无需考虑线程安全的情况下可以使用性能相对比较好的StringBuilder,但若系统有线程安全要求,只能选择StringBuffer。

StringBuilder和StringBuffer在不指定容量参数时,默认是16个字符。两者的扩容策略是将原来容量大小翻倍,以新的容量申请内存空间,建立新的char数组,然后将原来数据中的内容复制到这个新的数组中。因此,对于大对象的扩容会涉及大量的内存复制操作。所以,如果能够预先评估StringBuilder的大小,将能够有效地节省这些操作,从而提高系统的性能。

3.2 核心数据结构

3.2.1 List接口

List是重要的数据结构之一。包括3种List实现:ArrayList,Vector和LinkedList。3种List均继承自AbstractList的实现。而AbstractList直接实现了List接口,并扩展自AbstractCollection。

ArrayList和Vector使用了数组实现,可以认为封装了对内部数组的操作,几乎使用了相同的算法,它们的唯一区别可认为是对多线程的支持。ArrayList没有对任何一个方法做线程同步,因此不是线程安全的。Vector中绝大部分方法都做了线程同步,是一种线程安全的实现。

LinkedList使用了循环双向链表数据结构。LinkedList链表由一系列表项连接而成。一个表项包含3个部分:元素内容,前驱表项和后驱表项。在JDK的实现中,无论LinkedList是否为空,链表内都有一个header表项,它既表示链表的开始,也表示链表的结尾。表项header的后驱表项是链表中的第一个元素,表项header的前驱表项便是链表中最后一个元素。

增加元素到列表尾端
只要ArrayList的当前容量足够大,add()操作的效率非常高的。当ArrayList进行扩容时,才会数组复制,最终会调用System.arraycopy()方法,因此,add()操作的效率还是相当高的。

public boolean add(E e) {
    ensureCapacity(size + 1); // 确保内部数组有足够空间,扩容时会发生数组复制
    elementData[size + 1] = e;
    return true;
}

LinkedList由于使用了链表的结构,因此不需要维护容量的大小。从这点上说,它比ArrayList有一定的性能优势,然而,每次的元素增加都需要新建一个Entry对象,并进行更多的赋值操作。在频繁的系统调用中,对性能会产生一定的影响。使用LinkedList对堆内存和GC的要求更高。

private Entry<E> addBefore(E e, Entry<E> entry) {
    Entry<E> newEntry = new Entry<E>(e, entry, entry.previous);
    newEntry.previous.next = newEntry;
    newEntry.next.previous = newEntry;
    size ++;
    modCount ++;
    return newEntry;
}

增加元素到列表任意位置
由于ArrayList是基于数组实现的,而数组是一块连续的内存空间,如果在数组的任意位置插入元素,必然导致在该位置后的所有元素需要重新排列,因此,其效率相对会比较低。

public void add(int index, E element) {
    if (index > size || index < 0) {
        throw new IndexOutOfBoundsException ("Index: " + index + ", Size: " + size);
    }
    ensureCapacity(size + 1);
    System.arraycopy(elementData, index, elementData, index+1, size - index);
    element[index] = element;
    size ++;
}

每次插入操作,都会进行一次数组复制。而这个操作在增加元素到List尾端的时候是不存在的。大量的数组重组操作会导致系统性能低下。并且,插入的元素在List中的位置越靠前,数组重组的开销也越大。尽可能地将元素插入到List的尾端附近,有助于提高该方法的性能。

对于LinkedList来说,在List尾端插入数据与在任意位置插入数据是一样的。并不会因为插入的位置靠前而导致插入方法的性能降低。

public void add(int index, E element) {
    addBefore(element, (index == size ? header : entry(index)));
}

删除任意位置元素
对ArrayList来说,remove()方法和add()方法是雷同的。在任意位置移除元素后,都要进行数组的重组。

public E remove(int index) {
    RangeCheck(index);
    modCount++;
    E oldValue = (E) elementData[index];
    int numMoved = size - index - 1;
    if (numMoved > 0) {
        System.arraycopy(elementData, index+1, elementData, index, numMoved);
    }
    elementData[--size] = null;
    return oldValue;
}

在LinkedList的实现中,首先要通过循环找到要删除的元素。如果删除的位置处于List的前半段,则从前往后找;若其位置处于后半段,则从后往前找。因此无论要删除较为靠前或者靠后的元素都是非常高效的;但要移除List中间的元素却几乎要遍历完半个List,在List拥有大量元素的情况下,效率很低。

public E remove(int index) {
    return remove(entry(index));
}

private Entry<E> entry(int index) {
    if (index < 0 || index >= size) {
        throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size);
    }
    Entry<E> e = header;
    if (index < (size >> 1)) {
        for (int i = 0; i <= index; i++)
            e = e.next; 
    } else {
        for (int i = size; i > index; i--)
            e = e.previous;
    }
    return e;
}

容量参数
默认情况下,ArrayList数组的初始值大小为10,每次扩容将新的数组大小设置为原大小的1.5倍。Vector数组的初始值大小为10,每次扩容将新的数组大小设置为原大小的2.0倍。

遍历列表

String tmp;
List<String> list = new ArrayList<>();

// ForEach循环
long start = System.currentTimeMillis();
for (String s : list) {
    tmp = s;
}
System.out.println("foreach spend: " + (System.currentTimeMillis() - start));

// 迭代器
start = System.currentTimeMillis();
for (Iterator<String> it = list.iterator(); it.hasNext();) {
    tmp = it.next();
}
System.out.println("iterator spend: " + (System.currentTimeMillis() - start));

// for循环,使用随机访问
start = System.currentTimeMillis();
int size = list.size();
for (int i = 0; i < size; i++) {
    tmp = list.get(i);
}
System.out.println("for spend: " + (System.currentTimeMillis() - start));

对于数据量100万级别的ArrayList和LinkedList,使用以上三种循环方式遍历,可得知,对于最简便的ForEach循环并没有很好的性能表现,综合性能不如普通的迭代器。而使用for循环通过随机访问遍历列表时,ArrayList表现很好,但是LinkedList的表现却无法让人接受。这是因为对LinkedList进行随机访问时,总会进行一次列表的遍历操作。

编译器会将ForEach循环体作为迭代器处理,二者是完全等价的。但在ForEach循环的迭代操作中,又存在一步多余的赋值操作,从而导致ForEach循环的性能比直接使用迭代器略差一些。

对ArrayList这些基于数组的实现来说,随机访问的速度是很快的。在遍历这些List对象时,可以优先考虑随机访问。但对于LinkedList等基于链表的实现,随机访问的性能是非常差的,应避免使用。

3.2.2 Map接口

围绕着Map接口,最主要的实现类有Hashtable,HashMap,LinkedHashMap和TreeMap。在Hashtable的子类中,还有Properties类的实现。

HashMap的实现原理
简单地说,HashMap就是将key做hash算法,然后将hash值映射到内存地址,直接取得key所对应的数据。在HashMap中,底层数据结构使用的是数组,所谓的内存地址即数组的下标索引。HashMap的高性能需要保证以下几点:
(1)hash算法必须是高效的;
(2)hash值到内存地址(数组索引)的算法是快速的;
(3)根据内存地址(数组索引)可以直接取得对应的值;

在HashMap中,hash算法有关代码如下:

int hash = hash(key.hashCode());
public native int hashCode();
static int hash(int h) {
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

Object类的hashCode()方法默认是native的实现,可以认为不存在性能问题。而hash()函数的实现全部基于位运算。

native方法通常比一般的方法快,因为它直接调用操作系统本地链接库的API。由于hashCode()方法是可以重载的,因此,为了保证HashMap的性能,需要确保相关的hashCode()是高效的。而位运算也比算术,逻辑运算快。

当取得key的hash值后,需要通过hash值得到内存地址:

int i = indexFor(hash, table.length);
static int indexFor(int h, int length) {
    return h & (length - 1);
}

Hash冲突
HashMap的内部维护着一个Entry数组,每一个Entry表项包括key,value,next和hash几项。这里特别注意其中的next部分,它指向了另外一个Entry。可以看到当put()操作有冲突时,新的Entry依然会被安放在对应的索引下标内,并替换原有的值。同时,为了保证旧值不丢失,会将新的Entry的next指向旧值。这便实现了在一个数组索引空间内存放多个值项。HashMap实际上是一个链表的数组。

基于HashMap的这种实现机制,只要hashCode()和hash()方法实现的足够好,能够尽可能地减少冲突的产生,那么对HashMap的操作几乎等价于对数组的随机访问操作,具有很好的性能。但是,如果hashCode()或者hash()方法实现较差,在大量冲突产生的情况下,HashMap事实上就退化为几个链表,对HashMap的操作等价于遍历链表,此时性能很差。

容量参数
默认情况下,HashMap初始大小为16,负载因子为0.75。在HashMap内部,还维护了一个threshold变量,它始终被定义为当前数组总容量和负载因子的乘积,它表示HashMap的阀值。当HashMap的实际容量超过阀值时,HashMap便会进行扩容。因此,HashMap的实际填充率不会超过负载因子。

LinkedHashMap—有序的HashMap
LinkedHashMap继承自HashMap,因此,它具备了HashMap的优良特性—高性能。在HashMap的基础上,LinkedHashMap又在内部增加了一个链表,用以存放元素的顺序。因此,LinkedHashMap可以简单地理解为一个维护了元素次序表的HashMap。

LinkedHashMap可以提供两种类型的顺序:一是元素插入时的顺序;二是最近访问的顺序;

public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder)

其中accessOrder为true时,按照元素最后访问时间排序;当accessOrder为false时,按照元素插入顺序排序,默认为false;

在内部实现中,LinkedHashMap通过继承HashMap.Entry类,实现了LinkedHashMap.Entry,为HashMap.Entry增加了before和after属性用以记录某一表项的前驱和后继,并构成循环列表。

在这里还值得一提的是LinkedHashMap还可以根据元素最后访问时间进行排序。即,每当使用get()方法访问某一元素时,该元素便被移动到链表的尾端。如下:

for (Iterator iterator = map.keySet().iterator(); iterator.hasNext();) {
    String name = (String) iterator.next();
    System.out.println(name + "->" + map.get(name));
}

上面这段代码,运行结果出人意料,LinkedHashMao非但没有排序,程序反而抛出了ConcurrentModificationException异常,并终止了。ConcurrentModificationException异常一般会在集合迭代过程中被修改是抛出。不仅仅是LinkedHashMap,所有的集合都不允许在迭代器模式中修改集合的结构。问题就出在get()方法上。虽然一般认为get()方法是只读的,但是当前的LinkedHashMap却工作在按照元素访问顺序排序的模式中,get()方法会修改LinkedHashMap中的链表结构,以便将最近访问的元素放置到链表的末尾。因此,这个操作便引起了这个错误。

不要在迭代器模式中修改被迭代的集合。如果这么做,就会抛出ConcurrentModificationException异常。这个特性适用于所有的集合类,包括HashMap,Vector,ArrayList等。

TreeMap—另一种Map实现
在功能上讲,TreeMap有着比HashMap更为强大的功能,它实现了SortedMap接口,这意味着它可以对元素进行排序。对TreeMap的迭代输出将会以元素顺序进行。

TreeMap排序方式和LinkedHashMap是不同的。LinkedHashMap是基于元素进入集合的顺序或者被访问的先后顺序排序;而TreeMap则是基于元素的固有顺序(由Comparator或者Comparable确定)

public TreeMap(Comparator<? super K> comparator)

对于TreeMap而言,排序是一个必须进行的过程。因此,要正常使用TreeMap,一定要通过其中一种方式将排序规则传递给TreeMap。如果既不指定Comparator,元素又不去实现Comparable接口,那么在put()操作时,就会抛出java.lang.ClassCastException异常。

TreeMap的内部实现是基于红黑树的。红黑树是一种平衡查找树,它的统计性能要优于平衡二叉树。它具有良好的最坏情况运行时间,可以在O(log n)时间内做查找,插入和删除,n表示树中元素的数目。

3.2.3 Set接口

Set接口并没有在Collection接口之上增加额外的操作,Set集合中的元素是不能重复的。其中最为重要的是HashSet,LinkedHashSet和TreeSet的实现。所有这些Set的实现,都只是对应的Map的一种封装而已。

HashSet对应HashMap的封装,代码如下:

public boolean add(E e) {
    return map.put(e, PRESENT) == null;
}

以此类推,LinkedHashSet对应LinkedHashMap,TreeSet对应TreeMap。

3.2.4 优化集合访问代码

分离循环中被重复调用的代码;
省略相同操作;
减少方法调用:如果可以,则尽量直接访问内部元素,而不要调用对应的接口。函数调用是需要消耗资源的,直接访问元素会更高效。

3.2.5 RandomAccess接口

RandomAccess接口是一个标志接口,本身并没有提供任何方法,任何实现RandomAccess接口的对象都可以认为是支持快速随机访问的对象。此接口的主要目的标示那些可支持快速随机访问的List实现。

在JDK的实现中,任何一个基于数组的List实现都实现了RandomAccess接口,而基于链表的实现则都没有。因为只有数组能够进行快速的随机访问,而对链表的随机访问需要进行链表的遍历。

通过RandomAccess可以知道List是否支持快速随机访问。同时,需要记住,如果应用程序需要通过索引下标对List做快速随机访问,尽量不要使用LinkedList,ArrayList和Vector都是不错的选择。

3.3 使用NIO提升性能

在软件系统中,由于IO的速度要内存速度慢,因此,IO读写在很多场合都会成为系统的瓶颈。提升IO速度,对提升系统整体性能有着很大的好处。

在Java的标准IO中,提供了基于流的IO实现,即InputStream和OutputStream。这种基于流的实现以字节为单位处理数据,并且非常容易建立各种过滤器。

NIO是New IO的简称,与旧式的基于流的IO方法相对,它表示新的一套Java IO标准。它是在Java 1.4中被纳入到JDK中的,并具有以下特性:

  1. 为所有原始类型提供(Buffer)缓冲支持;
  2. 使用java.nio.charset.CharSet作为字符集编码解码解决方案;
  3. 增加通道(Channel)对象,作为新的原始IO抽象;
  4. 支持锁和内存映射文件的文件访问接口;
  5. 提供了基于Selector的异步网络IO;

与流式的IO不同,NIO是基于块(Block)的,它以块为基本单位处理数据。在NIO中,最为重要的两个组件是缓冲Buffer和通道Channel。缓冲是一块连续的内存块,是NIO读写数据的中转地。通道表示缓冲数据的源头或者目的地,它用于向缓冲读取或者写入数据,是访问缓冲的接口。

3.3.1 NIO的Buffer类族和Channel

在NIO的实现中,Buffer是一个抽象类。JDK为每一种Java原生类型都创建了一个Buffer,如:ByteBuffer,CharBuffer,DoubleBuffer,FloatBuffer,IntBuffer,LongBuffer,ShortBuffer;

除了ByteBuffer外,其他每一种Buffer都具有完全一样的操作,唯一的区别仅仅在于它们所对应的数据类型。因为ByteBuffer多用于绝大多数标准IO操作的接口,因此它有些特殊的方法。

在NIO中,和Buffer配合使用的还有Channel。Channel是一个双向通道,既可读,也可写。有点类似Stream,但Stream是单向的。应用程序中不能直接对Channel进行读写操作,而必须通过Buffer来进行。

public static void nioCopyFile(String resource, String destination) throws IOException {
    FileInputStream fis = new FileInputStream(resource);
    FileOutputStream fos = new FileOutputStream(destination);

    // 读文件通道
    FileChannel readChannel = fis.getChannel();
    // 写文件通道
    FileChannel writeChannel = fos.getChannel();

    // 读入数据缓冲
    ByteBuffer buffer = ByteBuffer.allocate(1024);
    while (true) {
        buffer.clear();
        // 读入数据
        int len = readChannel.read(buffer);
        if (len == -1) {
            // 读取完毕
            break;
        }
        // 重置POSITION
        buffer.flip();
        // 写入文件
        writeChannel.write(buffer);
    }
}

3.3.2 Buffer的基本原理

Buffer中有3个重要的参数,如下:
(1)位置(position):当前缓冲区写入/读取的位置,将从此位置后写入/读取数据;
(2)容量(capacity):缓冲区的总容量上限;
(3)上限(limit):缓冲区的实际上限,它总是小于等于容量。通常情况下,和容量相等;

public class BufferTest {
    public static void main(String [] args) {
        // 15个字节大小的缓冲区
        ByteBuffer b = ByteBuffer.allocate(15);
        System.out.println("limit=" + b.limit() + ", capacity=" + b.capacity() + ", position=" + b.position());
        // 存入10个字节数据
        for (int i = 0; i < 10; i++) {
            b.put((byte)i);
        }
        System.out.println("limit=" + b.limit() + ", capacity=" + b.capacity() + ", position=" + b.position());
        // 重置position
        b.flip();
        System.out.println("limit=" + b.limit() + ", capacity=" + b.capacity() + ", position=" + b.position());
        // 读取5个字节数据
        for (int i = 0; i < 5; i++) {
            System.out.print(b.get());
        }
        System.out.println();
        System.out.println("limit=" + b.limit() + ", capacity=" + b.capacity() + ", position=" + b.position());
        b.flip();
        System.out.println("limit=" + b.limit() + ", capacity=" + b.capacity() + ", position=" + b.position());
    }
}

通常,将Buffer从写模式转换为读模式时,需要执行flip()方法。flip()方法不仅重置了当前的position为0,还将limit设置到当前position的位置,这样做的目的是防止在读模式中,读到应用程序根本没有进行操作的区域。

3.3.3 Buffer的相关操作

Buffer的创建

// 从堆中分配
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 从既有数组中创建
byte [] array = new byte[1024];
ByteBuffer buffer = ByteBuffer.wrap(array);

重置和清空缓冲区
这里所谓的重置,只是指重置了Buffer的各项标志位,并不真正清空Buffer的内容。
buffer.rewind():将position置零,并清除标志位(mark),主要作用于提取Buffer的有效数据做准备;
buffer.clear():将position置零,同时将limit设置为capacity的大小,并清除了标志位(mark),主要作用于为重新写Buffer做准备;
buffer.flip():将limit设置到position所在位置,然后将position置零,并清除标志位(mark),主要作用于在读写转换时使用;
读写缓冲区
对Buffer进行读写操作是Buffer最为重要的操作。以ByteBuffer为例,JDK提供了以下常用的读写操作:

public byte get(); // 返回当前position上的数据,并将position位置向后移一位
public ByteBuffer get(byte [] dst); // 读取当前Buffer的数据到dst中,并恰当地移动position位置
public byte get(int index); // 读取给定index索引上的数据,不改变position的位置
public ByteBuffer put(byte b); // 当前位置写入给定的数据,position位置向后移一位
public ByteBuffer put(int index, byte b); // 将数据b写入到Buffer的index位置,不改变position的位置
public final ByteBuffer put(byte [] src); // 将给定的数据写入当前Buffer,并恰当地移动position位置

标志缓冲区
标志(mark)缓冲区是一项在数据处理时很有用的功能。buffer.mark()用于记录当前位置;buffer.reset()用于恢复到mark所在的位置。

public static void testMark() {
    // 15个字节大小的缓冲区
    ByteBuffer b = ByteBuffer.allocate(15);
    // 存入10个字节数据
    for (int i = 0; i < 10; i++) {
        b.put((byte)i);
    }
    b.flip();
    for (int i = 0; i < b.limit(); i++) {
        System.out.print(b.get());
        if (i == 4) {
            // 在第4个位置做mark
            b.mark();
            System.out.print("{mark at " + i + "}");
        }
    }
    // 回到mark的位置,并处理后续数据
    b.reset();
    System.out.println("\nreset to mark");
    while (b.hasRemaining()) {
        System.out.print(b.get());
    }
}

复制缓冲区
复制缓冲区是指以原缓冲区为基础,生成一个完全一样的新缓冲区。方法如下:

public ByteBuffer duplicate();

这个函数对处理复杂的Buffer数据很有好处。因为新生成的缓冲区和原缓冲共享相同的内存数据,所以对任意一方的数据改动都是相互可见的,但两者又独立维护了各自的position,limit和mark。大大增加了程序的灵活性,为多方同时处理数据提供了可能。

public static void testDuplicate() {
    // 15个字节大小的缓冲区
    ByteBuffer b = ByteBuffer.allocate(15);
    // 存入10个字节数据
    for (int i = 0; i < 10; i++) {
        b.put((byte)i);
    }
    // 复制当前缓冲区
    ByteBuffer c = b.duplicate();
    System.out.println("After b.duplicate()");
    System.out.println(b);
    System.out.println(c);
    // 重置缓冲区c
    c.flip();
    System.out.println("After c.flip()");
    System.out.println(b);
    System.out.println(c);
    // 向缓冲区c存入数据
    c.put((byte)100);
    System.out.println("After c.put((byte)100)");
    System.out.println("b.get(0)=" + b.get(0));
    System.out.println("c.get(0)=" + c.get(0));
}

缓冲区分片
缓冲区分片使用slice()方法实现,它将在现有的缓冲区中,创建新的子缓冲区,子缓冲区和父缓冲区共享数据。这个方法有助于将系统模块化。当需要处理一个Buffer的一个片段时,可以使用slice()方法取得一个子缓冲区,然后像处理普通的缓冲区一样处理这个片段,而无需考虑缓冲区的边界问题。

缓冲区分片可以将一个大缓冲区进行分割处理,得到的子缓冲区都具有完整的缓冲区模型结构。因此,这个操作有利于系统的模块化。

public static void testSlice() {
    // 15个字节大小的缓冲区
    ByteBuffer b = ByteBuffer.allocate(15);
    // 存入10个字节数据
    for (int i = 0; i < 10; i++) {
        b.put((byte)i);
    }
    b.position(2);
    b.limit(6);
    // 生成子缓冲区
    ByteBuffer subBuffer = b.slice();
    for (int i = 0; i < subBuffer.capacity(); i++) {
        byte bb = subBuffer.get(i);
        bb *= 10;
        subBuffer.put(i, bb);
    }
    // 重置父缓冲区,并查看父缓冲区的数据
    b.position(0);
    b.limit(b.capacity());
    while (b.hasRemaining()) {
        System.out.print(b.get());
    }
}

只读缓冲区
使用缓冲区对象的asReadOnlyBuffer()方法得到一个与当前缓冲区一致的,并且共享内存数据的只读缓冲区。只读缓冲区对于数据安全非常有用。并且,对原始缓冲区的修改,只读缓冲区也是可见的。

public static void testReadOnlyBuffer() {
    // 15个字节大小的缓冲区
    ByteBuffer b = ByteBuffer.allocate(15);
    // 存入10个字节数据
    for (int i = 0; i < 10; i++) {
        b.put((byte)i);
    }
    // 创建只读缓冲区
    ByteBuffer readOnly = b.asReadOnlyBuffer();
    readOnly.flip();
    while (readOnly.hasRemaining()) {
        System.out.println(readOnly.get());
    }
    System.out.println();
    // 修改原始缓冲区数据
    b.put(2, (byte)20);
    // readOnly.put(2, (byte)20); 会抛出ReadOnlyBufferException
    readOnly.flip();
    // 新的改动,在只读缓冲区内可见
    while (readOnly.hasRemaining()) {
        System.out.println(readOnly.get());
    }
}

文件映射到内存
NIO提供了一种将文件映射到内存的方法进行IO操作,它可以比普通的基于流的IO快很多。

public static void testMappedBuffer() throws IOException {
    RandomAccessFile raf = new RandomAccessFile("/home/taomk/abc.txt", "rw");
    FileChannel fc = raf.getChannel();

    // 将文件映射到内存
    MappedByteBuffer mbb = fc.map(FileChannel.MapMode.READ_WRITE, 0, raf.length());
    while (mbb.hasRemaining()) {
        System.out.print((char)mbb.get());
    }
    mbb.put(0, (byte) 98);
    raf.close();
}

使用文件映射的方式,将文本文件通过FileChannel映射到内存中。然后从内存中读取文件的内容。在程序的最后,通过修改Buffer,将实际数据写到对应的磁盘文件中。
处理结构化数据
NIO还提供了处理结构化数据的方法,称之为散射(Scattering)和聚集(Gathering)。散射是指将数据读入到一组Buffer中,而不仅仅是一个。聚集与之相反,指将数据写入一组Buffer中。散射和聚集的基本使用方法和对应单个Buffer操作时的使用方法相当类似。在JDK中,通过ScatteringByteChannel和GatheringByteChannel接口提供相关操作。
ScatteringByteChannel主要方法:

public long read(ByteBuffer [] dsts) throws IOException;
public long read(ByteBuffer [] dsts, int offset, int length) throws IOException;

GatheringByteChannel主要方法:

public long write(ByteBuffer [] srcs) throws IOException;
public long write(ByteBuffer [] srcs, int offset, int length) throws IOException;

在散射读取中,通道依次填充每个缓冲区。填满一个缓冲区后,它就开始填充下一个。在某种意义上,缓冲区数组就像一个大缓冲区。在JDK提供的各种通道中,DatagramChannel,FileChannel和SocketChannel都实现了这两个接口。

    /**
     * 缓冲区:聚集
     */
    public static void testGathering() throws Exception {
        ByteBuffer bookBuf = ByteBuffer.wrap("java性能优化技巧".getBytes("UTF-8"));
        ByteBuffer autBuf = ByteBuffer.wrap("葛一鸣".getBytes("UTF-8"));
        ByteBuffer [] bufs = new ByteBuffer[]{bookBuf, autBuf};
        File file = new File("");
        if (!file.exists()) {
            file.createNewFile();
        }
        FileOutputStream fos = new FileOutputStream(file);
        FileChannel fc = fos.getChannel();
        fc.write(bufs);
        fos.close();
    }

    /**
     * 缓冲区:散射
     */
    public static void testScattering() throws Exception {
        ByteBuffer bookBuf = ByteBuffer.allocate(16);
        ByteBuffer autBuf = ByteBuffer.allocate(6);
        ByteBuffer [] bufs = new ByteBuffer[]{bookBuf, autBuf};
        File file = new File("");
        FileInputStream fis = new FileInputStream(file);
        FileChannel fc = fis.getChannel();
        fc.read(bufs);
        String bookName = new String(bufs[0].array(), "UTF-8");
        String authName = new String(bufs[1].array(), "UTF-8");
        System.out.println(bookName + ", " + authName);
    }

3.3.4 MappedByteBuffer性能评估

在基于Buffer的实现中,额外又增加了这些数据转换开销,其性能也好于基于流的实现。在NIO还额外提供了一种将文件直接映射到内存的方法:MappedByteBuffer,其性能远远高于前两者。这其中,由于ByteBuffer是将文件一次性读入内存再做后续处理,而Stream方式则是边读文件边处理数据(虽然也使用了缓冲组件BufferedInputStream),这也是导致性能差距的原因之一。

3.3.5 直接内存访问

NIO的Buffer还提供了一个可以直接访问系统物理内存的类—DirectBuffer。DirectBuffer继承自ByteBuffer,但和普通的ByteBuffer不同。普通的ByteBuffer仍然在JVM堆上分配空间,其最大内存,收到最大堆的限制。而DirectBuffer直接分配在物理内存中,并不占用堆空间。

在对普通的ByteBuffer访问时,系统总是会使用一个“内核缓冲区”进行间接的操作。而DirectBuffer所出的位置,就相当于这个“内核缓冲区”。因此,使用DirectBuffer是一种更接近系统底层的方法,所以,它的速度比普通的ByteBuffer更快。

    /**
     * 缓冲区:直接内存
     */
    public static void testDirectBuffer() {
        // 分配DirectBuffer
        ByteBuffer b = ByteBuffer.allocateDirect(500);
        for (int i = 0; i < 100000; i++) {
            for (int j = 0; j < 99; j++) {
                b.putInt(j);
            }
            b.flip();
            for (int j = 0; j < 99; j++) {
                b.getInt();
            }
            b.clear();
        }
    }

由于DirectBuffer占用的内存空间并不在堆中,因此对堆空间的操作就相对较少(注意,DirectBuffer对象本身还是在堆上分配的)。在需要频繁创建Buffer的场合,由于创建和销毁DirectBuffer的代价要远远大于在堆上分配ByteBuffer空间,是不宜使用DirectBuffer的,但是如果能进DirectBuffer进行复用,那么,在读写频繁的情况下,它完全可以大幅改善系统性能。

一段可用于DirectBuffer监控的代码

    /**
     * 缓冲区:直接内存监控
     */
    public static void monitorDirectBuffer() throws Exception {
        Class c = Class.forName("java.nio.Bits");

        // 通过反射取得私有属性
        Field maxMemory = c.getDeclaredField("maxMemory");
        maxMemory.setAccessible(true);

        Field reservedMemory = c.getDeclaredField("reservedMemory");
        reservedMemory.setAccessible(true);

        synchronized (c) {
            // 总大小
            Long maxMemoryVal = (Long) maxMemory.get(null);
            // 剩余大小
            Long reservedMemoryVal = (Long) reservedMemory.get(null);

            System.out.println("maxMemoryVal: " + maxMemoryVal);
            System.out.println("reservedMemoryVal: " + reservedMemoryVal);
        }
    }

使用参数-XX:MaxDirectMemorySize=10M可以指定DirectBuffer的最大可用空间,DirectBuffer的空间不在堆上分配,但DirectBuffer对象是在堆上分配的。因此可以使应用程序突破最大堆的内存限制。对DirectBuffer的读写操作比普通Buffer快,但是对它的创建和销毁却比普通Buffer慢。

3.4 引用类型

3.4.1 强引用

StringBuffer str = new StringBuffer("Hello World");

(1)强引用可以直接访问对象;
(2)强引用所指向的对象在任何时候都不会被系统回收。JVM宁愿抛出OOM异常,也不回收强引用所指向的对象;
(3)强引用可能导致内存泄漏;

3.4.2 软引用

软引用是除了强引用外,最强的引用类型。可以通过java.lang.ref.SoftReference使用软引用。一个软引用的对象,不会被JVM很快回收,JVM会根据当前堆的使用情况来判断何时回收。当堆使用率临近阀值时,才会去回收软引用的对象。只要有足够的内存,软引用便可能在内存中存活相当长一段时间。因此,软引用可以用于实现对内存敏感的Cache。

public class MyObject {

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        // 被回收时输出
        System.out.println("MyObject finalize called");
    }

    @Override
    public String toString() {
        return "I am MyObject";
    }
}

public class CheckRefQueue extends Thread {

    private ReferenceQueue<MyObject> referenceQueue;

    public CheckRefQueue(ReferenceQueue<MyObject> referenceQueue) {
        this.referenceQueue = referenceQueue;
    }

    @Override
    public void run() {
        try {
            Reference<MyObject> obj = (Reference<MyObject>) referenceQueue.remove();
            if (obj != null) {
                System.out.println("Object for SoftReference is " + obj.get());
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

public class SoftReferenceTest {

    public static void main(String [] args) {
        MyObject obj = new MyObject();
        ReferenceQueue<MyObject> softQueue = new ReferenceQueue<>();

        SoftReference<MyObject> softObjRef = new SoftReference<>(obj, softQueue);
        new CheckRefQueue(softQueue).start();
        obj = null;
        System.gc();
        System.out.println("After GC: Soft Get= " + softObjRef.get());
        System.out.println("分配大块内存");
        byte [] b = new byte[1450 * 1024 * 925];
        System.out.println("After new byte[]: Soft Get= " + softObjRef.get());
    }
}

在系统内存紧张的情况下,软引用被回收。此例中软引用被回收时,会被加入注册的引用队列。

3.4.3 弱引用

弱引用是一种比软引用还弱的引用类型。在系统GC时,只要发现弱引用,不管系统堆空间是否足够,都会将对象进行回收。但是,由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间。

public class WeakReferenceTest {

    public static void main(String [] args) {
        MyObject obj = new MyObject();
        ReferenceQueue<MyObject> weakQueue = new ReferenceQueue<>();

        WeakReference<MyObject> weakObjRef = new WeakReference<>(obj, weakQueue);
        new CheckRefQueue(weakQueue).start();
        obj = null;
        System.out.println("Before GC: Weak Get= " + weakObjRef.get());
        System.gc();
        System.out.println("After new byte[]: Soft Get= " + weakObjRef.get());
    }
}

软引用和弱引用都非常适合来保存那些可有可无的缓存数据。如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。

3.4.4 虚引用

虚引用是所有引用类型中最弱的一个。一个持有虚引用的对象,和没有引用几乎是一样的。随时都可能被垃圾回收器回收。当试图使用虚引用的get()方法取得强引用时,总是会失败。并且虚引用必须和引用队列一起使用,它的作用在于跟踪垃圾回收过程。
当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在垃圾回收后,销毁这个对象时,将这个虚引用加入引用队列。

public class PhantomReferenceTest {

    public static void main(String [] args) throws InterruptedException {
        MyObject obj = new MyObject();
        ReferenceQueue<MyObject> phantomQueue = new ReferenceQueue<>();

        PhantomReference<MyObject> phantomObjRef = new PhantomReference<>(obj, phantomQueue);
        System.out.println("Phantom Get: " + phantomObjRef.get());

        new CheckRefQueue(phantomQueue).start();
        obj = null;

        Thread.sleep(1000);
        int i = 1;
        while (true) {
            System.out.println("第" + i++ + "次GC");
            System.gc();
            Thread.sleep(1000);
        }
    }
}

在第一次GC时,系统找到了垃圾对象,并调用其finalize()方法回收内存,但没有立即加入回收队列。第二次GC时,该对象真正被GC清除,此时,加入虚引用队列。

虚引用最大的作用在于跟踪对象回收,清理被销毁对象的相关资源。通常,当对象不被使用时,重载该类的finalize()方法可以回收对象的资源。但是,如果finalize()方法使用不慎,可能导致该对象复活。由于finalize()只会被调用一次,因此,再进行下一次GC时,对象就没有机会再度复活了。所以,在第一次GC时对象复活后,再进行下一次GC前,必须要手动将使用obj=null,去除该对象的强引用,并且不会再次执行finalize()方法了。

使用虚引用来清理对象所占用的资源,是对finalize()方法的一种可行替代方案。

3.4.5 WeakHashMap类及其实现

WeakHashMap是弱引用的一种典型应用,可以作为简单的缓存表解决方案。因为WeakHashMap会在系统内存范围内,保存所有表项,而一旦内存不足,在GC时,没有被引用的表项又会很快被清除掉,从而避免系统内存溢出。

WeakHashMap使用弱引用,可以自动释放已经被回收的key所在的表项,但如果WeakHashMap的key都在系统内持有强引用,那么WeakHashMap就退化为普通的HashMap,因此所有的表项都无法被自动清理。

3.5 有助于改善性能的技巧

3.5.1 慎用异常

try-cache语句对系统性能而言是非常糟糕的,虽然在一次try-cache中,无法察觉到它对性能带来的影响,但是,一旦try-cache语句被应用于循环中,就会给系统性能带来极大伤害。切记循环中使用。

int a = 0;
for (int i = 0; i < 10000000; i++) {
    try {
        a ++;
    } cache (Exception e) {
    }
}

应改为:

int a = 0;
try {
    for (int i = 0; i < 10000000; i++) {
        a ++;
    }
} cache (Exception e) {
}

3.5.2 使用局部变量

调用方法时传递的参数以及在调用中创建的临时变量都保存在栈(Stack)中,速度较快。其他变量,如静态变量,实例变量等,都在堆(Heap)中创建,速度较慢。

3.5.3 位运算代替乘除法

在所有运算中,位运算是最为高效的。

3.5.4 替换switch

3.5.5 一维数组代替二维数组

一维数组访问速度要优于二维数组。

3.5.6 提取表达式

尽可能让程序少做重复的计算,尤其要关注在循环体内的代码,从循环体内提取重复的代码可以有效地提升性能。

3.5.7 展开循环

展开循环是一种在极端情况下使用的优化手段,因为展开循环很可能会影响代码的可读性和维护性。

3.5.8 布尔运算代替位运算

虽然位运算的速度远远高于算术运算,但是在条件判断时,使用位运算替代布尔运算却是非常错误的选择。布尔运算可进行短路运算。

3.5.9 使用arrayCopy()

System.arrayCopy()函数是native函数,通常native函数的性能要优于普通的函数。仅处于性能考虑,在软件开发时,应尽可能调用native函数。

3.5.10 使用Buffer进行IO操作

无论对于读取还是写入文件,适当地使用缓冲,可以提升系统的文件读写性能。

3.5.11 使用clone()代替new

对于重量级对象,由于对象在构造函数中可能会进行一些复杂且耗时的操作,因此,构造函数的执行时间可能会比较长。

Object.clone()方法可以绕过对象构造函数,快速复制一个对象实例。由于不需要调用对象构造函数,因此,clone()方法不会受到构造函数性能的影响,能够快速生成一个实例。但是默认情况下,clone()方法生成的实例只是原对象的浅拷贝。如果需要深拷贝,则需要重新实现clone()方法。

3.5.12 静态方法替代实例方法

在Java中,由于实例方法需要维护一张类似虚函数表的结构,以实现对多态的支持。与静态方法相比,实例方法的调用需要更多的资源。对于一些常用的工具类方法,没有对其进行重载的必要,那么将它们声明为static,便可以加速方法的调用。

你可能感兴趣的:(第3章 Java程序优化)