Java篇:大厂Java语法基础高频面试题及参考答案
大厂在校招面试时,很重视面试者基础是否扎实,如果基础扎实,会被认为是可培养人才。大厂有完善培训机制,一些框架可以入职后再学。下面这些面试题是从字节跳动、腾讯、阿里、美团等大厂的几百份面经中挑选最高频的Java语法基础的面试题。如果这些Java语法基础(送分题)还答得不好,就会给面试官基础不牢固的印象。
Java 基础数据类型有哪些?分别占用多少个字节?
Java 的基本数据类型分为四类八种。
第一类是整数类型,包括 byte、short、int 和 long。byte 类型占用 1 个字节,它能表示的范围是 - 128 到 127。主要用于节省内存空间,例如在处理一些底层的、数据量小且范围有限的整数情况,比如在存储文件的字节流数据时可以考虑使用。short 类型占用 2 个字节,范围是 - 32768 到 32767。在一些对内存比较敏感,同时数据范围又不是很大的情况下可以使用,不过在实际开发中用得不是特别多。int 类型是最常用的整数类型,占用 4 个字节,范围是 - 2147483648 到 2147483647,在一般的计数器、数组下标等场景下广泛使用。long 类型占用 8 个字节,它能表示的范围非常大,用于存储较大的整数,比如存储时间戳(从 1970 年 1 月 1 日 00:00:00 UTC 到某个时间点的毫秒数)这种可能很大的整数值。
第二类是浮点类型,包含 float 和 double。float 类型占用 4 个字节,它用于表示单精度浮点数,在存储精度要求不是特别高的小数时使用,例如在一些简单的图形处理中表示坐标位置等。double 类型占用 8 个字节,是双精度浮点数,精度更高,在大多数科学计算、金融计算等对精度要求较高的场景下广泛使用。
第三类是字符类型 char,它占用 2 个字节,用于存储单个字符,字符在 Java 中是使用 Unicode 编码表示的,所以可以存储多种语言的字符。
第四类是布尔类型 boolean,它的取值只有 true 和 false,理论上占用 1 个字节或者 1 位(在实际的虚拟机实现中可能会有所不同),主要用于条件判断等逻辑场景。
在 Java 中基本数据类型 char 类型占几个字节?
在 Java 中,基本数据类型 char 占 2 个字节。这是因为 Java 中的 char 类型是使用 Unicode 编码来表示字符的。Unicode 是一种全球字符编码标准,它为世界上几乎所有的字符都分配了一个唯一的数字编号。
Unicode 编码最初设计是用 16 位(2 个字节)来表示一个字符,这样可以涵盖世界上大部分语言的字符。例如,英文字母、数字、标点符号等常见字符,以及汉字、日文假名、韩文等其他语言的字符都可以用 char 类型来存储。
在内存中,这 2 个字节可以存储从 0 到 65535(2 的 16 次方 - 1)的整数编号,每个编号对应一个特定的字符。例如,字符 'A' 在 Unicode 编码中有一个对应的编号,这个编号就存储在这 2 个字节中。而且,Java 提供了一系列的方法来操作 char 类型,比如可以将一个 char 类型的变量转换为对应的整数编号,也可以通过整数编号来获取对应的字符。
这种 2 字节的设计使得 Java 的 char 类型具有很强的通用性,能够方便地处理各种语言的字符,为国际化的软件应用开发提供了良好的基础。比如在开发一个多语言支持的文本编辑软件或者网站时,char 类型就可以很好地存储和处理不同语言的文本内容。
如何选择 Java 基础数据类型?
在选择 Java 基础数据类型时,需要综合考虑多个因素。
首先是数据范围。如果要处理的数据范围较小,比如在 - 128 到 127 之间的整数,byte 类型是一个很好的选择,它可以有效地节省内存空间。例如,在处理一些简单的文件加密中的字节数据或者简单的小型计数器等场景下可以使用 byte。而如果数据范围可能会超过 byte 但小于 32768,short 类型就比较合适,不过在实际开发中 short 用得相对较少。当数据范围不确定或者可能比较大,但又没有达到 long 那么大的规模时,int 类型是最常用的,像数组下标、循环计数器等场景基本都是用 int。如果数据范围非常大,例如处理一些天文数字或者时间戳等信息,long 类型就是必须的。
其次是数据精度。对于小数来说,如果精度要求不是特别高,比如在简单的图形绘制中表示一些坐标位置,float 类型可以满足要求。但如果是在科学计算、金融计算等对精度要求很高的场景下,就需要使用 double 类型。因为 double 的精度更高,可以更准确地表示小数的值。
然后是数据类型的用途。对于字符数据,就需要使用 char 类型,它用于存储单个字符,并且因为是基于 Unicode 编码,能够存储多种语言的字符。在处理文本信息,如读取文件中的字符或者在用户界面中处理字符输入输出等场景下会用到 char。而对于逻辑判断,只有 true 和 false 两种状态的情况,就使用 boolean 类型,在条件判断语句(如 if - else 语句)、循环控制(如 while 循环的条件判断)等逻辑场景下广泛使用。
另外,还需要考虑内存占用和性能。较小的数据类型通常占用较少的内存,在处理大量数据时,如果能合理选择数据类型可以节省内存空间。但也要注意,在一些处理器架构上,某些数据类型的操作可能会更快。例如,在一些 32 位的处理器上,对 int 类型的操作可能比 byte 或者 short 类型更高效,因为处理器的字长是 32 位,与 int 类型的长度相匹配。
引用数据类型有哪些?
在 Java 中,引用数据类型主要包括类(class)、接口(interface)、数组(array)和枚举(enum)。
类是引用数据类型中最常见的一种。它是面向对象编程的核心概念,用于封装数据和行为。例如,我们可以定义一个名为 Person 的类,在这个类中可以包含姓名、年龄等属性,以及说话、行走等行为(通过方法来实现)。当我们创建一个 Person 类的对象时,实际上是在内存中开辟了一块空间来存储这个对象的属性值,而变量只是引用这个对象在内存中的位置。
接口也是引用数据类型。它定义了一组方法签名,但没有具体的实现。接口主要用于定义规范,实现多态等面向对象的设计模式。比如,我们可以定义一个名为 Drawable 的接口,里面有一个 draw 方法。然后不同的类可以实现这个接口,以提供自己的绘制逻辑。
数组是一种特殊的引用数据类型,它用于存储多个相同类型的数据。
在内存中,数组对象存储了数组元素的引用,这些元素可以是基本数据类型或者引用数据类型。
枚举是一种特殊的数据类型,它允许我们定义一组命名的常量。枚举类型的值是有限的、预定义的,它提供了一种更安全、更可读的方式来处理一组固定的值,而不是使用整数或者字符串来表示这些固定的值。
面向对象编程是什么?
面向对象编程(Object - Oriented Programming,简称 OOP)是一种编程范式,它以对象为核心来组织程序代码。
在面向对象编程中,对象是类的实例。类是一种抽象的数据类型,它定义了对象的属性和行为。例如,我们可以定义一个 “汽车” 类,这个类里面可以包含汽车的各种属性,如颜色、品牌、速度等,还可以包含汽车的行为,如启动、加速、刹车等。这些属性通过变量来表示,行为通过方法来表示。当我们创建一个 “汽车” 类的对象时,就好像是在现实世界中制造了一辆具体的汽车,这个对象有自己的颜色、品牌和速度等属性,并且可以执行启动、加速和刹车等操作。
面向对象编程有三个主要的特性:封装、继承和多态。
封装是指将对象的属性和行为封装在一个类中,并且对外部隐藏对象的内部细节。这样做的好处是可以提高代码的安全性和可维护性。例如,对于汽车的速度属性,我们可以通过方法来设置和获取速度,而不是直接让外部代码访问这个属性,这样可以防止不合理的速度设置。
继承是一种代码复用的机制。它允许我们创建一个新的类(子类),这个子类继承自一个现有的类(父类)。子类可以继承父类的属性和行为,并且可以添加自己新的属性和行为。例如,我们可以定义一个 “跑车” 类,它继承自 “汽车” 类,跑车类除了具有汽车的一般属性和行为外,还可以有自己特殊的属性,如最高时速更高等。
多态是指同一种行为在不同的对象中有不同的实现方式。例如,在一个动物类层次结构中,“动物” 类有一个 “叫” 的行为。当我们创建 “狗” 类和 “猫” 类作为 “动物” 类的子类时,“狗” 的 “叫” 可能是 “汪汪” 声,“猫” 的 “叫” 可能是 “喵喵” 声。在程序中,我们可以通过父类的引用指向子类的对象,当调用 “叫” 这个行为时,会根据对象的实际类型来执行相应的行为。
面向对象编程还包括一些其他的概念,如抽象类、接口等。抽象类是一种不能被实例化的类,它主要用于定义一些抽象的方法和属性,作为其他类的基类。接口则是一种更纯粹的抽象,它只定义了一组方法签名,没有具体的实现,用于规定类的行为规范,实现多态等高级的面向对象设计。通过这些概念,我们可以构建出复杂的、易于维护和扩展的软件系统。
Java 的三大特性是什么?
Java 的三大特性是封装、继承和多态。
封装是把对象的属性和操作(方法)结合为一个独立的整体,并尽可能隐藏对象的内部细节。通过封装,外界不能直接访问对象的私有属性,只能通过公共的方法来访问和修改。例如,一个银行账户类(BankAccount),账户余额(balance)是一个私有属性,不能被外部随意修改。可以通过存款(deposit)和取款(withdraw)等公共方法来操作余额。这样做的好处是可以保证数据的安全性和一致性。如果没有封装,外部代码可以随意修改余额,可能会导致数据错误。同时,封装也使得代码的维护更加容易,因为对属性的操作都集中在类的方法中,当需要修改属性的操作逻辑时,只需要修改类内部的方法,而不用在所有使用该属性的地方进行修改。
继承是一种代码复用机制,允许创建一个新的类(子类)继承现有类(父类)的属性和方法。子类可以继承父类的非私有属性和方法,并且可以添加自己的新属性和方法。比如,有一个动物(Animal)类,它有属性如动物的名称(name)和方法如发出声音(makeSound)。然后有一个狗(Dog)类继承自动物类,狗类除了继承动物类的名称属性和发出声音的方法外,还可以添加自己特有的属性,如狗的品种(breed),并且可以重写发出声音的方法,让狗发出 “汪汪” 声。通过继承,可以构建类的层次结构,提高代码的复用性,减少代码的冗余。
多态是指同一种行为在不同的对象中有不同的实现方式。多态主要通过方法重写和方法重载来实现。方法重写是在子类中重新定义父类中已经存在的方法,当通过父类引用指向子类对象并调用重写的方法时,会执行子类中的方法。例如,动物类中有发出声音的方法,猫类和狗类继承动物类并分别重写这个方法,当使用动物类的引用指向猫类或者狗类对象并调用发出声音的方法时,会分别发出 “喵喵” 和 “汪汪” 声。方法重载是在同一个类中定义多个同名方法,但参数列表不同,根据传入的参数不同来执行不同的方法。多态使得程序具有更好的扩展性和灵活性,能够根据对象的实际类型来执行相应的操作。
Java 中的抽象类与接口有何区别?请给出一个实际例子。
在 Java 中,抽象类和接口有许多区别。
首先,抽象类是一种不能被实例化的类,它可以包含抽象方法和非抽象方法。抽象方法只有方法签名,没有方法体,它强制子类去实现这些抽象方法。非抽象方法则可以有具体的实现,子类可以直接继承这些方法。例如,有一个抽象的图形(Shape)抽象类,它有一个抽象方法计算面积(calculateArea),因为不同的图形(如三角形、圆形)计算面积的方式不同。同时,它可以有一个非抽象方法打印图形信息(printShapeInfo),这个方法在所有图形类中都有相同的基本逻辑,如打印图形的名称。
接口则是完全抽象的,它只包含方法签名,没有方法体,并且接口中的方法默认都是 public 和 abstract 的。接口主要用于定义一组规范,实现类必须实现接口中的所有方法。例如,有一个可绘制(Drawable)接口,里面有一个绘制(draw)方法。这个接口规定了任何实现它的类都必须能够实现绘制自己的功能。
在继承方面,一个类只能继承一个抽象类,这体现了 Java 的单继承原则。但是一个类可以实现多个接口,这样可以让一个类具有多种不同的行为规范。比如,有一个汽车(Car)类,它可以实现可驾驶(Drivable)接口和可维修(Repairable)接口,分别规定了汽车可以被驾驶和可以被维修的行为规范。
从设计角度看,抽象类更多地用于在一组相关的类中提取公共的属性和行为,并且可以部分实现这些行为。而接口更多地用于定义不同类之间的一种契约或者规范,让这些类能够以一种统一的方式进行交互。例如,在一个图形绘制系统中,抽象类 Shape 可以包含图形的公共属性如颜色、位置等,而接口 Drawable 可以用于规定所有能够被绘制的图形都必须实现的绘制方法,这样可以方便地对不同的图形进行绘制操作。
Java 实例化对象的方式有哪些?
在 Java 中有多种实例化对象的方式。
一种常见的方式是使用 new 关键字。例如,有一个简单的类 Person,包含姓名(name)和年龄(age)两个属性,还有一个打招呼(sayHello)的方法。
class Person { String name; int age; void sayHello() { System.out.println("我叫" + name + ",今年" + age + "岁。"); } }
可以通过以下方式实例化对象:
Person person = new Person(); person.name = "张三"; person.age = 20; person.sayHello();
这里使用 new 关键字在堆内存中为 Person 对象分配空间,然后可以通过对象引用(person)来访问对象的属性和方法。
另外,还可以通过反射来实例化对象。反射是 Java 的一个强大特性,它允许在运行时检查和操作类、方法、属性等。例如,对于上述的 Person 类,可以通过以下反射方式来实例化:
import java.lang.reflect.Constructor; try { Class<?> personClass = Class.forName("Person"); Constructor<?> constructor = personClass.getConstructor(); Person personByReflection = (Person) constructor.newInstance(); personByReflection.name = "李四"; personByReflection.age = 22; personByReflection.sayHello(); } catch (Exception e) { e.printStackTrace(); }
在这个例子中,首先通过类的全限定名(在这里假设 Person 类在默认包下)获取 Class 对象,然后获取类的构造函数,最后通过构造函数来实例化对象。反射方式在一些框架开发、动态代理等场景下非常有用,比如在 Spring 框架中,通过反射来实例化和管理 Bean 对象。
还有一种方式是通过克隆(Clone)来创建对象。要使用克隆,需要让类实现 Cloneable 接口,并重写 clone 方法。例如:
class CloneablePerson implements Cloneable { String name; int age; @Override public Object clone() throws CloneNotSupportedException { return super.clone(); } }
可以这样克隆对象:
try { CloneablePerson original = new CloneablePerson(); original.name = "王五"; original.age = 24; CloneablePerson cloned = (CloneablePerson) original.clone(); } catch (CloneNotSupportedException e) { e.printStackTrace(); }
克隆可以快速创建一个与已有对象内容相同的新对象,不过对于引用类型的属性,只是复制了引用,可能需要进一步处理来实现深克隆。
重载和重写有什么区别?形参和实参的区别?重写中形参可以一样吗?重载呢?
重载(Overloading)是指在同一个类中,有多个方法具有相同的名字,但它们的参数列表不同(参数的个数、类型或者顺序不同)。例如,有一个计算面积的类 ShapeCalculator,它可以有多个计算面积的方法:一个方法用于计算圆形的面积,接收圆的半径作为参数;另一个方法用于计算矩形的面积,接收矩形的长和宽作为参数。
class ShapeCalculator { double calculateArea(double radius) { return Math.PI * radius * radius; } double calculateArea(double length, double width) { return length * width; } }
当调用这些方法时,编译器会根据传入的实际参数的类型、个数和顺序来确定调用哪一个具体的方法。重载主要是为了方便程序员使用相同的方法名来表示相似的操作,增强了代码的可读性和可维护性。
重写(Overriding)是在继承关系中发生的,子类重新定义了父类中已经存在的方法。要求方法名、参数列表和返回值类型(对于基本数据类型和 void 必须相同,对于引用数据类型可以是子类)都要和父类的方法相同。例如,有一个动物类 Animal,它有一个发出声音的方法 makeSound,而狗类 Dog 继承自 Animal 类,并重写了 makeSound 方法,让狗发出 “汪汪” 声。
class Animal { void makeSound() { System.out.println("动物发出声音"); } } class Dog extends Animal { @Override void makeSound() { System.out.println("汪汪"); } }
重写体现了多态性,使得程序能够根据对象的实际类型来执行相应的方法,这在面向对象编程中是非常重要的概念,用于实现不同对象对同一行为的不同实现。总的来说,重载是在一个类内部的多态表现,重写是在继承关系中的多态体现。
形参(形式参数)是在方法定义中声明的参数,它用于接收方法调用时传入的实际参数。例如,在一个方法定义 public void printName (String name) 中,name 就是形参,它规定了这个方法需要接收一个字符串类型的参数。实参(实际参数)是在调用方法时实际传递给方法的参数。比如,在 printName ("张三") 这个调用中,“张三” 就是实参。
在重写中,形参必须和父类中的方法形参完全一样,这是重写的规则之一。如果形参不同,那就不是重写而是定义了一个新的方法。在重载中,形参必须不同,这是判断重载的关键因素之一。因为如果形参相同,仅仅靠返回值等其他因素无法区分方法,不符合重载的定义,如前面提到的会导致编译错误。
如果返回值不同其他都一样,是可以形成重写或者重载吗?会有什么问题?
在 Java 中,如果仅仅是返回值不同而其他(方法名、参数列表)都一样,这既不是重载也不是重写。
对于重载来说,如前面所述,其判断依据是方法名相同但参数列表不同。如果只有返回值不同,编译器无法根据传入的参数来区分调用哪一个方法,这就违反了重载的规则。例如,假设有一个类有两个方法,方法名都是 test,参数都是一个整数,只是一个返回整数,一个返回字符串,当调用 test 方法并传入一个整数参数时,编译器不知道该调用哪一个方法,这会导致编译错误。
对于重写而言,规则要求方法名、参数列表和返回值类型(对于基本数据类型和 void 必须相同,对于引用数据类型可以是子类)都要和父类的方法相同。如果仅仅返回值不同,这也不符合重写的定义。这样做会导致编译器认为子类中的方法和父类中的方法是两个不同的方法,而不是重写关系。当通过父类引用指向子类对象并调用这个方法时,不会按照重写的规则执行子类中的方法,而是会执行父类中的方法,这可能会导致程序逻辑出现错误。例如,父类中有一个方法返回一个整数,子类中同名同参数的方法返回一个字符串,当通过父类引用调用这个方法时,期望得到一个整数结果,但实际得到的可能是不符合预期的字符串,破坏了程序的一致性和多态性。
Java 类加载器有哪些特点?
Java 类加载器是 Java 运行时环境(JRE)的一部分,用于将类的字节码文件加载到内存中。
首先,Java 类加载器具有层次结构。最顶层是引导类加载器(Bootstrap Class Loader),它是用本地代码实现的,主要负责加载 Java 的核心库,如 java.lang 包中的类。这些类对于 Java 程序的运行是最基础的,比如 Object 类、String 类等都是由引导类加载器加载的。由于它是使用本地代码编写,在 Java 代码中无法直接获取它的引用。
其次是扩展类加载器(Extension Class Loader),它的作用是加载 Java 的扩展库。这些扩展库通常位于 JDK 安装目录下的 jre/lib/ext 目录中。它是由 Java 代码实现的,是 Sun 公司(现在是 Oracle 公司)为了方便开发者加载一些标准扩展库而提供的加载器。
然后是应用程序类加载器(Application Class Loader),也称为系统类加载器,它主要负责加载应用程序的类路径(classpath)下的类。这是开发者最常接触的类加载器,因为它加载的是我们自己编写的类以及第三方库中的类。
Java 类加载器还有双亲委派模型的特点。当一个类加载器收到加载类的请求时,它首先会把这个请求委派给它的父类加载器去完成。只有当父类加载器无法完成加载任务时,才会由自己来加载。例如,当应用程序类加载器收到加载一个类的请求时,它会先让扩展类加载器尝试加载,扩展类加载器又会先让引导类加载器尝试加载。这种机制的好处是保证了 Java 核心库的类加载优先级最高,避免了自定义类覆盖 Java 核心库的类,从而保证了 Java 程序的安全性和稳定性。
另外,类加载器还支持自定义。开发者可以根据自己的需求编写自定义的类加载器,例如,可以通过自定义类加载器来实现从网络上加载类的字节码文件,或者对类的字节码进行加密和解密等特殊操作。
所有异常的父类是什么?Java 空指针异常属于哪种异常?
在 Java 中,所有异常的父类是 Throwable 类。Throwable 类有两个直接子类:Error 和 Exception。
Error 表示严重的错误,通常是 JVM 或系统级别的问题,一般不应该由程序代码处理,因为这些错误通常是不可恢复的。例如 OutOfMemoryError 表示内存溢出,StackOverflowError 表示栈溢出,这些情况通常是由于系统资源耗尽或程序错误导致的,应用程序很难从这些错误中恢复。
Exception 是程序中可能会出现的异常,并且通常是可以被程序代码处理的。Exception 又分为检查异常(Checked Exception)和运行时异常(Runtime Exception)。
检查异常是在编译时必须处理的异常,例如 IOException、SQLException 等。如果一个方法可能会抛出检查异常,那么该方法要么使用 try-catch 语句处理这个异常,要么在方法签名中使用 throws 关键字声明抛出该异常,否则代码将无法编译通过。
Java 空指针异常(NullPointerException)属于运行时异常,它是由于程序试图访问一个空对象的成员而引发的。这种异常通常是由于代码逻辑错误,如未正确初始化对象、对象引用被错误地设置为 null 或在不应该为 null 的地方使用了 null 引用等原因导致的。
请解释 Java 中的异常处理机制。
Java 中的异常处理机制是一种用于处理程序运行过程中出现的错误和异常情况的结构化方式。
当一个方法在执行过程中遇到了异常情况,例如,试图打开一个不存在的文件、数组下标越界或者网络连接中断等,它会抛出一个异常对象。这个异常对象是Throwable类或者其子类的一个实例。异常分为两种主要类型:检查异常(Checked Exceptions)和运行时异常(Runtime Exceptions)。
检查异常是那些在编译时编译器会强制要求处理的异常。例如IOException,当一个方法可能抛出检查异常时,调用这个方法的代码要么使用try - catch语句块来捕获并处理这个异常,要么在方法签名中使用throws关键字将这个异常继续向上抛出。
import java.io.File; import java.io.FileInputStream; import java.io.IOException; class ExceptionExample { public static void main(String[] args) { try { File file = new File("nonexistent.txt"); FileInputStream fis = new FileInputStream(file); } catch (IOException e) { System.out.println("文件不存在或者无法读取"); e.printStackTrace(); } } }
在这个例子中,试图打开一个不存在的文件会抛出IOException,通过try - catch语句块来捕获这个异常并进行处理。
运行时异常是在程序运行时可能会出现的异常,例如NullPointerException或者ArrayIndexOutOfBoundsException。编译器不会强制要求对运行时异常进行捕获和处理,因为这些异常通常是由于编程错误导致的,理论上可以通过更好的编程习惯来避免。
异常处理机制还包括finally块。finally块中的代码无论是否有异常被抛出,也无论异常是否被捕获,都会被执行。这在一些资源释放的场景中非常有用,例如关闭文件流、释放数据库连接等。
通过合理地使用异常处理机制,可以使程序更加健壮,能够在遇到错误和异常情况时进行适当的处理,而不是直接崩溃。
Java 异常继承体系是怎样的?
Java 异常继承体系的顶层是 Throwable 类。它是所有异常和错误的超类。Throwable 类有两个重要的子类,分别是 Exception 和 Error。
Exception 类用于表示程序可以捕获和处理的异常情况。例如,当用户输入的数据不符合要求、文件不存在或者网络连接中断等情况时,就会抛出 Exception 类型的异常。Exception 又可以分为运行时异常(RuntimeException)和非运行时异常(也称为检查异常,Checked Exception)。
运行时异常是指在 Java 程序运行过程中可能会出现的异常,它继承自 RuntimeException 类。这类异常通常是由于程序员的编码错误导致的,比如空指针异常(NullPointerException)、数组下标越界异常(ArrayIndexOutOfBoundsException)和类型转换异常(ClassCastException)等。这些异常编译器不会强制要求程序员进行捕获和处理,因为它们可能在程序的很多地方出现,如果强制要求处理会使代码变得非常繁琐。
非运行时异常(检查异常)是指在编译时编译器就会检查是否对其进行处理的异常。这些异常通常是由于外部因素导致的,比如文件读取异常(IOException)、SQL 异常(SQLException)等。当一个方法可能抛出检查异常时,调用这个方法的代码要么捕获这个异常并进行处理,要么将这个异常继续向上抛出。
Error 类用于表示严重的错误情况,这些错误通常是不可恢复的,比如虚拟机错误(VirtualMachineError)、内存溢出错误(OutOfMemoryError)等。当出现 Error 时,一般来说程序很难从这种错误中恢复过来,因为这些错误通常是由于系统资源耗尽或者环境问题导致的。
介绍一下 Java 中的弱引用和软引用。
在 Java 中,软引用(SoftReference)和弱引用(WeakReference)都是用于处理对象引用的特殊类型,它们主要是为了帮助开发者更灵活地管理内存。
软引用是一种相对较强的引用。当内存足够时,软引用所指向的对象不会被垃圾回收器回收;只有当内存不足时,垃圾回收器才会回收软引用指向的对象。这使得软引用非常适合用于缓存场景。例如,在一个图像加载应用中,我们可以使用软引用存储已经加载过的图像对象。当内存充足时,这些图像对象可以保留在内存中,以便快速再次访问;而当内存紧张时,这些对象可以被回收,以释放内存空间。软引用可以通过 Java 的 SoftReference 类来实现。
import java.lang.ref.SoftReference; class SoftRefExample { public static void main(String[] args) { SoftReference<String> softRef = new SoftReference<>("Hello"); String str = softRef.get(); if (str!= null) { System.out.println(str); } } }
弱引用则是一种更弱的引用。一旦垃圾回收器发现一个对象只有弱引用指向它,那么这个对象就会被立即回收。弱引用通常用于一些需要更积极地回收内存的场景,或者用于解决一些对象引用导致的内存泄漏问题。例如,在一个容器类中,如果希望容器中的元素在没有其他强引用指向它们时能够被快速回收,就可以使用弱引用来存储这些元素。Java 中通过 WeakReference 类来实现弱引用。
import java.lang.ref.WeakReference; class WeakRefExample { public static void main(String[] args) { WeakReference<String> weakRef = new WeakReference<>("World"); String str = weakRef.get(); if (str!= null) { System.out.println(str); } } }
使用软引用和弱引用时,需要注意它们的生命周期和回收机制。在实际应用中,要根据具体的业务场景和内存管理需求来合理选择使用软引用还是弱引用。同时,由于它们的回收时机具有不确定性,可能会导致对象在某些情况下不可用,因此在使用时需要谨慎处理空引用的情况。
方法中的值传递是弱引用还是软引用?
在 Java 方法中是值传递,既不是弱引用也不是软引用。
值传递意味着当一个变量作为参数传递给一个方法时,实际上传递的是这个变量的值的副本。对于基本数据类型,传递的是实际的值。例如,如果有一个方法接收一个整数参数,传递进去的是这个整数的一个副本。在方法内部对这个参数进行修改,不会影响到方法外部的原始变量。
class ValuePassingExample { public static void main(String[] args) { int num = 5; modifyValue(num); System.out.println(num); } static void modifyValue(int n) { n = 10; } }
在这个例子中,虽然在 modifyValue 方法中修改了参数 n 的值为 10,但在方法外部,num 的值仍然是 5,因为传递进去的是 num 的值的副本。
对于引用数据类型,传递的是对象的引用的副本。这可能会让人误解为是传递对象本身,但实际上还是有区别的。当在方法内部通过这个引用副本修改对象的属性时,会影响到外部对象,因为引用副本和外部引用指向的是同一个对象。但是如果在方法内部重新赋值这个引用,例如让它指向一个新的对象,这不会影响到外部的引用。
这和弱引用、软引用的概念完全不同。弱引用和软引用主要是用于在垃圾回收机制中控制对象的生命周期,而方法中的值传递是关于参数如何在方法调用过程中传递和处理的机制。
对于 Integer,127 == Integer.valueOf (127) 是否成立?为什么?
在 Java 中,对于127 == Integer.valueOf(127)这个表达式是成立的。
这是因为 Java 的Integer类有一个缓存机制。Integer类在内部缓存了一个范围是-128到127的Integer对象。当调用Integer.valueOf()方法并且传入的值在这个范围内时,它会直接返回缓存中的对象,而不是重新创建一个新的Integer对象。
例如,当执行Integer a = 127;和Integer b = Integer.valueOf(127);时,实际上a和b指向的是同一个缓存中的对象。在 Java 中,==操作符用于比较两个对象的引用是否相同。由于a和b引用的是同一个缓存中的对象,所以127 == Integer.valueOf(127)这个比较结果为真。
但是,如果传入的值超出了这个缓存范围,例如Integer c = 128;和Integer d = Integer.valueOf(128);,此时c和d指向的是不同的Integer对象,因为超出缓存范围后,Integer.valueOf()会重新创建新的对象,那么128 == Integer.valueOf(128)这个比较结果就为假,这种情况下应该使用equals()方法来比较两个Integer对象的值是否相等,因为equals()方法比较的是对象所包含的值,而不是引用。
这种缓存机制的存在是为了提高性能,在很多情况下,对于较小的整数值的频繁使用,通过缓存可以减少对象创建的开销,提高程序的运行效率。
continue、break 和 return 的区别是什么?
在 Java 中,continue、break 和 return 是用于控制程序流程的关键字,但它们的功能和作用场景有很大区别。
continue 主要用于循环结构(for、while、do - while)中。当程序执行到 continue 语句时,它会立即终止当前这一轮循环的剩余部分代码的执行,然后直接开始下一轮循环。例如,在一个从 1 到 10 的 for 循环中,如果有一个条件判断,当满足这个条件时使用 continue,那么满足条件的那次循环中 continue 之后的代码不会执行,循环会直接进入下一轮。这对于跳过某些不需要处理的循环情况很有用,比如在遍历一个数组查找特定元素时,遇到不符合要求的元素就可以使用 continue 跳过对它的其他处理。
break 也用于循环结构和 switch 语句中。在循环中,当执行到 break 语句时,它会立即终止整个循环,程序流程会跳出循环,继续执行循环之后的代码。比如,在一个无限循环中,当满足某个退出条件时使用 break 可以让循环结束。在 switch 语句中,break 用于结束当前的 case 分支,防止程序继续执行下一个 case 分支。如果没有 break,程序会从匹配的 case 分支开始,一直执行到 switch 语句块的结尾或者遇到下一个 break。
return 则用于方法中。它的作用是结束方法的执行,并返回一个值(如果方法有返回值类型,返回值类型必须和方法声明的返回值类型兼容)。当程序执行到 return 语句时,方法立即结束,控制权返回给调用该方法的地方。如果方法没有返回值类型(void),return 语句可以单独使用来结束方法。例如,在一个验证用户输入是否合法的方法中,当发现输入不合法时,可以使用 return 直接结束方法并返回错误信息。return 在构建方法逻辑和返回结果方面起到了关键作用,是方法与调用者之间交互的重要手段。
把一个 int 数字转为 String 对象,有几种方法?
在 Java 中,将一个 int 数字转换为 String 对象有多种方法。
一种常见的方法是使用Integer
类的toString()
方法。例如,有一个 int 变量num = 123
,可以通过Integer.toString(num)
来将其转换为String
对象。这种方法简单直接,它返回一个表示指定整数的String
对象。这个方法内部实现了将数字的每一位转换为字符,然后拼接成一个字符串的过程。
另外,还可以使用String
类的valueOf()
方法。对于同样的num
,可以使用String.valueOf(num)
来进行转换。这个方法实际上是一个重载的方法,它可以接受多种基本数据类型和对象作为参数,并将其转换为String
对象。在处理 int 转换为String
时,它的内部实现可能和Integer.toString()
类似,但是它提供了一种更通用的将各种类型转换为String
的方式,因为它可以处理不同的数据类型,而不仅仅是整数。
还有一种方法是通过连接空字符串来实现转换。例如,num + ""
,在 Java 中,当一个基本数据类型和一个字符串进行连接操作时,基本数据类型会自动转换为字符串。这种方式在代码书写上比较简洁,但是在性能上可能不如前两种方法,因为它涉及到字符串连接操作的内部机制,可能会创建一些额外的临时对象。不过在简单的代码场景下,这种方式也是很方便的。
如果使用 new String (1),这里面会创建几个对象?
当使用new String(1)
这种方式时,实际上会创建两个对象。
首先,在 Java 中,字符(char
)可以作为参数传递给String
的构造函数来创建一个字符串。这里的1
会被当作char
类型的值(其对应的字符是'\u0001'
,这是一个不可见的控制字符)。当执行这个构造函数时,会先创建一个char
数组来存放这个字符。这个char
数组就是一个对象。
然后,String
构造函数会使用这个char
数组来创建一个String
对象。String
类在内部会对这个char
数组进行封装,形成一个新的String
对象。这个String
对象包含了对char
数组的引用,以及一些其他用于管理字符串的属性(如长度等)。
从内存的角度来看,char
数组对象在堆内存中有自己的空间,String
对象也有自己的空间。String
对象中的一个成员变量指向了这个char
数组对象,用于存储和管理字符串的字符内容。这种创建方式在某些情况下可能会导致额外的内存开销,因为它涉及到了中间char
数组对象的创建。如果只是简单地想要一个包含特定字符的字符串,还可以考虑使用其他更简洁的方式,比如通过字符串常量或者String
类的其他构造函数来实现,这样可能会减少不必要的对象创建,提高程序的性能和内存利用率。
在 Java 中两个字符串拼接起来,怎么做性能最高?
在 Java 中,有多种方式拼接字符串,不同方式的性能有所差异。
使用StringBuilder
或StringBuffer
(在单线程环境下优先使用StringBuilder
)进行字符串拼接性能最高。String
对象在 Java 中是不可变的,这意味着每次对String
进行拼接操作时,都会创建一个新的String
对象。例如,当执行
String str1 = "Hello"; String str2 = "World"; String result = str1 + str2;
时,实际上会先创建一个包含"Hello"
的String
对象,然后创建一个包含"World"
的String
对象,最后在拼接时会创建一个新的String
对象来存储"HelloWorld"
,之前的两个String
对象如果没有其他引用,就会等待垃圾回收。
而StringBuilder
和StringBuffer
是可变的字符序列。以StringBuilder
为例,当要拼接两个字符串时,可以这样操作:
StringBuilder sb = new StringBuilder(); sb.append("Hello"); sb.append("World"); String result = sb.toString();
。StringBuilder
内部有一个字符数组来存储字符序列,当调用append
方法时,它会将新的字符添加到这个字符数组中(如果数组容量不够,会自动扩容),而不是创建新的String
对象。最后通过toString
方法返回一个String
对象。这种方式避免了多次创建String
对象的开销,大大提高了性能。
在多线程环境下,StringBuffer
是线程安全的,因为它的方法(如append
)是使用synchronized
关键字修饰的,这保证了在多个线程同时访问和修改StringBuffer
对象时不会出现数据不一致的情况。不过,这种同步机制会带来一定的性能开销,所以在单线程环境下,StringBuilder
是更好的选择。
== 和 equals () 有什么区别?
在 Java 中,==
和equals()
有很大的区别。
==
是一个运算符,它主要用于比较两个变量的值是否相等。对于基本数据类型,它比较的是实际的值。例如,对于两个int
变量a = 5
和b = 5
,a == b
的结果为true
,因为它们的值是相同的。对于引用数据类型,==
比较的是两个对象的引用是否相同,也就是看这两个变量是否指向内存中的同一个对象。例如,有两个String
对象str1 = new String("Hello");
和str2 = new String("Hello");
,str1 == str2
的结果为false
,因为尽管它们的内容相同,但它们是两个不同的对象,在内存中有不同的存储位置。
equals()
是Object
类中的一个方法,所有的类都继承自Object
类,所以所有的对象都有equals()
方法。在Object
类中,equals()
方法的默认实现实际上和==
运算符对于引用比较的行为是一样的。但是,很多类会重写equals()
方法来提供更符合业务逻辑的比较方式。例如,String
类重写了equals()
方法,它会比较两个String
对象的内容是否相同。对于前面提到的str1
和str2
,str1.equals(str2)
的结果为true
,因为String
类的equals()
方法比较的是字符串的字符序列是否相同,而不是对象引用。
在实际编程中,当需要比较基本数据类型的值时,使用==
;当需要比较对象的内容是否相等(特别是对于自定义类)时,需要根据类是否正确重写了equals()
方法来决定是否使用equals()
进行比较。如果没有重写equals()
方法,可能会得到不符合预期的比较结果。
解释一下多态的概念
多态是面向对象编程中的一个重要概念,它允许不同类的对象对同一消息做出响应。多态主要体现在以下几个方面:
首先,多态基于继承和接口实现。在继承关系中,子类可以继承父类的方法和属性,并且可以重写父类的方法。当使用父类的引用指向子类的对象时,通过这个父类引用调用被重写的方法,实际执行的是子类的方法,而不是父类的原方法。这就是多态的一种表现形式,也称为方法重写多态。例如,假设有一个父类 Animal 有一个方法叫 makeSound (),而子类 Dog 和 Cat 都继承自 Animal 并分别重写了 makeSound () 方法。当我们创建一个 Animal 类型的引用变量 animal,并将其指向 Dog 或 Cat 的对象时,调用 animal.makeSound () 会根据具体指向的对象不同而产生不同的声音。
其次,多态也可以通过接口来实现。接口定义了一组方法签名,但不包含具体的实现。不同的类可以实现同一个接口,并提供接口中方法的具体实现。当一个接口类型的引用变量指向实现该接口的不同类的对象时,调用接口方法时会根据具体对象的实现而有不同的行为。比如定义一个接口 Flyable,有一个方法 fly (),类 Bird 和 Plane 都实现了 Flyable 接口,当我们使用 Flyable 类型的引用变量 flyObj 分别指向 Bird 和 Plane 的对象时,调用 flyObj.fly () 会根据具体对象执行不同的飞行操作。
多态提高了代码的可扩展性和可维护性。在大型项目中,如果没有多态,我们可能需要为每个具体的对象编写特定的调用代码,这样会导致代码冗余且难以维护。而通过多态,我们可以使用父类或接口类型的引用,使得代码更加通用和灵活。例如,在一个动物管理系统中,如果有多种动物,我们可以使用 Animal 类型的引用数组来存储不同类型的动物对象,当需要调用 makeSound () 方法时,只需要遍历数组并调用 makeSound () 即可,而不用关心具体是哪种动物,系统会根据对象的实际类型调用相应的方法。
此外,多态还可以结合抽象类来实现。抽象类可以定义抽象方法,子类必须实现这些抽象方法。当使用抽象类的引用指向子类对象时,同样可以实现多态性。多态使得程序在运行时能够根据对象的实际类型来决定调用哪个方法,这为开发人员提供了极大的便利,同时也遵循了面向对象的设计原则,如开闭原则,即对扩展开放,对修改关闭。我们可以方便地添加新的子类而不影响现有的代码,只要新的子类遵循父类或接口的约定,现有代码可以正常调用新子类的方法,无需对现有代码进行修改,从而提高了代码的复用性和可扩展性。
在 Java 中如何使用多态?
在 Java 中使用多态主要有以下几种常见的方法:
方法重写是实现多态的一种基础方式。首先,创建一个父类,在父类中定义一个或多个方法。然后创建子类,子类继承父类并使用 @Override 注解重写父类的方法。例如,假设父类是 Shape,有一个 draw () 方法:
class Shape { public void draw() { System.out.println("Drawing a shape"); } } class Circle extends Shape { @Override public void draw() { System.out.println("Drawing a circle"); } } class Square extends Shape { @Override public void draw() { System.out.println("Drawing a square"); } }
这里,Circle 和 Square 类都继承自 Shape 类,并分别重写了 draw () 方法。接下来,可以使用父类的引用指向子类的对象:
Shape shape1 = new Circle(); Shape shape2 = new Square(); shape1.draw(); shape2.draw();
当调用 shape1.draw () 时,实际执行的是 Circle 类中的 draw () 方法,会输出 "Drawing a circle";当调用 shape2.draw () 时,实际执行的是 Square 类中的 draw () 方法,会输出 "Drawing a square"。这就是多态的一种体现,通过父类引用调用重写的方法,实际执行的是子类的方法。
使用接口也是实现多态的重要手段。首先定义一个接口,例如:
interface Printable { void print(); } class Document implements Printable { @Override public void print() { System.out.println("Printing a document"); } } class Image implements Printable { @Override public void print() { System.out.println("Printing an image"); } }
然后可以创建接口的引用,并指向实现该接口的不同类的对象:
Printable printable1 = new Document(); Printable printable2 = new Image(); printable1.print(); printable2.print();
调用 printable1.print () 会输出 "Printing a document",而 printable2.print () 会输出 "Printing an image"。
此外,多态还可以通过抽象类来实现。抽象类可以有抽象方法和非抽象方法,抽象方法需要在子类中实现:
abstract class Vehicle { public void start() { System.out.println("Vehicle is starting"); } abstract void move(); } class Car extends Vehicle { @Override void move() { System.out.println("Car is moving"); } } class Bike extends Vehicle { @Override void move() { System.out.println("Bike is moving"); } }
同样,可以使用抽象类的引用指向子类对象:
Vehicle vehicle1 = new Car(); Vehicle vehicle2 = new Bike(); vehicle1.start(); vehicle2.start(); vehicle1.move(); vehicle2.move();
当调用 vehicle1.move () 时,会执行 Car 类的 move () 方法,调用 vehicle2.move () 会执行 Bike 类的 move () 方法。
在实际开发中,多态常常用于方法的参数传递。例如,定义一个方法接受父类或接口类型的参数:
public void performAction(Shape shape) { shape.draw(); }
这个方法可以接受任何 Shape 子类的对象,如 Circle 或 Square,并且会根据实际传入的对象调用相应的 draw () 方法:
performAction(new Circle()); performAction(new Square());
或者使用接口作为参数:
public void printItem(Printable item) { item.print(); }
可以传递 Document 或 Image 等实现了 Printable 接口的对象:
printItem(new Document()); printItem(new Image());
这样,我们可以根据不同的对象类型,在不修改 performAction 或 printItem 方法的情况下,灵活地调用不同的操作,提高了代码的通用性和可扩展性。同时,多态还可以应用于集合中,比如使用 List<Shape> 可以存储不同类型的 Shape 子类对象,通过遍历集合并调用 draw () 方法,可以根据对象的实际类型进行相应的操作,使得代码更加简洁和灵活。
String、StringBuilder 和 StringBuffer 有什么区别?
在 Java 中,String、StringBuilder 和 StringBuffer 都与字符串处理有关,但它们在一些重要方面存在区别:
首先,String 是不可变类。这意味着一旦一个 String 对象被创建,它的值就不能被修改。每次对 String 进行修改操作,如拼接、替换等,实际上都会创建一个新的 String 对象。例如:
String str = "Hello"; str = str + " World";
在上述代码中,当执行 str = str + "World" 时,会创建一个新的 String 对象,原始的 "Hello" 对象仍然存在,只是 str 引用指向了新创建的 "Hello World" 对象。这可能会导致性能问题,尤其是在频繁修改字符串的情况下,会产生大量的中间对象,增加内存开销。
而 StringBuilder 和 StringBuffer 是可变的字符串序列。它们允许对字符串进行修改而不创建新的对象。两者的主要区别在于线程安全性。
StringBuilder 是非线程安全的,它的性能相对较高,适用于单线程环境。例如:
StringBuilder sb = new StringBuilder("Hello"); sb.append(" World");
在这里,调用 append () 方法会在原 StringBuilder 对象上进行修改,不会创建新的对象,从而提高了性能。
StringBuffer 是线程安全的,它的方法使用了 synchronized 关键字进行同步,适用于多线程环境。例如:
StringBuffer sbf = new StringBuffer("Hello"); sbf.append(" World");
在多线程并发修改字符串的情况下,使用 StringBuffer 可以保证操作的一致性,避免出现数据不一致的问题。但由于同步机制的存在,其性能会略低于 StringBuilder。
在性能方面,对于单线程应用,StringBuilder 通常是更好的选择,因为它避免了同步的开销。而在多线程环境中,如果需要保证字符串操作的线程安全性,应该使用 StringBuffer。
另外,从方法的角度来看,它们都提供了一些相似的方法,如 append () 用于追加内容,insert () 用于插入内容,delete () 用于删除内容等,但由于 String 是不可变的,其相关操作实际上是返回一个新的 String 对象,而 StringBuilder 和 StringBuffer 会在原对象上进行修改。
17年+码农经历了很多次面试,多次作为面试官面试别人,多次大数据面试和面试别人,深知哪些面试题是会被经常问到,熟背八股文和总结好自己项目经验,将让你在面试更容易拿到Offer。 在多家企业从0到1开发过离线数仓实时数仓等多个大型项目,详细介绍项目架构等企业内部秘不外传的资料,介绍踩过的坑和开发干货,分享多个拿来即用的大数据ETL工具,让小白用户快速入门并精通,指导如何入职后快速上手。