一、首先来看他们之间的关系
二、Set的集合
2.1 Set的概述
set就类似于一个箱子,在"箱子"里面可以存放各种对象,这些对象就组成了集合;
Set的特点:无序、不重复
2.2 HashSet类
HashSet是Java集合Set的一个实现类,Set是一个接口,其实现类除HashSet之外,还有TreeSet,并继承了Collection,HashSet集合很常用;HashSet按Hash算法来存储集合中的元素,因此具有很好的存取和查找性能。底层数据结构是哈希表。
哈希表
一个元素为链表的数组,综合了数组与链表的优点。
HashSet具有以下特点:
- 不能保证元素的排列顺序,顺序可能与添加顺序不同,顺序也可能发生变化;
- HashSet不是同步的;
- 集合元素值可以是null;
public class HashSet<E>
extends AbstractSet<E>
implements Set<E>, Cloneable, java.io.Serializable
{}
HashSet构造
HashSet有几个重载的构造方法
private transient HashMap<E,Object> map;
//默认构造器
public HashSet() {
map = new HashMap<>();
}
//将传入的集合添加到HashSet的构造器
public HashSet(Collection<? extends E> c) {
map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
addAll(c);
}
//明确初始容量和装载因子的构造器
public HashSet(int initialCapacity, float loadFactor) {
map = new HashMap<>(initialCapacity, loadFactor);
}
//仅明确初始容量的构造器(装载因子默认0.75)
public HashSet(int initialCapacity) {
map = new HashMap<>(initialCapacity);
}
从上面的源码可以看出来,HashSet的底层依赖于HashMap来实现,就相当于HashSet是一个外包,负责接活,然后把活扔给HashMap来做;
HashSet的add方法
HashSet的add方法时通过HashMap的put方法实现的,不过HashMap是key-value键值对,而HashSet是集合;那么HashSet是怎么存储的呢?
private static final Object PRESENT = new Object();
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
通过观看源码可以发现,HashSet添加的元素是存放在HashMap的key位置上,而value取了默认常量PRESENT,是一个空对象。
内部存储机制
当向HashSet集合中存入一个元素时,HashSet会调用该对象的hashCode方法来得到该对象的hashCode值,然后根据该hashCode值决定该对象在HashSet中的存储位置。如果有两个元素通过equals方法比较true,但它们的hashCode方法返回的值不相等,HashSet将会把它们存储在不同位置,依然可以添加成功。
也就是说。HashSet集合判断两个元素的标准是两个对象通过equals方法比较相等,并且两个对象的hashCode方法返回值也相等。
靠元素重写hashCode方法和equals方法来判断两个元素是否相等,如果相等则覆盖原来的元素,依此来确保元素的唯一性
实例:
没有重写hashCode和equals方法
创建一个Student类
public class Student {
private String name;
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public Student(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "Student{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
创建一个TestOfSet进行测试
public class TestOfSet {
public static void main(String[] args) {
String a1 = new String("星期一");
String a2 = new String("星期二");
String a3 = new String("星期一");
String a4 = new String("星期三");
String a5 = new String("星期四");
String a6 = new String("星期五");
Set<String> setString = new HashSet<> ();
setString.add(a1);
setString.add(a2);
setString.add(a3);
setString.add(a4);
setString.add(a5);
setString.add(a6);
Iterator it = setString.iterator();
while (it.hasNext()) {
System.out.println(it.next());
}
System.out.println("============第二种输出String=========");
for (String s : setString) {
System.out.println(s);
}
Student s1 = new Student("露娜", 12);
Student s2 = new Student("赵云", 18);
Student s3 = new Student("露娜", 16);
Student s4 = new Student("小莫", 3);
Student s5 = new Student("5号", 8);
Student s6 = new Student("小莫",3);
Set<Student> h1 = new HashSet<>();
h1.add(s1);
h1.add(s2);
h1.add(s3);
h1.add(s4);
h1.add(s5);
h1.add(s6);
for (Student s : h1) {
System.out.println("姓名:"+s.getName()+"=====年龄:"+s.getAge());
}
}
}
输出结果:
从结果可以看出,Student里面的name,跟age是相同的,但是Set并没有帮我们执行去重的操作,这是为什么呢?
从上面的结果可以看出来,如果传进来的是String的话,Set会帮自动去重,如果是一个自定义对象的话,就没有去重的操作;
为什么呢?
看一下String的源码
equals方法
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
//进行值比较,逐一比较字符
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}
String的hashcode()的方法
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
因为String重写了equals方法,比较的是值,所以值一样的话,那么hashCode的结果也是一样的,因为HashSet是根据hashCode来存储的,equals为true的话,那么就覆盖掉了原来的值,所以String值的话,就可以去重;
那么我们来重写一下Student类的equals()方法跟hashCode()方法
//判断两个对象是否相等,是否存在,里面的参数是否相等
@Override
public boolean equals(Object obj) {
//首先判断两个对象是否相等,也就是,是否属于同一个对象,如果属于,则返回true
if (this == obj) return true;
//比较的对象是否为空,或者对象的构造是否存在,如果不存在,则返回false
if (obj == null || getClass() != obj.getClass()) return false;
//将对象转化成Student类进行比较
Student student = (Student) obj;
//对象存在,比较里面的参数是否相等
//参数相等,则返回true
return age == student.age &&
Objects.equals(name, student.name);
}
//返回对象的name值和age值
@Override
public int hashCode() {
return Objects.hash(name, age);
}
接着运行看一下结果:
如果需要把某个类的对象保存到HashSet集合中,重写这个类的equals方法和hashCode方法时,
应尽量保证两个对象通过equals发那个法比较返回true时,他们的hashCode方法返回值也相等。
2.3 TreeSet类
TreeSet是SortedSet接口的实现类,TreeSet可以确保集合元素处于排序状态。
内部存储机制
TreeSet内部实现的是红黑树,默认整形排序为从小到大。
TreeSet底层依赖于TreeMap(TreeMap的数据结构是二叉树,红黑树)、
根据红黑树的性质:
1. 每个结点不是红色就是黑色
2. 根节点是黑色的
3. 如果一个节点是红色的,则它的两个孩子结点是黑色的
每条路径上的黑色节点的数量必须是一样的
例子:存储下列元素、
21,19,24,23,18,25,20,19,25
在程序上写代码
public class TestTreeSet {
public static void main(String[] args) {
//添加21,19,24,23,18,25,20,19,25
TreeSet<Integer> treeSet = new TreeSet<>();
treeSet.add(21);
treeSet.add(19);
treeSet.add(24);
treeSet.add(23);
treeSet.add(18);
treeSet.add(25);
treeSet.add(20);
treeSet.add(19);
treeSet.add(25);
System.out.println(treeSet);
for(Integer i : treeSet) {
System.out.println("输出:"+i);
}
}
}
输出结果:
可以看到,输出已经是去重,排序都有了
public class TestTreeSet {
public static void main(String[] args) {
//添加21,19,24,23,18,25,20,19,25
TreeSet<Integer> treeSet = new TreeSet<>();
treeSet.add(21);
treeSet.add(19);
treeSet.add(24);
treeSet.add(23);
treeSet.add(18);
treeSet.add(25);
treeSet.add(20);
treeSet.add(19);
treeSet.add(25);
System.out.println(treeSet);
System.out.println("集合中的第一个元素:"+treeSet.first());//集合中的第一个元素:
System.out.println("集合中的最后一个元素:"+treeSet.last());//集合中的最后一个元素:
System.out.println("集合小于20的子集,不包含20:"+treeSet.headSet(20));//集合小于20的子集,不包含20
System.out.println("集合大于等于20的子集:"+treeSet.tailSet(20));//集合大于等于20的子集:
System.out.println("集合中大于等于18,小于21的子集:"+treeSet.subSet(18,21));//集合中大于等于18,小于21的子集:[2]
}
}
使用二叉树存储数据的时候:
第一次存储数据没有树根,就创建了一个树根,然后把这个元素赋值给了树根。
第二次存储的时候,首先应该和根节点进行比较
①当前存储元素的值大于根节点存储元素的值,那么就把当前的元素存储在树根的右边
②如果小于的话,就存储在树根的左边
③如果相等,则不存储(达到去重的效果)
取出元素:从根节点开始依次从左、中、右开始;
保证元素的唯一性,是靠compareTo的方法返回值确定,如果是0则不存储,保证了元素了唯一性;
从前面来看,TreeSet帮我们自动排序了数字,从小到大来排序;
问题:对于数字,能不能降序排序呢?如果是一个对象,又是怎么排序呢?
TreeSet支持两种排序方法:自然排序和定制排序,在默认情况下,采用的是自然排序
自然排序
TreeSet会调用集合元素的compareTo(Objec obj)方法来比较元素之间的大小关系,然后将集合元素按升序排列,这就是自然排序。
Java提供了一个Comparable接口,该接口里定义了一个compareTo(Object obj)方法,该方法返回一个整数值,实现该接口的类必须实现该方法,实现了该接口的类必须实现该方法,实现接口的类就可以比较大小了。当调用一个一个对象调用该方法与另一个对象进行比较时,obj1.compareTo(obj2)如果返回0表示两个对象相等;如果返回正整数则表明obj1大于obj2,如果是负整数则相反。
案例:
实现存储学生类的集合,排序方式,按年龄大小,如果年龄相等,则按name字符串长度,如果长度相等则比较字符。如果name和age都相等则视为同一对象。
首先让Student类继承一个Comparable,重写实现compareTo的方法
/**
* @description:
* @author: WEN
* @create: 2020-08-14
**/
public class Student implements Comparable<Student>{
private String name;
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public Student(String name, int age) {
this.name = name;
this.age = age;
}
//判断两个对象是否相等,是否存在,里面的参数是否相等
@Override
public boolean equals(Object obj) {
//首先判断两个对象是否相等,也就是,是否属于同一个对象,如果属于,则返回true
if (this == obj) return true;
//比较的对象是否为空,或者对象的构造是否存在,如果不存在,则返回false
if (obj == null || getClass() != obj.getClass()) return false;
//将对象转化成Student类进行比较
Student student = (Student) obj;
//对象存在,比较里面的参数是否相等
//参数相等,则返回true
return age == student.age &&
Objects.equals(name, student.name);
}
//返回对象的name值和age值
@Override
public int hashCode() {
return Objects.hash(name, age);
}
@Override
public String toString() {
return "Student{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
@Override
public int compareTo(Student o) {
//比较age
int num = this.age - o.age;
//如果age相等则比较name长度 ,num = 0的话,就执行num1 = this.name.length() - o.name.length()
int num1 = num == 0 ? this.name.length() - o.name.length(): num;
//如果两者相等则比较name字符串
int num2 = num1 == 0 ? this.name.compareTo(o.name) : num1;
//返回最终的比较
return num2;
}
}
写一个demo类进行测试一下
public class TreeSetDemo {
public static void main(String[] args) {
TreeSet<Student> tree = new TreeSet<>();
//向集合中添加元素
Student s1 = new Student("露娜", 12);
Student s2 = new Student("赵云", 18);
Student s3 = new Student("露娜", 16);
Student s4 = new Student("小莫", 3);
Student s5 = new Student("5号", 8);
Student s6 = new Student("小莫",3);
tree.add(s1);
tree.add(s2);
tree.add(s3);
tree.add(s4);
tree.add(s5);
tree.add(s6);
//遍历
for (Student s : tree) {
System.out.println("name:"+s.getName()+"====age:"+s.getAge());
}
}
}
输出结果:
对于修改数字的排序,可以在定义的时候这样修改
将默认的升序,改成降序排序
public class TestTreeSet {
public static void main(String[] args) {
//添加21,19,24,23,18,25,20,19,25
TreeSet<Integer> treeSet = new TreeSet<>(new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return -(o1-o2);
}
});
treeSet.add(21);
treeSet.add(19);
treeSet.add(24);
treeSet.add(23);
treeSet.add(18);
treeSet.add(25);
treeSet.add(20);
treeSet.add(19);
treeSet.add(25);
System.out.println(treeSet);
System.out.println("集合中的第一个元素:"+treeSet.first());//集合中的第一个元素:
System.out.println("集合中的最后一个元素:"+treeSet.last());//集合中的最后一个元素:
System.out.println("集合小于20的子集,不包含20:"+treeSet.headSet(20));//集合小于20的子集,不包含20
System.out.println("集合大于等于20的子集:"+treeSet.tailSet(20));//集合大于等于20的子集:
}
}
输出结果:
使用Lambda表达式来实现
public class TestTreeSet2 {
public static void main(String[] args) {
//添加21,19,24,23,18,25,20,19,25
//使用Lambda表达式来实现
TreeSet<Integer> treeSet = new TreeSet<>((a,b) -> -(a-b));
treeSet.add(21);
treeSet.add(19);
treeSet.add(24);
treeSet.add(23);
treeSet.add(18);
treeSet.add(25);
treeSet.add(20);
treeSet.add(19);
treeSet.add(25);
System.out.println(treeSet);
}
}
输出结果:
三 、Map集合
3.1 map的概述
map集合用于保存具有映射关系的数据,map集合保存着两组值,一组用于保存map的key,一组用于保存map的value;
所有的key不能重复,没有顺序,key跟value要一一对应,key数据组相当于一个set集合;
map集合的作用
和查字典类似,通过key找到对应的value,通过页数找到对应的信息。用学生类来说,key相当于学号,value对应name,age,sex等信息。用这种对应关系方便查找。
Map和Set的关系
可以说关系是很密切了,虽然Map中存放的时键值对,Set中存放的是单个对象,但如果把value看做key的附庸,key在哪里,value就在哪里,这样就可以像对待Set一样来对待Map了。事实上,Map提供了一个Entry内部类来封装key-value对,再计算Entry存储时则只考虑Entry封装的key。
如果把Map集合里的所有value放在一起来看,它们又类似于一个List,元素可以重复,每个元素可以根据索引来找,只是Map中的索引不再是整数值,而是以另一个对象作为索引。
Map中的常用方法:
void clear()
:删除该Map对象中所有键值对;boolean containsKey(Object key)
:查询Map中是否包含指定的key值;boolean containsValue(Object value)
:查询Map中是否包含一个或多个value;Set entrySet()
:返回map中包含的键值对所组成的Set集合,每个集合都是Map.Entry对象。Object get()
:返回指定key对应的value,如果不包含key则返回null;boolean isEmpty()
:查询该Map是否为空;Set keySet()
:返回Map中所有key组成的集合;Collection values()
:返回该Map里所有value组成的Collection。Object put(Object key,Object value)
:添加一个键值对,如果集合中的key重复,则覆盖原来的键值对;void putAll(Map m)
:将Map中的键值对复制到本Map中;Object remove(Object key)
:删除指定的key对应的键值对,并返回被删除键值对的value,如果不存在,则返回null;boolean remove(Object key,Object value)
:删除指定键值对,删除成功返回true;int size()
:返回该Map里的键值对个数;
内部类Entry
Map中包括一个内部类Entry,该类封装一个键值对,常用方法:
Object getKey()
:返回该Entry里包含的key值;Object getvalue()
:返回该Entry里包含的value值;Object setValue(V value)
:设置该Entry里包含的value值,并设置新的value值。
例子:
创建一个Student类
/**
* @description:
* @author: WEN
* @create: 2020-08-15
**/
public class Student {
//学号
private String sno;
//姓名
private String name;
//年龄
private int age;
public String getSno() {
return sno;
}
public void setSno(String sno) {
this.sno = sno;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public Student(String sno, String name, int age) {
this.sno = sno;
this.name = name;
this.age = age;
}
}
创建一个demo测试一下,用学号作为map的key,将Student对象作为value来存取
public class MapDemo {
public static void main(String[] args) {
//定义一个Hashmap
Student s1 = new Student("003","张五",5);
Student s2 = new Student("004","张六",6);
Student s3 = new Student("001","张三",3);
Student s4 = new Student("002","张四",4);
Student s5 = new Student("005","张七",7);
//定义一个Hashmap,用学号来作为key,value来存放一个对象
HashMap<String,Student> hashMap = new HashMap<>();
hashMap.put(s1.getSno(),s1);
hashMap.put(s2.getSno(),s2);
hashMap.put(s3.getSno(),s3);
hashMap.put(s4.getSno(),s4);
hashMap.put(s5.getSno(),s5);
//利用set集合来获取map集合的key,可以运用map的keySet()方法
Set<String> keys = hashMap.keySet();
//循环输出
for (String key : keys) {
Student student = hashMap.get(key);
System.out.println("学号:"+student.getSno()+"====姓名:"+student.getName()+"====age:"+student.getAge());
}
}
}
输出结果:
可以看到上面输出是按学号顺序来的,map本身是无序的,只不过set集合将学号进行了升序排序,最后输出按学号顺序来输出
再来看一下下面这段代码
public class MapDemo {
public static void main(String[] args) {
//定义一个Hashmap
Student s1 = new Student("003","张五",5);
Student s2 = new Student("004","张六",6);
Student s3 = new Student("001","张三",3);
Student s4 = new Student("002","张四",4);
Student s6 = new Student("002","张四",6);
Student s5 = new Student("005","张七",7);
Student s7 = new Student("006","张八",9);
Student s8 = new Student("007","张八",9);
//定义一个Hashmap,用学号来作为key,value来存放一个对象
HashMap<String,Student> hashMap = new HashMap<>();
hashMap.put(s1.getSno(),s1);
hashMap.put(s2.getSno(),s2);
hashMap.put(s3.getSno(),s3);
hashMap.put(s4.getSno(),s4);
hashMap.put(s5.getSno(),s5);
hashMap.put(s6.getSno(),s6);
hashMap.put(s7.getSno(),s7);
hashMap.put(s8.getSno(),s8);
//利用set集合来获取map集合的key,可以运用map的keySet()方法
Set<String> keys = hashMap.keySet();
//循环输出
for (String key : keys) {
Student student = hashMap.get(key);
System.out.println("学号:"+student.getSno()+"====姓名:"+student.getName()+"====age:"+student.getAge());
}
System.out.println(hashMap.get("002").getAge());
}
}
输出结果:
方法名称 | 说明 |
---|---|
V get(Object key) | 返回 Map 集合中指定键对象所对应的值。V 表示值的数据类型 |
V put(K key, V value) | 向 Map 集合中添加键-值对,返回 key 以前对应的 value,如果没有, 则返回 null |
V remove(Object key) | 从 Map 集合中删除 key 对应的键-值对,返回 key 对应的 value,如果没有,则返回null |
Set entrySet() | 返回 Map 集合中所有键-值对的 Set 集合,此 Set 集合中元素的数据类型为 Map.Entry |
Set keySet() | 返回 Map 集合中所有键对象的 Set 集合 |
public class MapDemo {
public static void main(String[] args) {
//定义一个Hashmap
Student s1 = new Student("003","张五",5);
Student s2 = new Student("004","张六",6);
Student s3 = new Student("001","张三",3);
Student s4 = new Student("002","张四",4);
Student s6 = new Student("002","张四",6);
Student s5 = new Student("005","张七",7);
Student s7 = new Student("006","张八",9);
Student s8 = new Student("007","张八",9);
//定义一个Hashmap,用学号来作为key,value来存放一个对象
HashMap<String,Student> hashMap = new HashMap<>();
hashMap.put(s1.getSno(),s1);
hashMap.put(s2.getSno(),s2);
hashMap.put(s3.getSno(),s3);
hashMap.put(s4.getSno(),s4);
hashMap.put(s5.getSno(),s5);
hashMap.put(s6.getSno(),s6);
hashMap.put(s7.getSno(),s7);
hashMap.put(s8.getSno(),s8);
//利用set集合来获取map集合的key,可以运用map的keySet()方法
Set<String> keys = hashMap.keySet();
Set<Map.Entry<String, Student>> entrySet = hashMap.entrySet();
for (Map.Entry<String, Student> entry : entrySet) {
System.out.println("key:"+entry.getKey()+"----姓名:"+entry.getValue().getName());
}
//查看map中是否存在某个key <boolean containsKey(Object key)>
if(hashMap.containsKey("003")){
System.out.println("存在003学号的key");
}else {
System.out.println("不存在003学号的key");
}
if(hashMap.containsKey("008")){
System.out.println("存在学号为008的key");
}else {
System.out.println("不存在学号为008的key");
}
Map<String,Student> hashmap2 = new HashMap<>();
hashmap2.put(s1.getSno(),s8);
Student student2 = hashmap2.get(s1.getSno());
System.out.println("测试学号:"+s1.getSno()+"22学号:"+student2.getSno()+"====姓名:"+student2.getName()+"===age:"+student2.getAge());
//循环输出
for (String key : keys) {
Student student = hashMap.get(key);
System.out.println("学号:"+student.getSno()+"====姓名:"+student.getName()+"====age:"+student.getAge());
}
System.out.println("年龄:"+hashMap.get("002").getAge());
}
}
输出结果:
3.2 hashmap的数据结构
3.2.1 HashMap的底层数据结构
- JDK1.7及之前:数组+链表
- JDK1.8:数组+链表+红黑树
首先抛出几个面试常见的问题:
1:为什么数组的长度为2的次幂
2:为什么hashmap的加载因子要设置成0.75
3:为什么链表的长度要大于等于8时转成了红黑树
以前计算index用下面这条:
index = hashCode % 数组长度
为什么这样,要用取模运算来计算index,也就是存放value数组的下标?
答:因为根据取模计算出来的index能够更加均匀分布
有什么问题?
在实际中,往往不会产生这样理想的状况,有时候,计算出来的index值都是一样的,index值一样时就会产生hash冲突。
hash冲突那怎么解决?
这时候,产生了第二中数据结构 --- 链表,产生冲突的元素会在该索引处以链表的形式保存。但是这时候就出现了另外一个问题,如果冲突过多,链表过长,就会影响查询效率。
怎么解决链表过长,影响查询效率的问题?
这时候,红黑树就出现了,红黑树是一颗接近平衡的二叉树,他的查询时间复杂度为O(logn),在多元素下,查询效率远要比链表的查询效率高。当链表长度大于等于8时,就会转成红黑树。
为什么链表长度大于等于8时就转成红黑树,其他不行吗?
这里需要提到一个概率论中的泊松分布,的因为链表长度大于等于8时转成红黑树正式遵循泊松分布
再来查看一下hashmap源码对泊松分布的描述
从源码描述可以看出,当个数到达8的时候,这概率已经是非常小了,所以当链表个数不多的时候,就没必要转成红黑树了,因为个数不多,转成红黑树,效率提高不了多少,并且转换过程中也会耗资源。
为什么数组的长度默认为16,或者是2的次幂呢!是10不行吗?
前面讲到为了使算出来的尽量分布均匀,使用index = hashCode(key) % 数组长度,这条公式固然可以算出均匀分布,但是存在一个问题,就是效率低下。这时候有人就想到了位运算,如何进行位运算呢?
就是: index = hashCode(key) & (数组长度 - 1)
下面我们以值为“book”的Key来演示整个过程:
1.计算book的hashcode,结果为十进制的3029737,二进制的1011100011101011101001。
2.假定HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111。
3.把以上两个结果做与运算,101110001110101110 1001 & 1111 = 1001,十进制是9,所以index=9。可以说,Hash算法最终得到的index结果,完全取决于Key的Hashcode值的最后几位。
这样的方式有什么好处呢?为什么长度必须是16或者2的幂?比如HashMap长度是10会怎么样?
这样做不但效果上等同于取模,而且还大大提高了性能。至于为什么采用16,我们可以试试长度是10会出现什么问题。
假设HashMap的长度是10,重复刚才的运算步骤:
单独看这个结果,表面上并没有问题。我们再来尝试一个新的HashCode 10 1110 0011 1010 1110 1011 :
让我们再换一个HashCode 10 1110 0011 1010 1110 1111 试试 :
这上面的例子可以看出,虽然HashCode的倒数第二第三位从0变成了1,但是运算的结果都是1001。也就是说,当HashMap长度为10的时候,有些index结果的出现几率会更大,而有些index结果永远不会出现(比如0111)!
这样,显然不符合Hash算法均匀分布的原则。
反观长度16或者其他2的幂,Length-1的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值。只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。
为什么加载因子是0.75,设成0.5或者1不行吗?
加载因子如果定的太大,比如1,这就意味着数组的每个空位都需要填满,即达到理想状态,不产生链表,但实际是不可能达到这种理想状态,如果一直等数组填满才扩容,虽然达到了最大的数组空间利用率,但会产生大量的哈希碰撞,同时产生更多的链表,显然不符合我们的需求。
但如果设置的过小,比如0.5,这样一来保证了数组空间很充足,减少了哈希碰撞,这种情况下查询效率很高,但消耗了大量空间。
因此,我们就需要在时间和空间上做一个折中,选择最合适的负载因子以保证最优化,取到了0.75
hashmap扩容是怎么扩的?
扩容是是新建了一个HashMap的底层数组,而后调用transfer方法,将就HashMap的全部元素添加到新的HashMap中(要重新计算元素在新的数组中的索引位置)。 很明显,扩容是一个相当耗时的操作,因为它需要重新计算这些元素在新的数组中的位置并进行复制处理。因此,我们在用HashMap的时,最好能提前预估下HashMap中元素的个数,这样有助于提高HashMap的性能。
另外,无论我们指定的容量为多少,构造方法都会将实际容量设为不小于指定容量的2的次方的一个数,且最大值不能超过2的30次方
3.3 hashmap和hashtable的区别
继承的父类不同
Hashtable继承自Dictionary类,而HashMap继承自AbstractMap类。但二者都实现了Map接口。
线程安全性不同
javadoc中关于hashmap的一段描述如下:此实现不是同步的。如果多个线程同时访问一个哈希映射,而其中至少一个线程从结构上修改了该映射,则它必须保持外部同步。
Hashtable 中的方法是Synchronize的,而HashMap中的方法在缺省情况下是非Synchronize的。在多线程并发的环境下,可以直接使用Hashtable,不需要自己为它的方法实现同步,但使用HashMap时就必须要自己增加同步处理。(结构上的修改是指添加或删除一个或多个映射关系的任何操作;仅改变与实例已经包含的键关联的值不是结构上的修改。)这一般通过对自然封装该映射的对象进行同步操作来完成。如果不存在这样的对象,则应该使用 Collections.synchronizedMap 方法来“包装”该映射。最好在创建时完成这一操作,以防止对映射进行意外的非同步访问,如下所示:
Map m = Collections.synchronizedMap(new HashMap(...));
Hashtable 线程安全很好理解,因为它每个方法中都加入了Synchronize
为什么hashmap是线程不安全的?
HashMap底层是一个Entry数组,当发生hash冲突的时候,hashmap是采用链表的方式来解决的,在对应的数组位置存放链表的头结点。对链表而言,新加入的节点会从头结点加入。
下面来分析一下多线程访问:
(1)在hashmap做put操作的时候会调用下面方法:
// 新增Entry。将“key-value”插入指定位置,bucketIndex是位置索引。
void addEntry(int hash, K key, V value, int bucketIndex) {
// 保存“bucketIndex”位置的值到“e”中
Entry<K,V> e = table[bucketIndex];
// 设置“bucketIndex”位置的元素为“新Entry”,
// 设置“e”为“新Entry的下一个节点”
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
// 若HashMap的实际大小 不小于 “阈值”,则调整HashMap的大小
if (size++ >= threshold)
resize(2 * table.length);
}
在hashmap做put操作的时候会调用到以上的方法。现在假如A线程和B线程同时对同一个数组位置调用addEntry,两个线程会同时得到现在的头结点,然后A写入新的头结点之后,B也写入新的头结点,那B的写入操作就会覆盖A的写入操作造成A的写入操作丢失
(2)删除键值对的方法
<span style="font-size: 18px;"> </span>// 删除“键为key”的元素
final Entry<K,V> removeEntryForKey(Object key) {
// 获取哈希值。若key为null,则哈希值为0;否则调用hash()进行计算
int hash = (key == null) ? 0 : hash(key.hashCode());
int i = indexFor(hash, table.length);
Entry<K,V> prev = table[i];
Entry<K,V> e = prev;
// 删除链表中“键为key”的元素
// 本质是“删除单向链表中的节点”
while (e != null) {
Entry<K,V> next = e.next;
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {
modCount++;
size--;
if (prev == e)
table[i] = next;
else
prev.next = next;
e.recordRemoval(this);
return e;
}
prev = e;
e = next;
}
return e;
}
(3)addEntry中当加入新的键值对后对总数量超过门限值的时候,会调用一个resize操作,代码如下:
// 重新调整HashMap的大小,newCapacity是调整后的容量
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
//如果就容量已经达到了最大值,则不能再扩容,直接返回
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
// 新建一个HashMap,将“旧HashMap”的全部元素添加到“新HashMap”中,
// 然后,将“新HashMap”赋值给“旧HashMap”。
Entry[] newTable = new Entry[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
这个操作会新生成一个新的容量的数组,然后对原数组的所有键值对重新进行计算和写入新的数组,之后指向新生成的数组。
当多个线程同时检测到总数量超过门限值的时候就会同时调用resize操作,各自生成新的数组并rehash后赋给该map底层的数组table,结果最终只有最后一个线程生成的新数组被赋给table变量,其他线程的均会丢失。而且当某些线程已经完成赋值而其他线程刚开始的时候,就会用已经被赋值的table作为原始数组,这样也会有问题。
是否提供contains方法?
HashMap把Hashtable的contains方法去掉了,改成containsValue和containsKey,因为contains方法容易让人引起误解。
Hashtable则保留了contains,containsValue和containsKey三个方法,其中contains和containsValue功能相同。
我们看一下Hashtable的ContainsKey方法和ContainsValue的源码:
public boolean containsValue(Object value) {
return contains(value);
}
// 判断Hashtable是否包含“值(value)”
public synchronized boolean contains(Object value) {
//注意,Hashtable中的value不能是null,
// 若是null的话,抛出异常!
if (value == null) {
throw new NullPointerException();
}
// 从后向前遍历table数组中的元素(Entry)
// 对于每个Entry(单向链表),逐个遍历,判断节点的值是否等于value
Entry tab[] = table;
for (int i = tab.length ; i-- > 0 ;) {
for (Entry<K,V> e = tab[i] ; e != null ; e = e.next) {
if (e.value.equals(value)) {
return true;
}
}
}
return false;
}
// 判断Hashtable是否包含key
public synchronized boolean containsKey(Object key) {
Entry tab[] = table;
/计算hash值,直接用key的hashCode代替
int hash = key.hashCode();
// 计算在数组中的索引值
int index = (hash & 0x7FFFFFFF) % tab.length;
// 找到“key对应的Entry(链表)”,然后在链表中找出“哈希值”和“键值”与key都相等的元素
for (Entry<K,V> e = tab[index] ; e != null ; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
return true;
}
}
return false;
}
下面我们看一下HashMap的ContainsKey方法和ContainsValue的源码:
// HashMap是否包含key
public boolean containsKey(Object key) {
return getEntry(key) != null;
}
// 返回“键为key”的键值对
final Entry<K,V> getEntry(Object key) {
// 获取哈希值
// HashMap将“key为null”的元素存储在table[0]位置,“key不为null”的则调用hash()计算哈希值
int hash = (key == null) ? 0 : hash(key.hashCode());
// 在“该hash值对应的链表”上查找“键值等于key”的元素
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
// 是否包含“值为value”的元素
public boolean containsValue(Object value) {
// 若“value为null”,则调用containsNullValue()查找
if (value == null)
return containsNullValue();
// 若“value不为null”,则查找HashMap中是否有值为value的节点。
Entry[] tab = table;
for (int i = 0; i < tab.length ; i++)
for (Entry e = tab[i] ; e != null ; e = e.next)
if (value.equals(e.value))
return true;
return false;
}
Key和value是否允许null值
其中key和value都是对象,并且不能包含重复key,但可以包含重复的value。
通过上面的ContainsKey方法和ContainsValue的源码我们可以很明显的看出:
Hashtable中,key和value都不允许出现null值。但是如果在Hashtable中有类似put(null,null)的操作,编译同样可以通过,因为key和value都是Object类型,但运行时会抛出NullPointerException异常,这是JDK的规范规定的。
HashMap中,null可以作为键,这样的键只有一个;可以有一个或多个键所对应的值为null。当get()方法返回null值时,可能是 HashMap中没有该键,也可能使该键所对应的值为null。因此,在HashMap中不能由get()方法来判断HashMap中是否存在某个键, 而应该用containsKey()方法来判断。
两个遍历方式上的内部实现上不同
Hashtable、HashMap都使用了 Iterator。而由于历史原因,Hashtable还使用了Enumeration的方式 。
hash值不同
哈希值的使用不同,HashTable直接使用对象的hashCode。而HashMap重新计算hash值。
hashCode是jdk根据对象的地址或者字符串或者数字算出来的int类型的数值。
Hashtable计算hash值,直接用key的hashCode(),而HashMap重新计算了key的hash值,Hashtable在求hash值对应的位置索引时,用取模运算,而HashMap在求位置索引时,则用与运算,且这里一般先用hash&0x7FFFFFFF后,再对length取模,&0x7FFFFFFF的目的是为了将负的hash值转化为正值,因为hash值有可能为负数,而&0x7FFFFFFF后,只有符号外改变,而后面的位都不变。
内部实现使用的数组初始化和扩容方式不同
HashTable在不指定容量的情况下的默认容量为11,而HashMap为16,Hashtable不要求底层数组的容量一定要为2的整数次幂,而HashMap则要求一定为2的整数次幂。
Hashtable扩容时,将容量变为原来的2倍加1,而HashMap扩容时,将容量变为原来的2倍。
Hashtable和HashMap它们两个内部实现方式的数组的初始大小和扩容的方式。HashTable中hash数组默认大小是11,增加的方式是 old*2+1。
四、List集合
4.1 list集合的一些特性
1.List接口继承Collection接口,实现了List接口的类称为List集合。
2.在List集合中允许出现重复的元素,所有元素以线性方式进行存储,可以通过索引来访问集合中指定的元素。List集合的元素的存储顺序和取出顺序一致。
3.List不但继承了Collection接口中的全部方法,还增加了一些根据元素位置索引来操作集合的特有方法。
注:集合中不能定义为基本数据类型如(int、char、float……),应该定义为包装的数据类型(Integer、String...)
ArrayList集合和LinkedList集合的区别:
1、ArrayList集合底层是数组,而且是Object [] 类型;而LinkedList集合底层是链表。
2、ArrayList集合查询数据很快,但是增删数据很慢;LinkedList集合增删数据很快。但是查询数据很慢。
ist集合中常用方法:
add(Object object):向集合中添加数据
get(int index):获取集合中指定的索引位置的元素数值
size():获取集合的长度
isEmpty():判断集合是否为空
contains(Object object);//判断结合中是否含有指定的这个元素
set(int index, Object object):更改集合中指定索引位置的元素数值
toArray():将集合转换为数组
remove(int index):删除集合中指定索引位置的元素数值
clear():清空集合元素数值,谨慎使用
问题:在list中随机插入100个1-100的数,用set对这些数进行去重,最后按从小到大顺序输出来。
public class TestListAndTreeset {
//定义一个函数,将list进行去重跟排序的操作
public static List removeDulByTreeSet(List<Integer> list){
TreeSet treeSet = new TreeSet(list);
list.clear();
list.addAll(treeSet);
return list;
}
public static void main(String[] args) {
List<Integer> list = new ArrayList();
Random random = new Random();
while(list.size()<100){
int s = random.nextInt(101);
list.add(s);
}
list = removeDulByTreeSet(list);
for (int i = 0; i < list.size(); i++) {
System.out.print(list.get(i)+" ");
}
System.out.println();
System.out.println("总数:"+list.size());
}
}
4.2 ArrayList集合
1.ArrayList是List接口的一个实现类,它是程序中最常见的一种集合类;
2.在ArrayList内部封装了一个数组对象,初始长度缺省为10,当存入的元素超过数组长度时,ArrayList会在
内存中分配一个更大的数组来重新容纳这些元素,因此可以将ArrayList集合看作一个长度可变的数组;
3.ArrayList集合类的大部分方法都是从父类Collection和List继承过来的,其中add()方法和get()方法用于
实现元素的添加和读取。
4.ArrayList集合的内部使用一个数组来保存元素。在删除元素时,会将被删除元素之后的元素都向前移一个位
置以填补空位;而在用add(intindex, Object element)方法添加元素时,是把元素插入index指向的位置,
先把该位置的元素以及后续元素都向后移一个位置,如果超出数组的容量,会创建更大的新数组。因为增删元
素会导致大量的内存操作,所以效率低,但ArrayList集合允许通过索引随机的访问元素,查询效率高。
5.集合和数组一样,索引的取值范围是从0开始,到size-1为止(size是集合的长度),不能超出此范围,否则
会引发异常。add(Objecto)方法是把元素添加到集合的尾部,而add(intindex, Object o)是把元素添加到由
索引index指定的位置。
代码测试,新建一个ListDemo测试一下
/**
* @description:
* @author: WEN
* @create: 2020-08-16
**/
public class ListDemo1 {
public static void main(String[] args) {
ArrayList alist = new ArrayList();
//添加各种类型的元素到list集合
alist.add(11);
alist.add(22);
alist.add(55);
alist.add(33);
alist.add(5.00);
alist.add(6.00);
alist.add(3.0);
alist.add("字符串1");
alist.add("字符串4");
alist.add("字符串1");
alist.add("字符串2");
alist.add('A');
alist.add('B');
//获取集合的长度
int length = alist.size();
System.out.println("集合长度是:"+length);
//判断集合是否为空
boolean flag = alist.isEmpty();
//判断结合中是否还有55这个数据
boolean flag2 = alist.contains(55);
//集合是否为空:false
System.out.println("集合是否为空:"+flag);
//集合中是否含有数字55:true
System.out.println("集合中是否含有数字55:"+flag2);
//获取集合下标为7的元素值
Object obj = alist.get(7);
System.out.println("集合下标为7的元素值:"+obj);
//更改集合下标为4的元素的值,将其修改为:张无忌
alist.set(4, "张无忌");
Object[] obj2 = alist.toArray();
System.out.println("数组obj2的数值是:");
for (Object object : obj2) {
System.out.println("obj2输出:"+object);
}
//删除集合下标为3的元素数值
alist.remove(3);
// alist.clear() 清空集合,谨慎使用
System.out.println("alist集合的数值是:");
for (Object object : alist) {
System.out.println(object);
}
}
}
输出结果:
集合长度是:13
集合是否为空:false
集合中是否含有数字55:true
集合下标为7的元素值:字符串1
数组obj2的数值是:
obj2输出:11
obj2输出:22
obj2输出:55
obj2输出:33
obj2输出:张无忌
obj2输出:6.0
obj2输出:3.0
obj2输出:字符串1
obj2输出:字符串4
obj2输出:字符串1
obj2输出:字符串2
obj2输出:A
obj2输出:B
alist集合的数值是:
11
22
55
张无忌
6.0
3.0
字符串1
字符串4
字符串1
字符串2
A
B
给ArrayList指定类型
public class ListDemo2 {
public static void main(String[] args) {
ArrayList<String> alist = new ArrayList<>();
//向集合alist中添加数据
alist.add("赵云");
alist.add("张飞");
alist.add("亚索");
alist.add("张君宝");
alist.add("张敏");
alist.add("周星驰");
//获取集合的长度
int length = alist.size();
System.out.println("集合长度是:"+length);
//判断集合是否为空
boolean flag = alist.isEmpty();
//判断结合中是否还有55这个数据
boolean flag2 = alist.contains(55);
//集合是否为空:false
System.out.println("集合是否为空:"+flag);
//集合中是否含有数字55:false
System.out.println("集合中是否含有数字55:"+flag2);
//获取集合下标为4的元素值
Object obj = alist.get(4);
//集合下标为4的元素值:字符串5
System.out.println("集合下标为4的元素值:"+obj);
Object[] obj2 = alist.toArray();
for(Object object : obj2) {
System.out.println("obj2输出:"+object);
}
alist.remove(2);
alist.set(3, "我是超人");
System.out.println("alist集合的数值是:");
for (Object object : alist) {
System.out.println(object);
}
}
}
输出结果:
集合长度是:6
集合是否为空:false
集合中是否含有数字55:false
集合下标为4的元素值:张敏
obj2输出:赵云
obj2输出:张飞
obj2输出:亚索
obj2输出:张君宝
obj2输出:张敏
obj2输出:周星驰
alist集合的数值是:
赵云
张飞
张君宝
我是超人
周星驰
4.3数组和list集合之间的转换
1、List的toArray()方法用于将集合转换成数组,但实际上改方法是在Collection中定义的,所以所有的集合都具备这个功能,
其有两个方法:Object[] toArray() 和 T<T> [] toArray(T[] a)第二个方法是比较常用的 ,我们可以传入一个指定类型的数组,
该数据的元素类型应与集合的元素类型一致,返回值则是转换后的数组,该数组会保存集合中的所有元素。
eg:
List<String> list = new ArrayList<String>();
list.add("a");
list.add("b");
list.add("c");
String[] strArr = list.toArray(new String[] {});
System.out.println(Arrays.toString(strArr)); // [a, b, c]
2、List将数组转换成
ListString[] strArr = { "a", "b", "c" };
List<String> list = Arrays.asList(strArr);
System.out.println(list); // [a, b, c]