--- # dir: # text: Java全栈面试 # icon: laptop-code # collapsible: true # expanded: true # link: true # index: true title: Java全栈面试 index: true # icon: laptop-code # sidebar: true # toc: true # editLink: false --- ## 1 Java基础 > Java基础部分,包括语法基础,泛型,注解,异常,反射和其它(如SPI机制等)。 ### 1.1 语法基础 #### 面向对象特性? - **封装** 利用抽象数据类型将数据和基于数据的操作封装在一起,使其构成一个不可分割的独立实体。数据被保护在抽象数据类型的内部,尽可能地隐藏内部的细节,只保留一些对外接口使之与外部发生联系。用户无需知道对象内部的细节,但可以通过对象对外提供的接口来访问该对象。 优点: - 减少耦合: 可以独立地开发、测试、优化、使用、理解和修改 - 减轻维护的负担: 可以更容易被程序员理解,并且在调试的时候可以不影响其他模块 - 有效地调节性能: 可以通过剖析确定哪些模块影响了系统的性能 - 提高软件的可重用性 - 降低了构建大型系统的风险: 即使整个系统不可用,但是这些独立的模块却有可能是可用的 以下 Person 类封装 name、gender、age 等属性,外界只能通过 get() 方法获取一个 Person 对象的 name 属性和 gender 属性,而无法获取 age 属性,但是 age 属性可以供 work() 方法使用。 注意到 gender 属性使用 int 数据类型进行存储,封装使得用户注意不到这种实现细节。并且在需要修改 gender 属性使用的数据类型时,也可以在不影响客户端代码的情况下进行。 ```java public class Person { private String name; private int gender; private int age; public String getName() { return name; } public String getGender() { return gender == 0 ? "man" : "woman"; } public void work() { if (18 <= age && age <= 50) { System.out.println(name + " is working very hard!"); } else { System.out.println(name + " can't work any more!"); } } } ``` - **继承** 继承实现了 **IS-A** 关系,例如 Cat 和 Animal 就是一种 IS-A 关系,因此 Cat 可以继承自 Animal,从而获得 Animal 非 private 的属性和方法。 继承应该遵循里氏替换原则,子类对象必须能够替换掉所有父类对象。 Cat 可以当做 Animal 来使用,也就是说可以使用 Animal 引用 Cat 对象。父类引用指向子类对象称为 **向上转型** 。 ```java Animal animal = new Cat(); ``` - **多态** 多态分为编译时多态和运行时多态: - 编译时多态主要指方法的重载 - 运行时多态指程序中定义的对象引用所指向的具体类型在运行期间才确定 运行时多态有三个条件: - 继承 - 覆盖(重写) - 向上转型 下面的代码中,乐器类(Instrument)有两个子类: Wind 和 Percussion,它们都覆盖了父类的 play() 方法,并且在 main() 方法中使用父类 Instrument 来引用 Wind 和 Percussion 对象。在 Instrument 引用调用 play() 方法时,会执行实际引用对象所在类的 play() 方法,而不是 Instrument 类的方法。 ```java public class Instrument { public void play() { System.out.println("Instrument is playing..."); } } public class Wind extends Instrument { public void play() { System.out.println("Wind is playing..."); } } public class Percussion extends Instrument { public void play() { System.out.println("Percussion is playing..."); } } public class Music { public static void main(String[] args) { List instruments = new ArrayList<>(); instruments.add(new Wind()); instruments.add(new Percussion()); for(Instrument instrument : instruments) { instrument.play(); } } } ``` #### a = a + b 与 a += b 的区别 += 隐式的将加操作的结果类型强制转换为持有结果的类型。如果两个整型相加,如 byte、short 或者 int,首先会将它们提升到 int 类型,然后在执行加法操作。 ```java byte a = 127; byte b = 127; b = a + b; // error : cannot convert from int to byte b += a; // ok ``` (因为 a+b 操作会将 a、b 提升为 int 类型,所以将 int 类型赋值给 byte 就会编译出错) #### 3*0.1 == 0.3 将会返回什么? true 还是 false? false,因为有些浮点数不能完全精确的表示出来。 #### 能在 Switch 中使用 String 吗? 从 Java 7 开始,我们可以在 switch case 中使用字符串,但这仅仅是一个语法糖。内部实现在 switch 中使用字符串的 hash code。 #### 对equals()和hashCode()的理解? - **为什么在重写 equals 方法的时候需要重写 hashCode 方法**? 因为有强制的规范指定需要同时重写 hashcode 与 equals 是方法,许多容器类,如 HashMap、HashSet 都依赖于 hashcode 与 equals 的规定。 - **有没有可能两个不相等的对象有相同的 hashcode**? 有可能,两个不相等的对象可能会有相同的 hashcode 值,这就是为什么在 hashmap 中会有冲突。相等 hashcode 值的规定只是说如果两个对象相等,必须有相同的hashcode 值,但是没有关于不相等对象的任何规定。 - **两个相同的对象会有不同的 hash code 吗**? 不能,根据 hash code 的规定,这是不可能的。 #### final、finalize 和 finally 的不同之处? final 是一个修饰符,可以修饰变量、方法和类。如果 final 修饰变量,意味着该变量的值在初始化后不能被改变。 Java 技术允许使用 finalize() 方法在垃圾收集器将对象从内存中清除出去之前做必要的清理工作。这个方法是由垃圾收集器在确定这个对象没有被引用时对这个对象调用的,但是什么时候调用 finalize 没有保证。 finally 是一个关键字,与 try 和 catch 一起用于异常的处理。finally 块一定会被执行,无论在 try 块中是否有发生异常。 #### String、StringBuffer与StringBuilder的区别? 第一点: 可变和适用范围。String对象是不可变的,而StringBuffer和StringBuilder是可变字符序列。每次对String的操作相当于生成一个新的String对象,而对StringBuffer和StringBuilder的操作是对对象本身的操作,而不会生成新的对象,所以对于频繁改变内容的字符串避免使用String,因为频繁的生成对象将会对系统性能产生影响。 第二点: 线程安全。String由于有final修饰,是immutable的,安全性是简单而纯粹的。StringBuilder和StringBuffer的区别在于StringBuilder不保证同步,也就是说如果需要线程安全需要使用StringBuffer,不需要同步的StringBuilder效率更高。 #### 接口与抽象类的区别? - 一个子类只能继承一个抽象类, 但能实现多个接口 - 抽象类可以有构造方法, 接口没有构造方法 - 抽象类可以有普通成员变量, 接口没有普通成员变量 - 抽象类和接口都可有静态成员变量, 抽象类中静态成员变量访问类型任意,接口只能public static final(默认) - 抽象类可以没有抽象方法, 抽象类可以有普通方法;接口在JDK8之前都是抽象方法,在JDK8可以有default方法,在JDK9中允许有私有普通方法 - 抽象类可以有静态方法;接口在JDK8之前不能有静态方法,在JDK8中可以有静态方法,且只能被接口类直接调用(不能被实现类的对象调用) - 抽象类中的方法可以是public、protected; 接口方法在JDK8之前只有public abstract,在JDK8可以有default方法,在JDK9中允许有private方法 #### this() & super()在构造方法中的区别? - 调用super()必须写在子类构造方法的第一行, 否则编译不通过 - super从子类调用父类构造, this在同一类中调用其他构造均需要放在第一行 - 尽管可以用this调用一个构造器, 却不能调用2个 - this和super不能出现在同一个构造器中, 否则编译不通过 - this()、super()都指的对象,不可以在static环境中使用 - 本质this指向本对象的指针。super是一个关键字 #### Java移位运算符? java中有三种移位运算符 - `<<` :左移运算符,`x << 1`,相当于x乘以2(不溢出的情况下),低位补0 - `>>` :带符号右移,`x >> 1`,相当于x除以2,正数高位补0,负数高位补1 - `>>>` :无符号右移,忽略符号位,空位都以0补齐 ### 1.2 泛型 #### 为什么需要泛型? 1. **适用于多种数据类型执行相同的代码** ```java private static int add(int a, int b) { System.out.println(a + "+" + b + "=" + (a + b)); return a + b; } private static float add(float a, float b) { System.out.println(a + "+" + b + "=" + (a + b)); return a + b; } private static double add(double a, double b) { System.out.println(a + "+" + b + "=" + (a + b)); return a + b; } ``` 如果没有泛型,要实现不同类型的加法,每种类型都需要重载一个add方法;通过泛型,我们可以复用为一个方法: ```java private static double add(T a, T b) { System.out.println(a + "+" + b + "=" + (a.doubleValue() + b.doubleValue())); return a.doubleValue() + b.doubleValue(); } ``` - **泛型中的类型在使用时指定,不需要强制类型转换**(**类型安全**,编译器会**检查类型**) 看下这个例子: ```java List list = new ArrayList(); list.add("xxString"); list.add(100d); list.add(new Person()); ``` 我们在使用上述list中,list中的元素都是Object类型(无法约束其中的类型),所以在取出集合元素时需要人为的强制类型转化到具体的目标类型,且很容易出现`java.lang.ClassCastException`异常。 引入泛型,它将提供类型的约束,提供编译前的检查: ```java List list = new ArrayList(); // list中只能放String, 不能放其它类型的元素 ``` #### 泛型类如何定义使用? - 从一个简单的泛型类看起: ```java class Point{ // 此处可以随便写标识符号,T是type的简称 private T var ; // var的类型由T指定,即:由外部指定 public T getVar(){ // 返回值的类型由外部决定 return var ; } public void setVar(T var){ // 设置的类型也由外部决定 this.var = var ; } } public class GenericsDemo06{ public static void main(String args[]){ Point p = new Point() ; // 里面的var类型为String类型 p.setVar("it") ; // 设置字符串 System.out.println(p.getVar().length()) ; // 取得字符串的长度 } } ``` - 多元泛型 ```java class Notepad{ // 此处指定了两个泛型类型 private K key ; // 此变量的类型由外部决定 private V value ; // 此变量的类型由外部决定 public K getKey(){ return this.key ; } public V getValue(){ return this.value ; } public void setKey(K key){ this.key = key ; } public void setValue(V value){ this.value = value ; } } public class GenericsDemo09{ public static void main(String args[]){ Notepad t = null ; // 定义两个泛型类型的对象 t = new Notepad() ; // 里面的key为String,value为Integer t.setKey("汤姆") ; // 设置第一个内容 t.setValue(20) ; // 设置第二个内容 System.out.print("姓名;" + t.getKey()) ; // 取得信息 System.out.print(",年龄;" + t.getValue()) ; // 取得信息 } } ``` #### 泛型接口如何定义使用? - 简单的泛型接口 ```java interface Info{ // 在接口上定义泛型 public T getVar() ; // 定义抽象方法,抽象方法的返回值就是泛型类型 } class InfoImpl implements Info{ // 定义泛型接口的子类 private T var ; // 定义属性 public InfoImpl(T var){ // 通过构造方法设置属性内容 this.setVar(var) ; } public void setVar(T var){ this.var = var ; } public T getVar(){ return this.var ; } } public class GenericsDemo24{ public static void main(String arsg[]){ Info i = null; // 声明接口对象 i = new InfoImpl("汤姆") ; // 通过子类实例化对象 System.out.println("内容:" + i.getVar()) ; } } ``` #### 泛型方法如何定义使用? 泛型方法,是在调用方法的时候指明泛型的具体类型。 - 定义泛型方法语法格式 ![img](https://b2files.173114.xyz/blogimg/2025/03/4eebe47eb4f2f9d0833c0d687d44b940.png) - 调用泛型方法语法格式 ![img](https://b2files.173114.xyz/blogimg/2025/03/2e383a7c2bbbf60c5bb35c95f128edf9.png) 说明一下,定义泛型方法时,必须在返回值前边加一个``,来声明这是一个泛型方法,持有一个泛型`T`,然后才可以用泛型T作为方法的返回值。 `Class`的作用就是指明泛型的具体类型,而`Class`类型的变量c,可以用来创建泛型类的对象。 为什么要用变量c来创建对象呢?既然是泛型方法,就代表着我们不知道具体的类型是什么,也不知道构造方法如何,因此没有办法去new一个对象,但可以利用变量c的newInstance方法去创建对象,也就是利用反射创建对象。 泛型方法要求的参数是`Class`类型,而`Class.forName()`方法的返回值也是`Class`,因此可以用`Class.forName()`作为参数。其中,`forName()`方法中的参数是何种类型,返回的`Class`就是何种类型。在本例中,`forName()`方法中传入的是User类的完整路径,因此返回的是`Class`类型的对象,因此调用泛型方法时,变量c的类型就是`Class`,因此泛型方法中的泛型T就被指明为User,因此变量obj的类型为User。 当然,泛型方法不是仅仅可以有一个参数`Class`,可以根据需要添加其他参数。 **为什么要使用泛型方法呢**?因为泛型类要在实例化的时候就指明类型,如果想换一种类型,不得不重新new一次,可能不够灵活;而泛型方法可以在调用的时候指明类型,更加灵活。 #### 泛型的上限和下限? 在使用泛型的时候,我们可以为传入的泛型类型实参进行上下边界的限制,如:类型实参只准传入某种类型的父类或某种类型的子类。 上限 ```java class Info{ // 此处泛型只能是数字类型 private T var ; // 定义泛型变量 public void setVar(T var){ this.var = var ; } public T getVar(){ return this.var ; } public String toString(){ // 直接打印 return this.var.toString() ; } } public class demo1{ public static void main(String args[]){ Info i1 = new Info() ; // 声明Integer的泛型对象 } } ``` 下限 ```java class Info{ private T var ; // 定义泛型变量 public void setVar(T var){ this.var = var ; } public T getVar(){ return this.var ; } public String toString(){ // 直接打印 return this.var.toString() ; } } public class GenericsDemo21{ public static void main(String args[]){ Info i1 = new Info() ; // 声明String的泛型对象 Info i2 = new Info() ; // 声明Object的泛型对象 i1.setVar("hello") ; i2.setVar(new Object()) ; fun(i1) ; fun(i2) ; } public static void fun(Info temp){ // 只能接收String或Object类型的泛型,String类的父类只有Object类 System.out.print(temp + ", ") ; } } ``` #### 如何理解Java中的泛型是伪泛型? 泛型中类型擦除 Java泛型这个特性是从JDK 1.5才开始加入的,因此为了兼容之前的版本,Java泛型的实现采取了“伪泛型”的策略,即Java在语法上支持泛型,但是在编译阶段会进行所谓的“类型擦除”(Type Erasure),将所有的泛型表示(尖括号中的内容)都替换为具体的类型(其对应的原生态类型),就像完全没有泛型一样。 ### 1.3 注解 #### 注解的作用? 注解是JDK1.5版本开始引入的一个特性,用于对代码进行说明,可以对包、类、接口、字段、方法参数、局部变量等进行注解。它主要的作用有以下四方面: - 生成文档,通过代码里标识的元数据生成javadoc文档。 - 编译检查,通过代码里标识的元数据让编译器在编译期间进行检查验证。 - 编译时动态处理,编译时通过代码里标识的元数据动态处理,例如动态生成代码。 - 运行时动态处理,运行时通过代码里标识的元数据动态处理,例如使用反射注入实例。 #### 注解的常见分类? - **Java自带的标准注解**,包括`@Override`、`@Deprecated`和`@SuppressWarnings`,分别用于标明重写某个方法、标明某个类或方法过时、标明要忽略的警告,用这些注解标明后编译器就会进行检查。 - **元注解**,元注解是用于定义注解的注解,包括`@Retention`、`@Target`、`@Inherited`、`@Documented` - `@Retention`用于标明注解被保留的阶段 - `@Target`用于标明注解使用的范围 - `@Documented`用于标明是否生成javadoc文档 - **自定义注解**,可以根据自己的需求定义注解,并可用元注解对自定义注解进行注解。 ### 1.4 异常 #### Java异常类层次结构? - **Throwable** 是 Java 语言中所有错误与异常的超类。 - **Error** 类及其子类:程序中无法处理的错误,表示运行应用程序中出现了严重的错误。 - **Exception** 程序本身可以捕获并且可以处理的异常。Exception 这种异常又分为两类:运行时异常和编译时异常。 ![img](https://b2files.173114.xyz/blogimg/2025/03/5b8dd265a5f94619125f81a2829c14c1.png) - **运行时异常** 都是RuntimeException类及其子类异常,如NullPointerException(空指针异常)、IndexOutOfBoundsException(下标越界异常)等,这些异常是不检查异常,程序中可以选择捕获处理,也可以不处理。这些异常一般是由程序逻辑错误引起的,程序应该从逻辑角度尽可能避免这类异常的发生。 运行时异常的特点是Java编译器不会检查它,也就是说,当程序中可能出现这类异常,即使没有用try-catch语句捕获它,也没有用throws子句声明抛出它,也会编译通过。 - **非运行时异常** (编译异常) 是RuntimeException以外的异常,类型上都属于Exception类及其子类。从程序语法角度讲是必须进行处理的异常,如果不处理,程序就不能编译通过。如IOException、SQLException等以及用户自定义的Exception异常,一般情况下不自定义检查异常。 #### 可查的异常(checked exceptions)和不可查的异常(unchecked exceptions)区别? - **可查异常**(编译器要求必须处置的异常): 正确的程序在运行中,很容易出现的、情理可容的异常状况。可查异常虽然是异常状况,但在一定程度上它的发生是可以预计的,而且一旦发生这种异常状况,就必须采取某种方式进行处理。 除了RuntimeException及其子类以外,其他的Exception类及其子类都属于可查异常。这种异常的特点是Java编译器会检查它,也就是说,当程序中可能出现这类异常,要么用try-catch语句捕获它,要么用throws子句声明抛出它,否则编译不会通过。 - **不可查异常**(编译器不要求强制处置的异常) 包括运行时异常(RuntimeException与其子类)和错误(Error)。 #### throw和throws的区别? - **异常的申明(throws)** 在Java中,当前执行的语句必属于某个方法,Java解释器调用main方法执行开始执行程序。若方法中存在检查异常,如果不对其捕获,那必须在方法头中显式声明该异常,以便于告知方法调用者此方法有异常,需要进行处理。 在方法中声明一个异常,方法头中使用关键字throws,后面接上要声明的异常。若声明多个异常,则使用逗号分割。如下所示: ```java public static void method() throws IOException, FileNotFoundException{ //something statements } ``` - **异常的抛出(throw)** ```java public static double method(int value) { if(value == 0) { throw new ArithmeticException("参数不能为0"); //抛出一个运行时异常 } return 5.0 / value; } ``` #### Java 7 的 try-with-resource? 如果你的资源实现了 AutoCloseable 接口,你可以使用这个语法。大多数的 Java 标准资源都继承了这个接口。当你在 try 子句中打开资源,资源会在 try 代码块执行后或异常处理后自动关闭。 ```java public void automaticallyCloseResource() { File file = new File("./tmp.txt"); try (FileInputStream inputStream = new FileInputStream(file);) { // use the inputStream to read a file } catch (FileNotFoundException e) { log.error(e); } catch (IOException e) { log.error(e); } } ``` #### 异常的底层? 提到JVM处理异常的机制,就需要提及Exception Table,以下称为异常表。我们暂且不急于介绍异常表,先看一个简单的 Java 处理异常的小例子。 ```java public static void simpleTryCatch() { try { testNPE(); } catch (Exception e) { e.printStackTrace(); } } ``` 使用javap来分析这段代码(需要先使用javac编译) ```java //javap -c Main public static void simpleTryCatch(); Code: 0: invokestatic #3 // Method testNPE:()V 3: goto 11 6: astore_0 7: aload_0 8: invokevirtual #5 // Method java/lang/Exception.printStackTrace:()V 11: return Exception table: from to target type 0 3 6 Class java/lang/Exception ``` 看到上面的代码,应该会有会心一笑,因为终于看到了Exception table,也就是我们要研究的异常表。 异常表中包含了一个或多个异常处理者(Exception Handler)的信息,这些信息包含如下 - **from** 可能发生异常的起始点 - **to** 可能发生异常的结束点 - **target** 上述from和to之前发生异常后的异常处理者的位置 - **type** 异常处理者处理的异常的类信息 ### 1.5 反射 #### 什么是反射? JAVA反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为java语言的反射机制。 ![img](https://b2files.173114.xyz/blogimg/2025/03/3b98c1ae126cb173dc1245761f418573.png) #### 反射的使用? 在Java中,Class类与java.lang.reflect类库一起对反射技术进行了全力的支持。在反射包中,我们常用的类主要有Constructor类表示的是Class 对象所表示的类的构造方法,利用它可以在运行时动态创建对象、Field表示Class对象所表示的类的成员变量,通过它可以在运行时动态修改成员变量的属性值(包含private)、Method表示Class对象所表示的类的成员方法,通过它可以动态调用对象的方法(包含private) - Class类对象的获取 ```java @Test public void classTest() throws Exception { // 获取Class对象的三种方式 logger.info("根据类名: \t" + User.class); logger.info("根据对象: \t" + new User().getClass()); logger.info("根据全限定类名:\t" + Class.forName("com.test.User")); // 常用的方法 logger.info("获取全限定类名:\t" + userClass.getName()); logger.info("获取类名:\t" + userClass.getSimpleName()); logger.info("实例化:\t" + userClass.newInstance()); } ``` - Constructor类及其用法 - Field类及其用法 - Method类及其用法 #### getName、getCanonicalName与getSimpleName的区别? - getSimpleName:只获取类名 - getName:类的全限定名,jvm中Class的表示,可以用于动态加载Class对象,例如Class.forName。 - getCanonicalName:返回更容易理解的表示,主要用于输出(toString)或log打印,大多数情况下和getName一样,但是在内部类、数组等类型的表示形式就不同了。 ### 1.6 SPI机制 #### 什么是SPI机制? SPI(Service Provider Interface),是JDK内置的一种 服务提供发现机制,可以用来启用框架扩展和替换组件,主要是被框架的开发人员使用,比如java.sql.Driver接口,其他不同厂商可以针对同一接口做出不同的实现,MySQL和PostgreSQL都有不同的实现提供给用户,而Java的SPI机制可以为某个接口寻找服务实现。Java中SPI机制主要思想是将装配的控制权移到程序之外,在模块化设计中这个机制尤其重要,其核心思想就是 **解耦**。 SPI整体机制图如下: ![img](https://b2files.173114.xyz/blogimg/2025/03/4c506154da87b62d5de62b4d8d05130f.jpg) 当服务的提供者提供了一种接口的实现之后,需要在classpath下的`META-INF/services/`目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体的实现类。当其他的程序需要这个服务的时候,就可以通过查找这个jar包(一般都是以jar包做依赖)的`META-INF/services/`中的配置文件,配置文件中有接口的具体实现类名,可以根据这个类名进行加载实例化,就可以使用该服务了。JDK中查找服务的实现的工具类是:`java.util.ServiceLoader`。 #### SPI机制的应用? - SPI机制 - JDBC DriverManager 在JDBC4.0之前,我们开发有连接数据库的时候,通常会用Class.forName("com.mysql.jdbc.Driver")这句先加载数据库相关的驱动,然后再进行获取连接等的操作。**而JDBC4.0之后不需要用Class.forName("com.mysql.jdbc.Driver")来加载驱动,直接获取连接就可以了,现在这种方式就是使用了Java的SPI扩展机制来实现**。 - JDBC接口定义 首先在java中定义了接口`java.sql.Driver`,并没有具体的实现,具体的实现都是由不同厂商来提供的。 - mysql实现 在mysql的jar包`mysql-connector-java-6.0.6.jar`中,可以找到`META-INF/services`目录,该目录下会有一个名字为`java.sql.Driver`的文件,文件内容是`com.mysql.cj.jdbc.Driver`,这里面的内容就是针对Java中定义的接口的实现。 - postgresql实现 同样在postgresql的jar包`postgresql-42.0.0.jar`中,也可以找到同样的配置文件,文件内容是`org.postgresql.Driver`,这是postgresql对Java的`java.sql.Driver`的实现。 - 使用方法 上面说了,现在使用SPI扩展来加载具体的驱动,我们在Java中写连接数据库的代码的时候,不需要再使用`Class.forName("com.mysql.jdbc.Driver")`来加载驱动了,而是直接使用如下代码: ```java String url = "jdbc:xxxx://xxxx:xxxx/xxxx"; Connection conn = DriverManager.getConnection(url,username,password); ..... ``` #### SPI机制的简单示例? 我们现在需要使用一个内容搜索接口,搜索的实现可能是基于文件系统的搜索,也可能是基于数据库的搜索。 - 先定义好接口 ```java public interface Search { public List searchDoc(String keyword); } ``` - 文件搜索实现 ```java public class FileSearch implements Search{ @Override public List searchDoc(String keyword) { System.out.println("文件搜索 "+keyword); return null; } } ``` - 数据库搜索实现 ```java public class DatabaseSearch implements Search{ @Override public List searchDoc(String keyword) { System.out.println("数据搜索 "+keyword); return null; } } ``` - resources 接下来可以在resources下新建META-INF/services/目录,然后新建接口全限定名的文件:`com.cainiao.ys.spi.learn.Search`,里面加上我们需要用到的实现类 ```xml com.cainiao.ys.spi.learn.FileSearch ``` - 测试方法 ```java public class TestCase { public static void main(String[] args) { ServiceLoader s = ServiceLoader.load(Search.class); Iterator iterator = s.iterator(); while (iterator.hasNext()) { Search search = iterator.next(); search.searchDoc("hello world"); } } } ``` 可以看到输出结果:文件搜索 hello world 如果在`com.cainiao.ys.spi.learn.Search`文件里写上两个实现类,那最后的输出结果就是两行了。 这就是因为`ServiceLoader.load(Search.class)`在加载某接口时,会去`META-INF/services`下找接口的全限定名文件,再根据里面的内容加载相应的实现类。 这就是spi的思想,接口的实现由provider实现,provider只用在提交的jar包里的`META-INF/services`下根据平台定义的接口新建文件,并添加进相应的实现类内容就好。 ## 2 Java 集合 > 容器主要包括 Collection 和 Map 两种,Collection 存储着对象的集合,而 Map 存储着键值对(两个对象)的映射表。 ### 2.1 Collection #### 集合有哪些类? - Set - TreeSet 基于红黑树实现,支持有序性操作,例如根据一个范围查找元素的操作。但是查找效率不如 HashSet,HashSet 查找的时间复杂度为 O(1),TreeSet 则为 O(logN)。 - HashSet 基于哈希表实现,支持快速查找,但不支持有序性操作。并且失去了元素的插入顺序信息,也就是说使用 Iterator 遍历 HashSet 得到的结果是不确定的。 - LinkedHashSet 具有 HashSet 的查找效率,且内部使用双向链表维护元素的插入顺序。 - List - ArrayList 基于动态数组实现,支持随机访问。 - Vector 和 ArrayList 类似,但它是线程安全的。 - LinkedList 基于双向链表实现,只能顺序访问,但是可以快速地在链表中间插入和删除元素。不仅如此,LinkedList 还可以用作栈、队列和双向队列。 - Queue - LinkedList 可以用它来实现双向队列。 - PriorityQueue 基于堆结构实现,可以用它来实现优先队列。 #### ArrayList的底层? *ArrayList*实现了*List*接口,是顺序容器,即元素存放的数据与放进去的顺序相同,允许放入`null`元素,底层通过**数组实现**。除该类未实现同步外,其余跟*Vector*大致相同。每个*ArrayList*都有一个容量(capacity),表示底层数组的实际大小,容器内存储元素的个数不能多于当前容量。当向容器中添加元素时,如果容量不足,容器会自动增大底层数组的大小。前面已经提过,Java泛型只是编译器提供的语法糖,所以这里的数组是一个Object数组,以便能够容纳任何类型的对象。 ![ArrayList_base](https://b2files.173114.xyz/blogimg/2025/03/7a76ac539fb5f30a4e92e3a0041365e7.png) #### ArrayList自动扩容? 每当向数组中添加元素时,都要去检查添加后元素的个数是否会超出当前数组的长度,如果超出,数组将会进行扩容,以满足添加数据的需求。数组扩容通过ensureCapacity(int minCapacity)方法来实现。在实际添加大量元素前,我也可以使用ensureCapacity来手动增加ArrayList实例的容量,以减少递增式再分配的数量。 数组进行扩容时,会将老数组中的元素重新拷贝一份到新的数组中,每次数组容量的增长大约是其原容量的1.5倍。这种操作的代价是很高的,因此在实际使用时,我们应该尽量避免数组容量的扩张。当我们可预知要保存的元素的多少时,要在构造ArrayList实例时,就指定其容量,以避免数组扩容的发生。或者根据实际需求,通过调用ensureCapacity方法来手动增加ArrayList实例的容量。 ![ArrayList_add](https://b2files.173114.xyz/blogimg/2025/03/eda4b8c24ff6fc96ef99dafe70445f15.png) #### ArrayList的Fail-Fast机制? ArrayList也采用了快速失败的机制,通过记录modCount参数来实现。在面对并发的修改时,迭代器很快就会完全失败,而不是冒着在将来某个不确定时间发生任意不确定行为的风险。 ### 2.2 Map #### Map有哪些类? - `TreeMap` 基于红黑树实现。 - `HashMap` 1.7基于哈希表实现,1.8基于数组+链表+红黑树。 - `HashTable` 和 HashMap 类似,但它是线程安全的,这意味着同一时刻多个线程可以同时写入 HashTable 并且不会导致数据不一致。它是遗留类,不应该去使用它。现在可以使用 ConcurrentHashMap 来支持线程安全,并且 ConcurrentHashMap 的效率会更高(1.7 ConcurrentHashMap 引入了分段锁, 1.8 引入了红黑树)。 - `LinkedHashMap` 使用双向链表来维护元素的顺序,顺序为插入顺序或者最近最少使用(LRU)顺序。 #### JDK7 HashMap如何实现? 哈希表有两种实现方式,一种开放地址方式(Open addressing),另一种是冲突链表方式(Separate chaining with linked lists)。**Java7 \*HashMap\*采用的是冲突链表方式**。 ![HashMap_base](https://b2files.173114.xyz/blogimg/2025/03/77e35c8cf322cc1413b76694b15d47e0.png) 从上图容易看出,如果选择合适的哈希函数,`put()`和`get()`方法可以在常数时间内完成。但在对*HashMap*进行迭代时,需要遍历整个table以及后面跟的冲突链表。因此对于迭代比较频繁的场景,不宜将*HashMap*的初始大小设的过大。 有两个参数可以影响*HashMap*的性能: 初始容量(inital capacity)和负载系数(load factor)。初始容量指定了初始`table`的大小,负载系数用来指定自动扩容的临界值。当`entry`的数量超过`capacity*load_factor`时,容器将自动扩容并重新哈希。对于插入元素较多的场景,将初始容量设大可以减少重新哈希的次数。 #### JDK8 HashMap如何实现? 根据 Java7 HashMap 的介绍,我们知道,查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。 为了降低这部分的开销,在 Java8 中,当链表中的元素达到了 8 个时,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。 ![img](https://b2files.173114.xyz/blogimg/2025/03/f4920fea0c56de4c7f5c9f6b9318a426.png) #### HashSet是如何实现的? *HashSet*是对*HashMap*的简单包装,对*HashSet*的函数调用都会转换成合适的*HashMap*方法 ```Java //HashSet是对HashMap的简单包装 public class HashSet { ...... private transient HashMap map;//HashSet里面有一个HashMap // Dummy value to associate with an Object in the backing Map private static final Object PRESENT = new Object(); public HashSet() { map = new HashMap<>(); } ...... public boolean add(E e) {//简单的方法转换 return map.put(e, PRESENT)==null; } ...... } ``` #### 什么是WeakHashMap? 我们都知道Java中内存是通过GC自动管理的,GC会在程序运行过程中自动判断哪些对象是可以被回收的,并在合适的时机进行内存释放。GC判断某个对象是否可被回收的依据是,**是否有有效的引用指向该对象**。如果没有有效引用指向该对象(基本意味着不存在访问该对象的方式),那么该对象就是可回收的。这里的**有效引用** 并不包括**弱引用**。也就是说,**虽然弱引用可以用来访问对象,但进行垃圾回收时弱引用并不会被考虑在内,仅有弱引用指向的对象仍然会被GC回收**。 WeakHashMap 内部是通过弱引用来管理entry的,弱引用的特性对应到 WeakHashMap 上意味着什么呢? *WeakHashMap* 里的`entry`可能会被GC自动删除,即使程序员没有调用`remove()`或者`clear()`方法。 ***WeakHashMap\* 的这个特点特别适用于需要缓存的场景**。在缓存场景下,由于内存是有限的,不能缓存所有对象;对象缓存命中可以提高系统效率,但缓存MISS也不会造成错误,因为可以通过计算重新得到。 ## 3 Java 并发 > 并发和多线程 ### 3.1 并发基础 - [Java 并发 - 理论基础](https://pdai.tech/md/java/thread/java-thread-x-theorty.html) - [Java 并发 - 线程基础](https://pdai.tech/md/java/thread/java-thread-x-thread-basic.html) #### 多线程的出现是要解决什么问题的? 本质什么? CPU、内存、I/O 设备的速度是有极大差异的,为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为: - CPU 增加了缓存,以均衡与内存的速度差异;// 导致可见性问题 - 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;// 导致原子性问题 - 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。// 导致有序性问题 #### Java是怎么解决并发问题的? Java 内存模型是个很复杂的规范,具体看[Java 内存模型详解](https://pdai.tech/md/java/jvm/java-jvm-jmm.html)。 **理解的第一个维度:核心知识点** JMM本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括: - volatile、synchronized 和 final 三个关键字 - Happens-Before 规则 **理解的第二个维度:可见性,有序性,原子性** - **原子性** 在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。 请分析以下哪些操作是原子性操作: ```java x = 10; //语句1: 直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中 y = x; //语句2: 包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。 x++; //语句3: x++包括3个操作:读取x的值,进行加1操作,写入新的值。 x = x + 1; //语句4: 同语句3 ``` 上面4个语句只有语句1的操作具备原子性。 也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。 > 从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。 - **可见性** Java提供了volatile关键字来保证可见性。 当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。 而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。 > 另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。 - **有序性** 在Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。当然JMM是通过Happens-Before 规则来保证有序性的。 #### 线程安全有哪些实现思路? 1. **互斥同步** synchronized 和 ReentrantLock。 2. **非阻塞同步** 互斥同步最主要的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。 互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。 - CAS 随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略: 先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。 乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是: 比较并交换(Compare-and-Swap,CAS)。CAS 指令需要有 3 个操作数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。 - AtomicInteger J.U.C 包里面的整数原子类 AtomicInteger,其中的 compareAndSet() 和 getAndIncrement() 等方法都使用了 Unsafe 类的 CAS 操作。 3. **无同步方案** 要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。 - 栈封闭 多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。 - 线程本地存储(Thread Local Storage) 如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。 #### 如何理解并发和并行的区别? 并发是指一个处理器同时处理多个任务。 ![img](https://b2files.173114.xyz/blogimg/2025/03/515a4311f5bc08242f089a66a68438a3.png) 并行是指多个处理器或者是多核的处理器同时处理多个不同的任务。 #### 线程有哪几种状态? 分别说明从一种状态到另一种状态转变有哪些方式? ![image](https://b2files.173114.xyz/blogimg/2025/03/8fb338e7bab2ae5adfced07f8175793f.png) - 新建(New) 创建后尚未启动。 - 可运行(Runnable) 可能正在运行,也可能正在等待 CPU 时间片。 包含了操作系统线程状态中的 Running 和 Ready。 - 阻塞(Blocking) 等待获取一个排它锁,如果其线程释放了锁就会结束此状态。 - 无限期等待(Waiting) 等待其它线程显式地唤醒,否则不会被分配 CPU 时间片。 | 进入方法 | 退出方法 | | ------------------------------------------ | ------------------------------------ | | 没有设置 Timeout 参数的 Object.wait() 方法 | Object.notify() / Object.notifyAll() | | 没有设置 Timeout 参数的 Thread.join() 方法 | 被调用的线程执行完毕 | | LockSupport.park() 方法 | - | - 限期等待(Timed Waiting) 无需等待其它线程显式地唤醒,在一定时间之后会被系统自动唤醒。 调用 Thread.sleep() 方法使线程进入限期等待状态时,常常用“使一个线程睡眠”进行描述。 调用 Object.wait() 方法使线程进入限期等待或者无限期等待时,常常用“挂起一个线程”进行描述。 睡眠和挂起是用来描述行为,而阻塞和等待用来描述状态。 阻塞和等待的区别在于,阻塞是被动的,它是在等待获取一个排它锁。而等待是主动的,通过调用 Thread.sleep() 和 Object.wait() 等方法进入。 | 进入方法 | 退出方法 | | ---------------------------------------- | ----------------------------------------------- | | Thread.sleep() 方法 | 时间结束 | | 设置了 Timeout 参数的 Object.wait() 方法 | 时间结束 / Object.notify() / Object.notifyAll() | | 设置了 Timeout 参数的 Thread.join() 方法 | 时间结束 / 被调用的线程执行完毕 | | LockSupport.parkNanos() 方法 | - | | LockSupport.parkUntil() 方法 | - | - 死亡(Terminated) 可以是线程结束任务之后自己结束,或者产生了异常而结束。 #### 通常线程有哪几种使用方式? 有三种使用线程的方法: - 实现 Runnable 接口; - 实现 Callable 接口; - 继承 Thread 类。 实现 Runnable 和 Callable 接口的类只能当做一个可以在线程中运行的任务,不是真正意义上的线程,因此最后还需要通过 Thread 来调用。可以说任务是通过线程驱动从而执行的。 #### 基础线程机制有哪些? - **Executor** Executor 管理多个异步任务的执行,而无需程序员显式地管理线程的生命周期。这里的异步是指多个任务的执行互不干扰,不需要进行同步操作。 主要有三种 Executor: 1. CachedThreadPool: 一个任务创建一个线程; 2. FixedThreadPool: 所有任务只能使用固定大小的线程; 3. SingleThreadExecutor: 相当于大小为 1 的 FixedThreadPool。 - **Daemon** 守护线程是程序运行时在后台提供服务的线程,不属于程序中不可或缺的部分。 当所有非守护线程结束时,程序也就终止,同时会杀死所有守护线程。 main() 属于非守护线程。使用 setDaemon() 方法将一个线程设置为守护线程。 - **sleep()** Thread.sleep(millisec) 方法会休眠当前正在执行的线程,millisec 单位为毫秒。 sleep() 可能会抛出 InterruptedException,因为异常不能跨线程传播回 main() 中,因此必须在本地进行处理。线程中抛出的其它异常也同样需要在本地进行处理。 - **yield()** 对静态方法 Thread.yield() 的调用声明了当前线程已经完成了生命周期中最重要的部分,可以切换给其它线程来执行。该方法只是对线程调度器的一个建议,而且也只是建议具有相同优先级的其它线程可以运行。 #### 线程的中断方式有哪些? 一个线程执行完毕之后会自动结束,如果在运行过程中发生异常也会提前结束。 - **InterruptedException** 通过调用一个线程的 interrupt() 来中断该线程,如果该线程处于阻塞、限期等待或者无限期等待状态,那么就会抛出 InterruptedException,从而提前结束该线程。但是不能中断 I/O 阻塞和 synchronized 锁阻塞。 对于以下代码,在 main() 中启动一个线程之后再中断它,由于线程中调用了 Thread.sleep() 方法,因此会抛出一个 InterruptedException,从而提前结束线程,不执行之后的语句。 ```java public class InterruptExample { private static class MyThread1 extends Thread { @Override public void run() { try { Thread.sleep(2000); System.out.println("Thread run"); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) throws InterruptedException { Thread thread1 = new MyThread1(); thread1.start(); thread1.interrupt(); System.out.println("Main run"); } } ``` ```html Main run java.lang.InterruptedException: sleep interrupted at java.lang.Thread.sleep(Native Method) at InterruptExample.lambda$main$0(InterruptExample.java:5) at InterruptExample$$Lambda$1/713338599.run(Unknown Source) at java.lang.Thread.run(Thread.java:745) ``` - **interrupted()** 如果一个线程的 run() 方法执行一个无限循环,并且没有执行 sleep() 等会抛出 InterruptedException 的操作,那么调用线程的 interrupt() 方法就无法使线程提前结束。 但是调用 interrupt() 方法会设置线程的中断标记,此时调用 interrupted() 方法会返回 true。因此可以在循环体中使用 interrupted() 方法来判断线程是否处于中断状态,从而提前结束线程。 - **Executor 的中断操作** 调用 Executor 的 shutdown() 方法会等待线程都执行完毕之后再关闭,但是如果调用的是 shutdownNow() 方法,则相当于调用每个线程的 interrupt() 方法。 #### 线程的互斥同步方式有哪些? 如何比较和选择? Java 提供了两种锁机制来控制多个线程对共享资源的互斥访问,第一个是 JVM 实现的 synchronized,而另一个是 JDK 实现的 ReentrantLock。 **1. 锁的实现** synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的。 **2. 性能** 新版本 Java 对 synchronized 进行了很多优化,例如自旋锁等,synchronized 与 ReentrantLock 大致相同。 **3. 等待可中断** 当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。 ReentrantLock 可中断,而 synchronized 不行。 **4. 公平锁** 公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。 synchronized 中的锁是非公平的,ReentrantLock 默认情况下也是非公平的,但是也可以是公平的。 **5. 锁绑定多个条件** 一个 ReentrantLock 可以同时绑定多个 Condition 对象。 #### 线程之间有哪些协作方式? 当多个线程可以一起工作去解决某个问题时,如果某些部分必须在其它部分之前完成,那么就需要对线程进行协调。 - **join()** 在线程中调用另一个线程的 join() 方法,会将当前线程挂起,而不是忙等待,直到目标线程结束。 对于以下代码,虽然 b 线程先启动,但是因为在 b 线程中调用了 a 线程的 join() 方法,b 线程会等待 a 线程结束才继续执行,因此最后能够保证 a 线程的输出先于 b 线程的输出。 ```java public class JoinExample { private class A extends Thread { @Override public void run() { System.out.println("A"); } } private class B extends Thread { private A a; B(A a) { this.a = a; } @Override public void run() { try { a.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("B"); } } public void test() { A a = new A(); B b = new B(a); b.start(); a.start(); } } ``` ```java public static void main(String[] args) { JoinExample example = new JoinExample(); example.test(); } ``` ```java A B ``` - **wait() notify() notifyAll()** 调用 wait() 使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其它线程会调用 notify() 或者 notifyAll() 来唤醒挂起的线程。 它们都属于 Object 的一部分,而不属于 Thread。 只能用在同步方法或者同步控制块中使用,否则会在运行时抛出 IllegalMonitorStateExeception。 使用 wait() 挂起期间,线程会释放锁。这是因为,如果没有释放锁,那么其它线程就无法进入对象的同步方法或者同步控制块中,那么就无法执行 notify() 或者 notifyAll() 来唤醒挂起的线程,造成死锁。 **wait() 和 sleep() 的区别** - wait() 是 Object 的方法,而 sleep() 是 Thread 的静态方法; - wait() 会释放锁,sleep() 不会。 - **await() signal() signalAll()** java.util.concurrent 类库中提供了 Condition 类来实现线程之间的协调,可以在 Condition 上调用 await() 方法使线程等待,其它线程调用 signal() 或 signalAll() 方法唤醒等待的线程。相比于 wait() 这种等待方式,await() 可以指定等待的条件,因此更加灵活。 ### 3.2 并发关键字 - [关键字: synchronized详解](https://pdai.tech/md/java/thread/java-thread-x-key-synchronized.html) - [关键字: volatile详解](https://pdai.tech/md/java/thread/java-thread-x-key-volatile.html) - [关键字: final详解](https://pdai.tech/md/java/thread/java-thread-x-key-final.html) #### Synchronized可以作用在哪里? - 对象锁 - 方法锁 - 类锁 #### Synchronized本质上是通过什么保证线程安全的? - **加锁和释放锁的原理** 深入JVM看字节码,创建如下的代码: ```java public class SynchronizedDemo2 { Object object = new Object(); public void method1() { synchronized (object) { } } } ``` 使用javac命令进行编译生成.class文件 ```bash >javac SynchronizedDemo2.java ``` 使用javap命令反编译查看.class文件的信息 ```bash >javap -verbose SynchronizedDemo2.class ``` 得到如下的信息: ![img](https://b2files.173114.xyz/blogimg/2025/03/0dc7c376142c8f494b26452e52a382e3.png) 关注红色方框里的`monitorenter`和`monitorexit`即可。 `Monitorenter`和`Monitorexit`指令,会让对象在执行,使其锁计数器加1或者减1。每一个对象在同一时间只与一个monitor(锁)相关联,而一个monitor在同一时间只能被一个线程获得,一个对象在尝试获得与这个对象相关联的Monitor锁的所有权的时候,monitorenter指令会发生如下3中情况之一: - monitor计数器为0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器+1,一旦+1,别的线程再想获取,就需要等待 - 如果这个monitor已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成2,并且随着重入的次数,会一直累加 - 这把锁已经被别的线程获取了,等待锁释放 `monitorexit指令`:释放对于monitor的所有权,释放过程很简单,就是讲monitor的计数器减1,如果减完以后,计数器不是0,则代表刚才是重入进来的,当前线程还继续持有这把锁的所有权,如果计数器变成0,则代表当前线程不再拥有该monitor的所有权,即释放锁。 下图表现了对象,对象监视器,同步队列以及执行线程状态之间的关系: ![img](https://b2files.173114.xyz/blogimg/2025/03/4b7871367f135c7f1b6b41775860719f.png) 该图可以看出,任意线程对Object的访问,首先要获得Object的监视器,如果获取失败,该线程就进入同步状态,线程状态变为BLOCKED,当Object的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。 - **可重入原理:加锁次数计数器** 看如下的例子: ```java public class SynchronizedDemo { public static void main(String[] args) { synchronized (SynchronizedDemo.class) { } method2(); } private synchronized static void method2() { } } ``` 对应的字节码 ```bash public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: (0x0009) ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=3, args_size=1 0: ldc #2 // class tech/pdai/test/synchronized/SynchronizedDemo 2: dup 3: astore_1 4: monitorenter 5: aload_1 6: monitorexit 7: goto 15 10: astore_2 11: aload_1 12: monitorexit 13: aload_2 15: invokestatic #3 // Method method2:()V Exception table: from to target type 5 7 10 any 10 13 10 any ``` 上面的SynchronizedDemo中在执行完同步代码块之后紧接着再会去执行一个静态同步方法,而这个方法锁的对象依然就这个类对象,那么这个正在执行的线程还需要获取该锁吗? 答案是不必的,从上图中就可以看出来,执行静态同步方法的时候就只有一条monitorexit指令,并没有monitorenter获取锁的指令。这就是锁的重入性,即在同一锁程中,线程不需要再次获取同一把锁。 Synchronized先天具有重入性。每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一。 - **保证可见性的原理:内存模型和happens-before规则** Synchronized的happens-before规则,即监视器锁规则:对同一个监视器的解锁,happens-before于对该监视器的加锁。继续来看代码: ```java public class MonitorDemo { private int a = 0; public synchronized void writer() { // 1 a++; // 2 } // 3 public synchronized void reader() { // 4 int i = a; // 5 } // 6 } ``` 该代码的happens-before关系如图所示: ![img](https://b2files.173114.xyz/blogimg/2025/03/31aff5f51d54ae1b363a1a1178b1c2c1.png) 在图中每一个箭头连接的两个节点就代表之间的happens-before关系,黑色的是通过程序顺序规则推导出来,红色的为监视器锁规则推导而出:线程A释放锁happens-before线程B加锁,蓝色的则是通过程序顺序规则和监视器锁规则推测出来happens-befor关系,通过传递性规则进一步推导的happens-before关系。现在我们来重点关注2 happens-before 5,通过这个关系我们可以得出什么? 根据happens-before的定义中的一条:如果A happens-before B,则A的执行结果对B可见,并且A的执行顺序先于B。线程A先对共享变量A进行加一,由2 happens-before 5关系可知线程A的执行结果对线程B可见即线程B所读取到的a的值为1。 #### Synchronized使得同时只有一个线程可以执行,性能比较差,有什么提升的方法? 简单来说在JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)如果每次都调用Mutex Lock那么将严重的影响程序的性能。**不过在jdk1.6中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销**。 - **锁粗化(Lock Coarsening)**:也就是减少不必要的紧连在一起的unlock,lock操作,将多个连续的锁扩展成一个范围更大的锁。 - **锁消除(Lock Elimination)**:通过运行时JIT编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,通过逃逸分析也可以在线程本地Stack上进行对象空间的分配(同时还可以减少Heap上的垃圾收集开销)。 - **轻量级锁(Lightweight Locking)**:这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒。 - **偏向锁(Biased Locking)**:是为了在无锁竞争的情况下避免在锁获取过程中执行不必要的CAS原子指令,因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟。 - **适应性自旋(Adaptive Spinning)**:当线程在获取轻量级锁的过程中执行CAS操作失败时,在进入与monitor相关联的操作系统重量级锁(mutex semaphore)前会进入忙等待(Spinning)然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该monitor关联的semaphore(即互斥锁)进入到阻塞状态。 #### Synchronized由什么样的缺陷? Java Lock是怎么弥补这些缺陷的? - **synchronized的缺陷** 1. **效率低**:锁的释放情况少,只有代码执行完毕或者异常结束才会释放锁;试图获取锁的时候不能设定超时,不能中断一个正在使用锁的线程,相对而言,Lock可以中断和设置超时 2. **不够灵活**:加锁和释放的时机单一,每个锁仅有一个单一的条件(某个对象),相对而言,读写锁更加灵活 3. **无法知道是否成功获得锁**,相对而言,Lock可以拿到状态 - **Lock解决相应问题** Lock类这里不做过多解释,主要看里面的4个方法: 1. `lock()`: 加锁 2. `unlock()`: 解锁 3. `tryLock()`: 尝试获取锁,返回一个boolean值 4. `tryLock(long,TimeUtil)`: 尝试获取锁,可以设置超时 Synchronized只有锁只与一个条件(是否获取锁)相关联,不灵活,后来**Condition与Lock的结合**解决了这个问题。 多线程竞争一个锁时,其余未得到锁的线程只能不停的尝试获得锁,而不能中断。高并发的情况下会导致性能下降。ReentrantLock的lockInterruptibly()方法可以优先考虑响应中断。 一个线程等待时间过长,它可以中断自己,然后ReentrantLock响应这个中断,不再让这个线程继续等待。有了这个机制,使用ReentrantLock时就不会像synchronized那样产生死锁了。 #### Synchronized和Lock的对比,和选择? - **存在层次上** synchronized: Java的关键字,在jvm层面上 Lock: 是一个接口 - **锁的释放** synchronized: 1、以获取锁的线程执行完同步代码,释放锁 2、线程执行发生异常,jvm会让线程释放锁 Lock: 在finally中必须释放锁,不然容易造成线程死锁 - **锁的获取** synchronized: 假设A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待 Lock: 分情况而定,Lock有多个锁获取的方式,大致就是可以尝试获得锁,线程可以不用一直等待(可以通过tryLock判断有没有锁) - **锁的释放(死锁产生)** synchronized: 在发生异常时候会自动释放占有的锁,因此不会出现死锁 Lock: 发生异常时候,不会主动释放占有的锁,必须手动unlock来释放锁,可能引起死锁的发生 - **锁的状态** synchronized: 无法判断 Lock: 可以判断 - **锁的类型** synchronized: 可重入 不可中断 非公平 Lock: 可重入 可判断 可公平(两者皆可) - **性能** synchronized: 少量同步 Lock: 大量同步 Lock可以提高多个线程进行读操作的效率。(可以通过readwritelock实现读写分离) 在资源竞争不是很激烈的情况下,Synchronized的性能要优于ReetrantLock,但是在资源竞争很激烈的情况下,Synchronized的性能会下降几十倍,但是ReetrantLock的性能能维持常态; ReentrantLock提供了多样化的同步,比如有时间限制的同步,可以被Interrupt的同步(synchronized的同步是不能Interrupt的)等。在资源竞争不激烈的情形下,性能稍微比synchronized差点点。但是当同步非常激烈的时候,synchronized的性能一下子能下降好几十倍。而ReentrantLock确还能维持常态。 - **调度** synchronized: 使用Object对象本身的wait 、notify、notifyAll调度机制 Lock: 可以使用Condition进行线程之间的调度 - **用法** synchronized: 在需要同步的对象中加入此控制,synchronized可以加在方法上,也可以加在特定代码块中,括号中表示需要锁的对象。 Lock: 一般使用ReentrantLock类做为锁。在加锁和解锁处需要通过lock()和unlock()显示指出。所以一般会在finally块中写unlock()以防死锁。 - **底层实现** synchronized: 底层使用指令码方式来控制锁的,映射成字节码指令就是增加来两个指令:monitorenter和monitorexit。当线程执行遇到monitorenter指令时会尝试获取内置锁,如果获取锁则锁计数器+1,如果没有获取锁则阻塞;当遇到monitorexit指令时锁计数器-1,如果计数器为0则释放锁。 Lock: 底层是CAS乐观锁,依赖AbstractQueuedSynchronizer类,把所有的请求线程构成一个CLH队列。而对该队列的操作均通过Lock-Free(CAS)操作。 #### Synchronized在使用时有何注意事项? - 锁对象不能为空,因为锁的信息都保存在对象头里 - 作用域不宜过大,影响程序执行的速度,控制范围过大,编写代码也容易出错 - 避免死锁 - 在能选择的情况下,既不要用Lock也不要用synchronized关键字,用java.util.concurrent包中的各种各样的类,如果不用该包下的类,在满足业务的情况下,可以使用synchronized关键,因为代码量少,避免出错 #### Synchronized修饰的方法在抛出异常时,会释放锁吗? 会 #### 多个线程等待同一个Synchronized锁的时候,JVM如何选择下一个获取锁的线程? 非公平锁,即抢占式。 #### synchronized是公平锁吗? synchronized实际上是非公平的,新来的线程有可能立即获得监视器,而在等待区中等候已久的线程可能再次等待,这样有利于提高性能,但是也可能会导致饥饿现象。 #### volatile关键字的作用是什么? - **防重排序** 我们从一个最经典的例子来分析重排序问题。大家应该都很熟悉单例模式的实现,而在并发环境下的单例实现方式,我们通常可以采用双重检查加锁(DCL)的方式来实现。其源码如下: ```java public class Singleton { public static volatile Singleton singleton; /** * 构造函数私有,禁止外部实例化 */ private Singleton() {}; public static Singleton getInstance() { if (singleton == null) { synchronized (singleton.class) { if (singleton == null) { singleton = new Singleton(); } } } return singleton; } } ``` 现在我们分析一下为什么要在变量singleton之间加上volatile关键字。要理解这个问题,先要了解对象的构造过程,实例化一个对象其实可以分为三个步骤: 1. 分配内存空间。 2. 初始化对象。 3. 将内存空间的地址赋值给对应的引用。 但是由于操作系统可以**对指令进行重排序**,所以上面的过程也可能会变成如下过程: 1. 分配内存空间。 2. 将内存空间的地址赋值给对应的引用。 3. 初始化对象 如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量。 - **实现可见性** 可见性问题主要指一个线程修改了共享变量值,而另一个线程却看不到。引起可见性问题的主要原因是每个线程拥有自己的一个高速缓存区——线程工作内存。volatile关键字能有效的解决这个问题,我们看下下面的例子,就可以知道其作用: ```java public class TestVolatile { private static boolean stop = false; public static void main(String[] args) { // Thread-A new Thread("Thread A") { @Override public void run() { while (!stop) { } System.out.println(Thread.currentThread() + " stopped"); } }.start(); // Thread-main try { TimeUnit.SECONDS.sleep(1); System.out.println(Thread.currentThread() + " after 1 seconds"); } catch (InterruptedException e) { e.printStackTrace(); } stop = true; } } ``` 执行输出如下 ```bash Thread[main,5,main] after 1 seconds // Thread A一直在loop, 因为Thread A 由于可见性原因看不到Thread Main 已经修改stop的值 ``` 可以看到 Thread-main 休眠1秒之后,设置 stop = ture,但是Thread A根本没停下来,这就是可见性问题。如果通过在stop变量前面加上volatile关键字则会真正stop: ```bash Thread[main,5,main] after 1 seconds Thread[Thread A,5,main] stopped Process finished with exit code 0 ``` - **保证原子性:单次读/写** volatile不能保证完全的原子性,只能保证单次的读/写操作具有原子性。 #### volatile能保证原子性吗? 不能完全保证,只能保证单次的读/写操作具有原子性。 #### 32位机器上共享的long和double变量的为什么要用volatile? 因为long和double两种数据类型的操作可分为高32位和低32位两部分,因此普通的long或double类型读/写可能不是原子的。因此,鼓励大家将共享的long和double变量设置为volatile类型,这样能保证任何情况下对long和double的单次读/写操作都具有原子性。 如下是JLS中的解释: > 17.7 Non-Atomic Treatment of double and long - For the purposes of the Java programming language memory model, a single write to a non-volatile long or double value is treated as two separate writes: one to each 32-bit half. This can result in a situation where a thread sees the first 32 bits of a 64-bit value from one write, and the second 32 bits from another write. - Writes and reads of volatile long and double values are always atomic. - Writes to and reads of references are always atomic, regardless of whether they are implemented as 32-bit or 64-bit values. - Some implementations may find it convenient to divide a single write action on a 64-bit long or double value into two write actions on adjacent 32-bit values. For efficiency’s sake, this behavior is implementation-specific; an implementation of the Java Virtual Machine is free to perform writes to long and double values atomically or in two parts. - Implementations of the Java Virtual Machine are encouraged to avoid splitting 64-bit values where possible. Programmers are encouraged to declare shared 64-bit values as volatile or synchronize their programs correctly to avoid possible complications. 目前各种平台下的商用虚拟机都选择把 64 位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不把long 和 double 变量专门声明为 volatile多数情况下也是不会错的。 #### volatile是如何实现可见性的? 内存屏障。 #### volatile是如何实现有序性的? happens-before等 #### 说下volatile的应用场景? 使用 volatile 必须具备的条件 1. 对变量的写操作不依赖于当前值。 2. 该变量没有包含在具有其他变量的不变式中。 3. 只有在状态真正独立于程序内其他内容时才能使用 volatile。 - **例子 1: 单例模式** 单例模式的一种实现方式,但很多人会忽略 volatile 关键字,因为没有该关键字,程序也可以很好的运行,只不过代码的稳定性总不是 100%,说不定在未来的某个时刻,隐藏的 bug 就出来了。 ```java class Singleton { private volatile static Singleton instance; private Singleton() { } public static Singleton getInstance() { if (instance == null) { syschronized(Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } } ``` - **例子2: volatile bean** 在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须非常普通 —— 除了获取或设置相应的属性外,不能包含任何逻辑。此外,对于对象引用的数据成员,引用的对象必须是有效不可变的。(这将禁止具有数组值的属性,因为当数组引用被声明为 volatile 时,只有引用而不是数组本身具有 volatile 语义)。对于任何 volatile 变量,不变式或约束都不能包含 JavaBean 属性。 ```java @ThreadSafe public class Person { private volatile String firstName; private volatile String lastName; private volatile int age; public String getFirstName() { return firstName; } public String getLastName() { return lastName; } public int getAge() { return age; } public void setFirstName(String firstName) { this.firstName = firstName; } public void setLastName(String lastName) { this.lastName = lastName; } public void setAge(int age) { this.age = age; } } ``` #### 所有的final修饰的字段都是编译期常量吗? 不是 #### 如何理解private所修饰的方法是隐式的final? 类中所有private方法都隐式地指定为final的,由于无法取用private方法,所以也就不能覆盖它。可以对private方法增添final关键字,但这样做并没有什么好处。看下下面的例子: ```java public class Base { private void test() { } } public class Son extends Base{ public void test() { } public static void main(String[] args) { Son son = new Son(); Base father = son; //father.test(); } } ``` Base和Son都有方法test(),但是这并不是一种覆盖,因为private所修饰的方法是隐式的final,也就是无法被继承,所以更不用说是覆盖了,在Son中的test()方法不过是属于Son的新成员罢了,Son进行向上转型得到father,但是father.test()是不可执行的,因为Base中的test方法是private的,无法被访问到。 #### 说说final类型的类如何拓展? 比如String是final类型,我们想写个MyString复用所有String中方法,同时增加一个新的toMyString()的方法,应该如何做? 外观模式: ```java /** * @pdai */ class MyString{ private String innerString; // ...init & other methods // 支持老的方法 public int length(){ return innerString.length(); // 通过innerString调用老的方法 } // 添加新方法 public String toMyString(){ //... } } ``` #### final方法可以被重载吗? 我们知道父类的final方法是不能够被子类重写的,那么final方法可以被重载吗? 答案是可以的,下面代码是正确的。 ```java public class FinalExampleParent { public final void test() { } public final void test(String str) { } } ``` #### 父类的final方法能不能够被子类重写? 不可以 #### 说说基本类型的final域重排序规则? 先看一段示例性的代码: ```java public class FinalDemo { private int a; //普通域 private final int b; //final域 private static FinalDemo finalDemo; public FinalDemo() { a = 1; // 1. 写普通域 b = 2; // 2. 写final域 } public static void writer() { finalDemo = new FinalDemo(); } public static void reader() { FinalDemo demo = finalDemo; // 3.读对象引用 int a = demo.a; //4.读普通域 int b = demo.b; //5.读final域 } } ``` 假设线程A在执行writer()方法,线程B执行reader()方法。 - **写final域重排序规则** 写final域的重排序规则禁止对final域的写重排序到构造函数之外,这个规则的实现主要包含了两个方面: - JMM禁止编译器把final域的写重排序到构造函数之外; - 编译器会在final域写之后,构造函数return之前,插入一个storestore屏障。这个屏障可以禁止处理器把final域的写重排序到构造函数之外。 我们再来分析writer方法,虽然只有一行代码,但实际上做了两件事情: - 构造了一个FinalDemo对象; - 把这个对象赋值给成员变量finalDemo。 我们来画下存在的一种可能执行时序图,如下: ![img](https://b2files.173114.xyz/blogimg/2025/03/7025bf0bfee4b15c3bbabc261b370fef.png) 由于a,b之间没有数据依赖性,普通域(普通变量)a可能会被重排序到构造函数之外,线程B就有可能读到的是普通变量a初始化之前的值(零值),这样就可能出现错误。而final域变量b,根据重排序规则,会禁止final修饰的变量b重排序到构造函数之外,从而b能够正确赋值,线程B就能够读到final变量初始化后的值。 因此,写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域就不具有这个保障。比如在上例,线程B有可能就是一个未正确初始化的对象finalDemo。 - **读final域重排序规则** 读final域重排序规则为:在一个线程中,初次读对象引用和初次读该对象包含的final域,JMM会禁止这两个操作的重排序。(注意,这个规则仅仅是针对处理器),处理器会在读final域操作的前面插入一个LoadLoad屏障。实际上,读对象的引用和读该对象的final域存在间接依赖性,一般处理器不会重排序这两个操作。但是有一些处理器会重排序,因此,这条禁止重排序规则就是针对这些处理器而设定的。 read()方法主要包含了三个操作: - 初次读引用变量finalDemo; - 初次读引用变量finalDemo的普通域a; - 初次读引用变量finalDemo的final域b; 假设线程A写过程没有重排序,那么线程A和线程B有一种的可能执行时序为下图: ![img](https://b2files.173114.xyz/blogimg/2025/03/14b87719194eef3677496394792dd0eb.png) 读对象的普通域被重排序到了读对象引用的前面就会出现线程B还未读到对象引用就在读取该对象的普通域变量,这显然是错误的操作。而final域的读操作就“限定”了在读final域变量前已经读到了该对象的引用,从而就可以避免这种情况。 读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读这个包含这个final域的对象的引用。 #### 说说final的原理? - 写final域会要求编译器在final域写之后,构造函数返回前插入一个StoreStore屏障。 - 读final域的重排序规则会要求编译器在读final域的操作前插入一个LoadLoad屏障。 PS:很有意思的是,如果以X86处理为例,X86不会对写-写重排序,所以StoreStore屏障可以省略。由于不会对有间接依赖性的操作重排序,所以在X86处理器中,读final域需要的LoadLoad屏障也会被省略掉。也就是说,以X86为例的话,对final域的读/写的内存屏障都会被省略!具体是否插入还是得看是什么处理器。 ### 3.3 JUC全局观 - [JUC - 类汇总和学习指南](https://pdai.tech/md/java/thread/java-thread-x-juc-overview.html) #### JUC框架包含几个部分? 五个部分: ![image](https://b2files.173114.xyz/blogimg/2025/03/6162f47c67c15b9c7e2483a248da11ec.png) 主要包含: (注意: 上图是网上找的图,无法表述一些继承关系,同时少了部分类;但是主体上可以看出其分类关系也够了) - Lock框架和Tools类(把图中这两个放到一起理解) - Collections: 并发集合 - Atomic: 原子类 - Executors: 线程池 #### Lock框架和Tools哪些核心的类? ![image](https://b2files.173114.xyz/blogimg/2025/03/f26929232c6641d3cd8b815274f19f26.png) - **接口: Condition**, Condition为接口类型,它将 Object 监视器方法(wait、notify 和 notifyAll)分解成截然不同的对象,以便通过将这些对象与任意 Lock 实现组合使用,为每个对象提供多个等待 set (wait-set)。其中,Lock 替代了 synchronized 方法和语句的使用,Condition 替代了 Object 监视器方法的使用。可以通过await(),signal()来休眠/唤醒线程。 - **接口: Lock**,Lock为接口类型,Lock实现提供了比使用synchronized方法和语句可获得的更广泛的锁定操作。此实现允许更灵活的结构,可以具有差别很大的属性,可以支持多个相关的Condition对象。 - **接口ReadWriteLock** ReadWriteLock为接口类型, 维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。只要没有 writer,读取锁可以由多个 reader 线程同时保持。写入锁是独占的。 - **抽象类: AbstractOwnableSynchonizer** AbstractOwnableSynchonizer为抽象类,可以由线程以独占方式拥有的同步器。此类为创建锁和相关同步器(伴随着所有权的概念)提供了基础。AbstractOwnableSynchronizer 类本身不管理或使用此信息。但是,子类和工具可以使用适当维护的值帮助控制和监视访问以及提供诊断。 - **抽象类(long): AbstractQueuedLongSynchronizer** AbstractQueuedLongSynchronizer为抽象类,以 long 形式维护同步状态的一个 AbstractQueuedSynchronizer 版本。此类具有的结构、属性和方法与 AbstractQueuedSynchronizer 完全相同,但所有与状态相关的参数和结果都定义为 long 而不是 int。当创建需要 64 位状态的多级别锁和屏障等同步器时,此类很有用。 - **核心抽象类(int): AbstractQueuedSynchronizer** AbstractQueuedSynchronizer为抽象类,其为实现依赖于先进先出 (FIFO) 等待队列的阻塞锁和相关同步器(信号量、事件,等等)提供一个框架。此类的设计目标是成为依靠单个原子 int 值来表示状态的大多数同步器的一个有用基础。 - **锁常用类: LockSupport** LockSupport为常用类,用来创建锁和其他同步类的基本线程阻塞原语。LockSupport的功能和"Thread中的 Thread.suspend()和Thread.resume()有点类似",LockSupport中的park() 和 unpark() 的作用分别是阻塞线程和解除阻塞线程。但是park()和unpark()不会遇到“Thread.suspend 和 Thread.resume所可能引发的死锁”问题。 - **锁常用类: ReentrantLock** ReentrantLock为常用类,它是一个可重入的互斥锁 Lock,它具有与使用 synchronized 方法和语句所访问的隐式监视器锁相同的一些基本行为和语义,但功能更强大。 - **锁常用类: ReentrantReadWriteLock** ReentrantReadWriteLock是读写锁接口ReadWriteLock的实现类,它包括Lock子类ReadLock和WriteLock。ReadLock是共享锁,WriteLock是独占锁。 - **锁常用类: StampedLock** 它是java8在java.util.concurrent.locks新增的一个API。StampedLock控制锁有三种模式(写,读,乐观读),一个StampedLock状态是由版本和模式两个部分组成,锁获取方法返回一个数字作为票据stamp,它用相应的锁状态表示并控制访问,数字0表示没有写锁被授权访问。在读锁上分为悲观锁和乐观锁。 - **工具常用类: CountDownLatch** CountDownLatch为常用类,它是一个同步辅助类,在完成一组正在其他线程中执行的操作之前,它允许一个或多个线程一直等待。 - **工具常用类: CyclicBarrier** CyclicBarrier为常用类,其是一个同步辅助类,它允许一组线程互相等待,直到到达某个公共屏障点 (common barrier point)。在涉及一组固定大小的线程的程序中,这些线程必须不时地互相等待,此时 CyclicBarrier 很有用。因为该 barrier 在释放等待线程后可以重用,所以称它为循环 的 barrier。 - **工具常用类: Phaser** Phaser是JDK 7新增的一个同步辅助类,它可以实现CyclicBarrier和CountDownLatch类似的功能,而且它支持对任务的动态调整,并支持分层结构来达到更高的吞吐量。 - **工具常用类: Semaphore** Semaphore为常用类,其是一个计数信号量,从概念上讲,信号量维护了一个许可集。如有必要,在许可可用前会阻塞每一个 acquire(),然后再获取该许可。每个 release() 添加一个许可,从而可能释放一个正在阻塞的获取者。但是,不使用实际的许可对象,Semaphore 只对可用许可的号码进行计数,并采取相应的行动。通常用于限制可以访问某些资源(物理或逻辑的)的线程数目。 - **工具常用类: Exchanger** Exchanger是用于线程协作的工具类, 主要用于两个线程之间的数据交换。它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过exchange()方法交换数据,当一个线程先执行exchange()方法后,它会一直等待第二个线程也执行exchange()方法,当这两个线程到达同步点时,这两个线程就可以交换数据了。 #### JUC并发集合哪些核心的类? ![image](https://b2files.173114.xyz/blogimg/2025/03/26ed03fb86a5c122412a9234660f2d51.png) - **Queue: ArrayBlockingQueue** 一个由数组支持的有界阻塞队列。此队列按 FIFO(先进先出)原则对元素进行排序。队列的头部 是在队列中存在时间最长的元素。队列的尾部 是在队列中存在时间最短的元素。新元素插入到队列的尾部,队列获取操作则是从队列头部开始获得元素。 - **Queue: LinkedBlockingQueue** 一个基于已链接节点的、范围任意的 blocking queue。此队列按 FIFO(先进先出)排序元素。队列的头部 是在队列中时间最长的元素。队列的尾部 是在队列中时间最短的元素。新元素插入到队列的尾部,并且队列获取操作会获得位于队列头部的元素。链接队列的吞吐量通常要高于基于数组的队列,但是在大多数并发应用程序中,其可预知的性能要低。 - **Queue: LinkedBlockingDeque** 一个基于已链接节点的、任选范围的阻塞双端队列。 - **Queue: ConcurrentLinkedQueue** 一个基于链接节点的无界线程安全队列。此队列按照 FIFO(先进先出)原则对元素进行排序。队列的头部 是队列中时间最长的元素。队列的尾部 是队列中时间最短的元素。新的元素插入到队列的尾部,队列获取操作从队列头部获得元素。当多个线程共享访问一个公共 collection 时,ConcurrentLinkedQueue 是一个恰当的选择。此队列不允许使用 null 元素。 - **Queue: ConcurrentLinkedDeque** 是双向链表实现的无界队列,该队列同时支持FIFO和FILO两种操作方式。 - **Queue: DelayQueue** 延时无界阻塞队列,使用Lock机制实现并发访问。队列里只允许放可以“延期”的元素,队列中的head是最先“到期”的元素。如果队里中没有元素到“到期”,那么就算队列中有元素也不能获取到。 - **Queue: PriorityBlockingQueue** 无界优先级阻塞队列,使用Lock机制实现并发访问。priorityQueue的线程安全版,不允许存放null值,依赖于comparable的排序,不允许存放不可比较的对象类型。 - **Queue: SynchronousQueue** 没有容量的同步队列,通过CAS实现并发访问,支持FIFO和FILO。 - **Queue: LinkedTransferQueue** JDK 7新增,单向链表实现的无界阻塞队列,通过CAS实现并发访问,队列元素使用 FIFO(先进先出)方式。LinkedTransferQueue可以说是ConcurrentLinkedQueue、SynchronousQueue(公平模式)和LinkedBlockingQueue的超集, 它不仅仅综合了这几个类的功能,同时也提供了更高效的实现。 - **List: CopyOnWriteArrayList** ArrayList 的一个线程安全的变体,其中所有可变操作(add、set 等等)都是通过对底层数组进行一次新的复制来实现的。这一般需要很大的开销,但是当遍历操作的数量大大超过可变操作的数量时,这种方法可能比其他替代方法更 有效。在不能或不想进行同步遍历,但又需要从并发线程中排除冲突时,它也很有用。 - **Set: CopyOnWriteArraySet** 对其所有操作使用内部CopyOnWriteArrayList的Set。即将所有操作转发至CopyOnWriteArayList来进行操作,能够保证线程安全。在add时,会调用addIfAbsent,由于每次add时都要进行数组遍历,因此性能会略低于CopyOnWriteArrayList。 - **Set: ConcurrentSkipListSet** 一个基于ConcurrentSkipListMap 的可缩放并发 NavigableSet 实现。set 的元素可以根据它们的自然顺序进行排序,也可以根据创建 set 时所提供的 Comparator 进行排序,具体取决于使用的构造方法。 - **Map: ConcurrentHashMap** 是线程安全HashMap的。ConcurrentHashMap在JDK 7之前是通过Lock和segment(分段锁)实现,JDK 8 之后改为CAS+synchronized来保证并发安全。 - **Map: ConcurrentSkipListMap** 线程安全的有序的哈希表(相当于线程安全的TreeMap);映射可以根据键的自然顺序进行排序,也可以根据创建映射时所提供的 Comparator 进行排序,具体取决于使用的构造方法。 #### JUC原子类哪些核心的类? 其基本的特性就是在多线程环境下,当有多个线程同时执行这些类的实例包含的方法时,具有排他性,即当某个线程进入方法,执行其中的指令时,不会被其他线程打断,而别的线程就像自旋锁一样,一直等到该方法执行完成,才由JVM从等待队列中选择一个另一个线程进入,这只是一种逻辑上的理解。实际上是借助硬件的相关指令来实现的,不会阻塞线程(或者说只是在硬件级别上阻塞了)。 - 原子更新基本类型 - AtomicBoolean: 原子更新布尔类型。 - AtomicInteger: 原子更新整型。 - AtomicLong: 原子更新长整型。 - 原子更新数组 - AtomicIntegerArray: 原子更新整型数组里的元素。 - AtomicLongArray: 原子更新长整型数组里的元素。 - AtomicReferenceArray: 原子更新引用类型数组里的元素。 - 原子更新引用类型 - AtomicIntegerFieldUpdater: 原子更新整型的字段的更新器。 - AtomicLongFieldUpdater: 原子更新长整型字段的更新器。 - AtomicStampedFieldUpdater: 原子更新带有版本号的引用类型。 - AtomicReferenceFieldUpdater: 上面已经说过此处不在赘述 - 原子更新字段类 - AtomicReference: 原子更新引用类型。 - AtomicStampedReference: 原子更新引用类型, 内部使用Pair来存储元素值及其版本号。 - AtomicMarkableReferce: 原子更新带有标记位的引用类型。 #### JUC线程池哪些核心的类? ![img](https://b2files.173114.xyz/blogimg/2025/03/f83d5f9004e87e571d1443a8ed6cf931.png) - **接口: Executor** Executor接口提供一种将任务提交与每个任务将如何运行的机制(包括线程使用的细节、调度等)分离开来的方法。通常使用 Executor 而不是显式地创建线程。 - **ExecutorService** ExecutorService继承自Executor接口,ExecutorService提供了管理终止的方法,以及可为跟踪一个或多个异步任务执行状况而生成 Future 的方法。 可以关闭 ExecutorService,这将导致其停止接受新任务。关闭后,执行程序将最后终止,这时没有任务在执行,也没有任务在等待执行,并且无法提交新任务。 - **ScheduledExecutorService** ScheduledExecutorService继承自ExecutorService接口,可安排在给定的延迟后运行或定期执行的命令。 - **AbstractExecutorService** AbstractExecutorService继承自ExecutorService接口,其提供 ExecutorService 执行方法的默认实现。此类使用 newTaskFor 返回的 RunnableFuture 实现 submit、invokeAny 和 invokeAll 方法,默认情况下,RunnableFuture 是此包中提供的 FutureTask 类。 - **FutureTask** FutureTask 为 Future 提供了基础实现,如获取任务执行结果(get)和取消任务(cancel)等。如果任务尚未完成,获取任务执行结果时将会阻塞。一旦执行结束,任务就不能被重启或取消(除非使用runAndReset执行计算)。FutureTask 常用来封装 Callable 和 Runnable,也可以作为一个任务提交到线程池中执行。除了作为一个独立的类之外,此类也提供了一些功能性函数供我们创建自定义 task 类使用。FutureTask 的线程安全由CAS来保证。 - **核心: ThreadPoolExecutor** ThreadPoolExecutor实现了AbstractExecutorService接口,也是一个 ExecutorService,它使用可能的几个池线程之一执行每个提交的任务,通常使用 Executors 工厂方法配置。 线程池可以解决两个不同问题: 由于减少了每个任务调用的开销,它们通常可以在执行大量异步任务时提供增强的性能,并且还可以提供绑定和管理资源(包括执行任务集时使用的线程)的方法。每个 ThreadPoolExecutor 还维护着一些基本的统计数据,如完成的任务数。 - **核心: ScheduledThreadExecutor** ScheduledThreadPoolExecutor实现ScheduledExecutorService接口,可安排在给定的延迟后运行命令,或者定期执行命令。需要多个辅助线程时,或者要求 ThreadPoolExecutor 具有额外的灵活性或功能时,此类要优于 Timer。 - **核心: Fork/Join框架** ForkJoinPool 是JDK 7加入的一个线程池类。Fork/Join 技术是分治算法(Divide-and-Conquer)的并行实现,它是一项可以获得良好的并行性能的简单且高效的设计技术。目的是为了帮助我们更好地利用多处理器带来的好处,使用所有可用的运算能力来提升应用的性能。 - **工具类: Executors** Executors是一个工具类,用其可以创建ExecutorService、ScheduledExecutorService、ThreadFactory、Callable等对象。它的使用融入到了ThreadPoolExecutor, ScheduledThreadExecutor和ForkJoinPool中。 ### 3.4 JUC原子类 - [JUC原子类: CAS, Unsafe和原子类详解](https://pdai.tech/md/java/thread/java-thread-x-juc-AtomicInteger.html) #### 线程安全的实现方法有哪些? 线程安全的实现方法包含: - 互斥同步: synchronized 和 ReentrantLock - 非阻塞同步: CAS, AtomicXXXX - 无同步方案: 栈封闭,Thread Local,可重入代码 #### 什么是CAS? CAS的全称为Compare-And-Swap,直译就是对比交换。是一条CPU的原子指令,其作用是让CPU先进行比较两个值是否相等,然后原子地更新某个位置的值,经过调查发现,其实现方式是基于硬件平台的汇编指令,就是说CAS是靠硬件实现的,JVM只是封装了汇编调用,那些AtomicInteger类便是使用了这些封装后的接口。   简单解释:CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较下在旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换。 CAS操作是原子性的,所以多线程并发使用CAS更新数据时,可以不使用锁。JDK中大量使用了CAS来更新数据而防止加锁(synchronized 重量级锁)来保持原子更新。 相信sql大家都熟悉,类似sql中的条件更新一样:update set id=3 from table where id=2。因为单条sql执行具有原子性,如果有多个线程同时执行此sql语句,只有一条能更新成功。 #### CAS使用示例,结合AtomicInteger给出示例? 如果不使用CAS,在高并发下,多线程同时修改一个变量的值我们需要synchronized加锁(可能有人说可以用Lock加锁,Lock底层的AQS也是基于CAS进行获取锁的)。 ```java public class Test { private int i=0; public synchronized int add(){ return i++; } } ``` java中为我们提供了AtomicInteger 原子类(底层基于CAS进行更新数据的),不需要加锁就在多线程并发场景下实现数据的一致性。 ```java public class Test { private AtomicInteger i = new AtomicInteger(0); public int add(){ return i.addAndGet(1); } } ``` #### CAS会有哪些问题? CAS 方式为乐观锁,synchronized 为悲观锁。因此使用 CAS 解决并发问题通常情况下性能更优。 但使用 CAS 方式也会有几个问题: - ABA问题 因为CAS需要在操作值的时候,检查值有没有发生变化,比如没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时则会发现它的值没有发生变化,但是实际上却变化了。 ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A->B->A就会变成1A->2B->3A。 从Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。 - 循环时间长开销大 自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。pause指令有两个作用:第一,它可以延迟流水线执行命令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;第二,它可以避免在退出循环的时候因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空(CPU Pipeline Flush),从而提高CPU的执行效率。 - 只能保证一个共享变量的原子操作 当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。 还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i = 2,j = a,合并一下ij = 2a,然后用CAS来操作ij。 从Java 1.5开始,JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作。 #### AtomicInteger底层实现? - CAS+volatile - volatile保证线程的可见性,多线程并发时,一个线程修改数据,可以保证其它线程立马看到修改后的值CAS 保证数据更新的原子性。 #### 请阐述你对Unsafe类的理解? UnSafe类总体功能: ![img](https://b2files.173114.xyz/blogimg/2025/03/ecd3a0c9f142d6ed1d343b57ac66abe6.png) 如上图所示,Unsafe提供的API大致可分为内存操作、CAS、Class相关、对象操作、线程调度、系统信息获取、内存屏障、数组操作等几类,下面将对其相关方法和应用场景进行详细介绍。 #### 说说你对Java原子类的理解? 包含13个,4组分类,说说作用和使用场景。 - 原子更新基本类型 - AtomicBoolean: 原子更新布尔类型。 - AtomicInteger: 原子更新整型。 - AtomicLong: 原子更新长整型。 - 原子更新数组 - AtomicIntegerArray: 原子更新整型数组里的元素。 - AtomicLongArray: 原子更新长整型数组里的元素。 - AtomicReferenceArray: 原子更新引用类型数组里的元素。 - 原子更新引用类型 - AtomicIntegerFieldUpdater: 原子更新整型的字段的更新器。 - AtomicLongFieldUpdater: 原子更新长整型字段的更新器。 - AtomicStampedFieldUpdater: 原子更新带有版本号的引用类型。 - AtomicReferenceFieldUpdater: 上面已经说过此处不在赘述 - 原子更新字段类 - AtomicReference: 原子更新引用类型。 - AtomicStampedReference: 原子更新引用类型, 内部使用Pair来存储元素值及其版本号。 - AtomicMarkableReferce: 原子更新带有标记位的引用类型。 #### AtomicStampedReference是怎么解决ABA的? AtomicStampedReference主要维护包含一个对象引用以及一个可以自动更新的整数"stamp"的pair对象来解决ABA问题。 ### 3.5 JUC锁 - [JUC锁: LockSupport详解](https://pdai.tech/md/java/thread/java-thread-x-lock-LockSupport.html) - [JUC锁: 锁核心类AQS详解](https://pdai.tech/md/java/thread/java-thread-x-lock-AbstractQueuedSynchronizer.html) - [JUC锁: ReentrantReadWriteLock详解](https://pdai.tech/md/java/thread/java-thread-x-lock-ReentrantReadWriteLock.html) #### 为什么LockSupport也是核心基础类? AQS框架借助于两个类:Unsafe(提供CAS操作)和LockSupport(提供park/unpark操作) #### 通过wait/notify实现同步? ```java class MyThread extends Thread { public void run() { synchronized (this) { System.out.println("before notify"); notify(); System.out.println("after notify"); } } } public class WaitAndNotifyDemo { public static void main(String[] args) throws InterruptedException { MyThread myThread = new MyThread(); synchronized (myThread) { try { myThread.start(); // 主线程睡眠3s Thread.sleep(3000); System.out.println("before wait"); // 阻塞主线程 myThread.wait(); System.out.println("after wait"); } catch (InterruptedException e) { e.printStackTrace(); } } } } ``` 运行结果 ```html before wait before notify after notify after wait ``` 说明: 具体的流程图如下 ![img](https://b2files.173114.xyz/blogimg/2025/03/3680d462fd0aae454f47b56d5b117a70.png) 使用wait/notify实现同步时,必须先调用wait,后调用notify,如果先调用notify,再调用wait,将起不了作用。具体代码如下 ```java class MyThread extends Thread { public void run() { synchronized (this) { System.out.println("before notify"); notify(); System.out.println("after notify"); } } } public class WaitAndNotifyDemo { public static void main(String[] args) throws InterruptedException { MyThread myThread = new MyThread(); myThread.start(); // 主线程睡眠3s Thread.sleep(3000); synchronized (myThread) { try { System.out.println("before wait"); // 阻塞主线程 myThread.wait(); System.out.println("after wait"); } catch (InterruptedException e) { e.printStackTrace(); } } } } ``` 运行结果: ```html before notify after notify before wait ``` 说明: 由于先调用了notify,再调用的wait,此时主线程还是会一直阻塞。 #### 通过LockSupport的park/unpark实现同步? ```java import java.util.concurrent.locks.LockSupport; class MyThread extends Thread { private Object object; public MyThread(Object object) { this.object = object; } public void run() { System.out.println("before unpark"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } // 获取blocker System.out.println("Blocker info " + LockSupport.getBlocker((Thread) object)); // 释放许可 LockSupport.unpark((Thread) object); // 休眠500ms,保证先执行park中的setBlocker(t, null); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } // 再次获取blocker System.out.println("Blocker info " + LockSupport.getBlocker((Thread) object)); System.out.println("after unpark"); } } public class test { public static void main(String[] args) { MyThread myThread = new MyThread(Thread.currentThread()); myThread.start(); System.out.println("before park"); // 获取许可 LockSupport.park("ParkAndUnparkDemo"); System.out.println("after park"); } } ``` 运行结果: ```html before park before unpark Blocker info ParkAndUnparkDemo after park Blocker info null after unpark ``` 说明: 本程序先执行park,然后在执行unpark,进行同步,并且在unpark的前后都调用了getBlocker,可以看到两次的结果不一样,并且第二次调用的结果为null,这是因为在调用unpark之后,执行了Lock.park(Object blocker)函数中的setBlocker(t, null)函数,所以第二次调用getBlocker时为null。 上例是先调用park,然后调用unpark,现在修改程序,先调用unpark,然后调用park,看能不能正确同步。具体代码如下 ```java import java.util.concurrent.locks.LockSupport; class MyThread extends Thread { private Object object; public MyThread(Object object) { this.object = object; } public void run() { System.out.println("before unpark"); // 释放许可 LockSupport.unpark((Thread) object); System.out.println("after unpark"); } } public class ParkAndUnparkDemo { public static void main(String[] args) { MyThread myThread = new MyThread(Thread.currentThread()); myThread.start(); try { // 主线程睡眠3s Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("before park"); // 获取许可 LockSupport.park("ParkAndUnparkDemo"); System.out.println("after park"); } } ``` 运行结果: ```html before unpark after unpark before park after park ``` 说明: 可以看到,在先调用unpark,再调用park时,仍能够正确实现同步,不会造成由wait/notify调用顺序不当所引起的阻塞。因此park/unpark相比wait/notify更加的灵活。 #### Thread.sleep()、Object.wait()、Condition.await()、LockSupport.park()的区别? 重点 - **Thread.sleep()和Object.wait()的区别** 首先,我们先来看看Thread.sleep()和Object.wait()的区别,这是一个烂大街的题目了,大家应该都能说上来两点。 1. Thread.sleep()不会释放占有的锁,Object.wait()会释放占有的锁; 2. Thread.sleep()必须传入时间,Object.wait()可传可不传,不传表示一直阻塞下去; 3. Thread.sleep()到时间了会自动唤醒,然后继续执行; 4. Object.wait()不带时间的,需要另一个线程使用Object.notify()唤醒; 5. Object.wait()带时间的,假如没有被notify,到时间了会自动唤醒,这时又分好两种情况,一是立即获取到了锁,线程自然会继续执行;二是没有立即获取锁,线程进入同步队列等待获取锁; 其实,他们俩最大的区别就是Thread.sleep()不会释放锁资源,Object.wait()会释放锁资源。 - **Object.wait()和Condition.await()的区别** Object.wait()和Condition.await()的原理是基本一致的,不同的是Condition.await()底层是调用LockSupport.park()来实现阻塞当前线程的。 实际上,它在阻塞当前线程之前还干了两件事,一是把当前线程添加到条件队列中,二是“完全”释放锁,也就是让state状态变量变为0,然后才是调用LockSupport.park()阻塞当前线程。 - **Thread.sleep()和LockSupport.park()的区别** LockSupport.park()还有几个兄弟方法——parkNanos()、parkUtil()等,我们这里说的park()方法统称这一类方法。 1. 从功能上来说,Thread.sleep()和LockSupport.park()方法类似,都是阻塞当前线程的执行,且都不会释放当前线程占有的锁资源; 2. Thread.sleep()没法从外部唤醒,只能自己醒过来; 3. LockSupport.park()方法可以被另一个线程调用LockSupport.unpark()方法唤醒; 4. Thread.sleep()方法声明上抛出了InterruptedException中断异常,所以调用者需要捕获这个异常或者再抛出; 5. LockSupport.park()方法不需要捕获中断异常; 6. Thread.sleep()本身就是一个native方法; 7. LockSupport.park()底层是调用的Unsafe的native方法; - **Object.wait()和LockSupport.park()的区别** 二者都会阻塞当前线程的运行,他们有什么区别呢? 经过上面的分析相信你一定很清楚了,真的吗? 往下看! 1. Object.wait()方法需要在synchronized块中执行; 2. LockSupport.park()可以在任意地方执行; 3. Object.wait()方法声明抛出了中断异常,调用者需要捕获或者再抛出; 4. LockSupport.park()不需要捕获中断异常; 5. Object.wait()不带超时的,需要另一个线程执行notify()来唤醒,但不一定继续执行后续内容; 6. LockSupport.park()不带超时的,需要另一个线程执行unpark()来唤醒,一定会继续执行后续内容; park()/unpark()底层的原理是“二元信号量”,你可以把它相像成只有一个许可证的Semaphore,只不过这个信号量在重复执行unpark()的时候也不会再增加许可证,最多只有一个许可证。 #### 如果在wait()之前执行了notify()会怎样? 如果当前的线程不是此对象锁的所有者,却调用该对象的notify()或wait()方法时抛出IllegalMonitorStateException异常; 如果当前线程是此对象锁的所有者,wait()将一直阻塞,因为后续将没有其它notify()唤醒它。 #### 如果在park()之前执行了unpark()会怎样? 线程不会被阻塞,直接跳过park(),继续执行后续内容 #### 什么是AQS? 为什么它是核心? AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。 AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。 AbstractQueuedSynchronizer类底层的数据结构是使用**CLH(Craig,Landin,and Hagersten)队列**是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。其中Sync queue,即同步队列,是双向链表,包括head结点和tail结点,head结点主要用作后续的调度。而Condition queue不是必须的,其是一个单向链表,只有当使用Condition时,才会存在此单向链表。并且可能会有多个Condition queue。 ![image](https://b2files.173114.xyz/blogimg/2025/03/0c154b2c6cb1ee52f2c70c6dfbd33774.png) #### AQS的核心思想是什么? 底层数据结构: AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。 #### AQS有哪些核心的方法? ```java isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。 tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。 tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。 tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。 tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。 ``` #### AQS定义什么样的资源获取方式? AQS定义了两种资源获取方式: - **独占**(只有一个线程能访问执行,又根据是否按队列的顺序分为**公平锁**和**非公平锁**,如`ReentrantLock`) - **共享**(多个线程可同时访问执行,如`Semaphore`、`CountDownLatch`、 `CyclicBarrier` )。`ReentrantReadWriteLock`可以看成是组合式,允许多个线程同时对某一资源进行读。 #### AQS底层使用了什么样的设计模式? 模板, 共享锁和独占锁在一个接口类中。 - [JUC锁: ReentrantLock详解](https://pdai.tech/md/java/thread/java-thread-x-lock-ReentrantLock.html) #### 什么是可重入,什么是可重入锁? 它用来解决什么问题? **可重入**:(来源于维基百科)若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错”,则称其为可重入(reentrant或re-entrant)的。即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的。 **可重入锁**:又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。 #### ReentrantLock的核心是AQS,那么它怎么来实现的,继承吗? ReentrantLock总共有三个内部类,并且三个内部类是紧密相关的,下面先看三个类的关系。 ![image](https://b2files.173114.xyz/blogimg/2025/03/9bec6a79b735ba283e75942cd5e2df22.png) 说明: ReentrantLock类内部总共存在Sync、NonfairSync、FairSync三个类,NonfairSync与FairSync类继承自Sync类,Sync类继承自AbstractQueuedSynchronizer抽象类。下面逐个进行分析。 #### ReentrantLock是如何实现公平锁的? FairSync #### ReentrantLock是如何实现非公平锁的? UnFairSync #### ReentrantLock默认实现的是公平还是非公平锁? 非公平锁 #### 为什么了有了ReentrantLock还需要ReentrantReadWriteLock? 读锁和写锁分离:ReentrantReadWriteLock表示可重入读写锁,ReentrantReadWriteLock中包含了两种锁,读锁ReadLock和写锁WriteLock,可以通过这两种锁实现线程间的同步。 #### ReentrantReadWriteLock底层实现原理? ReentrantReadWriteLock有五个内部类,五个内部类之间也是相互关联的。内部类的关系如下图所示。 ![img](https://b2files.173114.xyz/blogimg/2025/03/4d07794406d59dbded22963fc2f2925f.png) 说明: 如上图所示,Sync继承自AQS、NonfairSync继承自Sync类、FairSync继承自Sync类;ReadLock实现了Lock接口、WriteLock也实现了Lock接口。 #### ReentrantReadWriteLock底层读写状态如何设计的? 高16位为读锁,低16位为写锁 #### 读锁和写锁的最大数量是多少? 2的16次方-1 #### 本地线程计数器ThreadLocalHoldCounter是用来做什么的? 本地线程计数器,与对象绑定(线程-》线程重入的次数) #### 写锁的获取与释放是怎么实现的? tryAcquire/tryRelease #### 读锁的获取与释放是怎么实现的? tryAcquireShared/tryReleaseShared #### 什么是锁的升降级? RentrantReadWriteLock为什么不支持锁升级? RentrantReadWriteLock不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程)。目的也是保证数据可见性,如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的。 ### 3.6 JUC集合类 - [JUC集合: ConcurrentHashMap详解](https://pdai.tech/md/java/thread/java-thread-x-juc-collection-ConcurrentHashMap.html) - [JUC集合: CopyOnWriteArrayList详解](https://pdai.tech/md/java/thread/java-thread-x-juc-collection-CopyOnWriteArrayList.html) - [JUC集合: ConcurrentLinkedQueue详解](https://pdai.tech/md/java/thread/java-thread-x-juc-collection-ConcurrentLinkedQueue.html) - [JUC集合: BlockingQueue详解](https://pdai.tech/md/java/thread/java-thread-x-juc-collection-BlockingQueue.html) #### 为什么HashTable慢? 它的并发度是什么? 那么ConcurrentHashMap并发度是什么? Hashtable之所以效率低下主要是因为其实现使用了synchronized关键字对put等操作进行加锁,而synchronized关键字加锁是对整个对象进行加锁,也就是说在进行put等修改Hash表的操作时,锁住了整个Hash表,从而使得其表现的效率低下。 #### ConcurrentHashMap在JDK1.7和JDK1.8中实现有什么差别? JDK1.8解決了JDK1.7中什么问题 - `HashTable` : 使用了synchronized关键字对put等操作进行加锁; - `ConcurrentHashMap JDK1.7`: 使用分段锁机制实现; - `ConcurrentHashMap JDK1.8`: 则使用数组+链表+红黑树数据结构和CAS原子操作实现; #### ConcurrentHashMap JDK1.7实现的原理是什么? 在JDK1.5~1.7版本,Java使用了分段锁机制实现ConcurrentHashMap. 简而言之,ConcurrentHashMap在对象中保存了一个Segment数组,即将整个Hash表划分为多个分段;而每个Segment元素,它通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全;这样,在执行put操作时首先根据hash算法定位到元素属于哪个Segment,然后对该Segment加锁即可。因此,ConcurrentHashMap在多线程并发编程中可是实现多线程put操作。 ![img](https://b2files.173114.xyz/blogimg/2025/03/65a53c3faf9cbefe04cbc7f173ebd2b9.png) `concurrencyLevel`: Segment 数(并行级别、并发数)。默认是 16,也就是说 ConcurrentHashMap 有 16 个 Segments,所以理论上,这个时候,最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩容的。 #### ConcurrentHashMap JDK1.7中Segment数(concurrencyLevel)默认值是多少? 为何一旦初始化就不可再扩容? 默认是 16 #### ConcurrentHashMap JDK1.7说说其put的机制? 整体流程还是比较简单的,由于有独占锁的保护,所以 segment 内部的操作并不复杂 1. 计算 key 的 hash 值 2. 根据 hash 值找到 Segment 数组中的位置 j; ensureSegment(j) 对 segment[j] 进行初始化(Segment 内部是由 **数组+链表** 组成的) 3. 插入新值到 槽 s 中 #### ConcurrentHashMap JDK1.7是如何扩容的? rehash(注:segment 数组不能扩容,扩容是 segment 数组某个位置内部的数组 HashEntry[] 进行扩容) #### ConcurrentHashMap JDK1.8实现的原理是什么? 在JDK1.7之前,ConcurrentHashMap是通过分段锁机制来实现的,所以其最大并发度受Segment的个数限制。因此,在JDK1.8中,ConcurrentHashMap的实现原理摒弃了这种设计,而是选择了与HashMap类似的数组+链表+红黑树的方式实现,而加锁则采用CAS和synchronized实现。 简而言之:数组+链表+红黑树,CAS #### ConcurrentHashMap JDK1.8是如何扩容的? tryPresize, 扩容也是做翻倍扩容的,扩容后数组容量为原来的 2 倍 #### ConcurrentHashMap JDK1.8链表转红黑树的时机是什么? 临界值为什么是8? size = 8, log(N) #### ConcurrentHashMap JDK1.8是如何进行数据迁移的? transfer, 将原来的 tab 数组的元素迁移到新的 nextTab 数组中 #### 先说说非并发集合中Fail-fast机制? 快速失败 #### CopyOnWriteArrayList的实现原理? COW基于拷贝 ```java // 将toCopyIn转化为Object[]类型数组,然后设置当前数组 setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class)); ``` 属性中有一个可重入锁,用来保证线程安全访问,还有一个Object类型的数组,用来存放具体的元素。当然,也使用到了反射机制和CAS来保证原子性的修改lock域。 ```java // 可重入锁 final transient ReentrantLock lock = new ReentrantLock(); // 对象数组,用于存放元素 private transient volatile Object[] array; // 反射机制 private static final sun.misc.Unsafe UNSAFE; // lock域的内存偏移量 private static final long lockOffset; ``` #### 弱一致性的迭代器原理是怎么样的? ``` COWIterator ``` COWIterator表示迭代器,其也有一个Object类型的数组作为CopyOnWriteArrayList数组的快照,这种快照风格的迭代器方法在创建迭代器时使用了对当时数组状态的引用。此数组在迭代器的生存期内不会更改,因此不可能发生冲突,并且迭代器保证不会抛出 ConcurrentModificationException。创建迭代器以后,迭代器就不会反映列表的添加、移除或者更改。在迭代器上进行的元素更改操作(remove、set 和 add)不受支持。这些方法将抛出 UnsupportedOperationException。 #### CopyOnWriteArrayList为什么并发安全且性能比Vector好? Vector对单独的add,remove等方法都是在方法上加了synchronized; 并且如果一个线程A调用size时,另一个线程B 执行了remove,然后size的值就不是最新的,然后线程A调用remove就会越界(这时就需要再加一个Synchronized)。这样就导致有了双重锁,效率大大降低,何必呢。于是vector废弃了,要用就用CopyOnWriteArrayList 吧。 #### CopyOnWriteArrayList有何缺陷,说说其应用场景? CopyOnWriteArrayList 有几个缺点: - 由于写操作的时候,需要拷贝数组,会消耗内存,如果原数组的内容比较多的情况下,可能导致young gc或者full gc - 不能用于实时读的场景,像拷贝数组、新增元素都需要时间,所以调用一个set操作后,读取到数据可能还是旧的,虽然CopyOnWriteArrayList 能做到最终一致性,但是还是没法满足实时性要求; **CopyOnWriteArrayList 合适读多写少的场景,不过这类慎用** 因为谁也没法保证CopyOnWriteArrayList 到底要放置多少数据,万一数据稍微有点多,每次add/set都要重新复制数组,这个代价实在太高昂了。在高性能的互联网应用中,这种操作分分钟引起故障。 #### 要想用线程安全的队列有哪些选择? Vector,`Collections.synchronizedList( List list)`, ConcurrentLinkedQueue等 #### ConcurrentLinkedQueue实现的数据结构? ConcurrentLinkedQueue的数据结构与LinkedBlockingQueue的数据结构相同,都是使用的链表结构。ConcurrentLinkedQueue的数据结构如下: ![img](https://b2files.173114.xyz/blogimg/2025/03/64094946b0b241e172b1c9d0cb156c28.png) 说明: ConcurrentLinkedQueue采用的链表结构,并且包含有一个头节点和一个尾结点。 #### ConcurrentLinkedQueue底层原理? ```java // 反射机制 private static final sun.misc.Unsafe UNSAFE; // head域的偏移量 private static final long headOffset; // tail域的偏移量 private static final long tailOffset; ``` 说明: 属性中包含了head域和tail域,表示链表的头节点和尾结点,同时,ConcurrentLinkedQueue也使用了反射机制和CAS机制来更新头节点和尾结点,保证原子性。 #### ConcurrentLinkedQueue的核心方法有哪些? offer(),poll(),peek(),isEmpty()等队列常用方法 #### 说说ConcurrentLinkedQueue的HOPS(延迟更新的策略)的设计? 通过上面对offer和poll方法的分析,我们发现tail和head是延迟更新的,两者更新触发时机为: - **tail更新触发时机**:当tail指向的节点的下一个节点不为null的时候,会执行定位队列真正的队尾节点的操作,找到队尾节点后完成插入之后才会通过casTail进行tail更新;当tail指向的节点的下一个节点为null的时候,只插入节点不更新tail。 - **head更新触发时机**:当head指向的节点的item域为null的时候,会执行定位队列真正的队头节点的操作,找到队头节点后完成删除之后才会通过updateHead进行head更新;当head指向的节点的item域不为null的时候,只删除节点不更新head。 并且在更新操作时,源码中会有注释为:`hop two nodes at a time`。所以这种延迟更新的策略就被叫做HOPS的大概原因是这个(猜的 😃),从上面更新时的状态图可以看出,head和tail的更新是“跳着的”即中间总是间隔了一个。那么这样设计的意图是什么呢? 如果让tail永远作为队列的队尾节点,实现的代码量会更少,而且逻辑更易懂。但是,这样做有一个缺点,如果大量的入队操作,每次都要执行CAS进行tail的更新,汇总起来对性能也会是大大的损耗。如果能减少CAS更新的操作,无疑可以大大提升入队的操作效率,所以doug lea大师每间隔1次(tail和队尾节点的距离为1)进行才利用CAS更新tail。对head的更新也是同样的道理,虽然,这样设计会多出在循环中定位队尾节点,但总体来说读的操作效率要远远高于写的性能,因此,多出来的在循环中定位尾节点的操作的性能损耗相对而言是很小的。 #### ConcurrentLinkedQueue适合什么样的使用场景? ConcurrentLinkedQueue通过无锁来做到了更高的并发量,是个高性能的队列,但是使用场景相对不如阻塞队列常见,毕竟取数据也要不停的去循环,不如阻塞的逻辑好设计,但是在并发量特别大的情况下,是个不错的选择,性能上好很多,而且这个队列的设计也是特别费力,尤其的使用的改良算法和对哨兵的处理。整体的思路都是比较严谨的,这个也是使用了无锁造成的,我们自己使用无锁的条件的话,这个队列是个不错的参考。 #### 什么是BlockingDeque? 适合用在什么样的场景? BlockingQueue 通常用于一个线程生产对象,而另外一个线程消费这些对象的场景。下图是对这个原理的阐述: ![img](https://b2files.173114.xyz/blogimg/2025/03/350e12215c505cb9227f01f8c0e589b1.png) 一个线程往里边放,另外一个线程从里边取的一个 BlockingQueue。 一个线程将会持续生产新对象并将其插入到队列之中,直到队列达到它所能容纳的临界点。也就是说,它是有限的。如果该阻塞队列到达了其临界点,负责生产的线程将会在往里边插入新对象时发生阻塞。它会一直处于阻塞之中,直到负责消费的线程从队列中拿走一个对象。 负责消费的线程将会一直从该阻塞队列中拿出对象。如果消费线程尝试去从一个空的队列中提取对象的话,这个消费线程将会处于阻塞之中,直到一个生产线程把一个对象丢进队列。 #### BlockingQueue大家族有哪些? ArrayBlockingQueue, DelayQueue, LinkedBlockingQueue, SynchronousQueue... #### BlockingQueue常用的方法? BlockingQueue 具有 4 组不同的方法用于插入、移除以及对队列中的元素进行检查。如果请求的操作不能得到立即执行的话,每个方法的表现也不同。这些方法如下: | | 抛异常 | 特定值 | 阻塞 | 超时 | | ---- | --------- | -------- | ------ | --------------------------- | | 插入 | add(o) | offer(o) | put(o) | offer(o, timeout, timeunit) | | 移除 | remove() | poll() | take() | poll(timeout, timeunit) | | 检查 | element() | peek() | | | 四组不同的行为方式解释: - 抛异常: 如果试图的操作无法立即执行,抛一个异常。 - 特定值: 如果试图的操作无法立即执行,返回一个特定的值(常常是 true / false)。 - 阻塞: 如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行。 - 超时: 如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行,但等待时间不会超过给定值。返回一个特定值以告知该操作是否成功(典型的是 true / false)。 #### BlockingQueue 实现例子? 这里是一个 Java 中使用 BlockingQueue 的示例。本示例使用的是 BlockingQueue 接口的 ArrayBlockingQueue 实现。 首先,BlockingQueueExample 类分别在两个独立的线程中启动了一个 Producer 和 一个 Consumer。Producer 向一个共享的 BlockingQueue 中注入字符串,而 Consumer 则会从中把它们拿出来。 ```java public class BlockingQueueExample { public static void main(String[] args) throws Exception { BlockingQueue queue = new ArrayBlockingQueue(1024); Producer producer = new Producer(queue); Consumer consumer = new Consumer(queue); new Thread(producer).start(); new Thread(consumer).start(); Thread.sleep(4000); } } ``` 以下是 Producer 类。注意它在每次 put() 调用时是如何休眠一秒钟的。这将导致 Consumer 在等待队列中对象的时候发生阻塞。 ```java public class Producer implements Runnable{ protected BlockingQueue queue = null; public Producer(BlockingQueue queue) { this.queue = queue; } public void run() { try { queue.put("1"); Thread.sleep(1000); queue.put("2"); Thread.sleep(1000); queue.put("3"); } catch (InterruptedException e) { e.printStackTrace(); } } } ``` 以下是 Consumer 类。它只是把对象从队列中抽取出来,然后将它们打印到 System.out。 ```java public class Consumer implements Runnable{ protected BlockingQueue queue = null; public Consumer(BlockingQueue queue) { this.queue = queue; } public void run() { try { System.out.println(queue.take()); System.out.println(queue.take()); System.out.println(queue.take()); } catch (InterruptedException e) { e.printStackTrace(); } } } ``` #### 什么是BlockingDeque? 适合用在什么样的场景? java.util.concurrent 包里的 BlockingDeque 接口表示一个线程安放入和提取实例的双端队列。 BlockingDeque 类是一个双端队列,在不能够插入元素时,它将阻塞住试图插入元素的线程;在不能够抽取元素时,它将阻塞住试图抽取的线程。 deque(双端队列) 是 "Double Ended Queue" 的缩写。因此,双端队列是一个你可以从任意一端插入或者抽取元素的队列。 在线程既是一个队列的生产者又是这个队列的消费者的时候可以使用到 BlockingDeque。如果生产者线程需要在队列的两端都可以插入数据,消费者线程需要在队列的两端都可以移除数据,这个时候也可以使用 BlockingDeque。BlockingDeque 图解: ![img](https://b2files.173114.xyz/blogimg/2025/03/66e18f20839cdfc7c5005195838cc277.png) #### BlockingDeque 与BlockingQueue有何关系,请对比下它们的方法? BlockingDeque 接口继承自 BlockingQueue 接口。这就意味着你可以像使用一个 BlockingQueue 那样使用 BlockingDeque。如果你这么干的话,各种插入方法将会把新元素添加到双端队列的尾端,而移除方法将会把双端队列的首端的元素移除。正如 BlockingQueue 接口的插入和移除方法一样。 以下是 BlockingDeque 对 BlockingQueue 接口的方法的具体内部实现: | BlockingQueue | BlockingDeque | | ------------- | --------------- | | add() | addLast() | | offer() x 2 | offerLast() x 2 | | put() | putLast() | | remove() | removeFirst() | | poll() x 2 | pollFirst() | | take() | takeFirst() | | element() | getFirst() | | peek() | peekFirst() | #### BlockingDeque大家族有哪些? LinkedBlockingDeque 是一个双端队列,在它为空的时候,一个试图从中抽取数据的线程将会阻塞,无论该线程是试图从哪一端抽取数据。 #### BlockingDeque 实现例子? 既然 BlockingDeque 是一个接口,那么你想要使用它的话就得使用它的众多的实现类的其中一个。java.util.concurrent 包提供了以下 BlockingDeque 接口的实现类: LinkedBlockingDeque。 以下是如何使用 BlockingDeque 方法的一个简短代码示例: ```java BlockingDeque deque = new LinkedBlockingDeque(); deque.addFirst("1"); deque.addLast("2"); String two = deque.takeLast(); String one = deque.takeFirst(); ``` ### 3.7 JUC线程池 - [JUC线程池: FutureTask详解](https://pdai.tech/md/java/thread/java-thread-x-juc-executor-FutureTask.html) - [JUC线程池: ThreadPoolExecutor详解](https://pdai.tech/md/java/thread/java-thread-x-juc-executor-ThreadPoolExecutor.html) - [JUC线程池: ScheduledThreadPool详解](https://pdai.tech/md/java/thread/java-thread-x-juc-executor-ScheduledThreadPoolExecutor.html) - [JUC线程池: Fork/Join框架详解](https://pdai.tech/md/java/thread/java-thread-x-juc-executor-ForkJoinPool.html) #### FutureTask用来解决什么问题的? 为什么会出现? FutureTask 为 Future 提供了基础实现,如获取任务执行结果(get)和取消任务(cancel)等。如果任务尚未完成,获取任务执行结果时将会阻塞。一旦执行结束,任务就不能被重启或取消(除非使用runAndReset执行计算)。FutureTask 常用来封装 Callable 和 Runnable,也可以作为一个任务提交到线程池中执行。除了作为一个独立的类之外,此类也提供了一些功能性函数供我们创建自定义 task 类使用。FutureTask 的线程安全由CAS来保证。 #### FutureTask类结构关系怎么样的? ![img](https://b2files.173114.xyz/blogimg/2025/03/40e17b140b8f8efc52df1a0981bb9c38.png) 可以看到,FutureTask实现了RunnableFuture接口,则RunnableFuture接口继承了Runnable接口和Future接口,所以FutureTask既能当做一个Runnable直接被Thread执行,也能作为Future用来得到Callable的计算结果。 #### FutureTask的线程安全是由什么保证的? FutureTask 的线程安全由CAS来保证。 #### FutureTask通常会怎么用? 举例说明。 ```java import java.util.concurrent.*; public class CallDemo { public static void main(String[] args) throws ExecutionException, InterruptedException { /** * 第一种方式:Future + ExecutorService * Task task = new Task(); * ExecutorService service = Executors.newCachedThreadPool(); * Future future = service.submit(task1); * service.shutdown(); */ /** * 第二种方式: FutureTask + ExecutorService * ExecutorService executor = Executors.newCachedThreadPool(); * Task task = new Task(); * FutureTask futureTask = new FutureTask(task); * executor.submit(futureTask); * executor.shutdown(); */ /** * 第三种方式:FutureTask + Thread */ // 2. 新建FutureTask,需要一个实现了Callable接口的类的实例作为构造函数参数 FutureTask futureTask = new FutureTask(new Task()); // 3. 新建Thread对象并启动 Thread thread = new Thread(futureTask); thread.setName("Task thread"); thread.start(); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("Thread [" + Thread.currentThread().getName() + "] is running"); // 4. 调用isDone()判断任务是否结束 if(!futureTask.isDone()) { System.out.println("Task is not done"); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } } int result = 0; try { // 5. 调用get()方法获取任务结果,如果任务没有执行完成则阻塞等待 result = futureTask.get(); } catch (Exception e) { e.printStackTrace(); } System.out.println("result is " + result); } // 1. 继承Callable接口,实现call()方法,泛型参数为要返回的类型 static class Task implements Callable { @Override public Integer call() throws Exception { System.out.println("Thread [" + Thread.currentThread().getName() + "] is running"); int result = 0; for(int i = 0; i < 100;++i) { result += i; } Thread.sleep(3000); return result; } } } ``` #### 为什么要有线程池? 线程池能够对线程进行统一分配,调优和监控: - 降低资源消耗(线程无限制地创建,然后使用完毕后销毁) - 提高响应速度(无须创建线程) - 提高线程的可管理性 #### Java是实现和管理线程池有哪些方式? 请简单举例如何使用。 从JDK 5开始,把工作单元与执行机制分离开来,工作单元包括Runnable和Callable,而执行机制由Executor框架提供。 - WorkerThread ```java public class WorkerThread implements Runnable { private String command; public WorkerThread(String s){ this.command=s; } @Override public void run() { System.out.println(Thread.currentThread().getName()+" Start. Command = "+command); processCommand(); System.out.println(Thread.currentThread().getName()+" End."); } private void processCommand() { try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } } @Override public String toString(){ return this.command; } } ``` - SimpleThreadPool ```java import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class SimpleThreadPool { public static void main(String[] args) { ExecutorService executor = Executors.newFixedThreadPool(5); for (int i = 0; i < 10; i++) { Runnable worker = new WorkerThread("" + i); executor.execute(worker); } executor.shutdown(); // This will make the executor accept no new threads and finish all existing threads in the queue while (!executor.isTerminated()) { // Wait until all threads are finish,and also you can use "executor.awaitTermination();" to wait } System.out.println("Finished all threads"); } } ``` 程序中我们创建了固定大小为五个工作线程的线程池。然后分配给线程池十个工作,因为线程池大小为五,它将启动五个工作线程先处理五个工作,其他的工作则处于等待状态,一旦有工作完成,空闲下来工作线程就会捡取等待队列里的其他工作进行执行。 这里是以上程序的输出。 ```html pool-1-thread-2 Start. Command = 1 pool-1-thread-4 Start. Command = 3 pool-1-thread-1 Start. Command = 0 pool-1-thread-3 Start. Command = 2 pool-1-thread-5 Start. Command = 4 pool-1-thread-4 End. pool-1-thread-5 End. pool-1-thread-1 End. pool-1-thread-3 End. pool-1-thread-3 Start. Command = 8 pool-1-thread-2 End. pool-1-thread-2 Start. Command = 9 pool-1-thread-1 Start. Command = 7 pool-1-thread-5 Start. Command = 6 pool-1-thread-4 Start. Command = 5 pool-1-thread-2 End. pool-1-thread-4 End. pool-1-thread-3 End. pool-1-thread-5 End. pool-1-thread-1 End. Finished all threads ``` 输出表明线程池中至始至终只有五个名为 "pool-1-thread-1" 到 "pool-1-thread-5" 的五个线程,这五个线程不随着工作的完成而消亡,会一直存在,并负责执行分配给线程池的任务,直到线程池消亡。 Executors 类提供了使用了 ThreadPoolExecutor 的简单的 ExecutorService 实现,但是 ThreadPoolExecutor 提供的功能远不止于此。我们可以在创建 ThreadPoolExecutor 实例时指定活动线程的数量,我们也可以限制线程池的大小并且创建我们自己的 RejectedExecutionHandler 实现来处理不能适应工作队列的工作。 这里是我们自定义的 RejectedExecutionHandler 接口的实现。 - RejectedExecutionHandlerImpl.java ```java import java.util.concurrent.RejectedExecutionHandler; import java.util.concurrent.ThreadPoolExecutor; public class RejectedExecutionHandlerImpl implements RejectedExecutionHandler { @Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { System.out.println(r.toString() + " is rejected"); } } ``` ThreadPoolExecutor 提供了一些方法,我们可以使用这些方法来查询 executor 的当前状态,线程池大小,活动线程数量以及任务数量。因此我是用来一个监控线程在特定的时间间隔内打印 executor 信息。 - MyMonitorThread.java ```java import java.util.concurrent.ThreadPoolExecutor; public class MyMonitorThread implements Runnable { private ThreadPoolExecutor executor; private int seconds; private boolean run=true; public MyMonitorThread(ThreadPoolExecutor executor, int delay) { this.executor = executor; this.seconds=delay; } public void shutdown(){ this.run=false; } @Override public void run() { while(run){ System.out.println( String.format("[monitor] [%d/%d] Active: %d, Completed: %d, Task: %d, isShutdown: %s, isTerminated: %s", this.executor.getPoolSize(), this.executor.getCorePoolSize(), this.executor.getActiveCount(), this.executor.getCompletedTaskCount(), this.executor.getTaskCount(), this.executor.isShutdown(), this.executor.isTerminated())); try { Thread.sleep(seconds*1000); } catch (InterruptedException e) { e.printStackTrace(); } } } } ``` 这里是使用 ThreadPoolExecutor 的线程池实现例子。 - WorkerPool.java ```java import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class WorkerPool { public static void main(String args[]) throws InterruptedException{ //RejectedExecutionHandler implementation RejectedExecutionHandlerImpl rejectionHandler = new RejectedExecutionHandlerImpl(); //Get the ThreadFactory implementation to use ThreadFactory threadFactory = Executors.defaultThreadFactory(); //creating the ThreadPoolExecutor ThreadPoolExecutor executorPool = new ThreadPoolExecutor(2, 4, 10, TimeUnit.SECONDS, new ArrayBlockingQueue(2), threadFactory, rejectionHandler); //start the monitoring thread MyMonitorThread monitor = new MyMonitorThread(executorPool, 3); Thread monitorThread = new Thread(monitor); monitorThread.start(); //submit work to the thread pool for(int i=0; i<10; i++){ executorPool.execute(new WorkerThread("cmd"+i)); } Thread.sleep(30000); //shut down the pool executorPool.shutdown(); //shut down the monitor thread Thread.sleep(5000); monitor.shutdown(); } } ``` 注意在初始化 ThreadPoolExecutor 时,我们保持初始池大小为 2,最大池大小为 4 而工作队列大小为 2。因此如果已经有四个正在执行的任务而此时分配来更多任务的话,工作队列将仅仅保留他们(新任务)中的两个,其他的将会被 RejectedExecutionHandlerImpl 处理。 上面程序的输出可以证实以上观点。 ```bash pool-1-thread-1 Start. Command = cmd0 pool-1-thread-4 Start. Command = cmd5 cmd6 is rejected pool-1-thread-3 Start. Command = cmd4 pool-1-thread-2 Start. Command = cmd1 cmd7 is rejected cmd8 is rejected cmd9 is rejected [monitor] [0/2] Active: 4, Completed: 0, Task: 6, isShutdown: false, isTerminated: false [monitor] [4/2] Active: 4, Completed: 0, Task: 6, isShutdown: false, isTerminated: false pool-1-thread-4 End. pool-1-thread-1 End. pool-1-thread-2 End. pool-1-thread-3 End. pool-1-thread-1 Start. Command = cmd3 pool-1-thread-4 Start. Command = cmd2 [monitor] [4/2] Active: 2, Completed: 4, Task: 6, isShutdown: false, isTerminated: false [monitor] [4/2] Active: 2, Completed: 4, Task: 6, isShutdown: false, isTerminated: false pool-1-thread-1 End. pool-1-thread-4 End. [monitor] [4/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false [monitor] [2/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false [monitor] [2/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false [monitor] [2/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false [monitor] [2/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false [monitor] [2/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false [monitor] [0/2] Active: 0, Completed: 6, Task: 6, isShutdown: true, isTerminated: true [monitor] [0/2] Active: 0, Completed: 6, Task: 6, isShutdown: true, isTerminated: true ``` 注意 executor 的活动任务、完成任务以及所有完成任务,这些数量上的变化。我们可以调用 shutdown() 方法来结束所有提交的任务并终止线程池。 #### ThreadPoolExecutor的原理? 其实java线程池的实现原理很简单,说白了就是一个线程集合workerSet和一个阻塞队列workQueue。当用户向线程池提交一个任务(也就是线程)时,线程池会先将任务放入workQueue中。workerSet中的线程会不断的从workQueue中获取线程然后执行。当workQueue中没有任务的时候,worker就会阻塞,直到队列中有任务了就取出来继续执行。 ![img](https://b2files.173114.xyz/blogimg/2025/03/0599c81084cc070ca1c7ce16a7ba3178.png) 当一个任务提交至线程池之后: 1. 线程池首先当前运行的线程数量是否少于corePoolSize。如果是,则创建一个新的工作线程来执行任务。如果都在执行任务,则进入2. 2. 判断BlockingQueue是否已经满了,倘若还没有满,则将线程放入BlockingQueue。否则进入3. 3. 如果创建一个新的工作线程将使当前运行的线程数量超过maximumPoolSize,则交给RejectedExecutionHandler来处理任务。 当ThreadPoolExecutor创建新线程时,通过CAS来更新线程池的状态ctl. #### ThreadPoolExecutor有哪些核心的配置参数? 请简要说明 ```java public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, RejectedExecutionHandler handler) ``` - `corePoolSize` 线程池中的核心线程数,当提交一个任务时,线程池创建一个新线程执行任务,直到当前线程数等于corePoolSize, 即使有其他空闲线程能够执行新来的任务, 也会继续创建线程;如果当前线程数为corePoolSize,继续提交的任务被保存到阻塞队列中,等待被执行;如果执行了线程池的prestartAllCoreThreads()方法,线程池会提前创建并启动所有核心线程。 - `workQueue` 用来保存等待被执行的任务的阻塞队列. 在JDK中提供了如下阻塞队列: 具体可以参考[JUC 集合: BlockQueue详解](https://pdai.tech/md/java/thread/java-thread-x-juc-collection-BlockingQueue.html) - `ArrayBlockingQueue`: 基于数组结构的有界阻塞队列,按FIFO排序任务; - `LinkedBlockingQueue`: 基于链表结构的阻塞队列,按FIFO排序任务,吞吐量通常要高于ArrayBlockingQueue; - `SynchronousQueue`: 一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQueue; - `PriorityBlockingQueue`: 具有优先级的无界阻塞队列; `LinkedBlockingQueue`比`ArrayBlockingQueue`在插入删除节点性能方面更优,但是二者在`put()`, `take()`任务的时均需要加锁,`SynchronousQueue`使用无锁算法,根据节点的状态判断执行,而不需要用到锁,其核心是`Transfer.transfer()`. - `maximumPoolSize `线程池中允许的最大线程数。如果当前阻塞队列满了,且继续提交任务,则创建新的线程执行任务,前提是当前线程数小于maximumPoolSize;当阻塞队列是无界队列, 则maximumPoolSize则不起作用, 因为无法提交至核心线程池的线程会一直持续地放入workQueue. - `keepAliveTime `线程空闲时的存活时间,即当线程没有任务执行时,该线程继续存活的时间;默认情况下,该参数只在线程数大于corePoolSize时才有用, 超过这个时间的空闲线程将被终止; - `unit `keepAliveTime的单位 - `threadFactory `创建线程的工厂,通过自定义的线程工厂可以给每个新建的线程设置一个具有识别度的线程名。默认为DefaultThreadFactory - `handler `线程池的饱和策略,当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4种策略: - `AbortPolicy`: 直接抛出异常,默认策略; - `CallerRunsPolicy`: 用调用者所在的线程来执行任务; - `DiscardOldestPolicy`: 丢弃阻塞队列中靠最前的任务,并执行当前任务; - `DiscardPolicy`: 直接丢弃任务; 当然也可以根据应用场景实现RejectedExecutionHandler接口,自定义饱和策略,如记录日志或持久化存储不能处理的任务。 #### ThreadPoolExecutor可以创建哪是哪三种线程池呢? - newFixedThreadPool ```java public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue()); } ``` 线程池的线程数量达corePoolSize后,即使线程池没有可执行任务时,也不会释放线程。 FixedThreadPool的工作队列为无界队列LinkedBlockingQueue(队列容量为Integer.MAX_VALUE), 这会导致以下问题: - 线程池里的线程数量不超过corePoolSize,这导致了maximumPoolSize和keepAliveTime将会是个无用参数 - 由于使用了无界队列, 所以FixedThreadPool永远不会拒绝, 即饱和策略失效 - newSingleThreadExecutor ```java public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue())); } ``` 初始化的线程池中只有一个线程,如果该线程异常结束,会重新创建一个新的线程继续执行任务,唯一的线程可以保证所提交任务的顺序执行. 由于使用了无界队列, 所以SingleThreadPool永远不会拒绝, 即饱和策略失效 - newCachedThreadPool ```java public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue()); } ``` 线程池的线程数可达到Integer.MAX_VALUE,即2147483647,内部使用SynchronousQueue作为阻塞队列; 和newFixedThreadPool创建的线程池不同,newCachedThreadPool在没有任务执行时,当线程的空闲时间超过keepAliveTime,会自动释放线程资源,当提交新任务时,如果没有空闲线程,则创建新线程执行任务,会导致一定的系统开销; 执行过程与前两种稍微不同: - 主线程调用SynchronousQueue的offer()方法放入task, 倘若此时线程池中有空闲的线程尝试读取 SynchronousQueue的task, 即调用了SynchronousQueue的poll(), 那么主线程将该task交给空闲线程. 否则执行(2) - 当线程池为空或者没有空闲的线程, 则创建新的线程执行任务. - 执行完任务的线程倘若在60s内仍空闲, 则会被终止. 因此长时间空闲的CachedThreadPool不会持有任何线程资源. #### 当队列满了并且worker的数量达到maxSize的时候,会怎么样? 当队列满了并且worker的数量达到maxSize的时候,执行具体的拒绝策略 ```java private volatile RejectedExecutionHandler handler; ``` #### 说说ThreadPoolExecutor有哪些RejectedExecutionHandler策略? 默认是什么策略? - AbortPolicy, 默认 该策略是线程池的默认策略。使用该策略时,如果线程池队列满了丢掉这个任务并且抛出RejectedExecutionException异常。 源码如下: ```java public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { //不做任何处理,直接抛出异常 throw new RejectedExecutionException("xxx"); } ``` - DiscardPolicy 这个策略和AbortPolicy的slient版本,如果线程池队列满了,会直接丢掉这个任务并且不会有任何异常。 源码如下: ```java public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { //就是一个空的方法 } ``` - DiscardOldestPolicy 这个策略从字面上也很好理解,丢弃最老的。也就是说如果队列满了,会将最早进入队列的任务删掉腾出空间,再尝试加入队列。 因为队列是队尾进,队头出,所以队头元素是最老的,因此每次都是移除对头元素后再尝试入队。 源码如下: ```java public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { if (!e.isShutdown()) { //移除队头元素 e.getQueue().poll(); //再尝试入队 e.execute(r); } } ``` - CallerRunsPolicy 使用此策略,如果添加到线程池失败,那么主线程会自己去执行该任务,不会等待线程池中的线程去执行。就像是个急脾气的人,我等不到别人来做这件事就干脆自己干。 源码如下: ```java public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { if (!e.isShutdown()) { //直接执行run方法 r.run(); } } ``` #### 简要说下线程池的任务执行机制? execute –> addWorker –>runworker (getTask) 1. 线程池的工作线程通过Woker类实现,在ReentrantLock锁的保证下,把Woker实例插入到HashSet后,并启动Woker中的线程。 2. 从Woker类的构造方法实现可以发现: 线程工厂在创建线程thread时,将Woker实例本身this作为参数传入,当执行start方法启动线程thread时,本质是执行了Worker的runWorker方法。 3. firstTask执行完成之后,通过getTask方法从阻塞队列中获取等待的任务,如果队列中没有任务,getTask方法会被阻塞并挂起,不会占用cpu资源; #### 线程池中任务是如何提交的? ![img](https://b2files.173114.xyz/blogimg/2025/03/77d1562b494618c5b23b8fe7af462e3b.png) 1. submit任务,等待线程池execute 2. 执行FutureTask类的get方法时,会把主线程封装成WaitNode节点并保存在waiters链表中, 并阻塞等待运行结果; 3. FutureTask任务执行完成后,通过UNSAFE设置waiters相应的waitNode为null,并通过LockSupport类unpark方法唤醒主线程; ```java public class Test{ public static void main(String[] args) { ExecutorService es = Executors.newCachedThreadPool(); Future future = es.submit(new Callable() { @Override public String call() throws Exception { try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } return "future result"; } }); try { String result = future.get(); System.out.println(result); } catch (Exception e) { e.printStackTrace(); } } } ``` 在实际业务场景中,Future和Callable基本是成对出现的,Callable负责产生结果,Future负责获取结果。 1. Callable接口类似于Runnable,只是Runnable没有返回值。 2. Callable任务除了返回正常结果之外,如果发生异常,该异常也会被返回,即Future可以拿到异步执行任务各种结果; 3. Future.get方法会导致主线程阻塞,直到Callable任务执行完成; #### 线程池中任务是如何关闭的? - shutdown 将线程池里的线程状态设置成SHUTDOWN状态, 然后中断所有没有正在执行任务的线程. - shutdownNow 将线程池里的线程状态设置成STOP状态, 然后停止所有正在执行或暂停任务的线程. 只要调用这两个关闭方法中的任意一个, isShutDown() 返回true. 当所有任务都成功关闭了, isTerminated()返回true. #### 在配置线程池的时候需要考虑哪些配置因素? 从任务的优先级,任务的执行时间长短,任务的性质(CPU密集/ IO密集),任务的依赖关系这四个角度来分析。并且近可能地使用有界的工作队列。 性质不同的任务可用使用不同规模的线程池分开处理: - CPU密集型: 尽可能少的线程,Ncpu+1 - IO密集型: 尽可能多的线程, Ncpu*2,比如数据库连接池 - 混合型: CPU密集型的任务与IO密集型任务的执行时间差别较小,拆分为两个线程池;否则没有必要拆分。 #### 如何监控线程池的状态? 可以使用ThreadPoolExecutor以下方法: - `getTaskCount()` Returns the approximate total number of tasks that have ever been scheduled for execution. - `getCompletedTaskCount()` Returns the approximate total number of tasks that have completed execution. 返回结果少于getTaskCount()。 - `getLargestPoolSize()` Returns the largest number of threads that have ever simultaneously been in the pool. 返回结果小于等于maximumPoolSize - `getPoolSize()` Returns the current number of threads in the pool. - `getActiveCount()` Returns the approximate number of threads that are actively executing tasks. #### 为什么很多公司不允许使用Executors去创建线程池? 那么推荐怎么使用呢? 线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。 说明:Executors各个方法的弊端: - newFixedThreadPool和newSingleThreadExecutor:   主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至OOM。 - newCachedThreadPool和newScheduledThreadPool:   主要问题是线程数最大数是Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至OOM。 - 推荐方式 1 首先引入:commons-lang3包 ```java ScheduledExecutorService executorService = new ScheduledThreadPoolExecutor(1, new BasicThreadFactory.Builder().namingPattern("example-schedule-pool-%d").daemon(true).build()); ``` - 推荐方式 2 首先引入:com.google.guava包 ```java ThreadFactory namedThreadFactory = new ThreadFactoryBuilder().setNameFormat("demo-pool-%d").build(); //Common Thread Pool ExecutorService pool = new ThreadPoolExecutor(5, 200, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue(1024), namedThreadFactory, new ThreadPoolExecutor.AbortPolicy()); // excute pool.execute(()-> System.out.println(Thread.currentThread().getName())); //gracefully shutdown pool.shutdown(); ``` - 推荐方式 3 spring配置线程池方式:自定义线程工厂bean需要实现ThreadFactory,可参考该接口的其它默认实现类,使用方式直接注入bean调用execute(Runnable task)方法即可 ```xml //in code userThreadPool.execute(thread); ``` #### ScheduledThreadPoolExecutor要解决什么样的问题? 在很多业务场景中,我们可能需要周期性的运行某项任务来获取结果,比如周期数据统计,定时发送数据等。在并发包出现之前,Java 早在1.3就提供了 Timer 类(只需要了解,目前已渐渐被 ScheduledThreadPoolExecutor 代替)来适应这些业务场景。随着业务量的不断增大,我们可能需要多个工作线程运行任务来尽可能的增加产品性能,或者是需要更高的灵活性来控制和监控这些周期业务。这些都是 ScheduledThreadPoolExecutor 诞生的必然性。 #### ScheduledThreadPoolExecutor相比ThreadPoolExecutor有哪些特性? ScheduledThreadPoolExecutor继承自 ThreadPoolExecutor,为任务提供延迟或周期执行,属于线程池的一种。和 ThreadPoolExecutor 相比,它还具有以下几种特性: - 使用专门的任务类型—ScheduledFutureTask 来执行周期任务,也可以接收不需要时间调度的任务(这些任务通过 ExecutorService 来执行)。 - 使用专门的存储队列—DelayedWorkQueue 来存储任务,DelayedWorkQueue 是无界延迟队列DelayQueue 的一种。相比ThreadPoolExecutor也简化了执行机制(delayedExecute方法,后面单独分析)。 - 支持可选的run-after-shutdown参数,在池被关闭(shutdown)之后支持可选的逻辑来决定是否继续运行周期或延迟任务。并且当任务(重新)提交操作与 shutdown 操作重叠时,复查逻辑也不相同。 #### ScheduledThreadPoolExecutor有什么样的数据结构,核心内部类和抽象类? ![img](https://b2files.173114.xyz/blogimg/2025/03/9972b33388146673cd186abe82e730f7.png) ScheduledThreadPoolExecutor继承自 `ThreadPoolExecutor`: - 详情请参考: [JUC线程池: ThreadPoolExecutor详解](https://pdai.tech/md/java/thread/java-thread-x-juc-executor-ThreadPoolExecutor.html) ScheduledThreadPoolExecutor 内部构造了两个内部类 `ScheduledFutureTask` 和 `DelayedWorkQueue`: - `ScheduledFutureTask`: 继承了FutureTask,说明是一个异步运算任务;最上层分别实现了Runnable、Future、Delayed接口,说明它是一个可以延迟执行的异步运算任务。 - `DelayedWorkQueue`: 这是 ScheduledThreadPoolExecutor 为存储周期或延迟任务专门定义的一个延迟队列,继承了 AbstractQueue,为了契合 ThreadPoolExecutor 也实现了 BlockingQueue 接口。它内部只允许存储 RunnableScheduledFuture 类型的任务。与 DelayQueue 的不同之处就是它只允许存放 RunnableScheduledFuture 对象,并且自己实现了二叉堆(DelayQueue 是利用了 PriorityQueue 的二叉堆结构)。 #### ScheduledThreadPoolExecutor有哪两个关闭策略? 区别是什么? **shutdown**: 在shutdown方法中调用的关闭钩子onShutdown方法,它的主要作用是在关闭线程池后取消并清除由于关闭策略不应该运行的所有任务,这里主要是根据 run-after-shutdown 参数(continueExistingPeriodicTasksAfterShutdown和executeExistingDelayedTasksAfterShutdown)来决定线程池关闭后是否关闭已经存在的任务。 **showDownNow**: 立即关闭 #### ScheduledThreadPoolExecutor中scheduleAtFixedRate 和 scheduleWithFixedDelay区别是什么? **注意scheduleAtFixedRate和scheduleWithFixedDelay的区别**: 乍一看两个方法一模一样,其实,在unit.toNanos这一行代码中还是有区别的。没错,scheduleAtFixedRate传的是正值,而scheduleWithFixedDelay传的则是负值,这个值就是 ScheduledFutureTask 的period属性。 #### 为什么ThreadPoolExecutor 的调整策略却不适用于 ScheduledThreadPoolExecutor? 例如: 由于 ScheduledThreadPoolExecutor 是一个固定核心线程数大小的线程池,并且使用了一个无界队列,所以调整maximumPoolSize对其没有任何影响(所以 ScheduledThreadPoolExecutor 没有提供可以调整最大线程数的构造函数,默认最大线程数固定为Integer.MAX_VALUE)。此外,设置corePoolSize为0或者设置核心线程空闲后清除(allowCoreThreadTimeOut)同样也不是一个好的策略,因为一旦周期任务到达某一次运行周期时,可能导致线程池内没有线程去处理这些任务。 #### Executors 提供了几种方法来构造 ScheduledThreadPoolExecutor? - newScheduledThreadPool: 可指定核心线程数的线程池。 - newSingleThreadScheduledExecutor: 只有一个工作线程的线程池。如果内部工作线程由于执行周期任务异常而被终止,则会新建一个线程替代它的位置。 #### Fork/Join主要用来解决什么样的问题? ForkJoinPool 是JDK 7加入的一个线程池类。Fork/Join 技术是分治算法(Divide-and-Conquer)的并行实现,它是一项可以获得良好的并行性能的简单且高效的设计技术。目的是为了帮助我们更好地利用多处理器带来的好处,使用所有可用的运算能力来提升应用的性能。 #### Fork/Join框架是在哪个JDK版本中引入的? JDK 7 #### Fork/Join框架主要包含哪三个模块? 模块之间的关系是怎么样的? Fork/Join框架主要包含三个模块: - 任务对象: `ForkJoinTask` (包括`RecursiveTask`、`RecursiveAction` 和 `CountedCompleter`) - 执行Fork/Join任务的线程: `ForkJoinWorkerThread` - 线程池: `ForkJoinPool` 这三者的关系是: ForkJoinPool可以通过池中的ForkJoinWorkerThread来处理ForkJoinTask任务。 #### ForkJoinPool类继承关系? ![img](https://b2files.173114.xyz/blogimg/2025/03/7b480dbcfdb74974829175ca2445f8d8.png) 内部类介绍: - ForkJoinWorkerThreadFactory: 内部线程工厂接口,用于创建工作线程ForkJoinWorkerThread - DefaultForkJoinWorkerThreadFactory: ForkJoinWorkerThreadFactory 的默认实现类 - InnocuousForkJoinWorkerThreadFactory: 实现了 ForkJoinWorkerThreadFactory,无许可线程工厂,当系统变量中有系统安全管理相关属性时,默认使用这个工厂创建工作线程。 - EmptyTask: 内部占位类,用于替换队列中 join 的任务。 - ManagedBlocker: 为 ForkJoinPool 中的任务提供扩展管理并行数的接口,一般用在可能会阻塞的任务(如在 Phaser 中用于等待 phase 到下一个generation)。 - WorkQueue: ForkJoinPool 的核心数据结构,本质上是work-stealing 模式的双端任务队列,内部存放 ForkJoinTask 对象任务,使用 @Contented 注解修饰防止伪共享。 - 工作线程在运行中产生新的任务(通常是因为调用了 fork())时,此时可以把 WorkQueue 的数据结构视为一个栈,新的任务会放入栈顶(top 位);工作线程在处理自己工作队列的任务时,按照 LIFO 的顺序。 - 工作线程在处理自己的工作队列同时,会尝试窃取一个任务(可能是来自于刚刚提交到 pool 的任务,或是来自于其他工作线程的队列任务),此时可以把 WorkQueue 的数据结构视为一个 FIFO 的队列,窃取的任务位于其他线程的工作队列的队首(base位)。 - 伪共享状态: 缓存系统中是以缓存行(cache line)为单位存储的。缓存行是2的整数幂个连续字节,一般为32-256个字节。最常见的缓存行大小是64个字节。当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。 #### ForkJoinTask抽象类继承关系? ![img](https://b2files.173114.xyz/blogimg/2025/03/89df69611d124ab408ef51353b9f8ed3.png) ForkJoinTask 实现了 Future 接口,说明它也是一个可取消的异步运算任务,实际上ForkJoinTask 是 Future 的轻量级实现,主要用在纯粹是计算的函数式任务或者操作完全独立的对象计算任务。fork 是主运行方法,用于异步执行;而 join 方法在任务结果计算完毕之后才会运行,用来合并或返回计算结果。 其内部类都比较简单,ExceptionNode 是用于存储任务执行期间的异常信息的单向链表;其余四个类是为 Runnable/Callable 任务提供的适配器类,用于把 Runnable/Callable 转化为 ForkJoinTask 类型的任务(因为 ForkJoinPool 只可以运行 ForkJoinTask 类型的任务)。 #### 整个Fork/Join 框架的执行流程/运行机制是怎么样的? - 首先介绍任务的提交流程 - 外部任务(external/submissions task)提交 - 然后介绍任务的提交流程 - 子任务(Worker task)提交 - 再分析任务的执行过程(ForkJoinWorkerThread.run()到ForkJoinTask.doExec()这一部分); - 最后介绍任务的结果获取(ForkJoinTask.join()和ForkJoinTask.invoke()) #### 具体阐述Fork/Join的分治思想和work-stealing 实现方式? - 分治算法(Divide-and-Conquer) 分治算法(Divide-and-Conquer)把任务递归的拆分为各个子任务,这样可以更好的利用系统资源,尽可能的使用所有可用的计算能力来提升应用性能。首先看一下 Fork/Join 框架的任务运行机制: ![img](https://pdai.tech/images/thread/java-thread-x-forkjoin-2.png) - work-stealing(工作窃取)算法 work-stealing(工作窃取)算法: 线程池内的所有工作线程都尝试找到并执行已经提交的任务,或者是被其他活动任务创建的子任务(如果不存在就阻塞等待)。这种特性使得 ForkJoinPool 在运行多个可以产生子任务的任务,或者是提交的许多小任务时效率更高。尤其是构建异步模型的 ForkJoinPool 时,对不需要合并(join)的事件类型任务也非常适用。 在 ForkJoinPool 中,线程池中每个工作线程(ForkJoinWorkerThread)都对应一个任务队列(WorkQueue),工作线程优先处理来自自身队列的任务(LIFO或FIFO顺序,参数 mode 决定),然后以FIFO的顺序随机窃取其他队列中的任务。 具体思路如下: - 每个线程都有自己的一个WorkQueue,该工作队列是一个双端队列。 - 队列支持三个功能push、pop、poll - push/pop只能被队列的所有者线程调用,而poll可以被其他线程调用。 - 划分的子任务调用fork时,都会被push到自己的队列中。 - 默认情况下,工作线程从自己的双端队列获出任务并执行。 - 当自己的队列为空时,线程随机从另一个线程的队列末尾调用poll方法窃取任务。 ![img](https://b2files.173114.xyz/blogimg/2025/03/cc6a40d083aaa365aa2ec22cf8c5a31c.png) #### 有哪些JDK源码中使用了Fork/Join思想? 我们常用的数组工具类 Arrays 在JDK 8之后新增的并行排序方法(parallelSort)就运用了 ForkJoinPool 的特性,还有 ConcurrentHashMap 在JDK 8之后添加的函数式方法(如forEach等)也有运用。 #### 如何使用Executors工具类创建ForkJoinPool? Java8在Executors工具类中新增了两个工厂方法: ```java // parallelism定义并行级别 public static ExecutorService newWorkStealingPool(int parallelism); // 默认并行级别为JVM可用的处理器个数 // Runtime.getRuntime().availableProcessors() public static ExecutorService newWorkStealingPool(); ``` #### 写一个例子: 用ForkJoin方式实现1+2+3+...+100000? ```java public class Test { static final class SumTask extends RecursiveTask { private static final long serialVersionUID = 1L; final int start; //开始计算的数 final int end; //最后计算的数 SumTask(int start, int end) { this.start = start; this.end = end; } @Override protected Integer compute() { //如果计算量小于1000,那么分配一个线程执行if中的代码块,并返回执行结果 if(end - start < 1000) { System.out.println(Thread.currentThread().getName() + " 开始执行: " + start + "-" + end); int sum = 0; for(int i = start; i <= end; i++) sum += i; return sum; } //如果计算量大于1000,那么拆分为两个任务 SumTask task1 = new SumTask(start, (start + end) / 2); SumTask task2 = new SumTask((start + end) / 2 + 1, end); //执行任务 task1.fork(); task2.fork(); //获取任务执行的结果 return task1.join() + task2.join(); } } public static void main(String[] args) throws InterruptedException, ExecutionException { ForkJoinPool pool = new ForkJoinPool(); ForkJoinTask task = new SumTask(1, 10000); pool.submit(task); System.out.println(task.get()); } } ``` - 执行结果 ```java ForkJoinPool-1-worker-1 开始执行: 1-625 ForkJoinPool-1-worker-7 开始执行: 6251-6875 ForkJoinPool-1-worker-6 开始执行: 5626-6250 ForkJoinPool-1-worker-10 开始执行: 3751-4375 ForkJoinPool-1-worker-13 开始执行: 2501-3125 ForkJoinPool-1-worker-8 开始执行: 626-1250 ForkJoinPool-1-worker-11 开始执行: 5001-5625 ForkJoinPool-1-worker-3 开始执行: 7501-8125 ForkJoinPool-1-worker-14 开始执行: 1251-1875 ForkJoinPool-1-worker-4 开始执行: 9376-10000 ForkJoinPool-1-worker-8 开始执行: 8126-8750 ForkJoinPool-1-worker-0 开始执行: 1876-2500 ForkJoinPool-1-worker-12 开始执行: 4376-5000 ForkJoinPool-1-worker-5 开始执行: 8751-9375 ForkJoinPool-1-worker-7 开始执行: 6876-7500 ForkJoinPool-1-worker-1 开始执行: 3126-3750 50005000 ``` #### Fork/Join在使用时有哪些注意事项? 结合JDK中的斐波那契数列实例具体说明。 斐波那契数列: 1、1、2、3、5、8、13、21、34、…… 公式 : F(1)=1,F(2)=1, F(n)=F(n-1)+F(n-2)(n>=3,n∈N*) ```java public static void main(String[] args) { ForkJoinPool forkJoinPool = new ForkJoinPool(4); // 最大并发数4 Fibonacci fibonacci = new Fibonacci(20); long startTime = System.currentTimeMillis(); Integer result = forkJoinPool.invoke(fibonacci); long endTime = System.currentTimeMillis(); System.out.println("Fork/join sum: " + result + " in " + (endTime - startTime) + " ms."); } //以下为官方API文档示例 static class Fibonacci extends RecursiveTask { final int n; Fibonacci(int n) { this.n = n; } @Override protected Integer compute() { if (n <= 1) { return n; } Fibonacci f1 = new Fibonacci(n - 1); f1.fork(); Fibonacci f2 = new Fibonacci(n - 2); return f2.compute() + f1.join(); } } ``` 当然你也可以两个任务都fork,要注意的是两个任务都fork的情况,必须按照f1.fork(),f2.fork(), f2.join(),f1.join()这样的顺序,不然有性能问题,详见上面注意事项中的说明。 官方API文档是这样写到的,所以平日用invokeAll就好了。invokeAll会把传入的任务的第一个交给当前线程来执行,其他的任务都fork加入工作队列,这样等于利用当前线程也执行任务了。 ```java { // ... Fibonacci f1 = new Fibonacci(n - 1); Fibonacci f2 = new Fibonacci(n - 2); invokeAll(f1,f2); return f2.join() + f1.join(); } public static void invokeAll(ForkJoinTask... tasks) { Throwable ex = null; int last = tasks.length - 1; for (int i = last; i >= 0; --i) { ForkJoinTask t = tasks[i]; if (t == null) { if (ex == null) ex = new NullPointerException(); } else if (i != 0) //除了第一个都fork t.fork(); else if (t.doInvoke() < NORMAL && ex == null) //留一个自己执行 ex = t.getException(); } for (int i = 1; i <= last; ++i) { ForkJoinTask t = tasks[i]; if (t != null) { if (ex != null) t.cancel(false); else if (t.doJoin() < NORMAL) ex = t.getException(); } } if (ex != null) rethrow(ex); } ``` ### 3.8 JUC工具类 - [JUC工具类: CountDownLatch详解](https://pdai.tech/md/java/thread/java-thread-x-juc-tool-countdownlatch.html) - [JUC工具类: CyclicBarrier详解](https://pdai.tech/md/java/thread/java-thread-x-juc-tool-cyclicbarrier.html) - [JUC工具类: Semaphore详解](https://pdai.tech/md/java/thread/java-thread-x-juc-tool-semaphore.html) - [JUC工具类: Phaser详解](https://pdai.tech/md/java/thread/java-thread-x-juc-tool-phaser.html) - [JUC工具类: Exchanger详解](https://pdai.tech/md/java/thread/java-thread-x-juc-tool-exchanger.html) - [Java 并发 - ThreadLocal详解](https://pdai.tech/md/java/thread/java-thread-x-threadlocal.html) #### 什么是CountDownLatch? CountDownLatch底层也是由AQS,用来同步一个或多个任务的常用并发工具类,强制它们等待由其他任务执行的一组操作完成。 #### CountDownLatch底层实现原理? 其底层是由AQS提供支持,所以其数据结构可以参考AQS的数据结构,而AQS的数据结构核心就是两个虚拟队列: 同步队列sync queue 和条件队列condition queue,不同的条件会有不同的条件队列。CountDownLatch典型的用法是将一个程序分为n个互相独立的可解决任务,并创建值为n的CountDownLatch。当每一个任务完成时,都会在这个锁存器上调用countDown,等待问题被解决的任务调用这个锁存器的await,将他们自己拦住,直至锁存器计数结束。 #### CountDownLatch一次可以唤醒几个任务? 多个 #### CountDownLatch有哪些主要方法? await(), 此函数将会使当前线程在锁存器倒计数至零之前一直等待,除非线程被中断。 countDown(), 此函数将递减锁存器的计数,如果计数到达零,则释放所有等待的线程 #### 写道题:实现一个容器,提供两个方法,add,size 写两个线程,线程1添加10个元素到容器中,线程2实现监控元素的个数,当个数到5个时,线程2给出提示并结束? 说出使用CountDownLatch 代替wait notify 好处? ```java import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; /** * 使用CountDownLatch 代替wait notify 好处是通讯方式简单,不涉及锁定 Count 值为0时当前线程继续执行, */ public class T3 { volatile List list = new ArrayList(); public void add(int i){ list.add(i); } public int getSize(){ return list.size(); } public static void main(String[] args) { T3 t = new T3(); CountDownLatch countDownLatch = new CountDownLatch(1); new Thread(() -> { System.out.println("t2 start"); if(t.getSize() != 5){ try { countDownLatch.await(); System.out.println("t2 end"); } catch (InterruptedException e) { e.printStackTrace(); } } },"t2").start(); new Thread(()->{ System.out.println("t1 start"); for (int i = 0;i<9;i++){ t.add(i); System.out.println("add"+ i); if(t.getSize() == 5){ System.out.println("countdown is open"); countDownLatch.countDown(); } } System.out.println("t1 end"); },"t1").start(); } } ``` #### 什么是CyclicBarrier? - 对于CountDownLatch,其他线程为游戏玩家,比如英雄联盟,主线程为控制游戏开始的线程。在所有的玩家都准备好之前,主线程是处于等待状态的,也就是游戏不能开始。当所有的玩家准备好之后,下一步的动作实施者为主线程,即开始游戏。 - 对于CyclicBarrier,假设有一家公司要全体员工进行团建活动,活动内容为翻越三个障碍物,每一个人翻越障碍物所用的时间是不一样的。但是公司要求所有人在翻越当前障碍物之后再开始翻越下一个障碍物,也就是所有人翻越第一个障碍物之后,才开始翻越第二个,以此类推。类比地,每一个员工都是一个“其他线程”。当所有人都翻越的所有的障碍物之后,程序才结束。而主线程可能早就结束了,这里我们不用管主线程。 #### CountDownLatch和CyclicBarrier对比? - CountDownLatch减计数,CyclicBarrier加计数。 - CountDownLatch是一次性的,CyclicBarrier可以重用。 - CountDownLatch和CyclicBarrier都有让多个线程等待同步然后再开始下一步动作的意思,但是CountDownLatch的下一步的动作实施者是主线程,具有不可重复性;而CyclicBarrier的下一步动作实施者还是“其他线程”本身,具有往复多次实施动作的特点。 #### 什么是Semaphore? Semaphore底层是基于AbstractQueuedSynchronizer来实现的。Semaphore称为计数信号量,它允许n个任务同时访问某个资源,可以将信号量看做是在向外分发使用资源的许可证,只有成功获取许可证,才能使用资源 #### Semaphore内部原理? Semaphore总共有三个内部类,并且三个内部类是紧密相关的,下面先看三个类的关系。 ![img](https://b2files.173114.xyz/blogimg/2025/03/8fcd28add26a3000624645ee229a072f.png) 说明: Semaphore与ReentrantLock的内部类的结构相同,类内部总共存在Sync、NonfairSync、FairSync三个类,NonfairSync与FairSync类继承自Sync类,Sync类继承自AbstractQueuedSynchronizer抽象类。下面逐个进行分析。 #### Semaphore常用方法有哪些? 如何实现线程同步和互斥的? #### 单独使用Semaphore是不会使用到AQS的条件队列? 不同于CyclicBarrier和ReentrantLock,单独使用Semaphore是不会使用到AQS的条件队列的,其实,只有进行await操作才会进入条件队列,其他的都是在同步队列中,只是当前线程会被park。 #### Semaphore初始化有10个令牌,11个线程同时各调用1次acquire方法,会发生什么? 拿不到令牌的线程阻塞,不会继续往下运行。 #### Semaphore初始化有10个令牌,一个线程重复调用11次acquire方法,会发生什么? 线程阻塞,不会继续往下运行。可能你会考虑类似于锁的重入的问题,很好,但是,令牌没有重入的概念。你只要调用一次acquire方法,就需要有一个令牌才能继续运行。 #### Semaphore初始化有1个令牌,1个线程调用一次acquire方法,然后调用两次release方法,之后另外一个线程调用acquire(2)方法,此线程能够获取到足够的令牌并继续运行吗? 能,原因是release方法会添加令牌,并不会以初始化的大小为准。 #### Semaphore初始化有2个令牌,一个线程调用1次release方法,然后一次性获取3个令牌,会获取到吗? 能,原因是release会添加令牌,并不会以初始化的大小为准。Semaphore中release方法的调用并没有限制要在acquire后调用。 具体示例如下,如果不相信的话,可以运行一下下面的demo,在做实验之前,笔者也认为应该是不允许的。。 ```java public class TestSemaphore2 { public static void main(String[] args) { int permitsNum = 2; final Semaphore semaphore = new Semaphore(permitsNum); try { System.out.println("availablePermits:"+semaphore.availablePermits()+",semaphore.tryAcquire(3,1, TimeUnit.SECONDS):"+semaphore.tryAcquire(3,1, TimeUnit.SECONDS)); semaphore.release(); System.out.println("availablePermits:"+semaphore.availablePermits()+",semaphore.tryAcquire(3,1, TimeUnit.SECONDS):"+semaphore.tryAcquire(3,1, TimeUnit.SECONDS)); }catch (Exception e) { } } } ``` #### Phaser主要用来解决什么问题? Phaser是JDK 7新增的一个同步辅助类,它可以实现CyclicBarrier和CountDownLatch类似的功能,而且它支持对任务的动态调整,并支持分层结构来达到更高的吞吐量。 #### Phaser与CyclicBarrier和CountDownLatch的区别是什么? Phaser 和 CountDownLatch、CyclicBarrier 都有很相似的地方。 Phaser 顾名思义,就是可以分阶段的进行线程同步。 - CountDownLatch 只能在创建实例时,通过构造方法指定同步数量; - Phaser 支持线程动态地向它注册。 利用这个动态注册的特性,可以达到分阶段同步控制的目的: 注册一批操作,等待它们执行结束;再注册一批操作,等它们结束... #### Phaser运行机制是什么样的? ![img](https://pdai.tech/images/thread/java-thread-x-juc-phaser-1.png) - **Registration(注册)** 跟其他barrier不同,在phaser上注册的parties会随着时间的变化而变化。任务可以随时注册(使用方法register,bulkRegister注册,或者由构造器确定初始parties),并且在任何抵达点可以随意地撤销注册(方法arriveAndDeregister)。就像大多数基本的同步结构一样,注册和撤销只影响内部count;不会创建更深的内部记录,所以任务不能查询他们是否已经注册。(不过,可以通过继承来实现类似的记录) - **Synchronization(同步机制)** 和CyclicBarrier一样,Phaser也可以重复await。方法arriveAndAwaitAdvance的效果类似CyclicBarrier.await。phaser的每一代都有一个相关的phase number,初始值为0,当所有注册的任务都到达phaser时phase+1,到达最大值(Integer.MAX_VALUE)之后清零。使用phase number可以独立控制 到达phaser 和 等待其他线程 的动作,通过下面两种类型的方法: > - **Arrival(到达机制)** arrive和arriveAndDeregister方法记录到达状态。这些方法不会阻塞,但是会返回一个相关的arrival phase number;也就是说,phase number用来确定到达状态。当所有任务都到达给定phase时,可以执行一个可选的函数,这个函数通过重写onAdvance方法实现,通常可以用来控制终止状态。重写此方法类似于为CyclicBarrier提供一个barrierAction,但比它更灵活。 > - **Waiting(等待机制)** awaitAdvance方法需要一个表示arrival phase number的参数,并且在phaser前进到与给定phase不同的phase时返回。和CyclicBarrier不同,即使等待线程已经被中断,awaitAdvance方法也会一直等待。中断状态和超时时间同样可用,但是当任务等待中断或超时后未改变phaser的状态时会遭遇异常。如果有必要,在方法forceTermination之后可以执行这些异常的相关的handler进行恢复操作,Phaser也可能被ForkJoinPool中的任务使用,这样在其他任务阻塞等待一个phase时可以保证足够的并行度来执行任务。 - **Termination(终止机制)** : 可以用isTerminated方法检查phaser的终止状态。在终止时,所有同步方法立刻返回一个负值。在终止时尝试注册也没有效果。当调用onAdvance返回true时Termination被触发。当deregistration操作使已注册的parties变为0时,onAdvance的默认实现就会返回true。也可以重写onAdvance方法来定义终止动作。forceTermination方法也可以释放等待线程并且允许它们终止。 - **Tiering(分层结构)** : Phaser支持分层结构(树状构造)来减少竞争。注册了大量parties的Phaser可能会因为同步竞争消耗很高的成本, 因此可以设置一些子Phaser来共享一个通用的parent。这样的话即使每个操作消耗了更多的开销,但是会提高整体吞吐量。 在一个分层结构的phaser里,子节点phaser的注册和取消注册都通过父节点管理。子节点phaser通过构造或方法register、bulkRegister进行首次注册时,在其父节点上注册。子节点phaser通过调用arriveAndDeregister进行最后一次取消注册时,也在其父节点上取消注册。 - **Monitoring(状态监控)** : 由于同步方法可能只被已注册的parties调用,所以phaser的当前状态也可能被任何调用者监控。在任何时候,可以通过getRegisteredParties获取parties数,其中getArrivedParties方法返回已经到达当前phase的parties数。当剩余的parties(通过方法getUnarrivedParties获取)到达时,phase进入下一代。这些方法返回的值可能只表示短暂的状态,所以一般来说在同步结构里并没有啥卵用。 #### 给一个Phaser使用的示例? 模拟了100米赛跑,10名选手,只等裁判一声令下。当所有人都到达终点时,比赛结束。 ```java public class Match { // 模拟了100米赛跑,10名选手,只等裁判一声令下。当所有人都到达终点时,比赛结束。 public static void main(String[] args) throws InterruptedException { final Phaser phaser=new Phaser(1) ; // 十名选手 for (int index = 0; index < 10; index++) { phaser.register(); new Thread(new player(phaser),"player"+index).start(); } System.out.println("Game Start"); //注销当前线程,比赛开始 phaser.arriveAndDeregister(); //是否非终止态一直等待 while(!phaser.isTerminated()){ } System.out.println("Game Over"); } } class player implements Runnable{ private final Phaser phaser ; player(Phaser phaser){ this.phaser=phaser; } @Override public void run() { try { // 第一阶段——等待创建好所有线程再开始 phaser.arriveAndAwaitAdvance(); // 第二阶段——等待所有选手准备好再开始 Thread.sleep((long) (Math.random() * 10000)); System.out.println(Thread.currentThread().getName() + " ready"); phaser.arriveAndAwaitAdvance(); // 第三阶段——等待所有选手准备好到达,到达后,该线程从phaser中注销,不在进行下面的阶段。 Thread.sleep((long) (Math.random() * 10000)); System.out.println(Thread.currentThread().getName() + " arrived"); phaser.arriveAndDeregister(); } catch (InterruptedException e) { e.printStackTrace(); } } } ``` #### Exchanger主要解决什么问题? Exchanger用于进行两个线程之间的数据交换。它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过exchange()方法交换数据,当一个线程先执行exchange()方法后,它会一直等待第二个线程也执行exchange()方法,当这两个线程到达同步点时,这两个线程就可以交换数据了。 #### 对比SynchronousQueue,为什么说Exchanger可被视为 SynchronousQueue 的双向形式? Exchanger是一种线程间安全交换数据的机制。可以和之前分析过的SynchronousQueue对比一下:线程A通过SynchronousQueue将数据a交给线程B;线程A通过Exchanger和线程B交换数据,线程A把数据a交给线程B,同时线程B把数据b交给线程A。可见,SynchronousQueue是交给一个数据,Exchanger是交换两个数据。 #### Exchanger在不同的JDK版本中实现有什么差别? - 在JDK5中Exchanger被设计成一个容量为1的容器,存放一个等待线程,直到有另外线程到来就会发生数据交换,然后清空容器,等到下一个到来的线程。 - 从JDK6开始,Exchanger用了类似ConcurrentMap的分段思想,提供了多个slot,增加了并发执行时的吞吐量。 #### Exchanger实现举例 来一个非常经典的并发问题:你有相同的数据buffer,一个或多个数据生产者,和一个或多个数据消费者。只是Exchange类只能同步2个线程,所以你只能在你的生产者和消费者问题中只有一个生产者和一个消费者时使用这个类。 ```java public class Test { static class Producer extends Thread { private Exchanger exchanger; private static int data = 0; Producer(String name, Exchanger exchanger) { super("Producer-" + name); this.exchanger = exchanger; } @Override public void run() { for (int i=1; i<5; i++) { try { TimeUnit.SECONDS.sleep(1); data = i; System.out.println(getName()+" 交换前:" + data); data = exchanger.exchange(data); System.out.println(getName()+" 交换后:" + data); } catch (InterruptedException e) { e.printStackTrace(); } } } } static class Consumer extends Thread { private Exchanger exchanger; private static int data = 0; Consumer(String name, Exchanger exchanger) { super("Consumer-" + name); this.exchanger = exchanger; } @Override public void run() { while (true) { data = 0; System.out.println(getName()+" 交换前:" + data); try { TimeUnit.SECONDS.sleep(1); data = exchanger.exchange(data); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(getName()+" 交换后:" + data); } } } public static void main(String[] args) throws InterruptedException { Exchanger exchanger = new Exchanger(); new Producer("", exchanger).start(); new Consumer("", exchanger).start(); TimeUnit.SECONDS.sleep(7); System.exit(-1); } } ``` 可以看到,其结果可能如下: ```html Consumer- 交换前:0 Producer- 交换前:1 Consumer- 交换后:1 Consumer- 交换前:0 Producer- 交换后:0 Producer- 交换前:2 Producer- 交换后:0 Consumer- 交换后:2 Consumer- 交换前:0 Producer- 交换前:3 Producer- 交换后:0 Consumer- 交换后:3 Consumer- 交换前:0 Producer- 交换前:4 Producer- 交换后:0 Consumer- 交换后:4 Consumer- 交换前:0 ``` #### 什么是ThreadLocal? 用来解决什么问题的? 我们在[Java 并发 - 并发理论基础](https://pdai.tech/md/java/thread/java-thread-x-theorty.html#线程安全的实现方法)总结过线程安全(是指广义上的共享资源访问安全性,因为线程隔离是通过副本保证本线程访问资源安全性,它不保证线程之间还存在共享关系的狭义上的安全性)的解决思路: - 互斥同步: synchronized 和 ReentrantLock - 非阻塞同步: CAS, AtomicXXXX - 无同步方案: 栈封闭,本地存储(Thread Local),可重入代码 ThreadLocal是通过线程隔离的方式防止任务在共享资源上产生冲突, 线程本地存储是一种自动化机制,可以为使用相同变量的每个不同线程都创建不同的存储。 ThreadLocal是一个将在多线程中为每一个线程创建单独的变量副本的类; 当使用ThreadLocal来维护变量时, ThreadLocal会为每个线程创建单独的变量副本, 避免因多线程操作共享变量而导致的数据不一致的情况。 #### 说说你对ThreadLocal的理解 提到ThreadLocal被提到应用最多的是session管理和数据库链接管理,这里以数据访问为例帮助你理解ThreadLocal: - 如下数据库管理类在单线程使用是没有任何问题的 ```java class ConnectionManager { private static Connection connect = null; public static Connection openConnection() { if (connect == null) { connect = DriverManager.getConnection(); } return connect; } public static void closeConnection() { if (connect != null) connect.close(); } } ``` 很显然,在多线程中使用会存在线程安全问题:第一,这里面的2个方法都没有进行同步,很可能在openConnection方法中会多次创建connect;第二,由于connect是共享变量,那么必然在调用connect的地方需要使用到同步来保障线程安全,因为很可能一个线程在使用connect进行数据库操作,而另外一个线程调用closeConnection关闭链接。 - 为了解决上述线程安全的问题,第一考虑:互斥同步 你可能会说,将这段代码的两个方法进行同步处理,并且在调用connect的地方需要进行同步处理,比如用Synchronized或者ReentrantLock互斥锁。 - 这里再抛出一个问题:这地方到底需不需要将connect变量进行共享? 事实上,是不需要的。假如每个线程中都有一个connect变量,各个线程之间对connect变量的访问实际上是没有依赖关系的,即一个线程不需要关心其他线程是否对这个connect进行了修改的。即改后的代码可以这样: ```java class ConnectionManager { private Connection connect = null; public Connection openConnection() { if (connect == null) { connect = DriverManager.getConnection(); } return connect; } public void closeConnection() { if (connect != null) connect.close(); } } class Dao { public void insert() { ConnectionManager connectionManager = new ConnectionManager(); Connection connection = connectionManager.openConnection(); // 使用connection进行操作 connectionManager.closeConnection(); } } ``` 这样处理确实也没有任何问题,由于每次都是在方法内部创建的连接,那么线程之间自然不存在线程安全问题。但是这样会有一个致命的影响:导致服务器压力非常大,并且严重影响程序执行性能。由于在方法中需要频繁地开启和关闭数据库连接,这样不仅严重影响程序执行效率,还可能导致服务器压力巨大。 - 这时候ThreadLocal登场了 那么这种情况下使用ThreadLocal是再适合不过的了,因为ThreadLocal在每个线程中对该变量会创建一个副本,即每个线程内部都会有一个该变量,且在线程内部任何地方都可以使用,线程之间互不影响,这样一来就不存在线程安全问题,也不会严重影响程序执行性能。下面就是网上出现最多的例子: ```java import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class ConnectionManager { private static final ThreadLocal dbConnectionLocal = new ThreadLocal() { @Override protected Connection initialValue() { try { return DriverManager.getConnection("", "", ""); } catch (SQLException e) { e.printStackTrace(); } return null; } }; public Connection getConnection() { return dbConnectionLocal.get(); } } ``` #### ThreadLocal是如何实现线程隔离的? ThreadLocalMap #### 为什么ThreadLocal会造成内存泄露? 如何解决 网上有这样一个例子: ```java import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class ThreadLocalDemo { static class LocalVariable { private Long[] a = new Long[1024 * 1024]; } // (1) final static ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(5, 5, 1, TimeUnit.MINUTES, new LinkedBlockingQueue<>()); // (2) final static ThreadLocal localVariable = new ThreadLocal(); public static void main(String[] args) throws InterruptedException { // (3) Thread.sleep(5000 * 4); for (int i = 0; i < 50; ++i) { poolExecutor.execute(new Runnable() { public void run() { // (4) localVariable.set(new LocalVariable()); // (5) System.out.println("use local varaible" + localVariable.get()); localVariable.remove(); } }); } // (6) System.out.println("pool execute over"); } } ``` 如果用线程池来操作ThreadLocal 对象确实会造成内存泄露, 因为对于线程池里面不会销毁的线程, 里面总会存在着``的强引用, 因为final static 修饰的 ThreadLocal 并不会释放, 而ThreadLocalMap 对于 Key 虽然是弱引用, 但是强引用不会释放, 弱引用当然也会一直有值, 同时创建的LocalVariable对象也不会释放, 就造成了内存泄露; 如果LocalVariable对象不是一个大对象的话, 其实泄露的并不严重, `泄露的内存 = 核心线程数 * LocalVariable`对象的大小; 所以, 为了避免出现内存泄露的情况, ThreadLocal提供了一个清除线程中对象的方法, 即 remove, 其实内部实现就是调用 ThreadLocalMap 的remove方法: ```java private void remove(ThreadLocal key) { Entry[] tab = table; int len = tab.length; int i = key.threadLocalHashCode & (len-1); for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { if (e.get() == key) { e.clear(); expungeStaleEntry(i); return; } } } ``` 找到Key对应的Entry, 并且清除Entry的Key(ThreadLocal)置空, 随后清除过期的Entry即可避免内存泄露。 #### 还有哪些使用ThreadLocal的应用场景? - 每个线程维护了一个“序列号” ~~~java public class SerialNum { // The next serial number to be assigned private static int nextSerialNum = 0; private static ThreadLocal serialNum = new ThreadLocal() { protected synchronized Object initialValue() { return new Integer(nextSerialNum++); } }; public static int get() { return ((Integer) (serialNum.get())).intValue(); } } + 经典的另外一个例子: ```java private static final ThreadLocal threadSession = new ThreadLocal(); public static Session getSession() throws InfrastructureException { Session s = (Session) threadSession.get(); try { if (s == null) { s = getSessionFactory().openSession(); threadSession.set(s); } } catch (HibernateException ex) { throw new InfrastructureException(ex); } return s; } ~~~ - 看看阿里巴巴 java 开发手册中推荐的 ThreadLocal 的用法: ```java import java.text.DateFormat; import java.text.SimpleDateFormat; public class DateUtils { public static final ThreadLocal df = new ThreadLocal(){ @Override protected DateFormat initialValue() { return new SimpleDateFormat("yyyy-MM-dd"); } }; } ``` 然后我们再要用到 DateFormat 对象的地方,这样调用: ```java DateUtils.df.get().format(new Date()); ``` ## 4 Java IO > Java IO相关 ### 4.1 基础IO #### [#](https://pdai.tech/md/interview/x-interview.html#如何从数据传输方式理解io流)如何从数据传输方式理解IO流? 从数据传输方式或者说是运输方式角度看,可以将 IO 类分为: 1. **字节流**, 字节流读取单个字节,字符流读取单个字符(一个字符根据编码的不同,对应的字节也不同,如 UTF-8 编码中文汉字是 3 个字节,GBK编码中文汉字是 2 个字节。) 2. **字符流**, 字节流用来处理二进制文件(图片、MP3、视频文件),字符流用来处理文本文件(可以看做是特殊的二进制文件,使用了某种编码,人可以阅读)。 **字节是给计算机看的,字符才是给人看的** - **字节流** ![img](https://b2files.173114.xyz/blogimg/2025/03/1457114da1be9556eebadff672d78afc.png) - **字符流** ![img](https://b2files.173114.xyz/blogimg/2025/03/3eba60e14c0777f6815da413d78ffecf.png) - **字节转字符**? ![img](https://b2files.173114.xyz/blogimg/2025/03/974fed10a0d03a29a8ff92ac69e07194.png) #### 如何从数据操作上理解IO流? 从数据来源或者说是操作对象角度看,IO 类可以分为: ![img](https://b2files.173114.xyz/blogimg/2025/03/b6fba9b0e5f13ac9f1aada1914eab2ad.png) #### Java IO设计上使用了什么设计模式? **装饰者模式**: 所谓装饰,就是把这个装饰者套在被装饰者之上,从而动态扩展被装饰者的功能。 - **装饰者举例** 设计不同种类的饮料,饮料可以添加配料,比如可以添加牛奶,并且支持动态添加新配料。每增加一种配料,该饮料的价格就会增加,要求计算一种饮料的价格。 下图表示在 DarkRoast 饮料上新增新添加 Mocha 配料,之后又添加了 Whip 配料。DarkRoast 被 Mocha 包裹,Mocha 又被 Whip 包裹。它们都继承自相同父类,都有 cost() 方法,外层类的 cost() 方法调用了内层类的 cost() 方法。 ![img](https://b2files.173114.xyz/blogimg/2025/03/ac7c7f4030e037c0fcb919f1111bd3f7.jpg) - 以 InputStream 为例 - InputStream 是抽象组件; - FileInputStream 是 InputStream 的子类,属于具体组件,提供了字节流的输入操作; - FilterInputStream 属于抽象装饰者,装饰者用于装饰组件,为组件提供额外的功能。例如 BufferedInputStream 为 FileInputStream 提供缓存的功能。 ![image](https://b2files.173114.xyz/blogimg/2025/03/73713cdf33a3beb26aaa02a0cc8a1de2.png) 实例化一个具有缓存功能的字节流对象时,只需要在 FileInputStream 对象上再套一层 BufferedInputStream 对象即可。 ```java FileInputStream fileInputStream = new FileInputStream(filePath); BufferedInputStream bufferedInputStream = new BufferedInputStream(fileInputStream); ``` DataInputStream 装饰者提供了对更多数据类型进行输入的操作,比如 int、double 等基本类型。 ### 4.2 5种IO模型 #### 什么是阻塞?什么是同步? - **阻塞IO 和 非阻塞IO** 这两个概念是**程序级别**的。主要描述的是程序请求操作系统IO操作后,如果IO资源没有准备好,那么程序该如何处理的问题: 前者等待;后者继续执行(并且使用线程一直轮询,直到有IO资源准备好了) - **同步IO 和 非同步IO** 这两个概念是**操作系统级别**的。主要描述的是操作系统在收到程序请求IO操作后,如果IO资源没有准备好,该如何响应程序的问题: 前者不响应,直到IO资源准备好以后;后者返回一个标记(好让程序和自己知道以后的数据往哪里通知),当IO资源准备好以后,再用事件机制返回给程序。 #### 什么是Linux的IO模型? 网络IO的本质是socket的读取,socket在linux系统被抽象为流,IO可以理解为对流的操作。刚才说了,对于一次IO访问(以read举例),**数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间**。所以说,当一个read操作发生时,它会经历两个阶段: - 第一阶段:等待数据准备 (Waiting for the data to be ready)。 - 第二阶段:将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)。 对于socket流而言, - 第一步:通常涉及等待网络上的数据分组到达,然后被复制到内核的某个缓冲区。 - 第二步:把数据从内核缓冲区复制到应用进程缓冲区。 网络应用需要处理的无非就是两大类问题,网络IO,数据计算。相对于后者,网络IO的延迟,给应用带来的性能瓶颈大于后者。网络IO的模型大致有如下几种: 1. 同步阻塞IO(bloking IO) 2. 同步非阻塞IO(non-blocking IO) 3. 多路复用IO(multiplexing IO) 4. 信号驱动式IO(signal-driven IO) 5. 异步IO(asynchronous IO) ![img](https://b2files.173114.xyz/blogimg/2025/03/d513714dd8a87c7fd2b0bea7d3ae284e.png) PS: 这块略复杂,在后面的提供了问答,所以用了最简单的举例结合Linux IO图例帮你快速理解。 #### 什么是同步阻塞IO? 应用进程被阻塞,直到数据复制到应用进程缓冲区中才返回。 - **举例理解** 你早上去买有现炸油条,你点单,之后一直等店家做好,期间你啥其它事也做不了。(你就是应用级别,店家就是操作系统级别, 应用被阻塞了不能做其它事) - **Linux 中IO图例** ![img](https://b2files.173114.xyz/blogimg/2025/03/72bebd621376c95b46750ba2a2636528.png) #### 什么是同步非阻塞IO? 应用进程执行系统调用之后,内核返回一个错误码。应用进程可以继续执行,但是需要不断的执行系统调用来获知 I/O 是否完成,这种方式称为轮询(polling)。 - **举例理解** 你早上去买现炸油条,你点单,点完后每隔一段时间询问店家有没有做好,期间你可以做点其它事情。(你就是应用级别,店家就是操作系统级别,应用可以做其它事情并通过轮询来看操作系统是否完成) - **Linux 中IO图例** ![img](https://b2files.173114.xyz/blogimg/2025/03/1f59eda3a1d50459108d26a60964a72d.png) #### 什么是多路复用IO? 系统调用可能是由多个任务组成的,所以可以拆成多个任务,这就是多路复用。 - **举例理解** 你早上去买现炸油条,点单收钱和炸油条原来都是由一个人完成的,现在他成了瓶颈,所以专门找了个收银员下单收钱,他则专注在炸油条。(本质上炸油条是耗时的瓶颈,将他职责分离出不是瓶颈的部分,比如下单收银,对应到系统级别也时一样的意思) - **Linux 中IO图例** 使用 select 或者 poll 等待数据,并且可以等待多个套接字中的任何一个变为可读,这一过程会被阻塞,当某一个套接字可读时返回。之后再使用 recvfrom 把数据从内核复制到进程中。 它可以让单个进程具有处理多个 I/O 事件的能力。又被称为 Event Driven I/O,即事件驱动 I/O。 ![img](https://b2files.173114.xyz/blogimg/2025/03/3f71f559ff70411e3d33d28161b907bf.png) #### 有哪些多路复用IO? 目前流程的多路复用IO实现主要包括四种: `select`、`poll`、`epoll`、`kqueue`。下表是他们的一些重要特性的比较: | IO模型 | 相对性能 | 关键思路 | 操作系统 | JAVA支持情况 | | ------ | -------- | ---------------- | ------------- | ------------------------------------------------------------ | | select | 较高 | Reactor | windows/Linux | 支持,Reactor模式(反应器设计模式)。Linux操作系统的 kernels 2.4内核版本之前,默认使用select;而目前windows下对同步IO的支持,都是select模型 | | poll | 较高 | Reactor | Linux | Linux下的JAVA NIO框架,Linux kernels 2.6内核版本之前使用poll进行支持。也是使用的Reactor模式 | | epoll | 高 | Reactor/Proactor | Linux | Linux kernels 2.6内核版本及以后使用epoll进行支持;Linux kernels 2.6内核版本之前使用poll进行支持;另外一定注意,由于Linux下没有Windows下的IOCP技术提供真正的 异步IO 支持,所以Linux下使用epoll模拟异步IO | | kqueue | 高 | Proactor | Linux | 目前JAVA的版本不支持 | 多路复用IO技术最适用的是“高并发”场景,所谓高并发是指1毫秒内至少同时有上千个连接请求准备好。其他情况下多路复用IO技术发挥不出来它的优势。另一方面,使用JAVA NIO进行功能实现,相对于传统的Socket套接字实现要复杂一些,所以实际应用中,需要根据自己的业务需求进行技术选择。 #### 什么是信号驱动IO? 应用进程使用 sigaction 系统调用,内核立即返回,应用进程可以继续执行,也就是说等待数据阶段应用进程是非阻塞的。内核在数据到达时向应用进程发送 SIGIO 信号,应用进程收到之后在信号处理程序中调用 recvfrom 将数据从内核复制到应用进程中。 相比于非阻塞式 I/O 的轮询方式,信号驱动 I/O 的 CPU 利用率更高。 - **举例理解** 你早上去买现炸油条,门口排队的人多,现在引入了一个叫号系统,点完单后你就可以做自己的事情了,然后等叫号就去拿就可以了。(所以不用再去自己频繁跑去问有没有做好了) - **Linux 中IO图例** ![img](https://b2files.173114.xyz/blogimg/2025/03/52dddf2144b63584f2c9347d5bdd7f68.png) #### 什么是异步IO? 相对于同步IO,异步IO不是顺序执行。用户进程进行aio_read系统调用之后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程可以去做别的事情。等到socket数据准备好了,内核直接复制数据给进程,然后从内核向进程发送通知。IO两个阶段,进程都是非阻塞的。 - **举例理解** 你早上去买现炸油条, 不用去排队了,打开美团外卖下单,然后做其它事,一会外卖自己送上门。(你就是应用级别,店家就是操作系统级别, 应用无需阻塞,这就是非阻塞;系统还可能在处理中,但是立刻响应了应用,这就是异步) - **Linux 中IO图例** (Linux提供了AIO库函数实现异步,但是用的很少。目前有很多开源的异步IO库,例如libevent、libev、libuv) ![img](https://b2files.173114.xyz/blogimg/2025/03/014e3f14da0fc522196450e74463f684.png) #### 什么是Reactor模型? 大多数网络框架都是基于Reactor模型进行设计和开发,Reactor模型基于事件驱动,特别适合处理海量的I/O事件。 - **传统的IO模型**? 这种模式是传统设计,每一个请求到来时,大致都会按照:请求读取->请求解码->服务执行->编码响应->发送答复 这个流程去处理。 ![img](https://b2files.173114.xyz/blogimg/2025/03/e3435d682d6c6455355cb30eb261c1e8.png) 服务器会分配一个线程去处理,如果请求暴涨起来,那么意味着需要更多的线程来处理该请求。若请求出现暴涨,线程池的工作线程数量满载那么其它请求就会出现等待或者被抛弃。若每个小任务都可以使用非阻塞的模式,然后基于异步回调模式。这样就大大提高系统的吞吐量,这便引入了Reactor模型。 - **Reactor模型中定义的三种角色**: 1. **Reactor**:负责监听和分配事件,将I/O事件分派给对应的Handler。新的事件包含连接建立就绪、读就绪、写就绪等。 2. **Acceptor**:处理客户端新连接,并分派请求到处理器链中。 3. **Handler**:将自身与事件绑定,执行非阻塞读/写任务,完成channel的读入,完成处理业务逻辑后,负责将结果写出channel。可用资源池来管理。 - **单Reactor单线程模型** Reactor线程负责多路分离套接字,accept新连接,并分派请求到handler。Redis使用单Reactor单进程的模型。 ![img](https://b2files.173114.xyz/blogimg/2025/03/56c683b77082c846b3fff1a5e711589d.png) 消息处理流程: 1. Reactor对象通过select监控连接事件,收到事件后通过dispatch进行转发。 2. 如果是连接建立的事件,则由acceptor接受连接,并创建handler处理后续事件。 3. 如果不是建立连接事件,则Reactor会分发调用Handler来响应。 4. handler会完成read->业务处理->send的完整业务流程。 - **单Reactor多线程模型** 将handler的处理池化。 ![img](https://b2files.173114.xyz/blogimg/2025/03/396a1707faa152da5227fc196ea8427e.png) - **多Reactor多线程模型** 主从Reactor模型: 主Reactor用于响应连接请求,从Reactor用于处理IO操作请求,读写分离了。 ![img](https://b2files.173114.xyz/blogimg/2025/03/1fe78cdc15fc2131011095592a76fd5c.png) #### 什么是Java NIO? NIO主要有三大核心部分:Channel(通道),Buffer(缓冲区), Selector。**传统IO基于字节流和字符流进行操作**,而**NIO基于Channel和Buffer(缓冲区)进行操作**,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(选择区)用于监听多个通道的事件(比如:连接打开,数据到达)。因此,单个线程可以监听多个数据通道。 NIO和传统IO(一下简称IO)之间第一个最大的区别是,IO是面向流的,NIO是面向缓冲区的。 ![img](https://b2files.173114.xyz/blogimg/2025/03/f01a75dce5062ac8b31e5fb78eec67fa.png) ### 4.3 零拷贝 #### [#](https://pdai.tech/md/interview/x-interview.html#传统的io存在什么问题-为什么引入零拷贝的)传统的IO存在什么问题?为什么引入零拷贝的? 如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。 传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。 代码通常如下,一般会需要两个系统调用: ```c read(file, tmp_buf, len); write(socket, tmp_buf, len); ``` 代码很简单,虽然就两行代码,但是这里面发生了不少的事情。 ![img](https://b2files.173114.xyz/blogimg/2025/03/7f4423a9b07b6efec62da9b8ead8cb03.png) 首先,**期间共发生了 4 次用户态与内核态的上下文切换**,因为发生了两次系统调用,一次是 read() ,一次是 write(),每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。 上下文切换到成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。 其次,还发生了 **4 次数据拷贝**,其中**两次是 DMA 的拷贝**,另外**两次则是通过 CPU 拷贝**的,下面说一下这个过程: - **第一次拷贝**,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。 - **第二次拷贝**,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。 - **第三次拷贝**,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。 - **第四次拷贝**,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。 我们回过头看这个文件传输的过程,我们只是搬运一份数据,结果却搬运了 4 次,过多的数据拷贝无疑会消耗 CPU 资源,大大降低了系统性能。 这种简单又传统的文件传输方式,存在冗余的上文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。 所以,**要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数**。 #### mmap + write怎么实现的零拷贝? 在前面我们知道,read() 系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap() 替换 read() 系统调用函数。 ```c buf = mmap(file, len); write(sockfd, buf, len); ``` mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。 ![img](https://b2files.173114.xyz/blogimg/2025/03/665b6b7951011033f570d44fdc42bc74.png) 具体过程如下: - 应用进程调用了 mmap() 后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核「共享」这个缓冲区; - 应用进程再调用 write(),操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据; - 最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。 我们可以得知,通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程。 但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次。 #### sendfile怎么实现的零拷贝? 在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile(),函数形式如下: ```c #include ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count); ``` 它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。 首先,它可以替代前面的 read() 和 write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。 其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图: ![img](https://b2files.173114.xyz/blogimg/2025/03/2c6ecf312b82d5865673e094c67f4870.png) 但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(**The Scatter-Gather Direct Memory Access**)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。 你可以在你的 Linux 系统通过下面这个命令,查看网卡是否支持 scatter-gather 特性: ```c $ ethtool -k eth0 | grep scatter-gather scatter-gather: on ``` 于是,从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile() 系统调用的过程发生了点变化,具体过程如下: - 第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里; - 第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝; 所以,这个过程之中,只进行了 2 次数据拷贝,如下图: ![img](https://b2files.173114.xyz/blogimg/2025/03/c5dc7c70150fe98c131bbfb2a6f1ef73.png) 这就是所谓的**零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的**。 零拷贝技术的文件传输方式相比传统文件传输的方式,**减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运**。 ## 5 JVM和调优 > JVM虚拟机和调优相关。 ### 5.1 类加载机制 #### [#](https://pdai.tech/md/interview/x-interview.html#类加载的生命周期)类加载的生命周期? 其中类加载的过程包括了**加载、验证、准备、解析、初始化**五个阶段。在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)*。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。 ![img](https://b2files.173114.xyz/blogimg/2025/03/938e32576ac2e8a5330674ec1afd5d93.png) - 类的加载: 查找并加载类的二进制数据 - 连接 - 验证: 确保被加载的类的正确性 - 准备: 为类的静态变量分配内存,并将其初始化为默认值 - 解析: 把类中的符号引用转换为直接引用 - 初始化:为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。 - 使用: 类访问方法区内的数据结构的接口, 对象是Heap区的数据 - 卸载: 结束生命周期 #### 类加载器的层次? ![img](https://b2files.173114.xyz/blogimg/2025/03/fb3ff60c80c5b96d109be59e46e7fb30.png) - **启动类加载器**: Bootstrap ClassLoader,负责加载存放在JDK\jre\lib(JDK代表JDK的安装目录,下同)下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库(如rt.jar,所有的java.*开头的类均被Bootstrap ClassLoader加载)。启动类加载器是无法被Java程序直接引用的。 - **扩展类加载器**: Extension ClassLoader,该加载器由`sun.misc.Launcher$ExtClassLoader`实现,它负责加载JDK\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器。 - **应用程序类加载器**: Application ClassLoader,该类加载器由`sun.misc.Launcher$AppClassLoader`来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。 - **自定义类加载器**: 因为JVM自带的ClassLoader只是懂得从本地文件系统加载标准的java class文件,因此如果编写了自己的ClassLoader,便可以做到如下几点: - 在执行非置信代码之前,自动验证数字签名。 - 动态地创建符合用户特定需要的定制化构建类。 - 从特定的场所取得java class,例如数据库中和网络中。 #### Class.forName()和ClassLoader.loadClass()区别? - `Class.forName()`: 将类的.class文件加载到jvm中之外,还会对类进行解释,执行类中的static块; - `ClassLoader.loadClass()`: 只干一件事情,就是将.class文件加载到jvm中,不会执行static中的内容,只有在newInstance才会去执行static块。 - `Class.forName(name, initialize, loader)`带参函数也可控制是否加载static块。并且只有调用了newInstance()方法采用调用构造函数,创建类的对象 。 #### JVM有哪些类加载机制? - **JVM类加载机制有哪些**? 1. **全盘负责**,当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入 2. **父类委托**,先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类 3. **缓存机制**,缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效 4. **双亲委派机制**, 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。 - **双亲委派机制过程?** 1. 当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。 2. 当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。 3. 如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载; 4. 若ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。 ### 5.2 内存结构 #### 说说JVM内存整体的结构?线程私有还是共享的? JVM 整体架构,中间部分就是 Java 虚拟机定义的各种运行时数据区域。 ![jvm-memory-structure](https://b2files.173114.xyz/blogimg/2025/03/349ba45d008c3979602df9157b9597d7.jpg) Java 虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程一一对应的数据区域会随着线程开始和结束而创建和销毁。 - **线程私有**:程序计数器、虚拟机栈、本地方法区 - **线程共享**:堆、方法区, 堆外内存(Java7的永久代或JDK8的元空间、代码缓存) #### 什么是程序计数器(线程私有)? PC 寄存器用来存储指向下一条指令的地址,即将要执行的指令代码。由执行引擎读取下一条指令。 - **PC寄存器为什么会被设定为线程私有的?** 多线程在一个特定的时间段内只会执行其中某一个线程方法,CPU会不停的做任务切换,这样必然会导致经常中断或恢复。为了能够准确的记录各个线程正在执行的当前字节码指令地址,所以为每个线程都分配了一个PC寄存器,每个线程都独立计算,不会互相影响。 #### 什么是虚拟机栈(线程私有)? 主管 Java 程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。每个线程在创建的时候都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次 Java 方法调用,是线程私有的,生命周期和线程一致。 - **特点?** 1. 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器 2. JVM 直接对虚拟机栈的操作只有两个:每个方法执行,伴随着**入栈**(进栈/压栈),方法执行结束**出栈** 3. 栈不存在垃圾回收问题 4. 可以通过参数`-Xss`来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度 - **该区域有哪些异常**? 1. 如果采用固定大小的 Java 虚拟机栈,那每个线程的 Java 虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过 Java 虚拟机栈允许的最大容量,Java 虚拟机将会抛出一个 **StackOverflowError** 异常 2. 如果 Java 虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那 Java 虚拟机将会抛出一个**OutOfMemoryError**异常 - **栈帧的内部结构?** 1. 局部变量表(Local Variables) 2. 操作数栈(Operand Stack)(或称为表达式栈) 3. 动态链接(Dynamic Linking):指向运行时常量池的方法引用 4. 方法返回地址(Return Address):方法正常退出或异常退出的地址 5. 一些附加信息 ![jvm-stack](https://b2files.173114.xyz/blogimg/2025/03/d56db4f1e02265ca20870fa5ca5dbc11.jpg) #### Java虚拟机栈如何进行方法计算的? 以如下代码为例: ```java private static int add(int a, int b) { int c = 0; c = a + b; return c; } ``` 可以通过jsclass 等工具查看bytecode ![img](https://b2files.173114.xyz/blogimg/2025/03/f3b9c1353fdd2e9f9e7723f1ff6f8f25.png) 压栈的步骤如下: ```java 0: iconst_0 // 0压栈 1: istore_2 // 弹出int,存放于局部变量2 2: iload_0 // 把局部变量0压栈 3: iload_1 // 局部变量1压栈 4: iadd //弹出2个变量,求和,结果压栈 5: istore_2 //弹出结果,放于局部变量2 6: iload_2 //局部变量2压栈 7: ireturn //返回 ``` 如果计算100+98的值,那么操作数栈的变化如下图 ![img](https://b2files.173114.xyz/blogimg/2025/03/a968e2c20822af3419cceea3fe681b9e.png) #### 什么是本地方法栈(线程私有)? - **本地方法接口** 一个 Native Method 就是一个 Java 调用非 Java 代码的接口。我们知道的 Unsafe 类就有很多本地方法。 - **本地方法栈(Native Method Stack)** Java 虚拟机栈用于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用 #### 什么是方法区(线程共享)? 方法区(method area)只是 **JVM 规范**中定义的一个概念,用于存储类信息、常量池、静态变量、JIT编译后的代码等数据,并没有规定如何去实现它,不同的厂商有不同的实现。而**永久代(PermGen)\**是 \*\*Hotspot\*\* 虚拟机特有的概念, Java8 的时候又被\**元空间**取代了,永久代和元空间都可以理解为方法区的落地实现。 JDK1.8之前调节方法区大小: ```bash -XX:PermSize=N //方法区(永久代)初始大小 -XX:MaxPermSize=N //方法区(永久代)最大大小,超出这个值将会抛出OutOfMemoryError ``` JDK1.8开始方法区(HotSpot的永久代)被彻底删除了,取而代之的是元空间,元空间直接使用的是本机内存。参数设置: ```bash -XX:MetaspaceSize=N //设置Metaspace的初始(和最小大小) -XX:MaxMetaspaceSize=N //设置Metaspace的最大大小 ``` **栈、堆、方法区的交互关系** ![img](https://b2files.173114.xyz/blogimg/2025/03/e70400fd99edf6f7301d60e20f04a6a0.png) #### 永久代和元空间内存使用上的差异? Java虚拟机规范中只定义了方法区用于存储已被虚拟机加载的类信息、常量、静态变量和即时编译后的代码等数据 1. jdk1.7开始符号引用存储在native heap中,字符串常量和静态类型变量存储在普通的堆区中,但分离的并不彻底,此时永久代中还保存另一些与类的元数据无关的杂项 2. jdk8后HotSpot 原永久代中存储的类的**元数据将存储在metaspace**中,而**类的静态变量和字符串常量将放在Java堆中**,metaspace是方法区的一种实现,只不过它使用的不是虚拟机内的内存,而是本地内存。在元空间中保存的数据比永久代中纯粹很多,就只是类的元数据,这些信息只对编译期或JVM的运行时有用。 3. 永久代有一个JVM本身设置固定大小上线,无法进行调整,而**元空间使用的是直接内存,受本机可用内存的限制,并且永远不会得到java.lang.OutOfMemoryError**。 4. **符号引用没有存在元空间中,而是存在native heap中**,这是两个方式和位置,不过都可以算作是本地内存,在虚拟机之外进行划分,没有设置限制参数时只受物理内存大小限制,即只有占满了操作系统可用内存后才OOM。 #### 堆区内存是怎么细分的? 对于大多数应用,Java 堆是 Java 虚拟机管理的内存中最大的一块,被所有线程共享。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数据都在这里分配内存。 为了进行高效的垃圾回收,虚拟机把堆内存**逻辑上**划分成三块区域(分代的唯一理由就是优化 GC 性能): 1. 新生带(年轻代):新对象和没达到一定年龄的对象都在新生代 2. 老年代(养老区):被长时间使用的对象,老年代的内存空间应该要比年轻代更大 ![jvm-memory](https://b2files.173114.xyz/blogimg/2025/03/2fbbc559cdf20242c200b7c85c941ec0.png) Java 虚拟机规范规定,Java 堆可以是处于物理上不连续的内存空间中,只要逻辑上是连续的即可,像磁盘空间一样。实现时,既可以是固定大小,也可以是可扩展的,主流虚拟机都是可扩展的(通过 `-Xmx` 和 `-Xms` 控制),如果堆中没有完成实例分配,并且堆无法再扩展时,就会抛出 `OutOfMemoryError` 异常。 - **年轻代 (Young Generation)** 年轻代是所有新对象创建的地方。当填充年轻代时,执行垃圾收集。这种垃圾收集称为 **Minor GC**。年轻一代被分为三个部分——伊甸园(**Eden Memory**)和两个幸存区(**Survivor Memory**,被称为from/to或s0/s1),默认比例是`8:1:1` 1. 大多数新创建的对象都位于 Eden 内存空间中 2. 当 Eden 空间被对象填充时,执行**Minor GC**,并将所有幸存者对象移动到一个幸存者空间中 3. Minor GC 检查幸存者对象,并将它们移动到另一个幸存者空间。所以每次,一个幸存者空间总是空的 4. 经过多次 GC 循环后存活下来的对象被移动到老年代。通常,这是通过设置年轻一代对象的年龄阈值来实现的,然后他们才有资格提升到老一代 - **老年代(Old Generation)** 旧的一代内存包含那些经过许多轮小型 GC 后仍然存活的对象。通常,垃圾收集是在老年代内存满时执行的。老年代垃圾收集称为 主GC(Major GC),通常需要更长的时间。 大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。这样做的目的是避免在 Eden 区和两个Survivor 区之间发生大量的内存拷贝 #### JVM中对象在堆中的生命周期? 1. 在 JVM 内存模型的堆中,堆被划分为新生代和老年代 - 新生代又被进一步划分为 **Eden区** 和 **Survivor区**,Survivor 区由 **From Survivor** 和 **To Survivor** 组成 2. 当创建一个对象时,对象会被优先分配到新生代的 Eden 区 - 此时 JVM 会给对象定义一个**对象年轻计数器**(`-XX:MaxTenuringThreshold`) 3. 当 Eden 空间不足时,JVM 将执行新生代的垃圾回收(Minor GC) - JVM 会把存活的对象转移到 Survivor 中,并且对象年龄 +1 - 对象在 Survivor 中同样也会经历 Minor GC,每经历一次 Minor GC,对象年龄都会+1 4. 如果分配的对象超过了`-XX:PetenureSizeThreshold`,对象会**直接被分配到老年代** #### JVM中对象的分配过程? 为对象分配内存是一件非常严谨和复杂的任务,JVM 的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法和内存回收算法密切相关,所以还需要考虑 GC 执行完内存回收后是否会在内存空间中产生内存碎片。 1. new 的对象先放在伊甸园区,此区有大小限制 2. 当伊甸园的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区 3. 然后将伊甸园中的剩余对象移动到幸存者 0 区 4. 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者 0 区,如果没有回收,就会放到幸存者 1 区 5. 如果再次经历垃圾回收,此时会重新放回幸存者 0 区,接着再去幸存者 1 区 6. 什么时候才会去养老区呢? 默认是 15 次回收标记 7. 在养老区,相对悠闲。当养老区内存不足时,再次触发 Major GC,进行养老区的内存清理 8. 若养老区执行了 Major GC 之后发现依然无法进行对象的保存,就会产生 OOM 异常 #### 什么是 TLAB (Thread Local Allocation Buffer)? - 从内存模型而不是垃圾回收的角度,对 Eden 区域继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内 - 多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为**快速分配策略** - OpenJDK 衍生出来的 JVM 大都提供了 TLAB 设计 #### 为什么要有 TLAB ? - 堆区是线程共享的,任何线程都可以访问到堆区中的共享数据 - 由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的 - 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度 尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选。 在程序中,可以通过 `-XX:UseTLAB` 设置是否开启 TLAB 空间。 默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,我们可以通过 `-XX:TLABWasteTargetPercent` 设置 TLAB 空间所占用 Eden 空间的百分比大小。 一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。 ### 5.3 GC垃圾回收 #### 如何判断一个对象是否可以回收? - **引用计数算法** 给对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。 两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。 正因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法。 - **可达性分析算法** 通过 GC Roots 作为起始点进行搜索,能够到达到的对象都是存活的,不可达的对象可被回收。 ![image](https://b2files.173114.xyz/blogimg/2025/03/e9e1029af98b1b016472a96b5e684718.png) Java 虚拟机使用该算法来判断对象是否可被回收,在 Java 中 GC Roots 一般包含以下内容: - 虚拟机栈中引用的对象 - 本地方法栈中引用的对象 - 方法区中类静态属性引用的对象 - 方法区中的常量引用的对象 #### 对象有哪些引用类型? 无论是通过引用计算算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。 Java 具有四种强度不同的引用类型。 - **强引用** 被强引用关联的对象不会被回收。 使用 new 一个新对象的方式来创建强引用。 ```java Object obj = new Object(); ``` - **软引用** 被软引用关联的对象只有在内存不够的情况下才会被回收。 使用 SoftReference 类来创建软引用。 ```java Object obj = new Object(); SoftReference sf = new SoftReference(obj); obj = null; // 使对象只被软引用关联 ``` - **弱引用** 被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。 使用 WeakReference 类来实现弱引用。 ```java Object obj = new Object(); WeakReference wf = new WeakReference(obj); obj = null; ``` - **虚引用** 又称为幽灵引用或者幻影引用。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象。 为一个对象设置虚引用关联的唯一目的就是能在这个对象被回收时收到一个系统通知。 使用 PhantomReference 来实现虚引用。 ```java Object obj = new Object(); PhantomReference pf = new PhantomReference(obj); obj = null; ``` #### 有哪些基本的垃圾回收算法? - **标记 - 清除** ![image](https://pdai.tech/images/pics/a4248c4b-6c1d-4fb8-a557-86da92d3a294.jpg) 将存活的对象进行标记,然后清理掉未被标记的对象。 不足: - 标记和清除过程效率都不高; - 会产生大量不连续的内存碎片,导致无法给大对象分配内存。 - **标记 - 整理** ![image](https://pdai.tech/images/pics/902b83ab-8054-4bd2-898f-9a4a0fe52830.jpg) 让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。 - **复制** ![image](https://pdai.tech/images/pics/e6b733ad-606d-4028-b3e8-83c3a73a3797.jpg) 将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。 主要不足是只使用了内存的一半。 现在的商业虚拟机都采用这种收集算法来回收新生代,但是并不是将新生代划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和使用过的那一块 Survivor。 HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间存储放不下的对象。 - **分代收集** 现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。 一般将堆分为新生代和老年代。 - 新生代使用: 复制算法 - 老年代使用: 标记 - 清除 或者 标记 - 整理 算法 #### 分代收集算法和分区收集算法区别? ![img](https://b2files.173114.xyz/blogimg/2025/03/4e3fa436564caa5ddbc428ea8ee785c7.jpg) - **分代收集算法** 当前主流 VM 垃圾收集都采用”分代收集”(Generational Collection)算法, 这种算法会根据 对象存活周期的不同将内存划分为几块, 如 JVM 中的 新生代、老年代、永久代,这样就可以根据 各年代特点分别采用最适当的 GC 算法 在新生代-复制算法: 每次垃圾收集都能发现大批对象已死, 只有少量存活. 因此选用复制算法, 只需要付出少量 存活对象的复制成本就可以完成收集 在老年代-标记整理算法: 因为对象存活率高、没有额外空间对它进行分配担保, 就必须采用“标记—清理”或“标 记—整理”算法来进行回收, 不必进行内存复制, 且直接腾出空闲内存. 1. **ParNew**: 一款多线程的收集器,采用复制算法,主要工作在 Young 区,可以通过 `-XX:ParallelGCThreads` 参数来控制收集的线程数,整个过程都是 STW 的,常与 CMS 组合使用。 2. **CMS**: 以获取最短回收停顿时间为目标,采用“标记-清除”算法,分 4 大步进行垃圾收集,其中初始标记和重新标记会 STW ,多数应用于互联网站或者 B/S 系统的服务器端上,JDK9 被标记弃用,JDK14 被删除。 - **分区收集算法** 分区算法则将整个堆空间划分为连续的不同小区间, 每个小区间独立使用, 独立回收. 这样做的 好处是可以控制一次回收多少个小区间 , 根据目标停顿时间, 每次合理地回收若干个小区间(而不是 整个堆), 从而减少一次 GC 所产生的停顿。 1. **G1**: 一种服务器端的垃圾收集器,应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能地满足垃圾收集暂停时间的要求。 2. **ZGC**: JDK11 中推出的一款低延迟垃圾回收器,适用于大内存低延迟服务的内存管理和回收,SPECjbb 2015 基准测试,在 128G 的大堆下,最大停顿时间才 1.68 ms,停顿时间远胜于 G1 和 CMS。 #### 什么是Minor GC、Major GC、Full GC? JVM 在进行 GC 时,并非每次都对堆内存(新生代、老年代;方法区)区域一起回收的,大部分时候回收的都是指新生代。 针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大类:部分收集(Partial GC),整堆收集(Full GC) - 部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为: - 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集 - 老年代收集(Major GC/Old GC):只是老年代的垃圾收集 - 目前,只有 CMS GC 会有单独收集老年代的行为 - 很多时候 Major GC 会和 Full GC 混合使用,需要具体分辨是老年代回收还是整堆回收 - 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集 - 目前只有 G1 GC 会有这种行为 - 整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