Java 基础面试总结

编程知识 更新时间:2023-04-16 17:03:31

Java 基础

2.1.1. 面向对象和面向过程的区别

  • ⾯向过程⾯向过程性能⽐⾯向对象⾼。 因为类调⽤时需要实例化,开销⽐᫾⼤,⽐᫾消耗资源,所以当性能是最重要的考量因素的时候,⽐如单⽚机、嵌⼊式开发、Linux/Unix 等⼀般采⽤⾯向过程开发。但是,⾯向过程没有⾯向对象易维护、易复⽤、易扩展。

  • ⾯向对象 :面对象就是万物皆对象,能与现实生活中的各个实体一一对应,包括它的行为,动作也能抽象出来。⾯向对象易维护、易复⽤、易扩展。 因为⾯向对象有封装、继承、多态性的特性,所以可以设计出低耦合的系统,使系统更加灵活、更加易于维护。但是,⾯向对象性能****⽐⾯向过程低

⾯向过程 :⾯向过程性能⽐⾯向对象⾼?

这个并不是根本原因,⾯向过程也需要分配内存,计算内存偏移量,Java 性能差的主要原因并不是因为它是⾯向对象语⾔,⽽是 Java 是半编译语⾔,最终的执⾏代码并不是可以直接被 CPU 执⾏的⼆进制机械码。⽽⾯向过程语⾔⼤多都是直接编译成机械码在电脑上执⾏,并且其它⼀些⾯向过程的脚本语⾔性能也并不⼀定⽐ Java 好。

什么是面对像:

对象就是面向对象程序设计的核心。所谓对象就是真实世界中的实体,对象与实体是一一对应的,也就是说现实世界中每一个实体都是一个对象,它是一种具体的概念。对象有以下特点:

  • 对象具有属性和行为。

  • 对象具有变化的状态。

  • 对象具有唯一性。

  • 对象都是某个类别的实例。

  • 一切皆为对象,真实世界中的所有事物都可以视为对象。

    特点:

    1. 可重用性:代码重复使用,减少代码量,提高开发效率。下面介绍的面向对象的三大核心特性(继承、封装和多态)都围绕这个核心。
    2. 可扩展性:指新的功能可以很容易地加入到系统中来,便于软件的修改。
    3. 可管理性:能够将功能与数据结合,方便管理。

2.1.2. Java 语言有哪些特点?

  1. 简单易学;

  2. 面向对象(封装,继承,多态);

  3. 平台无关性( Java 虚拟机实现平台无关性);

  4. 可靠性;

  5. 安全性;

  6. 支持多线程( C++ 语言没有内置的多线程机制,因此必须调用操作系统的多线程功能来进
    行多线程程序设计,而 Java 语言却提供了多线程支持);

  7. 支持网络编程并且很方便( Java 语言诞生本身就是为简化网络编程设计的,因此 Java 语
    言不仅支持网络编程而且很方便);

  8. 编译与解释并存;

2.1.3. 关于 JVM JDK 和 JRE 最详细通俗的解答

2.1.3.1. JVM

Java 虚拟机(JVM)是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。

什么是字节码?采用字节码的好处是什么?

在 Java 中,JVM 可以理解的代码就叫做 (即扩展名为 .class 的文件),它不面向任何特定的处理器,只面向虚拟机。Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以 Java 程序运行时比较高效,而且,由于字节码并不针对一种特定的机器,因此,Java 程序无须重新编译便可在多种不同操作系统的计算机上运行

Java 程序从源代码到运行一般有下面 3 步:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-R3NO4QJp-1676365602968)(E:\新建文件夹\面试题\总结面试\Java 基础面试总结.assets\image-20220604095427936.png)]

总结:

Java 虚拟机(JVM)是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语言“一次编译,随处可以运行”的关键所在。

2.1.3.2. JDK 和 JRE

  • JDK 是 Java Development Kit,它是功能⻬全的 Java SDK。它包含了JRE,还有编译器(javac)和工具(如 javadoc 和 jdb)。它能够创建和编译程序。

  • JRE 是 Java 运行时环境。它是运行已编译 Java 程序所需的所有内容的集合,包括 Java 虚拟机(JVM),Java 类库java 命令和其他的一些基础构件。但是,它不能用于创建新程序。

如果你只是为了运行一下 Java 程序的话,那么你只需要安装 JRE 就可以了。如果你需要进行一些 Java 编程方面的工作,那么你就需要安装 JDK 了。但是,这不是绝对的。有时,即使您不打算在计算机上进行任何 Java 开发,仍然需要安装 JDK。例如,如果要使用 JSP 部署 Web 应用程序,那么从技术上讲,您只是在应用程序服务器中运行 Java 程序。那你为什么需要 JDK 呢?因为应用程序服务器会将 JSP 转换为 Java servlet,并且需要使用 JDK 来编译 servlet。

2.1.4. Oracle JDK 和 OpenJDK 的对比

可能在看这个问题之前很多人和我一样并没有接触和使用过 OpenJDK 。那么 Oracle 和OpenJDK 之间是否存在重大差异?下面我通过收集到的一些资料,为你解答这个被很多人忽视的问题。

对于 Java 7,没什么关键的地方。OpenJDK 项目主要基于 Sun 捐赠的 HotSpot 源代码。此外,OpenJDK 被选为 Java 7 的参考实现,由 Oracle 工程师维护。关于 JVM,JDK,JRE 和OpenJDK 之间的区别,Oracle 博客帖子在 2012 年有一个更详细的答案:

问:OpenJDK 存储库中的源代码与用于构建 Oracle JDK 的代码之间有什么区别?

答:非常接近 - 我们的 Oracle JDK 版本构建过程基于 OpenJDK 7 构建,只添加了几个部分,例如部署代码,其中包括 Oracle 的 Java 插件和 Java WebStart 的实现,以及一些封闭的源代码派对组件,如图形光栅化器,一些开源的第三方组件,如 Rhino,以及一些零碎的东⻄,如附加文档或第三方字体。展望未来,我们的目的是开源 Oracle JDK 的所有部分,除了我们考虑商业功能的部分。

总结:

  1. Oracle JDK 大概每 6 个月发一次主要版本,而 OpenJDK 版本大概每三个月发布一次。但
    这不是固定的,我觉得了解这个没啥用处。详情参⻅:https://blogs.oracle/java-platfor
    m-group/update-and-faq-on-the-java-se-release-cadence。

  2. OpenJDK 是一个参考模型并且是完全开源的,而 Oracle JDK 是 OpenJDK 的一个实现,并不是完全开源的

  3. Oracle JDK 比 OpenJDK 更稳定。OpenJDK 和 Oracle JDK 的代码几乎相同,但 OracleDK 有更多的类和一些错误修复。因此,如果您想开发企业/商业软件,我建议您选择Oracle JDK,因为它经过了彻底的测试和稳定。某些情况下,有些人提到在使用 OpenJDK可能会遇到了许多应用程序崩溃的问题,但是,只需切换到 Oracle JDK 就可以解决问题;

  4. 在响应性和 JVM 性能方面,Oracle JDK 与 OpenJDK 相比提供了更好的性能;

  5. Oracle JDK 不会为即将发布的版本提供⻓期支持,用户每次都必须通过更新到最新版本获
    得支持来获取最新版本;

  6. Oracle JDK 根据二进制代码许可协议获得许可,而 OpenJDK 根据 GPL v2 许可获得许可。

2.1.5. Java 和 C++的区别?

我知道很多人没学过 C++,但是面试官就是没事喜欢拿咱们 Java 和 C++ 比呀!没办法!!!就
算没学过 C++,也要记下来!

  1. 都是面向对象的语言,都支持封装、继承和多态
  2. Java 不提供指针来直接访问内存,程序内存更加安全
  3. Java 的类是单继承的,C++ 支持多重继承;虽然 Java 的类不可以多继承,但是接口可以多继承。
  4. Java 有自动内存管理机制,不需要程序员手动释放无用内存
  5. 在 C 语言中,字符串或字符数组最后都会有一个额外的字符‘\0’来表示结束。但是,Java 语言中没有结束符这一概念。 这是一个值得深度思考的问题,具体原因推荐看这篇文章:https://blog.csdn/sszgg2006/article/details/49148189

2.1.6. 字符型常量和字符串常量的区别?

  1. 形式上: 字符常量是单引号引起的一个字符; 字符串常量是双引号引起的若干个字符

  2. 含义上: 字符常量相当于一个整型值( ASCII 值),可以参加表达式运算; 字符串常量代表一个地址值(该字符串在内存中存放位置)

  3. 占内存大小 字符常量只占 2 个字节; 字符串常量占若干个字节 ( 注意: char 在 Java 中占两****个字节 )

2.1.7. 构造器 Constructor 是否可被 override?

Constructor 不能被 override(重写),但是可以 overload(重载),所以你可以看到一个类中有多个构造函数的情况。

2.1.8. 重载和重写的区别

重载就是同样的一个方法能够根据输入数据的不同,做出不同的处理。重写就是当子类继承自父类的相同方法,输入数据一样,但要做出有别于父类的响应时,你

就要覆盖父类方法.

重载:

发生在同一个类中,方法名必须相同,参数类型不同、个数不同、顺序不同,方法返回值和访问修饰符可以不同。

综上:重载就是同一个类中多个同名方法根据不同的传参来执行不同的逻辑处理。

重写:

重写发生在运行期,是子类对父类的允许访问的方法的实现过程进行重新编写。

  1. 返回值类型、方法名、参数列表必须相同,抛出的异常范围小于等于父类,访问修饰符范围大于等于父类

  2. 如果父类方法访问修饰符为 private/final/static 则子类就不能重写该方法,但是被 static 修饰
    的方法能够被再次声明。

  3. 构造方法无法被重写

综上:重写就是子类对父类方法的重新改造,外部样子不能改变,内部逻辑可以改变

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6IwY1LiF-1676365602970)(E:\新建文件夹\面试题\总结面试\Java 基础面试总结.assets\image-20220604103935427.png)]

2.1.9. Java 面向对象编程三大特性: 封装 继承 多态

2.1.9.1. 封装

封装,也就是把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏。

封装把一个**对象的属性私有化,**同时提供一些可以被外界访问的属性的方法,如果属性不想被外界访问,我们大可不必提供方法给外界访问。但是如果一个类没有提供给外界访问的方法,那么这个类也没有什么意义了。

2.1.9.2. 继承

继承是使用已存在的类的定义作为基础建立新类的技术,新类的定义可以**增加新的数据或新的功能,也可以用父类的功能,**但不能选择性地继承父类。通过使用继承我们能够非常方便地复用以前的代码。

关于继承如下 3 点请记住:

  1. 子类拥有父类对象所有的属性和方法(包括私有属性和私有方法),但是父类中的私有属性和方法子类是无法访问, 只是拥有 。

  2. 子类可以拥有自己属性和方法,即子类可以对父类进行扩展。

  3. 子类可以用自己的方式实现父类的方法。(以后介绍)。

2.1.9.3. 多态

所谓多态就是指程序中定义的引用变量所指向的具体类型和通过该引用变量发出的方法调用在编程时并不确定,而是在程序运行期间才确定,即一个引用变量到底会指向哪个类的实例对象,该引用变量发出的方法调用到底是哪个类中实现的方法,必须在由程序运行期间才能决定。

在 Java 中有两种形式可以实现多态:继承(多个子类对同一方法的重写)和接口(实现接口并
覆盖接口中同一方法)。

2.1.10. String StringBuffer 和 StringBuilder 的区别是什么?

可变性:

简单的来说:String 类中使用 final 关键字修饰字符数组来保存字符串, private final charvalue[] ,所以 String 对象是不可变的。

  • 补充(来自issue 675):在 Java 9 之后,String 类的实现改用 byte 数组存储字符串private final byte[] value

而 StringBuilder 与 StringBuffer 都继承自 ==AbstractStringBuilder 类,==在 AbstractStringBuilder 中也是使用字符数组保存字符串char[]value 但是没有用 final 关键字修饰,所以这两种对象都是可变的。StringBuilder 与 StringBuffer 的构造方法都是调用父类构造方法也就是 AbstractStringBuilder 实现的,大家可以自行查阅源码。

abstract class AbstractStringBuilder implements Appendable, CharSequence {
/**
* The value is used for character storage.
*/
char[] value;
/**
* The count is the number of characters used.
*/
int count;
AbstractStringBuilder(int capacity) {
value = new char[capacity];
}
线程安全性:

String 中的对象是不可变的,也就可以理解为常量,线程安全。

AbstractStringBuilder 是StringBuilder 与 StringBuffer 的公共父类,定义了一些字符串的基本操作,如 expandCapacity、append、insert、indexOf 等公共方法。StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。

性能:

每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String对象。StringBuffer 每次都会对 StringBuffer 对象本身进行操作,而不是生成新的对象并改变对象引用。相同情况下使用 StringBuilder 相比使用 StringBuffer 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的⻛险。

对于三者使用的总结:

  1. 操作少量的数据: 适用 String
  2. 单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder
  3. 多线程操作字符串缓冲区下操作大量数据: 适用 StringBuffer

2.1.11. 自动装箱与拆箱

装箱 :将基本类型用它们对应的引用类型包装起来;

拆箱 :将包装类型转换为基本数据类型;

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JNMSNYFF-1676365602970)(E:\新建文件夹\面试题\总结面试\Java 基础面试总结.assets\image-20220604143207367.png)]

更多内容⻅:深入剖析 Java 中的装箱和拆箱

基本类型和包装类型的区别主要有以下 几点

  • 包装类型可以为 null,而基本类型不可以。它使得包装类型可以应用于 POJO 中,而基本类型则不行。那为什么 POJO 的属性必须要用包装类型呢?《阿里巴巴 Java 开发手册》上有详细的说明, 数据库的查询结果可能是 null,如果使用基本类型的话,因为要自动拆箱(将包装类型转为基本类型,比如说把 Integer 对象转换成 int 值),就会抛出 NullPointerException 的异常。

  • 包装类型可用于泛型,而基本类型不可以。泛型不能使用基本类型,因为使用基本类型时会编译出错。

    List<int> list = new ArrayList<>(); // 提示 Syntax error, insert "Dimensions" to complete ReferenceType
    List<Integer> list = new ArrayList<>();
    

    因为泛型在编译时会进行类型擦除,最后只保留原始类型,而原始类型只能是 Object 类及其子类——基本类型是个特例。

  • 基本类型比包装类型更高效。基本类型在栈中直接存储的具体数值,而包装类型则存储的是堆中的引用。 很显然,相比较于基本类型而言,包装类型需要占用更多的内存空间。

2.1.12. 在一个静态方法内调用一个非静态成员为什么是非法的?(先说两个的创建和内存储存区域)

静态方法是属于类的,即静态方法是随着类的加载而加载的,在加载类时,程序就会为静态方法分配内存,而非静态方法是属于对象的,对象是在类加载之后创建的,也就是说静态方法先于对象存在当你创建一个对象时,程序为其在堆中分配内存,一般是通过this指针来指向该对象。静态方法不依赖于对象的调用,它是通过‘类名.静态方法名’这样的方式来调用的。而对于非静态方法,在对象创建的时候程序才会为其分配内存,然后通过类的对象去访问非静态方法。因此在对象未存在时非静态方法也不存在,静态方法自然不能调用一个不存在的方法。

2.1.13. 在 Java 中定义一个不做事且没有参数的构造方法的作用

Java 程序在执行子类的构造方法之前,如果没有用 super() 来调用父类特定的构造方法,则会调用父类中“没有参数的构造方法”。因此,如果父类中只定义了有参数的构造方法,而在子类的构造方法中又没有用 super() 来调用父类中特定的构造方法,则编译时将发生错误,因为 Java 程序在父类中找不到没有参数的构造方法可供执行。解决办法是在父类里加上一个不做事且没有参数的构造方法。

2.1.14. 接口和抽象类的区别是什么?

  1. 接口的方法默认是 public ,所有方法在接口中不能有实现都是抽象方法(Java 8 开始接口方法可以有默认实现),而抽象类可以有非抽象的方法。
  2. 接口中除了 static 、 final 变量,不能有其他变量,而抽象类中则不一定。
  3. 一个类可以实现多个接口,但只能实现一个抽象类。接口自己本身可以通过 extends 关键
    字扩展多个接口。
  4. 接口方法默认修饰符是 public,抽象方法可以有 public**、 protected 和 default 这些修饰**
    符(抽象方法就是为了被重写所以不能使用 private 关键字修饰!)。
  5. 从设计层面来说,抽象是对类的抽象,是一种模板设计,==而接口是对行为的抽象,==是一种行
    为的规范。

