目录
(四)在求key的hash值时,为什么要无符号右移16位,然后做异或运算
一.集合与数组的区别
1.1 数组
- 数组长度开始时必须指定,而且一旦指定,不能更改
- 保存的必须为同一类型(基本类型/引用类型)的元素
- 使用数组进行增加/删除元素的代码比较复杂
1.2 集合
- 集合不仅可以用来存储不同类型(不加泛型时)不同数量的对象,还可以保存具有映射关系的数据
- 集合是可以动态扩展容量,可以根据需要动态改变大小
- 集合提供了更多的成员方法,能满足更多的需求
二.Java集合
Java集合类存放于 java.util 包中,是一个用来存放对象的容器。主要是由两大接口派生而来:一个是 Collection 接口,主要用于存放单一元素;另一个是 Map 接口,主要用于存放键值对。对于 Collection 接口,下面又有三个主要的子接口:List、Set 和 Queue。
注意:
① 集合只能存放对象。比如你存一个 int 型数据 1 放入集合中,其实它是自动装箱成 Integer 类后存入的,Java中每一种基本类型都有对应的引用类型。
② 集合存放的是多个对象的引用,对象本身还是放在堆内存中。
③ 集合可以存放不同类型,不限数量的数据类型。
如果增加了泛型,Java 集合可以记住容器中对象的数据类型,即只允许存放一种数据类型。
2.1 Java 集合框架体系
- List: 存储的元素是有序的、可重复的。
- Set: 存储的元素是无序的、不可重复的。
- Queue: 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。
- Map: 使用键值对(key-value)存储,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。
集合主要是分了两组(单列集合和双列集合),单列集合表明在集合里放的是单个元素,双列集合往往是键值对形式(key-value)
2.2 List
List 接口是 Collection 接口的子接口,常用的List实现类有ArrayList、Vector、LinkedList
- List集合类中元素有序(即添加顺序和取出顺序一致)、且可重复
- List集合中的每个元素都有其对应的顺序索引,即支持索引
- List集合可以添加任意元素,包括null,并且可以添加多个
1.ArrayList
(一)ArrayList的底层实现
ArrayList底层维护了一个Object类型的数组,所以ArrayList里面可以存放任意类型的元素。transient表示该属性不会被序列化
size变量用来保存当前数组中已经添加了多少元素
(二)ArrayList的扩容机制
- 当创建ArrayList对象时,如果使用的是无参构造器,则初始elementData容量为0,第1次添加,则扩容elementData为10,如需要再次扩容,则扩容elementData为1.5倍(如果是奇数的话会丢掉小数,下同)。
- 如果使用的是指定大小的构造器,则初始elementData容量为指定大小,如果需要扩容则直接扩容elementData为1.5倍。
补充:JDK6 new 无参构造的 ArrayList 对象时,直接创建了长度是10的Object[] 数组 elementData
无参构造器
//使用无参构造器创建 ArrayList 对象
ArrayList list = new ArrayList();
源码:
有参构造器
ArrayList list = new ArrayList(8);
源码:
添加元素
//使用无参构造器创建 ArrayList 对象
ArrayList list = new ArrayList();
for (int i = 1; i <= 11; i++) {
list.add(i);
}
第一次add时
源码分析:
先确定是否需要扩容,再执行赋值
确定最小需求容量minCapacity,这里返回的minCapacity值为10
最小需求容量minCapacity(此时为10) - elementData数组容量长度(此时为0)明显是大于0的,所以需要扩容
确定扩容大小,执行扩容,并且可以看到除了无参构造第一次添加元素以外,扩容都是1.5倍的
建议自己用debug模式走一遍
(三)ArrayList是线程不安全的
线程不安全:因为没有采用加锁机制,不提供数据访问保护,当多线程访问同一个资源时,有可能出现多个线程先后更改数据造成所得到的数据是脏数据
补充:
2.Vector
(一)Vector的底层实现
Vector的底层也是一个Object类型的数组
(二)Vector的扩容机制
- 如果是无参,初始elementData容量为10,默认10满后,就按2倍扩容
- 如果指定大小,则每次直接按2倍扩容
建议自己用debug模式走一遍,有很多地方其实和ArrayList相似,我在这里就只简单讲讲了
//无参构造器
Vector vector = new Vector();
for (int i = 0; i <= 10; i++) {
vector.add(i);
}
源码分析:
第11次add时
从这里就可以看到 Vector 和 ArrayList 的不同点,Vector的add方法加上了synchronized锁,任何时刻至多只能有一个线程访问该方法,所以Vector是线程安全的。
ensureCapacityHelper是为了确定是否需要扩容
最小需求容量minCapacity(此时为11) - elementData数组容量长度(此时为10)明显是大于0的,所以进入grow方法
Vector在此时扩容了一倍
(三)Vector和ArrayList的区别
3.LinkedList
LinkedList 同时实现了List接口和Deque接口,也就是说它既可以看作一个顺序容器,又可以看作一个队列(Queue)。但 LinkedList 是采用链表结构的方式来实现List接口的,因此在进行insert 和remove动作时效率要比ArrayList高。LinkedList 是不同步的,也就是不保证线程安全
(一)LinkedList的底层实现
- LinkedList中维护了一个双向链表,两个属性 first 和 last 分别指向 首节点和尾节点
- 每个节点 (Node对象),里面又维护了prev、next、item三个属性,其中通过prev指向前一个,通过next指向后一个节点。如下图
LinkedList linkedList = new LinkedList();
linkedList.add(1);
linkedList.add(2);
linkedList.remove(); // 这里默认删除的是第一个结点
System.out.println("linkedList=" + linkedList);
debug模式走一波,第一次add时
效果如下:
第二次add后,效果如下:
(二)Arraylist与LinkedList的区别
- 底层数据结构: Arraylist 底层使用的是 Object 数组;LinkedList 底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环)
- 是否支持快速随机访问:LinkedList 不支持高效的随机元素访问,而 ArrayList 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)。
- 插入和删除是否受元素位置的影响:ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。LinkedList 采用链表存储,所以对于add(E e)方法的插入,删除元素时间复杂度不受元素位置的影响,近似 O(1),如果是要在指定位置i插入和删除元素的话((add(int index, E element)) 时间复杂度近似为o(n))因为需要先移动到指定位置再插入。
- 是否支持快速随机访问: LinkedList 不支持高效的随机元素访问,而 ArrayList(实现了RandomAccess接口) 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)
注:我在项目中很少使用到 LinkedList (基本没有),需要用到 LinkedList 的场景几乎都可以使用 ArrayList 来代替,并且,性能通常会更好,即便是在元素增删的场景下,因为LinkedList 仅仅在头尾插入或者删除元素的时候时间复杂度近似 O(1),其他情况增删元素的时间复杂度都是 O(n)
2.3 Set
Set接口是 Collection 接口的子接口,常用的Set实现类有HashSet、TreeSet、LinkedHashSet。
- Set集合无序 (添加和取出的顺序不一致),没有索引
- Set集合不允许重复元素
1.HashSet
问题引入:Hashset 不能添加相同的元素/数据,它是以什么为判断依据的?
(一)HashSet 的底层实现
@SuppressWarnings("all")
public class HashSet_ {
public static void main(String[] args) {
HashSet set = new HashSet();
set.add("lucy");//会返回一个boolean值
set.add("lucy");//加入不了
set.add(new Dog("tom"));//true
set.add(new Dog("tom"));//true
System.out.println("set=" + set);
//非常经典的面试题
set.add(new String("111"));//true
set.add(new String("111"));//false
System.out.println("set=" + set);
}
}
class Dog { //定义了 Dog 类
private String name;
public Dog(String name) {
this.name = name;
}
@Override
public String toString() {
return "Dog{" +
"name='" + name + '\'' +
'}';
}
}
HashSet 的底层是 HashMap(数组+链表+红黑树)
- 添加一个元素时,通过哈希函数得到hash值,通过((n - 1) & hash) 将hash值转成 索引值。HashMap没有简单的直接通过对 数组长度取模% 来散列,它是用了位与运算,用hash值跟数组大小n减一做&。这种算法同样能达到取模那种效果,而且二进制的位运算,速度快。
- 找到存储数据表table(数组),看这个索引位置是否已经存放了元素
- 如果没有存放,则直接添加
- 如果存放了,调用 equals() 比较,如果相同,就放弃添加,如果不相同,则添加到最后
- 在java8中,如果一条 链表 的元素个数 超过 TREEIFY_THRESHOLD(默认是8)[我看到网上其他人有说达到的,应该是没把第一个位于数组的元素算进来],并且 table 的大小 >= MIN_TREEIFY_CAPACITY(默认是64),就会进行树化(红黑树)。如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树
从这里已经可以看出HashMap是使用 拉链法 来解决Hash冲突。
注:JDK1.8 之前 HashMap 就是由数组+链表组成的,1.8的时候加入了红黑树转换机制。还有一个就是当我们发生Hash碰撞时1.7采用 头插法,而1.8采用 尾插法
并且根据hashcode()+equals()方法判重
HashSet先调用元素对象的hashcode方法,通过((n - 1) & hash)算出散列的索引值。 如果该位置上已经存在元素,再根据两个元素对象的equals方法判断在业务上是否相等,是否返回true,为ture则被认为是相同对象,不能重复添加,为false则可以添加。
结构大致如下图所示(未树化前)
debug模式开启
@SuppressWarnings("all")
public class HashSetSource {
public static void main(String[] args) {
HashSet hashSet = new HashSet();
hashSet.add("java");
hashSet.add("java");
System.out.println("set=" + hashSet);
}
}
无参构造器
HashSet hashSet = new HashSet();
源码
从这里就可以很明显的看到HashSet的底层,当第一次add("java")时,我们来看它的add方法
PRESENT其实是一个静态对象,起到占位的作用,因为HashMap是一个Key-Value结构的, HashSet需要用它来充当所有Key的Value
我们接着看put方法
这里我们先通过强制步入的方式看一看它的Hash算法
这里的 ^ 是按位异或,>>>是算术右移(即无符号右移,符号位要一起移动,并且在左边补上符号位,也就是如果符号位是1就补1,符号位是0就补0)。将生成的hashcode值的高16位于低16位进行异或运算,这样得到的值再与(数组长度-1)进行相与[在后面的putVal方法里],可以得到最散列的下标值。这里得到hash值后我们返回去看一下putVal方法。
- table就是我们之前讲到的数组 ,tab、p、n都是一些辅助变量
这里我们进入resize()扩容方法 重点分析一下,我们在这里先只看它的上半段,因为旧数组不为空才能进入下半段,很明显此时不符合这个条件,我们后面借助另一个程序再来分析下半段
- final float loadFactor; 加载因子,代表了table的填充度有多少,默认是0.75。如果初始容量为16,等到满16个元素才扩容,某些桶(数组的一个元素又称作桶)里可能就有不止一个元素了。 所以加载因子默认为0.75,也就是说大小为16的HashMap,到了第13个元素,就会扩容成32。
- int threshold; 阈值,当table被填充了,也就是为table分配内存空间后, threshold一般为 capacity*loadFactory。HashMap在进行扩容时需要参考threshold,如果哈希表里的元素个数size(只要加入了一个节点,size就会++,不论节点存在数组或是链表或是树中)超过了阈值就会扩容
HashMap 的加载因子是为了平衡哈希表的性能和空间占用而引入的。当哈希表的元素数量达到容量乘以加载因子时,就会触发扩容操作,将哈希表的容量增加一倍,并重新计算每个元素在新哈希表中的位置。
加载因子的默认值是 0.75,这个值经过实验得出,可以在时间和空间上取得一个比较好的平衡点。设置更高的加载因子可以减少哈希表的空间占用,但会增加哈希冲突的概率,导致查找性能下降。相反,设置更低的加载因子可以提高哈希表的查找性能,但会增加空间占用。
总结:上半段主要是确定新的容量和阈值,并且进行扩容
分析完 resize() 扩容方法后我们返回去看 putVal() 方法
- 根据先前得出的key的 hash 值,通过 (n - 1) & hash 去计算该 key 应该存放到 table 表的哪个索引位置 ,并把这个位置的对象,赋给 p
- 判断 p 是否为 null ,如果 p 为 null, 表示该位置还没有存放元素, 就创建一个 Node ,并放在此处
我们追过去看看newNode
Node其实是HashMap的一个静态内部类
我们继续往下执行看看
- transient int size; 实际存储的key-value键值对的个数(只要加入了一个节点,size就会++,不论节点存在数组或是链表或是树中)
- transient int modCount; HashMap被改变的次数,由于HashMap非线程安全,在对HashMap进行迭代时, 如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作), 需要抛出异常ConcurrentModificationException
当它返回null时,程序回到了add方法
此时很明显表示添加成功
当第二次add("java")时,我们直接看putVal方法
因为第一次已经添加了值为"java"的key,它们的hash值和内容都是一样的,可是当前索引位置已经存放了元素,所以前两个if都不会进,执行完 e=p 后便会进入下面的程序,然后返回value,因为value!=null,所以会添加失败
这里我假设又add了一个字符串"jack",并且假设它的hash值和"java"一样,但很明显它们的内容不一样,所以它会先进入下面这个判断
判断此时p是否已经为红黑树,如果是则按红黑树的方式添加节点。我们追过去看看TreeNode
它也是HashMap的一个静态内部类,继承自LinkedHashMap中的Entry类,关于LInkedHashMap.Entry这个类我们后面再讲。
TreeNode是一个典型的树型节点,其中,prev是链表中的节点,用于在删除元素的时候可以快速找到它的前置节点。
这里明显还没有树化,接着就会进入下面的程序段
先前说了 HashMap是使用 拉链法 来解决Hash冲突 ,这里就是使用 for 循环比较链表每个元素是否与将要加入的key重复。
再提醒一下,JDK1.7采用的是 头插法,JDK1.8采用的是 尾插法
把元素添加到链表后,立即判断 该链表是否已经达到 8 个结点 , 如果已经达到,就调用 treeifyBin() 对当前这个链表进行树化(转成红黑树)
注意,在转成红黑树时,要进行判断
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)resize();
如果上面条件成立,也就是table此时的长度<64时,会先对 table 扩容。 只有上面条件不成立时,才进行转成红黑树。
分析到这里我再提一个问题:为什么建议重写equals方法需同时重写hashCode方法
我们看一个具体的例子:
public class Test {
private static class Person{
String name;
public Person(String name) {
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return Objects.equals(name, person.name);
}
}
public static void main(String []args){
HashMap<Person,String> map = new HashMap<>();
Person person = new Person("金刚");
//put到hashmap中去
map.put(person,"功");
System.out.println("结果:"+map.get(new Person("金刚")));
}
}
实际输出结果:null。尽管key从逻辑上讲是等值的(通过equals比较是相等的),但由于没有重写hashCode方法,所以get操作时导致没有定位到一个数组位置而返回逻辑上错误的值null(也有可能碰巧定位到一个数组位置,但是也会判断其node的hash值是否相等)
所以,在重写equals的方法的时候,必须注意重写hashCode方法,同时还要保证通过equals判断相等的两个对象,调用hashCode方法要返回同样的整数值。
(二)探究HashMap扩容时如何重新分配桶
debug模式开启
@SuppressWarnings("all")
public class HashSetIncrement_ {
public static void main(String[] args) {
/*
HashSet 底层是 HashMap, 第一次添加时,table 数组扩容到 16,
临界值(threshold)是 16*加载因子(loadFactor)是 0.75 = 12
如果 table 数组使用超过了临界值 12,就会扩容到 16 * 2 = 32,
新的临界值就是 32*0.75 = 24, 依次类推
*/
HashSet hashSet = new HashSet();
for(int i=1;i<=100;i++){
hashSet.add(i);
}
}
}
因为初始临界值是12,我们在第十三次add的时候进去看看
很明显他会进入resize() 扩容方法,我们先前说了
上半段主要是确定新的容量和阈值,并且进行扩容
我们再看看它的下半段
总结: 扩容后,HashMap会重新计算索引,并且重新分配元素,从而减少哈希冲突,提高查找和插入操作的效率。
- 如果这个桶中只有一个元素,把它搬移到新桶里新的位置。具体的新位置需要根据(e.hash & (newCap - 1))来计算
- 如果这个链表不止一个元素且不是一颗树,则分化成两个链表插入到新的桶中去。具体的新位置需要根据(e.hash & oldCap)来计算,(e.hash & oldCap) == 0的元素放在低位链表中,否则放在高位链表中。高位链表在新桶中的位置正好是原来的位置加上旧容量。如果不能理解的可以先看下面的面试题。
- 如果第一个元素是树节点,则把这颗树打散成两颗树插入到新桶中去
注:HashMap扩容是一个挺影响性能的过程,实际项目中可以通过给出合适的初始化容量来减少扩容次数
(三)为何HashMap的数组长度一定是2的次幂
目的是为了让一个数到前导1之后的bit位都置1,也就得到一个0b111...111的数。最后再加1,就得到一个2的幂的数:0b100...00。
最前的c-1,是为了防止c是一个2的幂的数,导致最终得到一个比它大(二倍)的2的幂。
- 将key尽可能均匀地分布在数组中,并且速度更快。Hash值是很大的,我们不能直接用hash值作为下标索引值,所以用之前还要先做对数组的长度取模运算,得到的余数才能用来作为要存放的位置,也就是对应的数组下标。但是HashMap并没有用这么简单的取模算法,它是用了位与运算,用hash值跟数组大小减一做&。这种算法同样能达到取模那种效果(取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是 2 的 n 次方)),而且二进制的位运算,速度更快。
因为hash值是不固定的,所以说key的hash值的二进制数任何位都可能是0也可能是1,那么要想保证尽量减少hash碰撞,而且充分占据每个数组的位置,因为我们的容量是2的次幂所以 (容量 - 1)就可以保证它的高位都是0,而低位都是1,所以他再与我们的hash进行与运算后一定能得到在我们容量之内的一个值,这个值也就是它存储在数组的下标。
-
扩容迁移的时候不需要再重新计算hash值。如果数组的长度不是2的次幂,那么每次扩容时就需要重新计算每个元素的索引位置,这样会增加计算量和时间复杂度。而如果数组的长度是2的次幂,那么扩容时只需要进行位运算即可,计算效率更高。
当数组元素没有挂着链表时
上图中,桶数组大小 n = 16,hash1 与 hash2 不相等。但因为只有后4位参与求余,所以结果相等。当桶数组扩容后,n 由16变成了32,对上面的 hash 值重新进行映射:
扩容后,参与模运算的位数由4位变为了5位。由于两个 hash 第5位的值是不一样,所以两个 hash 算出的结果也不一样。
当数组元素挂着链表时
假设我们上图的桶数组进行扩容,扩容后容量 n = 16,重新映射过程如下:
依次遍历链表,并计算节点 hash & oldCap
的值。如下图所示
如果值为0,将 loHead 和 loTail 指向这个节点。如果后面还有节点 hash & oldCap 为0的话,则将节点链入 loHead 指向的链表中,并将 loTail 指向该节点。如果值为非0的话,则让 hiHead 和 hiTail 指向该节点。完成遍历后,可能会得到两条链表,此时就完成了链表分组:
最后再将这两条链接存放到相应的桶中,完成扩容。如下图:
从上图可以发现,对于链表类型节点,需先对链表进行分组,重新映射后,组内节点相对位置保持不变。
所以即便创建时给定了容量初始值,HashMap 也会将其扩充为最近的 2 的幂次方大小(HashMap 中的tableSizeFor()方法保证)
HashMap里还有个比较有意思的地方
(四)在求key的hash值时,为什么要无符号右移16位,然后做异或运算
因为最后参与&运算的是hashMap长度-1,而在hashMap的长度不是特别长的情况下,hashMap长度-1 的二进制高16位肯定都是0。所以大部分最后参与&运算的哈希值都只有二进制的低位参与,高位是会被hashMap长度-1的二进制高位的0屏蔽掉的,是不参与不了&运算的,所以此时就需要 把key的哈希值先右移16位再做异或运算,来把高位的一些特征也加入到低位中,就相当于让高位的一些特征也参与到&运算,这样&算出来的结果才会更散列,更均匀,这个在hashMap中叫做“扰动”
2.LinkedHashSet
LinkedHashSet继承自HashSet,它的添加、删除、查询等方法都是直接用的HashSet的,唯一的不同就是它使用LinkedHashMap存储元素(这里建议先看下面的LinkedHashMap再回来看LinkedHashSet)。
LinkedHashSet所有的构造方法都是调用HashSet的同一个构造方法
我们追过去看
因为构造器里把accessOrder 写死了,所以,LinkedHashSet是不支持按访问顺序对元素排序的,只能按插入顺序排序。
2.4 Map
- Map 用于保存具有映射关系的数据,因此 Map 集合里保存着两组值,一组值用于保存 Map 里的 Key,另外一组用于保存 Map 里的 Value。
- Map 中的 key 和 value 都可以是任何引用类型的数据 Map 中的 Key 不允许重复,即同一个 Map 对象的任何两个 Key 通过 equals 方法比较中返回 false。
- Key 和 Value 之间存在单向一对一关系,即通过指定的 Key 总能找到唯一的,确定的 Value。
1.HashMap
JDK1.8 之前 HashMap 就是由数组+链表组成的,1.8的时候加入了红黑树转换机制
HashMap的扩容机制和HashSet是一样的:
- HashMap底层维护了Node类型的数组table,默认为null
- 当创建对象时,将加载因子(loadfactor)初始化为0.75.当添加key-val时,根据key的 hash 值,通过 (n - 1) & hash 计算出索引值(实际上和对数组长度取模的效果是一样的,只不过位运算更快) 。然后判断该索引处是否有元素
- 如果没有元素直接添加。如果该索引处有元素,继续判断该元素的key和准备加入的key相是否等,如果相等,则直接替换val;如果不相等需要判断是树结构还是链表结构,做出相应处理。如果添加时发现容量不够,则需要扩容。
- 第1次添加,则需要扩容table容量为16,临界值(threshold)为12 (16*0.75)
- 以后再扩容,则需要扩容table容量为原来的2倍(32),临界值为原来的2倍,即24,依次类推
- 在Java8中,如果一条链表的元素个数超过 TREEIFY THRESHOLD(默认是 8)[我看到网上其他人有说达到的,应该是没把第一个位于数组的元素算进来],并且table的大小 >= MIN TREEIFY CAPACITY(默认64),就会进行树化(红黑树)。如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树。
@SuppressWarnings("all")
public class HashMap_ {
public static void main(String[] args) {
HashMap map = new HashMap();
map.put("java", 10);//ok
map.put("php", 10);//ok
map.put("java", 20);//替换 value
System.out.println("map=" + map);
}
}
无参构造器
HashMap map = new HashMap();
源码
- 初始化加载因子 loadfactor = 0.75
- Node[] table = null
当执行第一次put时
这其实就和之前HashSet分析的流程是一样的了,因为HashSet的底层就是HashMap,这里就不再赘述了。
Map实现类之间的区别
2.LinkedHashMap
LinkedHashMap继承HashMap,拥有HashMap的所有特性,并且额外增加了按一定顺序访问的特性。LinkedHashMap也是线程不安全的。
我们知道HashMap使用(数组 + 单链表 + 红黑树)的存储结构,LinkedHashMap的内部也有这三种结构,但是它还额外添加了一种 “双向链表” 的结构存储所有元素的顺序。
LinkedHashMap可以看成是 LinkedList + HashMap。添加删除元素的时候需要同时维护在HashMap中的存储,也要维护在LinkedList中的存储,所以性能上来说会比HashMap稍慢。
(一)LinkedHashMap的存储结构
存储节点,继承自HashMap的Node类,next 用于单链表存储于table数组(桶)中,before 和 after 用于双向链表存储所有元素。
(二)按访问顺序排序的特性
LinkedHashMap还有一个比较重要的属性是accessOrder,默认构造器会将其赋为false,即按插入顺序存储元素,当然LinkedHashMap也留了一个构造器可以让我们指定accessOrder的值,如果传入true,LinkedHashMap就可以按访问顺序存储元素。
有兴趣的看看LinkedHashMap对HashMap的3个空方法的实现以及LinkedHashMap的get方法
这里我就讲一下LinkedHashMap对afterNodeAccess的实现吧
- 如果accessOrder为true,并且访问的节点不是尾节点;
- 从双向链表中移除访问的节点;
- 把访问的节点加到双向链表的末尾;(末尾为最新访问的元素)
(三)使用LinkedHashMap实现LRU缓存淘汰策略
LRU,Least Recently Used,最近最少使用,也就是优先淘汰最近最少使用的元素。
基于LinkedHashMap可以按访问顺序排序的特性,用LinkedHashMap写一个有关LRU的小demo
public class LRUTest {
public static void main(String[] args) {
// 创建一个只有5个元素的缓存
LRU<Integer,String>lru=new LRU<>(5,0.75f);
lru.put(1,"a");
lru.put(2,"b");
lru.put(3,"c");
lru.put(4,"d");
lru.put(5,"e");
lru.put(6,"f");
System.out.println(lru.get(3));
System.out.println(lru);
}
}
class LRU<K,V> extends LinkedHashMap<K,V>{
//保存缓存的容量
private int capacity;
public LRU(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor, true);
this.capacity = initialCapacity;
}
//重写removeEldestEntry()方法设置何时移除旧元素
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
// 当元素个数大于了缓存的容量, 就移除元素
return size()>this.capacity;
}
}
removeEldestEntry方法是设置何时移除旧元素,在LinkedHashMap里就是一直返回false,即不会移除旧元素
如果需要移除旧元素,则需要重写removeEldestEntry()方法设定移除策略
3.Hashtable
Hashtable 是线程安全的,因为 Hashtable 内部的方法基本都经过synchronized 修饰。
(一)Hashtable的底层数据结构
Hashtable的底层就是由 数组+链表 组成的,数组的类型是 Hashtable.Entry
Hashtable没有像HashMap那样的红黑树转换机制
注:Hashtable 基本被淘汰,如果要保证线程安全的话建议使用 ConcurrentHashMap,效率更高
(二)HashMap 与 Hashtable的区别
线程是否安全: HashMap 是非线程安全的,Hashtable 是线程安全的,因为 Hashtable 内部的方法基本都经过synchronized 修饰。
效率: 因为线程安全的问题,HashMap 要比 Hashtable 效率高一点。
对 Null key 和 Null value 的支持: HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出 NullPointerException
初始容量大小和每次扩充容量大小的不同 :创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。
底层数据结构: HashMap多了一个红黑树转换机制