java面试基础篇-1

144 阅读37分钟

一、equal 与 == 的区别

二、final ,fianlly,finalize 的区别

三、重载和重写的区别

四、两个对象 hashCode 相同, 则equals  是否也为true 

五、抽象类和接口有什么区别

六、BIO NIO AIO 有什么区别

七、String StringBuffer StringBuilder 的区别

八、Java 创建对象有几种方式? 

九、如何实现线程同步?

十、什么是守护线程与普通线程有什么区别?

一、equal 与 == 的区别

基本数据类型比较

  • ==操作符

    • 对于基本数据类型(如intdoublecharboolean等),==用于比较两个变量的值是否相等。例如:

      int a = 5; int b = 5; System.out.println(a == b); // 输出为true,因为a和b的值都是5

  • 它是直接比较两个值在内存中的存储内容,基本数据类型变量存储的就是实际的值。

  • 不存在equal用于基本数据类型比较:在基本数据类型的比较中,一般不会使用equal,因为它是Object类中的方法,基本数据类型不是对象,没有这个方法。

引用数据类型比较

    • ==操作符

      • 对于引用数据类型(如StringArrayListObject等自定义类的对象),==比较的是两个对象的引用(即内存地址)是否相同。例如:

      String str1 = new String("hello"); String str2 = new String("hello"); System.out.println(str1 == str2); // 输出为false,因为str1和str2是两个不同的对象,它们在内存中有不同的地址

  • 即使两个对象的内容完全相同,但如果它们是通过不同的方式创建的,==也会返回false

  • equals方法

    • equals方法是在Object类中定义的,其默认行为与==相同,即比较对象的引用。但是,很多类(如String类)重写了equals方法,用于比较对象的内容是否相等。例如:

      String str1 = new String("hello"); String str2 = new String("hello"); System.out.println(str1.equals(str2)); // 输出为true,因为String类重写了equals方法,比较的是字符串的内容

  • 当自定义类时,如果需要比较对象的内容而不是引用,就需要重写equals方法。一个简单的自定义类重写equals方法的示例如下:

       class Person {
           private String name;
           private int age;
           public Person(String name, int age) {
               this.name = name;
               this.age = age;
           }
           @Override
           public boolean equals(Object o) {
               if (this == o) return true;
               if (o == null || getClass()!= o.getClass()) return false;
               Person person = (Person) o;
               return age == person.age && Objects.equals(name, person.name);
           }
       }
    

在这个Person类中,重写的equals方法首先检查两个对象是否是同一个引用(if (this == o) return true;),然后检查传入的对象是否为null或者是否属于不同的类(if (o == null || getClass()!= o.getClass()) return false;),最后比较对象的属性(agename)是否相等来确定两个对象是否内容相等。

二、final ,fianlly,finalize 的区别

final关键字

  • 含义和用途
    • final用于修饰类、方法和变量,它表示 “最终的、不可改变的”。

    • 当修饰类时,该类不能被继承。例如,final class MyFinalClass {},这样就不能创建一个继承自MyFinalClass的子类。这种设计可以保证类的完整性和安全性,防止被其他类继承后修改其行为。

    • 当修饰方法时,该方法不能被重写。比如在一个父类中有final void myFinalMethod() {},那么在子类中就不能再定义同名的方法来覆盖它。这在某些情况下用于确保方法的行为在继承体系中保持一致。

    • 当修饰变量时,变量就成为了常量。如果是基本数据类型的变量,其值在初始化后不能被改变;如果是引用数据类型的变量,其引用不能被改变,但对象的内容可能可以通过对象的方法来改变。例如:

      final int num = 10; // num的值不能再被改变 final StringBuffer buffer = new StringBuffer("hello"); buffer.append(" world"); // 可以通过对象的方法修改对象内容 // buffer = new StringBuffer("new"); 这是不允许的,不能改变引用

