画师:Recneps-SAIS     封面ID:51123044

1. Java基础知识

1.1 Java命名规范

参考链接:Java 命名规范

  1. 项目名全部小写。

  2. 包名全部小写。

  3. 类名首字母大写,其余组成词首字母依次大写。

  4. 变量名,方法名首字母小写,如果名称由多个单词组成,除首字母外的每个单词的首字母都要大写。

  5. 常量名全部大写。

  6. 所有命名规则必须遵循以下规则 :

    • 名称只能由字母、数字、下划线、$符号组成。

    • 不能以数字开头。

    • 名称不能使用Java中的关键字。

    • 坚决不允许出现中文及拼音命名。

1.2 static 关键字

参考链接:点击查看

总结:

static是java中非常重要的一个关键字,而且它的用法也很丰富,主要有四种用法:

  1. 用来修饰成员变量,将其变为类的成员,从而实现所有对象对于该成员的共享;
  2. 用来修饰成员方法,将其变为类方法,可以直接使用 “类名.方法名” 的方式调用,常用于工具类;
  3. 静态块用法,将多个类成员放在一起初始化,使得程序更加规整,其中理解对象的初始化过程非常关键;
  4. 静态导包用法,将类的方法直接导入到当前类中,从而直接使用 “方法名” 即可调用类方法,更加方便。

1.3 final 关键字

参考链接:点击查看

总结:

final关键字是我们经常使用的关键字之一,它的用法有很多,但是并不是每一种用法都值得我们去广泛使用。它的主要用法有以下四种:

  1. 用来修饰数据,包括成员变量和局部变量,该变量只能被赋值一次且它的值无法被改变。对于成员变量来讲,我们必须在声明时或者构造方法中对它赋值;
  2. 用来修饰方法参数,表示在变量的生存期中它的值不能被改变;
  3. 修饰方法,表示该方法无法被重写;
  4. 修饰类,表示该类无法被继承。

上面的四种方法中,第三种和第四种方法需要谨慎使用,因为在大多数情况下,如果是仅仅为了一点设计上的考虑,我们并不需要使用final来修饰方法和类。

1.4 this 关键字

参考链接:Java之路:this关键字的用法

1.5 null 与 “”

参考链接:java中NULL与" "的区别

  • null是没有地址
  • ""是有地址但是里面的内容是空的

1.6 变量初始化

在Java中,成员变量(定义在方法外,类中)和静态变量可以不用初始化,Java会自动根据其类型赋予默认值。

Java各个类型默认值:

数据类型 默认值
byte 0
short 0
int 0
long 0L
float 0.0f
double 0.0d
char ‘u0000’
String (or any object) null
boolean false

而局部变量使用时(注意是使用时,如果只是声明而不使用是不会报错的)必须初始化,但方法中的形参除外(main()方法有String[] args形参,在main()方法中直接打印它并不报错)。

1.7 数据类型

参考链接:[Java基础]Java中字符串string属于什么数据类型?

Java数据类型

Java数据类型在内存中的存储:

  • 基本数据类型的存储原理:所有的简单数据类型不存在“引用”的概念,基本数据类型都是直接存储在内存中的内存栈上的,数据本身的值就是存储在栈空间里面,而 Java 语言里面八种数据类型就是这种存储模型;

  • 引用类型的存储原理:引用类型继承于 Object 类(也是引用类型),它们都是按照 Java 里面存储对象的内存模型来进行数据存储的,使用 Java 内存堆和内存栈来进行这种类型的数据存储,简单地讲,“引用”是存储在有序的内存栈上的,而对象本身的值存储在内存堆上的;

区别:基本数据类型和引用类型的区别主要在于基本数据类型是分配在栈上的,而引用类型是分配在堆上的(需要使用 Java 中的栈、堆概念)。

那 Java 中字符串 String 属于什么数据类型?

Java 中的字符串 String 属于引用数据类型,因为 String 是一个类。

1.8 重载和重写的区别

定义上的区别

1、重载是指不同的函数使用相同的函数名,但是函数的参数个数或类型不同。调用的时候根据函数的参数来区别不同的函数。

2、重写(也叫覆盖)是指在派生类中重新对基类中的虚函数(注意是虚函数)重新实现。即函数名和参数都一样,只是函数的实现体不一样。

