Q&A-02 Java容器

HashMap

简单说下HashMap的实现原理:
首先有一个每个元素都是链表(可能表述不准确)的数组,当添加一个元素(key-value)时,就首先计算元素key的hash值,以此确定插入数组中的位置,但是可能存在同一hash值的元素已经被放在数组同一位置了,这时就添加到同一hash值的元素的后面,他们在数组的同一位置,但是形成了链表,同一各链表上的Hash值是相同的,所以说数组存放的是链表。而当链表长度太长时,链表就转换为红黑树,这样大大提高了查找的效率。
当链表数组的容量超过初始容量的0.75时,再散列将链表数组扩大2倍,把原链表数组的搬移到新的数组中。

//初始table长度16
static final int DEFAULT_INITIAL_CAPACITY = 16;
static final float DEFAULT_LOAD_FACTOR = 0.75F;
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
transient HashMap.Node[] table;
transient Set> entrySet;
transient int size;
transient int modCount;
int threshold;
final float loadFactor;

JDK1.8 的优化

  1. 红黑树

JDK1.8之前,HashMap采用位桶+链表实现,即使用链表处理冲突,同一hash值的链表都存储在一个链表里。但是当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低。

JDK1.8中,HashMap采用位桶+链表+红黑树实现,超过阈值时,将链表转换为红黑树,这样大大减少了查找时间。
单向链表->红黑树:table长度>=64 且 bucket里链表长度>=8
红黑树->单向链表:bucket里链表长度<=6

static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
  1. 尾插法

根据对冲突的处理方式不同,哈希表有两种实现方式,一种开放地址方式(Open addressing),另一种是冲突链表方式(Separate chaining with linked lists)。
Java HashMap采用的是冲突链表方式/拉链法。

  • 1.8 之前用头插法,在冲突链表头部插入新的entry。
  • 1.8 开始用尾插法

hash值

  1. hash值的计算

Object:本地方法,返回引用地址对应的编码

byte(8位)
int(16位) 值
short(32位) 值
long(64位) value^(value>>32) 高32位与低32位异或
float(32位) int floatToIntBits(value)
double(64位) value^(value>>32) 高32位与低32位异或
char,String 如jack:jn^3 + an^2 + cn^1 + kn^0, n = 31 ( 31*i+i = (i << 5) - i)

String 类型是如何重写 hashCode 方法的呢?

我们看看代码:

public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;
        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

代码非常简单,就是使用 String 的 char 数组的数字每次乘以 31 再叠加最后返回,因此,每个不同的字符串,返回的 hashCode 肯定不一样。上面提到Arrays.hashCode方法也是乘以 31 再叠加,那么为什么使用 31 呢?

在名著 《Effective Java》第 42 页就有对 hashCode 为什么采用 31 做了说明:

之所以使用 31, 是因为他是一个奇素数。

  • 奇数:如果乘数是偶数,并且乘法溢出的话,信息就会丢失,因为与2相乘等价于移位运算(低位补0)。
  • 素数:使用素数的好处并不很明显,但是习惯上使用素数来计算散列结果。
  • 位运算:31 有个很好的性能,即用移位和减法来代替乘法,可以得到更好的性能: 31 * i == (i << 5)- i, 现代的 VM 可以自动完成这种优化。

参考链接:hashCode 为什么乘以 31?深入理解 hashCode 和 hash 算法

  1. hash值的进一步处理:扰动计算

高16位与低16位的异或运算:
hash = hash ^ (hash>>> 16)

因为hashcode的计算方法导致哈希值的差异主要在高位,而 (n - 1) & hash是忽略了容量以上的高位的,使用扰动函数就是为了增加随机性,让数据元素更加均衡的散列,减少碰撞。

计算index(在数组哪个位置)

index = hash & (n - 1)
前提是table长度n是2的幂,因为当 n 为 2次幂时,hash & (n - 1) == hash % n ,%是取余即取模运算。

扩容

负载因子(loadFactor) = key-value键值对entry的总数量(不是不同的hash值)/table.length
loadFactor > 0.75 : table扩容为原来2倍

