集合

集合概述

(学习集合时做的笔记,作为参考)

(1)Collection接口单列集合,用来存储一个一个的对象(需要基本数据类型的时候用包装类)
<1>子接口1:List接口:存储有序的、可重复的数据。 —>比较像数组,但是为“动态”数组
Collection接口 —> List接口 —> 实现类 —— ArrayList、LinkedList、Vector
<2>子接口2:Set接口:存储无序的、不可重复的数据 —>高中讲的“集合
Collection接口 —> Set接口 —> 实现类 —— HashSet、LinkedHashSet、TreeSet
(Collection下面不止List & Set两个接口,还有其他的)

(2)Map接口双列集合,用来存储一对(key - value)一对的数据,代表一种映射
Map接口 —> 实现类——HashMap、LinkedHashMap、TreeMap、Hashtable、Properties

image.png

image.png

你知道哪些线程安全的集合?

image.png

解题思路:Collections、java.util.concurrent (JUC)
java.util包下的集合类中,大部分都是非线程安全的,但也有少数的线程安全的集合类,例如Vector、Hashtable,它们都是非常古老的API。虽然它们是线程安全的,但是性能很差,已经不推荐使用了。
对于这个包下非线程安全的集合,可以利用Collections工具类,该工具类提供的synchronizedXxx()方法,可以将这些集合类包装成线程安全的集合类。 从JDK 1.5开始,并发包下新增了大量高效的并发的容器,这些容器按照实现机制可以分为三类。
第一类是以降低锁粒度来提高并发性能的容器,它们的类名以Concurrent开头,如ConcurrentHashMap。
第二类是采用写时复制技术实现的并发容器,它们的类名以CopyOnWrite开头,如CopyOnWriteArrayList。
第三类是采用Lock实现的阻塞队列,内部创建两个Condition分别用于生产者和消费者的等待,这些类都实现了BlockingQueue接口,如ArrayBlockingQueue。 加分回答 Collections还提供了如下三类方法来返回一个不可变的集合,这三类方法的参数是原有的集合对象,返回值是该集合的“只读”版本。
通过Collections提供的三类方法,可以生成“只读”的Collection或Map。 emptyXxx():返回一个空的不可变的集合对象 singletonXxx():返回一个只包含指定对象的不可变的集合对象 unmodifiableXxx():返回指定集合对象的不可变视图

请说说你对Java集合的了解

解题思路:Set、List、Quque、Map

Java中的集合类分为4大类,分别由4个接口来代表,它们是Set、List、Queue、Map。其中,Set、List、Queue、都继承自Collection接口。
Set 代表无序的、元素不可重复的集合。
List 代表有序的、元素可以重复的集合。
Queue 代表先进先出(FIFO)的队列
Map 代表具有映射关系(key-value)的集合。
Java提供了众多集合的实现类,它们都是这些接口的直接或间接的实现类,其中比较常用的有:HashSet、TreeSet、ArrayList、LinkedList、ArrayDeque、HashMap、TreeMap等。

加分回答
上面所说的集合类的接口或实现,都位于 java.util 包下,这些实现大多数都是非线程安全的。虽然非线程安全,但是这些类的性能较好。如果需要使用线程安全的集合类,则可以利用Collections工具类,该工具类提供的 synchronizedXxx() 方法,可以将这些集合类包装成线程安全的集合类。
java.util包下的集合类中,也有少数的线程安全的集合类,例如 Vector、Hashtable,它们都是非常古老的API。虽然它们是线程安全的,但是性能很差,已经不推荐使用了

从JDK 1.5开始,并发包下新增了大量高效的并发的容器,这些容器按照实现机制可以分为三类。
第一类是以降低锁粒度来提高并发性能的容器,它们的类名以 Concurrent 开头,如 ConcurrentHashMap。
第二类是采用写时复制技术实现的并发容器,它们的类名以CopyOnWrite开头,如CopyOnWriteArrayList。
第三类是采用 Lock 实现的阻塞队列,内部创建两个Condition分别用于生产者和消费者的等待,这些类都实现了 BlockingQueue 接口,如ArrayBlockingQueue。

Map 之 HashMap

HashMap 底层原理

要求

  • 掌握 HashMap 的基本数据结构
  • 掌握树化
  • 理解索引计算方法、二次 hash 的意义、容量对索引计算的影响
  • 掌握 put 流程、扩容、扩容因子
  • 理解并发使用 HashMap 可能导致的问题
  • 理解 key 的设计

    1)基本数据结构

  • 1.7 数组 + 链表

  • 1.8 数组 + (链表 | 红黑树)

2)树化与退化

树化意义,为何用红黑树?红黑树一定比链表好吗?

红黑树用来避免 DoS 攻击,防止链表超长时性能下降,树化应当是偶然情况,是保底策略。
hash 表的查找,更新的时间复杂度是Java 高级 - 图4 ,而红黑树的查找,更新的时间复杂度是Java 高级 - 图5,TreeNode 占用空间也比普通 Node 的大如非必要,尽量还是使用链表。

树化规则与原因

(1)何时树化?
链表长度超过树化阈值 8 时,先尝试扩容来减少链表长度,如果数组容量已经 >= 64,才会进行树化。
即:链表长度大于8 & 数组长度小于64。(因此链表的长度是可能超过 10 的)
(2)为什么?
hash 值如果足够随机,则在 hash 表内按泊松分布,在负载因子 0.75 的情况下,长度超过 8 的链表出现概率是 0.00000006树化阈值选择 8 就是为了让树化几率足够小