备注:

  1. 在 JDK8 中,接口也可以定义静态方法,可以直接用接口名调用。实现类和实现是不可以调用的。如果同时实现两个接口,接口中定义了一样的默认方法,则必须重写,不然会报错。(详⻅ issue:https://github/Snailclimb/JavaGuide/issues/146。

  2. jdk9 的接口被允许定义私有方法 。

总结一下 jdk7~jdk9 Java 中接口概念的变化(相关阅读):

  1. 在 jdk 7 或更早版本中,接口里面只能有常量变量和抽象方法。这些接口方法必须由选择实
    现接口的类实现。
  2. jdk 8 的时候接口可以有默认方法静态方法功能。
  3. Jdk 9 在接口中引入了私有方法和私有静态方法。

2.1.15. 成员变量与局部变量的区别有哪些?

  1. 从语法形式上看:成员变量是属于类的,而局部变量是在方法中定义的变量或是方法的参数;

  2. 成员变量可以被 public ,private ,static 等修饰符所修饰,而局部变量不能被访问控制修饰
    符及 static 所修饰;但是,成员变量和局部变量都能被 final 所修饰。

  3. 从变量在内存中的存储方式来看:如果成员变量是使用static 修饰的,那么这个成员变量是属于类的,如果没有使用static 修饰,这个成员变量是属于实例的。对象存于堆内存,如果局部变量类型为基本数据类型,那么存储在栈内存,如果为引用数据类型,那存放的**是指向堆****内存对象的引用或者是指向常量池中的地址。

  4. 从变量在内存中的生存时间上看**:成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动消失。**

  5. 成员变量如果没有被赋初值:则会自动以类型的默认值而赋值(一种情况例外:被 final 修饰的成员变量也必须显式地赋值),而局部变量则不会自动赋值

2.1.16. 创建一个对象用什么运算符?对象实体与对象引用有何不同?

new 运算符,new 创建对象实例(对象实例在堆内存中),对象引用指向对象实例(对象引用存放在栈内存中)。一个对象引用可以指向 0 个或 1 个对象(一根绳子可以不系气球,也可以系一个气球);一个对象可以有 n 个引用指向它(可以用 n 条绳子系住一个气球)。

2.1.17. 什么是方法的返回值?返回值在类的方法里的作用是什么?

方法的返回值是指我们获取到的某个方法体中的代码执行后产生的结果!(前提是该方法可能产生结果)。返回值的作用:接收出结果,使得它可以用于其他的操作!

2.1.18. 一个类的构造方法的作用是什么? 若一个类没有声明构造方法,该程序能正确执行吗? 为什么?

主要作用是完成对类对象的初始化工作。可以执行。因为一个类即使没有声明构造方法也会有默认的不带参数的构造方法。

2.1.19. 构造方法有哪些特性?

  1. 名字与类名相同。

  2. **没有返回值,**但不能用 void 声明构造函数。

  3. 生成类的对象时自动执行,无需调用

2.1.20. 静态方法和实例方法有何不同

  1. 在外部调用静态方法时,可以使用"类名.方法名"的方式,也可以使用"对象名.方法名"的方式。而实例方法只有后面这种方式。也就是说,调用静态方法可以无需创建对象。

  2. 静态方法在访问本类的成员时,只允许访问静态成员(即静态成员变量和静态方法),而不允许访问实例成员变量和实例方法;实例方法则无此限制。

2.1.21. 对象的相等与指向他们的引用相等,两者有什么不同?

对象的相等,比的是内存中存放的内容是否相等。而引用相等,比较的是他们指向的内存地址是否相等。

2.1.22. 在调用子类构造方法之前会先调用父类没有参数的构造方法,

其目的是?

帮助子类做初始化工作。+

2.1.23. == 与 equals(重要)

== : 它的作用是判断两个对象的地址是不是相等。即,判断两个对象是不是同一个对象(基本数据类型比较的是值,引用数据类型比较的是内存地址)。

equals() : 它的作用也是判断两个对象是否相等。但它一般有两种使用情况:

情况 1 :类没有覆盖 equals() 方法。则通过 equals() 比较该类的两个对象时,等价于通过“==”比较这两个对象。
情况 2 :类覆盖了 equals() 方法。一般,我们都覆盖 equals() 方法来比较两个对象的内容是否相等;若它们的内容相等,则返回 true (即,认为这两个对象相等)。

2.1.24. hashCode 与 equals (重要)

面试官可能会问你:“你重写过 hashcode 和 equals么,为什么重写 equals 时必须重写hashCode 方法?”

1)hashCode()介绍:

hashCode() 的作用是获取哈希码,也称为散列码;它实际上是返回一个 int 整数。这个哈希码的作用是确定该对象在哈希表中的索引位置。hashCode() 定义在 JDK 的 Object 类中,这就意味着 Java 中的任何类都包含有 hashCode() 函数。另外需要注意的是: Object 的 hashcode 方法是本地方法,也就是用 c 语言或 c++ 实现的,该方法通常用来将对象的 内存地址 转换为整数之后返回。

散列表存储的是键值对(key-value),它的特点是:能根据“键”快速的检索出对应的“值”。这其中就利用到了散列码!(可以快速找到所需要的对象)

2)为什么要有 hashCode?

我们以“ HashSet 如何检查重复”为例子来说明为什么要有 hashCode?

当你把对象加入 HashSet 时, HashSet 会先计算对象的 hashcode 值来判断对象加入的位置,同时也会与其他已经加入的对象的 hashcode 值作比较,如果没有相符的 hashcode, HashSet会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用 equals() 方法来检查 hashcode 相等的对象是否真的相同。如果两者相同, HashSet 就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。(摘自我的 Java 启蒙书《Head First Java》第二版)。这样我们就大大减少了 equals 的次数,相应就大大提高了执行速度。

3)为什么重写 equals 时必须重写 hashCode 方法?

如果两个对象equals比较相等,则 hashcode 一定也是相同的。两个对象相等,对两个对象分别调用 equals方法都返回 true。但是,两个对象有相同的 hashcode 值,它们也不一定是相等的 。 因此,equals 方法被覆盖过,则 hashCode 方法也必须被覆盖。

hashCode()的默认行为是对堆上的对象产生独特值。如果没有重写 hashCode() ,则该class 的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)

4)为什么两个对象有相同的 hashcode 值,它们也不一定是相等的?

public native int hashCode();

在这里解释一位小伙伴的问题。以下内容摘自《Head Fisrt Java》。

因为 hashCode() 所使用的杂凑算法也许刚好会让多个对象传回相同的杂凑值。越糟糕的杂凑算法越容易碰撞,但这也与数据值域分布的特性有关(所谓碰撞也就是指的是不同的对象得到相同的 hashCode。

我们刚刚也提到了 HashSet,如果 HashSet 在对比的时候,同样的 hashcode 有多个对象,它会使用 equals() 来判断是否真的相同。也就是说 hashcode 只是用来缩小查找成本。

2.1.25. 为什么 Java 中只有值传递?

首先回顾一下在程序设计语言中有关将参数传递给方法(或函数)的一些专业术语。 按值调用

(call by value)表示方法接收的是调用者提供的值,而按引用调用(call by reference)表示方法接收的是调用者提供的变量地址。一个方法可以修改传递引用所对应的变量值,而不能修改传递值调用所对应的变量值。 它用来描述各种程序设计语言(不只是 Java)中方法参数传递方式。

Java 程序设计语言总是采用按值调用。也就是说,方法得到的是所有参数值的一个拷⻉,也就是说,方法不能修改传递给它的任何参数变量的内容。

下面通过 3 个例子来给大家说明

详见JavaGuide

2.1.26. 简述线程、程序、进程的基本概念。以及他们之间关系是什

么?

线程 与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享同一块内存空间和一组系统资源,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。

程序 是含有指令和数据的文件,被存储在磁盘或其他的数据存储设备中,也就是说程序是静态的代码。

进程 是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。简单来说,一个进程就是一个执行中的程序,它在计算机中一个指令接着一个指令地执行着,同时,每个进程还占有某些系统资源如 CPU 时间,内存空间,文件,输入输出设备的使用权等等。换句话说,当程序在执行时,将会被操作系统载入内存中。

线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。从另一⻆度来说,进程属于操作系统的范畴,主要是同一段时间内,可以同时执行一个以上的程序,而线程则是在同一程序内几乎同时执行一个以上的程序段。

2.1.27. 线程有哪些基本状态?

2.1.28. 关于 final 关键字的一些总结

final 关键字主要用在三个地方:变量、方法、类。

  1. 对于一个 final 变量,如果是基本数据类型的变量,则其数值一旦在初始化之后便不能更改;如果是引用类型的变量,则在对其初始化之后便不能再让其指向另一个对象。
  2. 当用 final 修饰一个类时,表明这个类不能被继承。final 类中的所有成员方法都会被隐式地指定为 final 方法。
  3. 使用 final 方法的原因有两个。第一个原因是把方法锁定,以防任何继承类修改它的含义;第二个原因是效率。在早期的 Java 实现版本中,会将 final 方法转为内嵌调用。但是如果方法过于庞大,可能看不到内嵌调用带来的任何性能提升(现在的 Java 版本已经不需要使用final 方法进行这些优化了)。类中所有的 private 方法都隐式地指定为 final。

2.1.29. Java 中的异常处理

2.1.29.1. Java 异常类层次结构图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GfC94MDb-1676365602971)(E:\新建文件夹\面试题\总结面试\Java 基础面试总结.assets\image-20220604193002365.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bJXf2ntC-1676365602971)(Java 基础面试总结.assets/image-20220604193248521.png)]

在 Java 中,所有的异常都有一个共同的祖先 java.lang 包中的 Throwable 类。 Throwable 类有两个重要的子类 Exception (异常)和 Error (错误)。 Exception 能被程序本身处理( try-catch ), Error 是无法处理的(只能尽量避免)。Exception 和 Error 二者都是 Java 异常处理的重要子类,各自都包含大量子类。

Exception :

  • 程序本身可以处理的异常,可以通过 catch 来进行捕获。 Exception 又可以分为 受检查异常(必须处理) 和 不受检查异常(可以不处理)。

Error :

  • Error 属于程序无法处理的错误 ,我们没办法通过 catch 来进行捕获 。例如,Java 虚拟机运行错误( Virtual MachineError )、虚拟机内存不够错误

( OutOfMemoryError )、类定义错误( NoClassDefFoundError )等 。这些异常发生时,Java虚拟机(JVM)一般会选择线程终止。

StackOverflowError
原因 : 函数调用栈太深了,注意代码中是否有了循环调用方法而无法退出的情况

原理:
StackOverflowError 是一个java中常出现的错误:在jvm运行时的数据区域中有一个java虚拟机栈,当执行java方法时会进行压栈弹栈的操作。在栈中会保存局部变量,操作数栈,方法出口等等。jvm规定了栈的最大深度,当执行时栈的深度大于了规定的深度,就会抛出StackOverflowError错误

受检查异常

Java 代码在编译过程中,如果受检查异常没有被 catch /throw 处理的话,就没办法通过编译。比如下面这段 IO 操作的代码。

除了 RuntimeException及其子类以外,其他的 Exception 类及其子类都属于检查异常 。常⻅的受检查异常有: IO 相关的异常、 ClassNotFoundException 、SQLException …。

不受检查异常

Java 代码在编译过程中 ,我们即使不处理不受检查异常也可以正常通过编译。

RuntimeException 及其子类都统称为非受检查异常,例如: **NullPointExecrption、 NumberFormatException (字符串转换为数字)、ArrayIndexOutOfBoundsException(数组越界)、 ClassCastException (类型转换错误)、ArithmeticException (算术错误)**等。

2.1.29.2. Throwable 类常用方法

  • public string getMessage() :返回异常发生时的简要描述
  • public string toString() :返回异常发生时的详细信息
  • public string getLocalizedMessage() :返回异常对象的本地化信息。使用 Throwable 的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与getMessage 返回的结果相同
  • public void printStackTrace() :在控制台上打印 Throwable 对象封装的异常信息

2.1.29.3. 异常处理总结

try 块: 用于捕获异常。其后可接零个或多个 catch 块,如果没有 catch 块,则必须跟一个 finally 块。
catch 块: 用于处理 try 捕获到的异常。
finally 块: 无论是否捕获或处理异常,finally 块里的语句都会被执行。当在 try 块或catch 块中遇到 return 语句时, finally 语句块将在方法返回之前被执行。

在以下 3 种特殊情况下, finally 块不会被执行:

  1. 在 try 或 finally 块中用了 System.exit(int) 退出程序。但是,如果 System.exit(int) 在异常
    语句之后,finally 还是会被执行
  2. 程序所在的线程死亡。
  3. 关闭 CPU。

下面这部分内容来自 issue:https://github/Snailclimb/JavaGuide/issues/190。

注意: 当 try 语句和 finally 语句中都有 return 语句时,在方法返回之前,finally 语句的内容将被
执行,并且 finally 语句的返回值将会覆盖原始的返回值。如下:

如果调用 f(2),返回值将是 0 ,因为 finally 语句的返回值覆盖了 try 语句块的返回值。

2.1.30. Java 序列化中如果有些字段不想进行序列化,怎么办?

对于不想进行序列化的变量,使用 transient 关键字修饰。

transient 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient 修饰的变量值不会被持久化和恢复。transient 只能修饰变量,不能修饰类和方
法。

2.1.31. 获取用键盘输入常用的两种方法

方法 1 :通过 Scanner

Scanner input = new Scanner(System.in);
String s = input.nextLine();
input.close();

方法 2 :通过 BufferedReader

BufferedReader input = new BufferedReader(new InputStreamReader(System.in));
String s = input.readLine();				

2.1.32. Java 中 IO 流

2.1.32.1. Java 中 IO 流分为几种?

按照流的流向分,可以分为输入流和输出流

按照操作单元划分,可以划分为字节流和字符流;

按照流的⻆色划分为节点流和处理流

Java Io 流共涉及 40 多个类,这些类看上去很杂乱,但实际上很有规则,而且彼此之间存在非常紧密的联系, Java I0 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。

InputStream/Reader: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。
OutputStream/Writer: 所有输出流的基类,前者是字节输出流,后者是字符输出流。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-61Y44o9o-1676365602972)(Java 基础面试总结.assets/image-20220604195752218.png)]

按操作方式分类结构图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vHdryRtc-1676365602972)(Java 基础面试总结.assets/image-20220604195817882.png)]

按操作对象分类结构图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vgv5Ar3i-1676365602973)(Java 基础面试总结.assets/image-20220604195834701.png)]

2.1.32.2. 既然有了字节流,为什么还要有字符流?

问题本质想问: 不管是文件读写还是网络发送接收,信息的最小存储单元都是字节,那为什么I/O 流操作要分为字节流操作和字符流操作呢?

回答:字符流是由 Java 虚拟机将字节转换得到的,问题就出在这个过程还算是非常耗时,并且,如果我们不知道编码类型就很容易出现乱码问题。所以, I/O 流就干脆提供了一个直接操作字符的接口,方便我们平时对字符进行流操作。如果音频文件、图片等媒体文件用字节流比较好,如果涉及到字符的话使用字符流比较好。

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

BIO (Blocking I/O):

同步阻塞 I/O 模式,数据的读取写入必须阻塞在一个线程内等待其完成。在活动连接数不是特别高(小于单机 1000 )的情况下,这种模型是比较不错的,可以让每一个连接专注于自己的 I/O 并且编程模型简单,也不用过多考虑系统的过载、限流等问题。线程池本身就是一个天然的漏斗,可以缓冲一些系统处理不了的连接或请求。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。
NIO (Non-blocking/New I/O):

NIO 是一种同步非阻塞的 I/O 模型,在 Java 1.4 中引入了NIO 框架,对应 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它支持面向缓冲的,基于通道的 I/O 操作方法。NIO 提供了与传统 BIO 模型中的 SocketerverSocket 相对应的 SocketChannel 和ServerSocketChannel 两种不同的套接字通道实现,两种通道都支持阻塞和非阻塞两种模式。阻塞模式使用就像传统中的支持一样,比较简单,但是性能和可靠性都不好;非阻塞模式正好与之相反。对于低负载、低并发的应用程序,可以使用同步阻塞 I/O 来提升开发速率和更好的维护性;对于高负载、高并发的(网络)应用,应使用 NIO 的非阻塞模式来开发
AIO (Asynchronous I/O):

AIO 也就是 NIO 2。在 Java 7 中引入了 NIO 的改进版 NIO 2,它是异步非阻塞的 IO 模型。异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。AIO 是异步 IO 的缩写,虽然 NIO 在网络操作中,提供了非阻塞的方法,但是 NIO 的 IO 行为还是同步的。对于 NIO 来说,我们的业务线程是在 IO 操作准备好时,得到通知,接着就由这个线程自行进行 IO 操作,IO 操作本身是同步的。查阅网上相关资料,我发现就目前来说 AIO 的应用还不是很广泛,Netty 之前也尝试使用过 AIO,不过又放弃了。

2.1.33. 深拷⻉ vs 浅拷⻉

  1. 浅拷⻉ :对基本数据类型进行值传递,对引用数据类型进行引用传递般的拷⻉,此为浅拷⻉。

  2. 深拷⻉ :对基本数据类型进行值传递,对引用数据类型,创建一个新的对象,并复制其内容,此为深拷⻉。

2.1.34. 参考

https://stackoverflow/questions/1906445/what-is-the-difference-between-jdk-and-jre
https://www.educba/oracle-vs-openjdk/
https://stackoverflow/questions/22358071/differences-between-oracle-jdk-and-openjdk
?answertab=active#tab-top

final、finally、finalize的区别?

final 用于修饰变量、方法和类。

  • final 变量:被修饰的变量不可变,不可变分为引用不可变对象不可变,final 指的是引用不可变,final 修饰的变量必须初始化,通常称被修饰的变量为常量
  • final 方法:被修饰的方法不允许任何子类重写,子类可以使用该方法。
  • final 类:被修饰的类不能被继承,所有方法不能被重写。

