Java基础技术点整理讲解——反射,集合,内部类,设计模式

本篇主要记录Java的核心技术点,其他知识点另外写文章记录(字数多很卡)。

回答问题抓重点,要和面试官保持在同一纬度。抛出锚点,看他对什么有兴趣。按照总-分-总的顺序回答问题,先做总结,再做分项描述。不会的就绕过去

持续更新,用于个人记录整理,包括后续的面试总结也会更新在这里。记得一定要有自己的回答思路和理解,不要全背。


一:面向对象

Java遵循面向对象设计原则,面向对象编程(OOP)是一种以对象为中心的编程风格,相比于面向过程编程(专注这个流程的实现)更加模块化,具有灵活可扩展性强的优点。

注重对象的模块化和可复用性,面向对象的三大特性是封装、继承、多态,提供面向对象实现。

封装:将数据和行为封装在对象内部,对外提供访问方法,隐藏实现细节,提高安全性和扩展。

继承:子类可以继承父类的属性和方法,实现代码复用和增强扩展。

多态:对象可以通过父类或接口调用不同子类,实现不同的行为,降低耦合度,提高可扩展性。

新增子类或实现类时,不用修改原有代码(同Jdk8接口默认方法),只需要父类或接口调用即可。

多态分为:编译时多态和运行时多态,在不同阶段决定方法的绑定。

编译时多态:通过方法重载实现,在编译阶段完成绑定,编译器通过方法名+参数列表,直接确定调用的具体方法。

运行时多态:通过方法重写实现,在运行阶段完成绑定,通过子类的实例动态查找并调用重写后的方法。


重载:在一个类或接口中,允许存在多个方法名相同的方法,只要其参数列表(类型,数量,顺序)不同。在调用时,会根据传参动态选择调用的具体方法,适用于同一个类下定义不同场景下的行为。

重写:发生在继承或接口实现场景,子类可以重写父类或接口的方法,为该方法提供新的个性化或增强的实现,实现运行时多态性,增强系统可扩展性。需要注意重写的方法访问修饰符需大于或等于父类,否则会编译报错,添加@Override注解,可帮助编译器验证重写是否符合规范(非强制但推荐使用)。

另外子类重写方法抛出的异常类型需与父类一致或是其子类,不能抛出更宽泛的检查异常‌。

二:String字符串

总:String,StringBuilder,StringBuffer 都是Java中处理字符串的类。区别主要体现在是否可变,线程安全以及性能上。

String:String是final修饰的不可变类,字符串一旦创建,其内容就无法更改。后续对其的修改截取操作,都会重新创建字符串对象。因其不可变特性,在JVM中被用作常量池。

在Java8及以后,编译器会对字符串的常量拼接做优化,将字符串常量拼接替换为StringBuilder,提高了代码性能。但是开发中,最好还是根据业务场景确定使用类型。

StringBuffer:跟随Java1.0发布存在,对象是可变的,可以进行字符串的追加、删除等操作。所有方法使用 Synchronized 方法修饰,保证线程安全。同样由于同步会导致性能开销,适用于多线程环境下进行字符串操作。

StringBuilder:Java5发布的性能优化,对象也是可变的,并提供了与StringBuffer类似的方法,可对字符串进行处理。非线程安全,所以性能更好。平时使用较多,适用于单线程环境下频繁修改字符串或修改大量字符串的场景。

底层

StringBuffer 和 StringBuilder 都继承了 AbstractStringBuilder 抽象类,底层都是利用可修改的char数组实现(Jdk 9是byte数组),其大致流程为:

1:使用字符数组存储字符序列 char[],(String底层也是char数组,但也是不可变的)。

2:使用方法append()、insert()等操作,是直接修改内部的字符数组,不会创建新的对象。

3:操作字符串时,如果当前数组容量不足,会优先以2倍+2容量扩展数组长度,减少扩展次数,提供性能。

扩展

StringBuilder 底层是使用char数组存储数据。而数组是连续内存结构,为了避免频繁扩容复制和申请内存,可以在创建时传入capacity(数组大小,默认16),减少扩容次数,提升效率

具体新增逻辑:在调用append方法时,先判断转成char需要占数组的几位,然后计算现在的数组容量是否足够,如果不够就扩容。随后将数据转换成char放到数组中,并更新字符数。

扩容就是使用 Arrays.copyOf(),进行拷贝加扩容,扩容后的数组容量为:原容量的两倍 + 2。但当扩容后也不够时,则会直接扩容到恰好能容纳所有数据的最小值。


另外,在Jdk 9中,底层的char数组优化为 byte数组 和一个 coder 标志位,更加节省内存。其中coder=0 表示字符串仅包含 Latin-1 字符(单字节编码),coder=1 表示包含 UTF-16 字符(双字节编码)。

追加字符时,若遇到无法用当前编码表示的字符(如 Latin-1 模式下追加中文字符),会触发编码转换:将整个 byte[] 转换为 UTF-16 格式(双字节),并更新 coder=1。此过程可能伴随扩容和内存复制。

Latin-1 编码下每个字符占用 1 字节,相比 char[](每个字符 2 字节)可减少 50% 的内存占用,但对非 Latin-1 字符会回退到双字节。

三:异常

总:Java中的异常程序是管理程序运行时错误的重要机制,在日常开发中,也经常遇到代码异常的问题(空指针,微服务找不到,数组越界,sql语句有问题等),以及指定业务抛出错误提示等。

Throwable是所有异常和错误的基类,其下有分为 Error 和 Exception 两个子类。异常的处理格式为:try-catch-finally,finally代码块为最后执行(无论是否异常)。

finally这个可能会问:是否总会被执行?正常情况下是的,除非有以下几种特殊情况:

1:在try/catch中调用 System.exit(),终止Java的虚拟机。2:线程被终止。3:JVM崩溃。

1:Error

指程序无法处理的报错,严重错误,例如OutOfMemoryError,出现这个报错时,程序将被迫停止运行。

2:Exception

指程序可处理的异常,也是平时遇到比较多的。其下又分为 CheckedException(检查异常) 和 RuntimeException(运行时异常),发生在不同的环节:

CheckedException:异常发生在代码编译阶段,Java编译器会去强制程序对此类异常进行处理: try-catch 或 throws 上抛,但是注意,如果子类重写父类的方法上没有抛出,则子类必须自己处理。常见的异常有:

  • SQLException:数据库操作失败(如连接断开、SQL 语法错误)。
  • IOException:文件流相关异常,文件流操作失败。
  • FileNotFoundException‌:IO异常子类,文件路径不存在或无法访问。
  • ClassNotFoundException:尝试通过字符串类名加载不存在的类。
  • InterruptedException‌:线程在等待、睡眠时被中断。

RuntimeException:发生在程序运行过程中,会导致程序当前线程运行失败。无须显式处理,但出现该异常,则表示代码逻辑错误,程序需要优化处理。常见异常有:

  • NullPointerException:空指针异常,尝试访问空对象的方法或属性,处理是代码添加判空。
  • IndexOutOfBoundsException:集合或字符串下标越界,获取长度外的索引数据。
  • ClassCastException:对象类型强制转换不兼容,如强转接收类。
  • ‌NumberFormatException‌:转数值格式不合法,例如String转int,处理是可使用工具类。
  • ArithmeticException:数学运算错误(如除以零)‌,int result = 5 / 0。

3:异常优化

Java在后续版本中对异常也做了优化,主要是资源释放和多个异常捕获。

在Java1.7及以后,可使用 try-with-resources 语法,自动关闭资源,不用再手动设置finally块释放资源,减少了冗余代码,简化操作。注意需实现 AutoCloseable 接口。

try (FileInputStream file = new FileInputStream("file.txt")) {
    // 使用资源
} catch (IOException e) {
    e.printStackTrace();
}

同样在1.7版本,优化了多异常捕捉。不用像之前写多个catch,相同处理逻辑的可放在一起。

catch (IOException | SQLException e) {
    e.printStackTrace();
}

这是系统层面的优化,而对于我们开发中,对异常的处理也要规范运用和优化。

1:首先是尽量避免异常,例如空指针这些,提前判空处理,就不会发生。开发人员平时应该写出高质量代码,避免一些逻辑bug。

2:尽量捕捉具体的异常,而不是直接用Exception顶级异常。会造成代码逻辑不清晰,且可能捕捉到预期之外的异常,不好排查问题。

3:不要吞了异常,捕获到异常之后要做相应处理,例如打个日志,抛出一个报错提示等。如果什么都不做,则线上不能及时地发现问题(还不如不捕捉),最后可能造成更严重地数据问题。且还不好排查(无日志)。

4:try-catch范围尽量小,只需要圈住关键逻辑代码,而不是整个方法。因为try-catch会影响JVM对代码地优化,例如重排序等。

5:不要在 finally 代码块处理返回值和直接return,因为这里会涉及JVM的栈的执行指令,会覆盖返回值或吞掉异常(可在JVM详讲)。

4:Throw 和 throws 的区别

首先,一个是用来具体使用,一个是用来声明可能抛出的异常。

throw:在代码中手动抛出一个异常,需要写在方法内部。抛出后可对齐进行捕获,也可向上传递,由调用者处理。开发中,可根据业务场景抛出指定异常或自定义异常提示(将底层异常转为业务异常)。

throws:在创建方法时声明可能抛出的异常,需要写在方法参数列表之后。声明后由调用者处理。子类重写父类的含异常声明的方法时,不能抛出比父类异常的上级或其他无关联的异常。

class Parent {
    public void doSomething() throws IOException { }
}

class Child extends Parent {
    @Override
    public void doSomething() throws Exception { // ❌ 编译错误:Exception是IOException的父类
        // ...
    }

    @Override
    public void doSomething() throws SQLException { // ❌ 编译错误:SQLException是新的Checked异常
        // ...
    }
}

5:自定义异常

在Java中,自定义异常是通过继承现有异常类来实现的,通常用于表示特定业务场景或系统逻辑中的错误。统一异常规范,将系统错误和业务报错区分开来,提高系统可维护性,简化开发流程,便于排查问题。

自定义异常的创建步骤:

1:选择继承的父类,继承父类Exception 或 RuntimeException,常使用运行时异常。

2:命名规范推荐以 Exception 结尾,如果安装有阿里规范插件,会标黄提示。

3:提供构造器,可传递参数并使用super调用父类的构造器实现。

4:添加业务数据,如单号,错误代码,时间戳等,便于记录及后续排查。

public class PaymentFailedException extends RuntimeException {
    private String orderId; // 自定义字段:订单ID
    private int errorCode; // 自定义字段:错误码

    // 构造器
    public PaymentFailedException(String message, String orderId, int errorCode) {
        super(message);
        this.orderId = orderId;
        this.errorCode = errorCode;
    }

    // Getter方法
    public String getOrderId() { return orderId; }
    public int getErrorCode() { return errorCode; }
}

开发中,避免自定义异常过多,会再次导致异常混乱。

保持简洁,避免添加过多字段,导致异常类臃肿。

可根据场景传递并保留原始异常,便于后续排查。

通过合理设计自定义异常,可以显著提升代码的可读性和可维护性,同时为调用者提供清晰的错误处理入口。

四:注解

Java注解是Java 5引入的一种元数据处理机制,用于为代码添加额外的信息,这些信息可以被编译器、工具或者运行时框架读取和处理。说白了就是在引入注解的地方做一些事情,简化代码。注解本身不直接影响代码逻辑,但可以用于生成代码、配置框架行为、静态检查等场景。

注解有Java自带的,框架中提供的,或自定义的。在代码中随处可见,非常简便,使用频率很高。

1:注解概念