我们自定义 HashMap 容量最好是多少?
那我们如何自定义呢?自从有了阿里的规约插件,每次楼主都要初始化容量,如果我们预计我们的散列表中有2个数据,那么我就初始化容量为2吗?
绝对不行,如果大家看过源码就会发现,如果Map中已有数据的容量达到了初始容量的 75%,那么散列表就会扩容,而扩容将会重新将所有的数据重新散列,性能损失严重,所以,我们可以必须要大于我们预计数据量的 1.34 倍,如果是2个数据的话,就需要初始化 2.68 个容量。当然这是开玩笑的,2.68 不可以,3 可不可以呢?肯定也是不可以的,我前面说了,如果不是2的幂次方,散列结果将会大大下降。导致出现大量链表。那么我可以将初始化容量设置为4。 当然了,如果你预计大概会插入 12 条数据的话,那么初始容量为16简直是完美,一点不浪费,而且也不会扩容。

迁移

if ((e.hash & oldCap) == 0) {
    if (loTail == null)
        loHead = e;
    else
        loTail.next = e;
    loTail = e;
}
else {
    if (hiTail == null)
        hiHead = e;
    else
        hiTail.next = e;
    hiTail = e;
}

HashMap的数组长度一定保持2的次幂,比如16的二进制表示为 10000,那么length-1就是15,二进制为01111,同理扩容后的数组长度为32,二进制表示为100000,length-1为31,二进制表示为011111。从下图可以我们也能看到这样会保证低位全为1,而扩容后只有一位差异,也就是多出了最左位的1,这样在通过 h&(length-1)的时候,只要h对应的最左边的那一个差异位为0,就能保证得到的新的数组索引和老数组索引一致(大大减少了之前已经散列良好的老数组的数据位置重新调换),这也是为啥数组长度必须是2的幂次原因之一。

image.png

参考链接:HashMap扩容时数据迁移实现的亮点(ConcurrentHashMap类似)

参考链接:

  • https://blog.csdn.net/weixin_37356262/article/details/80543218
  • https://www.jianshu.com/p/5ddf1b664641

LinkedHashMap

LinkedHashMap是HashMap的直接子类。
区别:
LinkedHashMap在HashMap的基础上,采用双向链表(doubly-linked list)的形式将所有entry连接起来,顺序为插入顺序或者最近最少使用(LRU)顺序。

注意,每个hash对应的bucket里是单向链表。

除了可以保迭代历顺序,这种结构还有一个好处 : 迭代LinkedHashMap时不需要像HashMap那样遍历整个table,而只需要直接遍历header指向的双向链表即可,也就是说LinkedHashMap的迭代时间就只跟entry的个数相关,而跟table的大小无关。

HashMap,HashTable,ConcurrentHashMap的区别。

HashTable与HashMap、ConcurrentHashMap主要的区别在于HashMap不是同步的、线程不安全的和不适合应用于多线程并发环境下,而ConcurrentHashMap是线程安全的集合容器,特别是在多线程和并发环境中,通常作为Map的主要实现。
HashMap 的迭代器是 fail-fast 迭代器。

底层数据结构 实现线程安全的方式 是否可以存储null 初始容量 扩容
HashTable 数组+链表 全表锁,synchronized,同一个 HashTable 对象的同一个或不同方法,同一时间只能由一个线程访问 无论key还是value都不能为null 创建时如果不指定容量初始值, Hashtable 默认的初始⼤⼩为 11;创建时如果给定了容量初始值,那么Hashtable 会直接使⽤你给定的⼤⼩ 每次扩充,容量变为原来的 2n+1
HashMap 1.7 数组+链表;1.8 数组+链表、红黑树 线程不安全 可以存储null键和null值 创建时如果不指定容量初始值, HashMap 默认的初始⼤⼩为 16;创建时如果给定了容量初始值,HashMap 会将其扩充为 2 的幂次⽅⼤⼩ 每次扩充,容量变为原来的 2 倍
ConcurrentHashMap 1.7 分段数组+链表;1.8 数组+链表、红黑树 1.7 分段锁;1.8 抛弃分段,先用CAS,在 CAS 操作失败时使用内置锁 synchronized。synchronized只锁定当前链表或红⿊⼆叉树的⾸节点,这样只要hash不冲突,就不会产⽣并发。

连接:https://www.cnblogs.com/heyonggang/p/9112731.html

HashMap在高并发下如果没有处理线程安全会有怎样的安全隐患,具体表现是什么

  1. 并发下的Rehash 会造成元素之间会形成⼀个循环链表。不过,jdk 1.8 后解决了这个问题,但是还是不建议在多线程下使⽤ HashMap,因为多线程下使⽤ HashMap 还是会存在其他问题⽐如数据丢失。并发环境下推荐使⽤ ConcurrentHashMap 。

  2. 多线程put时可能导致元素丢失,原因:当多个线程同时执行addEntry(hash,key ,value,i)时,如果产生哈希碰撞,导致两个线程得到同样的bucketIndex去存储,就可能会发生元素覆盖丢失的情况。

