一.简介
集合的主要作用是存储对象的容器,本质是用于存储对象的数据结构。
二.类型
集合类存放于java.util包中,主要set、list、map。
- Collection是集合list set queue的最基本接口
- Iterator:迭代器,可以通过迭代器遍历集合中的数据
- Map:映射表的基础接口
关系如下图:
2.1 List 一共三个实现类:ArrayList、Vector、LinkedList
ArryList 查询较快,增删较慢。内部通过数组实现,允许对元素进行快速随机访问。数组的每个元素之间不能有间隔,当数组大小不能满足时需要增加存储能力,就要将已有数组的数据复制到新的存储空间中。
Vector 也是通过数组实现,支持同步,即某一时刻只有一个线程能够写Vector,避免多线程同时写而引起的不一致性。
LinkList 用链表结构存储数据,增删较快,查询较慢,可以当作堆栈、队列和双向队列使用
2.2 Set 无序,值不重复,如果想要让两个不同的对象视为相等的,必须覆盖Object的hashCode方法和equals方法
HashSet 哈希表存放的是哈希值。HashSet 存储元素的顺序并不是按照存入时的顺序(和 List 显然不 同) 而是按照哈希值来存的所以取数据也是按照哈希值取得。元素的哈希值是通过元素的 hashcode 方法来获取的, HashSet 首先判断两个元素的哈希值,如果哈希值一样,接着会比较 equals 方法 如果 equls 结果为 true ,HashSet 就视为同一个元素。如果 equals 为 false 就不是同一个元素。
TreeSet
- TreeSet()是使用二叉树的原理对新 add()的对象按照指定的顺序排序(升序、降序),每增 加一个对象都会进行排序,将对象插入的二叉树指定的位置;
- Integer 和 String 对象都可以进行默认的 TreeSet 排序,而自定义类的对象是不可以的,自己定义的类必须实现 Comparable 接口,并且覆写相应的 compareTo()函数,才可以正常使用;
- 在覆写 compare()函数时,要返回相应的值才能使 TreeSet 按照一定的规则来排序;
- 比较此对象与指定对象的顺序。如果该对象小于、等于或大于指定对象,则分别返回负整数、零或正整数。
LinkHashSet
LinkedHashSet底层使用 LinkedHashMap 来保存所有元素,它继承于 HashSet
2.3 Map
HashMap HashMap 根据键的 hashCode 值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap 最多只允许一条记录的键为 null,允许多条记录的值为 null。HashMap 非线程安全,即任一时刻可以有多个线程同时写 HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以使用ConcurrentHashMap。 java7和java8的区别 根据 Java7 HashMap 的介绍,我们知道,查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。为了降低这部分的开销,在 Java8 中,当链表中的元素超过了 8 个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。
HashTable 遗留类,建议不使用,不需要线程安全的场合可以使用HashMap,需要线程安全的场合可以用ConcurrentHashMap
TreeMap TreeMap 实现 SortedMap 接口,能够把它保存的记录根据键排序,默认是按键值的升序排序.在使用 TreeMap 时,key 必须实现 Comparable 接口或者在构造 TreeMap 传入自定义的Comparator,否则会在运行时抛出 java.lang.ClassCastException 类型的异常。
LinkHashMap 有序
DEFAULT_INITIAL_CAPACITY =16:默认初始容量 MAXIMUM_CAPACITY :最大容量1 << 30 DEFAULT_LOAD_FACTOR =0.75f:负载因子 TREEIFY_THRESHOLD =8:阈值 如果链表长度超过阀值就把链表转成红黑树 UNTREEIFY_THRESHOLD =6:链表长度低于6,就把红黑树转回链表 MIN_TREEIFY_CAPACITY =64:开始转换为树结构的值
三. 扩容机制 如果这个桶中bin的数量小于 TREEIFY_THRESHOLD 当然不会转化成树形结构存储; 如果这个桶中bin的数量大于了 TREEIFY_THRESHOLD ,但是capacity小于 MIN_TREEIFY_CAPACITY 则 依然使用链表结构进行存储,此时会对HashMap进行扩容; 如果capacity大于了 MIN_TREEIFY_CAPACITY ,则会进行树化。
HashMap 在多线程环境下,使用HashMap进行put操作会引起死循环,导致CPU利用率接近100%,所以在并发环境下不能使用HashMap。 jdk1.7采用头插法 HashMap的线程不安全主要是发生在扩容函数中,即根源在transfer函数中
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
这段代码是 HashMap 的扩容操作,重新定位每个桶的下标,并采用头插法将元素迁移到新数组中。头插法会将链表的顺序翻转,这也是形成死循环的关键点。理解了头插法后再继续往下看是如何造成死循环以及数据丢失的。 扩容造成死循环和数据丢失的分析过程 假设有两个线程A、B同时对下面这个HashMap进行扩容操作 正常扩容后的结果是下面这样子的: 但是当线程A执行到上面 transfer 函数的第11行代码时,线程A被挂起。即如下图中位置所示: newTable[i] = e; //线程在此处挂起 此时线程A中:e=3、next=7、e.next=null 当线程A的时间片耗尽后,CPU开始执行线程B,并在线程B中成功的完成了数据迁移 重点来了,根据Java内存模式可知,线程B执行完数据迁移后,此时主内存中 newTable 和 table 都是最新的,也就是说:7.next=3、3.next=null。 随后线程A获得CPU时间片继续执行 newTable[i] = e ,将3放入新数组对应的位置,执行完此轮循环后线程A的情况如下: 接着继续执行下一轮循环,此时e=7,从主内存中读取e.next时发现主内存中7.next=3,于是乎next=3,并将7采用头插法的方式放入新数组中,并继续执行完此轮循环,结果如下: 执行下一次循环可以发现,next=e.next=null,所以此轮循环将会是最后一轮循环。接下来当执行完e.next=newTable[i]即3.next=7后,3和7之间就相互连接了,当执行完newTable[i]=e后,3被头插法重新插入到链表中,执行结果如下图所示: 上面说了此时e.next=null即next=null,当执行完e=null后,将不会进行下一轮循环。到此线程A、B的扩容操作完成,很明显当线程A执行完后, HashMap 中出现了环形结构,当在以后对该 HashMap 进行操作时会出现死循环。并且从上图可以发现,元素5在扩容期间被莫名的丢失了,这就发生了数据丢失的问题。 JDK1.8不安全分析(尾插法) 根据上面JDK1.7出现的问题,在JDK1.8中已经得到了很好的解决,如果你去阅读1.8的源码会发现找不到 transfer 函数,因为JDK1.8直接在 resize 函数中完成了数据迁移。另外说一句,JDK1.8在进行元素插入时使用的是尾插法。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p;
int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
其中第六行代码是判断是否出现hash碰撞,假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。 除此之前,还有就是代码的第38行处有个 ++size ,我们这样想,还是线程A、B,这两个线程同时进行put操作时,假设当前 HashMap 的zise大小为10,当线程A执行到第38行代码时,从主内存中获得size的值为10后准备进行+1操作,但是由于时间片耗尽只好让出CPU,线程B快乐的拿到CPU还是从主内存中拿到size的值10进行+1操作,完成了put操作并将size=11写回主内存,然后线程A再次拿到CPU并继续执行(此时size的值仍为10),当执行完put操作后,还是将size=11写回内存,此时,线程A、B都执行了一次put操作,但是size的值只增加了1,所有说还是由于数据覆盖又导致了线程不安全。
|