finally 作为异常处理的一部分,它只能在 try/catch 语句中,并且附带一个语句块表示这段语句最终一定被执行(无论是否抛出异常),经常被用在需要释放资源的情况下,System.exit (0) 可以阻断 finally 执行。

finalize 是在 java.lang.Object 里定义的方法,也就是说每一个对象都有这么个方法,这个方法在 gc 启动,该对象被回收的时候被调用

一个对象的 finalize 方法只会被调用一次,finalize 被调用不一定会立即回收该对象,所以有可能调用 finalize 后,该对象又不需要被回收了,然后到了真正要被回收的时候,因为前面调用过一次,所以不会再次调用 finalize 了,进而产生问题,因此不推荐使用 finalize 方法。

为什么要用static关键字?

通常来说,用new创建类的对象时,数据存储空间才被分配,方法才供外界调用。但有时我们只想为特定域分配单一存储空间,不考虑要创建多少对象或者说根本就不创建任何对象,再就是我们想在没有创建对象的情况下也想调用方法。在这两种情况下,static关键字,满足了我们的需求。

Java中变量的初始化顺序

继承关系初始化顺序

父类静态成员,静态代码块 -》 子类静态成员,静态代码块 -》 父类实例变量(属性,实例代码块,构造方法)-》子类实例变量(属性,实例代码块,构造方法)

Java中赋值顺序:

1. 父类的静态变量赋值 
2. 自身的静态变量赋值 
3. 父类成员变量赋值 
4. 父类块赋值 
5. 父类构造函数赋值 
6. 自身成员变量赋值 
7. 自身块赋值 
8. 自身构造函数赋值

先说结论:静态变量初始零值 -> 静态变量显式赋值 -> 静态代码块赋值 -> 实例变量默认零值 -> 构造代码块赋值 -> 构造函数赋值

如果考虑到父类,其初始化顺序为:父类静态变量初始零值 -> 父类静态变量显式赋值 -> 父类静态代码块赋值 -> 子类静态变量初始零值 -> 子类静态变量显式赋值 -> 子类静态代码块赋值 -> 实例变量默认零值 -> 父类构造代码块赋值 -> 父类构造函数赋值 -> 子类构造代码块赋值 -> 子类构造函数赋值

class Parent {
    private int p1 = 100; //实例变量显式初始化
    private static int p2 = 10; //静态变量显式初始化

    {
        p1 = 101; //构造代码块初始化
    }

    static {
        p2 = 11; //静态代码块初始化
    }

    public Parent() {  //构造函数初始化
        this.p1 = 102;
        this.p2 = 12;
    }

}

public class Son extends Parent{

    private int s1 = 100;
    private static int s2 = 10;

    {
        s1 = 101;
    }

    static {
        s2 = 11;
    }

    public Son() {
        this.s1 = 102;
        this.s2 = 12;
    }

    public static void main(String[] args) {
        Son son = new Son();
    }
}

上述代码赋值流程:

  • p2 = 0 (父类加载的准备阶段,静态变量设置默认零值)
  • p2 = 10 , p2 = 11(父类加载的初始化阶段,执行clinit)
  • s2 = 0 (子类加载的准备阶段,静态变量设置默认零值)
  • s2 = 10 , s2 = 11(子类加载的初始化阶段,执行clinit)
  • p1 = 0,s1 = 0 (子类对象在堆空间分配,实例字段设置默认零值,这里实例字段也包括从父类中继承来的字段)
  • p1 = 100, p1 = 101, p1 = 102,p2 = 12 (父类对象执行init函数,包括显式初始化,构造代码块和构造函数)
  • s1 = 100, s1 = 101, s1 = 102,s2 = 12 (子类对象执行init函数,包括显式初始化,构造代码块和构造函数)

java静态变量、代码块、和静态方法的执行顺序是什么?

基本上代码块分为三种:Static静态代码块、构造代码块、普通代码块

代码块执行顺序静态代码块——> 构造代码块 ——> 构造函数——> 普通代码块

继承中代码块执行顺序:父类静态块——>子类静态块——>父类代码块——>父类构造器——>子类代码块——>子类构造器

Java语言是如何实现多态的?

本质上多态分两种:

1、编译时多态(又称静态多态)

2、运行时多态(又称动态多态)

重载(overload)就是编译时多态的一个例子,编译时多态在编译时就已经确定,运行的时候调用的是确定的方法。

**我们通常所说的多态指的都是运行时多态,也就是编译时不确定究竟调用哪个具体方法,一直延迟到运行时才能确定。**这也是为什么有时候多态方法又被称为延迟方法的原因。

Java实现多态有 3 个必要条件:继承、重写和向上转型。只有满足这 3 个条件,开发人员才能够在同一个继承结构中使用统一的逻辑实现代码处理不同的对象,从而执行不同的行为。

  • 继承:在多态中必须存在有继承关系的子类和父类。
  • 重写:子类对父类中某些方法进行重新定义,在调用这些方法时就会调用子类的方法。
  • 向上转型:在多态中需要将子类的引用赋给父类对象,只有这样该引用才既能可以调用父类的方法,又能调用子类的方法。

Java多态的实现原理可看这篇文章:https://my.oschina/u/4432600/blog/4535042

重载的方法能否根据返回值类型进行区分?

**不能根据返回值类型来区分重载的方法。**因为调用时不指定类型信息,编译器不知道你要调用哪个函数。

float max(int a, int b);
int max(int a, int b);

当调用max(1,2);时无法确定调用的是哪个,单从这一点上来说,仅返回值类型不同的重载是不应该允许的。

java 创建对象有哪几种方式?

java中提供了以下四种创建对象的方式:

  • new创建新对象
  • 通过反射机制
  • 采用clone机制
  • 通过序列化机制

前两者都需要显式地调用构造方法。对于clone机制,需要注意浅拷贝和深拷贝的区别,对于序列化机制需要明确其实现原理,在java中序列化可以通过实现Externalizable或者Serializable来实现。

String为什么要设计成不可变的?

1.便于实现字符串池(String pool)

在Java中,由于会大量的使用String常量,如果每一次声明一个String都创建一个String对象,那将会造成极大的空间资源的浪费。Java提出了String pool的概念,在堆中开辟一块存储空间String pool,当初始化一个String变量时,如果该字符串已经存在了,就不会去创建一个新的字符串变量,而是会返回已经存在了的字符串的引用。

String a = "Hello world!";
String b = "Hello world!";

如果字符串是可变的,某一个字符串变量改变了其值,那么其指向的变量的值也会改变,String pool将不能够实现!

2.使多线程安全

在并发场景下,多个线程同时读一个资源,是安全的,不会引发竞争,但对资源进行写操作时是不安全的,不可变对象不能被写,所以保证了多线程的安全

3.避免安全问题

在网络连接和数据库连接中字符串常常作为参数,例如,网络连接地址URL,文件路径path,反射机制所需要的String参数。==其不可变性可以保证连接的安全性。==如果字符串是可变的,黑客就有可能改变字符串指向对象的值,那么会引起很严重的安全问题。

4.加快字符串处理速度

由于==String是不可变的,保证了hashcode的唯一性,==于是在创建对象时其hashcode就可以放心的缓存了,**不需要重新计算。**这也就是Map喜欢将String作为Key的原因,处理速度要快过其它的键对象。所以HashMap中的键往往都使用String。

总体来说,String不可变的原因要包括 设计考虑,效率优化,以及安全性这三大方面。

