java容器 - Wangxiaoman/tech-note GitHub Wiki
容器类在java语言中是非常重要的,无论是面试还是实际工作中都是重中之重,我们下面就对这个话题深入探讨一下。对于基本实现我们只做简单描述,细节大家可以直接看源码来理解,我们主要的篇幅还是放在实战上。
对于任何事物的理解,我们还是需要从定义出发,我们看一下“Thinking in java” 一书中对容器的描述:通常,我们的程序需要根据程序运行时才知道的一些标准创建新对象。若非程序正式运行,否则我们根本不知道自己到底需要多少数量的对象,甚至不知道它们的准确类型。为了满足常规编程的需要,我们要求能在任何时候、任何地点创建任意数量的对象。为解决这个非常关键的问题,Java提供了容纳对象(或者对象的句柄)的多种方式。
通过定义可以看出容器类解决的核心就是对象的容纳问题,在容器类中最重要组成是Collection和Map,我们接下来看Collection和Map是如何定义的。
- Collection
JDK描述 A collection represents a group of objects, known as its elements. Some collections allow duplicate elements and others do not. Some are ordered and others unordered. 含义 集合是用来代表一组元素,有些元素允许重复有的不允许,有些有序有些无序。
Collection的类图如下
- Map
JDK描述 An object that maps keys to values. A map cannot contain duplicate keys;each key can map to at most one value. 含义 存储KV映射,key为独立且只对应一个value。
Map的类图如下
了解完Collection和Map的定义以及类图中的各类之间的关系,我们下面来实际分析下各种容器类的特性和实现方式。
- ArrayList:底层为数组,支持按照下标访问,访问高效。
- LinkedList:底层为双向链表,只能支持顺序访问,节点的删除和新增比较高效。
- 扩展思考:两种list特性实际就是底层数据结构的特性;两种List适用的场景,ArrayList偏重读的性能,而LinkedList更加偏重写的性能。
- HashSet:HashMap实现
- TreeSet:TreeMap实现
- PriorityQueue:有序列表,实现优先队列(存储对象通过继承java.util.Comparable实现)
- BlockingQueue:juc下的阻塞队列,这里就不扩展了
- HashMap:底层基于哈希表实现,支持快速查找,JDK1.8之前为数组加链表的二维结构实现,当数组hashCode冲突的时候,将值添加到链表上;JDK1.8之后有所调整,当链表长度大于阈值(默认为8),链表转化为红黑树,提高查询效率。
- TreeMap:底层为红黑树实现,有序集合。
- 核心面试点:HashMap的扩容过程,线程是否安全,以及保证线程安全的解决方案。
分析完以上容器类,我们可以看出,容器类就是一个对象仓库;而数据结构就是存储对象的规则,不同的数据结构体现了不同特性的存储和查找规则,其实数据结构才是容器类的核心。
在JDK中容器类体现出来实际是在JVM中的数据存储(内存中),如果是要存储到磁盘上,比如Mysql、RocksDB,那么这些存储方式是否还适用呢?(这个问题我们可以在讲解Mysql的时候再来讨论)
因为篇幅有限,各种容器类的实现细节大家可以通过阅读源码方式理解,让我们更加聚焦于面试点和项目上的实际应用。
在实际面试中,不仅仅是要看候选者对理论的掌握,更重要的是对于原理的理解以及灵活的应用,下面就是几个实际例子,逐步增加同学们对容器类的理解
我们先构造一个整型的ArrayList如下:
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(3);
list.add(1);
list.add(1);
list.add(10);
然后我们来提出几个问题:
- 我们怎样删除ArrayList中的值为10的元素,下面两种方式是否有问题?
list.remove(10);
list.remove(new Integer(10));
我们来具体分析一下,ArrayList中的remove方法有两个: E remove(int index); boolean remove(Object o); 那么如果入参为int,就会调用第一个方法直接根据数组下标来删除数据,然后调用native方法System.arrayCopy将数组下面的数据上移,所以根据分析来看第一个函数会报数组越界。如果想删除List中的整型对象,就要将基本数据类型int值封装为Integer,这样会调用第二个方法。在实际项目中,在ArrayList中存储Integer元素的时候,一定要注意这个问题。
- 集合遍历过程中,怎样删除对象? 下面有几种删除方式,大家先花几分钟看看哪种方式是合理的。
// 1.基于数组下标循环
for (int i = 0; i < list.size(); i++) {
if (list.get(i) == 1) {
list.remove(1);
}
}
// 2.foreach
list.forEach(i -> {
if(i == 1){
list.remove(new Integer(1));
}
});
// 3.返回一个新列表
List<Integer> newList = new ArrayList<>(list.size());
for (int i = 0; i < list.size(); i++) {
if (list.get(i) % 5 == 0) {
newList.add(list.get(i));
}
}
return newList;
// 4.迭代器
Iterator<Integer> iter = list.iterator();
while(iter.hasNext()){
Integer element = iter.next();
if(element == 1){
iter.remove();
}
}
如果你的答案是前两个,那么你需要对 list 的删除详细了解一下了,我们下面来逐次分析。
第一种方式,直接基于列表的下标删除。上面我们已经介绍了ArrayList的remove过程,这个删除方法的问题就交给大家自行分析了。(习题1)
第二种为foreach中删除,在ArrayList中有一个变量叫做modCount,对列表的更新、删除操作,都会将 modCount 加1,主要目的是为了避免对当前列表并发的新增和删除元素(无论是当前线程和其他线程)。 下面我们可以看一下foreach的代码:
public void forEach(Consumer<? super E> action) {
Objects.requireNonNull(action);
final int expectedModCount = modCount;
@SuppressWarnings("unchecked")
final E[] elementData = (E[]) this.elementData;
final int size = this.size;
for (int i=0; modCount == expectedModCount && i < size; i++) {
action.accept(elementData[i]);
}
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}
从代码中我们可以看到,进入方法后 expectedModCount 用modCount 赋值,当执行accept前后都对 modCount 和 expectedModCount 做了比较,如果不相等,那么会抛出异常,因为 accept 中调的 list 的 add、remove 方法都会将 modCount 加1,所以在删除之后两值不等,就会抛出异常。
第三种其实是利用副本的方式来实现,只不过多浪费了存储空间。
第四种迭代器的方式是比较通用的解法,ArrayList中有一个内部类Itr实现了Iterator,Iterator中维护了当前位置的索引cursor。大家可以查看源码,这里就不细聊。
问题描述:怎样实现一个容器类,实现一个key对应多个value? 代码示例如下:
put('key',1);
put('key',2);
put('key',3);
put('key',2);
get('key'); // 结果为 [1,2,3]
问题分析: 我们通过自问自答的方式,逐步深入来分析这个问题。
- 问:需要构造的数据存储结构属于哪类容器? 答:KV映射(Map)。
- 问:这个结构的value有何特点? 答:value为一个有序不重复集合(TreeSet)。
- 问:该结构如何设计? 答:通过上面的分析,可以得出结论,将容器类组合起来就可以实现这个功能。
实际解决:
import java.util.HashMap;
import java.util.Map;
import java.util.TreeSet;
public class MutiHashMap<K, V> {
private Map<K, TreeSet<V>> map = new HashMap<>();
public TreeSet<V> get(K k) {
return map.get(k);
}
public TreeSet<V> put(K k, V v) {
TreeSet<V> set = map.getOrDefault(k, new TreeSet<V>());
set.add(v);
return map.put(k, set);
}
public TreeSet<V> remove(K k){
return map.remove(k);
}
public static void main(String[] args) {
MutiHashMap<String, Integer> mutiMap = new MutiHashMap<>();
mutiMap.put("kv1", 1);
mutiMap.put("kv1", 2);
mutiMap.put("kv1", 3);
mutiMap.put("kv1", 2);
System.out.println("mutiMap kv1:" + mutiMap.get("kv1"));
}
}
Guava 中的 collect 包下实现了各种类型的 MutiMap,大家可以看一下增深下理解。
问题描述 有一份ip文件,大概有200M以内(每一段至少覆盖100个ip),每天更新一次,格式描述如下(只截取几行)。
开始ip | 结束ip | 地址 | 运营商 |
---|---|---|---|
0.0.0.0 | 0.255.255.255 | 保留地址 | 无 |
1.0.0.0 | 1.0.0.255 | 美国 | 亚太互联网络信息中心 |
1.0.1.0 | 1.0.3.154 | 中国福建省福州市 | 中国电信 |
1.0.3.155 | 1.0.3.255 | 中国福建省泉州市 | 中国电信 |
1.0.4.0 | 1.0.7.255 | 澳大利亚墨尔本 | Goldenit有限公司 |
需要提供一个高可用,高并发的服务,能够根据具体的ip值获取相应的地址和运营商,并且要说明该设计能提供的并发量和理由。请求接口示例如下:
请求接口:http://xxxx?ip=1.0.1.2
返回数据为:
{
"address":"中国福建省",
"operator":"中国电信"
}
问题分析
- 需求分析:要求服务高可用,高并发。
- 数据特征:ipv4可以转换为数字来存储;数据连续有序并且数据之前不重合;数据文件不大;每天更新一次。
- 问题分析,大家看了需求和数据的特征,可以先自己思考一下,不妨自己有了一些思路,再来看我的分析。
实际解决 最简方案:将ip转化为Number,直接将数据写入mysql,使用开始ip建立索引。这样服务的并发量基本就是mysql所能承受的并发量,按照题目表数据量大概为百万级,考虑磁盘存储,查询条件又是一个范围查询,性能不会很好,个人估计也就是几百的量级。(这只是一个估测,因为机器配置和性能对其影响很大,也先不考虑数据库的缓存等因素,大家可以自己建表来实践一下)。
简单方案升级:不使用Mysql,我们将ip段的连续数据打散,都存到HashMap中,根据数据分析,基于每段至少覆盖100个ip,那么存储需要占10G(100M * 100)左右的内存。这种方式是典型的以空间换时间,内存存储加上HashMap的高效查询,支持万级QPS不是梦
继续升级:上面的方法虽然性能不错,但是资源耗费严重,有没一些其他的方案呢,这个数据连续有序的特性又要怎样利用呢?List不就是最典型的连续存储嘛,而且数据写少读多,那我们不妨就用ArrayList来试一下。 先将文件顺序写入,然后根据开始ip做排序(文件本身有序则不需要排序,保证顺序写入ArrayList即可);ip查询的时候,我们通过二分来查找,该ip 属于[开始ip,结束ip]范围,那么返回该节点对象,否则为空。 在这种方案下,每次查询的时间复杂度是O(logN),存储空间基本和文件体积一致,基于内存来存储,性能比上一个方法不会有太大的差距。(如果我们的数据不是每天更新一次,要提供接口实时更新,当前的结构是否还合适-习题二)
下面为具体的代码实现:
import java.util.ArrayList;
import org.springframework.util.CollectionUtils;
import lombok.Data;
public class IpList {
private static ArrayList<Ip> ipList = new ArrayList<>();
public static void load() {
ipList.add(new Ip("0.0.0.0", "0.255.255.255", "保留地址", "无"));
ipList.add(new Ip("1.0.0.0", "1.0.0.255", "美国", "亚太互联网络信息中心"));
ipList.add(new Ip("1.0.1.0", "1.0.3.154", "中国福建省福州市","中国电信"));
ipList.add(new Ip("1.0.3.155", "1.0.3.255", "中国福建省泉州市","中国电信"));
ipList.add(new Ip("1.0.4.0", "1.0.7.255", "澳大利亚墨尔本","Goldenit有限公司"));
ipList.add(new Ip("1.0.9.0", "255.255.255.255", "其他","其他"));
}
public static Ip search(String ip) {
long longIp = Ip.ip2Long(ip);
if(CollectionUtils.isEmpty(ipList)){
return null;
}
int index = binarySearch(ipList, 0, ipList.size(), longIp);
if(index >= 0){
return ipList.get(index);
}
return null;
}
private static int binarySearch(ArrayList<Ip> ips, int fromIndex, int toIndex, long ip) {
int low = fromIndex;
int high = toIndex - 1;
while (low <= high) {
int mid = (low + high) >>> 1;
Ip midIp = ips.get(mid);
// 该ip如果大于当前节点的结束ip,那么向后搜索
if (midIp.getEndIp() < ip)
low = mid + 1;
// 该ip如果小于当前节点的开始ip,那么向前搜索
else if (midIp.getBeginIp() > ip)
high = mid - 1;
else
return mid; // key found
}
return -(low + 1); // key not found.
}
public static void main(String[] args) {
load();
System.out.println(search("243.12.1.1"));
System.out.println(search("0.0.0.1"));
System.out.println(search("1.0.1.0"));
System.out.println(search("1.0.2.255"));
System.out.println(search("1.0.8.0"));
}
}
@Data
class Ip {
public Ip(String beginIpStr, String endIpStr, String address, String operator) {
this.beginIp = ip2Long(beginIpStr);
this.endIp = ip2Long(endIpStr);
this.address = address;
this.operator = operator;
}
private long beginIp;
private long endIp;
private String address;
private String operator;
// ip 转换
public static Long ip2Long(final String ip) {
Long iplong = null;
try {
final String[] ipNums = ip.split("\\.");
iplong = (Long.parseLong(ipNums[0]) << 24) + (Long.parseLong(ipNums[1]) << 16)
+ (Long.parseLong(ipNums[2]) << 8) + (Long.parseLong(ipNums[3]));
} catch (Exception e) {
e.printStackTrace();
}
return iplong;
}
}
- ArrayList、HashMap是工作中最长用到的容器类,可以从这两个类的源码开始学习。
- 理解容器类的核心是理解各种数据结构的特点。
- 在实际容器类的使用中,目标一般是空间占得更小,读取和写入速度更快,但是往往不能够都满足,那就需要根据服务的特点做均衡。
- 死记硬背不可取,深入理解和灵活应用才是硬道理。
- 在集合类删除中,分析ArrayList第一种删除方式的问题。
- 在ip服务中,如果ip库数据会实时变化,那么ArrayList是否还合适呢?我们又应该如何处理?如果在这个前提下我们还有ip段的查询,比如ip从 0.0.0.0->3.0.0.255 那我们又该怎么处理?(提醒一下:Redis中有一种数据类型比较匹配;Guava中也有一些容器类可以利用)