Java 面试语法基础篇

229 阅读28分钟

第 1 章 Java 程序设计概述

Java 的完整定义?

Java 并不只是一门语言,而是一个完整的平台,有一个庞大的库,其中包含了很多可重用的代码,以及一个提供诸如安全性、跨操作系统的可移植性以及自动垃圾收集等服务的执行环境

Java 有哪些典型特征?

  • 面向对象:Java 与 C++ 主要不同点在于 多重继承 ,在 Java 中,取而代之的是更简单的 接口 概念。
  • 健壮性:Java 非常强调进行早期的问题检测后期动态的(运行时)检测,以及消除容易出错的情况。 Java 与 C/C++ 最大的不同在于 Java 采用的 指针模型 可以消除重写内存和损坏数据的可能性。
  • 可移植性在 Java 中,数值类型有固定的字节数,这消除了代码移植时一个令人头疼的主要问题。二进制数据以固定的格式进行存储和传输,消除了字节顺序的困扰。字符串采用标准的 Unicode 格式存储。
  • 多线程:Java 是第一个支持并发程序设计的主流语言。

Java8 主要功能?

函数式编程 可以很容易地表达并发执行的计算。

对 Java 的误解

  1. Java 适用所有平台吗? 理论上可能,实际某些领域其他语言更出色,例如浏览器开发使用 JavaScript,iOS 开发使用 Objective 和 Swift,Windows 开发使用 C++ 和 C#,Java 在服务端编程和跨平台客户端应用领域更有优势
  2. Java 开源吗? Java 开源,但是专利曾在 2017 年前收费了 10 年,现在也免费了。
  3. Java 是解释型语言,因此对于关键的应用程序速度太慢了? 早期的 Java 是解释型的,现在 Java 虚拟机使用了 即时编译,采用了 Java 编写的“热点”代码运行速度与 C++ 相差无几,有些情况甚至更快。
  4. JavaScript 是 Java 的简易版吗? JavaScript 是一种可以在网页中使用的脚本语言,由 Netscape 发明,最初名字是 LiveScript,与 Java 没有任何关系。尤其是,Java 是 强类型 的,编译器能捕获类型滥用导致的很多错误,而 JavaScript 只有程序运行时才能发现这些错误,所以消除错误会很费劲。

第 2 章 Java 程序设计环境

JDK 和 JRE 有什么区别?

  • JDK:我们把 Java 程序设计语言、Java 虚拟机、Java 类库这三部分统称为 JDK(Java Development Kit),JDK 是用于支持 Java 程序开发的最小环境。
  • JRE:把 Java 类库 API 中的 Java SE API 子集和 Java 虚拟机这两部分统称为 JRE(Java Runtime Environment),JRE 是支持Java 程序运行的标准环境。

jdk-jre.png

如果你只需要运行 Java 程序,只需安装 JRE 就可以了。如果你需要编写 Java 程序,需要安装 JDK。一般我们开发过程中都直接安装 JDK 。

Java ME、SE 和 EE 有什么区别?

  • ME(Micro Edition):支持 Java 程序运行在移动终端(手机、PDA)上的平台,对Java API 有所精简,并加入了移动终端的针对性支持;
  • SE(Standard Edition):支持面向桌面级应用(如 Windows 下的应用程序)的 Java 平台,提供了完整的 Java 核心 API,我们最常用的 Java 8、Java 21 其实就是 Java SE 8、Java SE 21
  • EE(Enterprise Edition):支持使用多层架构的企业应用(如 ERP、MIS、CRM 应用)的 Java 平台,除了提供 Java SE API 外,还对其做了大量有针对性的扩充,并提供了相关的部署支持。在 JDK 10 以后被 Oracle 放弃,捐献给 Eclipse 基金会管理,此后被称为 Jakarta EE。

如何在命令行运行 Java 程序?

加入我写了一个 Java 程序 HelloWorld.java ,需要首先使用 JDK 自带的 Java 编译器 ==javac== 将程序编译成 Java 虚拟机可识别的字节码:

javac HelloWorld.java

此时会生成 HelloWorld.class 字节码文件。然后执行 ==java== 命令启动 JVM 虚拟机,使其运行字节码文件即可:

java HelloWorld

注意,此处不需要写 .class 后缀。

第 3 章 Java 的基本程序设计结构

main 函数

  1. 每个 Java 应用程序都必须有一个 main 方法;
  2. public static void main(String[] args)args 指 Java 启动时的输入参数,例如:
java HelloWorld apple orange

其中,args 获取到的内容为:

args = [apple, orange]