注解是一种以 @注解名 形式,依附在类、方法、字段、参数等代码元素上的标记,用以做一些查验和处理。注解的处理形式可总体分为三个阶段:

  1. 编译时处理:生成代码。例如Lombok注解(@Data、@Getter、@Setter)。
  2. 运行时处理:通过反射获取注解信息。例如Spring注解(@Autowired、@Component、@RequestMapping等),Jackson实例化注解(@JsonProperty),Swagger注解(@ApiModel、@ApiOperation),JUnit单元测试注解(@Test)等。
  3. 静态检查:编译器验证代码是否符合规范。例如验证重写方法(@Override),过时方法标记(@Deprecated),参数判空(@NonNull)等。

以上都为简化代码配置,如果让我们代码实现也很容易做,但是调用多了,就会造成代码冗余,重复逻辑过多。并且不如注解清晰,便于代码维护和扩展。

2:元注解

元注解是用于定义其他注解的行为,需写在其他注解的上面。

常见的有:

1:@Retention‌:指定注解的保留策略。有三个枚举可选,可做了解。

//RetentionPolicy.SOURCE:表示仅源码保留(编译后丢弃,例如Lombok注解)。
//RetentionPolicy.CLASS:保留到字节码(默认,运行时不可见)。
//RetentionPolicy.RUNTIME:运行时保留(可以通过反射读取)。
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotation { ... }

2:Target:指定注解可以应用的目标元素类型。

//ElementType.TYPE:类、接口、枚举
//ElementType.METHOD:方法
//ElementType.FIELD:字段
//ElementType.PARAMETER:参数
//ElementType.CONSTRUCTOR:构造函数
//其他如ElementType.ANNOTATION_TYPE(注解类型)等。

@Target({ElementType.METHOD, ElementType.TYPE})
public @interface MyAnnotation { ... }

包括上面说的@Documented‌,以及@Inherited标注类是否可被继承等。

3:自定义注解

根据开发中的业务场景,也可以把用的频繁的或者特殊业务配置成自定义注解的形式,在指定位置添加使用。创建时选择annotation可带出,同样使用 @interface 关键字,可在内部设置属性。

//1:创建(注意Target设置使用权限)

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface annotationTest {

    //该种方式在使用时不能传递参数赋值
    String message = null;

    //可设置默认值,参数后需设置括号
    String name() default "张三";

    //可设置默认值,参数后需设置括号
    String address();

    
}

//2:使用
@annotationTest(name = "里斯", address = "北京")
public class StudentEntity {
    //...
}

在开发中,还可以通过反射获取注解信息,但是不推荐,影响性能。

Method method = obj.getClass().getMethod("testMethod");
if (method.isAnnotationPresent(MyAnnotation.class)) {
    MyAnnotation annotation = method.getAnnotation(MyAnnotation.class);
    System.out.println("Name: " + annotation.name());
}

自定义注解结合反射或注解处理器可实现灵活的功能扩展,处理一些相同的业务场景。

五:泛型

Java中的泛型是比较重要的一个特性,泛型的作用是在编译时检查类型安全,允许开发人员编写更通用和灵活的代码,避免在运行期出现类型转换错误。

泛型是Java 5引入的,在这之前是没有这个特性的,例如 List 以前的使用:

List list = new ArrayList();
list.add("yes"); // 加入string
list.add(233); // 加入int


String str = list.get(0);
String str = list.get(1);  //报错

这样插入数据时不会报错,都是当作Object类型。但是在获取数据时,会要求我们强转数据。那么此时可能就会发生类型转换异常 ClassCaseException 。

所以Java引入泛型,约束参数类型,在编译期就可以识别不符合类型的数据,提前抛出错误提示。并且在获取数据时无需再显式转换。

注意:Java的泛型只在编译时生效,JVM运行时泛型被擦除了,获取不到泛型信息(可在运行中使用反射插入不符合泛型的数据验证)。

1:泛型类

在开发中,有时需要考虑代码通用,例如请求接口或接口返回的报文格式。或业务场景推送数据时,传入不同的对象。此时我们也可以自己定义泛型类,在创建类时传入类型并使用。

括号内时泛型参数,这个参数名字是自己定义的,下面可以使用。

public class Response<T> {
    private T content;
    public void setContent(T content) { this.content = content; }
    public T getContent() { return content; }
}


//使用
Response<String> response = new Response<>();
response.setContent("Hello");

例如我之前有一个推送需求,根据场景不同要传入不同的对象。那么我在创建类的时候,可以定义泛型为 Object 类型即可,很灵活。

2:泛型方法

不仅仅可以定义泛型类,还可以定义泛型方法,使得方法能够处理多种不同的数据类型。使用格式是:在方法返回类型前声明类型参数。

例如一个方法需要返回不同对象。但是注意实际使用时,这样反而不利于获取对象,要先判断返回的对象类型,然后强转。这里用class做一个演示,根据传入参数可以确定返回类型。

public <T> T createInstance(Class<T> clazz) throws Exception {
    return clazz.getDeclaredConstructor().newInstance();
}

// 使用示例
String str = createInstance(String.class); // 新建空字符串
Date date = createInstance(Date.class); // 新建Date对象

包括可以设置键值格式泛型。

public <K, V> void printMapEntries(Map<K, V> map) {
    for (Map.Entry<K, V> entry : map.entrySet()) {
        System.out.println(entry.getKey() + ": " + entry.getValue());
    }
}

// 使用示例
Map<String, Integer> scores = new HashMap<>();
scores.put("Alice", 95);
printMapEntries(scores); // 输出: Alice: 95

3:通配符

指在泛型中添加符号,用于对泛型类型参数进行范围限制。包含无界限定符、上界限定符和下界限定符。

无界限定符:表示未知类型,使用格式为:在泛型内添加 ? ,该种类型只支持读取为Object,为最大的类型。

public void printList(List<?> list) {
    for (Object elem : list) {
        System.out.println(elem);
    }
}

 上界限定符:使用格式为 <? extends T> ,表示只接受T及其子类型。通常用于读取操作,确保可以读取为T或T的子类的对象。一般直接使用T进行读取,这是比较稳妥安全的。

void processNumbers(List<? extends Number> list) {
    Number num = list.get(0); // 安全读取
}

上界限定符一般用来读取,因为即使限制了指定类及其子类,但在添加元素时,会违反类型安全性(不知道哪个子类),例如往LIst添加元素。

List<? extends Number> numberList = new ArrayList<>();
        
//会编译错误:不能向上界类型中添加元素
//numberList.add(10);


 下界限定符:使用格式为 <? super T> ,接受T及其父类型。通常用以写入操作,确保可以安全地向泛型集合中插入指定类型的对象。

void addIntegers(List<? super Integer> list) {
    list.add(42); // 安全写入
}

下界限定符一般只用来写入,因为虽然限制了添加元素时必须是T或其父类。但在读取时,无法确定是哪种具体类型,也就不能用上面传入的类型接收(T)。

List<? super Integer> integerList = new ArrayList<>();
integerList.add(10); // 正确,可以添加 Integer 类型
integerList.add(20);

//会编译错误:虽然是 Integer 的父类,但不能保证是 Integer 类型,因此不能读取为 Integer
//Integer num = integerList.get(0);

上下界限定符的设计,为我们提供了在某个范围内的泛型参数类型设置,可以确保在不同场景下使用泛型时既能获得灵活性,又能保证类型安全。


‌PECS原则:Producer-Extends, Consumer-Super,指的是对开发人员的一种使用规范。

‌Producer(生产者)‌:使用<? extends T>,只能读取数据。

‌Consumer(消费者)‌:使用<? super T>,可写入数据。

这样规范使用,可以确保我们对泛型的使用是灵活和安全的。

4:泛型擦除

指的是Java编译器在编译时将所有泛型信息删除的过程,以确保和Java 1.4及之前的版本保持兼容(1.5引入泛型)。这里引出另一个概念:泛型边界。

泛型边界:编译器会根据泛型的上下界执行擦除,所有泛型默认都会替换为 Object 类型。当设置了上界限定符时,会替换为边界类型(父类);而下界限定符是要求必须为T或其父类,所以替换为Object(所有类最终父类)是安全的。

// 编译前
List<? extends Number> list = new ArrayList<Integer>();

// 编译后
List list = new ArrayList(); // 视为 List<Number>

擦除的影响:确保了代码的兼容性,但也限制了在运行时对泛型的操作,比如运行时获取泛型类型,会对其使用 instanceof 检查。

在我们运行时 get 数据时,不需要强转是因为编译器隐性的帮我们插入了强转的代码(字节码)。

5:协变和逆变

用来描述类型之间的兼容性关系(父子类)。

协变:子类型可以替换父类型,当一个泛型对象或方法参数类型,允许子类替换父类时(上界限定符),就是协变。

class Animal {}
class Dog extends Animal {}

//创建子类对象,触发协变   子类型(Dog)替换父类型(Animal)
List<? extends Animal> animals = new ArrayList<Dog>();

逆变:指的是父类型可以替换子类型,当一个泛型对象或方法参数类型,允许父类替代子类时(下界限定符),就是逆变。

class Animal {}
class Dog extends Animal {}

//逆变  父类型(Animal)替换子类型(Dog)
List<? super Dog> dogs = new ArrayList<Animal>();

所以,其实这里本质也就是通配符的运用,他们两者使用场景不同:

协变主要解决返回值的灵活性问题,允许更具体的子类对象返回。

逆变主要解决参数传递的灵活性问题,允许更加广泛的类型传入。

6:其他优化

避免重载冲突‌:类型擦除后相同签名的方法会导致编译错误。

Java 7 引入钻石操作符,我们通常叫泛型折叠,语法为<>,允许在实例化泛型类时省略右侧的泛型类型参数,由编译器自动推断。减少冗余代码,提升可读性。

// Java 5~6:必须显式指定泛型类型
List<String> list = new ArrayList<String>();

// Java 7+:使用钻石操作符简化
List<String> list = new ArrayList<>(); // 编译器自动推断为 ArrayList<String>

Java 8 进一步‌增强了泛型方法的类型推断能力,尤其是在方法调用链和 Lambda 表达式中。

// Java 8+:方法调用中的类型推断
List<String> list = Collections.emptyList(); // 无需显式指定类型

Java 9 允许在匿名类中使用钻石操作符。

// Java 9+:匿名类支持 <>
List<String> list = new ArrayList<>() {}; // 合法

六:接口和抽象类的区别

面试常问,先介绍共同点,再介绍不同点,以及应用场景和为什么用。

1:两者共同点

1.实例化:接口和抽象类都不能直接实例化,只能被实现或继承后才能创建具体的对象。更加灵活,便于扩展和维护(引出多态)。

2.抽象方法:接口和抽象类都可以包含抽象方法。抽象方法没有方法体,必须在子类或实现类中重写。

2:两者不同点

从代码自上而下,描述不同的地方,随后描述使用时有什么不同。

接口使用interface关键字定义,同一个类可以实现多个接口。接口类似协议,用来指定规范,统一行为。

接口不能包含构造函数,接口中的成员变量默认为public static final,即常量。

接口中的方法默认为抽象方法,在Java 8之后,可以设置default方法和静态方法。


抽象类使用abstract关键字定义,抽象类遵循类的单继承原则。抽象类可看作模板,可用于公共部分抽取,提高代码复用,便于子类扩展。

抽象类可以包含构造函数,抽象类中的成员变量可以有不同的访问修饰符,并且可以不是常量。

抽象类中的方法可以为任意类型。

3:接口方法描述

接口的静态方法,用于集中处理与接口强相关的逻辑,通过接口名直接调用,简化调用。

public interface Logger {
    void log(String msg);
    
    static Logger getFileLogger() {
        return new FileLogger(); // 返回具体实现类
    }
}

