1. 基础概念
1.0 Java 特点
Java 语言有以下几个主要特点:
- 简单易学:Java 去掉了 C++ 中的指针和操作符重载等特性(Java 中不让开发者直接使用指针),使得 Java 语法相对简单易懂,并且提供了丰富的 API 和标准库,使得编程更加方便;
- 跨平台性:Java 程序编译成字节码,可以在任何安装了 Java 虚拟机(JVM)的平台上运行,实现了一次编写,到处运行;
- 面向对象:Java 是一门纯面向对象的编程语言,所有的程序都是由对象组成的,支持封装、继承和多态等特性;
- 自动内存管理:Java 提供了垃圾回收机制,不需要手动释放内存,避免了内存泄漏和野指针等问题;
- 安全性:Java 提供了安全管理机制,保证程序的安全性。
相比于 C++,Java 语言更注重代码的可移植性、安全性和易用性,同时牺牲了一些性能。C++ 则更注重程序的性能和效率,同时要求程序员有较高的编程能力和经验。
1.1 JVM、JDK、JRE
Java 虚拟机(Java Virtual Machine)是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语言“一次编译,随处可以运行”的关键所在。
JDK(Java Development Kit)缩写,它是功能齐全的 Java SDK。它拥有 JRE 所拥有的一切,还有编译器(javac)和工具(如 javadoc 和 jdb)。它能够创建和编译程序。包含了 Java 编译器、Java 调试器、Java 文档生成器等。
JRE(Java Runtime Environment)是 Java 运行时环境。它是运行已编译 Java 程序所需的所有内容的集合,包括 Java 虚拟机(JVM),Java 类库,Java 命令和其他的一些基础构件。但是,它不能用于创建新程序。
简单来说,JDK 提供了开发 Java 应用程序所需的工具,JRE 提供了运行 Java 应用程序所需的环境,而 JVM 则是将 Java 应用程序翻译成机器指令的关键部分。
1.2 字节码
字节码是 Java 源代码编译生成的中间代码,它并不是本地机器码,而是一种与平台无关的二进制码。字节码文件通常以 “.class” 为后缀,可以被 JVM(Java 虚拟机)解释执行。与本地机器码不同,字节码是面向 JVM 的指令集,而不是特定的硬件平台。因此,Java 程序可以在不同的操作系统上运行,只需要在该平台上安装一个符合 JVM 规范的虚拟机即可(Java 跨平台的保证,并为 Java 程序的安全性和可靠性提供了保障)。
Java 源代码通过编译器将源代码转换为字节码,字节码文件包含了 Java 程序的所有信息,包括类、方法、变量、常量、异常处理等信息。当 Java 程序被执行时,JVM 加载字节码文件,并将其转换为机器码执行。
1.3 Java 语言“编译与解释并存”
Java 代码在运行时的处理方式。Java 程序源代码先被编译成字节码文件,然后由 JVM 进行解释和执行。由 Java 编写的程序需要先经过编译步骤,生成字节码(.class
文件),这种字节码必须由 Java 解释器来解释执行。
后面引进了 JIT(just-in-time compilation) 即时编译技术,当 JIT 编译器完成第一次编译后,在程序运行时将热点代码(频繁执行的代码)编译成本地机器码,下次可以直接使用,以提高程序的性能。
1.4 Java 编码格式
当时 Java 选择使用 UCS-2(UTF-16 旧编码),这是一个定长编码。后来 UCS-2 无法表示所有 Unicode 字符的时候,过渡到了兼容它的 UTF-16 上也是最自然以及迁移成本最低的选择。UTF-8 兼容 ASCII,UTF-16 是为了向下兼容旧标准。
为了显示其他语言,需要统一的 Unicode 编码,但是这会导致内存浪费(只需要一个字节的英文字符却占用四个字节)。Java 默认使用 UTF-8 优化内存占用,特点是对不同范围的字符使用不同长度的编码。UTF-8 是变长字节表示,遵循一个转换的原则:
对于单字节的符号,字节的第一位设为 0,后面的 7 位为这个符号的 Unicode 码,因此对于英文字母,UTF-8 编码和 ASCII 码是相同的;
对于 n 字节的符号(n > 1),第一个字节的前 n 位都设为 1,第 n + 1 位设为 0,后面字节的前两位一律设为 10,剩下的没有提及的二进制位,全部为这个符号的 Unicode 码。
而 JVM 内部采用 UTF-16,所以 Java 中的 char 是两字节的占用。char 可以存储中文字符,但是如果某些特殊汉字没有添加进 Unicode 字符集中就不能存储。
1.5 字节序
字节序是指多字节数据在计算机内存中存储或网络传输时个字节的存储顺序。通常由小端和大端两组方式。
- 小端:低位字节存放在内存的低地址端,高位字节存放在内存的高地址端;
- 大端:高位字节存放在内存的低地址端,低位字节存放在内存的高地址端。
Java 语言属于大端字节序。
2. Java SE
2.1 Java 基础
2.1.1 面向对象三大特性
封装性,继承性,多态性。
封装:是指把一个对象的状态信息(也就是属性)隐藏在对象内部,不允许外部对象直接访问对象的内部信息。但是可以提供一些可以被外界访问的方法来操作属性,就是 Java 对象中 Setter 和 Getter;
继承:在已有类的基础上,通过扩展继承来的属性和方法,创建一个新的类,新类包含已有类的所有属性和方法,并可以在此基础上添加新的功能。通过继承可以提高代码的复用性和扩展性;
多态:指同一种操作作用于不同的对象上,可以有不同的解释和不同的实现。多态可以通过方法的重载和重写来实现,在运行时动态绑定对象和方法。表示一个对象具有多种的状态,具体表现为父类的引用指向子类的实例:
对象类型和引用类型之间具有继承(类)/实现(接口)的关系;
引用类型变量发出的方法调用的到底是哪个类中的方法,必须在程序运行期间才能确定(引用变量所指向的具体实例对象的方法,也就是内存里正在运行的那个对象的方法,而不是引用变量的类型中定义的方法。Java 多态实现机制的方法);
多态不能调用“只在子类存在但在父类不存在”的方法;
如果子类重写了父类的方法,真正执行的是子类覆盖的方法,如果子类没有覆盖父类的方法,执行的是父类的方法。
Java 多态
Java 多态可以分为编译时多态和运行时多态。
编译时多态主要指方法的重载,即通过参数列表的不同来区分不同的方法。运行时多态主要指继承父类和实现接口时,可使用父类引用指向子类对象。
运行时多态的实现:主要依靠方法表(方法表是一个数组,用于存储类中所有方法的引用。在编译时,编译器会确定调用哪个方法。在运行时,JVM 会在方法表中查找对应的方法来执行),如果子类改写了父类的方法,那么子类和父类的那些同名方法共享一个方法表项,都被认作是父类的方法。因此可以实现运行时多态。
具体方法如下:
- 在编译时,编译器会为每个类中的方法生成一个唯一的编号,称为方法的签名。方法的签名由方法名、参数类型和返回类型组成;
- 每个类在内存中都有一个方法表,方法表是一个数组,用于存储类中所有方法的引用。方法表中最先存放的是 Object 类的方法,接下来是该类的父类的方法,最后是该类本身的方法;
- 在运行时,JVM 会根据对象的实际类型在方法表中查找对应的方法来执行。这个过程称为动态绑定或者动态分派;
- JVM 会根据方法签名来查找方法。如果找到了对应的方法,则直接执行该方法。如果没有找到,则向上递归到父类的方法表中查找,直到找到对应的方法或者到达 Object 类;
- 如果在某个方法中调用了另一个方法,则会继续在方法表中查找对应的方法来执行。
class Animal {
public void makeSound() {
System.out.println("Animal is making a sound.");
}
}
class Dog extends Animal {
@Override
public void makeSound() {
System.out.println("Dog is barking.");
}
}
class Cat extends Animal {
@Override
public void makeSound() {
System.out.println("Cat is meowing.");
}
}
public class PolymorphismExample {
public static void main(String[] args) {
Animal animal1 = new Dog();
Animal animal2 = new Cat();
animal1.makeSound(); // 输出:Dog is barking.
animal2.makeSound(); // 输出:Cat is meowing.
}
}
2.1.2 接口和抽象类
比较两者语法细节区别的条理是:
先从一个类中的构造方法、普通成员变量和方法(包括抽象方法),静态变量和方法,继承性等6个方面逐一去比较回答。
共同点:
- 都不能被实例化;
- 都可以包含抽象方法(在接口中,方法会默认修饰
public abstract
); - 都可以有默认实现的方法(Java 8 可以用
default
关键在接口中定义默认方法);
区别:
- 一个类只能继承一个类,但是可以实现多个接口;
- 抽象类可以有构造方法,接口没有。抽象类中可以包含静态方法,接口中不能包含静态方法;
抽象类中可以有普通成员变量,接口中没有普通成员变量。抽象类和接口中都可以包含静态成员变量,接口中的成员变量只能是
public static final
,不能被修改且必须有初始值,而抽象类的成员变量默认default
,可在子类中被重新定义,也可被重新赋值。抽象类中的方法不一定要是抽象的;接口可以继承接口。抽象类可以实现(implements)接口,抽象类可以继承具体类。抽象类中可以有静态的 main 方法。
接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系。接口更多的是在系统架构设计方法发挥作用,主要用于定义模块之间的通信契约。而抽象类在代码实现方面发挥作用,可以实现代码的重用。
例如,模板方法设计模式是抽象类的一个典型应用,假设某个项目的所有 Servlet 类都要用相同的方式进行权限判断、记录访问日志和处理异常,那么就可以定义一个抽象的基类,让所有的 Servlet 都继承这个抽象基类,在抽象基类的 service 方法中完成权限判断、记录访问日志和处理异常的代码,在各个子类中只是完成各自的业务逻辑代码,伪代码如下:
public abstract class BaseServlet extends HttpServlet {
public final void service(HttpServletRequest request,
HttpServletResponse response)
throws IOException, ServletException {
// 记录访问日志, 进行权限判断
if (具有权限) {
try {
doService(request, response);
} catch (Exception e) {
// todo:记录异常信息
}
}
}
protected abstract void doService(HttpServletRequest request,
HttpServletResponse response)
throws IOException, ServletException {
//注意访问权限定义成protected,显得既专业,又严谨,因为它是专门给子类用的
}
}
abstract
所有的抽象方法必须在抽象类中声明。抽象方法只能声明在抽象类中是因为抽象方法没有实现体,它只是一个方法的声明,没有具体的实现代码。因此,抽象方法必须由子类来实现,子类需要提供具体的实现代码。而抽象类本身也不能被实例化,只有其子类可以被实例化并提供实现。
abstract 的 method 不可以是 static 的,因为抽象的方法是要被子类实现的,而 static 表示可以直接用类调用该方法。
native 方法表示该方法要用另外一种依赖平台的编程语言实现的,不存在着被子类实现的问题,所以,它也不能是抽象的,不能与 abstract 混用。
public abstract class AbstractClass {
public abstract void doSomething();
}
public class SubClass extends AbstractClass {
@Override
public synchronized void doSomething() {
// 这里的代码是线程安全的
}
}
当一个方法被声明为 abstract 和 synchronized 的时候,这是一种矛盾的设计,因为抽象方法只是方法的声明,没有具体的实现,而 synchronized 关键字需要对一个具体的对象进行同步。因此,Java 编译器不会允许同时使用 abstract 和 synchronized 关键字来定义方法。
假设一个方法被声明为 abstract 和 synchronized 的,那么如果所持有的锁是 this,this 指的是该类的一个对象,而抽象类是没有对象的,因此是冲突的。
public abstract class AbstractClass {
public abstract void doSomething();
}
public class SubClass extends AbstractClass {
@Override
public synchronized void doSomething() {
// 这里的代码是线程安全的
}
}
2.1.3 修饰符
- 访问权限修饰符:用于控制类、方法、变量等元素的访问权限,Java 中有四种访问权限修饰符:public、protected、default、private。其中,public 表示公共的,可以被任何类访问;protected 表示受保护的,可以被同一包中的类和不同包中的子类访问;default(没有显式声明)表示包访问权限,可以被同一包中的类访问;private 表示私有的,只能被本类中的方法访问。
- 非访问权限修饰符:用于控制类、方法、变量等元素的作用范围、继承性、抽象性等。Java 中常见的非访问权限修饰符包括:static、final、abstract、synchronized、transient、volatile、native、strictfp 等。
这四个作用域的可见范围如下表所示:
作用域 | 当前类 | 同一package | 子孙类 | 其他package |
---|---|---|---|---|
public | √ | √ | √ | √ |
protected | √ | √ | √ | × |
friendly | √ | √ | × | × |
private | √ | × | × | × |
2.1.4 Overload & Override
重载 Overload 表示同一个类中可以有多个名称相同的方法,但这些方法的参数列表各不相同(即参数个数或类型不同)。
- 在使用重载时只能通过不同的参数样式。例如,不同的参数类型,不同的参数个数,不同的参数顺序;
- 不能通过访问权限、返回类型、抛出的异常进行重载;
- 方法的异常类型和数目不会对重载造成影响;
- 对于继承来说,如果某一方法在父类中是访问权限是 priavte,那么就不能在子类对其进行重载。如果定义的话,也只是定义了一个新方法,而不会达到重载的效果。
重写 Override 表示子类中的方法可以与父类中的某个方法的名称和参数完全相同,通过子类创建的实例对象调用这个方法时,将调用子类中的定义方法,在方法表中查找到了子类的方法,并进行调用。
覆盖要注意以下的几点:
- 覆盖的方法的标志必须要和被覆盖的方法的标志完全匹配,才能达到覆盖的效果;
- 子类覆盖父类的方法时,只能比父类抛出更少的异常,或者是抛出父类抛出的异常的子异常,因为子类可以解决父类的一些问题,不能比父类有更多的问题;
- 子类方法的访问权限只能比父类的更大,不能更小(子类继承了父类中的方法,如果子类中的方法访问修饰符少于父类中的方法访问修饰符,那么该方法在子类中就无法被其他类调用,违反了继承和多态性的基本原则);
- 被覆盖的方法不能为 private,否则在其子类中只是新定义了一个方法,并没有对其进行覆盖。
构造器 Constructor 不能被继承,因此不能重写 Override,但可以被重载 Overload。
class Animal {
public void makeSound() {
System.out.println("Animal is making a sound.");
}
}
class Cat extends Animal {
// 重载makeSound方法,传入一个字符串作为参数
public void makeSound(String sound) {
System.out.println("Cat is making " + sound + " sound.");
}
// 覆盖父类中的makeSound方法
@Override
public void makeSound() {
System.out.println("Cat is making a sound.");
}
}
public class Test {
public static void main(String[] args) {
Animal animal = new Animal();
animal.makeSound(); // 输出 "Animal is making a sound."
Cat cat = new Cat();
cat.makeSound(); // 输出 "Cat is making a sound."
cat.makeSound("meow"); // 输出 "Cat is making meow sound."
}
}
2.1.5 内部类
概括:首先根据印象说出自己对内部类的总体方面的特点。例如,在两个地方可以定义,可以访问外部类的成员变量,不能定义静态成员,这是大的特点。然后再说一些细节方面的知识,例如,几种定义方式的语法区别,静态内部类,以及匿名内部类。
内部类可以定义在类的内部、方法的内部。
内部类可以在方法中或者在方法外部创建。在方法内部定义的内部类前面不能有访问类型修饰符,就好像方法中定义的局部变量一样,但这种内部类的前面可以使用 final 或 abstract 修饰符。这种内部类对其他类是不可见的其他类无法引用这种内部类,但是这种内部类创建的实例对象可以传递给其他类访问。
这种内部类必须是先定义,后使用,即内部类的定义代码必须出现在使用该类之前,这与方法中的局部变量必须先定义后使用的道理也是一样的。这种内部类可以访问方法体中的局部变量,但是,该局部变量前必须加 final 修饰符。内部类的实现中,访问方法中的局部变量实际上是通过复制该变量的值来实现的,而不是直接引用该变量。如果局部变量不是 final 的,那么它的值可能会在内部类使用之前被修改,导致内部类中访问到的值与预期不一致。
在内部类中访问方法中的局部变量时,由于内部类的实例可以在方法执行完毕后继续存在(堆),因此需要在内部类实例中存储对这些变量的引用,以便在内部类的实例执行期间可以访问它们。然而,由于局部变量(栈)和方法参数在方法执行完毕后就会被销毁,所以内部类无法直接访问它们。因此,在内部参数中修改这些变量或者参数不会影响到原始值。
public class MethodLocalInnerClassExample {
public void outerMethod() {
final int x = 10;
class Inner {
private final int id;
public Inner(int id) {
this.id = id;
}
@Override
public String toString() {
return "Inner{" +
"id=" + id +
'}';
}
// x 定义为 final 可以访问
public void innerMethod() {
System.out.println("x = " + x);
}
}
Inner inner = new Inner(5);
inner.innerMethod();
System.out.println(inner);
}
public static void main(String[] args) {
MethodLocalInnerClassExample example = new MethodLocalInnerClassExample();
example.outerMethod();
}
}
在 Java 8 之后,如果局部变量的值在使用之前不会发生改变,那么就不必将其声明为 final 了。在 Java 8 中,Java 引入了对“effectively final”变量的支持,这意味着变量的值在使用之前只被赋值一次,并且在后续的代码中不会被修改。在这种情况下,Java 允许内部类访问局部变量,即使它们没有被声明为 final。
创建内部类的实例对象时,一定要先创建外部类的实例对象,然后用这个外部类的实例对象去创建内部类的实例对象,代码如下:
Outer outer = new Outer();
Outer.Inner1 inner1 = outer.new Innner1();
内部类可以直接访问外部类中的成员变量,内部类可以定义在外部类的方法外面,也可以定义在外部类的方法体中。
在内部类中定义静态成员变量可能会导致一些问题,主要是因为内部类和外部类之间的关系比较复杂:
- 静态成员变量是属于外部类的,而内部类可以访问外部类的成员变量。如果允许内部类定义静态成员变量,那么就可能导致内部类中的静态成员变量和外部类中的成员变量发生冲突,造成混淆。
- 内部类实例可以在外部类的实例创建之前创建。如果内部类定义了静态成员变量,而这些静态成员变量依赖于外部类的实例变量,则可能会出现一些问题,因为在外部类的实例创建之前,这些实例变量还不存在。
在 JDK 16 之前不允许直接在内部类中定义静态内部变量,需要将内部类变为静态内部类。
class Outer {
public class Inner {
static int innerStaticVar = 1;
int innerNonStaticVar = 2;
public void printVars() {
System.out.println("innerStaticVar = " + innerStaticVar++);
System.out.println("innerNonStaticVar = " + innerNonStaticVar);
}
}
public static void main(String[] args) {
Outer outer = new Outer();
Outer.Inner inner = outer.new Inner();
inner.printVars(); // 输出 innerStaticVar = 1,innerNonStaticVar = 2
outer = new Outer();
inner = outer.new Inner();
inner.printVars(); // innerStaticVar = 2,innerNonStaticVar = 2
}
}
静态内部类
如果把静态嵌套类当作内部类的一种特例,那在这种情况下不可以访问外部类的普通成员变量,而只能访问外部类中的静态成员。静态内部类与非静态内部类的主要区别在于,静态内部类不持有外部类的引用,因此它可以独立存在,而非静态内部类必须与外部类的实例绑定在一起。另外,静态内部类也可以拥有静态成员变量和方法,这些静态成员只会在类被加载时初始化一次。
class Outer {
public static class Inner {
static int innerStaticVar = 1;
int innerNonStaticVar = 2;
public void printVars() {
System.out.println("innerStaticVar = " + innerStaticVar);
System.out.println("innerNonStaticVar = " + innerNonStaticVar);
}
}
public static void main(String[] args) {
Inner inner = new Inner();
inner.printVars(); // 输出 innerStaticVar = 1,innerNonStaticVar = 2
}
}
在外面引用 Static Nested Class 类的名称为“外部类名.内部类名”。在外面不需要创建外部类的实例对象,就可以直接创建 Static Nested Class,例如,假设 Inner 是定义在 Outer 类中的 Static Nested Class,那么可以使用如下语句创建 Inner 类:
Outer.Inner inner = new Outer.Inner();
匿名内部类
在方法体内部还可以采用如下语法来创建一种匿名内部类,即定义某一接口或类的子类的同时,还创建了该子类的实例对象,无需为该子类定义名称:
public class Outer {
public void start() {
new Thread(new Runnable() {
public void run() {
}
}).start();
}
}
可以在实例化一个类的同时定义一个继承父类或实现接口的子类,因此它可以同时继承父类或实现接口:
public class AnonymousInnerClassExample {
public static void main(String[] args) {
// 使用匿名内部类实现一个继承自Thread类的子类
Thread t = new Thread() {
public void run() {
System.out.println("Hello from anonymous inner class!");
}
};
t.start();
// 使用匿名内部类实现一个实现Runnable接口的子类
Runnable r = new Runnable() {
public void run() {
System.out.println("Hello from another anonymous inner class!");
}
};
new Thread(r).start();
}
}
effectively final
在 Java 8 中,Java 引入了对“effectively final”变量的支持。Effectively final 变量指的是那些被赋值后在后续的代码中没有再被重新赋值的局部变量或参数。在这种情况下,Java 允许内部类或 lambda 表达式访问这些变量,即使它们没有被声明为 final。
在 Java 8 中,由于引入了 effectively final 变量的概念,内部类或 lambda 表达式可以访问那些在赋值后没有被重新赋值的局部变量或参数,即使这些变量没有被声明为 final。这样可以简化代码,减少需要声明 final 变量的情况,并且可以更自然地表示程序的意图。
2.1.6 数据类型
基本数据类型包括 byte、int、char(占用两字节)、long、float、double、boolean 和 short。
Java 封装类型
在 Java 里面,之所以要对基础类型设计一个对应的封装类型。是因为 Java 本身是一门面向对象的语言,对象是 Java 语言的基础单元,从这个点来说,封装类型存在的意义就很大。
基本类型和 Integer 类型混合使用时,Java 会自动通过拆箱和装箱实现类型转换 Integer 作为一个对象类型,封装了一些方法和属性,我们可以利用这些方法来操作数据。
封装类型还有很多好处,比如:
- 安全性较好,可以避免外部操作随意修改成员变量的值,保证了成员变量和数据传递的安全性;
- Integer 可以存储 null 值(Integer 是一个对象),int 不行;
- Integer 存储在堆内存,int 类型是直接存储在栈空间;
- 隐藏了实现细节,对使用者更加友好,只需要调用对象提供的方法就可以完成对应的操作。
在 Hibernate 中,如果将 OID 定义为 Integer 类型,那么 Hibernate 就可以根据其值是否为 null 而判断一个对象是否是临时的,如果将 OID 定义为了 int 类型,还需要在 hbm 映射文件中设置其 unsaved-value 属性为 0。
Hibernate
Hibernate 是一个 Java 的 ORM(Object-Relational Mapping,对象关系映射)框架,它可以将 Java 对象与数据库表之间的映射关系进行管理。Hibernate 的目标是简化数据持久化的开发,使开发人员能够更专注于业务逻辑,而不用过多关注底层数据库操作。
在 Hibernate 中,OID(Object Identifier,对象标识符)通常指代的是实体对象的主键,用来标识每个持久化的实体对象。如果将 OID 定义为 Integer 类型,则当其值为 null 时,Hibernate 就认为对应的实体对象是临时对象。这是因为,持久化实体对象时,Hibernate 需要为其分配一个 OID 值,如果 OID 值为 null,就表示该实体对象还没有被持久化。
IntegerCache
Integer a1 = 100、Integer a2 = 100
,请问 a1 == a2
的运行结果以及为什么?(结果为 true)
Integer a1=100, 把一个 int 数字赋值给一个封装类型,Java 会默认进行装箱操作,调用 Integer.valueOf() 方法,把数字 100 包装成封装类型 Integer。
在 Integer 内部设计中,用到了享元模式的设计,享元模式的核心思想是通过复用对象,减少对象的创建数量,从而减少内存占用和提升性能。Integer 内部维护了一个 IntegerCache,它缓存了 -128 到 127 这个区间的数值对应的 Integer 类型。一旦程序调用valueOf 方法,如果数字是在 -128 到 127 之间就直接在 cache 缓存数组中去取 Integer 对象。
String
String s = "Hello";
s = s + " world!";
// 这两行代码执行后,原始的 String 对象中的内容到底变了没有?
在这段代码中,s 原先指向一个 String 对象,内容是 “Hello”,然后对 s 进行了 + 操作。这时,s 不指向原来那个对象了,而指向了另一个 String 对象,内容为 “Hello world!”,原来那个对象还存在于内存之中,只是 s 这个引用变量不再指向它了。
s = "Initial Value";
// 初始化之后会放入常量池,之后的 String 类型都指向同一个对象
s = new String("Initial Value");
// 后者每次都会调用构造器,生成新对象,性能低下且内存开销大
s = new String("Initial Value");
,会创建两个或一个对象,“xyz”对应一个对象,这个对象放在字符串常量缓冲区,常量:“xyz”不管出现多少遍,都是缓冲区中的那一个。
不可变
String 字符串常量,在 Java 中设定不可变,源码中定义为 final。
提升使用效率(常量池的需求):
如果大量的使用 String 常量,每一次声明一个 String 都创建一个 String 对象,将造成极大的空间浪费。Java 提出 String pool 的概念,在堆中开辟一块存储空间 String pool,当初始化一个 String 变量时,如果该字符串已经存在了,就不会去创建一个新的字符串变量,而是会返回已经存在了的字符串的引用,也可以提高 GC 的效率。
由于 String 是不可变的,保证了 hashcode 的唯一性,于是在创建对象时其 hashcode 就缓存了,不需要重新计算。Map 将 String 作为 Key,处理速度要快过其它的键对象,所以键往往都使用 String;
避免值修改问题:
如果采取可变的引用方式,对于一个值的修改会牵连到其他值,会导致安全问题:
public class test {
// 不可变的 String
public static String appendStr(String s) {
s += "bbb";
return s;
}
// 可变的 StringBuilder
public static StringBuilder appendSb(StringBuilder sb) {
return sb.append("bbb");
}
public static void main(String[] args) {
String s = new String("aaa");
// 常量池和堆内存中会分别分配一块内存
String ns = test.appendStr(s);
// s 的值会保持不变
System.out.println("String aaa>>>" + s);
// StringBuilder 做参数
StringBuilder sb = new StringBuilder("aaa");
StringBuilder nsb = test.appendSb(sb);
System.out.println("StringBuilder aaa >>>" + sb.toString());
}
}
多线程问题:
在多线程情况中,读取不会有安全性问题。但是在写入的情况下,就可能会出现各种竞争的问题。设定为不可变之后,String 也可以直接存储 hash 值;
安全性问题:
在网络连接和数据库连接中字符串常常作为参数,例如,网络连接地址 URL,文件路径 path,反射机制所需要的 String 参数。其不可变性可以保证连接的安全性。如果字符串是可变的,黑客就有可能改变字符串指向对象的值。因为String是不可变的,所以它的值是不可改变的。但由于 String 不可变,也就没有任何方式能修改字符串的值。
StringBuilder
StringBuffer 是线程安全的 StringBuilder 是不安全的(StringBuffer 是线程安全的,内部使用 synchronized 进行同步)。
StringBuilder 不支持并发操作,线性不安全的,不适合多线程中使用。新引入的 StringBuilder 类不是线程安全的,但其在单线程中的性能比 StringBuffer 高。
StringTokenizer
StringTokenizer 是 Java 中的一个类,用于将字符串按照指定分隔符分割成多个子字符串。它是一个早期的字符串分割工具,现在已经被推荐使用更加灵活的 String.split() 方法代替。
StringTokenizer 通过构造函数指定待分割的字符串和分割符,然后通过 nextToken() 方法依次获取分割后的每个子字符串,可以通过 hasMoreTokens() 方法判断是否还有剩余的子字符串。
String str = "apple,orange,banana";
StringTokenizer st = new StringTokenizer(str, ",");
while (st.hasMoreTokens()) {
String token = st.nextToken();
System.out.println(token);
}
String pool
Java 中的字符串常量池(String pool)是一个特殊的内存区域,用于存储字符串常量,它可以被多个字符串对象共享。在 Java 中,字符串常量池被实现为一个缓存,用于提高字符串的存取效率和节省内存空间。
String s1 = "a";
String s2 = s1 + "b";
String s3 = "a" + "b";
System.out.println(s2 == "ab");
// false
System.out.println(s3 == "ab");
// true
String s = "a" + "b" + "c" + "d";
System.out.println(s == "abcd");
// true,相当于直接定义了一个 ”abcd” 的字符串,只创建一个对象
javac 编译可以对字符串常量直接相加的表达式进行优化,不必要等到运行期去进行加法运算处理,而是在编译时去掉其中的加号,直接将其编译成一个这些常量相连的结果。
2.2 Object
2.3.1 深拷贝和浅拷贝
- 浅拷贝:浅拷贝会在堆上创建一个新的对象(区别于引用拷贝),如果拷贝基本类型就拷贝基本类型的值,如果原对象内部的属性是引用类型的话,浅拷贝会直接复制内部对象的引用地址,也就是说拷贝对象和原对象共用同一个内部对象;
- 深拷贝:深拷贝会完全复制整个对象,包括这个对象所包含的内部对象;
- 引用拷贝:引用指向同一个对象,如果一个对象进行改动,会牵连到另一个对象。
clone()
clone() 是 Object 的 protected 方法,它不是 public,一个类不显式去重写 clone(),其它类就不能直接去调用该类实例的 clone() 方法。clone() 方法并不是 Cloneable 接口的方法,而是 Object 的一个 protected 方法。使用 super.clone() 原因在于 Object 类中的 clone() 方法是 native 的,无法被子类直接调用,而 Cloneable 接口只是标记接口,它并没有定义任何方法。
clone 有缺省行为,在重写 clone() 方法时,需要使用 super.clone() 方法来创建一个与当前对象完全相同的新对象,这个新对象的属性值将与原对象完全相同。首先要把父类中的成员复制到位,然后才是复制自己的成员。
缺省行为指的是在特定情况下系统或程序会采取的默认行为,如果没有指定明确的行为或设置,则会使用缺省行为。在编程中,很多方法或函数都有缺省实现,这些缺省实现通常是最常见的使用方式。
如果一个类没有实现 Cloneable 接口又调用了 clone() 方法,就会抛出 CloneNotSupportedException。下面是浅拷贝:
public class Address implements Cloneable{
private String name;
// 省略构造函数、Getter & Setter 方法
@Override
public Address clone() {
try {
return (Address) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}
public class Person implements Cloneable {
private Address address;
// 省略构造函数、Getter & Setter 方法
@Override
public Person clone() {
try {
Person person = (Person) super.clone();
return person;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}
// test
Person person1 = new Person(new Address("武汉"));
Person person1Copy = person1.clone();
// true
System.out.println(person1.getAddress() == person1Copy.getAddress());
深拷贝:
@Override
public Person clone() {
try {
Person person = (Person) super.clone();
// clone 的 address 之后引用不同地址
person.setAddress(person.getAddress().clone());
return person;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
// test
Person person1 = new Person(new Address("武汉"));
Person person1Copy = person1.clone();
// false
System.out.println(person1.getAddress() == person1Copy.getAddress());
还有一种延时拷贝,是浅拷贝和深拷贝的一个组合,实际上很少会使用。
当最开始拷贝一个对象时,会使用速度较快的浅拷贝,还会使用一个计数器来记录有多少对象共享这个数据。当程序想要修改原始的对象时,它会决定数据是否被共享(通过检查计数器)并根据需要进行深拷贝。延迟拷贝从外面看起来就是深拷贝,但是只要有可能它就会利用浅拷贝的速度。当原始对象中的引用不经常改变的时候可以使用延迟拷贝。由于存在计数器,效率下降很高,但只是常量级的开销。
Clone 替代
使用 clone() 方法来拷贝一个对象即复杂又有风险,它会抛出异常,并且还需要类型转换。Effective Java 书上讲到,最好不要去使用 clone(),可以使用拷贝构造函数或者拷贝工厂来拷贝一个对象。
public class Dog {
private final String name;
private final int age;
public Dog(String name, int age) {
this.name = name;
this.age = age;
}
// 使用拷贝构造函数进行深拷贝
public Dog(Dog original) {
this(original.name, original.age);
}
// 通过拷贝工厂 clone 新对象
public static Dog newInstance(Dog dog) {
return new Dog(dog.name, dog.age);
}
}
序列化问题
通过序列化来实现深拷贝。序列化将整个对象图写入到一个持久化存储文件中并且当需要的时候把它读取回来, 这意味着当你需要把它读取回来时你需要整个对象图的一个拷贝。这就是当你深拷贝一个对象时真正需要的东西。请注意,当你通过序列化进行深拷贝时,必须确保对象图中所有类都是可序列化的。
2.3.2 == & equal
==
操作符专门用来比较两个变量的值是否相等,也就是用于比较变量所对应的内存中所存储的数值是否相同,要比较两个基本类型的数据或两个引用变量是否相等,只能用 ==
操作符。
如果一个类没有自己定义 equals 方法,那么它将继承 Object 类的 equals 方法,Object 类的 equals 方法的实现代码如下:
boolean equals(Object o){
return this==o;
}
如果一个类没有自己定义 equals 方法,它默认的 equals 方法(从 Object 类继承的)就是使用 ==
操作符,也是在比较两个变量指向的对象是否是同一对象,这时候使用 equals 和使用 ==
会得到同样的结果,如果比较的是两个独立的对象则总返回 false。
2.3 Math
Math 类中提供了三个与取整有关的方法:ceil、floor、round,这些方法的作用与它们的英文名称的含义相对应。
向下取整(floor):向下取整即将一个数向下取整到最接近的整数;
向上取整(ceil):向上取整即将一个数向上取整到最接近的整数;
四舍五入(round):四舍五入即将一个数按照标准的四舍五入规则取整;
去尾取整(trunc):去尾取整即将一个数直接去掉小数部分。在 Java 中,可以使用强制类型转换实现去尾取整(强制类型转换只是将浮点数转换成整数,小数部分并没有被四舍五入或者向下取整。如果想要实现四舍五入或者向下取整,需要先对浮点数进行处理,然后再进行类型转换)。
2.4 Collection
Collection 是一个集合接口,它提供了对集合对象进行基本操作的通用接口方法,所有集合都是它的子类,比如 List、Set 等。
Collections 是一个包装类,包含了很多静态方法、不能被实例化,而是作为工具类使用,比如提供的排序方法:sort(list);提供的反转方法:reverse(list)。
可以类比 Array 和 Arrays。
Collection 继承了 Iterable 接口,其中的 iterator() 方法能够产生一个 Iterator 对象,通过这个对象就可以迭代遍历 Collection 中的元素。在 JDK 1.5 之后就可以通过 foreach 方法便利实现 Iterable 接口的聚合对象。
// 容器中使用适配器模式,Arrays.asList 中可以
// asList 的参数为泛型的变长参数
Integer[] arr = {1, 2,3 };
List list = Arrays.asList(arr);
List list = Arrays.asList(1, 2, 3);
2.4.1 List
ArrayList
ArrayList 和 Vector 都是使用数组方式存储数据,此数组元素数大于实际存储的数据以便增加和插入元素。
ArrayList
是List
的主要实现类,底层使用Object[ ]
存储,适用于频繁的查找工作,线程不安全 ;Vector
是List
的古老实现类,底层使用Object[ ]
存储,线程安全的(Vector 每次扩容请求其大小的 2 倍(也可以通过构造函数设置增长的容量)。
Arraylist
底层使用的是 Object
数组;LinkedList
底层使用的是 双向链表 数据结构(在 JDK 1.7 中,对 LinkedList 进行了一些优化),两者都不是线程安全的。
Vector 与 Hashtable 是旧的,是 Java 一诞生就提供了的,它们是线程安全的,ArrayList 与 HashMap 是 Java 2 时才提供的。
建议项目中不要使用 LinkedList(基于双向链表实现,只能顺序访问,但是可以快速地在链表中间插入和删除元素。不仅如此,LinkedList 还可以用作栈、队列和双向队列)。
在 Java 中,Stack(Vector 子类) 中也使用了 synchronized 关键字,是线程安全,但是效率低。Vector 和 HashTable 一样,内部使用 synchronized 保证安全,性能和并发效率较低。这几种 List 在迭代期间不允许编辑,如果在迭代期间进行添加或删除元素等操作,则会抛出 ConcurrentModificationException 异常。
LinkedList
在 JDK 1.7 之前(此处使用 JDK1.6 来举例),LinkedList 是通过 headerEntry 实现的一个循环链表的。先初始化一个空的 Entry,用来做 header,然后首尾相连,形成一个循环链表。每次添加/删除元素都是默认在链尾操作。
在 JDK 1.7,1.6 的 headerEntry 循环链表被替换成了 firstEntry 和 lastEntry 组成的非循环链表。
CopyOnWrite
从 JDK 1.5 开始,Java 并发包里提供了使用 CopyOnWrite 机制实现的并发容器 CopyOnWriteArrayList 作为主要的并发 List,CopyOnWrite 的并发集合还包括 CopyOnWriteArraySet,其底层正是利用 CopyOnWriteArrayList 实现的。
CopyOnWriteArrayList 适用于读多写少的场景。CopyOnWriteArrayList 读取不加锁,写入也不会阻塞读取操作,在写入的同时进行读取。只有写入和写入之间需要进行同步。
CopyOnWrite 的含义在于,当容器需要被修改的时候,不直接修改当前容器,而是先将当前容器进行 Copy,复制出一个新的容器,然后修改新的容器,完成修改之后,再将原容器的引用指向新的容器,这样完成修改过程。因为不变性,读取的过程就不需要加锁同步(Collections.synchronizedList()
也能得到一个线程安全的 ArrayList)。
public class CopyOnWriteArrayListDemo {
public static void main(String[] args) {
CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>(new Integer[]{1, 2, 3});
System.out.println(list); // [1, 2, 3]
// Get iterator 1
Iterator<Integer> itr1 = list.iterator();
// Add one element and verify list is updated
list.add(4);
System.out.println(list); // [1, 2, 3, 4]
// Get iterator 2
Iterator<Integer> itr2 = list.iterator();
System.out.println("====Verify Iterator 1 content====");
itr1.forEachRemaining(System.out::println); // 1,2,3
System.out.println("====Verify Iterator 2 content====");
itr2.forEachRemaining(System.out::println); // 1,2,3,4
}
}
缺点
- CopyOnWrite 的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,这一点会占用额外的内存空间。复制过程不仅会占用双倍内存,还需要消耗 CPU 等资源,会降低整体性能;
- 如果写操作过于频繁,会导致性能问题;
- 由于 CopyOnWrite 容器的修改是先修改副本,所以这次修改对于其他线程来说,并不是实时能看到的,只有在修改完之后才能体现出来。
源码分析
以 add 方法为例:
public boolean add(E e) {
// 加锁
final ReentrantLock lock = this.lock;
lock.lock();
try {
// 得到原数组的长度和元素
Object[] elements = getArray();
int len = elements.length;
// 复制出一个新数组
Object[] newElements = Arrays.copyOf(elements, len + 1);
// 添加时,将新元素添加到新数组中
newElements[len] = e;
// 将volatile Object[] array 的指向替换成新数组
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
在添加的时候首先上锁,并复制一个新数组,增加操作在新数组上完成,然后将 array 指向到新数组,最后解锁。
而在迭代器 COWIterator 中,有两个重要的属性,分别是 Object[] snapshot 和 int cursor。其中 snapshot 代表数组的快照,cursor 则是迭代器的游标。
private COWIterator(Object[] elements, int initialCursor) {
cursor = initialCursor;
snapshot = elements;
}
迭代器在被构建的时候,会把当时的 elements 赋值给 snapshot,而之后的迭代器所有的操作都基于 snapshot 数组进行。
synchronizedList()
Collections.synchronizedList()
是在原有 List 上添加了同步锁,使得在多线程环境中对 List 进行读写操作时,能够保证线程安全。使用该方法获得的 List 适合于读操作多于写操作的场景。
扩容机制
ArrayList 是基于数组实现,RandomAccess 接口标识着该类支持快速随机访问。数组的默认大小为 10。
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
}
}
/**
* Constructs an empty list with an initial capacity of ten.
* ArrayList():并不是在初始化 ArrayList 的时候就设置初始容量为 10,而是在添加第一个元素时,将初始容量设置为 10(DEFAULT_CAPACITY)。
* 在 JDK 1.6 中,无参构造方法的初始容量为 10
*/
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
public ArrayList(Collection<? extends E> c) {
elementData = c.toArray();
if ((size = elementData.length) != 0) {
// c.toArray might (incorrectly) not return Object[] (see 6260652)
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, size, Object[].class);
} else {
// replace with empty array.
this.elementData = EMPTY_ELEMENTDATA;
}
}
添加元素时使用 ensureCapacityInternal()
方法来保证容量足够,如果不够时,需要使用 grow()
方法进行扩容,新容量的大小为 oldCapacity + (oldCapacity >> 1)
,即 oldCapacity + oldCapacity / 2。其中 oldCapacity >> 1 需要取整,所以新容量大约是旧容量的 1.5 倍左右(通过位运算提高效率)。
扩容操作需要调用 Arrays.copyOf()
把原数组整个复制到新数组中,之后再将新元素添加到新数组中,这个操作代价很高,因此最好在创建 ArrayList 对象时就指定大概的容量大小,减少扩容操作的次数。
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
// 在源码中可以看到,每次需要扩容的时候,新数组都会扩展到原数组的 1.5 倍
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
transient 分析
ArrayList 基于数组实现,并且具有动态扩容特性,因此保存元素的数组不一定都会被使用,那么就没必要全部进行序列化。
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
// 数组默认不会被序列化
transient Object[] elementData;
// 在这里的分配大小选择了 Int 最大长度 - 8
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException {
// Write out element count, and any hidden stuff
int expectedModCount = modCount;
s.defaultWriteObject();
// Write out size as capacity for behavioral compatibility with clone()
s.writeInt(size);
// Write out all elements in the proper order.
for (int i=0; i<size; i++) {
s.writeObject(elementData[i]);
}
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}
private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException {
// Read in size, and any hidden stuff
s.defaultReadObject();
// Read in capacity
s.readInt(); // ignored
if (size > 0) {
// like clone(), allocate array based upon size not capacity
SharedSecrets.getJavaObjectInputStreamAccess().checkArray(s, Object[].class, size);
Object[] elements = new Object[size];
// Read in all elements in the proper order.
for (int i = 0; i < size; i++) {
elements[i] = s.readObject();
}
elementData = elements;
} else if (size == 0) {
elementData = EMPTY_ELEMENTDATA;
} else {
throw new java.io.InvalidObjectException("Invalid size: " + size);
}
}
}
elementData 是一个缓存数组,它通常会预留一些空间,比如初始化添加第一个元素的时候,容量是 10,只有当存第 11 个元素的时候,才会扩容,所以存 1~10 个元素的时候,容量都是 10,所以并不是所有空间都有元素。因此,整个 ArrayList 重写序列化和反序列化方法,保证只序列化到实际储存的元素,而不是整个数组,避免了不必要的浪费。
序列化时需要使用 ObjectOutputStream 的 writeObject() 将对象转换为字节流并输出。而 writeObject() 方法在传入的对象存在 writeObject() 的时候会去反射调用该对象的 writeObject() 来实现序列化。反序列化使用的是 ObjectInputStream 的 readObject() 方法,原理类似。
ArrayList list = new ArrayList();
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file));
oos.writeObject(list);
2.4.2 Set
冷知识:Java 的 Set 底层实现还是 Map。
HashSet
、LinkedHashSet
和 TreeSet
的主要区别在于底层数据结构不同:
HashSet
的底层数据结构是哈希表(基于HashMap
实现,使用 Iterator 遍历得到的结果是不确定的)。LinkedHashSet
的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO(先进先出原则)。TreeSet
底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序(查找效率不如 HashSet,HashSet 查找的时间复杂度为 O(1),TreeSet 则为 O(logN))。
在 JDK 8 中,实际上无论 HashSet
中是否已经存在了某元素,HashSet
都会直接插入,只是会在 add()
方法的返回值处告诉我们插入前是否存在相同元素。
TreeSet
TreeSet 里面放对象,如果同时放入了父类和子类的实例对象,那比较时使用的是父类的 compareTo 方法,还是使用的子类的 compareTo 方法,还是抛异常?
当前的 add 方法放入的是哪个对象,就调用哪个对象的 compareTo 方法,至于这个 compareTo 方法怎么做,就看当前这个对象的类中是如何编写这个方法的。如果子类没有覆写父类的 compareTo()
方法,那么放入的对象会根据父类的 compareTo()
方法进行排序。如果子类覆写了父类的 compareTo()
方法,那么放入的对象会根据子类的 compareTo()
方法进行排序。
如果是不同类型的比较,优先会调用父类的 compareTo。
hashCode
HashSet 中,equals 与 hashCode 之间的关系?
equals 和 hashCode 这两个方法都是从 Object 类中继承过来的,equals 主要用于判断对象的内存地址引用是否是同一个地址;hashCode 根据定义的哈希规则将对象的内存地址转换为一个哈希码。HashSet 中存储的元素是不能重复的,主要通过 hashCode 与 equals 两个方法来判断存储的对象是否相同:
- 如果两个对象的 hashCode 值不同,说明两个对象不相同;
- 如果两个对象的 hashCode 值相同,接着会调用对象的 equals 方法,如果 equlas 方法的返回结果为 true,那么说明两个对象相同,否则不相同。
2.4.3 Map
List、Set 和 Queue 接口都继承自 Collection 接口,而 Map 接口则不是继承自 Collection 接口。这是因为 Map 是一个键值对的映射表,与 Collection 的元素集合不同,因此它们采用不同的接口继承体系。
在 Java 中,HashMap 使用拉链法解决哈希冲突问题(新冲突的数据会插入链表的头部)。
HashMap
HashMap
默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。
// 在 hashCode() 的基础上,再次进行哈希变换,进行二次散列
hash(k) = (key.hashCode() ^ (key.hashCode() >>> 16)) & (capacity - 1)
JDK 1.8 以后的 HashMap
在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间(使用红黑树避免出现二叉查找树的线性结构的问题)。
对于负载因子的选择,Java 底层采取了 0.75,涉及到数学中的泊松分布。在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少扩容 rehash 操作次数,所以,一般在使用 HashMap 时建议根据预估值设置初始容量,以便减少扩容操作。选择 0.75 作为默认的加载因子,完全是时间和空间成本上寻求的一种折衷选择。
在多线程情况下使用 HashMap 可能会出现循环链表的情况(在线程中一和线程二中,节点分别指向了之后的节点,形成一个闭环):
对于 HashMap 的遍历问题,详情参考这篇文章的内容。结论来说,遍历 HashMap 的过程中,如果使用 map.remove(key)
方法删除元素,会导致元素的数量发生变化,可能会破坏 HashMap 内部维护的数据结构,导致遍历时出现异常。可以使用迭代器的 iterator.remove()
的方法来删除数据,这是安全的删除集合的方式。
从性能来说,在获取迭代器中EntrySet
比 KeySet
的性能高。KeySet
在循环时使用了 map.get(key)
,而 map.get(key)
相当于又遍历了一遍 Map 集合去查询 key
所对应的值。在使用迭代器或者 for 循环时,已经遍历了一遍 Map 集合了,因此再使用 map.get(key)
查询时,相当于遍历了两遍。而 EntrySet
只遍历了一遍 Map 集合,之后通过代码Entry<Integer, String> entry = iterator.next()
把对象的 key
和 value
值都放入到了 Entry
对象中,因此再获取 key
和 value
值时就无需再遍历 Map 集合。
public static void entrySet() {
Iterator<Map.Entry<KeyType, ValueType>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<KeyType, ValueType> entry = iterator.next();
if (entry.getKey().equals(key)) {
iterator.remove();
}
}
}
HashMap 本质并非安全的方法,在源码中会涉及 count++,如果同时 put 可能会产生死锁等等问题。
扩容
设 HashMap 的 table 长度为 M,需要存储的键值对数量为 N,如果哈希函数满足均匀性的要求,那么每条链表的长度大约为 N/M,因此查找的复杂度为 O(N/M)。
为了让查找的成本降低,应该使 N/M 尽可能小,因此需要保证 M 尽可能大,也就是说 table 要尽可能大。HashMap 采用动态扩容来根据当前的 N 值来调整 M 值,使得空间效率和时间效率都能得到保证。
参数 | 含义 |
---|---|
capacity | table 的容量大小,默认为 16。需要注意的是 capacity 必须保证为 2 的 n 次方。 |
size | 键值对数量。 |
threshold | size 的临界值,当 size 大于等于 threshold 就必须进行扩容操作。 |
loadFactor | 装载因子,table 能够使用的比例,threshold = (int)(capacity * loadFactor)。 |
static final int DEFAULT_INITIAL_CAPACITY = 16;
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
transient Entry[] table;
transient int size;
int threshold;
final float loadFactor;
transient int modCount;
// 当需要扩容时,令 capacity 为原来的两倍。
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
if (size++ >= threshold)
resize(2 * table.length);
}
// 扩容操作同样需要把 oldTable 的所有键值对重新插入 newTable 中,这一步是很费时
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
红黑树
红黑树(Red-Black Tree)是一种自平衡二叉查找树,它能保证在最坏情况下基本动态集合操作的时间复杂度为 O(log n)。红黑树的名称来自于节点颜色的约束,每个节点都是红色或黑色的。
红黑树除了具备二叉查找树的基本特性之外,还具备以下特性:
- 节点是红色或黑色;
- 根节点是黑色;
- 所有叶子都是黑色的空节点(NIL 节点);
- 每个红色节点必须有两个黑色的子节点,也就是说从每个叶子到根的所有路径上,不能有两个连续的红色节点;
- 从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑色节点。
红黑树能够实现自平衡和保持红黑树特征的主要手段是:变色、左旋和右旋。
左旋指的是围绕某个节点向左旋转,也就是逆时针旋转某个节点,使得父节点被自己的右子节点所替代。右旋指的是围绕某个节点向右旋转,也就是顺时针旋转某个节点,此时父节点会被自己的左子节点取代。对于红黑树来说,如果当前节点的左、右子节点均为红色时,因为需要满足红黑树定义的第四条特征,所以需要执行变色操作。
使用红黑树的好处是,在有序的情况下,插入、删除、查找等操作的时间复杂度可以保持在 O(logn) 级别,而链表的时间复杂度则为 O(n)。所以,当 HashMap 中的元素数量较多、键分布较为均匀时,使用红黑树可以提高查找效率。但是,如果元素数量较少或者键分布不均匀,使用红黑树反而会降低效率,因为红黑树的节点比链表节点更为复杂,占用更多的内存空间,而且在查询元素时需要进行比较操作,比链表更为耗时。因此,HashMap 在进行节点类型选择时需要综合考虑元素数量、键的分布情况等因素,以达到最优的性能表现。
HashTable
- 不允许键值为 null。例如,如果存储了
null
值,则在取值时无法区分键不存在的情况和键对应的值为null
的情况。; - put 方法使用 synchronized 方法进行线程同步。
单线程无需同步,多线程可用 concurrent 包的类型,Vector 也有类似的问题,所以两者都已经不建议使用(遗留类)。
从功能特性的角度来说 HashTable 是线程安全的,而 HashMap 不是。HashMap 的性能要比 HashTable 更好。因为 HashTable 采用了全局同步锁来保证安全性,对性能影响较大。
从内部实现的角度来说 HashTable 使用数组 + 链表、HashMap 采用了数组 + 链表 + 红黑树。
HashMap 初始容量是 16、HashTable 初始容量是 11。HashMap 可以使用 null 作为 key,HashMap 会把 null 转化为 0 进行存储,而 Hashtable 不允许。
最后,他们两个的 key 的散列算法不同,HashTable 直接是使用 key 的 hashcode 对数组长度做取模。而 HashMap 对 key 的 hashcode 做了二次散列,从而避免 key 的分布不均匀问题影响到查询性能。
ConcurrentHashMap
在 JDK 1.7 的时候,ConcurrentHashMap
(分段锁) 对整个桶数组进行了分割分段(Segment
),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。每个分段锁维护着几个桶(HashEntry),多个线程可以同时访问不同分段锁上的桶,从而使其并发度更高(并发度就是 Segment 的个数)。
static final class Segment<K, V> extends ReentrantLock implements Serializable
Segment 继承自 ReentrantLock。内部 Segment 继承 ReentrantLock 可以理解为一把锁。各个 Segment 之间都是相互独立上锁的,互不影响。相比于之前的 Hashtable 每次操作都需要把整个对象锁住而言,大大提高了并发效率。
JDK 1.8 之后 ConcurrentHashMap
取消了 Segment
分段锁,采用 CAS 和 synchronized
来保证并发安全(在 CAS 操作失败时使用内置锁 synchronized)。数据结构跟 HashMap 1.8 的结构类似,数组 + 链表/红黑二叉树。synchronized
只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发。
图中的节点有三种类型:
- 空着的位置表示还没有元素进行填充;
- 和 HashMap 非常类似的拉链法结构,在每一个槽中会首先填入第一个节点,但是后续如果计算出相同的 Hash 值,就用链表的形式往后进行延伸;
- 红黑树结构,这是 Java 7 的 ConcurrentHashMap 中所没有的结构。
在这样的结构中,链表长度大于 8 就会转换成红黑树;如果节点数小于 6,又会回退到链表。
JDK 的源码注释中对这个问题的解释:
Because TreeNodes are about twice the size of regular nodes, use them only when bins contain enough nodes to warrant use(see TREEIFY_THRESHOLD). And when they become too small (due removal or resizing) they are converted back to plain bins.
单个 TreeNode 需要占用的空间大约是普通 Node 的两倍,所以只有当包含足够多的 Nodes 时才会转成 TreeNodes,而是否足够多就是由 TREEIFY_THRESHOLD 的值决定的。而当桶中节点数由于移除或者 resize 变少后,又会变回普通的链表的形式,以便节省空间。
In usages with well-distributed user hashCodes, tree bins are rarely used. Ideally, under random hashCodes, the frequency of nodes in bins follows a Poisson distribution (http://en.wikipedia.org/wiki/Poisson_distribution) with a parameter of about 0.5 on average for the default resizing threshold of 0.75, although with a large variance because of resizing granularity. Ignoring variance, the expected occurrences of list size k are (exp(-0.5) * pow(0.5, k) / factorial(k)). The first values are:
0:0.60653066
1:0.30326533
2:0.07581633
3:0.01263606
4:0.00157952
5:0.00015795
6:0.00001316
7:0.00000094
8:0.00000006
more: less than 1 in ten million
如果 hashCode 分布良好,也就是 hash 计算的结果离散好的话,那么红黑树这种形式是很少会被用到的,因为各个值都均匀分布,很少出现链表很长的情况。在理想情况下,链表长度符合泊松分布,各个长度的命中概率依次递减,当长度为 8 的时候,概率仅为 0.00000006。
null 问题
为什么 ConcurrentHashMap 不允许 key 或者 value 为空呢?
为了避免在多线程环境下出现歧义问题。如果 key 或者 value 为 null,当我们通过 get(key) 获取对应的 value 的时候,如果返回的结果是 null 我们没办法判断,它是put(k,v) 的时候,value 本身为 null 值,还是这个 key 本身就不存在。
比如在这样一种情况下,线程 T1 调用 containsKey 方法判断 key 是否存在,假设当前这个 key 不存在,本来应该返回 false。但是在 T1 线程返回之前,正好有一个 T2 线程插入了这个 key,但是 value 为null。这就导致原本 T1 线程返回的结果有可能是 true,有可能是 false,取决于 T1 和 T2 线程的执行顺序。
HashMap 允许存储 null 自然是因为不是线程安全集合。
HashTree
TreeMap
和HashMap
都继承自 AbstractMap
,在这之外TreeMap
实现了NavigableMap
接口和SortedMap
接口。
实现
NavigableMap
接口让TreeMap
有了对集合内元素的搜索的能力。实现
SortedMap
接口让TreeMap
有了对集合中的元素根据键排序的能力(默认是按 key 的升序排序)。
TreeMap 重写排序示例:
public class Person {
private final Integer age;
public Person(Integer age) {
this.age = age;
}
public Integer getAge() {
return age;
}
public static void main(String[] args) {
// 通过 age 进行升序排序
TreeMap<Person, String> treeMap = new TreeMap<>(Comparator.comparing(Person::getAge));
treeMap.put(new Person(3), "person1");
treeMap.put(new Person(18), "person2");
treeMap.put(new Person(35), "person3");
treeMap.put(new Person(16), "person4");
treeMap.forEach((k, v) -> System.out.println(k.getAge() + ":" + v));
}
}
LinkedHashMap
继承自 HashMap,因此具有和 HashMap 一样的快速查找特性(public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V>
)。内部维护了一个双向链表,用来维护插入顺序或者 LRU(Least Recently Used) 顺序。
/**
* The head (eldest) of the doubly linked list.
*/
transient LinkedHashMap.Entry<K,V> head;
/**
* The tail (youngest) of the doubly linked list.
*/
transient LinkedHashMap.Entry<K,V> tail;
// accessOrder 决定了顺序,默认为 false,此时维护的是插入顺序
final boolean accessOrder;
afterNodeAccess()
当一个节点被访问时,如果 accessOrder 为 true,则会将该节点移到链表尾部。也就是说指定为 LRU 顺序之后,在每次访问一个节点时,会将这个节点移到链表尾部,保证链表尾部是最近访问的节点,那么链表首部就是最近最久未使用的节点。
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMap.Entry<K,V> last;
if (accessOrder && (last = tail) != e) {
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
p.after = null;
if (b == null)
head = a;
else
b.after = a;
if (a != null)
a.before = b;
else
last = b;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
tail = p;
++modCount;
}
}
afterNodeInsertion()
在 put 等操作之后执行,当 removeEldestEntry() 方法返回 true 时会移除最晚的节点,也就是链表首部节点 first。evict 只有在构建 Map 的时候才为 false,在这里为 true。
removeEldestEntry() 默认为 false,如果需要让它为 true,需要继承 LinkedHashMap 并且覆盖这个方法的实现,这在实现 LRU 的缓存中特别有用,通过移除最近最久未使用的节点,从而保证缓存空间足够,并且缓存的数据都是热点数据。
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}
LRU 缓存
- 设定最大缓存空间 MAX_ENTRIES 为 3;
- 使用 LinkedHashMap 的构造函数将 accessOrder 设置为 true,开启 LRU 顺序;
- 覆盖 removeEldestEntry() 方法实现,在节点多于 MAX_ENTRIES 就会将最近最久未使用的数据移除。
class LRUCache<K, V> extends LinkedHashMap<K, V> {
private static final int MAX_ENTRIES = 3;
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > MAX_ENTRIES;
}
LRUCache() {
super(MAX_ENTRIES, 0.75f, true);
}
}
hashCode
两个对象值相同(x.equals(y) == true),但却可有不同的 hash code,这句话对不对?
如果对象要保存在 HashSet 或 HashMap 中,它们的 equals 相等,那么,它们的 hashcode 值就必须相等。
如果不是要保存在 HashSet 或 HashMap,则与 hashcode 没有什么关系了,这时候 hashcode 不等是可以的,例如 ArrayList 存储的对象就不用实现 hashcode,当然,我们没有理由不实现,通常都会去实现的。
2.4.4 面试题
Java 集合接口特点
List 接口实现了有序列表,允许重复元素。可以通过索引值来访问列表中的元素,也可以使用迭代器进行遍历。List 的实现类有 ArrayList、LinkedList 等,ArrayList 是基于动态数组实现的,而 LinkedList 是基于双向链表实现的。因此,如果需要频繁地在列表中间插入或删除元素,可以选择 LinkedList,否则可以选择 ArrayList。
Map 接口实现了键值对的映射表,不允许重复的键,但允许有重复的值。通过键来访问值,可以使用 put 方法添加键值对,使用 get 方法获取对应的值,也可以使用迭代器遍历键值对。Map 的实现类有 HashMap、TreeMap、LinkedHashMap 等,其中 HashMap 是最常用的实现类,基于哈希表实现,可以快速地根据键来查找对应的值。
Set 接口实现了无序的不重复元素集合。可以使用 add 方法添加元素,使用 contains 方法判断是否包含某个元素,也可以使用迭代器遍历元素。Set 的实现类有 HashSet、TreeSet、LinkedHashSet 等,其中 HashSet 是最常用的实现类,基于哈希表实现,可以快速地判断元素是否存在。如果需要对元素进行排序,可以使用 TreeSet,它基于红黑树实现,可以对元素进行有序排序。如果需要保持元素插入的顺序,可以使用 LinkedHashSet,它基于哈希表和双向链表实现,可以快速地判断元素是否存在,并且可以保持元素插入的顺序。
fail-fast & fail-safe
“fail-fast” 和 “fail-safe” 是两种 Java 集合框架中常用的遍历机制。
“fail-fast” 机制指的是在迭代集合时,如果发现集合已经被修改,那么会立即抛出 ConcurrentModificationException 异常,这种机制需要通过遍历器或者迭代器遍历集合。
“fail-safe” 机制指的是在迭代集合时,不会抛出 ConcurrentModificationException 异常,但是它并不是在集合被遍历时直接对原来的集合进行操作,而是在遍历时将原集合进行了复制,然后在复制的集合上进行遍历和操作。因此,即使原集合被修改了,也不会影响遍历过程中的操作。这种机制通常使用 CopyOnWriteArrayList、ConcurrentHashMap 等并发集合实现。
“fail-fast” 是对原始集合进行直接操作,如果集合在操作过程中被修改就会抛出异常,而 “fail-safe” 是对集合进行复制,然后对复制的集合进行操作,因此不会抛出异常,但可能会在迭代期间看不到最新的更改。
哈希算法
普通的hash算法在分布式应用中的不足。比如,在分布式的存储系统中,要将数据存储到具体的节点上,如果我们采用普通的hash算法进行路由,将数据映射到具体的节点上,如 key%N,key 是数据的 key,N 是机器节点数,如果有一个机器加入或退出这个集群,则所有的数据映射都无效了,如果是持久化存储则要做数据迁移,如果是分布式缓存,则其他缓存就失效了。
哈希冲突是指不同的键值对在经过哈希函数计算后,得到的哈希值相同的情况。为了解决哈希冲突,常见的方式包括以下几种:
- 开放地址法(Open Addressing):当产生冲突时,采用探测技术寻找下一个空闲的散列地址,然后将数据插入。探测技术的方式有线性探测、二次探测和再哈希法等。
- 链地址法(Separate Chaining):使用链表等数据结构来存储相同哈希值的数据,当出现哈希冲突时,将数据存储在链表的末尾。这是目前使用最为广泛的一种方式。
- 建立公共溢出区(Public Overflow Area):在哈希表之外,建立一个公共溢出区,将哈希表中产生冲突的元素存储在该区域中。
- 再哈希法(Rehashing):当哈希表中出现冲突时,通过再次进行哈希计算来产生一个新的哈希值,直到没有冲突为止。
判断哈希好坏/一致性哈希
一致性哈希提出了在动态变化的 Cache 环境中,哈希算法应该满足的 4 个适应条件
- 平衡性(Balance): 指哈希的结果能够尽可能分布到所有的缓冲中去,这样可以使得所有的缓冲空间都得到利用;
- 单调性(Monotonicity): 指如果已经有一些内容通过哈希分派到了相应的缓冲中,又有新的缓冲加入到系统中。哈希的结果应能够保证原有已分配的内容可以被映射到原有的或者新的缓冲中去,而不会被映射到旧的缓冲集合中的其他缓冲区;
- 分散性(Spread): 在分布式环境中,终端有可能看不到所有的缓冲,而是只能看到其中的一部分。当终端希望通过哈希过程将内容映射到缓冲上时,由于不同终端所见的缓冲范围有可能不同,从而导致哈希的结果不一致,最终的结果是相同的内容被不同的终端映射到不同的缓冲区中。这种情况显然是应该避免的,因为它导致相同内容被存储到不同缓冲中去,降低了系统存储的效率。分散性的定义就是上述情况发生的严重程度。好的哈希算法应能够尽量避免不一致的情况发生,也就是尽量降低分散性;
- 负载(Load): 负载问题实际上是从另一个角度看待分散性问题。既然不同的终端可能将相同的内容映射到不同的缓冲区中,那么对于一个特定的缓冲区而言,也可能被不同的用户映射为不同的内容。与分散性一样,这种情况也是应当避免的,因此好的哈希算法应能够尽量降低缓冲的负荷。
2.5 Java 传参
Java 的参数是以值传递的形式传入方法中,而不是引用传递。
当参数是对象时,传递的是对象的值,也就是对象的首地址。就是把对象的地址赋值给形参。
public class PassByValueExample {
public static void main(String[] args) {
Dog dog = new Dog("A");
System.out.println(dog.getObjectAddress()); // Dog@4554617c
func(dog);
System.out.println(dog.getObjectAddress()); // Dog@4554617c
System.out.println(dog.getName()); // A
}
private static void func(Dog dog) {
System.out.println(dog.getObjectAddress()); // Dog@4554617c
dog = new Dog("B");
System.out.println(dog.getObjectAddress()); // Dog@74a14482
System.out.println(dog.getName()); // B
}
}
根据 StackOverflow 帖子中回答的解释,在函数中更改的是形参中的引用。但并没有对主函数中的 dog 产生改动,所以之后输出的 name 依然是 A。这不是引用传递,因为这并非是指向原来内容的指针。但如果修改引用对象内部的数据,原内部数据也会发生相应变化。
2.6 运算
在 Java 运算中,禁止隐式向下转化(double 不能转成 float,1.1f
才表示 float 类型)。Java 中涉及表达式运算时的类型自动提升,所以会出现如下代码:
short x = 1;
// 不允许,3 默认是 int 类型,不能隐式转化
x = x + 3;
// 允许,相当于 x = (short) (x + 3)
x += 3;
2.6.1 位运算
在 Java 中,二进制表示的数值的最高位是符号位。
>>
:表示有符号右移,会考虑到符号位的情况;
>>>
:表示无符号左移,会使用0补全最高位。
public class Test {
public static void main(String[] args) {
int a = 20;
int b = -20;
System.out.println(a >> 2);
System.out.println(b >> 2);
System.out.println(a >>> 2);
System.out.println(b >>> 2);
/*
5
-5
5
1073741819
*/
}
}
2.6.2 BigInteger
BigInteger 主要为解决极大的整数运算,类位于 java.math 包中。
当要存放的整数大于 32 位时,就会被分割成 32 位为一组的形式,每一组就作为底层数组的一个元素。底层结构的实现都是 final 的,这代表 BigInteger 不可变。
// 符号标志,-1:负数、0:零、1:正数
final int signum;
// 采用大端存储(高位低索引位),只存放二进制位的绝对值
final int[] mag;
针对于 signum,Java 源码解释为 0 的存在让 0 只存在唯一的表示形式(同时也要求 mag 拥有 0 长度的数组)。
对于 mag 数组的存储方式为什么直接使用补码,个人认为是为了使操作的算法更为高效和方便:
- 如果采用补码,* / 等操作将会变得很复杂,并且,获取相反数、绝对值等算法的复杂度也会由常数变为线性;
- 由于底层数组是 final 的,仅仅想改改符号位也是不可能的,必须要深拷贝一份, 如果有了 signum 存放符号, 求个相反数只需要求反个 signum, 拷贝 mag 只需浅拷贝;
- 有了 signum 使判断是否为 0 十分方便。
设计一个一百亿的计算器
我们得自己设计一个类可以用于表示很大的整数,并且提供了与另外一个整数进行加减乘除的功能,大概功能如下:
1)这个类内部有两个成员变量,一个表示符号,另一个用字节数组表示数值的二进制数;
2)有一个构造方法,把一个包含有多位数值的字符串转换到内部的符号和字节数组中;
3)提供加减乘除的功能。
public class BigInteger{
int sign;
byte[] val;
public Biginteger(String val) {
sign = ;
val = ;
}
public BigInteger add(BigInteger other) {
}
public BigInteger subtract(BigInteger other) {
}
public BigInteger multiply(BigInteger other){
}
public BigInteger divide(BigInteger other){
}
}
2.7 static & final
static
关键字是 Java 中一个很常用的关键字,它可以用来修饰变量、方法和类。它的主要作用是:
- 静态变量:被所有对象共享,只在类加载的时候初始化一次,可以通过类名直接访问,无需创建对象;
- 静态方法:属于类,不属于对象,可以通过类名直接调用,无需创建对象。静态方法不能访问非静态成员,因为非静态成员只有在创建对象后才会存在;
- 静态块:在类加载时执行,只执行一次,用于初始化静态变量或执行一些静态代码;
- 静态内部类:与非静态内部类不同,静态内部类只能访问外部类的静态成员,因为它不需要外部类的对象实例。
static:
- 为某种特定数据类型或对象分配与创建对象个数无关的单一的存储空间;
- 使得某个方法或属性与类而不是对象关联在一起,即在不创建对象的情况下可通过类直接调用方法或使用类的属性;
- static 还能用来用在 import 中,这样使用变量和方法不需要指定 ClassName。
final:
用于声明属性、方法和类,分别表示属性不可变、方法不可覆盖、类不可继承。
申明的引用对象可以使引用不变,但是引用对象本身可以做出更改。
final StringBuffer a = new StringBuffer("immutable");
// 不允许
a = new StringBuffer("");
// 允许,引用对象的内容可以改变
a.append(" broken!");
2.7.1 代码执行顺序
- 父类(静态变量、静态语句块);
- 子类(静态变量、静态语句块);
- 父类(实例变量、普通语句块/非静态代码块);
- 父类(构造函数);
- 子类(实例变量、普通语句块/非静态代码块);
- 子类(构造函数);
- 普通代码块。
2.7.2 执行问题
是否可以从一个 static 方法内部发出对非 static 方法的调用?
不可以。因为非 static 方法是要与对象关联在一起的,必须创建一个对象后,才可以在该对象上进行方法调用,而 static 方法调用时不需要创建对象,可以直接调用。也就是说,当一个 static 方法被调用时,可能还没有创建任何实例对象,如果从一个 static 方法中发出对非 static 方法的调用,那个非 static 方法是关联到哪个对象上的呢?这个逻辑无法成立,所以,一个 static 方法内部发出对非 static 方法的调用。
2.8 Java 异常
Java 异常是指在程序执行过程中出现的错误或异常情况,例如空指针引用、数组越界、文件找不到等。Java 通过异常处理机制来处理这些异常情况。
Java 异常处理机制包括以下几个关键字和语句:
- try-catch-finally 语句块:try 块中包含可能会抛出异常的代码,catch 块中包含处理异常的代码,finally 块中包含一定会执行的代码,无论是否出现异常;
- throw 关键字:用于在代码块中抛出异常;
- throws 关键字:用于声明一个方法可能会抛出哪些类型的异常;
- try-with-resources 语句块:Java 7 引入的新特性,用于自动关闭资源,例如文件、数据库连接等。
Java 中的异常分为两种:受检异常(checked exceptions)和非受检异常(unchecked exceptions)。受检异常需要在方法声明中使用 throws 关键字声明,或在方法内部使用 try-catch 捕获和处理;非受检异常不需要在方法声明中使用 throws 关键字声明,也不要求在方法内部捕获和处理。
// try-with-resources 使用
public class MyAutoClosable implements AutoCloseable {
public void doIt() {
System.out.println("MyAutoClosable doing it!");
}
@Override
public void close() throws Exception {
System.out.println("MyAutoClosable closed!");
}
public static void main(String[] args) {
try(MyAutoClosable myAutoClosable = new MyAutoClosable()){
myAutoClosable.doIt();
} catch (Exception e) {
e.printStackTrace();
}
}
}
2.8.1 error & exception
异常表示程序运行过程中可能出现的非正常状态,运行时异常表示虚拟机的通常操作中可能遇到的异常,是一种常见运行错误。Java 编译器要求方法必须声明抛出可能发生的非运行时异常,但是并不要求必须声明抛出未被捕获的运行时异常。
在 Java 中,Error 和 Exception 都是 Throwable 类的子类,但它们用于表示不同类型的异常情况。
Error 表示系统级别的错误或故障,通常是由 Java 虚拟机或底层系统抛出,程序无法捕获或处理这种异常。比如 OutOfMemoryError、StackOverflowError 等。
Exception 表示程序运行时的异常,是可以被捕获并进行相应的处理的。Exception 又分为 checked exception(可检查异常)和unchecked exception(不可检查异常)两种:
- checked exception 通常是在编译时就可以被发现的异常,必须在代码中进行捕获或者抛出。比如 IOException、SQLException 等;
- unchecked exception 则是在运行时才能够被发现的异常,不强制要求在代码中进行处理,但也可以进行处理。比如 NullPointerException、ArrayIndexOutOfBoundsException 等。
常见 checked exception
编译时异常通常指的是受检异常(checked exceptions),主要包括以下几个方面:
- 文件操作异常,如
IOException
、FileNotFoundException
。 - 网络通信异常,如
SocketException
、SocketTimeoutException
。 - 数据库操作异常,如
SQLException
、DataAccessException
。 - 反射操作异常,如
ClassNotFoundException
、NoSuchMethodException
。 - XML 文件操作异常,如
SAXException
、XPathException
。
这些异常一般都需要程序员在代码中显式处理,即使用 try-catch
或者向上抛出。
常见 RuntimeException
- NullPointerException(空指针异常):当一个变量为 null,但尝试调用它的方法或访问它的属性时,抛出该异常;
- IndexOutOfBoundsException(下标越界异常):当尝试访问数组、字符串或集合的不存在的位置时,抛出该异常;
- IllegalArgumentException(非法参数异常):当传递给方法的参数不符合方法的预期值范围时,抛出该异常;
- ArithmeticException(算术异常):当进行算术运算时发生异常,例如除以零;
- ClassCastException(类转换异常):当尝试将一个对象强制转换为其子类或不相关的类时,抛出该异常。
异常机制应用
Java 中异常处理机制的应用非常广泛,可以用于处理各种错误和异常情况,比如网络连接异常、文件读写异常、数组越界异常、空指针异常等等。在程序中,我们通常会使用 try-catch 语句块来捕捉这些异常并进行相应的处理,比如记录日志、提示用户、重试连接等等。
throw 在程序段中,让程序显式抛出异常。
同时,在设计 API 时,我们也会使用 throws 关键字来声明可能抛出的异常,这样可以让调用者在调用时进行相应的处理。
在使用异常中,要注意以下规范:
- 不要过度使用异常,即不对大量代码块使用异常,应该尽量保证代码健壮性;
- 不捕获 Java 类库中定义继承自 RuntimeException 的异常,这类异常应该让程序员预检查来规避;
- 存在文件流或者其他情况,需要在 finally 里进行关闭。
2.8.2 finally
finally 语句块在两种情况下不会执行:
- 在 try 或者cache 语句块中,执行了
System.exit(0)
语句,导致 JVM 直接退出; - 程序没有进入到 try 语句块因为异常导致程序终止,这个问题主要是开发人员在编写代码的时候,异常捕获的范围不够。
执行顺序
public class Test {
public static void main(String[] args) {
System.out.println(Test.test());
// 结果为 1
}
static int test() {
int x = 1;
try {
return x;
} finally {
++x;
}
}
}
当 try 语句块中的 return x;
被执行时,会将 x 的值返回并且退出该方法,但是在方法退出前会执行 finally 语句块中的代码。在 finally 语句块中,x 的值被增加了 1,但这个值并不会被返回。
public class SmallT {
public static void main(String args[]) {
SmallT t = new SmallT();
int b = t.get();
System.out.println(b);
// 返回结果为 2
}
public int get() {
try {
return 1;
} finally {
return 2;
}
}
}
try 中的 return 语句调用的函数先于 finally 中调用的函数执行,也就是说 return 语句先执行,finally 语句后执行,所以,返回的结果是 2。return 并不是让函数马上返回,而是 return 语句执行后,将把返回结果放置进函数栈中,此时函数并不是马上返回,它要执行 finally 语句后才真正开始返回。
// 上述代码的执行顺序可以用这个程序进行解释
public class Test {
public static void main(String[] args) {
System.out.println(new Test().test());
}
int test() {
try {
return func1();
} finally {
return func2();
}
}
int func1() {
System.out.println("func1");
return 1;
}
int func2() {
System.out.println("func2");
return 2;
}
}
-----------执行结果-----------------
func1
func2
2
finally 中的代码比 return 和 break 语句后执行。
final、finally、finalize
final 用于声明属性、方法和类,分别表示属性不可变、方法不可覆盖、类不可继承;
finally 作为异常处理的一部分,只能在 try/catch 语句中使用,finally 附带一个语句块用来表示这个语句最终一定被执行,经常被用在需要释放资源的情况下;
- finalize 是 Object 类的一个方法,在垃圾收集器执行的时候会调用被回收对象的 finalize() 方法。当垃圾回收器准备好释放对象占用空间时,首先会调用 finalize() 方法,并在下一次垃圾回收动作发生时真正回收对象占用的内存。
2.8.3 assert
assertion(断言)在软件开发中是一种常用的调试方式,很多开发语言中都支持这种机制。在实现中,assertion 就是在程序中的一条语句,它对一个 boolean 表达式进行检查,一个正确程序必须保证这个 boolean 表达式的值为 true;如果该值为 false,说明程序已经处于不正确的状态下,assert 将给出警告或退出。一般来说,assertion 用于保证程序最基本、关键的正确性。
assertion 检查通常在开发和测试时开启。为了提高性能,在软件发布后,assertion 检查通常是关闭的。
3. Java 进阶
3.1 Java 代理模式
需要进行代理的接口以及实现:
// 需要代理的接口
public interface SmsService {
void send(String message);
}
public class SmsServiceImpl implements SmsService {
@Override
public void send(String message) {
System.out.println("send message:" + message);
}
}
3.1.1 静态代理
静态代理中,我们对目标对象的每个方法的增强都是手动完成的,接口一旦新增加方法,目标对象和代理对象都要进行修改且需要对每个目标类都单独写一个代理类。通过代理类调用原先实现类,之后再由提供给开发者调用。
// 静态代理类的写法
public class SmsProxy implements SmsService {
private final SmsService smsService;
public SmsProxy(SmsService smsService) {
this.smsService = smsService;
}
@Override
public void send(String message) {
// 调用方法之前,我们可以添加自己的操作
System.out.println("before method send()");
smsService.send(message);
// 调用方法之后,我们同样可以添加自己的操作
System.out.println("after method send()");
}
}
// 主类直接调用代理类
public class Main {
public static void main(String[] args) {
SmsService smsService = new SmsServiceImpl();
SmsProxy smsProxy = new SmsProxy(smsService);
smsProxy.send("java");
}
}
3.1.2 动态代理
静态代理与动态代理的区别主要在:
- 静态代理在编译时就已经实现,编译完成后代理类是一个实际的 class 文件;
- 动态代理是在运行时动态生成的,即编译完成后没有实际的 class 文件,而是在运行时动态生成类字节码,并加载到 JVM 中。
动态代理对象不需要实现接口,但是要求目标对象必须实现接口,否则不能使用动态代理。
JDK 代理
Spring AOP、RPC 框架应该是两个不得不提的,它们的实现都依赖了动态代理。
- 创建被代理的目标类以及其实现的接口;
- 创建
InvocationHandler
接口的实现类,在invoke()
中完成要代理的功能; - 用
Proxy.newInstance()
动态地构造出代理对象。
// 实现处理类,处理 proxy 的方法实现
public class DebugInvocationHandler implements InvocationHandler {
private final Object target;
public DebugInvocationHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy,
Method method,
Object[] args) throws Throwable {
System.out.println("before");
Object result = method.invoke(target, args);
System.out.println("after");
return result;
}
}
// 封装一个代理工厂类
public class JdkProxyFactory {
public static Object getProxy(Object target) {
return Proxy.newProxyInstance(
target.getClass().getClassLoader(), // 目标类的类加载
target.getClass().getInterfaces(), // 代理需要实现的接口,可指定多个
new DebugInvocationHandler(target) // 代理对象对应的自定义 InvocationHandler
);
}
}
public class Main {
public static void main(String[] args) {
SmsService smsService = (SmsService) JdkProxyFactory.getProxy(new SmsServiceImpl());
smsService.send("java");
}
}
CGLIB 代理
CGLIBopen in new window(Code Generation Library) 实际是属于一个开源项目,如果你要使用它的话,需要手动添加相关依赖。如果你已经有 spring-core 的 jar 包,则无需引入,因为 Spring 中包含了 cglib。
<dependency>
<groupId>cglib</groupId>
<artifactId>cglib</artifactId>
<version>3.3.0</version>
</dependency>
- JDK 动态代理只能代理实现了接口的类或者直接代理接口,而 CGLIB 可以代理未实现任何接口的类,达到代理无侵入。 另外,CGLIB 动态代理是通过生成一个被代理类的子类来拦截被代理类的方法调用,因此不能代理声明为 final 类型的类和方法、无法代理 static 方法等;
- 就二者的效率来说,大部分情况都是 JDK 动态代理更优秀,随着 JDK 版本的升级,这个优势更加明显。JDK 代理使用的是反射机制实现 AOP 的动态代理,CGLib 代理使用字节码处理框架 ASM,通过修改字节码生成子类;
- JDK 动态代理机制是委托机制,在动态生成的实现类里面委托 hanlder 去调用原始实现类方法。CGLib 则使用的继承机制,被代理类和代理类是继承关系,所以代理类是可以赋值给被代理类的,如果被代理类有接口,那么代理类也可以赋值给接口。
public class UserDao{
public void save() {
System.out.println("保存数据");
}
}
public class ProxyFactory implements MethodInterceptor{
private Object target;//维护一个目标对象
public ProxyFactory(Object target) {
this.target = target;
}
//为目标对象生成代理对象
public Object getProxyInstance() {
//工具类
Enhancer en = new Enhancer();
//设置父类
en.setSuperclass(target.getClass());
//设置回调函数
en.setCallback(this);
//创建子类对象代理
return en.create();
}
@Override
public Object intercept(Object obj,
Method method,
Object[] args,
MethodProxy proxy) throws Throwable {
System.out.println("开启事务");
// 执行目标对象的方法
Object returnValue = method.invoke(target, args);
System.out.println("关闭事务");
return null;
}
}
public class TestProxy {
@Test
public void testCglibProxy(){
//目标对象
UserDao target = new UserDao();
System.out.println(target.getClass());
//代理对象
UserDao proxy = (UserDao) new ProxyFactory(target).getProxyInstance();
System.out.println(proxy.getClass());
//执行代理对象方法
proxy.save();
}
}
3.2 注解
Java 注解(Annotation)是一种特殊的标记,可以用来在代码中添加元数据(metadata),以实现一些特殊的行为。
Java 提供了一些预定义的注解,例如 @Override、@Deprecated、@SuppressWarnings 等,开发者也可以自定义注解来标记自己的代码。常见的自定义注解包括 @Component、@Service、@Controller 等等。
3.2.1 元注解
Java 中的元注解是一组特殊的注解,用于注解其他注解。它们可以用来控制注解的作用范围,以及指定注解的使用条件等。
- @Retention:用于指定注解的生命周期,包括三个 RetentionPolicy 值:SOURCE、CLASS 和 RUNTIME;
- @Target:用于指定注解可以应用的程序元素,包括 ANNOTATION_TYPE、CONSTRUCTOR、FIELD、LOCAL_VARIABLE、METHOD、PACKAGE、PARAMETER 和 TYPE;
- @Documented:用于指定注解是否包含在 Java 文档中;
- @Inherited:用于指定注解是否可以被继承;
- @Repeatable:用于指定注解是否可以重复应用于同一程序元素上,该元注解只在 JDK 8 之后引入。
3.3 Java 序列化
将存放在内存中的数据转移到硬盘中的过程,需要用到序列化(比如 ORM 框架)。
- 序列化: 将数据结构或对象转换成二进制字节流的过程;
- 反序列化:将在序列化过程中所生成的二进制字节流的过程转换成数据结构或者对象的过程。
- 对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化;
- 将对象存储到文件中的时候需要进行序列化,将对象从文件中读取出来需要进行反序列化;
- 将对象存储到缓存数据库(如 Redis)时需要用到序列化,将对象从缓存数据库中读取出来需要反序列化。
Java 中的序列化需要实现 Serializable 接口,反序列化时需要提供无参的构造函数,不然会报错。
通过 ObjectOutputStream
& ObjectInputStream
实现这一系列操作,分别调用 writeObject(Object obj)
& readObject()
方法。
3.3.1 序列化方式
原生 JDK 序列化方式存在性能差(序列化之后的字节数组体积较大,导致传输成本加大),不支持跨语言调用。
静态成员变量不能序列化,序列化针对对象属性,而静态成员变量属于类;
在序列化时添加一个
serialVersionUID
。Java 会将传来的 UID 和本地的 UID 进行比对,如果不一致,不同意反序列化操作。导致 UID 兼容性问题可能有这些原因:
- 手动修改导致当前的 serialVersionUID 与序列化前的不一样;
- 缺少 serialVersionUID 常量,JVM 内部会根据类结构去计算 serialVersionUID,在类结构发生改变时(属性增加,删除或者类型修改了)导致 serialVersionUID 发生变化。
- 假如类结构没有发生改变,并且没有定义 serialVersionUID ,虚拟机不一样也可能导致 serialVersionUID 不一样。
Kryo
Kryo 是一种高性能的 Java 序列化框架,相较于 Java 自带的序列化机制,它更加高效、快速、灵活,能够处理更多的数据类型。Kryo 采用二进制序列化,序列化后的数据更小、速度更快,因此在需要频繁序列化和反序列化的场景下,使用 Kryo 可以有效提升系统性能。
使用 Kryo 进行序列化,需要先定义一个 Kryo 实例,然后将需要序列化的对象传递给该实例进行序列化。Kryo 会将对象序列化为字节数组,然后可以将该字节数组存储在磁盘或通过网络传输给其他进程或系统。反序列化时,需要再次创建一个 Kryo 实例,将序列化后的字节数组传递给该实例进行反序列化,最终得到原始的 Java 对象。
Kryo 支持的数据类型非常丰富,包括 Java 中的基本数据类型、日期类型、集合类型、自定义类型等等。同时,Kryo 还支持自定义序列化器,可以对特定的数据类型进行定制化的序列化和反序列化操作。因此,Kryo 适用于需要高性能、灵活性强的数据序列化和反序列化场景,例如分布式缓存、消息队列等等。
3.3.2 transient
transient
关键字的主要作用就是让某些被 transient 关键字修饰的成员属性变量不被序列化。导致被 transient 修饰的字段会重新计算,初始化。
比如:如果一个用户有一些密码等信息,为了安全起见,不希望在网络操作中被传输,这些信息对应的变量就可以加上 transient 关键字。
3.4 Java 反射
Java 中,直接使用 new 对类进行实例化,作为正射操作。
Java 反射机制是在运行状态中,对于任意一个类,才知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为 Java 语言的反射机制。
反射优点:
- 增加程序的灵活性,可以在运行的过程中动态对类进行修改和操作;
- 提高代码的复用率,比如动态代理,就是用到了反射来实现;
- 可以在运行时轻松获取任意一个类的方法、属性,并且还能通过反射进行动态调用。
反射的缺点如下:
性能瓶颈:反射涉及了动态类型的解析,所以 JVM 无法对这些代码进行优化。因此,反射操作的效率要比那些非反射操作低得多。代码可读性也会下降;
安全问题:使用反射技术要求程序必须在一个没有安全限制的环境中运行。如果一个程序必须在有安全限制的环境中运行,如 Applet,那么这就是个问题了;
内部暴露:由于反射允许代码执行一些在正常情况下不被允许的操作(比如访问私有的属性和方法),所以使用反射可能会导致意料之外的副作用,这可能导致代码功能失调并破坏可移植性。反射代码破坏了抽象性,因此当平台发生改变的时候,代码的行为就有可能也随着变化。
反射使用:
- 我们在使用 JDBC 连接数据库时使用 Class.forName() 通过反射加载数据库的驱动程序;
- Spring 框架的 IOC(动态加载管理 Bean)创建对象以及 AOP(动态代理)功能都和反射有联系;
- 动态配置实例的属性。
3.4.1 Class 对象
Java 中对象可以分为实例对象和 Class 对象,每一个类都有一个 Class 对象,其包含了与该类有关的信息。
获取 Class 对象的方法:
- Class.forName(“类的全限定名”);
- 实例对象.getClass();
- 类名.class。
// 1.通过字符串获取Class对象,这个字符串必须带上完整路径名
Class studentClass = Class.forName("com.test.reflection.Student");
// 2.通过类的class属性
Class studentClass2 = Student.class;
// 3.通过对象的getClass()函数
Student studentObject = new Student();
Class studentClass3 = studentObject.getClass();
3.4.2 反射机制
Java 反射机制是指在程序的运行过程中可以构造任意一个类的对象、获取任意一个类的成员变量和成员方法、获取任意一个对象所属的类信息、调用任意一个对象的属性和方法。反射机制使得 Java 具有动态获取程序信息和动态调用对象方法的能力。
Java 反射主要包括以下内容:
- Class 类:Java 反射机制的核心类,代表一个类的字节码文件,在程序运行时动态加载类并获取类的各种信息;
- Field 类:代表类的成员变量,包括变量类型、名称、修饰符、注解等信息;
- Method 类:代表类的方法,包括方法名、参数类型、返回值类型、修饰符、注解等信息;
- Constructor 类:代表类的构造函数,包括参数类型、修饰符、注解等信息。
使用 Java 反射机制可以实现很多高级的功能,如依赖注入(DI)、ORM(对象关系映射)、动态代理等。但是由于 Java 反射机制的高度动态性和灵活性,使用反射机制可能会导致代码的可读性和性能问题,因此在使用反射时需要谨慎选择。
public class ReflectionExample {
public static void main(String[] args)
throws ClassNotFoundException, NoSuchMethodException,
InvocationTargetException, InstantiationException, IllegalAccessException {
Class<?> clazz = Class.forName("test.MyClass");
Method method = clazz.getMethod("sayHello", String.class);
Constructor<?> constructor = clazz.getDeclaredConstructor();
// jdk 9 已废弃直接使用 newInstance()
// Object o = clazz.newInstance();
Object o = constructor.newInstance();
Object res = method.invoke(o, "Cxy621");
// 返回类型是 void,所以 res 为 null
System.out.println(res);
}
}
class MyClass {
public void sayHello(String name) {
System.out.println("Hello, " + name + "!");
}
}
通过反编译代码来看,类默认的构造器访问修饰符为 default。
获取内容
获取字段有两个 API:getDeclaredFields
和 getFields
。他们的区别是:getDeclaredFields
用于获取所有声明的字段,包括公有字段和私有字段,getFields
仅用来获取公有字段。
获取构造方法同样包含了两个 API:用于获取所有构造方法的 getDeclaredConstructors
和用于获取公有构造方法的 getConstructors
。
获取非构造方法的两个 API 是:获取所有声明的非构造函数的 getDeclaredMethods
和仅获取公有非构造函数的 getMethods
。
3.5 泛型
泛型(Generics)是 Java 5 中引入的一个重要特性,它可以让程序员在代码中定义一些类型参数(Type Parameters),从而实现代码的重用和类型安全。
使用泛型可以让程序员在编写代码时将类型参数作为变量来使用,这些类型参数可以是任何 Java 类型,包括自定义类型。在实际使用时,程序员可以将具体的类型传递给这些类型参数,从而创建出具体的类型。泛型可以应用于类、接口、方法等各种 Java 元素中。
通用的泛型标准:
- E-Element 在集合中使用,表示在集合中存放的元素;
- T-Type 表示 Java 类;
- K-Key 表示键;
- V-Value 表示值;
- N-Number 表示数值类型;
- ? 表示不确定的类型,是类型实参,不是类型形参。
对泛型的上限限定:<? extends T>,表示该通配符所代表的类型是T的子类或是T的实现类;
对泛型的下限限定:<? super T>,表示该通配符所代表的类型是T的父类或父接口。
public class GenericTest {
public class Generic<T> {
private final T key;
public Generic(T key) {
this.key = key;
}
public T getKey() {
return key;
}
/*
修饰符和返回值之间的 <T, K> 必不可少,表示泛型方法并申明泛型类型
(因为类已经申明 T 类型,所以 <T> 可以省略)
public <T, K> K showKeyName(Generic<T> container) {
...
}
*/
public T showKeyName(Generic<T> container) {
return container.getKey();
}
}
}
3.5.1 类型擦除
但是 Java 的泛型并不是真正的泛型,在编译时,泛型中的类型会被擦除,在运行时不存在任何类型相关的信息。例如 List<String>
在运行时仅用一个 List 来表示。为了确保能和 Java 5 之前的版本开发二进制类库进行兼容。
public static void main(String[] args) {
ArrayList<String> arrayString = new ArrayList<String>();
ArrayList<Integer> arrayInteger = new ArrayList<Integer>();
// output true
System.out.println(arrayString.getClass()==arrayInteger.getClass());
}
3.5.2 面试题
List<String>
并不能传递给 List<Object>
,因为 Object 范围更宽,这样传递会导致编译错误。
Array 并不支持泛型,Effective Java 书中建议使用 List 来代替 Array。利用 List 的泛型可以保证编译期间的类型安全保证。
如果把泛型和原始类型混合起来使用,例如下列代码,Java 5 的 javac 编译器会产生类型未检查的警告,例如 List<String> rawList = new ArrayList()
。
3.6 字节流 & 字符串流
要把一片二进制数据数据逐一输出到某个设备中,或者从某个设备中逐一读取一片二进制数据,不管输入输出设备是什么,我们要用统一的方式来完成这些操作,用一种抽象的方式进行描述,这个抽象描述方式起名为 IO 流,对应的抽象类为 OutputStream 和 InputStream ,不同的实现类就代表不同的输入和输出设备,它们都是针对字节进行操作的。
字符流以字符为单位进行输入和输出操作,常用于处理文本数据,因为它能够自动处理字符集编码和解码的问题,避免了处理乱码的繁琐过程。
另外,字节流可以操作任何类型的对象,而字符流只能操作字符类型的对象。在实际开发中,根据需要选择合适的流进行操作,可以提高程序的运行效率和稳定性。
3.7 设计模式
3.7.1 单例模式
单例模式,就是一个类在任何情况下绝对只有一个实例,并且提供一个全局访问点来获取该实例。使用最频繁的设计模式之一,为了防止浪费资源。
加锁
懒汉式加载,最初不进行初始化,在第一次调用时才进行初始化,之后返回初始化后的结果。问题也比较明显,没法避免多线程问题。
public class Singleton {
private static Singleton INSTANCE;
private Singleton() {}
public static Singleton getInstance() {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
return INSTANCE;
}
}
改进的方式是通过 synchronized 进行加锁处理。但是加锁会出现性能问题,而且锁指需要在第一次初始化中有用。
public class Singleton {
private static Singleton INSTANCE;
private Singleton() {}
public static synchronized Singleton getInstance() {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
return INSTANCE;
}
}
使用双锁是为了解决单例模式在多线程环境下的并发问题。当多个线程同时访问 getInstance() 方法时,如果单例实例还未创建,那么多个线程会同时进入 if (INSTANCE == null) 这个判断语句,导致多个线程同时创建了多个实例,违背了单例模式的定义。
需要注意的是,使用双重检查锁时需要将单例变量设置为 volatile,确保线程间的可见性和防止指令重排。
public class Singleton {
private static volatile Singleton INSTANCE;
private Singleton() {}
public static Singleton getInstance() {
if (INSTANCE == null) {
synchronized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
静态内部类
比较简单的实现方式是饿汉式,不管是否调用都会初始化。因为是静态块里初始化,所以只会执行一次。
public class Singleton {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return INSTANCE;
}
}
饿汉式的问题在于无论何时都会进行加载,这会可能会导致资源无意义消耗。我们可以把 INSTANCE 写在静态内部类中,静态内部类只有在调用时被加载,这样实现了延时加载。
public class Singleton {
private Singleton() {}
public static Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
}
枚举
- 线程安全:枚举类默认就是线程安全的,不需要再使用同步锁或者 volatile 关键字;
- 防止反序列化:枚举类的实例是在类加载时创建的,并且是 final 类型,不能被反序列化创建新的对象;
- 防止反射攻击:枚举类中没有公开的构造函数,不能使用反射创建新的对象。
public enum Singleton {
INSTANCE;
public void someMethod() {
// do something
}
}
4. JDK 特性
4.1 JDK 1.8
JDK 1.8,第一个 Java 更新的大版本。正式实现了 Lambda 表达式以及 LocalDate/LocalDateTime。对底层内容进行不少的优化。
4.1.1 interface
新 interface 的方法可以用default
或 static
修饰,这样就可以有方法体,实现类也不必重写此方法。
default
修饰的方法,是普通实例方法,可以用this
调用,可以被子类继承、重写;static
修饰的方法,使用上和一般类静态方法一样。但它不能被子类继承,只能用Interface
调用。
4.1.2 Lambda
可以使用方法引用来代替某些 Lambda 表达式。
lambda 表达式有个限制,那就是只能引用 final 或 final 局部变量,这就是说不能在lambda内部修改定义在域外的变量。
public class Test {
public static void main(String[] args) {
List<String> list = Arrays.asList("a", "b", "c", "d", "e");
list.forEach(System.out::println);
// list.forEach((String s) -> System.out.println(s));
// 编译器会推断类型
List<Integer> strings = Arrays.asList(1, 2, 3);
/*strings.sort(new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o1 - o2;
}
});*/
strings.sort(Comparator.comparingInt(o -> o));
}
}
使用例子
public static void main(String[] args) {
Supplier<Student> a = Student::new;// 构造引用
a.get().setName("cxy");
System.out.println(a);
// Supplier<String> b = () -> "Hello, World";
}
Java
Supplier
是一个功能接口,代表结果的提供者。
一个 Supplier
可以通过 lambda
表达式、方法引用或默认构造函数来实例化。
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();// 对象:实例方法
list.forEach(System.out::println);
// list.forEach(o -> {System.out.println(o);});
Stream<Double> stream = Stream.generate(Math::random);
}
Stream
jdk 1.8 正式引入 stream 操作,通过时间换空间的方式,简化开发操作。stream 不用对原本的数据进行修改。
filter
filter 是对数据进行过滤,主要用于筛选特定数值的数据。
Predicate 直译是谓词,这里表示用于 filter 的条件参数,用于自定义过滤条件。在源码中,Predicate 也提供了 or、and 等方法形成多条件过滤。
public static void main(String[] args) {
List<String> languages = Arrays.asList("Java", "Scala", "C++", "Haskell", "Lisp");
System.out.println("Languages which starts with J :");
filter(languages, (str) -> str.startsWith("J"));
System.out.println("Languages which ends with a ");
filter(languages, (str) -> str.endsWith("a"));
System.out.println("Print all languages :");
filter(languages, (str) -> true);
System.out.println("Print no language : ");
filter(languages, (str) -> false);
System.out.println("Print language whose length greater than 4:");
filter(languages, (str) -> str.length() > 4);
}
public static void filter(List<String> names, Predicate<String> condition) {
names.stream().filter(condition).forEach((name) -> {
System.out.println(name + " ");
});
}
map & Collectors
map 将集合类(例如列表)元素进行转换的,最常用的有 mapToInt 这种可以让 List 中的 Integer 转化为 int 执行一些操作。
public static void main(String[] args) {
List<Integer> costBeforeTax = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// 通过 lambda 表达式计算总花费,使用 reduce 方法将所有值合并
double bill = costBeforeTax.stream().map((i) -> i + 0.12 * i).reduce(Double::sum).get();
// 61.599999999999994
System.out.println(bill);
List<String> G7 = Arrays.asList("USA", "Japan", "France", "Germany", "Italy", "UK", "Canada");
String total = G7.stream().map(String::toUpperCase).collect(Collectors.joining(", "));
// String total = G7.stream().map(String::toUpperCase).reduce("", (s, s2) -> s + ", " + s2);
// , USA, JAPAN, FRANCE, GERMANY, ITALY, UK, CANADA(reduce 中的参数为初始值)
System.out.println(total);
// USA, JAPAN, FRANCE, GERMANY, ITALY, UK, CANADA
/* Collectors.joining(", ")
Collectors.toList()
Collectors.toSet() ,生成 set 集合
Collectors.toMap(MemberModel::getUid, Function.identity())
Collectors.toMap(ImageModel::getAid, o -> IMAGE_ADDRESS_PREFIX + o.getUrl())*/
}
对于 reduce 的返回值是 Optional,是防止 stream 中的内容为空。
Optional<Integer> opt = stream.reduce((acc, n) -> acc + n);
if (opt.isPresent()) {
System.out.println(opt.get());
}
peek
peek 作为调试用的方法,类似于在 stream 的过程中打断点。
对于 peek,可以只使用一个空的方法体,但是有些 IDE 可能不允许为空,可以通过输出解决问题。
List<Person> lists = new ArrayList<Person>();
lists.add(new Person(1L, "p1"));
lists.add(new Person(2L, "p2"));
lists.add(new Person(3L, "p3"));
lists.add(new Person(4L, "p4"));
System.out.println(lists);
List<Person> list2 = lists.stream()
.filter(f -> f.getName().startsWith("p"))
.peek(t -> {
System.out.println(t.getName());
})
.collect(Collectors.toList());
System.out.println(list2);
其他
distinct:去除重复数值;
count:统计元素个数;
flatMap:将多个 Stream 进行连接;
List<Integer> result= Stream.of(Arrays.asList(1,3),Arrays.asList(5,6)) .flatMap(a->a.stream()).collect(Collectors.toList());
max/min:可以通过重写比较器实现复杂逻辑的计算;
Person a = lists.stream().min(new Comparator<Person>() { @Override public int compare(Person o1, Person o2) { if (o1.getId() > o2.getId()) return -1; if (o1.getId() < o2.getId()) return 1; return 0; } }).get(); //获取数字的个数、最小值、最大值、总和以及平均值 List<Integer> primes = Arrays.asList(2, 3, 5, 7, 11, 13, 17, 19, 23, 29); IntSummaryStatistics stats = primes.stream().mapToInt((x) -> x).summaryStatistics(); System.out.println("Highest prime number in List : " + stats.getMax()); System.out.println("Lowest prime number in List : " + stats.getMin()); System.out.println("Sum of all prime numbers : " + stats.getSum()); System.out.println("Average of all prime numbers : " + stats.getAverage());
Match:类似 filter,但返回布尔值类型;
boolean anyStartsWithA = stringCollection .stream() .anyMatch((s) -> s.startsWith("a")); System.out.println(anyStartsWithA); // true boolean allStartsWithA = stringCollection .stream() .allMatch((s) -> s.startsWith("a")); System.out.println(allStartsWithA); // false boolean noneStartsWithZ = stringCollection .stream() .noneMatch((s) -> s.startsWith("z")); System.out.println(noneStartsWithZ); // true
4.1.3 默认方法
在 JDK 1.8 中 接口可以有实现方法,而且不需要实现类去实现其方法。只需在方法名前面加个 default 关键字即可。
interface HumanActivity {
default void run() {
System.out.println("He can run!");
}
}
至于为何添加默认方法,主要是为了在添加新的接口时,不用实现接口中定义的所有方法。JDK 1.8 之前的集合框架没有 forEach 方法,通常能想到的解决办法是在JDK里给相关的接口添加新的方法及实现。然而,对于已经发布的版本,是没法在给接口添加新方法的同时不影响已有的实现。所以引进的默认方法。他们的目的是为了解决接口的修改与现有的实现不兼容的问题。
4.1.4 Optional
开发中,我们需要预防 NPE(java.lang.NullPointerException
)问题。
(1) 返回类型为基本数据类型,return 包装数据类型的对象时,自动拆箱有可能产生 NPE;
(2) 数据库的查询结果可能为 null;
(3) 集合里的元素即使 isNotEmpty,取出的数据元素也可能为 null;
(4) 远程调用返回对象时,一律要求进行空指针判断,防止 NPE;
(5) 对于 Session 中获取的数据,建议进行 NPE 检查,避免空指针;
(6) 级联调用 obj.getA().getB().getC();一连串调用,易产生 NPE。
Optional<String> a = Optional.of("123");// 正常 Optional 不允许值为空
// 使用 ofNullable 表示可以传入 null
// ifPresent 用于判断值是否存在
// 在最后使用 get() 获得实例的值
Zoo zoo = getZoo();
if(zoo != null){
Dog dog = zoo.getDog();
if(dog != null){
int age = dog.getAge();
System.out.println(age);
}
}
// 对于冗长的连续空值判读,我们可以简化成以下代码块
// 这里使用方法引用,也可以使用 lambda 表达式
// 使用 map 内容对应 function
Optional.ofNullable(zoo).map(Zoo::getDog).map(Dog::getAge).ifPresent(System.out::println);
// ifPresent 用于判断值存在之后的操作
Optional 常用方法:
Optional.ofNullable(zoo).map(o -> o.getDog()).map(d -> d.getAge()).filter(v->v==1).orElse(3);
// orElse() 不管当前 value 是否为空都会执行
// orElseGet() 懒加载形式,只有为 null 的情况才会调用 Get(推荐使用)
// System.out.println(empty.orElseGet(() -> "Default Value"));
// orElseThrow() 例子:empty.orElseThrow(ValueAbsentException::new);
// 如果为空抛出对应异常
// filter 与 stream 用法类似
Optional.ofNullable(zoo).map(Zoo::getDog).map(Dog::getAge).filter(age -> age > 10).ifPresent(System.out::println);
// map 也提供类型转换,map 方法中的 lambda 表达式返回值可以是任意类型,在 map 函数返回之前会包装为 Optional
// flatMap 方法中的 lambda 表达式返回值必须是 Optionl 实例
// map 方法用于将 Optional 对象的值转换为另一种类型,而 flatMap 方法用于将 Optional 对象的值转换为另一个 Optional 对象
Optional<Dog> dogOptional = Optional.of(dog);
Optional<String> o = dogOptional.map(Dog::getName);
Optional<String> u = dogOptional.flatMap(d -> Optional.of(d.getName()));
4.2 JDK 11
JDK 9 已经允许在接口中使用私有方法。
JDK 10 之后引入了局部变量推断 var
,JDK 11 之后允许开发者 Lambda 表达式中使用 var 进行参数声明。看似在 Lambda 中使用参数类型没有作用,因为 Lambda 使用隐式类型定义。添加上类型定义同时使用 @Nonnull 和 @Nullable 等类型注释还是很有用的,既能保持与局部变量的一致写法,也不丢失代码简洁。
@Nonnull var x = new Foo();
(@Nonnull var x, @Nullable var y) -> x.process(y)
4.2.1 新工具和库更新
在集合上,Java 9 增加 了 List.of()
、Set.of()
、Map.of()
和 Map.ofEntries()
等工厂方法来创建不可变集合 (这点类似 kotlin 创建数组的写法)。
List.of();
List.of("Hello", "World");
List.of(1, 2, 3);
Set.of();
Set.of("Hello", "World");
Set.of(1, 2, 3);
Map.of();
Map.of("Hello", 1, "World", 2);
Stream 中增加了新的方法 ofNullable、dropWhile、takeWhile 和 iterate。
var count = Stream.of(1, 2, 3, 4, 5)
.dropWhile(i -> i % 2 != 0)
.toArray();
for (var i : count) {
System.out.println("i = " + i);
}
/*
i = 2
i = 3
i = 4
i = 5
*/
Collectors 中增加了新的方法 filtering 和 flatMapping。Optional 类中新增了 ifPresentOrElse、or 和 stream 等方法(stream 可以返回对应的 stream 流)。
var count1 = Stream.of(
Optional.of(1),
Optional.empty(),
Optional.of(2)
).flatMap(Optional::stream).toArray();
for (var i : count1) {
System.out.println("i = " + i);
}
4.2.2 进程 API
Java 9 增加了 ProcessHandle 接口,可以对原生进程进行管理,尤其适合于管理长时间运行的进程。在使用 ProcessBuilder 来启动一个进程之后,可以通过 Process.toHandle() 方法来得到一个 ProcessHandle 对象的实例。通过 ProcessHandle 可以获取到由 ProcessHandle.Info 表示的进程的基本信息,如命令行参数、可执行文件路径和启动时间等。ProcessHandle 的 onExit()方法返回一个 CompletableFuture对象,可以在进程结束时执行自定义的动作。
final ProcessBuilder processBuilder = new ProcessBuilder("top")
.inheritIO();
final ProcessHandle processHandle = processBuilder.start().toHandle();
processHandle.onExit().whenCompleteAsync((handle, throwable) -> {
if (throwable == null) {
System.out.println(handle.pid());
} else {
throwable.printStackTrace();
}
});
4.2.3 简化启动
JDK 9 中新增 jshell,可以用于执行 Java 代码并立即获得执行结果。支持定义变量、方法、类等,支持输入语句、表达式,支持导入外部 Java 源文件(类似 Python 直接执行输入代码)。
Java 11 版本中增强了 Java 启动器,使之能够运行单一文件的 Java 源代码。此功能允许使用 Java 解释器直接执行 Java 源代码。源代码在内存中编译,然后由解释器执行。唯一的约束在于所有相关的类必须定义在同一个 Java 文件中(可以直接通过 java *.java
文件完成编译)。
4.2.4 安全性提升
Java 9 新增了 4 个 SHA-3
哈希算法,SHA3-224、SHA3-256、SHA3-384 和 SHA3-512。另外也增加了通过 java.security.SecureRandom
生成使用 DRBG 算法的强随机数。
根证书认证 & TLS
自 Java 9 起在 keytool 中加入参数 -cacerts ,可以查看当前 JDK 管理的根证书。而 Java 9 中 cacerts 目录为空,从 Java 10 开始,将会在 JDK 中提供一套默认的 CA 根证书。
作为 JDK 一部分的 cacerts 密钥库旨在包含一组能够用于在各种安全协议的证书链中建立信任的根证书。但是,JDK 源代码中的 cacerts 密钥库至目前为止一直是空的。因此,在 JDK 构建中,默认情况下,关键安全组件(如 TLS)是不起作用的。要解决此问题,用户必须使用一组根证书配置和 cacerts 密钥库下的 CA 根证书。
Java 11 中包含了传输层安全性(TLS)1.3 规范(RFC 8446)的实现,替换了之前版本中包含的 TLS,包括 TLS 1.2,同时还改进了其他 TLS 功能,例如 OCSP 装订扩展(RFC 6066,RFC 6961),以及会话散列和扩展主密钥扩展(RFC 7627),在安全性和性能方面也做了很多提升(之前版本中使用的 KRB5 密码套件实现已从 Java 11 中删除,因为该算法已不再安全。同时注意,TLS 1.3 与以前的版本不直接兼容)。
HTTP Client 升级
Java 11 对 Java 9 中引入并在 Java 10 中进行了更新的 Http Client API 进行了标准化,在前两个版本中进行孵化的同时,Http Client 几乎被完全重写,并且现在完全支持异步非阻塞。
新版 Java 中,Http Client 的包名由 jdk.incubator.http 改为 java.net.http,该 API 通过 CompleteableFutures 提供非阻塞请求和响应语义,可以联合使用以触发相应的动作,并且 RX Flo w 的概念也在 Java 11 中得到了实现。现在,在用户层请求发布者和响应发布者与底层套接字之间追踪数据流更容易了。这降低了复杂性,并最大程度上提高了 HTTP/1 和 HTTP/2 之间的重用的可能性。
4.3 JDK 17
在 SpringBoot 升级到 3 之后,JDK 最低版本要求为 17。JDK17 更快,高吞吐量垃圾回收器比低延迟垃圾回收器更快。
在 JDK 14 中,switch 正式可以使用 -> 进行简化(类似 kotlin 的 when)。JDK 17 推出 switch 的模式匹配,解决 instanceof 的连续判断问题,但是目前还是 preview 阶段,不建议在正式环境中使用。
在 switch(expr1) 中,expr1 只支持一个证书变量或者枚举常量,整数表达式可以是 int 或 Integer。由于,byte、short、char 都可以隐含转换为 int,这些类型以及这些类型的包装类型也是可以的。switch 不支持 long 类型;从 Java 1.7 开始 switch 开始支持 String,这是 Java 的语法糖。
public class SwitchDemo {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int id = in.nextInt();
String res = switch (id) {
case 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 -> "<10";
case 11 -> "it's 10";
default -> "default value";
};
System.out.println("res = " + res);
}
}
JDK 15 确定使用文本块(类似 kotlin 中的多行字符串)。通过使用这样的文本块,在书写 json/SQL 等语句会更加清晰易懂。
String demoString = """
select id \
from account \
where id=#{id};\
""";
// demoString = select id from account where id=#{id};
var languages = """
[
{
"name": "kotlin",
"type": "static"
},
{
"name": "julia",
"type": "dynamic"
}
]
""";
JDK 16 中正式改进 instanceof,可以在判断中直接进行类型转换(类似 C# 中的 is),不再需要每次都创建一个局部变量并进行强制转换。这个变量的范围可以延伸到之后的 && 判断中。
if (person instanceof Student student) {
student.say();
// other student operations
} else if (person instanceof Teacher teacher) {
teacher.say();
// other teacher operations
}
/*
if (person instanceof Student) {
Student student = (Student) person;
student.say();
} else if (person instanceof Teacher) {
Teacher teacher = (Teacher) person;
teacher.say();
}
*/
JDK 16 正式引入 Record。Record 这一特性主要用在特定领域的类上;与枚举类型一样,Record 类型是一种受限形式的类型,主要用于存储、保存数据,并且没有其它额外自定义行为的场景下。其效果有些类似 Lombok 的 @Data 注解、Kotlin 中的 data class。Record 描述的这个“类”只用来存储数据。
public record Person(String name, int age) {
}
// 会自动重写 tostring()、equals() 等等方法
public record Person(String name, int age) {
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String name() {
return this.name;
}
public int age() {
return this.age;
}
}
JDK 17 正式引入 sealed 关键字,解释为密封类。封闭类可以是封闭类和或者封闭接口,用来增强 Java 编程语言,防止其他类或接口扩展或实现它们。这个特性由 Java 15 的预览版本晋升为正式版本。
这样在写 API 或者其他功能时可以直接限制类的层次。
// 添加sealed修饰符,permits后面跟上只能被继承的子类名称
public sealed class Person permits Teacher, Worker, Student{ } //人
// 子类可以被修饰为 final
final class Teacher extends Person { }//教师
// 子类可以被修饰为 non-sealed,此时 Worker类就成了普通类,谁都可以继承它
non-sealed class Worker extends Person { } //工人
// 任何类都可以继承Worker
class AnyClass extends Worker{}
//子类可以被修饰为 sealed,同上
sealed class Student extends Person permits MiddleSchoolStudent,GraduateStudent{ } //学生
final class MiddleSchoolStudent extends Student { }
final class GraduateStudent extends Student { }
参考文章
- Java 学习+面试整理
- 轻松看懂 Java 字节码
- UTF-8 到底是什么意思
- 后端八股文整合
- 为什么这么多人不喜欢用 goto?
- abstract 与 static、synchronized、native 不能同时使用的原因
- Is Java “pass-by-reference” or “pass-by-value”?
- Java 16 可以在非静态内部类中定义静态方法
- Why don’t Java’s +=, -=, *=, /= compound assignment operators require casting?
- Java 大数源码剖析(一) - BigInteger 的底层数据结构
- 红黑树和平衡二叉树有什么区别?
- Chapter 15. Expressions (oracle.com)
- 一致性 Hash 算法详解
- 面试:说说啥是一致性哈希算法?
- JDK 1.7 LinkedList 循环链表优化
- Java 中的复制构造函数和工厂方法
- Java 深拷贝和浅拷贝
- 面试官:说说你对 Java 异常的理解
- Java 三种代理模式:静态代理、动态代理和 cglib 代理
- Java 序列化与反序列化
- JDK 动态代理和 CGLib 动态代理的对比
- Why String is immutable in Java?
- ArrayList 的扩容机制
- HashMap 的实现原理
- 为什么 HashMap 的加载因子是0.75?
- Java 中的 String 为什么要设计成不可变的?
- 为什么 HashTable 被弃用了
- HashTable 和 Vector 为什么逐渐被废弃
- Java CopyOnWriteArrayList 详解
- Java HashMap的死循环
- HashMap? ConcurrentHashMap?
- Java 中的 transient 关键字详解
- 详解面试中常考的 Java 反射机制
- Java 基础八股文背诵版
- 10 道 Java 泛型面试题
- ConcurrentHashMap,分段锁,CAS
- Java 动态代理
- Java 锁与线程的那些事
- Java 并发编程:Synchronized 底层优化(偏向锁、轻量级锁)
- Java Supplier
- Java 9 新工具 jshell 使用指南
- Java 17 新特性:switch 的模式匹配(Preview)
- 追随 Kotlin/Scala,看 Java 12-15 的现代语言特性
- 记录类 - 廖雪峰的官方网站