一些容易忘掉的知识点 - Java

1 Java 基础

1.1 快速失败(fail-fast)和安全失败(fail-safe)机制

快速失败(fail-fast)是 Java 集合的一种错误检测机制。

使用迭代器对集合进行遍历的时候,在多线程下操作非安全失败(fail-safe)的集合类可能就会触发 fail-fast 机制,导致抛出 ConcurrentModificationException 异常。

在单线程下,如果在遍历过程中对集合对象的内容进行了修改的话也会触发 fail-fast 机制。

例如:多线程下,如果线程 1 正在对集合进行遍历,此时线程 2 对集合进行修改(增加、删除、修改),或者线程 1 在遍历过程中对集合进行修改,都会导致线程 1 抛出 ConcurrentModificationException 异常。

原理

迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变 modCount 的值。

每当迭代器使用 hashNext ()/next () 遍历下一个元素之前,都会检测 modCount 变量是否为 expectedmodCount 值,是的话就返回遍历;否则抛出异常,终止遍历。

采用安全失败(fail-safe机制的集合容器(JUC 包下的容器),在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历,可以在多线程下并发使用,并发修改。所以,在遍历过程中对原集合所作的修改并不能被迭代器检测到,故不会抛 ConcurrentModificationException 异常。

1.2 HashMap 与 ConcurrentHashMap 对 null 值的处理问题

结论:HashMap 的 key 和 value 都可以为 null,ConcurrentHashMap 的均不可以为 null

HashTable 也是不允许 key 和 value 为空的,原因和 ConcurrentHashMap 一致

上源码!

HashMap,当 key 为 null 的时候,hash 值为 0,后续的 put 方法中也没有判断 value 是否为 null 的代码。

1
2
3
4
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

ConcurrentHashMap,key 或 value 为 null 直接抛出空指针异常!

1
2
3
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
......

这么做的原因:

会有一个问题,当通过 get(k) 获取对应的 value 时,如果获取到的是 null,无法判断它是 put(k,v) 的时候 value 为 null,还是这个 key 从来没有做过映射。

HashMap 多用于非并发场景中,可以通过 contains(key) 来做这个判断。而在多用于并发场景中的 ConcurrentHashMap 中调用 m.contains(key) 判断然后再 m.get(key) 时,m 可能已经被其他线程修改而不同了。为了避免这种二义性,ConcurrentHashMap 在设计之初就直接令 key 和 value 都不为 null。

1.3 Arrays.asList()

参考:数组转集合

《阿里巴巴 Java 开发手册》对 Arrays.asList() 的描述:

注意 1 传递的数组必须是对象数组,而不是基本类型。

因为 Arrays.asList() 是泛型方法,传入的对象必须是对象数组。对基本类型来说,可以使用包装类型数组可以解决这个问题。

1
Integer[] myArray = {1, 2, 3};

注意 2 使用集合的修改方法: add()remove()clear() 会抛出异常。

Arrays.asList() 方法返回的并不是 java.util.ArrayList ,而是 java.util.Arrays 的一个内部类,这个内部类并没有实现集合的修改方法或者说并没有重写这些方法。

1
2
3
4
List myList = Arrays.asList(1, 2, 3);
myList.add(4); // 运行时报错:UnsupportedOperationException
myList.remove(1); // 运行时报错:UnsupportedOperationException
myList.clear(); // 运行时报错:UnsupportedOperationException

1.4 Java 中用到的排序算法

Arrays.sort()

在该方法的源码中调用了 DualPivotQuicksort.sort(),发现在此方法中有如下判断:

1
2
3
4
5
6
7
8
9
static void sort(int[] a, int left, int right,
int[] work, int workBase, int workLen) {
// Use Quicksort on small arrays
if (right - left < QUICKSORT_THRESHOLD) {
sort(a, left, right, true);
return;
}

...

可以发现如果数组的长度小于 QUICKSORT_THRESHOLD 的话就会使用双轴快速排序(DualPivotQuicksort),这个值为 286。

在双轴快速排序中,有如下判断。如果数组长度小于 INSERTION_SORT_THRESHOLD == 47,则使用插入排序

1
2
3
4
// Use insertion sort on tiny arrays
if (length < INSERTION_SORT_THRESHOLD) {
...
}

如果数组长度不小于 286,会继续检查该数组的连续升序和连续降序性好不好 (Check if the array is nearly sorted),如果好的话就用归并排序,不好就用双轴快速排序

总结:Arrays.sort() 方法,如果数组长度小于 286 且大于等于 47 的话就用双轴快速排序,如果长度小于 47 的话就用插入排序。如果数组长度大于等于 286 且连续性好的话,就用 TimSort 归并排序(归并排序的优化版本),如果大于等于 286 且连续性不好的话就用双轴快速排序

数组长度 l 排序方法
l < 47 插入排序
47 <= l < 286 双轴快速排序
286 <= l (连续性好) TimSort 归并排序
286 <= l (连续性不好) 双轴快速排序

Collections.sort()

针对集合类容器排序,首先调用 this.toArray() 方法将集合元素放进 Object 数组,使用 Array.sort() 的重载方法对该 Object 数组排序。如果用户指定 LegacyMergeSort.userRequested == true,则使用归并排序,否则使用 TimSort 排序

1.5 BIO、NIO

同步和异步:

  • 同步:两个同步任务相互依赖,并且一个任务必须以依赖于另一任务的某种方式执行。 比如在 A->B 事件模型中,你需要先完成 A 才能执行 B。 再换句话说,同步调用中被调用者未处理完请求之前,调用不返回,调用者会一直等待结果的返回。

  • 异步:两个异步的任务完全独立的,一方的执行不需要等待另外一方的执行。再换句话说,异步调用种一调用就返回结果不需要等待结果返回,当结果返回的时候通过回调函数或者其他方式拿着结果再做相关事情,

阻塞和非阻塞:

  • 阻塞:阻塞就是发起一个请求,调用者一直等待请求结果返回,也就是当前线程会被挂起,无法从事其他任务,只有当条件就绪才能继续。
  • 非阻塞:非阻塞就是发起一个请求,调用者不用一直等着结果返回,可以先去干其他事情。

在程序里:

  • 同步阻塞,相当于一个线程在等待。
  • 同步非阻塞,相当于一个线程在正常运行。
  • 异步阻塞,相当于多个线程都在等待。
  • 异步非阻塞,相当于多个线程都在正常运行。

BIO:同步阻塞 IO (Blocking I/O)

  • 同步并阻塞,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,可以使用线程池机制改善。
  • BIO 方式适用于连接数比较小且固定的架构

NIO:同步非阻塞 IO(New I/O, Non-blocking I/O)

  • 同步非阻塞,服务器实现为一个请求一个线程,即客户端发送的连接请求都会注册到多路复用器(Selector)上,Selector 轮询到连接有 IO 请求时才启动一个线程进行处理
  • NIO 适用于连接数目多且比较短的架构,比如聊天服务器,编程复杂,JDK1.4 才开始支持

为什么 NIO 是同步非阻塞的?

因为无论多少客户端都可以接入服务端,客户端接入并不会耗费一个线程,只会创建一个连接然后注册到 Selector 上去,这样就可以去干其他想干的其他事情了。一个 Selector 线程不断的轮询所有的 Socket 连接(Channel),发现有事件了就通知,然后就启动一个线程处理一个请求即可,这个过程的话就是非阻塞的。但是这个处理的过程中,还是要先读取数据,处理,再返回的,这是个同步的过程。

多路复用机制是如何支持海量连接?

NIO 的线程模型对 Socket 发起的连接不需要每个都创建一个线程,使用一个 Selector 来多路复用监听 N 多个 Channel 是否有请求,该请求是对应的连接请求,还是发送数据的请求。

一个 Selector 不断的轮询多个 Channel,这样避免了创建多个线程。只有当某个 Channel 有对应的请求的时候才会创建线程,可能说 1000 个请求中只有 100 个请求是有数据交互的,这个时候可能 Server 端就提供 10 个线程就能够处理这些请求。这样的话就可以避免了创建大量的线程。

选择器 Selector 使用单个线程处理多个通道。因此,它需要较少的线程来处理这些通道。

NIO 中的 Buffer 是什么?

IO 面向流(Stream oriented),NIO 面向缓冲区(Buffer oriented)

通过 NIO 写数据到文件或者网络,或者是从文件和网络读取数据出来,此时就需要通过 Buffer 缓冲区来进行。

在 NIO 库中,所有数据都是用缓冲区处理的。在读取数据时,读到缓冲区中;在写入数据时,写入到缓冲区中。任何时候访问 NIO 中的数据,都是通过缓冲区进行操作。

每一种 Java 基本类型(除了 Boolean 类型)都对应有一种缓冲区。

NIO 中的 Channel 是什么?

  • Channel(通道)是 NIO 中的数据通道,NIO 通过 Channel 进行读写。类似流,但是又有些不同
  • 通道是双向的,既可从 Channel 中读取数据,又可以写数据到 Channel 中。流的读写通常是单向的
  • Channel 中的数据总是要先读到一个 Buffer 中,或者从 Buffer 中将数据写到 Channel 中。因为 Buffer,Channel 可以异步的读写

1.6 HashMap 的 put () 方法

为什么要重写 hashCode 方法?

保证相同的对象返回相同的 hash 值,不同的对象返回不同的 hash 值。和重写 equals 类似。

hash 算法

1
2
3
4
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

h 右移 16 位,向下传播较高位的影响,异或是合并这些影响。应为在小表中可能永远用不到高位信息。

1.7 HashMap 的 resize () 方法

为什么链表大小超过 8 个时会自动转化为红黑树,当删除小于 6 时重新变为链表?

根据泊松分布,在负载因子默认为 0.75 的时候,单个 hash 槽内元素个数为 8 的概率小于百万分之一,所以将 7 作为一个分水岭,等于 7 的时候不转换,大于等于 8 的时候才进行转换,小于等于 6 的时候就化为链表。

loadFactor 为什么默认为 0.75?

loadFactor 加载因子是控制数组存放数据的疏密程度,loadFactor 越趋近于 1,那么数组中存放的数据(entry)也就越多、越密,会让链表的长度增加;loadFactor 越小,也就是趋近于 0,数组中存放的数据(entry)也就越少,也就越稀疏。

loadFactor 太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。loadFactor 的默认值为 0.75f 是官方给出的一个比较好的临界值。

1.8 HashMap 获取所有的 Key

遍历获取 HashMap 中所有的 value 时,使用遍历 EntrySet 的方法比先取 KeySet 再取值快。

1
2
3
4
5
6
7
HashMap<String, String> map = new HashMap<>();
Set<String> keySet = map.keySet(); // 获取所有的 key
Set<Map.Entry<String, String>> entrySet = map.entrySet(); // 获取所有的 entry
for (Map.Entry<String, String> entry : entrySet) {
String key = entry.getKey(); // 单个 entry 的 key
String value = entry.getValue(); // 单个 entry 的 value
}

1.9 ConcurrentHashMap

ConcurrentHashMap & Hashtable

JDK 1.8 中:采用了 自旋 + CAS + synchronized 来保证并发安全性。跟 HashMap 很像,把 Node 中的 value 和 next 使用 volatile 修饰,保证了可见性,并且也引入了红黑树,在链表大于一定值的时候会转换(默认是 8)。

ConcurrentHashMap 的 put 操作大致可以分为以下步骤:

  1. 根据 key 计算出 hashcode
  2. 判断是否需要进行初始化,若需要,执行 initTable()
  3. 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功
  4. 如果当前位置的 hashcode == MOVED == -1, 则需要进行扩容 helpTransfer()
  5. 如果都不满足,则利用 synchronized 锁写入数据
  6. 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树

ConcurrentHashMap 的初始化 initTable() 是通过自旋和 CAS 操作完成的。里面需要注意的是变量 sizeCtl ,它的值决定着当前的初始化状态。

  1. -1 说明正在初始化
  2. -N 说明有 N-1 个线程正在进行扩容
  3. 表示 table 初始化大小,如果 table 没有初始化
  4. 表示 table 容量,如果 table 已经初始化。

1.10 内部类有什么作用

  • 封装性

    作为一个类的编写者,很显然需要对这个类的使用访问者的访问权限做出一定的限制,将一些我们不愿意让别人看到的操作隐藏起来,如果我们的内部类不想轻易被任何人访问,可以选择使用 private 修饰内部类(对于普通类是不能用的),这样我们就无法通过创建对象的方法来访问,想要访问只需要在外部类中定义一个 public 修饰的方法,间接调用。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public interface Demo {
    void show();
    }

    class Outer {
    private class test implements Demo {
    public void show() {
    System.out.println("密码备份文件");
    }
    }

    public Demo getInner() {
    return new test();
    }
    }
  • 实现多继承

    Java 是不可以实现多继承的,一次只能继承一个类,可以用接口来实现多继承的效果,即一个接口有多个实现,但是这里也是有一点弊端的,那就是,一旦实现一个接口就必须实现里面的所有方法,有时候就会出现一些累赘,但是使用内部类可以很好的解决这些问题。

    编写两个待继承的类 Demo1 和 Demo2,在 MyDemo 类中书写了两个内部类,test1 和 test2 两者分别继承了 Demo1 和 Demo2 类,这样 MyDemo 中就间接的实现了多继承。

    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
      public class Demo1 {
    public String name() {
    return "BWH_Steven";
    }
    }

    public class Demo2 {
    public String email() {
    return "xxx.@163.com";
    }
    }

    public class MyDemo {

    private class test1 extends Demo1 {
    public String name() {
    return super.name();
    }
    }

    private class test2 extends Demo2 {
    public String email() {
    return super.email();
    }
    }

    public String name() {
    return new test1().name();
    }

    public String email() {
    return new test2().email();
    }

    public static void main(String args[]) {
    MyDemo md = new MyDemo();
    System.out.println("我的姓名:" + md.name());
    System.out.println("我的邮箱:" + md.email());
    }
    }

1.11 创建对象的几种方式

  1. 使用 new 关键字(最常见)

  2. 使用 Class 类的 newInstance() 方法(反射)

    只能调用无参构造函数,不能调用私有的构造函数

    1
    2
    3
    User user = (User)Class.forName("cn.morooi.User").newInstance();
    // 或
    User user = User.class.newInstance();
  3. 使用 Constructor 类的 newInstance() 方法(反射)

    1
    2
    Constructor constructor = Class.forName("cn.morooi.User").getConstructor();
    User user = (User)constructor.newInstance()
  4. 使用 clone() 方法(不会调用任何构造函数)

  5. 反序列化

1.12 双亲委派机制

类加载器详解

在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,首先会把该请求委派该父类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。当父类加载器无法处理时,才由自己来处理。当父类加载器为 null 时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。

好处

双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。

如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类。

三个 Classloader

  • BootstrapClassLoader(启动类加载器):最顶层的加载类,由 C++ 实现,负责加载 %JAVA_HOME%/lib 目录下的 jar 包和类或者或被 -Xbootclasspath 参数指定的路径中的所有类。
  • ExtensionClassLoader(扩展类加载器):主要负责加载目录 %JRE_HOME%/lib/ext 目录下的 jar 包和类,或被 java.ext.dirs 系统变量所指定的路径下的 jar 包。
  • AppClassLoader(应用程序类加载器):面向用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。

1.13 实现只读集合

Collections.unmodifiable...() 可以实现只读集合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.util.*;

public class Main {
public static void main(String[] argv)
throws Exception {
List stuff = Arrays.asList(new String[] { "a", "b" });
List list = new ArrayList(stuff);
list = Collections.unmodifiableList(list);
try {
list.set(0, "new value");
}
catch (UnsupportedOperationException e) {
}
Set set = new HashSet(stuff);
set = Collections.unmodifiableSet(set);
Map map = new HashMap();
map = Collections.unmodifiableMap(map);
System.out.println("集合现在是只读");
}
}

1.14 接口和抽象类的区别

  1. 接口方法的默认修饰符是 public,在 JDK 8 以前所有方法在接口中不能有实现。JDK 8 中可以在接口中定义默认方法和静态方法。静态方法直接用接口名调用,实现类和实现是不可以调用的。如果同时实现两个接口,接口中定义了一样的默认方法,则必须重写。
  2. 抽象方法可以有 public、protected 和 default 这些修饰符,也可以有非抽象的方法。
  3. 一个类可以实现多个接口,但只能实现一个抽象类。接口自己本身可以通过 extends 关键字扩展多个接口。
  4. 接口中的变量只能用 static final 修饰,不能有其他变量,而抽象类中则不一定。
  5. 从设计层面来说,抽象是对类的抽象,是一种模板设计,而接口是对行为的抽象,是一种行为的规范。
  6. JDK 9 在接口中引入了私有方法和私有静态方法。

1.15 类的生命周期、类加载过程

类加载过程详解

Java 类加载机制

Java 类加载过程通常分为以下三个阶段:

1. 加载(Loading)

  • 在加载阶段,类加载器负责查找类的字节码文件并将其加载到内存中。类加载器根据类的全名(包括包路径)来查找对应的 .class 文件。
  • 找到类文件后,将其读取到内存中,并创建一个表示该类的 Class 对象。
  • 类加载器可以是系统类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)或应用程序类加载器(Application ClassLoader)

2. 链接(Linking)

链接阶段进一步分为三个步骤:

  • 验证(Verification):Java 虚拟机执行验证步骤以确保类文件的完整性和合法性。这包括检查字节码文件的格式、语法和语义,以防止安全漏洞和运行时错误。
  • 准备(Preparation):Java 虚拟机为类的静态变量分配内存空间,并设置默认初始值,例如数字类型的默认值是 0,对象引用的默认值是 null。这些变量的内存分配在类加载阶段进行,而不是在类初始化阶段。
  • 解析(Resolution):解析是可选的步骤,根据需要执行。在解析阶段,虚拟机将符号引用(如类、方法、字段的引用)转换为直接引用,以便在后续的类初始化和运行中快速访问类和其成员。

3. 初始化(Initialization)

  • 初始化是类加载的最后一个阶段。在初始化阶段,Java 虚拟机执行类的初始化代码,这包括执行静态初始化块和静态字段初始化。初始化阶段确保在使用类之前,类的状态已经准备好。
  • 类的初始化是一个延迟操作,只有在首次使用类时才会触发。

类初始化的时机包括:

  • 创建类的实例(对象初始化时)。
  • 调用类的静态方法。
  • 访问类的静态字段(除 final 常量外)。
  • 初始化类的子类。

Java 的类加载机制确保了类的懒加载(只有在需要时才加载)和类初始化的顺序。每个类只会被加载和初始化一次,除非它被卸载(通常不会发生)。这个机制有助于提高性能和节省内存。

1.16 几个容器的声明

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
// ArrayList
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {...}

// LinkedList
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable {...}

// HashMap
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {...}

// ConcurrentHashMap
public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
implements ConcurrentMap<K,V>, Serializable {...}

// HashTable
public class Hashtable<K,V>
extends Dictionary<K,V>
implements Map<K,V>, Cloneable, java.io.Serializable {...}

// CopyOnWriteArrayList
public class CopyOnWriteArrayList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {...}

// HashSet
public class HashSet<E>
extends AbstractSet<E>
implements Set<E>, Cloneable, java.io.Serializable {...}

1.17 StringBuilder 和 StringBuffer 的区别

String、StringBuffer、StringBuilder 的区别?

StringBuilderStringBuffer 都继承自 AbstractStringBuilder 抽象类,在 AbstractStringBuilder 中也是使用字符数组保存字符串 char[]value,但是没有用 final 关键字修饰,所以这两种对象都是可变的。

StringBuilderStringBuffer 的方法都是调用父类的方法实现的,区别在于 StringBuffer 的方法都使用 synchronized 关键字修饰保证线程安全。

1.18 错误与异常

Java 中所有的异常都继承于 java.lang.Throwable 类。Throwable 有两个重要的子类:Exception(异常)Error(错误)

  • 错误 Error:是程序无法处理的错误,表示运行应用程序中较严重问题。大多数错误与代码编写者执行的操作无关,而表示代码运行时 JVM(Java 虚拟机)出现的问题。例如,Java 虚拟机运行错误(Virtual MachineError),当 JVM 不再有继续执行操作所需的内存资源时,将出现 OutOfMemoryError。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。
  • 异常 Exception:是程序本身可以处理的异常。Exception 类有一个重要的子类 RuntimeException。RuntimeException 异常由 Java 虚拟机抛出。NullPointerException(要访问的变量没有引用任何对象时,抛出该异常)、ArithmeticException(算术运算异常,一个整数除以 0 时,抛出该异常)和 ArrayIndexOutOfBoundsException(下标越界异常)

异常和错误的区别:异常能被程序本身处理,错误无法处理

1.19 String 类的一些用法

  • 新建一个 String 对象

    1
    2
    3
    4
    5
    6
    7
    String s1 = "s";
    String s2 = new String("s"); // s2 == "s"
    char[] chars = {'a', 'b', 'c', 'd'};
    String s3 = new String(chars); // s3 == "abcd"
    String s4 = new String(chars, 1, 2); // s4 == "bc"
    byte[] bytes = new byte[]{65, 66};
    String s5 = new String(bytes); // s5 == "AB"
  • 常用方法

    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
    // 提取子串
    String s = "abcdef";
    String s1 = s.substring(s, 2); // s1 == "cdef"
    String s2 = s.substring(s, 2, 4); // s2 == "cd",左闭右开区间

    // 字符串连接
    String s = "aa".concat("bb").concat("cc"); // s == "aabbcc", 相当于 "aa" + "bb" + "cc"

    // 查找参数字符串在本字符串中首次出现的索引位置,没有返回 -1
    String str = "I am a good student";
    int a = str.indexOf('a'); // a == 2
    int b = str.indexOf("good"); // b == 7
    int c = str.indexOf("w", 2); // c == -1,该方法从第二个参数位置向后查找
    int d = str.lastIndexOf("a"); // d == 5,该方法从字符串的末尾位置向前查找
    int e = str.lastIndexOf("a", 3); // e == 2,该方法从第二个参数位置向前查找

    // 转换
    public char[] toCharArray(); // 将当前字符串拆分成为字符数组作为返回值
    public byte[] getBytes(); // 获取当前字符串底层的字节数组
    public String toLowerCase(); //返回将当前字符串中所有字符转换成小写后的新串
    public String toUpperCase(); //返回将当前字符串中所有字符转换成大写后的新串

    // 替换
    // 将所有出现的老字符串替换成为新的字符串,返回替换后的结果新字符串
    public String replace(CharSequence oldString, CharSequence newString);
    // 用字符 replacement 的内容替换当前字符串中遇到的第一个和字符串 regex 相匹配的子串
    public String replaceFirst(String regex, String replacement);
    // 用字符 replacement 的内容替换当前字符串中遇到的所有和字符串regex相匹配的子串
    public String replaceAll(String regex, String replacement);

    // 去掉空格
    public String trim(); // 去掉首尾的空格,中间的不处理
    str.replace(" ", ""); // 使用 replace() 方法去掉所有空格,包括首尾、中间
    str.replaceAll(" ", ""); // 使用 replaceAll() 方法去掉所有空格,包括首尾、中间
    str.replaceAll("\\s*", ""); // 使用 replaceAll() 方法去掉所有空白字符,包括空格、制表符、换页符等

    // 分割
    public String[] split(String regex); // 按照参数规则将字符串切分成若干部分,返回 String 数组
  • 字符串与基本类型的转换

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 基本类型转字符串,使用 String 类中的 valueOf() 方法
    public static String valueOf(char data[])
    public static String valueOf(char data[], int offset, int count)
    public static String valueOf(boolean b)
    public static String valueOf(char c)
    public static String valueOf(int i)
    public static String valueOf(long l)
    public static String valueOf(float f)
    public static String valueOf(double d)

    // 字符串转基本类型,使用基本类型的包装类中的 parseXxx() 方法
    public static byte parseByte(String s)
    public static short parseShort(String s)
    public static int parseInt(String s)
    public static long parseLong(String s)
    public static float parseFloat(String s)
    public static double parseDouble(String s)

1.20 线程安全的容器

  • 同步容器:HashTable, Vector
  • 并发容器:ConcurrentHashMap, CopyOnWriteArrayList, CopyOnWriteArraySet
  • 队列:ConcurrentLinkedQueue, ConcurrentLinkedDeque
  • 阻塞队列:ArrayBlockingQueue, LinkedBlockingQueue, PriorityBlockingQueue, DelayQueue, SynchronousQueue

1.21 HashMap 为什么线程不安全

HashMap 不是线程安全的主要原因是多个线程可以同时修改 HashMap 中的键值对,导致不一致性。具体来说,当两个线程同时尝试在 HashMap 中添加或删除键值对时,可能会发生以下情况:

  1. 两个线程同时调用 put 方法并且插入相同的键,这会导致其中一个线程的插入操作被覆盖,并且可能会导致该键值对丢失。
  2. 当一个线程正在调用 put 方法添加键值对时,另一个线程同时调用 get 方法来获取相同的键值对,这可能会导致 get 方法返回旧值,而不是最新的值。
  3. 当一个线程正在调用 put 方法添加键值对时,另一个线程同时调用 remove 方法来删除相同的键值对,这可能会导致删除失败或者删除了错误的键值对。

1.22 HashMap 为什么扩容总是 2 的幂次

HashMap 的扩容总是 2 的幂次方,是因为这样可以在进行哈希映射计算时,用位运算的方式代替除法运算,从而提高运算速度。

具体来说,在 HashMap 中,对于一个 key,它的哈希值会先通过以下方式转换为在数组中的索引位置:index = (hash & (capacity - 1))

其中,**hash** 是 key 的哈希值,**capacity** 是数组的容量,**&** 是按位与运算符。这种转换方式可以保证 index 的值不会超过数组的大小,而且可以均匀地分布在数组中。

如果数组的大小不是 2 的幂次,那么 (capacity - 1) 得到的值的二进制表示中会有一些 0 后面跟着一些 1,这样做位运算时就会多次执行操作。而如果数组大小是 2 的幂次,**(capacity - 1)** 得到的值的二进制表示中所有的位都是 1,这样做位运算时只需要执行一次操作就可以了。

此外,由于扩容时需要将所有的元素重新计算索引位置并放到新数组中,如果数组大小是 2 的幂次,那么元素的索引位置计算也会更加高效。这是因为,如果数组大小是 2 的幂次,那么元素在新数组中的索引位置要么不变,要么变为原索引位置加上旧数组大小。这种计算方式只需要用位运算来实现,非常高效。

综上所述,HashMap 的扩容总是 2 的幂次方,可以提高运算速度并且使得索引位置的计算更加高效。

1.23 HashMap 为什么要用红黑树 不用其他树

在 HashMap 的实现中,当链表长度达到一定阈值时,会将链表转换为红黑树。这是因为红黑树相对于其他树,有以下优势:

  1. 查询、插入和删除的时间复杂度为 O (log n),与平衡树的性质有关,相对于链表的线性复杂度,性能更高。
  2. 红黑树相对于其他平衡树来说,实现比较简单,而且旋转操作次数较少,因此更适合在 HashMap 中使用。

红黑树是一种自平衡二叉搜索树,能够保证树的高度始终在 O (log n) 的范围内,从而保证了插入、删除和查询的时间复杂度都能够达到 O (log n)。

相对于其他平衡树如 AVL 树,红黑树的旋转操作较少,实现较为简单,而且能够保证插入、删除和查询的时间复杂度也能够达到 O (log n),因此更适合在 HashMap 中使用。

1.24 HashMap 为什么到 8 的时候变为红黑树

当链表长度较小时,使用链表能够较为高效地进行查找、插入和删除操作,而且实现比较简单。但是当链表长度较大时,链表的性能会逐渐变差,因为查找、插入和删除操作的时间复杂度为 O (n)。

为了解决这个问题,Java 8 引入了红黑树来替代链表,因为红黑树能够保证插入、删除和查找的时间复杂度为 O (log n),相对于链表而言具有更好的性能表现。而 8 这个阈值是通过经验和实验得到的一个比较优秀的值,能够在实际使用中达到较好的性能表现。

需要注意的是,当红黑树中节点数目小于等于 6 时,HashMap 又会将红黑树转换为链表(在 resize 的时候),这是为了避免红黑树的过度复杂化和占用过多的内存空间。

1.25 符号引用和直接引用

1. 符号引用(Symbolic Reference)

  • 符号引用是在编译阶段生成的,它用符号来表示要引用的类、方法、字段等元素,而不包含具体的内存地址或偏移量信息。
  • 符号引用提供了元素的名称、类型以及所在类的引用,但它不包含具体的内存地址。这使得编译器和链接器可以在后续的处理阶段(如类加载阶段)进行解析,并将符号引用映射到具体的内存地址或偏移量。
  • 符号引用的使用使得 Java 虚拟机能够支持动态链接和类加载,因为它们可以在运行时动态解析为直接引用。

2. 直接引用(Direct Reference)

  • 直接引用是在虚拟机执行时实际使用的引用,它包含了元素的内存地址或偏移量信息,可以直接访问该元素。
  • 直接引用通常在符号引用解析之后生成,以便虚拟机能够高效地访问类、方法、字段等元素。
  • 直接引用的使用使得虚拟机能够快速访问内存中的数据,而不需要重新计算或查找。

总结来说,符号引用是编程语言在编译阶段使用的抽象引用,不包含具体的内存地址信息,而直接引用是在执行阶段使用的实际引用,包含了元素的内存地址或偏移量信息,以便能够直接访问数据。在类加载和链接过程中,符号引用会被解析为直接引用,以便程序可以正确地执行。这种分离使得 Java 虚拟机能够支持动态链接和类加载,并且在执行时具有高效的内存访问。

2 多线程

2.1 sleep ()、wait ()、join () 的区别

  • sleep() 是线程线程类(Thread)的方法,调用会暂停此线程指定的时间,但监控依然保持,不会释放对象锁,到时间自动恢复。
  • wait() 是 Object 的方法,并且只能在同步方法或同步块中使用,如果当前线程不是锁的持有者,该方法抛出一个 IllegalMonitorStateException 异常。调用会放弃对象锁,进入等待队列,待调用 notify() / notifyAll() 唤醒指定的线程或者所有线程后才会进入锁池,再次获得对象锁才会进入运行状态。
  • join() 是线程线程类(Thread)的方法,调用 join() 方法不会释放锁,调用线程会一直等待被调用的线程执行完毕(转换为 TERMINATED 状态)
  • sleep() 方法可以在任何地方使用,并且必须要指定时间,wait() 方法则只能在同步方法或同步块中使用。

2.2 线程的生命周期和状态

Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态。

线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。

2.3 锁升级

见:synchronize 与锁

锁的升级流程

每一个线程在准备获取共享资源时:

  1. 检查 MarkWord 里面是不是放的自己的 ThreadId , 如果是,表示当前线程是处于 “偏向锁”
  2. 如果 MarkWord 不是自己的 ThreadId,锁升级,这时候,用 CAS 来执行切换,新的线程根据 MarkWord 里面现有的 ThreadId,通知之前线程暂停,之前线程将 Markword 的内容置为空
  3. 两个线程都把锁对象的 HashCode 复制到自己新建的用于存储锁的记录空间,接着开始通过 CAS 操作, 把锁对象的 MarKword 的内容修改为自己新建的记录空间的地址的方式竞争 MarkWord
  4. 第三步中成功执行 CAS 的获得资源,失败的则进入自旋
  5. 自旋的线程在自旋过程中,成功获得资源 (即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于 轻量级锁的状态,如果自旋失败
  6. 进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己

总的来说,先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁

各种锁的优缺点对比

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 适用于只有一个线程访问同步块场景。
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度。 如果始终得不到锁竞争的线程使用自旋会消耗 CPU。 追求响应时间。同步块执行速度非常快。
重量级锁 线程竞争不使用自旋,不会消耗 CPU。 线程阻塞,响应时间缓慢。 追求吞吐量。同步块执行时间较长。

2.4 线程为什么比进程快

进程和线程的区别

进程是一个独立的运行环境,而线程是在进程中执行的一个任务。他们两个本质的区别是是否单独占有内存地址空间及其它系统资源(比如 I/O)

  • 进程单独占有一定的内存地址空间,所以进程间存在内存隔离,数据是分开的,数据共享复杂但是同步简单,各个进程之间互不干扰;而线程共享所属进程占有的内存地址空间和资源,数据共享简单,但是同步复杂。
  • 进程单独占有一定的内存地址空间,一个进程出现问题不会影响其他进程,不影响主程序的稳定性,可靠性高;一个线程崩溃可能影响整个程序的稳定性,可靠性较低。
  • 进程单独占有一定的内存地址空间,进程的创建和销毁不仅需要保存寄存器和栈信息,还需要资源的分配回收以及页调度,开销较大;线程只需要保存寄存器和栈信息,开销较小。

另外一个重要区别是,进程是操作系统进行资源分配的基本单位,而线程是操作系统进行调度的基本单位,即 CPU 分配时间的单位 。

2.5 使用多线程的好处

多进程的方式也可以实现并发,为什么我们要使用多线程?

多进程方式确实可以实现并发,但使用多线程,有以下几个好处:

  • 进程间的通信比较复杂,而线程间的通信比较简单,通常情况下,我们需要使用共享资源,这些资源在线程间的通信比较容易。
  • 进程是重量级的,而线程是轻量级的,故多线程方式的系统开销更小。

2.6 使用多线程可能会带来的问题

内存泄漏

死锁

线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。

死锁发生的条件:

  1. 互斥条件:资源 x 的任意一个时刻只被一个线程持有
  2. 占有且等待:线程 1 占有资源 x 的同时等待资源 y ,并不释放 x
  3. 不可抢占:资源 x 一旦被线程 1 占有,其他线程不能抢占 x
  4. 循环等待:线程 1 持有 x,等待 y,线程 2 持有 y,等待 x,当全部满足时才会死锁

避免死锁:

  1. 破坏互斥条件 :这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。
  2. 破坏占有且等待 :一次性申请所有的资源。
  3. 破坏不可抢占 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  4. 破坏循环等待 :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。
  5. 使用 tryLock 方法(ReentrantLock、ReentrantReadWriteLock),设置超时时间,超时可以退出防止死锁。

线程不安全

2.7 synchronized 与 Lock 的区别

类别 synchronized Lock
存在层次 Java 的关键字,在 JVM 层面上 是一个类
锁的释放 1. 已获取锁的线程执行完同步代码,释放锁
2. 线程执行发生异常,JVM 会让线程释放锁
必须在 finally 中释放锁,不然容易造成线程死锁
锁的获取 假设 A 线程获得锁,B 线程等待
如果 A 线程阻塞,B 线程会一直等待
分情况而定,Lock 有多个锁获取的方式
大致就是可以尝试获得锁,线程可以不用一直等待
锁状态 无法判断 可以判断
锁类型 可重入、不可中断、非公平 可重入、可判断、可公平(两者皆可)
性能 少量同步 大量同步

synchronized 有什么不足之处?

  • 如果临界区是只读操作,其实可以多线程一起执行,但使用 synchronized 的话,同一时间只能有一个线程执行
  • synchronized 无法知道线程有没有成功获取到锁
  • 使用 synchronized,如果临界区因为 IO 或者 sleep 方法等原因阻塞了,而当前线程又没有释放锁,就会导致所有线程等待

Lock 接口有什么优势?

  • 可以使锁更公平
  • 可以使线程在等待锁的时候响应中断
  • 可以让线程尝试获取锁,并在无法获取锁的时候立即返回或者等待一段时间
  • 可以在不同的范围,以不同的顺序获取和释放锁

整体上来说 Lock 是 synchronized 的扩展版,Lock 提供了无条件的、可轮询的(tryLock 方法)、定时的(tryLock 带参方法)、可中断的(lockInterruptibly)、可多条件队列的(newCondition 方法)锁操作。另外 Lock 的实现类基本都支持非公平锁(默认)和公平锁,synchronized 只支持非公平锁,当然,在大部分情况下,非公平锁是高效的选择。

ReentrantLock

ReentrantLock 是 Lock 接口的 JDK 默认实现,特点:

  • 可重入锁

  • 支持公平锁和非公平锁,默认为非公平锁

    在 ReentrantLock 的构造方法里,可以传入一个 boolean 类型的参数,来指定它是否是一个公平锁,默认情况下是非公平的。这个参数一旦实例化后就不能修改,只能通过 isFair() 方法来查看。

  • 是排他锁,独占不能共享

ReentrantReadWriteLock

ReentrantReadWriteLock 是 ReadWriteLock 接口的 JDK 默认实现,特点:

  • 可重入锁
  • 支持公平锁和非公平锁,默认为非公平锁
  • 支持读写锁,在 “写” 操作的时候,其它线程不能写也不能读,称这种现象为 “写饥饿”。

StampedLock

StampedLock 把读分为了悲观读和乐观读,悲观读就等价于 ReadWriteLock 的读,而乐观读在一个线程写共享变量时,不会被阻塞,乐观读是不加锁的。所以没锁肯定是比有锁的性能好,这样的话在大并发读情况下效率就更高了。StampedLock 在获取锁和乐观读时,都会返回一个 stamp,解锁时需要传入这个 stamp,在乐观读时是用来验证共享变量是否被其他线程写过。

2.8 线程池

来自:线程池原理

使用线程池主要有以下三个原因:

  1. 创建 / 销毁线程需要消耗系统资源,线程池可以复用已创建的线程
  2. 控制并发的数量。并发数量过多,可能会导致资源消耗过多,从而造成服务器崩溃。(主要原因)
  3. 可以对线程做统一管理

Java 中的线程池顶层接口是 Executor 接口,ThreadPoolExecutor 是这个接口的实现类。

四种常见的线程池

Executors 类中提供的几个静态方法来创建线程池。

  • newFixedThreadPool

    1
    2
    3
    4
    5
    public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
    0L, TimeUnit.MILLISECONDS,
    new LinkedBlockingQueue<Runnable>());
    }

    核心线程数量和总线程数量相等,都是传入的参数 nThreads,所以只能创建核心线程,不能创建非核心线程。

  • newSingleThreadExecutor

    1
    2
    3
    4
    5
    6
    public static ExecutorService newSingleThreadExecutor() {
    return new FinalizableDelegatedExecutorService
    (new ThreadPoolExecutor(1, 1,
    0L, TimeUnit.MILLISECONDS,
    new LinkedBlockingQueue<Runnable>()));
    }

    有且仅有一个核心线程(corePoolSize == maximumPoolSize == 1),使用了 LinkedBlockingQueue(容量很大),所以,不会创建非核心线程。所有任务按照先来先执行的顺序执行。如果这个唯一的线程不空闲,那么新来的任务会存储在任务队列里等待执行。

  • newCachedThreadPool

    1
    2
    3
    4
    5
    public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
    60L, TimeUnit.SECONDS,
    new SynchronousQueue<Runnable>());
    }

    不创建核心线程,线程池最大为 Integer.MAX_VALUE。尝试将任务添加到 SynchronousQueue 队列。如果 SynchronousQueue 入列成功,等待被当前运行的线程空闲后拉取执行。如果当前没有空闲线程,那么就创建一个非核心线程,然后从 SynchronousQueue 拉取任务并在当前线程执行。如果 SynchronousQueue 已有任务在等待,入列操作将会阻塞。

  • newScheduledThreadPool

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
    return new ScheduledThreadPoolExecutor(corePoolSize);
    }

    // class ScheduledThreadPoolExecutor
    public ScheduledThreadPoolExecutor(int corePoolSize) {
    super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
    new DelayedWorkQueue());
    }

    创建一个定长线程池,支持定时及周期性任务执行。