String str="aaa"与 String str=new String(“aaa”)一样吗?new String(“aaa”);创建了几个字符串对象?

  • 使用String a = “aaa” ;,程序运行时会在常量池中查找”aaa”字符串,若没有,会将”aaa”字符串放进常量池,再将其地址赋给a;若有,将找到的”aaa”字符串的地址赋给a。
  • 使用String b = new String(“aaa”);`,程序会在堆内存中开辟一片新空间存放新对象,同时会将”aaa”字符串放入常量池,相当于创建了两个对象,无论常量池中有没有”aaa”字符串,程序都会在堆内存中开辟一片新空间存放新对象。

String的intern函数:
因为不是所有的字符串创建方式都会在常量池中生成对应的字符串,而intern函数用于根据所给的字符串在常量池中创建对应的字符串。jdk7以后,调用intern方法时,如果该字符串已经存在于常量池中,则将常量池中的引用直接返回;如果不存在,则在常量池中生成一个对原字符串的引用

String s = new String("hello");
String str1 =  s+ "world";//通过创建Stringbuilder来实现拼接,这种情况下并不会在常量池中生成“helloworld”字符串。
String str3 = "helloworld";
	

str3==str1是否相等???答案是 false

原因:str1指向的的是堆中的StringBuilder对象,而str3指向的是常量池中的“helloworld”,所以两者不是同一对象

什么是字符串常量池?

java中常量池的概念主要有三个:全局字符串常量池class文件常量池运行时常量池。我们现在所说的就是全局字符串常量池,对这个想弄明白的同学可以看这篇Java中几种常量池的区分。

jvm为了提升性能和减少内存开销,避免字符的重复创建,其维护了一块特殊的内存空间,即字符串池,当需要使用字符串时,先去字符串池中查看该字符串是否已经存在,如果存在,则可以直接使用,如果不存在,初始化,并将该字符串放入字符串常量池中。

字符串常量池的位置也是随着jdk版本的不同而位置不同。在jdk6中,常量池的位置在永久代(方法区)中,此时常量池中存储的是对象。在jdk7中,常量池的位置在堆中,此时,常量池存储的就是引用了。在jdk8中,永久代(方法区)被元空间取代了。

总结全局字符串常量池class文件常量池运行时常量池

https://blog.csdn/u011069294/article/details/107415210

  • 1.全局常量池在每个VM中只有一份,存放的是字符串常量的引用值。
  • 2.class常量池是在编译的时候每个class都有的,在编译阶段,存放的是常量的符号引用。
  • 3.运行时常量池是在类加载完成之后,将每个class常量池中的符号引用值转

在使用 HashMap 的时候,用 String 做 key 有什么好处?

HashMap 内部实现是通过 key 的 hashcode 来确定 value 的存储位置,因为字符串是不可变的,所以当创建字符串时,它的 hashcode 被缓存下来,不需要再次计算,所以相比于其他对象更快。

​ 包装类型

包装类型是什么?基本类型和包装类型有什么区别?

Java 为每一个基本数据类型都引入了对应的包装类型(wrapper class),int 的包装类就是 Integer,从 Java 5 开始引入了自动装箱/拆箱机制,把基本类型转换成包装类型的过程叫做装箱(boxing);反之,把包装类型转换成基本类型的过程叫做拆箱(unboxing),使得二者可以相互转换。

Java 为每个原始类型提供了包装类型:

原始类型: boolean,char,byte,short,int,long,float,double

包装类型:Boolean,Character,Byte,Short,Integer,Long,Float,Double

基本类型和包装类型的区别主要有以下 几点

  • 包装类型可以为 null,而基本类型不可以。它使得包装类型可以应用于 POJO 中,而基本类型则不行。那为什么 POJO 的属性必须要用包装类型呢?《阿里巴巴 Java 开发手册》上有详细的说明, 数据库的查询结果可能是 null,如果使用基本类型的话,因为要自动拆箱(将包装类型转为基本类型,比如说把 Integer 对象转换成 int 值),就会抛出 NullPointerException 的异常。

  • 包装类型可用于泛型,而基本类型不可以。泛型不能使用基本类型,因为使用基本类型时会编译出错。

    List<int> list = new ArrayList<>(); // 提示 Syntax error, insert "Dimensions" to complete ReferenceType
    List<Integer> list = new ArrayList<>();
    

    因为泛型在编译时会进行类型擦除,最后只保留原始类型,而原始类型只能是 Object 类及其子类——基本类型是个特例。

  • 基本类型比包装类型更高效。基本类型在栈中直接存储的具体数值,而包装类型则存储的是堆中的引用。 很显然,相比较于基本类型而言,包装类型需要占用更多的内存空间。

解释一下自动装箱和自动拆箱?

自动装箱:将基本数据类型重新转化为对象

    public class Test {  
        public static void main(String[] args) {  
            // 声明一个Integer对象,用到了自动的装箱:解析为:Integer num = Integer.valueOf(9);
	        Integer num = 9;
        }  
    }  

9是属于基本数据类型的,原则上它是不能直接赋值给一个对象Integer的。但jdk1.5 开始引入了自动装箱/拆箱机制,就可以进行这样的声明,自动将基本数据类型转化为对应的封装类型,成为一个对象以后就可以调用对象所声明的所有的方法。

自动拆箱:将对象重新转化为基本数据类型

 public class Test {  
        public static void main(String[] args) {  
            / /声明一个Integer对象
	        Integer num = 9;
            
            // 进行计算时隐含的有自动拆箱
		    System.out.print(num--);
        }  
    }  

因为对象时不能直接进行运算的,而是要转化为基本数据类型后才能进行加减乘除

int 和 Integer 有什么区别?

  • Integer是int的包装类;int是基本数据类型;
  • Integer变量必须实例化后才能使用;int变量不需要;
  • Integer实际是对象的引用,指向此new的Integer对象;int是直接存储数据值 ;
  • Integer的默认值是null;int的默认值是0。

两个new生成的Integer变量的对比

由于Integer变量实际上是对一个Integer对象的引用,所以两个通过new生成的Integer变量永远是不相等的(因为new生成的是两个对象,其内存地址不同)。

Integer i = new Integer(10000);
Integer j = new Integer(10000);
System.out.print(i == j); //false

Integer变量和int变量的对比

Integer变量和int变量比较时,只要两个变量的值是向等的,则结果为true(因为包装类Integer和基本数据类型int比较时,java会自动拆包装为int,然后进行比较,实际上就变为两个int变量的比较)

    int a = 10000;
    Integer b = new Integer(10000);
    Integer c=10000;
    System.out.println(a == b); // true
    System.out.println(a == c); // true

非new生成的Integer变量和new Integer()生成变量的对比

非new生成的Integer变量和new Integer()生成的变量比较时,结果为false。(因为非new生成的Integer变量指向的是java常量池中的对象,而new Integer()生成的变量指向堆中新建的对象,两者在内存中的地址不同)

    Integer b = new Integer(10000);
    Integer c=10000;
    System.out.println(b == c); // false

两个非new生成的Integer对象的对比

对于两个非new生成的Integer对象,进行比较时,如果两个变量的值在区间-128到127之间,则比较结果为true,如果两个变量的值不在此区间,则比较结果为false

Integer i = 100;
Integer j = 100;
System.out.print(i == j); //true

Integer i = 128;
Integer j = 128;
System.out.print(i == j); //false

当值在 -128 ~ 127之间时,java会进行自动装箱,然后会对值进行缓存,如果下次再有相同的值,会直接在缓存中取出使用。缓存是通过Integer的内部类IntegerCache来完成的。当值超出此范围,会在堆中new出一个对象来存储。

给一个Integer对象赋一个int值的时候,会调用Integer类的静态方法valueOf,源码如下:

public static Integer valueOf(String s, int radix) throws NumberFormatException {
        return Integer.valueOf(parseInt(s,radix));
    }
/**
 * (1)在-128~127之内:静态常量池中cache数组是static final类型,cache数组对象会被存储于静态常量池中。
 * cache数组里面的元素却不是static final类型,而是cache[k] = new Integer(j++),
 * 那么这些元素是存储于堆中,只是cache数组对象存储的是指向了堆中的Integer对象(引用地址)
 * 
 * (2)在-128~127 之外:新建一个 Integer对象,并返回。
 */
public static Integer valueOf(int i) {
        assert IntegerCache.high >= 127;
        if (i >= IntegerCache.low && i <= IntegerCache.high) {
            return IntegerCache.cache[i + (-IntegerCache.low)];
        }
        return new Integer(i);
    }

IntegerCache是Integer的内部类,源码如下:

     /**
      * 缓存支持自动装箱的对象标识语义 -128和127(含)。
      * 缓存在第一次使用时初始化。 缓存的大小可以由-XX:AutoBoxCacheMax = <size>选项控制。
      * 在VM初始化期间,java.lang.Integer.IntegerCache.high属性可以设置并保存在私有系统属性中
     */
    private static class IntegerCache {
        static final int low = -128;
        static final int high;
        static final Integer cache[];

        static {
            // high value may be configured by property
            int h = 127;
            String integerCacheHighPropValue =
                sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
            if (integerCacheHighPropValue != null) {
                int i = parseInt(integerCacheHighPropValue);
                i = Math.max(i, 127);
                // Maximum array size is Integer.MAX_VALUE
                h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
            }
            high = h;

            cache = new Integer[(high - low) + 1];
            int j = low;
            for(int k = 0; k < cache.length; k++) {
                cache[k] = new Integer(j++); // 创建一个对象
            }
        }

        private IntegerCache() {}
    }

comparable和comparator接口是干什么的

Comparable接口功能类似一个比较器,如果想对对象进行比较、排序,要实现Comparable接口

接口只包含一个compareTo()方法,这个方法通过两个对象相减进行比较,它会返回负数,0,正数 来表明输入对象小于,等于,大于已经存在的对象。

@Data
public class student implements Comparable<student> {
    private int age;
    //定义比较规则
    @Override
    public int compareTo(student other) {
        return this.getAge()-other.getAge();
    }
}

Comparator接口是一个比较器,当我们需要控制某个类的次序,而这个类是不支持排序的(没有实现comparable接口),那我们用比较器来进行排序

**compare(**T o1 , T o2):比较两个参数的顺序。实际效果也是相减得到值,负数、0、正数

**equals(Object obj):**比较某个对象是否等于此比较器

1、Comparable 更像是自然排序

2、Comparator 更像是定制排序

同时存在时采用 Comparator(定制排序)的规则进行比较。

对于一些普通的数据类型(比如 String, Integer, Double…),它们默认实现了Comparable 接口,实现了 compareTo 方法,我们可以直接使用。

而对于一些自定义类,它们可能在不同情况下需要实现不同的比较策略,我们可以新创建 Comparator 接口,然后使用特定的 Comparator 实现进行比较。

反射

什么是反射?

反射是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为 Java 语言的反射机制。

反射机制的优缺点有哪些?

优点:能够运行时动态获取类的实例提高灵活性可与动态编译结合Class.forName('com.mysql.jdbc.Driver.class');,加载MySQL的驱动类。

缺点:使用反射性能较低,需要解析字节码,将内存中的对象进行解析。其解决方案是:通过setAccessible(true)关闭JDK的安全检查来提升反射速度;多次创建一个类的实例时,有缓存会快很多;ReflflectASM工具类,通过字节码生成的方式加快反射速度。

如何获取反射中的Class对象?

  1. Class.forName(“类的路径”);当你知道该类的全路径名时,你可以使用该方法获取 Class 类对象。

    Class clz = Class.forName("java.lang.String");
    
  2. 类名.class。这种方法只适合在编译前就知道操作的 Class。

    Class clz = String.class;
    
  3. 对象名.getClass()。

    String str = new String("Hello");
    Class clz = str.getClass();
    
  4. 如果是基本类型的包装类,可以调用包装类的Type属性来获得该包装类的Class对象。

Java反射API有几类?

反射 API 用来生成 JVM 中的类、接口或则对象的信息。

  • Class 类:反射的核心类,可以获取类的属性,方法等信息。

  • Field 类:Java.lang.reflec 包中的类,表示类的成员变量,可以用来获取和设置类之中的属性值。

  • Method 类:Java.lang.reflec 包中的类,表示类的方法,它可以用来获取类中的方法信息或者执行方法。

  • Constructor 类:Java.lang.reflec 包中的类,表示类的构造方法。

反射使用的步骤?

  1. 获取想要操作的类的Class对象,这是反射的核心,通过Class对象我们可以任意调用类的方法。

  2. 调用 Class 类中的方法,既就是反射的使用阶段。

  3. 使用反射 API 来操作这些信息。

具体可以看下面的例子:

public class Apple {

    private int price;

    public int getPrice() {
        return price;
    }

    public void setPrice(int price) {
        this.price = price;
    }

    public static void main(String[] args) throws Exception{
        //正常的调用
        Apple apple = new Apple();
        apple.setPrice(5);
        System.out.println("Apple Price:" + apple.getPrice());
        //使用反射调用
        Class clz = Class.forName("com.chenshuyi.api.Apple");
        Method setPriceMethod = clz.getMethod("setPrice", int.class);
        Constructor appleConstructor = clz.getConstructor();
        Object appleObj = appleConstructor.newInstance();
        setPriceMethod.invoke(appleObj, 14);
        Method getPriceMethod = clz.getMethod("getPrice");
        System.out.println("Apple Price:" + getPriceMethod.invoke(appleObj));
    }
}

从代码中可以看到我们使用反射调用了 setPrice 方法,并传递了 14 的值。之后使用反射调用了 getPrice 方法,输出其价格。上面的代码整个的输出结果是:

Apple Price:5
Apple Price:14

从这个简单的例子可以看出,一般情况下我们使用反射获取一个对象的步骤:

  • 获取类的 Class 对象实例
Class clz = Class.forName("com.zhenai.api.Apple");
  • 根据 Class 对象实例获取 Constructor 对象
Constructor appleConstructor = clz.getConstructor();
  • 使用 Constructor 对象的 newInstance 方法获取反射类对象
Object appleObj = appleConstructor.newInstance();

而如果要调用某一个方法,则需要经过下面的步骤:

  • 获取方法的 Method 对象
Method setPriceMethod = clz.getMethod("setPrice", int.class);
  • 利用 invoke 方法调用方法
setPriceMethod.invoke(appleObj, 14);

为什么引入反射概念?反射机制的应用有哪些?

我们来看一下 Oracle 官方文档中对反射的描述:

从 Oracle 官方文档中可以看出,反射主要应用在以下几方面:

  • 反射让开发人员可以通过外部类的全路径名创建对象,并使用这些类,实现一些扩展的功能。
  • 反射让开发人员可以枚举出类的全部成员,包括构造函数、属性、方法。以帮助开发者写出正确的代码。
  • 测试时可以利用反射 API 访问类的私有成员,以保证测试代码覆盖率。

也就是说,Oracle 希望开发者将反射作为一个工具,用来帮助程序员实现本不可能实现的功能。

举两个最常见使用反射的例子,来说明反射机制的强大之处:

第一种:JDBC 的数据库的连接

在JDBC 的操作中,如果要想进行数据库的连接,则必须按照以上的几步完成

  1. 通过Class.forName()加载数据库的驱动程序 (通过反射加载,前提是引入相关了Jar包);
  2. 通过 DriverManager 类进行数据库的连接,连接的时候要输入数据库的连接地址、用户名、密码;
  3. 通过Connection 接口接收连接
public class ConnectionJDBC {  
  
    /** 
     * @param args 
     */  
    //驱动程序就是之前在classpath中配置的JDBC的驱动程序的JAR 包中  
    public static final String DBDRIVER = "com.mysql.jdbc.Driver";  
    //连接地址是由各个数据库生产商单独提供的,所以需要单独记住  
    public static final String DBURL = "jdbc:mysql://localhost:3306/test";  
    //连接数据库的用户名  
    public static final String DBUSER = "root";  
    //连接数据库的密码  
    public static final String DBPASS = "";  
      
      
    public static void main(String[] args) throws Exception {  
        Connection con = null; //表示数据库的连接对象  
        Class.forName(DBDRIVER); //1、使用CLASS 类加载驱动程序 ,反射机制的体现 
        con = DriverManager.getConnection(DBURL,DBUSER,DBPASS); //2、连接数据库  
        System.out.println(con);  
        con.close(); // 3、关闭数据库  
    }  

第二种:Spring 框架的使用,最经典的就是xml的配置模式

Spring 通过 XML 配置模式装载 Bean 的过程:

  1. 将程序内所有 XML 或 Properties 配置文件加载入内存中;
  2. Java类里面解析xml或properties里面的内容,得到对应实体类的字节码字符串以及相关的属性信息;
  3. 使用反射机制,根据这个字符串获得某个类的Class实例;
  4. 动态配置实例的属性。

Spring这样做的好处是:

  • 不用每一次都要在代码里面去new或者做其他的事情;
  • 以后要改的话直接改配置文件,代码维护起来就很方便了;
  • 有时为了适应某些需求,Java类里面不一定能直接调用另外的方法,可以通过反射机制来实现。

模拟 Spring 加载 XML 配置文件:

public class BeanFactory {
       private Map<String, Object> beanMap = new HashMap<String, Object>();
       /**
       * bean工厂的初始化.
       * @param xml xml配置文件
       */
       public void init(String xml) {
              try {
                     //读取指定的配置文件
                     SAXReader reader = new SAXReader();
                     ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
                     //从class目录下获取指定的xml文件
                     InputStream ins = classLoader.getResourceAsStream(xml);
                     Document doc = reader.read(ins);
                     Element root = doc.getRootElement();  
                     Element foo;
                    
                     //遍历bean
                     for (Iterator i = root.elementIterator("bean"); i.hasNext();) {  
                            foo = (Element) i.next();
                            //获取bean的属性id和class
                            Attribute id = foo.attribute("id");  
                            Attribute cls = foo.attribute("class");
                           
                            //利用Java反射机制,通过class的名称获取Class对象
                            Class bean = Class.forName(cls.getText());
                           
                            //获取对应class的信息
                            java.beans.BeanInfo info = java.beans.Introspector.getBeanInfo(bean);
                            //获取其属性描述
                            java.beans.PropertyDescriptor pd[] = info.getPropertyDescriptors();
                            //设置值的方法
                            Method mSet = null;
                            //创建一个对象
                            Object obj = bean.newInstance();
                           
                            //遍历该bean的property属性
                            for (Iterator ite = foo.elementIterator("property"); ite.hasNext();) {  
                                   Element foo2 = (Element) ite.next();
                                   //获取该property的name属性
                                   Attribute name = foo2.attribute("name");
                                   String value = null;
                                  
                                   //获取该property的子元素value的值
                                   for(Iterator ite1 = foo2.elementIterator("value"); ite1.hasNext();) {
                                          Element node = (Element) ite1.next();
                                          value = node.getText();
                                          break;
                                   }
                                  
                                   for (int k = 0; k < pd.length; k++) {
                                          if (pd[k].getName().equalsIgnoreCase(name.getText())) {
                                                 mSet = pd[k].getWriteMethod();
                                                 //利用Java的反射极致调用对象的某个set方法,并将值设置进去
                                                 mSet.invoke(obj, value);
                                          }
                                   }
                            }
                           
                            //将对象放入beanMap中,其中key为id值,value为对象
                            beanMap.put(id.getText(), obj);
                     }
              } catch (Exception e) {
                     System.out.println(e.toString());
              }
       }
      
       //other codes
}

反射机制的原理是什么?

Class actionClass=Class.forName(MyClass);
Object action=actionClass.newInstance();
Method method = actionClass.getMethod(“myMethod”,null);
method.invoke(action,null);

上面就是最常见的反射使用的例子,前两行实现了类的装载、链接和初始化(newInstance方法实际上也是使用反射调用了方法),后两行实现了从class对象中获取到method对象然后执行反射调用。

因反射原理较复杂,下面简要描述下流程,想要详细了解的小伙伴,可以看这篇文章:https://wwwblogs/yougewe/p/10125073.html

  1. 反射获取类实例 Class.forName(),并没有将实现留给了java,而是交给了jvm去加载!主要是先获取 ClassLoader, 然后调用 native 方法,获取信息,加载类则是回调 java.lang.ClassLoader。最后,jvm又会回调 ClassLoader 进类加载!

  2. newInstance() 主要做了三件事:

    • 权限检测,如果不通过直接抛出异常;
    • 查找无参构造器,并将其缓存起来;
    • 调用具体方法的无参构造方法,生成实例并返回。
  3. 获取Method对象,

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PV5BWcKI-1676365602974)(Java 基础面试总结.assets/image-20210226195426092.png)]

上面的Class对象是在加载类时由JVM构造的,JVM为每个类管理一个独一无二的Class对象,这份Class对象里维护着该类的所有Method,Field,Constructor的cache,这份cache也可以被称作根对象。

每次getMethod获取到的Method对象都持有对根对象的引用,因为一些重量级的Method的成员变量(主要是MethodAccessor),我们不希望每次创建Method对象都要重新初始化,于是所有代表同一个方法的Method对象都共享着根对象的MethodAccessor,每一次创建都会调用根对象的copy方法复制一份:

 Method copy() { 

        Method res = new Method(clazz, name, parameterTypes, returnType,

                                exceptionTypes, modifiers, slot, signature,

                                annotations, parameterAnnotations, annotationDefault);

        res.root = this;

        res.methodAccessor = methodAccessor;

        return res;

    }
  1. 调用invoke()方法。调用invoke方法的流程如下:

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-60qqjJkb-1676365602975)(Java 基础面试总结.assets/image-20210226195531619.png)]

调用Method.invoke之后,会直接去调MethodAccessor.invoke。MethodAccessor就是上面提到的所有同名method共享的一个实例,由ReflectionFactory创建。

创建机制采用了一种名为inflation的方式(JDK1.4之后):如果该方法的累计调用次数<=15,会创建出NativeMethodAccessorImpl,它的实现就是直接调用native方法实现反射;如果该方法的累计调用次数>15,会由java代码创建出字节码组装而成的MethodAccessorImpl。(是否采用inflation和15这个数字都可以在jvm参数中调整)
以调用MyClass.myMethod(String s)为例,生成出的MethodAccessorImpl字节码翻译成Java代码大致如下:

public class GeneratedMethodAccessor1 extends MethodAccessorImpl {    
    public Object invoke(Object obj, Object[] args)  throws Exception {
        try {
            MyClass target = (MyClass) obj;
            String arg0 = (String) args[0];
            target.myMethod(arg0);
        } catch (Throwable t) {
            throw new InvocationTargetException(t);
        }
    }
}

泛型

Java中的泛型是什么 ?

泛型是 JDK1.5 的一个新特性,**泛型就是将类型参数化,其在编译时才确定具体的参数。**这种参数类型可以用在类、接口和方法的创建中,分别称为泛型类、泛型接口、泛型方法。

使用泛型的好处是什么?

远在 JDK 1.4 版本的时候,那时候是没有泛型的概念的,如果使用 Object 来实现通用、不同类型的处理,有这么两个缺点:

  1. 每次使用时都需要强制转换成想要的类型
  2. 在编译时编译器并不知道类型转换是否正常,运行时才知道,不安全。

如这个例子:

List list = new ArrayList();
list.add("wwwblogs");
list.add(23);
String name = (String)list.get(0);
String number = (String)list.get(1);	//ClassCastException

上面的代码在运行时会发生强制类型转换异常。这是因为我们在存入的时候,第二个是一个 Integer 类型,但是取出来的时候却将其强制转换为 String 类型了。Sun 公司为了使 Java 语言更加安全,减少运行时异常的发生。于是在 JDK 1.5 之后推出了泛型的概念。

根据《Java 编程思想》中的描述,泛型出现的动机在于:有许多原因促成了泛型的出现,而最引人注意的一个原因,就是为了创建容器类

使用泛型的好处有以下几点

  1. 类型安全

    • 泛型的主要目标是提高 Java 程序的类型安全
    • 编译时期就可以检查出因 Java 类型不正确导致的 ClassCastException 异常
    • 符合越早出错代价越小原则
  2. 消除强制类型转换

    • 泛型的一个附带好处是,使用时直接得到目标类型,消除许多强制类型转换
    • 所得即所需,这使得代码更加可读,并且减少了出错机会
  3. 潜在的性能收益

    • 由于泛型的实现方式,支持泛型(几乎)不需要 JVM 或类文件更改
    • 所有工作都在编译器中完成
    • 编译器生成的代码跟不使用泛型(和强制类型转换)时所写的代码几乎一致,只是更能确保类型安全而已

Java泛型的原理是什么 ? 什么是类型擦除 ?

泛型是一种语法糖,泛型这种语法糖的基本原理是类型擦除。Java中的泛型基本上都是在编译器这个层次来实现的,也就是说:**泛型只存在于编译阶段,而不存在于运行阶段。**在编译后的 class 文件中,是没有泛型这个概念的。

类型擦除:使用泛型的时候加上的类型参数,编译器在编译的时候去掉类型参数。

例如:

public class Caculate<T> {
    private T num;
}

我们定义了一个泛型类,定义了一个属性成员,该成员的类型是一个泛型类型,这个 T 具体是什么类型,我们也不知道,它只是用于限定类型的。反编译一下这个 Caculate 类:

public class Caculate{
    public Caculate(){}
    private Object num;
}

发现编译器擦除 Caculate 类后面的两个尖括号,并且将 num 的类型定义为 Object 类型。

那么是不是所有的泛型类型都以 Object 进行擦除呢?大部分情况下,泛型类型都会以 Object 进行替换,而有一种情况则不是。那就是使用到了extends和super语法的有界类型,如:

public class Caculate<T extends String> {
    private T num;
}

这种情况的泛型类型,num 会被替换为 String 而不再是 Object。这是一个类型限定的语法,它限定 T 是 String 或者 String 的子类,也就是你构建 Caculate 实例的时候只能限定 T 为 String 或者 String 的子类,所以无论你限定 T 为什么类型,String 都是父类,不会出现类型不匹配的问题,于是可以使用 String 进行类型擦除。

实际上编译器会正常的将使用泛型的地方编译并进行类型擦除,然后返回实例。但是除此之外的是,如果构建泛型实例时使用了泛型语法,那么编译器将标记该实例并关注该实例后续所有方法的调用,每次调用前都进行安全检查,非指定类型的方法都不能调用成功。

实际上编译器不仅关注一个泛型方法的调用,它还会为某些返回值为限定的泛型类型的方法进行强制类型转换,由于类型擦除,返回值为泛型类型的方法都会擦除成 Object 类型,当这些方法被调用后,编译器会额外插入一行 checkcast 指令用于强制类型转换。这一个过程就叫做『泛型翻译』。

什么是泛型中的限定通配符和非限定通配符 ?

限定通配符对类型进行了限制。有两种限定通配符,一种是<? extends T>它通过确保类型必须是T的子类来设定类型的上界,另一种是<? super T>它通过确保类型必须是T的父类来设定类型的下界。泛型类型必须用限定内的类型来进行初始化,否则会导致编译错误。

非限定通配符 ,可以用任意类型来替代。如List<?> 的意思是这个集合是一个可以持有任意类型的集合,它可以是List<A>,也可以是List<B>,或者List<C>等等。

List<? extends T>和List <? super T>之间有什么区别 ?

这两个List的声明都是限定通配符的例子,List<? extends T>可以接受任何继承自T的类型的List,而List<? super T>可以接受任何T的父类构成的List。例如List<? extends Number>可以接受List或List。

可以把List<String>传递给一个接受List<Object>参数的方法吗?

不可以。真这样做的话会导致编译错误。因为List可以存储任何类型的对象包括String, Integer等等,而List却只能用来存储String。

List<Object> objectList;
List<String> stringList;
objectList = stringList;  //compilation error incompatible types

Array中可以用泛型吗?

不可以。这也是为什么 Joshua Bloch 在 《Effective Java》一书中建议使用 List 来代替 Array,因为 List 可以提供编译期的类型安全保证,而 Array 却不能。

判断ArrayList<String>ArrayList<Integer>是否相等?

ArrayList<String> a = new ArrayList<String>();
ArrayList<Integer> b = new ArrayList<Integer>();
Class c1 = a.getClass();
Class c2 = b.getClass();
System.out.println(c1 == c2); 

输出的结果是 true。因为无论对于 ArrayList 还是 ArrayList,它们的 Class 类型都是一直的,都是 ArrayList.class。

那它们声明时指定的 String 和 Integer 到底体现在哪里呢?

**答案是体现在类编译的时候。**当 JVM 进行类编译时,会进行泛型检查,如果一个集合被声明为 String 类型,那么它往该集合存取数据的时候就会对数据进行判断,从而避免存入或取出错误的数据。

序列化

Java序列化与反序列化是什么?

Java序列化是指把Java对象转换为字节序列的过程,而Java反序列化是指把字节序列恢复为Java对象的过程:

  • **序列化:**序列化是把对象转换成有序字节流,以便在网络上传输或者保存在本地文件中。核心作用是对象状态的保存与重建。我们都知道,Java对象是保存在JVM的堆内存中的,也就是说,如果JVM堆不存在了,那么对象也就跟着消失了。

    而序列化提供了一种方案,可以让你在即使JVM停机的情况下也能把对象保存下来的方案。就像我们平时用的U盘一样。把Java对象序列化成可存储或传输的形式(如二进制流),比如保存在文件中。这样,当再次需要这个对象的时候,从文件中读取出二进制流,再从二进制流中反序列化出对象。

  • **反序列化:**客户端从文件中或网络上获得序列化后的对象字节流,根据字节流中所保存的对象状态及描述信息,通过反序列化重建对象。

为什么需要序列化与反序列化?

简要描述:对内存中的对象进行持久化或网络传输, 这个时候都需要序列化和反序列化

深入描述:

  1. 对象序列化可以实现分布式对象。

主要应用例如:RMI(即远程调用Remote Method Invocation)要利用对象序列化运行远程主机上的服务,就像在本地机上运行对象时一样。

  1. java对象序列化不仅保留一个对象的数据,而且递归保存对象引用的每个对象的数据。

可以将整个对象层次写入字节流中,可以保存在文件中或在网络连接上传递。利用对象序列化可以进行对象的"深复制",即复制对象本身及引用的对象本身。序列化一个对象可能得到整个对象序列。

  1. 序列化可以将内存中的类写入文件或数据库中。

比如:将某个类序列化后存为文件,下次读取时只需将文件中的数据反序列化就可以将原先的类还原到内存中。也可以将类序列化为流数据进行传输。

总的来说就是将一个已经实例化的类转成文件存储,下次需要实例化的时候只要反序列化即可将类实例化到内存中并保留序列化时类中的所有变量和状态。

  1. 对象、文件、数据,有许多不同的格式,很难统一传输和保存。

序列化以后就都是字节流了,无论原来是什么东西,都能变成一样的东西,就可以进行通用的格式传输或保存,传输结束以后,要再次使用,就进行反序列化还原,这样对象还是对象,文件还是文件。

序列化实现的方式有哪些?

实现Serializable接口或者Externalizable接口。

Serializable接口

类通过实现 java.io.Serializable 接口以启用其序列化功能。可序列化类的所有子类型本身都是可序列化的。序列化接口没有方法或字段,仅用于标识可序列化的语义。

如以下例子:

import java.io.Serializable;

public class User implements Serializable {
   private String name;
   private int age;
   public String getName() {
       return name;
   }
   public void setName(String name) {
       this.name = name;
   }

   @Override
   public String toString() {
       return "User{" +
               "name='" + name +
               '}';
   }
}

通过下面的代码进行序列化及反序列化:

public class SerializableDemo {

   public static void main(String[] args) {
       //Initializes The Object
       User user = new User();
       user.setName("cosen");
       System.out.println(user);

       //Write Obj to File
       try (FileOutputStream fos = new FileOutputStream("tempFile"); ObjectOutputStream oos = new ObjectOutputStream(
           fos)) {
           oos.writeObject(user);
       } catch (IOException e) {
           e.printStackTrace();
       }

       //Read Obj from File
       File file = new File("tempFile");
       try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file))) {
           User newUser = (User)ois.readObject();
           System.out.println(newUser);
       } catch (IOException | ClassNotFoundException e) {
           e.printStackTrace();
       }
   }
}

//OutPut:
//User{name='cosen'}
//User{name='cosen'}

Externalizable接口

Externalizable继承自Serializable,该接口中定义了两个抽象方法:writeExternal()readExternal()

当使用Externalizable接口来进行序列化与反序列化的时候需要开发人员重写writeExternal()readExternal()方法。否则所有变量的值都会变成默认值。

public class User implements Externalizable {

   private String name;
   private int age;

   public String getName() {
       return name;
   }
   public void setName(String name) {
       this.name = name;
   }
   public void writeExternal(ObjectOutput out) throws IOException {
       out.writeObject(name);
   }
   public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
       name = (String) in.readObject();
   }

   @Override
   public String toString() {
       return "User{" +
               "name='" + name +
               '}';
   }
}

通过下面的代码进行序列化及反序列化:

public class ExternalizableDemo1 {

  public static void main(String[] args) {
      //Write Obj to file
      User user = new User();
      user.setName("cosen");
      try(ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("tempFile"))){
          oos.writeObject(user);
      } catch (IOException e) {
          e.printStackTrace();
      }

      //Read Obj from file
      File file = new File("tempFile");
      try(ObjectInputStream ois =  new ObjectInputStream(new FileInputStream(file))){
          User newInstance = (User) ois.readObject();
          //output
          System.out.println(newInstance);
      } catch (IOException | ClassNotFoundException e ) {
          e.printStackTrace();
      }
  }
}

//OutPut:
//User{name='cosen'}

两种序列化的对比

实现Serializable接口实现Externalizable接口
系统自动存储必要的信息程序员决定存储哪些信息
Java内建支持,易于实现,只需要实现该接口即可,无需任何代码支持必须实现接口内的两个方法
性能略差性能略好

什么是serialVersionUID?

serialVersionUID 用来表明类的不同版本间的兼容性

Java的序列化机制是通过在运行时判断类的serialVersionUID来验证版本一致性的。在进行反序列化时,JVM会把传来的字节流中的serialVersionUID与本地相应实体(类)的serialVersionUID进行比较,如果相同就认为是一致的,可以进行反序列化,否则就会出现序列化版本不一致的异常。

为什么还要显示指定serialVersionUID的值?

如果不显示指定serialVersionUID, JVM在序列化时会根据属性自动生成一个serialVersionUID, 然后与属性一起序列化, 再进行持久化或网络传输. 在反序列化时, JVM会再根据属性自动生成一个新版serialVersionUID, 然后将这个新版serialVersionUID与序列化时生成的旧版serialVersionUID进行比较, 如果相同则反序列化成功, 否则报错.

如果显示指定了, JVM在序列化和反序列化时仍然都会生成一个serialVersionUID, 但值为我们显示指定的值, 这样在反序列化时新旧版本的serialVersionUID就一致了.

在实际开发中, 不显示指定serialVersionUID的情况会导致什么问题? 如果我们的类写完后不再修改, 那当然不会有问题, 但这在实际开发中是不可能的, 我们的类会不断迭代, 一旦类被修改了, 那旧对象反序列化就会报错. 所以在实际开发中, 我们都会显示指定一个serialVersionUID, 值是多少无所谓, 只要不变就行。

serialVersionUID什么时候修改?

《阿里巴巴Java开发手册》中有以下规定:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OvZQTb3Q-1676365602976)(Java 基础面试总结.assets/image-20210226222339606.png)]

想要深入了解的小伙伴,可以看这篇文章:https://juejin/post/6844903746682486791

Java 序列化中如果有些字段不想进行序列化,怎么办?

对于不想进行序列化的变量,使用 transient 关键字修饰。

transient 关键字的作用是控制变量的序列化,在变量声明前加上该关键字,可以阻止该变量被序列化到文件中,在被反序列化后,transient 变量的值被设为初始值,如 int 型的是 0,对象型的是 null。transient 只能修饰变量,不能修饰类和方法。

静态变量会被序列化吗?

不会。因为序列化是针对对象而言的, 而静态变量优先于对象存在, 随着类的加载而加载, 所以不会被序列化.

看到这个结论, 是不是有人会问, serialVersionUID也被static修饰, 为什么serialVersionUID会被序列化? 其实serialVersionUID属性并没有被序列化, JVM在序列化对象时会自动生成一个serialVersionUID, 然后将我们显示指定的serialVersionUID属性值赋给自动生成的serialVersionUID。

异常

Error 和 Exception 区别是什么?

Java 中,所有的异常都有一个共同的祖先 java.lang 包中的 Throwable 类。Throwable 类有两个重要的子类 Exception(异常)和 Error(错误)。

ExceptionError 二者都是 Java 异常处理的重要子类,各自都包含大量子类。

  • Exception :程**序本身可以处理的异常,可以通过 catch 来进行捕获,通常遇到这种错误,**应对其进行处理,使应用程序可以继续正常运行。Exception 又可以分为运行时异常(RuntimeException, 又叫非受检查异常)和非运行时异常(又叫受检查异常) 。
  • ErrorError 属于程序无法处理的错误 ,我们没办法通过 catch 来进行捕获 。例如,系统崩溃,内存不足,堆栈溢出等,编译器不会对这类错误进行检测,一旦这类错误发生,通常应用程序会被终止,仅靠应用程序本身无法恢复。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dJS9LE66-1676365602977)(Java 基础面试总结.assets/image-20210227103256234.png)]

非受检查异常(运行时异常)和受检查异常(一般异常)区别是什么?

非受检查异常:包括 RuntimeException 类及其子类,表示 JVM 在运行期间可能出现的异常。 Java 编译器不会检查运行时异常。例如:NullPointException(空指针)NumberFormatException(字符串转换为数字)IndexOutOfBoundsException(数组越界)ClassCastException(类转换异常)ArrayStoreException(数据存储异常,操作数组时类型不一致)等。

受检查异常:是Exception 中除 RuntimeException 及其子类之外的异常。 Java 编译器会检查受检查异常。常见的受检查异常有: **IO 相关的异常、ClassNotFoundExceptionSQLExceptio**n等。

非受检查异常和受检查异常之间的区别:是否强制要求调用者必须处理此异常,如果强制要求调用者必须进行处理,那么就使用受检查异常,否则就选择非受检查异常。

throw 和 throws 的区别是什么?

Java 中的异常处理除了包括捕获异常和处理异常之外,还包括声明异常和拋出异常,可以通过 throws 关键字在方法上声明该方法要拋出的异常,或者在方法内部通过 throw 拋出异常对象。

  • throw:

    表示方法内抛出某种异常对象
    如果异常对象是非 RuntimeException 则需要在方法申明时加上该异常的抛出 即需要加上 throws 语句 或者 在方法体内 try catch 处理该异常,否则编译报错
    执行到 throw 语句则后面的语句块不再执行

  • throws:

    方法的定义上使用 throws 表示这个方法可能抛出某种异常

​ 需要由方法的调用者进行异常处理

举例如下:

throw 关键字

public static void main(String[] args) {
		String s = "abc";
		if(s.equals("abc")) {
			throw new NumberFormatException();
		} else {
			System.out.println(s);
		}
		//function();
}

throws 关键字

public static void function() throws NumberFormatException{
		String s = "abc";
		System.out.println(Double.parseDouble(s));
	}
	
	public static void main(String[] args) {
		try {
			function();
		} catch (NumberFormatException e) {
			System.err.println("非数据类型不能转换。");
			//e.printStackTrace();
		}
}

NoClassDefFoundError 和 ClassNotFoundException 区别?

NoClassDefFoundError 是一个 Error 类型的异常,是由 JVM 引起的,不应该尝试捕获这个异常。引起该异常的原因是 JVM 或 ClassLoader 尝试加载某类时在内存中找不到该类的定义,该动作发生在运行期间,即编译时该类存在,但是在运行时却找不到了,可能是编译后被删除了等原因导致。

ClassNotFoundException 是一个受检查异常,需要显式地使用 try-catch 对其进行捕获和处理,或在方法签名中用 throws 关键字进行声明。当使用 Class.forName, ClassLoader.loadClass 或 ClassLoader.findSystemClass 动态加载类到内存的时候,通过传入的类路径参数没有找到该类,就会抛出该异常;另一种抛出该异常的可能原因是某个类已经由一个类加载器加载至内存中,另一个加载器又尝试去加载它。

Java常见异常有哪些?

  • java.lang.IllegalAccessError:违法访问错误。当一个应用试图访问、修改某个类的域(Field)或者调用其方法,但是又违反域或方法的可见性声明,则抛出该异常。
  • java.lang.InstantiationError:实例化错误。当一个应用试图通过Java的new操作符构造一个抽象类或者接口时抛出该异常.
  • java.lang.OutOfMemoryError:内存不足错误。当可用内存不足以让Java虚拟机分配给一个对象时抛出该错误。
  • java.lang.StackOverflowError:堆栈溢出错误。当一个应用**递归调用的层次太深而导致堆栈溢出或者陷入死循环时抛出该错误。**
  • java.lang.ClassCastException:类造型异常。假设有类A和B(A不是B的父类或子类),O是A的实例,那么当强制将O构造为类B的实例时抛出该异常。该异常经常被称为强制类型转换异常。
  • java.lang.ClassNotFoundException:找不到类异常。当应用试图根据字符串形式的类名构造类,而在遍历CLASSPAH之后找不到对应名称的class文件时,抛出该异常。
  • java.lang.ArithmeticException:算术条件异常。譬如:整数除零等。
  • java.lang.ArrayIndexOutOfBoundsException:数组索引越界异常。当对数组的索引值为负数或大于等于数组大小时抛出。
  • java.lang.IndexOutOfBoundsException:索引越界异常。当访问某个序列的索引值小于0或大于等于序列大小时,抛出该异常。
  • java.lang.InstantiationException:实例化异常。当试图通过newInstance()方法创建某个类的实例,而该类是一个抽象类或接口时,抛出该异常。
  • java.lang.NoSuchFieldException:属性不存在异常。当访问某个类的不存在的属性时抛出该异常。
  • java.lang.NoSuchMethodException:方法不存在异常。当访问某个类的不存在的方法时抛出该异常。
  • java.lang.NullPointerException:空指针异常。当应用试图在要求使用对象的地方使用了null时,抛出该异常。譬如:调用null对象的实例方法、访问null对象的属性、计算null对象的长度、使用throw语句抛出null等等。
  • java.lang.NumberFormatException:数字格式异常。当试图将一个String转换为指定的数字类型,而该字符串确不满足数字类型要求的格式时,抛出该异常。
  • java.lang.StringIndexOutOfBoundsException:字符串索引越界异常。当使用索引值访问某个字符串中的字符,而该索引值小于0或大于等于序列大小时,抛出该异常。

try-catch-finally 中哪个部分可以省略?

以下三种情况都是可以的:
try-catch
try-finally
try-catch-finally
可以省略catch或者finally。catch和finally不可以同时省略。

catch 可以省略。更为严格的说法其实是:try只适合处理运行时异常,try+catch适合处理运行时异常+普通异常。也就是说,如果你只用try去处理普通异常却不加以catch处理,编译是通不过的,因为编译器硬性规定,普通异常如果选择捕获,则必须用catch显示声明以便进一步处理。而运行时异常在编译时没有如此规定,所以catch可以省略,你加上catch编译器也觉得无可厚非。

理论上,编译器看任何代码都不顺眼,都觉得可能有潜在的问题,所以你即使对所有代码加上try,代码在运行期时也只不过是在正常运行的基础上加一层皮。但是你一旦对一段代码加上try,就等于显示地承诺编译器,对这段代码可能抛出的异常进行捕获而非向上抛出处理。如果是普通异常,编译器要求必须用catch捕获以便进一步处理;如果运行时异常,捕获然后丢弃并且+finally扫尾处理,或者加上catch捕获以便进一步处理。

至于加上finally,则是在不管有没捕获异常,都要进行的“扫尾”处理。

try-catch-finally 中,如果 catch 中 return 了,finally 还会执行吗?

会执行,在 return 前执行。

在 finally 中改变返回值的做法是不好的,因为如果存在 finally 代码块,try中的 return 语句不会立马返回调用者,而是记录下返回值待 finally 代码块执行完毕之后再向调用者返回其值,然后如果在 finally 中修改了返回值,就会返回修改后的值。显然,在 finally 中返回或者修改返回值会对程序造成很大的困扰,Java 中也可以通过提升编译器的语法检查级别来产生警告或错误。
代码示例1:

public static int getInt() {
    int a = 10;
    try {
        System.out.println(a / 0);
        a = 20;
    } catch (ArithmeticException e) {
        a = 30;
        return a;
        /*
         * return a 在程序执行到这一步的时候,这里不是return a 而是 return 30;这个返回路径就形成了
         * 但是呢,它发现后面还有finally,所以继续执行finally的内容,a=40
         * 再次回到以前的路径,继续走return 30,形成返回路径之后,这里的a就不是a变量了,而是常量30
         */
    } finally {
        a = 40;
    }
	return a;
}

//执行结果:30

代码示例2:

public static int getInt() {
    int a = 10;
    try {
        System.out.println(a / 0);
        a = 20;
    } catch (ArithmeticException e) {
        a = 30;
        return a;
    } finally {
        a = 40;
        //如果这样,就又重新形成了一条返回路径,由于只能通过1个return返回,所以这里直接返回40
        return a; 
    }

}

// 执行结果:40

JVM 是如何处理异常的?

在一个方法中如果发生异常,这个方法会创建一个异常对象,并转交给 JVM,该异常对象包含异常名称,异常描述以及异常发生时应用程序的状态。创建异常对象转交给 JVM 的过程称为抛出异常。可能有一系列的方法调用,最终才进入抛出异常的方法,这一系列方法调用的有序列表叫做调用栈。

JVM 会顺着调用栈去查找看是否有可以处理异常的代码,如果有,则调用异常处理代码。当 JVM 发现可以处理异常的代码时,会把发生的异常传递给它。如果 JVM 没有找到可以处理该异常的代码块,JVM 就会将该异常转交给默认的异常处理器(默认处理器为 JVM 的一部分),默认异常处理器打印出异常信息终止应用程序。
想要深入了解的小伙伴可以看这篇文章:https://wwwblogs/qdhxhz/p/10765839.html

IO

Java的IO 流分为几种?

  • 按照流的方向:输入流(inputStream)和输出流(outputStream);
  • 按照实现功能分:节点流(可以从或向一个特定的地方读写数据,如 FileReader)和处理流(是对一个已存在的流的连接和封装,通过所封装的流的功能调用实现数据读写, BufferedReader);
  • 按照处理数据的单位: 字节流和字符流。分别由四个抽象类来表示(每种流包括输入和输出两种所以一共四个):InputStream,OutputStream,Reader,Writer。Java中其他多种多样变化的流均是由它们派生出来的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ooShM0AK-1676365602977)(Java 基础面试总结.assets/image-20210227113301593.png)]

字节流如何转为字符流?

字节输入流转字符输入流通过 InputStreamReader 实现,该类的构造函数可以传入 InputStream 对象。

字节输出流转字符输出流通过 OutputStreamWriter 实现,该类的构造函数可以传入 OutputStream 对象。

字符流与字节流的区别?

  • 读写的时候字节流是按字节读写,字符流按字符读写。
  • 字节流适合所有类型文件的数据传输,因为计算机字节(Byte)是电脑中表示信息含义的最小单位。字符流只能够处理纯文本数据,其他类型数据不行,但是字符流处理文本要比字节流处理文本要方便。
  • 在读写文件需要对内容按行处理,比如比较特定字符,处理某一行数据的时候一般会选择字符流。
  • 只是读写文件,和文件内容无关时,一般选择字节流。

BIO、NIO、AIO的区别?

  • BIO:同步并阻塞,在服务器中实现的模式为一个连接一个线程。也就是说,客户端有连接请求的时候,服务器就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,当然这也可以通过线程池机制改善。BIO一般适用于连接数目小且固定的架构,这种方式对于服务器资源要求比较高,而且并发局限于应用中,是JDK1.4之前的唯一选择,但好在程序直观简单,易理解。
  • NIO:同步并非阻塞,在服务器中实现的模式为一个请求一个线程,也就是说,客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到有连接IO请求时才会启动一个线程进行处理。NIO一般适用于连接数目多且连接比较短(轻操作)的架构,并发局限于应用中,编程比较复杂,从JDK1.4开始支持。
  • AIO:异步并非阻塞,在服务器中实现的模式为一个有效请求一个线程,也就是说,客户端的IO请求都是通过操作系统先完成之后,再通知服务器应用去启动线程进行处理AIO则是在数据准备好之后,才会通知数据使用者,这样使用者就不需要不停地轮询了。AIO一般适用于连接数目多且连接比较长(重操作)的架构,充分调用操作系统参与并发操作,编程比较复杂,从JDK1.7开始支持。
  • BIO (Blocking I/O):同步阻塞I/O模式,数据的读取写入从发起请求起,线程一直阻塞,直到操作完成。,这个连接不做任何事情会造成不必要的线程开销
  • NIO (New I/O):同时支持阻塞与非阻塞模式,NIO本身是基于事件驱动的思想来实现的,其目的就是解决BIO的大并发问题,在BIO模型中,如果需要并发处理多个I/O请求,那就需要多线程来支持,NIO使用了多路复用器机制,以socket使用来说,多路复用器通过==不断轮询各个连接的状态,==只有在socket**有流可读或者可写时,应用程序才需要去处理它**,在线程的使用上,就不需要一个连接就必须使用一个处理线程了,而是只是有效请求时(确实需要进行I/O处理时),才会使用一个线程去处理,这样就避免了BIO模型下大量线程处于阻塞等待状态的情景。

Java IO都有哪些设计模式?

使用了适配器模式装饰器模式

适配器模式

Reader reader = new INputStreamReader(inputStream);

把一个类的接口变换成客户端所期待的另一种接口,从而使原本因接口不匹配而无法在一起工作的两个类能够在一起工作

  • 类适配器:Adapter类(适配器)继承Adaptee类(源角色)实现Target接口(目标角色)
  • 对象适配器:Adapter类(适配器)持有Adaptee类(源角色)对象实例,实现Target接口(目标角色)
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QyPX3p8d-1676365602978)(Java 基础面试总结.assets/image-20210227114919307.png)]

装饰器模式

new BufferedInputStream(new FileInputStream(inputStream));

一种动态地往一个类中添加新的行为的设计模式。就功能而言,装饰器模式相比生成子类更为灵活,这样可以给某个对象而不是整个类添加一些功能。

  • ConcreteComponent(具体对象)和Decorator(抽象装饰器)实现相同的Conponent(接口)并且Decorator(抽象装饰器)里面持有Conponent(接口)对象,可以传递请求。
  • ConcreteComponent(具体装饰器)覆盖Decorator(抽象装饰器)的方法并用super进行调用,传递请求。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Vr7vuQVi-1676365602978)(Java 基础面试总结.assets/image-20210227115040999.png)]

多线程

2.3.1.1. 何为进程?

进程是程序的一次执行过程,是系统运行程序的基本单位,是系统分配资源的基本单位。因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程

如下图所示,在 windows 中通过查看任务管理器的方式,我们就可以清楚看到 window 当前运行
的进程(.exe 文件的运行)。

2.3.1.2. 何为线程?

线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的 方法区 资源,但每个线程有自己的 程序计数器虚拟机栈本地方法栈 ,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。Java 程序天生就是多线程程序,我们可以通过 JMX 来看一下一个普通的 Java 程序有哪些线
程,代码如下。

上述程序输出如下(输出内容可能不同,不用太纠结下面每个线程的作用,只用知道 main 线程执行 main 方法即可):

从上面的输出内容可以看出: 一个 Java 程序的运行是 main 线程和多个其他线程同时运行

2.3.2. 请简要描述线程与进程的关系,区别及优缺点?

从 JVM ⻆度说进程和线程之间的关系

2.3.2.1. 图解进程和线程的关系

下图是 Java 内存区域,通过下图我们从 JVM 的⻆度来说一下线程和进程之间的关系。如果你对Java 内存区域 (运行时数据区) 这部分知识不太了解的话可以阅读一下这篇文章:《可能是把Java 内存区域讲的最清楚的一篇文章》

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8JXyf7d5-1676365602978)(E:\新建文件夹\面试题\总结面试\Java 基础面试总结.assets\image-20220604085030666.png)]

从上图可以看出:

一个进程中可以有多个线程,多个线程共享进程的 堆 和 方法区 (JDK1.8 之后的元空间) 资源,但是每个线程有自己的 程序计数器 、 虚拟机栈 和 本地方法栈 。

总结:

线程 是 进程 划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。

下面是该知识点的扩展内容!

下面来思考这样一个问题:为什么 程序计数器 、 虚拟机栈 和 本地方法栈 是线程私有的呢?为什么

堆和方法区是线程共享的呢?

2.3.2.2. 程序计数器为什么是私有的?

程序计数器主要有下面两个作用:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。

  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了

需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。

所以,程序计数器私有主要是为了 线程切换后能恢复到正确的执行位置

2.3.2.3. 虚拟机栈和本地方法栈为什么是私有的?

  1. 虚拟机栈: 每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
  2. 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

所以,为了 保证线程中的局部变量不被别的线程访问到 ,虚拟机栈和本地方法栈是线程私有的。

2.3.2.4. 一句话简单了解堆和方法区

堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (所有对象都在这里分配内存),方法区主要用于存放已被加载的

类信息、常量、静态变量、即时==编译器编译后的代码等数据。==

2.3.3. 说说并发与并行的区别?

并发: 同一时间段,多个任务都在执行 (单位时间内不一定同时执行);

并行: 单位时间内,多个任务同时执行。

2.3.4. 为什么要使用多线程呢?

先从总体上来说:

  • 从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位**,线程间的切换和调度的成本远远小于进程**。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。

  • 从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。

再深入到计算机底层来探讨:

  • 单核时代: 在单核时代多线程主要是为了提高 CPU 和 IO 设备的综合利用率。举个例子:当只有一个线程的时候会导致 CPU 计算时,IO 设备空闲;进行 IO 操作时,CPU 空闲。我们可以简单地说这两者的利用率目前都是 50%左右。但是当有两个线程的时候就不一样了,当一个线程执行 CPU 计算时,另外一个线程可以进行 IO 操作,这样两个的利用率就可以在理想情况下达到 100%了

  • 多核时代: 多核时代多线程主要是为了提高 CPU 利用率。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,CPU 只会一个 CPU 核心被利用到,而创建多个线程就可以让多个 CPU 核心被利用到,这样就提高了 CPU 的利用率。

2.3.5. 使用多线程可能带来什么问题?

并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如: 内存泄漏 、 上下文切换 、 死锁

2.3.6. 说说线程的生命周期和状态?

Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态(图源《Java 并发编程艺术》4.1.4 节)。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cMQUOd84-1676365602978)(E:\新建文件夹\面试题\总结面试\Java 基础面试总结.assets\image-20220604093311815.png)]

2.3.7. 什么是上下文切换?

多线程编程中一般**线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,**为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。

概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。 任务从保存到再加载的过程就是一次上下文切换 。

上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。

Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文
切换和模式切换的时间消耗非常少。

2.3.8. 什么是线程死锁?如何避免死锁?

2.3.8.1. 认识线程死锁

线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

如下图所示,线程 A 持有资源 2 ,线程 B 持有资源 1 ,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LpOFoYSp-1676365602979)(Java 基础面试总结.assets/image-20220606185343954.png)]

线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过Thread.sleep(1000); 让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。上面的例子符合产生死锁的四个必要条件。

学过操作系统的朋友都知道产生死锁必须具备以下四个条件:

  1. 互斥条件:该资源任意一个时刻只由一个线程占用。
  2. 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕
    后才释放资源。
  4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

2.3.8.2. 如何避免线程死锁?

我上面说了产生死锁的四个必要条件,为了避免死锁,我们只要破坏产生死锁的四个条件中的其中一个就可以了。现在我们来挨个分析一下:

  1. 破坏互斥条件 :这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。

  2. 破坏请求与保持条件 :一次性申请所有的资源。

  3. 破坏不剥夺条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。

  4. 破坏循环等待条件 :靠按序申请资源来预防。某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

我们对线程 2 的代码修改成下面这样就不会产生死锁了。

我们分析一下上面的代码为什么避免了死锁的发生?

线程 1 首先获得到 resource1 的监视器锁,这时候线程 2 就获取不到了。然后线程 1 再去获取resource2 的监视器锁,可以获取到。然后线程 1 释放了对 resource1、resource2 的监视器锁的占用,线程 2 获取到就可以执行了。这样就破坏了破坏循环等待条件,因此避免了死锁。

2.3.9. 说说 sleep() 方法和 wait() 方法区别和共同点?

  • 两者最主要的区别在于: sleep() ⽅法没有释放锁,⽽ wait() ⽅法释放了锁
  • 两者都可以暂停线程的执⾏。
  • wait() 通常被⽤于线程间交互/通信, sleep() 通常被⽤于暂停执⾏。
  • wait() ⽅法被调⽤后,线程不会⾃动苏醒,需要别的线程调⽤同⼀个对象上的 notify() 或 者 notifyAll() ⽅法。 sleep() ⽅法执⾏完成后,线程会⾃动苏醒。或者可以使⽤ wait(longtimeout) 超时后线程会⾃动苏醒。

2.3.10. 为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?

这是另一个非常经典的 java 多线程面试问题,而且在面试中会经常被问到。很简单,但是很多人都会答不上来!

new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run()方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

总结: 调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会
以多线程的方式执行。

2.3.11. 说一说自己对于 synchronized 关键字的了解

synchronized 关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

另外,在 Java 早期版本中, synchronized 属于 重量级锁 ,效率低下。

synchronized 是我们所说的重量级锁,所说的重量级是相对于那些自旋锁(AQS)而言的,比如可重入锁ReentrantLock

为什么呢?

因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较⻓的时间,时间成本相对较高。

庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对 synchronized 较大优化,所以现在的synchronized 锁效率也优化得很不错了。JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。所以,你会发现目前的话,不论是各种开源框架还是 JDK 源码都大量使用了 synchronized 关键字。

2.3.12. 说说自己是怎么使用 synchronized 关键字

synchronized 关键字最主要的三种使用方式:

1.修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁

synchronized void method() {
 //业务代码
}

2.修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁 。因为静态成员不属于任何一个实例对象,是类成员(static 表明这是该类的一个静态资源,不管 new 了多少个对象,只有一份)。所以,如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象, 因为访问静态 synchronized 方法占用的锁是当前类的锁,而访
问非静态 synchronized 方法占用的锁是当前实例对象锁

synchronized void staic method() {
 //业务代码
}

3.修饰代码块 :指定加锁对象,对给定对象/类加锁。synchronized(this|object) 表示进入同步代码库前要获得 给定对象的锁 。 synchronized(.class) 表示进入同步代码前要获得 当前 class 的锁

总结:

  • synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class类上锁。
  • synchronized 关键字加到实例方法上是给对象实例上锁。
  • 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能!

下面我以一个常⻅的面试题为例讲解一下 synchronized 关键字的具体使用。

synchronized void method() {
//业务代码
}
synchronized void staic method() {
//业务代码
}
synchronized(this) {
//业务代码
}

面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!”

双重校验锁实现对象单例(线程安全)

public class Singleton {
 private volatile static Singleton uniqueInstance;
 private Singleton() {
 }
 public static Singleton getUniqueInstance() {
 //先判断对象是否已经实例过,没有实例化过才进⼊加锁代码
     if (uniqueInstance == null) {
     //类对象加锁
         synchronized (Singleton.class) {
            if (uniqueInstance == null) {
                uniqueInstance = new Singleton();
            }
         }
     }
     return uniqueInstance;
  }
}

另外,需要注意 uniqueInstance 采用 volatile 关键字修饰也是很有必要。

uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这
段代码其实是分为三步执行:

  1. 为 uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. 将 uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3 ,此时 T2 调用 getUniqueInstance () 后发现 uniqueInstance 不为空,因此返回
uniqueInstance ,但此时 uniqueInstance 还未被初始化。

使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。

2.3.13. 构造方法可以使用 synchronized 关键字修饰么?

先说结论: 构造方法不能使用 synchronized 关键字修饰。

构造方法本身就属于线程安全的,不存在同步的构造方法一说。

2.3.14. 讲一下 synchronized 关键字的底层原理

见JavaGuide面试文档

总结

synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中monitorenter 指令指向同步代码块的开始位置, monitorexit 指令则指明同步代码块的结束位
置。

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。

不过两者的本质都是对对象监视器 monitor 的获取。

2.3.15. 为什么要弄一个 CPU 高速缓存呢?

类比我们开发网站后台系统使用的缓存(比如 Redis)是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。 **CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。**当处理器发出内存访问请求时,会先查看缓存内是否有请求数据。如果存在(命中),则不经访问内存直接返回该数据;如果不存在(失效),则要先把内存中的相应数据载入缓存,再将其返回处理器。

我们甚至可以把 内存可以看作外存的高速缓存 ,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。

总结:CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。

为了更好地理解,我画了一个简单的 CPU Cache 示意图如下(实际上,现代的 CPU Cache 通常分为三层,分别叫 L1,L2,L3 Cache):

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eWQnY9Gi-1676365602979)(Java 基础面试总结.assets/image-20220606194011356.png)]

CPU Cache 的工作方式:

先复制一份数据到 CPU Cache 中,当 CPU 需要用到的时候就可以直接从 CPU Cache 中读取数据,当运算完成后,再将运算得到的数据写回 Main Memory 中。但是,这样存在 内存缓存不一致性的问题 !比如我执行一个 i++操作的话,如果两个线程同时执行的话,假设两个线程从 CPUCache 中读取的 i=1,两个线程做了 1++运算完之后再写回 Main Memory 之后 i=2,而正确结果应该是 i=3。

CPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议或者其他手段来解决。

2.3.16. 讲一下 JMM(Java 内存模型)

在 JDK1.2 之前,Java 的内存模型实现总是从 主存 (即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存 本地内存 (比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷⻉,造成 数据的不一致

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YMhHDOmy-1676365602979)(Java 基础面试总结.assets/image-20220608095447711.png)]

要解决这个问题,就需要把变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

所以, volatile 关键字 除了防止 JVM 的指令重排 ,还有一个重要的作用就是保证变量的可⻅性

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HzqBJw4o-1676365602980)(Java 基础面试总结.assets/image-20220608095345691.png)]

2.3.17. 说说 synchronized 关键字和 volatile 关键字的区别

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

  • volatile 关键字 是线程同步的 轻量级实现 ,所以 volatile 性能肯定比synchronized 关键字要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块
  • volatile 关键字能保证数据的可⻅性,但不能保证数据的原子性。 synchronized 关键字两者都能保证。
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Oz5pGham-1676365602980)(Java 基础面试总结.assets/image-20220608100618899.png)]
  • volatile 关键字主要用于解决变量在多个线程之间的可⻅性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

2.3.18. ThreadLocal 了解么?

通常情况下,我们创建的变量是可以被任何⼀个线程访问并修改的。如果想实现==每⼀个线程都有⾃⼰的专属本地变量==该如何解决呢? JDK 中提供的 ThreadLocal 类正是为了解决这样的问题。

ThreadLocal类主要解决的就是让每个线程绑定⾃⼰的值,可以将 ThreadLocal 类形象的⽐喻成存放数据的盒⼦,盒⼦中可以存储每个线程的私有数据。如果你创建了⼀个ThreadLocal** **变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是** ThreadLocal 变量名的由来。他们可以使⽤ get set **⽅法来获取默认值*或将其值更改为当前线程所存的副本的值,从⽽避免了线程安全问题。**再举个简单的例⼦:⽐如有两个⼈去宝屋收集宝物,这两个共⽤⼀个袋⼦的话肯定会产⽣争执,但是给他们两个⼈每个⼈分配⼀个袋⼦的话就不会出现这样的问题。如果把这两个⼈⽐作线程的话,那么ThreadLocal 就是⽤来避免这两个线程竞争的。

2.3.19. ThreadLocal 原理讲一下

ThreadLocal主要用来为当前线程存储数据,这个数据只有当前线程可以访问。

从 Thread 类源代码入手。

public class Thread implements Runnable {
 ......
//与此线程有关的ThreadLocal值。由ThreadLocal类维护
ThreadLocal.ThreadLocalMap threadLocals = null;
//与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
 ......
}

从上面Thread 类 源代码可以看出 Thread 类中有一个 threadLocals 和 一个inheritableThreadLocals 变量,它们都是 ThreadLocalMap 类型的变量,我们可以把
ThreadLocalMap 理解为ThreadLocal 类实现的定制化的 HashMap 。默认情况下这两个变量都是 null,只有当前线程调用 ThreadLocal 类的 set或 get方法时才创建它们,实际上调用这两个方法的时候,我们调用的是ThreadLocalMap 类对应的 get()set() 方法。

ThreadLocal 类的 set() ⽅法:

public void set(T value) {
 	Thread t = Thread.currentThread();
 	ThreadLocalMap map = getMap(t);
 	if (map != null)
	 	map.set(this, value);
 	else
 	createMap(t, value);
}
ThreadLocalMap getMap(Thread t) {
 	return t.threadLocals;
 }

通过上面这些内容,我们足以通过猜测得出结论: 最终的变量是在了当前线程的**ThreadLocalMap 中,并不是存在 ThreadLocal 上, ThreadLocal 可以理解为只是ThreadLocalMap 的封装,传递了变量值。** ThrealLocal 类中可以通过 Thread.currentThread()获取到当前线程对象后,直接通过getMap(Thread t) 可以访问到该线程的ThreadLocalMap 对象。ThreadLocal 内部维护的是一个类似 Map 的 ThreadLocalMap 数据结构, key 为当前对象的 Thread 对象,值为 Object 对象。

比如我们在同一个线程中声明了两个 ThreadLocal 对象的话,会使用 Thread 内部都是使用仅有那个 ThreadLocalMap 存放数据的, ThreadLocalMap的 key 就是 ThreadLocal 对象,value 就是ThreadLocal 对象调用 set方法设置的值。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xqeuxyK8-1676365602981)(Java 基础面试总结.assets/image-20220608101550386.png)]

ThreadLocalMap 是 ThreadLocal 的静态内部类。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FaBak0pn-1676365602981)(Java 基础面试总结.assets/image-20220608102703956.png)]

2.3.20. ThreadLocal 内存泄露问题了解不?

ThreadLocalMap 中使用的 **key 为 ThreadLocal 的弱引用,**而 value强引用。所以,如果ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来,ThreadLocalMap 中就==会出现 key 为 null 的 Entry。==假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set() 、 get() 、remove() 方法的时候,会清理掉 key 为 null的记录。使用完 ThreadLocal 方法后 最好手动调用 remove() 方法

弱引用介绍:如果一个对象只具有弱引用,那就类似于 可有可无的生活用品 。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一**旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。**不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。

弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃
圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。

2.3.21. 线程池

2.3.21.1. 为什么要用线程池?

池化技术相比大家已经屡⻅不鲜了,线程池、数据库连接池、Http 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。

线程池 提供了一种限制和管理资源(包括执行一个任务)。 每个 线程池 还维护一些基本统计信息,例如已完成任务的数量。

这里借用《Java 并发编程的艺术》提到的来说一下 使用线程池的好处

  • 降低资源消耗 。通过重复利用已创建的线程降低线程创建和销毁造成的消耗
  • 提高响应速度 。当任务到达时,任务可以不需要的等到线程创建就能立即执行。
  • 提高线程的可管理性 。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降
  • 低系统的稳定性,使用线程池可以进行统一的分配,调优和监控

2.3.21.2. 实现 Runnable 接口和 Callable 接口的区别

Runnable 自 Java 1.0 以来一直存在,但Callable仅在 Java 1.5 中引入,目的就是为了来处理Runnable 不支持的用例。 Runnable 接口 不会返回结果抛出检查异常,但是 Callable 接口可以。所以,如果任务不需要返回结果或抛出异常推荐使用 Runnable 接口 ,这样代码看起来会更加简洁。

工具类 Executors 可以实现 Runnable 对象和 Callable 对象之间的相互转换。
(Executors.callableRunnable task )或 Executors.callableRunnable taskObject resule )。

Runnable :

@FunctionalInterface
public interface Runnable {
 /**
 * 被线程执⾏,没有返回值也⽆法抛出异常
 */
 public abstract void run();
}

Callable:

@FunctionalInterface
public interface Callable<V> {
 /**
 * 计算结果,或在⽆法这样做时抛出异常。
 * @return 计算得出的结果
 * @throws 如果⽆法计算结果,则抛出异常
 */
 V call() throws Exception; }	

2.3.21.3. 执行 execute()方法和 submit()方法的区别是什么呢?

  1. execute() 方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;
  2. submit()方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功,并且可以通过 Future 的 get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 getlong timeoutTimeUnitunit 方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。

我们以 AbstractExecutorService 接口中的一个 submit 方法为例子来看看源代码:

public Future<?> submit(Runnable task) {
     if (task == null) throw new NullPointerException();
     RunnableFuture<Void> ftask = newTaskFor(task, null);
     execute(ftask);
     return ftask;
 }

上面方法调用的 newTaskFor 方法返回了一个 FutureTask 对象。

 protected <T> RunnableFuture<T> newTaskFor(Runnable runnable, T value) {
 	return new FutureTask<T>(runnable, value);
 }	

我们再来看看 execute()方法:

public void execute(Runnable command) {
 ...
 }	

2.3.21.4. 如何创建线程池

《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建,而是通过ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的⻛险

Executors 返回线程池对象的弊端如下:

  • FixedThreadPool SingleThreadExecutor : 允许请求的队列⻓度为Integer.MAX_VALUE ,可能堆积⼤量的请求,从⽽导致 OOM。

  • CachedThreadPool ScheduledThreadPool : 允许创建的线程数量为Integer.MAX_VALUE ,可能会创建⼤量线程,从⽽导致 OOM。

方式一:通过构造方法实现

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WQOx8qyF-1676365602982)(Java 基础面试总结.assets/image-20220608104952619.png)]

方式二:通过 Executor 框架的工具类 Executors 来实现
我们可以创建三种类型的 ThreadPoolExecutor:

  • FixedThreadPool : 该⽅法返回⼀个固定线程数量的线程池。该线程池中的线程数量始终不变。当有⼀个新的任务提交时,线程池中若有空闲线程,则⽴即执⾏。若没有,则新的任务会被暂存在⼀个任务队列中,待有线程空闲时,便处理在任务队列中的任务。

  • SingleThreadExecutor**:** ⽅法返回⼀个只有⼀个线程的线程池。若多余⼀个任务被提交到该线程池,任务会被保存在⼀个任务队列中,待线程空闲,按先⼊先出的顺序执⾏队列中的任务。

  • CachedThreadPool**:** 该⽅法返回⼀个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复⽤,则会优先使⽤可复⽤的线程。若所有线程均在⼯作,⼜有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执⾏完毕后,将返回线程池进⾏复⽤。对应 Executors 工具类中的方法如图所示:

2.3.21.5. ThreadPoolExecutor 类分析

ThreadPoolExecutor 类中提供的四个构造方法。我们来看最⻓的那个,其余三个都是在这个构造方法的基础上产生(其他几个构造方法说白点都是给定某些默认参数的构造方法比如默认制定拒绝策略是什么),这里就不贴代码讲了,比较简单。

/**
 * ⽤给定的初始参数创建⼀个新的ThreadPoolExecutor。
 */
 public ThreadPoolExecutor(int corePoolSize,
     int maximumPoolSize,
     long keepAliveTime,
     TimeUnit unit,
     BlockingQueue<Runnable> workQueue,
     ThreadFactory threadFactory,
     RejectedExecutionHandler handler) {
     if (corePoolSize < 0 ||
             maximumPoolSize <= 0 ||
             maximumPoolSize < corePoolSize ||
             keepAliveTime < 0)throw new IllegalArgumentException();
     if (workQueue == null || threadFactory == null || handler == null)
  			throw new NullPointerException();
     this.corePoolSize = corePoolSize;
     this.maximumPoolSize = maximumPoolSize;
     this.workQueue = workQueue;
     this.keepAliveTime = unit.toNanos(keepAliveTime);
     this.threadFactory = threadFactory;
     this.handler = handler;
 }

下面这些对创建 非常重要,在后面使用线程池的过程中你一定会用到!所以,务必拿着小本本记清楚。

**2.3.21.5.1. ThreadPoolExecutor 构造函数重要参数分析

ThreadPoolExecutor 3 个最重要的参数:

  • corePoolSize : 核⼼线程数线程数定义了最⼩可以同时运⾏的线程数量。

  • maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运⾏的线程数量变为最⼤线程数

  • workQueue : 当新任务来的时候会先判断当前运⾏的线程数量是否达到核⼼线程数,如果达到的话,新任务就会被存放在队列中。

ThreadPoolExecutor 其他常⻅参数:

  1. keepAliveTime :当线程池中的线程数量⼤于 corePoolSize 的时候,如果这时没有新的任务提交,核⼼线程外的线程不会⽴即销毁,⽽是会等待,直到等待的时间超过了keepAliveTime 才会被回收销毁;

  2. unit : keepAliveTime 参数的时间单位。

  3. threadFactory :executor 创建新线程的时候会⽤到。

  4. handler :饱和策略。关于饱和策略下⾯单独介绍⼀下。

2.3.21.5.2. ThreadPoolExecutor 饱和策略

ThreadPoolExecutor 饱和策略定义:

如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任时, ThreadPoolTaskExecutor 定义一些策略:

  • ThreadPoolExecutor==.AbortPolicy== :抛出 RejectedExecutionException来拒绝新任务的处理。
  • ThreadPoolExecutor.CallerRunsPolicy :调用执行自己的线程运行任务。您不会任务请求。但是这种策略会降低对于新任务提交速度,影响程序的整体性能。另外,这个策略喜欢增加队列容量。如果您的应用程序可以承受此延迟并且你不能任务丢弃任何一个任务请求的话,你可以选择这个策略。
  • ThreadPoolExecutor.DiscardPolicy : 不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy : 此策略将丢弃最早的未处理的任务请求。

举个例子: Spring 通过 ThreadPoolTaskExecutor 或者我们直接通过 ThreadPoolExecutor 的构造函数创建线程池的时候,当我们不指定 RejectedExecutionHandler 饱和策略的话来配置线程池的时候默认使用的是 ThreadPoolExecutor.AbortPolicy 。在默认情况下,ThreadPoolExecutor 将抛出
RejectedExecutionException 来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。 对于可伸缩的应用程序,建议使用ThreadPoolExecutor.CallerRunsPolicy。当最大池被填满时,此策略为我们提供可伸缩队列。(这个直接查看 ThreadPoolExecutor 的构造函数源码就可以看出,比较简单的原因,这里就不贴代码了)

2.3.21.6. 线程池原理分析

承接 4.6 节,我们通过代码输出结果可以看出: 线程池每次会同时执行 5 个任务,这 5 个任务执行完之后,剩余的 5 个任务才会被执行。 大家可以先通过上面讲解的内容,分析一下到底是咋回事?(自己独立思考一会)现在,我们就分析上面的输出内容来简单分析一下线程池原理。

为了搞懂线程池的原理,我们需要首先分析一下 execute方法。 在 4.6 节中的 Demo 中我们使用executor.execute(worker) 来提交一个任务到线程池中去,这个方法非常重要,下面我们来看看它的源码:

    // 存放线程池的运⾏状态 (runState) 和线程池内有效线程的数量 (workerCount)
     private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
     private static int workerCountOf(int c) {
            return c & CAPACITY;
     }
     private final BlockingQueue<Runnable> workQueue;

     public void execute(Runnable command) {
         // 如果任务为null,则抛出异常。
         if (command == null)  throw new NullPointerException();
         // ctl 中保存的线程池当前的⼀些状态信息
         int c = ctl.get();
         
         // 下⾯会涉及到 3 步 操作
         // 1.⾸先判断当前线程池中之⾏的任务数量是否⼩于 corePoolSize
         // 如果⼩于的话,通过addWorker(command, true)新建⼀个线程,并将任务(command) 添加到该线程中;然后,启动该线程从⽽执⾏任务。
     	if (workerCountOf(c) < corePoolSize) {
    		 if (addWorker(command, true))
     			return;
    		 c = ctl.get();
        }
            
         // 2.如果当前之⾏的任务数量⼤于等于 corePoolSize 的时候就会⾛到这⾥
         // 通过 isRunning ⽅法判断线程池状态,线程池处于 RUNNING 状态才会被并且队列可以加⼊任务,该任务才会被加⼊进去
     	if (isRunning(c) && workQueue.offer(command)) {
     		 int recheck = ctl.get();
     	// 再次获取线程池状态,如果线程池状态不是 RUNNING 状态就需要从任务队列中移除任务,并尝试判断线程是否全部执⾏完毕。同时执⾏拒绝策略。
             if (!isRunning(recheck) && remove(command))  reject(command);
             // 如果当前线程池为空就新创建⼀个线程并执⾏。
             else if (workerCountOf(recheck) == 0)  addWorker(null, false);
    	 }
         
     //3. 通过addWorker(command, false)新建⼀个线程,并将任务(command)添加到该线程中;然后,启动该线程从⽽执⾏任务。
     //如果addWorker(command, false)执⾏失败,则通过reject()执⾏相应的拒绝策略的内容。
    	 else if (!addWorker(command, false))
     		reject(command);
     	}

通过下图可以更好的对上面这 3 步做一个展示,下图是我为了省事直接从网上找到,原地址不明。在,让我们在回到 4.6 节我们写的 Demo, 现在应该是不是很容易就可以搞懂它的原理了呢?没搞懂的话,也没关系,可以看看我的分析:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Fd3ZaXAy-1676365602983)(Java 基础面试总结.assets/image-20220608195709883.png)]

现在,让我们在回到 4.6 节我们写的 Demo, 现在应该是不是很容易就可以搞懂它的原理了呢?

没搞懂的话,也没关系,可以看看我的分析:

我们在代码中模拟了 10 个任务,我们配置的核心线程数为 5 、等待队列容量为 100 ,所以每次只可能存在 5 个任务同时执行,剩下的 5 个任务会被放到等待队列中去。当前的 5 个任务之行完成后,才会之行剩下的 5 个任务。

2.3.22. 介绍一下 Atomic 原子类

Atomic 翻译成中文是原子的意思。在化学上,我们知道原子是构成一般物质的最小单位,在化学反应中是不可分割的。在我们这里 Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。

所以,所谓原子类说简单点就是具有原子/原子操作特征的类。

并发包 java.util.concurrent 的原子类都存放在java.util.concurrent.atomic下,如下图所示。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LXEBLJ7v-1676365602983)(Java 基础面试总结.assets/image-20220608195855600.png)]

2.3.23. JUC 包中的原子类是哪 4 类?

基本类型

使用原子的方式更新基本类型

  • AtomicInteger :整形原子类
  • AtomicLong :⻓整型原子类
  • AtomicBoolean :布尔型原子类

数组类型

使用原子的方式更新数组里的某个元素

  • AtomicIntegerArray :整形数组原子类
  • AtomicLongArray :⻓整形数组原子类
  • AtomicReferenceArray :引用类型数组原子类

引用类型

  • AtomicReference :引用类型原子类
  • AtomicStampedReference :原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
  • AtomicMarkableReference :原子更新带有标记位的引用类型

对象的属性修改类型

  • AtomicIntegerFieldUpdater :原子更新整形字段的更新器
  • AtomicLongFieldUpdater :原子更新⻓整形字段的更新器
  • AtomicReferenceFieldUpdater :原子更新引用类型字段的更新器

2.3.24. AQS 了解么?

AQS 的全称为( AbstractQueuedSynchronizer ),这个类在 java.util.concurrent.locks包下面。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1TnReBVw-1676365602983)(Java 基础面试总结.assets/image-20220608204701459.png)]

AQS 是一个用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 ReentrantLockSemaphore,其他的诸如ReentrantReadWriteLock, SynchronousQueue, FutureTask 等等皆是基于 AQS 的。当然,我们自己也能利用 AQS 非常轻松容易地构造出符合我们自己需求的同步器。

2.3.25. AQS 原理了解么?

AQS 原理这部分参考了部分博客,在 5.2 节末尾放了链接。

在面试中被问到并发知识的时候,大多都会被问到“请你说一下自己对于 AQS 原理的理解”。下面给大家一个示例供大家参加,面试不是背题,大家一定要加入自己的思想,即使加入不了自己的思想也要保证自己能够通俗的讲出来而不是背出来。

下面大部分内容其实在 AQS 类注释上已经给出了,不过是英语看着比较吃力一点,感兴趣的话可以看看源码。

2.3.25.1. AQS 原理概览

AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个CLH 锁队列的一个结点(Node)来实现锁的分配。

看个 AQS(AbstractQueuedSynchronizer)原理图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TLLEZSIE-1676365602984)(Java 基础面试总结.assets/image-20220608205152515.png)]

AQS 使用一个 int 成员变量来表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改。状态信息通过 protected 类型的 getState,setState,compareAndSetState 进行操作

    private volatile int state;//共享变量,使用volatile修饰保证线程可⻅性
    //返回同步状态的当前值
    protected final int getState() {
        return state;
    }
    // 设置同步状态的值
    protected final void setState(int newState) {
    	state = newState;
    }
    //原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值)
    protected final boolean compareAndSetState(int expect, int update) {
    	return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }

2.3.25.2. AQS 对资源的共享方式

AQS 定义两种资源共享⽅式

Exclusive(独占):

只有⼀个线程能执⾏,如 ReentrantLock 。

⼜可分为公平锁和⾮公平锁:

  • 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁

  • ⾮公平锁:当线程要获取锁时,⽆视队列顺序直接去抢锁,谁抢到就是谁的

Share(共享):

多个线程可同时执⾏,如CountDownLatch 、 Semaphore 、 CountDownLatch 、 CyclicBarrier 、 ReadWriteLock 我们都会在后⾯讲到。

ReentrantReadWriteLock 可以看成是组合式,因为 ReentrantReadWriteLock 也就是读写锁允许多个线程同时对某⼀资源进⾏读。不同的⾃定义同步器争⽤共享资源的⽅式也不同。⾃定义同步器在实现时只需要实现共享资源state 的获取与释放⽅式即可,⾄于具体线程等待队列的维护(如获取资源失败⼊队/唤醒出等),AQS 已经在顶层实现好了。。

2.3.25.3. AQS 底层使用了模板方法模式

同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):

  1. 使用者继承 AbstractQueuedSynchronizer 并重写指定的方法。(这些重写方法很简单,无非是对于共享资源 state 的获取和释放)
  2. 将 AQS 组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。

这和我们以往通过实现接口的方式有很大区别,这是模板方法模式很经典的一个运用。

AQS 使用了模板方法模式,自定义同步器时需要重写下面几个 AQS 提供的模板方法:

    isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。
    tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。
    tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。
    tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败; 0 表示成功,但没有剩余可用资
    源;正数表示成功,且有剩余资源。
    tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。

默认情况下,每个方法都抛出 UnsupportedOperationException。 这些方法的实现必须是内部线程
安全的,并且通常应该简短而不是阻塞。AQS 类中的其他方法都是 final ,所以无法被其他类使
用,只有这几个方法可以被其他类使用。

以 ReentrantLock 为例,state 初始化为 0 ,表示未锁定状态。A 线程 lock()时,会调用
tryAcquire()独占该锁并将 state+1。此后,其他线程再 tryAcquire()时就会失败,直到 A 线程
unlock()到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程
自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就
要释放多么次,这样才能保证 state 是能回到零态的。

再以 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线
程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后 countDown() 一次,state 会
CAS(Compare and Swap)减 1 。等到所有子线程都执行完后(即 state=0),会 unpark()主调用线
程,然后主调用线程就会从 await() 函数返回,继续后余动作。

一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-
tryRelease、 tryAcquireShared-tryReleaseShared 中的一种即可。但 AQS 也支持自定义同步器同时
实现独占和共享两种方式,如ReentrantReadWriteLock。

推荐两篇 AQS 原理和相关源码分析的文章:

http://wwwblogs/waterystone/p/4920797.html
https://wwwblogs/chengxiao/archive/2017/07/24/7141160.html

2.3.26. AQS 组件总结

  • Semaphore (信号量)-允许多个线程同时访问: synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。

  • CountDownLatch (倒计时器): CountDownLatch 是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。

  • CyclicBarrier (循环栅栏): CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和CountDownLatch 类似。CyclicBarrier 的字面意思是可循环使用(Cyclic )的屏障(Barrier )。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开⻔,所有被屏障拦截的线程才会继续干活。 CyclicBarrier 默认的构造方法CyclicBarrier(int parties) ,其参数表示屏障拦截的线程数量,每个线程调用 await() 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。

2.3.27. 用过 CountDownLatch 么?什么场景下用的?

CountDownLatch 的作用就是 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。之前在项目中,有一个使用多线程读取多个文件处理的场景,我用到了 CountDownLatch 。具体场景是下面这样的:

我们要读取处理 6 个文件,这 6 个任务都是没有执行顺序依赖的任务,但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。

为此我们定义了一个线程池和 count 为 6 的 CountDownLatch对象 。使用线程池处理读取任务,每一个线程处理完之后就将 count-1,调用CountDownLatch对象的 await() 方法,直到所有文件读取完之后,才会接着执行后面的逻辑。

伪代码是下面这样的:

    public class CountDownLatchExample1 {
    	 // 处理⽂件的数量
     	private static final int threadCount = 6;
    	 public static void main(String[] args) throws InterruptedException {
     // 创建⼀个具有固定线程数量的线程池对象(推荐使⽤构造⽅法创建)
    	 ExecutorService threadPool = Executors.newFixedThreadPool(10);
    	 final CountDownLatch countDownLatch = new CountDownLatch(threadCount);
    	 for (int i = 0; i < threadCount; i++) {
     			final int threadnum = i;
     			threadPool.execute(() -> {
                    try {
                     //处理⽂件的业务操作
                    ......
                    } catch (InterruptedException e) {
     					e.printStackTrace();
     				} finally {
     					//表示⼀个⽂件已经被完成
     					countDownLatch.countDown();
    			 	}	
     			});
    	 }
     	countDownLatch.await();
     	threadPool.shutdown();
     	System.out.println("finish");
     }
    }

有没有可以改进的地方呢?

可以使用 CompletableFuture 类来改进!Java8 的 CompletableFuture 提供了很多对多线程友好的方法,使用它可以很方便地为我们编写多线程程序,什么异步、串行、并行或者等待所有线程执行完任务什么的都非常方便。

上面的代码还可以接续优化,当任务过多的时候,把每一个 task 都列出来不太现实,可以考虑通
过循环来添加任务。

    CompletableFuture<Void> task1 =CompletableFuture.supplyAsync(()->{
             //⾃定义业务操作
     	});
   		 ......
   		 CompletableFuture<Void> task6 =CompletableFuture.supplyAsync(()->{
    		 //⾃定义业务操作
     	});
    	......
    CompletableFuture<Void> headerFuture=CompletableFuture.allOf(task1,.....,task6);
     try {
    	 headerFuture.join();
     } catch (Exception ex) {
     	 ......
     }
    System.out.println("all done. ");

2.4.1. Reference

1.内存泄漏与内存溢出

  • 内存溢出: out of memory,是指程序在申请内存时,没有足够的内存空间供其使用,出现out of memory;比如申请了一个integer,但给它存了long才能存下的数,那就是内存溢出。

  • 内存泄露 :memory leak,是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃,内存空间使用完之后没有回收。

JVM

说一下类的加载过程

  • 概述

    一个Java文件从编码完成到最终执行,一般主要包括两个过程

    • 编译:

      即把我们写好的java文件,通过javac命令编译成字节码,也就是我们常说的.class文件。

    • 加载****运行:

      运行,则是把编译生成的.class文件交给Java虚拟机(JVM)执行。而我们所说的类加载过程即是指JVM虚拟机把.class文件中类信息加载进内存,并进行解析生成对应的class对象的过程。举个通俗点的例子来说,JVM在执行某段代码时,遇到了class A, 然而此时内存中并没有class A的相关信息,于是JVM就会到相应的class文件中去寻找class A的类信息,并加载进内存中,这就是我们所说的类加载过程。

      由此可见,JVM不是一开始就把所有的类都加载进内存中,而是只有第一次遇到某个需要运行的类时才会加载,且只加载一次

  • 类加载的过程主要分为三个部分:

  1. 加载
  2. 链接
    • 验证
    • 准备
    • 解析
  3. 初始化

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dupElejg-1676365602984)(E:\新建文件夹\面试题\总结面试\Java 基础面试总结.assets\image-20220604151019655.png)]

加载:

将class字节码文件加载到内存中,并将这些数据转换成运行时数据区(静态变量、静态代码块、常量池等),在堆中生成一个Class类对象代表这个类(反射原理),作为方法区类数据的访问入口

链接:

  • 验证:

    该阶段主要是为了保证加载进来的字节流符合JVM的规范,不会对JVM有安全性问题。其中有对元数据的验证,例如检查类是否继承了被final修饰的类;还对符号引用的验证,例如校验符号引用是否可以通过全限定名找到,或者是检查符号引用的权限(private、public)是否符合语法规定等。

  • 准备:

    准备阶段的主要任务是为类的类变量开辟空间并赋默认值
    1、静态变量是基本类型(int、long、short、char、byte、boolean、float、double)的默认值为0
    2、静态变量是引用类型的,默认值为null
    3、静态常量默认值为声明时设定的值

  • 解析:

    该阶段的主要职责为将Class在常量池中的符号引用转变为直接引用,此处针对的是静态方法及属性和私有方法与属性,因为这类方法与私有方法不能被写静态属性在运行期也没有多态这一说,即在编译器可知,运行期不可变,所以适合在该阶段解析,譬如类方法main替换为直接引用,为静态连接,区别于运行时的动态连接(后续我会写关于JVM内存结构的文章,在讲解栈帧时会介绍动态链接)。

初始化

该阶段主要是为类的类变量初始化值的,初始化有两种方式:
1、在声明类变量时,直接给变量赋值
2、在静态初始化块为类变量赋值

类的加载时机(包括加载、连接、初始化)

  1. 创建该类的实例
  2. 调用该类的类方法
  3. 访问类或接口的类变量,或为类变量赋值
  4. 利用反射Class.forName(String name, boolean initialize,ClassLoader loader);
  5. 当使用ClassLoader类的loadClass()方法来加载类时,该类只进行加载阶段,而不会经历初始化阶段,使用Class类的静态方法forName(),根据initialize来决定会不会初始化该类,不传该参数默认强制初始化
  6. 初始化该类的子类
  7. 运行main方法,main方法所在类会被加载

8
}
});
}
countDownLatch.await();
threadPool.shutdown();
System.out.println(“finish”);
}
}




有没有可以改进的地方呢?

可以使用 CompletableFuture 类来改进!Java8 的 CompletableFuture 提供了很多对多线程友好的方法,使用它可以很方便地为我们编写多线程程序,什么异步、串行、并行或者等待所有线程执行完任务什么的都非常方便。

上面的代码还可以接续优化,当任务过多的时候,把每一个 task 都列出来不太现实,可以考虑通
过循环来添加任务。

```java
    CompletableFuture<Void> task1 =CompletableFuture.supplyAsync(()->{
             //⾃定义业务操作
     	});
   		 ......
   		 CompletableFuture<Void> task6 =CompletableFuture.supplyAsync(()->{
    		 //⾃定义业务操作
     	});
    	......
    CompletableFuture<Void> headerFuture=CompletableFuture.allOf(task1,.....,task6);
     try {
    	 headerFuture.join();
     } catch (Exception ex) {
     	 ......
     }
    System.out.println("all done. ");

