Java面试-基础篇(上)

图片[1]-Java面试-基础篇(上)-速优课

1、接口和抽象类的区别 

在 Java 中,接口(interface)和抽象类(abstract class)是两种用于实现代码复用和多态性的机制。它们之间存在显著的区别,同时也有一些共同点。以下是对接口和抽象类的详细分析,并附有示例代码以帮助理解。

1.1 定义关键字不同

  • 接口:使用 interface 关键字定义。
  • 抽象类:使用 abstract 关键字定义。
// 定义接口
public interface Animal {
    void eat();
    void sleep();
}

// 定义抽象类
public abstract class Shape {
    public abstract double getArea();
    public void print() {
        System.out.println("This is a shape");
    }
}

1.2 继承或实现的关键字不同

  • 接口:使用 implements 关键字来实现接口。
  • 抽象类:使用 extends 关键字来继承抽象类。
// 实现接口
public class Cat implements Animal {
    @Override
    public void eat() {
        System.out.println("Cat is eating");
    }

    @Override
    public void sleep() {
        System.out.println("Cat is sleeping");
    }
}

// 继承抽象类
public class Circle extends Shape {
    private double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    @Override
    public double getArea() {
        return Math.PI * radius * radius;
    }
}

1.3 子类扩展的数量不同

  • 接口:一个类可以实现多个接口,支持多继承。
  • 抽象类:一个类只能继承一个抽象类,遵循单继承原则。
// 实现多个接口
public class Bird implements Flyable, Swimmable {
    @Override
    public void fly() {
        System.out.println("Bird is flying");
    }

    @Override
    public void swim() {
        System.out.println("Bird is swimming");
    }
}

// 不能继承多个抽象类
// 下面的代码会报错
// public class Car extends Vehicle, Machine { ... }

1.4 属性访问控制符不同

  • 接口:属性的访问控制符只能是 public,并且默认是 public static final 修饰的。
  • 抽象类:属性的访问控制符无限制,可以为任意控制符,如 private、protected 或 public。
// 接口中定义属性
public interface Constants {
    int MAX_VALUE = 100; // 默认是 public static final
}

// 抽象类中定义属性
public abstract class User {
    private String name;
    protected int age;
    public String address;

    // 构造函数
    public User(String name, int age, String address) {
        this.name = name;
        this.age = age;
        this.address = address;
    }

    // Getter 和 Setter 方法
    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public String getAddress() {
        return address;
    }

    public void setAddress(String address) {
        this.address = address;
    }
}

1.5 方法控制符不同

  • 接口:方法的默认控制符是 public,并且不能定义为其他控制符。
  • 抽象类:方法控制符无限制,其中抽象方法不能使用 private 修饰。
// 接口中定义方法
public interface Animal {
    void eat(); // 默认是 public abstract
}

// 抽象类中定义方法
public abstract class Shape {
    public abstract double getArea(); // 抽象方法
    public void print() {
        System.out.println("This is a shape");
    }
}

1.6 方法实现不同

  • 接口:普通方法不能有具体的方法实现,在 JDK 8 之后,static 和 default 方法必须有方法实现。
  • 抽象类:普通方法可以有具体的方法实现,抽象方法不能有方法实现。
// 接口中定义静态方法和默认方法
public interface Animal {
    void eat(); // 抽象方法

    default void makeSound() {
        System.out.println("Animal makes sound");
    }

    static void commonBehavior() {
        System.out.println("Common behavior for all animals");
    }
}

// 抽象类中定义具体方法和抽象方法
public abstract class Shape {
    public abstract double getArea(); // 抽象方法

    public void print() {
        System.out.println("This is a shape");
    }
}

1.7 静态代码块使用不同

  • 接口:不能使用静态代码块。
  • 抽象类:可以使用静态代码块。
// 抽象类中使用静态代码块
public abstract class Shape {
    static {
        System.out.println("Static block in abstract class");
    }

    public abstract double getArea();
    public void print() {
        System.out.println("This is a shape");
    }
}

// 接口中不能使用静态代码块
// 下面的代码会报错
// public interface Animal {
//     static {
//         System.out.println("Static block in interface");
//     }
// }

相同点

  • 都不能实例化:接口和抽象类都不能直接实例化,必须通过子类或实现类来创建对象。
  • 都位于继承的顶端:接口和抽象类通常位于继承层次结构的顶端,用于被其他类实现或继承。
  • 都包含抽象方法:接口和抽象类都可以包含抽象方法,其子类或实现类必须覆写这些抽象方法。

如何选择?

  • 优先选用接口:行为模型应该总是通过接口而不是抽象类定义,因为接口提供了更大的灵活性和代码的松耦合性。接口允许一个类实现多个接口,从而实现多重继承的效果。
  • 选择抽象类的情况:当需要定义子类的行为,同时又要为子类提供通用的功能时,可以选择使用抽象类。抽象类可以包含具体的方法实现,而接口则不能(除了 static 和 default 方法)。此外,如果类之间存在“是什么”的关系,那么使用抽象类更为合适;如果只是一种“能做什么”的关系,则使用接口更为合适。