线程池的参数

  • int corePoolSize:该线程池中核心线程数最大值

  • int maximumPoolSize:该线程池中线程总数最大值。(等于核心线程数量 + 非核心线程数量)

  • long keepAliveTime非核心线程闲置超时时长

    非核心线程如果处于闲置状态超过该值,就会被销毁。

    如果设置 allowCoreThreadTimeOut(true),则会也作用于核心线程。

  • TimeUnit unit:keepAliveTime 的单位。

  • BlockingQueue workQueue:阻塞队列,维护着等待执行的 Runnable 任务对象

  • ThreadFactory threadFactory:创建线程的工厂 ,用于批量创建线程。

  • RejectedExecutionHandler handler:拒绝处理策略,线程数量大于最大线程数就会采用拒绝处理策略。

核心线程:线程池中有两类线程,核心线程和非核心线程。核心线程默认情况下会一直存在于线程池中,即使这个核心线程什么都不干(铁饭碗),而非核心线程如果长时间的闲置,就会被销毁(临时工)。

常用的几个阻塞队列

  1. LinkedBlockingQueue

    链式阻塞队列,底层数据结构是链表,默认大小是 Integer.MAX_VALUE,也可以指定大小。

  2. ArrayBlockingQueue

    数组阻塞队列,底层数据结构是数组,需要指定队列的大小。

  3. SynchronousQueue

    同步队列,内部容量为 0,每个 put 操作必须等待一个 take 操作,反之亦然。

  4. DelayQueue

    延迟队列,该队列中的元素只有当其指定的延迟时间到了,才能够从队列中获取到该元素。

