java集合类原理总结

以下集合讲解没有指定jdk版本默认都是jdk8

ArrayList

  • 线程不安全。
  • 基于一个Object[]数组实现,默认数组是个空数组,可以插入空数据,可以随机访问。如果要找到是否存在某个值,需要遍历数组匹配,时间复杂度是O(n)。由于通过存放的是动态数组,arrayList重写序列化方法readObject和writeObject,只序列化有值的数组位置。
  • add(E e)添加元素方法: 会检查数组容量是否够存放新加的数据,判断如果不够存放就会进行扩容,首次扩容判断是否当前维护的数组是空数组,是的话初始化一个容量为10的数组,不是的话按照当前数组容量1.5倍扩容
  • add(int index,E e)指定位置添加元素: 同样会检查容量,然后会基于system.arrayCopy数组复制将index位置开始的数据往后一位复制,最后将index位置的数据赋为e。
  • addList(Collection c) 添加一个集合,同样会检查容量,由于添加的是一个集合,1.5倍扩容可能不够存放即将存放的集合,这时判断不够存放,会将新数组的大小扩容为原数组大小加上c的大小
  • remove(int index)方法也是基于数组复制,将index位置后面的数据往前一位复制,将index位置的数据顶掉。
  • foreach基于普通for循环实现的,建议用增强for循环效率高些。

Vector  

  • 线程安全,类似ArrayList加上了同步。
  • 与ArrayList类似也是维护一个数组,但是Vector操作数组的方法都在方法上加上synchronized进行同步处理。

linkedList 

  • 线程不安全,有序的集合,按照插入顺序排序。
  • 基于双向链表实现,查找数据的时候会判断index偏向尾节点还是头节点,偏向投节点则从头节点开始遍历查找,反之同样。通过空间换时间,查找时间复杂度o(n/2)。集合实现内部类node, 包含数据、指向上一节点的node对象和指向下一节点的node对象。集合维护起始节点和结束节点。
  • add(E e)方法  将现在的结尾节点下一个指向e,将e改为结尾节点,速度快。
  • remove(int index) 需要先找到这个位置的节点花费o(n/2),移动指针将该位置的节点e的上一个节点的next指向e的下一个节点,e的下一个节点的prev指向e的上一个节点。

hashMap 

JDK7和JDK8的区别是resize的方式不同(尾插法和头插法),hash冲突处理方式也不同,jdk7就是单纯的链表,JDK8链表长度大于等于8的时候会转成红黑树,小于等于6时再退成链表。

  • 线程不安全,key value形式的集合。
  • hashmap基于数组和链表实现(jdk8加上了红黑树),数组默认容量是16,负载因子是0.75,当容量(总节点数)大于等于16*0.75=12时会发生扩容,扩容的方式是创建一个新数组,将老数组的值都重新计算hash值存放进去。
  • hashmap通过与数组容量减1相与进行位运算,即hash % size = hash & size-1,实现和数组容量取余相同的效果,位运算性能会更好,但是也要求了数组大小是2的幂,如果不是2的幂减1就不是全是1的二进制数。如果创建hashMap指定的容量不是2的幂,会自动替换成大于这个容量的且最近这个容量的2的幂,比如设置9则会自动替换成16。
  • put方法,hashmap通过hash算法计算key的hashcode值,再与数组容量减1相与,得到数组下标,如果该数组下标没有存在数据,则直接存放进去,如果该位置已经存在数据,则以链表的形式将该位置的数据连接起来,jdk8在链表长度大于等于8时会将链表转换成红黑树。在容量超过 容量*负载因子 时,会发生扩容,jdk7和jdk8扩容的方式不同。新数组冲突数据jdk7使用的是头插法和jdk8是尾插法,jdk7头插法会导致死循环
  • resize: jdk7扩容的方式是遍历每个数组位置和每个位置上的链表的数据,根据当前hash值重新与新数组大小取余放到新数组。新数组数据碰撞jdk7使用头插法的形式。假设现在hashMap的数组0号位是A->B,首先会重新hash计算A在新数组的位置(这时会先把A的下一位先存到next,因为后面会把A的下一位替换掉),得到A在新数组的2号位,新数组的2号位没有数据,A放到新数组的1号位,接着拿到暂存的next值B,把B的下一位暂存到next,如果刚好计算也是新数组的2号位,因为2号位已经有节点A,这时会将2号位替换成节点B,然后B.next指向A,这时新数组的2号位变成B->A(与原数组的顺序相反),由于暂存的B的下一位是空的,此时0号位的节点迁移完成,开始1号位的迁移....。下图是jdk7扩容的代码。