序号 区别点 重载 重写
1 单词 Overloading Overriding
2 定义 方法名称相同,参数的类型或个数不同 方法名称、参数列表、返回值类型全部相同
3 修饰符 与修饰符无关 重写的方法修饰符不小于父类的方法
4 范围 发生在一个类中 发生在继承中

对于重载来说,返回值类型可以相同也可以不相同,但不能通过返回类型是否相同来判断重载。

规则上的区别

1、重载的规则:

① 必须具有不同的参数列表(参数类型、参数个数甚至是参数顺序)。

② 可以有不同的访问修饰符。

③ 可以抛出不同的异常。

2、重写方法的规则:

① 参数列表必须完全与被重写的方法相同

② 重写方法返回值类型应比父类方法返回值类型更小或相等

③ 访问修饰符的限制要大于或等于被重写方法的访问修饰符。

④ 重写方法一定不能抛出新的检查异常或者比被重写方法申明更加宽泛的检查型异常。

类的关系上的区别

重写是子类和父类之间的关系,是垂直关系;

重载是同一个类中方法之间的关系,是水平关系。

面试问重载和重写的区别

参考链接: Java 重写和重载的区别

答:方法的重载和重写都是实现多态的方式,区别在于前者实现的是编译时的多态性,而后者实现的是运行时的多态性。重载发生在一个类中,同名的方法如果有不同的参数列表(参数类型不同、参数个数不同或者二者都不同)则视为重载;重写发生在子类与父类之间,重写要求子类被重写方法与父类被重写方法有相同的参数列表,有兼容的返回类型,比父类被重写方法更好访问,不能比父类被重写方法声明更多的异常(里氏代换原则)。重载对返回类型没有特殊的要求,不能根据返回类型进行区分。

1.9 访问修饰符

参考链接: Java 修饰符

Java中,可以使用访问控制符来保护对类、变量、方法和构造方法的访问。Java 支持 4 种不同的访问权限。

  • default (即默认,什么也不写): 在同一包内可见,不使用任何修饰符。使用对象:类、接口、变量、方法。
  • private : 在同一类内可见。使用对象:变量、方法。 注意:不能修饰类(外部类)
  • public : 对所有类可见。使用对象:类、接口、变量、方法
  • protected : 对同一包内的类和所有子类可见。使用对象:变量、方法。 注意:不能修饰类(外部类)

可以通过下表来说明访问权限:

