Java集合框架深度学习

201 阅读16分钟

Java集合框架.png

数据结构

1.数组

特点 :顺序存储结构,元素类型是一致的,每个元素的占用内存长度是相同的,i 存放 的是head元素的内存地址,通过下标访问,也叫随机访问,查询效率比较高,增删慢

静态数组,长度固定

int [] i = new int[4];

基本数据类型的话是存储数据

User[] i =new user [4];

引用数据类型的话,存放的是引用数据类型堆内的地址值

int [] i = new int[4];

当进行get(3)操作时,就是找 head元素的内存地址+3*每个元素占用的内存长度

公式 :get(i) 就是head元素的内存地址+i*每个元素占用的内存长度 时间复杂度一直都是O(1)

动态数组

扩容因子 时间复杂度O(n),

2.链表

可以利用内存零碎空间,查询效率低,增删快

单向链表

链表3.png

链表需要在全局上维护一个 head 变量并指向0的内存地址,然后0指向1的内存地址,依次类推,就能维护起来。

内部类 中维护 Node ,里面放着(item=0,next=1)

时间复杂度O(N) get(3) O(3)

可以维护 last(tail) 从尾部找,更快

双向链表

单向链表加上指针,就是双向链表

双向链表.png

一般使用Integer.maxValue来限制链表的最大值

3.索引

查询效率高,增删也快

索引.png

4.散列

元素的全部或者一部分 通过散列算法 例如 hash散列算法 转换成一个数值 然后用这个数值 对数组长度进行取模得到的就是存储下标

通过锁解决多线程并发的安全

悲观锁

synchronized 用在方法上 让方法变成串行,不让多线程进来,一个个进来

synchronized 在内部维护 一个队列 线程进来时需要一个个拿锁资源 其他的在队列里面排队(先自旋在排队)

抢到synchronized 就是加上了锁,执行完之后,就会释放锁 自动的

Object wait notify(释放有一定随机性)

加锁

ReentrantLock lock = new ReentrantLock (); 阻塞时加到队列里

Condotion condotion = lock.newCondition(); //通过锁获取Condotion实例

lock.lock ();

释放锁

condotion.await //当前对象的await方法,唤醒当前对象锁

condotion.signal

lock.unLock()

乐观锁

无锁的机制 运用主要时cas+自旋的机制 compare and set 比较然后设置

利用 JVM 底层 Unsafe类 操作系统提供的指令 来保证 上面两个compare and set 的原子性

只有一个线程cas能执行成功

单线程进来不加锁 ,没有争抢资源的情况 ,多线程进来才会加锁

Thread

LockSupport.park();阻塞

LockSupport.unPark(指定唤醒线程);

List

ArrayList

动态数组

源码 : ArrayList 在全局变量里面有一个数组 elementData存储元素

扩容

未指定初始长度

ArrayList 无参构造器

ArrayList无参构造器.png

new 一个空参的时候 会指向一个空的数组

ArrayList空数组.png

数组长度为0,存储元素时无法存储,需要扩容

指定初始长度

传入容量

ArrayList有参构造.png

判断是否合法,如果为零的话,指向下图数组

ArrayList为0指向数组.png

为什么指定长度为零的话指定这个数组 EMPTY_ELEMENTDATA,而无参构造器指向DEFAULTCAPACITY_EMPTY_ELEMENTDATA?

实际上是为了判断用了哪个构造器来构造的List,判断是否指定了初始容量。也就是说如果指向的数组是EMPTY_ELEMENTDATA这个数组的话就表明有初始容量,之后再进行对应的扩容操作(按照1.5倍来扩容)。如果指向的数组是这个DEFAULTCAPACITY_EMPTY_ELEMENTDATA的话,说明是无参构造,没有初始容量,那么在第一次扩容的时候会默认将新数组扩容为十。

grow 方法

ArrayList

数据结构:动态数组,自动扩容

  1. 添加元素时容量不够
  2. 新建一个数组,长度为原数组的长度*扩容因子
  3. 新元素放入新数组,并将新数组赋值给集合,原数组丢弃