退化规则

情况1:在扩容时如果拆分树时,树元素个数 <= 6 则会退化链表
情况2:remove 树节点时,若 root、root.left、root.right、root.left.left 有一个为 null ,也会退化为链表。

3)索引计算

image.png

索引计算方法
  • 首先,计算对象的 hashCode().
  • 再进行调用 HashMap 的 hash() 方法进行二次哈希.

二次 hash() 是为了综合高位数据,让哈希分布更为均匀.(仅低位,可能计算结果随机性不高)

  • 最后 & (capacity – 1) 得到索引(要求 capacity 必须是 2^n 才能这样做,原因见下小节)

不同 JDK 版本的 二次 hash 运算,以及原因(1)不同版本 二次哈希 运算
1.8 中的二次 hash:如图,原始 hash 需要右移 16位,即把高位拿过来,然后再和原始 hash 进行异或运算。
image.png
1.7 中的二次 hash 更为复杂,多次移位,抑或运算。
image.png
(2)为什么需要这样做?
hash 值搞得不好,导致低位情况相似,取模后余数都差不多,这样会导致 某一链表过长。
为此,我们希望高位也参与运算,让最后的 hash 更加随机
image.png

数组容量为何是 2 的 n 次幂

好处1:计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模. 为什么必须是 2^n?得明白一个事情,Java 高级 - 图10,原因如下图。
这要是为什么要求数组容量为Java 高级 - 图11 的原因,只有此时才能这样取模
可以想到,任何Java 高级 - 图12的倍数,他的1都在n+1号位之前,-1之后,n开始往后都是0,所以与运算也必是0。这样就一定能进行取模运算。
image.png

好处2:扩容时重新计算索引效率更高
因为容量是参与hash运算的,扩容导致 容量改变,需要重新计算 hash值。
hash & oldCap(hash 按位与 旧容量):
== 0:元素留在原来位置 ;
! = 0:否则新位置 = 旧位置 + oldCap.

你看这个链表这么长,扩容后得重新分配吧。
这里说的效率高就是为了让链表中的某些值,不同移位到别的位置上去,让他在原位置待着。
image.png
image.png

注意:二次哈希是必须的嘛?容量是 2 的 n 次幂的弊端?

  • 二次 hash 是为了配合 容量是 2 的 n 次幂 这一设计前提,如果 hash 表的容量不是 2 的 n 次幂,则不必二次 hash
  • 容量是 2 的 n 次幂 这一设计计算索引效率更好,但 hash 的分散性就不好,需要二次 hash 来作为补偿没有采用这一设计的典型例子是 Hashtable.

∴ 如果选择一个好的质数作为容量大小,那么不用二次哈希,而且 hash 分布还会不错.

4)put 与扩容

put 流程
  1. HashMap 是懒惰创建数组的,首次使用才创建数组
  2. 计算索引(桶下标)
  3. 如果桶下标还没人占用,创建 Node 占位返回
  4. 如果桶下标已经有人占用
    1. 已经是 TreeNode 走红黑树的添加或更新逻辑
    2. 普通 Node,走链表的添加或更新逻辑,如果链表长度超过树化阈值,走树化逻辑
  5. 返回前检查容量是否超过阈值,一旦超过进行扩容

1.7 与 1.8 的区别
  1. 链表插入节点时,1.7 是头插法,1.8 是尾插法
  2. 1.7 是大于等于阈值且没有空位时才扩容,而 1.8 是大于阈值就扩容
  3. 1.8 在扩容计算 Node 索引时,会优化

扩容(加载)因子为何默认是 0.75f
  1. 在空间占用与查询时间之间取得较好的权衡
  2. 大于这个值,空间节省了,但链表就会比较长影响性能
  3. 小于这个值,冲突减少了,但扩容就会更频繁,空间占用也更多

扩容行为条件

向HashMap中添加数据时,有三个条件会触发它的扩容行为:
1. 如果数组为空,则进行首次扩容
2. 将元素接入链表后,如果链表长度达到 8,并且数组长度小于 64,则扩容。
3. 添加后,如果数组中元素超过阈值,即比例超出限制(默认为0.75),则扩容。并且,每次扩容时都是将容量翻倍,即创建一个2倍大的新数组,然后再将旧数组中的数组迁移到新数组里。
由于HashMap中数组的容量为 2^N,所以可以用位移运算计算新容量,效率很高。

5)并发问题

扩容死链(1.7 会存在)

1.7 源码如下:

  1. void transfer(Entry[] newTable, boolean rehash) {
  2. int newCapacity = newTable.length;
  3. for (Entry<K,V> e : table) {
  4. while(null != e) {
  5. Entry<K,V> next = e.next;
  6. if (rehash) {
  7. e.hash = null == e.key ? 0 : hash(e.key);
  8. }
  9. int i = indexFor(e.hash, newCapacity);
  10. e.next = newTable[i];
  11. newTable[i] = e;
  12. e = next;
  13. }
  14. }
  15. }
  • e 和 next 都是局部变量,用来指向当前节点和下一个节点
  • 线程1(绿色)的临时变量 e 和 next 刚引用了这俩节点,还未来得及移动节点,发生了线程切换,由线程2(蓝色)完成扩容和迁移

image.png

  • 线程2 扩容完成,由于头插法,链表顺序颠倒。但线程1 的临时变量 e 和 next 还引用了这俩节点,还要再来一遍迁移