// 使用静态工厂
Logger logger = Logger.getFileLogger();

接口的默认方法,用于接口的向后兼容,避免破坏现有实现类。新增方法时不强制修改所有子类。

同一个接口中可定义任意数量的default方法,只要方法名和参数列表不同即可。

default方法可重写,子类重写时,访问修饰符必须为public,不写默认或非public会导致编译错误。

菱形继承问题

如果一个类实现了多个接口,而这些接口有相同签名的默认方法,此时需要在实现类中强制重写该方法来解决冲突,指定调用哪个接口的方法。

interface A { default void foo() { System.out.println("A"); } }
interface B { default void foo() { System.out.println("B"); } }

class C implements A, B {
    // ██ 必须重写冲突方法
    @Override
    public void foo() {
        A.super.foo(); // 明确指定调用A接口的默认方法
    }
}

4:其他细节

如果想在类中定义抽象方法,则该类必须为抽象类。

抽象类的子类必须重写抽象方法,除非子类也是一个抽象类。

Java 9引入私有方法,允许接口中定义private方法,用于default方法的内部逻辑复用补充。

Java 14引入sealed接口,进一步增强了接口的功能。

七:反射

反射最重要的两个特性,运行时和动态,要提到这两点。讲解顺序为:创建class、创建实例、Jdk版本变化影响。

Java 的反射机制是指在运行时获取类的结构信息(如方法、字段、构造函数)并操作对象的一种机制,而无需在编译时知道这些类的具体信息。

在框架中用的较多,因为很多场景需要很灵活,不确定目标对象的类型,届时只能通过反射动态获取对象信息,例如:

  • Spring使用反射机制来创建Bean对象,从而实现依赖注入和面向切面编程等功能。
  • JDK动态代理通过反射机制,在运行时动态地创建代理对象。

反射缺点:影响性能,尤其是高并发场景。降低代码可读性,不利于DeBug等。

优化方案:设计系统时,尽量使用更稳定和易于维护的设计方案,只有在确实需要时才使用反射。

可以将第一次通过反射获取的方法对象(Method 对象)缓存起来。之后再需要调用相同的方法时,可以直接从缓存中获取方法对象,从而避免再次进行动态解析和加载的过程。这种方法可以显著提高性能,尤其是在高并发环境中。

1:创建Class对象

常用的有三种核心方式,对于JVM的初始化阶段有不同处理。

// 1. 通过 .class 语法直接获取
Class<String> stringClass = String.class;

// 2. 通过对象实例的 getClass() 方法
String str = "Hello";
Class<?> strClass = str.getClass();

// 3. 通过 Class.forName() 动态加载类
Class<?> clazz = Class.forName("java.lang.String");

选择方式时优先考虑 .class 语法(类型安全),动态场景使用 Class.forName(),反射框架中常用 getClass()。

其中:.class方式不需要实例,不会立即初始化(静态代码块和变量不赋值),适用编译期已知类。

对象.getClass()需要实例,且类已被初始化,适用于通过实例反向获取Class对象。

Class.forName()不需要实例,默认会触发初始化,适用于动态加载未知类(全路径)。可设置initialize为false,实现默认不初始化,但需确保传入的 ClassLoader 能正确找到目标类。

//1:使用当前类加载器且不初始化类
ClassLoader loader = MyClass.class.getClassLoader();
Class<?> clazz = Class.forName("com.example.MyClass", false, loader);

//2:使用当前线程的类加载器且不初始化类
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
Class<?> clazz = Class.forName("com.example.MyClass", false, loader);

ClassLoader.getSystemClassLoader() 和 Thread.currentThread().getContextClassLoader()区别是,前者是固定启动时创建的类加载器,后者为当前线程的可动态调整的类加载器。

补充:

基本数据类型‌:可通过 int.class, double.class 直接获取。

数组类型‌:使用 String[].class 或 Class.forName("[Ljava.lang.String;")

扩展:

可通过类加载器 classLoader.loadClass() 获取到CLass对象。会绕过初始化阶段‌:仅执行加载 -> 验证 -> 准备阶段,不执行静态代码块和静态变量赋值。

// 通过类加载器加载类(不触发类初始化)
ClassLoader classLoader = ClassLoader.getSystemClassLoader();
Class<?> clazz = classLoader.loadClass("com.example.MyClass");

不同类加载器加载的同名类被视为不同类(可使用自定义类加载器)。

适用于预加载大量类但不立即消耗资源。

需注意:谨慎打破双亲委派模型、及时清理无用的ClassLoader防止内存泄漏


2:获取对象实例

反射动态创建实例的 3 种核心方式,注意跟随jdk版本有变化。

// ▍1. 通过 Class.newInstance()(已过时,仅作历史参考)
Class<?> clazz = Class.forName("com.example.User");
Object instance = clazz.newInstance();  // 要求无参构造方法为public

// ▍2. 推荐:Constructor.newInstance()(当前标准方式)
Constructor<?> constructor = clazz.getDeclaredConstructor(String.class, int.class);
constructor.setAccessible(true);  // 突破private限制
Object user = constructor.newInstance("Alice", 28);
//获取public构造方法(必须显式声明为public)
Constructor<User> publicCon = clazz.getConstructor(String.class);   

// ▍3. 特殊场景:Unsafe类(规避构造方法)
Unsafe unsafe = Unsafe.getUnsafe();
User user = (User) unsafe.allocateInstance(User.class);  // 不执行任何构造方法

第一种:class.newInstance(),于JDK9中被标注@Deprecated(过时),并于Java17中正式移除。原因是:①其异常混乱且无法处理受检异常,②强制要求public无参构造

第二种:constructor.newInstance(),目前的标准方法,以及Java8之后的替换方法。其中,一般使用 getConstructor() 方法为调用显式public构造方法,getDeclaredConstructor() 为调用所有的构造方法(包含私有构造方法,需设置突破访问权限)。且此种方式会统一抛出包装真实异常。可引出,Spring创建Bean即为调用无参public构造创建实例。

还需注意:以上两种获取构造器的方法,找不到构造函数时,会抛出NoSuchMethodException。

3:访问字段及方法

反射可获取到类的结构和所有信息,所以也可以调用类下的属性和方法。

//访问字段
Field field = clazz.getField("myField");
field.setAccessible(true); // 允许访问 private 字段
Object value = field.get(obj);
field.set(obj, newValue);

//访问方法
Method method = clazz.getMethod("myMethod", String.class);
Object result = method.invoke(obj, "param");

八:集合

数组在面试时一般用于暖场,大致总体描述下,等待后续提问。Java中的集合框架分为两大核心接口,Collection 和 Map。其中Collection下又分为List,Set和Queue接口。

说是描述集合,也可扩展下底层的存储结构的差异,例如:数组、链表、红黑树的差异。

1:时间空间复杂度

Java中,时间复杂度用于描述算法执行时间随数据规模增长的变化趋势,是衡量算法效率的核心指标。空间复杂度衡量算法运行过程中临时占用的存储空间大小,包括变量、递归调用栈等‌。

时间复杂度:

可分为基础时间复杂度定义,和进阶时间复杂度类型,常会问基础的时间复杂度效率对比(可在回答数组问题时引出)。

效率排序‌:O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(2ⁿ) < O(n!)‌

基础时间复杂度定义描述及场景
O(1) 常数时间复杂度

算法的执行时间与输入规模无关,无论数据量多大,操作耗时固定。

‌示例:数组随机访问(如array[i])、哈希表查找(无冲突时)。

O(log n) 对数时间复杂度

执行时间随数据规模按对数增长,常见于分治算法。数据量翻倍时耗时仅增加固定步数。

示例:二分查找、分治算法‌。

O(n) 线性时间复杂度

执行时间与输入规模成正比,数据量增大几倍,耗时也增大几倍。

示例:遍历数组或链表、简单搜索未排序的列表‌。

 进阶时间复杂度类型,做一个了解,面试时能想起来就说,不是很重要。

进阶时间复杂度类型描述及场景
O(n log n) 线性对数时间复杂度

耗时与数据规模及其对数乘积相关,常见于高效排序算法。

示例:快速排序、归并排序‌。

O(n²) 平方时间复杂度

耗时随数据规模平方增长,常见于简单嵌套循环。

示例:冒泡排序、选择排序‌。

‌O(2ⁿ) 指数时间复杂度

耗时呈指数级增长,通常用于穷举类问题。

示例:斐波那契递归。

O(n!) 阶乘时间复杂度

耗时随数据规模阶乘增长,常见于全排列问题。

总时间复杂度为O((n−1)!⋅n),即O(n!),适用于极小的n(如n≤10)

示例:旅行商问题的暴力解法‌。

优化方案:优先选择O(1)或O(log n)算法(如哈希表、二分查找)。O(n)或O(n log n)算法适用于数据处理(如遍历、排序)。O(n²)及更高复杂度的算法在大数据场景下性能急剧下降‌。

另外:时间复杂度仅描述增长趋势,具体执行时间受硬件、代码优化等影响。

空间复杂度:

可做了解,常用于算法中的空间衡量,问到的话简单回答下即可。

分为三种类型,但要根据使用场景及代码优化动态确认。

常见空间复杂度类型描述及场景
‌O(1) 常数空间复杂度

算法仅使用固定大小的额外空间,与输入规模无关。

‌示例:无论数组长度如何,操作仅需常量空间‌。

O(n) 线性空间复杂度

算法占用的空间与输入规模成正比。

示例:新建数组的长度为n(动态输入),占用空间为O(n)‌。

O(n²) 平方空间复杂度

算法占用的空间与输入规模的平方成正比。

示例:int[][] matrix = new int[n][n];   // 创建n×n的二维数组

二维数组占用空间为n²级别‌。

其他:递归调用时会占用栈空间,递归深度直接影响空间复杂度。

部分算法通过修改输入数据本身减少额外空间占用,空间复杂度可优化至O(1)。

优先选择O(1)或O(n)的算法(如原地排序、双指针操作),避免创建高维数组或深度递归,防止内存溢出。

可以权衡时空效率‌:某些算法可能通过增加空间复杂度降低时间复杂度(如哈希表加速查找)‌。

2:List

元素按插入顺序保存,允许重复,支持索引查询。

List接口讲解描述顺序定为:数据结构、时间复杂度、初始因子,以及集合的个性化特点。

ArrayList:基于动态数组,查询快O(1),增删慢(需移动元素)。

使用无参构造创建时,在Jdk1.7之前,初始容量为10,而在1.7之后,初始容量为0,在第一次使用时设置为10。

加载因子为1,扩容因子为1.5倍。当一次性添加许多元素,扩容1.5倍也无法容纳时,则新容量取所需最小容量,例如:10+8,会扩容至18。


LinkedList:基于双向链表实现,增删快O(1),查询慢(O(n)),同时实现了Deque接口,可用作队列或栈。

链表是动态数据结构,没有初始容量。没有扩容机制(无限,受限于内存)。如果是头尾插入,时间复杂度为O(1),如果是中间插入为O(n)遍历,例如:list.add(指定索引位置, 5);


Vector:线程安全的动态数组,查询快O(1),插入慢O(n)。Vector初始容量为10,加载因子为1,扩容因子为2。

每个方法都使用synchronized实现线程安全,性能较差,很少使用,已被Collections.synchronizedList 或 CopyOnWriteArrayList取代。(可引出两者区别)。

也可先引出 Collections.synchronizedList 和 Vector 的区别:

首先两者都是基于synchronized实现的线程安全,但是前者性能更好一些且更加灵活。

1:synchronizedList通过装饰器模式包装目标 List,在代码块中使用synchronized锁定内部实例对象,代码块级同步‌,灵活性更高‌;而Vector在方法上使用关键字,方法级同步,可能导致更高的锁竞争。

