JVM、多线程

java后端面试题大全

  • 1.JVM
    • 1.1 运行时数据区(JVM内存)是怎么样的?(难度:★★ 频率:★★★★)
    • 1.2 java类的加载流程(难度:★★ 频率:★★★★★)
    • 1.3 哪些情况会导致JVM内存泄漏(难度:★★ 频率:★★)
    • 1.4 JVM双亲委派模型(难度:★★★ 频率:★)
    • 1.5 JDK8垃圾回收机制(难度:★★★ 频率:★★★★)
    • 1.6 垃圾回收器的工作原理(难度:★★★ 频率:★★)
    • 1.7 Minor GC和Full GC的触发时机。(难度:★★★ 频率:★★)
    • 1.8 JVM调优参数(难度:★★ 频率:★★★★)
  • 2.多线程
    • 2.1 ThreadLocal是什么?(难度:★★ 频率:★★★★)
    • 2.2 ThreadLocal导致内存泄漏(难度:★★ 频率:★★★)
    • 2.3 项目中哪些地方用到了多线程?(难度:★★★ 频率:★★★★★)
    • 2.4 死锁以及死锁产生的条件?(难度:★★ 频率:★★★★★)
    • 2.5 i++是否线程安全?(难度:★ 频率:★)
    • 2.6 synchronized的使用(难度:★★ 频率:★★)
    • 2.7 SimpleDateFormat线程安全吗?怎么保证线程安全?(难度:★★★ 频率:★★★)
    • 2.8 线程池的优缺点难度(难度:★★ 频率:★★)
    • 2.9 线程池有哪些参数?(难度:★★ 频率:★★★★★)
    • 2.10 阻塞队列的作用以及用法(难度:★★ 频率:★★★)
    • 2.11 不同阻塞队列的区别(难度:★★ 频率:★★★)
    • 2.12 线程池有哪几种?它们分别对应什么队列?(难度:★★ 频率:★★★)
    • 2.13 线程池执行流程(难度:★★ 频率:★★★★★)
    • 2.14 线程工厂的作用, 以及使用方法(难度:★ 频率:★★)
    • 2.15 线程池拒绝策略有哪些?默认是哪个?(难度:★ 频率:★★★★)
    • 2.16 如何捕获线程池中的异常? (难度:★★ 频率:★★★★★)

1.JVM

1.1 运行时数据区(JVM内存)是怎么样的?(难度:★★ 频率:★★★★)

JVM、多线程_第1张图片
运行时数据区主要分为5块:

  1. 方法区
  2. 虚拟机栈
  3. 本地方法栈
  4. 程序计数器

1.方法区
方法区是java虚拟机中一个特殊的区域, 用来存储类的信息和静态数据, 主要包括以下内容

  1. 类的信息: 包括类的全限定名、父类的信息、类的接口等。
  2. 字段信息:包括类的静态字段和实例字段的信息,如字段名、字段类型、字段修饰符等。
  3. 方法信息:包括类的静态方法和实例方法的信息,如方法名、方法参数、方法返回值类型、方法修饰符等。
  4. 静态变量:包括类的静态变量,这些变量在类加载时被初始化,并在类的整个生命周期中存在。
  5. 常量池:用于存储类的常量数据,包括字符串常量、数值常量、类名、字段名、方法名等。

方法区是所有线程共享的,不同的线程共享方法区中的数据。当一个类被加载到方法区中时,它的信息、字段、方法、常量池和静态变量都会被创建,并存储在方法区中,供类的实例和方法使用

2.堆

  1. 对象实例: 包括对象实例的状态信息以及行为信息
    状态信息: 主要是对象实例的字段,可以是基本数据类型的值,也可以是引用类型的值。
    行为信息:主要是对象实例的方法。
  2. 字符串常量池: 用于存储字符串字面量字符串常量。字符串常量池中的字符串对象在类的整个生命周期中是不变的,并且它们是线程安全的。
  3. 数组: 包括数组的元素数组的长度。数组的元素可以是基本数据类型的值,也可以是引用类型的值。

3.虚拟机栈
虚拟机栈(Java Virtual Machine Stack,也称为栈帧栈)是Java虚拟机内存模型中的一个重要区域,用于存储栈帧。虚拟机栈是线程私有的,每个线程在启动时都会创建一个虚拟机栈,用于存储该线程执行的方法的栈帧。

4.本地方法栈
本地方法栈(Native Method Stack)是Java虚拟机内存模型中的一个重要区域,用于存储本地方法的栈帧。本地方法栈是线程私有的,每个线程在启动时都会创建一个本地方法栈,用于存储该线程执行的本地方法的栈帧。

本地方法是指由其他编程语言(通常是C或C++)编写的方法,而不是由Java语言编写的方法。这些方法通常是通过Java Native Interface(JNI)调用的,从而允许Java程序调用非Java代码中的方法。

5.程序计数器
是Java虚拟机中的一个内存区域,用于存储当前线程正在执行的字节码指令的地址。程序计数器是线程私有的,每个线程都有自己独立的程序计数器。

程序计数器的主要作用是指示当前线程正在执行哪个字节码指令,从而控制线程的执行流程。当线程开始执行一个方法时,程序计数器会指向该方法的字节码指令序列的第一个字节码指令。随着线程的执行,程序计数器会递增,指向下一个字节码指令。

问题引申: 常量是不是都存储在常量池?
有一些常量是存在在常量池, 但不是所有的常量都存储在常量池

  1. 字符串常量: 字符串常量是存储在字符串常量池中的,例如通过双引号括起来的字符串字面量
String str = "hello"; // "hello" 存储在字符串常量池中
  1. 基本数据类型的常量值: 一些基本数据类型的常量值,例如整数和浮点数的常量,可能会被放入常量池。
int x = 42; // 42 可能被存储在常量池中
  1. final修饰的常量字段: 在类中使用final修饰的基本数据类型或字符串字段,如果其值在编译时已知,那么它们也可能被放入常量池。
public static final int CONSTANT_VALUE = 42; // 常量池中的整数常量

然而,并非所有的常量都会进入常量池。例如,通过运行时计算得到的常量值通常不会被放入常量池,而是在运行时动态生成。此外,对于对象实例、数组等,它们的常量信息一般不会直接放入常量池。

String dynamicString = "Hello" + " " + "World";

在这个例子中,字符串 “Hello World” 的拼接是在运行时发生的,而不是在编译时。因此,这个字符串的实例通常会被存储在堆中,而不是字符串常量池中。

1.2 java类的加载流程(难度:★★ 频率:★★★★★)

java文件到最终运行, 需要经过编译类加载这两个阶段
编译的过程:把.java文件编译成.class文件
类加载的过程:把.class文件加载到jvm的内存中

下图展示了类加载的过程

JVM、多线程_第2张图片

1.加载
加载过程指的是: 将类的字节码文件(.class)从磁盘加载到Java虚拟机内存中这一过程

在这个阶段,Java虚拟机会根据类的全限定名查找字节码文件,以流的方式将其内容读取到内存中,然后将其转换为方法区的运行时数据结构,这个过程也会在方法区中生成一个代表这个类的java.lang.Class对象。