image.png

  • 第一次循环
    • 循环接着线程切换前运行,注意此时 e 指向的是节点 a,next 指向的是节点 b
    • e 头插 a 节点,注意图中画了两份 a 节点,但事实上只有一个(为了不让箭头特别乱画了两份)
    • 当循环结束是 e 会指向 next 也就是 b 节点

image.png

  • 第二次循环
    • next 指向了节点 a
    • e 头插节点 b
    • 当循环结束时,e 指向 next 也就是节点 a

image.png

  • 第三次循环
    • next 指向了 null
    • e 头插节点 a,a 的 next 指向了 b(之前 a.next 一直是 null),b 的 next 指向 a,死链已成
    • 当循环结束时,e 指向 next 也就是 null,因此第四次循环时会正常退出

image.png

数据错乱(1.7,1.8 都会存在)

6)key 的设计

key 的设计要求

  1. HashMap 的 key 可以为 null,但 Map 的其他实现则不然
  2. 作为 key 的对象,必须实现 hashCode 和 equals,并且 key 的内容不能修改(不可变)
  3. key 的 hashCode 应该有良好的散列性

如果 key 可变,例如修改了 age 会导致再次查询时查询不到

  1. public class HashMapMutableKey {
  2. public static void main(String[] args) {
  3. HashMap<Student, Object> map = new HashMap<>();
  4. Student stu = new Student("张三", 18);
  5. map.put(stu, new Object());
  6. System.out.println(map.get(stu));
  7. stu.age = 19;
  8. System.out.println(map.get(stu));
  9. }
  10. static class Student {
  11. String name;
  12. int age;
  13. public Student(String name, int age) {
  14. this.name = name;
  15. this.age = age;
  16. }
  17. public String getName() {
  18. return name;
  19. }
  20. public void setName(String name) {
  21. this.name = name;
  22. }
  23. public int getAge() {
  24. return age;
  25. }
  26. public void setAge(int age) {
  27. this.age = age;
  28. }
  29. @Override
  30. public boolean equals(Object o) {
  31. if (this == o) return true;
  32. if (o == null || getClass() != o.getClass()) return false;
  33. Student student = (Student) o;
  34. return age == student.age && Objects.equals(name, student.name);
  35. }
  36. @Override
  37. public int hashCode() {
  38. return Objects.hash(name, age);
  39. }
  40. }
  41. }

String 对象的 hashCode() 设计

image.png

对比 HashMap & HashTable & TreeMap

Hashtable、HashMap、TreeMap 都是最常见的一些 Map 实现,是以键值对的形式存储和操作数据的容器类型。

Hashtable 是早期 Java 类库提供的一个哈希表实现,本身是同步的,不支持 null 键和值,由于同步导致的性能开销,所以已经很少被推荐使用
HashMap 是应用更加广泛的哈希表实现,行为上大致上与 HashTable 一致,主要区别在于 HashMap 不是同步的,支持 null 键和值等。通常情况下,HashMap 进行 put 或者 get 操作,可以达到常数时间的性能,所以它是绝大部分利用键值对存取场景的首选,比如,实现一个用户 ID 和用户信息对应的运行时存储结构。
TreeMap 则是基于红黑树的一种提供顺序访问的 Map,和 HashMap 不同,它的 get、put、remove 之类操作都是 O(log(n))的时间复杂度,具体顺序可以由指定的 Comparator 来决定,或者根据键的自然顺序来判断

HashMap是线程安全的吗?如果不是该如何解决?

解题思路:Hashtable、Collections、ConcurrentHashMap

HashMap是非线程安全的,在多线程环境下,多个线程同时触发HashMap的改变时,有可能会发生冲突。所以,在多线程环境下不建议使用HashMap。想要使用线程安全的HashMap,一共有三种办法:使用Hashtable、使用Collections将HashMap包装成线程安全的HashMap、使用ConcurrentHashMap,其中第三种方式最为高效,是我们最推荐的方式。

Hashtable
HashMap 和 Hashtable 都是典型的 Map 实现,而 Hashtable 是线程安全的。
虽然这算是一个可选方案,但却是不推荐的方案。因为Hashtable是一个古老的API,从Java 1.0开始就出现了,它的同步方案还不成熟、性能不好,甚至官方都给出了不推荐使用的建议。

Collections
Collections类中提供了 synchronizedMap() 方法,可以将我们传入的Map包装成线程同步的Map。
除此以外,Collections还提供了如下三类方法来返回一个不可变的集合,这三类方法的参数是原有的集合对象,返回值是该集合的“只读”版本。通过Collections提供的三类方法,可以生成“只读”Map。
emptyMap():返回一个空的不可变的Map对象。
singletonMap():返回一个只包含指定键值对的不可变的Map对象。
unmodifiableMap() :返回指定Map对象的不可变视图。