void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry e : table) {
            while(null != e) {
                Entry 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;
            } 
        }
}
  1. 在多线程环境下,线程1和线程2同时都去扩容,各自创建新数组。同样先处理数组0号位,线程1先把节点A存放新数组2号位,还没开始第二次循环,线程1挂起。
  2. 线程2扩容,创建新数组,将新数组的2号位替换成B->A,在执行到下面代码e.next=newTable[i]的位置,线程2挂起。线程1这时会继续处理节点B,而此时数据B.next已经被线程2改成了节点A(节点A会被暂存到next),第二遍循环执行完线程1的新数组是B->A。
  3. 判断next不为空开始第三遍循环,先把A.next暂存(这时A.next是空的),接着把节点A.next改成新数组上的2号位B,而此时新数组上的数据已经是B->A,那么这时就会变成B-><-A,变成环形链表,接着新数组的位置替换成A,判断next为空结束循环。最终新数组的2号位变成A-><-B,形成环形链表,如果get方法获取到这个位置,就陷入死循环查找。
  • resize: jdk8把头插法改成了尾插法,避免了resize导致的死循环。与jdk7一样也是遍历老数组,遍历每个数组上面的链表或红黑树,并且判断当前节点是普通节点还是树节点(jdk8有两种节点,普通节点是Node类,树节点是TreeNode),扩容的方式也不同。
  1. 普通节点的扩容。hashMap设置了4个节点变量,分别是低位头节点,低位尾节点,高位头节点,高位尾节点。低位代表了当前节点的hash值与新数组的容量取余后,并没有改变位置还是保持在原有的位置,高位表示与新数组容量取余后位置偏移了一个旧数组容量位置(因为容量是两倍扩容的,比如7%4=3,7%8=7,即7=4+3)。hashMap判断高低位,不是直接与新数组取余后跟当前数组下标比较,而是与旧数组的大小相与判断是不是等于0,这一步是真的强!(前面讲到hashMap用hash值与容量减1进行与运算实现和取余一样的效果,即hash & size -1,那么如果和新数组容量取余就是hash & 2*size -1,假设size是4即100,减去1即0011,那么2倍size就是8即1000,减去1即0111,hash值如果和100相与为0,说明从右往左第三位为0,那么与0011和0111相与的结果就是一样的,就可以说明和新数组容量相与是一样的结果)。通过以上所诉,即旧数组的数据只会放在新数组相同的位置或者偏移一个旧数组容量的位置。hashMap把第一个进入数组某个位置的节点设置成头节点,同时设置为尾节点,通过循环把新进来的数据放到尾节点的next和替换尾节点实现尾部插入节点。假设有节点A、B、C,第一步head=A,tail=A,第二步tail.next=B, 即head=A->B,tail=B,第三步tail.next=C,head=A->B->C,tail=C,最后只要把head放到对应的数组位置即可。以下是jdk8 hashMap resize的主要方法明细。
    for (int j = 0; j < oldCap; ++j) {
        Node e;
        if ((e = oldTab[j]) != null) {
            oldTab[j] = null;
            if (e.next == null)
                newTab[e.hash & (newCap - 1)] = e;
            else if (e instanceof TreeNode)
                ((TreeNode)e).split(this, newTab, j, oldCap);
            else { // preserve order
                Node loHead = null, loTail = null;
                Node hiHead = null, hiTail = null;
                Node next;
                do {
                    next = e.next;
                    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;
                    }
                } while ((e = next) != null);
                if (loTail != null) {
                    loTail.next = null;
                    newTab[j] = loHead;
                }
                if (hiTail != null) {
                    hiTail.next = null;
                    newTab[j + oldCap] = hiHead;
                }
            }
        }
    }
    
  2. 红黑树的扩容(先跳过,等再补充)。
  • jdk8虽然通过尾插法解决了死循环的问题,但是hashMap追求的是高性能,并没有对共享变量做安全控制,在多线程环境下肯定会出现变量被覆盖,并发修改某个位置的值造成数据丢失各种问题,因此如果是多线程环境,要用concurrentHashMap。
  • get方法:通过计算key的hash值取余得到对应数组位置,取出该位置的数据并比较hash值,和==比较或equals比较,比较相同则返回,不同则判断是树节点还是普通节点,树节点通过遍历树节点判断,普通节点则遍历链表判断。
  • 首次创建不会分配数组,put数据的时候发现为空才创建数组。
  • 为什么设置成8转红黑树,因为红黑树查找平均时间复杂度为log(2)N,链表遍历平均是N/2,8以下的查找效率差不多,但是链表的插入效率会好很多,链表插入是o(1),红黑是树是log(2)N。为什么设置6转链表,与上面同理,不设置7是防止map频繁增加和删除,频繁转换节点结构。