这个数据结构存储了类的字段、方法、构造方法等信息,同时也包含了类的常量池,即字面量(如字符串、final常量)和符号引用等。

2.连接

  • 2.1 验证
    JVM对已经加载的字节码进行校验. 这个阶段主要包括文件格式验证、元数据验证、字节码验证等,以确保类文件的正确性和安全性。这个阶段主要包括文件格式验证、元数据验证、字节码验证等,以确保类文件的正确性和安全性
  • 2.2 准备
    JVM为类中的静态变量分配内存并设置默认初始值. 这些默认初始值通常是数据类型的零值,如整型为0、浮点型为0.0、引用类型为null等。
  • 2.3 解析
    在解析阶段,Java虚拟机会将类的符号引用替换为直接引用。符号引用是对类、方法、字段的描述,而直接引用是对具体内存地址或偏移量的引用

3.初始化
在初始化阶段,Java虚拟机会执行类的静态初始化方法(静态块)和静态变量的赋值操作,以完成类的初始化工作。这个阶段是类加载过程的最后一个阶段,也是最重要的一个阶段。

1.3 哪些情况会导致JVM内存泄漏(难度:★★ 频率:★★)

什么是内存泄漏
程序运行过程中存在指针或引用,指向了不再被应用程序需要的堆内存,因为某些原因, 程序没有正确释放这些堆内存, 导致它们持续占用内存, 最终导致堆内存不足应用程序性能下降

可能会导致内存泄漏的原因
内存泄漏通常是由程序员的错误引起的,可能是由于静态字段未释放资源集合容器改变哈希值内部类持有外部类字符串常量ThreadLocal等等

当发生内存泄漏时,垃圾回收器无法回收这些不再使用的对象,因为这些对象仍然被引用,尽管程序不再需要它们。

1.静态字段(static)导致内存泄漏

import java.util.ArrayList;
import java.util.List;

public class StaticMemoryLeak {
    private static List<MyObject> myObjects = new ArrayList<>();

    public static void main(String[] args) {
        createObjects();
        // 此时,myObjects中持有了MyObject的引用

        // 假设后续的代码逻辑中不再需要这些对象,但由于myObjects中仍然持有引用,它们无法被垃圾回收。
    }

    private static void createObjects() {
        MyObject obj1 = new MyObject();
        MyObject obj2 = new MyObject();

        myObjects.add(obj1);
        myObjects.add(obj2);
    }

    private static class MyObject {
        // 这里可以包含一些数据
    }
}

由于myObjects是静态的, 它会在整个程序运行期间一直存在,而列表中的对象也不会被移除。 即使在应用程序的执行过程中,这些对象已经不再被需要,它们仍然会一直保留在列表中,无法被垃圾回收。

  • 解决方式一: 及时移除对象引用

当不再需要某个对象时,确保将其从静态集合中移除

public static void main(String[] args) {
	   createObjects();
	   // 此时,myObjects中持有了MyObject的引用
	
	   // 假设后续的代码逻辑中不再需要这些对象,移除它们
	   myObjects.clear();
}
  • 解决方式二: 使用弱引用或软引用

如果静态集合中持有的对象不需要强引用,可以考虑使用弱引用(WeakReference)或软引用(SoftReference)。这样,在垃圾回收器决定回收内存时,这些引用对象就会被自动释放。

private static List<WeakReference<MyObject>> myObjects = new ArrayList<>();

private static void createObjects() {
    MyObject obj1 = new MyObject();
    MyObject obj2 = new MyObject();

    myObjects.add(new WeakReference<>(obj1));
    myObjects.add(new WeakReference<>(obj2));
}

