3.1 字符串优化处理
String 对象及其特点
在 Java 中,String 对象是一个重要的数据类型,但是它并不属于基本类型。下图展示了 Java 中 String 类的基本实现,其主要由 char 数组、偏移量和 String 的长度组成。
在 Java 中,设计者对 String 对象进行了大量优化,主要表现在一下3个方面;
□ 不变性:对象一旦生成,则不能对它进行改变。
□ 针对常量池的优化:当两个 String 对象拥有相同的值时,它们只引用常量池中同一个拷贝。
□ 类的 final 定义:不能被继承,不能被改变。
字符串分割和查找
1、最原始的字符串分割是 split() 方法,使用简单,功能强大;但是在性能敏感的系统中频繁使用这个方法不可取。
2、使用效率更高的 StringTokenizer 类分割字符。
3、手动完成字符串分割算法(使用 indexOf()、subString() )。
public static List<String> split(String str, char c) {String temp = str;List<String> stringList = new ArrayList<>();while (true) {String splitStr = null;int i = temp.indexOf(c);if (i < 0) {stringList.add(temp);break;}splitStr = temp.substring(0, i);temp = temp.substring(i + 1);stringList.add(splitStr);}return stringList;}
经过测试对比,spilt() 方法功能强大, 但是效率最差;StringTokenizer 性能优于 split() 方法,因此,在能够使用 StringTokenizer 的模块中,就没有必要使用 split();而完全由自己实现的分割算法性能最好,但相对来说,代码的可读性和系统的可维护性最差,只有当系统性能问题成为主要矛盾时,才推荐使用该方法。在实际软件开发过程中,开发人员需要在系统的各个方面进行权衡,采用最合适的方法处理问题。
4、高效率的 charAt() 方法
这个方法与上述提到的 indexOf() 效率方面差不多,但是功能与 indexOf() 相反,它是给定字符串中位置在 index 的字符。
StringBuffer 和 StringBuilder
由于 String 对象是不可改变对象,因此,在需要对字符串进行修改操作时(如字符串连接、替换),String 对象总是生成新对象,所以性能较差,因此 JDK 专门提供了两个处理字符串操作的工具 StringBuffer 和 StringBuilder。
1、String 拼接操作
对于静态字符串的拼接操作,Java 在编译时会进行彻底的优化,将多个拼接操作的字符串在编译时合成一个单独的长字符串。<br />对于变量字符串的拼接操作,Java 也做了相应的优化,使用了 StringBuilder 对象来实现字符串的拼接。<br />对于构建超大的 String 对象, String 的加法操作虽然会被优化,但是编译器不够聪明,每次都会创建一个新的 StringBuilder 对象,所以对于 String 的操作,应该减少使用 ‘+’ 或 ‘+=’ 这样的运算符。
2、StringBuffer 和 StringBuilder 的选择
首先,先看一下两个类的结构,StringBuffer 与 StringBuilder 的抽象父类和实现的接口是一样的。所以它们拥有几乎相同的对外接口。
两者的最大不同在于 StringBuffer 对几乎所有的方法都做了同步,即加了 synchronize 关键字,而 String-Builder 并没有做任何同步。由于方法同步需要消耗一定的系统资源,因此,StringBuider 的效率也好于StringBuffer。但是,在多线程系统中,StringBuilder 无法保证线程安全,不能使用。
3、容量参数
无论是 StringBuilder 或者 StringBuffer,在初始化时都可以设置一个容量参数,对应的构造函数如下:
public StringBuffer() {
super(16);
}
public StringBuffer(int capacity) {
super(capacity);
}
public StringBuffer(String str) {
super(str.length() + 16);
}
在不指定容量参数时,默认是 16 字节,如果创建对象是传入字符串,则容量为传入字符串长度 + 16 字节。在追加字符串时,如果需要容量超过实际 char 数组长度,则需要进行扩容。扩容函数在 AbstractStringBuilder 中定义如下:
public void ensureCapacity(int minimumCapacity) {
if (minimumCapacity > 0)
ensureCapacityInternal(minimumCapacity);
}
private void ensureCapacityInternal(int minimumCapacity) {
// overflow-conscious code
if (minimumCapacity - value.length > 0) {
value = Arrays.copyOf(value,
newCapacity(minimumCapacity));
}
}
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 int hugeCapacity(int minCapacity) {
if (Integer.MAX_VALUE - minCapacity < 0) { // overflow
throw new OutOfMemoryError();
}
return (minCapacity > MAX_ARRAY_SIZE)
? minCapacity : MAX_ARRAY_SIZE;
}
从上面代码可以看到。扩容策略是将原有的容量大小翻倍,以新的容量申请内存空间,建立新的char 数组,然后将原数组中的内容复制到这个新的数组中。因此,对于大对象的扩容会涉及大量的内存复制操作。所以,如果能够预先评估 StringBuilder/StringBuffer 的大小,将能够有效地节省这些操作,从而提高系统的性能。
3.2 核心数据接口
下面主要介绍一些 JDK 提供的常用的集合类的数据结构的接口和使用。本章的的数据结构以及代码均来自于 Jdk 1.8;
List 接口
List 是重要的数据结构之一,这一节中主要介绍最为常见也是较为重要的三个实现类:ArrayList、Vector和LinkedList,它们的类图结构如下图所示:
从上图可以看出,3种 List 均来自 AbstratList 的实现。而 AbstractList 直接实现了 List 接口,并扩展自 AbstractCollection。在这 3 种不同的实现中,ArrayList 和 Vector 使用了数组实现,可以认为,ArrayList
或者 Vector 封裝了对内部数组的操作。比如向数组中添加、州除、插入新的元素或者数组的扩展和重定义。对 ArrayList 或者 Vector 的操作,等价于对内部对象数据的操作。
⚠️ ArrayList 和 Vector 几乎使用了相同的算法,它们的唯一区别可以认为是对多线程的支持。ArrayList 没有对任何一个方法做线程同步,因此不是线程安全的。Vector 中绝大部分方法都做了线程同步,是一种线程安全的实现。因此 ArrayList和 Vector 的性能特性相差无几。
LinkedList
LinkedList 使用的循环双向链表数据结构。它是由一系列节点连接而成。一个节点包含3个部分:元素内容、前驱节点和后继节点,结构如下图所示:
上图展示的是一个节点所包含的内容,下图展示了一个包含3 个元素的 LinkedList 的各个表项间的连接关系。在 JDK 的实现中,无论 LinkedList 是否为空,链表内都有一个 header 节点,它既表示链表的开始,也表示链表的结尾。节点 header 的后继节点便是链表中第一个元素,节点 header 的前驱节点便是链表中最后一个元素。
对于 List 数据的增加、删除可以参考数组的操作方式;对于 LinkedList 的操作可以参考 C 的指针用法。
Map 接口
Map 是非常常用的一种数据结构。在 Java 中,提供了成熟的 Map 实现。最常用的一些Map 实现如下图所示。
围绕着 Map 接口,最主要的现实类有 Hashtable、HashMap、LinkedHashMap 和 TreeMap。在 Hashtable 的之类中,还有 Properties 类的实现。
HashMap的数据结构
HashMap 的主干是一个 Entry 数组,Entry 是 HashMap 的基本组成单元,每一个 Entry 包含一个 key-value 键值对。在 jdk1.7 中,HashMap 是数组 + 链表的数据结构。jdk1.8 中是数组 + 链表 + 红黑树的数据结构,在 jdk1.8 中,当 HashMap 的长度和链表的长度满足一定条件时,链表会转换成红黑树。数据结构如下图所示:
HashMap源码、实现
LinkedHashMap —— 有序的 HashMap
总的来说,HashMap 性能表现非常不错,也被广泛使用。但是有一个缺点就是它的无序性,也就是说 遍历输出顺序和 put 顺序不一致。而当出现需要保证元素有序进出的需求时,则可以使用 LinkedHashMap 来替代。<br />从前面的结构图可以看出,LinkedHashMap 继承自 HashMap,因此它的性能和 HashMap 差不多。在 HashMap 的基础上,LinkedHashMap 在内部增加来一条链表用来保存元素的顺序;LinkedHashMap 可以提供两种类型的顺序:一是元素插入时的顺序;二是最近访问的顺序。可以通过一下构造函数指定排序行为:
public LinkedHashMap(int initialCapacity,
float loadFactor,
boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}
其中 accessOrder 为 true 时,按照元素最后访问时间排序;为 false 时。按照插入顺序排序。默认为 false;
对于一般集合来说,不要在迭代器模式中修改被迭代的集合,如果操作了,就会抛出 Concurrent-ModificationEception 异常。而对于 LinkedHashMap 来说,除了 put、 remove 方法外,在迭代中使用 get() 方法也会抛出异常,因为如果当前 LinkedHashMap 是按照访问最后访问顺序排序的时候,如果调用了 get() 方法,则会改变 LinkedHashMap 的链表结构,以便将最近访问的元素放置到链表末尾,因此会产生异常。
TreeMap —— 另一种 Map 实现
TreeMap 是 Map 的另一种实现方式,和 HashMap 最大的区别就是支持排序,因为 TreeMap 实现了 SortedMap 接口,同时,正因为这一点,所以导致 TreeMap 的性能在某些方面是不及 HashMap 的。<br />TreeMap 的排序方式和 LingkedHashMap 不同,LinkedHashMap 是基于元素进入集合的顺序或者被访问的先后顺序排序;而 TreeMap 则是基于元素的固有顺序(由 Comparator 或者 Comparable 确定)。<br />TreeMap 的排序使用:
1、在 TreeMap 的构造函数中注入一个 Comparator
public TreeMap(Comparator<? super K> comparator) {
this.comparator = comparator;
}
2、使用一个实现了 Comparable 接口的 key
Set 接口
Set 集合有一个特性,就是集合中的元素不能重复,其中重要的实现有 HashSet、LinkedHashSet、TreeSet。其中 HashSet 是对 HashMap 的封装,LinkedHashSet 对应 LinkedHashMap,TreeSet 对应 TreeMap;接口关系图如下图所示;
优化集合访问代码
1、分离循环中被重复调用的代码 2、省略相同的操作 3、减少方法调用
3.3 使用 NIO 提升性能
在软件系统中,由于 I/O 的速度有要比内存慢,因此 I/O读写在很多场合都会成为系统的瓶颈。所以提升 I/O 速度,对提升系统的整体性能有着很大的好处。
在Java 的标准 I/O 中,提供了基于流的 I/O 实现,即 InputStream 和 OutputStream。这种基于流的实现以字节为单位处理数据,并且非常容易建立各种过滤器。以下是 NIO 的特性介绍;
□ 为所有的原始类型提供缓存 ( Buffer ) 支持
□ 使用 Java.nio.charset.Charser 作为字符集编码解码解决方案
□ 增加通道 ( Channel ) 对象,作为新的原始 I/O 抽象
□ 支持锁和内存映射文件的文件访问入口
□ 提供了基于 Selector 的异步网络 I/O
与流式的 I/O 不同,NIO 是基于块 (Block) 的,它以块为基本单位处理数据。在 NIO 中,最为重要的两个组件是缓冲 Buffer 和通道 Channel。缓冲是一块连续的内存块,是 NIO 读写数据的中转地。通道表示缓冲数据的源头或者目的地。下图展示了通道和缓冲的关系。
NIO 的 Buffer 类族和 Channel
在 NIO 的实现中,Buffer 是一个抽象类。JDK 为每一种 Java 原生类型都创建了一个 Buffer,如下图所示。
除 ByteBuffer 外,其他每一种 Buffer 都具有完全一样的操作。
在 NIO 中和 Buffer 配合使用的还有 Channel。Channel 是一个可读可写的双向通道,在应用程序中,不能直接对 Channel 进行读写操作,必须通过 Buffer 来进行,
Buffer 的基本原理
Buffer 中有 3 个重要的参数:位置 (position)、容量 (capactiy) 和上限 (limit)。参数含义如下表所示;
| 参数 | 写模式 | 读模式 |
|---|---|---|
| 位置 (position) | 当前缓冲区的位置,将从 position 的下一个位置写数据 | 当前缓冲区读取的位置,将从此位置后,读取数据 |
| 容量 (capactiy) | 缓冲区的总容量上限 | 缓冲区的总容量上限 |
| 上限 (limit) | 缓冲区的实际上限,他总是小于等于容量。通常情况下,和容量相等 | 代表可读取的总容量,和上次写入的数据量相等。 |
如果想仔细了解 Buffer 的工作模式,可以自行百度,这里不做过多说明。
Buffer 的相关操作
1、Buffer 的创建
Buffer 有两种创建方式:使用静态方法 allocate() 从堆中分配缓冲区,或者从一个既有数组中创建缓冲区。
// 从堆中分配
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
// 从既有数组中创建
byte[] byteArr = new byte[1024];
ByteBuffer buffer = ByteBuffer.wrap(byteArr);
2、重置和清空缓冲区
| rewind() | clear() | filp() | |
|---|---|---|---|
| position | 置零 | 置零 | 置零 |
| mark | 清空 | 清空 | 清空 |
| limit | 未改动 | 设置为 capacity | 设置为 position |
| 作用 | 为读取 Buffer 中有效数据作准备 | 为重新写入 Buffer 作准备 | 在读写切换时调用 |
3、读 / 写缓冲区
下面列出几个具有代表性的常见的缓冲区读写方法。
□ get() 方法:返回当前 position 上的数据,并将 position 位置后移一位。
□ get(byte[] dst) 方法:读取当前 Buffer 的数据到 dst 中,并恰当地移动 psition 位置。
□ get(int index) 方法:读取给定 index 索引上的数据,不改变 position 的位置。
□ put(byte b) 方法:当前位置写入给定的数据,position 位置向后移一位。
□ put(int index, byte b) 方法:将数据 b 写入当前 Buffer 的 index 位置。
□ put(byte[] src) 方法:将给定的数据写入当前 Buffer。
4、标志缓冲区
标志(mark)缓冲区是一项在数据处理时很有用的功能,它就像书签一样,在数据处理的过程中,可以随时记录当前位置。然后再任意时刻,回到这个位置,从而加快或简化数据处理流程。
public final Buffer mark() {} // 记录当前位置
public final Buffer reset() {} // 恢复到 mark 所在的位置
5、复制缓冲区
复制缓冲区是指以原缓冲区为基础,生成一个完全一样的新缓冲区。方法如下:
public abstract ByteBuffer duplicate();
这个函数新生成的缓冲区和原缓冲区共享相同的内存数据。并且,对任意一方的数据改动都是相互可见的,但两者有独立维护了各自的 position、limit 和 mark。
6、缓冲区分片
缓冲区切片使用 slice() 方法实现,它可以将一个大的缓冲区进行分割处理,得到的子缓冲区和父缓冲区共享数据,具有完整的缓冲区模型结构。因此,这个操作有利于系统的模块化。
7、只读缓冲区
可以使用缓冲区对象的 asReadOnlyBuffer() 方法得到一个与当前缓冲区一致的,并且共享内存数据的只读缓冲区。只读缓冲区可以保证核心数据的安全,如果不希望数据被随意篡改,返回一个只读缓冲区即可。
8、文件映射到内存
NIO 提供了一种将文件映射到内存的方法进行 I/O 操作,他可以比常规的基于流的 I/O 快很多,这个操作主要由 FileChannel.map() 实现。使用实例如下;
private static void modifyFile(String path) throws IOException {
RandomAccessFile randomAccessFile = new RandomAccessFile(path,"rw");
FileChannel fileChannel = randomAccessFile.getChannel();
//将文件映射到内存
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE,0,randomAccessFile.length());
while (mappedByteBuffer.hasRemaining()){
System.out.println(mappedByteBuffer.get());
}
//修改文件
mappedByteBuffer.put(0, (byte) 100);
randomAccessFile.close();
}
9、处理结构化数据
NIO 提供了处理结构化数据的fangfa,称之为散射 (Scattering) 和聚集 (Gathering)。散射是指将数据读入一组 Buffer 中,而不仅仅是一个。聚集与之相反,指将数据写入一组 Buffer 中。散射和聚集的基本使用方法与单个对 Buffer 操作时的使用方法类似。在 JDK 中,通过 ScatteringByteChannel 和 GatheringByteChannel 接口提供相关操作。
ScatteringByteChannel 主要方法:
public long read (ByteBuffer[] dsts) throws IOException;
public long reas (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;
在撒射读取中,通道依次填充每个缓冲区。填满一个缓冲区后,它就开始填充下一个。在某种意义上,缓冲区数组就像是一个大缓冲区。通过 ScatteringByteChannel 和 GatheringByteChannel,可以简化 Buffer 对结构化数据的处理,同时有利于实现程序的模块化。
MappedByteBuffer 性能评估
实验代码略。使用 MappedByteBuffer 可以大大提高读取和写入文件的速度。下表列出各种 I/O 方式的速度比较。
| 使用 Stream | ByteBuffer | MappedByteBuffer | |
|---|---|---|---|
| write | 1640 | 950 | 110 |
| read | 1300 | 300 | 80 |
需要注意的是,虽然使用 ByteBuffer 读文件比 Stream 快很多,但这并不足以表明 Stream 方式与 ByteBuffer 有如此大的差距。其中,由于 ByteBuffer 是将文件一次性读入内存再做后续处理,而 Stream 则是边读文件边处理数据,这也是导致两者性能差异的原因一致。即使如此,仍不能掩盖使用 NIO 方法的优势。鉴于 I/O 在很多场合都极有可能成为系统瓶颈,因此使用 NIO 替代传统 I/O 操作,对系统整体性能的优化应该是有立竿见影的效果的。
直接内存访问
NIO 的 Buffer 还提供了一个可以直接访问系统屋里内存的类——DirectBuffer。DirectBuffer 继承自 ByteBuffer,但和普通的不同的是普通的 ByteBuffer 仍然在 JVM 堆上分配空间,其最大内存受到堆大小的限制。而 DirectBuffer 直接分配在物理内存中,不占用对空间。
在对普通的 ByteBuffer 访问时,系统总是会使用一个“内核缓冲区”进行间接的操作。而 DiectBuffer 所处的位置,就相当于这个“内核缓冲区”。因此,使用 DirectBuflier 是一种更加接近系统底层的方法,所以,它的速度比普通的 ByteBuffer 更快。
使用 MaxDirectMemorySize 可以指定 DirectBuffer 的最大可用空间,DirectBuffer 的缓存空间不在堆上分配,因此可以使应用程序突破最大堆的内存限制。对 BirectBuffer 的读写操作比普通 Buffer 快,但是对它的创建和销毁却比普通Buffer慢。
3.4 引用类型
在 Java 中,提供了 4 个级别的引用:强引用、软引用、软引用和虚引用。这 4 个引用级别中,只有强引用 FinalReference 类是包可见,其他 3 种均为 public,可以在应用程序中直接使用。引用类型的类结构图如下图所示。
强引用
Java 中的引用,类似于 C++ 的指针。通过引用,可以对堆中的对象进行操作。假设在代码中创建一个对象实例,那么对象实例会被分配在堆上,而定义的变量则是分配在栈上,而变量指向实例所在的对空间,通过操作变量可以操作该实例。如下图所示;在代码中运行一个创建对象实例的代码:
StringBUffer sb = new StringBuffer("str");

强引用具备以下特点:
□ 强引用可以直接访问目标对象
□ 强引用所指向的对象在任何时候都不会被系统回收。 JVM 宁愿抛出 OOM 异常也不回收强引用所指向的对象。
□ 强引用可能导致内存泄漏
软引用
软引用是除了强引用以外最强的引用类型。可以通过 java.lang.ref.SoftReference 使用软引用。一个持有软引用的对象,不会被 JVM 很快回收,JVM 会根据当前堆的使用情况来判断何时回收。当堆使用率临近阈值时才会去回收软引用的对象。一般软引用可以用于实现对内存敏感的 Cache。
弱引用
弱引用是一种比软引用较弱的引用类型,在系统 GC 时,只要发现弱引用,不管系统堆空间是否足够,都会将对象进行回收。
软引用、弱引用都是比较适合来保存那些可有可无的缓存数据。如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。
虚引用
虚引用是所有引用类型中最弱的一个。一个持有虚引用的对象,和没有引用几乎是一样的,随时可能呗垃圾回收器回收。当试图通过虚引用的 get() 方法取得强引用时,总是会失败。使用虚引用来清理对象所占用的资源,是对 finalize() 函数的一种可行的替代方法。
3.5 有助于改善性能的技巧
程序的性能受到代码质量的直接影响,因此一些小技巧有助于在代码级别上提升系统性能。有兴趣可以看看《代码整洁之道》。
