每日总结[11] 20191117 Java SE复习-集合框架util包/解析HashMap源码

(1)头脑风暴:把我能想到的写下来:
Java类集提供了两个重要的接口:Collection和Map。

Collection是所有存放单个元素的集合的最大父接口。它有List(允许重复)和Set(不允许重复)两个重要子接口。

List有几个常见实现类:ArrayList [ 动态对象数组 ] ,Vector, LinkedList [ 链表实现 ]。

[补:List比Collection多提供了get和set方法。]

ArrayList与Vector相同之处:底层都是Object[ ]数组,默认初始容量都是10,以1.5倍扩容,遍历集合中元素可以调用iterator,foreach,listIterator;
不同之处:
1.ArrayList是JDK1.2提出的,Vector是JDK1.0提出的。
2.ArrayList非同步,线程不安全,Vector同步,线程安全。
3.在需要遍历集合中元素时,Vector可以调用elements方法,返回值是Enumetor类型。

[补:
Vector扩容时可以指定容量增长因子。
异步处理性能高。
同步:某一时刻只有一个线程能够写。避免多线程同时写而引起的不一致性。
线程安全:多线程访问时,采用了加锁机制,当一个线程访问该类某个数据时,进行保护,其他线程不能进行访问,直至该线程读取完,其他线程才可使用,不会出现数据不一致或者污染。
]

  • ArrayList线程不安全的地方体现在以下两种情况:

(1)ArrayList的add(E e)方法:

ensureSizeInternal(size+1);
elementData[size++]=e;

将e添加入集合是分两步的:
先elementData[size]=e;再size++;
假设现在的size=10,线程A先将A的e值赋给elementDate[10],CPU空出时间片给线程B,这时size仍等于10,线程B把B的e值也赋给elementDate[10],相当于覆盖了A的e,然后时间片给线程A,这时size++变为11,又给线程B,size++变为12,导致线程A的数据被污染且elementDate[11]为null。
(2)
ensureSizeInternal(int size);判断容量是否足够是否需要grow扩容是这样的:如果size+1>length,才进行扩容。
假设现在size=9,elementDate的length是默认的初始值10,线程A执行ensureSizeInternal(size+1),传入9,因为9+1不大于10,判断为不需要扩容,然后CPU让出时间片给线程B,size仍为9,不需要扩容,将B的e赋给elementData的[9],然后size++变为10,CPU让出时间片给线程A,A之前已经判断过不需要扩容,(然而这时size=10了)于是将A的e赋给elementData[10],可是elementData的length为10,数组下标越界,抛出indexOutOfBoundsException。

[改:抛出的那是 ArrayIndexOutOfBoundsException]

ArrayList的remove方法:当前索引对应的元素删除,是将后面的元素依次往前覆盖,最后给最后一个元素置为null。

遍历集合中元素时调用iterator [迭代器] 方法,以ArrayList为例:
public Iterator iterator( ) return new Itr();
(Iterator类实现了Iterable接口,该接口中也就这一个方法。 )

[改:
Iterator也是个接口。
Iterable接口中不只一个方法。
有三个方法:
default void forEach();
Iterator iterator();
default Spliterator spliterator();]

Itr是ArrayList的一个内部类,继承Iterator类,它有两个重要属性:
[改:实现Iterator接口。]

int cursor;
int lastRet=-1;

[补:还有个int expectedModCount=modCount;
后面的fail-fast机制中有用到。]

cursor [指针],是指向next的下标,初始值是0,lastRet是指向last [上一个]的用来return的下标,初始值为-1。(从-1开始才能保证第一次调用next是从0开始)

[补:
public boolean hasNext()
{return cursor!=size;}]

如果想要删除某个元素,正确的操作是先调用next:

int i=cursor;
cursor++;
return elementData[lastRet=i];

比如说最初cursor=0,先将cursor赋给i,cursor++,指向1,再将lastRet赋为0。
这时调用remove:

cursor=lastRet;
ArrayList.this.remove(lastRet);
lastRet=-1;

[补:Itr的remove方法还有一句:expectedModCount=modCount;这样调Iterator时不会抛出ConCurrentComdifaction]

调用remove时cursor会往前指一个(因为删除掉当前的元素,它后面的所有元素会依次往前覆盖,然后末尾赋null,(正是通过ArrayList的remove(int index)方法实现的,ArrayList.this表示外部类的对象,因为是在内部类Itr中调用这个方法)

[补:类名.this:在内部类方法中指定某个嵌套层次的外围类的“this”]

所以cursor需要前指一个保持是最新的next)。而且lastRet会变为-1,因此每次remove前都需要调用next方法,才能通过next方法中的cursor找到当前的next,然后将该值赋给lastRet,lastRet不为-1,才不会出现数组下标越界,而抛出ArrayIndexOutOfBoundsException,才能正确删除元素。
所以如果用listIterator可以从前往后也可以从后向前遍历,需要先从头往前,才能从后向前遍历,正是因为cursor和lastRet这两个下标。