修饰符 当前类 同一包内 子孙类(同一包) 子孙类(不同包) 其他包
public Y Y Y Y Y
protected Y Y Y Y/N(说明 N
default Y Y Y N N
private Y N N N N

因此,对于这些访问修饰符来说,范围是: public > protected > default > private

1.10 运算符自动转换类型

在 Java 中处理数据时,经常会遇到类型转换的问题,我们常用的方式是强制类型转换,就是在需要转换类型的数据前加上一个括号,然后在括号里写上需要转换的类型。

但有时这样写会出现很多括号,使代码变得繁琐。

我们可以使用不同的运算符,编译器在编译时会进行优化,自动进行类型转换。

赋值运算符

复合赋值运算符会自动进行强制类型转换 ,这是一种编译器的优化。合理地使用这个优化,可以减少括号的出现。

什么是复合赋值运算符?

赋值运算符应该都懂,就是 = 。复合赋值运算符就是 +=-=*=&= 等类似的运算符。

那前面说的是什么意思?

看下面的例子:

1
2
3
4
5
static void test() {
int i = 10;
i = i / 2.5; // 报错,double 转 int 会有精度缺失
System.out.println(i);
}

在上面的例子中, i 是 int 类型的,但是 2.5 又是 double 类型的,最后又赋值给 int 类型的 i。这样做会出现精度的缺失,因此编译器会报错。但如果硬要进行转换也不是不行,可以使用强制类型转换,比如:

1
2
3
4
5
static void test() {
int i = 10;
i = (int) ( i / 2.5); // 强制类型转换
System.out.println(i); // 输出 4
}

像上面这样进行强制类型转换后就不会报错了。我们知道 i = i / a 就等于 i /= a ,因此在这里可以使用以下复合赋值运算符:

1
2
3
4
5
static void test() {
int i = 10;
i /= 2.5; // 使用运算符
System.out.println(i); // 输出 4
}

使用了复合赋值运算符后,结果自动进行了强制类型转换,代码也变得整洁起来。

三元运算符

三元运算符的研究可以根据这样一道题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static void test2() {
char a = 'a';
int i = 96;

System.out.println(2 == 2 ? i : 9.0); // 96.0

System.out.println(2 == 2 ? 98 : a); // b

System.out.println(false ? 21478364L : a); // 97

System.out.println(2 == 2 ? a : i); // 97

System.out.println(2 == 2 ? 99 : 9.0); // 99.0

System.out.println(2 == 2 ? 99 : 'b'); // c
}

最终的输出结果已使用注释写在了代码中,如果不了解三元运算符的类型转换,会觉得输出结果很奇怪,但是只要知道了其中的规则,这点东西还不是手到擒来? 😜

三元操作符类型的转换规则:
1、若两个操作数不可转换,则不做转换,返回值为 Object 类型

2、若两个操作数是明确类型的表达式(比如变量),则按照正常的二进制数字来转换。比如 int 类型转换为 long 类型,long 类型转换为 float 类型等,简单来说就是转换成精度更大的类型

基本数据类型精度比较

3、若两个操作数中有一个是数字 S ,另外一个是表达式,且其类型标示为 T ,若数字 S 在 T 的范围内,则转换为 T 类型;若 S 超出了 T 类型的范围,则 T 转换为 S 类型

4、若两个操作数都是直接量数字,则返回值类型为精度高(范围大)的

我们发现下列代码的输出结果似乎不符合上述规则:

1
System.out.println(2 == 2 ? 99 : 'b'); // c

最终居然输出的是 c

数字 99 是 int 类型的, ‘b’ 是 char 类型的,int 类型的数据精度比 char 类型的大,最终输出应该是 int 类型的,为什么会是 char 类型的 c 呢?

这里涉及到一个 JVM 的知识:JVM 在给数值分配数据类型时,数字 99 并不是 int 类型的,而是 byte / short (反编译字节码文件可看出),故结果会变成为 ASCII 码 99 对应的字符 c


有关 Java 运算符其他的知识,可以参考这篇文章: java类型转换和赋值运算符

1.11 Java 中只有值传递?

先给出结论:Java 中只有值传递,没有引用传递!在解释之前,先明白值传递和引用传递的区别:

按值调用(call by value)是指方法接收的是调用者提供的值,而按引用调用(call by reference)指方法接收的是调用者提供的变量地址。

既然 Java 是值传递,也就是说,方法得到的是所有参数值的一个拷贝,换言之,方法不能改变传递给方法的任何参数变量的内容。

除此之外,我们还要明白基本数据类型和引用数据类型之间的区别:

比如:

1
2
int num = 20;
String str = "mofan";
数据类型比较

简单来说,对于基本数据类型,值就直接保存在变量中,而对于引用数据类型来说,变量中保存的是对象的地址。

来看几个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* @author 默烦 2020/10/30
*/
public class CallByValueTest {
public static void main(String[] args) {
int num1 = 1;
int num2 = 2;

swap(num1, num2); // 交换两个数

System.out.println("num1 = " + num1);
System.out.println("num2 = " + num2);
}

public static void swap(int a1, int a2) {

int temp = a1;
a1 = a2;
a2 = temp;

System.out.println("a1 = " + a1);
System.out.println("a2 = " + a2);
}
}

运行后控制台的结果为:

1
2
3
4
a1 = 2
a2 = 1
num1 = 1
num2 = 2

num1 和 num2 的原始值分别是 1 和 2,尽管经过了交换,但是最后打印出还是原值。

原因很简单,正是因为 Java 是值传递的,所以参数中传递的参数是基本数据类型 num1 和 num2 的拷贝,在函数中交换的也是那份拷贝的值,而不是数据本身,因此最后输出的还是原值。

基本数据类型值传递

那再看看引用数据类型,数组也是引用数据类型,我们来测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* @author 默烦 2020/10/30
*/
public class CallByValueTest {
public static void main(String[] args) {
int[] arr = {1, 2, 3, 4, 5};
System.out.println(arr[0]);

change(arr);
System.out.println(arr[0]);
}

public static void change(int[] array) {
array[0] = 0; // 将数组第一个元素设置为 0
}
}

运行查看输出结果有:

1
2
1
0

这是怎么回事?不是说 Java 是值传递吗,怎么方法的修改影响了原数据?

可以看下面这张图:

数组值传递_1

通过图就很容易理解了,array 被初始化成 arr 的拷贝,它们都指向 heap 中某个数组对象,因此将数组 array 第一个元素值进行修改后,arr 也会发生改变。

感觉有点绕?

在看下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class CallByValueTest {
public static void main(String[] args) {
String[] str = {"ABC", "123"};
for (String s : str) {
System.out.print(s);
}
System.out.println();

change(str);
System.out.println();

for (String s : str) {
System.out.print(s);
}
}

public static void change(String[] b) {
String[] a = {"abc"};
b = a;
for (String s : b) {
System.out.print(s);
}
}
}

运行后查看输出结果:

1
2
3
ABC123
abc
ABC123

这样的输出结果是不是就更预期一样了。再看看下图就更好理解了,未修改前:

数组值传递_2

修改后:

数组值传递_3

通过上面两张图的对比,可以明白:修改方法并没有改变 str 的对象引用,change 方法的参数 b 初始化为对象引用的拷贝,修改的只是拷贝而不是原对象,因此原对象不会发生改变。

需要理解一下最后两个示例,针对其他引用类型也和给出的示例一样。

总之一句话:Java 中只有值传递。

1.12 如何理解面向对象?

参考链接:什么是面向对象思想?面试必答题

面向对象是一种思想,是基于面向过程而言的,就是说面向对象是将功能等通过对象来实现,将功能封装进对象之中,让对象去实现具体的细节;这种思想是将数据作为第一位,而方法或者说是算法作为其次,这是对数据一种优化,操作起来更加的方便,简化了过程。面向对象有三大特征:封装性、继承性、多态性,其中封装性指的是隐藏了对象的属性和实现细节,仅对外提供公共的访问方式,这样就隔离了具体的变化,便于使用,提高了复用性和安全性。对于继承性,就是两种事物间存在着一定的所属关系,那么继承的类就可以从被继承的类中获得一些属性和方法;这就提高了代码的复用性。继承是作为多态的前提的。多态是说父类或接口的引用指向了子类对象,这就提高了程序的扩展性,也就是说只要实现或继承了同一个接口或类,那么就可以使用父类中相应的方法,提高程序扩展性,但是多态有一点不好之处在于:父类引用不能访问子类中的成员的特有方法和属性。

举例来说:就是:比如说你要去饭店吃饭,你只需要去饭店,找到饭店的服务员,跟她说你要吃什么,然后就会给你做出来让你吃,你并不需要知道这个饭是怎么做的,你只需要面向这个服务员,告诉他你要吃什么,然后他也只需要面向你吃完收到钱就好,不需要知道你怎么对这个饭怎么吃的。

面向对象的特点:

1、将复杂的事情简单化。

2、面向对象将以前的过程中的执行者,变成了指挥者。

3、面向对象这种思想是符合现在人们思考习惯的一种思想。

面向对象的三大特征:

封装,继承和多态。

然后谈一下对这三大特征的理解 ~

1.13 嵌套类与内部类

在 Java 中,可以在一个类中再定义一个类,被定义的这个类成为嵌套类。嵌套类外围的类称为外部类。最顶层类不能是静态类,只有嵌套类才能是静态的。

嵌套类分为静态嵌套类和非静态嵌套类,非静态嵌套类又被成为内部类。

静态嵌套类与内部类(非静态嵌套类)的区别

  • 静态嵌套类不需要外部类对象的引用,而内部类需要,因此内部类对象的创建依赖与外部类对象的创建。
  • 静态嵌套类不能访问外部类中非静态的成员(变量和方法),而内部类既可以访问外部类的静态成员,还可以访问非静态成员,即使被声明为 private
  • 如果不要求嵌套类的创建必须依赖外部类的引用,通常选择静态嵌套类作为使用的嵌套类。

创建内部类对象

内部类对象的创建依赖于外部类对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Outer {

public Inner getInner() {
return new Inner();
}

public class Inner {
}

public static void main(String[] args) {
Outer outer = new Outer();

Inner firstInner = outer.getInner();
Inner secondInner = outer.new Inner();
}
}

直接创建内部类对象的语法是:外部类对象.new 内部类名()

内部类引用外部类同名的域

1
2
3
4
5
6
7
8
9
10
public class Outer {
private String str = "I am Outer";
public class Inner {
private String str = "I am Inner";
public void printText() {
System.out.println(str);
System.out.println(Outer.this.str);
}
}
}

使用 外部类名.this.变量名 的形式进行引用。

注意: 内部类不能包含静态声明(常量除外,即被声明为 static final)。

局部类

局部内与内部类类似,它定义在方法或作用域的内部,比如:

1
2
3
4
5
6
class Outer {
public void method() {
class Local { }
Local local = new Local();
}
}

局部类只能在定义的作用域内被访问,它可以像内部类一样访问外部类的方法或变量。

局部类访问其所在方法的局部变量或方法参数时,这些参数必须是 finaleffectively final。所谓 effectively final 表示变量在初始化后不再被改变。

局部类也能在静态方法中被声明,此时的局部类只能访问外部类的静态信息。除此之外,此时的局部类不能包含静态声明,尽管是在静态方法中被声明,但它依旧是非静态的。

匿名类

如果嵌套类没有名称,就称为匿名类。匿名类通常作为某个类的子类或接口的实现来声明。

1
2
3
4
5
6
7
8
9
public interface MyInterface {
public void doSomething();
}

MyInterface instance = new MyInterface() {
public void doSomething() {
System.out.println("Anonymous class");
}
};

匿名类可以访问外部类的成员变量,也可以访问被声明为 finaleffectively final 的局部变量。

匿名类中可以声明非静态的变量或方法, 不能声明静态的变量(常量除外)或方法, 由于匿名类没有名称,因此也不能 显式 声明构造方法。

2. Java 方法

2.1 Arrays.sort()

参考链接: Java的Arrays.sort()方法到底用的什么排序算法

Arrays.sort()排序方式

2.2 遍历 Map 的几种方式

参考链接:HashMap 的 7 种遍历方式与性能分析!(强烈推荐)

迭代器遍历

使用 entrySet 遍历:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* @author 默烦 2020/10/10
*/
public class MapTest {
public static void main(String[] args) {
HashMap<Integer, String> map = new HashMap<>();
map.put(1, "List");
map.put(2, "Queue");
map.put(3, "Dequeue");
map.put(4, "Set");
map.put(5, "Map");

Iterator<Map.Entry<Integer, String>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<Integer, String> entry = iterator.next();
System.out.println(entry.getKey()+ " ---> " + entry.getValue());
}
}
}