2:synchronizedList可包装任何List下的实现,更灵活;而Vector是固定的动态数组实现。

3:synchronizedList可搭配ArrayList实现,内存扩容为1.5倍;Vector扩容默认翻倍,可能占用更大的内存开销和浪费。

3:线程安全处理

CopyOnWriteArrayList 和 Collections.synchronizedList 都是数组线程安全的处理方式,分别描述他们的区别和优缺点。

CopyOnWriteArrayList:List下的一个实现类,特性是写时复制。

每次写操作都会创建一个新数组复制元素并加锁,读不加锁,有很好的高并发读性能。适用于读多写少的场景。

// 初始化
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();

// 添加元素
list.add("data1");
list.addIfAbsent("data1");  // 去重添加(Java 8 支持)

// 删除元素
list.remove("data1");

// 替换元素(需明确索引)
list.set(0, "updated_data");

缺点:

1:写性能较差,每次写操作时,需创建新数组并复制元素,时间复杂度 O(n),在数据量大或频繁写入的场景,性能较差。 

2:内存消耗,数据量大的情况下,创建新数组并复制元素,同一时间会存在两倍List大小的内存占用,内存开销较大。


Collections.synchronizedList:包装方法,支持传入 List 接口下的集合,将普通 List 包装为线程安全集合,通过同步代码块(synchronized)‌保证原子性操作。适用于‌读写操作均衡‌的并发场景,或简单将 List 转为线程安全版本临时使用的场景。

// 原始非线程安全列表
List<String> rawList = new ArrayList<>();
// 包装为线程安全集合
List<String> syncList = Collections.synchronizedList(rawList);

// 添加元素(自动加锁)
syncList.add("Task-A");
syncList.add("Task-B");

// 删除元素(自动加锁)
syncList.remove("Task-A");

注意对后续的操作,也要加锁保持原子性,例如遍历数据、判断是否存在并添加。

// 错误用法:非原子性判断-执行
if (!syncList.contains("A")) { 
    syncList.add("A"); // 可能被其他线程插入导致重复
}

// 正确用法:手动同步代码块
synchronized (syncList) {
    if (!syncList.contains("A")) {
        syncList.add("A");
    }
}

禁止直接操作原始列表,会破坏线程安全性。 

缺点为:读写都需要加锁,高并发下性能不高,纯高频读取场景性能低于CopyOnWriteArrayList。


Collections.synchronizedMap:Collections下提供的一个map集合的包装方法,支持传入map接口下的实现类,可将一个普通map集合转换为线程安全版本。

原理是根据传入的map集合创建一个 synchronizedMap() 对象,对象内的m属性为传入的集合,对象内还维护了一个Object类型的 mutex 对象,该对象用于方法内的同步代码块锁对象,实现线程安全。

缺点是锁粒度太大,锁住了整个map,同一时间只能一个线程访问map,高并发时性能下降明显。

其还有个特点,迭代时map被修改,会直接抛出异常:ConcurrentModificationException(并发修改)。

适用于需快速实现线程安全且并发量低的场景,或需要使用允许键值为空的线程安全实现。(不强制非空,跟随具体map实现类特性)。

所以,高并发下还是优先推荐 ConcurrentHashMap 或 CurrentSkipListMap。

4:Set

特点是元素唯一(依赖equals()和hashCode(),可引出两者关系),不允许重复。注意是不保证顺序,实际还是看具体实现类。

Set接口讲解描述定为:数据结构、元素是否有序、时间复杂度、集合的个性化特点。

HashSet:基于哈希表实现。元素无序,不允许重复。插入/查询时间复杂度接近O(1)(冲突少时),遍历时为O(n)。哈希冲突时,链表查询为O(n),红黑树为O(log n)。

底层是使用HashMap存储元素,键为元素本身,值为一个固定占位对象(PRESENT),因此,HashSet仅操作HashMap的键部分,包括插入删除其实也是使用HashMap的方法。

注意具体的特性可以在HashMap讲。

初始容量同HashMap为16,可自定义初始容量,实际容量会被调整为不小于指定值的2次幂;每次扩容翻倍,时间复杂度为O(n)(可抛出锚点,这两是HashMap的优化)。

默认加载因子为0.75(平衡时间与空间效率的折中值)。


LinkedHashSet:基于双向链表和哈希表,不允许重复。时间复杂度同HashSet。

继承于HashSet(底层HashMap),同时维护了一个 LinkedHashMap(底层双向链表),维护插入顺序,迭代时按插入顺序输出(先进先出),不支持自定义排序‌。可根据场景选择 TreeSet 或 LinkedHashMap 替代。


TreeSet:基于红黑树,元素有序,不允许重复。底层是基于TreeMap,红黑树是动态增长的,没有初始容量、加载因子的概念。插入、删除、查找的时间复杂度为 ‌O(log n)‌,红黑树的自平衡。

相比其他两种Set集合,比较关键的一点是支持自定义排序功能。可在创建时默认或指定排序。

//自然排序(元素实现 Comparable)
TreeSet<Integer> numbers = new TreeSet<>();
numbers.add(5);
numbers.add(3);
numbers.add(7);
System.out.println(numbers); // 输出 [3, 5, 7]


//自定义排序(通过 Comparator)
TreeSet<String> words = new TreeSet<>((a, b) -> b.compareTo(a)); // 逆序
words.add("apple");
words.add("banana");
words.add("cherry");
System.out.println(words); // 输出 [cherry, banana, apple]

可通过使用 Comparator 定义排序规则(无需修改 Person 类)。或为类实现 Comparable 接口,重写 compareTo 排序方法。该逻辑同PriorityQueue。

如果创建时未指定排序,且插入对象未实现接口时,会抛出ClassCastException。

适用于需要元素有序‌且支持范围查询(subSet(), headSet())的场景,及数据规模较大但需保证高效查找和有序遍历的场景。

不适用于需要极高频插入/删除操作的场景,及内存敏感的场景,树的内存开销高于哈希表。

5:Queue

Queue(队列)是 Java 集合框架中定义的一种数据结构,遵循先进先出(FIFO) 的基本规则,但同时也支持更灵活的变体(如双端队列、优先级队列、阻塞队列等)。大多数实现类按插入顺序处理元素,可运用于需要有序处理元素的场景。

阻塞队列在多线程中使用较多,线程池工厂底层会使用队列。

PriorityQueue:元素按优先级排序,队头始终是极值(最小或最大),默认最小。非线程安全。

可使用 Comparator 定义排序规则,或为类实现 Comparable 接口,重写 compareTo 排序方法。

//默认排序,最小,输出 3
Queue<Integer> pq = new PriorityQueue<>();
pq.offer(5);
pq.offer(3);
pq.offer(7);
System.out.println(pq.poll());


//创建 PriorityQueue,未提供 Comparator
PriorityQueue<Person> pq = new PriorityQueue<>();
//插入 Person 对象(未实现 Comparable)
pq.offer(new Person("Alice", 30)); // 抛出 ClassCastException
pq.offer(new Person("Bob", 25));

//解决方式1:创建时指定排序
//使用 Comparator 定义排序规则(无需修改 Person 类)
PriorityQueue<Person> pq = new PriorityQueue<>(
    Comparator.comparingInt(p -> p.age) // 按年龄升序
);

//解决方式2:为类实现 Comparable 接口
class Person implements Comparable<Person> {
    @Override
    public int compareTo(Person other) {
        return Integer.compare(this.age, other.age); // 按年龄升序
    }
}

LinkedList‌:双向链表,可作为普通队列或双端队列(实现 Deque 接口),插入删除高效(O(1)),适用于需要快速插入/删除的 FIFO 场景。

Queue<String> queue = new LinkedList<>();
queue.offer("A");
queue.offer("B");
System.out.println(queue.poll()); // 输出 A

BlockingQueue:

BlockingQueue 是一个‌线程安全的队列接口‌,专为多线程环境设计,提供‌阻塞式入队/出队操作‌,简化了生产者-消费者模型的实现。

其所有操作(如插入、删除)都是原子的,无需额外同步代码。常用实现类:

  1. ArrayBlockingQueue:有界数组,基于数据实现,创建时需指定固定容量。
  2. LinkedBlockingQueue:可选择有界或无界(默认 Integer.MAX_VALUE),基于链表,吞吐量通常更高。
  3. PriorityBlockingQueue:支持优先级排序的无界队列,元素需实现 Comparable 接口。
  4. SynchronousQueue‌:容量为 0 的队列,每个插入操作必须等待一个移除操作。
  5. DelayQueue‌:无界队列,元素需实现 Delayed 接口,按到期时间排序。

提供了阻塞方法和非阻塞方法,可根据场景选择不同方法。

方法名描述
void put(E e)阻塞方法,插入元素,队列满时阻塞线程。
E take()阻塞方法,取出元素,队列空时阻塞线程。
boolean offer(E e)非阻塞方法,插入元素,成功返回 true,队列满时立即返回 false。可传入时间参数。
E poll()非阻塞方法,取出元素,队列空时返回 null。可传入时间参数。
E peek()查看队首元素(不删除),队列空时返回 null。
int remainingCapacity()返回队列剩余容量(近似值)。

可用来自动处理线程等待与唤醒,无需手动使用wait、notify方法。

BlockingQueue<Task> queue = new LinkedBlockingQueue<>();

// 生产者
public void produce(Task task) throws InterruptedException {
    queue.put(task); // 队列满时阻塞
}

// 消费者
public Task consume() throws InterruptedException {
    return queue.take(); // 队列空时阻塞
}

注意使用时,通过有界队列限制系统最大并发请求数,避免资源耗尽。

所有阻塞方法都会抛出 InterruptedException,妥善处理线程中断(如恢复中断状态)。

Java线程池(如 ThreadPoolExecutor)默认使用 BlockingQueue 存储待执行任务。

6:HashMap

Map集合存储的是键值对,每个元素有一个对应的key,可根据key获取到具体的value。

哈希表数据格式图:哈希表底层数组初始化时均为null,插入数据时,根据计算的哈希值找到数组下标,如果为null则在该节点插入数据,具体看是从首尾插入,注意以前的null会被保留;如果不为null表示已有数据,需处理哈希冲突。

哈希冲突的几种处理方式:开放寻址法,再哈希法,链表地址法,建立公共溢出区等。

抛出锚点,Java8的优化:哈希表、插入顺序、函数计算、扩容机制的优化。

基于哈希表,键值对无序,键值可为空,不允许键重复。查询新增操作在冲突较少的情况下,时间复杂度接近O(1),最坏为O(n)或O(log n)。初始容量16,加载因子0.75,扩容翻倍。

初始容量:初始容量为16,可自定义初始容量,实际容量会被调整为不小于指定值的2次幂。

加载因子:默认负载因子为0.75是为了在时间复杂度空间复杂度之间取得一个合理的平衡,负载因子为 0.75 时,避免过多扩容的同时,也保证了不会出现过多的哈希冲奕,确保查找和插入操作的效率,维持良好的性能表现。


主要内容围绕HashMap在Jdk1.8中,做了几个重点优化,提升了效率并解决了一些问题。

哈希表结构调整:

注意:只是针对HashMap的哈希表进行调整,并非调整所有的哈希表结构。

哈希冲突:不同键值经哈希函数计算后,得到相同数组下标,此时应如何处理。

链地址法(拉链法)‌ ‌是主流方案:冲突元素以链表或红黑树形式存储在同一个哈希桶中。

1.8之前使用链表解决哈希冲突,但当数据量大时,链表的操作时间复杂度从O(1)退化为O(n)。