四种拒绝处理策略

  1. ThreadPoolExecutor.AbortPolicy默认拒绝处理策略,丢弃任务并抛出 RejectedExecutionException 异常。
  2. ThreadPoolExecutor.DiscardPolicy:丢弃新来的任务,但是不抛出异常。
  3. ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列头部(最旧的)的任务,然后重新尝试执行程序(如果再次失败,重复此过程)。
  4. ThreadPoolExecutor.CallerRunsPolicy:由调用线程处理该任务。

线程池主要的任务处理流程

  1. 线程总数量 < corePoolSize,无论线程是否空闲,都会新建一个核心线程执行任务(让核心线程数量快速达到 corePoolSize,在核心线程数量 < corePoolSize 时)。注意,这一步需要获得全局锁。
  2. 线程总数量 >= corePoolSize 时,新来的线程任务会进入任务队列中等待,然后空闲的核心线程会依次去缓存队列中取任务来执行(体现了线程复用)。
  3. 当缓存队列满了,说明这个时候任务已经多到爆棚,需要一些 “临时工” 来执行这些任务了。于是会创建非核心线程去执行这个任务。注意,这一步需要获得全局锁。
  4. 缓存队列满了, 且总线程数达到了 maximumPoolSize,则会采取上面提到的拒绝策略进行处理。

