Java从接触到放弃(二十七) ---集合(下)

Day Twenty-Seven

集合的其他内容

Iterator

  • Iterator专门为遍历集合而生,集合并没有提供专门的遍历的方法

  • Iterator实际上就是迭代器设计模式在Java中的实现

  • Iterator的常用的方法:

    • boolean hashNext():判断是否存在另一个可访问的元素
    • Object next():返回要访问的下一个元素
    • void remove():删除上次访问返回的对象
  • 哪些集合可以使用Iterator遍历

    • Collection、List、Set可以;但是Map不可以,需要先转换成Set之后才可以
    • 只要接口或者类提供了iterator()这个方法就可以将元素交给Iterator,进行遍历。
    • 实现Iterator接口的集合类都可以使用迭代器遍历
  • for-each循环和Iterator的联系

    • for-each循环在遍历集合的时候,其底层使用的还是Iterator迭代器
    • 凡是可以使用for-each循环进行遍历的集合,也一定能使用Iterator进行遍历
  • for-each循环和Iterator的区别

    • for-each还能遍历数组,但是Iterator只能遍历集合
    • 在使用for-each遍历集合的时候,不能删除元素,会抛并发修改异常ConcurrentModificationException。
    • 使用迭代器Iterator进行遍历的时候能够删除元素,但是要使用集合在调用iterator()方法时声明的局部变量,用局部变量来直接调用remove()方法,里面也不用写参数。如果直接使用集合去调用remove方法然后传要删除的值得话,也会报并发修改异常。
  • Iterator是一个接口,那么它的实现类在哪?

    在相应的集合实现类中,比如说在ArrayList中存在一个内部类 Itr 实现了Iterator。

  • 为什么Iterator不设计成一个类,而是一个接口

    不同的集合类其底层结构也不相同,迭代的方式也不相同,所以说提供了一个接口,让相应的实现类来实现。

