Java语言

1. Java基础

1.1 为什么Java代码可以实现一次编写、到处运行?

参考答案

JVM(Java虚拟机)是Java跨平台的关键。

在程序运行前,Java源代码(.java)需要经过编译器编译成字节码(.class)。在程序运行时,JVM负责将字节码翻译成特定平台下的机器码并运行,也就是说,只要在不同的平台上安装对应的JVM,就可以运行字节码文件。

同一份Java源代码在不同的平台上运行,它不需要做任何的改变,并且只需要编译一次。而编译好的字节码,是通过JVM这个中间的“桥梁”实现跨平台的,JVM是与平台相关的软件,它能将统一的字节码翻译成该平台的机器码。

注意事项

  1. 编译的结果是生成字节码、不是机器码,字节码不能直接运行,必须通过JVM翻译成机器码才能运行;
  2. 跨平台的是Java程序、而不是JVM,JVM是用C/C++开发的软件,不同平台下需要安装不同版本的JVM。

1.2 一个Java文件里可以有多个类吗(不含内部类)?

参考答案

  1. 一个java文件里可以有多个类,但最多只能有一个被public修饰的类;
  2. 如果这个java文件中包含public修饰的类,则这个类的名称必须和java文件名一致。

1.3 说一说你对Java访问权限的了解

参考答案

Java语言为我们提供了三种访问修饰符,即private、protected、public,在使用这些修饰符修饰目标时,一共可以形成四种访问权限,即private、default、protected、public,注意在不加任何修饰符时为default访问权限。

在修饰成员变量/成员方法时,该成员的四种访问权限的含义如下:

  • private:该成员可以被该类内部成员访问;
  • default:该成员可以被该类内部成员访问,也可以被同一包下其他的类访问;
  • protected:该成员可以被该类内部成员访问,也可以被同一包下其他的类访问,还可以被它的子类访问;
  • public:该成员可以被任意包下,任意类的成员进行访问。

在修饰类时,该类只有两种访问权限,对应的访问权限的含义如下:

  • default:该类可以被同一包下其他的类访问;
  • public:该类可以被任意包下,任意的类所访问。

1.4 介绍一下Java的数据类型

参考答案

Java数据类型包括基本数据类型和引用数据类型两大类。

基本数据类型有8个,可以分为4个小类,分别是整数类型(byte/short/int/long)、浮点类型(float/double)、字符类型(char)、布尔类型(boolean)。其中,4个整数类型中,int类型最为常用。2个浮点类型中,double最为常用。另外,在这8个基本类型当中,除了布尔类型之外的其他7个类型,都可以看做是数字类型,它们相互之间可以进行类型转换。

引用类型就是对一个对象的引用,根据引用对象类型的不同,可以将引用类型分为3类,即数组、类、接口类型。引用类型本质上就是通过指针,指向堆中对象所持有的内存空间,只是Java语言不再沿用指针这个说法而已。

扩展阅读

对于基本数据类型,你需要了解每种类型所占据的内存空间,面试官可能会追问这类问题:

  • byte:1字节(8位),数据范围是 -2^7 ~ 2^7-1。
  • short:2字节(16位),数据范围是 -2^15 ~ 2^15-1。
  • int:4字节(32位),数据范围是 -2^31 ~ 2^31-1。
  • long:8字节(64位),数据范围是 -2^63 ~ 2^63-1。
  • float:4字节(32位),数据范围大约是 -3.410^38 ~ 3.410^38。
  • double:8字节(64位),数据范围大约是 -1.810^308 ~ 1.810^308。
  • char:2字节(16位),数据范围是 \u0000 ~ \uffff。
  • boolean:Java规范没有明确的规定,不同的JVM有不同的实现机制。

对于引用数据类型,你需要了解JVM的内存分布情况,知道引用以及引用对象存放的位置,详见JVM部分的题目。

1.5 int类型的数据范围是多少?

参考答案

int类型占4字节(32位),数据范围是 -2^31 ~ 2^31-1。

1.6 请介绍全局变量和局部变量的区别

参考答案

Java中的变量分为成员变量和局部变量,它们的区别如下:

成员变量:

  1. 成员变量是在类的范围里定义的变量;
  2. 成员变量有默认初始值;
  3. 未被static修饰的成员变量也叫实例变量,它存储于对象所在的堆内存中,生命周期与对象相同;
  4. 被static修饰的成员变量也叫类变量,它存储于方法区中,生命周期与当前类相同。

局部变量:

  1. 局部变量是在方法里定义的变量;
  2. 局部变量没有默认初始值;
  3. 局部变量存储于栈内存中,作用的范围结束,变量空间会自动的释放。

注意事项

Java中没有真正的全局变量,面试官应该是出于其他语言的习惯说全局变量的,他的本意应该是指成员变量。

1.7 请介绍一下实例变量的默认值

参考答案

实例变量若为引用数据类型,其默认值一律为null。若为基本数据类型,其默认值如下:

  • byte:0
  • short:0
  • int:0
  • long:0L
  • float:0.0F
  • double:0.0
  • char:‘\u0000’
  • boolean:false

注意事项

上述默认值规则适用于所有的成员变量,所以对于类变量也是适用的。

1.8 为啥要有包装类?

参考答案

Java语言是面向对象的语言,其设计理念是“一切皆对象”。但8种基本数据类型却出现了例外,它们不具备对象的特性。正是为了解决这个问题,Java为每个基本数据类型都定义了一个对应的引用类型,这就是包装类。

扩展阅读

Java之所以提供8种基本数据类型,主要是为了照顾程序员的传统习惯。这8种基本数据类型的确带来了一定的方便性,但在某些时候也会受到一些制约。比如,所有的引用类型的变量都继承于Object类,都可以当做Object类型的变量使用,但基本数据类型却不可以。如果某个方法需要Object类型的参数,但实际传入的值却是数字的话,就需要做特殊的处理了。有了包装类,这种问题就可以得以简化。

1.9 说一说自动装箱、自动拆箱的应用场景

参考答案

自动装箱、自动拆箱是JDK1.5提供的功能。

自动装箱:可以把一个基本类型的数据直接赋值给对应的包装类型;

自动拆箱:可以把一个包装类型的对象直接赋值给对应的基本类型;

通过自动装箱、自动拆箱功能,可以大大简化基本类型变量和包装类对象之间的转换过程。比如,某个方法的参数类型为包装类型,调用时我们所持有的数据却是基本类型的值,则可以不做任何特殊的处理,直接将这个基本类型的值传入给方法即可。

1.10 如何对Integer和Double类型判断相等?

参考答案

Integer、Double不能直接进行比较,这包括:

  • 不能用==进行直接比较,因为它们是不同的数据类型;
  • 不能转为字符串进行比较,因为转为字符串后,浮点值带小数点,整数值不带,这样它们永远都不相等;
  • 不能使用compareTo方法进行比较,虽然它们都有compareTo方法,但该方法只能对相同类型进行比较。

整数、浮点类型的包装类,都继承于Number类型,而Number类型分别定义了将数字转换为byte、short、int、long、float、double的方法。所以,可以将Integer、Double先转为转换为相同的基本数据类型(如double),然后使用==进行比较。

示例代码

Integer i = ``100``;``Double d = ``100.00``;``System.out.println(i.doubleValue() == d.doubleValue());

1.11 int和Integer有什么区别,二者在做==运算时会得到什么结果?

参考答案

int是基本数据类型,Integer是int的包装类。二者在做==运算时,Integer会自动拆箱为int类型,然后再进行比较。届时,如果两个int值相等则返回true,否则就返回false。

1.12 说一说你对面向对象的理解

参考答案

面向对象是一种更优秀的程序设计方法,它的基本思想是使用类、对象、继承、封装、消息等基本概念进行程序设计。它从现实世界中客观存在的事物出发来构造软件系统,并在系统构造中尽可能运用人类的自然思维方式,强调直接以现实世界中的事物为中心来思考,认识问题,并根据这些事物的本质特点,把它们抽象地表示为系统中的类,作为系统的基本构成单元,这使得软件系统的组件可以直接映像到客观世界,并保持客观世界中事物及其相互关系的本来面貌。

扩展阅读

结构化程序设计方法主张按功能来分析系统需求,其主要原则可概括为自顶向下、逐步求精、模块化等。结构化程序设计首先采用结构化分析方法对系统进行需求分析,然后使用结构化设计方法对系统进行概要设计、详细设计,最后采用结构化编程方法来实现系统。

因为结构化程序设计方法主张按功能把软件系统逐步细分,因此这种方法也被称为面向功能的程序设计方法;结构化程序设计的每个功能都负责对数据进行一次处理,每个功能都接受一些数据,处理完后输出一些数据,这种处理方式也被称为面向数据流的处理方式。

结构化程序设计里最小的程序单元是函数,每个函数都负责完成一个功能,用以接收一些输入数据,函数对这些输入数据进行处理,处理结束后输出一些数据。整个软件系统由一个个函数组成,其中作为程序入口的函数被称为主函数,主函数依次调用其他普通函数,普通函数之间依次调用,从而完成整个软件系统的功能。

每个函数都是具有输入、输出的子系统,函数的输入数据包括函数形参、全局变量和常量等,函数的输出数据包括函数返回值以及传出参数等。结构化程序设计方式有如下两个局限性:

  • 设计不够直观,与人类习惯思维不一致。采用结构化程序分析、设计时,开发者需要将客观世界模型分解成一个个功能,每个功能用以完成一定的数据处理。
  • 适应性差,可扩展性不强。由于结构化设计采用自顶向下的设计方式,所以当用户的需求发生改变,或需要修改现有的实现方式时,都需要自顶向下地修改模块结构,这种方式的维护成本相当高。

1.13 面向对象的三大特征是什么?

参考答案

面向对象的程序设计方法具有三个基本特征:封装、继承、多态。其中,封装指的是将对象的实现细节隐藏起来,然后通过一些公用方法来暴露该对象的功能;继承是面向对象实现软件复用的重要手段,当子类继承父类后,子类作为一种特殊的父类,将直接获得父类的属性和方法;多态指的是子类对象可以直接赋给父类变量,但运行时依然表现出子类的行为特征,这意味着同一个类型的对象在执行同一个方法时,可能表现出多种行为特征。

扩展阅读

抽象也是面向对象的重要部分,抽象就是忽略一个主题中与当前目标无关的那些方面,以便更充分地注意与当前目标有关的方面。抽象并不打算了解全部问题,而只是考虑部分问题。例如,需要考察Person对象时,不可能在程序中把Person的所有细节都定义出来,通常只能定义Person的部分数据、部分行为特征,而这些数据、行为特征是软件系统所关心的部分。

1.14 封装的目的是什么,为什么要有封装?

参考答案

封装是面向对象编程语言对客观世界的模拟,在客观世界里,对象的状态信息都被隐藏在对象内部,外界无法直接操作和修改。对一个类或对象实现良好的封装,可以实现以下目的:

  • 隐藏类的实现细节;
  • 让使用者只能通过事先预定的方法来访问数据,从而可以在该方法里加入控制逻辑,限制对成员变量的不合理访问;
  • 可进行数据检查,从而有利于保证对象信息的完整性;
  • 便于修改,提高代码的可维护性。

扩展阅读

为了实现良好的封装,需要从两个方面考虑:

  • 将对象的成员变量和实现细节隐藏起来,不允许外部直接访问;
  • 把方法暴露出来,让方法来控制对这些成员变量进行安全的访问和操作。

封装实际上有两个方面的含义:把该隐藏的隐藏起来,把该暴露的暴露出来。这两个方面都需要通过使用Java提供的访问控制符来实现。

1.15 说一说你对多态的理解

参考答案

因为子类其实是一种特殊的父类,因此Java允许把一个子类对象直接赋给一个父类引用变量,无须任何类型转换,或者被称为向上转型,向上转型由系统自动完成。

当把一个子类对象直接赋给父类引用变量时,例如 BaseClass obj = new SubClass();,这个obj引用变量的编译时类型是BaseClass,而运行时类型是SubClass,当运行时调用该引用变量的方法时,其方法行为总是表现出子类方法的行为特征,而不是父类方法的行为特征,这就可能出现:相同类型的变量、调用同一个方法时呈现出多种不同的行为特征,这就是多态。

扩展阅读

多态可以提高程序的可扩展性,在设计程序时让代码更加简洁而优雅。

例如我要设计一个司机类,他可以开轿车、巴士、卡车等等,示例代码如下:

class` `Driver {``  ``void` `drive(Car car) { ... }``  ``void` `drive(Bus bus) { ... }``  ``void` `drive(Truck truck) { ... }``}

在设计上述代码时,我已采用了重载机制,将方法名进行了统一。这样在进行调用时,无论要开什么交通工具,都是通过driver.drive(obj)这样的方式来调用,对调用者足够的友好。

但对于程序的开发者来说,这显得繁琐,因为实际上这个司机可以驾驶更多的交通工具。当系统需要为这个司机增加车型时,开发者就需要相应的增加driver方法,类似的代码会堆积的越来越多,显得臃肿。

采用多态的方式来设计上述程序,就会变得简洁很多。我们可以为所有的交通工具定义一个父类Vehicle,然后按照如下的方式设计drive方法。调用时,我们可以传入Vehicle类型的实例,也可以传入任意的Vehicle子类型的实例,对于调用者来说一样的方便,但对于开发者来说,代码却变得十分的简洁了。

class` `Driver {``  ``void` `drive(Vehicle vehicle) { ... }``}

1.16 Java中的多态是怎么实现的?

参考答案

多态的实现离不开继承,在设计程序时,我们可以将参数的类型定义为父类型。在调用程序时,则可以根据实际情况,传入该父类型的某个子类型的实例,这样就实现了多态。对于父类型,可以有三种形式,即普通的类、抽象类、接口。对于子类型,则要根据它自身的特征,重写父类的某些方法,或实现抽象类/接口的某些抽象方法。

1.17 Java为什么是单继承,为什么不能多继承?

参考答案

首先,Java是单继承的,指的是Java中一个类只能有一个直接的父类。Java不能多继承,则是说Java中一个类不能直接继承多个父类。

其次,Java在设计时借鉴了C++的语法,而C++是支持多继承的。Java语言之所以摒弃了多继承的这项特征,是因为多继承容易产生混淆。比如,两个父类中包含相同的方法时,子类在调用该方法或重写该方法时就会迷惑。

准确来说,Java是可以实现"多继承"的。因为尽管一个类只能有一个直接父类,但是却可以有任意多个间接的父类。这样的设计方式,避免了多继承时所产生的混淆。

1.18 说一说重写与重载的区别

参考答案

重载发生在同一个类中,若多个方法之间方法名相同、参数列表不同,则它们构成重载的关系。重载与方法的返回值以及访问修饰符无关,即重载的方法不能根据返回类型进行区分。

重写发生在父类子类中,若子类方法想要和父类方法构成重写关系,则它的方法名、参数列表必须与父类方法相同。另外,返回值要小于等于父类方法,抛出的异常要小于等于父类方法,访问修饰符则要大于等于父类方法。还有,若父类方法的访问修饰符为private,则子类不能对其重写。

1.19 构造方法能不能重写?

参考答案

构造方法不能重写。因为构造方法需要和类保持同名,而重写的要求是子类方法要和父类方法保持同名。如果允许重写构造方法的话,那么子类中将会存在与类名不同的构造方法,这与构造方法的要求是矛盾的。

1.20 介绍一下Object类中的方法

参考答案

Object类提供了如下几个常用方法:

  • Class<?> getClass():返回该对象的运行时类。
  • boolean equals(Object obj):判断指定对象与该对象是否相等。
  • int hashCode():返回该对象的hashCode值。在默认情况下,Object类的hashCode()方法根据该对象的地址来计算。但很多类都重写了Object类的hashCode()方法,不再根据地址来计算其hashCode()方法值。
  • String toString():返回该对象的字符串表示,当程序使用System.out.println()方法输出一个对象,或者把某个对象和字符串进行连接运算时,系统会自动调用该对象的toString()方法返回该对象的字符串表示。Object类的toString()方法返回 运行时类名@十六进制hashCode值 格式的字符串,但很多类都重写了Object类的toString()方法,用于返回可以表述该对象信息的字符串。

另外,Object类还提供了wait()、notify()、notifyAll()这几个方法,通过这几个方法可以控制线程的暂停和运行。Object类还提供了一个clone()方法,该方法用于帮助其他对象来实现“自我克隆”,所谓“自我克隆”就是得到一个当前对象的副本,而且二者之间完全隔离。由于该方法使用了protected修饰,因此它只能被子类重写或调用。

扩展阅读

Object类还提供了一个finalize()方法,当系统中没有引用变量引用到该对象时,垃圾回收器调用此方法来清理该对象的资源。并且,针对某一个对象,垃圾回收器最多只会调用它的finalize()方法一次。

注意,finalize()方法何时调用、是否调用都是不确定的,我们也不要主动调用finalize()方法。从JDK9开始,这个方法被标记为不推荐使用的方法。

1.21 说一说hashCode()和equals()的关系

参考答案

hashCode()用于获取哈希码(散列码),eauqls()用于比较两个对象是否相等,它们应遵守如下规定:

  • 如果两个对象相等,则它们必须有相同的哈希码。
  • 如果两个对象有相同的哈希码,则它们未必相等。

扩展阅读

在Java中,Set接口代表无序的、元素不可重复的集合,HashSet则是Set接口的典型实现。

当向HashSet中加入一个元素时,它需要判断集合中是否已经包含了这个元素,从而避免重复存储。由于这个判断十分的频繁,所以要讲求效率,绝不能采用遍历集合逐个元素进行比较的方式。实际上,HashSet是通过获取对象的哈希码,以及调用对象的equals()方法来解决这个判断问题的。

HashSet首先会调用对象的hashCode()方法获取其哈希码,并通过哈希码确定该对象在集合中存放的位置。假设这个位置之前已经存了一个对象,则HashSet会调用equals()对两个对象进行比较。若相等则说明对象重复,此时不会保存新加的对象。若不等说明对象不重复,但是它们存储的位置发生了碰撞,此时HashSet会采用链式结构在同一位置保存多个对象,即将新加对象链接到原来对象的之后。之后,再有新添加对象也映射到这个位置时,就需要与这个位置中所有的对象进行equals()比较,若均不相等则将其链到最后一个对象之后。

1.22 为什么要重写hashCode()和equals()?

参考答案

Object类提供的equals()方法默认是用==来进行比较的,也就是说只有两个对象是同一个对象时,才能返回相等的结果。而实际的业务中,我们通常的需求是,若两个不同的对象它们的内容是相同的,就认为它们相等。鉴于这种情况,Object类中equals()方法的默认实现是没有实用价值的,所以通常都要重写。

由于hashCode()与equals()具有联动关系(参考“说一说hashCode()和equals()的关系”一题),所以equals()方法重写时,通常也要将hashCode()进行重写,使得这两个方法始终满足相关的约定。

1.23 ==和equals()有什么区别?

参考答案

==运算符:

  • 作用于基本数据类型时,是比较两个数值是否相等;
  • 作用于引用数据类型时,是比较两个对象的内存地址是否相同,即判断它们是否为同一个对象;

equals()方法:

  • 没有重写时,Object默认以 == 来实现,即比较两个对象的内存地址是否相同;
  • 进行重写后,一般会按照对象的内容来进行比较,若两个对象内容相同则认为对象相等,否则认为对象不等。

1.24 String类有哪些方法?

参考答案

String类是Java最常用的API,它包含了大量处理字符串的方法,比较常用的有:

  • char charAt(int index):返回指定索引处的字符;
  • String substring(int beginIndex, int endIndex):从此字符串中截取出一部分子字符串;
  • String[] split(String regex):以指定的规则将此字符串分割成数组;
  • String trim():删除字符串前导和后置的空格;
  • int indexOf(String str):返回子串在此字符串首次出现的索引;
  • int lastIndexOf(String str):返回子串在此字符串最后出现的索引;
  • boolean startsWith(String prefix):判断此字符串是否以指定的前缀开头;
  • boolean endsWith(String suffix):判断此字符串是否以指定的后缀结尾;
  • String toUpperCase():将此字符串中所有的字符大写;
  • String toLowerCase():将此字符串中所有的字符小写;
  • String replaceFirst(String regex, String replacement):用指定字符串替换第一个匹配的子串;
  • String replaceAll(String regex, String replacement):用指定字符串替换所有的匹配的子串。

注意事项

String类的方法太多了,你没必要都记下来,更不需要一一列举。面试时能说出一些常用的方法,表现出对这个类足够的熟悉就可以了。另外,建议你挑几个方法仔细看看源码实现,面试时可以重点说这几个方法。

1.25 String可以被继承吗?

参考答案

String类由final修饰,所以不能被继承。

扩展阅读

在Java中,String类被设计为不可变类,主要表现在它保存字符串的成员变量是final的。

  • Java 9之前字符串采用char[]数组来保存字符,即 private final char[] value;
  • Java 9做了改进,采用byte[]数组来保存字符,即 private final byte[] value;

之所以要把String类设计为不可变类,主要是出于安全和性能的考虑,可归纳为如下4点。

  • 由于字符串无论在任何 Java 系统中都广泛使用,会用来存储敏感信息,如账号,密码,网络路径,文件处理等场景里,保证字符串 String 类的安全性就尤为重要了,如果字符串是可变的,容易被篡改,那我们就无法保证使用字符串进行操作时,它是安全的,很有可能出现 SQL 注入,访问危险文件等操作。
  • 在多线程中,只有不变的对象和值是线程安全的,可以在多个线程中共享数据。由于 String 天然的不可变,当一个线程”修改“了字符串的值,只会产生一个新的字符串对象,不会对其他线程的访问产生副作用,访问的都是同样的字符串数据,不需要任何同步操作。
  • 字符串作为基础的数据结构,大量地应用在一些集合容器之中,尤其是一些散列集合,在散列集合中,存放元素都要根据对象的 hashCode() 方法来确定元素的位置。由于字符串 hashcode 属性不会变更,保证了唯一性,使得类似 HashMap,HashSet 等容器才能实现相应的缓存功能。由于 String 的不可变,避免重复计算 hashcode,只要使用缓存的 hashcode 即可,这样一来大大提高了在散列集合中使用 String 对象的性能。
  • 当字符串不可变时,字符串常量池才有意义。字符串常量池的出现,可以减少创建相同字面量的字符串,让不同的引用指向池中同一个字符串,为运行时节约很多的堆内存。若字符串可变,字符串常量池失去意义,基于常量池的 String.intern() 方法也失效,每次创建新的字符串将在堆内开辟出新的空间,占据更多的内存。

因为要保证String类的不可变,那么将这个类定义为final的就很容易理解了。如果没有final修饰,那么就会存在String的子类,这些子类可以重写String类的方法,强行改变字符串的值,这便违背了String类设计的初衷。

1.26 说一说String和StringBuffer有什么区别

参考答案

String类是不可变类,即一旦一个String对象被创建以后,包含在这个对象中的字符序列是不可改变的,直至这个对象被销毁。

StringBuffer对象则代表一个字符序列可变的字符串,当一个StringBuffer被创建以后,通过StringBuffer提供的append()、insert()、reverse()、setCharAt()、setLength()等方法可以改变这个字符串对象的字符序列。一旦通过StringBuffer生成了最终想要的字符串,就可以调用它的toString()方法将其转换为一个String对象。

1.27 说一说StringBuffer和StringBuilder有什么区别

参考答案

StringBuffer、StringBuilder都代表可变的字符串对象,它们有共同的父类 AbstractStringBuilder,并且两个类的构造方法和成员方法也基本相同。不同的是,StringBuffer是线程安全的,而StringBuilder是非线程安全的,所以StringBuilder性能略高。一般情况下,要创建一个内容可变的字符串,建议优先考虑StringBuilder类。

1.28 使用字符串时,new和""推荐使用哪种方式?

参考答案

先看看 “hello” 和 new String(“hello”) 的区别:

  • 当Java程序直接使用 “hello” 的字符串直接量时,JVM将会使用常量池来管理这个字符串;
  • 当使用 new String(“hello”) 时,JVM会先使用常量池来管理 “hello” 直接量,再调用String类的构造器来创建一个新的String对象,新创建的String对象被保存在堆内存中。

显然,采用new的方式会多创建一个对象出来,会占用更多的内存,所以一般建议使用直接量的方式创建字符串。

1.29 说一说你对字符串拼接的理解

参考答案

拼接字符串有很多种方式,其中最常用的有4种,下面列举了这4种方式各自适合的场景。

  1. + 运算符:如果拼接的都是字符串直接量,则适合使用 + 运算符实现拼接;
  2. StringBuilder:如果拼接的字符串中包含变量,并不要求线程安全,则适合使用StringBuilder;
  3. StringBuffer:如果拼接的字符串中包含变量,并且要求线程安全,则适合使用StringBuffer;
  4. String类的concat方法:如果只是对两个字符串进行拼接,并且包含变量,则适合使用concat方法;

扩展阅读

采用 + 运算符拼接字符串时:

  • 如果拼接的都是字符串直接量,则在编译时编译器会将其直接优化为一个完整的字符串,和你直接写一个完整的字符串是一样的,所以效率非常的高。
  • 如果拼接的字符串中包含变量,则在编译时编译器采用StringBuilder对其进行优化,即自动创建StringBuilder实例并调用其append()方法,将这些字符串拼接在一起,效率也很高。但如果这个拼接操作是在循环中进行的,那么每次循环编译器都会创建一个StringBuilder实例,再去拼接字符串,相当于执行了 new StringBuilder().append(str),所以此时效率很低。

采用StringBuilder/StringBuffer拼接字符串时:

  • StringBuilder/StringBuffer都有字符串缓冲区,缓冲区的容量在创建对象时确定,并且默认为16。当拼接的字符串超过缓冲区的容量时,会触发缓冲区的扩容机制,即缓冲区加倍。
  • 缓冲区频繁的扩容会降低拼接的性能,所以如果能提前预估最终字符串的长度,则建议在创建可变字符串对象时,放弃使用默认的容量,可以指定缓冲区的容量为预估的字符串的长度。

采用String类的concat方法拼接字符串时:

  • concat方法的拼接逻辑是,先创建一个足以容纳待拼接的两个字符串的字节数组,然后先后将两个字符串拼到这个数组里,最后将此数组转换为字符串。
  • 在拼接大量字符串的时候,concat方法的效率低于StringBuilder。但是只拼接2个字符串时,concat方法的效率要优于StringBuilder。并且这种拼接方式代码简洁,所以只拼2个字符串时建议优先选择concat方法。

1.30 两个字符串相加的底层是如何实现的?

参考答案

如果拼接的都是字符串直接量,则在编译时编译器会将其直接优化为一个完整的字符串,和你直接写一个完整的字符串是一样的。

如果拼接的字符串中包含变量,则在编译时编译器采用StringBuilder对其进行优化,即自动创建StringBuilder实例并调用其append()方法,将这些字符串拼接在一起。

1.31 String a = “abc”; ,说一下这个过程会创建什么,放在哪里?

参考答案

JVM会使用常量池来管理字符串直接量。在执行这句话时,JVM会先检查常量池中是否已经存有"abc",若没有则将"abc"存入常量池,否则就复用常量池中已有的"abc",将其引用赋值给变量a。

1.32 new String(“abc”) 是去了哪里,仅仅是在堆里面吗?

参考答案

在执行这句话时,JVM会先使用常量池来管理字符串直接量,即将"abc"存入常量池。然后再创建一个新的String对象,这个对象会被保存在堆内存中。并且,堆中对象的数据会指向常量池中的直接量。

1.33 接口和抽象类有什么区别?

参考答案

从设计目的上来说,二者有如下的区别:

接口体现的是一种规范。对于接口的实现者而言,接口规定了实现者必须向外提供哪些服务;对于接口的调用者而言,接口规定了调用者可以调用哪些服务,以及如何调用这些服务。当在一个程序中使用接口时,接口是多个模块间的耦合标准;当在多个应用程序之间使用接口时,接口是多个程序之间的通信标准。

抽象类体现的是一种模板式设计。抽象类作为多个子类的抽象父类,可以被当成系统实现过程中的中间产品,这个中间产品已经实现了系统的部分功能,但这个产品依然不能当成最终产品,必须有更进一步的完善,这种完善可能有几种不同方式。

从使用方式上来说,二者有如下的区别:

  • 接口里只能包含抽象方法、静态方法、默认方法和私有方法,不能为普通方法提供方法实现;抽象类则完全可以包含普通方法。
  • 接口里只能定义静态常量,不能定义普通成员变量;抽象类里则既可以定义普通成员变量,也可以定义静态常量。
  • 接口里不包含构造器;抽象类里可以包含构造器,抽象类里的构造器并不是用于创建对象,而是让其子类调用这些构造器来完成属于抽象类的初始化操作。
  • 接口里不能包含初始化块;但抽象类则完全可以包含初始化块。
  • 一个类最多只能有一个直接父类,包括抽象类;但一个类可以直接实现多个接口,通过实现多个接口可以弥补Java单继承的不足。

扩展阅读

接口和抽象类很像,它们都具有如下共同的特征:

  • 接口和抽象类都不能被实例化,它们都位于继承树的顶端,用于被其他类实现和继承。
  • 接口和抽象类都可以包含抽象方法,实现接口或继承抽象类的普通子类都必须实现这些抽象方法。

1.34 接口中可以有构造函数吗?

参考答案

由于接口定义的是一种规范,因此接口里不能包含构造器和初始化块定义。接口里可以包含成员变量(只能是静态常量)、方法(只能是抽象实例方法、类方法、默认方法或私有方法)、内部类(包括内部接口、枚举)定义。

1.35 谈谈你对面向接口编程的理解

参考答案

接口体现的是一种规范和实现分离的设计哲学,充分利用接口可以极好地降低程序各模块之间的耦合,从而提高系统的可扩展性和可维护性。基于这种原则,很多软件架构设计理论都倡导“面向接口”编程,而不是面向实现类编程,希望通过面向接口编程来降低程序的耦合。

1.36 遇到过异常吗,如何处理?

参考答案

在Java中,可以按照如下三个步骤处理异常:

  1. 捕获异常

    将业务代码包裹在try块内部,当业务代码中发生任何异常时,系统都会为此异常创建一个异常对象。创建异常对象之后,JVM会在try块之后寻找可以处理它的catch块,并将异常对象交给这个catch块处理。

  2. 处理异常

    在catch块中处理异常时,应该先记录日志,便于以后追溯这个异常。然后根据异常的类型、结合当前的业务情况,进行相应的处理。比如,给变量赋予一个默认值、直接返回空值、向外抛出一个新的业务异常交给调用者处理,等等。

  3. 回收资源

    如果业务代码打开了某个资源,比如数据库连接、网络连接、磁盘文件等,则需要在这段业务代码执行完毕后关闭这项资源。并且,无论是否发生异常,都要尝试关闭这项资源。将关闭资源的代码写在finally块内,可以满足这种需求,即无论是否发生异常,finally块内的代码总会被执行。

1.37 说一说Java的异常机制

参考答案

关于异常处理:

在Java中,处理异常的语句由try、catch、finally三部分组成。其中,try块用于包裹业务代码,catch块用于捕获并处理某个类型的异常,finally块则用于回收资源。当业务代码发生异常时,系统会创建一个异常对象,然后由JVM寻找可以处理这个异常的catch块,并将异常对象交给这个catch块处理。若业务代码打开了某项资源,则可以在finally块中关闭这项资源,因为无论是否发生异常,finally块一定会执行。

关于抛出异常:

当程序出现错误时,系统会自动抛出异常。除此以外,Java也允许程序主动抛出异常。当业务代码中,判断某项错误的条件成立时,可以使用throw关键字向外抛出异常。在这种情况下,如果当前方法不知道该如何处理这个异常,可以在方法签名上通过throws关键字声明抛出异常,则该异常将交给JVM处理。

关于异常跟踪栈:

程序运行时,经常会发生一系列方法调用,从而形成方法调用栈。异常机制会导致异常在这些方法之间传播,而异常传播的顺序与方法的调用相反。异常从发生异常的方法向外传播,首先传给该方法的调用者,再传给上层调用者,以此类推。最终会传到main方法,若依然没有得到处理,则JVM会终止程序,并打印异常跟踪栈的信息

1.38 请介绍Java的异常接口

参考答案

Throwable是异常的顶层父类,代表所有的非正常情况。它有两个直接子类,分别是Error、Exception。

Error是错误,一般是指与虚拟机相关的问题,如系统崩溃、虚拟机错误、动态链接失败等,这种错误无法恢复或不可能捕获,将导致应用程序中断。通常应用程序无法处理这些错误,因此应用程序不应该试图使用catch块来捕获Error对象。在定义方法时,也无须在其throws子句中声明该方法可能抛出Error及其任何子类。

Exception是异常,它被分为两大类,分别是Checked异常和Runtime异常。所有的RuntimeException类及其子类的实例被称为Runtime异常;不是RuntimeException类及其子类的异常实例则被称为Checked异常。Java认为Checked异常都是可以被处理(修复)的异常,所以Java程序必须显式处理Checked异常。如果程序没有处理Checked异常,该程序在编译时就会发生错误,无法通过编译。Runtime异常则更加灵活,Runtime异常无须显式声明抛出,如果程序需要捕获Runtime异常,也可以使用try…catch块来实现。

1.39 finally是无条件执行的吗?

参考答案

不管try块中的代码是否出现异常,也不管哪一个catch块被执行,甚至在try块或catch块中执行了return语句,finally块总会被执行。

注意事项

如果在try块或catch块中使用 System.exit(1); 来退出虚拟机,则finally块将失去执行的机会。但是我们在实际的开发中,重来都不会这样做,所以尽管存在这种导致finally块无法执行的可能,也只是一种可能而已。

1.40 在finally中return会发生什么?

参考答案

在通常情况下,不要在finally块中使用return、throw等导致方法终止的语句,一旦在finally块中使用了return、throw语句,将会导致try块、catch块中的return、throw语句失效。

详细解析

当Java程序执行try块、catch块时遇到了return或throw语句,这两个语句都会导致该方法立即结束,但是系统执行这两个语句并不会结束该方法,而是去寻找该异常处理流程中是否包含finally块,如果没有finally块,程序立即执行return或throw语句,方法终止;如果有finally块,系统立即开始执行finally块。只有当finally块执行完成后,系统才会再次跳回来执行try块、catch块里的return或throw语句;如果finally块里也使用了return或throw等导致方法终止的语句,finally块已经终止了方法,系统将不会跳回去执行try块、catch块里的任何代码。

1.41 说一说你对static关键字的理解

参考答案

在Java类里只能包含成员变量、方法、构造器、初始化块、内部类(包括接口、枚举)5种成员,而static可以修饰成员变量、方法、初始化块、内部类(包括接口、枚举),以static修饰的成员就是类成员。类成员属于整个类,而不属于单个对象。

对static关键字而言,有一条非常重要的规则:类成员(包括成员变量、方法、初始化块、内部类和内部枚举)不能访问实例成员(包括成员变量、方法、初始化块、内部类和内部枚举)。因为类成员是属于类的,类成员的作用域比实例成员的作用域更大,完全可能出现类成员已经初始化完成,但实例成员还不曾初始化的情况,如果允许类成员访问实例成员将会引起大量错误。

1.42 static修饰的类能不能被继承?

参考答案

static修饰的类可以被继承。

扩展阅读

如果使用static来修饰一个内部类,则这个内部类就属于外部类本身,而不属于外部类的某个对象。因此使用static修饰的内部类被称为类内部类,有的地方也称为静态内部类。

static关键字的作用是把类的成员变成类相关,而不是实例相关,即static修饰的成员属于整个类,而不属于单个对象。外部类的上一级程序单元是包,所以不可使用static修饰;而内部类的上一级程序单元是外部类,使用static修饰可以将内部类变成外部类相关,而不是外部类实例相关。因此static关键字不可修饰外部类,但可修饰内部类。

静态内部类需满足如下规则:

  1. 静态内部类可以包含静态成员,也可以包含非静态成员;

  2. 静态内部类不能访问外部类的实例成员,只能访问它的静态成员;

  3. 外部类的所有方法、初始化块都能访问其内部定义的静态内部类;

  4. 在外部类的外部,也可以实例化静态内部类,语法如下:

    外部类.内部类 变量名 = new 外部类.内部类构造方法();
    

1.43 static和final有什么区别?

参考答案

static关键字可以修饰成员变量、成员方法、初始化块、内部类,被static修饰的成员是类的成员,它属于类、不属于单个对象。以下是static修饰这4种成员时表现出的特征:

  • 类变量:被static修饰的成员变量叫类变量(静态变量)。类变量属于类,它随类的信息存储在方法区,并不随对象存储在堆中,类变量可以通过类名来访问,也可以通过对象名来访问,但建议通过类名访问它。
  • 类方法:被static修饰的成员方法叫类方法(静态方法)。类方法属于类,可以通过类名访问,也可以通过对象名访问,建议通过类名访问它。
  • 静态块:被static修饰的初始化块叫静态初始化块。静态块属于类,它在类加载的时候被隐式调用一次,之后便不会被调用了。
  • 静态内部类:被static修饰的内部类叫静态内部类。静态内部类可以包含静态成员,也可以包含非静态成员。静态内部类不能访问外部类的实例成员,只能访问外部类的静态成员。外部类的所有方法、初始化块都能访问其内部定义的静态内部类。

final关键字可以修饰类、方法、变量,以下是final修饰这3种目标时表现出的特征:

  • final类:final关键字修饰的类不可以被继承。
  • final方法:final关键字修饰的方法不可以被重写。
  • final变量:final关键字修饰的变量,一旦获得了初始值,就不可以被修改。

扩展阅读

变量分为成员变量、局部变量。

final修饰成员变量:

  • 类变量:可以在声明变量时指定初始值,也可以在静态初始化块中指定初始值;
  • 实例变量:可以在声明变量时指定初始值,也可以在初始化块或构造方法中指定初始值;

final修饰局部变量:

  • 可以在声明变量时指定初始值,也可以在后面的代码中指定初始值。

注意:被 final 修饰的任何形式的变量,一旦获得了初始值,就不可以被修改!

1.44 说一说你对泛型的理解

参考答案

Java集合有个缺点—把一个对象“丢进”集合里之后,集合就会“忘记”这个对象的数据类型,当再次取出该对象时,该对象的编译类型就变成了Object类型(其运行时类型没变)。

Java集合之所以被设计成这样,是因为集合的设计者不知道我们会用集合来保存什么类型的对象,所以他们把集合设计成能保存任何类型的对象,只要求具有很好的通用性。但这样做带来如下两个问题:

  • 集合对元素类型没有任何限制,这样可能引发一些问题。例如,想创建一个只能保存Dog对象的集合,但程序也可以轻易地将Cat对象“丢”进去,所以可能引发异常。
  • 由于把对象“丢进”集合时,集合丢失了对象的状态信息,只知道它盛装的是Object,因此取出集合元素后通常还需要进行强制类型转换。这种强制类型转换既增加了编程的复杂度,也可能引发ClassCastException异常。

从Java 5开始,Java引入了“参数化类型”的概念,允许程序在创建集合时指定集合元素的类型,Java的参数化类型被称为泛型(Generic)。例如 List,表明该List只能保存字符串类型的对象。

有了泛型以后,程序再也不能“不小心”地把其他对象“丢进”集合中。而且程序更加简洁,集合自动记住所有集合元素的数据类型,从而无须对集合元素进行强制类型转换。

1.45 介绍一下泛型擦除

参考答案

在严格的泛型代码里,带泛型声明的类总应该带着类型参数。但为了与老的Java代码保持一致,也允许在使用带泛型声明的类时不指定实际的类型。如果没有为这个泛型类指定实际的类型,此时被称作raw type(原始类型),默认是声明该泛型形参时指定的第一个上限类型。

当把一个具有泛型信息的对象赋给另一个没有泛型信息的变量时,所有在尖括号之间的类型信息都将被扔掉。比如一个 List 类型被转换为List,则该List对集合元素的类型检查变成了泛型参数的上限(即Object)。

上述规则即为泛型擦除,可以通过下面代码进一步理解泛型擦除:

List<String> list1 = ...; List list2 = list1; // list2将元素当做Object处理

扩展阅读

从逻辑上来看,List 是List的子类,如果直接把一个List对象赋给一个List对象应该引起编译错误,但实际上不会。对泛型而言,可以直接把一个List对象赋给一个 List 对象,编译器仅仅提示“未经检查的转换”。

上述规则叫做泛型转换,可以通过下面代码进一步理解泛型转换:

List list1 = ...; List<String> list2 = list1; // 编译时警告“未经检查的转换”

1.46 List<? super T>和List<? extends T>有什么区别?

参考答案

  • ? 是类型通配符,List<?> 可以表示各种泛型List的父类,意思是元素类型未知的List;
  • List<? super T> 用于设定类型通配符的下限,此处 ? 代表一个未知的类型,但它必须是T的父类型;
  • List<? extends T> 用于设定类型通配符的上限,此处 ? 代表一个未知的类型,但它必须是T的子类型。

扩展阅读

在Java的早期设计中,允许把Integer[]数组赋值给Number[]变量,此时如果试图把一个Double对象保存到该Number[]数组中,编译可以通过,但在运行时抛出ArrayStoreException异常。这显然是一种不安全的设计,因此Java在泛型设计时进行了改进,它不再允许把 List 对象赋值给 List 变量。

数组和泛型有所不同,假设Foo是Bar的一个子类型(子类或者子接口),那么Foo[]依然是Bar[]的子类型,但G 不是 G 的子类型。Foo[]自动向上转型为Bar[]的方式被称为型变,也就是说,Java的数组支持型变,但Java集合并不支持型变。Java泛型的设计原则是,只要代码在编译时没有出现警告,就不会遇到运行时ClassCastException异常。

1.47 说一说你对Java反射机制的理解

参考答案

Java程序中的对象在运行时可以表现为两种类型,即编译时类型和运行时类型。例如 Person p = new Student(); ,这行代码将会生成一个p变量,该变量的编译时类型为Person,运行时类型为Student。

有时,程序在运行时接收到外部传入的一个对象,该对象的编译时类型是Object,但程序又需要调用该对象的运行时类型的方法。这就要求程序需要在运行时发现对象和类的真实信息,而解决这个问题有以下两种做法:

  • 第一种做法是假设在编译时和运行时都完全知道类型的具体信息,在这种情况下,可以先使用instanceof运算符进行判断,再利用强制类型转换将其转换成其运行时类型的变量即可。
  • 第二种做法是编译时根本无法预知该对象和类可能属于哪些类,程序只依靠运行时信息来发现该对象和类的真实信息,这就必须使用反射。

具体来说,通过反射机制,我们可以实现如下的操作:

  • 程序运行时,可以通过反射获得任意一个类的Class对象,并通过这个对象查看这个类的信息;
  • 程序运行时,可以通过反射创建任意一个类的实例,并访问该实例的成员;
  • 程序运行时,可以通过反射机制生成一个类的动态代理类或动态代理对象。

1.48 Java反射在实际项目中有哪些应用场景?

参考答案

Java的反射机制在实际项目中应用广泛,常见的应用场景有:

  • 使用JDBC时,如果要创建数据库的连接,则需要先通过反射机制加载数据库的驱动程序;
  • 多数框架都支持注解/XML配置,从配置中解析出来的类是字符串,需要利用反射机制实例化;
  • 面向切面编程(AOP)的实现方案,是在程序运行时创建目标对象的代理类,这必须由反射机制来实现。

1.49 说一说Java的四种引用方式

参考答案

Java对象的四种引用方式分别是强引用、软引用、弱引用、虚引用,具体含义如下:

  • 强引用:这是Java程序中最常见的引用方式,即程序创建一个对象,并把这个对象赋给一个引用变量,程序通过该引用变量来操作实际的对象。当一个对象被一个或一个以上的引用变量所引用时,它处于可达状态,不可能被系统垃圾回收机制回收。
  • 软引用:当一个对象只有软引用时,它有可能被垃圾回收机制回收。对于只有软引用的对象而言,当系统内存空间足够时,它不会被系统回收,程序也可使用该对象。当系统内存空间不足时,系统可能会回收它。软引用通常用于对内存敏感的程序中。
  • 弱引用:弱引用和软引用很像,但弱引用的引用级别更低。对于只有弱引用的对象而言,当系统垃圾回收机制运行时,不管系统内存是否足够,总会回收该对象所占用的内存。当然,并不是说当一个对象只有弱引用时,它就会立即被回收,正如那些失去引用的对象一样,必须等到系统垃圾回收机制运行时才会被回收。
  • 虚引用:虚引用完全类似于没有引用。虚引用对对象本身没有太大影响,对象甚至感觉不到虚引用的存在。如果一个对象只有一个虚引用时,那么它和没有引用的效果大致相同。虚引用主要用于跟踪对象被垃圾回收的状态,虚引用不能单独使用,虚引用必须和引用队列联合使用。

2. 集合类

2.1 Java中有哪些容器(集合类)?

参考答案

Java中的集合类主要由Collection和Map这两个接口派生而出,其中Collection接口又派生出三个子接口,分别是Set、List、Queue。所有的Java集合类,都是Set、List、Queue、Map这四个接口的实现类,这四个接口将集合分成了四大类,其中

  • Set代表无序的,元素不可重复的集合;
  • List代表有序的,元素可以重复的集合;
  • Queue代表先进先出(FIFO)的队列;
  • Map代表具有映射关系(key-value)的集合。

这些接口拥有众多的实现类,其中最常用的实现类有HashSet、TreeSet、ArrayList、LinkedList、ArrayDeque、HashMap、TreeMap等。

扩展阅读

Collection体系的继承树:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xBImPsYk-1666574215917)(https://uploadfiles.nowcoder.com/images/20220224/4107856_1645688819081/74C2C3389688C6364FF2DE8AA768A039)]

Map体系的继承树:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yin3Jp0X-1666574215919)(https://uploadfiles.nowcoder.com/images/20220224/4107856_1645688841352/E26BABF74692B006DA33C112A6FD5EEC)]

注:紫色框体代表接口,其中加粗的是代表四类集合的接口。蓝色框体代表实现类,其中有阴影的是常用实现类。

2.2 Java中的容器,线程安全和线程不安全的分别有哪些?

参考答案

java.util包下的集合类大部分都是线程不安全的,例如我们常用的HashSet、TreeSet、ArrayList、LinkedList、ArrayDeque、HashMap、TreeMap,这些都是线程不安全的集合类,但是它们的优点是性能好。如果需要使用线程安全的集合类,则可以使用Collections工具类提供的synchronizedXxx()方法,将这些集合类包装成线程安全的集合类。

java.util包下也有线程安全的集合类,例如Vector、Hashtable。这些集合类都是比较古老的API,虽然实现了线程安全,但是性能很差。所以即便是需要使用线程安全的集合类,也建议将线程不安全的集合类包装成线程安全集合类的方式,而不是直接使用这些古老的API。

从Java5开始,Java在java.util.concurrent包下提供了大量支持高效并发访问的集合类,它们既能包装良好的访问性能,有能包装线程安全。这些集合类可以分为两部分,它们的特征如下:

  • 以Concurrent开头的集合类:

    以Concurrent开头的集合类代表了支持并发访问的集合,它们可以支持多个线程并发写入访问,这些写入线程的所有操作都是线程安全的,但读取操作不必锁定。以Concurrent开头的集合类采用了更复杂的算法来保证永远不会锁住整个集合,因此在并发写入时有较好的性能。

  • 以CopyOnWrite开头的集合类:

    以CopyOnWrite开头的集合类采用复制底层数组的方式来实现写操作。当线程对此类集合执行读取操作时,线程将会直接读取集合本身,无须加锁与阻塞。当线程对此类集合执行写入操作时,集合会在底层复制一份新的数组,接下来对新的数组执行写入操作。由于对集合的写入操作都是对数组的副本执行操作,因此它是线程安全的。

扩展阅读

java.util.concurrent包下线程安全的集合类的体系结构:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-p6LGgxAV-1666574215920)(https://uploadfiles.nowcoder.com/images/20220224/4107856_1645688877249/53AA859E1B3A9CD7709DF9366999B88D)]

2.3 Map接口有哪些实现类?

参考答案

Map接口有很多实现类,其中比较常用的有HashMap、LinkedHashMap、TreeMap、ConcurrentHashMap。

对于不需要排序的场景,优先考虑使用HashMap,因为它是性能最好的Map实现。如果需要保证线程安全,则可以使用ConcurrentHashMap。它的性能好于Hashtable,因为它在put时采用分段锁/CAS的加锁机制,而不是像Hashtable那样,无论是put还是get都做同步处理。

对于需要排序的场景,如果需要按插入顺序排序则可以使用LinkedHashMap,如果需要将key按自然顺序排列甚至是自定义顺序排列,则可以选择TreeMap。如果需要保证线程安全,则可以使用Collections工具类将上述实现类包装成线程安全的Map。

2.4 描述一下Map put的过程

参考答案

HashMap是最经典的Map实现,下面以它的视角介绍put的过程:

  1. 首次扩容:

    先判断数组是否为空,若数组为空则进行第一次扩容(resize);

  2. 计算索引:

    通过hash算法,计算键值对在数组中的索引;

  3. 插入数据:

    • 如果当前位置元素为空,则直接插入数据;
    • 如果当前位置元素非空,且key已存在,则直接覆盖其value;
    • 如果当前位置元素非空,且key不存在,则将数据链到链表末端;
    • 若链表长度达到8,则将链表转换成红黑树,并将数据插入树中;
  4. 再次扩容

    如果数组中元素个数(size)超过threshold,则再次进行扩容操作。

2.5 如何得到一个线程安全的Map?

参考答案

  1. 使用Collections工具类,将线程不安全的Map包装成线程安全的Map;
  2. 使用java.util.concurrent包下的Map,如ConcurrentHashMap;
  3. 不建议使用Hashtable,虽然Hashtable是线程安全的,但是性能较差。

2.6 HashMap有什么特点?

参考答案

  1. HashMap是线程不安全的实现;
  2. HashMap可以使用null作为key或value。

2.7 JDK7和JDK8中的HashMap有什么区别?

参考答案

JDK7中的HashMap,是基于数组+链表来实现的,它的底层维护一个Entry数组。它会根据计算的hashCode将对应的KV键值对存储到该数组中,一旦发生hashCode冲突,那么就会将该KV键值对放到对应的已有元素的后面, 此时便形成了一个链表式的存储结构。

JDK7中HashMap的实现方案有一个明显的缺点,即当Hash冲突严重时,在桶上形成的链表会变得越来越长,这样在查询时的效率就会越来越低,其时间复杂度为O(N)。

JDK8中的HashMap,是基于数组+链表+红黑树来实现的,它的底层维护一个Node数组。当链表的存储的数据个数大于等于8的时候,不再采用链表存储,而采用了红黑树存储结构。这么做主要是在查询的时间复杂度上进行优化,链表为O(N),而红黑树一直是O(logN),可以大大的提高查找性能。

2.8 介绍一下HashMap底层的实现原理

参考答案

它基于hash算法,通过put方法和get方法存储和获取对象。

存储对象时,我们将K/V传给put方法时,它调用K的hashCode计算hash从而得到bucket位置,进一步存储,HashMap会根据当前bucket的占用情况自动调整容量(超过Load Facotr则resize为原来的2倍)。获取对象时,我们将K传给get,它调用hashCode计算hash从而得到bucket位置,并进一步调用equals()方法确定键值对。

如果发生碰撞的时候,HashMap通过链表将产生碰撞冲突的元素组织起来。在Java 8中,如果一个bucket中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度。

2.9 介绍一下HashMap的扩容机制

参考答案

  1. 数组的初始容量为16,而容量是以2的次方扩充的,一是为了提高性能使用足够大的数组,二是为了能使用位运算代替取模预算(据说提升了5~8倍)。
  2. 数组是否需要扩充是通过负载因子判断的,如果当前元素个数为数组容量的0.75时,就会扩充数组。这个0.75就是默认的负载因子,可由构造器传入。我们也可以设置大于1的负载因子,这样数组就不会扩充,牺牲性能,节省内存。
  3. 为了解决碰撞,数组中的元素是单向链表类型。当链表长度到达一个阈值时(7或8),会将链表转换成红黑树提高性能。而当链表长度缩小到另一个阈值时(6),又会将红黑树转换回单向链表提高性能。
  4. 对于第三点补充说明,检查链表长度转换成红黑树之前,还会先检测当前数组数组是否到达一个阈值(64),如果没有到达这个容量,会放弃转换,先去扩充数组。所以上面也说了链表长度的阈值是7或8,因为会有一次放弃转换的操作。

2.10 HashMap中的循环链表是如何产生的?

参考答案

在多线程的情况下,当重新调整HashMap大小的时候,就会存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历。如果条件竞争发生了,那么就会产生死循环了。

2.11 HashMap为什么用红黑树而不用B树?

参考答案

B/B+树多用于外存上时,B/B+也被成为一个磁盘友好的数据结构。

HashMap本来是数组+链表的形式,链表由于其查找慢的特点,所以需要被查找效率更高的树结构来替换。如果用B/B+树的话,在数据量不是很多的情况下,数据都会“挤在”一个结点里面,这个时候遍历效率就退化成了链表。

2.12 HashMap为什么线程不安全?

参考答案

HashMap在并发执行put操作时,可能会导致形成循环链表,从而引起死循环。

2.13 HashMap如何实现线程安全?

参考答案

  1. 直接使用Hashtable类;
  2. 直接使用ConcurrentHashMap;
  3. 使用Collections将HashMap包装成线程安全的Map。

2.14 HashMap是如何解决哈希冲突的?

参考答案

为了解决碰撞,数组中的元素是单向链表类型。当链表长度到达一个阈值时,会将链表转换成红黑树提高性能。而当链表长度缩小到另一个阈值时,又会将红黑树转换回单向链表提高性能。

2.15 说一说HashMap和HashTable的区别

参考答案

  1. Hashtable是一个线程安全的Map实现,但HashMap是线程不安全的实现,所以HashMap比Hashtable的性能高一点。
  2. Hashtable不允许使用null作为key和value,如果试图把null值放进Hashtable中,将会引发空指针异常,但HashMap可以使用null作为key或value。

扩展阅读

从Hashtable的类名上就可以看出它是一个古老的类,它的命名甚至没有遵守Java的命名规范:每个单词的首字母都应该大写。也许当初开发Hashtable的工程师也没有注意到这一点,后来大量Java程序中使用了Hashtable类,所以这个类名也就不能改为HashTable了,否则将导致大量程序需要改写。

与Vector类似的是,尽量少用Hashtable实现类,即使需要创建线程安全的Map实现类,也无须使用Hashtable实现类,可以通过Collections工具类把HashMap变成线程安全的Map。

2.16 HashMap与ConcurrentHashMap有什么区别?

参考答案

HashMap是非线程安全的,这意味着不应该在多线程中对这些Map进行修改操作,否则会产生数据不一致的问题,甚至还会因为并发插入元素而导致链表成环,这样在查找时就会发生死循环,影响到整个应用程序。

Collections工具类可以将一个Map转换成线程安全的实现,其实也就是通过一个包装类,然后把所有功能都委托给传入的Map,而包装类是基于synchronized关键字来保证线程安全的(Hashtable也是基于synchronized关键字),底层使用的是互斥锁,性能与吞吐量比较低。

ConcurrentHashMap的实现细节远没有这么简单,因此性能也要高上许多。它没有使用一个全局锁来锁住自己,而是采用了减少锁粒度的方法,尽量减少因为竞争锁而导致的阻塞与冲突,而且ConcurrentHashMap的检索操作是不需要锁的。

2.17 介绍一下ConcurrentHashMap是怎么实现的?

参考答案

JDK 1.7中的实现:

在 jdk 1.7 中,ConcurrentHashMap 是由 Segment 数据结构和 HashEntry 数组结构构成,采取分段锁来保证安全性。Segment 是 ReentrantLock 重入锁,在 ConcurrentHashMap 中扮演锁的角色,HashEntry 则用于存储键值对数据。一个 ConcurrentHashMap 里包含一个 Segment 数组,一个 Segment 里包含一个 HashEntry 数组,Segment 的结构和 HashMap 类似,是一个数组和链表结构。

JDK 1.8中的实现:

JDK1.8 的实现已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 Synchronized 和 CAS 来操作,整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本。

2.18 ConcurrentHashMap是怎么分段分组的?

参考答案

get操作:

Segment的get操作实现非常简单和高效,先经过一次再散列,然后使用这个散列值通过散列运算定位到 Segment,再通过散列算法定位到元素。get操作的高效之处在于整个get过程都不需要加锁,除非读到空的值才会加锁重读。原因就是将使用的共享变量定义成 volatile 类型。

put操作:

当执行put操作时,会经历两个步骤:

  1. 判断是否需要扩容;
  2. 定位到添加元素的位置,将其放入 HashEntry 数组中。

插入过程会进行第一次 key 的 hash 来定位 Segment 的位置,如果该 Segment 还没有初始化,即通过 CAS 操作进行赋值,然后进行第二次 hash 操作,找到相应的 HashEntry 的位置,这里会利用继承过来的锁的特性,在将数据插入指定的 HashEntry 位置时(尾插法),会通过继承 ReentrantLock 的 tryLock() 方法尝试去获取锁,如果获取成功就直接插入相应的位置,如果已经有线程获取该Segment的锁,那当前线程会以自旋的方式去继续的调用 tryLock() 方法去获取锁,超过指定次数就挂起,等待唤醒。

2.19 说一说你对LinkedHashMap的理解

参考答案

LinkedHashMap使用双向链表来维护key-value对的顺序(其实只需要考虑key的顺序),该链表负责维护Map的迭代顺序,迭代顺序与key-value对的插入顺序保持一致。

LinkedHashMap可以避免对HashMap、Hashtable里的key-value对进行排序(只要插入key-value对时保持顺序即可),同时又可避免使用TreeMap所增加的成本。

LinkedHashMap需要维护元素的插入顺序,因此性能略低于HashMap的性能。但因为它以链表来维护内部顺序,所以在迭代访问Map里的全部元素时将有较好的性能。

2.20 请介绍LinkedHashMap的底层原理

参考答案

LinkedHashMap继承于HashMap,它在HashMap的基础上,通过维护一条双向链表,解决了HashMap不能随时保持遍历顺序和插入顺序一致的问题。在实现上,LinkedHashMap很多方法直接继承自HashMap,仅为维护双向链表重写了部分方法。

如下图,淡蓝色的箭头表示前驱引用,红色箭头表示后继引用。每当有新的键值对节点插入时,新节点最终会接在tail引用指向的节点后面。而tail引用则会移动到新的节点上,这样一个双向链表就建立起来了。

2.21 请介绍TreeMap的底层原理

参考答案

TreeMap基于红黑树(Red-Black tree)实现。映射根据其键的自然顺序进行排序,或者根据创建映射时提供的 Comparator 进行排序,具体取决于使用的构造方法。TreeMap的基本操作containsKey、get、put、remove方法,它的时间复杂度是log(N)。

TreeMap包含几个重要的成员变量:root、size、comparator。其中root是红黑树的根节点。它是Entry类型,Entry是红黑树的节点,它包含了红黑树的6个基本组成:key、value、left、right、parent和color。Entry节点根据根据Key排序,包含的内容是value。Entry中key比较大小是根据比较器comparator来进行判断的。size是红黑树的节点个数。

2.22 Map和Set有什么区别?

参考答案

Set代表无序的,元素不可重复的集合;

Map代表具有映射关系(key-value)的集合,其所有的key是一个Set集合,即key无序且不能重复。

2.23 List和Set有什么区别?

参考答案

Set代表无序的,元素不可重复的集合;

List代表有序的,元素可以重复的集合。

2.24 ArrayList和LinkedList有什么区别?

参考答案

  1. ArrayList的实现是基于数组,LinkedList的实现是基于双向链表;
  2. 对于随机访问ArrayList要优于LinkedList,ArrayList可以根据下标以O(1)时间复杂度对元素进行随机访问,而LinkedList的每一个元素都依靠地址指针和它后一个元素连接在一起,查找某个元素的时间复杂度是O(N);
  3. 对于插入和删除操作,LinkedList要优于ArrayList,因为当元素被添加到LinkedList任意位置的时候,不需要像ArrayList那样重新计算大小或者是更新索引;
  4. LinkedList比ArrayList更占内存,因为LinkedList的节点除了存储数据,还存储了两个引用,一个指向前一个元素,一个指向后一个元素。

2.25 有哪些线程安全的List?

参考答案

  1. Vector

    Vector是比较古老的API,虽然保证了线程安全,但是由于效率低一般不建议使用。

  2. Collections.SynchronizedList

    SynchronizedList是Collections的内部类,Collections提供了synchronizedList方法,可以将一个线程不安全的List包装成线程安全的List,即SynchronizedList。它比Vector有更好的扩展性和兼容性,但是它所有的方法都带有同步锁,也不是性能最优的List。

  3. CopyOnWriteArrayList

    CopyOnWriteArrayList是Java 1.5在java.util.concurrent包下增加的类,它采用复制底层数组的方式来实现写操作。当线程对此类集合执行读取操作时,线程将会直接读取集合本身,无须加锁与阻塞。当线程对此类集合执行写入操作时,集合会在底层复制一份新的数组,接下来对新的数组执行写入操作。由于对集合的写入操作都是对数组的副本执行操作,因此它是线程安全的。在所有线程安全的List中,它是性能最优的方案。

2.26 介绍一下ArrayList的数据结构?

参考答案

ArrayList的底层是用数组来实现的,默认第一次插入元素时创建大小为10的数组,超出限制时会增加50%的容量,并且数据以 System.arraycopy() 复制到新的数组,因此最好能给出数组大小的预估值。

按数组下标访问元素的性能很高,这是数组的基本优势。直接在数组末尾加入元素的性能也高,但如果按下标插入、删除元素,则要用 System.arraycopy() 来移动部分受影响的元素,性能就变差了,这是基本劣势。

2.27 谈谈CopyOnWriteArrayList的原理

参考答案

CopyOnWriteArrayList是Java并发包里提供的并发类,简单来说它就是一个线程安全且读操作无锁的ArrayList。正如其名字一样,在写操作时会复制一份新的List,在新的List上完成写操作,然后再将原引用指向新的List。这样就保证了写操作的线程安全。

CopyOnWriteArrayList允许线程并发访问读操作,这个时候是没有加锁限制的,性能较高。而写操作的时候,则首先将容器复制一份,然后在新的副本上执行写操作,这个时候写操作是上锁的。结束之后再将原容器的引用指向新容器。注意,在上锁执行写操作的过程中,如果有需要读操作,会作用在原容器上。因此上锁的写操作不会影响到并发访问的读操作。

  • 优点:读操作性能很高,因为无需任何同步措施,比较适用于读多写少的并发场景。在遍历传统的List时,若中途有别的线程对其进行修改,则会抛出ConcurrentModificationException异常。而CopyOnWriteArrayList由于其"读写分离"的思想,遍历和修改操作分别作用在不同的List容器,所以在使用迭代器进行遍历时候,也就不会抛出ConcurrentModificationException异常了。
  • 缺点:一是内存占用问题,毕竟每次执行写操作都要将原容器拷贝一份,数据量大时,对内存压力较大,可能会引起频繁GC。二是无法保证实时性,Vector对于读写操作均加锁同步,可以保证读和写的强一致性。而CopyOnWriteArrayList由于其实现策略的原因,写和读分别作用在新老不同容器上,在写操作执行过程中,读不会阻塞但读取到的却是老容器的数据。

2.28 说一说TreeSet和HashSet的区别

参考答案

HashSet、TreeSet中的元素都是不能重复的,并且它们都是线程不安全的,二者的区别是:

  1. HashSet中的元素可以是null,但TreeSet中的元素不能是null;
  2. HashSet不能保证元素的排列顺序,而TreeSet支持自然排序、定制排序两种排序的方式;
  3. HashSet底层是采用哈希表实现的,而TreeSet底层是采用红黑树实现的。

2.29 说一说HashSet的底层结构

参考答案

HashSet是基于HashMap实现的,默认构造函数是构建一个初始容量为16,负载因子为0.75 的HashMap。它封装了一个 HashMap 对象来存储所有的集合元素,所有放入 HashSet 中的集合元素实际上由 HashMap 的 key 来保存,而 HashMap 的 value 则存储了一个 PRESENT,它是一个静态的 Object 对象。

2.30 BlockingQueue中有哪些方法,为什么这样设计?

参考答案

为了应对不同的业务场景,BlockingQueue 提供了4 组不同的方法用于插入、移除以及对队列中的元素进行检查。如果请求的操作不能得到立即执行的话,每组方法的表现是不同的。这些方法如下:

抛异常 特定值 阻塞 超时
插入 add(e) offer(e) put(e) offer(e, time, unit)
移除 remove() poll() take() poll(time, unit)
检查 element() peek()

四组不同的行为方式含义如下:

  • 抛异常:如果操作无法立即执行,则抛一个异常;
  • 特定值:如果操作无法立即执行,则返回一个特定的值(一般是 true / false)。
  • 阻塞:如果操作无法立即执行,则该方法调用将会发生阻塞,直到能够执行;
  • 超时:如果操作无法立即执行,则该方法调用将会发生阻塞,直到能够执行。但等待时间不会超过给定值,并返回一个特定值以告知该操作是否成功(典型的是true / false)。

2.31 BlockingQueue是怎么实现的?

参考答案

BlockingQueue是一个接口,它的实现类有ArrayBlockingQueue、DelayQueue、 LinkedBlockingQueue、PriorityBlockingQueue、SynchronousQueue等。它们的区别主要体现在存储结构上或对元素操作上的不同,但是对于put与take操作的原理是类似的。下面以ArrayBlockingQueue为例,来说明BlockingQueue的实现原理。

首先看一下ArrayBlockingQueue的构造函数,它初始化了put和take函数中用到的关键成员变量,这两个变量的类型分别是ReentrantLock和Condition。ReentrantLock是AbstractQueuedSynchronizer(AQS)的子类,它的newCondition函数返回的Condition实例,是定义在AQS类内部的ConditionObject类,该类可以直接调用AQS相关的函数。

public ArrayBlockingQueue(int capacity, boolean fair) {      if (capacity <= 0)          throw new IllegalArgumentException();      this.items = new Object[capacity];      lock = new ReentrantLock(fair);      notEmpty = lock.newCondition();      notFull = lock.newCondition();  }

put函数会在队列末尾添加元素,如果队列已经满了,无法添加元素的话,就一直阻塞等待到可以加入为止。函数的源码如下所示。我们会发现put函数使用了wait/notify的机制。与一般生产者-消费者的实现方式不同,同步队列使用ReentrantLock和Condition相结合的机制,即先获得锁,再等待,而不是synchronized和wait的机制。

public void put(E e) throws InterruptedException {      checkNotNull(e);      final ReentrantLock lock = this.lock;      lock.lockInterruptibly();      try {          while (count == items.length)              notFull.await();          enqueue(e);      } finally {          lock.unlock();      }  }

再来看一下消费者调用的take函数,take函数在队列为空时会被阻塞,一直到阻塞队列加入了新的元素。

public E take() throws InterruptedException {      final ReentrantLock lock = this.lock;      lock.lockInterruptibly();      try {          while (count == 0)              notEmpty.await();          return dequeue();      } finally {          lock.unlock();      }  }

扩展阅读

await操作:

我们发现ArrayBlockingQueue并没有使用Object.wait,而是使用的Condition.await,这是为什么呢?Condition对象可以提供和Object的wait和notify一样的行为,但是后者必须先获取synchronized这个内置的monitor锁才能调用,而Condition则必须先获取ReentrantLock。这两种方式在阻塞等待时都会将相应的锁释放掉,但是Condition的等待可以中断,这是二者唯一的区别。

我们先来看一下Condition的await函数,await函数的流程大致如下图所示。await函数主要有三个步骤,一是调用addConditionWaiter函数,在condition wait queue队列中添加一个节点,代表当前线程在等待一个消息。然后调用fullyRelease函数,将持有的锁释放掉,调用的是AQS的函数。最后一直调用isOnSyncQueue函数判断节点是否被转移到sync queue队列上,也就是AQS中等待获取锁的队列。如果没有,则进入阻塞状态,如果已经在队列上,则调用acquireQueued函数重新获取锁。

signal操作:

signal函数将condition wait queue队列中队首的线程节点转移等待获取锁的sync queue队列中。这样的话,await函数中调用isOnSyncQueue函数就会返回true,导致await函数进入最后一步重新获取锁的状态。

我们这里来详细解析一下condition wait queue和sync queue两个队列的设计原理。condition wait queue是等待消息的队列,因为阻塞队列为空而进入阻塞状态的take函数操作就是在等待阻塞队列不为空的消息。而sync queue队列则是等待获取锁的队列,take函数获得了消息,就可以运行了,但是它还必须等待获取锁之后才能真正进行运行状态。

signal函数其实就做了一件事情,就是不断尝试调用transferForSignal函数,将condition wait queue队首的一个节点转移到sync queue队列中,直到转移成功。因为一次转移成功,就代表这个消息被成功通知到了等待消息的节点。

2.32 Stream(不是IOStream)有哪些方法?

参考答案

Stream提供了大量的方法进行聚集操作,这些方法既可以是“中间的”,也可以是“末端的”。

  • 中间方法:中间操作允许流保持打开状态,并允许直接调用后续方法。上面程序中的map()方法就是中间方法。中间方法的返回值是另外一个流。
  • 末端方法:末端方法是对流的最终操作。当对某个Stream执行末端方法后,该流将会被“消耗”且不再可用。上面程序中的sum()、count()、average()等方法都是末端方法。

除此之外,关于流的方法还有如下两个特征:

  • 有状态的方法:这种方法会给流增加一些新的属性,比如元素的唯一性、元素的最大数量、保证元素以排序的方式被处理等。有状态的方法往往需要更大的性能开销。
  • 短路方法:短路方法可以尽早结束对流的操作,不必检查所有的元素。

下面简单介绍一下Stream常用的中间方法:

  • filter(Predicate predicate):过滤Stream中所有不符合predicate的元素。
  • mapToXxx(ToXxxFunction mapper):使用ToXxxFunction对流中的元素执行一对一的转换,该方法返回的新流中包含了ToXxxFunction转换生成的所有元素。
  • peek(Consumer action):依次对每个元素执行一些操作,该方法返回的流与原有流包含相同的元素。该方法主要用于调试。
  • distinct():该方法用于排序流中所有重复的元素(判断元素重复的标准是使用equals()比较返回true)。这是一个有状态的方法。
  • sorted():该方法用于保证流中的元素在后续的访问中处于有序状态。这是一个有状态的方法。
  • limit(long maxSize):该方法用于保证对该流的后续访问中最大允许访问的元素个数。这是一个有状态的、短路方法。

下面简单介绍一下Stream常用的末端方法:

  • forEach(Consumer action):遍历流中所有元素,对每个元素执行action。
  • toArray():将流中所有元素转换为一个数组。
  • reduce():该方法有三个重载的版本,都用于通过某种操作来合并流中的元素。
  • min():返回流中所有元素的最小值。
  • max():返回流中所有元素的最大值。
  • count():返回流中所有元素的数量。
  • anyMatch(Predicate predicate):判断流中是否至少包含一个元素符合Predicate条件。
  • noneMatch(Predicate predicate):判断流中是否所有元素都不符合Predicate条件。
  • findFirst():返回流中的第一个元素。
  • findAny():返回流中的任意一个元素。

除此之外,Java 8允许使用流式API来操作集合,Collection接口提供了一个stream()默认方法,该方法可返回该集合对应的流,接下来即可通过流式API来操作集合元素。由于Stream可以对集合元素进行整体的聚集操作,因此Stream极大地丰富了集合的功能。

扩展阅读

Java 8新增了Stream、IntStream、LongStream、DoubleStream等流式API,这些API代表多个支持串行和并行聚集操作的元素。上面4个接口中,Stream是一个通用的流接口,而IntStream、LongStream、DoubleStream则代表元素类型为int、long、double的流。

Java 8还为上面每个流式API提供了对应的Builder,例如Stream.Builder、IntStream.Builder、LongStream.Builder、DoubleStream.Builder,开发者可以通过这些Builder来创建对应的流。

独立使用Stream的步骤如下:

  1. 使用Stream或XxxStream的builder()类方法创建该Stream对应的Builder。
  2. 重复调用Builder的add()方法向该流中添加多个元素。
  3. 调用Builder的build()方法获取对应的Stream。
  4. 调用Stream的聚集方法。

在上面4个步骤中,第4步可以根据具体需求来调用不同的方法,Stream提供了大量的聚集方法供用户调用,具体可参考Stream或XxxStream的API文档。对于大部分聚集方法而言,每个Stream只能执行一次。

3. IO

3.1 介绍一下Java中的IO流

参考答案

IO(Input Output)用于实现对数据的输入与输出操作,Java把不同的输入/输出源(键盘、文件、网络等)抽象表述为流(Stream)。流是从起源到接收的有序数据,有了它程序就可以采用同一方式访问不同的输入/输出源。

  • 按照数据流向,可以将流分为输入流和输出流,其中输入流只能读取数据、不能写入数据,而输出流只能写入数据、不能读取数据。
  • 按照数据类型,可以将流分为字节流和字符流,其中字节流操作的数据单元是8位的字节,而字符流操作的数据单元是16位的字符。
  • 按照处理功能,可以将流分为节点流和处理流,其中节点流可以直接从/向一个特定的IO设备(磁盘、网络等)读/写数据,也称为低级流,而处理流是对节点流的连接或封装,用于简化数据读/写功能或提高效率,也称为高级流。

Java提供了大量的类来支持IO操作,下表给大家整理了其中比较常用的一些类。其中,黑色字体的是抽象基类,其他所有的类都继承自它们。红色字体的是节点流,蓝色字体的是处理流。

根据命名很容易理解各个流的作用:

  • 以File开头的文件流用于访问文件;
  • 以ByteArray/CharArray开头的流用于访问内存中的数组;
  • 以Piped开头的管道流用于访问管道,实现进程之间的通信;
  • 以String开头的流用于访问内存中的字符串;
  • 以Buffered开头的缓冲流,用于在读写数据时对数据进行缓存,以减少IO次数;
  • InputStreamReader、InputStreamWriter是转换流,用于将字节流转换为字符流;
  • 以Object开头的流是对象流,用于实现对象的序列化;
  • 以Print开头的流是打印流,用于简化打印操作;
  • 以Pushback开头的流是推回输入流,用于将已读入的数据推回到缓冲区,从而实现再次读取;
  • 以Data开头的流是特殊流,用于读写Java基本类型的数据。

3.2 怎么用流打开一个大文件?

参考答案

打开大文件,应避免直接将文件中的数据全部读取到内存中,可以采用分次读取的方式。

  1. 使用缓冲流。缓冲流内部维护了一个缓冲区,通过与缓冲区的交互,减少与设备的交互次数。使用缓冲输入流时,它每次会读取一批数据将缓冲区填满,每次调用读取方法并不是直接从设备取值,而是从缓冲区取值,当缓冲区为空时,它会再一次读取数据,将缓冲区填满。使用缓冲输出流时,每次调用写入方法并不是直接写入到设备,而是写入缓冲区,当缓冲区填满时它会自动刷入设备。
  2. 使用NIO。NIO采用内存映射文件的方式来处理输入/输出,NIO将文件或文件的一段区域映射到内存中,这样就可以像访问内存一样来访问文件了(这种方式模拟了操作系统上的虚拟内存的概念),通过这种方式来进行输入/输出比传统的输入/输出要快得多。

3.3 说说NIO的实现原理

参考答案

Java的NIO主要由三个核心部分组成:Channel、Buffer、Selector。

基本上,所有的IO在NIO中都从一个Channel开始,数据可以从Channel读到Buffer中,也可以从Buffer写到Channel中。Channel有好几种类型,其中比较常用的有FileChannel、DatagramChannel、SocketChannel、ServerSocketChannel等,这些通道涵盖了UDP和TCP网络IO以及文件IO。

Buffer本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。Java NIO里关键的Buffer实现有CharBuffer、ByteBuffer、ShortBuffer、IntBuffer、LongBuffer、FloatBuffer、DoubleBuffer。这些Buffer覆盖了你能通过IO发送的基本数据类型,即byte、short、int、long、float、double、char。

Buffer对象包含三个重要的属性,分别是capacity、position、limit,其中position和limit的含义取决于Buffer处在读模式还是写模式。但不管Buffer处在什么模式,capacity的含义总是一样的。

  • capacity:作为一个内存块,Buffer有个固定的最大值,就是capacity。Buffer只能写capacity个数据,一旦Buffer满了,需要将其清空才能继续写数据往里写数据。
  • position:当写数据到Buffer中时,position表示当前的位置。初始的position值为0。当一个数据写到Buffer后, position会向前移动到下一个可插入数据的Buffer单元。position最大可为capacity–1。当读取数据时,也是从某个特定位置读。当将Buffer从写模式切换到读模式,position会被重置为0。当从Buffer的position处读取数据时,position向前移动到下一个可读的位置。
  • limit:在写模式下,Buffer的limit表示最多能往Buffer里写多少数据,此时limit等于capacity。当切换Buffer到读模式时, limit表示你最多能读到多少数据,此时limit会被设置成写模式下的position值。

Selector允许单线程处理多个 Channel,如果你的应用打开了多个连接(通道),但每个连接的流量都很低,使用Selector就会很方便。要使用Selector,得向Selector注册Channel,然后调用它的select()方法。这个方法会一直阻塞到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件,事件例如有新连接进来,数据接收等。

这是在一个单线程中使用一个Selector处理3个Channel的图示:

扩展阅读

Java NIO根据操作系统不同, 针对NIO中的Selector有不同的实现:

  • macosx:KQueueSelectorProvider
  • solaris:DevPollSelectorProvider
  • Linux:EPollSelectorProvider (Linux kernels >= 2.6)或PollSelectorProvider
  • windows:WindowsSelectorProvider

所以不需要特别指定,Oracle JDK会自动选择合适的Selector。如果想设置特定的Selector,可以设置属性,例如: -Djava.nio.channels.spi.SelectorProvider=sun.nio.ch.EPollSelectorProvider。

JDK在Linux已经默认使用epoll方式,但是JDK的epoll采用的是水平触发,所以Netty自4.0.16起, Netty为Linux通过JNI的方式提供了native socket transport。Netty重新实现了epoll机制。

  1. 采用边缘触发方式;
  2. netty epoll transport暴露了更多的nio没有的配置参数,如 TCP_CORK, SO_REUSEADDR等等;
  3. C代码,更少GC,更少synchronized。

3.4 介绍一下Java的序列化与反序列化

参考答案

序列化机制可以将对象转换成字节序列,这些字节序列可以保存在磁盘上,也可以在网络中传输,并允许程序将这些字节序列再次恢复成原来的对象。其中,对象的序列化(Serialize),是指将一个Java对象写入IO流中,对象的反序列化(Deserialize),则是指从IO流中恢复该Java对象。

若对象要支持序列化机制,则它的类需要实现Serializable接口,该接口是一个标记接口,它没有提供任何方法,只是标明该类是可以序列化的,Java的很多类已经实现了Serializable接口,如包装类、String、Date等。

若要实现序列化,则需要使用对象流ObjectInputStream和ObjectOutputStream。其中,在序列化时需要调用ObjectOutputStream对象的writeObject()方法,以输出对象序列。在反序列化时需要调用ObjectInputStream对象的readObject()方法,将对象序列恢复为对象。

3.5 Serializable接口为什么需要定义serialVersionUID变量?

参考答案

serialVersionUID代表序列化的版本,通过定义类的序列化版本,在反序列化时,只要对象中所存的版本和当前类的版本一致,就允许做恢复数据的操作,否则将会抛出序列化版本不一致的错误。

如果不定义序列化版本,在反序列化时可能出现冲突的情况,例如:

  1. 创建该类的实例,并将这个实例序列化,保存在磁盘上;
  2. 升级这个类,例如增加、删除、修改这个类的成员变量;
  3. 反序列化该类的实例,即从磁盘上恢复修改之前保存的数据。

在第3步恢复数据的时候,当前的类已经和序列化的数据的格式产生了冲突,可能会发生各种意想不到的问题。增加了序列化版本之后,在这种情况下则可以抛出异常,以提示这种矛盾的存在,提高数据的安全性。

3.6 除了Java自带的序列化之外,你还了解哪些序列化工具?

参考答案

  • JSON:目前使用比较频繁的格式化数据工具,简单直观,可读性好,有jackson,gson,fastjson等等,比较优秀的JSON解析工具的表现还是比较好的,有些json解析工具甚至速度超过了一些二进制的序列化方式。
  • Protobuf:一个用来序列化结构化数据的技术,支持多种语言诸如C++、Java以及Python语言,可以使用该技术来持久化数据或者序列化成网络传输的数据。相比较一些其他的XML技术而言,该技术的一个明显特点就是更加节省空间(以二进制流存储)、速度更快以及更加灵活。另外Protobuf支持的数据类型相对较少,不支持常量类型。由于其设计的理念是纯粹的展现层协议(Presentation Layer),目前并没有一个专门支持Protobuf的RPC框架。
  • Thrift:是Facebook开源提供的一个高性能,轻量级RPC服务框架,其产生正是为了满足当前大数据量、分布式、跨语言、跨平台数据通讯的需求。 但是,Thrift并不仅仅是序列化协议,而是一个RPC框架。 相对于JSON和XML而言,Thrift在空间开销和解析性能上有了比较大的提升,对于对性能要求比较高的分布式系统,它是一个优秀的RPC解决方案。但是由于Thrift的序列化被嵌入到Thrift框架里面, Thrift框架本身并没有透出序列化和反序列化接口,这导致其很难和其他传输层协议共同使用(例如HTTP)。
  • Avro:提供两种序列化格式,即JSON格式或者Binary格式。Binary格式在空间开销和解析性能方面可以和Protobuf媲美, JSON格式方便测试阶段的调试。 Avro支持的数据类型非常丰富,包括C++语言里面的union类型。Avro支持JSON格式的IDL和类似于Thrift和Protobuf的IDL(实验阶段),这两者之间可以互转。Schema可以在传输数据的同时发送,加上JSON的自我描述属性,这使得Avro非常适合动态类型语言。 Avro在做文件持久化的时候,一般会和Schema一起存储,所以Avro序列化文件自身具有自我描述属性,所以非常适合于做Hive、Pig和MapReduce的持久化数据格式。对于不同版本的Schema,在进行RPC调用的时候,服务端和客户端可以在握手阶段对Schema进行互相确认,大大提高了最终的数据解析速度。

3.7 如果不用JSON工具,该如何实现对实体类的序列化?

参考答案

可以使用Java原生的序列化机制,但是效率比较低一些,适合小项目;

可以使用其他的一些第三方类库,比如Protobuf、Thrift、Avro等。

4. 多线程

4.1 创建线程有哪几种方式?

参考答案

创建线程有三种方式,分别是继承Thread类、实现Runnable接口、实现Callable接口。

通过继承Thread类来创建并启动线程的步骤如下:

  1. 定义Thread类的子类,并重写该类的run()方法,该run()方法将作为线程执行体。
  2. 创建Thread子类的实例,即创建了线程对象。
  3. 调用线程对象的start()方法来启动该线程。

通过实现Runnable接口来创建并启动线程的步骤如下:

  1. 定义Runnable接口的实现类,并实现该接口的run()方法,该run()方法将作为线程执行体。
  2. 创建Runnable实现类的实例,并将其作为Thread的target来创建Thread对象,Thread对象为线程对象。
  3. 调用线程对象的start()方法来启动该线程。

通过实现Callable接口来创建并启动线程的步骤如下:

  1. 创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,且该call()方法有返回值。然后再创建Callable实现类的实例。
  2. 使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。
  3. 使用FutureTask对象作为Thread对象的target创建并启动新线程。
  4. 调用FutureTask对象的get()方法来获得子线程执行结束后的返回值。

扩展阅读

通过继承Thread类、实现Runnable接口、实现Callable接口都可以实现多线程,不过实现Runnable接口与实现Callable接口的方式基本相同,只是Callable接口里定义的方法有返回值,可以声明抛出异常而已。因此可以将实现Runnable接口和实现Callable接口归为一种方式。

采用实现Runnable、Callable接口的方式创建多线程的优缺点:

  • 线程类只是实现了Runnable接口或Callable接口,还可以继承其他类。
  • 在这种方式下,多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。
  • 劣势是,编程稍稍复杂,如果需要访问当前线程,则必须使用Thread.currentThread()方法。

采用继承Thread类的方式创建多线程的优缺点:

  • 劣势是,因为线程类已经继承了Thread类,所以不能再继承其他父类。
  • 优势是,编写简单,如果需要访问当前线程,则无须使用Thread.currentThread()方法,直接使用this即可获得当前线程。

鉴于上面分析,因此一般推荐采用实现Runnable接口、Callable接口的方式来创建多线程。

4.2 说说Thread类的常用方法

参考答案

Thread类常用构造方法:

  • Thread()
  • Thread(String name)
  • Thread(Runnable target)
  • Thread(Runnable target, String name)

其中,参数 name为线程名,参数 target为包含线程体的目标对象。

Thread类常用静态方法:

  • currentThread():返回当前正在执行的线程;
  • interrupted():返回当前执行的线程是否已经被中断;
  • sleep(long millis):使当前执行的线程睡眠多少毫秒数;
  • yield():使当前执行的线程自愿暂时放弃对处理器的使用权并允许其他线程执行;

Thread类常用实例方法:

  • getId():返回该线程的id;
  • getName():返回该线程的名字;
  • getPriority():返回该线程的优先级;
  • interrupt():使该线程中断;
  • isInterrupted():返回该线程是否被中断;
  • isAlive():返回该线程是否处于活动状态;
  • isDaemon():返回该线程是否是守护线程;
  • setDaemon(boolean on):将该线程标记为守护线程或用户线程,如果不标记默认是非守护线程;
  • setName(String name):设置该线程的名字;
  • setPriority(int newPriority):改变该线程的优先级;
  • join():等待该线程终止;
  • join(long millis):等待该线程终止,至多等待多少毫秒数。

4.3 run()和start()有什么区别?

参考答案

run()方法被称为线程执行体,它的方法体代表了线程需要完成的任务,而start()方法用来启动线程。

调用start()方法启动线程时,系统会把该run()方法当成线程执行体来处理。但如果直接调用线程对象的run()方法,则run()方法立即就会被执行,而且在run()方法返回之前其他线程无法并发执行。也就是说,如果直接调用线程对象的run()方法,系统把线程对象当成一个普通对象,而run()方法也是一个普通方法,而不是线程执行体。

4.4 线程是否可以重复启动,会有什么后果?

参考答案

只能对处于新建状态的线程调用start()方法,否则将引发IllegalThreadStateException异常。

扩展阅读

当程序使用new关键字创建了一个线程之后,该线程就处于新建状态,此时它和其他的Java对象一样,仅仅由Java虚拟机为其分配内存,并初始化其成员变量的值。此时的线程对象没有表现出任何线程的动态特征,程序也不会执行线程的线程执行体。

当线程对象调用了start()方法之后,该线程处于就绪状态,Java虚拟机会为其创建方法调用栈和程序计数器,处于这个状态中的线程并没有开始运行,只是表示该线程可以运行了。至于该线程何时开始运行,取决于JVM里线程调度器的调度。

4.5 介绍一下线程的生命周期

参考答案

在线程的生命周期中,它要经过新建(New)、就绪(Ready)、运行(Running)、阻塞(Blocked)和死亡(Dead)5种状态。尤其是当线程启动以后,它不可能一直“霸占”着CPU独自运行,所以CPU需要在多条线程之间切换,于是线程状态也会多次在运行、就绪之间切换。

当程序使用new关键字创建了一个线程之后,该线程就处于新建状态,此时它和其他的Java对象一样,仅仅由Java虚拟机为其分配内存,并初始化其成员变量的值。此时的线程对象没有表现出任何线程的动态特征,程序也不会执行线程的线程执行体。

当线程对象调用了start()方法之后,该线程处于就绪状态,Java虚拟机会为其创建方法调用栈和程序计数器,处于这个状态中的线程并没有开始运行,只是表示该线程可以运行了。至于该线程何时开始运行,取决于JVM里线程调度器的调度。

如果处于就绪状态的线程获得了CPU,开始执行run()方法的线程执行体,则该线程处于运行状态,如果计算机只有一个CPU,那么在任何时刻只有一个线程处于运行状态。当然,在一个多处理器的机器上,将会有多个线程并行执行;当线程数大于处理器数时,依然会存在多个线程在同一个CPU上轮换的现象。

当一个线程开始运行后,它不可能一直处于运行状态,线程在运行过程中需要被中断,目的是使其他线程获得执行的机会,线程调度的细节取决于底层平台所采用的策略。对于采用抢占式策略的系统而言,系统会给每个可执行的线程一个小时间段来处理任务。当该时间段用完后,系统就会剥夺该线程所占用的资源,让其他线程获得执行的机会。当发生如下情况时,线程将会进入阻塞状态:

  • 线程调用sleep()方法主动放弃所占用的处理器资源。
  • 线程调用了一个阻塞式IO方法,在该方法返回之前,该线程被阻塞。
  • 线程试图获得一个同步监视器,但该同步监视器正被其他线程所持有。
  • 线程在等待某个通知(notify)。
  • 程序调用了线程的suspend()方法将该线程挂起。但这个方法容易导致死锁,所以应该尽量避免使用该方法。

针对上面几种情况,当发生如下特定的情况时可以解除上面的阻塞,让该线程重新进入就绪状态:

  • 调用sleep()方法的线程经过了指定时间。
  • 线程调用的阻塞式IO方法已经返回。
  • 线程成功地获得了试图取得的同步监视器。
  • 线程正在等待某个通知时,其他线程发出了一个通知。
  • 处于挂起状态的线程被调用了resume()恢复方法。

线程会以如下三种方式结束,结束后就处于死亡状态:

  • run()或call()方法执行完成,线程正常结束。
  • 线程抛出一个未捕获的Exception或Error。
  • 直接调用该线程的stop()方法来结束该线程,该方法容易导致死锁,通常不推荐使用。

4.6 如何实现线程同步?

参考答案

  1. 同步方法

    即有synchronized关键字修饰的方法,由于java的每个对象都有一个内置锁,当用此关键字修饰方法时, 内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。需要注意, synchronized关键字也可以修饰静态方法,此时如果调用该静态方法,将会锁住整个类。

  2. 同步代码块

    即有synchronized关键字修饰的语句块,被该关键字修饰的语句块会自动被加上内置锁,从而实现同步。需值得注意的是,同步是一种高开销的操作,因此应该尽量减少同步的内容。通常没有必要同步整个方法,使用synchronized代码块同步关键代码即可。

  3. ReentrantLock

    Java 5新增了一个java.util.concurrent包来支持同步,其中ReentrantLock类是可重入、互斥、实现了Lock接口的锁,它与使用synchronized方法和快具有相同的基本行为和语义,并且扩展了其能力。需要注意的是,ReentrantLock还有一个可以创建公平锁的构造方法,但由于能大幅度降低程序运行效率,因此不推荐使用。

  4. volatile

    volatile关键字为域变量的访问提供了一种免锁机制,使用volatile修饰域相当于告诉虚拟机该域可能会被其他线程更新,因此每次使用该域就要重新计算,而不是使用寄存器中的值。需要注意的是,volatile不会提供任何原子操作,它也不能用来修饰final类型的变量。

  5. 原子变量

    在java的util.concurrent.atomic包中提供了创建了原子类型变量的工具类,使用该类可以简化线程同步。例如AtomicInteger 表可以用原子方式更新int的值,可用在应用程序中(如以原子方式增加的计数器),但不能用于替换Integer。可扩展Number,允许那些处理机遇数字类的工具和实用工具进行统一访问。

4.7 说一说Java多线程之间的通信方式

参考答案

在Java中线程通信主要有以下三种方式:

  1. wait()、notify()、notifyAll()

    如果线程之间采用synchronized来保证线程安全,则可以利用wait()、notify()、notifyAll()来实现线程通信。这三个方法都不是Thread类中所声明的方法,而是Object类中声明的方法。原因是每个对象都拥有锁,所以让当前线程等待某个对象的锁,当然应该通过这个对象来操作。并且因为当前线程可能会等待多个线程的锁,如果通过线程来操作,就非常复杂了。另外,这三个方法都是本地方法,并且被final修饰,无法被重写。

    wait()方法可以让当前线程释放对象锁并进入阻塞状态。notify()方法用于唤醒一个正在等待相应对象锁的线程,使其进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到CPU的执行。notifyAll()用于唤醒所有正在等待相应对象锁的线程,使它们进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到CPU的执行。

    每个锁对象都有两个队列,一个是就绪队列,一个是阻塞队列。就绪队列存储了已就绪(将要竞争锁)的线程,阻塞队列存储了被阻塞的线程。当一个阻塞线程被唤醒后,才会进入就绪队列,进而等待CPU的调度。反之,当一个线程被wait后,就会进入阻塞队列,等待被唤醒。

  2. await()、signal()、signalAll()

    如果线程之间采用Lock来保证线程安全,则可以利用await()、signal()、signalAll()来实现线程通信。这三个方法都是Condition接口中的方法,该接口是在Java 1.5中出现的,它用来替代传统的wait+notify实现线程间的协作,它的使用依赖于 Lock。相比使用wait+notify,使用Condition的await+signal这种方式能够更加安全和高效地实现线程间协作。

    Condition依赖于Lock接口,生成一个Condition的基本代码是lock.newCondition() 。 必须要注意的是,Condition 的 await()/signal()/signalAll() 使用都必须在lock保护之内,也就是说,必须在lock.lock()和lock.unlock之间才可以使用。事实上,await()/signal()/signalAll() 与 wait()/notify()/notifyAll()有着天然的对应关系。即:Conditon中的await()对应Object的wait(),Condition中的signal()对应Object的notify(),Condition中的signalAll()对应Object的notifyAll()。

  3. BlockingQueue

    Java 5提供了一个BlockingQueue接口,虽然BlockingQueue也是Queue的子接口,但它的主要用途并不是作为容器,而是作为线程通信的工具。BlockingQueue具有一个特征:当生产者线程试图向BlockingQueue中放入元素时,如果该队列已满,则该线程被阻塞;当消费者线程试图从BlockingQueue中取出元素时,如果该队列已空,则该线程被阻塞。

    程序的两个线程通过交替向BlockingQueue中放入元素、取出元素,即可很好地控制线程的通信。线程之间需要通信,最经典的场景就是生产者与消费者模型,而BlockingQueue就是针对该模型提供的解决方案。

4.8 说一说Java同步机制中的wait和notify

参考答案

wait()、notify()、notifyAll()用来实现线程之间的通信,这三个方法都不是Thread类中所声明的方法,而是Object类中声明的方法。原因是每个对象都拥有锁,所以让当前线程等待某个对象的锁,当然应该通过这个对象来操作。并且因为当前线程可能会等待多个线程的锁,如果通过线程来操作,就非常复杂了。另外,这三个方法都是本地方法,并且被final修饰,无法被重写,并且只有采用synchronized实现线程同步时才能使用这三个方法。

wait()方法可以让当前线程释放对象锁并进入阻塞状态。notify()方法用于唤醒一个正在等待相应对象锁的线程,使其进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到CPU的执行。notifyAll()方法用于唤醒所有正在等待相应对象锁的线程,使它们进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到CPU的执行。

每个锁对象都有两个队列,一个是就绪队列,一个是阻塞队列。就绪队列存储了已就绪(将要竞争锁)的线程,阻塞队列存储了被阻塞的线程。当一个阻塞线程被唤醒后,才会进入就绪队列,进而等待CPU的调度。反之,当一个线程被wait后,就会进入阻塞队列,等待被唤醒。

4.9 说一说sleep()和wait()的区别

参考答案

  1. sleep()是Thread类中的静态方法,而wait()是Object类中的成员方法;
  2. sleep()可以在任何地方使用,而wait()只能在同步方法或同步代码块中使用;
  3. sleep()不会释放锁,而wait()会释放锁,并需要通过notify()/notifyAll()重新获取锁。

4.10 说一说notify()、notifyAll()的区别

参考答案

  • notify()

    用于唤醒一个正在等待相应对象锁的线程,使其进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到CPU的执行。

  • notifyAll()

    用于唤醒所有正在等待相应对象锁的线程,使它们进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到CPU的执行。

4.11 如何实现子线程先执行,主线程再执行?

参考答案

启动子线程后,立即调用该线程的join()方法,则主线程必须等待子线程执行完成后再执行。

扩展阅读

Thread类提供了让一个线程等待另一个线程完成的方法——join()方法。当在某个程序执行流中调用其他线程的join()方法时,调用线程将被阻塞,直到被join()方法加入的join线程执行完为止。

join()方法通常由使用线程的程序调用,以将大问题划分成许多小问题,每个小问题分配一个线程。当所有的小问题都得到处理后,再调用主线程来进一步操作。

4.12 阻塞线程的方式有哪些?

参考答案

当发生如下情况时,线程将会进入阻塞状态:

  • 线程调用sleep()方法主动放弃所占用的处理器资源;
  • 线程调用了一个阻塞式IO方法,在该方法返回之前,该线程被阻塞;
  • 线程试图获得一个同步监视器,但该同步监视器正被其他线程所持有;
  • 线程在等待某个通知(notify);
  • 程序调用了线程的suspend()方法将该线程挂起,但这个方法容易导致死锁,所以应该尽量避免使用该方法。

4.13 说一说synchronized与Lock的区别

参考答案

  1. synchronized是Java关键字,在JVM层面实现加锁和解锁;Lock是一个接口,在代码层面实现加锁和解锁。
  2. synchronized可以用在代码块上、方法上;Lock只能写在代码里。
  3. synchronized在代码执行完或出现异常时自动释放锁;Lock不会自动释放锁,需要在finally中显示释放锁。
  4. synchronized会导致线程拿不到锁一直等待;Lock可以设置获取锁失败的超时时间。
  5. synchronized无法得知是否获取锁成功;Lock则可以通过tryLock得知加锁是否成功。
  6. synchronized锁可重入、不可中断、非公平;Lock锁可重入、可中断、可公平/不公平,并可以细分读写锁以提高效率。

4.14 说一说synchronized的底层实现原理

参考答案

一、synchronized作用在代码块时,它的底层是通过monitorenter、monitorexit指令来实现的。

  • monitorenter:

    每个对象都是一个监视器锁(monitor),当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

    如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1。如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

  • monitorexit:

    执行monitorexit的线程必须是objectref所对应的monitor持有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。

    monitorexit指令出现了两次,第1次为同步正常退出释放锁,第2次为发生异步退出释放锁。

二、方法的同步并没有通过 monitorenter 和 monitorexit 指令来完成,不过相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符。JVM就是根据该标示符来实现方法的同步的:

当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。

三、总结

两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。

4.15 synchronized可以修饰静态方法和静态代码块吗?

参考答案

synchronized可以修饰静态方法,但不能修饰静态代码块。

当修饰静态方法时,监视器锁(monitor)便是对象的Class实例,因为Class数据存在于永久代,因此静态方法锁相当于该类的一个全局锁。

4.16 谈谈ReentrantLock的实现原理

参考答案

ReentrantLock是基于AQS实现的,AQS即AbstractQueuedSynchronizer的缩写,这个是个内部实现了两个队列的抽象类,分别是同步队列和条件队列。其中同步队列是一个双向链表,里面储存的是处于等待状态的线程,正在排队等待唤醒去获取锁,而条件队列是一个单向链表,里面储存的也是处于等待状态的线程,只不过这些线程唤醒的结果是加入到了同步队列的队尾,AQS所做的就是管理这两个队列里面线程之间的等待状态-唤醒的工作。

在同步队列中,还存在2中模式,分别是独占模式和共享模式,这两种模式的区别就在于AQS在唤醒线程节点的时候是不是传递唤醒,这两种模式分别对应独占锁和共享锁。

AQS是一个抽象类,所以不能直接实例化,当我们需要实现一个自定义锁的时候可以去继承AQS然后重写获取锁的方式和释放锁的方式还有管理state,而ReentrantLock就是通过重写了AQS的tryAcquire和tryRelease方法实现的lock和unlock。

首先ReentrantLock 实现了 Lock 接口,然后有3个内部类,其中Sync内部类继承自AQS,另外的两个内部类继承自Sync,这两个类分别是用来公平锁和非公平锁的。通过Sync重写的方法tryAcquire、tryRelease可以知道,ReentrantLock实现的是AQS的独占模式,也就是独占锁,这个锁是悲观锁。

4.17 如果不使用synchronized和Lock,如何保证线程安全?

参考答案

  1. volatile

    volatile关键字为域变量的访问提供了一种免锁机制,使用volatile修饰域相当于告诉虚拟机该域可能会被其他线程更新,因此每次使用该域就要重新计算,而不是使用寄存器中的值。需要注意的是,volatile不会提供任何原子操作,它也不能用来修饰final类型的变量。

  2. 原子变量

    在java的util.concurrent.atomic包中提供了创建了原子类型变量的工具类,使用该类可以简化线程同步。例如AtomicInteger 表可以用原子方式更新int的值,可用在应用程序中(如以原子方式增加的计数器),但不能用于替换Integer。可扩展Number,允许那些处理机遇数字类的工具和实用工具进行统一访问。

  3. 本地存储

    可以通过ThreadLocal类来实现线程本地存储的功能。每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。

  4. 不可变的

    只要一个不可变的对象被正确地构建出来,那其外部的可见状态永远都不会改变,永远都不会看到它在多个线程之中处于不一致的状态,“不可变”带来的安全性是最直接、最纯粹的。Java语言中,如果多线程共享的数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的。如果共享数据是一个对象,由于Java语言目前暂时还没有提供值类型的支持,那就需要对象自行保证其行为不会对其状态产生任何影响才行。String类是一个典型的不可变类,可以参考它设计一个不可变类。

4.18 说一说Java中乐观锁和悲观锁的区别

参考答案

悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。Java中悲观锁是通过synchronized关键字或Lock接口来实现的。

乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据。乐观锁适用于多读的应用类型,这样可以提高吞吐量。在JDK1.5 中新增 java.util.concurrent (J.U.C)就是建立在CAS之上的。相对于对于 synchronized 这种阻塞算法,CAS是非阻塞算法的一种常见实现。所以J.U.C在性能上有了很大的提升。

4.19 公平锁与非公平锁是怎么实现的?

参考答案

在Java中实现锁的方式有两种,一种是使用Java自带的关键字synchronized对相应的类或者方法以及代码块进行加锁,另一种是ReentrantLock,前者只能是非公平锁,而后者是默认非公平但可实现公平的一把锁。

ReentrantLock是基于其内部类FairSync(公平锁)和NonFairSync(非公平锁)实现的,并且它的实现依赖于Java同步器框架AbstractQueuedSynchronizer(AQS),AQS使用一个整形的volatile变量state来维护同步状态,这个volatile变量是实现ReentrantLock的关键。

ReentrantLock 的公平锁和非公平锁都委托了 AbstractQueuedSynchronizer#acquire 去请求获取。

public final void acquire(int arg) {     if (!tryAcquire(arg) &&         acquireQueued(addWaiter(Node.EXCLUSIVE), arg))         selfInterrupt(); }
  • tryAcquire 是一个抽象方法,是公平与非公平的实现原理所在。
  • addWaiter 是将当前线程结点加入等待队列之中。公平锁在锁释放后会严格按照等到队列去取后续值,而非公平锁在对于新晋线程有很大优势。
  • acquireQueued 在多次循环中尝试获取到锁或者将当前线程阻塞。
  • selfInterrupt 如果线程在阻塞期间发生了中断,调用 Thread.currentThread().interrupt() 中断当前线程。

公平锁和非公平锁在说的获取上都使用到了 volatile 关键字修饰的state字段, 这是保证多线程环境下锁的获取与否的核心。但是当并发情况下多个线程都读取到 state == 0时,则必须用到CAS技术,一门CPU的原子锁技术,可通过CPU对共享变量加锁的形式,实现数据变更的原子操作。volatile 和 CAS的结合是并发抢占的关键。

  • 公平锁FairSync

    公平锁的实现机理在于每次有线程来抢占锁的时候,都会检查一遍有没有等待队列,如果有, 当前线程会执行如下步骤:

    if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {        setExclusiveOwnerThread(current);     return true;  }
    

    其中hasQueuedPredecessors是用于检查是否有等待队列的:

    public final boolean hasQueuedPredecessors() {     Node t = tail; // Read fields in reverse initialization order     Node h = head;     Node s;     return h != t &&         ((s = h.next) == null || s.thread != Thread.currentThread()); }
    
  • 非公平锁NonfairSync

    非公平锁在实现的时候多次强调随机抢占:

    if (c == 0) {  if (compareAndSetState(0, acquires)) {   setExclusiveOwnerThread(current);   return true;      } }
    

    与公平锁的区别在于新晋获取锁的进程会有多次机会去抢占锁,被加入了等待队列后则跟公平锁没有区别。

4.20 了解Java中的锁升级吗?

参考答案

JDK 1.6之前,synchronized 还是一个重量级锁,是一个效率比较低下的锁。但是在JDK 1.6后,JVM为了提高锁的获取与释放效率对synchronized 进行了优化,引入了偏向锁和轻量级锁 ,从此以后锁的状态就有了四种:无锁、偏向锁、轻量级锁、重量级锁。并且四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级,这四种锁的级别由低到高依次是:无锁、偏向锁,轻量级锁,重量级锁。

  1. 无锁

    无锁是指没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。无锁的特点是修改操作会在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。

  2. 偏向锁

    初次执行到synchronized代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。

    偏向锁是指当一段同步代码一直被同一个线程所访问时,即不存在多个线程的竞争时,那么该线程在后续访问时便会自动获得锁,从而降低获取锁带来的消耗,即提高性能。

    当一个线程访问同步代码块并获取锁时,会在 Mark Word 里存储锁偏向的线程 ID。在线程进入和退出同步块时不再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。

    偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。关于偏向锁的撤销,需要等待全局安全点,即在某个时间点上没有字节码正在执行时,它会先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁,恢复到无锁(标志位为01)或轻量级锁(标志位为00)的状态。

  3. 轻量级锁

    轻量级锁是指当锁是偏向锁的时候,却被另外的线程所访问,此时偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能。

    轻量级锁的获取主要由两种情况:

    1. 当关闭偏向锁功能时;
    2. 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁。

    一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。这里要明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。

    在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。

    长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。

  4. 重量级锁

    重量级锁显然,此忙等是有限度的(有个计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是CAS修改锁标志位,但不修改持有锁的线程ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。

    重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。简言之,就是所有的控制权都交给了操作系统,由操作系统来负责线程间的调度和线程的状态变更。而这样会出现频繁地对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资。

扩展阅读

synchronized 用的锁是存在Java对象头里的,那么什么是对象头呢?我们以 Hotspot 虚拟机为例进行说明,Hopspot 对象头主要包括两部分数据:Mark Word(标记字段) 和 Klass Pointer(类型指针)。

  • Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
  • Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

那么,synchronized 具体是存在对象头哪里呢?答案是:存在锁对象的对象头的Mark Word中,那么MarkWord在对象头中到底长什么样,它到底存储了什么呢?

下面我们以 32位虚拟机为例,来看一下其 Mark Word 的字节具体是如何分配的:

  • 无锁 :对象头开辟 25bit 的空间用来存储对象的 hashcode ,4bit 用于存放对象分代年龄,1bit 用来存放是否偏向锁的标识位,2bit 用来存放锁标识位为01。
  • 偏向锁: 在偏向锁中划分更细,还是开辟 25bit 的空间,其中23bit 用来存放线程ID,2bit 用来存放 Epoch,4bit 存放对象分代年龄,1bit 存放是否偏向锁标识, 0表示无锁,1表示偏向锁,锁的标识位还是01。
  • 轻量级锁:在轻量级锁中直接开辟 30bit 的空间存放指向栈中锁记录的指针,2bit 存放锁的标志位,其标志位为00。
  • 重量级锁: 在重量级锁中和轻量级锁一样,30bit 的空间用来存放指向重量级锁的指针,2bit 存放锁的标识位,为11。
  • GC标记: 开辟30bit 的内存空间却没有占用,2bit 空间存放锁标志位为11。

其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态。关于内存的分配,我们可以在git中openJDK中 markOop.hpp 可以看出:

public:   // Constants   enum { age_bits          = 4,          lock_bits         = 2,          biased_lock_bits  = 1,          max_hash_bits     = BitsPerWord - age_bits - lock_bits - biased_lock_bits,          hash_bits         = max_hash_bits > 31 ? 31 : max_hash_bits,          cms_bits          = LP64_ONLY(1) NOT_LP64(0),          epoch_bits        = 2  };
  • age_bits: 就是我们说的分代回收的标识,占用4字节。
  • lock_bits: 是锁的标志位,占用2个字节。
  • biased_lock_bits: 是是否偏向锁的标识,占用1个字节。
  • max_hash_bits: 是针对无锁计算的hashcode 占用字节数量,如果是32位虚拟机,就是 32 - 4 - 2 -1 = 25 byte,如果是64 位虚拟机,64 - 4 - 2 - 1 = 57 byte,但是会有 25 字节未使用,所以64位的 hashcode 占用 31 byte。
  • hash_bits: 是针对 64 位虚拟机来说,如果最大字节数大于 31,则取31,否则取真实的字节数。
  • cms_bits: 不是64位虚拟机就占用 0 byte,是64位就占用 1byte。
  • epoch_bits: 就是 epoch 所占用的字节大小,2字节。

4.21 如何实现互斥锁(mutex)?

参考答案

在Java里面,最基本的互斥同步手段就是synchronized关键字,这是一种块结构(Block Structured)的同步语法。synchronized关键字经过Javac编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java源码中的synchronized明确指定了对象参数,那就以这个对象的引用作为reference。如果没有明确指定,那将根据synchronized修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的Class对象来作为线程要持有的锁。

自JDK 5起,Java类库中新提供了java.util.concurrent包(J.U.C包),其中的java.util.concurrent.locks.Lock接口便成了Java的另一种全新的互斥同步手段。基于Lock接口,用户能够以非块结构(Non-Block Structured)来实现互斥同步,从而摆脱了语言特性的束缚,改为在类库层面去实现同步,这也为日后扩展出不同调度算法、不同特征、不同性能、不同语义的各种锁提供了广阔的空间。

4.22 分段锁是怎么实现的?

参考答案

在并发程序中,串行操作是会降低可伸缩性,并且上下文切换也会减低性能。在锁上发生竞争时将通水导致这两种问题,使用独占锁时保护受限资源的时候,基本上是采用串行方式—-每次只能有一个线程能访问它。所以对于可伸缩性来说最大的威胁就是独占锁。

我们一般有三种方式降低锁的竞争程度:

  1. 减少锁的持有时间;
  2. 降低锁的请求频率;
  3. 使用带有协调机制的独占锁,这些机制允许更高的并发性。

在某些情况下我们可以将锁分解技术进一步扩展为一组独立对象上的锁进行分解,这称为分段锁。其实说的简单一点就是:容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

如下图,ConcurrentHashMap使用Segment数据结构,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。所以说,ConcurrentHashMap在并发情况下,不仅保证了线程安全,而且提高了性能。

4.23 说说你对读写锁的了解

参考答案

与传统锁不同的是读写锁的规则是可以共享读,但只能一个写,总结起来为:读读不互斥、读写互斥、写写互斥,而一般的独占锁是:读读互斥、读写互斥、写写互斥,而场景中往往读远远大于写,读写锁就是为了这种优化而创建出来的一种机制。 注意是读远远大于写,一般情况下独占锁的效率低来源于高并发下对临界区的激烈竞争导致线程上下文切换。因此当并发不是很高的情况下,读写锁由于需要额外维护读锁的状态,可能还不如独占锁的效率高。因此需要根据实际情况选择使用。

在Java中ReadWriteLock的主要实现为ReentrantReadWriteLock,其提供了以下特性:

  1. 公平性选择:支持公平与非公平(默认)的锁获取方式,吞吐量非公平优先于公平。
  2. 可重入:读线程获取读锁之后可以再次获取读锁,写线程获取写锁之后可以再次获取写锁。
  3. 可降级:写线程获取写锁之后,其还可以再次获取读锁,然后释放掉写锁,那么此时该线程是读锁状态,也就是降级操作。

4.24 volatile关键字有什么用?

参考答案

当一个变量被定义成volatile之后,它将具备两项特性:

  1. 保证可见性

    当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去,这个写会操作会导致其他线程中的volatile变量缓存无效。

  2. 禁止指令重排

    使用volatile关键字修饰共享变量可以禁止指令重排序,volatile禁止指令重排序有一些规则:

    • 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见,在其后面的操作肯定还没有进行;
    • 在进行指令优化时,不能将对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

    即执行到volatile变量时,其前面的所有语句都执行完,后面所有语句都未执行。且前面语句的结果对volatile变量及其后面语句可见。

注意,虽然volatile能够保证可见性,但它不能保证原子性。volatile变量在各个线程的工作内存中是不存在一致性问题的,但是Java里面的运算操作符并非原子操作,这导致volatile变量的运算在并发下一样是不安全的。

4.25 谈谈volatile的实现原理

参考答案

volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障,内存屏障会提供3个功能:

  1. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  2. 它会强制将对缓存的修改操作立即写入主存;
  3. 如果是写操作,它会导致其他CPU中对应的缓存行无效。

4.26 说说你对JUC的了解

参考答案

JUC是java.util.concurrent的缩写,该包参考自EDU.oswego.cs.dl.util.concurrent,是JSR 166标准规范的一个实现。JSR 166是一个关于Java并发编程的规范提案,在JDK中该规范由java.util.concurrent包实现。即JUC是Java提供的并发包,其中包含了一些并发编程用到的基础组件。

JUC这个包下的类基本上包含了我们在并发编程时用到的一些工具,大致可以分为以下几类:

  • 原子更新

    Java从JDK1.5开始提供了java.util.concurrent.atomic包,方便程序员在多线程环 境下,无锁的进行原子操作。在Atomic包里一共有12个类,四种原子更新方式,分别是原子更新基本类型,原子更新 数组,原子更新引用和原子更新字段。

  • 锁和条件变量

    java.util.concurrent.locks包下包含了同步器的框架 AbstractQueuedSynchronizer,基于AQS构建的Lock以及与Lock配合可以实现等待/通知模式的Condition。JUC 下的大多数工具类用到了Lock和Condition来实现并发。

  • 线程池

    涉及到的类比如:Executor、Executors、ThreadPoolExector、 AbstractExecutorService、Future、Callable、ScheduledThreadPoolExecutor等等。

  • 阻塞队列

    涉及到的类比如:ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue、LinkedBlockingDeque等等。

  • 并发容器

    涉及到的类比如:ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue、CopyOnWriteArraySet等等。

  • 同步器

    剩下的是一些在并发编程中时常会用到的工具类,主要用来协助线程同步。比如:CountDownLatch、CyclicBarrier、Exchanger、Semaphore、FutureTask等等。

4.27 说说你对AQS的理解

参考答案

抽象队列同步器AbstractQueuedSynchronizer (以下都简称AQS),是用来构建锁或者其他同步组件的骨架类,减少了各功能组件实现的代码量,也解决了在实现同步器时涉及的大量细节问题,例如等待线程采用FIFO队列操作的顺序。在不同的同步器中还可以定义一些灵活的标准来判断某个线程是应该通过还是等待。

AQS采用模板方法模式,在内部维护了n多的模板的方法的基础上,子类只需要实现特定的几个方法(不是抽象方法!不是抽象方法!不是抽象方法!),就可以实现子类自己的需求。

基于AQS实现的组件,诸如:

  • ReentrantLock 可重入锁(支持公平和非公平的方式获取锁);
  • Semaphore 计数信号量;
  • ReentrantReadWriteLock 读写锁。

扩展阅读

AQS内部维护了一个int成员变量来表示同步状态,通过内置的FIFO(first-in-first-out)同步队列来控制获取共享资源的线程。

我们可以猜测出,AQS其实主要做了这么几件事情:

  • 同步状态(state)的维护管理;
  • 等待队列的维护管理;
  • 线程的阻塞与唤醒。

通过AQS内部维护的int型的state,可以用于表示任意状态!

  • ReentrantLock用它来表示锁的持有者线程已经重复获取该锁的次数,而对于非锁的持有者线程来说,如果state大于0,意味着无法获取该锁,将该线程包装为Node,加入到同步等待队列里。
  • Semaphore用它来表示剩余的许可数量,当许可数量为0时,对未获取到许可但正在努力尝试获取许可的线程来说,会进入同步等待队列,阻塞,直到一些线程释放掉持有的许可(state+1),然后争用释放掉的许可。
  • FutureTask用它来表示任务的状态(未开始、运行中、完成、取消)。
  • ReentrantReadWriteLock在使用时,稍微有些不同,int型state用二进制表示是32位,前16位(高位)表示为读锁,后面的16位(低位)表示为写锁。
  • CountDownLatch使用state表示计数次数,state大于0,表示需要加入到同步等待队列并阻塞,直到state等于0,才会逐一唤醒等待队列里的线程。

AQS通过内置的FIFO(first-in-first-out)同步队列来控制获取共享资源的线程。CLH队列是FIFO的双端双向队列,AQS的同步机制就是依靠这个CLH队列完成的。队列的每个节点,都有前驱节点指针和后继节点指针。

4.28 LongAdder解决了什么问题,它是如何实现的?

参考答案

高并发下计数,一般最先想到的应该是AtomicLong/AtomicInt,AtmoicXXX使用硬件级别的指令 CAS 来更新计数器的值,这样可以避免加锁,机器直接支持的指令,效率也很高。但是AtomicXXX中的 CAS 操作在出现线程竞争时,失败的线程会白白地循环一次,在并发很大的情况下,因为每次CAS都只有一个线程能成功,竞争失败的线程会非常多。失败次数越多,循环次数就越多,很多线程的CAS操作越来越接近 自旋锁(spin lock)。计数操作本来是一个很简单的操作,实际需要耗费的cpu时间应该是越少越好,AtomicXXX在高并发计数时,大量的cpu时间都浪费会在 自旋 上了,这很浪费,也降低了实际的计数效率。

LongAdder是jdk8新增的用于并发环境的计数器,目的是为了在高并发情况下,代替AtomicLong/AtomicInt,成为一个用于高并发情况下的高效的通用计数器。说LongAdder比在高并发时比AtomicLong更高效,这么说有什么依据呢?LongAdder是根据锁分段来实现的,它里面维护一组按需分配的计数单元,并发计数时,不同的线程可以在不同的计数单元上进行计数,这样减少了线程竞争,提高了并发效率。本质上是用空间换时间的思想,不过在实际高并发情况中消耗的空间可以忽略不计。

现在,在处理高并发计数时,应该优先使用LongAdder,而不是继续使用AtomicLong。当然,线程竞争很低的情况下进行计数,使用Atomic还是更简单更直接,并且效率稍微高一些。其他情况,比如序号生成,这种情况下需要准确的数值,全局唯一的AtomicLong才是正确的选择,此时不应该使用LongAdder。

4.29 介绍下ThreadLocal和它的应用场景

参考答案

ThreadLocal顾名思义是线程私有的局部变量存储容器,可以理解成每个线程都有自己专属的存储容器,它用来存储线程私有变量,其实它只是一个外壳,内部真正存取是一个Map。每个线程可以通过set()和get()存取变量,多线程间无法访问各自的局部变量,相当于在每个线程间建立了一个隔板。只要线程处于活动状态,它所对应的ThreadLocal实例就是可访问的,线程被终止后,它的所有实例将被垃圾收集。总之记住一句话:ThreadLocal存储的变量属于当前线程。

ThreadLocal经典的使用场景是为每个线程分配一个 JDBC 连接 Connection,这样就可以保证每个线程的都在各自的 Connection 上进行数据库的操作,不会出现 A 线程关了 B线程正在使用的 Connection。 另外ThreadLocal还经常用于管理Session会话,将Session保存在ThreadLocal中,使线程处理多次处理会话时始终是同一个Session。

4.30 请介绍ThreadLocal的实现原理,它是怎么处理hash冲突的?

参考答案

Thread类中有个变量threadLocals,它的类型为ThreadLocal中的一个内部类ThreadLocalMap,这个类没有实现map接口,就是一个普通的Java类,但是实现的类似map的功能。每个线程都有自己的一个map,map是一个数组的数据结构存储数据,每个元素是一个Entry,entry的key是ThreadLocal的引用,也就是当前变量的副本,value就是set的值。代码如下所示:

public class Thread implements Runnable {     /* ThreadLocal values pertaining to this thread. This map is maintained      * by the ThreadLocal class. */     ThreadLocal.ThreadLocalMap threadLocals = null;     }

ThreadLocalMap是ThreadLocal的内部类,每个数据用Entry保存,其中的Entry继承与WeakReference,用一个键值对存储,键为ThreadLocal的引用。为什么是WeakReference呢?如果是强引用,即使把ThreadLocal设置为null,GC也不会回收,因为ThreadLocalMap对它有强引用。代码如下所示:

static class Entry extends WeakReference<ThreadLocal<?>> {     /** The value associated with this ThreadLocal. */     Object value;      Entry(ThreadLocal<?> k, Object v) {         super(k);         value = v;     } }

ThreadLocal中的set方法的实现逻辑,先获取当前线程,取出当前线程的ThreadLocalMap,如果不存在就会创建一个ThreadLocalMap,如果存在就会把当前的threadlocal的引用作为键,传入的参数作为值存入map中。代码如下所示:

public void set(T value) {     Thread t = Thread.currentThread();     ThreadLocalMap map = getMap(t);     if (map != null) {         map.set(this, value);     } else {         createMap(t, value);     } }

ThreadLocal中get方法的实现逻辑,获取当前线程,取出当前线程的ThreadLocalMap,用当前的threadlocak作为key在ThreadLocalMap查找,如果存在不为空的Entry,就返回Entry中的value,否则就会执行初始化并返回默认的值。代码如下所示:

public T get() {     Thread t = Thread.currentThread();     ThreadLocalMap map = getMap(t);     if (map != null) {         ThreadLocalMap.Entry e = map.getEntry(this);         if (e != null) {             @SuppressWarnings("unchecked")             T result = (T)e.value;             return result;         }     }     return setInitialValue(); }

ThreadLocal中remove方法的实现逻辑,还是先获取当前线程的ThreadLocalMap变量,如果存在就调用ThreadLocalMap的remove方法。ThreadLocalMap的存储就是数组的实现,因此需要确定元素的位置,找到Entry,把entry的键值对都设为null,最后也Entry也设置为null。其实这其中会有哈希冲突,具体见下文。代码如下所示:

public void remove() {     ThreadLocalMap m = getMap(Thread.currentThread());     if (m != null) {         m.remove(this);     } }

ThreadLocal中的hash code非常简单,就是调用AtomicInteger的getAndAdd方法,参数是个固定值0x61c88647。上面说过ThreadLocalMap的结构非常简单只用一个数组存储,并没有链表结构,当出现Hash冲突时采用线性查找的方式,所谓线性查找,就是根据初始key的hashcode值确定元素在table数组中的位置,如果发现这个位置上已经有其他key值的元素被占用,则利用固定的算法寻找一定步长的下个位置,依次判断,直至找到能够存放的位置。如果产生多次hash冲突,处理起来就没有HashMap的效率高,为了避免哈希冲突,使用尽量少的threadlocal变量。

4.31 介绍一下线程池

参考答案

系统启动一个新线程的成本是比较高的,因为它涉及与操作系统交互。在这种情形下,使用线程池可以很好地提高性能,尤其是当程序中需要创建大量生存期很短暂的线程时,更应该考虑使用线程池。

与数据库连接池类似的是,线程池在系统启动时即创建大量空闲的线程,程序将一个Runnable对象或Callable对象传给线程池,线程池就会启动一个空闲的线程来执行它们的run()或call()方法,当run()或call()方法执行结束后,该线程并不会死亡,而是再次返回线程池中成为空闲状态,等待执行下一个Runnable对象的run()或call()方法。

从Java 5开始,Java内建支持线程池。Java 5新增了一个Executors工厂类来产生线程池,该工厂类包含如下几个静态工厂方法来创建线程池。创建出来的线程池,都是通过ThreadPoolExecutor类来实现的。

  • newCachedThreadPool():创建一个具有缓存功能的线程池,系统根据需要创建线程,这些线程将会被缓存在线程池中。
  • newFixedThreadPool(int nThreads):创建一个可重用的、具有固定线程数的线程池。
  • newSingleThreadExecutor():创建一个只有单线程的线程池,它相当于调用newFixedThread Pool()方法时传入参数为1。
  • newScheduledThreadPool(int corePoolSize):创建具有指定线程数的线程池,它可以在指定延迟后执行线程任务。corePoolSize指池中所保存的线程数,即使线程是空闲的也被保存在线程池内。
  • newSingleThreadScheduledExecutor():创建只有一个线程的线程池,它可以在指定延迟后执行线程任务。
  • ExecutorService newWorkStealingPool(int parallelism):创建持有足够的线程的线程池来支持给定的并行级别,该方法还会使用多个队列来减少竞争。
  • ExecutorService newWorkStealingPool():该方法是前一个方法的简化版本。如果当前机器有4个CPU,则目标并行级别被设置为4,也就是相当于为前一个方法传入4作为参数。

4.32 介绍一下线程池的工作流程

参考答案

线程池的工作流程:

  1. 判断核心线程池是否已满,没满则创建一个新的工作线程来执行任务。
  2. 判断任务队列是否已满,没满则将新提交的任务添加在工作队列。
  3. 判断整个线程池是否已满,没满则创建一个新的工作线程来执行任务,已满则执行饱和(拒绝)策略。

4.33 线程池都有哪些状态?

参考答案

线程池一共有五种状态, 分别是:

  1. RUNNING :能接受新提交的任务,并且也能处理阻塞队列中的任务。
  2. SHUTDOWN:关闭状态,不再接受新提交的任务,但却可以继续处理阻塞队列中已保存的任务。在线程池处于 RUNNING 状态时,调用 shutdown()方法会使线程池进入到该状态。
  3. STOP:不能接受新任务,也不处理队列中的任务,会中断正在处理任务的线程。在线程池处于 RUNNING 或 SHUTDOWN 状态时,调用 shutdownNow() 方法会使线程池进入到该状态。
  4. TIDYING:如果所有的任务都已终止了,workerCount (有效线程数) 为0,线程池进入该状态后会调用 terminated() 方法进入TERMINATED 状态。
  5. TERMINATED:在terminated() 方法执行完后进入该状态,默认terminated()方法中什么也没有做。进入TERMINATED的条件如下:
    • 线程池不是RUNNING状态;
    • 线程池状态不是TIDYING状态或TERMINATED状态;
    • 如果线程池状态是SHUTDOWN并且workerQueue为空;
    • workerCount为0;
    • 设置TIDYING状态成功。

4.34 谈谈线程池的拒绝策略

参考答案

当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就会采取任务拒绝策略,通常有以下四种策略:

  1. AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。
  2. DiscardPolicy:也是丢弃任务,但是不抛出异常。
  3. DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复该过程)。
  4. CallerRunsPolicy:由调用线程处理该任务。

4.35 线程池的队列大小你通常怎么设置?

参考答案

  1. CPU密集型任务

    尽量使用较小的线程池,一般为CPU核心数+1。 因为CPU密集型任务使得CPU使用率很高,若开过多的线程数,会造成CPU过度切换。

  2. IO密集型任务

    可以使用稍大的线程池,一般为2*CPU核心数。 IO密集型任务CPU使用率并不高,因此可以让CPU在等待IO的时候有其他线程去处理别的任务,充分利用CPU时间。

  3. 混合型任务

    可以将任务分成IO密集型和CPU密集型任务,然后分别用不同的线程池去处理。 只要分完之后两个任务的执行时间相差不大,那么就会比串行执行来的高效。因为如果划分之后两个任务执行时间有数据级的差距,那么拆分没有意义。因为先执行完的任务就要等后执行完的任务,最终的时间仍然取决于后执行完的任务,而且还要加上任务拆分与合并的开销,得不偿失。

4.36 线程池有哪些参数,各个参数的作用是什么?

参考答案

线程池主要有如下6个参数:

  1. corePoolSize(核心工作线程数):当向线程池提交一个任务时,若线程池已创建的线程数小于corePoolSize,即便此时存在空闲线程,也会通过创建一个新线程来执行该任务,直到已创建的线程数大于或等于corePoolSize时。
  2. maximumPoolSize(最大线程数):线程池所允许的最大线程个数。当队列满了,且已创建的线程数小于maximumPoolSize,则线程池会创建新的线程来执行任务。另外,对于无界队列,可忽略该参数。
  3. keepAliveTime(多余线程存活时间):当线程池中线程数大于核心线程数时,线程的空闲时间如果超过线程存活时间,那么这个线程就会被销毁,直到线程池中的线程数小于等于核心线程数。
  4. workQueue(队列):用于传输和保存等待执行任务的阻塞队列。
  5. threadFactory(线程创建工厂):用于创建新线程。threadFactory创建的线程也是采用new Thread()方式,threadFactory创建的线程名都具有统一的风格:pool-m-thread-n(m为线程池的编号,n为线程池内的线程编号)。
  6. handler(拒绝策略):当线程池和队列都满了,再加入线程会执行此策略

5. JVM

5.1 JVM包含哪几部分?

参考答案

JVM 主要由四大部分组成:ClassLoader(类加载器),Runtime Data Area(运行时数据区,内存分区),Execution Engine(执行引擎),Native Interface(本地库接口。

JVM 是执行 Java 程序的虚拟计算机系统,那我们来看看执行过程:首先需要准备好编译好的 Java 字节码文件(即class文件),计算机要运行程序需要先通过一定方式(类加载器)将 class 文件加载到内存中(运行时数据区),但是字节码文件是JVM定义的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解释器(执行引擎)将字节码翻译成特定的操作系统指令集交给 CPU 去执行,这个过程中会需要调用到一些不同语言为 Java 提供的接口(例如驱动、地图制作等),这就用到了本地 Native 接口(本地库接口)。

  • ClassLoader:负责加载字节码文件即 class 文件,class 文件在文件开头有特定的文件标示,并且 ClassLoader 只负责class 文件的加载,至于它是否可以运行,则由 Execution Engine 决定。
  • Runtime Data Area:是存放数据的,分为五部分:Stack(虚拟机栈),Heap(堆),Method Area(方法区),PC Register(程序计数器),Native Method Stack(本地方法栈)。几乎所有的关于 Java 内存方面的问题,都是集中在这块。
  • Execution Engine:执行引擎,也叫 Interpreter。Class 文件被加载后,会把指令和数据信息放入内存中,Execution Engine 则负责把这些命令解释给操作系统,即将 JVM 指令集翻译为操作系统指令集。
  • Native Interface:负责调用本地接口的。他的作用是调用不同语言的接口给 JAVA 用,他会在 Native Method Stack 中记录对应的本地方法,然后调用该方法时就通过 Execution Engine 加载对应的本地 lib。原本多用于一些专业领域,如JAVA驱动,地图制作引擎等,现在关于这种本地方法接口的调用已经被类似于Socket通信,WebService等方式取代。

5.2 JVM是如何运行的?

参考答案

JVM的启动过程分为如下四个步骤:

  1. JVM的装入环境和配置

    java.exe负责查找JRE,并且它会按照如下的顺序来选择JRE:

    • 自己目录下的JRE;
    • 父级目录下的JRE;
    • 查注册中注册的JRE。
  2. 装载JVM

    通过第一步找到JVM的路径后,Java.exe通过LoadJavaVM来装入JVM文件。LoadLibrary装载JVM动态连接库,然后把JVM中的到处函数JNI_CreateJavaVM和JNI_GetDefaultJavaVMIntArgs 挂接到InvocationFunction 变量的CreateJavaVM和GetDafaultJavaVMInitArgs 函数指针变量上。JVM的装载工作完成。

  3. 初始化JVM,获得本地调用接口

    调用InvocationFunction -> CreateJavaVM,也就是JVM中JNI_CreateJavaVM方法获得JNIEnv结构的实例。

  4. 运行Java程序

    JVM运行Java程序的方式有两种:jar包 与 class。

    运行jar 的时候,java.exe调用GetMainClassName函数,该函数先获得JNIEnv实例然后调用JarFileJNIEnv类中getManifest(),从其返回的Manifest对象中取getAttrebutes(“Main-Class”)的值,即jar 包中文件:META-INF/MANIFEST.MF指定的Main-Class的主类名作为运行的主类。之后main函数会调用Java.c中LoadClass方法装载该主类(使用JNIEnv实例的FindClass)。

    运行Class的时候,main函数直接调用Java.c中的LoadClass方法装载该类。

5.3 Java程序是怎么运行的?

参考答案

概括来说,写好的 Java 源代码文件经过 Java 编译器编译成字节码文件后,通过类加载器加载到内存中,才能被实例化,然后到 Java 虚拟机中解释执行,最后通过操作系统操作 CPU 执行获取结果。

5.4 本地方法栈有什么用?

参考答案

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(譬如Hot-Spot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。

5.5 没有程序计数器会怎么样?

参考答案

没有程序计数器,Java程序中的流程控制将无法得到正确的控制,多线程也无法正确的轮换。

扩展阅读

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。

5.6 说一说Java的内存分布情况

参考答案

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域。

  1. 程序计数器

    程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

    由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

    如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。

  2. Java虚拟机栈

    与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧[插图](Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

    在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。

  3. 本地方法栈

    本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

    《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(譬如Hot-Spot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。

  4. Java堆

    对于Java应用程序来说,Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。在《Java虚拟机规范》中对Java堆的描述是:“所有的对象实例以及数组都应当在堆上分配”,而这里笔者写的“几乎”是指从实现角度来看,随着Java语言的发展,现在已经能看到些许迹象表明日后可能出现值类型的支持,即使只考虑现在,由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致一些微妙的变化悄然发生,所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。

    根据《Java虚拟机规范》的规定,Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。但对于大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。

    Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

  5. 方法区

    方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。

    根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。

  6. 运行时常量池

    运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

    既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

  7. 直接内存

    直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。

    显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。

5.7 类存放在哪里?

参考答案

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。

5.8 局部变量存放在哪里?

参考答案

与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

5.9 介绍一下Java代码的编译过程

参考答案

从Javac代码的总体结构来看,编译过程大致可以分为1个准备过程和3个处理过程,它们分别如下所示。

  1. 准备过程:初始化插入式注解处理器。

  2. 解析与填充符号表过程,包括:

    • 词法、语法分析,将源代码的字符流转变为标记集合,构造出抽象语法树。
    • 填充符号表,产生符号地址和符号信息。
  3. 插入式注解处理器的注解处理过程:

    在Javac源码中,插入式注解处理器的初始化过程是在initPorcessAnnotations()方法中完成的,而它的执行过程则是在processAnnotations()方法中完成。这个方法会判断是否还有新的注解处理器需要执行,如果有的话,通过JavacProcessing-Environment类的doProcessing()方法来生成一个新的JavaCompiler对象,对编译的后续步骤进行处理。

  4. 分析与字节码生成过程,包括:

    • 标注检查,对语法的静态信息进行检查。
    • 数据流及控制流分析,对程序动态运行过程进行检查。
    • 解语法糖,将简化代码编写的语法糖还原为原有的形式。
    • 字节码生成,将前面各个步骤所生成的信息转化成字节码。

上述3个处理过程里,执行插入式注解时又可能会产生新的符号,如果有新的符号产生,就必须转回到之前的解析、填充符号表的过程中重新处理这些新符号。

5.10 介绍一下类加载的过程

参考答案

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。

在上述七个阶段中,包括了类加载的全过程,即加载、验证、准备、解析和初始化这五个阶段。

一、加载

“加载”(Loading)阶段是整个“类加载”(Class Loading)过程中的一个阶段,在加载阶段,Java虚拟机需要完成以下三件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了,方法区中的数据存储格式完全由虚拟机实现自行定义,《Java虚拟机规范》未规定此区域的具体数据结构。类型数据妥善安置在方法区之后,会在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口。

二、验证

验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证。

  1. 文件格式验证:

    第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。

  2. 元数据验证:

    第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求。

  3. 字节码验证:

    第三阶段是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。

  4. 符号引用验证:

    符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。

三、准备

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在JDK7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的。而在JDK 8及之后,类变量则会随着Class对象一起存放在Java堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了。

四、解析

解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用在Class文件中以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现,那解析阶段中所说的直接引用与符号引用又有什么关联呢?

符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。

直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。

五、初始化

类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由Java虚拟机来主导控制。直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。

进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。我们也可以从另外一种更直接的形式来表达:初始化阶段就是执行类构造器()方法的过程。()并不是程序员在Java代码中直接编写的方法,它是Javac编译器的自动生成物。

5.11 介绍一下对象的实例化过程

参考答案

对象实例化过程,就是执行类构造函数对应在字节码文件中的()方法(实例构造器),()方法由非静态变量、非静态代码块以及对应的构造器组成。

  • ()方法可以重载多个,类有几个构造器就有几个()方法;
  • ()方法中的代码执行顺序为:父类变量初始化、父类代码块、父类构造器、子类变量初始化、子类代码块、子类构造器。

扩展阅读

Java是一门面向对象的编程语言,Java程序运行过程中无时无刻都有对象被创建出来。在语言层面上,创建对象通常(例外:复制、反序列化)仅仅是一个new关键字而已,而在虚拟机中,对象(文中讨论的对象限于普通Java对象,不包括数组和Class对象等)的创建又是怎样一个过程呢?

当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)。但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。

除如何划分可用空间之外,还有另外一个需要考虑的问题:对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题有两种可选方案:一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性;另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。

接下来,Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的视角看来,对象创建才刚刚开始——构造函数,即Class文件中的()方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。一般来说(由字节码流中new指令后面是否跟随invokespecial指令所决定,Java编译器会在遇到new关键字的地方同时生成这两条字节码指令,但如果直接通过其他方式产生的则不一定如此),new指令之后会接着执行()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。

5.12 元空间在栈内还是栈外?

参考答案

在栈外,元空间占用的是本地内存。

扩展阅读

许多Java程序员都习惯在HotSpot虚拟机上开发、部署程序,很多人都更愿意把方法区称呼为“永久代“,或将两者混为一谈。本质上这两者并不是等价的,因为仅仅是当时的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。但是对于其他虚拟机实现,譬如BEAJRockit、IBM J9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。

现在回头来看,当年使用永久代来实现方法区的决定并不是一个好主意,这种设计导致了Java应用更容易遇到内存溢出的问题(永久代有-XX:MaxPermSize的上限,即使不设置也有默认大小,而J9和JRockit只要没有触碰到进程可用内存的上限,例如32位系统中的4GB限制,就不会出问题),而且有极少数方法(例如String::intern())会因永久代的原因而导致不同虚拟机下有不同的表现。

当Oracle收购BEA获得了JRockit的所有权后,准备把JRockit中的优秀功能,譬如Java Mission Control管理工具,移植到HotSpot虚拟机时,但因为两者对方法区实现的差异而面临诸多困难。考虑到HotSpot未来的发展,在JDK 6的时候HotSpot开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计划了,到了JDK 7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Meta-space)来代替,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。

5.13 谈谈JVM的类加载器,以及双亲委派模型

参考答案

一、类加载器

Java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(Class Loader)。

类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远超类加载阶段。对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

二、双亲委派模型

自JDK1.2以来,Java一直保持着三层类加载器、双亲委派的类加载架构。对于这个时期的Java应用,绝大多数Java程序都会使用到以下3个系统提供的类加载器来进行加载。

  • 启动类加载器(Bootstrap Class Loader):这个类加载器负责加载存放在<JAVA_HOME>\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可。
  • 扩展类加载器(Extension Class Loader):这个类加载器是在类sun.misc.Launcher$ExtClassLoader中以Java代码的形式实现的。它负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。根据“扩展类加载器”这个名称,就可以推断出这是一种Java系统类库的扩展机制,JDK的开发团队允许用户将具有通用性的类库放置在ext目录里以扩展Java SE的功能,在JDK 9之后,这种扩展机制被模块化带来的天然的扩展能力所取代。由于扩展类加载器是由Java代码实现的,开发者可以直接在程序中使用扩展类加载器来加载Class文件。
  • 应用程序类加载器(Application Class Loader):这个类加载器由sun.misc.Launcher$AppClassLoader来实现。由于应用程序类加载器是ClassLoader类中的getSystem-ClassLoader()方法的返回值,所以有些场合中也称它为“系统类加载器”。它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

这些类加载器之间的协作关系“通常”会如下图所示,图中展示的各种类加载器之间的层次关系被称为类加载器的“双亲委派模型(Parents Delegation Model)”。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用组合(Composition)关系来复用父加载器的代码。

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

使用双亲委派模型来组织类加载器之间的关系,一个显而易见的好处就是Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个名为java.lang.Object的类,并放在程序的ClassPath中,那系统中就会出现多个不同的Object类,Java类型体系中最基础的行为也就无从保证,应用程序将会变得一片混乱。

扩展阅读

双亲委派模型对于保证Java程序的稳定运作极为重要,但它的实现却异常简单,用以实现双亲委派的代码只有短短十余行,全部集中在java.lang.ClassLoader的loadClass()方法之中。

protected Class<?> loadClass(String name, boolean resolve)     throws ClassNotFoundException {     synchronized (getClassLoadingLock(name)) {         // First, check if the class has already been loaded         Class<?> c = findLoadedClass(name);         if (c == null) {             long t0 = System.nanoTime();             try {                 if (parent != null) {                     c = parent.loadClass(name, false);                 } else {                     c = findBootstrapClassOrNull(name);                 }             } catch (ClassNotFoundException e) {                 // ClassNotFoundException thrown if class not found                 // from the non-null parent class loader             }              if (c == null) {                 // If still not found, then invoke findClass in order                 // to find the class.                 long t1 = System.nanoTime();                 c = findClass(name);                  // this is the defining class loader; record the stats                 PerfCounter.getParentDelegationTime().addTime(t1 - t0);                 PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);                 PerfCounter.getFindClasses().increment();             }         }         if (resolve) {             resolveClass(c);         }         return c;     } }

这段代码的逻辑清晰易懂:先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。假如父类加载器加载失败,抛出ClassNotFoundException异常的话,才调用自己的findClass()方法尝试进行加载。

5.14 双亲委派机制会被破坏吗?

参考答案

双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们的类加载器实现方式。在Java的世界中大部分的类加载器都遵循这个模型,但也有例外的情况,直到Java模块化出现为止,双亲委派模型主要出现过3次较大规模“被破坏”的情况。

双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前——即JDK 1.2面世以前的“远古”时代。由于双亲委派模型在JDK 1.2之后才被引入,但是类加载器的概念和抽象类java.lang.ClassLoader则在Java的第一个版本中就已经存在,面对已经存在的用户自定义类加载器的代码,Java设计者们引入双亲委派模型时不得不做出一些妥协,为了兼容这些已有代码,无法再以技术手段避免loadClass()被子类覆盖的可能性,只能在JDK 1.2之后的java.lang.ClassLoader中添加一个新的protected方法findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在loadClass()中编写代码。双亲委派的具体逻辑就实现在这里面,按照loadClass()方法的逻辑,如果父类加载失败,会自动调用自己的findClass()方法来完成加载,这样既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类加载器是符合双亲委派规则的。

双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的,双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),基础类型之所以被称为“基础”,是因为它们总是作为被用户代码继承、调用的API存在,但程序设计往往没有绝对不变的完美规则,如果有基础类型又要调用回用户的代码,那该怎么办呢?

这并非是不可能出现的事情,一个典型的例子便是JNDI服务,JNDI现在已经是Java的标准服务,它的代码由启动类加载器来完成加载(在JDK 1.3时加入到rt.jar的),肯定属于Java中很基础的类型了。但JNDI存在的目的就是对资源进行查找和集中管理,它需要调用由其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提供者接口(Service Provider Interface,SPI)的代码,现在问题来了,启动类加载器是绝不可能认识、加载这些代码的,那该怎么办?

为了解决这个困境,Java的设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContext-ClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。

有了线程上下文类加载器,程序就可以做一些“舞弊”的事情了。JNDI服务使用这个线程上下文类加载器去加载所需的SPI服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则,但也是无可奈何的事情。Java中涉及SPI的加载基本上都采用这种方式来完成,例如JNDI、JDBC、JCE、JAXB和JBI等。不过,当SPI的服务提供者多于一个的时候,代码就只能根据具体提供者的类型来硬编码判断,为了消除这种极不优雅的实现方式,在JDK 6时,JDK提供了java.util.ServiceLoader类,以META-INF/services中的配置信息,辅以责任链模式,这才算是给SPI的加载提供了一种相对合理的解决方案。

双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的,这里所说的“动态性”指的是一些非常“热”门的名词:代码热替换(HotSwap)、模块热部署(Hot Deployment)等。说白了就是希望Java应用程序能像我们的电脑外设那样,接上鼠标、U盘,不用重启机器就能立即使用,鼠标有问题或要升级就换个鼠标,不用关机也不用重启。对于个人电脑来说,重启一次其实没有什么大不了的,但对于一些生产系统来说,关机重启一次可能就要被列为生产事故,这种情况下热部署就对软件开发者,尤其是大型系统或企业级软件开发者具有很大的吸引力。

早在2008年,在Java社区关于模块化规范的第一场战役里,由Sun/Oracle公司所提出的JSR-294、JSR-277规范提案就曾败给以IBM公司主导的JSR-291(即OSGi R4.2)提案。尽管Sun/Oracle并不甘心就此失去Java模块化的主导权,随即又再拿出Jigsaw项目迎战,但此时OSGi已经站稳脚跟,成为业界“事实上”的Java模块化标准。曾经在很长一段时间内,IBM凭借着OSGi广泛应用基础让Jigsaw吃尽苦头,其影响一直持续到Jigsaw随JDK 9面世才算告一段落。而且即使Jigsaw现在已经是Java的标准功能了,它仍需小心翼翼地避开OSGi运行期动态热部署上的优势,仅局限于静态地解决模块间封装隔离和访问控制的问题,现在我们先来简单看一看OSGi是如何通过类加载器实现热部署的。

OSGi实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。在OSGi环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构,当收到类加载请求时,OSGi将按照下面的顺序进行类搜索:

  1. 将以java.*开头的类,委派给父类加载器加载。
  2. 否则,将委派列表名单内的类,委派给父类加载器加载。
  3. 否则,将Import列表中的类,委派给Export这个类的Bundle的类加载器加载。
  4. 否则,查找当前Bundle的ClassPath,使用自己的类加载器加载。
  5. 否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载。
  6. 否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
  7. 否则,类查找失败。

上面的查找顺序中只有开头两点仍然符合双亲委派模型的原则,其余的类查找都是在平级的类加载器中进行的。

5.15 介绍一下Java的垃圾回收机制

参考答案

一、哪些内存需要回收

在Java内存运行时区域的各个部分中,堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理,我们平时所说的内存分配与回收也仅仅特指这一部分内存。

二、怎么定义垃圾

引用计数算法:

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

但是,在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。

举个简单的例子:对象objA和objB都有字段instance,赋值令objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。

可达性分析算法:

当前主流的商用程序语言的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

回收方法区:

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃常量与回收Java堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

三、怎么回收垃圾

分代收集理论:

当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(GenerationalCollection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

  1. 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  2. 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

标记-清除算法:

最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法,如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。

它的主要缺点有两个:第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-复制算法:

为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969年Fenichel提出了一种称为“半区复制”(Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。

在1989年,Andrew Appel针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为“Appel式回收”。Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。当然,98%的对象可被回收仅仅是“普通场景”下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活,因此Appel式回收还有一个充当罕见情况的“逃生门”的安全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

标记-整理算法:

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

5.16 请介绍一下分代回收机制

参考答案

当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(GenerationalCollection)[插图]的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

  1. 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  2. 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。把分代收集理论具体放到现在的商用Java虚拟机里,设计者一般至少会把Java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。

分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不是孤立的,对象之间会存在跨代引用。假如要现在进行一次只局限于新生代区域内的收集,但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:

  1. 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。

依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(称为“记忆集”,RememberedSet),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

5.17 JVM中一次完整的GC流程是怎样的?

参考答案

新创建的对象一般会被分配在新生代中,常用的新生代的垃圾回收器是 ParNew 垃圾回收器,它按照 8:1:1 将新生代分成 Eden 区,以及两个 Survivor 区。某一时刻,我们创建的对象将 Eden 区全部挤满,这个对象就是挤满新生代的最后一个对象。此时,Minor GC 就触发了。

在正式 Minor GC 前,JVM 会先检查新生代中对象,是比老年代中剩余空间大还是小。为什么要做这样的检查呢?原因很简单,假如 Minor GC 之后 Survivor 区放不下剩余对象,这些对象就要进入到老年代,所以要提前检查老年代是不是够用。这样就有两种情况:

  1. 老年代剩余空间大于新生代中的对象大小,那就直接Minor GC,GC完survivor不够放,老年代也绝对够放;

  2. 老年代剩余空间小于新生代中的对象大小,这个时候就要查看是否启用了“老年代空间分配担保规则”,具体来说就是看 -XX:-HandlePromotionFailure 参数是否设置了。

    老年代空间分配担保规则是这样的,如果老年代中剩余空间大小,大于历次 Minor GC 之后剩余对象的大小,那就允许进行 Minor GC。因为从概率上来说,以前的放的下,这次的也应该放的下。那就有两种情况:

    老年代中剩余空间大小,大于历次Minor GC之后剩余对象的大小,进行 Minor GC;

    老年代中剩余空间大小,小于历次Minor GC之后剩余对象的大小,进行Full GC,把老年代空出来再检查。

开启老年代空间分配担保规则只能说是大概率上来说,Minor GC 剩余后的对象够放到老年代,所以当然也会有万一,Minor GC 后会有这样三种情况:

  1. Minor GC 之后的对象足够放到 Survivor 区,皆大欢喜,GC 结束;
  2. Minor GC 之后的对象不够放到 Survivor 区,接着进入到老年代,老年代能放下,那也可以,GC 结束;
  3. Minor GC 之后的对象不够放到 Survivor 区,老年代也放不下,那就只能 Full GC。

前面都是成功 GC 的例子,还有 3 中情况,会导致 GC 失败,报 OOM:

  1. 紧接上一节 Full GC 之后,老年代任然放不下剩余对象,就只能 OOM;
  2. 未开启老年代分配担保机制,且一次 Full GC 后,老年代任然放不下剩余对象,也只能 OOM;
  3. 开启老年代分配担保机制,但是担保不通过,一次 Full GC 后,老年代任然放不下剩余对象,也是能 OOM。

5.18 Full GC会导致什么?

参考答案

Full GC会“Stop The World”,即在GC期间全程暂停用户的应用程序。

5.19 JVM什么时候触发GC,如何减少FullGC的次数?

参考答案

当 Eden 区的空间耗尽时 Java 虚拟机便会触发一次 Minor GC 来收集新生代的垃圾,存活下来的对象,则会被送到 Survivor 区,简单说就是当新生代的Eden区满的时候触发 Minor GC。

serial GC 中,老年代内存剩余已经小于之前年轻代晋升老年代的平均大小,则进行 Full GC。而在 CMS 等并发收集器中则是每隔一段时间检查一下老年代内存的使用量,超过一定比例时进行 Full GC 回收。

可以采用以下措施来减少Full GC的次数:

  1. 增加方法区的空间;
  2. 增加老年代的空间;
  3. 减少新生代的空间;
  4. 禁止使用System.gc()方法;
  5. 使用标记-整理算法,尽量保持较大的连续内存空间;
  6. 排查代码中无用的大对象。

5.20 如何确定对象是可回收的?

参考答案

引用计数算法:

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

但是,在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。

举个简单的例子:对象objA和objB都有字段instance,赋值令objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。

可达性分析算法:

当前主流的商用程序语言的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

如下图所示,对象object 5、object 6、object 7虽然互有关联,但是它们到GC Roots是不可达的,因此它们将会被判定为可回收的对象。

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

5.21 对象如何晋升到老年代?

参考答案

虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。对象通常在Eden区里诞生,如果经过第一次MinorGC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。

5.22 为什么老年代不能使用标记复制?

参考答案

因为老年代保留的对象都是难以消亡的,而标记复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低,所以在老年代一般不能直接选用这种算法。

5.23 新生代为什么要分为Eden和Survivor,它们的比例是多少?

参考答案

现在的商用Java虚拟机大多都优先采用了“标记-复制算法”去回收新生代,该算法早期采用“半区复制”的机制进行垃圾回收。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。

实际上,新生代中的对象有98%熬不过第一轮收集,因此并不需要按照1∶1的比例来划分新生代的内存空间。在1989年,Andrew Appel提出了一种更优化的半区复制分代策略,现在称为“Appel式回收”。Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。

HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。

5.24 为什么要设置两个Survivor区域?

参考答案

设置两个 Survivor 区最大的好处就是解决内存碎片化。

我们先假设一下,Survivor 只有一个区域会怎样。Minor GC 执行后,Eden 区被清空了,存活的对象放到了 Survivor 区,而之前 Survivor 区中的对象,可能也有一些是需要被清除的。问题来了,这时候我们怎么清除它们?在这种场景下,我们只能标记清除,而我们知道标记清除最大的问题就是内存碎片,在新生代这种经常会消亡的区域,采用标记清除必然会让内存产生严重的碎片化。因为 Survivor 有 2 个区域,所以每次 Minor GC,会将之前 Eden 区和 From 区中的存活对象复制到 To 区域。第二次 Minor GC 时,From 与 To 职责兑换,这时候会将 Eden 区和 To 区中的存活对象再复制到 From 区域,以此反复。

这种机制最大的好处就是,整个过程中,永远有一个 Survivor space 是空的,另一个非空的 Survivor space 是无碎片的。那么,Survivor 为什么不分更多块呢?比方说分成三个、四个、五个?显然,如果 Survivor 区再细分下去,每一块的空间就会比较小,容易导致 Survivor 区满,两块 Survivor 区可能是经过权衡之后的最佳方案。

5.25 说一说你对GC算法的了解。

参考答案

标记-清除算法:

最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法,如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。

它的主要缺点有两个:第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-复制算法:

为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969年Fenichel提出了一种称为“半区复制”(Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。

在1989年,Andrew Appel针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为“Appel式回收”。Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。当然,98%的对象可被回收仅仅是“普通场景”下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活,因此Appel式回收还有一个充当罕见情况的“逃生门”的安全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

标记-整理算法:

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

5.26 为什么新生代和老年代要采用不同的回收算法?

参考答案

如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间。如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

5.27 请介绍G1垃圾收集器

参考答案

G1(Garbage First)是一款主要面向服务端应用的垃圾收集器,JDK 9发布之日,G1宣告取代ParallelScavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器,而CMS则沦落至被声明为不推荐使用(Deprecate)的收集器。G1收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。

虽然G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数 -XX:G1HeapRegionSize 设定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region 之中,G1的大多数行为都把 Humongous Region 作为老年代的一部分来进行看待。

虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。

5.28 请介绍CMS垃圾收集器

参考答案

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。从名字上就可以看出CMS收集器是基于标记-清除算法实现的,它的运作过程分为四个步骤,包括:

  1. 初始标记(CMS initial mark);
  2. 并发标记(CMS concurrent mark);
  3. 重新标记(CMS remark);
  4. 并发清除(CMS concurrent sweep)。

其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。通过下图可以比较清楚地看到CMS收集器的运作步骤中并发和需要停顿的阶段。

CMS收集器还远达不到完美的程度,它至少有以下三个明显的缺点:

首先,CMS收集器对处理器资源非常敏感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。

然后,由于CMS收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现“Con-current Mode Failure”失败进而导致另一次完全“Stop TheWorld”的Full GC的产生。

还有最后一个缺点,CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。

5.29 内存泄漏和内存溢出有什么区别?

参考答案

内存泄漏(memory leak):内存泄漏指程序运行过程中分配内存给临时变量,用完之后却没有被GC回收,始终占用着内存,既不能被使用也不能分配给其他程序,于是就发生了内存泄漏。

内存溢出(out of memory):简单地说内存溢出就是指程序运行过程中申请的内存大于系统能够提供的内存,导致无法申请到足够的内存,于是就发生了内存溢出。

5.30 什么是内存泄漏,怎么解决?

参考答案

内存泄漏的根本原因是长生命周期的对象持有短生命周期对象的引用,尽管短生命周期的对象已经不再需要,但由于长生命周期对象持有它的引用而导致不能被回收。以发生的方式来分类,内存泄漏可以分为4类:

  1. 常发性内存泄漏。发生内存泄漏的代码会被多次执行到,每次被执行的时候都会导致一块内存泄漏。
  2. 偶发性内存泄漏。发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。常发性和偶发性是相对的。对于特定的环境,偶发性的也许就变成了常发性的。所以测试环境和测试方法对检测内存泄漏至关重要。
  3. 一次性内存泄漏。发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有一块仅且一块内存发生泄漏。
  4. 隐式内存泄漏。程序在运行过程中不停的分配内存,但是直到结束的时候才释放内存。严格的说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存。但是对于一个服务器程序,需要运行几天,几周甚至几个月,不及时释放内存也可能导致最终耗尽系统的所有内存。所以,我们称这类内存泄漏为隐式内存泄漏。

避免内存泄漏的几点建议:

  1. 尽早释放无用对象的引用。
  2. 避免在循环中创建对象。
  3. 使用字符串处理时避免使用String,应使用StringBuffer。
  4. 尽量少使用静态变量,因为静态变量存放在永久代,基本不参与垃圾回收。

5.31 什么是内存溢出,怎么解决?

参考答案

内存溢出(out of memory):简单地说内存溢出就是指程序运行过程中申请的内存大于系统能够提供的内存,导致无法申请到足够的内存,于是就发生了内存溢出。

引起内存溢出的原因有很多种,常见的有以下几种:

  1. 内存中加载的数据量过于庞大,如一次从数据库取出过多数据;
  2. 集合类中有对对象的引用,使用完后未清空,使得JVM不能回收;
  3. 代码中存在死循环或循环产生过多重复的对象实体;
  4. 使用的第三方软件中的BUG;
  5. 启动参数内存值设定的过小。

内存溢出的解决方案:

  • 第一步,修改JVM启动参数,直接增加内存。
  • 第二步,检查错误日志,查看“OutOfMemory”错误前是否有其它异常或错误。
  • 第三步,对代码进行走查和分析,找出可能发生内存溢出的位置。
  • 第四步,使用内存查看工具动态查看内存使用情况。

5.32 哪些区域会OOM,怎么触发OOM?

参考答案

除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OOM异常的可能。

  1. Java堆溢出

    Java堆用于储存对象实例,我们只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常。

  2. 虚拟机栈和本地方法栈溢出

    HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。

  3. 方法区和运行时常量池溢出

    方法区溢出也是一种常见的内存溢出异常,在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况。这类场景常见的包括:程序使用了CGLib字节码增强和动态语言、大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为Java类)、基于OSGi的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。

    在JDK 6或更早之前的HotSpot虚拟机中,常量池都是分配在永久代中,即常量池是方法去的一部分,所以上述问题在常量池中也同样会出现。而HotSpot从JDK 7开始逐步“去永久代”的计划,并在JDK 8中完全使用元空间来代替永久代,所以上述问题在JDK 8中会得到避免。

  4. 本地直接内存溢出

    直接内存(Direct Memory)的容量大小可通过-XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆最大值(由-Xmx指定)一致。如果直接通过反射获取Unsafe实例进行内存分配,并超出了上述的限制时,将会引发OOM异常

数据库

1. SQL

1.1 介绍一下数据库分页

参考答案

MySQL的分页语法:

在MySQL中,SELECT语句默认返回所有匹配的行,它们可能是指定表中的每个行。为了返回第一行或前几行,可使用LIMIT子句,以实现分页查询。LIMIT子句的语法如下:

-- 在所有的查询结果中,返回前5行记录。 SELECT prod_name FROM products LIMIT 5; -- 在所有的查询结果中,从第5行开始,返回5行记录。 SELECT prod_name FROM products LIMIT 5,5;

总之,带一个值的LIMIT总是从第一行开始,给出的数为返回的行数。带两个值的LIMIT可以指定从行号为第一个值的位置开始。

优化LIMIT分页:

在偏移量非常大的时候,例如 LIMIT 10000,20 这样的查询,这时MySQL需要查询10020条记录然后只返回最后20条,前面的10000条记录都将被抛弃,这样的代价是非常高的。如果所有的页面被访问的频率都相同,那么这样的查询平均需要访问半个表的数据。要优化这种查询,要么是在页面中限制分页的数量,要么是优化大偏移量的性能。

优化此类分页查询的一个最简单的办法就是尽可能地使用索引覆盖扫描,而不是查询所有的列,然后根据需要做一次关联操作再返回所需的列。对于偏移量很大的时候,这样做的效率会提升非常大。考虑下面的查询:

SELECT film_id,description FROM sakila.film ORDER BY title LIMIT 50,5;

如果这个表非常大,那么这个查询最好改写成下面的样子:

SELECT film.film_id,film.description  FROM sakila.film INNER JOIN (  SELECT film_id FROM sakila.film ORDER BY title LIMIT 50,5 ) AS lim USING(film_id);

这里的“延迟关联”将大大提升查询效率,它让MySQL扫描尽可能少的页面,获取需要访问的记录后再根据关联列回原表查询需要的所有列。这个技术也可以用于优化关联查询中的LIMIT子句。

有时候也可以将LIMIT查询转换为已知位置的查询,让MySQL通过范围扫描获得对应的结果。例如,如果在一个位置列上有索引,并且预先计算出了边界值,上面的查询就可以改写为:

SELECT film_id,description FROM skila.film WHERE position BETWEEN 50 AND 54 ORDER BY position;

对数据进行排名的问题也与此类似,但往往还会同时和GROUP BY混合使用,在这种情况下通常都需要预先计算并存储排名信息。

LIMIT和OFFSET的问题,其实是OFFSET的问题,它会导致MySQL扫描大量不需要的行然后再抛弃掉。如果可以使用书签记录上次取数的位置,那么下次就可以直接从该书签记录的位置开始扫描,这样就可以避免使用OFFSET。例如,若需要按照租赁记录做翻页,那么可以根据最新一条租赁记录向后追溯,这种做法可行是因为租赁记录的主键是单调增长的。首先使用下面的查询获得第一组结果:

SELECT * FROM sakila.rental ORDER BY rental_id DESC LIMIT 20;

假设上面的查询返回的是主键16049到16030的租赁记录,那么下一页查询就可以从16030这个点开始:

SELECT * FROM sakila.rental  WHERE rental_id < 16030 ORDER BY rental_id DESC LIMIT 20;

该技术的好处是无论翻页到多么后面,其性能都会很好。

1.2 介绍一下SQL中的聚合函数

参考答案

常用的聚合函数有COUNT()、AVG()、SUM()、MAX()、MIN(),下面以MySQL为例,说明这些函数的作用。

COUNT()函数:

COUNT()函数统计数据表中包含的记录行的总数,或者根据查询结果返回列中包含的数据行数,它有两种用法:

  • COUNT(*)计算表中总的行数,不管某列是否有数值或者为空值。
  • COUNT(字段名)计算指定列下总的行数,计算时将忽略空值的行。

COUNT()函数可以与GROUP BY一起使用来计算每个分组的总和。

AVG()函数():

AVG()函数通过计算返回的行数和每一行数据的和,求得指定列数据的平均值。

AVG()函数可以与GROUP BY一起使用,来计算每个分组的平均值。

SUM()函数:

SUM()是一个求总和的函数,返回指定列值的总和。

SUM()可以与GROUP BY一起使用,来计算每个分组的总和。

MAX()函数:

MAX()返回指定列中的最大值。

MAX()也可以和GROUP BY关键字一起使用,求每个分组中的最大值。

MAX()函数不仅适用于查找数值类型,也可应用于字符类型。

MIN()函数:

MIN()返回查询列中的最小值。

MIN()也可以和GROUP BY关键字一起使用,求出每个分组中的最小值。

MIN()函数与MAX()函数类似,不仅适用于查找数值类型,也可应用于字符类型。

1.3 表跟表是怎么关联的?

参考答案

表与表之间常用的关联方式有两种:内连接、外连接,下面以MySQL为例来说明这两种连接方式。

内连接:

内连接通过INNER JOIN来实现,它将返回两张表中满足连接条件的数据,不满足条件的数据不会查询出来。

外连接:

外连接通过OUTER JOIN来实现,它会返回两张表中满足连接条件的数据,同时返回不满足连接条件的数据。外连接有两种形式:左外连接(LEFT OUTER JOIN)、右外连接(RIGHT OUTER JOIN)。

  • 左外连接:可以简称为左连接(LEFT JOIN),它会返回左表中的所有记录和右表中满足连接条件的记录。
  • 右外连接:可以简称为右连接(RIGHT JOIN),它会返回右表中的所有记录和左表中满足连接条件的记录。

除此之外,还有一种常见的连接方式:等值连接。这种连接是通过WHERE子句中的条件,将两张表连接在一起,它的实际效果等同于内连接。出于语义清晰的考虑,一般更建议使用内连接,而不是等值连接。

以上是从语法上来说明表与表之间关联的实现方式,而从表的关系上来说,比较常见的关联关系有:一对多关联、多对多关联、自关联。

  • 一对多关联:这种关联形式最为常见,一般是两张表具有主从关系,并且以主表的主键关联从表的外键来实现这种关联关系。另外,以从表的角度来看,它们是具有多对一关系的,所以不再赘述多对一关联了。
  • 多对多关联:这种关联关系比较复杂,如果两张表具有多对多的关系,那么它们之间需要有一张中间表来作为衔接,以实现这种关联关系。这个中间表要设计两列,分别存储那两张表的主键。因此,这两张表中的任何一方,都与中间表形成了一对多关系,从而在这个中间表上建立起了多对多关系。
  • 自关联:自关联就是一张表自己与自己相关联,为了避免表名的冲突,需要在关联时通过别名将它们当做两张表来看待。一般在表中数据具有层级(树状)时,可以采用自关联一次性查询出多层级的数据。

1.4 说一说你对外连接的了解

参考答案

外连接通过OUTER JOIN来实现,它会返回两张表中满足连接条件的数据,同时返回不满足连接条件的数据。常见的外连接有两种形式:左外连接(LEFT OUTER JOIN)、右外连接(RIGHT OUTER JOIN)。

  • 左外连接:可以简称为左连接(LEFT JOIN),它会返回左表中的所有记录和右表中满足连接条件的记录。
  • 右外连接:可以简称为右连接(RIGHT JOIN),它会返回右表中的所有记录和左表中满足连接条件的记录。

实际上,外连接还有一种形式:完全外连接(FULL OUTER JOIN),但MySQL不支持这种形式。

1.5 说一说数据库的左连接和右连接

参考答案

外连接通过OUTER JOIN来实现,它会返回两张表中满足连接条件的数据,同时返回不满足连接条件的数据。常见的外连接有两种形式:左外连接(LEFT OUTER JOIN)、右外连接(RIGHT OUTER JOIN)。

  • 左外连接:可以简称为左连接(LEFT JOIN),它会返回左表中的所有记录和右表中满足连接条件的记录。
  • 右外连接:可以简称为右连接(RIGHT JOIN),它会返回右表中的所有记录和左表中满足连接条件的记录。

1.6 SQL中怎么将行转成列?

参考答案

我们以MySQL数据库为例,来说明行转列的实现方式。

首先,假设我们有一张分数表(tb_score),表中的数据如下图:

然后,我们再来看一下转换之后需要得到的结果,如下图:

可以看出,这里行转列是将原来的subject字段的多行内容选出来,作为结果集中的不同列,并根据userid进行分组显示对应的score。通常,我们有两种方式来实现这种转换。

  1. 使用 CASE…WHEN…THEN 语句实现行转列,参考如下代码:

    SELECT userid, SUM(CASE `subject` WHEN '语文' THEN score ELSE 0 END) as '语文', SUM(CASE `subject` WHEN '数学' THEN score ELSE 0 END) as '数学', SUM(CASE `subject` WHEN '英语' THEN score ELSE 0 END) as '英语', SUM(CASE `subject` WHEN '政治' THEN score ELSE 0 END) as '政治'  FROM tb_score  GROUP BY userid
    

    注意,SUM() 是为了能够使用GROUP BY根据userid进行分组,因为每一个userid对应的subject="语文"的记录只有一条,所以SUM() 的值就等于对应那一条记录的score的值。假如userid =‘001’ and subject=‘语文’ 的记录有两条,则此时SUM() 的值将会是这两条记录的和,同理,使用Max()的值将会是这两条记录里面值最大的一个。但是正常情况下,一个user对应一个subject只有一个分数,因此可以使用SUM()、MAX()、MIN()、AVG()等聚合函数都可以达到行转列的效果。

  2. 使用 IF() 函数实现行转列,参考如下代码:

    SELECT userid, SUM(IF(`subject`='语文',score,0)) as '语文', SUM(IF(`subject`='数学',score,0)) as '数学', SUM(IF(`subject`='英语',score,0)) as '英语', SUM(IF(`subject`='政治',score,0)) as '政治'  FROM tb_score  GROUP BY userid
    

    注意,IF(subject=‘语文’,score,0) 作为条件,即对所有subject='语文’的记录的score字段进行SUM()、MAX()、MIN()、AVG()操作,如果score没有值则默认为0。

1.7 谈谈你对SQL注入的理解

参考答案

SQL注入的原理是将SQL代码伪装到输入参数中,传递到服务器解析并执行的一种攻击手法。也就是说,在一些对SERVER端发起的请求参数中植入一些SQL代码,SERVER端在执行SQL操作时,会拼接对应参数,同时也将一些SQL注入攻击的“SQL”拼接起来,导致会执行一些预期之外的操作。

举个例子:

比如我们的登录功能,其登录界面包括用户名和密码输入框以及提交按钮,登录时需要输入用户名和密码,然后提交。此时调用接口/user/login/ 加上参数username、password,首先连接数据库,然后后台对请求参数中携带的用户名、密码进行参数校验,即SQL的查询过程。假设正确的用户名和密码为ls和123456,输入正确的用户名和密码、提交,相当于调用了以下的SQL语句。

SELECT * FROM user WHERE username = 'ls' AND password = '123456'

SQL中会将#及–以后的字符串当做注释处理,如果我们使用 ’ or 1=1 # 作为用户名参数,那么服务端构建的SQL语句就如下:

select * from user where username='' or 1=1 #' and password='123456'

而#会忽略后面的语句,而1=1属于常等型条件,因此这个SQL将查询出所有的登录用户。其实上面的SQL注入只是在参数层面做了些手脚,如果是引入了一些功能性的SQL那就更危险了,比如上面的登录功能,如果用户名使用这个 ’ or 1=1;delete * from users; #,那么在";"之后相当于是另外一条新的SQL,这个SQL是删除全表,是非常危险的操作,因此SQL注入这种还是需要特别注意的。

如何解决SQL注入

  1. 严格的参数校验

    参数校验就没得说了,在一些不该有特殊字符的参数中提前进行特殊字符校验即可。

  2. SQL预编译

    在知道了SQL注入的原理之后,我们同样也了解到MySQL有预编译的功能,指的是在服务器启动时,MySQL Client把SQL语句的模板(变量采用占位符进行占位)发送给MySQL服务器,MySQL服务器对SQL语句的模板进行编译,编译之后根据语句的优化分析对相应的索引进行优化,在最终绑定参数时把相应的参数传送给MySQL服务器,直接进行执行,节省了SQL查询时间,以及MySQL服务器的资源,达到一次编译、多次执行的目的,除此之外,还可以防止SQL注入。

    具体是怎样防止SQL注入的呢?实际上当将绑定的参数传到MySQL服务器,MySQL服务器对参数进行编译,即填充到相应的占位符的过程中,做了转义操作。我们常用的JDBC就有预编译功能,不仅提升性能,而且防止SQL注入。

1.8 将一张表的部分数据更新到另一张表,该如何操作呢?

参考答案

可以采用关联更新的方式,将一张表的部分数据,更新到另一张表内。参考如下代码:

update b set b.col=a.col from a,b where a.id=b.id;
update b set col=a.col from b inner join a on a.id=b.id;
update b set b.col=a.col from b left Join a on b.id = a.id;

1.9 WHERE和HAVING有什么区别?

参考答案

WHERE是一个约束声明,使用WHERE约束来自数据库的数据,WHERE是在结果返回之前起作用的,WHERE中不能使用聚合函数。

HAVING是一个过滤声明,是在查询返回结果集以后对查询结果进行的过滤操作,在HAVING中可以使用聚合函数。另一方面,HAVING子句中不能使用除了分组字段和聚合函数之外的其他字段。

从性能的角度来说,HAVING子句中如果使用了分组字段作为过滤条件,应该替换成WHERE子句。因为WHERE可以在执行分组操作和计算聚合函数之前过滤掉不需要的数据,性能会更好。

2. 索引

2.1 说一说你对MySQL索引的理解

参考答案

索引是一个单独的、存储在磁盘上的数据库结构,包含着对数据表里所有记录的引用指针。使用索引可以快速找出在某个或多个列中有一特定值的行,所有MySQL列类型都可以被索引,对相关列使用索引是提高查询操作速度的最佳途径。

索引是在存储引擎中实现的,因此,每种存储引擎的索引都不一定完全相同,并且每种存储引擎也不一定支持所有索引类型。MySQL中索引的存储类型有两种,即BTREE和HASH,具体和表的存储引擎相关。MyISAM和InnoDB存储引擎只支持BTREE索引;MEMORY/HEAP存储引擎可以支持HASH和BTREE索引。

索引的优点主要有以下几条:

  1. 通过创建唯一索引,可以保证数据库表中每一行数据的唯一性。
  2. 可以大大加快数据的查询速度,这也是创建索引的主要原因。
  3. 在实现数据的参考完整性方面,可以加速表和表之间的连接。
  4. 在使用分组和排序子句进行数据查询时,也可以显著减少查询中分组和排序的时间。

增加索引也有许多不利的方面,主要表现在如下几个方面:

  1. 创建索引和维护索引要耗费时间,并且随着数据量的增加所耗费的时间也会增加。
  2. 索引需要占磁盘空间,除了数据表占数据空间之外,每一个索引还要占一定的物理空间,如果有大量的索引,索引文件可能比数据文件更快达到最大文件尺寸。
  3. 当对表中的数据进行增加、删除和修改的时候,索引也要动态地维护,这样就降低了数据的维护速度。

2.2 索引有哪几种?

参考答案

MySQL的索引可以分为以下几类:

  1. 普通索引和唯一索引

    普通索引是MySQL中的基本索引类型,允许在定义索引的列中插入重复值和空值。

    唯一索引要求索引列的值必须唯一,但允许有空值。如果是组合索引,则列值的组合必须唯一。

    主键索引是一种特殊的唯一索引,不允许有空值。

  2. 单列索引和组合索引

    单列索引即一个索引只包含单个列,一个表可以有多个单列索引。

    组合索引是指在表的多个字段组合上创建的索引,只有在查询条件中使用了这些字段的左边字段时,索引才会被使用。使用组合索引时遵循最左前缀集合。

  3. 全文索引

    全文索引类型为FULLTEXT,在定义索引的列上支持值的全文查找,允许在这些索引列中插入重复值和空值。全文索引可以在CHAR、VARCHAR或者TEXT类型的列上创建。

  4. 空间索引

    空间索引是对空间数据类型的字段建立的索引,MySQL中的空间数据类型有4种,分别是GEOMETRY、POINT、LINESTRING和POLYGON。MySQL使用SPATIAL关键字进行扩展,使得能够用创建正规索引类似的语法创建空间索引。创建空间索引的列,必须将其声明为NOT NULL,空间索引只能在存储引擎为MyISAM的表中创建。

2.3 如何创建及保存MySQL的索引?

参考答案

MySQL支持多种方法在单个或多个列上创建索引:

在创建表的时候创建索引:

使用CREATE TABLE创建表时,除了可以定义列的数据类型,还可以定义主键约束、外键约束或者唯一性约束,而不论创建哪种约束,在定义约束的同时相当于在指定列上创建了一个索引。创建表时创建索引的基本语法如下:

CREATE TABLE table_name [col_name data_type] [UNIQUE|FULLTEXT|SPATIAL] [INDEX|KEY] [index_name] (col_name [length]) [ASC|DESC]

其中,UNIQUE、FULLTEXT和SPATIAL为可选参数,分别表示唯一索引、全文索引和空间索引;INDEX与KEY为同义词,两者作用相同,用来指定创建索引。

例如,可以按照如下方式,在id字段上使用UNIQUE关键字创建唯一索引:

CREATE TABLE t1 (  id INT NOT NULL,     name CHAR(30) NOT NULL,     UNIQUE INDEX UniqIdx(id) );

在已存在的表上创建索引

在已经存在的表中创建索引,可以使用ALTER TABLE语句或者CREATEINDEX语句。

ALTER TABLE创建索引的基本语法如下:

ALTER TABLE table_name ADD  [UNIQUE|FULLTEXT|SPATIAL] [INDEX|KEY] [index_name] (col_name[length],...) [ASC|DESC]

例如,可以按照如下方式,在bookId字段上建立名称为UniqidIdx的唯一索引:

ALTER TABLE book ADD UNIQUE INDEX UniqidIdx (bookId);

CREATE INDEX创建索引的基本语法如下:

CREATE [UNIQUE|FULLTEXT|SPATIAL] INDEX index_name  ON table_name (col_name [length],...) [ASC|DESC]

例如,可以按照如下方式,在bookId字段上建立名称为UniqidIdx的唯一索引:

CREATE UNIQUE INDEX UniqidIdx ON book (bookId);

2.4 MySQL怎么判断要不要加索引?

参考答案

建议按照如下的原则来创建索引:

  1. 当唯一性是某种数据本身的特征时,指定唯一索引。使用唯一索引需能确保定义的列的数据完整性,以提高查询速度。
  2. 在频繁进行排序或分组(即进行group by或order by操作)的列上建立索引,如果待排序的列有多个,可以在这些列上建立组合索引。

2.5 只要创建了索引,就一定会走索引吗?

参考答案

不一定。

比如,在使用组合索引的时候,如果没有遵从“最左前缀”的原则进行搜索,则索引是不起作用的。

举例,假设在id、name、age字段上已经成功建立了一个名为MultiIdx的组合索引。索引行中按id、name、age的顺序存放,索引可以搜索id、(id,name)、(id, name, age)字段组合。如果列不构成索引最左面的前缀,那么MySQL不能使用局部索引,如(age)或者(name,age)组合则不能使用该索引查询。

2.6 如何判断数据库的索引有没有生效?

参考答案

可以使用EXPLAIN语句查看索引是否正在使用。

举例,假设已经创建了book表,并已经在其year_publication字段上建立了普通索引。执行如下语句:

EXPLAIN SELECT * FROM book WHERE year_publication=1990;

EXPLAIN语句将为我们输出详细的SQL执行信息,其中:

  • possible_keys行给出了MySQL在搜索数据记录时可选用的各个索引。
  • key行是MySQL实际选用的索引。

如果possible_keys行和key行都包含year_publication字段,则说明在查询时使用了该索引。

2.7 如何评估一个索引创建的是否合理?

建议按照如下的原则来设计索引:

  1. 避免对经常更新的表进行过多的索引,并且索引中的列要尽可能少。应该经常用于查询的字段创建索引,但要避免添加不必要的字段。
  2. 数据量小的表最好不要使用索引,由于数据较少,查询花费的时间可能比遍历索引的时间还要短,索引可能不会产生优化效果。
  3. 在条件表达式中经常用到的不同值较多的列上建立索引,在不同值很少的列上不要建立索引。比如在学生表的“性别”字段上只有“男”与“女”两个不同值,因此就无须建立索引,如果建立索引不但不会提高查询效率,反而会严重降低数据更新速度。
  4. 当唯一性是某种数据本身的特征时,指定唯一索引。使用唯一索引需能确保定义的列的数据完整性,以提高查询速度。
  5. 在频繁进行排序或分组(即进行group by或order by操作)的列上建立索引,如果待排序的列有多个,可以在这些列上建立组合索引。

2.8 索引是越多越好吗?

参考答案

索引并非越多越好,一个表中如有大量的索引,不仅占用磁盘空间,还会影响INSERT、DELETE、UPDATE等语句的性能,因为在表中的数据更改时,索引也会进行调整和更新。

2.9 数据库索引失效了怎么办?

参考答案

可以采用以下几种方式,来避免索引失效:

  1. 使用组合索引时,需要遵循“最左前缀”原则;
  2. 不在索引列上做任何操作,例如计算、函数、类型转换,会导致索引失效而转向全表扫描;
  3. 尽量使用覆盖索引(之访问索引列的查询),减少 select * 覆盖索引能减少回表次数;
  4. MySQL在使用不等于(!=或者<>)的时候无法使用索引会导致全表扫描;
  5. LIKE以通配符开头(%abc)MySQL索引会失效变成全表扫描的操作;
  6. 字符串不加单引号会导致索引失效(可能发生了索引列的隐式转换);
  7. 少用or,用它来连接时会索引失效。

2.10 所有的字段都适合创建索引吗?

参考答案

不是。

下列几种情况,是不适合创建索引的:

  1. 频繁更新的字段不适合建立索引;
  2. where条件中用不到的字段不适合建立索引;
  3. 数据比较少的表不需要建索引;
  4. 数据重复且分布比较均匀的的字段不适合建索引,例如性别、真假值;
  5. 参与列计算的列不适合建索引。

2.11 说一说索引的实现原理

参考答案

在MySQL中,索引是在存储引擎层实现的,不同存储引擎对索引的实现方式是不同的,下面我们探讨一下MyISAM和InnoDB两个存储引擎的索引实现方式。

MyISAM索引实现:

MyISAM引擎使用B+Tree作为索引结构,叶节点的data域存放的是数据记录的地址,MyISAM索引的原理图如下。这里假设表一共有三列,假设我们以Col1为主键,则上图是一个MyISAM表的主索引(Primary key)示意。可以看出MyISAM的索引文件仅仅保存数据记录的地址。在MyISAM中,主索引和辅助索引(Secondary key)在结构上没有任何区别,只是主索引要求key是唯一的,而辅助索引的key可以重复。

如果我们在Col2上建立一个辅助索引,则此索引的结构如下图所示。同样也是一颗B+Tree,data域保存数据记录的地址。因此,MyISAM中索引检索的算法为首先按照B+Tree搜索算法搜索索引,如果指定的Key存在,则取出其data域的值,然后以data域的值为地址,读取相应数据记录。

InnoDB索引实现:

虽然InnoDB也使用B+Tree作为索引结构,但具体实现方式却与MyISAM截然不同。

第一个重大区别是InnoDB的数据文件本身就是索引文件。从上文知道,MyISAM索引文件和数据文件是分离的,索引文件仅保存数据记录的地址。而在InnoDB中,表数据文件本身就是按B+Tree组织的一个索引结构,这棵树的叶节点data域保存了完整的数据记录。这个索引的key是数据表的主键,因此InnoDB表数据文件本身就是主索引。

下图是InnoDB主索引(同时也是数据文件)的示意图,可以看到叶节点包含了完整的数据记录。这种索引叫做聚集索引。因为InnoDB的数据文件本身要按主键聚集,所以InnoDB要求表必须有主键(MyISAM可以没有),如果没有显式指定,则MySQL系统会自动选择一个可以唯一标识数据记录的列作为主键,如果不存在这种列,则MySQL自动为InnoDB表生成一个隐含字段作为主键,这个字段长度为6个字节,类型为长整形。

第二个与MyISAM索引的不同是InnoDB的辅助索引data域存储相应记录主键的值而不是地址。换句话说,InnoDB的所有辅助索引都引用主键作为data域。下图为定义在Col3上的一个辅助索引。这里以英文字符的ASCII码作为比较准则。聚集索引这种实现方式使得按主键的搜索十分高效,但是辅助索引搜索需要检索两遍索引:首先检索辅助索引获得主键,然后用主键到主索引中检索获得记录。

了解不同存储引擎的索引实现方式对于正确使用和优化索引都非常有帮助,例如知道了InnoDB的索引实现后,就很容易明白为什么不建议使用过长的字段作为主键,因为所有辅助索引都引用主索引,过长的主索引会令辅助索引变得过大。再例如,用非单调的字段作为主键在InnoDB中不是个好主意,因为InnoDB数据文件本身是一颗B+Tree,非单调的主键会造成在插入新记录时数据文件为了维持B+Tree的特性而频繁的分裂调整,十分低效,而使用自增字段作为主键则是一个很好的选择。

2.12 介绍一下数据库索引的重构过程

参考答案

什么时候需要重建索引呢?

  1. 表上频繁发生update,delete操作;
  2. 表上发生了alter table …move操作(move操作导致了rowid变化)。

怎么判断索引是否应该重建?

  1. 一般看索引是否倾斜的严重,是否浪费了空间,对索引进行结构分析:

    analyze index index_name validate structure;
    
  2. 在相同的session中查询index_stats表:

    select height,DEL_LF_ROWS/LF_ROWS from index_stats;
    

    当查询的height>=4(索引的深度,即从根到叶节点的高度)或DEL_LF_ROWS/LF_ROWS>0.2的情况下,就应该考虑重建该索引。

如何重建索引?

  • drop原索引,然后再创建索引:

    drop index index_name;
    create index index_name on table_name (index_column);
    

    这种方式相当耗时,一般不建议使用。

  • 直接重建索引:

    alter index indexname rebuild;
    alter index indexname rebuild online;
    

    此方法较快,建议使用。

rebuild是快速重建索引的一种有效的办法,因为它是一种使用现有索引项来重建新索引的方法。如果重建索引时有其他用户在对这个表操作,尽量使用带online参数来最大限度的减少索引重建时将会出现的任何加锁问题。由于新旧索引在建立时同时存在,因此,使用这种重建方法需要有额外的磁盘空间可供临时使用,当索引建完后把老索引删除,如果没有成功,也不会影响原来的索引。利用这种办法可以用来将一个索引移到新的表空间。

rebuild重建索引的过程:

  1. Rebuild以index fast full scan或table full scan方式(采用那种方式取决于cost)读取原索引中的数据来构建一个新的索引,重建过程中有排序操作,rebuild online执行表扫描获取数据,重建过程中有排序的操作;
  2. Rebuild会阻塞DML操作,rebuild online不会阻塞DML操作;
  3. rebuild online时系统会产生一个SYS_JOURNAL_xxx的IOT类型的系统临时日志表,所有rebuild online时索引的变化都记录在这个表中,当新的索引创建完成后,把这个表的记录维护到新的索引中去,然后drop掉旧的索引,rebuild online就完成了。

重建索引过程中的注意事项:

  1. 执行rebuild操作时,需要检查表空间是否足够;
  2. 虽然说rebuild online操作允许DML操作,但还是建议在业务不繁忙时间段进行;
  3. Rebuild操作会产生大量Redo Log;

2.13 MySQL的索引为什么用B+树?

参考答案

B+树由B树和索引顺序访问方法演化而来,它是为磁盘或其他直接存取辅助设备设计的一种平衡查找树,在B+树中,所有记录节点都是按键值的大小顺序存放在同一层的叶子节点,各叶子节点通过指针进行链接。如下图:

B+树索引在数据库中的一个特点就是高扇出性,例如在InnoDB存储引擎中,每个页的大小为16KB。在数据库中,B+树的高度一般都在2~4层,这意味着查找某一键值最多只需要2到4次IO操作,这还不错。因为现在一般的磁盘每秒至少可以做100次IO操作,2~4次的IO操作意味着查询时间只需0.02~0.04秒。

2.14 联合索引的存储结构是什么,它的有效方式是什么?

参考答案

从本质上来说,联合索引还是一棵B+树,不同的是联合索引的键值数量不是1,而是大于等于2,参考下图。另外,只有在查询条件中使用了这些字段的左边字段时,索引才会被使用,所以使用联合索引时遵循最左前缀集合。

2.15 MySQL的Hash索引和B树索引有什么区别?

参考答案

hash索引底层就是hash表,进行查找时,调用一次hash函数就可以获取到相应的键值,之后进行回表查询获得实际数据。B+树底层实现是多路平衡查找树,对于每一次的查询都是从根节点出发,查找到叶子节点方可以获得所查键值,然后根据查询判断是否需要回表查询数据。它们有以下的不同:

  • hash索引进行等值查询更快(一般情况下),但是却无法进行范围查询。因为在hash索引中经过hash函数建立索引之后,索引的顺序与原顺序无法保持一致,不能支持范围查询。而B+树的的所有节点皆遵循(左节点小于父节点,右节点大于父节点,多叉树也类似),天然支持范围。
  • hash索引不支持使用索引进行排序,原理同上。
  • hash索引不支持模糊查询以及多列索引的最左前缀匹配,原理也是因为hash函数的不可预测。
  • hash索引任何时候都避免不了回表查询数据,而B+树在符合某些条件(聚簇索引,覆盖索引等)的时候可以只通过索引完成查询。
  • hash索引虽然在等值查询上较快,但是不稳定,性能不可预测,当某个键值存在大量重复的时候,发生hash碰撞,此时效率可能极差。而B+树的查询效率比较稳定,对于所有的查询都是从根节点到叶子节点,且树的高度较低。

因此,在大多数情况下,直接选择B+树索引可以获得稳定且较好的查询速度。而不需要使用hash索引。

2.16 聚簇索引和非聚簇索引有什么区别?

参考答案

在InnoDB存储引擎中,可以将B+树索引分为聚簇索引和辅助索引(非聚簇索引)。无论是何种索引,每个页的大小都为16KB,且不能更改。

聚簇索引是根据主键创建的一棵B+树,聚簇索引的叶子节点存放了表中的所有记录。辅助索引是根据索引键创建的一棵B+树,与聚簇索引不同的是,其叶子节点仅存放索引键值,以及该索引键值指向的主键。也就是说,如果通过辅助索引来查找数据,那么当找到辅助索引的叶子节点后,很有可能还需要根据主键值查找聚簇索引来得到数据,这种查找方式又被称为书签查找。因为辅助索引不包含行记录的所有数据,这就意味着每页可以存放更多的键值,因此其高度一般都要小于聚簇索引。

2.17 什么是联合索引?

参考答案

联合索引是指对表上的多个列进行索引,联合索引的创建方法与单个索引创建的方法一样,不同之处仅在于有多个索引列。从本质上来说,联合索引还是一棵B+树,不同的是联合索引的键值数量不是1,而是大于等于2,参考下图。另外,只有在查询条件中使用了这些字段的左边字段时,索引才会被使用,所以使用联合索引时遵循最左前缀集合。

2.18 select in语句中如何使用索引?

参考答案

索引是否起作用,主要取决于字段类型:

  • 如果字段类型为字符串,需要给in查询中的数值与字符串值都需要添加引号,索引才能起作用。
  • 如果字段类型为int,则in查询中的值不需要添加引号,索引也会起作用。

IN的字段,在联合索引中,按以上方法,也会起作用。

2.19 模糊查询语句中如何使用索引?

参考答案

在MySQL中模糊查询 mobile like ‘%8765’,这种情况是不能使用 mobile 上的索引的,那么如果需要根据手机号码后四位进行模糊查询,可以用一下方法进行改造。

我们可以加入冗余列(MySQL5.7之后加入了虚拟列,使用虚拟列更合适,思路相同),比如 mobile_reverse,内部存储为 mobile 的倒叙文本,如 mobile为17312345678,那么 mobile_reverse 存储 87654321371,为 mobile_reverse 列建立索引,查询中使用语句 mobile_reverse like reverse(’%5678’) 即可。

reverse 是 MySQL 中的反转函数,这条语句相当于 mobile_reverse like ‘8765%’ ,这种语句是可以使用索引的。

3. 事务

3.1 说一说你对数据库事务的了解

参考答案

事务可由一条非常简单的SQL语句组成,也可以由一组复杂的SQL语句组成。在事务中的操作,要么都执行修改,要么都不执行,这就是事务的目的,也是事务模型区别于文件系统的重要特征之一。

事务需遵循ACID四个特性:

  • A(atomicity),原子性。原子性指整个数据库事务是不可分割的工作单位。只有使事务中所有的数据库操作都执行成功,整个事务的执行才算成功。事务中任何一个SQL语句执行失败,那么已经执行成功的SQL语句也必须撤销,数据库状态应该退回到执行事务前的状态。
  • C(consistency),一致性。一致性指事务将数据库从一种状态转变为另一种一致的状态。在事务开始之前和事务结束以后,数据库的完整性约束没有被破坏。
  • I(isolation),隔离性。事务的隔离性要求每个读写事务的对象与其他事务的操作对象能相互分离,即该事务提交前对其他事务都不可见,这通常使用锁来实现。
  • D(durability) ,持久性。事务一旦提交,其结果就是永久性的,即使发生宕机等故障,数据库也能将数据恢复。持久性保证的是事务系统的高可靠性,而不是高可用性。

事务可以分为以下几种类型:

  • 扁平事务:是事务类型中最简单的一种,而在实际生产环境中,这可能是使用最为频繁的事务。在扁平事务中,所有操作都处于同一层次,其由BEGIN WORK开始,由COMMIT WORK或ROLLBACK WORK结束。处于之间的操作是原子的,要么都执行,要么都回滚。
  • 带有保存点的扁平事务:除了支持扁平事务支持的操作外,允许在事务执行过程中回滚到同一事务中较早的一个状态,这是因为可能某些事务在执行过程中出现的错误并不会对所有的操作都无效,放弃整个事务不合乎要求,开销也太大。保存点(savepoint)用来通知系统应该记住事务当前的状态,以便以后发生错误时,事务能回到该状态。
  • 链事务:可视为保存点模式的一个变种。链事务的思想是:在提交一个事务时,释放不需要的数据对象,将必要的处理上下文隐式地传给下一个要开始的事务。注意,提交事务操作和开始下一个事务操作将合并为一个原子操作。这意味着下一个事务将看到上一个事务的结果,就好像在一个事务中进行的。
  • 嵌套事务:是一个层次结构框架。有一个顶层事务(top-level transaction)控制着各个层次的事务。顶层事务之下嵌套的事务被称为子事务(subtransaction),其控制每一个局部的变换。
  • 分布式事务:通常是一个在分布式环境下运行的扁平事务,因此需要根据数据所在位置访问网络中的不同节点。对于分布式事务,同样需要满足ACID特性,要么都发生,要么都失效。

对于MySQL的InnoDB存储引擎来说,它支持扁平事务、带有保存点的扁平事务、链事务、分布式事务。对于嵌套事务,MySQL数据库并不是原生的,因此对于有并行事务需求的用户来说MySQL就无能为力了,但是用户可以通过带有保存点的事务来模拟串行的嵌套事务。

3.2 事务有哪几种类型,它们之间有什么区别?

参考答案

事务可以分为以下几种类型:

  • 扁平事务:是事务类型中最简单的一种,而在实际生产环境中,这可能是使用最为频繁的事务。在扁平事务中,所有操作都处于同一层次,其由BEGIN WORK开始,由COMMIT WORK或ROLLBACK WORK结束。处于之间的操作是原子的,要么都执行,要么都回滚。
  • 带有保存点的扁平事务:除了支持扁平事务支持的操作外,允许在事务执行过程中回滚到同一事务中较早的一个状态,这是因为可能某些事务在执行过程中出现的错误并不会对所有的操作都无效,放弃整个事务不合乎要求,开销也太大。保存点(savepoint)用来通知系统应该记住事务当前的状态,以便以后发生错误时,事务能回到该状态。
  • 链事务:可视为保存点模式的一个变种。链事务的思想是:在提交一个事务时,释放不需要的数据对象,将必要的处理上下文隐式地传给下一个要开始的事务。注意,提交事务操作和开始下一个事务操作将合并为一个原子操作。这意味着下一个事务将看到上一个事务的结果,就好像在一个事务中进行的。
  • 嵌套事务:是一个层次结构框架。有一个顶层事务(top-level transaction)控制着各个层次的事务。顶层事务之下嵌套的事务被称为子事务(subtransaction),其控制每一个局部的变换。
  • 分布式事务:通常是一个在分布式环境下运行的扁平事务,因此需要根据数据所在位置访问网络中的不同节点。对于分布式事务,同样需要满足ACID特性,要么都发生,要么都失效。

对于MySQL的InnoDB存储引擎来说,它支持扁平事务、带有保存点的扁平事务、链事务、分布式事务。对于嵌套事务,MySQL数据库并不是原生的,因此对于有并行事务需求的用户来说MySQL就无能为力了,但是用户可以通过带有保存点的事务来模拟串行的嵌套事务。

3.3 MySQL的ACID特性分别是怎么实现的?

参考答案

原子性实现原理:

实现原子性的关键,是当事务回滚时能够撤销所有已经成功执行的sql语句。InnoDB实现回滚靠的是undo log,当事务对数据库进行修改时,InnoDB会生成对应的undo log。如果事务执行失败或调用了rollback,导致事务需要回滚,便可以利用undo log中的信息将数据回滚到修改之前的样子。

undo log属于逻辑日志,它记录的是sql执行相关的信息。当发生回滚时,InnoDB会根据undo log的内容做与之前相反的工作。对于insert,回滚时会执行delete。对于delete,回滚时会执行insert。对于update,回滚时则会执行相反的update,把数据改回去。

持久性实现原理:

InnoDB作为MySQL的存储引擎,数据是存放在磁盘中的,但如果每次读写数据都需要磁盘IO,效率会很低。为此,InnoDB提供了缓存(Buffer Pool),Buffer Pool中包含了磁盘中部分数据页的映射,作为访问数据库的缓冲。当从数据库读取数据时,会首先从Buffer Pool中读取,如果Buffer Pool中没有,则从磁盘读取后放入Buffer Pool。当向数据库写入数据时,会首先写入Buffer Pool,Buffer Pool中修改的数据会定期刷新到磁盘中(这一过程称为刷脏)。

Buffer Pool的使用大大提高了读写数据的效率,但是也带了新的问题:如果MySQL宕机,而此时Buffer Pool中修改的数据还没有刷新到磁盘,就会导致数据的丢失,事务的持久性无法保证。

于是,redo log被引入来解决这个问题。当数据修改时,除了修改Buffer Pool中的数据,还会在redo log记录这次操作。当事务提交时,会调用fsync接口对redo log进行刷盘。如果MySQL宕机,重启时可以读取redo log中的数据,对数据库进行恢复。redo log采用的是WAL(Write-ahead logging,预写式日志),所有修改先写入日志,再更新到Buffer Pool,保证了数据不会因MySQL宕机而丢失,从而满足了持久性要求。

既然redo log也需要在事务提交时将日志写入磁盘,为什么它比直接将Buffer Pool中修改的数据写入磁盘(即刷脏)要快呢?主要有以下两方面的原因:

  • 刷脏是随机IO,因为每次修改的数据位置随机,但写redo log是追加操作,属于顺序IO。
  • 刷脏是以数据页(Page)为单位的,MySQL默认页大小是16KB,一个Page上一个小修改都要整页写入。而redo log中只包含真正需要写入的部分,无效IO大大减少。

隔离性实现原理:

隔离性追求的是并发情形下事务之间互不干扰。简单起见,我们主要考虑最简单的读操作和写操作(加锁读等特殊读操作会特殊说明),那么隔离性的探讨,主要可以分为两个方面。

第一方面,(一个事务)写操作对(另一个事务)写操作的影响:锁机制保证隔离性。

隔离性要求同一时刻只能有一个事务对数据进行写操作,InnoDB通过锁机制来保证这一点。锁机制的基本原理可以概括为:事务在修改数据之前,需要先获得相应的锁。获得锁之后,事务便可以修改数据。该事务操作期间,这部分数据是锁定的,其他事务如果需要修改数据,需要等待当前事务提交或回滚后释放锁。

按照粒度,锁可以分为表锁、行锁以及其他位于二者之间的锁。表锁在操作数据时会锁定整张表,并发性能较差。行锁则只锁定需要操作的数据,并发性能好。但是由于加锁本身需要消耗资源,因此在锁定数据较多情况下使用表锁可以节省大量资源。MySQL中不同的存储引擎支持的锁是不一样的,例如MyIsam只支持表锁,而InnoDB同时支持表锁和行锁,且出于性能考虑,绝大多数情况下使用的都是行锁。

第二方面,(一个事务)写操作对(另一个事务)读操作的影响:MVCC保证隔离性。

InnoDB默认的隔离级别是RR(REPEATABLE READ),RR解决脏读、不可重复读、幻读等问题,使用的是MVCC。MVCC全称Multi-Version Concurrency Control,即多版本的并发控制协议。它最大的优点是读不加锁,因此读写不冲突,并发性能好。InnoDB实现MVCC,多个版本的数据可以共存,主要基于以下技术及数据结构:

  1. 隐藏列:InnoDB中每行数据都有隐藏列,隐藏列中包含了本行数据的事务id、指向undo log的指针等。
  2. 基于undo log的版本链:每行数据的隐藏列中包含了指向undo log的指针,而每条undo log也会指向更早版本的undo log,从而形成一条版本链。
  3. ReadView:通过隐藏列和版本链,MySQL可以将数据恢复到指定版本。但是具体要恢复到哪个版本,则需要根据ReadView来确定。所谓ReadView,是指事务(记做事务A)在某一时刻给整个事务系统(trx_sys)打快照,之后再进行读操作时,会将读取到的数据中的事务id与trx_sys快照比较,从而判断数据对该ReadView是否可见,即对事务A是否可见。

一致性实现原理:

可以说,一致性是事务追求的最终目标。前面提到的原子性、持久性和隔离性,都是为了保证数据库状态的一致性。此外,除了数据库层面的保障,一致性的实现也需要应用层面进行保障。实现一致性的措施包括:

  • 保证原子性、持久性和隔离性,如果这些特性无法保证,事务的一致性也无法保证。
  • 数据库本身提供保障,例如不允许向整形列插入字符串值、字符串长度不能超过列的限制等。
  • 应用层面进行保障,例如如果转账操作只扣除转账者的余额,而没有增加接收者的余额,无论数据库实现的多么完美,也无法保证状态的一致。

3.4 谈谈MySQL的事务隔离级别

参考答案

SQL 标准定义了四种隔离级别,这四种隔离级别分别是:

  • 读未提交(READ UNCOMMITTED);
  • 读提交 (READ COMMITTED);
  • 可重复读 (REPEATABLE READ);
  • 串行化 (SERIALIZABLE)。

事务隔离是为了解决脏读、不可重复读、幻读问题,下表展示了 4 种隔离级别对这三个问题的解决程度:

隔离级别 脏读 不可重复读 幻读
READ UNCOMMITTED 可能 可能 可能
READ COMMITTED 不可能 可能 可能
REPEATABLE READ 不可能 不可能 可能
SERIALIZABLE 不可能 不可能 不可能

上述4种隔离级别MySQL都支持,并且InnoDB存储引擎默认的支持隔离级别是REPEATABLE READ,但是与标准SQL不同的是,InnoDB存储引擎在REPEATABLE READ事务隔离级别下,使用Next-Key Lock的锁算法,因此避免了幻读的产生。所以,InnoDB存储引擎在默认的事务隔离级别下已经能完全保证事务的隔离性要求,即达到SQL标准的SERIALIZABLE隔离级别。

扩展阅读

并发情况下,读操作可能存在的三类问题:

  1. 脏读:当前事务(A)中可以读到其他事务(B)未提交的数据(脏数据),这种现象是脏读。
  2. 不可重复读:在事务A中先后两次读取同一个数据,两次读取的结果不一样,这种现象称为不可重复读。脏读与不可重复读的区别在于:前者读到的是其他事务未提交的数据,后者读到的是其他事务已提交的数据。
  3. 幻读:在事务A中按照某个条件先后两次查询数据库,两次查询结果的条数不同,这种现象称为幻读。不可重复读与幻读的区别可以通俗的理解为:前者是数据变了,后者是数据的行数变了。

3.5 MySQL的事务隔离级别是怎么实现的?

参考答案

InnoDB支持四种隔离级别,每种级别解决掉的问题如下表:

脏读 不可重复读幻读 幻读
READ UNCOMMITTED Y Y Y
READ COMMITTED N Y Y
REPEATABLE READ(默认) N N N
SERIALIZABLE N N N

这四种隔离级别的实现机制如下:

  1. READ UNCOMMITTED & READ COMMITTED:

    通过Record Lock算法实现了行锁,但READ UNCOMMITTED允许读取未提交数据,所以存在脏读问题。而READ COMMITTED允许读取提交数据,所以不存在脏读问题,但存在不可重复读问题。

  2. REPEATABLE READ:

    使用Next-Key Lock算法实现了行锁,并且不允许读取已提交的数据,所以解决了不可重复读的问题。另外,该算法包含了间隙锁,会锁定一个范围,因此也解决了幻读的问题。

  3. SERIALIZABLE:

    对每个SELECT语句后自动加上LOCK IN SHARE MODE,即为每个读取操作加一个共享锁。因此在这个事务隔离级别下,读占用了锁,对一致性的非锁定读不再予以支持。

3.6 事务可以嵌套吗?

参考答案

可以,因为嵌套事务也是众多事务分类中的一种,它是一个层次结构框架。有一个顶层事务控制着各个层次的事务,顶层事务之下嵌套的事务被称为子事务,它控制每一个局部的变换。

需要注意的是,MySQL数据库不支持嵌套事务。

3.7 如何实现可重复读?

参考答案

MySQL的InnoDB引擎,在默认的REPEATABLE READ的隔离级别下,实现了可重复读,同时也解决了幻读问题。它使用Next-Key Lock算法实现了行锁,并且不允许读取已提交的数据,所以解决了不可重复读的问题。另外,该算法包含了间隙锁,会锁定一个范围,因此也解决了幻读的问题。

3.8 如何解决幻读问题?

参考答案

MySQL的InnoDB引擎,在默认的REPEATABLE READ的隔离级别下,实现了可重复读,同时也解决了幻读问题。它使用Next-Key Lock算法实现了行锁,并且不允许读取已提交的数据,所以解决了不可重复读的问题。另外,该算法包含了间隙锁,会锁定一个范围,因此也解决了幻读的问题。

3.9 MySQL事务如何回滚?

参考答案

在MySQL默认的配置下,事务都是自动提交和回滚的。当显示地开启一个事务时,可以使用ROLLBACK语句进行回滚。该语句有两种用法:

  • ROLLBACK:要使用这个语句的最简形式,只需发出ROLLBACK。同样地,也可以写为ROLLBACK WORK,但是二者几乎是等价的。回滚会结束用户的事务,并撤销正在进行的所有未提交的修改。
  • ROLLBACK TO [SAVEPOINT] identifier :这个语句与SAVEPOINT命令一起使用。可以把事务回滚到标记点,而不回滚在此标记点之前的任何工作。

4. 锁

4.1 了解数据库的锁吗?

参考答案

锁是数据库系统区别于文件系统的一个关键特性,锁机制用于管理对共享资源的并发访问。下面我们以MySQL数据库的InnoDB引擎为例,来说明锁的一些特点。

锁的类型:

InnoDB存储引擎实现了如下两种标准的行级锁:

  • 共享锁(S Lock),允许事务读一行数据。
  • 排他锁(X Lock),允许事务删除或更新一行数据。

如果一个事务T1已经获得了行r的共享锁,那么另外的事务T2可以立即获得行r的共享锁,因为读取并没有改变行r的数据,称这种情况为锁兼容。但若有其他的事务T3想获得行r的排他锁,则其必须等待事务T1、T2释放行r上的共享锁,这种情况称为锁不兼容。下图显示了共享锁和排他锁的兼容性,可以发现X锁与任何的锁都不兼容,而S锁仅和S锁兼容。需要特别注意的是,S和X锁都是行锁,兼容是指对同一记录(row)锁的兼容性情况。

锁的粒度:

InnoDB存储引擎支持多粒度锁定,这种锁定允许事务在行级上的锁和表级上的锁同时存在。为了支持在不同粒度上进行加锁操作,InnoDB存储引擎支持一种额外的锁方式,称之为意向锁。意向锁是将锁定的对象分为多个层次,意向锁意味着事务希望在更细粒度上进行加锁。

InnoDB存储引擎支持意向锁设计比较简练,其意向锁即为表级别的锁。设计目的主要是为了在一个事务中揭示下一行将被请求的锁类型。其支持两种意向锁:

  • 意向共享锁(IS Lock),事务想要获得一张表中某几行的共享锁。
  • 意向排他锁(IX Lock),事务想要获得一张表中某几行的排他锁。

由于InnoDB存储引擎支持的是行级别的锁,因此意向锁其实不会阻塞除全表扫以外的任何请求。故表级意向锁与行级锁的兼容性如下图所示。

锁的算法:

InnoDB存储引擎有3种行锁的算法,其分别是:

  • Record Lock:单个行记录上的锁。
  • Gap Lock:间隙锁,锁定一个范围,但不包含记录本身。
  • Next-Key Lock∶Gap Lock+Record Lock,锁定一个范围,并且锁定记录本身。

Record Lock总是会去锁住索引记录,如果InnoDB存储引擎表在建立的时候没有设置任何一个索引,那么这时InnoDB存储引擎会使用隐式的主键来进行锁定。Next-Key Lock是结合了Gap Lock和Record Lock的一种锁定算法,在Next-Key Lock算法下,InnoDB对于行的查询都是采用这种锁定算法。采用Next-Key Lock的锁定技术称为Next-Key Locking,其设计的目的是为了解决Phantom Problem(幻读)。而利用这种锁定技术,锁定的不是单个值,而是一个范围,是谓词锁(predict lock)的一种改进。

关于死锁:

死锁是指两个或两个以上的事务在执行过程中,因争夺锁资源而造成的一种互相等待的现象。若无外力作用,事务都将无法推进下去。

解决死锁问题最简单的一种方法是超时,即当两个事务互相等待时,当一个等待时间超过设置的某一阈值时,其中一个事务进行回滚,另一个等待的事务就能继续进行。

除了超时机制,当前数据库还都普遍采用wait-for graph(等待图)的方式来进行死锁检测。较之超时的解决方案,这是一种更为主动的死锁检测方式。InnoDB存储引擎也采用的这种方式。wait-for graph要求数据库保存以下两种信息:

  • 锁的信息链表;
  • 事务等待链表;

通过上述链表可以构造出一张图,而在这个图中若存在回路,就代表存在死锁,因此资源间相互发生等待。这是一种较为主动的死锁检测机制,在每个事务请求锁并发生等待时都会判断是否存在回路,若存在则有死锁,通常来说InnoDB存储引擎选择回滚undo量最小的事务。

锁的升级:

锁升级(Lock Escalation)是指将当前锁的粒度降低。举例来说,数据库可以把一个表的1000个行锁升级为一个页锁,或者将页锁升级为表锁。

InnoDB存储引擎不存在锁升级的问题。因为其不是根据每个记录来产生行锁的,相反,其根据每个事务访问的每个页对锁进行管理的,采用的是位图的方式。因此不管一个事务锁住页中一个记录还是多个记录,其开销通常都是一致的。

4.2 介绍一下间隙锁

参考答案

InnoDB存储引擎有3种行锁的算法,间隙锁(Gap Lock)是其中之一。间隙锁用于锁定一个范围,但不包含记录本身。它的作用是为了阻止多个事务将记录插入到同一范围内,而这会导致幻读问题的产生。

4.3 InnoDB中行级锁是怎么实现的?

参考答案

InnoDB行级锁是通过给索引上的索引项加锁来实现的。只有通过索引条件检索数据,InnoDB才使用行级锁,否则,InnoDB将使用表锁。

当表中锁定其中的某几行时,不同的事务可以使用不同的索引锁定不同的行。另外,不论使用主键索引、唯一索引还是普通索引,InnoDB都会使用行锁来对数据加锁。

4.4 数据库在什么情况下会发生死锁?

参考答案

死锁是指两个或两个以上的事务在执行过程中,因争夺锁资源而造成的一种互相等待的现象。若无外力作用,事务都将无法推进下去。下图演示了死锁的一种经典的情况,即A等待B、B等待A,这种死锁问题被称为AB-BA死锁。

4.5 说说数据库死锁的解决办法

参考答案

解决死锁问题最简单的一种方法是超时,即当两个事务互相等待时,当一个等待时间超过设置的某一阈值时,其中一个事务进行回滚,另一个等待的事务就能继续进行。

除了超时机制,当前数据库还都普遍采用wait-for graph(等待图)的方式来进行死锁检测。较之超时的解决方案,这是一种更为主动的死锁检测方式。InnoDB存储引擎也采用的这种方式。wait-for graph要求数据库保存以下两种信息:

  • 锁的信息链表;
  • 事务等待链表;

通过上述链表可以构造出一张图,而在这个图中若存在回路,就代表存在死锁,因此资源间相互发生等待。这是一种较为主动的死锁检测机制,在每个事务请求锁并发生等待时都会判断是否存在回路,若存在则有死锁,通常来说InnoDB存储引擎选择回滚undo量最小的事务。

5. 优化

5.1 说一说你对数据库优化的理解

参考答案

MySQL数据库优化是多方面的,原则是减少系统的瓶颈,减少资源的占用,增加系统的反应速度。例如,通过优化文件系统,提高磁盘I\O的读写速度;通过优化操作系统调度策略,提高MySQL在高负荷情况下的负载能力;优化表结构、索引、查询语句等使查询响应更快。

针对查询,我们可以通过使用索引、使用连接代替子查询的方式来提高查询速度。

针对慢查询,我们可以通过分析慢查询日志,来发现引起慢查询的原因,从而有针对性的进行优化。

针对插入,我们可以通过禁用索引、禁用检查等方式来提高插入速度,在插入之后再启用索引和检查。

针对数据库结构,我们可以通过将字段很多的表拆分成多张表、增加中间表、增加冗余字段等方式进行优化。

5.2 该如何优化MySQL的查询?

参考答案

使用索引:

如果查询时没有使用索引,查询语句将扫描表中的所有记录。在数据量大的情况下,这样查询的速度会很慢。如果使用索引进行查询,查询语句可以根据索引快速定位到待查询记录,从而减少查询的记录数,达到提高查询速度的目的。

索引可以提高查询的速度,但并不是使用带有索引的字段查询时索引都会起作用。有几种特殊情况,在这些情况下有可能使用带有索引的字段查询时索引并没有起作用。

  1. 使用LIKE关键字的查询语句

    在使用LIKE关键字进行查询的查询语句中,如果匹配字符串的第一个字符为“%”,索引不会起作用。只有“%”不在第一个位置,索引才会起作用。

  2. 使用多列索引的查询语句

    MySQL可以为多个字段创建索引。一个索引可以包括16个字段。对于多列索引,只有查询条件中使用了这些字段中的第1个字段时索引才会被使用。

  3. 使用OR关键字的查询语句

    查询语句的查询条件中只有OR关键字,且OR前后的两个条件中的列都是索引时,查询中才使用索引。否则,查询将不使用索引。

优化子查询:

使用子查询可以进行SELECT语句的嵌套查询,即一个SELECT查询的结果作为另一个SELECT语句的条件。子查询可以一次性完成很多逻辑上需要多个步骤才能完成的SQL操作。

子查询虽然可以使查询语句很灵活,但执行效率不高。执行子查询时,MySQL需要为内层查询语句的查询结果建立一个临时表。然后外层查询语句从临时表中查询记录。查询完毕后,再撤销这些临时表。因此,子查询的速度会受到一定的影响。如果查询的数据量比较大,这种影响就会随之增大。

在MySQL中,可以使用连接(JOIN)查询来替代子查询。连接查询不需要建立临时表,其速度比子查询要快,如果查询中使用索引,性能会更好。

5.3 怎样插入数据才能更高效?

参考答案

影响插入速度的主要是索引、唯一性校验、一次插入记录条数等。针对这些情况,可以分别进行优化。

对于MyISAM引擎的表,常见的优化方法如下:

  1. 禁用索引

    对于非空表,插入记录时,MySQL会根据表的索引对插入的记录建立索引。如果插入大量数据,建立索引会降低插入记录的速度。为了解决这种情况,可以在插入记录之前禁用索引,数据插入完毕后再开启索引。对于空表批量导入数据,则不需要进行此操作,因为MyISAM引擎的表是在导入数据之后才建立索引的。

  2. 禁用唯一性检查

    插入数据时,MySQL会对插入的记录进行唯一性校验。这种唯一性校验也会降低插入记录的速度。为了降低这种情况对查询速度的影响,可以在插入记录之前禁用唯一性检查,等到记录插入完毕后再开启。

  3. 使用批量插入

    插入多条记录时,可以使用一条INSERT语句插入一条记录,也可以使用一条INSERT语句插入多条记录。使用一条INSERT语句插入多条记录的情形如下,而这种方式的插入速度更快。

    INSERT INTO fruits VALUES ('x1', '101', 'mongo2', '5.7'), ('x2', '101', 'mongo3', '5.7'), ('x3', '101', 'mongo4', '5.7');
    
  4. 使用LOAD DATA INFILE批量导入

    当需要批量导入数据时,如果能用LOAD DATA INFILE语句,就尽量使用。因为LOAD DATA INFILE语句导入数据的速度比INSERT语句快。

对于InnoDB引擎的表,常见的优化方法如下:

  1. 禁用唯一性检查

    插入数据之前执行set unique_checks=0来禁止对唯一索引的检查,数据导入完成之后再运行set unique_checks=1。这个和MyISAM引擎的使用方法一样。

  2. 禁用外键检查

    插入数据之前执行禁止对外键的检查,数据插入完成之后再恢复对外键的检查。

  3. 禁用自动提交

    插入数据之前禁止事务的自动提交,数据导入完成之后,执行恢复自动提交操作。

5.4 表中包含几千万条数据该怎么办?

参考答案

建议按照如下顺序进行优化:

  1. 优化SQL和索引;
  2. 增加缓存,如memcached、redis;
  3. 读写分离,可以采用主从复制,也可以采用主主复制;
  4. 使用MySQL自带的分区表,这对应用是透明的,无需改代码,但SQL语句是要针对分区表做优化的;
  5. 做垂直拆分,即根据模块的耦合度,将一个大的系统分为多个小的系统;
  6. 做水平拆分,要选择一个合理的sharding key,为了有好的查询效率,表结构也要改动,做一定的冗余,应用也要改,sql中尽量带sharding key,将数据定位到限定的表上去查,而不是扫描全部的表。

5.5 MySQL的慢查询优化有了解吗?

参考答案

优化MySQL的慢查询,可以按照如下步骤进行:

开启慢查询日志:

MySQL中慢查询日志默认是关闭的,可以通过配置文件my.ini或者my.cnf中的log-slow-queries选项打开,也可以在MySQL服务启动的时候使用–log-slow-queries[=file_name]启动慢查询日志。

启动慢查询日志时,需要在my.ini或者my.cnf文件中配置long_query_time选项指定记录阈值,如果某条查询语句的查询时间超过了这个值,这个查询过程将被记录到慢查询日志文件中。

分析慢查询日志:

直接分析mysql慢查询日志,利用explain关键字可以模拟优化器执行SQL查询语句,来分析sql慢查询语句。

常见慢查询优化:

  1. 索引没起作用的情况

    • 在使用LIKE关键字进行查询的查询语句中,如果匹配字符串的第一个字符为“%”,索引不会起作用。只有“%”不在第一个位置,索引才会起作用。
    • MySQL可以为多个字段创建索引。一个索引可以包括16个字段。对于多列索引,只有查询条件中使用了这些字段中的第1个字段时索引才会被使用。
    • 查询语句的查询条件中只有OR关键字,且OR前后的两个条件中的列都是索引时,查询中才使用索引。否则,查询将不使用索引。
  2. 优化数据库结构

    • 对于字段比较多的表,如果有些字段的使用频率很低,可以将这些字段分离出来形成新表。因为当一个表的数据量很大时,会由于使用频率低的字段的存在而变慢。
    • 对于需要经常联合查询的表,可以建立中间表以提高查询效率。通过建立中间表,把需要经常联合查询的数据插入到中间表中,然后将原来的联合查询改为对中间表的查询,以此来提高查询效率。
  3. 分解关联查询

    很多高性能的应用都会对关联查询进行分解,就是可以对每一个表进行一次单表查询,然后将查询结果在应用程序中进行关联,很多场景下这样会更高效。

  4. 优化LIMIT分页

    当偏移量非常大的时候,例如可能是limit 10000,20这样的查询,这是mysql需要查询10020条然后只返回最后20条,前面的10000条记录都将被舍弃,这样的代价很高。优化此类查询的一个最简单的方法是尽可能的使用索引覆盖扫描,而不是查询所有的列。然后根据需要做一次关联操作再返回所需的列。对于偏移量很大的时候这样做的效率会得到很大提升。

5.6 说一说你对explain的了解

参考答案

MySQL中提供了EXPLAIN语句和DESCRIBE语句,用来分析查询语句,EXPLAIN语句的基本语法如下:

EXPLAIN [EXTENDED] SELECT select_options

使用EXTENED关键字,EXPLAIN语句将产生附加信息。执行该语句,可以分析EXPLAIN后面SELECT语句的执行情况,并且能够分析出所查询表的一些特征。下面对查询结果进行解释:

  • id:SELECT识别符。这是SELECT的查询序列号。
  • select_type:表示SELECT语句的类型。
  • table:表示查询的表。
  • type:表示表的连接类型。
  • possible_keys:给出了MySQL在搜索数据记录时可选用的各个索引。
  • key:是MySQL实际选用的索引。
  • key_len:给出索引按字节计算的长度,key_len数值越小,表示越快。
  • ref:给出了关联关系中另一个数据表里的数据列名。
  • rows:是MySQL在执行这个查询时预计会从这个数据表里读出的数据行的个数。
  • Extra:提供了与关联操作有关的信息。

扩展阅读

DESCRIBE语句的使用方法与EXPLAIN语句是一样的,分析结果也是一样的,并且可以缩写成DESC。。DESCRIBE语句的语法形式如下:

DESCRIBE SELECT select_options

5.7 explain关注什么?

参考答案

重点要关注如下几列:

列名 备注
type 本次查询表联接类型,从这里可以看到本次查询大概的效率。
key 最终选择的索引,如果没有索引的话,本次查询效率通常很差。
key_len 本次查询用于结果过滤的索引实际长度。
rows 预计需要扫描的记录数,预计需要扫描的记录数越小越好。
Extra 额外附加信息,主要确认是否出现 Using filesort、Using temporary 这两种情况。

其中,type包含以下几种结果,从上之下依次是最差到最好:

类型 备注
ALL 执行full table scan,这是最差的一种方式。
index 执行full index scan,并且可以通过索引完成结果扫描并且直接从索引中取的想要的结果数据,也就是可以避免回表,比ALL略好,因为索引文件通常比全部数据要来的小。
range 利用索引进行范围查询,比index略好。
index_subquery 子查询中可以用到索引。
unique_subquery 子查询中可以用到唯一索引,效率比 index_subquery 更高些。
index_merge 可以利用index merge特性用到多个索引,提高查询效率。
ref_or_null 表连接类型是ref,但进行扫描的索引列中可能包含NULL值。
fulltext 全文检索。
ref 基于索引的等值查询,或者表间等值连接。
eq_ref 表连接时基于主键或非NULL的唯一索引完成扫描,比ref略好。
const 基于主键或唯一索引唯一值查询,最多返回一条结果,比eq_ref略好。
system 查询对象表只有一行数据,这是最好的情况。

另外,Extra列需要注意以下的几种情况:

关键字 备注
Using filesort 将用外部排序而不是按照索引顺序排列结果,数据较少时从内存排序,否则需要在磁盘完成排序,代价非常高,需要添加合适的索引。
Using temporary 需要创建一个临时表来存储结果,这通常发生在对没有索引的列进行GROUP BY时,或者ORDER BY里的列不都在索引里,需要添加合适的索引。
Using index 表示MySQL使用覆盖索引避免全表扫描,不需要再到表中进行二次查找数据,这是比较好的结果之一。注意不要和type中的index类型混淆。
Using where 通常是进行了全表/全索引扫描后再用WHERE子句完成结果过滤,需要添加合适的索引。
Impossible WHERE 对Where子句判断的结果总是false而不能选择任何数据,例如where 1=0,无需过多关注。
Select tables optimized away 使用某些聚合函数来访问存在索引的某个字段时,优化器会通过索引直接一次定位到所需要的数据行完成整个查询,例如MIN()\MAX(),这种也是比较好的结果之一。

6. 其他

6.1 介绍一下数据库设计的三大范式

参考答案

目前关系数据库有六种范式,一般来说,数据库只需满足第三范式(3NF)就行了。

第一范式(1NF):

是指在关系模型中,对于添加的一个规范要求,所有的域都应该是原子性的,即数据库表的每一列都是不可分割的原子数据项,而不能是集合,数组,记录等非原子数据项。

即实体中的某个属性有多个值时,必须拆分为不同的属性。在符合第一范式表中的每个域值只能是实体的一个属性或一个属性的一部分。简而言之,第一范式就是无重复的域。

第二范式(2NF):

在1NF的基础上,非码属性必须完全依赖于候选码(在1NF基础上消除非主属性对主码的部分函数依赖)。

第二范式是在第一范式的基础上建立起来的,即满足第二范式必须先满足第一范式。第二范式要求数据库表中的每个实例或记录必须可以被唯一地区分。选取一个能区分每个实体的属性或属性组,作为实体的唯一标识。

例如在员工表中的身份证号码即可实现每个一员工的区分,该身份证号码即为候选键,任何一个候选键都可以被选作主键。在找不到候选键时,可额外增加属性以实现区分,如果在员工关系中,没有对其身份证号进行存储,而姓名可能会在数据库运行的某个时间重复,无法区分出实体时,设计辟如ID等不重复的编号以实现区分,被添加的编号或ID选作主键。

第三范式(3NF):

在2NF基础上,任何非主属性不依赖于其它非主属性(在2NF基础上消除传递依赖)。

第三范式是第二范式的一个子集,即满足第三范式必须满足第二范式。简而言之,第三范式要求一个关系中不包含已在其它关系已包含的非主关键字信息。

例如,存在一个部门信息表,其中每个部门有部门编号(dept_id)、部门名称、部门简介等信息。那么在员工信息表中列出部门编号后就不能再将部门名称、部门简介等与部门有关的信息再加入员工信息表中。如果不存在部门信息表,则根据第三范式(3NF)也应该构建它,否则就会有大量的数据冗余。

6.2 说一说你对MySQL引擎的了解

参考答案

MySQL提供了多个不同的存储引擎,包括处理事务安全表的引擎和处理非事务安全表的引擎。在MySQL中,不需要在整个服务器中使用同一种存储引擎,针对具体的要求,可以对每一个表使用不同的存储引擎。MySQL 8.0支持的存储引擎有InnoDB、MyISAM、Memory、Merge、Archive、Federated、CSV、BLACKHOLE等。其中,最常用的引擎是InnoDB和MyISAM。

InnoDB存储引擎:

InnoDB是事务型数据库的首选引擎,支持事务安全表(ACID),支持行锁定和外键。MySQL 5.5.5之后,InnoDB作为默认存储引擎,主要特性如下:

  1. InnoDB给MySQL提供了具有提交、回滚和崩溃恢复能力的事务安全(ACID兼容)存储引擎。InnoDB锁定在行级并且也在SELECT语句中提供一个类似Oracle的非锁定读。这些功能增加了多用户部署和性能。在SQL查询中,可以自由地将InnoDB类型的表与其他MySQL表的类型混合起来,甚至在同一个查询中也可以混合。
  2. InnoDB是为处理巨大数据量的最大性能设计。它的CPU效率可能是任何其他基于磁盘的关系数据库引擎所不能匹敌的。
  3. InnoDB存储引擎完全与MySQL服务器整合,为在主内存中缓存数据和索引而维持它自己的缓冲池。InnoDB将它的表和索引存在一个逻辑表空间中,表空间可以包含数个文件(或原始磁盘分区)。这与MyISAM表不同,比如在MyISAM表中每个表被存在分离的文件中。InnoDB表可以是任何尺寸,即使在文件尺寸被限制为2GB的操作系统上。
  4. InnoDB支持外键完整性约束(FOREIGN KEY)。存储表中的数据时,每张表的存储都按主键顺序存放,如果没有显示在表定义时指定主键,InnoDB会为每一行生成一个6B的ROWID,并以此作为主键。
  5. InnoDB被用在众多需要高性能的大型数据库站点上。InnoDB不创建目录,使用InnoDB时,MySQL将在数据目录下创建一个名为ibdata1的10MB大小的自动扩展数据文件,以及两个名为ib_logfile0和ib_logfile1的5MB大小的日志文件。

MyISAM存储引擎:

MyISAM基于ISAM存储引擎,并对其进行扩展。它是在Web、数据仓储和其他应用环境下最常使用的存储引擎之一。MyISAM拥有较高的插入、查询速度,但不支持事务。MyISAM的主要特性如下:

  1. 在支持大文件(达63位文件长度)的文件系统和操作系统上被支持。
  2. 当把删除和更新及插入操作混合使用的时候,动态尺寸的行产生更少碎片。这要通过合并相邻被删除的块以及若下一个块被删除则扩展到下一块来自动完成。
  3. 每个MyISAM表最大的索引数是64,这可以通过重新编译来改变。每个索引最大的列数是16个。
  4. 最大的键长度是1000B,这也可以通过编译来改变。对于键长度超过250B的情况,一个超过1024B的键将被用上。
  5. BLOB和TEXT列可以被索引。
  6. NULL值被允许在索引的列中,这个值占每个键的0~1个字节。
  7. 所有数字键值以高字节优先被存储,以允许一个更高的索引压缩。
  8. 每个表一个AUTO_INCREMENT列的内部处理。MyISAM为INSERT和UPDATE操作自动更新这一列,这使得AUTO_INCREMENT列更快(至少10%)。在序列顶的值被删除之后就不能再利用。
  9. 可以把数据文件和索引文件放在不同目录。
  10. 每个字符列可以有不同的字符集。
  11. 有VARCHAR的表可以固定或动态记录长度。
  12. VARCHAR和CHAR列可以多达64KB。

6.3 说一说你对redo log、undo log、binlog的了解

参考答案

binlog(Binary Log):

二进制日志文件就是常说的binlog。二进制日志记录了MySQL所有修改数据库的操作,然后以二进制的形式记录在日志文件中,其中还包括每条语句所执行的时间和所消耗的资源,以及相关的事务信息。

默认情况下,二进制日志功能是开启的,启动时可以重新配置–log-bin[=file_name]选项,修改二进制日志存放的目录和文件名称。

redo log:

重做日志用来实现事务的持久性,即事务ACID中的D。它由两部分组成:一是内存中的重做日志缓冲(redo log buffer),其是易失的;二是重做日志文件(redo log file),它是持久的。

InnoDB是事务的存储引擎,它通过Force Log at Commit机制实现事务的持久性,即当事务提交(COMMIT)时,必须先将该事务的所有日志写入到重做日志文件进行持久化,待事务的COMMIT操作完成才算完成。这里的日志是指重做日志,在InnoDB存储引擎中,由两部分组成,即redo log和undo log。

redo log用来保证事务的持久性,undo log用来帮助事务回滚及MVCC的功能。redo log基本上都是顺序写的,在数据库运行时不需要对redo log的文件进行读取操作。而undo log是需要进行随机读写的。

undo log:

重做日志记录了事务的行为,可以很好地通过其对页进行“重做”操作。但是事务有时还需要进行回滚操作,这时就需要undo。因此在对数据库进行修改时,InnoDB存储引擎不但会产生redo,还会产生一定量的undo。这样如果用户执行的事务或语句由于某种原因失败了,又或者用户用一条ROLLBACK语句请求回滚,就可以利用这些undo信息将数据回滚到修改之前的样子。

redo存放在重做日志文件中,与redo不同,undo存放在数据库内部的一个特殊段(segment)中,这个段称为undo段(undo segment),undo段位于共享表空间内。

6.4 谈谈你对MVCC的了解

参考答案

InnoDB默认的隔离级别是RR(REPEATABLE READ),RR解决脏读、不可重复读、幻读等问题,使用的是MVCC。MVCC全称Multi-Version Concurrency Control,即多版本的并发控制协议。它最大的优点是读不加锁,因此读写不冲突,并发性能好。InnoDB实现MVCC,多个版本的数据可以共存,主要基于以下技术及数据结构:

  1. 隐藏列:InnoDB中每行数据都有隐藏列,隐藏列中包含了本行数据的事务id、指向undo log的指针等。
  2. 基于undo log的版本链:每行数据的隐藏列中包含了指向undo log的指针,而每条undo log也会指向更早版本的undo log,从而形成一条版本链。
  3. ReadView:通过隐藏列和版本链,MySQL可以将数据恢复到指定版本。但是具体要恢复到哪个版本,则需要根据ReadView来确定。所谓ReadView,是指事务(记做事务A)在某一时刻给整个事务系统(trx_sys)打快照,之后再进行读操作时,会将读取到的数据中的事务id与trx_sys快照比较,从而判断数据对该ReadView是否可见,即对事务A是否可见。

6.5 MySQL主从同步是如何实现的?

参考答案

复制(replication)是MySQL数据库提供的一种高可用高性能的解决方案,一般用来建立大型的应用。总体来说,replication的工作原理分为以下3个步骤:

  1. 主服务器(master)把数据更改记录到二进制日志(binlog)中。
  2. 从服务器(slave)把主服务器的二进制日志复制到自己的中继日志(relay log)中。
  3. 从服务器重做中继日志中的日志,把更改应用到自己的数据库上,以达到数据的最终一致性。

复制的工作原理并不复杂,其实就是一个完全备份加上二进制日志备份的还原。不同的是这个二进制日志的还原操作基本上实时在进行中。这里特别需要注意的是,复制不是完全实时地进行同步,而是异步实时。这中间存在主从服务器之间的执行延时,如果主服务器的压力很大,则可能导致主从服务器延时较大。复制的工作原理如下图所示,其中从服务器有2个线程,一个是I/O线程,负责读取主服务器的二进制日志,并将其保存为中继日志;另一个是SQL线程,复制执行中继日志。

Web开发

1. Spring Boot

1.1 说说你对Spring Boot的理解

参考答案

从本质上来说,Spring Boot就是Spring,它做了那些没有它你自己也会去做的Spring Bean配置。Spring Boot使用“习惯优于配置”的理念让你的项目快速地运行起来,使用Spring Boot很容易创建一个能独立运行、准生产级别、基于Spring框架的项目,使用Spring Boot你可以不用或者只需要很少的Spring配置。

简而言之,Spring Boot本身并不提供Spring的核心功能,而是作为Spring的脚手架框架,以达到快速构建项目、预置三方配置、开箱即用的目的。Spring Boot有如下的优点:

  • 可以快速构建项目;
  • 可以对主流开发框架的无配置集成;
  • 项目可独立运行,无需外部依赖Servlet容器;
  • 提供运行时的应用监控;
  • 可以极大地提高开发、部署效率;
  • 可以与云计算天然集成。

1.2 Spring Boot Starter有什么用?

参考答案

Spring Boot通过提供众多起步依赖(Starter)降低项目依赖的复杂度。起步依赖本质上是一个Maven项目对象模型(Project Object Model, POM),定义了对其他库的传递依赖,这些东西加在一起即支持某项功能。很多起步依赖的命名都暗示了它们提供的某种或某类功能。

举例来说,你打算把这个阅读列表应用程序做成一个Web应用程序。与其向项目的构建文件里添加一堆单独的库依赖,还不如声明这是一个Web应用程序来得简单。你只要添加Spring Boot的Web起步依赖就好了。

1.3 介绍Spring Boot的启动流程

参考答案

首先,Spring Boot项目创建完成会默认生成一个名为 *Application 的入口类,我们是通过该类的main方法启动Spring Boot项目的。在main方法中,通过SpringApplication的静态方法,即run方法进行SpringApplication类的实例化操作,然后再针对实例化对象调用另外一个run方法来完成整个项目的初始化和启动。

SpringApplication调用的run方法的大致流程,如下图:

其中,SpringApplication在run方法中重点做了以下操作:

  • 获取监听器和参数配置;
  • 打印Banner信息;
  • 创建并初始化容器;
  • 监听器发送通知。

当然,除了上述核心操作,run方法运行过程中还涉及启动时长统计、异常报告、启动日志、异常处理等辅助操作。比较完整的流程,可以参考如下源代码:

public ConfigurableApplicationContext run(String... args) {     // 创建StopWatch对象,用于统计run方法启动时长。     StopWatch stopWatch = new StopWatch();     // 启动统计     stopWatch.start();     ConfigurableApplicationContext context = null;     Collection<SpringBootExceptionReporter> exceptionReporters = new ArrayList<>();     // 配置Headless属性     configureHeadlessProperty();     // 获得SpringApplicationRunListener数组,     // 该数组封装于SpringApplicationRunListeners对象的listeners中。     SpringApplicationRunListeners listeners = getRunListeners(args);     // 启动监听,遍历SpringApplicationRunListener数组每个元素,并执行。     listeners.starting();     try {         // 创建ApplicationArguments对象         ApplicationArguments applicationArguments =              new DefaultApplicationArguments(args);         // 加载属性配置,包括所有的配置属性。         ConfigurableEnvironment environment =              prepareEnvironment(listeners, applicationArguments);         configureIgnoreBeanInfo(environment);         // 打印Banner         Banner printedBanner = printBanner(environment);         // 创建容器         context = createApplicationContext();         // 异常报告器         exceptionReporters = getSpringFactoriesInstances(             SpringBootExceptionReporter.class,             new Class[] { ConfigurableApplicationContext.class }, context);         // 准备容器,组件对象之间进行关联。         prepareContext(context, environment,                         listeners, applicationArguments, printedBanner);         // 初始化容器         refreshContext(context);         // 初始化操作之后执行,默认实现为空。         afterRefresh(context, applicationArguments);         // 停止时长统计         stopWatch.stop();         // 打印启动日志         if (this.logStartupInfo) {             new StartupInfoLogger(this.mainApplicationClass)                 .logStarted(getApplicationLog(), stopWatch);         }         // 通知监听器:容器完成启动。         listeners.started(context);         // 调用ApplicationRunner和CommandLineRunner的运行方法。         callRunners(context, applicationArguments);     } catch (Throwable ex) {         // 异常处理         handleRunFailure(context, ex, exceptionReporters, listeners);         throw new IllegalStateException(ex);     }      try {         // 通知监听器:容器正在运行。         listeners.running(context);     } catch (Throwable ex) {         // 异常处理         handleRunFailure(context, ex, exceptionReporters, null);         throw new IllegalStateException(ex);     }     return context; }

1.4 Spring Boot项目是如何导入包的?

参考答案

通过Spring Boot Starter导入包。

Spring Boot通过提供众多起步依赖(Starter)降低项目依赖的复杂度。起步依赖本质上是一个Maven项目对象模型(Project Object Model, POM),定义了对其他库的传递依赖,这些东西加在一起即支持某项功能。很多起步依赖的命名都暗示了它们提供的某种或某类功能。

举例来说,你打算把这个阅读列表应用程序做成一个Web应用程序。与其向项目的构建文件里添加一堆单独的库依赖,还不如声明这是一个Web应用程序来得简单。你只要添加Spring Boot的Web起步依赖就好了。

1.5 请描述Spring Boot自动装配的过程

参考答案

使用Spring Boot时,我们只需引入对应的Starters,Spring Boot启动时便会自动加载相关依赖,配置相应的初始化参数,以最快捷、简单的形式对第三方软件进行集成,这便是Spring Boot的自动配置功能。Spring Boot实现该运作机制锁涉及的核心部分如下图所示:

整个自动装配的过程是:Spring Boot通过@EnableAutoConfiguration注解开启自动配置,加载spring.factories中注册的各种AutoConfiguration类,当某个AutoConfiguration类满足其注解@Conditional指定的生效条件(Starters提供的依赖、配置或Spring容器中是否存在某个Bean等)时,实例化该AutoConfiguration类中定义的Bean(组件等),并注入Spring容器,就可以完成依赖框架的自动配置。

1.6 说说你对Spring Boot注解的了解

参考答案

@SpringBootApplication注解:

在Spring Boot入口类中,唯一的一个注解就是@SpringBootApplication。它是Spring Boot项目的核心注解,用于开启自动配置,准确说是通过该注解内组合的@EnableAutoConfiguration开启了自动配置。

@EnableAutoConfiguration注解:

@EnableAutoConfiguration的主要功能是启动Spring应用程序上下文时进行自动配置,它会尝试猜测并配置项目可能需要的Bean。自动配置通常是基于项目classpath中引入的类和已定义的Bean来实现的。在此过程中,被自动配置的组件来自项目自身和项目依赖的jar包中。

@Import注解:

@EnableAutoConfiguration的关键功能是通过@Import注解导入的ImportSelector来完成的。从源代码得知@Import(AutoConfigurationImportSelector.class)是@EnableAutoConfiguration注解的组成部分,也是自动配置功能的核心实现者。

@Conditional注解:

@Conditional注解是由Spring 4.0版本引入的新特性,可根据是否满足指定的条件来决定是否进行Bean的实例化及装配,比如,设定当类路径下包含某个jar包的时候才会对注解的类进行实例化操作。总之,就是根据一些特定条件来控制Bean实例化的行为。

@Conditional衍生注解:

在Spring Boot的autoconfigure项目中提供了各类基于@Conditional注解的衍生注解,它们适用不同的场景并提供了不同的功能。通过阅读这些注解的源码,你会发现它们其实都组合了@Conditional注解,不同之处是它们在注解中指定的条件(Condition)不同。

  • @ConditionalOnBean:在容器中有指定Bean的条件下。
  • @ConditionalOnClass:在classpath类路径下有指定类的条件下。
  • @ConditionalOnCloudPlatform:当指定的云平台处于active状态时。
  • @ConditionalOnExpression:基于SpEL表达式的条件判断。
  • @ConditionalOnJava:基于JVM版本作为判断条件。
  • @ConditionalOnJndi:在JNDI存在的条件下查找指定的位置。
  • @ConditionalOnMissingBean:当容器里没有指定Bean的条件时。
  • @ConditionalOnMissingClass:当类路径下没有指定类的条件时。
  • @ConditionalOnNotWebApplication:在项目不是一个Web项目的条件下。
  • @ConditionalOnProperty:在指定的属性有指定值的条件下。
  • @ConditionalOnResource:类路径是否有指定的值。
  • @ConditionalOnSingleCandidate:当指定的Bean在容器中只有一个或者有多个但是指定了首选的Bean时。
  • @ConditionalOnWebApplication:在项目是一个Web项目的条件下。

2. Spring

2.1 请你说说Spring的核心是什么

参考答案

Spring框架包含众多模块,如Core、Testing、Data Access、Web Servlet等,其中Core是整个Spring框架的核心模块。Core模块提供了IoC容器、AOP功能、数据绑定、类型转换等一系列的基础功能,而这些功能以及其他模块的功能都是建立在IoC和AOP之上的,所以IoC和AOP是Spring框架的核心。

IoC(Inversion of Control)是控制反转的意思,这是一种面向对象编程的设计思想。在不采用这种思想的情况下,我们需要自己维护对象与对象之间的依赖关系,很容易造成对象之间的耦合度过高,在一个大型的项目中这十分的不利于代码的维护。IoC则可以解决这种问题,它可以帮我们维护对象与对象之间的依赖关系,降低对象之间的耦合度。

说到IoC就不得不说DI(Dependency Injection),DI是依赖注入的意思,它是IoC实现的实现方式,就是说IoC是通过DI来实现的。由于IoC这个词汇比较抽象而DI却更直观,所以很多时候我们就用DI来代替它,在很多时候我们简单地将IoC和DI划等号,这是一种习惯。而实现依赖注入的关键是IoC容器,它的本质就是一个工厂。

AOP(Aspect Oriented Programing)是面向切面编程思想,这种思想是对OOP的补充,它可以在OOP的基础上进一步提高编程的效率。简单来说,它可以统一解决一批组件的共性需求(如权限检查、记录日志、事务管理等)。在AOP思想下,我们可以将解决共性需求的代码独立出来,然后通过配置的方式,声明这些代码在什么地方、什么时机调用。当满足调用条件时,AOP会将该业务代码织入到我们指定的位置,从而统一解决了问题,又不需要修改这一批组件的代码。

2.2 说一说你对Spring容器的了解

参考答案

Spring主要提供了两种类型的容器:BeanFactory和ApplicationContext。

  • BeanFactory:是基础类型的IoC容器,提供完整的IoC服务支持。如果没有特殊指定,默认采用延 迟初始化策略。只有当客户端对象需要访问容器中的某个受管对象的时候,才对该受管对象进行初始化以及依赖注入操作。所以,相对来说,容器启动初期速度较快,所需要的资源有限。对于资源有限,并且功能要求不是很严格的场景,BeanFactory是比较合适的IoC容器选择。
  • ApplicationContext:它是在BeanFactory的基础上构建的,是相对比较高级的容器实现,除了拥有BeanFactory的所有支持,ApplicationContext还提供了其他高级特性,比如事件发布、国际化信息支持等。ApplicationContext所管理的对象,在该类型容器启动之后,默认全部初始化并绑定完成。所以,相对于BeanFactory来说,ApplicationContext要求更多的系统资源,同时,因为在启动时就完成所有初始化,容 器启动时间较之BeanFactory也会长一些。在那些系统资源充足,并且要求更多功能的场景中,ApplicationContext类型的容器是比较合适的选择。

2.3 说一说你对BeanFactory的了解

参考答案

BeanFactory是一个类工厂,与传统类工厂不同的是,BeanFactory是类的通用工厂,它可以创建并管理各种类的对象。这些可被创建和管理的对象本身没有什么特别之处,仅是一个POJO,Spring称这些被创建和管理的Java对象为Bean。并且,Spring中所说的Bean比JavaBean更为宽泛一些,所有可以被Spring容器实例化并管理的Java类都可以成为Bean。

BeanFactory是Spring容器的顶层接口,Spring为BeanFactory提供了多种实现,最常用的是XmlBeanFactory。但它在Spring 3.2中已被废弃,建议使用XmlBeanDefinitionReader、DefaultListableBeanFactory替代。BeanFactory最主要的方法就是 getBean(String beanName),该方法从容器中返回特定名称的Bean。

2.4 说一说你对Spring IOC的理解

参考答案

IoC(Inversion of Control)是控制反转的意思,这是一种面向对象编程的设计思想。在不采用这种思想的情况下,我们需要自己维护对象与对象之间的依赖关系,很容易造成对象之间的耦合度过高,在一个大型的项目中这十分的不利于代码的维护。IoC则可以解决这种问题,它可以帮我们维护对象与对象之间的依赖关系,降低对象之间的耦合度。

说到IoC就不得不说DI(Dependency Injection),DI是依赖注入的意思,它是IoC实现的实现方式,就是说IoC是通过DI来实现的。由于IoC这个词汇比较抽象而DI却更直观,所以很多时候我们就用DI来代替它,在很多时候我们简单地将IoC和DI划等号,这是一种习惯。而实现依赖注入的关键是IoC容器,它的本质就是一个工厂。

在具体的实现中,主要由三种注入方式:

  1. 构造方法注入

    就是被注入对象可以在它的构造方法中声明依赖对象的参数列表,让外部知道它需要哪些依赖对象。然后,IoC Service Provider会检查被注入的对象的构造方法,取得它所需要的依赖对象列表,进而为其注入相应的对象。构造方法注入方式比较直观,对象被构造完成后,即进入就绪状态,可以马上使用。

  2. setter方法注入

    通过setter方法,可以更改相应的对象属性。所以,当前对象只要为其依赖对象所对应的属性添加setter方法,就可以通过setter方法将相应的依赖对象设置到被注入对象中。setter方法注入虽不像构造方法注入那样,让对象构造完成后即可使用,但相对来说更宽松一些, 可以在对象构造完成后再注入。

  3. 接口注入

    相对于前两种注入方式来说,接口注入没有那么简单明了。被注入对象如果想要IoC Service Provider为其注入依赖对象,就必须实现某个接口。这个接口提供一个方法,用来为其注入依赖对象。IoC Service Provider最终通过这些接口来了解应该为被注入对象注入什么依赖对象。相对于前两种依赖注入方式,接口注入比较死板和烦琐。

总体来说,构造方法注入和setter方法注入因为其侵入性较弱,且易于理解和使用,所以是现在使用最多的注入方式。而接口注入因为侵入性较强,近年来已经不流行了。

2.5 Spring是如何管理Bean的?

参考答案

Spring通过IoC容器来管理Bean,我们可以通过XML配置或者注解配置,来指导IoC容器对Bean的管理。因为注解配置比XML配置方便很多,所以现在大多时候会使用注解配置的方式。

以下是管理Bean时常用的一些注解:

  1. @ComponentScan用于声明扫描策略,通过它的声明,容器就知道要扫描哪些包下带有声明的类,也可以知道哪些特定的类是被排除在外的。
  2. @Component、@Repository、@Service、@Controller用于声明Bean,它们的作用一样,但是语义不同。@Component用于声明通用的Bean,@Repository用于声明DAO层的Bean,@Service用于声明业务层的Bean,@Controller用于声明视图层的控制器Bean,被这些注解声明的类就可以被容器扫描并创建。
  3. @Autowired、@Qualifier用于注入Bean,即告诉容器应该为当前属性注入哪个Bean。其中,@Autowired是按照Bean的类型进行匹配的,如果这个属性的类型具有多个Bean,就可以通过@Qualifier指定Bean的名称,以消除歧义。
  4. @Scope用于声明Bean的作用域,默认情况下Bean是单例的,即在整个容器中这个类型只有一个实例。可以通过@Scope注解指定prototype值将其声明为多例的,也可以将Bean声明为session级作用域、request级作用域等等,但最常用的还是默认的单例模式。
  5. @PostConstruct、@PreDestroy用于声明Bean的生命周期。其中,被@PostConstruct修饰的方法将在Bean实例化后被调用,@PreDestroy修饰的方法将在容器销毁前被调用。

2.6 介绍Bean的作用域

参考答案

默认情况下,Bean在Spring容器中是单例的,我们可以通过@Scope注解修改Bean的作用域。该注解有如下5个取值,它们代表了Bean的5种不同类型的作用域:

类型 说明
singleton 在Spring容器中仅存在一个实例,即Bean以单例的形式存在。
prototype 每次调用getBean()时,都会执行new操作,返回一个新的实例。
request 每次HTTP请求都会创建一个新的Bean。
session 同一个HTTP Session共享一个Bean,不同的HTTP Session使用不同的Bean。
globalSession 同一个全局的Session共享一个Bean,一般用于Portlet环境。

2.7 说一说Bean的生命周期

参考答案

Spring容器管理Bean,涉及对Bean的创建、初始化、调用、销毁等一系列的流程,这个流程就是Bean的生命周期。整个流程参考下图:

这个过程是由Spring容器自动管理的,其中有两个环节我们可以进行干预。

  1. 我们可以自定义初始化方法,并在该方法前增加@PostConstruct注解,届时Spring容器将在调用SetBeanFactory方法之后调用该方法。
  2. 我们可以自定义销毁方法,并在该方法前增加@PreDestroy注解,届时Spring容器将在自身销毁前,调用这个方法。

2.8 Spring是怎么解决循环依赖的?

参考答案

首先,需要明确的是spring对循环依赖的处理有三种情况:

  1. 构造器的循环依赖:这种依赖spring是处理不了的,直接抛出BeanCurrentlylnCreationException异常。
  2. 单例模式下的setter循环依赖:通过“三级缓存”处理循环依赖。
  3. 非单例循环依赖:无法处理。

接下来,我们具体看看spring是如何处理第二种循环依赖的。

Spring单例对象的初始化大略分为三步:

  1. createBeanInstance:实例化,其实也就是调用对象的构造方法实例化对象;
  2. populateBean:填充属性,这一步主要是多bean的依赖属性进行填充;
  3. initializeBean:调用spring xml中的init 方法。

从上面讲述的单例bean初始化步骤我们可以知道,循环依赖主要发生在第一步、第二步。也就是构造器循环依赖和field循环依赖。 Spring为了解决单例的循环依赖问题,使用了三级缓存。

/** Cache of singleton objects: bean name –> bean instance */ private final Map singletonObjects = new ConcurrentHashMap(256); /** Cache of singleton factories: bean name –> ObjectFactory */ private final Map> singletonFactories = new HashMap>(16); /** Cache of early singleton objects: bean name –> bean instance */ private final Map earlySingletonObjects = new HashMap(16);

这三级缓存的作用分别是:

  • singletonFactories : 进入实例化阶段的单例对象工厂的cache (三级缓存);
  • earlySingletonObjects :完成实例化但是尚未初始化的,提前暴光的单例对象的Cache (二级缓存);
  • singletonObjects:完成初始化的单例对象的cache(一级缓存)。

我们在创建bean的时候,会首先从cache中获取这个bean,这个缓存就是sigletonObjects。主要的调用方法是:

protected Object getSingleton(String beanName, boolean allowEarlyReference) {     Object singletonObject = this.singletonObjects.get(beanName);     //isSingletonCurrentlyInCreation()判断当前单例bean是否正在创建中     if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {         synchronized (this.singletonObjects) {             singletonObject = this.earlySingletonObjects.get(beanName);             //allowEarlyReference 是否允许从singletonFactories中通过getObject拿到对象             if (singletonObject == null && allowEarlyReference) {                 ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);                 if (singletonFactory != null) {                     singletonObject = singletonFactory.getObject();                     //从singletonFactories中移除,并放入earlySingletonObjects中。                     //其实也就是从三级缓存移动到了二级缓存                     this.earlySingletonObjects.put(beanName, singletonObject);                     this.singletonFactories.remove(beanName);                 }             }         }     }     return (singletonObject != NULL_OBJECT ? singletonObject : null); }

从上面三级缓存的分析,我们可以知道,Spring解决循环依赖的诀窍就在于singletonFactories这个三级cache。这个cache的类型是ObjectFactory,定义如下:

public interface ObjectFactory<T> {     T getObject() throws BeansException; }

这个接口在AbstractBeanFactory里实现,并在核心方法doCreateBean()引用下面的方法:

protected void addSingletonFactory(String beanName, ObjectFactory<?> singletonFactory) {     Assert.notNull(singletonFactory, "Singleton factory must not be null");     synchronized (this.singletonObjects) {         if (!this.singletonObjects.containsKey(beanName)) {             this.singletonFactories.put(beanName, singletonFactory);             this.earlySingletonObjects.remove(beanName);             this.registeredSingletons.add(beanName);         }     } }

这段代码发生在createBeanInstance之后,populateBean()之前,也就是说单例对象此时已经被创建出来(调用了构造器)。这个对象已经被生产出来了,此时将这个对象提前曝光出来,让大家使用。

这样做有什么好处呢?让我们来分析一下“A的某个field或者setter依赖了B的实例对象,同时B的某个field或者setter依赖了A的实例对象”这种循环依赖的情况。A首先完成了初始化的第一步,并且将自己提前曝光到singletonFactories中,此时进行初始化的第二步,发现自己依赖对象B,此时就尝试去get(B),发现B还没有被create,所以走create流程,B在初始化第一步的时候发现自己依赖了对象A,于是尝试get(A),尝试一级缓存singletonObjects(肯定没有,因为A还没初始化完全),尝试二级缓存earlySingletonObjects(也没有),尝试三级缓存singletonFactories,由于A通过ObjectFactory将自己提前曝光了,所以B能够通过ObjectFactory.getObject拿到A对象(虽然A还没有初始化完全,但是总比没有好呀),B拿到A对象后顺利完成了初始化阶段1、2、3,完全初始化之后将自己放入到一级缓存singletonObjects中。此时返回A中,A此时能拿到B的对象顺利完成自己的初始化阶段2、3,最终A也完成了初始化,进去了一级缓存singletonObjects中,而且更加幸运的是,由于B拿到了A的对象引用,所以B现在hold住的A对象完成了初始化。

2.9 @Autowired和@Resource注解有什么区别?

参考答案

  1. @Autowired是Spring提供的注解,@Resource是JDK提供的注解。
  2. @Autowired是只能按类型注入,@Resource默认按名称注入,也支持按类型注入。
  3. @Autowired按类型装配依赖对象,默认情况下它要求依赖对象必须存在,如果允许null值,可以设置它required属性为false,如果我们想使用按名称装配,可以结合@Qualifier注解一起使用。@Resource有两个中重要的属性:name和type。name属性指定byName,如果没有指定name属性,当注解标注在字段上,即默认取字段的名称作为bean名称寻找依赖对象,当注解标注在属性的setter方法上,即默认取属性名作为bean名称寻找依赖对象。需要注意的是,@Resource如果没有指定name属性,并且按照默认的名称仍然找不到依赖对象时, @Resource注解会回退到按类型装配。但一旦指定了name属性,就只能按名称装配了。

2.10 Spring中默认提供的单例是线程安全的吗?

参考答案

不是。

Spring容器本身并没有提供Bean的线程安全策略。如果单例的Bean是一个无状态的Bean,即线程中的操作不会对Bean的成员执行查询以外的操作,那么这个单例的Bean是线程安全的。比如,Controller、Service、DAO这样的组件,通常都是单例且线程安全的。如果单例的Bean是一个有状态的Bean,则可以采用ThreadLocal对状态数据做线程隔离,来保证线程安全。

2.11 说一说你对Spring AOP的理解

参考答案

AOP(Aspect Oriented Programming)是面向切面编程,它是一种编程思想,是面向对象编程(OOP)的一种补充。面向对象编程将程序抽象成各个层次的对象,而面向切面编程是将程序抽象成各个切面。所谓切面,相当于应用对象间的横切点,我们可以将其单独抽象为单独的模块。

AOP的术语:

  • 连接点(join point):对应的是具体被拦截的对象,因为Spring只能支持方法,所以被拦截的对象往往就是指特定的方法,AOP将通过动态代理技术把它织入对应的流程中。
  • 切点(point cut):有时候,我们的切面不单单应用于单个方法,也可能是多个类的不同方法,这时,可以通过正则式和指示器的规则去定义,从而适配连接点。切点就是提供这样一个功能的概念。
  • 通知(advice):就是按照约定的流程下的方法,分为前置通知、后置通知、环绕通知、事后返回通知和异常通知,它会根据约定织入流程中。
  • 目标对象(target):即被代理对象。
  • 引入(introduction):是指引入新的类和其方法,增强现有Bean的功能。
  • 织入(weaving):它是一个通过动态代理技术,为原有服务对象生成代理对象,然后将与切点定义匹配的连接点拦截,并按约定将各类通知织入约定流程的过程。
  • 切面(aspect):是一个可以定义切点、各类通知和引入的内容,SpringAOP将通过它的信息来增强Bean的功能或者将对应的方法织入流程。

Spring AOP:

AOP可以有多种实现方式,而Spring AOP支持如下两种实现方式。

  • JDK动态代理:这是Java提供的动态代理技术,可以在运行时创建接口的代理实例。Spring AOP默认采用这种方式,在接口的代理实例中织入代码。
  • CGLib动态代理:采用底层的字节码技术,在运行时创建子类代理的实例。当目标对象不存在接口时,Spring AOP就会采用这种方式,在子类实例中织入代码。

2.12 请你说说AOP的应用场景

参考答案

Spring AOP为IoC的使用提供了更多的便利,一方面,应用可以直接使用AOP的功能,设计应用的横切关注点,把跨越应用程序多个模块的功能抽象出来,并通过简单的AOP的使用,灵活地编制到模块中,比如可以通过AOP实现应用程序中的日志功能。另一方面,在Spring内部,一些支持模块也是通过Spring AOP来实现的,比如事务处理。从这两个角度就已经可以看到Spring AOP的核心地位了。

2.13 Spring AOP不能对哪些类进行增强?

参考答案

  1. Spring AOP只能对IoC容器中的Bean进行增强,对于不受容器管理的对象不能增强。
  2. 由于CGLib采用动态创建子类的方式生成代理对象,所以不能对final修饰的类进行代理。

2.14 JDK动态代理和CGLIB有什么区别?

参考答案

JDK动态代理

这是Java提供的动态代理技术,可以在运行时创建接口的代理实例。Spring AOP默认采用这种方式,在接口的代理实例中织入代码。

CGLib动态代理

采用底层的字节码技术,在运行时创建子类代理的实例。当目标对象不存在接口时,Spring AOP就会采用这种方式,在子类实例中织入代码。

2.15 既然有没有接口都可以用CGLIB,为什么Spring还要使用JDK动态代理?

参考答案

在性能方面,CGLib创建的代理对象比JDK动态代理创建的代理对象高很多。但是,CGLib在创建代理对象时所花费的时间比JDK动态代理多很多。所以,对于单例的对象因为无需频繁创建代理对象,采用CGLib动态代理比较合适。反之,对于多例的对象因为需要频繁的创建代理对象,则JDK动态代理更合适。

2.16 Spring如何管理事务?

参考答案

Spring为事务管理提供了一致的编程模板,在高层次上建立了统一的事务抽象。也就是说,不管是选择MyBatis、Hibernate、JPA还是Spring JDBC,Spring都可以让用户以统一的编程模型进行事务管理。

Spring支持两种事务编程模型:

  1. 编程式事务

    Spring提供了TransactionTemplate模板,利用该模板我们可以通过编程的方式实现事务管理,而无需关注资源获取、复用、释放、事务同步及异常处理等操作。相对于声明式事务来说,这种方式相对麻烦一些,但是好在更为灵活,我们可以将事务管理的范围控制的更为精确。

  2. 声明式事务

    Spring事务管理的亮点在于声明式事务管理,它允许我们通过声明的方式,在IoC配置中指定事务的边界和事务属性,Spring会自动在指定的事务边界上应用事务属性。相对于编程式事务来说,这种方式十分的方便,只需要在需要做事务管理的方法上,增加@Transactional注解,以声明事务特征即可。

2.17 Spring的事务传播方式有哪些?

参考答案

当我们调用一个业务方法时,它的内部可能会调用其他的业务方法,以完成一个完整的业务操作。这种业务方法嵌套调用的时候,如果这两个方法都是要保证事务的,那么就要通过Spring的事务传播机制控制当前事务如何传播到被嵌套调用的业务方法中。

Spring在TransactionDefinition接口中规定了7种类型的事务传播行为,它们规定了事务方法和事务方法发生嵌套调用时如何进行传播,如下表:

事务传播类型 说明
PROPAGATION_REQUIRED 如果当前没有事务,则新建一个事务;如果已存在一个事务,则加入到这个事务中。这是最常见的选择。
PROPAGATION_SUPPORTS 支持当前事务,如果当前没有事务,则以非事务方式执行。
PROPAGATION_MANDATORY 使用当前的事务,如果当前没有事务,则抛出异常。
PROPAGATION_REQUIRES_NEW 新建事务,如果当前存在事务,则把当前事务挂起。
PROPAGATION_NOT_SUPPORTED 以非事务方式执行操作,如果当前存在事务,则把当前事务挂起。
PROPAGATION_NEVER 以非事务方式执行操作,如果当前存在事务,则抛出异常。
PROPAGATION_NESTED 如果当前存在事务,则在嵌套事务内执行;如果当前没有事务,则执行与PROPAGATION_REQUIRED类似的操作。

2.18 Spring的事务如何配置,常用注解有哪些?

参考答案

事务的打开、回滚和提交是由事务管理器来完成的,我们使用不同的数据库访问框架,就要使用与之对应的事务管理器。在Spring Boot中,当你添加了数据库访问框架的起步依赖时,它就会进行自动配置,即自动实例化正确的事务管理器。

对于声明式事务,是使用@Transactional进行标注的。这个注解可以标注在类或者方法上。

  • 当它标注在类上时,代表这个类所有公共(public)非静态的方法都将启用事务功能。
  • 当它标注在方法上时,代表这个方法将启用事务功能。

另外,在@Transactional注解上,我们可以使用isolation属性声明事务的隔离级别,使用propagation属性声明事务的传播机制。

2.19 说一说你对声明式事务的理解

参考答案

Spring事务管理的亮点在于声明式事务管理,它允许我们通过声明的方式,在IoC配置中指定事务的边界和事务属性,Spring会自动在指定的事务边界上应用事务属性。相对于编程式事务来说,这种方式十分的方便,只需要在需要做事务管理的方法上,增加@Transactional注解,以声明事务特征即可。

3. Spring MVC

3.1 什么是MVC?

参考答案

MVC是一种设计模式,在这种模式下软件被分为三层,即Model(模型)、View(视图)、Controller(控制器)。Model代表的是数据,View代表的是用户界面,Controller代表的是数据的处理逻辑,它是Model和View这两层的桥梁。将软件分层的好处是,可以将对象之间的耦合度降低,便于代码的维护。

3.2 DAO层是做什么的?

参考答案

DAO是Data Access Object的缩写,即数据访问对象,在项目中它通常作为独立的一层,专门用于访问数据库。这一层的具体实现技术有很多,常用的有Spring JDBC、Hibernate、JPA、MyBatis等,在Spring框架下无论采用哪一种技术访问数据库,它的编程模式都是统一的。

3.3 介绍一下Spring MVC的执行流程

参考答案

  1. 整个过程开始于客户端发出的一个HTTP请求,Web应用服务器接收到这个请求。如果匹配DispatcherServlet的请求映射路径,则Web容器将该请求转交给DispatcherServlet处理。
  2. DispatcherServlet接收到这个请求后,将根据请求的信息(包括URL、HTTP方法、请求报文头、请求参数、Cookie等)及HandlerMapping的配置找到处理请求的处理器(Handler)。可将HandlerMapping看做路由控制器,将Handler看做目标主机。值得注意的是,在Spring MVC中并没有定义一个Handler接口,实际上任何一个Object都可以成为请求处理器。
  3. 当DispatcherServlet根据HandlerMapping得到对应当前请求的Handler后,通过HandlerAdapter对Handler进行封装,再以统一的适配器接口调用Handler。HandlerAdapter是Spring MVC框架级接口,顾名思义,HandlerAdapter是一个适配器,它用统一的接口对各种Handler方法进行调用。
  4. 处理器完成业务逻 辑的处理后,将返回一个ModelAndView给DispatcherServlet,ModelAndView包含了视图逻辑名和模型数据信息。
  5. ModelAndView中包含的是“逻辑视图名”而非真正的视图对象,DispatcherServlet借由ViewResolver完成逻辑视图名到真实视图对象的解析工作。
  6. 当得到真实的视图对象View后,DispatcherServlet就使用这个View对象对ModelAndView中的模型数据进行视图渲染。
  7. 最终客户端得到的响应消息可能是一个普通的HTML页面,也可能是一个XML或JSON串,甚至是一张图片或一个PDF文档等不同的媒体形式。

3.4 说一说你知道的Spring MVC注解

参考答案

@RequestMapping:

作用:该注解的作用就是用来处理请求地址映射的,也就是说将其中的处理器方法映射到url路径上。

属性:

  • method:是让你指定请求的method的类型,比如常用的有get和post。
  • value:是指请求的实际地址,如果是多个地址就用{}来指定就可以啦。
  • produces:指定返回的内容类型,当request请求头中的Accept类型中包含指定的类型才可以返回的。
  • consumes:指定处理请求的提交内容类型,比如一些json、html、text等的类型。
  • headers:指定request中必须包含那些的headed值时,它才会用该方法处理请求的。
  • params:指定request中一定要有的参数值,它才会使用该方法处理请求。

@RequestParam:

作用:是将请求参数绑定到你的控制器的方法参数上,是Spring MVC中的接收普通参数的注解。

属性:

  • value是请求参数中的名称。
  • required是请求参数是否必须提供参数,它的默认是true,意思是表示必须提供。

@RequestBody:

作用:如果作用在方法上,就表示该方法的返回结果是直接按写入的Http responsebody中(一般在异步获取数据时使用的注解)。

属性:required,是否必须有请求体。它的默认值是true,在使用该注解时,值得注意的当为true时get的请求方式是报错的,如果你取值为false的话,get的请求是null。

@PathVaribale:

作用:该注解是用于绑定url中的占位符,但是注意,spring3.0以后,url才开始支持占位符的,它是Spring MVC支持的rest风格url的一个重要的标志。

3.5 介绍一下Spring MVC的拦截器

参考答案

拦截器会对处理器进行拦截,这样通过拦截器就可以增强处理器的功能。Spring MVC中,所有的拦截器都需要实现HandlerInterceptor接口,该接口包含如下三个方法:preHandle()、postHandle()、afterCompletion()。

这些方法的执行流程如下图:

通过上图可以看出,Spring MVC拦截器的执行流程如下:

  • 执行preHandle方法,它会返回一个布尔值。如果为false,则结束所有流程,如果为true,则执行下一步。
  • 执行处理器逻辑,它包含控制器的功能。
  • 执行postHandle方法。
  • 执行视图解析和视图渲染。
  • 执行afterCompletion方法。

Spring MVC拦截器的开发步骤如下:

  1. 开发拦截器:

    实现handlerInterceptor接口,从三个方法中选择合适的方法,实现拦截时要执行的具体业务逻辑。

  2. 注册拦截器:

    定义配置类,并让它实现WebMvcConfigurer接口,在接口的addInterceptors方法中,注册拦截器,并定义该拦截器匹配哪些请求路径。

3.6 怎么去做请求拦截?

参考答案

如果是对Controller记性拦截,则可以使用Spring MVC的拦截器。

如果是对所有的请求(如访问静态资源的请求)进行拦截,则可以使用Filter。

如果是对除了Controller之外的其他Bean的请求进行拦截,则可以使用Spring AOP。

4. MyBatis

4.1 谈谈MyBatis和JPA的区别

参考答案

ORM映射不同:

MyBatis是半自动的ORM框架,提供数据库与结果集的映射;

JPA(默认采用Hibernate实现)是全自动的ORM框架,提供对象与数据库的映射。

可移植性不同:

JPA通过它强大的映射结构和HQL语言,大大降低了对象与数据库的耦合性;

MyBatis由于需要写SQL,因此与数据库的耦合性直接取决于SQL的写法,如果SQL不具备通用性而用了很多数据库的特性SQL的话,移植性就会降低很多,移植时成本很高。

日志系统的完整性不同:

JPA日志系统非常健全、涉及广泛,包括:SQL记录、关系异常、优化警告、缓存提示、脏数据警告等;

MyBatis除了基本的记录功能外,日志功能薄弱很多。

SQL优化上的区别:

由于Mybatis的SQL都是写在XML里,因此优化SQL比Hibernate方便很多。

而Hibernate的SQL很多都是自动生成的,无法直接维护SQL。虽有HQL,但功能还是不及SQL强大,见到报表等复杂需求时HQL就无能为力,也就是说HQL是有局限的Hhibernate虽然也支持原生SQL,但开发模式上却与ORM不同,需要转换思维,因此使用上不是非常方便。总之写SQL的灵活度上Hibernate不及Mybatis。

4.2 MyBatis输入输出支持的类型有哪些?

参考答案

parameterType:

MyBatis支持多种输入输出类型,包括:

  1. 简单的类型,如整数、小数、字符串等;
  2. 集合类型,如Map等;
  3. 自定义的JavaBean。

其中,简单的类型,其数值直接映射到参数上。对于Map或JavaBean则将其属性按照名称映射到参数上。

4.3 MyBatis里如何实现一对多关联查询?

参考答案

一对多映射有两种配置方式,都是使用collection标签实现的。在此之前,为了能够存储一对多的数据,需要在主表对应的实体类中增加集合属性,用于封装子表对应的实体类。

嵌套查询:

  1. 通过select标签定义查询主表的SQL,返回结果通过reusltMap进行映射。
  2. 在resultMap中,除了映射主表属性,还要通过collection标签映射子表属性,该标签需包含如下内容:
    • 通过property属性指定子表属性名;
    • 通过javaType属性指定封装子表属性的集合类型;
    • 通过ofType属性指定子表的实体类型;
    • 通过select属性指定查询子表所依赖的SQL,这个SQL需单独定义,内部包含查询子表的语句。

嵌套结果:

  1. 通过select标签定义关联查询主表和子表的SQL,返回结果通过resultMap进行映射。
  2. 在resultMap中,除了映射主表属性,还要通过collection标签映射子表属性,该标签需包含如下内容:
    • 通过property属性指定子表属性名;
    • 通过ofType属性指定子表的实体类型;
    • 通过result子标签定义子表字段和属性的映射关系。

4.4 MyBatis中的$和#有什么区别?

参考答案

使用#设置参数时,MyBatis会创建预编译的SQL语句,然后在执行SQL时MyBatis会为预编译SQL中的占位符(?)赋值。预编译的SQL语句执行效率高,并且可以防止注入攻击。

使用$设置参数时,MyBatis只是创建普通的SQL语句,然后在执行SQL语句时MyBatis将参数直接拼入到SQL里。这种方式在效率、安全性上均不如前者,但是可以解决一些特殊情况下的问题。例如,在一些动态表格(根据不同的条件产生不同的动态列)中,我们要传递SQL的列名,根据某些列进行排序,或者传递列名给SQL都是比较常见的场景,这就无法使用预编译的方式了。

4.5 既然不安全,为什么还需要不安全,为什么还需要不安全,为什么还需要,什么时候会用到它?

参考答案

它可以解决一些特殊情况下的问题。例如,在一些动态表格(根据不同的条件产生不同的动态列)中,我们要传递SQL的列名,根据某些列进行排序,或者传递列名给SQL都是比较常见的场景,这就无法使用预编译的方式了。

4.6 MyBatis的xml文件和Mapper接口是怎么绑定的?

参考答案

是通过xml文件中, 根标签的namespace属性进行绑定的,即namespace属性的值需要配置成接口的全限定名称,MyBatis内部就会通过这个值将这个接口与这个xml关联起来。

4.7 MyBatis分页和自己写的分页哪个效率高?

参考答案

自己写的分页效率高。

在MyBatis中,我们可以通过分页插件实现分页,也可以通过分页SQL自己实现分页。其中,分页插件的原理是,拦截查询SQL,在这个SQL基础上自动为其添加limit分页条件。它会大大的提高开发的效率,但是无法对分页语句做出有针对性的优化,比如分页偏移量很大的情况,而这些在自己写的分页SQL里却是可以灵活实现的。

4.8 了解MyBatis缓存机制吗?

参考答案

MyBatis的缓存分为一级缓存和二级缓存。

一级缓存:

一级缓存也叫本地缓存,它默认会启用,并且不能关闭。一级缓存存在于SqlSession的生命周期中,即它是SqlSession级别的缓存。在同一个 SqlSession 中查询时,MyBatis 会把执行的方法和参数通过算法生成缓存的键值,将键值和查询结果存入一个Map对象中。如果同一个SqlSession 中执行的方法和参数完全一致,那么通过算法会生成相同的键值,当Map 缓存对象中己经存在该键值时,则会返回缓存中的对象。

二级缓存:

二级缓存存在于SqlSessionFactory 的生命周期中,即它是SqlSessionFactory级别的缓存。若想使用二级缓存,需要在如下两处进行配置。

在MyBatis 的全局配置settings 中有一个参数cacheEnabled,这个参数是二级缓存的全局开关,默认值是true ,初始状态为启用状态。

MyBatis 的二级缓存是和命名空间绑定的,即二级缓存需要配置在Mapper.xml 映射文件中。在保证二级缓存的全局配置开启的情况下,给Mapper.xml 开启二级缓存只需要在Mapper. xml 中添加如下代码:

<cache />

二级缓存具有如下效果:

  • 映射语句文件中的所有SELECT 语句将会被缓存。
  • 映射语句文件中的所有时INSERT 、UPDATE 、DELETE 语句会刷新缓存。
  • 缓存会使用Least Rece ntly U sed ( LRU ,最近最少使用的)算法来收回。
  • 根据时间表(如no Flush Int erv al ,没有刷新间隔),缓存不会以任何时间顺序来刷新。
  • 缓存会存储集合或对象(无论查询方法返回什么类型的值)的1024 个引用。
  • 缓存会被视为read/write(可读/可写)的,意味着对象检索不是共享的,而且可以安全地被调用者修改,而不干扰其他调用者或线程所做的潜在修改。

5. 其他

5.1 cookie和session的区别是什么?

参考答案

  1. 存储位置不同:cookie存放于客户端;session存放于服务端。
  2. 存储容量不同:单个cookie保存的数据<=4KB,一个站点最多保存20个cookie;而session并没有上限。
  3. 存储方式不同:cookie只能保存ASCII字符串,并需要通过编码当时存储为Unicode字符或者二进制数据;session中能够存储任何类型的数据,例如字符串、整数、集合等。
  4. 隐私策略不同:cookie对客户端是可见的,别有用心的人可以分析存放在本地的cookie并进行cookie欺骗,所以它是不安全的;session存储在服务器上,对客户端是透明的,不存在敏感信息泄露的风险。
  5. 生命周期不同:可以通过设置cookie的属性,达到cookie长期有效的效果;session依赖于名为JSESSIONID的cookie,而该cookie的默认过期时间为-1,只需关闭窗口该session就会失效,因此session不能长期有效。
  6. 服务器压力不同:cookie保存在客户端,不占用服务器资源;session保管在服务器上,每个用户都会产生一个session,如果并发量大的话,则会消耗大量的服务器内存。
  7. 浏览器支持不同:cookie是需要浏览器支持的,如果客户端禁用了cookie,则会话跟踪就会失效;运用session就需要使用URL重写的方式,所有用到session的URL都要进行重写,否则session会话跟踪也会失效。
  8. 跨域支持不同:cookie支持跨域访问,session不支持跨域访问。

5.2 cookie和session各自适合的场景是什么?

参考答案

对于敏感数据,应存放在session里,因为cookie不安全。

对于普通数据,优先考虑存放在cookie里,这样会减少对服务器资源的占用。

5.3 请介绍session的工作原理

参考答案

session依赖于cookie。

当客户端首次访问服务器时,服务器会为其创建一个session对象,该对象具有一个唯一标识SESSIONID。并且在响应阶段,服务器会创建一个cookie,并将SESSIONID存入其中。

客户端通过响应的cookie而持有SESSIONID,所以当它再次访问服务器时,会通过cookie携带这个SESSIONID。服务器获取到SESSIONID后,就可以找到与之对应的session对象,进而从这个session中获取该客户端的状态。

5.4 get请求与post请求有什么区别?

参考答案

  • GET在浏览器回退时是无害的,而POST会再次提交请求。
  • GET产生的URL地址可以被Bookmark,而POST不可以。
  • GET请求会被浏览器主动cache,而POST不会,除非手动设置。
  • GET请求只能进行url编码,而POST支持多种编码方式。
  • GET请求参数会被完整保留在浏览器历史记录里,而POST中的参数不会被保留。
  • GET请求在URL中传送的参数是有长度限制的,而POST没有。
  • 对参数的数据类型,GET只接受ASCII字符,而POST没有限制。
  • GET比POST更不安全,因为参数直接暴露在URL上,所以不能用来传递敏感信息。
  • GET参数通过URL传递,POST放在Request body中。

5.5 get请求的参数能放到body里面吗?

参考答案

GET请求是可以将参数放到BODY里面的,官方并没有明确禁止,但给出的建议是这样不符合规范,无法保证所有的实现都支持。这就意味着,如果你试图这样做,可能出现各种未知的问题,所以应该当避免。

5.6 post不幂等是为什么?

参考答案

HTTP方法的幂等性是指一次和多次请求某一个资源应该具有同样的副作用。幂等性属于语义范畴,正如编译器只能帮助检查语法错误一样,HTTP规范也没有办法通过消息格式等语法手段来定义它。

POST所对应的URI并非创建的资源本身,而是资源的接收者。比如:POST http://www.forum.com/articles的语义是在http://www.forum.com/articles下创建一篇帖子,HTTP响应中应包含帖子的创建状态以及帖子的URI。两次相同的POST请求会在服务器端创建两份资源,它们具有不同的URI。所以,POST方法不具备幂等性。

5.7 页面报400错误是什么意思?

参考答案

400状态码标识请求的语义有误,当前请求无法被服务器理解。除非进行修改,否则客户端不应该重复提交这个请求。通常情况下,是本次请求中包含有错误的参数,此时应该排查前端传递的参数。

5.8 请求数据出现乱码该怎么处理?

参考答案

服务端出现请求乱码的原因是,客户端编码与服务器解码方案不一致,可以有如下几种解决办法:

  1. 将获得的数据按照客户端编码转成BYTE,再将BYTE按服务端编码转成字符串,这种方案对各种请求方式均有效,但是十分的麻烦。
  2. 在接受请求数据之前,显示声明实体内容的编码与服务器一致,这种方式只对POST请求有效。
  3. 修改服务器的配置文件,显示声明请求路径的编码与服务器一致,这种方式只对GET请求有效。

5.9 如何在SpringBoot框架下实现一个定时任务?

参考答案

Spring给我们提供了可执行定时任务的线程池ThreadPoolTaskScheduler,该线程池提供了多个可以与执行定时任务的方法,如下图。在Spring Boot中,只需要在配置类中启用线程池注解,就可以直接使用这个线程池了。

5.10 调用接口时要记录日志,该怎么设计?

参考答案

可以定义一个记录日志的组件,并通过AOP将其织入到这个接口的调用中。这种方式对接口无需做任何改造,业务代码中也无需增加任何调用的逻辑,完美地消除了记录日志和业务代码的耦合度。

5.11 了解Spring Boot JPA吗?

参考答案

JPA即Java Persistence API,它是一个基于O/R映射的标准规范。也就是说它指定以了标准规则,不提供实现,软件提供商可以按照标准规范来实现,而使用者只需按照规范中定义的方式来使用,不用和软件提供商打交道。JPA主要实现有Hibernate、EclipseLink、OpenJPA等,我们使用JPA来开发,无论是采用哪一种实现方式都一样。

中间件

1. Redis

1.1 Redis可以用来做什么?

参考答案

  1. Redis最常用来做缓存,是实现分布式缓存的首先中间件;
  2. Redis可以作为数据库,实现诸如点赞、关注、排行等对性能要求极高的互联网需求;
  3. Redis可以作为计算工具,能用很小的代价,统计诸如PV/UV、用户在线天数等数据;
  4. Redis还有很多其他的使用场景,例如:可以实现分布式锁,可以作为消息队列使用。

1.2 Redis和传统的关系型数据库有什么不同?

参考答案

Redis是一种基于键值对的NoSQL数据库,而键值对的值是由多种数据结构和算法组成的。Redis的数据都存储于内存中,因此它的速度惊人,读写性能可达10万/秒,远超关系型数据库。

关系型数据库是基于二维数据表来存储数据的,它的数据格式更为严谨,并支持关系查询。关系型数据库的数据存储于磁盘上,可以存放海量的数据,但性能远不如Redis。

1.3 Redis有哪些数据类型?

参考答案

  1. Redis支持5种核心的数据类型,分别是字符串、哈希、列表、集合、有序集合;
  2. Redis还提供了Bitmap、HyperLogLog、Geo类型,但这些类型都是基于上述核心数据类型实现的;
  3. Redis在5.0新增加了Streams数据类型,它是一个功能强大的、支持多播的、可持久化的消息队列。

1.4 Redis是单线程的,为什么还能这么快?

参考答案

  1. 对服务端程序来说,线程切换和锁通常是性能杀手,而单线程避免了线程切换和竞争所产生的消耗;
  2. Redis的大部分操作是在内存上完成的,这是它实现高性能的一个重要原因;
  3. Redis采用了IO多路复用机制,使其在网络IO操作中能并发处理大量的客户端请求,实现高吞吐率。

关于Redis的单线程架构实现,如下图:

1.5 Redis在持久化时fork出一个子进程,这时已经有两个进程了,怎么能说是单线程呢?

参考答案

Redis是单线程的,主要是指Redis的网络IO和键值对读写是由一个线程来完成的。而Redis的其他功能,如持久化、异步删除、集群数据同步等,则是依赖其他线程来执行的。所以,说Redis是单线程的只是一种习惯的说法,事实上它的底层不是单线程的。

1.6 set和zset有什么区别?

参考答案

set:

  • 集合中的元素是无序、不可重复的,一个集合最多能存储232-1个元素;
  • 集合除了支持对元素的增删改查之外,还支持对多个集合取交集、并集、差集。

zset:

  • 有序集合保留了集合元素不能重复的特点;
  • 有序集合会给每个元素设置一个分数,并以此作为排序的依据;
  • 有序集合不能包含相同的元素,但是不同元素的分数可以相同。

1.7 说一下Redis中的watch命令

参考答案

很多时候,要确保事务中的数据没有被其他客户端修改才执行该事务。Redis提供了watch命令来解决这类问题,这是一种乐观锁的机制。客户端通过watch命令,要求服务器对一个或多个key进行监视,如果在客户端执行事务之前,这些key发生了变化,则服务器将拒绝执行客户端提交的事务,并向它返回一个空值。

1.8 说说Redis中List结构的相关操作

参考答案

列表是线性有序的数据结构,它内部的元素是可以重复的,并且一个列表最多能存储2^32-1个元素。列表包含如下的常用命令:

  • lpush/rpush:从列表的左侧/右侧添加数据;
  • lrange:指定索引范围,并返回这个范围内的数据;
  • lindex:返回指定索引处的数据;
  • lpop/rpop:从列表的左侧/右侧弹出一个数据;
  • blpop/brpop:从列表的左侧/右侧弹出一个数据,若列表为空则进入阻塞状态。

1.9 你要如何设计Redis的过期时间?

参考答案

  1. 热点数据不设置过期时间,使其达到“物理”上的永不过期,可以避免缓存击穿问题;
  2. 在设置过期时间时,可以附加一个随机数,避免大量的key同时过期,导致缓存雪崩。

1.10 Redis中,sexnx命令的返回值是什么,如何使用该命令实现分布式锁?

参考答案

setnx命令返回整数值,当返回1时表示设置值成果,当返回0时表示设置值失败(key已存在)。

一般我们不建议直接使用setnx命令来实现分布式锁,因为为了避免出现死锁,我们要给锁设置一个自动过期时间。而setnx命令和设置过期时间的命令不是原子的,可能加锁成果而设置过期时间失败,依然存在死锁的隐患。对于这种情况,Redis改进了set命令,给它增加了nx选项,启用该选项时set命令的效果就会setnx一样了。

采用Redis实现分布式锁,就是在Redis里存一份代表锁的数据,通常用字符串即可。采用改进后的setnx命令(即set…nx…命令)实现分布式锁的思路,以及优化的过程如下:

加锁:

第一版,这种方式的缺点是容易产生死锁,因为客户端有可能忘记解锁,或者解锁失败。

setnx key value

第二版,给锁增加了过期时间,避免出现死锁。但这两个命令不是原子的,第二步可能会失败,依然无法避免死锁问题。

setnx key value expire key seconds

第三版,通过“set…nx…”命令,将加锁、过期命令编排到一起,它们是原子操作了,可以避免死锁。

set key value nx ex seconds

解锁:

解锁就是删除代表锁的那份数据。

del key

问题:

看起来已经很完美了,但实际上还有隐患,如下图。进程A在任务没有执行完毕时,锁已经到期被释放了。等进程A的任务执行结束后,它依然会尝试释放锁,因为它的代码逻辑就是任务结束后释放锁。但是,它的锁早已自动释放过了,它此时释放的可能是其他线程的锁。

想要解决这个问题,我们需要解决两件事情:

  1. 在加锁时就要给锁设置一个标识,进程要记住这个标识。当进程解锁的时候,要进行判断,是自己持有的锁才能释放,否则不能释放。可以为key赋一个随机值,来充当进程的标识。
  2. 解锁时要先判断、再释放,这两步需要保证原子性,否则第二步失败的话,就会出现死锁。而获取和删除命令不是原子的,这就需要采用Lua脚本,通过Lua脚本将两个命令编排在一起,而整个Lua脚本的执行是原子的。

按照以上思路,优化后的命令如下:

# 加锁 set key random-value nx ex seconds   # 解锁 if redis.call("get",KEYS[1]) == ARGV[1] then     return redis.call("del",KEYS[1]) else     return 0 end

1.11 说一说Redis的持久化策略

参考答案

Redis支持RDB持久化、AOF持久化、RDB-AOF混合持久化这三种持久化方式。

RDB:

RDB(Redis Database)是Redis默认采用的持久化方式,它以快照的形式将进程数据持久化到硬盘中。RDB会创建一个经过压缩的二进制文件,文件以“.rdb”结尾,内部存储了各个数据库的键值对数据等信息。RDB持久化的触发方式有两种:

  • 手动触发:通过SAVE或BGSAVE命令触发RDB持久化操作,创建“.rdb”文件;
  • 自动触发:通过配置选项,让服务器在满足指定条件时自动执行BGSAVE命令。

其中,SAVE命令执行期间,Redis服务器将阻塞,直到“.rdb”文件创建完毕为止。而BGSAVE命令是异步版本的SAVE命令,它会使用Redis服务器进程的子进程,创建“.rdb”文件。BGSAVE命令在创建子进程时会存在短暂的阻塞,之后服务器便可以继续处理其他客户端的请求。总之,BGSAVE命令是针对SAVE阻塞问题做的优化,Redis内部所有涉及RDB的操作都采用BGSAVE的方式,而SAVE命令已经废弃!

BGSAVE命令的执行流程,如下图:

BGSAVE命令的原理,如下图:

RDB持久化的优缺点如下:

  • 优点:RDB生成紧凑压缩的二进制文件,体积小,使用该文件恢复数据的速度非常快;

  • 缺点:BGSAVE每次运行都要执行fork操作创建子进程,属于重量级操作,不宜频繁执行,

    所以RDB持久化没办法做到实时的持久化。

AOF:

AOF(Append Only File),解决了数据持久化的实时性,是目前Redis持久化的主流方式。AOF以独立日志的方式,记录了每次写入命令,重启时再重新执行AOF文件中的命令来恢复数据。AOF的工作流程包括:命令写入(append)、文件同步(sync)、文件重写(rewrite)、重启加载(load),如下图:

AOF默认不开启,需要修改配置项来启用它:

appendonly yes         # 启用AOF appendfilename "appendonly.aof"  # 设置文件名

AOF以文本协议格式写入命令,如:

*3\r\n$3\r\nset\r\n$5\r\nhello\r\n$5\r\nworld\r\n

文本协议格式具有如下的优点:

  1. 文本协议具有很好的兼容性;
  2. 直接采用文本协议格式,可以避免二次处理的开销;
  3. 文本协议具有可读性,方便直接修改和处理。

AOF持久化的文件同步机制:

为了提高程序的写入性能,现代操作系统会把针对硬盘的多次写操作优化为一次写操作。

  1. 当程序调用write对文件写入时,系统不会直接把书记写入硬盘,而是先将数据写入内存的缓冲区中;
  2. 当达到特定的时间周期或缓冲区写满时,系统才会执行flush操作,将缓冲区中的数据冲洗至硬盘中;

这种优化机制虽然提高了性能,但也给程序的写入操作带来了不确定性。

  1. 对于AOF这样的持久化功能来说,冲洗机制将直接影响AOF持久化的安全性;
  2. 为了消除上述机制的不确定性,Redis向用户提供了appendfsync选项,来控制系统冲洗AOF的频率;
  3. Linux的glibc提供了fsync函数,可以将指定文件强制从缓冲区刷到硬盘,上述选项正是基于此函数。

appendfsync选项的取值和含义如下:

AOF持久化的优缺点如下:

  • 优点:与RDB持久化可能丢失大量的数据相比,AOF持久化的安全性要高很多。通过使用everysec选项,用户可以将数据丢失的时间窗口限制在1秒之内。
  • 缺点:AOF文件存储的是协议文本,它的体积要比二进制格式的”.rdb”文件大很多。AOF需要通过执行AOF文件中的命令来恢复数据库,其恢复速度比RDB慢很多。AOF在进行重写时也需要创建子进程,在数据库体积较大时将占用大量资源,会导致服务器的短暂阻塞。

RDB-AOF混合持久化:

Redis从4.0开始引入RDB-AOF混合持久化模式,这种模式是基于AOF持久化构建而来的。用户可以通过配置文件中的“aof-use-rdb-preamble yes”配置项开启AOF混合持久化。Redis服务器在执行AOF重写操作时,会按照如下原则处理数据:

  • 像执行BGSAVE命令一样,根据数据库当前的状态生成相应的RDB数据,并将其写入AOF文件中;
  • 对于重写之后执行的Redis命令,则以协议文本的方式追加到AOF文件的末尾,即RDB数据之后。

通过使用RDB-AOF混合持久化,用户可以同时获得RDB持久化和AOF持久化的优点,服务器既可以通过AOF文件包含的RDB数据来实现快速的数据恢复操作,又可以通过AOF文件包含的AOF数据来将丢失数据的时间窗口限制在1s之内。

1.12 如何实现Redis的高可用?

参考答案

实现Redis的高可用,主要有哨兵和集群两种方式。

哨兵:

Redis Sentinel(哨兵)是一个分布式架构,它包含若干个哨兵节点和数据节点。每个哨兵节点会对数据节点和其余的哨兵节点进行监控,当发现节点不可达时,会对节点做下线标识。如果被标识的是主节点,它就会与其他的哨兵节点进行协商,当多数哨兵节点都认为主节点不可达时,它们便会选举出一个哨兵节点来完成自动故障转移的工作,同时还会将这个变化实时地通知给应用方。整个过程是自动的,不需要人工介入,有效地解决了Redis的高可用问题!

一组哨兵可以监控一个主节点,也可以同时监控多个主节点,两种情况的拓扑结构如下图:

哨兵节点包含如下的特征:

  1. 哨兵节点会定期监控数据节点,其他哨兵节点是否可达;
  2. 哨兵节点会将故障转移的结果通知给应用方;
  3. 哨兵节点可以将从节点晋升为主节点,并维护后续正确的主从关系;
  4. 哨兵模式下,客户端连接的是哨兵节点集合,从中获取主节点信息;
  5. 节点的故障判断是由多个哨兵节点共同完成的,可有效地防止误判;
  6. 哨兵节点集合是由多个哨兵节点组成的,即使个别哨兵节点不可用,整个集合依然是健壮的;
  7. 哨兵节点也是独立的Redis节点,是特殊的Redis节点,它们不存储数据,只支持部分命令。

集群:

Redis集群采用虚拟槽分区来实现数据分片,它把所有的键根据哈希函数映射到0-16383整数槽内,计算公式为slot=CRC16(key)&16383,每一个节点负责维护一部分槽以及槽所映射的键值数据。虚拟槽分区具有如下特点:

  1. 解耦数据和节点之间的关系,简化了节点扩容和收缩的难度;
  2. 节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据;
  3. 支持节点、槽、键之间的映射查询,用于数据路由,在线伸缩等场景。

Redis集群中数据的分片逻辑如下图:

1.13 Redis的主从同步是如何实现的?

参考答案

从2.8版本开始,Redis使用psync命令完成主从数据同步,同步过程分为全量复制和部分复制。全量复制一般用于初次复制的场景,部分复制则用于处理因网络中断等原因造成数据丢失的场景。psync命令需要以下参数的支持:

  1. 复制偏移量:主节点处理写命令后,会把命令长度做累加记录,从节点在接收到写命令后,也会做累加记录;从节点会每秒钟上报一次自身的复制偏移量给主节点,而主节点则会保存从节点的复制偏移量。
  2. 积压缓冲区:保存在主节点上的一个固定长度的队列,默认大小为1M,当主节点有连接的从节点时被创建;主节点处理写命令时,不但会把命令发送给从节点,还会写入积压缓冲区;缓冲区是先进先出的队列,可以保存最近已复制的数据,用于部分复制和命令丢失的数据补救。
  3. 主节点运行ID:每个Redis节点启动后,都会动态分配一个40位的十六进制字符串作为运行ID;如果使用IP和端口的方式标识主节点,那么主节点重启变更了数据集(RDB/AOF),从节点再基于复制偏移量复制数据将是不安全的,因此当主节点的运行ID变化后,从节点将做全量复制。

psync命令的执行过程以及返回结果,如下图:

全量复制的过程,如下图:

部分复制的过程,如下图:

1.14 Redis为什么存的快,内存断电数据怎么恢复?

参考答案

Redis存的快是因为它的数据都存放在内存里,并且为了保证数据的安全性,Redis还提供了三种数据的持久化机制,即RDB持久化、AOF持久化、RDB-AOF混合持久化。若服务器断电,那么我们可以利用持久化文件,对数据进行恢复。理论上来说,AOF/RDB-AOF持久化可以将丢失数据的窗口控制在1S之内。

1.15 说一说Redis的缓存淘汰策略

参考答案

当写入数据将导致超出maxmemory限制时,Redis会采用maxmemory-policy所指定的策略进行数据淘汰,该策略一共包含如下8种选项:

策略 描述 版本
noeviction 直接返回错误;
volatile-ttl 从设置了过期时间的键中,选择过期时间最小的键,进行淘汰;
volatile-random 从设置了过期时间的键中,随机选择键,进行淘汰;
volatile-lru 从设置了过期时间的键中,使用LRU算法选择键,进行淘汰;
volatile-lfu 从设置了过期时间的键中,使用LFU算法选择键,进行淘汰; 4.0
allleys-random 从所有的键中,随机选择键,进行淘汰;
allkeys-lru 从所有的键中,使用LRU算法选择键,进行淘汰;
allkeys-lfu 从所有的键中,使用LFU算法选择键,进行淘汰; 4.0

其中,volatile前缀代表从设置了过期时间的键中淘汰数据,allkeys前缀代表从所有的键中淘汰数据。关于后缀,ttl代表选择过期时间最小的键,random代表随机选择键,需要我们额外关注的是lru和lfu后缀,它们分别代表采用lru算法和lfu算法来淘汰数据。

LRU(Least Recently Used)是按照最近最少使用原则来筛选数据,即最不常用的数据会被筛选出来!

  • 标准LRU:把所有的数据组成一个链表,表头和表尾分别表示MRU和LRU端,即最常使用端和最少使用端。刚被访问的数据会被移动到MRU端,而新增的数据也是刚被访问的数据,也会被移动到MRU端。当链表的空间被占满时,它会删除LRU端的数据。
  • 近似LRU:Redis会记录每个数据的最近一次访问的时间戳(LRU)。Redis执行写入操作时,若发现内存超出maxmemory,就会执行一次近似LRU淘汰算法。近似LRU会随机采样N个key,然后淘汰掉最旧的key,若淘汰后内存依然超出限制,则继续采样淘汰。可以通过maxmemory_samples配置项,设置近似LRU每次采样的数据个数,该配置项的默认值为5。

LRU算法的不足之处在于,若一个key很少被访问,只是刚刚偶尔被访问了一次,则它就被认为是热点数据,短时间内不会被淘汰。

LFU算法正式用于解决上述问题,LFU(Least Frequently Used)是Redis4新增的淘汰策略,它根据key的最近访问频率进行淘汰。LFU在LRU的基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。当使用LFU策略淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出内存。如果两个数据的访问次数相同,LFU再比较这两个数据的访问时间,把访问时间更早的数据淘汰出内存。

1.16 请介绍一下Redis的过期策略

参考答案

Redis支持如下两种过期策略:

惰性删除:客户端访问一个key的时候,Redis会先检查它的过期时间,如果发现过期就立刻删除这个key。

定期删除:Redis会将设置了过期时间的key放到一个独立的字典中,并对该字典进行每秒10次的过期扫描,

过期扫描不会遍历字典中所有的key,而是采用了一种简单的贪心策略。该策略的删除逻辑如下:

  1. 从过期字典中随机选择20个key;
  2. 删除这20个key中已过期的key;
  3. 如果已过期key的比例超过25%,则重复步骤1。

1.17 缓存穿透、缓存击穿、缓存雪崩有什么区别,该如何解决?

参考答案

缓存穿透:

问题描述:

客户端查询根本不存在的数据,使得请求直达存储层,导致其负载过大,甚至宕机。出现这种情况的原因,可能是业务层误将缓存和库中的数据删除了,也可能是有人恶意攻击,专门访问库中不存在的数据。

解决方案:

  1. 缓存空对象:存储层未命中后,仍然将空值存入缓存层,客户端再次访问数据时,缓存层会直接返回空值。
  2. 布隆过滤器:将数据存入布隆过滤器,访问缓存之前以过滤器拦截,若请求的数据不存在则直接返回空值。

缓存击穿:

问题描述:

一份热点数据,它的访问量非常大。在其缓存失效的瞬间,大量请求直达存储层,导致服务崩溃。

解决方案:

  1. 永不过期:热点数据不设置过期时间,所以不会出现上述问题,这是“物理”上的永不过期。或者为每个数据设置逻辑过期时间,当发现该数据逻辑过期时,使用单独的线程重建缓存。
  2. 加互斥锁:对数据的访问加互斥锁,当一个线程访问该数据时,其他线程只能等待。这个线程访问过后,缓存中的数据将被重建,届时其他线程就可以直接从缓存中取值。

缓存雪崩:

问题描述:

在某一时刻,缓存层无法继续提供服务,导致所有的请求直达存储层,造成数据库宕机。可能是缓存中有大量数据同时过期,也可能是Redis节点发生故障,导致大量请求无法得到处理。

解决方案:

  1. 避免数据同时过期:设置过期时间时,附加一个随机数,避免大量的key同时过期。
  2. 启用降级和熔断措施:在发生雪崩时,若应用访问的不是核心数据,则直接返回预定义信息/空值/错误信息。或者在发生雪崩时,对于访问缓存接口的请求,客户端并不会把请求发给Redis,而是直接返回。
  3. 构建高可用的Redis服务:采用哨兵或集群模式,部署多个Redis实例,个别节点宕机,依然可以保持服务的整体可用。

1.18 如何保证缓存与数据库的双写一致性?

参考答案

四种同步策略:

想要保证缓存与数据库的双写一致,一共有4种方式,即4种同步策略:

  1. 先更新缓存,再更新数据库;
  2. 先更新数据库,再更新缓存;
  3. 先删除缓存,再更新数据库;
  4. 先更新数据库,再删除缓存。

从这4种同步策略中,我们需要作出比较的是:

  1. 更新缓存与删除缓存哪种方式更合适?
  2. 应该先操作数据库还是先操作缓存?

更新缓存还是删除缓存:

下面,我们来分析一下,应该采用更新缓存还是删除缓存的方式。

  • 更新缓存

    优点:每次数据变化都及时更新缓存,所以查询时不容易出现未命中的情况。

    缺点:更新缓存的消耗比较大。如果数据需要经过复杂的计算再写入缓存,那么频繁的更新缓存,就会影响服务器的性能。如果是写入数据频繁的业务场景,那么可能频繁的更新缓存时,却没有业务读取该数据。

  • 删除缓存

    优点:操作简单,无论更新操作是否复杂,都是将缓存中的数据直接删除。

    缺点:删除缓存后,下一次查询缓存会出现未命中,这时需要重新读取一次数据库。

从上面的比较来看,一般情况下,删除缓存是更优的方案。

先操作数据库还是缓存:

下面,我们再来分析一下,应该先操作数据库还是先操作缓存。

首先,我们将先删除缓存与先更新数据库,在出现失败时进行一个对比:

如上图,是先删除缓存再更新数据库,在出现失败时可能出现的问题:

  1. 进程A删除缓存成功;
  2. 进程A更新数据库失败;
  3. 进程B从缓存中读取数据;
  4. 由于缓存被删,进程B无法从缓存中得到数据,进而从数据库读取数据;
  5. 进程B从数据库成功获取数据,然后将数据更新到了缓存。

最终,缓存和数据库的数据是一致的,但仍然是旧的数据。而我们的期望是二者数据一致,并且是新的数据。

如上图,是先更新数据库再删除缓存,在出现失败时可能出现的问题:

  1. 进程A更新数据库成功;
  2. 进程A删除缓存失败;
  3. 进程B读取缓存成功,由于缓存删除失败,所以进程B读取到的是旧的数据。

最终,缓存和数据库的数据是不一致的。

经过上面的比较,我们发现在出现失败的时候,是无法明确分辨出先删缓存和先更新数据库哪个方式更好,以为它们都存在问题。后面我们会进一步对这两种方式进行比较,但是在这里我们先探讨一下,上述场景出现的问题,应该如何解决呢?

实际上,无论上面我们采用哪种方式去同步缓存与数据库,在第二步出现失败的时候,都建议采用重试机制解决,因为最终我们是要解决掉这个错误的。而为了避免重试机制影响主要业务的执行,一般建议重试机制采用异步的方式执行,如下图:

这里我们按照先更新数据库,再删除缓存的方式,来说明重试机制的主要步骤:

  1. 更新数据库成功;
  2. 删除缓存失败;
  3. 将此数据加入消息队列;
  4. 业务代码消费这条消息;
  5. 业务代码根据这条消息的内容,发起重试机制,即从缓存中删除这条记录。

好了,下面我们再将先删缓存与先更新数据库,在没有出现失败时进行对比:

如上图,是先删除缓存再更新数据库,在没有出现失败时可能出现的问题:

  1. 进程A删除缓存成功;
  2. 进程B读取缓存失败;
  3. 进程B读取数据库成功,得到旧的数据;
  4. 进程B将旧的数据成功地更新到了缓存;
  5. 进程A将新的数据成功地更新到数据库。

可见,进程A的两步操作均成功,但由于存在并发,在这两步之间,进程B访问了缓存。最终结果是,缓存中存储了旧的数据,而数据库中存储了新的数据,二者数据不一致。

如上图,是先更新数据库再删除缓存,再没有出现失败时可能出现的问题:

  1. 进程A更新数据库成功;
  2. 进程B读取缓存成功;
  3. 进程A更新数据库成功。

可见,最终缓存与数据库的数据是一致的,并且都是最新的数据。但进程B在这个过程里读到了旧的数据,可能还有其他进程也像进程B一样,在这两步之间读到了缓存中旧的数据,但因为这两步的执行速度会比较快,所以影响不大。对于这两步之后,其他进程再读取缓存数据的时候,就不会出现类似于进程B的问题了。

最终结论:

经过对比你会发现,先更新数据库、再删除缓存是影响更小的方案。如果第二步出现失败的情况,则可以采用重试机制解决问题。

扩展阅读

延时双删

上面我们提到,如果是先删缓存、再更新数据库,在没有出现失败时可能会导致数据的不一致。如果在实际的应用中,出于某些考虑我们需要选择这种方式,那有办法解决这个问题吗?答案是有的,那就是采用延时双删的策略,延时双删的基本思路如下:

  1. 删除缓存;
  2. 更新数据库;
  3. sleep N毫秒;
  4. 再次删除缓存。

阻塞一段时间之后,再次删除缓存,就可以把这个过程中缓存中不一致的数据删除掉。而具体的时间,要评估你这项业务的大致时间,按照这个时间来设定即可。

采用读写分离的架构怎么办?

如果数据库采用的是读写分离的架构,那么又会出现新的问题,如下图:

进程A先删除缓存,再更新主数据库,然后主库将数据同步到从库。而在主从数据库同步之前,可能会有进程B访问了缓存,发现数据不存在,进而它去访问从库获取到旧的数据,然后同步到缓存。这样,最终也会导致缓存与数据库的数据不一致。这个问题的解决方案,依然是采用延时双删的策略,但是在评估延长时间的时候,要考虑到主从数据库同步的时间。

第二次删除失败了怎么办?

如果第二次删除依然失败,则可以增加重试的次数,但是这个次数要有限制,当超出一定的次数时,要采取报错、记日志、发邮件提醒等措施。

1.19 请介绍Redis集群的实现方案

参考答案

Redis集群的分区方案:

Redis集群采用虚拟槽分区来实现数据分片,它把所有的键根据哈希函数映射到0-16383整数槽内,计算公式为slot=CRC16(key)&16383,每一个节点负责维护一部分槽以及槽所映射的键值数据。虚拟槽分区具有如下特点:

  1. 解耦数据和节点之间的关系,简化了节点扩容和收缩的难度;
  2. 节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据;
  3. 支持节点、槽、键之间的映射查询,用于数据路由,在线伸缩等场景。

Redis集群中数据的分片逻辑如下图:

Redis集群的功能限制:

Redis集群方案在扩展了Redis处理能力的同时,也带来了一些使用上的限制:

  1. key批量操作支持有限。如mset、mget,目前只支持具有相同slot值的key执行批量操作。对于映射为不同slot值的key由于执行mset、mget等操作可能存在于多个节点上所以不被支持。
  2. key事务操作支持有限。同理只支持多key在同一节点上的事务操作,当多个key分布在不同的节点上时无法使用事务功能。
  3. key作为数据分区的最小粒度,因此不能将一个大的键值对象(如hash、list等)映射到不同的节点。
  4. 不支持多数据库空间。单机下的Redis可以支持16个数据库,集群模式下只能使用一个数据库空间,即DB0。
  5. 复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构。

Redis集群的通信方案:

在分布式存储中需要提供维护节点元数据信息的机制,所谓元数据是指:节点负责哪些数据,是否出现故障等状态信息。常见的元数据维护方式分为:集中式和P2P方式。

Redis集群采用P2P的Gossip(流言)协议,Gossip协议的工作原理就是节点彼此不断通信交换信息,一段时间后所有的节点都会知道集群完整的信息,这种方式类似流言传播。通信的大致过程如下:

  1. 集群中每个节点都会单独开辟一个TCP通道,用于节点之间彼此通信,通信端口号在基础端口号上加10000;
  2. 每个节点再固定周期内通过特定规则选择几个节点发送ping消息;
  3. 接收ping消息的节点用pong消息作为响应。

其中,Gossip协议的主要职责就是信息交换,而信息交换的载体就是节点彼此发送的Gossip消息,Gossip消息分为:meet消息、ping消息、pong消息、fail消息等。

  • meet消息:用于通知新节点加入,消息发送者通知接受者加入到当前集群。meet消息通信正常完成后,接收节点会加入到集群中并进行周期性的ping、pong消息交换。
  • ping消息:集群内交换最频繁的消息,集群内每个节点每秒向多个其他节点发送ping消息,用于检测节点是否在线和交换彼此状态信息。ping消息封装了自身节点和一部分其他节点的状态数据。
  • pong消息:当接收到meet、ping消息时,作为响应消息回复给发送方确认消息正常通信。pong消息内封装了自身状态数据,节点也可以向集群内广播自身的pong消息来通知整个集群对自身状态进行更新。
  • fail消息:当节点判定集群内另一个节点下线时,会向集群内广播一个fail消息,其他节点接收到fail消息之后把对应节点更新为下线状态。

虽然Gossip协议的信息交换机制具有天然的分布式特性,但它是有成本的。因为Redis集群内部需要频繁地进行节点信息交换,而ping/pong消息会携带当前节点和部分其他节点的状态数据,势必会加重带宽和计算的负担。所以,Redis集群的Gossip协议需要兼顾信息交换的实时性和成本的开销。

  • 集群里的每个节点默认每隔一秒钟就会从已知节点列表中随机选出五个节点,然后对这五个节点中最长时间没有发送过PING消息的节点发送PING消息,以此来检测被选中的节点是否在线。
  • 如果节点A最后一次收到节点B发送的PONG消息的时间,距离当前时间已经超过了节点A的超时选项设置时长的一半(cluster-node-timeout/2),那么节点A也会向节点B发送PING消息,这可以防止节点A因为长时间没有随机选中节点B作为PING消息的发送对象而导致对节点B的信息更新滞后。
  • 每个消息主要的数据占用:slots槽数组(2KB)和整个集群1/10的状态数据(10个节点状态数据约1KB)。

1.20 说一说Redis集群的分片机制

参考答案

Redis集群采用虚拟槽分区来实现数据分片,它把所有的键根据哈希函数映射到0-16383整数槽内,计算公式为slot=CRC16(key)&16383,每一个节点负责维护一部分槽以及槽所映射的键值数据。虚拟槽分区具有如下特点:

  1. 解耦数据和节点之间的关系,简化了节点扩容和收缩的难度;
  2. 节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据;
  3. 支持节点、槽、键之间的映射查询,用于数据路由,在线伸缩等场景。

Redis集群中数据的分片逻辑如下图:

1.21 说一说Redis集群的应用和优劣势

参考答案

优势:

Redis Cluster是Redis的分布式解决方案,在3.0版本正式推出,有效地解决了Redis分布式方面的需求。当遇到单机内存、并发、流量等瓶颈时,可以采用Cluster架构方案达到负载均衡的目的。

劣势:

Redis集群方案在扩展了Redis处理能力的同时,也带来了一些使用上的限制:

  1. key批量操作支持有限。如mset、mget,目前只支持具有相同slot值的key执行批量操作。对于映射为不同slot值的key由于执行mset、mget等操作可能存在于多个节点上所以不被支持。
  2. key事务操作支持有限。同理只支持多key在同一节点上的事务操作,当多个key分布在不同的节点上时无法使用事务功能。
  3. key作为数据分区的最小粒度,因此不能将一个大的键值对象(如hash、list等)映射到不同的节点。
  4. 不支持多数据库空间。单机下的Redis可以支持16个数据库,集群模式下只能使用一个数据库空间,即DB0。
  5. 复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构。

1.22 说一说hash类型底层的数据结构

参考答案

哈希对象有两种编码方案,当同时满足以下条件时,哈希对象采用ziplist编码,否则采用hashtable编码:

  • 哈希对象保存的键值对数量小于512个;
  • 哈希对象保存的所有键值对中的键和值,其字符串长度都小于64字节。

其中,ziplist编码采用压缩列表作为底层实现,而hashtable编码采用字典作为底层实现。

压缩列表:

压缩列表(ziplist),是Redis为了节约内存而设计的一种线性数据结构,它是由一系列具有特殊编码的连续内存块构成的。一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数组或一个整数值。

压缩列表的结构如下图所示:

该结构当中的字段含义如下表所示:

属性 类型 长度 说明
zlbytes uint32_t 4字节 压缩列表占用的内存字节数;
zltail uint32_t 4字节 压缩列表表尾节点距离列表起始地址的偏移量(单位字节);
zllen uint16_t 2字节 压缩列表包含的节点数量,等于UINT16_MAX时,需遍历列表计算真实数量;
entryX 列表节点 不固定 压缩列表包含的节点,节点的长度由节点所保存的内容决定;
zlend uint8_t 1字节 压缩列表的结尾标识,是一个固定值0xFF;

其中,压缩列表的节点由以下字段构成:

previous_entry_length(pel)属性以字节为单位,记录当前节点的前一节点的长度,其自身占据1字节或5字节:

  1. 如果前一节点的长度小于254字节,则“pel”属性的长度为1字节,前一节点的长度就保存在这一个字节内;
  2. 如果前一节点的长度达到254字节,则“pel”属性的长度为5字节,其中第一个字节被设置为0xFE,之后的四个字节用来保存前一节点的长度;

基于“pel”属性,程序便可以通过指针运算,根据当前节点的起始地址计算出前一节点的起始地址,从而实现从表尾向表头的遍历操作。

content属性负责保存节点的值(字节数组或整数),其类型和长度则由encoding属性决定,它们的关系如下:

encoding 长度 content
00 xxxxxx 1字节 最大长度为26 -1的字节数组;
01 xxxxxx bbbbbbbb 2字节 最大长度为214-1的字节数组;
10 __ bbbbbbbb … … … 5字节 最大长度为232-1的字节数组;
11 000000 1字节 int16_t类型的整数;
11 010000 1字节 int32_t类型的整数;
11 100000 1字节 int64_t类型的整数;
11 110000 1字节 24位有符号整数;
11 111110 1字节 8位有符号整数;
11 11xxxx 1字节 没有content属性,xxxx直接存[0,12]范围的整数值;

字典:

字典(dict)又称为散列表,是一种用来存储键值对的数据结构。C语言没有内置这种数据结构,所以Redis构建了自己的字典实现。

Redis字典的实现主要涉及三个结构体:字典、哈希表、哈希表节点。其中,每个哈希表节点保存一个键值对,每个哈希表由多个哈希表节点构成,而字典则是对哈希表的进一步封装。这三个结构体的关系如下图所示:

其中,dict代表字典,dictht代表哈希表,dictEntry代表哈希表节点。可以看出,dictEntry是一个数组,这很好理解,因为一个哈希表里要包含多个哈希表节点。而dict里包含2个dictht,多出的哈希表用于REHASH。当哈希表保存的键值对数量过多或过少时,需要对哈希表的大小进行扩展或收缩操作,在Redis中,扩展和收缩哈希表是通过REHASH实现的,执行REHASH的大致步骤如下:

  1. 为字典的ht[1]哈希表分配内存空间

    如果执行的是扩展操作,则ht[1]的大小为第1个大于等于ht[0].used*2的2n。如果执行的是收缩操作,则ht[1]的大小为第1个大于等于ht[0].used的2n。

  2. 将存储在ht[0]中的数据迁移到ht[1]上

    重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上。

  3. 将字典的ht[1]哈希表晋升为默认哈希表

    迁移完成后,清空ht[0],再交换ht[0]和ht[1]的值,为下一次REHASH做准备。

当满足以下任何一个条件时,程序会自动开始对哈希表执行扩展操作:

  1. 服务器目前没有执行bgsave或bgrewriteof命令,并且哈希表的负载因子大于等于1;
  2. 服务器目前正在执行bgsave或bgrewriteof命令,并且哈希表的负载因子大于等于5。

为了避免REHASH对服务器性能造成影响,REHASH操作不是一次性地完成的,而是分多次、渐进式地完成的。渐进式REHASH的详细过程如下:

  1. 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表;
  2. 在字典中的索引计数器rehashidx设置为0,表示REHASH操作正式开始;
  3. 在REHASH期间,每次对字典执行添加、删除、修改、查找操作时,程序除了执行指定的操作外,还会顺带将ht[0]中位于rehashidx上的所有键值对迁移到ht[1]中,再将rehashidx的值加1;
  4. 随着字典不断被访问,最终在某个时刻,ht[0]上的所有键值对都被迁移到ht[1]上,此时程序将rehashidx属性值设置为-1,标识REHASH操作完成。

REHSH期间,字典同时持有两个哈希表,此时的访问将按照如下原则处理:

  1. 新添加的键值对,一律被保存到ht[1]中;
  2. 删除、修改、查找等其他操作,会在两个哈希表上进行,即程序先尝试去ht[0]中访问要操作的数据,若不存在则到ht[1]中访问,再对访问到的数据做相应的处理。

1.23 介绍一下zset类型底层的数据结构

参考答案

有序集合对象有2种编码方案,当同时满足以下条件时,集合对象采用ziplist编码,否则采用skiplist编码:

  • 有序集合保存的元素数量不超过128个;
  • 有序集合保存的所有元素的成员长度都小于64字节。

其中,ziplist编码的有序集合采用压缩列表作为底层实现,skiplist编码的有序集合采用zset结构作为底层实现。

其中,zset是一个复合结构,它的内部采用字典和跳跃表来实现,其源码如下。其中,dict保存了从成员到分支的映射关系,zsl则按分值由小到大保存了所有的集合元素。这样,当按照成员来访问有序集合时可以直接从dict中取值,当按照分值的范围访问有序集合时可以直接从zsl中取值,采用了空间换时间的策略以提高访问效率。

typedef struct zset {     dict *dict;  // 字典,保存了从成员到分值的映射关系;     zskiplist *zsl; // 跳跃表,按分值由小到大保存所有集合元素; } zset;

综上,zset对象的底层数据结构包括:压缩列表、字典、跳跃表。

压缩列表:

压缩列表(ziplist),是Redis为了节约内存而设计的一种线性数据结构,它是由一系列具有特殊编码的连续内存块构成的。一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数组或一个整数值。

压缩列表的结构如下图所示:

该结构当中的字段含义如下表所示:

属性 类型 长度 说明
zlbytes uint32_t 4字节 压缩列表占用的内存字节数;
zltail uint32_t 4字节 压缩列表表尾节点距离列表起始地址的偏移量(单位字节);
zllen uint16_t 2字节 压缩列表包含的节点数量,等于UINT16_MAX时,需遍历列表计算真实数量;
entryX 列表节点 不固定 压缩列表包含的节点,节点的长度由节点所保存的内容决定;
zlend uint8_t 1字节 压缩列表的结尾标识,是一个固定值0xFF;

其中,压缩列表的节点由以下字段构成:

previous_entry_length(pel)属性以字节为单位,记录当前节点的前一节点的长度,其自身占据1字节或5字节:

  1. 如果前一节点的长度小于254字节,则“pel”属性的长度为1字节,前一节点的长度就保存在这一个字节内;
  2. 如果前一节点的长度达到254字节,则“pel”属性的长度为5字节,其中第一个字节被设置为0xFE,之后的四个字节用来保存前一节点的长度;

基于“pel”属性,程序便可以通过指针运算,根据当前节点的起始地址计算出前一节点的起始地址,从而实现从表尾向表头的遍历操作。

content属性负责保存节点的值(字节数组或整数),其类型和长度则由encoding属性决定,它们的关系如下:

encoding 长度 content
00 xxxxxx 1字节 最大长度为26 -1的字节数组;
01 xxxxxx bbbbbbbb 2字节 最大长度为214-1的字节数组;
10 __ bbbbbbbb … … … 5字节 最大长度为232-1的字节数组;
11 000000 1字节 int16_t类型的整数;
11 010000 1字节 int32_t类型的整数;
11 100000 1字节 int64_t类型的整数;
11 110000 1字节 24位有符号整数;
11 111110 1字节 8位有符号整数;
11 11xxxx 1字节 没有content属性,xxxx直接存[0,12]范围的整数值;

字典:

字典(dict)又称为散列表,是一种用来存储键值对的数据结构。C语言没有内置这种数据结构,所以Redis构建了自己的字典实现。

Redis字典的实现主要涉及三个结构体:字典、哈希表、哈希表节点。其中,每个哈希表节点保存一个键值对,每个哈希表由多个哈希表节点构成,而字典则是对哈希表的进一步封装。这三个结构体的关系如下图所示:

其中,dict代表字典,dictht代表哈希表,dictEntry代表哈希表节点。可以看出,dictEntry是一个数组,这很好理解,因为一个哈希表里要包含多个哈希表节点。而dict里包含2个dictht,多出的哈希表用于REHASH。当哈希表保存的键值对数量过多或过少时,需要对哈希表的大小进行扩展或收缩操作,在Redis中,扩展和收缩哈希表是通过REHASH实现的,执行REHASH的大致步骤如下:

  1. 为字典的ht[1]哈希表分配内存空间

    如果执行的是扩展操作,则ht[1]的大小为第1个大于等于ht[0].used*2的2n。如果执行的是收缩操作,则ht[1]的大小为第1个大于等于ht[0].used的2n。

  2. 将存储在ht[0]中的数据迁移到ht[1]上

    重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上。

  3. 将字典的ht[1]哈希表晋升为默认哈希表

    迁移完成后,清空ht[0],再交换ht[0]和ht[1]的值,为下一次REHASH做准备。

当满足以下任何一个条件时,程序会自动开始对哈希表执行扩展操作:

  1. 服务器目前没有执行bgsave或bgrewriteof命令,并且哈希表的负载因子大于等于1;
  2. 服务器目前正在执行bgsave或bgrewriteof命令,并且哈希表的负载因子大于等于5。

为了避免REHASH对服务器性能造成影响,REHASH操作不是一次性地完成的,而是分多次、渐进式地完成的。渐进式REHASH的详细过程如下:

  1. 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表;
  2. 在字典中的索引计数器rehashidx设置为0,表示REHASH操作正式开始;
  3. 在REHASH期间,每次对字典执行添加、删除、修改、查找操作时,程序除了执行指定的操作外,还会顺带将ht[0]中位于rehashidx上的所有键值对迁移到ht[1]中,再将rehashidx的值加1;
  4. 随着字典不断被访问,最终在某个时刻,ht[0]上的所有键值对都被迁移到ht[1]上,此时程序将rehashidx属性值设置为-1,标识REHASH操作完成。

REHSH期间,字典同时持有两个哈希表,此时的访问将按照如下原则处理:

  1. 新添加的键值对,一律被保存到ht[1]中;
  2. 删除、修改、查找等其他操作,会在两个哈希表上进行,即程序先尝试去ht[0]中访问要操作的数据,若不存在则到ht[1]中访问,再对访问到的数据做相应的处理。

跳跃表:

跳跃表的查找复杂度为平均O(logN),最坏O(N),效率堪比红黑树,却远比红黑树实现简单。跳跃表是在链表的基础上,通过增加索引来提高查找效率的。

有序链表插入、删除的复杂度为O(1),而查找的复杂度为O(N)。例:若要查找值为60的元素,需要从第1个元素依次向后比较,共需比较6次才行,如下图:

跳跃表是从有序链表中选取部分节点,组成一个新链表,并以此作为原始链表的一级索引。再从一级索引中选取部分节点,组成一个新链表,并以此作为原始链表的二级索引。以此类推,可以有多级索引,如下图:

跳跃表在查找时,优先从高层开始查找,若next节点值大于目标值,或next指针指向NULL,则从当前节点下降一层继续向后查找,这样便可以提高查找的效率了。

跳跃表的实现主要涉及2个结构体:zskiplist、zskiplistNode,它们的关系如下图所示:

其中,蓝色的表格代表zskiplist,红色的表格代表zskiplistNode。zskiplist有指向头尾节点的指针,以及列表的长度,列表中最高的层级。zskiplistNode的头节点是空的,它不存储任何真实的数据,它拥有最高的层级,但这个层级不记录在zskiplist之内。

1.24 如何利用Redis实现分布式Session?

参考答案

在web开发中,我们会把用户的登录信息存储在session里。而session是依赖于cookie的,即服务器创建session时会给它分配一个唯一的ID,并且在响应时创建一个cookie用于存储这个SESSIONID。当客户端收到这个cookie之后,就会自动保存这个SESSIONID,并且在下次访问时自动携带这个SESSIONID,届时服务器就可以通过这个SESSIONID得到与之对应的session,从而识别用户的身。如下图:

现在的互联网应用,基本都是采用分布式部署方式,即将应用程序部署在多台服务器上,并通过nginx做统一的请求分发。而服务器与服务器之间是隔离的,它们的session是不共享的,这就存在session同步的问题了,如下图:

如果客户端第一次访问服务器,请求被分发到了服务器A上,则服务器A会为该客户端创建session。如果客户端再次访问服务器,请求被分发到服务器B上,则由于服务器B中没有这个session,所以用户的身份无法得到验证,从而产生了不一致的问题。

解决这个问题的办法有很多,比如可以协调多个服务器,让他们的session保持同步。也可以在分发请求时做绑定处理,即将某一个IP固定分配给同一个服务器。但这些方式都比较麻烦,而且性能上也有一定的消耗。更合理的方式就是采用类似于Redis这样的高性能缓存服务器,来实现分布式session。

从上面的叙述可知,我们使用session保存用户的身份信息,本质上是要做两件事情。第一是保存用户的身份信息,第二是验证用户的身份信息。如果利用其它手段实现这两个目标,那么就可以不用session,或者说我们使用的是广义上的session了。

具体实现的思路如下图,我们在服务端增加两段程序:

第一是创建令牌的程序,就是在用户初次访问服务器时,给它创建一个唯一的身份标识,并且使用cookie封装这个标识再发送给客户端。那么当客户端下次再访问服务器时,就会自动携带这个身份标识了,这和SESSIONID的道理是一样的,只是改由我们自己来实现了。另外,在返回令牌之前,我们需要将它存储起来,以便于后续的验证。而这个令牌是不能保存在服务器本地的,因为其他服务器无法访问它。因此,我们可以将其存储在服务器之外的一个地方,那么Redis便是一个理想的场所。

第二是验证令牌的程序,就是在用户再次访问服务器时,我们获取到了它之前的身份标识,那么我们就要验证一下这个标识是否存在了。验证的过程很简单,我们从Redis中尝试获取一下就可以知道结果。

1.25 如何利用Redis实现一个分布式锁?

参考答案

何时需要分布式锁?

在分布式的环境下,当多个server并发修改同一个资源时,为了避免竞争就需要使用分布式锁。那为什么不能使用Java自带的锁呢?因为Java中的锁是面向多线程设计的,它只局限于当前的JRE环境。而多个server实际上是多进程,是不同的JRE环境,所以Java自带的锁机制在这个场景下是无效的。

如何实现分布式锁?

采用Redis实现分布式锁,就是在Redis里存一份代表锁的数据,通常用字符串即可。实现分布式锁的思路,以及优化的过程如下:

  1. 加锁:

    第一版,这种方式的缺点是容易产生死锁,因为客户端有可能忘记解锁,或者解锁失败。

    setnx key value
    

    第二版,给锁增加了过期时间,避免出现死锁。但这两个命令不是原子的,第二步可能会失败,依然无法避免死锁问题。

    setnx key value expire key seconds
    

    第三版,通过“set…nx…”命令,将加锁、过期命令编排到一起,它们是原子操作了,可以避免死锁。

    set key value nx ex seconds
    
  2. 解锁:

    解锁就是删除代表锁的那份数据。

    del key
    
  3. 问题:

    看起来已经很完美了,但实际上还有隐患,如下图。进程A在任务没有执行完毕时,锁已经到期被释放了。等进程A的任务执行结束后,它依然会尝试释放锁,因为它的代码逻辑就是任务结束后释放锁。但是,它的锁早已自动释放过了,它此时释放的可能是其他线程的锁。

想要解决这个问题,我们需要解决两件事情:

  1. 在加锁时就要给锁设置一个标识,进程要记住这个标识。当进程解锁的时候,要进行判断,是自己持有的锁才能释放,否则不能释放。可以为key赋一个随机值,来充当进程的标识。
  2. 解锁时要先判断、再释放,这两步需要保证原子性,否则第二步失败的话,就会出现死锁。而获取和删除命令不是原子的,这就需要采用Lua脚本,通过Lua脚本将两个命令编排在一起,而整个Lua脚本的执行是原子的。

按照以上思路,优化后的命令如下:

# 加锁 set key random-value nx ex seconds   # 解锁 if redis.call("get",KEYS[1]) == ARGV[1] then     return redis.call("del",KEYS[1]) else     return 0 end

基于RedLock算法的分布式锁:

上述分布式锁的实现方案,是建立在单个主节点之上的。它的潜在问题如下图所示,如果进程A在主节点上加锁成功,然后这个主节点宕机了,则从节点将会晋升为主节点。若此时进程B在新的主节点上加锁成果,之后原主节点重启,成为了从节点,系统中将同时出现两把锁,这是违背锁的唯一性原则的。

总之,就是在单个主节点的架构上实现分布式锁,是无法保证高可用的。若要保证分布式锁的高可用,则可以采用多个节点的实现方案。这种方案有很多,而Redis的官方给出的建议是采用RedLock算法的实现方案。该算法基于多个Redis节点,它的基本逻辑如下:

  • 这些节点相互独立,不存在主从复制或者集群协调机制;
  • 加锁:以相同的KEY向N个实例加锁,只要超过一半节点成功,则认定加锁成功;
  • 解锁:向所有的实例发送DEL命令,进行解锁;

RedLock算法的示意图如下,我们可以自己实现该算法,也可以直接使用Redisson框架。

1.26 说一说你对布隆过滤器的理解

参考答案

布隆过滤器可以用很低的代价,估算出数据是否真实存在。例如:给用户推荐新闻时,要去掉重复的新闻,就可以利用布隆过滤器,判断该新闻是否已经推荐过。

布隆过滤器的核心包括两部分:

  1. 一个大型的位数组;
  2. 若干个不一样的哈希函数,每个哈希函数都能将哈希值算的比较均匀。

布隆过滤器的工作原理:

  1. 添加key时,每个哈希函数都利用这个key计算出一个哈希值,再根据哈希值计算一个位置,并将位数组中这个位置的值设置为1。
  2. 询问key时,每个哈希函数都利用这个key计算出一个哈希值,再根据哈希值计算一个位置。然后对比这些哈希函数在位数组中对应位置的数值:
    • 如果这几个位置中,有一个位置的值是0,就说明这个布隆过滤器中,不存在这个key。
    • 如果这几个位置中,所有位置的值都是1,就说明这个布隆过滤器中,极有可能存在这个key。之所以不是百分之百确定,是因为也可能是其他的key运算导致该位置为1。

1.27 多台Redis抗高并发访问该怎么设计?

参考答案

Redis Cluster是Redis的分布式解决方案,在3.0版本正式推出,有效地解决了Redis分布式方面的需求。当遇到单机内存、并发、流量等瓶颈时,可以采用Cluster架构方案达到负载均衡的目的。

Redis集群采用虚拟槽分区来实现数据分片,它把所有的键根据哈希函数映射到0-16383整数槽内,计算公式为slot=CRC16(key)&16383,每一个节点负责维护一部分槽以及槽所映射的键值数据。虚拟槽分区具有如下特点:

  1. 解耦数据和节点之间的关系,简化了节点扩容和收缩的难度;
  2. 节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据;
  3. 支持节点、槽、键之间的映射查询,用于数据路由,在线伸缩等场景。

Redis集群中数据的分片逻辑如下图:

1.28 如果并发量超过30万,怎么设计Redis架构?

参考答案

Redis Cluster是Redis的分布式解决方案,在3.0版本正式推出,有效地解决了Redis分布式方面的需求。当遇到单机内存、并发、流量等瓶颈时,可以采用Cluster架构方案达到负载均衡的目的。

Redis集群采用虚拟槽分区来实现数据分片,它把所有的键根据哈希函数映射到0-16383整数槽内,计算公式为slot=CRC16(key)&16383,每一个节点负责维护一部分槽以及槽所映射的键值数据。虚拟槽分区具有如下特点:

  1. 解耦数据和节点之间的关系,简化了节点扩容和收缩的难度;
  2. 节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据;
  3. 支持节点、槽、键之间的映射查询,用于数据路由,在线伸缩等场景。

Redis集群中数据的分片逻辑如下图:

2. 消息队列

2.1 MQ有什么用?

参考答案

消息队列有很多使用场景,比较常见的有3个:解耦、异步、削峰。

  1. 解耦:传统的软件开发模式,各个模块之间相互调用,数据共享,每个模块都要时刻关注其他模块的是否更改或者是否挂掉等等,使用消息队列,可以避免模块之间直接调用,将所需共享的数据放在消息队列中,对于新增业务模块,只要对该类消息感兴趣,即可订阅该类消息,对原有系统和业务没有任何影响,降低了系统各个模块的耦合度,提高了系统的可扩展性。
  2. 异步:消息队列提供了异步处理机制,在很多时候应用不想也不需要立即处理消息,允许应用把一些消息放入消息中间件中,并不立即处理它,在之后需要的时候再慢慢处理。
  3. 削峰:在访问量骤增的场景下,需要保证应用系统的平稳性,但是这样突发流量并不常见,如果以这类峰值的标准而投放资源的话,那无疑是巨大的浪费。使用消息队列能够使关键组件支撑突发访问压力,不会因为突发的超负荷请求而完全崩溃。消息队列的容量可以配置的很大,如果采用磁盘存储消息,则几乎等于“无限”容量,这样一来,高峰期的消息可以被积压起来,在随后的时间内进行平滑的处理完成,而不至于让系统短时间内无法承载而导致崩溃。在电商网站的秒杀抢购这种突发性流量很强的业务场景中,消息队列的强大缓冲能力可以很好的起到削峰作用。

2.2 说一说生产者与消费者模式

参考答案

所谓生产者-消费者问题,实际上主要是包含了两类线程。一种是生产者线程用于生产数据,另一种是消费者线程用于消费数据,为了解耦生产者和消费者的关系,通常会采用共享的数据区域,就像是一个仓库。生产者生产数据之后直接放置在共享数据区中,并不需要关心消费者的行为。而消费者只需要从共享数据区中去获取数据,就不再需要关心生产者的行为。但是,这个共享数据区域中应该具备这样的线程间并发协作的功能:

  1. 如果共享数据区已满的话,阻塞生产者继续生产数据放置入内;
  2. 如果共享数据区为空的话,阻塞消费者继续消费数据。

在Java语言中,实现生产者消费者问题时,可以采用三种方式:

  1. 使用 Object 的 wait/notify 的消息通知机制;
  2. 使用 Lock 的 Condition 的 await/signal 的消息通知机制;
  3. 使用 BlockingQueue 实现。

2.3 消息队列如何保证顺序消费?

参考答案

在生产中经常会有一些类似报表系统这样的系统,需要做 MySQL 的 binlog 同步。比如订单系统要同步订单表的数据到大数据部门的 MySQL 库中用于报表统计分析,通常的做法是基于 Canal 这样的中间件去监听订单数据库的 binlog,然后把这些 binlog 发送到 MQ 中,再由消费者从 MQ 中获取 binlog 落地到大数据部门的 MySQL 中。

在这个过程中,可能会有对某个订单的增删改操作,比如有三条 binlog 执行顺序是增加、修改、删除。消费者愣是换了顺序给执行成删除、修改、增加,这样能行吗?肯定是不行的。不同的消息队列产品,产生消息错乱的原因,以及解决方案是不同的。下面我们以RabbitMQ、Kafka、RocketMQ为例,来说明保证顺序消费的办法。

RabbitMQ:

对于 RabbitMQ 来说,导致上面顺序错乱的原因通常是消费者是集群部署,不同的消费者消费到了同一订单的不同的消息。如消费者A执行了增加,消费者B执行了修改,消费者C执行了删除,但是消费者C执行比消费者B快,消费者B又比消费者A快,就会导致消费 binlog 执行到数据库的时候顺序错乱,本该顺序是增加、修改、删除,变成了删除、修改、增加。

RabbitMQ 的问题是由于不同的消息都发送到了同一个 queue 中,多个消费者都消费同一个 queue 的消息。解决这个问题,我们可以给 RabbitMQ 创建多个 queue,每个消费者固定消费一个 queue 的消息,生产者发送消息的时候,同一个订单号的消息发送到同一个 queue 中,由于同一个 queue 的消息是一定会保证有序的,那么同一个订单号的消息就只会被一个消费者顺序消费,从而保证了消息的顺序性。

Kafka:

对于 Kafka 来说,一个 topic 下同一个 partition 中的消息肯定是有序的,生产者在写的时候可以指定一个 key,通过我们会用订单号作为 key,这个 key 对应的消息都会发送到同一个 partition 中,所以消费者消费到的消息也一定是有序的。

那么为什么 Kafka 还会存在消息错乱的问题呢?问题就出在消费者身上。通常我们消费到同一个 key 的多条消息后,会使用多线程技术去并发处理来提高消息处理速度,否则一条消息的处理需要耗时几十 毫秒,1 秒也就只能处理几十条消息,吞吐量就太低了。而多线程并发处理的话,binlog 执行到数据库的时候就不一定还是原来的顺序了。

Kafka 从生产者到消费者消费消息这一整个过程其实都是可以保证有序的,导致最终乱序是由于消费者端需要使用多线程并发处理消息来提高吞吐量,比如消费者消费到了消息以后,开启 32 个线程处理消息,每个线程线程处理消息的快慢是不一致的,所以才会导致最终消息有可能不一致。

所以对于 Kafka 的消息顺序性保证,其实我们只需要保证同一个订单号的消息只被同一个线程处理的就可以了。由此我们可以在线程处理前增加个内存队列,每个线程只负责处理其中一个内存队列的消息,同一个订单号的消息发送到同一个内存队列中即可。

RocketMQ:

对于 RocketMQ 来说,每个 Topic 可以指定多个 MessageQueue,当我们写入消息的时候,会把消息均匀地分发到不同的 MessageQueue 中,比如同一个订单号的消息,增加 binlog 写入到 MessageQueue1 中,修改 binlog 写入到 MessageQueue2 中,删除 binlog 写入到 MessageQueue3 中。

但是当消费者有多台机器的时候,会组成一个 Consumer Group,Consumer Group 中的每台机器都会负责消费一部分 MessageQueue 的消息,所以可能消费者A消费了 MessageQueue1 的消息执行增加操作,消费者B消费了 MessageQueue2 的消息执行修改操作,消费者C消费了 MessageQueue3 的消息执行删除操作,但是此时消费 binlog 执行到数据库的时候就不一定是消费者A先执行了,有可能消费者C先执行删除操作,因为几台消费者是并行执行,是不能够保证他们之间的执行顺序的。

RocketMQ 的消息乱序是由于同一个订单号的 binlog 进入了不同的 MessageQueue,进而导致一个订单的 binlog 被不同机器上的 Consumer 处理。

要解决 RocketMQ 的乱序问题,我们只需要想办法让同一个订单的 binlog 进入到同一个 MessageQueue 中就可以了。因为同一个 MessageQueue 内的消息是一定有序的,一个 MessageQueue 中的消息只能交给一个 Consumer 来进行处理,所以 Consumer 消费的时候就一定会是有序的。

2.4 消息队列如何保证消息不丢?

参考答案

丢数据一般分为两种,一种是mq把消息丢了,一种就是消费时将消息丢了。下面从rabbitmq和kafka分别说一下,丢失数据的场景。

RabbitMQ:

RabbitMQ丢失消息分为如下几种情况:

  1. 生产者丢消息:

    生产者将数据发送到RabbitMQ的时候,可能在传输过程中因为网络等问题而将数据弄丢了。

  2. RabbitMQ自己丢消息:

    如果没有开启RabbitMQ的持久化,那么RabbitMQ一旦重启数据就丢了。所以必须开启持久化将消息持久化到磁盘,这样就算RabbitMQ挂了,恢复之后会自动读取之前存储的数据,一般数据不会丢失。除非极其罕见的情况,RabbitMQ还没来得及持久化自己就挂了,这样可能导致一部分数据丢失。

  3. 消费端丢消息:

    主要是因为消费者消费时,刚消费到还没有处理,结果消费者就挂了,这样你重启之后,RabbitMQ就认为你已经消费过了,然后就丢了数据。

针对上述三种情况,RabbitMQ可以采用如下方式避免消息丢失:

  1. 生产者丢消息:

    • 可以选择使用RabbitMQ提供是事务功能,就是生产者在发送数据之前开启事务,然后发送消息,如果消息没有成功被RabbitMQ接收到,那么生产者会受到异常报错,这时就可以回滚事务,然后尝试重新发送。如果收到了消息,那么就可以提交事务。这种方式有明显的缺点,即RabbitMQ事务开启后,就会变为同步阻塞操作,生产者会阻塞等待是否发送成功,太耗性能会造成吞吐量的下降。
    • 可以开启confirm模式。在生产者那里设置开启了confirm模式之后,每次写的消息都会分配一个唯一的id,然后如何写入了RabbitMQ之中,RabbitMQ会给你回传一个ack消息,告诉你这个消息发送OK了。如果RabbitMQ没能处理这个消息,会回调你一个nack接口,告诉你这个消息失败了,你可以进行重试。而且你可以结合这个机制知道自己在内存里维护每个消息的id,如果超过一定时间还没接收到这个消息的回调,那么你可以进行重发。

    事务机制是同步的,你提交了一个事物之后会阻塞住,但是confirm机制是异步的,发送消息之后可以接着发送下一个消息,然后RabbitMQ会回调告知成功与否。 一般在生产者这块避免丢失,都是用confirm机制。

  2. RabbitMQ自己丢消息:

    设置消息持久化到磁盘,设置持久化有两个步骤:

    • 创建queue的时候将其设置为持久化的,这样就可以保证RabbitMQ持久化queue的元数据,但是不会持久化queue里面的数据。
    • 发送消息的时候讲消息的deliveryMode设置为2,这样消息就会被设为持久化方式,此时RabbitMQ就会将消息持久化到磁盘上。 必须要同时开启这两个才可以。

    而且持久化可以跟生产的confirm机制配合起来,只有消息持久化到了磁盘之后,才会通知生产者ack,这样就算是在持久化之前RabbitMQ挂了,数据丢了,生产者收不到ack回调也会进行消息重发。

  3. 消费端丢消息:

    使用RabbitMQ提供的ack机制,首先关闭RabbitMQ的自动ack,然后每次在确保处理完这个消息之后,在代码里手动调用ack。这样就可以避免消息还没有处理完就ack。

Kafka:

Kafka丢失消息分为如下几种情况:

  1. 生产者丢消息:

    生产者没有设置相应的策略,发送过程中丢失数据。

  2. Kafka自己丢消息:

    比较常见的一个场景,就是Kafka的某个broker宕机了,然后重新选举partition的leader时。如果此时follower还没来得及同步数据,leader就挂了,然后某个follower成为了leader,它就少了一部分数据。

  3. 消费端丢消息:

    消费者消费到了这个数据,然后消费之自动提交了offset,让Kafka知道你已经消费了这个消息,当你准备处理这个消息时,自己挂掉了,那么这条消息就丢了。

针对上述三种情况,Kafka可以采用如下方式避免消息丢失:

  1. 生产者丢消息:

    关闭自动提交offset,在自己处理完毕之后手动提交offset,这样就不会丢失数据。

  2. Kafka自己丢消息:

    一般要求设置4个参数来保证消息不丢失:

    • 给topic设置 replication.factor 参数,这个值必须大于1,表示要求每个partition必须至少有2个副本。
    • 在kafka服务端设置 min.isync.replicas 参数,这个值必须大于1,表示 要求一个leader至少感知到有至少一个follower在跟自己保持联系正常同步数据,这样才能保证leader挂了之后还有一个follower。
    • 在生产者端设置 acks=all ,表示 要求每条每条数据,必须是写入所有replica副本之后,才能认为是写入成功了。
    • 在生产者端设置 retries=MAX (很大的一个值),表示这个是要求一旦写入事变,就无限重试。
  3. 消费端丢消息:

    如果按照上面设置了ack=all,则一定不会丢失数据,要求是,你的leader接收到消息,所有的follower都同步到了消息之后,才认为本次写成功了。如果没满足这个条件,生产者会自动不断的重试,重试无限次。

2.5 消息队列如何保证不重复消费?

参考答案

先大概说一说可能会有哪些重复消费的问题。首先就是比如rabbitmq、rocketmq、kafka,都有可能会出现消费重复消费的问题,正常。因为这问题通常不是mq自己保证的,是给你保证的。然后我们挑一个kafka来举个例子,说说怎么重复消费吧。

kafka实际上有个offset的概念,就是每个消息写进去,都有一个offset,代表他的序号,然后consumer消费了数据之后,每隔一段时间,会把自己消费过的消息的offset提交一下,代表我已经消费过了,下次我要是重启啥的,你就让我继续从上次消费到的offset来继续消费吧。

但是凡事总有意外,比如我们之前生产经常遇到的,就是你有时候重启系统,看你怎么重启了,如果碰到点着急的,直接kill进程了,再重启。这会导致consumer有些消息处理了,但是没来得及提交offset,尴尬了。重启之后,少数消息会再次消费一次。

其实重复消费不可怕,可怕的是你没考虑到重复消费之后,怎么保证幂等性。举个例子,假设你有个系统,消费一条往数据库里插入一条,要是你一个消息重复两次,你不就插入了两条,这数据不就错了?但是你要是消费到第二次的时候,自己判断一下已经消费过了,直接扔了,不就保留了一条数据?

一条数据重复出现两次,数据库里就只有一条数据,这就保证了系统的幂等性幂等性。通俗点说,就一个数据,或者一个请求,给你重复来多次,你得确保对应的数据是不会改变的,不能出错。

想要保证不重复消费,其实还要结合业务来思考,这里给几个思路:

  1. 比如你拿个数据要写库,你先根据主键查一下,如果这数据都有了,你就别插入了,update一下。
  2. 比如你是写redis,那没问题了,反正每次都是set,天然幂等性。
  3. 比如你不是上面两个场景,那做的稍微复杂一点,你需要让生产者发送每条数据的时候,里面加一个全局唯一的id,类似订单id之类的东西,然后你这里消费到了之后,先根据这个id去比如redis里查一下,之前消费过吗?如果没有消费过,你就处理,然后这个id写redis。如果消费过了,那你就别处理了,保证别重复处理相同的消息即可。

还有比如基于数据库的唯一键来保证重复数据不会重复插入多条,我们之前线上系统就有这个问题,就是拿到数据的时候,每次重启可能会有重复,因为kafka消费者还没来得及提交offset,重复数据拿到了以后我们插入的时候,因为有唯一键约束了,所以重复数据只会插入报错,不会导致数据库中出现脏数据。

2.6 MQ处理消息失败了怎么办?

参考答案

一般生产环境中,都会在使用MQ的时候设计两个队列:一个是核心业务队列,一个是死信队列。核心业务队列,就是比如专门用来让订单系统发送订单消息的,然后另外一个死信队列就是用来处理异常情况的。

比如说要是第三方物流系统故障了,此时无法请求,那么仓储系统每次消费到一条订单消息,尝试通知发货和配送,都会遇到对方的接口报错。此时仓储系统就可以把这条消息拒绝访问,或者标志位处理失败!注意,这个步骤很重要。

一旦标志这条消息处理失败了之后,MQ就会把这条消息转入提前设置好的一个死信队列中。然后你会看到的就是,在第三方物流系统故障期间,所有订单消息全部处理失败,全部会转入死信队列。然后你的仓储系统得专门有一个后台线程,监控第三方物流系统是否正常,能否请求的,不停的监视。一旦发现对方恢复正常,这个后台线程就从死信队列消费出来处理失败的订单,重新执行发货和配送的通知逻辑。死信队列的使用,其实就是MQ在生产实践中非常重要的一环,也就是架构设计必须要考虑的。

2.7 请介绍消息队列推和拉的使用场景

参考答案

推模式:

推模式是服务器端根据用户需要,由目的、按时将用户感兴趣的信息主动发送到用户的客户端。

优点:

  • 对用户要求低,方便用户获取需要的信息;
  • 及时性好,服务器端及时地向客户端推送更新动态信息,吞吐量大。

缺点:

  • 不能确保发送成功,推模式采用广播方式,只有服务器端和客户端在同一个频道上,推模式才有效,用户才能接收到信息;
  • 没有信息状态跟踪,推模式采用开环控制技术,一个信息推送后的状态,比如客户端是否接收等,无从得知;
  • 针对性较差。推送的信息可能并不能满足客户端的个性化需求。

拉模式:

拉模式是客户端主动从服务器端获取信息。

优点:

  • 针对性强,能满足客户端的个性化需求;
  • 信息传输量较小,网络中传输的只是客户端的请求和服务器端对该请求的响应;
  • 服务器端的任务轻。服务器端只是被动接收查询,对客户端的查询请求做出响应。

缺点:

  • 实时性较差,针对于服务器端实时更新的信息,客户端难以获取实时信息;
  • 对于客户端用户的要求较高,需要对服务器端具有一定的了解。

2.8 RabbitMQ和Kafka有什么区别?

参考答案

在实际生产应用中,通常会使用Kafka作为消息传输的数据管道,RabbitMQ作为交易数据作为数据传输管道,主要的取舍因素则是是否存在丢数据的可能。RabbitMQ在金融场景中经常使用,具有较高的严谨性,数据丢失的可能性更小,同事具备更高的实时性。而Kafka优势主要体现在吞吐量上,虽然可以通过策略实现数据不丢失,但从严谨性角度来讲,大不如RabbitMQ。而且由于Kafka保证每条消息最少送达一次,有较小的概率会出现数据重复发送的情况。详细来说,它们之间主要有如下的区别:

  1. 应用场景方面

    RabbitMQ:用于实时的,对可靠性要求较高的消息传递上。

    Kafka:用于处于活跃的流式数据,大数据量的数据处理上。

  2. 架构模型方面

    RabbitMQ:以broker为中心,有消息的确认机制。

    Kafka:以consumer为中心,没有消息的确认机制。

  3. 吞吐量方面

    RabbitMQ:支持消息的可靠的传递,支持事务,不支持批量操作,基于存储的可靠性的要求存储可以采用内存或硬盘,吞吐量小。

    Kafka:内部采用消息的批量处理,数据的存储和获取是本地磁盘顺序批量操作,消息处理的效率高,吞吐量高。

  4. 集群负载均衡方面

    RabbitMQ:本身不支持负载均衡,需要loadbalancer的支持。

    Kafka:采用zookeeper对集群中的broker,consumer进行管理,可以注册topic到zookeeper上,通过zookeeper的协调机制,producer保存对应的topic的broker信息,可以随机或者轮询发送到broker上,producer可以基于语义指定分片,消息发送到broker的某个分片上。

2.9 Kafka为什么速度快?

参考答案

Kafka的消息是保存或缓存在磁盘上的,一般认为在磁盘上读写数据是会降低性能的,因为寻址会比较消耗时间,但是实际上,Kafka的特性之一就是高吞吐率。即使是普通的服务器,Kafka也可以轻松支持每秒百万级的写入请求,超过了大部分的消息中间件,这种特性也使得Kafka在日志处理等海量数据场景广泛应用。

下面从数据写入和读取两方面分析,为什么Kafka速度这么快:

写入数据:

Kafka会把收到的消息都写入到硬盘中,它绝对不会丢失数据。为了优化写入速度Kafka采用了两个技术,顺序写入和MMFile 。

一、顺序写入

磁盘读写的快慢取决于你怎么使用它,也就是顺序读写或者随机读写。在顺序读写的情况下,磁盘的顺序读写速度和内存持平。因为硬盘是机械结构,每次读写都会寻址->写入,其中寻址是一个“机械动作”,它是最耗时的。所以硬盘最讨厌随机I/O,最喜欢顺序I/O。为了提高读写硬盘的速度,Kafka就是使用顺序I/O。

而且Linux对于磁盘的读写优化也比较多,包括read-ahead和write-behind,磁盘缓存等。如果在内存做这些操作的时候,一个是JAVA对象的内存开销很大,另一个是随着堆内存数据的增多,JAVA的GC时间会变得很长,使用磁盘操作有以下几个好处:

  1. 磁盘顺序读写速度超过内存随机读写;
  2. JVM的GC效率低,内存占用大。使用磁盘可以避免这一问题;
  3. 系统冷启动后,磁盘缓存依然可用。

下图就展示了Kafka是如何写入数据的, 每一个Partition其实都是一个文件 ,收到消息后Kafka会把数据插入到文件末尾(虚框部分):

这种方法有一个缺陷——没有办法删除数据 ,所以Kafka是不会删除数据的,它会把所有的数据都保留下来,每个消费者(Consumer)对每个Topic都有一个offset用来表示读取到了第几条数据 。

二、Memory Mapped Files

即便是顺序写入硬盘,硬盘的访问速度还是不可能追上内存。所以Kafka的数据并不是实时的写入硬盘 ,它充分利用了现代操作系统分页存储来利用内存提高I/O效率。Memory Mapped Files(后面简称mmap)也被翻译成 内存映射文件,在64位操作系统中一般可以表示20G的数据文件,它的工作原理是直接利用操作系统的Page来实现文件到物理内存的直接映射。完成映射之后你对物理内存的操作会被同步到硬盘上(操作系统在适当的时候)。

通过mmap,进程像读写硬盘一样读写内存(当然是虚拟机内存),也不必关心内存的大小有虚拟内存为我们兜底。使用这种方式可以获取很大的I/O提升,省去了用户空间到内核空间复制的开销(调用文件的read会把数据先放到内核空间的内存中,然后再复制到用户空间的内存中。)

但也有一个很明显的缺陷——不可靠,写到mmap中的数据并没有被真正的写到硬盘,操作系统会在程序主动调用flush的时候才把数据真正的写到硬盘。Kafka提供了一个参数——producer.type来控制是不是主动flush,如果Kafka写入到mmap之后就立即flush然后再返回Producer叫 同步 (sync);写入mmap之后立即返回Producer不调用flush叫异步 (async)。

读取数据:

一、基于sendfile实现Zero Copy

传统模式下,当需要对一个文件进行传输的时候,其具体流程细节如下:

  • 调用read函数,文件数据被copy到内核缓冲区;
  • read函数返回,文件数据从内核缓冲区copy到用户缓冲区;
  • write函数调用,将文件数据从用户缓冲区copy到内核与socket相关的缓冲区;
  • 数据从socket缓冲区copy到相关协议引擎。

以上细节是传统read/write方式进行网络文件传输的方式,我们可以看到,在这个过程当中,文件数据实际上是经过了四次copy操作:硬盘->内核buf->用户buf->socket相关缓冲区->协议引擎。而sendfile系统调用则提供了一种减少以上多次copy,提升文件传输性能的方法。

在内核版本2.1中,引入了sendfile系统调用,以简化网络上和两个本地文件之间的数据传输。sendfile的引入不仅减少了数据复制,还减少了上下文切换。运行流程如下:

  • sendfile系统调用,文件数据被copy至内核缓冲区;
  • 再从内核缓冲区copy至内核中socket相关的缓冲区;
  • 最后再socket相关的缓冲区copy到协议引擎。

相较传统read/write方式,2.1版本内核引进的sendfile已经减少了内核缓冲区到user缓冲区,再由user缓冲区到socket相关缓冲区的文件copy,而在内核版本2.4之后,文件描述符结果被改变,sendfile实现了更简单的方式,再次减少了一次copy操作。

在Apache、Nginx、lighttpd等web服务器当中,都有一项sendfile相关的配置,使用sendfile可以大幅提升文件传输性能。Kafka把所有的消息都存放在一个一个的文件中,当消费者需要数据的时候Kafka直接把文件发送给消费者,配合mmap作为文件读写方式,直接把它传给sendfile。

二、批量压缩

在很多情况下,系统的瓶颈不是CPU或磁盘,而是网络IO,对于需要在广域网上的数据中心之间发送消息的数据流水线尤其如此。进行数据压缩会消耗少量的CPU资源,不过对于kafka而言,网络IO更应该需要考虑。

  • 如果每个消息都压缩,但是压缩率相对很低,所以Kafka使用了批量压缩,即将多个消息一起压缩而不是单个消息压缩;
  • Kafka允许使用递归的消息集合,批量的消息可以通过压缩的形式传输并且在日志中也可以保持压缩格式,直到被消费者解压缩;
  • Kafka支持多种压缩协议,包括Gzip和Snappy压缩协议。

总结:

Kafka速度的秘诀在于,它把所有的消息都变成一个批量的文件,并且进行合理的批量压缩,减少网络IO损耗,通过mmap提高I/O速度,写入数据的时候由于单个Partion是末尾添加所以速度最优。读取数据的时候配合sendfile直接暴力输出。

2.10 RabbitMQ如何保证消息已达?

参考答案

RabbitMQ可能丢失消息分为如下几种情况:

  1. 生产者丢消息:

    生产者将数据发送到RabbitMQ的时候,可能在传输过程中因为网络等问题而将数据弄丢了。

  2. RabbitMQ自己丢消息:

    如果没有开启RabbitMQ的持久化,那么RabbitMQ一旦重启数据就丢了。所以必须开启持久化将消息持久化到磁盘,这样就算RabbitMQ挂了,恢复之后会自动读取之前存储的数据,一般数据不会丢失。除非极其罕见的情况,RabbitMQ还没来得及持久化自己就挂了,这样可能导致一部分数据丢失。

  3. 消费端丢消息:

    主要是因为消费者消费时,刚消费到还没有处理,结果消费者就挂了,这样你重启之后,RabbitMQ就认为你已经消费过了,然后就丢了数据。

针对上述三种情况,RabbitMQ可以采用如下方式避免消息丢失:

  1. 生产者丢消息:

    • 可以选择使用RabbitMQ提供是事务功能,就是生产者在发送数据之前开启事务,然后发送消息,如果消息没有成功被RabbitMQ接收到,那么生产者会受到异常报错,这时就可以回滚事务,然后尝试重新发送。如果收到了消息,那么就可以提交事务。这种方式有明显的缺点,即RabbitMQ事务开启后,就会变为同步阻塞操作,生产者会阻塞等待是否发送成功,太耗性能会造成吞吐量的下降。
    • 可以开启confirm模式。在生产者那里设置开启了confirm模式之后,每次写的消息都会分配一个唯一的id,然后如何写入了RabbitMQ之中,RabbitMQ会给你回传一个ack消息,告诉你这个消息发送OK了。如果RabbitMQ没能处理这个消息,会回调你一个nack接口,告诉你这个消息失败了,你可以进行重试。而且你可以结合这个机制知道自己在内存里维护每个消息的id,如果超过一定时间还没接收到这个消息的回调,那么你可以进行重发。

    事务机制是同步的,你提交了一个事物之后会阻塞住,但是confirm机制是异步的,发送消息之后可以接着发送下一个消息,然后RabbitMQ会回调告知成功与否。 一般在生产者这块避免丢失,都是用confirm机制。

  2. RabbitMQ自己丢消息:

    设置消息持久化到磁盘,设置持久化有两个步骤:

    • 创建queue的时候将其设置为持久化的,这样就可以保证RabbitMQ持久化queue的元数据,但是不会持久化queue里面的数据。
    • 发送消息的时候讲消息的deliveryMode设置为2,这样消息就会被设为持久化方式,此时RabbitMQ就会将消息持久化到磁盘上。 必须要同时开启这两个才可以。

    而且持久化可以跟生产的confirm机制配合起来,只有消息持久化到了磁盘之后,才会通知生产者ack,这样就算是在持久化之前RabbitMQ挂了,数据丢了,生产者收不到ack回调也会进行消息重发。

  3. 消费端丢消息:

    使用RabbitMQ提供的ack机制,首先关闭RabbitMQ的自动ack,然后每次在确保处理完这个消息之后,在代码里手动调用ack。这样就可以避免消息还没有处理完就ack。

3. 搜索引擎

3.1 说说ElasticSearch put的全过程

参考答案

put过程主要分为三个阶段:

  1. 协调阶段:

    Client 客户端选择一个 node 发送 put 请求,此时当前节点就是协调节点(coordinating node)。协调节点根据 document 的 id 进行路由,将请求转发给对应的 node。这个 node 上的是 primary shard 。

  2. 主要阶段:

    对应的 primary shard 处理请求,写入数据 ,然后将数据同步到 replica shard。

    • primary shard 会验证传入的数据结构;
    • 本地执行相关操作;
    • 将操作转发给 replica shard。

    当数据写入 primary shard 和 replica shard 成功后,路由节点返回响应给 Client。

  3. 副本阶段:

    每个 replica shard 在转发后,会进行本地操作。

在写操作时,默认情况下,只需要 primary shard 处于活跃状态即可进行操作。在索引设置时可以设置这个属性:index.write.wait_for_active_shards。默认是 1,即 primary shard 写入成功即可返回。 如果设置为 all 则相当于 number_of_replicas+1 就是 primary shard 数量 + replica shard 数量。就是需要等待 primary shard 和 replica shard 都写入成功才算成功。可以通过索引设置动态覆盖此默认设置。

3.2 说说ElasticSearch的倒排索引

参考答案

Elasticsearch 使用一种称为倒排索引的结构,它适用于快速的全文搜索。一个倒排索引由文档中所有不重复词的列表构成,对于其中每个词,有一个包含它的文档列表。

例如,假设我们有两个文档,每个文档的 content 域包含如下内容:

  1. The quick brown fox jumped over the lazy dog
  2. Quick brown foxes leap over lazy dogs in summer

为了创建倒排索引,我们首先将每个文档的 content 域拆分成单独的 词(我们称它为 词条 或 tokens ),创建一个包含所有不重复词条的排序列表,然后列出每个词条出现在哪个文档。结果如下所示:

现在,如果我们想搜索 quick brown ,我们只需要查找包含每个词条的文档:

两个文档都匹配,但是第一个文档比第二个匹配度更高。如果我们使用仅计算匹配词条数量的简单相似性算法 ,那么,我们可以说,对于我们查询的相关性来讲,第一个文档比第二个文档更佳。

但是,我们目前的倒排索引有一些问题:

  • Quick 和 quick 以独立的词条出现,然而用户可能认为它们是相同的词。
  • fox 和 foxes 非常相似, 就像 dog 和 dogs ;他们有相同的词根。
  • jumped 和 leap, 尽管没有相同的词根,但他们的意思很相近。他们是同义词。

使用前面的索引搜索 +Quick +fox 不会得到任何匹配文档。(记住,+ 前缀表明这个词必须存在。)只有同时出现 Quick 和 fox 的文档才满足这个查询条件,但是第一个文档包含 quick fox ,第二个文档包含 Quick foxes 。

我们的用户可以合理的期望两个文档与查询匹配。我们可以做的更好。如果我们将词条规范为标准模式,那么我们可以找到与用户搜索的词条不完全一致,但具有足够相关性的文档。例如:

  • Quick 可以小写化为 quick 。
  • foxes 可以 词干提取 --变为词根的格式-- 为 fox 。类似的, dogs 可以为提取为 dog 。
  • jumped 和 leap 是同义词,可以索引为相同的单词 jump 。

现在索引看上去像这样:

这还远远不够。我们搜索 +Quick +fox 仍然 会失败,因为在我们的索引中,已经没有 Quick 了。但是,如果我们对搜索的字符串使用与 content 域相同的标准化规则,会变成查询 +quick +fox ,这样两个文档都会匹配!

3.3 说一说你对solr的了解

参考答案

Solr是一个高性能,采用Java开发,基于Lucene的全文搜索服务器。同时对其进行了扩展,提供了比Lucene更为丰富的查询语言,同时实现了可配置、可扩展并对查询性能进行了优化,并且提供了一个完善的功能管理界面,是一款非常优秀的全文搜索引擎。

Java经典全套面试汇总(二十万字)相关推荐

  1. JAVA面经复习(二十六)面试难度:☆☆☆☆

    JAVA面经复习(二十六)面试难度:☆☆☆☆ 面试难度:☆☆☆☆ 推荐指数:☆☆☆☆☆ 推荐原因:总体来说本篇面经难度不高,且基本都是基础知识,不涉及复杂的分布式应用的工具,适合新手复习. 声明:答案 ...

  2. 我是如何从写不出来到写二十万字的稿的

    去年过年的时候,父母从乡下来到我在洛阳的家:晚上陪他们看完新闻联播后,我忍不住激动的心情,特意把北航出版社给我签的书稿(Web全栈开发进阶之路)合同捧出来给他们看,并郑重其事地介绍了一番. 我以为他们 ...

  3. 我是如何从写不出来,到完成二十万字书稿的?

    去年过年的时候,父母从乡下来到我在洛阳的家:晚上陪他们看完新闻联播后,我忍不住激动的心情,特意把北航出版社给我签的书稿(Web全栈开发进阶之路)合同捧出来给他们看,并郑重其事地介绍了一番. 我以为他们 ...

  4. Java经典面试题汇总200道

    超详细的Java学习资料汇总 Java经典面试题汇总 Java 基础 1. JDK 和 JRE 有什么区别? 2. == 和 equals 的区别是什么? 3. 两个对象的 hashCode()相同, ...

  5. javaSE从入门到精通的二十万字总结(一)

    目录 前言 1. 面向对象 1.1 final关键字 1.2 抽象类 1.3 接口 1.4 接口与抽象类结合 1.5 接口的作用 1.6 包和import 1.7 访问控制权限 1.8 Object类 ...

  6. 2021年4月16日 阿里供应链Java研发实习面试(二面)

    title: 2021年4月16日 阿里供应链Java研发实习面试(二面) tags: 面经 2021年4月16日 阿里供应链Java研发实习面试(二面)(70min) 自我介绍 介绍自己的研究生项目 ...

  7. java从入门到精通二十四(三层架构完成增删改查)

    java从入门到精通二十四(三层架构完成增删改查) 前言 环境准备 创建web项目结构 导入依赖和配置文件 创建层次模型 实现查询 实现添加 实现修改 完成删除 做一个用户登录验证 会话技术 cook ...

  8. Java游戏服务器开发之二十--xml配置文件

    Java游戏服务器开发之二十--xml配置文件     游戏中使用的配置文件很多     像游戏中的关卡,英雄等级,装备之类都是使用配置文件(xml等)     比如有一个装备,配置表中有一条记录,数 ...

  9. Java后端技术面试汇总(第一套)

    面试汇总,整理一波,doc文档可点击[ 此处下载] 1.基础篇 1.1.Java基础 • 面向对象的特征:继承.封装和多态 • final, finally, finalize 的区别 • Excep ...

  10. 二十万字带你入门C语言-史上最强C语言教程(汇总篇)

    一.前言 至此,史上最强C语言教程系列已经全部完成,今天是给大家来做一个汇总,笔者目前已经完成了C语言阶段的学习,一直以来感谢大家的陪伴与支持,笔者后续还会继续更新C++.数据结构.Linux.Mys ...

最新文章

  1. struts导入Excel进行解析
  2. WPF 框架全构建环境虚拟机硬盘分享
  3. zTree 优秀的jquery树插件
  4. 文科思维Java_开源之Processing:这好玩的编程语言是为文科生艺术家准备的
  5. 前端学习(2374):技术栈的使用
  6. git tag和分支的区别_GIT常用命令大全
  7. rename批量修改文件名
  8. Java Script 第09章 Ajax应用
  9. vs 2005應用2003框架
  10. 【渝粤题库】国家开放大学2021春2779植物病虫害防治基础题目
  11. Android 窗口结构(一) 窗口层级构造
  12. 川大计算机系1999级高伟,四川大学计算机系主任魏骁勇研发人脸识别无人机课堂点名...
  13. linux操作系统2试题,linux操作系统试题
  14. ipad/ios按钮背景颜色为渐变色?去除iOS按钮渐变色
  15. 改进YOLOv7系列:首发结合最新Centralized Feature Pyramid集中特征金字塔,通过COCO数据集验证强势涨点
  16. sas 读取mysql数据类型_SAS | 格式规范数据读取
  17. 如何快速关闭8080端口
  18. 计算机网络体系结构i层,计算机网络体系结构及协议之网际互连IBEBEE
  19. (原)详解生产线物流规划的原理及操作方式
  20. 从业务需求出发,开启IDC高效运维之路

热门文章

  1. php版本微信公众号开发
  2. 【毕业设计】基于大数据的招聘与租房分析可视化系统
  3. android 适配俄语添加,【技术贴】怎么在手机上添加俄语输入法?,怎样在电脑上打俄语重音?...
  4. 面试题---测试用例设计
  5. 办公室网络带宽叠加(有线和WiFi同时使用)
  6. 软件测试与质量保障之间的关系
  7. Python对列表数据进行区间统计
  8. java作业中数据分析与设计_Java课程设计——博客作业教学数据分析系统(201521123091 李嘉廉)...
  9. 网站被攻击如何修复网站漏洞
  10. android获取全部ram和rom,手机中的RAM和ROM,你懂得多少?