Java面试题系列(二)——Java集合

1. Collection 和 Collections

  • Collection是集合类的上级接口,继承他的接口主要有Set 和List.
  • Collections是针对集合类的一个帮助类,他提供一系列静态方法实现对各种集合的搜索、排序、线程安全化等操作。

2. 常用的集合

image.png
Collection 接口的接口对象的集合(单列集合) 
├——List 接口:元素按进入先后有序保存,可重复 
│     ├ LinkedList 接口实现类,链表,插入删除,没有同步,线程不安全 
│     ├ ArrayList 接口实现类,数组, 随机访问,没有同步,线程不安全 (Collections.synchronizedList(new ArrayList<>());copyOnWriteArrayList是线程安全的)
│     └ Vector 接口实现类 数组,同步,线程安全
│         └ Stack 是Vector类的实现类 
└——Set 接口:仅接收一次,不可重复,并做内部排序 
│     └HashSet 使用hash表(数组)存储元素 
│         └ LinkedHashSet 链表维护元素的插入次序 
└ —————TreeSet 底层实现为二叉树,元素排好序

Map 接口键值对的集合 (双列集合) 
├———Hashtable 接口实现类,同步,线程安全 
├———HashMap 接口实现类,没有同步,线程不安全 
│         ├ LinkedHashMap 双向链表和哈希表实现 
│         └ WeakHashMap 
├ ——–TreeMap 红黑树对所有的key进行排序 
└———IdentifyHashMap
image.png

3. ArrayList扩容机制

  • 创建ArrayList对象时,若未指定集合容量,集合默认容量为0;
  • 当集合对象调用add方法存储数据时,进行初始化容量为10
  • 集合初始化后,再次调用add方法,先将集合扩大1.5倍,如果仍然不够,新长度为传入集合大小。并调用Arrays.copyOf方法将elementData数组指向新的长度为扩容后长度的内存空间
  • 若使用addAll方法添加元素,则初始化大小为10和添加集合长度的较大值

4. 数组(Array)和列表(ArrayList)的区别

  • Array可以包含基本类型和对象类型,ArrayList只能包含对象类型。
  • Array大小是固定的,ArrayList的大小是动态变化的。
  • ArrayList提供了更多的方法和特性,比如:addAll(),removeAll(),iterator()等等。

5. HashSet如何保证元素唯一性

  HashSet集合的底层数据结构是哈希表。哈希表的存储依赖两个方法:hashCode()和equals()。首先判断对象的hashCode()哈希值是否相同:

  • 如果不同,就直接添加到集合中。
  • 如果相同,就继续执行equals()方法。
    • 如果equals()方法返回true,说明元素重复了。就不添加。
    • 如果equals()方法返回false,说明元素没有重复的,就添加到集合中。

6. 为什么重写equals还要重写hashcode?

  equals()方法来自object对象,默认比较的是对象的引用是否指向同一块内存地址,重写的目的是为了比较两个对象的value值是否相等。
  如果我们对一个对象重写了euqals,意思是只要对象的成员变量值都相等那么euqals就等于true,但不重写hashcode,那么我们再new一个新的对象,当原对象.equals(新对象)等于true时,两者的hashcode却是不一样的,由此将产生了理解的不一致,如在存储散列集合时(如Set类),将会存储了两个值一样的对象,导致混淆,因此,就也需要重写hashcode。
为了保证这种一致性,必须满足以下两个条件:

  • 当obj1.equals(obj2)为true时,obj1.hashCode() == obj2.hashCode()必须为true
  • 当obj1.hashCode() == obj2.hashCode()为false时,obj1.equals(obj2)必须为false

7. map的分类和常见的情况