HashSet

  • 线程不安全,用于让存放的数据去重。
  • hashSet就是基于hashMap实现的,创建hashSet底层就是创建一个hashMap,存放数据基于hashMap的key不能重复,map的value存放一个固定的对象。hashSet的遍历就是基于hashMap的key集合进行遍历。

LinkedHashMap

  • 继承hashMap,线程不安全。有序的hashMap,实际上存储还是和hashMap一致,但是底层类似LinkedList维护多一个双向链表用于保存节点的顺序,支持两种排序,一种是按照插入顺序排序成员变量accessOrder=false,一种是按照访问顺序排序成员变量accessOrder=true(访问顺序包括查询节点和修改已存在的key的节点,都会修改该节点的位置),默认accessOrder=false,即按照插入顺序排序

  • 内部封装了一个Entry,继承了HashMap的Node类,封装多了两个参数,before和after用于指向节点前后的数据(跟hashMap的Node节点的next不一样,next是存储hash冲突后链表的下一个节点,befor和after是全局的节点顺序)。代码如下。

static class Entry extends HashMap.Node {
        Entry before, after;
        Entry(int hash, K key, V value, Node next) {
            super(hash, key, value, next);
        }
}
  • put方法,使用hashMap的put方法,但是重写了put方法调用到的几个方法,newNode、newTreeNode、afterNodeAccess、afterNodeInsertion。
  1. newNode方法的改动:创建Node改为创建Entry。linkedHashMap内部维护两个成员变量,Entry head和 Entry tail,用于存放头节点和尾节点,创建第一个节点会设置成head和tail为该节点,之后每创建一个节点会修改尾节点tail为当前的节点,旧的尾节点after设置为当前节点,新的尾节点before设置成旧的尾节点。即head-><-node1-><-node2-><-tail,通过双向链表将所有节点连接起来
  2. newTreeNode方法和newNode加上一样的操作。(newNode是创建普通节点,newTreeNode是创建树节点)。
  3. afterNodeAccess方法:hashMap在插入节点的时候如果当前的key已经存在会修改当前key对应的value,修改value后hashMap会调用afterNodeAccess方法。这个方法在hashMap中是一个空方法,LinkedHashMap实现了这个方法。在这个方法中判断如果accessOrder=true并且当前节点不是尾节点,则会将该节点移动到尾节点。具体操作就是移动该节点的前后节点的指针,把指向当前节点的after和before互连,然后把该节点替换成尾节点,旧的尾节点after指向该节点。(即按照访问顺序排序才会移动已存在的key节点)
  4. afterNodeInsertion方法(用于给子类实现LRU):hashMap在put方法的最后会调用这个方法,同样hashMap没有实现,LinkedHashMap实现了。但是在LinkedHashMap里面这个方法是没有任何意义的,这个方法的作用是移除头节点,但是移除前会调用removeEldestEntry方法判断要不要移除,LinkedHashMap这个方法固定会返回false,也就是永远不会移除。这样设计的目的是,如果开发者想要实现一种机制,当集合的总数达到一定数量后,把最早插入的节点移除或者把最近最少访问的节点移除(也就是移除头节点),那么继承LinkedHashMap并重写这个方法就可以了,开发者可以在里面自定义策略返回true和false。
  • remove方法调用hashMap的remove,remove方法调用了afterNodeRemoval,LinkedHashMap实现这个方法删除双向链表中该节点。
  • get方法,LinkedHashMap自己实现了这个方法,获取节点和hashMap一样,加了一步判断accessOrder=true会把该节点移动到双向链表尾部。
  • entrySet遍历集合,封装了个内部迭代器,通过遍历双向链表实现。