Iterator的原理

  • Iterator接口中包含三个基本方法,next(), hasNext(), remove(),其中对于List的遍历删除只能用Iterator的remove方法。

    public interface Iterator<E> {
        boolean hasNext();
        E next();
        //Java8的新特性:可以通过default在接口中写个方法的实现
        default void remove() {
            throw new UnsupportedOperationException("remove");
        }
        default void forEachRemaining(Consumer<? super E> action) {
            Objects.requireNonNull(action);
            while (hasNext())
                action.accept(next());
        }
    }
    
  • 我们通过ArrayList的Iterator的实现来分析Iterator的原理.

    • 在ArrayList里面有一个迭代器方法,这个方法返回的是一个Itr对象,这个对象实现了iterator方法。

          public Iterator<E> iterator() {
              return new Itr();
          }
      
  • 看ArrayList中实现类Itr:我们主要就看hasNext()、next()、remove()这三个主要的方法。

        private class Itr implements Iterator<E> {
            int cursor;       //下一个返回的位置
            int lastRet = -1; //当前操作的位置
            int expectedModCount = modCount;//这玩意可以理解为版本号,检查List是否有更新
            
            Itr() {}//无参构造
            
            //判断是否有下一个元素
            public boolean hasNext() {
                return cursor != size;
            }
    
            //返回下一个元素
            @SuppressWarnings("unchecked")
            public E next() {
                checkForComodification();
                int i = cursor;//cursor记录的是下一个元素,所以调用next时返回的是cursor对应的元素
                if (i >= size)
                    throw new NoSuchElementException();
                Object[] elementData = ArrayList.this.elementData;//记录需要返回的元素
                if (i >= elementData.length)
                    throw new ConcurrentModificationException();
                cursor = i + 1;//记录下一个元素
                return (E) elementData[lastRet = i];//返回当前元素
            }
    
            //移除元素
            public void remove() {
                if (lastRet < 0)
                    throw new IllegalStateException();
                checkForComodification();//检查是否有更改,remove或者add
    
                try {
                    ArrayList.this.remove(lastRet);//删除当前元素
                    cursor = lastRet;//下一个返回的位置指向当前被删除的元素的位置
                    lastRet = -1;//当前操作的位置
                    expectedModCount = modCount;//保持版本号一致
                } catch (IndexOutOfBoundsException ex) {
                    throw new ConcurrentModificationException();
                }
            }
    
  • 从以上的代码中可以看出,对于Iterator的实现类中主要有cursor,lastRest,expectedModCount这三个变量,其中cursor将记录下一个位置,lastRest记录的是当前的位置,expectedModCount记录没有修改的List的版本号。

  • 在上面的时候我们说到List中在iterator遍历的时候,不能随便添加和删除元素,我们来看一看这是为什么。

    • 在iterator遍历的时候抛出的异常都是checkForComodification()这个方法进行检查的,我们先来看看这个方法的源码。

              final void checkForComodification() {
                  if (modCount != expectedModCount)
                      throw new ConcurrentModificationException();
              }
      
    • 这个源码的意思就是当modCount和expectedModCount不相等的时候就会抛出这个ConcurrentModificationException异常。

  • 为什么不相等呢?

    • 我们从ArrayList的add()和remove()方法的源码入手。

      //add方法
      public boolean add(E e) {
          ensureCapacityInternal(size + 1);  // Increments modCount!!
          elementData[size++] = e;
          return true;
      }
      
      //remove方法
      public E remove(int index) {
          rangeCheck(index);
          modCount++;
          E oldValue = elementData(index);
          int numMoved = size - index - 1;
          if (numMoved > 0)
              System.arraycopy(elementData, index+1, elementData, index, numMoved);
          elementData[--size] = null; // clear to let GC do its work
          return oldValue;
      }
      
  • 从上面的代码中可以看出只要对ArrayList作了添加或删除操作都会增加modCount版本号,这样的意思是在迭代期间,会不断检查modCount和迭代器持有的expectedModCount两者是不是相等,如果不相等就抛出异常了。

  • 这样在迭代器迭代期间不能对ArrayList作任何增删操作,但是可以通过iterator的remove作删除操作,从之前的代码可以看出,在iterator的remove()中有一行代码,expectedModCount = modCount; 这个赋值操作保证了iterator的remove是可用性的。

  • 当然,iterator期间不能增删的根本原因是ArrayList遍历会不准,就像遍历数组的时候改变了数组的长度一样。

ListIterator

  • ListIterator和Iterator的关系
    • ListIterator这个接口继承了Iterator
    • 都可以遍历List
  • ListIterator和Iterator的区别
    • 使用范围不同
      • Iterator可以应用于更多的集合,Set,List和这些集合的子类型。
      • ListIterator只能用于List及其子类型。
    • 遍历顺序不同
      • Iterator只能顺序向后遍历;ListIterator还可以逆序向前遍历
    • Iterator可以在遍历的过程中remove();ListIterator可以在遍历的过程中remove()、add()、set()
    • ListIterator可以定位到当前索引的位置,nextIndex()和previousIndex()可以实现。但是Iterator没有这个功能。
  • 当ListIterator在进行逆序向前遍历的时候,必须要先执行正常的顺序向后遍历,再执行向前遍历,否则的话,直接执行向前遍历的结果就会为null。

Collections工具类

  • 关于集合操作的工具类,好比Arrays,Math
  • 唯一的构造方法private,不允许在类的外部创建对象
  • 提供了大量的static方法,可以通过类名直接调用
public class TestCollections {
    public static void main(String[] args) {
        //给集合快速赋值
        List<Integer> list = new ArrayList<>();
        Collections.addAll(list,20,50,80,90,40,60,10,2);
        System.out.println(list);
        System.out.println("===================================");

        //排序
        Collections.sort(list);
        System.out.println(list);

        //查找元素(元素必须有序)
        //调用Collections的工具方法binarySearch(在哪里找,找什么元素);
        //其返回值是这个元素在集合中的索引位置,
        //是一个int类型的值
        int index = Collections.binarySearch(list, 60);
        System.out.println(index);

        //最大值
        System.out.println("最大值:" + Collections.max(list));

        //最小值
        System.out.println("最小值:" + Collections.min(list));

        //填充集合
        //Collections.fill(哪个集合,全部用几去填充);
        //结果:[0, 0, 0, 0, 0, 0, 0, 0]
        //Collections.fill(list,0);
        //System.out.println(list);

        //复制集合
        //Collections.copy(目的集合,源集合);
        //目的集合的size要 >= 源集合的size
        List<Integer> list2 = new ArrayList<>();
        Collections.addAll(list2,0,0,0,0,0,0,0,0,0,0);
        Collections.copy(list2,list);
        System.out.println(list2);

        //同步集合
        StringBuffer buffer;//线程同步的
        StringBuilder  builder;//线程不同步
        ArrayList<String> arrayList;//线程不安全,在多线程操作会有安全问题
        //Collections.synchronizedList(不安全的集合);其返回值是一个安全的集合
        List<Integer> synchronizedList = Collections.synchronizedList(list);
    }
}

旧的集合类

  • Vector
    • 实现原理和ArrayList相同,功能相同,都是长度可变的数组结构,很多情况下可以互用。
    • 两者的主要区别如下:
      • Vector是早期JDK接口,ArrayList是代替Vector的新接口
      • Vector线程安全,效率低;ArrayList效率高但是不安全。
      • 当大小需要扩展的时候,Vector默认的是直接扩展一倍,ArrayList扩展50%
  • Hashtable类
    • 实现原理和HashMap相同,功能相同,底层都是哈希表结构,查询速度快,很多情况下可以互用。
    • 两者的主要区别如下:
      • Hashtable是早期JDK提供,HashMap是新版JDK提供的
      • Hashtable继承了Dictionary类,HashMap实现Map接口
      • Hashtable线程安全,HashMap线程不安全
      • Hashtable不允许null值,HashMap允许null值

新一代并发集合类

集合类的发展历程
  • 早期集合类Vector、Hashtable都是线程安全的,那么怎么保证线程安全的呢,是使用了synchronized修饰方法。
  • 为了提高性能,使用了ArrayList、HashMap进行替换,虽然说性能好了,但是他们是线程不安全的。 那么怎么样使他们变成线程安全的呢?
    • 使用Collections.synchronizedList(list)、Collections.synchronizedMap(m)解决,底层使用synchronized代码块锁。
    • 虽然也是锁住了所有的代码,但是锁在方法里边,比锁在外面的性能会高一些,因为在进方法的时候本身就是要分配资源的。
  • 在大量并发情况下该如何提高集合的效率和安全呢?
    • 随着技术的更新换代,Java提供了新的线程同步集合类,在java.util.concurrent(JUC)包下面,使用Lock锁或者volatile+CAS的无锁化。
      • ConcurrentHashMap
      • CopyOnWriteArrayList
      • CopyOnWriteArraySet
新一代的并发集合类
ConcurrentHashMap

ConcurrentHashMap:Node数组+链表/红黑树

在这里插入图片描述

  • Java 7中ConcurrentHashMap使用的是分段锁,每一个Segment上只有一个线程可以操作,每一个Segment都是类似HashMap结构,可以扩容,遇到冲突可以转化为链表,但是Segment的长度是固定的,一旦初始化就不能改变。

  • Java 8中ConcurrentHashMap使用的是CASsynchronized锁,机构也变为Node数组+链表/红黑树。它摒弃了Segment分段锁的概念,而是启用了一种全新的方式实现。利用volatile + CAS实现无锁化操作。为了做到并发,又增加了很多辅助的类,例如TreeBin,Traverser等对象内部类。

  • ConcurrentHashMap初始化

    private final Node<K,V>[] initTable() {
        Node<K,V>[] tab; int sc;
        while ((tab = table) == null || tab.length == 0) {
            //如果sizeCtl < 0 ,说明另外的线程执行CAS成功,正在进行初始化。
            if ((sc = sizeCtl) < 0)
                //让出CPU使用权
                Thread.yield(); // lost initialization race; just spin
            else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
                try {
                    if ((tab = table) == null || tab.length == 0) {
                        int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                        @SuppressWarnings("unchecked")
                        Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                        table = tab = nt;
                        sc = n - (n >>> 2);
                    }
                } finally {
                    sizeCtl = sc;
                }
                break;
            }
        }
        return tab;
    }
    

    ConcurrentHashMap的初始化是通过自旋CAS操作实现的,sizeCtl变量的值有如下几个含义:
    (1)-1:说明正在初始化。
    (2)-N:说明有N-1个线程正在进行扩容。
    (3)如果table没有初始化,则表名table初始化大小。
    (4)如果table已经初始化,则表示table容量。

  • put方法

    public V put(K key, V value) {
        return putVal(key, value, false);
    }
    
    /** Implementation for put and putIfAbsent */
    final V putVal(K key, V value, boolean onlyIfAbsent) {
        //key和value不能为空
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            //f = 目标位置元素
            //fh 后面存放目标位置的元素 hash 值
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)
                //数组桶为空,初始化数组桶(自旋 + CAS)
                tab = initTable();
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                //桶内为空,CAS放入,不加锁,成功了就直接break跳出
                if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
                V oldVal = null;
                //使用synchronized加锁加入节点
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        //说明是链表
                        if (fh >= 0) {
                            binCount = 1;
                            //循环加入新的或者覆盖节点
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key, value, null);
                                    break;
                                }
                            }
                        }
                        else if (f instanceof TreeBin) {
                            //红黑树
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }
    
    1. 根据key计算出 hashcode
    2. 判断是否需要进行初始化。
    3. 即为当前key定位出的Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
    4. 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
    5. 如果都不满足,则利用synchronized 锁写入数据。
    6. 如果数量大于TREEIFY_THRESHOLD 则要转换为红黑树。
  • get方法

    public V get(Object key) {
        Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
        //key所在的hash位置
        int h = spread(key.hashCode());
        if ((tab = table) != null && (n = tab.length) > 0 && (e = tabAt(tab, (n - 1) & h)) != null) {
            //如果指定位置元素存在,头结点hash值相同
            if ((eh = e.hash) == h) {
                if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                    //key的hash值相等,key值相等,直接返回元素value
                    return e.val;
            }
            else if (eh < 0)
                //头结点hash值 < 0,说明正在扩容或者是红黑树,则要用find方法进行查找
                return (p = e.find(h, key)) != null ? p.val : null;
            while ((e = e.next) != null) {
                //是链表,进行遍历查找
                if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek))))
                    return e.val;
            }
        }
        return null;
    }
    
    1. 根据 hash值计算位置。
    2. 查找到指定位置,如果头结点就是要找的,直接返回它的 value
    3. 如果头结点hash 值小于 0 ,说明正在扩容或者是红黑树,find查找。
    4. 如果是链表,遍历查找。
