数组与集合
在java语言中,数组可以存储基本类型数据和引用类型数据。既然如此,仅使用数组就可以存储任何类型数据,为什么还要特意弄出集合的概念来呢?来看这样一个例子:
public class Student {
private String name;
private Integer age;
public Student() {
super();
}
public Student(String name, Integer age) {
super();
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
@Override
public String toString() {
return "Student [name=" + name + ", age=" + age + "]";
}
}
public class Test {
public static void main(String[] args) {
Student[] arr = new Student[3];
arr[0] = new Student("张三", 20);
arr[1] = new Student("李四", 21);
arr[2] = new Student("王五", 22);
print(arr);
System.out.println("================");
arr = add(arr, new Student("赵六", 23));
print(arr);
}
public static Student[] add(Student[] arr, Student... stu) {
Student[] result = new Student[arr.length + stu.length];
for(int i = 0; i < arr.length; i++) {
result[i] = arr[i];
}
for(int i = 0; i < stu.length; i ++) {
result[arr.length + i] = stu[i];
}
return result;
}
public static void print(Student[] arr) {
for(int i = 0; i < arr.length; i++ ) {
System.out.println(arr[i].getName() + ": " + arr[i].getAge());
}
}
}
由于数组的长度是创建的时候就已固定的,上面代码中向数组arr中添加3条数据之后,想要继续添加第4条数据,需要怎么做呢?
这里的做法是调用add()方法,该方法先创建一个长度更大的新数组,把旧数组中的数据拷贝到新数组中,然后再向新数组中添加新数据。
为了向数组中添加数据,就需要编写像这样的add()方法,而且很多不同的程序员很可能会写出相同add()方法,这样就是重复造轮子了,对开发很不友好。于是集合出现了。
来看集合是怎么处理上面这个问题:
public class Test{
public static void main(String[] args) {
List<Student> list = new ArrayList<>();
list.add(new Student("张三", 20));
list.add(new Student("李四", 21));
list.add(new Student("王五", 22));
list.add(new Student("赵六", 23));
System.out.println(list);
}
}
使用集合就没有了创建时候的必须给定长度的限制,需要向集合里添加数据,直接调用add()方法就行了。那么集合和数组是两种完全不同的数据结构吗?带着这个疑问来学习集合吧!
相较于数组,集合有下面特点:
- 只能存储引用数据类型(基本数据类型会自动装箱)
- 长度不固定(集合大小会随着元素的添加而增加)
Collection
作为集合体系的根接口,Collection中的方法是子类实现中所共有的。
public interface Collection<E> extends Iterable<E>
常用方法
public class Test{
public static void main(String[] args) {
Collection a1 = new ArrayList();
a1.add("a");
a1.add("b");
a1.add("c");
Collection a2 = new ArrayList();
a2.add("a");
a2.add("b");
a2.add("c");
Collection a3 = new ArrayList();
a3.add("a");
a3.add("c");
a3.add("b");
System.out.println(a1.equals(a2)); // true (集合对象之间的比较)
System.out.println(a1.equals(a3)); // false (集合对象中元素插入的顺序也必须一致)
System.out.println(a1.contains("a")); // true (集合对象中是否包含元素)
System.out.println(a1.contains("e")); // false
Object[] objects = a1.toArray(); // 集合转换成数组
a2.clear(); // 清空集合对象
System.out.println(a2.isEmpty()); // true (判断集合对象是否为空)
System.out.println(a3.remove("a")); // true (移除集合对象中的元素)
System.out.println(a1.size()); // 3 (集合对象的大小)
System.out.println(a1.containsAll(a3)); // true (判断集合对象是否包含传入集合对象的所有元素)
a1.addAll(a3); // 添加传入集合对象中的所有元素(返回true/false)
System.out.println(a1); // [a, b, c, c, b]
a1.add(a3); // 添加一个是集合对象的元素(返回true/false)
System.out.println(a1); // [a, b, c, c, b, [c, b]]
a1.removeAll(a3); // 删除与传入集合对象包含元素有交集的元素(返回true/false)
System.out.println(a1); // [a, [c, b]]
a1.retainAll(a3); // 留下与传入集合对象包含元素的有交集的元素(返回true/false)
System.out.println(a1); // []
}
}
上面示例的用法,添加的元素用的都是java提供的String类型。如果向集合中添加自己定义的类型的元素,使用equals、contains、remove等方法就会出现问题。看下面一段代码:
public class Test{
public static void main(String[] args) {
Collection a1 = new ArrayList();
a1.add(new Student("张三", 20));
a1.add("abc");
a1.add(123);
System.out.println(a1.remove(new Student("张三", 20))); //false
System.out.println(a1); //[Student [name=张三, age=20], abc, 123]
}
}
可以看到,集合a1调用remove方法之后,元素[name=张三, age=20]没有被移除。这是因为contains和remove方法的底层都是依赖于equals方法,使用对象是自定义类型的时候要注意重写equals方法。
在Student类中重写equals方法之后,再运行上面的代码:
public class Student {
private String name;
private Integer age;
public Student() {
super();
}
public Student(String name, Integer age) {
super();
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
@Override
public String toString() {
return "Student [name=" + name + ", age=" + age + "]";
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
Student other = (Student) obj;
if (age == null) {
if (other.age != null)
return false;
} else if (!age.equals(other.age))
return false;
if (name == null) {
if (other.name != null)
return false;
} else if (!name.equals(other.name))
return false;
return true;
}
}
public class test {
public static void main(String[] args) {
Collection a1 = new ArrayList();
a1.add(new Student("张三", 20));
a1.add("abc");
a1.add(123);
System.out.println(a1.remove(new Student("张三", 20))); // true
System.out.println(a1); // [abc, 123]
}
}
迭代器
迭代器,顾名思义就是用来迭代集合的。集合对象自带iterator()方法,通过该方法可以获取集合对象的迭代器。使用迭代器的hasNex()t方法和next()方法,就可以实现对集合对象的遍历。下面用迭代器遍历集合:
public class test {
public static void main(String[] args) {
Collection b = new ArrayList();
b.add("a");
b.add("b");
b.add("c");
Iterator c = b.iterator();
while (c.hasNext()) //用while循环,会在循环外创建一个全局变量c,while结束,c依然存在
System.out.println(c.next()); //a b c
for(Iterator i = b.iterator();i.hasNext();) // 用for循环,循环结束,变量i立刻被释放掉,节省一点点资源
System.out.println(i.next()); // a b c
}
}
看上面的代码,可能会觉得有点奇怪。
为什么Collection要费力的去实现Iterator接口,而不是直接在内部定义hasNext和next方法呢?
// Iterator接口
public interface Iterator<E> {
boolean hasNext();
E next();..........
}
使用迭代器是要对集合进行遍历,然而每一种集合内部的存储结构是不同的,因此每一种集合存和取的方式也是不一样的。那么就需要在每一种集合内部定义hasNext()和next()方法,这样会让整个集合体系变得臃肿。而迭代器是将这样的方法向上抽取而成的接口,每一种集合只需要实现它,在内部实现自己的迭代方式。
这样做的好处有二:
- 规定了整个集合体系的的遍历方式
- 代码由底层实现,不需要知道具体实现,不影响开发者使用
List
List里存放的数据有序,有索引,可重复。
ArrayList
public class test {
public static void main(String[] args) {
List list1 = new ArrayList();
list1.add("a");
list1.add("b");
list1.add("c");
List list2 = new ArrayList();
list2.add("d");
list2.add("e");
list2.add("f");
list1.add(1, "e"); // 在指定索引位置添加元素(索引位置不可大于集合对象大小(这里是3), 否则有数组越界异常)
System.out.println(list1); // [a, e, b, c]
list1.addAll(4, list2); // 在指定索引位置添加传入集合对象中的所有元素
System.out.println(list1); // [a, e, b, c, d, e, f]
System.out.println(list1.get(1)); // e (根据索引获取集合对象中的元素(索引不可超过集合对象大小,否则有数组越界异常))
System.out.println(list1.indexOf("e")); // 1 (根据传入数据顺序获取集合对象中元素的索引(第一次出现位置,不存在返回-1))
System.out.println(list1.lastIndexOf("e")); // 5 (根据传入数据逆序获取集合对象中元素的索引(第一次出现位置,不存在返回-1))
list1.remove("e"); // 根据传入数据顺序删除集合对象中的元素(第一次出现的元素)
System.out.println(list1); // [a, b, c, d, e, f]
list1.set(5, "z"); // 修改集合对象中指定索引位置的元素(索引不可超过集合对象大小,否则有数组越界异常)
System.out.println(list1); // [a, b, c, d, e, z]
System.out.println(list1.subList(3, 5)); // [d, e] (截取集合对象,含头不含尾)
}
}
通过上面的代码我们不难发现:方法中使用了索引的地方,不可避免会出现IndexOutOfBoundsException异常。那么是否可以猜测ArrayList的底层实现就是数组呢?我们先看看源码是怎么写的:
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
private static final long serialVersionUID = 8683452581122892189L;
private static final int DEFAULT_CAPACITY = 10;
private static final Object[] EMPTY_ELEMENTDATA = {};
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
}
通过源码可以发现,ArrayList的底层实现就是数组。创建ArrayList的本质就是创建了一个初始大小为10的数组,它的add方法也就是我们最开始写的add方法。
List特有的迭代器
public class test {
public static void main(String[] args) {
List list = new ArrayList();
list.add("a");
list.add("b");
list.add("c");
list.add("d");
ListIterator it = list.listIterator();
while (it.hasNext()) {
System.out.println(it.next()); //a b c d
}
while (it.hasPrevious()) { //判断是否有前一个元素
System.out.println(it.previous()); //d c b a, 输出前一个元素(注意不要在指针指向集合开始位置的时候使用)
}
}
}
tip:上面代码中的第二段while代码块之所以能实现倒着遍历集合对象,是因为前一段while代码块中不断的调用it.next()方法,把指针指向了集合对象的末尾。如果把前一段while代码块注释掉,直接执行第二段while代码块将什么都打印不出来。
上面的示例代码在用迭代器遍历集合对象的时候没有对集合对象进行增删操作,然而我们实际使用的时候很有可能会这样做。这样做会带来一些问题,看这样一段代码:
public class test {
public static void main(String[] args) {
List list = new ArrayList();
list.add("a");
list.add("b");
list.add("c");
list.add("d");
ListIterator it = list.listIterator();
while (it.hasNext()) {
String add = (String) it.next();
if (add.equals("c")) {
list.add("add"); // ConcurrentModificationException (并发修改异常)
}
}
}
}
运行上面的代码就会出现ConcurrentModificationException 。这是因为迭代器在对集合对象进行遍历的同时,我们又修改了集合对象的大小,两边起了冲突,就出现了并发修改异常。所以要在遍历的时候同时修改集合对象的大小,最好遍历和修改都使用同一种方式。下面给出两种做法:
public class test {
public static void main(String[] args) {
List list = new ArrayList();
list.add("a");
list.add("b");
list.add("c");
list.add("d");
// 用迭代器在遍历集合对象的同时,修改集合对象的大小
ListIterator it = list.listIterator();
while (it.hasNext()) {
String add = (String) it.next();
if (add.equals("c")) {
it.add("add");
}
}
System.out.println(list); // [a, b, c, add, d]
// 根据集合对象的大小遍历集合对象,同时修改集合对象的大小
for (int i = 0; i < list.size(); i++) {
String add = (String) list.get(i);
if (add.equals("c")) {
list.add(i + 1, "add");
}
}
System.out.println(list); // [a, b, c, add, add, d]
}
}
LinkedList
public class test {
public static void main(String[] args) {
LinkedList linkedList = new LinkedList();
linkedList.add("b");
linkedList.add("c");
linkedList.add("d");
linkedList.addFirst("a"); // 在集合对象头部添加元素
linkedList.addLast("e"); // 在集合对象尾部添加元素
System.out.println(linkedList);
linkedList.removeFirst(); // 移除集合对象的头部元素
System.out.println(linkedList);
linkedList.removeLast(); // 移除集合对象的尾部元素
System.out.println(linkedList);
}
}
通过上面的代码可以看到,LinkedList对象的特有方法都是针对集合对象的头部和尾部操作。这就让人联想到一种数据结构 — —链表。
不同于ArrayList,LinkedList的底层实现是链表。在操作上它们没有什么大的区别,它们的区别主要体现在不同的操作上对应的速度会有稍许的差异。看这样一段代码:
public class test {
public static void main(String[] args) {
LinkedList linkedList = new LinkedList();
for(int i = 0; i < 1000000; i++) {
linkedList.add(i);
}
long start1 = System.currentTimeMillis(); // 获取开始时间
linkedList.get(500000);
long end1 = System.currentTimeMillis(); // 获取结束时间
System.out.println((end1 - start1) + "ms"); // 4ms(不同的电脑会有稍许的差异)
List arrayList = new ArrayList<>();
for(int i = 0; i < 1000000; i++) {
arrayList.add(i);
}
long start2 = System.currentTimeMillis(); // 获取开始时间
arrayList.get(500000);
long end2 = System.currentTimeMillis(); // 获取结束时间
System.out.println((end2 - start2) + "ms"); // 0ms
}
}
通过上面的代码可以看到,ArrayList在查找的速度上稍稍优先于LinkedList。这是因为ArrayList的底层实现是数组, 数组有索引,查找操作直接就可以通过索引定位到对应位置。而LinkedList的底层实现是链表,要定位到对应位置,使用的是二分查找。所以ArrayList的查找和修改比较快,而LinkedList的优势则体现在增和删上:
public class test {
public static void main(String[] args) {
LinkedList linkedList = new LinkedList();
for(int i = 0; i < 10000000; i++) {
linkedList.add(i);
}
long start1 = System.currentTimeMillis(); // 获取开始时间
linkedList.add(1, "1000000");
long end1 = System.currentTimeMillis(); // 获取结束时间
System.out.println((end1 - start1) + "ms"); // 0ms
List arrayList = new ArrayList<>();
for(int i = 0; i < 10000000; i++) {
arrayList.add(i);
}
long start2 = System.currentTimeMillis(); // 获取开始时间
arrayList.add(1, "1000000");
long end2 = System.currentTimeMillis(); // 获取结束时间
System.out.println((end2 - start2) + "ms"); // 7ms(不同的电脑会有稍许的差异)
}
}
Vector
功能和ArrayList差不多,主要区别体现在线程安全上。Vector出现于JDK1.0版本,现在不怎么用了,这里也不介绍了。
总结:
ArrayList和Vector的底层实现是数组,而LinkedList的底层实现是链表。
所以相对而言,ArrayList和Vector的查找和修改比较快。但Vector出现时间比较早,它的线程是安全的安全,效率相对低一点,目前几乎不用;而ArrayList的线程不安全,所以效率相对快一点。
LinkedList的优势则体现在增和删上,和ArrayList一样,它的线程也不安全,效率高一些。
泛型
在前面部分提到过泛型,但之前的代码中我们并没有用到。先看这样一段代码:
public class test {
public static void main(String[] args) {
List list = new ArrayList();
list.add("a");
list.add(100);
list.add(new Student("zhang1", 20));
Iterator it = list.iterator();
while (it.hasNext()) {
Student temp = (Student) it.next();
System.out.println(temp.getName() + temp.getAge());
}
}
}
这段代码在编译期并不会报错,但是即使不运行也看的出来代码在运行期肯定会报错。因为对象“a”和100是无法强转成Student对象的。代码中的list集合对象中只有三的元素,所以我们可以一眼就看问题。那如果集合对象中的元素多到成百上千个呢,届时我们要凭借肉眼去找到是哪个元素出的问题就会非常的困难。
为了解决这一问题,Java在JDK1.5版本引入了泛型这一概念。泛型即泛化类型、参数化类型。在创建一个集合对象时,向这个集合对象中加入泛型指定引用数据类型之外的其它引用类型数据时,编译期就会报错,把运行期的ClassCastException提前到了编译期。
说了这么多,来看看怎么使用泛型:
List<Student> list = new ArrayList<Student>();
创建集合对象list之后,list中就只能存放Student对象。不过这样看起来有点傻,因为两个尖括号里的数据类型必须保持一致(即使是继承关系也不行)。所以Java在JDK1.7版本引入菱形泛型的概念,即=之后的尖括号里不用再写一遍数据类型了。于是定义一个个集合对象就变成了这样:
List<Student> list = new ArrayList<>();
泛型类
泛型类最典型的是用来做容器类,像前面介绍的ArrayList,Vector,LinkedList都是。下面自己定义一个泛型类:
public class Restaurant<P> { // 这里的P就是一个普通的参数,只要当做一个普通的的变量来看待它就行
private P p; // 常见的如T、E、K、V等形式的参数常用于表示泛型
public Restaurant() {
super();
}
public P getP() {
return p;
}
public void setP(P p) {
this.p = p;
}
@Override
public String toString() {
return "Restaurant [p=" + p + "]";
}
}
public class test {
public static void main(String[] args) {
Restaurant<Student> r = new Restaurant<>();
r.setP(new Student("zhangsan", 21));
System.out.println(r);; // Restaurant [p=Student [name=zhangsan, age=21]]
}
}
泛型方法
tip:上面定义泛型类Restaurant中使用的get、set方法并不是泛型方法,只是一个类中的成员方法。因为他们使用的泛型是泛型类定义时候就已经声明好的。
public class Restaurant<P> {
private P p;
public Restaurant() {
super();
}
public void eat(P p) { // 使用和泛型类声明的类型
System.out.println(p + "eat");
}
public <T> void fanxingEat(T t) { // 使用泛型方法自己定义的类型
System.out.println(t + "eat");
}
public static<R> void staticEat (R r) {
System.out.println(r + "eat");
}
}
public class test {
public static void main(String[] args) {
Restaurant<Student> r = new Restaurant<>();
r.eat(new Student("zhangsan", 21)); // Student [name=zhangsan, age=21]eat
r.fanxingEat(new Person("lisi", 22)); // Person [name=lisi, age=22]eat
Restaurant.staticEat(new Person("zhaoliu", 23)); // Person [name=zhaoliu, age=23]eat
}
}
上面的泛型类Restaurant中, 泛型方法可以使用自己定义的泛型,也可以使用泛型类的泛型。但静态泛型方法必须使用自己定义的泛型,因为静态泛型方法优先于泛型对象存在,而那时泛型对象还没有创建出来。
泛型接口
.......
泛型通配符<?>
有些时候,我们想要泛型不是只限定一种类型,我们可能还需要某个类型的子类、父类。然而,像下面这样写编译就通不过:
List<Object> list = new ArrayList<Student>(); // 报错:cannot convert from ArrayList<Student> to List<Object>
使用泛型通配符之后,就可以通过编译:
List<?> list = new ArrayList<Person>();
泛型通配符最大的作用是限定边界。
限定上边界 — — ? extends Class
不能往里存,只能往外取
限定下边界 — — ? super Class
不影响往里存,但往外取只能放在Object对象里
增强型for循环(foreach)
public class test {
public static void main(String[] args) {
List<Person> list = new ArrayList<>();
list.add(new Person("zhangsan", 21));
list.add(new Person("lisi", 22));
for (Person person : list) { // 泛型 变量名 : 集合名
System.out.println(person);
}
}
}
之前遍历集合有两种方法— —迭代器和for循环。在学习完泛型之后,就可以使用增强型for循环了。相对于前两种方法,foreach的写法更为简洁。
可变参数
public class test {
public static void main(String[] args) {
int[] arr = { 1,2,3,4,5 };
print1(arr); // 只能接受一个数组作为参数
print2(arr); // 接受一个数组作为参数
print2(1,2); // 接受两个数字作为参数
print2(); // 不传入任何参数
}
public static void print1 (int[] arr) {
for (int i : arr) {
System.out.println(i);
}
}
public static void print2 (int... arr) {
for (int i : arr) {
System.out.println(i);
}
}
}
上面代码中如果方法print1和方法print2的方法签名都改为print,编译就会通不过,这就说明...和[]起着差不多的作用,或者说在作为参数的时候...上是[]的增强。相比于数组,可变参数不仅可以收集数组,还可以0~N个元素。
数组转集合
在最开始的时候,我们使用的Arrays的asList方法把数组转成集合来打印。那么使用这个方法,数组就真的变成集合了吗?
public class test {
public static void main(String[] args) {
Integer[] arr = {11,22,33,44,55};
List<Integer> list = Arrays.asList(arr);
System.out.println(list); // [11, 22, 33, 44, 55]
list.add(66); // java.lang.UnsupportedOperationException
}
}
运行上面的代码,就可以得到答案 — — 并没有。使用Arrays的asList的方法是有限制的。数组转换成集合不能改变长度,但是可以应用集合中其他的方法,即不能增删可以改查。这是因为List的底层就是数组,是一个长度可变的数组,而现在转换成集合的数组长度已经固定了,所以就不能再更改。
public class test {
public static void main(String[] args) {
int [] arr2 = {11,22,33,44,55};
System.out.println(arr2); // [I@15db9742
List<int[]> list2 = Arrays.asList(arr2);
System.out.println(list2); // [[I@15db9742]
}
}
再看上面这段代码,连一个最基本的打印也无法给出想要的结果。这又是为什么呢?这里首先要明确两点:
- 集合里只能存放引用数据类型
- 数组属于引用数据类型
这里集合对象list2是把数组arr2作为一个元素存放到集合对象里。数组也继承了Object的toString方法,但是没办法重写,所以可以看到两次输出的地址值是一样的。
Set
Set里存放的数据无序,无索引,不重复。
HashSet
public class test {
public static void main(String[] args) {
Set<Person> set = new HashSet<>();
set.add(new Person("zhangsan",18));
set.add(new Person("zhangsan",18));
set.add(new Person("lisi",19));
set.add(new Person("wangwu",20));
System.out.println(set);
}
}
Set集合存放的数据应该是无序,无索引,不重复的。然而运行上面的代码查看最后的打印结果,可以看到最后打印了两条Person [name=zhangsan, age=18]的数据,这是怎么回事呢?
其实问题是出现在没有重写hashCode()和equals()两个方法上。重写这两个方法:
public class Person {
private String name;
private Integer age;
public Person(String name, Integer age) {
super();
this.name = name;
this.age = age;
}
public Person() {
super();
}
@Override
public String toString() {
return "Person [name=" + name + ", age=" + age + "]";
}
@Override
public int hashCode() {
final int prime = 31; // java自动生成的质数是31, 质数31不大不小, 而且是(2<<4)- 1,容易得到
int result = 1;
result = prime * result + ((age == null) ? 0 : age.hashCode());
result = prime * result + ((name == null) ? 0 : name.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj) // 首先比较两条数据地址值,判断是不是同一条数据
return true;
if (obj == null) // 接着看要比较的数据是不是null
return false;
if (getClass() != obj.getClass()) // 再比较字节码文件,判断是不是同属于一个类
return false;
Person other = (Person) obj; // 最后才是判断类里的成员变量
if (age == null) {
if (other.age != null)
return false;
} else if (!age.equals(other.age))
return false;
if (name == null) {
if (other.name != null)
return false;
} else if (!name.equals(other.name))
return false;
return true;
}
}
向set集合对象中存入数据的时候,首先会调用hashCode()方法,比较数据的hash值。默认的hashCode()方法返回的是对象的内存地址转换成的一个整数。本质上就是内存地址。所以插入的每一条数据的hash值都是不一样的,因此也就没有走到第二步 — —调用equals()方法。
重写hashCode() 方法之后,成员变量完全一样的数据计算得到的hash值也就会相同。hash值相同的数据会再调用equals() 方法接着比较,equals()方法返回false,才会存入set集合对象中;反之,则不存入数据。
@Override
public int hashCode() {
return 1;
}
如果像上面这样重写hashCode()方法,再运行代码会怎么样呢?
通过打印的结果可以看到,重复数据也被去除了。既然这样做就可以达到去重的目的,为什么还要费力的重写hashCode()方法呢?
答案很简单 — — 效率。
这里用到一种叫hash桶的结构(大概就长下面这样)。
当上面代码开始运行的时候,底层操作是这样的:
首先向集合对象set里存入第一条数据 — — Person [name=zhangsan, age=18],这时会调用hasCode()方法计算返回它的hash值,这里返回的是1,这条数据就会被“挂”在hash值为1的位置上。
接着向集合对象set里存入第二条数据 — — Person [name=zhangsan, age=18],它的hash值也是1,但是hash值为1的位置上已经有数据了,这时候就会调用equals()方法和第一条数据去比较,equals()方法返回true,第二条数据就没有存入集合对象set。
然后第三条数据 — — Person [name=lisi, age=19]开始存入,它的hash值也是1,但是equals()方法返回false,这条数据就会存入集合对象set,被“挂”在第一条数据下面。后面的数据以此类推。
通过上面的解释可以看出一个问题:向set集合中存入数据时,调用hasCode()方法计算得到的hash值一样的数据会调用equals()方法。
上面的代码中hasCode()每次返回的值都是1,这样每条数据存入的时候,都会调用equals()和hash值为1的位置上“挂”着的数据去比较,而且随着“挂”上去的数据越多,调用equals()方法的次数也越多。
这就是hashCode()存在的意义 — — 减少hash碰撞。
总结:
- hasCode():属性相同的对象返回值必须相同,属性不同的对象返回值尽量不同。
tip:其实HashSet集合对象里存放的数据也是有顺序的,只不过这个有序指的是按照hash值来排序。
public class test {
public static void main(String[] args) {
Set<Integer> set = new HashSet<>();
set.add(5);
set.add(2);
set.add(3);
set.add(1);
set.add(5);
set.add(4);
System.out.println(set); // [1, 2, 3, 4, 5]
}
}
LinkedHashSet
public class LinkedHashSet<E> extends HashSet<E> implements Set<E>, Cloneable, java.io.Serializable {
private static final long serialVersionUID = -2851667679971038690L;
public LinkedHashSet(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor, true);
}
public LinkedHashSet(int initialCapacity) {
super(initialCapacity, .75f, true);
}
public LinkedHashSet() {
super(16, .75f, true);
}
public LinkedHashSet(Collection<? extends E> c) {
super(Math.max(2*c.size(), 11), .75f, true);
addAll(c);
}
@Override
public Spliterator<E> spliterator() {
return Spliterators.spliterator(this, Spliterator.DISTINCT | Spliterator.ORDERED);
}
}
查看LinkedHashSet集合的源码可以看到,除了四个构造方法,它没有自己的方法,它所有的方法都是继承自HashSet。
它和HashSet的不同就是LinkedHashSet里存放的数据是有序的 — — 数据存进去是什么顺序,取出来还是什么顺序。
public class test {
public static void main(String[] args) {
Set<Integer> set = new LinkedHashSet<>();
set.add(5);
set.add(2);
set.add(3);
set.add(1);
set.add(5);
set.add(4);
System.out.println(set); // [5, 2, 3, 1, 4]
}
}
LinkedHashSet集合同样是根据元素的hashCode()返回的值来决定元素的存储位置,但是它同时使用链表维护元素的次序。以此使得元素看起来像是以插入顺序保存的。
TreeSet
public class test {
public static void main(String[] args) {
TreeSet<Integer> ts1 = new TreeSet<>();
ts1.add(2);
ts1.add(1);
ts1.add(4);
ts1.add(3);
ts1.add(4);
System.out.println(ts1); // [1, 2, 3, 4]
TreeSet<String> ts2 = new TreeSet<>();
ts2.add("d");
ts2.add("a");
ts2.add("c");
ts2.add("a");
ts2.add("b");
System.out.println(ts2); // [a, b, c, d]
}
}
从上面的代码可以看出来,TreeSet与又与LinkedHashSet不同,它不仅帮我们去重,还完成了真正意义上的排序。下面用我们自己定义的类试试看:
public class test {
public static void main(String[] args) {
TreeSet<Person> ts = new TreeSet<>();
ts.add(new Person("张三",23));
ts.add(new Person("王五",25));
ts.add(new Person("赵六",26));
ts.add(new Person("张三",23));
ts.add(new Person("李四",24));
System.out.println(ts); // ClassCastException 类型转换异常
}
}
运行上面的代码会看到报错信息:类型转换异常。很明显相较于java定义的类,我们自定义的类少了点什么。
public final class Integer extends Number implements Comparable<Integer> {......}
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {......)
查看源码可以发现Integer和String两个类有一个共通的地方 — — 它们都实现了Comparable接口。
public interface Comparable<T> {
public int compareTo(T o);
}
Comparable接口里只定义了一个compareTo方法。也就是说,TreeSet去重和排序的依据就compareTo方法。下面让让Person类实现Comparable接口:
public class Person implements Comparable<Person> {
private String name;
private Integer age;
public Person(String name, Integer age) {
super();
this.name = name;
this.age = age;
}
public Person() {
super();
}
@Override
public String toString() {
return "Person [name=" + name + ", age=" + age + "]";
}
@Override
public int compareTo(Person o) {
return this.age - o.age;
}
}
再运行上面的代码就可以看到完成排序和的数据。
public class test {
public static void main(String[] args) {
TreeSet<Person> ts = new TreeSet<>();
ts.add(new Person("张三",23));
ts.add(new Person("王五",25));
ts.add(new Person("赵六",26));
ts.add(new Person("张三",23));
ts.add(new Person("李四",24));
System.out.println(ts); // [Person [name=张三, age=23], Person [name=李四, age=24], Person [name=王五, age=25], Person [name=赵六, age=26]]
}
}
TreeSet的底层实现是二叉树。ts中存入的第一条数据会作为根节点,第二条数据存入的时候会调用compareTo()方法,返回值大于0,作为根节点的右节点,返回值小于零作为根节点的左节点,返回值等于零则不存。
之后的数据存入以此类推。ts里的数据读取方法使用的是先序遍历。
上面重写的compareTo()方法并不完整,因为当两条数据的的年龄一样的时候,调用compareTo() 方法的那条数据就不会被存到集合对象里。所以在比较完年龄之后应该继续比较姓名,补全之后是这样:
@Override
public int compareTo(Person o) {
int temp = this.age - o.age;
return temp == 0 ? this.name.compareTo(o.name) : temp;
}
TreeSet另一种实现排序的方法
TreeSet接受一个Comparator作为参数,重写Comparator的compare()方法即可:
public class test {
public static void main(String[] args) {
TreeSet<Student> ts = new TreeSet<>(new Comparator<Student>() {
@Override
public int compare(Student o1, Student o2) {
int num = o1.getAge() - o2.getAge();
return num == 0 ? o1.getName().compareTo(o2.getName()) : num;
};
});
ts.add(new Student("zhang1", 25));
ts.add(new Student("zhang2", 14));
ts.add(new Student("zhang3", 25));
System.out.println(ts); // [Student [name=zhang2, age=14], Student [name=zhang1, age=25], Student[name=zhang3, age=25]]
}
}
Map
HashMap
public class test {
public static void main(String[] args) {
Map<String, String> hm = new HashMap<>();
hm.put("zhangsan", "nanjing");
hm.put("lisi", "shanghai");
hm.put("wangwu", "anhui");
hm.put("zhaoliu", "beijing");
System.out.println(hm.values()); // [shanghai, beijing, nanjing, anhui] (获取集合对象中值的集合)
System.out.println(hm.size()); // 4 (获取集合对象中键值对个数)
System.out.println(hm.containsKey("zhangsan")); // true (判断集合对象中是否包含传入的键)
System.out.println(hm.remove("lisi")); // shanghai (根据传入的键,移除集合对象中的键值对)
System.out.println(hm.containsValue("shanghai")); // false (判断集合对象是否包含传入的值)
System.out.println(hm.get("wangwu")); // anhui (从集合对象中根据传入的键获取对应的值, 键不存在返回null)
System.out.println(hm.get("lisi")); // null
hm.clear(); // 清空集合对象的所有键值对方
System.out.println(hm.isEmpty()); // true (判断集合对象是否为空)
}
}
HashMap与HashSet的关系
public class test {
public static void main(String[] args) {
HashMap<String, String> hm = new HashMap<>();
String h1 = hm.put("zhangsan", "nanjing");
String h2 = hm.put("zhangsan", "beijing");
String h3 = hm.put("lisi", "shanghai");
String h4 = hm.put("lisi", "anhui");
String h5 = hm.put("wangwu", "anhui");
System.out.println(h1); //null ,返回覆盖键的值
System.out.println(h2); //nanjing
System.out.println(h3); //null
System.out.println(h4); //shanghai
System.out.println(h5); //null
System.out.println(hm); //{lisi=anhui, zhangsan=beijing, wangwu=anhui}
}
}
从最后的打印结果可以得到两个结论:
- HashMap的键是唯一的
- HashMap的键值对存放是无序的
这样看来的话,HashSet和HashMap的底层实现是差不多的。从减少代码复用性的角度考虑,应该有一方的底层用另一方实现。当然一切以源码为准:
public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable
{private transient HashMap<E,Object> map;
private static final Object PRESENT = new Object();
public HashSet() {
map = new HashMap<>();
}
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<>(initialCapacity, loadFactor);
}
public Iterator<E> iterator() {
return map.keySet().iterator();
}
public int size() {
return map.size();
}
public boolean isEmpty() {
return map.isEmpty();
}
public boolean contains(Object o) {
return map.containsKey(o);
}
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
public boolean remove(Object o) {
return map.remove(o)==PRESENT;
}
public void clear() {
map.clear();
}..........
}
通过源码里HashSet中的一些方法实现可以看出来,HashS的底层是用HashMap实现。HashSet中对象的唯一性其实就是HashMap中键的唯一性,只是HashSet隐藏了值那一列。
为什么是HashSet依赖于HashMap呢?因为做减法总比做加法来的简单。
Map的两种遍历方式
public class test {
public static void main(String[] args) {
Map<String, String> hm = new HashMap<>();
hm.put("zhangsan", "nanjing");
hm.put("lisi", "shanghai");
hm.put("wangwu", "anhui");
hm.put("zhaoliu", "beijing");
// 增强型for循环,循环存放所有key的set
for (String key : hm.keySet()) {
System.out.println(key + "=" + hm.get(key));
}
// 把键值对作为一个Entry实体,迭代存放Entry实体的set
Set<Entry<String, String>> en = hm.entrySet();
Iterator<Entry<String, String>> it = en.iterator();
while (it.hasNext()) {
Entry<String, String> entry = it.next();
entry.getKey();
System.out.println(entry.getKey() + "=" + entry.getValue());
}
}
}
Entry与KeySet的不同是,一个是把每个键值对先包装成一个对象再打包成集合对象,一个是直接把所有的键打包成集合对象。前者在遍历的时候每次可以从自身直接拿到键和值,后者则还要根据key去Map集合对象中找到对应的值。所以前者的效率会稍微快一点。
LinkedHashMap、TreeMap
LinkedHashSet、TreeMap和HashMap的区别,同LinkedHashSet、TreeSet和HashSet的区别相同,这里不再赘述。
Hashtable
光看Hashtable的名字,就能发现它出现的时间肯定比较早,因为它的命名甚至不遵循驼峰法则。Hashtable出现于JDK1.0,它是线程安全的,效率低。它和HashMap的区别有点像Vector和ArrayList。
但除此之外,Hashtable里不能存放null键和null值。
public class test {
public static void main(String[] args) {
Hashtable<String, Integer> ht = new Hashtable<>();
ht.put(null, 12); //java.lang.NullPointerException
ht.put("zhangsan", null); //java.lang.NullPointerException
HashMap<String, Integer> hm = new HashMap<>();
hm.put(null, 12);
hm.put("zhangsan", null);
System.out.println(hm); // {null=12, zhangsan=null}
}
}