示例

假设我们有一个动物管理系统,其中不同的动物有不同的行为。我们可以使用接口来定义动物的行为,使用抽象类来定义动物的共同特征。

// 定义接口
public interface Flyable {
    void fly();
}

public interface Swimmable {
    void swim();
}

// 定义抽象类
public abstract class Animal {
    private String name;
    private int age;

    public Animal(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public abstract void eat();
    public abstract void sleep();
}

// 实现接口并继承抽象类
public class Bird extends Animal implements Flyable, Swimmable {
    public Bird(String name, int age) {
        super(name, age);
    }

    @Override
    public void eat() {
        System.out.println("Bird is eating");
    }

    @Override
    public void sleep() {
        System.out.println("Bird is sleeping");
    }

    @Override
    public void fly() {
        System.out.println("Bird is flying");
    }

    @Override
    public void swim() {
        System.out.println("Bird is swimming");
    }
}

// 测试
public class Main {
    public static void main(String[] args) {
        Bird bird = new Bird("Penguin", 5);
        bird.eat();
        bird.sleep();
        bird.fly();
        bird.swim();
    }
}

在这个例子中,Animal 是一个抽象类,定义了所有动物的共同特征和行为。Flyable 和 Swimmable 是两个接口,分别定义了飞行和游泳的行为。Bird 类继承了 Animal 类并实现了 Flyable 和 Swimmable 接口,从而具备了具体的飞行和游泳能力。这种设计方式既保持了代码的灵活性,又确保了类之间的关系清晰明确。

通过上述分析和示例,我们可以看到接口和抽象类各有优劣,选择时应根据具体的需求和场景进行权衡。接口更适合用于定义行为规范,而抽象类则更适合用于定义类的共同特征和行为。在实际开发中,合理地结合使用接口和抽象类,可以构建出更加灵活、可维护的代码结构。

2、final 有什么用? 

在 Java 中,final 关键字是一个非常重要的修饰符,它可以用于修饰类、方法和变量。final 的核心含义是“最终的”或“不可改变的”,具体作用如下:

  1. 被 final 修饰的类不可以被继承
  2. 被 final 修饰的方法不可以被重写
  3. 被 final 修饰的变量引用不可以被改变,但内容可以改变

接下来,我们将对每个方面进行详细的分析,并提供相应的示例代码。

2.1 被 final 修饰的类不可以被继承

当一个类被声明为 final 时,它就不能被其他类继承。这意味着该类是一个最终类(final class),不能再有子类。通常,final 类用于那些不希望被扩展的类,或者出于安全考虑,防止类的行为被意外修改。例如,Java 标准库中的 String 类就是一个 final 类,确保其不可变性。

示例:

// 定义一个 final 类
public final class FinalClass {
    public void display() {
        System.out.println("This is a final class.");
    }
}

// 尝试继承 final 类(编译错误)
// public class SubClass extends FinalClass { } // 编译器会报错

在这个例子中,FinalClass 是一个 final 类,因此不能被任何其他类继承。如果尝试继承 FinalClass,编译器将抛出错误,提示不能继承 final 类。

2.2 被 final 修饰的方法不可以被重写

当一个方法被声明为 final 时,它就不能被子类重写。这有助于确保方法的行为在继承层次结构中保持一致,防止子类无意或有意地改变父类的方法实现。final 方法通常用于那些已经实现了最优解或关键逻辑的方法,不允许子类对其进行修改。此外,final 方法还可以提高性能,因为 JVM 可以对其进行内联优化。

示例:

// 定义一个包含 final 方法的类
public class ParentClass {
    public final void finalMethod() {
        System.out.println("This is a final method.");
    }

    public void nonFinalMethod() {
        System.out.println("This is a non-final method.");
    }
}

// 继承并尝试重写 final 方法(编译错误)
public class ChildClass extends ParentClass {
    @Override
    public void nonFinalMethod() {
        System.out.println("Overriding non-final method.");
    }

    // 下面的代码会导致编译错误
    // @Override
    // public void finalMethod() {
    //     System.out.println("Attempting to override final method."); // 编译器会报错
    // }
}

// 测试
public class Test {
    public static void main(String[] args) {
        ParentClass parent = new ParentClass();
        ChildClass child = new ChildClass();

        parent.finalMethod(); // 输出: This is a final method.
        parent.nonFinalMethod(); // 输出: This is a non-final method.

        child.nonFinalMethod(); // 输出: Overriding non-final method.
        child.finalMethod(); // 输出: This is a final method.
    }
}

在这个例子中,ParentClass 中的 finalMethod() 是一个 final 方法,因此不能在 ChildClass 中被重写。如果尝试重写 finalMethod(),编译器将抛出错误,提示不能重写 final 方法。而 nonFinalMethod() 则可以正常重写。

2.3 被 final 修饰的变量引用不可以被改变,但内容可以改变

final 变量的含义取决于它所修饰的数据类型:

  • 基本数据类型:final 基本数据类型的值一旦被赋值后,就不能再被改变。这意味着 final 基本数据类型实际上是一个常量。
  • 引用数据类型:final 引用类型的变量一旦被初始化后,就不能再指向其他对象,但该对象的内容是可以改变的。换句话说,final 引用类型的变量只能绑定一次,但对象的状态可以发生变化。

示例:

// 定义 final 基本数据类型变量
public class FinalVariables {
    private final int number = 10;

    // 尝试修改 final 基本数据类型变量(编译错误)
    // number = 20; // 编译器会报错

    // 定义 final 引用类型变量
    private final List<String> list = new ArrayList<>();

    public void modifyList() {
        // 可以修改 list 的内容
        list.add("Java");
        list.add("Python");
        list.add("C++");

        // 但不能重新赋值给 list(编译错误)
        // list = new ArrayList<>(); // 编译器会报错
    }

    public void printList() {
        System.out.println("List content: " + list);
    }
}

// 测试
public class Test {
    public static void main(String[] args) {
        FinalVariables fv = new FinalVariables();
        fv.modifyList();
        fv.printList(); // 输出: List content: [Java, Python, C++]
    }
}

在这个例子中,number 是一个 final 基本数据类型变量,因此它的值一旦被赋值后就不能再改变。而 list 是一个 final 引用类型变量,虽然它不能重新指向其他 List 对象,但我们可以修改 list 中的内容。通过调用 modifyList() 方法,我们可以在 list 中添加元素,而不会违反 final 的约束。

2.4 final 的其他用途

除了上述三种主要用途外,final 还有一些其他的应用场景:

  • 匿名内部类:在使用匿名内部类时,传递给内部类的局部变量必须是 final 或者是“有效地 final”。这是因为匿名内部类可能会在外部方法结束后仍然存在,因此需要确保这些变量的值不会发生改变。
  • 线程安全性:final 变量在多线程环境中具有一定的线程安全性。由于 final 变量只能被赋值一次,JVM 保证了在构造函数中初始化的 final 变量在其他线程中可见,从而避免了竞态条件。
  • 性能优化:虽然现代 JVM 已经不再依赖 final 方法进行内联优化,但在某些情况下,final 方法仍然可以帮助 JVM 更好地优化代码执行。

总结

final 关键字在 Java 中提供了多种功能,主要用于确保代码的稳定性和安全性。通过使用 final,开发者可以:

  • 防止类被继承,确保类的行为不会被意外修改。
  • 防止方法被重写,确保方法的实现不会被子类改变。
  • 确保变量的值或引用不会被改变,从而提高代码的可读性和可维护性。

合理使用 final 可以帮助编写更加健壮、高效的代码,尤其是在设计框架或库时,final 可以有效防止误用或滥用。然而,过度使用 final 也可能限制代码的灵活性,因此在实际开发中应根据具体需求权衡使用。

示例总结

通过上述示例,我们可以看到 final 关键字在不同场景下的应用:

  • final:防止类被继承,确保类的行为不变。
  • final 方法:防止方法被重写,确保方法的实现不变。
  • final 变量:确保变量的值或引用不变,但允许对象的内容变化。

3、static

在 Java 中,static 关键字用于修饰类的成员(变量、方法、代码块和内部类),表示这些成员属于类本身,而不是类的任何特定实例。这意味着 static 成员在类加载时被初始化,并且在整个应用程序的生命周期中只存在一份副本,所有类的实例共享这份副本。下面我们将详细分析 static 的特性,并通过示例进行说明。

3.1 static 成员不属于任何一个实例对象,而是被类的实例对象所共享

static 成员(包括静态变量和静态方法)属于类本身,而不是类的某个具体实例。因此,无论创建了多少个类的实例,静态成员始终只有一份副本,所有实例共享这个副本。静态成员可以通过类名直接访问,而不需要创建类的实例。

示例:

public class Student {
    // 静态变量,属于类本身,所有实例共享
    public static int studentCount = 0;

    // 实例变量,每个实例都有独立的副本
    private String name;

    // 构造函数
    public Student(String name) {
        this.name = name;
        studentCount++;  // 每创建一个学生实例,计数器加1
    }

    // 静态方法,可以直接通过类名调用
    public static void printStudentCount() {
        System.out.println("Total students: " + studentCount);
    }

    // 实例方法,需要通过实例调用
    public void printName() {
        System.out.println("Student name: " + name);
    }
}

// 测试
public class Test {
    public static void main(String[] args) {
        // 通过类名直接调用静态方法
        Student.printStudentCount();  // 输出: Total students: 0

        // 创建两个学生实例
        Student s1 = new Student("Alice");
        Student s2 = new Student("Bob");

        // 再次调用静态方法
        Student.printStudentCount();  // 输出: Total students: 2

        // 调用实例方法
        s1.printName();  // 输出: Student name: Alice
        s2.printName();  // 输出: Student name: Bob
    }
}

在这个例子中,studentCount 是一个静态变量,它属于 Student 类本身,而不是某个具体的 Student 实例。每当创建一个新的 Student 实例时,studentCount 会递增,所有实例共享这个计数器。我们可以通过类名 Student 直接调用静态方法 printStudentCount(),而不需要创建类的实例。

3.2 static 成员只在类第一次使用时加载并进行初始化

static 成员(包括静态变量和静态代码块)在类加载时被初始化,并且只初始化一次。类加载是指 JVM 将类的字节码加载到内存中的过程,通常发生在第一次使用该类时。一旦类被加载,静态成员就会被初始化,并且在整个应用程序的生命周期中保持不变,除非显式地重新赋值。

示例:

public class MyClass {
    // 静态变量,在类加载时初始化
    public static int count = 0;