2.未关闭的资源导致内存泄漏
未关闭的资源可能导致内存泄漏,特别是对于一些与外部资源(如文件、网络连接、数据库连接等)有关的资源。

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class ResourceLeakExample {
    public static void main(String[] args) {
        readFromFile("example.txt");
        // 此时,文件流未关闭,可能导致资源泄漏
    }

    private static void readFromFile(String filename) {
        BufferedReader reader = null;
        try {
            reader = new BufferedReader(new FileReader(filename));
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            // 注意:在这个例子中,finally块中关闭资源的代码漏写了,可能导致资源泄漏
            
            // 应该添加以下代码确保关闭资源
            if (reader != null) {
                try {
                    reader.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

如果在readFromFile方法调用后, 不再需要这个文件流,它就会一直保持打开状态,可能导致文件资源泄漏

  • 解决方式一: 显式在 finally 块中手动关闭资源
  • 解决方式二: try-with-resources语句
private static void readFromFile(String filename) {
    try (BufferedReader reader = new BufferedReader(new FileReader(filename))) {
        String line;
        while ((line = reader.readLine()) != null) {
            System.out.println(line);
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

知识点引申: try-with-resources的原理和用法

原理:
在 try-with-resources 语句中,可以在 try 关键字后面的圆括号内声明一个或多个资源。这些资源必须实现 AutoCloseable 接口。在 try 代码块执行结束时,无论是否发生异常,会自动调用资源的 close() 方法。这样就无需显式在 finally 块中手动关闭资源。

用法:

try (ResourceType1 resource1 = new ResourceType1();
     ResourceType2 resource2 = new ResourceType2();
     // ... 可以有更多资源声明
) {
    // 代码块,处理资源
} catch (ExceptionType e) {
    // 异常处理
}

3.集合容器
区别于上面的static集合容器, 此处使用非static容器来掩饰

import java.util.ArrayList;
import java.util.List;

@Coment
public class SingleExample{
	
	// 实例变量
    private List<MyObject> myObjects = new ArrayList<>();
	
	// 往集合容器中添加元素
    public void addObject(MyObject obj) {
        myObjects.add(obj);
    }
}

// 元素对象
public class MyObject {
}

// 测试
public static void main(String[] args) {
        BeanFactory beanFactory = new XmlBeanFactory(new ClassPathResource("applicationContext.xml"));
SingleExample example= (User) beanFactory.getBean("singleExample");
        
    MyObject obj1 = new MyObject();
    MyObject obj2 = new MyObject();

    example.addObject(obj1);
    example.addObject(obj2);
}

SingleExample作为单例对象, 在容器生命周期内通常不会被销毁, Spring容器会一直保持这个单例对象的引用, 这种情况下, 实例变量myObjects也会随着 SingleExample对象一起成为不可达对象, 可能会导致内存泄漏

  • 解决方式一: 手动清理资源, 在业务完成后, 调用myObjects.clear()
  • 解决方式二: 改变作用域, 将SingleExample的作用域从单例改为其他作用域
  • 解决方式三: 使用弱引用

1.4 JVM双亲委派模型(难度:★★★ 频率:★)

双亲委派模型是java类加载机制中的一种机制, 通过这种机制, 保证类的加载是由下至上的, 即从子类加载器开始,逐级向上委派,直至达到最顶层的启动类加载器。这是确保类加载的一致性和唯一性的关键机制。

下面是其工作原理:

  1. 类加载请求: 当程序运行时需要加载一个类, 首先由当前类加载器尝试加载该类
  2. 检查类是否已加载: 类加载器检查这个类是否已经被加载过, 如果已经加载过, 直接返回已加载的类的Class对象
  3. 委派给父类加载器: 如果当前类加载器无法加载这个类, 就会委派父类加载器来加载(父类加载器可能是扩展类加载器启动类加载器,取决于当前类加载器的类型)
  4. 递归委派: 父类加载器尝试加载该类,如果父类加载器也无法加载,则会继续向上委派给其父类加载器,直到达到顶层的启动类加载器。
  5. 查找类: 启动类加载器是最顶层的类加载器,它负责加载核心的Java类,如java.lang包中的类。如果顶层的启动类加载器无法加载,就意味着类路径中没有这个类,会抛出ClassNotFoundException。
  6. 加载成功: 如果某个类加载器成功加载了类,就会返回该类的Class对象,并将该Class对象缓存起来,以便下次加载时直接返回已加载的Class对象。

通过这样的双亲委派机制,可以防止类的重复加载,确保了类的唯一性。同时,它也增加了类加载的安全性,防止恶意类的加载,因为类加载器在加载一个类之前会先委派给其父类加载器,如果父类加载器已经加载了这个类,那么就不会加载重复的类,从而避免了恶意类的替换。

1.5 JDK8垃圾回收机制(难度:★★★ 频率:★★★★)

Java内存回收, 主要是针对年轻代(Young)老年代(Tenured)元空间(MetaSpace)进行操作的
JVM、多线程_第3张图片
JVM、多线程_第4张图片

  • 当使用关键词new创建一个新对象时, JVM会将对象分配到Eden区域, 如果Eden区没有足够的空间来容纳新对象,就会触发Minor GC, 清除不活跃的对象, 释放Eden区的内存空间, 随后对Eden区再次判断
    • GC后有空余空间: 将对象分配到Eden区
    • GC后依旧空间不足: 对象晋升到Survivor区
  • 存储到Survivor区之前, 会先判断其空间是否充足
    • 有空余空间: 将对象分配到Survivor区
    • 无空余空间: 对象晋升到Tenured区
  • 存储到Tenured区之前, 会先判断其空间是否充足
    • 有空余空间: 将对象分配到Tenured区
    • 无空余空间: 执行FullGC, 释放年轻代和老年代中保存的不活跃对象
  • 如果老年代的内存区也己经被占满,则会抛“OutOfMemoryError(OOM错误)”,程序将中断运行

Java在每次创建对象时如果发现内存不足都会自动向其他区域延伸。为了提高性能,在实际应用中可能会开辟尽量大的内存空间,以实现更加合理的GC控制。

1.Minor GC
Minor GC是只针对新生代的垃圾回收, 清理Eden区Survivor区不再被引用的对象, 此外它还会将满足年龄的阈值的对象从Eden区晋升到Survivor区, 晋升之后, 原来在 Eden 区的对象会被删除

年轻代的对象晋升到 Survivor 区的年龄阈值通常是15, 具体来说, 对象在 Eden 区创建后, 每次经历一次 Minor GC,对象的年龄就会递增1, 当对象的年龄达到一定的阈值时,它将晋升到 Survivor 区

Minor GC主要采用Parallel Scavenge

2.Full GC
Full GC 主要集中在老年代的垃圾回收,以及对整个堆内存(包括年轻代和老年代)的清理和整理

Full GC主要采用Parallel Old

1.6 垃圾回收器的工作原理(难度:★★★ 频率:★★)

Minor GC和Full GC是通过垃圾回收器进行垃圾回收的, 最常见的垃圾回收器有以下几种

  • Serial收集器
  • Parallel收集器
  • CMS收集器
  • G1收集器

JDK8中, 默认的垃圾回收器取决于运行的平台

  • 客户端模式: 客户端模式主要用于提供较短的启动时间和更快的响应。
    • 新生代: Parallel收集器(Parallel Scavenge)
    • 老年代: Serial收集器(Serial Old)
  • 服务器模式: 服务器模式主要用于长时间运行的服务端应用,以获取更高的吞吐量。
    • 新生代: Parallel收集器(Parallel Scavenge)
    • 老年代: Parallel收集器(Parallel Old)

垃圾回收器通过可达性分析来标记不被引用的对象, 从GC Roots出发, 沿着对象的引用关系, 标记所有直接或间接与GC Roots相连的存活对象。

这样,垃圾收集器就能够确定哪些对象是存活的,哪些对象是可以被回收的垃圾。

查看默认的垃圾回收器

java -XX:+PrintCommandLineFlags -version

在这里插入图片描述

Parallel Scavenge和Parallel Old的区别

  • 应用场景
    • Parallel Scavenge 主要用于年轻代的垃圾回收,强调在尽量减少暂停时间的前提下提高吞吐量。
    • Parallel Old 主要用于老年代的垃圾回收,同样注重吞吐量,但它处理的是老年代的对象,其算法和策略更适应老年代的特点。
  • 代的特点
    • Parallel Scavenge 关注于年轻代,采用复制算法,将幸存的对象复制到另一个幼年代区域,并在这个过程中进行垃圾回收。
    • Parallel Old 则关注于老年代,采用标记-清除-整理算法,包括标记阶段、清除阶段和整理阶段,以回收不再使用的对象并整理存活的对象。
  • 目标
    • Parallel Scavenge 的主要目标是在减少暂停时间的同时,达到较高的吞吐量,适用于注重系统的整体运行效率,而对于单次垃圾回收的暂停时间相对较为宽容的场景。
    • Parallel Old 同样注重吞吐量,但由于处理的是老年代,更关注老年代的稳定性和性能。
  • 参数配置
    • Parallel Scavenge 可以通过参数 -XX:+UseParallelGC 启用,也可以通过其他参数进行进一步的配置。
    • Parallel Old 可以通过参数 -XX:+UseParallelOldGC 启用,同时也可以搭配 -XX:+UseParallelGC 以使用并行垃圾回收器处理年轻代。

下面是标记-清除(Mark and Sweep)垃圾回收算法的基本流程:

  1. 初始标记(Initial Marking)
    从根对象出发,直接可达的对象会被标记为活动对象。这个过程比较快,因为它只涉及到根对象的直接引用关系。
  2. 并发标记(Concurrent Marking)
    在并发标记阶段,垃圾回收器可以与应用程序并发执行,继续标记可达对象,包括通过引用链间接可达的对象。这个过程是在程序运行的同时进行的,以减少垃圾回收对应用程序性能的影响。
  3. 重新标记(Remark)
    在并发标记阶段之后,可能会有一些对象在并发标记期间发生了变化,需要重新标记。这一阶段会短暂停止应用程序,进行最终的标记。
  4. 清除(Sweep)
    将未被标记的对象识别为垃圾并清除(回收)它们的内存空间。这一步是垃圾回收的最终阶段。

知识点一: 可作为GC Roots的对象
在Java中,垃圾收集器通过GC Roots来确定对象的可达性。以下是一些常见的对象类型,它们可以作为GC Roots:

  1. 虚拟机栈(Java栈)中的局部变量
    活动线程的本地变量引用的对象。
  2. 本地方法栈中的局部变量
    本地方法中引用的对象。
  3. 方法区中的静态变量
    类的静态变量引用的对象,包括常量池中的静态引用。
  4. JNI(Java Native Interface)引用的对象
    由本地方法使用JNI创建的对象。
  5. 当前线程的引用
    当前线程对象本身,以及通过ThreadLocal等机制与当前线程关联的对象。
  6. 类的引用
    被系统类加载器(BootstrapClassLoader)加载的类对象,以及由它们引用的对象
  7. 同步锁(锁池、Wait Set)中的对象
    被线程持有的同步锁引用的对象,以及等待线程(Wait Set)中的对象
  8. 虚拟机内部的引导类加载器(Bootstrap ClassLoader)
    JVM内部的类加载器引用的对象。

这些对象都是垃圾收集的起始点,通过追踪这些GC Roots对象及其引用关系,垃圾收集器可以确定哪些对象是可达的,哪些对象是不可达的垃圾。这种方式保证了不会误将仍然可达的对象回收,同时能够识别并清理掉不可达的对象。

1.7 Minor GC和Full GC的触发时机。(难度:★★★ 频率:★★)

Minor GC
多数情况下,对象在年轻代中的Eden区进行分配,若Eden区没有足够空间,就会触发YGC(Minor GC)

Full GC

  • 老年代的内存使用率达到了一定阈值(默认是92% 通过-XX:MaxTenuringThreshoId设置)
    • 情景1:Survivor区进行了15次清理后还没清理的对象(年龄达到阈值),则放到老年代。
    • 情景2:Minor GC时的检查
  • Metaspace(元空间)扩容到了-XX:MetaspaceSize 参数的指定值。(元空间在空间不足时会进行扩容)
  • 程序执行了System.gc()
  • jmap 加了:live参数
  • 其他

1.8 JVM调优参数(难度:★★ 频率:★★★★)

JVM调优是为了优化Java程序的性能和资源利用率. 通过调整JVM的参数, 可以更好地适应不同的应用场景和硬件环境。以下是一些常见的JVM调优参数以及它们的作用:

  • -Xms 初始堆大小
  • -Xmx 最大堆大小
    一般将Xms和Xmx设为一样的值,若-Xms比较小,又需要初始化很多对象,jvm就必须反复增加内存。一样大也可避免每次垃圾回收完成后JVM重新分配内存。
  • -Xmn 新生代大小
    老年代的大小将是总堆大小减去新生代的大小
    等效于使用 -XX:NewSize 设置初始化大小并使用-XX:MaxNewSize 设置最大大小。
  • -XX:NewRatio=n
    设置年轻代和年老代的比值。如:为3,表示年轻代与年老代比值为1:3,年轻代占整个年轻代年老代和的1/4
  • -XX:SurvivorRatio=n
    年轻代中Eden区与两个Survivor区的比值。
  • -XX:MetaspaceSize=n
    元空间大小。
  • -XX:MaxMetaspaceSize=n
    最大元空间大小。

2.多线程

2.1 ThreadLocal是什么?(难度:★★ 频率:★★★★)

1.作用
ThreadLocal是一种线程隔离机制, 使得每个线程都可以拥有自己独立的变量副本,从而避免了多线程环境下的线程安全问题。

public class Demo {
    static ThreadLocal<String> threadLocal = new ThreadLocal<>();
 
    static void print(String str){
        System.out.println(str + ":" + threadLocal.get());
    }
 
    public static void main(String[] args) {
        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                threadLocal.set("abc");
                print("thread1 variable");
            }
        });
 
        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                threadLocal.set("def");
                print("thread2 variable");
            }
        });
 
        thread1.start();
        thread2.start();
    }
}

2.内部结构和原理
JVM、多线程_第5张图片
最初的设计
每个ThreadLocal是自己维护一个ThreadLocalMap, key是当前线程, value是要存储的局部变量, 这样就可以达到各个线程的局部变量隔离的效果

JDK8的设计
每个Thread维护一个ThreadLocalMap, 这个Map的key是ThreadLocal本身, value是要存储的变量. 具体的流程如下

这样设计的优点:

  1. 每个Map存储的Entry数量变少,之前Entry的数量是由线程个数决定的, 线程个数越多, Entry就越多, 而现在是由ThreadLocal决定的, 在实际开发中, ThreadLocal数量往往少于线程数
  2. 当Thread销毁的时候, ThreadLocalMap会随之销毁, 减少内存的使用, 早期的方案中线程执行结束并不会把ThreadLocalMap销毁(垃圾回收)

3.ThreadLocal与synchronized对比
虽然ThreadLocal与synchronized都用于处理多线程并发访问变量的问题, 不过两者处理问题的角度和思路不同

  1. 每个Thread线程内部都有一个Map(ThreadLocalMap)
  2. Map中存储了ThreadLocal对象(key)和变量副本(value)
  3. Thread内部的Map是ThreadLocal维护的, 由ThreadLocal负责向map中获取和设置线程的变量值
  4. 对于不同的线程, 每个获取副本值时, 别的线程并不能获取到当前线程的副本值, 形成了线程的隔离, 互不干扰
synchronized ThreadLocal
原理 同步机制采用时间换空间的方法, 只提供了一份变量, 让不同的线程排队访问 ThreadLocal采用空间换时间的方式, 为每一个线程提供了一份变量的副本, 从而实现同时访问, 互不干扰
侧重点 多个线程之间访问资源的同步 多线程中让每个线程之间数据相互隔离

4.源码分析

  • set方法

JVM、多线程_第6张图片

  • get方法

JVM、多线程_第7张图片
JVM、多线程_第8张图片

2.2 ThreadLocal导致内存泄漏(难度:★★ 频率:★★★)

1.内存泄漏和内存溢出的区别

内存溢出 内存泄漏
定义 内存溢出指的是程序在运行过程中申请的内存超过了系统或者进程所能提供的内存大小(结果) 内存泄漏指的是程序中已经不再需要的内存未被释放,造成系统内存的浪费(起因)
原因 通常是由于程序中存在大量的内存申请,而且没有及时释放,导致系统的可用内存被耗尽 内存泄漏通常是由于程序中存在指针或引用,指向了不再使用的内存块,但程序却没有释放这些内存
表现 当内存溢出发生时,程序通常会崩溃,并且系统可能会报告无法分配内存的错误 内存泄漏不会导致程序立即崩溃,但随着时间的推移,系统可用内存会逐渐减少,最终可能导致系统变慢或者崩溃

总体来说,内存溢出是由于申请的内存过多,超出了系统限制,而内存泄漏是因为未能及时释放已经不再使用的内存。

解决内存溢出和内存泄漏的方法通常包括合理管理内存的申请和释放过程,使用合适的数据结构,以及利用内存管理工具进行检测和优化。

需要说明一点: 虽然内存泄漏可能会导致内存溢出,但内存溢出也可能是由于其他原因,例如程序中存在大量的内存申请,但这些内存并没有被泄漏,而是在程序执行期间一直保持被占用状态,最终导致系统内存耗尽。

2.强引用和弱引用的区别

  • 强引用
    最常见的引用类型, 如果一个对象具有强引用,即使系统面临内存不足的情况,垃圾回收器也不会回收具有强引用的对象
Object obj = new Object(); // 强引用
  • 弱引用
    当垃圾回收器进行扫描时,无论内存是否充足,都会回收只有弱引用的对象。
    弱引用通常用于构建缓存和实现类似的功能,使得在内存不足时,可以更容易地释放一些占用内存较大但仍可以重新计算或重新加载的对象
WeakReference<Object> weakRef = new WeakReference<>(new Object()); // 弱引用

总结:

  1. 强引用可以阻止对象被垃圾回收,只有在没有任何强引用指向对象时,垃圾回收器才会考虑回收该对象。
  2. 弱引用相对较弱,即使还有弱引用指向对象,垃圾回收器仍然可以在需要时回收该对象。
  3. 强引用适合确保对象不被提前回收的场景,而弱引用适合那些在内存紧张时可以被更容易释放的场景。

3.哪些情况下, ThreadLocal会导致内存泄漏?
3.1 长时间存活的线程

public class MyRunnable implements Runnable {
    private static ThreadLocal<MyObject> myThreadLocal = new ThreadLocal<>();

    @Override
    public void run() {
        MyObject obj = new MyObject();
        myThreadLocal.set(obj);

        // 执行任务...

        // 如果线程一直存活,myThreadLocal 将一直持有对 obj 的引用,即使任务执行完毕。
    }
}

在这个例子中,即使任务执行完毕,ThreadLocal 对象仍然持有对 MyObject 的引用,而线程的生命周期可能会很长,导致 MyObject 无法被垃圾回收,从而引发内存泄漏。

为了避免这种情况,需要在不再需要 ThreadLocal 存储的对象时,显式调用 remove() 方法来清理 ThreadLocal。这样可以确保 ThreadLocal 对象中的弱引用被正确清理,从而防止内存泄漏。例如:

public class MyRunnable implements Runnable {
    private static ThreadLocal<MyObject> myThreadLocal = new ThreadLocal<>();

    @Override
    public void run() {
        try {
            MyObject obj = new MyObject();
            myThreadLocal.set(obj);

            // 执行任务...

        } finally {
            // 清理 ThreadLocal,防止内存泄漏
            myThreadLocal.remove();
        }
    }
}

3.2 使用线程池
如果在使用线程池的情况下,ThreadLocal被设置在某个任务中,而这个任务在线程池中执行完成后线程被放回线程池而不是销毁,那么ThreadLocal可能在下一次任务执行时仍然持有对上次设置的对象的引用。

ExecutorService executorService = Executors.newFixedThreadPool(5);

executorService.submit(() -> {
    MyObject obj = new MyObject();
    myThreadLocal.set(obj);

    // 执行任务...

    // 线程被放回线程池,但 ThreadLocal 可能仍然持有对 obj 的引用。
});

为了避免这类问题,确保在ThreadLocal不再需要时,调用remove()方法清理它所持有的对象引用。这通常在任务执行结束时或者线程即将被销毁时执行。例如:

public class MyRunnable implements Runnable {
    private static ThreadLocal<MyObject> myThreadLocal = new ThreadLocal<>();

    @Override
    public void run() {
        try {
            MyObject obj = new MyObject();
            myThreadLocal.set(obj);

            // 执行任务...

        } finally {
            // 清理 ThreadLocal,防止内存泄漏
            myThreadLocal.remove();
        }
    }
}

2.3 项目中哪些地方用到了多线程?(难度:★★★ 频率:★★★★★)

  • 定时任务: 定时处理数据进行统计等
  • 异步处理: 发邮件、记录日志、发短信等等, 例如注册成功后发激活邮件
  • 批量处理: 缩短响应时间

2.4 死锁以及死锁产生的条件?(难度:★★ 频率:★★★★★)

死锁是指多个线程(两个或以上)在执行过程中互相等待对方释放资源, 无法继续执行下去.

在一个典型的死锁情况中, 每个进程都在等待某个资源, 但同时拥有另一个资源, 由于每个进程都不愿意先释放自己已经占有的资源, 所以形成了相互等待的状况

1.死锁的4个必要条件

  1. 互斥条件: 指线程对己经获取到的资源进行排它性使用,即该资源同时只由一个线程占用。如果此时还有其他线程请求获取该资源,则请求者只能等待,直至占有资源的线程释放该资源。
  2. 占有且等待条件: 指一个线程己经持有了至少一个资源,但又提出了新的资源请求,而新资源己被其他线程占有,所以当前线程会被阻塞,但阻塞的同时并不释放自己己经获取的资源
  3. 不可剥夺条件: 指线程获取到的资源在自己使用完之前不能被其他线程抢占,只有在自己使用完毕后才由自己释放该资源。
  4. 循环等待条件: 进程之间形成一个循环等待链,每个进程都在等待下一个进程所占有的资源。

2.5 i++是否线程安全?(难度:★ 频率:★)

i++操作本身并不是线程安全的

i++实际上是一个复合操作,包括读取 i 的当前值、将其加一、然后将结果写回 i。其中涉及到读取、修改、写入三个步骤。如果两个线程同时执行这段代码,可能会导致竞态条件,使得最终的结果不是期望的增加2,而可能是增加1或者其他值。

解决方式一: 使用同步(synchronization)

synchronized (lock) {
    i++;
}

解决方式二: 使用原子类(Atomic Classes)

AtomicInteger i = new AtomicInteger(0);
i.incrementAndGet();

2.6 synchronized的使用(难度:★★ 频率:★★)

1.synchronized的三种用法

  1. 修饰普通方法
public synchronized void increase() {
   
}
  1. 修饰静态方法
public static synchronized void increase() {
   
}
  1. 修饰代码块
public Object synMethod(Object a1) {
    synchronized(a1) {
        // 操作
    }
}

2.synchronized用于静态方法与普通方法有区别吗?

  1. 普通方法的synchronized
public class MyClass {
    public synchronized void instanceMethod() {
        // 实例方法的同步代码块
    }
}

实例方法中, 锁住的是当前实例对象(this), 对于MyClass类的不同实例, 它们的实力方法是独立的, 可以同时执行

  1. 静态方法的synchronized
public class MyClass {
    public static synchronized void staticMethod() {
        // 静态方法的同步代码块
    }
}

静态方法中, 锁住的是整个类的Class对象, 对于MyClass类的所有实例,同一时间只能有一个线程执行该静态方法。

2.7 SimpleDateFormat线程安全吗?怎么保证线程安全?(难度:★★★ 频率:★★★)

SimpleDateFormat类在多线程环境下是线程不安全的。

SimpleDateFormat内部维护了一个Calendar实例,而Calendar是线程不安全的

Calendar 的实现并没有在设计上考虑到多线程并发访问的情况。因此,多个线程可能同时修改 Calendar 内部的状态,而不受到足够的同步或锁的保护,从而导致线程安全问题。

解决方式一: 使用局部变量
在每个线程中创建一个独立的 SimpleDateFormat 实例,而不是共享一个实例。

SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");

解决方式二: 使用线程安全的替代方案
如果你需要在多线程环境中进行日期格式化和解析操作,可以考虑使用 java.time.format.DateTimeFormatter,它是 java.time 包中的类,设计为线程安全的。

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");

2.8 线程池的优缺点难度(难度:★★ 频率:★★)

1.优点
线程池通过提供一种有效的线程管理和调度机制,帮助提高应用程序的性能和可维护性,尤其在处理大量并发任务时,线程池是一种强大而有效的工具。

  1. 线程管理:
    线程池可以有效管理系统资源, 避免频繁创建和销毁线程, 减少系统开销, 通过重复利用线程, 降低资源的占用
  2. 调度和控制
    线程池允许对线程的数量进行有效的控制,可以防止系统因过度并发而陷入性能下降的状态。通过配置线程池的参数,可以灵活地调整线程的数量和行为。

2.缺点

  1. 资源占用: 线程池本身会占用一定的系统资源,包括内存和 CPU 资源。如果线程池设置不当,可能会导致资源浪费。
  2. 任务队列阻塞: 线程池的任务队列如果满了,新的任务可能会被阻塞或者拒绝。这可能导致任务延迟执行,特别是在高负载情况下。
  3. 难以调试: 当线程池中的线程发生问题时,调试可能会变得复杂。由于线程的生命周期和执行过程由线程池管理,追踪问题可能会比直接管理线程的情况更加困难。
  4. 配置复杂: 需要合理配置线程池的参数,包括线程数量、任务队列大小等。配置不当可能导致性能问题,需要开发人员具有一定的经验和调优技能。
  5. 不适用于所有场景: 线程池并不是在所有情况下都是最佳选择。对于某些类型的任务,例如计算密集型任务,其他并发模型可能更为合适。

2.9 线程池有哪些参数?(难度:★★ 频率:★★★★★)

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler)
  • corePoolSize 核心线程数
    核心线程数是指线程池中保持存活的最小线程数量, 这些线程会一直存活, 即使它们处于空闲状态
  • maximumPoolSize 最大线程数
    最大线程数是指线程池中允许的最大线程数量。当线程池中的任务队列已满,且活动线程数未达到最大线程数时,线程池会创建新的线程来执行任务,直到达到最大线程数。超过最大线程数的任务会根据线程池的拒绝策略进行处理,可能会被拒绝执行或者以其他方式处理。
  • keepAliveTime 非核心线程存活时间
  • unit 指定keepAliveTime的单位
  • workQueue 阻塞队列
    它充当了缓冲区的角色, 任务在没有核心线程处理是, 优先将任务扔到阻塞队列中
  • threadFactory 线程工厂
    指定创建线程的方式, 例如设置线程的名称、优先级、是否为守护线程等待
  • handler 拒绝政策
    用于定义当前线程池无法接受新任务时的策略

当我们核心线程数已经到达最大值、阻塞队列也已经放满了所有的任务、而且我们工作线程个数已经达到最大线程数, 此时如果还有新任务, 就只能走拒绝策略了

2.10 阻塞队列的作用以及用法(难度:★★ 频率:★★★)

1.作用

  1. 任务缓冲
    作为一个缓冲区, 可以在生产者产生任务时缓存这些任务, 等待线程池中的线程来执行
  2. 任务调度
    不同类型的BlockingQueue实现了不同的任务调度策略, 例如FIFO(先进先出)、LIFO(后进先出)、优先级队列等, 这有助于更灵活的控制任务的执行顺序

2.用法
在线程池中, BlockingQueue主要通过以下两个参数进行配置

  • corePoolSize和maximumPoolSize这个两个参数来指定线程池的基本大小和最大大小
    • 当线程池中的线程未达到corePoolSize时, 新任务将创建新线程.
    • 当线程池中的线程数达到corePoolSize且任务队列未满时,新任务将被放入队列等待
    • 当队列也满了,且线程池中的线程数未达到 maximumPoolSize 时,新任务将创建新线程
    • 当线程池中的线程数达到 maximumPoolSize 时,新任务将由饱和策略处理
  • workQueue: 这个参数就是 BlockingQueue,用于存储等待执行的任务。通过选择不同的 BlockingQueue 实现,可以实现不同的任务调度策略。
import java.util.concurrent.*;

public class ThreadPoolExample {
    public static void main(String[] args) {
        // 创建一个LinkedBlockingQueue作为任务队列
        BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(10);

        // 创建一个ThreadPoolExecutor,使用LinkedBlockingQueue作为任务队列
        ExecutorService executor = new ThreadPoolExecutor(
                5,  // corePoolSize
                10, // maximumPoolSize
                1,  // keepAliveTime
                TimeUnit.SECONDS,
                queue);

        // 提交任务到线程池
        for (int i = 0; i < 15; i++) {
            executor.submit(() -> {
                try {
                    Thread.sleep(1000);
                    System.out.println("Task completed by: " + Thread.currentThread().getName());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }

        // 关闭线程池
        executor.shutdown();
    }
}

打印结果:
Task completed by: pool-1-thread-5
Task completed by: pool-1-thread-1
Task completed by: pool-1-thread-2
Task completed by: pool-1-thread-4
Task completed by: pool-1-thread-3
Task completed by: pool-1-thread-1
Task completed by: pool-1-thread-4
Task completed by: pool-1-thread-2
Task completed by: pool-1-thread-5
Task completed by: pool-1-thread-3
Task completed by: pool-1-thread-2
Task completed by: pool-1-thread-4
Task completed by: pool-1-thread-5
Task completed by: pool-1-thread-1
Task completed by: pool-1-thread-3

在这个例子中,LinkedBlockingQueue 作为任务队列,可以存储最多 10 个等待执行的任务。线程池的核心线程数为 5,最大线程数为 10,因此在任务队列未满时,新任务将放入队列等待。如果队列已满,新任务将创建新线程执行,但不会超过最大线程数。

因为开启了15个线程, 而核心线程数+阻塞队列容量正好为15个, 所以不会创建新的线程

2.11 不同阻塞队列的区别(难度:★★ 频率:★★★)

1.ArrayBlockingQueue
基于数组实现的有界队列
固定容量,一旦创建就不能更改。
需要指定容量,适用于任务数量固定的情况。

ThreadPoolExecutor threadPool = new ThreadPoolExecutor(1, 2, 1000, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<Runnable>(10),Executors.defaultThreadFactory(),new ThreadPoolExecutor.AbortPolicy());

使用 ArrayBlockingQueue 有界任务队列,若有新的任务需要执行时,线程池会创建新的线程,直到创建的线程数量达到 corePoolSize 时,则会将新的任务加入到等待队列中。若等待队列已满,即超过 ArrayBlockingQueue 初始化的容量,则继续创建线程,直到线程数量达到 maximumPoolSize 设置的最大线程数量,若大于 maximumPoolSize,则执行拒绝策略。在这种情况下,线程数量的上限与有界任务队列的状态有直接关系,如果有界队列初始容量较大或者没有达到超负荷的状态,线程数将一直维持在 corePoolSize 以下,反之当任务队列已满时,则会以 maximumPoolSize 为最大线程数上限

2.LinkedBlockingQueue
基于链表实现的有界或无界队列
可以选择是否指定容量,如果不指定容量则默认是 Integer.MAX_VALUE
适用于任务数量不固定的情况

ThreadPoolExecutor threadPool = new ThreadPoolExecutor(1, 2, 1000, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(),Executors.defaultThreadFactory(),new ThreadPoolExecutor.AbortPolicy());

无界任务队列,线程池的任务队列可以无限制的添加新的任务,而线程池创建的最大线程数量就是你 corePoolSize 设置的数量,也就是说在这种情况下 maximumPoolSize 这个参数是无效的,哪怕你的任务队列中缓存了很多未执行的任务,当线程池的线程数达到 corePoolSize 后,就不会再增加了;若后续有新的任务加入,则直接进入队列等待,当使用这种任务队列模式时,一定要注意你任务提交与处理之间的协调与控制,不然会出现队列中的任务由于无法及时处理导致一直增长,直到最后资源耗尽的问题。

3.SynchronousQueue
一个不存储元素的队列
每个插入操作必须等待另一个线程的对应移除操作,反之亦然。
主要用于直接传递任务的场景,一个线程产生任务,另一个线程消费任务

4.PriorityBlockingQueue
基于优先级堆的无界队列。
元素需要实现Comparable接口或者在构造方法中提供Comparator

5.DelayedWorkQueue
一个支持延时获取元素的无界队列,用于实现定时任务。
元素需要实现 Delayed 接口

2.12 线程池有哪几种?它们分别对应什么队列?(难度:★★ 频率:★★★)

FixedThreadPool SingleThreadExecutor ScheduledThreadPool CachedThreadPool
名称 固定大小线程池 单线程线程池 定时任务线程池 缓存线程池
特点 固定线程数量的线程池,适用于负载较重的服务器 只有一个工作线程的线程池,确保所有任务按顺序执行 支持定时及周期性任务执行的线程池 线程数量根据需求动态调整,线程空闲一定时间后被回收

1.FixedThreadPool
创建一个固定大小的线程池,可控制并发的线程数,超出的线程会在队列中等待

public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());
}
  • corePoolSize与maximumPoolSize相等,即其线程全为核心线程,是一个固定大小的线程池,是其优势;
  • keepAliveTime = 0 该参数默认对核心线程无效,而 FixedThreadPool 全部为核心线程;
  • workQueue 为 LinkedBlockingQueue(无界阻塞队列),队列最大值为 Integer.MAX_VALUE。
    如果任务提交速度持续大于任务处理速度,会造成队列大量阻塞。因为队列很大,很有可能在拒绝策略前,内存溢出。是其劣势;
  • FixedThreadPool 的任务执行是无序的;
public class NewFixedThreadPoolTest {

    public static void main(String[] args) {
        System.out.println("主线程启动");
        // 1.创建1个有2个线程的线程池
        ExecutorService threadPool = Executors.newFixedThreadPool(2);

        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(2000);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                System.out.println("任务被执行,线程:" + Thread.currentThread().getName());
            }
        };
        // 2.线程池执行任务(添加4个任务,每次执行2个任务,得执行两次)
        threadPool.submit(runnable);
        threadPool.execute(runnable);
        threadPool.execute(runnable);
        threadPool.execute(runnable);
        System.out.println("主线程结束");
    }
}

