StringBuffer在进行字符串处理时,不生成新的对象,在内存使用上要优于String类,所以在实际使用时,如果经常需要对一个字符串进行修改,例如追加、插入、删除等操作,使用StringBuffer要更加适合一些。对于StringBuffer对象的每次修改都会改变对象自身,这点是和String类最大的区别。
public final class StringBuffer
extends AbstractStringBuilder
implements java.io.Serializable, CharSequence{……}
对应的源码为:
public StringBuffer() {
super(16);
}
public StringBuffer(int capacity) {
super(capacity);
}
public StringBuffer(String str) {
super(str.length() + 16);
append(str);
}
public StringBuffer(CharSequence seq) {
this(seq.length() + 16);
append(seq);
}
StringBuffer连初始化都要用到append,我们接下来看看append。
查看StringBuffer中多种append方法的源码,我们发现,很多都是直接调用超类的append方法,所以我们来看看AbstractStringBuilder中几个典型的append是怎么实现的。
abstract class AbstractStringBuilder implements Appendable, CharSequence {
/**
* The value is used for character storage.
*/
char[] value;
/**
* The count is the number of characters used.
*/
int count;
…………………………
public AbstractStringBuilder append(String str) {
if (str == null) str = "null";
int len = str.length();
ensureCapacityInternal(count + len);
//void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin)
str.getChars(0, len, value, count);
count += len;
return this;
}
private void ensureCapacityInternal(int minimumCapacity) {
// overflow-conscious code
if (minimumCapacity - value.length > 0)
expandCapacity(minimumCapacity);
}
/**
* This implements the expansion semantics of ensureCapacity with no
* size check or synchronization.
*/
void expandCapacity(int minimumCapacity) {
int newCapacity = value.length * 2 + 2;
if (newCapacity - minimumCapacity < 0)
newCapacity = minimumCapacity;
if (newCapacity < 0) {
if (minimumCapacity < 0) // overflow
throw new OutOfMemoryError();
newCapacity = Integer.MAX_VALUE;
}
value = Arrays.copyOf(value, newCapacity);
}
//char[] java.util.Arrays.copyOf(char[] original, int newLength)
public static char[] copyOf(char[] original, int newLength) {
char[] copy = new char[newLength];
System.arraycopy(original, 0, copy, 0,
Math.min(original.length, newLength));
return copy;
}
看完超类的代码相信就明白了,原来StringBuffer也不过是用char[] value在存储数据。如果用new StringBuffer() 新建,默认给该数组分配16 char的空间。对StringBuffer进行append时会先用ensureCapacityInternal判断是否有足够的空间,如果空间不够,就尝试将容量扩大到原来的2倍(+2),判断够不够;如果够,就以新的容量新建一个数组;如果不够,就以需要的容量新建数组。然后把原数组里的数据复制到新数组中。
关于append其他类型的字符串(或字符数组),跟上面的流程一样,这里不在赘述。有点不一样的就是append其他数据类型的话,该方法会将其转化为标准的字符串类型追加到原StringBuffer后面。
分析到这里,其实StringBuffer的本质已经被展示出来了,所以insert、delete等操作其实也就是对于字符数组在进行操作。
网上说StringBuffer比String的优越性都说的比较笼统,分析了上面StringBuffer的源码之后,也没有感觉特别高效率。而且String到底是怎么进行“+”连接的也还没有弄清楚。
这里,引用另一篇博客的内容可以很好地说明这个问题。(http://blog.csdn.net/shi1122/article/details/8053680)
我们通过一个简单的程序来看其执行的流程:
public class Buffer {
public static void main(String[] args) {
String s1 = "aaaaa";
String s2 = "bbbbb";
String r = null;
int i = 3694;
r = s1 + i + s2;
for(int j=0;i<10;j++){
r+="23124";
}
}
}
将清单1和清单2对应起来看,清单2的字节码中ldc指令即从常量池中加载“aaaaa”字符串到栈顶,istore_1将“aaaaa”存到变量1中,后面的一样,sipush是将一个短整型常量值(-32768~32767)推送至栈顶,这里是常量“3694”,更多的Java指令集请查看另一篇文章“Java指令集”。
让我们直接看到13,13~17是new了一个StringBuffer对象并调用其初始化方法,20~21则是先通过aload_1将变量1压到栈顶,前面说过变量1放的就是字符串常量“aaaaa”,接着通过指令invokevirtual调用StringBuffer的append方法将“aaaaa”拼接起来,后续的24~30同理。最后在33调用StringBuffer的toString函数获得String结果并通过astore存到变量3中。
看到这里可能有人会说,“既然JVM内部采用了StringBuffer来连接字符串了,那么我们自己就不用用StringBuffer,直接用”+“就行了吧!“。是么?当然不是了。俗话说”存在既有它的理由”,让我们继续看后面的循环对应的字节码。
37~42都是进入for循环前的一些准备工作,37,38是将j置为1。44这里通过if_icmpge将j与10进行比较,如果j大于10则直接跳转到73,也即return语句退出函数;否则进入循环,也即47~66的字节码。这里我们只需看47到51就知道为什么我们要在代码中自己使用StringBuffer来处理字符串的连接了,因为每次执行“+”操作时jvm都要new一个StringBuffer对象来处理字符串的连接,这在涉及很多的字符串连接操作时开销会很大。
还有一点上面没有提到的是:当用StringBuffer进行append而容量足够时的开销是非常小的。一个StringBuffer对象不停append,用大约每次翻倍的方式增加容量从而从总体上减少了重新申请内存的次数。不像String “+”每次新建一个StringBuffer,每次申请内存,这样的开销非常大。
StringBuffer 与 StringBuilder 中的方法和功能完全是等价的,只是StringBuffer 中的方法大都采用了 synchronized 关键字进行修饰,因此是线程安全的,而 StringBuilder 没有这个修饰,可以被认为是线程不安全的。 下面引用一下JDK官方文档上面的表述。
StringBuffer:线程安全的可变字符序列。一个类似于 String 的字符串缓冲区,但不能修改。虽然在任意时间点上它都包含某种特定的字符序列,但通过某些方法调用可以改变该序列的长度和内容。
可将字符串缓冲区安全地用于多个线程。可以在必要时对这些方法进行同步,因此任意特定实例上的所有操作就好像是以串行顺序发生的,该顺序与所涉及的每个线程进行的方法调用顺序一致。
StringBuffer 上的主要操作是 append 和 insert 方法,可重载这些方法,以接受任意类型的数据。每个方法都能有效地将给定的数据转换成字符串,然后将该字符串的字符追加或插入到字符串缓冲区中。append 方法始终将这些字符添加到缓冲区的末端;而 insert 方法则在指定的点添加字符。
例如,如果 z 引用一个当前内容为 “start” 的字符串缓冲区对象,则此方法调用 z.append(“le”) 会使字符串缓冲区包含 “startle”,而 z.insert(4, “le”) 将更改字符串缓冲区,使之包含 “starlet”。
通常,如果 sb 引用 StringBuilder 的一个实例,则 sb.append(x) 和 sb.insert(sb.length(), x) 具有相同的效果。
当发生与源序列有关的操作(如源序列中的追加或插入操作)时,该类只在执行此操作的字符串缓冲区上而不是在源上实现同步。
每个字符串缓冲区都有一定的容量。只要字符串缓冲区所包含的字符序列的长度没有超出此容量,就无需分配新的内部缓冲区数组。如果内部缓冲区溢出,则此容量自动增大。从 JDK 5 开始,为该类补充了一个单个线程使用的等价类,即 StringBuilder。与该类相比,通常应该优先使用 StringBuilder 类,因为它支持所有相同的操作,但由于它不执行同步,所以速度更快。
.
StringBuilder:一个可变的字符序列。此类提供一个与 StringBuffer 兼容的 API,但不保证同步。该类被设计用作 StringBuffer 的一个简易替换,用在字符串缓冲区被单个线程使用的时候(这种情况很普遍)。如果可能,建议优先采用该类,因为在大多数实现中,它比 StringBuffer 要快。
在 StringBuilder 上的主要操作是 append 和 insert 方法,可重载这些方法,以接受任意类型的数据。每个方法都能有效地将给定的数据转换成字符串,然后将该字符串的字符追加或插入到字符串生成器中。append 方法始终将这些字符添加到生成器的末端;而 insert 方法则在指定的点添加字符。
例如,如果 z 引用一个当前内容为 “start” 的字符串的生成器对象,则该方法调用 z.append(“le”) 将使字符串生成器包含 “startle”,而 z.insert(4, “le”) 将更改字符串生成器,使之包含 “starlet”。
通常,如果 sb 引用 StringBuilder 的实例,则 sb.append(x) 和 sb.insert(sb.length(), x) 具有相同的效果。每个字符串生成器都有一定的容量。只要字符串生成器所包含的字符序列的长度没有超出此容量,就无需分配新的内部缓冲区。如果内部缓冲区溢出,则此容量自动增大。
将 StringBuilder 的实例用于多个线程是不安全的。如果需要这样的同步,则建议使用 StringBuffer。
java的集合类主要由两个接口生成:
(1) Collection:Set(无序集,只可以根据元素本身来访问,所以不能重复),List(有序集,元素可以重复),Queue(队列)
(2) Map:key-value对。将键映射到值的对象。一个映射不能包含重复的键;每个键最多只能映射到一个值。Map 接口提供三种collection 视图,允许以键集、值集或键-值映射关系集的形式查看某个映射的内容。映射顺序定义为迭代器在映射的 collection 视图上返回其元素的顺序。某些映射实现可明确保证其顺序,如 TreeMap
类;另一些映射实现则不保证顺序,如 HashMap
类。
常用的实现类有HashSet、TreeSet、ArrayList、LinkedList、ArrayQueue和HashMap、TreeMap等。Java 8新增了Predicate和Stream操作集合,感兴趣的朋友可以查看API。
基于哈希表的 Map 接口的实现。此实现提供所有可选的映射操作,并 允许使用 null 值和 null 键。 (除了非同步和允许使用 null 之外,HashMap 类与 Hashtable 大致相同。) 此类不保证映射的顺序,特别是它不保证该顺序恒久不变。
此实现假定哈希函数将元素适当地分布在各桶之间,可为基本操作(get 和 put)提供稳定的性能。迭代 collection 视图所需的时间与 HashMap 实例的“容量”(桶的数量)及其大小(键-值映射关系数)成比例。所以,如果迭代性能很重要,则不要将初始容量设置得太高(或将加载因子设置得太低)。
HashMap 的实例有两个参数影响其性能:初始容量
和 加载因子
。
容量
是哈希表中桶的数量或者说是桶数组大小,初始容量只是哈希表在创建时的容量。负载因子
是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了负载因子与当前容量的乘积时(threshold = capacity * loadFactor
),则要对该哈希表进行 rehash
操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数。通常,默认加载因子 (.75) 在时间和空间成本上寻求一种折衷。加载因子过高虽然减少了空间开销,但同时也增加了查询成本(在大多数 HashMap 类的操作中,包括 get 和 put 操作,都反映了这一点)。在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少 rehash 操作次数。如果初始容量大于最大条目数除以加载因子,则不会发生 rehash 操作。
如果很多映射关系要存储在 HashMap 实例中,则相对于按需执行自动的 rehash 操作以增大表的容量来说,使用足够大的初始容量创建它将使得映射关系能更有效地存储。这么说可能不够清楚,我们结合源码来看。
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;
threshold = initialCapacity;
init();
}
HashMap的基础构造器HashMap(int initialCapacity, float loadFactor)带有两个参数,它们是初始容量initialCapacity和加载因子loadFactor。
负载因子衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。
HashMap的实现中,通过threshold字段来判断HashMap的最大容量:
threshold = (int)(capacity * loadFactor);
结合负载因子的定义公式可知,threshold就是在此loadFactor和capacity对应下允许的最大元素数目,超过这个数目就重新resize,以降低实际的负载因子。默认的的负载因子0.75是对空间和时间效率的一个平衡选择。当容量超出此最大容量时, resize后的HashMap容量是容量的两倍。
注意,此实现不是同步的。如果多个线程同时访问一个哈希映射,而其中至少一个线程从结构上修改了该映射,则它必须 保持外部同步。(结构上的修改是指添加或删除一个或多个映射关系的任何操作;仅改变与实例已经包含的键关联的值不是结构上的修改。)这一般通过对自然封装该映射的对象进行同步操作来完成。如果不存在这样的对象,则应该使用 Collections.synchronizedMap 方法来“包装”该映射。最好在创建时完成这一操作,以防止对映射进行意外的非同步访问,如下所示:
Map m = Collections.synchronizedMap(new HashMap(...));
由所有此类的“collection 视图方法”所返回的迭代器都是快速失败 的:在迭代器创建之后,如果从结构上对映射进行修改,除非通过迭代器本身的 remove 方法,其他任何时间任何方式的修改,迭代器都将抛出 ConcurrentModificationException。因此,面对并发的修改,迭代器很快就会完全失败,而不冒在将来不确定的时间发生任意不确定行为的风险。
注意,迭代器的快速失败行为不能得到保证,一般来说,存在非同步的并发修改时,不可能作出任何坚决的保证。快速失败迭代器尽最大努力抛出 ConcurrentModificationException。因此,编写依赖于此异常的程序的做法是错误的,正确做法是:迭代器的快速失败行为应该仅用于检测程序错误。
/**
* The table, resized as necessary. Length MUST Always be a power of two.
*/
transient Entry[] table = (Entry[]) EMPTY_TABLE;
/**
* The number of key-value mappings contained in this map.
*/
transient int size;
public V put(K key, V value) {
//如果Hash表是空的,则进行一系列初始化操作
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//如果key是null,则遍历查找表中是否有null键;如果有,则修改其value,返回旧的value
if (key == null)
return putForNullKey(value);
//只有一般的key,才能进行hash,得到hashCode
int hash = hash(key);
//通过hashCode可以确定该应该落入的桶的索引,也就是table数组的下标
int i = indexFor(hash, table.length);
//在这个桶中查找一下该k是不是已经存在,如果存在,则修改旧值,返回旧值
for (Entry e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
//用于防止多线程时被修改的一个机制,如果不理解可以暂时忽略
modCount++;
//在该桶下新建一个
addEntry(hash, key, value, i);
return null;
}
//如果要新建一个,那么就要先判断一下现在table的容量达到阈值没有。如果超过阈值threshold,则table要扩大到原来的两倍,然后还要重现hash一下key,再创建新的entry。以前的table中的entry也会被从新分配,所以Map不能保证顺序
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
//在桶的前端创建新的entry
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
其实put都弄清楚了,get不过是查找,就更容易理解了,所以这里我就不再赘述。
remove稍微比get复杂,因为HashMap底层的table使用的是这样的数据结构:
static class Entry implements Map.Entry {
final K key;
V value;
Entry next;
int hash;
/**
* Creates new entry.
*/
Entry(int h, K k, V v, Entry n) {
value = v;
next = n;
key = k;
hash = h;
}
……………………
}
也就是一种链表。所以我们先找到桶,再找到entry,然后像删除链表节点一样删除这个元素。
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
在源码中,多出强调了table的大小要是2的次幂,为什么呢?
我们可以看到在HashMap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置。如何计算这个位置就是hash算法。前面说过HashMap的数据结构是数组和链表的结合,所以我们当然希望这个HashMap里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表,这样就大大优化了查询的效率。
对于任意给定的对象,只要它的 hashCode() 返回值相同,那么程序调用 hash(int h) 方法所计算得到的 hash 码值总是相同的。我们首先想到的就是把hash值对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,“模”运算的消耗还是比较大的。在HashMap中是这样做的:
/**
* Returns index for hash code h.
*/
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
}
这个方法非常巧妙,它通过 h & (table.length -1) 来得到该对象的保存位,而HashMap底层数组的长度总是 2 的 n 次方,这是HashMap在速度上的优化。在 HashMap 构造器中有如下代码:
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
这段代码保证初始化时HashMap的容量总是2的n次方,即底层数组的长度总是为2的n次方。当length总是 2 的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。这看上去很简单,其实比较有玄机的,我们举个例子来说明:假设数组长度分别为15和16,优化后的hash码分别为8和9,那么&运算后的结果如下。
从上面的例子中可以看出:当它们和15-1(1110)“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到数组中的同一个位置上形成链表,那么查询的时候就需要遍历这个链 表,得到8或者9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为15的时候,hash值会与15-1(1110)进行“与”,那么 最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!
而当数组长度为16时,即为2的n次方时,2n-1得到的二进制数的每个位上的值都为1,这使得在低位上&时,得到的和原hash的低位相同,加之hash(int h)方法对key的hashCode的进一步优化,加入了高位计算,就使得只有相同的hash值的两个值才会被放到数组中的同一个位置上形成链表。
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
所以说,当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。
HashMap要比HashTable出现得晚一些。
从图中可以看出,两个类的继承体系有些不同。虽然都实现了Map、Cloneable、Serializable三个接口。但是HashMap继承自抽象类AbstractMap,而HashTable继承自抽象类Dictionary。其中Dictionary类是一个已经被废弃的类。
从公开的方法上来看,这两个类提供的,是一样的功能。都提供键值映射的服务,可以增、删、查、改键值对,可以对建、值、键值对提供遍历视图。支持浅拷贝,支持序列化。
HashMap是支持null键和null值的,而HashTable在遇到null时,会抛出NullPointerException异常。
这并不是因为HashTable有什么特殊的实现层面的原因导致不能支持null键和null值,这仅仅是因为HashMap在实现时对null做了特殊处理,将null的hashCode值定为了0,从而将其存放在哈希表的第0个bucket中。
HashMap/HashTable还需要有算法来将给定的键key,映射到确定的hash桶(数组位置)。需要有算法在哈希桶内的键值对多到一定程度时,扩充哈希表的大小(数组的大小)。HashMap每次扩充桶数组,都保证其大小是2的幂,从而在进行Hash运算的时候可以用位运算代替除法运算,提高效率。然后为了使哈希结果更加离散,又使用了比较复杂的位运算进行优化。
初始容量大小和每次扩充容量大小的不同。先看代码:
以下代码及注释来自java.util.HashTable
// 哈希表默认初始大小为11
public Hashtable() {
this(11, 0.75f);
}
protected void rehash() {
int oldCapacity = table.length;
Entry[] oldMap = table;
// 每次扩容为原来的2n+1
int newCapacity = (oldCapacity << 1) + 1;
// ...
}
以下代码及注释来自java.util.HashMap
// 哈希表默认初始大小为2^4=16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
void addEntry(int hash, K key, V value, int bucketIndex) {
// 每次扩充为原来的2n
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
}
而HashMap则总是使用2的幂作为哈希表的大小。我们知道当哈希表的大小为素数时,简单的取模哈希的结果会更加均匀,所以单从这一点上看,HashTable的哈希表大小选择,似乎更高明些。但另一方面我们又知道,在取模计算时,如果模数是2的幂,那么我们可以直接使用位运算来得到结果,效率要大大高于做除法。所以从hash计算的效率上,又是HashMap更胜一筹。
所以,事实就是HashMap为了加快hash的速度,将哈希表的大小固定为了2的幂。当然这引入了哈希分布不均匀的问题,所以HashMap为解决这问题,又对hash算法做了一些改动。具体我们来看看,在获取了key对象的hashCode之后,HashTable和HashMap分别是怎样将他们hash到确定的哈希桶(Entry数组位置)中的。
以下代码及注释来自java.util.HashTable
// hash 不能超过Integer.MAX_VALUE 所以要取其最小的31个bit
int hash = hash(key);
int index = (hash & 0x7FFFFFFF) % tab.length;
// 直接计算key.hashCode()
private int hash(Object k) {
// hashSeed will be zero if alternative hashing is disabled.
return hashSeed ^ k.hashCode();
}
以下代码及注释来自java.util.HashMap
int hash = hash(key);
int i = indexFor(hash, table.length);
// 在计算了key.hashCode()之后,做了一些位运算来减少哈希冲突
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
// 取模不再需要做除法
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
}
HashTable是同步的,HashMap不是,也就是说HashTable在多线程使用的情况下,不需要做额外的同步,而HashMap必须加上外同步。
虽然HashMap和HashTable的公开接口应该不会改变,或者说改变不频繁。但每一版本的JDK,都会对HashMap和HashTable的内部实现做优化,比如JDK 1.8中,映射到同一个哈希桶(数组位置)的Entry对象,使用了红黑树来存储,从而大大加速了其查找效率。
遍历是List中最常用的操作。说到遍历,就不得不提Java里的迭代器了。由于Java中数据容器众多,而对数据容器的操作在很多时候都具有极大的共性,于是Java采用了迭代器为各种容器提供公共的操作接口。使用Java的迭代器iterator
可以使得对容器的遍历操作完全与其底层相隔离,可以到达极好的解耦效果。所以,我们不光要学会怎么使用迭代器,更重要的是,它是一个很好的例子,可以去体会Java的编程思想。拿List当一个切入点,先看看下面几段源码:
public interface List<E> extends Collection<E> {……}
public interface Collection<E> extends Iterable<E> {……}
public interface Iterable<T> {
Iterator iterator();
}
public interface Iterator<E> {
boolean hasNext();
E next();
void remove();
}
总结一下就是List接口继承了Collection接口,Collection接口继承了Iterable
接口,Iterable接口返回一个Iterator,Iterator中只有三种方法:hasNext、next、remove。而在List接口中有一个对应的方法:
Iterator iterator();
所以接下来我们就要看看ArrayList和LinkedList怎么实现iterator这个方法的。
所以,ArrayList返回迭代器的方法就是它本身的一个方法,而LinkedList返回迭代器的方法继承至抽象类AbstractSequentialList。而ArrayList的iterator()来至于它本身的方法。那么上面有提到两个抽象类,AbstractList和AbstractSequentialList,干啥子的咧? 我们一起来扒一扒。
public abstract class AbstractCollection<E> implements Collection<E> {……}
此类提供 Collection 接口的骨干实现,以最大限度地减少了实现此接口所需的工作。
要实现一个不可修改的 collection,编程人员只需扩展此类,并提供 iterator 和 size 方法的实现。(iterator 方法返回的迭代器必须实现 hasNext 和 next。)
要实现可修改的 collection,编程人员必须另外重写此类的 add 方法(否则,会抛出 UnsupportedOperationException),iterator 方法返回的迭代器还必须另外实现其 remove 方法。
按照 Collection 接口规范中的建议,编程人员通常应提供一个 void (无参数)和 Collection 构造方法。
此类中每个非抽象方法的文档详细描述了其实现。如果要实现的 collection 允许更有效的实现,则可以重写这些方法中的每个方法。
public abstract class AbstractList<E> extends AbstractCollection<E> implements List<E> {……}
此类提供 List 接口的骨干实现,以最大限度地减少实现“随机访问”数据存储(如数组)支持的该接口所需的工作。对于连续的访问数据(如链表),应优先使用 AbstractSequentialList,而不是此类。
要实现不可修改的列表,编程人员只需扩展此类,并提供 get(int) 和 size() 方法的实现。
要实现可修改的列表,编程人员必须另外重写 set(int, E) 方法(否则将抛出 UnsupportedOperationException)。如果列表为可变大小,则编程人员必须另外重写 add(int, E) 和 remove(int) 方法。
按照 Collection 接口规范中的建议,编程人员通常应该提供一个 void(无参数)和 collection 构造方法。
与其他抽象 collection 实现不同,编程人员不必 提供迭代器实现;迭代器和列表迭代器由此类在以下“随机访问”方法上实现:get(int)、set(int, E)、add(int, E) 和 remove(int)。
此类中每个非抽象方法的文档详细描述了其实现。如果要实现的 collection 允许更有效的实现,则可以重写所有这些方法。
public abstract class AbstractSequentialList<E> extends AbstractList<E> {……}
此类提供了 List 接口的骨干实现,从而最大限度地减少了实现受“连续访问”数据存储(如链接列表)支持的此接口所需的工作。对于随机访问数据(如数组),应该优先使用 AbstractList,而不是先使用此类。
从某种意义上说,此类与在列表的列表迭代器上实现“随机访问”方法(get(int index)、set(int index, E element)、add(int index, E element) 和 remove(int index))的 AbstractList 类相对立,而不是其他关系。
要实现一个列表,程序员只需要扩展此类,并提供 listIterator 和 size 方法的实现即可。对于不可修改的列表,程序员只需要实现列表迭代器的 hasNext、next、hasPrevious、previous 和 index 方法即可。
对于可修改的列表,程序员应该再另外实现列表迭代器的 set 方法。对于可变大小的列表,程序员应该再另外实现列表迭代器的 remove 和 add 方法。
按照 Collection 接口规范中的推荐,程序员通常应该提供一个 void(无参数)构造方法和 collection 构造方法。
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable{……}
public Iterator iterator() {
return new Itr();
}
/**
* An optimized version of AbstractList.Itr
*/
private class Itr implements Iterator<E> {
int cursor; // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
int expectedModCount = modCount;
public boolean hasNext() {
return cursor != size;
}
@SuppressWarnings("unchecked")
public E next() {
checkForComodification();
int i = cursor;
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
所以,ArrayList是在抽象类AbstractList上创建的。虽然AbstractList中已经实现了iterator(),但是ArrayList还是对其进行了重写,也就是优化。
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{……}
LinkedList并没有对iterator()进行重写,所以它的iterator()来至AbstractSequentialList,而AbstractSequentialList中的iterator()实际上也是继承于AbstractList的listIterator()。233333……这里又引出另一个原先被忽略的接口ListIterator。
列表迭代器,允许程序员按任一方向遍历列表、迭代期间修改列表,并获得迭代器在列表中的当前位置。ListIterator 没有当前元素;它的光标位置 始终位于调用 previous() 所返回的元素和调用 next() 所返回的元素之间。长度为 n 的列表的迭代器有 n+1 个可能的指针位置。
注意,remove() 和 set(Object) 方法不是 根据光标位置定义的;它们是根据对调用 next() 或 previous() 所返回的最后一个元素的操作定义的。
上面看似讲了很多,实际上只是将Java Iterator的由来和几个接口和类之间的继承关系列了一下,主要是为了满足博主自己的好奇心。在使用上,迭代器是非常简单的,Java作为一个美丽的高级编程语言,使很多细节透明化了。
在这里,我想强调的一点是:效率问题。ArrayList是数组,LinkedList是双向链表,get对于前者需O(1),对于后者是O(N/2);那么对于这样一段反复get的代码:
public static int sum1(List list) {
int total = 0;
for(int i=0;ireturn total;
}
前者需O(N),对于后者是O(N^2),因为LinkeList的每一次get都需要沿着链表遍历一部分到指定index。(对于这一点如果不是很理解,可以参考博主的上一篇博客:http://blog.csdn.net/dustin_cds/article/details/50788608)但是如果改用Iterator遍历,则不管是ArrayList还是LinkedList都只会遍历一次,都是O(N):
public static int sum2(List list) {
int total = 0;
int i = 0;
for (Iterator iterator = list.iterator(); iterator.hasNext();) {
Integer integer = (Integer) iterator.next();
i = integer.intValue();
total += i;
}
return total;
}
或者这样写:
public static int sum3(List list) {
int total = 0;
int i=0;
for (Integer integer : list) {
i = integer.intValue();
total += i;
}
return total;
}
foreach的原理也是利用Collection的迭代器,所以sum3实际上和sum2等价,而且代码更加简洁,所以推荐使用foreach写法。所以,在对容器进行遍历的时候,推荐使用迭代器。
ArrayList的remove:一次shift而已
public E remove(int index) {
rangeCheck(index);
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}
LinkedList的remove:通过部分遍历找到节点然后调整引用而已
public E remove(int index) {
checkElementIndex(index);
return unlink(node(index));
}
ArrayList的Iterator的remove:调用ArrayList的remove,每次只能删除last element returned,而不是任意删除。删除之后会对expectedModCount进行维护,可以简单地理解为一种防止迭代器混乱的机制。
public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
LinkedList的Iterator的remove:
//AbstractSequentialList中
public E remove(int index) {
try {
ListIterator e = listIterator(index);
E outCast = e.next();
e.remove();
return outCast;
} catch (NoSuchElementException exc) {
throw new IndexOutOfBoundsException("Index: "+index);
}
}
//LinkedList中
public ListIterator listIterator(int index) {
checkPositionIndex(index);
return new ListItr(index);
}
private class ListItr implements ListIterator<E> {
private Node lastReturned = null;
private Node next;
private int nextIndex;
private int expectedModCount = modCount;
ListItr(int index) {
// assert isPositionIndex(index);
next = (index == size) ? null : node(index);
nextIndex = index;
}
…………………………
public void remove() {
checkForComodification();
if (lastReturned == null)
throw new IllegalStateException();
//ListIterator 没有当前元素;它的光标位置 始终位于调用 previous() 所返回的元素和调用 next() 所返回的元素之间。长度为 n 的列表的迭代器有 n+1 个可能的指针位置
Node lastNext = lastReturned.next;
unlink(lastReturned);
if (next == lastReturned)
next = lastNext;
else
nextIndex--;
lastReturned = null;
expectedModCount++;
}
……………………
}
同样的,LinkedList的迭代器的remove会调用LinedList的unlink方法,然后会维护expectedModCount。
所以,关于迭代的一个需要注意的问题就是,在跌代器跌代的过程中不能用集合的remove,因为他会改变modCount这个指标,提示迭代器,集合已经发生改变,可能造成混乱,系统会报错。示例:
但是,如果用普通的for循环是可以的,只是效率上很低而已。不论是ArrayList还是LinkedList的remove的时间复杂度都是O(N),如果进行removeEvensVer这样的操作,就要花费O(N^2)。这是不可取的。
如果使用迭代器的remove方法,则一次遍历即可。对于LinkedList,由于是找到了偶数节点才删除,所以一次remove花费常数时间,总过程花费线性时间。对于ArrayList,由于每次remove都需要shift,所以最好情况(一个为偶数的节点都没有)则花费O(N);最坏情况(每个节点的值都是偶数),则花费O(N^2);所以,就变为了一个性能与输入数据相关的算法,但总的来讲,比for循环遍历然后调用Collection的remove方法的效率要高。
综上所述:
所以数组一般通过在高端进行插入操作建成,之后只访问,不进行插入和删除,尤其是在表的前端。
新建的数组中保存的实际对象是保存在堆(heap)中的;如果引用该数组的数组引用变量是一个局部变量,那么它被保存在栈(stack)中。例如:
int[] test = new int[100];
test这个引用变量是保存在栈中的,那新建的100int的数据是保存在堆中的。虽然它还没有初始化,但是内存已经分配给它了。
当一个方法执行时,每个方法都会建立自己的内存栈,在这个方法内定义的局部变量都会放入这块内存里,随着方法的执行结束,这个方法的内存栈也将自然销毁。因此,所有在方法中定义的局部变量都是放在栈中的。
但是,程序中被new出来的东西一般都放在堆内存中以便程序反复利用,这个堆内存也叫作【运行时数据区】,这个称号更能表述它的性质不是吗。
堆中的对象是不会随方法的结束而立刻销毁,只要还有引用变量指向它,GC就不会回收。这一点在方法的参数传递中很常见。只有当堆中的对象没有一个引用变量指向它,GC才会在合适的时候回收它。所以在JDK源码中,我们经常看到一些没用了的引用变量被显式地赋值为null,就是为了让GC尽快回收被创建的对象。
Java 数组初始化的两种方法:
- 静态初始化: 程序员在初始化数组时为数组每个元素赋值;
- 动态初始化: 数组初始化时,程序员只指定数组的长度,由系统为每个元素赋初值。
public class ArrayInit {
public static void main(String[] args) {
//静态初始化数组:方法一
String cats[] = new String[] {
"Tom","Sam","Mimi"
};
//静态初始化数组:方法二
String dogs[] = {"Jimmy","Gougou","Doggy"};
//动态初始化数据
String books[] = new String[2];
books[0] = "Thinking in Java";
books[1] = "Effective Java";
System.out.println(cats.length);
System.out.println(dogs.length);
System.out.println(books.length);
}
}
典型错误是:
int[] nums = new int[6]{1,2,3,4,5,6};
常规的说法是“Java支持多维数组”,但是,从本质上来讲,根本没用什么多维数组,不过是引用变量数组而已。当然,说法上不用太纠结。这里就强调一下多维数组的新建,两种:
//先新建第一纬度,再新建第二纬度
int[][] test = new int[4][];
for(int i=0; inew int[100];
}
//两个维度同时新建
int[][] test2 = new int[4][100];
System.out.println(test2.length);
System.out.println(test2[0].length);
Arrays 在Java 8 的 java.util 包下,包含一些static的可以直接操纵数组的方法:binarySearch、copyOf、copyOfRange、equals、fill、sort、toString。使数组与String越来越像了。
Collection接口扩展了Iterable接口。
实现了Iterable接口的类可以拥有增强的for循环:
for(AnyType item: coll)
简单说就是抽象类的的某些派生类实现里,或者接口的某个实现类里面,某个方法可能是无意义的,调用该方法会抛出一个异常。例如在collection的某些实现类里,里面的元素可能都是只读的,那么add这个接口是无意义的,调用会抛出UnspportedOperationException异常。
从设计的角度说,如果一个接口的方法设计为optional,表示这个方法不是为所有的实现而设定的,而只是为某一类的实现而设定的。
对List的实现有两种:
- ArrayList:可增长的数组。因为是数组嘛,get,set效率高,remove和insert代价高;
- LinkedList:双链表。insert和remove的代价相对于数组更高一些,但是平均时间开销也是线性的。更加不建议使用get和set。一般使用LinkedList进行最多的应该是两端的操作,所以它提供了addFirst,removeFirst,addLast,removeLast,以及getFirst,getLast等操作。
为了更好的理解这两个List,接下来会对它们的源码简要分析,然后着重看看add/remove,get/set这四个最常用的方法的源码。
首先,来看看它的成员变量与基本的构造函数,将它就是个数组的屌丝特性暴露无遗:
public ArrayList(int initialCapacity) {
super();
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
this.elementData = new Object[initialCapacity];
}
public ArrayList() {
super();
this.elementData = EMPTY_ELEMENTDATA;
}
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
上述代码不难看出,add(E e) 是将新元素追加到数组末尾的。
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++;
}
这里的void java.lang.System.arraycopy(Object src, int srcPos, Object dest, int destPos, int length)即将数组从index到末尾的元素统一往后移了一位,然后将elementData[index]赋值为指定的数值,也就是典型的数组插入嘛。
public E remove(int index) {
rangeCheck(index);
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index, numMoved);
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}
检查index是否越界,保存要删除元素的值,数组shift,elementData[–size] = null,返回删除的值。
public E get(int index) {
rangeCheck(index);
return elementData(index);
}
数组取值O(1)爽啊!
public E set(int index, E element) {
rangeCheck(index);
E oldValue = elementData(index);
elementData[index] = element;
return oldValue;
}
数组赋值效率高啊!
首先,我把可以反映LinkedList本质的一些源码贴在一起:
private static class Node<E> {
E item;
Node next;
Node prev;
Node(Node prev, E element, Node next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
transient int size = 0;
transient Node first;
transient Node last;
public LinkedList() {
}
public LinkedList(Collection extends E> c) {
this();
addAll(c);
}
看到上面这些代码,我们应该立刻明白,几个特殊情况:
再来看看内部有特点的方法,反映了处理双向链表的编程艺术,值得好好看看,而且只有把这些方法弄明白了,就可以把LinkedList理解透彻了。
linkFirst与linkLast需要注意的特殊情况是list是否为空:
/**
* Links e as first element.
*/
private void linkFirst(E e) {
final Node f = first;
//newNode prev为null,值为e,next为f
final Node newNode = new Node<>(null, e, f);
first = newNode;
//如果原list为空的话,把last也赋值为newNode;不为空则将原first的prev指向新first
if (f == null)
last = newNode;
else
f.prev = newNode;
//时刻注意维护size
size++;
modCount++;
}
/**
* Links e as last element.
*/
void linkLast(E e) {
final Node l = last;
//newNode prev为原last,值为e,next为null
final Node newNode = new Node<>(l, e, null);
last = newNode;
//如果为空
if (l == null)
first = newNode;
//如果不为空
else
l.next = newNode;
size++;
modCount++;
}
linkBefore需要注意的特殊情况是插入的位置是否在表头:
/**
* Inserts element e before non-null Node succ.
*/
void linkBefore(E e, Node succ) {
// assert succ != null;
final Node pred = succ.prev;
//一步就在succ与succ.prev之间插入了一个新Node,接下来调整引用
final Node newNode = new Node<>(pred, e, succ);
succ.prev = newNode;
//如果插入的位置为表头,则first需要更改
if (pred == null)
first = newNode;
else
pred.next = newNode;
size++;
modCount++;
}
unlinkFirst需要注意的特殊情况是如果list只有一个Node,则last也需要修改
/**
* Unlinks non-null first node f.
*/
private E unlinkFirst(Node f) {
// assert f == first && f != null;
final E element = f.item;
final Node next = f.next;
f.item = null;
f.next = null; // help GC
first = next;
if (next == null)
last = null;
else
next.prev = null;
size--;
modCount++;
return element;
}
unlinkLast需要注意的特殊情况是如果list只有一个元素,则first也需要修改
/**
* Unlinks non-null last node l.
*/
private E unlinkLast(Node l) {
// assert l == last && l != null;
final E element = l.item;
final Node prev = l.prev;
l.item = null;
l.prev = null; // help GC
last = prev;
if (prev == null)
first = null;
else
prev.next = null;
size--;
modCount++;
return element;
}
unlink任意节点需要注意的特殊情况是这个节点在两端。一般的思想可能是如果是首节点,写一段代码;是尾节点写一段代码;在中间写一段代码。但这里的思路是以要操纵的节点为中心,考虑它的前一个节点后后一个节点的情况。代码量减少,思路更清晰,这样的思路更具有普遍意义。
/**
* Unlinks non-null node x.
*/
E unlink(Node x) {
// assert x != null;
final E element = x.item;
final Node next = x.next;
final Node prev = x.prev;
if (prev == null) {
first = next;
} else {
prev.next = next;
x.prev = null;
}
if (next == null) {
last = prev;
} else {
next.prev = prev;
x.next = null;
}
x.item = null;
size--;
modCount++;
return element;
}
然后来看看add/remove,get/set是怎么实现的,与ArrayList有什么区别。
public boolean add(E e) {
linkLast(e);
return true;
}
空间add就是添加在list的末尾。
public boolean remove(Object o) {
//哈哈,果然源码是最准确的,原来remove(null)都可以,我才知道!!!
if (o == null) {
for (Node x = first; x != null; x = x.next) {
if (x.item == null) {
unlink(x);
return true;
}
}
} else {
//删除掉第一个找到的节点
for (Node x = first; x != null; x = x.next) {
if (o.equals(x.item)) {
unlink(x);
return true;
}
}
}
return false;
}
//移除首节点除外
public E remove() {
return removeFirst();
}
如果index在前半段,从first开始遍历;如果在后半段,从last开始遍历
/**
* Returns the (non-null) Node at the specified element index.
*/
Node node(int index) {
// assert isElementIndex(index);
if (index < (size >> 1)) {
Node x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}
public E set(int index, E element) {
checkElementIndex(index);
Node x = node(index);
E oldVal = x.item;
x.item = element;
return oldVal;
}