Set不允许元素重复,主要有两个实现类:TreeSet和HashSet。

TreeSet要求放进去的元素具有可比性,该类对象应该实现Comparable接口,覆写CompareTo方法。

[改:compareTo方法]

HashSet底层有个HashMap,是将添加的元素作为key,其value都是一个new出来的Object对象。它是通过元素的hashCode和equals方法来判断是否重复。
这里的hashCode是个native方法,不由java实现,(本地方法可用于Java与底层操作系统交互) 为不同的对象生成不同的整型数字(可为负数),即内存地址。

[改:
public native int hashCode();
返回对象的Hash码值,通常通过将对象的内部地址转换为整数
如果两个对象的hashCode值相等,它们的equals不一定相等。比如String类型的"通话"和“重地”。——如果两个对象根据equals比较并不相等,并不要求在每个对象上调用hashCode都必须产生不同的结果。
如果没有覆写过equals方法,那么x.equlas(y)为true,x与y的hashCode应是相同的。
如果覆写过equals方法,比如比较的是Person类的name属性,那么x.equlas(y)为true,x与y的hashCode可能不同。
因此,重写了equals也要重写hashCode, (如果一个含有本地方法的类被继承,子类会继承这个本地方法并且可以用java语言重写这个方法,而如果一个本地方法被fianl标识,它被继承后不能被重写。就比如说所有的类都继承了Object类,而Object类中有这个非final的hashCode本地方法,那么所有的类都可以覆写这个本地方法。) 以维护hashCode方法的常规规定:相等对象必须具有相等的hashCode。]

[补:
调用HashSet的add方法,value是一个private static final修饰的Object对象。
(因为底层是HashMap)默认初始容量为16,负载因子为0.75]

Map用来存键值对类型的数据。重要的子类HashMap,TreeMap。
Map的每一个键值对都是一个Entry。

HashMap是基于hash表的数组(索引效率高,查找快,插入、删除慢)和链表(相反)相结合的数据结构。
HashMap与HashTable大致相同,不过它非同步,线程不安全,它允许null。

[补:数组被分为若干个桶。桶可以提供常量级时间性能。
HashMap不能保证map的次序,不能保证次序随时间不变。]

它的有参构造方法会传入两个重要参数:initialCapacity [初始容量] (默认初始为16)和loadFactor [负载因子](默认初始为0.75)。初始容量即桶的初始数量,负载因子用来描述当桶的装载程度达到多少时会进行再散列。负载因子不宜太大:说明空间利用程度高,散列冲突的几率大,链表就长,查找效率变低;不宜太小:容易触发扩容,造成空间浪费。这个构造方法还会给threshold [阈值] 属性赋值,阈值=容量负载因子,调用tableSizefor(initialCapacityloadFactor),该方法会先判断传入的值是否为2的幂次方且大于0且小于最大容许阈值(2^30),否则返回比它大的最小的2次幂。
为什么要设置成2次幂呢?因为计算key对应的桶索引时,是用key对应的hashCode%桶的数量,如果桶的数量是2的幂次方,那么该区域计算等价于&(除数-1),按位与计算效率高。而桶的数量受阈值影响,如果通过有参构造算出的阈值为2的幂次方,那么这个桶的数量也一定会为2的幂次方。

数组被分为一个个桶(bucket),即一个桶对应一个下标,给定key值时,用key对应的hashCode对桶的数量取余,求得桶的索引,如果索引相同,用拉链法解决冲突,即桶上链链表。每一个桶都是Node<Key,Value>,属性为key,value,next; 整个hash表是一个Node数组:Node<Key,Value>[] table
在JDK1.8上添加了新性能:当链表长度大于8时转换为红黑树。节点变为TreeNode,属性为parent,left,right,red(boolean类型)。
HashMap采用的是懒加载机制,当第一次调用put时,(put内部只是调用putVal方法)发现table==nulltable.length==0,才调用resize()进行扩容。每次put完都会给size+1,如果size>threshold,则需要扩容,调用resize()方法。

下面来分析resize()方法,由上一段可知,它起到两个作用:(1)初始化桶数组。(2)填充程度达到threshold时进行扩容。其实它不仅是容量、阈值变化,还完成了扩容后数据的转移。先看容量、阈值变化的部分,其中newCap的值用于扩容后数据转移至新table用,newThreshold会再赋给threshold,用于判断何时需要再散列。
由原table可得原容量,oldCap,
原阈值oldThr=threshold,
根据原table去相应的分支:

如果是table为null,也没有通过有参构造改变初始容量和负载因子,就将
新容量newCap赋为默认初始值16,
新阈值newThr赋为新容量newCap:16*负载因子默认初始值0.75=12;

如果已经通过有参构造改变初始容量和负载因子,那么旧阈值oldThr=threshold已经是2的幂次方,
新容量newCap赋为oldThr即可。
这时新阈值newThr=0,稍后处理。

如果table不为null,已经初始化过了,是因为size>threshold才来扩容的,首先看oldCap是否大于允许最大容量(2的30次方),如果大于,太大了,也没有扩容的必要了,
将newThr赋为允许最大阈值(2的30次方)
返回原table即可。

如果oldCap没有超出允许最大容量,
将新容量newCap变为原容量oldCap的两倍,>>>——双倍扩容。
如果新容量大于允许最大容量,只将允许最大容量赋给新容量newCap;
如果新容量不大于允许最大容量,且新容量大于默认的容量16,
将新阈值newThr也变为原阈值oldThr的两倍——双倍扩阈值。

如果翻倍了的这个新容量小于16,那么newThr=0,稍后处理。

接下来处理newThr=0的情况,由上可知,有两种情况会来到这个分支:
(1)已经通过有参构造改变过初始容量和负载因子的,它的threshold已经是2的幂次方了,赋给newCap刚好,这时它的新阈值应当是多少呢?按照阈值计算公式:阈值=容量负载因子,这时的新阈值newThr应该等于新容量newCap负载因子。
(2)双倍扩容后发现容量小于默认容量16的,它的容量已经是2的幂次方了,但是鉴于容量较小,还没人家默认的大,阈值没必要也跟着扩大成2倍的,所以新阈值newThr也等于新容量newCap*负载因子。——节约空间,提高效率起见。

然后都会将新阈值newThreshold赋给threshold,以继续进行判断——空间使用到什么程度需要再散列?

执行完扩容,就会得到newCap,接下来需要转移数据,步骤如下:
新建newCap大小的newTab,遍历Node[ ]oldTab数组,先取得当前节点,后把原数组oldTab该节点置为null,
如果该节点并没有链链表,直接往新数组newTab赋值即可,因为已经经历了扩容,数组长度即桶的数量改变,因此需要再散列。
如果该节点链了链表,先判断是否红黑树,如果是,调用红黑树的putTreeVal,如果不是,JDK1.8做了这样的优化:先通过(e.hash()&oldCap),如果结果为0,说明位置不需要改变。以oldCap=16为例。我们知道,为key寻找对应的桶索引是用key.hash&(capacity-1),16-1=15的二进制表示为:
00001111
经过双倍扩容后,这时的capacity-1变为16*2-1=31,二进制表示为:
00011111
与key的hashCode进行与运算,可以看出,主要影响结果的是hashCode的低5位,而oldCap=16,它的二进制表示为:
00010000
如果hashCode的低5位为“0”,它&(oldCap-1)与&(newCap-1)结果相同,即位置不需要改变,而&oldCap结果为0;同理,如果hashCode的低5位为“1”,它&(oldCap-1)与&(newCap-1)结果不同,即位置需要改变,且到新数组newTab中的位置正好是原位置+oldTab,而&oldCap结果为1。
JDK1.8遍历该Node节点所链链表的所有元素,将位置不需要改变(lohead,lotail)的和位置需要改变(hihead,hitail)的分别链成两个链表,然后才把这两个链表链到新数组newTab的相应位置。

这样避免了因为HashMap线程不安全可能导致的循环链表,调用get方法时出现死循环,而且链表尾插,数据不会逆序。

Map的public Set<key,value> entrySet()方法返回的是一个EntrySet类的对象,EntrySet继承了AbstractSet,返回的并不是真正的Set,只是一个Set视图,格式是[key=value]。
同理,keySet方法返回的是一个KeySet类的对象,KeySet类继承了AbstractSet。
values方法返回的是一个Value类的对象,Value类继承了AbstractCollection。(因为value是允许重复的,所以不是Set)

(2)需要查资料补充的:
疑问1:为什么调用HashMap的put给的key重复时,会覆盖原来key对应的value,然后返回原来的value?

答:HashMap的put方法是这样的:

 public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

相当于调用了putVal方法:

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) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

从这个方法可以看到:
如果当前table=null或长度为0,就调用resize()方法,初始化table,否则根据桶的长度和由key求得的hash值计算桶索引,
如果当前node节点为null,直接插入即可;
如果当前node节点不为null,已经有元素了,说明发生了哈希冲突,如果key值相同,直接将节点覆盖,返回原来的key对应的value。
如果key值不同,检查该节点是否为红黑树的节点,如果是,调用putTreeVal,如果不是,在链表尾插元素即可,尾插时注意当链表节点为8时需要转换成二叉树,返回null。