使用 keySet 遍历:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void main(String[] args) {
HashMap<Integer, String> map = new HashMap<>();
map.put(1, "List");
map.put(2, "Queue");
map.put(3, "Dequeue");
map.put(4, "Set");
map.put(5, "Map");

Iterator<Integer> iterator = map.keySet().iterator();
while (iterator.hasNext()) {
Integer next = iterator.next();
System.out.println(next + " ---> " + map.get(next));
}
}

ForEach 遍历

如果使用的 IDE 是 IDEA,在使用迭代器进行遍历时,可以按照 IDE 的提示转换成 ForEach 遍历。

使用 entrySet 遍历:

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) {
HashMap<Integer, String> map = new HashMap<>();
map.put(1, "List");
map.put(2, "Queue");
map.put(3, "Dequeue");
map.put(4, "Set");
map.put(5, "Map");

for (Map.Entry<Integer, String> entry : map.entrySet()) {
System.out.println(entry.getKey() + " ---> " + entry.getValue());
}
}

使用 keySet 遍历:

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) {
HashMap<Integer, String> map = new HashMap<>();
map.put(1, "List");
map.put(2, "Queue");
map.put(3, "Dequeue");
map.put(4, "Set");
map.put(5, "Map");

for (Integer integer : map.keySet()) {
System.out.println(integer + " ---> " + map.get(integer));
}
}