两个常量空数组:节省集合创建时占用的内存 每new一个都指向这两个常量空数组其中一个,减少内存空间的使用

  1. EMPTY_ELEMENTDATA

    构造器指定了初始容量,容量不够时按1.5倍扩容

  2. DEFAULTCAPACITY_EMPTY_ELEMENTDATA

无参构造器,初次添加元素时容量默认初始化为10,容量不够时按1.5倍扩容

迭代器:fail-fast 快速失败

ArrayList操作数.png

modCount是操作数,无论进行添加还是修改,底层会对其进行++,用于记录操作数

为什么需要记录操作数?

  1. iterator()获取迭代器时,保存modCount(操作次数)快照,游标cursor初始化为0
  2. hasNext判断条件:cursor != size,如果有其他线程修改了集合长度(新增删除元素),会导致size变化,modCount和快照也会有差
  3. next()取元素时,为防下标越界,先判断modCount和快照是否相同,不同意味着size已变化
public class Demo02 {
    public static void main(String[] args) {
        List l = new ArrayList();
        l.add(new Person("张三",11));
        l.add(new Person("李四",18));
        l.add(new Person("王五",20));
        l.add(new Person("赵六",22));
        Iterator it = l.iterator();
        while(it.hasNext()){
            Object o = it.next();
            Person p = (Person)o;
            if(p.getName().equals("赵六")){
              //ConcurrentModificationException 并发修改异常
                //产生的原因就是在迭代器的遍历的过程中,使用集合对象进行了增删集合中的元素
                //修改了集合的长度
                //此时在迭代器中进行遍历集合的每一个元素
                //迭代器依存于集合
                //而我们直接修改了集合的内容,导致迭代器遍历判断就发生了问题
                //l.remove(new Person("王五",20));
                //迭代器中想要删除目标元素,一定使用迭代器对象提供的remove()方法
                //删除当前指向位置的元素
                it.remove();//it指向哪个元素,就是删除哪个元素
            }
            System.out.println(p);
        }
        System.out.println(l);
    }
}

Iterator 在ArrayList中 是以内部类的形式存在 ,维护了三个变量!

Iterator.png

cursor 游标 用来记录迭代器循环到哪个地方了 相当于for循环遍历的 i

游标.png

游标从0开始 到最后一位的时候 cursor为5 再cursor++ 就变成6了, size(大小)为6

当cursor和size相等就说明后面没有元素可以取了

while(it.hasNext()){

}

hasNext 底层就是判断 cursor!=size

lastRet 最后访问的元素 expectedModCount 期望操作数 modCount 实际操作数

再创建时会将modCount 赋值给expectedModCount

原因 :如果我们不进行modCount和expectedModCount(创建迭代器的时候将当时的modCount赋值给expectedModCount),这个程序肯定会报ArrayIndexOutOfBoundsException,所以迭代器设计时不支持多线程,当迭代器检测到集合的操作就会立马抛出ConcurrentModificationException 并发修改异常,进行快速失败,避免下面的操作。

拷贝:拷贝栈中的内容

不管什么拷贝都是栈中的内容

浅拷贝 互相不独立,实现Cloneable接口,重写super.clone 方法,方法默认

  1. 拷贝前后变量是否独立

    1)基本类型:独立 在栈中分配,拷贝一份栈中的内容,因此拷贝前后的变量完全独立

    2)引用类型:不独立 栈中只记录了引用地址,拷贝一份栈中内容,拷贝前后都是指向堆中的同一块内存

    3)String: 独立 栈中记录了内存地址,但是指向的内容在常量池中,如果修改该常量,不会直接修改, 而是生成一个新的常量并赋值给变量,因此拷贝前后的变量指向了不同的内存地址

  2. 正常拷贝,实现Cloneable接口,clone方法默认,则为浅拷贝

  3. 深拷贝:需要递归所有的引用类型,递归到基本类型和String,数组为止,都拷贝一次

