目录
一.集合与数组的区别
1.1 数组
1.2 集合
二.Java集合
2.1 Java 集合框架体系
2.2 Collections
2.2.1 List
1.ArrayList
2.Vector
3.LinkedList
2.2.2 Set
1.HashSet
2.LinkedHashSet
3.TreeSet
2.3 Map
1.HashMap
2.LinkedHashMap
3.Hashtable
4.TreeMap
Java集合类存放于 java.util 包中,是一个用来存放对象的容器。主要是由两大接口派生而来:一个是
Collection
接口,主要用于存放单一元素;另一个是Map
接口,主要用于存放键值对。对于Collection
接口,下面又有三个主要的子接口:List
、Set
和Queue
。注意:
① 集合只能存放对象。比如你存一个 int 型数据 1放入集合中,其实它是自动装箱成 Integer 类后存入的,Java中每一种基本类型都有对应的引用类型。
② 集合存放的是多个对象的引用,对象本身还是放在堆内存中。
③ 集合可以存放不同类型,不限数量的数据类型。
如果增加了泛型,Java 集合可以记住容器中对象的数据类型,即只允许存放一种数据类型。
集合主要是分了两组(单列集合和双列集合),单列集合表明在集合里放的是单个元素,双列集合往往是键值对形式(key-value)
Collections 中提供了大量对集合元素进行排序、查询和修改等操作的方法,还提供了对集合对象设置不可变、对集合对象实现同步控制等方法。以下定义的方法既可用于操作 List集合,也可用于操作Set 和 Queue
常用操作:
- add() - 将元素添加到列表
- addAll() - 将一个列表的所有元素添加到另一个
- get() - 有助于从列表中随机访问元素
- iterator() - 返回迭代器对象,该对象可用于顺序访问列表的元素
- set() - 更改列表的元素
- remove() - 从列表中删除一个元素
- removeAll() - 从列表中删除所有元素
- clear() - 从列表中删除所有元素(比removeAll()效率更高)
- size() - 返回列表的长度
- toArray() - 将列表转换为数组
- contains() - 如果列表包含指定的元素,则返回true
List 接口是 Collection 接口的子接口,常用的List实现类有ArrayList、Vector、LinkedList
(一)ArrayList的底层实现
ArrayList底层维护了一个Object类型的数组,所以ArrayList里面可以存放任意类型的元素transient表示该属性不会被序列化
size变量用来保存当前数组中已经添加了多少元素
(二)ArrayList的扩容机制(面试题)
补充: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倍的
(三)ArrayList是线程不安全的
线程不安全:因为没有采用加锁机制,不提供数据访问保护,当多线程访问同一个资源时,有可能出现多个线程先后更改数据造成所得到的数据是脏数据
我们可以看它的添加元素的有关方法
ensureCapacityInternal()的作用就是判断如果将当前的新元素加到列表后面,列表的elementData数组的大小是否满足,如果size + 1的这个需求长度大于了elementData这个数组的长度,那么就要对这个数组进行扩容。
安全隐患(一)
在多个线程进行add操作时可能会导致elementData数组越界。
安全隐患(二)
另外第二步 elementData[size++] = e 设置值的操作同样会导致线程不安全。从这儿可以看出,这步操作也不是一个原子操作,它由如下两步操作构成:
在单线程执行这两条代码时没有任何问题,但是当多线程环境下执行时,可能就会发生一个线程的值覆盖另一个线程添加的值,具体逻辑如下:
这样线程AB执行完毕后,理想中情况为size为2,elementData下标0的位置为A,下标1的位置为B。而实际情况变成了size为2,elementData下标为0的位置变成了B,下标1的位置上什么都没有。并且后续除非使用set方法修改此位置的值,否则将一直为null,因为size为2,添加元素时会从下标为2的位置上开始。
注意 :JDK11 移除了 ensureCapacityInternal() 和 ensureExplicitCapacity() 方法
补充:
(一)Vector的底层实现
Vector的底层也是一个Object类型的数组
(二)Vector的扩容机制
建议自己用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的区别(面试题)
LinkedList 同时实现了List接口和Deque接口,也就是说它既可以看作一个顺序容器,又可以看作一个队列(Queue)。但 LinkedList 是采用链表结构的方式来实现List接口的,因此在进行insert 和remove动作时效率要比ArrayList高。LinkedList 是不同步的,也就是不保证线程安全
LinkedList的底层实现
LinkedList linkedList = new LinkedList();
linkedList.add(1);
linkedList.add(2);
linkedList.remove(); // 这里默认删除的是第一个结点
System.out.println("linkedList=" + linkedList);
debug模式走一波,第一次add时
效果如下:
第二次add后,效果如下:
Arraylist 与 LinkedList 的区别(面试题)
注:我在项目中很少使用到 LinkedList (基本没有),需要用到 LinkedList 的场景几乎都可以使用 ArrayList 来代替,并且,性能通常会更好,即便是在元素增删的场景下,因为LinkedList 仅仅在头尾插入或者删除元素的时候时间复杂度近似 O(1),其他情况增删元素的时间复杂度都是 O(n)
Set接口是 Collection 接口的子接口,常用的Set实现类有HashSet、TreeSet、LinkedHashSet。
问题引入: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(数组+链表+红黑树)
从这里已经可以看出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方法
这里的 ^ 是按位异或,>>>是算术右移 。将生成的hashcode值的高16位于低16位进行异或运算,这样得到的值再与(数组长度-1)进行相与[在后面的putVal方法里],可以得到最散列的下标值。这里得到hash值后我们返回去看一下putVal方法
这里我们进入resize()扩容方法 重点分析一下,我们在这里先只看它的上半段,因为旧数组不为空才能进入下半段,很明显此时不符合这个条件,我们后面借助另一个程序再来分析下半段
HashMap 的加载因子是为了平衡哈希表的性能和空间占用而引入的。当哈希表的元素数量达到容量乘以加载因子时,就会触发扩容操作,将哈希表的容量增加一倍,并重新计算每个元素在新哈希表中的位置。
加载因子的默认值是 0.75,这个值经过实验得出,可以在时间和空间上取得一个比较好的平衡点。设置更高的加载因子可以减少哈希表的空间占用,但会增加哈希冲突的概率,导致查找性能下降。相反,设置更低的加载因子可以提高哈希表的查找性能,但会增加空间占用。
总结:上半段主要是确定新的容量和阈值,并且进行扩容
分析完 resize() 扩容方法后我们返回去看 putVal() 方法
我们追过去看看newNode
Node其实是HashMap的一个静态内部类
我们继续往下执行看看
当它返回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 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会重新计算索引,并且重新分配元素,从而减少哈希冲突,提高查找和插入操作的效率。
注:HashMap扩容是一个挺影响性能的过程,实际项目中可以通过给出合适的初始化容量来减少扩容次数
为何HashMap的数组长度一定是2的次幂(面试题)
因为hash值是不固定的,所以说key的hash值的二进制数任何位都可能是0也可能是1,那么要想保证尽量减少hash碰撞,而且充分占据每个数组的位置,因为我们的容量是2的次幂所以 (容量 - 1)就可以保证它的高位都是0,而低位都是1,所以他再与我们的hash进行与运算后一定能得到在我们容量之内的一个值,这个值也就是它存储在数组的下标。
扩容迁移的时候不需要再重新计算hash值。如果数组的长度不是2的次幂,那么每次扩容时就需要重新计算每个元素的索引位置,这样会增加计算量和时间复杂度。而如果数组的长度是2的次幂,那么扩容时只需要进行位运算即可,计算效率更高。
例如我们从16扩展为32时,具体的变化如下所示:
我们可以再看看下图为16扩充为32的resize示意图:
这个设计非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以 认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。
所以即便创建时给定了容量初始值,HashMap 也会将其扩充为最近的 2 的幂次方大小(HashMap 中的tableSizeFor()方法保证)
HashMap里还有个比较有意思的地方
在求key的hash值时,为什么要无符号右移16位,然后做异或运算?(面试题)
因为最后参与&运算的是hashMap长度-1,而在hashMap的长度不是特别长的情况下,hashMap长度-1 的二进制高16位肯定都是0。所以大部分最后参与&运算的哈希值都只有二进制的低位参与,高位是会被hashMap长度-1的二进制高位的0屏蔽掉的,是不参与不了&运算的,所以此时就需要 把key的哈希值先右移16位再做异或运算,来把高位的一些特征也加入到低位中,就相当于让高位的一些特征也参与到&运算,这样&算出来的结果才会更散列,更均匀,这个在hashMap中叫做“扰动”
LinkedHashSet继承自HashSet,它的添加、删除、查询等方法都是直接用的HashSet的,唯一的不同就是它使用LinkedHashMap存储元素(这里建议先看下面的LinkedHashMap再回来看LinkedHashSet)。
LinkedHashSet所有的构造方法都是调用HashSet的同一个构造方法
我们追过去看
因为构造器里把accessOrder 写死了,所以,LinkedHashSet是不支持按访问顺序对元素排序的,只能按插入顺序排序。
TreeSet 是 SortedSet 接口的实现类,TreeSet 可以确保集合元素处于排序状态。 TreeSet 支持两种排序方法:自然排序和定制排序。默认情况下,TreeSet 采用自然排序。
自然排序
TreeSet 会调用集合元素的 compareTo(Object obj) 方法来比较元素之间的大小关系,然后将集合元素按升序排列。
如果 this > obj,返回正数 1
如果 this < obj,返回负数 -1
如果 this = obj,返回 0 ,则认为这两个对象相等
必须放入同样类的对象(默认会进行排序) 否则可能会发生类型转换异常.我们可以使用泛型来进行限制
定制排序
如果需要实现定制排序,则需要在创建 TreeSet 集合对象时,提供一个 Comparator 接口的实现类对象,该实现类需要重写Comparator 接口中的compare方法。由该 Comparator 对象负责集合元素的排序逻辑
下面是操作演示:
import java.util.Comparator;
import java.util.HashSet;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.StreamSupport;
public class TreeSet_ {
public static void main(String[] args) {
Set set=new TreeSet();
set.add(3);
set.add(1);
set.add(0);
set.add(8);
set.add(-6);
System.out.println(set);
for(Object obj:set)
{
System.out.print(obj+" ");
}
System.out.println();
Person p1=new Person(18,"张三");
Person p2=new Person(11,"李华");
Person p3=new Person(1,"小明");
Person p4=new Person(45,"李四");
Set set1=new TreeSet(new Person());//由该 Comparator 对象负责集合元素的排序逻辑
set1.add(p1);
set1.add(p2);
set1.add(p3);
set1.add(p4);
for(Person obj:set1)
{
System.out.println(obj.name+'\t'+obj.age);
}
}
}
class Person implements Comparator
{
int age;
String name;
public Person(){
}
public Person(int age, String name) {
this.age = age;
this.name = name;
}
public int compare(Person x,Person y)//重写了compare方法,以年龄大小升序排列
{
if(x.age>y.age)return 1;
else if(x.age
- Map 用于保存具有映射关系的数据,因此 Map 集合里保存着两组值,一组值用于保存 Map 里的 Key,另外一组用于保存 Map 里的 Value。
- Map 中的 key 和 value 都可以是任何引用类型的数据 Map 中的 Key 不允许重复,即同一个 Map 对象的任何两个 Key 通过 equals 方法比较中返回 false。
- Key 和 Value 之间存在单向一对一关系,即通过指定的 Key 总能找到唯一的,确定的 Value。
常用操作:
- put(K,V) - 将键K和值V的关联插入到map中。如果键已经存在,则新值将替换旧值。
- putAll() - 将指定Map集合中的所有条目插入此Map集合中。
- putIfAbsent(K,V) - 如果键K尚未与value关联,则插入关联V。
- get(K) - 返回与指定键K关联的值。如果找不到该键,则返回null。
- getOrDefault(K,defaultValue) - 返回与指定键K关联的值。如果找不到键,则返回defaultValue。
- containsKey(K) - 检查指定的键K是否在map中。
- containsValue(V) - 检查指定的值V是否存在于map中。
- replace(K,V) - 将键K的值替换为新的指定值V。
- replace(K,oldValue,newValue) - 仅当键K与值oldValue相关联时,才用新值newValue替换键K的值。
- remove(K) - 从键K表示的Map中删除条目。
- remove(K,V) - 从Map集合中删除键K与值V相关联的条目。
- keySet() -返回Map集合中存在的所有键的集合。
- values() -返回一组包含在Map集合中的所有值。
- entrySet() -返回map中存在的所有键/值映射的集合。
JDK1.8 之前 HashMap 就是由数组+链表组成的,1.8的时候加入了红黑树转换机制
HashMap的扩容机制和HashSet是一样的:
@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();
源码
当执行第一次put时
这其实就和之前HashSet分析的流程是一样的了,因为HashSet的底层就是HashMap,这里就不再赘述了。
Map实现类之间的区别
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的实现吧
使用LinkedHashMap实现LRU缓存淘汰策略
LRU,Least Recently Used,最近最少使用,也就是优先淘汰最近最少使用的元素。
基于LinkedHashMap可以按访问顺序排序的特性,用LinkedHashMap写一个有关LRU的小demo
public class LRUTest {
public static void main(String[] args) {
// 创建一个只有5个元素的缓存
LRUlru=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 extends LinkedHashMap{
//保存缓存的容量
private int capacity;
public LRU(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor, true);
this.capacity = initialCapacity;
}
//重写removeEldestEntry()方法设置何时移除旧元素
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
// 当元素个数大于了缓存的容量, 就移除元素
return size()>this.capacity;
}
}
removeEldestEntry方法是设置何时移除旧元素,在LinkedHashMap里就是一直返回false,即不会移除旧元素
如果需要移除旧元素,则需要重写removeEldestEntry()方法设定移除策略
Hashtable 是线程安全的,因为 Hashtable 内部的方法基本都经过synchronized 修饰。
Hashtable的底层数据结构
Hashtable的底层就是由 数组+链表 组成的,数组的类型是 Hashtable.Entry
Hashtable没有像HashMap那样的红黑树转换机制
Hashtable的扩容机制
注: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多了一个红黑树转换机制
TreeMap 存储 Key-Value 对时,需要根据 Key 对 key-value 对进行排序。TreeMap 可以保证所有的 Key-Value 对处于有序状态。
TreeMap 的 Key 的排序:
演示如下:
import java.util.Map;
import java.util.TreeMap;
public class TreeMap_ {
public static void main(String[] args) {
//默认排序
Map map=new TreeMap();
map.put(4,"d");
map.put(1,"w");
map.put(3,"q");
map.put(0,"b");
System.out.println(map);
Mapmap1=new TreeMap();
map1.put("1","a");//根据ASCII码值,数字在字母前
map1.put("15","b");
map1.put("13","c");
map1.put("ab","d");
map1.put("c","e");
map1.put("dd","f");
System.out.println(map1);
}
}