上述代码:创建了一个有2个线程的线程池,但一次给它分配了4个任务,每次只能执行2个任务,所以,得执行两次。

该线程池重用固定数量的线程在共享的无界队列中运行。 在任何时候,最多 nThreads 线程将是活动的处理任务。如果在所有线程都处于活动状态时提交了其他任务,它们将在队列中等待,直到有线程可用。 所以,它会一次执行 2 个任务(2 个活跃的线程),另外 2 个任务在工作队列中等待着。

submit() 方法和 execute() 方法都是执行任务的方法。它们的区别是:submit() 方法有返回值,而 execute() 方法没有返回值。

2.CachedThreadPool
创建一个可缓存的线程池,若线程数超过处理所需,缓存一段时间后会回收,若线程数不够,则新建线程。

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>());
}
  • corePoolSize = 0,maximumPoolSize = Integer.MAX_VALUE,即线程数量几乎无限制;
  • keepAliveTime = 60s,线程空闲 60s 后自动结束
  • workQueue 为 SynchronousQueue 同步队列,这个队列类似于一个接力棒,入队出队必须同时传递,因为 CachedThreadPool 线程创建无限制,不会有队列等待,所以使用 SynchronousQueue

适用场景:快速处理大量耗时较短的任务,如 Netty 的 NIO 接受请求时,可使用 CachedThreadPool。