java.util.Map:它有四个实现类,分别是HashMap、Hashtable、LinkedHashMap 和TreeMap.

  • Hashmap 是一个最常用的Map,它根据键的HashCode值存储数据,根据键可以直接获取它的值,具有很快的访问速度,遍历时,取得数据的顺序是完全随机的。HashMap最多只允许一条记录的键为Null,允许多条记录的值为 Null;HashMap不支持线程的同步,即任一时刻如果有多个线程同时写HashMap,可能会导致数据的不一致。如果需要同步,可以用 Collections的synchronizedMap方法使HashMap具有同步的能力,或者使用ConcurrentHashMap。
  • Hashtable与 HashMap类似,它继承自Dictionary类,不同的是:它不允许记录的键或者值为空;它支持线程的同步,即任一时刻只有一个线程能写Hashtable,因此也导致了 Hashtable在写入时会比较慢。
  • LinkedHashMap 是HashMap的一个子类,保存了记录的插入顺序,在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的.也可以在构造时用带参数,按照应用次数排序。在遍历的时候会比HashMap慢,不过有种情况例外,当HashMap容量很大,实际数据较少时,遍历起来可能会比LinkedHashMap慢,因为LinkedHashMap的遍历速度只和实际数据有关,和容量无关,而HashMap的遍历速度和他的容量有关。
  • TreeMap实现SortMap接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator 遍历TreeMap时,得到的记录是排过序的。
    小结:一般情况下,我们用的最多的是HashMap,在Map中插入、删除和定位元素,HashMap 是最好的选择。但如果您要按自然顺序或自定义顺序遍历键,那么TreeMap会更好。如果需要输出的顺序和输入的相同,那么用LinkedHashMap可以实现,它还可以按读取顺序来排列.

8. HashMap

  • 哈希表:根据关键码值(key value)而直接进行访问的数据结构。也就是说,它通过关键码值映射到表中一个位置来访问记录,以加快查找速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。

  • 哈希:把任意长度的输入通过哈希算法映射成固定长度的输出。

  • 哈希冲突(无法避免):计算得到的哈希值相同。
    解决方法:
    1)开放定址法
    2)再哈希法:双哈希法计算
    3)链址法:HashMap实现方式,next指针连接Node
    4)建立公共溢出区:建立基本表和溢出表,哈希值相同的直接放到溢出表
    哈希算法要求:
    1)高效,能够处理长文本
    2)不能逆推原文
    3)尽量分散,减少哈希冲突

  • HashMap

    • 每个数据单元为一个Node结构,包含key,value,hash,next四个字段
    • 采用懒加载机制,即在进行put操作时才真正构建table数组。
    • 初始长度为16,默认负载因子为0.75,当HashMap的长度达到16*0.75=12时,就会触发扩容流程,每次扩容为原来的2倍(碰撞的概率低)。
    • 允许第一个位置的key为空,允许value为空
    • 线程不安全,导致cpu100%:jdk7链表成环,jdk8红黑树父子节点成环
    • JDK1.7:数组+链表;JDK1.8:数组+链表+红黑树(链表长度大于8且table大于64转为红黑树,红黑树节点个数小于6转为链表)


      image.png

      image.png
  • 红黑树(自平衡二叉查找树)特性:
    1)每个结点是黑色或者红色。
    2)根结点是黑色。
    3)每个叶子结点(NIL)是黑色。 [注意:这里叶子结点,是指为空(NIL或NULL)的叶子结点!]
    4)如果一个结点是红色的,则它的子结点必须是黑色的。
    5)每个结点到叶子结点NIL所经过的黑色结点的个数一样的。

  • HashMap的get流程:
    1)首先会判断数组是否不等于null,或者数组的长度是否大于0,如果不满足,就说明HashMap里没有数据,直接返回null。
    2)通过 hash & (table.length - 1)获取该key对应的数据节点的hash槽;
    3)判断首节点是否为空,为空则直接返回空;
    4)再判断首节点.key是否和目标值相同,相同则直接返回(首节点不用区分链表还是红黑树);首节点.next为空,则直接返回空;
    5)首节点是树形节点,则进入红黑树数的取值流程,并返回结果;
    6)否则就会进入一个do while循环进行查询链表。并返回结果;

  • HashMap的put流程:
    1)如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold
    2)如果key为null时被放在了tab下标为0的位置.
    3)根据hash值来确认存放的位置。如果当前位置是空直接添加到table中
    4)如果在首结点与我们待插入的元素有相同的hash和key值,则先记录。
    5)如果首结点的类型是红黑树类型,则按照红黑树方法添加该元素
    6)如果首结点类型为链表类型,遍历到末尾时,先在尾部追加该元素结点。当遍历的结点数目大于8时,则采取树化结构。
    7)modCount++;如果集合在被遍历期间如果内容发生变化则++modCount,只能检测并发修改的bug,不能保证线程安全(ABA,祥见CAS)
    8)当结点数+1大于threshold时,则进行扩容