Lambda 表达式遍历

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) {
HashMap<Integer, String> map = new HashMap<>();
map.put(1, "List");
map.put(2, "Queue");
map.put(3, "Dequeue");
map.put(4, "Set");
map.put(5, "Map");

map.forEach((integer, s) -> {
System.out.println(integer + " ---> " + s);
});
}

Stream 流遍历

单线程遍历:

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) {
HashMap<Integer, String> map = new HashMap<>();
map.put(1, "List");
map.put(2, "Queue");
map.put(3, "Dequeue");
map.put(4, "Set");
map.put(5, "Map");

map.entrySet().stream().forEach(entry -> {
System.out.println(entry.getKey() + " ---> " + entry.getValue());
});
}

使用 Stream 流 单线程 遍历时,经过 IDEA 的智能提示可以变成 Lambda 表达式的遍历。

多线程遍历:

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) {
HashMap<Integer, String> map = new HashMap<>();
map.put(1, "List");
map.put(2, "Queue");
map.put(3, "Dequeue");
map.put(4, "Set");
map.put(5, "Map");

map.entrySet().parallelStream().forEach(entry -> {
System.out.println(entry.getKey() + " ---> " + entry.getValue());
});
}

编写一个方法将 Map 的 value 存入 List 并返回

HashMap<Integer, String>为例:

1
2
3
4
5
6
7
8
9
10
11
private static List<String> getValueList (HashMap<Integer, String> map) {
// 获取集合中所有 value,想遍历 value 使用 迭代器
Collection<String> values = map.values();
// 获取集合中所有 key
// Set<Integer> integers = map.keySet();
ArrayList<String> list = new ArrayList<>();
for (String value : values) {
list.add(value);
}
return list;
}

上述代码可以直接压缩成两行,瞬间变得优雅:

1
2
3
4
private static List<String> getValueList (HashMap<Integer, String> map) {
Collection<String> values = map.values();
return new ArrayList<>(values);
}

删除 Map 中某个元素

迭代器方式删除:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) {
HashMap<Integer, String> map = new HashMap<>();
// 省略插入的元素

Iterator<Map.Entry<Integer, String>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<Integer, String> next = iterator.next();
// 删除 key 为 1 的元素
if (next.getKey() == 1){
iterator.remove();
} else {
System.out.println(next.getKey() + " ---> " + next.getValue());
}
}
}

注意: For 循环中删除元素的方式不安全,因此不建议在 For 循环中删除 Map 中的元素!

Lambda 表达式方式删除:

1
2
3
4
5
6
7
8
9
10
public static void main(String[] args) {
HashMap<Integer, String> map = new HashMap<>();
// 省略插入的元素

// 删除 key 为 1 的元素
map.keySet().removeIf(key -> key == 1);
map.forEach((integer, s) -> {
System.out.println(integer + " ---> " + s);
});
}

Stream 流删除:

1
2
3
4
5
6
7
8
public static void main(String[] args) {
HashMap<Integer, String> map = new HashMap<>();
// 省略插入的元素

map.entrySet().stream().filter(entry -> entry.getKey() != 1).forEach(entry -> {
System.out.println(entry.getKey() + " ---> " + entry.getValue());
});
}

总结

有这么多的遍历方式,应该选择哪种呢?

首先从性能出发,看看谁性能高。

谁的性能最高?

经过测试,除了使用并行循环的 parallelStream 性能比极高之外(毕竟是多线程),其他几种遍历在性能方面几乎没有差异。

KeySet 的性能远不如 EntrySet

还可以将遍历的代码编译成字节码然后查看,会发现使用 EntrySetKeySet 代码差别不是很大,KeySet 的性能并不是远不如 EntrySet,单从性能的角度来说 EntrySetKeySet 几乎是相近的,但从代码的优雅型和可读性来说,还是推荐使用 EntrySet 更好!

如何删除 Map 中的元素?

从代码中看,除了使用 For 循环外删除元素,其他三种都是可以安全地删除 Map 中的元素的,但要论简洁和优雅,还是使用 Lambda 表达式和 Stream 流更加奈斯。

到底如何选择?

Lambda 表达式和 Stream 流是 JDK8 的新特性,如果使用 JDK8 建议使用这两种方式,在这两种方式中,又更加推荐使用 Stream 流,它使用 parallelStream 遍历 Map 时性能高,也能安全删除元素。

如果使用的 JDK 版本小于 8,推荐使用迭代器模式。

2.3 对象与字节数组的互转

目标对象信息如下:

1
2
3
4
5
6
7
8
9
10
11
/**
* @author mofan
* @date 2022/10/16 17:42
*/
@Getter
@Setter
public class People implements Serializable {
private static final long serialVersionUID = 3547129463753827085L;

private String name;
}

互转的测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
public void testSerializable() {
People people = new People();
people.setName("mofan");
byte[] bytes = getBytesFromObject(people);
People result = (People) deserialize(bytes);
Assert.assertEquals("mofan", result.getName());
}

@SneakyThrows
private byte[] getBytesFromObject(Serializable serializable) {
ByteArrayOutputStream bo = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bo);
oos.writeObject(serializable);
return bo.toByteArray();
}

@SneakyThrows
private Object deserialize(byte[] bytes) {
ByteArrayInputStream bis = new ByteArrayInputStream(bytes);
ObjectInputStream ois = new ObjectInputStream(bis);
return ois.readObject();
}

3. Java 特性

3.1 default 关键词

在 JDK8 之前,规定接口内只能有抽象方法(或者说方法的声明),不能有方法的实现。