1.8之后使用链表+红黑树处理,当链表长度大于等于阈值(默认8),并且数组长度大于等于64时,链表转换为红黑树,红黑树的操作时间复杂度为:O(log n);当链表长度小于等于6时,恢复链表(此时可进一步详细介绍设计理念)。

1:链表大于等于阈值8,是为了时间与空间的平衡,默认负载因子的情况下,链表长度为8的概率较小。并且红黑树占用内存较大,不过早转换也能节省一部分空间。

2:数组长度大于等于64,这个很多人容易忽略。当数组长度不够时,哈希冲突的概率就会加大,等待其发生扩容后,哈希冲突的概率自然会减少。

这样设计会避免提前树化,紧接着发生扩容,造成频繁树化性能浪费。

树结构占用内存大,尤其是节点较少的场景,所以用数组长度控制也能节省一些内存空间。

3:不直接使用红黑树的原因,同上,树结构内存较大,为了节省内存空间。

4:长度小于等于6才恢复链表,变链表是为了省内存空间。没有立即转变是为了留个缓冲,避免频繁切换结构,性能开销。

在JDK1.8中,HashMap通过静态常量定义了红黑树转换相关的阈值参数,这些参数均为final修饰的不可变值。‌若需修改默认阈值,必须通过反射机制强制修改常量值‌,但官方不推荐此操作,可能引发性能问题或破坏数据结构的稳定性‌。

//TREEIFY_THRESHOLD      链表转红黑树阈值,默认8
//UNTREEIFY_THRESHOLD    红黑树转链表阈值,默认6
//MIN_TREEIFY_CAPACITY   最小树化容量,默认64

Field field = HashMap.class.getDeclaredField("TREEIFY_THRESHOLD");
field.setAccessible(true);
//移除 final 标志,比强行修改final字段要好(破坏Java语言)
field.setInt(field, field.getModifiers() & ~Modifier.FINAL);
//第一个参数null,表示无需对象实例(静态变量)
field.setInt(null, 新阈值/容量);

1.8优化后,主要优化哈希计算元素移动。关键就在于数组的长度是保持2的倍数。所以上述初始容量和扩容因子的设置,主要是为了提高哈希值的分布均匀性和哈希计算的效率。

索引计算:1.8中,HashMap通过 (n - 1) & hash (在数据初始插入、扩容时计算都会用到)来计算元素存储的位置,这种位运算只有在数组容量是2的n次方时才能确保索引均匀分布。位运算的效率高于取模运算(hash%n),提高了哈希计算的速度。 

数据插入顺序:在1.8之前,在数组下的链表插入数据时,使用头插法,即在链表头部插入数据。好处是插入不用遍历链表,缺点是扩容时会逆序,而逆序在多线程扩容下可能出现死循环(主要原因是e和next指针互相指向)。1.8修改为尾插法,扩容后顺序保持一致,避免该问题。

优化函数计算:发生在插入元素时,用于计算键的哈希值。在Java8中优化了哈希函数计算,使用扰动函数(对原始哈希码进行二次处理,检查高位是否为1),使哈希值的分布更均匀,减少了哈希冲突概率。随后使用(n - 1) & hash 位运算计算存储位置,该优化是使用2次幂的主要原因之一

扩容机制优化:优化改动比较重要的一部分,分为索引计算和元素移动。触发扩容时,会在原容量上乘2,之后重新计算元素哈希值,并移动到新数组中。其中确认新索引位置,该优化是使用2次幂的另外一个原因

1.7及之前,是一个个重新计算hash,然后移动过去,数据量大时,会有较大的性能开销。

1.8及之后,扩容时通过数组长度的二进制判断哈希值的高位信息((e.hash & oldCap) == 0)确定新索引位置,无需重新计算完整的哈希值,会将时间复杂度从 O(n) 降低至 O(1)。

若高位为0,新索引 = 原索引; 若高位为1,新索引 = 原索引 + 原容量值(oldCap)‌。

该优化可判断出,不需要移动位置的元素,以及需要移动的元素的位置(索引加老数组长度)。也就是说数组中的元素在扩容时,可能只需要移动一部分,该优化减少了扩容时的性能开销。

元素移动:扩容时,将原链表拆分为 lo链表(低位链表)和 hi链表(高位链表),分别放入新数组的 原索引 和 原索引 + oldCap 位置。保持链表顺序,对比 JDK7 的倒置链表操作,减少了指针操作的开销。

HashMap的优化方案

预估数据量,创建时设置合理的初始容量,避免多次扩容。

根据业务场景动态调整负载因子:负载因子小则冲突少,提高读取速度,但频繁扩容会造成性能开销。负载因子大则减少扩容次数和内存消耗,但发生哈希冲突概率变大,可能影响查询速度。

避免使用质量不高的哈希函数,确保哈希值均匀分布。

7:Map

Map的讲解顺序定为:数据结构、是否有序和重复、时间复杂度、初始容量、个性化特点。

LinkedHashMap:基于双向链表和哈希表,有序,键值可为空,不允许键重复。初始容量及时间复杂度同HashMap,默认O(1),极端情况下O(n)或O(log n),维护顺序不影响时间复杂度。

直接继承了HashMap类,同时添加双向链表维护元素顺序。顺序维护默认插入顺序,可通过参数设置为访问顺序,访问顺序可实现 LRU缓存机制‌。

同 HashMap 一样是非线程安全的,可通过 Collections.synchronizedMap 包装实现线程安全。

// 默认按插入顺序维护
LinkedHashMap<String, Integer> map = new LinkedHashMap<>();
map.put("A", 1);
map.put("B", 2);
map.put("C", 3);

// 遍历输出顺序:A → B → C
for (Map.Entry<String, Integer> entry : map.entrySet()) {
    System.out.println(entry.getKey() + ": " + entry.getValue());
}

可在创建对象时,传参 accessOrder=true 开启访问顺序,每次get操作会将元素移至链表尾部‌。

// 设置 accessOrder=true 开启访问顺序
LinkedHashMap<String, Integer> map = new LinkedHashMap<>(16, 0.75f, true);
map.put("A", 1);
map.put("B", 2);
map.put("C", 3);

map.get("A"); // 访问 A 后,A 被移到链表尾部

// 遍历输出顺序:B → C → A
for (Map.Entry<String, Integer> entry : map.entrySet()) {
    System.out.println(entry.getKey() + ": " + entry.getValue());
}

可用来实现LRU缓存,用于缓存等动态存储最新数据的场景。

创建固定容量集合,通过重写 removeEldestEntry 方法实现自动淘汰最久未使用元素‌。

//固定容量的 LRU 缓存
class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;

    public LRUCache(int capacity) {
        super(capacity, 0.75f, true); // 启用访问顺序
        this.capacity = capacity;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity; // 容量超限时移除最旧元素
    }
}

//使用示例
LRUCache<String, Integer> cache = new LRUCache<>(3);
cache.put("A", 1);
cache.put("B", 2);
cache.put("C", 3);
cache.put("D", 4); // 插入 D 后,最旧的 A 被移除

访问顺序模式下频繁 get 操作会增加链表指针调整的开销‌,插入和删除操作因维护链表而略慢于 HashMap。


TreeMap:基于红黑树,元素有序,不允许键为空,不允许键重复,非线程安全。查询新增等操作时间复杂度为O(log n),返回第一和最后节点为O(1)。键值按默认升序排序,支持自定义排序。红黑树是动态结构,没有初始容量和扩容的概念。

可使用 Comparator 定义排序规则,或为类实现 Comparable 接口,重写 compareTo 排序方法。


HashTable:基于哈希表,早期提供的线程安全实现。无序,不允许键和值为空(可引出不允许的原因)。时间复杂度‌平均O(1),最坏O(n),未采用红黑树优化。

初始容量为11,可手动指定,且不会自动优化2次幂;加载因子0.75;扩容为原容量×2+1,原因是依赖于质数容量减少哈希冲突,用来弥补同步带来的性能损失。

对每个方法添加 synchronized 实现线程安全,但高并发下性能差,且不支持现代优化(红黑树,延迟初始化等)。已很少使用,推荐使用 ConcurrentHashMap 替代(JDK 5+)。

适用于老系统的兼容,以及低并发场景下简单线程安全需求。

8:ConcurrentHashMap

主要描述其高并发下的线程安全和高性能。

描述顺序:无序不允许为空、时间复杂度、版本的变化(结构,原理,扩容)。

线程安全的哈希表,适用于高并发场景。无序,不允许键和值为空。时间复杂度冲突少时为O(1),最差为O(log n)。初始容量及因子同HashMap。

适用于大规模分布式缓存,在高并发场景下兼顾了性能与线程安全,经常在Java设计模式和框架中使用(引出策略模式,Spring创建Bean使用)。

1:JDK 1.7实现

JDK 1.7采用的是分段锁,可理解为在元素上方添加一个segment数组,每个区域单独控制。(每个segment下面的数据不一样,会用元素的hash值查到segment)。最坏查询效率O(n)。

segment数组长度:

默认segment数组长度是16,无法精准设置segment数组长度,但可以通过 concurrencyLevel (默认16)字段参数间接设置,该参数的作用是告诉Map预计会有多少个线程同时访问它,从而调整Segment的数量。

在创建时传入参数,实际的Segment数组的长度会取大于等于 concurrencyLevel 的最小2的幂次方‌(同HashMap),这一设计是为了优化哈希计算和索引分配(通过位运算代替取模运算)。

//设置 concurrencyLevel=10,实际 Segment 长度为 16
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>(16, 0.75f, 10);

需要注意,segment数组只能在构造时确认,并且之后无法修改。

  • 较大的数组长度可减少锁竞争,但占用额外内存空间(每个都有锁和哈希表结构)。
  • 较小的数组长度可减少内存占用,但可能导致锁竞争加剧,影响并发性能。

所以这是其缺点之一并发度受限于segment数组长度,高并发场景下可能出现瓶颈。

内部结构及原理:

每个segment是独立的,继承于ReentrantLock,其内部维护了一个HashEntry(类似一个HahsMap,数组加链表结构)。

读操作无锁,依赖 volatile 保证可见性;写操作仅锁定对应 Segment,不同 Segment 可并发写入。

写原理:先通过key的哈希(高位)得到segment数组的下标,尝试使用 Segment.lock() 将这个segment上锁(竞争激烈会堵塞)。再通过key的哈希(低位)得到HashEntry数组的下标。之后就是同hashMap的插入操作了,注意1.7链表是头插法。

缺点:查询效率低:查询需遍历两次哈希(先定位 Segment,再定位 HashEntry)。且链表过长时查询效率低(未优化为红黑树)。

扩容:

每个 Segment 维护自己的负载因子,当其中的HashEntry超过阈值时,会进行单独扩容。

其他:调用size方法的时候,不会加锁,会调用三次,三次一致则返回,不一致则加锁计算。

2:JDK 1.8实现

JDK 1.8采用的是CAS + synchronized锁。移除了segment,锁的粒度变得更细化,可以理解为HashMap的数组每个位置都是一把锁,并发度随数组扩容增加。数组中链表长度 ≥8 时转为红黑树结构,查询效率稳定在 O(log n)。

读操作无锁,依赖 volatile 和 Unsafe 保证内存可见性,可保证获取到最新数据。

插入空桶使用 ‌CAS‌ 无锁操作。非空桶则 ‌synchronized 锁定链表头或树根‌,锁粒度更细,有更高的并发度。

实现原理:当插入一个值的时候,先计算key的hash获取数组下标,随后根据桶的状态执行操作。

  • 桶为空:使用CAS无锁插入新节点。
  • 桶为ForwardingNode:会协助扩容,完成后继续插入。
  • 桶不为空:使用synchronized锁住头部节点。插入后会判断是否需要扩容。

