基础概念与常识
Java 语言的优点?
- 简单易学,去除C++语言中难以理解、容易混淆的特性,如头文件、指针、结构、单元、运算符重载、虚拟基础类、多重继承等,使得程序更加严谨、简洁;
- 面向对象,因此通过它,开发人员编写程序更为容易;
- 平台无关性,摆脱硬件束缚,"一次编写,到处运行"。编译器会把Java代码变成“中间代码”,然后在Java虚拟机上解释执行。由于中间代码与平台无关,因此,Java语言可以很好地跨平台执行,具有很好的可移植性;
- 支持多线程,Java 语言却提供了多线程支持。
- 具有较好的安全性和健壮性。Java语言经常被用在网络环境中,为了增强程序的安全性,Java语言提供了一个防止恶意代码攻击的安全机制(数组边界检测和Bytecode校验等)。Java的强类型机制、垃圾回收器、异常处理和安全检查机制使得用Java语言编写的程序有很好的健壮性;
- 支持网络编程并且很方便,Java 语言诞生本身就是为简化网络编程设计的,因此 Java 语言不仅支持网络编程而且很方便;
- 高性能,随着热点代码检测和JIT(Just-In-Time, 编译与解释并存)编译器技术和 JVM 的发展(GraalVM),使Java程序的性能越来越接近 C ++;
- 完善丰富的开发生态系统;
Java 如何实现平台无关?
- 字节码:Java源代码经过编译器编译后生成与计算机体系结构无关的字节码指令文件,即**.class**的文件,它不面向任何特定的处理器,只面向虚拟机,因此,Java程序无须重新编译便可在多种不同的计算机上运行。Java语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以Java程序运行时比较高效。Java的特点的编译与解释并存的解释。
- JVM:字节码文件不仅可以轻易地在任何机器上解释执行,还可以动态地转换成本地机器代码,转换是由 JVM 实现的,JVM 是平台相关的,屏蔽了不同操作系统的差异。
- 语言规范: 基本数据类型大小有明确规定,例如 int 永远为 32 位,而 C/C++ 中可能是 16 位、32 位,也可能是编译器开发商指定的其他大小。Java 中数值类型有固定字节数,二进制数据以固定格式存储和传输,字符串采用标准的 Unicode 格式存储。
为什么说 Java 语言“编译与解释并存”?
可以将高级编程语言按照程序的执行方式分为两种:
- 编译型:编译型语言会通过编译器将源代码一次性翻译成可被该平台执行的机器码。一般情况下,编译语言的执行速度比较快,开发效率比较低。常见的编译性语言有 C、C++、Go、Rust 等等。
- 解释型:解释型语言会通过解释器一句一句的将代码解释(interpret)为机器代码后再执行。解释型语言开发效率比较快,执行速度比较慢。常见的解释性语言有 Python、JavaScript、PHP 等等。
为了改善编译语言的效率而发展出的即时编译技术,已经缩小了这两种语言间的差距。这种技术混合了编译语言与解释型语言的优点,它像编译语言一样,先把程序源代码编译成字节码。到执行期时,再将字节码直译,之后执行。Java与LLVM是这种技术的代表产物。Java 语言既具有编译型语言的特征,也具有解释型语言的特征。因为 Java 程序要经过先编译,后解释两个步骤,由 Java 编写的程序需要先经过编译步骤,生成字节码(.class 文件),这种字节码必须由 Java 解释器来解释执行。
AOT 有什么优点?为什么不全部使用 AOT 呢?
JDK 9 引入了一种新的编译模式 AOT(Ahead of Time Compilation) 。和 JIT 不同的是,这种编译模式会在程序被执行前就将其编译成机器码,属于静态编译。AOT 避免了 JIT 预热等各方面的开销,可以提高 Java 程序的启动速度,避免预热时间长。并且,AOT 还能减少内存占用和增强 Java 程序的安全性(AOT 编译后的代码不容易被反编译和修改),特别适合云原生场景。
AOT 的主要优势在于启动时间、内存占用和打包体积。JIT 的主要优势在于具备更高的极限处理能力,可以降低请求的最大延迟。GraalVM 是一种高性能的 JDK(完整的 JDK 发行版本),它可以运行 Java 和其他 JVM 语言,以及 JavaScript、Python 等非 JVM 语言。 GraalVM 不仅能提供 AOT 编译,还能提供 JIT 编译。
AOT 更适合当下的云原生场景,对微服务架构的支持也比较友好。除此之外,AOT 编译无法支持 Java 的一些动态特性,如反射、动态代理、动态加载、JNI(Java Native Interface)等。然而,很多框架和库(如 Spring、CGLIB)都用到了这些特性。如果只使用 AOT 编译,那就没办法使用这些框架和库了,或者说需要针对性地去做适配和优化。如,CGLIB 动态代理使用的是 ASM 技术,而这种技术大致原理是运行时直接在内存中生成并加载修改后的字节码文件也就是 .class 文件,如果全部使用 AOT 提前编译,也就不能使用 ASM 技术了。为了支持类似的动态特性,所以选择使用 JIT 即时编译器。
JDK8 新特性有哪些?
- lambda 表达式:允许把函数作为参数传递到方法,简化匿名内部类代码。
- 函数式接口:使用 @FunctionalInterface 标识,有且仅有一个抽象方法,可被隐式转换为 lambda 表达式。
- Stream 类:引入函数式编程风格,提供了很多功能,使代码更加简洁。方法包括 forEach 遍历、count 统计个数、filter 按条件过滤、limit 取前 n 个元素、skip 跳过前 n 个元素、map 映射加工、concat 合并 stream 流等。
- 方法引用:可以引用已有类或对象的方法和构造方法,进一步简化 lambda 表达式。
- 接口:接口可以定义 default 修饰的默认方法,降低了接口升级的复杂性,还可以定义静态方法。
- 注解:引入重复注解机制,相同注解在同地方可以声明多次。注解作用范围也进行了扩展,可作用于局部变量、泛型、方法异常等。
- 类型推测:加强了类型推测机制,使代码更加简洁。
- Optional 类:处理空指针异常,提高代码可读性。
- 日期:增强了日期和时间 API,新的 java.time 包主要包含了处理日期、时间、日期/时间、时区、时刻和时钟等操作。
- JavaScript:提供了一个新的 JavaScript 引擎,允许在 JVM上运行特定 JavaScript 应用。
Java 方法调用原理是怎样的?
Java 方法调用原理详解
Java 方法调用的核心任务是 确定被调用方法的版本,这一过程涉及 符号引用到直接引用的转换 以及 分派机制(静态分派与动态分派)。以下从多个维度详细说明其原理:
一、方法调用的核心机制
- 符号引用与直接引用
- 符号引用:Class 文件中存储的方法调用信息是符号引用(如
java/lang/Object.<init>
),而非内存中的实际入口地址。 - 直接引用:在类加载的 解析阶段,部分符号引用会被转换为直接引用(指向方法在内存中的入口地址)。此过程需满足 “编译期可知,运行期不可变” 的条件。
- 解析(Resolution)
- 非虚方法(Non-Virtual Method):以下方法在类加载阶段即可确定唯一版本,直接绑定直接引用:
- 静态方法(
invokestatic
) - 私有方法(
invokespecial
) - 实例构造器(
<init>
,invokespecial
) - 父类方法(
invokespecial
) final
修饰的方法(invokevirtual
,但无法被重写)
- 静态方法(
- 解析的静态性:解析是静态过程,无需运行时多态选择。
- 分派(Dispatch)
- 静态分派:编译时根据 静态类型 选择方法版本(如方法重载)。
- 动态分派:运行时根据 实际类型 选择方法版本(如方法重写)。
静态分派与动态分派
- 静态分派(方法重载)
静态类型(Static Type):变量声明时的类型(编译期可知)。
匹配优先级:
- 精确匹配(如
int
→int
) - 基本类型自动提升(如
int
→long
) - 自动装箱/拆箱(如
int
→Integer
) - 向上转型(如
Dog
→Animal
) - 可变参数(如
String...
)
- 精确匹配(如
示例:
javavoid print(Object o) {} // 版本1 void print(String s) {} // 版本2 print("hello"); // 调用版本2(精确匹配)
- 动态分派(方法重写)
- 实际类型(Actual Type):变量指向对象的真实类型(运行期确定)。
invokevirtual
指令的执行流程:- 获取操作数栈顶对象的实际类型
C
。 - 在
C
的虚方法表中查找匹配方法。 - 若未找到,逐级向父类搜索。
- 若最终未找到,抛出
AbstractMethodError
。
- 获取操作数栈顶对象的实际类型
- 本质:通过 虚方法表(vtable) 实现高效多态:
- 每个类维护虚方法表,记录可重写方法的入口地址。
- 子类重写方法时,虚方法表对应项替换为子类方法地址。
- 索引一致性:父类与子类中相同签名的方法在虚方法表中索引一致。
虚方法表(vtable)与优化
- 虚方法表的作用
- 加速动态分派:避免每次调用都遍历类继承关系。
- 结构特点:
- 未被重写的方法指向父类实现。
- 重写的方法指向子类实现。
- 虚方法表的初始化
- 时机:类加载的连接阶段(与类变量初始化同步)。
- 接口方法表(itable):接口方法的动态分派需遍历接口方法表,效率略低。
- 优化技术
- 类型继承关系分析(CHA):预测方法是否可内联。
- 守护内联(Guarded Inlining):基于运行时条件的内联优化。
- 内联缓存(Inline Cache):缓存最近调用的方法版本,减少查表开销。
单分派与多分派
- 宗量(Dispatch Parameter)
- 定义:方法的 接收者(调用对象) 和 参数 统称为宗量。
- 分派类型:
- 静态多分派:编译时根据 参数类型(多宗量)选择方法(重载)。
- 动态单分派:运行时根据 接收者实际类型(单宗量)选择方法(重写)。
- Java 的分派特性
- 静态多分派:支持方法重载,依赖多个参数类型。
- 动态单分派:支持方法重写,仅依赖接收者实际类型。
特殊指令与场景
invokedynamic
指令
- 动态语言支持:用于 Lambda 表达式、方法引用等场景。
- 分派逻辑:由用户定义的
BootStrap
方法在运行时动态解析。
- 字段不参与多态
- 字段访问:直接通过类名访问,无多态性(如
obj.field
始终指向声明类的字段)。
总结
解析与分派的协作:
- 解析:处理非虚方法,绑定直接引用(编译期)。
- 分派:处理虚方法,依赖静态类型(编译期)或实际类型(运行期)。
Java 方法调用的核心特点:
- 静态多分派:方法重载的复杂性(依赖多个参数类型)。
- 动态单分派:方法重写的高效性(依赖虚方法表)。
性能关键:
- 虚方法表加速动态分派。
- JIT 优化(如内联、缓存)减少运行时开销。
理解这些原理有助于编写高效代码,并深入掌握多态、反射、动态代理等高级特性。
Java对象属性默认值是怎样的?
对象数据域中的变量若没有赋值,引用型数据的默认值是null,数值型的默认值是0,boolean型的默认值是false,char型的默认值是/u0000
(空白字符)。
Java创建对象有几种机制?
new创建新对象 ;通过反射机制 ;采用clone机制 ;通过序列化/反序列化机制
Math 工具类中提供了哪些取整的方法?
static double ceil(double a)
:返回大于等于a的最小整数。static double floor(double a)
:返回小于等于a的最大整数。static double rint(double a)
:四舍五入方法,返回与a的值最相近的整数,为double类型。static long round(double a)
:四舍五入方法,返回与a的值最相近的长整型数。static int round(float a)
:四舍五入方法,返回与a的值最相近的整型数。
Java中数组是不是对象?
是对象,数组是指具有相同类型的数据的集合,它们一般具有固定的长度,并且在内存中占据连续的空间。在Java语言中,数组不仅有其自己的属性(例如length属性),也有一些方法可以被调用(例如clone方法)。由于对象的特点是封装了一些数据,同时提供了一些属性和方法,从这个角度来讲,数组是对象。每个数组类型都有其对应的类型,可以通过instanceof来判断数组的类型。
数组的初始化方式有哪几种?
在Java语言中,一维数组的声明方式为:
**type arrayName[ ] 或 type[ ]arrayName**
其中,type既可以是基本的数据类型,也可以是类,arrayName表示数组的名字,[ ]用来表示这个变量的类型为一维数组。与C/C++语言不同的是,在Java语言中,数组被创建后会根据数组存放的数据类型初始化成对应的初始值(例如,int类型会初始化为0,对象会初始化为null)。另外一个不同之处是Java数组在定义时,并不会给数组元素分配存储空间,因此[ ]中不需要指定数组的长度,对于使用上面方式定义的数组在使用时还必须为之分配空间,分配方法为:
**arrayName=new type[arraySize];//arraySize表示数组的长度**
在完成数组的声明后,需要对其进行初始化,下面介绍两种初始化方法:
int[ ]a=new int[5];//动态创建了一个包含5个整型值的数组,默认初始化为0
int[ ]a={1,2,3,4,5};//声明一个数组类型变量并初始化
在Java语言中,二维数组有如下三种声明的方法:
type arrayName[][]:
type [][]arrayName:
type[]arrayName[]:
需要注意的是,在声明二维数组时,其中方括号[]中内容必须为空。
二维数组也可以用初始化列表的方式来进行初始化,其一般形式为
type arrayName[ ][ ]={{c11,c12,c13..},{c21,c22,c23..},{c31,c32,c33…}…};
除了以上介绍的方法以外,也可以通过new关键字来给数组申请存储空间,形式如下:
type arrayname[ ][ ]=new type[行数][列数]
与C/C++语言不同的是,在Java语言中,二维数组的第二维的长度可以不同。假如要定义一个有两行的二维数组,第一行有两列,第二行有三列,定义方法如下:
int[ ][ ]arr={{1,2},{3,4,5}};
int[ ][ ]a=new int[2][ ];
a[0]=new int[ ]{1,2};
a[1]=new int[ ]{3,4,5};
对二维数组的访问也是通过下标来完成,一般形式为arryName[行号][列号].
一个Java文件中是否可以定义多个类?
一个Java文件中可以定义多个类,但是最多只能有一个类被public修饰,并且这个类的类名与文件名必须相同,若这个文件中没有public的类,则文件名随便是一个类的名字即可。需要注意的是,当用javac指令编译这个.java文件时,它会给每一个类生成一个对应的.class文件。
内部类的作用是什么,有哪些分类?
内部类可对同一包中其他类隐藏,内部类方法可以访问定义这个内部类的作用域中的数据,包括 private 数据。内部类是一个编译器现象,与虚拟机无关。编译器会把内部类转换成常规的类文件,用 $ 分隔外部类名与内部类名,其中匿名内部类使用数字编号。
- 静态内部类: 属于外部类,只加载一次。作用域仅在包内,可通过 外部类名.内部类名 直接访问,类内只能访问外部类所有静态属性和方法。
- 成员内部类: 属于外部类的每个对象,随对象一起加载。不可以定义静态成员和方法,可访问外部类的所有内容。 只有在外部的类被实例化后,这个内部类才能被实例化。需要注意的是,非静态内部类中不能有静态成员。
- 局部内部类: 定义在方法内,不能声明访问修饰符,只能定义实例成员变量和实例方法,作用范围仅在声明类的代码块中。
- 匿名内部类: 只用一次的没有名字的类,可以简化代码,创建的对象类型相当于 new 的类的子类类型。用于实现事件监听和其他回调。
- 匿名内部类不能有构造函数。
- 匿名内部类不能定义静态成员、方法和类。
- 匿名内部类不能是public、protected、private、static。
- 只能创建匿名内部类的一个实例。
- 一个匿名内部类一定是在new的后面,这个匿名类必须继承一个父类或实现一个接口。
- 因为匿名内部类为局部内部类,所以局部内部类的所有限制都对其生效。
访问权限控制符有哪些?
访问权限控制符 | 本类 | 包内 | 包外子类 | 任何地方 |
---|---|---|---|---|
public | √ | √ | √ | √ |
protected | √ | √ | √ | × |
无 | √ | √ | × | × |
private | √ | × | × | × |
这些修饰符只能修饰成员变量,不能用来修饰局部变量。private 与 protected不能用来修饰类(只有public、abstract或final能用来修饰类)。
接口和抽象类的异同?
相同点:
- 都不能被实例化。
- 接口的实现类或抽象类的子类都只有实现了接口或抽象类中的方法后才能被实例化。
不同点:
- 实现数量:抽象类的子类使用 extends 来继承,单继承;接口必须使用 implements 来实现接口,可多继承。
- 构造函数:抽象类可以有构造函数;接口不能有。它们都不能实例化。
main
方法:抽象类可以有**main()**
方法,并且我们能运行它;接口不能有 main 方法。- 方法定义:接口只有定义,其方法不能在接口中实现,只有实现接口的类才能实现接口中定义的方法,而抽象类可以有定义与实现,在实现时,必须包含相同的或者更低的访问级别。**抽象类可以没有抽象方法,但有抽象方法一定是抽象类。**JDK8 支持默认/静态方法,JDK9 支持私有方法。
- 访问修饰符:接口中的方法默认使用
public abstract
修饰,里面不能有私有的方法或变量;抽象类中的方法可以是任意访问修饰符。 - 成员变量: 抽象类无特殊要求,接口默认
public static final
常量。 - 设计理念: 接口强调特定功能的实现,是“has-a”关系;而抽象类强调所属关系,其设计理念为“is-a”关系。接口被运用于实现比较常用的功能,便于日后维护或者添加删除方法;而抽象类更倾向于充当公共类的角色,不适用于日后重新对里面的代码进行修改。
子类初始化的顺序?
- 父类静态代码块和静态变量。
- 子类静态代码块和静态变量。
- 父类普通代码块和普通变量。
- 父类构造方法。子类会默认调用父类的无参数的构造方法。如果父类无默认构造方法,必须通过
super()
手工指定构造方法,且必须在方法第一行调用,否则会抛出异常。 - 子类普通代码块和普通变量。
- 子类构造方法。
基本语法
标识符和关键字的区别是什么?
在我们编写程序的时候,需要大量地为程序、类、变量、方法等取名字,于是就有了 标识符 。简单来说, 标识符就是一个名字 。
有一些标识符,Java 语言已经赋予了其特殊的含义,只能用于特定的地方,这些特殊的标识符就是 关键字 。简单来说,关键字是被赋予特殊含义的标识符 。比如,在我们的日常生活中,如果我们想要开一家店,则要给这个店起一个名字,起的这个“名字”就叫标识符。但是我们店的名字不能叫“警察局”,因为“警察局”这个名字已经被赋予了特殊的含义,而“警察局”就是我们日常生活中的关键字。
Java 语言关键字有哪些?
分类 | 关键字 | ||||||
---|---|---|---|---|---|---|---|
访问控制 | private | protected | public | ||||
类,方法和变量修饰符 | abstract | class | extends | final | implements | interface | native |
new | static | strictfp | synchronized | transient | volatile | enum | |
程序控制 | break | continue | return | do | while | if | else |
for | instanceof | switch | case | default | assert | ||
错误处理 | try | catch | throw | throws | finally | ||
包相关 | import | package | |||||
基本类型 | boolean | byte | char | double | float | int | long |
short | |||||||
变量引用 | super | this | void | ||||
保留字 | goto | const |
所有的关键字都是小写的。
default 这个关键字很特殊,既属于程序控制,也属于类,方法和变量修饰符,还属于访问控制。
- 在程序控制中,当在 switch 中匹配不到任何情况时,可以使用 default 来编写默认匹配的情况。
- 在类,方法和变量修饰符中,从 JDK8 开始引入了默认方法,可以使用 default 关键字来定义一个方法的默认实现。
- 在访问控制中,如果一个方法前没有任何修饰符,则默认会有一个修饰符 default,但是这个修饰符加上了就会报错。
注意:虽然 true, false, 和 null 看起来像关键字但实际上他们是字面值,同时你也不可以作为标识符来使用。
i++ 与 ++i 的区别?
在写代码的过程中,常见的一种情况是需要某个整数类型变量增加 1 或减少 1,Java 提供了一种特殊的运算符,用于这种表达式,叫做自增运算符(++)和自减运算符(--)。
前置与后置,它们的不同点在于后置的i是在程序执行完毕后自增,而前置的i是在程序开始执行前进行自增。都不是原子操作。
语句i=i++的执行过程如下:先把i的值取出来放到栈顶,可以理解为引入了一个第三方变量k,此时,k的值为i,然后执行自增操作,于是i的值变为1,最后执行赋值操作i=k(自增前的值),因此,执行结束后,i的值还是0。i=i++语句的执行过程由多个操作组成,它不是原子操作,因此,它不是线程安全的。在Java语言中,i和i操作并不是线程安全的。
Java位运算有哪些?
- 左移(<<)
m << n
的含义:把整数m表示的二进制数左移 n 位,高位移出 n 位都舍弃,低位补0。(此时将会出现正数变成负数的可能,溢出)。在数字没有溢出的前提下,对于正数和负数,左移n位都相当于m乘以2的n次方 - 右移(>>)
m >> n
的含义:把整数m表示的二进制数右移n位,m为正数,高位全部补0;m为负数,高位全部补1,相当于m除以2的n次方,得到的为整数时,即为结果。 - 无符号右移(>>>) m>>>n:整数m表示的二进制右移n位,不论正负数,高位都补0,
- 按位非操作(~)
~按位取反操作符,对每个二进制位的内容求反,即1变成0,0变成1
- 按位与操作(&) 位与操作符,对应的二进制位进行与操作,两个都为1才为1,其他情况均为0,
- 按位或操作(|) | 位或操作符,对应的二进制位进行或操作,两个都为0才为0,其他情况均为1
- 按位异或操作( ^ ) ^ 异或操作符,相同位值为0 否则为1
由于 double,float 在二进制中的表现比较特殊,因此不能来进行移位操作。
移位操作符实际上支持的类型只有int和long,编译器在对short、byte、char类型进行移位前,都会将其转换为int类型再操作。
如果移位的位数超过数值所占有的位数会怎样?
当 int 类型左移/右移位数大于等于 32 位操作时,会先求余(%)后再进行左移/右移操作。也就是说左移/右移 32 位相当于不进行移位操作(32%32=0),左移/右移 42 位相当于左移/右移 10 位(42%32=10)。当 long 类型进行左移/右移操作时,由于 long 对应的二进制是 64 位,因此求余操作的基数也变成了 64。
也就是说:x<<42等同于x<<10,x>>42等同于x>>10,x >>>42等同于x >>> 10。
continue、break 和 return 的区别是什么?
在循环结构中,当循环条件不满足或者循环次数达到要求时,循环会正常结束。但是,有时候可能需要在循环的过程中,当发生了某种条件之后 ,提前终止循环,这就需要用到下面几个关键词:
- continue:指跳出当前的这一次循环,继续下一次循环。
- break:指跳出整个循环体,继续执行循环下面的语句。
return 用于跳出所在方法,结束该方法的运行。return 一般有两种用法:
- return;:直接使用 return 结束方法执行,用于没有返回值函数的方法
- return value;:return 一个特定值,用于有返回值函数的方法
throw 和 throws 的区别?
throws是用来声明一个方法可能抛出的所有异常信息,是将异常声明但是不处理,而是将异常往上传,谁调用就交给谁处理。而throw则是指抛出的一个具体的异常类型。
final、finally、finalize 有什么区别?
- final 可以修饰类、变量、方法,修饰类表示该类不能被继承、修饰方法表示该方法不能被重写、修饰变量表示该变量是一个常量不能被重新赋值。
- finally 一般作用在 try-catch 代码块中,在处理异常的时候,通常我们将一定要执行的代码方法 finally 代码块中,表示不管是否出现异常,该代码块都会执行,一般用来存放一些关闭资源的代码。
- finalize 是一个方法,属于Object类的一个方法,而Object类是所有类的父类,该方法一般由垃圾回收器来调用,当我们调用System的gc()方法的时候,由垃圾回收器调用
finalize()
,回收垃圾。
& 与 && 有什么区别?
&是按位与操作符,a&b是把a和b都转换成二进制数后,然后再进行按位与的运算。而&&为逻辑与操作符,a&&b就是当且仅当两个操作数均为true时,其结果才为true,只要有一个为false,a&&b的结果就为false。
此外,&&还具有短路的功能,在参与运算的两个表达式中,只有当第一个表达式的返回值为true时,才会去计算第二个表达式的值,如果第一个表达式的返回值为false,则此时&&运算的结果就为false,不会去计算第二个表达式的值。
final 在 java 中有什么作用?
final属性:被final修饰的变量不可变。引用的不可变性,即它只能指向初始时指向的那个对象,而不关心指向对象内容的变化。所以,被final修饰的变量必须被初始化:
- 在定义的时候初始化。
- final成员变量可以在初始化块中初始化,但不可在静态初始化块中初始化。
- 静态final成员变量可以在静态初始化块中初始化,但不可在初始化块中初始化。
- 在类的构造器中初始化,但静态final成员变量不可以在构造函数中初始化。
final方法:当一个方法声明为final时,该方法不允许任何子类重写这个方法,但子类仍然可以使用这个方法。另外,还有一种被称为inline(内联)的机制,当调用一个被声明为final的方法时,直接将方法主体插入到调用处,而不是进行方法调用,这样做能提高程序的效率。
final参数:用来表示这个参数在这个函数内部不允许被修改。
final类:当一个类被声明为final时,此类不能被继承,所有方法都不能被重写。但这并不表示final类的成员变量也是不可改变的,要想做到final类的成员变量不可改变,必须给成员变量增加final修饰。值得注意的是,一个类不能既被声明为abstract,又被声明为final。
在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间 不能重排序 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
关键字 static 的作用是什么?
static关键字主要有两种作用:
- 为某特定数据类型或对象分配单一的存储空间,而与创建对象的个数无关。
- 希望某个方法或属性与类而不是对象关联在一起,也就是说,在不创建对象的情况下就可以通过类来直接调用方法或使用类的属性。
- static成员变量 可以通过static关键字来达到全局的效果。Java类提供了两种类型的变量:用static关键字修饰的静态变量和没有static关键字的实例变量。静态变量属于类,在内存中只有一个副本(所有实例都指向同一个内存地址),只要静态变量所在的类被加载,这个静态变量就会被分配空间。对静态变量的引用有两种方式,分别为“类.静态变量”和“对象.静态变量”。实例变量属于对象,对象被创建后,实例变量才会被分配空间,才能被使用,它在内存中存在多个副本。只能用“对象.静态变量”的方式来引用。静态变量只有一个,被类拥有,所有的对象都共享这个静态变量,而实例对象是与具体对象相关的。不能在方法体中定义static变量。
- static成员方法 Java类同时也提供了static方法与非static方法。static方法是类的方法,不需要创建对象就可以被调用,而非static方法是对象的方法,只有对象被创建出来后才可以被使用。static方法中不能使用this和super关键字,不能调用非static方法,只能访问所属类的静态成员变量和成员方法,因为当static方法被调用时,这个类的对象可能还没被创建,即使已经被创建了,也无法确定调用哪个对象的方法。同理,static方法也不能访问非static类型的变量。static的一个很重要的用途是实现单例模式。单例模式的特点是该类只能有一个实例,为了实现这一功能,必须隐藏类的构造方法,即把构造方法声明为private,并提供一个创建对象的方法。由于构造对象被声明为private,外界无法直接创建这个类型的对象,只能通过该类提供的方法来获取类的对象,要达到这样的目的只能把创建对象的方法声明为static。用public修饰的static变量和方法本质上都是全局的,如果在static变量前用private修饰则表示这个变量可以在类的静态代码块或者类的其他静态成员方法中使用,但是不能在其他类中通过类名来直接引用。
- static 代码块 static代码块(静态代码块)在类中是独立于成员变量和成员函数的代码块。它不在任何一个方法体内,JVM在加载类的时候会执行static 代码块,如果有多个static代码块,JVM将会按顺序来执行。static代码块经常被用来初始化静态变量。static代码块只会被执行一次。
- static内部类 static内部类是指被声明为static的内部类,它可以不依赖于外部类实例对象而被实例化而通常的内部类需要在外部类实例化后才能实例化。静态内部类不能与外部类有相同的名字不能访问外部类的普通成员变量,只能访问外部类中的静态成员和静态方法(包括私有类型)。只有内部类才能被定义为static。
关键字 assert 有什么作用?
断言(assert)作为一种软件调试的方法,提供了一种在代码中进行正确性检查的机制。它的主要作用是对一个boolean表达式进行检查,一个正确运行的程序必须保证这个boolean表达式的值为true,若boolean表达式的值为false,则说明程序已经处于一种不正确的状态下,系统需要提供告警信息并且退出程序。assert主要用来保证程序的正确性,通常在程序开发和测试时使用。为了提高程序运行的效率,assert 检查默认是被关闭的,需要显示的开启生效才有作用。 -esa 参数打开,使用 -dsa 参数关闭。
它的语法形式有如下所示的两种形式:
assert condition;
condition 是一个布尔表达式。如果表达式的结果为true,那么断言为真,无任何行动;如果表达式为false,则断言失败,则会抛出一个AssertionError
对象,该对象继承于Error对象。assert condition:expr;
condition是和上面一样的,这个冒号后跟的 expr 表示一个基本类型或者是一个对象,通常用于断言失败后的提示信息,它是一个传到AssertionError
构造函数的值,如果断言失败,该值被转化为它对应的字符串,并显示出来。
assert的应用范围很多,主要包括:检查控制流;检查输入参数是否有效;检查函数结果是否有效;检查程序不变量。
虽然assert的功能与if判断类似,但二者存在着本质的区别:assert一般在调试程序时使用,但如果不小心用assert来控制了程序的业务流程,那在调试结束后去掉assert就意味着修改了程序的正常逻辑,这样的做法是非常危险的;而if判断是逻辑判断,本身就是用以控制程序流程的。
switch 分支有什么特点?
switch语句用于多分支选择,在使用switch(expr)
时,expr只能是一个枚举常量或一个整数表达式,其中,整数表达式可以是基本数据类型int或其对应的包装类Integer,包括不同的长度整型,byte、short和char都能够被隐式地转换为int类型。但是,long、float、double及String类型由于不能够隐式地转换为int类型,因此,它们不能被用作switch的表达式。如果一定要使用long、float或double作为switch的参数,必须将其强制转换为int型才可以。
与switch对应的是case语句,case语句之后可以是直接的常量数值,也可以是一个常量计算式,还可以是final型的变量(final变量必须是编译时的常量),但不能是变量或带有变量的表达式,更不能是浮点型数。
在Java7中,switch开始支持String类型。**switch对字符串的支持,其实是int类型值的匹配。**它的实现原理如下:通过对case后面的String对象调用hashCode()方法,得到一个int类型的Hash值,然后用这个Hash值来唯一标识着这个case。那么当匹配的时候,首先调用这个字符串hashCode()方法获取一个Hash值,用这个Hash值来匹配所有的case,如果没有匹配成功,说明不存在;如果匹配成功了,接着会调用字符串的String.equals()方法进行匹配。所以String变量不能为null,switch的case子句中使用的字符串也不能为null。
在使用switch时,一般必须在case语句结尾添加break语句。因为一旦通过switch语句确定了入口点,就会顺序执行后面的代码,直到遇到关键字break。如果省略了break语句,那么匹配的case值后的所有情况(包括default情况)都会被执行。
instanceof 有什么作用?
instanceof是Java语言中的一个二元运算符,它的作用是判断一个引用类型的变量所指向的对象是否是一个类(或接口、抽象类、父类,数组)的实例。常见的用法为:result=object instanceof class
。如果object是class的一个实例,那么instanceof 运算符返回true;如果object不是class的一个实例,或者object是null,那么instanceof 运算符返回false。
strictfp有什么作用?
关键字strictfp是strict float point的缩写,指的是精确浮点,它用来确保浮点数运算的准确性。**JVM在执行浮点数运算时,如果没有指定strictfp关键字,此时计算结果可能会不精确,而且计算结果在不同平台或厂商的虚拟机上会有不同的结果。**而使用了strictfp来声明一个类、接口或者方法,那么在所声明的范围内,Java编译器以及运行环境会完全依照IEEE二进制浮点数算术标准(IEEE 754)来执行。需要注意的是,当一个类被strictfp修饰时,所有方法都会自动被strictfp修饰。
for 循环体的执行流程是怎样的?
for循环语句的基本结构: for(表达式1;表达式2;表达式3){循环体}
,它的执行过程如下:
- 执行初始化语句:表达式1(只会被执行一次)。
- 执行表达式2,如果表达式2的结果为false,则结束循环,否则,执行循环体,然后执行表达式3。
- 循环步骤 2,直到表达式2的结果为false时退出循环,或者循环体内有退出循环的语句(return或break)。
基本数据类型
Java 有哪些基本数据类型?
Java 中有 8 种基本数据类型,分别为:
6 种数字类型:
4 种整数型:byte、short、int、long
2 种浮点型:float、double
1 种字符类型:char
1 种布尔型:boolean。
数据类型 | 内存大小 | 默认值 | 包装类型 | 常量池范围 |
---|---|---|---|---|
byte | 1B | 0 | byte | [-128,127] |
short | 2B | 0 | short | [-128,127] |
int | 4B | 0 | int | [-128,127] |
long | 8B | 0 | long | [-128,127] |
float | 4B | 0.0f | float | |
double | 8B | 0.0d | double | |
char | 2B(Unicode) | \u0000 | Character | [0,127] |
boolean | 单个变量4B, 数组1B | false | Boolean | TRUE or FALSE |
- byte、short、int、long能表示的最大正数都减 1 了。这是因为在二进制补码表示法中,最高位是用来表示符号的(0 表示正数,1 表示负数),其余位表示数值部分。所以,如果我们要表示最大的正数,我们需要把除了最高位之外的所有位都设为 1。如果我们再加 1,就会导致溢出,变成一个负数。
- 对于 boolean,Java规范没有明确的规定,因此,不同的JVM可能会有不同的实现。**单个 boolean 变量用 int 代替,boolean 数组会编码成 byte 数组。**JVM 没有boolean赋值的专用字节码指令,
boolean f = false
就是使用ICONST_0
即常数0赋值。 - Java 的每种基本类型所占存储空间的大小不会随机器硬件架构的变化而变化。这种所占存储空间大小的不变性是 Java 程序比用其他大多数语言编写的程序更具可移植性的原因之一。
- 内码是程序内部使用的字符编码,某种语言实现其char或String类型在内存里用的内部编码;外码是程序与外部交互时外部使用的字符编码。“外部”相对“内部”而言;不是char或String在内存里用的内部编码的地方都可以认为是“外部”。Java语言规范规定,Java的char类型是UTF-16的code unit(内码),也就是一定是16位(2字节);
- Java语言中默认**直接写的整型数字是int类型的,**long 类型的数据一定要在数值后面加上 L,否则将作为整型解析。小数是double类型的 给 float类型变量直接赋值需要强制转换,float f=(float)3.4或者float f=3.4F。
- Java语言中,还存在另外一种基本类型void,它也有对应的封装类
java.lang.Void
,只是无法直接对它进行操作而已。 - 引用本身存储的对象的地址信息,而这个地址信息是存储在栈中的,在声明后就会立刻在栈上分配存储空间,占4个字节。在方法调用传递引用的时候对形参引用的值本身所做的修改对实参不可见,因此,从本质上来讲,引用也是原始数据类型(Primitive)。虽然引用在底层是通过指针实现的,但是引用和指针不能等同,例如指针可以执行比较运算和整数加减运算,而引用却不行,所以引用不是指针。
- Java语言中除了8种基本数据类型,其他的类型都是对象,数组也是对象。
Java整型的字节序是怎样的?
在计算机系统中,所有的存储都是以字节(一个字节占用8bit)为单位进行存储的,字节序是指多字节数据在计算机内存中存储或者网络传输时各字节的存储顺序,通常两种方式:
- 小端(Little-Endian)是指低位字节存放在内存的低地址端,高位字节存放在内存的高地址端。
- 大端(Big-Endian)是指高位字节存放在内存的低地址端,低位字节存放在内存的高地址端。
如十六进制数字表示在内存中的存储方式为(低地址------------------>高地址):
书写序 | 小端 | 大端 |
---|---|---|
0x12345678 | 0x78|0x56|0x34|0x12 | 0x12|0x34|0x56|0x78 |
- Java字节序:指的是在 Java 虚拟机中多字节类型数据的存放顺序,Java字节序是BigEndian(大端)。
- 主机字节序:指的是在计算机中多字节类型数据的存放顺序,主机字节序与CPU类型有关。
IA架构(Intel、AMD)的CPU中是Little-Endian,而PowerPC 、SPARC和Motorola处理器是Big-Endian。
获取CPU是大端还是小端:java.nio.ByteOrder.nativeOrder()
。
- 网络字节序:是指多字节类型数据在网络上传输时的顺序,在Internet的网络字节序是BigEndian(大端)。
JVM会根据底层的操作系统和CPU自动进行字节序的转换,所以我们使用Java进行网络编程,几乎感觉不到字节序的存在。
Java 数据表示形式是怎样的?
在Java中,所有数据的表示方式都是以补码形式来表示:正数:原码、补码相同;负数:符号位为1,其余各位是对原码取反,然后整个数加1
基本类型和包装类型的区别?
八种基本类型都有对应的包装类分别为:Byte、Short、Integer、Long、Float、Double、Character、Boolean 。
用途:除了定义一些常量和局部变量之外,我们在其他地方比如方法参数、对象属性中很少会使用基本类型来定义变量。并且,包装类型可用于泛型,而基本类型不可以。
存储方式:基本数据类型的局部变量存放在 Java 虚拟机栈中的局部变量表中,基本数据类型的成员变量(未被 static 修饰 )存放在 Java 虚拟机的堆中。包装类型属于对象类型,我们知道几乎所有对象实例都存在于堆中。
占用空间:相比于包装类型(对象类型), 基本数据类型占用的空间往往非常小。
默认值:成员变量包装类型不赋值就是 null ,而基本类型有默认值且不是 null。
比较方式:对于基本数据类型来说,== 比较的是值。对于包装数据类型来说,== 比较的是对象的内存地址。所有整型包装类对象之间值的比较,全部使用 equals() 方法。
包装类型的缓存机制了解么?
Java 基本数据类型的包装类型的大部分都用到了缓存机制来提升性能。
Byte,Short,Integer,Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在 [0,127] 范围的缓存数据,Boolean 直接返回 True or False。两种浮点数类型的包装类 Float,Double 并没有实现缓存机制。
缓存范围可以通过-XX:AutoBoxCacheMax
来指定缓冲池的大小。
常量池范围内的对象 用**==**
比较也为 true,但是比较两个包装类数值要用**equals()**
,而不能用**==**
。通过**valueOf()**
方法获取才取常量池中的数据。直接**new()**
的对象不是从常量池中获取 **==**
仍未 false 。
自动装箱与拆箱原理是什么?
装箱:将基本类型用它们对应的引用类型包装起来;
拆箱:将包装类型转换为基本数据类型;
装箱其实就是调用了 包装类的valueOf()
方法,拆箱其实就是调用了xxxValue()
方法。如果频繁拆装箱的话,也会严重影响系统的性能。我们应该尽量避免不必要的拆装箱操作。
为什么浮点数运算的时候会有精度丢失的风险?
这个和计算机保存浮点数的机制有关。计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就是解释了为什么浮点数没有办法用二进制精确表示。
通常我们使用 BigDecimal 可以实现对浮点数的运算,不会造成精度丢失。通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 BigDecimal 来做的。
超过 long 整型的数据应该如何表示?
基本数值类型都有一个表达范围,如果超过这个范围就会有数值溢出的风险。
在 Java 中,64 位 long 整型是最大的整数类型。可以使用 BigInteger 内部使用 int[] 数组来存储任意大小的整形数据。相对于常规整数类型的运算来说,BigInteger 运算的效率会相对较低。
参与运算时,不同数据类型的转换有哪些规则?
当参与运算的两个变量的数据类型不同时,就需要进行隐式的数据类型转换,转换的规则为:从低精度向高精度转换,在Java语言中,类型转换可以分为以下几种类型:
- 类型自动转换低级数据类型可以自动转换为高级数据类型。需要注意以下几点:
- char类型的数据转换为高级类型时,会转换为其对应的ASCII码。
- byte、char、short类型的数据在参与运算时会自动转换为int型,但当使用“+=”运算时,就不会产生类型的转换。
- 基本数据类型与boolean类型是不能相互转换的。
总之,当有多种类型的数据混合运算时,系统会先自动地将所有数据转换成容量最大的那一种数据类型,然后再进行计算。
- 强制类型转换 当需要从高级数据类型转换为低级数据类型时,就需要进行强制类型转换,需要注意的是,在进行强制类型转换时可能会损失精度。
a=a+b与a+=b有什么不同?
+=
操作符会进行隐式自动类型转换,此处a+=b隐式的将加操作的结果类型强制转换为持有结果的类型,而a=a+b则不会自动进行类型转换。在Java中,当参与运算的两个数是byte、short 或 int 时,它们首先都会被转换为int类型,再进行计算
byte a = 127;
byte b = 127; b = a + b; // 报编译错误:cannot convert from int to byte b
short s1= 1; s1 = s1 + 1; //报编译错误
short s1= 1; s1 += 1; //right
变量
Java 变量命名有哪些规则?
在Java语言中,变量名、函数名、数组名统称为标识符,Java语言规定标识符:
- 只能由字母(a~z,A~Z)、数字(0~9)、下画线(_)和$组成,
- 标识符的第一个字符必须是字母、下画线或$。
- 标识符也不能包含空白字符(换行符、空格和制表符)。变量名对大小写敏感,无长度限制。
成员变量与局部变量的区别有哪些?
- 从语法形式上看,成员变量是属于类的,而局部变量是在代码块或方法中定义的变量或是方法的参数;成员变量可以被 public,private,static 等修饰符所修饰,而局部变量不能被访问控制修饰符及 static 所修饰;但是,成员变量和局部变量都能被 final 所修饰。
- 从变量在内存中的存储方式来看,如果成员变量是使用 static 修饰的,那么这个成员变量是属于类的,如果没有使用 static 修饰,这个成员变量是属于实例的。而对象存在于堆内存,局部变量则存在于栈内存。
- 从变量在内存中的生存时间上看,成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动消失。
- 从变量是否有默认值来看,成员变量如果没有被赋初,则会自动以类型的默认值而赋值(一种情况例外:被 final 修饰的成员变量也必须显式地赋值),而局部变量则不会自动赋值。
为什么成员变量有默认值?
- 如果没有默认值,变量存储的是内存地址对应的任意随机值,程序读取该值运行会出现意外。
- 默认值有两种设置方式:手动和自动,根据第一点,没有手动赋值一定要自动赋值。成员变量在运行时可借助反射等方法手动赋值,而局部变量不行。
- 对于编译器(javac)来说,局部变量没赋值很好判断,可以直接报错。而成员变量可能是运行时赋值,无法判断,误报“没默认值”又会影响用户体验,所以采用自动赋默认值。
静态变量有什么作用?
静态变量也就是被 static 关键字修饰的变量。它可以被类的所有实例共享,无论一个类创建了多少个对象,它们都共享同一份静态变量。也就是说,静态变量只会被分配一次内存,即使创建多个对象,这样可以节省内存。
静态变量是通过类名来访问的,例如StaticVariableExample.staticVar(如果被 private关键字修饰就无法这样访问了)。
通常情况下,静态变量会被 final 关键字修饰成为常量。
字符型常量和字符串常量的区别?
- 形式 : 字符常量是单引号引起的一个字符,字符串常量是双引号引起的 0 个或若干个字符。
- 含义 : 字符常量相当于一个整型值( ASCII 值),可以参加表达式运算; 字符串常量代表一个地址值(该字符串在内存中存放位置)。
- 占内存大小:字符常量只占 2 个字节; 字符串常量占若干个字节。
char 在 Java 中占两个字节。
方法
什么是方法的返回值?方法有哪几种类型?
方法的返回值 是指我们获取到的某个方法体中的代码执行后产生的结果,前提是该方法可能产生结果。可以按照方法的返回值和参数类型将方法分为下面这几种:
1、无参数无返回值的方法
2、有参数无返回值的方法
3、有返回值无参数的方法
4、有返回值有参数的方法
静态方法为什么不能调用非静态成员?
- 静态方法是属于类的,在类加载的时候就会分配内存,可以通过类名直接访问。而非静态成员属于实例对象,只有在对象实例化之后才存在,需要通过类的实例对象去访问。
- 在类的非静态成员不存在的时候静态方法就已经存在了,此时调用在内存中还不存在的非静态成员,属于非法操作。
静态方法和实例方法有何不同?
- 调用方式
在外部调用静态方法时,可以使用 类名.方法名 的方式,也可以使用 对象.方法名 的方式,而实例方法只有后面这种方式。也就是说,调用静态方法可以无需创建对象 。
不过,需要注意的是一般不建议使用 对象.方法名 的方式来调用静态方法。这种方式非常容易造成混淆,静态方法不属于类的某个对象而是属于这个类。
因此,一般建议使用 类名.方法名 的方式来调用静态方法。
- 访问类成员是否存在限制
静态方法在访问本类的成员时,只允许访问静态成员(即静态成员变量和静态方法),不允许访问实例成员(即实例成员变量和实例方法),而实例方法不存在这个限制。
重载和重写有什么区别?
- 重载
重载指方法名称相同,但参数列表不同:参数类型不同、个数不同、顺序不同,但方法返回值和访问修饰符可以不同。发生在同一个类中(或者父类和子类之间),是行为水平方向不同实现。对编译器来说,方法名称和参数列表组成了一个唯一键,称为方法签名,JVM 通过方法签名与特定方法调用所使用的值类型进行匹配来决定调用哪种重载方法,这个过程被称为重载解析(overloading resolution),如果编译器找不到匹配的参数, 就会产生编译时错误。重载在编译时知道调用哪种目标方法,因此属于静态绑定。不能仅仅通过返回值类型不同进行重载。Java 允许重载任何方法, 包括构造器方法。
- 重写
重写指子类实现接口或继承父类时,保持方法签名(方法名称和参数列表)完全相同,实现不同方法体,是行为垂直方向不同实现。
元空间有一个方法表保存方法信息,如果子类重写了父类的方法,则方法表中的方法引用会指向子类实现,重写是在运行时知道调用哪种目标方法,因此属于动态绑定。父类引用执行子类方法时无法调用子类存在而父类不存在的方法。
重写方法访问权限不能变小,返回类型和抛出的异常类型不能变大,建议加 @Override 。
构造方法无法被重写。
重写的返回值类型:如果方法的返回类型是 void 和基本数据类型,则返回值重写时不可修改。但是如果方法的返回值是引用类型,重写时是可以返回该引用类型的子类的。
如果父类方法访问修饰符为 private/final/static 则子类就不能重写该方法,但是被 static 修饰的方法能够被再次声明。
区别点 | 重载方法 | 重写方法 |
---|---|---|
发生范围 | 同一个类 | 子类 |
参数列表 | 必须修改 | 一定不能修改 |
返回类型 | 可修改 | 返回值是对象类型则可以是父类返回值的派生类,返回值是 void 或基本数据类型,则返回值重写时不可修改 |
异常 | 可修改 | 子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等 |
访问修饰符 | 可修改 | 不能做更严格的限制(可以降低限制) |
发生阶段 | 编译期 | 运行期 |
重载方法中选择合适方法的顺序是怎样的?
- 按参数列表精确匹配。
- 基本数据类型自动转换成更大表示范围。
- 自动拆箱与装箱。
- 子类向上转型。
- 可变参数。
如果仍未找的则抛出异常。
可变参数有什么特点?
在Java语言中,可以使用省略号**…**
来实现可变参数,有如下特点:
- 可以接受 0 个或者多个参数。
- 只能作为最后一个参数出现。
- 只能位于变量的类型和变量名之间。
- 编译器为可变参数隐含创建一个数组,在调用的时候,可以用数组的形式来访问可变参数。
为什么需要public static void main (String[]args)
ß这个方法?
main
方法为Java程序的入口方法,JVM 在运行程序时,会首先查找 main
方法。JVM在启动时就是按照上述方法的签名(必须有public与static修饰,返回值为void,且方法的参数为字符串数组)来查找方法的入口地址,若能找到,就执行;找不到,则会报错。void表明方法没有返回值,main是JVM识别的特殊方法名,是程序的入口方法。字符串数组参数args为开发人员在命令行状态下与程序交互提供了一种手段。其他定义形式:
//public与static没有先后顺序关系
static public void main(String[ ]args)
// 可以把main()方法定义为final
public static final void main(String[ ]args)
// 也可以用synchronized来修饰
static public synchronized void main(String[ ]args)
不管哪种定义方式,都必须保证方法的返回值为void,并有static与public关键字修饰。同时由于main方法为程序的入口方法,因此不能用abstract关键字来修饰。Java程序可以定义多个main方法,但是只有public static void main(String[] args)
方法才是Java程序的入口方法,其他main方法都不是,并且这个入口方法必须被定义在类名与文件名相同的被public修饰的类中。
Java中构造方法和普通方法的区别?
- 构造方法名与类名必须完全一致,普通方法也可以与类名一致。
- 构造方法没有任何返回值类型的声明,包括void也没有
- 构造方法中不能使用return语句
- 构造方法可以重载,不能被重写,也不能被继承。
- 接口和抽象类不允许被实例化,所以没有构造方法。
- 每个类可以有多个构造函数。当一个类中没有定义构造函数时,系统会默认添加一个无参的默认构造方法,但该构造函数不会执行任何代码。存在自定义构造方法时,不会再自动添加无参的构造方法。默认构造器的修饰符只跟当前类的修饰符相同,如,如果一个类被定义为public,那么它的构造函数也是public。
- 构造方法不能被static、final、synchronized、abstract和native修饰,可以被权限修饰符public、protected 和 private 修饰。
- 在构造方法中调用其他构造方法(
this()
)或者子类调用父类构造方法(super()
)时需都要在第一行。 - 构造函数总是伴随着new操作一起调用,由系统调用。构造函数在对象实例化时会被自动调用,且只运行一次;而普通的方法是在程序执行到它时被调用,且可以被该对象调用多次。
- 在Java语言中,不管方法体里有几条语句,所有的方法体都必须用大括号{}括起来。
值传递与引用传递有哪些区别?
在方法调用时,通常需要传递一些参数来完成特定的功能。Java语言提供了两种参数传递的方式:值传递和引用传递。
- 值传递在方法调用中,实参会把它的值传递给形参,形参只是用实参的值初始化一个临时的存储单元,因此形参与实参虽然有着相同的值,但是却有着不同的存储单元,因此对形参的改变不会影响实参的值。
- 引用传递在方法调用中,传递的是对象(也可以看作是对象的地址),这时形参与实参的对象指向同一块存储单元,因此对形参的修改就会影响实参的值。
Java 中将实参传递给方法(或函数)的方式是 值传递:
- 如果参数是基本类型的话,很简单,传递的就是基本类型的字面量值的拷贝,会创建副本。
- 如果参数是引用类型,传递的就是实参所引用的对象在堆中地址值的拷贝,同样也会创建副本。
面向对象基础
面向对象有哪些特性?
- 抽象 抽象就是将一些事物的共性和相似点抽离出来,并将这些属性归为一个类,这个类只考虑这些事物的共性和相似之处,并且会忽略与当前业务和目标无关的那些方面,只将注意力集中在与当前目标有关的方面。
- 封装 封装是指把一个对象的状态信息(也就是属性)隐藏在对象内部,不允许外部对象直接访问对象的内部信息。可以提供一些可以被外界访问的方法来操作属性。如果属性不想被外界访问,我们可不提供方法给外界访问。但是如果一个类没有提供给外界访问的方法,那么这个类也没有什么意义了。
封装是对象功能内聚的表现形式,在抽象基础上决定信息是否公开及公开等级,核心问题是以什么方式暴漏哪些信息。主要任务是对属性、数据、敏感行为实现隐藏,对属性的访问和修改必须通过公共接口实现。
封装使对象关系变得简单,降低了代码耦合度,方便维护。
如果将 public 的属性和行为修改为 private 一般依赖模块都会报错,因此不知道使用哪种权限时应优先使用 private。
迪米特原则就是对封装的要求,即 A 模块使用 B 模块的某接口行为,对 B 模块中除此行为外的其他信息知道得应尽可能少。
- 继承
不同类型的对象,相互之间经常有一定数量的共同点。同时,每一个对象还定义了额外的特性使得他们与众不同。继承是使用已存在的类的定义作为基础建立新类的技术,新类可以增加新的数据或新的功能,也可以复用父类的功能,但不能选择性地继承父类。通过使用继承,可以快速地创建新的类,提高代码的重用,程序的可维护性,节省大量创建新类的时间 ,提高我们的开发效率。
关于继承如下特点:
- 子类拥有父类对象所有的属性和方法(包括私有属性和私有方法),但是父类中的私有属性和方法子类是无法访问,只是拥有。
- 继承是"is-a"关系,可使用里氏替换原则判断是否满足"is-a"关系,即任何父类出现的地方子类都可以出现。
- Java语言不支持多重继承,但是可以通过实现多个接口来达到多重继承的目的。
- 子类可以拥有自己属性和方法,即子类可以对父类进行扩展。
- 子类可以用自己的方式实现父类的方法,采用与父类相同的方法名,相同的参数个数与类型。
- 当子类中定义的成员变量和父类中定义的成员变量同名时,子类中的成员变量会覆盖父类的成员变量,而不会继承。
- 多态
多态以封装和继承为基础,表示一个对象具有多种的状态,具体表现为父类的引用指向子类的实例,根据运行时对象实际类型使同一行为具有不同表现形式。
Java中提供了两种多态的机制:编译时多态和运行时多态。编译时多态是通过方法重载实现的,运行时多态是通过方法覆盖(子类覆盖父类方法)实现的。由于重载属于静态绑定,本质上重载结果是完全不同的方法,因此多态一般专指重写。
多态的特点:
对象类型和引用类型之间具有继承(类)/实现(接口)的关系;
引用类型变量发出的方法调用的到底是哪个类中的方法,必须在程序运行期间才能确定;
多态不能调用“只在子类存在但在父类不存在”的方法;
如果子类重写了父类的方法,真正执行的是子类覆盖的方法,如果子类没有覆盖父类的方法,执行的是父类的方法。
面向对象和面向过程的区别?
两者的主要区别在于解决问题的方式不同:
- 面向过程把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题。
- 面向对象会先抽象出对象,然后用对象执行方法的方式解决问题。
另外,面向对象开发的程序一般更易维护、易复用、易扩展。
对象的相等和引用相等的区别?
- 对象的相等一般比较的是内存中存放的内容是否相等。
- 引用相等一般比较的是他们指向的内存地址是否相等。
如果一个类没有声明构造方法,该程序能正确执行吗?
构造方法是一种特殊的方法,主要作用是完成对象的初始化工作。
如果一个类没有声明构造方法,也可以执行。因为一个类即使没有声明构造方法也会有默认的不带参数的构造方法。Java程序在执行子类的构造方法之前,如果没有用super()来调用父类特定的构造方法,则会调用父类中没有参数的构造方法。如果父类中只定义了有参数的构造方法,而在子类的构造方法中又没有用super()
来调用父类中特定的构造方法,则编译时将发生错误。解决办法是在父类里加上一个不做事且没有参数的构造方法。
如果我们自己添加了类的构造方法(无论是否有参),Java 就不会添加默认的无参数的构造方法了。
构造方法有哪些特点?是否可被 override?
构造方法特点如下:
- 名字与类名相同。
- 没有返回值,但不能用 void 声明构造函数。
- 生成类的对象时自动执行,无需调用。
构造方法不能被 override(重写),但是可以 overload(重载),所以你可以看到一个类中有多个构造函数的情况。
类之间有哪些关系?
类关系 | 描述 | 权力强侧 | 举例 |
---|---|---|---|
继承 | 父子类之间的关系:is-a | 父类 | 小狗继承于动物 |
实现 | 接口和实现类之间的关系:can-do | 接口 | 小狗实现了狗叫接口 |
组合 | 比聚合更强的关系:contains-a | 整体 | 头是身体的一部分 |
聚合 | 暂时组装的关系:has-a | 组装方 | 小狗和绳子是暂时的聚合关系 |
依赖 | 一个类用到另一个:depends-a | 被依赖方 | 人养小狗,人依赖于小狗 |
关联 | 平等的使用关系:links-a | 平等 | 人使用卡消费,卡可以提取人的信息 |
组合和继承有什么区别?
组合和继承是面向对象中两种代码复用的方式。组合是指在新类里面创建原有类的对象,重复利用已有类的功能。继承是面向对象的主要特性之一,它允许设计人员根据其他类的实现来定义一个类的实现。组合和继承都允许在新的类中设置子对象(subObject),只是组合是显式的,而继承则是隐式的。组合和继承存在着对应关系:组合中的整体类和继承中的子类对应,组合中的局部类和继承中的父类对应。
对二者的选择一般情况下,遵循以下两点原则:
- 除非两个类之间是“is-a”的关系,否则不要轻易地使用继承,不要单纯地为了实现代码的重用而使用继承,因为过多地使用继承会破坏代码的可维护性,当父类被修改时,会影响到所有继承自它的子类,从而增加程序的维护难度与成本。
- 不要仅仅为了实现多态而使用继承,如果类之间没有“is-a”的关系,可以通过实现接口与组合的方式来达到相同的目的。设计模式中的策略模式可以很好地说明这一点,采用接口与组合的方式比采用继承的方式具有更好的可扩展性。
Java语言只支持单继承,如果想同时继承两个类或多个类,在Java中是无法直接实现的。如果继承使用太多,也会让一个class里面的内容变得臃肿不堪。所以,在Java语言中,能使用组合就尽量不要使用继承。
接口和抽象类有什么共同点和区别?
共同点:
- 都不能被实例化。
- 都可以包含抽象方法。
- 都可以有默认实现的方法(Java 8 可以用 default 关键字在接口中定义默认方法)。
区别:
- 接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系。
- 一个类只能继承一个类,但是可以实现多个接口。
- 接口中的成员变量只能是 public static final 类型的,不能被修改且必须有初始值,而抽象类的成员变量默认 default,可在子类中被重新定义,也可被重新赋值。
普通类和抽象类有哪些区别?
- 普通类不能包含抽象方法,抽象类可以包含抽象方法。
- 抽象类不能直接实例化,普通类可以直接实例化。
- 抽象类不能使用 final 修饰,如果定义为 final 该类就不能被继承,这样彼此就会产生矛盾,所以 final 不能修饰抽象类。
什么是深拷贝和浅拷贝?
- 浅复制:浅拷贝会在堆上创建一个新的对象(区别于引用拷贝的一点),复制对象的所有变量都含有与原来对象相同的值,而所有对其他对象的引用仍然指向原来的对象,Java 默认为浅复制。
- 深复制:被复制对象的所有变量都含有与原来对象相同的值,而所有对其他对象的引用将指向被复制的新对象。
/**
* native 方法,用于返回当前运行时对象的 Class 对象,使用了 final 关键字修饰,故不允许子类重写。
*/
public final native Class<?> getClass()
/**
* native 方法,用于返回对象的哈希码,主要使用在哈希表中,比如 JDK 中的HashMap。
*/
public native int hashCode()
/**
* 用于比较 2 个对象的内存地址是否相等,String 类对该方法进行了重写以用于比较字符串的值是否相等。
*/
public boolean equals(Object obj)
/**
* native 方法,用于创建并返回当前对象的一份拷贝。 clone 方法声明为 protected ,类只能通过该方法克隆它自己的对象。
* 如果一个对象的类没有实现 Cloneable接口,调用clone方法会抛出CloneNotSupport异常。
* 默认的 clone方法是浅拷贝,重写clone方法需要实现 Cloneable 接口并指定访问修饰符为 public。
*/
protected native Object clone() throws CloneNotSupportedException
/**
* 返回类的名字实例的哈希码的 16 进制的字符串。建议 Object 所有的子类都重写这个方法。
*/
public String toString()
/**
* native 方法,并且不能重写。唤醒一个在此对象监视器上等待的线程(监视器相当于就是锁的概念)。如果有多个线程在等待只会任意唤醒一个。
*/
public final native void notify()
/**
* native 方法,并且不能重写。跟 notify 一样,唯一的区别就是会唤醒在此对象监视器上等待的所有线程,而不是一个线程。
*/
public final native void notifyAll()
/**
* native方法,并且不能重写。暂停线程的执行。注意:sleep 方法没有释放锁,而 wait 方法释放了锁 ,timeout 是等待时间。
*/
public final native void wait(long timeout) throws InterruptedException
/**
* 多了 nanos 参数,这个参数表示额外时间(以纳秒为单位,范围是 0-999999)。 所以超时的时间还需要加上 nanos 纳秒。。
*/
public final void wait(long timeout, int nanos) throws InterruptedException
/**
* 跟之前的2个wait方法一样,只不过该方法一直等待,没有超时时间这个概念
*/
public final void wait() throws InterruptedException
/**
* 实例被垃圾回收器回收的时候触发的操作。确定一个对象死亡至少要经过两次标记,
* 如果对象在可达性分析后发现没有与 GC Roots 连接的引用链会被第一次标记,随后进行第二次筛选,条件是对象是否有必要执行 finalize 方法。
* 假如对象没有重写该方法或方法已被虚拟机调用,都视为没有必要执行。
* 如果有必要执行,对象会被放置在 F-Queue 队列,由一条低调度优先级的 Finalizer 线程去执行。虚拟机会触发该方法但不保证会结束,
* 这是为了防止某个对象的 finalize 方法执行缓慢或发生死循环。只要对象在 finalize 方法中重新与引用链上的对象建立关联就会在第二次标记时被移出回收集合。
* 由于运行代价高昂且无法保证调用顺序,在 JDK 9 被标记为过时方法,并不适合释放资源。
*/
protected void finalize() throws Throwable { }
== 和 equals() 的区别?
== 对于基本类型和引用类型的作用效果是不同的:
- 对于基本数据类型来说,== 比较的是值。比较一个基本类型的包装类对象使用 == 和 equals进行比较的结果,也有可能返回true(对象池)。
- 对于引用数据类型来说,== 比较的是对象的内存地址。注意,两边的对象类型必须是同一类型的(可以是父子类之间)才能编译通过。
因为 Java 只有值传递,所以,对于 == 来说,不管是比较基本数据类型,还是引用数据类型的变量,其本质比较的都是值,只是引用类型变量存的值是对象的地址。
equals()方法存在于Object类中,而Object类是所有类的直接或间接父类,因此所有的类都有equals()方法。
- 类没有重写 **equals()**方法:通过equals()比较该类的两个对象时,等价于通过“==”比较这两个对象,使用的默认是 Object类equals()方法。
- 类重写了 equals()方法:一般我们都重写 equals()方法来比较两个对象中的属性是否相等;若它们的属性相等,则返回 true(即,认为这两个对象相等)。一般来说重写
equals()
方法的同时也需要重写hasCode()
方法。
重写 equals()方法需要遵循哪些原则?
- 自反性:对任意引用值X,x.equals(x)的返回值一定为true;
- 对称性:对于任何引用值x,y,当且仅当y.equals(x)返回值为true时,x.equals(y)的返回值一定为true;
- 传递性:如果x.equals(y)=true, y.equals(z)=true,则x.equals(z)=true ;
- 一致性:如果参与比较的对象没任何改变,则对象比较的结果也不应该有任何改变;
- 非空性:任何非空的引用值X,x.equals(null)的返回值一定为false
hashCode() 有什么用?
hashCode() 的作用是获取哈希码(int 整数),也称为散列码。这个哈希码的作用是确定该对象在哈希表中的索引位置。
hashCode() 定义在 JDK 的 Object 类中,这就意味着 Java 中的任何类都包含有 hashCode() 函数。另外需要注意的是:Object 的 hashCode() 方法是本地方法,也就是用 C 语言或 C++ 实现的。
该方法在 Oracle OpenJDK8 中默认是 "使用线程局部状态来实现随机数生成", 并不是 "地址" 或者 "地址转换而来", 不同 JDK/VM 可能不同在 Oracle OpenJDK8 中有六种生成方式 (其中第五种是返回地址), 通过添加 VM 参数: -XX:hashCode=4 启用第五种。
对象的hashCode() 方法和equals()方法有什么关联性?
为了在集合中正确使用,一般需要同时重写equals()
和 hashCode()
,如果对象调用equals() 方法结果为true,则要求hashCode() 方法返回值必须相同,如果对象调用hashCode()方法值相同,equals()未必相同,因此 hashCode 是对象相等的必要不充分条件。这是因为在一些容器(比如 HashMap、HashSet)中,有了 hashCode() 之后,判断元素是否在对应容器中的效率会更高。
如果 HashSet 在对比的时候,同样的 hashCode 有多个对象,它会继续使用 equals() 来判断是否真的相同。也就是说 hashCode 帮助我们大大缩小了查找成本。
总结下来就是:
- 如果两个对象的hashCode 值相等,那这两个对象不一定相等(哈希碰撞)。
- 如果两个对象的hashCode 值相等并且equals()方法也返回 true,我们才认为这两个对象相等。
- 如果两个对象的hashCode 值不相等,我们就可以直接认为这两个对象不相等。
为什么要使用clone方法,如何实现对象克隆?
Java语言中克隆针对的是类的实例。实现有两种方式:
- 实现
Cloneable
接口并重写Object类中的clone()
方法;
- 实现clone的类首先需要继承
**Cloneable**
接口。该接口是一个标识接口,没有任何接口方法。 - 在类中重写
**clone()**
方法。 - 在clone方法中调用
super.clone()
,都会直接或间接调用java.lang.Object.clone()
方法。 - 把浅复制的引用指向原型对象新的克隆体。
- 检查类有无非基本类型(即对象)的数据成员。若没有,则返回
super.clone()
即可;若有,确保类中包含的所有非基本类型的成员变量都实现了深复制。
· @Override protected Object clone() throws CloneNotSupportedException {
Object obj = super.clone();
//如果进行深度拷贝,则对每一个对象attr执行以下语句:
o.attr=this.getAttr().clone();
return obj;
}
- 实现Serializable接口,通过对象的序列化和反序列化实现克隆,可以实现真正的深度克隆。基于序列化和反序列化实现的克隆不仅仅是深度克隆,更重要的是通过泛型限定,可以检查出要克隆的对象是否支持序列化,这项检查是编译器完成的,不是在运行时抛出异常,这种是方案明显优于使用Object类的clone方法克隆对象。让问题在编译的时候暴露出来总是好过把问题留到运行时。
如何获取父类的类名?
Java语言提供了获取类名的方法:getClass().getName()
,开发人员可以调用这个方法来获取类名,Java语言中任何类都继承自Object类,getClass()
方法在Object类中被定义为final与native,子类不能覆盖该方法。因此**this.getClass()**
和**super.getClass()**
最终都调用的是**Object.getClass()**
方法。而Object的getClass()
方法的释义是:返回此Object的运行时类。所以需通过Java的反射机制,使用getClass().getSuperclass().getName()
,得到父类的名字。
String
String、StringBuffer、StringBuilder 的区别?
可变性
String 是不可变的,每次操作都会生成新的 String 对象,然后将指针指向新的 String 对象。
StringBuilder 与 StringBuffer 都继承自 AbstractStringBuilder 类,在 AbstractStringBuilder 中也是使用字符数组保存字符串,不过没有使用 final 和 private 关键字修饰,可以在原有对象的基础上进行操作,AbstractStringBuilder 类还提供了很多修改字符串的方法比如 append 方法。
线程安全性
String 中的对象是不可变的,也就可以理解为常量,线程安全。AbstractStringBuilder 是 StringBuilder 与 StringBuffer 的公共父类,定义了一些字符串的基本操作,如 expandCapacity、append、insert、indexOf 等公共方法。StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。
性能
- 操作少量的数据: 适用 String
- 单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder
- 多线程操作字符串缓冲区下操作大量数据: 适用 StringBuffer
字符串拼接的方式有哪些,如何将字符串反转?
- 直接用 + ,底层用
StringBuilder
实现。只适用小数量,如果在循环中使用+
拼接,效率极差,所以要避免在 循环体中使用 + 进行拼接。 - 使用
String.**concat()**
方法,效率稍高于直接使用 +。 - 使用 StringBuilder 或 StringBuffer,两者的
**append**()
方法都继承自 AbstractStringBuilder。StringBuilder 是 JDK5 引入的,效率高但线程不安全。StringBuffer 使用**synchronized**
保证线程安全。
字符串反转:使用 StringBuilder
或者 stringBuffer
的 reverse()
方法。
什么是不可变类?
不可变类(immutable class)是指当创建了这个类的实例后,该实例在其整个生命周期中它的成员变量就不能被修改了。**在Java类库中,所有基本类型的包装类和String也是不可变类。**创建一个不可变类需要遵循下面几条基本原则:
- 类中所有成员变量被private所修饰。
- 类中没有写或者修改成员变量的方法(例如 setter 方法),只提供构造函数,一次生成,永不改变。
- 把类定义为final或者把类中的方法定义为final确保类中所有方法不会被子类覆盖。
- 如果一个类成员不是不可变变量,那么在成员初始化或者使用
get()
方法获取该成员变量时,需要通过clone()
方法来确保类的不可变性。 - 如果有必要,可使用覆盖Object类的
equals()
方法和hashCode()
方法。
由于类的不可变性,在创建对象时就需要初始化所有成员变量,因此最好提供一个带参数的构造函数来初始化这些成员变量。不可变类具有使用简单、线程安全、节省内存等优点。不可变类自然也有其缺点,例如,不可变类的对象会因为值的不同而产生新的对象,所以,切不可滥用这种模式。
String 为什么是不可变的?
我们知道被 final 关键字修饰的类不能被继承,修饰的方法不能被重写,修饰的变量是基本数据类型则值不能改变,修饰的变量是引用类型则不能再指向其他对象。因此,final 关键字修饰的数组保存字符串并不是 String 不可变的根本原因,因为这个数组保存的字符串是可变的。
String 真正不可变有下面几点原因:
- 保存字符串的数组被 final 修饰且为私有的,并且String 类没有提供/暴露修改这个字符串的方法。
- String 类被 final 修饰导致其不能被继承,进而避免了子类破坏 String 不可变。
Java 9 为何要将 String 的底层实现由 char[] 改成了 byte[] ?
在 Java 9 之后,String、StringBuilder 与 StringBuffer 的实现改用 byte 数组存储字符串。
新版的 String 其实支持两个编码方案:Latin-1 和 UTF-16。如果字符串中包含的汉字没有超过 Latin-1 可表示范围内的字符,那就会使用 Latin-1 作为编码方案。Latin-1 编码方案下,byte 占一个字节(8 位),char 占用 2 个字节(16),byte 相较 char 节省一半的内存空间。
JDK 官方就说了绝大部分字符串对象只包含 Latin-1 可表示的字符。
如果字符串中包含的汉字超过 Latin-1 可表示范围内的字符,byte 和 char 所占用的空间是一样的
字符串拼接用“+” 还是 StringBuilder?
Java 语言本身并不支持运算符重载,“+”和“+=”是专门为 String 类重载过的运算符,也是 Java 中仅有的两个重载过的运算符。
字符串对象通过“+”的字符串拼接方式,实际上是通过 **StringBuilder**
调用 append()
方法实现的,拼接完成之后调用 toString()
得到一个 String 对象 。
不过,在循环内使用“+”进行字符串的拼接的话,存在比较明显的缺陷:编译器不会创建单个 StringBuilder 以复用,会导致创建过多的 StringBuilder 对象。StringBuilder 对象是在循环内部被创建的,这意味着每循环一次就会创建一个 StringBuilder 对象。如果直接使用 StringBuilder 对象进行字符串拼接的话,就不会存在这个问题了。
不过,在 JDK9 当中,字符串相加 “+” 改为了用动态方法 makeConcatWithConstants() 来实现,而不是大量的 StringBuilder 了。这也意味着 JDK 9 之后,你可以放心使用“+” 进行字符串拼接了。
字符串常量池的作用了解吗?
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
String s1 = new String("abc");这句话创建了几个字符串对象?
会创建 1 或 2 个字符串对象。
1、如果字符串常量池中不存在字符串对象“abc”的引用,那么它将首先在字符串常量池中创建,然后在堆空间中创建,因此将创建总共 2 个字符串对象。
示例代码(JDK 1.8):
String s1 = new String("abc");
对应的字节码:
ldc 命令用于判断字符串常量池中是否保存了对应的字符串对象的引用,如果保存了的话直接返回,如果没有保存的话,会在堆中创建对应的字符串对象并将该字符串对象的引用保存到字符串常量池中。
2、如果字符串常量池中已存在字符串对象“abc”的引用,则只会在堆中创建 1 个字符串对象“abc”。
示例代码(JDK 1.8):
// 字符串常量池中已存在字符串对象“abc”的引用
String s1 = "abc";
// 下面这段代码只会在堆中创建 1 个字符串对象“abc”
String s2 = new String("abc");
对应的字节码:
这里就不对上面的字节码进行详细注释了,7 这个位置的 ldc 命令不会在堆中创建新的字符串对象“abc”,这是因为 0 这个位置已经执行了一次 ldc 命令,已经在堆中创建过一次字符串对象“abc”了。7 这个位置执行 ldc 命令会直接返回字符串常量池中字符串对象“abc”对应的引用。
String 类型的变量和常量做“+”运算时发生了什么?
字符串不加 final 关键字拼接的情况:
对于编译期可以确定值的字符串,也就是常量字符串 ,jvm 会将其存入字符串常量池。并且,字符串常量拼接得到的字符串常量在编译阶段就已经被存放字符串常量池,这个得益于编译器的优化。
在编译过程中,Javac 编译器(下文中统称为编译器)会进行一个叫做 常量折叠(Constant Folding) 的代码优化。常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中,这是 Javac 编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)。
对于 String str3 = "str" + "ing"; 编译器会给你优化成 String str3 = "string"; 。
并不是所有的常量都会进行折叠,只有编译器在程序编译期就可以确定值的常量才可以:
- 基本数据类型( byte、boolean、short、char、int、float、long、double)以及字符串常量。
- final 修饰的基本数据类型和字符串变量
- 字符串通过 “+”拼接得到的字符串、基本数据类型之间算数运算(加减乘除)、基本数据类型的位运算(<<、>>、>>> )
引用的值在程序编译期是无法确定的,编译器无法对其进行优化。
对象引用和“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。
在平时写代码的时候,尽量避免多个字符串对象拼接,因为这样会重新创建对象。如果需要改变字符串的话,可以使用 StringBuilder 或者 StringBuffer。
不过,字符串使用 final 关键字声明之后,可以让编译器当做常量来处理。
被 final 关键字修饰之后的 String 会被编译器当做常量来处理,编译器在程序编译期就可以确定它的值,其效果就相当于访问常量。
如果 ,编译器在运行时才能知道其确切值的话,就无法对其优化。
String a = "a" + new String("b") 创建了几个对象?
常量和常量拼接仍是常量,结果在常量池,只要有变量参与拼接结果就是变量,存在堆(对象)。使用字面量时只创建一个常量池中的常量,使用 new 时如果常量池中没有该值就会在常量池中新创建,再在堆中创建一个对象引用常量池中常量。因此 String a = "a" + new String("b")
会创建四个对象,常量池中的 a 和 b,堆中的 b 和堆中的 ab。
String#intern 方法有什么作用?
String.intern() 是一个 native(本地)方法,其作用是将指定的字符串对象的引用保存在字符串常量池中,可以简单分为两种情况:
- 如果字符串常量池中保存了对应的字符串对象的引用,就直接返回该引用。
- 如果字符串常量池中没有保存了对应的字符串对象的引用,那就在常量池中创建一个指向该字符串对象的引用并返回。
String 类的常用方法都有那些?
indexOf()
:返回指定字符的索引。charAt()
:返回指定索引处的字符。replace()
:字符串替换。trim()
:去除字符串两端空白。split()
:分割字符串,返回一个分割后的字符串数组。getBytes()
:返回字符串的 byte 类型数组。length()
:返回字符串长度。toLowerCase()
:将字符串转成小写字母。toUpperCase()
:将字符串转成大写字符。substring()
:截取字符串。equals()
:字符串比较。
一些敏感的数据(例如密码),为什么使用字符数组存储比使用String更安全?
在Java语言中,String是不可变类,它被存储在常量字符串池中,从而实现了字符串的共享,减少了内存的开支。正因为如此,一旦一个String类型的字符串被创建出来这个字符串就会存在于常量池中直到被垃圾回收器回收为止。因此,即使这个字符串(比如密码)不再被使用,它仍然会在内存中存在一段时间(只有垃圾回收器才会回收这块内容,程序员没有办法直接回收字符串)。此时有权限访问memory dump(存储器转储)的程序都可能会访问到这个字符串,从而把敏感的数据暴露出去,这是一个非常大的安全隐患。如果使用字符数组,一旦程序不再使用这个数据,程序员就可以把字符数组的内容设置为空,此时这个数据在内存中就不存在了。从以上分析可以看出,与使用String相比,使用字符数组,程序员对数据的生命周期有更好的控制,从而可以增强安全性。
字符型char常量和字符串String常量的区别?
- 形式上:字符常量是单引号引起的一个字符; 字符串常量是双引号引起的若干个字符
- 含义上:字符常量相当于一个整型值( ASCII 值),可以参加表达式运算; 字符串常量代表一个地址值(该字符串在内存中存放位置)
- 占内存大小:字符常量只占 2 个字节; 字符串常量占若干个字节,至少包括一个字符串结束符
异常
介绍一下Java中的异常体系?
所有异常都是 Throwable 的子类,分为 Error 和 Exception。
**Error 是 Java 运行时系统的内部错误或资源耗尽错误,这种异常程序无法处理,并且该错误是不可恢复的,没办法通过 catch 来进行捕获。**这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。 例如,Java 虚拟机运行错误(Virtual MachineError)、虚拟机内存不够错误(OutOfMemoryError)、虚拟机栈溢出(StackOverFlowError)、类定义错误(NoClassDefFoundError)等 。
Exception 表示可恢复的异常,是编译器可以通过 catch 来进行捕获。Exception 又可以分为受检异常和非受检异常,受检异常需要在代码中显式处理或在方法声明中抛出,否则会编译出错,非受检异常是运行时异常,继承自 RuntimeException。
- 受检异常:所有继承自Exception并且不是运行时异常的异常都是检查异常。Java编译器强制程序去捕获此类型的异常,即把可能会出现这些异常的代码放到try块中,把对异常的处理的代码放到catch块中。常见受检异常有 FileNotFoundException、ClassNotFoundException、IOException,SQLException等。
- 非受检异常:运行时异常编译器没有强制对其进行捕获并处理。如果不对这种异常进行处理,系统会把异常一直往上层抛出,直到遇到处理代码为止。若没有处理块,则抛到最上层;如果是多线程就用
Thread.run()
方法抛出,如果是单线程,就用main()
方法抛出。抛出之后,如果是线程,那么这个线程也就退出了。如果是主程序抛出的异常,那么整个程序也就退出了。所以,如果不对运行时的异常进行处理,一旦发生,要么是线程中止,要么是主程序终止。常见的运行时异常包括NullPointerException(空指针异常)、ClassCastException(类型转换异常)、ArrayIndexOutOfBoundsException(数组越界异常)、ArrayStoreException(数组存储异常)、BufferOverflowException(缓冲区溢出异常)、ArithmeticException(算术异常)等。
在使用异常处理时,还需要注意以下几个问题:
- Java异常处理用到了多态的概念,如果在异常处理过程中,先捕获了基类,然后再捕获子类,那么捕获子类的代码块将永远不会被执行。因此,在进行异常捕获时,正确的写法是:先捕获子类,再捕获基类的异常信息。
- 尽早抛出异常,同时对捕获的异常进行处理。或者从错误中恢复,或者让程序继续执行。
- 对捕获的异常不进行任何处理是一个非常不好的习惯,非常不利于调试。但也不是抛出异常越多越好,对于有些异常类型,例如运行时异常,实际上根本不必处理。
- 可以根据实际的需求自定义异常类。
- 异常能处理就处理,不能处理就抛出。对于最终没有处理的异常,JVM会进行处理。
Throwable 类常用方法?
**public string getMessage()**
:返回异常发生时的简要描述。**public string toString()**
:返回异常发生时的详细信息。**public string getLocalizedMessage()**
:返回异常对象的本地化信息。使用 Throwable 的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与getMessage()
返回的结果相同。**public void printStackTrace()**
:在控制台上打印 Throwable 对象封装的异常信息。
try-catch-finally 如何使用?
Java编译器只允许如下三种组合方式
try{}catch()
。try{}finally{}
。try{}catch()finally{}
。
- try块:用于捕获异常。其后可接零个或多个 catch 块,如果没有 catch 块,则必须跟一个 finally 块。
- catch块:用于处理 try 捕获到的异常。
- finally 块:无论是否捕获或处理异常,finally 块里的语句都会被执行。当在 try 块或 catch 块中遇到 return 语句时,finally 语句块将在方法返回之前被执行。
可以省略catch 或者 finally,catch和finally不可以同时省略。try 只适合处理运行时异常,try + catch 适合处理运行时异常和受检异常。如果只用 try 去处理受检异常却不加以catch处理,编译是通不过的,因为编译器硬性规定,受检异常如果选择捕获,则必须用catch显示声明以便进一步处理。而运行时异常在编译时没有如此规定,所以catch可以省略。
不要在 finally 语句块中使用 return! 当 try 语句和 finally 语句中都有 return 语句时,try 语句块中的 return 语句会被忽略。这是因为 try 语句中的 return 返回值会先被暂存在一个本地变量中,
try-catch-finally 中,如果catch中return了,finally 还会执行吗?
会,通常情况无论是否捕获或处理异常,finally 块里的语句都会被执行。当在 try 块或 catch 块中遇到 return 语句时,finally 语句块将在方法返回之前被执行。如果try-finally或者catch-finally中都有return,那么finally块中的return语句将会覆盖别处的return语句,最终返回到调用者那里的是finally中return的值。但是在以下 3 种特殊情况下,finally 块不会被执行:
- 在 try 或 finally块中用了
System.exit(int)
退出程序。 - JVM 退出时守护线程中的 finally 块不一定执行
- 程序所在的线程死亡。
- 关闭 CPU。
try-with-resources 特点?
- 适用范围: 任何实现
java.lang.AutoCloseable
或者java.io.Closeable
的对象 - 关闭资源和 finally 块的执行顺序:在
try-with-resources
语句中,任何 catch 或 finally 块在声明的资源关闭后运行。 - 当然多个资源需要关闭的时候,通过使用分号分隔,可以在try-with-resources块中声明多个资源。
- 异常抑制,当对外部资源进行处理(例如读或写)时,如果遭遇了异常,且在随后的关闭外部资源过程中,又遭遇了异常,那么catch到的将会是对外部资源进行处理时遭遇的异常,关闭资源时遭遇的异常将被“抑制”但不是丢弃,通过异常的getSuppressed方法,可以提取出被抑制的异常。
异常使用有哪些需要注意的地方?
- 不要把异常定义为静态变量,因为这样会导致异常栈信息错乱。每次手动抛出异常,我们都需要手动 new 一个异常对象抛出。
- 抛出的异常信息一定要有意义。
- 建议抛出更加具体的异常比如字符串转换为数字格式错误的时候应该抛出NumberFormatException而不是其父类IllegalArgumentException。
- 使用日志打印异常之后就不要再抛出异常了(两者不要同时存在一段代码逻辑中)。
泛型
什么是泛型,有什么作用?
Java 泛型(Generics) 是 JDK 5 中引入的一个新特性。使用泛型参数,可以增强代码的可读性以及稳定性。编译器可以对泛型参数进行检测,并且通过泛型参数可以指定传入的对象类型,如果传入其他类型的对象就会报错。泛型本质是参数化类型,解决不确定对象具体类型的问题。泛型在定义处只具备执行 Object 方法的能力。
泛型的作用:
- 类型安全,放置什么出来就是什么,不存在
ClassCastException
。 - 提升可读性,编码阶段就显式知道泛型集合、泛型方法等处理的对象类型。
- 代码重用,合并了同类型的处理代码。
泛型的使用方式有哪几种?
泛型一般有三种使用方式:泛型类、泛型接口、泛型方法。
在实例化泛型类时,必须指定T的具体类型。
实现泛型接口,即可不指定类型,指定类型: 静态泛型方法,在 java 中泛型只是一个占位符,必须在传递类型后才能使用。类在实例化时才能真正的传递类型参数,由于静态方法的加载先于类的实例化,也就是说类中的泛型还没有传递真正的类型参数,静态的方法的加载就已经完成了,所以静态泛型方法是没有办法使用类上声明的泛型的。只能使用自己声明的。
泛型中extends和super的区别?
<? extends T>
:是指:上界通配符(Upper Bounds Wildcards),对象都至少是 T 的子类;
<? super T>
:是指:下界通配符(Lower Bounds Wildcards),对象都至少是 T 的父类
PECS(Producer extends,Consumer super)原则:
- 往外读取内容(get(),容器为 Producer)的,适合用上界extends。
- 向容器插入(add(),容器为 Consumer )的,适合用下界 super。
注意:即是生产者,也是消费者,不能使用泛型通配符声明列表。java.util.Collections
里的copy方法(JDK1.7)用到了PECS原则,实现了对参数的保护。
泛型擦除是什么?
泛型信息(类型变量、参数化类型)用于编译阶段,编译后的字节码文件不包含泛型类型信息,虚拟机没有泛型类型对象,所有对象都属于普通类。例如定义 List<Object>
或 List<String>
,在编译后都会变成 List
,运行时的 class 是没有泛型约束的,所以在运行时可以通过反射将任何对象对象加入**List**
中。
注意:在JDK1.5中大幅度增强了Java语言的语法,在此之后,任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量(Type Variables)或参数化类型(Parameterized Types),则Singature属性会为它记录泛型签名信息。Java可以通过反射获得泛型类型,最终的数据来源也就是这个属性。
反射
何谓反射?
反射主要是指程序可以访问、检测和修改它本身状态或行为的一种能力。Java反射机制主要提供了以下功能:
- 在运行时判断任意一个对象所属的类
- 在运行时构造任意一个类的对象。
- 在运行时判断任意一个类所具有的成员变量和方法。
- 在运行时调用任意一个对象的方法。
反射破坏了封装性以及泛型约束,它赋予了我们在运行时分析类以及执行类中方法的能力,反射是框架的核心,Spring 大量使用反射。
反射的优缺点?
反射可以让我们的代码更加灵活、为各种框架提供开箱即用的功能提供了便利。
不过,反射让我们在运行时有了分析操作类的能力的同时,也增加了安全问题,比如可以无视泛型参数的安全检查(泛型参数的安全检查发生在编译时)。另外,反射的性能也要稍差点,不过,对于框架来说实际是影响不大的。
反射的应用场景?
通过反射,才能这么轻松地使用各种框架。像 Spring/Spring Boot、MyBatis 等等框架中都大量使用了反射机制。这些框架中也大量使用了动态代理,而动态代理的实现也依赖反射。
比如下面是通过 JDK 实现动态代理,其中就使用了反射类 Method 来调用指定的方法。
另外,Java 中的一大利器 注解 的实现也用到了反射。因为可以基于反射分析类,然后获取到类/属性/方法/方法的参数上的注解。你获取到注解之后,就可以做进一步的处理。
什么是代理模式?
代理模式是一种设计模式。简单来说就是 我们使用代理对象来代替对真实对象(real object)的访问,这样就可以在不修改原目标对象的前提下,提供额外的功能操作,扩展目标对象的功能。
代理模式的主要作用是扩展目标对象的功能,比如说在目标对象的某个方法执行前后你可以增加一些自定义的操作。代理模式有静态代理和动态代理两种实现方式。
什么是静态代理?
静态代理中,我们对目标对象的每个方法的增强都是手动完成的,非常不灵活(比如接口一旦新增加方法,目标对象和代理对象都要进行修改)且麻烦(需要对每个目标类都单独写一个代理类)。 从 JVM 层面来说, 静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。
静态代理实现步骤:
定义一个接口及其实现类;
创建一个代理类同样实现这个接口
将目标对象注入进代理类,然后在代理类的对应方法调用目标类中的对应方法。这样的话,我们就可以通过代理类屏蔽对目标对象的访问,并且可以在目标方法执行前后做一些自己想做的事情。
什么是动态代理?
相比于静态代理来说,动态代理更加灵活。我们不需要针对每个目标类都单独创建一个代理类,并且也不需要我们必须实现接口,我们可以直接代理实现类( CGLIB 动态代理机制)。
从 JVM 角度来说,动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。
Spring AOP、RPC 框架应该是两个不得不提的,它们的实现都依赖了动态代理。
就 Java 来说,动态代理的实现方式有很多种,比如 JDK 动态代理、CGLIB 动态代理等等。
静态代理和动态代理的有什么区别?
- 灵活性:动态代理更加灵活,不需要必须实现接口,可以直接代理实现类,并且可以不需要针对每个目标类都创建一个代理类。另外,静态代理中,接口一旦新增加方法,目标对象和代理对象都要进行修改。
- JVM 层面:静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。而动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。
怎么实现JDK动态代理?
- 创建一个实现接口
InvocationHandler
的类,实现 invoke 方法
public interface InvocationHandler {
public Object invoke(Object proxy,Method method,
Object[] args)throws Throwable;
}
invoke() 方法有下面三个参数:proxy :动态生成的代理类;method : 与代理类对象调用的方法相对应;args : 当前 method 方法的参数
- 创建一个被代理的类和接口
- 通过Proxy的静态方法
newInstance()
创建代理类
public static Object newProxyInstance(
ClassLoader loader,Class<?>[] interfaces,InvocationHandler h)
这个方法一共有 3 个参数:loader :类加载器,用于加载代理对象。 interfaces : 被代理类实现的一些接口;h : 实现了 InvocationHandler 接口的对象;
- 通过代理类调用具体方法。通过Proxy 类的 newProxyInstance() 创建的代理对象在调用方法的时候,实际会调用到实现InvocationHandler 接口的类的 invoke()方法。 你可以在 invoke() 方法中自定义处理逻辑,比如在方法执行前后做什么事情。
JDK 动态代理的原理是什么?
原理:JDK底层生成了一个叫$Proxy0(这个名字后面的0是编号,有多个代理类会一次递增)的代理类,这个类文件时放在内存中的,在创建代理对象时,就是通过反射获得这个类的构造方法,然后创建的代理实例。InvocationHandler看做一个中介类,中介类持有一个被代理对象,在invoke方法中调用了被代理对象的相应方法。通过聚合方式持有被代理对象的引用,把外部对invoke的调用最终都转为对被代理对象的调用。
代理类调用自己方法时,通过自身持有的中介类对象来调用中介类对象的invoke方法,从而达到代理执行被代理对象的方法。也就是说,动态代理通过中介类实现了具体的代理功能。
动态生成的代理类有如下特性:
- JDK的动态代理不支持对实现类的代理,只支持接口的代理。
- 提供了一个使用
InvocationHandler
作为参数的构造方法。 - 生成静态代码块来初始化接口中方法的Method对象,以及Object类的equals、hashCode、toString方法。
- 重写了Object类的equals、hashCode、toString,它们都只是简单的调用了InvocationHandler的invoke方法,即可以对其进行特殊的操作,也就是说JDK的动态代理还可以代理上述三个方法。
- 代理类实现代理接口的具体方法中,只是简单的调用了InvocationHandler的invoke方法,我们可以在invoke方法中进行一些特殊操作,甚至不调用实现的方法,直接返回。
CGLIB 动态代理机制是什么?
JDK 动态代理有一个最致命的问题是其只能代理实现了接口的类,为了解决这个问题,我们可以用 CGLIB 动态代理机制来避免。
CGLIB(Code Generation Library)是一个基于ASM的字节码生成库,它允许我们在运行时对字节码进行修改和动态生成。
Spring 中的 AOP 模块中:如果目标对象实现了接口,则默认采用 JDK 动态代理,否则采用 CGLIB 动态代理。
在 CGLIB 动态代理机制中 MethodInterceptor
接口和 Enhancer
类是核心。
需要自定义 MethodInterceptor
并重写 intercept
方法,intercept
用于拦截增强被代理类的方法。
public interface MethodInterceptor extends Callback{
// 拦截被代理类中的方法
public Object intercept(Object obj,
java.lang.reflect.Method method, Object[] args,
MethodProxy proxy) throws Throwable;
}
obj : 被代理的对象(需要增强的对象)
method : 被拦截的方法(需要增强的方法)
args : 方法入参
proxy : 用于调用原始方法
你可以通过 Enhancer类来动态获取被代理类,当代理类调用方法的时候,实际调用的是 MethodInterceptor 中的 intercept 方法。
CGLIB 动态代理类使用步骤
定义一个类;
自定义 MethodInterceptor 并重写 intercept 方法,intercept 用于拦截增强被代理类的方法,和 JDK 动态代理中的 invoke 方法类似;
通过 Enhancer 类的 create()创建代理类;
JDK 动态代理和 CGLIB 动态代理有什么区别?
- JDK 动态代理只能代理实现了接口的类或者直接代理接口,而 CGLIB 可以代理未实现任何接口的类。
- CGLIB 动态代理是通过生成一个被代理类的子类来拦截被代理类的方法调用,因此不能代理声明为 final 类型的类和方法。
- 就二者的效率来说,大部分情况都是 JDK 动态代理更优秀,随着 JDK 版本的升级,这个优势更加明显。
注解
何谓注解?
Annotation (注解) 是 Java5 开始引入的新特性,可以看作是一种特殊的注释,主要用于修饰类、方法或者变量,提供某些信息供程序在编译或者运行时使用,帮助编译器和 JVM 完成一些特定功能,例如:
@Override,表示当前的方法定义将覆盖超类中的方法。如果你不小心拼写错误,或者方法签名对不上被覆盖的方法,编译器就会发出错误提示。
@Deprecated,如果程序员使用了注解为它的元素,那么编译器会发出警告信息。
@SuppressWarnings,关闭不当的编译器警告信息。在java SE5之前的版本中,也可以使用该注解,不过会被忽略不起作用。
注解本质是一个继承了Annotation 的特殊接口:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}
public interface Override extends Annotation{
}
什么是元注解?
元注解(meta-annotation)是自定义注解的注解,可以使用元注解来对我们自定义的注解类型进行注解:
- @Target:约束注解的使用范围(即:被修饰的注解可以用在什么地方) 。值是 ElementType 枚举常量,包括 :
TYPE: Class, interface (including annotation type), or enum declaration
FIELD: Field declaration (includes enum constants)
METHOD: Method
PARAMETER: Formal parameter
CONSTRUCTOR: Constructor
LOCAL_VARIABLE: Local variable
ANNOTATION_TYPE: Annotation type
PACKAGE: Package
TYPE_PARAMETER: Type parameter, since 1.8
TYPE_USE: Use of a type
MODULE: Module declaration. since 9
- @Retention:约束注解保留的时间范围,值是 RetentionPolicy 枚举常量,包括:
ROURCE 源码;CLASS 字节码;RUNTIME 运行时
- @Documented:在使用 Javadoc 工具为类生成帮助文档时是否要保留其注解信息。
- @Inherited:使被它修饰的注解具有继承性(如果某个类使用了被@Inherited修饰的注解,则其子类将自动具有该注解)。
注解的解析方法有哪几种?
注解只有被解析之后才会生效,常见的解析方法有两种:
- 编译期直接扫描:编译器在编译 Java 代码的时候扫描对应的注解并处理,比如某个方法使用@Override 注解,编译器在编译的时候就会检测当前的方法是否重写了父类对应的方法。
- 运行期通过反射处理:像框架中自带的注解(比如 Spring 框架的 @Value、@Component)都是通过反射来进行处理的。
SPI
何谓 SPI?
SPI 即 Service Provider Interface ,字面意思就是:“服务提供者的接口”,我的理解是:专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。
SPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。
很多框架都使用了 Java 的 SPI 机制,比如:Spring 框架、数据库加载驱动、日志接口、以及 Dubbo 的扩展实现等等。
SPI 和 API 有什么区别?
一般模块之间都是通过接口进行通讯,那我们在服务调用方和服务实现方(也称服务提供者)之间引入一个“接口”。当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是 API ,这种接口和实现都是放在实现方的。
当接口存在于调用方这边时,就是 SPI ,由接口调用方确定接口规则,然后由不同的厂商去根据这个规则对这个接口进行实现,从而提供服务。
SPI 的优缺点?
通过 SPI 机制能够大大地提高接口设计的灵活性,但是 SPI 机制也存在一些缺点,比如:
- 需要遍历加载所有的实现类,不能做到按需加载,这样效率还是相对较低的。
- 当多个 ServiceLoader 同时 load 时,会有并发问题。
序列化和反序列化
什么是反序列化?
反序列化是将流转换为对象的过程。反序列化的过程中,每个类都有一个特定的serialVersionUID
,在反序列化的过程中,通过serialVersionUID
来判定类的兼容性。如果待序列化的对象与目标对象的serialVersionUID不同,那么在反序列化时就会抛出InvalidClassException
异常。作为一个好的编程习惯,最好在被序列化的类中显式地声明serialVersionUID
(必须定义为static final)。自定义serialVersionUID
主要有如下3个优点:
- **提高程序的运行效率。**如果在类中未显式声明
serialVersionUID
,那么在序列化时会通过计算得到一个serialVersionUID值。通过显式声明省去了计算的过程,因此提高了程序的运行效率。 - **提高程序不同平台上的兼容性。**由于各个平台的编译器在计算
serialVersionUID
时完全有可能会采用不同的计算方式,这就会导致在一个平台上序列化的对象在另外一个平台上将无法实现反序列化的操作。通过显式声明serialVersionUID
的方法完全可以避免该问题的发生。 - **增强程序各个版本的可兼容性。**在默认情况下,每个类都有唯一的
serialVersionUID
,因此,当后期对类进行修改时(例如加入新的属性),类的serialVersionUID
值将会发生变化,这将会导致类在修改前对象序列化的文件在修改后将无法进行反序列化操作。同样,通过显式声明则会解决这个问题。
什么是序列化和反序列化?
如果我们需要持久化 Java 对象比如将 Java 对象保存在文件中,或者在网络传输 Java 对象,这些场景都需要用到序列化。
- 序列化:将数据结构或对象转换成二进制字节流的过程。
序列化有以下两个特点:如果一个类能被序列化,那么它的子类也能够被序列化
由于static(静态)代表类的成员,transient
(如果用transient声明一个实例变量,当对象存储时,它的值不需要维持。)代表对象的临时数据,因此被声明为这两种类型的数据成员是不能够被序列化的。
- 反序列化:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程。
由于序列化的使用会影响系统的性能,因此如果不是必须要使用序列化,应尽可能不要使用序列化。下面是序列化和反序列化常见应用场景:
- 对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化;
- 将对象存储到文件之前需要进行序列化,将对象从文件中读取出来需要进行反序列化;
- 将对象存储到数据库(如 Redis)之前需要用到序列化,将对象从缓存数据库中读取出来需要反序列化;
- 将对象存储到内存之前需要进行序列化,从内存中读取出来之后需要进行反序列化。
序列化协议对应于 TCP/IP 4 层模型的哪一层?
我们知道网络通信的双方必须要采用和遵守相同的协议。TCP/IP 四层模型:
- 应用层
- 传输层
- 网络层
- 网络接口层
OSI 七层协议模型中,表示层做的事情主要就是对应用层的用户数据进行处理转换为二进制流。就是将二进制流转换成应用层的用户数据。就对应的是序列化和反序列化。因为,OSI 七层协议模型中的应用层、表示层和会话层对应的都是 TCP/IP 四层模型中的应用层,所以序列化协议属于 TCP/IP 协议应用层的一部分。
如果有些字段不想进行序列化怎么办?
对于不想进行序列化的变量,使用 transient 关键字修饰。
transient 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient 修饰的变量值不会被持久化和恢复。
关于 transient 还有几点注意:
- transient 只能修饰变量,不能修饰类和方法。
- transient 修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰 int 类型,那么反序列后结果就是 0。
- static 变量因为不属于任何对象(Object),所以无论有没有 transient 关键字修饰,均不会被序列化。
常见序列化协议有哪些?
JDK 自带的序列化方式一般不会用 ,因为序列化效率低并且存在安全问题。比较常用的序列化协议有 Hessian、Kryo、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议。像 JSON 和 XML 这种属于文本类序列化方式。虽然可读性比较好,但是性能较差,一般不会选择。
JDK 自带的序列化方式
JDK 自带的序列化,只需实现 java.io.Serializable接口即可。
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@ToString
public class RpcRequest implements Serializable {
private static final long serialVersionUID = 1905122041950251207L;
private String requestId;
private String interfaceName;
private String methodName;
private Object[] parameters;
private Class<?>[] paramTypes;
private RpcMessageTypeEnum rpcMessageTypeEnum;
}
Kryo
Kryo 是一个高性能的序列化/反序列化工具,由于其变长存储特性并使用了字节码生成机制,拥有较高的运行速度和较小的字节码体积。
另外,Kryo 已经是一种非常成熟的序列化实现了,已经在 Twitter、Groupon、Yahoo 以及多个著名开源项目(如 Hive、Storm)中广泛的使用。
guide-rpc-framework 就是使用的 kryo 进行序列化。
Protobuf
Protobuf 出自于 Google,性能还比较优秀,也支持多种语言,同时还是跨平台的。就是在使用中过于繁琐,因为你需要自己定义 IDL 文件和生成对应的序列化代码。这样虽然不灵活,但是,另一方面导致 protobuf 没有序列化漏洞的风险。
Protobuf 包含序列化格式的定义、各种语言的库以及一个 IDL 编译器。正常情况下你需要定义 proto 文件,然后使用 IDL 编译器编译成需要的语言,一个简单的 proto 文件如下:
// protobuf的版本
syntax = "proto3";
// SearchRequest会被编译成不同的编程语言的相应对象,比如Java中的class、Go中的struct
message Person {
//string类型字段
string name = 1;
// int 类型字段
int32 age = 2;
}
ProtoStuff
由于 Protobuf 的易用性,它的哥哥 Protostuff 诞生了。
protostuff 基于 Google protobuf,但是提供了更多的功能和更简易的用法。虽然更加易用,但是不代表 ProtoStuff 性能更差。
Hessian
Hessian 是一个轻量级的,自定义描述的二进制 RPC 协议。Hessian 是一个比较老的序列化实现了,并且同样也是跨语言的。Dubbo2.x 默认启用的序列化方式是 Hessian2 ,但是,Dubbo 对 Hessian2 进行了修改,不过大体结构还是差不多。
总结
Kryo 是专门针对 Java 语言序列化方式并且性能非常好,如果你的应用是专门针对 Java 语言的话可以考虑使用,并且 Dubbo 官网的一篇文章中提到说推荐使用 Kryo 作为生产环境的序列化方式。像 Protobuf、 ProtoStuff、hessian 这类都是跨语言的序列化方式,如果有跨语言需求的话可以考虑使用。除了我上面介绍到的序列化方式的话,还有像 Thrift,Avro 这些。
serialVersionUID 有什么作用?
序列化号 serialVersionUID 属于版本控制的作用。反序列化时,会检查 serialVersionUID 是否和当前类的 serialVersionUID 一致。如果 serialVersionUID 不一致则会抛出 InvalidClassException
异常。强烈推荐每个序列化类都手动指定其 serialVersionUID,如果不手动指定,那么编译器会动态生成默认的 serialVersionUID。
serialVersionUID 不是被 static 变量修饰,为什么还会被“序列化”?
static 修饰的变量是静态变量,位于方法区,本身是不会被序列化的。但是,serialVersionUID 的序列化做了特殊处理,在序列化时,会将 serialVersionUID 序列化到二进制字节流中;在反序列化时,也会解析它并做一致性判断。
如果想显式指定 serialVersionUID ,则需要在类中使用 static 和 final 关键字来修饰一个 long 类型的变量,变量名字必须为 "serialVersionUID" 。也就是说,serialVersionUID 只是用来被 JVM 识别,实际并没有被序列化。
为什么不推荐使用 JDK 自带的序列化?
我们很少或者说几乎不会直接使用 JDK 自带的序列化方式,主要原因有下面这些原因:
- 不支持跨语言调用 : 如果调用的是其他语言开发的服务的时候就不支持了。
- 性能差:相比于其他序列化框架性能更低,主要原因是序列化之后的字节数组体积较大,导致传输成本加大。
- 存在安全问题:序列化和反序列化本身并不存在问题。但当输入的反序列化的数据可被用户控制,那么攻击者即可通过构造恶意输入,让反序列化产生非预期的对象,在此过程中执行构造的任意代码。
List
ArrayList 和 Array(数组)的区别?
ArrayList 内部基于动态数组实现,比 Array(静态数组) 使用起来更加灵活:
- ArrayList会根据实际存储的元素动态地扩容或缩容,而 Array 被创建之后就不能改变它的长度了。
- ArrayList 允许你使用泛型来确保类型安全,Array 则不可以。
- ArrayList 中只能存储对象。对于基本类型数据,需要使用其对应的包装类(如 Integer、Double 等)。Array 可以直接存储基本类型数据,也可以存储对象。
- ArrayList 支持插入、删除、遍历等常见操作,并且提供了丰富的 API 操作方法,比如 add()、remove()等。Array 只是一个固定长度的数组,只能按照下标访问其中的元素,不具备动态添加、删除元素的能力。
- ArrayList创建时不需要指定大小,而Array创建时必须指定大小。
- List转换成为数组:调用
ArrayList.toArray()
方法。 - 数组转换成为List:调用
Arrays.asList()
方法。
说一说 ArrayList?
- ArrayList 是容量可变的非线程安全列表,使用数组实现,集合扩容时会创建更大的数组,把原有数组复制到新数组。
- 支持对元素的快速随机访问,插入与删除速度很慢。实现了 RandomAcess 标记接口,如果一个类实现了该接口,那么表示使用索引遍历比迭代器更快
- fail-fast类型容器。
- 扩容调用
Arrays.copyOf()
进行复制扩容后int newCapacity = oldCapacity + (oldCapacity >> 1)
,所以每次扩容之后容量都会变为原来的 1.5 倍左右,奇偶不同, transient Object[] elementData;
是 ArrayList 的数据域,被 transient 修饰,序列化时会调用writeObject()
写入流,反序列化时调用readObject()
重新赋值到新对象的elementData。elementData 容量通常大于实际存储元素的数量,所以只需发送真正有实际值的数组元素。- size 是当前实际大小,elementData 大小大于等于 size。
ArrayList 和 Vector 的区别是什么?
ArrayList和Vector都是基于Object[]array
来实现的,它们会在内存中开辟一块连续的空间来存储。
- Vector是同步的,而ArrayList不是。
- ArrayList比Vector快,它因为它是非同步。
- ArrayList更加通用,因为我们可以使用Collections工具类轻易地获取同步列表和只读列表。
- Vector默认扩充为原来的2倍(每次扩充空间的大小是可以设置的),而ArrayList默认扩充为原来的1.5倍左右。
Vector 和 Stack 的区别?
- Vector 和 Stack 两者都是线程安全的,都是使用 synchronized 关键字进行同步处理。
- Stack 继承自 Vector,是一个后进先出的栈,而 Vector 是一个列表。
随着 Java 并发编程的发展,Vector 和 Stack 已经被淘汰,推荐使用并发集合类(例如 ConcurrentHashMap、CopyOnWriteArrayList 等)或者手动实现线程安全的方法来提供安全的多线程操作支持。
ArrayList 可以添加 null 值吗?
ArrayList 中可以存储任何类型的对象,包括 null 值。不过,不建议向ArrayList 中添加 null 值, null 值无意义,会让代码难以维护比如忘记做判空处理就会导致空指针异常。
遍历ArrayList时如何正确移除一个元素?
public static void remove(ArrayList<String> list) {
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String s = it.next();
if (s.equals("bb")) {
it.remove();
}
}
}
ArrayList 插入和删除元素的时间复杂度?
对于插入:
- 头部插入:由于需要将所有元素都依次向后移动一个位置,因此时间复杂度是 O(n)。
- 尾部插入:当 ArrayList 的容量未达到极限时,往列表末尾插入元素的时间复杂度是 O(1),因为它只需要在数组末尾添加一个元素即可;当容量已达到极限并且需要扩容时,则需要执行一次 O(n) 的操作将原数组复制到新的更大的数组中,然后再执行 O(1) 的操作添加元素。
- 指定位置插入:需要将目标位置之后的所有元素都向后移动一个位置,然后再把新元素放入指定位置。这个过程需要移动平均 n/2 个元素,因此时间复杂度为 O(n)。
对于删除:
- 头部删除:由于需要将所有元素依次向前移动一个位置,因此时间复杂度是 O(n)。
- 尾部删除:当删除的元素位于列表末尾时,时间复杂度为 O(1)。
- 指定位置删除:需要将目标元素之后的所有元素向前移动一个位置以填补被删除的空白位置,因此需要移动平均 n/2 个元素,时间复杂度为 O(n)。
说一说 LinkedList?
- LinkedList 本质是双向链表,与 ArrayList 相比插入和删除速度更快,但随机访问元素很慢。
- 除继承 AbstractList 外还实现了 Deque 接口,这个接口具有队列和栈的性质。
- 成员变量被
transient
修饰,原理和 ArrayList 类似。 - LinkedList 包含三个重要的成员:size、first 和 last。 size 是双向链表中节点的个数,first 和 last 分别指向首尾节点的引用。
- LinkedList 的优点在于可以将零散的内存单元通过附加引用的方式关联起来,形成按链路顺序查找的线性结构,内存利用率较高。
LinkedList 插入和删除元素的时间复杂度?
- 头部插入/删除:只需要修改头结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。
- 尾部插入/删除:只需要修改尾结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。
- 指定位置插入/删除:需要先移动到指定位置,再修改指定节点的指针完成插入/删除,因此需要移动平均 n/2 个元素,时间复杂度为 O(n)。
Arraylist 与 LinkedList 区别?
区别 | Arraylist | LinkedList |
---|---|---|
是否保证线程安全 | 不同步 | 不同步 |
底层数据结构 | Object 数组 | 双向链表(JDK1.6之前为循环链表,JDK1.7取消了循环。 |
插入和删除 | 插入和删除元素的时间复杂度受元素位置的影响,时间复杂度就为 O(n-i) | 所以插入,删除元素时间复杂度不受元素位置的影响,都是近似 O(1) |
速随机访问 | 支持 | 不支持 |
内存空间占用 | list列表的结尾会预留一定的容量空间,一般为物理连续空间 | 需存放直接后继和直接前驱以及数据,每一个元素都需要消耗比ArrayList更多的空间。物理空间可不连续 |
我们在项目中一般是不会使用到 LinkedList 的,需要用到 LinkedList 的场景几乎都可以使用 ArrayList 来代替,并且,性能通常会更好。
另外,不要下意识地认为 LinkedList 作为链表就最适合元素增删的场景。LinkedList 仅仅在头尾插入或者删除元素的时候时间复杂度近似 O(1),其他情况增删元素的平均时间复杂度都是 O(n) 。
双向链表: 包含两个指针,一个 prev 指向前一个节点,一个 next 指向后一个节点。
双向循环链表: 最后一个节点的 next 指向 head,而 head 的 prev 指向最后一个节点,构成一个环。
谈一谈 RandomAccess 接口 ?
java.util.RandomAccess
接口是一个标志接口(Marker),无方法定义。集合实现这个接口,标记该集合就能支持快速随机访问。java.util.Collections.binarySearch()
方法,判断传入的list 是否 RamdomAccess 的实例,如果是调用indexedBinarySearch()
方法,如果不是,调用iteratorBinarySearch()
方法。
- 实现了 RandomAccess 接口的list,优先选择普通
for
循环 ,其次 foreach, - 未实现 RandomAccess 接口的list,优先选择
iterator
遍历(foreach遍历底层也是通过iterator实现),大size的数据,千万不要使用普通for循环。
ArrayList 实现了 RandomAccess 接口, 而 LinkedList 没有实现。ArrayList 底层是数组,而 LinkedList 底层是链表。数组天然支持随机访问,时间复杂度为 O(1),所以称为快速随机访问。链表需要遍历到特定位置才能访问特定位置的元素,时间复杂度为 O(n),所以不支持快速随机访问。ArrayList 实现了 RandomAccess 接口,就表明了他具有快速随机访问功能。 RandomAccess 接口只是标识,并不是说 ArrayList 实现 RandomAccess 接口才具有快速随机访问功能的!
CopyOnWriteArrayList可以用于什么应用场景?
CopyOnWriteArrayList
(免锁容器)的好处之一是当多个迭代器同时遍历和修改这个列表时,不会抛出ConcurrentModificationException
。在CopyOnWriteArrayList
中,写入将导致创建整个底层数组的副本,而源数组将保留在原地,使得复制的数组在被修改时,读取操作可以安全地执行。由于写操作的时候,需要拷贝数组,会消耗内存,如果原数组的内容比较多的情况下,可能导致young gc或者full gc;不能用于实时读的场景,像拷贝数组、新增元素都需要时间,所以调用一个set操作后,读取到数据可能还是旧的,虽然CopyOnWriteArrayList 能做到最终一致性,但是还是没法满足实时性要求;CopyOnWriteArrayList透露的思想:读写分离,读和写分开;最终一致性;使用另外开辟空间的思路,来解决并发冲突。
Queue
在 Queue 中 poll()和 remove()有什么区别?
poll() 和 remove() 都是从队列中取出一个元素,但是 poll() 在获取元素失败的时候会返回空,但是 remove()
失败的时候会抛出异常。
Queue 与 Deque 的区别
Queue 是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循 先进先出(FIFO) 规则。
Queue 扩展了 Collection 的接口,根据 因为容量问题而导致操作失败后处理方式的不同 可以分为两类方法: 一种在操作失败后会抛出异常,另一种则会返回特殊值。
Queue 接口 | 抛出异常 | 返回特殊值 |
---|---|---|
插入队尾 | add(E e) | offer(E e) |
删除队首 | remove() | poll() |
查询队首元素 | element() | peek() |
Deque 是双端队列,在队列的两端均可以插入或删除元素。
Deque 扩展了 Queue 的接口, 增加了在队首和队尾进行插入和删除的方法,同样根据失败后处理方式的不同分为两类:
Deque 接口 | 抛出异常 | 返回特殊值 |
---|---|---|
插入队首 | addFirst(E e) | offerFirst(E e) |
插入队尾 | addLast(E e) | offerLast(E e) |
删除队首 | removeFirst() | pollFirst() |
删除队尾 | removeLast() | pollLast() |
查询队首元素 | getFirst() | peekFirst() |
查询队尾元素 | getLast() | peekLast() |
事实上,Deque 还提供有 push() 和 pop() 等其他方法,可用于模拟栈。
ArrayDeque 与 LinkedList 的区别
ArrayDeque 和 LinkedList 都实现了 Deque 接口,两者都具有队列的功能,但两者有什么区别呢?
- ArrayDeque 是基于可变长的数组和双指针来实现,而 LinkedList 则通过链表来实现。
- ArrayDeque 不支持存储 NULL 数据,但 LinkedList 支持。
- ArrayDeque 是在 JDK1.6 才被引入的,而LinkedList 早在 JDK1.2 时就已经存在。
- ArrayDeque 插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。虽然 LinkedList 不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。
从性能的角度上,选用 ArrayDeque 来实现队列要比 LinkedList 更好。此外,ArrayDeque 也可以用于实现栈。
说一说 PriorityQueue
PriorityQueue 是在 JDK1.5 中被引入的, 其与 Queue 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。
这里列举其相关的一些要点:
- PriorityQueue 利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据
- PriorityQueue 通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。
- PriorityQueue 是非线程安全的,且不支持存储 NULL 和 non-comparable 的对象。
- PriorityQueue 默认是小顶堆,但可以接收一个 Comparator 作为构造参数,从而来自定义元素优先级的先后。
PriorityQueue 在面试中可能更多的会出现在手撕算法的时候,典型例题包括堆排序、求第 K 大的数、带权图的遍历等,所以需要会熟练使用才行。
什么是 BlockingQueue?
BlockingQueue (阻塞队列)是一个接口,继承自 Queue。BlockingQueue阻塞的原因是其支持当队列没有元素时一直阻塞,直到有元素;还支持如果队列已满,一直等到队列可以放入新元素时再放入。
BlockingQueue 常用于生产者-消费者模型中,生产者线程会向队列中添加数据,而消费者线程会从队列中取出数据进行处理。
BlockingQueue 的实现类有哪些?
BlockingQueue 的实现类
Java 中常用的阻塞队列实现类有以下几种:
- ArrayBlockingQueue:使用数组实现的有界阻塞队列。在创建时需要指定容量大小,并支持公平和非公平两种方式的锁访问机制。
- LinkedBlockingQueue:使用单向链表实现的可选有界阻塞队列。在创建时可以指定容量大小,如果不指定则默认为Integer.MAX_VALUE。和ArrayBlockingQueue类似, 它也支持公平和非公平的锁访问机制。
- PriorityBlockingQueue:支持优先级排序的无界阻塞队列。元素必须实现Comparable接口或者在构造函数中传入Comparator对象,并且不能插入 null 元素。
- SynchronousQueue:同步队列,是一种不存储元素的阻塞队列。每个插入操作都必须等待对应的删除操作,反之删除操作也必须等待插入操作。因此,SynchronousQueue通常用于线程之间的直接传递数据。
- DelayQueue:延迟队列,其中的元素只有到了其指定的延迟时间,才能够从队列中出队。
ArrayBlockingQueue 和 LinkedBlockingQueue 有什么区别?
ArrayBlockingQueue 和 LinkedBlockingQueue 是 Java 并发包中常用的两种阻塞队列实现,它们都是线程安全的。不过,不过它们之间也存在下面这些区别:
- 底层实现:ArrayBlockingQueue 基于数组实现,而 LinkedBlockingQueue 基于链表实现。
- 是否有界:ArrayBlockingQueue 是有界队列,必须在创建时指定容量大小。LinkedBlockingQueue 创建时可以不指定容量大小,默认是Integer.MAX_VALUE,也就是无界的。但也可以指定队列大小,从而成为有界的。
- 锁是否分离: ArrayBlockingQueue中的锁是没有分离的,即生产和消费用的是同一个锁;LinkedBlockingQueue中的锁是分离的,即生产用的是putLock,消费是takeLock,这样可以防止生产者和消费者线程之间的锁争夺。
- 内存占用:ArrayBlockingQueue 需要提前分配数组内存,而 LinkedBlockingQueue 则是动态分配链表节点内存。这意味着,ArrayBlockingQueue 在创建时就会占用一定的内存空间,且往往申请的内存比实际所用的内存更大,而LinkedBlockingQueue 则是根据元素的增加而逐渐占用内存空间。
Set
Comparable 和 Comparator的区别?
Comparable 接口和 Comparator 接口都是 Java 中用于排序的接口,它们在实现类对象之间比较大小、排序等方面发挥了重要作用:
- Comparable 接口实际上是出自java.lang包 它有一个 compareTo(Object obj)方法用来排序
- Comparator接口实际上是出自 java.util 包它有一个compare(Object obj1, Object obj2)方法用来排序
一般我们需要对一个集合使用自定义排序时,我们就要重写compareTo()方法或compare()方法,当我们需要对某一个集合实现两种排序方式,我们可以重写compareTo()方法和使用自制的Comparator方法或者以两个 Comparator 来实现歌名排序和歌星名排序,第二种代表我们只能使用两个参数版的 Collections.sort().
无序性和不可重复性的含义是什么?
- 无序性不等于随机性 ,无序性是指存储的数据在底层数组中并非按照数组索引的顺序添加 ,而是根据数据的哈希值决定的。
- 不可重复性是指添加的元素按照 equals() 判断时 ,返回 false,需要同时重写 equals() 方法和 hashCode() 方法。
比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同
- HashSet、LinkedHashSet 和 TreeSet 都是 Set 接口的实现类,都能保证元素唯一,并且都不是线程安全的。
- HashSet、LinkedHashSet 和 TreeSet 的主要区别在于底层数据结构不同。HashSet 的底层数据结构是哈希表(基于 HashMap 实现)。LinkedHashSet 的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。TreeSet 底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。
- 底层数据结构不同又导致这三者的应用场景不同。HashSet 用于不需要保证元素插入和取出顺序的场景,LinkedHashSet 用于保证元素的插入和取出顺序满足 FIFO 的场景,TreeSet 用于支持对元素自定义排序规则的场景。
Set 有什么特点,有哪些实现?
Set 元素不重复且无序,常用实现有 HashSet、 LinkedHashSet 和 TreeSet。 HashSet 通过 HashMap 实现,HashMap 的 Key 即 HashSet 存储的元素,所有 Key 都使用相同的 Value ,一个名为 PRESENT 的 Object 类型常量。使用 Key 保证元素唯一性,但不保证有序性。由于 HashSet 是 HashMap 实现的,因此线程不安全。 HashSet 判断元素是否相同时,对于包装类型直接按值比较。对于引用类型先比较 **hashCode()**
是否相同,不同则代表不是同一个对象,相同则继续比较 **equals()**
,都相同才是同一个对象。 LinkedHashSet 继承自 HashSet,通过 LinkedHashMap 实现,使用双向链表维护元素插入顺序。 TreeSet 通过 TreeMap 实现的,添加元素到集合时按照比较规则将其插入合适的位置,保证插入后的集合仍然有序。
Map
HashMap 和 Hashtable 的区别?
- 线程是否安全: HashMap 是非线程安全的,Hashtable 是线程安全的,因为 Hashtable 内部的方法基本都经过synchronized 修饰。
- 效率: 因为线程安全的问题,HashMap 要比 Hashtable 效率高一点。另外,Hashtable 基本被淘汰,不要在代码中使用它,如果你要保证线程安全的话就使用 ConcurrentHashMap
- 对 Null key 和 Null value 的支持: HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出 NullPointerException。
- 初始容量大小和每次扩充容量大小的不同,两者的填充因子默认都是0.75。:
- 创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。
- 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小。
- HashMap继承了
AbstractMap
,HashTable继承Dictionary
抽象类,两者均实现Map接口。 - Hashtable的底层实现都是数组+链表结构实现,HashMap从JDK1.8开始根据长度不同可以切换为红黑树。
- HashMap去掉了HashTable 的
contains()
方法,加上了containsValue()
和containsKey()
方法。 - Hashtable是线程安全的,因此,没有采用快速失败机制,HashMap是非线程安全的,因此,迭代HashMap采用了快速失败机制。
- Hashtable使用
Enumeration
进行遍历,HashMap使用Iterator
进行遍历。 - 计算hash值的方法不同, Hashtable直接使用对象的hashCode。hashCode是JDK根据对象的地址或者字符串或者数字算出来的int类型的数值。然后再使用除留余数发来获得最终的位置。
HashMap 和 HashSet 区别
HashSet 底层就是基于 HashMap 实现的。(HashSet 除了 clone()、writeObject()、readObject()是 HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法。
HashMap | HashSet |
---|---|
实现了 Map 接口 | 实现 Set 接口 |
存储键值对 | 仅存储对象 |
调用 put()向 map 中添加元素 | 调用 add()方法向 Set 中添加元素 |
HashMap 使用键(Key)计算 hashcode | HashSet 使用成员对象来计算 hashcode 值,对于两个对象来说 hashcode 可能相同,所以equals()方法用来判断对象的相等性 |
HashMap 和 TreeMap 区别
TreeMap 和HashMap 都继承自AbstractMap ,但是需要注意的是TreeMap它还实现了NavigableMap接口和SortedMap 接口。
实现 NavigableMap 接口让 TreeMap 有了对集合内元素的搜索的能力。
实现SortedMap接口让 TreeMap 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。
综上,相比于HashMap来说 TreeMap 主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力。
HashSet 如何检查重复?
当你把对象加入HashSet时,HashSet 会先计算对象的hashcode值来判断对象加入的位置,同时也会与其他加入的对象的 hashcode 值作比较,如果没有相符的 hashcode,HashSet 会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用equals()方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让加入操作成功。
HashMap 有什么特点?
HashMap以null作为key时,总是存储在table数组的第一个节点上。
JDK8 之前底层实现是数组 + 链表,JDK8 改为数组 + 链表/红黑树,节点类型从Entry 变更为 Node。 主要成员变量包括存储数据的 table 数组、元素数量 size、加载因子 loadFactor。table 数组记录 HashMap 的数据,每个下标对应一条链表,所有哈希冲突的数据都会被存放到同一条链表,Node/Entry 节点包含四个成员变量:key、value、next 指针和 hash 值。 HashMap 中数据以键值对的形式存在,键对应的 hash 值用来计算数组下标,如果两个元素 key 的 hash 值一样,就会发生哈希冲突,被放到同一个链表上。 HashMap 默认初始化容量为16,扩容容量必须是 2 的幂次方,在构造函数中指定大小时,实际容量为不小于指定数值的2的幂次方的最小正整数,最大容量为 1<<30
,默认加载因子为0.75。
JDK1.8 HashMap中添加键值对时流程是怎样的?
根据插入的key,hash数值。具体算法是就key的hashCode(int)的高16和低16位做异或运算)。
查看node数组有没有初始化。没有就创建,容量是不小于指定容量(默认为16)的最小的2的的幂次方的数。
根据hash值计算数组中唯一角标。具体算法是(capacity-1)& hash值,得到的结果必然是0~(capacity-1)之间的数,找到具体角标,
此时有两种种情况:
当前角标(桶)下没有元素,为null。直接创建新的node节点,放入key-value即可。
当前桶下有元素。分为三种情况:
当前桶的第一个元素k和要插入的key值一模一样。暂存当前的node,在e中,方便后面返回OldValue。
当前桶的第一个元素 instanceof TreeNode,也就是说当前桶结构为红黑树,则调用红黑树的putTreeNode方法。
当前桶结构为链表。循环遍历这个链表,直到
node.next==null
是才插入该key-value。如果插入之后链表长度大于8,就会进行树化处理。在遍历过程中,如果发现有node的k和插入的key相同,直接退出遍历。注意:在树化过程中,如果元素个数小于64只会通过扩容降低Hash冲突。返回旧值情况:用e设置新value,并放回旧的value。注意:此处都会返回原先node上面的value,如果相同也会返回。
验证当前集合容量是否达到阈值,如果达到进行resize扩容。没有旧值返回就返回null。(也就是当前桶没有元素的的时候返回null)
使用自定义类作为HashMap的key时,需要注意什么?
- 重写
equals()
方法和hashCode()
方法,确保如果两个对象相等,那么这两个对象有着相同的hashCode,反之则不成立。 - 当自定义类作为HashMap(Hashtable)的key时,最好把这个类设计为不可变类。
HashMap中为什么用hash(Object key)而不直接使用Object的hashCode?
hashCode()
方法返回的是int整数类型,其范围为-231 ~ 231 - 1
,约有40亿个映射空间,而HashMap的容量范围是在16(初始化默认值)~230,HashMap通常情况下是取不到最大值的,并且设备上也难以提供这么多的存储空间,从而导致通过hashCode()
计算出的哈希值可能不在数组大小范围内,进而无法匹配存储位置;
JDK1.7 与JDK1.8 中HashMap 区别?
- 最重要的一点是底层结构不一样,1.7是数组+链表,1.8则是数组+链表+红黑树结构;
- jdk1.7中当哈希表为空时,会先调用
inflateTable()
初始化一个数组;而1.8则是直接调用resize()
扩容; - 插入键值对的put方法的区别,1.8 中会将节点插入到链表尾部,而1.7中是采用头插;
- jdk1.7中的hash函数对哈希值的计算直接使用key的hashCode值,而1.8中则是采用key的hashCode异或上key的hashCode进行无符号右移16位的结果(2次扰动),避免了只靠低位数据来计算哈希时导致的冲突,计算结果由高低位结合决定,使元素分布更均匀;
- 扩容时1.8会保持原链表的顺序,而1.7会颠倒链表的顺序;而且1.8是在元素插入后检测是否需要扩容,1.7则是在元素插入前;
- jdk1.8是扩容时通过
hash&cap==0
将链表分散,无需改变hash值,而1.7是通过更新hashSeed来修改hash值达到分散的目的; - 扩容策略:1.7中是只要不小于阈值就直接扩容2倍;而1.8当数组容量未达到64时,以2倍进行扩容,超过64之后若桶中元素个数>= 7 就将链表转换为红黑树,但如果红黑树中的元素个数小于6就会还原为链表,当红黑树中元素不小于32的时候才会再次扩容。
HashMap 为什么线程不安全?
- 并发赋值被覆盖: 在 createEntry 方法中,新添加的元素直接放在头部,使元素之后可以被更快访问,但如果两个线程同时执行到此处,会导致其中一个线程的赋值被覆盖。
- 已遍历区间新增元素丢失: 当某个线程在 transfer 方法迁移时,其他线程新增的元素可能落在已遍历过的哈希槽上。遍历完成后,table 数组引用指向了 newTable,新增元素丢失。
- 新表被覆盖: 如果 resize 完成,执行了 table = newTable,则后续元素就可以在新表上进行插入。但如果多线程同时 resize ,每个线程都会 new 一个数组,这是线程内的局部对象,线程之间不可见。迁移完成后resize 的线程会赋值给 table 线程共享变量,可能会覆盖其他线程的操作,在新表中插入的对象都会被丢弃。
- 死循环: 扩容时 resize 调用 transfer 使用头插法迁移元素,虽然 newTable 是局部变量,但原先 table 中的 Entry 链表是共享的,问题根源是 Entry 的 next 指针并发修改,某线程还没有将 table 设为 newTable 时用完了 CPU 时间片,导致数据丢失或死循环。
JDK8 在 resize 方法中完成扩容,并改用尾插法,不会产生死循环,但并发下仍可能丢失数据。可用 ConcurrentHashMap
或Collections.synchronizedMap
包装成同步集合。
HashMap 的长度为什么是2的幂次方?
2的n次方实际就是1后面n个0,2的n次方-1 实际就是n个1;在hashMap的length等于2的n次方的时候有hash%length==hash&(length-1);
哈希算法的目的是为了加快哈希计算以及减少哈希冲突,计算机中直接求余效率不如位移运算,所以此时&操作更合适,所以在length等于2的幂次方的时候,可以使用&操作加快操作且减少冲突,所以hashMap长度是2的幂次方。
ConcurrentHashMap 和 Hashtable 的区别?
- 底层数据结构: JDK1.7的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。Hashtable底层数据结构类似都是采用数组+链表的形式,数组是主体,链表则是主要为了解决哈希冲突而存在的;
- 实现线程安全的方式(重要):在JDK1.7的时候,ConcurrentHashMap 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 直接用Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;Hashtable使用synchronized来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
JDK7 的 ConcurrentHashMap 原理?
在JDK1.7中ConcurrentHashMap采用了数组+Segment+分段锁的方式实现。
- **Segment(分段锁):**ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表,同时又是一个ReentrantLock(Segment继承了ReentrantLock)。
- **内部结构:**一个ConcurrentHashMap里包含一个Segment数组,Segment的结构和HashMap类似,是一种数组和链表结构, 一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素, 每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。
- 初始化,CurrentHashMap的初始化一共有三个参数,一个initialCapacity,表示初始的容量,一个loadFactor,表示负载参数,最后一个是concurrentLevel,ConcurrentHashMap内部的Segment的数量是不大于concurrentLevel的最大的2的指数,ConcurrentLevel一经指定,不可改变,后续如果ConcurrentHashMap的元素数量增加导致ConrruentHashMap需要扩容,ConcurrentHashMap不会增加Segment的数量,而只会增加Segment中链表数组的容量大小。
- get() 实现简单高效,先经过一次再散列,再用这个散列值通过散列运算定位到 Segment,最后通过散列算法定位到元素。get 的高效在于不需要加锁,除非读到空值才会加锁重读。get 方法中将共享变量定义为 volatile,在 get 操作里只需要读所以不用加锁。
- put()必须加锁,首先定位到 Segment,然后进行插入操作,第一步判断是否需要对 Segment 里的 HashEntry 数组进行扩容,第二步定位添加元素的位置,然后将其放入数组。
- size()操作用于统计元素的数量,必须统计每个 Segment 的大小然后求和,在统计结果累加的过程中,之前累加过的 count 变化几率很小,因此先尝试两次通过不加锁的方式统计结果,如果统计过程中容器大小发生了变化,再加锁统计所有 Segment 大小。判断容器是否发生变化根据 modCount 确定。
JDK8 的 ConcurrentHashMap 原理?
K8中ConcurrentHashMap参考了JDK8 HashMap的实现,采用了数组+链表+红黑树的实现方式来设计,内部大量采用CAS操作。并发控制使⽤**synchronized 和 CAS 来操作。**整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;JDK1.8的Node节点中value和next都用volatile修饰,保证并发的可见性。可以理解为,synchronized 只锁定当前链表或红⿊⼆叉树的⾸节点,这样只要 hash 不冲突,就不会产⽣并发,效率⼜提升 N 倍。主要对 JDK7 做了三点改造:
- 取消分段锁机制,进一步降低冲突概率。
- 引入红黑树结构,同一个哈希槽上的元素个数超过一定阈值后,单向链表改为红黑树结构。
- 使用了更加优化的方式统计集合内的元素数量。具体优化表现在:在 put, resize 和 size 方法中设计元素总数的更新和计算都避免了锁,使用 CAS 代替。
get 同样不需要同步,put 操作时如果没有出现哈希冲突,就使用 CAS 添加元素,否则使用 synchronized 加锁添加元素。
当某个槽内的元素个数达到 7 且 table 容量不小于 64 时,链表转为红黑树。当某个槽内的元素减少到 6 时,由红黑树重新转为链表。在转化过程中,使用同步块锁住当前槽的首元素,防止其他线程对当前槽进行增删改操作,转化完成后利用 CAS 替换原有链表。由于 TreeNode 节点也存储了 next 引用,因此红黑树很简单,只需从 first 元素开始遍历所有节点,并把节点从 TreeNode 转为 Node 类型即可,当构造好新链表后同样用 CAS 替换红黑树。
JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同?
- 线程安全实现方式:JDK 1.7 采用 Segment 分段锁来保证安全, Segment 是继承自 ReentrantLock。JDK1.8 放弃了 Segment 分段锁的设计,采用 Node + CAS + synchronized 保证线程安全,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点。
- Hash 碰撞解决方法 : JDK 1.7 采用拉链法,JDK1.8 采用拉链法结合红黑树(链表长度超过一定阈值时,将链表转换为红黑树)。
- 并发度:JDK 1.7 最大并发度是 Segment 的个数,默认是 16。JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。
ConcurrentHashMap 为什么 key 和 value 不能为 null?
ConcurrentHashMap 的 key 和 value 不能为 null 主要是为了避免二义性。null 是一个特殊的值,表示没有对象或没有引用。如果你用 null 作为键,那么你就无法区分这个键是否存在于 ConcurrentHashMap 中,还是根本没有这个键。同样,如果你用 null 作为值,那么你就无法区分这个值是否是真正存储在 ConcurrentHashMap 中的,还是因为找不到对应的键而返回的。
拿 get 方法取值来说,返回的结果为 null 存在两种情况:
- 值没有在集合中 ;
- 值本身就是 null。
这也就是二义性的由来。
多线程环境下,存在一个线程操作该 ConcurrentHashMap 时,其他的线程将该 ConcurrentHashMap 修改的情况,所以无法通过 containsKey(key) 来判断否存在这个键值对,也就没办法解决二义性问题了。
与此形成对比的是,HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个。如果传入 null 作为参数,就会返回 hash 值为 0 的位置的值。单线程环境下,不存在一个线程操作该 HashMap 时,其他的线程将该 HashMap 修改的情况,所以可以通过 contains(key)来做判断是否存在这个键值对,从而做相应的处理,也就不存在二义性问题。
也就是说,多线程下无法正确判定键值对是否存在(存在其他线程修改的情况),单线程是可以的(不存在其他线程修改的情况)。
如果你确实需要在 ConcurrentHashMap 中使用 null 的话,可以使用一个特殊的静态空对象来代替 null。
ConcurrentHashMap 能保证复合操作的原子性吗?
ConcurrentHashMap 是线程安全的,意味着它可以保证多个线程同时对它进行读写操作时,不会出现数据不一致的情况,也不会导致 JDK1.7 及之前版本的 HashMap 多线程操作导致死循环问题。但是,这并不意味着它可以保证所有的复合操作都是原子性的,一定不要搞混了!
复合操作是指由多个基本操作(如put、get、remove、containsKey等)组成的操作,例如先判断某个键是否存在containsKey(key),然后根据结果进行插入或更新put(key, value)。这种操作在执行过程中可能会被其他线程打断,导致结果不符合预期。
那如何保证 ConcurrentHashMap 复合操作的原子性呢?
ConcurrentHashMap 提供了一些原子性的复合操作,如 putIfAbsent、compute、computeIfAbsent 、computeIfPresent、merge等。这些方法都可以接受一个函数作为参数,根据给定的 key 和 value 来计算一个新的 value,并且将其更新到 map 中。
TreeMap 有什么特点?
TreeMap基于红黑树实现,增删改查的平均和最差时间复杂度均为 O(logn) ,最大特点是 Key 有序。Key 必须实现 Comparable 接口或提供的 Comparator 比较器,所以 Key 不允许为 null。HashMap 依靠 hashCode()
和 equals()
去重,而 TreeMap 依靠 Comparable
或 Comparator
。 TreeMap 排序时,如果比较器不为空就会优先使用比较器的 compare()
方法,否则使用 Key 实现的 Comparable 的 compareTo()
方法,两者都不满足会抛出异常。
如何决定使用 HashMap 还是 TreeMap?
对于在Map中插入、删除和定位元素这类操作,HashMap是最好的选择。然而,假如你需要对一个有序的key集合进行遍历,TreeMap是更好的选择。基于collection的大小,也许向HashMap中添加元素会更快,将map换为TreeMap进行有序key的遍历。
哪些集合类是线程安全的?
- 容器中线程安全的如:Hashtable 和 Vector,Stack
- 对于非线程安全的如:HashMap,ArrayList等可以使用Collections中的
synchronizedList(list)
,synchronizedMap(map)
,synchronizedSet(set)
等方法来使原来非线程安全的容器编程线程安全,其返回一个线程安全的容器,底层使用synchronized (mutex)
实现。 - JUC包中的同步容器,大多数是使用系统底层技术实现的线程安全。类似native。Java8中使用AQS。
Iterator 怎么使用,有什么特点?
迭代器是一种设计模式,它是一个对象,它可以遍历并选择序列中的对象,而不需要了解该序列的底层结构。迭代器通常被称为“轻量级”对象,因为创建它的代价小。Java中的Iterator功能比较简单,并且只能单向移动:
**iterator()**
要求容器返回一个Iterator。第一次调用Iterator的**next**()
方法时,它返回序列的第一个元素。注意:iterator()
是java.lang.Iterable
接口中的方法,被Collection
继承。- 使用
**next()**
获得序列中的下一个元素。 - 使用
**hasNext**()
检查序列中是否还有元素。 - 使用
**remove**()
将迭代器新返回的元素删除。 - 多线程访问容器的过程中可能抛出ConcurrentModificationException异常(fail-fast)
解决方案:1. 线程安全的容器,比如ConcurrentHashMap和CopyOnWriteArrayList等。可以使用这些线程安全的容器来代替非线程安全的容器。2. 在使用迭代器遍历容器时对容器的操作放到synchronized代码块中,但是当引用程序并发程度比较高时,这会严重影响程序的性能。
fail-fast和fail-safe迭代器的区别是什么?
它们的主要区别是fail-safe允许在遍历的过程中对容器中的数据进行修改,而fail-fast则不允许。
- fail-fast:容器的modCount属性记录了这个列表在结构上被修改的次数。容器初始状态
int expectedModCount = modCount;
这个容器进行修改操作(add(),remove(),clear(),replace()等)都会使得modCout++
,在调用next方法时会比较变量expectedModCount与modCount的值是否相等,若二者不相等,则会抛出ConcurrentModificationException异常,因此在使用Iterator遍历容器的过程中,如果对容器进行增加或删除操作,就会改变容器中对象的数量,从而导致抛出异常。 - fail-safe:这种遍历基于容器的一个克隆。因此,对容器中内容的修改不影响遍历。常见的使用fail-safe方式遍历的容器有
ConcurrentHashMap
和CopyOnWriteArrayList
。
Iterator 和 ListIterator 有什么区别?
- Iterator可用来遍历Set和List集合,但是ListIterator只能用来遍历List。
- Iterator对集合只能是前向遍历,ListIterator既可以前向也可以后向。
- ListIterator实现了Iterator接口,并包含其他的功能,比如:增加元素,替换元素,获取前一个和后一个元素的索引,等等。
Collection 和 Collections 有什么区别?
java.util.Collection
是一个集合接口(集合类的一个顶级接口)。它提供了对集合对象进行基本操作的通用接口方法。Collection接口在Java 类库中有很多具体的实现。Collection接口的意义是为各种具体的集合提供了最大化的统一操作方式,其直接继承接口有List与Set。java.util.Collections
是集合类的一个工具类/帮助类,其中提供了一系列静态方法,用于对集合中元素进行排序、搜索以及线程安全等各种操作。
Collections.sort和Arrays.sort的实现原理?
Collection.sort()
是对list进行排序,Arrays.sort()
是对数组进行排序。Collections.sort()
方法底层就是调用的Arrays.sort()
底层就是:legacyMergeSort(a)
,归并排序;ComparableTimSort.sort():即Timsort排序。
Java集合类框架的最佳实践有哪些?
- 根据应用需要正确选择要使用的集合类型对性能非常重要,比如:假如知道元素的大小是固定的,那么选用Array类型而不是ArrayList类型更为合适。
- 有些集合类型允许指定初始容量。如果我们能估计出存储的元素的数目,可以指定初始容量来避免重新计算hash值或者扩容等。
- 为了类型安全、可读性和健壮性等原因总是要使用泛型。同时,使用泛型还可以避免运行时的ClassCastException。
- 使用JDK提供的不变类(immutable class)作为Map的键可以避免为我们自己的类实现hashCode()和equals()方法。
- 编程的时候变量类型接口优于实现
- 底层的集合实际上是空的情况下,返回为长度是0的集合或数组而不是null。
IO
java.io 包下有哪些流?
Java语言中,输入和输出都被称为抽象的流,流可以被看作一组有序的字节集合,即数据在两设备之间的传输。
流的本质是数据传输,根据处理数据类型的不同,流可以分为两大类:字节流和字符流。
字节流以字节(8bit)为单位,按功能来分,包含两个抽象类:InputStream(输入流)和OutputStream(输出流)。
字符流以字符为单位,根据码表映射字符,一次可以读多个字节,它包含两个抽象类:Reader(输入流)和Writer(输出流)。字符流一般用于文本文件,字节流一般用于图像或其他文件。
Java IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。
- InputStream/Reader: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。
- OutputStream/Writer: 所有输出流的基类,前者是字节输出流,后者是字符输出流。
字符流和字节流都有对应的缓冲流,字节流也可以包装为字符流,缓冲流带有一个 8KB 的缓冲数组,可以提高流的读写效率。除了缓冲流外还有过滤流 FilterReader、字符数组流 CharArrayReader、字节数组流 ByteArrayInputStream、文件流 FileInputStream 等。
字符流是由 Java 虚拟机将字节转换得到的,问题就出在这个过程还算是非常耗时,并且,如果不知道编码类型就很容易出现乱码问题。所以, I/O 流就干脆提供了一个直接操作字符的接口,方便我们平时对字符进行流操作。如果音频文件、图片等媒体文件用字节流比较好,如果涉及到字符的话使用字符流比较好。
Java IO 中的设计模式有哪些?
- 装饰器模式
装饰器(Decorator)模式 可以在不改变原有对象的情况下拓展其功能。装饰器模式通过组合替代继承来扩展原始类的功能,在一些继承关系比较复杂的场景(IO 这一场景各种类的继承关系就比较复杂)更加实用。对于字节流来说, FilterInputStream (对应输入流)和FilterOutputStream(对应输出流)是装饰器模式的核心,分别用于增强 InputStream 和OutputStream子类对象的功能。我们常见的BufferedInputStream(字节缓冲输入流)、DataInputStream 等等都是FilterInputStream 的子类,BufferedOutputStream(字节缓冲输出流)、DataOutputStream等等都是FilterOutputStream的子类。
- 适配器模式
适配器(Adapter Pattern)模式 主要用于接口互不兼容的类的协调工作。适配器模式中存在被适配的对象或者类称为 适配者(Adaptee) ,作用于适配者的对象或者类称为适配器(Adapter) 。适配器分为对象适配器和类适配器。类适配器使用继承关系来实现,对象适配器使用组合关系来实现。
IO 流中的字符流和字节流的接口不同,它们之间可以协调工作就是基于适配器模式来做的,更准确点来说是对象适配器。通过适配器,我们可以将字节流对象适配成一个字符流对象,这样我们可以直接通过字节流对象来读取或者写入字符数据。
InputStreamReader 和 OutputStreamWriter 就是两个适配器(Adapter), 同时,它们两个也是字节流和字符流之间的桥梁。InputStreamReader 使用 StreamDecoder (流解码器)对字节进行解码,实现字节流到字符流的转换,OutputStreamWriter 使用StreamEncoder(流编码器)对字符进行编码,实现字符流到字节流的转换。InputStream 和 OutputStream 的子类是被适配者, InputStreamReader 和 OutputStreamWriter是适配器。
适配器模式和装饰器模式有什么区别呢?
装饰器模式 更侧重于动态地增强原始类的功能,装饰器类需要跟原始类继承相同的抽象类或者实现相同的接口。并且,装饰器模式支持对原始类嵌套使用多个装饰器。
适配器模式 更侧重于让接口不兼容而不能交互的类可以一起工作,当我们调用适配器对应的方法时,适配器内部会调用适配者类或者和适配类相关的类的方法,这个过程透明的。
- 工厂模式
工厂模式用于创建对象,NIO 中大量用到了工厂模式,比如 Files 类的 newInputStream 方法用于创建 InputStream 对象(静态工厂)、 Paths 类的 get 方法创建 Path 对象(静态工厂)、ZipFileSystem 类(sun.nio包下的类,属于 java.nio 相关的一些内部实现)的 getPath 的方法创建 Path 对象(简单工厂)。
- 观察者模式
NIO 中的文件目录监听服务使用到了观察者模式。NIO 中的文件目录监听服务基于 WatchService 接口和 Watchable 接口。WatchService 属于观察者,Watchable 属于被观察者。Watchable 接口定义了一个用于将对象注册到 WatchService(监控服务) 并绑定监听事件的方法 register 。
BIO、NIO 和 AIO 的区别?
BIO:一个连接一个线程,客户端有连接请求时服务器端就需要启动一个线程进行处理。线程开销大。 伪异步IO:将请求连接放入线程池,一对多。BIO是面向流的且是单向的,各种流是阻塞的。
AIO:一个有效请求一个线程,客户端的I/O请求都是由OS先完成了再通知服务器应用去启动线程进行处理,
NIO:一个请求一个线程,但客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求时才启动一个线程进行处理。NIO是面向缓冲区的和非阻塞的,NIO的channel是双向的。NIO的特点:事件驱动模型、单线程处理多任务、非阻塞I/O,I/O读写不再阻塞,而是返回0、基于block的传输比基于流的传输更高效、更高级的IO函数zero-copy、IO多路复用大大提高了Java网络应用的可伸缩性和实用性。基于Reactor线程模型。
NIO的组成?
NIO 是 JDK1.4 引入的同步非阻塞 IO。服务器实现模式为多个连接请求对应一个线程,客户端连接请求会注册到一个多路复用器 Selector ,Selector 轮询到连接有 IO 请求时才启动一个线程处理。适用连接数目多且连接时间短的场景。同步是指线程还是要不断接收客户端连接并处理数据,非阻塞是指如果一个管道没有数据,不需要等待,可以轮询下一个管道。NIO非阻塞的实现主要采用了Reactor(反应器)设计模式,这个设计模式与Observer(观察者)设计模式类似,只不过Observer设计模式只能处理一个事件源,而Reactor设计模式可以用来处理多个事件源。核心组件:
Buffer: 缓冲区,本质是一块可读写数据的内存,用来简化数据读写。与Channel进行交互,数据是从Channel读入缓冲区,从缓冲区写入Channel中。Buffer 三个重要属性:position 下次读写数据的位置,limit 本次读写的极限位置,capacity 最大容量。使用步骤:向 Buffer 写数据,调用 flip 方法转为读模式;从 Buffer 中读数据,调用 clear 或 compact 方法清空缓冲区。
allocate()
:分配缓冲区内存空间put()
:写入数据到缓冲区read()
:从channel 写入数据到缓冲区get()
:从Buffer中读取数据flip()
: 反转此缓冲区,将position给limit,然后将position置为0,其实就是写模式切换到读模式clear()
:清除此缓冲区,将position置为0,把capacity的值给limit。其实就是切换到写模式。rewind()
: 重绕此缓冲区,将position置为0,所以你可以重读Buffer中的所有数据。limit保持不变,仍然表示能从Buffer中读取多少个元素。compact()
:将所有未读的数据拷贝到Buffer起始处。然后将position设到最后一个未读元素正后面。limit属性依然像clear()方法一样,设置成capacity。将Buffer读转为写模式,但是不会覆盖未读的数据。mark()
:标记Buffer中的一个特定positionreset()
:恢复到这个mark 方法 标记的positionChannel 双向通道,既可以从通道中读取数据,又可以写数据到通道。但流的读写通常是单向的,表示 IO 源与目标打开的连接,替换了 BIO 中的 Stream 流,不能直接访问数据,要通过 Buffer 来读写数据,通道中的数据总是要先读到一个Buffer,或者总是要从一个Buffer中写入。也可以和其他 Channel 交互。FileChannel的read方法和write方法都导致数据复制了两次,通道可以异步地读写。
FileChannel 从文件中读写数据。
DatagramChannel 能通过UDP读写网络中的数据。
SocketChannel 能通过TCP读写网络中的数据。
ServerSocketChannel可以监听新进来的TCP连接,像Web服务器那样。对每一个新进来的连接都会创建一个SocketChannel。
Selector 多路复用器,轮询检查多个 Channel 的状态,判断注册事件是否发生,即判断 Channel 是否处于可读或可写状态。使用前需要将 Channel 注Selector,注册后会得到一个 SelectionKey,通过 SelectionKey 获取 Channel 和 Selector 相关信息。可使一个单独的线程管理多个Channel。
open方法可创建Selector
register方法向多路复用器器注册通道,可以监听的事件类型:CONNECT,ACCEPT,READ,WRITE。可以用“位或”操作符将多个事件常量连接起来,注册事件后会产生一个SelectionKey:它表示SelectableChannel 和Selector 之间的注册关系
select方法。这些方法返回你所感兴趣的事件(如连接、接受、读或写)已经准备就绪的那些通道。该方法返回的int值表示有多少通道已经就绪。亦即,自上次调用select()方法后有多少通道变成就绪状态。如果调用select()方法,因为有一个通道变成就绪状态,返回了1,若再次调用select()方法,如果另一个通道就绪了,它会再次返回1。如果对第一个就绪的channel没有做任何操作,现在就有两个就绪的通道,但在每次select()方法调用之间,只有一个通道就绪了。
select()
阻塞到至少有一个通道在你注册的事件上就绪了。select(long timeout)
和select()一样,除了最长会阻塞timeout毫秒(参数)。selectNow()
不会阻塞,不管什么通道就绪都立刻返回selectedKeys方法:访问已选择键集(selected key set)中的就绪通道,可以遍历访问就绪的事件,并进行相应操作,注意每次迭代末尾的keyIterator.remove()调用。Selector不会自己从已选择键集中移除SelectionKey实例。必须在处理完通道时自己移除。下次该通道变成就绪时,Selector会再次将其放入已选择键集中。
wakeup方法:某个线程调用select()方法后阻塞了,即使没有通道已经就绪,也有办法让其从select()方法返回。只要让其它线程在第一个线程调用select()方法的那个对象上调用Selector.wakeup()方法即可。阻塞在select()方法上的线程会立马返回。如果有其它线程调用了wakeup()方法,但当前没有线程阻塞在select()方法上,下个调用select()方法的线程会立即“醒来(wake up)”。
close方法:close()方法会关闭该Selector,且使注册到该Selector上的所有SelectionKey实例无效。通道本身并不会关闭。
Pipe:管道是2个线程之间的单向数据连接。
Pipe
有一个source通道和一个sink通道。数据会被写到sink通道,从source通道读取。Pipe.open():创建管道
pipe.sink():返回 Pipe.SinkChannel ,通过调用SinkChannel的
write()
方法,可将数据写入SinkChannel
source:返回 Pipe.SourceChannel,调用source通道的
read()
方法来读取数据。
如何创建ByteBuffer对象?
ByteBuffer buffer = ByteBuffer.allocate(6);
ByteBuffer buffer2 = ByteBuffer.allocateDirect(10);
byte[] bytes = new byte[10];
ByteBuffer buffer3 = ByteBuffer.wrap(bytes);
Files的常用方法都有哪些?
对文件或目录进行管理与操作在编程中有着非常重要的作用,Java提供了一个非常重要的类(File)来管理文件和文件夹,通过类不仅能够查看文件或目录的属性,而且还可以实现对文件或目录的创建、删除与重命名等操作。下面是File类中常用的几个方法:
- Files.exists():检测文件路径是否存在。
- Files.createFile():创建文件。
- Files.createDirectory():创建文件夹。
- Files.delete():删除一个文件或目录。
- Files.copy():复制文件。
- Files.move():移动文件。
- Files.size():查看文件个数。
- Files.read():读取文件。
- Files.write():写入文件。
Java Socket是什么?
网络上的两个程序通过一个双向的通信连接实现数据的交换,这个双向链路的一端称为一个Socket。Socket也称为套接字,可以用来实现不同虚拟机或不同计算机之间的通信。在Java语言中,Socket可以分为两种类型:
- 面向连接的Socket通信协议(TCP,Transmission Control Protocol,传输控制协议)
- 面向无连接的Socket通信协议(UDP,User Datagram Protocol,用户数据报协议)。
任何一个Socket都是由IP地址和端口号唯一确定的。
基于TCP的通信过程如下:
- 首先,Server(服务器)端Listen(监听)指定的某个端口(建议使用大于1024的端口)是否有连接请求;
- 其次,Client(客户)端向Server端发出Connect(连接)请求;
- 最后,Server端向Client端发回Accept(接受)消息。一个连接就建立起来了,会话随即产生。Server端和Client端都可以通过Send、Write等方法与对方通信。
Socket的生命周期可以分为3个阶段:打开Socket、使用Socket收发数据和关闭Socket。在Java语言中,可以使用ServerSocket来作为服务器端,Socket作为客户端来实现网络通信。
同步/异步/阻塞/非阻塞 IO 的区别?
同步和异步是通信机制,阻塞和非阻塞是调用状态。
- 同步 IO 是用户线程发起 IO 请求后需要等待或轮询内核 IO 操作完成后才能继续执行。
- 异步 IO 是用户线程发起 IO 请求后可以继续执行,当内核 IO 操作完成后会通知用户线程,或调用用户线程注册的回调函数。
- 阻塞 IO 是 IO 操作需要彻底完成后才能返回用户空间 。
- 非阻塞 IO 是 IO 操作调用后立即返回一个状态值,无需等 IO 操作彻底完成。
什么是Reactor模式?
Reactor模式是处理并发I/O常见的一种模式,称为反应器模式或应答者模式,是基于事件驱动的设计模式,拥有一个或多个并发输入源,有一个服务处理器和多个请求处理器,用于同步I/O,其中心思想是将所有要处理的I/O事件注册到一个中心I/O多路复用器上,**主线程阻塞在多路复用器上,一旦有I/O事件到来或是准备就绪,多路复用器将返回并将相应I/O事件分发到对应的处理器中,**调用应用程序注册的接口(回调函数)。当客户端请求抵达后,服务处理程序使用多路分配策略,**由一个非阻塞的线程来接收所有请求,然后将请求派发到相关的工作线程并进行处理的过程。**在事件驱动的应用中,将一个或多个客户端的请求分离和调度给应用程序,同步有序地接收并处理多个服务请求。对于高并发系统经常会使用到Reactor模式,用来替代常用的多线程处理方式以节省系统资源并提高系统的吞吐量。
直接缓存区和非直接缓存区的区别?
类型 | 优点 | 缺点 |
---|---|---|
直接缓冲区 | 在虚拟机内存外,开辟的内存,IO操作直接进行,没有再次复制 | 创建和销毁开销大 |
非直接缓冲区 | 在虚拟机内存中创建,易回收 | 但占用虚拟机内存开销,处理中有复制过程。 |