注意,我们现在说的数组可不是ArrayList,而且new double[10]
的那个数组
数组的问题:
1)长度开始时必须指定,而且一旦指定,不能更改
2)保存的必须为同一类型的元素
3)使用数组进行增加或者删除元素的示意代码-比较麻烦
比如你一开始创建了一个大小为3的int数组new Int[3],当你需要扩容的时候你必须new Int[4]然后使用for循环把原先数组的数据拷贝过去。
所以数组的问题就是长度必须指定而且一旦指定就不能修改。而且保存的数据必须是同一类型的元素。
所以我们要使用集合,集合有哪些好处呢?
集合的好处
1)可以动态保存任意多个对象,使用比较方便
2)提供了一系列方便的操作对象的方法:add、remove、set,get等
3)使用集合添加,删除新元素的示意代码-简洁了
Java.util.Collections是一个工具类仅包含静态方法,它们操作或返回集合。它包含操作集合的多态算法,返回一个由指定集合支持的新集合和其它一些内容。这个类包含集合框架算法的方法,比如折半搜索、排序、混编和逆序等。
Collection接口指定一组对象,对象即为它的元素。如何维护这些元素由Collection的具体实现决定。例如,一些如List的Collection实现允许重复的元素,而其它的如Set就不允许。
当与集合的具体实现打交道的时候,克隆或序列化的语义和含义才发挥作用。所以,集合的具体实现应该决定如何对它进行克隆或序列化,或它是否可以被克隆或序列化。而不是让整个集合类都从Cloneable和Serializable接口继承。
在所有的实现中授权克隆和序列化,最终导致更少的灵活性和更多的限制。特定的实现应该决定它是否可以被克隆和序列化。
1.首先Map提供的是键值对映射(即Key和value的映射),而collection提供的是一组数据(并不是键值对映射)。如果map继承了collection接口,那么所有实现了map接口的类到底是用map的键值对映射数据还是用collection的一组数据呢。
2.Map和List、set不同,Map放的是键值对,list、set放的是一个个的对象。说到底是因为数据结构不同,数据结构不同,操作就不一样,所以接口是分开的。
Collection接口的子接口包括:Set接口和List接口
List接口的实现类主要有:ArrayList、LinkedList、Vector以及Stack等
Set接口的实现类主要有:HashSet、TreeSet、LinkedHashSet等
Map接口的实现类主要有:HashMap、TreeMap、LinkedHashMap、Hashtable、Properties等
List:
ArrayList底层是数组。
Vector底层是数组。
LinkedList底层是双向链表。
Set:
HashSet底层是HashMap。
TreeSet底层是红黑树。
LinkedHashSet底层是LinkedHashMap。
Map:
HashMap在jdk1.7是数组+链表,jdk1.8后是数组+链表+红黑树
LinkedHashMap底层修改自HashMap,底层虽然也是数组+链表+红黑树,但它包含一个维护插入顺序的双向链表。
HashTable底层是数组+单项链表组成的哈希表。
TreeMap底层是红黑树
主要根据集合的特点来选用,
比如我们需要根据键值获取到元素值时就选用 Map 接口下的集合:
需要排序时选择 TreeMap,不需要排序时就选择 HashMap,
需要保证线程安全就选用 ConcurrentHashMap。
当我们只需要存放元素值时,就选择实现Collection 接口的集合:
需要保证元素唯一时选择实现 Set 接口的集合比如 TreeSet 或 HashSet,
不需要就选择实现 List 接口的比如 ArrayList 或 LinkedList,然后再根据实现这些接口的集合的特点来选用。
ArrayList、HashMap、TreeMap和HashTable类提供对元素的随机访问。
集合框架是一个用来代表和操纵集合的统一架构。所有的集合框架都包含如下内容:
除了集合,该框架也定义了几个 Map 接口和类。Map 里存储的是键/值对。尽管 Map 不是集合,但是它们完全整合在集合中。
集合框架的部分优点如下:
(1) Java集合框架为程序员提供了预先包装的数据结构和算法来操纵他们。
(2)使用核心集合类降低开发成本,而非实现我们自己的集合类。
(3)随着使用经过严格测试的集合框架类,代码质量会得到提高。
(4)通过使用JDK附带的集合类,可以降低代码维护成本。
(5)复用性和可操作性。
你把方法写成泛型,这样就不用针对不同的数据类型(比如int,double,float)分别写方法,只要写一个方法就可以了,提高了代码的复用性,减少了工作量。
泛型允许我们为集合提供一个可以容纳的对象类型,如果你添加其它类型的任何元素,它会在编译时报错。这避免了在运行时出现ClassCastException
栈和队列两者都被用来预存储数据。java.util.Queue是一个接口,它的实现类在Java并发包中。
队列允许先进先出(FIFO)检索元素,但并非总是这样。Deque接口允许从两端检索元素。
栈与队列很相似,但它允许对元素进行后进先出(LIFO)进行检索。
Stack是一个扩展自Vector的类,而Queue是一个接口。
HashSet 的底层结构就是 HashMap
思考: 但是为什么我调用 HashSet.add() 的方法,只需要传递一个元素,而 HashMap 是需要传递 key-value 键值对 ?
首先我们查看 hashSet 的 add 方法
private static final Object PRESENT = new Object();
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
我们能发现但我们调用 add 的时候,存储一个值进入map中,只是作为key进行存储,而 value 存储的是一个Object 类型的常量,也就是说 HashSet 只关心key,而不关心 value 。
1)可以存放null值,但是只能有一个null(相同的元素不能存放多个)
2) HashSet不保证存放元素的顺序和取出顺序一致,但是无论取多少次每次取出的顺序是一样的
3)不能有重复元素/对象
4)在存储对象进HashSet时,对象重写equals方法一定要重写hashCode方法(老生常谈了,重写了equals后必须重写hashcode)
set = new HashSet();
set.add("Lucy");//添加成功
set.add("Lucy");//添加失败(重复的东西只能添加一次)
set.add(new Dog("tom"));//添加成功
set.add(new Dog("tom"));//添加成功(每次使用new都会new出一个新的对象,所以二者并没有冲突了)
set.add(new String("aaa"));//添加成功
set.add(new String("aaa"));//添加失败(重复的东西只能添加一次)
String它是重写了equals()方法的:它规定如果两个String名字相同那么它们就相等;
但是你new一个Dog对象时由于你并没有重写equals()方法,并没有规定两只狗怎么样才算相等,所以两个对象自然也就不一样;
但是如果你要重写Dog对象的equals方法一定要重写hashCode方法(老生常谈了,重写了equals后必须重写hashcode)
JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列。HashMap 通过 key 的 hashCode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就按照相应的方法插入。
JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
HashSet底层是HashMap,HashMap添加一个元素时,
第一步,先执行hash(Object key),把key转为对应的hashcode
第二步,执行putVal(hashcode, key, value)方法,具体如下:
先根据数组长度和hashcode执行一次路由运算 得到index,根据index找node数组中的元素,
情况①:node数组中该索引下没有元素,那太好了,我直接把k-v键值对扔进去
情况②:node数组中该索引下有元素,而且很巧的是当前桶位中的元素的key与你要插入的元素的key完全一致,那就执行替换操作;
情况③:node数组中该索引下有元素,而且当前桶位中的元素的key与你要插入的元素的key不一致,那就判断它下面是链表结构还是树结构,如果是链表,那就遍历链表,如果如果到末尾了还是没有找到这个key那就把这个k-v键值对插到最后一位。把元素添加到链表后判断如果当前链表的大小大于8个节点,就调用树化方法,但是在进行树化时里面还会判断总元素数量是否小于64,如果小于64的话就不会把他变为红黑树而是先对表进行扩容;
情况④:已经树化了,另说。
第三步,因为你插入了新元素所以在函数最后还判断了现在散列表所有元素个数是否大于扩容阈值,如果大于扩容阈值那就执行 resize(),进行扩容
1.第一次添加时,table数组扩容到16,临界值(threshold)是16 乘以 加载因子(loadFactor)0.75 = 12
2.如果table数组使用到了临界值12,就会扩容到16 x 2= 32,新的临界值就是32 x 0.75= 24,依次类推
3.在Java8中,如果一条链表的元素个数到达TREEIFY_THRESHOLD(默认是8),并且table的大小>=MIN_TREEIFY_ CAPACITY(默认64),就会进行树化(红黑树),否则仍然采用数组扩容机制。
4.如果单条链表节点超过了8但是总的节点数没达到64的话,就扩容table表(按2倍扩容)
案例:
重写HashSet的hashcode方法,让其返回一个固定在100,也就是说所有节点的hashcode都是100,那么你添加12个元素由于它们的hashcode相同所有都会被添加到同一条链上。
用Debug启动,一开始table表长度为16,
当你添加完第8个元素后,由于单条链表节点超过了8但是总的节点数没达到64,所以扩容table表为32,但是这8个元素由于hashcode一模一样导致即使扩容后它们还是在同一条链上;
当你添加完第9个元素后,由于单条链表节点超过了8但是总的节点数没达到64,所以扩容table表为64,但是这9个元素由于hashcode一模一样导致即使扩容后它们还是在同一条链上;
当你添加完第10个元素后,由于单条链表节点超过了8而且总的节点数达到64,所以该链表变为红黑树结构
具体实现:
1.回顾HashMap 的四种构造方法
新建一个HashMap 有四种构造方法
1.new HashMap(int initialCapacity, float loadFactor);
2.new HashMap(int initialCapacity);
3.new HashMap(Map);
4.new HashMap();
前三种扩容阈值和负载因子均不为空
,第四种负载因子loadFactor不空但扩容阈值threshold为空
2.扩容的具体过程:
第一步,先计算newCap与newThr
①oldCap > 0 说明扩容前的哈希表大于0,表示hashMap散列表已经初始化过,是正常扩容。那就继续判断:
如果扩容之前的table数组已经大小达到最大容量后,则不扩容,且设置扩容条件threshold为Integer.MAX_VALUE(一个无穷大的数);
如果扩容之前的table数组没有达到最大扩容,那就扩容翻倍,如何扩容翻倍呢?用位运算,让oldCap左移一位就实现了翻倍(newCap = oldCap << 1),同时newThr = oldThr << 1也就是说下次再次触发扩容的条件也翻倍了。
总结:oldCap > 0 ,要么不扩容,要扩容的话newCap = oldCap << 1而且newThr = oldThr << 1
补充:
上面分析了oldCap > 0 大于0的情况,
而如果oldCap = 0说明hashMap中的散列表是null,散列表是null我们还要判断扩容阈值是不是空的。
②oldCap = 0时,如果oldThr > 0,那么设置newCap设置为oldThr的大小,设置newThr的大小为(nweCap)乘以(负载因子loadFactor)
补充:什么时候就会出现oldThr > 0但是oldCap = 0的情况呢?
由于oldThr=threshold,oldThr > 0说明threshold > 0,在使用前三种构造HashMap的构造方法构造出的HashMap就是一个扩容阈值threshold > 0的HashMap,构造完HashMap还没有初始化所以就出现了oldCap = 0且oldThr > 0的情况了
③oldCap = 0时,如果oldThr = 0,那么newCap设置为默认的数组大小(16)
,newThr设置为“(16*0.75=12)”
补充:什么时候就会出现oldCap = 0而且oldThr = 0的情况呢?
在使用第四种构造HashMap的构造方法构造出的HashMap就是一个扩容阈值threshold为空的HashMap,构造完HashMap还没有初始化所以就出现了oldCap > = 0且oldThr = 0的情况了
第二步:进行扩容
先创建一个更大更长的哈希表,
如果扩容前的哈希表不为空,首先根据index找到对应的桶,
①桶里面如果有数据,那就判断桶的下一位是否为空,如果为空那说明桶里面就它一个元素,那就使用寻址算法找到该元素在新的哈希表的index,然后设置进去;然后把旧的哈希表里面的桶元素置空(让JVM去回收)。
②桶里面如果有数据,桶的下一位不为空而且桶里面的数据已经树化,那就按照树的方式去处理;
③桶里面如果有数据,桶的下一位不为空而且桶里面的数据还是链表,那就开始寻址了:
因为table是2倍扩容,所以只需要看当前元素的hash值与扩容前数组的长度做"与运算",结果为0,那么还是原来的index;否则index = index + oldCap;
(下面会解释这句话)
我们知道15号桶的hash值的后五位要么是1 1111,要么是0 1111
假如它的hash值的后五位是1 1111,扩容前table数组的长度是16(2进制为1 0000),二者做与运算结果要么是0要么是1,显然此时为1,那么它现在的索引就是15+16=31;
假如它的hash值的后五位是0 1111,扩容前table数组的长度是16(2进制为1 0000),二者做与运算结果要么是0要么是1,显然此时为0,那么它现在的索引还是15;
我们来看源码
++modCount;
if (++size > threshold)
resize();
加入一个元素的时候,不管是加载table表的某一个位置还是说table表的某一条链表上,都会执行size++
就是下面这段代码,先加的7个A在一条链表上面,然后加7个B到另一条链上,那么,我们在加到第5个B的时候,链表就会扩容了,这说明当总元素达到12个的时候就发生了扩容,所以你千万不要错误的以为是table表被占用了12个
for(int i = 1; i <= 7; i++) {//在table的某一条链表上添加了 7个A对象
hashSet.add(new A(i));//
}
for(int i = 1; i <= 7; i++) {//在table的另外一条链表上添加了 7个B对象
hashSet.add(new B(i));//
}
当你把对象加入HashSet时,HashSet 会先计算对象的hashcode值来判断对象加入的位置,同时也会与其他加入的对象的 hashcode 值作比较,如果没有相符的 hashcode,HashSet 会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用equals()方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让加入操作成功。
List(对付顺序的好帮手): 存储的元素是有序的、可重复的。
Set(注重独一无二的性质): 存储的元素是无序的、不可重复的。
Map(用 Key 来搜索的专家): 使用键值对(kye-value)存储;Key 是无序的、不可重复的;value 是无序的、可重复的,每个键最多映射到一个值。
HashSet 底层就是基于 HashMap 实现的。(HashSet 的源码非常非常少,因为除了 clone()、writeObject()、readObject()是 HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法。
HashSet 是 Set 接口的主要实现类 ,HashSet 的底层是 HashMap,线程不安全的,可以存储 null 值;
LinkedHashSet 是 HashSet 的子类,能够按照添加的顺序遍历;
TreeSet 底层使用红黑树,能够按照添加元素的顺序进行遍历,排序的方式有自然排序和定制排序。
ConcurrentHashMap
底层采用 分段的数组+链表
实现,JDK1.8 采用的数据结构跟HashMap1.8
的结构一样,数组+链表/红黑二叉树
。Hashtable
和 JDK1.8
之前的 HashMap 的底层数据结构类似都是采用 数组+链表
的形式。ConcurrentHashMap
(分段锁) 对整个桶数组进行了分割分段
(Segment),然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。 到了 JDK1.8 的时候已经摒弃了分割分段
的概念,而是直接用 Node 数组+链表+红黑树
的数据结构来实现,并发控制使用 synchronized 和 CAS
来操作。synchronized
只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,效率又提升 N 倍。①在 JDK1.7 的时候,ConcurrentHashMap
(分段锁) 对整个桶数组进行了分割分段
(Segment),然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。 到了 JDK1.8 的时候已经摒弃了分割分段
的概念,而是直接用 Node 数组+链表+红黑树
的数据结构来实现,并发控制使用 synchronized 和 CAS
来操作。synchronized
只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,效率又提升 N 倍。
在JDK中默认长度是16,并且默认长度和扩容后的长度都必须是 2 的幂。
初始容量代表了哈希表中桶的初始数量,即 Entry< K,V>[] table 数组的初始长度。
加载因子是哈希表在其容量自动增加之前可以达到多满的一种饱和度百分比,其衡量了一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。
JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列。HashMap 通过 key 的 hashCode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就按照相应的方法插入。
JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
HashMap添加一个元素时,
第一步,先执行hash(Object key),把key转为对应的hashcode
第二步,执行putVal(hashcode, key, value)方法,具体如下:
先根据数组长度和hashcode执行一次路由运算 得到index,根据index找node数组中的元素,
情况①:node数组中该索引下没有元素,那太好了,我直接把k-v键值对扔进去
情况②:node数组中该索引下有元素,而且很巧的是当前桶位中的元素的key与你要插入的元素的key完全一致,那就执行替换操作;
情况③:node数组中该索引下有元素,而且当前桶位中的元素的key与你要插入的元素的key不一致,那就判断它下面是链表结构还是树结构,如果是链表,那就遍历链表,如果如果到末尾了还是没有找到这个key那就把这个k-v键值对插到最后一位。把元素添加到链表后判断如果当前链表的大小大于8个节点,就调用树化方法,但是在进行树化时里面还会判断总元素数量是否小于64,如果小于64的话就不会把他变为红黑树而是先对表进行扩容;
情况④:已经树化了,另说。
第三步,因为你插入了新元素所以在函数最后还判断了现在散列表所有元素个数是否大于扩容阈值,如果大于扩容阈值那就执行 resize(),进行扩容
1.第一次添加时,table数组扩容到16,临界值(threshold)是16 乘以 加载因子(loadFactor)0.75 = 12
2.如果table数组使用到了临界值12,就会扩容到16 x 2= 32,新的临界值就是32 x 0.75= 24,依次类推
3.在Java8中,如果一条链表的元素个数到达TREEIFY_THRESHOLD(默认是8),并且table的大小>=MIN_TREEIFY_ CAPACITY(默认64),就会进行树化(红黑树),否则仍然采用数组扩容机制。
4.如果单条链表节点超过了8但是总的节点数没达到64的话,就扩容table表(按2倍扩容)
案例:
重写HashSet的hashcode方法,让其返回一个固定在100,也就是说所有节点的hashcode都是100,那么你添加12个元素由于它们的hashcode相同所有都会被添加到同一条链上。
用Debug启动,一开始table表长度为16,
当你添加完第8个元素后,由于单条链表节点超过了8但是总的节点数没达到64,所以扩容table表为32,但是这8个元素由于hashcode一模一样导致即使扩容后它们还是在同一条链上;
当你添加完第9个元素后,由于单条链表节点超过了8但是总的节点数没达到64,所以扩容table表为64,但是这9个元素由于hashcode一模一样导致即使扩容后它们还是在同一条链上;
当你添加完第10个元素后,由于单条链表节点超过了8而且总的节点数达到64,所以该链表变为红黑树结构
具体实现:
1.回顾HashMap 的四种构造方法
新建一个HashMap 有四种构造方法
1.new HashMap(int initialCapacity, float loadFactor);
2.new HashMap(int initialCapacity);
3.new HashMap(Map);
4.new HashMap();
前三种扩容阈值和负载因子均不为空
,第四种负载因子loadFactor不空但扩容阈值threshold为空
2.扩容的具体过程:
第一步,先计算newCap与newThr
①oldCap > 0 说明扩容前的哈希表大于0,表示hashMap散列表已经初始化过,是正常扩容。那就继续判断:
如果扩容之前的table数组已经大小达到最大容量后,则不扩容,且设置扩容条件threshold为Integer.MAX_VALUE(一个无穷大的数);
如果扩容之前的table数组没有达到最大扩容,那就扩容翻倍,如何扩容翻倍呢?用位运算,让oldCap左移一位就实现了翻倍(newCap = oldCap << 1),同时newThr = oldThr << 1也就是说下次再次触发扩容的条件也翻倍了。
总结:oldCap > 0 ,要么不扩容,要扩容的话newCap = oldCap << 1而且newThr = oldThr << 1
补充:
上面分析了oldCap > 0 大于0的情况,
而如果oldCap = 0说明hashMap中的散列表是null,散列表是null我们还要判断扩容阈值是不是空的。
②oldCap = 0时,如果oldThr > 0,那么设置newCap设置为oldThr的大小,设置newThr的大小为(nweCap)乘以(负载因子loadFactor)
补充:什么时候就会出现oldThr > 0但是oldCap = 0的情况呢?
由于oldThr=threshold,oldThr > 0说明threshold > 0,在使用前三种构造HashMap的构造方法构造出的HashMap就是一个扩容阈值threshold > 0的HashMap,构造完HashMap还没有初始化所以就出现了oldCap = 0且oldThr > 0的情况了
③oldCap = 0时,如果oldThr = 0,那么newCap设置为默认的数组大小(16)
,newThr设置为“(16*0.75=12)”
补充:什么时候就会出现oldCap = 0而且oldThr = 0的情况呢?
在使用第四种构造HashMap的构造方法构造出的HashMap就是一个扩容阈值threshold为空的HashMap,构造完HashMap还没有初始化所以就出现了oldCap > = 0且oldThr = 0的情况了
第二步:进行扩容
先创建一个更大更长的哈希表,
如果扩容前的哈希表不为空,首先根据index找到对应的桶,
①桶里面如果有数据,那就判断桶的下一位是否为空,如果为空那说明桶里面就它一个元素,那就使用寻址算法找到该元素在新的哈希表的index,然后设置进去;然后把旧的哈希表里面的桶元素置空(让JVM去回收)。
②桶里面如果有数据,桶的下一位不为空而且桶里面的数据已经树化,那就按照树的方式去处理;
③桶里面如果有数据,桶的下一位不为空而且桶里面的数据还是链表,那就开始寻址了:
因为table是2倍扩容,所以只需要看当前元素的hash值与扩容前数组的长度做"与运算",结果为0,那么还是原来的index;否则index = index + oldCap;
(下面会解释这句话)
我们知道15号桶的hash值的后五位要么是1 1111,要么是0 1111
假如它的hash值的后五位是1 1111,扩容前table数组的长度是16(2进制为1 0000),二者做与运算结果要么是0要么是1,显然此时为1,那么它现在的索引就是15+16=31;
假如它的hash值的后五位是0 1111,扩容前table数组的长度是16(2进制为1 0000),二者做与运算结果要么是0要么是1,显然此时为0,那么它现在的索引还是15;
因为hashMap 的数组长度都是2的n次幂 ,那么对于这个数再减去1,转换成二进制的话,就肯定是最高位为0,其他位全是1 的数。
当数组长度不为2的n次幂 的时候,hashCode 值与数组长度减一做与运算 的时候,会出现重复的数据,
因为不为2的n次幂 的话,对应的二进制数肯定有一位为0 , 这样不管你的hashCode 值对应的该位是0还是1 ,最终得到的该位上的数肯定是0,这带来的问题就是HashMap上的数组元素分布不均匀,而数组上的某些位置,永远也用不到。
这将带来的问题就是你的HashMap 数组的利用率太低,并且链表可能因为上边的(n - 1) & hash 运算结果碰撞率过高,导致链表太深。
主要原因在于 并发下的Rehash 会造成元素之间会形成一个循环链表。不过,jdk 1.8 后解决了这个问题,但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在其他问题比如数据丢失。并发环境下推荐使用 ConcurrentHashMap 。
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
未扩容前:
插入第4个节点时,发生rehash,假设现在有两个线程同时进行,线程1和线程2,两个线程都会新建新的数组
假设线程2 在执行到Entry < K,V > next = e.next;之后,cpu时间片用完了,在线程一中此时变量e指向节点a,变量next指向节点b(后面会用到)。
线程1继续执行,很不巧,a、b、c节点rehash之后又是在同一个位置7,开始移动节点
第一步,移动节点a
第二步,移动节点b
注意,这里的顺序是反过来的,继续移动节点c
这个时候 线程1 的时间片用完,内部的table还没有设置成新的newTable, 线程2 开始执行,这时内部的引用关系如下:
刚刚说过,在 线程2 中,变量e指向节点a,变量next指向节点b,开始执行循环体的剩余逻辑。
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
执行之后的引用关系如下图
执行后,变量e指向节点b,因为e不是null,则继续执行循环体,执行后的引用关系
由于经过线程一的操做b的下一位指向a,所以变量e又重新指回节点a,只能继续执行循环体,这里仔细分析下:
1、执行完Entry < K,V > next = e.next;,目前节点a没有next,所以变量next指向null;
2、e.next = newTable[i]; 其中 newTable[i] 指向节点b,那就是把a的next指向了节点b,这样a和b就相互引用了,形成了一个环;
3、newTable[i] = e 把节点a放到了数组i位置;
4、e = next; 把变量e赋值为null,因为第一步中变量next就是指向null;
所以最终的引用关系是这样的:
节点a和b互相引用,形成了一个环,当在数组该位置get寻找对应的key时,就发生了死循环。
另外,如果线程2把newTable设置成到内部的table,节点c的数据就丢了,看来还有数据遗失的问题。
对于在Map中插入、删除和定位元素这类操作,HashMap是最好的选择。然而,假如你需要对一个有序的key集合进行遍历,TreeMap是更好的选择。基于你的collection的大小,也许向HashMap中添加元素会更快,将map换为TreeMap进行有序key的遍历。
1) Map中的key 不允许重复,原因和HashSet一样, Map中的value可以重复。
2)如果添加相同的key,则会覆盖原来的key-val ,等同于修改.(key不会替换,val会替换)
3) Map 的key 可以为null, value 也可以为null,注意key为null,只能有一个value为null ,可以多个.
4) 与HashSet一样,不保证映射的顺序,因为底层是以hash表的方式来存储的.
LinkedHashSet在添加一个元素时,先求hash值,在求索引.,确定该元素在hashtable的位置,然后将添加的元素加入到双向链表(如果已经存在,不添加
LinkedHashSet用LinkedHashMap对象来存储元素,存放的结点类型是 Entry类型,Entry中增加了两个域,分别为before和after,分别用来存储前一个和后一个元素,这两个域使得LinkedHashMap成为一个双向链表.
//继承关系是在内部类完成.
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
LinkedHashSet的构造方法完全就是继承父类HashSet的东西
public class LinkedHashSet<E> extends HashSet<E> implements Set<E>, Cloneable, java.io.Serializable {
private static final long serialVersionUID = -2851667679971038690L;
/**
* 构造一个带有指定初始容量和加载因子的新空链接哈希set。
*
* 底层会调用父类的构造方法,构造一个有指定初始容量和加载因子的LinkedHashMap实例。
* @param initialCapacity 初始容量。
* @param loadFactor 加载因子。
*/
public LinkedHashSet(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor, true);
}
/**
* 构造一个带指定初始容量和默认加载因子0.75的新空链接哈希set。
*
* 底层会调用父类的构造方法,构造一个带指定初始容量和默认加载因子0.75的LinkedHashMap实例。
* @param initialCapacity 初始容量。
*/
public LinkedHashSet(int initialCapacity) {
super(initialCapacity, .75f, true);
}
/**
* 构造一个带默认初始容量16和加载因子0.75的新空链接哈希set。
*
* 底层会调用父类的构造方法,构造一个带默认初始容量16和加载因子0.75的LinkedHashMap实例。
*/
public LinkedHashSet() {
super(16, .75f, true);
}
/**
* 构造一个与指定collection中的元素相同的新链接哈希set。
*
* 底层会调用父类的构造方法,构造一个足以包含指定collection
* 中所有元素的初始容量和加载因子为0.75的LinkedHashMap实例。
* @param c 其中的元素将存放在此set中的collection。
*/
public LinkedHashSet(Collection<? extends E> c) {
super(Math.max(2 * c.size(), 11), .75f, true);
addAll(c);
}
}
读者可能就会怀疑了,不是说 LinkedHashSet 是基于 LinkedHashMap 实现的吗?那我为什么在源码中甚至都没有看到出现过 LinkedHashMap。不要着急,我们可以看到在 LinkedHashSet 的构造方法中,其调用了父类的构造方法。我们可以进去看一下:
/**
* 以指定的initialCapacity和loadFactor构造一个新的空链接哈希集合。
* 此构造函数为包访问权限,不对外公开,实际只是是对LinkedHashSet的支持。
*
* 实际底层会以指定的参数构造一个空LinkedHashMap实例来实现。
* @param initialCapacity 初始容量。
* @param loadFactor 加载因子。
* @param dummy 标记。
*/
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<E,Object>(initialCapacity, loadFactor);
}
LinkedHashSet 通过继承 HashSet,底层使用 LinkedHashMap,以很简单明了的方式来实现了其自身的所有功能。
我们以“HashSet 如何检查重复”为例子来说明为什么要有 hashCode:
当你把对象加入 HashSet 时,HashSet 会先计算对象的 hashcode 值来判断对象加入的位置,同时也会与其他已经加入的对象的 hashcode 值作比较,如果没有相符的hashcode,HashSet会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用equals()方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。这样我们就大大减少了 equals 的次数,相应就大大提高了执行速度。
问法一:hashCode(),equals()两种方法是什么关系?
问法二:两个对象的 hashCode() 相同,则 equals() 也一定为 true,对吗?
对于hashcode方法,会返回一个哈希值,哈希值对数组的长度取余后会确定一个存储的下标位置,如图中用数组括起来的第一列。
不同的哈希值取余之后的结果可能是相同的模的时候就用equals方法判断是否为相同的对象,不同则在链表中插入。
所以就有:
hashcode不相同,用equals()方法判断的返回的一定为false。
hashcode相同,equals()方法返回值不能确认,可能为true,可能为false。
如果两个对象相等,则hashcode一定也是相同的;
如果两个对象相等,对两个对象分别调用equals方法都返回true;
两个对象有相同的hashcode值,它们也不一定是相等的
上述了两种方法的关系之后,我们知道判断的时候先根据hashcode进行的判断,相同的情况下再根据equals()方法进行判断。如果只重写了equals方法,而不重写hashcode的方法,会造成hashcode的值不同,而equals()方法判断出来的结果为true。
在Java中的一些容器中,不允许有两个完全相同的对象,插入的时候,如果判断相同则会进行覆盖。只重写equals不重写hashCode,就会导致相同的对象可能会散列到不同的位置,那就不能进行覆盖。
对象的相等 比的是内存中存放的内容是否相等而 引用相等 比较的是他们指向的内存地址是否相等。
不对,如果两个对象x和y满足x.equals(y) == true,它们的哈希码(hash code)应当相同。
Java对于eqauls方法和hashCode方法是这样规定的:
(1)如果两个对象相同(equals方法返回true),那么它们的hashCode值一定要相同;
(2)如果两个对象的hashCode相同,它们并不一定相同。
==
和equals有哪些区别equals和==
最大的区别是一个是方法一个是运算符。
==
∶如果比较的对象是基本数据类型,则比较的是数值是否相等;如果比较的是引用数据类型,则比较的是对象的地址值是否相等。我们可以使用任何类作为Map的key,然而在使用它们之前,需要考虑以下几点:
(1)如果类重写了equals()方法,它也应该重写hashCode()方法。
(2)类的所有实例需要遵循与equals()和hashCode()相关的规则。
(3)如果一个类没有使用equals(),你不应该在hashCode()中使用它。
(4)用户自定义key类的最佳实践是使之为不可变的,这样,hashCode()值可以被缓存起来,拥有更好的性能。不可变的类也可以确保hashCode()和equals()在未来不会改变,这样就会解决与可变相关的问题了。
Student s2 = new Student();
s2.setName("二号");
s2.setSex("女");
map.put(s1, "111");
System.out.println(map.get(s1)); //111
map.put(new Student("二号","女"),"333");
System.out.println(map.get(new Student("二号","女"))); //null
//为什么第一个输出是111,但是第二个输出为null呢?
因为map存储获取都是根据hashcode值和equals方法有联系,前边将map的key拿到再放进去获取value,并没有发生对象的变化,hashcode值也不变,下边我又以新的对象去map中获取,此时的hashcode值已经变了,所以返回为null,因为那是我新new的对象所以,map已经不认识了。
怎么才能解决这个问题呢。如果要以可变对象作为key的话,需要重写hashcode和equal方法来达到这个目的。
①Vector 是线程安全的但效率不高,ArrayList 是线程不安全的但效率高。(在Vector中大多的方法使用了synchronized来进行修饰,以确保该方法是同步的。)
②扩容机制的不同:
③Vector的构造方法中引入了扩展因子,默认初始化为0。扩展因子的大小可以由用户自己自定义,从而来决定Vector到底是呈几倍来增长的。
1)ArrayList中维护了一个被transient修饰的Object类型的数组
(Object类型的数组说明什么类型都可以放)
transient Object[] elementData; //对象在序列化的时候,transient修饰的属性不会被序列化
2)当创建ArrayList对象时,如果使用的是无参构造器,则初始elementData容量为0,第1次添加,则扩容elementData为10,如需要再次扩容,则扩容elementData为1.5倍。
3)当创建ArrayList对象时,如果使用的是指定容量capacity的构造器,则初始elementData容量为capacity,如果需要扩容,则直接扩容elementData为1.5倍。
4)当添加元素时:先判断是否需要扩容,如果需要扩容,则调用grow方法,否则直接添加元素到合适位置
当添加元素时,先判断是否需要扩容,如果需要扩容,则调用grow方法
grow方法使用位运算,新数组大小=原先数组大小+原先数组大小的一半。向右移动一位相当于除以2.
不过要注意,如果第一次使用grow()方法时由于数组长度为0所以“新数组大小=原先数组大小+原先数组大小的一半=0
”,但是程序会判断如果计算出来的新数组大小比10还小那就新数组大小直接使用10
真正的扩容时调用了CopyOf函数,CopyOf函数会保留原先的数据,扩容空间的数据为null
记录当前集合被修改的次数是为了防止有多个线程同时修改集合,如果有多个线程同时修改集合就会抛出异常
视频链接:【韩顺平讲Java】Java集合专题
1)当创建Vector对象时,如果使用的是无参构造器,则初始elementData容量为10,如需要再次扩容,则扩容elementData为2倍。
2)当创建Vector对象时,如果使用的是指定容量capacity的构造器,则初始elementData容量为capacity,如果需要扩容,则直接扩容elementData为2倍。
3)当添加元素时:先判断是否需要扩容,如果需要扩容,则调用grow方法,否则直接添加元素到合适位置
当添加元素时,先判断是否需要扩容,如果需要扩容,则调用grow方法
这里的grow()方法,若不对capacityIncrement进行自定义,则默认扩容为原来的2倍大小,若定义了capacityIncrement的大小,则原来的大小 + 定义的大小。
真正的扩容时调用了CopyOf函数,CopyOf函数会保留原先的数据,扩容空间的数据为null
RandomAccess 接口不过是一个标识罢了。标识什么? 标识实现这个接口的类具有随机访问功能。
ArrayList 实现了 RandomAccess 接口, 而 LinkedList 没有实现。为什么呢?我觉得还是和底层数据结构有关!ArrayList 底层是数组,而 LinkedList 底层是链表。数组天然支持随机访问,时间复杂度为 O(1),所以称为快速随机访问。链表需要遍历到特定位置才能访问特定位置的元素,时间复杂度为 O(n),所以不支持快速随机访问。,ArrayList 实现了 RandomAccess 接口,就表明了他具有快速随机访问功能。 RandomAccess 接口只是标识,并不是说 ArrayList 实现 RandomAccess 接口才具有快速随机访问功能的!
LinkedList的结构
LinkedList的全面说明
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{
//元素个数
transient int size = 0;
/**
* 指向第一个节点
*
*/
transient Node<E> first;
/**
* 指向最后一个节点
*/
transient Node<E> last;
.
}
LinedList的字段比较少,多添加了两个引用first和last用来指向头尾节点,和一个size用来存储节点的个数,这样当计算元素个数的时候,只需要O(1)的时间复杂度。
构造函数
/**
* 空的构造函数
*/
public LinkedList() {
}
/**
* 包含一个集合的构造函数,链表中的顺序按照集合中的元素顺序进行插入
*/
public LinkedList(Collection<? extends E> c) {
this();
addAll(c);//这里调用了addAll(),就是插入所有元素
}
有两个构造函数,一个是初始化一个空的实例,另外一个是传入一个集合进行初初始化。在初始化的时候主要调用了addAll()方法,那么这个addAll()方法是怎么样添加元素的呢。
addAll(int index, Collection extends E> c)
/**
* 插入给定集合的元素,从指定的index开始插入
*/
public boolean addAll(int index, Collection<? extends E> c) {
checkPositionIndex(index);
Object[] a = c.toArray();//第一步,将集合转为数组?????
... //添加元素的代码
addAll()方法首先是将集合转为数组,那么为什么要这样子做呢,我的理解是转为toArray能保证在多线程的环境下不会被其他线程所修改,从而保证了数据的安全性。
然后就是插入方法,整体的插入过程可以用下图来解释
1、找到节点
找到下标为Index的节点标记为succ,找到它的前驱节点标记为pred,
2、插入新节点、移动指向
1.将元素转换为链表节点,2.增加该节点的前后引用(即pre和next分别指向哪一个节点),3.前后节点对该节点的引用(前节点的next指向该节点,后节点的pre指向该节点)。
3、循环执行步骤2
linkFirst(E e)方法
private void linkFirst(E e) {
final Node<E> f = first;//1、创建一个引用
final Node<E> newNode = new Node<>(null, e, f);//2、创建新节点,它的下一个节点为当前的头结点
first = newNode;//3、头引用指向新节点
if (f == null)//如果没有头结点,只有尾结点
last = newNode;//新节点为尾结点
else
f.prev = newNode;//4、否则之前的头结点的前引用指向新节点
size++;
modCount++;
}
具体步骤如下所示
linkLast(E e)
void linkLast(E e) {
final Node<E> l = last;//创建一个指向尾部的指针
final Node<E> newNode = new Node<>(l, e, null);//创建新节点,新节点的前一个节点为当前的尾结点
last = newNode;//last引用指向新节点
if (l == null)
first = newNode;
else
l.next = newNode;//当前尾结点的前引用指向新的尾结点
size++;
modCount++;
}
过程与在头部插入类似
linkBefore(E e, Node succ)
void linkBefore(E e, Node<E> succ) {
// assert succ != null;
final Node<E> pred = succ.prev;//找到该节点的前驱
final Node<E> newNode = new Node<>(pred, e, succ);
succ.prev = newNode;
if (pred == null)//前驱为空
first = newNode;//新节点为第一个节点
else
pred.next = newNode;//否则在前面插入
size++;
modCount++;
}
unlink(Node x)
E unlink(Node<E> x) {
// assert x != null;
//1、找到保存节点信息,找到前置和后继节点
final E element = x.item;//保存要删除节点的信息
final Node<E> next = x.next;//保存后继节点
final Node<E> prev = x.prev;//保存前置节点
//2、修改前置指针
if (prev == null) {//如果前置为空,
first = next;//则后置节点为第一个节点
} else {
prev.next = next;//否则将前置节点的下一个节点指向后继
x.prev = null;//将要删除节点的引用置空
}
//3、修改后继指针
if (next == null) {//后继为空,则前置节点为最后节点
last = prev;
} else {
next.prev = prev;//否则后继节点的引用指向前置
x.next = null;//将要删除节点的引用置空
}
//4、将节点置空,方便GC
x.item = null;//节点置空,GC
size--;
modCount++;
return element;
}
1、找到保存节点信息,找到前置和后继节点
2、修改前置指针
3、修改后继指针
4、将节点置空,方便GC
node(int index)
Node<E> node(int index) {
if (index < (size >> 1)) { //如果index在前半段
Node<E> x = first;//从头开始找
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {//在后半段
Node<E> x = last;
for (int i = size - 1; i > index; i--)//从后往前找
x = x.prev;
return x;
}
}
查找指定元素,这个方法用到一个小技巧,当检索的下标大于元素的一半,则从后往前找,小于一半则从前往后找
get(),set(),add(),remove()
/**
* 获取指定位置的元素
*/
public E get(int index) {
checkElementIndex(index);//检查下标
return node(index).item;//索引节点
}
/**
* 修改下标为index的节点
*/
public E set(int index, E element) {
checkElementIndex(index);
Node<E> x = node(index);
E oldVal = x.item;
x.item = element;
return oldVal;
}
/**
* 在指定位置插入元素
*/
public void add(int index, E element) {
checkPositionIndex(index);//检查下标
if (index == size)//如果index刚好为之后一个位置
linkLast(element);//插入最后面
else
linkBefore(element, node(index));//插在指定位置前面
}
/**
* 删除指定位置的元素
*/
public E remove(int index) {
checkElementIndex(index);//检查下标
return unlink(node(index));
}
增删改查都是基于前面的链表操作(link和unlink)来实现的
https://blog.csdn.net/tangyuan_sibal/article/details/89789385
Iterator接口提供遍历任何Collection的接口。我们可以从一个Collection中使用迭代器方法来获取迭代器实例。迭代器取代了Java集合框架中的Enumeration。迭代器允许调用者在迭代过程中移除元素。
Enumeration的速度是Iterator的两倍,也使用更少的内存。Enumeration是非常基础的,也满足了基础的需要。但是,与Enumeration相比,Iterator更加安全,因为当一个集合正在被遍历的时候,它会阻止其它线程去修改集合。
迭代器取代了Java集合框架中的Enumeration。迭代器允许调用者从集合中移除元素,而Enumeration不能做到。为了使它的功能更加清晰,迭代器方法名已经经过改善。
iterator = col.iterator();//得到一个集合的迭代器
while (iterator.hasNext()) { //使用hasNext():判断是否还有下一个元素
Object obj = iterator.next(); //使用iterator.next():①指针下移②将下移以后集合位置上的元素返回
System.out.println("obj=" + obj);
}
增强for的底层仍然是迭代器,会调用Iterator,增强for可以看成简易版的迭代器
增强for不仅可以使用在集合上还可以使用在数组上,
Iterator一般就是用在集合的遍历上
(1)我们可以使用Iterator来遍历Set和List集合,而ListIterator只能遍历List。
(2)Iterator只可以向前遍历,而LIstIterator可以双向遍历。
(3)ListIterator从Iterator接口继承,然后添加了一些额外的功能,比如添加一个元素、替换一个元素、获取前面或后面元素的索引位置。
相同点:
不同点:
public class Person implements Comparable<Person>{
private String name;
private int age;
public Person() {
}
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
//重写排序的规则
@Override
public int compareTo(Person o) {
//自定义比较的规则,比较两个人的年龄(this,参数Person)
//return this.getAge() - o.getAge();//用自己(this)减去参数就是升序,年龄升序排序
return o.getAge() - this.getAge();//年龄升序排序
}
}
public class Demo02Sort {
public static void main(String[] args) {
ArrayList<Person> list03 = new ArrayList<>();
list03.add(new Person("张三",18));
list03.add(new Person("李四",20));
list03.add(new Person("王五",15));
Collections.sort(list03);
System.out.println(list03);//[ Person{name='王五', age=15},Person{name='张三', age=18}, Person{name='李四', age=20}]
}
}
public class Student {
private String name;
private int age;
public Student() {
}
public Student(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "Student{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
public class Demo03Sort {
public static void main(String[] args) {
ArrayList<Integer> list01 = new ArrayList<>();
list01.add(1);
list01.add(3);
list01.add(2);
System.out.println(list01);//[1, 3, 2]
Collections.sort(list01, new Comparator<Integer>() {
//重写比较的规则
@Override
public int compare(Integer o1, Integer o2) {
//return o1-o2;//升序
return o2-o1;//降序
}
});
System.out.println(list01);
ArrayList<Student> list02 = new ArrayList<>();
list02.add(new Student("a迪丽热巴",18));
list02.add(new Student("古力娜扎",20));
list02.add(new Student("杨幂",17));
list02.add(new Student("b杨幂",18));
System.out.println(list02);
Collections.sort(list02, new Comparator<Student>() {
@Override
public int compare(Student o1, Student o2) {
//按照年龄升序排序
return o1.getAge()-o2.getAge();
}
});
System.out.println(list02);
}
}