深拷贝 浅拷贝.png

序列化

重写了序列化方法,因为存放元素的数组elementData里面有很多占位用的null,序列化时只需要按照size读写

序列化.png

ArrayList序列化.png

Arrays.asList

接收的是可变参数,基本类型不支持泛型化,会把整个数组当成一个元素放入新的数组,传入可变参数

CopyOnWriteArrayList

效率高 推荐使用

  1. 数据结构同ArrayList,数组array
  2. 写(除了读)时加锁复制:ReentrantLock 保证线程安全,修改数组之前先将数组拷贝一份,操作新数组,并赋值给array,旧数组丢弃
  3. 读操作无锁 读的是旧数组,写不会阻塞读,读写分离
  4. 弱一致性:读不一定读到新的值 写操作会生成新的数组,读的数据可能已经被修改,迭代器也是弱一致性,读的是快照

写和写之间是会排斥的,写的是新数组,读的是旧数组,读写不阻塞

LinkedList

  1. 数据结构:链表,维护一个内部类Node,元素以Node节点的item属性存在,同时维护next和prev记录前驱后继的指针
  2. 双端队列:实现了Deque接口 支持从首部和尾部存取元素

Set

HashSet

底层使用HashMap存储数据,用map的key存储元素

  1. 元素不重复,key不能重复 因为HashMap的key是不能重复的
  2. 元素是无序的,key是无序的 因为HashMap的key是无序的
  3. 元素允许一个null值,key允许一个null 因为HashMap的key是允许一个null的
  4. 非线程安全,没有get()方法 因为HashMap的key是无法通过get取出的

向HashSet里面存储一个数据 就是向HashMap中存储一个 key

底层都是操作的Map,将值存到key里,values是PRESENT常量

   public boolean add(E e) {
        return map.put(e, PRESENT)==null;
    }
​
​
public boolean remove(Object o) {
        return map.remove(o)==PRESENT;
    }

虚拟对象PRESENT,存map时,value固定为此值

LinkedHashSet

没有成员变量及api,全部使用LinkedHashMap替代

TreeSet

底层使用NavigableMap存储元素,实际上大部分情况就是TreeMap

CopyOnWriteArraySet

底层使用CopyOnWriteArrayList

调addIfAbsent方法保证元素的不可重复

ConcurrentSkipListSet

底层使用ConcurrentSkipListMap

HashMap

数据结构 Node类型数组和单向链表链表 内部维护一个数组 一个Node内部类

当put(K,V) 时 通过 hash算法 hash(key) = hashcode 进行两次hash 得到一个hashcode值

然后得到的这个hashcode值和数组长度取模(类似取余,不过结果的符合应该和被除一致)得到一个比数组长度更小的数 就是 下标值

为了更快定位到数组下标位置

    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
      Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;   
        }

K值和V值是通过Node数组存储的,Node数组有两个属性,一个key 一个 value

当存储的key值为null时(只能存储一个key值为null的) 一般直接存储在第一个位置

当通过计算下标存入一组数据后 再次存储时有可能计算的一直 会造成hash冲突

当计算的下标一直时 会使用Node的另一个属性 next 会在此下标上用链表存储

get(k)时 在同一个下标上进行 equals方法比对 链表的查询效率低 需要一个个进行equals 效率非常低

HashMap put.jpg

所以在jdk8之后 当链表的高度达到了8,数组的长度达到了64 时 这个链表就会转换为 红黑树

JDK7 Node 数组+单向链表

JDK8 Node 数组+单向链表+红黑树

红黑树就是平衡二叉树中一个比较好的实现

扩容

负载因子 默认是0.75 数组长度*负载因子

将原数组进行扩容 需要再次计算hash值,因为数组长度改变了

JDK7

进行扩容复制时使用头插法,会将原来链表顺序发生一个改变,将链表第二个元素放在第一个元素前面

在这里HashMap会出现一个并发的问题 死锁

HashMap JDK7死锁.png