链接:

  • https://juejin.cn/post/6844903606148136968
  • 疫苗:JAVA HASHMAP的死循环

HashSet

HashSet 是基于 HashMap 实现的。
set里的元素对应map的key,value是一个静态常量Object对象。

HashSet 是 Set 接⼝的主要实现类 , HashSet 的底层是 HashMap ,线程不安全的,可以存储 null 值;
LinkedHashSet 是 HashSet 的⼦类,能够按照添加的顺序遍历;
TreeSet 底层使⽤红⿊树,能够按照添加元素的顺序进⾏遍历,排序的⽅式有⾃然排序和定制排序。

java.util.Arrays.asList()

Arrays.asList() 方法返回的 ArrayList 不是 java.util.ArrayList ,而是 java.util.Arrays 的一个静态内部类ArrayList。

在这个内部类中有一个被声明为 final 的数组 a ,所有传入的元素都会被保存在这个数组 a 中,因此用 Arrays.asList() 方法产生的 ArrayList 是不可修改大小的,如果在该方法的返回对象上进行remove()或add(),会抛出了一个java.lang.UnsupportedOperationException 异常。

底层还是数组,只是对一些常见方法做了适配。

private static class ArrayList extends AbstractList implements RandomAccess, Serializable{
   ....
   private final E[] a;
   ...
}

创建一个真正的 ArrayList 的方法:
List myList = new ArrayList(Arrays.asList(myArray));

参考链接:https://www.jianshu.com/p/2b113f487e5e

ArrayList

ArrayList的特点

  1. ArrayList的底层数据结构是数组,所以查找遍历快,增删慢。
  2. ArrayList可随着元素的增长而自动扩容,正常扩容的话,每次扩容到原来的1.5倍。
  3. ArrayList的线程是不安全的。

ArrayList的扩容

ArrayList的构造函数总共有三个:
(1)ArrayList()
(2)ArrayList(Collection c)
(3)ArrayList(int initialCapacity)

扩容可分为两种情况:

第一种情况,当ArrayList的容量为0时,此时添加元素的话,需要扩容,三种构造方法创建的ArrayList在扩容时略有不同:

  1. 无参构造ArrayList(),创建容量为0的DEFAULTCAPACITY_EMPTY_ELEMENTDATA,添加第一个元素后,容量变为10,此后若需要扩容,则正常扩容。

  2. 传容量构造ArrayList(int initialCapacity),当参数为0时,创建容量为0的EMPTY_ELEMENTDATA,添加第一个元素后,容量为1,此时ArrayList是满的,下次添加元素时需正常扩容。

  3. 传列表构造ArrayList(Collection c),当列表为空时,创建ArrayList后容量为0,添加第一个元素后,容量为1,此时ArrayList是满的,下次添加元素时需正常扩容。

第二种情况,当ArrayList的容量大于0,并且ArrayList是满的时,此时添加元素的话,进行正常扩容,每次扩容到原来的1.5倍。

链接:浅谈 ArrayList 及其扩容机制
透过源码角度一步一步带你分析 ArrayList 扩容机制

一个ArrayList在循环过程中删除,会不会出问题,为什么

List 相邻的两个一样的字符串:
普通 for 循环-使用List 的 remove( Object) 正序删除:会漏掉后面那个字符串;
普通 for 循环-使用List 的 remove( Object) 倒序删除:可以正常删除,且线程安全;
增强 forEach 循环:使用List 的 remove( Object) 删除,forEach 循环写法是对实际的Iterator、hasNext、next 方法的简写,checkForComodification 方法会抛出并发修改异常 ConcurrentModificationException;
迭代器删除 iterator.remove():多线程环境下无法使用;

快速失败(fail—fast)

在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增加、删除、修改),则会抛出Concurrent Modification Exception。
原理:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。
注意:这里异常的抛出条件是检测到 modCount!=expectedmodCount 这个条件。如果集合发生变化时修改modCount值刚好又设置为了expectedmodCount值,则异常不会抛出。因此,不能依赖于这个异常是否抛出而进行并发操作的编程,这个异常只建议用于检测并发修改的bug。
场景:java.util包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改)。

