一、沉默王二-JVM-JVM 核心知识点
1、垃圾收集器
并行与并发是并发编程中的专有名词,在谈论垃圾收集器的上下文语境中,它们的含义如下:
①、并行 (Parallel) :并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,此时通常默认用户线程是处于等待状态。
②、并发 (Concurrent) :并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。但由于垃圾收集器线程会占用一部分系统资源,所以程序的吞吐量依然会受到一定影响。
HotSpot 中一共存在七款经典的垃圾收集器:
注:收集器之间存在连线,代表它们可以搭配使用。
1.1 Serial 收集器
Serial 收集器是最基础、历史最悠久的收集器,它是一个单线程收集器,在进行垃圾回收时,必须暂停其他所有的工作线程,直到收集结束,这是其主要缺点。
它的优点在于单线程避免了多线程复杂的上下文切换,因此在单线程环境下收集效率非常高,由于这个优点,迄今为止,其仍然是 HotSpot 虚拟机在客户端模式下默认的新生代收集器:
1.2 ParNew 收集器
它是 Serial 收集器的多线程版本,可以使用多条线程进行垃圾回收:
1.3 Parallel Scavenge 收集器
Parallel Scavenge 也是新生代收集器,基于 标记-复制 算法进行实现,它的目标是达到一个可控的吞吐量。这里的吞吐量指的是处理器运行用户代码的时间与处理器总消耗时间的比值:
吞吐量 = 运行用户代码时间 \ (运行用户代码时间 + 运行垃圾收集时间)
Parallel Scavenge 收集器提供两个参数用于精确控制吞吐量:
①、-XX:MaxGCPauseMillis:控制最大垃圾收集时间,假设需要回收的垃圾总量不变,那么降低垃圾收集的时间就会导致收集频率变高,所以需要将其设置为合适的值,不能一味减小。
②、-XX:MaxGCTimeRatio:直接用于设置吞吐量大小,它是一个大于 0 小于 100 的整数。假设把它设置为 19,表示此时允许的最大垃圾收集时间占总时间的 5%(即 1/(1+19) );默认值为 99 ,即允许最大 1%( 1/(1+99) )的垃圾收集时间。
1.4 Serial Old 收集器
从名字也能看出来,它是 Serial 收集器的老年代版本,同样是一个单线程收集器,采用 标记-整理 算法,主要用于给客户端模式下的 HotSpot 使用:
1.5 Paralled Old 收集器
Paralled Old 是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,采用 标记-整理 算法实现:
1.6 CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,基于 标记-清除 算法实现,整个收集过程分为以下四个阶段:
- 初始标记 (inital mark) :标记
GC Roots能直接关联到的对象,耗时短但需要暂停用户线程; - 并发标记 (concurrent mark) :从
GC Roots能直接关联到的对象开始遍历整个对象图,耗时长但不需要暂停用户线程; - 重新标记 (remark) :采用增量更新算法,对并发标记阶段因为用户线程运行而产生变动的那部分对象进行重新标记,耗时比初始标记稍长且需要暂停用户线程;
- 并发清除 (inital sweep) :并发清除掉已经死亡的对象,耗时长但不需要暂停用户线程。
其优点在于耗时长的 并发标记 和 并发清除 阶段都不需要暂停用户线程,因此其停顿时间较短,其主要缺点如下:
- 由于涉及并发操作,因此对处理器资源比较敏感。
- 由于是基于 标记-清除 算法实现的,因此会产生大量空间碎片。
- 无法处理浮动垃圾(Floating Garbage):由于并发清除时用户线程还是在继续,所以此时仍然会产生垃圾,这些垃圾就被称为浮动垃圾,只能等到下一次垃圾收集时再进行清理。
1.7 Garbage First 收集器
Garbage First(简称 G1)是一款面向服务端的垃圾收集器,也是 JDK 9 服务端模式下默认的垃圾收集器,它的诞生具有里程碑式的意义。
G1 虽然也遵循分代收集理论,但不再以固定大小和固定数量来划分分代区域,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region)。每一个 Region 都可以根据不同的需求来扮演新生代的 Eden 空间、Survivor 空间或者老年代空间,收集器会根据其扮演角色的不同而采用不同的收集策略。
上面还有一些 Region 使用 H 进行标注,它代表 Humongous,表示这些 Region 用于存储大对象(humongous object,H-obj),即大小大于等于 region 一半的对象。
G1 收集器的运行大致可以分为以下四个步骤:
①、初始标记 (Inital Marking) :标记 GC Roots 能直接关联到的对象,并且修改 TAMS(Top at Mark Start)指针的值,让下一阶段用户线程并发运行时,能够正确的在 Reigin 中分配新对象。
G1 为每一个 Reigin 都设计了两个名为 TAMS 的指针,新分配的对象必须位于这两个指针位置以上,位于这两个指针位置以上的对象默认被隐式标记为存活的,不会纳入回收范围;
②、并发标记 (Concurrent Marking) :从 GC Roots 能直接关联到的对象开始遍历整个对象图。遍历完成后,还需要处理 SATB 记录中变动的对象。
SATB(snapshot-at-the-beginning,开始阶段快照)能够有效的解决并发标记阶段因为用户线程运行而导致的对象变动,其效率比 CMS 重新标记阶段所使用的增量更新算法效率更高;
③、最终标记 (Final Marking) :对用户线程做一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的少量的 STAB 记录。虽然并发标记阶段会处理 SATB 记录,但由于处理时用户线程依然是运行中的,因此依然会有少量的变动,所以需要最终标记来处理;
④、筛选回收 (Live Data Counting and Evacuation) :负责更新 Regin 统计数据,按照各个 Regin 的回收价值和成本进行排序,在根据用户期望的停顿时间进行来指定回收计划,可以选择任意多个 Regin 构成回收集。
然后将回收集中 Regin 的存活对象复制到空的 Regin 中,再清理掉整个旧的 Regin 。此时因为涉及到存活对象的移动,所以需要暂停用户线程,并由多个收集线程并行执行。
1.8 内存分配原则
1.8.1 对象优先在 Eden 分配
大多数情况下,对象在新生代的 Eden 区中进行分配,当 Eden 区没有足够空间时,虚拟机将进行一次 Minor GC。
1.8.2 大对象直接进入老年代
大对象就是指需要大量连续内存空间的 Java 对象,最典型的就是超长的字符串或者元素数量很多的数组,它们将直接进入老年代。
主要是因为如果在新生代分配,因为其需要大量连续的内存空间,可能会导致提前触发垃圾回收;并且由于新生代的垃圾回收本身就很频繁,此时复制大对象也需要额外的性能开销。
1.8.3 长期存活的对象将进入老年代
虚拟机会给每个对象在其对象头中定义一个年龄计数器。对象通常在 Eden 区中诞生,如果经历第一次 Minor GC 后仍然存活,并且能够被 Survivor 容纳的话,该对象就会被移动到 Survivor 中,并将其年龄加 1。
对象在 Survivor 中每经过一次 Minor GC,年龄就加 1,当年龄达到一定程度后(由 -XX:MaxTenuringThreshold 设置,默认值为 15)就会进入老年代中。
1.8.4 动态年龄判断
如果在 Survivor 空间中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,那么年龄大于或等于该年龄的对象就可以直接进入老年代,而无需等待年龄到达 -XX:MaxTenuringThreshold 设置的值。
1.8.5 空间担保分配
在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果条件成立,那么这一次的 Minor GC 可以确认是安全的。
如果不成立,虚拟机会查看 -XX:HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于或者 -XX:HandlePromotionFailure 的值设置不允许冒险,那么就要改为进行一次 Full GC 。
2、类加载机制
Java 虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程被称为虚拟机的类加载机制。
2.1 类加载时机
一个类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载、验证、准备、卸载、解析、初始化、使用、卸载七个阶段,其中验证、准备、解析三个部分统称为连接:
《Java 虚拟机规范》严格规定了有且只有六种情况必须立即对类进行初始化:
①、遇到 new、 getstatic、 putstatic、 invokestatic 这四条字节码指令,能够生成这四条指令码的典型 Java 代码场景有:
- 使用
new关键字实例化对象时; - 读取或设置一个类型的静态字段时(被 final 修饰,已在编译期把结果放入常量池的静态字段除外);
- 调用一个类的静态方法时。
②、使用 java.lang.reflect 包的方法对 Class 进行反射调用时,如果类型没有进行过初始化、则需要触发其初始化;
③、当初始化类时,如发现其父类还没有进行过初始化、则需要触发其父类进行初始化;
④、当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类;
⑤、当使用 JDK 7 新加入的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后解析的结果为 REF_getStatic , REF_putStatic , REF_invokeStatic , REF_newInvokeSpecial 四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化;
⑥、当一个接口中定义了 JDK 8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那么该接口要在其之前被初始化。
2.2 类加载过程
2.2.1 加载
在加载阶段,虚拟机需要完成以下三件事:
- 通过一个类的全限定名来获取定义此类的二进制字节流 ;
- 将这个字节流所代表的静态存储结构转换为运行时数据结构;
- 在内存中生成一个代表这个类的
java.lang.Class对象,作为这个类的各种数据的访问入口。
《Java 虚拟机规范》并没有限制从何处获取二进制流,因此可以从 JAR 包、WAR 包获取,也可以从 JSP 生成的 Class 文件等处获取。
2.2.2 验证
这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,从而保证这些信息被当做代码运行后不会危害虚拟机自身的安全。
验证阶段大致会完成下面四项验证:
- 文件格式验证:验证字节流是否符合 Class 文件格式的规范;
- 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java 语言规范》的要求(如除了
java.lang.Object外,所有的类都应该有父类); - 字节码验证:通过数据流分析和控制流分析,确定程序语义是合法的,符合逻辑的(如允许把子类对象赋值给父类数据类型,但不能把父类对象赋值给子类数据类型);
- 符号引用验证:验证类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。如果无法验证通过,则会抛出一个
java.lang.IncompatibleClassChangeError的子类异常,如java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
2.2.3 准备
准备阶段是正式为类中定义的变量(即静态变量,被 static 修饰的变量)分配内存并设置类变量初始值的阶段。
2.2.4 解析
解析是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程:
- 符号引用:符号引用用一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
- 直接引用:直接引用是指可以直接指向目标的指针、相对偏移量或者一个能间接定位到目标的句柄。
整个解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这 7 类符号引用进行解析。
2.2.5 初始化
初始化阶段就是执行类构造器的 <clinit>() 方法的过程,该方法具有以下特点:
<clinit>()方法由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生,编译器收集顺序由语句在源文件中出现的顺序决定。<clinit>()方法与类的构造方法(即在虚拟机视角中的实例构造器<init>()方法)不同,它不需要显示的调用父类的构造器,Java 虚拟机会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。- 由于父类的
<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类变量的赋值操作。 <clinit>()方法对于类或者接口不是必须的,如果一个类中没有静态语句块,也没有对变量进行赋值操作,那么编译器可以不为这个类生成<clinit>()方法。- 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成
<clinit>()方法。 - Java 虚拟机必须保证一个类的
<clinit>()方法在多线程环境中被正确的加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待。
2.2.6 类加载器
能够通过一个类的全限定名来获取描述该类的二进制字节流的工具称为类加载器。
每一个类加载器都拥有一个独立的类名空间,因此对于任意一个类,都必须由加载它的类加载器和这个类本身来共同确立其在 Java 虚拟机中的唯一性。
这意味着要想比较两个类是否相等,必须在同一类加载器加载的前提下;如果两个类的类加载器不同,则它们一定不相等。
2.2.7双亲委派模型
从 Java 虚拟机角度而言,类加载器可以分为以下两类:
- 启动类加载器 :启动类加载器(Bootstrap ClassLoader)由 C++ 语言实现(以 HotSpot 为例),它是虚拟机自身的一部分;
- 其他所有类的类加载器 :由 Java 语言实现,独立存在于虚拟机外部,并且全部继承自
java.lang.ClassLoader。
从开发人员角度而言,类加载器可以分为以下三类:
- 启动类加载器 (Boostrap Class Loader) :负责把存放在
<JAVA_HOME>\lib目录中,或被-Xbootclasspath参数所指定的路径中存放的能被 Java 虚拟机识别的类库加载到虚拟机的内存中; - 扩展类加载器 (Extension Class Loader) :负责加载
<JAVA_HOME>\lib\ext目录中,或被java.ext.dirs系统变量所指定的路径中的所有类库。 - 应用程序类加载器 (Application Class Loader) :负责加载用户类路径(ClassPath)上的所有的类库。
JDK 9 之前的 Java 应用都是由这三种类加载器相互配合来完成加载:
上图所示的各种类加载器之间的层次关系被称为类加载器的 “双亲委派模型”,“双亲委派模型” 要求除了顶层的启动类加载器外,其余的类加载器都应该有自己的父类加载器,需要注意的是这里的加载器之间的父子关系一般不是以继承关系来实现的,而是使用组合关系来复用父类加载器的代码。
双亲委派模型的工作过程如下:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
基于双亲委派模型可以保证程序中的类在各种类加载器环境中都是同一个类,否则就有可能出现一个程序中存在两个不同的 java.lang.Object 的情况。
2.2.8 模块化下的类加载器
JDK 9 之后为了适应模块化的发展,类加载器做了如下变化:
- 仍维持三层类加载器和双亲委派的架构,但扩展类加载器被平台类加载器所取代;
- 当平台及应用程序类加载器收到类加载请求时,要首先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载;
- 启动类加载器、平台类加载器、应用程序类加载器全部继承自
java.internal.loader.BuiltinClassLoader,BuiltinClassLoader 中实现了新的模块化架构下类如何从模块中加载的逻辑,以及模块中资源可访问性的处理。
3、程序编译
3.1 编译器分类
- 前端编译器:把
*.java文件转变成.class文件的过程;如 JDK 的 Javac,Eclipse JDT 中的增量式编译器。 - 即时编译器:常称为 JIT 编译器(Just In Time Complier),在运行期把字节码转变成本地机器码的过程;如 HotSpot 虚拟机中的 C1、C2 编译器,Graal 编译器。
- 提前编译器:直接把程序编译成目标机器指令集相关的二进制代码的过程。如 JDK 的 jaotc,GUN Compiler for the Java(GCJ),Excelsior JET 。
3.2 解释器与编译器
在 HotSpot 中,Java 程序最初都是通过解释器(Interpreter)进行解释执行的,其优点在于可以省去编译时间,让程序快速启动。
当程序启动后,如果虚拟机发现某个方法或代码块的运行特别频繁,就会使用编译器将其编译为本地机器码,并使用各种手段进行优化,从而提高执行效率,这就是即时编译器。
HotSpot 内置了两个(或三个)即时编译器:
- 客户端编译器 (Client Complier) :简称 C1;
- 服务端编译器 (Servier Complier) :简称 C2,在有的资料和 JDK 源码中也称为 Opto 编译器;
- Graal 编译器 :在 JDK 10 时才出现,长期目标是替代 C2。
在分层编译的工作模式出现前,不管是采用客户端编译器还是服务端编译器完全取决于虚拟机是运行在客户端模式还是服务端模式下,可以在启动时通过 -client 或 -server 参数进行指定,也可以让虚拟机根据自身版本和宿主机性能来自主选择。
3.3 分层编译
要编译出优化程度越高的代码通常都需要越长的编译时间,为了在程序启动速度与运行效率之间达到最佳平衡,HotSpot 在编译子系统中加入了分层编译(Tiered Compilation):
- 第 0 层:程序纯解释执行,并且解释器不开启性能监控功能;
- 第 1 层:使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能;
- 第 2 层:仍然使用客户端编译执行,仅开启方法及回边次数统计等有限的性能监控;
- 第 3 层:仍然使用客户端编译执行,开启全部性能监控;
- 第 4 层:使用服务端编译器将字节码编译为本地代码,其耗时更长,并且会根据性能监控信息进行一些不可靠的激进优化。
以上层次并不是固定不变的,根据不同的运行参数和版本,虚拟机可以调整分层的数量。各层次编译之间的交互转换关系如下图所示:
实施分层编译后,解释器、客户端编译器和服务端编译器就会同时工作,可以用客户端编译器获取更高的编译速度、用服务端编译器来获取更好的编译质量。
3.4 热点探测
即时编译器编译的目标是 “热点代码”,它主要分为以下两类:
- 被多次调用的方法。
- 被多次执行循环体。这里指的是一个方法只被少量调用过,但方法体内部存在循环次数较多的循环体,此时也认为是热点代码。但编译器编译的仍然是循环体所在的方法,而不会单独编译循环体。
判断某段代码是否是热点代码的行为称为 “热点探测” (Hot Spot Code Detection),主流的热点探测方法有以下两种:
- 基于采样的热点探测 (Sample Based Hot Spot Code Detection) :采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那么就认为它是 “热点方法”。
- 基于计数的热点探测 (Counter Based Hot Spot Code Detection) :采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是 “热点方法”。
4、代码优化
即时编译器除了将字节码编译为本地机器码外,还会对代码进行一定程度的优化,它包含多达几十种优化技术,这里选取其中代表性的四种进行介绍:
4.1 方法内联
最重要的优化手段,它会将目标方法中的代码原封不动地 “复制” 到发起调用的方法之中,避免发生真实的方法调用,并采用名为类型继承关系分析(Class Hierarchy Analysis,CHA)的技术来解决虚方法(Java 语言中默认的实例方法都是虚方法)的内联问题。
4.2 逃逸分析
逃逸行为主要分为以下两类:
- 方法逃逸:当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,此时称为方法逃逸;
- 线程逃逸:当一个对象在方法里面被定义后,它可能被外部线程所访问,例如赋值给可以在其他线程中访问的实例变量,此时称为线程逃逸,其逃逸程度高于方法逃逸。
public static StringBuilder concat(String... strings) {
StringBuilder sb = new StringBuilder();
for (String string : strings) {
sb.append(string);
}
return sb; // 发生了方法逃逸
}
public static String concat(String... strings) {
StringBuilder sb = new StringBuilder();
for (String string : strings) {
sb.append(string);
}
return sb.toString(); // 没有发生方法逃逸
}
如果能证明一个对象不会逃逸到方法或线程之外,或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可以对这个对象实例采取不同程序的优化:
- 栈上分配 (Stack Allocations) :如果一个对象不会逃逸到线程外,那么将会在栈上分配内存来创建这个对象,而不是 Java 堆上,此时对象所占用的内存空间就会随着栈帧的出栈而销毁,从而可以减轻垃圾回收的压力。
- 标量替换 (Scalar Replacement) :如果一个数据已经无法再分解成为更小的数据类型,那么这些数据就称为标量(如 int、long 等数值类型及 reference 类型等);反之,如果一个数据可以继续分解,那它就被称为聚合量(如对象)。如果一个对象不会逃逸外方法外,那么就可以将其改为直接创建若干个被这个方法使用的成员变量来替代,从而减少内存占用。
- 同步消除 (Synchronization Elimination) :如果一个变量不会逃逸出线程,那么对这个变量实施的同步措施就可以消除掉。
4.3 公共子表达式消除
如果一个表达式 E 之前已经被计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生过变化,那么 E 这次的出现就称为公共子表达式。对于这种表达式,无需再重新进行计算,只需要直接使用前面的计算结果即可。
4.4 数组边界检查消除
对于虚拟机执行子系统来说,每次数组元素的读写都带有一次隐含的上下文检查以避免访问越界。如果数组的访问发生在循环之中,并且使用循环变量来访问数据,即循环变量的取值永远在 [0,list.length) 之间,那么此时就可以消除整个循环的数据边界检查,从而避免多次无用的判断。
二、沉默王二-图解网络-HTTP/1.1 优化
我们可以从下面这三种优化思路来优化 HTTP/1.1 协议:
- 尽量避免发送 HTTP 请求;
- 在需要发送 HTTP 请求时,考虑如何减少请求次数;
- 减少服务器的 HTTP 响应的数据大小;
1、如何避免发送 HTTP 请求?
对于一些具有重复性的 HTTP 请求,比如每次请求得到的数据都一样的,我们可以把这对「请求-响应」的数据都缓存在本地,那么下次就直接读取本地的数据,不必在通过网络获取服务器的响应了,这样的话 HTTP/1.1 的性能肯定肉眼可见的提升。
所以,避免发送 HTTP 请求的方法就是通过缓存技术,HTTP 设计者早在之前就考虑到了这点,因此 HTTP 协议的头部有不少是针对缓存的字段。
那缓存是如何做到的呢?
客户端会把第一次请求以及响应的数据保存在本地磁盘上,其中将请求的 URL 作为 key,而响应作为 value,两者形成映射关系。
这样当后续发起相同的请求时,就可以先在本地磁盘上通过 key 查到对应的 value,也就是响应,如果找到了,就直接从本地读取该响应。毋庸置疑,读取本地磁盘的速度肯定比网络请求快得多,如下图:
聪明的你可能想到了,万一缓存的响应不是最新的,而客户端并不知情,那么该怎么办呢?
放心,这个问题 HTTP 设计者早已考虑到。
所以,服务器在发送 HTTP 响应时,会估算一个过期的时间,并把这个信息放到响应头部中,这样客户端在查看响应头部的信息时,一旦发现缓存的响应是过期的,则就会重新发送网络请求。
如果客户端从第一次请求得到的响应头部中发现该响应过期了,客户端重新发送请求,假设服务器上的资源并没有变更,还是老样子,那么你觉得还要在服务器的响应带上这个资源吗?
很显然不带的话,可以提高 HTTP 协议的性能,那具体如何做到呢?
只需要客户端在重新发送请求时,在请求的 Etag 头部带上第一次请求的响应头部中的摘要,这个摘要是唯一标识响应的资源,当服务器收到请求后,会将本地资源的摘要与请求中的摘要做个比较。
如果不同,那么说明客户端的缓存已经没有价值,服务器在响应中带上最新的资源。
如果相同,说明客户端的缓存还是可以继续使用的,那么服务器仅返回不含有包体的 304 Not Modified 响应,告诉客户端仍然有效,这样就可以减少响应资源在网络中传输的延时,如下图:
缓存真的是性能优化的一把万能钥匙,小到 CPU Cache、Page Cache、Redis Cache,大到 HTTP 协议的缓存。
2、如何减少 HTTP 请求次数?
减少 HTTP 请求次数自然也就提升了 HTTP 性能,可以从这 3 个方面入手:
- 减少重定向请求次数;
- 合并请求;
- 延迟发送请求;
2.1 减少重定向请求次数
我们先来看看什么是重定向请求?
服务器上的一个资源可能由于迁移、维护等原因从 url1 移至 url2 后,而客户端不知情,它还是继续请求 url1,这时服务器不能粗暴地返回错误,而是通过 302 响应码和 Location 头部,告诉客户端该资源已经迁移至 url2 了,于是客户端需要再发送 url2 请求以获得服务器的资源。
那么,如果重定向请求越多,那么客户端就要多次发起 HTTP 请求,每一次的 HTTP 请求都得经过网络,这无疑会越降低网络性能。
另外,服务端这一方往往不只有一台服务器,比如源服务器上一级是代理服务器,然后代理服务器才与客户端通信,这时客户端重定向就会导致客户端与代理服务器之间需要 2 次消息传递,如下图:
如果重定向的工作交由代理服务器完成,就能减少 HTTP 请求次数了,如下图:
而且当代理服务器知晓了重定向规则后,可以进一步减少消息传递次数,如下图:
除了 302 重定向响应码,还有其他一些重定向的响应码,你可以从下图看到:
其中,301 和 308 响应码是告诉客户端可以将重定向响应缓存到本地磁盘,之后客户端就自动用 url2 替代 url1 访问服务器的资源。
2.2 合并请求
如果把多个访问小文件的请求合并成一个大的请求,虽然传输的总资源还是一样,但是减少请求,也就意味着减少了重复发送的 HTTP 头部。
另外由于 HTTP/1.1 是请求响应模型,如果第一个发送的请求,未收到对应的响应,那么后续的请求就不会发送(PS:HTTP/1.1 管道模式是默认不使用的,所以讨论 HTTP/1.1 的队头阻塞问题,是不考虑管道模式的),于是为了防止单个请求的阻塞,所以一般浏览器会同时发起 5-6 个请求,每一个请求都是不同的 TCP 连接,那么如果合并了请求,也就会减少 TCP 连接的数量,因而省去了 TCP 握手和慢启动过程耗费的时间。
接下来,具体看看合并请求的几种方式。
有的网页会含有很多小图片、小图标,有多少个小图片,客户端就要发起多少次请求。那么对于这些小图片,我们可以考虑使用 CSS Image Sprites 技术把它们合成一个大图片,这样浏览器就可以用一次请求获得一个大图片,然后再根据 CSS 数据把大图片切割成多张小图片。
这种方式就是通过将多个小图片合并成一个大图片来减少 HTTP 请求的次数,以减少 HTTP 请求的次数,从而减少网络的开销。
除了将小图片合并成大图片的方式,还有服务端使用 webpack 等打包工具将 js、css 等资源合并打包成大文件,也是能达到类似的效果。
另外,还可以将图片的二进制数据用 base64 编码后,以 URL 的形式嵌入到 HTML 文件,跟随 HTML 文件一并发送.
这样客户端收到 HTML 后,就可以直接解码出数据,然后直接显示图片,就不用再发起图片相关的请求,这样便减少了请求的次数。
可以看到,合并请求的方式就是合并资源,以一个大资源的请求替换多个小资源的请求。
但是这样的合并请求会带来新的问题,当大资源中的某一个小资源发生变化后,客户端必须重新下载整个完整的大资源文件,这显然带来了额外的网络消耗。
2.3 延迟发送请求
不要一口气吃成大胖子,一般 HTML 里会含有很多 HTTP 的 URL,当前不需要的资源,我们没必要也获取过来,于是可以通过「按需获取」的方式,来减少第一时间的 HTTP 请求次数。
请求网页的时候,没必要把全部资源都获取到,而是只获取当前用户所看到的页面资源,当用户向下滑动页面的时候,再向服务器获取接下来的资源,这样就达到了延迟发送请求的效果。
3、如何减少 HTTP 响应的数据大小?
对于 HTTP 的请求和响应,通常 HTTP 的响应的数据大小会比较大,也就是服务器返回的资源会比较大。
于是,我们可以考虑对响应的资源进行压缩,这样就可以减少响应的数据大小,从而提高网络传输的效率。
压缩的方式一般分为 2 种,分别是:
- 无损压缩;
- 有损压缩;
3.1 无损压缩
无损压缩是指资源经过压缩后,信息不被破坏,还能完全恢复到压缩前的原样,适合用在文本文件、程序可执行文件、程序源代码。
首先,我们针对代码的语法规则进行压缩,因为通常代码文件都有很多换行符或者空格,这些是为了帮助程序员更好的阅读,但是机器执行时并不要这些符,把这些多余的符号给去除掉。
接下来,就是无损压缩了,需要对原始资源建立统计模型,利用这个统计模型,将常出现的数据用较短的二进制比特序列表示,将不常出现的数据用较长的二进制比特序列表示,生成二进制比特序列一般是「霍夫曼编码」算法。
gzip 就是比较常见的无损压缩。客户端支持的压缩算法,会在 HTTP 请求中通过头部中的 Accept-Encoding 字段告诉服务器:
Accept-Encoding: gzip, deflate, br
服务器收到后,会从中选择一个服务器支持的或者合适的压缩算法,然后使用此压缩算法对响应资源进行压缩,最后通过响应头部中的 Content-Encoding 字段告诉客户端该资源使用的压缩算法。
Content-Encoding: gzip
gzip 的压缩效率相比 Google 推出的 Brotli 算法还是差点意思,也就是上文中的 br,所以如果可以,服务器应该选择压缩效率更高的 br 压缩算法。
3.2 有损压缩
与无损压缩相对的就是有损压缩,经过此方法压缩,解压的数据会与原始数据不同但是非常接近。
有损压缩主要将次要的数据舍弃,牺牲一些质量来减少数据量、提高压缩比,这种方法经常用于压缩多媒体数据,比如音频、视频、图片。
可以通过 HTTP 请求头部中的 Accept 字段里的「 q 质量因子」,告诉服务器期望的资源质量。
Accept: audio/*; q=0.2, audio/basic
关于图片的压缩,目前压缩比较高的是 Google 推出的 WebP 格式,它与常见的 Png 格式图片的压缩比例对比如下图:
可以发现,相同图片质量下,WebP 格式的图片大小都比 Png 格式的图片小,所以对于大量图片的网站,可以考虑使用 WebP 格式的图片,这将大幅度提升网络传输的性能。
关于音视频的压缩,音视频主要是动态的,每个帧都有时序的关系,通常时间连续的帧之间的变化是很小的。
比如,一个在看书的视频,画面通常只有人物的手和书桌上的书是会有变化的,而其他地方通常都是静态的,于是只需要在一个静态的关键帧,使用增量数据来表达后续的帧,这样便减少了很多数据,提高了网络传输的性能。对于视频常见的编码格式有 H264、H265 等,音频常见的编码格式有 AAC、AC3。
4、总结
这次主要从 3 个方面介绍了优化 HTTP/1.1 协议的思路。
第一个思路是,通过缓存技术来避免发送 HTTP 请求。客户端收到第一个请求的响应后,可以将其缓存在本地磁盘,下次请求的时候,如果缓存没过期,就直接读取本地缓存的响应数据。如果缓存过期,客户端发送请求的时候带上响应数据的摘要,服务器比对后发现资源没有变化,就发出不带包体的 304 响应,告诉客户端缓存的响应仍然有效。
第二个思路是,减少 HTTP 请求的次数,有以下的方法:
- 将原本由客户端处理的重定向请求,交给代理服务器处理,这样可以减少重定向请求的次数;
- 将多个小资源合并成一个大资源再传输,能够减少 HTTP 请求次数以及 头部的重复传输,再来减少 TCP 连接数量,进而省去 TCP 握手和慢启动的网络消耗;
- 按需访问资源,只访问当前用户看得到/用得到的资源,当客户往下滑动,再访问接下来的资源,以此达到延迟请求,也就减少了同一时间的 HTTP 请求次数。
第三思路是,通过压缩响应资源,降低传输资源的大小,从而提高传输效率,所以应当选择更优秀的压缩算法。
不管怎么优化 HTTP/1.1 协议都是有限的,不然也不会出现 HTTP/2 和 HTTP/3 协议,后续我们再来介绍 HTTP/2 和 HTTP/3 协议。