HashTable

  • 线程安全,与hashMap类似,但是方法上都加上了syschronized同步,不可以存放NULL

  • 默认大小11,总节点数(包含链表中的节点)超过容量*负载因子则扩容,扩容两倍+1扩容。基于数组和链表实现,类似jdk7 hashMap。

  • 插入数据采用头插法。相同位置的节点,先进的会在链表后面。

TreeMap

  • TreeMap是一个的有序的key value集合,基于存放数据进行比较排序,继承AbstractMap,底层由红黑树实现,不可以存放NULL
  • 构造函数可以接收一个Comparator比较器,用于自定义比较方法,如果没有设置Comparator则通过类本身实现的Comparable进行元素比较(如果类没有实现Comparable转换就报错了)。
  • get方法,自上而下比较节点(二叉查找树规则)
  • put方法先查找应该存放的位置,存放后校验是不是符合红黑树特性,不符合则进行变色旋转调整。
  • delete方法,寻找到替换节点,将替换节点替换掉删除节点,然后判断被删除的节点是不是黑色,如果是的话说明此时红黑树已经不满足特性,需要旋转和变色重新调整树结构。

ConcurrentHashMap

jdk7和jdk8的实现不一样,jdk7是通过分段锁实现线程安全,jdk8通过。由于差别比较大分开总结。

concurrentHashMap jdk7版本

  • 线程安全,不允许key value为空。
  • 两个比较重要的参数,Segment(实现了ReentrantLock的类)数组,HashEntry数组,Segment中包含HashEntry。
  • 每个Segment类似一个HashMap对象。同HashMap一样,Segment包含一个HashEntry数组,数组中的每一个HashEntry既是一个键值对,也是一个链表的头节点,hash冲突时会变成链表连接,与jdk7的hashmap类似。默认Segment的个数是16个,同时会保证是2的幂次方(类似hashMap),HashEntry默认的个数是2个(initialCapacity/concurrencyLevel,最小是2个)

  • 构造函数:默认构造函数设置3个参数的值,initialCapacity(用于计算HashEntry数组容量,initialCapacity/concurrencyLevel),  loadFactor 负载因子(Segment的节点数超过 负载因子*entry容量后开始扩容), concurrencyLevel(Segment的个数,如果不是2的幂次数会扩大到最接近的2的幂次数)。默认构造函数Segment的个数是16,但是只会先初始化第一个Segment,其他的懒加载。

  • get方法,通过计算key的hash值(根据key的hashCode再hash,防止散列不均匀),再通过int j =(hash >>> segmentShift) & segmentMask这个算法获取到所在的Segment的位置j。segmentShift=32-sshift,而2的sshift次方=ssize,ssize即Segment分个数,默认Segment个数为16即ssize为16,sshift即为4,segmentShift=32-4=28。segmentMask为ssize-1即默认为15(二进制位全是1,因ssize是2的幂次数),即段的位置j=(hash >>> 28) & 15,即hash值右移28位(保留高位),再和15相与(相当于和16取余计算)。获取当前key所在的Segment,再根据hash值和数组的大小取余获取HashEntry数组对应的位置。

  • 总结就是计算Segment的位置是获取到key的hashCode再hash后,根据hash值先右移保留高位,再取余。计算HashEntry的位置是直接根据hash值取余(之所以一个取高位,一个直接使用hash值是为了防止两个数值相同,基于Segment已经散列开,但是基于HashEntry没有散列开)。补充一点get方法是无锁的,HashEntry的value是volatile,确保线程的修改立即可见,而且即使同个线程同时操作,一个写入一个读取,因为对 volatile 字段的写入操作先于读操作,所以也能确保拿到新数据。

  • put方法,获取Segment位置和get同样,再获取这个Segment的锁,通过hash计算对应的entry,存放数据,如果该数组存在数据,则判断是否重复,重复则替换,不重复则头插法插入数组。容量超过负载因子*容量即会扩容,与hashMap相反是先判断要不要扩容再存放数据,防止扩容后就没数据进来,浪费空间。扩容基于某个Segment扩容,两倍扩容。

  • size()方法,Segment中维护两个值,一个是数据的总数count,一个是HashEntry改动的次数modCount。获取map中数据的总数,需要加各个Segment中数据的总数count累加起来。这个累加过程是不加锁的,因此可能会在统计过程中某个Segment的个数又增加。所以基于乐观锁和悲观锁的理念,累加会进行修改次数的比较,相等则返回,不相等则重试(乐观锁),重试默认超过2次就会把所有的Segment都加锁(悲观锁),再统计size返回。在第一次循环统计modCount和统计count,然后比较last(记录上次统计修改次数,第一次循环last=0)和这次累加modCount是否相等(第一次循环last默认值为0,所以只有集合没有改动过即没有数据,这一次modCount的统计才会和last相等,才会第一次循环就退出)。第二次循环的累加count和累加modCount,然后比较last和这次累加modCount是否相等,相等说明统计count前后没有发现改变跳出循环,否则重复第二次循环的操作,超过重试次数就加锁统计。