2.9 ThreadLocal

Java 并发 - ThreadLocal 详解

ThreadLocal 详解

ThreadLocal 到底会不会内存泄漏?实战直接告诉你答案!

证明:ThreadLocal 的 get,set 方法无法防止内存泄漏

ThreadLocal 面试场景

2.10 volatile

volatile 详解

volatile 为什么可以保证内存可见性?

volatile 是在汇编层面加 Lock,使用缓存一致性协议(MESI)解决并发可见性的。

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存 (L1,L2 或其他) 后再进行操作,但操作完不知道何时会写到内存。

如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。

为了保证各个处理器的缓存是一致的,实现了缓存一致性协议(MESI),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

所有多核处理器下还会完成:当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。

volatile 变量通过这样的机制就使得每个线程都能获得该变量的最新值。

volatile 是怎么禁止指令重排序的?

volatile 关键字通过内存屏障(Memory Barrier)来禁止指令重排序。

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

通过插入这些屏障,volatile 变量的读写操作的顺序在编译器和处理器中都得到了限制,保证了它们的顺序与代码中的顺序一致。这样就避免了在多线程环境下由于指令重排而导致的读取脏数据的问题。

3 JVM

3.1 CMS 收集器

来自:JVM 垃圾回收详解G1 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

从名字中的 Mark Sweep 这两个词可以看出,CMS 收集器是一种 “标记 - 清除” 算法实现的,整个过程分为四个步骤:

  • 初始标记:暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快。
  • 并发标记:同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
  • 重新标记:重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。
  • 并发清除:开启用户线程,同时 GC 线程开始对未标记的区域做清扫。

