上次关于JAVA的文章《SUN核心解析--常规程序--基本类》从简单基本类简单说名了JAVA的部分原理以及JAVA的底层对于编写优秀代码的重要性,但是就知识面是较窄的,包括今天说的集合类,也只是皮毛而已,只是从我个人的角度,由一点点实践希望可以在这方面起到一点抛砖引玉的作用,下面进入正题:
首先我从自己的角度来说,对于JAVA的内存管理思想(因为从我的角度来说不知道内存大致怎么样的,学习集合类只是应付一些常规开发而已,对集合类的底层根本一无所知,也许通过本文对集合类还是不太理解,但是大致原理我相信还是知道一点点的),从抽象角度说就只有句柄(handle或者叫引用)保存一个实体内存的首地址,只是实体可能也是一个数组,用它的每一个位置可能会指向其它的位置,逐层连接。其次,不论是什么集合类,内存管理其实都是基于数组或链表或者是两者的结合的基础上建立类,定义其一些列属性和操作方法,成为集合类,所以不要觉得它很神奇,他存在的理由是JAVA是面向对象的,对于信息的操作完全是进行了封装,进而达到安全、减少冗余和错误概率等等目,首先下面给一张JAVA中最直观简单的内存管理模式(当然有很多细节,逐步阐述,这里先阐述一点只是引开集合类的话题):
这是一种最简单的说法,当然大体上可以这样说明,不过JAVA内部如果要细节分还有:新域、旧域、救助域、永久域,这部分在核心层介绍时进行说明,这里将整个堆看成一个整体,继续说明对象数组的管理方式:
记住,这是对象数组的管理方式,也就是数组中每个元素都是一个对象,如果数组中每个元素是基本类型如:int、char、float、double、byte、long、boolean、short、等等,就只会到第二层(其值直接存储在数组内部),尤其像String类型的对象,内部对char数组的管理,是比较特殊的,这里不说太多东西,目前只讨论集合类,集合类和这种方式唯一的区别就是,在这个基础上套了一层,也就是在一个类的内部定义了一个数组或者链表结构或者树结构,在这个基础上提供一系列的插入、修改、删除、克隆等等方法,以在不同的场合灵活应用达到高效、安全的目的,并且这部分代码也不用你自己去编写了(付:之所以写那么多集合类,是因为要应对不同的应用场合,在效率和开销以及安全等角度进行取舍,这个需要细细研究每一种集合类的特性),不论是数组、链表、树结构原理一致,数组保存首地址、链表保存头结点的地址、树结构保存根节点的地址,这里就不一一说明,只是以数组为基础阐述问题,从个人的角度一般只抽象为两个概念:连续的空间和不连续的空间。
我们假象模拟一下一个很简单集合类的方式:
可能你已经注意到数组下标3和数组下标1指向同一块内存空间,这是允许的,因为仅仅是保存地址而已,除非在Set集合类里面(其实SET内部是用MAP管理的),只要通过计算后得到的最终KEY值是相同的,后者会替换前者,因为SET的基本规律就是不允许重复数据存在,这里先抛开Set集合方式,我们主要以集合类最常用的ArrayList和HashMap作为研究对象,顺带简单提及一些的其他的数据结构。
上述图形容易让很多人产生误解,因为这样会让人觉得类对应的实体包含了内部数据的实体,这两部分在内存分布上其实是分开的(逻辑上可以认为它是在一起的),我们现在抛开是那一种数据结构,假如在自定义类内部定义了基本数据、对象数据、普通类型数组、对象数组、对象链表,给一种较全的对象指向关系(不包含树和图),任何数据结构我们暂时都可以抽象为数组和链表结构,树和图在其基础上有更多的特征,这里图形就不展示了,可以自己研究:
这里简单说明下,首先看出的是句柄和实体都是分开的,基本数据类型直接存储,基本类型的数组的值直接存储在申请的数组空间内,对象类型的数组,数组内部存储的还是句柄,链表结构只是将存储句柄的方式改成了链表,而没有将真正的操作实体进行链表化,因为实体是你自己定义的,它没有能力在你的实体内部做一个next句柄来指向下一个节点;学过数据结构的知道,如果结构内的数据经常发生增加、删除操作,此时链表是较好的操作(JAVA中带Linked开头的都有使用链表结构来完成),因为它不用来回移动元素,而不经常改变这些信息,用于经常定位数据,使用数组级别的列表是较好的选择,因为数组是通过下标定位句柄位置的(句柄都是存储地址的信息,以及包含一个所指向实体的类型的tag,所以它存储的始终是long型的地址信息,我们先抛开类型,如你要获取a[10],数组首地址和a[0]一致,那么a[10]地址自然是a[0]地址+(long的byte长度))JAVA中long的长度为8个byte位,即64个二进制位。
首先我们要对JAVA常用的集合类的概括和继承关系要有一个初步的认识,我这里简单画了一个图形表示常用结合类的接口、对象的继承和实现关系如下图所示:
这里描述了常见集合类的关系,所有类都放在java.util包下,其次,还有Enumeration、Iterator、Dictionary辅助集合类,以及不太常用的Query、AbstractQueue、PriorityQueue、WeakHashMap、LinkedHashSet、EnumMap、EnumSet、ListIterator;另外对于java.util包下面还有很多非常用的实用类,本文没有详细阐述,只是在必要时候适当携带;由于图形版面所限,这里只是列个大概,实现源码分析就阐述两个最常用的ArrayList和HashMap,这两个结构操作细节有所不同,不过实现底层的结构都是数组,细节可以继续研究,接下来才进入正题。
第一步,看ArrayList常用的源码部分:
首先来看下ArrayList属性定义部分:
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
//这里的E就是在泛型定义时设置的数据类型,说明它也是内部使用数组实现的
private transient E[] elementData;
private int size;//为数组使用的长度,而不是数组实际的长度
然后看下几个构造方法:
第一个最常用的(看不出任何东西,只是自己调用另一个带一个参数的构造方法):
public ArrayList() {
this(10);
}
第二个构造方法(可以看到调用了父亲类,这个父亲类方法体是空的,最重要的是,创建了一个长度为指定长度的数组,并强制类型转换为泛型指定的长度了,通过上一个构造方法可见:若使用new ArrayList()创建,默认情况下即使自己不使用,也会创建10个长度的空间的数组,只是这些数组存储的是句柄而已,所以如果你知道数组的长度,此时最好使用new ArrayList(数组实际长度);这样比较节约空间):
public ArrayList(int initialCapacity) {
super();
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal Capacity: "+initialCapacity);
this.elementData = (E[])new Object[initialCapacity];
}
第三个构造方法为通过Collection进行数据拷贝过程,这里就不多说了,因为和阐述问题的关键关系不大,而且这个方法我不是很推荐,经过查看JAVA源码发现它这段代码写得不怎么样,在内部调用toArray()的时候,传入的参数创建了一个大数组进去,结果那个大数组只是判定一下返回值类型,根本不适用,所以这段句柄空间是浪费的。
看第一个方法(通过方法发现,数组将多余的空节点去除掉,也就是将数组的长度和实际使用的长度保持一致,这个方法很少使用,因为我们发现它还要申请新的空间去做拷贝,时间和空间上的拷贝都不是那么乐观,只是由于该集合类可能浪费的垃圾空间(创建时就有预留的10个空间)过多,但是想将该集合类长期驻留内存,且几乎不会改变大小,此时为了节约空间,我们可以采用以下方式,前段申请的句柄将被在适当的时候被回收掉):
public void trimToSize() {
modCount++;
int oldCapacity = elementData.length;
if (size < oldCapacity) {
Object oldData[] = elementData;
elementData = (E[])new Object[size];
System.arraycopy(oldData, 0, elementData, 0, size);
}
}
再看第二个方法(非常常用的就是获取集合类实际的使用长度,原来它是一个属性,至于属性如何变化,下面的方法看了自有结论):
public int size() {
return size;
}
这个方法是判定集合类的内部数据是否为空的情况,其实也是判定size属性,只是代码非常干净利落。
public boolean isEmpty() {
return size == 0;
}
下面这个方法貌似看不出什么(因为是调用另一个方法完成的查看数据是否在集合内部存在,那么肯定就要看编写的indexOf方法了):
public boolean contains(Object elem) {
return indexOf(elem) >= 0;
}
看下indexOf集合类方法吧,返回的是匹配成功的下标(可以看出两个特征,一个是即使是null也能匹配;其次若不为null对象的匹配使用的是equals,所以当你的集合类内部使用的对象非SUN默认提供的类似于String、Integer、Double等等,那么需要你自己重写Object的equals方法,否则它将直接使用Object的该方法,该方法默认对比的是句柄信息,那么你使用的contains将始终为false):
public int indexOf(Object elem) {
if (elem == null) {
for (int i = 0; i < size; i++)
if (elementData[i]==null)
return i;
} else {
for (int i = 0; i < size; i++)
if (elem.equals(elementData[i]))
return i;
}
return -1;
}
对于方法:public int lastIndexOf(Object elem)就不多说,和indexOf区别就是从后向前找而已。
下面看下克隆方法,这是被很多人忽略的方法,但是也是很好用的方法,我们在数据转换为保证安全性并快速复制的过程,使用克隆是非常好的选择;
从第一段代码看出已经implementsCloneable,只要我们实现了clone方法,就可以进行克隆了,其实你会发现Cloneable这是一个空接口,这里可能是比较疑问的一件事情,这里展开一点小知识:
1、JAVA的记号接口,JAVA中存在很多记号接口,即那些接口是空的,但是他们标记着一定的意义(就像标记一种状态一样),内部方法体将根据实现的这些类的实体运行时,必要时做一定的内部控制,如:Serializable是序列号的操作接口,允许你按照以该类所申请的对象为单位进行流的读写操作;而实现Cloneable接口的类的实体,在调用clone()方法时认为是合法的,否则会抛出异常:CloneNotSupportedException,这部分是由底层C语言内部机制完成,对外是透明的,因为这部分不允许被重写。
2、clone()的过程,是形成一个副本,所谓副本就是对实体对象进行了copy,这个过程由Object类内部的本地化方法完成,它实现对实体的内存复制,但是若该实体内部还有定义其它的实体,此时他们定义的句柄将会指向同一块内存,如下图所示:
很明显一个特征就是:当克隆实体进行add、remove等类型操作时,对原实体也会产生影响,所以JAVA必须要对其进行进一步的深度拷贝,在ArrayList内部clone()我们看下源码是在这样的:
public Object clone() {
try {
ArrayList<E> v = (ArrayList<E>) super.clone();
v.elementData = (E[])new Object[size];
System.arraycopy(elementData, 0, v.elementData, 0, size);
v.modCount = 0;
return v;
} catch (CloneNotSupportedException e) {
throw new InternalError();
}
}
可以看出,克隆后的对象被创建了一个新的数组,新数组的内容通过System.arraycopy将句柄内容拷贝,申请大小为原数组使用的大小size而不是原数组的长度。我们在数据传送、静态存储,可以保证安全性以及转换的效率,在实际应用中必要时推荐使用,在深度拷贝后,实现的示意图修改成如下图所示:
看下toArray()方法吧,有些时候我们也使用:
public Object[] toArray() {
Object[] result = new Object[size];
System.arraycopy(elementData, 0, result, 0, size);
return result;
}
可以见toArray()也是申请了一个新的数组句柄空间,大小为使用的空间大小,并将句柄存储的值进行拷贝,和克隆有点相似,但是它毕竟是基本类型,而克隆是返回实体对象,在一般情况JAVA中还是支持对象操作,不过数组直接操作也有好处,那就是快,因为你通过源码读取发现它还是对数组操作,只是封装了一些操作而已,达到安全、封装、抽象公共操作等的目的。
对于public <T> T[] toArray(T[] a)方法细节的这里不想多说了,你可以知道一点点使用就是在JDK1.5以后,如果定义了泛型,若toArray()的数据不想逐个强制类型转换回来就很简单了,如:
List <String>list = new ArrayList();
list.add("zhongguo");
list.add("meiguo");
String []str = list.toArray(new String[0]);//这一步可以直接返回String类型的数组,不用强制类型转换了(JDK 1.5)。
看下常用的get方法(RangeCheck方法可以不关心它,它负责进行下标校验,若不符合规范,则抛出IndexOutOfBoundsException异常,而功能是返回数组内部第index个元素内部值,其实就是该下标值存储的句柄):
public E get(int index) {
RangeCheck(index);
return elementData[index];
}
一个不常用的set方法(这个太简单了,就是改变一个下标的句柄指向):
public E set(int index, E element) {
RangeCheck(index);
E oldValue = elementData[index];
elementData[index] = element;
return oldValue;
}
再看下非常长常用的add方法(发现第二行后,全是句柄复制,那我们最想看的还是ensureCapacity方法,因为它才是处理核心):
public boolean add(E o) {
ensureCapacity(size + 1);
elementData[size++] = o;
return true;
}
那就看一下ensureCapacity方法吧:
public void ensureCapacity(int minCapacity) {
modCount++;
int oldCapacity = elementData.length;
if (minCapacity > oldCapacity) {
Object oldData[] = elementData;
int newCapacity = (oldCapacity * 3)/2 + 1;
if (newCapacity < minCapacity)
newCapacity = minCapacity;
elementData = (E[])new Object[newCapacity];
System.arraycopy(oldData, 0, elementData, 0, size);
}
}
通过add方法可以看出,传入的长度为当前使用长度+1,若发现长度比数组的整体长度还要长,那么就申请一个为以前3/2+1个长度的空间来存储,但是这块空间是新的空间,申请后将会将以前的数组句柄部分拷贝到新的空间,那么以前的空间毫无疑问成为了垃圾空间,而且这些垃圾空间还是句柄指向了一些实体,当这块空间没有被回收掉以前,即使使用集合类的clear方法,他们所指向的实体也不会被认为是垃圾内存,实际的数据结构可能还存在多层的现象,那样会产生更多的句柄指向,JVM有些时候就在考虑其性能的基础上的算法,就很不容易清楚了,JVM的垃圾空间就是我们这样不经意间逐步形成的,所以我们要尽量让他进行内存拷贝和产生垃圾,就最好能提前预知大致的空间长度,用完我们就尽量将它clear()掉,这是最好的习惯,对于clear()文章后面会详细介绍。
对于public void add(int index, E element)方法多一步操作就是通过RangeCheck方法校验位置,在进行插入前要将当前位置后的所有数组向后移动一个位置。
public E remove(int index)源码也可以自己看下,也很简单,通过方法RangeCheck校验下标后,从指定位置起后的数组元素向前移动一个位置,最后一个位置设置为null,数组使用量size减少1。
public boolean remove(Object o)指定对象删除,其实这个方法看了内部后发现循环和对比过程和indexOf很像,说明要删除对象,若非普通对象,也需要自己重写equals方法才行,内部调用了一个fastRemove(int index)方法,它负责删除,看下源码:
private void fastRemove(int index) {
modCount++;
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index, numMoved);
elementData[--size] = null;
}
可以发现没有使用RangeCheck校验下标,外部你也会发现你根本没法使用这个方法,因为他是private类型的,因为它不安全,只能在内部被调用,被调用以前就会确定下来这个下标必须是存在的。
再看一个推荐使用完集合类使用的但是很多人都很没有习惯使用的方法:
public void clear() {
modCount++;
for (int i = 0; i < size; i++)
elementData[i] = null;
size = 0;
}
看起来很简单,将每个元素的句柄置为null,然后空间为空,其实这是简单化内存的句柄,因为当你有大量的复制和深度拷贝过程后,通过上面的方法发现JAVA的结构内部句柄将会非常混乱,很多数组的句柄空间没有被回收,它所指向的实体空间即使即使目前所指向的集合类进行clear()操作,同样有一些不可知的句柄指向他们,这样越来越多就会形成大量的垃圾内存了。
剩下还有一些:addAll、removeRange、RangeCheck、writeObject、readObject等等方法就自己查看源码吧。
通过上述源码的分析,每个步骤都应该得出一些编码的经验和注意事项,因为很多时候不惊异间就产生了很多的垃圾内存,当然垃圾内存的形成还有很多,这只是在JAVA中常见的一类而已,后续相关说明中会提及。
对于Vecter来说,你看完源码后你会发现主要和ArrayList有两个特点的区别,一个是它进行add和remove操作的时候会对方法体进行synchronized操作,即只有同一个线程能在一个时候调用同一个实体的同一个方法,即相当于同步一个代码段(关于线程和同步来说可能会专门说明,内存也分代码段和数据段,一般我们只关心数据段,但是如果知道代码段对实现一些细节的理解更加有帮助),而另一个区别就是当Vecter内部的空间不够用的时候,新增加空间大小默认情况下是按照2倍增长,但是Vecter可以再创建时设置每次增长的长度大小,增长时判定的条件就是对应的增长数字你是设置为非0,当然默认为0了。
第二部分,HashMap源码部分:
首先也是看下属性定义个继承实现部分:
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
{
static final int DEFAULT_INITIAL_CAPACITY = 16;
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
transient Entry[] table;
transient int size;
int threshold;
final float loadFactor;
transient volatile int modCount;
static final Object NULL_KEY = new Object();
可能发现这些东西看不懂,暂时不用管,暂时知道table是存储数据的就OK,而Entry是数据类型,其实它是一个内部类,下面会详细介绍和说明。
先看第一个默认的构造方法(发现有点晕,大概知道loadFactor被设置为默认值0.75f、threshold 被设置为默认值16*0.75,table申请了一个长度为默认长度16的数组,然后调用一个init()方法,这个方法不用看,是空body,用以扩展的,这里初步结论是:默认情况下HashMap会给你申请长度为16,类型为Entry的数组):
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
table = new Entry[DEFAULT_INITIAL_CAPACITY];
init();
}
再看下带一个int参数的空间的构造方法(貌似看不出什么,带上自身的一个默认值0.75f,携带传入的参数,调用两个参数的方法):
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
看下两个参数的构造方法体(可以看出第一个参数是用于创建数组大小的,但是这个数组大小并不是和你设置的大小一摸一样,而是2的多少次方,直到这个2的多少次方大于等于你的参数,否则继续左移动,其他参数的操作除了一些参数的异常外,就和无参数的一致,当然这里可以修改loadFactor的大小而已,不过通过异常判定猜出的一点就是:数组的长度应该不能大于MAXIMUM_CAPACITY,这个值为1<<30,即2的30次方为HashMap允许的数组最大长度):
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);
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
this.loadFactor = loadFactor;
threshold = (int)(capacity * loadFactor);
table = new Entry[capacity];
init();
}
对于构造方法:public HashMap(Map<? extends K, ? extends V> m)也不必多提了。
另外对于size()、isEmpty()、clear()、clone()方法和ArrayList的实现思路差不多,不必多提,我们这里主要还是说明下插入删除等方法吧。
首先看插入(这段看懂几乎整个HashMap的实现机制都明白了,第一行maskNull(key)是对传入KEY的空值转转换为前序定义的:NULL_KEY,其次hash(k)是根据将空值判定后的计算出一个hash值,indexFor将会按位求与的方式得到一个所在的数组下标(同一个节点存储多个元素时使用Entry对象的next属性做链表),得到对应下标后,即为一个Entry链表的首地址,开始循环遍历该链表,若存在某hash的值与传入key的hash值相同,则替换数据,所以它没有重复的key,当没有发现时,调用addEntry方法新增数组节点来完成,后续说明):
public V put(K key, V value) {
K k = maskNull(key);
int hash = hash(k);
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
if (e.hash == hash && eq(k, e.key)) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, k, value, i);
return null;
}
上述需要调用的内部方法:
static <T> T maskNull(T key) {
return key == null ? (T)NULL_KEY : key;
}
static int hash(Object x) {
int h = x.hashCode();
h += ~(h << 9);
h ^= (h >>> 14);
h += (h << 4);
h ^= (h >>> 10);
return h;
}
static int indexFor(int h, int length) {
return h & (length-1);
}
而对于addEntry方法实现如下:
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
if (size++ >= threshold)
resize(2 * table.length);
}
说明创建了一个新的Entry结点,并放入当前使用的最后一个index后面,如果发现size>=threshold则申请以前二倍的空间(resize我们不看都知道要申请以前2倍的空间)那么threshold是什么,就是在初始化的时候设置的信息,等价于数组长度*0.75f,当然这个0.75f是可以被修改的;这个new Entry貌似有些奇怪,好像就直接由数组指向他就完了,那么其他的链表结构呢,看下new Entry这个内部类的构造方法就知道了,因为它传入了一个e,而e是在赋值以前得到原来数组所指向的Entry节点,此时将e赋值进去后,会成为这个新生成的Entry的next节点句柄,它就这样形成链表了。
而resize方法内部会申请空间,因为申请空间部分都差不多,所以不多提,它有一个特殊的操作,还会做一个transfer()操作,为什么它不直接使用像ArrayList一样的数组拷贝呢?从前面可以看到当进行put操作的时候,会根据数组的长度计算数组的下标,当数组的长度发生变化时,计算出来的下标就不一样了,否则当长度发生变化时,通过你的key就找不到对应的数据了。这个transfer操作除了进行句柄的复制以外,还需要重新计算每一个KEY在结构中的位置,所以其开销是非常大的,尤其当这个HashMap比较大的时候,所以还是如果知道HashMap的大,尽量提前预知为实际的4/3,因为你会发现,当最坏的情况数组长度和节点个数一致,但是这种情况是我们查询的时候最理想的,因为所有节点直接就是链表的第一个节点,通过hash值几乎可以直接定位数据,而不需要继续遍历链表,其实0.75的概率也是一个计算出来的较为常用的值,它将形成链表的概率降低得很低,因为数组的长度就比实际的元素个数要多很多,这是算法中典型的用空间换取时间的概念,数组长度为12时,就会申请32个空间,当长度为24时就需要申请64个空间,这在程序中很多地方我们都是可以控制的。
通过上述发现,HashMap内部存放的数据是将KEY转换指定的hash值后得到的,所以在查找数据的时候非常迅速因为对比hash值是基本直接定位到数组的的下标的,链表的长度一般不会太长,所以hash的查找算法可以使用O(1)来说明,所以提供了一个方法(查找数据是否存在这个方法是很快速的,而且key值不用重写equals方法,因为不是比较对象值,而是计算出的hash信息):
public boolean containsKey(Object key) {
Object k = maskNull(key);
int hash = hash(k);
int i = indexFor(hash, table.length);
Entry e = table[i];
while (e != null) {
if (e.hash == hash && eq(k, e.key))
return true;
e = e.next;
}
return false;
}
另外有一个default范围类型的获取对象方法:Entry<K,V> getEntry(Object key)获取方法类似,对于remove操作其实这里说没多大意义,因为remove操作只是在对应数组所指向的链表中找到对应的对象,链表删除节点是很简单的过程。
上述hash曾经让我产生过联想,因为hash值是int类型的,所以在很多对象做equals比较的时候,是否可以用hash值来判定呢?首先为了不引起误解确定一下,这个肯定是不成立的,虽然有结果,但是得不偿失;还是以上次说过的String来说吧,跟进去源码可以看到,通过hashCode方法获取字符串的hash值是需要逐个字符遍历,当两个字符串比较时,要进行两次循环,这样的开销还不如直接用equals呢,因为equals内部可能不会循环遍历字符串,最多循环也只会循环一次;另外即使为不同的对象,计算出的hash值是有可能重复的,就像刚才的HashMap一样,当遇到重复的,就存储在一个链表中(数据结构中把这个链表叫做桶),所以基本可以不用考虑用hash值来比较对象。
对于这部分最后需要注意的是,上述未key值的比较,HashMap内部只会对key进行转换,而不会对值进行hash转换,所以当你使用public boolean containsValue(Object value)方法的时候,同样要保证值所在对象已经重写了equals方法,内部还包含很多其他的方法,这里就不一一列举了,可以自行查看的,总之我们数据结构总体上可以抽象的分为数组结构和链表结构(即连续句柄存储、非连续句柄存储),通过组合和操作方式我们可以实现更为复杂的结构;从逻辑层来说,实现了向量、队列、栈、动态数组列表、树、集合、键值对、枚举、迭代器;按照JAVA提供的对象。
数据结构常规操作,对于List的常用循环:
先定义一个测试结合类:
List <String>list = new ArrayList<String>(3);
list.add("中国");
list.add("美国");
list.add("俄罗斯");
迭代循环测试输出:
for(Iterator <String>iterator = list.iterator();iterator.hasNext();) {
String str = iterator.next();
System.out.println(str);
}
增强循环:
for (String str : list) {
System.out.println(str);
}
按照下标遍历(注意分号的位置,在初始的时候先将长度获取出来):
for (int index = 0, count = list.size(); index < count;) {
System.out.println(list.get(index++));
}
对于Vecter还有一种使用Enumeration来迭代循环的方式:
Vector <String>vec = new Vector<String>();
vec.add("中国");
vec.add("美国");
vec.add("俄罗斯");
Enumeration <String>em = vec.elements();
while(em.hasMoreElements()) {
String str = em.nextElement();
System.out.println(str);
}
对于HashMap遍历常见有以下三种方式(可以根据实际情况将三种情况演化为根据上述同结构遍历的3*3种方式,这里全部用迭代器去做了):
也先申请一个对象
Map <String,String>map = new HashMap<String,String>(4);
map.put("NO1", "中国");
map.put("NO2", "美国");
1.按照键去遍历:
for(Iterator <String>iterator = map.keySet().iterator();iterator.hasNext();) {
String key = iterator.next();
System.out.println("key="+key+",value="+map.get(key));
}
2.按照值去遍历(但仅仅只能遍历出值):
for(Iterator <String>iterator = map.values().iterator();iterator.hasNext();) {
System.out.println(iterator.next());
}
3.将节点对象遍历出来(这个操作外部可以被修改,所以不是那么安全):
for(Iterator<Map.Entry<String,String>>iterator = map.entrySet().iterator();iterator.hasNext();) {
Map.Entry<String,String> entry = iterator.next();
System.out.println("key="+entry.getKey()+",value="+entry.getValue());
}
为了阐述克隆的说法,最后做一个简单的实验,首先创建一个类为继承克隆类定义类CloneInfo:
class CloneInfo implements Cloneable {
private String[] str = new String[2];
public CloneInfo() {
str[0] = new String("a");
str[1] = new String("b");
}
public void setInfo(String a, String b) {
str[0] = a;
str[1] = b;
}
public Object clone() {
CloneInfo temp = null;
try {
temp = (CloneInfo) super.clone();
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
return temp;
}
public String toString() {
return str[0] + "/t" + str[1];
}
}
在定义一个测试类:
public class CloneTest {
public static void main(String[] agrs) {
CloneInfo info1 = new CloneInfo();
CloneInfo info2 = (CloneInfo) info1.clone();
info2.setInfo("c", "d");
System.out.println(info1.toString());
}
}
发现输出为:
c d
为什么对info2克隆对象进行修改,info1对象也被修改,因为它只进行了浅拷贝,实现深入拷贝需要我们自己去完成,将public Object clone()方法体做如下修改:
public Object clone() {
CloneInfo temp = null;
try {
temp = (CloneInfo) super.clone();
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
temp.str = new String[this.str.length];
System.arraycopy(str, 0, temp.str, 0, str.length);
return temp;
}
此时再次允许测试类输出:
a b
说明对info2进行修改,对info1已经没有影响了,这只是一个例子为说明问题而已,对于实际情况,如上述数组存储的是StringBuilder仍然会相互影响,当然就看要求了,若要求完全不影响需要进进一步对每一个StringBuilder克隆过来。
本文最后小小扩展部分:
很多时候我们习惯将很多几乎在运行过程中不会频繁改变的列表数据放于内存静态存储,作为共享对象提供给大家提取和使用,假如我们使用ArrayList来存放,如果直接将这个实体的句柄返回给调用代码的程序设计人员,它们的代码里面就可以对这个ArrayList的内容进行修改,如进行一些add、remove等等操作(框架本身有能力对其进行内部的修改操作),但是如果复制一份内存出去显得比较浪费空间,当然你可以告诉每一个程序员,这样得到的List只允许查不允许修改,但是人总有疏忽的时候,我们这部分可以再框架内部去完成,这里我们继承于ArrayList做一些控制级别的重写(这里可能有人会认为用final定义List,其实可以做一下试验就知道final只能保证不然集合类第二次被赋值,不能保证其内部实体的修改,另外即使保证内部实体不被修改,但是这里要求框架本身有修改这个集合类的能力,所以这个方法很明显是不可以行的了):
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.RandomAccess;
public class MyArrayList<E> extends ArrayList<E> implements List<E>,
RandomAccess, Cloneable, java.io.Serializable {
/**
* serialVersionUID
*/
private static final long serialVersionUID = 1765158294150418629L;
/**
* 是否已经锁定记录不允许被修改,true不能被修改
*/
private boolean islocked = false;
/**
* 设置islocked
*/
public void setLocked(boolean islocked) {
this.islocked = islocked;
}
/**
* 通过数量初始化
*/
public MyArrayList(int initialCapacity) {
super(initialCapacity);
}
/**
* 直接初始化
*/
public MyArrayList() {
super(10);
}
/**
* 对应值修改
*/
public E set(int index, E element) {
if(!islocked) {
return super.set(index, element);
}
return null;
}
/**
* add方法
*/
public boolean add(E o) {
if(!islocked) {
return super.add(o);
}
return false;
}
/**
* add方法 带index下标
*/
public void add(int index, E element) {
if(!islocked) {
super.add(index, element);
}
}
/**
* 重写remove方法
*/
public E remove(int index) {
if(!islocked) {
return super.remove(index);
}
return null;
}
/**
* 重写remove方法2
*/
public boolean remove(Object o) {
if(!islocked) {
return super.remove(o);
}
return false;
}
/**
* 重写addAll方法
*/
public boolean addAll(Collection<? extends E> c) {
if(!islocked) {
return super.addAll(c);
}
return false;
}
/**
* 重写addAll方法2
*/
public boolean addAll(int index, Collection<? extends E> c) {
if(!islocked) {
return super.addAll(index, c);
}
return false;
}
/**
* 清空列表
*/
public void clear() {
if(!islocked) {
super.clear();
}
}
}
此时集合类已经被重写,需要定义一个调用类,这部分代码一般在框架部分就需要完成:
import java.util.List;
public class TestMyArrayList {
//定义一个静态的MyArrayList
private static MyArrayList<String> myList = new MyArrayList<String>();
//初始化模拟几条数据进去
static {
myList.add("张三");
myList.add("李四");
myList.setLocked(true);//设置共享对象不可以被修改
}
//直接获取原始的List列表信息,通过上溯造型,避免外部调用控制体结构
public static List<String> getList() {
return myList;
}
//获取克隆后的List信息
@SuppressWarnings("unchecked")
public static List<String> getCloneList() {
MyArrayList<String>list = (MyArrayList<String>)myList.clone();
list.setLocked(false);//通过克隆获取到的列表,允许被修改
return list;
}
}
下面测试调用一下:
public static void main(String[] args) {
System.out.println("通过[克隆调用]后,尝试删除掉一条数据后,显示列表内容如下:");
List<String> list2 = getCloneList();
list2.remove(1);
for (String str : list2) {
System.out.println(str);
}
System.out.println("通过[直接获取]后,尝试清空数据后,显示列表内容如下:");
List<String> list = getList();
list.clear();
for (String str : list) {
System.out.println(str);
}
}
输出结果:
通过[克隆调用]后,尝试删除掉一条数据后,显示列表内容如下:
张三
通过[直接获取]后,尝试清空数据后,显示列表内容如下:
张三
李四
通过结果可以看出,通过克隆后的数据可以被修改,通过原始获取的达到我们要的目的,不能被业务调用代码修改,只能被框架代码通过MyArrayList来修改,这部分对于程序设计人员是私有的,程序设计人员可能绝大部分调用这部分数据的时候不会被修改,可以直接调用也保证数据的安全性;部分特殊情况需要做相应的转换可以通过调用克隆方法。
补充,这里调用克隆方法虽然使用了默认的深度克隆,但是内部存储的是String类型,它作为一种常量对象被管理,所以不需要对实际对象深度克隆,如内部存储的是对象,需要对对象进行进一步克隆才可以保证对象内容不被修改,这个对象也必须implements Cloneable,并实现clone方法,对内容进行克隆,这个需要根据实际情况而定了。