1. Java 语言的特点
- 简单易学;
- 面向对象(封装,继承,多态);
- 平台无关性( Java 虚拟机实现平台无关性);
- 支持多线程;
- 可靠性(具备异常处理和自动内存管理机制);
- 安全性(Java 语言本身的设计就提供了多重安全防护机制如访问权限修饰符、限制程序直接访问操作系统资源);
- 高效性(通过 Just In Time 编译器等技术的优化,Java 语言的运行效率还是非常不错的);
- 支持网络编程并且很方便;
- 编译与解释并存;
- 强大的生态;
2. JVM、 JDK、 JRE
2.1 JVM:Java Virtual Machine,Java虚拟机
- JVM是Java程序的运行环境,它能够执行Java程序编译后的字节码文件。JVM是运行Java程序的核心,因为Java程序必须在JVM上运行才能执行。
- JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。字节码和不同系统的JVM实现是Java语言“一次编译,随处可以运行”(Write Once, Run Anywhere)的关键所在。
2.1 JDK:Java Development Kit
- 它是功能齐全的 Java SDK,是提供给开发者使用的,能够创建和编译 Java 程序。
- JDK包含了JRE,同时还包含了javac(编译 java 源码的编译器)、javadoc(文档注释工具)、jdb(调试器)、jconsole(基于 JMX 的可视化监控⼯具)、javap(反编译工具)等等。
2.3 JRE(Java Runtime Environment)
- JRE是 Java 运行时环境。
- 它是运行已编译 Java 程序所需的所有内容的集合,主要包括 Java 虚拟机(JVM)、Java 基础类库(Class Library)。
3. 字节码
- JVM可以理解的代码就叫做字节码(即扩展名为 .class 的文件),它不面向任何特定的处理器,只面向虚拟机。
- Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以, Java 程序运行时相对来说还是高效的,而且,由于字节码并不针对一种特定的机器,因此,Java 程序无须重新编译便可在多种不同操作系统的计算机上运行。
- .class -> 机器码
1
2
3
4JVM 类加载器首先加载字节码文件,然后通过解释器逐行解释执行,这种方式的执行速度会相对比较慢。
而且,有些方法和代码块是经常需要被调用的(也就是所谓的热点代码),
所以后面引进了 JIT(just-in-time compilation) 编译器,而 JIT 属于运行时编译。
当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用。
4. 注释
- 单行注释:通常用于解释方法内某单行代码的作用。
- 多行注释:通常用于解释一段代码的作用。
- 文档注释:通常用于生成 Java 开发文档。
5. 标识符和关键字
- 标识符:就是一个名字,比如为程序、类、变量、方法等取名字;
- 关键字:被赋予特殊含义的标识符,比如private、protected、public等。
6. 自增自减运算符
- 自增运算符(++)和自减运算符(–)
- 当运算符放在变量之前时(前缀),先自增/减,再赋值;当运算符放在变量之后时(后缀),先赋值,再自增/减。
7. 移位运算符
- << : 左移运算符,向左移若干位,高位丢弃,低位补零。x << 1,相当于 x 乘以 2(不溢出的情况下)。
: 带符号右移,向右移若干位,高位补符号位,低位丢弃。正数高位补 0,负数高位补 1。x >> 1,相当于 x 除以 2。
: 无符号右移,忽略符号位,空位都以 0 补齐。
8. continue、break 和 return 的区别
- continue:指跳出当前的这一次循环,继续下一次循环。
- break:指跳出整个循环体,继续执行循环下面的语句。
- return:用于跳出所在方法,结束该方法的运行。
9. 基本数据类型
8 种基本数据类型
- 6 种数字类型:
- 4 种整数型:byte(1)、short(2)、int(4)、long(8)
- 2 种浮点型:float(4)、double(8)
- 1 种字符类型:char(2)
- 1 种布尔型:boolean
10. 基本类型和包装类型的区别
- 用途:除了定义一些常量和局部变量之外,我们在其他地方比如方法参数、对象属性中很少会使用基本类型来定义变量。并且,包装类型可用于泛型,而基本类型不可以。
- 存储方式:基本数据类型的局部变量存放在 Java 虚拟机栈中的局部变量表中,基本数据类型的成员变量(未被 static 修饰 )存放在 Java 虚拟机的堆中。包装类型属于对象类型,我们知道几乎所有对象实例都存在于堆中。
- 占用空间:相比于包装类型(对象类型),基本数据类型占用的空间往往非常小。
- 默认值:成员变量包装类型不赋值就是 null ,而基本类型有默认值且不是null。
- 比较方式:对于基本数据类型来说,== 比较的是值。对于包装数据类型来说,== 比较的是对象的内存地址。所有整型包装类对象之间值的比较,全部使用 equals() 方法。
11. 包装类型的缓存机制
- Java 基本数据类型的包装类型的大部分都用到了缓存机制来提升性能。
- Byte,Short,Integer,Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据;
- Character 创建了数值在 [0,127] 范围的缓存数据,Boolean 直接返回 True or False;
- 两种浮点数类型的包装类 Float,Double 并没有实现缓存机制。
12. 自动装箱与拆箱是什么,原理是什么
- 装箱:将基本类型用它们对应的引用类型包装起来;
- 拆箱:将包装类型转换为基本数据类型;
1
2Integer i = 10; //装箱
int n = i; //拆箱 - 原理:从字节码中,我们发现装箱其实就是调用了包装类的valueOf()方法,拆箱其实就是调用了 xxxValue()方法。
1
2Integer i = 10 等价于 Integer i = Integer.valueOf(10)
int n = i 等价于 int n = i.intValue(); - 注意:如果频繁拆装箱的话,也会严重影响系统的性能。我们应该尽量避免不必要的拆装箱操作
13. 为什么浮点数运算的时候会有精度丢失的风险
这个和计算机保存浮点数的机制有很大关系。
我们知道计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。
14. 如何解决浮点数运算的精度丢失问题
BigDecimal 可以实现对浮点数的运算,不会造成精度丢失。
通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 BigDecimal 来做的。
1
2
3
4
5
6
7BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("0.9");
System.out.println(a.add(b)); // 1.9
System.out.println(a.subtract(b)); // 0.1
System.out.println(a.multiply(b)); // 0.90
System.out.println(a.divide(b)); // 无法除尽,抛出 ArithmeticException 异常
System.out.println(a.divide(b, 2, RoundingMode.HALF_UP));// 1.11
15. 超过 long 整型的数据应该如何表示
- 基本数值类型都有一个表达范围,如果超过这个范围就会有数值溢出的风险。在 Java 中,64 位 long 整型是最大的整数类型。
1
2
3long l = Long.MAX_VALUE;
System.out.println(l + 1); // -9223372036854775808
System.out.println(l + 1 == Long.MIN_VALUE); // true - 此时要使用BigInteger类进行操作,BigInteger内部使用int[]数组来存储任意大小的整形数据。相对于常规整数类型的运算来说,BigInteger 运算的效率会相对较低。
1
2
3
4
5
6
7
8
9
10BigInteger bi1 = new BigInteger("123456789") ; // 声明BigInteger对象
Integer bi2 = new BigInteger("987654321") ; // 声明BigInteger对象
tem.out.println("加法操作:" + bi2.add(bi1)) ; // 加法操作
System.out.println("减法操作:" + bi2.subtract(bi1)) ; // 减法操作
System.out.println("乘法操作:" + bi2.multiply(bi1)) ; // 乘法操作
System.out.println("除法操作:" + bi2.divide(bi1)) ; // 除法操作
System.out.println("最大数:" + bi2.max(bi1)) ; // 求出最大数
System.out.println("最小数:" + bi2.min(bi1)) ; // 求出最小数
BigInteger result[] = bi2.divideAndRemainder(bi1) ; // 求出余数的除法操作
System.out.println("商是:" + result[0] + ";余数是:" + result[1]) ;
16. 成员变量与局部变量的区别
- 语法形式:从语法形式上看,成员变量是属于类的,而局部变量是在代码块或方法中定义的变量或是方法的参数;成员变量可以被 public,private,static 等修饰符所修饰,而局部变量不能被访问控制修饰符及 static 所修饰;但是,成员变量和局部变量都能被 final 所修饰。
- 存储方式:从变量在内存中的存储方式来看,如果成员变量是使用 static 修饰的,那么这个成员变量是属于类的,如果没有使用 static 修饰,这个成员变量是属于实例的。而对象存在于堆内存,局部变量则存在于栈内存。
- 生存时间:从变量在内存中的生存时间上看,成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动生成,随着方法的调用结束而消亡。
- 默认值:从变量是否有默认值来看,成员变量如果没有被赋初始值,则会自动以类型的默认值而赋值(一种情况例外:被 final 修饰的成员变量也必须显式地赋值),而局部变量则不会自动赋值。
17. 静态变量有什么作用
- 静态变量也就是被 static 关键字修饰的变量。
- 它可以被类的所有实例共享,无论一个类创建了多少个对象,它们都共享同一份静态变量。
- 也就是说,静态变量只会被分配一次内存,即使创建多个对象,这样可以节省内存。
18. 字符型常量和字符串常量的区别
- 形式 : 字符常量是单引号引起的一个字符,字符串常量是双引号引起的 0 个或若干个字符。
- 含义 : 字符常量相当于一个整型值(ASCII值),可以参加表达式运算; 字符串常量代表一个地址值(该字符串在内存中存放位置)。
- 占内存大小:字符常量只占 2 个字节; 字符串常量占若干个字节。
19. 静态方法为什么不能调用非静态成员
- 静态方法是属于类的,在类加载的时候就会分配内存,可以通过类名直接访问。而非静态成员属于实例对象,只有在对象实例化之后才存在,需要通过类的实例对象去访问。
- 在类的非静态成员不存在的时候静态方法就已经存在了,此时调用在内存中还不存在的非静态成员,属于非法操作。
20. 静态方法和实例方法有何不同
20.1 调用方式
- 在外部调用静态方法时,可以使用
类名.方法名
的方式,也可以使用对象.方法名
的方式,而实例方法只有后面这种方式。也就是说,调用静态方法可以无需创建对象。 - 不过,需要注意的是一般不建议使用
对象.方法名
的方式来调用静态方法。这种方式非常容易造成混淆,静态方法不属于类的某个对象而是属于这个类。因此,一般建议使用类名.方法名
的方式来调用静态方法。
20.2 访问类成员是否存在限制
静态方法在访问本类的成员时,只允许访问静态成员(即静态成员变量和静态方法),不允许访问实例成员(即实例成员变量和实例方法),而实例方法不存在这个限制。
21. 重载和重写有什么区别
- 重载就是同一个类中多个同名方法根据不同的传参来执行不同的逻辑处理
- 重写发生在运行期,是子类对父类的允许访问的方法的实现过程进行重新编写,遵循”两同两小一大”
- “两同”即方法名相同、形参列表相同;
- “两小”指的是子类方法返回值类型应比父类方法返回值类型更小或相等,子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等;
- “一大”指的是子类方法的访问权限应比父类方法的访问权限更大或相等。
22. 可变长参数
- 从 Java5 开始,Java 支持定义可变长参数,所谓可变长参数就是允许在调用方法时传入不定长度的参数。
1
2
3public static void method1(String... args) {
//......
} - 可变参数只能作为函数的最后一个参数,但其前面可以有也可以没有任何其他参数
1
2
3public static void method2(String arg1, String... args) {
//......
} - 方法重载会优先匹配固定参数的方法,因为固定参数的方法匹配度更高
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17public 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");
}
} - Java 的可变参数编译后实际会被转换成一个数组
23. 面向对象和面向过程的区别
- 两者的主要区别在于解决问题的方式不同:
- 面向过程把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题。
- 面向对象会先抽象出对象,然后用对象执行方法的方式解决问题。
- 面向对象开发的程序一般更易维护、易复用、易扩展
24. 创建一个对象用什么运算符?对象实体与对象引用有何不同?
- new 运算符,new 创建对象实例(对象实例在 内存中),对象引用指向对象实例(对象引用存放在栈内存中)。
- 一个对象引用可以指向 0 个或 1 个对象(一根绳子可以不系气球,也可以系一个气球);
- 一个对象可以有 n 个引用指向它(可以用 n 条绳子系住一个气球)。
25. 对象的相等和引用相等的区别
- 对象的相等一般比较的是内存中存放的内容是否相等。
- 引用相等一般比较的是他们指向的内存地址是否相等。
26. 如果一个类没有声明构造方法,该程序能正确执行吗?
- 构造方法是一种特殊的方法,主要作用是完成对象的初始化工作。
- 如果一个类没有声明构造方法,也可以执行!因为一个类即使没有声明构造方法也会有默认的不带参数的构造方法。
- 如果我们自己添加了类的构造方法(无论是否有参),Java 就不会添加默认的无参数的构造方法了。
27. 构造方法有哪些特点?是否可被 override
27.1 特点:
- 名字与类名相同
- 没有返回值,但不能用 void 声明构造函数
- 生成类的对象时自动执行,无需调用
27.2 是否可被重写
- 构造方法不能被 override(重写),但是可以 overload(重载),所以你可以看到一个类中有多个构造函数的情况。
28. 面向对象三大特征
- 封装:一个对象的状态信息(也就是属性)隐藏在对象内部,不允许外部对象直接访问对象的内部信息,但是可以提供一些可以被外界访问的方法来操作属性。
- 继承:通过使用继承,可以快速地创建新的类,可以提高代码的重用,程序的可维护性,节省大量创建新类的时间 ,提高我们的开发效率。
- 多态:表示一个对象具有多种的状态,具体表现为父类的引用指向子类的实例。
29. 接口和抽象类有什么共同点和区别
29.1 共同点:
- 都不能被实例化。
- 都可以包含抽象方法。
- 都可以有默认实现的方法(Java 8 可以用 default 关键字在接口中定义默认方法)。
29.2 区别:
- 接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系。
- 一个类只能继承一个类,但是可以实现多个接口。
- 接口中的成员变量只能是 public static final 类型的,不能被修改且必须有初始值,而抽象类的成员变量默认 default,可在子类中被重新定义,也可被重新赋值。
30. 深拷贝和浅拷贝区别了解吗?什么是引用拷贝
- 浅拷贝:浅拷贝会在堆上创建一个新的对象(区别于引用拷贝的一点),不过,如果原对象内部的属性是引用类型的话,浅拷贝会直接复制内部对象的引用地址,也就是说拷贝对象和原对象共用同一个内部对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32# 实现了 Cloneable 接口,并重写了 clone() 方法
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();
System.out.println(person1.getAddress() == person1Copy.getAddress()); // true
// 从输出结构就可以看出, person1 的克隆对象和 person1 使用的仍然是同一个 Address 对象。 - 深拷贝:深拷贝会完全复制整个对象,包括这个对象所包含的内部对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19public class Person implements Cloneable {
private Address address;
// 省略构造函数、Getter&Setter方法
@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();
System.out.println(person1.getAddress() == person1Copy.getAddress()); // false
// 从输出结构就可以看出,显然 person1 的克隆对象和 person1 包含的 Address 对象已经是不同的了。 - 引用拷贝:两个不同的引用指向同一个对象
31. Object 类的常见方法有哪些
Object 类是一个特殊的类,是所有类的父类。它主要提供了以下 11 个方法:
1 | /** |
32. == 和 equals() 的区别
32.1 ==
- 对于基本数据类型来说,== 比较的是值。
- 对于引用数据类型来说,== 比较的是对象的内存地址。
32.2 equals()
- equals()不能用于判断基本数据类型的变量,只能用来判断两个对象是否相等。
- equals()方法存在于Object类中,而Object类是所有类的直接或间接父类,因此所有的类都有equals()方法。
33. hashCode() 有什么用
- hashCode() 的作用是获取哈希码(int 整数),也称为散列码。这个哈希码的作用是确定该对象在哈希表中的索引位置。
- hashCode() 定义在 JDK 的 Object 类中,这就意味着 Java 中的任何类都包含有 hashCode() 函数。
- 另外需要注意的是:Object 的 hashCode() 方法是本地方法,也就是用 C 语言或 C++ 实现的。
34. 为什么要有 hashCode
- hashCode() 和 equals()都是用于比较两个对象是否相等。
- 在一些容器(比如 HashMap、HashSet)中,有了 hashCode() 之后,判断元素是否在对应容器中的效率会更高(参考添加元素进HashSet的过程)
- 如果两个对象的 hashCode 值相等,那这两个对象不一定相等(哈希碰撞)。
- 如果两个对象的 hashCode 值相等并且equals()方法也返回 true,我们才认为这两个对象相等。
- 如果两个对象的 hashCode 值不相等,我们就可以直接认为这两个对象不相等。
35. 为什么重写 equals() 时必须重写 hashCode() 方法
- 因为两个相等的对象的 hashCode 值必须是相等。也就是说如果 equals 方法判断两个对象是相等的,那这两个对象的 hashCode 值也要相等。
- 如果重写 equals() 时没有重写 hashCode() 方法的话就可能会导致 equals 方法判断是相等的两个对象,hashCode 值却不相等。
36. String、StringBuffer、StringBuilder 的区别
36.1 可变性
- String 是不可变的。
- StringBuilder 与 StringBuffer 都继承自 AbstractStringBuilder 类,在 AbstractStringBuilder 中也是使用字符数组保存字符串,不过没有使用 final 和 private 关键字修饰,最关键的是这个 AbstractStringBuilder 类还提供了很多修改字符串的方法比如 append 方法。
36.2 线程安全性
- String 中的对象是不可变的,也就可以理解为常量,线程安全。
- AbstractStringBuilder 是 StringBuilder 与 StringBuffer 的公共父类,定义了一些字符串的基本操作,如 expandCapacity、append、insert、indexOf 等公共方法。
- StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。
- StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。
36.3 性能
- 每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象。
- StringBuffer 每次都会对 StringBuffer 对象本身进行操作,而不是生成新的对象并改变对象引用。
- 相同情况下使用 StringBuilder 相比使用 StringBuffer 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。
36.4 总结
- 操作少量的数据: 适用 String
- 单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder
- 多线程操作字符串缓冲区下操作大量数据: 适用 StringBuffer
37. String 为什么是不可变的
- 保存字符串的数组被 final 修饰且为私有的,并且String 类没有提供/暴露修改这个字符串的方法。
- String 类被 final 修饰导致其不能被继承,进而避免了子类破坏 String 不可变。
38. 字符串拼接用“+” 还是 StringBuilder
38.1 +
- 字符串对象通过“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象。
1
2
3
4String str1 = "he";
String str2 = "llo";
String str3 = "world";
String str4 = str1 + str2 + str3; - 在循环内使用“+”进行字符串的拼接的话,存在比较明显的缺陷:编译器不会创建单个 StringBuilder 以复用,会导致创建过多的 StringBuilder 对象。
1
2
3
4
5
6String[] arr = {"he", "llo", "world"};
String s = "";
for (int i = 0; i < arr.length; i++) {
s += arr[i];
}
System.out.println(s);
38.2 StringBuilder
- 如果直接使用 StringBuilder 对象进行字符串拼接的话,就不会存在这个问题了。
1
2
3
4
5
6String[] arr = {"he", "llo", "world"};
StringBuilder s = new StringBuilder();
for (String value : arr) {
s.append(value);
}
System.out.println(s);
39. String#equals() 和 Object#equals() 有何区别
- String 中的 equals 方法是被重写过的,比较的是 String 字符串的值是否相等。
- Object 的 equals 方法是比较的对象的内存地址。
40. 字符串常量池的作用了解吗
- 字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域.
- 主要目的是为了避免字符串的重复创建。
1
2
3
4
5// 在常量池中创建字符串对象”ab“
String aa = "ab";
// 直接返回常量池中字符串”ab“的引用
String bb = "ab";
System.out.println(aa==bb); // true
41. String s1 = new String(“abc”);这句话创建了几个字符串对象?
- 会创建 1 或 2 个字符串对象。
- 如果字符串常量池中不存在字符串“abc”:
- 首先在字符串常量池中创建一个”abc”对象
- 然后在堆内存中创建1个对象指向字符串常量池中的”abc”,因此将创建总共 2 个字符串对象。
- 如果常量池中已存在字符串“abc”,则只会在堆中创建一个对象指向字符串常量池中的”abc”
42. String#intern 方法有什么作用
- String.intern() 是一个 native(本地)方法,可以简单分为两种情况:
- 如果字符串常量池中保存了对应的字符串,就直接返回该字符串的引用。
- 如果字符串常量池中没有保存对应的字符串,那就在常量池中创建一个该字符串的对象并返回。
1
2
3
4
5
6
7
8
9
10
11
12
13
14// 在字符串常量池中创建一个"Java"对象
String s1 = "Java";
// 直接返回字符串常量池中字符串对象”Java“
String s2 = s1.intern();
// 会在堆中在单独创建一个字符串对象,指向常量池中”Java“对象
String s3 = new String("Java");
// 直接返回字符串常量池中字符串对象”Java“
String s4 = s3.intern();
// s1 和 s2 指向的是常量池中的同一个对象
System.out.println(s1 == s2); // true
// s3 和 s4 指向的是不同的对象,s3指向的是堆中的一个字符串对象,s4指向的是常量池中的”Java“对象
System.out.println(s3 == s4); // false
// s1 和 s4 指向的是常量池中的同一个”Java“对象
System.out.println(s1 == s4); //true
43. Exception 和 Error 有什么区别?
在 Java 中,所有的异常都有一个共同的祖先 java.lang 包中的 Throwable 类。Throwable 类有两个重要的子类:
- Exception: 程序本身可以处理的异常,可以通过 catch 来进行捕获。Exception 又可以分为 Checked Exception (受检查异常,必须处理) 和 Unchecked Exception (不受检查异常,可以不处理)。
- Error:Error 属于程序无法处理的错误 ,不建议通过catch捕获 。例如 Java 虚拟机运行错误(Virtual MachineError)、虚拟机内存不够错误(OutOfMemoryError)、类定义错误(NoClassDefFoundError)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。
44. Checked Exception 和 Unchecked Exception 有什么区别
- Checked Exception 即 受检查异常 ,Java 代码在编译过程中,如果受检查异常没有被 catch或者throws 关键字处理的话,就没办法通过编译。
- Unchecked Exception 即 不受检查异常 ,Java 代码在编译过程中 ,我们即使不处理不受检查异常也可以正常通过编译。
45. Throwable 类常用方法有哪些?
- String getMessage(): 返回异常发生时的简要描述
- String toString(): 返回异常发生时的详细信息
- String getLocalizedMessage(): 返回异常对象的本地化信息。使用 Throwable 的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与 getMessage()返回的结果相同
- void printStackTrace(): 在控制台上打印 Throwable 对象封装的异常信息
46. try-catch-finally 如何使用?
- try块:用于捕获异常。其后可接零个或多个 catch 块,如果没有 catch 块,则必须跟一个 finally 块。
- catch块:用于处理 try 捕获到的异常。
- finally 块:无论是否捕获或处理异常,finally 块里的语句都会被执行。当在 try 块或 catch 块中遇到 return 语句时,finally 语句块将在方法返回之前被执行。
1
2
3
4
5
6
7
8try {
System.out.println("Try to do something");
throw new RuntimeException("RuntimeException");
} catch (Exception e) {
System.out.println("Catch Exception -> " + e.getMessage());
} finally {
System.out.println("Finally");
}1
2
3
4输出:
Try to do something
Catch Exception -> RuntimeException
Finally
47. finally 中的代码一定会执行吗?
不一定的!在某些情况下,finally 中的代码不会被执行。
- finally执行之前虚拟机被终止运行的话,finally 中的代码就不会被执行。
- 程序所在的线程死亡。
- 关闭 CPU。
48. 如何使用 try-with-resources 代替try-catch-finally
- Java 中类似于InputStream、OutputStream、Scanner、PrintWriter等的资源都需要我们调用close()方法来手动关闭,一般情况下我们都是通过try-catch-finally语句来实现这个需求,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14//读取文本文件的内容
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 语句改造上面的代码:
1
2
3
4
5
6
7
8try (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可能会带来很多问题。
1
2
3
4
5
6
7
8
9
10try (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();
}
49. 异常使用有哪些需要注意的地方?
- 不要把异常定义为静态变量,因为这样会导致异常栈信息错乱。每次手动抛出异常,我们都需要手动 new 一个异常对象抛出。
- 抛出的异常信息一定要有意义。
- 建议抛出更加具体的异常,比如字符串转换为数字格式错误的时候应该抛出NumberFormatException而不是其父类IllegalArgumentException。
- 使用日志打印异常之后就不要再抛出异常了(两者不要同时存在一段代码逻辑中)
50. 什么是泛型?有什么作用?
- 泛型(Generics)是 JDK5 中引入的一个新特性。
- 使用泛型参数,可以增强代码的可读性以及稳定性。
- 编译器可以对泛型参数进行检测,并且通过泛型参数可以指定传入的对象类型。比如 ArrayList
persons = new ArrayList () 这行代码就指明了该 ArrayList 对象只能传入 Person 对象,如果传入其他类型的对象就会报错。
51. 泛型的使用方式有哪几种?
- 泛型一般有三种使用方式:泛型类、泛型接口、泛型方法。
- 泛型类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14// 此处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;
}
}1
2// 实例化泛型类
Generic<Integer> genericInteger = new Generic<Integer>(123456); - 泛型接口:
1
2
3public interface Generator<T> {
public T method();
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15// 实现泛型接口,不指定类型:
class GeneratorImpl<T> implements Generator<T>{
@Override
public T method() {
return null;
}
}
// 实现泛型接口,指定类型:
class GeneratorImpl<T> implements Generator<String>{
@Override
public String method() {
return "hello";
}
} - 泛型方法:
1
2
3
4
5public static <E> void printArray(E[] inputArray){
for (E element : inputArray ){
System.out.printf( "%s ", element );
}
}
52. 何谓注解
- Annotation (注解) 是 Java5 开始引入的新特性,可以看作是一种特殊的注释,主要用于修饰类、方法或者变量,提供某些信息供程序在编译或者运行时使用。
- 注解本质是一个继承了Annotation 的特殊接口:
1
2
3
4
5
6
7@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}
public interface Override extends Annotation{
}
53. 注解的解析方法有哪几种
注解只有被解析之后才会生效,常见的解析方法有两种:
- 编译期直接扫描:编译器在编译 Java 代码的时候扫描对应的注解并处理,比如某个方法使用@Override 注解,编译器在编译的时候就会检测当前的方法是否重写了父类对应的方法。
- 运行期通过反射处理:像框架中自带的注解(比如 Spring 框架的 @Value、@Component)都是通过反射来进行处理的。
54. Java 值传递
Java 中将实参传递给方法(或函数)的方式是值传递:
- 如果参数是基本类型:传递的就是基本类型的字面量值的拷贝,会创建副本。
- 如果参数是引用类型:传递的就是实参所引用的对象在堆中地址值的拷贝,同样也会创建副本。
55. 什么是序列化和反序列化?
55.1 定义
如果我们需要持久化 Java 对象比如将 Java 对象保存在文件中,或者在网络传输 Java 对象,这些场景都需要用到序列化。
55.2 区别
- 序列化:serialization, 将数据结构或对象转换成二进制字节流的过程,主要目的是通过网络传输对象或者说是将对象存储到文件系统、数据库、内存中。
- 反序列化:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程
55.3 应用场景
- 对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化;
- 将对象存储到文件之前需要进行序列化,将对象从文件中读取出来需要进行反序列化;
- 将对象存储到数据库(如 Redis)之前需要用到序列化,将对象从缓存数据库中读取出来需要反序列化;
- 将对象存储到内存之前需要进行序列化,从内存中读取出来之后需要进行反序列化。
55.4 序列化协议对应于 TCP/IP 4 层模型的哪一层
- TCP/IP 四层模型是:应用层、传输层、网络层、网络接口层
- 序列化协议属于 TCP/IP 协议应用层的一部分
56. 常见序列化协议有哪些
56.1 JDK 自带的序列化方式
- JDK 自带的序列化,只需实现 java.io.Serializable接口即可。
1
2
3
4
5
6
7
8
9
10
11
12
13
14@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;
} - 为什么不推荐使用 JDK 自带的序列化?
- 不支持跨语言调用
- 性能差
- 存在安全问题
56.2 其他序列化协议
- 有 Hessian、Kryo、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议。
57. 何为反射?
- 反射(Reflection) 是 Java 程序开发语言的特征之一,它允许运行中的 Java 程序对自身进行检查.
- 通过反射你可以获取任意一个类的所有属性和方法,你还可以调用这些方法和属性
58. 反射的应用场景了解么
- 平时大部分时候都是在写业务代码,很少会接触到直接使用反射机制的场景;
- 像Spring/Spring Boot、MyBatis等框架中大量使用了动态代理,而动态代理的实现依赖反射。
59. 反射机制的优缺点
- 优点:
- 可以让咱们的代码更加灵活、为各种框架提供开箱即用的功能提供了便利
- 缺点:
- 安全问题:比如可以无视泛型参数的安全检查(泛型参数的安全检查发生在编译时)。
- 性能问题:反射的性能要稍差点,但是对于框架来说实际是影响不大。
60. 获取 Class 对象的四种方式
- 知道具体类的情况下可以使用:
1
Class alunbarClass = TargetObject.class;
- 通过 Class.forName()传入类的全路径获取:
1
Class alunbarClass1 = Class.forName("cn.javaguide.TargetObject");
- 通过对象实例instance.getClass()获取:
1
2TargetObject o = new TargetObject();
Class alunbarClass2 = o.getClass(); - 通过类加载器xxxClassLoader.loadClass()传入类路径获取:
1
ClassLoader.getSystemClassLoader().loadClass("cn.javaguide.TargetObject");
61. 反射的一些基本操作
1 | // 创建一个我们要使用反射操作的类 TargetObject |
1 | publicMethod |
62. 代理模式
62.1 概念:
- 我们使用代理对象来代替对真实对象(real object)的访问,这样就可以在不修改原目标对象的前提下,提供额外的功能操作,扩展目标对象的功能。
- 代理模式的主要作用是扩展目标对象的功能,比如说在目标对象的某个方法执行前后你可以增加一些自定义的操作。
62.2 静态代理
- 静态代理中,我们对目标对象的每个方法的增强都是手动完成的,非常不灵活(比如接口一旦新增加方法,目标对象和代理对象都要进行修改),且麻烦(需要对每个目标类都单独写一个代理类)。
- 从 JVM 层面来说, 静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。
- 实现步骤:
- 定义一个接口及其实现类;
- 创建一个代理类同样实现这个接口
- 将目标对象注入进代理类,然后在代理类的对应方法调用目标类中的对应方法。这样的话,我们就可以通过代理类屏蔽对目标对象的访问,并且可以在目标方法执行前后做一些自己想做的事情。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44// 1. 定义发送短信的接口
public interface SmsService {
String send(String message);
}
// 2. 实现发送短信的接口
public class SmsServiceImpl implements SmsService {
public String send(String message) {
System.out.println("send message:" + message);
return message;
}
}
// 3. 创建代理类并同样实现发送短信的接口
public class SmsProxy implements SmsService {
private final SmsService smsService;
public SmsProxy(SmsService smsService) {
this.smsService = smsService;
}
@Override
public String send(String message) {
//调用方法之前,我们可以添加自己的操作
System.out.println("before method send()");
smsService.send(message);
//调用方法之后,我们同样可以添加自己的操作
System.out.println("after method send()");
return null;
}
}
// 4. 实际使用
public class Main {
public static void main(String[] args) {
SmsService smsService = new SmsServiceImpl();
SmsProxy smsProxy = new SmsProxy(smsService);
smsProxy.send("java");
}
}
// 5. 运行上述代码之后,控制台打印出:
before method send()
send message:java
after method send()
62.3 动态代理
从 JVM 角度来说,动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。
实现步骤
- 定义一个接口及其实现类;
- 创建动态代理类,实现InvocationHandler接口,重写invoke方法,在 invoke 方法中调用原生方法(被代理类的方法)并自定义一些处理逻辑;
- 创建代理对象的工厂类,通过 Proxy.newProxyInstance(ClassLoader loader,Class<?>[] interfaces,InvocationHandler h) 方法创建代理对象;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57// 1. 定义发送短信的接口
public interface SmsService {
String send(String message);
}
// 2. 实现发送短信的接口
public class SmsServiceImpl implements SmsService {
public String send(String message) {
System.out.println("send message:" + message);
return message;
}
}
// 3. 定义一个 JDK 动态代理类
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public class DebugInvocationHandler implements InvocationHandler {
/**
* 代理类中的真实对象
*/
private final Object target;
public DebugInvocationHandler(Object target) {
this.target = target;
}
public Object invoke(Object proxy, Method method, Object[] args) throws InvocationTargetException, IllegalAccessException {
//调用方法之前,我们可以添加自己的操作
System.out.println("before method " + method.getName());
Object result = method.invoke(target, args);
//调用方法之后,我们同样可以添加自己的操作
System.out.println("after method " + method.getName());
return result;
}
}
// 4. 获取代理对象的工厂类
public class JdkProxyFactory {
// 主要通过Proxy.newProxyInstance()方法获取某个类的代理对象
public static Object getProxy(Object target) {
return Proxy.newProxyInstance(
target.getClass().getClassLoader(), // 目标类的类加载器
target.getClass().getInterfaces(), // 代理需要实现的接口,可指定多个
new DebugInvocationHandler(target) // 代理对象对应的自定义 InvocationHandler
);
}
}
// 5. 实际使用
SmsService smsService = (SmsService) JdkProxyFactory.getProxy(new SmsServiceImpl());
smsService.send("java");
// 6. 运行上述代码之后,控制台打印出:
before method send
send message:java
after method send
62.4 静态代理和动态代理的对比
- 灵活性:
- 静态代理中,接口一旦新增加方法,目标对象和代理对象都要进行修改,非常麻烦
- 动态代理更加灵活,不需要必须实现接口,可以直接代理实现类,并且可以不需要针对每个目标类都创建一个代理类。
- JVM 层面:
- 静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。
- 而动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。
63. SPI
63.1 SPI和API的区别
- 实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是 API ,这种接口和实现都是放在实现方的。
- 当接口存在于调用方这边时,就是SPI ,由接口调用方确定接口规则,然后由不同的厂商去根据这个规则对这个接口进行实现,从而提供服务。
- 举个通俗易懂的例子:公司 H 是一家科技公司,新设计了一款芯片,然后现在需要量产了,而市面上有好几家芯片制造业公司,这个时候,只要 H 公司指定好了这芯片生产的标准(定义好了接口标准),那么这些合作的芯片公司(服务提供者)就按照标准交付自家特色的芯片(提供不同方案的实现,但是给出来的结果是一样的)。
63.2 应用场景
很多框架都使用了 Java 的 SPI 机制,比如:Spring 框架、数据库加载驱动、日志接口、以及 Dubbo 的扩展实现等等。
64. 语法糖
64.1 定义
- 语法糖(Syntactic Sugar) 也称糖衣语法。
- 指在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。
- 简而言之,语法糖让程序更加简洁,有更高的可读性。
64.2 switch 支持 String
- switch自身原本就支持基本类型。比如int、char等。
- 对于int类型,直接进行数值的比较。
- 对于char类型则是比较其 ascii 码。
- 所以,对于编译器来说,switch中其实只能使用整型,任何类型的比较都要转换成整型。
- Java 7 中switch开始支持String
- 字符串的 switch 是通过equals()和hashCode()方法来实现的
64.3 泛型
- 对于JVM来说,他根本不认识Map<String, String> map这样的语法。需要在编译阶段通过类型擦除的方式进行解语法糖。
1
2
3
4
5
6
7
8
9
10
11// 原来的代码:
Map<String, String> map = new HashMap<String, String>();
map.put("name", "hollis");
map.put("wechat", "Hollis");
map.put("blog", "www.hollischuang.com");
// 解语法糖之后会变成:
Map map = new HashMap();
map.put("name", "hollis");
map.put("wechat", "Hollis");
map.put("blog", "www.hollischuang.com");
64.4 自动装箱与拆箱
- 装箱过程是通过调用包装器的 valueOf 方法实现的,
- 拆箱过程是通过调用包装器的 xxxValue 方法实现的。
64.5 可变长参数
可变参数在被使用的时候,他首先会创建一个数组,数组的长度就是调用该方法是传递的实参的个数,然后再把参数值全部放到这个数组当中,然后再把这个数组作为参数传递到被调用的方法中。
64.6 断言
断言的底层实现就是 if 语言,如果断言结果为 true,则什么都不做,程序继续执行,如果断言结果为 false,则程序抛出 AssertionError 来打断程序的执行。
1 | public class AssertTest { |
64.7 数值字面量
编译器并不认识在数字字面量中的_,需要在编译阶段把他去掉。
1 | // 数值字面量,不管是整数还是浮点数,都允许在数字之间插入任意多个下划线。 |
64.8 for-each
for-each 的实现原理其实就是使用了普通的 for 循环和迭代器
64.9 try-with-resource
背后的原理很简单,那些我们没有做的关闭资源的操作,编译器都帮我们做了。
65. 集合概述
65.1 集合概览
集合,也叫作容器,主要是由两大接口派生而来:
Collection
接口,主要用于存放单一元素,包含三个主要子接口:List
、Set
和Queue
。Map
接口,主要用于存放键值对。
65.2 说说 List, Set, Queue, Map 四者的区别?
List
(对付顺序的好帮手): 存储的元素是有序、可重复、有索引。Set
(注重独一无二的性质): 存储的元素是无序、不可重复、无索引。Queue
(实现排队功能的叫号机): 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。Map
(用 key 来搜索的专家): 使用键值对(key-value)存储,类似于数学上的函数 y=f(x),”x” 代表 key,”y” 代表 value,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。
65.3 集合框架底层数据结构总结
先来看一下 Collection
接口下面的集合。
List
ArrayList
:Object[]
数组Vector
:Object[]
数组LinkedList
:双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)
Set
HashSet
(无序,唯一): 基于HashMap
(JDK8以前:数组+链表,JDK8以后:数组+链表+红黑树)实现的,底层采用HashMap
来保存元素。LinkedHashSet
:LinkedHashSet
是HashSet
的子类,基于HashMap来实现,每个元素额外多了一个双链表记录前后元素的位置。TreeSet
(有序,唯一): 红黑树(自平衡的排序二叉树)
Queue
PriorityQueue
:Object[]
数组来实现二叉堆ArrayQueue
:Object[]
数组 + 双指针
再来看看 Map
接口下面的集合。
Map
HashMap
:JDK1.8 之前HashMap
由数组+链表组成的,数组是HashMap
的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间LinkedHashMap
:LinkedHashMap
继承自HashMap
,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。TreeMap
:红黑树(自平衡的排序二叉树)
65.4 如何选用集合?
我们主要根据集合的特点来选择合适的集合。比如:
- 我们需要根据键值获取到元素值时就选用
Map
接口下的集合,- 需要排序时选择
TreeMap
- 不需要排序时就选择
HashMap
- 需要保证线程安全就选用
ConcurrentHashMap
。
- 需要排序时选择
- 我们只需要存放元素值时,就选择实现
Collection
接口的集合- 需要保证元素唯一时选择实现
Set
接口的集合比如TreeSet
或HashSet
, - 不需要就选择实现
List
接口的比如ArrayList
或LinkedList
- 需要保证元素唯一时选择实现
65.5 为什么要使用集合?
- 数组:
- 当我们需要存储一组类型相同的数据时,数组是最常用且最基本的容器之一。
- 但是,使用数组存储对象存在一些不足:存储类型固定,长度固定。
- 集合:
- 集合的优势在于它们的大小可变、支持泛型等。
- 集合提高了数据的存储和处理灵活性。
66. List
66.1 ArrayList底层的原理
- 利用无参构造器创建的集合,会在底层创建一个默认长度为0的数组
- 添加第一个元素时,底层会创建一个新的长度为10的数组
- 存满时,会扩容1.5倍
- 如果一次添加多个元素,1.5倍还放不下,则新创建数组的长度以实际为准
66.2 ArrayList 和 Array(数组)的区别?
ArrayList
内部基于动态数组实现,比 Array
(静态数组) 使用起来更加灵活:
ArrayList
会根据实际存储的元素动态地扩容或缩容,而Array
被创建之后就不能改变它的长度了。ArrayList
允许你使用泛型来确保类型安全,Array
则不可以。ArrayList
中只能存储对象。对于基本类型数据,需要使用其对应的包装类(如 Integer、Double 等)。Array
可以直接存储基本类型数据,也可以存储对象。ArrayList
支持插入、删除、遍历等常见操作,并且提供了丰富的 API 操作方法,比如add()
、remove()
等。Array
只是一个固定长度的数组,只能按照下标访问其中的元素,不具备动态添加、删除元素的能力。ArrayList
创建时不需要指定大小,而Array
创建时必须指定大小。
1 | // Array |
66.3 ArrayList 可以添加 null 值吗?
ArrayList
中可以存储任何类型的对象,包括null
值。- 不建议向
ArrayList
中添加null
值,null
值无意义,会让代码难以维护比如忘记做判空处理就会导致空指针异常。
1 | ArrayList<String> listOfStrings = new ArrayList<>(); |
66.4 ArrayList 插入和删除元素的时间复杂度?
插入:
- 头部插入:由于需要将所有元素都依次向后移动一个位置,因此时间复杂度是 **O(n)**。
- 尾部插入:当
ArrayList
的容量未达到极限时,往列表末尾插入元素的时间复杂度是 **O(1)**,因为它只需要在数组末尾添加一个元素即可;当容量已达到极限并且需要扩容时,则需要执行一次 O(n) 的操作将原数组复制到新的更大的数组中,然后再执行 O(1) 的操作添加元素。 - 指定位置插入:需要将目标位置之后的所有元素都向后移动一个位置,然后再把新元素放入指定位置。这个过程需要移动平均 n/2 个元素,因此时间复杂度为 **O(n)**。
删除:
- 头部删除:由于需要将所有元素依次向前移动一个位置,因此时间复杂度是 **O(n)**。
- 尾部删除:当删除的元素位于列表末尾时,时间复杂度为 **O(1)**。
- 指定位置删除:需要将目标元素之后的所有元素向前移动一个位置以填补被删除的空白位置,因此需要移动平均 n/2 个元素,时间复杂度为 **O(n)**。
66.5 LinkedList 插入和删除元素的时间复杂度?
- 头部插入/删除:只需要修改头结点的指针即可完成插入/删除操作,因此时间复杂度为 **O(1)**。
- 尾部插入/删除:只需要修改尾结点的指针即可完成插入/删除操作,因此时间复杂度为 **O(1)**。
- 指定位置插入/删除:需要先移动到指定位置,再修改指定节点的指针完成插入/删除,因此需要移动平均 n/2 个元素,时间复杂度为 **O(n)**。
66.6 LinkedList 为什么不能实现 RandomAccess 接口?
RandomAccess
是一个标记接口,用来表明实现该接口的类支持随机访问(即可以通过索引快速访问元素)。- 由于
LinkedList
底层数据结构是链表,内存地址不连续,只能通过指针来定位,不支持随机快速访问,所以不能实现RandomAccess
接口。
66.7 ArrayList 与 LinkedList 区别?
- 是否保证线程安全:
ArrayList
和LinkedList
都是不同步的,不保证线程安全;
- 底层数据结构:
ArrayList
底层使用的是Object
数组;LinkedList
底层使用的是 双向链表 数据结构
- 插入和删除是否受元素位置的影响:
ArrayList
采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。LinkedList
采用链表存储,所以在头尾插入或者删除元素不受元素位置的影响。
- 是否支持快速随机访问:
ArrayList
实现了RandomAccess
接口, 支持随机访问。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)
方法)LinkedList
不支持高效的随机元素访问。
- 内存空间占用:
ArrayList
的空间浪费主要体现在 list 列表的结尾会预留一定的容量空间;LinkedList
的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。
- 应用:
- 我们在项目中一般是不会使用到
LinkedList
的,需要用到LinkedList
的场景几乎都可以使用ArrayList
来代替,并且,性能通常会更好!
- 我们在项目中一般是不会使用到
67. Set
67.1 HashSet底层原理
- HashSet集合底层是基于哈希表实现的,哈希表根据JDK版本的不同,也是有点区别的
- JDK8以前:哈希表 = 数组+链表
- JDK8以后:哈希表 = 数组+链表+红黑树
67.2 LinkedHashSet底层原理
67.3 Comparable 和 Comparator 的区别
Comparable
接口和Comparator
接口都是 Java 中用于排序的接口,它们在实现类对象之间比较大小、排序等方面发挥了重要作用:Comparable
接口实际上是出自java.lang
包,它有一个compareTo(Object obj)
方法用来排序Comparator
接口实际上是出自java.util
包,它有一个compare(Object obj1, Object obj2)
方法用来排序
Comparator 定制排序
1 | ArrayList<Integer> arrayList = new ArrayList<Integer>(); |
重写 compareTo 方法实现按年龄来排序
1 | // person对象没有实现Comparable接口,所以必须实现,这样才不会出错,才可以使treemap中的数据按顺序排列 |
67.4 无序性和不可重复性的含义是什么
- 无序性不等于随机性.
- 无序性是指存储的数据在底层数组中并非按照数组索引的顺序添加 ,而是根据数据的哈希值决定的。
- 不可重复性是指添加的元素按照
equals()
判断时 ,返回 false,需要同时重写equals()
方法和hashCode()
方法。
67.5 比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同
- 相同点:都是
Set
接口的实现类,都能保证元素唯一,并且都不是线程安全的。 - 不同点:主要区别在于底层数据结构不同。
HashSet
的底层数据结构是哈希表(基于HashMap
实现)。LinkedHashSet
的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。TreeSet
底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。
68. Queue 与 Deque 的区别
Queue
是单端队列,只能从一端插入元素,另一端删除元素,遵循 先进先出(FIFO) 规则。Deque
(Double Ended Queue)是双端队列,在队列的两端均可以插入或删除元素。
69. 集合判空
- 判断所有集合内部的元素是否为空,使用
isEmpty()
方法,而不是size()==0
的方式。 - 这是因为
isEmpty()
方法的可读性更好,并且时间复杂度为 O(1)。
80. 集合遍历
- 不要在 foreach 循环里进行元素的
remove/add
操作。remove 元素请使用Iterator
方式,如果并发操作,需要对Iterator
对象加锁。
81. 集合数组互转
1 | // 数组转集合 |
82. 乐观锁和悲观锁
82.1 基本概念
乐观锁和悲观锁是两种思想,用于解决并发场景下的数据竞争问题。
- 乐观锁:在操作数据时非常乐观,认为别人不会同时修改数据。因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作,否则执行操作。
- 悲观锁:在操作数据时比较悲观,认为别人会同时修改数据。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其它线程阻塞,其他人不能修改数据。
83.2 实现方式
- 乐观锁的实现方式主要有两种:版本号机制和CAS机制
版本号机制:一般是在数据表中加上一个数据版本号
version
字段,表示数据被修改的次数。当数据被修改时,version
值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取version
值,在提交更新时,若刚才读取到的 version 值为当前数据库中的version
值相等时才更新,否则重试更新操作,直到更新成功。1
2
3
4
5
6
7
8**举一个简单的例子**:假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( `balance` )为 \$100 。
1. 操作员 A 此时将其读出( `version`=1 ),并从其帐户余额中扣除 $50( $100-\$50 )。
2. 在操作员 A 操作的过程中,操作员 B 也读入此用户信息( `version`=1 ),并从其帐户余额中扣除 $20 ( $100-\$20 )。
3. 操作员 A 完成了修改工作,将数据版本号( `version`=1 ),连同帐户扣除后余额( `balance`=\$50 ),提交至数据库更新,此时由于提交数据版本等于数据库记录当前版本,数据被更新,数据库记录 `version` 更新为 2 。
4. 操作员 B 完成了操作,也将版本号( `version`=1 )试图向数据库提交数据( `balance`=\$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 1 ,数据库记录当前版本也为 2 ,不满足 “ 提交版本必须等于当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。
这样就避免了操作员 B 用基于 `version`=1 的旧数据修改的结果覆盖操作员 A 的操作结果的可能。CAS 算法:
- CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
- CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。(原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。)
- CAS 涉及到三个操作数:V、E、N。当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。
- V:要更新的变量值(Var)
- E:预期值(Expected)
- N:拟写入的新值(New)
1
2
3
4**举一个简单的例子**:线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E=1,N=6,假设不存在 ABA 问题)。
1. i 与 1 进行比较,如果相等, 则说明没被其他线程修改,可以被设置为 6 。
2. i 与 1 进行比较,如果不相等,则说明被其他线程修改,当前线程放弃更新,CAS 操作失败。
当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。
- 悲观锁的实现方式是加锁,加锁既可以是对代码块加锁(如Java的synchronized关键字),也可以是对数据加锁(如MySQL中的排它锁)。
83.3 优缺点和应用场景
- 当竞争不激烈 (出现并发冲突的概率小)时,乐观锁更有优势,因为悲观锁会锁住代码块或数据,其他线程无法同时访问,影响并发,而且加锁和释放锁都需要消耗额外的资源。
- 当竞争激烈(出现并发冲突的概率大)时,悲观锁更有优势,因为乐观锁在执行更新时频繁失败,需要不断重试,浪费CPU资源。
83.4 乐观锁加锁吗
- 乐观锁本身是不加锁的,只是在更新时判断一下数据是否被其他线程更新了;AtomicInteger便是一个例子。
- 有时乐观锁可能与加锁操作合作,但是不能改变“乐观锁本身不加锁”这一事实。
83.4 CAS有哪些缺点
- ABA问题:
- 定义:如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 “ABA”问题。
- 解决思路:ABA 问题的解决思路是在变量前面追加上版本号或者时间戳。
- 高竞争下的开销问题:
- 定义:在并发冲突概率大的高竞争环境下,如果CAS一直失败,会一直重试,CPU开销较大。
- 解决思路:针对这个问题的一个思路是引入退出机制,如重试次数超过一定阈值后失败退出。当然,更重要的是避免在高竞争环境下使用乐观锁。
- 功能限制:
- 例如CAS只能保证单个变量(或者说单个内存值)操作的原子性,当操作涉及跨多个共享变量时 CAS 无效。
83.5 总结
- 高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。不过,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。
- 乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些。
- CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
- 乐观锁的问题:ABA 问题、循环时间长开销大、只能保证一个共享变量的原子操作。
84. JMM(Java Memory Model)
84.1 什么是 JMM?为什么需要 JMM?
- Java内存模型即Java Memory Model,简称JMM。用来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各平台下都能够达到一致的内存访问效果。
- JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。
84.2 多线程下,对主内存中的一个共享变量进行操作有可能诱发线程安全问题。
线程 1 和线程 2 分别对同一个共享变量进行操作,一个执行修改,一个执行读取。
线程 2 读取到的是线程 1 修改之前的值还是修改后的值并不确定,都有可能,因为线程 1 和线程 2 都是先将共享变量从主内存拷贝到对应线程的工作内存中。
84.3 Java 内存区域和 JMM 有何区别?
- Java 内存区域和内存模型是完全不一样的两个东西:
- Java 内存区域是指 Jvm 运行时将数据分区域存储,强调对内存空间的划分,就比如说堆主要用于存放对象实例。
- Java 内存模型抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中。
84.4 happens-before 是什么?
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见(保障可见性),并且第一个操作的执行顺序排在第二个操作之前(JMM对程序员做出的一个逻辑保障,并不是代码真正的执行保障)。
- 即使两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。只要不改变程序的执行结果,编译器、处理器怎么优化都可以。
1 | int userNum = getUserNum(); // 1 |
虽然 1 happens-before 2,但对 1 和 2 进行重排序不会影响代码的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。但 1 和 2 必须是在 3 执行之前,也就是说 1,2 happens-before 3 。
happens-before 原则表达的意义其实并不是一个操作发生在另外一个操作的前面,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里。
84.5 happens-before 原则:(一共8条,重点是下面5条)
- 程序顺序规则:一个线程内,按照代码顺序,书写在前面的操作 happens-before 于书写在后面的操作;
- 解锁规则:解锁 happens-before 于加锁;
- volatile 变量规则:对一个 volatile 变量的写操作 happens-before 于后面对这个 volatile 变量的读操作。说白了就是对 volatile 变量的写操作的结果对于发生于其后的任何操作都是可见的。
- 传递规则:如果 A happens-before B,且 B happens-before C,那么 A happens-before C;
- 线程启动规则:Thread 对象的
start()
方法 happens-before 于此线程的每一个动作。
84.6 并发编程三个重要特性
- 原子性
- 可见性
- 有序性
84.7 volatile关键字
- 在 Java 中,volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
- 防止 JVM 的指令重排序。如果将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。
- volatile 关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。
84.8 synchronized关键字
概念:
- synchronized 是 Java 中的一个关键字,翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
使用:
- 修饰实例方法 (锁当前对象实例)
1
2
3
4synchronized void method() {
//业务代码
} - 修饰静态方法 (锁当前类)
1
2
3synchronized static void method() {
//业务代码
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43// 定义类
class SyncThread implements Runnable {
private static int count;
public SyncThread() {
count = 0;
}
public synchronized static void method() {
for (int i = 0; i < 5; i ++) {
try {
System.out.println(Thread.currentThread().getName() + ":" + (count++));
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public synchronized void run() {
method();
}
}
// 代码调用
SyncThread syncThread1 = new SyncThread();
SyncThread syncThread2 = new SyncThread();
Thread thread1 = new Thread(syncThread1, "SyncThread1");
Thread thread2 = new Thread(syncThread2, "SyncThread2");
thread1.start();
thread2.start();
// 结果如下:
SyncThread1:0
SyncThread1:1
SyncThread1:2
SyncThread1:3
SyncThread1:4
SyncThread2:5
SyncThread2:6
SyncThread2:7
SyncThread2:8
SyncThread2:9 - 修饰代码块 (锁指定对象/类)
1
2
3synchronized(this) {
//业务代码
}
- 修饰实例方法 (锁当前对象实例)
底层原理:
- javac在编译时,会生成对应的
monitorenter
和monitorexit
指令分别对应synchronized
同步块的进入和退出. - 有两个
monitorexit
指令的原因是:为了保证抛异常的情况下也能释放锁,所以javac为同步代码块添加了一个隐式的try-finally,在finally中会调用monitorexit
命令释放锁。
- javac在编译时,会生成对应的
构造方法可以用 synchronized 修饰么?
- 构造方法不能使用 synchronized 关键字修饰。
- 构造方法本身就属于线程安全的,不存在同步的构造方法一说。
synchronized 和 volatile 有什么区别?
- volatile 关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized要好。
- volatile 关键字只能用于变量,而 synchronized 关键字可以修饰方法以及代码块。
- volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
- volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。
85. 线程
85.1 创建线程的方式(三种)
Thread
- 继承
Thread
类,重写run
方法,run方法代表线程要完成的任务, - 调用线程实例对象的
start()
方法来启动该线程.1
2
3
4
5
6
7
8
9
10class ThreadDemo1 extends Thread{
@Override
public void run(){
System.out.println("线程的第一种创建方式");
}
}
// main方法调用
ThreadDemo1 demo1 = new ThreadDemo1();
demo1.start();
- 继承
runnable
- 实现
runnable
接口,重写该接口的run()
方法,run方法代表线程要完成的任务,调用线程实例对象的start()方法来启动该线程 - 创建
Runnable
实现类的实例,并依此实例作为Thread的target来创建Thread对象,该Thread
对象才是真正的线程对象。 - 调用
Thread
线程对象的start()方法来启动该线程。1
2
3
4
5
6
7
8
9
10class ThreadDemo2 implements Runnable{
@Override
public void run() {
System.out.println("线程的第二种创建方式-实现runnable接口");
}
}
// main方法调用
ThreadDemo2 demo2 = new ThreadDemo2();
new Thread(demo2).start();
- 实现
Callable
- 实现
Callable
接口,重写call()
方法,该call()
方法将作为线程执行体,并且有返回值; - 创建
Callable
实现类的实例对象,使用FutureTask
类来包装Callable
对象,该FutureTask
对象封装了该Callable
对象的call()
方法的返回值。 - 使用FutureTask对象作为Thread对象的target创建并启动新线程。
- 调用FutureTask对象的get()方法来获得子线程执行结束后的返回值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20class ThreadDemo3 implements Callable<Integer>{
private int i =0;
@Override
public Integer call() throws Exception {
for(int i=0;i<100;i++){
this.i+=i;
}
return i;
}
}
//main调用
ThreadDemo3 demo3 = new ThreadDemo3();
FutureTask<Integer> task = new FutureTask<>(demo3);
new Thread(task).start();
try {
System.out.println(task.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
}
- 实现
85.2 Java对象引用级别
- JDK把对象的引用级别由高到低分为强引用、软引用、弱引用、虚引用四种级别
- 强引用 StrongReference:属于不可回收资源,GC绝对不会回收它,即使是内存不足,JVM宁愿抛出OutOfMemoryError异常,使程序终止,也不会来回收强引用对象。
1
User user = new User("Tom");
- 软引用 SoftReference:它的性质属于可有可无,因为内存空间充足的情况下,GC不会回收它,但是内存空间紧张,GC发现它仅有软引用,就会回收该对象,所以软引用对象适合作为内存敏感的缓存对象。
1
SoftReference<User> soft = new SoftReference<>(new User("Tom"));
- 弱引用 WeakReference:弱引用对象相对软引用对象具有更短暂的生命周期,只要GC发现它仅有弱引用,不管内存空间是否充足,都会回收它,不过GC是一个优先级很低的线程,因此不一定会很快发现那些仅有弱引用的对象。
1
WeakReference<Object> weak = new WeakReference<>(new User("Tom"));
85.3 ThreadLocal
概念:创建了一个ThreadLocal变量,它是线程隔离的,访问这个变量的每个线程都会有这个变量的本地副本。他们可以使用 get() 和 set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。
使用:
- get()
- set()
- remove()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52import java.text.SimpleDateFormat;
import java.util.Random;
public class ThreadLocalExample implements Runnable{
// SimpleDateFormat 不是线程安全的,所以每个线程都要有自己独立的副本
private static final ThreadLocal<SimpleDateFormat> formatter = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyyMMdd HHmm"));
public static void main(String[] args) throws InterruptedException {
ThreadLocalExample obj = new ThreadLocalExample();
for(int i=0 ; i<10; i++){
Thread t = new Thread(obj, ""+i);
Thread.sleep(new Random().nextInt(1000));
t.start();
}
}
@Override
public void run() {
System.out.println("Thread Name= "+Thread.currentThread().getName()+" default Formatter = "+formatter.get().toPattern());
try {
Thread.sleep(new Random().nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
//formatter pattern is changed here by thread, but it won't reflect to other threads
formatter.set(new SimpleDateFormat());
System.out.println("Thread Name= "+Thread.currentThread().getName()+" formatter = "+formatter.get().toPattern());
}
}
// 输出结果:
Thread Name= 0 default Formatter = yyyyMMdd HHmm
Thread Name= 0 formatter = yy-M-d ah:mm
Thread Name= 1 default Formatter = yyyyMMdd HHmm
Thread Name= 2 default Formatter = yyyyMMdd HHmm
Thread Name= 1 formatter = yy-M-d ah:mm
Thread Name= 3 default Formatter = yyyyMMdd HHmm
Thread Name= 2 formatter = yy-M-d ah:mm
Thread Name= 4 default Formatter = yyyyMMdd HHmm
Thread Name= 3 formatter = yy-M-d ah:mm
Thread Name= 4 formatter = yy-M-d ah:mm
Thread Name= 5 default Formatter = yyyyMMdd HHmm
Thread Name= 5 formatter = yy-M-d ah:mm
Thread Name= 6 default Formatter = yyyyMMdd HHmm
Thread Name= 6 formatter = yy-M-d ah:mm
Thread Name= 7 default Formatter = yyyyMMdd HHmm
Thread Name= 7 formatter = yy-M-d ah:mm
Thread Name= 8 default Formatter = yyyyMMdd HHmm
Thread Name= 9 default Formatter = yyyyMMdd HHmm
Thread Name= 8 formatter = yy-M-d ah:mm
Thread Name= 9 formatter = yy-M-d ah:mm
原理:
- ThreadLocal底层是ThreadLocalMap。 每个Thread中都具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为key ,Object对象为 value 的键值对。
1
2
3
4public class Thread implements Runnable {
// 与此线程有关的ThreadLocal值。由ThreadLocal类维护
ThreadLocal.ThreadLocalMap threadLocals = null;
} - ThreadLocal的set()方法:调用的是ThreadLocalMap的set方法将(ThreadLocal, value)存储到ThreadLocalMap
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public void set(T value) {
//获取当前请求的线程
Thread t = Thread.currentThread();
//取出 Thread 类内部的 threadLocals 变量(哈希表结构)
ThreadLocalMap map = getMap(t);
if (map != null){
// 将需要存储的值放入到这个哈希表中
map.set(this, value);
}else{
createMap(t, value);
}
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
- ThreadLocal底层是ThreadLocalMap。 每个Thread中都具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为key ,Object对象为 value 的键值对。
内存泄露问题是怎么导致的?
ThreadLocalMap
中使用的 key 为ThreadLocal
的弱引用,而 value 是强引用,所以在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。- 这样,
ThreadLocalMap
中就会出现key
为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。 - 解决方案:使用完
ThreadLocal
方法后最好手动调用remove()
方法
85.4 线程池
概念:为了避免频繁重复的创建和销毁线程,我们可以让这些线程进行复用。而线程池是将创建的线程存储到一个池中,在需要使用时从池中去拿,使用完之后再将线程归还到池中,下一次接着使用。
如何创建线程池:通过
ThreadPoolExecutor
构造函数来创建线程池常见参数有哪些?如何解释?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26/**
* 用给定的初始参数创建一个新的ThreadPoolExecutor。
*/
public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量
int maximumPoolSize,//线程池的最大线程数
long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间
TimeUnit unit,//时间单位
BlockingQueue<Runnable> workQueue,//任务队列,用来储存等待执行任务的队列
ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可
RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}ThreadPoolExecutor
3 个最重要的参数:corePoolSize
: 任务队列未达到队列容量时,最大可以同时运行的线程数量。maximumPoolSize
: 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。workQueue
: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
ThreadPoolExecutor
其他常见参数 :keepAliveTime
:线程池中的线程数量大于corePoolSize
的时候,如果这时没有新的任务提交,多余的空闲线程不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime
才会被回收销毁,线程池回收线程时,会对核心线程和非核心线程一视同仁,直到线程池中线程的数量等于corePoolSize
,回收过程才会停止。unit
:keepAliveTime
参数的时间单位。threadFactory
:executor 创建新线程的时候会用到。handler
:饱和策略。关于饱和策略下面单独介绍一下。
线程池的饱和策略有哪些?
- 接口
RejectedExecutionHandler
定义了饱和策略,所有的饱和策略都需要实现该接口。1
2
3public interface RejectedExecutionHandler {
void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
} ThreadPoolExecutor.AbortPolicy
: 拒绝处理,抛出RejectedExecutionException
异常ThreadPoolExecutor.CallerRunsPolicy
: 由创建该线程的线程(main)执行ThreadPoolExecutor.DiscardPolicy
: 丢弃,不抛出异常ThreadPoolExecutor.DiscardOldestPolicy
: 和最早创建的线程进行竞争,不抛出异常
- 接口
86. AQS
86.1 概述
- AQS 的全称为
AbstractQueuedSynchronizer
,翻译过来的意思就是抽象队列同步器。 - 这个类在
java.util.concurrent.locks
包下面。 - AQS 是一个抽象类,主要用来构建锁和同步器。
86.2 自旋锁和非自旋锁
- 什么是自旋锁:
- 它并不会放弃CPU时间片,而是通过自旋等待锁的释放,也就是说,它会不停地再次地尝试获取锁,如果失败就再次尝试,直到成功为止;
- 什么是非自旋锁:
- 如果它发现此时获取不到锁,它就把自己的线程切换状态,让线程休眠,然后 CPU 就可以在这段时间去做很多其他的事情,直到之前持有这把锁的线程释放了锁,于是 CPU 再把之前的线程恢复回来,让这个线程再去尝试获取这把锁。如果再次失败,就再次让线程休眠,如果成功,一样可以成功获取到同步资源的锁
- 非自旋锁和自旋锁最大的区别,在非自旋锁遇到拿不到锁的情况,它会把线程阻塞,直到被唤醒。而自旋锁会不停地尝试。
86.3 CLH锁
- CLH 锁是对自旋锁的一种改进,是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系),暂时获取不到锁的线程将被加入到该队列中。AQS 将每条请求共享资源的线程封装成一个 CLH 队列锁的一个结点(Node)来实现锁的分配。
- 在 CLH 队列锁中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。
- CLH 队列结构如下图所示:
86.4 AQS原理
AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是基于 CLH 锁 (Craig, Landin, and Hagersten locks) 实现的。
87. IO
87.1 IO流简介
- 概念:IO 即
Input/Output
,输入和输出。数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。 - 分类:IO 流在 Java 中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流。
- 4个抽象类基类:Java IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。
InputStream
/Reader
: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。OutputStream
/Writer
: 所有输出流的基类,前者是字节输出流,后者是字符输出流。
87.2 字节流
InputStream(字节输入流)(抽象类)
- FileInputStream(子类)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19/**
* 目标:掌握文件字节输入流,每次读取一个字节。
*/
public class FileInputStreamTest1 {
public static void main(String[] args) throws Exception {
// 1、创建文件字节输入流管道,与源文件接通。
InputStream is = new FileInputStream(("file-io-app\\src\\itheima01.txt"));
// 2、开始读取文件的字节数据。
// public int read():每次读取一个字节返回,如果没有数据了,返回-1.
int b; // 用于记住读取的字节。
while ((b = is.read()) != -1){
System.out.print((char) b);
}
//3、流使用完毕之后,必须关闭!释放系统资源!
is.close();
}
} - BufferedInputStream(字节缓冲输入流):缓冲流的底层自己封装了一个长度为8KB(8129byte)的字节数组,但是缓冲流不能单独使用,它需要依赖于原始流。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24public class BufferedInputStreamTest1 {
public static void main(String[] args) {
try (
InputStream is = new FileInputStream("io-app2/src/itheima01.txt");
// 1、定义一个字节缓冲输入流包装原始的字节输入流
InputStream bis = new BufferedInputStream(is);
OutputStream os = new FileOutputStream("io-app2/src/itheima01_bak.txt");
// 2、定义一个字节缓冲输出流包装原始的字节输出流
OutputStream bos = new BufferedOutputStream(os);
){
byte[] buffer = new byte[1024];
int len;
while ((len = bis.read(buffer)) != -1){
bos.write(buffer, 0, len);
}
System.out.println("复制完成!!");
} catch (Exception e) {
e.printStackTrace();
}
}
}
- FileInputStream(子类)
OutputStream(字节输出流)(抽象类)
- FileOutputStream(子类)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27/**
* 目标:掌握文件字节输出流FileOutputStream的使用。
*/
public class FileOutputStreamTest4 {
public static void main(String[] args) throws Exception {
// 1、创建一个字节输出流管道与目标文件接通。
// 覆盖管道:覆盖之前的数据
// OutputStream os = new FileOutputStream("file-io-app/src/itheima04out.txt");
// 追加数据的管道
OutputStream os = new FileOutputStream("file-io-app/src/itheima04out.txt", true);
// 2、开始写字节数据出去了
os.write(97); // 97就是一个字节,代表a
os.write('b'); // 'b'也是一个字节
// os.write('磊'); // [ooo] 默认只能写出去一个字节
byte[] bytes = "我爱你中国abc".getBytes();
os.write(bytes);
os.write(bytes, 0, 15);
// 换行符
os.write("\r\n".getBytes());
os.close(); // 关闭流
}
} - BufferedOutputStream(字节缓冲输出流)
- FileOutputStream(子类)
87.3 字符流
Reader(字符输入流)(抽象类)
- FileReader(子类)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21/**
* 目标:掌握文件字符输入流。
*/
public class FileReaderTest1 {
public static void main(String[] args) {
try (
// 1、创建一个文件字符输入流管道与源文件接通
Reader fr = new FileReader("io-app2\\src\\itheima01.txt");
){
// 2、每次读取多个字符。
char[] buffer = new char[3];
int len; // 记住每次读取了多少个字符。
while ((len = fr.read(buffer)) != -1){
// 读取多少倒出多少
System.out.print(new String(buffer, 0, len));
}
} catch (Exception e) {
e.printStackTrace();
}
}
}- BufferedReader(字符缓冲输入流):它底层也会有一个8KB的字符数组。字符缓冲流也不能单独使用,它需要依赖于原始字符流一起使用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public class BufferedReaderTest2 {
public static void main(String[] args) {
try (
Reader fr = new FileReader("io-app2\\src\\itheima04.txt");
// 创建一个字符缓冲输入流包装原始的字符输入流
BufferedReader br = new BufferedReader(fr);
){
String line; // 记住每次读取的一行数据
while ((line = br.readLine()) != null){
System.out.println(line);
}
} catch (Exception e) {
e.printStackTrace();
}
}
} - InputStreamReader(转换流):
- FileReader默认只能读取UTF-8编码格式的文件。如果使用FileReader读取GBK格式的文件,可能存在乱码。转换流可以将字节流转换为字符流,并且可以指定编码方案。
- InputStreamReader类表示可以把InputStream转换为Reader,它是字符输入流。
- InputStreamReader也是不能单独使用的,它内部需要封装一个InputStream的子类对象,再指定一个编码表,如果不指定编码表,默认会按照UTF-8形式进行转换。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21public class InputStreamReaderTest2 {
public static void main(String[] args) {
try (
// 1、得到文件的原始字节流(GBK的字节流形式)
InputStream is = new FileInputStream("io-app2/src/itheima06.txt");
// 2、把原始的字节输入流按照指定的字符集编码转换成字符输入流
Reader isr = new InputStreamReader(is, "GBK");
// 3、把字符输入流包装成缓冲字符输入流
BufferedReader br = new BufferedReader(isr);
){
String line;
while ((line = br.readLine()) != null){
System.out.println(line);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
- BufferedReader(字符缓冲输入流):它底层也会有一个8KB的字符数组。字符缓冲流也不能单独使用,它需要依赖于原始字符流一起使用。
- FileReader(子类)
Writer(字符输出流)(抽象类)
- FileWriter(子类)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36/**
* 目标:掌握文件字符输出流:写字符数据出去
*/
public class FileWriterTest2 {
public static void main(String[] args) {
try (
// 0、创建一个文件字符输出流管道与目标文件接通。
// 覆盖管道
// Writer fw = new FileWriter("io-app2/src/itheima02out.txt");
// 追加数据的管道
Writer fw = new FileWriter("io-app2/src/itheima02out.txt", true);
){
// 1、public void write(int c): 写一个字符出去
fw.write('a');
fw.write(97);
fw.write('磊'); // 写一个字符出去
fw.write("\r\n"); // 换行
// 2、public void write(String c): 写一个字符串出去
fw.write("我爱你中国abc");
fw.write("\r\n");
// 3、public void write(String c ,int pos ,int len): 写字符串的一部分出去
fw.write("我爱你中国abc", 0, 5);
fw.write("\r\n");
// 4、public void write(char[] buffer): 写一个字符数组出去
char[] buffer = {'黑', '马', 'a', 'b', 'c'};
fw.write(buffer);
fw.write("\r\n");
// 5、public void write(char[] buffer ,int pos ,int len):写字符数组的一部分出去
fw.write(buffer, 0, 2);
fw.write("\r\n");
} catch (Exception e) {
e.printStackTrace();
}
}
} - BufferedWriter(字符缓冲输出流)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20public class BufferedWriterTest3 {
public static void main(String[] args) {
try (
Writer fw = new FileWriter("io-app2/src/itheima05out.txt", true);
// 创建一个字符缓冲输出流管道包装原始的字符输出流
BufferedWriter bw = new BufferedWriter(fw);
){
bw.write('a');
bw.write(97);
bw.write('磊');
bw.newLine();
bw.write("我爱你中国abc");
bw.newLine();
} catch (Exception e) {
e.printStackTrace();
}
}
} - OutputStreamWriter(转换流)
- OutputStreamWriter类表示可以把OutputStream转换为Writer,它是字符输出流。
- OutputStreamReader也是不能单独使用的,它内部需要封装一个OutputStream的子类对象,再指定一个编码表,如果不指定编码表,默认会按照UTF-8形式进行转换。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19public class OutputStreamWriterTest3 {
public static void main(String[] args) {
// 指定写出去的字符编码。
try (
// 1、创建一个文件字节输出流
OutputStream os = new FileOutputStream("io-app2/src/itheima07out.txt");
// 2、把原始的字节输出流,按照指定的字符集编码转换成字符输出转换流。
Writer osw = new OutputStreamWriter(os, "GBK");
// 3、把字符输出流包装成缓冲字符输出流
BufferedWriter bw = new BufferedWriter(osw);
){
bw.write("我是中国人abc");
bw.write("我爱你中国123");
} catch (Exception e) {
e.printStackTrace();
}
}
}
- FileWriter(子类)
87.4 打印流
- PrintStream:字节打印流
- System.out 实际是用于获取一个 PrintStream 对象,print方法实际调用的是 PrintStream 对象的 write 方法。
- PrintStream 是 OutputStream 的子类
- PrintWriter:字符打印流
- PrintWriter 是 Writer 的子类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21public class PrintTest1 {
public static void main(String[] args) {
try (
// 1、创建一个打印流管道
// PrintStream ps = new PrintStream("io-app2/src/itheima08.txt", Charset.forName("GBK"));
// PrintStream ps = new PrintStream("io-app2/src/itheima08.txt");
PrintWriter ps = new PrintWriter(new FileOutputStream("io-app2/src/itheima08.txt", true));
){
ps.print(97); //文件中显示的就是:97
ps.print('a'); //文件中显示的就是:a
ps.println("我爱你中国abc"); //文件中显示的就是:我爱你中国abc
ps.println(true);//文件中显示的就是:true
ps.println(99.5);//文件中显示的就是99.5
ps.write(97); //文件中显示a,发现和前面println方法的区别了吗?
} catch (Exception e) {
e.printStackTrace();
}
}
}
- PrintWriter 是 Writer 的子类
87.5 序列化流
ObjectInputStream
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28// 注意:对象如果需要序列化,必须实现序列化接口。
public class User implements Serializable {
private String loginName;
private String userName;
private int age;
// transient 这个成员变量将不参与序列化。
private transient String passWord;
public User() {
}
public User(String loginName, String userName, int age, String passWord) {
this.loginName = loginName;
this.userName = userName;
this.age = age;
this.passWord = passWord;
}
@Override
public String toString() {
return "User{" +
"loginName='" + loginName + '\'' +
", userName='" + userName + '\'' +
", age=" + age +
", passWord='" + passWord + '\'' +
'}';
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14public class Test2ObjectInputStream {
public static void main(String[] args) {
try (
// 1、创建一个对象字节输入流管道,包装 低级的字节输入流与源文件接通
ObjectInputStream input = new ObjectInputStream(new FileInputStream("io-app2/src/itheima11out.txt"));
){
// 2. 读取object对象
User u = (User) input.readObject();
System.out.println(u);
} catch (Exception e) {
e.printStackTrace();
}
}
}ObjectOutputStream
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18public class Test1ObjectOutputStream {
public static void main(String[] args) {
try (
// 1、创建一个对象字节输出流包装原始的字节 输出流。
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("io-app2/src/itheima11out.txt"));
){
// 2、创建一个Java对象。
User u = new User("admin", "张三", 32, "666888xyz");
// 3、序列化对象到文件中去
oos.writeObject(u);
System.out.println("序列化对象成功!!");
} catch (Exception e) {
e.printStackTrace();
}
}
}
87.6 IO 设计模式
装饰器模式:
- 概念:Decorator Pattern,可以在不改变原有对象的情况下拓展其功能。装饰器模式通过组合,替代继承来扩展原始类的功能,在一些继承关系比较复杂的场景(IO 这一场景各种类的继承关系就比较复杂)更加实用。
1
2
3
4
5
6
7
8
9
10// 例子:
try (BufferedInputStream input = new BufferedInputStream(new FileInputStream("input.txt"))) {
int content;
long skip = input.skip(2);
while ((content = input.read()) != -1) {
System.out.print((char) content);
}
} catch (IOException e) {
e.printStackTrace();
} - 思考:为什么不直接弄一个
BufferedFileInputStream
(字符缓冲文件输入流)呢?1
BufferedFileInputStream bfis = new BufferedFileInputStream("input.txt");
- InputStream的子类实在太多,继承关系也非常复杂。如果为每一个子类都定制一个对应的缓冲输入流,非常麻烦。
- 通过装饰器模式代替继承可以扩展原始类的功能。
- 概念:Decorator Pattern,可以在不改变原有对象的情况下拓展其功能。装饰器模式通过组合,替代继承来扩展原始类的功能,在一些继承关系比较复杂的场景(IO 这一场景各种类的继承关系就比较复杂)更加实用。
适配器模式:
- 概念:Adapter Pattern,主要用于接口互不兼容的类的协调工作。被适配的对象或者类称为适配者(Adaptee),作用于适配者的对象或者类称为适配器(Adapter) 。
- 适配器:
InputStreamReader
和OutputStreamWriter
就是两个适配器(Adapter),它们两个也是字节流和字符流之间的桥梁。InputStreamReader
使用StreamDecoder
(流解码器)对字节进行解码,实现字节流到字符流的转换,OutputStreamWriter
使用StreamEncoder
(流编码器)对字符进行编码,实现字符流到字节流的转换。 - 适配者:InputStream 和 OutputStream 的子类是适配者。
1
2
3
4// InputStreamReader 是适配器,FileInputStream 是被适配的类
InputStreamReader input = new InputStreamReader(new FileInputStream(fileName), "UTF-8");
// BufferedReader 增强 InputStreamReader 的功能(装饰器模式)
BufferedReader bufferedReader = new BufferedReader(input);
88. IO模型
88.1 BIO (Blocking I/O)
- 定义:
- BIO 属于同步阻塞 IO 模型 。同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。
- 缺点:
- 在客户端连接数量不高的情况下,是没问题的。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。
88.2 NIO (Non-blocking/New I/O)
- 定义:
- NIO 属于同步非阻塞 IO 模型。同步非阻塞 IO 模型中,应用程序会一直发起 read 调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间。
88.3 AIO (Asynchronous I/O)
- 定义:
- AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2,它是异步 IO 模型。
89. JVM 内存区域
89.1 概述
- Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。
- JDK 1.8 和之前的版本略有不同,我们这里以 JDK 1.7 和 JDK 1.8 这两个版本为例介绍。
线程私有的:
- 程序计数器
- 虚拟机栈
- 本地方法栈
线程共享的:
- 堆
- 方法区
- 直接内存 (非运行时数据区的一部分)
89.2 线程私有:程序计数器(Program Counter Register)
- 概念:
- 程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令。
- 特点:
- 线程私有
- 生命周期:与线程共存亡
- 一块较小的内存空间,存储字节码行号;
- 是唯一一块不会出现OutOfMemoryError的内存区域;
- 作用:
- 字节码解释器通过改变程序计数器的值来选取下一条需要执行的字节码指令,从而实现代码的流程控制;
- 多线程情况下,用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪了。
89.3 线程私有:Java 虚拟机栈
- 概念:
- 描述的是Java方法执行的线程内存模型:每个方法被执行时,Java虚拟机都会同步创建一个栈帧(用于存储局部变量表、操作数栈、动态链接、方法返回地址),每个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
- 栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的数据结构,只支持出栈和入栈两种操作。
- 特点:
- 线程私有
- 生命周期:与线程共存亡
- 栈帧中存储局部变量表、操作数栈、动态链接、方法返回地址
- 会出现两种错误:
- StackOverFlowError:(stack内存不允许动态扩展时)当线程请求的栈的深度超过当前Java虚拟机栈的最大深度时报错;
- OutOfMemoryError:(stack内存允许动态扩展时)如果虚拟机的动态扩展栈时无法申请到足够的空间,则报异常。(HotSpot虚拟机是不支持动态扩展的,但如果是手动申请栈空间失败了也会报OOM异常)
89.4 线程私有:本地方法栈
- 概念:
- 基本功能和Java虚拟机栈基本一样。
- 和Java虚拟机栈的区别是:
- Java虚拟机栈描述Java方法的执行;
- 本地方法栈描述Native方法的执行。
89.5 线程共享:堆
特点:
- 唯一目的:存放实例对象(几乎所有的实例对象和数组都在这里分配内存)
- 线程共享
- 生命周期:与虚拟机共存亡
- Java虚拟机所管制内存中最大的一块
- GC的主要区域
- 最容易出现OutOfMemoryError错误
堆内存划分:
- 更好地回收内存,更快的分配内存。
- 不同版本堆内存划分:
- 在 JDK 7 版本及之前版本,堆内存被通常分为下面三部分:
- 新生代内存(Young Generation)(Eden 区、两个 Survivor 区 S0 和 S1 )
- 老生代(Old Generation)
- 永久代(Permanent Generation)
- JDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空间) 取代,元空间使用的是本地内存。
- 在 JDK 7 版本及之前版本,堆内存被通常分为下面三部分:
年轻代(Eden、S0、S1)、老年代
- 默认情况下,年轻代与老年代比例为1:2,可以通过参数 -xx:NewRatio 修改,NewRatio默认值是2
- 默认情况下Eden、S0、S1的比例是8:1:1,可以通过参数 -xx:SurvivorRatio 修改,SurvivorRatio默认值是8
89.6 线程共享:方法区
概念:
- 当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
特点:
- 线程共享
- 存储:已被虚拟机加载的类信息、静态变量、常量、即时编译器编译后的代码等数据
- GC较少出现,但并非不出现
方法区和永久代的关系
- 方法区:是一个概念,并没有具体的实现(类似于接口)
- 永久代:是HotSpot虚拟机中对方法区的一种实现方式 (类似于接口的实现类)
为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
- 永久代有JVM本身设置的固定内存大小上限,而元空间使用直接内存,受本机可用内存的限制,使得溢出的几率减小。
- Java虚拟机能够加载多少类可直接由系统的实际可用空间来控制,使得能够加载更多的类。
89.7 线程共享:运行时常量池
特点:
- 方法区的一部分;
- 常量池将在类加载后存放到方法区的运行时常量池中;
- 当常量池无法再申请到内存时会抛出OutOfMemoryError错误
方法区的Class文件信息,Class常量池和运行时常量池的三者关系
89.8 线程共享:字符串常量池
概念:
- 字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
1
2
3
4
5
6// 在堆中创建字符串对象”ab“
// 将字符串对象”ab“的引用保存在字符串常量池中
String aa = "ab";
// 直接返回字符串常量池中字符串对象”ab“的引用
String bb = "ab";
System.out.println(aa==bb); // true
- 字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
JDK 1.7 为什么要将字符串常量池移动到堆中?
- 主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。
89.9 线程共享:直接内存
- 不是虚拟机运行时数据区的一部分
- 会导致OutOfMemoryError错误出现
- 本机直接内存的分配不会受到Java堆的限制,但是既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。
90. HotSpot虚拟机对象
90.1 对象的创建
Step1:类加载检查
- 虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
Step2:分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
内存分配的两种方式 (补充内容,需要掌握):
- 指针碰撞:
- 适用场合:堆内存规整(即没有内存碎片)的情况下。
- 原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
- 使用该分配方式的 GC 收集器:Serial, ParNew
- 空闲列表:
- 适用场合:堆内存不规整的情况下。
- 原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。
- 使用该分配方式的 GC 收集器:CMS
选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是”标记-清除”,还是”标记-整理”(也称作”标记-压缩”),值得注意的是,复制算法内存也是规整的。
内存分配并发问题(补充内容,需要掌握)
- 在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:
- CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
- TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
Step3:初始化零值
- 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
Step4:设置对象头
- 初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
Step5:执行 init 方法
- 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,
<init>
方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行<init>
方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
- 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,
90.2 对象的访问定位
- 概念:
- 建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄、直接指针。
- 分类:
- 句柄:
- 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。
- 直接指针
- 如果使用直接指针访问,reference 中存储的直接就是对象的地址。
- 句柄:
- 两种分类的优缺点:
- 这两种对象访问方式各有优势。
- 使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。
- 使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
90. JVM垃圾回收
90.1 垃圾回收机制
- 所有的对象首先会在Eden区进行分配,当Eden区满了之后会进行第1次Minor GC;
- 第1次GC之后仍然存活的对象,会复制到Survivor S0,同时对象年龄+1(此时年龄=1),然后清理其之前占用的内存;
- 第2次会对Eden+S0同时进行GC,仍然存活的对象会复制到Survivor S1,年龄+1,同时清理之前占用的内存(此时S0区会变成空);
- 以此类推,每次都有一个Survivor区是空的;
- 当Survivor区域对象的年龄达到 -xx:MaxTenuringThreshold 设定的值(默认15),会将此对象移到老年代,同时清空他们在年轻代占用的内存空间;
- 当老年代空间不够用了,会发生Full GC (回收整个堆内存);
- 当某些大对象需要分配一块较大的连续空间时会直接进入老年代,而不会经过以上步骤。
90.2 死亡对象判断方法
引用计数法:
- 概念:给对象中添加一个引用计数器:每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。
- 缺点:这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间循环引用的问题。
可达性分析算法:
- 概念:目前的主流算法。这个算法的基本思想就是通过一系列被称为“GC Roots”的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。下图中的 Object 6 ~ Object 10 之间虽有引用关系,但它们到 GC Roots 不可达,因此为需要被回收的对象。
- 对象可以被回收,就代表一定会被回收吗?
- 被判定为需要回收的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。
- 概念:目前的主流算法。这个算法的基本思想就是通过一系列被称为“GC Roots”的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。下图中的 Object 6 ~ Object 10 之间虽有引用关系,但它们到 GC Roots 不可达,因此为需要被回收的对象。
90.3 垃圾回收算法
- 标记-清除算法
- 它的思想就是先标记(两次标记),再清除。
- 缺点:
- 效率不高(标记和清除两个过程效率都不高。)
- 会产生大量内存碎片(内存碎片是指内存的空间比较零碎,缺少大段的连续空间。这样假如突然来了一个大对象,会找不到足够大的连续空间来存放,于是不得不再触发一次gc。)
- 复制算法
- 概念:为了解决标记-清除算法的效率和内存碎片问题,复制算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。
- 缺点:
- 可用内存变小:可用内存缩小为原来的一半。
- 不适合老年代:如果存活对象数量比较大,复制性能会变得很差。
- 标记-整理算法
- 概念:标记-整理(Mark-and-Compact)算法是根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
- 缺点:
- 由于多了整理这一步,因此效率也不高,适合老年代这种垃圾回收频率不是很高的场景。
- 分代回收算法
- 概念:当前虚拟机的垃圾收集都采用分代收集算法,根据对象存活周期的不同将内存分为几块。一般将 Java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。比如在新生代中,每次收集都会有大量对象死去,所以可以选择”标记-复制“算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
91. 类加载器
91.1 类加载过程
类的生命周期:
- 类从被加载到虚拟机内存中开始到卸载出内存为止,它的整个生命周期可以简单概括为 7 个阶段:
- 加载(Loading)
- 验证(Verification)
- 准备(Preparation)
- 解析(Resolution)(验证、准备和解析这三个阶段可以统称为连接(Linking))
- 初始化(Initialization)
- 使用(Using)
- 卸载(Unloading)
- 类从被加载到虚拟机内存中开始到卸载出内存为止,它的整个生命周期可以简单概括为 7 个阶段:
类加载过程:
- 系统加载 Class 类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析。
- 系统加载 Class 类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析。
91.1 类加载器
作用:
- 类加载器的主要作用就是加载 Java 类的字节码(
.class
文件)到 JVM 中(在内存中生成一个代表该类的Class
对象)。 字节码可以是 Java 源程序(.java
文件)经过javac
编译得来,也可以是通过工具动态生成或者通过网络下载得来。 - 其实除了加载类之外,类加载器还可以加载 Java 应用所需的资源如文本、图像、配置文件、视频等等文件资源。
- 类加载器的主要作用就是加载 Java 类的字节码(
类加载器加载规则
- JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好。
- 对于已经加载的类会被放在
ClassLoader
中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次。
JVM 中内置了三个重要的
ClassLoader
:- **
Bootstrap ClassLoader
(启动类加载器)**:最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库(%JAVA_HOME%/lib
目录下的jar 包和类)以及被-Xbootclasspath
参数指定的路径下的所有类。 - **
Extension ClassLoader
(扩展类加载器)**:主要负责加载%JRE_HOME%/lib/ext
目录下的 jar 包和类以及被java.ext.dirs
系统变量所指定的路径下的所有类。 - **
Application Classloader
(应用程序类加载器)**:面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。
- **
91.2 双亲委派模型
- 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)。
- 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器
loadClass()
方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器BootstrapClassLoader
中。 - 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的
findClass()
方法来加载类)。
91.3 双亲委派模型的好处
- 双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。
- 如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为
java.lang.Object
类的话,那么程序运行的时候,系统就会出现两个不同的Object
类。双亲委派模型可以保证加载的是 JRE 里的那个Object
类,而不是你写的Object
类。这是因为AppClassLoader
在加载你的Object
类时,会委托给ExtClassLoader
去加载,而ExtClassLoader
又会委托给BootstrapClassLoader
,BootstrapClassLoader
发现自己已经加载过了Object
类,会直接返回,不会去加载你写的Object
类。
92. 排查 OOM
92.1 常见的 OOM 异常情况有两种
- 堆内存溢出
- 方法区溢出
92.2 堆内存溢出
- java.lang.OutOfMemoryError: Java heap space ——>java 堆内存溢出,
- 此种情况最常见,一般由于内存泄露或者堆的大小设置不当引起。
- 对于内存泄露,需要通过内存监控软件查找程序中的泄露代码,而堆大小可以通过虚拟机参数-Xms,-Xmx 来修改。
92.3 方法区溢出
- java.lang.OutOfMemoryError: PermGen space 或 java.lang.OutOfMemoryError:MetaSpace ——>java 方法区溢出
- 一般出现在大量 Class、或者采用 cglib 等反射机制的情况,因为这些情况会产生大量的 Class 信息存储于方法区。过多的常量尤其是字符串也会导致方法区溢出。
- 这种情况可以通过更改方法区的大小来解决,使用类似-XX:PermSize=64m -XX:MaxPermSize=256m 的形式修改。
92.4 排查方式:
- 先获取内存的 Dump 文件,Dump 文件有两种方式来生成:
- 第一种是配置 JVM 启动参数,当触发了 OOM 异常的时候自动生成,
- 第二种是使用 jmap 工具来生成。
- 然后使用 MAT 工具来分析 Dump 文件。
- 如果是内存泄漏,可进一步通过工具查看泄漏对象到 GC Roots 的引用链。掌握了泄漏对象的类信息和 GC Roots 引用链的信息,就可以比较准确地定位泄漏代码的位置。
- 如果是普通的内存溢出,确实有很多占用内存的对象,那就只需要提升堆内存空间即可。