ConcurrentHashMap
ConcurrentHashMap是线程安全且高效的HashMap,并且在JDK 8中进行了升级,使其在JDK 7的基础上进一步降低了锁的粒度,从而提高了并发的能力。
在JDK 7中ConcurrentHashMap的底层数据结构为 “ 数组 + 链表 ” ,但是为了降低锁的粒度,JDK7将一个Map拆分为若干子Map,每一个子Map称为一个段。多个段之间是相互独立的,而每个段都包含若干个槽,段中数据发生碰撞时采用链表结构解决。在并发插入数据时,ConcurrentHashMap锁定的是段,而不是整个Map。因为锁的粒度是段,所以这种模式也叫“分段锁”。另外,段在容器初始化的时候就被确定下来了,之后不能更改。而每个段是可以独立扩容的,各个段之间互不影响,所以并不存在并发扩容的问题。
在JDK8中ConcurrentHashMap的底层数据结构为 “ 数组 + 链表 + 红黑树 ” ,但是为了进一步降低锁的粒度,JDK8 取消了段的设定,而是直接在Map的槽内存储链表或红黑树。并发插入时它锁定的是头节点,相比于段头节点的个数是可以随着扩容而增加的,所以粒度更小。引入红黑树,则是为了在冲突剧烈时,提高查找槽内元素的效率。

LinkedHashMap 怎么实现有序?

LinkedHashMap维护了⼀个双向链表,有头尾节点,同时 LinkedHashMap 节点 Entry 内部除了继承 HashMap 的 Node 属性,还有 before 和 after ⽤于标识前置节点和后置节点。 可以实现按插⼊的顺序或访问顺序排序。

TreeMap 怎么实现有序?

TreeMap 是按照 Key 的⾃然顺序或者 Comprator 的顺序进⾏排序,内部是通过红⿊树来实现。所以要么 key 所属的类实现 Comparable 接⼜,或者⾃定义⼀个实现了 Comparator 接⼜的⽐较器,传给 TreeMap ⽤于 key 的⽐较

Map 之 HashTable

HashMap 和 Hashtable 的区别

解题思路:线程安全、null
HashMap 和 Hashtable 都是典型的 Map 实现,它们的区别在于是否线程安全,是否可以存入null值。

  1. Hashtable在实现Map接口时保证了线程安全性,而 HashMap 则是非线程安全的。
    所以,Hashtable 的性能不如 HashMap,因为为了保证线程安全它牺牲了一些性能。
    2. Hashtable不允许存入null,无论是以null作为key或value,都会引发异常。
    而HashMap是允许存入null的,无论是以null作为key或value,都是可以的。

加分回答
虽然Hashtable是线程安全的,但仍然不建议在多线程环境下使用 Hashtable。因为它是一个古老的API,从Java 1.0开始就出现了,它的同步方案还不成熟,性能不好。
如果要在多线程环下使用 HashMap,建议使用 ConcurrentHashMap。它不但保证了线程安全,也通过降低锁的粒度提高了并发访问时的性能。

Collection.List 之 ArrayList & LinkedList

说说你对ArrayList的理解

解题思路:数组实现、默认容量10、每次扩容1.5倍
标准回答
ArrayList是基于数组实现的,它的内部封装了一个Object[]数组。 通过默认构造器创建容器时,该数组先被初始化为空数组,之后在首次添加数据时再将其初始化成长度为10的数组。
我们也可以使用有参构造器来创建容器,并通过参数来显式指定数组的容量,届时该数组被初始化为指定容量的数组。如果向ArrayList中添加数据会造成超出数组长度限制,则会触发自动扩容,然后再添加数据。
扩容就是数组拷贝,将旧数组中的数据拷贝到新数组里,而新数组的长度为原来长度的1.5倍。
ArrayList支持缩容,但不会自动缩容,即便是ArrayList中只剩下少量数据时也不会主动缩容。如果我们希望缩减ArrayList的容量,则需要自己调用它的trimToSize()方法,届时数组将按照元素的实际个数进行缩减。

加分回答
Set、List、Queue都是Collection的子接口,它们都继承了父接口的iterator()方法,从而具备了迭代的能力。但是,相比于另外两个接口,List还单独提供了listIterator()方法,增强了迭代能力。
iterator()方法返回Iterator迭代器,listIterator()方法返回ListIterator迭代器,并且ListIterator是Iterator的子接口。ListIterator在Iterator的基础上,增加了向前遍历的支持,增加了在迭代过程中修改数据的支持。

ArrayList 扩容原理

ArrayList 的底层是动态数组,它的容量能动态增长。在添加大量元素前,应用可以使用ensureExplicitCapacity操作增加实例的容量。ArrayList 继承了 AbstractList ,并实现了 List 接口。

扩容规则

  1. ArrayList() 会使用长度为零的数组
  2. ArrayList(int initialCapacity) 会使用指定容量的数组
  3. public ArrayList(Collection<? extends E> c) 会使用 c 的大小作为数组容量
  4. add(Object o) 首次扩容为 10,再次扩容为上次容量的 1.5 倍
  5. addAll(Collection c) 没有元素时,扩容为 Math.max(10, 实际元素个数),有元素时为 Math.max(原容量 1.5 倍, 实际元素个数)

其中第 4 点必须知道,其它几点视个人情况而定

ArrayList & LinkedList & Vector 的区别


Guide 版本

#ArrayList 和 Vector 的区别?

  • ArrayList 是 List 的主要实现类,底层使用 Object[ ]存储,适用于频繁的查找工作,线程不安全 ;
  • Vector 是 List 的古老实现类,底层使用Object[ ] 存储,线程安全的。

    #ArrayList 与 LinkedList 区别?

  1. 是否保证线程安全: ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全;
  2. 底层数据结构: ArrayList 底层使用的是 Object 数组;LinkedList 底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别,下面有介绍到!)
  3. 插入和删除是否受元素位置的影响:
    • ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)方法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element))时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。
    • LinkedList 采用链表存储,所以,如果是在头尾插入或者删除元素不受元素位置的影响(add(E e)、addFirst(E e)、addLast(E e)、removeFirst() 、 removeLast()),时间复杂度为 O(1),如果是要在指定位置 i 插入和删除元素的话(add(int index, E element),remove(Object o)), 时间复杂度为 O(n) ,因为需要先移动到指定位置再插入。
  4. 是否支持快速随机访问: LinkedList 不支持高效的随机元素访问,而 ArrayList 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)。
  5. 内存空间占用: ArrayList 的空 间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。

