前言:此篇博客笔者参考了JavaGuide、三分恶等博主的八股文,结合Chat老师和自己的理解,整理了一篇关于Java基础的八股文。全篇图文并茂,每个知识点都有细致描述,详略得当,理解通透。希望对各位读者有所帮助,欢迎大家点赞、收藏、关注,后续将陆续推出后端八股文~~
Java概述
什么是Java?
- Java是一门面向对象的编程语言,不仅吸收了C++语言的各种优点,还摒弃了C++里难以理解的多继承、指针等概念,因此Java语言具有功能强大和简单易用两个特征。
- Java语言作为静态面向对象编程语言的优秀代表,极好地实现了面向对象理论,允许程序员以优雅的思维方式进行复杂的编程 。
Java语言有哪些特点?
Java语言的突出特点:
- 面向对象(封装,继承,多态);
- 编译与解释并存;
- 平台无关性,平台无关性的具体表现在于,Java 是“一次编写,到处运行”的语言,因此采用 Java 语言编写的程序具有很好的可移植性,而保证这一点的正是 Java 的虚拟机机制。在引入虚拟机之后,Java 语言在不同的平台上运行不需要重新编译。
- 支持多线程。C++ 语言没有内置的多线程机制,因此必须调用操作系统的多线程功能来进行多线程程序设计,而 Java 语言却提供了多线程支持。
JVM、JDK和JRE有什么区别?
如下图:
- JVM:Java Virtual Machine,Java虚拟机,Java程序运行在Java虚拟机上。针对不同系统(Windows,Linux,macOS)的实现不同的JVM,因此Java语言可以实现跨平台。
- JRE:Java Runtime Enviroment,Java 运行时环境。 是运行基于 Java 语言编写的程序所不可缺少的运行环境,用于解释执行 Java 的字节码文件。JRE 是 Java 运行环境,并不是一个开发环境,所以不含任何开发工具,它包括 Java 虚拟机(JVM)、Java 核心类库。
- JDK:Java Development Kit,Java开发工具包,是整个 Java 的核心。它提供了编译、运行 Java 程序所需的各种工具和资源,包括了 Java 运行环境 JRE、Java 开发工具和 Java 基础类库。
总之,JDK包含JRE,JRE包含JVM。
什么是Java的跨平台性?原理是什么?
- 所谓跨平台性,是指Java语言编写的程序,一次编译后,可以在多个系统平台上运行。
- 实现原理:Java 程序其实是运行在JVM (Java虚拟机) 上的,使用 Java 编译器编译 Java 程序时,生成的是与平台无关的字节码,这些字节码只面向 JVM。不同平台的 JVM 都是不同的,但它们都提供了相同的接口,这也正是 Java 跨平台的原因。
什么是字节码? 采用字节码的好处是什么?
所谓的字节码,就是Java程序经过编译之后产生的.class文件,字节码能够被虚拟机识别,从而实现Java程序的跨平台性。
Java 程序从源代码到运行主要有三步:
- 编译 :将源代码(.java)编译成虚拟机可以识别理解的字节码文件(.class);
- 解释 :虚拟机执行字节码文件,将字节码翻译成机器能识别的机器码;
- 执行 :对应的机器执行二进制机器码。
只需要把Java程序编译成Java虚拟机能识别的Java字节码,不同的平台安装对应的Java虚拟机,这样就可以可以实现Java语言的平台无关性。
为什么说 Java 语言“编译与解释并存”?
高级编程语言按照程序的执行方式分为编译型和解释型两种。
- 编译型语言是指编译器针对特定的操作系统将源代码一次性翻译成可被该平台执行的机器码。
- 解释型语言是指解释器将源代码逐行解释成特定平台的机器码并立即执行。
Java 语言既具有编译型语言的特征,也具有解释型语言的特征,因为 Java 程序要经过先编译,后解释两个步骤。由 Java 编写的程序需要先经过编译步骤,生成字节码文件,这种字节码必须再经过JVM,解释成操作系统能识别的机器码,在由操作系统执行。因此,我们可以认为 Java 语言编译与解释并存。
基础语法
Java有哪些数据类型?
Java语言数据类型分为两种:基本数据类型和引用数据类型。
Java基本数据类型范围和默认值:
需要注意一下,对于布尔类型:
- 如果 boolean 是单独使用(在编译之后使用 Java 虚拟机中的 int 数据类型来代替),则占 4 个字节;
- 如果 boolean 是以“boolean数组”的形式使用,则占 1 个字节(不过 Java 规范对 boolean 占用的内存空间没有明确的规定,不同的 JVM 有不同的实现机制)。
自动类型转换、 强制类型转换? 看看这几行代码?
Java 所有的数值型变量可以相互转换,当把一个表数范围小的数值或变量直接赋给另一个表数范围大的变量时,可以进行自动类型转换;反之,需要强制转换。
这就好像,小杯里的水倒进大杯没问题,但大杯的水倒进小杯就不行了,可能会溢出。
float f = 3.4; // 对吗?
不正确。3.4 是双精度数,将双精度型(double)赋值给浮点型(float)属于向下转型,会造成精度损失。
正确写法:
float f = (float)3.4; // 写法1
float f = 3.4F; // 写法2
// 对吗?
short s1 = 1;
s1 = s1 + 1;
这会编译出错,因为1是int类型,而s1 + 1运算结果是int类型,要把这个结果赋值给左侧的short类型,这属于向下转型。向下转型并不是默认的,因此需要显示地强制类型转换:
short s1 = 1;
s1 = (short) (s1 + 1);
// 对吗?
short s1 = 1;
s1 += 1;
可以正确编译,因为s1 += 1
其实就相当于s1 = (short) (s1 + 1)
,这其中就有隐含的强制类型转换。
什么是自动拆箱/封箱?
- 装箱 :将基本数据类型用它们对应的引用类型包装起来;
- 拆箱 :将包装类型转换为基本数据类型;
Java可以自动对基本数据类型和它们的包装类进行装箱和拆箱。
举个栗子:
Integer i = 10; // 装箱
int n = i; // 拆箱
说说自增自减运算? 看下这几个代码运行结果?
++
和--
运算符可以放在变量之前,也可以放在变量之后。
- 当运算符放在变量之前时(前缀),先自增/减,再赋值;
- 当运算符放在变量之后时(后缀),先赋值,再自增/减。
例如,当 b = ++a
时,先自增(自己增加 1),再赋值(赋值给 b);当 b = a++
时,先赋值(赋值给 b),再自增(自己增加 1)。也就是,++a 输出的是 a+1 的值,a++输出的是 a 值。
看一下这段代码运行结果?
int i = 1;
i = i++;
System.out.println(i); // 输出结果是1
对于JVM而言,它对自增运算的处理,是会先定义一个临时变量来接收i
的值,然后进行自增运算,最后又将临时变量赋给了值为2的i
,所以最后的结果为1。
相当于这样的代码:
int i = 1;
int temp = i;
i++;
i = temp;
System.out.println(i);
这段代码会输出什么?
int count = 0;
for(int i = 0; i < 100; i++) {
count = count++;
}
System.out.println("count = " + count); // 输出结果是0
相当于这样的代码:
int count = 0;
int temp = count;
count++;
count = temp;
System.out.println(count);
面向对象
面向对象和面向过程的区别?
- 面向过程是具体化、流程化的,面向过程就是分析出解决问题所需要的步骤,然后用函数把这些步骤一步一步实现,使用的时候再一个一个的调用即可。
- 面向对象是模型化的,面向对象把构成问题的事务分解成各个对象,而建立对象的目的也不是为了完成一个个步骤,而是为了描述某个事件在解决整个问题的过程所发生的行为。 目的是为了写出通用的代码,加强代码的重用,屏蔽差异性。
面向对象有哪些特性?
面向对象的三大特性:封装、继承、多态。
- 封装
将抽象出的数据、代码封装在一起,把⼀个对象的属性私有化,隐藏对象的属性和实现细节,仅对外提供公共访问方式,将变化隔离,便于使用,提高代码复用性和安全性。
- 继承
在已有类的基础上,通过增加新的属性或方法进而扩展形成新的类,提高代码复用性。继承是多态的前提。
关于继承有以下三个要点:
- 子类拥有父类对象所有的属性和方法(包括私有属性和私有方法),但是父类中的私有属性和方法子类是⽆法访问的,只是拥有。
- 子类可以拥有自己的属性和方法,即子类可以对父类进行扩展。
- 子类可以用自己的方式实现父类的方法。
- 多态
所谓多态就是指程序中定义的引用变量所指向的具体类型和通过该引用变量发出的方法调用在编程时并不确定,而是在程序运行期间才确定,即一个引用变量到底会指向哪个类的实例对象,该引用变量发出的方法调用到底是哪个类中实现的方法,必须在由程序运行期间才能决定。
在 Java 中有两种形式可以实现多态:
- 继承(多个子类对同一个方法的重写);
- 接口(实现接口并覆盖接口中的同一个方法);
或者这么理解,要实现多态需要做两件事:
- 一是子类继承父类并重写父类中的方法;
- 二是用父类型引用子类型的对象;
重载(overload) 和重写(override) 的区别?
多态可以分为编译时多态(方法重载)和运行时多态(方法重写),方法的重载和重写都是实现多态的方式,区别在于前者实现的是编译时的多态性,而后者实现的是运行时的多态性。
- 重载
重载发生在一个类中,同名的方法如果有不同的参数列表(参数类型不同 或 参数个数不同 或 参数顺序不同)则视为重载。
- 重写(又称为覆盖)
重写是发生在子类与父类之间。
重写要求:
- 子类被重写方法 与 父类被重写方法 有相同的返回类型;
- 子类被重写方法 比 父类被重写方法 更好访问,即子类的访问修饰符要大于等于父类的访问修饰符;
- 子类被重写方法 不能比 父类被重写方法 声明更大的异常,即子类抛出的异常要小于等于父类抛出的异常。
访问修饰符public、 private、 protected、 以及不写(默认) 时的 区别?
Java 支持 4 种不同的访问权限。
- default (即默认,什么也不写):该成员可以被该类内部成员访问,也可以被同一包下其他的类访问。可以修饰在类、接口、变量、方法。
- private:该成员可以被该类内部成员访问。可以修饰变量、方法。注意:不能修饰类(外部类)。
- public : 该成员可以被任意包下,任意类的成员进行访问。可以修饰类、接口、变量、方法。
- protected : 该成员可以被该类内部成员访问,也可以被同一包下其他的类访问,还可以被它的子类访问。可以修饰变量、方法。注意:不能修饰类(外部类) 。
可见性 | private | default | protected | public |
---|---|---|---|---|
同一个类中 | √ | √ | √ | √ |
同一个包中 | × | √ | √ | √ |
子类中 | × | × | √ | √ |
全局范围 | × | × | × | √ |
抽象类(abstract class)和接口(interface)有什么区别?
相同: 接口和抽象类对实体类进行更高层次的抽象,仅定义公共行为和特征。
抽象类
- 抽象类是一种特殊的类,它不能被实例化,只能作为其他类的父类来使用;
- 抽象类中可以包含抽象方法(没有实现的方法)和具体方法(有实现的方法);
- 如果一个类继承了抽象类,则必须实现该抽象类中所有的抽象方法,否则该子类也必须声明为抽象类;
- 抽象类的主要作用是为子类提供通用的方法实现,同时强制子类必须实现某些方法以保证其正确性。
抽象类的适用场景是当多个子类具有共同的方法实现时,可以将这些共同的方法实现放在抽象类中,由子类继承并扩展各自的方法,模板方法模式就是抽象类的一个典型应用。此外,抽象类也可以用来限制实例化,只有子类可以被实例化,从而增强程序的安全性。
接口
接口(Interface)是一种抽象类型,在 Java 8 之前,接口非常纯粹,只能包含抽象方法,也就是没有方法体的方法。而 Java 8 中接口出现了些许的变化,开始允许接口包含默认方法和静态方法。接口中的方法将自动被设置为 public 类型,属性/字段将被自动被设置为 public static final 类型。接口可以被实现(implements)多次,而一个类只能继承(extends)一个类。接口的主要作用是定义一组行为规范,让实现该接口的类能够符合规范并具有通用性。
区别 | 抽象类 | 接口 |
---|---|---|
成员变量 | 无特殊要求,可以和普通类─样定义任意类型 | 只能是 public static final 常量 |
构造方法 | 有构造方法,用于子类实例化使用 | 没有构造方法,不能实例化 |
方法 | 抽象类中可以做方法定义,也可以有方法实现。 有抽象方法的类一定是抽象类,在抽象类中,可以没有抽象方法。 | 接口只有定义,不能有方法的实现,JDK8 支持默认/静态方法,JDK9 支持私有方法 |
继承 | 单继承 | 多继承 |
成员变量与局部变量的区别有哪些?
- 从语法形式上看:成员变量是属于类的,而局部变量是在方法中定义的变量或是方法的参数;成员变量可以被 public , private , static 等修饰符所修饰,而局部变量不能被访问控制修饰符及 static 所修饰;但是,成员变量和局部变量都能被 final 所修饰。
- 从变量在内存中的存储方式来看:如果成员变量是使用 static 修饰的,那么这个成员变量是属于类的,如果没有使用 static 修饰,这个成员变量是属于实例的,对象存于堆内存。如果局部变量类型为基本数据类型,那么存储在栈内存,如果为引用数据类型,那存放的是指向堆内存对象的引用或者是指向常量池中的地址。
- 从变量在内存中的生存时间上看:成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动消失。
- 成员变量如果没有被赋初值,则会自动以类型的默认值而赋值(一种情况例外:被 final 修饰的成员变量也必须显式地赋值),而局部变量则不会自动赋值。
静态变量和实例变量的区别? 静态方法、 实例方法呢?
静态变量和实例变量的区别:
- 静态变量:是被static修饰的变量,也称为类变量,它属于类,并不属于类的任何一个对象。一个类不管创建多少个对象,静态变量在内存中有且仅有唯一的一个副本。
- 实例变量:必须依存于某一实例,需要先创建对象然后通过对象才能访问到它。
静态方法和实例方法的区别:
- 静态方法:是被static修饰的方法,也称为类方法。在外部调用静态方法时,可以使用“类名.方法名”方式,也可以使用“对象名.方法名”方式。静态方法里并不能访问类的非静态成员变量和非静态成员方法,只能访问类的静态成员变量和静态成员方法。
- 实例方法:是指对象的方法,方法需要通过对象来调用,即使用“对象名.方法名”的方式调用。实例方法可以访问该对象的所有属性和方法。在实例方法内部,可以使用关键字 this 来引用当前对象。
在 JVM 类加载方面,实例方法和静态方法的区别主要体现在两个方面:
- 类加载时刻的初始化顺序;在类被加载到 JVM 中时,静态方法会被初始化,而实例方法并不会被初始化。这是因为静态方法属于类级别的成员,需要在类初始化时完成相应的初始化操作,而实例方法属于实例级别的成员,只有在类实例化后才会被调用。这就是为什么静态方法不能访问实例属性和实例方法了。
- 调用方法时的对象类型:调用静态方法时,可以直接使用类名调用,不需要先创建类的实例对象。而调用实例方法时,需要先创建类的实例对象,然后通过实例对象来调用。
final关键字有什么作用?
final表示不可变的意思,可用于修饰类、属性和方法,但是不能修饰抽象类和接口(因为接口和抽象类本身就是用来继承或者实现的,与final的作用相斥)。
- 被final修饰的类不可以被继承;
- 被final修饰的方法不可以被重写;
- 被final修饰的变量不可变,被final修饰的变量必须被显式地指定初始值。注意:这里的不可变指的是变量的引用不可变,而不是说引用指向的内容不可变。
举个栗子说明第三条:
finale StringBuilder sb = new StringBuilder("abc");
sb.append("d");
System.out.println(sb); // 输出结果abcd
图解说明:
final、 finally、 finalize的区别?
- final用于修饰类、属性和方法,但是不能修饰抽象类和接口。final修饰的类不可以被继承,修饰的方法不可以被重写,修饰的变量不可变。
- finally作为异常处理的一部分,它只能在
try/catch
语句中,并且附带一个语句块表示这段语句最终一定被执行(无论是否抛出异常)。经常被用在需要释放资源的情况下,System.exit (0)
可以阻断 finally 执行。 - finalize是
java.lang.Object
里定义的方法,也就是说每个对象都有这个方法,这个方法在GC启动,该对象被回收的时候进行调用。一个对象的 finalize 方法只会被调用一次,finalize 被调用并不一定会立即回收该对象。所以有可能调用 finalize 后,该对象又不需要被回收了,然后到了真正要被回收的时候,因为前面调用过一次,所以不会再次调用 finalize 了,进而产生问题,因此不推荐使用 finalize 方法。
==和 equals 的区别?
==
的作用是判断两个对象的地址是不是相等。即判断两个对象是不是同一个对象(基本数据类型==
比较的是值,引用数据类型==
比较的是内存地址)。也就是说:
- 作用于基本数据类型: 比较的是值;
- 作用于对象: 比较的是内存地址;
equals的作用也是判断两个对象是否相等,注意它并不能用于比较基本数据类型哦。equals的两种使用情况:
- 类没有重写equals方法:此时通过
equals()
比较该类的两个对象时,等价于通过==
比较这两个对象,还是相当于比较内存地址。 - 类重写了equals方法:一般来说,我们都会覆盖
equals()
方法来比较两个对象的内容而不是其引用。我们平时覆盖的equals()
方法一般是比较两个对象的内容是否相同,自定义了一个相等的标准,也就是两个对象的值是否相等。
举个栗子说明类重写了equals方法:
// Person,我们认为两个人的编号和姓名相同,就是一个人:
public class Person {
private String no;
private String name;
@Override
public boolean equals(Object o) { // 重写equals方法
if (this == o) return true;
if (!(o instanceof Person)) return false;
Person person = (Person) o;
return Objects.equals(no, person.no) && Objects.equals(name, person.name);
}
@Override
public int hashCode() {
return Objects.hash(no, name);
}
}
hashCode()和equals()两种方法是什么关系?
如果两个对象相等,则hashcode一定也是相同的。理解:由于equals默认比较两个对象的地址是否相等,因此如果两个对象equals相等,则说明是同一个内存地址。而hashcode()方法这个方法通常用来将对象的内存地址转换为整数之后返回。故这两个对象得到的哈希码必然是相同的。
两个对象相等,对两个对象分别调用equals方法都返回true。反之,两个对象有相同的hashcode值,它们也不一定是相等的(因为在散列表中,hashCode() 相等即两个键值对的哈希值相等,然而哈希值相等,并不一定能得出键值对相等【哈希冲突】)。
为什么重写equals方法必须重写hashCode方法?
hashCode()的作用是获取哈希码,也称为散列码;它实际上是返回⼀个 int 整数,定义在 Object 类中,是一个本地方法,这个方法通常用来将对象的内存地址转换为整数之后返回。
public native int hashCode();
- hashCode() 用于获取哈希码(散列码)
- eauqls() 用于比较两个对象是否相等
它们应遵守如下规定:
- 如果两个对象 equals 相等,则它们必须有相同的哈希码。
- 如果两个对象有相同的哈希码,则它们未必 equals 相等。
在 Java 中,如果一个类的实例需要被用作 Map 的键或者集合中的元素,就必须同时重写 hashCode() 和 equals() 方法。这是因为在 Map 和集合中,键和元素的比较是基于它们的 hashCode() 和 equals() 方法的结果。
举个栗子,当向 HashSet 中加入一个元素时,它需要判断集合中是否已经包含了这个元素,从而避免重复存储。
由于这个判断十分的频繁,所以要讲求效率,绝不能采用遍历集合逐个元素进行比较的方式。
HashSet 首先会调用对象的 hashCode() 方法获取其哈希码,并通过哈希码确定该对象在集合中存放的位置。假设这个位置之前已经存了一个对象,则 HashSet 会进一步调用 equals() 对两个对象进行比较:
- 若相等则说明对象重复,此时不会保存新加的对象
- 若不等说明对象不重复,但是它们存储的位置发生了碰撞, 此时 HashSet 会采用链式结构在同一位置保存多个对象,即将新加对象链接到原来对象的之后
而 Object 类提供的 equals() 方法默认是用 ==
来进行比较的,也就是说只有两个对象是同一个对象时(地址相同),才能返回相等的结果。而实际的业务中,我们通常的需求是,若两个不同的对象它们的内容是相同的,就认为它们相等。
鉴于这种情况,Object 类中 equals() 方法的默认实现是没有实用价值的,所以通常都要重写。 由于 hashCode() 与 equals() 具有联动关系,所以 equals() 方法重写时,通常也要将 hashCode() 进行重写,使得这两个方法始终满足上述规定。
Java是值传递, 还是引用传递?
结论:Java是值传递!
- 按值调用 (call by value ) 表示方法接收的是调用者提供的值。
- 按引用调用 ( call by reference ) 表示方法接收的是调用者提供的变量地址。一个方法可以修改传递引用所对应的变量值,而不能修改传递值调用所对应的变量值。
Java 程序设计语言总是采用「按值调用」。也就是说,方法得到的是所有参数值的一个拷贝,方法不能修改传递给它的任何参数变量的内容。
在 Java 中,方法参数共有两种类型:
- 基本数据类型:一个方法【不能】修改一个基本数据类型的参数
- 引用数据类型:一个方法【可以】改变一个引用数据类型的参数
Java 语言的方法调用只支持参数的值传递。当一个对象实例作为一个参数被传递到方法中时,参数的值就是对该对象的引用。对象的属性可以在被调用过程中被改变,但对对象引用的改变是不会影响到调用者的。
JVM 的内存分为堆和栈,其中栈中存储了基本数据类型和引用数据类型实例的地址,也就是对象地址。而对象所占的空间是在堆中开辟的,所以传递的时候可以理解为把变量存储的对象地址给传递过去,因此引用类型也是值传递。
举个栗子,假定一个方法试图将一个参数值增加至 3 倍:
public static void tripleValue(double x){
x *= 3;
}
--------------------------
double percent = 10;
tripleValue(percent); // 调用这个方法之后,percent 的值还是 10
下面看一下具体的执行过程:
- x 被初始化为
percent
值的一个拷贝(也就是 10 ) - x 被乘以 3 后等于 30。 但是
percent
仍然是 10 - 这个方法结束之后,参数变量 x 不再使用。
可以看到,一个方法不可能修改一个基本数据类型的参数。
但是对象引用作为参数就不同了,可以很容易地修改对象的字段值(比如我们有个 Employee
类,其中有字段 salary
和 方法 raiseSalary
):
class Employee {
private salary;
......
public void raiseSalary() {
salary += 200;
}
}
-----------------------
public static void tripleSalary (Employee x) {
x.raiseSalary();
}
-----------------------
harry = new Emplyee(...);
tipleSalary(harry);
具体的执行过程为:
- x 被初始化为
harry
值的拷贝,这里是一个对象的引用。 raiseSalary
方法应用于这个对象引用。x 和harry
同时引用的那个Employee
对象的薪金提高了 200。- 方法结束后,参数变量 x 不再使用。当然,对象变量
harry
继续引用那个薪金增加 200 的对象。
深拷贝和浅拷贝的区别?
- 浅拷贝:仅拷贝被拷贝对象的成员变量的值,也就是基本数据类型变量的值,和引用数据类型变量的地址值,而对于引用类型变量指向的堆中的对象不会拷贝。
- 深拷贝:完全拷贝一个对象,拷贝被拷贝对象的成员变量的值,堆中的对象也会拷贝一份。
例如现在有一个order对象,里面有一个products列表,它的浅拷贝和深拷贝的示意图:
因此深拷贝是安全的,浅拷贝的话如果有引用类型,那么拷贝后对象,引用类型变量修改,会影响原对象。
浅拷贝如何实现呢?
Object类提供的clone()方法可以非常简单地实现对象的浅拷贝。
深拷贝如何实现呢?
- 重写克隆方法:重写克隆方法,引用类型变量单独克隆,这里可能会涉及多层递归。
- 序列化:可以先将原对象序列化,然后再反序列化成拷贝对象。
Java 创建对象有哪几种方式?
Java中有以下四种创建对象的方式:
- 使用new关键字创建对象;
- 通过反射机制创建对象;
- 采用clone机制创建对象;
- 通过序列化方式创建对象;
前两者都需要显式地调用构造方法。对于clone机制,需要注意浅拷贝和深拷贝的区别,对于序列化机制需要明确其实现原理,在Java中序列化可以通过实现Externalizable或者Serializable来完成。
常用类
String和StringBuilder、 StringBuffer的区别?
可变性 | 线程安全 | 性能 | |
---|---|---|---|
String | 不可变 | 因为不可变,所以是线程安全的 | 由于 String 是不可变的,所以每次对字符串进行修改时都会创建一个新的字符串对象,这样会产生很多临时对象,导致内存开销比较大 |
StringBuffer | 可变 | 线程安全的,因为其内部大多数方法都使用 synchronized 进行同步,不过其效率较低 | StringBuffer 是可变的,在进行修改时不会创建新的对象,因此性能比 String 好;但是大量使用了同步方法,所以性能比 StringBuilder 差 |
StringBuilder | 可变 | 不是线程安全的,因为没有使用 synchronized 进行同步,这也是其效率高于 StringBuffer 的原因。单线程下,优先考虑使用 StringBuilder | 性能最好 |
可变和不可变:
- String是用final修饰的字符数组,所以是不可变的,如果操作的是少量的数据,则可以使用String;
- StringBuilder和StringBuffer是可变的字符串数组;
是否线程安全:
- String中的对象是不可变的,也就可以理解为常量,显然线程安全。
- StringBuilder是非线程安全的,因为Stringbuilder继承了父类AbstractStringBuilder的append方法,该方法中有一个count+=len的操作不是原子操作,所以在多线程中采用StringBuilder会丢失数据的准确性并且会抛ArrayIndexOutOfBoundsException的异常。
- StringBuffer是线程安全的,因为它的append方法被synchronized关键字修饰了,所以它能够保证线程同步和数据的准确性 。
性能:
- 每次对String类型进行改变的时候,都会生成一个新的String对象,然后将指针指向新的String对象,效率低。
- 因为StringBuffer是被synchronized修饰的,所以在单线程的情况下StringBuilder的执行效率是要比StringBuffer高的。一般在单线程下执行大量的数据使用StringBuilder,多线程的情况下则使用StringBuffer。
String str1 = new String(“abc”)和String str2 = “abc” 和 区别?
两个语句都会去字符串常量池中检查是否已经存在“abc”,如果有则直接使用,如果没有则会在常量池中创建“abc” 对象。
但是不同的是,String str1 = new String(“abc”) 还会通过 new String() 在堆里创建一个"abc" 字符串对象实例。
new String(“hello”) 创建了几个字符串对象?
使用 new String(“hello”) 创建了两个字符串对象,其中一个是字符串常量池中的对象,另一个是堆内存中的对象。
当执行 new String(“hello”) 时:
- 首先会在字符串常量池中查找是否已经有字符串 “hello”,如果没有,则在字符串常量池中创建一个新的对象,并将 “hello” 添加到字符串常量池中;
- 接着,在堆内存中创建一个新的字符串对象,并将这个新对象的引用返回给变量。
因此,执行 new String(“hello”) 时,会创建两个不同的字符串对象:一个在字符串常量池中,一个在堆内存中。
但是,由于字符串常量池中已经存在字符串 “hello”,所以在堆内存中创建的对象其实是不必要的,这会造成额外的内存开销。因此,在实际开发中,一般不建议使用 String str = new String("hello")
这种方式来创建字符串对象,而是使用字符串字面值的方式来创建 String str = "hello"
。
String有哪些特性?
- 不变性:String 是只读字符串,是一个典型的 immutable 对象,对它进行任何操作,其实都是创建一个新的对象,再把引用指向该对象。不变模式的主要作用在于当一个对象需要被多线程共享并频繁访问时,可以保证数据的一致性;
- 常量池优化:String 对象创建之后,会在字符串常量池中进行缓存,如果下次创建同样的对象时,会直接返回缓存的引用;
- final:使用 final 来定义 String 类,表示 String 类不能被继承,提高了系统的安全性。
为什么说 String 是不可变的?
String
不可变的表现就是当我们试图对一个已有的对象 “abcd” 赋值为 “abcde”,String
会新创建一个对象:
String
用 final 修饰 char 数组,这个数组无法被修改:
但是!!!这个无法被修改仅仅是指引用地址不可被修改(也就是说栈里面的这个叫 value 的引用地址不可变,编译器不允许我们把 value 指向堆中的另一个地址),并不代表存储在堆中的这个数组本身的内容不可变。
举个栗子:
final int[] value = {1, 2, 3}
int[] newValue = {4, 5, ,6}
value = newValue; // 编译器报错,final不可变
如果我们直接修改数组中的元素,是完全 OK 的:
final int[] value = {1, 2, 3}
value[2] = 10; // 这时候数组里面已经是{1, 2, 10}
那既然我们说 String
是不可变的,那显然仅仅靠 final 是远远不够的:
- 首先,char 数组是 private 的,并且
String
类没有对外提供修改这个数组的方法,所以它初始化之后外界没有有效的手段去改变它; - 其次,
String
类被 final 修饰的,也就是不可继承,避免被他人继承后破坏; - 最重要的!是因为 Java 作者在
String
的所有方法里面,都很小心地避免去修改了 char 数组中的数据,涉及到对 char 数组中数据进行修改的操作全部都会重新创建一个String
对象。
你可以随便翻个源码看看来验证这个说法,比如 substring 方法:
String 为什么要设计成不可变的呢?
(1)首先,字符串常量池的需要。
如下面的代码所示,堆中只会创建一个 String
对象:
String str1 = "hello";
String str2 = "hello";
System.out.println(str1 == str2) // true
假设 String
允许被改变,那如果我们修改了 str2 的内容为 good,那么 str1 也会被修改,显然这不是我们想要看见的结果。
(2)String
被设计成不可变就是为了安全。
作为最基础最常用的数据类型,String
被许多 Java 类库用来作为参数,如果 String
可变,将会引起各种安全隐患。
举个栗子,我们来看看将可变的字符串 StringBuilder
存入 HashSet
的场景:
我们把可变字符串 s3 指向了 s1 的地址,然后改变 s3 的值,由于 StringBuilder
没有像 String
那样设计成不可变的,所以 s3 就会直接在 s1 的地址上进行修改,导致 s1 的值也发生了改变。于是,糟糕的事情发生了,HashSet
中出现了两个相等的元素,破坏了 HashSet
的不包含重复元素的原则。
另外,在多线程环境下,众所周知,多个线程同时想要修改同一个资源,是存在危险的,而 String
作为不可变对象,不能被修改,并且多个线程同时读同一个资源,是完全没有问题的,所以 String
是线程安全的。
String 真的不可变吗?
想要改变 String
无非就是改变 char 数组 value 的内容,而 value 是私有属性,那么在 Java 中有没有某种手段可以访问类的私有属性呢?
没错,就是反射,使用反射可以直接修改 char 数组中的内容,当然,一般来说我们不这么做。
看下面代码:
在使用 HashMap 的时候,用 String 做 key 有什么好处?
HashMap 内部实现是通过 key 的 hashcode 来确定 value 的存储位置,因此要求key是不可变的。又由于String是不可变的,所以当创建字符串时,它的 hashcode 被缓存下来,不需要再次计算,所以相比于其他对象更快。
两个字符串相加的底层是如何实现的?
- 如果拼接的都是字符串常量,则在编译时编译器会将其直接优化为一个完整的字符串,和你直接写一个完整的字符串是一样的。
- 如果拼接的字符串中包含变量,则在编译时编译器采用StringBuilder对其进行优化,即自动创建StringBuilder实例并调用其append()方法,将这些字符串拼接在一起。
intern方法有什么作用?
* <p>
* When the intern method is invoked, if the pool already contains a
* string equal to this {@code String} object as determined by
* the {@link #equals(Object)} method, then the string from the pool is
* returned. Otherwise, this {@code String} object is added to the
* pool and a reference to this {@code String} object is returned.
* <p>
源码中也说明了:
- 如果当前字符串内容存在于字符串常量池(即equals()方法为true,也就是内容一样),直接返回字符串常量池中的字符串;
- 否则,将此String对象添加到池中,并返回String对象的引用。
字符串拼接的方式有哪些?
- 直接用
+
,底层用 StringBuilder 实现。只适用小数量,如果在循环中使用+
拼接,相当于不断创建新的 StringBuilder 对象再转换成 String 对象,效率极差。 - 使用 String 的 concat 方法,该方法使用 Arrays.copyOf 创建一个新的字符数组 buf ,并将当前字符串 value 数组的值拷贝到 buf 中,buf 长度 = 当前字符串长度 + 拼接字符串长度。之后调用 getChars 方法使用 System.arraycopy 将拼接字符串的值也拷贝到 buf 数组,最后用 buf 作为构造参数 new 一个新的 String 对象返回。效率稍高于直接使用
+
。 - 使用 StringBuilder 或 StringBuffer,两者的 append 方法都继承自 AbstractStringBuilder,该方法首先使用 Arrays.copyOf 确定新的字符数组容量,再调用 getChars 方法使用 System.arraycopy 将新的值追加到数组中。StringBuilder 是 JDK5 引入的,效率高但线程不安全。StringBuffer 使用 synchronized 保证线程安全。
说下String.hashCode()源码
String.hashCode()源码:
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
从源码中可知:
-
String有一个私有变量hash来缓存哈希值,即当该串第一次调用hashCode()方法时,hash默认值为0,继续执行,当字符串长度大于0时计算出一个哈希值赋给hash,之后再调用hashCode()方法时不会重新计算,直接返回hash;
-
计算时,使用的是该字符串截成的一个字符数组,用每个字符的ASCII值进行计算,根据注释可以看出哈希计算公式是: s 0 × 3 1 n − 1 + s 1 × 3 1 n − 2 + ⋯ + s n − 1 s_0\times 31^{n-1} + s_1\times 31^{n-2} + \cdots + s_{n-1} s0×31n−1+s1×31n−2+⋯+sn−1。其中n是字符数组的长度,s是字符数组;
-
算法中还有一个乘数31,为什么使用31呢?
- hash函数必须选用质数,这是被科学家论证过的hash函数减少冲突的理论;
- 如果乘数是偶数,并且乘法溢出的话,信息就会丢失,因为使用偶数相当于位移运算(低位补0);
- 31 * i 可以用 (i << 5) - i 来计算,而移位操作的效率高于乘法,所以这是基于性能角度的考虑;
- 31是个不大不小的质数,兼顾了性能和冲突率,太小hash冲突概率大,太大过于分散占用存储空间大,所以选择一个不大不小的质数很有必要。
String类的equals()源码了解多少?
String类的equals源码:
public boolean equals(Object anObject) {
// 检查两个字符串是否指向同一个对象。如果是,则直接返回 true
if (this == anObject) {
return true;
}
// 检查给定的对象是否是 String 类型的。如果不是,则返回 false
if (anObject instanceof String) {
String anotherString = (String) anObject;
int n = count;
// 比较两个字符串的长度是否相等。如果不相等,则返回 false
if (n == anotherString.count) {
char v1[] = value;
char v2[] = anotherString.value;
int i = offset;
int j = anotherString.offset;
// 逐个比较两个字符串的每个字符是否相等。如果有任意一个字符不相等,则返回 false;
while (n-- != 0) {
if (v1[i++] != v2[j++])
return false;
}
// 否则,返回 true
return true;
}
}
return false;
}
String源码中有哪些地方被final修饰?
java.lang.String 类的源码中,有以下几处地方被 final 修饰:
private final char[] value
:表示字符串的值的数组。这个字段被 final 修饰,意味着一旦初始化完成就不能再更改。private final int offset
:表示字符串的值数组的偏移量。这个字段被 final 修饰,意味着一旦初始化完成就不能再更改。private final int count
:表示字符串的值数组的长度。这个字段被 final 修饰,意味着一旦初始化完成就不能再更改。private final int hash
:表示字符串的哈希值。这个字段被 final 修饰,意味着一旦初始化完成就不能再更改。public final class String
:这个类被 final 修饰,意味着其不可以被继承。- 此外,在 java.lang.String 类中还有若干个方法被 final 修饰,这些方法不能被子类覆盖。例如,
final int length()
方法就是一个返回字符串长度的方法,它被 final 修饰,意味着不能被子类覆盖。
Integer a= 127,Integer b = 127;Integer c= 128,Integer d = 128;相等吗?
请观察以下代码:
public static void main(String[] args) {
Integer a = 127;
Integer b = 127;
Integer b1 = new Integer(127);
System.out.println(a == b); //true
System.out.println(b == b1); //false
Integer c = 128;
Integer d = 128;
System.out.println(c == d); //false
}
我们知道,==
拥有两种应用场景:
- 对于基本数据类型,
==
比较的是值内容是否相等; - 对于引用数据类型,
==
比较的是引用地址是否相等;
这段代码主要涉及Java中的自动装箱和缓存机制。Java中的自动装箱是指将基本数据类型自动转换为对应的包装类对象。例如,int类型可以自动转换为Integer类型。当使用自动装箱时,Java会从一个内部的缓存池中获取包装对象,如果缓存池中已经存在该值的包装对象,那么直接返回该对象的引用。如果缓存池中不存在该值的包装对象,则创建新的对象并添加到缓存池中。
在Java中,自动装箱是指将基本类型转换为其对应的包装类类型。例如,将int转换为Integer。当我们使用自动装箱时,如果装箱的值在-128到127之间,Java会自动缓存这些值,以便进行重用。这意味着,如果我们在这个范围内创建两个相同的Integer对象,它们将引用相同的对象,因此在使用==
运算符进行比较时,它们将返回true。
在这段代码中,a和b都是在范围内的值,因此它们使用自动装箱得到的对象引用是相同的,因为在缓存池中已经存在值为127的Integer对象,所以a和b都指向同一个对象。而b和b1虽然包含相同的值,但是b是从缓存中获取的,而b1是通过显式创建一个新的Integer对象得到的,它们不是同一个对象,因此返回false。
当c和d的值为128时,c和d的值超出了缓存范围,它们使用自动装箱得到的对象引用是不同的,因为在缓存池中没有值为128的Integer对象,因此Java会创建两个不同的Integer对象。c和d引用的是不同的对象,因此返回false。
需要注意的是,在实际编程中,应该尽可能避免使用==
运算符来比较两个包装类对象,而应该使用equals()方法来比较它们的值。
包装类的缓存机制?
Java 基本数据类型的包装类型的大部分都用到了缓存机制来提升性能。Byte
,Short
,Integer
,Long
这 4 种整数类型的包装类默认创建了数值[-128,127]的相应类型的缓存数据,Character
创建了数值在[0,127]范围的缓存数据,Boolean
直接返回 True
or False
。
以 Integer.valueOf
为例:
IntegerCache
是啥,点进去看看:
IntegerCache
是 Integer
类中的静态内部类,综合这两段代码,我们大概也能知道,IntegerCache
其实就是个缓存,其中定义了一个缓冲区 cache
,用于存储 Integer
类型的数据,缓存区间是 [-128, 127]。
回到 valueOf
的源码:它首先会判断 int 类型的实参 i 是否在可缓存区间内,如果在,就直接从缓存 IntegerCache
中获取对应的 Integer
对象;如果不在缓存区间内,则会 new 一个新的 Integer
对象。
String转Integer的方法有哪些?
String转成Integer,主要有两个方法:
(1)Integer.parseInt(String s)
使用Integer类的静态方法parseInt()。该方法接受一个字符串参数,并返回该字符串表示的整数。
举个栗子:
String str = "123";
int i = Integer.parseInt(str);
(2)Integer.valueOf(String s)
使用Integer类的静态方法valueOf()。该方法接受一个字符串参数,并返回该字符串表示的整数的Integer包装类对象。
举个栗子:
String str = "123";
Integer i = Integer.valueOf(str);
不管哪一种,最终还是会调用Integer类内中的parseInt(String s, int radix)方法。
核心代码:
public static int parseInt(String s, int radix) throws NumberFormatException{
int result = 0;
// 是否是负数
boolean negative = false;
// char字符数组下标和长度
int i = 0, len = s.length();
……
int digit;
// 判断字符长度是否大于0,否则抛出异常
if (len > 0) {
……
while (i < len) {
// Accumulating negatively avoids surprises near MAX_VALUE
// 返回指定基数中字符表示的数值。(此处是十进制数值)
digit = Character.digit(s.charAt(i++),radix);
// 进制位乘以数值
result *= radix;
result -= digit;
}
}
// 根据上面得到的是否负数,返回相应的值
return negative ? result : -result;
}
注意:
- 该方法不能将空字符串转换为整数,否则会抛出NumberFormatException异常。
- 该方法不能将非数字字符串转换为整数,否则会抛出NumberFormatException异常。
Object 类的常见方法?
Object 类是一个特殊的类,是所有类的父类,也就是说所有类都可以调用它的方 法。它主要提供了以下 11 个方法,大概可以分为六类:
对象比较
public native int hashCode()
:native方法,用于返回对象的哈希码,主要使用在哈希表中,比如JDK中的HashMap。public boolean equals(Object obj)
:用于比较两个对象的内存地址是否相等,String 类对该方法进行了重写用户比较字符串的值是否相等。
对象拷贝
protected native Object clone() throws CloneNotSupportedException
:native方法, 用于创建并返回当前对象的一份拷贝。一般情况下,对于任何对象x,表达式x.clone() != x
为true,x.clone().getClass() == x.getClass()
为true。Object本身没有实现Cloneable接口,因此如果不重写clone方法就进行调用的话会发生CloneNotSupportedException异常。
对象转字符串
public String toString()
:返回 运行时类名@实例的哈希码的16进制的字符串。建议Object所有的子类都重写这个方法。
多线程调度:
public final native void notify()
:native方法,并且不能重写。唤醒一个在此对象监视器上等待的线程,如果有多个线程在等待只会任意唤醒一个。public final native void notifyAll()
:native方法,并且不能重写。跟notify一样,唯一的区别就是会唤醒在此对象监视器上等待的所有线程,而不是一个线程。public final native void wait(long timeout) throws InterruptedException
:native方法,并且不能重写。暂停线程的执行。注意:sleep方法没有释放锁,而wait方法释放了锁 。timeout是等待时间。public final void wait(long timeout, int nanos) throws InterruptedException
:多了 nanos参数,这个参数表示额外时间(以毫微秒为单位,范围是 0-999999)。 所
以超时的时间还需要加上nanos毫秒。public final void wait() throws InterruptedException
:跟之前的两个wait方法一样, 只不过该方法一直等待,没有超时时间这个概念。
反射
public final native Class<?> getClass()
:native方法,用于返回当前运行时对象的Class对象,使用了final关键字修饰,故不允许子类重写。
垃圾回收
protected void finalize() throws Throwable
:通知垃圾收集器回收对象。
异常处理
介绍一下Java中的异常处理体系?
Java的异常体系是分为多层的。异常是指在程序运行期间出现的意外或错误情况。在 Java 中,所有的异常都是 Throwable 类或其子类的实例,Throwable 有两个直接子类:Error 和 Exception。
(1)Error
Error 表示应用程序无法处理的严重问题。通常情况下,Error 表示 JVM 自身出现的问题,比如 OutOfMemoryError
、StackOverflowError
等。Error一般是无法被程序员处理的,因为它们表示 JVM 中的严重问题,一旦发生就意味着程序无法继续执行。
(2)Exception
Exception 则表示应用程序本身出现了问题,可以通过修改代码进行避免或者处理。Exception 又分为两类:Checked Exception 和 Unchecked Exception(RuntimeException)。
- Checked Exception(编译时异常)通常意味着程序在编译时必须使用
try catch
捕获或者使用 throws 声明抛出,否则编译器会报错。这些异常通常是由外部环境或资源导致的,例如文件不存在、网络连接中断、数据库访问失败等。Checked Exception 包括IOException
(IO 异常)、ClassNotFoundException
、SQLException
、NoSuchMethodException
、IllegalAccessException
、InterruptedException
等。 - Unchecked Exception(运行时异常)则是指那些程序在运行时才会出现的异常,是指在编译时不需要强制捕获或声明的异常,这些异常通常是由程序逻辑错误引起的,例如除数为零、数组越界、类型转换错误等。与 Checked Exception 不同的是,即使有 Unchecked Exception,程序也可以正常通过编译,但在运行过程中如果抛出 Unchecked Exception 将会终止程序的执行,UnChecked Exception 不强制在代码中进行捕获。
常见的Checked Exception(编译时异常):
- IOException:在输入输出操作中出现的异常,例如文件不存在、文件被占用等。
- SQLException:在访问数据库时出现的异常。
- ClassNotFoundException:在使用Class.forName()方法时找不到指定类时抛出的异常。
- NoSuchMethodException:在使用反射时找不到指定方法时抛出的异常。
- IllegalAccessException:在使用反射时访问权限不足时抛出的异常。
- InterruptedException:在线程被中断时抛出的异常。
常见的Unchecked Exception(运行时异常):
ArithmeticException
:在算术操作中出现的异常,例如除数为零。NullPointerException
:在尝试访问空对象引用时出现的异常。ArrayIndexOutOfBoundsException
:在访问数组元素时越界时出现的异常。ClassCastException
:在进行类型转换时出现的异常。IllegalArgumentException
:在传递非法参数或参数值时出现的异常。IllegalStateException
:在对象状态不合法时出现的异常。UnsupportedOperationException
:在不支持操作时出现的异常。ConcurrentModificationException
:在并发修改集合时出现的异常。
如何处理异常?
针对异常的处理主要有两种方式:
throw和throws
遇到异常不进行具体处理,而是继续抛给调用者 (throw,throws)。抛出异常有三种形式,一个是throw,另一个是throws,还有一种系统自动抛异常。throws用在方法上,后面跟的是异常类,可以跟多个;而throw用在方法内,后面跟的是异常对象。
try catch 捕获异常
三步走:
- 捕获异常
try
:将业务代码包裹在 try 块内部,当业务代码中发生任何异常时,系统都会为此异常创建一个异常对象。创建异常对象之后,JVM 会在 try 块之后寻找可以处理它的 catch 块,并将异常对象交给这个 catch 块处理。 - 处理异常
catch
:在 catch 块中处理异常时,应该先记录日志,便于以后追溯这个异常。然后根据异常的类型、结合当前的业务情况,进行相应的处理。比如,给变量赋予一个默认值、直接返回空值、向外抛出一个新的业务异常交给调用者处理,等等。 - 回收资源
finally
:如果业务代码打开了某个资源,比如数据库连接、网络连接、磁盘文件等,则需要在这段业务代码执行完毕后关闭这项资源。并且无论是否发生异常,都要尝试关闭这项资源。将关闭资源的代码写在 finally 块内,可以满足这种需求,即无论是否发生异常,finally 块内的代码总会被执行。
举个栗子:
public class Example {
public static void main(String[] args) {
try {
// 包含可能会出现异常的代码以及声明异常的方法
myMethod();
} catch (MyException e) {
// 捕获异常并进行处理
e.printStackTrace();
} finally {
// 可选,必执行的代码
}
}
public static void myMethod() throws MyException {
// 抛出 MyException 异常
throw new MyException("This is a custom exception.");
}
}
如何自定义异常?
自定义异常一般都继承自 Exception
或者 RuntimeException
。其中
- 继承自
Exception
的异常被称为受检异常,需要在方法声明中显式抛出或者捕获处理 - 继承自
RuntimeException
的异常则被称为非受检异常,可以不在方法声明中显式抛出或者捕获处理。
自定义异常可以提供更加详细的异常信息,以便于调用者进行处理。一般来说,自定义异常应该包含以下内容:
- 异常的构造方法,用于传递异常信息。
- 异常的字段,用于存储异常信息。
- 异常的 getter 和 setter 方法,用于访问异常信息。
以下是一个简单的自定义异常的示例:
public class MyException extends RuntimeException {
private String errorCode; // 异常状态码
private String errorMsg; // 异常状态信息
public MyException(String errorCode, String errorMsg) {
this.errorCode = errorCode;
this.errorMsg = errorMsg;
}
public String getErrorCode() {
return errorCode;
}
public void setErrorCode(String errorCode) {
this.errorCode = errorCode;
}
public String getErrorMsg() {
return errorMsg;
}
public void setErrorMsg(String errorMsg) {
this.errorMsg = errorMsg;
}
}
使用自定义异常的示例代码如下:
public void myMethod() {
try {
// ...
} catch (Exception e) {
throw new MyException("10001", "发生异常了!");
}
}
在上面的示例代码中,如果 try
代码块中出现了异常,就会抛出自定义的 MyException
异常,并传递异常信息。调用者可以在捕获到 MyException
异常之后,根据异常信息来进行特殊处理。
在 finally 中执行 return 会发生什么 ?
在通常情况下,不要在 finally 块中使用 return、throw 等导致方法终止的语句,一旦在 finally 块中使用了 return、throw语句,将会导致try块、catch块中的return、throw语句失效。
当Java程序执行try块、catch块时遇到了return或throw语句,这两个语句都会导致该方法立即结束,但是系统在执行这两个语句前会先去寻找该异常处理流程中是否包含 finally 块:
- 如果没有 finally块,程序立即执行 return 或 throw 语句,方法终止;
- 如果有finally块,系统立即开始执行 finally 块,只有当 finally 块执行完成后,系统才会再次跳回来执行 try 块、catch 块里的 return 或 throw 语句;
如果 finally 块里也使用了 return 或 throw 等导致方法终止的语句,finally 块已经终止了方法,系统将不会跳回去执行 try 块、catch 块里的任何代码。
三道经典异常处理代码题
题目1
public class TryDemo {
public static void main(String[] args) {
System.out.println(test());
}
public static int test() {
try {
return 1;
} catch (Exception e) {
return 2;
} finally {
System.out.print("3");
}
}
}
// 执行结果:31
try、catch。finally 的基础用法,在 return 前会先执行 finally 语句块,所以是先输出 finally 里的 3,再输出 return 的 1。
题目2
public class TryDemo {
public static void main(String[] args) {
System.out.println(test1());
}
public static int test1() {
try {
return 2;
} finally {
return 3;
}
}
}
// 执行结果:3
try 返回前先执行 finally,结果 finally 里不按套路出牌,直接 return 了,自然也就走不到 try 里面的 return 了。
题目3
public class TryDemo {
public static void main(String[] args) {
System.out.println(test1());
}
public static int test1() {
int i = 0;
try {
i = 2;
return i;
} finally {
i = 3;
}
}
}
// 执行结果:2
大家可能会以为结果应该是 3,因为在 return 前会执行 finally,而 i 在 finally 中被修改为 3 了,那最终返回 i 不是应该为 3 吗?
但其实,在执行 finally 之前,JVM 会先将 i 的结果暂存起来,然后 finally 执行完毕后,会返回之前暂存的结果,而不是返回 i,所以即使 i 已经被修改为 3,最终返回的还是之前暂存起来的结果 2。
I/O
流按照不同的特点,有很多种划分方式。
- 按照流的流向分,可以分为输入流 和输出流;
- 按照操作单元划分,可以划分为字节流 和字符流 ;
- 按照流的角色划分为节点流 和处理流;
Java I/O流共涉及40多个类,看上去杂乱,其实都存在一定的关联, Java I/O流的40多 个类都是从如下4个抽象类基类中派生出来的。
- InputStream / Reader : 所有输入流的基类,前者是字节输入流,后者是字符输入流。
- OutputStream / Writer : 所有输出流的基类,前者是字节输出流,后者是字符输出流。
Java的IO流体系用到了一个设计模式——装饰器模式。
举个栗子,InputStream相关的部分类图如下:
说说什么是字节流和字符流?
Java中的I/O操作可以分为字节流和字符流两种类型,它们的主要区别在于处理的数据类型和编码方式不同。
- 字节流:以字节为单位进行I/O操作,适用于处理二进制数据或字节流数据。字节流的输入输出对象有InputStream和OutputStream,常用的字节流类有FileInputStream、FileOutputStream、ByteArrayInputStream、ByteArrayOutputStream等。使用字节流进行I/O操作时,数据以字节的形式进行读写,不会对数据进行编码和解码操作。因此,字节流适用于处理二进制文件,如图片、音频、视频等。
- 字符流:以字符为单位进行I/O操作,适用于处理文本数据。字符流的输入输出对象有Reader和Writer,常用的字符流类有FileReader、FileWriter、StringReader、StringWriter等。使用字符流进行I/O操作时,数据以字符的形式进行读写,可以对数据进行编码和解码操作,常用的编码方式有ASCII、Unicode、UTF-8等。因此,字符流适用于处理文本文件,如文本文档、配置文件等。
介绍一下BIO、 NIO、 AIO?
三种IO如下图:
BIO(blocking I/O)
就是传统的IO,同步阻塞,服务器中实现模式为一个连接一个线程。即客户端有连接请求时服务器就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,可以通过连接池机制改善(实现多个客户连接服务器)。
BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4 以前的唯一选择,程序简单易理解。
NIO
全称 java non-blocking IO,是指 JDK 提供的新 API。从JDK1.4开始,Java 提供了一系列改进的输入/输出的新特性,被统称为NIO(即New IO)。
NIO是同步非阻塞的,在服务器中实现的模式为一个请求一个线程,服务器端用一个线程处理多个连接,客户端发送的连接请求会注册到多路复用器上,多路复用器轮询到连接有IO请求就会进行处理:
NIO的数据是面向缓冲区Buffer的,必须从Buffer中读取或写入。
所以完整的NIO示意图:
可以看出,NIO的运行机制:
- 每个Channel对应一个Buffer。
- Selector对应一个线程,一个线程对应多个Channel。
- Selector会根据不同的事件,在各个通道上切换。
- Buffer是内存块,底层是数据。
NIO一般适用于连接数目多且连接比较短(轻操作)的架构,并发局限于应用中,编程比较复杂,从JDK1.4开始支持。
AIO
JDK 7 引入了 Asynchronous I/O,是异步不阻塞的 IO。异步并非阻塞,在服务器中实现的模式为一个有效请求一个线程,也就是说,客户端的IO请求都是通过操作系统先完成之后,再通知服务器应用去启动线程进行处理。
AIO一般适用于连接数目多且连接比较长(重操作)的架构,充分调用操作系统参与并发操作,编程比较复杂,从JDK1.7开始支持。
序列化
什么是序列化? 什么是反序列化?
如果我们需要持久化 Java 对象比如将 Java 对象保存在文件中,或者在网络传输 Java 对象,这些场景都需要用到序列化。
- 序列化:把Java对象转为二进制流,方便存储和传输。
- 反序列化:把二进制流恢复成Java对象。
序列化的主要目的是通过网络传输对象或者说是将对象存储到文件系统、数据库、内存中。
类比我们生活中一些大件物品的运输,运输的时候把它拆了打包,用的时候再拆包组装。
若对象要支持序列化机制,则它的类需要实现 Serializable
接口,该接口是一个标记接口,它没有提供任何方法,只是标明该类是可以序列化的。Java 的很多类已经实现了Serializable接口,如包装类、 String、Date等。
若要实现序列化,则需要使用对象流 ObjectInputStream 和 ObjectOutputStream。其中,在序列化时需要调用 ObjectOutputStream 对象的 writeObject() 方法,以输出对象序列。在反序列化时需要调用 ObjectInputStream 对象的readObject() 方法,将对象序列恢复为对象。
如果有些变量不想序列化,怎么办?
对于不想进行序列化的变量,使用transient关键字修饰。
transient 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient 修饰的变量值不会被持久化和恢复。transient 只能修饰变量,不能修饰类和方法。
说说有几种序列化方式?
Java序列化方式有很多,常见的有三种:
- Java对象流序列化:Java原生序列化方法即通过Java原生流(InputStream和OutputStream之间的转化)的方式进行转化,一般是对象输出流ObjectOutputStream和对象输入流 ObjectInputStream。
- JSON序列化:这个可能是我们最常用的序列化方式,Json序列化的选择很多,一般会使用jackson包,通过ObjectMapper类来进行一些操作,比如将对象转化为byte数组或者将json串转化为对象。
- ProtoBuff序列化:ProtocolBuffer是一种轻便高效的结构化数据存储格式,ProtoBuff序列化对象可以很大程度上将其压缩,可以大大减少数据传输大小,提高系统性能。
泛型
什么是泛型?
Java 泛型(generics)是 JDK 5 中引入的一个新特性, 泛型提供了编译时类型安全检测机制,该机制允许程序员在编译时检测到非法的类型。泛型的本质是参数化类型,也就是说所操作的数据类型被指定为一个参数。泛型就是将类型参数化,其在编译时才确定具体的参数。
泛型的好处:
- 类型安全
- 泛型的主要目标是提高 Java 程序的类型安全
- 编译时期就可以检查出因 Java 类型不正确导致的 ClassCastException(类转换异常) 异常
- 符合越早出错代价越小原则
- 消除强制类型转换
- 泛型的一个附带好处是,使用时直接得到目标类型,消除许多强制类型转换
- 所得即所需,这使得代码更加可读,并且减少了出错机会
- 潜在的性能效益
- 由于泛型的实现方式,支持泛型(几乎)不需要 JVM 或类文件更改
- 所有工作都在编译器中完成
- 编译器生成的代码跟不使用泛型(和强制类型转换)时所写的代码几乎一致,只是更能确保类型安全而已
泛型一般有三种使用方式:泛型类、泛型接口、泛型方法。
泛型类
// 此处T可以随便写为任意标识,常见的如T、E、K、V等形式的参数常用于标识泛型
// 在实例化泛型类时,必须指定T的具体类型
public class Generic<T> {
private T key;
public Generic(T key) {
this.key = key;
}
public T getKey() {
return this.key;
}
}
如何实例化泛型类:
Generic<Integer> genericInteger = new Generic<>(666);
泛型接口
class GeneratorImpl<T> implements Generator<T> {
@Override
public T method() {
return null;
}
}
实现泛型接口,指定类型:
class GeneratorImpl<T> implements Generator<String> {
@Override
public String method() {
return null;
}
}
泛型方法
public static <E> void printArray(E[] inputArray) {
for (E element : inputArray) {
System.out.printf( "%s", element);
}
System.out.println();
}
使用:
// 创建不同类型数组: Integer, Double 和 Character
Integer[] intArray = { 1, 2, 3 };
String[] stringArray = { "Hello", "World" };
printArray(intArray);
printArray(stringArray);
泛型常用的通配符有哪些?
常用的通配符为: T, E, K, V, ?
?
表示不确定的 java 类型T
(type) 表示具体的一个 java 类型E
(element) 代表 ElementK V
(key value) 分别代表 java 键值中的 Key Value
什么是泛型擦除?
泛型擦除,官方名叫类型擦除(Type Erasure)。使用泛型的时候加上类型参数,编译器在编译的时候去掉类型参数。
声明了泛型的 .java
源代码,在编译生成 .class
文件之后,泛型相关的信息就消失了。可以认为,源代码中泛型相关的信息,就是提供给编译器用的。泛型信息对 Java 编译器可以见,对 Java 虚拟机不可见。泛型的实现原理就是类型擦除,泛型只存在于编译阶段,而不存在于运行阶段。编译后的字节码文件不包含泛型类型信息,因为虚拟机没有泛型类型对象,所有对象都属于普通类。
Java 编译器通过如下规则实现类型擦除:
- 规则一:用
Object
或者界定类型替代泛型。例如编译器会将List<T>
替换为List<Object>
,泛型参数的具体类型信息被删除了,只留下了 Object 类型的信息。这样,编译器就可以将泛型类型当做普通的 Object 类型来处理了。 - 规则二:在恰当的位置插入强制类型转换代码来确保类型安全;
- 规则三:在继承了泛型类或接口的类中插入桥接方法来保留多态性
之所以要有泛型擦除,主要是为了向下兼容,因为JDK5之前是没有泛型的,为了让JVM保持向下兼容,就出了泛型擦除这个策略。
注解
谈谈你对注解的理解?
注解本质上是一个标记,注解可以标记在类上、方法上、属性上等,标记自身也可以设置一些值,有了标记之后,我们就可以在编译或者运行阶段去识别这些标记,然后做一些事情,这就是注解的用处。
注解是 JDK1.5 引入的特性,其实可以简单理解为 “标注”、“标签”。Java语言使用 @interface
语法来定义注解(Annotation
),它的格式如下:
public @interface Report {
int type() default 0;
String level() default "info";
String value() default "";
}
注解的参数类似无参数方法,可以用 default
设定一个默认值
注解的本质是一个继承了 Annotation
的特殊接口:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}
public interface Override extends Annotation{
}
什么是元注解?
有一些注解可以修饰其他注解,这些注解就称为元注解。简言之,元注解就是自定义注解的注解。
Java 标准库已经定义了一些元注解,我们只需要使用元注解,通常不需要自己去编写元注解。常见的元注解有四个:
-
@Target
-
@Retention
-
@Repeatable
-
@Inherited
(1)@Target
:定义Annotation
能够被应用于源码的哪些位置
- 类或接口:
ElementType.TYPE
- 字段:
ElementType.FIELD
- 方法:
ElementType.METHOD
- 构造方法:
ElementType.CONSTRUCTOR
- 方法参数:
ElementType.PARAMETER
举个栗子:
定义注解@Report
可用在方法上,我们必须添加一个@Target(ElementType.METHOD)
:
@Target(ElementType.METHOD)
public @interface Report {
int type() default 0;
String level() default "info";
String value() default "";
}
定义注解@Report
可用在方法或字段上,可以把@Target
注解参数变为数组{ ElementType.METHOD, ElementType.FIELD }
:
@Target({
ElementType.METHOD,
ElementType.FIELD
})
public @interface Report {
...
}
(2)@Retention
:定义了 Annotation
的生命周期
RetentionPolicy.SOURCE
(仅编译器):给编译器用的,不会写入 class 文件RetentionPolicy.CLASS
(仅class文件):会写入 class 文件,在类加载阶段丢弃,也就是运行的时候就没这个信息了RetentionPolicy.RUNTIME
(运行期):会写入 class 文件,永久保存,可以通过反射获取注解信息
如果 @Retention
不存在,则该Annotation
默认为CLASS
。因为通常我们自定义的Annotation
都是RUNTIME
,所以,务必要加上@Retention(RetentionPolicy.RUNTIME)
这个元注解:
@Retention(RetentionPolicy.RUNTIME)
public @interface Report {
int type() default 0;
String level() default "info";
String value() default "";
}
(3)@Repeatable
:定义Annotation
是否可重复。这个注解应用不是特别广泛。
举个栗子:
@Repeatable(Reports.class)
@Target(ElementType.TYPE)
public @interface Report {
int type() default 0;
String level() default "info";
String value() default "";
}
@Target(ElementType.TYPE)
public @interface Reports {
Report[] value();
}
经过@Repeatable
修饰后,在某个类型声明处,就可以添加多个@Report
注解:
@Report(type=1, level="debug")
@Report(type=2, level="warning")
public class Hello {
}
(4)@Inherited
:定义子类是否可继承父类定义的Annotation
。
@Inherited
仅针对@Target(ElementType.TYPE)
类型的annotation
有效,并且仅针对class
的继承,对interface
的继承无效:
举个栗子:
@Inherited
@Target(ElementType.TYPE)
public @interface Report {
int type() default 0;
String level() default "info";
String value() default "";
}
在使用的时候,如果一个类用到了@Report
:
@Report(type=1)
public class Person {
}
则它的子类默认也定义了该注解:
public class Student extends Person {
}
注解的实现原理?
注解的本质是继承了 Annotation
的特殊接口,注解中定义的注解成员属性会转化为抽象方法,那么最后这些注解成员属性怎么进行赋值的呢?
答案就是:为注解对应的接口生成一个实现该接口的动态代理类。
Java 通过 JDK 动态代理的方式生成了一个实现了"注解对应接口"的实例,该代理类实例实现了"注解成员属性对应的方法",这个步骤类似于"注解成员属性"的赋值过程,这样子就可以在程序运行的时候通过反射获取到注解的成员属性(这里注解必须是运行时可见的,也就是使用了@Retention(RetentionPolicy.RUNTIME
)。
反射
什么是反射?
我们通常都是利用 new 方式来创建对象实例,这可以说就是一种“正射”,这种方式在编译时候就确定了类型信息。而如果我们想动态地获取类信息、创建类实例、调用类方法这时候就要用到反射。通过反射你可以获取任意一个类的所有属性和方法,你还可以调用这些方法和属性。
反射:在运行状态中,对于任意一个类都能知道它的所有属性和方法,对于任意一个对象都能调用它的任意方法和属性,这种动态获取信息及调用对象方法的功能称为反射。
反射最核心的四个类:
反射的作用?
反射拥有以下四大功能:
- 运行时动态获取类的信息,比如类的名称、方法、属性等。
- 创建对象,即使我们不知道具体的类名,也可以在运行时创建对象。
- 调用方法,即使我们不知道方法名,也可以在运行时调用方法。
- 访问属性,即使我们不知道属性名,也可以在运行时访问属性。
这种动态获取信息、动态创建/调用对象的方法的功能就称为 Java 语言的反射机制。
反射的原理?
在通常情况下,一定是先有类然后再 new 一个对象出来的对吧,类的正常加载过程是这样的:
Date date = new Date(); // 后有对象
首先 JVM 会将我们的代码编译成一个 .class
字节码文件,然后被类加载器(ClassLoader)加载进 JVM 的内存中,同时会创建这个类的 Class
对象存到堆中(注意这个不是 new 出来的对象,而是类的类型对象)。JVM 在实例化这个类的对象 date 前,会先检查其类(Date)是否加载,寻找类对应的 Class
对象,若加载好,则为其分配内存,然后再进行初始化 new
操作。
那么在加载完一个类后,堆内存的方法区就产生了一个 Class
对象,并且包含了这个类的完整结构信息,我们可以通过这个 Class
对象看到类的结构,就好比一面镜子。所以我们形象的称之为:反射
说的再详细点,在通常情况下,一定是先有类再有对象,我们把这个通常情况称为 “正”。那么反射中的这个 “反” 我们就可以理解为根据对象找到对象所属的类(对象的出处)。
Date date = new Date();
System.out.println(date.getClass()); // "class java.util.Date"
通过反射,也就是调用了 getClass()
方法后,我们就获得了这个类对应的 Class
对象,看到了这个类的结构,输出了类对象所属的类的完整名称,即找到了对象的出处。当然,获取 Class
对象的方式不止这一种。
简言之,反射的原理就是通过将类对应的字节码文件加载到JVM内存中得到一个Class对象,然后通过这个Class对象可以反向获取实例的各个属性以及调用它的方法。
获取 Class 类对象有几种方式?
从 Class
类的源码可以看出,它的构造函数是私有的,也就是说只有 JVM 可以创建 Class
类的对象,我们不能像普通类一样直接 new 一个 Class
对象。
我们只能通过已有的类来得到一个 Class
类对象,Java 提供了四种方式:
第一种:知道具体类的情况下可以使用
Class alunbarClass = TargetObject.class;
但是我们一般是不知道具体类的,基本都是通过遍历包下面的类来获取 Class 对象,通过此方式获取 Class 对象不会进行初始化。
第二种:通过 Class.forName()
传入全类名获取
Class alunbarClass1 = Class.forName("com.xxx.TargetObject");
这个方法内部实际调用的是 forName0
:
第 2 个 boolean
参数表示类是否需要初始化,默认是需要初始化。一旦初始化,就会触发目标对象的 static
块代码执行,static
参数也会被再次初始化。
第三种:通过对象实例 instance.getClass()
获取
Date date = new Date();
Class alunbarClass2 = date.getClass(); // 获取该对象实例的 Class 类对象
第四种:通过类加载器 xxxClassLoader.loadClass()
传入类路径获取
class clazz = ClassLoader.LoadClass("com.xxx.TargetObject");
通过类加载器获取 Class 对象不会进行初始化,意味着不进行包括初始化等一些列步骤,静态块和静态对象不会得到执行。
JDK1.8新特性
JDK1.8有哪些新特性?
JDK1.8有不少新特性,我们经常接触到的新特性如下:
- 接口默认方法:Java 8允许我们给接口添加一个非抽象的方法实现,只需要使用 default关键字修饰即可。
- Lambda 表达式:Lambda 表达式本质上是一段匿名内部类,也可以是一段可以传递的代码。Lambda 允许把函数作为一个方法的参数(函数作为参数传递到方法中),使用 Lambda 表达式使代码更加简洁,但是也不要滥用,否则会有可读性等问题。
- 函数式接口:使用 @FunctionalInterface 标识,有且仅有一个抽象方法,可被隐式转换为 lambda 表达式。
- Stream API:用函数式编程方式在集合类上进行复杂操作的工具,配合Lambda表达式可以方便的对集合进行处理。
- 日期时间API:Java 8 引入了新的日期时间API改进了日期时间的管理。
- Optional 类:用来解决空指针异常的问题。
举个栗子
Lambda表达式:
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
numbers.forEach(x -> System.out.println(x));
函数式接口(Functional Interface)和方法引用:
@FunctionalInterface
interface Converter<F, T> {
T convert(F from);
}
Converter<String, Integer> converter = Integer::valueOf;
Integer result = converter.convert("123);
Stream API:
List<String> fruits = Arrays.asList("Apple", "Banana", "Orange");
fruits.stream()
.filter(fruit -> fruit.startsWith("A"))
.map(String::toUpperCase)
.forEach(System.out::println);
接口默认方法(Default Methods):
interface Vehicle {
void start();
default void stop() {
System.out.println("Vehicle stopped");
}
}
class Car implements Vehicle {
@Override
public void start() {
System.out.println("Car started");
}
}
Car car = new Car();
car.start();
car.stop();
方法引用和构造函数引用:
List<String> names = Arrays.asList("John", "Alice", "Bob");
names.sort(String::compareToIgnoreCase);
Supplier<List<String>> listSupplier = ArrayList::new;
List<String> list = listSupplier.get();
新的日期和时间 API(java.time 包):
LocalDate today = LocalDate.now();
LocalTime now = LocalTime.now();
LocalDateTime dateTime = LocalDateTime.of(today, now);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String formattedDateTime = dateTime.format(formatter);
System.out.println(formattedDateTime);
Java8有哪些内置函数式接口?
JDK 1.8 API 包含了很多内置的函数式接口。其中就包括我们在老版本中经常见到的 Comparator 和 Runnable,Java 8 为他们都添加了 @FunctionalInterface 注解,以用来支持 Lambda 表达式。除了这两个之外,还有Callable、Predicate、Function、Supplier、Consumer等等。
Optional了解吗?
Optional 是用于防范 NullPointerException 。
可以将 Optional 看做是包装对象(可能是 null , 也有可能非 null )的容器。 当我们定义了 一个方法,这个方法返回的对象可能是空,也有可能非空的时候,我们就可以考虑用 Optional 来包装它,这也是在 Java 8 被推荐使用的做法。
Optional<String> optional = Optional.of("bam");
optional.isPresent(); // true
optional.get(); // "bam"
optional.orElse("fallback"); // "bam"
optional.ifPresent((s) -> System.out.println(s.charAt(0)));
API:
List<String> fruits = Arrays.asList("Apple", "Banana", "Orange");
fruits.stream()
.filter(fruit -> fruit.startsWith("A"))
.map(String::toUpperCase)
.forEach(System.out::println);
接口默认方法(Default Methods):
interface Vehicle {
void start();
default void stop() {
System.out.println("Vehicle stopped");
}
}
class Car implements Vehicle {
@Override
public void start() {
System.out.println("Car started");
}
}
Car car = new Car();
car.start();
car.stop();
方法引用和构造函数引用:
List<String> names = Arrays.asList("John", "Alice", "Bob");
names.sort(String::compareToIgnoreCase);
Supplier<List<String>> listSupplier = ArrayList::new;
List<String> list = listSupplier.get();
新的日期和时间 API(java.time 包):
LocalDate today = LocalDate.now();
LocalTime now = LocalTime.now();
LocalDateTime dateTime = LocalDateTime.of(today, now);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String formattedDateTime = dateTime.format(formatter);
System.out.println(formattedDateTime);
Java8有哪些内置函数式接口?
JDK 1.8 API 包含了很多内置的函数式接口。其中就包括我们在老版本中经常见到的 Comparator 和 Runnable,Java 8 为他们都添加了 @FunctionalInterface 注解,以用来支持 Lambda 表达式。除了这两个之外,还有Callable、Predicate、Function、Supplier、Consumer等等。
Optional了解吗?
Optional 是用于防范 NullPointerException 。
可以将 Optional 看做是包装对象(可能是 null , 也有可能非 null )的容器。 当我们定义了 一个方法,这个方法返回的对象可能是空,也有可能非空的时候,我们就可以考虑用 Optional 来包装它,这也是在 Java 8 被推荐使用的做法。
Optional<String> optional = Optional.of("bam");
optional.isPresent(); // true
optional.get(); // "bam"
optional.orElse("fallback"); // "bam"
optional.ifPresent((s) -> System.out.println(s.charAt(0)));