在 JDK8 之后,开了个“小窗”,允许接口内有部分实现,只需要在实现的方法前使用 default 关键字即可。

都讲到 default 关键词了,顺便复习一下函数式接口,所谓函数式接口就是只有一个方法的声明的接口(在 Lambda 表达式中使用)。同时,还引入一个注解:@FunctionalInterface

这个注解作用于接口上时,表示当前接口是函数式接口。但是 Lambda 表达式中的函数式接口上并没有 @FunctionalInterface 注解是怎么回事?

这就相当于所有类的都默认继承了 Object 类一样,默认给函数式接口上加了一个 @FunctionalInterface 注解。但这两者又有区别:

如果显式给一个接口上加了 @FunctionalInterface 注解(注解的接口必须是函数式接口),但这接口又不是函数式接口,那么就会报错。

我们知道 函数式接口 内只能有一个方法的声明(一个抽象方法),但被 default 关键词修饰的方法不包含在其中,甚至可以有多个被 default 修饰的方法。

在函数式接口内,除了可以有多个被 default 修饰的方法以外,还允许拥有多个静态方法。

比如下列函数式接口的代码就是正确的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* @author 默烦 2020/10/24
*/
@FunctionalInterface // 函数式接口
public interface DefaultTest {
int add(int x, int y);

default int fun(int x, int y) {
System.out.println("hello default");
return x * y;
}

// 接口内可以有多个 default 关键词修饰的方法
default int fun2(int x, int y) {
System.out.println("hello default2");
return x - y;
}

// 也可以有多个静态方法
static int div(int x, int y) {
return x / y;
}
}

由于上述接口是函数式接口,甚至还可以使用 Lambda 表达式:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Test {
public static void main(String[] args) {
DefaultTest dt = (x, y) -> {
System.out.println("hello world");
return x + y;
};

System.out.println(dt.add(3, 4));
System.out.println(dt.fun(3, 4));
System.out.println(dt.fun2(3, 4));
System.out.println(DefaultTest.div(6, 3));
}
}

上述代码可以正常运行并在控制台输出:

default测试结果

3.2 toMap() 的使用

在 Java8 的 Stream 流中提供了集合转 Map 的方法,即:toMap() 方法,但此方法的使用有以下两点注意事项:

1、转换后的 Map 的 value 不能为 null,否则会抛出 NullPointException

2、转换后的 Map 的 key 不能重复,否则会抛出 IllegalStateException

转换后的 Map 的 value 为什么不能为 null

Collectors 中的 toMap() 方法有很多重载,我们一般会选择使用两个参数的 toMap() 方法,即:

1
2
3
4
5
public static <T, K, U>
Collector<T, ?, Map<K,U>> toMap(Function<? super T, ? extends K> keyMapper,
Function<? super T, ? extends U> valueMapper) {
return toMap(keyMapper, valueMapper, throwingMerger(), HashMap::new);
}

这个方法调用了 4 个参数的 toMap() 方法:

1
2
3
4
5
6
7
8
9
10
public static <T, K, U, M extends Map<K, U>>
Collector<T, ?, M> toMap(Function<? super T, ? extends K> keyMapper,
Function<? super T, ? extends U> valueMapper,
BinaryOperator<U> mergeFunction,
Supplier<M> mapSupplier) {
BiConsumer<M, T> accumulator
= (map, element) -> map.merge(keyMapper.apply(element),
valueMapper.apply(element), mergeFunction);
return new CollectorImpl<>(mapSupplier, accumulator, mapMerger(mergeFunction), CH_ID);
}

该方法的函数含义如下:

1、keyMapper:key 的映射函数;
2、valueMapper:value 的映射函数;
3、mergeFunction:key 冲突时,调用的合并方法;
4、mapSupplier:Map 构造器,在需要返回特定的 Map 时使用;

并且我们可以看到此方法中调用了 Map 接口中的 merge() 方法,merge() 方法也是 Java8 中新增的。

再回到两个参数的 toMap() 方法中,这个方法使用的默认 Map 构造器是 HashMap 的,因此查看 HashMap 的 merge() 方法:

1
2
3
4
5
6
7
8
9
10
public V merge(K key, V value,
BiFunction<? super V, ? super V, ? extends V> remappingFunction) {
if (value == null)
throw new NullPointerException();
if (remappingFunction == null)
throw new NullPointerException();
int hash = hash(key);
Node<K,V>[] tab; Node<K,V> first; int n, i;
// ...
}