主要优点:并发收集、低停顿

主要缺点:

  • 对 CPU 资源敏感
  • 无法处理浮动垃圾
  • 它使用的 “标记 - 清除” 算法会导致收集结束时会有大量空间碎片产生

3.2 G1 收集器

来自:JVM 垃圾回收详解G1 收集器

G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器。以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.

被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。它具备以下特点:

  • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
  • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
  • 空间整合:与 CMS 的 “标记 - 清理” 算法不同,G1 从整体来看是基于 “标记 - 整理” 算法实现的收集器,从局部上来看是基于 “复制” 算法实现的。
  • 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。

G1 之前的 JVM 内存模型

G1 收集器的内存模型

G1 堆内存结构

堆内存会被切分成为很多个固定大小区域(Region),每个是连续范围的虚拟内存。

堆内存中一个区域(Region)的大小可以通过 -XX:G1HeapRegionSize 参数指定,大小区间最小 1M、最大 32M,总之是 2 的幂次方。

默认把堆内存按照 2048 份均分。

G1 堆内存分配

每个 Region 被标记了 E、S、O 和 H,这些区域在逻辑上被映射为 Eden,Survivor 和老年代。

存活的对象从一个区域转移(即复制或移动)到另一个区域。区域被设计为并行收集垃圾,可能会暂停所有应用线程。

如上图所示,区域可以分配到 Eden,survivor 和老年代。此外,还有第四种类型,被称为巨型区域(Humongous Region)。Humongous 区域是为了那些存储超过 50% 标准 region 大小的对象而设计的,它用来专门存放巨型对象。如果一个 H 区装不下一个巨型对象,那么 G1 会寻找连续的 H 分区来存储。为了能找到连续的 H 区,有时候不得不启动 Full GC。

G1 回收流程

G1 收集器的运作大致分为以下几个步骤(类似于 CMS 收集器):

  • 初始标记:这个阶段是 STW (Stop the World) 的,所有应用线程会被暂停,标记出从 GC Root 开始直接可达的对象。
  • 并发标记:从 GC Roots 开始对堆中对象进行可达性分析,找出存活对象,耗时较长。当并发标记完成后,开始最终标记(Final Marking)阶段
  • 最终标记:标记那些在并发标记阶段发生变化的对象,将被回收。
  • 筛选回收:首先对各个 Regin 的回收价值和成本进行排序,根据用户所期待的 GC 停顿时间指定回收计划,回收一部分 Region。

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region (这也就是它的名字 Garbage-First 的由来)。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

3.3 Java 内存中各部分的内容

参考:Java 内存区域详解可能是把 Java 内存区域讲的最清楚的一篇文章

线程共享的

  • Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

  • 方法区

    各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

  • 运行时常量池
    JDK1.7 前在方法区中,1.7 及之后在 Java 堆中的一块区域存放运行时常量池,用于存放编译期生成的各种字面量和符号引用。

    1. JDK1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区,此时 hotspot 虚拟机对方法区的实现为永久代
    2. JDK1.7 字符串常量池被从方法区拿到了堆中,这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区,也就是 hotspot 中的永久代
    3. JDK1.8 hotspot 移除了永久代用元空间 (Metaspace) 取而代之,这时候字符串常量池还在堆,运行时常量池还在方法区,只不过方法区的实现从永久代变成了元空间 (Metaspace)

线程私有的

  • 程序计数器

    唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

    两个作用:

    • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
    • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
  • 虚拟机栈

    由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息

    局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。

  • 本地方法栈

    和虚拟机栈所发挥的作用非常相似,虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,本地方法栈则为虚拟机使用到的 Native 方法服务。在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

3.4 永久代会有垃圾回收吗

永久代也是可以回收的,条件是

  1. 该类的实例都被回收

  2. 加载该类的 classLoader 已经被回收

  3. 该类不能通过反射访问到其方法,而且该类的 java.lang.class 没有被引用

当满足这 3 个条件时,是可以回收,但回不回收还得看 JVM。

3.5 JDK 8 的默认垃圾回收器

使用 java -XX:+PrintCommandLineFlags -version 可以查看

1
2
3
4
5
6
java -XX:+PrintCommandLineFlags -version

-XX:InitialHeapSize=268435456 -XX:MaxHeapSize=4294967296 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC
java version "1.8.0_231"
Java(TM) SE Runtime Environment (build 1.8.0_231-b11)
Java HotSpot(TM) 64-Bit Server VM (build 25.231-b11, mixed mode)

其中 UseParallelGC 表示使用 Parallel Scavenge + Parallel Old 垃圾收集器。

3.6 JVM 思维导图

4 数据库

4.1 MyISAM 与 InnoDB 的区别

MyISAM 和 InnoDB 有什么区别?

MySQL 5.5 版之前 MyISAM 是默认数据库引擎,5.5 版本后默认的存储引擎为 InnoDB。

两者的对比:

1. 是否支持行级锁

MyISAM 只有表级锁 (table-level locking),而 InnoDB 支持行级锁 (row-level locking) 和表级锁,默认为行级锁

2. 是否支持事务

MyISAM 不提供事务支持。InnoDB 提供事务支持,实现了 SQL 标准定义了四个隔离级别,具有提交 (commit) 和回滚 (rollback) 事务的能力。并且,InnoDB 默认使用的 REPEATABLE-READ(可重读)隔离级别是可以解决幻读问题发生的(基于 MVCC 和 Next-Key Lock)

3. 是否支持外键

MyISAM 不支持,InnoDB 支持。

4. 是否支持数据库异常崩溃后的安全恢复

MyISAM 不支持,InnoDB 支持。使用 InnoDB 的数据库在异常崩溃后,数据库重新启动的时候会保证数据库恢复到崩溃前的状态。这个恢复的过程依赖于 redo log。

5. 是否支持 MVCC

仅 InnoDB 支持。应对高并发事务 MVCC 比单纯的加锁更高效,MVCC 只在 READ COMMITTED 和 REPEATABLE READ 两个隔离级别下工作,MVCC 可以使用乐观锁和悲观锁来实现。

总结:

  • InnoDB 支持行级别的锁粒度,MyISAM 不支持,只支持表级别的锁粒度
  • MyISAM 不提供事务支持,InnoDB 提供事务支持,实现了 SQL 标准定义了四个隔离级别
  • MyISAM 不支持外键,InnoDB 支持
  • MyISAM 不支持 MVVC,InnoDB 支持
  • 虽然 MyISAM 引擎和 InnoDB 引擎都是使用 B+Tree 作为索引结构,但是两者的实现方式不太一样
  • MyISAM 不支持数据库异常崩溃后的安全恢复,InnoDB 支持
  • InnoDB 的性能比 MyISAM 更强大。

4.2 数据库事务

MySQL 事务隔离级别详解

Spring 事务详解

Spring 事务传播行为详解

4.3 数据库索引

MySQL 索引详解

《爱上面试官》系列 - 数据库索引

索引的使用

MySQL 的索引是怎么加速查询的?

合适建索引的字段:

  • 不为 NULL 的字段
  • 被频繁查询的字段
  • 被作为条件查询的字段
  • 被经常频繁用于连接的字段
  • 经常需要排序的列

不合适创建索引的字段:

  • 被频繁更新的字段
  • 不被经常查询的字段
  • 特大型表的话维护开销会很大,不适合建索引

其他注意:

  • 注意遵守最左前缀原则
  • 尽可能的考虑建立联合索引而不是单列索引
  • 注意避免冗余索引
  • 考虑在字符串类型的字段上使用前缀索引代替普通索引