扩容:

全局扩容:取消了分段锁,当任意位置超过阈值时,触发全局扩容,意味着并发度提升。

渐进式扩容:扩容时,并非一次性分配所有数据,而是使用ForwardingNode标记状态,多个线程协同扩容,逐步迁移数据,降低扩容时性能开销。

调用size方法的时候,会将每个节点的数量累加返回。

所以,Java8通过 CAS + synchronized 细化锁粒度,优化了锁竞争,结合红黑树优化查询,支持更高并发和更大数据量。

9:ConcurrentSkipListMap

线程安全的排序集合,描述路线为:强调其有序安全的特性,以及时间复杂度、初始容量、实现原理、运用场景。

ConcurrentSkipListMap 是 Java 并发包中基于跳表(SkipList)实现的有序、线程安全映射。不允许键和值为空,时间复杂度通常为O(log n),其类似二分查找,快速跳过大量节点。

无需初始容量及扩容,底层使用跳表,跳表通过‌随机层数(Random Level)‌动态扩展结构。

查找操作无需加锁,依赖 volatile 变量和内存屏障保证可见性。

插入/删除时,通过 CAS 更新指针或锁定邻近节点,确保原子性。失败时会重试(乐观锁策略),减少阻塞。并且仅需锁定局部节点,允许多线程同时修改不同部分,避免了全局锁的开销。

支持键按自然顺序或自定义 Comparator 排序(同TreeMap),支持高效的范围查询。

// 默认构造器(按键的自然顺序排序)
ConcurrentSkipListMap<K, V> map = new ConcurrentSkipListMap<>();
// 自定义 Comparator 构造器
ConcurrentSkipListMap<K, V> map = new ConcurrentSkipListMap<>(Comparator.comparing(...));

迭代器不保证实时反映所有修改,但不会抛出 ConcurrentModificationException(可引出其描述),类似其他并发容器的设计。

ConcurrentSkipListMap 通过跳表的分层结构和无锁并发控制,在保证 O(log n) 时间复杂度的同时,实现了高效的线程安全操作。

其设计平衡了有序性和并发性能,是高并发有序映射的理想选择,适用于并发下保证顺序的场景,如排行榜、销售榜等。

10:线程安全不允许空值

在线程安全的Map集合中,不允许键和值为空,如果填充null值会抛出空指针异常。其主要原因是避免歧义和简化设计。

避免歧义:如果允许空,调用 get(key) 返回 null 时, 无法判断是键不存在,还是键对应的值是null。单线程环境中,可使用 containsKey(key) 辅助判断;多线程中,containsKey(key) 和 get(key)之间的操作,由于竟态条件,可能被其他线程修改,会导致结果不可靠出问题。

简化操作:如果允许空值的话,代码需要在多个地方检查键或值是否为null,增加了复杂度,高并发场景下可能影响性能。且可能引出空指针等问题,导致在多线程中代码不可靠。HashTable中还是使用的方法同步,还需要在所有涉及到键值的地方添加同步空值处理,进一步降低效率。

所以这种设计体现了线程安全容器对确定性和性能的权衡,确保开发者在多线程环境中写出更可靠的代码。

11:ConcurrentModificationException

该异常通常在集合被遍历时,出现修改操作时抛出,是一个 fail-fast 机制。

Fail-fast:是一种程序设计理念,指的是在程序运行过程中,如果遇到错误或异常状态,系统立即停止或抛出异常,而不是继续运行。通过该机制,可以在问题发生的初期就暴露出潜在的错误,避免在后续引发更严重的问题或数据不一致。

Java中集合类默认都采用该机制,避免数据不一致问题。

它是一种运行时异常,在遍历过程中,使用非迭代器的方式增删元素就会触发。其原理为内部维护一个 modCount(修改计数器),每次新增删除时会增减该值。而迭代器在初始化时会记录当前的modCount,在调用 next() 或 remove() 时,会校验两个modCount是否一致,不一致则报错。

List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));

// 触发异常:增强型 for 循环隐含使用迭代器
for (String s : list) {
    list.remove(s); // 直接通过集合删除元素
}

// 触发异常:显式使用迭代器但未通过迭代器删除
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    String s = it.next();
    list.remove(s); // 错误!应使用 it.remove()
}

解决方式:

1:单线程环境下使用迭代器处理新增删除操作,避免modCount不一致。

2:多线程环境下添加同步代码块锁,避免其他线程修改数据。

3:多线程环境下使用并发集合类,避免数据不一致。CopyOnWriteArrayList 及 CopyOnWriteArraySet,是基于创建时的数据快照,允许修改集合。ConcurrentHashMap迭代器是弱一致性的,遍历时修改不会抛出异常。

所以,该异常是系统的一个设计优化,旨在尽早地发现问题并返回。只要根据场景规范使用集合操作,就可以避免。

九:Java内部类

总:Java内部类是指在一个类的内部定义的类,Java支持多种类型的内部类,包括成员内部类、静态内部类、局部内部类和匿名内部类,内部类可以访问外部成员的变量和方法(包括私有方法)。

内部类的主要作用有:将逻辑相关的类封装在一起,提高类的内聚性。简化调用场景少的类的代码,减少冗余代码。优化代码结构,用于事件回调处理等。

描述顺序:方法定义、使用外部类范围、特殊处理及实例化、隐式final、强制final原因。

1:成员内部类

非静态类,定义在另外一个类中的内部类,可以使用外部类的所有成员变量以及方法,包括私有属性。不可以定义静态成员(编译器报错),除非是常量。适用于对方法补充及个性化处理。

内部类和外部类有重名方法时,在内部类可使用:外部类名.this.方法名 调用。

实例方式:声明外部类对象,随后:对象.new 内部类名() 获取内部类的实例。

2:静态内部类

使用static修饰的成员内部类,仅可使用外部类的静态成员变量和方法,包括私有属性。可定义自己的静态变量和方法。适用于不依赖实例的类,且和外部类联系紧密的,简化调用的场景。

例如HashMap内部维护的静态类:Entry,使用数组用来存储元素。

可以使用 new 外部类名.内部类名() 获取内部类的实例。

3:局部内部类

在方法或代码块中定义的类,作用域仅限于方法或代码块。只能访问外部类或方法中的 final 或 effectively final 的局部变量。适用于封装补充扩展方法或代码块内的逻辑,对外部完全隐藏。

强制要求访问final类型的变量原因,和匿名内部类一样,是因为生命周期的问题,下面统一描述。

无法在外部实例化该内部类,只能在方法或代码块中创建实例对象。

4:匿名内部类

没有类名的内部类,通常用于创建短期使用的类实例,主要用于快速实现接口或继承抽象类。只能访问外部类的 final 或 effectively final 变量。

它的语法紧凑但可读性较低,适合简化代码结构。适用于快速实现回调、事件监听、线程任务等简单逻辑,且使用次数较少的场景,如使用场景多,考虑其他内部类或独立类。

缺点是代码可读性差,无法复用,不适合方法过多或调用次数过多的场景。

// 实现接口
InterfaceName obj = new InterfaceName() {
    @Override
    public void method() {
        // 实现方法
    }
};

// 继承抽象类
AbstractClassName obj = new AbstractClassName() {
    @Override
    public void method() {
        // 覆盖方法
    }
};

直接通过new关键字实例化内部类,内部类中的 this 指向自身,调用外部类属性需使用:外部类名.this.方法名

常用的有:实现Runnable接口,继承抽象类,事件监听等。

public class AnonymousClassDemo {
    public static void main(String[] args) {
        Runnable task = new Runnable() {
            @Override
            public void run() {
                System.out.println("匿名内部类实现的线程!");
            }
        };
        new Thread(task).start();  // 输出:匿名内部类实现的线程!
    }
}

在Java 8以及之后版本,单方法接口可使用Lambda表达式优化代码,提高可读性。

// 替代 Runnable 示例
Runnable task = () -> System.out.println("Lambda实现的线程!");
new Thread(task).start();

5:effectively final

Java 中的 ‌effectively final‌ 是编译器自动识别的一种变量状态,变量在初始化后,未更改引用类型的地址或基本数据类型的值,即使未显式声明final,编译器也会将其视为不可变变量。

引入effectively final‌的好处是,减少了冗余的 final 声明,提高代码简洁度,提高开发效率。

常见的应用场景有:Lambda 和 局部/匿名内部类中,这三个场景都要求引用的外部变量必须是 final 或 effectively final‌,以保证线程安全和一致性。

Java 8及之后,对匿名内部类做了一些优化,内部类访问的外部变量,可隐式视为 effectively final,无需显式 final 修饰(Java 7 及之前版本需显式声明)‌。

当变量初始化后的值或地址改变后,则不再为 effectively final 类型,上述场景会编译报错。

// Java 8 示例:effectively final 变量
String name = "Java";  // 未声明 final,但未重新赋值
Runnable r = () -> System.out.println(name);  // 合法,name 是 effectively final

// 以下代码会编译失败(name 被修改)
String name = "Java";
name = "C++";  // 修改后,name 不再是 effectively final
Runnable r = () -> System.out.println(name);  // 编译错误

6:强制final的原因

在 Java 中,局部内部类和匿名内部类访问外部方法中的变量时,必须要求这些变量是 final 或 ‌effectively final‌(即变量在初始化后未被修改)。这一限制的核心原因大致分为三个:

1:变量生命周期问题

局部变量生命周期:方法中的局部变量,会存放在栈帧中,在方法执行完毕后,栈帧会被销毁。

上述内部类的生命周期:内部类对象可能存活更久,存放在堆内存中,可能被传递到其他线程或存储在指定集合中。

如果内部类直接访问局部变量,在方法执行完毕之后,局部变量可能已被销毁。此时如果内部类再试图访问变量,这会导致悬空引用‌(Dangling Reference)问题(C++常见错误,直接操作内存)。

悬空引用:指一个指针或引用指向的内存区域已经被释放或销毁,但该指针/引用仍被保留并使用。访问悬空引用会导致未定义行为‌(如程序崩溃、数据错误或安全漏洞)。

在 Java 的局部内部类/匿名内部类场景中,悬空引用的风险与变量生命周期管理‌密切相关。

2:数据一致性

Java对于上述悬空引用的处理是变量拷贝,原理是:Java编译器会在内部类中隐式创建一个外部变量的拷贝,基本类型是拷贝值,对象则是拷贝引用地址。

之后,内部类不再依赖外部变量,而是使用其拷贝,即使外部变量被销毁,拷贝值仍然有效。

但是如果外部变量被修改,内部类中的拷贝和原始变量会出现数据不一致‌情况。所以通过强制变量不可变,确保所有使用该变量的地方看到的是同一份值。(如果是effectively final‌,修改值后内部类编译报错)。

final修饰引用类型时,其对象的值可变,引用地址不可变。

3:线程安全问题

如果外部变量可修改,在多线程环境下,可能导致竞态条件(Race Condition),不一致问题。

竞态条件:竞态条件‌是多线程编程中的经典问题,本质是程序正确性依赖于不可控的线程执行顺序。当多个线程在没有适当同步的情况下访问和操作共享数据时,就可能发生竞态条件。

核心问题:共享数据读写、非原子性操作(可能被打断)、依赖执行顺序(最终结果取决于线程执行的随机交错顺序,结果具有极大不确定性)。

严重的还可能导致程序崩溃(空指针)或安全漏洞(权限控制)。

所以,Java通过强制不可变来避免此问题,并且无需同步机制,提高性能。

其他的原因还包括:设计语言的简化(闭包)、隐式final的支持优化等。

十:设计模式

