Java面试题——Java基础、集合、IO、并发
一、基础概念与常识
1.JVM、JDK与JRE
JVM是Java字节码文件的虚拟机。
JRE是Java运行时环境,包含Java程序运行时所需的所有内容,如jvm、Java类库、Java命令等。但是不能用于创建新的Java程序。
JDK拥有 JRE 拥有的一切,还有编译器(javac)和工具(如 javadoc 和 jdb),能够创建和编译程序。
2.字节码文件与Java编译到运行流程
(1)字节码文件
字节码文件就是扩展名为.class的文件,是Java源代码(.java)经过javac编译后的文件。
字节码文件只面向jvm,而不是面向特定的操作系统,所以只要一个os装了jvm,Java程序无需重新编译就能在该os上运行。
(2)Java 程序从源代码到运行的过程
我们写的代码就是.java文件,然后经javac编译后形成.class文件,然后jvm的类加载器加载字节码,通过解释器逐行解释成机器码(机器能理解的代码)。
但是在字节码解释成机器码时,用解释器比较慢,而且有些方法和代码块经常要被调用(热点代码),所以后来有了 JIT 编译器(实时编译器,属于运行时编译)。当JIT完成第一次编译后,会将其字节码对应的机器码保存下来,下次就可以直接得到机器码了。
(3)为什么说 Java 是编译与解释共存的语言?
首先搞清楚什么是编译型语言和解释型语言:
-
编译型语言——一次编译,到处运行
编译型语言在运行前,需要通过编译器将源码翻译为机器码(二进制指令码),进而生成可执行文件。可执行文件的运行无需源码与编译器,也就是说代码只要编译完了就可以脱离开发环境运行。
-
解释型语言——一次编写,到处运行
解释型语言可以直接运行源码,在运行源码时,解释器逐行将源码翻译为机器码,但不生成可执行文件,也就是说再次运行源码时需要再次解释,所以解释型语言无法脱离开发环境运行,而且运行效率低于编译型语言。
而Java属于编译与解释并存的语言,因为Java程序是先编译后解释,才能执行。Java 源代码通过javac编译成字节码(class文件),运行时JVM的解释器会将字节码逐行解释为机器码;同时一些热点代码的字节码会通过JIT编译器直接编译为机器码,再次运行时可以直接调用,无需解释了。
3.Java 和 C++ 的区别?
虽然,Java 和 C++ 都是面向对象的语言,都支持封装、继承和多态,但是它们还是有挺多不相同的地方:
- Java 不提供指针来直接访问内存,程序内存更加安全;
- Java 类只能单继承,而C++ 支持多继承;虽然 Java 的类不可以多继承,但是接口可以多继承。
- Java 有 GC 垃圾回收机制,不需要手动释放内存。
- C ++同时支持方法重载和操作符重载,但是 Java 只支持方法重载(操作符重载增加了复杂性,这与 Java 最初的设计思想不符)。
二、基本语法
1.标识符和关键字的区别是什么?
在我们写代码的时候,经常为程序、类、变量、方法等取名字,简单来说,我们取的名字就是标识符。
但是有一些标识符,Java 已经赋予了其特殊的含义,只能用于特定的地方。简单来说,关键字是被赋予特殊含义的标识符 。比如,在我们的日常生活中,如果我们想要开一家店,则要给这个店起一个名字,起的这个“名字”就叫标识符。但是我们店的名字不能叫“警察局”,因为“警察局”这个名字已经被赋予了特殊的含义,而“警察局”就是我们日常生活中的关键字。
(1)final修饰类、对象、方法、变量有什么区别?
- 被final修饰的类不能被继承;
-
被final修饰的对象的引用地址不能改变,对象的属性可以修改;
- 被final修饰的方法不能被重写;
- 被final修饰的变量会变成常量,值不能被更改。
2.移位运算符
移位操作中,被操作的数据被视为二进制数,移位就是将其向左或向右移动若干位的运算。
Java 中有三种移位运算符:
- <<:左移运算符,向左移若干位,高位丢弃,低位补零。x << 1,相当于 x 乘以 2(不溢出的情况下)。
- >>:带符号右移,向右移若干位,高位补符号位,低位丢弃。正数高位补 0,负数高位补 1。x >> 1,相当于 x 除以 2。
- >>>:无符号右移,忽略符号位,空位都以 0 补齐。
【tips】由于 double,float 在二进制中的表现比较特殊,因此不能来进行移位操作。
如果移位的位数超过数值所占有的位数会怎样?
当 int 类型左移/右移位数大于等于 32 位操作时,会先求余(%)后再进行左移/右移操作。也就是说左移/右移 32 位相当于不进行移位操作(32%32=0),左移/右移 42 位相当于左移/右移 10 位(42%32=10)。
当 long 类型进行左移/右移操作时,由于 long 对应的二进制是 64 位,因此求余操作的基数也变成了 64。
3.变量
(1)成员变量与局部变量的区别?
- 语法形式 :成员变量定义在类里,而局部变量定义在代码块或方法中,或是方法的参数;成员变量可以被public,private,static等修饰符修饰,而局部变量不能被访问控制修饰符(private、protected、public)及static修饰;二者都能被final修饰。
- 存储方式 :静态成员变量是属于类的,如果没有使用static修饰,这个成员变量是属于实例的。而实例对象存在于堆内存,局部变量则存在于栈内存。
- 生存时间 :成员变量随着对象的创建而存在,而局部变量随着方法的调用自动生成,随着方法的调用结束而消亡。
- 默认值 :成员变量如果没有被赋初始值,则会自动以类型的默认值而赋值(一种情况例外:被final修饰的成员变量也必须显式地赋值),而局部变量不会自动赋值。
(2)静态变量有什么作用?
当变量被static修饰后,该变量可以被类所有的实例对象所共享。
4.方法
(1)静态方法为什么不能调用非静态成员?
静态方法是属于类的,当这个类被加载时就会为该方法分配内存,可以通过类直接访问该方法。
而非静态成员(变量和方法)是属于实例对象的,只有在对象实例化之后才存在,需要通过类的实例对象去访问。
所以非静态成员不存在的时候静态方法就已经存在了,因此静态方法不能调用在内存中不存在的非静态成员。
(2)静态方法和实例方法有何不同?
-
1)调用方式不同
可以用类名直接调用静态方法,也可以用对象调用静态方法;而实例方法能通过对象调用。
【tips】一般不建议使用 对象.方法名 的方式来调用静态方法。这种方式非常容易造成混淆,静态方法不属于类的某个对象而是属于这个类。
-
2)能访问的东西不同
静态方法只允许访问静态成员,不允许访问实例成员,而实例方法二者都能访问。
(3)方法重载与方法重写有什么区别?
方法重载就是对于同样的一个方法,能够根据输入的数据不同,做出不同的处理;
方法重写就是当子类继承了父类的相同方法,输入的数据一样,但是要做出与父类不同的响应时,就要重写父类方法。
-
方法重载
定义在同一个类中的多个方法,方法名必须相同,参数类型不同、个数不同、顺序不同(三者满足其一就是重载),返回值类型和修饰符可以不同,也就是说两个方法是否是方法重载,只与参数(类型、个数、顺序)有关,与返回值类型和修饰符无关。
-
方法重写
方法重写就是对父类方法的内部逻辑进行重写,而不改变方法名和参数列表。方法的重写要遵循“两同两小一大”:-
“两小”:指的是子类方法返回值类型应比父类方法返回值类型更小或相等,子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等;
★如果方法的返回类型是 void 和基本数据类型,则重写时不可修改。但是如果方法的返回值是引用类型,重写时可以返回该引用类型的子类。
- “一大”:指的是子类方法的访问权限应比父类方法的访问权限更大或相等。
-
“两小”:指的是子类方法返回值类型应比父类方法返回值类型更小或相等,子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等;
(4)什么是可变长参数?
可变长参数就是允许在调用方法时传入不定长度的参数。比如下面的这个 printVariable 方法就可以接受 0 个或者多个String类型的参数:
如果有多个参数,可变长参数只能放在最后:
(5)★对于方法重载,是优先匹配固定参数还是可变长参数的方法呢?
优先匹配固定参数的方法,因为固定参数的方法匹配度更高。
三、基本数据类型
1.Java 中的8种基本数据类型
-
6 种数字类型:
- 4 种整数型:byte、short、int、long
- 2 种浮点型:float、double
- 1 种字符类型:char
- 1 种布尔型:boolean
(1)默认值以及所占空间的大小
【tips】Java 的每种基本类型所占存储空间的大小不会像其他大多数语言那样随机器硬件架构的变化而变化。这种所占存储空间大小的不变性是 Java 程序比用其他大多数语言编写的程序更具可移植性的原因之一。
(2)基本类型和包装类型的区别?
八种基本类型都有对应的包装类,分别为:Byte、Short、Integer、Long、Float、Double、Character、Boolean 。
- 包装类型的默认值都是null;而基本类型有各自对应的默认值。
- 包装类型可用于泛型,而基本类型不可以。
-
包装类存放在堆内存;基本数据类型作为局部变量时,存放在栈内存;作为成员变量存放在堆内存。
当对象new出来,是存在于堆的,对象的成员变量已经在堆上分配空间,但对象里面的方法是没有出现的,只有方法的声明,此时方法里面的局部变量并没有创建。等到对象调用此方法时,方法中的局部变量才会在栈中创建。所以,成员变量在堆内存,方法中的局部变量在栈内存。
- 相比于包装类, 基本数据类型占用的空间更小。
(3)★包装类的缓存机制了解么?
Java中除了float和double外,其余6种基本数据类型的包装类都用到了缓存机制来提高性能。
创建一个包装类的方法有两种,一个是通过构造方法new出来,一个是自动装箱,new出来的对象都是新创建的对象,而自动装箱时会判断数据是否在缓存数据范围内,如果在,就返回一个缓存对象;如果不在才新创建一个对象。
4种整数型的包装类 Byte、Short、Integer、Long 创建了 [-128,127] 的缓存数据,Character创建了数值在 [0,127] 范围的缓存数据,Boolean直接返回True或False。
【tips】所有整型包装类对象之间值的比较,全部用 equals 方法比较。
(4)什么是自动装箱与拆箱?原理是什么?
- 装箱:将基本类型用对应的引用类型(包装类)包装起来;
-
拆箱:将包装类型转换为基本数据类型。
Integer i = 10; //装箱 int n = i; //拆箱
- Integer i = 10 等价于 Integer i = Integer.valueOf(10)
- int n = i 等价于 int n = i.intValue();
为什么使用整型包装类时,推荐使用valueOf()方法,少使用parseXXX()方法?
因为整型的包装类有缓存机制,valueOf 方***从缓存中取值,如果命中缓存,会减少资源的开销,parseXXX 方法没有这个机制。(5)如何解决浮点数运算的精度丢失问题?
BigDecimal 可以实现对浮点数的运算,不会造成精度丢失。通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 BigDecimal 来做的。
四、面向对象相关问题
1.如果一个类没有声明构造方法,该程序能正确执行吗?
如果一个类没有声明构造方法,Java会默认生成一个无参构造方法。
【tips】构造方法不能被重写,但是可以被重载。所以同一个类中可以有多个构造方法。
2.面向对象三大特征
(1)封装
封装是指把一个对象的状态信息(也就是属性)隐藏在对象内部,不允许外部对象直接访问对象的内部信息,可以提供一些能被外界访问的方法来操作属性。
(2)继承
不同类之间经常有一定共同点。继承就是以已存在的类作为基础建立新类,已存在的类就是父类,继承了父类的就是子类。子类还可以有自己的成员变量和成员方法。
通过继承可以快速创建类,提高开发效率,提高代码的重用和程序的可维护性。
关于继承需要注意:
- 子类可以继承父类所有的属性和方法,包括私有属性和私有方法,但是子类无法访问父类中的私有属性和方法(除了自己,谁都无法访问)。
- 子类可以对父类进行扩展。
- 子类可以用自己的方式实现父类的方法。
(3)★多态
多态表示一个对象可以具有多种的状态,具体表现为父类引用(Animal a)指向子类对象(new Cat())。
/* 对于右边的同一对象,左边是他的不同形态——即猫既可以是猫(Cat类),也可以是动物(Animal类) */ Cat c = new Cat(); Animal a = new Cat();//Cat类继承Animal类,并有方法重写
多态的特点:
- 对象类型和引用类型之间具有继承/实现的关系:Cat继承Animal;
- 引用类型变量发出的方法调用的到底是哪个类中的方法,必须在程序运行期间才能确定;
- 多态不能调用“只在子类存在但在父类不存在”的方法;
- 如果子类重写了父类的方法,真正执行的是子类覆盖的方法,如果子类没有覆盖父类的方法,执行的是父类的方法。
3.接口和抽象类
(1)什么是抽象类和抽象方法?
抽象类用abstract修饰,可以包含一般的方法和抽象方法,有抽象方法的类一定是抽象类,但是抽象类可以没有抽象方法。
抽象方法没有方法体,连{}都没有。
(2)为什么要有抽象类和抽象方法?
抽象类其实就是用来实现日常生活中的抽象概念。比如说Animal类有个叫()方法——动物会叫(动物怎么叫?动物多了去了,应该是具体哪个动物怎么叫,所以动物叫就是个抽象概念),Cat和Dog是其子类,重写了叫()方法,对于Cat和Dog就有具体的方法体(具体怎么叫),这时就可以把父类Animal中的叫()写成抽象方法,我们不用去关心叫()方法的具体实现,让子类去具体实现。
-
有一个父类Animal:
-
Cat和Dog继承了Animal并重写了cry(),改写Animal的cry方法为抽象方法:
(3)★抽象类的特点
- 抽象类不能被实例化,要用多态的形式创建对象;
- 有抽象方法的类一定是抽象类,但是抽象类可以没有抽象方法;
-
子类必须重写抽象父类中所有的抽象方法(因为不重写就继承了,继承过来自己又成抽象类了)
所以抽象类可以用抽象方法来限定子类必须做哪些事情。
(4)接口和抽象类的区别?
-
共同点:
- 都不能被实例化。
- 都可以包含抽象方法。
- Java 8之后,都可以有默认实现的方法(Java 8 可以用 default 关键字在接口中定义默认方法)。
-
区别:
- 接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系。
- 一个类只能继承一个抽象类,但是可以实现多个接口。
- 接口中的成员变量只能是public static final类型的,不能被修改且必须有初始值,而抽象类的成员变量默认 default,可在子类中被重新定义,也可被重新赋值。
4.深拷贝和浅拷贝区别了解吗?什么是引用拷贝?
(1)什么是浅拷贝和深拷贝?二者的区别是什么?
我们知道基本数据类型是直接存储在栈内存中;引用类型并不是真正的对象,而是存储在栈内存中的对象的引用,真正的对象存在堆内存中。引用数据类型在栈中存储了指针,指针指向存储在堆中的对象的地址。
浅拷贝和深拷贝只是针对引用类型来说的,
- 浅拷贝只会复制指向某个对象的指针,而不是复制对象本身,所以拷贝后的新对象实际上还是原来的旧对象(内存地址没变),因此对新对象的操作会影响旧对象。
- 深拷贝会另外创造一个一模一样的对象,新对象跟原对象不共享内存,修改新对象不会影响旧对象。
(2)引用拷贝
引用拷贝就是两个不同的引用指向同一个对象。
(3)引用拷贝和浅拷贝的区别?
引用拷贝就是对引用地址的拷贝,不会创建新对象,指向堆中的对象还是同一个对象。浅拷贝会创建一个新对象,但是新旧对象还是共享内存地址。
五、Java常见类
1.Object
(1)Object 类的常见方法有哪些?
Object 是所有类的父类,主要提供了以下 11 个方法:
/** * native 方法,用于返回当前运行时对象的 Class 对象,使用了 final 关键字修饰,故不允许子类重写。 */ public final native Class<?> getClass() /** * native 方法,用于返回对象的哈希码,主要使用在哈希表中,比如 JDK 中的HashMap。 */ public native int hashCode() /** * 用于比较 2 个对象的内存地址是否相等,String 类对该方法进行了重写以用于比较字符串的值是否相等。 */ public boolean equals(Object obj) /** * naitive 方法,用于创建并返回当前对象的一份拷贝。 */ 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 /** * 实例被垃圾回收器回收的时候触发的操作 */ protected void finalize() throws Throwable { }
(2)== 和 equals() 的区别?
-
== 对于基本类型和引用类型的作用效果是不同的:
- 对于基本数据类型来说,== 比较的是值;
- 对于引用数据类型来说,== 比较的是对象的内存地址。
-
equals() 不能用于判断基本数据类型的变量,只能用来判断两个对象是否相等。
- 如果类没有重写equals()方法,使用就是Object的equals()方法。此时通过equals()比较该类的两个对象时,等价于 ==,比较的是内存地址。
-
还可以重写equals()方法,比如重写equals()方法来比较两个对象中的属性是否相等。比如String中的equals()就是被重写过的,它用来比较字符串的值是否相等,即使二者是两个不同的对象,只要值相等,就会返回true。
(3)hashCode()有什么用?为什么要有hashCode()?
hashCode()是用来获取对象的哈希码。
我们知道HashSet不允许元素重复,它在检查重复时,不是直接用equals()来检查,而是用到了hashCode()。当把对象加入HashSet时,会先通过hashCode()计算对象的哈希值来判断对象加入的位置,同时也会与其他对象的哈希值比较,如果没有相同的哈希值,HashSet会认为没有重复出现;如果发现加入的对象与已有对象有相同的哈希值,会再调用equals()方法来检查二者是否真的相同,如果二者相同,该对象就不能加到HashSet中;如果不同,就会将该对象重新散列到其他位置。这样可以减少equals的次数,提高执行速度。
(4)为什么重写 equals() 时必须重写 hashCode() 方法?
如果重写equals()时没有重写hashCode(),可能会导致用equals()判断两个对象是否相等时,出现对象相等但哈希值不等的情况,而我们要保证对象相等哈希值也相等,所以重写equals就必须重新hashCode。
【tips】对象相等,哈希值必须相等;哈希值相等,对象不一定相等(哈希冲突);哈希值相等+equals返回true,对象一定相等;哈希值不等,对象一定不等。
2.String
(1)★String、StringBuffer、StringBuilder 的区别?
-
可变性
- String 是不可变的。
- StringBuilder 与 StringBuffer 都继承自 AbstractStringBuilder 类,它提供了很多修改字符串的方法,比如 append()。
-
线程安全性
- String对象是不可变的,就可以理解为常量,是线程安全的。
- StringBuffer 对 AbstractStringBuilder 中的方法加了同步锁,所以是线程安全的。
- StringBuilder 并没有对方法加同步锁,所以是非线程安全的。
-
性能
- 每次对 String 进行改变时,都会生成一个新的 String 对象。
- StringBuffer 和 StringBuilder 每次都会对对象本身进行操作,而不是生成新的对象。StringBuilder 比 StringBuffer 性能要好一点,但有多线程不安全的风险。
综上,在操作少量的数据时,适用 String;单线程操作大量数据,适用 StringBuilder;多线程操作大量数据,适用 StringBuffer。
(2)String 为什么是不可变的?
String不可变指的是给一个已有字符串赋值时,不是在原内存地址上修改数据,而是重新指向一个新地址,也就是说赋值后成了一个新对象。
那么String为什么不可变呢?这就要看String的源码了:
String的本质是个char数组value,String就是用这个value数组保存字符串的。这个数组被final修饰并且是private的,而且String也没有提供修改这个数组的方法,同时String类也被final修饰了,所以它不能被继承,就避免了子类破坏String的不可变性。所以说问起String为什么不能被修改,从三个方面回答:
- 数组被final和private修饰;
- 没有提供修改数组的方法;
- String被final修饰。
(3)字符串拼接用“+” 还是 StringBuilder?
运算符重载就是对已有的运算符重新进行定义,赋予其另一种功能,以适应不同的数据类型。Java 不支持运算符重载,但是“+”和“+=”是专门为 String 类重载过的运算符,也是 Java 中仅有的两个重载过的运算符。
字符串对象通过“+”拼接,实际上是通过StringBuilder调用append(),拼接完成后再调用toString()来实现的。所以说不建议在循环中用+拼接字符串,StringBuilder对象是在循环内部被创建的,这意味着每循环一次就会创建一个 StringBuilder 对象。
(4)★字符串常量池的作用了解吗?
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对String 类专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
(5)String s1 = new String("abc");这句话创建了几个字符串对象?
要看字符串常量池中有没有"abc"的引用。
- 如果字符串常量池中不存在“abc”的引用,那么会在堆中创建 2 个字符串对象“abc”;
-
如果字符串常量池中已存在“abc”的引用,则只会在堆中创建 1 个字符串对象“abc”。
(6)String 类型的变量和常量做“+”运算时发生了什么?
-
字符串变量使用+进行拼接时,实际上是调用了StringBuilder的append()和toSting()方法,最终会得到一个新的String对象(堆中)。所以String str4=str1+str2;等价于:
String str4 = new StringBuilder().append(str1).append(str2).toString();
-
字符串常量(加了final)会被编译器当成常量处理,对其进行常量折叠优化,会将拼接结果放在常量池。
常量折叠就是会把String c = "str" + "ing"优化成String c = "string"。
六、异常
1.Exception 和 Error 有什么区别?
Exception 和 Error 有一个共同的祖先Throwable。二者的区别是:
- Exception 是程序本身可以处理的异常,可以通过catch来捕获。Exception又可以分为 Checked Exception (受检查异常,必须处理) 和 Unchecked Exception (不受检查异常,可以不处理)。
- Error 属于程序无法处理的错误 ,不建议通过catch捕获 。例如 jvm运行错误(Virtual MachineError)、OOM、类定义错误(NoClassDefFoundError)等 。Error异常发生时,JVM一般会选择线程终止。
2.Checked Exception 和 Unchecked Exception 有什么区别?
- Checked Exception又叫受检查异常,在编译过程中,必须对受检查异常进行处理,不然不能通过编译。除了RuntimeException及其子类以外,其他的Exception类及其子类都属于受检查异常。常见的有IO 相关的异常、ClassNotFoundException、SQLException等。
-
Unchecked Exception又叫不受检查异常 ,这些异常在写代码时不会报错,编译时也不会检查,只有在运行时才会报错。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 返回值。(其实还是上面的try遇到return就先执行finally)
5.finally 中的代码一定会执行吗?
不一定的!如果jvm在 finally 之前被终止(System.exit(1);//终止jvm)了,finally 中的代码就不会被执行。七、泛型
1.什么是泛型?有什么作用?
泛型可以指定传入的参数类型,比如 ArrayList<Person> persons = new ArrayList<Person>();这行代码就指明了该ArrayList对象只能传入Person对象,如果传入其他类型的对象就会报错。
2.泛型有哪些使用方式?
泛型一般有三种使用方式:泛型类、泛型接口、泛型方法。
(1)泛型类
实例化泛型类:
(2)泛型接口
(2)泛型接口
(3)泛型方法
3.静态泛型方法能使用类上声明的泛型吗?
上面的 public static <E> void printArray( E[] inputArray )一般被称为静态泛型方法。类在实例化时才能传递类型参数,而静态方法的加载在类的实例化之前,也就是说类中的泛型还没有传递类型,静态方法就加载完了,所以静态泛型方法不能使用类上声明的泛型的,只能使用自己声明的<E>。
4.★项目里用了哪些泛型?
- 自定义接口的通用返回结果CommonResult<T>时,通过参数T可根据具体的返回类型动态指定结果的数据类型。
- 构建集合工具类(参考Collections中的sort,binarySearch方法)。
八、反射与注解 Annotation
1.什么是反射?
通过反射可以在运行时获取类的属性和执行类的方法,比如类的构造器对象Constructor、成员变量对象Field、成员方法对象Method。
反射的关键和前提就是要先获取类的字节码文件.class,只要存在对应的class,一切都能够被调用。获取class对象的三种方法:
2.★反射有哪些应用场景?
- 在JDBC连接数据库时,先通过Class.forName()加载数据库驱动,这一步就是通过反射来完成的。
- 框架中的注解、xml文件的解析,解析出来的是字节码字符串,因此必须通过反射将字符串实例化成对象。
3.什么是注解?
注解是可以看作是一种特殊的注释,可以加在类、方法或者变量上,提供某些信息供程序在编译或者运行时使用。注解本质是一个继承了Annotation 的特殊接口:
4.注解的解析方法有哪几种?
常见的解析注解的方法有两种:
- 编译期直接扫描:编译器在代码编译时会扫描注解并处理,比如某个方法使用@Override 注解,编译器在编译时就会检测是否进行了方法重写。
- 运行期通过反射处理:框架中自带的注解,比如 Spring 框架的 @Value 、@Component,都是通过反射来进行处理的。这些注解解析出来的是字节码字符串,必须通过反射将字符串实例化成对象。
九、SPI
1.何谓 SPI?
SPI(Service Provider Interface)字面意思就是:“服务提供者的接口”,我的理解是:专门给服务提供者使用的一个接口。
2.SPI和API的区别?
从广义上来讲,二者都是接口。但是API和SPI接口的位置不同。为了实现服务调用者和服务提供者之间的通讯,我们会引入一个接口。
- 当服务提供者实现了一个功能,并且定义了对应的接口,那么服务调用者就可以通过该接口来使用这个功能。这就是API,接口和实现都在服务提供方。
- 当接口放在服务调用者,由调用者定义接口的规则,然后不同的服务提供者根据接口规则对接口进行实现,从而提供服务,这就是SPI。
3.SPI 的优缺点?
通过 SPI 可以提高接口设计的灵活性,但是 SPI 机制也存在一些缺点:
- 需要遍历加载所有的实现类,不能做到按需加载,效率相对较低。
- 当多个 ServiceLoader 同时 load 时,会有并发问题。
十、序列化与反序列化
1.什么是序列化和反序列化?
如果要对对象数据进行跨平台存储和网络传输,就要用到IO,而IO支持的是字节流,不能直接传输对象,所以我们就要把对象转换成字节流(序列化),同时我们还要能再根据字节流还原数据(反序列化),这两个过程分别对应的就是序列化和反序列化。所以,简单来说:
- 序列化: 将对象转换成二进制字节流的过程。
- 反序列化:将在序列化过程中所生成的二进制字节流转换成对象的过程。
2.序列化和反序列化常见应用场景
- 对象在进行网络传输(比如远程调用 RPC 的时候)之前,需要先被序列化,接收到序列化的对象后,还需要再进行反序列化;
- 将对象存储到文件之前需要进行序列化,将对象从文件中读取出来需要进行反序列化;
- 将对象存储到数据库(如 Redis)之前需要用到序列化,将对象从缓存数据库中读取出来需要反序列化;
- 将对象存储到内存之前需要进行序列化,从内存中读取出来之后需要进行反序列化。
3.序列化协议对应于 TCP/IP 4 层模型的哪一层?
如上图所示,OSI 七层协议模型中,表示层做的事情主要就是对应用层的用户数据进行处理转换为二进制流。反过来的话,就是将二进制流转换成应用层的用户数据。OSI 七层协议模型中的应用层、表示层和会话层对应的都是 TCP/IP 四层模型中的应用层,所以序列化协议属于 TCP/IP 协议应用层。
4.★常见序列化协议有哪些?
-
JDK 自带的序列化方式
一般不推荐,序列化效率低并且存在安全问题。JDK 自带的序列化,只需实现 java.io.Serializable接口即可。
serialVersionUID 是什么?有什么作用?
——serialVersionUID 是序列化号,是用来做版本控制的。反序列化时,会检查serialVersionUID是否和当前类的serialVersionUID一致。如果不一致,则会抛出 InvalidClassException 异常。
【tips】强烈推荐每个序列化类都手动指定其serialVersionUID,如果不手动指定,那么编译器会动态生成默认的serialVersionUID。
如果有些字段不想进行序列化怎么办?
——对于不想进行序列化的变量,可以用 transient 修饰。当然在反序列化时,被transient修饰的变量也不会被持久化和恢复。
关于transient还有几点注意:- 只能修饰变量,不能修饰类和方法。
- transient修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰int类型,那么反序列后结果就是0。
- static变量因为属于类而不属于任何对象,所以无论有没有被transient修饰,都不会被序列化。
-
JSON 和 XML 序列化方式
这种属于文本类序列化方式。虽然可读性比较好,但是性能较差。
十一、IO流
1.IO流简介
IO 即输入输出(Input/Output),数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。我们知道为了保证os的安全和稳定,一个进程的地址空间分为用户空间和内核空间。平常运行的应用程序都是运行在用户空间,只有内核空间才能进行系统态级别的资源有关的操作,比如文件管理、进程通信、内存管理等等。也就是说,我们想要进行 IO 操作,就要依赖内核空间的能力。因此,用户进程想要执行 IO 操作的话,必须通过系统调用来间接访问内核空间。当应用程序发起 I/O 系统调用后,会经历两个步骤:
- 内核等待 I/O 设备准备好数据;
- 内核将数据从内核空间拷贝到用户空间。
数据传输过程类似于水流,因此称为 IO 流。IO 流在 Java 中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流。
IO 流中有四个超类:
- InputStream/OutputStream:字节输入/输出流的超类;
- Reader/Writer:字符输入/输出流。
2.字节流
(1)字节输入流 InputStream
InputStream 用来读取字节数据到内存中,java.io.InputStream抽象类是所有字节输入流的父类。
-
FileInputStream 是 InputStream 的子类,可以直接指定文件路径,用来从文件中读取字节数据。
【tips】一般不会单独使用 FileInputStream ,通常会配合 BufferedInputStream 字节缓冲输入流来使用。
-
DataInputStream 用于读取指定类型数据,不能单独使用,必须结合 FileInputStream 。
-
ObjectInputStream 用于从输入流中读取 Java 对象,是反序列化的过程。
(2)字节输出流 OutputStream
OutputStream 用于将字节数据写入到目的地,java.io.OutputStream抽象类是所有字节输出流的父类。
- FileOutputStream 是最常用的字节输出流对象,可直接指定文件路径。
- DataOutputStream 用于写入指定类型数据,不能单独使用,必须结合 FileOutputStream。
-
ObjectOutputStream 用于将对象写入到输出流,是序列化的过程。
3.字符流
如果要传输的内容有中文,在使用字节流传输时会出现乱码。I/O 流提供了一个直接操作字符的接口,方便对字符进行流操作。
字节流适用于传输音频文件、图片等媒体文件,要传输的内容涉及到字符,用字符流比较好。
(1)字符输入流 Reader
Reader用于将字符数据(文本)读取到内存中,java.io.Reader抽象类是所有字符输入流的父类。
-
FileReader:可以直接操作字符文件。InputStreamReader 是字节流转换为字符流的桥梁,FileReader 就是它的子类。
(2)字符输出流 Writer
Writer 用于将字符数据写入到目的地(通常是文件),java.io.Writer抽象类是所有字符输出流的父类。
- FileWriter
4.字节缓冲流
IO 操作十分消耗性能,可以利用缓冲流先将数据加载到缓冲区,然后一次性读取/写入多个字节,从而避免频繁的 IO 操作,能提高流的传输效率。
(1)BufferedInputStream(字节缓冲输入流)
BufferedInputStream 从将字节数据读取到内存时,不会一个字节一个字节的读取,而是会先将读取到的字节存放在缓存区,并从内部缓冲区中单独读取字节。这样大幅减少了 IO 次数,提高了读取效率。
BufferedInputStream 内部维护了一个字节数组来实现缓冲的作用。
【tips】缓冲区大小默认为 8192 字节,也可以通过 BufferedInputStream(InputStream in, int size) 这个构造方法来指定缓冲区的大小。
(2)BufferedOutputStream(字节缓冲输出流)
类似于 BufferedInputStream ,BufferedOutputStream 内部也维护了一个缓冲区,并且,这个缓存区的大小也是 8192 字节。
5.字符缓冲流
BufferedReader(字符缓冲输入流)和BufferedWriter(字符缓冲输出流)类似于BufferedInputStream(字节缓冲输入流)和BufferedOutputStream(字节缓冲输入流),内部都维护了一个字节数组作为缓冲区。
6.打印流
我们经常使用sout:
System.out 是用于获取一个PrintStream对象,print方法实际调用的就是PrintStream对象的write方法。
PrintStream属于字节打印流,与之对应的是PrintWriter(字符打印流)。
PrintStream是OutputStream的子类,PrintWriter是Writer的子类。
7.★Java IO中涉及到哪些设计模式?
(1)装饰器模式(Decorator Pattern)
装饰器模式通过组合替代继承,可以在不改变原有对象的情况下拓展其功能。在一些继承关系比较复杂的场景(IO 这一场景各种类的继承关系就比较复杂)更加实用。
以字节流为例,FilterInputStream 和 FilterOutputStream 是装饰器模式的核心,分别可以增强InputStream和OutputStream子类的功能。比如常见的字节缓冲输入流BufferedInputStream就是 FilterInputStream的子类,可以通过字节缓冲流来增强FileInputStream的功能。
(2)适配器模式(Adapter Pattern)
适配器模式主要用于协调接口不同的类。
比如IO流中的字节流和字符流的接口不同,它们之间可以协调工作就是基于适配器模式来做的。通过适配器,我们可以将字节流对象适配成一个字符流对象,这样我们可以直接通过字节流对象来读写字符数据。InputStreamReader和OutputStreamWriter就是两个适配器。InputStreamReader使用StreamDecoder(流解码器)对字节进行解码,实现字节流到字符流的转换, OutputStreamWriter使用StreamEncoder(流编码器)对字符进行编码,实现字符流到字节流的转换。
-
适配器模式和装饰器模式有什么区别呢?
-
装饰器模式 更侧重于动态地增强原始类的功能,装饰器类需要跟原始类继承相同的抽象类或者实现相同的接口。并且,装饰器模式支持对原始类嵌套使用多个装饰器。
- 适配器模式 更侧重于让接口不同的类可以一起工作,当我们调用适配器对应的方法时,适配器内部会调用适配者类或者和适配类相关的类的方法,这个过程透明的。就比如说StreamDecoder(流解码器)和StreamEncoder(流编码器)就是分别基于InputStream和OutputStream来获取FileChannel对象并调用对应的read方法和write方法进行字节数据的读取和写入。
-
装饰器模式 更侧重于动态地增强原始类的功能,装饰器类需要跟原始类继承相同的抽象类或者实现相同的接口。并且,装饰器模式支持对原始类嵌套使用多个装饰器。
(3)工厂模式
工厂模式用于创建对象,比如Files类的newInputStream方法用于创建InputStream对象(静态工厂)、Paths类的get方法创建Path对象(静态工厂)、ZipFileSystem类(sun.nio包下的类,属于java.nio相关的一些内部实现)的getPath的方法创建Path对象(简单工厂)。
(4)观察者模式
NIO 中的文件目录监听服务使用到了观察者模式。
NIO 中的文件目录监听服务基于WatchService接口和Watchable接口。WatchService属于观察者,用于监听文件目录的变化,同一个 WatchService 对象能够监听多个文件目录;Watchable属于被观察者。Watchable接口定义了一个用于将对象注册到WatchService(监控服务) 并绑定监听事件的方法register。
8.Java IO模型
(1)有哪些常见的 IO 模型?
UNIX 系统下, IO 模型一共有 5 种: 同步阻塞 I/O、同步非阻塞 I/O、信号驱动 I/O 、异步 I/O、I/O 多路复用。
【tips】什么是同步、异步、阻塞、非阻塞?
- 同步是指用户线程发起IO请求后,需要等待内核IO操作完成后才能继续执行;
- 异步是指用户线程发起IO请求后仍能继续执行,当内核IO操作完成后会通知用户线程;
- 阻塞是指应用程序发起read调用后会一直阻塞直到从内核把数据拷贝到用户空间;
- 非阻塞是指IO操作被调用后立即返回给用户一个状态值,无需等到IO操作彻底完成。
(2)Java 中 3 种常见 IO 模型
-
BIO (Blocking I/O)
BIO 属于同步阻塞 IO 模型,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。显然这种IO模型在面对高并发时并不适用。
-
NIO (Non-blocking/New I/O)
NIO 中的 N 可以理解为 Non-blocking,属于同步非阻塞IO模型,它是支持面向缓冲、基于通道的 I/O,适用于高负载、高并发的(网络)应用。
在这种模型中,应用程序会反复发起 read 调用,但是等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的。通过反复调用read,避免了一直阻塞(没有完全避免阻塞)。
但是不断进行read调用是十分消耗 CPU 资源的。这就用到了IO多路复用模型。
IO 多路复用模型中,线程会先发起 select 调用,询问内核数据是否准备就绪,准备就绪后线程再发起 read 调用。不过数据从内核空间拷贝到用户空间还是阻塞的。IO 多路复用模型通过减少无效的系统调用,减少了对 CPU 资源的消耗。 -
AIO (Asynchronous I/O)
AIO属于异步IO模型,基于事件和回调机制,也就是说在调用read后会直接返回,不会一直阻塞在那里,当后台处理完成后,操作系统会通知相应的线程继续进行后续的操作。
9.★什么是IO多路复用?select、poll、epoll之间的区别?
IO多路复用是一种异步阻塞IO模型,"多路复用"指的是一个线程可以处理多个IO请求,不需要创建和维护太多的线程。
这三者都是IO多路复用的系统调用函数,通过这些函数可以同时监视多个文件描述符(fd),询问内核数据是否准备好。
(1)select
select是通过轮询遍历文件描述符集合(fdSet)的方式,找到就绪的文件描述符,然后触发io操作,
【优点】
支持跨平台。
【缺点】
①因为select需要轮询遍历fdSet,所以随着fd数量(吞吐量)的增加,性能会不断下降;而且每次调用select时,都需要在内核和用户空间之间复制fdSet,这样开销也比较大;
②一个进程能监视的fd的数量是有限的,一般是1024个。
(2)poll
poll和select差不多,也是采用轮询遍历的方式,但是poll是用链表来存储fd,所以它的优点就是没有最大fd数量的限制(主要取决于内存的大小)。
【缺点】
采用轮询遍历的方式,随着fd数量增加,性能会下降。
采用轮询遍历的方式,随着fd数量增加,性能会下降。
(3)★epoll
epoll是基于事件驱动,它会申请一个epollfd文件(B+树)来管理fd,主要会调用epoll_create()、epoll_ctl()、epoll_wait()。
- 通过调用epoll_create()来创建一个fd对象;
- epoll_ctl()会在每新建一个连接的时候,更新fd的信息,并绑定一个回调函数;
- epoll_wait()会轮询所有的回调函数集合,根据事件触发io操作。
①epoll将轮询(O(n))改成了回调(O(1)),不会出现随着fd数量增加性能下降的情况;
②没有fd数量的限制,主要受限于系统能建立的最大连接数。
【缺点】
只能在Linux下工作,不支持跨平台。
十二、语法糖
1.什么是语法糖?
语法糖(Syntactic sugar) 指的是为了方便开发而设计的一种特殊语法。实现相同的功能,基于语法糖写出来的代码往往更简单简洁且更易阅读。
JVM 是不能识别语法糖的,语法糖要想被正确执行,需要先通过编译器进行解糖(desugar()),也就是在编译成 JVM 能识别的语法。也就是说 Java 中真正支持语法糖的是 Java 编译器而不是 JVM。
switch 其实只能使用整型,对char类型的比较也是根据其ascii码。在Java 7之后,switch 通过 equals()和hashCode() 来支持string,也就是说进行switch的实际是字符串的哈希值,然后通过equals()避免哈希冲突。
Java 中最常用的语法糖主要有泛型、自动拆装箱、变长参数、枚举、内部类、增强 for、try-with-resources 语法、lambda 表达式等。
2.Java中常见的语法糖
(1)switch 支持 String 与枚举
(2)泛型
jvm并不能识别泛型,泛型信息只存在于编译阶段,在进入JVM之前,会进行类型擦除,也就是说在字节码中是没有泛型的。
类型擦除就是将所有的泛型参数用其最顶级的父类替换,然后移除所有类型参数。
- 如果泛型没有限制,如<T>,那么就用Object替换;
- 如果泛型有限制,如<T extends Comparable>,那么就用Comparable替换。
(3)自动装箱与拆箱
-
自动装箱是基本数据类型转成对应的包装类,通过调用 valueOf 方法实现:
-
自动拆箱是包装类转成基本数据类型,通过调用 xxxValue 方法实现:
(4)可变长参数
可变参数在被使用的时候,他首先会创建一个数组,数组长度就是实参的个数,然后再把参数值全部放到这个数组当中,相当于把这个数组作为参数传递到被调用的方法中。
反编译后代码:
(5)枚举 enum
-
创建一个枚举:
-
当我们使用enum来定义一个枚举类型的时候,编译器会自动帮我们创建一个final类型的类继承Enum类,所以枚举类型不能被继承。
(6)内部类
(7)条件编译
(8)断言assert
断言是程序中的错误,遇到时必须终止程序。断言的底层实现就是 if 语句,如果断言的结果为 true,程序继续执行,如果结果为 false,则程序抛出 AssertError 来打断程序的执行。
(9)数值字面量
字面量其实就是数值,比如你的体重是70kg,你的身高是172cm,西瓜价格是3元/kg等等,这些就是字面量(数据/数值)。
数值字面量,不管是整数还是浮点数,都允许在数字之间插入任意多个下划线。这些下划线不会对字面量的数值产生影响,目的就是方便阅读。比如:
在编译时,就会直接把"_"去掉,反编译的结果:
(10)增强for(for-each)
for-each 的实现原理其实就是使用了普通的 for 循环和迭代器。
(11)try-with-resource
1)简介
对于IO操作,数据库连接等,用完需要调用close()方法将其关闭。关闭资源一般放在finally块里,比如,我们经常会写这样的代码:
从 Java 7 开始,可以使用try-with-resources语句改写上面的代码,就不用我们手动close资源了:
2)try-with-resource处理机制
- 所有实现了 java.lang.AutoCloseable 接口的都可以作为资源,把资源对象声明在try的()里,在try结束后,close方法会被自动调用,关闭资源;
- 在 try 语句中越是最后使用的资源,越是最早被关闭;
- 不管是否出现异常,资源都会被调用close方法。
3)try-with-resource对异常的处理
-
在之前的try-catch-finally,假设try和finally中的close()都可能发生异常:
- try 中没有发生异常时,直接调用finally块,这时如果 close 发生异常,就处理close的异常;
- try 中发生了异常,就会被 catch 捕捉,进行异常处理,然后调用 finally 块,如果 close 发生异常,就进行close的异常处理。
- 在try-with-resource中,假设try和自动调用close()时都可能发生异常:
- try 没有发生异常时,然后自动调用 close 方法,如果调用close时发生异常,会被 catch 捕捉并处理该异常;
-
try 发生了异常,然后自动调用 close 方法,如果 close 也发生异常,catch 只会捕捉 try 抛出的异常。
【tips】无论是否发生异常,try完了都先调用close()。
(12)lambda表达式
3.可能遇到的坑
(1)泛型
-
当泛型遇到方法重载
在上面的方法重载中,虽然二者参数类型不同,一个是List<String>,另一个是List<Integer>,但是这段代码是编译通不过的,因为在编译时会进行类型擦除,这两个参数的类型都会变成List,就不符合方法重载的条件了。 -
当泛型遇到catch
泛型的类型参数不能用在 catch 中。因为异常处理是由 JVM 在运行时刻进行的,由于类型信息被擦除,JVM 会无法区分异常类型的。 -
当泛型内包含静态变量
以上代码输出结果为:2!由于经过类型擦除,所有的泛型类实例都关联到同一份字节码上,因此泛型类的所有静态变量是共享的。
(2)自动装箱与拆箱
在对包装类进行对象比较时,要注意包装类的缓存机制,如果在缓存范围(-128~127)内,引用的就是同一个对象,所以c == d是true;而a和b不在缓存范围内,就是各自创建的新对象,所以a == b是false。
(3)增强for
在使用增强for时,不能对被迭代对象进行结构上的修改,比如下面这段代码,直接把一个要遍历的对象给删除了,这就属于修改了结构:
会抛出ConcurrentModificationException异常。因为增强for的底层原理是利用普通for循环+迭代器Iterator。迭代器被创建后会建立一个指向原来对象的单链索引表,当对象数量发生变化时,索引表的内容不会同步改变,所以如果对被迭代对象进行了结构上的修改,索引指针往后移动的时候就找不到要迭代的对象,按照 fail-fast (快速失败)原则, Iterator 会马上抛出ConcurrentModificationException异常。
但是可以使用 Iterator 自己的remove()来删除对象,Iterator.remove() 会在删除当前迭代对象的同时维护索引的一致性。
十三、Java 代理模式
代理模式简单来说就是使用代理对象来代替对真实对象(real object)的访问,这样就可以在不修改目标对象的前提下,提供额外的功能操作,扩展目标对象的功能。
代理模式有静态代理和动态代理两种实现方式。
1.静态代理
(1)什么是静态代理?
静态代理中,我们对目标对象的每个方法的增强都是手动完成的,非常不灵活(一旦增加方法,目标对象和代理对象都要进行修改),而且需要对每个目标类都单独写一个代理类。
从 JVM 层面来说, 静态代理在编译时就将接口、实现类、代理类这些都变成一个个实际的 class 文件。
静态代理在实际应用场景中用的非常非常少。
(2)静态代理实现步骤
- 定义一个接口及其实现类;
- 创建一个代理类同样实现这个接口;
- 将目标对象注入代理类,然后在代理类的对应方法调用目标类中的对应方法。
代码演示:
-
定义一个发送短信的接口
-
实现该接口
-
创建代理类并同样实现该接口
-
将目标对象注入代理类,然后在代理类的对应方法调用目标类中的对应方法
-
使用代理对象来代理访问发送短信接口的实现类
-
执行结果
2.★动态代理
(1)简介
相比于静态代理来说,动态代理更加灵活,不需要针对每个目标类都创建一个代理类,也不需要我们必须实现接口,可以直接代理实现类( CGLIB 动态代理机制)。
从 JVM 角度来说,动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。
动态代理在日常开发中使用的相对较少,但是在框架中的几乎是必用的一门技术。学会了动态代理之后,对于我们理解和学习各种框架的原理也非常有帮助。Spring AOP、RPC 框架的实现都依赖了动态代理。
就 Java 来说,动态代理的实现方式有很多种,比如 JDK 动态代理、CGLIB 动态代理等等。(2)JDK 动态代理机制
1)JDK 动态代理简介
在 Java 动态代理机制中 InvocationHandler 接口和 Proxy 类是核心。
-
Proxy类中使用频率最高的方法是:newProxyInstance(),这个方法主要用来生成一个代理对象。注意三个参数!
-
要实现动态代理的话,还必须实现InvocationHandler来自定义处理逻辑。当我们的动态代理对象调用一个方法时,这个方法的调用就会被转发到InvocationHandler接口的invoke()来调用。
invoke() 方法有下面三个参数:
- proxy :动态生成的代理类
- method : 与代理类对象调用的方法相对应
- args : 当前 method 方法的参数
也就是说:通过Proxy类的newProxyInstance()创建的代理对象,在调用方法的时候,实际上调用的是实现了InvocationHandler接口的实现类的invoke()方法。所以可以在invoke()方法中自定义处理逻辑,比如在方法执行前后做什么事情。
定义一个接口及其实现类(目标类,被代理的类);
自定义InvocationHandler的实现类并重写invoke方法,在invoke方法中我们可以通过反射调用被代理类的方法)并自定义一些处理逻辑;
通过Proxy.newProxyInstance()方法创建代理对象。
2) JDK 动态代理类使用步骤
代码演示:
-
定义发送短信的接口及其实现类
-
定义一个JDK动态代理类(即实现了InvocationHandler类)
-
获取代理对象的工厂类
-
实际使用及执行结果
(3)CGLIB 动态代理机制
1)简介
JDK 动态代理有一个最致命的问题是只能代理实现了接口的类。为了解决这个问题,可以用 CGLIB 动态代理机制。CGLIBopen(Code Generation Library)是一个基于ASM的字节码生成库,它允许我们在运行时对字节码进行修改和动态生成。
CGLIB 通过继承的方式实现代理。
例如 Spring 中的 AOP 模块中,如果目标对象实现了接口,则默认采用 JDK 动态代理;否则采用 CGLIB 动态代理。
在 CGLIB 动态代理机制中MethodInterceptor接口和Enhancer类是核心。
-
需要自定义 MethodInterceptor 并重写 intercept 方法,intercept 用于拦截增强被代理类的方法。
- obj : 被代理的对象(需要增强的对象)
- method : 被拦截的方法(需要增强的方法)
- args : 方法入参
- proxy : 用于调用原始方法
-
可以通过 Enhancer类来动态获取被代理类,当代理类调用方法的时候,实际调用的是 MethodInterceptor 中的 intercept 方法。
2)CGLIB 动态代理类使用步骤
-
引入依赖:不同于 JDK 动态代理不需要额外的依赖。CGLIB实际是属于一个开源项目,需要手动添加相关依赖。
- 定义一个类(不用其实现类!);
- 自定义MethodInterceptor并重写intercept方法,intercept用于拦截增强被代理类的方法,和 JDK 动态代理中的invoke方法类似;
- 通过Enhancer类的create()创建代理类;
代码示例:
-
实现一个使用阿里云发送短信的类
-
自定义 MethodInterceptor
-
获取代理类
-
实际使用及执行结果
(4)JDK 动态代理和 CGLIB 动态代理的对比
- JDK 动态代理只能代理实现了接口的类或者直接代理接口,而 CGLIB 可以代理未实现任何接口的类。 另外,CGLIB 动态代理是通过生成一个被代理类的子类来拦截被代理类的方法调用,因此不能代理声明为 final 类型的类和方法。
- 就二者的效率来说,大部分情况都是 JDK 动态代理更优秀,随着 JDK 版本的升级,这个优势更加明显。
3.静态代理和动态代理的对比
- 灵活性 :动态代理更加灵活,不需要必须实现接口,可以直接代理实现类,并且可以不需要针对每个目标类都创建一个代理类;静态代理中,接口一旦新增加方法,目标对象和代理对象都要进行修改,这是非常麻烦的!
- JVM 层面 :静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件;而动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。
十四、★Java 集合
1.Java 集合概述
Java 集合也叫作容器,主要有两大接口,一个是Collection接口,主要用于存放单一元素;另一个是Map接口,主要用于存放键值对。对于Collection接口,下面又有三个主要的子接口:List、Set和Queue。
(1)集合体系结构
(2)List, Set, Queue, Map 四者的区别?
- List存储的元素是有序的、可重复的,这里的有序指的是存进去和取出来的顺序是一致的,不是说list里面一定是升序或者降序排列。
- Set存储的元素是无序的、不可重复的。
- Queue存储的元素是有序的、可重复的,遵循先进先出的原则。
- Map使用键值对存储元素,key 是唯一的,value 是无序的、可重复的,每个键最多映射到一个值。
2.集合框架底层数据结构总结
(1)List
- ArrayList:Object[]数组
- Vector:Object[]数组
- LinkedList:双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)
(2)Set
- HashSet(无序,唯一):基于HashMap实现的,底层采用HashMap来保存元素
- LinkedHashSet:LinkedHashSet是HashSet的子类,并且其内部是通过LinkedHashMap来实现的。有点类似于我们之前说的LinkedHashMap其内部是基于HashMap实现一样,不过还是有一点点区别的
- TreeSet(有序,唯一):红黑树(自平衡的排序二叉树)
(3)Queue
- PriorityQueue(优先级队列):Object[]数组来实现二叉堆
- ArrayQueue:Object[]数组 + 双指针
(4)Map
-
HashMap:
JDK1.8 之前HashMap由数组+链表组成的,数组是HashMap的主体,链表主要是为了解决哈希冲突。
JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,会将链表转化为红黑树,转换前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树,以减少搜索时间。 - LinkedHashMap:LinkedHashMap继承自HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。
- Hashtable:数组+链表组成的,数组是Hashtable的主体,链表则是主要为了解决哈希冲突而存在的
- TreeMap:红黑树(自平衡的排序二叉树)
3.List集合
(1)ArrayList 和 Vector 的区别?
二者都是List的实现类,底层用的都是数组,但是ArrayList是线程不安全的,Vector是线程安全的。
(2)ArrayList 与 LinkedList 区别?
- 线程安全: ArrayList和LinkedList都是不保证线程安全的;
- 底层数据结构: ArrayList使用的是数组;LinkedList使用的是 双向链表;
-
插入和删除、访问元素(数组和链表的区别):
- ArrayList采用数组存储,在插入和删除元素时需要移动元素,因此不适合频繁插入和删除元素,但是适合根据序号快速获取元素(get()方法);
- LinkedList采用链表存储,插入和删除元素比较方便,但是不支持随机访问元素。但是 LinkedList 仅仅在头尾插入或者删除元素的时候时间复杂度近似 O(1),其他情况增删元素的时间复杂度都是 O(n) 。不要认为LinkedList就一定最适合元素增删的场景。
- 内存空间占用:ArrayList的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的每一个元素都需要消耗比 ArrayList 更多的空间,因为LinkedList底层实现是双向链表,要额外存放直接后继和直接前驱。
(3)RandomAccess 接口
通过查看RandomAccess 接口可以发现,其实它什么都没有定义,所以我对它的理解只是用来标识当前实现类是否能随机访问。
所以ArrayList实现了RandomAccess接口, 而LinkedList没有实现。因为ArrayList底层是数组,而LinkedList底层是链表,数组天然支持随机访问,;而链表需要遍历到特定位置才能访问特定位置的元素,所以不支持快速随机访问。ArrayList实现了RandomAccess接口,就表明了它能快速随机访问,而不是说ArrayList因为实现了RandomAccess接口才具有快速随机访问功能的。
(4)ArrayList 的初始化方式
在JDK1.8中,ArrayList 有3种初始化方式:
-
通过无参构造用默认容量10初始化一个空列表。当真正向列表中添加第一个元素时,容量才会扩为 10。
-
通过带参构造传入一个指定容量:
-
通过带参构造传入一个集合:
(5)★ ArrayList 的扩容机制
- 我们以使用无参构造初始化的列表为例,它的默认容量大小为10。
-
当我们调用add()方法添加元素时,会先调用ensureCapacityInternal()方法得到最小扩容量(minCapacity),它会先判断是不是空列表,如果是空列表,会比较minCapacity与默认容量10的大小,然后取大的作为minCapacity,然后再调用ensureExplicitCapacity()方法;如果不是空列表的话,会直接调用ensureExplicitCapacity()方法。
-
调用ensureExplicitCapacity()方法,通过比较minCapacity是否大于列表长度,来判断需不需要扩容,需要扩容的话就会调用grow()方法进行扩容。
-
grow()方法的扩容机制是先扩容为原来列表长度的1.5倍,如果还是不能满足最小扩容量,就直接扩容到最小扩容量。如果扩容后的容量大于MAX_ARRAY_SIZE(最大数组容量),就会调用hugeCapacity()方法。
-
在hugeCapacity()方法中,会判断 minCapacity 与 MAX_ARRAY_SIZE的大小,如果minCapacity大于最大数组容量,那么扩容后的容量则为Integer.MAX_VALUE;否则,扩容后的容量为 MAX_ARRAY_SIZE( 即为 Integer.MAX_VALUE - 8)。
【tips】因为数组需要8bytes去存储它自己的大小。
(6)说一下 System.arraycopy() 和 Arrays.copyOf()方法?
二者都可以用来复制数组。
arraycopy() 方法是一个native方法,他可以传入5个参数:源数组、源数组起始位置、目标数组、目标数组起始位置、要复制的元素个数,因此 arraycopy() 是需要目标数组的,会将源数组拷贝到目标数组(源数组也能做目标数组),而且可以选择拷贝的起点和长度以及放入新数组中的位置。
copyOf() 方法会new一个新数组,然后调用arraycopy()进行复制,最后返回new出来的新数组。
4.Set集合
(1)★Comparable 和 Comparator 的区别
一般我们需要对一个集合使用自定义排序时,我们就要重写compareTo()方法或compare()方法。
-
使用Comparator比较器对arrayList进行降序排序:
(2)比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同
- 三者都是Set的实现类,都能保证元素唯一,且都不是线程安全的;
-
HashSet、LinkedHashSet和TreeSet的主要区别在于底层数据结构不同:
- HashSet的底层数据结构是哈希表(基于HashMap实现)。
- LinkedHashSet的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。
- TreeSet底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。
-
底层数据结构不同又导致这三者的应用场景不同:
- HashSet用于不需要保证元素插入和取出顺序的场景;
- LinkedHashSet用于保证元素的插入和取出顺序满足 FIFO 的场景;
- TreeSet用于支持对元素自定义排序规则的场景。
5.Queue
(1)Queue 与 Deque 的区别
-
Queue 是单向队列,继承了Collection接口,对失败的处理方式有两种,一种在操作失败后会抛出异常,另一种则会返回特殊值。
-
Deque 是双向队列,在队列的两端均可以插入或删除元素。Deque 继承了 Queue 的接口, 增加了在队首和队尾进行插入和删除的方法,同样根据失败后处理方式的不同分为两类:
Deque 还提供了 push() 和 pop() 等其他方法,可用于模拟栈。
(2)ArrayDeque 与 LinkedList 的区别
二者都实现了Deque接口,都能用来模拟队列。
-
ArrayDeque是底层是用数组和双指针来实现的,而LinkedList是用链表来实现。
-
ArrayDeque不支持存储NULL,但LinkedList支持。
-
ArrayDeque插入时可能存在扩容过程(可变数组), 不过均摊后的插入操作依然为 O(1)。虽然LinkedList不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。
从性能的角度上来说,用 ArrayDeque 来实现队列要比 LinkedList 更好。此外,ArrayDeque 也可以用于实现栈。
(3)说一说 PriorityQueue
PriorityQueue是优先级队列,总是优先级最高的元素先出队。
- PriorityQueue利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据
- PriorityQueue通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。
- PriorityQueue是非线程安全的,且不支持存储NULL。
- PriorityQueue默认是小顶堆,但可以接收一个Comparator作为构造参数,从而来自定义元素优先级的先后。
6.Map
(1)HashMap 和 Hashtable 的区别
- 线程是否安全: HashMap是非线程安全的,Hashtable是线程安全的,因为Hashtable内部的方法基本都经过synchronized修饰。(如果你要保证线程安全的话就使用ConcurrentHashMap)
- 效率: 因为线程安全的问题,HashMap要比Hashtable效率高一点。另外,Hashtable基本被淘汰,不要在代码中使用它;
- 对 Null key 和 Null value 的支持: HashMap可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出NullPointerException。
-
初始容量大小和每次扩充容量大小的不同 :
- 创建时不指定容量初始值,Hashtable默认大小为 11,之后每次扩充容量变为原来的 2n+1。HashMap默认大小为 16,之后每次扩充容量变为原来的 2 倍。
-
创建时指定了容量初始值,Hashtable会直接用指定的大小,而HashMap会将其扩充为 2 的幂次方大小,也就是说HashMap总是使用 2 的幂作为哈希表的大小。
下面这个方法保证了 HashMap 总是使用 2 的幂作为哈希表的大小:
- 底层数据结构:JDK1.8 以后的HashMap在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树,同时转化前会判断,如果当前数组的长度小于 64,那么会先扩容数组。Hashtable没有这样的机制。
(2)HashMap 和 TreeMap 区别
对于 HashMap 来说,TreeMap 主要多了对键排序的能力以及对元素搜索的能力。这是由于TreeMap实现了 NavigableMap 接口和 SortedMap 接口。
TreeMap实现了SortedMap接口后,可以在实例化时通过Comparator比较器对键进行定制排序。
【tips】 Navigable-易浏览的
(3)HashMap的底层实现
-
在 JDK1.8 之前,HashMap底层用的是 数组+链表。通过哈希计算得到key的hash值从而确定元素的位置,如果当前位置已经有元素了,也就是说发生了哈希冲突,就判断两个元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。
-
在 JDK1.8 之后,在解决哈希冲突时有了一点变化。当链表长度大于阈值(默认为 8)时,需要对链表进行转换:如果当前数组的长度小于 64,会先扩容数组;反之会将链表转化为红黑树。
(4)HashMap 的长度为什么是 2 的幂次方?
为了让HashMap存储更加高效,尽量减少哈希冲突的发生,应该尽量把数据分散均匀。HashMap采用的是对数组长度length取模的方式来得到数据的存放位置,要实现这个操作首先我们想到的是取余(%)操作,也就是"hash值%length",但实际上HashMap是用与(&)操作来实现的,即hash&(length-1),因为&是二进制位操作,比%运算的更快。但是只有当取余的除数是2的幂次方时,二者才是等价的,所以HashMap的长度要设计成2的幂次方。
(5)HashMap 多线程操作导致死循环问题
首先HashMap本身就是线程不安全的,因此在多线程下推荐使用ConcurrentHashMap。
HashMap在多线程下可能导致死循环问题的主要原因在于,并发下的 Rehash 会造成元素之间会形成一个循环链表。
(6)★HashMap 有哪几种常见的遍历方式?
总的来说有四大类,可以用迭代器(Iterator)、For Each、Lambda 表达式以及 Stream流对HashMap进行遍历,其中又可以选择EntrySet或者KeySet进行遍历。
-
使用迭代器进行遍历:对HashMap来说,可以分别获取EntrySet(所有的键值对集合)和KeySet(所有的key集合)进行遍历。
-
使用 EntrySet 遍历:
-
使用 KeySet 遍历:
-
使用 EntrySet 遍历:
-
使用 for each 进行遍历,也是通过EntrySet和KeySet进行遍历:
-
for each+EntrySet:
-
for each+KeySet:
-
for each+EntrySet:
-
直接使用map.forEach()进行遍历:
-
使用Stream流进行遍历,可分为单线程遍历和多线程遍历:
-
Stream单线程遍历:
-
Stream多线程遍历:
-
Stream单线程遍历:
(7)HashMap几种遍历方式的比较
- 性能上:用 EntrySet 遍历比用 KeySet 的性能高。因为 KeySet 在循环时需要用 map.get(key) 来获取key,相当于再遍历一次Map集合,而EntrySet把key放到了对象里,可以直接从对象中获取key,因此KeySet相当于比 EntrySet 多遍历了一遍 Map 集合。
-
安全性上:不能在遍历中使用 map.remove() 来删除数据,这是非安全的操作方式,但我们可以使用迭代器的 iterator.remove() 的方法来删除数据,这才是安全的删除集合的方式。同样的我们也可以使用 Lambda 中的 removeIf 来提前删除数据,或者是使用 Stream 中的 filter 过滤掉要删除的数据进行循环,这样都是安全的,当然我们也可以在 for 循环前删除数据在遍历也是线程安全的。
综上所述,推荐使用 迭代器+EntrySet 来进行HashMap的遍历。
(8)★ConcurrentHashMap 和 Hashtable 的区别
ConcurrentHashMap 和 Hashtable 都是线程安全的,二者的区别主要体现在实现线程安全的方式上有所不同。
-
底层数据结构:
-
ConcurrentHashMap:
-
JDK1.8 以前,ConcurrentHashMap底层用 Segment数组和HashEntry数组 来实现,Segment 的个数一但初始化就不能改变,Segment数组中的每个元素包含一个HashEntry数组,每个HashEntry数组属于链表结构。
-
JDK1.8 采用的是 数组+链表/红黑树,当链表达到一定长度时,会转成红黑树。
-
JDK1.8 以前,ConcurrentHashMap底层用 Segment数组和HashEntry数组 来实现,Segment 的个数一但初始化就不能改变,Segment数组中的每个元素包含一个HashEntry数组,每个HashEntry数组属于链表结构。
-
Hashtable和 JDK1.8 之前的HashMap一样,都是采用 数组+链表 的形式。
-
ConcurrentHashMap:
-
★实现线程安全的方式:
-
ConcurrentHashMap:
- JDK1.8 以前,ConcurrentHashMap 对数组进行了分段(Segment数组),然后给每一段数据上一把锁(可重入锁),这样当一个线程占用锁访问其中一段数据时,其他段的数据也能被其他线程访问,提高并发访问率。
- JDK1.8 以后,ConcurrentHashMap 采用 Node + CAS + synchronized 来实现线程安全,并发控制使用synchronized和 CAS 来操作,synchronized 只锁定当前链表或红黑树的首节点,这样锁的粒度更细,只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。
- Hashtable(同一把锁) :使用 synchronized 来保证线程安全,当一个线程访问同步方法时,其他线程再访问该方法,就会阻塞,因此效率比较低。比如使用 put 添加元素时,另一个线程既不能put,也不能get,竞争会越来越激烈。
-
ConcurrentHashMap:
(9)JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同?
-
底层数据结构:
JDK1.7的ConcurrentHashMap底层用 Segment数组和HashEntry数组 来实现,Segment 的个数一但初始化就不能改变,Segment数组中的每个元素包含一个HashEntry数组,每个HashEntry数组属于链表结构。
JDK1.8的ConcurrentHashMap采用的是 数组+链表/红黑树,当链表达到一定长度时,会转成红黑树。
-
线程安全的实现方式 :
JDK1.7 采用分段锁来保证安全,Segment是继承自ReentrantLock(自旋锁)。
JDK1.8 采用Node + CAS + synchronized来保证线程安全,synchronized只锁定当前链表或红黑二叉树的首节点,锁粒度更细。 -
哈希冲突的解决:
JDK 1.7 采用拉链法
JDK1.8 采用拉链法+红黑树,即链表长度超过一定阈值时,将链表转换为红黑树。 -
★并发度:
JDK 1.7 最大并发度是 Segment 的个数,默认是 16。
JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。
(10)HashMap的扩容机制?
在jdk1.8以后,HashMap底层是由数组+链表和红黑树来实现的,当添加元素后,数组长度超过了一个临界值,也就是原数组长度(默认16)×负载因子(默认0.75),就会扩容为原数组的两倍。还有一种扩容情况就是当某个链表长度大于8了,并且数组长度没有超过64,数组也会扩容为原来的两倍;如果链表超过8数组也超过64,那么这个链表就会转换成红黑树。
十五、★Java并发
1.线程相关知识
(1)线程的生命周期/6大状态
线程在整个生命周期中可能处于以下某个状态:
- NEW:初始状态,线程刚被创建出来但没有被start();
-
RUNNABLE:运行状态,线程被调用了start()等待运行的状态。
线程创建后将处于 NEW状态,调用start()方法后处于 READY状态,此时线程需要获得 CPU 时间片才能真正RUNNING。 - BLOCKED:阻塞状态,需要等待锁的释放。
- WAITING:等待状态,线程需要等待其他线程的通知或中断。
- TIME_WAITING:超时等待状态,可以在指超时时间后自行返回而不是像 WAITING 那样一直等待。
- TERMINATED:终止状态,线程已经运行完毕。
(2)★sleep() 方法和 wait() 方法的比较
- 共同点 :二者都可以暂停线程的执行。
-
区别 :
-
sleep()是Thread类的静态本地方法,wait()是Object类的本地方法;
- sleep()方法没有释放锁,而wait()方法释放了锁;
- 如果不指定超时时间(timeout),wait()方法被调用后,线程不会自动苏醒,需要其他线程调用notify()或者notifyAll()方法,所以一般用来实现线程交互;sleep()方法执行完成后,线程会自动苏醒,一般用来暂停线程的执行。
(3)为什么 wait() 方法不定义在 Thread 中而在 Object 中?
wait()是让获得对象锁(锁住对象的锁)的线程实现等待,同时释放对象锁。每个对象(Object)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(Object)而非当前线程(Thread)。因此wait() 方法不定义在 Thread 中而在 Object 中。
类似的问题:为什么sleep()方法定义在Thread中?因为sleep()是让当前线程暂停执行,不需要获得对象锁,不涉及到对象类。
(4)可以直接调用 Thread 类的 run 方法吗?
直接调用Thread.run(),会被认为是main()方法调用的,并不能开启一个新线程。正确的做法应该是先创建一个新线程,然后通过调用start()来等待获取时间片,然后自动执行run()方法。
2.JMM(Java 内存模型、JSR 133内存模型)
(1)内存模型与Java内存模型
内存模型是os用来规范并发时,cpu cache缓存与内存不一致的问题。
同样地,在Java中也需要规范线程与内存的关系,以及定义并发编程时的规范。理论上来说,Java可以复用os本身的内存模型,但是不同os的内存模型不同,而Java又是跨平台的,所以Java需要提供自己的一套内存模型,称之为JMM。
- 规范线程与内存的关系:JMM给每个线程都抽象了线程私有的本地内存,线程之间的共享数据必须存储在主存中,线程将共享数据拷贝到本地内存进行操作,而不是直接读写主存。
-
定义并发编程时的规范:提供了并发相关的一些关键字和类,比如 volatile、synchronized、各种 Lock等。
(2)JVM内存区域与JMM的区别?
- JVM的内存区域定义了JVM在运行时如何分区保存数据,比如在堆中存放实例对象;
- JMM与Java的并发编程有关,抽象了线程和主存之间的关系,规定了从 Java 源代码到 CPU 可执行指令的过程要遵守的并发规范,其主要目的是为了简化多线程编程。
(3)并发编程的三个重要特性
- 原子性:一组操作要么全执行,要么全不执行。可以借助synchronized 、各种 Lock 以及各种原子类实现原子性。
- 可见性:当一个线程对共享变量进行了修改,那么其他线程应该立即能看到修改后的最新值。可以借助synchronized 、volatile 以及各种 Lock 实现可见性。
- 有序性:由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。指令重排序可以保证串行语义一致,但是在多线程下,指令重排序可能会导致一些问题。volatile 可以禁止指令进行重排序优化。
3.volatile 关键字
(1)如何保证变量的可见性?
可以将变量声明为 volatile ,这样每次用到该变量都需要到主存中进行读取最新值。
volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
(2)如何禁止指令重排序/如何保证有序性?
将变量声明为 volatile ,那么在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。
【tips】内存屏障(又叫内存栅栏)是一种 CPU 指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。
(3)★双重检验锁方式实现单例模式
单例模式中用volatile和synchronized来满足双重检查锁机制。
uniqueInstance = new Singleton();这段代码其实是分为三步执行的:
- 为uniqueInstance分配内存空间
- 初始化uniqueInstance
- 将uniqueInstance指向分配的内存地址
4.乐观锁和悲观锁
(1)什么是乐观锁和悲观锁?各自的使用场景是什么?
-
乐观锁是认为每次访问共享资源不会出现问题,无需加锁也无需等待,只在提交时才验证共享资源是否被其他线程修改了(版本号机制或CAS算法)。
乐观锁适用于读多写少,可以避免频繁加锁带来的性能损耗。 -
悲观锁是认为只要访问共享资源就会出现问题,因此在每次访问共享资源时都会加锁,一旦某个线程加了锁正在访问该资源,其他线程想要访问该资源就会阻塞,直到该线程释放锁。像synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
悲观锁适用于写多读少,避免频繁失败和重试影响性能。
(2)★如何实现乐观锁?
乐观锁一般会使用 版本号机制 或 CAS算法 实现。
- 版本号机制:一般是在数据表中加上一个版本号字段version。当数据更新成功时,version会加一。当线程 A 要更新数据值时,会拿到更新前的version,更新完提交时,先检查此时的version跟刚才拿到的相不相等,相等时才更新,然后把version+1,否则重试更新操作,直到更新成功。
-
CAS算法:CAS 的全称是 Compare And Swap(比较与交换),就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。CAS 是一个原子操作,涉及到三个操作数:
- V :要更新的变量值(Var)
- E :预期值(Expected)
- N :拟写入的新值(New)
(3)乐观锁存在哪些问题?
-
ABA问题
ABA问题就是一个线程在开始读到变量值是A,然后在修改后发现变量还是A,就认为该变量一定没有被修改,但其实可能是这期间被其他线程修改了,然后又改回了A。
ABA问题的解决思路就是在变量前加上一个标志,一般是版本号或者时间戳。比如AtomicStampedReference类就是用来解决 ABA 问题的,其中的compareAndSet()方法就是检查当前引用是否等于预期引用,并且判断当前标志是否等于预期标志,如果都相等,才认为这期间没有被其他线程修改,再进行更新操作。
-
循环时间长、开销大
CAS 经常会用到自旋操作来进行重试,直到成功为止。如果长时间不成功,就会给 CPU 带来比较大的开销。
-
只能保证一个共享资源的原子性
CAS 只对单个共享变量有效。
Java 提供了 AtomicReference 类来保证引用对象间的原子性,可以用锁或者AtomicReference类把多个共享变量合并成一个共享变量来操作,就可以解决这个问题了。
5.synchronized 关键字
(1)synchronized 是什么?有什么用?
synchronized 可以保证被修饰的方法或者代码块在任意时刻只能有一个线程执行。它可以用来修饰静态方法和实例方法以及代码块,但是不能修饰变量和构造方法。
-
修饰实例方法,锁的是当前实例对象
-
修饰静态方法,锁的是当前类
给当前类加锁,会作用于该类所有的实例对象 ,进入同步代码前要获得 当前 class 的锁。
【静态 synchronized 方法和非静态 synchronized 方法之间的调用互斥么?】
——不互斥。如果线程A调用了一个实例的非静态同步方法,而线程B想要调用该实例所在类的静态同步方法,这是允许的,不会发生互斥,因为访问静态同步方法占用的是当前类的锁,访问非静态同步方法占用的是当前实例对象的锁。 -
修饰代码块,锁的是指定对象或类
修饰代码块是对括号里的对象或类加锁:- synchronized(object) 表示进入同步代码库前要获得 给定对象的锁
-
synchronized(类.class) 表示进入同步代码前要获得 给定 Class 的锁
(2)构造方法可以用 synchronized 修饰么?
构造方法不能使用 synchronized 关键字修饰。因为构造方法本身就属于线程安全的,不存在同步的构造方法一说。
(3)★synchronized 底层原理了解吗?
synchronized 同步代码块时,靠的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指向结束位置。当执行 monitorenter 时,线程会尝试获取锁,也就是获取 对象监视器 monitor 的持有权,如果锁的计数器为0则表示可以获取锁,获取后将计数器加1。当完成对代码块的访问后,再执行monitorexit来释放锁,然后将计数器置为0。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
synchronized 修饰方法时,靠的是 ACC_SYNCHRONIZED 标识,JVM 通过该访问(ACC)标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。如果修饰的是实例方法,JVM 会尝试获取实例对象的锁;如果修饰的是静态方法,JVM 会尝试获取当前 class 的锁。
无论是 synchronized 同步代码块还是修饰方法,本质都是对 对象监视器monitor 的获取。
(4)TODO JDK1.6 之后的 synchronized 底层做了哪些优化?
(5)synchronized 和 volatile 有什么区别?
- volatile是一种轻量级的线程同步实现方式,所以在性能上,volatile要比synchronized好。但是volatile只能修饰变量,而synchronized可以修饰方法以及代码块;
- volatile能保证数据的可见性,但不能保证原子性,因此volatile主要用于解决变量在多线程间的可见性;而synchronized两者都能保证,解决的是多线程之间访问共享资源的同步性。
6.ReentrantLock
(1)ReentrantLock 是什么?
ReentrantLock是一种可重入的独占锁,和synchronized类似。但是ReentrantLock的功能更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。
ReentrantLock 默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。
【可重入锁】
可重入锁又叫递归锁,指的是一个线程可以再次获取自己占有的锁。比如一个线程获得了某个对象的锁,此时这个锁还没有释放,该线程可以再次获取这个对象的锁。(如果是不可重入锁的话,这种情况下就会造成死锁)
可重入锁又叫递归锁,指的是一个线程可以再次获取自己占有的锁。比如一个线程获得了某个对象的锁,此时这个锁还没有释放,该线程可以再次获取这个对象的锁。(如果是不可重入锁的话,这种情况下就会造成死锁)
所有 Lock 的实现类以及 synchronized 都是可重入的。
(2)公平锁和非公平锁有什么区别?
-
公平锁:公平锁是先申请的线程可以先得到锁。
性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。 -
非公平锁:非公平锁是后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。
性能更好,但可能会导致某些线程永远无法获取到锁。
(3)★synchronized 和 ReentrantLock 有什么区别?
二者都是可重入锁。
-
synchronized 是 JVM 层面的,而 ReentrantLock 是 API 层面的。
synchronized在同步代码块时是通过jvm的两个指令——monitorenter和monitorexit实现的,在同步方法时是通过acc_synchronized标识实现的;
ReentrantLock是API 层面的,是基于AQS实现的,需要 lock() 和 unlock() 方法配合 try-finally 语句块来完成。 -
★ReentrantLock 比 synchronized 增加了一些功能,主要来说主要有三点:
-
等待可中断:ReentrantLock可以通过调用lockInterruptibly()来中断等待锁的线程,让正在等待锁的线程放弃等待,改为处理其他事情;而synchronized获取不到锁会一直阻塞等待直到获取到锁;
- 可实现公平锁:ReentrantLock可以指定是公平锁还是非公平锁;而synchronized只能是非公平锁;
- ★可实现选择性通知:synchronized与wait()和notify()/notifyAll()方法相结合,可以实现等待/通知机制,但是只要调用notifyAll()会通知所有等待中的线程。ReentrantLock类借助Condition接口,可以创建多个Condition,可以有选择地将线程注册到指定的Condition中,实现选择性通知,在调用signalAll()时,只会唤醒注册在该Condition中的等待线程。
-
等待可中断:ReentrantLock可以通过调用lockInterruptibly()来中断等待锁的线程,让正在等待锁的线程放弃等待,改为处理其他事情;而synchronized获取不到锁会一直阻塞等待直到获取到锁;
- 在资源竞争不是很激烈的情况下,synchronized的性能要优于ReetrantLock,但是在资源竞争很激烈的情况下,synchronized的性能会下降几十倍,而ReetrantLock的性能能维持常态。
(4)谈谈对AQS的理解?
AQS就是一个抽象类,主要用来构建锁和同步器。比如ReentrantLock、Semaphore(信号量)、CountDownLatch等都是基于 AQS 的。
AQS 的核心思想是,如果共享资源(state)没被占用,就将请求资源的线程设置为有效工作线程,并将共享资源设置为锁定状态;如果共享资源已经被占用,那么就需要一套线程阻塞等待以及被唤醒时如何分配锁的机制,这个机制 AQS 是基于 CLH 锁实现的。
CLH 锁相当于一个双向队列,获取不到锁的线程将被加入该队列。AQS 会将每个请求共享资源的线程封装成一个结点(Node)来实现锁的分配,也就是说在 CLH 锁中,一个节点就表示一个线程。这个节点记录了线程的引用、线程状态、前驱和后继节点。
AQS 用 整型成员变量 state 表示资源的状态,同时用volatile修饰,保证资源状态的可见性。
【如何基于AQS实现ReentrantLock?】
★以ReentrantLock为例,state初始值为 0,表示资源未锁定。当线程A调用lock()进行上锁时,会调用tryAcquire()独占该锁并将state+1。此后其他线程再tryAcquire()时就会失败,直到线程A unlock()到state=0(即释放锁)为止。因为是可重入锁,所以释放锁之前,线程A可以重复获取此锁的,此时state会累加。但要注意,获取多少次就要释放多少次,这样才能保证 state 归零。
【AQS的资源共享方式】
AQS 有两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore、CountDownLatch)。【★信号量Semaphore 的原理是什么?】
Semaphore是一种共享锁,它是基于AQS实现的。它默认的 AQS 的state值为permits,当调用acquire()方法获取信号量时,如果state >= 0的话,就表示可以获取成功,然后用 CAS 操作(保证原子性)去对stat-1;如果state<0的话,则表示信号量数量不足,此时会创建一个 Node 节点加入CLH阻塞队列,挂起当前线程。在调用release()释放信号量时,会用 CAS 操作去给state+1,同时会唤醒队列中的一个线程,被唤醒的线程会重新尝试去获取信号量。
(5)★CountDownLatch和CyclicBarrier的区别?
CountDownLatch和CyclicBarrier都是一种线程同步的工具类。
- CountDownLatch 允许一个或多个线程阻塞在一个地方,直至所有线程的任务都执行完毕,然后调用await()的线程才能继续执行下去;而 CyclicBarrier 是当一个线程到达某个状态后,就阻塞,等待其他线程,只有所有线程均到达以后,各自才能继续执行。
- 因此二者调用await()的主体不同,CountDownLatch 是主线程调用await(),而 CyclicBarrier 是任务线程调用await(),所以 CyclicBarrier 阻塞的是任务线程,对主线程没有影响。
-
二者的实现原理也不同,CountDownLatch 是基于AQS实现的,CyclicBarrier 是基于ReentrantLock 和 Condition 实现的。
【★实现原理】- CountDownLatch 实现原理:在构建CountDownLatch对象时,传入的值(要等待几个任务)会赋值给 AQS 的 state;当执行countDown()方法时,就利用 CAS 将 state 减一;当执行await()方法时,会判断state是否为0,如果不为0说明任务还没执行完,await()就会一直阻塞;CountDownLatch 会自旋,以 CAS 判断 state 是否等于0,如果等于0了,就会释放所有等待的线程,这时调用await()的线程就可以继续执行了。
- CyclicBarrier 实现原理:在构建CyclicBarrier对象时,传入的值(要拦截几个任务)会赋值给 CyclicBarrier 内部维护的count变量(计数器)和parties变量(每次拦截几个任务),每个任务线程通过调用await()将count减1,相当于告诉 CyclicBarrier 自己到达屏障了,然后自己就会被阻塞。当count不为0,阻塞的线程就会被加到condition中;如果count为0,就会把condition中的线程全部唤醒,并将count重置为parties的值。在这个过程中,用 ReentrantLock 来保证线程安全。
- 从二者的实现原理中也可以看出,CountDownLatch 只能用一次,而 CyclicBarrier 可以通过重置 count 的值为 parties 实现复用。
7.★ThreadLocal
(1)ThreadLocal 有什么用?
ThreadLocal可以用来实现同一线程共享数据。每一个请求进来后,Tomcat会开一个线程来进行处理,从拦截器→controller→service→dao,一直到请求结束给浏览器响应,从始至终都是同一个线程。这样如果在拦截器中保存的数据想在controller中取出来用,就不用传给controller了,可以直接通过ThreadLocal获取。
(2)★ThreadLocal 原理了解吗?
在Thread中有两个ThreadLocalMap类型的变量,一个是threadLocals,另一个是inheritableThreadLocals,默认情况下这两个变量都是 null,只有当前线程调用了ThreadLocal的set、get方法时才会创建它们。这个ThreadLocalMap可以理解为Thread的一个HashMap,每个Thread都有一个ThreadLocalMap,这个Map以ThreadLocal为 key ,Object 对象为 value 进行存储,我们保存在ThreadLocal中的变量实际上都是保存在当前线程的这个Map中的。所以在调用ThreadLocal的set、get方法时,实际上调用的是ThreadLocalMap对应的get()、set()方法(如下图)。
(3)★ThreadLocal 内存泄露问题是怎么导致的?
ThreadLocalMap 的 key 是ThreadLocal的弱引用( WeakReference ),而 value 是强引用。所以如果ThreadLocal没有被外部强引用,那么在垃圾回收时,key 会被清理掉,而 value 不会被清理掉。这样一来如果我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。
ThreadLocalMap实现中已经考虑了这种情况,在调用set()、get()、remove()方法的时候,会清理掉 key 为 null 的记录。因此使用完ThreadLocal方法后最好手动调用remove()方法。
【tips】强引用、弱引用、软引用、虚引用
-
强引用
我们平时声明变量使用的就是强引用,比如String s="Hello World"。强引用可以直接访问目标对象,所指向的对象在任何时候都不会被系统回收,JVM宁愿抛出OOM也不会回收强引用所指向的对象,因此强引用可能导致内存泄漏。
-
弱引用
java中使用WeakReference来表示弱引用。如果某个对象与弱引用关联,那么当JVM在进行垃圾回收时,无论内存是否充足,都会回收弱引用对象。
-
软引用
java中使用SoftRefence来表示软引用。软引用是除了强引用外最强的引用类型。一个持有软引用的对象,它不会被JVM很快回收,JVM会根据当前堆的使用情况来判断何时回收。
-
虚引用
java中使用PhantomReference来表示虚引用。JVM不负责清理虚引用,但是会把虚引用放到引用队列里面。虚引用的主要目的是在一个对象所占的内存被实际回收之前得到通知,从而可以进行一些相关的清理工作。
8.★线程池
(1)什么是线程池?线程池有什么优点?
线程池就是管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是等待下一个任务。
使用线程池的好处:
- 降低资源消耗。通过重用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
(2)如何创建线程池?
有两种创建线程池的方法:-
方法一:通过ThreadPoolExecutor构造函数来创建(推荐)。
-
方法二:通过 Executor 框架的工具类 Executors 来创建。
使用Executors可以创建多种类型的ThreadPoolExecutor,比如FixedThreadPool、SingleThreadExecutor、CachedThreadPool、ScheduledThreadPool。
(3)★为什么不推荐使用Executors创建线程池?
一般不用Executors创建线程池,而是根据实际需求通过ThreadPoolExecutor自定义线程池。
因为通过ThreadPoolExecutor自定义线程池可以根据实际需求去创建线程池,避免浪费资源。而用 Executors 创建线程池对象,可能会导致OOM。比如 FixedThreadPool 和 SingleThreadExecutor、ScheduledThreadPool使用的队列最大长度都是Integer.MAX_VALUE,可能堆积大量的请求而导致OOM;CachedThreadPool 允许创建的线程数量为 Integer.MAX_VALUE,可能创建大量线程而导致 OOM。
(4)★线程池常见参数有哪些?如何解释?——7大参数
【解释】
- corePoolSize:核心线程数。核心线程是指任务队列不满时,可以同时运行的最大线程数量。
- maximumPoolSize:最大线程数。最大线程数指的是当任务队列满了,可以同时运行的最大线程数量。
- workQueue:任务队列。如果新任务到来时没有核心线程了,新任务就会被放进任务队列。
- keepAliveTime:空闲线程最大存活时间。非核心线程空闲时不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime才会被回收销毁。
- unit:最大存活时间的时间单位。
- threadFactory:线程工厂。创建新线程的时候会用到。
- handler:拒绝策略。当新任务不能被线程及时处理时,可以通过不同的拒绝策略来处理这些任务。
(5)线程池有哪些拒绝策略?
- AbortPolicy:默认策略,抛出RejectedExecutionException(拒绝执行异常)来拒绝新任务的处理。
- CallerRunsPolicy: 调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。这种策略会降低对于新任务提交速度,影响程序的整体性能。
- DiscardPolicy:把新任务直接丢弃(discard)。
- DiscardOldestPolicy: 丢弃最早的未处理的任务。
(6)线程池有哪些任务队列(阻塞队列)?
- 容量为Integer.MAX_VALUE的LinkedBlockingQueue(无界队列):FixedThreadPool和SingleThreadExector用的。由于队列永远不会被放满,因此FixedThreadPool最多只能创建核心线程数的线程。
- SynchronousQueue(同步队列) :CachedThreadPool。SynchronousQueue没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool的最大线程数是Integer.MAX_VALUE,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。
- DelayedWorkQueue(延迟阻塞队列):ScheduledThreadPool和SingleThreadScheduledExecutor。DelayedWorkQueue的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达Integer.MAX_VALUE,所以最多只能创建核心线程数的线程。
(7)★线程池处理任务的流程了解吗?
当来了一个新任务,会先判断有无核心线程,如果有就交给核心线程处理,如果没有就放到任务队列;如果任务队列也满了,会判断是否达到最大线程数,如果没达到最大线程数,就新建一个线程来处理任务,如果达到了最大线程数,会根据相应的拒绝策略处理该任务。
(8)如何给线程池命名?
初始化线程池的时候需要显示地对线程池进行命名,这样可以更快的定位问题。
默认创建的线程名字类似pool-1-thread-n这样的,没有业务意义,不利于定位问题。
给线程池里的线程命名通常有下面两种方式:
-
方法一:利用 guava 的 ThreadFactoryBuilder 命名:
//通过ThreadFactoryBuilder创建一个线程工厂,并给线程池命名 ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat(threadNamePrefix + "-%d").setDaemon(true).build(); //使用创建的线程工厂自定义线程池 ExecutorService threadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.MINUTES, workQueue, threadFactory);
-
方法二:自己实现一个 ThreadFactor:
(9)如何设定线程池的大小?
线程池设置得过大或过小都会有问题,可以根据任务类型进行设置。
- 对于 CPU 密集型任务,消耗的主要是 CPU 资源,可以将线程数设置为 CPU 核心数+1。+1是如果任务由于各种原因暂停了,CPU 就会处于空闲状态,这时多出来这一个线程就可以充分利用 CPU 的空闲时间了。
- 对于 I/O 密集型任务(网络读取,文件读取),系统大部分时间都会来处理 I/O 交互,而线程在处理 I/O 这段时间不会占用 CPU ,这时就可以将 CPU 让给其它线程。因此在 I/O 密集型任务的应用中,我们可以相对多配置一些线程,一般来说可以配置为2倍的CPU核数。
(10)线程池被创建后里面有线程吗?如果没有的话,你知道有什么方法对线程池进行预热吗?
线程池被创建后如果没有任务过来,里面是不会有线程的。
如果需要预热(预启动——prestart)的话可以调用下面的两个方法:
-
启动全部coreThread——prestartAllCoreThread()
-
只启动一个coreThread——prestartCoreThread()
9.Future
(1)Future 类有什么用?
Future主要是用来实现异步调用,简单理解就是:我有一个任务,提交给了Future来处理。任务执行期间我自己可以去做任何想做的事情,并且在这期间我还可以取消任务以及获取任务的执行状态。一段时间之后,我就可以从Future那里直接取出任务执行的结果。
(2)★Callable 和 Future 有什么关系?
我们可以通过FutureTask来理解Callable和Future之间的关系。FutureTask实现了Future接口,它有两个构造函数,可以传入 Callable 或者 Runnable 对象,常用来封装Callable和Runnable,可以通过调用方法来取消任务、查看任务是否执行完成以及获取任务的执行结果。
【Callable与Runnable有什么区别?】
- 返回值:Callable可以用来处理有返回结果的情况,而Runnable不会返回结果;
- 异常:Callable如果无法计算结果,会抛出异常,而Runnable无法抛出经过检查的异常;
- 最终实现的方法不同:Callable通过call()实现,Runnable通过run()实现。
(3)CompletableFuture 类有什么用?
Future 不支持异步编排,而且获取计算结果的 get() 方法是阻塞式调用。CompletableFuture 对 Future 进行了扩展,可以实现异步编排等功能。