4.4 SQL 语句的执行过程

见:SQL 语句在 MySQL 中的执行过程

4.5 MySQL 的锁机制

见:MySQL 锁机制简单了解一下

4.6 MVCC 多版本并发控制

MVCC 和事务隔离级别的关系

MySQL-InnoDB-MVCC 多版本并发控制

4.7 delete , truncate, drop 删除的区别

特点

delete

  • 删除指定数据:delete from test where username = 'abc'
  • 删除整个表:delete * from testdelete from test,仅删除表内的所有内容,保留表的定义,不释放空间,可以回滚恢复

dropdrop 表名,删除表,并释放空间,将表删除的一干二净。

truncatetruncate 表名,删除表里的内容,并释放空间,但表结构及其列、约束、索引等保持不变。

是否可以恢复?

  • delete 语句是数据库操作语言(DML),这个操作会放到 rollback segement 中,事务提交之后才生效,可以回滚。
  • truncatedrop 是数据库定义语言(DDL),操作立即生效,原数据不放到 rollback segment 中,不能回滚。

执行速度drop > truncate > delete

4.8 内连接、左连接、右连接

  • 内连接(inner join),交集

    1
    2
    3
    4
    5
    6
    7
    8
    select *
    from a_table a
    inner join b_table b on a.a_id = b.b_id;

    # 或省去 inner
    select *
    from a_table a
    join b_table b on a.a_id = b.b_id;

    以上语句等价于

    1
    2
    3
    4
    select *
    from a_table a,
    b_table b
    where a.a_id = b.b_id;
  • 左外连接(左连接,left outer join),差集

    1
    2
    3
    4
    5
    6
    7
    8
    select * 
    from a_table a
    left outer join b_table b on a.a_id = b.b_id;

    # 或省去 outer
    select *
    from a_table a
    left join b_table b on a.a_id = b.b_id;

    左连接,左表(a_table)的记录将会全部表示出来,而右表(b_table)只会显示符合搜索条件的记录,右表记录不足的地方均为 NULL。

  • 右外连接(右连接,right outer join),差集

    1
    2
    3
    4
    5
    6
    7
    8
    select * 
    from a_table a
    right outer join b_table b on a.a_id = b.b_id;

    # 或省去 outer
    select *
    from a_table a
    right join b_table b on a.a_id = b.b_id;

    与左连接相反,右连接,左表(a_table)只会显示符合搜索条件的记录,而右表(b_table)的记录将会全部表示出来。左表记录不足的地方均为 NULL。

    右连接可以与左连接等价互换

  • 合并(union),并集

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    /*
    union 联合 合并: 将多条查询语句的结果合并成一个结果

    语法:
    查询语句1
    union
    查询语句2
    union
    ...
    */

    SELECT *
    FROM employees
    WHERE email LIKE '%a%'
    UNION
    SELECT *
    FROM employees
    WHERE department_id > 90;

    union 特点:

    1. 要求多条查询语句的查询列数是一致的
    2. 要求多条查询语句的查询的每一列的类型和顺序最好一致
    3. union 关键字默认去重,如果使用 union all 可以包含重复项
    4. 用于要查询的结果来自于多个表,且多个表没有直接的连接关系,但查询的信息一致时

4.9 MySQL 中的常用函数

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
# 聚合函数
COUNT(col) # 统计查询结果的行数
MIN(col) # 查询指定列的最小值
MAX(col) # 查询指定列的最大值
SUM(col) # 求和,返回指定列的总和
AVG(col) # 求平均值,返回指定列数据的平均值

# 数值函数
ABS(x)  # 返回x的绝对值
BIN(x)  # 返回x的二进制
CEILING(x)  # 返回大于x的最小整数值
EXP(x)  # 返回值e(自然对数的底)的x次方
FLOOR(x)  # 返回小于x的最大整数值
GREATEST(x1,x2,...,xn)  # 返回集合中最大的值
LEAST(x1,x2,...,xn)  # 返回集合中最小的值
LN(x)  # 返回x的自然对数
LOG(x,y)  # 返回x的以y为底的对数
MOD(x,y)  # 返回x/y的模(余数)
PI()  # 返回pi的值(圆周率)
RAND()  # 返回0到1内的随机值,可以通过提供一个参数(种子)使RAND()随机数生成器生成一个指定的值
ROUND(x,y)  # 返回参数x的四舍五入的有y位小数的值
TRUNCATE(x,y)  # 返回数字x截短为y位小数的结果

# 字符串函数
LENGTH(s)  # 计算字符串长度函数,返回字符串的字节长度
CONCAT(s1, s2..., sn)  # 合并字符串函数,返回结果为连接参数产生的字符串,参数可以是一个或多个
INSERT(str, x, y, instr)  # 将字符串str从第x位置开始,y个字符长的子串替换为字符串instr,返回结果
LOWER(str)  # 将字符串中的字母转换为小写
UPPER(str)  # 将字符串中的字母转换为大写
LEFT(str, x)  # 返回字符串str中最左边的x个字符
RIGHT(str, x)  # 返回字符串str中最右边的x个字符
TRIM(str)  # 删除字符串左右两侧的空格
REPLACE(s,s1,s2)  # 使用字符串 s2 替换字符串 s 中所有的字符串 s1
SUBSTRING(s,n,len)  # 从字符串 s 返回一个长度同 len 字符相同的子字符串,起始于位置 n
REVERSE(str)  # 返回颠倒字符串str的结果

# 时间函数
CURDATE() / CURRENT_DATE() # 两个函数作用相同,返回当前系统的日期值
CURTIME() / CURRENT_TIME() # 两个函数作用相同,返回当前系统的时间值
NOW() / SYSDATE() # 两个函数作用相同,返回当前系统的日期和时间值
UNIX_TIMESTAMP() # 获取 UNIX 时间戳
TIMETOSEC() # 将时间参数转换为秒数
SECTOTIME() # 将秒数转换为时间
ADDTIME() # 时间加法运算,在原始时间上添加指定的时间
SUBTIME() # 时间减法运算,在原始时间上减去指定的时间

5 分布式

5.1 分布式架构

分布式系统的经典基础理论

三歪非要听我说完分布式才肯睡

5.2 分布式事务

小米 - 分布式事务,这一篇就够了

敖丙 - 分布式事务

  • 两阶段提交 / XA
  • TCC (Try-Confirm-Cancel)
  • 本地消息表
  • 可靠消息最终一致性 (消息事务)

5.3 Spring Cloud 技术图

6 Redis

缓存雪崩、穿透,缓存与数据库双写一致

妈妈再也不担心我面试被 Redis 问得脸都绿了

6.1 数据库的双写一致性问题

面试前必须要知道的 Redis 面试题

  • 先更新数据库,后删除缓存(多用)

    正常的情况是这样的:

    • 先操作数据库,成功;
    • 再删除缓存,也成功;

    如果原子性被破坏了:

    • 第一步成功(操作数据库),第二步失败(删除缓存),会导致数据库里是新数据,而缓存里是旧数据
    • 如果第一步(操作数据库)就失败了,我们可以直接返回错误(Exception),不会出现数据不一致。

    如果在高并发的场景下,出现数据库与缓存数据不一致的概率特别低,也不是没有:

    • 缓存刚好失效
    • 线程 A 查询数据库,得一个旧值
    • 线程 B 将新值写入数据库
    • 线程 B 删除缓存
    • 线程 A 将查到的旧值写入缓存

    要达成上述情况,还是说一句概率特别低

    因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大。

    删除缓存失败的解决思路

    • 将需要删除的 key 发送到消息队列中
    • 自己消费消息,获得需要删除的 key
    • 不断重试删除操作,直到成功
  • 先删除缓存,后更新数据库

    正常情况是这样的:

    • 先删除缓存,成功;
    • 再更新数据库,也成功;

    如果原子性被破坏了:

    • 第一步成功(删除缓存),第二步失败(更新数据库),数据库和缓存的数据还是一致的。
    • 如果第一步(删除缓存)就失败了,我们可以直接返回错误(Exception),数据库和缓存的数据还是一致的。

    看起来是很美好,但是我们在并发场景下分析一下,就知道还是有问题的了:

    • 线程 A 删除了缓存
    • 线程 B 查询,发现缓存已不存在
    • 线程 B 去数据库查询得到旧值
    • 线程 B 将旧值写入缓存
    • 线程 A 将新值写入数据库

    所以也会导致数据库和缓存不一致的问题。

    并发下解决数据库与缓存不一致的思路

    • 将删除缓存、修改数据库、读取缓存等的操作积压到队列里边,实现串行化

两种策略各自有优缺点:

  • 先删除缓存,再更新数据库

    在高并发下表现不如意,在原子性被破坏时表现优异

  • 先更新数据库,再删除缓存(Cache Aside Pattern 设计模式)

    在高并发下表现优异,在原子性被破坏时表现不如意

6.2 RDB 与 AOF

持久化:RDB 和 AOF 机制详解

Redis 持久化机制详解

RDB 优点

  • 只有一个文件 dump.rdb方便持久化
  • 容灾性好,一个文件可以保存到安全的磁盘。
  • 性能最大化fork 子进程来完成写操作,让主进程继续处理命令,所以使 IO 最大化。使用单独子进程来进行持久化,主进程不会进行任何 IO 操作,保证了 Redis 的高性能
  • 相对于数据集大时,比 AOF 的 启动效率 更高。

RDB 缺点

  • 数据安全性低。RDB 是间隔一段时间进行持久化,如果持久化之间 Redis 发生故障,会发生数据丢失。所以这种方式更适合数据要求不严谨的时候。

AOF 优点

  • 数据安全,aof 持久化可以配置 appendfsync 属性,有 always,每进行一次命令操作就记录到 aof 文件中一次。
  • 通过 append 模式写文件,即使中途服务器宕机,可以通过 redis-check-aof 工具解决数据一致性问题。
  • AOF 机制的 rewrite 模式。AOF 文件没被 rewrite 之前(文件过大时会对命令 进行合并重写),可以删除其中的某些命令(比如误操作的 flushall)

AOF 缺点

  • AOF 文件比 RDB 文件大,且恢复速度慢
  • 数据集大的时候,比 RDB 启动效率低

Redis 的数据恢复有着如下的优先级

  1. 如果只配置 AOF ,重启时加载 AOF 文件恢复数据;
  2. 如果同时配置了 RDB 和 AOF ,启动只加载 AOF 文件恢复数据;
  3. 如果只配置 RDB,启动将加载 dump 文件恢复数据。

