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种方式各自适合的场景。
+ 运算符:如果拼接的都是字符串直接量,则适合使用 + 运算符实现拼接;
StringBuilder:如果拼接的字符串中包含变量,并不要求线程安全,则适合使用StringBuilder;
StringBuffer:如果拼接的字符串中包含变量,并且要求线程安全,则适合使用StringBuffer;
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中,可以按照如下三个步骤处理异常:
捕获异常 将业务代码包裹在try块内部,当业务代码中发生任何异常时,系统都会为此异常创建一个异常对象。创建异常对象之后,JVM会在try块之后寻找可以处理它的catch块,并将异常对象交给这个catch块处理。
处理异常 在catch块中处理异常时,应该先记录日志,便于以后追溯这个异常。然后根据异常的类型、结合当前的业务情况,进行相应的处理。比如,给变量赋予一个默认值、直接返回空值、向外抛出一个新的业务异常交给调用者处理,等等。
回收资源 如果业务代码打开了某个资源,比如数据库连接、网络连接、磁盘文件等,则需要在这段业务代码执行完毕后关闭这项资源。并且,无论是否发生异常,都要尝试关闭这项资源。将关闭资源的代码写在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关键字不可修饰外部类,但可修饰内部类。
静态内部类需满足如下规则:
静态内部类可以包含静态成员,也可以包含非静态成员;
静态内部类不能访问外部类的实例成员,只能访问它的静态成员;
外部类的所有方法、初始化块都能访问其内部定义的静态内部类;
在外部类的外部,也可以实例化静态内部类,语法如下: 外部类.内部类 变量名 = 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 list1 = ...; List list2 = list1; // list2将元素当做Object处理
扩展阅读
从逻辑上来看,List 是List的子类,如果直接把一个List对象赋给一个List对象应该引起编译错误,但实际上不会。对泛型而言,可以直接把一个List对象赋给一个 List 对象,编译器仅仅提示“未经检查的转换”。
上述规则叫做泛型转换,可以通过下面代码进一步理解泛型转换: List list1 = ...; List list2 = list1; // 编译时警告“未经检查的转换”
1.46 List和List有什么区别?
参考答案
? 是类型通配符,List 可以表示各种泛型List的父类,意思是元素类型未知的List;
List 用于设定类型通配符的下限,此处 ? 代表一个未知的类型,但它必须是T的父类型;
List 用于设定类型通配符的上限,此处 ? 代表一个未知的类型,但它必须是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体系的继承树:
Map体系的继承树:
注:紫色框体代表接口,其中加粗的是代表四类集合的接口。蓝色框体代表实现类,其中有阴影的是常用实现类。
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包下线程安全的集合类的体系结构:
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的过程:
首次扩容: 先判断数组是否为空,若数组为空则进行第一次扩容(resize);
计算索引: 通过hash算法,计算键值对在数组中的索引;
插入数据:
如果当前位置元素为空,则直接插入数据;
如果当前位置元素非空,且key已存在,则直接覆盖其value;
如果当前位置元素非空,且key不存在,则将数据链到链表末端;
若链表长度达到8,则将链表转换成红黑树,并将数据插入树中;
再次扩容 如果数组中元素个数(size)超过threshold,则再次进行扩容操作。
扩展阅读
HashMap添加数据的详细过程,如下图:
2.5 如何得到一个线程安全的Map?
参考答案
使用Collections工具类,将线程不安全的Map包装成线程安全的Map;
使用java.util.concurrent包下的Map,如ConcurrentHashMap;
不建议使用Hashtable,虽然Hashtable是线程安全的,但是性能较差。
2.6 HashMap有什么特点?
参考答案
HashMap是线程不安全的实现;
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的扩容机制
参考答案
数组的初始容量为16,而容量是以2的次方扩充的,一是为了提高性能使用足够大的数组,二是为了能使用位运算代替取模预算(据说提升了5~8倍)。
数组是否需要扩充是通过负载因子判断的,如果当前元素个数为数组容量的0.75时,就会扩充数组。这个0.75就是默认的负载因子,可由构造器传入。我们也可以设置大于1的负载因子,这样数组就不会扩充,牺牲性能,节省内存。
为了解决碰撞,数组中的元素是单向链表类型。当链表长度到达一个阈值时(7或8),会将链表转换成红黑树提高性能。而当链表长度缩小到另一个阈值时(6),又会将红黑树转换回单向链表提高性能。
对于第三点补充说明,检查链表长度转换成红黑树之前,还会先检测当前数组数组是否到达一个阈值(64),如果没有到达这个容量,会放弃转换,先去扩充数组。所以上面也说了链表长度的阈值是7或8,因为会有一次放弃转换的操作。
扩展阅读
例如我们从16扩展为32时,具体的变化如下所示:
因此元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
因此,我们在扩充HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。可以看看下图为16扩充为32的resize示意图:
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。
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如何实现线程安全?
参考答案
直接使用Hashtable类;
直接使用ConcurrentHashMap;
使用Collections将HashMap包装成线程安全的Map。
2.14 HashMap是如何解决哈希冲突的?
参考答案
为了解决碰撞,数组中的元素是单向链表类型。当链表长度到达一个阈值时,会将链表转换成红黑树提高性能。而当链表长度缩小到另一个阈值时,又会将红黑树转换回单向链表提高性能。
2.15 说一说HashMap和HashTable的区别
参考答案
Hashtable是一个线程安全的Map实现,但HashMap是线程不安全的实现,所以HashMap比Hashtable的性能高一点。
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操作时,会经历两个步骤:
判断是否需要扩容;
定位到添加元素的位置,将其放入 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有什么区别?
参考答案
ArrayList的实现是基于数组,LinkedList的实现是基于双向链表;
对于随机访问ArrayList要优于LinkedList,ArrayList可以根据下标以O(1)时间复杂度对元素进行随机访问,而LinkedList的每一个元素都依靠地址指针和它后一个元素连接在一起,查找某个元素的时间复杂度是O(N);
对于插入和删除操作,LinkedList要优于ArrayList,因为当元素被添加到LinkedList任意位置的时候,不需要像ArrayList那样重新计算大小或者是更新索引;
LinkedList比ArrayList更占内存,因为LinkedList的节点除了存储数据,还存储了两个引用,一个指向前一个元素,一个指向后一个元素。
2.25 有哪些线程安全的List?
参考答案
Vector Vector是比较古老的API,虽然保证了线程安全,但是由于效率低一般不建议使用。
Collections.SynchronizedList SynchronizedList是Collections的内部类,Collections提供了synchronizedList方法,可以将一个线程不安全的List包装成线程安全的List,即SynchronizedList。它比Vector有更好的扩展性和兼容性,但是它所有的方法都带有同步锁,也不是性能最优的List。
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中的元素都是不能重复的,并且它们都是线程不安全的,二者的区别是:
HashSet中的元素可以是null,但TreeSet中的元素不能是null;
HashSet不能保证元素的排列顺序,而TreeSet支持自然排序、定制排序两种排序的方式;
HashSet底层是采用哈希表实现的,而TreeSet底层是采用红黑树实现的。
2.29 说一说HashSet的底层结构
参考答案
HashSet是基于HashMap实现的,默认构造函数是构建一个初始容量为16,负载因子为0.75 的HashMap。它封装了一个 HashMap 对象来存储所有的集合元素,所有放入 HashSet 中的集合元素实际上由 HashMap 的 key 来保存,而 HashMap 的 value 则存储了一个 PRESENT,它是一个静态的 Object 对象。
2.30 BlockingQueue中有哪些方法,为什么这样设计?
参考答案
为了应对不同的业务场景,BlockingQueue 提供了4 组不同的方法用于插入、移除以及对队列中的元素进行检查。如果请求的操作不能得到立即执行的话,每组方法的表现是不同的。这些方法如下:
四组不同的行为方式含义如下:
抛异常:如果操作无法立即执行,则抛一个异常;
特定值:如果操作无法立即执行,则返回一个特定的值(一般是 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队列中,直到转移成功。因为一次转移成功,就代表这个消息被成功通知到了等待消息的节点。
signal函数的示意图如下所示。
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的步骤如下:
使用Stream或XxxStream的builder()类方法创建该Stream对应的Builder。
重复调用Builder的add()方法向该流中添加多个元素。
调用Builder的build()方法获取对应的Stream。
调用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 怎么用流打开一个大文件?
参考答案
打开大文件,应避免直接将文件中的数据全部读取到内存中,可以采用分次读取的方式。
使用缓冲流。缓冲流内部维护了一个缓冲区,通过与缓冲区的交互,减少与设备的交互次数。使用缓冲输入流时,它每次会读取一批数据将缓冲区填满,每次调用读取方法并不是直接从设备取值,而是从缓冲区取值,当缓冲区为空时,它会再一次读取数据,将缓冲区填满。使用缓冲输出流时,每次调用写入方法并不是直接写入到设备,而是写入缓冲区,当缓冲区填满时它会自动刷入设备。
使用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机制。
采用边缘触发方式;
netty epoll transport暴露了更多的nio没有的配置参数,如 TCP_CORK, SO_REUSEADDR等等;
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代表序列化的版本,通过定义类的序列化版本,在反序列化时,只要对象中所存的版本和当前类的版本一致,就允许做恢复数据的操作,否则将会抛出序列化版本不一致的错误。
如果不定义序列化版本,在反序列化时可能出现冲突的情况,例如:
创建该类的实例,并将这个实例序列化,保存在磁盘上;
升级这个类,例如增加、删除、修改这个类的成员变量;
反序列化该类的实例,即从磁盘上恢复修改之前保存的数据。
在第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类来创建并启动线程的步骤如下:
定义Thread类的子类,并重写该类的run()方法,该run()方法将作为线程执行体。
创建Thread子类的实例,即创建了线程对象。
调用线程对象的start()方法来启动该线程。
通过实现Runnable接口来创建并启动线程的步骤如下:
定义Runnable接口的实现类,并实现该接口的run()方法,该run()方法将作为线程执行体。
创建Runnable实现类的实例,并将其作为Thread的target来创建Thread对象,Thread对象为线程对象。
调用线程对象的start()方法来启动该线程。
通过实现Callable接口来创建并启动线程的步骤如下:
创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,且该call()方法有返回值。然后再创建Callable实现类的实例。
使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。
使用FutureTask对象作为Thread对象的target创建并启动新线程。
调用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()方法来结束该线程,该方法容易导致死锁,通常不推荐使用。
扩展阅读
线程5种状态的转换关系,如下图所示:
4.6 如何实现线程同步?
参考答案
同步方法 即有synchronized关键字修饰的方法,由于java的每个对象都有一个内置锁,当用此关键字修饰方法时, 内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。需要注意, synchronized关键字也可以修饰静态方法,此时如果调用该静态方法,将会锁住整个类。
同步代码块 即有synchronized关键字修饰的语句块,被该关键字修饰的语句块会自动被加上内置锁,从而实现同步。需值得注意的是,同步是一种高开销的操作,因此应该尽量减少同步的内容。通常没有必要同步整个方法,使用synchronized代码块同步关键代码即可。
ReentrantLock Java 5新增了一个java.util.concurrent包来支持同步,其中ReentrantLock类是可重入、互斥、实现了Lock接口的锁,它与使用synchronized方法和快具有相同的基本行为和语义,并且扩展了其能力。需要注意的是,ReentrantLock还有一个可以创建公平锁的构造方法,但由于能大幅度降低程序运行效率,因此不推荐使用。
volatile volatile关键字为域变量的访问提供了一种免锁机制,使用volatile修饰域相当于告诉虚拟机该域可能会被其他线程更新,因此每次使用该域就要重新计算,而不是使用寄存器中的值。需要注意的是,volatile不会提供任何原子操作,它也不能用来修饰final类型的变量。
原子变量 在java的util.concurrent.atomic包中提供了创建了原子类型变量的工具类,使用该类可以简化线程同步。例如AtomicInteger 表可以用原子方式更新int的值,可用在应用程序中(如以原子方式增加的计数器),但不能用于替换Integer。可扩展Number,允许那些处理机遇数字类的工具和实用工具进行统一访问。
4.7 说一说Java多线程之间的通信方式
参考答案
在Java中线程通信主要有以下三种方式:
wait()、notify()、notifyAll() 如果线程之间采用synchronized来保证线程安全,则可以利用wait()、notify()、notifyAll()来实现线程通信。这三个方法都不是Thread类中所声明的方法,而是Object类中声明的方法。原因是每个对象都拥有锁,所以让当前线程等待某个对象的锁,当然应该通过这个对象来操作。并且因为当前线程可能会等待多个线程的锁,如果通过线程来操作,就非常复杂了。另外,这三个方法都是本地方法,并且被final修饰,无法被重写。 wait()方法可以让当前线程释放对象锁并进入阻塞状态。notify()方法用于唤醒一个正在等待相应对象锁的线程,使其进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到CPU的执行。notifyAll()用于唤醒所有正在等待相应对象锁的线程,使它们进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到CPU的执行。 每个锁对象都有两个队列,一个是就绪队列,一个是阻塞队列。就绪队列存储了已就绪(将要竞争锁)的线程,阻塞队列存储了被阻塞的线程。当一个阻塞线程被唤醒后,才会进入就绪队列,进而等待CPU的调度。反之,当一个线程被wait后,就会进入阻塞队列,等待被唤醒。
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()。
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()的区别
参考答案
sleep()是Thread类中的静态方法,而wait()是Object类中的成员方法;
sleep()可以在任何地方使用,而wait()只能在同步方法或同步代码块中使用;
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的区别
参考答案
synchronized是Java关键字,在JVM层面实现加锁和解锁;Lock是一个接口,在代码层面实现加锁和解锁。
synchronized可以用在代码块上、方法上;Lock只能写在代码里。
synchronized在代码执行完或出现异常时自动释放锁;Lock不会自动释放锁,需要在finally中显示释放锁。
synchronized会导致线程拿不到锁一直等待;Lock可以设置获取锁失败的超时时间。
synchronized无法得知是否获取锁成功;Lock则可以通过tryLock得知加锁是否成功。
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 结构如下图所示:
首先ReentrantLock 实现了 Lock 接口,然后有3个内部类,其中Sync内部类继承自AQS,另外的两个内部类继承自Sync,这两个类分别是用来公平锁和非公平锁的。通过Sync重写的方法tryAcquire、tryRelease可以知道,ReentrantLock实现的是AQS的独占模式,也就是独占锁,这个锁是悲观锁。
4.17 如果不使用synchronized和Lock,如何保证线程安全?
参考答案
volatile volatile关键字为域变量的访问提供了一种免锁机制,使用volatile修饰域相当于告诉虚拟机该域可能会被其他线程更新,因此每次使用该域就要重新计算,而不是使用寄存器中的值。需要注意的是,volatile不会提供任何原子操作,它也不能用来修饰final类型的变量。
原子变量 在java的util.concurrent.atomic包中提供了创建了原子类型变量的工具类,使用该类可以简化线程同步。例如AtomicInteger 表可以用原子方式更新int的值,可用在应用程序中(如以原子方式增加的计数器),但不能用于替换Integer。可扩展Number,允许那些处理机遇数字类的工具和实用工具进行统一访问。
本地存储 可以通过ThreadLocal类来实现线程本地存储的功能。每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。
不可变的 只要一个不可变的对象被正确地构建出来,那其外部的可见状态永远都不会改变,永远都不会看到它在多个线程之中处于不一致的状态,“不可变”带来的安全性是最直接、最纯粹的。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的类图:
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 进行了优化,引入了偏向锁和轻量级锁 ,从此以后锁的状态就有了四种:无锁、偏向锁、轻量级锁、重量级锁。并且四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级,这四种锁的级别由低到高依次是:无锁、偏向锁,轻量级锁,重量级锁。如下图所示:
无锁 无锁是指没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。无锁的特点是修改操作会在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。
偏向锁 初次执行到synchronized代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。 偏向锁是指当一段同步代码一直被同一个线程所访问时,即不存在多个线程的竞争时,那么该线程在后续访问时便会自动获得锁,从而降低获取锁带来的消耗,即提高性能。 当一个线程访问同步代码块并获取锁时,会在 Mark Word 里存储锁偏向的线程 ID。在线程进入和退出同步块时不再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。 偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。关于偏向锁的撤销,需要等待全局安全点,即在某个时间点上没有字节码正在执行时,它会先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁,恢复到无锁(标志位为01)或轻量级锁(标志位为00)的状态。
轻量级锁 轻量级锁是指当锁是偏向锁的时候,却被另外的线程所访问,此时偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能。 轻量级锁的获取主要由两种情况:
当关闭偏向锁功能时;
由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁。
一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。这里要明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。 在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。 长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。
重量级锁 重量级锁显然,此忙等是有限度的(有个计数器记录自旋次数,默认允许循环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位的虚拟机中:
在64位的虚拟机中:
下面我们以 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 分段锁是怎么实现的?
参考答案
在并发程序中,串行操作是会降低可伸缩性,并且上下文切换也会减低性能。在锁上发生竞争时将通水导致这两种问题,使用独占锁时保护受限资源的时候,基本上是采用串行方式—-每次只能有一个线程能访问它。所以对于可伸缩性来说最大的威胁就是独占锁。
我们一般有三种方式降低锁的竞争程度:
减少锁的持有时间;
降低锁的请求频率;
使用带有协调机制的独占锁,这些机制允许更高的并发性。
在某些情况下我们可以将锁分解技术进一步扩展为一组独立对象上的锁进行分解,这称为分段锁。其实说的简单一点就是:容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
如下图,ConcurrentHashMap使用Segment数据结构,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。所以说,ConcurrentHashMap在并发情况下,不仅保证了线程安全,而且提高了性能。
4.23 说说你对读写锁的了解
参考答案
与传统锁不同的是读写锁的规则是可以共享读,但只能一个写,总结起来为:读读不互斥、读写互斥、写写互斥,而一般的独占锁是:读读互斥、读写互斥、写写互斥,而场景中往往读远远大于写,读写锁就是为了这种优化而创建出来的一种机制。 注意是读远远大于写,一般情况下独占锁的效率低来源于高并发下对临界区的激烈竞争导致线程上下文切换。因此当并发不是很高的情况下,读写锁由于需要额外维护读锁的状态,可能还不如独占锁的效率高。因此需要根据实际情况选择使用。
在Java中ReadWriteLock的主要实现为ReentrantReadWriteLock,其提供了以下特性:
公平性选择:支持公平与非公平(默认)的锁获取方式,吞吐量非公平优先于公平。
可重入:读线程获取读锁之后可以再次获取读锁,写线程获取写锁之后可以再次获取写锁。
可降级:写线程获取写锁之后,其还可以再次获取读锁,然后释放掉写锁,那么此时该线程是读锁状态,也就是降级操作。
4.24 volatile关键字有什么用?
参考答案
当一个变量被定义成volatile之后,它将具备两项特性:
保证可见性 当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去,这个写会操作会导致其他线程中的volatile变量缓存无效。
禁止指令重排 使用volatile关键字修饰共享变量可以禁止指令重排序,volatile禁止指令重排序有一些规则:
当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见,在其后面的操作肯定还没有进行;
在进行指令优化时,不能将对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
即执行到volatile变量时,其前面的所有语句都执行完,后面所有语句都未执行。且前面语句的结果对volatile变量及其后面语句可见。
注意,虽然volatile能够保证可见性,但它不能保证原子性。volatile变量在各个线程的工作内存中是不存在一致性问题的,但是Java里面的运算操作符并非原子操作,这导致volatile变量的运算在并发下一样是不安全的。
4.25 谈谈volatile的实现原理
参考答案
volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障,内存屏障会提供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> { /** 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 介绍一下线程池的工作流程
参考答案
线程池的工作流程如下图所示:
判断核心线程池是否已满,没满则创建一个新的工作线程来执行任务。
判断任务队列是否已满,没满则将新提交的任务添加在工作队列。
判断整个线程池是否已满,没满则创建一个新的工作线程来执行任务,已满则执行饱和(拒绝)策略。
4.33 线程池都有哪些状态?
参考答案
线程池一共有五种状态, 分别是:
RUNNING :能接受新提交的任务,并且也能处理阻塞队列中的任务。
SHUTDOWN:关闭状态,不再接受新提交的任务,但却可以继续处理阻塞队列中已保存的任务。在线程池处于 RUNNING 状态时,调用 shutdown()方法会使线程池进入到该状态。
STOP:不能接受新任务,也不处理队列中的任务,会中断正在处理任务的线程。在线程池处于 RUNNING 或 SHUTDOWN 状态时,调用 shutdownNow() 方法会使线程池进入到该状态。
TIDYING:如果所有的任务都已终止了,workerCount (有效线程数) 为0,线程池进入该状态后会调用 terminated() 方法进入TERMINATED 状态。
TERMINATED:在terminated() 方法执行完后进入该状态,默认terminated()方法中什么也没有做。进入TERMINATED的条件如下:
线程池不是RUNNING状态;
线程池状态不是TIDYING状态或TERMINATED状态;
如果线程池状态是SHUTDOWN并且workerQueue为空;
workerCount为0;
设置TIDYING状态成功。
下图为线程池的状态转换过程:
4.34 谈谈线程池的拒绝策略
参考答案
当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就会采取任务拒绝策略,通常有以下四种策略:
AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。
DiscardPolicy:也是丢弃任务,但是不抛出异常。
DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复该过程)。
CallerRunsPolicy:由调用线程处理该任务。
4.35 线程池的队列大小你通常怎么设置?
参考答案
CPU密集型任务 尽量使用较小的线程池,一般为CPU核心数+1。 因为CPU密集型任务使得CPU使用率很高,若开过多的线程数,会造成CPU过度切换。
IO密集型任务 可以使用稍大的线程池,一般为2*CPU核心数。 IO密集型任务CPU使用率并不高,因此可以让CPU在等待IO的时候有其他线程去处理别的任务,充分利用CPU时间。
混合型任务 可以将任务分成IO密集型和CPU密集型任务,然后分别用不同的线程池去处理。 只要分完之后两个任务的执行时间相差不大,那么就会比串行执行来的高效。因为如果划分之后两个任务执行时间有数据级的差距,那么拆分没有意义。因为先执行完的任务就要等后执行完的任务,最终的时间仍然取决于后执行完的任务,而且还要加上任务拆分与合并的开销,得不偿失。
4.36 线程池有哪些参数,各个参数的作用是什么?
参考答案
线程池主要有如下6个参数:
corePoolSize(核心工作线程数):当向线程池提交一个任务时,若线程池已创建的线程数小于corePoolSize,即便此时存在空闲线程,也会通过创建一个新线程来执行该任务,直到已创建的线程数大于或等于corePoolSize时。
maximumPoolSize(最大线程数):线程池所允许的最大线程个数。当队列满了,且已创建的线程数小于maximumPoolSize,则线程池会创建新的线程来执行任务。另外,对于无界队列,可忽略该参数。
keepAliveTime(多余线程存活时间):当线程池中线程数大于核心线程数时,线程的空闲时间如果超过线程存活时间,那么这个线程就会被销毁,直到线程池中的线程数小于等于核心线程数。
workQueue(队列):用于传输和保存等待执行任务的阻塞队列。
threadFactory(线程创建工厂):用于创建新线程。threadFactory创建的线程也是采用new Thread()方式,threadFactory创建的线程名都具有统一的风格:pool-m-thread-n(m为线程池的编号,n为线程池内的线程编号)。
handler(拒绝策略):当线程池和队列都满了,再加入线程会执行此策略
5. JVM
5.1 JVM包含哪几部分?
参考答案
JVM 主要由四大部分组成:ClassLoader(类加载器),Runtime Data Area(运行时数据区,内存分区),Execution Engine(执行引擎),Native Interface(本地库接口),下图可以大致描述 JVM 的结构。
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的启动过程分为如下四个步骤:
JVM的装入环境和配置 java.exe负责查找JRE,并且它会按照如下的顺序来选择JRE:
自己目录下的JRE;
父级目录下的JRE;
查注册中注册的JRE。
装载JVM 通过第一步找到JVM的路径后,Java.exe通过LoadJavaVM来装入JVM文件。LoadLibrary装载JVM动态连接库,然后把JVM中的到处函数JNI_CreateJavaVM和JNI_GetDefaultJavaVMIntArgs 挂接到InvocationFunction 变量的CreateJavaVM和GetDafaultJavaVMInitArgs 函数指针变量上。JVM的装载工作完成。
初始化JVM,获得本地调用接口 调用InvocationFunction -> CreateJavaVM,也就是JVM中JNI_CreateJavaVM方法获得JNIEnv结构的实例。
运行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虚拟机所管理的内存将会包括以下几个运行时数据区域。
程序计数器 程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。 由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。 如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。
Java虚拟机栈 与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧[插图](Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。 在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。
本地方法栈 本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。 《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(譬如Hot-Spot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。
Java堆 对于Java应用程序来说,Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。在《Java虚拟机规范》中对Java堆的描述是:“所有的对象实例以及数组都应当在堆上分配”,而这里笔者写的“几乎”是指从实现角度来看,随着Java语言的发展,现在已经能看到些许迹象表明日后可能出现值类型的支持,即使只考虑现在,由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致一些微妙的变化悄然发生,所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。 根据《Java虚拟机规范》的规定,Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。但对于大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。 Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。
方法区 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。 根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。
运行时常量池 运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。 既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。
直接内存 直接内存(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个处理过程,它们分别如下所示。
准备过程:初始化插入式注解处理器。
解析与填充符号表过程,包括:
词法、语法分析,将源代码的字符流转变为标记集合,构造出抽象语法树。
填充符号表,产生符号地址和符号信息。
插入式注解处理器的注解处理过程: 在Javac源码中,插入式注解处理器的初始化过程是在initPorcessAnnotations()方法中完成的,而它的执行过程则是在processAnnotations()方法中完成。这个方法会判断是否还有新的注解处理器需要执行,如果有的话,通过JavacProcessing-Environment类的doProcessing()方法来生成一个新的JavaCompiler对象,对编译的后续步骤进行处理。
分析与字节码生成过程,包括:
标注检查,对语法的静态信息进行检查。
数据流及控制流分析,对程序动态运行过程进行检查。
解语法糖,将简化代码编写的语法糖还原为原有的形式。
字节码生成,将前面各个步骤所生成的信息转化成字节码。
上述3个处理过程里,执行插入式注解时又可能会产生新的符号,如果有新的符号产生,就必须转回到之前的解析、填充符号表的过程中重新处理这些新符号,从总体来看,三者之间的关系与交互顺序如图所示。
5.10 介绍一下类加载的过程
参考答案
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。这七个阶段的发生顺序如下图所示。
在上述七个阶段中,包括了类加载的全过程,即加载、验证、准备、解析和初始化这五个阶段。
一、加载
“加载”(Loading)阶段是整个“类加载”(Class Loading)过程中的一个阶段,在加载阶段,Java虚拟机需要完成以下三件事情:
通过一个类的全限定名来获取定义此类的二进制字节流。
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了,方法区中的数据存储格式完全由虚拟机实现自行定义,《Java虚拟机规范》未规定此区域的具体数据结构。类型数据妥善安置在方法区之后,会在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口。
二、验证
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证。
文件格式验证: 第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。
元数据验证: 第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求。
字节码验证: 第三阶段是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。
符号引用验证: 符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。
三、准备
准备阶段是正式为类中定义的变量(即静态变量,被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):这个类加载器负责加载存放在\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可。
扩展类加载器(Extension Class Loader):这个类加载器是在类sun.misc.Launcher$ExtClassLoader中以Java代码的形式实现的。它负责加载\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将按照下面的顺序进行类搜索:
将以java.*开头的类,委派给父类加载器加载。
否则,将委派列表名单内的类,委派给父类加载器加载。
否则,将Import列表中的类,委派给Export这个类的Bundle的类加载器加载。
否则,查找当前Bundle的ClassPath,使用自己的类加载器加载。
否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载。
否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
否则,类查找失败。
上面的查找顺序中只有开头两点仍然符合双亲委派模型的原则,其余的类查找都是在平级的类加载器中进行的。
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到这个对象不可达时,则证明此对象是不可能再被使用的。
如下图所示,对象object 5、object 6、object 7虽然互有关联,但是它们到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)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:
弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
强分代假说(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)[插图]的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:
弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。把分代收集理论具体放到现在的商用Java虚拟机里,设计者一般至少会把Java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。
分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不是孤立的,对象之间会存在跨代引用。假如要现在进行一次只局限于新生代区域内的收集,但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:
跨代引用假说(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 区放不下剩余对象,这些对象就要进入到老年代,所以要提前检查老年代是不是够用。这样就有两种情况:
老年代剩余空间大于新生代中的对象大小,那就直接Minor GC,GC完survivor不够放,老年代也绝对够放;
老年代剩余空间小于新生代中的对象大小,这个时候就要查看是否启用了“老年代空间分配担保规则”,具体来说就是看 -XX:-HandlePromotionFailure 参数是否设置了。 老年代空间分配担保规则是这样的,如果老年代中剩余空间大小,大于历次 Minor GC 之后剩余对象的大小,那就允许进行 Minor GC。因为从概率上来说,以前的放的下,这次的也应该放的下。那就有两种情况: 老年代中剩余空间大小,大于历次Minor GC之后剩余对象的大小,进行 Minor GC; 老年代中剩余空间大小,小于历次Minor GC之后剩余对象的大小,进行Full GC,把老年代空出来再检查。
开启老年代空间分配担保规则只能说是大概率上来说,Minor GC 剩余后的对象够放到老年代,所以当然也会有万一,Minor GC 后会有这样三种情况:
Minor GC 之后的对象足够放到 Survivor 区,皆大欢喜,GC 结束;
Minor GC 之后的对象不够放到 Survivor 区,接着进入到老年代,老年代能放下,那也可以,GC 结束;
Minor GC 之后的对象不够放到 Survivor 区,老年代也放不下,那就只能 Full GC。
前面都是成功 GC 的例子,还有 3 中情况,会导致 GC 失败,报 OOM:
紧接上一节 Full GC 之后,老年代任然放不下剩余对象,就只能 OOM;
未开启老年代分配担保机制,且一次 Full GC 后,老年代任然放不下剩余对象,也只能 OOM;
开启老年代分配担保机制,但是担保不通过,一次 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的次数:
增加方法区的空间;
增加老年代的空间;
减少新生代的空间;
禁止使用System.gc()方法;
使用标记-整理算法,尽量保持较大的连续内存空间;
排查代码中无用的大对象。
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收集器是基于标记-清除算法实现的,它的运作过程分为四个步骤,包括:
初始标记(CMS initial mark);
并发标记(CMS concurrent mark);
重新标记(CMS remark);
并发清除(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类:
常发性内存泄漏。发生内存泄漏的代码会被多次执行到,每次被执行的时候都会导致一块内存泄漏。
偶发性内存泄漏。发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。常发性和偶发性是相对的。对于特定的环境,偶发性的也许就变成了常发性的。所以测试环境和测试方法对检测内存泄漏至关重要。
一次性内存泄漏。发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有一块仅且一块内存发生泄漏。
隐式内存泄漏。程序在运行过程中不停的分配内存,但是直到结束的时候才释放内存。严格的说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存。但是对于一个服务器程序,需要运行几天,几周甚至几个月,不及时释放内存也可能导致最终耗尽系统的所有内存。所以,我们称这类内存泄漏为隐式内存泄漏。
避免内存泄漏的几点建议:
尽早释放无用对象的引用。
避免在循环中创建对象。
使用字符串处理时避免使用String,应使用StringBuffer。
尽量少使用静态变量,因为静态变量存放在永久代,基本不参与垃圾回收。
5.31 什么是内存溢出,怎么解决?
参考答案
内存溢出(out of memory):简单地说内存溢出就是指程序运行过程中申请的内存大于系统能够提供的内存,导致无法申请到足够的内存,于是就发生了内存溢出。
引起内存溢出的原因有很多种,常见的有以下几种:
内存中加载的数据量过于庞大,如一次从数据库取出过多数据;
集合类中有对对象的引用,使用完后未清空,使得JVM不能回收;
代码中存在死循环或循环产生过多重复的对象实体;
使用的第三方软件中的BUG;
启动参数内存值设定的过小。
内存溢出的解决方案:
第一步,修改JVM启动参数,直接增加内存。
第二步,检查错误日志,查看“OutOfMemory”错误前是否有其它异常或错误。
第三步,对代码进行走查和分析,找出可能发生内存溢出的位置。
第四步,使用内存查看工具动态查看内存使用情况。
5.32 哪些区域会OOM,怎么触发OOM?
参考答案
除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OOM异常的可能。
Java堆溢出 Java堆用于储存对象实例,我们只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常。
虚拟机栈和本地方法栈溢出 HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。
方法区和运行时常量池溢出 方法区溢出也是一种常见的内存溢出异常,在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况。这类场景常见的包括:程序使用了CGLib字节码增强和动态语言、大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为Java类)、基于OSGi的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。 在JDK 6或更早之前的HotSpot虚拟机中,常量池都是分配在永久代中,即常量池是方法去的一部分,所以上述问题在常量池中也同样会出现。而HotSpot从JDK 7开始逐步“去永久代”的计划,并在JDK 8中完全使用元空间来代替永久代,所以上述问题在JDK 8中会得到避免。
本地直接内存溢出 直接内存(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。通常,我们有两种方式来实现这种转换。
使用 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()等聚合函数都可以达到行转列的效果。
使用 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注入
严格的参数校验 参数校验就没得说了,在一些不该有特殊字符的参数中提前进行特殊字符校验即可。
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索引。
索引的优点主要有以下几条:
通过创建唯一索引,可以保证数据库表中每一行数据的唯一性。
可以大大加快数据的查询速度,这也是创建索引的主要原因。
在实现数据的参考完整性方面,可以加速表和表之间的连接。
在使用分组和排序子句进行数据查询时,也可以显著减少查询中分组和排序的时间。
增加索引也有许多不利的方面,主要表现在如下几个方面:
创建索引和维护索引要耗费时间,并且随着数据量的增加所耗费的时间也会增加。
索引需要占磁盘空间,除了数据表占数据空间之外,每一个索引还要占一定的物理空间,如果有大量的索引,索引文件可能比数据文件更快达到最大文件尺寸。
当对表中的数据进行增加、删除和修改的时候,索引也要动态地维护,这样就降低了数据的维护速度。
2.2 索引有哪几种?
参考答案
MySQL的索引可以分为以下几类:
普通索引和唯一索引 普通索引是MySQL中的基本索引类型,允许在定义索引的列中插入重复值和空值。 唯一索引要求索引列的值必须唯一,但允许有空值。如果是组合索引,则列值的组合必须唯一。 主键索引是一种特殊的唯一索引,不允许有空值。
单列索引和组合索引 单列索引即一个索引只包含单个列,一个表可以有多个单列索引。 组合索引是指在表的多个字段组合上创建的索引,只有在查询条件中使用了这些字段的左边字段时,索引才会被使用。使用组合索引时遵循最左前缀集合。
全文索引 全文索引类型为FULLTEXT,在定义索引的列上支持值的全文查找,允许在这些索引列中插入重复值和空值。全文索引可以在CHAR、VARCHAR或者TEXT类型的列上创建。
空间索引 空间索引是对空间数据类型的字段建立的索引,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怎么判断要不要加索引?
参考答案
建议按照如下的原则来创建索引:
当唯一性是某种数据本身的特征时,指定唯一索引。使用唯一索引需能确保定义的列的数据完整性,以提高查询速度。
在频繁进行排序或分组(即进行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 如何评估一个索引创建的是否合理?
建议按照如下的原则来设计索引:
避免对经常更新的表进行过多的索引,并且索引中的列要尽可能少。应该经常用于查询的字段创建索引,但要避免添加不必要的字段。
数据量小的表最好不要使用索引,由于数据较少,查询花费的时间可能比遍历索引的时间还要短,索引可能不会产生优化效果。
在条件表达式中经常用到的不同值较多的列上建立索引,在不同值很少的列上不要建立索引。比如在学生表的“性别”字段上只有“男”与“女”两个不同值,因此就无须建立索引,如果建立索引不但不会提高查询效率,反而会严重降低数据更新速度。
当唯一性是某种数据本身的特征时,指定唯一索引。使用唯一索引需能确保定义的列的数据完整性,以提高查询速度。
在频繁进行排序或分组(即进行group by或order by操作)的列上建立索引,如果待排序的列有多个,可以在这些列上建立组合索引。
2.8 索引是越多越好吗?
参考答案
索引并非越多越好,一个表中如有大量的索引,不仅占用磁盘空间,还会影响INSERT、DELETE、UPDATE等语句的性能,因为在表中的数据更改时,索引也会进行调整和更新。
2.9 数据库索引失效了怎么办?
参考答案
可以采用以下几种方式,来避免索引失效:
使用组合索引时,需要遵循“最左前缀”原则;
不在索引列上做任何操作,例如计算、函数、类型转换,会导致索引失效而转向全表扫描;
尽量使用覆盖索引(之访问索引列的查询),减少 select * 覆盖索引能减少回表次数;
MySQL在使用不等于(!=或者<>)的时候无法使用索引会导致全表扫描;
LIKE以通配符开头(%abc)MySQL索引会失效变成全表扫描的操作;
字符串不加单引号会导致索引失效(可能发生了索引列的隐式转换);
少用or,用它来连接时会索引失效。
2.10 所有的字段都适合创建索引吗?
参考答案
不是。
下列几种情况,是不适合创建索引的:
频繁更新的字段不适合建立索引;
where条件中用不到的字段不适合建立索引;
数据比较少的表不需要建索引;
数据重复且分布比较均匀的的字段不适合建索引,例如性别、真假值;
参与列计算的列不适合建索引。
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 介绍一下数据库索引的重构过程
参考答案
什么时候需要重建索引呢?
表上频繁发生update,delete操作;
表上发生了alter table ..move操作(move操作导致了rowid变化)。
怎么判断索引是否应该重建?
一般看索引是否倾斜的严重,是否浪费了空间,对索引进行结构分析: analyze index index_name validate structure;
在相同的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重建索引的过程:
Rebuild以index fast full scan或table full scan方式(采用那种方式取决于cost)读取原索引中的数据来构建一个新的索引,重建过程中有排序操作,rebuild online执行表扫描获取数据,重建过程中有排序的操作;
Rebuild会阻塞DML操作,rebuild online不会阻塞DML操作;
rebuild online时系统会产生一个SYS_JOURNAL_xxx的IOT类型的系统临时日志表,所有rebuild online时索引的变化都记录在这个表中,当新的索引创建完成后,把这个表的记录维护到新的索引中去,然后drop掉旧的索引,rebuild online就完成了。
重建索引过程中的注意事项:
执行rebuild操作时,需要检查表空间是否足够;
虽然说rebuild online操作允许DML操作,但还是建议在业务不繁忙时间段进行;
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,多个版本的数据可以共存,主要基于以下技术及数据结构:
隐藏列:InnoDB中每行数据都有隐藏列,隐藏列中包含了本行数据的事务id、指向undo log的指针等。
基于undo log的版本链:每行数据的隐藏列中包含了指向undo log的指针,而每条undo log也会指向更早版本的undo log,从而形成一条版本链。
ReadView:通过隐藏列和版本链,MySQL可以将数据恢复到指定版本。但是具体要恢复到哪个版本,则需要根据ReadView来确定。所谓ReadView,是指事务(记做事务A)在某一时刻给整个事务系统(trx_sys)打快照,之后再进行读操作时,会将读取到的数据中的事务id与trx_sys快照比较,从而判断数据对该ReadView是否可见,即对事务A是否可见。
一致性实现原理:
可以说,一致性是事务追求的最终目标。前面提到的原子性、持久性和隔离性,都是为了保证数据库状态的一致性。此外,除了数据库层面的保障,一致性的实现也需要应用层面进行保障。实现一致性的措施包括:
保证原子性、持久性和隔离性,如果这些特性无法保证,事务的一致性也无法保证。
数据库本身提供保障,例如不允许向整形列插入字符串值、字符串长度不能超过列的限制等。
应用层面进行保障,例如如果转账操作只扣除转账者的余额,而没有增加接收者的余额,无论数据库实现的多么完美,也无法保证状态的一致。
3.4 谈谈MySQL的事务隔离级别
参考答案
SQL 标准定义了四种隔离级别,这四种隔离级别分别是:
读未提交(READ UNCOMMITTED);
读提交 (READ COMMITTED);
可重复读 (REPEATABLE READ);
串行化 (SERIALIZABLE)。
事务隔离是为了解决脏读、不可重复读、幻读问题,下表展示了 4 种隔离级别对这三个问题的解决程度:
上述4种隔离级别MySQL都支持,并且InnoDB存储引擎默认的支持隔离级别是REPEATABLE READ,但是与标准SQL不同的是,InnoDB存储引擎在REPEATABLE READ事务隔离级别下,使用Next-Key Lock的锁算法,因此避免了幻读的产生。所以,InnoDB存储引擎在默认的事务隔离级别下已经能完全保证事务的隔离性要求,即达到SQL标准的SERIALIZABLE隔离级别。
扩展阅读
并发情况下,读操作可能存在的三类问题:
脏读:当前事务(A)中可以读到其他事务(B)未提交的数据(脏数据),这种现象是脏读。
不可重复读:在事务A中先后两次读取同一个数据,两次读取的结果不一样,这种现象称为不可重复读。脏读与不可重复读的区别在于:前者读到的是其他事务未提交的数据,后者读到的是其他事务已提交的数据。
幻读:在事务A中按照某个条件先后两次查询数据库,两次查询结果的条数不同,这种现象称为幻读。不可重复读与幻读的区别可以通俗的理解为:前者是数据变了,后者是数据的行数变了。
3.5 MySQL的事务隔离级别是怎么实现的?
参考答案
InnoDB支持四种隔离级别,每种级别解决掉的问题如下表:
这四种隔离级别的实现机制如下:
READ UNCOMMITTED & READ COMMITTED: 通过Record Lock算法实现了行锁,但READ UNCOMMITTED允许读取未提交数据,所以存在脏读问题。而READ COMMITTED允许读取提交数据,所以不存在脏读问题,但存在不可重复读问题。
REPEATABLE READ: 使用Next-Key Lock算法实现了行锁,并且不允许读取已提交的数据,所以解决了不可重复读的问题。另外,该算法包含了间隙锁,会锁定一个范围,因此也解决了幻读的问题。
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的查询?
参考答案
使用索引:
如果查询时没有使用索引,查询语句将扫描表中的所有记录。在数据量大的情况下,这样查询的速度会很慢。如果使用索引进行查询,查询语句可以根据索引快速定位到待查询记录,从而减少查询的记录数,达到提高查询速度的目的。
索引可以提高查询的速度,但并不是使用带有索引的字段查询时索引都会起作用。有几种特殊情况,在这些情况下有可能使用带有索引的字段查询时索引并没有起作用。
使用LIKE关键字的查询语句 在使用LIKE关键字进行查询的查询语句中,如果匹配字符串的第一个字符为“%”,索引不会起作用。只有“%”不在第一个位置,索引才会起作用。
使用多列索引的查询语句 MySQL可以为多个字段创建索引。一个索引可以包括16个字段。对于多列索引,只有查询条件中使用了这些字段中的第1个字段时索引才会被使用。
使用OR关键字的查询语句 查询语句的查询条件中只有OR关键字,且OR前后的两个条件中的列都是索引时,查询中才使用索引。否则,查询将不使用索引。
优化子查询:
使用子查询可以进行SELECT语句的嵌套查询,即一个SELECT查询的结果作为另一个SELECT语句的条件。子查询可以一次性完成很多逻辑上需要多个步骤才能完成的SQL操作。
子查询虽然可以使查询语句很灵活,但执行效率不高。执行子查询时,MySQL需要为内层查询语句的查询结果建立一个临时表。然后外层查询语句从临时表中查询记录。查询完毕后,再撤销这些临时表。因此,子查询的速度会受到一定的影响。如果查询的数据量比较大,这种影响就会随之增大。
在MySQL中,可以使用连接(JOIN)查询来替代子查询。连接查询不需要建立临时表,其速度比子查询要快,如果查询中使用索引,性能会更好。
5.3 怎样插入数据才能更高效?
参考答案
影响插入速度的主要是索引、唯一性校验、一次插入记录条数等。针对这些情况,可以分别进行优化。
对于MyISAM引擎的表,常见的优化方法如下:
禁用索引 对于非空表,插入记录时,MySQL会根据表的索引对插入的记录建立索引。如果插入大量数据,建立索引会降低插入记录的速度。为了解决这种情况,可以在插入记录之前禁用索引,数据插入完毕后再开启索引。对于空表批量导入数据,则不需要进行此操作,因为MyISAM引擎的表是在导入数据之后才建立索引的。
禁用唯一性检查 插入数据时,MySQL会对插入的记录进行唯一性校验。这种唯一性校验也会降低插入记录的速度。为了降低这种情况对查询速度的影响,可以在插入记录之前禁用唯一性检查,等到记录插入完毕后再开启。
使用批量插入 插入多条记录时,可以使用一条INSERT语句插入一条记录,也可以使用一条INSERT语句插入多条记录。使用一条INSERT语句插入多条记录的情形如下,而这种方式的插入速度更快。 INSERT INTO fruits VALUES ('x1', '101', 'mongo2', '5.7'), ('x2', '101', 'mongo3', '5.7'), ('x3', '101', 'mongo4', '5.7');
使用LOAD DATA INFILE批量导入 当需要批量导入数据时,如果能用LOAD DATA INFILE语句,就尽量使用。因为LOAD DATA INFILE语句导入数据的速度比INSERT语句快。
对于InnoDB引擎的表,常见的优化方法如下:
禁用唯一性检查 插入数据之前执行set unique_checks=0来禁止对唯一索引的检查,数据导入完成之后再运行set unique_checks=1。这个和MyISAM引擎的使用方法一样。
禁用外键检查 插入数据之前执行禁止对外键的检查,数据插入完成之后再恢复对外键的检查。
禁用自动提交 插入数据之前禁止事务的自动提交,数据导入完成之后,执行恢复自动提交操作。
5.4 表中包含几千万条数据该怎么办?
参考答案
建议按照如下顺序进行优化:
优化SQL和索引;
增加缓存,如memcached、redis;
读写分离,可以采用主从复制,也可以采用主主复制;
使用MySQL自带的分区表,这对应用是透明的,无需改代码,但SQL语句是要针对分区表做优化的;
做垂直拆分,即根据模块的耦合度,将一个大的系统分为多个小的系统;
做水平拆分,要选择一个合理的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慢查询语句。
常见慢查询优化:
索引没起作用的情况
在使用LIKE关键字进行查询的查询语句中,如果匹配字符串的第一个字符为“%”,索引不会起作用。只有“%”不在第一个位置,索引才会起作用。
MySQL可以为多个字段创建索引。一个索引可以包括16个字段。对于多列索引,只有查询条件中使用了这些字段中的第1个字段时索引才会被使用。
查询语句的查询条件中只有OR关键字,且OR前后的两个条件中的列都是索引时,查询中才使用索引。否则,查询将不使用索引。
优化数据库结构
对于字段比较多的表,如果有些字段的使用频率很低,可以将这些字段分离出来形成新表。因为当一个表的数据量很大时,会由于使用频率低的字段的存在而变慢。
对于需要经常联合查询的表,可以建立中间表以提高查询效率。通过建立中间表,把需要经常联合查询的数据插入到中间表中,然后将原来的联合查询改为对中间表的查询,以此来提高查询效率。
分解关联查询 很多高性能的应用都会对关联查询进行分解,就是可以对每一个表进行一次单表查询,然后将查询结果在应用程序中进行关联,很多场景下这样会更高效。
优化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包含以下几种结果,从上之下依次是最差到最好:
另外,Extra列需要注意以下的几种情况:
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作为默认存储引擎,主要特性如下:
InnoDB给MySQL提供了具有提交、回滚和崩溃恢复能力的事务安全(ACID兼容)存储引擎。InnoDB锁定在行级并且也在SELECT语句中提供一个类似Oracle的非锁定读。这些功能增加了多用户部署和性能。在SQL查询中,可以自由地将InnoDB类型的表与其他MySQL表的类型混合起来,甚至在同一个查询中也可以混合。
InnoDB是为处理巨大数据量的最大性能设计。它的CPU效率可能是任何其他基于磁盘的关系数据库引擎所不能匹敌的。
InnoDB存储引擎完全与MySQL服务器整合,为在主内存中缓存数据和索引而维持它自己的缓冲池。InnoDB将它的表和索引存在一个逻辑表空间中,表空间可以包含数个文件(或原始磁盘分区)。这与MyISAM表不同,比如在MyISAM表中每个表被存在分离的文件中。InnoDB表可以是任何尺寸,即使在文件尺寸被限制为2GB的操作系统上。
InnoDB支持外键完整性约束(FOREIGN KEY)。存储表中的数据时,每张表的存储都按主键顺序存放,如果没有显示在表定义时指定主键,InnoDB会为每一行生成一个6B的ROWID,并以此作为主键。
InnoDB被用在众多需要高性能的大型数据库站点上。InnoDB不创建目录,使用InnoDB时,MySQL将在数据目录下创建一个名为ibdata1的10MB大小的自动扩展数据文件,以及两个名为ib_logfile0和ib_logfile1的5MB大小的日志文件。
MyISAM存储引擎:
MyISAM基于ISAM存储引擎,并对其进行扩展。它是在Web、数据仓储和其他应用环境下最常使用的存储引擎之一。MyISAM拥有较高的插入、查询速度,但不支持事务。MyISAM的主要特性如下:
在支持大文件(达63位文件长度)的文件系统和操作系统上被支持。
当把删除和更新及插入操作混合使用的时候,动态尺寸的行产生更少碎片。这要通过合并相邻被删除的块以及若下一个块被删除则扩展到下一块来自动完成。
每个MyISAM表最大的索引数是64,这可以通过重新编译来改变。每个索引最大的列数是16个。
最大的键长度是1000B,这也可以通过编译来改变。对于键长度超过250B的情况,一个超过1024B的键将被用上。
BLOB和TEXT列可以被索引。
NULL值被允许在索引的列中,这个值占每个键的0~1个字节。
所有数字键值以高字节优先被存储,以允许一个更高的索引压缩。
每个表一个AUTO_INCREMENT列的内部处理。MyISAM为INSERT和UPDATE操作自动更新这一列,这使得AUTO_INCREMENT列更快(至少10%)。在序列顶的值被删除之后就不能再利用。
可以把数据文件和索引文件放在不同目录。
每个字符列可以有不同的字符集。
有VARCHAR的表可以固定或动态记录长度。
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,多个版本的数据可以共存,主要基于以下技术及数据结构:
隐藏列:InnoDB中每行数据都有隐藏列,隐藏列中包含了本行数据的事务id、指向undo log的指针等。
基于undo log的版本链:每行数据的隐藏列中包含了指向undo log的指针,而每条undo log也会指向更早版本的undo log,从而形成一条版本链。
ReadView:通过隐藏列和版本链,MySQL可以将数据恢复到指定版本。但是具体要恢复到哪个版本,则需要根据ReadView来确定。所谓ReadView,是指事务(记做事务A)在某一时刻给整个事务系统(trx_sys)打快照,之后再进行读操作时,会将读取到的数据中的事务id与trx_sys快照比较,从而判断数据对该ReadView是否可见,即对事务A是否可见。
6.5 MySQL主从同步是如何实现的?
参考答案
复制(replication)是MySQL数据库提供的一种高可用高性能的解决方案,一般用来建立大型的应用。总体来说,replication的工作原理分为以下3个步骤:
主服务器(master)把数据更改记录到二进制日志(binlog)中。
从服务器(slave)把主服务器的二进制日志复制到自己的中继日志(relay log)中。
从服务器重做中继日志中的日志,把更改应用到自己的数据库上,以达到数据的最终一致性。
复制的工作原理并不复杂,其实就是一个完全备份加上二进制日志备份的还原。不同的是这个二进制日志的还原操作基本上实时在进行中。这里特别需要注意的是,复制不是完全实时地进行同步,而是异步实时。这中间存在主从服务器之间的执行延时,如果主服务器的压力很大,则可能导致主从服务器延时较大。复制的工作原理如下图所示,其中从服务器有2个线程,一个是I/O线程,负责读取主服务器的二进制日志,并将其保存为中继日志;另一个是SQL线程,复制执行中继日志。
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 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容器,它的本质就是一个工厂。
在具体的实现中,主要由三种注入方式:
构造方法注入 就是被注入对象可以在它的构造方法中声明依赖对象的参数列表,让外部知道它需要哪些依赖对象。然后,IoC Service Provider会检查被注入的对象的构造方法,取得它所需要的依赖对象列表,进而为其注入相应的对象。构造方法注入方式比较直观,对象被构造完成后,即进入就绪状态,可以马上使用。
setter方法注入 通过setter方法,可以更改相应的对象属性。所以,当前对象只要为其依赖对象所对应的属性添加setter方法,就可以通过setter方法将相应的依赖对象设置到被注入对象中。setter方法注入虽不像构造方法注入那样,让对象构造完成后即可使用,但相对来说更宽松一些, 可以在对象构造完成后再注入。
接口注入 相对于前两种注入方式来说,接口注入没有那么简单明了。被注入对象如果想要IoC Service Provider为其注入依赖对象,就必须实现某个接口。这个接口提供一个方法,用来为其注入依赖对象。IoC Service Provider最终通过这些接口来了解应该为被注入对象注入什么依赖对象。相对于前两种依赖注入方式,接口注入比较死板和烦琐。
总体来说,构造方法注入和setter方法注入因为其侵入性较弱,且易于理解和使用,所以是现在使用最多的注入方式。而接口注入因为侵入性较强,近年来已经不流行了。
2.5 Spring是如何管理Bean的?
参考答案
Spring通过IoC容器来管理Bean,我们可以通过XML配置或者注解配置,来指导IoC容器对Bean的管理。因为注解配置比XML配置方便很多,所以现在大多时候会使用注解配置的方式。
以下是管理Bean时常用的一些注解:
@ComponentScan用于声明扫描策略,通过它的声明,容器就知道要扫描哪些包下带有声明的类,也可以知道哪些特定的类是被排除在外的。
@Component、@Repository、@Service、@Controller用于声明Bean,它们的作用一样,但是语义不同。@Component用于声明通用的Bean,@Repository用于声明DAO层的Bean,@Service用于声明业务层的Bean,@Controller用于声明视图层的控制器Bean,被这些注解声明的类就可以被容器扫描并创建。
@Autowired、@Qualifier用于注入Bean,即告诉容器应该为当前属性注入哪个Bean。其中,@Autowired是按照Bean的类型进行匹配的,如果这个属性的类型具有多个Bean,就可以通过@Qualifier指定Bean的名称,以消除歧义。
@Scope用于声明Bean的作用域,默认情况下Bean是单例的,即在整个容器中这个类型只有一个实例。可以通过@Scope注解指定prototype值将其声明为多例的,也可以将Bean声明为session级作用域、request级作用域等等,但最常用的还是默认的单例模式。
@PostConstruct、@PreDestroy用于声明Bean的生命周期。其中,被@PostConstruct修饰的方法将在Bean实例化后被调用,@PreDestroy修饰的方法将在容器销毁前被调用。
2.6 介绍Bean的作用域
参考答案
默认情况下,Bean在Spring容器中是单例的,我们可以通过@Scope注解修改Bean的作用域。该注解有如下5个取值,它们代表了Bean的5种不同类型的作用域:
2.7 说一说Bean的生命周期
参考答案
Spring容器管理Bean,涉及对Bean的创建、初始化、调用、销毁等一系列的流程,这个流程就是Bean的生命周期。整个流程参考下图:
这个过程是由Spring容器自动管理的,其中有两个环节我们可以进行干预。
我们可以自定义初始化方法,并在该方法前增加@PostConstruct注解,届时Spring容器将在调用SetBeanFactory方法之后调用该方法。
我们可以自定义销毁方法,并在该方法前增加@PreDestroy注解,届时Spring容器将在自身销毁前,调用这个方法。
2.8 Spring是怎么解决循环依赖的?
参考答案
首先,需要明确的是spring对循环依赖的处理有三种情况:
构造器的循环依赖:这种依赖spring是处理不了的,直接抛出BeanCurrentlylnCreationException异常。
单例模式下的setter循环依赖:通过“三级缓存”处理循环依赖。
非单例循环依赖:无法处理。
接下来,我们具体看看spring是如何处理第二种循环依赖的。
Spring单例对象的初始化大略分为三步:
createBeanInstance:实例化,其实也就是调用对象的构造方法实例化对象;
populateBean:填充属性,这一步主要是多bean的依赖属性进行填充;
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 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注解有什么区别?
参考答案
@Autowired是Spring提供的注解,@Resource是JDK提供的注解。
@Autowired是只能按类型注入,@Resource默认按名称注入,也支持按类型注入。
@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不能对哪些类进行增强?
参考答案
Spring AOP只能对IoC容器中的Bean进行增强,对于不受容器管理的对象不能增强。
由于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支持两种事务编程模型:
编程式事务 Spring提供了TransactionTemplate模板,利用该模板我们可以通过编程的方式实现事务管理,而无需关注资源获取、复用、释放、事务同步及异常处理等操作。相对于声明式事务来说,这种方式相对麻烦一些,但是好在更为灵活,我们可以将事务管理的范围控制的更为精确。
声明式事务 Spring事务管理的亮点在于声明式事务管理,它允许我们通过声明的方式,在IoC配置中指定事务的边界和事务属性,Spring会自动在指定的事务边界上应用事务属性。相对于编程式事务来说,这种方式十分的方便,只需要在需要做事务管理的方法上,增加@Transactional注解,以声明事务特征即可。
2.17 Spring的事务传播方式有哪些?
参考答案
当我们调用一个业务方法时,它的内部可能会调用其他的业务方法,以完成一个完整的业务操作。这种业务方法嵌套调用的时候,如果这两个方法都是要保证事务的,那么就要通过Spring的事务传播机制控制当前事务如何传播到被嵌套调用的业务方法中。
Spring在TransactionDefinition接口中规定了7种类型的事务传播行为,它们规定了事务方法和事务方法发生嵌套调用时如何进行传播,如下表:
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的执行流程
参考答案
整个过程开始于客户端发出的一个HTTP请求,Web应用服务器接收到这个请求。如果匹配DispatcherServlet的请求映射路径,则Web容器将该请求转交给DispatcherServlet处理。
DispatcherServlet接收到这个请求后,将根据请求的信息(包括URL、HTTP方法、请求报文头、请求参数、Cookie等)及HandlerMapping的配置找到处理请求的处理器(Handler)。可将HandlerMapping看做路由控制器,将Handler看做目标主机。值得注意的是,在Spring MVC中并没有定义一个Handler接口,实际上任何一个Object都可以成为请求处理器。
当DispatcherServlet根据HandlerMapping得到对应当前请求的Handler后,通过HandlerAdapter对Handler进行封装,再以统一的适配器接口调用Handler。HandlerAdapter是Spring MVC框架级接口,顾名思义,HandlerAdapter是一个适配器,它用统一的接口对各种Handler方法进行调用。
处理器完成业务逻 辑的处理后,将返回一个ModelAndView给DispatcherServlet,ModelAndView包含了视图逻辑名和模型数据信息。
ModelAndView中包含的是“逻辑视图名”而非真正的视图对象,DispatcherServlet借由ViewResolver完成逻辑视图名到真实视图对象的解析工作。
当得到真实的视图对象View后,DispatcherServlet就使用这个View对象对ModelAndView中的模型数据进行视图渲染。
最终客户端得到的响应消息可能是一个普通的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拦截器的开发步骤如下:
开发拦截器: 实现handlerInterceptor接口,从三个方法中选择合适的方法,实现拦截时要执行的具体业务逻辑。
注册拦截器: 定义配置类,并让它实现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支持多种输入输出类型,包括:
简单的类型,如整数、小数、字符串等;
集合类型,如Map等;
自定义的JavaBean。
其中,简单的类型,其数值直接映射到参数上。对于Map或JavaBean则将其属性按照名称映射到参数上。
4.3 MyBatis里如何实现一对多关联查询?
参考答案
一对多映射有两种配置方式,都是使用collection标签实现的。在此之前,为了能够存储一对多的数据,需要在主表对应的实体类中增加集合属性,用于封装子表对应的实体类。
嵌套查询:
通过select标签定义查询主表的SQL,返回结果通过reusltMap进行映射。
在resultMap中,除了映射主表属性,还要通过collection标签映射子表属性,该标签需包含如下内容:
通过property属性指定子表属性名;
通过javaType属性指定封装子表属性的集合类型;
通过ofType属性指定子表的实体类型;
通过select属性指定查询子表所依赖的SQL,这个SQL需单独定义,内部包含查询子表的语句。
嵌套结果:
通过select标签定义关联查询主表和子表的SQL,返回结果通过resultMap进行映射。
在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 中添加如下代码:
二级缓存具有如下效果:
映射语句文件中的所有SELECT 语句将会被缓存。
映射语句文件中的所有时INSERT 、UPDATE 、DELETE 语句会刷新缓存。
缓存会使用Least Rece ntly U sed ( LRU ,最近最少使用的)算法来收回。
根据时间表(如no Flush Int erv al ,没有刷新间隔),缓存不会以任何时间顺序来刷新。
缓存会存储集合或对象(无论查询方法返回什么类型的值)的1024 个引用。
缓存会被视为read/write(可读/可写)的,意味着对象检索不是共享的,而且可以安全地被调用者修改,而不干扰其他调用者或线程所做的潜在修改。
5. 其他
5.1 cookie和session的区别是什么?
参考答案
存储位置不同:cookie存放于客户端;session存放于服务端。
存储容量不同:单个cookie保存的数据<=4KB,一个站点最多保存20个cookie;而session并没有上限。
存储方式不同:cookie只能保存ASCII字符串,并需要通过编码当时存储为Unicode字符或者二进制数据;session中能够存储任何类型的数据,例如字符串、整数、集合等。
隐私策略不同:cookie对客户端是可见的,别有用心的人可以分析存放在本地的cookie并进行cookie欺骗,所以它是不安全的;session存储在服务器上,对客户端是透明的,不存在敏感信息泄露的风险。
生命周期不同:可以通过设置cookie的属性,达到cookie长期有效的效果;session依赖于名为JSESSIONID的cookie,而该cookie的默认过期时间为-1,只需关闭窗口该session就会失效,因此session不能长期有效。
服务器压力不同:cookie保存在客户端,不占用服务器资源;session保管在服务器上,每个用户都会产生一个session,如果并发量大的话,则会消耗大量的服务器内存。
浏览器支持不同:cookie是需要浏览器支持的,如果客户端禁用了cookie,则会话跟踪就会失效;运用session就需要使用URL重写的方式,所有用到session的URL都要进行重写,否则session会话跟踪也会失效。
跨域支持不同: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 请求数据出现乱码该怎么处理?
参考答案
服务端出现请求乱码的原因是,客户端编码与服务器解码方案不一致,可以有如下几种解决办法:
将获得的数据按照客户端编码转成BYTE,再将BYTE按服务端编码转成字符串,这种方案对各种请求方式均有效,但是十分的麻烦。
在接受请求数据之前,显示声明实体内容的编码与服务器一致,这种方式只对POST请求有效。
修改服务器的配置文件,显示声明请求路径的编码与服务器一致,这种方式只对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可以用来做什么?
参考答案
Redis最常用来做缓存,是实现分布式缓存的首先中间件;
Redis可以作为数据库,实现诸如点赞、关注、排行等对性能要求极高的互联网需求;
Redis可以作为计算工具,能用很小的代价,统计诸如PV/UV、用户在线天数等数据;
Redis还有很多其他的使用场景,例如:可以实现分布式锁,可以作为消息队列使用。
1.2 Redis和传统的关系型数据库有什么不同?
参考答案
Redis是一种基于键值对的NoSQL数据库,而键值对的值是由多种数据结构和算法组成的。Redis的数据都存储于内存中,因此它的速度惊人,读写性能可达10万/秒,远超关系型数据库。
关系型数据库是基于二维数据表来存储数据的,它的数据格式更为严谨,并支持关系查询。关系型数据库的数据存储于磁盘上,可以存放海量的数据,但性能远不如Redis。
1.3 Redis有哪些数据类型?
参考答案
Redis支持5种核心的数据类型,分别是字符串、哈希、列表、集合、有序集合;
Redis还提供了Bitmap、HyperLogLog、Geo类型,但这些类型都是基于上述核心数据类型实现的;
Redis在5.0新增加了Streams数据类型,它是一个功能强大的、支持多播的、可持久化的消息队列。
1.4 Redis是单线程的,为什么还能这么快?
参考答案
对服务端程序来说,线程切换和锁通常是性能杀手,而单线程避免了线程切换和竞争所产生的消耗;
Redis的大部分操作是在内存上完成的,这是它实现高性能的一个重要原因;
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的过期时间?
参考答案
热点数据不设置过期时间,使其达到“物理”上的永不过期,可以避免缓存击穿问题;
在设置过期时间时,可以附加一个随机数,避免大量的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的任务执行结束后,它依然会尝试释放锁,因为它的代码逻辑就是任务结束后释放锁。但是,它的锁早已自动释放过了,它此时释放的可能是其他线程的锁。
想要解决这个问题,我们需要解决两件事情:
在加锁时就要给锁设置一个标识,进程要记住这个标识。当进程解锁的时候,要进行判断,是自己持有的锁才能释放,否则不能释放。可以为key赋一个随机值,来充当进程的标识。
解锁时要先判断、再释放,这两步需要保证原子性,否则第二步失败的话,就会出现死锁。而获取和删除命令不是原子的,这就需要采用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
文本协议格式具有如下的优点:
文本协议具有很好的兼容性;
直接采用文本协议格式,可以避免二次处理的开销;
文本协议具有可读性,方便直接修改和处理。
AOF持久化的文件同步机制:
为了提高程序的写入性能,现代操作系统会把针对硬盘的多次写操作优化为一次写操作。
当程序调用write对文件写入时,系统不会直接把书记写入硬盘,而是先将数据写入内存的缓冲区中;
当达到特定的时间周期或缓冲区写满时,系统才会执行flush操作,将缓冲区中的数据冲洗至硬盘中;
这种优化机制虽然提高了性能,但也给程序的写入操作带来了不确定性。
对于AOF这样的持久化功能来说,冲洗机制将直接影响AOF持久化的安全性;
为了消除上述机制的不确定性,Redis向用户提供了appendfsync选项,来控制系统冲洗AOF的频率;
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的高可用问题!
一组哨兵可以监控一个主节点,也可以同时监控多个主节点,两种情况的拓扑结构如下图:
哨兵节点包含如下的特征:
哨兵节点会定期监控数据节点,其他哨兵节点是否可达;
哨兵节点会将故障转移的结果通知给应用方;
哨兵节点可以将从节点晋升为主节点,并维护后续正确的主从关系;
哨兵模式下,客户端连接的是哨兵节点集合,从中获取主节点信息;
节点的故障判断是由多个哨兵节点共同完成的,可有效地防止误判;
哨兵节点集合是由多个哨兵节点组成的,即使个别哨兵节点不可用,整个集合依然是健壮的;
哨兵节点也是独立的Redis节点,是特殊的Redis节点,它们不存储数据,只支持部分命令。
集群:
Redis集群采用虚拟槽分区来实现数据分片,它把所有的键根据哈希函数映射到0-16383整数槽内,计算公式为slot=CRC16(key)&16383,每一个节点负责维护一部分槽以及槽所映射的键值数据。虚拟槽分区具有如下特点:
解耦数据和节点之间的关系,简化了节点扩容和收缩的难度;
节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据;
支持节点、槽、键之间的映射查询,用于数据路由,在线伸缩等场景。
Redis集群中数据的分片逻辑如下图:
1.13 Redis的主从同步是如何实现的?
参考答案
从2.8版本开始,Redis使用psync命令完成主从数据同步,同步过程分为全量复制和部分复制。全量复制一般用于初次复制的场景,部分复制则用于处理因网络中断等原因造成数据丢失的场景。psync命令需要以下参数的支持:
复制偏移量:主节点处理写命令后,会把命令长度做累加记录,从节点在接收到写命令后,也会做累加记录;从节点会每秒钟上报一次自身的复制偏移量给主节点,而主节点则会保存从节点的复制偏移量。
积压缓冲区:保存在主节点上的一个固定长度的队列,默认大小为1M,当主节点有连接的从节点时被创建;主节点处理写命令时,不但会把命令发送给从节点,还会写入积压缓冲区;缓冲区是先进先出的队列,可以保存最近已复制的数据,用于部分复制和命令丢失的数据补救。
主节点运行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种选项:
其中,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,而是采用了一种简单的贪心策略。该策略的删除逻辑如下:
从过期字典中随机选择20个key;
删除这20个key中已过期的key;
如果已过期key的比例超过25%,则重复步骤1。
1.17 缓存穿透、缓存击穿、缓存雪崩有什么区别,该如何解决?
参考答案
缓存穿透:
问题描述:
客户端查询根本不存在的数据,使得请求直达存储层,导致其负载过大,甚至宕机。出现这种情况的原因,可能是业务层误将缓存和库中的数据删除了,也可能是有人恶意攻击,专门访问库中不存在的数据。
解决方案:
缓存空对象:存储层未命中后,仍然将空值存入缓存层,客户端再次访问数据时,缓存层会直接返回空值。
布隆过滤器:将数据存入布隆过滤器,访问缓存之前以过滤器拦截,若请求的数据不存在则直接返回空值。
缓存击穿:
问题描述:
一份热点数据,它的访问量非常大。在其缓存失效的瞬间,大量请求直达存储层,导致服务崩溃。
解决方案:
永不过期:热点数据不设置过期时间,所以不会出现上述问题,这是“物理”上的永不过期。或者为每个数据设置逻辑过期时间,当发现该数据逻辑过期时,使用单独的线程重建缓存。
加互斥锁:对数据的访问加互斥锁,当一个线程访问该数据时,其他线程只能等待。这个线程访问过后,缓存中的数据将被重建,届时其他线程就可以直接从缓存中取值。
缓存雪崩:
问题描述:
在某一时刻,缓存层无法继续提供服务,导致所有的请求直达存储层,造成数据库宕机。可能是缓存中有大量数据同时过期,也可能是Redis节点发生故障,导致大量请求无法得到处理。
解决方案:
避免数据同时过期:设置过期时间时,附加一个随机数,避免大量的key同时过期。
启用降级和熔断措施:在发生雪崩时,若应用访问的不是核心数据,则直接返回预定义信息/空值/错误信息。或者在发生雪崩时,对于访问缓存接口的请求,客户端并不会把请求发给Redis,而是直接返回。
构建高可用的Redis服务:采用哨兵或集群模式,部署多个Redis实例,个别节点宕机,依然可以保持服务的整体可用。
1.18 如何保证缓存与数据库的双写一致性?
参考答案
四种同步策略:
想要保证缓存与数据库的双写一致,一共有4种方式,即4种同步策略:
先更新缓存,再更新数据库;
先更新数据库,再更新缓存;
先删除缓存,再更新数据库;
先更新数据库,再删除缓存。
从这4种同步策略中,我们需要作出比较的是:
更新缓存与删除缓存哪种方式更合适?
应该先操作数据库还是先操作缓存?
更新缓存还是删除缓存:
下面,我们来分析一下,应该采用更新缓存还是删除缓存的方式。
更新缓存 优点:每次数据变化都及时更新缓存,所以查询时不容易出现未命中的情况。 缺点:更新缓存的消耗比较大。如果数据需要经过复杂的计算再写入缓存,那么频繁的更新缓存,就会影响服务器的性能。如果是写入数据频繁的业务场景,那么可能频繁的更新缓存时,却没有业务读取该数据。
删除缓存 优点:操作简单,无论更新操作是否复杂,都是将缓存中的数据直接删除。 缺点:删除缓存后,下一次查询缓存会出现未命中,这时需要重新读取一次数据库。
从上面的比较来看,一般情况下,删除缓存是更优的方案。
先操作数据库还是缓存:
下面,我们再来分析一下,应该先操作数据库还是先操作缓存。
首先,我们将先删除缓存与先更新数据库,在出现失败时进行一个对比:
如上图,是先删除缓存再更新数据库,在出现失败时可能出现的问题:
进程A删除缓存成功;
进程A更新数据库失败;
进程B从缓存中读取数据;
由于缓存被删,进程B无法从缓存中得到数据,进而从数据库读取数据;
进程B从数据库成功获取数据,然后将数据更新到了缓存。
最终,缓存和数据库的数据是一致的,但仍然是旧的数据。而我们的期望是二者数据一致,并且是新的数据。
如上图,是先更新数据库再删除缓存,在出现失败时可能出现的问题:
进程A更新数据库成功;
进程A删除缓存失败;
进程B读取缓存成功,由于缓存删除失败,所以进程B读取到的是旧的数据。
最终,缓存和数据库的数据是不一致的。
经过上面的比较,我们发现在出现失败的时候,是无法明确分辨出先删缓存和先更新数据库哪个方式更好,以为它们都存在问题。后面我们会进一步对这两种方式进行比较,但是在这里我们先探讨一下,上述场景出现的问题,应该如何解决呢?
实际上,无论上面我们采用哪种方式去同步缓存与数据库,在第二步出现失败的时候,都建议采用重试机制解决,因为最终我们是要解决掉这个错误的。而为了避免重试机制影响主要业务的执行,一般建议重试机制采用异步的方式执行,如下图:
这里我们按照先更新数据库,再删除缓存的方式,来说明重试机制的主要步骤:
更新数据库成功;
删除缓存失败;
将此数据加入消息队列;
业务代码消费这条消息;
业务代码根据这条消息的内容,发起重试机制,即从缓存中删除这条记录。
好了,下面我们再将先删缓存与先更新数据库,在没有出现失败时进行对比:
如上图,是先删除缓存再更新数据库,在没有出现失败时可能出现的问题:
进程A删除缓存成功;
进程B读取缓存失败;
进程B读取数据库成功,得到旧的数据;
进程B将旧的数据成功地更新到了缓存;
进程A将新的数据成功地更新到数据库。
可见,进程A的两步操作均成功,但由于存在并发,在这两步之间,进程B访问了缓存。最终结果是,缓存中存储了旧的数据,而数据库中存储了新的数据,二者数据不一致。
如上图,是先更新数据库再删除缓存,再没有出现失败时可能出现的问题:
进程A更新数据库成功;
进程B读取缓存成功;
进程A更新数据库成功。
可见,最终缓存与数据库的数据是一致的,并且都是最新的数据。但进程B在这个过程里读到了旧的数据,可能还有其他进程也像进程B一样,在这两步之间读到了缓存中旧的数据,但因为这两步的执行速度会比较快,所以影响不大。对于这两步之后,其他进程再读取缓存数据的时候,就不会出现类似于进程B的问题了。
最终结论:
经过对比你会发现,先更新数据库、再删除缓存是影响更小的方案。如果第二步出现失败的情况,则可以采用重试机制解决问题。
扩展阅读
延时双删
上面我们提到,如果是先删缓存、再更新数据库,在没有出现失败时可能会导致数据的不一致。如果在实际的应用中,出于某些考虑我们需要选择这种方式,那有办法解决这个问题吗?答案是有的,那就是采用延时双删的策略,延时双删的基本思路如下:
删除缓存;
更新数据库;
sleep N毫秒;
再次删除缓存。
阻塞一段时间之后,再次删除缓存,就可以把这个过程中缓存中不一致的数据删除掉。而具体的时间,要评估你这项业务的大致时间,按照这个时间来设定即可。
采用读写分离的架构怎么办?
如果数据库采用的是读写分离的架构,那么又会出现新的问题,如下图:
进程A先删除缓存,再更新主数据库,然后主库将数据同步到从库。而在主从数据库同步之前,可能会有进程B访问了缓存,发现数据不存在,进而它去访问从库获取到旧的数据,然后同步到缓存。这样,最终也会导致缓存与数据库的数据不一致。这个问题的解决方案,依然是采用延时双删的策略,但是在评估延长时间的时候,要考虑到主从数据库同步的时间。
第二次删除失败了怎么办?
如果第二次删除依然失败,则可以增加重试的次数,但是这个次数要有限制,当超出一定的次数时,要采取报错、记日志、发邮件提醒等措施。
1.19 请介绍Redis集群的实现方案
参考答案
Redis集群的分区方案:
Redis集群采用虚拟槽分区来实现数据分片,它把所有的键根据哈希函数映射到0-16383整数槽内,计算公式为slot=CRC16(key)&16383,每一个节点负责维护一部分槽以及槽所映射的键值数据。虚拟槽分区具有如下特点:
解耦数据和节点之间的关系,简化了节点扩容和收缩的难度;
节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据;
支持节点、槽、键之间的映射查询,用于数据路由,在线伸缩等场景。
Redis集群中数据的分片逻辑如下图:
Redis集群的功能限制:
Redis集群方案在扩展了Redis处理能力的同时,也带来了一些使用上的限制:
key批量操作支持有限。如mset、mget,目前只支持具有相同slot值的key执行批量操作。对于映射为不同slot值的key由于执行mset、mget等操作可能存在于多个节点上所以不被支持。
key事务操作支持有限。同理只支持多key在同一节点上的事务操作,当多个key分布在不同的节点上时无法使用事务功能。
key作为数据分区的最小粒度,因此不能将一个大的键值对象(如hash、list等)映射到不同的节点。
不支持多数据库空间。单机下的Redis可以支持16个数据库,集群模式下只能使用一个数据库空间,即DB0。
复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构。
Redis集群的通信方案:
在分布式存储中需要提供维护节点元数据信息的机制,所谓元数据是指:节点负责哪些数据,是否出现故障等状态信息。常见的元数据维护方式分为:集中式和P2P方式。
Redis集群采用P2P的Gossip(流言)协议,Gossip协议的工作原理就是节点彼此不断通信交换信息,一段时间后所有的节点都会知道集群完整的信息,这种方式类似流言传播。通信的大致过程如下:
集群中每个节点都会单独开辟一个TCP通道,用于节点之间彼此通信,通信端口号在基础端口号上加10000;
每个节点再固定周期内通过特定规则选择几个节点发送ping消息;
接收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,每一个节点负责维护一部分槽以及槽所映射的键值数据。虚拟槽分区具有如下特点:
解耦数据和节点之间的关系,简化了节点扩容和收缩的难度;
节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据;
支持节点、槽、键之间的映射查询,用于数据路由,在线伸缩等场景。
Redis集群中数据的分片逻辑如下图:
1.21 说一说Redis集群的应用和优劣势
参考答案
优势:
Redis Cluster是Redis的分布式解决方案,在3.0版本正式推出,有效地解决了Redis分布式方面的需求。当遇到单机内存、并发、流量等瓶颈时,可以采用Cluster架构方案达到负载均衡的目的。
劣势:
Redis集群方案在扩展了Redis处理能力的同时,也带来了一些使用上的限制:
key批量操作支持有限。如mset、mget,目前只支持具有相同slot值的key执行批量操作。对于映射为不同slot值的key由于执行mset、mget等操作可能存在于多个节点上所以不被支持。
key事务操作支持有限。同理只支持多key在同一节点上的事务操作,当多个key分布在不同的节点上时无法使用事务功能。
key作为数据分区的最小粒度,因此不能将一个大的键值对象(如hash、list等)映射到不同的节点。
不支持多数据库空间。单机下的Redis可以支持16个数据库,集群模式下只能使用一个数据库空间,即DB0。
复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构。
1.22 说一说hash类型底层的数据结构
参考答案
哈希对象有两种编码方案,当同时满足以下条件时,哈希对象采用ziplist编码,否则采用hashtable编码:
哈希对象保存的键值对数量小于512个;
哈希对象保存的所有键值对中的键和值,其字符串长度都小于64字节。
其中,ziplist编码采用压缩列表作为底层实现,而hashtable编码采用字典作为底层实现。
压缩列表:
压缩列表(ziplist),是Redis为了节约内存而设计的一种线性数据结构,它是由一系列具有特殊编码的连续内存块构成的。一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数组或一个整数值。
压缩列表的结构如下图所示:
该结构当中的字段含义如下表所示:
其中,压缩列表的节点由以下字段构成:
previous_entry_length(pel)属性以字节为单位,记录当前节点的前一节点的长度,其自身占据1字节或5字节:
如果前一节点的长度小于254字节,则“pel”属性的长度为1字节,前一节点的长度就保存在这一个字节内;
如果前一节点的长度达到254字节,则“pel”属性的长度为5字节,其中第一个字节被设置为0xFE,之后的四个字节用来保存前一节点的长度;
基于“pel”属性,程序便可以通过指针运算,根据当前节点的起始地址计算出前一节点的起始地址,从而实现从表尾向表头的遍历操作。
content属性负责保存节点的值(字节数组或整数),其类型和长度则由encoding属性决定,它们的关系如下:
字典:
字典(dict)又称为散列表,是一种用来存储键值对的数据结构。C语言没有内置这种数据结构,所以Redis构建了自己的字典实现。
Redis字典的实现主要涉及三个结构体:字典、哈希表、哈希表节点。其中,每个哈希表节点保存一个键值对,每个哈希表由多个哈希表节点构成,而字典则是对哈希表的进一步封装。这三个结构体的关系如下图所示:
其中,dict代表字典,dictht代表哈希表,dictEntry代表哈希表节点。可以看出,dictEntry是一个数组,这很好理解,因为一个哈希表里要包含多个哈希表节点。而dict里包含2个dictht,多出的哈希表用于REHASH。当哈希表保存的键值对数量过多或过少时,需要对哈希表的大小进行扩展或收缩操作,在Redis中,扩展和收缩哈希表是通过REHASH实现的,执行REHASH的大致步骤如下:
为字典的ht[1]哈希表分配内存空间 如果执行的是扩展操作,则ht[1]的大小为第1个大于等于ht[0].used*2的2n。如果执行的是收缩操作,则ht[1]的大小为第1个大于等于ht[0].used的2n。
将存储在ht[0]中的数据迁移到ht[1]上 重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上。
将字典的ht[1]哈希表晋升为默认哈希表 迁移完成后,清空ht[0],再交换ht[0]和ht[1]的值,为下一次REHASH做准备。
当满足以下任何一个条件时,程序会自动开始对哈希表执行扩展操作:
服务器目前没有执行bgsave或bgrewriteof命令,并且哈希表的负载因子大于等于1;
服务器目前正在执行bgsave或bgrewriteof命令,并且哈希表的负载因子大于等于5。
为了避免REHASH对服务器性能造成影响,REHASH操作不是一次性地完成的,而是分多次、渐进式地完成的。渐进式REHASH的详细过程如下:
为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表;
在字典中的索引计数器rehashidx设置为0,表示REHASH操作正式开始;
在REHASH期间,每次对字典执行添加、删除、修改、查找操作时,程序除了执行指定的操作外,还会顺带将ht[0]中位于rehashidx上的所有键值对迁移到ht[1]中,再将rehashidx的值加1;
随着字典不断被访问,最终在某个时刻,ht[0]上的所有键值对都被迁移到ht[1]上,此时程序将rehashidx属性值设置为-1,标识REHASH操作完成。
REHSH期间,字典同时持有两个哈希表,此时的访问将按照如下原则处理:
新添加的键值对,一律被保存到ht[1]中;
删除、修改、查找等其他操作,会在两个哈希表上进行,即程序先尝试去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为了节约内存而设计的一种线性数据结构,它是由一系列具有特殊编码的连续内存块构成的。一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数组或一个整数值。
压缩列表的结构如下图所示:
该结构当中的字段含义如下表所示:
其中,压缩列表的节点由以下字段构成:
previous_entry_length(pel)属性以字节为单位,记录当前节点的前一节点的长度,其自身占据1字节或5字节:
如果前一节点的长度小于254字节,则“pel”属性的长度为1字节,前一节点的长度就保存在这一个字节内;
如果前一节点的长度达到254字节,则“pel”属性的长度为5字节,其中第一个字节被设置为0xFE,之后的四个字节用来保存前一节点的长度;
基于“pel”属性,程序便可以通过指针运算,根据当前节点的起始地址计算出前一节点的起始地址,从而实现从表尾向表头的遍历操作。
content属性负责保存节点的值(字节数组或整数),其类型和长度则由encoding属性决定,它们的关系如下:
字典:
字典(dict)又称为散列表,是一种用来存储键值对的数据结构。C语言没有内置这种数据结构,所以Redis构建了自己的字典实现。
Redis字典的实现主要涉及三个结构体:字典、哈希表、哈希表节点。其中,每个哈希表节点保存一个键值对,每个哈希表由多个哈希表节点构成,而字典则是对哈希表的进一步封装。这三个结构体的关系如下图所示:
其中,dict代表字典,dictht代表哈希表,dictEntry代表哈希表节点。可以看出,dictEntry是一个数组,这很好理解,因为一个哈希表里要包含多个哈希表节点。而dict里包含2个dictht,多出的哈希表用于REHASH。当哈希表保存的键值对数量过多或过少时,需要对哈希表的大小进行扩展或收缩操作,在Redis中,扩展和收缩哈希表是通过REHASH实现的,执行REHASH的大致步骤如下:
为字典的ht[1]哈希表分配内存空间 如果执行的是扩展操作,则ht[1]的大小为第1个大于等于ht[0].used*2的2n。如果执行的是收缩操作,则ht[1]的大小为第1个大于等于ht[0].used的2n。
将存储在ht[0]中的数据迁移到ht[1]上 重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上。
将字典的ht[1]哈希表晋升为默认哈希表 迁移完成后,清空ht[0],再交换ht[0]和ht[1]的值,为下一次REHASH做准备。
当满足以下任何一个条件时,程序会自动开始对哈希表执行扩展操作:
服务器目前没有执行bgsave或bgrewriteof命令,并且哈希表的负载因子大于等于1;
服务器目前正在执行bgsave或bgrewriteof命令,并且哈希表的负载因子大于等于5。
为了避免REHASH对服务器性能造成影响,REHASH操作不是一次性地完成的,而是分多次、渐进式地完成的。渐进式REHASH的详细过程如下:
为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表;
在字典中的索引计数器rehashidx设置为0,表示REHASH操作正式开始;
在REHASH期间,每次对字典执行添加、删除、修改、查找操作时,程序除了执行指定的操作外,还会顺带将ht[0]中位于rehashidx上的所有键值对迁移到ht[1]中,再将rehashidx的值加1;
随着字典不断被访问,最终在某个时刻,ht[0]上的所有键值对都被迁移到ht[1]上,此时程序将rehashidx属性值设置为-1,标识REHASH操作完成。
REHSH期间,字典同时持有两个哈希表,此时的访问将按照如下原则处理:
新添加的键值对,一律被保存到ht[1]中;
删除、修改、查找等其他操作,会在两个哈希表上进行,即程序先尝试去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里存一份代表锁的数据,通常用字符串即可。实现分布式锁的思路,以及优化的过程如下:
加锁: 第一版,这种方式的缺点是容易产生死锁,因为客户端有可能忘记解锁,或者解锁失败。 setnx key value 第二版,给锁增加了过期时间,避免出现死锁。但这两个命令不是原子的,第二步可能会失败,依然无法避免死锁问题。 setnx key value expire key seconds 第三版,通过“set...nx...”命令,将加锁、过期命令编排到一起,它们是原子操作了,可以避免死锁。 set key value nx ex seconds
解锁: 解锁就是删除代表锁的那份数据。 del key
问题: 看起来已经很完美了,但实际上还有隐患,如下图。进程A在任务没有执行完毕时,锁已经到期被释放了。等进程A的任务执行结束后,它依然会尝试释放锁,因为它的代码逻辑就是任务结束后释放锁。但是,它的锁早已自动释放过了,它此时释放的可能是其他线程的锁。
想要解决这个问题,我们需要解决两件事情:
在加锁时就要给锁设置一个标识,进程要记住这个标识。当进程解锁的时候,要进行判断,是自己持有的锁才能释放,否则不能释放。可以为key赋一个随机值,来充当进程的标识。
解锁时要先判断、再释放,这两步需要保证原子性,否则第二步失败的话,就会出现死锁。而获取和删除命令不是原子的,这就需要采用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 说一说你对布隆过滤器的理解
参考答案
布隆过滤器可以用很低的代价,估算出数据是否真实存在。例如:给用户推荐新闻时,要去掉重复的新闻,就可以利用布隆过滤器,判断该新闻是否已经推荐过。
布隆过滤器的核心包括两部分:
一个大型的位数组;
若干个不一样的哈希函数,每个哈希函数都能将哈希值算的比较均匀。
布隆过滤器的工作原理:
添加key时,每个哈希函数都利用这个key计算出一个哈希值,再根据哈希值计算一个位置,并将位数组中这个位置的值设置为1。
询问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,每一个节点负责维护一部分槽以及槽所映射的键值数据。虚拟槽分区具有如下特点:
解耦数据和节点之间的关系,简化了节点扩容和收缩的难度;
节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据;
支持节点、槽、键之间的映射查询,用于数据路由,在线伸缩等场景。
Redis集群中数据的分片逻辑如下图:
1.28 如果并发量超过30万,怎么设计Redis架构?
参考答案
Redis Cluster是Redis的分布式解决方案,在3.0版本正式推出,有效地解决了Redis分布式方面的需求。当遇到单机内存、并发、流量等瓶颈时,可以采用Cluster架构方案达到负载均衡的目的。
Redis集群采用虚拟槽分区来实现数据分片,它把所有的键根据哈希函数映射到0-16383整数槽内,计算公式为slot=CRC16(key)&16383,每一个节点负责维护一部分槽以及槽所映射的键值数据。虚拟槽分区具有如下特点:
解耦数据和节点之间的关系,简化了节点扩容和收缩的难度;
节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据;
支持节点、槽、键之间的映射查询,用于数据路由,在线伸缩等场景。
Redis集群中数据的分片逻辑如下图:
2. 消息队列
2.1 MQ有什么用?
参考答案
消息队列有很多使用场景,比较常见的有3个:解耦、异步、削峰。
解耦:传统的软件开发模式,各个模块之间相互调用,数据共享,每个模块都要时刻关注其他模块的是否更改或者是否挂掉等等,使用消息队列,可以避免模块之间直接调用,将所需共享的数据放在消息队列中,对于新增业务模块,只要对该类消息感兴趣,即可订阅该类消息,对原有系统和业务没有任何影响,降低了系统各个模块的耦合度,提高了系统的可扩展性。
异步:消息队列提供了异步处理机制,在很多时候应用不想也不需要立即处理消息,允许应用把一些消息放入消息中间件中,并不立即处理它,在之后需要的时候再慢慢处理。
削峰:在访问量骤增的场景下,需要保证应用系统的平稳性,但是这样突发流量并不常见,如果以这类峰值的标准而投放资源的话,那无疑是巨大的浪费。使用消息队列能够使关键组件支撑突发访问压力,不会因为突发的超负荷请求而完全崩溃。消息队列的容量可以配置的很大,如果采用磁盘存储消息,则几乎等于“无限”容量,这样一来,高峰期的消息可以被积压起来,在随后的时间内进行平滑的处理完成,而不至于让系统短时间内无法承载而导致崩溃。在电商网站的秒杀抢购这种突发性流量很强的业务场景中,消息队列的强大缓冲能力可以很好的起到削峰作用。
2.2 说一说生产者与消费者模式
参考答案
所谓生产者-消费者问题,实际上主要是包含了两类线程。一种是生产者线程用于生产数据,另一种是消费者线程用于消费数据,为了解耦生产者和消费者的关系,通常会采用共享的数据区域,就像是一个仓库。生产者生产数据之后直接放置在共享数据区中,并不需要关心消费者的行为。而消费者只需要从共享数据区中去获取数据,就不再需要关心生产者的行为。但是,这个共享数据区域中应该具备这样的线程间并发协作的功能:
如果共享数据区已满的话,阻塞生产者继续生产数据放置入内;
如果共享数据区为空的话,阻塞消费者继续消费数据。
在Java语言中,实现生产者消费者问题时,可以采用三种方式:
使用 Object 的 wait/notify 的消息通知机制;
使用 Lock 的 Condition 的 await/signal 的消息通知机制;
使用 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丢失消息分为如下几种情况:
生产者丢消息: 生产者将数据发送到RabbitMQ的时候,可能在传输过程中因为网络等问题而将数据弄丢了。
RabbitMQ自己丢消息: 如果没有开启RabbitMQ的持久化,那么RabbitMQ一旦重启数据就丢了。所以必须开启持久化将消息持久化到磁盘,这样就算RabbitMQ挂了,恢复之后会自动读取之前存储的数据,一般数据不会丢失。除非极其罕见的情况,RabbitMQ还没来得及持久化自己就挂了,这样可能导致一部分数据丢失。
消费端丢消息: 主要是因为消费者消费时,刚消费到还没有处理,结果消费者就挂了,这样你重启之后,RabbitMQ就认为你已经消费过了,然后就丢了数据。
针对上述三种情况,RabbitMQ可以采用如下方式避免消息丢失:
生产者丢消息:
可以选择使用RabbitMQ提供是事务功能,就是生产者在发送数据之前开启事务,然后发送消息,如果消息没有成功被RabbitMQ接收到,那么生产者会受到异常报错,这时就可以回滚事务,然后尝试重新发送。如果收到了消息,那么就可以提交事务。这种方式有明显的缺点,即RabbitMQ事务开启后,就会变为同步阻塞操作,生产者会阻塞等待是否发送成功,太耗性能会造成吞吐量的下降。
可以开启confirm模式。在生产者那里设置开启了confirm模式之后,每次写的消息都会分配一个唯一的id,然后如何写入了RabbitMQ之中,RabbitMQ会给你回传一个ack消息,告诉你这个消息发送OK了。如果RabbitMQ没能处理这个消息,会回调你一个nack接口,告诉你这个消息失败了,你可以进行重试。而且你可以结合这个机制知道自己在内存里维护每个消息的id,如果超过一定时间还没接收到这个消息的回调,那么你可以进行重发。
事务机制是同步的,你提交了一个事物之后会阻塞住,但是confirm机制是异步的,发送消息之后可以接着发送下一个消息,然后RabbitMQ会回调告知成功与否。 一般在生产者这块避免丢失,都是用confirm机制。
RabbitMQ自己丢消息: 设置消息持久化到磁盘,设置持久化有两个步骤:
创建queue的时候将其设置为持久化的,这样就可以保证RabbitMQ持久化queue的元数据,但是不会持久化queue里面的数据。
发送消息的时候讲消息的deliveryMode设置为2,这样消息就会被设为持久化方式,此时RabbitMQ就会将消息持久化到磁盘上。 必须要同时开启这两个才可以。
而且持久化可以跟生产的confirm机制配合起来,只有消息持久化到了磁盘之后,才会通知生产者ack,这样就算是在持久化之前RabbitMQ挂了,数据丢了,生产者收不到ack回调也会进行消息重发。
消费端丢消息: 使用RabbitMQ提供的ack机制,首先关闭RabbitMQ的自动ack,然后每次在确保处理完这个消息之后,在代码里手动调用ack。这样就可以避免消息还没有处理完就ack。
Kafka:
Kafka丢失消息分为如下几种情况:
生产者丢消息: 生产者没有设置相应的策略,发送过程中丢失数据。
Kafka自己丢消息: 比较常见的一个场景,就是Kafka的某个broker宕机了,然后重新选举partition的leader时。如果此时follower还没来得及同步数据,leader就挂了,然后某个follower成为了leader,它就少了一部分数据。
消费端丢消息: 消费者消费到了这个数据,然后消费之自动提交了offset,让Kafka知道你已经消费了这个消息,当你准备处理这个消息时,自己挂掉了,那么这条消息就丢了。
针对上述三种情况,Kafka可以采用如下方式避免消息丢失:
生产者丢消息: 关闭自动提交offset,在自己处理完毕之后手动提交offset,这样就不会丢失数据。
Kafka自己丢消息: 一般要求设置4个参数来保证消息不丢失:
给topic设置 replication.factor 参数,这个值必须大于1,表示要求每个partition必须至少有2个副本。
在kafka服务端设置 min.isync.replicas 参数,这个值必须大于1,表示 要求一个leader至少感知到有至少一个follower在跟自己保持联系正常同步数据,这样才能保证leader挂了之后还有一个follower。
在生产者端设置 acks=all ,表示 要求每条每条数据,必须是写入所有replica副本之后,才能认为是写入成功了。
在生产者端设置 retries=MAX (很大的一个值),表示这个是要求一旦写入事变,就无限重试。
消费端丢消息: 如果按照上面设置了ack=all,则一定不会丢失数据,要求是,你的leader接收到消息,所有的follower都同步到了消息之后,才认为本次写成功了。如果没满足这个条件,生产者会自动不断的重试,重试无限次。
2.5 消息队列如何保证不重复消费?
参考答案
先大概说一说可能会有哪些重复消费的问题。首先就是比如rabbitmq、rocketmq、kafka,都有可能会出现消费重复消费的问题,正常。因为这问题通常不是mq自己保证的,是给你保证的。然后我们挑一个kafka来举个例子,说说怎么重复消费吧。
kafka实际上有个offset的概念,就是每个消息写进去,都有一个offset,代表他的序号,然后consumer消费了数据之后,每隔一段时间,会把自己消费过的消息的offset提交一下,代表我已经消费过了,下次我要是重启啥的,你就让我继续从上次消费到的offset来继续消费吧。
但是凡事总有意外,比如我们之前生产经常遇到的,就是你有时候重启系统,看你怎么重启了,如果碰到点着急的,直接kill进程了,再重启。这会导致consumer有些消息处理了,但是没来得及提交offset,尴尬了。重启之后,少数消息会再次消费一次。
其实重复消费不可怕,可怕的是你没考虑到重复消费之后,怎么保证幂等性。举个例子,假设你有个系统,消费一条往数据库里插入一条,要是你一个消息重复两次,你不就插入了两条,这数据不就错了?但是你要是消费到第二次的时候,自己判断一下已经消费过了,直接扔了,不就保留了一条数据?
一条数据重复出现两次,数据库里就只有一条数据,这就保证了系统的幂等性幂等性。通俗点说,就一个数据,或者一个请求,给你重复来多次,你得确保对应的数据是不会改变的,不能出错。
想要保证不重复消费,其实还要结合业务来思考,这里给几个思路:
比如你拿个数据要写库,你先根据主键查一下,如果这数据都有了,你就别插入了,update一下。
比如你是写redis,那没问题了,反正每次都是set,天然幂等性。
比如你不是上面两个场景,那做的稍微复杂一点,你需要让生产者发送每条数据的时候,里面加一个全局唯一的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保证每条消息最少送达一次,有较小的概率会出现数据重复发送的情况。详细来说,它们之间主要有如下的区别:
应用场景方面 RabbitMQ:用于实时的,对可靠性要求较高的消息传递上。 Kafka:用于处于活跃的流式数据,大数据量的数据处理上。
架构模型方面 RabbitMQ:以broker为中心,有消息的确认机制。 Kafka:以consumer为中心,没有消息的确认机制。
吞吐量方面 RabbitMQ:支持消息的可靠的传递,支持事务,不支持批量操作,基于存储的可靠性的要求存储可以采用内存或硬盘,吞吐量小。 Kafka:内部采用消息的批量处理,数据的存储和获取是本地磁盘顺序批量操作,消息处理的效率高,吞吐量高。
集群负载均衡方面 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时间会变得很长,使用磁盘操作有以下几个好处:
磁盘顺序读写速度超过内存随机读写;
JVM的GC效率低,内存占用大。使用磁盘可以避免这一问题;
系统冷启动后,磁盘缓存依然可用。
下图就展示了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可能丢失消息分为如下几种情况:
生产者丢消息: 生产者将数据发送到RabbitMQ的时候,可能在传输过程中因为网络等问题而将数据弄丢了。
RabbitMQ自己丢消息: 如果没有开启RabbitMQ的持久化,那么RabbitMQ一旦重启数据就丢了。所以必须开启持久化将消息持久化到磁盘,这样就算RabbitMQ挂了,恢复之后会自动读取之前存储的数据,一般数据不会丢失。除非极其罕见的情况,RabbitMQ还没来得及持久化自己就挂了,这样可能导致一部分数据丢失。
消费端丢消息: 主要是因为消费者消费时,刚消费到还没有处理,结果消费者就挂了,这样你重启之后,RabbitMQ就认为你已经消费过了,然后就丢了数据。
针对上述三种情况,RabbitMQ可以采用如下方式避免消息丢失:
生产者丢消息:
可以选择使用RabbitMQ提供是事务功能,就是生产者在发送数据之前开启事务,然后发送消息,如果消息没有成功被RabbitMQ接收到,那么生产者会受到异常报错,这时就可以回滚事务,然后尝试重新发送。如果收到了消息,那么就可以提交事务。这种方式有明显的缺点,即RabbitMQ事务开启后,就会变为同步阻塞操作,生产者会阻塞等待是否发送成功,太耗性能会造成吞吐量的下降。
可以开启confirm模式。在生产者那里设置开启了confirm模式之后,每次写的消息都会分配一个唯一的id,然后如何写入了RabbitMQ之中,RabbitMQ会给你回传一个ack消息,告诉你这个消息发送OK了。如果RabbitMQ没能处理这个消息,会回调你一个nack接口,告诉你这个消息失败了,你可以进行重试。而且你可以结合这个机制知道自己在内存里维护每个消息的id,如果超过一定时间还没接收到这个消息的回调,那么你可以进行重发。
事务机制是同步的,你提交了一个事物之后会阻塞住,但是confirm机制是异步的,发送消息之后可以接着发送下一个消息,然后RabbitMQ会回调告知成功与否。 一般在生产者这块避免丢失,都是用confirm机制。
RabbitMQ自己丢消息: 设置消息持久化到磁盘,设置持久化有两个步骤:
创建queue的时候将其设置为持久化的,这样就可以保证RabbitMQ持久化queue的元数据,但是不会持久化queue里面的数据。
发送消息的时候讲消息的deliveryMode设置为2,这样消息就会被设为持久化方式,此时RabbitMQ就会将消息持久化到磁盘上。 必须要同时开启这两个才可以。
而且持久化可以跟生产的confirm机制配合起来,只有消息持久化到了磁盘之后,才会通知生产者ack,这样就算是在持久化之前RabbitMQ挂了,数据丢了,生产者收不到ack回调也会进行消息重发。
消费端丢消息: 使用RabbitMQ提供的ack机制,首先关闭RabbitMQ的自动ack,然后每次在确保处理完这个消息之后,在代码里手动调用ack。这样就可以避免消息还没有处理完就ack。
3. 搜索引擎
3.1 说说ElasticSearch put的全过程
参考答案
put过程主要分为三个阶段:
协调阶段: Client 客户端选择一个 node 发送 put 请求,此时当前节点就是协调节点(coordinating node)。协调节点根据 document 的 id 进行路由,将请求转发给对应的 node。这个 node 上的是 primary shard 。
主要阶段: 对应的 primary shard 处理请求,写入数据 ,然后将数据同步到 replica shard。
primary shard 会验证传入的数据结构;
本地执行相关操作;
将操作转发给 replica shard。
当数据写入 primary shard 和 replica shard 成功后,路由节点返回响应给 Client。
副本阶段: 每个 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 域包含如下内容:
The quick brown fox jumped over the lazy dog
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更为丰富的查询语言,同时实现了可配置、可扩展并对查询性能进行了优化,并且提供了一个完善的功能管理界面,是一款非常优秀的全文搜索引擎。
1. 分布式
1.1 什么是CAP原则?
参考答案
CAP定理又称CAP原则,指的是在一个分布式系统中,Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),最多只能同时三个特性中的两个,三者不可兼得。
Consistency (一致性): “all nodes see the same data at the same time”,即更新操作成功并返回客户端后,所有节点在同一时间的数据完全一致,这就是分布式的一致性。一致性的问题在并发系统中不可避免,对于客户端来说,一致性指的是并发访问时更新过的数据如何获取的问题。从服务端来看,则是更新如何复制分布到整个系统,以保证数据最终一致。
Availability (可用性): 可用性指“Reads and writes always succeed”,即服务一直可用,而且是正常响应时间。好的可用性主要是指系统能够很好的为用户服务,不出现用户操作失败或者访问超时等用户体验不好的情况。
Partition Tolerance (分区容错性): 即分布式系统在遇到某节点或网络分区故障的时候,仍然能够对外提供满足一致性和可用性的服务。分区容错性要求能够使应用虽然是一个分布式系统,而看上去却好像是在一个可以运转正常的整体。比如现在的分布式系统中有某一个或者几个机器宕掉了,其他剩下的机器还能够正常运转满足系统需求,对于用户而言并没有什么体验上的影响。
1.2 说一说你对高并发的理解
参考答案
1. 如何理解高并发?
高并发意味着大流量,需要运用技术手段抵抗流量的冲击,这些手段好比操作流量,能让流量更平稳地被系统所处理,带给用户更好的体验。我们常见的高并发场景有:淘宝的双11、春运时的抢票、微博大V的热点新闻等。除了这些典型事情,每秒几十万请求的秒杀系统、每天千万级的订单系统、每天亿级日活的信息流系统等,都可以归为高并发。很显然,上面谈到的高并发场景,并发量各不相同,那到底多大并发才算高并发呢?
不能只看数字,要看具体的业务场景。不能说10W QPS的秒杀是高并发,而1W QPS的信息流就不是高并发。信息流场景涉及复杂的推荐模型和各种人工策略,它的业务逻辑可能比秒杀场景复杂10倍不止。因此,不在同一个维度,没有任何比较意义。
业务都是从0到1做起来的,并发量和QPS只是参考指标,最重要的是:在业务量逐渐变成原来的10倍、100倍的过程中,你是否用到了高并发的处理方法去演进你的系统,从架构设计、编码实现、甚至产品方案等维度去预防和解决高并发引起的问题?而不是一味的升级硬件、加机器做水平扩展。
此外,各个高并发场景的业务特点完全不同:有读多写少的信息流场景、有读多写多的交易场景,那是否有通用的技术方案解决不同场景的高并发问题呢?我觉得大的思路可以借鉴,别人的方案也可以参考,但是真正落地过程中,细节上还会有无数的坑。另外,由于软硬件环境、技术栈、以及产品逻辑都没法做到完全一致,这些都会导致同样的业务场景,就算用相同的技术方案也会面临不同的问题,这些坑还得一个个趟。
2. 高并发系统设计的目标是什么?
先搞清楚高并发系统设计的目标,在此基础上再讨论设计方案和实践经验才有意义和针对性。
2.1 宏观目标
高并发绝不意味着只追求高性能,这是很多人片面的理解。从宏观角度看,高并发系统设计的目标有三个:高性能、高可用,以及高可扩展。
高性能:性能体现了系统的并行处理能力,在有限的硬件投入下,提高性能意味着节省成本。同时,性能也反映了用户体验,响应时间分别是100毫秒和1秒,给用户的感受是完全不同的。
高可用:表示系统可以正常服务的时间。一个全年不停机、无故障;另一个隔三差五出线上事故、宕机,用户肯定选择前者。另外,如果系统只能做到90%可用,也会大大拖累业务。
高扩展:表示系统的扩展能力,流量高峰时能否在短时间内完成扩容,更平稳地承接峰值流量,比如双11活动、明星离婚等热点事件。
这3个目标是需要通盘考虑的,因为它们互相关联、甚至也会相互影响。比如说:考虑系统的扩展能力,你会将服务设计成无状态的,这种集群设计保证了高扩展性,其实也间接提升了系统的性能和可用性。再比如说:为了保证可用性,通常会对服务接口进行超时设置,以防大量线程阻塞在慢请求上造成系统雪崩,那超时时间设置成多少合理呢?一般,我们会参考依赖服务的性能表现进行设置。
2.2 微观目标
再从微观角度来看,高性能、高可用和高扩展又有哪些具体的指标来衡量?为什么会选择这些指标呢?
2.2.1 性能指标
通过性能指标可以度量目前存在的性能问题,同时作为性能优化的评估依据。一般来说,会采用一段时间内的接口响应时间作为指标。
平均响应时间:最常用,但是缺陷很明显,对于慢请求不敏感。比如1万次请求,其中9900次是1ms,100次是100ms,则平均响应时间为1.99ms,虽然平均耗时仅增加了0.99ms,但是1%请求的响应时间已经增加了100倍。
TP90、TP99等分位值:将响应时间按照从小到大排序,TP90表示排在第90分位的响应时间, 分位值越大,对慢请求越敏感。
吞吐量:和响应时间呈反比,比如响应时间是1ms,则吞吐量为每秒1000次。
通常,设定性能目标时会兼顾吞吐量和响应时间,比如这样表述:在每秒1万次请求下,AVG控制在50ms以下,TP99控制在100ms以下。对于高并发系统,AVG和TP分位值必须同时要考虑。另外,从用户体验角度来看,200毫秒被认为是第一个分界点,用户感觉不到延迟,1秒是第二个分界点,用户能感受到延迟,但是可以接受。因此,对于一个健康的高并发系统,TP99应该控制在200毫秒以内,TP999或者TP9999应该控制在1秒以内。
2.2.2 可用性指标
高可用性是指系统具有较高的无故障运行能力,可用性 = 平均故障时间 / 系统总运行时间,一般使用几个9来描述系统的可用性。
对于高并发系统来说,最基本的要求是:保证3个9或者4个9。原因很简单,如果你只能做到2个9,意味着有1%的故障时间,像一些大公司每年动辄千亿以上的GMV或者收入,1%就是10亿级别的业务影响。
2.2.3 可扩展性指标
面对突发流量,不可能临时改造架构,最快的方式就是增加机器来线性提高系统的处理能力。
对于业务集群或者基础组件来说,扩展性 = 性能提升比例 / 机器增加比例,理想的扩展能力是:资源增加几倍,性能提升几倍。通常来说,扩展能力要维持在70%以上。
但是从高并发系统的整体架构角度来看,扩展的目标不仅仅是把服务设计成无状态就行了,因为当流量增加10倍,业务服务可以快速扩容10倍,但是数据库可能就成为了新的瓶颈。
像MySQL这种有状态的存储服务通常是扩展的技术难点,如果架构上没提前做好规划(垂直和水平拆分),就会涉及到大量数据的迁移。
因此,高扩展性需要考虑:服务集群、数据库、缓存和消息队列等中间件、负载均衡、带宽、依赖的第三方等,当并发达到某一个量级后,上述每个因素都可能成为扩展的瓶颈点。
3. 高并发的实践方案有哪些?
了解了高并发设计的3大目标后,再系统性总结下高并发的设计方案,会从以下两部分展开:先总结下通用的设计方法,然后再围绕高性能、高可用、高扩展分别给出具体的实践方案。
3.1 通用的设计方法
通用的设计方法主要是从「纵向」和「横向」两个维度出发,俗称高并发处理的两板斧:纵向扩展和横向扩展。
3.1.1 纵向扩展(scale-up)
它的目标是提升单机的处理能力,方案又包括:
提升单机的硬件性能:通过增加内存、CPU核数、存储容量、或者将磁盘升级成SSD等堆硬件的方式来提升。
提升单机的软件性能:使用缓存减少IO次数,使用并发或者异步的方式增加吞吐量。
3.1.2 横向扩展(scale-out)
因为单机性能总会存在极限,所以最终还需要引入横向扩展,通过集群部署以进一步提高并发处理能力,又包括以下2个方向:
做好分层架构:这是横向扩展的提前,因为高并发系统往往业务复杂,通过分层处理可以简化复杂问题,更容易做到横向扩展。 上面这种图是互联网最常见的分层架构,当然真实的高并发系统架构会在此基础上进一步完善。比如会做动静分离并引入CDN,反向代理层可以是LVS+Nginx,Web层可以是统一的API网关,业务服务层可进一步按垂直业务做微服务化,存储层可以是各种异构数据库。
各层进行水平扩展:无状态水平扩容,有状态做分片路由。业务集群通常能设计成无状态的,而数据库和缓存往往是有状态的,因此需要设计分区键做好存储分片,当然也可以通过主从同步、读写分离的方案提升读性能。
3.2 具体的实践方案
下面再结合我的个人经验,针对高性能、高可用、高扩展3个方面,总结下可落地的实践方案。
3.2.1 高性能的实践方案
集群部署,通过负载均衡减轻单机压力。
多级缓存,包括静态数据使用CDN、本地缓存、分布式缓存等,以及对缓存场景中的热点key、缓存穿透、缓存并发、数据一致性等问题的处理。
分库分表和索引优化,以及借助搜索引擎解决复杂查询问题。
考虑NoSQL数据库的使用,比如HBase、TiDB等,但是团队必须熟悉这些组件,且有较强的运维能力。
异步化,将次要流程通过多线程、MQ、甚至延时任务进行异步处理。
限流,需要先考虑业务是否允许限流(比如秒杀场景是允许的),包括前端限流、Nginx接入层的限流、服务端的限流。
对流量进行削峰填谷,通过MQ承接流量。
并发处理,通过多线程将串行逻辑并行化。
预计算,比如抢红包场景,可以提前计算好红包金额缓存起来,发红包时直接使用即可。
缓存预热,通过异步任务提前预热数据到本地缓存或者分布式缓存中。
减少IO次数,比如数据库和缓存的批量读写、RPC的批量接口支持、或者通过冗余数据的方式干掉RPC调用。
减少IO时的数据包大小,包括采用轻量级的通信协议、合适的数据结构、去掉接口中的多余字段、减少缓存key的大小、压缩缓存value等。
程序逻辑优化,比如将大概率阻断执行流程的判断逻辑前置、For循环的计算逻辑优化,或者采用更高效的算法。
各种池化技术的使用和池大小的设置,包括HTTP请求池、线程池(考虑CPU密集型还是IO密集型设置核心参数)、数据库和Redis连接池等。
JVM优化,包括新生代和老年代的大小、GC算法的选择等,尽可能减少GC频率和耗时。
锁选择,读多写少的场景用乐观锁,或者考虑通过分段锁的方式减少锁冲突。
上述方案无外乎从计算和 IO 两个维度考虑所有可能的优化点,需要有配套的监控系统实时了解当前的性能表现,并支撑你进行性能瓶颈分析,然后再遵循二八原则,抓主要矛盾进行优化。
3.2.2 高可用的实践方案
对等节点的故障转移,Nginx和服务治理框架均支持一个节点失败后访问另一个节点。
非对等节点的故障转移,通过心跳检测并实施主备切换(比如redis的哨兵模式或者集群模式、MySQL的主从切换等)。
接口层面的超时设置、重试策略和幂等设计。
降级处理:保证核心服务,牺牲非核心服务,必要时进行熔断;或者核心链路出问题时,有备选链路。
限流处理:对超过系统处理能力的请求直接拒绝或者返回错误码。
MQ场景的消息可靠性保证,包括producer端的重试机制、broker侧的持久化、consumer端的ack机制等。
灰度发布,能支持按机器维度进行小流量部署,观察系统日志和业务指标,等运行平稳后再推全量。
监控报警:全方位的监控体系,包括最基础的CPU、内存、磁盘、网络的监控,以及Web服务器、JVM、数据库、各类中间件的监控和业务指标的监控。
灾备演练:类似当前的“混沌工程”,对系统进行一些破坏性手段,观察局部故障是否会引起可用性问题。
高可用的方案主要从冗余、取舍、系统运维3个方向考虑,同时需要有配套的值班机制和故障处理流程,当出现线上问题时,可及时跟进处理。
3.2.3 高扩展的实践方案
合理的分层架构:比如上面谈到的互联网最常见的分层架构,另外还能进一步按照数据访问层、业务逻辑层对微服务做更细粒度的分层(但是需要评估性能,会存在网络多一跳的情况)。
存储层的拆分:按照业务维度做垂直拆分、按照数据特征维度进一步做水平拆分(分库分表)。
业务层的拆分:最常见的是按照业务维度拆(比如电商场景的商品服务、订单服务等),也可以按照核心接口和非核心接口拆,还可以按照请求源拆(比如To C和To B,APP和H5)。
1.3 如何实现分布式存储?
参考答案
分布式存储是一个大的概念,其包含的种类繁多,除了传统意义上的分布式文件系统、分布式块存储和分布式对象存储外,还包括分布式数据库和分布式缓存等。下面我们探讨一下分布式文件系统等传统意义上的存储架构,实现这种存储架构主要有三种通用的形式,其它存储架构也基本上基于上述架构,并没有太大的变化。
中间控制节点架构(HDFS)
分布式存储最早是由谷歌提出的,其目的是通过廉价的服务器来提供使用与大规模,高并发场景下的Web访问问题。下图是谷歌分布式存储(HDFS)的简化的模型。在该系统的整个架构中将服务器分为两种类型,一种名为namenode,这种类型的节点负责管理管理数据(元数据),另外一种名为datanode,这种类型的服务器负责实际数据的管理。
上图分布式存储中,如果客户端需要从某个文件读取数据,首先从namenode获取该文件的位置(具体在哪个datanode),然后从该位置获取具体的数据。在该架构中namenode通常是主备部署,而datanode则是由大量节点构成一个集群。由于元数据的访问频度和访问量相对数据都要小很多,因此namenode通常不会成为性能瓶颈,而datanode集群可以分散客户端的请求。因此,通过这种分布式存储架构可以通过横向扩展datanode的数量来增加承载能力,也即实现了动态横向扩展的能力。
完全无中心架构---计算模式(Ceph)
下图是Ceph存储系统的架构,在该架构中与HDFS不同的地方在于该架构中没有中心节点。客户端是通过一个设备映射关系计算出来其写入数据的位置,这样客户端可以直接与存储节点通信,从而避免中心节点的性能瓶颈。
在Ceph存储系统架构中核心组件有Mon服务、OSD服务和MDS服务等。对于块存储类型只需要Mon服务、OSD服务和客户端的软件即可。其中Mon服务用于维护存储系统的硬件逻辑关系,主要是服务器和硬盘等在线信息。Mon服务通过集群的方式保证其服务的可用性。OSD服务用于实现对磁盘的管理,实现真正的数据读写,通常一个磁盘对应一个OSD服务。 客户端访问存储的大致流程是,客户端在启动后会首先从Mon服务拉取存储资源布局信息,然后根据该布局信息和写入数据的名称等信息计算出期望数据的位置(包含具体的物理服务器信息和磁盘信息),然后该位置信息直接通信,读取或者写入数据。
完全无中心架构---一致性哈希(Swift)
与Ceph的通过计算方式获得数据位置的方式不同,另外一种方式是通过一致性哈希的方式获得数据位置。一致性哈希的方式就是将设备做成一个哈希环,然后根据数据名称计算出的哈希值映射到哈希环的某个位置,从而实现数据的定位。
上图是一致性哈希的基本原理,为了绘制简单,本文以一个服务器上的一个磁盘为例进行介绍。为了保证数据分配的均匀性及出现设备故障时数据迁移的均匀性,一致性哈希将磁盘划分为比较多的虚拟分区,每个虚拟分区是哈希环上的一个节点。整个环是一个从0到32位最大值的一个区间,并且首尾相接。当计算出数据(或者数据名称)的哈希值后,必然落到哈希环的某个区间,然后以顺时针,必然能够找到一个节点。那么,这个节点就是存储数据的位置。 Swift存储的整个数据定位算法就是基于上述一致性哈希实现的。在Swift对象存储中,通过账户名/容器名/对象名三个名称组成一个位置的标识,通过该唯一标识可以计算出一个整型数来。而在存储设备方面,Swift构建一个虚拟分区表,表的大小在创建集群是确定(通常为几十万),这个表其实就是一个数组。这样,根据上面计算的整数值,以及这个数组,通过一致性哈希算法就可以确定该整数在数组的位置。而数组中的每项内容是数据3个副本(也可以是其它副本数量)的设备信息(包含服务器和磁盘等信息)。也就是经过上述计算,可以确定一个数据存储的具体位置。这样,Swift就可以将请求重新定向到该设备进行处理。
上述计算过程是在一个名为Proxy的服务中进行的,该服务可以集群化部署。因此可以分摊请求的负载,不会成为性能瓶颈。
1.4 说一说你对分布式事务的了解
参考答案
分布式事务就是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。简单的说,就是一次大的操作由不同的小操作组成,这些小的操作分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同数据库的数据一致性。
要实现分布式事务,有如下几种常见的解决方案:
2PC
说到2PC就不得不聊数据库分布式事务中的 XA Transactions。
如上图,在XA协议中分为两阶段:
第一阶段:事务管理器要求每个涉及到事务的数据库预提交(precommit)此操作,并反映是否可以提交。
第二阶段:事务协调器要求每个数据库提交数据,或者回滚数据。
优点:
尽量保证了数据的强一致,实现成本较低,在各大主流数据库都有自己实现,对于MySQL是从5.5开始支持。
缺点:
单点问题:事务管理器在整个流程中扮演的角色很关键,如果其宕机,比如在第一阶段已经完成,在第二阶段正准备提交的时候事务管理器宕机,资源管理器就会一直阻塞,导致数据库无法使用。
同步阻塞:在准备就绪之后,资源管理器中的资源一直处于阻塞,直到提交完成,释放资源。
数据不一致:两阶段提交协议虽然为分布式数据强一致性所设计,但仍然存在数据不一致性的可能,比如在第二阶段中,假设协调者发出了事务commit的通知,但是因为网络问题该通知仅被一部分参与者所收到并执行了commit操作,其余的参与者则因为没有收到通知一直处于阻塞状态,这时候就产生了数据的不一致性。
总的来说,XA协议比较简单,成本较低,但是其单点问题,以及不能支持高并发依然是其最大的弱点。
TCC
关于TCC(Try-Confirm-Cancel)的概念,最早是由Pat Helland于2007年发表的一篇名为《Life beyond Distributed Transactions:an Apostate’s Opinion》的论文提出。 TCC事务机制相比于上面介绍的XA,解决了其几个缺点:
解决了协调者单点,由主业务方发起并完成这个业务活动。业务活动管理器也变成多点,引入集群。
同步阻塞:引入超时,超时后进行补偿,并且不会锁定整个资源,将资源转换为业务逻辑形式,粒度变小。
数据一致性,有了补偿机制之后,由业务活动管理器控制一致性。
对于TCC做出如下的解释:
Try阶段:尝试执行,完成所有业务检查(一致性),预留必须业务资源(准隔离性)。
Confirm阶段:确认执行真正执行业务,不作任何业务检查,只使用Try阶段预留的业务资源,Confirm操作满足幂等性。要求具备幂等设计,Confirm失败后需要进行重试。
Cancel阶段:取消执行,释放Try阶段预留的业务资源 Cancel操作满足幂等性Cancel阶段的异常和Confirm阶段异常处理方案基本上一致。
举个简单的例子如果你用100元买了一瓶水,在Try阶段你需要向你的钱包检查是否够100元并锁住这100元,水也是一样的。如果有一个失败,则进行cancel(释放这100元和这一瓶水),如果cancel失败不论什么失败都进行重试cancel,所以需要保持幂等。如果都成功,则进行confirm,确认这100元扣,和这一瓶水被卖,如果confirm失败无论什么失败则重试(会依靠活动日志进行重试)。
对于TCC来说适合以下场景:
强隔离性,严格一致性要求的活动业务。
执行时间较短的业务。
本地消息表
本地消息表这个方案最初是ebay提出的,此方案的核心是将需要分布式处理的任务通过消息日志的方式来异步执行。消息日志可以存储到本地文本、数据库或消息队列,再通过业务规则自动或人工发起重试。人工重试更多的是应用于支付场景,通过对账系统对事后问题的处理。
对于本地消息队列来说核心是把大事务转变为小事务,还是举上面用100元去买一瓶水的例子:
当你扣钱的时候,你需要在你扣钱的服务器上新增加一个本地消息表,你需要把你扣钱和写入减去水的库存到本地消息表放入同一个事务(依靠数据库本地事务保证一致性。
这个时候有个定时任务去轮询这个本地事务表,把没有发送的消息,扔给商品库存服务器,叫他减去水的库存,到达商品服务器之后这个时候得先写入这个服务器的事务表,然后进行扣减,扣减成功后,更新事务表中的状态。
商品服务器通过定时任务扫描消息表或者直接通知扣钱服务器,扣钱服务器本地消息表进行状态更新。
针对一些异常情况,定时扫描未成功处理的消息,进行重新发送,在商品服务器接到消息之后,首先判断是否是重复的,如果已经接收,在判断是否执行,如果执行在马上又进行通知事务,如果未执行,需要重新执行需要由业务保证幂等,也就是不会多扣一瓶水。
本地消息队列是BASE理论,是最终一致模型,适用于对一致性要求不高的场景,实现这个模型时需要注意重试的幂等。
MQ事务
在RocketMQ中实现了分布式事务,实际上其实是对本地消息表的一个封装,将本地消息表移动到了MQ内部,下面简单介绍一下MQ事务。
基本流程如下:
第一阶段Prepared消息,会拿到消息的地址。
第二阶段执行本地事务。
第三阶段通过第一阶段拿到的地址去访问消息,并修改状态。消息接受者就能使用这个消息。
如果确认消息失败,在RocketMq Broker中提供了定时扫描没有更新状态的消息,如果有消息没有得到确认,会向消息发送者发送消息,来判断是否提交,在rocketmq中是以listener的形式给发送者,用来处理。
如果消费超时,则需要一直重试,消息接收端需要保证幂等。如果消息消费失败,这个就需要人工进行处理,因为这个概率较低,如果为了这种小概率时间而设计这个复杂的流程反而得不偿失。
Saga事务
Saga是30年前一篇数据库伦理提到的一个概念。其核心思想是将长事务拆分为多个本地短事务,由Saga事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。 Saga的组成:
每个Saga由一系列sub-transaction Ti 组成 每个Ti 都有对应的补偿动作Ci,补偿动作用于撤销Ti造成的结果,这里的每个T,都是一个本地事务。 可以看到,和TCC相比,Saga没有“预留 try”动作,它的Ti就是直接提交到库。
Saga的执行顺序有两种:
T1, T2, T3, ..., Tn
T1, T2, ..., Tj, Cj,..., C2, C1,其中0 < j < n
Saga定义了两种恢复策略:
向后恢复,即上面提到的第二种执行顺序,其中j是发生错误的sub-transaction,这种做法的效果是撤销掉之前所有成功的sub-transation,使得整个Saga的执行结果撤销。 向前恢复,适用于必须要成功的场景,执行顺序是类似于这样的:T1, T2, ..., Tj(失败), Tj(重试),..., Tn,其中j是发生错误的sub-transaction。该情况下不需要Ci。这里要注意的是,在saga模式中不能保证隔离性,因为没有锁住资源,其他事务依然可以覆盖或者影响当前事务。
还是拿100元买一瓶水的例子来说,这里定义:
T1=扣100元,T2=给用户加一瓶水,T3=减库存一瓶水;
C1=加100元,C2=给用户减一瓶水,C3=给库存加一瓶水;
我们一次进行T1,T2,T3如果发生问题,就执行发生问题的C操作的反向。 上面说到的隔离性的问题会出现在,如果执行到T3这个时候需要执行回滚,但是这个用户已经把水喝了(另外一个事务),回滚的时候就会发现,无法给用户减一瓶水了。这就是事务之间没有隔离性的问题。
可以看见saga模式没有隔离性的影响还是较大,可以参照华为的解决方案:从业务层面入手加入一 Session 以及锁的机制来保证能够串行化操作资源。也可以在业务层面通过预先冻结资金的方式隔离这部分资源, 最后在业务操作的过程中可以通过及时读取当前状态的方式获取到最新的更新。
1.5 分布式系统如何保证最终一致性?
参考答案
国际开放标准组织Open Group定义了DTS(分布式事务处理模型),模型中包含4种角色:应用程序、事务管理器、资源管理器和通信资源管理器。事务管理器是统管全局的管理者,资源管理器和通信资源管理器是事务的参与者。
JEE(Java企业版)规范也包含此分布式事务处理模型的规范,并在所有AppServer中进行实现。在JEE规范中定义了TX协议和XA协议,TX协议定义应用程序与事务管理器之间的接口,XA协议则定义事务管理器与资源管理器之间的接口。在过去使用 AppServer如WebSphere、 WebLogic、JBoss等配置数据源时会看见类似XADatasource的数据源,这就是实现了分布式事务处理模型的关系型数据库的数据源。在企业级开发JEE中,关系型数据库、JMS服务扮演资源管理器的角色,而EJB容器扮演事务管理器的角色。
下面我们介绍两阶段提交协议、三阶段提交协议及阿里巴巴提出的 TCC,它们都是根据DTS这一思想演变而来的。
两阶段提交协议
两阶段提交协议把分布式事务分为两个阶段,一个是准备阶段,另一个是提交阶段。准备阶段和提交阶段都是由事务管理器发起的,为了接下来讲解方便,我们将事务管理器称为协调者,将资源管理器称为参与者。
两阶段提交协议的流程如下所述。
准备阶段:协调者向参与者发起指令,参与者评估自己的状态,如果参与者评估指令可以完成,则会写redo或者undo日志(Write-Ahead Log的一种),然后锁定资源,执行操作,但是并不提交。
提交阶段:如果每个参与者明确返回准备成功,也就是预留资源和执行操作成功,则协调者向参与者发起提交指令,参与者提交资源变更的事务,释放锁定的资源;如果任何一个参与者明确返回准备失败,也就是预留资源或者执行操作失败,则协调者向参与者发起中止指令,参与者取消已经变更的事务,执行undo日志,释放锁定的资源。两阶段提交协议的成功场景如下图所示。 我们看到两阶段提交协议在准备阶段锁定资源,这是一个重量级的操作,能保证强一致性,但是实现起来复杂、成本较高、不够灵活,更重要的是它有如下致命的问题。
阻塞:从上面的描述来看,对于任何一次指令都必须收到明确的响应,才会继续进行下一步,否则处于阻塞状态,占用的资源被一直锁定,不会被释放。
单点故障:如果协调者宕机,参与者没有协调者指挥,则会一直阻塞,尽管可以通过选举新的协调者替代原有协调者,但是如果协调者在发送一个提交指令后宕机,而提交指令仅仅被一个参与者接收,并且参与者接收后也宕机,则新上任的协调者无法处理这种情况。
脑裂:协调者发送提交指令,有的参与者接收到并执行了事务,有的参与者没有接收到事务就没有执行事务,多个参与者之间是不一致的。
上面的所有问题虽然很少发生,但都需要人工干预处理,没有自动化的解决方案,因此两阶段提交协议在正常情况下能保证系统的强一致性,但是在出现异常的情况下,当前处理的操作处于错误状态,需要管理员人工干预解决,因此可用性不够好,这也符合CAP协议的一致性和可用性不能兼得的原理。
三阶段提交协议
三阶段提交协议是两阶段提交协议的改进版本。它通过超时机制解决了阻塞的问题,并且把两个阶段增加为以下三个阶段。
询问阶段:协调者询问参与者是否可以完成指令,协调者只需要回答是或不是,而不需要做真正的操作,这个阶段超时会导致中止。
准备阶段:如果在询问阶段所有参与者都返回可以执行操作,则协调者向参与者发送预执行请求,然后参与者写redo和undo日志,执行操作但是不提交操作;如果在询问阶段任意参与者返回不能执行操作的结果,则协调者向参与者发送中止请求,这里的逻辑与两阶段提交协议的准备阶段是相似的。
提交阶段:如果每个参与者在准备阶段返回准备成功,也就是说预留资源和执行操作成功,则协调者向参与者发起提交指令,参与者提交资源变更的事务,释放锁定的资源;如果任何参与者返回准备失败,也就是说预留资源或者执行操作失败,则协调者向参与者发起中止指令,参与者取消已经变更的事务,执行 undo 日志,释放锁定的资源,这里的逻辑与两阶段提交协议的提交阶段一致。
三阶段提交协议的成功场景示意图如下图所示:
三阶段提交协议与两阶段提交协议主要有以下两个不同点:
增加了一个询问阶段,询问阶段可以确保尽可能早地发现无法执行操作而需要中止的行为,但是它并不能发现所有这种行为,只会减少这种情况的发生。
在准备阶段以后,协调者和参与者执行的任务中都增加了超时,一旦超时,则协调者和参与者都会继续提交事务,默认为成功,这也是根据概率统计超时后默认为成功的正确性最大。
三阶段提交协议与两阶段提交协议相比,具有如上优点,但是一旦发生超时,系统仍然会发生不一致,只不过这种情况很少见,好处是至少不会阻塞和永远锁定资源。
TCC
签名讲解了两阶段提交协议和三阶段提交协议,实际上它们能解决常见的分布式事务的问题,但是遇到极端情况时,系统会产生阻塞或者不一致的问题,需要运营或者技术人员解决。两阶段及三阶段方案中都包含多个参与者、多个阶段实现一个事务,实现复杂,性能也是一个很大的问题,因此,在互联网的高并发系统中,鲜有使用两阶段提交和三阶段提交协议的场景。
后来有人提出了TCC协议,TCC协议将一个任务拆分成Try、Confirm、Cancel三个步骤,正常的流程会先执行Try,如果执行没有问题,则再执行Confirm,如果执行过程中出了问题,则执行操作的逆操作Cancel。从正常的流程上讲,这仍然是一个两阶段提交协议,但是在执行出现问题时有一定的自我修复能力,如果任何参与者出现了问题,则协调者通过执行操作的逆操作来Cancel之前的操作,达到最终的一致状态。
可以看出,从时序上来说,如果遇到极端情况,则TCC会有很多问题,例如,如果在取消时一些参与者收到指令,而另一些参与者没有收到指令,则整个系统仍然是不一致的。对于这种复杂的情况,系统首先会通过补偿的方式尝试自动修复,如果系统无法修复,则必须由人工参与解决。
从TCC的逻辑上看,可以说TCC是简化版的三阶段提交协议,解决了两阶段提交协议的阻塞问题,但是没有解决极端情况下会出现不一致和脑裂的问题。然而,TCC通过自动化补偿手段,将需要人工处理的不一致情况降到最少,也是一种非常有用的解决方案。某著名的互联网公司在内部的一些中间件上实现了TCC模式。
我们给出一个使用TCC的实际案例,在秒杀的场景中,用户发起下订单请求,应用层先查询库存,确认商品库存还有余量,则锁定库存,此时订单状态为待支付,然后指引用户去支付,由于某种原因用户支付失败或者支付超时,则系统会自动将锁定的库存解锁以供其他用户秒杀。
TCC协议的使用场景如下图所示:
在大规模、高并发服务化系统中,一个功能被拆分成多个具有单一功能的子功能,一个流程会有多个系统的多个单一功能的服务组合实现,如果使用两阶段提交协议和三阶段提交协议,则确实能解决系统间的一致性问题。除了这两个协议的自身问题,其实现也比较复杂、成本比较高,最重要的是性能不好,相比来看,TCC协议更简单且更容易实现,但是TCC协议由于每个事务都需要执行Try,再执行Confirm,略显臃肿,因此,现实系统的底线是仅仅需要达到最终一致性,而不需要实现专业的、复杂的一致性协议。实现最终一致性有一些非常有效、简单的模式,下面就介绍这些模式及其应用场景。
查询模式
任何服务操作都需要提供一个查询接口,用来向外部输出操作执行的状态。服务操作的使用方可以通过查询接口得知服务操作执行的状态,然后根据不同的状态来做不同的处理操作。
为了能够实现查询,每个服务操作都需要有唯一的流水号标识,也可使用此次服务操作对应的资源ID来标识,例如:请求流水号、订单号等。首先,单笔查询操作是必须提供的,也鼓励使用单笔订单查询,这是因为每次调用需要占用的负载是可控的。批量查询则根据需要来提供,如果使用了批量查询,则需要有合理的分页机制,并且必须限制分页的大小,以及对批量查询的吞吐量有容量评估、熔断、隔离和限流等措施。
补偿模式
有了上面的查询模式,在任何情况下,我们都能得知具体的操作所处的状态,如果整个操作都处于不正常的状态,则我们需要修正操作中有问题的子操作,这可能需要重新执行未完成的子操作,后者取消已经完成的子操作,通过修复使整个分布式系统达到一致。为了让系统最终达到一致状态而做的努力都叫作补偿。
对于服务化系统中同步调用的操作,若业务操作发起方还没有收到业务操作执行方的明确返回或者调用超时,业务发起方需要及时地调用业务执行方来获得操作执行的状态,这里使用在前面学习的查询模式。在获得业务操作执行方的状态后,如果业务执行方已经完成预设工作,则业务发起方向业务的使用方返回成功;如果业务操作执行方的状态为失败或者未知,则会立即告诉业务使用方失败,也叫作快速失败策略,然后调用业务操作的逆向操作,保证操作不被执行或者回滚已经执行的操作,让业务使用方、业务操作发起方和业务操作执行方最终达到一致状态。
异步确保模式
异步确保模式是补偿模式的一个典型案例,经常应用到使用方对响应时间要求不太高的场景中,通常把这类操作从主流程中摘除,通过异步的方式进行处理,处理后把结果通过通知系统通知给使用方。这个方案的最大好处是能够对高并发流量进行消峰,例如:电商系统中的物流、配送,以及支付系统中的计费、入账等。
在实践中将要执行的异步操作封装后持久入库,然后通过定时捞取未完成的任务进行补偿操作来实现异步确保模式,只要定时系统足够健壮,则任何任务最终都会被成功执行。
异步确保模式如下图所示:
定期校对模式
系统在没有达到一致之前,系统间的状态是不一致的,甚至是混乱的,需要通过补偿操作来达到最终一致性的目的,但是如何来发现需要补偿的操作呢?
在操作主流程中的系统间执行校对操作,可以在事后异步地批量校对操作的状态,如果发现不一致的操作,则进行补偿,补偿操作与补偿模式中的补偿操作是一致的。
另外,实现定期校对的一个关键就是分布式系统中需要有一个自始至终唯一的ID,生成全局唯一ID有以下两种方法:
持久型:使用数据库表自增字段或者Sequence生成,为了提高效率,每个应用节点可以缓存一个批次的ID,如果机器重启则可能会损失一部分ID,但是这并不会产生任何问题。
时间型:一般由机器号、业务号、时间、单节点内自增ID组成,由于时间一般精确到秒或者毫秒,因此不需要持久就能保证在分布式系统中全局唯一、粗略递增等。
可靠消息模式
在分布式系统中,对于主流程中优先级比较低的操作,大多采用异步的方式执行,也就是前面提到的异步确保模型,为了让异步操作的调用方和被调用方充分解耦,也由于专业的消息队列本身具有可伸缩、可分片、可持久等功能,我们通常通过消息队列实现异步化。对于消息队列,我们需要建立特殊的设施来保证可靠的消息发送及处理机的幂等性。
缓存一致性模式
在大规模、高并发系统中的一个常见的核心需求就是亿级的读需求,显然,关系型数据库并不是解决高并发读需求的最佳方案,互联网的经典做法就是使用缓存来抗住读流量。
如果性能要求不是非常高,则尽量使用分布式缓存,而不要使用本地缓存。
写缓存时数据一定要完整,如果缓存数据的一部分有效,另一部分无效,则宁可在需要时回源数据库,也不要把部分数据放入缓存中。
使用缓存牺牲了一致性,为了提高性能,数据库与缓存只需要保持弱一致性,而不需要保持强一致性,否则违背了使用缓存的初衷。
读的顺序是先读缓存,后读数据库,写的顺序要先写数据库,后写缓存。
1.6 谈谈你对分布式的单点问题的了解
参考答案
在分布式系统中,单点问题是一个比较常见的问题,对于单点问题可以分为有状态服务的单点问题和无状态服务的单点问题。
无状态服务的单点问题
对于无状态的服务,单点问题的解决比较简单,因为服务是无状态的,所以服务节点很容易进行平行扩展。比如,在分布式系统中,为了降低各进程通信的网络结构的复杂度,我们会增加一个代理节点,专门做消息的转发,其他的业务进行直接和代理节点进行通信,类似一个星型的网络结构。
参考上面两个图,图中proxy是一个消息转发代理,业务进程中的消息都会经过该代理,这也是比较场景的一个架构。在上图中,只有一个proxy,如果该节点挂了,那么所有的业务进程之间都无法进行通信。由于proxy是无状态的服务,所以很容易想到第二个图中的解决方案,增加一个proxy节点,两个proxy节点是对等的。增加新节点后,业务进程需要与两个Proxy之间增加一个心跳的机制,业务进程在发送消息的时候根据proxy的状态,选择一个可用的proxy进行消息的传递。从负载均衡的角度来看,如果两个proxy都是存活状态的话,业务进程应当随机选择一个proxy。
那么该解决方案中会存在什么问题呢?主要存在的问题是消息的顺序性问题。一般来说,业务的消息都是发送、应答,再发送、再应答这样的顺序进行的,在业务中可以保证消息的顺序性。但是,在实际的应用中,会出现这样一个情况:在业务进程1中,有个业务需要给业务进程3发送消息A和消息B,根据业务的特性,消息A必须要在消息B之前到达。如果业务进程1在发送消息A的时候选择了proxy1,在发送消息B的时候选择了proxy2,那么在分布式环境中,我们并不能确保先发送的消息A一定就能比后发送的消息B先到达业务进程3。那么怎么解决这个问题?其实方案也比较简单,对于这类对消息顺序有要求的业务,我们可以指定对应的proxy进行发送,比如消息A和消息B都是使用proxy1进行发送,这样就可以保证消息A比消息B先到达业务进程3。
整体来说,对于无状态的服务的单点问题的解决方案还是比较简单的,只要增加对应的服务节点即可。
有状态服务的单点问题
相对无状态服务的单点问题,有状态服务的单点问题就复杂多了。如果在架构中,有个节点是单点的,并且该节点是有状态的服务,那么首先要考虑的是该节点是否可以去状态,如果可以,则优先选择去除状态的方案(比如说把状态存储到后端的可靠DB中,可能存在性能的损耗),然后就退化成了一个无状态服务的单点问题了,这就可以参考上一方案了。 但是,并不是所有的服务都是可以去状态的,比如说对于一些业务它只能在一个节点中进行处理,如果在不同的节点中处理的话可能会造成状态的不一致,这类型的业务是无法去除状态的。对于这种无法去除状态的单点的问题的解决方案也是有多种,但是越完善的方案实现起来就越复杂,不过整体的思路都是采用主备的方式。
第一个方案就是就是增加一个备用节点,备用节点和业务进程也可以进行通信,但是所有的业务消息都发往Master节点进行处理。Master节点和Slave节点之间采用ping的方式进行通信。Slave节点会定时发送ping包给Master节点,Master节点收到后会响应一个Ack包。当Slave节点发现Master节点没有响应的时候,就会认为Master节点挂了,然后把自己升级为Master节点,并且通知业务进程把消息转发给自己。该方案看起来也是挺完美的,好像不存在什么问题,Slave升级为Master后所有的业务消息都会发给它。但是,如果在Master内部有一些自己的业务逻辑,比如说随机生成一些业务数据,并且定时存档。那么当Master和Slave之间的网络出现问题的时候,Slave会认为Master挂了,就会升级为Master,同样会执行Master的相应的业务逻辑,同样也会生成一些业务数据回写到DB。但是,其实Master是没有挂的,它同样也在运行对应的业务逻辑(即使业务进程的消息没有发给旧的Master了),这样就会出现两个Master进行写同一份数据了,造成数据的混乱。所以说,该方案并不是一个很好的方案。
那怎么解决可能会出现多个Master的问题?换个角度看,该问题其实就是怎么去裁决哪个节点是Master的问题。
方案一:引入第三方的服务进行裁决。
我们可以引入ZooKeeper,由ZooKeeper进行裁决。同样,我们启动两个主节点,“节点A”和节点B。它们启动之后向ZooKeeper去注册一个节点,假设节点A注册的节点为master001,节点B注册的节点为master002,注册完成后进行选举,编号小的节点为真正的主节点。那么,通过这种方式就完成了对两个Master进程的调度。
方案二: 通过选举算法和租约的方式实现Master的选举。
对于方案一的缺点主要要多维护一套ZooKeeper的服务,如果原本业务上并没有部署该服务的话,要增加该服务的维护也是比较麻烦的事情。这个时候我们可以在业务进程中加入Master的选举方案。目前有比较成熟的选举算法,比如Paxos和Raft。然后再配合租约机制,就可以实现Master的选举,并且确保当前只有一个Master的方案。但是,这些选举算法理解起来并不是那么地容易,要实现一套完善的方案也是挺难的。所以不建议重复造轮子,业内有很多成熟的框架或者组件可以使用,比如微信的PhxPaxos。
比如上图的方案中,三个节点其实都是对等的,通过选举算法确定一个Master。为了确保任何时候都只能存在一个Matster,需要加入租约的机制。一个节点成为Master后,Master和非Master节点都会进行计时,在超过租约时间后,三个节点后可以发起“我要成为Master”的请求,进行重新选举。由于三个节点都是对等的,任意一个都可以成为Master,也就是说租期过后,有可能会出现Master切换的情况,所以为了避免Master的频繁切换,Master节点需要比另外两个节点先发起自己要成为Master的请求(续租),告诉其他两个节点我要继续成为Master,然后另外两个节点收到请求后会进行应答,正常情况下另外两个节点会同意该请求。关键点就是,在租约过期之前,非Master节点不能发起“我要成为Master”的请求,这样就可以解决Master频繁切换的问题。
1.7 HTTP和RPC有什么区别?
参考答案
传输协议
RPC,可以基于TCP协议,也可以基于HTTP协议。
HTTP,基于HTTP协议。
传输效率
RPC,使用自定义的TCP协议,可以让请求报文体积更小,或者使用HTTP2协议,也可以很好的减少报文的体积,提高传输效率。
HTTP,如果是基于HTTP1.1的协议,请求中会包含很多无用的内容,如果是基于HTTP2.0,那么简单的封装一下是可以作为一个RPC来使用的,这时标准RPC框架更多的是服务治理。
性能消耗
RPC,可以基于thrift实现高效的二进制传输。
HTTP,大部分是通过json来实现的,字节大小和序列化耗时都比thrift要更消耗性能。
负载均衡
RPC,基本都自带了负载均衡策略。
HTTP,需要配置Nginx,HAProxy来实现。
服务治理
RPC,能做到自动通知,不影响上游。
HTTP,需要事先通知,修改Nginx/HAProxy配置。
总之,RPC主要用于公司内部的服务调用,性能消耗低,传输效率高,服务治理方便。HTTP主要用于对外的异构环境,浏览器接口调用,APP接口调用,第三方接口调用等。
1. 计算机网络
1.1 请介绍七层网络体系结构。
参考回答
为什么分七层 支持异构网络的互联互通。
七层分别负责的内容(功能) OSI 模型把网络通信的工作分为 7 层,从下到上分别是物理层、数据链路层、网络层、传输层、会话层、表示层和应用层。 (1) 物理层 任务:透明地传输比特流。 功能:为数据段设备提供传送数据通路 传输单位:比特 所实现的硬件:集线器,中继器 (2)数据链路层 任务:将网络层传输下来的IP数据报组装成帧 功能:a. 链路连接的建立、拆除和分离 b. 帧定界和帧同步 c.差错检测 传输单位:帧 所实现的硬件:交换机、网桥 协议:PPP,HDLC、SDLC、STP、ARQ (3)网络层 任务:a. 将传输层传下来的报文段封装成分组 b.选择合适的路由,使得传输层传下来的分组能够交付到目的主机 功能:a. 为传输层提供服务 b. 组包和拆包 c. 路由选择 d.拥塞控制 传输单位:数据段 所实现的硬件:路由器 协议:ICMP、ARP、RARP、IP、IGMP、OSPF (4)传输层 任务:负责主机中两个进程之间的通信 功能: a. 为端到端连接提供可靠的服务 b. 为端到端连接提供流量控制、差错控制、服务质量等管理服务 传输单位:报文段(TCP)或用户数据报(UDP) 协议:TCP、UDP (5)会话层 任务:不同主机上各进程间的对话 功能:管理主机间的会话进程,包括建立、管理以及终止进程间的会话。是一种端到端的服务 (6)表示层 负责处理在两个内部数据表示结构不同的通信系统之间交换信息的表示格式,为数据加密和解密以及为提高传输效率提供必需的数据压缩以及解压等功能。 (7)应用层 任务:提供系统与用户的接口 功能: a.文件传输 b. 访问和管理 c. 电子邮件服务 协议:FTP、SMTP、POP3、HTTP、DNS、TELnet
1.2 请介绍五层网络体系结构。
参考回答
五层网络体系结构分分别为:应用层、运输层、网络层、数据链路层、物理层。各层功能分别如下:
第五层——应用层(application layer) (1) 应用层(application layer):是体系结构中的最高。直接为用户的应用进程提供服务。 (2) 在因特网中的应用层协议很多,如支持万维网应用的HTTP协议,支持电子邮件的SMTP协议,支持文件传送的FTP协议等等。
第四层——运输层(transport layer) (1) 运输层(transport layer):负责向两个主机中进程之间的通信提供服务。由于一个主机可同时运行多个进程,因此运输层有复用和分用的功能。 a. 复用,就是多个应用层进程可同时使用下面运输层的服务。 b. 分用,就是把收到的信息分别交付给上面应用层中相应的进程。 (2) 运输层主要使用以下两种协议: (1) 传输控制协议TCP(Transmission Control Protocol):面向连接的,数据传输的单位是报文段,能够提供可靠的交付。 (2) 用户数据包协议UDP(User Datagram Protocol):无连接的,数据传输的单位是用户数据报,不保证提供可靠的交付,只能提供“尽最大努力交付”。
第三层——网络层(network layer) 网络层(network layer)主要包括以下两个任务: (1) 负责为分组交换网上的不同主机提供通信服务。在发送数据时,网络层把运输层残生的报文段或用户数据报封装成分组或包进行传送。在TCP/IP体系中,由于网络层使用IP协议,因此分组也叫做IP数据报,或简称为数据报。 (2) 选中合适的路由,使源主机运输层所传下来的分组,能够通过网络中的路由器找到目的主机。
第二层——数据链路层(data link layer) 数据链路层(data link layer):常简称为链路层,我们知道,两个主机之间的数据传输,总是在一段一段的链路上传送的,也就是说,在两个相邻结点之间传送数据是直接传送的(点对点),这时就需要使用专门的链路层的协议。 在两个相邻结点之间传送数据时,数据链路层将网络层交下来的IP数据报组装成帧(framing),在两个相邻结点之间的链路上“透明”地传送帧中的数据。 每一帧包括数据和必要的控制信息(如同步信息、地址信息、差错控制等)。典型的帧长是几百字节到一千多字节。 注意:”透明”是一个很重要的术语。它表示,某一个实际存在的事物看起来却好像不存在一样。”在数据链路层透明传送数据”表示无力什么样的比特组合的数据都能够通过这个数据链路层。因此,对所传送的数据来说,这些数据就“看不见”数据链路层。或者说,数据链路层对这些数据来说是透明的。 (1) 在接收数据时,控制信息使接收端能知道一个帧从哪个比特开始和到哪个比特结束。这样,数据链路层在收到一个帧后,就可从中提取出数据部分,上交给网络层。 (2) 控制信息还使接收端能检测到所收到的帧中有无差错。如发现有差错,数据链路层就简单地丢弃这个出了差错的帧,以免继续传送下去白白浪费网络资源。如需改正错误,就由运输层的TCP协议来完成。
第一层——物理层(physical layer) 物理层(physical layer):在物理层上所传数据的单位是比特。物理层的任务就是透明地传送比特流。
1.3 了解网络编程协议吗?客户端发送给服务器的请求,怎么确定具体的协议?
参考回答
了解,客户端发送给服务器端的请求,可以根据统一资源定位系统(uniform resource locator,URL)来确定具体使用的协议。
答案解析
一个完整的URL包括–协议部分、网址、文件地址部分。协议部分以//为分隔符,在interner中,我们可以使用多种协议:
HTTP——HyperText Transfer Protocol(超文本传输协议)
FTP——File Transfer Protocol(文件传输协议)
Gopher——The Internet Gopher Protocol(网际Gopher协议)
File——本地文件传输协议
HTTPS——安全套接字层超文本传输协议(http的安全版)
例如百度网址:http://baidu.com,可以看出使用的是http协议。
1.4 TCP、HTTP、FTP分别属于哪一层?
参考回答
TCP、HTTP、FTP分别属于传输层、应用层、应用层。
答案解析
TCP协议简介 (1)TCP协议的特性 TCP是面向连接的,提供全双工的服务,数据流可以双向传输。也是点对点的,即在单个发送放方和单个接收方之间的连接。 (2)TCP 报文段结构 序号:TCP 的序号是数据流中的字节数,不是分组的序号。表示该报文段数据字段首字节的序号。 确认号:TCP 使用累积确认,确认号是第一个未收到的字节序号,表示希望接收到的下一个字节。 首部长度:通常选项字段为空,所以一般 TCP 首部的长度是 20 字节。 选项字段(可选与变长的):用于发送方与接收方协商 MSS(最大报文段长),或在高速网络环境下用作窗口 调节因子。 标志字段 ACK:指示确认字段中的值是有效的 RST,SYN,FIN:连接建立与拆除 PSH:指示接收方应立即将数据交给上层 URG:报文段中存在着(被发送方的上层实体置位)“紧急”的数据 接收窗口:用于流量控制(表示接收方还有多少可用的缓存空间)。 TCP RFC 并没有规定失序到达的分组应该如何处理,而是交给程序员。可以选择丢弃或保留。如果发生超时,TCP 只重传第一个已发送而未确认的分组,超时时间间隔会设置为原来的 2 倍。 (3)流量控制 如果应用程序读取数据相当慢,而发送方发送数据太多、太快,会很容易使接收方的接收缓存溢出,流量控制就是用来进行发送速度和接收速度的匹配。发送方维护一个“接收窗口”变量,这个变量表示接收方当前可用的缓存空间。
HTTP(超文本传输协议)简介 (1)HTTP协议的特性 HTTP 协议一共有五大特点:a. 支持客户/服务器模式;b. 简单快速;c. 灵活;d. 无连接;e. 无状态。 无连接含义:限制每次连接只处理一个请求。服务器处理完客户的请求,并收到客户的应答后,即断开连接。采用这种方式可以节省传输时间。 无状态含义:指协议对于事务处理没有记忆能力,服务器不知道客户端是什么状态。即我们给服务器发送 HTTP 请求之后,服务器根据请求,会给我们发送数据过来,但是,发送完,不会记录任何信息。 (2)HTTP 客户机及服务器 HTTP 客户机:web 浏览器 HTTP 服务器:web 服务器,包含 web 对象(HTML 文件、JPEG 文件、java 小程序、视频片段等) (3)HTTP 方法字段: GET:绝大部分 HTTP 请求报文使用 GET 方法 POST:用户提交表单时(如向搜索引擎提供关键字),但提交表单不一定要用 POST 方法 HEAD:类似于 GET,区别在于服务器返回的响应报文中不包含请求对象(常用于故障跟踪) PUT:用于向服务器上传对象 DELETE:用于删除服务器上的对象 (4)HTTP 状态信息 301 Permanently Moved 被请求的资源已永久移动到新位置,新的URL在Location头中给出,浏览器应该自动地访问新的URL。 302 Found 请求的资源现在临时从不同的URL响应请求。301是永久重定向,而302是临时重定向。 200 OK 表示从客户端发来的请求在服务器端被正确处理 304 Not Modified 304状态码是告诉浏览器可以从缓存中获取所请求的资源。 400 bad request 请求报文存在语法错误 403 forbidden 表示对请求资源的访问被服务器拒绝 404 not found 表示在服务器上没有找到请求的资源 500 internal sever error 表示服务器端在执行请求时发生了错误 503 service unavailable 表明服务器暂时处于超负载或正在停机维护,无法处理请求 (4)HTTP中常见的文件格式 text/html: HTML格式 text/plain:纯文本格式 image/jpeg:jpg图片格式 application/json: JSON数据格式 application/x-www-form-urlencoded: form表单数据被编码为key/value格式发送到服务器(表单默认的提交数据格式) multipart/form-data: 在表单中进行文件上传时使用
FTP(文件传输协议简介) FTP 使用两个并行的 TCP 连接来传输文件: (1)控制连接(持久):传输控制信息,如用户标识、口令、改变远程目录命令、文件获取上传的命令; (2)数据连接(非持久):传输实际文件。 FTP 客户机发起向 FTP 服务器的控制连接,然后在该连接上发送用户标识和口令、改变远程目录的命令。FTP服务器收到命令后,发起一个到客户机的数据连接,在该连接上准确地传送一个文件并关闭连接。 有状态的协议:FTP 服务器在整个会话期间保留用户的状态信息。服务器必须把特定的用户账号和控制连接 联系起来。
传输层简介 (1)传输层的服务基本原理 a. 多路复用和解复用(分路)技术 复用:发送方的不同的应用进程都可以使用同一个传输层协议传送数据; 分路技术:接收方的传输层剥去报文首部之后能把这些数据正确的传输到正确的应用进程上。 b. 可靠数据传输 c. 流量控制和拥塞控制 (2)传输层提供的服务 a. 传输层寻址和端口 端口号就是用来标识应用进程的数字标识。其端口号的长度为16Bit;也就是能够标识2^16个不同的端口号。另外端口号根据端口范围分为2类。分别为服务端使用的端口号(熟知端口号数值范围:0-1023;登记端口号数值范围:1024-49151)和客户端使用的端口号(数值范围为49152-65535)。常见端口号如下: FTP:21 TELNET:23 SMTP:25 DNS:53 TFTP:69 HTTP:80 SNMP:161 b. 无连接服务和面向连接服务 (3)流量控制和拥塞控制 a. 流量控制:如果发送方把数据发送得过快,接收方可能会来不及接收,这就会造成数据的丢失。 b. 拥塞控制:拥塞控制就是防止过多的数据注入到网络中,这样可以使网络中的路由器或链路不致过载。 两者的区别:流量控制是为了预防拥塞。如:在马路上行车,交警跟红绿灯是流量控制,当发生拥塞时,如何进行疏散,是拥塞控制。流量控制指点对点通信量的控制。而拥塞控制是全局性的,涉及到所有的主机和降低网络性能的因素。
应用层简介 应用层的具体内容就是规定应用进程在通信时所遵循的协议。应用层协议分类如下: (1). 域名系统(Domain Name System,DNS):用于实现网络设备名字到IP地址映射的网络服务。 (2). 文件传输协议(File Transfer Protocol,FTP):用于实现交互式文件传输功能。 (3). 简单邮件传送协议(Simple Mail Transfer Protocol, SMTP):用于实现电子邮箱传送功能 (4). 超文本传输协议(HyperText Transfer Protocol,HTTP):用于实现WWW服务。 (5). 简单网络管理协议(simple Network Management Protocol,SNMP):用于管理与监视网络设备。 (6). 远程登录协议(Telnet):用于实现远程登录功能。
1.5 讲一下TCP/IP协议。
参考回答
TCP/IP协议定义 TCP/IP(Transmission Control Protocol/Internet Protocol,传输控制协议/网际协议)是指能够在多个不同网络间实现信息传输的协议簇。TCP/IP协议不仅仅指的是TCP和IP两个协议,而是指一个由FTP、SMTP、TCP、UDP、IP等协议构成的协议簇, 只是因为在TCP/IP协议中TCP协议和IP协议最具代表性,所以被称为TCP/IP协议。
TCP/IP协议组成 TCP/IP结构模型分为应用层、传输层、网络层、链路层(网络接口层)四层,以下是各层的详细介绍: (1)应用层 应用层是TCP/IP协议的第一层,是直接为应用进程提供服务的。 a. 对不同种类的应用程序它们会根据自己的需要来使用应用层的不同协议,邮件传输应用使用了SMTP协议、万维网应用使用了HTTP协议、远程登录服务应用使用了有TELNET协议。 b. 应用层还能加密、解密、格式化数据。 c. 应用层可以建立或解除与其他节点的联系,这样可以充分节省网络资源。 (2)传输层 作为TCP/IP协议的第二层,运输层在整个TCP/IP协议中起到了中流砥柱的作用。且在运输层中,TCP和UDP也同样起到了中流砥柱的作用。 (3)网络层 网络层在TCP/IP协议中的位于第三层。在TCP/IP协议中网络层可以进行网络连接的建立和终止以及IP地址的寻找等功能。 (4)链路层(网络接口层) 在TCP/IP协议中,网络接口层位于第四层。由于网络接口层兼并了物理层和数据链路层。所以,网络接口层既是传输数据的物理媒介,也可以为网络层提供一条准确无误的线路。
TCP/IP协议特点 TCP/IP协议能够迅速发展起来并成为事实上的标准,是它恰好适应了世界范围内数据通信的需要。它有以下特点: (1)协议标准是完全开放的,可以供用户免费使用,并且独立于特定的计算机硬件与操作系统; (2)独立于网络硬件系统,可以运行在广域网,更适合于互联网; (3)网络地址统一分配,网络中每一设备和终端都具有一个唯一地址; (4)高层协议标准化,可以提供多种多样可靠网络服务。
1.6 说一说你对ARP协议的理解。
参考回答
ARP协议即地址解析协议,是根据IP地址获取MAC地址的一个网络层协议。
工作原理 ARP首先会发起一个请求数据包,数据包的首部包含了目标主机的IP地址,然后这个数据包会在链路层进行再次包装,生成以太网数据包,最终由以太网广播给子网内的所有主机,每一台主机都会接收到这个数据包,并取出标头里的IP地址,然后和自己的IP地址进行比较,如果相同就返回自己的MAC地址,如果不同就丢弃该数据包。ARP接收返回消息,以此确定目标机的MAC地址;与此同时,ARP还会将返回的MAC地址与对应的IP地址存入本机ARP缓存中并保留一定时间,下次请求时直接查询ARP缓存以节约资源。
工作过程 主机A的IP地址为192.168.1.1,MAC地址为0A-11-22-33-44-01; 主机B的IP地址为192.168.1.2,MAC地址为0A-11-22-33-44-02; 当主机A要与主机B通信时,地址解析协议可以将主机B的IP地址(192.168.1.2)解析成主机B的MAC地址,以下为工作流程: 第1步:根据主机A上的路由表内容,IP确定用于访问主机B的转发IP地址是192.168.1.2。然后A主机在自己的本地ARP缓存中检查主机B的匹配MAC地址。 第2步:如果主机A在ARP缓存中没有找到映射,它将询问192.168.1.2的硬件地址,从而将ARP请求帧广播到本地网络上的所有主机。源主机A的IP地址和MAC地址都包括在ARP请求中。本地网络上的每台主机都接收到ARP请求并且检查是否与自己的IP地址匹配。如果主机发现请求的IP地址与自己的IP地址不匹配,它将丢弃ARP请求。 第3步:主机B确定ARP请求中的IP地址与自己的IP地址匹配,则将主机A的IP地址和MAC地址映射添加到本地ARP缓存中。 第4步:主机B将包含其MAC地址的ARP回复消息直接发送回主机A。 第5步:当主机A收到从主机B发来的ARP回复消息时,会用主机B的IP和MAC地址映射更新ARP缓存。本机缓存是有生存期的,生存期结束后,将再次重复上面的过程。主机B的MAC地址一旦确定,主机A就能向主机B发送IP通信了。
ARP报文格式 硬件类型:指明了发送方想知道的硬件接口类型,以太网的值为1; 协议类型:指明了发送方提供的高层协议类型,IP为0800(16进制); 硬件地址长度和协议长度:指明了硬件地址和高层协议地址的长度,这样ARP报文就可以在任意硬件和任意协议的网络中使用; 操作类型:用来表示这个报文的类型,ARP请求为1,ARP响应为2,RARP请求为3,RARP响应为4; 发送方硬件地址(0-3字节):源主机硬件地址的前3个字节; 发送方硬件地址(4-5字节):源主机硬件地址的后3个字节; 发送方IP地址(0-1字节):源主机硬件地址的前2个字节; 发送方IP地址(2-3字节):源主机硬件地址的后2个字节; 目标硬件地址(0-1字节):目的主机硬件地址的前2个字节; 目标硬件地址(2-5字节):目的主机硬件地址的后4个字节; 目标IP地址(0-3字节):目的主机的IP地址。
1.7 IP协议包含哪些字段?
参考回答
IP所包含字段结构图如下:
IP协议包含字段如下:
4位版本号:指定IP协议的版本,对于IPv4来说就是4
4位头部长度:IP头部长度有多少个4字节,所以头部最大长度就是15*4=60字节
8位服务类型:3位优先权(已弃用),4位TOS字段,1位保留字段(必须设置为0)。4为TOS为:最小延时,最大吞吐量,最高可靠性,最小成本,这四个只能选择一个
16位总长度:IP数据报整体占多少字节
16为标识:唯一的标识主机发送的报文,IP报文在数据链路层被分片,那么每一个片中的标识都是相同的
3位标志字段:第一位保留,第二位置1表示进制分片(报文长度超过MTU,丢弃报文),第三位更多分片,最后一个分片是1,其他是0
13位分片偏移:相对于原始IP报文开始处的偏移
8位生存时间:数据报到达目的地的最大报文跳数,每经过一个路由,TTL-=1,一直到0都没有到达目的地,报文丢弃。
8位协议:表示上层协议类型,把IP交给TCP还是UDP,其中ICMP是1,TCP是6,UDP是17
16位头部校验和:使用CRC校验,鉴别头部是否损坏
32位源地址和32位目标地址:表示发送端和接收端
1.8 应用层都包含什么协议?
参考回答
应用层包含的协议有DNS、FTP、SMTP、HTTP、SNMP、Telnet等,其作用分别如下:
域名系统(Domain Name System,DNS):用于实现网络设备名字到IP地址映射的网络服务。
文件传输协议(File Transfer Protocol,FTP):用于实现交互式文件传输功能。
简单邮件传送协议(Simple Mail Transfer Protocol, SMTP):用于实现电子邮箱传送功能。
超文本传输协议(HyperText Transfer Protocol,HTTP):用于实现WWW服务。
简单网络管理协议(simple Network Management Protocol,SNMP):用于管理与监视网络设备。
远程登录协议(Telnet):用于实现远程登录功能。
答案解析
应用层协议定义了运行在不同端系统上的应用程序进程如何相互传递消息。特别是定义了:
交换的消息类型,如请求消息和响应消息。
各种消息类型的语法,如消息中的各个字段及其详细描述。
字段的语义,即包含在字段中的信息的含义。
进程何时、如何发送消息及对消息进行响应的规则。
有些应用层协议是由RFC文档定义的,因此它们位于公共领域,例如HTTP。
有些应用层协议是公司或者个人私有的,位于私人领域,例如QQ。
1.9 应用层报文怎么传输到另一个应用层?
参考回答
应用层数据(报文)向外发送时,数据是由最上面的应用层向下经过一层层封装后发送给物理层;而接收数据时,数据是由物理层向上经过一层层解封后发给应用层。数据的封装和解封过程如下:
数据的封装过程简介 传输层及其以下的机制由内核提供, 应用层由用户进程提供, 应用程序对通讯数据的含义进行解释, 而传输层及其以下处理通讯的细节,将数据从一台计算机通过一定的路径发送到另一台计算机。 应用层数据通过协议栈发到网络上时,每层协议都要加上一个相对应的头部(header ),该过程称为封装封装过程如下图所示:
数据的解封过程简介 不 同 的 协 议 层 对 数 据 包 有 不 同的 称 谓 ,在 传 输 层 叫 做 段(segment ),在网络层叫做数据报( datagram) ,在链路层叫做帧(frame )。数据封装成帧后发到传输介质上,到达目的主机后,每层协议再剥掉相应的头部,最后将应用层数据交给应用程序处理,该过程称为解封。解封过程如下图所示:
举例说明数据封装和解封装过程 (1)从计算机 A 的应用层内网通软件向计算机 B 发出一个消息,生成数据; (2)请求从计算机 A 的应用层下到 计算机A 的传输层,传输层在上层数据前面加上 TCP报头,报头中包括目标端口以及源端口; (3)传输层数据下到网络层,计算机A 在网络层封装,源 IP地址为 计算机A地址,目标 IP地址为 计算机 B 地址; (4)计算机 A 将计算机B 的 IP 地址和子网掩码与自己做比对,可以发现 计算机 B 与自己处于相同的子网。所以数据传输不必经过网关设备; (5)数据包下到 计算机 A 的数据链路层进行封装,源 MAC 地址为 计算机A的 MAC地址,目标 MAC 地址查询自己的 ARP 表。 (6)计算机 A 把帧转换成 bit 流,从物理接口网卡发出; (7)物理层接收到电信号,把它交给数据链路层进行查看帧的目标 MAC 地址,和自己是否相等,如果相等说明该帧是发送给自己的,于是将MAC帧头解开并接着上传到网络层; (8)网络层查看目标 IP 地址和自己是否匹配,如果匹配即解开 IP 头封装。然后再把数据上传到传输层; (9)传输层解开对应的包头之后,继续把数据传给应用层,计算机 B 即可接收到计算机 A 发的消息。
答案解析
报文、报文段、分组、包、数据报、帧、数据流的概念区别如下:
报文(message) 我们将位于应用层的信息分组称为报文。报文是网络中交换与传输的数据单元,也是网络传输的单元。报文包含了将要发送的完整的数据信息,其长短不需一致。报文在传输过程中会不断地封装成分组、包、帧来传输,封装的方式就是添加一些控制信息组成的首部,那些就是报文头。
报文段(segment) 通常是指起始点和目的地都是传输层的信息单元。
分组/包(packet) 分组是在网络中传输的二进制格式的单元,为了提供通信性能和可靠性,每个用户发送的数据会被分成多个更小的部分。在每个部分的前面加上一些必要的控制信息组成的首部,有时也会加上尾部,就构成了一个分组。它的起始和目的地是网络层。
数据报(datagram) 面向无连接的数据传输,其工作过程类似于报文交换。采用数据报方式传输时,被传输的分组称为数据报。通常是指起始点和目的地都使用无连接网络服务的的网络层的信息单元。
帧(frame) 帧是数据链路层的传输单元。它将上层传入的数据添加一个头部和尾部,组成了帧。它的起始点和目的点都是数据链路层。
数据单元(data unit) 指许多信息单元。常用的数据单元有服务数据单元(SDU)、协议数据单元(PDU)。 SDU是在同一机器上的两层之间传送信息。PDU是发送机器上每层的信息发送到接收机器上的相应层(同等层间交流用的)。
1.10 介绍一下tcp的三次握手。
参考回答
第一次握手:建立连接时,客户端发送syn包(syn=x)到服务器,并进入SYN_SENT状态,等待服务器确认;SYN:同步序列编号(Synchronize Sequence Numbers)。
第二次握手:服务器收到syn包,必须确认客户的SYN(ack=x+1),同时自己也发送一个SYN包(syn=y),即SYN+ACK包,此时服务器进入SYN_RECV状态;
第三次握手:客户端收到服务器的SYN+ACK包,向服务器发送确认包ACK(ack=y+1),此包发送完毕,客户端和服务器进入ESTABLISHED(TCP连接成功)状态,完成三次握手。
1.11 介绍一下tcp的四次挥手。
参考回答
客户端进程发出连接释放报文,并且停止发送数据。释放数据报文首部,FIN=1,其序列号为seq=u(等于前面已经传送过来的数据的最后一个字节的序号加1),此时,客户端进入FIN-WAIT-1(终止等待1)状态。 TCP规定,FIN报文段即使不携带数据,也要消耗一个序号。
服务器收到连接释放报文,发出确认报文,ACK=1,ack=u+1,并且带上自己的序列号seq=v,此时,服务端就进入了CLOSE-WAIT(关闭等待)状态。TCP服务器通知高层的应用进程,客户端向服务器的方向就释放了,这时候处于半关闭状态,即客户端已经没有数据要发送了,但是服务器若发送数据,客户端依然要接受。这个状态还要持续一段时间,也就是整个CLOSE-WAIT状态持续的时间。
客户端收到服务器的确认请求后,此时,客户端就进入FIN-WAIT-2(终止等待2)状态,等待服务器发送连接释放报文(在这之前还需要接受服务器发送的最后的数据)。
服务器将最后的数据发送完毕后,就向客户端发送连接释放报文,FIN=1,ack=u+1,由于在半关闭状态,服务器很可能又发送了一些数据,假定此时的序列号为seq=w,此时,服务器就进入了LAST-ACK(最后确认)状态,等待客户端的确认。
客户端收到服务器的连接释放报文后,必须发出确认,ACK=1,ack=w+1,而自己的序列号是seq=u+1,此时,客户端就进入了TIME-WAIT(时间等待)状态。注意此时TCP连接还没有释放,必须经过2∗∗MSL(最长报文段寿命)的时间后,当客户端撤销相应的TCB后,才进入CLOSED状态。
服务器只要收到了客户端发出的确认,立即进入CLOSED状态。同样,撤销TCB后,就结束了这次的TCP连接。可以看到,服务器结束TCP连接的时间要比客户端早一些。
1.12 为什么需要四次挥手?
参考回答
四次挥手示意图
四次挥手过程 (1)客户端向服务器发送FIN控制报文段(首部中的 FIN 比特被置位); (2)服务端收到FIN,回复ACK。服务器进入关闭等待状态,发送FIN; (3)客户端收到FIN,给服务器回复ACK,客户端进入等待状态(进入“等待”,以确保服务器收到ACK真正关闭连接); (4)服务端收到ACK,链接关闭。
四次挥手原因 TCP协议是一种面向连接的、可靠的、基于字节流的运输层通信协议。TCP是全双工模式,这就意味着,当客户端发出FIN报文段时,只是表示客户端已经没有数据要发送了,客户端告诉服务器,它的数据已经全部发送完毕了;但是,这个时候客户端还是可以接受来自服务端的数据;当服务端返回ACK报文段时,表示它已经知道客户端没有数据发送了,但是服务端还是可以发送数据到客户端的;当服务端也发送了FIN报文段时,这个时候就表示服务端也没有数据要发送了,就会告诉客户端,我也没有数据要发送了,之后彼此就会愉快的中断这次TCP连接。 简单地说,前 2 次挥手用于关闭一个方向的数据通道,后两次挥手用于关闭另外一个方向的数据通道。
1.13 为什么要有最后一次ACK?
参考回答
三次握手示意图
四次挥手过程 (1)客户端发送一个SYN0给服务器(选择初始序列号,不携带任何数据) (2)服务器收到SYN0,回复SYN1和ACK(服务器分配缓存,选择自己初始序列号) (3)客户端收到SYN1、ACK,回复ACK(可以包含数据)
为什么要有最后一次ACK 客户端首先向服务器发送一个连接请求,但是可能这个连接请求走了远路,等了很长时间,服务器都没有收到,那么客户端可能会再次发送,此时服务器端收到并且回复SYN、ACK;在这个时候最先发送的那个连接请求到达服务器,那么服务器会回复一个SYN,ACK;但是客户端表示自己已经收到确认了,并不搭理这个回复,那么服务器可能陷入等待,如果这种情况多了,那么会导致服务器瘫痪,所以要发送第三个确认。
1.14 介绍一下tcp粘包、拆包的机制。
参考回答
TCP粘包和拆包问题 TCP是一个“流”协议,所谓流,就是没有界限的一长串二进制数据。TCP作为传输层协议并不了解上层业务数据的具体含义,它会根据TCP缓冲区的实际情况进行数据包的划分,所以在业务上认为是一个完整的包,可能会被TCP拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓的TCP粘包和拆包问题。
产生TCP粘包和拆包的原因 我们知道TCP是以流动的方式传输数据的,传输的最小单位为一个报文段(Segment)。TCP Header中有个Options标识位。常见的标识位为MSS(Maximum Segment Size)指的是,连接层每次传输的数据有个最大限制MTU(Maximum Transmission Unit),一般是1500bit,超过这个量要分成多个报文段,MSS则是这个最大限制减去TCP的header,光是要传输的数据的大小,一般为1460bit。换算成字节,也就是180多字节。 TCP为提高性能,发送端会将需要发送的数据发送到缓冲区,等待缓冲区满了以后,再将缓冲中的数据发送到接收方。同理,接收方也有缓冲区这样的机制来接受数据。 发生TCP粘包、拆包主要是以下原因: (1)应用程序写入数据大于套接字缓冲区大小,会发生拆包; (2)应用程序写入数据小于套接字缓冲区大小,网卡将应用多次写入的数据发送到网络上,这将会发送粘包; (3)进行MSS(最大报文长度)大小的TCP分段,当TCP报文长度——TCP header长度>MSS 的时候会发生拆包; (4)接收方法不及时读取套接字缓冲区数据,这将发生粘包。
如何处理粘包和拆包 假设应用层协议是http 我从浏览器中访问了一个网站,网站服务器给我发了200k的数据。建立连接的时候,通告的MSS是50k,所以为了防止ip层分片,tcp每次只会发送50k的数据,一共发了4个tcp数据包。如果我又访问了另一个网站,这个网站给我发了100k的数据,这次tcp会发出2个包,问题是,客户端收到6个包,怎么知道前4个包是一个页面,后两个是一个页面。既然是tcp将这些包分开了,那tcp会将这些包重组吗,它送给应用层的是什么?这是我自己想的一个场景,正式一点讲的话,这个现象叫拆包。 我们再考虑一个问题。 tcp中有一个negal算法,用途是这样的:通信两端有很多小的数据包要发送,虽然传送的数据很少,但是流程一点没少,也需要tcp的各种确认,校验。这样小的数据包如果很多,会造成网络资源很大的浪费,negal算法做了这样一件事,当来了一个很小的数据包,我不急于发送这个包,而是等来了更多的包,将这些小包组合成大包之后一并发送,不就提高了网络传输的效率的嘛。这个想法收到了很好的效果,但是我们想一下,如果是分属于两个不同页面的包,被合并在了一起,那客户那边如何区分它们呢?这就是粘包问题。 从粘包问题我们更可以看出为什么tcp被称为流协议,因为它就跟水流一样,是没有边界的,没有消息的边界保护机制,所以tcp只有流的概念,没有包的概念。 我们还需要有两个概念: (1)长连接: Client方与Server方先建立通讯连接,连接建立后不断开, 然后再进行报文发送和接收。 (2)短连接:Client方与Server每进行一次报文收发交易时才进行通讯连接,交易完毕后立即断开连接。此种方式常用于一点对多点 通讯,比如多个Client连接一个Server。 实际,我想象的关于粘包的场景是不对的,http连接是短连接,请求之后,收到回答,立马断开连接,不会出现粘包。 拆包现象是有可能存在的。 处理拆包这里提供两种方法: (1)通过包头+包长+包体的协议形式,当服务器端获取到指定的包长时才说明获取完整。 (2) 指定包的结束标识,这样当我们获取到指定的标识时,说明包获取完整。 处理粘包我们从上面的分析看到,虽然像http这样的短连接协议不会出现粘包的现象,但是一旦建立了长连接,粘包还是有可能会发生的。处理粘包的方法如下: (1)发送方对于发送方造成的粘包问题,可以通过关闭Nagle算法来解决,使用TCP_NODELAY选项来关闭算法。 (2)接收方没有办法来处理粘包现象,只能将问题交给应用层来处理。应用层的解决办法简单可行,不仅能解决接收方的粘包问题,还可以解决发送方的粘包问题。解决办法:循环处理,应用程序从接收缓存中读取分组时,读完一条数据,就应该循环读取下一条数据,直到所有数据都被处理完成,判断每条数据的长度的方法有两种: a. 格式化数据:每条数据有固定的格式(开始符,结束符),这种方法简单易行,但是选择开始符和结束符时一定要确保每条数据的内部不包含开始符和结束符。 b. 发送长度:发送每条数据时,将数据的长度一并发送,例如规定数据的前4位是数据的长度,应用层在处理时可以根据长度来判断每个分组的开始和结束位置。
答案解析
扩展资料
UDP会不会产生粘包问题呢?
TCP为了保证可靠传输并减少额外的开销(每次发包都要验证),采用了基于流的传输,基于流的传输不认为消息是一条一条的,是无保护消息边界的(保护消息边界:指传输协议把数据当做一条独立的消息在网上传输,接收端一次只能接受一条独立的消息)。UDP则是面向消息传输的,是有保护消息边界的,接收方一次只接受一条独立的信息,所以不存在粘包问题。
举个例子:有三个数据包,大小分别为2k、4k、6k,如果采用UDP发送的话,不管接受方的接收缓存有多大,我们必须要进行至少三次以上的发送才能把数据包发送完,但是使用TCP协议发送的话,我们只需要接受方的接收缓存有12k的大小,就可以一次把这3个数据包全部发送完毕。
1.15 介绍一下TCP和UDP的区别。
参考回答
TCP和UDP有如下区别:
连接:TCP面向连接的传输层协议,即传输数据之前必须先建立好连接;UDP无连接。
服务对象:TCP点对点的两点间服务,即一条TCP连接只能有两个端点;UDP支持一对一,一对多,多对一,多对多的交互通信。
可靠性:TCP可靠交付:无差错,不丢失,不重复,按序到达;UDP尽最大努力交付,不保证可靠交付。
拥塞控制/流量控制:有拥塞控制和流量控制保证数据传输的安全性;UDP没有拥塞控制,网络拥塞不会影响源主机的发送效率。
报文长度:TCP动态报文长度,即TCP报文长度是根据接收方的窗口大小和当前网络拥塞情况决定的;UDP面向报文,不合并,不拆分,保留上面传下来报文的边界。
首部开销:TCP首部开销大,首部20个字节;UDP首部开销小,8字节(源端口,目的端口,数据长度,校验和)。
适用场景(由特性决定):数据完整性需让位于通信实时性,则应该选用TCP 协议(如文件传输、重要状态的更新等);反之,则使用 UDP 协议(如视频传输、实时通信等)。
1.16 TCP和UDP对于网络稳定性有什么要求?
参考回答
TCP优缺点 优点:可靠、稳定 TCP的可靠体现在TCP在传输数据之前,会有三次握手来建立连接,而且在数据传递时,有确认、窗口、重传、拥塞控制机制,在数据传完之后,还会断开连接用来节约系统资源。 缺点:慢,效率低,占用系统资源高,易被攻击 在传递数据之前要先建立连接,这会消耗时间,而且在数据传递时,确认机制、重传机制、拥塞机制等都会消耗大量时间,而且要在每台设备上维护所有的传输连接。然而,每个链接都会占用系统的CPU、内存等硬件资源。因为TCP有确认机制、三次握手机制,这些也导致TCP容易被利用,实现DOS、DDOS、CC等攻击。
UDP优缺点 优点:快,比TCP稍安全 UDP没有TCP拥有的各种机制,是一个无状态的传输协议,所以传递数据非常快,没有TCP的这些机制,被攻击利用的机制就少一些,但是也无法避免被攻击。 缺点:不可靠,不稳定 因为没有TCP的那些机制,UDP在传输数据时,如果网络质量不好,就会很容易丢包,造成数据的缺失。
适用场景(网络稳定性要求) TCP:当对网络通讯质量有要求时,比如HTTP、HTTPS、FTP等传输文件的协议, POP、SMTP等邮件传输的协议 UDP:对网络通讯质量要求不高时,要求网络通讯速度要快的场景。 所以,TCP对网络稳定性要求高,而UDP相对弱一些。
1.17 如何让UDP可靠一些?
参考回答
为什么需要可靠的UDP 在弱网(2G、3G、信号不好)环境下,使用 TCP 连接的延迟很高,影响体验。使用 UDP 是很好的解决方案,既然把 UDP 作为弱网里面的 TCP 来使用,就必须保证数据传输能像 TCP 一样可靠
如何实现可靠的UDP UDP它不属于连接型协议,因而具有资源消耗小,处理速度快的优点,所以通常音频、视频和普通数据在传送时使用UDP较多,因为它们即使偶尔丢失一两个数据包,也不会对接收结果产生太大影响。传输层无法保证数据的可靠传输,只能通过应用层来实现了。实现的方式可以参照tcp可靠性传输的方式,只是实现不在传输层,实现转移到了应用层。关键在于两点,从应用层角度考虑: (1)提供超时重传,能避免数据报丢失。 (2)提供确认序列号,可以对数据报进行确认和排序。 本端:首先在UDP数据报定义一个首部,首部包含确认序列号和时间戳,时间戳是用来计算RTT(数据报传输的往返时间),计算出合适的RTO(重传的超时时间)。然后以等-停的方式发送数据报,即收到对端的确认之后才发送下一个的数据报。当时间超时,本端重传数据报,同时RTO扩大为原来的两倍,重新开始计时。 对端:接受到一个数据报之后取下该数据报首部的时间戳和确认序列号,并添加本端的确认数据报首部之后发送给对端。根据此序列号对已收到的数据报进行排序并丢弃重复的数据报。
答案解析
扩展资料
已经实现的可靠UDP:
(1)RUDP 可靠数据报传输协议;
(2)RTP 实时传输协议
为数据提供了具有实时特征的端对端传送服务;
Eg:组播或单播网络服务下的交互式视频、音频或模拟数据
(3)UDT
基于UDP的数据传输协议,是一种互联网传输协议;
主要目的是支持高速广域网上的海量数据传输,引入了新的拥塞控制和数据可靠性控制机制(互联网上的标准数据传输协议TCP在高带宽长距离的网络上性能很差);
UDT是面向连接的双向的应用层协议,同时支持可靠的数据流传输和部分可靠的数据报服务;
应用:高速数据传输,点到点技术(P2P),防火墙穿透,多媒体数据传输;
1.18 TCP报文首部中序号占多少字节?
参考回答
序号字段占4个字节(32位)。
答案解析
TCP首部字段详细图
TCP首部包括20字节的固定首部部分及长度可变的其他选项,所以TCP首部长度可变。20个字节又分为5部分,每部分4个字节32位,如图中的5行,每行表示32位。
源端口和目的端口字段——各占 2 字节(16位)。端口是运输层与应用层的服务接口。运输层的复用和分用功能都要通过端口才能实现。
序号字段——占 4 字节。TCP 连接中传送的数据流中的每一个字节都编上一个序号。序号字段的值则指的是本报文段所发送的数据的第一个字节的序号。比如分组的第一个数据包由文件的14个字节数据组成,那么该数据包所添加的序号就是1,同理第二个数据包由文件的59个字节数据组成,那么该数据包所添加的序号就是5;
确认号字段——占 4 字节,是期望收到对方的下一个报文段的数据的第一个字节的序号。比如接收端收到由文件14个字节数据+TCP首部组成的数据包后,删除首部提取14个字节数据,返回的确认号为5,即告诉发送端下一次应该发送文件的第5个字节及其之后字节组成的数据包过来。
数据偏移(即首部长度)——占 4 位,它指出 TCP 报文段的数据起始处距离 TCP 报文段的起始处有多远,也就是TCP首部的长度。“数据偏移”的单位是 32 位字(以 4 字节为计算单位),最大1111表示15x4=60个字节,即表示TCP首部最大长度为60个字节,因此“选项”部分最多40个字节。
保留字段——占 6 位,保留为今后使用,但目前应置为 0。
这里的六位二进制位,分别表示不同含义: (1)紧急 URG —— 当 URG = 1 时,表明紧急指针字段有效。它告诉系统此报文段中有紧急数据,应尽快传送(相当于高优先级的数据)。 即URG=1的数据包不用排队直接优先传输。 (2)同步 SYN —— 同步 SYN = 1 表示这是一个连接请求或连接接受报文。即A想与B建立连接,发送过去的第一个数据包(第一次握手)中SYN=1;B返回的数据包(第二次握手)中SYN=1表示同意建立连接。 (3)确认 ACK —— 只有当 ACK = 1 时确认号字段才有效。当 ACK = 0 时,确认号无效。
窗口字段 —— 占 2 字节,用来让对方设置发送窗口的依据,单位为字节。
检验和 —— 占 2 字节。检验和字段检验的范围包括首部和数据这两部分。在计算检验和时,要在 TCP 报文段的前面加上 12 字节的伪首部。
紧急指针字段 —— 占 16 位,指出在本报文段中紧急数据共有多少个字节(紧急数据放在本报文段数据的最前面)。
选项字段 —— 长度可变。TCP 最初只规定了一种选项,即最大报文段长度 MSS (Maximum Segment Size)是 TCP 报文段中的数据字段的最大长度。数据字段加上 TCP 首部才等于整个的 TCP 报文段。MSS 告诉对方 TCP:“我的缓存所能接收的报文段的数据字段的最大长度是 MSS 个字节。”其他选项有:窗口扩大选项、时间戳选项、选择确认选项(SACK)。
填充字段 —— 这是为了使整个首部长度是 4 字节的整数倍。
1.19 TCP中的缓存有什么作用?
参考回答
TCP缓冲区是什么 每个 socket 被创建后,都会分配两个缓冲区,输入缓冲区和输出缓冲区。
缓冲区的意义(作用) TCP套接字的I/O缓冲区示意图 TCP的发送缓冲区是用来缓存应用程序的数据,发送缓冲区的每个字节都有序列号,被应答确认的序列号对应的数据会从发送缓冲区删除掉。 write()/send() 并不立即向网络中传输数据,而是先将数据写入缓冲区中,再由TCP协议将数据从缓冲区发送到目标机器。一旦将数据写入到缓冲区,函数就可以成功返回,不管它们有没有到达目标机器,也不管它们何时被发送到网络,这些都是TCP协议负责的事情。 TCP协议独立于 write()/send() 函数,数据有可能刚被写入缓冲区就发送到网络,也可能在缓冲区中不断积压,多次写入的数据被一次性发送到网络,比如nagle算法,这取决于当时的网络情况、当前线程是否空闲等诸多因素,不由程序员控制。 read()/recv() 函数也是如此,也从输入缓冲区中读取数据,而不是直接从网络中读取。
I/O缓冲区特性 (1)I/O缓冲区在每个TCP套接字中单独存在; (2)I/O缓冲区在创建套接字时自动生成; (3)即使关闭套接字也会继续传送输出缓冲区中遗留的数据; (4)关闭套接字将丢失输入缓冲区中的数据。 输入输出缓冲区的默认大小一般都是 8K,可以通过 getsockopt() 函数获取: //代码实例(缓冲区大小获取) int servSock = socket(PF_INET, SOCK_STREAM, 0); unsigned optVal; int optLen = sizeof(int); getsockopt(servSock, SOL_SOCKET, SO_SNDBUF, (char*)&optVal, &optLen); /* 运行结果: Buffer length: 8192 */
1.20 说一说TCP是怎么控制流量的?
参考回答
所谓流量控制就是让发送发送速率不要过快,让接收方来得及接收。
TCP控制流量的方法 利用滑动窗口机制就可以实施流量控制。 原理就是运用TCP报文段中的窗口大小字段来控制,发送方的发送窗口不可以大于接收方发回的窗口大小。考虑一种特殊的情况,就是接收方若没有缓存足够使用,就会发送零窗口大小的报文,此时发送放将发送窗口设置为0,停止发送数据。之后接收方有足够的缓存,发送了非零窗口大小的报文,但是这个报文在中途丢失的,那么发送方的发送窗口就一直为零导致死锁。 解决这个问题,TCP为每一个连接设置一个持续计时器(persistence timer)。只要TCP的一方收到对方的零窗口通知,就启动该计时器,周期性的发送一个零窗口探测报文段。对方就在确认这个报文的时候给出现在的窗口大小(注意:TCP规定,即使设置为零窗口,也必须接收以下几种报文段:零窗口探测报文段、确认报文段和携带紧急数据的报文段)。
答案解析
TCP的滑动窗口 为了提高信道的利用率TCP协议不使用停止等待协议,而是使用连续ARQ协议,意思就是可以连续发出若干个分组然后等待确认,而不是发送一个分组就停止并等待该分组的确认。 TCP的两端都有发送/接收缓存和发送/接收窗口。TCP的缓存是一个循环队列,其中发送窗口可以用3个指针表示。而发送窗口的大小受TCP数据报中窗口大小的影响,TCP数据报中的窗口大小是接收端通知发送端其还可以接收多少数据,所以发送窗口根据接收的的窗口大小的值动态变化。 以下的几张图片就帮助理解一下滑动窗口的机制: 图1 根据B给出的窗口值,A构造出自己的发送窗口 图2 A发送了11个字节的数据 注意上图中的3个指针P1、P2、P3!此时接收窗口中接收的数据可能是失序的,但是也先存储在接收缓存之中。发送确认号的时候依然发送31,表示B期望接收的下一个数据报的标示符是31。 图3 A收到新的确认号,发送窗口向前滑动 图4 发送窗口内的序号都属于已经发送但未被确认 如果发送窗口中的数据报都属于已发送但未被确认的话,那么A就不能再继续发送数据,而需要进行等待。 图5 TCP的发送缓存和发送窗口(a)与接收缓存和接收窗口(b)
传输效率及Nagle算法 TCP的数据传输分为交互数据流和成块数据流,交互数据流一般是一些交互式应用程序的命令,所以这些数据很小,而考虑到TCP报头和IP报头的总和就有40字节,如果数据量很小的话,那么网络的利用效率就较低。 数据传输使用Nagle算法,Nagle算法很简单,就是规定一个TCP连接最多只能有一个未被确认的未完成的小分组。在该分组的确认到达之前不能发送其他的小分组。 但是也要考虑另一个问题,叫做糊涂窗口综合症。当接收方的缓存已满的时候,交互应用程序一次只从缓存中读取一个字节(这时候缓存中腾出一个字节),然后向发送方发送确认信息,此时发送方再发送一个字节(收到的窗口大小为1),这样网络的效率很低。 要解决这个问题,可以让接收方等待一段时间,使得接收缓存已有最够的空间容纳一个最长报文段,或者等到接收缓存已有一半的空间。只要这两种情况出现一种,就发送确认报文,同时发送方可以把数据积累成大的报文段发送。
1.21 HTTP2.0中TCP阻塞了怎么办?
参考回答
HTTP2.0中TCP阻塞了有如下两种方法可以解决:
(1)并发TCP连接(浏览器一个域名采用6-8个TCP连接,并发HTTP请求) (2)域名分片(多个域名,可以建立更多的TCP连接,从而提高HTTP请求的并发)
答案解析
1. TCP队头阻塞
TCP数据包是有序传输,中间一个数据包丢失,会等待该数据包重传,造成后面的数据包的阻塞。
2. HTTP队头阻塞
http队头阻塞和TCP队头阻塞完全不是一回事。
http1.x采用长连接(Connection:keep-alive),可以在一个TCP请求上,发送多个http请求。
有非管道化和管道化,两种方式。
非管道化,完全串行执行,请求->响应->请求->响应...,后一个请求必须在前一个响应之后发送。
管道化,请求可以并行发出,但是响应必须串行返回。后一个响应必须在前一个响应之后。原因是,没有序号标明顺序,只能串行接收。
管道化请求的致命弱点:
(1)会造成队头阻塞,前一个响应未及时返回,后面的响应被阻塞 (2)请求必须是幂等请求,不能修改资源。因为,意外中断时候,客户端需要把未收到响应的请求重发,非幂等请求,会造成资源破坏。
由于这个原因,目前大部分浏览器和Web服务器,都关闭了管道化,采用非管道化模式。
无论是非管道化还是管道化,都会造成队头阻塞(请求阻塞)。
解决http队头阻塞的方法:
(1)并发TCP连接(浏览器一个域名采用6-8个TCP连接,并发HTTP请求) (2)域名分片(多个域名,可以建立更多的TCP连接,从而提高HTTP请求的并发)
2. HTTP2方式
http2使用一个域名单一TCP连接发送请求,请求包被二进制分帧,不同请求可以互相穿插,避免了http层面的请求队头阻塞。 但是不能避免TCP层面的队头阻塞。
1.22 TCP如何保证可靠性?
参考回答
TCP协议保证数据传输可靠性的方式主要有:校验和、序列号、确认应答、超时重传、连接管理、流量控制、拥塞控制。
校验和 计算方式:在数据传输的过程中,将发送的数据段都当做一个16位的整数。将这些整数加起来。并且前面的进位不能丢弃,补在后面,最后取反,得到校验和。 发送方:在发送数据之前计算检验和,并进行校验和的填充。 接收方:收到数据后,对数据以同样的方式进行计算,求出校验和,与发送方的进行比对。 注意:如果接收方比对校验和与发送方不一致,那么数据一定传输有误。但是如果接收方比对校验和与发送方一致,数据不一定传输成功。
序列号和确认应答 序列号:TCP传输时将每个字节的数据都进行了编号,这就是序列号。 确认应答:TCP传输的过程中,每次接收方收到数据后,都会对传输方进行确认应答。也就是发送ACK报文。这个ACK报文当中带有对应的确认序列号,告诉发送方,接收到了哪些数据,下一次的数据从哪里发。 序列号的作用不仅仅是应答的作用,有了序列号能够将接收到的数据根据序列号排序,并且去掉重复序列号的数据。这也是TCP传输可靠性的保证之一。
超时重传 在进行TCP传输时,由于确认应答与序列号机制,也就是说发送方发送一部分数据后,都会等待接收方发送的ACK报文,并解析ACK报文,判断数据是否传输成功。如果发送方发送完数据后,迟迟没有等到接收方的ACK报文,这该怎么办呢?而没有收到ACK报文的原因可能是什么呢? 首先,发送方没有接收到响应的ACK报文原因可能有两点: (1)数据在传输过程中由于网络原因等直接全体丢包,接收方根本没有接收到。 (2)接收方接收到了响应的数据,但是发送的ACK报文响应却由于网络原因丢包了。 TCP在解决这个问题的时候引入了一个新的机制,叫做超时重传机制。简单理解就是发送方在发送完数据后等待一个时间,时间到达没有接收到ACK报文,那么对刚才发送的数据进行重新发送。如果是刚才第一个原因,接收方收到二次重发的数据后,便进行ACK应答。如果是第二个原因,接收方发现接收的数据已存在(判断存在的根据就是序列号,所以上面说序列号还有去除重复数据的作用),那么直接丢弃,仍旧发送ACK应答。
连接管理 连接管理就是三次握手与四次挥手的过程,保证可靠的连接,是保证可靠性的前提。
流量控制 收端在接收到数据后,对其进行处理。如果发送端的发送速度太快,导致接收端的结束缓冲区很快的填充满了。此时如果发送端仍旧发送数据,那么接下来发送的数据都会丢包,继而导致丢包的一系列连锁反应,超时重传呀什么的。而TCP根据接收端对数据的处理能力,决定发送端的发送速度,这个机制就是流量控制。 在TCP协议的报头信息当中,有一个16位字段的窗口大小。在介绍这个窗口大小时我们知道,窗口大小的内容实际上是接收端接收数据缓冲区的剩余大小。这个数字越大,证明接收端接收缓冲区的剩余空间越大,网络的吞吐量越大。接收端会在确认应答发送ACK报文时,将自己的即时窗口大小填入,并跟随ACK报文一起发送过去。而发送方根据ACK报文里的窗口大小的值的改变进而改变自己的发送速度。如果接收到窗口大小的值为0,那么发送方将停止发送数据。并定期的向接收端发送窗口探测数据段,让接收端把窗口大小告诉发送端。
拥塞控制 TCP传输的过程中,发送端开始发送数据的时候,如果刚开始就发送大量的数据,那么就可能造成一些问题。网络可能在开始的时候就很拥堵,如果给网络中在扔出大量数据,那么这个拥堵就会加剧。拥堵的加剧就会产生大量的丢包,就对大量的超时重传,严重影响传输。 所以TCP引入了慢启动的机制,在开始发送数据时,先发送少量的数据探路。探清当前的网络状态如何,再决定多大的速度进行传输。这时候就引入一个叫做拥塞窗口的概念。发送刚开始定义拥塞窗口为 1,每次收到ACK应答,拥塞窗口加 1。在发送数据之前,首先将拥塞窗口与接收端反馈的窗口大小比对,取较小的值作为实际发送的窗口。 拥塞窗口的增长是指数级别的。慢启动的机制只是说明在开始的时候发送的少,发送的慢,但是增长的速度是非常快的。为了控制拥塞窗口的增长,不能使拥塞窗口单纯的加倍,设置一个拥塞窗口的阈值,当拥塞窗口大小超过阈值时,不能再按照指数来增长,而是线性的增长。在慢启动开始的时候,慢启动的阈值等于窗口的最大值,一旦造成网络拥塞,发生超时重传时,慢启动的阈值会为原来的一半(这里的原来指的是发生网络拥塞时拥塞窗口的大小),同时拥塞窗口重置为 1。 拥塞控制是TCP在传输时尽可能快的将数据传输,并且避免拥塞造成的一系列问题。是可靠性的保证,同时也是维护了传输的高效性。
答案解析
无
1.23 说一说TCP里的reset状态。
参考回答
TCP异常终止(reset报文) TCP的异常终止是相对于正常释放TCP连接的过程而言的,我们都知道,TCP连接的建立是通过三次握手完成的,而TCP正常释放连接是通过四次挥手来完成,但是有些情况下,TCP在交互的过程中会出现一些意想不到的情况,导致TCP无法按照正常的四次挥手来释放连接,如果此时不通过其他的方式来释放TCP连接的话,这个TCP连接将会一直存在,占用系统的部分资源。在这种情况下,我们就需要有一种能够释放TCP连接的机制,这种机制就是TCP的reset报文。reset报文是指TCP报头的标志字段中的reset位置一的报文。
RST标志位(Reset) RST表示复位,用来异常的关闭连接,在TCP的设计中它是不可或缺的。就像上面说的一样,发送RST包关闭连接时,不必等缓冲区的包都发出去(不像上面的FIN包),直接就丢弃缓存区的包发送RST包。而接收端收到RST包后,也不必发送ACK包来确认。 TCP处理程序会在自己认为的异常时刻发送RST包。例如,A向B发起连接,但B之上并未监听相应的端口,这时B操作系统上的TCP处理程序会发RST包。 又比如,AB正常建立连接了,正在通讯时,A向B发送了FIN包要求关连接,B发送ACK后,网断了,A通过若干原因放弃了这个连接(例如进程重启)。网通了后,B又开始发数据包,A收到后表示压力很大,不知道这野连接哪来的,就发了个RST包强制把连接关了,B收到后会出现connect reset by peer错误。
答案解析
TCP异常终止的常见情形 (1)客户端尝试与服务器未对外提供服务的端口建立TCP连接,服务器将会直接向客户端发送reset报文。 (2)客户端和服务器的某一方在交互的过程中发生异常(如程序崩溃等),该方系统将向对端发送TCP reset报文,告之对方释放相关的TCP连接。 (3)接收端收到TCP报文,但是发现该TCP的报文,并不在其已建立的TCP连接列表内,则其直接向对端发送reset报文。 (4)在交互的双方中的某一方长期未收到来自对方的确认报文,则其在超出一定的重传次数或时间后,会主动向对端发送reset报文释放该TCP连接。 (5)有些应用开发者在设计应用系统时,会利用reset报文快速释放已经完成数据交互的TCP连接,以提高业务交互的效率。
1.24 如何利用UDP实现可靠传输?
参考回答
实现方法: (1)将实现放到应用层,然后类似于TCP,实现确认机制、重传机制和窗口确认机制; (2)给数据包进行编号,按顺序接收并存储,接收端收到数据包后发送确认信息给发送端,发送端接收到确认信息后继续发送,若接收端接收的数据不是期望的顺序编号,则要求重发;(主要解决丢包和包无序的问题)
已经实现的可靠UDP: (1)RUDP 可靠数据报传输协议; (2)RTP 实时传输协议 为数据提供了具有实时特征的端对端传送服务;例如:组播或单播网络服务下的交互式视频、音频或模拟数据。 (3)UDT 基于UDP的数据传输协议,是一种互联网传输协议; 主要目的是支持高速广域网上的海量数据传输,引入了新的拥塞控制和数据可靠性控制机制(互联网上的标准数据传输协议TCP在高带宽长距离的网络上性能很差);
UDT是面向连接的双向的应用层协议,同时支持可靠的数据流传输和部分可靠的数据报服务;
应用:高速数据传输,点到点技术(P2P),防火墙穿透,多媒体数据传输;
答案解析
无
1.25 报文乱序怎么办?
参考回答
数据包会因为IP层所规划的路由链路的不同导致数据包的接收顺序与发送顺序会有所不同。另外因为TCP是一种全双工的协议,乱序可能发生在正向链路,也可能发生在反向链路,这两种不同的情况给TCP带来的影响也会略有差异。
正向链路乱序 此时TCP会无法判断是数据包丢失还是乱序,因为丢包和乱序都会导致接收端收到次序混乱的数据包,造成接收端的数据空洞。TCP会将这种情况暂定为数据包的乱序,因为乱序是时间问题(可能是数据包的迟到),而丢包则意味着重传。当TCP意识到包出现乱序的情况时,会立即ACK,该ACK的TSER部分包含的TSEV值会记录当前接收端收到有序报文段的时刻。这会使得数据包的RTT样本值增大,进一步导致RTO时间延长。这对TCP来说无疑是有益的,因为TCP有充分的时间判断数据包到底是失序还是丢了来防止不必要的数据重传。当然严重的乱序则会让发送端以为是丢包一旦重复的ACK超过TCP的阈值,便会触发超时重传机制,以及时解决这种问题。
反向链路(ACK)乱序 顾名思义,如果发生这种情况,就会导致发送端窗口快速前移,这会导致发送端出现不必要的流量突发,影响网络带宽。
答案解析
无
1.26 说一说你对IP分类的了解。
参考回答
五类互联网地址
IP地址根据网络号和主机号来分,分为A、B、C三类及特殊地址D、E。 全0和全1的都保留不用。
A类:(1.0.0.0-126.0.0.0)(默认子网掩码:255.0.0.0或 0xFF000000)第一个字节为网络号,后三个字节为主机号。该类IP地址的最前面为“0”,所以地址的网络号取值于1~126之间。一般用于大型网络。
B类:(128.0.0.0-191.255.0.0)(默认子网掩码:255.255.0.0或0xFFFF0000)前两个字节为网络号,后两个字节为主机号。该类IP地址的最前面为“10”,所以地址的网络号取值于128~191之间。一般用于中等规模网络。
C类:(192.0.0.0-223.255.255.0)(子网掩码:255.255.255.0或 0xFFFFFF00)前三个字节为网络号,最后一个字节为主机号。该类IP地址的最前面为“110”,所以地址的网络号取值于192~223之间。一般用于小型网络。
D类:是多播地址。该类IP地址的最前面为“1110”,所以地址的网络号取值于224~239之间。一般用于多路广播用户 。
E类:是保留地址。该类IP地址的最前面为“1111”,所以地址的网络号取值于240~255之间。
1.27 IP为什么要分类?
参考回答
根据IP地址访问终端是通过路由器,路由设备当中有一张路由表,该路由表记录了所有IP地址的位置,这样就可以进行包的转发了,如果我们不区分网络地址,那么这张路由表当中就要保存有所有IP地址的方向,这张路由表就会很大,就像下面说的那样:如果不分网络位和主机位,路由器的路由表就是都是32位的地址,那所有的路由器维护的路由表会很大,转发速度会变慢(因为查询变慢)。而且所有的路由器都要有全Internet的地址,所有人的路由器都要有足够的性能来存下全网地址。估计建造这样的Internet成本是现在的几万倍,甚至更高。
有了网络地址,就可以限定拥有相同网络地址的终端都在同一个范围内,那么路由表只需要维护这个网络地址的方向,就可以找到相应的终端了。
1.28 IPV4和IPV6有什么区别?
参考回答
IPv4和IPv6是是目前使用的两种Internet协议版本,IPv4和IPv6协议之间存在各种差异,包括它们的功能,但关键的一点是它生成的地址(地址空间)的数量的区别。
协议地址的区别 (1)地址长度 IPv4协议具有32位(4字节)地址长度;IPv6协议具有128位(16字节)地址长度。 (2)地址的表示方法 IPv4地址是以小数表示的二进制数。 IPv6地址是以十六进制表示的二进制数。 (3)地址配置 IPv4协议的地址可以通过手动或DHCP配置的。 IPv6协议需要使用Internet控制消息协议版本6(ICMPv6)或DHCPv6的无状态地址自动配置(SLAAC)。
数据包的区别 (1)包的大小 IPv4协议的数据包需要576个字节,碎片可选 ;IPv6协议的数据包需要1280个字节,不会碎片。 (2)包头 IPv4协议的包头的长度为20个字节,不识别用于QoS处理的数据包流,包含checksum,包含最多40个字节的选项字段。 IPv6协议的包头的长度为40个字节,包含指定QoS处理的数据包流的Flow Label字段,不包含checksum;IPv6协议没有字段,但IPv6扩展标头可用。 (3)数据包碎片 IPv4协议的数据包碎片会由转发路由器和发送主机完成。IPv6协议的数据包碎片仅由发送主机完成。
DNS记录 IPv4协议的地址(A)记录,映射主机名;指针(PTR)记录,IN-ADDR.ARPA DNS域。 IPv6协议的地址(AAAA)记录,映射主机名;指针(PTR)记录,IP6.ARPA DNS域
IPSec支持 IPv4协议的IPSec支持只是可选的;IPv6协议有内置的IPSec支持。
地址解析协议 IPv4协议:地址解析协议(ARP)可用于将IPv4地址映射到MAC地址。 IPv6协议:地址解析协议(ARP)被邻居发现协议(NDP)的功能所取代。
身份验证和加密 Pv6提供身份验证和加密;但IPv4不提供。
答案解析
无
1.29 说一下http和https的区别。
参考回答
https和https主要存在以下的区别:
HTTPS 协议需要到 CA (Certificate Authority,证书颁发机构)申请证书,一般免费证书较少,因而需要一定费用。(以前的网易官网是http,而网易邮箱是 https 。)
HTTP 是超文本传输协议,信息是明文传输,HTTPS 则是具有安全性的 SSL 加密传输协议。
HTTP 和 HTTPS 使用的是完全不同的连接方式,用的端口也不一样,前者是80,后者是443。
HTTP 的连接很简单,是无状态的。HTTPS 协议是由 SSL+HTTP 协议构建的可进行加密传输、身份认证的网络协议,比 HTTP 协议安全。(无状态的意思是其数据包的发送、传输和接收都是相互独立的。无连接的意思是指通信双方都不长久的维持对方的任何信息。)
答案解析
超文本传输协议(HTTP,HyperText Transfer Protocol)是互联网上应用最为广泛的一种网络协议。设计 HTTP 最初的目的是为了提供一种发布和接收 HTML 页面的方法。它可以使浏览器更加高效。HTTP 协议是以明文方式发送信息的,如果黑客截取了 Web 浏览器和服务器之间的传输报文,就可以直接获得其中的信息。
HTTP原理 (1)客户端的浏览器首先要通过网络与服务器建立连接,该连接是通过 TCP 来完成的,一般 TCP 连接的端口号是80。 建立连接后,客户机发送一个请求给服务器,请求方式的格式为:统一资源标识符(URL)、协议版本号,后边是 MIME 信息包括请求修饰符、客户机信息和许可内容。 (2)服务器接到请求后,给予相应的响应信息,其格式为一个状态行,包括信息的协议版本号、一个成功或错误的代码,后边是 MIME 信息包括服务器信息、实体信息和可能的内容。
HTTPS(Hyper Text Transfer Protocol over SecureSocket Layer)是以安全为目标的 HTTP 通道,是 HTTP 的安全版。HTTPS 的安全基础是 SSL。SSL 协议位于 TCP/IP 协议与各种应用层协议之间,为数据通讯提供安全支持。SSL 协议可分为两层:SSL 记录协议(SSL Record Protocol),它建立在可靠的传输协议(如TCP)之上,为高层协议提供数据封装、压缩、加密等基本功能的支持。SSL 握手协议(SSL Handshake Protocol),它建立在 SSL 记录协议之上,用于在实际的数据传输开始前,通讯双方进行身份认证、协商加密算法、交换加密密钥等。
HTTPS的工作原理 我们都知道HTTPS能够加密信息,以免敏感信息被第三方获取,所以很多银行网站或电子邮箱等等安全级别较高的服务都会采用HTTPS协议。 客户端在使用HTTPS方式与Web服务器通信时有以下几个步骤,如图上图所示: (1)客户使用https的URL访问Web服务器,要求与Web服务器建立SSL连接。 (2)Web服务器收到客户端请求后,会将网站的证书信息(证书中包含公钥)传送一份给客户端。 (3)客户端的浏览器与Web服务器开始协商SSL连接的安全等级,也就是信息加密的等级。 (4)客户端的浏览器根据双方同意的安全等级,建立会话密钥,然后利用网站的公钥将会话密钥加密,并传送给网站。 (5)Web服务器利用自己的私钥解密出会话密钥。 (6)Web服务器利用会话密钥加密与客户端之间的通信。
HTTPS的优点 尽管HTTPS并非绝对安全,掌握根证书的机构、掌握加密算法的组织同样可以进行中间人形式的攻击,但HTTPS仍是现行架构下最安全的解决方案,主要有以下几个好处: (1)使用HTTPS协议可认证用户和服务器,确保数据发送到正确的客户机和服务器; (2)HTTPS协议是由SSL+HTTP协议构建的可进行加密传输、身份认证的网络协议,要比http协议安全,可防止数据在传输过程中不被窃取、改变,确保数据的完整性。 (3)HTTPS是现行架构下最安全的解决方案,虽然不是绝对安全,但它大幅增加了中间人攻击的成本。 (4)谷歌曾在2014年8月份调整搜索引擎算法,并称“比起同等HTTP网站,采用HTTPS加密的网站在搜索结果中的排名将会更高”。
HTTPS的缺点 虽然说HTTPS有很大的优势,但其相对来说,还是存在不足之处的: (1)HTTPS协议握手阶段比较费时,会使页面的加载时间延长近50%,增加10%到20%的耗电; (2)HTTPS连接缓存不如HTTP高效,会增加数据开销和功耗,甚至已有的安全措施也会因此而受到影响; (3)SSL证书需要钱,功能越强大的证书费用越高,个人网站、小网站没有必要一般不会用。 (4)SSL证书通常需要绑定IP,不能在同一IP上绑定多个域名,IPv4资源不可能支撑这个消耗。 (5)HTTPS协议的加密范围也比较有限,在黑客攻击、拒绝服务攻击、服务器劫持等方面几乎起不到什么作用。最关键的,SSL证书的信用链体系并不安全,特别是在某些国家可以控制CA根证书的情况下,中间人攻击一样可行。
1.30 https为什么采用混合加密机制?
参考回答
一方面,第一阶段的非对称加密,保证了对称密钥的安全性;另一方面,第二阶段的对称加密,可以提高加密/解密处理的速度,提高数据传输的效率。
答案解析
为什么需要加密? 因为http的内容是明文传输的,明文数据会经过中间代理服务器、路由器、wifi热点、通信服务运营商等多个物理节点,如果信息在传输过程中被劫持,传输的内容就完全暴露了,他还可以篡改传输的信息且不被双方察觉,这就是中间人攻击。所以我们才需要对信息进行加密。最简单容易理解的就是对称加密。
什么是对称加密? 就是有一个密钥,它可以对一段内容加密,加密后只能用它才能解密看到原本的内容,和我们日常生活中用的钥匙作用差不多。
用对称加密可行吗? 如果通信双方都各自持有同一个密钥,且没有别人知道,这两方的通信安全当然是可以被保证的(除非密钥被破解)。然而最大的问题就是这个密钥怎么让传输的双方知晓,同时不被别人知道。如果由服务器生成一个密钥并传输给浏览器,那这个传输过程中密钥被别人劫持弄到手了怎么办?之后他就能用密钥解开双方传输的任何内容了,所以这么做当然不行。 换种思路?试想一下,如果浏览器内部就预存了网站A的密钥,且可以确保除了浏览器和网站A,不会有任何外人知道该密钥,那理论上用对称加密是可以的,这样浏览器只要预存好世界上所有HTTPS网站的密钥就行啦!这么做显然不现实。 所以我们就需要神奇的非对称加密。
什么是非对称加密? 有两把密钥,通常一把叫做公钥、一把叫做私钥,用公钥加密的内容必须用私钥才能解开,同样,私钥加密的内容只有公钥能解开。
用非对称加密可行吗? 鉴于非对称加密的机制,我们可能会有这种思路:服务器先把公钥直接明文传输给浏览器,之后浏览器向服务器传数据前都先用这个公钥加密好再传,这条数据的安全似乎可以保障了!因为只有服务器有相应的私钥能解开这条数据。 然而由服务器到浏览器的这条路怎么保障安全?如果服务器用它的的私钥加密数据传给浏览器,那么浏览器用公钥可以解密它,而这个公钥是一开始通过明文传输给浏览器的,这个公钥被谁劫持到的话,他也能用该公钥解密服务器传来的信息了。所以目前似乎只能保证由浏览器向服务器传输数据时的安全性(其实仍有漏洞,下文会说)。
混合加密 非对称加密耗时,非对称加密+对称加密结合可以吗?而且得尽量减少非对称加密的次数。当然是可以的,而且非对称加密、解密各只需用一次即可。以下就是加密过程: (1)某网站拥有用于非对称加密的公钥A、私钥A’。 (2)浏览器像网站服务器请求,服务器把公钥A明文给传输浏览器。 (3)浏览器随机生成一个用于对称加密的密钥X,用公钥A加密后传给服务器。 (4)服务器拿到后用私钥A’解密得到密钥X。 (5)这样双方就都拥有密钥X了,且别人无法知道它。之后双方所有数据都用密钥X加密解密。 完美!HTTPS基本就是采用了这种方案。
1.31 https支持什么加密算法?
参考回答
常见的对称加密算法有:DES、3DES、Blowfish、IDEA、RC4、RC5、RC6和AES ;
常见的非对称加密算法有:RSA、ECC(移动设备用)、Diffie-Hellman、El Gamal、DSA(数字签名用);
常见的Hash算法有:MD2、MD4、MD5、HAVAL、SHA;
答案解析
对称加密技术 对称加密采用了对称密码编码技术,它的特点是文件加密和解密使用相同的密钥加密。也就是密钥也可以用作解密密钥,这种方法在密码学中叫做对称加密算法,对称加密算法使用起来简单快捷,密钥较短,且破译困难,除了数据加密标准(DES),另一个对称密钥加密系统是国际数据加密算法(IDEA),它比DES的加密性好,而且对计算机功能要求也没有那么高。对称加密算法在电子商务交易过程中存在几个问题:
(1)要求提供一条安全的渠道使通讯双方在首次通讯时协商一个共同的密钥。直接的面对面协商可能是不现实而且难于实施的,所以双方可能需要借助于邮件和电话等其它相对不够安全的手段来进行协商;
(2)密钥的数目难于管理。因为对于每一个合作者都需要使用不同的密钥,很难适应开放社会中大量的信息交流;
(3)对称加密算法一般不能提供信息完整性的鉴别。它无法验证发送者和接受者的身份;
(4)对称密钥的管理和分发工作是一件具有潜在危险的和烦琐的过程。对称加密是基于共同保守秘密来实现的,采用对称加密技术的贸易双方必须保证采用的是相同的密钥,保证彼此密钥的交换是安全可靠的,同时还要设定防止密钥泄密和更改密钥的程序。
假设两个用户需要使用对称加密方法加密然后交换数据,则用户最少需要2个密钥并交换使用,如果企业内用户有n个,则整个企业共需要n×(n-1) 个密钥,密钥的生成和分发将成为企业信息部门的恶梦。
常见的对称加密算法有DES、3DES、Blowfish、IDEA、RC4、RC5、RC6和AES
非对称加密技术 与对称加密算法不同,非对称加密算法需要两个密钥:公开密钥(publickey)和私有密钥(privatekey)。 公开密钥与私有密钥是一对,如果用公开密钥对数据进行加密,只有用对应的私有密钥才能解密;如果用私有密钥对数据进行加密,那么只有用对应的公开密钥才能解密。因为加密和解密使用的是两个不同的密钥,所以这种算法叫作非对称加密算法。 非对称加密算法实现机密信息交换的基本过程是:甲方生成一对密钥并将其中的一把作为公用密钥向其它方公开;得到该公用密钥的乙方使用该密钥对机密信息进行加密后再发送给甲方;甲方再用自己保存的另一把专用密钥对加密后的信息进行解密。甲方只能用其专用密钥解密由其公用密钥加密后的任何信息。 非对称加密的典型应用是数字签名。 常见的非对称加密算法有:RSA、ECC(移动设备用)、Diffie-Hellman、El Gamal、DSA(数字签名用)
Hash算法(摘要算法)
Hash算法 Hash算法特别的地方在于它是一种单向算法,用户可以通过hash算法对目标信息生成一段特定长度的唯一hash值,却不能通过这个hash值重新获得目标信息。因此Hash算法常用在不可还原的密码存储、信息完整性校验等。 常见的Hash算法有MD2、MD4、MD5、HAVAL、SHA
1.32 说一说HTTPS的秘钥交换过程。
参考回答
HTTPS的密钥交换过程如下:
客户端要访问一个网站,向支持https的服务器发起请求。
客户端向服务器发送自己支持的秘钥交换算法列表。
服务器选取一种秘钥交换算法加上CA证书返回给客户端。
客户端验证服务器是否合法,并生成一个随机数然后用协商好的加密算法加密生成随机秘钥,并用刚才从CA证书中拿到的公钥对其加密后发送给服务器。
服务器收到后用自己的私钥解密(中间人没有服务器的私钥,所以没有办法看到传输的数据,另外确认秘钥交换算法是在第一步,中间人是不知道秘钥交换算法(中间人是无法在第一步做手脚的,那等同于它自己就是一个真实客户端发起了一个新的请求,唯一一种情况攻击人有一个合法CA下发的证书,且客户端(一般为安卓设备)没有对CA下发的证书中的内容网站地址和当前请求地址做对比校验),就算攻击者有公钥,因为不知道协商协议,所以做不出来随机秘钥,顶多就是在传输过程中将报文拦截下来,乱改,但是给到服务器后,私钥是解不开乱改之后的密文的)。
服务器私钥解密之后,拿到对称秘钥,并且用它再加密一个信息,返回给浏览器。 注意:最关键的一步就是在客户端采用 RSA 或 Diffie-Hellman 等加密算法生成 Pre-master,这个随机秘钥是用来计算最终的对称秘钥的,用公钥加密之后攻击人是不知道这个这个随机秘钥的,只有服务器才能解的开。
1.33 说一说HTTPS的证书认证过程。
参考回答
HTTPS的证书认证过程如下:
浏览器将自己支持的一套加密规则发送给网站。
网站从中选出一组加密算法与HASH算法,并将自己的身份信息以证书的形式发回给浏览器。证书里面包含了网站地址,加密公钥,以及证书的颁发机构等信息。
浏览器获得网站证书之后浏览器要做以下工作: (1) 验证证书的合法性(颁发证书的机构是否合法,证书中包含的网站地址是否与正在访问的地址一致等),如果证书受信任,则浏览器栏里面会显示一个小锁头,否则会给出证书不受信的提示。 (2)如果证书受信任,或者是用户接受了不受信的证书,浏览器会生成一串随机数的密码,并用证书中提供的公钥加密。 (3)使用约定好的HASH算法计算握手消息,并使用生成的随机数对消息进行加密,最后将之前生成的所有信息发送给网站。
网站接收浏览器发来的数据之后要做以下的操作: (1) 使用自己的私钥将信息解密取出密码,使用密码解密浏览器发来的握手消息,并验证HASH是否与浏览器发来的一致。 (2) 使用密码加密一段握手消息,发送给浏览器。
浏览器解密并计算握手消息的HASH,如果与服务端发来的HASH一致,此时握手过程结束,之后所有的通信数据将由之前浏览器生成的随机密码并利用对称加密算法进行加密。
1.34 HTTP请求头中包含什么内容?
参考回答
HTTP请求头中包含如下三个内容:
User-Agent:产生请求的浏览器类型。
Accept:客户端可识别的内容类型列表。
Host:主机地址。
答案解析
请求报文(请求行/请求头/请求数据/空行) (1) 请求行 求方法字段、URL字段和HTTP协议版本 例如:GET /index.html HTTP/1.1 get方法将数据拼接在url后面,传递参数受限 请求方法: GET、POST、HEAD、PUT、DELETE、OPTIONS、TRACE、CONNECT (2) 请求头(key value形式) User-Agent:产生请求的浏览器类型。 Accept:客户端可识别的内容类型列表。 Host:主机地址 (3) 请求数据 post方法中,会把数据以key value形式发送请求 (4) 空行 发送回车符和换行符,通知服务器以下不再有请求头
响应报文(状态行、消息报头、响应正文) 状态行
消息报头
响应正文
1.35 HTTP是基于TCP还是UDP?
参考回答
HTTP是基于TCP的。
HTTP协议是建立在请求/响应模型上的。首先由客户建立一条与服务器的TCP链接,并发送一个请求到服务器,请求中包含请求方法、URI、协议版本以及 相关的MIME样式的消息。服务器响应一个状态行,包含消息的协议版本、一个成功和失败码以及相关的MIME式样的消息。 HTTP/1.0为每一次HTTP的请求/响应建立一条新的TCP链接,因此一个包含HTML内容和图片的页面将需要建立多次的短期的TCP链接。一次TCP链接的建立将需要3次握手。 另 外,为了获得适当的传输速度,则需要TCP花费额外的回路链接时间(RTT)。每一次链接的建立需要这种经常性的开销,而其并不带有实际有用的数据,只是 保证链接的可靠性,因此HTTP/1.1提出了可持续链接的实现方法。HTTP/1.1将只建立一次TCP的链接而重复地使用它传输一系列的请求/响应消 息,因此减少了链接建立的次数和经常性的链接开销。
答案解析
无
1.36 HTTP1.1和HTTP2.0有什么区别?
参考回答
HTTP2.0使用了多路复用的技术,做到同一个连接并发处理多个请求,而且并发请求的数量比HTTP1.1大了好几个数量级。HTTP1.1也可以多建立几个TCP连接,来支持处理更多并发的请求,但是创建TCP连接本身也是有开销的。
在HTTP1.1中,HTTP请求和响应都是由状态行、请求/响应头部、消息主体三部分组成。一般而言,消息主体都会经过gzip压缩,或者本身传输的就是压缩过后的二进制文件,但状态行和头部却没有经过任何压缩,直接以纯文本传输。随着Web功能越来越复杂,每个页面产生的请求数也越来越多,导致消耗在头部的流量越来越多,尤其是每次都要传输UserAgent、Cookie这类不会频繁变动的内容,完全是一种浪费。 HTTP1.1不支持header数据的压缩,HTTP2.0使用HPACK算法对header的数据进行压缩,这样数据体积小了,在网络上传输就会更快。
服务端推送是一种在客户端请求之前发送数据的机制。网页使用了许多资源:HTML、样式表、脚本、图片等等。在HTTP1.1中这些资源每一个都必须明确地请求。这是一个很慢的过程。浏览器从获取HTML开始,然后在它解析和评估页面的时候,增量地获取更多的资源。因为服务器必须等待浏览器做每一个请求,网络经常是空闲的和未充分使用的。 为了改善延迟,HTTP2.0引入了server push,它允许服务端推送资源给浏览器,在浏览器明确地请求之前,免得客户端再次创建连接发送请求到服务器端获取。这样客户端可以直接从本地加载这些资源,不用再通过网络。
答案解析
无
1.37 HTTP2.0和HTTP3.0有什么区别?
参考回答
HTTP2.0和HTTP3.0的区别在于前者使用tcp协议而后者使用udp协议。
答案解析
http发展历程:从http0.9 到 http3.0
HTTP0.9 最简单的只有请求行 GET index.html
HTTP1.0 (1)增加请求头、响应头,让请求和相应都更清晰 (2)增加状态码,让响应更清晰 (3)增加缓存功能,已请求过的内容再次请求时就可直接使用缓存
GET index.html HTTP/1.0 accept: application/html accept-charset: utf-8 accept-encoding: gzip accept-language: zh-CN HTTP/1.0 200 OK hello world!
a. accept 解决文件格式问题,是json还是html,浏览器根据不同文件格式来解析文件;
b. accept-charset 解决文件编码问题,告知浏览器如何将字符流解析成字节流;
c. accept-encoding 解决大文件压缩问题,浏览器采用指定的解压方式来解压;
d. accept-language 解决国际化问题,不同国家请求不同语言的文件。
HTTP1.1 (1)持久连接,多个http请求使用同一个tcp连接,减少了tcp建立连接时的开销 (2)客户端和服务器之间可以建立多个tcp连接以解决队头阻塞的问题 (3)响应体可以分块传输,无需一次传输全部内容 (4)响应头增加content-length字段满足动态内容无法一次计算出长度和无法一次传输完成的问题 (5)增加了安全机制和cookie机制
HTTP2.0 多路复用,客户端和服务器之间只建立一条tcp,每个http请求被切分成多帧,多个http的帧混合在一起在一个tcp连接上传送
HTTP3.0 不再使用tcp协议,因为tcp依然是顺序发送,顺序接收的,依然有队头堵塞问题,干掉tcp才能解决队头堵塞问题。google的QUIC就使用了udp协议。
1.38 谈谈HTTP的缓存机制,服务器如何判断当前缓存过期?
参考答案:
http 缓存策略 浏览器每次发起请求时,先在本地缓存中查找结果以及缓存标识,根据缓存标识来判断是否使用本地缓存。如果缓存有效,则使用本地缓存;否则,则向服务器发起请求并携带缓存标识。根据是否需向服务器发起HTTP请求,将缓存过程划分为两个部分: 强制缓存和协商缓存,强缓优先于协商缓存。
强缓存,服务器通知浏览器一个缓存时间,在缓存时间内,下次请求,直接用缓存,不在时间内,执行比较缓存策略。
协商缓存,让客户端与服务器之间能实现缓存文件是否更新的验证、提升缓存的复用率,将缓存信息中的Etag和Last-Modified 通过请求发送给服务器,由服务器校验,返回304状态码时,浏览器直接使用缓存。
HTTP缓存都是从第二次请求开始的:
第一次请求资源时,服务器返回资源,并在response header中回传资源的缓存策略;
第二次请求时,浏览器判断这些请求参数,击中强缓存就直接200,否则就把请求参数加到request header头中传给服务器,看是否击中协商缓存,击中则返回304,否则服务器会返回新的资源。这是缓存运作的一个整体流程图:
强缓存
强缓存命中则直接读取浏览器本地的资源,在network中显示的是from memory或者from disk
控制强制缓存的字段有:Cache-Control(http1.1)和Expires(http1.0)
Cache-control是一个相对时间,用以表达自上次请求正确的资源之后的多少秒的时间段内缓存有效。
Expires是一个绝对时间。用以表达在这个时间点之前发起请求可以直接从浏览器中读取数据,而无需发起请求
Cache-Control的优先级比Expires的优先级高。前者的出现是为了解决Expires在浏览器时间被手动更改导致缓存判断错误的问题。 如果同时存在则使用Cache-control。
强缓存-expires
该字段是服务器响应消息头字段,告诉浏览器在过期时间之前可以直接从浏览器缓存中存取数据。
Expires 是 HTTP 1.0 的字段,表示缓存到期时间,是一个绝对的时间 (当前时间+缓存时间)。在响应消息头中,设置这个字段之后,就可以告诉浏览器,在未过期之前不需要再次请求。
由于是绝对时间,用户可能会将客户端本地的时间进行修改,而导致浏览器判断缓存失效,重新请求该资源。此外,即使不考虑修改,时差或者误差等因素也可能造成客户端与服务端的时间不一致,致使缓存失效。
优势特点
HTTP 1.0 产物,可以在HTTP 1.0和1.1中使用,简单易用。
以时刻标识失效时间。
劣势问题
时间是由服务器发送的(UTC),如果服务器时间和客户端时间存在不一致,可能会出现问题。
存在版本问题,到期之前的修改客户端是不可知的。
强缓存-cache-control
已知Expires的缺点之后,在HTTP/1.1中,增加了一个字段Cache-control,该字段表示资源缓存的最大有效时间,在该时间内,客户端不需要向服务器发送请求。
这两者的区别就是前者是绝对时间,而后者是相对时间。下面列举一些 Cache-control 字段常用的值:(完整的列表可以查看MDN)
max-age:即最大有效时间。
must-revalidate:如果超过了 max-age 的时间,浏览器必须向服务器发送请求,验证资源是否还有效。
no-cache:不使用强缓存,需要与服务器验证缓存是否新鲜。
no-store: 真正意义上的“不要缓存”。所有内容都不走缓存,包括强制和对比。
public:所有的内容都可以被缓存 (包括客户端和代理服务器, 如 CDN)
private:所有的内容只有客户端才可以缓存,代理服务器不能缓存。默认值。
Cache-control 的优先级高于 Expires,为了兼容 HTTP/1.0 和 HTTP/1.1,实际项目中两个字段都可以设置。
该字段可以在请求头或者响应头设置,可组合使用多种指令:
可缓存性
public:浏览器和缓存服务器都可以缓存页面信息
private:default,代理服务器不可缓存,只能被单个用户缓存
no-cache:浏览器器和服务器都不应该缓存页面信息,但仍可缓存,只是在缓存前需要向服务器确认资源是否被更改。可配合private, 过期时间设置为过去时间。
only-if-cache:客户端只接受已缓存的响应
到期
max-age=:缓存存储的最大周期,超过这个周期被认为过期。
s-maxage=:设置共享缓存,比如can。会覆盖max-age和expires。
max-stale[=]:客户端愿意接收一个已经过期的资源
min-fresh=:客户端希望在指定的时间内获取最新的响应
stale-while-revalidate=:客户端愿意接收陈旧的响应,并且在后台一部检查新的响应。时间代表客户端愿意接收陈旧响应 的时间长度。
stale-if-error=:如新的检测失败,客户端则愿意接收陈旧的响应,时间代表等待时间。
重新验证和重新加载
must-revalidate:如页面过期,则去服务器进行获取。
proxy-revalidate:用于共享缓存。
immutable:响应正文不随时间改变。
其他
no-store:绝对禁止缓存
no-transform:不得对资源进行转换和转变。例如,不得对图像格式进行转换。
优势特点
HTTP 1.1 产物,以时间间隔标识失效时间,解决了Expires服务器和客户端相对时间的问题。
比Expires多了很多选项设置。
劣势问题
存在版本问题,到期之前的修改客户端是不可知的。
协商缓存
协商缓存的状态码由服务器决策返回200或者304
当浏览器的强缓存失效的时候或者请求头中设置了不走强缓存,并且在请求头中设置了If-Modified-Since 或者 If-None-Match 的时候,会将这两个属性值到服务端去验证是否命中协商缓存,如果命中了协商缓存,会返回 304 状态,加载浏览器缓存,并且响应头会设置 Last-Modified 或者 ETag 属性。
对比缓存在请求数上和没有缓存是一致的,但如果是 304 的话,返回的仅仅是一个状态码而已,并没有实际的文件内容,因此 在响应体体积上的节省是它的优化点。
协商缓存有 2 组字段(不是两个),控制协商缓存的字段有:Last-Modified/If-Modified-since(http1.0)和Etag/If-None-match(http1.1)
Last-Modified/If-Modified-since表示的是服务器的资源最后一次修改的时间;Etag/If-None-match表示的是服务器资源的唯一标 识,只要资源变化,Etag就会重新生成。
Etag/If-None-match的优先级比Last-Modified/If-Modified-since高。
协商缓存-协商缓存-Last-Modified/If-Modified-since
服务器通过 Last-Modified 字段告知客户端,资源最后一次被修改的时间,例如 Last-Modified: Mon, 10 Nov 2018 09:10:11 GMT
浏览器将这个值和内容一起记录在缓存数据库中。
下一次请求相同资源时时,浏览器从自己的缓存中找出“不确定是否过期的”缓存。因此在请求头中将上次的 Last-Modified 的值写入到请求头的 If-Modified-Since 字段
服务器会将 If-Modified-Since 的值与 Last-Modified 字段进行对比。如果相等,则表示未修改,响应 304;反之,则表示修改了,响应 200 状态码,并返回数据。
优势特点
不存在版本问题,每次请求都会去服务器进行校验。服务器对比最后修改时间如果相同则返回304,不同返回200以及资源内容。
劣势问题
只要资源修改,无论内容是否发生实质性的变化,都会将该资源返回客户端。例如周期性重写,这种情况下该资源包含的数据实际上一样的。
以时刻作为标识,无法识别一秒内进行多次修改的情况。 如果资源更新的速度是秒以下单位,那么该缓存是不能被使用的,因为它的时间单位最低是秒。
某些服务器不能精确的得到文件的最后修改时间。
如果文件是通过服务器动态生成的,那么该方法的更新时间永远是生成的时间,尽管文件可能没有变化,所以起不到缓存的作用。
协商缓存-Etag/If-None-match
为了解决上述问题,出现了一组新的字段 Etag 和 If-None-Match
Etag 存储的是文件的特殊标识(一般都是 hash 生成的),服务器存储着文件的 Etag 字段。之后的流程和 Last-Modified 一致,只是 Last-Modified 字段和它所表示的更新时间改变成了 Etag 字段和它所表示的文件 hash,把 If-Modified-Since 变成了 If-None-Match。服务器同样进行比较,命中返回 304, 不命中返回新资源和 200。
浏览器在发起请求时,服务器返回在Response header中返回请求资源的唯一标识。在下一次请求时,会将上一次返回的Etag值赋值给If-No-Matched并添加在Request Header中。服务器将浏览器传来的if-no-matched跟自己的本地的资源的ETag做对比,如果匹配,则返回304通知浏览器读取本地缓存,否则返回200和更新后的资源。
Etag 的优先级高于 Last-Modified。
优势特点
可以更加精确的判断资源是否被修改,可以识别一秒内多次修改的情况。
不存在版本问题,每次请求都回去服务器进行校验。
劣势问题
计算ETag值需要性能损耗。
分布式服务器存储的情况下,计算ETag的算法如果不一样,会导致浏览器从一台服务器上获得页面内容后到另外一台服务器上进行验证时现ETag不匹配的情况。
1.39 介绍一下HTTP协议中的长连接和短连接。
参考回答
HTTP协议的底层使用TCP协议,所以HTTP协议的长连接和短连接在本质上是TCP层的长连接和短连接。由于TCP建立连接、维护连接、释放连接都是要消耗一定的资源,浪费一定的时间。所对于服务器来说,频繁的请求释放连接会浪费大量的时间,长时间维护太多的连接的话又需要消耗资源。所以长连接和短连接并不存在优劣之分,只是适用的场合不同而已。长连接和短连接分别有如下优点和缺点:
长连接优点:可以节省较多的TCP连接和释放的操作,节约时间,对于频繁请求资源的用户来说,适合长连接。
长连接缺点:由于有保活功能,当遇到大量的恶意连接时,服务器的压力会越来越大。这时服务器需要采取一些策略,关闭一些长时间没有进行读写事件的的连接。
短连接优点:短连接对服务器来说管理比较简单,只要存在的连接都是有效连接,不需要额外的控制手段,而且不会长时间占用资源 。
短连接缺点:如果客户端请求频繁的话,会在TCP的建立和释放上浪费大量的时间。
注意:从HTTP/1.1版本起,默认使用长连接用以保持连接特性。使用长连接的HTTP协议,会在响应消息报文段加入: Connection: keep-alive。TCP中也有keep alive,但是TCP中的keep alive只是探测TCP连接是否活着,而HTTP中的keep-alive是让一个TCP连接获得更久一点。
答案解析
无
1.40 介绍一下HTTPS的流程。
参考回答
HTTPS在传输的过程中会涉及到三个密钥:服务器端的公钥和私钥,用来进行非对称加密;客户端生成的随机密钥,用来进行对称加密。一个HTTPS请求实际上包含了两次HTTP传输,如下图可以细分为以下8步:
客户端向服务器发起HTTPS请求,连接到服务器的443端口
服务器端有一个密钥对,即公钥和私钥,是用来进行非对称加密使用的,服务器端保存着私钥,不能将其泄露,公钥可以发送给任何人。
服务器将自己的公钥发送给客户端。
客户端收到服务器端的公钥之后,会对公钥进行检查,验证其合法性,如果发现发现公钥有问题,那么HTTPS传输就无法继续。严格的说,这里应该是验证服务器发送的数字证书的合法性,关于客户端如何验证数字证书的合法性,下文会进行说明。如果公钥合格,那么客户端会生成一个随机值,这个随机值就是用于进行对称加密的密钥,我们将该密钥称之为client key,即客户端密钥,这样在概念上和服务器端的密钥容易进行区分。然后用服务器的公钥对客户端密钥进行非对称加密,这样客户端密钥就变成密文了,至此,HTTPS中的第一次HTTP请求结束。
客户端会发起HTTPS中的第二个HTTP请求,将加密之后的客户端密钥发送给服务器。
服务器接收到客户端发来的密文之后,会用自己的私钥对其进行非对称解密,解密之后的明文就是客户端密钥,然后用客户端密钥对数据进行对称加密,这样数据就变成了密文。
然后服务器将加密后的密文发送给客户端。
客户端收到服务器发送来的密文,用客户端密钥对其进行对称解密,得到服务器发送的数据。这样HTTPS中的第二个HTTP请求结束,整个HTTPS传输完成。
答案解析
无
1.41 介绍一下HTTP的失败码。
参考回答
HTTP的错误码包含客户端错误4XX 和服务端错误5XX ,两种错误分别如下:
客户端错误 4XX 这类的状态码是适用于客户端似乎有错误的情况。除了响应给HEAD请求外,服务器应该包含一个包括错误情况描述的实体,和它是暂时的还是永久性的。这些状态码适用于任何请求方法。用户代理应该展示所有包含的实体给用户。 如果客户端正在发送数据,使用TCP的服务器应该在服务器关闭输出链接时,仔细确保客户端确认收到包含响应的数据包(receipt of the packet(s) ) 。如果客户端继续在服务器关闭后发送数据,服务器的TCP栈将会发生一个重置包给客户端,这可能会在 HTTP 应用程序读取和解释客户端的未确认输入缓冲区(input buffers)之前将其擦除。 400(错误请求) 服务器不理解请求的语法。 401(未授权) 请求要求进行身份验证。登录后,服务器可能会返回对页面的此响应。 403(已禁止) 服务器拒绝请求。如果在 Googlebot 尝试抓取您网站上的有效网页时显示此状态代码(您可在 Google 网站管理员工具中诊断下的网络抓取页面上看到此状态代码),那么,这可能是您的服务器或主机拒绝 Googlebot 对其进行访问。 404(未找到) 服务器找不到请求的网页。例如,如果请求是针对服务器上不存在的网页进行的,那么,服务器通常会返回此代码。 如果您的网站上没有 robots.txt 文件,而您在 Google 网站管理员工具”诊断”标签的 robots.txt 页上发现此状态,那么,这是正确的状态。然而,如果您有 robots.txt 文件而又发现了此状态,那么,这说明您的 robots.txt 文件可能是命名错误或位于错误的位置。(该文件应当位于顶级域名上,且应当名为 robots.txt)。 如果您在 Googlebot 尝试抓取的网址上发现此状态(位于”诊断”标签的 HTTP 错误页上),那么,这表示 Googlebot 所追踪的可能是另一网页中的无效链接(旧链接或输入有误的链接)。 405(方法禁用) 禁用请求中所指定的方法。 406(不接受) 无法使用请求的内容特性来响应请求的网页。 407(需要代理授权) 此状态代码与 401(未授权)类似,但却指定了请求者应当使用代理进行授权。如果服务器返回此响应,那么,服务器还会指明请求者应当使用的代理。 408(请求超时) 服务器等候请求时超时。 409(冲突) 服务器在完成请求时发生冲突。服务器必须包含有关响应中所发生的冲突的信息。服务器在响应与前一个请求相冲突的 PUT 请求时可能会返回此代码,同时会提供两个请求的差异列表。 410(已删除) 如果请求的资源已被永久删除,那么,服务器会返回此响应。该代码与 404(未找到)代码类似,但在资源以前有但现在已经不复存在的情况下,有时会替代 404 代码出现。如果资源已被永久删除,那么,您应当使用 301 代码指定该资源的新位置。 411(需要有效长度) 服务器不会接受包含无效内容长度标头字段的请求。 412(未满足前提条件) 服务器未满足请求者在请求中设置的其中一个前提条件。 413(请求实体过大) 服务器无法处理请求,因为请求实体过大,已超出服务器的处理能力。 414(请求的 URI 过长) 请求的 URI(通常为网址)过长,服务器无法进行处理。 415(不支持的媒体类型) 请求的格式不受请求页面的支持。 416(请求范围不符合要求) 如果请求是针对网页的无效范围进行的,那么,服务器会返回此状态代码。 417(未满足期望值) 服务器未满足”期望”请求标头字段的要求。
服务端错误 5XX 响应状态码已数字5开头,表明了这类服务器知道其错误或者无法执行请求的情况。出了响应HEAD请求外,服务器应该包括一个包含错误情况说明的实体,以及他是暂时地还是永久性的,用户代理应该将所有包含的实体展示给用户。这些响应代码适用于任何请求方法。 500(服务器内部错误) 服务器遇到错误,无法完成请求。 501(尚未实施) 服务器不具备完成请求的功能。例如,当服务器无法识别请求方法时,服务器可能会返回此代码。 502(错误网关) 服务器作为网关或代理,从上游服务器收到了无效的响应。 503(服务不可用) 目前无法使用服务器(由于超载或进行停机维护)。通常,这只是一种暂时的状态。 504(网关超时) 服务器作为网关或代理,未及时从上游服务器接收请求。 505(HTTP 版本不受支持) 服务器不支持请求中所使用的 HTTP 协议版本。
1.42 说一说你知道的http状态码。
参考回答
HTTP状态码由三个十进制数字组成,第一个十进制数字定义了状态码的类型,后两个数字没有分类的作用。HTTP状态码共分为5种类型,分类及分类描述如下表:
各类别常见状态码有如下几种:
2xx (3种) 200 OK:表示从客户端发送给服务器的请求被正常处理并返回; 204 No Content:表示客户端发送给客户端的请求得到了成功处理,但在返回的响应报文中不含实体的主体部分(没有资源可以返回); 206 Patial Content:表示客户端进行了范围请求,并且服务器成功执行了这部分的GET请求,响应报文中包含由Content-Range指定范围的实体内容。
3xx (5种) 301 Moved Permanently:永久性重定向,表示请求的资源被分配了新的URL,之后应使用更改的URL; 302 Found:临时性重定向,表示请求的资源被分配了新的URL,希望本次访问使用新的URL; 301与302的区别:前者是永久移动,后者是临时移动(之后可能还会更改URL) 303 See Other:表示请求的资源被分配了新的URL,应使用GET方法定向获取请求的资源; 302与303的区别:后者明确表示客户端应当采用GET方式获取资源 304 Not Modified:表示客户端发送附带条件(是指采用GET方法的请求报文中包含if-Match、If-Modified-Since、If-None-Match、If-Range、If-Unmodified-Since中任一首部)的请求时,服务器端允许访问资源,但是请求为满足条件的情况下返回该状态码; 307 Temporary Redirect:临时重定向,与303有着相同的含义,307会遵照浏览器标准不会从POST变成GET;(不同浏览器可能会出现不同的情况);
4xx (4种) 400 Bad Request:表示请求报文中存在语法错误; 401 Unauthorized:未经许可,需要通过HTTP认证; 403 Forbidden:服务器拒绝该次访问(访问权限出现问题) 404 Not Found:表示服务器上无法找到请求的资源,除此之外,也可以在服务器拒绝请求但不想给拒绝原因时使用;
5xx (2种) 500 Inter Server Error:表示服务器在执行请求时发生了错误,也有可能是web应用存在的bug或某些临时的错误时; 503 Server Unavailable:表示服务器暂时处于超负载或正在进行停机维护,无法处理请求;
答案解析
无
1.43 301和302有什么区别?
参考回答
301和302的区别在于,301重定向是永久的重定向,搜索引擎在抓取新内容的同时也将旧的网址交换为重定向之后的网址。302重定向是暂时的重定向,搜索引擎会抓取新的内容而保存旧的网址。由于效劳器前往302代码,搜索引擎以为新的网址只是暂时的。
1.44 302和304有什么区别?
参考回答
302和304是网页请求的两个不同的响应状态码。302 (临时移动)表示 服务器目前从不同位置的网页响应请求,但请求者应继续使用原有位置来进行以后的请求。 304 (未修改)表示 自从上次请求后,请求的网页未修改过。 服务器返回此响应时,不会返回网页内容。
答案解析
无
1.45 请描述一次完整的HTTP请求的过程。
参考回答
DNS解析流程图
首先客户端位置是一台电脑或手机,在打开浏览器以后,比如输入http://www.zdns.cn的域名,它首先是由浏览器发起一个DNS解析请求,如果本地缓存服务器中找不到结果,则首先会向根服务器查询,根服务器里面记录的都是各个顶级域所在的服务器的位置,当向根服务器请求http://www.zdns.cn的时候,根服务器就会返回.cn服务器的位置信息;
递归服务器拿到.cn的权威服务器地址以后,就会寻问.cn的权威服务器,知不知道http://www.zdns.cn的位置。这个时候.cn权威服务器查找并返回http://zdns.cn服务器的地址;
继续向http://zdns.cn的权威服务器去查询这个地址,由http://zdns.cn的服务器给出了地址:202.173.11.10;
最终进入http的链接,顺利访问网站;
补充说明:一旦递归服务器拿到解析记录以后,就会在本地进行缓存,如果下次客户端再请求本地的递归域名服务器相同域名的时候,就不会再这样一层一层查了,因为本地服务器里面已经有缓存了,这个时候就直接把http://www.zdns.cn的记录返回给客户端就可以了。
1.46 什么是重定向?
参考回答
重定向(Redirect)就是通过各种方法将各种网络请求重新定个方向转到其它位置(如:网页重定向、域名的重定向、路由选择的变化也是对数据报文经由路径的一种重定向)。
答案解析
需要重定向的情况 (1)网站调整(如改变网页目录结构); (2)网页被移到一个新地址; (3)网页扩展名改变(如应用需要把.php改成.Html或.shtml)。 这几种情况下,如果不做重定向,则用户收藏夹或搜索引擎数据库中旧地址只能让访问客户得到一个404 页面错误信息,访问流量白白丧失;再者某些注册了多个域名的网站,也需要通过重定向让访问这些域名的用户自动跳转到主站点等。
常用的重定向的方式 (1)301 redirect-----永久性转移 当用户或搜索引擎向网站服务器发出浏览请求时,服务器返回的HTTP数据流中头信息(header)中的状态码的一种,表示本网页永久性转移到另一个地址。 (2)302 redirect-----暂时性转移 (Temporarily Moved ) 也被认为是暂时重定向(temporary redirect),一条对网站浏览器的指令来显示浏览器被要求显示的不同的URL,当一个网页经历过短期的URL的变化时使用。一个暂时重定向是一种服务器端的重定向,能够被搜索引擎蜘蛛正确地处理。
新旧重定向方式的区别 302重定向是暂时的重定向,搜索引擎会抓取新的内容而保存旧的网址。由于效劳器前往302代码,搜索引擎以为新的网址只是暂时的; 301重定向是永久的重定向,搜索引擎在抓取新内容的同时也将旧的网址交换为重定向之后的网址。
为什么302 重定向和网址劫持有关联 从网址A 做一个302 重定向到网址B 时,主机服务器的隐含意思是网址A 随时有可能改主意,重新显示本身的内容或转向其他的地方。大部分的搜索引擎在大部分情况下,当收到302 重定向时,一般只要去抓取目标网址就可以了,也就是说网址B。如果搜索引擎在遇到302 转向时,百分之百的都抓取目标网址B 的话,就不用担心网址URL 劫持了。问题就在于,有的时候搜索引擎,尤其是Google,并不能总是抓取目标网址。 比如说,有的时候A 网址很短,但是它做了一个302 重定向到B 网址,而B 网址是一个很长的乱七八糟的URL 网址,甚至还有可能包含一些问号之类的参数。很自然的,A 网址更加用户友好,而B 网址既难看,又不用户友好。这时Google 很有可能会仍然显示网址A。由于搜索引擎排名算法只是程序而不是人,在遇到302 重定向的时候,并不能像人一样的去准确判定哪一个网址更适当,这就造成了网址URL 劫持的可能性。也就是说,一个不道德的人在他自己的网址A 做一个302 重定向到你的网址B,出于某种原因, Google 搜索结果所显示的仍然是网址A,但是所用的网页内容却是你的网址B 上的内容,这种情况就叫做网址URL 劫持。你辛辛苦苦所写的内容就这样被别人偷走了。 302 重定向所造成的网址URL 劫持现象,已经存在一段时间了。不过到目前为止,似乎也没有什么更好的解决方法。在正在进行的数据中心转换中,302 重定向问题也是要被解决的目标之一。从一些搜索结果来看,网址劫持现象有所改善,但是并没有完全解决。
1.47 重定向和请求转发有什么区别?
参考回答
请求转发 客户首先发送一个请求到服务器端,服务器端发现匹配的servlet,并指定它去执行,当这个servlet执行完之后,它要调用getRequestDispacther()方法,把请求转发给指定的student_list.jsp,整个流程都是在服务器端完成的,而且是在同一个请求里面完成的,因此servlet和jsp共享的是同一个request,在servlet里面放的所有东西,在student_list中都能取出来,因此,student_list能把结果getAttribute()出来,getAttribute()出来后执行完把结果返回给客户端。整个过程是一个请求,一个响应。
重定向 客户发送一个请求到服务器,服务器匹配servlet,servlet处理完之后调用了sendRedirect()方法,立即向客户端返回这个响应,响应行告诉客户端你必须要再发送一个请求,去访问student_list.jsp,紧接着客户端收到这个请求后,立刻发出一个新的请求,去请求student_list.jsp,这里两个请求互不干扰,相互独立,在前面request里面setAttribute()的任何东西,在后面的request里面都获得不了。可见,在sendRedirect()里面是两个请求,两个响应。(服务器向浏览器发送一个302状态码以及一个location消息头,浏览器收到请求后会向再次根据重定向地址发出请求)
二者区别 (1)请求次数:重定向是浏览器向服务器发送一个请求并收到响应后再次向一个新地址发出请求,转发是服务器收到请求后为了完成响应跳转到一个新的地址;重定向至少请求两次,转发请求一次; (2)地址栏不同:重定向地址栏会发生变化,转发地址栏不会发生变化; (3)是否共享数据:重定向两次请求不共享数据,转发一次请求共享数据(在request级别使用信息共享,使用重定向必然出错); (4)跳转限制:重定向可以跳转到任意URL,转发只能跳转本站点资源; (5)发生行为不同:重定向是客户端行为,转发是服务器端行为。
答案解析
无
1.48 介绍一下DNS寻址的过程。
参考回答
DNS解析流程图
首先客户端位置是一台电脑或手机,在打开浏览器以后,比如输入http://www.zdns.cn的域名,它首先是由浏览器发起一个DNS解析请求,如果本地缓存服务器中找不到结果,则首先会向根服务器查询,根服务器里面记录的都是各个顶级域所在的服务器的位置,当向根服务器请求http://www.zdns.cn的时候,根服务器就会返回.cn服务器的位置信息;
递归服务器拿到.cn的权威服务器地址以后,就会寻问.cn的权威服务器,知不知道http://www.zdns.cn的位置。这个时候.cn权威服务器查找并返回http://zdns.cn服务器的地址;
继续向http://zdns.cn的权威服务器去查询这个地址,由http://zdns.cn的服务器给出了地址:202.173.11.10;
最终进入http的链接,顺利访问网站;
补充说明:一旦递归服务器拿到解析记录以后,就会在本地进行缓存,如果下次客户端再请求本地的递归域名服务器相同域名的时候,就不会再这样一层一层查了,因为本地服务器里面已经有缓存了,这个时候就直接把http://www.zdns.cn的记录返回给客户端就可以了。
答案解析
什么是DNS DNS就是域名系统,是因特网中的一项核心服务,是用于实现域名和IP地址相互映射的一个分布式数据库,能够使用户更方便的访问互联网,而不用去记住能够被机器直接读取的IP数串。通过主机名,得到该主机名对应的IP地址的过程叫做域名解析(或主机名解析)。
域名解析结构 如上图所示,域名结构是树状结构,树的最顶端代表根服务器,根的下一层就是由我们所熟知的.com、.net、.cn等通用域和.cn、.uk等国家域组成,称为顶级域。网上注册的域名基本都是二级域名,比如http://baidu.com、http://taobao.com等等二级域名,它们基本上是归企业和运维人员管理。接下来是三级或者四级域名,这里不多赘述。总体概括来说域名是由整体到局部的机制结构。
1.49 说一说你对TIME_WAIT的理解。
参考回答
出现 TIME_WAIT的状态原因 TIME_WAIT状态之所以存在,是为了保证网络的可靠性。由于TCP连接是双向的,所以在关闭连接的时候,两个方向各自都需要关闭。先发FIN包的一方执行的是主动关闭,后发送FIN包的一方执行的是被动关闭。主动关闭的一方会进入TIME_WAIT状态,并且在此状态停留2MSL时长。如果Server端一直没有向client端发送FIN消息(调用close() API),那么这个CLOSE_WAIT会一直存在下去。
MSL概念 其指的是报文段的最大生存时间。如果报文段在网络中活动了MSL时间,还没有被接收,那么就会被丢弃。关于MSL的大小,RFC 793协议中给出的建议是2分钟,不过Linux中,通常是半分钟。
TIME_WAIT持续两个MSL的作用 首先,可靠安全地关闭TCP连接。比如网络拥塞,如果主动关闭方最后一个ACK没有被被动关闭方接收到,这时被动关闭方会对FIN进行超时重传,在这时尚未关闭的TIME_WAIT就会把这些尾巴问题处理掉,不至于对新连接及其他服务产生影响。其次,防止由于没有持续TIME_WAIT时间导致的新的TCP连接建立起来,延迟的FIN重传包会干扰新的连接。
TIME_WAIT占用的资源 少量内存(大概4K)和一个文件描述符fd。
TIME_WAIT关闭的危害 首先,当网络情况不好时,如果主动方无TIME_WAIT等待,关闭前个连接后,主动方与被动方又建立起新的TCP连接,这时被动方重传或延时过来的FIN包到达后会直接影响新的TCP连接;其次,当网络情况不好时,同时没有TIME_WAIT等待时,关闭连接后无新连接,那么当接收到被动方重传或延迟的FIN包后,会给被动方回送一个RST包,可能会影响被动方其他的服务连接。
答案解析
当client端传输完成数据,或者需要断开连接时:
Client端发送一个FIN报文给Server端。表示要终止Client到Server这个方向的连接。通过调用close(socket) API。表示Client不再会发送数据到Server端。(但Server还能继续发给Client端)。Client状态变为FIN_WAIT_1。
Server端收到FIN后,发送一个ACK报文给Client端(序号为M+1)。Server状态变为CLOSE_WAIT,Client收到序号为(M+1)的ACK后状态变为FIN_WAIT_2。Server端也发送一个FIN报文给Client端。(序号为N) 表示Server也要终止到Client端这个方向的连接。通过调用close(socket) API。Server端状态变为LAST_ACK。
Client端收到报文FIN后,也发送一个ACK报文给服务器。(序号N+1),Client状态变为TIME_WAIT。
Server端收到序号为(N+1)的ACK, Server的状态变为CLOSED。
等带2MSL之后,Client的状态也变为CLOSE。
至此,一个完整的TCP连接就关闭了。
1.50 TIME_WAIT、CLOSE_WAIT状态发生在哪一步?
参考回答
TIME_WAIT状态发生在客户端主动关闭连接时,发送最后一个ack后;CLOSE_WAIT状态发生在在Sever端收到Client的FIN消息之后。
出现 TIME_WAIT的状态原因 TIME_WAIT状态之所以存在,是为了保证网络的可靠性。由于TCP连接是双向的,所以在关闭连接的时候,两个方向各自都需要关闭。先发FIN包的一方执行的是主动关闭,后发送FIN包的一方执行的是被动关闭。主动关闭的一方会进入TIME_WAIT状态,并且在此状态停留2MSL时长。如果Server端一直没有向client端发送FIN消息(调用close() API),那么这个CLOSE_WAIT会一直存在下去。
出现CLOSE_WAIT的状态原因 假设最终的ACK丢失,server将重发FIN,client必须维护TCP状态信息以便可以重发最终的ACK,否则会发送RST,结果server认为发生错误。TCP实现必须可靠地终止连接的两个方向(全双工关闭),client必须进入 TIME_WAIT 状态,因为client可能面临重发最终ACK的情形。
为什么 TIME_WAIT 状态需要保持 2MSL 这么长的时间? 如果 TIME_WAIT 状态保持时间不足够长(比如小于2MSL),第一个连接就正常终止了。第二个拥有相同相关五元组的连接出现,而第一个连接的重复报文到达,干扰了第二个连接。TCP实现必须防止某个连接的重复报文在连接终止后出现,所以让TIME_WAIT状态保持时间足够长(2MSL),连接相应方向上的TCP报文要么完全响应完毕,要么被丢弃。建立第二个连接的时候,不会混淆。
答案解析
当client端传输完成数据,或者需要断开连接时:
Client端发送一个FIN报文给Server端。表示要终止Client到Server这个方向的连接。通过调用close(socket) API。表示Client不再会发送数据到Server端。(但Server还能继续发给Client端)。Client状态变为FIN_WAIT_1。
Server端收到FIN后,发送一个ACK报文给Client端(序号为M+1)。Server状态变为CLOSE_WAIT,Client收到序号为(M+1)的ACK后状态变为FIN_WAIT_2。Server端也发送一个FIN报文给Client端。(序号为N) 表示Server也要终止到Client端这个方向的连接。通过调用close(socket) API。Server端状态变为LAST_ACK。
Client端收到报文FIN后,也发送一个ACK报文给服务器。(序号N+1),Client状态变为TIME_WAIT。
Server端收到序号为(N+1)的ACK, Server的状态变为CLOSED。
等带2MSL之后,Client的状态也变为CLOSE。
至此,一个完整的TCP连接就关闭了。
1.51 有大量的TIME_WAIT状态怎么办?
参考回答
time_wait 状态的影响 TCP 连接中,主动发起关闭连接的一端,会进入 time_wait 状态,time_wait 状态,默认会持续 2 MSL(报文的最大生存时间),一般是 2x2 mins,time_wait 状态下,TCP 连接占用的端口,无法被再次使用,TCP 端口数量,上限是 6.5w(65535,16 bit),大量 time_wait 状态存在,会导致新建 TCP 连接会出错,address already in use : connect异常。
解决办法 (1)客户端:HTTP 请求的头部,connection 设置为 keep-alive,保持存活一段时间:现在的浏览器,一般都这么进行了 。 (2)服务器端 a. 允许 time_wait状态的 socket 被重用 b. 缩减 time_wait 时间,设置为 1 MSL(即,2 mins)
答案解析
无
1.52 请介绍socket通信的具体步骤。
参考回答
sockets(套接字)编程有三种:流式套接字(SOCK_STREAM),数据报套接字(SOCK_DGRAM),原始套接字(SOCK_RAW);基于TCP的socket编程是采用的流式套接字。
服务器端编程的步骤 (1)加载套接字库,创建套接字(WSAStartup()/socket()); (2)绑定套接字到一个IP地址和一个端口上(bind()); (3)将套接字设置为监听模式等待连接请求(listen()); (4)请求到来后,接受连接请求,返回一个新的对应于此次连接的套接字(accept()); (5)用返回的套接字和客户端进行通信(send()/recv()); (6)返回,等待另一连接请求; (7)关闭套接字,关闭加载的套接字库(closesocket()/WSACleanup())。
客户端编程的步骤: (1)加载套接字库,创建套接字(WSAStartup()/socket()); (2)向服务器发出连接请求(connect()); (3)和服务器端进行通信(send()/recv()); (4)关闭套接字,关闭加载的套接字库(closesocket()/WSACleanup())。
答案解析 //代码实例(服务器) #include #include void main() { WORD wVersionRequested; WSADATA wsaData; int err; wVersionRequested = MAKEWORD( 1, 1 ); err = WSAStartup( wVersionRequested, &wsaData ); if ( err != 0 ) { return; } if ( LOBYTE( wsaData.wVersion ) != 1 || HIBYTE( wsaData.wVersion ) != 1 ) { WSACleanup( ); return; } SOCKET sockSrv=socket(AF_INET,SOCK_STREAM,0); SOCKADDR_IN addrSrv; addrSrv.sin_addr.S_un.S_addr=htonl(INADDR_ANY); addrSrv.sin_family=AF_INET; addrSrv.sin_port=htons(6000); bind(sockSrv,(SOCKADDR*)&addrSrv,sizeof(SOCKADDR)); listen(sockSrv,5); SOCKADDR_IN addrClient; int len=sizeof(SOCKADDR); while(1) { SOCKET sockConn=accept(sockSrv,(SOCKADDR*)&addrClient,&len); char sendBuf[50]; sprintf(sendBuf,"Welcome %s to here!",inet_ntoa(addrClient.sin_addr)); send(sockConn,sendBuf,strlen(sendBuf)+1,0); char recvBuf[50]; recv(sockConn,recvBuf,50,0); printf("%s\n",recvBuf); closesocket(sockConn); } } //代码实例(客户端) #include #include void main() { WORD wVersionRequested; WSADATA wsaData; int err; wVersionRequested = MAKEWORD( 1, 1 ); err = WSAStartup( wVersionRequested, &wsaData ); if ( err != 0 ) { return; } if ( LOBYTE( wsaData.wVersion ) != 1 || HIBYTE( wsaData.wVersion ) != 1 ) { WSACleanup( ); return; } SOCKET sockClient=socket(AF_INET,SOCK_STREAM,0); SOCKADDR_IN addrSrv; addrSrv.sin_addr.S_un.S_addr=inet_addr("127.0.0.1"); addrSrv.sin_family=AF_INET; addrSrv.sin_port=htons(6000); connect(sockClient,(SOCKADDR*)&addrSrv,sizeof(SOCKADDR)); send(sockClient,"hello",strlen("hello")+1,0); char recvBuf[50]; recv(sockClient,recvBuf,50,0); printf("%s\n",recvBuf); closesocket(sockClient); WSACleanup(); }
1.53 服务端怎么提高处理socket连接的性能?
参考回答
提高处理socket连接的性能,请遵循以下技巧:
最小化报文传输的延时。
最小化系统调用的负载。
为 Bandwidth Delay Product 调节 TCP 窗口。
动态优化 GNU/Linux TCP/IP 栈。
答案解析
最小化报文传输的延时。 在通过 TCP socket 进行通信时,数据都拆分成了数据块,这样它们就可以封装到给定连接的 TCP payload(指 TCP 数据包中的有效负荷)中了。TCP payload 的大小取决于几个因素(例如最大报文长度和路径),但是这些因素在连接发起时都是已知的。为了达到最好的性能,我们的目标是使用尽可能多的可用数据来填充每个报文。当没有足够的数据来填充 payload 时(也称为最大报文段长度(maximum segment size)或 MSS),TCP 就会采用 Nagle 算法自动将一些小的缓冲区连接到一个报文段中。这样可以通过最小化所发送的报文的数量来提高应用程序的效率,并减轻整体的网络拥塞问题。
最小化系统调用的负载。 任何时候通过一个 socket 来读写数据时,都是在使用一个系统调用(system call)。这个调用(例如 read 或 write)跨越了用户空间应用程序与内核的边界。另外,在进入内核之前,该调用会通过 C 库来进入内核中的一个通用函数(system_call())。从 system_call()中,这个调用会进入文件系统层,内核会在这儿确定正在处理的是哪种类型的设备。最后,调用会进入 socket 层,数据就是在这里进行读取或进行排队从而通过 socket 进行传输的(这涉及数据的副本)。 这个过程说明系统调用不仅仅是在应用程序和内核中进行操作的,而且还要经过应用程序和内核中的很多层次。这个过程耗费的资源很高,因此调用次数越多,通过这个调用链进行的工作所需要的时间就越长,应用程序的性能也就越低。由于我们无法避免这些系统调用,因此唯一的选择是最小化使用这些调用的次数。
为 Bandwidth Delay Product 调节 TCP 窗口。 TCP 的性能取决于几个方面的因素。两个最重要的因素是链接带宽(link bandwidth)(报文在网络上传输的速率)和 往返时间(round-trip time) 或 RTT(发送报文与接收到另一端的响应之间的延时)。这两个值确定了称为 Bandwidth Delay Product(BDP)的内容。 给定链接带宽和 RTT 之后,就可以计算出 BDP 的值了,不过这代表什么意义呢?BDP 给出了一种简单的方法来计算理论上最优的 TCP socket 缓冲区大小(其中保存了排队等待传输和等待应用程序接收的数据)。如果缓冲区太小,那么 TCP 窗口就不能完全打开,这会对性能造成限制。如果缓冲区太大,那么宝贵的内存资源就会造成浪费。如果设置的缓冲区大小正好合适,那么就可以完全利用可用的带宽。
动态优化 GNU/Linux TCP/IP 栈。 标准的 GNU/Linux 发行版试图对各种部署情况都进行优化。这意味着标准的发行版可能并没有对现有的环境进行特殊的优化。GNU/Linux 提供了很多可调节的内核参数,可以使用这些参数为自己的操作系统进行动态配置。
1.54 介绍一下流量控制和拥塞控制。
参考回答
流量控制和拥塞控制定义 流量控制 如果发送方把数据发送得过快,接收方可能会来不及接收,这就会造成数据的丢失。流量控制就是让发送方慢点,要让接收方来得及接收。 拥塞控制 拥塞控制就是防止过多的数据注入到网络中,这样可以使网络中的路由器或链路不致过载。
流量控制和拥塞控制区别 流量控制是端到端的控制,例如A通过网络给B发数据,A发送的太快导致B没法接收(B缓冲窗口过小或者处理过慢),这时候的控制就是流量控制,原理是通过滑动窗口的大小改变来实现。 拥塞控制是A与B之间的网络发生堵塞导致传输过慢或者丢包,来不及传输。防止过多的数据注入到网络中,这样可以使网络中的路由器或链路不至于过载。拥塞控制是一个全局性的过程,涉及到所有的主机、路由器,以及与降低网络性能有关的所有因素。
TCP流量控制解决方法 TCP的流量控制是利用滑动窗口机制实现的,接收方在返回的数据中会包含自己的接收窗口的大小,以控制发送方的数据发送。
TCP拥塞控制解决方法 TCP拥塞控制的四种算法:慢开始、拥塞避免、快重传、快恢复。 (1)慢开始算法:当主机开始发送数据时,并不清楚网络的负载情况,所以由小到大逐渐增大拥塞窗口,每经过一个传输轮次没有出现超时就将拥塞窗口加倍。同时还需要设置一个慢开始门限,在拥塞窗口小于慢开始门限时使用慢开始算法,大于慢开始门限时,使用拥塞避免算法; (2)拥塞避免算法:在拥塞窗口大于慢开始门限时,让拥塞窗口按线性规律缓慢增长。即每经过一个传输轮次,拥塞窗口增大一个MSS最大报文段尺寸。(拥塞避免并非完全能够避免拥塞,只是使网络比较不容易出现拥塞) (3)快重传算法:使发送方今早知道发生了个别报文段丢失,并不是出现网络拥塞。 要求接受不要登塞自己发送数据时才进行捎带确认,而是立即发送确认,即使收到了失序的报文段也要立即发出对已收到报文段的重复确认。而发送方一旦受到三个连续的重读确认,就将相应的报文段立即重传。 (4)快恢复算法:发送方知道只有个别报文段丢失而不是网络拥塞时,不启动慢开始算法,而是执行快恢复算法,将慢开始门限和拥塞窗口值调整为当前窗口的一半,开始执行拥塞避免算法
答案解析
无
1.55 对路由协议是否有所了解?
参考回答
有了解。
路由协议定义 路由协议(英语:Routing protocol)是一种指定数据包转送方式的网上协议。Internet网络的主要节点设备是路由器,路由器通过路由表来转发接收到的数据。转发策略可以是人工指定的(通过静态路由、策略路由等方法)。在具有较小规模的网络中,人工指定转发策略没有任何问题。但是在具有较大规模的网络中(如跨国企业网络、ISP网络),如果通过人工指定转发策略,将会给网络管理员带来巨大的工作量,并且在管理、维护路由表上也变得十分困难。为了解决这个问题,动态路由协议应运而生。动态路由协议可以让路由器自动学习到其他路由器的网络,并且网络拓扑发生改变后自动更新路由表。网络管理员只需要配置动态路由协议即可,相比人工指定转发策略,工作量大大减少。
原理 路由协议通过在路由器之间共享路由信息来支持可路由协议。路由信息在相邻路由器之间传递,确保所有路由器知道到其它路由器的路径。总之,路由协议创建了路由表,描述了网络拓扑结构;路由协议与路由器,执行路由选择和数据包转发功能。
路由器的作用以及常见的路由协议 路由协议主要运行于路由器上,路由协议是用来确定到达路径的,起到一个地图导航,负责找路的作用。它工作在网络层。它包括RIP,IGRP(Cisco私有协议),EIGRP(Cisco私有协议),OSPF,IS-IS,BGP。以下为这六个协议的详细说明: (1)RIP(路由信息协议) RIP很早就被用在Internet上,是最简单的路由协议。它是“路由信息协议(Route Information Protocol)”的简写,主要传递路由信息,通过每隔30秒广播一次路由表,维护相邻路由器的位置关系,同时根据收到的路由表信息计算自己的路由表信息。RIP是一个距离矢量路由协议,最大跳数为15跳,超过15跳的网络则认为目标网络不可达。此协议通常用在网络架构较为简单的小型网络环境。分为RIPv1和RIPv2两个版本,后者支持VLSM技术以及一系列技术上的改进。RIP的收敛速度较慢。 (2)IGRP(内部网关路由协议) IGRP协议是“内部网关路由协议(Interior Gateway Routing Protocol)”的缩写,由Cisco于二十世纪八十年代独立开发,属于Cisco私有协议。IGRP和RIP一样,同属距离矢量路由协议,因此在诸多方面有着相似点,如IGRP也是周期性的广播路由表,也存在最大跳数(默认为100跳,达到或超过100跳则认为目标网络不可达)。IGRP最大的特点是使用了混合度量值,同时考虑了链路的带宽、延迟、负载、MTU、可靠性5个方面来计算路由的度量值,而不像其他IGP协议单纯的考虑某一个方面来计算度量值。IGRP已经被Cisco独立开发的EIGRP协议所取代,版本号为12.3及其以上的Cisco IOS(Internetwork Operating System)已经不支持该协议,已经罕有运行IGRP协议的网络。 (3)EIGRP(增强型内部网关路由协议) 由于IGRP协议的种种缺陷以及不足,Cisco开发了EIGRP协议(增强型内部网关路由协议)来取代IGRP协议。EIGRP属于高级距离矢量路由协议(又称混合型路由协议),继承了IGRP的混合度量值,最大特点在于引入了非等价负载均衡技术,并拥有极快的收敛速度。EIGRP协议在Cisco设备网络环境中广泛部署。 (4)OSPF(开放式最短路径优先) OSPF协议是“开放式最短路径优先(Open Shortest Path First)”的缩写,属于链路状态路由协议。OSPF提出了“区域(area)”的概念,每个区域中所有路由器维护着一个相同的链路状态数据库(LSDB)。区域又分为骨干区域(骨干区域的编号必须为0)和非骨干区域(非0编号区域),如果一个运行OSPF的网络只存在单一区域,则该区域可以是骨干区域或者非骨干区域。如果该网络存在多个区域,那么必须存在骨干区域,并且所有非骨干区域必须和骨干区域直接相连。OSPF利用所维护的链路状态数据库,通过最短路径优先算法(SPF算法)计算得到路由表。OSPF的收敛速度较快。由于其特有的开放性以及良好的扩展性,OSPF协议在各种网络中广泛部署。 (5)IS-IS(中间系统到中间系统) IS-IS协议是Intermediate system to intermediate system(中间系统到中间系统)的缩写,属于链路状态路由协议。标准IS-IS协议是由国际标准化组织制定的ISO/IEC 10589:2002所定义的,标准IS-IS不适合用于IP网络,因此IETF制定了适用于IP网络的集成化IS-IS协议(Integrated IS-IS)。和OSPF相同,IS-IS也使用了“区域”的概念,同样也维护着一份链路状态数据库,通过最短生成树算法(SPF)计算出最佳路径。IS-IS的收敛速度较快。集成化IS-IS协议是ISP骨干网上最常用的IGP协议。 (6)BGP(边界网关协议) 为了维护各个ISP的独立利益,标准化组织制定了ISP间的路由协议BGP。BGP是“边界网关协议(Border Gateway Protocol)”的缩写,处理各ISP之间的路由传递。但是BGP运行在相对核心的地位,需要用户对网络的结构有相当的了解,否则可能会造成较大损失。
答案解析
无
1.56 直播可能需要使用到什么样的协议?
参考回答
视频直播有多种协议,使用rtmp协议的就是rtmp直播。直播流就是视频流,即传递的视频数据。常见的协议有RTMP、RTSP、HTTP协议,这三个协议都属于互联网TCP/IP五层体系结构中应用层的协议。理论上这三种都可以用来做视频直播或点播。但通常来说,直播一般用RTMP、RTSP,而点播用HTTP。下面分别介绍下三者的特点。
RTMP协议 (1)是流媒体协议; (2)RTMP协议是Adobe的私有协议,未完全公开; (3)RTMP协议一般传输的是flv,f4v格式流; (4)RTMP一般在TCP1个通道上传输命令和数据。
RTSP协议 (1)是流媒体协议; (2)RTSP协议是共有协议,并有专门机构做维护; (3)RTSP协议一般传输的是ts、mp4格式的流; (4)RTSP传输一般需要2-3个通道,命令和数据通道分离。
HTTP协议 (1)不是是流媒体协议; (2)HTTP协议是共有协议,并有专门机构做维护; (3)HTTP协议没有特定的传输流; (4)HTTP传输一般需要2-3个通道,命令和数据通道分离。
答案解析
扩展资料
一个完整的视频直播过程,包括采集、处理、编码、封装、推流、传输、转码、分发、解码、播放等。
采集 音频采集音频的采集过程主要通过设备将环境中的模拟信号采集成 PCM 编码的原始数据,然后编码压缩成 MP3 等格式的数据分发出去。常见的音频压缩格式有:MP3,AAC,HE-AAC,Opus,FLAC,Vorbis (Ogg),Speex 和 AMR等。 图像采集 图像的采集过程主要由摄像头等设备拍摄成 YUV 编码的原始数据,然后经过编码压缩成 H.264 等格式的数据分发出去。常见的视频封装格式有:MP4、3GP、AVI、MKV、WMV、MPG、VOB、FLV、SWF、MOV、RMVB 和 WebM 等。
处理 视频或者音频完成采集之后得到原始数据,为了增强一些现场效果或者加上一些额外的效果,我们一般会在将其编码压缩前进行处理。 视频:美颜、水印、路径、自定义。 音频:混音、降噪、特效、自定义。
编码 对流媒体传输来说,编码非常重要,它的编码性能、编码速度和编码压缩比会直接影响整个流媒体传输的用户体验和传输成本。 常见的视频编码器: (1)H.264/AVC (2)HEVC/H.265 (3)VP8 (4)VP9 (5)FFmpeg 音频编码器:Mp3, AAC等。
封装 把编码器生成的多媒体内容(视频,音频,字幕,章节信息等)混合封装在一起几种常见的封装格式: (1)AVI 格式(后缀为 .avi) (2)DV-AVI 格式(后缀为 .avi) (3)QuickTime File Format 格式(后缀为 .mov) (4)MPEG 格式(文件后缀可以是 .mpg .mpeg .mpe .dat .vob .asf .3gp .mp4等) (5)WMV 格式(后缀为.wmv .asf) (6)Real Video 格式(后缀为 .rm .rmvb) (7)Flash Video 格式(后缀为 .flv) (8)Matroska 格式(后缀为 .mkv) (9)MPEG2-TS 格式 (后缀为 .ts) 目前,我们在流媒体传输,尤其是直播中主要采用的就是 FLV 和 MPEG2-TS 格式,分别用于 RTMP/HTTP-FLV 和 HLS 协议。
推流 推流是指使用推流工具等内容抓取软件把直播内容传输到服务器的过程。推送协议主要有三种: (1)RTSP(Real Time Streaming Protocol):实时流传送协议,是用来控制声音或影像的多媒体串流协议, 由Real Networks和Netscape共同提出的; (2)RTMP(Real Time Messaging Protocol):实时消息传送协议,是Adobe公司为Flash播放器和服务器之间音频、视频和数据传输 开发的开放协议; (3)HLS(HTTP Live Streaming):是苹果公司(Apple Inc.)实现的基于HTTP的流媒体传输协议;RTMP是目前主流的流媒体传输协议,广泛用于直播领域,市面上绝大多数的直播产品都采用了这个协议。 RTMP协议基于 TCP,是一种设计用来进行实时数据通信的网络协议,主要用来在 flash/AIR 平台和支持 RTMP 协议的流媒体/交互服务器之间进行音视频和数据通信。支持该协议的软件包括 Adobe Media Server/Ultrant Media Server/red5 等。它有三种变种: (1)RTMP工作在TCP之上的明文协议,使用端口1935; (2)RTMPT封装在HTTP请求之中,可穿越防火墙; (3)RTMPS类似RTMPT,但使用的是HTTPS连接; RTMP协议就像一个用来装数据包的容器,这些数据可以是AMF格式的数据,也可以是FLV中的视/音频数据。一个单一的连接可以通过不同的通道传输多路网络流。这些通道中的包都是按照固定大小的包传输的。
传输 推送出去的流媒体需要传输到观众,整个链路就是传输网络。
转码 视频直播播流端的码率是根据推流端决定的,即播流端的码率是与推流端的码率一致的。但是遇到以下场景会造成直播效果较差:推流端码率与播流端带宽不相匹配。当推流端码率较高而客户端带宽资源有限就会导致播放出现卡顿,而当推流端码率较低但是客户端对于直播效率要求较高时会导致播放效果较差。播放器插件需要实现多码率切换。前端播放器插件常可以设置码率切换,这就需要同一路推流可以同时提供多种码率的播流地址。因此,视频直播提供了实时转码功能对同一路推流地址同时提供多路不同码率播流地址提供服务。
分发 流媒体服务器的作用是负责直播流的发布和转播分发功能。
解码 编码器(Encoder):压缩信号的设备或程序; 解码器(Decoder):解压缩信号的设备或程序; 编解码器(Codec):编解码器对。
播放器流播放主要是实现直播节目在终端上的展现。因为这里使用的传输协议是RTMP, 所以只要支持 RTMP 流协议的播放器都可以使用。
1.57 谈谈单工、双工、半双工的通信方式。
参考回答
单工:数据传输只支持数据在一个方向上传输;在同一时间只有一方能接受或发送信息,不能实现双向通信。举例:电视,广播。
半双工:半双工数据传输允许数据在两个方向上传输,但是,在某一时刻,只允许数据在一个方向上传输,它实际上是一种切换方向的单工通信;在同一时间只可以有一方接受或发送信息,可以实现双向通信。举例:对讲机。
双工:全双工数据通信允许数据同时在两个方向上传输,因此,全双工通信是两个单工通信方式的结合,它要求发送设备和接收设备都有独立的接收和发送能力;在同一时间可以同时接受和发送信息,实现双向通信。举例:电话通信。
答案解析
扩展资料:
单工、半双工和全双工是电信计算机网络中的三种通信信道。这些通信信道可以提供信息传达的途径。通信信道可以是物理传输介质或通过多路复用介质的逻辑连接。物理传输介质是指能够传播能量波的材料物质,例如数据通信中的导线。并且逻辑连接通常指电路交换连接或分组模式虚拟电路连接,例如无线电信通道。由于通信信道的帮助,信息可以无障碍地传输。
单工模式一般用在只向一个方向传输数据的场合。例如计算机与打印机之间的通信是单工模式,因为只有计算机向打印机传输数据,而没有相反方向的数据传输。还有在某些通信信道中,如单工无线发送等。
1. 操作系统
1.1 Linux里如何查看一个想知道的进程?
参考回答
查看进程运行状态的指令:ps命令。“ps -aux | grep PID”,用来查看某PID进程状态
答案解析 //ps使用示例 //显示当前所有进程 ps -A //与grep联用查找某进程 ps -aux | grep apache //查看进程运行状态、查看内存使用情况的指令均可使用top指令。 top
1.2 Linux里如何查看带有关键字的日志文件?
参考回答
cat 路径/文件名 | grep 关键词
# 返回test.log中包含http的所有行 cat test.log | grep "http"
grep -i 关键词 路径/文件名 (与方法一效果相同,不同写法而已)
# 返回test.log中包含http的所有行(-i忽略大小写) grep -i "http" ./test.log
1.3 说说你对grep命令的了解?
参考回答
grep 命令。强大的文本搜索命令,grep(Global Regular Expression Print) 全局正则表达式搜索。
grep 的工作方式是这样的,它在一个或多个文件中搜索字符串模板。如果模板包括空格,则必须被引用,模板后的所有字符串被看作文件名。搜索的结果被送到标准输出,不影响原文件内容。
答案解析 1. //参数 2. -A n --after-context显示匹配字符后n行 3. -B n --before-context显示匹配字符前n行 4. -C n --context 显示匹配字符前后n行 5. -c --count 计算符合样式的列数 6. -i 忽略大小写 7. -l 只列出文件内容符合指定的样式的文件名称 8. -f 从文件中读取关键词 9. -n 显示匹配内容的所在文件中行数 10. -R 递归查找文件夹 11. 12. //grep 的规则表达式: 13. ^ #锚定行的开始 如:'^grep'匹配所有以grep开头的行。 14. $ #锚定行的结束 如:'grep$'匹配所有以grep结尾的行。 15. . #匹配一个非换行符的字符 如:'gr.p'匹配gr后接一个任意字符,然后是p。 16. * #匹配零个或多个先前字符 如:'*grep'匹配所有一个或多个空格后紧跟grep的行。 17. .* #一起用代表任意字符。 18. [] #匹配一个指定范围内的字符,如'[Gg]rep'匹配Grep和grep。 19. [^] #匹配一个不在指定范围内的字符,如:'[^A-FH-Z]rep'匹配不包含A-R和T-Z的一个字母开头,紧跟rep的行。 20. \(..\) #标记匹配字符,如'\(love\)',love被标记为1。 21. \< #锚定单词的开始,如:'\ #锚定单词的结束,如'grep\>'匹配包含以grep结尾的单词的行。 23. x\{m\} #重复字符x,m次,如:'0\{5\}'匹配包含5个o的行。 24. x\{m,\} #重复字符x,至少m次,如:'o\{5,\}'匹配至少有5个o的行。 25. x\{m,n\}#重复字符x,至少m次,不多于n次,如:'o\{5,10\}'匹配5--10个o的行。 26. \w #匹配文字和数字字符,也就是[A-Za-z0-9],如:'G\w*p'匹配以G后跟零个或多个文字或数字字符,然后是p。 27. \W #\w的反置形式,匹配一个或多个非单词字符,如点号句号等。 28. \b #单词锁定符,如: '\bgrep\b'只匹配grep。 //实例: 1. //查找指定进程 2. ps -ef | grep svn 3. 4. //查找指定进程个数 5. ps -ef | grep svn -c 6. 7. //从文件中读取关键词 8. cat test1.txt | grep -f key.log 9. 10. //显示包含 ed 或者 at 字符的内容行 11. grep -E 'ed|at' test.txt
1.4 Linux修改主机名的命令是什么?
参考回答
如果只需要临时更改主机名,可以使用hostname命令。 sudo hostname # 例如: sudo hostname myDebian #myDebian为修改名
如果想永久改变主机名,可以使用hostnamectl命令 sudo hostnamectl set-hostname myDebian #myDebian为修改名
1.5 Linux开机自动执行命令如何实现?
参考回答
方法 #1 - 使用 cron 任务 除了常用格式(分 / 时 / 日 / 月 / 周)外,cron 调度器还支持 @reboot 指令。这个指令后面的参数是脚本(启动时要执行的那个脚本)的绝对路径。 然而,这种方法需要注意两点: a) cron 守护进程必须处于运行状态(通常情况下都会运行),同时 b) 脚本或 crontab 文件必须包含需要的环境变量。
方法 #2 - 使用 /etc/rc.d/rc.local 这个方法对于 systemd-based 发行版 Linux 同样有效。不过,使用这个方法,需要授予 /etc/rc.d/rc.local 文件执行权限: # chmod +x /etc/rc.d/rc.local 然后在这个文件底部添加脚本。
1.6 Linux查看内存的命令是什么?
参考回答
查看内存使用情况的指令:free命令。“free -m”,命令查看内存使用情况。
查看进程运行状态、查看内存使用情况的指令均可使用top指令。
答案解析
free命令 Linux free命令用于显示内存状态。 free指令会显示内存的使用情况,包括实体内存,虚拟的交换文件内存,共享内存区段,以及系统核心使用的缓冲区等。 参数如下: -b 以Byte为单位显示内存使用情况。 -k 以KB为单位显示内存使用情况。 -m 以MB为单位显示内存使用情况。 -h 以合适的单位显示内存使用情况,最大为三位数,自动计算对应的单位值。单位有: B = bytes K = kilos M = megas G = gigas T = teras -o 不显示缓冲区调节列。 -s<间隔秒数> 持续观察内存使用状况。 -t 显示内存总和列。 -V 显示版本信息。 实例:显示内存使用情况 # free //显示内存使用信息 total used free shared buffers cached Mem: 254772 184568 70204 0 5692 89892 -/+ buffers/cache: 88984 165788 Swap: 524280 65116 459164
top命令top命令。显示当前系统正在执行的进程的相关信息,包括进程 ID、内存占用率、CPU 占用率等前五行是当前系统情况整体的统计信息区。
第一行,任务队列信息,同 uptime 命令的执行结果,具体参数说明情况如下: 00:12:54 — 当前系统时间 up ?days, 4:49 — 系统已经运行了?天4小时49分钟(在这期间系统没有重启过) 21users — 当前有1个用户登录系统 load average: 0.06, 0.02, 0.00 — load average后面的三个数分别是1分钟、5分钟、15分钟的负载情况。load average数据是每隔5秒钟检查一次活跃的进程数,然后按特定算法计算出的数值。如果这个数除以逻辑CPU的数量,结果高于5的时候就表明系统在超负荷运转了。
第二行,Tasks — 任务(进程),具体信息说明如下: 系统现在共有256个进程,其中处于运行中的有1个,177个在休眠(sleep),stoped状态的有0个,zombie状态(僵尸)的有0个。
第三行,cpu状态信息,具体属性说明如下: 0.2%us — 用户空间占用CPU的百分比。 0.2% sy — 内核空间占用CPU的百分比。 0.0% ni — 改变过优先级的进程占用CPU的百分比 99.5% id — 空闲CPU百分比 0.0% wa — IO等待占用CPU的百分比 0.0% hi — 硬中断(Hardware IRQ)占用CPU的百分比 0.0% si — 软中断(Software Interrupts)占用CPU的百分比
第四行,内存状态,具体信息如下: 2017552 total — 物理内存总量 720188 used — 使用中的内存总量 197916 free — 空闲内存总量 1099448 cached — 缓存的总量
第五行,swap交换分区信息,具体信息说明如下: 998396 total — 交换区总量 989936 free — 空闲交换区总量 8460 used — 使用的交换区总量 1044136 cached — 缓冲的交换区总量
1.7 free命令有哪些选项?
参考回答
Linux free命令用于显示内存状态。
free指令会显示内存的使用情况,包括实体内存,虚拟的交换文件内存,共享内存区段,以及系统核心使用的缓冲区等。
参数如下: -b 以Byte为单位显示内存使用情况。 -k 以KB为单位显示内存使用情况。 -m 以MB为单位显示内存使用情况。 -h 以合适的单位显示内存使用情况,最大为三位数,自动计算对应的单位值。单位有: B = bytes K = kilos M = megas G = gigas T = teras -o 不显示缓冲区调节列。 -s<间隔秒数> 持续观察内存使用状况。 -t 显示内存总和列。 -V 显示版本信息。
答案解析
实例:显示内存使用情况 # free //显示内存使用信息 total used free shared buffers cached Mem: 254772 184568 70204 0 5692 89892 -/+ buffers/cache: 88984 165788 Swap: 524280 65116 459164
1.8 Linux中压缩文件的命令是什么?
参考回答
Linux中压缩文件与解压文件的命令有:tar命令、gz命令、bz2命令、compress命令、zip命令、unzip命令。
答案解析
tar 命令详解 Linux tar(英文全拼:tape archive )命令用于备份文件。 tar 是用来建立,还原备份文件的工具程序,它可以加入,解开备份文件内的文件。 //命令格式: tar [-ABcdgGhiklmMoOpPrRsStuUvwWxzZ][-b <区块数目>][-C <目的目录>][-f <备份文件>][-F ][-K <文件>][-L <媒体容量>][-N <日期时间>][-T <范本文件>][-V <卷册名称>][-X <范本文件>][-<设备编号><存储密度>][--after-date=<日期时间>][--atime-preserve][--backuup=<备份方式>][--checkpoint][--concatenate][--confirmation][--delete][--exclude=<范本样式>][--force-local][--group=<群组名称>][--help][--ignore-failed-read][--new-volume-script=][--newer-mtime][--no-recursion][--null][--numeric-owner][--owner=<用户名称>][--posix][--erve][--preserve-order][--preserve-permissions][--record-size=<区块数目>][--recursive-unlink][--remove-files][--rsh-command=<执行指令>][--same-owner][--suffix=<备份字尾字符串>][--totals][--use-compress-program=<执行指令>][--version][--volno-file=<编号文件>][文件或目录...] //常用参数: //必要参数有如下: -A 新增压缩文件到已存在的压缩 -c 建立新的压缩文件 -d 记录文件的差别 -r 添加文件到已经压缩的文件 -u 添加改变了和现有的文件到已经存在的压缩文件 -x 从压缩的文件中提取文件 -t 显示压缩文件的内容 -z 支持gzip解压文件 -j 支持bzip2解压文件 -Z 支持compress解压文件 -v 显示操作过程 -l 文件系统边界设置 -k 保留原有文件不覆盖 -m 保留文件不被覆盖 -W 确认压缩文件的正确性 //实例 //1.压缩 tar -cf hhh.tar hhh //打包 hhh 文件为 hhh.tar tar -jcf hhh.tar.bz2 hhh //压缩打包 hhh 文件为 hhh.tar.bz2 tar -czf hhh.tar.gz hhh //压缩 hhh 文件为 hhh.tar.gz tar -tzvf test.tar.gz //列出压缩文件内容 //2.解压文件 tar -tzvf test.tar.gz
gz命令详解 Linux gzip命令用于压缩文件。 gzip是个使用广泛的压缩程序,文件经它压缩过后,其名称后面会多出".gz"的扩展名。 //命令格式: gzip [-acdfhlLnNqrtvV][-S <压缩字尾字符串>][-<压缩效率>][--best/fast][文件...] 或 gzip [-acdfhlLnNqrtvV][-S <压缩字尾字符串>][-<压缩效率>][--best/fast][目录] //常用参数: -a或--ascii 使用ASCII文字模式。 -c或--stdout或--to-stdout 把压缩后的文件输出到标准输出设备,不去更动原始文件。 -d或--decompress或----uncompress 解开压缩文件。 -f或--force 强行压缩文件。不理会文件名称或硬连接是否存在以及该文件是否为符号连接。 -h或--help 在线帮助。 -l或--list 列出压缩文件的相关信息。 -L或--license 显示版本与版权信息。 -n或--no-name 压缩文件时,不保存原来的文件名称及时间戳记。 -N或--name 压缩文件时,保存原来的文件名称及时间戳记。 -q或--quiet 不显示警告信息。 -r或--recursive 递归处理,将指定目录下的所有文件及子目录一并处理。 -S<压缩字尾字符串>或----suffix<压缩字尾字符串> 更改压缩字尾字符串。 -t或--test 测试压缩文件是否正确无误。 -v或--verbose 显示指令执行过程。 -V或--version 显示版本信息。 -<压缩效率> 压缩效率是一个介于1-9的数值,预设值为"6",指定愈大的数值,压缩效率就会愈高。 --best 此参数的效果和指定"-9"参数相同。 --fast 此参数的效果和指定"-1"参数相同。 //实例 //1.压缩 gzip * //压缩目录下的所有文件 //2.解压文件 gzip -dv * //解压文件,并列出详细信息
bz2命令详解 bzip2(选项)(参数):用于创建和管理.bz2格式的压缩包。 //命令格式: bzip2 源文件 //压缩不保留源文件 bzip2 -k 源文件 //压缩保留源文件 //注意 bzip2 命令不能解压目录 //常用参数: -c 将压缩与解压缩的结果送到标准输出 -d 执行解压缩 -f 在压缩或解压缩时,若输出文件与现有文件名相同,预设不会覆盖现有文件;使用该选项,可覆盖文件 -k 在压缩或解压缩后,会删除原是文件;若要保留原是文件,使用该选项 -v 压缩或解压缩文件时,显示详细的信息 -z 强制执行压缩 //实例 //1.压缩 bzip2 源文件 //压缩不保留源文件 bzip2 -k 源文件 //压缩保留源文件 //2.解压文件 bzip2 -d 源文件 //解压缩 -k 保留压缩文件 bunzip2 源文件 //解压缩 -k 保留压缩文件
compress命令详解 Linux compress命令是一个相当古老的 unix 档案压缩指令,压缩后的档案会加上一个 .Z 延伸档名以区别未压缩的档案,压缩后的档案可以以 uncompress 解压。若要将数个档案压成一个压缩档,必须先将档案 tar 起来再压缩。由于 gzip 可以产生更理想的压缩比例,一般人多已改用 gzip 为档案压缩工具。 //命令格式: compress [-dfvcV] [-b maxbits] [file ...] //常用参数: -c 输出结果至标准输出设备(一般指荧幕) -f 强迫写入档案,若目的档已经存在,则会被覆盖 (force) -v 将程序执行的讯息印在荧幕上 (verbose) -b 设定共同字串数的上限,以位元计算,可以设定的值为 9 至 16 bits 。由于值越大,能使用的共同字串就 越多,压缩比例就越大,所以一般使用预设值 16 bits (bits) -d 将压缩档解压缩 -V 列出版本讯息 //实例 //1.压缩 compress -f source.dat //将 source.dat 压缩成 source.dat.Z ,若 source.dat.Z 已经存在,内容则会被压缩档覆盖。 //2.解压文件 compress -d source.dat //将 source.dat.Z 解压成 source.dat ,若档案已经存在,使用者按 y 以确定覆盖档案,若使用 -df 程序则会自动覆盖档案。
zip 命令详解 //命令格式: zip [-AcdDfFghjJKlLmoqrSTuvVwXyz$][-b <工作目录>][-ll][-n <字尾字符串>][-t <日期时间>][-<压缩效率>][压缩文件][文件...][-i <范本样式>][-x <范本样式>] //常用参数: -m 将文件压缩并加入压缩文件后,删除原始文件,即把文件移到压缩文件中。 -o 以压缩文件内拥有最新更改时间的文件为准,将压缩文件的更改时间设成和该文件相同。 -q 不显示指令执行过程。 -r 递归处理,将指定目录下的所有文件和子目录一并处理。 -x<范本样式> 压缩时排除符合条件的文件。 //实例: //将 /home/html/ 这个目录下所有文件和文件夹打包为当前目录下的 html.zip: zip -q -r html.zip /home/html //如果在我们在 /home/html 目录下,可以执行以下命令: zip -q -r html.zip * //从压缩文件 cp.zip 中删除文件 a.c zip -dv cp.zip a.c
unzip 命令详解 Linux unzip命令用于解压缩zip文件 unzip为.zip压缩文件的解压缩程序。 //命令格式: unzip [-cflptuvz][-agCjLMnoqsVX][-P <密码>][.zip文件][文件][-d <目录>][-x <文件>] 或 unzip [-Z] //常用参数: -c 将解压缩的结果显示到屏幕上,并对字符做适当的转换。 -f 更新现有的文件。 -l 显示压缩文件内所包含的文件。 -p 与-c参数类似,会将解压缩的结果显示到屏幕上,但不会执行任何的转换。 -t 检查压缩文件是否正确。 -u 与-f参数类似,但是除了更新现有的文件外,也会将压缩文件中的其他文件解压缩到目录中。 -v 执行是时显示详细的信息。 -z 仅显示压缩文件的备注文字。 -a 对文本文件进行必要的字符转换。 -b 不要对文本文件进行字符转换。 -C 压缩文件中的文件名称区分大小写。 -j 不处理压缩文件中原有的目录路径。 -L 将压缩文件中的全部文件名改为小写。 -M 将输出结果送到more程序处理。 -n 解压缩时不要覆盖原有的文件。 -o 不必先询问用户,unzip执行后覆盖原有文件。 -P<密码> 使用zip的密码选项。 -q 执行时不显示任何信息。 -s 将文件名中的空白字符转换为底线字符。 -V 保留VMS的文件版本信息。 -X 解压缩时同时回存文件原来的UID/GID。 [.zip文件] 指定.zip压缩文件。 [文件] 指定要处理.zip压缩文件中的哪些文件。 -d<目录> 指定文件解压缩后所要存储的目录。 -x<文件> 指定不要处理.zip压缩文件中的哪些文件。 -Z unzip -Z等于执行zipinfo指令。 //实例 unzip text.zip //将压缩文件text.zip在指定目录/tmp下解压缩,如果已有相同的文件存在,要求unzip命令不覆盖原先的文件。 unzip -n text.zip -d /tmp //查看压缩文件目录,但不解压。
1.9 Linux查询连接数的命令是什么?
参考回答
netstat
//示例 查看Web服务器(Nginx Apache)的并发请求数及其TCP连接状态: netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}' 解释: 返回结果示例: LAST_ACK 5 (正在等待处理的请求数) SYN_RECV 30 ESTABLISHED 1597 (正常数据传输状态) FIN_WAIT1 51 FIN_WAIT2 504 TIME_WAIT 1057 (处理完毕,等待超时结束的请求数) 状态:描述 CLOSED:无连接是活动的或正在进行 LISTEN:服务器在等待进入呼叫 SYN_RECV:一个连接请求已经到达,等待确认 SYN_SENT:应用已经开始,打开一个连接 ESTABLISHED:正常数据传输状态 FIN_WAIT1:应用说它已经完成 FIN_WAIT2:另一边已同意释放 ITMED_WAIT:等待所有分组死掉 CLOSING:两边同时尝试关闭 TIME_WAIT:另一边已初始化一个释放 LAST_ACK:等待所有分组死掉
答案解析
无。
1.10 Linux中top命令有哪些参数?
参考回答
top命令。显示当前系统正在执行的进程的相关信息,包括进程 ID、内存占用率、CPU 占用率等
参数: -d 指定每两次屏幕信息刷新之间的时间间隔。当然用户可以使用s交互命令来改变之。 -p 通过指定监控进程ID来仅仅监控某个进程的状态。 -q 该选项将使top没有任何延迟的进行刷新。如果调用程序有超级用户权限,那么top将以尽可能高的优先级运行。 -S 指定累计模式 -s 使top命令在安全模式中运行。这将去除交互命令所带来的潜在危险。 -i 使top不显示任何闲置或者僵死进程。 -c 显示整个命令行而不只是显示命令名
答案解析
前五行是当前系统情况整体的统计信息区。
第一行,任务队列信息,同 uptime 命令的执行结果,具体参数说明情况如下: 00:12:54 — 当前系统时间 up ?days, 4:49 — 系统已经运行了?天4小时49分钟(在这期间系统没有重启过) 21users — 当前有1个用户登录系统 load average: 0.06, 0.02, 0.00 — load average后面的三个数分别是1分钟、5分钟、15分钟的负载情况。load average数据是每隔5秒钟检查一次活跃的进程数,然后按特定算法计算出的数值。如果这个数除以逻辑CPU的数量,结果高于5的时候就表明系统在超负荷运转了。
第二行,Tasks — 任务(进程),具体信息说明如下: 系统现在共有256个进程,其中处于运行中的有1个,177个在休眠(sleep),stoped状态的有0个,zombie状态(僵尸)的有0个。
第三行,cpu状态信息,具体属性说明如下: 0.2%us — 用户空间占用CPU的百分比。 0.2% sy — 内核空间占用CPU的百分比。 0.0% ni — 改变过优先级的进程占用CPU的百分比 99.5% id — 空闲CPU百分比 0.0% wa — IO等待占用CPU的百分比 0.0% hi — 硬中断(Hardware IRQ)占用CPU的百分比 0.0% si — 软中断(Software Interrupts)占用CPU的百分比
第四行,内存状态,具体信息如下: 2017552 total — 物理内存总量 720188 used — 使用中的内存总量 197916 free — 空闲内存总量 1099448 cached — 缓存的总量
第五行,swap交换分区信息,具体信息说明如下: 998396 total — 交换区总量 989936 free — 空闲交换区总量 8460 used — 使用的交换区总量 1044136 cached — 缓冲的交换区总量
1.11 Linux中,如何通过端口查进程,如何通过进程查端口?
参考回答
linux下通过进程名查看其占用端口: (1)先查看进程pid ps -ef | grep 进程名 (2)通过pid查看占用端口 netstat -nap | grep 进程pid
linux通过端口查看进程: netstat -nap | grep 端口号
1.12 请你说说ping命令?
参考回答
Linux ping命令用于检测主机。
执行ping指令会使用ICMP传输协议,发出要求回应的信息,若远端主机的网络功能没有问题,就会回应该信息,因而得知该主机运作正常。
答案解析
语法: ping [-dfnqrRv][-c<完成次数>][-i<间隔秒数>][-I<网络界面>][-l<前置载入>][-p<范本样式>][-s<数据包大小>][-t<存活数值>][主机名称或IP地址]
参数说明: -d 使用Socket的SO_DEBUG功能。 -c<完成次数> 设置完成要求回应的次数。 -f 极限检测。 -i<间隔秒数> 指定收发信息的间隔时间。 -I<网络界面> 使用指定的网络接口送出数据包。 -l<前置载入> 设置在送出要求信息之前,先行发出的数据包。 -n 只输出数值。 -p<范本样式> 设置填满数据包的范本样式。 -q 不显示指令执行过程,开头和结尾的相关信息除外。 -r 忽略普通的Routing Table,直接将数据包送到远端主机上。 -R 记录路由过程。 -s<数据包大小> 设置数据包的大小。 -t<存活数值> 设置存活数值TTL的大小。 -v 详细显示指令的执行过程。
实例: 检测是否与主机连通 # ping www.w3cschool.cc //ping主机 PING aries.m.alikunlun.com (114.80.174.110) 56(84) bytes of data. 64 bytes from 114.80.174.110: icmp_seq=1 ttl=64 time=0.025 ms 64 bytes from 114.80.174.110: icmp_seq=2 ttl=64 time=0.036 ms 64 bytes from 114.80.174.110: icmp_seq=3 ttl=64 time=0.034 ms 64 bytes from 114.80.174.110: icmp_seq=4 ttl=64 time=0.034 ms 64 bytes from 114.80.174.110: icmp_seq=5 ttl=64 time=0.028 ms 64 bytes from 114.80.174.110: icmp_seq=6 ttl=64 time=0.028 ms 64 bytes from 114.80.174.110: icmp_seq=7 ttl=64 time=0.034 ms 64 bytes from 114.80.174.110: icmp_seq=8 ttl=64 time=0.034 ms 64 bytes from 114.80.174.110: icmp_seq=9 ttl=64 time=0.036 ms 64 bytes from 114.80.174.110: icmp_seq=10 ttl=64 time=0.041 ms --- aries.m.alikunlun.com ping statistics --- 10 packets transmitted, 30 received, 0% packet loss, time 29246ms rtt min/avg/max/mdev = 0.021/0.035/0.078/0.011 ms //需要手动终止Ctrl+C 指定接收包的次数 # ping -c 2 www.w3cschool.cc PING aries.m.alikunlun.com (114.80.174.120) 56(84) bytes of data. 64 bytes from 114.80.174.120: icmp_seq=1 ttl=54 time=6.18 ms 64 bytes from 114.80.174.120: icmp_seq=2 ttl=54 time=15.4 ms --- aries.m.alikunlun.com ping statistics --- 2 packets transmitted, 2 received, 0% packet loss, time 1016ms rtt min/avg/max/mdev = 6.185/10.824/15.464/4.640 ms //收到两次包后,自动退出 多参数使用 # ping -i 3 -s 1024 -t 255 g.cn //ping主机 PING g.cn (203.208.37.104) 1024(1052) bytes of data. 1032 bytes from bg-in-f104.1e100.net (203.208.37.104): icmp_seq=0 ttl=243 time=62.5 ms 1032 bytes from bg-in-f104.1e100.net (203.208.37.104): icmp_seq=1 ttl=243 time=63.9 ms 1032 bytes from bg-in-f104.1e100.net (203.208.37.104): icmp_seq=2 ttl=243 time=61.9 ms --- g.cn ping statistics --- 3 packets transmitted, 3 received, 0% packet loss, time 6001ms rtt min/avg/max/mdev = 61.959/62.843/63.984/0.894 ms, pipe 2 [root@linux ~]# //-i 3 发送周期为 3秒 -s 设置发送包的大小 -t 设置TTL值为 255
1.13 什么是协程?
参考回答
协程:协程是微线程,在子程序内部执行,可在子程序内部中断,转而执行别的子程序,在适当的时候再返回来接着执行。
答案解析
线程与协程的区别: (1)协程执行效率极高。协程直接操作栈基本没有内核切换的开销,所以上下文的切换非常快,切换开销比线程更小。 (2)协程不需要多线程的锁机制,因为多个协程从属于一个线程,不存在同时写变量冲突,效率比线程高。 (3)一个线程可以有多个协程。
协程的优势: (1)协程调用跟切换比线程效率高:协程执行效率极高。协程不需要多线程的锁机制,可以不加锁的访问全局变量,所以上下文的切换非常快。 (2)协程占用内存少:执行协程只需要极少的栈内存(大概是4~5KB),而默认情况下,线程栈的大小为1MB。 (3)切换开销更少:协程直接操作栈基本没有内核切换的开销,所以切换开销比线程少。
1.14 为什么协程比线程切换的开销小?
参考回答
(1)协程执行效率极高。协程直接操作栈基本没有内核切换的开销,所以上下文的切换非常快,切换开销比线程更小。
(2)协程不需要多线程的锁机制,因为多个协程从属于一个线程,不存在同时写变量冲突,效率比线程高。避免了加锁解锁的开销。
1.15 线程和进程的区别?
参考回答
(1)一个线程从属于一个进程;一个进程可以包含多个线程。
(2)一个线程挂掉,对应的进程挂掉;一个进程挂掉,不会影响其他进程。
(3)进程是系统资源调度的最小单位;线程CPU调度的最小单位。
(4)进程系统开销显著大于线程开销;线程需要的系统资源更少。
(5)进程在执行时拥有独立的内存单元,多个线程共享进程的内存,如代码段、数据段、扩展段;但每个线程拥有自己的栈段和寄存器组。
(6)进程切换时需要刷新TLB并获取新的地址空间,然后切换硬件上下文和内核栈,线程切换时只需要切换硬件上下文和内核栈。
(7)通信方式不一样。
(8)进程适应于多核、多机分布;线程适用于多核
1.16 进程切换为什么比线程更消耗资源?
参考回答
进程切换时需要刷新TLB并获取新的地址空间,然后切换硬件上下文和内核栈;线程切换时只需要切换硬件上下文和内核栈。
答案解析
进程是程序的动态表现。 一个程序进行起来后,会使用很多资源,比如使用寄存器,内存,文件等。每当切换进程时,必须要考虑保存当前进程的状态。状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开的文件描述符的集合,这个状态叫做上下文(Context)。可见,想要切换进程,保存的状态还不少。不仅如此,由于虚拟内存机制,进程切换时需要刷新TLB并获取新的地址空间。
线程存在于进程中,一个进程可以有一个或多个线程。线程是运行在进程上下文中的逻辑流,这个线程可以独立完成一项任务。同样线程有自己的上下文,包括唯一的整数线程ID, 栈、栈指针、程序计数器、通用目的寄存器和条件码。可以理解为线程上下文是进程上下文的子集。
由于保存线程的上下文明显比进程的上下文小,因此系统切换线程时,必然开销更小。
1.17 介绍一下进程之间的通信。
参考回答
为了提高计算机系统的效率.增强计算机系统内各种硬件的并行操作能力.操作系统要求程序结构必须适应并发处理的需要.为此引入了进程的概念。而进程并行时,需要考虑进程间的通信,进程间通信主要有以下几种方式:匿名管道、命名管道、信号、消息队列、共享内存、信号量、Socket。
匿名管道:管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
#include #include #include #include #include int pipe_default[2]; int main() { pid_t pid; char buffer[32]; memset(buffer, 0, 32); if(pipe(pipe_default) < 0) { printf("Failed to create pipe!\n"); return 0; } if(0 == (pid = fork())) { close(pipe_default[1]); //关闭写端 sleep(2); if(read(pipe_default[0], buffer, 32) > 0) { printf("[Client] Receive data from server: %s \n", buffer); } close(pipe_default[0]); } else { close(pipe_default[0]); //关闭读端 char msg[32]="== hello world =="; if(-1 != write(pipe_default[1], msg, strlen(msg))) { printf("[Server] Send data to client: %s \n",msg); } close(pipe_default[1]); waitpid(pid, NULL, 0); } return 1; }
有名管道 匿名管道,由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了有名管道(FIFO)。 有名管道不同于匿名管道之处在于它提供了一个路径名与之关联,以有名管道的文件形式存在于文件系统中,这样,即使与有名管道的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能够彼此通过有名管道相互通信,因此,通过有名管道不相关的进程也能交换数据。值的注意的是,有名管道严格遵循先进先出(first in first out) ,对匿名管道及有名管道的读总是从开始处返回数据,对它们的写则把数据添加到末尾。它们不支持诸如lseek()等文件定位操作。有名管道的名字存在于文件系统中,内容存放在内存中。
信号
信号是Linux系统中用于进程间互相通信或者操作的一种机制,信号可以在任何时候发给某一进程,而无需知道该进程的状态。
如果该进程当前并未处于执行状态,则该信号就有内核保存起来,知道该进程回复执行并传递给它为止。
如果一个信号被进程设置为阻塞,则该信号的传递被延迟,直到其阻塞被取消是才被传递给进程。
以下列出几个常用的信号:
代码示例:
下面的代码收到程序退出信号后会执行用户定义的信号处理函数来替代系统默认的处理程序。 #include #include #include #include #include void sig_handle(int sig) { printf("received signal: %d, quit.\n", sig); exit(0); } int main () { signal(SIGINT, sig_handle); signal(SIGKILL, sig_handle); signal(SIGSEGV, sig_handle); signal(SIGTERM, sig_handle); int i = 0; while (1) { printf("%d\n", ++i); sleep(2); } printf("main quit."); return 0; }
运行结果: 1 2 received signal: 15, quit.
消息队列
消息队列是存放在内核中的消息链表,每个消息队列由消息队列标识符表示。
与管道(无名管道:只存在于内存中的文件;命名管道:存在于实际的磁盘介质或者文件系统)不同的是消息队列存放在内核中,只有在内核重启(即,操作系统重启)或者显示地删除一个消息队列时,该消息队列才会被真正的删除。
另外与管道不同的是,消息队列在某个进程往一个队列写入消息之前,并不需要另外某个进程在该队列上等待消息的到达。
消息队列特点总结:
(1)消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识.
(2)消息队列允许一个或多个进程向它写入与读取消息.
(3)管道和消息队列的通信数据都是先进先出的原则。
(4)消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取.比FIFO更有优势。
(5)消息队列克服了信号承载信息量少,管道只能承载无格式字 节流以及缓冲区大小受限等缺。
(6)目前主要有两种类型的消息队列:POSIX消息队列以及System V消息队列,系统V消息队列目前被大量使用。系统V消息队列是随内核持续的,只有在内核重起或者人工删除时,该消息队列才会被删除。
共享内存 进程间本身的内存是相互隔离的,而共享内存机制相当于给两个进程开辟了一块二者均可访问的内存空间,这时,两个进程便可以共享一些数据了。但是,多进程同时占用资源会带来一些意料之外的情况,这时,我们往往会采用上述的信号量来控制多个进程对共享内存空间的访问。 #include #include #include #include #include #include using namespace std; int main() { char *shmaddr; char *shmaddread; char str[]="Hello, I am a processing. \n"; int shmid; key_t key = ftok(".",1); pid_t pid1 = fork(); if(pid1 == -1){ cout << "Fork error. " << endl; exit(1); } else if(pid1 == 0){ //子进程 shmid = shmget(key,1024,IPC_CREAT | 0600); shmaddr = (char*)shmat(shmid, NULL, 0); strcpy(shmaddr, str); cout << "[Writer] write: " << shmaddr << endl; shmdt(shmaddr); } else { //父进程 pid_t pid2 = fork(); if(pid2 == -1){ cout << "Fork error. " << endl; exit(1); } else if(pid2 == 0){ //子进程 sleep(2); shmid = shmget(key,1024,IPC_CREAT | 0600); shmaddread = (char*)shmat(shmid, NULL, 0); cout << "[Reader] read: " << shmaddread << endl; shmdt(shmaddread); } } sleep(3); return 0; }
信号量 信号量主要用来解决进程和线程间并发执行时的同步问题,进程同步是并发进程为了完成共同任务采用某个条件来协调他们的活动,这是进程之间发生的一种直接制约关系。 对信号量的操作分为P操作和V操作,P操作是将信号量的值减一,V操作是将信号量的值加一。当信号量的值小于等于0之后,再进行P操作时,当前进程或线程会被阻塞,直到另一个进程或线程执行了V操作将信号量的值增加到大于0之时。锁也是用的这种原理实现的。 信号量我们需要定义信号量的数量,设定初始值,以及决定何时进行PV操作。 #include #include #include #include #include #include #include #include #define KEY (key_t)15030110070 #define N 20 static void p(int semid ,int semNum); static void v(int semid ,int semNum); union semun { int val; struct semid_ds *buf; ushort *array; }; int main(int argc ,char* argv[]) { int i; int semid; semid = semget(KEY,3,IPC_CREAT|0660); union semun arg[3]; arg[0].val = 1; //mutex [0] 对缓冲区进行操作的互斥信号量 arg[1].val = N; //empty [1] 缓冲区空位个数n arg[2].val = 0; //full [2] 产品个数 for(i=0;i<3;i++) semctl(semid,i,SETVAL,arg[i]); pid_t p1,p2; if((p1=fork()) == 0) { //子进程1,消费者 while(1) { printf("消费者 1 等待中...\n"); sleep(2); int product = rand() % 2 + 1; for(int i = 0; i < product; i++) { p(semid ,2); //消费 p(semid ,0); //加锁 printf(" [消费者 1] 消费产品 1. 剩余: %d\n", semctl(semid, 2, GETVAL, NULL)); v(semid ,0); //开锁 v(semid ,1); //释放空位 } sleep(2); } } else { if((p2=fork()) == 0) { //子进程2,消费者 while(1) { printf("消费者 2 等待中...\n"); sleep(2); int product = rand() % 2 + 1; for(int i = 0; i < product; i++) { p(semid ,2); //消费 p(semid ,0); //加锁 printf(" [消费者 2] 消费产品 1. 剩余: %d\n", semctl(semid, 2, GETVAL, NULL)); v(semid ,0); //开锁 v(semid ,1); //释放空位 } sleep(2); } } else { //父进程,生产者 while(1) { printf("生产者开始生产...\n"); int product = rand() % 5 + 1; for(int i = 0; i < product; i++) { p(semid ,1); //占用空位 p(semid ,0); //加锁 printf(" [生产者] 生产产品 1. 剩余: %d\n", semctl(semid, 2, GETVAL, NULL) + 1); v(semid ,0); //开锁 v(semid, 2); //生产 } sleep(2); } } } return 0; } /* p操作 */ void p(int semid ,int semNum) { struct sembuf sb; sb.sem_num = semNum; sb.sem_op = -1; sb.sem_flg = SEM_UNDO; semop(semid, &sb, 1); } /* v操作 */ void v(int semid ,int semNum) { struct sembuf sb; sb.sem_num = semNum; sb.sem_op = 1; sb.sem_flg = SEM_UNDO; semop(semid, &sb, 1); }
socket 套接字可以看做是:不同主机之间的进程进行双向通信的端点。(套接字 = IP地址 + 端口号)
1.18 介绍一下信号量。
参考回答
在多进程环境下,为了防止多个进程同时访问一个公共资源而出现问题,需要一种方法来协调各个进程,保证它们能够合理地使用公共资源。信号量就是这样一种机制。 信号量的数据类型为结构sem_t,它本质上是一个长整型的数。函数sem_init()用来初始化一个信号量。它的原型为: extern int sem_init _P ((sem_t *_sem, int _pshared, unsigned int _value)); sem为指向信号量结构的一个指针;pshared不为0时此信号量在进程间共享,否则只能为当前进程的所有线程共享;value给出了信号量的初始值。 (1)函数sem_post( sem_t *sem )用来增加信号量的值。当有线程阻塞在这个信号量上时,调用这个函数会使其中的一个线程不在阻塞,选择机制同样是由线程的调度策略决定的。 (2)函数sem_wait( sem_t *sem )被用来阻塞当前线程直到信号量sem的值大于0,解除阻塞后将sem的值减一,表明公共资源经使用后减少。函数sem_trywait ( sem_t *sem )是函数sem_wait()的非阻塞版本,它直接将信号量sem的值减一。 (3)函数sem_timedwait(sem_t *sem, const struct timespec *abs_timeout) 与 sem_wait() 类似,只不过 abs_timeout 指定一个阻塞的时间上限,如果调用因不能立即执行递减而要阻塞。 (4)函数sem_destroy(sem_t *sem)用来释放信号量sem。
使用示例代码如下
//g++ semtest.cpp -o test -lpthread #include #include #include #include #include sem_t sem; /*function:获取当前时间,精确到毫秒• * */ int64_t getTimeMsec() { struct timeval tv; gettimeofday(&tv, NULL); return tv.tv_sec * 1000 + tv.tv_usec / 1000; } void* func_sem_wait(void* arg) { printf("set wait\n"); sem_wait(&sem); printf("sem wait success\n"); int *running = (int*)arg; printf("func_sem_wait running\n"); printf("%d\n", *running); } void* func_sem_timedwait(void* arg) { timespec timewait; timewait.tv_sec = getTimeMsec() / 1000 + 2; timewait.tv_nsec = 0; printf("sem_timedwait\n"); int ret = sem_timedwait(&sem, &timewait); printf("sem_timedwait,ret=%d\n", ret); printf("func_sem_timedwait running\n"); } void* func_sem_post(void* arg) { printf("func_sem_post running\n"); printf("sem post\n"); int *a = (int*)arg; *a = 6; sem_post(&sem); sem_post(&sem); } int main() { sem_init(&sem, 0, 0); pthread_t thread[3]; int a = 5; pthread_create(&(thread[0]), NULL, func_sem_wait, &a); printf("thread func_sem_wait\n"); pthread_create(&(thread[2]), NULL, func_sem_timedwait, &a); printf("thread func_sem_timedwait\n"); sleep(4); pthread_create(&(thread[1]), NULL, func_sem_post, &a); printf("thread func_sem_post\n"); pthread_join(thread[0], NULL); pthread_join(thread[1], NULL); pthread_join(thread[2], NULL); sem_destroy(&sem); }
1.19 说说僵尸进程和孤儿进程。
参考回答
我们知道在unix/linux中,正常情况下,子进程是通过父进程创建的,子进程在创建新的进程。子进程的结束和父进程的运行是一个异步过程,即父进程永远无法预测子进程 到底什么时候结束。 当一个 进程完成它的工作终止之后,它的父进程需要调用wait()或者waitpid()系统调用取得子进程的终止状态。
孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。
僵尸进程:一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵尸进程。
1.20 请介绍进程之间的通信方式。
参考回答
进程间通信主要有以下几种方式
管道pipe:管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。 #include #include #include #include #include int pipe_default[2]; int main() { pid_t pid; char buffer[32]; memset(buffer, 0, 32); if(pipe(pipe_default) < 0) { printf("Failed to create pipe!\n"); return 0; } if(0 == (pid = fork())) { close(pipe_default[1]); //关闭写端 sleep(2); if(read(pipe_default[0], buffer, 32) > 0) { printf("[Client] Receive data from server: %s \n", buffer); } close(pipe_default[0]); } else { close(pipe_default[0]); //关闭读端 char msg[32]="== hello world =="; if(-1 != write(pipe_default[1], msg, strlen(msg))) { printf("[Server] Send data to client: %s \n",msg); } close(pipe_default[1]); waitpid(pid, NULL, 0); } return 1; }
命名管道FIFO:有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。 写管道: #include #include #include #include #include #include #include #include int main() { int nFd = 0; int nWrLen = 0, nReadLen = 0;; char szBuff[BUFSIZ] = {0}; /* 打开当前目录下的管道文件 */ nFd = open("pipe", O_RDWR); if (-1 == nFd) { perror("Open fifo failed\n"); return 1; } while (1) { /* 从终端读取数据 */ memset(szBuff,0,BUFSIZ); nReadLen = read(STDIN_FILENO,szBuff,BUFSIZ); if(nReadLen > 0) { /* 往管道写入数据 */ nWrLen = write(nFd, szBuff, strlen(szBuff)+1); if (nWrLen > 0) { printf("write data successful: %s \n", szBuff); } else { perror("write failed:"); } } } }
读管道: #include #include #include #include #include #include #include #include int main() { int nFd = 0; int nReadLen = 0;; char szBuff[BUFSIZ] = {0}; /* 打开当前目录下的管道文件 */ nFd = open("pipe", O_RDWR); if (-1 == nFd) { perror("Open fifo failed\n"); return 1; } while (1) { /* 从管道读取数据 */ memset(szBuff,0,BUFSIZ); nReadLen = read(nFd,szBuff,BUFSIZ); if(nReadLen > 0) { printf("read pipe data: %s\n", szBuff); } } }
消息队列MessageQueue:消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。 示例:使用消息队列进行进程间通信 接收信息的程序源文件为msgreceive.c的源代码为: #include #include #include #include #include #include struct msg_st { long int msg_type; char text[BUFSIZ]; }; int main() { int running = 1; int msgid = -1; struct msg_st data; long int msgtype = 0; //注意1 //建立消息队列 msgid = msgget((key_t)1234, 0666 | IPC_CREAT); if(msgid == -1) { fprintf(stderr, "msgget failed with error: %d\n", errno); exit(EXIT_FAILURE); } //从队列中获取消息,直到遇到end消息为止 while(running) { if(msgrcv(msgid, (void*)&data, BUFSIZ, msgtype, 0) == -1) { fprintf(stderr, "msgrcv failed with errno: %d\n", errno); exit(EXIT_FAILURE); } printf("You wrote: %s\n",data.text); //遇到end结束 if(strncmp(data.text, "end", 3) == 0) running = 0; } //删除消息队列 if(msgctl(msgid, IPC_RMID, 0) == -1) { fprintf(stderr, "msgctl(IPC_RMID) failed\n"); exit(EXIT_FAILURE); } exit(EXIT_SUCCESS); } 发送信息的程序的源文件msgsend.c的源代码为: #include #include #include #include #include #include #define MAX_TEXT 512 struct msg_st { long int msg_type; char text[MAX_TEXT]; }; int main() { int running = 1; struct msg_st data; char buffer[BUFSIZ]; int msgid = -1; //建立消息队列 msgid = msgget((key_t)1234, 0666 | IPC_CREAT); if(msgid == -1) { fprintf(stderr, "msgget failed with error: %d\n", errno); exit(EXIT_FAILURE); } //向消息队列中写消息,直到写入end while(running) { //输入数据 printf("Enter some text: "); fgets(buffer, BUFSIZ, stdin); data.msg_type = 1; //注意2 strcpy(data.text, buffer); //向队列发送数据 if(msgsnd(msgid, (void*)&data, MAX_TEXT, 0) == -1) { fprintf(stderr, "msgsnd failed\n"); exit(EXIT_FAILURE); } //输入end结束输入 if(strncmp(buffer, "end", 3) == 0) running = 0; sleep(1); } exit(EXIT_SUCCESS); } 运行结果如下: biao@ubuntu:~/test/msgRecvSend$ biao@ubuntu:~/test/msgRecvSend$ ls msgreceive.c msgsend.c recv send biao@ubuntu:~/test/msgRecvSend$ ./recv & [1] 8753 biao@ubuntu:~/test/msgRecvSend$ ./send Enter some text: helloworld You wrote: helloworld Enter some text: Caibiao Lee You wrote: Caibiao Lee Enter some text: end You wrote: end [1]+ Done ./recv biao@ubuntu:~/test/msgRecvSend$
共享存储SharedMemory:共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号两,配合使用,来实现进程间的同步和通信。 /* Linux 6.cpp */ #include #include #include #include #include #include using namespace std; int main() { char *shmaddr; char *shmaddread; char str[]="Hello, I am a processing. \n"; int shmid; key_t key = ftok(".",1); pid_t pid1 = fork(); if(pid1 == -1){ cout << "Fork error. " << endl; exit(1); } else if(pid1 == 0){ //子进程 shmid = shmget(key,1024,IPC_CREAT | 0600); shmaddr = (char*)shmat(shmid, NULL, 0); strcpy(shmaddr, str); cout << "[Writer] write: " << shmaddr << endl; shmdt(shmaddr); } else { //父进程 pid_t pid2 = fork(); if(pid2 == -1){ cout << "Fork error. " << endl; exit(1); } else if(pid2 == 0){ //子进程 sleep(2); shmid = shmget(key,1024,IPC_CREAT | 0600); shmaddread = (char*)shmat(shmid, NULL, 0); cout << "[Reader] read: " << shmaddread << endl; shmdt(shmaddread); } } sleep(3); return 0; }
信号量Semaphore:信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。 使用示例代码如下 //g++ semtest.cpp -o test -lpthread #include #include #include #include #include sem_t sem; /*function:获取当前时间,精确到毫秒 * */ int64_t getTimeMsec() { struct timeval tv; gettimeofday(&tv, NULL); return tv.tv_sec * 1000 + tv.tv_usec / 1000; } void* func_sem_wait(void* arg) { printf("set wait\n"); sem_wait(&sem); printf("sem wait success\n"); int *running = (int*)arg; printf("func_sem_wait running\n"); printf("%d\n", *running); } void* func_sem_timedwait(void* arg) { timespec timewait; timewait.tv_sec = getTimeMsec() / 1000 + 2; timewait.tv_nsec = 0; printf("sem_timedwait\n"); int ret = sem_timedwait(&sem, &timewait); printf("sem_timedwait,ret=%d\n", ret); printf("func_sem_timedwait running\n"); } void* func_sem_post(void* arg) { printf("func_sem_post running\n"); printf("sem post\n"); int *a = (int*)arg; *a = 6; sem_post(&sem); sem_post(&sem); } int main() { sem_init(&sem, 0, 0); pthread_t thread[3]; int a = 5; pthread_create(&(thread[0]), NULL, func_sem_wait, &a); printf("thread func_sem_wait\n"); pthread_create(&(thread[2]), NULL, func_sem_timedwait, &a); printf("thread func_sem_timedwait\n"); sleep(4); pthread_create(&(thread[1]), NULL, func_sem_post, &a); printf("thread func_sem_post\n"); pthread_join(thread[0], NULL); pthread_join(thread[1], NULL); pthread_join(thread[2], NULL); sem_destroy(&sem); }
套接字Socket:套解口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同机器间的进程通信。
信号 ( sinal ) : 信号是进程间通信机制中唯一的异步通信机制,可以看作是异步通知,通知接收信号的进程有哪些事情发生了。也可以简单理解为信号是某种形式上的软中断。 一般情况下,信号的来源可分为以下三种:
硬件方式:除数为零、无效的存储访问等硬件异常产生信号。这些事件通常由硬件(如:CPU)检测到,并将其通知给Linux操作系统内核,然后内核生成相应的信号,并把信号发送给该事件发生时正在进行的程序。
软件方式:用户在终端下调用kill命令向进程发送任务信号、进程调用kill或sigqueue函数发送信号、当检测到某种软件条件已经具备时发出信号,如由alarm或settimer设置的定时器超时时将生成SIGALRM信号等多种情景均可产生信号。
键盘输入:当用户在终端上按下某键时,将产生信号。如按下组合键Ctrl+C将产生一个SIGINT信号,Ctrl+\产生一个SIGQUIT信号等。 以下列出几个常用的信号: 信号 描述 SIGHUP 当用户退出终端时,由该终端开启的所有进程都退接收到这个信号,默认动作为终止进程。 SIGINT 程序终止(interrupt)信号, 在用户键入INTR字符(通常是Ctrl+C)时发出,用于通知前台进程组终止进程。 SIGQUIT 和SIGINT类似, 但由QUIT字符(通常是Ctrl+\)来控制. 进程在因收到SIGQUIT退出时会产生core文件, 在这个意义上类似于一个程序错误信号。 SIGKILL 用来立即结束程序的运行. 本信号不能被阻塞、处理和忽略。 SIGTERM 程序结束(terminate)信号, 与SIGKILL不同的是该信号可以被阻塞和处理。通常用来要求程序自己正常退出。 SIGSTOP 停止(stopped)进程的执行. 注意它和terminate以及interrupt的区别:该进程还未结束, 只是暂停执行. 本信号不能被阻塞, 处理或忽略.
代码示例:
下面的代码收到程序退出信号后会执行用户定义的信号处理函数来替代系统默认的处理程序。 #include #include #include #include #include void sig_handle(int sig) { printf("received signal: %d, quit.\n", sig); exit(0); } int main () { signal(SIGINT, sig_handle); signal(SIGKILL, sig_handle); signal(SIGSEGV, sig_handle); signal(SIGTERM, sig_handle); int i = 0; while (1) { printf("%d\n", ++i); sleep(2); } printf("main quit."); return 0; } 复制代码
运行结果: 1 2 received signal: 15, quit.
1.21 请介绍线程之间的通信方式。
参考回答
锁机制:包括互斥锁、条件变量、读写锁互斥锁提供了以排他方式防止数据结构被并发修改的方法。读写锁允许多个线程同时读共享数据,而对写操作是互斥的。条件变量可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用。
信号量机制(Semaphore):包括无名线程信号量和命名线程信号量
信号机制(Signal):类似进程间的信号处理线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制。
1.22 说一说进程的状态。
参考回答
进程的3种基本状态:运行、就绪和阻塞。
(1)就绪:当一个进程获得了除处理机以外的一切所需资源,一旦得到处理机即可运行,则称此进程处于就绪状态。就绪进程可以按多个优先级来划分队列。例如,当一个进程由于时间片用完而进入就绪状态时,排入低优先级队列;当进程由I/O操作完成而进入就绪状态时,排入高优先级队列。
(2)运行:当一个进程在处理机上运行时,则称该进程处于运行状态。处于此状态的进程的数目小于等于处理器的数目,对于单处理机系统,处于运行状态的进程只有一个。在没有其他进程可以执行时(如所有进程都在阻塞状态),通常会自动执行系统的空闲进程。
(3)阻塞:也称为等待或睡眠状态,一个进程正在等待某一事件发生(例如请求I/O而等待I/O完成等)而暂时停止运行,这时即使把处理机分配给进程也无法运行,故称该进程处于阻塞状态。
其转移图如下:
进程的五种状态 创建状态:进程在创建时需要申请一个空白PCB,向其中填写控制和管理进程的信息,完成资源分配。如果创建工作无法完成,比如资源无法满足,就无法被调度运行,把此时进程所处状态称为创建状态 就绪状态:进程已经准备好,已分配到所需资源,只要分配到CPU就能够立即运行 执行状态:进程处于就绪状态被调度后,进程进入执行状态 阻塞状态:正在执行的进程由于某些事件(I/O请求,申请缓存区失败)而暂时无法运行,进程受到阻塞。在满足请求时进入就绪状态等待系统调用 终止状态:进程结束,或出现错误,或被系统终止,进入终止状态。无法再执行
1.23 CPU调度的最小单位是什么?线程需要CPU调度吗?
参考回答
进程是CPU分配资源的最小单位,线程是CPU调度的最小单位。
线程是比进程更小的能独立运行的基本单位,需要通过CPU调度来切换上下文,达到并发的目的。
1.24 进程之间共享内存的通信方式有什么好处?
参考回答
采用共享内存通信的一个显而易见的好处是效率高,因为进程可以直接读写内存,而不需要任何数据的拷贝。对于像管道和消息队列等通信方式,则需要在内核和用户空间进行四次的数据拷贝,而共享内存则只拷贝两次数据:一次从输入文件到共享内存区,另一次从共享内存区到输出文件。
实际上,进程之间在共享内存时,并不总是读写少量数据后就解除映射,有新的通信时,再重新建立共享内存区域。而是保持共享区域,直到通信完毕为止,这样,数据内容一直保存在共享内存中,并没有写回文件。共享内存中的内容往往是在解除映射时才写回文件的。因此,采用共享内存的通信方式效率是非常高的。
1.25 如何杀死一个进程?
参考回答
杀死父进程并不会同时杀死子进程:每个进程都有一个父进程。可以使用 pstree 或 ps 工具来观察这一点。 # 启动两个虚拟进程 $ sleep 100 & $ sleep 101 & $ pstree -p init(1)-+ |-bash(29051)-+-pstree(29251) |-sleep(28919) `-sleep(28964) $ ps j -A PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND 0 1 1 1 ? -1 Ss 0 0:03 /sbin/init 29051 1470 1470 29051 pts/2 2386 SN 1000 0:00 sleep 100 29051 1538 1538 29051 pts/2 2386 SN 1000 0:00 sleep 101 29051 2386 2386 29051 pts/2 2386 R+ 1000 0:00 ps j -A 1 29051 29051 29051 pts/2 2386 Ss 1000 0:00 -bash 调用 ps 命令可以显示 PID(进程 ID) 和 PPID(父进程 ID)。 杀死父进程后,子进程将会成为孤儿进程,而 init 进程将重新成为它的父进程。
杀死进程组或会话中的所有进程 $ kill -SIGTERM -- -19701 这里用一个负数 -19701 向进程组发送信号。如果传递的是一个正数,这个数将被视为进程 ID 用于终止进程。如果传递的是一个负数,它被视为 PGID,用于终止整个进程组。负数来自系统调用的直接定义。 杀死会话中的所有进程与之完全不同。即使是具有会话 ID 的系统,例如 Linux,也没有提供系统调用来终止会话中的所有进程。需要遍历 /proc 输出的进程树,收集所有的 SID,然后一一终止进程。 Pgrep 实现了遍历、收集并通过会话 ID 杀死进程的算法。可以使用以下命令: pkill -s
1.26 说一说kill的原理。
参考回答
kill 命令的执行原理是这样的,kill 命令会向操作系统内核发送一个信号(多是终止信号)和目标进程的 PID,然后系统内核根据收到的信号类型,对指定进程进行相应的操作。kill 命令的基本格式如下: [root\@localhost ~]# kill [信号] PID
kill 命令是按照 PID 来确定进程的,所以 kill 命令只能识别 PID,而不能识别进程名。
kill 命令只是“发送”一个信号,因此,只有当信号被程序成功“捕获”,系统才会执行 kill 命令指定的操作;反之,如果信号被“封锁”或者“忽略”,则 kill 命令将会失效。
1.27 介绍下你知道的锁。
参考回答
悲观锁
悲观锁并不是某一个锁,是一个锁类型,无论是否并发竞争资源,都会锁住资源,并等待资源释放下一个线程才能获取到锁。 这明显很悲观,所以就叫悲观锁。这明显可以归纳为一种策略,只要符合这种策略的锁的具体实现,都是悲观锁的范畴。
乐观锁
与悲观锁相对的,乐观锁也是一个锁类型。当线程开始竞争资源时,不是立马给资源上锁,而是进行一些前后值比对,以此来操作资源。例如常见的CAS操作,就是典型的乐观锁。示例如下 int cas(long *addr, long old, long new) { /* 原子执行 */ if(*addr != old) return 0; *addr = new; return 1; }
自旋锁
自旋锁是一种基础的同步原语,用于保障对共享数据的互斥访问。与互斥锁的相比,在获取锁失败的时候不会使得线程阻塞而是一直自旋尝试获取锁。当线程等待自旋锁的时候,CPU不能做其他事情,而是一直处于轮询忙等的状态。
自旋锁主要适用于被持有时间短,线程不希望在重新调度上花过多时间的情况。实际上许多其他类型的锁在底层使用了自旋锁实现,例如多数互斥锁在试图获取锁的时候会先自旋一小段时间,然后才会休眠。如果在持锁时间很长的场景下使用自旋锁,则会导致CPU在这个线程的时间片用尽之前一直消耗在无意义的忙等上,造成计算资源的浪费。 // 用户空间用 atomic_flag 实现自旋互斥 #include #include #include #include std::atomic_flag lock = ATOMIC_FLAG_INIT; void f(int n) { for (int cnt = 0; cnt < 100; ++cnt) { while (lock.test_and_set(std::memory_order_acquire)) // 获得锁 ; // 自旋 std::cout << "Output from thread " << n << '\n'; lock.clear(std::memory_order_release); // 释放锁 } } int main() { std::vector v; for (int n = 0; n < 10; ++n) { v.emplace_back(f, n); } for (auto& t : v) { t.join(); } }
公平锁
多个线程竞争同一把锁,如果依照先来先得的原则,那么就是一把公平锁。
非公平锁
多个线程竞争锁资源,抢占锁的所有权。
共享锁
多个线程可以共享这个锁的拥有权。一般用于数据的读操作,防止数据被写修改。共享锁的代码示例如下: #include #include #include #include #include std::shared_mutex test_lock; std::mutex cout_lock; int arr[3] = {11, 22, 33}; void unique_lock_demo(int id) { std::unique_lock lock{test_lock}; for(int i =0; i < 3; i++) { arr[i] = i + 100 * id; } for(int i = 0; i < 3; i++) { std::unique_lock pl(cout_lock); std::cout << "In unique: " << id << ": " << arr[i] << std::endl; pl.unlock(); std::this_thread::sleep_for(std::chrono::seconds(1)); } } void shared_lock_demo(int id) { std::shared_lock lock{test_lock}; for(int i = 0; i < 3; i++) { std::unique_lock pl(cout_lock); std::cout << "In shared " << id << ": " << arr[i] << std::endl; pl.unlock(); std::this_thread::sleep_for(std::chrono::seconds(1)); } } int main() { std::thread t3(unique_lock_demo,3); std::thread t4(unique_lock_demo,4); std::thread t1(shared_lock_demo,1); std::thread t2(shared_lock_demo,2); t1.join(); t2.join(); t3.join(); t4.join(); return 0; }
输出为: In unique: 3: 300 In unique: 3: 301 In unique: 3: 302 In shared 1: 300 In shared 2: 300 In shared 1: 301 In shared 2: 301 In shared 1: 302 In shared 2: 302 In unique: 4: 400 In unique: 4: 401 In unique: 4: 402
从这个输出可以看出:
如果一个线程已经获取了共享锁,则其他任何线程都无法获取互斥锁,但是可以获取共享锁。
从这个输出可以看出,验证了如果一个线程已经获取了互斥锁,则其他线程都无法获取该锁。
死锁
死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。 mutex; //代表一个全局互斥对象 void A() { mutex.lock(); //这里操作共享数据 B(); //这里调用B方法 mutex.unlock(); return; } void B() { mutex.lock(); //这里操作共享数据 mutex.unlock(); return; }
1.28 什么情况下会产生死锁?
参考回答
如果在计算机系统中同时具备下面四个必要条件时,那么会发生死锁。换句话说,只要下面四个条件有一个不具备,系统就不会出现死锁。
互斥条件。即某个资源在一段时间内只能由一个进程占有,不能同时被两个或两个以上的进程占有。这种独占资源如CD-ROM驱动器,打印机等等,必须在占有该资源的进程主动释放它之后,其它进程才能占有该资源。这是由资源本身的属性所决定的。如独木桥就是一种独占资源,两方的人不能同时过桥。 #include #include #include std::list some_list; // 1 std::mutex some_mutex; // 2 void add_to_list(int new_value) { std::lock_guard guard(some_mutex); // 3 some_list.push_back(new_value); } bool list_contains(int value_to_find) { std::lock_guard guard(some_mutex); // 4 return std::find(some_list.begin(),some_list.end(),value_to_find) != some_list.end(); } 代码中有一个全局变量①,这个全局变量被一个全局的互斥量保护②。add_to_list()③和list_contains()④函数中使用std::lock_guard,使得这两个函数中对数据的访问是互斥的:list_contains()不可能看到正在被add_to_list()修改的列表。
不剥夺条件。进程所获得的资源在未使用完毕之前,资源申请者不能强行地从资源占有者手中夺取资源,而只能由该资源的占有者进程自行释放。如过独木桥的人不能强迫对方后退,也不能非法地将对方推下桥,必须是桥上的人自己过桥后空出桥面(即主动释放占有资源),对方的人才能过桥。
请求和保持条件。进程至少已经占有一个资源,但又申请新的资源;由于该资源已被另外进程占有,此时该进程阻塞;但是,它在等待新资源之时,仍继续占用已占有的资源。还以过独木桥为例,甲乙两人在桥上相遇。甲走过一段桥面(即占有了一些资源),还需要走其余的桥面(申请新的资源),但那部分桥面被乙占有(乙走过一段桥面)。甲过不去,前进不能,又不后退;乙也处于同样的状况。
循环等待条件。存在一个进程等待序列{P1,P2,...,Pn},其中P1等待P2所占有的某一资源,P2等待P3所占有的某一源,......,而Pn等待P1所占有的的某一资源,形成一个进程循环等待环。就像前面的过独木桥问题,甲等待乙占有的桥面,而乙又等待甲占有的桥面,从而彼此循环等待。 std::mutex m; void f() { // .... std::lock_guard lock(m); // 1 子线程锁住互斥量m // ... } int main() { std::thread t(f); std::lock_guard lock(m); // 2 主线程锁住互斥量m // ... t.join(); // 3 等待子线程结束 return 0; } 上述过程可能导致在2处上锁,然后子线程在1处发生阻塞,最后主线程在3处一直等待子线程结束,无穷等待下去。
上面提到的这四个条件在死锁时会同时发生。也就是说,只要有一个必要条件不满足,则死锁就可以排除。
1.29 说一说你对自旋锁的理解。
参考回答
旋锁的定义:当一个线程尝试去获取某一把锁的时候,如果这个锁此时已经被别人获取(占用),那么此线程就无法获取到这把锁,该线程将会等待,间隔一段时间后会再次尝试获取。这种采用循环加锁 -> 等待的机制被称为自旋锁(spinlock)。
自旋锁有以下特点
用于临界区互斥
在任何时刻最多只能有一个执行单元获得锁
要求持有锁的处理器所占用的时间尽可能短
等待锁的线程进入忙循环
自旋锁存在的问题
如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。
无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在“线程饥饿”问题。
自旋锁的优点
自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。(线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)
自旋锁与互斥锁的区别
自旋锁与互斥锁都是为了实现保护资源共享的机制。
无论是自旋锁还是互斥锁,在任意时刻,都最多只能有一个保持者。
获取互斥锁的线程,如果锁已经被占用,则该线程将进入睡眠状态;获取自旋锁的线程则不会睡眠,而是一直循环等待锁释放。
1.30 说一说你对悲观锁的理解。
参考回答
悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
1.31 说一说你对乐观锁的理解。
参考回答
乐观锁总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。
1.32 CAS在什么地方用到过吗?
参考回答
CAS是英文单词CompareAndSwap的缩写,中文意思是:比较并替换。CAS需要有3个操作数:内存地址V,旧的预期值A,即将要更新的目标值B。CAS指令执行时,当且仅当内存地址V的值与预期值A相等时,将内存地址V的值修改为B,否则就什么都不做。整个比较并替换的操作是一个原子操作。
高并发环境下,对同一个数据的并发读(两边都读出余额是100)与并发写(一个写回28,一个写回38)导致的数据一致性问题。 解决方案是在set写回的时候,加上初始状态的条件compare,只有初始状态不变时,才允许set写回成功,这是一种常见的降低读写锁冲突,保证数据一致性的方法。
1.33 谈谈IO多路复用。
参考回答
IO多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄;一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;没有文件句柄就绪时会阻塞应用程序,交出cpu。多路是指网络连接,复用指的是同一个线程。
IO多路复用有三种实现方式:select, poll, epoll (1)select:时间复杂度O(n),它仅仅知道了,有I/O事件发生了,却并不知道是哪那几个流(可能有一个,多个,甚至全部),只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。 int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述副就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以 通过遍历fdset,来找到就绪的描述符。 select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。select的一 个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低。
(2)poll:时间复杂度O(n),poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的。 int poll (struct pollfd *fds, unsigned int nfds, int timeout);
不同与select使用三个位图来表示三个fdset的方式,poll使用一个 pollfd的指针实现。 struct pollfd { int fd; /* file descriptor */ short events; /* requested events to watch */ short revents; /* returned events witnessed */ };
pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式。同时,pollfd并没有最大数量限制(但是数量过大后性能也是会下降)。 和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。
(3)epoll:时间复杂度O(1),epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。所以说epoll实际上是事件驱动(每个事件关联上fd) 的,此时对这些流的操作都是有意义的。
epoll操作过程需要三个接口,分别如下: int epoll_create(int size);//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
int epoll_create(int size):创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。当创建好epoll句柄后,它就会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event):函数是对指定描述符fd执行op操作。- epfd:是epoll_create()的返回值。- op:表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的监听事件。- fd:是需要监听的fd(文件描述符)- epoll_event:是告诉内核需要监听什么事,
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout):等待epfd上的io事件,最多返回maxevents个事件。参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。
select、poll、epoll区别
支持一个进程所能打开的最大连接数
FD剧增后带来的IO效率问题
消息传递方式
1.34 谈谈poll和epoll的区别。
参考回答
poll将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。 它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有缺点:
大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。 epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就绪态,并且只会通知一次。还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。其优点有:
没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)。
效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。
1.35 谈谈select和epoll的区别。
参考回答
select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。 select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低。 内核需要传递消息到用户空间,需要内存拷贝
相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。 epoll能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)。
效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
1.36 epoll有哪两种模式?
参考回答
epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:
LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
LT模式
LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的。
ET模式
ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
1.37 说一下epoll的原理,它的查询速度是O(1)的吗?
参考回答
epoll是一种更加高效的IO多路复用的方式,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。时间复杂度为O(1)。
epoll的执行过程如图所示:
创建红黑树,调用epoll_create()创建一颗空的红黑树,用于存放FD及其感兴趣事件;
注册感兴趣事件,调用epoll_ctl()向红黑树中添加节点(FD及其感兴趣事件),时间复杂度O(logN),向内核的中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它添加到就绪队列中。所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到就绪队列中了;
获取就绪事件,调用epoll_wait()返回就绪队列中的就绪事件,时间复杂度O(1);
1.38 介绍域名解析成IP的全过程。
参考回答
第一步:检查浏览器缓存中是否缓存过该域名对应的IP地址
用户通过浏览器浏览过某网站之后,浏览器就会自动缓存该网站域名对应的地址,当用户再次访问的时候,浏览器就会从缓存中查找该域名对应的IP地址,因为缓存不仅是有大小限制,而且还有时间限制(域名被缓存的时间通过属性来设置),所以存在域名对应的找不到的情况。当浏览器从缓存中找到了该网站域名对应的地址,那么整个解析过程结束,如果没有找到,将进行下一步骤。对于的缓存时间问题,不宜设置太长的缓存时间,时间太长,如果域名对应的发生变化,那么用户将在一段时间内无法正常访问到网站,如果太短,那么又造成频繁解析域名。
第二步:如果在浏览器缓存中没有找到IP,那么将继续查找本机系统是否缓存过IP
如果第一个步骤没有完成对域名的解析过程,那么浏览器会去系统缓存中查找系统是否缓存过这个域名对应的地址,也可以理解为系统自己也具备域名解析的基本能力。在系统中,可以通过设置文件来将域名手动绑定到某上,文件位置在。对于普通用户,并不推荐自己手动绑定域名和,对于开发者来说,通过绑定域名和,可以轻松切换环境,可以从测试环境切换到开发环境,方便开发和测试。在系统中,黑客常常修改他的电脑的文件,将用户常常访问的域名绑定到他指定的上,从而实现了本地解析,导致这些域名被劫持。在或者系统中,文件在,修改该文件也可以实现同样的目的。
前两步都是在本机上完成的,所以没有在上面示例图上展示出来,从第三步开始,才正在地向远程DNS服务器发起解析域名的请求。
第三步:向本地域名解析服务系统发起域名解析的请求
如果在本机上无法完成域名的解析,那么系统只能请求本地域名解析服务系统进行解析,本地域名系统一般都是本地区的域名服务器,比如你连接的校园网,那么域名解析系统就在你的校园机房里,如果你连接的是电信、移动或者联通的网络,那么本地域名解析服务器就在本地区,由各自的运营商来提供服务。对于本地服务器地址,系统使用命令就可以查看,在和系统下,直接使用命令来查看服务地址。一般都缓存了大部分的域名解析的结果,当然缓存时间也受域名失效时间控制,大部分的解析工作到这里就差不多已经结束了,负责了大部分的解析工作。
第四步:向根域名解析服务器发起域名解析请求
本地域名解析器还没有完成解析的话,那么本地域名解析服务器将向根域名服务器发起解析请求。
第五步:根域名服务器返回gTLD域名解析服务器地址
本地域名解析向根域名服务器发起解析请求,根域名服务器返回的是所查域的通用顶级域()地址,常见的通用顶级域有、、、等。
第六步:向gTLD服务器发起解析请求
本地域名解析服务器向gTLD服务器发起请求。
第七步:gTLD服务器接收请求并返回Name Server服务器
服务器接收本地域名服务器发起的请求,并根据需要解析的域名,找到该域名对应的域名服务器,通常情况下,这个服务器就是你注册的域名服务器,那么你注册的域名的服务商的服务器将承担起域名解析的任务。
第八步:Name Server服务器返回IP地址给本地服务器
服务器查找域名对应的地址,将地址连同值返回给本地域名服务器。
第九步:本地域名服务器缓存解析结果
本地域名服务器缓存解析后的结果,缓存时间由时间来控制。
1.39 如何在Linux上配置一个IP地址,如果给定端口号如何解析出域名?
参考回答
配置Linux系统的IP地址的方法,主要有以下三种:
ifconfig ifconfig 命令主要是用来查看网卡的配置信息,因为用它来配置网卡的IP地址时,只会临时生效(Linux服务器重启后就会失效)
setup setup 命令是 redhat 系列的linux系统(如CentOS)中专有的命令工具。可以使用 setup 命令,来对网络配置中的IP地址、子网掩码、默认网关、DNS服务器进行设置。而且,setup 网络配置工具设置的IP地址会永久生效。
修改网卡的配置文件 直接修改网卡的配置文件,设置方法有两种:
自动获取动态IP地址
手工配置静态的IP地址
使用dig命令解析域名
1.40 解释一下IP地址、子网掩码、网关。
参考回答
IP地址
IP地址有一个32位的连接地址,由4个8位字段组成,8位字段称为8位位组,每个8位位组之间用点号隔开,用于标识TCP/IP宿主机。每个IP地址都包含两部分:网络ID和主机ID,网络ID 标识在同一个物理网络上的所有宿主机,主机ID标识网络上的每一个宿主机,运行TCP/IP的每个计算机都需要唯一的IP地址。
Intenet委员会定义了五种地址类型以适应不同尺寸的网络。地址类型定义网络ID使用哪些位,它也定义了网络的可能数目和每个网络可能的宿主机数目.
子网掩码(Subnet Mask)
使用子网可以把单个大网分成多个物理网络,并用路由器把它们连接起来。子网掩码用于屏蔽IP地址的一部分,使得TCP/IP能够区别网络ID和宿主机ID。当TCP/IP宿主机要通信时,子网掩码用于判断一个宿主机是在本地网络还是在远程网络。
缺省的子网掩码用于不分成子网的TCP/IP网络,对应于网络ID的所有位都置为1,每个8位位组的十进制数是255,对应于宿主机ID的所有位都置为0。
用于子网掩码的位数决定可能的子网数目和每个子网的宿主机数目,子网掩码的位数越多,则子网越多,但是宿主机也较少。
例:假设A类地址子网数是14,则所需位数至少为4,用于子网的位为: 11111111, 11110000, 00000000, 00000000, 子网掩码为255.240.0.0,每个子网的宿主机数目为2^20-2=1,048, 574个。
网关(Gateway)
网关就是一个网络连接到另一个网络的“关口”。 按照不同的分类标准,网关也有很多种。TCP/IP协议里的网关是最常用的,在这里我们所讲的“网关”均指TCP/ IP协议下的网关。
网关实质上是一个网络通向其他网络的IP地址。比如有网络A和网络B,网络A的IP地址范围为“192.168.1.1~192. 168.1.254”,子网掩码为255.255.255.0;网络B的IP地址范围为“192.168.2.1~192. 168.2.254”,子网掩码为255.255.255.0。在没有路由器的情况下,两个网络之间是不能进行TCP/IP通信的,即使是两个网络连接在同一台交换机(或集线器)上,TCP/IP协议也会根据子网掩码(255.255.255.0)判定两个网络中的主机处在不同的网络里。而要实现这两个网络之间的通信,则必须通过网关。
如果网络A中的主机发现数据包的目的主机不在本地网络中,就把数据包转发给它自己的网关,再由网关转发给网络B的网关,网络B的网关再转发给网络B的某个主机。网络B向网络A转发数据包的过程也是如此。而要实现这两个网络之间的通信,则必须通过网关。如果网络A中的主机发现数据包的目的主机不在本地网络中,就把数据包转发给它自己的网关,再由网关转发给网络B的网关,网络B的网关再转发给网络B的某个主机。网络B向网络A转发数据包的过程也是如此 所以说,只有设置好网关的IP地址,TCP/IP协议才能实现不同网络之间的相互通信。那么这个IP地址是哪台机器的IP地址呢?网关的IP地址是具有路由功能的设备的IP地址,具有路由功能的设备有路由器、启用了路由协议的服务器(实质上相当于一台路由器)、代理服务器(也相当于一台路由器)。
1.41 说说IP如何寻址?
参考回答
IP寻址包括本地网络寻址和非本地网络寻址两部分
本地网络寻址 假设有2个主机,他们是属于同一个网段。主机A和主机B,首先主机A通过本机的hosts表或者wins系统或dns系统先将主机B的计算机名转换为IP地址,然后用自己的IP地址与子网掩码计算出自己所出的网段,比较目的主机B的ip地址与自己的子网掩码,发现与自己是出于相同的网段,于是在自己的ARP缓存中查找是否有主机B的mac地址,如果能找到就直接做数据链路层封装并且通过网卡将封装好的以太网帧发送有物理线路上去。 如果arp缓存中没有主机B的的mac地址,主机A将启动arp协议通过在本地网络上的arp广播来查询主机B的mac地址,获得主机B的mac地址厚写入arp缓存表,进行数据链路层的封装,发送数据。
非本地网络寻址 假设2个主机不是相同的网段,不同的数据链路层网络必须分配不同网段的IP地址并且由路由器将其连接起来。主机A通过本机的hosts表或wins系统或dns系统先主机B的计算机名转换为IP地址,然后用自己的IP地址与子网掩码计算出自己所处的网段,比较目的目的主机B的IP地址,发现与自己处于不同的网段。于是主机A将知道应该将次数据包发送给自己的缺省网关,即路由器的本地接口。 主机A在自己的ARP缓存中查找是否有缺省网关的MAC地址,如果能够找到就直接做数据链路层封装并通过网卡,将封装好的以太网数据帧发送到物理线路上去,如果arp缓存表中没有缺省网关的Mac地址,主机A将启动arp协议通过在本地网络上的arp广播来查询缺省网关的mac地址,获得缺省网关的mac地址后写入arp缓存表,进行数据链路层的封装,发送数据。 数据帧到达路由器的接受接口后首先解封装,变成IP数据包,对IP包进行处理,根据目的IP地址查找路由表,决定转发接口后做适应转发接口数据链路层协议帧的封装,并且发送到下一跳路由器,此过程继续直至到达目的的网络与目的主机。
1.42 操作系统的地址有几种,请具体说明。
参考回答
操作系统有物理地址、逻辑地址、线性地址(也叫虚拟地址)三种地址
物理地址 在存储器里以字节为单位存储信息,为正确地存放或取得信息,每一个字节单元给以一个唯一的存储器地址,称为物理地址(Physical Address),又叫实际地址或绝对地址。 地址从0开始编号,顺序地每次加1,因此存储器的物理地址空间是呈线性增长的。它是用二进制数来表示的,是无符号整数,书写格式为十六进制数。它是出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果。用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。
逻辑地址 逻辑地址是指在计算机体系结构中是指应用程序角度看到的内存单元(memory cell)、存储单元(storage element)、网络主机(network host)的地址。 逻辑地址往往不同于物理地址(physical address),通过地址翻译器(address translator)或映射函数可以把逻辑地址转化为物理地址。 在有地址变换功能的计算机中,访问指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的物理地址。把用户程序中使用的地址称为相对地址即逻辑地址。逻辑地址由两个16位的地址分量构成,一个为段基值,另一个为偏移量。两个分量均为无符号数编码。
线性地址 线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。 线性地址是一个32位无符号整数,可以用来表示高达4GB的地址,也就是,高达4294967296个内存单元。线性地址通常用十六进制数字表示,值的范围从0x00000000到0xffffffff)。程序代码会产生逻辑地址,通过逻辑地址变换就可以生成一个线性地址。如果启用了分页机制,那么线性地址可以再经过变换以产生一个物理地址。当采用4KB分页大小的时候,线性地址的高10位为页目录项在页目录表中的编号,中间10位为页表中的页号,其低12位则为偏移地址。如果是使用4MB分页机制,则高10位页号,低22位为偏移地址。如果没有启用分页机制,那么线性地址直接就是物理地址。
1.43 Linux的静态网络怎么配置?
参考回答
网络配置的配置文件在/etc/sysconfig/network-scripts/下,文件名前缀为ifcfg-后面跟的就是网卡的名称,可以使用ifconfig查看,也可以使用命令: ls /etc/sysconfig/network-scripts/ifcfg-* 列出所有的设备配置文件,
比如这里就是ifcfg-eno16777984这个文件,ifcfg-lo是本地回环地址的配置文件,所有计算机都有,不用动他,
现在使用: vim /etc/sysconfig/network-scripts/ifcfg-eno16777984 打开配置文件进行编辑,默认情况是dhcp动态获取的,如下图:
这时候如果想修改成静态的,首先把BOOTPROTO="dhcp"改成BOOTPROTO="static"表示静态获取,然后在最后追加比如下面的配置: BROADCAST=192.168.1.255 IPADDR=192.168.1.33 NETMASK=255.255.255.0 GATEWAY=192.168.1.1
BROADCAST设置的是局域网广播地址,IPADDR就是静态IP,NETMASK是子网掩码,GATEWAY就是网关或者路由地址;需要说明,原来还有个NETWORK配置的是局域网网络号,这个是ifcalc自动计算的,所以这里配置这些就足够了,最终配置如下图:
配置完成之后保存退出,
设置完毕,然后使用命令: /etc/init.d/network restart 或者 service network restart 重启网络服务,重启后如果路由配置了支持静态IP,那么linux就能获取到刚才配置的IP地址,这样静态IP就配置成功了
1.44 DNS用了哪些协议?
参考回答
DNS在进行区域传输的时候使用TCP协议,其它时候则使用UDP协议; DNS的规范规定了2种类型的DNS服务器,一个叫主DNS服务器,一个叫辅助DNS服务器。在一个区中主DNS服务器从自己本机的数据文件中读取该区的DNS数据信息,而辅助DNS服务器则从区的主DNS服务器中读取该区的DNS数据信息。当一个辅助DNS服务器启动时,它需要与主DNS服务器通信,并加载数据信息,这就叫做区传送(zone transfer)。
为什么既使用TCP又使用UDP?
UDP报文的最大长度为512字节,而TCP则允许报文长度超过512字节。当DNS查询超过512字节时,协议的TC标志出现删除标志,这时则使用TCP发送。通常传统的UDP报文一般不会大于512字节。
区域传送时使用TCP,主要有以下两点考虑:
辅域名服务器会定时(一般时3小时)向主域名服务器进行查询以便了解数据是否有变动。如有变动,则会执行一次区域传送,进行数据同步。区域传送将使用TCP而不是UDP,因为数据同步传送的数据量比一个请求和应答的数据量要多得多。
TCP是一种可靠的连接,保证了数据的准确性。
域名解析时使用UDP协议: 客户端向DNS服务器查询域名,一般返回的内容都不超过512字节,用UDP传输即可。不用经过TCP三次握手,这样DNS服务器负载更低,响应更快。虽然从理论上说,客户端也可以指定向DNS服务器查询的时候使用TCP,但事实上,很多DNS服务器进行配置的时候,仅支持UDP查询包。
1.45 说一说你对Linux内核的了解。
参考回答
内核是操作系统的核心,具有很多最基本功能,它负责管理系统的进程、内存、设备驱动程序、文件和网络系统,决定着系统的性能和稳定性。
Linux 内核有 4 项工作:
内存管理: 追踪记录有多少内存存储了什么以及存储在哪里
进程管理: 确定哪些进程可以使用中央处理器(CPU)、何时使用以及持续多长时间
设备驱动程序: 充当硬件与进程之间的调解程序/解释程序
系统调用和安全防护: 从流程接受服务请求
在正确实施的情况下,内核对于用户是不可见的,它在自己的小世界(称为内核空间)中工作,并从中分配内存和跟踪所有内容的存储位置。用户所看到的内容(例如 Web 浏览器和文件则被称为用户空间。这些应用通过系统调用接口(SCI)与内核进行交互。
举例来说, 内核就像是一个为高管(硬件)服务的忙碌的个人助理。助理的工作就是将员工和公众(用户)的消息和请求(进程)转交给高管,记住存放的内容和位置(内存),并确定在任何特定的时间谁可以拜访高管、会面时间有多长。
为了更具象地理解内核,不妨将 Linux 计算机想象成有三层结构:
硬件:物理机(这是系统的底层结构或基础)是由内存(RAM)、处理器(或 CPU)以及输入/输出(I/O)设备(例如存储、网络和图形)组成的。其中,CPU 负责执行计算和内存的读写操作。
Linux 内核:操作系统的核心。它是驻留在内存中的软件,用于告诉 CPU 要执行哪些操作。
用户进程:这些是内核所管理的运行程序。用户进程共同构成了用户空间。用户进程有时也简称为进程。内核还允许这些进程和服务器彼此进行通信(称为进程间通信或 IPC)。
系统执行的代码通过以下两种模式之一在 CPU 上运行:内核模式或用户模式。在内核模式下运行的代码可以不受限制地访问硬件,而用户模式则会限制 SCI 对 CPU 和内存的访问。内存也存在类似的分隔情况(内核空间和用户空间)。这两个小细节构成了一些复杂操作的基础,例如安全防护、构建容器和虚拟机的权限分隔。
这也意味着:如果进程在用户模式下失败,则损失有限,无伤大雅,可以由内核进行修复。另一方面,由于内核进程要访问内存和处理器,因此内核进程的崩溃可能会引起整个系统的崩溃。由于用户进程之间会有适当的保护措施和权限要求,因此一个进程的崩溃通常不会引起太多问题。
1.46 说一说你对Linux内核态与用户态的了解。
参考回答
内核态其实从本质上说就是内核,它是一种特殊的软件程序,控制计算机的硬件资源,例如协调CPU资源,分配内存资源,并且提供稳定的环境供应用程序运行。
用户态就是提供应用程序运行的空间,为了使应用程序访问到内核管理的资源例如CPU,内存,I/O。内核必须提供一组通用的访问接口,这些接口就叫系统调用。
系统调用是操作系统的最小功能单位。根据不同的应用场景,不同的Linux发行版本提供的系统调用数量也不尽相同,大致在240-350之间。这些系统调用组成了用户态跟内核态交互的基本接口。
从用户态到内核态切换可以通过三种方式:
系统调用:系统调用本身就是中断,但是是软件中断,跟硬中断不同。
异常:如果当前进程运行在用户态,如果这个时候发生了异常事件,就会触发切换。例如:缺页异常。
外设中断:当外设完成用户的请求时,会向CPU发送中断信号。
1.47 Linux负载是什么?
参考回答
负载(load)是linux机器的一个重要指标,直观了反应了机器当前的状态。
在UNIX系统中,系统负载是对当前CPU工作量的度量,被定义为特定时间间隔内运行队列中的平均线程数。load average 表示机器一段时间内的平均load。这个值越低越好。负载过高会导致机器无法处理其他请求及操作,甚至导致死机。
top 或 uptime 等命令会输出系统的平均负载 (Load Average),一般会有三个值,分别代表 1 分钟,5 分钟和 15 分钟的平均负载。
负载记录的是 CPU 的负荷,能对 CPU 造成负荷的是进程(包括线程)的执行。负载的数值代表的是 CPU 还没处理完的进程的数目。
系统的负载采用的是指数移动平均,计算方法如下: S(0) = 0 S(t) = a * X(t) + (1-a)*S(t-1)
其中,X(t) 为最近一次采样的值,a 为最近采样值占的比重,S(t) 则是系统最近一次采样的负载。
指数移动平均的计算方式会累计历史所有的采样值,但离现在越久,占的比重越小。更具体的,Linux 系统上对 1 分钟的平均负载取 a 的取值2为 1 - e^(-5/60),5 分钟为 1 - e^(-5s/5min),以此类推。
以一分钟为例,上面的取值能达到的效果是,最近一分钟的采样占所有历史值的比重约为 63%(准确值为 1 - 1/e),5 分钟和 15 分钟也一样。
单核满载是 1,有 n 核满载是 n。一般说线上运行的系统大于 0.7 的时候就要注意了。
1.48 Linux如何设置开机启动?
参考回答
编辑rc.loacl脚本 linux开机之后会执行/etc/rc.local文件中的脚本。 所以可以直接在/etc/rc.local中添加启动脚本。 $ vim /etc/rc.local
添加一个开机启动服务。 将启动脚本复制到 /etc/init.d目录下,并设置脚本权限, 假设脚本为test $ mv test /etc/init.d/test $ sudo chmod 755 /etc/init.d/test 将该脚本放倒启动列表中去 $ cd .etc/init.d $ sudo update-rc.d test defaults 95 注:其中数字95是脚本启动的顺序号,按照自己的需要相应修改即可。在有多个启动脚本,而它们之间又有先后启动的依赖关系时就知道这个数字的具体作用了。 将该脚本从启动列表中剔除 $ cd /etc/init.d $ sudo update-rc.d -f test remove
1.49 谈谈Linux的内存管理。
参考回答
常见的计算机存储层次如下:
寄存器:CPU提供的,读写ns级别,容量字节级别。
CPU缓存:CPU和CPU间的缓存,读写10ns级别,容量较大一些,百到千节。
主存:动态内存,读写100ns级别,容量GB级别。
外部存储介质:磁盘、SSD,读写ms级别,容量可扩展到TB级别。
CPU内的缓存示意图如下:
其中 L1d 和 L1i 都是CPU内部的cache,
L1d 是数据cache。
L1i 是指令缓存。
L2是CPU内部的,不区分指令和数据的。
由于现代PC有多个CPU,L3缓存多个核心共用一个。
对于编程人员来说,绝大部分观察主存和外部存储介质就可以了。如果要做极致的性能优化,可以关注L1、L2、L3的cache,比如nginx的绑核操作、pthread调度会影响CPU cache等。
1. 虚拟内存
物理内存是有限的(即使支持了热插拔)、非连续的,不同的CPU架构对物理内存的组织都不同。这使得直接使用物理内存非常复杂,为了降低使用内存的复杂度,引入了虚拟内存机制。
虚拟内存抽象了应用程序物理内存的细节,只允许物理内存保存所需的信息(按需分页),并提供了一种保护和控制进程间数据共享数据的机制。有了虚拟内存机制之后,每次访问可以使用更易理解的虚拟地址,让CPU转换成实际的物理地址访问内存,降低了直接使用、管理物理内存的门槛。
物理内存按大小被分成页框、页,每块物理内存可以被映射为一个或多个虚拟内存页。这块映射关系,由操作系统的页表来保存,页表是有层级的。层级最低的页表,保存实际页面的物理地址,较高层级的页表包含指向低层级页表的物理地址,指向顶级的页表的地址,驻留在寄存器中。当执行地址转换时,先从寄存器获取顶级页表地址,然后依次索引,找到具体页面的物理地址。
2. 大页机制
虚拟地址转换的过程中,需要好几个内存访问,由于内存访问相对CPU较慢,为了提高性能,CPU维护了一个TLB地址转换的cache,TLB是比较重要且珍稀的缓存,对于大内存工作集的应用程序,会因TLB命中率低大大影响到性能。
为了减少TLB的压力,增加TLB缓存的命中率,有些系统会把页的大小设为MB或者GB,这样页的数目少了,需要转换的页表项也小了,足以把虚拟地址和物理地址的映射关系,全部保存于TLB中。
3. 区域概念
通常硬件会对访问不同的物理内存的范围做出限制,在某些情况下设备无法对所有的内存区域做DMA。在其他情况下,物理内存的大小也会超过了虚拟内存的最大可寻址大小,需要执行特殊操作,才能访问这些区域。这些情况下,Linux对内存页的可能使用情况将其分组到各自的区域中(方便管理和限制)。比如ZONE_DMA用于指明哪些可以用于DMA的区域,ZONE_HIGHMEM包含未永久映射到内核地址空间的内存,ZONE_NORMAL标识正常的内存区域。
4. 节点
多核CPU的系统中,通常是NUMA系统(非统一内存访问系统)。在这种系统中,内存被安排成具有不同访问延迟的存储组,这取决于与处理器的距离。每一个库,被称为一个节点,每个节点Linux构建了一个独立的内存管理子系统。一个节点有自己的区域集、可用页和已用页表和各种统计计数器。
5. page cache
从外部存储介质中加载数据到内存中,这个过程是比较耗时的,因为外部存储介质读写性能毫秒级。为了减少外部存储设备的读写,Linux内核提供了Page cache。最常见的操作,每次读取文件时,数据都会被放入页面缓存中,以避免后续读取时所进行昂贵的磁盘访问。同样,当写入文件时,数据被重新放置在缓存中,被标记为脏页,定期的更新到存储设备上,以提高读写性能。
6. 匿名内存
匿名内存或者匿名映射表示不受文件系统支持的内存,比如程序的堆栈隐式创立的,或者显示通过mmap创立的。
7. 内存回收
贯穿系统的生命周期,一个物理页可存储不同类型的数据,可以是内核的数据结构,或是DMA访问的buffer,或是从文件系统读取的数据,或是用户程序分配的内存等。
根据页面的使用情况,Linux内存管理对其进行了不同的处理,可以随时释放的页面,称之为可回收页面,这类页面为:页面缓存或者是匿名内存(被再次交换到硬盘上)
大多数情况下,保存内部内核数据并用DMA缓冲区的页面是不能重新被回收的,但是某些情况下,可以回收使用内核数据结构的页面。例如:文件系统元数据的内存缓存,当系统处于内存压力情况下,可以从主存中丢弃它们。
释放可回收的物理内存页的过程,被称之为回收,可以同步或者异步的回收操作。当系统负载增加到一定程序时,kswapd守护进程会异步的扫描物理页,可回收的物理页被释放,并逐出备份到存储设备。
8. compaction
系统运行一段时间,内存就会变得支离破碎。虽然使用虚拟村内可以将分散的物理页显示为连续的物理页,但有时需要分配较大的物理连续内存区域。比如设备驱动程序需要一个用于DMA的大缓冲区时,或者大页内存机制分页时。内存compact可以解决了内存碎片的问题,这个机制将被占用的页面,从内存区域合适的移动,以换取大块的空闲物理页的过程,由kcompactd守护进程完成。
9. OOM killer
机器上的内存可能会被耗尽,并且内核将无法回收足够的内存用于运行新的程序,为了保存系统的其余部分,内核会调用OOM killer杀掉一些进程,以释放内存。
段页机制
段页机制是操作系统管理内存的一种方式,简单的来说,就是如何管理、组织系统中的内存。要理解这种机制,需要了解一下内存寻址的发展历程。
直接寻址:早期的内存很小,通过硬编码的形式,直接定位到内存地址。这种方式有着明显的缺点:可控性弱、难以重定位、难以维护
分段机制:8086处理器,寻址空间达到1MB,即地址线扩展了20位,由于制作20位的寄存器较为困难,为了能在16位的寄存器的基础上,寻址20位的地址空间,引入了段的概念,即内存地址=段基址左移4位+偏移
分页机制:随着寻址空间的进一步扩大、虚拟内存技术的引入,操作系统引入了分页机制。引入分页机制后,逻辑地址经过段机制转换得到的地址仅是中间地址,还需要通过页机制转换,才能得到实际的物理地址。逻辑地址 -->(分段机制) 线性地址 -->(分页机制) 物理地址。
1.50 谈谈内存映射文件。
参考回答
内存映射(mmap) 是一种内存映射文件的方法,即将一个文件或者其他对象映射到进程的地址空间,实现文件磁盘地址和应用程序进程虚拟地址空间中一段虚拟地址的一一映射关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写藏页面到对应的文件磁盘上。应用程序处理映射部分如同访问主存。
mmap内存映射原理
(1)线程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域。
先在用户空间调用库函数mmap,并在进程当前进程的虚拟地址空间中,寻找一段空闲的满足要求的连续虚拟地址作为内存虚拟映射区域,对此区域初始化并插入进程的虚拟地址区域链表或树中。
(2)系统在内核空间调用内核函数mmap,实现文件物理地址和进程虚拟地址之间的一一映射关系。
(3)进程发起堆这片映射空间的访问
进程读写操作访问虚拟地址,查询页表,发现这一段地址并不在内存的物理页面上,因为虽然建立了映射关系,但是还没有将文件从磁盘移到内存中。由此发生缺页中断,内核请求从磁盘调入页面。调页过程先在交换缓存空间(swap cache)中查找,若没有则通过nopage函数把缺失页从磁盘调入内存。之后进程会对其做读写操作,若写操作改变了页面内容,一段时间后系统会自动回写脏页面到磁盘中。(修改过的脏页面不会立即更新到文件中,可以调用msync来强制同步,写入文件)
mmap和分页文件操作的区别
区别在于分页文件操作在进程访存时是需要先查询页面缓存 (page cache) 的,若发生缺页中断,需要通过inode定位文件磁盘地址,先把缺失文件复制到page cache,再从page cache复制到内存中,才能进行访问。这样访存需要经过两次文件复制,写操作也是一样。总结来说,常规文件操作为了提高读写效率和保护磁盘,使用了页缓存机制。这样造成读文件时需要先将文件页从磁盘拷贝到页缓存中,由于页缓存处在内核空间,不能被用户进程直接寻址,所以还需要将页缓存中数据页再次拷贝到内存对应的用户空间中。但mmap的优势在于,把磁盘文件与进程虚拟地址做了映射,这样可以跳过page cache,只使用一次数据拷贝。
1.51 谈谈虚拟内存模型。
参考回答
虚拟内存分成五大区,分别为栈区、堆区、全局区(静态区)、文字常量区(常量存储区)、程序代码区。五大区特性如下:
栈区(stack): 由编译器自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
堆区(heap): 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收 。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表。
全局区(静态区)(static):全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域, 未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。程序结束后由系统释放。
文字常量区(常量存储区) :常量字符串就是放在这里的。 程序结束后由系统释放。这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改。
程序代码区:存放函数体的二进制代码。
答案解析
以32位的操作系统为例,32位的操作系统每个进程对应的虚拟内存为4G(2的32次方),其中内核区1G,用户区3G。结构图如下:
1.52 什么是物理内存和虚拟内存,为什么要有虚拟内存?
参考回答
物理内存及虚拟内存定义 物理内存是相对于虚拟内存而言的。物理内存指通过物理内存条而获得的内存空间,而虚拟内存则是指将硬盘的一块区域划分来作为内存。内存主要作用是在计算机运行时为操作系统和各种程序提供临时储存。
为什么要有虚拟内存 在早期的计算机中,要运行一个程序,会把这些程序全都装入内存,程序都是直接运行在内存上的,也就是说程序中访问的内存地址都是实际的物理内存地址。当计算机同时运行多个程序时,必须保证这些程序用到的内存总量要小于计算机实际物理内存的大小。 早期内存分配方法实例: 某台计算机总的内存大小是 128M ,现在同时运行两个程序 A 和 B , A 需占用内存 10M , B 需占用内存 110 。计算机在给程序分配内存时会采取这样的方法:先将内存中的前 10M 分配给程序 A ,接着再从内存中剩余的 118M 中划分出 110M 分配给程序 B 。这种分配方法可以保证程序 A 和程序 B 都能运行,但是这种简单的内存分配策略问题很多。如下图: 早期内存分配方法 早期的内存分配方法存在如下几个问题(为什么要有虚拟内存的原因): 问题 1 :进程地址空间不隔离。由于程序都是直接访问物理内存,所以恶意程序可以随意修改别的进程的内存数据,以达到破坏的目的。有些非恶意的,但是有 bug 的程序也可能不小心修改了其它程序的内存数据,就会导致其它程序的运行出现异常。这种情况对用户来说是无法容忍的,因为用户希望使用计算机的时候,其中一个任务失败了,至少不能影响其它的任务。 问题 2 :内存使用效率低。在 A 和 B 都运行的情况下,如果用户又运行了程序 C,而程序 C 需要 20M 大小的内存才能运行,而此时系统只剩下 8M 的空间可供使用,所以此时系统必须在已运行的程序中选择一个将该程序的数据暂时拷贝到硬盘上,释放出部分空间来供程序 C 使用,然后再将程序 C 的数据全部装入内存中运行。可以想象得到,在这个过程中,有大量的数据在装入装出,导致效率十分低下。 问题 3 :程序运行的地址不确定。当内存中的剩余空间可以满足程序 C 的要求后,操作系统会在剩余空间中随机分配一段连续的 20M 大小的空间给程序 C 使用,因为是随机分配的,所以程序运行的地址是不确定的。
虚拟内存的实现(可以在页式或段式内存管理的基础上实现) (1)在装入程序时,不必将其全部装入到内存,而只需将当前要执行的部分页面或段装入到内存,就可让程序开始执行; (2)在程序执行过程中,如果需执行的指令或访的数据尚未在内存(称为缺页或缺段),则由处理器通知操作系线将相应的页面或段调入到内存,然后继续执订程序; (3)另一方面,操作系统将内存中暂时不用的页面或段调出保存在外存上,从而腾出更多空困空间存放将要装入的程字以及将要调入的页画或段。 虚拟技术基本特征:大的用户空间(物理内存和外存相结合形成虚拟空间)、部分交换(调入和调出是对部分虚拟地址空间进行的)、不连续性(物理内存分配的不连续,虚拟地址空间使用的不连续)。
答案解析
分段 为了解决早期内存分配方式带来的问题,人们想到了一种变通的方法,就是增加一个中间层,利用一种间接的地址访问方法访问物理内存。按照这种方法,程序中访问的内存地址不再是实际的物理内存地址,而是一个虚拟地址,然后由操作系统将这个虚拟地址映射到适当的物理内存地址上。这样,只要操作系统处理好虚拟地址到物理内存地址的映射,就可以保证不同的程序最终访问的内存地址位于不同的区域,彼此没有重叠,就可以达到内存地址空间隔离的效果。 当创建一个进程时,操作系统会为该进程分配一个 4GB 大小的虚拟进程地址空间。之所以是 4GB ,是因为在 32 位的操作系统中,一个指针长度是 4 字节,而 4 字节指针的寻址能力是从 0x00000000~0xFFFFFFFF,最大值 0xFFFFFFFF 表示的即为 4GB 大小的容量。与虚拟地址空间相对的,还有一个物理地址空间,这个地址空间对应的是真实的物理内存。如果你的计算机上安装了 512M 大小的内存,那么这个物理地址空间表示的范围是 0x00000000~0x1FFFFFFF 。当操作系统做虚拟地址到物理地址映射时,只能映射到这一范围,操作系统也只会映射到这一范围。当进程创建时,每个进程都会有一个自己的 4GB 虚拟地址空间。要注意的是这个 4GB 的地址空间是“虚拟”的,并不是真实存在的,而且每个进程只能访问自己虚拟地址空间中的数据,无法访问别的进程中的数据,通过这种方法实现了进程间的地址隔离。那是不是这 4GB 的虚拟地址空间应用程序可以随意使用呢?很遗憾,在 Windows 系统下,这个虚拟地址空间被分成了 4 部分: NULL 指针区、用户区、 64KB 禁入区、内核区。 (1)NULL指针区 (0x00000000~0x0000FFFF): 如果进程中的一个线程试图操作这个分区中的数据,CPU就会引发非法访问。他的作用是,调用 malloc 等内存分配函数时,如果无法找到足够的内存空间,它将返回 NULL。而不进行安全性检查。它只是假设地址分配成功,并开始访问内存地址 0x00000000(NULL)。由于禁止访问内存的这个分区,因此会发生非法访问现象,并终止这个进程的运行。 (2)用户模式分区 ( 0x00010000~0xBFFEFFFF):这个分区中存放进程的私有地址空间。一个进程无法以任何方式访问另外一个进程驻留在这个分区中的数据 (相同 exe,通过 copy-on-write 来完成地址隔离)。(在windows中,所有 .exe 和动态链接库都载入到这一区域。系统同时会把该进程可以访问的所有内存映射文件映射到这一分区)。 (3)隔离区 (0xBFFF0000~0xBFFFFFFF):这个分区禁止进入。任何试图访问这个内存分区的操作都是违规的。微软保留这块分区的目的是为了简化操作系统的现实。 (4)内核区 (0xC0000000~0xFFFFFFFF):这个分区存放操作系统驻留的代码。线程调度、内存管理、文件系统支持、网络支持和所有设备驱动程序代码都在这个分区加载。这个分区被所有进程共享。 应用程序能使用的只是用户区而已,大约 2GB 左右 ( 最大可以调整到 3GB) 。内核区为 2GB ,内核区保存的是系统线程调度、内存管理、设备驱动等数据,这部分数据供所有的进程共享,但应用程序是不能直接访问的。 人们之所以要创建一个虚拟地址空间,目的是为了解决进程地址空间隔离的问题。但程序要想执行,必须运行在真实的内存上,所以,必须在虚拟地址与物理地址间建立一种映射关系。这样,通过映射机制,当程序访问虚拟地址空间上的某个地址值时,就相当于访问了物理地址空间中的另一个值。人们想到了一种分段(Sagmentation) 的方法,它的思想是在虚拟地址空间和物理地址空间之间做一一映射。比如说虚拟地址空间中某个 10M 大小的空间映射到物理地址空间中某个 10M 大小的空间。这种思想理解起来并不难,操作系统保证不同进程的地址空间被映射到物理地址空间中不同的区域上,这样每个进程最终访问到的。 物理地址空间都是彼此分开的。通过这种方式,就实现了进程间的地址隔离。还是以实例说明,假设有两个进程 A 和 B ,进程 A 所需内存大小为 10M ,其虚拟地址空间分布在 0x00000000 到 0x00A00000 ,进程 B 所需内存为 100M ,其虚拟地址空间分布为 0x00000000 到 0x06400000 。那么按照分段的映射方法,进程 A 在物理内存上映射区域为 0x00100000 到 0x00B00000 ,,进程 B 在物理内存上映射区域为0x00C00000 到 0x07000000 。于是进程 A 和进程 B 分别被映射到了不同的内存区间,彼此互不重叠,实现了地址隔离。从应用程序的角度看来,进程 A 的地址空间就是分布在 0x00000000 到 0x00A00000 ,在做开发时,开发人员只需访问这段区间上的地址即可。应用程序并不关心进程 A 究竟被映射到物理内存的那块区域上了,所以程序的运行地址也就是相当于说是确定的了。 下图显示的是分段方式的内存映射方法:
分段方式的内存映射方法
这种分段的映射方法虽然解决了上述中的问题1和问题3,但并没能解决问题2,即内存的使用效率问题。在分段的映射方法中,每次换入换出内存的都是整个程序, 这样会造成大量的磁盘访问操作,导致效率低下。所以这种映射方法还是稍显粗糙,粒度比较大。实际上,程序的运行有局部性特点,在某个时间段内,程序只是访问程序的一小部分数据,也就是说,程序的大部分数据在一个时间段内都不会被用到。基于这种情况,人们想到了粒度更小的内存分割和映射方法,这种方法就是分页 (Paging) 。
分页 分页的基本方法是,将地址空间分成许多的页。每页的大小由 CPU 决定,然后由操作系统选择页的大小。目前 Inter 系列的 CPU 支持 4KB 或 4MB 的页大小,而 PC上目前都选择使用 4KB 。按这种选择, 4GB 虚拟地址空间共可以分成 1048576 页, 512M 的物理内存可以分为 131072 个页。显然虚拟空间的页数要比物理空间的页数多得多。 在分段的方法中,每次程序运行时总是把程序全部装入内存,而分页的方法则有所不同。分页的思想是程序运行时用到哪页就为哪页分配内存,没用到的页暂时保留在硬盘上。当用到这些页时再在物理地址空间中为这些页分配内存,然后建立虚拟地址空间中的页和刚分配的物理内存页间的映射。下面通过介绍一个可执行文件的装载过程来说明分页机制的实现方法。 一个可执行文件 (PE 文件 ) 其实就是一些编译链接好的数据和指令的集合,它也会被分成很多页,在 PE 文件执行的过程中,它往内存中装载的单位就是页。当一个 PE 文件被执行时,操作系统会先为该程序创建一个 4GB 的进程虚拟地址空间。前面介绍过,虚拟地址空间只是一个中间层而已,它的功能是利用一种映射机制将虚拟地址空间映射到物理地址空间,所以,创建 4GB 虚拟地址空间其实并不是要真的创建空间,只是要创建那种映射机制所需要的数据结构而已,这种数据结构就是页目和页表。 当创建完虚拟地址空间所需要的数据结构后,进程开始读取 PE 文件的第一页。在PE 文件的第一页包含了 PE 文件头和段表等信息,进程根据文件头和段表等信息,将 PE 文件中所有的段一一映射到虚拟地址空间中相应的页 (PE 文件中的段的长度都是页长的整数倍 ) 。这时 PE 文件的真正指令和数据还没有被装入内存中,操作系统只是据 PE 文件的头部等信息建立了 PE 文件和进程虚拟地址空间中页的映射关系而已。当 CPU 要访问程序中用到的某个虚拟地址时,当 CPU 发现该地址并没有相相关联的物理地址时, CPU 认为该虚拟地址所在的页面是个空页面, CPU 会认为这是个页错误 (Page Fault) , CPU 也就知道了操作系统还未给该 PE 页面分配内存,CPU 会将控制权交还给操作系统。操作系统于是为该 PE 页面在物理空间中分配一个页面,然后再将这个物理页面与虚拟空间中的虚拟页面映射起来,然后将控制权再还给进程,进程从刚才发生页错误的位置重新开始执行。由于此时已为 PE 文件的那个页面分配了内存,所以就不会发生页错误了。随着程序的执行,页错误会不断地产生,操作系统也会为进程分配相应的物理页面来满足进程执行的需求。 分页方法的核心思想就是当可执行文件执行到第 x 页时,就为第 x 页分配一个内存页 y ,然后再将这个内存页添加到进程虚拟地址空间的映射表中 , 这个映射表就相当于一个 y=f(x) 函数。应用程序通过这个映射表就可以访问到 x 页关联的 y 页了。
1.53 内存和缓存有什么区别?
参考回答
内存和缓存是计算机不同的组成部件。
内存特性 内存也被称作内存储器,其作用是用于暂时存放CPU的运算数据,以及与硬盘等外部存储交换的数据。只要计算机在运行中,CPU就会把需要进行运算的数据调到内存中进行运算,当运算完成后CPU再将结果传送出来,内存的运行也决定了计算机的稳定运行。
缓存特性 CPU芯片面积和成本的因素影响,决定了缓存都很小。现在一般的缓存不过几M,CPU缓存的运行频率极高,一般是和处理器同频运作,工作效率远远大于系统内存和硬盘。实际工作时,CPU往往需要重复读取读取同样的数据块,而缓存容量的增大,可以大幅度提升CPU内部读取数据的命中率,而不用再到内存或者硬盘上寻找,以此提高系统性能。
1.54 请你说说缓存溢出。
参考回答
缓存溢出及其危害
缓存溢出是指输入到一个缓冲区或者数据保存区域的数据量超过了其容量,从而导致覆盖了其它区域数据的状况。攻击者造成并利用这种状况使系统崩溃或者通过插入特制的代码来控制系统。被覆盖的区域可能存有其它程序的变量、参数、类似于返回地址或指向前一个栈帧的指针等程序控制流数据。缓冲区可以位于堆、栈或进程的数据段。这种错误可能产生如下后果:
(1)破坏程序的数据;
(2)改变程序的控制流,因此可能访问特权代码。
最终很有可能造成程序终止。当攻击者成功地攻击了一个系统之后,作为攻击的一部分,程序的控制流可能会跳转到攻击者选择的代码处,造成的结果是被攻击的进程可以执行任意的特权代码(比如通过判断输入是否和密码匹配来访问特权代码,如果存在缓冲区漏洞,非法输入导致存放“密码”的内存区被覆盖,从而使得“密码”被改写,因此判断为匹配进而获得了特权代码的访问权)
缓冲区溢出攻击是最普遍和最具危害性的计算机安全攻击类型之一。
如何预防缓存溢出 广义上分为两类: (1)编译时防御系统,目的是强化系统以抵御潜伏于新程序中的恶意攻击 (2)运行时预防系统,目的是检测并终止现有程序中的恶意攻击
1.55 深拷贝和浅拷贝的区别是什么,它们各自的使用场景是什么?
参考回答
浅拷贝只是对指针的拷贝,拷贝后两个指针指向同一个内存空间;深拷贝不断对指针进行拷贝,而且对指针指向的内容进行拷贝,经深拷贝后的指针是指向两个不同的地址空间。
浅拷贝 对一个已知对象进行拷贝时,编译系统会自动调用一次构造函数(拷贝构造函数),如果用户未定义拷贝构造函数,则会调用默认拷贝构造函数,调用一次构造函数,调用两次析构函数,两个对象的指针成员所指内存相同,但是程序结束时该内存被释放了两次,会造成内存泄漏问题。
深拷贝 在对含有指针成员的对象进行拷贝时,必须要自己定义拷贝构造函数,使拷贝后的对象指针成员有自己的内存空间,即进行深拷贝,这样就避免了内存泄漏的发生,调用一次构造函数,一次自定义拷贝构造函数,两次析构函数。两个对象的指针成员所指内容不同。
1.56 说说IO模型。
参考回答
什么是IO 我们都知道unix世界里,一切皆文件。而文件是什么呢?文件就是一串二进制流而已。无论是socket,还是FIFO、管道、终端,对我们来说,一切都是文件,一切都是流。在信息交换的过程中,我们都是对这些流进行数据的收发操作简称为I/O操作(input and output)。往流中读出数据,系统调用read;写入数据,系统调用write。 计算机里有这么多的流,我怎么知道要操作哪个流呢? 做到这个的就是文件描述符,即通常所说的fd,一个fd就是一个整数,所以对这个整数的操作就是对这个文件(流)的操作。我们创建一个socket,通过系统调用会返回一个文件描述符,那么剩下对socket的操作就会转化为对这个描述符的操作。不能不说这又是一种分层和抽象的思想。
IO交互 对于一个输入操作来说,进程IO系统调用后,内核会先看缓冲区有没有相应的缓存数据,没有的话再到设备中读取,因为设备IO一般速度较慢,需要等待,内核缓冲区有数据则直接复制到进程空间。所以,对于一个网络输入操作通常包括两个不同阶段: (1)等待网络数据到达网卡->读取到内核缓冲区 (2)从内核缓冲区复制数据->用户空间 IO有内存IO、网络IO和磁盘IO三种,通常我们所说的IO指的是网络IO和磁盘IO两者。
五大I/O模型 Linux有五大I/O模型,分别为阻塞IO、同步非阻塞IO、IO多路复用、信号驱动IO、异步IO。五种IO模型特性分别如下: (1)阻塞IO(blocking IO) 最传统的一种IO模型,即在读写数据过程中会发生阻塞现象。 当用户线程发出IO请求之后,内核会去查看数据是否就绪,如果没有就绪就会等待数据就绪,而用户线程就会处于阻塞状态,用户线程交出CPU。当数据就绪之后,内核会将数据拷贝到用户线程,并返回结果给用户线程,用户线程才解除block状态。 典型的阻塞IO模型的例子为: data = socket.read(); 如果数据没有就绪,就会一直阻塞在read方法。
阻塞I/O模型
(2)同步非阻塞IO(nonblocking IO)
当用户线程发起一个read操作后,并不需要等待,而是马上就得到了一个结果。如果结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦内核中的数据准备好了,并且又再次收到了用户线程的请求,那么它马上就将数据拷贝到了用户线程,然后返回。
所以事实上,在非阻塞IO模型中,用户线程需要不断地询问内核数据是否就绪,也就说非阻塞IO不会交出CPU,而会一直占用CPU。
典型的非阻塞IO模型一般如下: while(true){ data = socket.read(); if(data!= error){ 处理数据 break; } }
但是对于非阻塞IO就有一个非常严重的问题,在while循环中需要不断地去询问内核数据是否就绪,这样会导致CPU占用率非常高,因此一般情况下很少使用while循环这种方式来读取数据。
同步非阻塞I/O模型
(3)IO多路复用(IO multiplexing)
多路复用IO模型是目前使用得比较多的模型。Java NIO实际上就是多路复用IO。
在多路复用IO模型中,会有一个线程不断去轮询多个socket的状态,只有当socket真正有读写事件时,才真正调用实际的IO读写操作。因为在多路复用IO模型中,只需要使用一个线程就可以管理多个socket,系统不需要建立新的进程或者线程,也不必维护这些线 程和 进程,并且只有在真正有socket读写事件进行时,才会使用IO资源,所以它大大减少了资源占用。
在Java NIO中,是通过selector.select()去查询每个通道是否有到达事件,如果没有事件,则一直阻塞在那里,因此这种方式会导致用户线程的阻塞。
也许有朋友会说,我可以采用多线程+ 阻塞IO 达到类似的效果,但是由于在多线程 + 阻塞IO 中,每个socket对应一个线程,这样会造成很大的资源占用,并且尤其是对于长连接来说,线程的资源一直不会释放,如果后面陆续有很多连接的话,就会造成性能上的瓶颈。
而多路复用IO模式,通过一个线程就可以管理多个socket,只有当socket真正有读写事件发生才会占用资源来进行实际的读写操作。因此,多路复用IO比较适合连接数比较多的情况。
另外多路复用IO为何比非阻塞IO模型的效率高是因为在非阻塞IO中,不断地询问socket状态时通过用户线程去进行的,而在多路复用IO中,轮询每个socket状态是内核在进行的,这个效率要比用户线程要高的多。
注意:多路复用IO模型是通过轮询的方式来检测是否有事件到达,并且对到达的事件逐一进行响应。因此对于多路复用IO模型来说,一旦事件响应体很大,那么就会导致后续的事件迟迟得不到处理,并且会影响新的事件轮询。
I/O多路复用模型
(4)信号驱动IO(signal driven IO)
在信号驱动IO模型中,当用户线程发起一个IO请求操作,会给对应的socket注册一个信号函数,然后用户线程会继续执行,当内核数据就绪时会发送一个信号给用户线程,用户线程接收到信号之后,便在信号函数中调用IO读写操作来进行实际的IO请求操作。这个一般用于UDP中,对TCP套接口几乎是没用的,原因是该信号产生得过于频繁,并且该信号的出现并没有说明发生了什么事情。
信号驱动I/O模型
(5)异步IO(asynchronous IO)
异步IO模型才是最理想的IO模型,在异步IO模型中,当用户线程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从内核的角度,当它收到一个asynchronous read之后,它会立刻返回,说明read请求已经成功发起了,因此不会对用户线程产生任何阻塞。然后,内核会等待数据准备完成,再将数据拷贝到用户线程,当这一切都完成之后,内核会给用户线程发送一个信号,告诉它read操作完成了。也就说用户线程完全不需要关心实际的整个IO操作是如何进行的,只需要先发起一个请求,当接收内核返回的成功信号时表示IO操作已经完成,可以直接去使用数据了。
也就说在异步IO模型中,IO操作的两个阶段都不会阻塞用户线程,这两个阶段都是由内核自动完成,然后发送一个信号告知用户线程操作已完成。用户线程中不需要再次调用IO函数进行具体的读写。这点是和信号驱动模型有所不同的,在信号驱动模型中,当用户线程接收到信号表示数据已经就绪,然后需要用户线程调用IO函数进行实际的读写操作;而在异步IO模型中,收到信号表示IO操作已经完成,不需要再在用户线程中调用IO函数进行实际的读写操作。
注意:异步IO是需要操作系统的底层支持,在Java 7中,提供了Asynchronous IO(简称AIO)。
异步I/O模型
前四种IO模型实际上都属于同步IO,只有最后一种是真正的异步IO,因为无论是多路复用IO还是信号驱动模型,IO操作的第2个阶段都会引起用户线程阻塞,也就是内核进行数据拷贝的过程都会让用户线程阻塞。
1.57 Linux中的软链接和硬链接有什么区别?
参考回答
inode概念 inode是文件系统中存储文件元信息的区域,中文叫节点索引,每个节点索引包含了文件的创建者,大小,日期等等。可以通过ls -i file 命令查看inode的值。
根据 Linux 系统存储文件的特点,链接的方式分为软链接和硬链接2 种 软链接相当于建立了一个新的快捷方式文件,该文件有自己的名称和inode以及物理存储的文件数据,文件数据里记录着如何跳转的设置数据,访问该快捷文件会被重新定向到原始文件,删除原始文件,软链文件失效;硬链接相当于为当前文件名对应的文件再建立了一个文件别名,别名对应的inode以及物理数据都是一样的,一旦建立,我们甚至根本无法区分谁是原始文件的原始名称,删除文件的其中一个名称,文件不会丢失,除非把所有的名称都删除。 如下图: hard link(硬链) 和file 都指向同一个 inode,inode对应了一个实际物理存储的文件。soft link(软链) 对应一个新的inode, 新的inode对应一个新的物理存储文件,物理存储文件又指向了目标文件 file。
软连接和硬链接的区别 (1) 软链接可以为文件和目录(哪怕是不存在的)创建链接;硬链接只能为文件创建链接。 (2) 软链接可以跨文件系统;硬链接必须是同一个文件系统 (3) 硬链接因为只是文件的一个别名,所以不重复占用内存;软链接因为只是一个访问文件的快捷方式文件,文件内只包含快捷指向信息,所以占用很小的内存。 (4) 软链接的文件权限和源文件可以不一样;硬链接文件权限肯定是一样的,因为他们本来就是一个文件的不同名称而已。
二者使用场景 一般比较重要的文件我们担心文件被误删除且传统复制备份方式占用double数量的空间会造成浪费,可以使用硬链做备份来解决;软链接一般被用来设置可执行文件的快捷方式的路径。
答案解析
创建硬链接 [root@localhost ~]# touch cangls [root@localhost ~]# ln /root/cangls /tmp #建立硬链接文件,目标文件没有写文件名,会和原名一致 #也就是/tmp/cangls 是硬链接文件
创建软链接 [root@localhost ~]# touch bols [root@localhost ~]# In -s /root/bols /tmp #建立软链接文件 注意:软链接文件的源文件必须写成绝对路径,而不能写成相对路径(硬链接没有这样的要求);否则软链接文件会报错。
1.58 说说缺页中断机制。
参考回答
缺页中断 在请求分页系统中,可以通过查询页表中的状态位来确定所要访问的页面是否存在于内存中。当所要访问的页面不在内存时,会产生一次缺页中断,此时操作系统会根据页表中的外存地址在外存中找到所缺的一页,将其调入内存。 缺页中断的处理流程如下: A. 在内存中有空闲物理页面时,分配一物理页帧f,转第E步; B. 依据页面置换算法选择被替换的物理页帧f,对应逻辑页q; C. 如果q被修改过,则把它写回外存; D. 修改q的页表项中驻留位置为0; E. 将需要访问的页p装入到物理页f; F. 修改p的页表项驻留位为1,物理页帧号为f; G. 重新执行产生缺页的指令。
缺页中断与一般的中断存在区别 (1)范围不同
一般中断只需要保护现场然后就直接跳到需及时处理的地方;
缺页中断除了保护现场之外,还要判断内存中是否有足够的空间存储所需的页或段,然后再把所需页调进来再使用。
(2)结果不同
一般中断在处理完之后返回时,执行下一条指令;
缺页中断返回时,执行产生中断的那一条指令。
(3)次数不同
一般中断只产生一次,发生中断指令后转入相应处理程序进行处理,恢复被中断程序现场;
在指令执行期间产生和处理缺页中断信号,一条指令在执行期间,可能产生多次缺页中断。
答案解析
产生缺页中断的几种情况:
当内存管理单元(MMU)中确实没有创建虚拟物理页映射关系,并且在该虚拟地址之后再没有当前进程的线性区(vma)的时候,这将杀掉该进程;
当MMU中确实没有创建虚拟页物理页映射关系,并且在该虚拟地址之后存在当前进程的线性区vma的时候,这很可能是缺页中断,并且可能是栈溢出导致的缺页中断;
当使用malloc/mmap等希望访问物理空间的库函数/系统调用后,由于linux并未真正给新创建的vma映射物理页,此时若先进行写操作,将和2产生缺页中断的情况一样;若先进行读操作虽然也会产生缺页异常,将被映射给默认的零页,等再进行写操作时,仍会产生缺页中断,这次必须分配1物理页了,进入写时复制的流程;
当使用fork等系统调用创建子进程时,子进程不论有无自己的vma,它的vma都有对于物理页的映射,但它们共同映射的这些物理页属性为只读,即linux并未给子进程真正分配物理页,当父子进程任何一方要写相应物理页时,导致缺页中断的写时复制。
1.59 软中断和硬中断有什么区别?
参考回答
硬中断 由与系统相连的外设(比如网卡、硬盘)自动产生的。主要是用来通知操作系统外设状态的变化。比如当网卡收到数据包的时候,就会发出一个中断。我们通常所说的中断指的是硬中断(hardirq)。
软终端 为了满足实时系统的要求,中断处理应该是越快越好。Linux为了实现这个特点,当中断发生的时候,硬中断处理那些短时间就可以完成的工作,而将那些处理事件比较长的工作,放到中断之后来完成,也就是软中断(softirq)来完成。
中断嵌套 Linux下硬中断是可以嵌套的,但是没有优先级的概念,也就是说任何一个新的中断都可以打断正在执行的中断,但同种中断除外。软中断不能嵌套,但相同类型的软中断可以在不同CPU上并行执行。
软中断与硬中断之间的区别 (1)硬中断是由外部事件引起的因此具有随机性和突发性;软中断是执行中断指令产生的,无外面事件中断请求信号,因此软中断的发生不是随机的而是由程序安排好的; (2)硬中断的中断号是由中断控制器提供的;软中断的中断号是由指令直接给出的,无需使用中断控制器。 (3)硬中断的中断响应周期,CPU需要发中断回合信号;软中断的中断响应周期,CPU不需要发中断回合信号。 (4)硬中断是可屏蔽的;软中断是不可屏蔽的。
1.60 介绍一下你对CopyOnWrite的了解。
参考回答
CopyOnWrite(写时拷贝技术) Linux的fork()使用写时拷贝页来实现新进程的创建,它是一种可推迟甚至避免数据拷贝的技术,开始时内核并不会复制整个地址空间,而是让父子进程共享地址空间,只有在写时才复制地址空间,使得父子进程都拥有独立的地址空间,即资源的复制是在只有需要写入时才会发生。在此之前都是以读的方式去和父进程共享资源,这样,在页根本不会被写入的场景下,fork()立即执行exec(),无需对地址空间进行复制,fork()的实际开销就是复制父进程的一个页表和为子进程创建一个进程描述符,也就是说只有当进程空间中各段的内存内容发生变化时,父进程才将其内容复制一份传给子进程,大大提高了效率。
写时拷贝技术优缺点 写时拷贝技术是一种很重要的优化手段,核心是懒惰处理实体资源请求,在多个实体资源之间只是共享资源,起初是并不真正实现资源拷贝,只有当实体有需要对资源进行修改时才真正为实体分配私有资源。但写时拷贝技术技术也有它的优点和缺点: 优点:写时拷贝技术可以减少分配和复制大量资源时带来的瞬间延时,但实际上是将这种延时附加到了后续的操作之中。 缺点:写时拷贝技术可以减少不必要的资源分配。比如fork进程时,并不是所有的页面都需要复制,父进程的代码段和只读数据段都不被允许修改,所以无需复制。
答案解析
写时复制技术详述:
现在有一个父进程P1,这是一个主体,那么它是有灵魂也就身体的。现在在其虚拟地址空间(有相应的数据结构表示)上有:正文段,数据段,堆,栈这四个部分,相应的,内核要为这四个部分分配各自的物理块。即:正文段块,数据段块,堆块,栈块。至于如何分配,这是内核去做的事,在此不详述。
现在P1用fork()函数为进程创建一个子进程P2, 内核: (1)复制P1的正文段,数据段,堆,栈这四个部分,注意是其内容相同。 (2)为这四个部分分配物理块,P2的:正文段->PI的正文段的物理块,其实就是不为P2分配正文段块,让P2的正文段指向P1的正文段块,数据段->P2自己的数据段块(为其分配对应的块),堆->P2自己的堆块,栈->P2自己的栈块。如下图所示:从左到右大的方向箭头表示复制内容。
写时复制技术:内核只为新生成的子进程创建虚拟空间结构,它们来复制于父进程的虚拟究竟结构,但是不为这些段分配物理内存,它们共享父进程的物理空间,当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间。
vfork():这个做法更加火爆,内核连子进程的虚拟地址空间结构也不创建了,直接共享了父进程的虚拟空间,当然了,这种做法就顺 水推舟的共享了父进程的物理空间!
通过以上的分析,相信大家对进程有个深入的认识,它是怎么一层层体现出自己来的,进程是一个主体,那么它就有灵魂与身体,系统必须为实现它创建相应的实体, 灵魂实体与物理实体。这两者在系统中都有相应的数据结构表示,物理实体更是体现了它的物理意义。以下援引LKD
传统的fork()系统调用直接把所有的资源复制给新创建的进程。这种实现过于简单并且效率低下,因为它拷贝的数据也许并不共享,更糟的情况是,如果新进程打算立即执行一个新的映像,那么所有的拷贝都将前功尽弃。Linux的fork()使用写时拷贝(copy-on-write)页实现。写时拷贝是一种可以推迟甚至免除拷贝数据的技术。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝。只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。也就是说,资源的复制只有在需要写入的时候才进行,在此之前,只是以只读方式共享。这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候。在页根本不会被写入的情况下—举例来说,fork()后立即调用exec()—它们就无需复制了。fork()的实际开销就是复制父进程的页表以及给子进程创建惟一的进程描述符。在一般情况下,进程创建后都会马上运行一个可执行的文件,这种优化可以避免拷贝大量根本就不会被使用的数据(地址空间里常常包含数十兆的数据)。由于Unix强调进程快速执行的能力,所以这个优化是很重要的。这里补充一点:Linux COW与exec没有必然联系。
实际上COW技术不仅仅在Linux进程上有应用,其他例如C++的String在有的IDE环境下也支持COW技术,即例如: string str1 = "hello world"; string str2 = str1;
之后执行代码: str1[1]='q'; str2[1]='w';
在开始的两个语句后,str1和str2存放数据的地址是一样的,而在修改内容后,str1的地址发生了变化,而str2的地址还是原来的,这就是C++中的COW技术的应用,不过VS2005似乎已经不支持COW。
1.61 Linux替换文本该如何操作呢?
参考回答
通过vi编辑器来替换 vi/vim 中可以使用 :s 命令来替换字符串。 :s/well/good/ 替换当前行第一个 well 为 good :s/well/good/g 替换当前行所有 well 为 good :n,$s/well/good/ 替换第 n 行开始到最后一行中每一行的第一个 well 为 good :n,$s/well/good/g 替换第 n 行开始到最后一行中每一行所有 well 为 good n 为数字,若 n 为 .,表示从当前行开始到最后一行 :%s/well/good/(等同于 :g/well/s//good/) 替换每一行的第一个 well 为 good :%s/well/good/g(等同于 :g/well/s//good/g) 替换每一行中所有 well 为 good,可以使用 # 作为分隔符,此时中间出现的 / 不会作为分隔符 :s#well/#good/# 替换当前行第一个 well/ 为 good/ :%s#/usr/bin#/bin#g 可以把文件中所有路径/usr/bin换成/bin
直接替换文件中的字符串。(此法不用打开文件即可替换字符串,而且可以批量替换多个文件。) (1)perl命令替换,参数含义如下: -a 自动分隔模式,用空格分隔$_并保存到@F中。相当于@F = split ”。分隔符可以使用-F参数指定 -F 指定-a的分隔符,可以使用正则表达式 -e 执行指定的脚本。 -i<扩展名> 原地替换文件,并将旧文件用指定的扩展名备份。不指定扩展名则不备份。 -l 对输入内容自动chomp,对输出内容自动添加换行 -n 自动循环,相当于 while(<>) { 脚本; } -p 自动循环+自动输出,相当于 while(<>) { 脚本; print; } 用法示例: perl -p -i.bak -e 's/\bfoo\b/bar/g' *.c 将所有C程序中的foo替换成bar,旧文件备份成.bak perl -p -i -e "s/shan/hua/g" ./lishan.txt ./lishan.txt.bak 将当前文件夹下lishan.txt和lishan.txt.bak中的“shan”都替换为“hua” perl -i.bak -pe 's/(\d+)/ 1 + $1 /ge' file1 file2 将每个文件中出现的数值都加一 (2)sed命令下批量替换文件内容 格式: sed -i "s/查找字段/替换字段/g" grep 查找字段 -rl 路径 文件名 -i 表示inplace edit,就地修改文件 -r 表示搜索子目录 -l 表示输出匹配的文件名 s表示替换,d表示删除 用法示例: sed -i "s/shan/hua/g" lishan.txt 把当前目录下lishan.txt里的shan都替换为hua
答案解析
sed的其他用法如下:
删除行首空格 sed 's/^[ ]*//g' filename sed 's/^ //g' filename sed 's/^[[:space:]]//g' filename
行后和行前添加新行 行后:sed 's/pattern/&\n/g' filename 行前:sed 's/pattern/\n&/g' filename &代表pattern
使用变量替换(使用双引号) sed -e "s/$var1/$var2/g" filename
在第一行前插入文本 sed -i '1 i\插入字符串' filename
在最后一行插入 sed -i '$ a\插入字符串' filename
在匹配行前插入 sed -i '/pattern/ i "插入字符串"' filename
在匹配行后插入 sed -i '/pattern/ a "插入字符串"' filename
删除文本中空行和空格组成的行以及#号注释的行 grep -v ^# filename | sed /^[[:space:]]*$/d | sed /^$/d
9.1哈希
请谈一谈,hashCode() 和equals() 方法的重要性体现在什么地方?
考察点:JAVA哈希表
参考回答:
Java中的HashMap使用hashCode()和equals()方法来确定键值对的索引,当根据键获取值的时候也会用到这两个方法。如果没有正确的实现这两个方法,两个不同的键可能会有相同的hash值,因此,可能会被集合认为是相等的。而且,这两个方法也用来发现重复元素。所以这两个方法的实现对HashMap的精确性和正确性是至关重要的。
请说一说,Java中的HashMap的工作原理是什么?
考察点:JAVA哈希表
参考回答:
HashMap是以键值对的形式存储元素的,需要一个哈希函数,使用hashcode和eaquels方法,从集合中添加和检索元素,调用put方法时,会计算key 的哈希值,然后把键值对存在集合中合适的索引上,如果键key已经存在,value会被更新为新值。另外HashMap的初始容量是16,在jdk1.7的时候底层是基于数组和链表实现的,插入方式是头插法。jdk1.8的时候底层是基于数组和链表/红黑树实现的,插入方式为尾插法。
介绍一下,什么是hashmap?
考察点:哈希表
参考回答:
HashMap 是一个散列表,它存储的内容是键值对(key-value)。
HashMap 继承于AbstractMap,实现了Map、Cloneable、java.io.Serializable接口。
HashMap 的实现不是同步的,所以它不是线程安全的。它的key、value都可以为null。此外,HashMap中的映射不是有序的。HashMap 的实例有两个参数影响其性能:“初始容量” 和 “负载因子”。容量 是哈希表中桶的数量,初始容量 只是哈希表在创建时的容量。负载因子 是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了加载因子与当前容量的乘积时,则要对该哈希表进行 rehash 操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数。
通常,默认加载因子是 0.75, 这是在时间和空间成本上寻求一种折衷。加载因子过高虽然减少了空间开销,但同时也增加了查询成本(在大多数 HashMap 类的操作中,包括 get 和 put 操作,都反映了这一点)。在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少 rehash 操作次数。如果初始容量大于最大条目数除以加载因子,则不会发生 rehash 操作。HashMap在扩容的时候是2的n次幂。
讲一讲,如何构造一致性哈希算法。
考察点:哈希算法
参考回答:
先构造一个长度为2^32的整数环(这个环被称为一致性Hash环),根据节点名称的Hash值(其分布为[0, 2^32-1])将服务器节点放置在这个Hash环上,然后根据数据的Key值计算得到其Hash值(其分布也为[0, 2^32-1]),接着在Hash环上顺时针查找距离这个Key值的Hash值最近的服务器节点,完成Key到服务器的映射查找。
这种算法解决了普通余数Hash算法伸缩性差的问题,可以保证在上线、下线服务器的情况下尽量有多的请求命中原来路由到的服务器。
请问,Object作为HashMap的key的话,对Object有什么要求吗?
考察点:哈希表
参考回答:
要求Object中hashcode不能变。
请问 HashSet 存的数是有序的吗?
考察点:哈希
参考回答:
HashSet是无序的。
9.2 树
TreeMap和TreeSet在排序时如何比较元素?Collections工具类中的sort()方法如何比较元素?
考察点:Tree
参考回答:
TreeSet要求存放的对象所属的类必须实现Comparable接口,该接口提供了比较元素的compareTo()方法,当插入元素时会回调该方法比较元素的大小。TreeMap要求存放的键值对映射的键必须实现Comparable接口从而根据键对元素进行排序。Collections工具类的sort方法有两种重载的形式,第一种要求传入的待排序容器中存放的对象比较实现Comparable接口以实现元素的比较;第二种不强制性的要求容器中的元素必须可比较,但是要求传入第二个参数,参数是Comparator接口的子类型(需要重写compare方法实现元素的比较),相当于一个临时定义的排序规则,其实就是通过接口注入比较元素大小的算法,也是对回调模式的应用(Java中对函数式编程的支持)。 public class Student implements Comparable { private String name; // 姓名 private int age; // 年龄 public Student(String name, int age) { this.name = name; this.age = age; } @Override public String toString() { return "Student [name=" + name + ", age=" + age + "]"; } @Override public int compareTo(Student o) { return this.age - o.age; // 比较年龄(年龄的升序) } } import java.util.Set; import java.util.TreeSet; class Test01 { public static void main(String[] args) { Set set = new TreeSet<>(); // Java 7 } } import java.util.Set; import java.util.TreeSet; class Test01 { public static void main(String[] args) { Set set = new TreeSet<>(); // Java 7的钻石语法(构造器后面的尖括号中不需要写类型) set.add(new Student("Hao LUO", 33)); set.add(new Student("XJ WANG", 32)); set.add(new Student("Bruce LEE", 60)); set.add(new Student("Bob YANG", 22)); for(Student stu : set) { System.out.println(stu); } // set.add(new Student("Hao LUO", 33)); set.add(new Student("XJ WANG", 32)); set.add(new Student("Bruce LEE", 60)); set.add(new Student("Bob YANG", 22)); for(Student stu : set) { System.out.println(stu); } // 输出结果: // Student [name=Bob YANG, age=22] // Student [name=XJ WANG, age=32] // Student [name=Hao LUO, age=33] // Student [name=Bruce LEE, age=60] } } // Student [name=Bob YANG, age=22] // Student [name=XJ WANG, age=32] // Student [name=Hao LUO, age=33] // Student [name=Bruce LEE, age=60] } }
如何打印二叉树每层的节点?
考察点:二叉树
注意:面试中一般写核心代码,另外就是可以问一下面试是输出ArrayList还是int数组。 /** * Definition for a binary tree node. * public class TreeNode { * int val; * TreeNode left; * TreeNode right; * TreeNode(int x) { val = x; } * } */ class Solution { public int[] levelOrder(TreeNode root) { // 如果root为空直接返回 if(root == null){ return new int[0]; } // 层次遍历 // 队列 Deque deque = new LinkedList<>(); // 存每一层节点 ArrayList temp = new ArrayList<>(); // 根节点入队 deque.offer(root); while(!deque.isEmpty()){ int size = deque.size(); for(int i=0;i
如何知道二叉树的深度?
考察点:二叉树
参考回答:
求二叉树的深度方式有两种,递归以及非递归。(出自剑指offer)
①递归实现:
为了求树的深度,可以先求其左子树的深度和右子树的深度,可以用递归实现,递归的出口就是节点为空。返回值为0; /** public class TreeNode { int val = 0; TreeNode left = null; TreeNode right = null; public TreeNode(int val) { this.val = val; } } */ public class Solution { public int TreeDepth(TreeNode root) { if(root==null) return 0; int left = TreeDepth(root.left); int right = TreeDepth(root.right); return (left>right)?(left+1):(right+1); } }
②非递归实现:
利用层次遍历的算法,将每一层的所有节点放入队列中,在将每一层节点的左右子节点存入放入到队列中,用一个变量记录树的高度即可。 import java.util.*; public class Solution { public int TreeDepth(TreeNode root) { if(root==null) return 0; Queue result = new LinkedList<>(); result.add(root); int height = 0; while(!result.isEmpty()){ //获取当前的根节点 int size = result.size(); while(size>0){//遍历当前层,将该层的子节点都加入队列中 TreeNode nowroot = result.poll(); if(nowroot.left!=null) result.add(nowroot.left); if(nowroot.right!=null) result.add(nowroot.right); size = size-1;// } height += 1;//高度加1 } return height; } }
二叉树任意两个节点之间路径的最大长度
考察点:树 int maxDist(Tree root) { //如果树是空的,则返回0 if(root == NULL) return 0; if(root->left != NULL) { root->lm = maxDist(root->left) +1; } if(root->right != NULL) root->rm = maxDist(root->right) +1; //如果以该节点为根的子树中有最大的距离,那就更新最大距离 int sum = root->rm + root->lm; if(sum > max) { max = sum; } return root->rm > root->lm ?root->rm : root->lm; }
算法题:二叉树层序遍历,进一步提问:要求每层打印出一个换行符
考察点:二叉树 public List> levelOrder(TreeNode root) { // 存最终结果 List> result = new ArrayList>(); // 队列 Queue queue = new LinkedList(); if (root == null) { return result; } queue.offer(root); // 层次遍历 while (queue.size() != 0) { List temp = new ArrayList(); int size = queue.size(); for (int i = 0; i < size; i++) { TreeNode temp = queue.poll(); temp.add(temp.val); // 左右子树是否为空 if (temp.left != null) { queue.offer(temp.left); } if (temp.right != null) { queue.offer(temp.right); } } result.add(temp); } return result; }
怎么求一个二叉树的深度?手撕代码?
考察点:二叉树
类似上面求二叉树的深度的题,这里给出递归的方式。 public class Solution { public int TreeDepth(TreeNode root) { if(root==null) return 0; int left = TreeDepth(root.left); int right = TreeDepth(root.right); return (left>right)?(left+1):(right+1); } }
请你说一下,B+树和B-树?
考察点:树
参考回答:
B+树内节点不存储数据,所有 data 存储在叶节点导致查询时间复杂度固定为 log n。而B-树查询时间复杂度不固定,与 key 在树中的位置有关,最好为O(1)。
B+树叶节点两两相连可大大增加区间访问性,可使用在范围查询等,而B-树每个节点 key 和 data 在一起,则无法区间查找。
B+树更适合外部存储。由于内节点无 data 域,每个节点能索引的范围更大更精确
9.3遍历
二叉树 Z 字型遍历
考察点:遍历 import java.util.*; public class Solution { public ArrayList> Print(TreeNode pRoot) { ArrayList> result = new ArrayList<>(); Queue queue = new LinkedList(); //标记奇偶层 开始为0层 从左->右遍历 boolean flag = true; if (pRoot == null) { return result; } queue.add(pRoot); while (queue.size() != 0) { flag = !flag; int size = queue.size(); ArrayList temp = new ArrayList(); for (int i = 0; i < size; i++) { TreeNode curroot = queue.poll(); temp.add(curroot.val); if (curroot.left != null) queue.add(curroot.left); if (curroot.right != null) queue.add(curroot.right); } // 反转 if (flag) { Collections.reverse(temp); } result.add(temp); } return result; } }
编程题:写一个函数,找到一个文件夹下所有文件,包括子文件夹
考察点:遍历 import java.io.File; public class Counter2 { public static void main(String[] args) { //取得目标目录 File file = new File("D:"); //获取目录下子文件及子文件夹 File[] files = file.listFiles(); readfile(files); } public static void readfile(File[] files) { if(files == null) {// 如果目录为空,直接退出 return; } for(File f:files) { //如果是文件,直接输出名字 if(f.isFile()) { System.out.println(f.getName()); } //如果是文件夹,递归调用 else if(f.isDirectory()) { readfile(f.listFiles()); } } } }
9.4链表
现在有一个单向链表,谈一谈,如何判断链表中是否出现了环
考察点:链表
参考回答:
单链表有环,是指单链表中某个节点的next指针域指向的是链表中在它之前的某一个节点,这样在链表的尾部形成一个环形结构。
解法:定义两个指针,同时从链表的头节点出发,一个指针一次走一步,另一个指针一次走两步。如果走得快的指针追上了走得慢的指针,那么链表就是环形链表;如果走得快的指针走到了链表的末尾(next指向 NULL)都没有追上第一个指针,那么链表就不是环形链表。
谈一谈,bucket如果用链表存储,它的缺点是什么?
考察点:链表
参考回答:
①查找速度慢,因为查找时,需要循环链表访问
②如果进行频繁插入和删除操作,会导致速度很慢。
有一个链表,奇数位升序偶数位降序,如何将链表变成升序
考察点:链表
思路:将链表按照奇偶数位分成两个链表head1和head2,如1->6->3->4->5->2,得到1->3->5和6->4->2两个链表,将6->4->2反转得到2->4->6,在将两个链表合并。 public class OddIncreaseEvenDecrease { /** * 按照奇偶位拆分成两个链表 * @param head * @return */ // 例子 1 6 3 4 5 2 变成 1 2 3 4 5 6 public static Node[] getLists(Node head){ Node head1 = null; Node head2 = null; Node cur1 = null; Node cur2 = null; int count = 1;//用来计数 while(head != null){ // 遇到1 3 5 if(count % 2 == 1){ if(cur1 != null){ cur1.next = head; cur1 = cur1.next; }else{ cur1 = head; head1 = cur1; } }else{ if(cur2 != null){ cur2.next = head; cur2 = cur2.next; }else{ cur2 = head; head2 = cur2; } } head = head.next; count++; } //跳出循环,要让最后两个末尾元素的下一个都指向null cur1.next = null; cur2.next = null; Node[] nodes = new Node[]{head1,head2}; return nodes; } /** * 反转链表 * @param head * @return */ public static Node reverseList(Node head){ Node pre = null; Node next = null; while(head != null){ next = head.next; head.next = pre; pre = head; head = next; } return pre; } /** * 合并两个有序链表 * @param head1 * @param head2 * @return */ public static Node CombineList(Node head1,Node head2){ if(head1 == null || head2 == null){ return head1 != null ? head1 :head2; } Node head = head1.value < head2.value ?head1 : head2; Node cur1 = head == head1 ? head1 :head2; Node cur2 = head == head1 ? head2 :head1; Node pre = null; Node next = null; while(cur1 != null && cur2 !=null){ if(cur1.value <= cur2.value){//这里一定要有=,否则一旦cur1的value和cur2的value相等的话,下面的pre.next会出现空指针异常 pre = cur1; cur1 = cur1.next; }else{ next = cur2.next; pre.next = cur2; cur2.next = cur1; pre = cur2; cur2 = next; } } pre.next = cur1 == null ? cur2 : cur1; return head; } }
随机链表的复制
考察点:链表 public RandomListNode copyRandomList(RandomListNode head) { if (head == null) return null; RandomListNode p = head; // copy every node and insert to list while (p != null) { RandomListNode copy = new RandomListNode(p.label); copy.next = p.next; p.next = copy; p = copy.next; } // copy random pointer for each new node p = head; while (p != null) { if (p.random != null) p.next.random = p.random.next; p = p.next.next; } // break list to two p = head; RandomListNode newHead = head.next; while (p != null) { RandomListNode temp = p.next; p.next = temp.next; if (temp.next != null) temp.next = temp.next.next; p = p.next; } return newHead; }
如何反转单链表
考察点:链表 public static Node reverseList(ListNode head){ ListNode pre = null; ListNode next = null; while(head != null){ next = head.next; head.next = pre; pre = head; head = next; } return pre; }
9.5 数组
写一个算法,可以将一个二维数组顺时针旋转90度,说一下思路。
考察点:数组 public void rotate(int[][] matrix) { int n = matrix.length; for (int i = 0; i < n/2; i++) { for (int j = i; j < n-1-i; j++) { int temp = matrix[i][j]; matrix[i][j] = matrix[n-1-j][i]; matrix[n-1-j][i] = matrix[n-1-i][n-1-j]; matrix[n-1-i][n-1-j] = matrix[j][n-1-i]; matrix[j][n-1-i] = temp; } } }
一个数组,除一个元素外其它都是两两相等,求那个元素?
考察点:数组
解法:位运算,数组中的全部元素的异或运算结果即为数组中只出现一次的数字。 public static int find1From2(int[] a){ int len = a.length, result = 0; for(int i = 0; i < len; i++){ result = result ^ a[i]; } return result; }
找出数组中和为S的一对组合,找出一组就行
考察点:数组
解法:两数之和经典题,找到一组即可返回,使用HashMap即可。 public int[] twoSum(int[] nums, int target) { HashMap map = new HashMap(); int[] a = new int[2]; map.put(nums[0], 0); for (int i = 1; i < nums.length;i++) { if (map.containsKey(target - nums[i])) { a[0] = map.get(target - nums[i]); a[1] = i; return a; } else { map.put(nums[i], i); } } return a; }
求一个数组中连续子向量的最大和
考察点:数组 public int maxSubArray(int[] nums) { int sum = 0; int maxSum = Integer.MIN_VALUE; if (nums == null || nums.length == 0) { return sum; } for (int i = 0; i < nums.length;i++) { sum += nums[i]; maxSum = Math.max(maxSum, sum); if (sum < 0) { sum = 0; } } return maxSum; }
寻找一数组中前K个最大的数
考察点:数组
解法:考场排序,可以用堆排序,也可以用快排,面试的时候看面试官怎么要求。这里给出快排的解法,自己也可以尝试其他的方法。 public int findKthLargest(int[] nums, int k) { if (k < 1 || nums == null) { return 0; } return getKth(nums.length - k +1, nums, 0,nums.length - 1); } // 快排模板 public int getKth(int k, int[] nums, int start, int end) { int pivot = nums[end]; int left = start; int right = end; while (true) { while(nums[left] < pivot && left < right) { left++; } while(nums[right] >= pivot && right > left) { right--; } if(left == right) { break; } swap(nums,left, right); } swap(nums, left, end); if (k == left + 1) { return pivot; } else if (k < left + 1) { return getKth(k, nums, start, left - 1); } else { return getKth(k, nums, left + 1, end); } } // 交换元素值 public void swap(int[] nums, int n1, int n2) { int tmp = nums[n1]; nums[n1] = nums[n2]; nums[n2] = tmp; }
9.6排序
用java写一个冒泡排序?
考察点:冒泡排序 public static void main(String[] args) { int[] result = {2,4,1,3,6,5}; int temp; System.out.println("----冒泡排序前顺序----"); for (int i : result) { System.out.print(i); } for(int i=0;i
介绍一下,排序都有哪几种方法?请列举出来。
考察点:排序
参考回答:
排序的方法有:
插入排序(简单插入排序、希尔排序)
交换排序(冒泡排序、快速排序)
选择排序(简单选择排序、堆排序)
归并排序
分配排序(箱排序、基数排序)
绍一下,归并排序的原理是什么?
考察点:归并排序
参考回答:
(1)归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。
(2)首先考虑下如何将将二个有序数列合并。这个非常简单,只要从比较二个数列的第一个数,谁小就先取谁,取了后就在对应数列中删除这个数。然后再进行比较,如果有数列为空,那直接将另一个数列的数据依次取出即可。
(3)解决了上面的合并有序数列问题,再来看归并排序,其的基本思路就是将数组分成二组A,B,如果这二组组内的数据都是有序的,那么就可以很方便的将这二组数据进行排序。如何让这二组组内数据有序了?
可以将A,B组各自再分成二组。依次类推,当分出来的小组只有一个数据时,可以认为这个小组组内已经达到了有序,然后再合并相邻的二个小组就可以了。这样通过先递归的分解数列,再合并数列就完成了归并排序。
介绍一下,堆排序的原理是什么?
考察点:堆排序
参考回答:
堆排序分大顶堆和小顶堆,这里以大顶堆为例讲解。
堆排序就是把最大堆堆顶的最大数取出,将剩余的堆继续调整为最大堆,再次将堆顶的最大数取出,这个过程持续到剩余数只有一个时结束。在堆中定义以下几种操作:
(1)最大堆调整(Max-Heapify):将堆的末端子节点作调整,使得子节点永远小于父节点。
(2)创建最大堆(Build-Max-Heap):将堆所有数据重新排序,使其成为最大堆。
(3)堆排序(Heap-Sort):移除位在第一个数据的根节点,并做最大堆调整的递归运算
谈一谈,如何得到一个数据流中的中位数?
考察点:排序
参考回答:
数据是从一个数据流中读出来的,数据的数目随着时间的变化而增加。如果用一个数据容器来保存从流中读出来的数据,当有新的数据流中读出来时,这些数据就插入到数据容器中。
数组是最简单的容器。如果数组没有排序,可以用 Partition 函数找出数组中的中位数。在没有排序的数组中插入一个数字和找出中位数的时间复杂度是 O(1)和 O(n)。
我们还可以往数组里插入新数据时让数组保持排序,这是由于可能要移动 O(n)个数,因此需要 O(n)时间才能完成插入操作。在已经排好序的数组中找出中位数是一个简单的操作,只需要 O(1)时间即可完成。
排序的链表时另外一个选择。我们需要 O(n)时间才能在链表中找到合适的位置插入新的数据。如果定义两个指针指向链表的中间结点(如果链表的结点数目是奇数,那么这两个指针指向同一个结点),那么可以在 O(1)时间得出中位数。此时时间效率与及基于排序的数组的时间效率一样。
如果能够保证数据容器左边的数据都小于右边的数据,这样即使左、右两边内部的数据没有排序,也可以根据左边最大的数及右边最小的数得到中位数。如何快速从一个容器中找出最大数?用最大堆实现这个数据容器,因为位于堆顶的就是最大的数据。同样,也可以快速从最小堆中找出最小数。
因此可以用如下思路来解决这个问题:用一个最大堆实现左边的数据容器,用最小堆实现右边的数据容器。往堆中插入一个数据的时间效率是 O(logn)。由于只需 O(1)时间就可以得到位于堆顶的数据,因此得到中位数的时间效率是 O(1)。
你知道哪些排序算法,这些算法的时间复杂度分别是多少,解释一下快排?
考察点:快排
参考回答:
快排:快速排序有两个方向,左边的i下标一直往右走(当条件a[i] <= a[center_index]时),其中center_index是中枢元素的数组下标,一般取为数组第0个元素。
而右边的j下标一直往左走(当a[j] > a[center_index]时)。
如果i和j都走不动了,i <= j, 交换a[i]和a[j],重复上面的过程,直到i>j。交换a[j]和a[center_index],完成一趟快速排序。
9.7 堆与栈
请你解释一下,内存中的栈(stack)、堆(heap) 和静态区(static area) 的用法。
考察点:堆栈
参考回答:
堆区:专门用来保存对象的实例(new 创建的对象和数组),实际上也只是保存对象实例的属性值,属性的类型和对象本身的类型标记等,并不保存对象的方法(方法是指令,保存在Stack中)
存储的全部是对象,每个对象都包含一个与之对应的class的信息。(class的目的是得到操作指令)
jvm只有一个堆区(heap)被所有线程共享,堆中不存放基本类型和对象引用,只存放对象本身.
一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收 。
栈区:对象实例在Heap 中分配好以后,需要在Stack中保存一个4字节的Heap内存地址,用来定位该对象实例在Heap 中的位置,便于找到该对象实例。
每个线程包含一个栈区,栈中只保存基础数据类型的对象和自定义对象的引用(不是对象),对象都存放在堆区
每个栈中的数据(原始类型和对象引用)都是私有的,其他栈不能访问。
栈分为3个部分:基本类型变量区、执行环境上下文、操作指令区(存放操作指令)。
由编译器自动分配释放 ,存放函数的参数值,局部变量的值等.
静态区/方法区:
方法区又叫静态区,跟堆一样,被所有的线程共享。方法区包含所有的class和static变量。
方法区中包含的都是在整个程序中永远唯一的元素,如class,static变量。
全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域, 未初始化的全局变量和未初始化的静态变量在相邻的另一块区域
说一说,heap和stack有什么区别。
考察点:堆与栈
参考回答:
1.heap是堆,stack是栈。
2.stack的空间由操作系统自动分配和释放,heap的空间是手动申请和释放的,heap常用new关键字来分配。
3.stack空间有限,heap的空间是很大的自由区。在Java中,若只是声明一个对象,则先在栈内存中为其分配地址空间,若再new一下,实例化它,则在堆内存中为其分配地址。
4.举例:数据类型 变量名;这样定义的东西在栈区。如:Object a =null; 只在栈内存中分配空间new 数据类型();或者malloc(长度); 这样定义的东西就在堆区如:Object b =new Object(); 则在堆内存中分配空间
介绍一下,堆与栈的不同是什么?
考察点:堆,栈
参考回答:
(1)Java的堆是一个运行时数据区,类的对象从中分配空间。通过比如:new等指令建立,不需要代码显式的释放,由垃圾回收来负责。
优点:可以动态地分配内存大小,垃圾收集器会自动回收垃圾数据。
缺点:由于其优点,所以存取速度较慢。
(2)栈:
其数据项的插入和删除都只能在称为栈顶的一端完成,后进先出。栈中存放一些基本类型的 变量 和 对象句柄。
优点:读取数度比堆要快,仅次于寄存器,栈数据可以共享。
缺点:比堆缺乏灵活性,存在栈中的数据大小与生存期必须是确定的。
举例:
String是一个特殊的包装类数据。可以用:
String str = new String("csdn");
String str = "csdn";
两种的形式来创建,第一种是用new()来新建对象的,它会在存放于堆中。每调用一次就会创建一个新的对象。而第二种是先在栈中创建一个对String类的对象引用变量str,然后查找栈中有没有存放"csdn",如果没有,则将"csdn"存放进栈,并令str指向”abc”,如果已经有”csdn” 则直接令str指向“csdn”。
9.8队列
什么是Java优先级队列(Priority Queue)?
考察点:队列
参考回答:
PriorityQueue是一个基于优先级堆的无界队列,它的元素是按照自然顺序(natural order)排序的。在创建的时候,可以给它提供一个负责给元素排序的比较器。PriorityQueue不允许null值,因为他们没有自然顺序,或者说他们没有任何的相关联的比较器。最后,PriorityQueue不是线程安全的,入队和出队的时间复杂度是O(log(n))。
9.9 高级算法
请你讲讲LRU算法的实现原理?
考察点:LRU算法
参考回答:
注意:面试可能会让手写LRU算法!
①LRU(Least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也很高”,反过来说“如果数据最近这段时间一直都没有访问,那么将来被访问的概率也会很低”,两种理解是一样的;常用于页面置换算法,为虚拟页式存储管理服务。
②达到这样一种情形的算法是最理想的:每次调换出的页面是所有内存页面中最迟将被使用的;这可以最大限度的推迟页面调换,这种算法,被称为理想页面置换算法。可惜的是,这种算法是无法实现的。
为了尽量减少与理想算法的差距,产生了各种精妙的算法,最近最少使用页面置换算法便是其中一个。LRU 算法的提出,是基于这样一个事实:在前面几条指令中使用频繁的页面很可能在后面的几条指令中频繁使用。反过来说,已经很久没有使用的页面很可能在未来较长的一段时间内不会被用到 。这个,就是著名的局部性原理——比内存速度还要快的cache,也是基于同样的原理运行的。因此,我们只需要在每次调换时,找到最近最少使用的那个页面调出内存。
LRU主要的两个函数 获取数据 get 和 写入数据 put 。
获取数据 get(key) - 如果关键字 (key) 存在于缓存中,则获取关键字的值(总是正数),否则返回 -1。
写入数据 put(key, value) - 如果关键字已经存在,则变更其数据值;如果关键字不存在,则插入该组「关键字/值」。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。
算法实现的关键
命中率:当存在热点数据时,LRU的效率很好,但偶发性的、周期性的批量操作会导致 LRU 命中率急剧下降,缓存污染情况比较严重。
复杂度:实现起来较为简单。
存储成本:几乎没有空间上浪费。
代价:命中时需要遍历链表,找到命中的数据块索引,然后需要将数据移到头部。
为什么要设计后缀表达式,有什么好处?
考察点:逆波兰表达式
参考回答:
后缀表达式又叫逆波兰表达式,逆波兰记法不需要括号来标识操作符的优先级。
请你设计一个算法,用来压缩一段URL?
考察点:MD5加密算法
参考回答:
该算法主要使用MD5 算法对原始链接进行加密(这里使用的MD5 加密后的字符串长度为32 位),然后对加密后的字符串进行处理以得到短链接的地址。
谈一谈,id全局唯一且自增,如何实现?
考察点:SnowFlake雪花算法
参考回答;
SnowFlake雪花算法
雪花ID生成的是一个64位的二进制正整数,然后转换成10进制的数。64位二进制数由如下部分组成:
snowflake id生成规则
1位标识符:始终是0,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0。
41位时间戳:41位时间截不是存储当前时间的时间截,而是存储时间截的差值(当前时间截 - 开始时间截 )得到的值,这里的的开始时间截,一般是我们的id生成器开始使用的时间,由我们程序来指定的。
10位机器标识码:可以部署在1024个节点,如果机器分机房(IDC)部署,这10位可以由 5位机房ID + 5位机器ID 组成。
12位序列:毫秒内的计数,12位的计数顺序号支持每个节点每毫秒(同一机器,同一时间截)产生4096个ID序号
优点
简单高效,生成速度快。
时间戳在高位,自增序列在低位,整个ID是趋势递增的,按照时间有序递增。
灵活度高,可以根据业务需求,调整bit位的划分,满足不同的需求。
缺点
依赖机器的时钟,如果服务器时钟回拨,会导致重复ID生成。
在分布式环境上,每个服务器的时钟不可能完全同步,有时会出现不是全局递增的情况。
Design and implement a data structure for Least Frequently Used (LFU) cache. It should support the following operations: get and put. get(key) - Get the value (will always be positive) of the key if the key exists in the cache, otherwise return -1. put(key, value) - Set or insert the value if the key is not already present. When the cache reaches its capacity, it should invalidate the least frequently used item before inserting a new item. For the purpose of this problem, when there is a tie (i.e., two or more keys that have the same frequency), the least recently used key would be evicted. Could you do both operations in O(1) time complexity?
考察点:LFU Cache public class LFUCache { private class Node{ int value; ArrayList set; Node prev; Node next; public Node (int value ){ this.value = value; this.set = new ArrayList (); this.prev = null; this.next = null; } } private class item{ int key; int value; Node parent ; public item(int key ,int value, Node parent){ this.key = key ; this.value = value; this.parent = parent; } } private HashMap map; private Node head,tail; private int capacity; // @param capacity, an integer public LFUCache(int capacity) { // Write your code here this.capacity = capacity; this.map = new HashMap (); this.head = new Node (0); this.tail = new Node(Integer.MAX_VALUE); head.next = tail; tail.prev = head; } // @param key, an integer // @param value, an integer // @return nothing public void set(int key, int value) { // Write your code here if (get(key) != -1 ) { map.get(key).value = value; return ; } if (map.size() == capacity ){ getLFUitem(); } Node newpar = head.next; if ( newpar.value != 1){ newpar = getNewNode(1,head,newpar); } item curitem = new item(key,value,newpar); map.put(key,curitem); newpar.set.add(key); return; } public int get(int key) { // Write your code here if (!map.containsKey(key)){ return -1; } item cur = map.get(key); Node curpar = cur.parent; if (curpar.next.value == curpar.value + 1){ cur.parent= curpar.next; cur.parent.set.add(key); }else{ Node newpar =getNewNode(curpar.value + 1,curpar,curpar.next); cur.parent = newpar; newpar.set.add(key); } curpar.set.remove(new Integer(key)); if(curpar.set.isEmpty()){ deleteNode(curpar); } return cur.value; } private Node getNewNode (int value ,Node prev , Node next){ Node temp = new Node(value); temp.prev = prev; temp.next = next; prev.next = temp; next.prev = temp; return temp; } private void deleteNode(Node temp){ temp.prev.next = temp.next; temp.next.prev = temp.prev; return ; } private void getLFUitem(){ Node temp = head.next; int LFUkey = temp.set.get(0); temp.set.remove(0); map.remove(LFUkey); if (temp.set.isEmpty()){ deleteNode(temp); } return; }
1. 设计模式
1.1 说一说设计模式的六大原则
参考答案
单一职责原则
一个类,应当只有一个引起它变化的原因;即一个类应该只有一个职责。
就一个类而言,应该只专注于做一件事和仅有一个引起变化的原因,这就是所谓的单一职责原则。该原则提出了对对象职责的一种理想期望,对象不应该承担太多职责,正如人不应该一心分为二用。唯有专注,才能保证对象的高内聚;唯有单一,才能保证对象的细粒度。对象的高内聚与细粒度有利于对象的重用。一个庞大的对象承担了太多的职责,当客户端需要该对象的某一个职责时,就不得不将所有的职责都包含进来,从而造成冗余代码。
里氏替换原则
在面向对象的语言中,继承是必不可少的、优秀的语言机制,它主要有以下几个优点:
代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;
提高代码的可重用性;
提高代码的可扩展性;
提高产品或项目的开放性。
相应的,继承也存在缺点,主要体现在以下几个方面:
继承是入侵式的。只要继承,就必须拥有父类的所有属性和方法;
降低代码的灵活性。子类必须拥有父类的属性和方法,使子类受到限制;
增强了耦合性。当父类的常量、变量和方法修改时,必须考虑子类的修改,这种修改可能造成大片的代码需要重构。
从整体上看,继承的“利”大于“弊”,然而如何让继承中“利”的因素发挥最大作用,同时减少“弊”所带来的麻烦,这就需要引入“里氏替换原则”。里氏替换原则的定义有以下两种:
如果对一个类型为S的对象o1,都有类型为T的对象o2,使得以S定义的所有程序P在所有的对象o1都代换成o2时,程序P的行为没有发生变化,那么类型T是类型S的子类型。
所有引用基类的地方必须能透明地使用其子类对象。清晰明确地说明只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或异常,使用者可能根本就不需要知道父类还是子类;但是反过来则不可以,有子类的地方,父类未必就能适应。
依赖倒置原则
依赖倒置原则包括三种含义:
高层模块不应该依赖低层模块,两者都依赖其抽象;
抽象不依赖细节;
细节应该依赖于抽象。
传统的过程性系统的设计办法倾向于高层次的模块依赖于低层次的模块;抽象层次依赖于具体层次。“倒置”原则将这个错误的依赖关系倒置了过来,如下图所示,由此命名为“依赖倒置原则”。
在Java语言中,抽象就是指接口或抽象类,两者都是不能直接被实例化的;细节就是具体的实现类,实现类实现了接口或继承了抽象类,其特点是可以直接被实例化。依赖倒置原则在Java语言中的表现是:
模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生;
接口或抽象类不依赖于实现类;
实现类依赖于接口或抽象类。
依赖倒置原则更加精确的定义就是“面向接口编程”——OOD(Object-Oriented Design)的精髓之一。依赖倒置原则可以减少类间的耦合性,提高系统的稳定性,降低并行开发引起的风险,提高代码的可读性和可维护性。依赖倒置原则是JavaBean、EJB和COM等组件设计模型背后的基本原则。
接口隔离原则
接口隔离原则有如下两种定义:
客户端不应该依赖它不需要的接口。
类间的依赖关系应该建立在最小的接口上。
接口隔离原则的具体含义如下:
一个类对另外一个类的依赖性应当是建立在最小的接口上的。
一个接口代表一个角色,不应当将不同的角色都交给一个接口。没有关系的接口合并在一起,形成一个臃肿的大接口,这是对角色和接口的污染。因此使用多个专门的接口比使用单一的总接口要好。
不应该强迫客户依赖于它们不用的方法。接口属于客户,不属于它所在的类层次结构,即不要强迫客户使用它们不用的方法,否则这些客户就会面临由于这些不使用的方法的改变所带来的改变。
迪米特法则
迪米特法则又叫最少知识原则,意思是一个对象应当对其他对象尽可能少的了解。迪米特法则不同于其他的OO设计原则,它具有很多种表述方式,其中具有代表性的是以下几种表述:
只与你直接的朋友们通信;
不要跟“陌生人”说话;
每一个软件单位对其他的单位都只有最少的了解,这些了解仅局限于那些与本单位密切相关的软件单位。
按照迪米特法则,如果两个类不必彼此直接通信,那么这两个类就不应当发生直接的相互作用;如果一个类需要调用另一个类的某一个方法,可以通过第三者转发这个调用。
开闭原则
开闭原则的定义是:一个软件实体应当对扩展开放,对修改关闭。这个原则说的是,在设计一个模块的时候,应当使这个模块可以在不被修改的前提下被扩展,即应当可以在不必修改源代码的情况下改变这个模块的行为。
在面向对象的编程中,开闭原则是最基础的原则,起到总的指导作用,其他原则(单一职责、里氏替换、依赖倒置、接口隔离、迪米特法则)都是开闭原则的具体形态,即其他原则都是开闭原则的手段和工具。开闭原则的重要性可以通过以下几个方面来体现。
开闭原则提高复用性。在面向对象的设计中,所有的逻辑都是从原子逻辑组合而来的,而不是在一个类中独立实现一个业务逻辑,代码粒度越小,被复用的可能性就越大,避免相同的逻辑重复增加。开闭原则的设计保证系统是一个在高层次上实现了复用的系统。
开闭原则提高可维护性。一个软件投产后,维护人员的工作不仅仅是对数据进行维护,还可能对程序进行扩展,就是扩展一个类,而不是修改一个类。开闭原则对已有软件模块,特别是最重要的抽象层模块要求不能再修改,这就使变化中的软件系统有一定的稳定性和延续性,便于系统的维护。
开闭原则提高灵活性。所有的软件系统都有一个共同的性质,即对系统的需求都会随时间的推移而发生变化。在软件系统面临新的需求时,系统的设计必须是稳定的。开闭原则可以通过扩展已有的软件系统,提供新的行为,能快速应对变化,以满足对软件新的需求,使变化中的软件系统有一定的适应性和灵活性。
开闭原则易于测试。测试是软件开发过程中必不可少的一个环节。测试代码不仅要保证逻辑的正确性,还要保证苛刻条件(高压力、异常、错误)下不产生“有毒代码”(Poisonous Code),因此当有变化提出时,原有健壮的代码要尽量不修改,而是通过扩展来实现。否则,就需要把原有的测试过程回笼一遍,需要进行单元测试、功能测试、集成测试,甚至是验收测试。开闭原则的使用,保证软件是通过扩展来实现业务逻辑的变化,而不是修改。因此,对于新增加的类,只需新增相应的测试类,编写对应的测试方法,只要保证新增的类是正确的就可以了。
1.2 说一下六大原则中的开闭原则
参考答案
开闭原则的定义是:一个软件实体应当对扩展开放,对修改关闭。这个原则说的是,在设计一个模块的时候,应当使这个模块可以在不被修改的前提下被扩展,即应当可以在不必修改源代码的情况下改变这个模块的行为。
在面向对象的编程中,开闭原则是最基础的原则,起到总的指导作用,其他原则(单一职责、里氏替换、依赖倒置、接口隔离、迪米特法则)都是开闭原则的具体形态,即其他原则都是开闭原则的手段和工具。开闭原则的重要性可以通过以下几个方面来体现。
开闭原则提高复用性。在面向对象的设计中,所有的逻辑都是从原子逻辑组合而来的,而不是在一个类中独立实现一个业务逻辑,代码粒度越小,被复用的可能性就越大,避免相同的逻辑重复增加。开闭原则的设计保证系统是一个在高层次上实现了复用的系统。
开闭原则提高可维护性。一个软件投产后,维护人员的工作不仅仅是对数据进行维护,还可能对程序进行扩展,就是扩展一个类,而不是修改一个类。开闭原则对已有软件模块,特别是最重要的抽象层模块要求不能再修改,这就使变化中的软件系统有一定的稳定性和延续性,便于系统的维护。
开闭原则提高灵活性。所有的软件系统都有一个共同的性质,即对系统的需求都会随时间的推移而发生变化。在软件系统面临新的需求时,系统的设计必须是稳定的。开闭原则可以通过扩展已有的软件系统,提供新的行为,能快速应对变化,以满足对软件新的需求,使变化中的软件系统有一定的适应性和灵活性。
开闭原则易于测试。测试是软件开发过程中必不可少的一个环节。测试代码不仅要保证逻辑的正确性,还要保证苛刻条件(高压力、异常、错误)下不产生“有毒代码”(Poisonous Code),因此当有变化提出时,原有健壮的代码要尽量不修改,而是通过扩展来实现。否则,就需要把原有的测试过程回笼一遍,需要进行单元测试、功能测试、集成测试,甚至是验收测试。开闭原则的使用,保证软件是通过扩展来实现业务逻辑的变化,而不是修改。因此,对于新增加的类,只需新增相应的测试类,编写对应的测试方法,只要保证新增的类是正确的就可以了。
1.3 手写一个单例模式
参考答案
饿汉式单例模式: public class Singleton { private static Singleton instance = new Singleton(); // 私有构造方法,保证外界无法直接实例化。 private Singleton() {} // 通过公有的静态方法获取对象实例 public static Singleton getInstace() { return instance; } }
懒汉式单例模式: public class Singleton { private static Singleton instance = null; // 私有构造方法,保证外界无法直接实例化。 private Singleton() {} // 通过公有的静态方法获取对象实例 public static Singleton getInstace() { if (instance == null) { instance = new Singleton(); } return instance; } }
1.4 手写一个线程安全的单例模式
参考答案
在懒汉式单例模式基础上实现线程同步: public class Singleton { private static Singleton instance = null; // 私有构造方法,保证外界无法直接实例化。 private Singleton() {} // 通过公有的静态方法获取对象实例 synchronized public static Singleton getInstace() { if (instance == null) { instance = new Singleton(); } return instance; } }
上述代码对静态方法 getInstance()进行同步,以确保多线程环境下只创建一个实例。如果getInstance()方法未被同步,并且线程A和线程B同时调用此方法,则执行if (instance == null)语句时都为真,那么线程A和线程B都会创建一个对象,在内存中就会出现两个对象,这样就违反了单例模式。而使用synchronized关键字进行同步后,则不会出现此种情况。
1.5 说一说你对工厂模式的理解
参考答案
工厂模式的用意是定义一个创建产品对象的工厂接口,将实际创建性工作推迟到子类中。工厂模式可分为简单工厂、工厂方法和抽象工厂模式。注意,我们常说的23种经典设计模式,包含了工程方法模式和抽象工厂模式,而并未包含简单工厂模式。另外,我们平时说的工厂模式,一般默认是指工厂方法模式。
简单工厂
简单工厂模式其实并不算是一种设计模式,更多的时候是一种编程习惯。简单工厂的实现思路是,定义一个工厂类,根据传入的参数不同返回不同的实例,被创建的实例具有共同的父类或接口。简单工厂的适用场景是:
需要创建的对象较少。
客户端不关心对象的创建过程。
示例:
创建一个可以绘制不同形状的绘图工具,可以绘制圆形,正方形,三角形,每个图形都会有一个draw()方法用于绘图,不看代码先考虑一下如何通过该模式设计完成此功能。
由题可知圆形,正方形,三角形都属于一种图形,并且都具有draw方法,所以首先可以定义一个接口或者抽象类,作为这三个图像的公共父类,并在其中声明一个公共的draw方法: public interface Shape { void draw(); }
下面就是编写具体的图形,每种图形都实现Shape接口: // 圆形 class CircleShape implements Shape { public CircleShape() { System.out.println("CircleShape: created"); } @Override public void draw() { System.out.println("draw: CircleShape"); } } // 正方形 class RectShape implements Shape { public RectShape() { System.out.println("RectShape: created"); } @Override public void draw() { System.out.println("draw: RectShape"); } } // 三角形 public class TriangleShape implements Shape { public TriangleShape() { System.out.println("TriangleShape: created"); } @Override public void draw() { System.out.println("draw: TriangleShape"); } }
下面是工厂类的具体实现: class ShapeFactory { public static Shape getShape(String type) { Shape shape = null; if (type.equalsIgnoreCase("circle")) { shape = new CircleShape(); } else if (type.equalsIgnoreCase("rect")) { shape = new RectShape(); } else if (type.equalsIgnoreCase("triangle")) { shape = new TriangleShape(); } return shape; } }
为工厂类传入不同的type可以new不同的形状,返回结果为Shape 类型,这个就是简单工厂核心的地方了。
工厂方法
工厂方法模式是简单工厂的仅一步深化, 在工厂方法模式中,我们不再提供一个统一的工厂类来创建所有的对象,而是针对不同的对象提供不同的工厂。也就是说每个对象都有一个与之对应的工厂。
工厂方法的实现思路是,定义一个用于创建对象的接口,让子类决定将哪一个类实例化。工厂方法模式让一个类的实例化延迟到其子类。
示例:
现在需要设计一个这样的图片加载类,它具有多个图片加载器,用来加载jpg,png,gif格式的图片,每个加载器都有一个read()方法,用于读取图片。下面我们完成这个图片加载类。
首先完成图片加载器的设计,编写一个加载器的公共接口: public interface Reader { void read(); }
然后完成各个图片加载器的代码: // jpg图片加载器 class JpgReader implements Reader { @Override public void read() { System.out.print("read jpg"); } } // png图片加载器 class PngReader implements Reader { @Override public void read() { System.out.print("read png"); } } // gif图片加载器 class GifReader implements Reader { @Override public void read() { System.out.print("read gif"); } }
现在我们按照定义所说定义一个抽象的工厂接口ReaderFactory: interface ReaderFactory { Reader getReader(); }
里面有一个getReader()方法返回我们的Reader 类,接下来我们把上面定义好的每个图片加载器都提供一个工厂类,这些工厂类实现了ReaderFactory 。 // jpg加载器工厂 class JpgReaderFactory implements ReaderFactory { @Override public Reader getReader() { return new JpgReader(); } } // png加载器工厂 class PngReaderFactory implements ReaderFactory { @Override public Reader getReader() { return new PngReader(); } } // gif加载器工厂 class GifReaderFactory implements ReaderFactory { @Override public Reader getReader() { return new GifReader(); } }
在每个工厂类中我们都通过重写的getReader()方法返回各自的图片加载器对象。
和简单工厂对比一下,最根本的区别在于,简单工厂只有一个统一的工厂类,而工厂方法是针对每个要创建的对象都会提供一个工厂类,这些工厂类都实现了一个工厂基类。
下面总结一下工厂方法的适用场景:
客户端不需要知道它所创建的对象的类。
客户端可以通过子类来指定创建对应的对象。
抽象工厂
这个模式最不好理解,而且在实际应用中局限性也蛮大的,因为这个模式并不符合开闭原则。实际开发还需要做好权衡。抽象工厂模式是工厂方法的仅一步深化,在这个模式中的工厂类不单单可以创建一个对象,而是可以创建一组对象。这是和工厂方法最大的不同点。
抽象工厂的实现思路是,提供一个创建一系列相关或相互依赖对象的接口,而无须指定它们具体的类。抽象工厂和工厂方法一样可以划分为4大部分:
AbstractFactory(抽象工厂):声明了一组用于创建对象的方法,注意是一组。
ConcreteFactory(具体工厂):它实现了在抽象工厂中声明的创建对象的方法,生成一组具体对象。
AbstractProduct(抽象产品):它为每种对象声明接口,在其中声明了对象所具有的业务方法。
ConcreteProduct(具体产品):它定义具体工厂生产的具体对象。
示例:
现在需要做一款跨平台的游戏,需要兼容Android,Ios,Wp三个移动操作系统,该游戏针对每个系统都设计了一套操作控制器(OperationController)和界面控制器(UIController),下面通过抽闲工厂方式完成这款游戏的架构设计。
由题可知,游戏里边的各个平台的UIController和OperationController应该是我们最终生产的具体产品。所以新建两个抽象产品接口。
抽象操作控制器: interface OperationController { void control(); }
抽象界面控制器: interface UIController { void display(); }
然后完成各个系统平台的具体操作控制器和界面控制器。
Android: class AndroidOperationController implements OperationController { @Override public void control() { System.out.println("AndroidOperationController"); } } class AndroidUIController implements UIController { @Override public void display() { System.out.println("AndroidInterfaceController"); } }
IOS: class IosOperationController implements OperationController { @Override public void control() { System.out.println("IosOperationController"); } } class IosUIController implements UIController { @Override public void display() { System.out.println("IosInterfaceController"); } }
WP: class WpOperationController implements OperationController { @Override public void control() { System.out.println("WpOperationController"); } } class WpUIController implements UIController { @Override public void display() { System.out.println("WpInterfaceController"); } }
下面定义一个抽闲工厂,该工厂需要可以创建OperationController和UIController。 public interface SystemFactory { public OperationController createOperationController(); public UIController createInterfaceController(); }
在各平台具体的工厂类中完成操作控制器和界面控制器的创建过程。
Android: public class AndroidFactory implements SystemFactory { @Override public OperationController createOperationController() { return new AndroidOperationController(); } @Override public UIController createInterfaceController() { return new AndroidUIController(); } }
IOS: public class IosFactory implements SystemFactory { @Override public OperationController createOperationController() { return new IosOperationController(); } @Override public UIController createInterfaceController() { return new IosUIController(); } }
WP: public class WpFactory implements SystemFactory { @Override public OperationController createOperationController() { return new WpOperationController(); } @Override public UIController createInterfaceController() { return new WpUIController(); } }
下面总结一下抽象工厂的适用场景:
和工厂方法一样客户端不需要知道它所创建的对象的类。
需要一组对象共同完成某种功能时。并且可能存在多组对象完成不同功能的情况。
系统结构稳定,不会频繁的增加对象。
1.6 简单工厂模式和抽象工厂模式有什么区别?
参考答案
简单工厂模式其实并不算是一种设计模式,更多的时候是一种编程习惯。简单工厂的实现思路是,定义一个工厂类,根据传入的参数不同返回不同的实例,被创建的实例具有共同的父类或接口。
工厂方法模式是简单工厂的仅一步深化, 在工厂方法模式中,我们不再提供一个统一的工厂类来创建所有的对象,而是针对不同的对象提供不同的工厂。也就是说每个对象都有一个与之对应的工厂。工厂方法的实现思路是,定义一个用于创建对象的接口,让子类决定将哪一个类实例化。工厂方法模式让一个类的实例化延迟到其子类。
抽象工厂模式是工厂方法的仅一步深化,在这个模式中的工厂类不单单可以创建一个对象,而是可以创建一组对象。这是和工厂方法最大的不同点。抽象工厂的实现思路是,提供一个创建一系列相关或相互依赖对象的接口,而无须指定它们具体的类。
1.7 如何实现工厂模式?
参考答案
简单工厂
示例:
创建一个可以绘制不同形状的绘图工具,可以绘制圆形,正方形,三角形,每个图形都会有一个draw()方法用于绘图,不看代码先考虑一下如何通过该模式设计完成此功能。
由题可知圆形,正方形,三角形都属于一种图形,并且都具有draw方法,所以首先可以定义一个接口或者抽象类,作为这三个图像的公共父类,并在其中声明一个公共的draw方法: public interface Shape { void draw(); }
下面就是编写具体的图形,每种图形都实现Shape接口: // 圆形 class CircleShape implements Shape { public CircleShape() { System.out.println("CircleShape: created"); } @Override public void draw() { System.out.println("draw: CircleShape"); } } // 正方形 class RectShape implements Shape { public RectShape() { System.out.println("RectShape: created"); } @Override public void draw() { System.out.println("draw: RectShape"); } } // 三角形 public class TriangleShape implements Shape { public TriangleShape() { System.out.println("TriangleShape: created"); } @Override public void draw() { System.out.println("draw: TriangleShape"); } }
下面是工厂类的具体实现: class ShapeFactory { public static Shape getShape(String type) { Shape shape = null; if (type.equalsIgnoreCase("circle")) { shape = new CircleShape(); } else if (type.equalsIgnoreCase("rect")) { shape = new RectShape(); } else if (type.equalsIgnoreCase("triangle")) { shape = new TriangleShape(); } return shape; } }
为工厂类传入不同的type可以new不同的形状,返回结果为Shape 类型,这个就是简单工厂核心的地方了。
工厂方法
示例:
现在需要设计一个这样的图片加载类,它具有多个图片加载器,用来加载jpg,png,gif格式的图片,每个加载器都有一个read()方法,用于读取图片。下面我们完成这个图片加载类。
首先完成图片加载器的设计,编写一个加载器的公共接口: public interface Reader { void read(); }
然后完成各个图片加载器的代码: // jpg图片加载器 class JpgReader implements Reader { @Override public void read() { System.out.print("read jpg"); } } // png图片加载器 class PngReader implements Reader { @Override public void read() { System.out.print("read png"); } } // gif图片加载器 class GifReader implements Reader { @Override public void read() { System.out.print("read gif"); } }
现在我们按照定义所说定义一个抽象的工厂接口ReaderFactory: interface ReaderFactory { Reader getReader(); }
里面有一个getReader()方法返回我们的Reader 类,接下来我们把上面定义好的每个图片加载器都提供一个工厂类,这些工厂类实现了ReaderFactory 。 // jpg加载器工厂 class JpgReaderFactory implements ReaderFactory { @Override public Reader getReader() { return new JpgReader(); } } // png加载器工厂 class PngReaderFactory implements ReaderFactory { @Override public Reader getReader() { return new PngReader(); } } // gif加载器工厂 class GifReaderFactory implements ReaderFactory { @Override public Reader getReader() { return new GifReader(); } }
在每个工厂类中我们都通过重写的getReader()方法返回各自的图片加载器对象。
抽象工厂
示例:
现在需要做一款跨平台的游戏,需要兼容Android,Ios,Wp三个移动操作系统,该游戏针对每个系统都设计了一套操作控制器(OperationController)和界面控制器(UIController),下面通过抽闲工厂方式完成这款游戏的架构设计。
由题可知,游戏里边的各个平台的UIController和OperationController应该是我们最终生产的具体产品。所以新建两个抽象产品接口。
抽象操作控制器: interface OperationController { void control(); }
抽象界面控制器: interface UIController { void display(); }
然后完成各个系统平台的具体操作控制器和界面控制器。
Android: class AndroidOperationController implements OperationController { @Override public void control() { System.out.println("AndroidOperationController"); } } class AndroidUIController implements UIController { @Override public void display() { System.out.println("AndroidInterfaceController"); } }
IOS: class IosOperationController implements OperationController { @Override public void control() { System.out.println("IosOperationController"); } } class IosUIController implements UIController { @Override public void display() { System.out.println("IosInterfaceController"); } }
WP: class WpOperationController implements OperationController { @Override public void control() { System.out.println("WpOperationController"); } } class WpUIController implements UIController { @Override public void display() { System.out.println("WpInterfaceController"); } }
下面定义一个抽闲工厂,该工厂需要可以创建OperationController和UIController。 public interface SystemFactory { public OperationController createOperationController(); public UIController createInterfaceController(); }
在各平台具体的工厂类中完成操作控制器和界面控制器的创建过程。
Android: public class AndroidFactory implements SystemFactory { @Override public OperationController createOperationController() { return new AndroidOperationController(); } @Override public UIController createInterfaceController() { return new AndroidUIController(); } }
IOS: public class IosFactory implements SystemFactory { @Override public OperationController createOperationController() { return new IosOperationController(); } @Override public UIController createInterfaceController() { return new IosUIController(); } }
WP: public class WpFactory implements SystemFactory { @Override public OperationController createOperationController() { return new WpOperationController(); } @Override public UIController createInterfaceController() { return new WpUIController(); } }
1.8 说一说你策略模式的理解
参考答案
策略模式(Strategy Pattern)也叫政策模式,是一种比较简单的模式。它的目的是定义一组算法,将每个算法都封装起来,并且使它们之间可以互换。其用意是针对一组算法,将每一个算法封装到具有共同接口的独立的类中,从而使得它们可以相互替换,使得算法可以在不影响到客户端的情况下发生变化。
策略模式的通用类图如下图所示:
策略模式涉及以下3个角色:
环境(Context)角色:该角色也叫上下文角色,起到承上启下的作用,屏蔽高层模块对策略、算法的直接访问,它持有一个Strategy类的引用。
抽象策略(Strategy)角色:该角色对策略、算法进行抽象,通常定义每个策略或算法必须具有的方法和属性。
具体策略(Concrete Strategy)角色:该角色实现抽象策略中的具体操作,含有具体的算法。
抽象策略Strategy的代码如下所示: public abstract class Strategy { public abstract void strategyInterface(); }
具体策略ConcreteStrategy的代码如下所示: public class ConcreteStrategy extends Strategy { public void strategyInterface() { ... } }
环境角色Context的代码如下所示: public class Context { private Strategy strategy = null; public Context(Strategy strategy) { this.strategy = strategy; } public void contextInterface() { this.strategy.strategyInterface(); } }
策略模式包括如下优点:
策略模式提供了管理相关的算法族的办法。策略类的等级结构定义了一个算法或行为族,恰当地使用继承可以把公共的代码移到父类中,从而避免代码重复。
策略模式提供了可以替换继承关系的办法。继承可以处理多种算法或行为,如果不用策略模式,那么使用算法或行为的环境类就可能会有一些子类,每一个子类提供一个不同的算法或行为。但是,这样算法或行为的使用者就和算法本身混在一起,从而不可能再独立演化。
使用策略模式可以避免使用多重条件转移语句。多重转移语句不易维护,它把采取哪一种算法或采取哪一种行为的逻辑与算法或行为的逻辑混合在一起,统统列在一个多重转移语句里面,这比使用继承的办法还要原始和落后。
策略模式包括如下缺点:
客户端必须知道所有的策略类,并自行决定使用哪一个策略类。这就意味着客户端必须理解这些算法的区别,以便适时选择恰当的算法类,即策略模式只适用于客户端知道所有的算法或行为的情况。
策略模式造成很多的策略类。有时候可以通过把依赖于环境的状态保持到客户端里面,而将策略类设计成可共享的,这样策略类实例可以被不同客户端使用。可以使用享元模式来减少对象的数量。
策略模式有如下几个应用场景:
多个类只是在算法或行为上稍有不同的场景。
算法需要自由切换的场景。
需要屏蔽算法规则的场景。
1.9 说一说你对观察者模式的了解
参考答案
观察者模式(Observer Pattern)也称发布订阅模式,它的目的是定义对象间一种一对多的依赖关系,使得每当一个对象改变状态,则所有依赖于它的对象都会得到通知并被自动更新。
观察者模式的类图如下图所:
观察者模式具有以下4个角色:
抽象主题(Subject)角色:该角色又称为“被观察者”,可以增加和删除观察者对象。
抽象观察者(Observer)角色:该角色为所有的具体观察者定义一个接口,在得到主题的通知时更新自己。
具体主题(Concrete Subject)角色:该角色又称为“具体被观察者”,它将有关状态存入具体观察者对象,在具体主题的内部状态改变时,给所有登记过的观察者发出通知。
具体观察者(Concrete Observer)角色:该角色实现抽象观察者所要求的更新接口,以便使自身的状态与主题的状态相协调。
上述类图所涉及的代码如下所示: interface Subject { // 登记一个新的观察者 public void attach(Observer obs); // 删除一个登记过的观察者 public void detach(Observer obs); // 通知所有登记过的观察者对象 public void notifyObserver(); } interface Observer { // 更新方法 public void update(); } class ConcreteSubject implements Subject { private Vector obsVector = new Vector(); // 登记一个新的观察者 public void attach(Observer obs) { obsVector.add(obs); } // 删除一个登记过的观察者 public void detach(Observer obs) { obsVector.remove(obs); } // 通知所有登记过的观察者对象 public void notifyObserver() { for (Observer e : obsVector) { e.update(); } } // 返回观察者集合的Enumeration对象 public Enumeration observers() { return obsVector.elements(); } // 业务方法,改变状态 public void change() { this.notifyObserver(); } } class ConcreteObserver implements Observer { // 实现更新方法 public void update() { System.out.println("收到通知,并进行处理!"); } }
观察者模式具有以下几个优点:
观察者和被观察者之间是抽象耦合。被观察者角色所知道的只是一个具体观察者集合,每一个具体观察者都符合一个抽象观察者的接口。被观察者并不认识任何一个具体的观察者,它只知道它们都有一个共同的接口。由于被观察者和观察者没有紧密的耦合在一起,因此它们可以属于不同的抽象化层次,且都非常容易扩展。
支持广播通信。被观察者会向所有登记过的观察者发出通知,这就是一个触发机制,形成一个触发链。
观察模式的缺点如下:
如果一个主题有多个直接或间接的观察者,则通知所有的观察者会花费很多时间,且开发和调试都比较复杂。
如果在主题之间有循环依赖,被观察者会触发它们之间进行循环调用,导致系统崩溃。在使用观察者模式时要特别注意这一点。
如果对观察者的通知是通过另外的线程进行异步投递,系统必须保证投递的顺序执行。
虽然观察者模式可以随时使观察者知道所观察的对象发生了变化,但是观察者模式没有提供相应的机制使观察者知道所观察的对象是如何发生变化。
观察者模式的应用场景如下:
关联行为场景。
事件多级触发场景。
跨系统的消息交换场景,如消息队列的处理机制。
1.10 说一说你对责任链模式的了解
参考答案
责任链模式(Chain of Responsibility Pattern)是一种常见的行为模式,它的目的是使多个对象都有机会处理请求,从而避免了请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有对象处理它为止。
责任链模式的重点是在“链”上,由一条链去处理相似的请求,在链中决定谁来处理这个请求,并返回相应的结果。责任链模式的类图如下图所示:
责任链模式涉及以下两个角色:
抽象处理者(Handler)角色:该角色对请求进行抽象,并定义一个方法以设定和返回对下一个处理者的引用。
具体处理者(Concrete Handler)角色:该角色接到请求后,可以选择将请求处理掉,或者将请求传给下一个处理者。由于具体处理者持有对下一个处理者的引用,因此,如果需要,具体处理者可以访问下一个处理者。
上述类图所涉及的代码如下所示: abstract class Handler { private Handler successor; public abstract void handleRequest(); public Handler getSuccessor() { return successor; } public void setSuccessor(Handler successor) { this.successor = successor; } } class ConcreteHandler extends Handler { // 处理请求 public void handleRequest() { if (getSuccessor() != null) { System.out.println("请求传递给" + getSuccessor()); getSuccessor().handleRequest(); } else { System.out.println("请求处理"); } } }
责任链模式的优点如下:
责任链模式将请求和处理分开,请求者不知道是谁处理的,处理者可以不用知道请求的全貌。
提高系统的灵活性。
责任链模式的缺点如下:
降低程序的性能,每个请求都是从链头遍历到链尾,当链比较长的时候,性能会大幅下降。
不易于调试,由于采用了类似递归的方式,调试的时候逻辑比较复杂。
责任链模式的应用场景如下:
一个请求需要一系列的处理工作。
业务流的处理,例如,文件审批。
对系统进行补充扩展。
1.11 说一说装饰器模式和适配器模式的区别
参考答案
装饰器的目的是动态地给一个对象添加一些额外的职责,这个对象的类型不会发生变化,但是行为却发生了改变。
适配器的目的是将一个类的接口变换成客户端所期待的另一种接口,就是可以将一个对象包装成另外的一个接口。
1.12 Spring框架中用到了哪些设计模式?
Spring框架在实现时运用了大量的设计模式,常见的有如下几种:
简单工厂 Spring中的BeanFactory就是简单工厂模式的体现,根据传入一个唯一的标识来获得Bean对象,但是否是在传入参数后创建还是传入参数前创建这个要根据具体情况来定。
工厂方法 实现了FactoryBean接口的bean是一类叫做factory的bean。其特点是,spring会在使用getBean()调用获得该bean时,会自动调用该bean的getObject()方法,所以返回的不是factory这个bean,而是这个bean的getOjbect()方法的返回值。
单例模式 Spring依赖注入Bean实例默认是单例的。Spring的依赖注入(包括lazy-init方式)都是发生在AbstractBeanFactory的getBean里。getBean的doGetBean方法调用getSingleton进行bean的创建。
适配器模式 SpringMVC中的适配器HandlerAdatper,它会根据Handler规则执行不同的Handler。即DispatcherServlet根据HandlerMapping返回的handler,向HandlerAdatper发起请求处理Handler。HandlerAdapter根据规则找到对应的Handler并让其执行,执行完毕后Handler会向HandlerAdapter返回一个ModelAndView,最后由HandlerAdapter向DispatchServelet返回一个ModelAndView。
装饰器模式 Spring中用到的装饰器模式在类名上有两种表现:一种是类名中含有Wrapper,另一种是类名中含有Decorator。
代理模式 AOP底层就是动态代理模式的实现。即:切面在应用运行的时刻被织入。一般情况下,在织入切面时,AOP容器会为目标对象创建动态的创建一个代理对象。SpringAOP就是以这种方式织入切面的。
观察者模式 Spring的事件驱动模型使用的是观察者模式,Spring中Observer模式常用的地方是listener的实现。
策略模式 Spring框架的资源访问Resource接口。该接口提供了更强的资源访问能力,Spring 框架本身大量使用了 Resource 接口来访问底层资源。Resource 接口是具体资源访问策略的抽象,也是所有资源访问类所实现的接口。
模板方法模式 Spring模板方法模式的实质,是模板方法模式和回调模式的结合,是Template Method不需要继承的另一种实现方式。Spring几乎所有的外接扩展都采用这种模式。
1. 场景应用
1.1 微信红包相关问题
参考答案
概况:2014年微信红包使用数据库硬抗整个流量,2015年使用cache抗流量。
微信的金额什么时候算?
微信红包的金额是拆的时候实时算出来,不是预先分配的,采用的是纯内存计算,不需要预算空间存储。采取实时计算金额的考虑,是因为实时效率很高,而预算需要占存储,预算空间效率低。
为什么明明抢到红包,点开后发现没有?
2014年的红包一点开就知道金额,分两次操作,先抢到金额,然后再转账。2015年的红包的拆和抢是分离的,需要点两次,因此会出现抢到红包了,但点开后告知红包已经被领完的状况。进入到第一个页面不代表抢到,只表示当时红包还有。
红包里的金额怎么算?为什么出现各个红包金额相差很大?
随机,额度在0.01和剩余平均值*2之间。
例如:发100块钱,总共10个红包,那么平均值是10块钱一个,那么发出来的红包的额度在0.01元~20元之间波动。当前面3个红包总共被领了40块钱时,剩下60块钱,总共7个红包,那么这7个红包的额度在:0.01~(60/7*2)=17.14之间。
注意:这里的算法是每被抢一个后,剩下的会再次执行上面的这样的算法。这样算下去,会超过最开始的全部金额,因此到了最后面如果不够这么算,那么会采取如下算法:保证剩余用户能拿到最低1分钱即可。如果前面的人手气不好,那么后面的余额越多,红包额度也就越多,因此实际概率一样的。
红包的设计
微信从财付通拉取金额数据过来,生成个数/红包类型/金额放到redis集群里,app端将红包ID的请求放入请求队列中,如果发现超过红包的个数,直接返回。根据红包的逻辑处理成功得到令牌请求,则由财付通进行一致性调用,通过像比特币一样,两边保存交易记录,交易后交给第三方服务审计,如果交易过程中出现不一致就强制回归。
红包如何计算被抢完?
cache会抵抗无效请求,将无效的请求过滤掉,实际进入到后台的量不大。cache记录红包个数,原子操作进行个数递减,到0表示被抢光。财付通按照20万笔每秒入账准备,但实际还不到8万每秒。
通如何保持8w每秒的写入?
多主sharding,水平扩展机器。
据容量多少?
一个红包只占一条记录,有效期只有几天,因此不需要太多空间。
查询红包分配,压力大不?
抢到红包的人数和红包都在一条cache记录上,没有太大的查询压力。
一个红包一个队列?
没有队列,一个红包一条数据,数据上有一个计数器字段。
有没有从数据上证明每个红包的概率是不是均等?
不是绝对均等,就是一个简单的拍脑袋算法。
拍脑袋算法,会不会出现两个最佳?
会出现金额一样的,但是手气最佳只有一个,先抢到的那个最佳。
每领一个红包就更新数据么?
每抢到一个红包,就cas更新剩余金额和红包个数。
红包如何入库入账?
数据库会累加已经领取的个数与金额,插入一条领取记录,入账则是后台异步操作。
入帐出错怎么办?比如红包个数没了,但余额还有?
最后会有一个take all操作,另外还有一个对账来保障。
1.2 秒杀系统相关问题
参考答案
秒杀应该考虑哪些问题?
超卖问题 分析秒杀的业务场景,最重要的有一点就是超卖问题,假如备货只有100个,但是最终超卖了200,一般来讲秒杀系统的价格都比较低,如果超卖将严重影响公司的财产利益,因此首当其冲的就是解决商品的超卖问题。
高并发 秒杀具有时间短、并发量大的特点,秒杀持续时间只有几分钟,而一般公司都为了制造轰动效应,会以极低的价格来吸引用户,因此参与抢购的用户会非常的多。短时间内会有大量请求涌进来,后端如何防止并发过高造成缓存击穿或者失效,击垮数据库都是需要考虑的问题。
接口防刷 现在的秒杀大多都会出来针对秒杀对应的软件,这类软件会模拟不断向后台服务器发起请求,一秒几百次都是很常见的,如何防止这类软件的重复无效请求,防止不断发起的请求也是需要我们针对性考虑的。
秒杀URL 对于普通用户来讲,看到的只是一个比较简单的秒杀页面,在未达到规定时间,秒杀按钮是灰色的,一旦到达规定时间,灰色按钮变成可点击状态。这部分是针对小白用户的,如果是稍微有点电脑功底的用户,会通过F12看浏览器的network看到秒杀的url,通过特定软件去请求也可以实现秒杀。或者提前知道秒杀url的人,一请求就直接实现秒杀了。这个问题我们需要考虑解决。
数据库设计 秒杀有把我们服务器击垮的风险,如果让它与我们的其他业务使用在同一个数据库中,耦合在一起,就很有可能牵连和影响其他的业务。如何防止这类问题发生,就算秒杀发生了宕机、服务器卡死问题,也应该让他尽量不影响线上正常进行的业务。
秒杀系统的设计方案
秒杀系统的数据库设计 针对秒杀的数据库问题,应该单独设计一个秒杀数据库,防止因为秒杀活动的高并发访问拖垮整个网站。这里只需要两张表,一张是秒杀商品表,一张是秒杀库存表: 订单表(item): 库存表(item_stock): 其实应该还有几张表,订单表:可以查到具体的下单时间、订单单价、订单数量、下单金额、参与活动等,还有用户表:根据用户user_id可以查询到用户昵称、用户手机号,收货地址等其他额外信息,这个具体就不给出实例了。
秒杀URL的设计 为了避免有程序访问经验的人通过下单页面url直接访问后台接口来秒杀货品,我们需要将秒杀的url实现动态化,即使是开发整个系统的人都无法在秒杀开始前知道秒杀的url。具体的做法就是通过md5加密一串随机字符作为秒杀的url,然后前端访问后台获取具体的url,后台校验通过之后才可以继续秒杀。
秒杀页面静态化 将商品的描述、参数、成交记录、图像、评价等全部写入到一个静态页面,用户请求不需要通过访问后端服务器,不需要经过数据库,直接在前台客户端生成,这样可以最大可能的减少服务器的压力。具体的方法可以使用freemarker模板技术,建立网页模板,填充数据,然后渲染网页。
单体redis升级为集群redis 秒杀是一个读多写少的场景,使用redis做缓存再合适不过。不过考虑到缓存击穿问题,我们应该构建redis集群,或采用哨兵模式,可以提升redis的性能和可用性。
使用nginx nginx是一个高性能web服务器,它的并发能力可以达到几万,而tomcat只有几百。通过nginx映射客户端请求,再分发到后台tomcat服务器集群中可以大大提升并发能力。
精简SQL 典型的一个场景是在进行扣减库存的时候,传统的做法是先查询库存,再去update。这样的话需要两个sql,而实际上一个sql我们就可以完成的。可以用这样的做法:update miaosha_goods set stock=stock-1 where goos_id={#goods_id} and version=#{version} and sock>0; 。这样的话,就可以保证库存不会超卖并且一次更新库存,还有注意一点这里使用了版本号的乐观锁,相比较悲观锁,它的性能较好。
redis预减库存 很多请求进来,都需要后台查询库存,这是一个频繁读的场景。可以使用redis来预减库存,在秒杀开始前可以在redis设值,比如 redis.set(goodsId,100),这里预放的库存为100可以设值为常量),每次下单成功之后,Integer stock = (Integer)redis.get(goosId); 然后判断sock的值,如果小于常量值就减去1。不过注意当取消的时候,需要增加库存,增加库存的时候也得注意不能大于之间设定的总库存数(查询库存和扣减库存需要原子操作,此时可以借助lua脚本)下次下单再获取库存的时候,直接从redis里面查就可以了。
接口限流 秒杀最终的本质是数据库的更新,但是有很多大量无效的请求,我们最终要做的就是如何把这些无效的请求过滤掉,防止渗透到数据库。限流的话,需要入手的方面很多:
前端限流:首先第一步就是通过前端限流,用户在秒杀按钮点击以后发起请求,那么在接下来的5秒是无法点击(通过设置按钮为disable)。这一小举措开发起来成本很小,但是很有效。
同一个用户x秒内重复请求直接拒绝:具体多少秒需要根据实际业务和秒杀的人数而定,一般限定为10秒。具体的做法就是通过redis的键过期策略,首先对每个请求都从String value = redis.get(userId);。如果获取到这个value为空或者为null,表示它是有效的请求,然后放行这个请求。如果不为空表示它是重复性请求,直接丢掉这个请求。如果有效,采用redis.setexpire(userId,value,10).value 可以是任意值,一般放业务属性比较好,这个是设置以userId为key,10秒的过期时间(10秒后,key对应的值自动为null)。
令牌桶算法限流:接口限流的策略有很多,我们这里采用令牌桶算法。令牌桶算法的基本思路是每个请求尝试获取一个令牌,后端只处理持有令牌的请求,生产令牌的速度和效率我们都可以自己限定。
异步下单 为了提升下单的效率,并且防止下单服务的失败。需要将下单这一操作进行异步处理。最常采用的办法是使用队列,队列最显著的三个优点:异步、削峰、解耦。这里可以采用rabbitmq,在后台经过了限流、库存校验之后,流入到这一步骤的就是有效请求。然后发送到队列里,队列接受消息,异步下单。下完单,入库没有问题可以用短信通知用户秒杀成功。假如失败的话,可以采用补偿机制,重试。
服务降级 假如在秒杀过程中出现了某个服务器宕机,或者服务不可用,应该做好后备工作。之前的博客里有介绍通过Hystrix进行服务熔断和降级,可以开发一个备用服务,假如服务器真的宕机了,直接给用户一个友好的提示返回,而不是直接卡死,服务器错误等生硬的反馈。
1.3 扫码登录流程
参考答案
什么是二维码?
二维码又称二维条码,常见的二维码为QR Code,QR全称Quick Response,是一个近几年来移动设备上超流行的一种编码方式,它比传统的Bar Code条形码能存更多的信息,也能表示更多的数据类型。-- 百度百科
在商品上,一般都会有条形码,条形码也称为一维码,条形码只能表示一串数字。二维码要比条形码丰富很多,可以存储数字、字符串、图片、文件等,比如我们可以把 www.nowcoder.com 存储在二维码中,扫码二维码我们就可以获取到牛客网的地址。
移动端基于token的认证机制
在了解扫码登录原理之前,有必要先了解移动端基于 token 的认证机制,对理解扫码登录原理还是非常有帮助的。基于 token 的认证机制跟我们常用的账号密码认证方式有较大的不同,安全系数比账号密码要高,如果每次验证都传入账号密码,那么被劫持的概率就变大了。
基于 token 的认证机制流程图,如下图所示:
基于 token 的认证机制,只有在第一次使用需要输入账号密码,后续使用将不在输入账号密码。其实在登陆的时候不仅传入账号、密码,还传入了手机的设备信息。在服务端验证账号、密码正确后,服务端会做两件事。
将账号与设备关联起来,在某种意义上,设备信息就代表着账号。
生成一个 token 令牌,并且在 token 与账号、设备关联,类似于key/value,token 作为 key ,账号、设备信息作为value,持久化在磁盘上。
将 token 返回给移动端,移动端将 token 存入在本地,往后移动端都通过 token 访问服务端 API ,当然除了 token 之外,还需要携带设备信息,因为 token 可能会被劫持。带上设备信息之后,就算 token 被劫持也没有关系,因为设备信息是唯一的。
这就是基于 token 的认证机制,将账号密码换成了 token、设备信息,从而提高了安全系数,可别小看这个 token ,token 是身份凭证,在扫码登录的时候也会用到。
二维码扫码登录的原理
好了,知道了移动端基于 token 的认证机制后,接下来就进入我们的主题:二维码扫码登陆的原理。先上二维码扫码登录的流程图:
扫码登录可以分为三个阶段:待扫描、已扫描待确认、已确认。我们就来看看这三个阶段。
带扫描阶段 待扫描阶段也就是流程图中 1~5 阶段,即生成二维码阶段,这个阶段跟移动端没有关系,是 PC 端跟服务端的交互过程。 首先 PC 端携带设备信息想服务端发起生成二维码请求,服务端会生成唯一的二维码 ID,你可以理解为 UUID,并且将 二维码 ID 跟 PC 设备信息关联起来,这跟移动端登录有点相似。 PC 端接受到二维码 ID 之后,将二维码 ID 以二维码的形式展示,等待移动端扫码。此时在 PC 端会启动一个定时器,轮询查询二维码的状态。如果移动端未扫描的话,那么一段时间后二维码将会失效。
已扫码待确认阶段 流程图中第 6 ~ 10 阶段,我们在 PC 端登录微信时,手机扫码后,PC 端的二维码会变成已扫码,请在手机端确认。这个阶段是移动端跟服务端交互的过程。 首先移动端扫描二维码,获取二维码 ID,然后将手机端登录的信息凭证(token)和 二维码 ID 作为参数发送给服务端,此时的手机一定是登录的,不存在没登录的情况。 服务端接受请求后,会将 token 与二维码 ID 关联,为什么需要关联呢?你想想,我们使用微信时,移动端退出, PC 端是不是也需要退出,这个关联就有点把子作用了。然后会生成一个一次性 token,这个 token 会返回给移动端,一次性 token 用作确认时候的凭证。
然后PC 端的定时器,会轮询到二维码的状态已经发生变化,会将 PC 端的二维码更新为已扫描,请确认。
已确认 流程图中的 第 11 ~ 15 步骤,这是扫码登录的最后阶段,移动端携带上一步骤中获取的临时 token ,确认登录,服务端校对完成后,会更新二维码状态,并且给 PC 端生成一个正式的 token ,后续 PC 端就是持有这个 token 访问服务端。 PC 端的定时器,轮询到了二维码状态为登录状态,并且会获取到了生成的 token ,完成登录,后续访问都基于 token 完成。 在服务器端会跟手机端一样,维护着 token 跟二维码、PC 设备信息、账号等信息。
1.4 如何实现单点登录?
参考答案
单点登录全称Single Sign On(以下简称SSO),是指在多系统应用群中登录一个系统,便可在其他所有系统中得到授权而无需再次登录,包括单点登录与单点注销两部分。
登录
相比于单系统登录,sso需要一个独立的认证中心,只有认证中心能接受用户的用户名密码等安全信息,其他系统不提供登录入口,只接受认证中心的间接授权。间接授权通过令牌实现,sso认证中心验证用户的用户名密码没问题,创建授权令牌,在接下来的跳转过程中,授权令牌作为参数发送给各个子系统,子系统拿到令牌,即得到了授权,可以借此创建局部会话,局部会话登录方式与单系统的登录方式相同。这个过程,也就是单点登录的原理,用下图说明:
下面对上图简要描述:
用户访问系统1的受保护资源,系统1发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数;
sso认证中心发现用户未登录,将用户引导至登录页面;
用户输入用户名密码提交登录申请;
sso认证中心校验用户信息,创建用户与sso认证中心之间的会话,称为全局会话,同时创建授权令牌;
sso认证中心带着令牌跳转会最初的请求地址(系统1);
系统1拿到令牌,去sso认证中心校验令牌是否有效;
sso认证中心校验令牌,返回有效,注册系统1;
系统1使用该令牌创建与用户的会话,称为局部会话,返回受保护资源;
用户访问系统2的受保护资源;
系统2发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数;
sso认证中心发现用户已登录,跳转回系统2的地址,并附上令牌;
系统2拿到令牌,去sso认证中心校验令牌是否有效;
sso认证中心校验令牌,返回有效,注册系统2;
系统2使用该令牌创建与用户的局部会话,返回受保护资源。
用户登录成功之后,会与sso认证中心及各个子系统建立会话,用户与sso认证中心建立的会话称为全局会话,用户与各个子系统建立的会话称为局部会话,局部会话建立之后,用户访问子系统受保护资源将不再通过sso认证中心,全局会话与局部会话有如下约束关系:
局部会话存在,全局会话一定存在;
全局会话存在,局部会话不一定存在;
全局会话销毁,局部会话必须销毁。
注销
单点登录自然也要单点注销,在一个子系统中注销,所有子系统的会话都将被销毁,用下面的图来说明:
sso认证中心一直监听全局会话的状态,一旦全局会话销毁,监听器将通知所有注册系统执行注销操作。下面对上图简要说明:
用户向系统1发起注销请求;
系统1根据用户与系统1建立的会话id拿到令牌,向sso认证中心发起注销请求;
sso认证中心校验令牌有效,销毁全局会话,同时取出所有用此令牌注册的系统地址;
sso认证中心向所有注册系统发起注销请求;
各注册系统接收sso认证中心的注销请求,销毁局部会话;
sso认证中心引导用户至登录页面。
部署
单点登录涉及sso认证中心与众子系统,子系统与sso认证中心需要通信以交换令牌、校验令牌及发起注销请求,因而子系统必须集成sso的客户端,sso认证中心则是sso服务端,整个单点登录过程实质是sso客户端与服务端通信的过程,用下图描述:
sso认证中心与sso客户端通信方式有多种,这里以简单好用的httpClient为例,web service、rpc、restful api都可以。
1.5 如何设计一个本地缓存?
参考答案
想要设计一个本地缓存,考虑点主要在数据用何种方式存储,能存储多少数据,多余的数据如何处理等几个点,下面我们来详细的介绍每个考虑点:
数据结构 首要考虑的就是数据该如何存储,用什么数据结构存储。最简单的就直接用Map来存储数据,或者复杂的如redis一样提供了多种数据类型哈希,列表,集合,有序集合等,底层使用了双端链表,压缩列表,集合,跳跃表等数据结构。
对象上限 因为是本地缓存,内存有上限,所以一般都会指定缓存对象的数量比如1024,当达到某个上限后需要有某种策略去删除多余的数据。
清除策略 上面说到当达到对象上限之后需要有清除策略,常见的比如有LRU(最近最少使用)、FIFO(先进先出)、LFU(最近最不常用)、SOFT(软引用)、WEAK(弱引用)等策略。
过期时间 除了使用清除策略,一般本地缓存也会有一个过期时间设置,比如redis可以给每个key设置一个过期时间,这样当达到过期时间之后直接删除,采用清除策略+过期时间双重保证。
线程安全 像redis是直接使用单线程处理,所以就不存在线程安全问题。而我们现在提供的本地缓存往往是可以多个线程同时访问的,所以线程安全是不容忽视的问题,并且线程安全问题是不应该抛给使用者去保证。
简明的接口 提供一个傻瓜式的对外接口是很有必要的,对使用者来说使用此缓存不是一种负担而是一种享受,提供常用的get,put,remove,clear,getSize等方法即可。
是否持久化 这个其实不是必须的,是否需要将缓存数据持久化看需求。本地缓存如ehcache是支持持久化的,而guava是没有持久化功能的。分布式缓存如redis是有持久化功能的,memcached是没有持久化功能的。
阻塞机制 我们使用缓存的目的就是因为被缓存的数据生成比较费时,比如调用对外的接口,查询数据库,计算量很大的结果等等。这时候如果多个线程同时调用get方法获取的结果都为null,每个线程都去执行一遍费时的计算,其实也是对资源的浪费。最好的办法是只有一个线程去执行,其他线程等待,计算一次就够了。但是此功能基本上都交给使用者来处理,很少有本地缓存有这种功能。
● 请你讲讲UML中有哪些常用的图?
考察点:用例图
参考回答:
UML定义了多种图形化的符号来描述软件系统部分或全部的静态结构和动态结构,包括:用例图(use case diagram)、类图(class diagram)、时序图(sequence diagram)、协作图(collaboration diagram)、状态图(statechart diagram)、活动图(activity diagram)、构件图(component diagram)、部署图(deployment diagram)等。在这些图形化符号中,有三种图最为重要,分别是:用例图(用来捕获需求,描述系统的功能,通过该图可以迅速的了解系统的功能模块及其关系)、类图(描述类以及类与类之间的关系,通过该图可以快速了解系统)、时序图(描述执行特定任务时对象之间的交互关系以及执行顺序,通过该图可以了解对象能接收的消息也就是说对象能够向外界提供的服务)。