CopyOnWriteArrayList
  • CopyOnWriteArrayList:CopyOnWrite+Lock锁

    对于set()、add()、remove()等方法使用ReentrantLocklockunlock来加锁和解锁。读操作不需要加锁(之前集合安全类,即使读操作也要加锁,保证数据的实时一致)。

  • CopyOnWrite容器写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。

在这里插入图片描述

  • CopyOnWrite的缺点
    1. 内存占用问题。因为CopyOnWrite的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)。如果这些对象占用的内存比较大,比如说200M左右,那么再写入100M数据进去,内存就会占用300M,那么这个时候很有可能造成频繁的Yong GC和Full GC。之前我们系统中使用了一个服务由于每晚使用CopyOnWrite机制更新大对象,造成了每晚15秒的Full GC,应用响应时间也随之变长。
      • 针对内存占用问题,可以通过压缩容器中的元素的方法来减少大对象的内存消耗,比如,如果元素全是10进制的数字,可以考虑把它压缩成36进制或64进制。或者不使用CopyOnWrite容器,而使用其他的并发容器,如ConcurrentHashMap。
    2. 数据一致性问题。CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器。
CopyOnWriteArraySet
  • CopyOnWriteArraySet:CopyOnWrite + Lock锁
    • 它是线程安全的无序集合,可以把它理解成线程安全的HashSet。有意思的是,CopyOnWriteArraySetHashSet虽然都继承于共同的父类AbstractSet;但是,HashSet是通过散列表(HashMap)实现的,而CopyOnWriteArraySet则是通过==动态数组(CopyOnWriteArrayList)==实现的,并不是散列表。
    • CopyOnWriteArraySet在CopyOnWriteArrayList的基础上使用了Java的装饰模式,所以底层是相同的。而CopyOnWriteArrayList本质上是一个动态数组队列,所以CopyOnWriteArraySet相当于通过动态数组实现的集合
    • CopyOnWriteArrayList中允许有重复的元素,但CopyOnWriteArraySet是一个集合,所以它不能有重复集合。因此,CopyOnWriteArrayList额外提供了addIfAbsent()和addAllAbsent()这两个添加元素的API,通过这些API来添加元素时,只有当元素不存在时才执行添加操作。