    // 静态代码块,在类加载时执行
    static {
        System.out.println("Static block executed, count initialized to: " + count);
        count = 10;
        System.out.println("After initialization, count is now: " + count);
    }

    // 实例变量
    private String name;

    // 构造函数
    public MyClass(String name) {
        this.name = name;
        count++;
        System.out.println("Instance created, count is now: " + count);
    }

    // 静态方法
    public static void printCount() {
        System.out.println("Current count: " + count);
    }
}

// 测试
public class Test {
    public static void main(String[] args) {
        // 第一次使用 MyClass 类,触发类加载
        MyClass.printCount();  // 输出:
                               // Static block executed, count initialized to: 0
                               // After initialization, count is now: 10
                               // Current count: 10

        // 创建两个实例
        MyClass obj1 = new MyClass("Object 1");  // 输出: Instance created, count is now: 11
        MyClass obj2 = new MyClass("Object 2");  // 输出: Instance created, count is now: 12

        // 再次调用静态方法
        MyClass.printCount();  // 输出: Current count: 12
    }
}

在这个例子中,静态代码块在类加载时被执行,初始化了静态变量 count。静态代码块只会执行一次,即使后续创建了多个类的实例,静态变量 count 也不会再次初始化。每次创建新的实例时,count 会递增,但静态代码块不会再执行。

3.3 static 变量值在类加载的时候分配空间,以后创建类对象的时候不会重新分配,可以任意赋值

static 变量在类加载时分配内存空间,并且在整个应用程序的生命周期中只分配一次。这意味着即使创建了多个类的实例,静态变量也只有一份副本,所有实例共享这份副本。静态变量可以在类加载后进行任意赋值,但它的内存空间不会因为创建新的实例而重新分配。

示例:

public class Counter {
    // 静态变量,类加载时分配空间
    public static int count = 0;

    // 实例方法
    public void increment() {
        count++;
        System.out.println("Count incremented to: " + count);
    }

    // 静态方法
    public static void reset() {
        count = 0;
        System.out.println("Count reset to: " + count);
    }
}

// 测试
public class Test {
    public static void main(String[] args) {
        // 通过类名直接访问静态变量
        System.out.println("Initial count: " + Counter.count);  // 输出: Initial count: 0

        // 创建两个实例
        Counter c1 = new Counter();
        Counter c2 = new Counter();

        // 调用实例方法,修改静态变量
        c1.increment();  // 输出: Count incremented to: 1
        c2.increment();  // 输出: Count incremented to: 2

        // 通过类名直接调用静态方法,重置静态变量
        Counter.reset();  // 输出: Count reset to: 0

        // 再次调用实例方法
        c1.increment();  // 输出: Count incremented to: 1
        c2.increment();  // 输出: Count incremented to: 2
    }
}

在这个例子中,count 是一个静态变量,它在类加载时分配内存空间,并且所有实例共享这份副本。即使创建了多个 Counter 实例,count 也只有一份副本。我们可以通过实例方法 increment() 修改静态变量的值,也可以通过类名直接调用静态方法 reset() 来重置静态变量的值。

3.4 static 方法只能访问静态成员,不能访问非静态成员

static 方法是类级别的方法,它们不依赖于任何具体的实例。因此,static 方法只能访问静态变量和静态方法,不能直接访问非静态成员(实例变量和实例方法)。如果需要访问非静态成员,必须通过类的实例来访问。

示例:

public class Example {
    // 静态变量
    public static int staticVar = 10;

    // 实例变量
    private int instanceVar = 20;

    // 静态方法
    public static void staticMethod() {
        // 可以访问静态变量
        System.out.println("Static variable: " + staticVar);

        // 不能直接访问实例变量(编译错误)
        // System.out.println("Instance variable: " + instanceVar); // 编译器会报错

        // 如果需要访问实例变量,必须通过实例来访问
        Example example = new Example();
        System.out.println("Instance variable via instance: " + example.instanceVar);
    }

    // 实例方法
    public void instanceMethod() {
        // 可以访问静态变量和实例变量
        System.out.println("Static variable: " + staticVar);
        System.out.println("Instance variable: " + instanceVar);
    }
}

// 测试
public class Test {
    public static void main(String[] args) {
        // 通过类名直接调用静态方法
        Example.staticMethod();  // 输出:
                                 // Static variable: 10
                                 // Instance variable via instance: 20

        // 创建实例并调用实例方法
        Example example = new Example();
        example.instanceMethod();  // 输出:
                                  // Static variable: 10
                                  // Instance variable: 20
    }
}

在这个例子中,staticMethod() 是一个静态方法,它可以访问静态变量 staticVar,但不能直接访问实例变量 instanceVar。如果需要访问实例变量,必须通过类的实例来访问。而 instanceMethod() 是一个实例方法,它可以访问静态变量和实例变量。

总结

static 关键字在 Java 中具有以下重要特性:

  • 静态成员属于类本身,而不是类的任何实例:所有实例共享同一份静态成员。
  • 静态成员在类加载时初始化:并且只初始化一次,类加载后可以任意赋值。
  • 静态变量在类加载时分配内存空间:并且在整个应用程序的生命周期中只分配一次。
  • 静态方法只能访问静态成员:不能直接访问非静态成员,必须通过实例来访问。

合理使用 static 可以帮助我们编写更加简洁、高效的代码,尤其是在处理类级别的数据或工具方法时。然而,过度使用 static 也可能导致代码的可测试性和灵活性降低,因此在实际开发中应根据具体需求权衡使用。

补充说明

  • 静态代码块:静态代码块用于在类加载时执行一些初始化操作,通常用于复杂的初始化逻辑。静态代码块只会在类第一次加载时执行一次,之后不会再执行。
  • 静态内部类:静态内部类是定义在另一个类中的类,但它不依赖于外部类的实例。静态内部类可以通过外部类的类名直接访问,而不需要创建外部类的实例。静态内部类不能访问外部类的非静态成员,但可以访问外部类的静态成员。
  • 静态变量的线程安全性:由于静态变量是共享的,多个线程可能会同时访问和修改同一个静态变量,因此在多线程环境中使用静态变量时需要注意线程安全问题。可以通过同步机制(如 synchronized 关键字)来确保线程安全。
  • 静态变量的初始化顺序:静态变量和静态代码块的初始化顺序遵循类的继承层次结构。父类的静态成员先于子类的静态成员被初始化。此外,静态成员的初始化顺序与其在类中声明的顺序一致。

4、String和StringBuffer、StringBuilder的区别是什么?

在 Java 中,String、StringBuffer 和 StringBuilder 是用于处理字符串的三个重要类。它们之间存在显著的区别,主要体现在可变性、线程安全性、性能和存储位置等方面。下面我们将详细分析这些区别,并通过示例代码进行说明。

4.1 可变性

  • String:String 是不可变类,这意味着一旦创建了一个 String 对象,它的内容就不能被修改。String 类内部使用 final char[] value(在 JDK 9 之后改为 byte[] value)来存储字符序列,因此每次对 String 进行修改操作时,都会创建一个新的 String 对象。例如:
String s1 = "Hello";
s1 += " World";  // 实际上是创建了一个新的 String 对象 "Hello World"

在上面的例子中,s1 += ” World” 实际上是创建了一个新的 String 对象 “Hello World”,而原来的 “Hello” 对象并没有被修改。这种不可变性确保了 String 的线程安全性,但同时也带来了性能开销,尤其是在频繁进行字符串拼接操作时。

  • StringBuffer 和 StringBuilder:这两个类是可变类,意味着它们的内容可以在不创建新对象的情况下进行修改。它们内部使用一个可变的字符数组来存储字符序列,因此可以高效地进行字符串拼接、插入等操作。例如:
StringBuffer sb = new StringBuffer("Hello");
sb.append(" World");  // 直接在原有对象上进行修改

在这个例子中,sb.append(” World”) 直接在原有的 StringBuffer 对象上进行了修改,而没有创建新的对象。这使得 StringBuffer 和 StringBuilder 在频繁修改字符串时具有更高的性能。

4.2 线程安全性

  • String:由于 String 是不可变类,因此它是线程安全的。多个线程可以同时访问同一个 String 对象,而不会导致数据不一致的问题。然而,这种线程安全性是以牺牲性能为代价的,因为每次修改 String 都会创建新的对象。
  • StringBuffer:StringBuffer 是线程安全的,它通过在每个方法上添加 synchronized 关键字来实现线程同步。这意味着在多线程环境下,多个线程可以安全地共享同一个 StringBuffer 对象,而不会出现并发问题。然而,这种线程同步机制也带来了额外的性能开销。
  • StringBuilder:StringBuilder 不是线程安全的,它没有实现任何同步机制。因此,在单线程环境下,StringBuilder 的性能要优于 StringBuffer。然而,在多线程环境下使用 StringBuilder 时,可能会出现线程安全问题,因此需要开发者自行处理线程同步。

4.3 性能

  • String:由于 String 是不可变的,因此在进行字符串拼接或修改时,每次操作都会创建新的对象并分配内存。这种频繁的对象创建和垃圾回收会导致性能下降,尤其是在大量字符串操作的场景下。因此,String 的性能通常是最差的。
  • StringBuffer:StringBuffer 的性能优于 String,因为它是一个可变类,可以直接在原有对象上进行修改,而不需要创建新的对象。然而,由于 StringBuffer 是线程安全的,它在每个方法上调用了 synchronized,这增加了额外的同步开销,因此其性能略低于 StringBuilder。
  • StringBuilder:StringBuilder 是 StringBuffer 的轻量级版本,它去掉了线程安全的同步机制,因此在单线程环境下具有更高的性能。由于没有同步锁的开销,StringBuilder 在大多数情况下是最佳选择,尤其是在频繁进行字符串操作的场景下。

4.4 存储位置