2.4.1. Reference

1.内存泄漏与内存溢出

  • 内存溢出: out of memory,是指程序在申请内存时,没有足够的内存空间供其使用,出现out of memory;比如申请了一个integer,但给它存了long才能存下的数,那就是内存溢出。

  • 内存泄露 :memory leak,是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃,内存空间使用完之后没有回收。

JVM

说一下类的加载过程

  • 概述

    一个Java文件从编码完成到最终执行,一般主要包括两个过程

    • 编译:

      即把我们写好的java文件,通过javac命令编译成字节码,也就是我们常说的.class文件。

    • 加载****运行:

      运行,则是把编译生成的.class文件交给Java虚拟机(JVM)执行。而我们所说的类加载过程即是指JVM虚拟机把.class文件中类信息加载进内存,并进行解析生成对应的class对象的过程。举个通俗点的例子来说,JVM在执行某段代码时,遇到了class A, 然而此时内存中并没有class A的相关信息,于是JVM就会到相应的class文件中去寻找class A的类信息,并加载进内存中,这就是我们所说的类加载过程。

      由此可见,JVM不是一开始就把所有的类都加载进内存中,而是只有第一次遇到某个需要运行的类时才会加载,且只加载一次

  • 类加载的过程主要分为三个部分:

  1. 加载
  2. 链接
    • 验证
    • 准备
    • 解析
  3. 初始化

