1.Java基础
1、Java基础
1.1 JDK JRE JVM的关系
- JDK(Java Development Kit)是针对 Java 开发人员的产品,是整个 Java 的核心,包括了 Java 运行环境 JRE、Java 工具和 Java 基础类库。
- JRE(Java Runtime Environment)是运行 Java 程序所必须的环境的集合,包 含 JVM 标准实现及 Java 核心类库。
- JVM(Java Virtual Machine)Java 虚拟机,是整个 java 实现跨平台的最核心 的部分,能够运行以 Java 语言写作的软件程序。
简单来说, JDK 是 Java 的开发工具;JRE 是 Java 程序运行所需的环境, JVM是Java虚拟机。它们之间的关系是JDK包含JRE和JVM,JRE包含JVM。
1.2 编译过程
javac
还要检查语言规范,编译后转换为.class
字节码文件。
1.3 Java和C++的区别
- Java可以通过虚拟机实现跨平台的特性,C++依赖于特定的平台;
- Java没有指针,它的引用可以理解为安全指针,C++有指针;
- Java支持自动垃圾回收,C++需要手动回收;
- Java不支持多重继承,只能通过实现多个接口来达到相同目的,C++支持多重继承;
1.4 Java 8有什么新特性?
- Lambda表达式:允许把函数作为一个方法的参数,也即函数作为参数传递到方法中;
- 函数式接口:一个有且精油一个抽象方法,但是可以有多个非抽象方法的接口,这样的接口可以隐式转换为Lambda表达式;
- Stream API:把真正的函数式编程风格引入到Java中;
- Date Time API:加强对日期和时间的处理。
1.5 Java支不支持运算符重载?为什么?
不支持。
- 简单性和清晰性。清晰性是 Java 设计者的目标之一。设计者不是只想复制语言,而是希望拥有一种清晰,真正面向对象的语言。添加运算符重载比没有它肯定会使设计更复杂,并且它可能导致更复杂的编译器, 或减慢 JVM,因为它需要做额外的工作来识别运算符的实际含义,并减少优化的机会, 以保证 Java 中运算符的行为。
- 避免编程错误。因为如果允许程序员进行运算符重载,将为同一运算符赋予多种含义,这将使任何开发人员的学习曲线变得陡峭,事情变得更加混乱。
- JVM复杂性。从JVM的角度来看,支持运算符重载使问题变得更加困难。通过更直观,更干净的方式使用方法重载也能实现同样的事情,因此不支持 Java 中的运算符重载是有意义的。与相对简单的 JVM 相比,复杂的 JVM 可能导致 JVM 更慢,并为保证在 Java 中运算符行为的确定性从而减少了优化代码的机会。
1.6 Java中不能被实例化的类有哪些?
- 抽象类。因为抽象类里面的方法尚未定义如何实现,所以不能被实例化;
- 内部类。因为内部类的实例化需要借助外部类,所以某种程度上来说不能被直接实例化;
- 构造函数的修饰符为private的类。这种情况一般出现在官方提供的类中,比如Math类和System类。
1.7 Java中的引用和C++中的指针有什么区别?
- 类型:引用的值为地址的数据元素,java封装了地址,可以转换成字符串查看,长度可以不必关心。C++指针是一个存放地址的变量,长度一般是计算机字长,可以认为是
int
; - 所占内存:引用的声明时没有实体,不占内存。C++如果声明后会用到才会赋值,如果用不到不会分配内存;
- 类型转换:引用的类型转换,也可能不成功,运行时抛异常或者编译就不能通过。C++指针只是个内存地址,指向哪里,对程序来说还都是一个地址,但可能所指的地址不是程序想要的;
- 初始化:引用初始化为java关键字
null
。C++指针是int
,如果不初始化指针,它的值就不固定了,这很危险; - 计算:引用是不可以计算的。C++指针是
int
,它可以计算,如++ 或者–,所以经常用指针来代替数组下标; - 控制:引用不可以计算。所以它只在自己的程序中,可以被控制。C++指针是内存地址,可以计算,所以他有可能指向一个不属于自己程序使用的内存地址,对于其他程序来说是很危险的,对自己程序来说也是不容易控制的;
- 内存泄露:java引用不会产生内存泄露。C++指针是容易产生内存泄露的,所以程序员要小心使用,即使回收;
- 作为参数:java的方法参数只传值,引用作为参数使用时,回给函数内引用的copy,所以在函数内交换两个引用参数是没有意义的,因为函数值交换参数的copy值,但在函数改变一个引用参数的属性是有意义的,因为引用参数的copy所引用的对象是和引用参数是同一个对象。C++指针作为参数给函数使用,实际上就是他所指的地址在被函数操作,所以函数内使用指针参数的操作都将直接作用到指针所指向的地址(变量、对象、函数等)。
2、数据类型
2.1 自动装箱与拆箱
- 装箱:将基本类型用包装器类型包装起来。
- 拆箱:将包装器类型转换为基本类型。
装箱其实就是调用了包装类的valueOf()
方法,拆箱其实就是调用了xxxValue()
方法。
比如:Integer i = 10
等价于Integer i = Integer.valueOf(10);
、int n = i
等价于int n = i.intValue();
2.1.1 有了基本数据类型,为什么还要有包装类?
Java 是一个面向对象的语言,而基本类型不具备面向对象的特性。 这是一个设计缺陷,自动装箱与拆箱是为了补救这个缺陷。
包装类里面有一些很有用的方法和属性,如 HashCode,ParseInt。
基本类型不能赋
null
值,但某些场合又需要。有些地方不能直接用基本类型,比如集合的泛型里面。
因此,光有基本数据类型是不行的,引入包装类,弥补基本数据类型的缺陷。
2.2 常量池缓存技术
在 Java 中基本类型的包装类的大部分都实现了常量池技术。比如: Byte,Short,Integer,Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在[0,127]范围的缓存数据, Boolean 直接返回 True Or False 。两种浮点数类型的包装类 Float,Double没有实现常量池技术。
1 | public static void main(String[] args) { |
2.2.1 包装类里面引入缓存技术的好处是什么?
有助于节省内存,提高性能。
2.3 String(不是基本数据类型)
在 Java 8 中,String 内部使用 char 数组存储数据。并且被声明为 final,因此它不可被继承。
2.3.1 为什么String要设计成不可变(不可变性的好处)
- 可以缓存hash值:因为String的hash值经常被使用,例如用String作为HashMap的key。String的不可变性可以使得hash值不可变,因此只需要一次计算。
- 常量池优化:String在创建对象后,会在字符串常量池中进行缓存,如果下次创建同样的对象时,会直接返回缓存的引用。
- 线程安全:String的不可变性天生具备线程安全的特性,可以在多个线程中安全地使用。
2.3.2 什么是字符串常量池
字符串常量池位于方法区中,专门用来存储字符串常量,可以提高内存的使用率,避免开辟多块空间存储相同的字符串。在创建字符串时 JVM 会首先检查字符串常量池,如果该字符串已经存在池中,则返回它的引用;如果不存在, 则实例化一个字符串放到池中,并返回其引用。
Q:String str = new String("A"+"B");
会创建多少对象?
A:会创建两个对象:一个是”AB”,另一个是new String()对象,其中包含值”AB”。具体来说,”A”和”B”都是字符串常量,它们会在编译时会被JVM优化,被合并为一个字符串常量”AB”。然后,使用这个常量来创建一个新的String对象。因此,总共会创建两个对象。
2.3.3 String、StringBuffer、StringBuilder之间区别
- 可变性。String不可变;StringBuffer和StringBuilder是可变的。
- 线程安全性,String由于不可变性,所以是线程安全的;StringBuffer对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的,StringBuilder没有加同步锁,不是线程安全的。
- 性能。StringBuilder > StringBuffer > String;
- 各自适合使用的场景:
- String:操作少量数据;
- StringBuffer:多线程操作字符串缓冲区下操作大量数据;
- StringBuilder:单线程操作字符串缓冲区下操作大量数据;
2.4 StringBuilder的底层是什么?
StringBuilder的底层其实就是没有用final
修饰的char
数组。
通过无参构造,append()添加元素时。数组的默认长度为16,每次扩容为数组原长度的2倍+2(value.length<<1+2)。
通过含参构造添加元素时。
- 直接添加一个长度。数组的初始长度为该长度;
- 添加一个字符串。数组的初始长度为该字符串的长度+16(str.length+16)。每次扩容为数组原长度的2倍+2(和上述append()方法相同,因为也是通过append()添加元素的)
2.5 StringBuffer为什么是线程安全的?
主要的原因是StringBuffer中很多方法都是用synchronized
修饰的,比如常用的length()
、append()
、delete()
、charAt()
等。
2.6 为什么基本数据类型正数取值范围要-1呢?
以byte数据类型为例,占用内存是1个字节,每个字节占8位。
正值范围为:0000 0000 ~ 0111 1111
负值范围为:1000 0000 ~ 1111 1111
其中第一位是符号位,后面七位才表示数值,所以正数的范围是0 ~ 127,负数的范围是-0 ~ -127;
那么就会出现正零(0000 0000)和负零(1000 0000)两种情况,而实际中,只需要一个0即可。
所以将-0(1000 0000)的第一位既看作符号位又看成数值位,转换成二进制就是-128,于是byte数据类型的取值范围就是-128 ~ 127。
2.7 为什么Java中float,double类型操作精度会丢失?
因为我们的计算机是二进制的。浮点数没有办法使用二进制进行精确表示。
计算机的 CPU 表示浮点数由两个部分组成:指数和尾数,这样的表示方法一般都会失去一定的精确度,有些浮点数运算也会产生一定的误差。
浮点运算很少是精确的,只要是超过精度能表示的范围就会产生误差。往往产生误差不是因为数的大小,而是因为数的精度。因此,产生的结果接近但不等于想要的结果。尤其在使用 float 和 double 作精确运算的时候要特别小心。
2.7.1 如何解决精度丢失的问题呢?
- 可以用BigDecimal类。
- 用
float
或者double
变量转为字符串,然后再构建BigDecimal
对象。通常使用BigDecimal(String val)
的构造方法把基本类型的变量构建成BigDecimal
对象。 - 通过调用
BigDecimal
的加,减,乘,除等相应的方法进行算术运算。 - 最后把
BigDecimal
对象转换成float
,double
,int
等类型。
- 用
- 可以用整数代替浮点数,二进制整数可以完整的表示所有十进制整数,不存在精度丢失问题,因此我们可以将小数位数固定或者较少的数字转换成整数存储。比如存储货币金额,如果存储单位是元,则需要保留两位小数,例如23.45元。如果将单位改成分,则可以完全使用整数存储,例如2345分。
- 可以转换为字符串,然后用字符串模拟加、减、乘、除的算法。
3、关键字和修饰符
3.1 static
作用:方便在没有创建对象时,调用方法和变量、优化程序性能。
3.1.1 static变量
用 static 修饰的变量被称为静态变量,也被称为类变量,可以直接通过类名来访问它。
静态变量被所有的对象共享,在内存中只有一个副本,仅当在类初次加载时会被初始化,而非静态变量在创建对象的时候被初始化,并且存在多个副本,各个对象拥有的副本互不影响。
3.1.2 static方法
static 方法不依赖于任何对象就可以进行访问,在 static 方法中不能访问类的非静态成员变量和非静态成员方法,因为非静态成员方法/变量都是必须依赖具体的对象才能够被调用,但是在非静态成员方法中是可以访问静态成员方法/变量的。
1 | public class Test { |
3.1.3 static代码块
静态代码块的主要用途是可以用来优化程序的性能,因为它只会在类加载时加载一次,很多时候会将一些只需要进行一次的初始化操作都放在 static 代码 块中进行。
如果程序中有多个 static 块,在类初次被加载的时候,会按照 static 块的顺序来执行每个 static 块。
1 | public class Test { |
3.1.4 初始化顺序
静态变量和静态语句块优先于实例变量和普通语句块,静态变量和静态语句块的初始化顺序取决于它们在代码中的顺序。
如果存在继承关系的话,初始化顺序为:
- 父类中的静态变量和静态代码块
- 子类中的静态变量和静态代码块
- 父类中的实例变量和普通代码块
- 父类的构造函数
- 子类中的实例变量和普通代码块
- 子类的构造函数
总结:静态优于普通,父类优于子类
3.2 final
- 类:被修饰的类不可以被继承
- 方法:被修饰的方法不可以被重写
- 变量:被修饰的变量是基本类型,变量的数值不能改变;被修饰的变量是引用类型,变量便不能再引用其他对象,但变量所引用的对象本身是可改变的。
1 | public class Test { |
3.2.1 final、finally、finalize之间区别
- final:主要用于修饰类、变量、方法;
- finally:一般用在try-catch中,表示不管是否出现异常,该代码块都会执行,一般用来存放一些关闭资源的代码。
- finalize:属于Object类中的一个方法, 该方法一般由垃圾回收器来调用,当我们调用 System.gc()方法的时候,由垃圾回收器调用 finalize(), 回收垃圾。但 finalize()方法不一定会被执行。
3.2.2 为什么finalize方法不一定会被执行?
因为JVM并不保证会在任何时刻都执行垃圾回收操作,所以也就无法保证**finalize()方法会被调用。另外,finalize()方法的执行也可能被延迟或者被中断,这可能会导致finalize()**方法不被执行。
因此,我们不能依赖于finalize()方法来进行重要的清理工作,尤其是对于需要确保资源正确释放的程序。相反,应该使用try-with-resources语句或者显式地在程序中调用**close()**方法来确保资源得到正确释放。
3.3 this
3.3.1 引用当前类的实例变量
主要用于形参与成员变量重名的时候,用this来区分
1 | String name; |
3.3.2 调用当前类方法
1 | public class Test { |
3.3.3 调用当前类的构造函数
注意!this()
一定要放在构造函数的第一行,否则编译不通过。
1 | public class Person { |
3.3.4 可以通过this访问静态成员变量吗?
可以。this代表当前对象,可以访问静态成员变量,而静态方法中是不能访问非静态变量,也不能用this引用。
3.4 super
1、 super 可以用来引用直接父类的实例变量。和 this 类似,主要用于区分父类和子类中相同的字段;
2、 super 可以用来调用直接父类构造函数。(注意:super()一定要放在构造函数的第一行) ;
3、 super 可以用来调用直接父类方法。
3.5 this和super的区别
相同点:
- 都必须在构造函数的第一行调用;
- 都指的是对象,均不可以在static环境中使用。
不同点:
- super是对父类构造函数的调用,而this是对重载构造函数的调用;
- super在继承了父类的子类的构造函数中使用,属于不同类间使用,而this是在同一类的不同构造函数中使用。
1 | public class Main { |
3.6 修饰符
- default (即默认,什么也不写): 在同一包内可见,不使用任何修饰符。使用对象:类、接口、变量、方法。
- private : 在同一类内可见。使用对象:变量、方法。 注意:不能修饰类(外部类)
- public : 对所有类可见。使用对象:类、接口、变量、方法
- protected : 对同一包内的类和所有子类可见。使用对象:变量、方法。 注意:不能修饰类(外部类)。
4、面向对象
4.1 面向对象和面向过程的区别
面向对象和面向过程是两种编程的思想。
- 面向对象的编程方式使得每一个类都只做一件事,像雇佣了一群职员,每个人做一件小事,各司其职,最终合作共赢。
- 面向过程会让一个类越来越全能,就像一个管家一样,一个做了所有的事。
面向对象:
- 优点:易维护、易复用、易扩展;
- 缺点:性能比面向过程低。
面向过程:
- 优点:性能比面向对象高。
- 缺点:但没有面向对象易维护、易复用、易扩展,开销比较大,比较消耗资源。
4.2 封装、继承、多态
封装:封装就是隐藏对象的属性和实现细节,仅对外公开接口,控制在程序中属性的读和修改的访问级别。(private/get/set 方法)。就好像我们看不到挂在墙上的空调的内部的零件信息(也就是属性),但是可以通过遥控器(方法)来控制空调。
继承:继承就是子类继承父类的特征和行为,使得子类对象(实例)具有父类的实例域和方法,或子类从父类继承方法,使得子类具有父类相同的行为。 通过使用继承,可以快速地创建新的类,可以提高代码的重用,程序的可维护性,节省大量创建新类的时间 ,提高我们的开发效率。
- 子类拥有父类对象所有的属性和方法(包括私有属性和私有方法),但是父类中的私有属性和方法子类是无法访问,只是拥有。
- 子类可以拥有自己属性和方法,即子类可以对父类进行扩展。
- 子类可以用自己的方式实现父类的方法。
多态:表示一个对象具有多种的状态,具体表现为父类的引用指向子类的实例。
- 对象类型和引用类型之间具有继承(类)/实现(接口)的关系;
- 引用类型变量发出的方法调用的到底是哪个类中的方法,必须在程序运行期间才能确定;
- 多态不能调用“只在子类存在但在父类不存在”的方法;
- 如果子类重写了父类的方法,真正执行的是子类覆盖的方法,如果子类没有覆盖父类的方法,执行的是父类的方法。
在 Java 中实现多态的三个必要条件:继承、重写、向上转型。继承和重写很好理解,向上转型是指在多态中需要将子类的引用赋给父类对象。
1 | public class Main { |
4.3 如何打破一个类的封装得到其方法、属性等信息?
- 反射(Reflection):Java提供了反射机制,允许在运行时获取类的信息,包括方法、属性、构造函数等。通过
Class
类和相关反射类,可以获取类的所有成员信息并进行调用。这是一种高级技术,需要注意不要滥用,因为它可以绕过封装,导致不安全或不稳定的代码。 - 继承:如果一个类是可继承的,可以创建它的子类,然后在子类中访问父类的受保护或包级私有成员。但这种方法需要继承的权限,且不适用于
final
类,所以通过继承来打破封装的行为有局限性。
4.4 面向对象的七大原则
- 单一职责原则:一个类只负责一个功能领域中的对应职责;
- 开闭原则:软件实体应对扩展开放,修改关闭;
- 里氏替换原则:所以引用基类(父类)的地方能够透明地使用其子类对象;
- 依赖倒转原则:抽象不应该依赖于细节,细节应该依赖于抽象;
- 接口隔离原则:使用多个专门的接口,而不使用单一的总接口;
- 合成复用原则:尽量使用对象组合,而不是继承来达到复用的目的;
- 迪米特法则:软件实体应尽可能少地与其他实体发生相互作用。
4.5 Java为什么不支持多继承?
- 因为菱形继承的问题而产生的歧义,考虑一个类 A 有
foo()
方法, 然后 B 和 C 派生自 A, 并且有自己的foo()
实现,现在 D 类使用多个继承派生自 B 和C,如果我们只引用 foo(), 编译器将无法决定它应该调用哪个foo()
; - 多重继承确实使设计复杂化并在强制转换、构造函数链接等过程中产生问题。假设你需要多重继承的情况并不多,简单起见,明智的决定是省略它。此外,Java 可以通过使用接口支持单继承来避免这种歧义。由于接口只有方法声明而且没有提供任何实现,因此只有一个特定方法的实现,因此不会有任何歧义。
5、重载和重写的区别
在Java中,方法重载(overloading)和方法重写(overriding)都是实现多态的方式。
重载:是指在同一个类中定义两个或多个方法,它们具有相同的名称,但是参数列表不同。当程序调用这个方法时,Java编译器根据调用时提供的参数类型和数量来确定使用哪个方法。重载的方法不能根据返回类型进行区分。
重写:是指在一个子类中定义一个与父类中同名、同参数的方法,这个方法会覆盖父类中的方法。当程序使用父类的对象调用这个方法时,实际上会调用子类中的方法。子类中重写的方法返回值类型要 ≤ 父类, 抛出的异常 ≤ 父类,访问修饰符 ≥ 父类;如果父类中该方法访问修饰符为 private/final/static
则子类中就不能重写。
方法的重写要遵循“两同两小一大”
重载就是同一个类中多个同名方法根据不同的传参来执行不同的逻辑处理。
重写就是子类对父类的重新改造,外部样子不能变,内部逻辑可以改变。
6、抽象类和接口的对比
- 抽象类:用来捕捉子类的通用特性的;
- 接口:抽象方法的集合。
6.1 两者异同
相同点:
- 都不能实例化;
- 都包含抽象方法,其子类都必须对这些方法进行重写。
- 都可以有默认实现的方法;
不同点:
- 接口中只能有抽象方法,抽象类中可以有非抽象的方法。
- 接口中变量只能是
public/static/final
类型,抽象类则不一定。 - 一个类可以实现多个接口,但是只能继承一个抽象类。
- 接口的方法默认是
public
,而抽象方法可以有public、protected、default
,但不能用private
。
6.2 接口应用场景
- 类与类之间需要特定的接口进行协调,而不在乎其如何实现。
- 作为能够实现特定功能的标识存在,也可以是什么接口方法都没有的纯粹标识。
- 需要将一组类视为单一的类,而调用者只通过接口来与这组类发生联系。
- 需要实现特定的多项功能,而这些功能之间可能完全没有任何联系。
6.3 抽象类应用场景
- 定义了一组接口,但又不想强迫每个实现类都必须实现所有的接口。可以用抽象类定义一组方法体,甚至可以是空方法体,然后由子类选择自己所感兴趣的方法来覆盖。
- 某些场合下,只靠纯粹的接口不能满足类与类之间的协调,还需要类中表示状态的变量来区别不同的关系。抽象类的中介作用可以很好地满足这一点。
- 规范了一组相互协调的方法,其中一些方法是共同的,与状态无关的,可以共享的,无需子类分别实现;而另一些方法却需要各个子类根据自己特定的状态来实现特定的功能。
一句话总结,在既需要统一的接口,又需要实例变量或缺省的方法的情况下,就可以使用它。
7、内部类
内部类包含:成员内部类、局部内部类、匿名内部类和静态内部类。
7.1 成员内部类
定义:位于另一个类的内部,成员内部类可以无条件访问外部类的所有成员属性和成员方法(包括 private 成员和静态成员)。
1 | public class Outer { |
- 当成员内部类拥有和外部类同名的成员变量或者方法时,默认情况下访问的是成员内部类的成员。如果要访问外部类的同名成员,需要以下面的形式进行访问:外部类.this.成员变量 ;
- 在外部类中如果要访问成员内部类的成员,必须先创建一个成员内部类的对象,再通过指向这个对象的引用来访问;
- 成员内部类是依附外部类而存在的,如果要创建成员内部类的对象,前提是必须存在一个外部类的对象。
1 | public class Outer { |
7.2 局部内部类
定义:是定义在一个方法或者一个作用域里面的类。
它和成员内部类的区别在于局部内部类的访问仅限于方法内或者该作用域内。定义在实例方法中的局部类可以访问外部类的所有变量和方法,定义在静态方法中的局部类只能访问外部类的静态变量和方法。
1 | public class Outer { |
7.3 匿名内部类
定义:没有名字的内部类,在日常开发中使用较多。
注意:使用匿名内部类的前提条件是必须继承一个父类或者实现一个接口。
1 | interface Person{ |
7.4 静态内部类
静态内部类是不需要依赖于外部类的,并且它**不能使用外部类的非 static 成员变量或者方法 **。
7.5 内部类优点
- 内部类不为同一包的其他类所见,具有很好的封装性;
- 匿名内部类可以很方便的定义回调。
- 每个内部类都能独立的继承一个接口的实现,所以无论外部类是否已经继承了某个(接口的)实现,对于内部类都没有影响。
- 内部类有效实现了“多重继承”,优化 Java语言单继承的缺陷。
8、hashCode和equals
8.1 equals
先看String中的equals()
方法的源码:
equals 方法会依次比较引用地址、对象类型、值的内容是否相同,都相同才会返回true。所以equals
方法比==
比较的范围更大、内容更多。
用==
判断为true的两个值,用equals
判断不一定为true。
1 | Integer a = 100; |
8.2 hashCode
hashCode 方法返回对象的散列码,返回值是 int 类型的散列码。散列码的作用是确定该对象在哈希表中的索引位置。
关于hashCode有一些约定:
- 两个对象的值相等,则hashCode一定相同。
- 两个对象有相同的hashCode值,它们不一定相等。
hashCode()
方法默认是对堆上的对象产生独特值,如果没有重写hashCode()
方法,则该类的两个对象的 hashCode 值肯定不同。
8.3 为什么重写equals方法后,hashCode方法也要重写?
以 HashSet
为例,HashSet
的特点是存储元素时无序且唯一,在向 HashSet
中添加对象时,首先会计算对象的 HashCode
值来确定对象的存储位置,如果该位置没有其他对象,直接将该对象添加到该位置;如果该存储位置有存储其他对象(此时新添加的对象和该存储位置的对象的HashCode值相同),则会调用 equals
方法判断两 个对象是否相同,如果相同,则添加对象失败,如果不相同,则会将该对象重新散列到其他位置。
**所以重写 equals 方法后,hashCode 方法不重写的话,会导致所有对象的 HashCode 值都不相同,都能添加成功,那么 HashSet 中会出现很多重复元素。 **
9、Java中只存在值传递
1 | public class Demo { |
运行结果:
可以看到将 a 的值传到 printValue
方法中,并将其值改为 2。但方法调用 结束后,a 的值还是 1,并未发生改变,所以这种情况下为值传递。
- 值传递:是指在调用函数时将实际参数复制一份传递到函数中,这样在函数中如果对参数进行修改,将不会影响到实际参数。
- 引用传递:是指在调用函数时将实际参数的地址直接传递到函数中,那么在 函数中对参数所进行的修改,将影响到实际参数。
可以明显看出,值传递和引用传递的区别在于向方法中传递的是实参的副本还是实参地址。
10、IO流
IO流主要可以分为输入流和输出流。
- 按照操作单元划分,可以划分为字节流和字符流。
- 按照流的角色划分,可以划分为节点流和处理流。
Java IO流的40多个类都是从四个抽象类基类派生出来的:
InputStream
:字节输入流OutputStream
:字节输出流Reader
:字符输入流Writer
:字符输出流
10.1 有了字节流为什么还需要字符流?
虽然字节流是信息处理的最小单位,但字符流是JVM转换得到,这个过程比较耗时,并且还容易出现乱码问题,因此Java在IO中就提供了可直接操作字符的字符流。
10.2 字节流和字符流区别?使用场景?
- 字节流操作的基本单元是字节,字符流操作的基本单元是字符;
- 字节流默认不使用缓冲区,字符流使用缓冲区;
- 字节流通常用于处理二进制数据,不支持直接读写字符,字符流通常用于处理文本数据;
- 在读写文件需要对文本内容进行处理:按行处理、比较特定字符的时候一般会选择字符流;仅仅读写文件,不处理内容,一般选择字节流
11、常见IO模型
在操作系统中, 为了保证操作系统的稳定性和安全性,一个进程的地址空间划分为用户空间 (User space)和 内核空间(Kernel space )。对于一次 IO 访问,数据会先被拷贝到内核的缓冲区中,然后才会从内核的缓冲区拷贝到应用程序的地址空间。
当发起 I/O 调用后,会经历两个步骤:
- 内核等待 I/O 设备准备好数据。
- 内核将数据从内核空间拷贝到用户空间。
由于存在这两个步骤,所以Linux产生了下面五种IO模型(BIO,NIO,IO多路复用,AIO,信号驱动IO),Java中前三种模型比较常见。
11.1 BIO(Blocking IO)
BIO属于同步阻塞IO模型,在该模型中, 应用程序发起 read()
调用后,会一直阻塞,直到内核把数据拷贝到用户空间。
在客户端连接数量不高的情况下,这种模式是没问题的。但是,当面对十万甚至百万级连接的时候,传统的BIO模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。
11.2 NIO(Non-blocking/New IO)
NIO属于同步非阻塞IO模型, 在 Java 1.4 中就引入了 NIO 的概念, 对应于 java.nio 包,提供了 Channel
,Selector
,Buffer
等抽象类。
NIO
有三大核心部分:**Channel
(通道)、Buffer
(缓冲区)、Selector
(选择器)** 。
它是一种支持面向缓冲的,基于通道的 I/O 操作方法。对于高负载、高并发的(网络) 应用,应使用 NIO 。在同步非阻塞 IO 模型中,应用程序会一直发起 read()调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间。
相比于同步阻塞 IO 模型,同步非阻塞 IO 模型确实有了很大改进。通过轮询操作,避免了一直阻塞。但是,这种 IO 模型同样存在问题:应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的。
适用场景:NIO适用于连接数目多而且连接比较短的架构,比如聊天服务器,并发局限于应用中。
11.2.1 Buffer的优点
缓冲区(Buffer)就是在内存中预留指定大小的存储空间用来对输入/输出(I/O)的数据作临时存储,这部分预留的内存空间就叫做缓冲区:
使用缓冲区有这么两个好处:
- 减少实际的物理读写次数。
- 缓冲区在创建时就被分配内存,这块内存区域一直被重用,可以减少动态分配和回收内存的次数。
11.2.2 Channel的优点
Channel是一个通道,可以通过它读取和写入数据,它就像是水管一样,网络数据通过 Channel 进行读取和写入。
通道和流的不同之处在与通道是双向的,流只是在一个方向上移动(一个流必须是 InputStream 或者 OutputStram 的子类),而且通道上可以用于读,写或者同时用于读写。
因为 Channel 是全双工的,所以它可以比流更好的映射底层操作系统的 API。
11.3 IO多路复用
IO 多路复用模型是通过一种机制,让一个进程可以监视多个Socket(套接字描述符)一旦某个Socket就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作,这样就不需要每个用户进程不断的询问内核数据准备好了没。
通过减少无效的系统调用,减少了对 CPU 资源的消耗。
IO 多路复用模型中,
- 首先将进行IO操作的socket添加到select中;
- 然后阻塞等待select系统调用返回,
- 当数据到达时,socket就被激活,select函数返回,用户发起read请求,即可获取数据
select函数避免了NIO轮询等待,创建多个socket,通过不断调用select读取被激活的socket,实现在同一个线程内同时处理多个IO请求。
Java 中的 NIO ,有一个非常重要的选择器 ( Selector ) 的概念,也可以被称为 多路复用器。通过它,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。
常用的 IO 多路复用方式有 select
、poll
和 epoll
。
11.3.1 IO多路复用的三种方式有什么区别?
select
和poll
只会通知用户进程有Socket就绪,但是不确定具体是哪个Socket,需要用户进程一个一个去询问;epoll
则会在通知用户进程有Socket就绪时,把已就绪的Socket写入用户空间,避免了用户询问的过程;
11.4 AIO(Asynchronous IO)
AIO就是异步IO模型,AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2,它是异步 IO 模型。
异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
11.5 BIO、NIO、AIO的区别
举个生活中简单的例子,你妈妈让你烧水
同步阻塞BIO: 小时候你比较笨,坐在水壶旁边傻等着水开(傻傻等待数据的到达)
- 优点:实现简单;
- 缺点:线程阻塞,并发能力差;
同步非阻塞NIO: 等你稍微大一点,你知道烧水的空隙可以去玩,只需时不时来看看水开了没有(轮询)
- 优点:线程不需要阻塞;
- 缺点:每个线程都需要多次轮询,CPU开销比较大;
异步非阻塞AIO : 后来你家用上水开会发声的壶,你只需听到响声就知水开了,等待期间可以随便玩(通知)
- 优点:非阻塞,不需要轮询,并发性高,CPU利用效率高;
- 缺点:不适合轻量级数据传输,因为性价比的太低。
12、Java反射机制
Java 反射机制指在运行状态中,
- 对于任意一个类,都能够获取这个类的所有属性和方法;
- 对于任意一个对象,都能够调用它的任意一个方法和属性。
这种动态获取类信息以及动态调用对象的方法的功能称为 Java 语言的反射机制。
1 | class Person{ |
因为在一个类在 JVM 中只会有一个 Class 实例,所以对 c1、c2、c3、c4 进行 equals 比较时返回的都是 true 。
12.1 反射机制优缺点
- 优点:可以让代码更加灵活、为各种框架提供开箱即用的功能提供了便利;
- 缺点:增加了安全问题。比如可以无视泛型参数的安全检查;反射的性能也要稍差点。
12.2 为什么反射的性能会差?
- 反射需要动态解析类的信息,包括访问修饰符、字段、方法、参数、注解等,因此需要进行大量的运行时检查和解析,会比直接调用代码的执行速度慢。
- 反射机制在执行时会涉及到许多动态分配对象的操作,这些操作会占用大量的内存,并且需要进行垃圾回收,导致额外的性能损耗。
- 反射方法的调用通常比直接调用方法慢很多,因为它需要进行许多额外的操作,如方法解析、参数类型检查、安全检查等。
- 反射方法的调用通常不能进行编译时优化,因此会导致运行时性能低下。
12.3 反射的使用场景
- Spring通过反射来帮我们实例化对象,并放入到IoC容器中 ;
- 使用JDBC链接数据库时加载数据库驱动Class.forName() ;
- 逆向代码 例如反编译;
- 利用反射,在泛型为
Integer
的ArryaList
集合中存放一个String
类型的对象。
13、Java异常
13.1 Java中的异常体系说一下
在Java中,所有的异常都有一个共同的祖先java.lang
包中的 Throwable
类。Throwable
类有两个重要的子类 Exception(异常)和 Error(错误)。
Exception 能被程序本身处理( try/catch
),Error 程序本身⽆法处理,只能尽量避免。
Exception 和 Error 二者都是 Java 异常处理的重要⼦类,各自都包含⼤量⼦类。
13.2 常见异常和错误
异常:
SQLException
操作数据库异常 ;IOException
输入输出异常ConcurrentModificationException
并发修改异常NullPointException
空指针异常ArrayOutOfBoundsException
数组下标越界异常ClassCastException
强制类型转换异常
错误:
VirtualMachineError
JVM运行错误StackOverFlowError
栈溢出错误OutOfMemoryError
堆空间不足错误
13.3 异常处理方式有哪些?
try-catch-finally
:其中try
用来捕获异常,catch
用来处理捕获到的异常,finally
用来关闭一些资源,无论是否捕获或处理异常,finally
都会被执行。throws
:加在方法声明中用来抛出异常,其实并没有处理异常,而是将异常抛给此方法的调用者处理。- 自定义异常类,但是必须继承某个异常类,比如编译时异常或运行时异常。
13.4 finally块一定会被执行吗?
不一定,当出现以下三种特殊情况,finally块不会被执行:
- 在
try
或finally
块中用了System.exit(int)
退出程序; - 程序所在的线程死亡;
- 关闭CPU。
13.4.1 try里面有return,finally还会被还行吗?
会被执行,但是finally里面的语句不会改变return的值。
这是因为在执行的过程中,try
执行到return
时,会先把返回值存在一个临时变量中,只有当finally
被执行完毕后,才会返回return
的结果,因此finally
哪怕会执行,也无法改变返回结果。
13.4.2 如果finally里面也有return,返回的结果已谁为准呢?
如果finally
中也有return
的话,这时候try
里面的return
结果就会丢失,只会返回finally
中的return
结果。
13.5 受检查异常和不受检查异常有什么区别?
- 受检查异常:Java 代码在编译过程中,如果受检查异常没有被
catch
或者throws
关键字处理的话,就没办法通过编译。 - 不受检查异常:Java 代码在编译过程中 ,我们即使不处理不受检查异常也可以正常通过编译。
RuntimeException
及其子类都统称为非受检查异常,常见的有:
NullPointerException
(空指针错误)IllegalArgumentException
(参数错误比如方法入参类型错误)NumberFormatException
(字符串转换为数字格式错误,IllegalArgumentException
的子类)ArrayIndexOutOfBoundsException
(数组越界错误)ClassCastException
(类型转换错误)ArithmeticException
(算术错误)SecurityException
(安全错误比如权限不够)UnsupportedOperationException
(不支持的操作错误比如重复创建同一用户)
除了RuntimeException
及其子类以外,其他的Exception
类及其子类都属于受检查异常 。常见的受检查异常有:IO 相关的异常、ClassNotFoundException
、SQLException
…
13.6 Java中常见的OOM异常有哪些?
- 当堆内存没有足够空间存放新创建的对象时,就会抛出OOM异常。
- 当 Java 进程花费 98% 以上的时间执行 GC,但只恢复了不到 2% 的内存,且该动作连续重复了 5 次,就会抛出
java.lang.OutOfMemoryError:GC overhead limit exceeded
错误。简单地说,就是应用程序已经基本耗尽了所有可用内存, GC 也无法回收。 - 元空间已被用满,通常是因为加载的 class 数目太多或体积太大。
- 每个 Java 线程都需要占用一定的内存空间,当 JVM 向底层操作系统请求创建一个新的 native 线程时,如果没有足够的资源分配就会报此类错误。
- JVM 限制了数组的最大长度,该错误表示程序请求创建的数组超过最大长度限制。
13.7 throw和throws的区别
13.7.1 共同点
两者在抛出异常时,抛出异常的方法并不负责处理,简单来说就是只负责抛出异常,由调用者来处理异常。
13.7.2 不同点
throws
用于方法头,表示的只是异常的声明,而throw
用于方法的内部,跑出的是异常对象;throws
可以一次性抛出多个异常,而throw只能抛出一个异常;throws
抛出异常时,调用者也要声明抛出异常或捕获,否则会导致编译错误,而throw
可以不声明或不捕获,编译器不会报错。
14、Java序列化
序列化就是将对象转换成字节流以便存储或传输,反序列化就是将字节流序列转换回对象的过程。
14.1 为什么要序列化和按序列化?
将 Java 对象转换成字节序列,这些字节序列更加便于通过网络传输或存储在磁盘上,在需要时可以通过反序列化恢复成原来的对象。
通过序列化与反序列化可以实现不同计算机环境或进程间的数据传输与共享。
14.2 为什么要实现Serializable接口?
实现Serializable接口是为了支持序列化和反序列化操作,只是起到一个标记作用。
- 可以确保只有那些被设计为可序列化的类的对象才能序列化;
- 规范了类的行为,表示该类的对象可以被序列化;
15、深拷贝、浅拷贝和引用拷贝
15.1 Java中的深拷贝、浅拷贝和引用拷贝了解吗?
- 引用拷贝:引用拷贝就是复制一个引用,两个不同的引用指向同一个对象;
- 浅拷贝:浅拷贝会在堆上创建一个新的对象(与引用拷贝的区别)。
- 对基本数据类型,拷贝的就是基本数据类型的值;
- 对引用数据类型,拷贝的就是内存地址,只是把内存地址赋给了新对象的成员变量,它们指向的使用一片内存空间。如果改变原对象的内容,浅拷贝的对象内容也会改变;
- 深拷贝:深拷贝也会在堆上创建一个新的对象,并申请了一个新的内存空间,相当于把复制的对象所引用的对象都复制了一遍。
- 对基本数据类型,拷贝的就是基本数据类型的值;
- 对引用数据类型,创建一个新的对象, 并复制其成员变量,两个引用指向两个不同的内存空间,但对象内容相同。改变原始对象的值,深拷贝对象的内容不会改变。
15.2 深拷贝实现方式
重载clone
方法
1 | class Address implements Cloneable{ |
16、常见的Object方法
String toString()
:返回该对象的字符串表示。
Object clone()
:创建与该对象的类相同的新对象。
Class getClass()
:返回一个对象运行时的实例类。
boolean equals(Object)
:比较两对象是否相等。
int hashCode()
:返回该对象的散列码值。
void wait()
:在其他线程调用此对象的notify() 方法或 notifyAll()方法前,导致当前线程等待。
void notify()
:唤醒等待在该对象的监视器上的一个线程。
void notifyAll()
:唤醒等待在该对象的监视器上的全部线程。
void finalize()
:当垃圾回收器确定不存在对该对象的更多引用时,对象垃圾回收器调用该方法。
17、泛型
17.1 什么是泛型?有什么优缺点?
Java 泛型(Generics) 是 JDK 5 中引入的一个新特性。使用泛型参数,可以增强代码的可读性以及稳定性。
编译器可以对泛型参数进行检测,并且通过泛型参数可以指定传入的对象类型。
比如 ArrayList<Person> persons = new ArrayList<Person>()
这行代码就指明了该 ArrayList
对象只能传入 Person
对象,如果传入其他类型的对象就会报错。
并且,原生 List
返回类型是 Object
,需要手动转换类型才能使用,使用泛型后编译器自动转换。
优点:
- 提高Java程序的类型安全。通过变量声明中捕获这一附加的类型信息,泛型允许编译实施这些附加的类型约束。类型错误就可以在编译时被捕获了,而不是在运行时当作ClassCastException展示出来;
- 消除强制类型转换。可以消除代码中的很多强制类型转换;
- 提高运行效率,避免很多不必要的装箱、拆箱操作,提高程序的性能。
缺点:
- 代码复杂性: 有时候泛型代码可能会比非泛型代码更加复杂,特别是当需要处理通配符、边界和类型擦除等特性时。
- 类型擦除: Java中的泛型是通过类型擦除实现的,这意味着在运行时无法获取泛型的实际类型参数,限制了一些高级的泛型操作。
17.2 说说什么是泛型的类型擦除?
下面代码的执行结果是什么?
1 | public static void main(String[] args) { |
因为getClass()
方法获取的是对象运行时的类,那么这个问题就可以转换为ArrayList<String>
和ArrayList<Integer>
的对象在运行时对应的Class是否相同?
通过运行代码,发现程序会打印true
,这也就说明虽然两个List
中都声明了具体的泛型,但是两个List
对象对应的Class是一样的,所以结果是true
。
也就是说,虽然ArrayList<String>
和ArrayList<Integer>
在编译时是不同的类型,但是在编译完成后都被编译器简化成了ArrayList
,这一现象,被称为泛型的类型擦除(Type Erasure)。泛型的本质是参数化类型,而类型擦除使得类型参数只存在于编译期,在运行时,jvm
是并不知道泛型的存在的。
总结:泛型信息只存在于代码编译阶段,在进入jvm之前,与泛型相关的信息会被擦除。
17.3 为什么要进行泛型的类型擦除呢?
主要的目的是避免过多的创建类而造成的运行时过度消耗资源,试想一下,如果用List<A>
表示一个类型,再用List<B>
表示另一个类型,以此类推,无疑会引起类型的数量爆炸。
17.4 反射能获取泛型的类型吗?
反射中的getTypeParameters
方法可以获得类、数组、接口等实体的类型参数,但是不能获得真正的泛型类型,只能获取到泛型的参数占位符。
18、动态代理
18.1 Java中的动态代理是什么?有哪些应用?
当想要给实现了某个接口的类中的方法,额外加一些处理,比如说日志、事务等。可以给这个类传建一个代理,顾名思义就是创建一个新的类,这个类不仅包含原来类方法的功能,而且还在原来的基础上添加了额外的功能。这个代理类并不是定义好的,而是动态生成的,灵活性、扩展性更强。
最经典的应用就是Spring AOP。
18.2 怎么实现动态代理?
每个动态代理类都必须要实现InvocationHandler
这个接口,并且每个代理类的实例都关联到了一个handler
。
当我们通过代理对象调用一个方法时,这个方法的调用就会被转发为由InvocationHandler
这个接口的invoke
方法来调用。