finally

  • 在异常处理中的位置和作用

    • finally是与try - catch语句一起使用的。try块用于包含可能会抛出异常的代码,catch块用于捕获并处理异常,而finally块中的代码无论try块中是否发生异常,都会被执行。例如:

      try { // 可能抛出异常的代码,如读取文件 FileReader reader = new FileReader("file.txt"); } catch (FileNotFoundException e) { // 处理文件不存在的异常 System.out.println("文件不存在"); } finally { // 无论是否发生异常,都会执行的代码 System.out.println("资源清理或其他必须执行的操作"); }

  • 它通常用于释放资源,比如关闭文件、数据库连接、网络连接等。这样可以保证在任何情况下,资源都能得到正确的释放,避免资源泄漏。

finalize方法

  • 垃圾回收相关的机制和调用时机

    • finalizeObject类中的一个方法,它用于在对象被垃圾回收之前执行一些清理操作。当一个对象没有任何引用指向它时,垃圾回收器会在合适的时机(这个时机是由 JVM 决定的,不是确定性的)调用对象的finalize方法。例如

      class MyObject { @Override protected void finalize() throws Throwable { System.out.println("对象即将被回收"); super.finalize(); } } public class Main { public static void main(String[] args) { MyObject object = new MyObject(); object = null; System.gc(); // 建议JVM进行垃圾回收,但不保证一定会立即回收 } }

  • 不过,在实际应用中,不建议过度依赖finalize方法来进行资源清理,因为它的执行时间不确定,而且如果在finalize方法中出现异常,可能会导致对象不能正常回收。现在更推荐使用try - finally或其他资源管理机制(如AutoCloseable接口)来确保资源的及时和正确清理。

三、重载和重写的区别

概念定义

  • 重载(Overloading)

    • 发生在同一个类中,是指多个方法具有相同的名字,但参数列表不同(参数的个数、类型或者顺序不同)。方法的返回类型和访问修饰符可以相同,也可以不同。例如:

      class Calculator { public int add(int num1, int num2) { return num1 + num2; } public double add(double num1, double num2) { return num1 + num2; } public int add(int num1, int num2, int num3) { return num1 + num2 + num3; } }

  • 在这个Calculator类中,add方法被重载了三次。第一个add方法接受两个int类型的参数,第二个接受两个double类型的参数,第三个接受三个int类型的参数。这样可以根据不同的参数类型和个数提供不同的实现,方便程序员调用合适的方法来满足具体的计算需求。

  • 重写(Overriding)

    • 发生在父子类之间,是指子类中定义了与父类中方法签名(方法名、参数列表、返回类型都相同,访问修饰符不能比父类中更严格)相同的方法,用于在子类中改变父类方法的行为。例如:

      class Animal { public void makeSound() { System.out.println("动物发出声音"); } } class Dog extends Animal { @Override public void makeSound() { System.out.println("汪汪汪"); } }

  • 在这个例子中,Dog类重写了Animal类的makeSound方法。当通过Dog类的对象调用makeSound方法时,将执行Dog类中重写后的方法,而不是Animal类中的方法。

规则和限制

  • 重载规则
    • 方法名必须相同。
    • 参数列表必须不同,包括参数的个数、类型或者顺序。例如,public void method(int a, double b)public void method(double b, int a)是不同的参数顺序,构成重载;public void method(int a)public void method(int a, int b)是不同的参数个数,也构成重载;public void method(int a)public void method(double a)是不同的参数类型,同样构成重载。
    • 方法的返回类型和访问修饰符可以不同,不过这些不同不会作为重载的判定条件。例如,public int method(int a)private double method(int a)是重载关系,但主要是因为参数列表相同而返回类型和访问修饰符不同。
  • 重写规则
    • 方法名、参数列表和返回类型都必须与父类中的方法相同。不过,如果返回类型是引用类型,子类方法的返回类型可以是父类方法返回类型的子类型。例如,父类方法返回Animal类型,子类方法可以返回Dog类型(前提是DogAnimal的子类)。
    • 访问修饰符不能比父类中的方法更严格。例如,如果父类方法是public,子类重写后的方法可以是public或者protected,但不能是private
    • 子类方法不能抛出比父类方法更宽泛的异常。也就是说,子类方法抛出的异常类型必须是父类方法抛出异常类型的子类型或者不抛出异常,除非父类方法声明了抛出java.lang.RuntimeException或其子类型(因为这些异常是不需要在方法签名中声明的)。

调用方式和行为表现

  • 重载调用方式
    • 在调用重载方法时,编译器会根据传递的实际参数的个数、类型和顺序来确定调用哪个重载版本的方法。例如,在Calculator类中,如果调用add(2, 3),编译器会选择add(int num1, int num2)这个版本的方法;如果调用add(2.0, 3.0),编译器会选择add(double num1, double num2)这个版本的方法。
  • 重写行为表现
    • 当通过子类对象调用重写后的方法时,会执行子类中定义的方法。如果想在子类方法中调用父类被重写的方法,可以使用super关键字。例如,在Dog类的makeSound方法中,可以通过super.makeSound()来调用Animal类中的makeSound方法。而且,多态性与重写紧密相关,当一个父类的引用指向一个子类的对象时(如Animal animal = new Dog();),调用makeSound方法会执行Dog类中重写后的方法,这体现了根据对象的实际类型来执行相应方法的特性。

四、两个对象 hashCode 相同, 则equals 是否也为true

  1. hashCodeequals方法的关系基础

    • 在 Java 中,hashCode方法和equals方法是定义在Object类中的两个重要方法。hashCode方法主要用于返回对象的哈希码值,这个值在哈希表(如HashMapHashSet等基于哈希实现的数据结构)相关操作中起到快速定位对象的作用。equals方法用于比较两个对象是否相等。
  2. hashCode相同的时候equals不一定为true

    • 根据hashCode方法的规范,两个相等的对象(根据equals方法判断)必须具有相同的哈希码值,但反过来并不成立。也就是说,如果两个对象的hashCode相同,它们不一定是相等的对象(equalstrue)。

    • 例如,假设有一个简单的自定义类Person,重写了hashCode方法但没有正确重写equals方法:

      class Person { private int age; public Person(int age) { this.age = age; } @Override public int hashCode() { return age % 10; } }

  • 可以创建两个Person对象:Person p1 = new Person(10);Person p2 = new Person(20);。这两个对象的hashCode是相同的(都为 0),因为10 % 10 = 020 % 10 = 0,但是它们显然不相等(按照默认的equals方法比较,比较的是对象引用,这两个对象是不同的引用),所以p1.equals(p2)false

正确的hashCodeequals实现原则

  • 当在自定义类中重写equals方法时,通常也应该重写hashCode方法,以保证遵循以下原则:
    • 如果两个对象根据equals方法比较是相等的,那么它们的hashCode值必须相同。
    • 如果两个对象的hashCode值相同,它们不一定相等,但在理想情况下,不同对象产生相同hashCode值的概率应该尽可能小,这样可以提高哈希表相关操作的效率。例如,在HashMap中,当多个对象具有相同的hashCode值时,会在同一个哈希桶中形成链表或者红黑树(在 Java 8 以后,当链表长度达到一定程度会转换为红黑树),这会影响数据访问的性能。所以正确的hashCode方法设计应该尽量使不同的对象产生不同的hashCode值,同时满足和equals方法的一致性要求。 

五、抽象类和接口有什么区别

概念和定义

  • 抽象类

    • 抽象类是一种不能被实例化的类,它用于作为其他类的基类(父类)。在抽象类中,可以包含抽象方法和非抽象方法。抽象方法是只有方法签名(方法名、参数列表和返回类型),没有方法体的方法,使用abstract关键字修饰。例如:

      abstract class Shape { // 抽象方法,计算面积,没有方法体 public abstract double calculateArea(); // 非抽象方法 public void printShapeName() { System.out.println("这是一个形状"); } }

  • 接口

    • 接口是一种特殊的抽象类型,它只包含抽象方法、常量和默认方法(Java 8 以后)、静态方法(Java 8 以后)。接口中的所有方法默认都是抽象方法,在 Java 8 之前,接口中的方法不能有方法体,从 Java 8 开始,接口可以有默认方法(使用default关键字)和静态方法(使用static关键字)。例如:

      interface Drawable { // 抽象方法,绘制图形 void draw(); // 常量 int DEFAULT_COLOR = 0; // 默认方法(Java 8+) default void printDrawingInfo() { System.out.println("这是一个可绘制的图形"); } // 静态方法(Java 8+) static int getMaxColorValue() { return 255; } }

设计目的和使用场景

  • 抽象类
    • 主要用于在一组相关的类中提取公共的属性和行为,并且为这些类提供一个通用的模板。当有一些通用的方法实现和部分需要子类去实现的抽象方法时,适合使用抽象类。例如,在一个图形类层次结构中,所有图形都有一个打印名称的方法可以有相同的实现,但计算面积的方法因图形不同而不同,就可以把打印名称的方法定义为非抽象方法,计算面积的方法定义为抽象方法放在抽象类Shape中。
    • 抽象类体现了一种 “is - a”(是一种)的关系,子类和抽象类是继承关系,子类是抽象类的一种具体实现。例如,Circle类是Shape抽象类的一种具体类型,它继承了Shape抽象类,并实现了calculateArea抽象方法来计算圆的面积。
  • 接口
    • 用于定义一组规范或者契约,规定了实现类必须实现的方法。它侧重于定义行为规范,而不关心实现细节。例如,在一个绘图系统中,Drawable接口定义了所有可绘制对象必须实现的draw方法,不同的图形类(如CircleRectangle等)只要实现了Drawable接口,就表明它们可以被绘制,至于如何绘制(draw方法的具体实现)则由各个类自己决定。
    • 接口体现了一种 “can - do”(能够做)的关系,实现类和接口是实现关系,表明实现类能够完成接口中规定的行为。例如,一个Car类可以实现Driveable接口,表示汽车能够行驶,而具体如何行驶(Driveable接口中方法的实现)由Car类自己决定。

语法规则和实现细节

  • 抽象类

    • 抽象类使用abstract关键字声明。抽象类可以有构造方法,这些构造方法用于子类的初始化。子类通过extends关键字继承抽象类,并且必须实现抽象类中的所有抽象方法,除非子类也是抽象类。例如:

      class Circle extends Shape { private double radius; public Circle(double radius) { this.radius = radius; } @Override public double calculateArea() { return Math.PI * radius * radius; } }

  • 接口

    • 接口使用interface关键字声明。接口中的变量默认是publicstaticfinal的常量,方法默认是publicabstract的抽象方法(在 Java 8 之前没有其他类型的方法)。类通过implements关键字实现接口,并且必须实现接口中的所有抽象方法,从 Java 8 开始,如果接口有默认方法,实现类可以不实现这些默认方法,但可以选择重写它们。例如:

      class Square implements Drawable { @Override public void draw() { System.out.println("绘制一个正方形"); } }

多重继承和实现方面的差异

  • 抽象类
    • 在 Java 中,类是单继承的,一个类只能继承一个抽象类。这是为了避免多继承带来的复杂性,如菱形继承问题(当一个类同时继承两个有共同父类的类时,会导致方法和属性的冲突)。
  • 接口
    • 一个类可以实现多个接口,这使得类能够具备多种行为规范。例如,一个类可以同时实现Drawable接口和Serializable接口,表示这个类既可以被绘制,又可以被序列化。这种多重实现的机制为 Java 的面向对象编程提供了更灵活的设计方式,使得一个类能够满足多种不同的需求。

六、BIO NIO AIO 有什么区别

  1. BIO(Blocking I/O,阻塞式 I/O)

    • 工作原理
      • 在 BIO 模式下,当一个线程发起一个 I/O 操作(如读取文件或网络通信中的读取数据)时,该线程会被阻塞,直到这个 I/O 操作完成。例如,在网络编程中,服务器端使用ServerSocket接受客户端连接,当调用accept方法时,如果没有客户端连接进来,线程就会一直阻塞在那里等待连接。同样,在读取客户端发送的数据时,如使用InputStreamread方法,线程也会阻塞直到有足够的数据可读。
    • 应用场景和优缺点
      • 应用场景:适用于连接数较少且连接比较稳定的场景。比如简单的命令行工具与服务器的交互,或者在一些小型的文件传输应用中,对性能要求不是特别高,且连接数相对固定的情况。
      • 优点:编程模型简单直观,易于理解和实现。对于初学者来说,比较容易上手。例如,简单的 TCP 服务器代码,使用 BIO 实现可以很清晰地看到服务器接受连接、读取数据和发送数据的流程。
      • 缺点:每个连接都需要占用一个线程,当连接数较多时,会导致线程资源的大量占用,从而造成系统性能下降。而且,由于线程被阻塞等待 I/O 操作完成,线程的利用率较低。例如,在一个高并发的 Web 服务器场景下,如果使用 BIO,可能会因为线程数过多而耗尽系统资源。
  2. NIO(Non - Blocking I/O,非阻塞式 I/O)

    • 工作原理
      • NIO 基于通道(Channel)和缓冲区(Buffer)进行 I/O 操作。通道可以理解为是一种连接源和目标的管道,而缓冲区是用于存储数据的地方。与 BIO 不同的是,NIO 的通道可以设置为非阻塞模式。在这种模式下,当线程发起一个 I/O 操作时,如果数据没有准备好,不会像 BIO 那样阻塞线程,而是会立即返回一个状态值,告知当前没有数据可读或者可写。例如,在网络编程中,使用SelectorSocketChannelServerSocketChannelSelector可以同时监听多个通道的事件(如可读、可写、连接建立等),一个线程就可以管理多个通道的 I/O 操作。
    • 应用场景和优缺点
      • 应用场景:适用于高并发、连接数较多的场景,如高性能的网络服务器、大规模的文件读写系统等。例如,在构建一个支持大量并发客户端连接的 Web 服务器时,NIO 可以通过少量的线程高效地处理大量的连接。
      • 优点:通过一个线程管理多个通道,减少了线程资源的占用,提高了系统的并发处理能力。同时,非阻塞模式使得线程不会被长时间阻塞,提高了线程的利用率。
      • 缺点:编程模型相对复杂,需要理解通道、缓冲区和选择器等概念,并且要正确处理各种 I/O 事件的逻辑。例如,对于初学者来说,掌握Selector的使用和事件处理机制可能会有一定的难度。
  3. AIO(Asynchronous I/O,异步 I/O)

    • 工作原理
      • AIO 是一种真正的异步 I/O 模型。当线程发起一个 I/O 操作后,线程可以继续执行其他任务,而不需要像 NIO 那样不断轮询检查 I/O 操作是否完成。当 I/O 操作完成后,系统会通过回调机制(如 Java 中的CompletionHandler)通知线程。例如,在文件读取场景下,线程发起一个异步读取文件的操作后,就可以去做其他事情,当文件读取完成后,会自动调用预先定义好的回调方法来处理读取到的数据。
    • 应用场景和优缺点
      • 应用场景:适用于对性能要求极高,需要充分利用系统资源并且 I/O 操作比较耗时的场景,如大型数据库系统的 I/O 操作、分布式文件系统等。
      • 优点:线程在发起 I/O 操作后不需要等待,可以最大限度地利用系统资源,提高系统的整体性能。同时,通过回调机制使得代码逻辑更加清晰,将 I/O 完成后的处理逻辑和 I/O 操作本身分离开来。
      • 缺点:编程模型的复杂度更高,需要处理回调函数的逻辑,而且不同的操作系统对 AIO 的支持程度可能不同,这可能会导致在跨平台应用中出现一些兼容性问题。

七、String StringBuffer StringBuilder 的区别

  1. 不可变性与可变性
    • String
      • String 是不可变的,这意味着一旦一个 String 对象被创建,它的值就不能被修改。例如,当你对一个 String 对象执行拼接操作时,实际上是创建了一个新的 String 对象。如String str = "Hello"; str = str + " World";,在这个过程中,原始的"Hello"字符串对象并没有改变,而是创建了一个新的包含"Hello World"的字符串对象。
      • 这种不可变性使得 String 对象在多线程环境下是安全的,因为多个线程可以同时访问同一个 String 对象,而不用担心数据被其他线程修改。
    • StringBuffer 和 StringBuilder
      • StringBuffer 和 StringBuilder 是可变的字符序列。这意味着可以在原对象上进行修改操作,如追加、插入、删除字符等。例如,使用 StringBuilder 的append方法可以在原有字符串基础上添加新的内容,StringBuilder sb = new StringBuilder("Hello"); sb.append(" World");,在这个例子中,sb对象的值被直接修改为"Hello World",而不是创建一个新的对象。
  2. 线程安全性
    • String
      • 由于其不可变性,在多线程环境下可以安全地共享 String 对象,不会出现数据不一致的问题。例如,多个线程可以同时读取同一个 String 常量,而不需要额外的同步措施。
    • StringBuffer
      • StringBuffer 是线程安全的。它的所有修改方法(如appendinsertdelete等)都使用了synchronized关键字进行修饰,这保证了在多线程环境下对同一个 StringBuffer 对象进行操作时,每次只有一个线程能够执行这些修改方法,从而避免了数据竞争和不一致性。例如,在一个多线程的 Web 应用中,如果多个线程需要共同修改一个用于记录日志的 StringBuffer 对象,使用 StringBuffer 可以确保日志内容的正确性。
    • StringBuilder
      • StringBuilder 是线程不安全的。它没有像 StringBuffer 那样使用同步机制,因此在多线程环境下,如果多个线程同时访问和修改同一个 StringBuilder 对象,可能会导致数据错误。但是,在单线程环境下,由于不需要同步带来的额外开销,它的性能比 StringBuffer 要高。例如,在一个只在单线程中进行字符串拼接操作的本地方法中,使用 StringBuilder 可以提高性能。
  3. 性能表现
    • String
      • 由于每次修改操作都会创建新的对象,频繁修改 String 对象会导致性能下降,特别是在大量字符串拼接操作的情况下。例如,在一个循环中拼接字符串,使用 String 会创建大量的临时对象,占用较多的内存和 CPU 资源。
    • StringBuffer 和 StringBuilder
      • 在字符串拼接、修改等操作方面,StringBuffer 和 StringBuilder 的性能比 String 好很多。在单线程环境下,由于 StringBuilder 不需要同步开销,它的性能略高于 StringBuffer。但在多线程环境下,如果需要保证线程安全,应该使用 StringBuffer,虽然它的性能会因为同步机制而略有损失,但可以确保数据的准确性。例如,在一个简单的性能测试中,在单线程环境下进行大量的字符串拼接操作,使用 StringBuilder 可能比 StringBuffer 快 10% - 30% 左右,具体的性能提升幅度会因操作的复杂程度和数据量等因素而有所不同。

八、Java 创建对象有几种方式?

1,使用new关键字

  • 原理和步骤

    • 这是最常见的创建对象的方式。当使用new关键字时,会在堆内存中为对象分配空间,然后调用对象的构造方法来初始化对象的属性。例如,创建一个Person类的对象:

      class Person { private String name; private int age; public Person(String name, int age) { this.name = name; this.age = age; } } public class Main { public static void main(String[] args) { Person person = new Person("John", 30); } }

  • 首先,JVM 会在堆内存中为Person对象开辟一块足够大的空间,这个空间的大小取决于Person类的成员变量和方法等所占用的空间总和。然后,调用Person类的构造方法Person(String name, int age),将"John"30分别传递给nameage参数,对对象的属性进行初始化。

  • 适用场景和特点

    • 适用于创建大多数自定义类的对象,特别是需要通过构造方法来初始化对象属性的情况。这种方式简单直接,能够明确地控制对象的初始化过程。它的缺点是,如果频繁地使用new关键字创建对象,可能会导致大量的小对象占用堆内存,增加垃圾回收的压力。

2,使用Class.newInstance()方法(已过时)

  • 原理和步骤

    • 这种方式通过类对象(java.lang.Class类型)来创建该类的实例。首先需要获取类对象,然后调用newInstance()方法。例如:

      class AnotherPerson { private String name; private int age; public AnotherPerson(String name, int age) { this.name = name; this.age = age; } } public class Main { public static void main(String[] args) throws InstantiationException, IllegalAccessException { Class clazz = AnotherPerson.class; AnotherPerson person = clazz.newInstance(); } }

  • 在这里,首先通过AnotherPerson.class获取AnotherPerson类的类对象,然后调用newInstance()方法创建AnotherPerson类的一个实例。这个方法会调用类的默认无构造方法(即没有参数的构造方法)来创建对象。

  • 适用场景和特点

    • 这种方式在某些框架或需要动态创建对象的场景中可能会用到。不过,它有一定的局限性,因为它只能调用默认的无构造方法,如果类没有默认无构造方法,就会抛出InstantiationException异常。而且从 Java 9 开始,这个方法已经被标记为过时,因为它在一些复杂的场景下可能会出现安全问题,推荐使用Constructor.newInstance()方法来代替。

3,使用Constructor.newInstance()方法

  • 原理和步骤

    • 首先需要获取类的构造方法对象(java.lang.reflect.Constructor类型),然后通过这个构造方法对象来创建对象。例如:

      import java.lang.reflect.Constructor; class YetAnotherPerson { private String name; private int age; public YetAnotherPerson(String name, int age) { this.name = name; this.age = age; } } public class Main { public static void main(String[] args) throws Exception { Class clazz = YetAnotherPerson.class; Constructor constructor = clazz.getConstructor(String.class, int.class); YetAnotherPerson person = constructor.newInstance("Alice", 25); } }

  • 在这个例子中,首先获取YetAnotherPerson类的类对象,然后通过getConstructor(String.class, int.class)方法获取带有Stringint类型参数的构造方法对象。最后,通过这个构造方法对象的newInstance("Alice", 25)方法创建一个YetAnotherPerson类的对象,将"Alice"25作为参数传递给构造方法来初始化对象。

  • 适用场景和特点

    • 适用于需要通过指定的构造方法来创建对象的情况,特别是在反射机制相关的编程中。它提供了更灵活的对象创建方式,可以根据不同的构造方法签名来创建对象,相比Class.newInstance()方法更加安全和灵活。

4,使用对象克隆(Clone方法)

  • 原理和步骤

    • 要使用对象克隆,需要让类实现java.lang.Cloneable接口,并重写clone()方法。例如:

      class CloneablePerson implements Cloneable { private String name; private int age; public CloneablePerson(String name, int age) { this.name = name; this.age = age; } @Override public Object clone() throws CloneNotSupportedException { return super.clone(); } } public class Main { public static void main(String[] args) throws CloneNotSupportedException { CloneablePerson person1 = new CloneablePerson("Bob", 35); CloneablePerson person2 = (CloneablePerson) person1.clone(); } }

  • 首先创建一个CloneablePerson类的对象person1,然后通过person1.clone()方法创建一个person2对象,这个person2对象是person1对象的一个克隆。在clone()方法内部,通过super.clone()调用了Object类的clone方法,它会创建一个新的对象,这个新对象的成员变量的值和原始对象相同(对于基本数据类型是值相同,对于引用数据类型是引用相同)。

  • 适用场景和特点

    • 适用于需要创建一个与现有对象属性值相同的新对象的场景。不过,这种方式有一些限制和潜在的问题。如果对象中包含引用类型的成员变量,克隆后的对象和原始对象的引用类型成员变量会指向相同的对象,这可能会导致在修改其中一个对象的引用类型成员变量时,影响到另一个对象。而且,正确实现clone方法需要注意一些细节,如浅克隆和深克隆的区别,以及确保类实现了Cloneable接口等。

5,使用反序列化(ObjectInputStream

  • 原理和步骤

    • 首先需要将对象序列化(通过ObjectOutputStream)保存到文件或者网络流等介质中,然后通过ObjectInputStream从介质中读取并反序列化得到对象。例如:

      import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; class SerializablePerson implements Serializable { private String name; private int age; public SerializablePerson(String name, int age) { this.name = name; this.age = age; } } public class Main { public static void main(String[] args) throws Exception { // 序列化对象 SerializablePerson person = new SerializablePerson("Charlie", 40); FileOutputStream fos = new FileOutputStream("person.ser"); ObjectOutputStream oos = new ObjectOutputStream(fos); oos.writeObject(person); oos.close(); fos.close(); // 反序列化对象 FileInputStream fis = new FileInputStream("person.ser"); ObjectInputStream ois = new ObjectInputStream(fis); SerializablePerson deserializedPerson = (SerializablePerson) ois.readObject(); ois.close(); fis.close(); } }

  • 在这个例子中,首先创建了一个SerializablePerson类的对象person,然后通过ObjectOutputStream将其序列化并保存到person.ser文件中。接着,通过ObjectInputStream从文件中读取并反序列化得到deserializedPerson对象。这个deserializedPerson对象和原始的person对象在内容上是相同的。

  • 适用场景和特点

    • 适用于对象需要在网络传输或者持久化存储后重新恢复的场景,如分布式系统中的对象传输、数据备份与恢复等。不过,使用反序列化需要类实现Serializable接口,并且要注意序列化版本号(serialVersionUID)等相关问题,以确保反序列化的正确性。同时,序列化和反序列化过程可能会消耗一定的时间和资源,尤其是对于复杂的对象结构。

九、如何实现线程同步?

1,使用synchronized关键字

  • 方法级别同步

    • 可以在方法声明中使用synchronized关键字,这样当一个线程访问该方法时,其他线程必须等待该线程执行完此方法后才能访问。例如:

      class Counter { private int count = 0; public synchronized void increment() { count++; } public synchronized int getCount() { return count; } }

  • 在这个Counter类中,incrementgetCount方法都被synchronized修饰。当一个线程调用increment方法时,其他线程不能同时调用incrementgetCount方法。这是因为每个Counter类的对象都有一个与之关联的锁(也称为监视器锁),当一个线程进入synchronized方法时,它获取了这个对象的锁,只有在该线程释放锁(即方法执行完毕或者遇到异常退出方法)后,其他线程才能获取锁并进入方法。

  • 代码块级别同步

    • 除了在方法级别使用synchronized,还可以在代码块中使用。这种方式更加灵活,可以只对需要同步的部分代码进行锁定。例如:

      class Data { private Object lock = new Object(); private int value; public void modifyValue() { synchronized (lock) { value++; } } }

  • 在这里,modifyValue方法中的代码块被synchronized修饰,并且指定了一个Object类型的锁对象lock。当一个线程进入这个同步代码块时,它获取lock对象的锁。这样,即使在同一个类中有其他方法或者代码块使用了不同的锁对象,只要这个代码块使用的lock对象是唯一的,就可以实现对这部分代码的独立同步控制。

2,使用ReentrantLock类(可重入锁)

  • 基本使用方法

    • ReentrantLockjava.util.concurrent.locks包中的一个类,它提供了比synchronized关键字更灵活的锁机制。例如:

      import java.util.concurrent.locks.ReentrantLock; class SharedResource { private final ReentrantLock lock = new ReentrantLock(); private int data = 0; public void increment() { lock.lock(); try { data++; } finally { lock.unlock(); } } }

  • 在这个SharedResource类中,首先创建了一个ReentrantLock对象lock。在increment方法中,通过lock.lock()获取锁,然后在try - finally块中执行需要同步的操作(这里是data++),最后在finally块中通过lock.unlock()释放锁。这样可以确保在任何情况下,锁都能被正确释放,避免死锁的发生。

  • synchronized的区别和优势

    • ReentrantLock提供了一些synchronized没有的功能,如可以尝试获取锁(tryLock方法)、可以设置公平锁(在构造函数中传入true)。公平锁的意思是按照线程请求锁的顺序来分配锁,而synchronized是非公平锁。另外,ReentrantLock可以通过newCondition方法创建多个条件对象(Condition),用于实现更复杂的线程等待和唤醒机制,比如实现生产者 - 消费者模型中的精确通知。

3,使用Semaphore(信号量)

  • 工作原理和基本用法

    • Semaphore是一种计数信号量,它可以控制同时访问某个资源的线程数量。例如,假设有一个资源池,我们想限制同时使用资源池的线程数量为 3,可以这样使用Semaphore

      import java.util.concurrent.Semaphore; class ResourcePool { private final Semaphore semaphore = new Semaphore(3); public void accessResource() throws InterruptedException { semaphore.acquire(); try { // 访问资源的代码,这里假设是打印一条信息 System.out.println("线程 " + Thread.currentThread().getName() + " 正在访问资源"); } finally { semaphore.release(); } } }

  • 在这个ResourcePool类中,创建了一个Semaphore对象semaphore,并将其初始值设为 3。在accessResource方法中,通过semaphore.acquire()获取一个许可,如果当前可用许可数大于 0,则获取成功并使可用许可数减 1,线程可以继续执行访问资源的代码;如果可用许可数为 0,则线程会被阻塞,直到有其他线程释放许可。在finally块中,通过semaphore.release()释放许可,使可用许可数加 1,这样其他等待的线程就有可能获取许可并访问资源。

  • 应用场景

    • Semaphore常用于控制对有限资源的并发访问,比如数据库连接池、线程池等场景,确保在资源有限的情况下,多个线程能够合理地共享资源,避免资源耗尽或者过度竞争。

4,使用CountDownLatch(倒计时器)

  • 工作原理和基本用法

    • CountDownLatch用于让一个或多个线程等待其他线程完成一系列操作。例如,假设有一个主线程需要等待多个子线程完成任务后再继续执行,可以这样使用CountDownLatch

      import java.util.concurrent.CountDownLatch; class MainTask { public static void main(String[] args) throws InterruptedException { int numThreads = 5; CountDownLatch latch = new CountDownLatch(numThreads); for (int i = 0; i < numThreads; i++) { new Thread(() -> { try { // 子线程执行的任务,这里假设是打印一条信息并休眠一段时间 System.out.println("子线程 " + Thread.currentThread().getName() + " 开始执行任务"); Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } finally { latch.countDown(); } }).start(); } latch.wait(); System.out.println("所有子线程任务完成,主线程继续执行"); } }

  • 在这个例子中,首先创建了一个CountDownLatch对象latch,并将计数器初始值设为numThreads(这里是 5)。然后启动了 5 个线程,每个线程在执行完任务后(这里是打印信息并休眠),通过latch.countDown()将计数器减 1。主线程通过latch.wait()等待,直到计数器的值变为 0,也就是所有子线程都完成任务后,主线程才会继续执行并打印出最后一条信息。

  • 应用场景

    • CountDownLatch常用于多线程任务的协调,比如在并行计算中,主线程需要等待所有子任务完成后进行结果汇总;或者在启动多个服务后,等待所有服务都初始化完成后再开始接受请求等场景。

5,使用CyclicBarrier(循环栅栏)

  • 工作原理和基本用法

    • CyclicBarrier类似于CountDownLatch,但它可以循环使用。它用于让一组线程等待彼此达到某个公共的屏障点后再一起继续执行。例如:

      import java.util.concurrent.CyclicBarrier; class TeamWork { public static void main(String[] args) { int numTeamMembers = 3; CyclicBarrier barrier = new CyclicBarrier(numTeamMembers, () -> { System.out.println("所有团队成员都已到达屏障点,开始下一阶段任务"); }); for (int i = 0; i < numTeamMembers; i++) { new Thread(() -> { try { // 假设每个团队成员执行的任务是打印一条信息并等待其他成员 System.out.println(Thread.currentThread().getName() + " 正在前往屏障点"); barrier.await(); System.out.println(Thread.currentThread().getName() + " 开始下一阶段任务"); } catch (Exception e) { e.printStackTrace(); } }).start(); } } }

  • 在这个例子中,创建了一个CyclicBarrier对象barrier,并将参与者数量设为numTeamMembers(这里是 3),同时还定义了一个在所有线程都到达屏障点后执行的任务(这里是打印一条信息)。启动了 3 个线程,每个线程在打印信息后通过barrier.await()等待其他线程到达屏障点。当所有 3 个线程都调用了await方法后,会执行定义好的屏障点任务,然后所有线程继续执行后面的任务(这里是打印开始下一阶段任务的信息)。

  • 应用场景

    • CyclicBarrier常用于多线程协作的场景,比如在并行的数据分析中,多个线程分别处理数据的不同部分,当所有线程都完成一部分处理后,在屏障点进行数据合并或者中间结果的汇总,然后再一起进行下一轮的处理。它的循环使用特性使得它在需要多次同步多个线程的任务中非常有用。

十、什么是守护线程与普通线程有什么区别?

  1. 概念理解
    • 普通线程
      • 普通线程是程序执行的主要载体,用于执行具体的任务逻辑。它的执行流程和生命周期相对独立,一旦启动,会按照代码逻辑顺序执行任务,直到任务完成(如执行完run方法)、遇到未捕获的异常或者被外部中断(通过interrupt方法)。例如,在一个文件下载程序中,负责从网络下载文件的线程就是普通线程,它会一直执行下载操作,直到文件下载完成或者出现错误。
    • 守护线程
      • 守护线程主要是在后台为其他线程(通常是普通线程)提供服务的线程。它就像一个默默工作的 “守护者”,不影响程序的主要业务逻辑。守护线程的存在是为了辅助程序的运行,比如执行一些周期性的任务、清理资源等。例如,在一个服务器应用程序中,有一个守护线程负责定期清理服务器端的临时文件,以释放磁盘空间。
  2. 生命周期差异
    • 普通线程
      • 普通线程的生命周期由自身任务决定。当线程的run方法开始执行时,线程进入运行状态。在运行过程中,如果遇到阻塞操作(如等待 I/O 操作完成、获取锁等),线程会进入阻塞状态;当阻塞条件解除后,线程会再次进入就绪状态,等待 CPU 调度重新运行。只有当run方法执行完毕或者线程因为异常等原因终止时,线程的生命周期才结束。例如,一个线程在读取一个大型文件时,可能会因为等待磁盘 I/O 而进入阻塞状态,当数据读取完成后,它又会回到就绪状态继续执行后续的处理任务。
    • 守护线程
      • 守护线程的生命周期依赖于普通线程。当程序中所有的普通线程都结束时,守护线程会立即停止,即使守护线程的任务还没有完成。这是因为守护线程被视为服务于普通线程的辅助线程,一旦没有普通线程需要服务,守护线程就失去了存在的意义。例如,假设一个守护线程用于记录程序运行日志,当程序的所有主要功能(由普通线程执行)都结束后,即使日志记录还没有完成当天的全部任务,守护线程也会随着程序的结束而停止。
  3. 用途对比
    • 普通线程
      • 用于实现程序的核心功能。例如,在一个多线程的游戏服务器中,处理玩家请求(如登录、移动、攻击等操作)的线程是普通线程,这些线程直接关系到游戏的正常运行和玩家的体验。每个玩家的操作请求都会由一个单独的普通线程来处理,以确保游戏的实时性和交互性。
    • 守护线程
      • 用于执行一些非核心但又必要的辅助任务。比如,在上述游戏服务器中,可能有一个守护线程负责定期备份玩家数据。这个备份任务对于游戏的实时运行不是必需的,但对于数据的安全性和持久性非常重要。守护线程可以在后台默默地执行备份操作,而不会影响游戏服务器对玩家请求的正常处理。
  4. 创建和使用细节区别
    • 普通线程
      • 创建普通线程通常有两种常见方式:一是通过继承Thread类,重写run方法;二是实现Runnable接口,将接口实现类的对象作为参数传递给Thread类的构造函数。例如:

        • 继承Thread类:

        class MyThread extends Thread { @Override public void run() { // 执行具体任务 } } // 创建并启动线程 MyThread myThread = new MyThread(); myThread.start();

  • 实现Runnable接口:

         class MyRunnable implements Runnable {
             @Override
             public void run() {
                 // 执行具体任务
             }
         }
         Thread thread = new Thread(new MyRunnable());
         thread.start();
    
  • 守护线程

    • 守护线程的创建方式和普通线程类似,但需要在启动线程之前通过setDaemon(true)方法将其设置为守护线程。注意,这个设置必须在start方法之前进行。例如:

      class MyDaemonThread implements Runnable { @Override public void run() { // 执行守护任务 } } Thread daemonThread = new Thread(new MyDaemonThread()); daemonThread.setDaemon(true); daemonThread.start();

  • 另外,由于守护线程可能会在任何时候被终止,所以在编写守护线程的任务代码时,要确保其逻辑能够正确处理突然终止的情况。例如,守护线程在执行文件写入操作时,应该尽量使用合适的机制(如缓冲写入、定期刷新等),以避免在突然终止时导致数据损坏或丢失。