foreach 循环里为什么不能进行元素的 remove/add 操作?

image

原因在于,foreach循环(也叫增强for循环,即for(Integer x : list)这样的写法,其实是Java本身给我们的一个语法糖,当你对编译之后class文件进行反编译之后,你会发现,foreach循环其实是依赖了while循环和Iterator实现的。

Iterator iterator = list.iterator();
do
{
    if(!iterator.hasNext())
        break;
    Object obj = iterator.next();
    // 业务逻辑 瞎编的
    if(canExecute()) {
        list.remove(object)    
    }
} while(true);

foreach循环:集合遍历是通过iterator进行的,但是但是元素的add/remove却是直接使用的集合对象自己的方法,集合对象自己的add/remove方法不会去更新迭代器自身的expectedModCount值。
Iterator:Iterator的add/remove方法,除了调用对应集合的对应add/remove方法的同时,它还会去修改自身的expectedModCount值。

安全失败(fail—safe)

采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。
原理:由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发Concurrent Modification Exception。
缺点:基于拷贝内容的优点是避免了Concurrent Modification Exception,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。
场景:java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改。

copy-on-write 机制

Copy-on-write 是解决并发的的一种思路,也是指的是实行读写分离,如果执行的是写操作,则复制一个新集合,在新集合内添加或者删除元素。待一切修改完成之后,再将原集合的引用指向新的集合。

这样的好处就是,可以高并发地对COW进行读和遍历操作,而不需要加锁。因为当前集合不会添加任何元素。

前面我们有提到过线程安全的集合Vector,但是Vector的加锁粒度太大,性能差,所以在并发环境下,推荐JUC包下的的CopyOnWriteArrayList来代替。CopyOnWriteArrayList就是COW家族中的一员。

一般我们认为,CopyOnWriteArrayList 是 同步List 的替代品,CopyOnWriteArraySet 是同步Set 的替代品。

CopyOnWriteArrayList*

核心理念就是读写分离:

  • 写操作在一个复制的数组上进行;写操作需要加锁,防止并发写入时导致数据丢失;写操作结束之后需要把 原始数组 指向新的复制数组。
  • 读操作还是在原始操作上进行,读写分离,互不影响。
  • 遍历:COWIterator,COWIterator内部维护了一个循环开始时的数组快照。

链接:

  • Java 容器 - 集合世界的fail-fast机制 和 CopyOnWriteArrayList 源码详解
  • https://juejin.cn/post/6844903672481054733
  • https://blog.csdn.net/L_kanglin/article/details/70148043

Vector

Vector 的实现与 ArrayList 类似,但是使用了 synchronized 进行同步。
Vector 的构造函数可以传入 capacityIncrement 参数,它的作用是在扩容时使容量 capacity 增长 capacityIncrement。如果这个参数的值小于等于 0,扩容时每次都令 capacity 为原来的两倍。

替代方案

可以使用 Collections.synchronizedList() 得到一个线程安全的 ArrayList。

List list = new ArrayList<>();
List synList = Collections.synchronizedList(list);

也可以使用 concurrent 并发包下的 CopyOnWriteArrayList 类。
CopyOnWriteArrayList 的特点:
读写分离
写操作在一个复制的数组上进行,读操作还是在原始数组中进行,读写分离,互不影响。
写操作需要加锁,防止并发写入时导致写入数据丢失。
写操作结束之后需要把原始数组指向新的复制数组。

List list = new CopyOnWriteArrayList<>();

参考链接:https://github.com/CyC2018/CS-Notes/blob/master/notes/Java%20%E5%AE%B9%E5%99%A8.md#vector

LinkedList

双向链表;
每个链表存储了 first 和 last 两个指针。

ArrayDeque

ArrayDequeLinkedListDeque的两个通用实现,官方更推荐使用AarryDeque用作栈和队列。

从名字可以看出ArrayDeque底层通过数组实现,为了满足可以同时在数组两端插入或删除元素的需求,该数组还必须是循环的,即循环数组(circular array),也就是说数组的任何一点都可能被看作起点或者终点。ArrayDeque是非线程安全的(not thread-safe),当多个线程同时使用的时候,需要程序员手动同步;另外,该容器不允许放入null元素。

参考链接:
https://www.pdai.tech/md/java/collection/java-collection-Queue&Stack.html

你可能感兴趣的:(Q&A-02 Java容器)