9. Hashmap扩容原理

  e.hash & oldCap,就是用于计算位置b到底是0还是1用的,只要其结果是0,则新散列下标就等于原散列下标,否则新散列坐标要在原散列坐标的基础上加上原table长度。


image.png
  • 触发扩容时机:
    1)当new完HashMap之后,第一次往HashMap进行put操作的时候,首先会进行扩容。
    2)当HashMap的使用的桶数达到总桶数*加载因子的时候会触发扩容;
    3)当某个桶中的链表长度达到8进行链表扭转为红黑树的时候,会检查总桶数是否小于64,如果总桶数小于64也会进行扩容;

10. Hashmap拓展问题

  • 为什么JDK1.8采用红黑树存储Hash冲突的元素?
    红黑树本质上是一棵二叉查找树,但它在二叉查找树的基础上增加了着色和相关的性质使得红黑树相对平衡,从而保证了红黑树的查找、插入、删除的时间复杂度最坏为O(log n)。能够加快检索速率。

  • 为什么在长度小于8时使用链表,不一直使用红黑树?
    桶中元素的插入只会在hash冲突时发生,而hash冲突发生的概率较小,一直维护一个红黑树比链表耗费资源更多,在桶中元素量较小时没有这个必要。

  • 为什么要使用红黑树而不使用AVL树?
    红黑树与AVL树,在检索的时候效率差不多,都是通过平衡来二分查找。但红黑树不像AVL树一样追求绝对的平衡,红黑树允许局部很少的不完全平衡,这样对于效率影响不大,但省去了很多没有必要的调平衡操作,AVL树调平衡有时候代价较大,所以效率不如红黑树。

  • 为什么数组容量必须是2次幂?
    索引计算公式为i = (n - 1) & hash,如果n为2次幂,那么n-1的低位就全是1,而扩容后只有一位差异,也就是多出了最左位的1,这样在通过 (length-1) &hash的时候,只要hash对应的最左边的那一个差异位为0,就能保证得到的新的数组索引和老数组索引一致(高效的数据迁移,大大减少了之前已经散列良好的老数组的数据位置重新调换),哈希值进行与操作时可以保证低位的值不变,如果低位值为1,则表示该位置可以插入值,从而保证分布均匀,效果等同于hash%n,但是位运算比取余运算要高效的多。


    image.png
  • 为什么单链表转为红黑树要求桶内的元素个数大于8?
    当hashCode离散性很好的时候,树型bin用到的概率非常小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度会达到阈值。但是在随机hashCode下,离散性可能会变差,然而JDK又不能阻止用户实现这种不好的hash算法,因此就可能导致不均匀的数据分布。不过理想情况下随机hashCode算法下所有bin中节点的分布频率会遵循泊松分布,而一个bin中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。
    同理,少于6就从红黑树转回单链表是为了节省维护一个树的资源消耗,而选择6作为临界值,是因理想情况下一个bin中元素个数达到6的概率是0.00001316,达到7的概率为0.00000094,二者跨度较大,可以减小树和链表之间频繁转化的可能性。

  • 为什么jdk1.8将头插法改成尾插法?
    JDK1.7中扩容时,每个元素的rehash之后,都会插入到新数组对应索引的链表头,所以这就导致原链表顺序为A->B->C,扩容之后,rehash之后的链表可能为C->B->A,元素的顺序发生了变化。在并发场景下,扩容时可能会出现循环链表的情况。而JDK1.8从头插入改成尾插入元素的顺序不变,避免出现循环链表的情况。