有 AOF 就用 AOF,没有就 RDB

6.3 数据结构

Redis 数据类型

数据类型:5 种基础数据类型详解

数据结构:底层数据结构详解

6.4 Redis 集群

高可拓展:分片技术(Redis Cluster)详解

史上最强「集群」入门实践教程

6.5 Redis 过期 key 的删除策略

  • 定时删除:在设置键的过期时间的同时,创建一个定时器,过期时间到达时,由定时器任务立即执行对应键的删除操作。
  • 惰性删除:放任键过期不管,但是每次获取键时,都检查取得的键是否过期,如果过期的话,就删除该键;如果没有过期,就返回该键。
  • 定期删除:周期性轮训 Redis 库中的时效性数据,采用随机抽取的策略,利用过期数据占比的方式控制删除频度。
删除策略 内存占用 CPU 占用 特点
定时删除 节约内存,无占用 不分时段占用 CPU 资源,频度高 用时间换空间
惰性删除 内存占用严重 延时执行,CPU 利用率高 用空间换时间
定期删除 内存定期随机清理 每秒花费固定的 CPU 资源维护内存 随机抽查,重点抽查

6.6 内存淘汰机制

  • volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 key
  • allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)
  • volatile-lfu:当内存不足以容纳新写入数据时,在过期密集的键中,使用 LFU 算法进行删除 key。
  • allkeys-lfu:当内存不足以容纳新写入数据时,使用 LFU 算法移除所有的 key。
  • volatile-random:当内存不足以容纳新写入数据时,在设置了过期的键中,随机删除一个 key。
  • allkeys-random:当内存不足以容纳新写入数据时,随机删除一个或者多个 key。
  • volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 key 优先移除。
  • noeviction:当内存不足以容纳新写入数据时,新写入操作会报错。

6.7 scan 命令

1 亿个 key,其中有 10 万个 key 是以某个固定的已知的前缀开头的,如果将它们全部找出来?

使用 keys 指令可以扫出指定模式的 key 列表。但是它有两个主要的缺点:

  1. 没有 offset、limit 参数,一次性吐出所有满足条件的 key

  2. keys 算法是遍历算法,复杂度是 O (n),如果实例中有千万级以上的 key,这个指令就会导致 Redis 服务卡顿,所有读写 Redis 的其它的指令都会被延后甚至会超时报错,因为 Redis 是单线程程序,顺序执行所有指令,其它指令必须等到当前的 keys 指令执行完了才可以继续。

scan 相比 keys 具备有以下特点:

  • 复杂度虽然也是 O (n),但是它是通过游标分步进行的,不会阻塞线程
  • 提供 limit 参数,可以控制每次返回结果的最大条数
  • 同 keys 一样,它也提供模式匹配功能
  • 服务器不需要为游标保存状态,游标的唯一状态就是 scan 返回给客户端的游标整数
  • 返回的结果可能会有重复,需要客户端去重复,这点非常重要
  • 遍历的过程中如果有数据修改,改动后的数据能不能遍历到是不确定的
  • 单次返回的结果是空的并不意味着遍历结束,而要看返回的游标值是否为 0

scan 指令可以无阻塞的提取出指定模式的 key 列表,但是会有一定的重复概率,在客户端做一次去重就可以了 ,但是整体所花费的时间会比直接用 keys 指令长。

6.8 异步队列和延迟队列

异步队列

Redis 通过 list 数据结构来实现消息队列,主要使用到如下命令:

  • lpushrpush 入队列
  • lpoprpop 出队列
  • blpopbrpop 阻塞式出队列

当队列里面没有消息时,使用 sleep 阻塞一段时间,但是 sleep 会导致消息的处理延迟增加。这个问题我们可以通过 blpop / brpop 来阻塞式读取队列。

blpop / brpop 在队列没有数据的时候,会立即进入休眠状态,一旦数据到来,则立刻醒过来。消息的延迟几乎为零。

延迟队列

如下场景:

  • 订单下单后超过一小时用户未支付,需要关闭订单
  • 订单的评论如果 7 天未评价,系统需要自动产生一条评论

可以使用延时队列,顾名思义就是需要延迟一段时间后执行。Redis 可通过 zset 实现。想要执行时间的时间戳作为 score,消息内容作为 key 调用 zadd 来生产消息,消费者用 zrangebyscore 指令获取 N 秒之前的数据轮询进行处理。

6.9 Redis 线程模型

Redis 线程模型

为什么 Redis 选择单线程模型

美团二面:Redis 究竟是单线程还是多线程?

7 Spring 全家桶

7.1 Spring 中依赖注入的方式

浅谈 Spring 为什么推荐使用构造器注入

常见的三种注入方式:

  • field 注入
  • 构造器注入(Spring 推荐)
  • setter 注入

构造器注入的好处:

  1. 保证依赖不可变(final 关键字)
  2. 保证依赖不为空(省去了我们对其检查)
  3. 保证返回客户端(调用)的代码的时候是完全初始化的状态
  4. 避免了循环依赖(如果有循环依赖会主动提醒)
  5. 提升了代码的可复用性

7.2 Spring 如何解决循环依赖

Spring 中的循环依赖及解决

7.3 SpringBoot 的启动流程

面试官:说一下 Spring Boot 的启动过程吧

SpringBoot 的启动流程是怎样的?

processOn

  1. 创建 SpringApplication 对象

    • 确定应用类型(Servlet、Reactive、None)
    • 设置初始化器(Initializers)
    • 设置监听器(Listeners)
  2. 执行 SpringApplication 对象的 run 方法

  • 获取监听器,发送 ApplicationStartingEvent 事件
  • 准备环境 (prepareEnvironment),创建 StandardServletEnvironment 对象,发送 ApplicationEnvironmentPreparedEvent 事件
  • 创建 IOC 容器 ApplicationContext (createApplicationContext),类名为 AnnotationConfigServletWebServerApplicationContext
  • 准备容器 (prepareContext),调用初始化器,发送事件 ApplicationPreparedEvent,加载资源,发送事件 ApplicationPreparedEvent
  • 刷新容器 (refreshContext) – 重要
    • 获取 beanFactory (obtainFreshBeanFactory),为 DefaultListableBeanFactory
    • 准备 beanFactory (prepareBeanFactory)
    • 调用 beanFactory 后置处理 (invokeBeanFactoryPostProcessors),先处理 BeanDefinitionRegistryPostProcessor 相关的,调用 postProcessBeanDefinitionRegistry 处理 Bean 的定义(在 Bean 定义注册之前);后处理 BeanFactoryPostProcessor 相关的,调用 postProcessBeanFactory(在 Bean 定义之后、实例化之前)
    • 注册 Bean 的后置处理器 (registerBeanPostProcessors),创建了 BeanPostProcessor 类型的对象
    • 初始化 MessageSource 组件 (initMessageSource)
    • 初始化 ApplicationEventMulticaster (initApplicationEventMulticaster)
    • 初始化特殊的 Bean (onRefresh),执行 ServletWebServerApplicationContext#onRefresh 启动 tomcat
    • 注册监听器 (registerListeners)
    • 初始化剩下的普通单例 (finishBeanFactoryInitialization)
    • 容器刷新完成 (finishRefresh)
  • 发送 ApplicationStartedEvent 事件
  • 回调 Runner 方法 (callRunners),ApplicationRunner、CommandLineRunner
  • 返回容器

SpringApplication 的创建

SpringApplication 的启动

SpringBoot 完整启动过程

7.4 过滤器和拦截器有什么区别

什么是过滤器(Filter)?

与 Servlet 相似,过滤器是一些 web 应用程序组件,可以绑定到一个 web 应用程序中。但是与其他 web 应用组件不同的是,过滤器是 “链” 在容器的处理过程中的。这就意味着它们可以在请求达到 Servlet 之前对其进行访问,也可以在响应信息返回到客户端之前对其进行拦截。这种访问使得过滤器可以检查并修改请求和响应的内容。

什么是拦截器(Interceptor)?

拦截器是 AOP 的一种实现策略,用于在某个方法或字段被访问前对它进行拦截,然后在其之前或之后加上某些操作。同 Filter 一样,Interceptor 也是链式调用。每个 Interceptor 的调用会依据它的声明顺序依次执行。一般来说拦截器可以用于以下方面 :

  • 日志记录 :记录请求信息的日志,以便进行信息监控、信息统计等
  • 权限检查 :对用户的访问权限,认证,或授权等进行检查
  • 性能监控 :通过拦截器在进入处理器前后分别记录开始时间和结束时间,从而得到请求的处理时间
  • 通用行为 :读取 cookie 得到用户信息并将用户对象放入请求头中,从而方便后续流程使用

区别?

两者最大的区别在于:过滤器是在 Servlet 规范中定义的,是由 Servlet 容器支持的,只能用于过滤请求;拦截器是在 Spring 容器内的,由 Spring 框架支持。

  • 过滤器作用于请求到达 Servlet 之前,在 Spring 中也就是在 DispacherServlet 之前。
  • 拦截器最早只能作用于请求到达 Servlet 之后。

拦截器作为 Spring 的一个组件,可以通过 IOC 容器进行管理,获取其中的各个 bean 实例,对 Spring 中的各种资源、对象,如 Service 对象、数据源、事务管理等进行调用;而过滤器则不能。

总的来说,两者主要在如下方面存在着差异 :

  • 过滤器是基于函数的回调,而拦截器是基于 Java 反射机制的
  • 过滤器可以修改 Request,而拦截器则不能
  • 过滤器需要在 Servlet 容器中实现,拦截器可以适用于 JavaEE、JavaSE 等各种环境
  • 拦截器可以调用 IOC 容器中的各种依赖,而过滤器不能
  • 过滤器只能在请求的前后使用,而拦截器可以详细到每个方法

Spring 中的过滤器与拦截器

7.5 SpringBoot 自动配置原理

SpringBoot 自动装配原理详解

7.6 Spring IoC 容器中 Bean 的生命周期

如何记忆 Spring Bean 的生命周期

