Java集合
常用集合
集合类的继承关系
集合类的时间复杂度
集合 |
ArrayList |
LinkedList |
描述 |
1、可增长的数组长度 2、查询快 get() set() 常数级 3、插入和现有所有项的删除代价昂贵 除非在表的末端 |
1、双链表 删快 2、新项的插入和现有项的删除都是非常的快 3、在表的前端添加和删除都是常数级 addFristremoveFrist addLast removeLast getFirst getLast 4、但是不容易做索引 |
时间复杂度 |
get() 直接读取第几个下标,复杂度 O(1) add(E) 添加元素,直接在后面添加,复杂度O(1) add(index, E) 添加元素,在第几个元素后面插入,后面的元素需要向后移动,复杂度O(n) remove()删除元素,后面的元素需要逐个移动,复杂度O(n) |
get() 获取第几个元素,依次遍历,复杂度O(n) add(E) 添加到末尾,复杂度O(1) add(index, E) 添加第几个元素后,需要先查找到第几个元素,直接指针指向操作,复杂度O(n) remove()删除元素,直接指针指向操作,复杂度O(1) |
集合 |
HashSet |
TreeSet |
LinkedHashSet |
描述 |
1、关注性能,应该使用HashSet; 2、HashSet是基于散列表实现的,元素没有顺序 |
1、需要一个有序的Set集合,应该使用TreeSet; 2、TreeSet是基于树实现的(红黑树),元素是有序的; |
1、需要一个Set集合保存了原始的元素插入顺序,应该使用LinkedHashSet。 2、LinkedHashSet介于HashSet和TreeSet之间,是基于哈希表和链表实现的,支持元素的插入顺序; |
时间复杂度 |
add、remove、contains方法的时间复杂度为O(1)。 |
add、remove、contains方法的时间复杂度为O(log (n))(contains为false时,插入前需要重新排序)。 |
基本方法的时间复杂度为O(1); |
集合 |
HashMap |
TreeMap |
LinkedHashMap |
描述 |
HashMap是基于散列表实现的, |
1、TreeMap基于红黑树(一种自平衡二叉查找树)实现的, |
LinkedHashMap的出现就是为了平衡这些因素 |
时间复杂度 |
1、时间复杂度平均能达到O(1)。 2、正常是0(1)到0(n) 3、jdk1.8添加了 红黑树 是 0(log n) |
时间复杂度平均能达到O(log n)。 |
以O(1)时间复杂度查找元素,又能够保证key的有序性 |
集合类的Null
- ArrayList可以存储多个null,ArrayList底层是数组,添加null并未对他的数据结构造成影响。
- LinkedList底层为双向链表,node.value = null也没有影响。
- HashMap可以有1个key为null的元素,value也可以为null,TreeMap不能有key为null的元素,value可以为null。
- Hashtable和CurrentHashMap的key和value均不能为null;
- Set底层是Map,所以HashSet可以有1个null的元素,TreeSet不能有key为null的元素。
用哪两种方式来实现集合的排序?
1、你可以使用有序集合,如 TreeSet 或 TreeMap(只能排序基本类型,integer,string);
如果需要排序实体对象,对象要实现implements Comparable<T>接口,重写compareTo方法。
2、你也可以使用有顺序的的集合,如 list,然后通过 Collections.sort() 来排序。
java集合的工具类Collections中提供了两种排序的方法,分别是:
(1)Collections.sort(List list)
(2)Collections.sort(List list,Comparator c)
Collections.sort(intList,new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
// 返回值为int类型,大于0表示正序,小于0表示逆序
return o2-o1;
}
});
我们能自己写一个容器类,然后使用 for-each 循环?
可以,可以写一个自己的容器类。如果你想使用 Java 中增强的循环来遍历,只需要实现 Iterable 接口。如果实现 Collection 接口,默认就具有该属性。
集合的遍历
Map遍历方式:
1、通过获取所有的key按照key来遍历
//Set<Integer> set = map.keySet(); //得到所有key的集合
for (Integer in : map.keySet()) {
String str = map.get(in);//得到每个key多对用value的值
}
2、通过Map.entrySet使用iterator遍历key和value
Iterator<Map.Entry<Integer, String>> it = map.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<Integer, String> entry = it.next();
System.out.println("key= " + entry.getKey() + " and value= " + entry.getValue());
}
3、通过Map.entrySet遍历key和value,推荐,尤其是容量大时
for (Map.Entry<Integer, String> entry : map.entrySet()) {
//Map.entry<Integer,String> 映射项(键-值对) 有几个方法:用上面的名字entry
//entry.getKey() ;entry.getValue(); entry.setValue();
//map.entrySet() 返回此映射中包含的映射关系的 Set视图。
System.out.println("key= " + entry.getKey() + " and value= " + entry.getValue());
}
4、通过Map.values()遍历所有的value,但不能遍历key
for (String v : map.values()) {
System.out.println("value= " + v);
}
List遍历方式:
第一种:
for(Iterator iterator = list.iterator();iterator.hasNext();){
int i = (Integer) iterator.next();
System.out.println(i);
}
第二种:
Iterator iterator = list.iterator();
while(iterator.hasNext()){
int i = (Integer) iterator.next();
System.out.println(i);
}
第三种:
for (Object object : list) {
System.out.println(object);
}
第四种:
for(int i = 0 ;i<list.size();i++) {
int j= (Integer) list.get(i);
System.out.println(j);
}
数据元素是怎样在内存中存放的?
主要有2种存储方式:
1、顺序存储,Random Access(Direct Access):
这种方式,相邻的数据元素存放于相邻的内存地址中,整块内存地址是连续的。可以根据元素的位置直接计算出内存地址,直接进行读取。读取一个特定位置元素的平均时间复杂度为O(1)。正常来说,只有基于数组实现的集合,才有这种特性。Java中以ArrayList为代表。
2、链式存储,Sequential Access:
这种方式,每一个数据元素,在内存中都不要求处于相邻的位置,每个数据元素包含它下一个元素的内存地址。不可以根据元素的位置直接计算出内存地址,只能按顺序读取元素。读取一个特定位置元素的平均时间复杂度为O(n)。主要以链表为代表。Java中以LinkedList为代表。
每个遍历方法的实现原理是什么?
1、传统的for循环遍历,基于计数器的:
遍历者自己在集合外部维护一个计数器,然后依次读取每一个位置的元素,当读取到最后一个元素后,停止。主要就是需要按元素的位置来读取元素。
2、迭代器遍历,Iterator:
每一个具体实现的数据集合,一般都需要提供相应的Iterator。相比于传统for循环,Iterator取缔了显式的遍历计数器。所以基于顺序存储集合的Iterator可以直接按位置访问数据。而基于链式存储集合的Iterator,正常的实现,都是需要保存当前遍历的位置。然后根据当前位置来向前或者向后移动指针。
3、foreach循环遍历:
根据反编译的字节码可以发现,foreach内部也是采用了Iterator的方式实现,只不过Java编译器帮我们生成了这些代码。
各遍历方式对于不同的存储方式,性能如何?
1、传统的for循环遍历,基于计数器的:
因为是基于元素的位置,按位置读取。所以我们可以知道,对于顺序存储,因为读取特定位置元素的平均时间复杂度是O(1),所以遍历整个集合的平均时间复杂度为O(n)。而对于链式存储,因为读取特定位置元素的平均时间复杂度是O(n),所以遍历整个集合的平均时间复杂度为O(n2)(n的平方)。
ArrayList按位置读取的代码:直接按元素位置读取。
LinkedList按位置读取的代码:每次都需要从第0个元素开始向后读取。其实它内部也做了小小的优化。
2、迭代器遍历,Iterator:
那么对于RandomAccess类型的集合来说,没有太多意义,反而因为一些额外的操作,还会增加额外的运行时间。但是对于Sequential Access的集合来说,就有很重大的意义了,因为Iterator内部维护了当前遍历的位置,所以每次遍历,读取下一个位置并不需要从集合的第一个元素开始查找,只要把指针向后移一位就行了,这样一来,遍历整个集合的时间复杂度就降低为O(n);
(这里只用LinkedList做例子)LinkedList的迭代器,内部实现,就是维护当前遍历的位置,然后操作指针移动就可以了。
3、foreach循环遍历:
分析Java字节码可知,foreach内部实现原理,也是通过Iterator实现的,只不过这个Iterator是Java编译器帮我们生成的,所以我们不需要再手动去编写。但是因为每次都要做类型转换检查,所以花费的时间比Iterator略长。时间复杂度和Iterator一样。
各遍历方式的适用于什么场合?
1、传统的for循环遍历,基于计数器的:
顺序存储:读取性能比较高。适用于遍历顺序存储集合。
链式存储:时间复杂度太大,不适用于遍历链式存储的集合。
2、迭代器遍历,Iterator:
顺序存储:如果不是太在意时间,推荐选择此方式,毕竟代码更加简洁,也防止了Off-By-One的问题。
链式存储:意义就重大了,平均时间复杂度降为O(n),还是挺诱人的,所以推荐此种遍历方式。
3、foreach循环遍历:
foreach只是让代码更加简洁了,但是他有一些缺点,就是遍历过程中不能操作数据集合(删除等),所以有些场合不使用。而且它本身就是基于Iterator实现的,但是由于类型转换的问题,所以会比直接使用Iterator慢一点,但是还好,时间复杂度都是一样的。所以怎么选择,参考上面两种方式,做一个折中的选择。
Java的最佳实践是什么?
Java数据集合框架中,提供了一个RandomAccess接口,该接口没有方法,只是一个标记。通常被List接口的实现使用,用来标记该List的实现是否支持Random Access。
一个数据集合实现了该接口,就意味着它支持Random Access,按位置读取元素的平均时间复杂度为O(1)。比如ArrayList。
而没有实现该接口的,就表示不支持Random Access。比如LinkedList。
所以看来JDK开发者也是注意到这个问题的,那么推荐的做法就是,如果想要遍历一个List,那么先判断是否支持Random Access,也就是 list instanceof RandomAccess。
if (list instanceof RandomAccess) {
//使用传统的for循环遍历。
} else {
//使用Iterator或者foreach。
}
List
ArrayList和LinkedList
arraylist底层是数组实现,默认大小10。
每次添加数据,先调用ensureCapacity()保证数组不溢出,如果数组满了,就会扩展数组length的1.5倍+1,然后调用array.copy方法,将原数字拷贝到新的数组中。
linkedlist是基于双链表实现的:
Object element;
Entry next,previous;
初始化时,有个header Entry,值为null;
使用header优点是:在任何一个条目(包括第一个和最后一个)都有一个前置条目和一个后置条目,因此在LinkedLIst对象的开始或者末尾进行插入操作没有特殊的地方。
场景:
1.ArrayList是实现了基于动态数组的数据结构,LinkedList是基于链表结构。
2.对于随机访问的get和set方法,ArrayList要优于LinkedList,因为LinkedList要移动指针。
3.对于添加和删除操作add和remove,一般大家都会说LinkedList要比ArrayList快,因为ArrayList要移动数据。但是实际情况并非这样,ArrayList想要在指定位置插入或删除元素时,主要耗时的是System.arraycopy动作,会移动index后面所有的元素;LinkedList主耗时的是要先通过for循环找到index,然后直接插入或删除。这就导致了两者并非一定谁快谁慢。所以当插入的数据量很小时,两者区别不太大,当插入的数据量大时,大约在容量的1/10之前,LinkedList会优于ArrayList,在其后就劣与ArrayList,且越靠近后面越差。所以个人觉得,一般首选用ArrayList,由于LinkedList可以实现栈、队列以及双端队列等数据结构,所以当特定需要时候,使用LinkedList。
4.有序数组(JDK中Arrays.sort)可以通过二分查找方法具有很高的查找效率(O(log n)),而链表只能使用顺序查找,效率低下(O(n))。
LinkedList
LinkedList的本质是双向链表(有pre,同时也有next)。
(01) LinkedList继承于AbstractSequentialList,并且实现了Dequeue接口。
(02) LinkedList包含两个重要的成员:header 和 size。
header是双向链表的表头,它是双向链表节点所对应的类Entry的实例。Entry中包含成员变量: previous, next, element。其中,previous是该节点的上一个节点,next是该节点的下一个节点,element是该节点所包含的值。
(03) LinkedList的克隆函数,即是将全部元素克隆到一个新的LinkedList对象中。
(04) LinkedList实现java.io.Serializable。当写入到输出流时,先写入“容量”,再依次写入“每一个节点保护的值”;当读出输入流时,先读取“容量”,再依次读取“每一个元素”。
(05) 由于LinkedList实现了Deque,而Deque接口定义了在双端队列两端访问元素的方法。提供插入、移除和检查元素的方法。每种方法都存在两种形式:一种形式在操作失败时抛出异常,另一种形式返回一个特殊值(null 或 false,具体取决于操作)。
LinkedList实际上是通过双向链表去实现的。既然是双向链表,那么它的顺序访问会非常高效,而随机访问效率比较低。
既然LinkedList是通过双向链表的,但是它也实现了List接口{也就是说,它实现了get(int location)、remove(int location)等“根据索引值来获取、删除节点的函数”}。LinkedList是如何实现List的这些接口的,如何将“双向链表和索引值联系起来的”?
实际原理非常简单,它就是通过一个计数索引值来实现的。例如,当我们调用get(int location)时,首先会比较“location”和“双向链表长度的1/2”;若前者大,则从链表头开始往后查找,直到location位置;否则,从链表末尾开始先前查找,直到location位置。这就是“双线链表和索引值联系起来”的方法。
JDK1.6:
jdk1.6使用的是一个带有头结点的双向循环链表,头结点不存储实际数据,循环链表指的是能够只通过一个方向的指针重新遍历到自己这个节点的链表,示意图如下:
// 将节点(节点数据是e)添加到表头(header)之前。即,将节点添加到双向链表的末端。
private transient Entry<E> header = new Entry<E>(null, null, null);
private transient int size = 0;
addBefore(e, header);
每次添加/删除元素都是默认在链尾操作。对应此处,就是在header前面操作,因为遍历是next方向的,所以在header前面操作,就相当于在链表尾操作。
JDK 1.7:
jdk1.7之后使用的是不带头结点的普通的双向链表,增加两个节点指针first、last分别指向首尾节点,示意图如下。
transient Node<E> first;
transient Node<E> last;
虽然链表的插入、删除都是O(1),但那是在节点已知的情况下。LinkedList中定位一个节点需要遍历链表,因此与下标有关的插入、删除时间复杂度都变为O(n),下标无关的删除 remove(obj) 也需要遍历,所以也是O(n),尾部的添加、删除不需要遍历,时间复杂度为O(1),因此LinkedList整体读写效率不如ArrayList。但是链表结构对内存要求低,不需要大块连续内存来满足扩容,能够自动动态地消耗内存,容量变小时会自动释放曾经占用的内存,ArrayList则需要手动trim。
JDK1.6使用Entry,JDK1.7使用Node.
private static class Entry<E> {
E element; // 当前存储元素
Entry<E> next; // 下一个元素节点
Entry<E> previous; // 上一个元素节点
Entry(E element, Entry<E> next, Entry<E> previous) {
this.element = element;
this.next = next;
this.previous = previous;
}
}
private static class Node<E> {
E item; //表示该节点包含的值
Node<E> next; //表达当前节点的下一个节点
Node<E> prev; //表示当前节点的上一个节点
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
Java 中怎么打印数组?
你可以使用 Arrays.toString() 和 Arrays.deepToString() 方法来打印数组。由于数组没有实现 toString() 方法,所以如果将数组传递给 System.out.println() 方法,将无法打印出数组的内容,但是 Arrays.toString() 可以打印每个元素。
Set
Set集合是最简单的一种集合,它的数据无序且唯一,它是一个继承了Collection接口的接口,他的实现类有两个,一个是HashSet,一个是TreeSet,Set集合中的对象的比较方法都是利用equals方法进行比较。
所有Set集合的共同点为:都不允许元素重复,都不是线程安全的类。解决方案:Set set = Collections.sysnchronizedSet(Set对象);
HashSet
①是基于HashMap实现的,默认构造函数是构建一个初始容量为16,负载因子为0.75 的HashMap。封装了一个 HashMap 对象来存储所有的集合元素,所有放入 HashSet 中的集合元素实际上由 HashMap 的 key 来保存,而 HashMap 的 value 则存储了一个 PRESENT,它是一个静态的 Object 对象。
②当我们试图把某个类的对象当成 HashMap的 key,或试图将这个类的对象放入 HashSet 中保存时,重写该类的equals(Object obj)方法和 hashCode() 方法很重要,而且这两个方法的返回值必须保持一致:当该类的两个的 hashCode() 返回值相同时,它们通过 equals() 方法比较也应该返回 true。通常来说,所有参与计算 hashCode() 返回值的关键属性,都应该用于作为 equals() 比较的标准。
HashSet使用成员对象来计算hashcode值,对于两个对象来说hashcode可能相同,所以equals()方法用来判断对象的相等性,如果两个对象不同的话,那么返回false。
HashSet:
1、不保证元素的先后添加顺序,底层采用的是哈希表算法,查询效率极高;
2、判断两个对象是否相等的规则:1、equals比较为true;2、hashCode值相同。要求:要求存在在哈希表中的对象元素都得覆盖equals和hashCode方法。
LinkedHashSet
因为我们知道哈希表能够保证数据的唯一性,但不能记录数据的先后顺序,链表结构能够保证数据的先后顺序,但是不能保证数据的唯一性,如果我们有这样的需求,就是既想数据无重复性,又想数据能够保证添加的先后顺序,这样我们需要哈希表和链表结构双向实现的一种集合,这就是LinkedHashSet。LinkedHashSet继承了HashSet类,所以它的底层用的也是哈希表的数据结构,但因为保持数据的先后添加顺序,所以又加了链表结构,但因为多加了一种数据结构,所以效率较低,不建议使用。
HashSet的子类,底层也采用的是哈希表算法,但是也使用了链表算法来维持元素的先后添加顺序,判断两个对象是否相等的规则和HashSet相同。因为需要多使用一个链表来记录元素的顺序,所以性能相对于HashSet较低;一般少用,如果要求一个集合急要保证元素不重复,也需要记录元素的先后添加顺序,才选择使用LinkedHashSet。
TreeSet类
TreeSet也是Set接口的实现类,也拥有set接口的一般特性,但是不同的是他也实现了SortSet接口,它底层采用的是红黑树算法(红黑树就是满足一下红黑性质的二叉搜索树:①每个节点是黑色或者红色②根节点是黑色的③每个叶子结点是黑色的④如果一个节点是红色的,那么他的两个子节点是黑色的⑤对每个节点,从该节点到其所有的后代叶子结点的简单路径上,仅包含相同数目的黑色结点,红黑树是许多“平衡”搜索树的一种,可以保证在最坏情况下的基本操作集合的时间复杂度为O(lgn)。
Tree最重要的就是它的两种排序方式:自然排序和客户端排序:
1)自然排序
因为TreeSet实现了Comparable接口,所以TreeSet可以调用对象的ComparableTo()方法来比较集合的大小,然后进行升序排序,这种排序方式叫做自然排序。其中实现了Comparable接口的还有BigDecimal、BigInteger、Byte、Double、Float、Integer、Long、Short(按照数字大小排序)、Character(按照Unicode值的数字大小进行排序)String(按照字符串中字符的Unicode值进行排序)类等。
在使用自然排序的时候,只能向TreeSet类中加入相同类型的对象,并且这些对象均实现了Comparable接口。为了能保证TreeSet正确排序,要求存取的对象必须得需要改类的compareTo方法与equals方法。按相同比较规则比较这个类的两个对象,又因为当重写equals方法的时候必然要重写HashCode方法,所以,一个类想要使用TreeSet进行存储,必须实现compareTo方法、equals方法、HashCode方法。要注意的是:当已经存储到TreeSet集合中的对象,当该集合对象的属性被修改时并不会重新对对象进行排序,所以,适合用TreeSet进行排序的是不可变类,也就是说这种类的属性是不能被修改的。
我们可以让它按指定规则对其中的元素进行排序。它又是如何判断两个元素是否相同呢?除了用equals方法检查两个元素是否相同外,还要检查compareTo方法是否返回为0。
2)客户化排序
其实就是实现java.util.Comparator<Type>接口提供的具体的排序方式,<Type> 是具体要比较对象的类型,他有个compare的方法,如compare(x,y)返回值大于0表示x大于y,以此类推,当我们希望按照自己的想法排序的时候可以重写compare方法。
TreeSet 底层用的红黑树算法,一般用于不可变类的排序,要实现comparable接口、重写HashCode、equals方法,存储的对象只能为同一类型的,要实现客户化排序需要继承java.util.Comparator<Type>接口,并实现compare方法。
Map
LinkedHashMap 和 PriorityQueue 的区别是什么?
PriorityQueue 保证最高或者最低优先级的的元素总是在队列头部,但是 LinkedHashMap 维持的顺序是元素插入的顺序。当遍历一个 PriorityQueue 时,没有任何顺序保证,但是 LinkedHashMap 课保证遍历顺序是元素插入的顺序。
TreeMap
Java 中的 TreeMap 是使用红黑树实现的。TreeMap 继承于AbstractMap,所以它是一个Map,即一个key-value集合。TreeMap 实现了NavigableMap接口,意味着它支持一系列的导航方法。比如返回有序的key集合。TreeMap 实现了Cloneable接口,意味着它能被克隆。TreeMap 实现了java.io.Serializable接口,意味着它支持序列化。
TreeMap基于红黑树(Red-Black tree)实现。该映射根据其键的自然顺序进行排序,或者根据创建映射时提供的 Comparator 进行排序,具体取决于使用的构造方法。TreeMap的基本操作 containsKey、get、put 和 remove 的时间复杂度是O(log(n)) 。另外,TreeMap是非同步的。 它的iterator 方法返回的迭代器是fail-fastl的。
ArrayList 和 HashMap 的默认大小是多数?
在 Java 7 中,ArrayList 的默认大小是 10 个元素,HashMap 的默认大小是16个元素(必须是2的幂)。
-ArrayList每次扩充至原有基础的1.5倍。
-Vector扩充2倍。
-HashMap扩充也是初始的2倍。
HashMap
初始容量和扩容因子
初始容量为16,扩容因子为0.75。
HashMap是AbstractMap的子类,实现了Map接口。
推荐集合初始化时,指定集合初始值大小。因为没有设置初始容量,随着元素增加,容量N次被扩大,resize需要重建hash表,影响性能。
initialCcapacity = (需要存储的个数/0.75)
原理
对象的hashcode和equals
hashCode是用来在散列存储结构中确定对象的存储地址的,用于查找使用的(基础算法就是取模)。
而equals是用于比较两个对象的是否相等的。
==比较基本数据类型,如果两个值相同,则结果为true
==而在比较引用时,如果引用指向内存中的同一对象,结果为true;
hashmap概述
HashMap是基于哈希表的Map接口的非同步实现,并允许使用null值和null键。
在java编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,HashMap也不例外。HashMap实际上是一个“链表散列”的数据结构,即数组和链表的结合体。
HashMap底层就是一个数组结构,数组中的每一项又是一个链表。
Entry就是数组中的元素,每个 Map.Entry 其实就是一个key-value对。
hashmap实现存储和读取
归纳起来简单地说,HashMap 在底层将 key-value 当成一个整体进行处理,这个整体就是一个 Entry 对象。HashMap 底层采用一个 Entry[] 数组来保存所有的 key-value 对,当需要存储一个 Entry 对象时,会根据hash算法来决定其在数组中的存储位置,在根据equals方法决定其在该数组位置上的链表中的存储位置;当需要取出一个Entry时,也会根据hash算法找到其在数组中的存储位置,再根据equals方法从该位置上的链表中取出该Entry。
hashmap的存储自定义对象(重写hashcode和equals方法)
首先明确重写的是存储对象的hashcode方法和equals方法。
未重写的equals()方法只比较两个对象是否相同,相当于==,而不同的对象hashCode()肯定是不同,所以如果我们不是看对象,而只看对象的属性,则要重写这两个方法,如Integer和String他们的equals()方法都是重写过了,都只是比较对象里的内容。
一般先写hashCode再写equals。因为它返回的是对象的哈希值,那么不同的new出不同的对象,他们虽然名字一样但是哈希码可能会不一样。
//都一样,变化的就是下面的
public int hashCode() {
return name.hashCode() + age * 10;
}
什么是哈希
哈希表是基于数组实现的,哈希算法就是如何将键值(key)转换成数组小标的方法,哈希化可以提供非常高的操作(插入、删除、查询)效率,因为对有序数组的查询,即使是二分查找也只能做到O(logN),因为哈希可以直接将要查询的key转化为数组小标,所以可以达到O(1)的时间级。
Hash算法:将key做hash后的值叫hashcode,hashcode的值范围可能很大,Hash算法是将一个较大范围的hashcode转换为定长的区间的数值。一个好的hash算法应该使hashcode均匀分布在区间内。 但是难免还是会有不同的key会产生相同的hashcode,此为哈希冲突。
例如,要对公司100个雇员的信息做哈希存储,要求以姓名作为键值,“frank” 的hashcode是3135416,如果直接用3135416做小标,意味着要用一个非常大的数组,显然非常浪费。mod是最简单,往往也是最有效的hash算法,3135416%100=16,这样就可以用array[16]来存储“frank”的信息了。
如何解决哈希冲突?
1、开放定址法
线性探测,二次探测,再哈希。
例如“leo”的hashcode是733616,用模100哈希算法后,其值也是16,因为array[16]已经被占用了,产生了“冲突”,那么顺序查看16的下个位置17,看array[17]是否可用,如果可以则存储leo,否则继续探测下个位置18,直到有空位为止。在查询时,若一个key的哈希值为16,不能就简单的返回array[16],因为哈希值为16的可能是frank 或者 leo,所以还要比对key值。
2、链地址法
Java 的 HashMap就是用LinkedList解决hash冲突的。
hashmap的hash算法实现
(1)key--hashcode(这一步是key对象继承自object的haskCode()方法或者重写后的)
hashCode是jdk根据对象的地址或者字符串或者数字算出来的int类型的数值。
通过jdk源码可看到public native int hashCode();此方法是交给C++等实现的。
获得hash值得方法有6种,其中包括随机数、地址基础上进行处理、默认值1、直接地址、一个顺序值、一个称为Marsaglia的方法,其中默认的就是第6种,即Marsaglia的方法。
(2)hashcode---hash值
key.hashCode()函数调用的是key键值类型自带的哈希函数,返回int型散列值。
理论上散列值是一个int型,如果直接拿散列值作为下标访问HashMap主数组的话,考虑到2进制32位带符号的int表值范围从-2147483648到2147483648。前后加起来大概40亿的映射空间。只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。
但问题是一个40亿长度的数组,内存是放不下的。你想,HashMap扩容之前的数组初始大小才16。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来访问数组下标。源码中模运算是在这个indexFor( )函数里完成的。
bucketIndex = indexFor(hash, table.length);
indexFor的代码也很简单,就是把散列值和数组长度做一个"与"操作。
static int indexFor(int h, int length) {
return h & (length-1);
}
顺便说一下,这也正好解释了为什么HashMap的数组长度要取2的整次幂。因为这样(数组长度-1)正好相当于一个“<b>低位掩码”。</b>“与”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度16为例,16-1=15。2进制表示是00000000 00000000 00001111。和某散列值做“与”操作如下,结果就是截取了最低的四位值。
10100101 11000100 00100101
&00000000 00000000 00001111
----------------------------------
00000000 00000000 00000101 //高位全部归零,只保留末四位
这时候问题就来了,这样就算我的散列值分布再松散,要是只取最后几位的话,碰撞也会很严重。更要命的是如果散列本身做得不好,分布上成等差数列的漏洞,恰好使最后几个低位呈现规律性重复,就无比蛋疼。这时候“扰动函数”的价值就体现出来了,说到这里大家应该猜出来了。看下面这个图:
右位移16位,正好是32bit的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。
<1>计算hashCode;
<2>hashCode右移16位(取高16位);
<3>1和2的值做异或(0⊕0=0,1⊕0=1,0⊕1=1,1⊕1=0(同为0,异为1))。
<4>length-1与操作3。
hashmap的resize
在hashmap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。
那么hashmap什么时候进行扩容呢?当hashmap中的元素个数超过数组大小*loadFactor时,就会进行数组扩容。
创建一个新的Entry空数组,长度是原数组的2倍。
在调整大小时,存储在链表中的元素的次序会反过来,因为在放入新的位置时,HashMap会将Entry对象不断的插入链表的头部。插入头部也主要是为了防止尾部遍历,否则这对key的HashCode相同的Entry每次添加还要定位到尾节点。
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {//遍历同桶数组中的每一个桶
while(null != e) {//顺序遍历某个桶的外挂链表
Entry<K,V> next = e.next;//引用next
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);//找到新表的桶位置;原桶数组中的某个桶上的同一链表中的Entry此刻可能被分散到不同的桶中去了,有效的缓解了哈希冲突。
e.next = newTable[i];//头插法插入新表中
newTable[i] = e;
e = next;
}
}
}
对于resize的过程,相对来讲是比较简单清晰易于理解的。旧桶数组中的某个桶的外挂单链表是通过头插法插入新桶数组中的,并且原链表中的Entry结点并不一定仍然在新桶数组的同一链表。
这里很容易就想到多线程情况下,隐约感觉这个transfer方法在多线程环境下会乱套。事实上也是这样的,由于缺乏同步机制,当多个线程同时resize的时候,某个线程t所持有的引用next(参考上面代码next指向原桶数组中某个桶外挂单链表的下一个需要转移的Entry),可能已经被转移到了新桶数组中,那么最后该线程t实际上在对新的桶数组进行transfer操作。
如果有更多的线程出现这种情况,那很可能出现大量线程都在对新桶数组进行transfer,那么就会出现多个线程对同一链表无限进行链表反转的操作,极易造成死循环,数据丢失等等,因此HashMap不是线程安全的,考虑在多线程环境下使用并发工具包下的ConcurrentHashMap。
为何不是线程安全
出现线程不安全的原因就是因为hashmap的rehash,在扩容时发生。
遍历原Entry数组,把所有的Entry重新Hash到新数组。为什么要重新Hash呢?因为长度扩大以后,Hash的规则也随之改变。
Rehash在并发的情况下可能会形成链表环。
HashMap的put方法
JDK7中HashMap采用的是位桶+链表的方式,即我们常说的散列链表的方式,而JDK8中采用的是位桶+链表/红黑树。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); //就是把key与其高16位异或
}
HashMap在put方法中,它使用hashCode()和equals()方法。
当我们通过传递key-value对调用put方法的时候,HashMap使用Key hashCode()和哈希算法来找出存储key-value对的索引。
如果索引处为空,则直接插入到对应的数组中,否则,判断是否是红黑树,若是,则红黑树插入,否则遍历链表,若长度超过8,则将链表转为红黑树,转成功之后 再插入。
HashMap到底是插入链表头部还是尾部
jdk1.6
阅读源码发现,如果遍历链表都没法发现相应的key值的话,则会调用addEntry方法在链表添加一个Entry,重点就在与addEntry方法是如何插入链表的,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对象(构造方法的最后一个参数传入了当前的Entry链表),然后直接用这个新的Entry对象取代了旧的Entry链表,可以猜测这应该是头插法,为了进一步确认这个想法,我们再看一下Entry的构造方法:
Entry( int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
从构造方法中的nexrt=n可以看出确实是把原本的链表直接链在了新建的Entry对象的后边,可以断定是插入头部。
jdk1.8
在jdk1.8中当链表长度大于8是会被转化成红黑树,所以源码看起来比jdk1.6要复杂不少,大量的if-else判断是为了处理红黑树的情况的,与链表插入相关的核心代码只有如下几行:
//e是p的下一个节点
if ((e = p.next) == null) {
//插入链表的尾部
p.next = newNode(hash, key, value, null);
//如果插入后链表长度大于8则转化为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
从这段代码中可以很显然地看出当到达链表尾部(即p是链表的最后一个节点)时,e被赋为null,会进入这个分支代码,然后会用newNode方法建立一个新的节点插入尾部。
结论:jdk1.8中是插入的是链表尾部。
在jdk1.6中,HashMap中有个内置Entry类,它实现了Map.Entry接口;而在jdk1.8中,这个Entry类不见了,变成了Node类,也实现了Map.Entry接口,与jdk1.6中的Entry是等价的。
总结:
Hashmap中的Entry是单向的,1.6版本中是头插法,当扩容的时候是将Entry节点反向;1.8中将Entry改成了Node,Node改成了尾插法,当链表长度大于8的时候改成了红黑树。
HashMap与ConcurrentHashMap区别
ConcurrentHashMap把Map分成了N个Segment(默认16),其中Segment是线程同步的,相当于分成了N个Hashtable。
当实现Put方法时,在key值经过正常的hash后,还要再经过一次segmentForHash算法,用来分配具体访问哪个Segment。后来的线程如果经过计算也是放在这个Segment下,则需要先获取锁,如果计算得出应该放在其他的Segment,则正常执行,不会影响效率,以此实现线程安全。ConcurrentHashMap使用锁分离技术,只要多个修改操作不发生在同一个Segment上,它们就可以并发进行。
有些方法需要跨段,比如size()和containsValue(),需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。这里“按顺序”是很重要的,否则极有可能出现死锁。
Hashtable 与 HashMap 有什么不同之处?
这两个类有许多不同的地方,下面列出了一部分:
a) Hashtable 是 JDK 1 遗留下来的类,而 HashMap 是后来增加的。
b)Hashtable 是同步的,比较慢,但 HashMap 没有同步策略,所以会更快。
c)Hashtable 不允许有个空的 key,但是 HashMap 允许出现一个 null key。
Hashtable 的 put(K key, V value) 和 get(Object key) 方法使用了 synchronized 关键字来保证其线程安全。
LinkedHashMap
HashMap有一个问题,就是迭代HashMap的顺序并不是HashMap放置的顺序,也就是无序。HashMap的这一缺点往往会带来困扰,因为有些场景,我们期待一个有序的Map。
这个时候,LinkedHashMap就闪亮登场了,它虽然增加了时间和空间上的开销,但是通过维护一个运行于所有条目的双向链表,LinkedHashMap保证了元素迭代的顺序。该迭代顺序可以是插入顺序或者是访问顺序。
关 注 点 |
结 论 |
LinkedHashMap是否允许空 |
Key和Value都允许空 |
LinkedHashMap是否允许重复数据 |
Key重复会覆盖、Value允许重复 |
LinkedHashMap是否有序 |
有序 |
LinkedHashMap是否线程安全 |
非线程安全 |
1、LinkedHashMap可以认为是HashMap+LinkedList,即它既使用HashMap操作数据结构,又使用LinkedList维护插入元素的先后顺序。
2、LinkedHashMap的基本实现思想就是----多态。可以说,理解多态,再去理解LinkedHashMap原理会事半功倍;反之也是,对于LinkedHashMap原理的学习,也可以促进和加深对于多态的理解。
public class LinkedHashMap<K,V>
extends HashMap<K,V>
implements Map<K,V>
{
...
}
LinkedHashMap中并没有什么操作数据结构的方法,也就是说LinkedHashMap操作数据结构(比如put一个数据),和HashMap操作数据的方法完全一样,无非就是细节上有一些的不同罢了。
LinkedHashMap只定义了两个属性:
/**
* The head of the doubly linked list.
* 双向链表的头节点
*/
private transient Entry<K,V> header;
/**
* The iteration ordering method for this linked hash map: true
* for access-order, false for insertion-order.
* true表示最近最少使用次序,false表示插入顺序
*/
private final boolean accessOrder;
从构造方法中可以看出,默认都采用插入顺序来维持取出键值对的次序。所有构造方法都是通过调用父类的构造方法来创建对象的。
LinkedHashMap和HashMap的区别在于它们的基本数据结构上,看一下LinkedHashMap的基本数据结构,也就是Entry:
private static class Entry<K,V> extends HashMap.Entry<K,V> {
// These fields comprise the doubly linked list used for iteration.
Entry<K,V> before, after;
Entry(int hash, K key, V value, HashMap.Entry<K,V> next) {
super(hash, key, value, next);
}
...
}
列一下Entry里面有的一些属性:
1、K key
2、V value
3、Entry<K, V> next
4、int hash
5、Entry<K, V> before
6、Entry<K, V> after
其中前面四个,也就是红色部分是从HashMap.Entry中继承过来的;后面两个,也就是蓝色部分是LinkedHashMap独有的。不要搞错了next和before、After,next是用于维护HashMap指定table位置上连接的Entry的顺序的,before、After是用于维护Entry插入的先后顺序的。
LinkedHashMap自身已经实现了顺序存储,默认情况下是按照元素的添加顺序存储,也可以启用按照访问顺序存储,即最近读取的数据放在最前面,最早读取的数据放在最后面,然后它还有一个判断是否删除最老数据的方法,默认是返回false,即不删除数据。
LinkedHashMap的全部数据结构,包含散列表和循环双向链表(1.7之后把循环去掉了),由于循环双向链表线条太多了,不好画,简单的画了一个节点(黄色圈出来的)示意一***意左边的红色箭头引用为Entry节点对象的next引用(散列表中的单链表),绿色线条为Entry节点对象的before, after引用(循环双向链表的前后引用);
map集合存放null值总结
集合类 | key | value | super | 说明 |
HashTable | 不能为null | 不能为null | Dictionary | 线程安全 |
ConcurrentHashMap | 不能为null | 不能为null | AbstractMap | 线程局部安全 |
TreeMap | 不能为null | 可以为null | AbstractMap | 线程不安全 |
HashMap | 可以为null | 可以为null | AbstractMap | 线程不安全 |