三、补充:
remove,contains都需要使用equals方法,

  • 写equals方法的原则:

在ArrayList的iterator方法中有实现了Iterator接口的内部类Itr,在该类的next方法中,有个 checkForComodification();:

  private class Itr implements Iterator<E> {
        int cursor;       // index of next element to return
        int lastRet = -1; // index of last element returned; -1 if no such
        int expectedModCount = modCount;

        public boolean hasNext() {
            return cursor != size;
        }

        @SuppressWarnings("unchecked")
        public E next() {
            checkForComodification();

这个方法的实现是这样的:

 final void checkForComodification() {
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
        }
    }

(摘自别人的微博,侵删原文链接:https://blog.csdn.net/chenssy/article/details/38151189)
迭代器的快速失败行为无法得到保证,因为一般来说,不可能对是否出现不同步并发修改做出任何硬性保证。快速失败迭代器会尽最大努力抛出 ConcurrentModificationException。
快速失败”也就是fail-fast,它是Java集合的一种错误检测机制。当多个线程对集合进行结构上的改变的操作时,有可能会产生fail-fast机制。记住是有可能,而不是一定。例如:假设存在两个线程(线程1、线程2),线程1通过Iterator在遍历集合A中的元素,在某个时候线程2修改了集合A的结构(是结构上面的修改,而不是简单的修改集合元素的内容),那么这个时候程序就会抛出 ConcurrentModificationException 异常,从而产生fail-fast机制。

ArrayList不是线程安全的,怎么办?
(1)用concurrent包提供的工具将ArrayList变为线程安全的。
如:
List<String> list1=Collections.synchronizedList(new ArrayList<String>());
要注意用户需要手动加锁(当通过Iterator、spliterator或Stream进行遍历时)。
(Vector也是需要用户手动加锁的。)
如:

synchronized(list1){Iterator i=list1.iterator();
while(i.hasNext())
{Systrm.out.println(i.next);}
} 

(2)
用CopyOnWriteArrayList [COW:写入时复制 容器]。增删改查时都会加锁,都会创建一个新数组,操作完成后再赋给原来的引用,而读操作是不需要锁的。
(还有CopyOnWriteSet呢。)
(等复习完线程好好了解一下这个类。)

(3)
Iterator接口是由Collection接口支持的”:
Collection接口只实现了一个接口,那就是Iterable接口,而Iterable中有三个方法:iterator(),Foreach(),spliterator(),
所以所有实现了Collection接口的子类(各种List、Set)都会覆写iterator方法(像ArrayList,是有个内部类Itr implements Iterator,调用iterator()方法时返回new Itr(); 像LinkedList,它的父类——一个抽象类AbstractSequentialList覆写了iterator()方法,返回的是一个ListIterator对象;
像HashSet,它的底层是HashMap,调用iterator()方法时是调用map.keySet的iterator方法,Set接口实现了Collection接口,它的实现类也是要覆写iterator()方法的。)
所以所有实现了Collection接口的子类都可以用foreach。

“ListIterator接口是由List接口支持的”
List接口中定义的listIterator()方法,这样所有实现了List接口的子类都需要覆写listiterator方法。

(4)HashTable1.2和HashMap1.0的区别:
1.与Hashtable相比,HashMap不同步,是非线程安全的,允许null值。
2.Hashtable继承自Directory,HashMap继承自AbstractMap.
2.HashMap默认初始容量为16,总是2的幂次方。
Hashtable默认初始容量为11,扩容机制是oldCap*2+1。
3.HashMap的keySet方法中遍历元素用的是Iterator,
而Hashtable由于版本遗留原因,用的是Iterator和Enumeration。(线程安全的Vector也用的是Enumeration.)
(用Iterator代替了Enumeration,新增了remove()方法。)
4.HashMap取消了原来HashTable有的contains(Object value)方法,改成了containsKey和containsValue。
5.散列方式不同,Hashtable直接用的hashCode。
6…Hashtable源码里说,如果需要线程安全,建议使用HashMap;
如果需要并发操作,建议使用ConCurrentMap>
7.我自己发现的:在Map接口中有Entry接口,在Hashtable中有个静态内部类Entry实现了这个接口,而在JDK1.8中,HashMap是有个静态类Node实现了Entry接口。

发布了47 篇原创文章 · 获赞 1 · 访问量 1269

猜你喜欢

转载自blog.csdn.net/weixin_41750142/article/details/103108133