集合常用概念辨析

集合和数组的比较

数组不是面向对象的,存在着明显的缺陷,集合完全弥补了数组的一些缺点,比数组更加灵活实用,可以大大提高软件的开发效率,而且不同的集合框架类可适用于不同场合。比如说:

  1. 数组容量固定且无法动态改变,集合类容量动态改变
  2. 数组能存放基本数据类型和引用数据类型的数据,而集合类中只能放引用数据类型的数据
  3. 数组无法判断其中实际存有多少元素,length只告诉了array的容量;集合可以判断实际存放了多少元素,而对总的容量不关心
  4. 集合有多种数据结构(顺序表、链表、哈希表、树等)、多种特征(是否有序,是否唯一)、不同适用场合(查询快、便于删除、有序),不像数组仅采用顺序表方式
  5. 集合以类的形式存在,具有封装、继承、多态等类的特性,通过简单的方法和属性调用即可实现各种复杂的操作,大大的提高了软件的开发效率。
ArrayList和LinkedList的联系和区别
  • 联系:

    • 都实现了List接口
    • 有序,不唯一(可重复)
  • ArrayList

    • 特点:在内存中分配连续的空间每个空间大小相同,逻辑顺序和物理顺序一致,实现了长度可变的数组
    • 优点:遍历元素和随机访问元素的效率比较高,按照索引查询效率高,直接计算出地址,不需要逐个进行比较,第n个元素的地址=数组首地址+每个元素空间大小*索引
    • 缺点:添加和删除需要大量的移动元素,效率低,按照内容查询效率低

    在这里插入图片描述

  • LinkedList

    • 特点:采用链表存储方式,底层是双向链表。在内存中分配不连续的空间,每个空间大小相同;每个节点分为两部分:数据和指向下一个节点的指针。逻辑顺序和物理顺序不一致。
    • 缺点:遍历和随机访问元素效率低。按照索引查询效率低,只能逐个进行查询,无法计算地址。
    • 优点:插入、删除元素效率比较高(但是前提也是必须先低效率查询才可以。如果说插入和删除的操作发生在头和尾的话,可以减少查询次数)

    在这里插入图片描述