public class NewCachedThreadPool {

    public static void main(String[] args) {
        ExecutorService threadPool = Executors.newCachedThreadPool();

        for (int i = 0; i < 10; i++) {
            threadPool.execute(() -> {
                System.out.println("任务被执行,线程:" + Thread.currentThread().getName());
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            });
        }
    }
}

3.SingleThreadExecutor
创建单个线程数的线程池,它可以保证先进先出的执行顺序。

4.SingleThreadScheduledExecutor
创建一个单线程的可以执行延迟任务的线程池。

public class SingleThreadScheduledExecutorTest {

    public static void main(String[] args) {
        ScheduledExecutorService threadPool = Executors.newSingleThreadScheduledExecutor();
        System.out.println("添加任务,时间:" + new Date());
        threadPool.schedule(() -> {
            System.out.println("任务被执行,时间:" + new Date());
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
            }
        }, 2, TimeUnit.SECONDS);
    }
}

2.13 线程池执行流程(难度:★★ 频率:★★★★★)

JVM、多线程_第9张图片

2.14 线程工厂的作用, 以及使用方法(难度:★ 频率:★★)

ThreadFactory是一个接口, 用于创建新线程的工厂, 它允许你自定义线程的创建过程, 例如设置线程的名称、优先级、守护状态等…