concurrentHashMap jdk8版本

  • 通过cas+syschronized实现,底层基于数组+链表+红黑树,类似hashMap。且concurrentHashMap通过Unsafe类基于内存地址操作变量值。
  • Unsafe类,类似c、c++的指针,可以直接通过内存地址操作内存数据,性能会更好,但是也会存在指针相关问题的风险,并且unsafe支持操作堆外内存(concurrentHashMap是用堆内内存,节点数组是通过new的方式创建的),但是需要自己管理内存,使得java不安全,因此也叫不安全类(但是因为不是堆内内存,不收垃圾回收管理,因此也避免了垃圾回收造成的停顿,并且提高io性能,避免io过程堆内存和堆外内存进行数据拷贝的操作)。java底层很多用到这个类进行操作。且该类支持执行处理指令cas指令和内存屏障指令,concurrentHashMap通过该类执行cas、操作数组、操作变量值
  • 几个比较重要的参数和数值说明。
  1.  sizeCtl参数。初始化或扩容时的标志参数,-1表示正在初始化,-N表示有N-1个线程正在扩容(concurrentHashMap 1.8支持多线程扩容),0表示没有初始化,正数表示下一次扩容的时机,即0.75*size。
  2. 节点主要有四种节点,分别是Node(普通节点)。TreeNode(树节点)。TreeBin(封装树的头结点,也就是数组存的是一个TreeBin,TreeBin基于TreeNode创建,TreeBin的构造函数会基于TreeNode构造红黑树(转换红黑树时,传入的TreeNode只是基于next连接节点))。ForwardingNode(正在迁移的节点,当一个数组正在被迁移时,旧数组上的节点会被设置成ForwardingNode,ForwardingNode保存了正在迁移的新数组数据)。
  3. 节点的hash值:node节点和TreeNode的hash值即为key的hash(hashCode再hash)值,TreeBin的hash值为-2,ForwardingNode的hash值为-1
  4. MIN_TRANSFER_STRIDE: 最小切割数,默认值为16。为了支持多线程扩容,concurrentHashMap通过切分数组,每个数组单独扩容,即每个线程最少处理16个数组位置的扩容。
  5. TRANSFERINDEX: 存储当前还未迁移的数组位置的最大下标,比如旧数组大小为64,每个线程处理16个数组位置,一开始TRANSFERINDEX设置成64,一个线程开始处理之后,这个值就变成64-16=48,那么第一个线程处理迁移的范围就是  49 ~ 64。
  • 构造函数是空方法,put方法判断为空再cas初始化数组(防止多线程初始化,cas失败会重试,重试如果判断),默认数组大小是16,sizeCtl为16*0.75=12。
  • get 方法:为无锁操作。通过hash值计算数组位置。(1)如果判断改位置的节点hash值和key都相等就返回该节点。(2)判断如果hash值小于0(因为TreeBin和ForwardingNode的hash值都被固定设置成负数),就通过节点本身的find方法查询节点。TreeBin的find方法就是通过红黑树的方式查找节点,ForwardingNode的find方法会再次确认,如果确认是ForwardingNode,会基于新数组查询,如果不再次确认时已经不是ForwardingNode,就类似之前的判断决定用遍历联表方式还是用红黑树的find。(3) 如果hash值大于0,就通过链表形式遍历查询。
  • put方法: 外层嵌套一个无限循环,break再跳出。(1)如果节点数组为空则初始化创建一个节点数组,继续下次循环判断。(2)通过计算hash值对应数组位置,如果该位置不存在节点则cas存放一个新节点,cas成功则break退出,失败则继续下一次循环判断。(3)判断改数组位置节点的hash值是不是-1(ForwardingNode节点),如果是代表正在扩容迁移数据到新数组中,调用helpTransfer方法帮助迁移。(4)通过syschronized锁住当前节点,判断如果数组上的节点的hash值大于0,代表是普通节点,通过链表形式扩容。如果hash值小于0,通过红黑树形式扩容。释放锁。(5) 存放节点之后,判断链表的长度binCount是否大于等于8,如果是则调用treeifyBin方法转换红黑树。(6)
  • treeifyBin方法:这个方法的作用是将数组某个位置的链表转为红黑树。(1)先判断节点数组的大小是否大于64,如果没有大于64则调用tryPresize方法进行数组扩容。扩大两倍扩容(2)如果节点数组的大小已经大于等于64,syschronized锁住当前节点,将当前数组位置的链表转换成红黑树。转红黑树先循环创建TreeNode节点,并将多个TreeNode节点通过next连接起来,循环结束后创建TreeBin节点,TreeBin接收第一个TreeNode节点,在构造函数中构建红黑树结构。
  • tryPresize 尝试扩容方法:根据传入的size扩大1.5倍+1,再扩大到最接近的2的幂次数(假设传入32,则放大到64)。(1)如果调用这个方法时,节点数组为空,创建节点数组。(2)调用transfer方法扩容。
  • transfer 扩容方法:通过cpu核数计算每个线程负责多少个数组位置的扩容,最少负责16个数组位置。再根据TRANSFERINDEX计算负责迁移的范围,遍历这个范围的数组节点处理。(1)判断节点为空则将老数组节点设置成ForwardingNode节点,继续遍历查找。(2)如果节点的hash值为-1(即已经是ForwardingNode),则说明改节点已经迁移完成,无需处理。(3)通过syschronized锁住当前节点,开始迁移。迁移与jdk7版本类似。一个线程处理完会重复这个流程。最后通过TRANSFERINDEX遍历到下标小于0,即所有数组位置都处理完就退出了。
  • addCount方法:case增加baseCount的值,校验是否需要扩容
  • size方法:
  • 为什么数组位置为空的时候,put采用cas,不为空的时候还要使用syschronized? 个人理解有两个原因。(1)数组位置为空的时候,cas有参照物且只需要一步操作,不为空则需要连接链表或者红黑树。链表添加值不可以用cas,遍历到尾节点,设置尾节点的next为空则替换成新元素,虽然能保证插入不会被重复设值,但是如果尾节点被另一个线程删除,那么新插入的节点也会丢失。红黑树因为存放数据后还需要调节平衡,如果调节平衡过程中put一个新数据会影响红黑树的调节出现问题,而且也没办法做cas操作。(2)数组位置为空时,扩容的时候可以同样通过cas将空位置替换成ForwardingNode,而put也是cas将空位置替换成普通节点,因此不会出现线程安全问题,只有一个成功,另一个失败的会重试。而在节点不为空的情况下,如果不加锁,因为扩容过程流程多,非原子操作,可能会出现正在复制节点时,刚复制好,还没把老数组设置成ForwardingNode,另一个线程又往老数组put了新数组,这时就出现数据丢失的情况。因此需要用syschronized加锁变成原子操作。
  • concurrentHashMap利用cas的方式大部分都是在外面嵌套一个无限循环,cas成功或者其他别的情况处理成功再break跳出循环。

CopyOnWriteArrayList

 

你可能感兴趣的:(集合,Java基础)