java 后面的类名不算在内。

String 是基本数据类型吗?

Java 只有 8 种基本数据类型:

  • 4 种整型(整型的范围与运行 Java 代码的机器无关
    • byte - 1 字节
    • short - 2 字节
    • int - 4 字节
    • long - 8 字节
  • 2 种浮点型
    • float - 4 字节
    • double - 8 字节
  • 1 种字符型
    • char - 1 字节
  • 1 种布尔型
    • boolean - 1 字节

所以,String 不是基本数据类型,而是 Java 类库中提供的一个预定义类。

StringBuilder 和 StringBuffer 的作用?

Java 的 String 类没有提供修改某个字符的方法,是不可变的。这样做有个优点:编译器将字符串放到一个公共的存储池里,所有字符串变量可以指向这些字符串共享。但是如要修改,只能使用子串substring() 方法,再配合上拼接。而且,字符串拼接 str = str1 + str2 其实是重新构建一个新的字符串,既耗时又浪费空间。

StringBuilder 可以避免这一问题:

StringBuilder builder = new StringBuilder();
builder.append("abc");
builder.append("123");
String result = builder.toString();

它的前身是 StringBufferStringBuffer 效率偏低,但是允许 多线程 方式添加或删除字符,不过一般情况用不到。

关于 char 类型

建议尽量不要使用 char 类型,太底层了。

char 数据类型是一个采用 UTF-16 编码表示 Unicode 码点的 代码单元 。最常用的 Unicode 字符使用一个代码单元就可以表示,而辅助字符需要一对代码单元表示。

例如下面这个字符串:

sentence = "𝕆 is the set of octonions.";

如果使用 char 类型读取字符:

char ch = sentence.charAt(1);

读取到的是 𝕆 的第二个代码单元,而不是空格!

switch 语句易错点

switch 语句的语法如下:

switch(choice) {
	case 1:
		...
		break;
	case 2:
		...
		break;
	default:
		...
		break;
}

实际开发中容易忘记break!

switch 语句将从与选项相匹配的 case 标签开始执行,直到遇到 break 语句,或者执行到 switch 语句的结束处为止。如果没有相匹配的 case 标签,而有 default 子句,就执行这个子句。

Java 支持几种大数?

如果基本的整数和浮点数精度不能满足需求,可以使用 java.math 包中的两个大数类:BigIntegerBigDecimal 。 普通数转换为大数使用静态的 valueOf 方法:

BigInteger a = BigInteger.valueOf(100);

大数的算术运算使用大数类提供的方法,例如 + 和 * :

BigInteger c = a.add(b);
BigInteger d = c.multiply(b.add(BigInteger.valueOf(2)));

Java 运算符重载

与 C++ 不同,Java 没有提供运算符重载的功能,Java 设计者只为字符串的连接重载了 + 运算符,而没有给 Java 程序员在自己的类中重载运算符的机会。

数组的深拷贝与浅拷贝

数组浅拷贝只两个变量引用同一个数组:

smallPrimes = new int[]{101, 231, 5688};
int[] luckyNumbers = smallPrimes;
luckyNumbers[2] = 10000; // 两个变量的内容都会被修改

数组深拷贝是使用 Arrays 类的 copyOf 方法,将一个数组的所有值复制到一个新数组中,通常用来增加数组的大小:

int[] copiedLuckyNumbers = Arrays.copyOf(luckyNumbers, 2 * luckyNumbers.length);

数组如何排序?

可以使用 Arrays 类中的 sort 方法:

int[] a = new int[1000];
...
Arrays.sort(a);

sort 方法使用 快速排序(QuickSort) 算法。

第 4 章 对象与类

用过 var 关键字吗?

从 Java 10 开始支持 var 关键字声明局部变量。例如以前声明一个 Employee 变量:

Employee harry = new Employee("Harry Hacker", 50000, 1996, 10, 1);

在 Java 10 可以声明如下:

var harry = new Employee("Harry Hacker", 50000, 1996, 10, 1);

这样做的好处是可以避免重复写类型名 Employee 。

如何理解 final 和 static 字段?

final 常量字段 必须要确保在构造函数执行以后,该字段的值已经被设置,且以后不能再修改,对于类型为基本类型或者不可变类型的字段尤其有用。 static 静态字段 属于类,而不属于任何单个对象。每个类只有一个这样的字段,而对于非静态字段,每个对象都有一个自己的副本。 我们最常用的是 static final 定义 静态常量

public class Math {
	public static final double PI = 3.14159265358979323846;
	...
}

Java 方法参数是值传递还是引用传递?

Java 程序设计语言总是采用按值调用,即方法得到的是所有参数值的一个副本。

对象引用也是按值传递 方法内部得到的是对象变量的一个副本,只不过这个副本和原对象变量指向同一个对象,如果通过对象变量副本修改对象内容,原对象变量所引用内容自然也会改变。

Java按值传递.jpg

默认无参构造函数

仅当没有任何其他构造函数时,Java 会提供一个默认无参构造函数,将所有的实例字段设置为默认值。

Java 支持析构器吗?

Java 会完成自动的垃圾回收,不需要人工回收内存,所以 Java 不支持析构器。

this 关键字有什么作用?

  1. 指示隐式参数的引用;
  2. 调用该类的其他构造器。

第 5 章 继承

super 关键字有什么作用?

  1. 调用超类的方法;
  2. 调用超类的构造器。

final 关键字修饰类或方法有什么作用?

final 修饰类:阻止该类被继承; final 修饰方法:阻止子类覆盖该方法。

final 类的方法,自动成为 final 方法。

@Override 注解的作用?

@Override 注解修饰的方法,表示要覆盖超类中的方法,如果超类中没有该方法,则会在编译期报错,该注解可以有效检验超出预期的错误。

如何比较两个对象是否相等?

public class Manager extends Employee {
	public boolean equals(Object otherObject) {
		// 引用变量相等,必然相等
		if (this == otherObject) return true;
		// this 必然不为 null, otherObject 为 null 必然不相等
		if (otherObject == null) return false;
		// 校验方式一:子类和父类、或者不同子类相比较不相等
		if (getClass() != otherObject.getClass()) return false;
		// 子类和父类、或者不同子类相比较都相等
		// if (!(otherObject instanceof ClassName)) return false;
		ClassName other = (ClassName) otherObject;
		return field1 == other.field1
			&& Objects.equals(field2, other.field2)
			&& ...
	}
}

对象的 toString() 方法在实际开发中如何使用?

所有类都继承自 Object 类,Object 类实现了 toString() 方法,所有的子类都可以实现并覆盖该类。在进行以下几种字符串操作时,都会自动调用对象的 toString() 方法:

  1. 对象参与字符串拼接
var p = new Point(10, 20);
// 字符串拼接会调用 p.toString()
String message = "The current position is " + p;
  1. 打印输出任意对象调用
// x 为任意对象,println 会自动调用 x.toString()
System.out.println(x);
  1. 打印日志
// 自动调用 position.toString()
Logger.global.info("Current position = " + position);

强烈建议为自定义的每个类都添加 toString() 方法,这样在日志记录时会受益匪浅。

对象包装器和自动装箱?

所有基本类型都有一个与之对应的类,例如 int 对应 Integer ,long 对应 Long ,boolean 对应 Boolean 等,通常这些类称为 ==包装器(wrapper)== 。 包装器类是不可变的,一旦构造类包装器,就不允许变更包装在其中的值;包装器是 final 类,所以不能派生它们的子类。

由于每个值分别包装在对象中,所以 ArrayList<Integer> 的效率要远低于 int[] 数组,只有当程序员的操作方便性比执行效率更重要时,才会考虑对较小集合使用这种构造。

有一个很有用的特性可以很容易地向 ArrayList<Integer> 添加 int 类型的元素:

list.add(3);    // -- 相当于:list.add(Integer.valueof(3));

这种变换称为 自动装箱。相反,当一个 Integer 对象赋值给一个 int 值时,将会 自动拆箱

int n = list.get(i);    // -- 相当于:int n = list.get(i).intValue();

自动装箱和自动拆箱是编译器要做的工作,而不是虚拟机。

反射必须知道的用法?

Class 类:Java 运行时系统为所有对象维护的运行时类型标识。对象的 getClass() 方法会返回一个 Class 类型的实例。

  • static Class forName(String className) 根据完整类名,获取 Class 对象。
  • Constructor getConstructor(Class... parameterTypes) 按照指定参数生成一个构造器。
  • Field[] getFields() 返回类或其超类的公共字段。
  • Method[] getMethods() 返回类或其超类的公共方法。
  • Constructor[] getConstructors() 返回类或其超类的公共构造器。
  • ==Field[] getDeclaredFields()== 返回类或其超类的所有字段。
  • ==Method[] getDeclaredMethods()== 返回类或其超类的所有方法。
  • ==Constructor[] getDeclaredConstructors()== 返回类或其超类的所有构造器。
  • getPackageName() 获取类的包名。

虽然 getDeclaredFields 方法可以获取到私有字段,但如果对私有字段执行 get 和 set 方法,会抛出 IllegalAccessException 异常,这是因为反射机制的默认行为受限于 Java 的访问控制,所以需要首先调用 setAccessible(true) 方法,覆盖 Java 的访问控制。

如何利用反射复制一个数组?

public static Object goodCopyOf(Object a, int newLength) {
	// 使用返回的 getClass 方法,获取对象类型
	Class clazz = a.getClass();
	// 使用 Class 类的 isArray 方法,判断对象是否是数组
	if (!clazz.isArray()) return null;
	// 使用 Class 类的 getComponentType 方法,获取元素类型
	Class componentType = clazz.getComponentType();
	// 使用 Array 类的 getLength 方法,获取数组的长度
	int length = Array.getLength(a);
	// 使用 Array 类的 newInstance 方法,构造指定长度的新数组
	Object newArray = Array.newInstance(componentType, newLength);
	// 使用 System 类的 arraycopy 方法,复制数组元素
	System.arraycopy(a, 0, newArray, Math.min(length, newLength));
	// 返回新数组
	return newArray;
}

第 6 章 接口、lambda 表达式与内部类

接口的特性?

  1. 一个类只能继承一个超类,但是可以实现多个接口;
  2. 接口中的方法自动是 public 方法,建议无需多写 public 关键字;
  3. 接口中不能包含实例字段,但可以包含常量,接口中的字段总是 public static final 类型;
  4. Java 8 开始,允许接口增加静态方法;
  5. 可以为接口提供默认方法,用 default 修饰。

接口的作用:

实际上,接口可以提供多重继承的大多数好处,同时还能避免多重继承的复杂性和低效性。

如何实现对象数组排序?

方法一:实现 Comparable 接口

public Employee implements Comparable<Employee> {
	// 实现 compareTo 方法
	public int compareTo(Employee other) {
		return Double.compare(salary, other.salary);
	}
}

方法二:实现 Comparator 比较器接口

Java 提供比较器接口的原因:如果我们想对 String 对象数组排序,例如不是按照字典排序,而是按照字符串长短排序,但是我们无法修改 String 类实现排序的方法。此时实现 Comparator 比较器即可。

class LengthComparator implements Comparator<String> {
	public int compare(String first, String second) {
		return first.length() - second.length();
	}
}

排序时对 Arrays.sort 方法传入 LengthComparator 比较器即可:

String[] friends = { "Peter", "Mary", "Paul" };
Arrays.sort(friends, new LengthComparator())

方法三:lambda 表达式

下面会提到。

[!tip] 不同子类之间、子类和父类之间如何比较? 这取决于业务场景,假如我们判断两个对象是否相等的条件是:父类的 ID 字段,那比较方法应该由父类实现,且定义为 final 方法,即不允许子类修改比较逻辑。 假如我们判断两个对象是否相等的条件和子类有关系,那父类的比较方法不能定义为 final ,子类要重写比较方法,实现自己的逻辑,覆盖父类比较方法。

如何克隆对象?

克隆分深拷贝和浅拷贝 2 种类型,Object 类提供的 clone 方法是浅拷贝,因为它无法判断非基本类型字段的类型,所以只能浅拷贝;深拷贝需要自己实现 Cloneable 接口。

深拷贝:

public class Employee implements Cloneable {
	private String name;      // 基本类型
	private double salary;    // 基本类型
	private Date hireDay;     // 对象类型

	public Employee clone() throws CloneNotSupportedException {
		// 基本类型:调用 Object 的 clone 方法
		Employee cloned = (Employee) super.clone();
		// 对象类型:调用对象自己的 clone 方法
		cloned.hireDay = (Date) hireDay.clone();
	}
}

lambda 表达式的作用?

转换为函数式接口,延迟执行可传递代码块。 希望延迟执行的原因有很多,例如:

  • 在一个单独的线程中运行代码;
  • 多次运行代码;
  • 在算法适当位置运行代码;
  • 发生某种情况时运行代码;
  • 只在必要时才运行代码。

函数式接口

对于只有一个抽象方法的接口,需要这种接口的对象时,就可以提供一个 lambda 表达式。这种接口称为函数式接口,一般使用 @FunctionalInterface 注解标识。

lambda 表达式如何使用?

基本语法格式:

(String first, String second) -> {
	if (first.length() < second.length()) return -1;
	else if (first.length() > second.length()) return 1;
	else return 0;
}

如果可以推断出参数类型,可以忽略其类型:

Comparator<String> comp = (first, second) -> 
	first.length() > second.length()

在这里 first 和 second 类型必然是 String。

即使 lambda 表达式没有参数,仍然要提供空括号:

() -> { for (int i = 100; i >= 0; i--) System.out.println(i); }

如果只有一个参数,且参数类型可以推断出,可以省略小括号:

ActionListener listener = event -> System.out.println("The time is " + Instant.ofEpochMilli(event.getWhen()));

什么是方法引用?

当 lambda 表达式的代码块只调用一个方法而不做其他操作时,可以把 lambda 表达式重写:

var timer = new Timer(1000, event -> System.out.println(event));

可以重写为:

var timer = new Timer(1000, System.out::println);

System.out::println 就是一个方法引用,它指示编译器生成一个函数式接口的实例,覆盖这个接口的抽象方法来调用给定的方法。

比较器 Comparator 的实现可以运用方法引用实现。例如我们想对 Person 对象数组进行排序,首先按照 name 字段进行字典排序,如果同名,按照 id 排序:

Arrays.sort(people, Comparator.comparing(Person::getName).thenComparing(Person::getId);

还可以按照姓名长度排序:

Arrays.sort(people, Comparator,comparing(Person::getName, (f, s) -> Integer.compare(f.length(), s.length())));

第 7 章 异常、断言和日志

异常如何分类?

异常分为 检查型异常非检查型异常 :派生于 Error 类或 RuntimeException 类的所有异常称为非检查型异常,其他异常都是检查型异常。 一个方法必须声明所有可能抛出的检查型异常。 如果超类方法没有抛出如何异常,子类也不能抛出任何异常。

异常分类.jpg

捕获异常后再次抛出异常如何做?

首先在封装时,可以把原始异常设置为新异常的“原因”:

try {
	// 数据库操作
} catch (SQLException original) {
	var e = new ServletException("database error");
	e.initCause(original);
	throw e;
}

在上层捕获到新异常,还可以获取原始异常:

Throwable original = caughtException.getCause();

try-with-resources 语法怎么用?

Java 8 开始支持 try-with-resources 语法,一般用法:

try (var in = new Scanner(new FileInputStream("/usr/share/dict/words"), StandardCharsets.UTF_8)) {
	while(in.hasNext()) {
		System.out.println(in.next());
	}
}

这个块正常退出时,或者存在异常退出时,都会调用 in.close() 方法,就像使用了 finally 块一样。

标准 Java 日志框架如何使用?

方式一:全局日志记录器

最简单的是直接使用全局日志记录器,调用 info 方法:

Logger.getGlobal().info("File->Open menu item selected");

也可以在合适的地方(如 main 的最前面)调用:

Logger.getGlobal().setLevel(Level.OFF);

取消所有日志。

方式二:自定义日志记录器

可以调用 getLogger 方法创建或获取日志记录器:

private static final Logger myLogger = Logger.getLogger("com.xiayu.app");

未被任何变量引用的日志记录器可能会被垃圾回收,未防止此类情况发生,需要用静态变量存储日志记录器的引用。

可以通过编辑配置文件修改日志的各个属性,默认情况下,配置文件位于: conf/logging-properties (Java9 之前位于 jre/lib/loggin.properties)。 若想使用其他位置的配置文件,需要在启动命令添加参数: -Djava.util.logging.config.file=xxx.properties。 若要修改默认的日志级别,需要编辑配置文件,修改一下命令行:

com.xiayu.app.level=ERROR

第 8 章 泛型程序设计

通配符的作用?

  • ==<? extends Employee>== 限定为任何继承了 Employee 的子类(extends 后面也可以使用接口,表示任何实现了该接口的类),只能作为方法的返回值,不能作为方法参数,因为方法内不知道 ? 具体指的是哪个子类;
  • ==<? super Manager>== 限定为 Manager 的任何超类,只能作为方法参数,不能作为返回值,因为无法保证返回值的类型。

带有超类型限定的通配符允许你写入一个范型对象,而带有子类型限定的通配符允许你读取一个泛型对象。

什么是类型擦除?

虚拟机没有泛型类型对象,所有对象都属于普通类。所以无论何时定义一个泛型类型,都会自动提供一个相应的原始类型,类型变量会被 擦除并替换为其限定类型,对于无限定类型的变量则替换为 Object。例如下面的泛型:

public class Pair<T> {
	private T first;
	private T second;
	public Pair(T first, T second) {
		this.first = first;
		this.second = second;
	}
}

因为没有限定类型,所以范型变量会被 Object 替换,会被擦除为:

public class Pair {
	private Object first;
	private Object second;
	public Pair(Object first, Object second) {
		this.first = first;
		this.second = second;
	}
}

再比如下面的泛型:

public class Interval<T extends Comparable & Serializable> implements Serializable {
	private T lower;
	private T upper;
	public Interval(T first, T second) {
		...
	}
}

限定类型替换 T ,被擦除为:

public class Interval implements implements {
	private Comparable lower;
	private Comparable upper;
	public Interval(Comparable first, Comparable second) {
		...
	}
}

如何获取泛型的真实类型?

Type parentType = getClass().getGenericSuperclass();
if (parentType instanceof ParameterizedType) {
	type = ((ParameterizedType) parentType).getActualTypeArguments()[0];
}

第 9 章 集合

集合都实现了哪些接口?

Java 集合库将接口与实现分离,Java 集合框架为不同类型的集合定义了大量接口,如下图所示:

集合接口.jpg

  • Iterator 是 Java 提供的迭代器,位于两个元素之间,当调用 next 时,迭代器就越过下一个元素,并返回刚刚越过的那个元素的引用;
  • Iterator 的 next 方法和 remove 方法调用之间存在依赖性,以下调用会报错:
it.remove();
it.remove();

必须先调用 next 越过要删除的元素:

it.remove();
it.next();
it.remove();
  • Collection 接口:扩展了 Iterable 接口,因此,对于标准库中的任何集合都可以使用 “for each” 循环。

具体有哪些集合?

集合框架.jpg

LinkedList 和 ArrayList 的区别?

使用链表的唯一理由是尽可能地减少在列表中间插入或删除元素的开销,其他大多数情况使用 ArrayList。

HashSet 和 TreeSet 的区别?

二者实现方式类似,HashSet 基于散列集实现,是无序集合;TreeSet 基于红黑树实现,是有序集合。 !

HashSet.jpg

HashSet 在插入数据时,利用 hashCode 方法计算对象的散列值,然后插入到对应的桶,位于同一个桶的对象会发生散列冲突,通过单向列表解决。 Java8 中,桶满的时候,会从单向链表变成平衡二叉树。 通常将桶的个数设置为预计元素个数的 ==75%~150%== (有些研究人员认为桶数设置为素数,可以防止键汇聚),当散列表快满时(默认 装填因子 达到 0.75,即 75%)需要进行 再散列 ,丢弃旧表创建新表,将旧表的元素插入新表,新表的桶数是原来的 2 倍。 TreeSet 比散列集有所改进,基于红黑树实现,是有序集合。遍历查找时要比 HashSet 更快,但是插入时更慢,而且有时候对某些数据的排序要远比给出一个散列函数更加困难。如果我们存放的是无序集合,有限考虑 HashSet,没必要选择 TreeSet 浪费额外的开销。

优先队列的应用?

任务调度,为任务设置优先级。

HashMap 和 LinkedHashMap 是如何实现的?

HashMap 的实现原理和 HashSet 一样,也是使用散列表实现。只是只对 HashMap 的键进行散列,对值不做处理。 LinkedHashMap 是有序映射,实现原理是在 HashMap 的基础上,对插入的元素按照先后顺序用双向链表连接起来。

LinkedHashMap.jpg

第 10 章 并发

进程和线程有什么区别?

本质区别在于每个进程拥有自己的一套变量,而线程则共享数据! 进程是操作系统进行资源分配的最小单元,线程是操作系统进行运算调度的最小单元。进程包含了线程,线程属于进程,一个进程可以包含多个线程,每个线程可以并行执行不同的任务。进程的创建、销毁和切换开销都远大于线程。(--这些核心内容能够理解并回答上来即可,二者区别的细节还有很多。)

线程有哪些状态?

线程状态.jpg

一共六种状态:

  • New(新建)
  • Runnable(可运行)
  • Blocked(阻塞)
  • Waiting(等待)
  • Timed waiting(计时等待)
  • Terminated(终止)

状态流转如右图所示。

什么是中断线程?

当线程的 run 方法执行方法体中最后一条语句后再执行 return 语句返回时,或者出现了方法中没有捕获的异常时,线程将终止。在早期的 Java 中还有一个 stop 方法可以终止其他线程,不过现在已经废弃了,现在可以调用 interrupt 方法来请求终止一个线程。 当对一个线程调用 interrupt 方法时,就会设置线程的 中断状态 ,这是每个线程都有的 boolean 标志,每个线程都应该不时地检查这个标志,以判断线程是否被中断。 但是,如果线程被阻塞,就无法检查中断状态。如果对一个阻塞的线程调用 interrupt 方法,阻塞线程将被一个 InterruptedException 异常中断。例如 sleep 函数:

Runner r = () {
	try {
		//...
		while (!Thread.currentThread().isInterrupted() && xxx) {
			// do something
			Thread.sleep(100)
		} catch (InterrupedException e) {
			// 线程在 sleep 期间被中断
		} finally {
			// 退出前的清理逻辑
		}
	}
}

没有任何语言要求被中断的线程应当立即终止,中断一个线程只是要引起它的注意,被中断的线程可以决定如何响应中断(是处理异常后继续执行,还是立即终止等)。

什么是守护线程?

守护线程唯一的用途是为其他线程服务,通过调用 t.setDaemon(true) 就可以将一个线程转换为守护线程。

有几种并发访问代码块的方式?

两种:synchronized 关键字 和可重入锁 ReentrantLock 类

ReentrantLock 类:

public class Bank {
	private var bankLock = new ReentrantLock();
	public void transfer(int from, int to, int amount) {
		bankLock.lock();
		try {
			accounts[from] -= amount;
			accounts[to] += account;
		} finally {
			bankLock.unlock();
		}
	}
}

可重入锁

ReentrantLock 之所以被称为可重入锁,是因为同一个线程可以反复获得已拥有的锁。锁有一个持有计数,线程每调用一次 lock 后都要调用 unlock 释放锁。

synchronized 关键字:

从 1.0 版开始,Java 中的每个对象都有一个内部锁,如果一个方法声明时有 synchronized 关键字,那么对象的锁将保护整个方法。也就是说,要调用这个方法,线程必须获得内部对象锁。

public class Bank {
	public synchronized void transfer(int from, int to, int amount) {
		accounts[from] -= amount;
		accounts[to] += account;
	}
}

synchronized 关键字用法是不是简洁了很多?所以,能用 synchronized 的地方,要优先考虑!

条件对象的作用?

条件对象用来管理那些已经获得了一个锁不能做有用工作的线程。

以上面的银行转账为例,假设我们考虑一个前提条件,如果账户金额小于转账金额,则不允许转出,需要等待其他线程往账户转入足够的金额才可以转出,此时条件对象就派上用场了。

假设我们使用可重入锁和条件对象,实现代码块的并发控制:

public class Bank {
	// 可重入锁
	private var bankLock = new ReentrantLock();
	// 条件对象
	private var sufficientFunds = bankLock.newCondition();
	// 转账
	public void transfer(int from, int to, int amount) {
		bankLock.lock();
		try {
			while(accounts[from] < amount)
				sufficientFunds.await();
			accounts[from] -= amount;
			accounts[to] += account;
			sufficientFunds.signalAll();
		} finally {
			bankLock.unlock();
		}
	}
}

一旦一个线程调用了 await 方法,它就进入了这个条件对象的 等待集,当锁可用时,该线程不会变为可运行状态,直到另一个线程在同一条件上调用 signalAll 方法。

内部对象锁只有一个关联条件,wait 方法将一个线程增加到等待集中,notifyAll / notify 方法可以接触等待线程的阻塞。即:

wait()      ==> sufficientFunds.await()
notifyAll() ==> sufficientFunds.signalAll()

假设我们使用 synchronized 和条件对象,实现代码块的并发控制:

public class Bank {
	// 转账
	public void transfer(int from, int to, int amount) {
		while(accounts[from] < amount)
			wait();
		accounts[from] -= amount;
		accounts[to] += account;
		notifyAll();
	}
}

如此简洁!

volatile 关键字的作用?

有时如果只是为了读写一两个实例字段而使用同步,所带来的开销好像有些话不来,volatile 关键字为实例字段的同步访问提供了一种免锁机制。例如:

private volatile boolean done;
public boolean isDone() { return done; }
public void setDone() { done = true; }

编译器会插入适当的代码,以确保如果一个线程对 done 变量做了修改,这个修改对读取这个变量的所有其他线程都可见。

final 变量的作用?

还有一种安全访问共享字段的方式,即将字段声明为 final 类型:

final var accounts = new HashMap<Stirng, Double>();

其他线程会在构造器完成构造之后才开到这个 accounts 变量,如果不用 final ,其他线程困难看到的是 null 。

为什么 HashMap 不是线程安全的?

HashMap 的实现没有使用同步机制去保障,假定在调整散列表各个桶之间的链接关系的过程中,这个线程的控制权被抢占,另一个线程开始遍历同一个链表,可能使用无效的链接并造成混乱,可能会抛出异常或陷入无限循环。

使用线程的有几种方法?

三种:

(1)实现 Runnable 接口

public class MyRunnable implements Runnable {
    public void run() {
        // ...
    }
}

public static void main(String[] args) {
    MyRunnable instance = new MyRunnable();
    Thread thread = new Thread(instance);
    thread.start();
}

(2)实现 Callable 接口

public class MyCallable implements Callable<Integer> {
    public Integer call() {
        return 123;
    }
}

public static void main(String[] args) throws ExecutionException, InterruptedException {
    MyCallable mc = new MyCallable();
    FutureTask<Integer> ft = new FutureTask<>(mc);
    Thread thread = new Thread(ft);
    thread.start();
    System.out.println(ft.get());
}

(3)继承 Thread 类

public class MyThread extends Thread {
    public void run() {
        // ...
    }
}

public static void main(String[] args) {
    MyThread mt = new MyThread();
    mt.start();
}

实现接口会更好一些,因为:

a. Java 不支持多重继承,因此继承了 Thread 类就无法继承其它类,但是可以实现多个接口; b. 类可能只要求可执行就行,继承整个 Thread 类开销过大。

Runnable 和 Callable 的区别?

Runnable 没有返回参数,Callable 有返回参数。

线程池如何工作?

(1)构造线程池

执行器(Executors)类有许多静态工厂方法,用来构造线程池。最常用的有:

  • newCachedThreadPool:线程生存时间很短,或大量时间都在阻塞,可以使用 缓存线程池
  • newFixedThreadPool:为获得最优运行速度,并发线程数等于处理器内核数,使用 固定线程池
  • newSingleThreadExecutor:临时使用 单线程池 替换缓存或固定线程池,能够测量不发生并发情况下应用的运行速度会慢多少。

这 3 个方法返回实现了 ExecutorService 接口的 ThreadPoolExecutor 类的对象。

(2)提交任务

提交任务有 2 种方式:

  • submit 提交单个任务
var executor = Executor.newCachedThreadPool();
Callable<Long> task = () -> occurrences(word, file);
Future<Long> result = executor.submit(task);
  • invokeAll 提交任务组
var executor = Executor.newCachedThreadPool();
var tasks = new ArrayList<Callable<Long>>;
for (Path file : files) {
	Callable<Long> task = () -> occurrences(word, file);
	tasks.add(task);
}
List<Future<long>> results = executor.invokeAll(tasks);

invokeAll 方法会一直阻塞,直到所有任务都完成。

(3)保存好返回的 Future 对象,以便得到结果或取消任务

通过 Future 接口的 get 方法获取结果:

long total = 0;
for (Future<Long> result : results)
	total += result.get();

get 方法会在读取第一个线程的结果时阻塞,直到第一个线程返回结果才会继续遍历到下一个线程。这就有个问题:假如所有任务几乎同时完成,上面的实现没有问题,但是如果任务执行快慢差异较大,就有必要将先执行完的线程结果放在结果集的前面先处理,提高效率,可以利用 ExecutorCompletionService 来管理:

var service = new ExecutorCompletionService<T>(executor);
for (Callable<T> task : tasks)
	service.submit(task);
for (int i = 0; i < tasks.size(); i++)
	processFurther(service.take().get());

但是有时候我们并不希望一直等待,而是希望无等待或异步计算,在线程执行完成时处理即可。CompletableFuture 类实现了 Future 接口,允许我们注册一个回调,一旦结果可用,就会利用该结果调用这个回调。 要想异步运行任务并得到 CompletableFuture ,不要把它之间提交给执行器服务,而应到调用静态方法 CompletableFuture.supplyAsync

public CompletableFuture<String> readPage(URL url) {
	return CompletableFuture.supplyAsync(() -> {
		try {
			return new String(url.openStream().readAllBytes(), "UTF-8");
		} catch(IOException e) {
			throw new UncheckedIOException(e);
		}
	}, executor);
}

(4)不想提交任何任务时,调用 shutdown

如何使用进程对象?

如果要在 Java 代码中执行另一个程序,可以使用 ProcessBuilder 和 Process 类。Process 类在一个单独的操作系统进程中执行一个命令,允许我们与标准输入、输出和错误流交互。ProcessBuilder 类则允许我们配置 Process 对象。

Process process = new ProcessBuilder("/bin/ls", "-l")
	.directory(Path.of("/tmp").toFile())
	.start();
try (var in = new Scanner(process.getInputStream())) {
	while (in.hasNextLine())
		System.out.println(in.nextLine());
}