  • String:String 对象存储在字符串常量池中。当使用字面量创建 String 对象时,JVM 会首先检查字符串常量池中是否已经存在相同的字符串。如果存在,则直接返回该字符串的引用;如果不存在,则将新的字符串添加到常量池中。这种方式可以节省内存,但也意味着字符串常量池中的字符串是共享的。
String s1 = "Hello";
String s2 = "Hello";
System.out.println(s1 == s2);  // 输出 true,因为两个字符串引用的是同一个对象
  • StringBuffer 和 StringBuilder:这两个类的对象存储在堆内存中,而不是字符串常量池中。每次创建 StringBuffer 或 StringBuilder 对象时,都会在堆内存中分配新的空间。因此,它们不会受到字符串常量池的限制,但在内存管理上不如 String 那样高效。

4.5 使用场景

  • String:适用于不需要频繁修改的字符串,或者字符串长度较短的情况。由于 String 是不可变的,因此在多线程环境下是线程安全的,但在频繁修改字符串时性能较差。
  • StringBuffer:适用于多线程环境下需要频繁修改字符串的情况。虽然 StringBuffer 的性能略低于 StringBuilder,但它的线程安全性使其成为多线程编程中的首选。
  • StringBuilder:适用于单线程环境下需要频繁修改字符串的情况。由于 StringBuilder 不是线程安全的,因此在单线程环境下具有最高的性能,是大多数情况下的最佳选择。

示例代码

下面通过一个简单的性能测试示例来展示 String、StringBuffer 和 StringBuilder 的性能差异:

public class PerformanceTest {
    public static void main(String[] args) {
        int iterations = 100000;

        // 测试 String 拼接性能
        long startTime = System.currentTimeMillis();
        String result = "";
        for (int i = 0; i < iterations; i++) {
            result += "A";  // 每次拼接都会创建新的 String 对象
        }
        long endTime = System.currentTimeMillis();
        System.out.println("String 耗时: " + (endTime - startTime) + " ms");

        // 测试 StringBuffer 拼接性能
        startTime = System.currentTimeMillis();
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < iterations; i++) {
            sb.append("A");  // 直接在原有对象上进行修改
        }
        endTime = System.currentTimeMillis();
        System.out.println("StringBuffer 耗时: " + (endTime - startTime) + " ms");

        // 测试 StringBuilder 拼接性能
        startTime = System.currentTimeMillis();
        StringBuilder sbd = new StringBuilder();
        for (int i = 0; i < iterations; i++) {
            sbd.append("A");  // 直接在原有对象上进行修改
        }
        endTime = System.currentTimeMillis();
        System.out.println("StringBuilder 耗时: " + (endTime - startTime) + " ms");
    }
}

在这个示例中,我们分别测试了 String、StringBuffer 和 StringBuilder 在进行 100,000 次字符串拼接操作时的性能。根据多次测试的结果,通常 String 的耗时最长,StringBuffer 次之,StringBuilder 的性能最高。

总结

  • String 是不可变类,适合用于不需要频繁修改的字符串,或者字符串长度较短的情况。由于其不可变性,String 是线程安全的,但在频繁修改字符串时性能较差。
  • StringBuffer是线程安全的可变类,适合用于多线程环境下需要频繁修改字符串的情况。虽然 StringBuffer 的性能略低于 StringBuilder,但它的线程安全性使其成为多线程编程中的首选。
  • StringBuilder 是非线程安全的可变类,适合用于单线程环境下需要频繁修改字符串的情况。由于没有同步锁的开销,StringBuilder 在大多数情况下是最佳选择,尤其是在频繁进行字符串操作的场景下。

通过合理选择合适的类,开发者可以根据具体的应用场景优化字符串操作的性能和线程安全性。

5、HashSet如何保证元素不重复? 

HashSet 是 Java 集合框架中的一个实现类,它实现了 Set 接口,提供了基本操作如添加、删除和检查元素是否存在等功能。HashSet 的核心特性之一是它可以确保集合中的元素不会重复。这一特性是通过底层使用 HashMap 来实现的,具体来说,HashSet 中的每个元素都被视为 HashMap 的一个键(key),而值(value)则是一个固定的常量对象 PRESENT 。

5.1 HashSet 的去重机制