import java.util.concurrent.*;

public class CustomThreadFactoryExample {
    public static void main(String[] args) {
        // 创建一个自定义的ThreadFactory
        ThreadFactory customThreadFactory = new CustomThreadFactory("CustomThread");

        // 使用自定义的ThreadFactory创建线程池
        ExecutorService executorService = Executors.newFixedThreadPool(5, customThreadFactory);

        // 提交一些任务
        for (int i = 0; i < 10; i++) {
            executorService.submit(() -> System.out.println(Thread.currentThread().getName()));
        }

        // 关闭线程池
        executorService.shutdown();
    }
}

// 自定义的ThreadFactory实现
class CustomThreadFactory implements ThreadFactory {
    private final String threadNamePrefix;

    public CustomThreadFactory(String threadNamePrefix) {
        this.threadNamePrefix = threadNamePrefix;
    }

    @Override
    public Thread newThread(Runnable r) {
        // 创建新线程并设置线程名称
        Thread thread = new Thread(r, threadNamePrefix + "-" + System.nanoTime());
        // 设置为后台线程(可选)
        thread.setDaemon(false);
        // 设置线程优先级(可选)
        thread.setPriority(Thread.NORM_PRIORITY);
        return thread;
    }
}

2.15 线程池拒绝策略有哪些?默认是哪个?(难度:★ 频率:★★★★)