可以看到一开始就对 value 进行了判断,如果为 null,就抛出 NullPointException

转换后的 Map 的 key 为什么不能重复

merge() 方法进行简单的分析,可以知道 merge() 方法的作用如下:当 Map 中不存在指定的 key 时,便将传入的 value 设置为 key 对应的值,相当于 put(key, value);当 key 存在值时执行 remappingFunction.apply() 方法,方法接收 key 对应的旧值和 merge()方法传入的 value,apply() 方法的返回值为 key 新对应的值。

通过前面的分析,merge() 方法是由 4 个参数的 toMap() 方法调用的,merge() 方法的第三个参数是由 toMap() 方法的第三个参数指定的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static <T, K, U, M extends Map<K, U>>
Collector<T, ?, M> toMap(Function<? super T, ? extends K> keyMapper,
Function<? super T, ? extends U> valueMapper,
BinaryOperator<U> mergeFunction,
Supplier<M> mapSupplier) {
BiConsumer<M, T> accumulator
= (map, element) -> map.merge(keyMapper.apply(element),
valueMapper.apply(element), mergeFunction);
return new CollectorImpl<>(mapSupplier, accumulator, mapMerger(mergeFunction), CH_ID);
}

public static <T, K, U>
Collector<T, ?, Map<K,U>> toMap(Function<? super T, ? extends K> keyMapper,
Function<? super T, ? extends U> valueMapper) {
return toMap(keyMapper, valueMapper, throwingMerger(), HashMap::new);
}

throwingMerger() 如下:

1
2
3
private static <T> BinaryOperator<T> throwingMerger() {
return (u,v) -> { throw new IllegalStateException(String.format("Duplicate key %s", u)); };
}

这下就不难明白为什么会抛出 IllegalStateException 异常了。

除此之外,也可以自定义 Key 冲突合并方法,比如将 List<Person> 转换成 Map,当 Key 冲突时,以新值代替旧值:

1
Map<String, Person> map = list.stream().collect(Collectors.toMap(Person::getId, Person::getName, (oldValue, newValue) -> newValue));

3.3 anyMatch 与 allMatch

anyMatch()allMatch() 是 Stream 中用来判断集合中是否存在满足给定谓词的元素,如果是这个集合是空集合呢?

1
2
3
4
5
6
7
8
9
10
@Test
public void testMatch() {
List<String> list = new ArrayList<>();
assertThat(list.stream().allMatch("test"::equals)).isTrue();
assertThat(list.stream().allMatch(i -> !"test".equals(i))).isTrue();
assertThat(list.stream().noneMatch("test"::equals)).isTrue();
assertThat(list.stream().noneMatch(i -> !"test".equals(i))).isTrue();
assertThat(list.stream().anyMatch(i -> !"test".equals(i))).isFalse();
assertThat(list.stream().anyMatch("test"::equals)).isFalse();
}

对于空集合来说:

  • allMatch()noneMatch() 总是返回 true
  • anyMatch() 总是返回 false

4. 一些小技巧

4.1 获取当前项目路径

以 Maven 工程 mofan-demo 下的 api-study Module 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* @author mofan
* @date 2023/7/10 19:10
*/
public class ProjectPathTest {
@Test
@SneakyThrows
public void test() {
// 相对路径:xxx/mofan-demo/api-study
String relativePath = System.getProperty("user.dir");

// 以一个目标文件为定位点
URL url = getClass().getClassLoader().getResource("test.txt");
Assertions.assertNotNull(url);
// xxx/mofan-demo/api-study/target/test-classes/test.txt
String path = new File(url.toURI()).getPath();
Assertions.assertEquals(relativePath + "\\target\\test-classes\\test.txt", path);
// 文件前要加 /
url = getClass().getResource("/test.txt");
Assertions.assertNotNull(url);
Assertions.assertEquals(path, new File(url.toURI()).getPath());

url = getClass().getResource("");
Assertions.assertNotNull(url);
// 加上具体的包路径
Assertions.assertEquals(relativePath + "\\target\\test-classes\\indi\\mofan", new File(url.toURI()).getPath());
// 与 System.getProperty("user.dir") 的结果一样
Assertions.assertEquals(relativePath, new File("").getCanonicalPath());
}
}

上述涉及到的 test.txt 文件在 /src/test/resources/ 目录下。

ClassLoader 的相关 API 介绍可以查阅 Java 的类资源加载 一文。