Java中的设计模式是一套被反复使用的,经过分类整合的代码设计经验的总结,能够帮忙开发人员创建更具可维护性、可扩展性和复用性的代码。

一般说的Java设计模式就是指GoF23 种经典设计模式,共分为三大类:创建型、结构型和行为型,分别表示不同的处理方式。(记录完之后这里规整)

创建型模式:多用来指处理对象创建,解耦实例化与使用等,共有5种。

结构型模式:多用来处理对象组合,类或对象的协作问题等,共有7种。

行为型模式:多用来处理对象之间的交互,优化算法或流程的协作方式等,共有11种。

1:单例模式

创建型设计模式,经常问的很经典的设计模式。核心思想是确保一个类仅有一个实例,并提供全局的访问,简化代码。

应用场景有配置管理,线程池以及数据库连接池等全局共享资源。以及框架中用的很多,Spring中的Bean就是默认单例的,

1:饿汉式

类加载时就创建指定实例,线程安全,但可能浪费资源。

public class Singleton {
    private static final Singleton INSTANCE = new Singleton();
    private Singleton() {}
    public static Singleton getInstance() { return INSTANCE; }
}

2:饱汉式

也称为懒加载,只在第一次使用时创建实例,但多线程下有并发问题,竞态条件导致重复创建。

public class Singleton {
    private static Singleton singleton = null;
    private Singleton() {}
    //不提供构造赋值方法,只对外提供获取实例方法
    public static Singleton getInstance() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
}

上述代码在多线程中,可能有两个线程同时等于空进入创建代码,导致重复创建。可以加锁处理,但是会影响性能。

静态方法加锁,锁是类对象,锁的是这个类下的所有静态方法。可使用静态代码块缩小锁粒度。

public class Singleton {
    private static Singleton singleton = null;
    private Singleton() {}
    //对外提供获取实例方法
    public static synchronized Singleton getInstance() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
}

3:双重校验锁

懒加载 + 线程安全的实现,无竟态条件的情况下是无锁状态,性能无影响。注意属性需添加 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;
    }
}

4:静态内部类

利用类加载机制保证线程安全,且懒加载。很好理解,当调用实例方法时,触发静态内部类的初始化,如果多个线程触发,JVM类创建会有一个标识,加锁执行 cinit 构造方法,完成后更新标识。其他阻塞线程判断标识就不会再执行初始化方法。

这种方法,如果强行说有问题,就是需要加载初始化两个类,以及代码可读性差。

public class Singleton {
    private static class Holder {
        static final Singleton INSTANCE = new Singleton();
    }
    public static Singleton getInstance() { return Holder.INSTANCE; }
}

5:枚举

枚举在Java中本质时一个继承自Enum的类,遵循类加载机制,成员变量会被隐式声明为 public static final。在枚举初始化时放入 cinit 中赋值实例化。

所以这个和静态内部类原理一样,都是利用JVM保证cinit的线程安全实现。但是枚举复用性高。

public enum Singleton {
    INSTANCE;
    public void doSomething() { /* ... */ }
}

2:简单工厂模式

注意:不属于23种经典设计模式之一,因其简单实用而被广泛应用,可以被算作创建型。

通过一个工厂类,‌根据传入的参数动态决定创建哪一种具体类的对象‌,实现了对象的创建与使用的解耦。

客户端仅依赖抽象接口和工厂类,无需关心具体实现类的实例化过程。并且将对象的创建逻辑集中到工厂类中,便于维护和扩展。但该方式违反了Java的开闭原则,新增产品时需要修改工厂类的逻辑。并且产品过多时,工厂类会很臃肿,可读性差。

适用于简单的对象创建且类型有限,一般在业务场景中,根据字段判断推送数据或对象,也是用了这种思想。

与另外的两个工厂模式对比时,只需要记住简单工厂是仅使用一个工厂类处理所有产品

public class EasyFactory {
    public Phone createPhone(String phoneName) {
        if ("Huawei".equals(phoneName)) {
            return new HuaweiPhone();
        } else if ("Xiaomi".equals(phoneName)) {
            return new XiaomiPhone();
        } else {
            throw new RuntimeException("未知的产品类型");
        }
    }
}
public class EasyFactoryTest {
    public static void main(String[] args) {
        EasyFactory easyFactory = new EasyFactory();
        Phone huawei = easyFactory.createPhone("Huawei");
        huawei.makeCall();
        Phone xiaomi = easyFactory.createPhone("Xiaomi");
        xiaomi.makeCall();
    }
}

3:工厂方法模式

创建型设计模式,核心思想是定义一个创建对象的接口,但由子类决定实例化哪个类,延迟实例化到子类。其实是运用到了方法的重写和多态,返回具体的子类对象。

该模式在面试和实际开发中都非常重要,应用场景例如Spring框架中的BeanFactory,日志框架中的LoggerFactory等。

工厂方法对比直接new对象的好处是:

  1. 客户端只依赖于抽象工厂和产品接口,与具体实现解耦;
  2. 扩展性强,新增产品时,只需要创建子类实现产品接口,添加子类的产品工厂继承抽象工厂即可。
  3. 可集中管理对象的创建逻辑。

经常会用工厂方法模式,和简单工厂、抽象工厂对比。只需要记住工厂方法是每个产品对应一个子类,符合开闭原则。具体的对比在后续描述。

1:创建产品及子类

首先定义一个产品接口,并规定指定的行为。创建子类对象并重写方法,实现不同行为。

//创建接口或抽象类规范行为
public interface Phone {
    void makeCall();  //打电话
    void sendMessage();  //发短信
}

//子类实现不同行为
public class XiaomiPhone implements Phone{
    @Override
    public void makeCall() {System.out.println("小米手机打电话");}
    @Override
    public void sendMessage() {System.out.println("小米手机发短信");}
}

public class HuaweiPhone implements Phone {
    @Override
    public void makeCall() {System.out.println("华为手机打电话");}
    @Override
    public void sendMessage() {System.out.println("华为手机发短信");}
}

到这里正常我们就可以直接new对象使用了,但是这样会导致使用的地方和具体类高度耦合,并且使用地方分散,不好维护。

2:定义工厂类或接口

创建一个抽象类或接口,作为工厂类,提供抽象的产品获取方法,供子类重写返回具体产品。工厂类中还可定义公用方法(接口1.8默认方法),供子类使用。

注意,这里只是一个思想,父类定义规范,子类实现。所以用抽象类或接口都可以。如果JDK版本较低,使用抽象类可以兼容比较安全,而使用接口则更加灵活,避免了类的单继承问题。

注意是返回的产品对象,由子类决定返回具体产品。

public interface PhoneFactory {
    Phone creatPhone();    //抽象方法
    default void doSomeThing(){    //公共方法
        System.out.println("准备手机零部件等其他事情");
    }
}

3:创建具体工厂类

创建具体的工厂类实现工厂模板,重写方法,返回具体产品对象。

public class HuaweiPhoneFactory implements PhoneFactory{
    @Override
    public Phone creatPhone() {
        doSomeThing();  //复用父类方法
        return new HuaweiPhone();   //返回具体的子类对象
    }
}

public class XiaomiPhoneFactory implements PhoneFactory{
    @Override
    public Phone creatPhone() {
        doSomeThing();
        return new XiaomiPhone();
    }
}

4:使用工厂获取对象

public class PhoneTest {
    public static void main(String[] args) {
        //使用华为工厂创建华为手机
        HuaweiPhoneFactory huaweiPhoneFactory = new HuaweiPhoneFactory();
        Phone huaweiPhone = huaweiPhoneFactory.creatPhone();
        huaweiPhone.makeCall();
        //使用小米工厂创建小米手机
        XiaomiPhoneFactory xiaomiPhoneFactory = new XiaomiPhoneFactory();
        Phone xiaomiPhone = xiaomiPhoneFactory.creatPhone();
        xiaomiPhone.makeCall();
    }
}

4:抽象工厂模式

创建型设计模式,核心思想是用于创建一组相关或相互依赖的对象,简单理解一个工厂生产同类型的多个产品

是一对多的关系,其实就是抽象工厂类中设置了多个产品,每个子类各自重写方法返回多个不同产品。

新增一个品牌相关产品时,只需要添加产品类和新工厂方法即可,无需修改工厂逻辑代码。但是如果要在家族中添加新产品组件(实体类),需修改所有工厂接口。

适用于解决跨平台(每个系统一套),或多套配置(例如主题切换,数据库兼容等)复杂场景,Spring 中的ApplicationContext可以看作抽象工厂,生成一组组相关的Bean对象信息。

1:产品家族及子类

抽象工厂一般用于家族产品的控制,例如一个工厂下可能生产该品牌的很多产品。

public interface Phone {
    void makeCall();  //打电话
    void sendMessage();  //发短信
}

public interface Headset {
    void listen();    //耳机听音乐
    void stopListen();   //停止听音乐
}

每个品牌具体分别实现上述产品接口,并自定义处理实现类。

//品牌一
public class OppoPhone implements Phone {
    @Override
    public void makeCall() {System.out.println("OPPO手机打电话");}
    @Override
    public void sendMessage() {System.out.println("OPPO手机发短信");}
}

public class OppoHeadset implements Headset{
    @Override
    public void listen() {System.out.println("OPPO耳机听音乐");}
    @Override
    public void stopListen() {System.out.println("OPPO耳机停止听音乐");}
}

//品牌二
public class VivoPhone implements Phone {
    @Override
    public void makeCall() {System.out.println("VIVO手机打电话");}
    @Override
    public void sendMessage() {System.out.println("VIVO手机发短信");}
}

public class VivoHeadset implements Headset{
    @Override
    public void listen() {System.out.println("VIVO耳机听音乐");}
    @Override
    public void stopListen() {System.out.println("VIVO耳机停止听音乐");}
}

2:定义家族工厂类

这里是和工厂方法的主要区别,这里定义一个家族产品,即多个产品,供子类实现。

这里同上,也可以使用接口,并提供默认方法。

public abstract class AbstractFactory {
    abstract Phone createPhone();    //家族产品-手机
    abstract Headset createHeadset();    //家族产品-耳机
    //...其他通用方法
}

3:家族工厂实现类

继承或实现定义的工厂模板,重写提供的产品方法,个性化返回各自的多个产品对象。

public class OppoFactory extends AbstractFactory {
    @Override
    Phone createPhone() {return new OppoPhone();}
    @Override
    Headset createHeadset() {return new OppoHeadset();}
}

public class VivoFactory extends AbstractFactory {
    @Override
    Phone createPhone() {return new VivoPhone();}
    @Override
    Headset createHeadset() {return new VivoHeadset();}
}

4:通用工厂调用

可配置一个静态的通用工厂获取方法,进一步简化调用,使用系统配置或传入参数获取。

这样配置,调用者不需要关心具体是哪个实例和品牌,降低代码耦合度。

5:建造者模式

创建型设计模式,核心是用来分步骤构建复杂对象,将对象的构造过程与其表示分离。

通过链式调用或分布设置的方式,解决传统构造器参数过多,难以维护的问题。可用来避免对象属性不完整时调用(指定字段必须传入),或隐藏对象的组装细节,按规定组装参数等。

建造者模式关注对象构造创建,工厂模式关注对象间解耦复用、后续扩展。

1:对象类及内部类

在多个参数的情况下,可能需要组合多个构造器,代码冗余且可读性差。

构造器的思想是:首先把类的默认构造器去除,并且添加一个私有构造器,参数为内部类对象。声明一个静态内部类,按照指定规则传参,最后在组装方法中校验,符合要求才调用外面对象的私有构造器赋值。

注意:建造者模式通常与不可变对象结合使用,以发挥其线程安全和数据一致性的优势。