一、AbortPolicy
这是默认的拒绝策略,当队列满时直接抛出
RejectedExecutionException异常,阻止系统继续运行。

ExecutorService executor = new ThreadPoolExecutor(
    corePoolSize,
    maximumPoolSize,
    keepAliveTime,
    timeUnit,
    new LinkedBlockingQueue<>(capacity),
    Executors.defaultThreadFactory(),
    new ThreadPoolExecutor.AbortPolicy());

二、CallerRunsPolicy
新任务会被直接在提交任务的线程中运行。这样做可以避免任务被拒绝,但会影响任务提交的线程的性能。

ExecutorService executor = new ThreadPoolExecutor(
    corePoolSize,
    maximumPoolSize,
    keepAliveTime,
    timeUnit,
    new LinkedBlockingQueue<>(capacity),
    Executors.defaultThreadFactory(),
    new ThreadPoolExecutor.CallerRunsPolicy());

三、DiscardPolicy
新任务被直接丢弃,不做任何处理。

ExecutorService executor = new ThreadPoolExecutor(
    corePoolSize,
    maximumPoolSize,
    keepAliveTime,
    timeUnit,
    new LinkedBlockingQueue<>(capacity),
    Executors.defaultThreadFactory(),
    new ThreadPoolExecutor.DiscardPolicy());