哈希表的原理(HashMap的底层原理)
  • 哈希表的特征

    • 快:查询快、添加快
  • 哈希表的结构

    • 最常用、最容易理解的结构是JDK1.7数组+链表结构
    • JDK1.8改成了数组+链表/红黑树(当链表长度>=8的时候,链表就转换成了红黑树

    在这里插入图片描述

  • 哈希表的添加原理

    • 计算哈希码(hashCode())
    • 计算存储位置(存储位置就是数组的索引)
    • 存入指定位置(要处理冲突,可能重复。需要借助equals()方法进行比较)
  • 哈希表的查询原理和添加的原理是相同

TreeMap的底层原理(红黑树的底层原理)
  • 基本特征

    • 二叉树、二叉查找树、二叉平衡树、红黑树

    在这里插入图片描述

  • 每个节点的结构

    在这里插入图片描述

  • 添加原理

    • 从根节点开始比较
    • 添加过程就是构造二叉平衡树的过程,会自动平衡
    • 平衡离不开比较;外部比较器优先,然后是内部比较器,否则会出错
  • 查询原理和添加原理基本类似

Collection和Collections的区别
  • Collection是Java提供的集合接口,存储一组不唯一,无需的对象。它有两个子接口List和Set。
  • Java中还有一个Collections类,专门用来操作集合类,它提供了一系列静态方法实现对各种集合的搜索、排序、线程安全化等操作。
Vector和ArrayList的联系和区别
  • 实现原理和ArrayList相同,功能相同,都是长度可变的数组结构,很多情况下可以互用。
  • 两者的主要区别如下
    • Vector是早期JDK接口,ArrayList是代替Vector的新接口
    • Vector线程安全效率低下;ArrayList看重速度,不重视安全,线程非安全
    • 在长度需要增加的时候,Vector默认增长一倍,ArrayList增长50%
HashMap和Hashtable的联系和区别
  • 实现原理相同,功能相同,底层都是哈希表,查询速度快,在很多情况下可以互用
  • 两者的主要区别如下:
    • Hashtable是早期JDK提供的接口,HashMap是新版JDK提供的接口
    • Hashtable继承Dictionary类,HashMap实现Map接口
    • Hashtable线程安全,HashMap线程不安全
    • Hashtable不允许null值,HashMap允许null值

a中还有一个Collections类,专门用来操作集合类,它提供了一系列静态方法实现对各种集合的搜索、排序、线程安全化等操作。

Vector和ArrayList的联系和区别
  • 实现原理和ArrayList相同,功能相同,都是长度可变的数组结构,很多情况下可以互用。
  • 两者的主要区别如下
    • Vector是早期JDK接口,ArrayList是代替Vector的新接口
    • Vector线程安全效率低下;ArrayList看重速度,不重视安全,线程非安全
    • 在长度需要增加的时候,Vector默认增长一倍,ArrayList增长50%
HashMap和Hashtable的联系和区别
  • 实现原理相同,功能相同,底层都是哈希表,查询速度快,在很多情况下可以互用
  • 两者的主要区别如下:
    • Hashtable是早期JDK提供的接口,HashMap是新版JDK提供的接口
    • Hashtable继承Dictionary类,HashMap实现Map接口
    • Hashtable线程安全,HashMap线程不安全
    • Hashtable不允许null值,HashMap允许null值
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

菜的跟狗一样

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值