HashMap不是线程安全的 不同线程进来都有可能发现数组的大小不够 需要扩容

当线程1扩容时 添加过第一个元素A后 第二个元素 通过 Node next = e.next A.next=B获取 ,A的next指向的是B 此时线程1 挂起

线程2进来的时候进行扩容 将B元素进行了头插法 使得 B.next = A

当线程1 唤醒时,继续进行扩容 发现 原来的 A.next=B 再添加时 B.next = A 就产生的死锁

实际上就是链表的闭链

JDK8

所以JDK8使用尾插法进行解决

就算线程2已经执行完了 也会将 A.next=B 就这样规避了死锁的问题

JDK8为什么不能直接采用红黑树,非要采用链表加入中间?

因为节点数太少的时候,数组长度低于64,节点数低于8 时 引用红黑树进行存储的话,红黑树的拆分和创建比链表的时间还要长

初始容量

要求为2的n次方,不是的话会默认找最近的一个2的n次方 源码会进行一个位运算

负载因子loadFactor

 static final float DEFAULT_LOAD_FACTOR = 0.75f;

扩容的阈值 默认是0.75

equals

hashCode 定位下标,equals定位点元素

使用HashMap什么时候需要重写equals方法?

当需要把自己声明的类的对象用作HashMap中的key值时,由于HashMap中get()与put()方法的实现中大量运用了equals方法对key元素的散列与查找进行比较操作,若希望携带自创类型为key或value的HashMap在散列时能够均匀、在存取时能够精准,需要在自己创建的类中重写类的equals方法,进而不去调用Object类的equals方法。

class Person{
    private String name;
    private String IDcard;
​
    public boolean equals(Person p){
        if(this.IDcard!=null || this.IDcard,equals(p.IDcard))
            return true;
        return false;
    }
​
}

源码分析

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }

上述的equals方法是 位于objets类中静态的比较方法,是为了避免NullPointerException异常

public static boolean equals(Object a, Object b) {
        return (a == b) || (a != null && a.equals(b));
    }

在实际调用equals方法是,HashMap使用父类AbstractMap的equals,下面是AbstractMap中的equals方法:

public boolean equals(Object o) {
        //首先判断两个对象的内存地址是否相同,若相同返回true
        if (o == this)
            return true;
​
        //判断参数是否为Map类或它的子类
        if (!(o instanceof Map))
            return false;
​
        //判断两个map的size是否相同,size()是同一个类中的方法
        Map<?,?> m = (Map<?,?>) o;
        if (m.size() != size())
            return false;
​
        try {
            //使用迭代器遍历调用equal方法的那个map,注意entrySet方法
            //m即为上面参数赋值过的那个传入的map对象
            Iterator<Entry<K,V>> i = entrySet().iterator();
            while (i.hasNext()) {
                Entry<K,V> e = i.next();
                K key = e.getKey();
                V value = e.getValue();
                if (value == null) {
                    //当这个节点的value值为null时,比较key
                    if (!(m.get(key)==null && m.containsKey(key)))
                        return false;
                } else {
                    //当节点的值不为空时,比较"调用方法的对象的值"与在"参数对象"中同一key取出的值
                    if (!value.equals(m.get(key)))
                        return false;
                }
            }
        } catch (ClassCastException unused) {
            return false;
        } catch (NullPointerException unused) {
            return false;
        }
​
        return true;
    }

containsKey方法

public boolean containsKey(Object key) {
        //此处是一个简单的判断key的过程,首先获取调用此方法的对象的迭代器
        Iterator<Map.Entry<K,V>> i = entrySet().iterator();
​
        //当传入key为null时,判断调用对象的key是否为null
        if (key==null) {
            while (i.hasNext()) {
                Entry<K,V> e = i.next();
                if (e.getKey()==null)
                    return true;
            }
        //当传入key不为null时,判断调用对象的key是否相同,调用的equals就到了Object的,按照不同数据类型的比较方法再次比较
        } else {
            while (i.hasNext()) {
                Entry<K,V> e = i.next();
                if (key.equals(e.getKey()))
                    return true;
            }
        }
        return false;
    }