11. ConcurrentHashMap

  • JDK1.7:Segment数组+HashEntry


    image.png

1)HashEntry中value,以及next(链表)都是 volatile 修饰的,保证了获取时的可见性。
2)原理上来说:ConcurrentHashMap 采用了分段锁技术,其中 Segment 继承于 ReentrantLock。不会像HashTable那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap 支持 CurrencyLevel (Segment 数组数量,默认为16)的线程并发。每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。

  • JDK1.8:Node +CAS+Synchorized+volatile
  • 对比Java7 和Java8 的异同和优缺点
    1)数据结构不同
    • Java 7采用数组+链表来实现,而 Java 8 中的 ConcurrentHashMap 使用数组 + 链表 + 红黑树
      2)并发度
    • Java 7 中,每个 Segment 独立加锁,最大并发个数就是 Segment 的个数,默认是 16。
    • Java 8 中,锁粒度更细,理想情况下 table 数组元素的个数(也就是数组长度)就是其支持并发的最大个数,并发度比之前有提高。
      3)保证并发安全的原理
    • Java 7 采用 Segment 分段锁来保证安全,而 Segment 是继承自 ReentrantLock。
    • Java 8 中放弃了 Segment 的设计,采用 Node + CAS + synchronized+volatile 保证线程安全。
      4)遇到 Hash 碰撞
    • Java 7 在 Hash 冲突时,会使用拉链法,也就是链表的形式。
    • Java 8 先使用拉链法,在链表长度超过一定阈值时,将链表转换为红黑树,来提高查找效率。
      5)查询时间复杂度
    • Java 7 遍历链表的时间复杂度是 O(n),n 为链表长度。
    • Java 8 如果变成遍历红黑树,那么时间复杂度降低为 O(logn),n 为树的节点个数。

12. 线程安全的Map

  • HashTable
  • SynchronizedMap:加了一个对象锁,每次操作hashmap都需要先获取这个对象锁
  • ConcurrentHashMap:线程安全是通过cas+synchronized+volatile来实现的
  • ConcurrentSkipListMap: 通过跳表来实现的高并发容器并且这个Map是有序排序的,根据key来排序

13. HashMap和Hashtable 的区别

JDK 1.8 中 HashMap 和 Hashtable 主要区别如下:

  • 父类不同。HashMap继承自AbstractMap;Hashtable继承自Dictionary。
  • 线程安全性不同。HashMap线程不安全;Hashtable 中的方法是Synchronized的。
  • HashMap最多只允许一条记录的键为Null,允许多条记录的值为 Null;Hashtable键和值都不允许为空。
  • 默认初始大小和扩容方式不同。HashMap默认初始大小16,容量必须是2的整数次幂,扩容时将容量变为原来的2倍;Hashtable默认初始大小11,扩容时将容量变为原来的2倍加1。
  • 迭代器不同。HashMap的Iterator是fail-fast迭代器;Hashtable还使用了enumerator迭代器。
  • hash的计算方式不同。HashMap计算了hash值;Hashtable使用了key的hashCode方法。
  • 是否有contains方法。HashMap没有contains方法;Hashtable包含contains方法,类似于containsValue。

14. TreeMap底层实现

  • TreeMap 的实现就是红黑树数据结构,也就说是一棵自平衡的排序二叉树,这样就可以保证快速检索指定节点。
  • 红黑树的插入、删除、遍历时间复杂度都为O(lgN),所以性能上低于哈希表。但是哈希表无法提供键值对的有序输出,红黑树因为是排序插入的,可以按照键的值的大小有序输出。

15. HashMap和TreeMap

  • HashMap基于散列桶(数组和链表)实现;TreeMap基于红黑树实现。
  • HashMap不支持排序;TreeMap默认是按照Key值升序排序的,可指定排序的比较器,主要用于存入元素时对元素进行自动排序。
  • HashMap大多数情况下有更好的性能,尤其是读数据。在没有排序要求的情况下,使用HashMap。
  • 都是非线程安全。

