- Java 中的几种基本数据类型是什么?对应的包装类型是什么?各自占用多少字节呢?
- 包装类型的缓存机制了解么?
- 自动装箱与拆箱了解吗?原理是什么?
- String 、 StringBuffer 和 StringBuilder 的区别是什么? String 为什么是不可变的?
- String s1 = new String(“abc”);这段代码创建了几个字符串对象?
- == 与 equals?hashCode 与 equals ?
- 重载和重写
- 深拷贝和浅拷贝区别了解吗?什么是引用拷贝?
- 谈谈对 Java 注解的理解,解决了什么问题?
- Exception 和 Error 有什么区别?
- Java 反射?反射有什么缺点?你是怎么理解反射的(为什么框架需要反射)?
- Java 泛型了解么?什么是类型擦除?介绍一下常用的通配符?
- 内部类了解吗?匿名内部类了解吗?
- BIO,NIO,AIO 有什么区别?
Java 数据类型
String、StringBuffer、StringBuilder
可变性
String
是不可变的。
String中保存字符串的数组被 final
和private修饰,并且String
类没有提供修改这个字符串的方法,被 final
修饰导致其不能被继承,进而避免了子类破坏 String
不可变。
StringBuilder
与 StringBuffer
都继承自 AbstractStringBuilder
类,在 AbstractStringBuilder
中也是使用字符数组保存字符串,不过没有使用 final
和 private
关键字修饰,最关键的是这个 AbstractStringBuilder
类还提供了很多修改字符串的方法比如 append
方法。
线程安全性
String
中的对象是不可变的,也就可以理解为常量,线程安全。StringBuffer
对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder
并没有对方法进行加同步锁,所以是非线程安全的。
性能
每次对 String
类型进行改变的时候,都会生成一个新的 String
对象,然后将指针指向新的 String
对象。StringBuffer
每次都会对 StringBuffer
对象本身进行操作,而不是生成新的对象并改变对象引用。相同情况下使用 StringBuilder
相比使用 StringBuffer
仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。
对于三者使用的总结:
- 操作少量的数据: 适用
String
- 单线程操作字符串缓冲区下操作大量数据: 适用
StringBuilder
- 多线程操作字符串缓冲区下操作大量数据: 适用
StringBuffer
String s1 = new String(“abc”)
会创建 1 或 2 个字符串对象。
1、如果字符串常量池中不存在字符串对象“abc”的引用,那么会在堆中创建 2 个字符串对象“abc”。
堆中创建字符串对象“abc”并在字符串常量池中保存对应的引用
2、如果字符串常量池中已存在字符串对象“abc”的引用,则只会在堆中创建 1 个字符串对象“abc”。
String 类型 “+”运算
先来看字符串不加 final
关键字拼接的情况(JDK1.8):
String str1 = "str"; //常量池中的对象
String str2 = "ing"; //常量池中的对象
String str3 = "str" + "ing"; //常量池中的对象
String str4 = str1 + str2; //堆中的创建的新对象
String str5 = "string";
System.out.println(str3 == str4);//false
System.out.println(str3 == str5);//true
System.out.println(str4 == str5);//false
对于编译期可以确定值的字符串,也就是常量字符串 ,jvm 会将其存入字符串常量池。并且,字符串常量拼接得到的字符串常量在编译阶段就已经被存放字符串常量池,这个得益于编译器的优化。
在编译过程中,Javac 编译器会进行一个叫做 常量折叠(Constant Folding) 的代码优化。
常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中,这是 Javac 编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)。
对于 String str3 = "str" + "ing";
编译器会给你优化成 String str3 = "string";
。
并不是所有的常量都会进行折叠,只有编译器在程序编译期就可以确定值的常量才可以:
- 基本数据类型(
byte
、boolean
、short
、char
、int
、float
、long
、double
)以及字符串常量。 final
修饰的基本数据类型和字符串变量- 字符串通过 “+”拼接得到的字符串、基本数据类型之间算数运算(加减乘除)、基本数据类型的位运算(<<、>>、>>> )
引用的值在程序编译期是无法确定的,编译器无法对其进行优化。
intern()
String.intern()
是一个 native(本地)方法,其作用是将指定的字符串对象的引用保存在字符串常量池中,可以简单分为两种情况:
- 如果字符串常量池中保存了对应的字符串对象的引用,就直接返回该引用。
- 如果字符串常量池中没有保存了对应的字符串对象的引用,那就在常量池中创建一个指向该字符串对象的引用并返回。
示例代码(JDK 1.8) :
// 在堆中创建字符串对象”Java“
// 将字符串对象”Java“的引用保存在字符串常量池中
String s1 = "Java";
// 直接返回字符串常量池中字符串对象”Java“对应的引用
String s2 = s1.intern();
// 会在堆中在单独创建一个字符串对象
String s3 = new String("Java");
// 直接返回字符串常量池中字符串对象”Java“对应的引用
String s4 = s3.intern();
// s1 和 s2 指向的是堆中的同一个对象
System.out.println(s1 == s2); // true
// s3 和 s4 指向的是堆中不同的对象
System.out.println(s3 == s4); // false
// s1 和 s4 指向的是堆中的同一个对象
System.out.println(s1 == s4); //true
== 、equals() 、hashcode()
- 对于基本数据类型来说,
==
比较的是值。 - 对于引用数据类型来说,
==
比较的是对象的内存地址。
equals()
方法存在两种使用情况:
- 类没有重写
equals()
方法 :通过equals()
比较该类的两个对象时,等价于通过 “== ” 比较这两个对象,使用的默认是Object
类equals()
方法。 - 类重写了
equals()
方法 :一般我们都重写equals()
方法来比较两个对象中的属性是否相等;若它们的属性相等,则返回 true(即,认为这两个对象相等)。
hashCode()
的作用是获取哈希码(int
整数),也称为散列码。这个哈希码的作用是确定该对象在哈希表中的索引位置。
对象加入 HashSet
时,HashSet
会先计算对象的 hashCode
值,如果没有相等的 hashCode
,HashSet
会假设对象没有重复出现。如果有,这时会调用 equals()
方法来检查 hashCode
相等的对象是否真的相同。如果两者相同,HashSet
就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。这样可以减少 equals
的次数,提高执行速度。
-
如果两个对象的
hashCode
值相等,那这两个对象不一定相等(哈希碰撞)。 -
如果两个对象的
hashCode
值相等并且equals()
方法也返回true
,我们才认为这两个对象相等。 -
如果两个对象的
hashCode
值不相等,我们就可以直接认为这两个对象不相等。 -
equals
方法判断两个对象是相等的,那这两个对象的hashCode
值也要相等。 -
两个对象有相同的
hashCode
值,他们也不一定是相等的(哈希碰撞)。
重载 vs 重写
包装类型的缓存机制
Java 基本数据类型的包装类型的大部分都用到了缓存机制来提升性能。
Byte
,Short
,Integer
,Long
这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character
创建了数值在 [0,127] 范围的缓存数据,Boolean
直接返回 True
or False
。
两种浮点数类型的包装类 Float
,Double
并没有实现缓存机制。
Integer i1 = 40;
Integer i2 = new Integer(40);
//`i1` 直接使用的是缓存中的对象。而`Integer i2 = new Integer(40)` 会直接创建新的对象。
System.out.println(i1==i2);//false
记住:所有整型包装类对象之间值的比较,全部使用 equals 方法比较。
自动装箱与拆箱
- 装箱:将基本类型用它们对应的引用类型包装起来;
- 拆箱:将包装类型转换为基本数据类型;
从字节码中,我们发现装箱其实就是调用了 包装类的valueOf()
方法,拆箱其实就是调用了 xxxValue()
方法。
Integer i = 10
等价于Integer i = Integer.valueOf(10)
int n = i
等价于int n = i.intValue()
;
注意:如果频繁拆装箱的话,也会严重影响系统的性能。我们应该尽量避免不必要的拆装箱操作。
深拷贝、浅拷贝、引用拷贝
- 浅拷贝:浅拷贝会在堆上创建一个新的对象,如果原对象内部的属性是引用类型的话,浅拷贝会直接复制内部对象的引用地址,也就是说拷贝对象和原对象共用同一个内部对象。
- 深拷贝 :深拷贝会完全复制整个对象,包括这个对象所包含的内部对象。
- 引用拷贝:两个不同的引用指向同一个对象。
为什么Java中只有值传递
Java 中将实参传递给方法(或函数)的方式是 值传递 :
- 如果参数是基本类型的话,很简单,传递的就是基本类型的字面量值的拷贝,会创建副本。
- 如果参数是引用类型,传递的就是实参所引用的对象在堆中地址值的拷贝,同样也会创建副本。
注解
Annotation
(注解) 是 Java5 开始引入的新特性,可以看作是一种特殊的注释,主要用于修饰类、方法或者变量,提供某些信息供程序在编译或者运行时使用。
注解本质是一个继承了Annotation
的特殊接口:
注解只有被解析之后才会生效,常见的解析方法有两种:
- 编译期直接扫描 :编译器在编译 Java 代码的时候扫描对应的注解并处理,比如某个方法使用
@Override
注解,编译器在编译的时候就会检测当前的方法是否重写了父类对应的方法。 - 运行期通过反射处理 :像框架中自带的注解(比如 Spring 框架的
@Value
、@Component
)都是通过反射来进行处理的。
序列化
- 序列化: 将数据结构或对象转换成二进制字节流的过程
- 反序列化:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程
下面是序列化和反序列化常见应用场景:
- 对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化;
- 将对象存储到文件之前需要进行序列化,将对象从文件中读取出来需要进行反序列化;
- 将对象存储到数据库(如 Redis)之前需要用到序列化,将对象从缓存数据库中读取出来需要反序列化;
- 将对象存储到内存之前需要进行序列化,从内存中读取出来之后需要进行反序列化。
序列化协议属于 TCP/IP 协议应用层的一部分
JDK 自带的序列化方式一般不会用 ,因为序列化效率低并且存在安全问题。比较常用的序列化协议有 Hessian、Kryo、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议。
像 JSON 和 XML 这种属于文本类序列化方式。虽然可读性比较好,但是性能较差,一般不会选择。
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;
}
serialVersionUID 有什么作用?
序列化号 serialVersionUID
属于版本控制的作用。反序列化时,会检查 serialVersionUID
是否和当前类的 serialVersionUID
一致。如果 serialVersionUID
不一致则会抛出 InvalidClassException
异常。强烈推荐每个序列化类都手动指定其 serialVersionUID
,如果不手动指定,那么编译器会动态生成默认的 serialVersionUID
。
serialVersionUID 不是被 static 变量修饰了吗?为什么还会被“序列化”?
static
修饰的变量是静态变量,位于方法区,本身是不会被序列化的。 static
变量是属于类的而不是对象。你反序列之后,static
变量的值就像是默认赋予给了对象一样,看着就像是 static
变量被序列化,实际只是假象罢了。
如果有些字段不想进行序列化怎么办?
对于不想进行序列化的变量,可以使用 transient
关键字修饰。
transient
关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient
修饰的变量值不会被持久化和恢复。
关于 transient
还有几点注意:
transient
只能修饰变量,不能修饰类和方法。transient
修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰int
类型,那么反序列后结果就是0
。static
变量因为不属于任何对象(Object),所以无论有没有transient
关键字修饰,均不会被序列化。
为什么不推荐使用 JDK 自带的序列化?
我们很少或者说几乎不会直接使用 JDK 自带的序列化方式,主要原因有下面这些原因:
- 不支持跨语言调用 : 如果调用的是其他语言开发的服务的时候就不支持了。
- 性能差 :相比于其他序列化框架性能更低,主要原因是序列化之后的字节数组体积较大,导致传输成本加大。
- 存在安全问题 :序列化和反序列化本身并不存在问题。但当输入的反序列化的数据可被用户控制,那么攻击者即可通过构造恶意输入,让反序列化产生非预期的对象,在此过程中执行构造的任意代码。相关阅读
反射
反射你可以获取任意一个类的所有属性和方法,你还可以调用这些方法和属性。
1. 知道具体类的情况下可以使用:
Class alunbarClass = TargetObject.class;
但是我们一般是不知道具体类的,基本都是通过遍历包下面的类来获取 Class 对象,通过此方式获取 Class 对象不会进行初始化
2. 通过 Class.forName()
传入类的全路径获取:
Class alunbarClass1 = Class.forName("cn.javaguide.TargetObject");
3. 通过对象实例instance.getClass()
获取:
TargetObject o = new TargetObject();
Class alunbarClass2 = o.getClass();
4. 通过类加载器xxxClassLoader.loadClass()
传入类路径获取:
ClassLoader.getSystemClassLoader().loadClass("cn.javaguide.TargetObject");
通过类加载器获取 Class 对象不会进行初始化,意味着不进行包括初始化等一系列步骤,静态代码块和静态对象不会得到执行
泛型
一、当泛型遇到重载
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!
由于经过类型擦除,所有的泛型类实例都关联到同一份字节码上,泛型类的所有静态变量是共享的。
异常
在 Java 中,所有的异常都有一个共同的祖先 java.lang
包中的 Throwable
类。Throwable
类有两个重要的子类:
Exception
:程序本身可以处理的异常,可以通过catch
来进行捕获。Exception
又可以分为 Checked Exception 和 Unchecked Exception。- Checked Exception 即 受检查异常 ,如果没有被
catch
或者throws
关键字处理的话,就没办法通过编译。除了RuntimeException
及其子类以外,其他的Exception
类及其子类都属于受检查异常 。常见的受检查异常有: IO 相关的异常、ClassNotFoundException
、SQLException
…。 - Unchecked Exception 即 不受检查异常 ,Java 代码在编译过程中 ,我们即使不处理不受检查异常也可以正常通过编译。
- Checked Exception 即 受检查异常 ,如果没有被
Error
:Error
属于程序无法处理的错误 ,不建议通过catch
捕获 。例如 Java 虚拟机运行错误(Virtual MachineError
)、虚拟机内存不够错误(OutOfMemoryError
)、类定义错误(NoClassDefFoundError
)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。
RuntimeException
及其子类都统称为非受检查异常,常见的有(建议记下来,日常开发中会经常用到):
NullPointerException
(空指针错误)IllegalArgumentException
(参数错误比如方法入参类型错误)NumberFormatException
(字符串转换为数字格式错误,IllegalArgumentException
的子类)ArrayIndexOutOfBoundsException
(数组越界错误)ClassCastException
(类型转换错误)ArithmeticException
(算术错误)SecurityException
(安全错误比如权限不够)UnsupportedOperationException
(不支持的操作错误比如重复创建同一用户)
try-with-resources
- 适用范围(资源的定义): 任何实现
java.lang.AutoCloseable
或者java.io.Closeable
的对象 - 关闭资源和 finally 块的执行顺序: 在
try-with-resources
语句中,任何 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();
}
IO模型
根据大学里学到的操作系统相关的知识:为了保证操作系统的稳定性和安全性,一个进程的地址空间划分为 用户空间(User space) 和 内核空间(Kernel space ) 。
像我们平常运行的应用程序都是运行在用户空间,只有内核空间才能进行系统态级别的资源有关的操作,比如文件管理、进程通信、内存管理等等。也就是说,我们想要进行 IO 操作,一定是要依赖内核空间的能力。
并且,用户空间的程序不能直接访问内核空间。
当想要执行 IO 操作时,由于没有执行这些操作的权限,只能发起系统调用请求操作系统帮忙完成。
因此,用户进程想要执行 IO 操作的话,必须通过 系统调用 来间接访问内核空间
我们在平常开发过程中接触最多的就是 磁盘 IO(读写文件) 和 网络 IO(网络请求和响应)。
从应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。
当应用程序发起 I/O 调用后,会经历两个步骤:
- 内核等待 I/O 设备准备好数据
- 内核将数据从内核空间拷贝到用户空间。
BIO 属于同步阻塞 IO 模型 。
同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。
NIO (Non-blocking/New I/O)
同步非阻塞 IO 模型。
同步非阻塞 IO 模型中,应用程序会一直发起 read 调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间。
相比于同步阻塞 IO 模型,同步非阻塞 IO 模型确实有了很大改进。通过轮询操作,避免了一直阻塞。
但是,这种 IO 模型同样存在问题:应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的。
I/O 多路复用模型
IO 多路复用模型中,线程首先发起 select 调用,询问内核数据是否准备就绪,等内核把数据准备好了,用户线程再发起 read 调用。read 调用的过程(数据从内核空间 -> 用户空间)还是阻塞的。
IO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。
Java 中的 NIO 于 Java 1.4 中引入,对应 java.nio
包,提供了 Channel
, Selector
,Buffer
等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它是支持面向缓冲的,基于通道的 I/O 操作方法。 对于高负载、高并发的(网络)应用,应使用 NIO 。
AIO (Asynchronous I/O)
AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2,它是异步 IO 模型。
异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
目前来说 AIO 的应用还不是很广泛。Netty 之前也尝试使用过 AIO,不过又放弃了。这是因为,Netty 使用了 AIO 之后,在 Linux 系统上的性能并没有多少提升。