Java基础面试题
一、基础概念与常识
1.JVM vs JDK vs JRE
JVM(Java Virtual Machine)Java虚拟机是运行Java字节码的虚拟机,针对不同的操作系统有不同实现,字节码和JVM是实现Java语言跨平台性的关键;
JRE(Java Runtime Environment)Java运行时环境是运行已编译Java程序所需的所有内容的集合,主要包含JVM和Java基础类库;
JDK(Java Development Kit)是用于Java应用程序的开发和调试的工具包,JDK除了包含JRE外,还包含了大量Java开发工具,例如javadoc文档注释工具、javap反编译工具、jdb调试工具等;
2.什么是字节码?采用字节码的好处是什么?
在 Java 中,JVM 可以理解的代码就叫做字节码(即扩展名为 .class
的文件),它不面向任何特定的处理器,只面向虚拟机。
字节码在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。
为什么经常会说 Java 是编译与解释共存的语言?
因为 Java 程序要经过先编译,后解释两个步骤,由 Java 编写的程序需要先经过编译步骤,生成字节码(.class
文件),这种字节码又必须由 Java 解释器来解释执行;
(热点代码会经由JIT编译器直接转换为机器码,无需经过解释器)
3.Java 和 C++ 的区别?
Java 和 C++ 都是面向对象的语言,都支持封装、继承和多态;
- Java 不提供指针来直接访问内存,程序内存更加安全
- Java 的类是单继承的,C++ 支持多重继承;虽然 Java 的类不可以多继承,但是接口可以多继承。
- Java 有自动内存管理垃圾回收机制(GC),不需要程序员手动释放无用内存。
- C ++同时支持方法重载和操作符重载,但是 Java 只支持方法重载
- Java只支持值传递,不支持引用传递,C++两个都支持;
4.Java参数传递只支持值传递
程序设计语言将实参传递给方法(或函数)的方式分为两种:
- 值传递:方法接收的是实参值的拷贝,会创建副本,对形参的修改不会影响到实参;
- 引用传递:方法接收的直接是实参所引用的对象在堆中的地址,不会创建副本,对形参的修改将影响到实参;
很多程序设计语言(比如 C++、 Pascal )提供了两种参数传递的方式,不过,在 Java 中只有值传递:
- 如果参数是基本类型的话,很简单,传递的就是基本类型的字面量值的拷贝,会(在新的方法的栈帧的局部变量表中)创建副本。
- 如果参数是引用类型,传递的就是实参所引用的对象在堆中地址值的拷贝,同样也会创建副本。
测试题
public class Person {
private String name;
// 省略构造函数、Getter&Setter方法
}
public static void main(String[] args) {
Person xiaoZhang = new Person("小张");
Person xiaoLi = new Person("小李");
swap(xiaoZhang, xiaoLi);
System.out.println("xiaoZhang:" + xiaoZhang.getName());
System.out.println("xiaoLi:" + xiaoLi.getName());
}
public static void swap(Person person1, Person person2) {
Person temp = person1;
person1 = person2;
person2 = temp;
System.out.println("person1:" + person1.getName());
System.out.println("person2:" + person2.getName());
}
输出:
person1:小李
person2:小张
xiaoZhang:小张
xiaoLi:小李
分析:
这里将main()方法栈帧中的xiaoZhang和xiaoLi的地址值作为参数传递给了swap方法,
swap()方法的栈帧中的局部变量中的person1、person2接收了这两个地址值,并对这两个变量的值进行了交换,
但这并不会影响main()方法栈帧中局部变量表的xiaoZhang和xiaoLi的值,故这两个地址指向的对象也没有发生变化,因此打印结果仍是小张和小李;
二、基本语法
1.Java中的移位运算符
<<
:左移运算符,向左移若干位,高位丢弃,低位补零。x << 1
,相当于 x 乘以 2(不溢出的情况下)
>>
:带符号右移,向右移若干位,高位补符号位(正数高位补 0,负数高位补 1),低位丢弃。x >> 1
,相当于 x 除以 2。
>>>
:无符号右移,忽略符号位,空位都以 0 补齐。
由于 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
三、基本数据类型
1.Java 中的几种基本数据类型了解么?
Java 中有 8 种基本数据类型,分别为:
- 6 种数字类型:
- 4 种整数型:
byte
、short
、int
、long
- 2 种浮点型:
float
、double
- 4 种整数型:
- 1 种字符类型:
char
- 1 种布尔型:
boolean
。
为什么像 byte
、short
、int
、long
能表示的最大正数都减 1 了?
因为在计算机中整型的首位是用来作为符号位的,0表示正数,1表示负数,而当符号位为0时,0000..0表示0,而当符号位为1时,1000...0则表示-2^n,所以正数比负数少了一个;
注意:
①Java 的每种基本类型所占存储空间的大小不会像其他大多数语言那样随机器硬件架构的变化而变化;
②Java 里使用 long
类型的数据一定要在数值后面加上 L,否则将作为int类型解析(可能会超出int类型的范围);
③这八种基本类型都有对应的包装类分别为:Byte
、Short
、Integer
、Long
、Float
、Double
、Character
、Boolean
2.基本类型和包装类型的区别?
①用途:包装类型可用于泛型,而基本类型不可以;
②存储方式:基本数据类型的局部变量存放在JVM栈中的局部变量表里,基本数据类型的未被static修饰的成员变量存放在JVM的堆中,而包装类型属于对象,基本都存放在堆中;
③占用空间:相比于包装类型(对象类型), 基本数据类型占用的空间往往非常小;
④默认值:成员变量包装类型不赋值就是 null
,而基本类型有默认值且不是 null
⑤比较方式:对于基本数据类型来说,==
比较的是值。对于包装数据类型来说,==
比较的是对象的内存地址。所有整型包装类对象之间值的比较,全部使用 equals()
方法
为什么说是几乎所有对象实例都存在于堆中呢?
因为 HotSpot 虚拟机引入了 JIT 优化之后,会对对象进行逃逸分析,如果发现某一个对象并没有逃逸到方法外部,那么就可能通过标量替换来实现栈上分配,而避免堆上分配内存;
3.包装类型的缓存机制/常量池技术?
Byte
,Short
,Integer
,Long
这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character
创建了数值在 [0,127] 范围的缓存数据,Boolean
直接返回 True
or False,
两种浮点数类型的包装类 Float
,Double
并没有实现缓存机制;
对于在缓存范围内的包装类型会直接从缓存中返回对应的对象(如果手动使用new,则不论是否在范围内仍会新建对象),只有在超出范围时才会新建对象;
Integer i1 = 33;
Integer i2 = 33;
System.out.println(i1 == i2);// 输出 true
Float i11 = 333f;
Float i22 = 333f;
System.out.println(i11 == i22);// 输出 false
Double i3 = 1.2;
Double i4 = 1.2;
System.out.println(i3 == i4);// 输出 false
测试题
Integer i1 = 40;
Integer i2 = new Integer(40);
System.out.println(i1==i2);
Integer i1=40
这一行代码会发生装箱,也就是说这行代码等价于 Integer i1=Integer.valueOf(40)
。因此,i1
直接使用的是缓存中的对象。而Integer i2 = new Integer(40)
会直接创建新的对象。
因此,答案是 false
。
记住:所有整型包装类对象之间值的比较,全部使用 equals 方法比较。
4.自动装箱与拆箱了解吗?原理是什么?
一种语法糖,
- 装箱:将基本类型用它们对应的引用类型包装起来,实际上就是调用了包装类的
Xxx.valueOf()
方法; - 拆箱:将包装类型转换为基本数据类型,实际上就是调用了基本数据类型的
xxxValue()
方法;
如果频繁拆装箱的话,也会严重影响系统的性能。我们应该尽量避免不必要的拆装箱操作。
①自动拆箱引发的NPE(Null Pointer Error 空指针异常)问题
比如说数据库查询的结果可能是NULL,此时对这一结果使用自动拆箱就会导致NPE;
原因分析:
自动拆箱本质上是调用了XxxValue()方法,对NULL调用方法自然会导致空指针
又比如三目运算符condition ? 表达式1 :表达式2中,有两种情况会触发类型对齐导致的自动拆箱:
Ⅰ.若两个表达式中有一个是基本数据类型,则会对另一个表达式进行自动拆箱;
Ⅱ.或是两个表达式的类型不一样,也会将范围更小的表达式强制拆箱并转换为范围更大的类型;
此时若自动拆箱的对象为空,则会导致空指针异常;
示例:
分析:因为0是基本数据类型int,而i是包装类型Integer,故会对i进行自动拆箱为int,又因为i为null,故导致空指针异常
实际开发中应避免三目运算符中的两个表达式触发自动装箱,比如这样写就不会导致NPE:
5.为什么浮点数运算的时候会有精度丢失的风险?如何解决?
为什么会出现这个问题?
因为用二进制是不能精确表示所有小数的,比如十进制的0.2用二进制是不能精确表示的,因为位数有限只能截断,因此会产生精度丢失;
如何解决?
Java提供的BigDecimal
可以实现对浮点数的运算,不会造成精度丢失。通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 BigDecimal
来做的
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("0.9");
BigDecimal c = new BigDecimal("0.8");
BigDecimal x = a.subtract(b);
BigDecimal y = b.subtract(c);
System.out.println(x); /* 0.1 */
System.out.println(y); /* 0.1 */
System.out.println(Objects.equals(x, y)); /* true */
BigDecimal详细使用方法:https://javaguide.cn/java/basis/bigdecimal.html
6.超过 long 整型的数据应该如何表示?
可以使用BigInteger类型来表示,BigInteger
内部使用 int[]
数组来存储任意大小的整形数据。
相对于常规整数类型的运算来说,BigInteger
运算的效率会相对较低;
四、变量
1.成员变量与局部变量的区别?
①语法形式:从语法形式上看,成员变量是属于类的,而局部变量是在代码块或方法中定义的变量或是方法的参数;
成员变量可以被 public
,private
等权限修饰符以及static
所修饰,而局部变量不能被权限修饰符及 static
所修饰;但是,成员变量和局部变量都能被 final
所修饰;
②存储方式:从变量在内存中的存储方式来看,成员变量如果是使用 static
修饰的,则存放于方法区中,如果没有使用 static
修饰,则存放于堆中;
而局部变量全部存放于栈中;
③生存时间:从变量在内存中的生存时间上看,成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动生成,随着方法的调用结束而消亡;
④默认值:从变量是否有默认值来看,成员变量如果没有被赋初始值,则会自动以类型的默认值而赋值(一种情况例外:被 final
修饰的成员变量也必须显式地赋值),而局部变量则不会自动赋值;
2.静态变量有什么作用?
静态变量也就是被 static
关键字修饰的变量。它可以被类的所有实例共享,无论一个类创建了多少个对象,它们都共享同一份静态变量。也就是说,静态变量只会被分配一次内存,即使创建多个对象,这样可以节省内存。
静态变量是通过类名来访问的,例如StaticVariableExample.staticVar
(如果被 private
关键字修饰就无法这样访问了)。
通常情况下,静态变量会被 final
关键字修饰成为常量。
五、方法
1.静态方法为什么不能调用非静态成员?
主要原因如下:
①静态方法是属于类的,在类加载的时候就会分配内存,可以通过类名直接访问。而非静态成员属于实例对象,只有在对象实例化之后才存在,需要通过类的实例对象去访问。
②在类的非静态成员不存在的时候静态方法就已经存在了,此时调用在内存中还不存在的非静态成员,属于非法操作。
2.静态方法和实例方法有何不同?
①调用方式不同:静态方法可以通过类名直接调用,而实例方法只能通过创建的对象实例来调用;
②静态方法内只允许访问静态成员变量和方法,不能访问实例成员变量和方法,而实例方法则没有这样的限制;
3.重载和重写有什么区别?
①重载是发生在同一个类中的,而重写是发生在父类和子类中的;
②重载要求参数列表必须修改,而重写要求参数列表不能修改;
③重载修改返回值、抛出的异常、权限修饰符且没有限制,
而重写要求子类返回值类型、抛出的异常必须比父类更小或相等,访问权限必须比父类更大或相等;(如果方法的返回类型是 void 和基本数据类型,则返回值重写时不可修改)
④重载发生在编译器,而重写发生在运行期;
4.什么是可变长参数?
可变长参数就是允许在调用方法时传入不定长度的参数;
另外,可变参数只能作为函数的最后一个参数,但其前面可以有也可以没有任何其他参数;
public static void method1(String... args) {
//......
}
public static void method2(String arg1, String... args) {
//......
}
遇到方法重载的情况怎么办呢?会优先匹配固定参数还是可变参数的方法呢?
会优先匹配固定参数的方法,因为固定参数的方法匹配度更高;
测试题
public class VariableLengthArgument {
public static void printVariable(String... args) {
for (String s : args) {
System.out.println(s);
}
}
public static void printVariable(String arg1, String arg2) {
System.out.println(arg1 + arg2);
}
public static void main(String[] args) {
printVariable("a", "b");
printVariable("a", "b", "c", "d");
}
}
输出:
ab
a
b
c
d
六、面向对象基础
1.面向对象和面向过程的区别
面向过程把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题;
面向对象会先抽象出对象,然后用对象执行方法的方式解决问题;
面向对象开发的程序一般更易维护、易复用、易扩展;
2.对象实体与对象引用有何不同?
①对象实例在堆内存中,对象引用存放在栈内存中;
②一个对象引用可以指向 0 个或 1 个对象,一个对象可以有 n 个引用指向它;
3.如果一个类没有声明构造方法,该程序能正确执行吗?
可以,因为因为一个类即使没有声明构造方法也会有默认的不带参数的构造方法。
但是如果我们自己添加了类的构造方法(无论是否有参),Java 就不会添加默认的无参数的构造方法了。(所以如果我们重载了有参的构造方法,记得都要把无参的构造方法也写出来)
4.构造方法有哪些特点?是否可被重写?
构造方法特点如下:
- 名字与类名相同。
- 没有返回值,但不能用 void 声明构造函数。
- 生成类的对象时自动执行,无需调用
构造方法不能被 override(重写),但是可以 overload(重载);
5.面向对象三大特征
①封装
封装是指把一个对象的状态信息(也就是属性)隐藏在对象内部,不允许外部对象直接访问对象的内部信息。但是可以提供一些可以被外界访问的方法来操作属性;
②继承
继承是指子类可以通过继承的方式子类拥有父类对象所有的属性和方法(不过父类中的私有属性和方法子类是无法访问,只是拥有),同时子类还可以拥有自己属性和方法;
继承和实现的区别?(也是接口和抽象类的主要区别)
继承体现的是代码复用,实现体现的是共用一套标准接口;
③多态
多态指不同类型的对象对同一方法有着不同的实现,具体表现为父类的引用指向子对象,
作用是既可以调用父类独有的方法,又可以调用子类中重写的方法(属性则只可以调用父类的属性,无法调用子类的属性);
缺点是不能调用子类中独有的方法,想要调用只能使用强制转换向下转型;
- 多态不能调用“只在子类存在但在父类不存在”的方法;
- 如果子类重写了父类的方法,真正执行的是子类覆盖的方法,如果子类没有覆盖父类的方法,执行的是父类的方法
6. instanceof & 向上转型、向下转型
Ⅰ. 向上转型:子类引用可自动转换为父类引用,无需强制转换
——多态性中父类的引用指向子类的实例,实际就是通过隐式转换将对子类对象的引用转换为了父类引用;
——向上转型后可以调用父类中独有的方法,也可以调用子类重写的方法;
Ⅱ. 向下转型:父类引用只能通过强制转换转为子类引用(强制转换前应先通过instanceof做一下判断,避免 ClassCastException
异常);
——如果父类引用最终指向的是父类对象,那么是不允许向下转型的,因为子类有的方法父类不一定有,
——如果父类引用实际指向的是子类对象,那么是可以允许向下转型的,转型后可以调用子类独有的方法了;
Ⅲ. a instanceof A:判断引用a是否指向类A的实例对象,进而判断引用a能否被强制转换为对A类的引用;
总结:只要最终指向的是子类对象,那么在调用子类重写过的方法时,不管是父类引用还是子类引用,都会调用子类中重写后的方法,
区别只在于能否调用父类/子类中独有的方法;
7.接口Interface和抽象类abstract有什么共同点和区别?
共同点:
- 都不能被实例化。
- 都可以包含抽象方法。
- 都可以有默认实现的方法(Java 8 可以用
default
关键字在接口中定义默认方法)
区别:
- 接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系。
- 一个类只能继承一个类,但是可以实现多个接口。
- 接口中的成员变量只能是
public static final
类型的,不能被修改且必须有初始值,而抽象类的成员变量默认 default,可在子类中被重新定义,也可被重新赋值;
8.深拷贝和浅拷贝区别了解吗?什么是引用拷贝?
关于深拷贝和浅拷贝区别,
- 浅拷贝:在堆上创建一个新的对象(区别于引用拷贝的一点),不过,如果原对象内部的属性是引用类型的话,浅拷贝会直接复制内部对象的引用地址,也就是说拷贝对象和原对象共用同一个内部对象。
- 深拷贝:深拷贝会完全新建整个对象,包括这个对象所包含的内部对象;
- 引用拷贝:不新建对象,直接新建一个引用来指向原对象;
浅拷贝案例:只新建了person对象,对于原对象的内部属性Address则是直接引用地址
public class Address implements Cloneable{
private String name;
// 省略构造函数、Getter&Setter方法
@Override
public Address clone() {
try {
return (Address) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}
public class Person implements Cloneable {
private Address address;
// 省略构造函数、Getter&Setter方法
@Override
public Person clone() {
try {
//浅拷贝
Person person = (Person) super.clone();
return person;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}
测试:
Person person1 = new Person(new Address("武汉"));
Person person1Copy = person1.clone();
// true
//从输出结构就可以看出, person1 的克隆对象和 person1 使用的仍然是同一个 Address 对象
System.out.println(person1.getAddress() == person1Copy.getAddress());
深拷贝案例:既新建了对象person,又新建了原对象的内部属性Address
@Override
public Person clone() {
try {
//深拷贝
Person person = (Person) super.clone();
person.setAddress(person.getAddress().clone());
return person;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
测试:
Person person1 = new Person(new Address("武汉"));
Person person1Copy = person1.clone();
// false
//从输出结构就可以看出,显然 person1 的克隆对象和 person1 包含的 Address 对象已经是不同的了
System.out.println(person1.getAddress() == person1Copy.getAddress());
七、Object
1.Object 类的常见方法有哪些?
Object 类是一个特殊的类,是所有类的父类。它主要提供了以下 11 个方法,
常见的如getClass()、hashCode()、equals()、clone()、toString()等;
2.== 和 equals() 的区别
==
对于基本类型和引用类型的作用效果是不同的:
- 对于基本数据类型来说,
==
比较的是值。 - 对于引用数据类型来说,
==
比较的是对象的内存地址
equals()
方法存在两种使用情况:
- 类没有重写
equals()
方法:通过equals()
比较该类的两个对象时,等价于通过“==”比较这两个对象,使用的默认是Object
类equals()
方法。 - 类重写了
equals()
方法:一般我们都重写equals()
方法来比较两个对象中的属性是否相等;若它们的属性相等,则返回 true(即,认为这两个对象相等)
Object
类 equals()
方法:
public boolean equals(Object obj) {
return (this == obj);
}
3.hashCode() 有什么用?/为什么要有 hashCode?
hashCode()
的作用是获取哈希码(int
整数),也称为散列码。这个哈希码的作用是确定该对象在哈希表中的索引位置,
其实, hashCode()
和 equals()
都是用于比较两个对象是否相等;
为什么 JDK 还要同时提供这两个方法呢?
因为在一些容器(比如 HashMap
、HashSet
)中,有了 hashCode()
之后,判断元素是否在对应容器中的效率比equals()会更高;
那为什么不只提供 hashCode()
方法呢?
因为两个对象的hashCode
值相等并不代表两个对象就相等,是有可能发生哈希碰撞的,即两个不同的对象经过哈希算法得到相同的哈希值;
总结下来就是:
- 如果两个对象的
hashCode
值相等,那这两个对象不一定相等(哈希碰撞)。 - 如果两个对象的
hashCode
值相等并且equals()
方法也返回true
,我们才认为这两个对象相等。 - 如果两个对象的
hashCode
值不相等,我们就可以直接认为这两个对象不相等
4.为什么重写 equals() 时必须重写 hashCode() 方法?
因为如果 equals
方法判断两个对象是相等的,那这两个对象的 hashCode
值也要相等,
如果重写 equals()
时没有重写 hashCode()
方法的话就可能会导致 equals
方法判断是相等的两个对象,hashCode
值却不相等;
八、String
1.String、StringBuffer、StringBuilder 的区别?
①可变性
String
是不可变的;
StringBuilder
与 StringBuffer
都是可变的;
②线程安全性
String
中的对象是不可变的,也就可以理解为常量,线程安全;
StringBuffer
对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的;
StringBuilder
并没有对方法进行加同步锁,所以是非线程安全的;
③性能
每次对 String
类型进行改变的时候,都会生成一个新的 String
对象,然后将指针指向新的 String
对象。
StringBuffer
每次都会对 StringBuffer
对象本身进行操作,而不是生成新的对象并改变对象引用。
相同情况下使用 StringBuilder
相比使用 StringBuffer
仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险;
对于三者使用的总结:
- 操作少量的数据: 适用
String
- 单线程操作字符串缓冲区下操作大量数据: 适用
StringBuilder
- 多线程操作字符串缓冲区下操作大量数据: 适用
StringBuffer
2.String 为什么是不可变的?
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
private final char value[];
//...
}
- String类内部的保存字符串的数组被
final
修饰且为私有的,并且String
类没有提供/暴露修改这个数组的方法。 String
类本身也被final
修饰导致其不能被继承,进而避免了子类破坏String
不可变
在 Java 9 之后,String
、StringBuilder
与 StringBuffer
的实现改用 byte
数组存储字符串
public final class String implements java.io.Serializable,Comparable<String>, CharSequence {
// @Stable 注解表示变量最多被修改一次,称为“稳定的”。
@Stable
private final byte[] value;
}
abstract class AbstractStringBuilder implements Appendable, CharSequence {
byte[] value;
}
Java 9 为何要将 String
的底层实现由 char[]
改成了 byte[]
?
答:新版的 String 其实支持两个编码方案:Latin-1 和 UTF-16。如果字符串中包含的汉字没有超过 Latin-1 可表示范围内的字符,那就会使用 Latin-1 作为编码方案。
Latin-1 编码方案下,byte
占一个字节(8 位),char
占用 2 个字节(16),byte
相较 char
节省一半的内存空间;
如果字符串中包含的汉字超过 Latin-1 可表示范围内的字符,则采用UTF-16编码方案,此时byte
和 char
所占用的空间是一样的;
3.字符串拼接用“+” 还是 StringBuilder?
字符串对象通过“+”的字符串拼接方式,实际上是通过 StringBuilder
调用 append()
方法实现的,拼接完成之后调用 toString()
得到一个 String
对象;
不过,在循环内使用“+”进行字符串的拼接的话,存在比较明显的缺陷:编译器不会创建单个 StringBuilder
以复用,会导致创建过多的 StringBuilder
对象,
如果直接使用 StringBuilder
对象进行字符串拼接的话,就不会存在这个问题了,
不过,使用 “+” 进行字符串拼接会产生大量的临时对象的问题在 JDK9 中得到了解决。在 JDK9 当中,字符串相加 “+” 改为了用动态方法 makeConcatWithConstants()
来实现,而不是大量的 StringBuilder
了。这个改进是 JDK9 的 JEP 280 提出的,这也意味着 JDK 9 之后,你可以放心使用“+” 进行字符串拼接了;
4.String#equals() 和 Object#equals() 有何区别?
String
中的 equals
方法是被重写过的,比较的是 String 字符串的值是否相等。 Object
的 equals
方法是比较的对象的内存地址
5.字符串常量池的作用了解吗?
字符串常量池 主要用于存储对字符串常量的引用,是 JVM 为了提升性能和减少内存消耗针对字符串专门在堆中开辟的一块区域;
(字符串常量,或者叫做字符串字面量,可以看作是一种特殊的对象,它属于引用类型,但不需要使用new 关键字来创建,
只要以"ab"这样的形式首次出现,就会触发在堆中新建一个字符串对象,并将对其的引用放入StringTable中,之后再次以字面量形式出现时,就无需重复创建对象,直接从StringTable返回对其的引用即可)
①String s ="ab"这行代码背后做了什么?
首先,在StringTable中查看是否有内容和"ab"相同的String引用,
若有则直接将该引用返回给s;
若没有,则在堆中创建一个"ab"对象,然后在StringTable中驻留这个对象的引用,并将这个引用返回给s;
②String s = new String("ab")这行代码背后做了什么?(创建了几个字符串对象——1或2个)
首先,在StringTable中查看是否有内容和"ab"相同的String引用,
若没有,则在堆中创建一个"ab"对象,然后在StringTable中驻留这个对象的引用;
若有则直接执行下一步;
然后在堆中再创建一个"ab"对象,并将引用返回给s;
测试题:
@Test
public void test0(){
String s1 = "a";
// true————s1是字符串常量池中的引用,"a"也是
System.out.println(s1 == "a");
String s2 = new String("b");
//false————s2是对堆中新建的String对象的引用,而"b"是字符串常量池中的引用
System.out.println(s2 == "b");
// 结论:只要出现new就会在堆中新建一个对象,并返回其引用
// 只要出现内容不和字符串常量池中任何引用指向的字符串匹配的字符串常量,也会新建一个对象,并将其引用存入字符串常量池
// 只有当出现内容和字符串常量池中某个引用指向的字符串相匹配的字符串常量,才会不新建对象,直接返回引用
String s3 = new String("a");
// false————有new,新建对象返回引用给s3,"a"是字符串常量池中的引用
System.out.println(s3 == "a");
}
③字符串常量拼接与字符串变量拼接
测试题:
@Test
public void test1(){
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = "a" + "b";
// true————字符串常量拼接会在编译时期就完成,s4="a"+"b"完全等价于s4="ab",自然返回的引用也相等
System.out.println(s3 == s4);
String s5 = s1 + s2;
// false————字符串变量拼接通过新建StringBuilder,再进行append(),最后执行toString()来实现,s5是一个全新的对象,不同于s3是字符串常量池中的引用
System.out.println(s3 == s5);
}
④intern()方法
对一个字符串对象使用intern()方法,其作用是:
若StringTable中已经存在内容和该对象相同的String引用,则直接返回StringTable中的引用;
若不存在,则直接将对该对象的引用添加到StringTable中(不会创建新的对象),返回值为对该对象的引用本身;
测试题:
@Test
public void test2(){
String s1 = "ab";
//对"ab"的引用已存在于字符串常量池中
String s2 = s1.intern();
//true——s1和s2都是字符串常量池中的引用
System.out.println(s1 == s2);
String s3 = new String("cd");
//对"ab"的引用已存在于字符串常量池中
String s4 = s3.intern();
//fasle————s3是对堆中新建的内容为"ef"的字符串的引用,s4则是字符串常量池中的引用
System.out.println(s3 == s4);
String s5 = new String("e") + new String("f");
//由字符串变量的拼接可知,对拼接结果"ef"的引用不存在于字符串常量池中,故此时直接将s5放入字符串常量池中(不新建对象),同时将s5作为返回值
String s6 = s5.intern();
//true
System.out.println(s5 == s6);
//true
System.out.println(s5 == "ef");
}
注意:
纠正一个理解偏差——似乎只有String s = "ab"才会导致新建一个字符串对象,并将对其的引用放入StringTable中?
但实际上String s = new String("ab")代码执行过程中也包含这一步骤,并在之后再在堆中新建一个内容为"ab"的对象并返回对其的引用;
——换言之,只要是首次以字面量的形式出现,就会导致新建一个字符串对象,并将对其的引用放入StringTable中,不论是赋值还是作为参数传入,
只有未曾以字面量形式出现的字符串,才会在StringTable中找不到对其的引用;
例如String s = new String("e") + new String("f"),只会导致"e"和"f"被放入StringTable中,而"ef"虽然在堆中创建了对象,但对其的引用并未放入StringTable中;
@Test
public void test3(){
String s1 = new String("e") + new String("f");
//由字符串变量的拼接可知,对拼接结果"ef"的引用不存在于字符串常量池中,故此时直接将s5放入字符串常量池中(不新建对象),同时将s5作为返回值
String s2 = s1.intern();
//true
System.out.println(s1 == s2);
String s3 = new String("gh");
String s4 = new String("g") + new String("h");
//"gh"已经以字面量形式出现过,所以在执行本条语句时StringTable中已经存在对"gh"的引用,直接返回引用
String s5 = s3.intern();
//false
System.out.println(s5 == s4);
}
9.几道测试题
①空字符串的拼接和打印
题目:
结果:
nullnull
分析:
public class Demo {
private static String s1;
private static String s2;
public static void main(String[] args) {
//null
System.out.println(s1);
//nullnull
System.out.println(s1 + s2);
}
}
Ⅰ.首先,所有未进行初始化的引用类型的默认值为null;
Ⅱ.对于为null的字符串(不是"null"),打印结果也会是null;
Ⅲ.对于为null的字符串的拼接,StringBuilder会将其转换为"null",再进行append();
②如何改变一个String变量的值(不通过创建新的String变量来实现)
String具有不可变性,常规方法无法改变,但可以通过反射直接对底层存储字符串的char型数组(jdk8之前是char[],jdk9之后就变成了byte[])进行修改;
九、异常
1.Exception 和 Error 有什么区别?
在 Java 中,所有的异常都有一个共同的祖先 java.lang
包中的 Throwable
类。Throwable
类有两个重要的子类:
Exception
:程序本身可以处理的异常,可以通过 catch
来进行捕获。Exception
又可以分为 Checked Exception (受检查异常,必须处理) 和 Unchecked Exception (不受检查异常,可以不处理);
Error
:Error
属于程序无法处理的错误 ,不建议通过catch
捕获 。例如 Java 虚拟机运行错误(Virtual MachineError
)、虚拟机内存不够错误(OutOfMemoryError
)、类定义错误(NoClassDefFoundError
)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止;
2.Checked Exception 和 Unchecked Exception 有什么区别?
Checked Exception 即 受检查异常 ,Java 代码在编译过程中,如果受检查异常没有被 catch
或者throws
关键字处理的话,就没办法通过编译;
除了RuntimeException
及其子类以外,其他的Exception
类及其子类都属于受检查异常 。常见的受检查异常有:IO 相关的异常、ClassNotFoundException
、SQLException
;
Unchecked Exception 即 不受检查异常 ,Java 代码在编译过程中 ,我们即使不处理不受检查异常也可以正常通过编译,
RuntimeException
及其子类都统称为非受检查异常,常见的有(建议记下来,日常开发中会经常用到):
NullPointerException
(空指针错误)IllegalArgumentException
(参数错误比如方法入参类型错误)NumberFormatException
(字符串转换为数字格式错误,IllegalArgumentException
的子类)ArrayIndexOutOfBoundsException
(数组越界错误)ClassCastException
(类型转换错误)ArithmeticException
(算术错误)SecurityException
(安全错误比如权限不够)UnsupportedOperationException
(不支持的操作错误比如重复创建同一用户)
3.Throwable 类常用方法有哪些?
String getMessage()
: 返回异常发生时的简要描述String toString()
: 返回异常发生时的详细信息String getLocalizedMessage()
: 返回异常对象的本地化信息。使用Throwable
的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与getMessage()
返回的结果相同void printStackTrace()
: 在控制台上打印Throwable
对象封装的异常信息
4.try-catch-finally 如何使用?
try
块:用于捕获异常。其后可接零个或多个catch
块,如果没有catch
块,则必须跟一个finally
块。catch
块:用于处理 try 捕获到的异常。finally
块:无论是否捕获或处理异常,finally
块里的语句都会被执行。当在try
块或catch
块中遇到return
语句时,finally
语句块将在方法返回之前被执行
注意:不要在 finally 语句块中使用 return! 当 try 语句和 finally 语句中都有 return 语句时,try 语句块中的 return 语句会被忽略。这是因为 try 语句中的 return 返回值会先被暂存在一个本地变量中,当执行到 finally 语句中的 return 之后,这个本地变量的值就变为了 finally 语句中的 return 返回值;
public static void main(String[] args) {
System.out.println(f(2)); //会输出0而不是4
}
public static int f(int value) {
try {
return value * value;
} finally {
if (value == 2) {
return 0;
}
}
}
5.finally 中的代码一定会执行吗?
不一定的!在某些情况下,finally 中的代码不会被执行。
就比如说 finally 之前虚拟机被终止运行的话,finally 中的代码就不会被执行;
另外,在以下 2 种特殊情况下,finally
块的代码也不会被执行:
- 程序所在的线程死亡。
- 关闭 CPU。
6.如何使用 try-with-resources
代替try-catch-finally
?
- 适用范围(资源的定义): 任何实现
java.lang.AutoCloseable
或者java.io.Closeable
的对象 - 关闭资源和 finally 块的执行顺序: 在
try-with-resources
语句中,任何 catch 或 finally 块在声明的资源关闭后运行;
Java 中类似于InputStream
、OutputStream
、Scanner
、PrintWriter
等的资源都需要我们调用close()
方法来手动关闭,一般情况下我们都是通过try-catch-finally
语句来实现这个需求,如下:
//读取文本文件的内容
Scanner scanner = null;
try {
scanner = new Scanner(new File("D://read.txt"));
while (scanner.hasNext()) {
System.out.println(scanner.nextLine());
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
if (scanner != null) {
scanner.close();
}
}
使用 Java 7 之后的 try-with-resources
语句改造上面的代码:
try (Scanner scanner = new Scanner(new File("test.txt"))) {
while (scanner.hasNext()) {
System.out.println(scanner.nextLine());
}
} catch (FileNotFoundException fnfe) {
fnfe.printStackTrace();
}
当然多个资源需要关闭的时候,使用 try-with-resources
实现起来也非常简单,如果你还是用try-catch-finally
可能会带来很多问题。
通过使用分号分隔,可以在try-with-resources
块中声明多个资源:
try (BufferedInputStream bin = new BufferedInputStream(new FileInputStream(new File("test.txt")));
BufferedOutputStream bout = new BufferedOutputStream(new FileOutputStream(new File("out.txt")))) {
int b;
while ((b = bin.read()) != -1) {
bout.write(b);
}
}
catch (IOException e) {
e.printStackTrace();
}
7.异常使用有哪些需要注意的地方?
- 不要把异常定义为静态变量,因为这样会导致异常栈信息错乱。每次手动抛出异常,我们都需要手动 new 一个异常对象抛出。
- 抛出的异常信息一定要有意义。
- 建议抛出更加具体的异常比如字符串转换为数字格式错误的时候应该抛出
NumberFormatException
而不是其父类IllegalArgumentException
。 - 使用日志打印异常之后就不要再抛出异常了(两者不要同时存在一段代码逻辑中)
8. Throws和try-catch的区别?
throws + 异常类型 写在方法的声明处。指明此方法执行时,可能会抛出的异常类型。一旦方法体执行时,出现异常,仍会在异常代码处生成一个异常类的对象,此对象满足thorws后异常类型时,就会被抛出。异常代码后续的代码就不执行;
throws()的方式只是将异常抛给了方法的调用者。并没有真正将异常处理掉,而try-catch-finally才是真正的将异常给处理掉了;
十、泛型
1.什么是泛型?有什么作用?
Java 泛型(Generics) 是 JDK 5 中引入的一个新特性。使用泛型参数,可以增强代码的可读性以及稳定性。
编译器可以对泛型参数进行检测,并且通过泛型参数可以指定传入的对象类型。
比如 ArrayList<Person> persons = new ArrayList<Person>()
这行代码就指明了该 ArrayList
对象只能传入 Person
对象,如果传入其他类型的对象就会报错。
并且,原生 List
返回类型是 Object
,需要手动转换类型才能使用,使用泛型后编译器自动转换;
2.泛型的使用方式有哪几种?
泛型一般有三种使用方式:泛型类、泛型接口、泛型方法;
①泛型类:
//此处T可以随便写为任意标识,常见的如T、E、K、V等形式的参数常用于表示泛型
//在实例化泛型类时,必须指定T的具体类型
public class Generic<T>{
private T key;
public Generic(T key) {
this.key = key;
}
public T getKey(){
return key;
}
}
如何实例化泛型类:
Generic<Integer> genericInteger = new Generic<Integer>(123456);
②泛型接口:
public interface Generator<T> {
public T method();
}
实现泛型接口,不指定类型:
class GeneratorImpl<T> implements Generator<T>{
@Override
public T method() {
return null;
}
}
实现泛型接口,指定类型:
class GeneratorImpl<T> implements Generator<String>{
@Override
public String method() {
return "hello";
}
}
③泛型方法:
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 );
注意: public static < E > void printArray( E[] inputArray )
一般被称为静态泛型方法;在 java 中泛型只是一个占位符,必须在传递类型后才能使用。类在实例化时才能真正的传递类型参数,由于静态方法的加载先于类的实例化,也就是说类中的泛型还没有传递真正的类型参数,静态的方法的加载就已经完成了,所以静态泛型方法是没有办法使用类上声明的泛型的。只能使用自己声明的泛型 <E>
3.什么是泛型擦除机制?为什么要擦除?
泛型擦除指的是Java在编译期间,编译器会动态地将泛型T擦除为Object,或将T extends xxx擦除为其限定类型xxx;
原因是为了保证引入泛型机制但不创建新的类型,减少虚拟机的运行开销,编译器通过擦除将泛型类转换为一般类;
4.什么是桥方法?
5.泛型使用有哪些限制?
例如:
6.什么是通配符?和泛型有什么区别?
7.通配符的种类
①无界通配符
②上边界通配符
③下边界通配符
十一、反射
1.何谓反射?
通过反射可以在运行时获取任意一个类的所有属性和方法,并且还可以调用这些方法和属性;
2.反射机制的优缺点
优点:可以让咱们的代码更加灵活、为各种框架提供开箱即用的功能提供了便利
缺点:让我们在运行时有了分析操作类的能力,这同样也增加了安全问题。比如可以无视泛型参数的安全检查(泛型参数的安全检查发生在编译时)。另外,反射的性能也要稍差点,不过,对于框架来说实际是影响不大的;
3.反射的应用场景?
①Spring/Spring Boot、MyBatis 等等框架中都大量使用了反射机制,这些框架中也大量使用了动态代理,而动态代理的实现也依赖反射;
②注解 的实现也用到了反射;
这些都是因为你可以基于反射分析类,然后获取到类/属性/方法/方法的参数上的注解。你获取到注解之后,就可以做进一步的处理;
4.获取Class的实例的方式(前三种方式需要掌握)
public class ReflectionTest {
//获取Class的实例的方式(前三种方式需要掌握)
@Test
public void test3() throws ClassNotFoundException {
//方式一:调用运行时类的属性:.class
Class clazz1 = Person.class;
System.out.println(clazz1);//class com.atguigu.java.Person
//方式二:通过运行时类的对象,调用getClass()
Person p1 = new Person();
Class clazz2 = p1.getClass();
System.out.println(clazz2);//class com.atguigu.java.Person
//方式三:调用Class的静态方法:forName(String classPath)————最常用的一种方法
Class clazz3 = Class.forName("com.atguigu.java.Person");
System.out.println(clazz3);//class com.atguigu.java.Person
//加载到内存中的运行时类,会缓存一定的时间。在此时间之内,我们可以通过不同的方式来获取此运行时类
System.out.println(clazz1 == clazz2);//true
System.out.println(clazz1 == clazz3);//true
//方式四:使用类的加载器:ClassLoader (了解)
ClassLoader classLoader = ReflectionTest.class.getClassLoader();
Class clazz4 = classLoader.loadClass("com.atguigu.java.Person");
System.out.println(clazz4);//class com.atguigu.java.Person
System.out.println(clazz1 == clazz4);//true
}
}
5.反射常用方法
getFields():获取当前运行时类及其父类中声明为public访问权限的属性;
getDeclaredFields():获取当前运行时类中声明的所有属性。(不包含父类中声明的属性);
getMethods():获取当前运行时类及其所有父类中声明为public权限的方法;
getConstructors():获取当前运行时类中声明为public的构造器;
十二、注解
1.注解的解析方法有哪几种?
注解只有被解析之后才会生效,常见的解析方法有两种:
- 编译期直接扫描:编译器在编译 Java 代码的时候扫描对应的注解并处理,比如某个方法使用
@Override
注解,编译器在编译的时候就会检测当前的方法是否重写了父类对应的方法。 - 运行期通过反射处理:像框架中自带的注解(比如 Spring 框架的
@Value
、@Component
)都是通过反射来进行处理的。
十三、SPI——理解的不是很深刻
1.何谓 SPI?
SPI 即 Service Provider Interface ,字面意思就是:“服务提供者的接口”,我的理解是:专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。
SPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。
很多框架都使用了 Java 的 SPI 机制,比如:Spring 框架、数据库加载驱动、日志接口、以及 Dubbo 的扩展实现等等;
2.SPI 和 API 有什么区别?
当接口和实现都是放在实现方时,就是 API,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力 ;
而当接口存在于调用方这边时,就是 SPI ,由接口调用方确定接口规则,然后由不同的厂商去根据这个规则对这个接口进行实现,从而提供服务;
3.SPI 的优缺点?
通过 SPI 机制能够大大地提高接口设计的灵活性,但是 SPI 机制也存在一些缺点,比如:
- 需要遍历加载所有的实现类,不能做到按需加载,这样效率还是相对较低的。
- 当多个
ServiceLoader
同时load
时,会有并发问题
十四、序列化和反序列化
1.什么是序列化?什么是反序列化?
- 序列化:将数据结构或对象转换成二进制字节流的过程,序列化的目的是将对象存储到文件系统、数据库、内存中;
- 反序列化:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程;
常见应用场景:
- 对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化;
- 将对象存储到文件之前需要进行序列化,将对象从文件中读取出来需要进行反序列化;
- 将对象存储到数据库(如 Redis)之前需要用到序列化,将对象从缓存数据库中读取出来需要反序列化;
- 将对象存储到内存之前需要进行序列化,从内存中读取出来之后需要进行反序列化。
2.序列化协议对应于 TCP/IP 4 层模型的哪一层?
序列化协议属于OSI 七层协议模型中的表示层,也是 TCP/IP 协议应用层的一部分;
(OSI 七层协议模型中的应用层、表示层和会话层对应的都是 TCP/IP 四层模型中的应用层)
3.如果有些字段不想进行序列化怎么办?
对于不想进行序列化的变量,使用 transient
关键字修饰;
transient
关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient
修饰的变量值不会被持久化和恢复。
关于 transient
还有几点注意:
transient
只能修饰变量,不能修饰类和方法。transient
修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰int
类型,那么反序列后结果就是0
。static
变量因为不属于任何对象(Object),所以无论有没有transient
关键字修饰,均不会被序列化
4.常见序列化协议有哪些?
JDK 自带的序列化方式一般不会用 ,因为序列化效率低并且存在安全问题。
比较常用的序列化协议有 Kryo(推荐使用)、Hessian、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议
5.为什么不推荐使用 JDK 自带的序列化?
- 不支持跨语言调用 : 如果调用的是其他语言开发的服务的时候就不支持了。
- 性能差:相比于其他序列化框架性能更低,主要原因是序列化之后的字节数组体积较大,导致传输成本加大。
- 存在安全问题:序列化和反序列化本身并不存在问题。但因为输入的反序列化的数据可被用户控制,所以攻击者即可通过构造恶意输入,让反序列化产生非预期的对象,在此过程中执行构造的任意代码;
十五、I/O
1.什么是Java IO 流?
IO 即 Input/Output
,输入和输出。数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出,
根据数据的处理方式又分为字节流和字符流;
——输入属于反序列化,输出属于序列化,而用于序列化和反序列化的类必须实现 Serializable
接口,对象中如果有属性不想被序列化,使用 transient
修饰
2.I/O 流为什么要分为字节流和字符流呢?
不管是文件读写还是网络发送接收,信息的最小存储单元都是字节,那为什么 I/O 流操作要分为字节流操作和字符流操作呢?
主要有两点原因:
- 如果我们不知道编码类型的话,使用字节流的过程中很容易出现乱码问题;
- 字符流的问题在于它是由 Java 虚拟机将字节转换得到的,这个过程是比较耗时的;
——音频文件、图片等媒体文件用字节流(InputStream/OutputStream)比较好,如果涉及到字符的话(例如中文)使用字符流(Reader/Writer)比较好;
Java IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。
InputStream
/Reader
: 所有的输入流的基类,前者是字节输入流,后者是字符输入流;OutputStream
/Writer
: 所有输出流的基类,前者是字节输出流,后者是字符输出流;
字符流默认采用的是 Unicode
编码,我们可以通过构造方法自定义编码。
顺便分享一下之前遇到的笔试题:常用字符编码所占字节数?utf8
:英文占 1 字节,中文占 3 字节,unicode
:任何字符都占 2 个字节,gbk
:英文占 1 字节,中文占 2 字节;
3.常用操作
字节流:FileInputStream/FileOutputStream、DataInputStream/DataOutputStream、ObjectInputStream/ObjectOutputStream;
字符流:FileReader/FileWriter、InputStreamReader/InputStreamWriter;
因为IO 操作是很消耗性能的,缓冲流将数据加载至缓冲区,一次性读取/写入多个字节,从而避免频繁的 IO 操作,提高流的传输效率,
所以推荐使用BufferedInputStream/BufferedOutputStream、BufferedReader/BufferedWriter
BufferedInputStream/BufferedOutputStream示例
@Test
void copy_pdf_to_another_pdf_with_byte_array_buffer_stream() {
// 记录开始时间
long start = System.currentTimeMillis();
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("深入理解计算机操作系统.pdf"));
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("深入理解计算机操作系统-副本.pdf"))) {
int len;
byte[] bytes = new byte[4 * 1024];
while ((len = bis.read(bytes)) != -1) {
bos.write(bytes, 0, len);
}
} catch (IOException e) {
e.printStackTrace();
}
// 记录结束时间
long end = System.currentTimeMillis();
System.out.println("使用缓冲流复制PDF文件总耗时:" + (end - start) + " 毫秒");
}
@Test
void copy_pdf_to_another_pdf_with_byte_array_stream() {
// 记录开始时间
long start = System.currentTimeMillis();
try (FileInputStream fis = new FileInputStream("深入理解计算机操作系统.pdf");
FileOutputStream fos = new FileOutputStream("深入理解计算机操作系统-副本.pdf")) {
int len;
byte[] bytes = new byte[4 * 1024];
while ((len = fis.read(bytes)) != -1) {
fos.write(bytes, 0, len);
}
} catch (IOException e) {
e.printStackTrace();
}
// 记录结束时间
long end = System.currentTimeMillis();
System.out.println("使用普通流复制PDF文件总耗时:" + (end - start) + " 毫秒");
}
4.打印流
System.out.print("Hello!");
System.out.println("Hello!");
System.out
实际是用于获取一个 PrintStream
对象,print
方法实际调用的是 PrintStream
对象的 write
方法;
PrintStream
属于字节打印流,与之对应的是 PrintWriter
(字符打印流)。PrintStream
是 OutputStream
的子类,PrintWriter
是 Writer
的子类;
5.随机访问流
这里要介绍的随机访问流指的是支持随意跳转到文件的任意位置进行读写的 RandomAccessFile
基本操作见:https://javaguide.cn/java/io/io-basis.html#%E9%9A%8F%E6%9C%BA%E8%AE%BF%E9%97%AE%E6%B5%81
RandomAccessFile
比较常见的一个应用就是实现大文件的 断点续传;
这就涉及到一个系统设计/场景题:如何解决大文件上传问题,详见:https://www.yuque.com/snailclimb/mf2z3k/akmquq
6.Java IO 中的设计模式有哪些?
①装饰器模式
详细讲解:https://zhuanlan.zhihu.com/p/444298983
通过组合替代继承来扩展原始类的功能,在一些继承关系比较复杂的场景更加实用,可以降低代码的耦合度;
装饰器类需要跟原始类继承相同的抽象类或者实现相同的接口,通过将原始类作为装饰器类的属性和构造器的参数,实现非继承关系下对原始类的功能的继承与扩展;
例如在Java IO中,BufferedInputStream和FileInputStream都继承自抽象类InputStream,前者通过将后者作为属性和构造器参数,实现了非继承关系下的功能的继承与扩展;
除此之外,还有ZipInputStream对BufferedInputStream的功能扩展(二者也都继承自抽象类InputStream);
BufferedInputStream和FileInputStream
public abstract class InputStream implements Closeable {
...
}
class FileInputStream extends InputStream{
...
}
public
class FilterInputStream extends InputStream {
protected volatile InputStream in;
protected FilterInputStream(InputStream in) {
this.in = in;
}
...
}
public
class BufferedInputStream extends FilterInputStream {
protected volatile byte buf[];
private static int DEFAULT_BUFFER_SIZE = 8192;
public BufferedInputStream(InputStream in) {
this(in, DEFAULT_BUFFER_SIZE);
}
public BufferedInputStream(InputStream in, int size) {
super(in);
if (size <= 0) {
throw new IllegalArgumentException("Buffer size <= 0");
}
buf = new byte[size];
}
}
②适配者模式
主要用于接口互不兼容的类的协调工作;
适配器模式中存在被适配的对象或者类称为 适配者(Adaptee) ,作用于适配者的对象或者类称为适配器(Adapter) ;
例如InputStreamReader
和 OutputStreamWriter
就是两个适配器(Adapter),
InputStreamReader
使用 StreamDecoder
(流解码器)对字节进行解码,实现字节流到字符流的转换, OutputStreamWriter
使用StreamEncoder
(流编码器)对字符进行编码,实现字符流到字节流的转换;
// InputStreamReader 是适配器,FileInputStream 是被适配的类
InputStreamReader isr = new InputStreamReader(new FileInputStream(fileName), "UTF-8");
// BufferedReader 增强 InputStreamReader 的功能(装饰器模式)
BufferedReader bufferedReader = new BufferedReader(isr);
public class InputStreamReader extends Reader {
//用于解码的对象
private final StreamDecoder sd;
public InputStreamReader(InputStream in) {
super(in);
try {
// 获取 StreamDecoder 对象
sd = StreamDecoder.forInputStreamReader(in, this, (String)null);
} catch (UnsupportedEncodingException e) {
throw new Error(e);
}
}
// 使用 StreamDecoder 对象做具体的读取工作
public int read() throws IOException {
return sd.read();
}
}
装饰器模式与适配器模式的区别?
Ⅰ.作用不同,前者用于实现非继承关系下对原始类的功能的继承与扩展,后者用于实现接口互不兼容的类的转换;
Ⅱ.要求不同,装饰器类需要跟原始类继承相同的抽象类或者实现相同的接口,而适配器和适配者两者不需要继承相同的抽象类或者实现相同的接口;
③工厂模式
就是用工厂方法替代new方法,将对象的创建交给专门的工厂类负责,实现了对象的创建和对象的使用分离,降低代码耦合度;
比如 Files
类的 newInputStream
方法用于创建 InputStream
对象(静态工厂)
InputStream is = Files.newInputStream(Paths.get(generatorLogoPath))
④观察者模式/发布-订阅模式
观察者(Observer)模式中包含两种对象,分别是目标对象和观察者对象。
目标对象和观察者对象间是一对多的对应关系,一个目标可以被多个观察者观察,当这个目标对象的状态发生变化时,所有依赖于它的观察者对象都会得到通知并执行它们各自特有的行为;
(换言之,订阅表示这些观察者对象需要向目标对象进行注册,这样目标对象才知道有哪些对象在观察它。发布指的是当目标对象的状态改变时,它就向它所有的观察者对象发布状态更改的消息,以让这些观察者对象知晓)
例如NIO 中的文件目录监听服务使用到了观察者模式;
7.Java 网络 IO 模型
①BIO/NIO/AIO的区别?
Ⅰ. BIO:Blocking I/O,即同步阻塞I/O
特点是发起IO系统调用的用户线程,在系统内核从准备数据到将数据拷贝到用户空间期间,都必须阻塞等待,IO操作完成后此线程才能进行下一步操作;
同步阻塞IO并发能力不强,只适用于连接数比较小的架构;
Ⅱ. NIO:Non-Blocking I/O,即同步非阻塞IO(通常我们说的NIO就是指IO多路复用)
特点是发起IO系统调用的用户线程不会阻塞等待,而是立即返回,并以轮询的方式不断向系统内核请求获取结果;
这种方式的缺点是频繁的轮询导致频繁的系统调用,其中大部分都是无效的,空费大量CPU资源;
为了省去这些无效的系统调用,又引入了IO多路复用,
特点是用户线程在发起IO系统调用之后不会阻塞等待,而是通过Selector来监听多个Channel的状态,当Channel中发生了感兴趣的IO事件时,Selector才会通知用户程序对事件进行处理,
又因为一个Selector可以管理多个Channel,所以可以实现一个线程管理多个客户端连接;
IO多路复用适用于连接有大量非活跃连接的场景,如高性能网络服务器(客户端没有使用的必要);
Ⅲ. AIO:Asynchronous I/O,即异步IO
特点是用户线程发起IO系统调用之后会立刻返回,待后台数据处理完成之后,再通知用户线程直接获取已经处理好的数据;
异步IO适用于连接数较多且连接时间较长的场景;
(不过目前Linux内核尚不支持原生的AIO,只能通过JVM在用户态线程中模拟异步操作,无法避免线程上下文切换和内核数据拷贝,因此性能不比NIO提高多少)
②NIO——IO多路复用是如何实现的?
(NIO的应用场景主要有Jetty、Mina、Netty、ZooKeeper等网络编程框架)
参考文章:NIO - IO多路复用详解
NIO底层主要通过Selector、Channel、和Buffer来实现:
Ⅰ. Channel——负责连接
Channel是一个连接了应用程序和操作系统的通道,可以用于交互事件、进行IO操作;
在JAVA的NIO框架中,常用的Channel通道有:
SocketChannel:TCP Socket套接字的监听通道,一个Socket套接字对应了一个客户端IP:端口到服务器IP:端口的通信连接;
ServerSocketChannel:应用服务器程序的监听通道;
DatagramChannel:UDP数据报文的监听通道;
Ⅱ. Buffer——负责数据的存取
用于保证每个通道的数据读写速度的数据缓存区,NIO框架为每个支持数据读写的Channel都集成了Buffer;
Ⅲ. Selector——监控Channel
Selector是一个用于监控Channel的事件选择器,其主要作用有:
事件订阅和Channel管理:应用程序可以向Selector注册它需要关注的Channel,以及每个Channel会对哪些IO事件感兴趣;
轮询代理:应用程序不需要直接询问操作系统事件是否发生,而是由Selector代其询问;
用户线程在发起IO系统调用之后,通过Selector来监听多个Channel的状态,当Channel中发生了感兴趣的IO事件时,Selector才会通知用户程序发起read系统调用来进行读写操作;
8.NIO零拷贝——零拷贝应该是操作系统的知识,待深入学习
零拷贝是指计算机执行 IO 操作时,CPU 不需要将数据从一个存储区域复制到另一个存储区域,从而可以减少上下文切换以及 CPU 的拷贝时间。也就是说,零拷贝主主要解决操作系统在处理 I/O 操作时频繁复制数据的问题。零拷贝的常见实现技术有: mmap+write
、sendfile
和 sendfile + DMA gather copy
下图展示了各种零拷贝技术的对比图:
可以看出,无论是传统的 I/O 方式,还是引入了零拷贝之后,2 次 DMA(Direct Memory Access) 拷贝是都少不了的。因为两次 DMA 都是依赖硬件完成的。零拷贝主要是减少了 CPU 拷贝及上下文的切换;
Java 对零拷贝的支持:
MappedByteBuffer
是 NIO 基于内存映射(mmap
)这种零拷⻉⽅式的提供的⼀种实现,底层实际是调用了 Linux 内核的mmap
系统调用。它可以将一个文件或者文件的一部分映射到内存中,形成一个虚拟内存文件,这样就可以直接操作内存中的数据,而不需要通过系统调用来读写文件;FileChannel
的transferTo()/transferFrom()
是 NIO 基于发送文件(sendfile
)这种零拷贝方式的提供的一种实现,底层实际是调用了 Linux 内核的sendfile
系统调用。它可以直接将文件数据从磁盘发送到网络,而不需要经过用户空间的缓冲区;
十六、语法糖
1.什么是语法糖?
语法糖(Syntactic sugar) 代指的是编程语言为了方便程序员开发程序而设计的一种特殊语法,使用语法糖可以用更简洁的代码来实现相同的功能;
——语法糖会在程序编译阶段转换成基本语法
不过,JVM 其实并不能识别语法糖,Java 语法糖要想被正确执行,需要先通过编译器进行解糖,也就是在程序编译阶段将其转换成 JVM 认识的基本语法。这也侧面说明,Java 中真正支持语法糖的是 Java 编译器而不是 JVM。如果你去看com.sun.tools.javac.main.JavaCompiler
的源码,你会发现在compile()
中有一个步骤就是调用desugar()
,这个方法就是负责解语法糖的实现的。
2.Java 中有哪些常见的语法糖?
主要有泛型、自动拆装箱、增强 for 循环、try-with-resources 语法、lambda 表达式、变长参数、枚举、内部类等;
3.常见语法糖的实现原理
①switch 支持 String 与枚举
在开始之前先科普下,Java 中的switch
自身原本就支持基本类型。比如int
、char
等。对于int
类型,直接进行数值的比较。对于char
类型则是比较其 ascii 码。所以,对于编译器来说,switch
中其实只能使用整型,任何类型的比较都要转换成整型。比如byte
。short
,char
(ascii 码是整型)以及int
;
Java 7 中switch
开始支持String
;
实现原理是使用hashCode()和equals()来判断String是否相等;
源代码和反编译之后的代码
public class switchDemoString {
public static void main(String[] args) {
String str = "world";
switch (str) {
case "hello":
System.out.println("hello");
break;
case "world":
System.out.println("world");
break;
default:
break;
}
}
}
public class switchDemoString
{
public switchDemoString()
{
}
public static void main(String args[])
{
String str = "world";
String s;
switch((s = str).hashCode())
{
default:
break;
case 99162322:
if(s.equals("hello"))
System.out.println("hello");
break;
case 113318802:
if(s.equals("world"))
System.out.println("world");
break;
}
}
}
②泛型
对于 Java 虚拟机来说,他根本不认识Map<String, String> map
这样的语法。需要在编译阶段通过类型擦除的方式进行解语法糖;
类型擦除的主要过程如下:1.将所有的泛型参数用其最左边界(最顶级的父类型)类型替换。 2.移除所有的类型参数;
源代码和反编译之后的代码
public static <A extends Comparable<A>> A max(Collection<A> xs) {
Iterator<A> xi = xs.iterator();
A w = xi.next();
while (xi.hasNext()) {
A x = xi.next();
if (w.compareTo(x) < 0)
w = x;
}
return w;
}
public static Comparable max(Collection xs){
Iterator xi = xs.iterator();
Comparable w = (Comparable)xi.next();
while(xi.hasNext())
{
Comparable x = (Comparable)xi.next();
if(w.compareTo(x) < 0)
w = x;
}
return w;
}
③自动装箱与拆箱
自动装箱就是 Java 自动将原始类型值转换成对应的对象,比如将 int 的变量转换成 Integer 对象,这个过程叫做装箱,反之将 Integer 对象转换成 int 类型值,这个过程叫做拆箱
所以,装箱过程是通过调用包装器的 valueOf 方法实现的,而拆箱过程是通过调用包装器的 xxxValue 方法实现的
源代码和反编译之后的代码
public static void main(String[] args) {
Integer i = 10;
int n = i;
}
public static void main(String args[])
{
Integer i = Integer.valueOf(10);
int n = i.intValue();
}
④可变长参数
可变参数(variable arguments
)是在 Java 1.5 中引入的一个特性。它允许一个方法把任意数量的值作为参数
可变参数在被使用的时候,他首先会创建一个数组,数组的长度就是调用该方法是传递的实参的个数,然后再把参数值全部放到这个数组当中,然后再把这个数组作为参数传递到被调用的方法中;
⑤枚举
Java SE5 提供了一种新的类型-Java 的枚举类型,关键字enum
可以将一组具名的值的有限集合创建为一种新的类型,而这些具名的值可以作为常规的程序组件使用
当我们使用enum
来定义一个枚举类型的时候,编译器会自动帮我们创建一个final
类型的类继承Enum
类,所以枚举类型不能被继承
⑥内部类
内部类又称为嵌套类,可以把内部类理解为外部类的一个普通成员
内部类之所以也是语法糖,是因为它仅仅是一个编译时的概念,outer.java
里面定义了一个内部类inner
,一旦编译成功,就会生成两个完全不同的.class
文件了,分别是outer.class
和outer$inner.class
。所以内部类的名字完全可以和它的外部类名字相同;
⑦断言
在 Java 中,assert
关键字是从 JAVA SE 1.4 引入的,为了避免和老版本的 Java 代码中使用了assert
关键字导致错误,Java 在执行的时候默认是不启动断言检查的(这个时候,所有的断言语句都将忽略!),如果要开启断言检查,则需要用开关-enableassertions
或-ea
来开启;
断言的底层实现就是 if 语言,如果断言结果为 true,则什么都不做,程序继续执行,如果断言结果为 false,则程序抛出 AssertError 来打断程序的执行
⑧数值字面量
在 java 7 中,数值字面量,不管是整数还是浮点数,都允许在数字之间插入任意多个下划线。这些下划线不会对字面量的数值产生影响,目的就是方便阅读;
反编译后就是把_
删除了。也就是说 编译器并不认识在数字字面量中的_
,需要在编译阶段把他去掉;
查看代码
public class Test {
public static void main(String... args) {
int i = 10_000;
System.out.println(i);
}
}
public class Test
{
public static void main(String[] args)
{
int i = 10000;
System.out.println(i);
}
}
⑨for-each
for-each 的实现原理其实就是使用了普通的 for 循环和迭代器
⑩Lambda 表达式
lambda 表达式的实现其实是依赖了一些底层的 api,在编译阶段,编译器会把 lambda 表达式进行解糖,转换成调用内部 api 的方式
4.语法糖的一些坑
①泛型
Ⅰ.当泛型遇到重载
public class GenericTypes {
public static void method(List<String> list) {
System.out.println("invoke method(List<String> list)");
}
public static void method(List<Integer> list) {
System.out.println("invoke method(List<Integer> list)");
}
}
上面这段代码,有两个重载的函数,因为他们的参数类型不同,一个是List<String>
另一个是List<Integer>
,但是,这段代码是编译通不过的。因为我们前面讲过,参数List<Integer>
和List<String>
编译之后都被擦除了,变成了一样的原生类型 List,擦除动作导致这两个方法的特征签名变得一模一样;
Ⅱ.当泛型遇到 catch
泛型的类型参数不能用在 Java 异常处理的 catch 语句中。因为异常处理是由 JVM 在运行时刻来进行的。由于类型信息被擦除,JVM 是无法区分两个异常类型MyException<String>
和MyException<Integer>
的;
Ⅲ.当泛型内包含静态变量
public class StaticTest{
public static void main(String[] args){
GT<Integer> gti = new GT<Integer>();
gti.var=1;
GT<String> gts = new GT<String>();
gts.var=2;
System.out.println(gti.var);
}
}
class GT<T>{
public static int var=0;
public void nothing(T x){}
}
以上代码输出结果为:2!
由于经过类型擦除,所有的泛型类实例都关联到同一份字节码上,泛型类的所有静态变量是共享的;
②自动装箱与拆箱
public static void main(String[] args) {
Integer a = 1000;
Integer b = 1000;
Integer c = 100;
Integer d = 100;
System.out.println("a == b is " + (a == b));
System.out.println(("c == d is " + (c == d)));
}
输出结果:
a == b is false
c == d is true
分析:
Byte
,Short
,Integer
,Long
这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character
创建了数值在 [0,127] 范围的缓存数据,Boolean
直接返回 True
or False
;
对于在缓存范围内的包装类型会直接从缓存中返回对应的对象(如果手动使用new,则不论是否在范围内仍会新建对象),只有在超出范围时才会新建对象;
a,b属于自动装箱,但不在缓存范围内,故是两个不同的对象,返回false,c,d属于自动装箱,且在缓存范围内,故实际上是同一个对象的不同引用,返回true;
③增强 for 循环
for (Student stu : students) {
if (stu.getId() == 2)
students.remove(stu);
}
会抛出ConcurrentModificationException
异常。
Iterator 是工作在一个独立的线程中,并且拥有一个 mutex 锁。 Iterator 被创建之后会建立一个指向原来对象的单链索引表,当原来的对象数量发生变化时,这个索引表的内容不会同步改变,所以当索引指针往后移动的时候就找不到要迭代的对象,所以按照 fail-fast 原则 Iterator 会马上抛出java.util.ConcurrentModificationException
异常。
所以 Iterator
在工作的时候是不允许被迭代的对象被改变的。但你可以使用 Iterator
本身的方法remove()
来删除对象,Iterator.remove()
方法会在删除当前迭代对象的同时维护索引的一致性;
十七、Java中的设计模式实现
1. 代理模式
参考文章:Java 代理模式的基本概念、使用场景、应用示例和实现方法
①什么是代理模式?
就是通过添加一个代理对象来控制对目标对象的访问,以实现在不修改目标对象的前提下,对目标对象进行功能扩展,例如访问计数、延迟加载、权限控制等;
代理模式的实现方式主要有静态代理和动态代理;
②静态代理和动态代理的区别
静态代理在编译期间就确定了代理对象,需要为每一个代理对象创建一个代理类,当代理对象较多时,会导致代码冗余和维护成本增加;
动态代理在运行期间根据需要动态生成代理对象,不需要手动创建代理类,可以减少代码冗余和维护成本;
(Spring AOP功能就用到了动态代理,例如创建HandlerInterceptor的实现类来对访问URL进行拦截,实现权限控制)
③动态代理中JDK 动态代理和 CGLIB 动态代理对比
Ⅰ.实现原理:
JDK动态代理是jdk原生的实现方式,基于反射机制实现,在运行时通过实现目标接口的方式来代理目标类,因此要求目标类必须实现一个或多个接口,
而CGLIB动态代理是第三方CGLIB库提供的实现方式,在运行时通过继承目标类来代理目标类,因此要求目标类不能是被final修饰的类,或是有被final修饰的方法;
Ⅱ.性能表现:
JDK动态代理因为要实现目标类的接口,所以性能相对较低;
2. 单例模式
参考文章:Java设计模式之单例模式
①什么是单例模式
就是保证一个类仅有一个实例,并提供一个全局访问点,例如线程池、数据库连接池都是单例模式;
其优点在于单个实例可以减少内存开销、避免对资源的多重占用,设置全局访问点,严格控制访问;
②单例模式的几种实现方式及各自的优缺点
Ⅰ. 饿汉式——不推荐使用
public class HungrySingleton {
private static HungrySingleton instance = new HungrySingleton();
private HungrySingleton(){
}
public static HungrySingleton getInstance(){
return instance;
}
}
Ⅱ. 懒汉式——不推荐使用
public class LazySingleton {
private static LazySingleton instance = null;
private LazySingleton(){
}
public static LazySingleton getInstance(){
if(instance == null){
instance = new LazySingleton();
}
return instance;
}
}
缺点:线程不安全,多线程情况下仍有可能创建多个实例
Ⅲ. 双重检验锁方式——不推荐使用,因为有更好的选择
public class DoubleCheckedSingleton {
/**
* 给instance加上volatile的作用:禁止指令重排
*
*
* */
private static volatile DoubleCheckedSingleton instance = null;
private DoubleCheckedSingleton(){
}
public static DoubleCheckedSingleton getInstance(){
if(instance == null){
synchronized (DoubleCheckedSingleton.class){
if(instance == null){
instance = new DoubleCheckedSingleton();
}
}
}
return instance;
}
}
优点:支持延迟加载,线程安全
缺点:通过反射还是可以获取多个实例,因为还是要加锁,所以高并发情况下对性能仍有影响
Ⅳ. 静态内部类式
public class StaticInnerClassSingleton {
/**
* 外部类初次加载,会初始化静态变量、静态代码块、静态方法,但不会加载内部类和静态内部类,
* 只有调用静态内部类的方法,静态域,或者构造方法的时候才会加载静态内部类,故能实现延迟加载;
* */
private static class SingletonHolder{
private static final StaticInnerClassSingleton INSTANCE = new StaticInnerClassSingleton();
}
private StaticInnerClassSingleton(){
}
public static StaticInnerClassSingleton getInstance(){
return SingletonHolder.INSTANCE;
}
}
缺点:通过反射还是可以获取多个实例
Ⅴ. 枚举类式
public enum EnumSingleton {
INSTANCE;
public static EnumSingleton getInstance(){
return INSTANCE;
}
}
优点:
枚举类型天生具有线程安全性,
序列化和反序列化不可破坏单例(因为枚举类型通过类名和常量名确定枚举常量,而这里常量名及其对应的枚举常量是唯一的,故不会创建新的对象),
反射不可破坏单例(无法通过反射来创建枚举类型,会直接抛出异常IllegalArgumentException:"Cannot reflectively create enum objects")
缺点:不支持延迟加载,加载时就会创建对象;
③单例模式需要满足的条件
Ⅰ. 私有构造器;
Ⅱ. 线程安全(保证多线程环境下的单例);
Ⅲ. 延迟加载(不要在编译阶段就初始化,会浪费内存空间);
Ⅳ. 防止反射攻击(要求通过反射也无法创建出多个实例);
Ⅴ. 序列化和反序列化也不能破坏单例性质;
④几种实现方式的总结——推荐改进后可以防范反射攻击、序列化和反序列化的的内部类式单例,或是枚举类式单例
懒汉式、饿汉式是基本的实现方式,无法同时满足前三个条件,实际生产中不推荐使用,
双重检验锁式和内部类式都满足前三个条件,其中内部类式因为不用加锁高并发情况下性能更佳,推荐使用,
针对内部类式不能防止反射攻击,可以通过给私有构造器添加一个判断条件:若静态内部类的INSTANCE不为空,直接抛出异常,避免利用反射通过私有构造器创建非法实例;
改进后、可以防范反射攻击的的内部类式单例如下:
public class StaticInnerClassSingleton {
/**
* 外部类初次加载,会初始化静态变量、静态代码块、静态方法,但不会加载内部类和静态内部类,
* 只有调用静态内部类的方法,静态域,或者构造方法的时候才会加载静态内部类,故能实现延迟加载;
* */
private static class SingletonHolder{
private static final StaticInnerClassSingleton INSTANCE = new StaticInnerClassSingleton();
}
private StaticInnerClassSingleton(){
if(SingletonHolder.INSTANCE != null){
throw new RuntimeException("不允许非法创建多个实例");
}
}
public static StaticInnerClassSingleton getInstance(){
return SingletonHolder.INSTANCE;
}
}
针对内部类式在序列化和反序列化时会生成不同实例,可以通过在单例类中添加一个readResolve()方法来解决,保证反序列化之后的对象和序列化前的对象是同一个对象;
改进后,可以防范反射攻击、序列化和反序列化的的内部类式单例如下:
public class StaticInnerClassSingleton implements Serializable {
private static final long serialVersionUID = 1L;
/**
* ObjectInputStream在调用readObject()方法实现反序列化时,会查看实例类内部是否有readResolve方法,
* 如果存在readResolve方法,则直接通过反射获取此方法的返回值,并作为readResolve方法的返回值;
* 如果不存在,则会重新创建一个实例对象作为返回值;
* */
private Object readResolve(){
return SingletonHolder.INSTANCE;
}
/**
* 外部类初次加载,会初始化静态变量、静态代码块、静态方法,但不会加载内部类和静态内部类,
* 只有调用静态内部类的方法,静态域,或者构造方法的时候才会加载静态内部类,故能实现延迟加载;
* */
private static class SingletonHolder{
private static final StaticInnerClassSingleton INSTANCE = new StaticInnerClassSingleton();
}
private StaticInnerClassSingleton(){
if(SingletonHolder.INSTANCE != null){
throw new RuntimeException("不允许非法创建多个实例");
}
}
public static StaticInnerClassSingleton getInstance(){
return SingletonHolder.INSTANCE;
}
}
枚举类式由于其枚举类型自带线程安全、不被反射、序列化与反序列化破坏的特性,且代码优雅简单,虽然不能延迟加载但无伤大雅,
总之也是非常推荐的一种单例式写法;
总结:
当需要单例时,只要不是特别重的对象,都建议使用枚举类式实现(因为加载时就会创建对象,对象太重会浪费大量内存空间),其他情况下使用改进的内部类式单例;
3. 设计模式总结
①介绍一下自己对设计模式的了解
设计模式是指在软件开发时,用于解决在特定环境下的经常出现的问题的方案,
设计模式按照设计目的可以分为三类:创建型模式、结构型模式、行为模式,
分别对应了面向对象开发的三个问题:如何创建对象、如何组合对象、如何处理对象间的动态通信和职责分配;
其中创建型模式常见的有单例模式、工厂模式,结构型模式常见的有代理模式、适配器模式,行为模式常见的有策略模式、观察者模式;
②设计模式中需要遵循的设计原则——主要是前四个
Ⅰ. 开闭原则
一个类应当对扩展开放,对修改关闭,
即当需要实现某个新的功能时,应当能够通过扩展来实现,而不用修改类内部的代码,
关键是要做好封装,隐藏类内部的实现细节,开发足够多的接口,这样外部代码只能通过这些接口来扩展功能,而不需要侵入到类的内部;
Ⅱ. 依赖倒置
抽象不应当依赖于具体实现,具体实现应当依赖于抽象,
核心思想是要面向接口编程,而不是面向实现编程
——因为相对于细节的多变性,抽象的东西要稳定的多,以抽象为基础搭建的架构比以细节为基础的架构要稳定的多;
(抽象:接口、实现类,具体实现:实现类)
Ⅲ. 组合优于继承
继承耦合度高,组合耦合度低;
(组合:将已有的对象纳入到新对象中,使之成为新对象的一部分,新对象存取成分对象的唯一方法是通过成分对象的接口,这种复用是黑箱复用;
继承:继承复用破坏了包装,因为继承父类的的实现细节暴露给子类,因此这种复用又称“白箱”复用)
Ⅳ.里氏替换
子类能够完全替换父类,而不会影响整个代码的功能
(反之父类不能替换子类);
Ⅴ. 接口隔离
建立单一接口,不要建立庞大的接口,接口中的方法要尽量少,避免强迫用户实现自己不需要的方法;
Ⅵ. 单一职责
在设计类的时候要尽量缩小粒度,使功能明确、单一,不要做多余的事情(高内聚,低耦合);