16. Java中集合遍历的方式

  • 经典for循环方式:Set不会将元素存储为基于索引的元素。 因此这种方法不能用于所有集合。
  • 迭代器
  • 加强的for循环for(Object item: class):加强for循环实际上在背后使用的是迭代器。
  • forEach方法:将迭代代码封装在集合本身中,因此程序员不必为迭代集合编写代码。
  • listNames.forEach(name -> System.out.println(name));

17. Iterator和ListIterator的区别

  迭代器定义:Iterator提供了统一遍历操作集合元素的统一接口, Collection接口实现Iterable接口,每个集合都通过实现Iterable接口中iterator()方法返回Iterator接口的实例, 然后对集合的元素进行迭代操作.

  • Iterator可用来遍历Set和List集合,但是ListIterator只能用来遍历List。
  • Iterator对集合只能是前向遍历,ListIterator既可以前向也可以后向。
  • ListIterator实现了Iterator接口,并包含其他的功能,比如:增加元素,替换元素,获取前一个和后一个元素的索引等等。

18. 快速失败(fail-fast)和安全失败(fail-safe)的区别

  • 快速失败(fail—fast)
    在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增加、删除、修改),则会抛出ConcurrentModificationException。如HashMap
    • 原理:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。
    • 注意:这里异常的抛出条件是检测到 modCount!=expectedmodCount 这个条件。如果集合发生变化时修改modCount值刚好又设置为了expectedmodCount值(ABA问题),则异常不会抛出。因此,不能依赖于这个异常是否抛出而进行并发操作的编程,这个异常只建议用于检测并发修改的bug。
    • 场景:java.util包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改)。
  • 安全失败(fail—safe)
    采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。
    • 原理:由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发Concurrent Modification Exception。
    • 缺点:基于拷贝内容的优点是避免了Concurrent Modification Exception,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。
    • 场景:java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改。
    • 总结:快速失败和安全失败是对迭代器而言的。 快速失败:当在迭代一个集合的时候,如果有另外一个线程在修改这个集合,就会抛出ConcurrentModification异常,java.util下都是快速失败。 安全失败:在迭代时候会在集合二层做一个拷贝,所以在修改集合上层元素不会影响下层。在java.util.concurrent下都是安全失败

19. 为什么集合类没有实现Cloneable和Serializable接口?

  克隆(cloning)或者是序列化(serialization)的语义和含义是跟具体的实现相关的。因此,应该由集合类的具体实现来决定如何被克隆或者是序列化。

  • 实现Serializable序列化的作用:将对象的状态保存在存储媒体中以便可以在以后重写创建出完全相同的副本;按值将对象从一个应用程序域发向另一个应用程序域。
  • 实现 Serializable接口的作用就是可以把对象存到字节流,然后可以恢复。要网络传输就得转为字节流,所以在分布式应用中,就得实现序列化。如果你不需要分布式应用,那就没必要实现实现序列化。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,014评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,796评论 3 386
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,484评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,830评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,946评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,114评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,182评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,927评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,369评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,678评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,832评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,533评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,166评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,885评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,128评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,659评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,738评论 2 351

推荐阅读更多精彩内容

  • 文章目录集合容器概述什么是集合集合的特点集合和数组的区别使用集合框架的好处常用的集合类有哪些?List,Set,M...
    灬佐手边阅读 341评论 0 1
  • 什么是集合 集合框架:用于存储数据的容器。 集合框架是为表示和操作集合而规定的一种统一的标准的体系结构。 任何集合...
    Java__JJ阅读 263评论 0 1
  • Java集合类可用于存储数量不等的对象,并可以实现常用的数据结构如栈,队列等,Java集合还可以用于保存具有映射关...
    小徐andorid阅读 1,925评论 0 13
  • 集合框架 JCF(Java collections framework),也就是java集合框架 包括: 集合(C...
    jection阅读 2,321评论 0 6
  • 刚刚经历过秋招,看了大量的面经,顺便将常见的Java集合常考知识点总结了一下,并根据被问到的频率大致做了一个标注。...
    dybaby阅读 285评论 0 3