Java常见问题及知识点总结

总结了Java 面试指南 | JavaGuide的Java ⾯试中最最最常问的⼀些问题的答案及相关知识点,供自己查阅。目前本文章包含Java基础、Java集合和Java并发。后续会继续补充。

Java基础

1. Java 中的几种基本数据类型?

  1. byte:1字节,-2^7 到 2^7-1

  2. short:2字节,-2^15 到 2^15-1

  3. int:4字节,-2^31 到 2^31-1

  4. long:8字节,-2^63 到 2^63-1

  5. float:4字节,最小值2^−126×2^−23=2^−149≈1.4E−45,最大值(2−2^−23)×2^127≈3.4E38

  6. double:8字节,最小值2^-1074≈4.9E-324,最大值(2−2^−52)×2^1023≈1.8E308

  7. char:2字节,最小值\u0000(十进制值为 0),最大值\uFFFF(十进制值为 65,535)

  8. boolean:1位,true/false

2. String 、 StringBuffer 和 StringBuilder ?

特性StringStringBufferStringBuilder
可变性不可变可变可变
线程安全性线程安全(不可变对象自动线程安全)线程安全(内部使用 synchronized不线程安全
性能每次修改都会创建新的对象,性能较低相比 String 性能更高,但因使用同步机制较慢性能最优,适用于单线程环境
初始容量不适用(固定不可变)默认初始容量 16,支持动态扩展默认初始容量 16,支持动态扩展
扩容策略不适用(不可变)当字符数超过容量时,容量增加一倍当字符数超过容量时,容量增加一倍
适用场景适合不可变字符串的操作,常用于常量或配置适合需要线程安全的可变字符串操作适合单线程环境下需要频繁修改字符串的操作
主要操作方法concat()replace()substring()append()insert()delete()reverse()append()insert()delete()reverse()
线程安全细节不适用使用 synchronized 锁保护方法不使用锁,性能更好

3. String s1 = new String("abc")?

这段代码可能会创建一个或两个字符串对象,具体取决于是否字符串 "abc" 已经存在于字符串常量池中。

 String s1 = new String("abc");
  • 如果 "abc" 已经存在于字符串常量池中,则 new String("abc") 会创建一个新的 String 对象在堆中。因此,总共会创建 1 个 新的 String 对象。

  • 如果 "abc" 不存在于字符串常量池中,则 "abc" 会被添加到常量池中,然后在堆中创建一个新的 String 对象。因此,总共会创建 2 个 String 对象,一个在字符串常量池中,一个在堆中。

4. == 与 equals? hashCode 与 equals ?

== vs equals

  • ==:比较的是两个对象的内存地址,即它们是否引用同一个对象。用于判断两个对象是否是同一个实例

  • equals:比较的是两个对象的内容是否相等。默认的实现是 Object 类中的 equals 方法,它实际上使用的是 == 来比较对象的内存地址,但可以在自定义类中重写 equals 方法来比较对象的实际内容。

hashCodeequals

  • hashCode:hashCode 方法返回对象的哈希码(一个整数),它用于在哈希表中定位对象。

  • equals: equals 方法用于判断两个对象是否相等(重写后)。

5. 包装类型的缓存机制?

  1. 缓存范围:

    • 对于 Integer 类型,Java 缓存了从 -128127 的整数值。这是因为这些值在大多数程序中非常常用,并且在这个范围内创建对象的开销较大,因此通过缓存来节省内存和提高性能。

    • 对于 Character 类型,缓存了从 \u0000\u007F 的字符(即 ASCII 范围内的字符)。

    • 对于其他类型,如 ByteShort,也有类似的缓存机制,但范围可能有所不同。

  2. 实现机制:

    • Integer.valueOf(int): 这个方法会先检查传入的整数是否在缓存范围内。如果是,它会返回缓存中的对象;如果不是,它会创建一个新的 Integer 对象。

    • Character.valueOf(char): 类似地,对于 Character 类型,valueOf 方法会检查字符是否在缓存范围内,并返回缓存的字符对象。

  3. 为什么缓存:

    • 性能优化: 对于小范围的常用值,缓存可以减少对象创建的次数,从而提高性能。

    • 内存节约: 缓存可以避免重复创建相同值的对象,从而节省内存。

  4. 示例:

    Integer a = 100;
    Integer b = 100;
    System.out.println(a == b); // 输出 true,因为 100 在缓存范围内,a 和 b 指向同一个对象
    ​
    Integer c = 200;
    Integer d = 200;
    System.out.println(c == d); // 输出 false,因为 200 不在缓存范围内,c 和 d 是不同的对象

注意事项

  • 缓存的局限性: 缓存机制只适用于特定范围的值。超出缓存范围的值会创建新的对象,即使它们的值相同。

  • new 关键字: 使用 new 关键字创建包装对象时(如 new Integer(100)),不会使用缓存机制,而是每次都创建新的对象。

6.自动装箱与拆箱

自动装箱:当将基本数据类型赋值给其对应的包装类对象时,Java 编译器会自动调用包装类的 valueOf 方法,将基本数据类型转换为包装类型对象。示例:

int a = 10;
Integer b = a; // 自动装箱,将 int 转换为 Integer
Integer b = Integer.valueOf(a);  //原理

Integer.valueOf(int) 方法会先检查值是否在缓存范围内(-128 到 127),如果在,则返回缓存中的 Integer 对象,否则创建新的 Integer 对象。

自动拆箱:当将包装类型对象赋值给基本数据类型或在表达式中使用时,Java 编译器会自动调用包装类的 xxxValue() 方法,将包装类型对象转换为基本数据类型。示例:

Integer a = 10;
int b = a; // 自动拆箱,将 Integer 转换为 int
int b = a.intValue(); //原理

注意事项

  1. 性能影响:

    • 自动装箱和拆箱会导致隐式的对象创建和方法调用,可能会影响性能,特别是在高频率的装箱和拆箱操作中。

    • 尤其在使用集合类(如 List<Integer>)时,频繁的装箱和拆箱可能会导致不必要的对象分配和垃圾回收。

  2. 空指针异常(NullPointerException):

    • 自动拆箱可能会导致空指针异常。例如,如果 Integer 对象为 null,尝试将其自动拆箱为 int 时会抛出 NullPointerException

    Integer a = null;
    int b = a; // 这里会抛出 NullPointerException

  3. 比较操作:

    自动装箱时需要注意使用 == 比较两个包装对象时,可能会出现意外结果。因为 == 比较的是对象的引用,而非值。要正确比较值,需要使用 equals 方法。

7.深拷贝和浅拷贝区别

浅拷贝:复制对象时,基本类型字段被复制,但引用类型字段只复制引用。两个对象共享引用类型的成员,指向同一个对象。 实现方法:.clone()方法、构造函数复制

深拷贝:复制对象时,基本类型字段被复制,引用类型字段也会复制为新的对象副本。两个对象完全独立,不共享任何引用类型的成员。 实现方法:递归调用clone方法、使用序列化和反序列化实现深拷贝

8.谈谈对 Java 注解的理解

Java 注解(Annotations)是一种用于提供元数据的机制,允许开发者在代码中添加额外的信息而不影响实际的业务逻辑。注解在 Java 5 中被引入,用来替代 Java 中的标记接口和配置文件,增强了代码的可读性和灵活性。

注解是以 @ 符号开头的,通常位于类、方法、字段、参数等的上方,注解本身并不会直接影响代码的执行,但它可以被编译器或运行时环境读取,并根据注解的信息采取相应的措施。示例:

@Override
public String toString() {
    return "Example";
}

上面这个 @Override 注解告诉编译器这个方法是从父类或接口中重写的,如果没有正确重写,编译器会报错。

Java 注解解决了什么问题?
  1. 替代配置文件:

    • 在 Java EE 和 Spring 等框架中,注解通常用来替代繁琐的 XML 配置文件。通过注解,配置可以直接和代码放在一起,减少了配置错误和冗余,提高了配置的可维护性。例如,Spring 中的 @Autowired 用于自动注入依赖,代替 XML 中的 <bean> 配置。

    @Autowired
    private MyService myService;

  2. 元数据提供:

    • 注解允许开发者为代码添加元数据,这些元数据可以在编译时、类加载时或运行时通过反射机制被读取和处理。比如,@Deprecated 注解标记某个方法已经过时,不推荐使用,这样在使用该方法时编译器就会发出警告。

    @Deprecated
    public void oldMethod() {
        // Deprecated method
    }

  3. 简化代码:

    • 注解简化了代码,通过注解的使用,可以减少代码重复,增强代码的可读性和维护性。比如,JPA 的 @Entity 注解将类声明为实体类,@Table 注解指定数据库表名等,大大简化了数据库表映射的工作。

    @Entity
    @Table(name = "users")
    public class User {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        @Column(name = "username")
        private String username;
    }

  4. 提高编译期的安全性:

    • 通过注解,可以在编译期进行一些检查,减少运行时错误。例如,@Override 注解用于检测方法是否正确重写父类方法,防止拼写错误或方法签名不匹配等问题。

  5. 框架和库的扩展:

    • 许多框架和库使用注解来简化开发工作。例如,JUnit 中的 @Test 注解标记测试方法,框架会自动识别并执行这些测试。注解让框架和库能够以一种简洁、灵活的方式进行扩展和定制。

  6. 编译时和运行时处理:

    • 注解可以在编译时通过注解处理器进行处理(如 @Retention(RetentionPolicy.SOURCE)),也可以在运行时通过反射机制进行处理(如 @Retention(RetentionPolicy.RUNTIME))。这为开发者提供了灵活的手段来控制代码的行为。

常见的 Java 注解
  • 内置注解:

    • @Override: 用于标记重写父类方法。

    • @Deprecated: 用于标记过时的方法、类或字段。

    • @SuppressWarnings: 用于告诉编译器忽略特定的警告。

  • 元注解:

    • @Retention: 指定注解的保留策略(SOURCE、CLASS、RUNTIME)。

    • @Target: 指定注解可以应用的代码元素(类、方法、字段等)。

    • @Inherited: 指定注解是否可以被子类继承。

    • @Documented: 指定注解是否会包含在 JavaDoc 中。

  • 自定义注解:

    • 开发者可以根据需求定义自己的注解,用来标记和处理特定的代码逻辑。例如,可以定义一个 @MyAnnotation 来标记需要特殊处理的方法。

    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface MyAnnotation {
        String value();
    }

9.ExceptionError的区别

ExceptionError 是 Java 中异常处理机制的重要组成部分,它们都是继承自 Throwable 类,但在概念和使用上有显著区别。

Throwable 是 Java 中所有异常和错误的超类,它有两个直接子类:ExceptionError

1.Exception

  • 定义: Exception 表示程序中可以捕获并处理的异常。它通常是由于代码错误或其他可预见的情况引发的,例如试图访问一个不存在的文件、除零操作、数组下标越界等。

  • 子类:

    • Checked Exception(受检异常): 必须在编译时处理(即用 try-catch 块捕获或用 throws 声明)。例如:IOException, SQLException

    • Unchecked Exception(未受检异常): 是 RuntimeException 的子类,不需要在编译时显式捕获。例如:NullPointerException, ArrayIndexOutOfBoundsException

  • 处理方式: 开发者可以通过 try-catch 语句捕获和处理这些异常,也可以通过在方法签名中使用 throws 关键字将异常抛出给调用者处理。

示例

try {
    int result = 10 / 0; // 可能抛出 ArithmeticException
} catch (ArithmeticException e) {
    System.out.println("Caught an ArithmeticException: " + e.getMessage());
}

2.Error

  • 定义: Error 表示严重的错误,这类错误通常是由于运行环境的问题导致的,程序无法合理地从这些错误中恢复。典型的例子包括内存不足 (OutOfMemoryError)、栈溢出 (StackOverflowError) 等。

  • 子类: Error 的子类包括 OutOfMemoryError, StackOverflowError, LinkageError 等。

  • 处理方式: 一般不应该捕获 Error,因为它们通常表示系统级别的错误,捕获和处理它们可能会隐藏更严重的问题,甚至导致系统不稳定。通常来说,这类错误应交由 JVM 处理。

示例

public class StackOverflowDemo {
    public static void recursiveMethod() {
        recursiveMethod(); // 递归调用导致 StackOverflowError
    }

    public static void main(String[] args) {
        try {
            recursiveMethod();
        } catch (StackOverflowError e) {
            System.out.println("Caught a StackOverflowError: " + e.getMessage());
        }
    }
}

区别总结:

  • Exception: 是开发者用于处理异常情况的工具,使得程序能够处理错误并继续执行。

  • Error: 是 JVM 用来表示程序无法继续执行的严重问题。

在编写代码时,开发者通常只需要关注 Exception 及其子类的处理,而不需要显式地处理 Error 类及其子类。

10. Java 反射的概念(不懂

反射(Reflection)是Java的一项强大特性,它允许程序在运行时动态地获取类的结构信息并操作类的成员(如属性、方法、构造器等)。通过反射,程序可以在编译时不知道类的具体信息的情况下,在运行时对类进行操作。

反射的基本操作

获取类的 Class 对象

每个 Java 类都有一个 Class 对象,包含了与该类相关的元数据。可以通过以下方式获取:

Class<?> clazz = Class.forName("com.example.MyClass"); // 使用类的全限定名:包名.子包名.类名
Class<?> clazz2 = MyClass.class; // 直接使用类名.class
Class<?> clazz3 = instance.getClass(); // 使用对象.getClass()

获取类的信息

可以通过反射获取类的构造函数、方法、字段、注解等信息。

Method[] methods = clazz.getDeclaredMethods(); // 获取所有方法
Field[] fields = clazz.getDeclaredFields(); // 获取所有字段
Constructor<?>[] constructors = clazz.getDeclaredConstructors(); // 获取所有构造函数

操作类的成员

通过反射,可以动态调用方法、设置字段值或创建类的实例。

Method method = clazz.getDeclaredMethod("methodName", String.class);
method.invoke(instance, "parameterValue"); // 动态调用方法

Field field = clazz.getDeclaredField("fieldName");
field.setAccessible(true); // 绕过访问控制检查
field.set(instance, "newValue"); // 动态设置字段值
反射的用途
  1. 框架和库

    • 反射在框架中很常用,例如 Spring、Hibernate 等。它们利用反射来实例化对象、调用方法、依赖注入等,而不需要在编译时知道具体的类。

  2. 动态代理

    • 反射用于动态生成代理类,动态代理在 AOP(面向切面编程)中尤为常见。代理类可以在运行时动态创建,处理方法调用并添加额外的逻辑。

  3. 序列化和反序列化

    • 在 JSON 序列化工具(如 Jackson、Gson)中,反射用于动态地将对象转换为 JSON 字符串,或将 JSON 字符串转换为对象。

  4. 开发工具和 IDE

    • 开发工具和 IDE 使用反射来提供功能,如代码自动补全、调试器的变量查看、JVM 内部状态的检查等。

反射的缺点
  1. 性能开销:反射操作比直接调用方法或访问字段慢得多,因为它绕过了常规的编译期优化。频繁使用反射可能会导致性能瓶颈。

  2. 安全问题:反射允许访问和修改私有成员,这可能破坏类的封装性和安全性。如果滥用,可能导致不可预知的错误或安全漏洞。

  3. 编译时类型检查缺失:由于反射是在运行时执行的,编译器无法对反射的操作进行类型检查。很多错误会在运行时才会暴露出来,增加了调试的复杂性。

  4. 可维护性降低:反射代码可能较难理解和维护,特别是当其被广泛使用时,会使得代码变得复杂且难以跟踪。

为什么框架需要反射?

框架使用反射主要是为了提供灵活性和动态性,使得程序员可以编写更少的代码,同时实现复杂的功能。例如,Spring 框架使用反射来自动装配依赖、管理 Bean 的生命周期,和实现 AOP 功能。这使得开发者可以专注于业务逻辑,而不需要处理繁琐的底层实现细节。

11. Java 泛型、类型擦除和通配符

泛型

Java 泛型是一种允许类、接口和方法在定义时使用类型参数的机制。泛型使得代码可以适用于多种数据类型,同时提供编译时的类型检查,增强了代码的安全性和可重用性。示例:

public class Box<T> {
    private T item;

    public void setItem(T item) {
        this.item = item;
    }

    public T getItem() {
        return item;
    }
}

在这个示例中,Box 类使用了泛型类型参数 T,因此可以在不修改 Box 类代码的情况下,创建适用于不同类型的 Box 对象。

类型擦除(Type Erasure)

类型擦除是 Java 泛型的一个重要特性。Java 编译器在编译过程中会删除或替换泛型信息,这意味着在运行时,所有的泛型类型参数都被擦除,转换为原始类型。

  • 作用: 类型擦除允许 Java 泛型与现有的非泛型代码兼容,保持了 Java 的向后兼容性。

  • 运行时表现: 由于类型擦除,泛型的类型参数在运行时并不存在。例如,List<String>List<Integer> 在运行时都是 List,无法通过反射或其他方式区分它们。

类型擦除的过程:

  1. 替换类型参数: 编译器用类型参数的上界替换类型参数。如果没有指定上界,替换为 Object

  2. 删除泛型方法的类型参数: 泛型方法的类型参数在编译时被擦除。

  3. 插入强制类型转换: 编译器会在必要的地方插入类型转换,以确保类型安全。

示例
public class Example<T> {
    public void print(T item) {
        System.out.println(item);
    }
}
在编译后,类型参数 T 被擦除为 Object:

public class Example {
    public void print(Object item) {
        System.out.println(item);
    }
}
常用的通配符

Java 泛型中的通配符用于表示不确定的类型,它们在泛型类和方法中提供了灵活性。

  1. 无界通配符 (?)

    • 定义: 表示未知的类型,适用于泛型类型参数可以是任何类型的场景。

    • 用途: 在只读取或只写入操作时使用,表示可以接受任何类型。

    • 示例:

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

  2. 上界通配符 (? extends T)

    • 定义: 表示类型参数可以是 TT 的子类型。

    • 用途: 常用于对泛型类型参数进行读取操作,限制类型参数的上界。

    • 示例:

      public void printNumbers(List<? extends Number> list) {
          for (Number num : list) {
              System.out.println(num);
          }
      }

  3. 下界通配符 (? super T)

    • 定义: 表示类型参数可以是 TT 的父类型。

    • 用途: 常用于对泛型类型参数进行写入操作,限制类型参数的下界。

    • 示例:

      public void addNumbers(List<? super Integer> list) {
          list.add(10);
          list.add(20);
      }

泛型的优势
  1. 类型安全: 在编译时进行类型检查,减少了运行时的类型转换异常。

  2. 代码重用: 编写泛型类或方法后,可以对多种类型使用,提升代码的重用性。

  3. 可读性和维护性: 泛型提供了更清晰的代码意图,使代码更易于理解和维护。

12.内部类、匿名内部类

内部类

内部类(Inner Class)是在另一个类的内部定义的类。Java 提供了多种类型的内部类,它们的主要目的是为了使代码更加清晰和结构化,尤其是在需要对类进行逻辑分组或对外界隐藏类的实现细节时。内部类与其外部类有紧密的联系,它们可以访问外部类的成员,包括私有成员。

内部类的几种类型

  1. 成员内部类(Member Inner Class)

    • 成员内部类是定义在另一个类中的类,但不在方法内。它作为外部类的一个成员存在,因此可以访问外部类的所有成员,包括私有字段和方法。

    • 示例:

      class OuterClass {
          private String message = "Hello, World!";
      
          class InnerClass {
              void display() {
                  System.out.println(message); // 访问外部类的成员
              }
          }
      }

  2. 局部内部类(Local Inner Class)

    • 局部内部类是在外部类的方法或作用域中定义的类,它的作用范围仅限于该方法或作用域。局部内部类可以访问包含它的方法中的局部变量,但这些局部变量必须是 final(在 Java 8 之后隐式的 final 变量也可以被访问)。

    • 示例:

      class OuterClass {
          void myMethod() {
              final int number = 42;
              class LocalInnerClass {
                  void printNumber() {
                      System.out.println(number); // 访问方法中的局部变量
                  }
              }
              LocalInnerClass localInner = new LocalInnerClass();
              localInner.printNumber();
          }
      }

  3. 静态内部类(Static Nested Class)

    • 静态内部类是用 static 修饰的内部类,它与外部类没有绑定关系。由于是静态的,它不能访问外部类的实例成员,只能访问外部类的静态成员。

    • 示例:

      class OuterClass {
          static String message = "Hello, World!";
      
          static class StaticNestedClass {
              void display() {
                  System.out.println(message); // 访问外部类的静态成员
              }
          }
      }

  4. 匿名内部类(Anonymous Inner Class)

    • 匿名内部类是没有名字的内部类,通常用于创建简单的类实例,这些类通常实现接口或继承自其他类。匿名内部类在创建时会立即实例化。

    • 语法:

      new 父类或接口() { // 类体定义 };

匿名内部类

特点

  1. 没有名字:匿名内部类没有类名,所以无法在其他地方复用。

  2. 可以继承一个类或实现一个接口:匿名内部类可以继承一个类或实现一个接口,但只能继承或实现一个(Java 的单继承机制决定了这一点)。

  3. 不能有构造函数:由于没有类名,匿名内部类不能定义构造函数,但可以通过实例代码块来初始化。

  4. 使用场景:通常用于实现回调、事件监听器或在需要一个简单的类时。

示例:

  • 继承一个类的匿名内部类:

abstract class Animal {
    abstract void makeSound();
}

public class Test {
    public static void main(String[] args) {
        Animal animal = new Animal() {  // 创建匿名内部类  
   //本来应该是Animal animal = new Dog();
            void makeSound() {
                System.out.println("Woof");
            }
        };
        animal.makeSound(); // 输出 "Woof"
    }
}
  • 实现一个接口的匿名内部类:

interface Greeting {
    void sayHello();
}

public class Test {
    public static void main(String[] args) {
        Greeting greeting = new Greeting() {  // 创建匿名内部类
//本来应该是Greeting greeting = new EnglishGreeting();
            public void sayHello() {
                System.out.println("Hello, World!");
            }
        };
        greeting.sayHello(); // 输出 "Hello, World!"
    }
}

13. BIO,NIO,AIO 有什么区别?

BIO、NIO 和 AIO 是 Java 中处理 I/O(输入/输出)操作的三种不同模式,每种模式在性能和使用场景上都有所不同。

1. BIO(Blocking I/O) - 阻塞 I/O

联想:传统餐厅-点菜就只能等着服务员把菜端出来,服务员(线程)会一直“阻塞”在厨房,直到食物准备好。

特点:

  • 同步阻塞:在 BIO 模型中,I/O 操作(如读写)是同步阻塞的。当一个线程发起 I/O 操作时,这个线程会被阻塞,直到 I/O 操作完成为止。即一个连接(客户端)对应一个处理线程。

  • 简单易用:编程模型简单直观,容易理解和使用,适用于小型应用或连接数较少的应用场景。

缺点:

  • 资源消耗大:由于每个连接都需要一个独立的线程来处理,线程资源消耗较大,特别是高并发场景下,线程数量可能会迅速增加,导致性能瓶颈。

  • 不适合高并发:由于阻塞的特点,在大量并发连接时,系统会因为线程的频繁创建和切换而出现性能问题。

使用场景:适用于连接数目较少、应用场景较简单的场合,如传统的 C/S 架构系统。

示例:

ServerSocket serverSocket = new ServerSocket(8080);
Socket socket = serverSocket.accept(); // 阻塞等待客户端连接
InputStream inputStream = socket.getInputStream(); // 阻塞等待数据输入
2. NIO(Non-blocking I/O) - 非阻塞 I/O

联想:自助餐厅-随意走动,菜还没做好先取别的菜。

特点:

  • 同步非阻塞:在 NIO 模型中,I/O 操作是非阻塞的。线程可以发起一个 I/O 操作后立即返回,然后通过轮询(Selector)来检查 I/O 操作是否已经完成,而不是一直等待。

  • 多路复用:NIO 使用了多路复用技术,通过一个线程(或少量线程)来管理多个连接(通道),因此可以处理大量的并发连接。

缺点:

  • 编程复杂:由于引入了多路复用和非阻塞机制,编程模型变得复杂,处理 I/O 操作时需要考虑更多的细节。

  • 轮询开销:Selector 的轮询虽然减少了线程数,但也会带来一定的性能开销。

使用场景:

  • 适用于高并发、高负载的场景,如聊天服务器、网络游戏服务器等需要同时处理大量连接的场景。

示例:

Selector selector = Selector.open();
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

while (true) {
    selector.select(); // 阻塞等待事件发生
    Set<SelectionKey> selectedKeys = selector.selectedKeys();
    for (SelectionKey key : selectedKeys) {
        if (key.isAcceptable()) {
            // 处理连接接收事件
        } else if (key.isReadable()) {
            // 处理读事件
        }
    }
}
3. AIO(Asynchronous I/O) - 异步 I/O

联想:高端餐厅-点菜之后自己随意进行活动,菜好了送到你面前。

特点

  • 异步非阻塞:在 AIO 模型中,I/O 操作是异步的。线程可以发起 I/O 操作后立即返回,由操作系统或框架自动完成 I/O 操作,并在操作完成后通过回调机制通知应用程序。

  • 真正的异步:AIO 通过回调函数(CompletionHandler)来处理 I/O 操作的完成事件,不需要手动轮询,提升了系统的响应效率。

缺点

  • 复杂性和支持性:AIO 的使用和配置较为复杂,虽然其异步模型理论上更优,但由于其依赖于底层操作系统的支持(在某些操作系统上,AIO 的性能并不一定优于 NIO),因此应用较少。

使用场景

  • 适用于对响应速度要求非常高的应用,尤其是在高并发场景下,AIO 能够发挥出最大的性能优势。

示例

AsynchronousServerSocketChannel serverSocketChannel = AsynchronousServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress(8080));

serverSocketChannel.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() {
    @Override
    public void completed(AsynchronousSocketChannel result, Void attachment) {
        // 处理连接接收事件
        serverSocketChannel.accept(null, this); // 接受下一个连接
    }

    @Override
    public void failed(Throwable exc, Void attachment) {
        exc.printStackTrace();
    }
});
总结
  • BIO 适用于小规模、低并发的简单场景,使用简单,但资源消耗较大。

  • NIO 是为了解决高并发场景下的性能瓶颈问题,通过非阻塞和多路复用机制,提高了系统的扩展性和性能。

  • AIO 进一步提升了并发处理能力,通过异步回调的方式减少了线程阻塞,适用于对性能要求极高的应用。

选择哪种 I/O 模型取决于应用的具体需求和环境。对于一般的高并发场景,NIO 是较为常用的选择,而对于需要极高性能的场景,AIO 是一个值得考虑的方案。

Java集合

1. List,Set,Map

  • List:一种有序的集合,允许存储重复的元素。List 通过索引来访问元素,可以根据插入顺序进行排序。适用于需要有序且允许重复的集合。

  • Set:一种无序的集合,不允许存储重复的元素。Set 不保证元素的顺序,主要用于确保集合中没有重复的对象。适用于需要去重的集合。

  • Map:一种键值对的集合,每个键(key)对应一个值(value)。Map 中的键是唯一的,不能重复,而值则可以重复。适用于键值对映射的集合。

1. List的常见实现及其底层数据结构

  • ArrayList

    • 底层数据结构:动态数组(Object[])。

    • 特点:支持随机访问,查询速度快(时间复杂度为 O(1));插入和删除操作在中间位置时会导致大量元素移动,效率较低(平均时间复杂度为 O(n));扩容时需要复制数组,通常扩容为原来大小的 1.5 倍。原因是要在性能和内存使用之间找到一个平衡点

  • LinkedList

    • 底层数据结构:双向链表。

    • 特点:插入和删除操作效率较高(时间复杂度为 O(1));查询操作效率较低(时间复杂度为 O(n));适合频繁插入、删除操作的场景。

  • Vector

    • 底层数据结构:动态数组(Object[])。

    • 特点:与 ArrayList 相似,但线程安全,所有方法都被 synchronized 修饰,性能较低。

2. Set 的常见实现及其底层数据结构

  • HashSet

    • 底层数据结构:基于 HashMap 实现的哈希表(数组 + 链表/红黑树)。

    • 特点:不保证顺序;通过哈希算法确定元素的位置,增删查操作速度快(平均时间复杂度为 O(1));不允许存储重复元素,允许存储一个 null 值。

  • LinkedHashSet

    • 底层数据结构:基于 LinkedHashMap 实现的哈希表和双向链表。

    • 特点:保留插入顺序;其他特点与 HashSet 相同。

  • TreeSet

    • 底层数据结构:红黑树(平衡二叉搜索树)。

    • 特点:元素按自然顺序或比较器顺序排序(时间复杂度为 O(log n));不允许存储重复元素。

3. Map 的常见实现及其底层数据结构

  • HashMap

    • 底层数据结构:哈希表(数组 + 链表/红黑树)。

    • 特点:通过哈希算法将键值对映射到数组的索引位置;查询、插入、删除操作速度快(平均时间复杂度为 O(1));不保证顺序;允许一个 null 键和多个 null 值。

  • LinkedHashMap

    • 底层数据结构:哈希表和双向链表。

    • 特点:保留插入顺序或访问顺序(可选择);其他特点与 HashMap 相同。

  • TreeMap

    • 底层数据结构:红黑树(平衡二叉搜索树)。

    • 特点:键值对按键的自然顺序或比较器顺序排序(时间复杂度为 O(log n));不允许 null 键。

2. 线程不安全的集合

  • 线程安全的:Vector、Stack、HashTable、ConcurrentHashMap

  • 不安全的:ArrayList、LinkedList、HashSet、LinkedHashSet、TreeSet、HashMap、TreeMap

不安全的原因:内部操作没有同步控制,多线程访问可能导致竞态条件。并发环境下可能出现数据不一致、异常等问题。

解决方法

1. 使用 Collections.synchronizedXXX 方法

Java 提供了 Collections 工具类,可以通过 Collections.synchronizedListCollections.synchronizedSetCollections.synchronizedMap 方法将普通的集合包装成线程安全的集合。

  • 示例

    List<String> synchronizedList = Collections.synchronizedList(new ArrayList<>());
    Set<String> synchronizedSet = Collections.synchronizedSet(new HashSet<>());
    Map<String, String> synchronizedMap = Collections.synchronizedMap(new HashMap<>());

  • 注意:在使用同步集合时,迭代操作仍需要手动同步以防止 ConcurrentModificationException 异常:

    synchronized (synchronizedList) {
        for (String item : synchronizedList) {
            // 迭代操作
        }
    }

2. 使用并发集合

Java 提供了 java.util.concurrent 包下的并发集合,这些集合是专门为多线程环境设计的,内部实现了更细粒度的同步控制或无锁算法,性能优于 Collections.synchronizedXXX

  • 常见并发集合

    • CopyOnWriteArrayList:线程安全的 ArrayList 实现,适合读多写少的场景。

    • CopyOnWriteArraySet:线程安全的 Set 实现,基于 CopyOnWriteArrayList

    • ConcurrentHashMap:线程安全的 HashMap 实现,基于分段锁机制,适合高并发读写操作。

    • ConcurrentLinkedQueue:线程安全的队列实现,基于无锁算法,适合高并发场景。

  • 示例

    List<String> concurrentList = new CopyOnWriteArrayList<>();
    Map<String, String> concurrentMap = new ConcurrentHashMap<>();

3. 手动同步

对于某些特定的操作或业务逻辑,可以使用 synchronized 关键字来手动控制线程访问的同步。将需要保护的代码块放在 synchronized 块中,确保在同一时刻只有一个线程能够执行该代码块。

  • 示例

    synchronized (lockObject) {
        // 对集合进行操作
        list.add("item");
    }

4. 使用 ThreadLocal

对于每个线程都需要独立访问的数据,可以使用 ThreadLocal 来实现线程安全。每个线程都会有自己独立的副本,不存在竞争。

  • 示例

    ThreadLocal<List<String>> threadLocalList = ThreadLocal.withInitial(ArrayList::new);

3. HashSet、LinkedHashSet 和 TreeSet 三者的异同

相同点

  • 实现接口:它们都实现了 Set 接口,因此它们都遵循 Set 的特性,即不允许存储重复的元素。

  • 线程不安全:它们都不是线程安全的。如果需要在多线程环境下使用,需要手动同步或使用 Collections.synchronizedSet() 方法进行包装,或使用并发集合如 ConcurrentSkipListSet

不同点

特性/集合HashSetLinkedHashSetTreeSet
底层数据结构哈希表(基于 HashMap哈希表 + 双向链表(基于 LinkedHashMap红黑树(平衡二叉搜索树)
元素顺序无序(不保证顺序)按照插入顺序排序按照元素的自然顺序或提供的比较器排序
性能查询、插入、删除操作的时间复杂度为 O(1)查询、插入、删除操作的时间复杂度为 O(1)查询、插入、删除操作的时间复杂度为 O(log n)
允许 null允许一个 null允许一个 null不允许 null
使用场景需要快速查找元素且不关心顺序的场景需要维护插入顺序且希望快速查找元素的场景需要对元素进行排序或按顺序迭代的场景

4. HashMap、 Hashtable 、 HashSet 三者的异同

相同点

  • 基于哈希表的实现

    • HashMapHashtable 都是基于哈希表实现的键值对映射(key-value pairs)的集合类。

    • HashSet 底层也是基于 HashMap 实现的,只不过它只存储键(key),而值(value)是一个固定对象。

  • 不允许重复元素

    • HashMapHashtable 中不允许键重复。

    • HashSet 不允许存储重复元素。

  • 时间复杂度

    • 对于 HashMapHashtableHashSet,插入、删除、查找操作的平均时间复杂度都是 O(1)。

不同点

特性/集合HashMapHashtableHashSet
数据结构基于哈希表实现,内部结构是数组 + 链表 + 红黑树基于哈希表实现(数组+链表)基于 HashMap 实现,元素存储为 HashMap 的键
线程安全不是线程安全的线程安全,所有方法都使用 synchronized 进行同步不是线程安全的
允许 null允许一个 null 键和多个 null不允许 null 键或 null允许一个 null 元素
性能性能高,适合单线程环境性能低于 HashMap,适合多线程环境性能高,适合单线程环境
引入版本Java 1.2Java 1.0Java 1.2
适用场景适合大部分不需要线程安全的场景适合需要线程安全的场景适合存储唯一元素且不需要线程安全的场景

5. HashMap和TreeMap的异同

相同点

  • 实现接口:它们都实现了 Map 接口,因此都用于存储键值对(key-value pairs)。

  • 线程不安全HashMapTreeMap 都不是线程安全的。在多线程环境中,需要手动同步或使用并发集合类(如 ConcurrentHashMap)。

不同点

特性/集合HashMapTreeMap
数据结构哈希表(数组 + 链表 + 红黑树)红黑树(平衡二叉搜索树)
顺序不保证顺序根据键的自然顺序或比较器排序
时间复杂度插入、删除、查找操作的时间复杂度为 O(1)插入、删除、查找操作的时间复杂度为 O(log n)
允许 null允许一个 null 键和多个 null不允许 null 键,但允许 null
性能一般性能较好,适合大部分情况下的快速查找和插入性能相对较低,适合需要排序或按顺序访问的情况
适用场景需要快速查找、插入和删除,且不关心顺序的场景需要对键进行排序或按顺序遍历的场景

6. HashMap底层实现

1. HashMap 的底层实现

HashMap 的底层数据结构主要包括以下几个部分:

  • 数组HashMap 内部使用一个数组来存储 Node 对象(链表或红黑树的节点)。这个数组被称为桶(bucket)。

  • 链表:当两个不同的键经过哈希函数计算后,落在了相同的桶中(即发生哈希冲突),这些冲突的键值对会被存储在同一个链表中。

  • 红黑树:当链表长度超过一定阈值(默认是 8)时,链表会转换为红黑树,以优化查找性能。

基本操作

  • 插入操作

    1. 计算键的哈希值,并根据哈希值找到对应的桶。

    2. 如果桶是空的,直接插入。

    3. 如果桶中有数据,判断是否存在相同的键,如果有则更新值;如果没有则将新节点插入到链表或红黑树中。

  • 查找操作

    1. 通过键计算哈希值,定位到对应的桶。

    2. 在桶中遍历链表或红黑树,找到相应的键值对并返回值。

  • 扩容

    • HashMap 中的元素数量超过 threshold(阈值,默认是 capacity * load factor),HashMap 会进行扩容,通常是将容量扩大为当前容量的两倍。

2. HashMap 的长度

HashMap 的长度(即桶的数量)总是保持为 2 的幂次方,这样设计的原因主要有以下几点:

  1. 提高哈希分布的均匀性

    • HashMap 的长度是 2 的幂次方时,HashMap 使用 key.hashCode() & (n - 1)n 是数组的长度)来定位数组的下标。这样计算可以确保哈希值的每一位都被用到,从而减少哈希冲突的可能性,使得数据分布更加均匀。

  2. 快速计算索引

    • 使用位运算 key.hashCode() & (n - 1) 来计算数组下标比取模运算 key.hashCode() % n 更快。因为位运算比取模运算要快得多,而这种位运算仅在 n 为 2 的幂次方时才有效。

  3. 节省空间

    • 由于数组长度为 2 的幂次方,可以更高效地利用内存,并在扩容时避免空间浪费。

3. 扩展说明:负载因子与扩容
  • 负载因子(load factor)HashMap 中的负载因子是一个决定 HashMap 何时需要扩容的参数。默认值是 0.75,意味着当 HashMap 的容量达到总长度的 75% 时,HashMap 会自动扩容(2倍)。

  • 扩容时的再哈希:当 HashMap 扩容时,所有的键值对需要重新计算哈希值并放入新的桶中。这是一个代价较大的操作,因此尽量避免频繁扩容可以提高性能。

7. ConcurrentHashMap 和 Hashtable 的区别?

特性HashtableConcurrentHashMap
线程安全实现方式使用 synchronized 锁整个数据结构使用分段锁(Java 7)或 CAS + synchronized(Java 8 及以后)
锁的粒度整个数据结构加锁部分桶/段加锁(Java 7),或 CAS + synchronized(Java 8 及以后)
性能高并发情况下性能较差高并发情况下性能更好
允许 null 键/值不允许 null 键或 null不允许 null 键或 null
遍历操作遍历时锁住整个集合,性能较差遍历时使用弱一致性,支持更高效的并发遍历
迭代器一致性强一致性(遍历期间修改集合会抛出 ConcurrentModificationException弱一致性(可能会反映出修改后的内容,但不保证反映所有修改)
适用场景较少使用,通常在现代 Java 开发中不推荐使用适合高并发环境,尤其是频繁读写操作的场景

8. ConcurrentHashMap 底层实现

1. 底层数据结构

ConcurrentHashMap 的底层主要由以下几个部分构成:

  • 数组:用于存储桶(bucket),每个桶可以是一个链表、红黑树或空的。

  • 链表:处理哈希冲突(链地址法)的基本结构,当链表长度超过阈值时,会转化为红黑树。

  • 红黑树:在链表长度超过一定阈值时,用于提高查找性能。

2. 线程安全实现方式

Java 7 及之前的实现

在 Java 7 及之前版本中,ConcurrentHashMap 使用分段锁(Segment)的设计:

  • 分段锁

    • ConcurrentHashMap 将数据结构划分为多个段(Segment),每个段是一个独立的哈希表,每个段使用独立的锁进行同步。这样,不同段之间的操作是并行的,减少了锁的竞争。

    • 默认情况下,ConcurrentHashMap 使用 16 个段(可以通过构造函数设置)。

    • 每个段内部使用 synchronized 关键字来实现线程安全。

  • 数据结构

    • 每个段使用数组 + 链表(或红黑树)来存储数据。

Java 8 及以后版本的实现

在 Java 8 及以后版本中,ConcurrentHashMap 的实现做了很多改进:

  • 锁分离机制

    • 在 Java 8 中,ConcurrentHashMap 不再使用分段锁。取而代之的是更细粒度的锁机制,即通过CAS(Compare-And-Swap)操作和 synchronized 来提高并发性能。

    • 使用了一种更精细的锁控制机制来减少锁的持有时间,并使用 synchronized 锁定桶内的链表或红黑树的节点。

  • 数据结构

    • ConcurrentHashMap 使用了一个数组加链表和红黑树的结构来存储数据。链表在长度超过阈值时会转换为红黑树,以提高性能。

    • 每个桶内部的链表或树结构在进行写操作(插入、更新、删除)时使用 synchronized 锁定,在尝试插入新元素时则使用 CAS 操作来保持并发性。读取操作在绝大多数情况下是无锁的,通过 volatile 修饰符来保证可见性,使得读取操作可以看到最新的数据变化。

3. 操作细节
  • 插入操作

    • 计算键的哈希值,根据哈希值确定桶的位置。如果首节点f为null则用CAS添加。

    • 如果不为空,使用 synchronized 锁定链表或树结构,以避免并发写操作引起的数据不一致。

    • 如果链表长度超过8且数据总量超过64,链表将转换为红黑树,以优化查找和插入操作。

  • 读取操作

    • 读取操作通过 CAS 操作获取数据,不需要对整个数据结构加锁。

    • 通过 volatile 变量和 synchronized 锁,确保数据的可见性和一致性。

  • 扩容

    • ConcurrentHashMap 中的元素数量超过阈值时,进行扩容操作。扩容过程中使用 synchronized 锁和重新计算哈希值,将元素移动到新的桶中。

4. 迭代器
  • 弱一致性迭代器

    • ConcurrentHashMap 提供的迭代器是弱一致性的,即在迭代期间可以进行并发修改操作,而不会抛出 ConcurrentModificationException

    • 迭代器在创建时会进行快照,确保迭代器视图的一致性,但不保证反映所有的并发修改。

总结
  • 在 Java 7 中,ConcurrentHashMap 使用分段锁来实现线程安全,提高了并发性能。

  • 在 Java 8 及以后版本中,ConcurrentHashMap 采用了更细粒度的锁控制机制,结合 CAS 操作和 synchronized 锁,进一步提高了性能。

  • ConcurrentHashMap 的底层数据结构主要由数组、链表和红黑树构成,针对哈希冲突提供了高效的解决方案。

Java并发

1. 线程和进程

特性进程(Process)线程(Thread)
基本单位操作系统分配资源的基本单位CPU 调度的基本单位
内存空间独立的内存空间(独立的堆和栈),不同进程之间不能直接访问对方内存共享进程的内存空间(包括堆和代码段),但每个线程有自己的栈
资源共享不共享,进程间通信成本较高共享进程的资源,线程间通信成本较低
创建开销创建和销毁开销较大,涉及分配独立的内存空间创建和销毁开销较小,因共享进程的资源
切换开销上下文切换开销较大上下文切换开销较小
崩溃影响一个进程的崩溃不会影响其他进程一个线程的崩溃可能导致整个进程崩溃
使用场景适用于独立运行的任务(如单个程序的执行)适用于并发操作的任务(如多任务并行处理)
优点- 独立性强,进程之间隔离,崩溃不互相影响- 通信简单,因共享内存空间
- 更安全的资源分配和管理- 创建和销毁的开销较低
缺点- 创建和切换开销较大- 容易出现线程安全问题,需要同步机制
- 进程间通信复杂- 一个线程的异常可能导致整个进程崩溃

总结

  • 进程:适合需要高安全性、稳定性以及独立执行的任务,尽管通信和切换的开销较高。

  • 线程:适合高并发、低开销的任务,并发编程中多线程可以提高性能,但需要注意同步问题和异常处理。

2. 为什么要使用多线程

1. 提高程序执行效率

  • 并行处理:多线程可以在多核处理器上实现并行处理,多个线程可以同时执行,从而提高程序的执行速度。

  • 资源利用:通过多线程可以更好地利用 CPU 资源,避免 CPU 空闲时间,提高系统整体效率。

2. 改善用户体验

  • 响应性:在图形界面程序中,多线程可以使得程序在执行耗时操作时仍然保持响应,比如在后台执行文件下载的同时,界面仍然可以响应用户的点击。

  • 异步操作:多线程可以用于执行异步操作,比如在服务器端处理多个客户端请求时,每个请求可以分配一个独立的线程处理,避免阻塞其他请求。

3. 简化程序设计

  • 自然模型:有些问题(如生产者-消费者问题)天然适合用多线程的方式建模。使用多线程可以使代码更直观,更容易理解和维护。

  • 并发操作:在需要并发处理多个任务的场景下,多线程可以简化代码编写,比如在服务器上同时处理多个客户端请求。

4. 更好的资源管理

  • I/O 密集型任务:对于 I/O 密集型任务,如文件读取、网络请求,使用多线程可以在等待 I/O 完成的同时继续处理其他任务,避免 CPU 闲置。

  • 任务分解:多线程可以将复杂的任务分解为多个子任务,并行处理,从而加快任务完成的速度。

5. 处理高并发

  • 大规模并发:在需要处理大量并发请求(如 web 服务器)时,多线程可以帮助处理多个请求,同时保证系统的稳定性和性能。

6. 避免主线程阻塞

  • 长时间任务:将长时间执行的任务放在后台线程中运行,避免阻塞主线程(如 UI 线程),从而保持程序的流畅性。

总结

使用多线程可以显著提升程序的性能和用户体验,尤其是在需要并行处理、处理大量并发请求、或执行异步操作的场景下。它可以充分利用多核处理器的优势,提高系统资源利用率,简化程序设计。然而,多线程编程也带来了一些挑战,如线程安全、死锁等问题,因此需要谨慎设计和使用。

3. 上下文切换

上下文切换(Context Switching)是操作系统在多任务环境下切换 CPU 执行任务时的一个关键过程。它指的是保存当前正在执行的进程或线程的状态(上下文),并恢复另一个进程或线程的状态,以便 CPU 可以继续执行新任务。

上下文包括哪些内容?

在上下文切换中,“上下文”通常包括以下内容:

  1. 程序计数器(Program Counter, PC):记录当前线程正在执行的指令地址。

  2. 寄存器(Registers):保存线程当前使用的 CPU 寄存器的状态,包括通用寄存器、栈指针(SP)、基址指针(BP)等。

  3. 堆栈信息(Stack Information):保存线程的堆栈信息,包括函数调用栈、局部变量等。

  4. 内存管理信息:进程的内存分配、页表等信息。

  5. 其他状态信息:如线程的优先级、标志位、错误码等。

上下文切换的步骤

  1. 保存当前线程的上下文:当操作系统决定切换任务时,它首先会保存当前正在运行的线程的上下文,以便将来恢复这个线程时能从中断的地方继续执行。

  2. 加载即将运行线程的上下文:接下来,操作系统从内存中恢复另一个线程的上下文,加载该线程的程序计数器、寄存器和其他状态信息。

  3. 切换线程:CPU 开始执行新线程的代码。

上下文切换的代价

上下文切换是操作系统实现多任务处理的基本手段,但它也带来了一定的性能开销:

  1. 时间开销:保存和恢复上下文需要时间,频繁的上下文切换会降低 CPU 的实际工作效率。

  2. 缓存污染:切换到一个新线程后,CPU 的高速缓存可能需要重新加载数据,导致缓存命中率下降,进而影响系统性能。

  3. 资源开销:操作系统需要管理每个进程或线程的上下文,增加了系统的资源开销。

什么时候会发生上下文切换?

上下文切换通常在以下几种情况下发生:

  1. 多任务调度:操作系统按照调度算法在多个进程或线程之间切换执行权,以实现并发执行。

  2. 中断处理:当硬件设备产生中断时,CPU 需要保存当前任务的上下文,转而执行中断处理程序。

  3. 系统调用:当进程发出系统调用时,操作系统可能会切换到内核态,执行相应的操作,然后再切换回用户态。

  4. 资源等待:当线程由于 I/O 操作或资源争用进入等待状态时,CPU 会切换到其他线程执行,待资源可用时再切换回来。

总结

上下文切换是操作系统在多任务处理中不可避免的一部分,它确保了多个任务能够共享 CPU 资源。然而,由于上下文切换带来了额外的时间和资源开销,因此需要通过合理的调度算法和设计来最小化不必要的上下文切换,以提高系统的整体性能。

4. 线程死锁

什么是线程死锁?

线程死锁(Deadlock)是指在多线程环境中,两个或多个线程互相等待对方释放资源,从而进入无限期等待的状态。简单来说,死锁发生在一个线程持有的资源被另一个线程请求,而这个线程本身又在等待其他线程持有的资源,导致所有相关线程都无法继续执行。

死锁发生的四个必要条件

根据“Coffman条件”理论,死锁的发生需要同时满足以下四个条件:

  1. 互斥条件(Mutual Exclusion):资源在某个时刻只能被一个线程占用。

  2. 请求与保持条件(Hold and Wait):线程已经持有至少一个资源,同时还在等待其他线程持有的资源。

  3. 不剥夺条件(No Preemption):线程已经获得的资源在未使用完毕前不能被其他线程强行剥夺。

  4. 循环等待条件(Circular Wait):两个或多个线程形成资源请求的循环等待链,即 T1 等待 T2 持有的资源,而 T2 又等待 T1 持有的资源。

只有在这四个条件都满足的情况下,死锁才会发生。

如何避免死锁?

避免死锁的方法主要围绕打破上述四个条件来进行。常见的策略包括:

1. 破坏循环等待条件
  • 资源有序分配:为每种资源分配一个全局顺序编号,并要求所有线程按顺序请求资源,避免形成循环等待。例如,线程在请求资源时总是按照从小到大的顺序请求,确保不会形成循环。

2. 破坏请求与保持条件
  • 一次性分配所有资源:要求线程一次性申请它所需要的所有资源,如果无法获得全部资源,则释放已获得的资源并重新尝试。这种方式避免了线程在持有部分资源的情况下等待其他资源。

  • 资源重试机制:如果线程无法获得所有资源,则释放已经获得的资源,并等待一段随机时间后重新尝试获取资源。

3. 破坏不剥夺条件
  • 允许资源剥夺:设计资源分配策略,使得某些资源可以被强行剥夺,例如可以强行中断某些低优先级线程并回收其资源,从而避免高优先级线程进入死锁。

4. 死锁检测和恢复
  • 死锁检测:定期检查系统中是否存在死锁情况,一旦发现死锁,可以选择中断某些线程来打破死锁循环。

  • 死锁恢复:如果检测到死锁,可以通过强制终止某些线程或回收部分资源的方式来恢复系统的正常运行。

5. 避免持有资源时间过长
  • 减少锁的粒度:避免持有资源的时间过长,例如尽量减少临界区的代码量,快速释放资源。

  • 使用超时机制:在请求资源时设置超时,如果超时未获得资源,则主动释放已经持有的资源,避免长时间占用资源。

5. 悲观锁和乐观锁

1. 悲观锁(Pessimistic Lock)
  • 假设:悲观锁假设每次操作都会有并发修改,因此在操作数据前会先锁定资源,以避免其他线程对数据的修改。

  • 实现方式:在数据库或其他资源操作时,悲观锁会锁定资源,如 SELECT FOR UPDATE 或数据库表锁。这种方式保证了在锁定期间,其他线程无法修改该资源。

  • 优点:避免了数据冲突,保证了数据的一致性。

  • 缺点:由于频繁加锁,可能会导致锁竞争严重,降低系统性能,特别是在高并发环境下。

2. 乐观锁(Optimistic Lock)
  • 假设:乐观锁假设大多数情况下数据竞争是不会发生的,因此在操作数据时不加锁,而是在提交更新时检查数据是否被其他线程修改过。

  • 实现方式:乐观锁通常使用版本号机制时间戳来实现。在更新操作时,会检查记录的版本号或时间戳,如果在提交更新时发现版本号或时间戳没有变化,则说明没有其他线程修改过数据,可以安全更新;如果发现变化,则说明有其他线程修改过数据,需要重新尝试或放弃更新。

  • 优点:减少了锁的使用,提升了系统的并发性能。

  • 缺点:在高并发修改的场景下,可能会导致大量的重试或失败。

如何实现乐观锁

乐观锁的实现方式通常采用版本号机制时间戳机制

1. 版本号机制
  • 步骤

    1. 添加版本号字段:在数据库表中为每一条记录添加一个 version 字段,初始值为 1。

    2. 读取数据:当读取数据时,获取当前的 version 值。

    3. 更新操作:在提交更新时,检查记录的当前 version 是否与读取时的 version 相同。如果相同,说明数据没有被修改过,可以执行更新,并将 version 增加 1;如果不同,说明数据已经被其他线程修改过,需要重新读取数据并尝试更新。

  • SQL 示例

    UPDATE table_name
    SET column1 = value1, version = version + 1
    WHERE id = record_id AND version = current_version;

    如果更新成功,表示版本号一致且数据未被其他线程修改。如果更新失败,表示版本号已变化,数据被其他线程修改过,需要重新尝试。

2. 时间戳机制
  • 步骤

    1. 添加时间戳字段:在数据库表中为每条记录添加一个 timestamp 字段,记录最后一次修改的时间。

    2. 读取数据:当读取数据时,获取当前的 timestamp 值。

    3. 更新操作:在提交更新时,检查记录的当前 timestamp 是否与读取时的 timestamp 相同。如果相同,说明数据没有被修改过,可以执行更新,并更新 timestamp;如果不同,说明数据已经被其他线程修改过,需要重新读取数据并尝试更新。

总结
  • 悲观锁通过加锁来防止数据竞争,适用于高竞争场景,但可能降低并发性能。

  • 乐观锁通过版本号或时间戳来检测数据是否被修改,适用于大多数情况下竞争较少的场景,可以提高并发性能。

乐观锁通常适用于读多写少的场景,因为它减少了不必要的锁开销,提高了系统的并发能力,而悲观锁则更适合竞争激烈的场景,确保数据的一致性。

6. sleep() 和 wait() 异同

共同点

  • 阻塞线程sleep()wait() 方法都会导致当前线程暂停执行,进入阻塞状态。

  • 恢复执行:当阻塞状态解除后,线程会恢复执行。

区别

特性sleep()wait()
所属类ThreadObject
用途用于让线程暂停执行指定的时间。用于在线程间进行同步,通常在多线程环境中用于线程间通信。
锁的释放不会释放持有的锁调用 wait() 方法时会释放当前持有的锁
恢复方式线程在指定的时间后自动恢复。必须由其他线程调用 notify()notifyAll() 方法,或者线程被中断才能恢复。
需要捕获异常需要捕获或声明 InterruptedException需要捕获或声明 InterruptedException
调用的线程状态在运行状态 (RUNNING) 下调用。在同步块或同步方法内部调用,并且线程必须持有对象的监视器。
常用场景用于实现定时操作或暂停线程一段时间。用于实现线程间的通信或线程等待某一条件的发生。

详细解释

1. sleep() 方法
  • 描述Thread.sleep() 是一个静态方法,属于 Thread 类。调用 sleep() 后,当前线程进入休眠状态,暂停执行一段指定的时间(以毫秒为单位)。

  • 锁的状态sleep() 不会释放线程持有的任何锁。因此,如果在同步块或同步方法中调用 sleep(),其他线程仍无法获取锁,必须等待当前线程恢复执行并释放锁。

  • 恢复执行:线程在指定的时间结束后自动恢复执行,并继续后续操作。

  • 应用场景:适用于需要暂停线程执行的场景,比如实现定时任务。

2. wait() 方法
  • 描述wait()Object 类中的方法,必须在同步块或同步方法中调用,且调用线程必须持有该对象的监视器(即锁)。调用 wait() 后,线程会进入等待状态,并释放当前持有的对象锁。

  • 锁的状态wait() 会释放持有的锁,允许其他线程获取锁并执行。当其他线程调用该对象的 notify()notifyAll() 方法时,等待的线程才会被唤醒,并尝试重新获取锁。

  • 恢复执行:被唤醒后,线程会尝试重新获取对象的锁,并在获取到锁后继续执行。

  • 应用场景:适用于线程间的通信,例如一个线程在等待某个条件满足时调用 wait(),当其他线程使该条件满足时,调用 notify()notifyAll() 唤醒等待线程。

总结
  • sleep() 主要用于线程暂停一段时间后继续执行,不涉及锁的释放与线程通信。

  • wait() 则用于线程间通信,线程在调用 wait() 后会释放锁,并等待其他线程唤醒自己再继续执行。

7.Java内存区域和JMM(Java 内存模型)

Java 内存区域Java 内存模型(JMM)是两个不同的概念,它们分别描述了 Java 程序在执行时的内存布局和多线程间的内存交互。以下是它们的区别和关系:

Java 内存区域

Java 内存区域描述了 JVM 在执行 Java 程序时所使用的各种内存区域。主要包括:

  1. 程序计数器(PC Register):线程执行位置

    • 每个线程都有一个程序计数器,记录当前线程执行的字节码指令的地址。它的作用是跟踪线程执行的位置。

  2. 堆(Heap):对象

    • 存储对象实例和数组 (字符串常量池也在)。所有线程共享这个区域。Java 堆是垃圾收集器(GC)管理的主要区域。

  3. 方法区(Method Area):类和代码

    • 存储类的结构信息、运行时常量池、静态变量和编译后的代码。所有线程共享这个区域。

  4. 栈(Stack):方法调用时

    • 每个线程都有自己的栈,用于存储局部变量、操作栈帧、方法调用的参数和返回值等信息。栈是线程私有的。

  5. 本地方法栈(Native Method Stack):本地方法调用

    • 与 Java 堆类似,但用于执行 native 方法(使用Java Native Interface- JNI 调用的本地方法)。每个线程有自己的本地方法栈。

Java 内存模型(JMM)

Java 内存模型(Java Memory Model, JMM)定义了多线程访问共享变量的规则,确保变量的可见性、原子性和有序性。主要包括:

  1. 内存可见性:JMM 确保一个线程对共享变量的修改对其他线程是可见的。volatile 关键字就是用来保证变量可见性的。

  2. 原子性:JMM 规定了对变量的基本读写操作是原子的,但复合操作(如 i++)不是原子的,需要通过其他同步机制(如 synchronized)来保证原子性。

  3. 有序性:JMM 规定了指令重排序的规则,确保在多线程环境中,线程对共享变量的操作顺序是可控的。volatile 关键字也用于避免指令重排序问题。

  4. happens-before 规则:用于定义操作间的排序关系,确保在多线程环境下操作的执行顺序性。

happens-before发生在..之前
  • 如果一个操作 A 发生在另一个操作 B之前,那么操作 A 的结果对于操作 B 是可见的。换句话说,B 能够“看到” A 的结果,并且 A 的执行顺序在B 之前。

以下是一些常见的 happens-before 规则

  1. 程序次序规则(Program Order Rule)

    • 在一个线程内,按照程序顺序,前面的操作 A happens-before 后面的操作 B。例如,在同一个线程中,语句 int a = 1; happens-before 语句 int b = a + 1;

  2. 监视器锁规则(Monitor Lock Rule)

    • 对一个锁的解锁操作 unlock happens-before 随后对该锁的加锁操作 lock。这意味着在一个线程释放锁之后,另一个线程获取该锁时,可以看到前一个线程在释放锁之前对共享变量的修改。

  3. volatile 变量规则(Volatile Variable Rule)

    • 对一个 volatile 变量的写操作 happens-before 随后对该 volatile 变量的读操作。也就是说,写入一个 volatile 变量的值对后续读取该 volatile 变量的线程是可见的。

  4. 传递性(Transitivity)

    • 如果操作 A happens-before 操作 B,而操作 B happens-before 操作 C,那么操作 A happens-before 操作 C

  5. 线程启动规则(Thread Start Rule)

    • 在主线程中调用 Thread.start() 方法 happens-before 该线程开始执行的任何操作。这意味着在启动新线程之前,主线程的操作对于新线程是可见的。

  6. 线程终止规则(Thread Termination Rule)

    • 一个线程中的所有操作 happens-before 其他线程检测到这个线程已经终止的操作(如 Thread.join() 返回)。这意味着当一个线程终止时,其他线程可以看到该线程的所有操作结果。

  7. 对象终结规则(Finalizer Rule)

    • 一个对象的构造函数中的操作 happens-before 该对象的 finalize() 方法。这意味着在对象被垃圾收集器回收之前,所有构造函数的操作都已经完成,并且对 finalize() 方法可见。

happens-before 原则的主要作用是定义多线程程序中操作的可见性和顺序性。在编写并发程序时,确保符合 happens-before 原则可以避免常见的并发问题,如数据竞争和不一致的读取操作。

总结

  • happens-before 是 Java 内存模型中用于定义操作顺序和可见性的原则。

  • 通过 happens-before 原则,可以保证在多线程环境中,一个线程的操作对另一个线程是可见的,并且可以确定操作的执行顺序。

  • 常见的 happens-before 规则包括程序次序规则、监视器锁规则、volatile 变量规则、线程启动和终止规则等。

区别和关系

  • Java 内存区域描述了 JVM 内部的内存结构,包括线程私有和共享的内存区域,具体是如何存储和管理数据的。

  • Java 内存模型(JMM)则关注于如何在多线程环境中保证内存操作的一致性和可见性,定义了变量的访问规则和内存交互行为。

  • 关系:JMM 是对 Java 内存区域的行为进行规范的模型,确保多线程程序在访问共享变量时遵循一致的内存访问规则。JMM 规定了如何在 Java 堆、方法区等内存区域中进行数据的读写操作,并保证这些操作在多线程环境中的可见性和有序性。

8.synchronized

synchronized 关键字通过锁机制解决了多线程环境下的原子性、可见性和有序性问题,确保了线程间对共享资源的正确访问和修改。它在多线程编程中是一个基础而又重要的工具,帮助开发者实现线程安全。

解决的问题

  1. 原子性问题

    • 问题描述:在多线程环境下,多个线程可能同时访问或修改共享数据,导致数据的修改操作不是原子的。例如,在不使用 synchronized 的情况下,多个线程可能会同时执行同一个方法或代码块,从而导致操作的结果不正确。

    • 解决方案synchronized 关键字可以保证同一时刻只有一个线程执行被同步的方法或代码块,从而确保这些操作是原子的,即操作要么全部执行完成,要么不执行。

  2. 可见性问题

    • 问题描述:线程之间共享变量时,一个线程修改了共享变量的值,其他线程未必能立刻看到修改后的值。这个问题源于 CPU 缓存和内存之间的可见性问题。

    • 解决方案synchronized 保证了进入同步块或方法的线程能够看到共享变量的最新值,并且在退出同步块或方法时,线程对共享变量的修改会立即刷回主内存,使得其他线程能够看到最新的变化。

  3. 有序性问题

    • 问题描述:为了优化性能,编译器和处理器可能会对指令进行重排序,这可能导致在多线程环境下代码执行的顺序与预期不一致。

    • 解决方案synchronized 不仅保证了同步块中的代码以正确的顺序执行,还在同步块的进入和退出时插入了“内存屏障”,防止指令重排序。

如何使用 synchronized

  • 同步实例方法:锁定当前对象实例,确保同一个对象实例的方法不能被多个线程同时执行。

    public synchronized void method() {
        // 同步方法
    }
  • 同步静态方法:锁定类的 Class 对象,确保同一个类的静态方法不能被多个线程同时执行。

    public static synchronized void staticMethod() {
        // 同步静态方法
    }
  • 同步代码块:锁定指定的对象或类,可以灵活控制锁的范围和粒度。

    public void method() {
        synchronized(this) {
            // 同步代码块
        }
    }

底层原理

  1. Monitor 对象

    • 每个对象都有一个 Monitor(监视器),Monitor 是依赖于底层操作系统的同步机制实现的。

    • 当一个线程进入同步代码块或同步方法时,它会尝试获取该对象的 Monitor,如果 Monitor 被其他线程持有,则当前线程会进入阻塞状态,直到 Monitor 被释放。

  2. 字节码指令

    • 当使用 synchronized 修饰方法或代码块时,编译器会在编译时生成相应的字节码指令。

    • 同步方法:对于同步方法,编译器会在方法的访问标志中设置 ACC_SYNCHRONIZED 标志。JVM 在调用该方法时会自动尝试获取方法所在对象的 Monitor,获取成功后才能执行方法体。

    • 同步代码块:对于同步代码块,编译器会生成 monitorentermonitorexit 两条字节码指令。monitorenter 用于尝试获取对象的 Monitor,而 monitorexit 用于释放 Monitor。

  3. Monitor 锁

    • Monitor 锁是一个对象锁,当线程获取对象的 Monitor 锁后,其他线程就无法再获取该锁,从而实现了同步。

    • 在 JVM 中,Monitor 是基于操作系统的互斥锁实现的,主要依赖于操作系统的 mutex 机制。

  4. 锁的优化

    • 偏向锁:在无竞争的情况下,锁会倾向于被第一个获取它的线程持有,减少同步的开销。

    • 轻量级锁:基于CAS实现,如果CAS操作失败进入自旋状态,持续尝试使用CAS操作来获取锁,不会阻塞自己,适用于短时间持有锁的场景。

    • 重量级锁:当竞争激烈时,JVM 会升级到重量级锁,此时线程会被阻塞和唤醒。

工作流程概述

  1. 方法级同步

    • JVM 会检查方法的 ACC_SYNCHRONIZED 标志,如果设置了该标志,JVM 会自动在方法调用时获取 Monitor 锁。

  2. 代码块同步

    • JVM 执行 monitorenter 指令,尝试获取对象的 Monitor 锁。

    • 如果成功获取,线程进入同步代码块,执行完毕后,通过 monitorexit 指令释放锁。

  3. 锁升级:JVM 会根据线程竞争的情况,逐步升级锁的级别,从偏向锁到轻量级锁,再到重量级锁。

    原理:在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候threadid 为空,jvm 让其持有偏向锁,并将 threadid 设置为其线程 id,再次进入的时候会先判断threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了synchronized 锁的升级。

    目的:为了减低了锁带来的性能消耗。在Java6之后优化 synchronized 的实现方式,使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,从而减低了锁带来的性能消耗。

9.volatile

volatile关键字在 JMM 中解决了以下两个问题:
  1. 可见性volatile 关键字保证了变量的可见性。它确保当一个线程修改了 volatile 变量的值,其他线程可以立即看到这个修改。具体实现是,使用 volatile 修饰的变量在写操作后会立即刷新到主内存中,而读操作会从主内存中读取最新的值。

  2. 禁止指令重排序volatile 还禁止了指令重排序优化。对于使用 volatile 修饰的变量,读操作和写操作不会与其他的读写操作重排序。这在某些场景下可以确保程序执行的顺序性。

volatile的原理

获取IIT(即时]ava编译器,把字节码解释为机器语言发送给处理器)的汇编代码,发现volatile多加了lock addl指令,这个操作相当于一个内存屏障,使得lock指令后的指令不能重排序到内存屏障前的位置。这也是为什么IDK1.5以后可以使用双锁检测实现单例模式。

lock前缀的另一层意义是使得本线程工作内存中的volatile变量值立即写入到主内存中,并且使得其他线程共享的该volatile变量无效化,这样其他线程必须重新从主内存中读取变量值。

10. synchronized 和 volatile 的区别

特性volatilesynchronized
解决的问题可见性、禁止指令重排序原子性、可见性
原子性不保证原子性,无法保证复合操作的完整性(如 i++)保证代码块的原子性,即同步块中的操作要么全部执行,要么全部不执行
可见性保证变量的可见性,修改会立即刷新到主内存保证同步块中所有变量的可见性
指令重排序禁止指令重排序在同步块内部指令也不会被重排序
锁机制不涉及锁机制,不会阻塞线程使用内部锁(即对象锁)实现,会导致阻塞
性能相对轻量级,没有线程阻塞与唤醒的开销相对较重,因为涉及线程上下文切换和锁竞争
适用场景适用于状态独立的共享变量,要求简单读写的场景适用于需要原子性操作和锁定代码块的场景
总结
  • volatile 关键字主要解决多线程环境下共享变量的可见性问题,同时防止指令重排序,适用于简单的读写场景。但它不保证复合操作的原子性(如自增、自减等),因此不能完全替代锁机制。

  • synchronized 关键字通过内部锁机制来保证代码块的原子性和可见性,适用于需要确保线程安全的复杂操作场景。由于涉及锁的竞争与上下文切换,synchronized 的开销较大,性能不如 volatile

  • JMM 的作用就是规范了多线程间如何共享和访问数据,通过 volatilesynchronized 等机制来解决多线程的可见性、原子性和有序性问题。

11.synchronized 和 ReentrantLock 的区别

ReetrantLock是一个可重入的独占锁,主要有两个特性,一个是支持公平锁和非公平锁,一个是可重入。依赖于AQS(AbstractQueuedsynchronizer)实现。主要依靠AQS维护一个阻塞队列,多个线程对加锁时,失败则会进入阻塞队列。等待唤醒,重新尝试加锁。

特性SynchronizedReentrantLock
使用方式内置关键字,修饰方法或代码块显式锁,需要手动获取和释放锁
锁的获取与释放隐式获取和释放,由 JVM 自动处理手动获取 (lock()) 和释放 (unlock()),通常在 try-finally 块中使用
中断性不可中断可中断,支持 lockInterruptibly() 方法
锁的公平性非公平锁支持公平锁和非公平锁(通过构造函数设置)
锁的特性简单易用,适合基础同步提供更多功能,如可重入、超时获取锁、查询锁状态
重入性支持,同一线程可以重入锁多次支持,同一线程可以多次获取同一个锁
性能JVM 优化后性能较好,适合简单同步性能灵活,可满足复杂同步需求,但性能可能略低于 synchronized
功能扩展无额外功能支持尝试锁定 (tryLock())、条件变量 (Condition) 等高级功能
适用场景适合简单的同步需求适合复杂的并发场景,要求更精细的锁控制

12.ThreadLocal 关键字

ThreadLocal 是 Java 提供的一种线程局部变量,它为每个使用该变量的线程都创建了一个独立的变量副本。这样,每个线程都可以独立地修改自己的副本,而不会与其他线程的副本相互干扰。ThreadLocal 主要用于解决线程安全问题,避免了显式的同步。

主要特点和使用场景
  1. 线程独立的变量副本

    • 每个线程都有一个单独的 ThreadLocal 副本,互不影响。可以使用 get()set() 方法来获取和设置当前线程的 ThreadLocal 值:

      ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
      threadLocal.set(100);
      Integer value = threadLocal.get(); // 获取当前线程的值

  2. 常见使用场景

    • 用户信息存储:在 web 应用中,一个请求对应一个线程,ThreadLocal 可以用于在整个请求生命周期中存储用户会话信息等。

    • 数据库连接、Session 会话:在处理数据库事务时,ThreadLocal 可以用于管理同一线程中的数据库连接或会话。

    • 非线程安全对象的封装:将某些非线程安全的对象(如 SimpleDateFormat)封装在 ThreadLocal 中,确保每个线程都有自己的实例。

使用 ThreadLocal 时需要注意可能引发的内存泄露问题,特别是在使用线程池的场景中。

内存泄露的原因
  1. ThreadLocal 的内部实现

    • ThreadLocal 实际上是通过 Thread 类的 ThreadLocalMap 实现的。ThreadLocalMapThread 类的一个成员变量,键是 ThreadLocal 实例,值是线程局部变量的副本。

    • 如果 ThreadLocal 对象没有被显式地移除(调用 remove() 方法),当线程结束后,ThreadLocal 实例可能仍然存在于 ThreadLocalMap 中,导致内存泄露。

  2. 弱引用与内存泄露

    • ThreadLocalMap 中,ThreadLocal 实例是以弱引用存储的。这意味着当 ThreadLocal 对象被垃圾回收时,键会变为 null。但对应的值(即线程局部变量的副本)依然会存在于 ThreadLocalMap 中,从而导致内存泄露。

  3. 线程池的影响

    • 在使用线程池时,线程不会立即被销毁,而是会被重复使用。如果 ThreadLocal 没有被正确清理,那么下一个使用该线程的任务可能会继承上一个任务遗留的 ThreadLocal 数据,从而导致内存泄露或数据污染。

避免内存泄露的措施
  1. 手动清理

    • 在不再使用 ThreadLocal 变量后,及时调用 remove() 方法显式移除:

      threadLocal.remove();
  2. 合理使用 ThreadLocal

    • 避免在长期存活的线程(如线程池中的线程)中使用 ThreadLocal,或确保使用后及时清理。

  3. 谨慎管理资源

    • 在设计系统时,尤其是在涉及到线程池和 ThreadLocal 结合使用时,需要特别注意资源管理,避免线程重复使用带来的问题。

总结

ThreadLocal 是一个强大的工具,适用于需要线程局部存储的场景,但在使用时需要小心管理,特别是在涉及线程池的环境中,必须注意及时清理,避免潜在的内存泄露问题。

13.线程池、内置线程池

线程池的作用

线程池是 Java 中用于管理和重用一组线程的机制,它可以在并发编程中显著提升应用程序的性能和资源管理效率。线程池的主要作用包括:

  1. 减少线程创建和销毁的开销

    • 线程的创建和销毁是有成本的,尤其是在并发量较大的情况下。线程池通过复用线程,避免了频繁的创建和销毁,节省了系统资源和时间。

  2. 提高响应速度

    • 当任务到达时,线程池可以立即从池中分配一个空闲线程来执行任务,而不需要等待新线程的创建,从而提高了系统的响应速度。

  3. 更好地管理资源

    • 线程池允许设置线程的最大数量,防止系统资源耗尽(例如,过多的线程导致内存耗尽)。这对于限制高并发情况下的资源使用特别重要。

  4. 提高系统的稳定性

    • 线程池可以帮助避免过度创建线程的问题,例如在高并发场景下,如果没有线程池,系统可能会因为创建过多线程而导致资源耗尽、系统崩溃等问题。

内置线程池

Java 中的 Executors 类提供了一些便捷的方法来创建常用的线程池,如 newFixedThreadPoolnewCachedThreadPoolnewSingleThreadExecutor 等。

  • newFixedThreadPool:创建一个固定数量的线程池,适合处理长期运行的并发任务。

  • newCachedThreadPool:创建一个可动态调整线程数量的线程池,适合处理大量短期异步任务。

  • newSingleThreadExecutor:创建一个单线程池,适合需要按顺序执行任务的场景。

然而,这些内置线程池并不总是适合所有的场景,以下是几个常见的原因:

  1. 无界队列导致的资源耗尽

    • 例如,newFixedThreadPoolnewSingleThreadExecutor 创建的线程池使用的是 LinkedBlockingQueue,它是一个无界队列。在高并发的情况下,如果任务提交速度超过任务处理速度,这些任务会无限制地堆积在队列中,可能导致 OutOfMemoryError

  2. newCachedThreadPool 的风险

    • newCachedThreadPool 会为每个提交的任务创建一个新线程,如果任务非常多且执行时间较长,可能导致系统创建大量线程,耗尽系统资源,导致性能下降甚至系统崩溃。

  3. 缺乏自定义能力

    • 内置的线程池创建方法通常不允许用户自定义线程池的核心参数,如线程池大小、队列类型、拒绝策略等。而实际生产环境中,往往需要根据具体业务需求定制这些参数,以更好地控制线程池的行为。

总结

线程池是并发编程中重要的工具,可以有效提升性能和资源管理效率。然而,使用 Java 内置的线程池可能存在潜在的风险,如无界队列导致的内存泄漏或系统资源耗尽。因此,在生产环境中,建议使用 ThreadPoolExecutor 自定义线程池参数,以更好地适应具体的业务场景。

14. 自定义线程池

为了避免上述问题,通常建议使用 ThreadPoolExecutor 来创建自定义线程池,明确指定线程池的各种参数:

ThreadPoolExecutor executor = new ThreadPoolExecutor(
    corePoolSize,   // 核心线程数:线程池中保持存活的最小线程数量,即使这些线程处于空闲状态也不会被回收。
    maximumPoolSize, // 最大线程数:线程池中允许的最大线程数量。
    keepAliveTime,  // 空闲线程的存活时间:当线程池中的线程数量超过核心线程数时,多余的空闲线程的存活时间。
    timeUnit,       // 存活时间的单位:可以是 TimeUnit.MILLISECONDS、TimeUnit.SECONDS 等。
    workQueue,      // 任务队列:用于保存等待执行的任务的队列。
    threadFactory,  // 线程工厂:用于创建新线程的工厂。可以给线程池命名。
    handler         // 拒绝策略:当线程池和队列都满了时,任务提交将会触发拒绝策略
);

通过这种方式,你可以:

  1. 根据业务需求设置合适的核心线程数和最大线程数

  2. 选择适合的任务队列类型,如 ArrayBlockingQueue(有界队列)以防止任务无限制堆积。

  3. 配置适当的拒绝策略(如丢弃、抛异常或调用者运行策略)以处理超出处理能力的任务。

动态的修改线程池参数

在Java中,ThreadPoolExecutor 提供了一些方法来动态修改线程池的参数。以下是一些常用的方法来调整线程池的配置:

  1. setCorePoolSize(int corePoolSize):设置线程池的核心线程数。

  2. setMaximumPoolSize(int maximumPoolSize):设置线程池的最大线程数。

  3. setKeepAliveTime(long time, TimeUnit unit):设置线程池中非核心线程的最大空闲时间。

  4. allowCoreThreadTimeOut(boolean value):允许核心线程在空闲一段时间后终止。 下面是一个例子,展示了如何使用这些方法来动态修改线程池的参数:

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class DynamicThreadPool {
    public static void main(String[] args) throws InterruptedException {
        // 创建线程池
        int corePoolSize = 5;
        int maximumPoolSize = 10;
        long keepAliveTime = 1;
        TimeUnit unit = TimeUnit.MINUTES;
        BlockingQueue<Runnable> workQueue = new java.util.concurrent.LinkedBlockingQueue<>();
        
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                corePoolSize,
                maximumPoolSize,
                keepAliveTime,
                unit,
                workQueue
        );
        // 运行一段时间后,动态修改线程池参数
        Thread.sleep(5000); // 等待5秒后修改参数
        
        // 设置新的核心线程数
        executor.setCorePoolSize(10);
        
        // 设置新的最大线程数
        executor.setMaximumPoolSize(15);
        
        // 设置非核心线程的空闲存活时间
        executor.setKeepAliveTime(2, TimeUnit.MINUTES);
        
        // 允许核心线程超时终止
        executor.allowCoreThreadTimeOut(true);
        // 其他操作...
    }
}

在使用这些方法时,请注意以下几点:

  • 安全性:动态修改线程池参数时,需要确保线程池的稳定性。例如,减小核心线程数可能会导致正在执行的任务被中断。

  • 同步:这些方法不是线程安全的,因此在并发环境下修改线程池参数时,应该同步操作或者确保没有其他线程正在使用线程池。

  • 线程状态:在调整线程池参数时,线程池可能会根据新的参数调整其线程的数量。例如,增加核心线程数可能导致线程池创建新的线程,而减少核心线程数可能导致空闲的核心线程被终止。

  • 队列容量:如果线程池的工作队列容量有限,修改线程池参数时需要考虑到队列中可能积压的任务数量。 动态调整线程池参数是一种高级操作,通常用于根据应用程序的负载动态调整资源使用,以提高性能和资源利用率。在实际应用中,需要谨慎使用,并且最好在充分理解线程池工作原理的基础上进行。

线程池处理任务的流程

1. 提交任务:通过调用 execute(Runnable task) 或 submit(Callable task) 方法
   |
   v
2. 判断线程池状态:状态正常就进入处理流程
   |
   v
3. 判断当前线程数与 corePoolSize
   |--(线程数 < corePoolSize)--> 创建新线程执行任务
   |--(线程数 >= corePoolSize)--> 进入任务队列(阻塞队列)
   |
   v
4. 判断任务队列
   |--(队列未满)--> 放入队列
   |--(队列已满)--> 判断当前线程数与 maximumPoolSize
   |   |--(线程数 < maximumPoolSize)--> 创建新线程执行任务
   |   |--(线程数 >= maximumPoolSize)--> 触发拒绝策略
   |
   v
5. 线程执行任务
   |
   v
6. 判断线程空闲时间,决定是否终止空闲线程
   |
   v
7. 线程池关闭(可选):可以通过调用 shutdown() 或 shutdownNow() 方法来关闭线程池。

阻塞队列的类型

  1. ArrayBlockingQueue:一个有界的阻塞队列,基于数组实现,必须指定容量。适合需要限制队列大小的场景。

  2. LinkedBlockingQueue:一个可选界限的阻塞队列,基于链表实现。如果不指定容量,它将是无界的。newFixedThreadPoolnewSingleThreadExecutor 默认使用此队列。

  3. SynchronousQueue:一个不存储元素的阻塞队列,每个插入操作必须等待另一个线程来取出,否则插入操作会阻塞。适合高并发短任务的场景,newCachedThreadPool 默认使用此队列。

  4. PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列,队列中的元素必须实现 Comparable 接口。适合需要按优先级处理任务的场景。

  5. DelayQueue:一个无界的阻塞队列,其中的元素只有在指定的延迟时间过去后才能被取走。适用于延时任务的场景。

拒绝策略的类型

当线程池无法处理新的任务时(例如,线程池中的线程已达到 maximumPoolSize 且队列已满),会触发拒绝策略。Java 提供了四种内置的拒绝策略:

  1. AbortPolicy(默认):直接抛出 RejectedExecutionException 异常,阻止系统正常工作。

  2. CallerRunsPolicy:由提交任务的线程(通常是主线程)直接运行该任务。这种策略不会丢弃任务,但会影响提交任务的线程的性能。

  3. DiscardPolicy:直接丢弃无法处理的任务,不会抛出异常。

  4. DiscardOldestPolicy:丢弃队列中最老的任务,然后尝试重新提交当前任务。

线程池命名

原因:方便调试、日志分析、监控、可读性、规范性

方法:线程工厂、使用Executors类的预定义方法、使用ThreadPoolExecutor直接构造、使用第三方库

15. Semaphore

Semaphore 是 Java 中的一个同步工具类,它用于控制对共享资源的访问数量。Semaphore 维护了一个许可集,线程可以通过获取许可来访问资源,并在使用完毕后释放许可。以下是其主要用途和原理:

主要用途

  1. 资源池管理Semaphore 常用于实现资源池,例如数据库连接池,确保同一时间访问资源的线程数量不会超过设定的最大值。

  2. 并发限制:控制同时执行某一操作的线程数量,例如,限制同时进行文件读写的线程数量。

  3. 信号量控制:用于线程间的信号传递,比如一个线程需要等待另一个线程完成某项任务后才能继续执行。

工作原理

Semaphore 的原理基于“信号量”的概念,它内部维护了一个计数器,用于表示当前可用的许可数量。Semaphore 提供了以下主要方法:

  • acquire(int permits):尝试获取指定数量的许可。如果当前计数器大于等于指定的数量,则减去相应的数量并立即返回,否则线程会阻塞直到有足够的许可可用。

  • release(int permits):释放指定数量的许可,并增加计数器的值。如果此时有正在等待许可的线程,它们将有机会获取许可并继续执行。

  • tryAcquire(int permits):尝试获取指定数量的许可,如果立即获取成功则返回 true,否则返回 false 而不会阻塞。

  • tryAcquire(int permits, long timeout, TimeUnit unit):在指定的时间内尝试获取许可,如果在超时时间内获取成功则返回 true,否则返回 false

示例:
初始状态:许可数量 = 3
-----------------------
线程A: acquire(1)  -> 许可数量 = 2
线程B: acquire(2)  -> 许可数量 = 0
线程C: release(2) -> 许可数量 = 2
线程D: acquire(3)  -> 等待,因为许可数量不足
线程A: release(1) -> 许可数量 = 3
线程D: 获取许可(因为线程C释放了许可)-> 许可数量 = 0
-----------------------

在内部实现上,Semaphore 主要依赖于 AbstractQueuedSynchronizer(AQS)框架,它是一个基于先进先出(FIFO)等待队列的同步器,用于实现阻塞锁和其他同步工具。 通过这种方式,Semaphore 能够有效地控制对共享资源的并发访问,是处理并发编程中资源限制问题的重要工具。

16. AQS

AQS是一个锁框架,定义了锁的实现机制,底层是由同步队列+条件队列组成,提供四个场景:获得锁,释放锁,条件队列的阻塞和唤醒。 AQS同步器是基于模版方法的设计模式,自定义同步器时需要重写模版方法。详见<JavaGuide>P98

17. 多个任务的编排方法

1. 使用 java.util.concurrent 包中的类:

  • ExecutorService:通过线程池执行多个任务,并可以使用 Future 来跟踪每个任务的执行状态。

  • CountDownLatch:允许一个或多个线程等待一组事件的完成。

  • CyclicBarrier:允许一组线程互相等待,直到所有线程都达到某个屏障点后才继续执行。

  • Phaser:可用于实现类似 CyclicBarrier 的功能,但更加灵活。

2. 使用 CompletableFuture

CompletableFuture 是Java 8引入的一个并发编程的强大工具,它提供了非阻塞的操作来组合多个异步任务。以下是使用 CompletableFuture 进行任务编排的一些方法:

  • 链式调用:使用 thenApply, thenAccept, thenCompose 等方法将多个任务按顺序链接起来。

  • 并行执行:使用 thenCombine, thenAcceptBoth, runAfterBoth 等方法来并行执行多个任务,并在它们都完成后执行某些操作。

  • 处理结果:使用 handle 方法来处理任务的结果或异常。

  • 异常处理:使用 exceptionallywhenComplete 方法来处理可能发生的异常。 以下是一个使用 CompletableFuture 编排多个任务的简单示例:

import java.util.concurrent.CompletableFuture;
public class CompletableFutureExample {
    public static void main(String[] args) {
        // 任务1
        CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
            // 模拟长时间运行的任务
            try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            return "Result of Task 1";
        });
        // 任务2,依赖于任务1的结果
        CompletableFuture<String> future2 = future1.thenApply(result1 -> {
            // 使用任务1的结果
            return result1 + " + Result of Task 2";
        });
        // 任务3,与任务2并行执行
        CompletableFuture<String> future3 = CompletableFuture.supplyAsync(() -> {
            // 模拟长时间运行的任务
            try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            return "Result of Task 3";
        });
        // 等待任务2和任务3都完成
        CompletableFuture<Void> combinedFuture = CompletableFuture.allOf(future2, future3);
        // 获取最终结果
        combinedFuture.thenAccept(v -> {
            System.out.println(future2.join()); // 输出任务2的结果
            System.out.println(future3.join()); // 输出任务3的结果
        }).join();
    }
}

至于项目是否使用了 CompletableFuture,这取决于具体的项目需求和技术选型。CompletableFuture 提供了一种非常灵活和强大的方式来处理异步任务,特别是当需要编排多个任务或者处理任务之间的依赖关系时。如果你的项目中有这样的需求,那么使用 CompletableFuture 是一个很好的选择。不过,具体是否使用,需要查看项目的代码库和设计文档来确定。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值