HashSet 保证元素不重复的关键在于它如何利用 HashMap 的 put 方法来插入元素。当调用 HashSet 的 add 方法时,实际上是在调用 HashMap 的 put 方法。put 方法的工作流程如下:

  • 计算哈希码:首先,HashSet 会调用元素的 hashCode() 方法来计算该元素的哈希码。这个哈希码用于确定元素在 HashMap 内部数组中的存储位置。
  • 查找位置:根据计算出的哈希码,HashSet 会找到 HashMap 内部数组中对应的位置。如果该位置为空,则直接将元素插入到该位置;如果该位置已有元素,则继续下一步。
  • 比较相等性:在同一个位置上可能存在多个元素(由于哈希冲突),此时 HashSet 会使用 equals() 方法来比较新元素与已存在的元素是否相等。如果找到一个与新元素相等的元素,则认为新元素已经存在,不会再次插入;否则,将新元素插入到该位置。
  • 处理哈希冲突:如果多个元素的哈希码相同,它们会被存储在同一个链表或红黑树中(取决于 HashMap 的实现)。在这种情况下,HashSet 仍然会使用 equals() 方法来确保不会插入重复的元素。

5.2 为什么需要重写 hashCode() 和 equals() 方法?

对于基本数据类型(如 int、long 等),HashSet 可以直接使用默认的 hashCode() 和 equals() 方法来判断元素是否重复。然而,当我们将自定义对象存储到 HashSet 中时,情况就变得复杂了。为了确保 HashSet 能够正确地识别和去重这些自定义对象,我们必须重写 hashCode() 和 equals() 方法。原因如下:

  • hashCode() 方法:hashCode() 方法返回一个整数,表示对象的哈希码。如果两个对象的 hashCode() 返回值不同,则它们一定不相等;如果返回值相同,则可能相等,但还需要进一步通过 equals() 方法进行确认。因此,为了确保 HashSet 能够正确地识别相同的对象,必须确保相等的对象具有相同的哈希码。
  • equals() 方法:equals() 方法用于判断两个对象是否相等。默认情况下,Object 类的 equals() 方法只会比较两个对象的引用是否相同,这对于自定义对象来说通常是不够的。为了确保 HashSet 能够正确地识别相同的对象,必须重写 equals() 方法,使其根据对象的实际内容进行比较。

5.3 示例:重写 hashCode() 和 equals() 方法

下面是一个示例,展示了如何在自定义类中重写 hashCode() 和 equals() 方法,以便 HashSet 能够正确地去重这些对象。

import java.util.HashSet;
import java.util.Objects;

class Person {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // 重写 hashCode() 方法
    @Override
    public int hashCode() {
        return Objects.hash(name, age);  // 使用 Objects.hash() 方法简化哈希码的计算
    }

    // 重写 equals() 方法
    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;  // 如果是同一个对象,直接返回 true
        if (obj == null || getClass() != obj.getClass()) return false;  // 如果对象为 null 或类型不同,返回 false
        Person person = (Person) obj;
        return age == person.age && Objects.equals(name, person.name);  // 比较 name 和 age 是否相等
    }

    @Override
    public String toString() {
        return "Person{name='" + name + "', age=" + age + "}";
    }
}

public class HashSetExample {
    public static void main(String[] args) {
        HashSet<Person> set = new HashSet<>();
        
        // 添加相同的 Person 对象
        set.add(new Person("Alice", 30));
        set.add(new Person("Alice", 30));  // 这个对象不会被添加,因为已经存在相同的对象
        
        // 添加不同的 Person 对象
        set.add(new Person("Bob", 25));
        set.add(new Person("Charlie", 35));

        // 打印 HashSet 中的所有元素
        for (Person person : set) {
            System.out.println(person);
        }
    }
}

5.4 示例输出

运行上述代码后,HashSet 中只会包含三个不同的 Person 对象,尽管我们尝试添加了两个相同的 Person 对象 “Alice”, 30。输出结果如下:

Person{name='Alice', age=30}
Person{name='Bob', age=25}
Person{name='Charlie', age=35}

5.5 为什么重写 hashCode() 和 equals() 是必要的?

如果不重写 hashCode() 和 equals() 方法,HashSet 将无法正确地识别相同的对象。例如,如果我们没有重写 hashCode() 和 equals() 方法,HashSet 会根据对象的内存地址来判断两个对象是否相等,而不是根据对象的实际内容。这意味着即使两个 Person 对象的内容完全相同,HashSet 也会认为它们是不同的对象,并允许它们同时存在于集合中。

5.6 总结

HashSet 通过底层使用 HashMap 来实现元素的去重功能。它利用 hashCode() 和 equals() 方法来确保每个元素都是唯一的。对于基本数据类型,HashSet 可以直接使用默认的 hashCode() 和 equals() 方法;但对于自定义对象,必须重写这两个方法,以确保 HashSet 能够正确地识别和去重这些对象。通过重写 hashCode() 和 equals() 方法,我们可以确保 HashSet 在处理自定义对象时能够保持元素的唯一性。

6、JDK 动态代理为什么只能代理有接口的类

JDK 动态代理之所以只能代理有接口的类,其根本原因在于 Java 语言本身的设计和 JDK 动态代理机制的具体实现方式。为了深入理解这一点,我们需要从多个角度进行探讨,包括 Java 的单继承特性、JDK 动态代理的工作原理以及实际代码示例。

6.1 Java 的单继承特性