四、DiscardOldestPolicy
尝试将最旧的未处理任务从队列中删除,然后重新尝试执行任务

ExecutorService executor = new ThreadPoolExecutor(
    corePoolSize,
    maximumPoolSize,
    keepAliveTime,
    timeUnit,
    new LinkedBlockingQueue<>(capacity),
    Executors.defaultThreadFactory(),
    new ThreadPoolExecutor.DiscardOldestPolicy());

2.16 如何捕获线程池中的异常? (难度:★★ 频率:★★★★★)

验证主线程无法捕获线程池中异常

public static void main(String[] args) {
    try {
        System.out.println("主线程开始");
        // 创建线程池
        ExecutorService executor = Executors.newSingleThreadExecutor();
        // 执行线程方法
        executor.execute(()->{
            System.out.println("子线程运行开始");
            int i = 1 / 0;
            System.out.println("子线程运行结束");
        });
        executor.shutdown();
        System.out.println("主线程结束");
    } catch (Exception e) {
        System.out.println("异常信息:" + e.getMessage());
    }
}

JVM、多线程_第10张图片
在上面的代码中, 异常无法被捕获的原因是因为异常发生在子线程中,而主线程并不直接捕获这个异常。

execute方法提交任务后,异常会被线程池中的线程捕获并处理,但是这个异常处理是在线程内部进行的,不会传递到主线程中。

我们发现不论是使用execute方法或者submit方法提交任务, 都没有办法在主线程中捕获到异常, 有没有解决方式?

  • 方式一: 使用submit提交任务, 并使用阻塞方法(get)直达任务完成, 如果这个过程中发生了异常, 会在主线程中被捕获
  • 方式二: 自定义ThreadPoolExecutor作为线程池

1.使用submit提交任务, 再通过阻塞方法等待任务完成, 如果这个过程中发生了异常, 会在主线程中被捕获
你可以通过Future对象的get方法来获取异常,但需要注意的是,如果任务执行过程中发生了异常,调用get方法会抛出ExecutionException,你需要在主线程中处理这个异常。

try {
    System.out.println("主线程开始");
    ExecutorService executor = Executors.newSingleThreadExecutor();
    Future<?> future = executor.submit(() -> {
        System.out.println("运行开始");
        int i = 1 / 0;
        System.out.println("运行结束");
    });
    executor.shutdown();

    // 获取任务执行的结果,这里会阻塞直到任务完成
    future.get();
    System.out.println("主线程结束");
} catch (Exception e) {
    System.out.println("捕获到异常:" + e.getMessage());
}

上述的方法使用submit提交任务, 那如果使用execute提交任务, 可以捕获线程池中的异常吗?

submit和execute方法的区别
submit和execute都可以提交任务, 两者有一些关键的区别

  • 返回值
    • execute 方法没有返回值,它用于执行实现了Runnable接口的任务。
    • submit 方法返回一个Future对象,该对象可以用于获取任务执行的结果。这是因为submit可以执行实现了Callable接口的任务,它们可以返回一个结果。
  • 任务类型
    • execute 方法接受Runnable类型的任务,这种任务没有返回值。
    • submit 方法既可以接受Runnable类型的任务,也可以接受Callable类型的任务,后者可以返回结果。
  • 异常处理
    • execute方法无法处理任务执行过程中抛出的异常,异常会被直接抛到调用者
    • submit方法可以通过Future对象的get方法来获取任务执行过程中抛出的异

Callable的返回值类型是在实现接口时指定的, 例如下面这个例子

public class GetStrService implements Callable<String> {
    private int i;

    public GetStrService(int i) {
        this.i = i;
    }

    @Override
    public String call() throws Exception {
        int t=(int) (Math.random()*(10-1)+1);
        System.out.println("第"+i+"个任务开始啦:"+Thread.currentThread().getName()+"准备延时"+t+"秒");
        Thread.sleep(t*1000);
        return "第"+i+"个GetStrService任务使用的线程:"+Thread.currentThread().getName();
    }
}

2.自定义ThreadPoolExecutor作为线程池

// 自定义线程池
class MyThreadPoolExecutor extends ThreadPoolExecutor {
    public MyThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue) {
        super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
    }

    @Override
    protected void afterExecute(Runnable r, Throwable t) {
        super.afterExecute(r, t);
        System.out.println("捕获到异常。异常信息为:" + t.getMessage());
        System.out.println("异常栈信息为:");
        t.printStackTrace();
    }
}

public static void main(String[] args) {
    System.out.println("主线程开始");
    // 创建线程池
    ExecutorService executor = new MyThreadPoolExecutor(5,50, 3, TimeUnit.SECONDS, new LinkedBlockingQueue<>(20));
    // 执行线程方法
    executor.execute(()->{
        System.out.println("子线程运行开始");
        int i = 1 / 0;
        System.out.println("子线程运行结束");
    });
    executor.shutdown();
    System.out.println("主线程结束");
}

JVM、多线程_第11张图片

你可能感兴趣的:(java,开发语言)