我们在项目中一般是不会使用到 LinkedList 的,需要用到 LinkedList 的场景几乎都可以使用 ArrayList 来代替,并且,性能通常会更好!就连 LinkedList 的作者约书亚 · 布洛克(Josh Bloch)自己都说从来不会使用 LinkedList 。

极客时间 版本https://time.geekbang.org/column/article/7810
这三者都是实现集合框架中的 List,也就是所谓的有序集合,因此具体功能也比较近似,比如都提供按照位置进行定位、添加或者删除的操作,都提供迭代器以遍历其内容等。但因为具体的设计区别,在行为、性能、线程安全等方面,表现又有很大不同。
Vector 是 Java 早期提供的线程安全的动态数组,如果不需要线程安全,并不建议选择,毕竟同步是有额外开销的。Vector 内部是使用对象数组来保存数据,可以根据需要自动的增加容量,当数组已满时,会创建新的数组,并拷贝原有数组数据。
ArrayList 是应用更加广泛的动态数组实现,它本身不是线程安全的,所以性能要好很多。与 Vector 近似,ArrayList 也是可以根据需要调整容量,不过两者的调整逻辑有所区别,Vector 在扩容时会提高 1 倍,而 ArrayList 则是增加 50%。
LinkedList 顾名思义是 Java 提供的双向链表,所以它不需要像上面两种那样调整容量,它也不是线程安全的

牛客版本:ArrayList 和 LinkedList 对比解题思路:数据结构、访问效率

  1. ArrayList的实现是基于数组,LinkedList的实现是基于双向链表
    2. 对于随机访问 ArrayList 要优于 LinkedList,ArrayList 可以根据下标以 O(1) 时间复杂度对元素进行随机访问,而LinkedList的每一个元素都依靠地址指针和它后一个元素连接在一起,查找某个元素的时间复杂度是O(N)。
    3. 对于插入和删除操作,LinkedList要优于ArrayList,因为当元素被添加到LinkedList 任意位置的时候,不需要像ArrayList那样重新计算大小或者是更新索引。
    4. LinkedList 比 ArrayList更占内存,因为 LinkedList 的节点除了存储数据,还存储了两个引用,一个指向前一个元素,一个指向后一个元素。

Collection.Set 之 HashSet

Set⾯试没啥好问的,拿HashSet来凑个数。