Bean 的生命周期概括起来就是 4 个阶段

  1. 实例化(Instantiation)
  2. 属性赋值(Populate)
  3. 初始化(Initialization)
  4. 销毁(Destruction)

  1. 实例化:第 1 步,实例化一个 bean 对象;
  2. 属性赋值:第 2 步,为 bean 设置相关属性和依赖;
  3. 初始化:第 3~7 步,步骤较多,其中第 5、6 步为初始化操作,第 3、4 步为在初始化前执行,第 7 步在初始化后执行,该阶段结束,才能被用户使用;
  4. 销毁:第 8~10 步,第 8 步不是真正意义上的销毁(还没使用呢),而是先在使用前注册了销毁的相关调用接口,为了后面第 9、10 步真正销毁 bean 时再执行相应的方法。

7.7 SpringMVC 的工作流程

SpringMVC 执行流程及工作原理

8 计算机网络

8.1 HTTP 版本

HTTP/2

HTTP/1.1 和 HTTP/2.0 有什么区别

HTTP/2.0 和 HTTP/3.0 有什么区别

8.2 HTTPS

HTTP 和 HTTPS 有什么区别

HTTP vs HTTPS

HTTPs

8.3 HTTP 方法

HTTP 方法

8.4 HTTP 的头

HTTP 首部

8.5 HTTP 状态码

HTTP 状态码

8.6 TCP 与 UDP

TCP 与 UDP

TCP 三次握手和四次挥手

8.7 输入 URL 到页面加载过程

从输入 URL 到页面展示到底发生了什么

输入 URL 到页面加载过程详解

9 Linux

9.1 常用命令

查看剩余内存:free -h

显示目前所有文件系统的可用空间及使用情形:df -h

查看某个路径下所有文件大小:du -h --max-depth=1 .

chown

chmod

mount

tail

cat

9.2 修改执行权限

通过 ls -lah 命令可以查看某个目录下的文件或目录的权限。

第一列的内容的信息解释如下:

文件的类型:

  • d: 代表目录
  • -: 代表文件
  • l: 代表软链接(可以认为是 window 中的快捷方式)

Linux 中权限分为以下几种

  • r:代表权限是可读,r 也可以用数字 4 表示
  • w:代表权限是可写,w 也可以用数字 2 表示
  • x:代表权限是可执行,x 也可以用数字 1 表示

文件和目录权限的区别:

对文件和目录而言,读写执行表示不同的意义。

对于文件:

权限名称 可执行操作
r 可以使用 cat 查看文件的内容
w 可以修改文件的内容
x 可以将其运行为二进制文件

对于目录:

权限名称 可执行操作
r 可以查看目录下列表
w 可以创建和删除目录下文件
x 可以使用 cd 进入目录

需要注意的是: 超级用户可以无视普通用户的权限,即使文件目录权限是 000,依旧可以访问。

修改文件 / 目录的权限的命令chmod

示例:修改 aaa.txt 的权限为文件所有者有全部权限,文件所有者所在的组有读写权限,其他用户只有读的权限。

chmod u=rwx,g=rw,o=r aaa.txt 或者 chmod 764 aaa.txt

10 MyBatis

MyBatis 面试常见问题

10.1 MyBatis 怎么防止 SQL 注入

MyBatis 中使用 #{} 防止 SQL 注入

1
2
3
4
5
6
7
@ResultMap("BaseResultMap")
@Select("select * from user where username = #{username}")
User getUserByParas1(@Param("username") String username);

@ResultMap("BaseResultMap")
@Select("select * from user where username = ${username}")
List<User> getUserByParas2(@Param("username") String username);

#{} 使用了 PreparedStatement 来进行预处理,然后通过 set 的方式对占位符进行设置,而 ${} 则是通过 Statement 直接进行查询,当有参数时直接拼接进行查询。

  • Mybatis 中使用 #{} 可以防止 SQL 注入,${} 并不能防止 SQL 注入
  • Mybatis 实现防止 SQL 注入的原理是调用了 JDBC 中的 PreparedStatement 来进行预处理。

10.2 实体类的属性名和表中的字段名不一样怎么办

TO DO

10.3 缓存

TO DO

11 消息队列

11.1 为什么要使用消息队列

消息队列(一)为什么要使用消息队列?

11.2 如何保证消息的顺序性

消息队列(五)如何保证消息的顺序性?

其他

红黑树

红黑树特点

  1. 每个节点非红即黑
  2. 根节点总是黑色的
  3. 每个叶子节点都是黑色的空节点(NIL 节点)
  4. 如果节点是红色的,则它的子节点必须是黑色的(反之不一定)
  5. 从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)

红黑树的应用

TreeMap、TreeSet 以及 JDK1.8 的 HashMap、ConcurrentHashMap 底层都用到了红黑树。

为什么要用红黑树?

简单来说红黑树就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。

图解红黑树

排序算法

常见排序算法知识体系详解

排序算法时间复杂度、空间复杂度、稳定性比较

排序算法 平均时间复杂度 最坏时间复杂度 最好时间复杂度 空间复杂度 稳定性
冒泡排序 O(n²) O(n²) O(n) O(1) 稳定
直接选择排序 O(n²) O(n²) O(n²) O(1) 不稳定
直接插入排序 O(n²) O(n²) O(n) O(1) 稳定
快速排序 O(nlogn) O(n²) O(nlogn) O(nlogn) 不稳定
堆排序 O(nlogn) O(nlogn) O(nlogn) O(1) 不稳定
希尔排序 O(nlogn) O(n²) O(n) O(1) 不稳定
归并排序 O(nlogn) O(nlogn) O(nlogn) O(n) 稳定
计数排序 O(n+k) O(n+k) O(n+k) O(n+k) 稳定
基数排序 O(N*M) O(N*M) O(N*M) O(M) 稳定

注:基数排序时间复杂度为 O (N*M),其中 N 为数据个数,M 为数据位数。

直接选择排序的时间复杂度与初始序列无关,都为 O (n²)

辅助记忆

  • 时间复杂度记忆

    1. 冒泡、选择、插入排序需要两个 for 循环,每次只关注一个元素,平均时间复杂度为 O (n²)(一遍找元素 O (n),一遍找位置 O (n))
    2. 快速、归并、希尔、堆基于二分思想,log 以 2 为底,平均时间复杂度为 O (nlogn)(一遍找元素 O (n),一遍找位置 O (logn))
  • 稳定性记忆

    快速排序、希尔排序、选择排序、堆排序,不稳定

    “快希选堆”(快牺牲稳定性)

快速排序是最快的通用排序算法,它的内循环的指令很少,而且它还能利用缓存,因为它总是顺序地访问数据。它的运行时间近似为~cNlogN,这里的 c 比其它线性对数级别的排序算法都要小。

使用三向切分快速排序,实际应用中可能出现的某些分布的输入能够达到线性级别,而其它排序算法仍然需要线性对数时间。

LRU 算法

LRU 原理和 Redis 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.util.LinkedHashMap;
import java.util.Map;

public class LRU<K, V> extends LinkedHashMap<K, V> {

private final int CACHE_SIZE;

public LRU(int cacheSize) {
// true 表示按访问顺序排序,最近访问放在头部,最老访问放在尾部
super((int)Math.ceil(cacheSize / 0.75) + 1, 0.75f, true);
CACHE_SIZE = cacheSize;
}

@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
// 当容量大于设定好的 cache size 时删除最早的节点
return size() > CACHE_SIZE;
}
}

设计模式

图说设计模式

面试官:“谈谈 Spring 中都用到了那些设计模式?”

单例模式

好处:

  • 对于频繁使用的对象,可以省略创建对象所花费的时间,这对于那些重量级对象而言,是非常可观的一笔系统开销
  • 由于 new 操作的次数减少,因而对系统内存的使用频率也会降低,这将减轻 GC 压力,缩短 GC 停顿时间。

1、饿汉式:全局的单例实例在类装载时构建(线程安全)

1
2
3
4
5
6
7
8
9
10
11
public class Singleton {
private static final Singleton INSTANCE = new Singleton();

// 用户无法通过 new 方法创建该对象实例
private Singleton() {
}

public static Singleton getInstence() {
return INSTANCE;
}
}

2、懒汉式(线程不安全)

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Singleton {
private static Singleton instance;

private Singleton() {
}

public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}

3、懒汉式:双重检查(线程安全)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Singleton {
// volatile保证当 instance 变量被初始化成 Singleton 实例时,多个线程可以正确处理 instance 变量
private volatile static Singleton instance;

private Singleton() {
}

public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

4、懒汉式:静态内部类(线程安全)

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Singleton {

private Singleton() {
}

private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}

public static Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}

5、枚举(线程安全,也能防止反序列化导致重新创建新的对象)

1
2
3
public enum Singleton{
INSTANCE;
}

工厂模式

观察者模式

求最大公约数和最小公倍数

最大公约数 GCD:

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
/**
* 递归求最大公约数
*/
public int GCD(int a, int b) {
if (b == 0) {
return a;
}
return GCD(b, a % b);
}

/**
* 辗转相除法求最大公约数
* 有两整数 a 和 b:
* 1. a % b 得余数 c
* 2. 若 c = 0,则 b 即为两数的最大公约数
* 3. 若 c != 0,则 a = b,b = c,再回去执行 1
*/
public int divisionGCD(int a, int b) {
int c;
while (b != 0) {
c = a % b;
a = b;
b = c;
}
return a;
}

/**
* 相减法求最大公约数
* 有两整数 a 和 b:
* 1. 若 a > b,则 a = a - b
* 2. 若 a < b,则 b = b - a
* 3. 若 a = b,则 a(或 b)即为两数的最大公约数
* 4. 若 a != b,则再回去执行 1
*/
public int subtractionGCD(int a, int b) {
while (a != b) {
if (a > b) {
a = a - b;
} else {
b = b - a;
}
}
return a;
}

最小公倍数 LCM:

1
2
3
public int LCM(int a, int b) {
return a * b / GCD(a, b);
}

多线程编程

用三个线程,顺序打印字母 A-Z,输出结果是 1A 2B 3C 1D 2E…

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
public class Test {

private static char c = 'A';
private static int i = 0;

public static void main(String[] args) {
Runnable runnable = new Runnable() {
@Override
public void run() {
synchronized (this) {
try {
int id = Integer.parseInt(Thread.currentThread().getName());
while (i < 26) {
if (i % 3 == id - 1) {
System.out.println("线程: " + id + " " + c++);
i++;
notifyAll();
} else {
wait();
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
new Thread(runnable, "1").start();
new Thread(runnable, "2").start();
new Thread(runnable, "3").start();
}
}

一些容易忘掉的知识点 - Java

https://morooi.com/2020/Java-面试/

作者

SJ Zhou

发布于

2020-08-16

更新于

2023-08-21

许可协议

评论