Java 是一种支持单继承的语言,这意味着一个类只能直接继承自另一个类。然而,Java 同时支持多实现(multiple implementation),即一个类可以实现多个接口。当使用 JDK 动态代理时,生成的代理类会继承 java.lang.reflect.Proxy 类。由于 Java 不支持多重继承,因此代理类不能再继承其他具体的业务逻辑类。这就意味着,如果我们要为某个具体类创建代理对象,而这个类没有实现任何接口,那么我们就无法通过继承该具体类来创建代理对象,因为代理类已经继承了 Proxy 类。因此,JDK 动态代理只能针对实现了接口的类进行代理。

6.2 JDK 动态代理的工作原理

JDK 动态代理的核心在于 java.lang.reflect.Proxy 类和 java.lang.reflect.InvocationHandler 接口。Proxy 类提供了创建动态代理类及其实例的方法,而 InvocationHandler 则用于定义如何处理对代理实例方法的调用。当我们调用 Proxy.newProxyInstance() 方法时,JVM 会在运行时动态生成一个新的类,这个类实现了我们在调用 newProxyInstance() 时传入的所有接口,并且继承了 Proxy 类。

(1)代理类的生成

在运行时,JVM 会根据传入的接口列表动态生成一个代理类。这个代理类的名字通常是 $ProxyN 形式,其中 N 是一个递增的数字。代理类不仅实现了指定的接口,还包含了对 InvocationHandler 的引用。每当调用代理类中的接口方法时,实际上是在调用 InvocationHandler 的 invoke() 方法,后者负责执行实际的业务逻辑或添加额外的功能。

(2)方法调用的拦截

当通过代理对象调用某个接口方法时,JVM 会将这次调用转发给与代理对象关联的 InvocationHandler 实例的 invoke() 方法。invoke() 方法接收三个参数:代理对象本身、被调用的方法对象以及传递给该方法的参数数组。在这个方法内部,我们可以执行任意代码,例如记录日志、检查权限等,然后再调用目标对象的实际方法,或者完全替换掉原始方法的行为。

6.3 代码示例

下面是一个简单的例子,展示了如何使用 JDK 动态代理来增强 UserService 接口的实现:

// 定义接口
public interface UserService {
    String getName(int id);
    Integer getAge(int id);
}

// 实现接口的具体类
public class UserServiceImpl implements UserService {
    @Override
    public String getName(int id) {
        System.out.println("------getName------");
        return "riemann";
    }

    @Override
    public Integer getAge(int id) {
        System.out.println("------getAge------");
        return 26;
    }
}

// 自定义 InvocationHandler
public class MyInvocationHandler implements InvocationHandler {
    private Object target;

    public MyInvocationHandler(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if ("getName".equals(method.getName())) {
            System.out.println("++++++before " + method.getName() + "++++++");
            Object result = method.invoke(target, args);
            System.out.println("++++++after " + method.getName() + "++++++");
            return result;
        } else {
            return method.invoke(target, args);
        }
    }
}

// 测试主程序
public class Main1 {
    public static void main(String[] args) {
        // 创建目标对象
        UserService userService = new UserServiceImpl();
        // 创建 InvocationHandler
        InvocationHandler invocationHandler = new MyInvocationHandler(userService);
        // 创建代理对象
        UserService userServiceProxy = (UserService) Proxy.newProxyInstance(
                userService.getClass().getClassLoader(),
                userService.getClass().getInterfaces(),
                invocationHandler
        );
        // 调用代理对象的方法
        System.out.println(userServiceProxy.getName(1));
        System.out.println(userServiceProxy.getAge(1));
    }
}

在这个例子中,MyInvocationHandler 类实现了 InvocationHandler 接口,并重写了 invoke() 方法以添加前置和后置处理逻辑。Main1 类中的 main() 方法展示了如何创建一个 UserServiceImpl 的代理对象,并通过代理对象调用 getName() 和 getAge() 方法。注意,这里我们传入的是 UserService 接口,而不是 UserServiceImpl 类本身。

6.4 CGLIB 动态代理

相比之下,CGLIB 动态代理并不依赖于接口的存在。它通过继承的方式为现有的类创建子类,并重写非 final 的方法来实现增强功能。CGLIB 使用 ASM 库来操作字节码,从而可以在运行时生成新的类。这种方式适用于那些没有实现接口的类,或者是希望避免接口带来的额外开销的情况。不过,CGLIB 也有自己的局限性,比如不能代理 final 类或 final 方法。

6.5 总结

综上所述,JDK 动态代理只能代理有接口的类,这是由 Java 的单继承特性和 JDK 动态代理的工作机制共同决定的。虽然这可能会给某些场景带来不便,但通过合理地设计接口和使用 JDK 动态代理,我们可以有效地实现各种增强功能。对于那些没有实现接口的类,或者需要更高性能的应用场景,可以选择 CGLIB 动态代理作为替代方案。

© 版权声明
THE END
喜欢就支持一下吧
点赞15
相关推荐
温馨提示:
1、本内容转载于网络,版权归原作者所有!
2、本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
3、本内容若侵犯到你的版权利益,请联系我们,会尽快给予删除处理!