[外链图片转存中…(img-dupElejg-1676365602984)]

加载:

将class字节码文件加载到内存中,并将这些数据转换成运行时数据区(静态变量、静态代码块、常量池等),在堆中生成一个Class类对象代表这个类(反射原理),作为方法区类数据的访问入口

链接:

  • 验证:

    该阶段主要是为了保证加载进来的字节流符合JVM的规范,不会对JVM有安全性问题。其中有对元数据的验证,例如检查类是否继承了被final修饰的类;还对符号引用的验证,例如校验符号引用是否可以通过全限定名找到,或者是检查符号引用的权限(private、public)是否符合语法规定等。

  • 准备:

    准备阶段的主要任务是为类的类变量开辟空间并赋默认值
    1、静态变量是基本类型(int、long、short、char、byte、boolean、float、double)的默认值为0
    2、静态变量是引用类型的,默认值为null
    3、静态常量默认值为声明时设定的值

  • 解析:

    该阶段的主要职责为将Class在常量池中的符号引用转变为直接引用,此处针对的是静态方法及属性和私有方法与属性,因为这类方法与私有方法不能被写静态属性在运行期也没有多态这一说,即在编译器可知,运行期不可变,所以适合在该阶段解析,譬如类方法main替换为直接引用,为静态连接,区别于运行时的动态连接(后续我会写关于JVM内存结构的文章,在讲解栈帧时会介绍动态链接)。

初始化

该阶段主要是为类的类变量初始化值的,初始化有两种方式:
1、在声明类变量时,直接给变量赋值
2、在静态初始化块为类变量赋值

类的加载时机(包括加载、连接、初始化)

  1. 创建该类的实例
  2. 调用该类的类方法
  3. 访问类或接口的类变量,或为类变量赋值
  4. 利用反射Class.forName(String name, boolean initialize,ClassLoader loader);
  5. 当使用ClassLoader类的loadClass()方法来加载类时,该类只进行加载阶段,而不会经历初始化阶段,使用Class类的静态方法forName(),根据initialize来决定会不会初始化该类,不传该参数默认强制初始化
  6. 初始化该类的子类
  7. 运行main方法,main方法所在类会被加载

8

更多推荐

Java 基础面试总结

本文发布于:2023-04-13 09:12:00,感谢您对本站的认可!
本文链接:https://www.elefans.com/category/jswz/b47315d5b52c447709a3888c42f73ca9.html
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。
本文标签:基础   Java

发布评论

评论列表 (有 0 条评论)
草根站长

>www.elefans.com

编程频道|电子爱好者 - 技术资讯及电子产品介绍!

  • 72695文章数
  • 14阅读数
  • 0评论数