通过私有构造器 + final 字段 + 无 Setter 方法实现严格不可变。除了手动编写建造者模式,还可以使用Lombok的 @Builder 注解,且该注解生成的类默认是不可变的。

public class Computer {
    private final String cpu;    //final字段,构建后不可修改,后续仅提供get方法
    private final String ram;
    private final String storage;

    private Computer(Builder builder) {   //私有构造器,只能通过建造者创建
        this.cpu = builder.cpu;
        this.ram = builder.ram;
        this.storage = builder.storage;
    }

    public static class Builder {    //静态内部建造者类
        private String cpu;
        private String ram;
        private String storage;

        public Builder(String cpu, String ram) {    //必选参数通过构造器传入
            this.cpu = cpu;
            this.ram = ram;
        }

        public Builder storage(String storage) {    //可选参数通过链式方法设置
            this.storage = storage;
            return this;
        }

        public Computer build() {    //最终构建方法,可添加判断逻辑
            return new Computer(this);
        }
    }
}

2:对象构建

调用其静态内部类,只传入必填参数,后续通过链式调用或其他地方设置非必填属性。最后调用构建方法完成赋值。

Computer computer = new Computer.Builder("Intel i7", "16GB DDR4")
        .storage("512GB SSD")
        .gpu("NVIDIA RTX 3080")
        .build();

6:适配器模式

结构性设计模式,用于在不修改原有代码的前提下,解决接口不兼容问题,将一个类的接口转换为客户端期望的另一个接口,适用于复用现有类,整合历史遗留代码等。

说白了就是有一个新接口规范,老的类想使用。就创建一个适配器,传入老的类,在新接口内特殊处理老的业务的代码逻辑。而实际的老业务调用的还是自己的方法,只是入口和返回对象是新的(例如Type-c的充电口使用耳机转换器,最终使用耳机听歌)。

Java标准库中,InputStreamReader 和 OutputStreamWriter‌ 会将字节流(InputStream/OutputStream)适配为字符流(Reader/Writer)。

适配器类名通常以 Adapter 结尾,并且不要过度滥用,接口不匹配时考虑是否重构代码,也可能是设计缺陷。

1:类适配器

比如现在有一个手机TypeC接口,然后有一个以前的耳机线,无法直接插入使用。

public interface TypeCCharger {    //目标接口,后续的统一规范(充电口连接)
    void chargeWithTypeC();
}

public class EarphoneCable {       //历史功能,被适配者(以前的耳机线)
    public void chargeWithEarphone() {
        System.out.println("连接并听音乐");
    }
}

此时想要使用,就可以添加一个适配器,支持传入耳机,并且经过处理能够正常使用。

使用方式为直接继承自被适配者,实现目标接口并重写方法,在方法内处理被适配者调用。

2:对象适配器

上面类适配的方式虽然简单,但是受类的单继承限制,假如以后有一个新的类型耳机,又需要新建一个类,不灵活不利于扩展。

使用对象适配器更加灵活可扩展,组合被适配者(传入参数)。例子同上,只是适配器处理不同。

使用时创建被适配对象并传入目标接口中,然后使用目标接口接收,隐藏实现细节。

7:装饰器模式

结构型设计模式,核心思想是通过组合替代继承,运行时灵活叠加装饰。适用于继承无法有效扩展功能时(子类数量爆炸),或需求动态透明的添加功能时。

缺点是多层装饰可能增加代码复杂度,设计时需保证设计规范,不如继承简单快捷。

装饰器与被装饰器实现同一接口,具体的装饰器实现类添加各自的特殊处理。

扩展已有组件的装饰时,只需新增具体装饰类子类即可,使用时传入组件实例;新增其他种类组件时,想要复用装饰器的话,需增加组件接口及实例,并维护到抽象装饰器中,最后创建具体装饰器使用。

Java I/O 流‌:如 BufferedReader 装饰 FileReader 提供缓冲功能。

1:组件接口与实现

public interface DrinksInterface {    //饮料组件
    double price();
    String message();
}

public class AppleJuice implements DrinksInterface {  //苹果汁
    @Override
    public double price() {return 3;}
    @Override
    public String message() {return "苹果汁";}
}

2:装饰器抽象类

根据上面实现类,以前想要装饰扩展的话,需要写类继承并修改,多个子类的情况下,可能造成类过多。使用该设计模式后,定义一个装饰器抽象类,将接口定义为属性,由后续实现类赋值并使用。

注意这里定义抽象类无需重写接口方法,只是用来定义接口属性,子类继承后会重写接口方法

另外,组件实现类的访问权限,最小要为受保护的,需提供给子类扩展使用。

3:具体装饰器

创建子类继承抽象类,并重写接口方法,获取到组件后添加装饰扩展。

如果有多个组件,可选择性装饰扩展(例如饮料,饼干等其他组件)。

4:装饰使用

根据实际场景,可选择获取原组件或装饰组件,代码更灵活。使用时注意先后顺序,避免拿到错误数据。

8:代理模式

面试必问的设计模式,是Spring框架中AOP的底层原理。

结构型设计模式,核心思想是通过一个代理对象控制对真实对象的访问,在客户端和真实对象之间起到中介作用,从而在不修改原始对象逻辑的前提下,增强或限制其功能。

主要解决控制访问(延迟加载)、增强功能(AOP)、简化复杂性(远程方法调用)等。

Java中的代码主要分为静态代理和动态代理,其中动态代理又分为JDK动态代理和CGLib动态代理。

注意静态代理和装饰者模式很像,都是通过包装对象实现扩展功能,但核心目的不同:

  1. 代理模式是控制访问,代理与目标对象的关系是纵向的,后续直接使用代理类。
  2. 装饰器模式是动态灵活叠加装饰,装饰器与目标对象关系是横向的,可以提供多个装饰器供后续使用。由于抽象类中不重写方法,所以无法做到控制访问。

1:静态代理

静态代理是在编译时手动编写代理类的一种方式,代理关系在编译时确定,缺点是需要为每个目标类编写对应的代理类。

1:创建类与接口

2:创建静态代理类

代理类和目标类都实现相同的接口,代理类内部持有目标对象的引用,并在调用方法前后添加额外逻辑(可理解为把装饰固定起来)。

注意这里和装饰器的区别,装饰器不会在装饰器类中增强,而是由具体的装饰器子类处理。

并且,这里属性可以设置为私有的,因为不需要具体子类继承,后续直接使用代理类。

2:JDK动态代理

静态代理的痛点就是场景不灵活,不能在运行时使用到了再创建,必须提前写好。并且不利于扩展,需要为每个目标类编写代理类,代码冗余。

动态代理就是在运行时动态生成类,无需手动编写代理代码。Java提供了 Proxy 和 InvocationHandler 接口实现基础的动态代理,也就是常说的基于接口的动态代理(目标类必须实现接口)。

‌Spring AOP‌:默认对接口使用JDK动态代理,对类使用CGLib(可通过配置强制使用CGLib)。


JDK动态代理底层是通过反射动态创建代理类,可以代理多个目标类,灵活便于扩展。

JDK代理的限制是无法代理未实现接口的类,反射调用较慢。使用方式为先实现接口,后续通过Proxy类将要代理对象传入即可实现代理。

3:CGLib动态代理

CGLib是一个基于字节码操作的第三方库,底层是通过继承目标类生成子类来实现代理,不需要目标类实现接口。

注意限制:无法代理final类或final方法(底层创建子类使用),需要引入CGLib依赖。

适用于对历史无接口的类进行代理,或提高生成的代理类执行效率(但生成速度慢)。

使用:先实现接口 MethodInterceptor(代理方法的拦截器),再通过Enhancer生成代理类。

public class PepsiBeverages {    //1:无接口的类
    public void makeBeverages() {
        System.out.println("制作百事可乐");
    }
}

//2:实现拦截器接口
public class BeveragesInterceptor implements MethodInterceptor {
    @Override
    public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
        System.out.println("CGLib动态代理执行前");
        Object invokeObj = methodProxy.invokeSuper(o, objects);  //调用父类(目标类)方法
        System.out.println("CGLib动态代理执行前");
        return invokeObj;
    }
}

//3:生成代理对象
public class CgLibDynamicTest {
    public static void main(String[] args) {
        //直接生成代理对象即可(不用像JDK代理传入接口对象)
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(PepsiBeverages.class);  //设置父类(目标类)
        enhancer.setCallback(new BeveragesInterceptor());
        PepsiBeverages pepsiBeverages = (PepsiBeverages) enhancer.create();
        pepsiBeverages.makeBeverages();
    }
}

9:外观模式

结构性设计模式,核心思想是隐藏系统复杂性,提供一个更易于使用的方法,简化客户端与子系统的交互。

说白了就是加一层,将多个复杂逻辑(如Service)整合起来。对外只使用外观类。缺点是不利于扩展维护,多了会导致代码臃肿。

创建多个类,再创建一个外观类将其他类汇总,只对外提供一个方法,后续调用该方法实现。

10:策略模式

行为型设计模式,核心思想是运行时根据不同的条件选择不同的算法或行为,动态选择,易于扩展。并可结合Spring框架,进一步管理策略实例,方便调用。

1:策略模式原型

首先描述Java中的策略模式原生思想,即定义一个实例接口,设置多个实现类。

策略类似于抽象工厂模式,都是使用父类接收子类并调用,二者的区别在于:关注点不同,策略模式关注于行为切换(不同子类调用),而抽象工厂关注于对象的创建(只是为获取到子类对象)。

public interface TaskType {     //任务类型
    void execute();
}

public class PolicySubmitTask implements TaskType{
    @Override
    public void execute() {
        System.out.println("投保提交");
    }
}

public class EndorseSubmitTask implements TaskType{
    @Override
    public void execute() {
        System.out.println("批改提交");
    }
}

再定义一个策略分发类,引入接口对象。提供方法传入不同子类动态分发调用。

2:Spring管理策略实现类

对于上述场景,如果后续业务扩展多了,策略子类不利于维护和排查问题。并且不好找,没有集中管理,无法在代码中动态找到具体实现类。

此时可以使用 Spring 在程序启动时自动收集所有策略实现类,将他们放入一个Map集合工厂中,添加标识并集中管理。

1:注入容器

首先需配置 @ComponentScan 注解扫描包,自动发现所有 @Component 标注的策略实现类。

@Configuration
@ComponentScan(basePackages = "com.example.strategies")
public class AppConfig {
    // 自动扫描策略实现类
}

@Component
public class PolicySubmitTask implements TaskType{  //所有策略实现类注入容器
}

2:统一管理

创建工厂类并注入容器,通过 ApplicationContextAware 接口获取 Spring 上下文(Bean实例化之后)。

获取到所有策略实现类,遍历并将其放入维护的全局Map集合中(ConcurrentHashMap保证线程安全),其中策略模式的标识作为键,对象作为值。

注意正式环境中,接口会提供一个获取标识的方法供子类重写,然后维护一个枚举,每个子类返回枚举中的值。在放入全局Map时,将获取到的枚举值作为键。

这样设置后,通过枚举可以简单的找到具体策略实现类,便于后续维护和调用。

@Component
public class PaymentStrategyFactory implements ApplicationContextAware {
    //ConcurrentHashMap保证线程安全
    private final Map<String, PaymentStrategy> strategies = new ConcurrentHashMap<>();

    //自动注册所有实现
    @Override
    public void setApplicationContext(ApplicationContext applicationContext) {
        applicationContext.getBeansOfType(PaymentStrategy.class)
                .values()
                .forEach(strategy -> 
                    //getType是重写的获取唯一标识的方法
                    strategies.put(strategy.getType().toUpperCase(), strategy)
                );
    }
}

其他设计模式后续更新

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值