ConcurrentHashMap

JDK7

线程安全

数据结构:ReentrantLock+Segment+HashEntry,一个Segment中包含一个HashEntry数组,每个HashEntry又是一个链表结构

有一个内部类 Segment 相当于维护了一个Segment数组 继承自ReentrantLock

还有个内部类 HashEntry 相当于以前的(Node)

关系如图

ConcurrentHashMap中Segment和HashEntry关系图  .png

分段锁

可以直接锁住第一个Segment ,保证线程安全,性能提升,比synchronized更细粒度,

元素查询

比HashMap慢一点

  1. 第一次Hash定位到Segment
  2. 第二次Hash定位到元素所在的链表的头部

锁:Segment分段锁

  1. Segment继承了ReentrantLock
  2. 锁定操作的Segment,其他的Segment不受影响
  3. 并发度为segment个数,可以通过构造函数指定
  4. 数组扩容不会影响其他的segment

get方法无需加锁

Node的val和next使用volatile修饰,当进行改变的时候会实时响应到主存中去,读线程会去主存中去读,可以读到最新的值

JDK8

数据结构:synchronized+CAS+Node+红黑树

  1. jdk8中对synchronized做了改变 加了自旋和偏移
  2. Node的val和next都用volatile修饰,保证可见性

查找,替换,赋值操作都使用CAS

  • 无锁,性能比较高

锁:锁链表的head节点

  1. 不影响其他元素的读写,锁粒度更细,效率更高
  2. 扩容时,阻塞所有的写操作

并发扩容

一个线程发起扩容的时候,就会通过cas设置sizeCtl属性(volatile修饰),告知其他线程扩容状态变更。创建一个两倍容量的新数组nextTable,单线程完成。

其他线程发现一个线程正在扩容 会进行协助扩容

扩容时候会判断这个值(sizeCtl属性),如果超过阈值就要扩容
  1. 首先根据运算得到需要遍历的次数i,然后利用tabAt方法获得i位置的元素f
  2. 初始化一个forwardNode实例fwd,如果f == null,则在table中的i位置放入fwd
  3. 否则采用头插法的方式把当前旧table数组的指定任务范围的数据给迁移到新的数组中,然后给旧table原位置赋值fwd
  4. 迁移数据至少每次获取16个迁移任务,transferIndex扩容索引,迁移数据前先cas操作该变量,相当于任务头指针(游标的作用)
  5. 直到遍历过所有的节点以后就完成了复制工作,把table指向nextTable,并更新sizeCtl为新数组大小的0.75倍 ,扩容完成。 在此期间如果其他线程的有读写操作都会判断head节点是否为forwardNode节点,如果是就帮助扩容。
ForwardingNode节点
  1. 标记作用,表示其他线程正在扩容,并且此节点已经扩容完毕
  2. 关联了extTable,扩容期间可以通过find方法,访问已经迁移到了nextTable中的数据
  3. 如果遍历到的节点是forward节点,就向后继续遍历,再加上给节点上锁的机制,就完成了多线程的控制。多线程遍历节点, 处理了一个节点,就把对应点的值set为forward,另一个线程看到forward,就向后遍历。这样交叉就完成了复制工作
读操作无锁
  1. Node的val和next使用volatile修饰,读写线程对该变量互相可见
  2. 数组用volatile修饰,保证扩容时被读线程感知

TreeMap

红黑树实现

LinkedHashMap

继承自HashMap

顺序

  1. accessOrder为false 插入顺序保存

  2. accessOrder为true 访问顺序保存,访问会导致顺序变动

    最近访问的元素在最后面

    可以利用这个特性实现LRU(缓存淘汰)

  3. 双向链表 元素直接有指针双向链接

缓存淘汰 动态更新,最新浏览 最后浏览 访问时间

Properties

以key=value 的 键值对的形式进行存储值。 key值不能重复

xml和properties文件相互转化

...