-
- 2.1. 再谈Hsah
- 2.2. 聊聊Hashcode
- 2.3. HashMap深入理解
- 2.4. HashTable深入理解
- 2.5. HashMap与Redis底层的Dict的区别
-
- 3.1. ArrayList深入理解
- 3.2. LinkedList深入理解
JAVA基础复习(一):集合类
1. 迭代器
- 迭代器是一种设计模式,可以通过统一的方式来遍历不同的容器。
- 为什么要采用这种设计模式?本质上是为了解耦。比如你访问数组的时候可以取下标遍历,访问链表的时候可以while next!=null遍历,但是你必须熟知这些集合的数据结构才行,而且不利于代码的复用,比如你用List这个接口指向两个对象,一个动态数组,一个链表,然后发现糟了,遍历方式不同,如果要从动态数组转为链表的话,所有遍历语句都要重写。怎么解决呢?采用迭代器,迭代器作为这些容器的内部类,实现Iterator接口,内部抽象出方法。无论你访问什么数据结构,都遵循1.先判断有没有下一个2.取出下一个这样的操作,实现了遍历与具体容器解耦。
- 方法解释
- hashNext 判断是否有下一个
- next 取出容器中的下一个值
- remove 删除容器中的某个元素
- HashMap中的样例分析
- 分析语句Iterator map1it=map.entrySet().iterator();while(map1it.hasNext());
- entrySet.iterator()返回值是EntrySetIterator对象,而这个迭代器对象是继承与HashIterator的。HashIterator作为父类并未完全实现Iterator中的方法,而是实现了hasNext,remove这样的方法,而具体的next()方法交给子类来完成。比如EntryIterator类就是HashIterator的子类,通过继承父类的hasNext与remove,自己实现next()的方式,彻底实现了Iterator接口。为什么next方法不在hashmapIterator中实现呢,因为还有KeyIterator和ValueIterator,他们分别继承自HashIterator,实现Iterator接口并重写next方法,这样既公用了hashmap内的remove和hasNext,同时还针对不同类型有不同的next()以返回不同值。
- fast-fail
- 如果exceptedmodCnt与modCnt不同,则说明数据数量已经发生修改,则立即发出异常。
- 为什么在迭代器遍历的过程中不允许元素数量有变动?为了防止并发情况下一个线程遍历集合的同时另一个线程进行增删操作,无法保证数据一致性。而且随意添加删除可能导致数组越界、重复遍历、遍历不到新添加的数据等一系列复杂的错误发生。
2. HashMap与HashTable
2.1. 再谈Hsah
- Hash本质上就是一种映射,将任意对象映射成数字,以便于存储。
- 哈希函数构造方法
- 直接定址法,比如以年龄为关键字定址
- 余数法,对散列表长度取余
- 平方取中,平方后选择数值中间的数字
- 折叠法,将关键词分为几部分,然后用叠加和作为散列地址
- 哈希冲突?
- 影响冲突产生的三个因素
- 哈希函数是否均匀
- 处理冲突的方法
- 哈希表的加载因子,即哈希表有32个空位,但是现在分配了64个,加载因子就是2,多了显然不好,因为必然冲突。默认为0.75.
- 解决方法:拉链法。数组拉链表,即发生冲突的时候,数据保存头指针,链表保存元素值。好处是数组的随机查找为O1,链表为On的顺序查找,但是链表不需要连续空间。
- 影响冲突产生的三个因素
2.2. 聊聊Hashcode
- 有的Hashcode()返回的是对象的存储地址,有些只是和存储地址有一定的关联,以地址为核心进行一系列的位预算可以获得hashcode。因此不同的对象可能具有同样的hashcode,但是如果hashcode不相等就一定不是相同的对象。
- ==表示左右两边指向的内存空间的东西是否相同,因为相同内容字符串指向堆中不同的对象,共享同一个堆中常量池的字符常量。
- 重写equals必须重写hashcode。因为equals表示内容相等,但是两个实例的hashcode如果不相同会导致HashMAP中无法针对key取道正确的值。比如A a1和A a2的内容一样,但是指向两个对象,此时a1!=a2,但是a1.equals(a2)。map.put(a1,1),但是map.get(a2)就拿不到值了,因此他们是不一样的对象。一般可以用String的hashcode,因为重写过了。
2.3. HashMap深入理解
- 特点概览
- 底层是链表数组,即数组存头指针,链表存数据。
- key用set存放,所以重写equals和hashcode后可以保证key不重复。
- 允许空键和空值,但是空键只能有一个,且放在第一位。
- 元素无序,且不定时改变。LinkedHashmap是有序的,以后再说。
- 插入获取是O1。
- 遍历Map需要的时间和数组长度成正比。
- 关键因子:初始容量,加载因子。
- HashTable的特点
- 多线程安全,不允许null
- HashMap的初始容量和加载因子
- 为什么这两个因子影响很大。
- 因为HashMap扩容需需要重新创建数组,重新哈希再分配,开销很大。
- 加载因子默认为0.75,太大则哈希冲突增多,插叙效率降低。太小的话频繁rehash开销太大。
- 初始容量默认为16,可以自己设置,但是必须是2的整数次方。
- TODO:为什么必须是2的整数次方?
- 为什么这两个因子影响很大。
- 方法解释
- 4种构造函数
- HashMap(int initialCapacity,float loadFactor)。你任意传入的initialCapacity都可以被转化成与之最接近的2^n,是通过tableSizeFor()方法实现,此方法的核心算法是对给定容量-1后,将该值的每一位都变为1,然后+1,就必然是2的整数倍。
- **transient Node<K,V>[] table;**就是链表数组。可以看到,其中的每个元素的类型是Node<K,V>
- Node是Map.Entry的一个实现类,没想到吧,Entry其实是接口Map的内部接口。Node是一个链表的元素,包含常见next指针,value值,作为哈希表中的元素,还包括key和hash。Node的hashcode是key的hashcode^value的hashcode
- put()。
- 4中构造函数里面的无参构造方法在put第一个元素的时候才设定容量。如果table[]的对应位置没有链表,则在该位置新建一个节点,换而言之,其实数组存放的是头结点,头结点中也是有值的。如果table[]位置已经有了,查看目前的数据结构是红黑树还是链表,然后插入。插入链表的时候注意,如果已经超过阈值(8)则转换数据结构为红黑树。如果超过阈值就扩容。
- hash()。
- 规定了key为null的在table[]中的下标为0且始终为0.hash值的获取通过将(h=key.hashcode)^(h>>>16)形成。这一步的目的是,将高16为和低16位异或。为了讲解这个,我们不得不回到put中,put方法中如何通过hash值插入呢?插入的位置是hash&(n-1),n就是table的长度,即capacity。n一定是2的整数次方,那n-1的二进制表示一定全是1,hash&(n-1),我们知道&操作对1来说是保持自身,那相当于是hash的低x位(比如n-1是1111,那x=4)被保留下来了。好了,计算hash值的时候不做高16位和低16位的异或,我们知道一般table不会特别特别大, 那相当于hash的高位几乎不会被用到,也就是说hash会不会被碰撞完全只受hash低x位的影响,这很不科学,丢失了hash高位的特征。因此用高16位^低16位,保全他们获得了全部的32位特征。
- resize().
- 返回值为新table[]。有可能是put引起的resize操作,此时一切默认创建一个table[]即可。如果确实是不够了再扩充的,就变为原来的两倍。扩充的过程要遍历老的table[],然后为新的table[]复制,只有一个的时候利用hash值和enwCap确定j,直接放入table[j],有多个的时候就有讲究了。
- 这是个骚操作,聚焦在两点,1.e.hash&oldCap 2.newTab[j]=loHead,newTab[j+oldCap]=hihead.对第1点来说,明确如果oldCap-1是x个1,你家么newCap必然是x+1个1,因为newCap是oldCap的两倍。因此hash&(oldCap-1)与hash&(newCap-1)的区别仅仅在多出来的那一位1.oldCap本题就是10…0,有x个90.即1位第x+1位,因此用e.hash&oldCap直接可以获得它是否会呆在原来的地方。如果是0,表示还呆在原来的地方,即原来是j,现在还是j。如果是1,说明不在原来的地方了,应该重写定位,用hash&(newCap-1)。但是开发JDK的人多骚啊,hash&(newCap-1)与hash&(oldCap-1)的区别就在x+1那一位上,反映到数组定位上,相当于偏移量多了oldcap。讲解完毕。
- get().
- 虽然我们用get方法传入的参数是key,但是其实table中定位的下标是hash(key),如果桶里第一个就是要找的就返回,否则遍历单链表或者二分查找红黑树。
- remove.
- 删除对应节点,分从树里删除和从链表删除。
- 新增加方法:
- putIfAbsent,如果当前map不存在key或者key关联的值为null,就执行put(key,value)
- compute,可采用Lamdba表达啊是,将value计算后赋新值。
- containsKey和containsValue
- 看上去一样的,但复杂度完全不同。key可以直接定位查询,而value需要遍历所有元素然后查询。
- readObject和writeObject
- 这是为了序列化。你想想看,如果采用默认的序列化,相当于把hash也序列化了,此时就糟了,因为在不同的jvm里面同一个key,同一个value对应的hashcode是不同的,那key就不同。所以每序列化一次都必须重新计算hash,这也是为什么要重写这两个方法。
- merge、replace、forEach()都在为了Lambda表达式设计的,之后聊Lambda的时候再说具体。这些方法都用default关键词修饰Map接口中的方法,接口的方法有方法体你敢信? 这是1.8新特性,如果接口中方法用default修饰则可以写方法体,如果实现类不重写则直接继承该方法体。
- 4种构造函数
- HashMap中的内部类结构
- KeySet,继承自AbstractSet。由此引出第一种遍历方式。
- for(K key:map.keySet())。
- EntrySet,继承自Map.Entry,记住住在table[]里的Node也是继承自Map.Entry。
- Iterator map1it=map.entrySet().iterator();while(map1it.hasNext());
- for(Map.Entry<K, V> entry: map.entrySet())
- 上下两句等价,JVM会自动把第二句翻译成第一句然后运行。
- values,继承自abstractcollection。
- for(V v:map.values())
- 以上都是通过modCount来实现fast-fail。即读取过程中不可以add或remove,适用于单线程、多线程情况下,但是只用于参考。modCount不是volatile的,因此可见性得不到保障,线程不安全。
- KeySet,继承自AbstractSet。由此引出第一种遍历方式。
2.4. HashTable深入理解
- 概述
- 虽然HashTable是并发安全的,但总觉得是过时的,过时的原因是并发安全通过synchronized实现过于重量,没有迭代器而是Enumeration,不够强大。源码中考虑的没有HashMap那么细,比如没有红黑树,就是比较简陋。在并发中还是使用concurrentHashMap吧。
- 方法分析
- 构造函数,默认初始容量为11,加载因子0.75,table[]中的元素是Entry<K,V>而不是Node。hashcode()也不是key和value的hashcode()做异或,而是hash与value的hashcode()做异或。
- 没有hash(),注意它没有hash()。他是字节用key的hashcode对table[]的长度取余得到的定位下标。
- rehash()用来扩容,将容量扩大到原来的2n+1倍,没有树也没有链表两开花的操作方式,就是单纯的遍历数据然后重哈希重放置,从table[] A放到table[] B中。太简陋了8.
- contains()。HashMap中没有这个方法,
- 为什么HashMap可以key可以有一个为null呢,因为其不需要null的hashcode,在hash()方法中如果为null就是0,而在HashTabl中没有hash()这个说法,直接就是key.hashcode(),如果key==null,就会报错。
- 如何实现线程同步、并发安全
- 对方法加synchronized。
- 对内部类获得的集合用Collections.synchronizedSet()返回线程安全的集合类。
- 将内部keySet,entrySet,values用volatile做修饰,保证可见性。
2.5. HashMap与Redis底层的Dict的区别
- Redis底层使用渐进式扩容,而hashmap是直接扩容。
3. ArrayList与LinkedList
3.1. ArrayList深入理解
- 特点概述
- 动态数组
- 有序,输入和输出顺序一致。这里的有序并不指大小顺序。
- 元素可以为null
- 读写操作为O1,但是add操作为On
- 属性概览
- Object[] elementData 动态数组里的数组
- size 容量,默认容量为10
- static final Object[] EMPTY_ELEMENTDATA 类变量,所有创建的空ArrayList共用这个空数组。
- static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};虽然大家都是空数组,但是EMPTY_ELEMENTDATA != DEFAULTCAPACITY_EMPTY_ELEMENTDATA,因为他们指向的对象不同,因此就出现了骚操作。
- EMPTY_ELEMENTDATA是赋值给有参为0的构造函数,或者复制构造函数参数的容量为空的默认空数组,DEFAULTCAPACITY_EMPTY_ELEMENTDATA是赋值给无参构造函数的空数组。
- 方法分析
- 三种构造函数。给定一个初始容量,新建一个对应容量的数组,注意size没在这里赋值,因为capacity是容量而size是实际存储数据的大小。
- add().
- 添加之前先判断是否数组越界。如果是无参构造函数创建的ArrayList,第一次添加的时候就使用默认数组长度10或者添加的数量,取更大的那个,不然如果是EMPTY_ELEMENTDATA就容量就取需要添加的数量。如果发现数据长度比需要的容量要小,就调用grow()方法扩容
- grow().
- 是否要扩容,要看size+numNew和capacity的关系。扩容过程是没超过1.5倍就扩充成1.5倍,超过了就扩充成需要的容量。比如目前容量是10,但是需求是11,就扩充到15,如果需求是20,那就扩充到20.
- ensureCapacity()手动扩容
- 这个方法是暴露出来让你手动扩容的,指定你需要的容量。这个扩容之前也会判断是无参构造器生成的还是有参构造器生成的。
- rangeCheck()与rangeCheckForAdd()
- 用于add(index,E e)进行越界警告
- readObject和writeObject
- ArrayList又不涉及到HashCode,为什么也要重写呢?是因为ArrayList中容量是大于size的,即容量为10的数组采用默认序列化会全部序列化到文件中,哪怕数组只用了第一位,后面全是null,重写的目的是为了省空间
- 迭代器分析
- itr implements Iterator
- 常规迭代器
- Listitr extends itr implements ListIterator
- 双向迭代器,还具有hasPrevious,previousIndex
- itr implements Iterator
- 与Vector的区别
- vector是并发安全的
- vector基础大小为10,构造函数与ArrayList不同
- vector扩容大小可以指定,如果是0,扩容后就是2倍,如果两倍还不够,就选择当前需要的容量
- 没有重写readObject
- Stack是利用Vector的方法实现了Stack,但是现在应该用LinkedList来实现栈,而不是Stack
3.2. LinkedList深入理解
- 特点概述
- 本质上是双向链表,可以当做队列,双端队列或者栈使用。
- 继承自AbstractSequentialList接口,同时实现了Deque,Queue接口
- 成员变量,均为transient
- first
- last
- size
- 方法分析
- 两种构造函数。无参构造函数为空,有参构造函数将集合内全部通过addAll添加。
- add()
- 方法中调用linkLast,即通过last字段将数据放置到双链表末尾
- add(int index,E e)
- 先要通过checkPositonIndex()判断index是否合法,然后通过node(index)定位到index下标内的元素,这个定位算法有个骚操作,通过index与size/2的大小判断在前一半还是后一半,前一半用头指针找,后一半用尾指针找。找到node(index)后,将新的e插入到它前面去。
- get(index)
- 就是用之前的node(index).item即可
- readObject和WriteObject
- 其他没什么好说的,就是双链表数据结构而已。
- 迭代器分析
- Listitr
4. LinkedHashMap深入理解
-
- 本质上就是HashMap然后将每个Node node通过before和after指针联系起来。
- 特点,实现了LRU算法。当你插入元素时,他会将节点插入双向链表的链尾,如果key重复,也会将节点移动至链尾,当用get方法获取value是也会将节点移动至链尾。
- 节点
- Entry extends HashMap.Entry,新增before和after指针。继承了next指针,hash值,key和value值。
- 成员变量
- LinkedHashMap.Entry<K,V> head、tail。整个链表的头尾指针,
- final boolean accessOrder。true按照access-order访问顺序,false按照insertion-order插入顺序迭代linkedHashMap。
- 什么是insertion-order呢?就是hashmap会像linkenlist一样的顺序保存数据,数据变成有序的,这个有序指的是按照插入顺序,你迭代也是插入顺序。
- 什么是access-order呢?就是你put的和get的都会导致节点防止到链尾。所以只有配置access-order才能是LRU的,默认不开启。
- 方法分析
- hashmap中定位的空方法,是留给ListedHashMap实现的。
- put。直接继承自HashMap方法,那如何体现出构建双链表的过程呢?put中要将新节点放入桶中,通过NewNode方法创建新节点,在LinkedHashMap中重写了newNode方法,除了创建一个LinkedHashMap.Entry外,还用了linkNodeLast方法以维护每次插入过程都在链表尾部。那如何体现出更新节点也放置到链尾呢?put方法中会检测key是否存在,如果存在就更改value,但是在linkedhashmap中更进一步,采用了afterNodeAccess方法,在HashMap中是空方法,但是在LinkedHashmap中被重写。
- linkNodeLast(Node p)。一开始head和tail均为null,当第一次put的时候,head和tail都指向p,否则让tail.after=p;p.before=tail;tail=p.即更新尾节点。
- afterNodeAccess(Node p).从链表中取出p,重建p前,p后节点的关系,重建p与tail的关系即可。
- get。get操作在access-order为真的情况下,执行afterNodeAccess方法,以放置末尾。
- removeEldestEntry。如果想实现LRU算法,重写此函数即可。因为在put方法的最后,调用的afterNodeInsertion()方法,在此方法中有removeEldestEntry方法。此方法默认false,即不调整链表长度,如果你重写为当大于某一值是为真,那么就相当于给定了LRU缓存的长度。
- remove,不仅要从hashmap里面拿走,更重要的是从linkedlist里面拿走。
- hashmap中定位的空方法,是留给ListedHashMap实现的。
- 疑问:HashMap中的红黑树怎么解决呢?
- 不需要额外的解决方式,在newTreeNode方法的基础上使用linkNodeLast即可,因为双向链表其实相当于是一个额外的数据结构,只要把这些节点串联起来即可。
5. ashSet深入理解
- 基本要求
- key必须重写hashcode和equals方法,并且equals返回ture,则hashcode必须相等,这个可以借鉴String,hashcode是用31进制对每个字母求和。
- 如果key已经存在,会直接覆盖。
- 如果equals返回true但是hashcode不相等,因为存储是根据俄hashcode来的,因此还是会存放两个key。
- 成员变量
- HashMap<E,Object> map;其中Object是一个私有类变量,因此可以理解成所有HashSet的实例共享一个object作为value以节省空间。
- 利用HashMap的key的性质实现Set
- 采用HashSet(initialCapacity,loadFactor,boolean dummy)的方法,dummy只是为了重载而已,可以返回一个linkedHashMap,实现有序的Set
6. PriorityQueue深入理解
- 基于堆数据结构实现的无界队列。元素的优先顺序基于实现Comparable接口或者构造函数中传入Comparator比较器。默认是最小堆,且不接受null值,非线程安全。
- 成员变量
- DEFAULT_INITIAL_CAPACITY=11,默认队列容量
- Object[] queue,用来实现最小堆
- size 元素个数
- Comparator comparator,比较器,可以通过构造函数传入。如果构造函数不传入,则采用数组元素自带的大小顺序,若无实现Comparable则报错。
- 方法概览
- 七种构造器,主要是指定排序方法,指定初始容量等。
- add,offer方法是同样的,通过siftUp方法,插入到队列的末尾,然后上浮到应该在的位置。
- siftUp又分为利用比较器的和实现比较接口两种,从下向上,如果比父亲小,就交换,直到比父亲大。(最小堆)
- peek,返回首元素。
- poll,返回并删除首元素。先首尾元素交换,删除微元素,然后将首元素从上向下下沉自应该存在的位置。
- 如果对方法有不理解,请参考我写的通过java实现堆排序的博客
https://blog.csdn.net/w8253497062015/article/details/89496516
7. TreeMap与TreeSet深入理解
7.1. TreeMap
- 底层由红黑树实现,每个节点都是一个红黑树节点,自动排序,但是添加和查找的效率低于HashMap,优势是根据排序规则保存有序状态。
7.2. TreeSet
- TreeSet通过NavigableMap实现,其实底层用的还是TreeMap