HashSet 底层就是基于 HashMap 实现的
( HashSet 的源码⾮常⾮常少,因为除了 clone() 、 writeObject() 、 readObject() 是 HashSet⾃⼰不得不实现之外,其他⽅法都是直接调⽤ HashMap 中的⽅法。
HashSet的add⽅法,直接调⽤HashMap的put⽅法,将添加的元素作为key,new⼀个Object作为value,直接调⽤HashMap的put⽅法,它会根据返回值是否为空来判断是否插⼊元素成功。

迭代器,比较器

并发容器

ConcurrentHashMap

image.png

image.png

数组初始值16
hash值选这个散列法很好,但是最终选择了 2^n.(方便迁移数据啥的)
image.png

不要只用后面的位数作为取模的值,高位考虑一下,如hashmap的二次哈希
image.png
扰动函数也不能从根本上解决hash冲突的问题.
长度必须是 2^n 才能用这种取模方式
image.png

image.png
初始化为什么是16?不是4,不是8?就是个数学的概率结果.

concurrenthashmap和hashmap数组长度不能通过构造器搞,初次Put的时候才会搞。
一开始,tab=0,所以会进入初始化这步
image.png
sizeCtl常问.(黑马那有写)

image.png

—————————
1.8 中,get方法没有锁.

put中,false默认代表可覆盖.
concurrenthashmap不允许有null,hashmap可以有
循环中tab代表哈希表,如果=null或者长度为0,进行初始化,底层使用的是cas操作.
第二个if:不空,看tab中是否有头结点,然后???
第三个if:帮忙扩容,如果发现在扩容(MOVED),不会阻塞,会帮别人扩容
第四个if:坑定没扩容,还发生了hash冲突,此时才有必要加锁!!之前都没有加锁,而且不锁整个tab,只给链表头结点加锁!里面分为链表的逻辑和红黑树的逻辑,链表的逻辑分为更新和添加,红黑树也差不多。bincount代表链表长度。释放锁了以后还对链表优化,看看树化还是咋样.

addcount方法中有扩容的逻辑

inittable方法,懒惰初始化,需要保证线程安全,只能有一个线程把它创建,没有创建时,进入while循环中,然后通过CAS创建哈希表,创建时把sizeCtl设置成-1,其他线程过来发现这个是sizectl,就yiedl把线程让出来,其他线程一直在等第一次获得锁的线程创建哈希表。sc代表初始容量,默认是16
最后把正数sc赋值给sizeCtl,然后就可以退出去了。
其他线程在初始化过程中供忙等待,没有阻塞住

addCount方法,在put方法中最后一步,记录哈希表中数据量,可以判断是否扩容啥的.
用到了累加单元的思想,适用于并发(不懂)(longadder一个思想)
baseCount的累加就是cas的.
cas失败就死循环,fullAddConunt这个就是死循环
累加完了检查边表长度,check,大于1时就扩容,进行进一步的判断.
主要就是两点:哈希表计数,以及超过阈值后的扩容

size计算流程,put方法中用addCount计算.
remove方法中也改变合格方法.

image.png
两个方法,size 和 sunCount
计数是有一定误差的,因为是多线程

transfer方法,扩容流程.
————————————
JDK 1.7 :segment——分段,继承于 reentrantlock
image.png
1.8 把锁加到列表头,1.7 把锁加在segment.

缺点:segment大小固定,并发度上去以后…
segment不是懒惰初始化的,一上来提前搞好,内存占用大.(数组0号位是这样的,其他都是懒惰初始化)

segment是数组,一开始下标为0的元素….(什么hashentry,真正的哈希表,没听懂)

(1)定位segment,研究两个重要属性

image.png
segment默认16,占4位,

就是在求 key 在 segment中的下标,就这么个对应关系

(2)put
计算key的hash
移位 掩码 运算后得segment下标
看看segment初始化没,没初始化先初始化
调用segment的put,segment类继承了reentrantlock
然后加锁,然后继续处理


解题思路
得分点 数组+链表+红黑树、锁的粒度
标准回答
在JDK8中,ConcurrentHashMap的底层数据结构与HashMap一样,也是采用“数组+链表+红黑树”的形式。同时,它又采用锁定头节点的方式降低了锁粒度,以较低的性能代价实现了线程安全。
底层数据结构的逻辑可以参考HashMap的实现,下面我重点介绍它的线程安全的实现机制。
1. 初始化数组或头节点时,ConcurrentHashMap并没有加锁,而是CAS的方式进行原子替换(原子操作,基于Unsafe类的原子操作API)。
2. 插入数据时会进行加锁处理,但锁定的不是整个数组,而是槽中的头节点。所以,ConcurrentHashMap中锁的粒度是槽,而不是整个数组,并发的性能很好。
3. 扩容时会进行加锁处理,锁定的仍然是头节点。并且,支持多个线程同时对数组扩容,提高并发能力。每个线程需先以CAS操作抢任务,争抢一段连续槽位的数据转移权。抢到任务后,该线程会锁定槽内的头节点,然后将链表或树中的数据迁移到新的数组里。
4. 查找数据时并不会加锁,所以性能很好。另外,在扩容的过程中,依然可以支持查找操作。如果某个槽还未进行迁移,则直接可以从旧数组里找到数据。如果某个槽已经迁移完毕,但是整个扩容还没结束,则扩容线程会创建一个转发节点存入旧数组,届时查找线程根据转发节点的提示,从新数组中找到目标数据。

加分回答
ConcurrentHashMap实现线程安全的难点在于多线程并发扩容,即当一个线程在插入数据时,若发现数组正在扩容,那么它就会立即参与扩容操作,完成扩容后再插入数据到新数组。在扩容的时候,多个线程共同分担数据迁移任务,每个线程负责的迁移数量是 (数组长度 >>> 3) / CPU核心数。 也就是说,为线程分配的迁移任务,是充分考虑了硬件的处理能力的。多个线程依据硬件的处理能力,平均分摊一部分槽的迁移工作。另外,如果计算出来的迁移数量小于16,则强制将其改为16,这是考虑到目前服务器领域主流的CPU运行速度,每次处理的任务过少,对于CPU的算力也是一种浪费。

CopyOnWrite

ConcurrentLinkedQueue

常用类

String、StringBuffer、Stringbuilder有什么区别

解题思路:字符串是否可变,StringBuffer、StringBuilder线程安全问题

String:不可变

Java中提供了String,StringBuffer两个类来封装字符串,并且提供了一系列方法来操作字符串对象。 String 是一个不可变类,也就是说,一个 String 对象创建之后,直到这个对象销毁为止,对象中的字符序列都不能被改变。任何对 String 的修改都会引发新的 String 对象的⽣ 成

StringBuffer:可变,线程安全

StringBuffer 对象则代表一个字符序列可变的字符串,当一个StringBuffer对象被创建之后,我们可以通过 StringBuffer 提供的append()、insert()、reverse()、setCharAt()、setLength()、等方法来改变这个字符串对象的字符序列。当通过 StringBuffer 得到期待中字符序列的字符串时,就可以通过 toString() 方法将其转换为String对象。使⽤ synchronized 来保证线程安全。

StringBuilder:可变,线程不安全,性能更高

StringBuilder 类是JDK1.5中新增的类,他也代表了字符串对象。和 StringBuffer 类相比,它们有共同的父类 AbstractStringBuilder二者无论是构造器还是方法都基本相同,不同的一点是,StringBuilder没有考虑线程安全问题,也正因如此,StringBuilder 比 StringBuffer 性能略高
因此,如果是在单线程下操作大量数据,应优先使用StringBuilder类;如果是在多线程下操作大量数据,应优先使用StringBuilder类。

请你说说String类,以及new和

解题思路
得分点 String常用方法简单介绍,String能否被继承,创建字符串的两种方式 标准回答 String类是Java最常用的API,它包含了大量处理字符串的方法,比较常用的有: - char charAt(int index):返回指定索引处的字符; - String substring(int beginIndex, int endIndex):从此字符串中截取出一部分子字符串; - String[] split(String regex):以指定的规则将此字符串分割成数组; - String trim():删除字符串前导和后置的空格; - int indexOf(String str):返回子串在此字符串首次出现的索引; - int lastIndexOf(String str):返回子串在此字符串最后出现的索引; - boolean startsWith(String prefix):判断此字符串是否以指定的前缀开头; - boolean endsWith(String suffix):判断此字符串是否以指定的后缀结尾; - String toUpperCase():将此字符串中所有的字符大写; - String toLowerCase():将此字符串中所有的字符小写; - String replaceFirst(String regex, String replacement):用指定字符串替换第一个匹配的子串; - String replaceAll(String regex, String replacement):用指定字符串替换所有的匹配的子串。 String类是由final修饰的,所以他不能被继承。 创建字符串有两种方式,一种是使用字符串直接量,另一种是使用new关键字,当使用字符串直接量的方式来创建字符串时,JVM会使用常量池来管理这个字符串,当使用new关键字来创建字符串时,JVM会先使用常量池来管理字符串直接量,再调用String类的构造器来创建一个新的String对象,新创建的String对象会被保存在堆内存中。对比来说,采用new的方式会多创建出一个对象来,占用了更多的内存 ,所以建议采用直接量的方式来创建字符串。

请你说说hashCode()和equals()的区别,为什么重写equals()就要重写hashcod()

解题思路
得分点 hashCode()用途,equals()用途,hashCode()、equals()约定 标准回答 hashCode()方法的主要用途是获取哈希码,equals()主要用来比较两个对象是否相等。二者之间有两个约定,如果两个对象相等,它们必须有相同的哈希码;但如果两个对象的哈希码相同,他们却不一定相等。也就是说,equals()比较两个对象相等时hashCode()一定相等,hashCode()相等的两个对象equqls()不一定相等。 加分回答 Object类提供的equals()方法默认是用==来进行比较的,也就是说只有两个对象是同一个对象时,才能返回相等的结果。而实际的业务中,我们通常的需求是,若两个不同的对象它们的内容是相同的,就认为它们相等。鉴于这种情况,Object类中equals()方法的默认实现是没有实用价值的,所以通常都要重写。 由于hashCode()与equals()具有联动关系,所以equals()方法重写时,通常也要将hashCode()进行重写,使得这两个方法始终满足相关的约定。

异常

请你说说Java的异常处理机制

解题思路
得分点 异常处理、抛出异常、异常跟踪栈 标准回答 异常处理机制可以让程序具有极好的容错性和健壮性,当程序运行出现了意料之外的状况时,系统会生成一个Exception对象来通知程序,从而实现“业务功能实现部分代码”与“错误处理部分代码”分离,使程序获得更好的可读性。 Java的异常机制可以分成异常处理、抛出异常和异常跟踪栈问题三个部分。 处理异常的语句由try、catch、finally三部分组成。try块用于包裹业务代码,catch块用于捕获并处理某个类型的异常,finally块则用于回收资源。如果业务代码发生异常,系统就会创建一个异常对象,并将这个异常对象提交给JVM,然后由JVM寻找可以处理这个异常的catch块,并将异常对象交给这个catch块处理。如果JVM没有找到可以处理异常的catch代码块,那么运行环境会终止,Java程序也会退出。若业务代码打开了某项资源,则可以在finally块中关闭这项资源,因为无论是否发生异常,finally块一定会执行(一般情况下)。 当程序出现错误时,系统会自动抛出异常。除此以外,Java也允许程序主动抛出异常。当业务代码中,判断某项错误的条件成立时,可以使用throw关键字向外抛出异常。在这种情况下,如果当前方法不知道该如何处理这个异常,可以在方法签名上通过throws关键字声明抛出异常,则该异常将交给JVM处理。 程序运行时,经常会发生一系列方法调用,从而形成方法调用栈。异常机制会导致异常在这些方法之间传播,而异常传播的顺序与方法的调用相反。异常从发生异常的方法向外传播,首先传给该方法的调用者,再传给上层调用者,以此类推。最终会传到mn方法,若依然没有得到处理,则JVM会终止程序,并打印异常跟踪栈的信息。 加分回答 throw、throws区别 throws: - 只能在方法签名中使用 - 可以声明抛出多个异常,多个一场之间用逗号隔开 - 表示当前方法不知道如何处理这个异常,这个异常由该方法的调用者处理(如果mn方法也不知该怎么处理异常,这个异常就会交给JVM处理,JVM处理异常的方式是,打印异常跟踪栈信息并终止程序运行,这也就是为什么程序遇到异常会自动结束的的原因) - throws表示出现异常的一种可能性,并不一定会发生这些异常 throw: - 表示方法内抛出某种异常对象,throw语句可以单独使用。 - throw语句抛出的是一个异常实例,不是一个异常类,而且每次只能抛出一个异常实例 - 执行throw一定抛出了某种异常 关于finally的问题 当Java程序执行try块、catch块时遇到了return或throw语句,这两个语句都会导致该方法立即结束,但是系统执行这两个语句并不会结束该方法,而是去寻找该异常处理流程中是否包含finally块,如果没有finally块,程序立即执行return或throw语句,方法终止;如果有finally块,系统立即开始执行finally块。只有当finally块执行完成后,系统才会再次跳回来执行try块、catch块里的return或throw语句;如果finally块里也使用了return或throw等语句,finally块会终止方法,系统将不会跳回去执行try块、catch块里的任何代码。这将会导致try块、catch块中的return、throw语句失效,所以,我们应该尽量避免在finally块中使用return或throw。 finally代码块不执行的几种情况: - 如果当一个线程在执行 try 语句块或者catch语句块时被打断interrupted或者被终止killed,与其相对应的 finally 语句块可能不会执行。 - 如果在try块或catch块中使用 System.exit(1); 来退出虚拟机,则finally块将失去执行的机会。

IO

泛型

请你说说泛型、泛型擦除

什么是泛型? (没泛型,运行时才报错;有泛型,编译时即可检测)

Java 泛型(generics)是 JDK 5 中引⼊的⼀个新特性, 泛型提供了编译时类型安全检测机制
该机制允许程序员在编译时检测到⾮法的类型。泛型的本质是参数化类型,也就是说所操作
的数据类型被指定为⼀个参数。

Java在 1.5 版本中引入了泛型,在没有泛型之前,每次从集合中读取对象都必须进行类型转换,而这么做带来的结果就是:如果有人不小心插入了类型错误的对象,那么在运行时转换处理阶段就会出错
在提出泛型之后,我们可以告诉编译器集合中接受哪些对象类型。编译器会自动的为你的插入进行转化,并在编译时告知是否插入了类型错误的对象。这使程序变得更加安全更加清楚

image.png

泛型使用方式

1:泛型类
2:泛型方法
(1)泛型方法:在方法中出现了泛型的结构,泛型参数与类的泛型参数没有任何关系。
换句话说,泛型方法所属的类是不是泛型类都没有关系
(2)泛型方法,可以声明为静态的。
原因:泛型参数是在调用方法时确定的,并非在实例化类时确定。
这里的E区别于所属类的泛型T;

3:泛型接口

泛型擦除(体现出泛型本质:编译期的检查机制,运行时“无泛型”)

Java泛型是伪泛型,因为Java代码在编译阶段,所有的泛型信息会被擦除,Java的泛型基本上都是在编辑器这个层次上实现的,在生成的字节码文件中是不包含泛型信息的,使用泛型的时候加上的类型,在编译阶段会被擦除掉,这个过程称为泛型擦除。
Java的泛型只存在于源码⾥,编译的时候给你静态地检查⼀下范型类型是否正确,⽽到了 运⾏时就不检查了。上⾯这段代码在JRE(Java运⾏环境)看来和下⾯这段没区别:

反射

请说说你对反射的了解

解题思路:反射概念,通过反射机制可以实现什么

“正射” & 反射

正射:我们通常都是利⽤ new ⽅式来创建对象实例,这可以说就是⼀种“正射”,这种⽅式在编译时候就确定了类型信息
反射:⽽如果,我们想在时候动态地(运行时)获取类信息、创建类实例、调⽤类⽅法这时候就要⽤到反射。 通过反射你可以获取任意⼀个类的所有属性和⽅法,你还可以调⽤这些⽅法和属性。

详细描述:
Java程序中,许多对象在运行时都会有 编译时异常 运行时异常 两种,例如多态情况下 Car c = new Audi();这行代码运行时会生成一个c变量,在编译时该变量的类型是Car,运行时该变量类型为Audi;另外还有更极端的情况,例如程序在运行时接收到了外部传入的一个对象,这个对象的编译时类型是Object,但程序又需要调用这个对象运行时类型的方法,这种情况下,有两种解决方法:
第一种做法是假设在 编译时 和 运行时 都完全知道类型的具体信息,在这种情况下,可以先使用instanceof 运算符进行判断,再利用强制类型转换将其转换成其运行时类型的变量。
第二种做法是 编译时 根本无法预知该对象和类可能属于哪些类,程序只依靠 运行时信息 来发现该对象和类的真实信息,这就必须使用反射。 (编译时不解析类的继承,运行时才判断)

反射的应用场景

使用JDBC时,如果要创建数据库的连接,则需要先通过反射机制加载数据库的驱动程序;
多数框架都支持注解/XML配置,从配置中解析出来的类是字符串,需要利用反射机制实例化
面向切面编程(AOP)的实现方案,是在程序运行时创建目标对象的代理类,这必须由反射机制来实现

反射的核心类

image.png

反射的原理?

我们都知道Java程序的执⾏分为编译运⾏两步,编译之后会⽣成字节码(.class)⽂件,JVM进
⾏类加载的时候,会加载字节码⽂件,将类型相关的所有信息加载进⽅法区,反射就是去获
取这些信息,然后进⾏各种操作。

新特性

请你讲一下Java 8的新特性

解题思路
得分点 Lambda表达式、Java8对接口的改进 标准回答 Java8是一个拥有丰富特性的版本,新增了很多特性,这里着重介绍几点: - Lambda表达式:该特性可以将功能视为方法参数,或者将代码视为数据。使用 Lambda 表达式,可以更简洁地表示单方法接口(称为功能接口)的实例。 - 方法引用:方法引用提供了非常有用的语法,可以直接引用已有Java类或对象(实例)的方法或构造器。与Lambda联合使用,方法引用可以使语言的构造更紧凑简洁,减少冗余代码。 - Java8对接口进行了改进:允许在接口中定义默认方法,默认方法必须使用default修饰。 - Stream API:新添加的Stream API(java.util.stream)支持对元素流进行函数式操作。Stream API 集成在 Collections API 中,可以对集合进行批量操作,例如顺序或并行的 map-reduce 转换。 - Date Time API:加强对日期与时间的处理。