一、沉默王二-JVM-JVM 核心知识点
1、Java 内存区域
1.2 程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。
字节码解释器通过改变程序计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要该计数器来完成。
每个线程都拥有一个独立的程序计数器,各个线程之间的计数器互不影响,独立存储。
1.3 虚拟机栈
虚拟机栈(Java Virtual Machine Stack)也是线程私有,它描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
方法从调用到结束就对应着一个栈帧从入栈到出栈的过程。在《Java 虚拟机规范》中,对该内存区域规定了两类异常:
- 如果线程请求的栈深度大于虚拟机所允许的栈深度,将抛出
StackOverflowError
异常; - 如果 Java 虚拟机栈的容量允许动态扩展,当栈扩展时如果无法申请到足够的内存会抛出
OutOfMemoryError
异常。
1.3 本地方法栈
本地方法栈(Native Method Stacks)与虚拟机栈类似,其区别在于:Java 虚拟机栈是为虚拟机执行 Java 方法(也就是字节码)服务的,而本地方法栈则是为 JVM 使用到的本地(Native)方法服务。
1.4 堆
堆(Java Heap)是虚拟机所管理的最大一块内存空间,它被所有线程所共享,用于存放对象实例。
Java 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为是连续的。Java 堆可以被实现成固定大小的,也可以是可扩展的。
当前大多数主流的虚拟机都是按照可扩展来实现的,即可以通过最大值参数 -Xmx
和最小值参数 -Xms
进行设定。
如果 Java 堆中没有足够的内存来完成对象实例分配,并且堆也无法再扩展时,Java 虚拟机将会抛出 OutOfMemoryError
异常。
1.5 方法区
方法区(Method Area)也是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、JIT 编译后的代码缓存等数据。
方法区也被称为 “非堆”,目的是与 Java 堆进行区分。《Java 虚拟机规范》规定,如果方法区无法满足新的内存分配需求时,将会抛出 OutOfMemoryError
异常。
JDK 8 以后的方法区实现已经不再是永久代(Permanent Generation)了,而是使用元空间(Metaspace)来实现。
运行时常量池(Runtime Constant Pool)也是方法区的一部分,用于存放常量池表(Constant Pool Table),常量池表中存放了编译期生成的各种符号字面量和符号引用。
JDK 8 以后的运行时常量池在元空间中。
2、 对象
2.1 对象的创建
当我们在代码中使用 new
关键字创建一个对象时,其在 JVM 中需要经过以下步骤:
2.1.1 类加载过程
当虚拟机遇到一条字节码指令 new
时,首先将去检查这个指令的参数是否能在常量池中定位到一个符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,就必须先执行相应的类加载过程。
2.1.2 分配内存
在类加载检查通过后,虚拟机需要给新生对象分配内存空间。根据 Java 堆是否规整,可以有以下两种分配方案:
①、指针碰撞:假设 Java 堆中内存是绝对规整的,所有使用的内存放在一边,所有未被使用的内存放在另外一边,中间以指针作为分界点指示器。
此时内存分配只是将指针向空闲方向偏移出对象大小的空间即可,这种方式被称为指针碰撞。
②、空闲列表:如果 Java 堆不是规整的,此时虚拟机需要维护一个列表,记录哪些内存块是可用的,哪些是不可用的。在进行内存分配时,只需要从该列表中选取出一块足够的内存空间划分给对象实例即可。
注:Java 堆是否规整取决于其采用的垃圾收集器是否带有空间压缩整理能力,前面讲过了。
除了分配方式外,由于对象创建在虚拟机中是一个非常频繁的行为,此时需要保证在并发环境下的线程安全:如果一个线程给对象 A 分配了内存空间,但指针还没来得及修改,此时就可能出现另外一个线程使用原来的指针来给对象 B 分配内存空间的情况。
想要解决这个问题有两个方案:
①、方式一:采用同步锁定,或采用 CAS 配上失败重试的方式来保证更新操作的原子性。
②、方式二:为每个线程在 Java 堆中预先分配一块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。
线程在进行内存分配时优先使用本地缓冲,当本地缓冲使用完成后,再向 Java 堆申请分配,此时 Java 堆采用同步锁定的方式来保证分配行为的线程安全。
2.1.3 对象头设置
将对象有关的元数据信息、对象的哈希码、分代年龄等信息存储到对象头中。
可以和 JIT 那节的内容关联起来。
2.1.4 对象初始化
调用对象的构造方法,即 Class 文件中的
来初始化对象,为相关字段赋值。
2.2 对象的内存布局
在 HotSpot 中,对象在堆内存中的存储布局可以划分为以下三个部分:
2.2.1 对象头 (Header)
对象头包括两部分信息:
- Mark Word:对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,官方统称为 Mark Word,我们曾在 synchronized 的四种锁状态讲过。
- 类型指针:对象指向它类型元数据的指针,Java 虚拟机通过这个指针来确定该对象是哪个类的实例。需要说明的是,并非所有的虚拟机都必须要在对象数据上保留类型指针,这取决于对象的访问定位方式。
2.2.2 实例数据 (Instance Data)
即我们在代码中定义的各种类型的字段,无论是从父类继承而来,还是子类中定义的都需要记录。
2.2.3 对齐填充 (Padding)
主要起占位符的作用。HotSpot 要求对象起始地址必须是 8 字节的整倍数,即间接要求了任何对象的大小都必须是 8 字节的整倍数。对象头部分在设计上就是 8 字节的整倍数,如果对象的实例数据不是 8 字节的整倍数,则由对齐填充进行补全。
2.3 对象的访问定位
对象创建后,Java 程序就可以通过栈上的 reference
(也就是引用)来操作堆上的具体对象。
《Java 虚拟机规范》规定 reference
是一个指向对象的引用,但并未规定其具体实现方式。主流的方式方式有以下两种:
- 句柄访问:Java 堆将划分出一块内存来作为句柄池,
reference
中存储的是对象的句柄地址,而句柄则包含了对象实例数据和类型数据的地址信息。 - 指针访问:
reference
中存储的直接就是对象地址,而对象的类型数据则由上文介绍的对象头中的类型指针来指定。
通过句柄访问对象:
通过直接指针访问对象:
句柄访问的优点在于对象移动时(垃圾收集时移动对象是非常普遍的行为)只需要改变句柄中实例数据的指针,而 reference
本身并不需要修改;
指针访问则反之,由于其 reference
中存储的直接就是对象地址,所以当对象移动时, reference
需要被修改。但针对只需要访问对象本身的场景,指针访问则可以减少一次定位开销。由于对象访问是一项非常频繁的操作,所以这类减少的效果会非常显著,基于这个原因,HotSpot 主要使用的是指针访问的方式。
3、垃圾收集机制
在 JVM 内存模型中,程序计数器、虚拟机栈、本地方法栈这 3 个区域都是线程私有的,会随着线程的结束而销毁,因此在这 3 个区域当中,无需过多考虑垃圾回收问题。垃圾回收问题主要发生在 Java 堆上。
在 Java 堆上,垃圾回收的主要内容是死亡的对象(不可能再被任何途径使用的对象)。
判断对象是否死亡有以下两种方法:
3.1 引用计数法
在对象中添加一个引用计数器,对象每次被引用时,该计数器加一;当引用失效时,计数器的值减一;只要计数器的值为零,则代表对应的对象不可能再被使用。该方法的缺点在于无法避免相互引用的问题:
java 代码解读复制代码objA.instance = objB
objB.instance = objA
objA = null;
objB = null;
System.gc();
如上所示,此时两个对象已经不能再被访问,但其互相持有对对方的引用,如果采用引用计数法,则两个对象都无法被回收。
3.2 可达性分析
但上面的代码在大多数虚拟机中都能被正确的回收,因为大多数主流的虚拟机都是采用的可达性分析方法来判断对象是否死亡。
可达性分析是通过一系列被称为 GC Roots
的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径被称为引用链(Reference Chain),如果某个对象到 GC Roots
间没有任何引用链相连,这代表 GC Roots
到该对象不可达, 此时证明该对象不可能再被使用。
在 Java 语言中,固定可作为 GC Roots
的对象包括以下几种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等;
- 在方法区(元空间)中类静态变量引用的对象,譬如 Java 类中引用类型的静态变量;
- 在方法区(元空间)中常量引用的对象,譬如字符串常量池(String Table)里的引用;
- 在本地方法栈中的 JNI(Native 方法)引用的对象;
- Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(如 NullPointException,OutOfMemoryError 等)及系统类加载器;
- 所有被同步锁(synchronized 关键字)持有的对象;
除了这些固定的 GC Roots
集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域的不同,还可能会有其他对象 “临时性” 地加入,共同构成完整的 GC Roots
集合。
3.3 对象引用
可达性分析是基于引用链进行判断的,在 JDK 1.2 之后,Java 将引用关系分为以下四类:
3.3.1 强引用 (Strongly Reference)
最传统的引用,如 Object obj = new Object()
。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
3.3.2 软引用 (Soft Reference)
用于描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出之前,会被列入回收范围内进行第二次回收,如果这次回收后还没有足够的内存,才会抛出内存溢出异常。
下面是一个使用 Java 中 SoftReference 类的示例代码:
java 代码解读复制代码class SoftReferenceExample {
public static void main(String[] args) {
// 创建一个强引用的对象
String strongReference = new String("二哥,我是个强引用");
// 创建一个软引用,指向上面的对象
SoftReference softReference = new SoftReference<>(strongReference);
// 干掉强引用
strongReference = null;
// 现在只有软引用指向 "二哥,我是个强引用" 对象
// 尝试通过软引用获取对象
String retrievedString = softReference.get();
System.out.println(retrievedString); // 输出 "二哥,我是个强引用"
// 强制进行垃圾回收,可能会清除软引用的对象
System.gc();
// 再次尝试通过软引用获取对象
retrievedString = softReference.get();
if (retrievedString != null) {
System.out.println(retrievedString);
} else {
System.out.println("软引用的对象已被垃圾回收");
}
}
}
这个例子中,我们首先创建了一个字符串对象的强引用,然后通过 SoftReference 创建了这个对象的软引用。在取消了强引用后,这个对象只剩下软引用。当我们尝试通过软引用获取对象时,如果对象还存在,软引用会返回它;如果对象已被垃圾收集器回收,则返回 null。
需要注意的是,第二次回收时,如果这次回收后还没有足够的内存,才会抛出内存溢出异常。这里的 “足够” 是指在抛出内存溢出异常之前,系统会进行最后一次尝试,如果这次回收后还是没有足够的内存,才会抛出内存溢出异常。
通常情况下,上面代码在执行 gc 后软引用不会被回收,因为此时内存还是足够的。
java代码解读复制代码二哥,我是个强引用 二哥,我是个强引用
3.3.3 弱引用 (Weak Reference)
用于描述那些非必须的对象,强度比软引用弱。被弱引用关联的对象只能生存到下一次垃圾收集发生时,无论当前内存是否足够,弱引用对象都会被回收。
来看这段代码:
java 代码解读复制代码class WeakReferenceExample {
public static void main(String[] args) {
// 创建一个强引用的对象
String strongReference = new String("二哥,我是强引用");
// 创建一个弱引用,指向上面的对象
WeakReference weakReference = new WeakReference<>(strongReference);
// 取消强引用
strongReference = null;
// 强制进行垃圾回收
System.gc();
// 尝试通过弱引用获取对象
String retrievedString = weakReference.get();
if (retrievedString != null) {
System.out.println(retrievedString);
} else {
System.out.println("弱引用的对象已被垃圾回收");
}
}
}
这个例子中,我们首先创建了一个字符串对象的强引用,然后通过 WeakReference 创建了这个对象的弱引用。在取消了强引用后,这个对象只剩下弱引用。当我们尝试通过弱引用获取对象时,如果对象还存在,弱引用会返回它;如果对象已被垃圾收集器回收,则返回 null。
运行结果就和软引用不一样了,gc 后弱引用被回收了。
java代码解读复制代码弱引用的对象已被垃圾回收
3.3.4 虚引用 (Phantom Reference)
最弱的引用关系。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被回收时收到一个系统通知。
虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾收集器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入到与之关联的引用队列中。
来看这个示例代码:
java 代码解读复制代码class PhantomReferenceExample {
public static void main(String[] args) {
// 创建一个强引用的对象
String strongReference = new String("二哥,我是强引用");
// 创建一个引用队列
ReferenceQueue referenceQueue = new ReferenceQueue<>();
// 创建一个虚引用,指向上面的对象,并与引用队列关联
PhantomReference phantomReference = new PhantomReference<>(strongReference, referenceQueue);
// 取消强引用
strongReference = null;
// 强制进行垃圾回收
System.gc();
// 检查引用队列,看是否有通知
if (referenceQueue.poll() != null) {
System.out.println("虚引用的对象已被垃圾回收,且收到了通知");
} else {
System.out.println("虚引用的对象未被垃圾回收,或未收到通知");
}
}
}
这个例子中,我们首先创建了一个字符串对象的强引用,然后通过 PhantomReference 创建了这个对象的虚引用,并与引用队列关联。在取消了强引用后,这个对象只剩下虚引用。当我们强制进行垃圾回收时,如果对象还存在,虚引用不会返回它;如果对象已被垃圾收集器回收,则返回 null。同时,如果对象被回收,虚引用会被加入到引用队列中。
运行结果如下:
java代码解读复制代码虚引用的对象已被垃圾回收,且收到了通知
3.4 对象真正死亡
要真正宣告一个对象死亡,需要经过至少两次标记过程:
①、如果对象在进行可达性分析后发现 GC Roots
不可达,将会进行第一次标记;
②、随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalized()
方法。
如果对象没有覆盖 finalized()
方法,或者 finalized()
已经被虚拟机调用过,这两种情况都会视为没有必要执行。
如果判定结果是有必要执行,此时对象会被放入名为 F-Queue
的队列,等待 Finalizer 线程执行其 finalized()
方法。
在这个过程中,收集器会进行第二次小规模的标记,如果对象在 finalized()
方法中重新将自己与引用链上的任何一个对象进行了关联,如将自己(this 关键字)赋值给某个类变量或者对象的成员变量,此时它就实现了自我拯救,则第二次标记会将其移除 “即将回收” 的集合,否则该对象就将被真正回收,走向死亡。
3.5 垃圾收集算法
3.5.1 分代收集理论
当前大多数虚拟机都遵循 “分代收集” 的理论进行设计,它建立在强弱两个分代假说下:
- 弱分代假说 (Weak Generational Hypothesis) :绝大多数对象都是朝生夕灭的。
- 强分代假说 (Strong Generational Hypothesis) :熬过越多次垃圾收集过程的对象就越难以消亡。
- 跨带引用假说 (Intergenerational Reference Hypothesis) :基于上面两条假说还可以得出的一条隐含推论:存在相互引用关系的两个对象,应该倾向于同时生存或者同时消亡。
强弱分代假说奠定了垃圾收集器的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄就是对象经历垃圾收集的次数)分配到不同的区域中进行存储。
之后如果一个区域中的对象都是朝生夕灭的,那么收集器只需要关注少量对象的存活而不是去标记那些大量将要被回收的对象,此时就能以较小的代价获取较大的空间。
最后再将难以消亡的对象集中到一块,根据强分代假说,它们是很难消亡的,因此虚拟机可以使用较低的频率进行回收,这就兼顾了时间和内存空间的开销。
3.5.2 回收类型
根据分代收集理论,收集范围可以分为以下几种类型:
①、部分收集 (Partial GC) :具体分为:
- 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
- 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
- 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
- 整堆收集 (Full GC) :收集整个 Java 堆和方法区。
3.5.3 标记-清除算法
它是最基础的垃圾收集算法,收集过程分为两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象;也可以反过来,标记存活对象,统一回收所有未被标记的对象。
它主要有以下两个缺点:
- 执行效率不稳定:如果 Java 堆上包含大量需要回收的对象,则需要进行大量标记和清除动作;
- 内存空间碎片化:标记清除后会产生大量不连续的空间,从而导致无法为大对象分配足够的连续内存。
3.5.4 标记-复制算法
标记-复制算法基于 ”半区复制“ 算法:它将可用内存按容量划分为大小相等的两块,每次只使用其中一块,当这一块的内存使用完了,就将还存活着的对象复制到另外一块,然后再把已经使用过的那块内存空间一次性清理掉。其优点在于避免了内存空间碎片化的问题,其缺点如下:
- 如果内存中多数对象都是存活的,这种算法将产生大量的复制开销;
- 浪费内存空间,内存空间变为了原有的一半。
基于新生代 “朝生夕灭” 的特点,大多数虚拟机都不会按照 1:1 的比例来进行内存划分,例如 HotSpot 会将内存空间划分为一块较大的 Eden
和两块较小的 Survivor
空间,它们之间的比例是 8:1:1 。
每次分配时只会使用 Eden
和其中的一块 Survivor
,发生垃圾回收时,只需要将存活的对象一次性复制到另外一块 Survivor
上,这样只有 10% 的内存空间会被浪费掉。
当 Survivor
空间不足以容纳一次 Minor GC
时,此时由其他内存区域(通常是老年代)来进行分配担保。
3.5.5 标记-整理算法
标记-整理算法是在标记完成后,让所有存活对象都向内存的一端移动,然后直接清理掉边界以外的内存。
其优点在于可以避免内存空间碎片化的问题,也可以充分利用内存空间;其缺点在于根据所使用的收集器的不同,在移动存活对象时可能要全程暂停用户程序:
二、小林-图解网络-HTTP相关问题
1、HTTP 特性
到目前为止,HTTP 常见到版本有 HTTP/1.1,HTTP/2.0,HTTP/3.0,不同版本的 HTTP 特性是不一样的。
1.1 HTTP/1.1 的优点有哪些?
HTTP 最突出的优点是「简单、灵活和易于扩展、应用广泛和跨平台」。
1. 简单
HTTP 基本的报文格式就是 header + body
,头部信息也是 key-value
简单文本的形式,易于理解,降低了学习和使用的门槛。
2. 灵活和易于扩展
HTTP 协议里的各类请求方法、URI/URL、状态码、头字段等每个组成要求都没有被固定死,都允许开发人员自定义和扩充。
同时 HTTP 由于是工作在应用层( OSI
第七层),则它下层可以随意变化,比如:
- HTTPS 就是在 HTTP 与 TCP 层之间增加了 SSL/TLS 安全传输层;
- HTTP/1.1 和 HTTP/2.0 传输协议使用的是 TCP 协议,而到了 HTTP/3.0 传输协议改用了 UDP 协议。
3. 应用广泛和跨平台
互联网发展至今,HTTP 的应用范围非常的广泛,从台式机的浏览器到手机上的各种 APP,从看新闻、刷贴吧到购物、理财、吃鸡,HTTP 的应用遍地开花,同时天然具有跨平台的优越性。
1.2 HTTP/1.1 的缺点有哪些?
HTTP 协议里有优缺点一体的双刃剑,分别是「无状态、明文传输」,同时还有一大缺点「不安全」。
1. 无状态双刃剑
无状态的好处,因为服务器不会去记忆 HTTP 的状态,所以不需要额外的资源来记录状态信息,这能减轻服务器的负担,能够把更多的 CPU 和内存用来对外提供服务。
无状态的坏处,既然服务器没有记忆能力,它在完成有关联性的操作时会非常麻烦。
例如登录->添加购物车->下单->结算->支付,这系列操作都要知道用户的身份才行。但服务器不知道这些请求是有关联的,每次都要问一遍身份信息。
这样每操作一次,都要验证信息,这样的购物体验还能愉快吗?别问,问就是酸爽!
对于无状态的问题,解法方案有很多种,其中比较简单的方式用 Cookie 技术。
Cookie
通过在请求和响应报文中写入 Cookie 信息来控制客户端的状态。
相当于,在客户端第一次请求后,服务器会下发一个装有客户信息的「小贴纸」,后续客户端请求服务器的时候,带上「小贴纸」,服务器就能认得了了,
2. 明文传输双刃剑
明文意味着在传输过程中的信息,是可方便阅读的,比如 Wireshark 抓包都可以直接肉眼查看,为我们调试工作带了极大的便利性。
但是这正是这样,HTTP 的所有信息都暴露在了光天化日下,相当于信息裸奔。在传输的漫长的过程中,信息的内容都毫无隐私可言,很容易就能被窃取,如果里面有你的账号密码信息,那你号没了。
3. 不安全
HTTP 比较严重的缺点就是不安全:
- 通信使用明文(不加密),内容可能会被窃听。比如,账号信息容易泄漏,那你号没了。
- 不验证通信方的身份,因此有可能遭遇伪装。比如,访问假的淘宝、拼多多,那你钱没了。
- 无法证明报文的完整性,所以有可能已遭篡改。比如,网页上植入垃圾广告,视觉污染,眼没了。
HTTP 的安全问题,可以用 HTTPS 的方式解决,也就是通过引入 SSL/TLS 层,使得在安全上达到了极致。
1.3 HTTP/1.1 的性能如何?
HTTP 协议是基于 TCP/IP,并且使用了「请求 - 应答」的通信模式,所以性能的关键就在这两点里。
1. 长连接
早期 HTTP/1.0 性能上的一个很大的问题,那就是每发起一个请求,都要新建一次 TCP 连接(三次握手),而且是串行请求,做了无谓的 TCP 连接建立和断开,增加了通信开销。
为了解决上述 TCP 连接问题,HTTP/1.1 提出了长连接的通信方式,也叫持久连接。这种方式的好处在于减少了 TCP 连接的重复建立和断开所造成的额外开销,减轻了服务器端的负载。
持久连接的特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。
当然,如果某个 HTTP 长连接超过一定时间没有任何数据交互,服务端就会主动断开这个连接。
2. 管道网络传输
HTTP/1.1 采用了长连接的方式,这使得管道(pipeline)网络传输成为了可能。
即可在同一个 TCP 连接里面,客户端可以发起多个请求,只要第一个请求发出去了,不必等其回来,就可以发第二个请求出去,可以减少整体的响应时间。
举例来说,客户端需要请求两个资源。以前的做法是,在同一个 TCP 连接里面,先发送 A 请求,然后等待服务器做出回应,收到后再发出 B 请求。那么,管道机制则是允许浏览器同时发出 A 请求和 B 请求,如下图:
但是服务器必须按照接收请求的顺序发送对这些管道化请求的响应。
如果服务端在处理 A 请求时耗时比较长,那么后续的请求的处理都会被阻塞住,这称为「队头堵塞」。
所以,HTTP/1.1 管道解决了请求的队头阻塞,但是没有解决响应的队头阻塞。
实际上 HTTP/1.1 管道化技术不是默认开启,而且浏览器基本都没有支持,所以后面所有文章讨论 HTTP/1.1 都是建立在没有使用管道化的前提。
3. 队头阻塞
「请求 - 应答」的模式会造成 HTTP 的性能问题。为什么呢?
因为当顺序发送的请求序列中的一个请求因为某种原因被阻塞时,在后面排队的所有请求也一同被阻塞了,会招致客户端一直请求不到数据,这也就是「队头阻塞」,好比上班的路上塞车。
总之 HTTP/1.1 的性能一般般,后续的 HTTP/2 和 HTTP/3 就是在优化 HTTP 的性能。
2、HTTP 与 HTTPS
2.1 HTTP 与 HTTPS 有哪些区别?
- HTTP 是超文本传输协议,信息是明文传输,存在安全风险的问题。HTTPS 则解决 HTTP 不安全的缺陷,在 TCP 和 HTTP 网络层之间加入了 SSL/TLS 安全协议,使得报文能够加密传输。
- HTTP 连接建立相对简单, TCP 三次握手之后便可进行 HTTP 的报文传输。而 HTTPS 在 TCP 三次握手之后,还需进行 SSL/TLS 的握手过程,才可进入加密报文传输。
- 两者的默认端口不一样,HTTP 默认端口号是 80,HTTPS 默认端口号是 443。
- HTTPS 协议需要向 CA(证书权威机构)申请数字证书,来保证服务器的身份是可信的。
2.2 HTTPS 解决了 HTTP 的哪些问题?
HTTP 由于是明文传输,所以安全上存在以下三个风险:
- 窃听风险,比如通信链路上可以获取通信内容,用户号容易没。
- 篡改风险,比如强制植入垃圾广告,视觉污染,用户眼容易瞎。
- 冒充风险,比如冒充淘宝网站,用户钱容易没。
HTTPS 在 HTTP 与 TCP 层之间加入了 SSL/TLS
协议,可以很好的解决了上述的风险:
- 信息加密:交互信息无法被窃取,但你的号会因为「自身忘记」账号而没。
- 校验机制:无法篡改通信内容,篡改了就不能正常显示,但百度「竞价排名」依然可以搜索垃圾广告。
- 身份证书:证明淘宝是真的淘宝网,但你的钱还是会因为「剁手」而没。
可见,只要自身不做「恶」,SSL/TLS 协议是能保证通信是安全的。
HTTPS 是如何解决上面的三个风险的?
- 混合加密的方式实现信息的机密性,解决了窃听的风险。
- 摘要算法的方式来实现完整性,它能够为数据生成独一无二的「指纹」,指纹用于校验数据的完整性,解决了篡改的风险。
- 将服务器公钥放入到数字证书中,解决了冒充的风险。
1. 混合加密
通过混合加密的方式可以保证信息的机密性,解决了窃听的风险。
HTTPS 采用的是对称加密和非对称加密结合的「混合加密」方式:
- 在通信建立前采用非对称加密的方式交换「会话秘钥」,后续就不再使用非对称加密。
- 在通信过程中全部使用对称加密的「会话秘钥」的方式加密明文数据。
采用「混合加密」的方式的原因:
- 对称加密只使用一个密钥,运算速度快,密钥必须保密,无法做到安全的密钥交换。
- 非对称加密使用两个密钥:公钥和私钥,公钥可以任意分发而私钥保密,解决了密钥交换问题但速度慢。
2. 摘要算法 + 数字签名
为了保证传输的内容不被篡改,我们需要对内容计算出一个「指纹」,然后同内容一起传输给对方。
对方收到后,先是对内容也计算出一个「指纹」,然后跟发送方发送的「指纹」做一个比较,如果「指纹」相同,说明内容没有被篡改,否则就可以判断出内容被篡改了。
那么,在计算机里会用摘要算法(哈希函数)来计算出内容的哈希值,也就是内容的「指纹」,这个哈希值是唯一的,且无法通过哈希值推导出内容。
通过哈希算法可以确保内容不会被篡改,但是并不能保证「内容 + 哈希值」不会被中间人替换,因为这里缺少对客户端收到的消息是否来源于服务端的证明。
举个例子,你想向老师请假,一般来说是要求由家长写一份请假理由并签名,老师才能允许你请假。
但是你有模仿你爸爸字迹的能力,你用你爸爸的字迹写了一份请假理由然后签上你爸爸的名字,老师一看到这个请假条,查看字迹和签名,就误以为是你爸爸写的,就会允许你请假。
那作为老师,要如何避免这种情况发生呢?现实生活中的,可以通过电话或视频来确认是否是由父母发出的请假,但是计算机里可没有这种操作。
那为了避免这种情况,计算机里会用非对称加密算法来解决,共有两个密钥:
- 一个是公钥,这个是可以公开给所有人的;
- 一个是私钥,这个必须由本人管理,不可泄露。
这两个密钥可以双向加解密的,比如可以用公钥加密内容,然后用私钥解密,也可以用私钥加密内容,公钥解密内容。
流程的不同,意味着目的也不相同:
- 公钥加密,私钥解密。这个目的是为了保证内容传输的安全,因为被公钥加密的内容,其他人是无法解密的,只有持有私钥的人,才能解密出实际的内容;
- 私钥加密,公钥解密。这个目的是为了保证消息不会被冒充,因为私钥是不可泄露的,如果公钥能正常解密出私钥加密的内容,就能证明这个消息是来源于持有私钥身份的人发送的。
一般我们不会用非对称加密来加密实际的传输内容,因为非对称加密的计算比较耗费性能的。
所以非对称加密的用途主要在于通过「私钥加密,公钥解密」的方式,来确认消息的身份,我们常说的数字签名算法,就是用的是这种方式,不过私钥加密内容不是内容本身,而是对内容的哈希值加密。
私钥是由服务端保管,然后服务端会向客户端颁发对应的公钥。如果客户端收到的信息,能被公钥解密,就说明该消息是由服务器发送的。
引入了数字签名算法后,你就无法模仿你爸爸的字迹来请假了,你爸爸手上持有着私钥,你老师持有着公钥。
这样只有用你爸爸手上的私钥才对请假条进行「签名」,老师通过公钥看能不能解出这个「签名」,如果能解出并且确认内容的完整性,就能证明是由你爸爸发起的请假条,这样老师才允许你请假,否则老师就不认。
3. 数字证书
前面我们知道:
- 可以通过哈希算法来保证消息的完整性;
- 可以通过数字签名来保证消息的来源可靠性(能确认消息是由持有私钥的一方发送的);
但是这还远远不够,还缺少身份验证的环节,万一公钥是被伪造的呢?
还是拿请假的例子,虽然你爸爸持有私钥,老师通过是否能用公钥解密来确认这个请假条是不是来源你父亲的。
但是我们还可以自己伪造出一对公私钥啊!
你找了个夜晚,偷偷把老师桌面上和你爸爸配对的公钥,换成了你的公钥,那么下次你在请假的时候,你继续模仿你爸爸的字迹写了个请假条,然后用你的私钥做个了「数字签名」。
但是老师并不知道自己的公钥被你替换过了,所以他还是按照往常一样用公钥解密,由于这个公钥和你的私钥是配对的,老师当然能用这个被替换的公钥解密出来,并且确认了内容的完整性,于是老师就会以为是你父亲写的请假条,又允许你请假了。
好家伙,为了一个请假,真的是斗智斗勇。
后面你的老师和父亲发现了你伪造公私钥的事情后,决定重新商量一个对策来应对你这个臭家伙。
正所谓魔高一丈,道高一尺。
既然伪造公私钥那么随意,所以你爸把他的公钥注册到警察局,警察局用他们自己的私钥对你父亲的公钥做了个数字签名,然后把你爸爸的「个人信息 + 公钥 + 数字签名」打包成一个数字证书,也就是说这个数字证书包含你爸爸的公钥。
这样,你爸爸如果因为家里确实有事要向老师帮你请假的时候,不仅会用自己的私钥对内容进行签名,还会把数字证书给到老师。
老师拿到了数字证书后,首先会去警察局验证这个数字证书是否合法,因为数字证书里有警察局的数字签名,警察局要验证证书合法性的时候,用自己的公钥解密,如果能解密成功,就说明这个数字证书是在警察局注册过的,就认为该数字证书是合法的,然后就会把数字证书里头的公钥(你爸爸的)给到老师。
由于通过警察局验证了数字证书是合法的,那么就能证明这个公钥就是你父亲的,于是老师就可以安心的用这个公钥解密出请假条,如果能解密出,就证明是你爸爸写的请假条。
正是通过了一个权威的机构来证明你爸爸的身份,所以你的伪造公私钥这个小伎俩就没用了。
在计算机里,这个权威的机构就是 CA (数字证书认证机构),将服务器公钥放在数字证书(由数字证书认证机构颁发)中,只要证书是可信的,公钥就是可信的。
数字证书的工作流程,我也画了一张图,方便大家理解:
通过数字证书的方式保证服务器公钥的身份,解决冒充的风险。
2.3 HTTPS 是如何建立连接的?其间交互了什么?
SSL/TLS 协议基本流程:
- 客户端向服务器索要并验证服务器的公钥。
- 双方协商生产「会话秘钥」。
- 双方采用「会话秘钥」进行加密通信。
前两步也就是 SSL/TLS 的建立过程,也就是 TLS 握手阶段。
TLS 的「握手阶段」涉及四次通信,使用不同的密钥交换算法,TLS 握手流程也会不一样的,现在常用的密钥交换算法有两种:RSA 算法 (opens new window)和 ECDHE 算法 (opens new window)。
基于 RSA 算法的 TLS 握手过程比较容易理解,所以这里先用这个给大家展示 TLS 握手过程,如下图:
TLS 协议建立的详细流程:
1. ClientHello
首先,由客户端向服务器发起加密通信请求,也就是 ClientHello
请求。
在这一步,客户端主要向服务器发送以下信息:
(1)客户端支持的 TLS 协议版本,如 TLS 1.2 版本。
(2)客户端生产的随机数(Client Random
),后面用于生成「会话秘钥」条件之一。
(3)客户端支持的密码套件列表,如 RSA 加密算法。
2. SeverHello
服务器收到客户端请求后,向客户端发出响应,也就是 SeverHello
。服务器回应的内容有如下内容:
(1)确认 TLS 协议版本,如果浏览器不支持,则关闭加密通信。
(2)服务器生产的随机数(Server Random
),也是后面用于生产「会话秘钥」条件之一。
(3)确认的密码套件列表,如 RSA 加密算法。
(4)服务器的数字证书。
3.客户端回应
客户端收到服务器的回应之后,首先通过浏览器或者操作系统中的 CA 公钥,确认服务器的数字证书的真实性。
如果证书没有问题,客户端会从数字证书中取出服务器的公钥,然后使用它加密报文,向服务器发送如下信息:
(1)一个随机数(pre-master key
)。该随机数会被服务器公钥加密。
(2)加密通信算法改变通知,表示随后的信息都将用「会话秘钥」加密通信。
(3)客户端握手结束通知,表示客户端的握手阶段已经结束。这一项同时把之前所有内容的发生的数据做个摘要,用来供服务端校验。
上面第一项的随机数是整个握手阶段的第三个随机数,会发给服务端,所以这个随机数客户端和服务端都是一样的。
服务器和客户端有了这三个随机数(Client Random、Server Random、pre-master key),接着就用双方协商的加密算法,各自生成本次通信的「会话秘钥」。
4. 服务器的最后回应
服务器收到客户端的第三个随机数(pre-master key
)之后,通过协商的加密算法,计算出本次通信的「会话秘钥」。
然后,向客户端发送最后的信息:
(1)加密通信算法改变通知,表示随后的信息都将用「会话秘钥」加密通信。
(2)服务器握手结束通知,表示服务器的握手阶段已经结束。这一项同时把之前所有内容的发生的数据做个摘要,用来供客户端校验。
至此,整个 TLS 的握手阶段全部结束。接下来,客户端与服务器进入加密通信,就完全是使用普通的 HTTP 协议,只不过用「会话秘钥」加密内容。
不过,基于 RSA 算法的 HTTPS 存在「前向安全」的问题:如果服务端的私钥泄漏了,过去被第三方截获的所有 TLS 通讯密文都会被破解。
为了解决这个问题,后面就出现了 ECDHE 密钥协商算法,我们现在大多数网站使用的正是 ECDHE 密钥协商算法。
客户端校验数字证书的流程是怎样的?
接下来,详细说一下实际中数字证书签发和验证流程。
如下图图所示,为数字证书签发和验证流程:
CA 签发证书的过程,如上图左边部分:
- 首先 CA 会把持有者的公钥、用途、颁发者、有效时间等信息打成一个包,然后对这些信息进行 Hash 计算,得到一个 Hash 值;
- 然后 CA 会使用自己的私钥将该 Hash 值加密,生成 Certificate Signature,也就是 CA 对证书做了签名;
- 最后将 Certificate Signature 添加在文件证书上,形成数字证书;
客户端校验服务端的数字证书的过程,如上图右边部分:
- 首先客户端会使用同样的 Hash 算法获取该证书的 Hash 值 H1;
- 通常浏览器和操作系统中集成了 CA 的公钥信息,浏览器收到证书后可以使用 CA 的公钥解密 Certificate Signature 内容,得到一个 Hash 值 H2 ;
- 最后比较 H1 和 H2,如果值相同,则为可信赖的证书,否则则认为证书不可信。
但事实上,证书的验证过程中还存在一个证书信任链的问题,因为我们向 CA 申请的证书一般不是根证书签发的,而是由中间证书签发的,比如百度的证书,从下图你可以看到,证书的层级有三级:
对于这种三级层级关系的证书的验证过程如下:
最开始客户端只信任根证书 GlobalSign Root CA 证书的,然后 “GlobalSign Root CA” 证书信任 “GlobalSign Organization Validation CA - SHA256 - G2” 证书,而 “GlobalSign Organization Validation CA - SHA256 - G2” 证书又信任 baidu.com 证书,于是客户端也信任 baidu.com 证书。
总括来说,由于用户信任 GlobalSign,所以由 GlobalSign 所担保的 baidu.com 可以被信任,另外由于用户信任操作系统或浏览器的软件商,所以由软件商预载了根证书的 GlobalSign 都可被信任。
这样的一层层地验证就构成了一条信任链路,整个证书信任链验证流程如下图所示:
最后一个问题,为什么需要证书链这么麻烦的流程?Root CA 为什么不直接颁发证书,而是要搞那么多中间层级呢?
这是为了确保根证书的绝对安全性,将根证书隔离地越严格越好,不然根证书如果失守了,那么整个信任链都会有问题。
2.4 HTTPS 的应用数据是如何保证完整性的?
TLS 在实现上分为握手协议和记录协议两层:
- TLS 握手协议就是我们前面说的 TLS 四次握手的过程,负责协商加密算法和生成对称密钥,后续用此密钥来保护应用程序数据(即 HTTP 数据);
- TLS 记录协议负责保护应用程序数据并验证其完整性和来源,所以对 HTTP 数据加密是使用记录协议;
TLS 记录协议主要负责消息(HTTP 数据)的压缩,加密及数据的认证,过程如下图:
具体过程如下:
- 首先,消息被分割成多个较短的片段,然后分别对每个片段进行压缩。
- 接下来,经过压缩的片段会被加上消息认证码(MAC 值,这个是通过哈希算法生成的),这是为了保证完整性,并进行数据的认证。通过附加消息认证码的 MAC 值,可以识别出篡改。与此同时,为了防止重放攻击,在计算消息认证码时,还加上了片段的编码。
- 再接下来,经过压缩的片段再加上消息认证码会一起通过对称密码进行加密。
- 最后,上述经过加密的数据再加上由数据类型、版本号、压缩后的长度组成的报头就是最终的报文数据。
记录协议完成后,最终的报文数据将传递到传输控制协议 (TCP) 层进行传输。
2.5 HTTPS 一定安全可靠吗?
之前有读者在字节面试的时候,被问到:HTTPS 一定安全可靠吗?
这个问题的场景是这样的:客户端通过浏览器向服务端发起 HTTPS 请求时,被「假基站」转发到了一个「中间人服务器」,于是客户端是和「中间人服务器」完成了 TLS 握手,然后这个「中间人服务器」再与真正的服务端完成 TLS 握手。
具体过程如下:
- 客户端向服务端发起 HTTPS 建立连接请求时,然后被「假基站」转发到了一个「中间人服务器」,接着中间人向服务端发起 HTTPS 建立连接请求,此时客户端与中间人进行 TLS 握手,中间人与服务端进行 TLS 握手;
- 在客户端与中间人进行 TLS 握手过程中,中间人会发送自己的公钥证书给客户端,客户端验证证书的真伪,然后从证书拿到公钥,并生成一个随机数,用公钥加密随机数发送给中间人,中间人使用私钥解密,得到随机数,此时双方都有随机数,然后通过算法生成对称加密密钥(A),后续客户端与中间人通信就用这个对称加密密钥来加密数据了。
- 在中间人与服务端进行 TLS 握手过程中,服务端会发送从 CA 机构签发的公钥证书给中间人,从证书拿到公钥,并生成一个随机数,用公钥加密随机数发送给服务端,服务端使用私钥解密,得到随机数,此时双方都有随机数,然后通过算法生成对称加密密钥(B),后续中间人与服务端通信就用这个对称加密密钥来加密数据了。
- 后续的通信过程中,中间人用对称加密密钥(A)解密客户端的 HTTPS 请求的数据,然后用对称加密密钥(B)加密 HTTPS 请求后,转发给服务端,接着服务端发送 HTTPS 响应数据给中间人,中间人用对称加密密钥(B)解密 HTTPS 响应数据,然后再用对称加密密钥(A)加密后,转发给客户端。
从客户端的角度看,其实并不知道网络中存在中间人服务器这个角色。那么中间人就可以解开浏览器发起的 HTTPS 请求里的数据,也可以解开服务端响应给浏览器的 HTTPS 响应数据。相当于,中间人能够 “偷看” 浏览器与服务端之间的 HTTPS 请求和响应的数据。
但是要发生这种场景是有前提的,前提是用户点击接受了中间人服务器的证书。
中间人服务器与客户端在 TLS 握手过程中,实际上发送了自己伪造的证书给浏览器,而这个伪造的证书是能被浏览器(客户端)识别出是非法的,于是就会提醒用户该证书存在问题。
如果用户执意点击「继续浏览此网站」,相当于用户接受了中间人伪造的证书,那么后续整个 HTTPS 通信都能被中间人监听了。
所以,这其实并不能说 HTTPS 不够安全,毕竟浏览器都已经提示证书有问题了,如果用户坚决要访问,那不能怪 HTTPS ,得怪自己手贱。
另外,如果你的电脑中毒了,被恶意导入了中间人的根证书,那么在验证中间人的证书的时候,由于你操作系统信任了中间人的根证书,那么等同于中间人的证书是合法的,这种情况下,浏览器是不会弹出证书存在问题的风险提醒的。
这其实也不关 HTTPS 的事情,是你电脑中毒了才导致 HTTPS 数据被中间人劫持的。
所以,HTTPS 协议本身到目前为止还是没有任何漏洞的,即使你成功进行中间人攻击,本质上是利用了客户端的漏洞(用户点击继续访问或者被恶意导入伪造的根证书),并不是 HTTPS 不够安全。
为什么抓包工具能截取 HTTPS 数据?
很多抓包工具 之所以可以明文看到 HTTPS 数据,工作原理与中间人一致的。
对于 HTTPS 连接来说,中间人要满足以下两点,才能实现真正的明文代理:
- 中间人,作为客户端与真实服务端建立连接这一步不会有问题,因为服务端不会校验客户端的身份;
- 中间人,作为服务端与真实客户端建立连接,这里会有客户端信任服务端的问题,也就是服务端必须有对应域名的私钥;
中间人要拿到私钥只能通过如下方式:
- 去网站服务端拿到私钥;
- 去CA处拿域名签发私钥;
- 自己签发证书,切要被浏览器信任;
不用解释,抓包工具只能使用第三种方式取得中间人的身份。
使用抓包工具进行 HTTPS 抓包的时候,需要在客户端安装 Fiddler 的根证书,这里实际上起认证中心(CA)的作用。
抓包工具能够抓包的关键是客户端会往系统受信任的根证书列表中导入抓包工具生成的证书,而这个证书会被浏览器信任,也就是抓包工具给自己创建了一个认证中心 CA,客户端拿着中间人签发的证书去中间人自己的 CA 去认证,当然认为这个证书是有效的。
如何避免被中间人抓取数据?
我们要保证自己电脑的安全,不要被病毒乘虚而入,而且也不要点击任何证书非法的网站,这样 HTTPS 数据就不会被中间人截取到了。
当然,我们还可以通过 HTTPS 双向认证来避免这种问题。
一般我们的 HTTPS 是单向认证,客户端只会验证了服务端的身份,但是服务端并不会验证客户端的身份。
如果用了双向认证方式,不仅客户端会验证服务端的身份,而且服务端也会验证客户端的身份。服务端一旦验证到请求自己的客户端为不可信任的,服务端就拒绝继续通信,客户端如果发现服务端为不可信任的,那么也中止通信。
3、HTTP/1.1、HTTP/2、HTTP/3 演变
3.1 HTTP/1.1 相比 HTTP/1.0 提高了什么性能?
HTTP/1.1 相比 HTTP/1.0 性能上的改进:
- 使用长连接的方式改善了 HTTP/1.0 短连接造成的性能开销。
- 支持管道(pipeline)网络传输,只要第一个请求发出去了,不必等其回来,就可以发第二个请求出去,可以减少整体的响应时间。
但 HTTP/1.1 还是有性能瓶颈:
- 请求 / 响应头部(Header)未经压缩就发送,首部信息越多延迟越大。只能压缩
Body
的部分; - 发送冗长的首部。每次互相发送相同的首部造成的浪费较多;
- 服务器是按请求的顺序响应的,如果服务器响应慢,会招致客户端一直请求不到数据,也就是队头阻塞;
- 没有请求优先级控制;
- 请求只能从客户端开始,服务器只能被动响应。
3.2 HTTP/2 做了什么优化?
HTTP/2 协议是基于 HTTPS 的,所以 HTTP/2 的安全性也是有保障的。
那 HTTP/2 相比 HTTP/1.1 性能上的改进:
- 头部压缩
- 二进制格式
- 并发传输
- 服务器主动推送资源
1. 头部压缩
HTTP/2 会压缩头(Header)如果你同时发出多个请求,他们的头是一样的或是相似的,那么,协议会帮你消除重复的部分。
这就是所谓的 HPACK
算法:在客户端和服务器同时维护一张头信息表,所有字段都会存入这个表,生成一个索引号,以后就不发送同样字段了,只发送索引号,这样就提高速度了。
2. 二进制格式
HTTP/2 不再像 HTTP/1.1 里的纯文本形式的报文,而是全面采用了二进制格式,头信息和数据体都是二进制,并且统称为帧(frame):头信息帧(Headers Frame)和数据帧(Data Frame) 。
这样虽然对人不友好,但是对计算机非常友好,因为计算机只懂二进制,那么收到报文后,无需再将明文的报文转成二进制,而是直接解析二进制报文,这增加了数据传输的效率。
比如状态码 200 ,在 HTTP/1.1 是用 '2''0''0' 三个字符来表示(二进制:00110010 00110000 00110000),共用了 3 个字节,如下图
在 HTTP/2 对于状态码 200 的二进制编码是 10001000,只用了 1 字节就能表示,相比于 HTTP/1.1 节省了 2 个字节,如下图:
Header: :status: 200 OK 的编码内容为:1000 1000,那么表达的含义是什么呢?
- 最前面的 1 标识该 Header 是静态表中已经存在的 KV。
- 在静态表里,“:status: 200 ok” 静态表编码是 8,二进制即是 1000。
因此,整体加起来就是 1000 1000。
3. 并发传输
我们都知道 HTTP/1.1 的实现是基于请求-响应模型的。同一个连接中,HTTP 完成一个事务(请求与响应),才能处理下一个事务,也就是说在发出请求等待响应的过程中,是没办法做其他事情的,如果响应迟迟不来,那么后续的请求是无法发送的,也造成了队头阻塞的问题。
而 HTTP/2 就很牛逼了,引出了 Stream 概念,多个 Stream 复用在一条 TCP 连接。
从上图可以看到,1 个 TCP 连接包含多个 Stream,Stream 里可以包含 1 个或多个 Message,Message 对应 HTTP/1 中的请求或响应,由 HTTP 头部和包体构成。Message 里包含一条或者多个 Frame,Frame 是 HTTP/2 最小单位,以二进制压缩格式存放 HTTP/1 中的内容(头部和包体)。
针对不同的 HTTP 请求用独一无二的 Stream ID 来区分,接收端可以通过 Stream ID 有序组装成 HTTP 消息,不同 Stream 的帧是可以乱序发送的,因此可以并发不同的 Stream ,也就是 HTTP/2 可以并行交错地发送请求和响应。
比如下图,服务端并行交错地发送了两个响应: Stream 1 和 Stream 3,这两个 Stream 都是跑在一个 TCP 连接上,客户端收到后,会根据相同的 Stream ID 有序组装成 HTTP 消息。
4、服务器推送
HTTP/2 还在一定程度上改善了传统的「请求 - 应答」工作模式,服务端不再是被动地响应,可以主动向客户端发送消息。
客户端和服务器双方都可以建立 Stream, Stream ID 也是有区别的,客户端建立的 Stream 必须是奇数号,而服务器建立的 Stream 必须是偶数号。
比如下图,Stream 1 是客户端向服务端请求的资源,属于客户端建立的 Stream,所以该 Stream 的 ID 是奇数(数字 1);Stream 2 和 4 都是服务端主动向客户端推送的资源,属于服务端建立的 Stream,所以这两个 Stream 的 ID 是偶数(数字 2 和 4)。
再比如,客户端通过 HTTP/1.1 请求从服务器那获取到了 HTML 文件,而 HTML 可能还需要依赖 CSS 来渲染页面,这时客户端还要再发起获取 CSS 文件的请求,需要两次消息往返,如下图左边部分:
如上图右边部分,在 HTTP/2 中,客户端在访问 HTML 时,服务器可以直接主动推送 CSS 文件,减少了消息传递的次数。
HTTP/2 有什么缺陷?
HTTP/2 通过 Stream 的并发能力,解决了 HTTP/1 队头阻塞的问题,看似很完美了,但是 HTTP/2 还是存在“队头阻塞”的问题,只不过问题不是在 HTTP 这一层面,而是在 TCP 这一层。
HTTP/2 是基于 TCP 协议来传输数据的,TCP 是字节流协议,TCP 层必须保证收到的字节数据是完整且连续的,这样内核才会将缓冲区里的数据返回给 HTTP 应用,那么当「前 1 个字节数据」没有到达时,后收到的字节数据只能存放在内核缓冲区里,只有等到这 1 个字节数据到达时,HTTP/2 应用层才能从内核中拿到数据,这就是 HTTP/2 队头阻塞问题。
举个例子,如下图:
图中发送方发送了很多个 packet,每个 packet 都有自己的序号,你可以认为是 TCP 的序列号,其中 packet 3 在网络中丢失了,即使 packet 4-6 被接收方收到后,由于内核中的 TCP 数据不是连续的,于是接收方的应用层就无法从内核中读取到,只有等到 packet 3 重传后,接收方的应用层才可以从内核中读取到数据,这就是 HTTP/2 的队头阻塞问题,是在 TCP 层面发生的。
所以,一旦发生了丢包现象,就会触发 TCP 的重传机制,这样在一个 TCP 连接中的所有的 HTTP 请求都必须等待这个丢了的包被重传回来。
3.3 HTTP/3 做了哪些优化?
前面我们知道了 HTTP/1.1 和 HTTP/2 都有队头阻塞的问题:
- HTTP/1.1 中的管道( pipeline)虽然解决了请求的队头阻塞,但是没有解决响应的队头阻塞,因为服务端需要按顺序响应收到的请求,如果服务端处理某个请求消耗的时间比较长,那么只能等响应完这个请求后, 才能处理下一个请求,这属于 HTTP 层队头阻塞。
- HTTP/2 虽然通过多个请求复用一个 TCP 连接解决了 HTTP 的队头阻塞 ,但是一旦发生丢包,就会阻塞住所有的 HTTP 请求,这属于 TCP 层队头阻塞。
HTTP/2 队头阻塞的问题是因为 TCP,所以 HTTP/3 把 HTTP 下层的 TCP 协议改成了 UDP!
UDP 发送是不管顺序,也不管丢包的,所以不会出现像 HTTP/2 队头阻塞的问题。大家都知道 UDP 是不可靠传输的,但基于 UDP 的 QUIC 协议 可以实现类似 TCP 的可靠性传输。
QUIC 有以下 3 个特点。
- 无队头阻塞
- 更快的连接建立
- 连接迁移
1、无队头阻塞
QUIC 协议也有类似 HTTP/2 Stream 与多路复用的概念,也是可以在同一条连接上并发传输多个 Stream,Stream 可以认为就是一条 HTTP 请求。
QUIC 有自己的一套机制可以保证传输的可靠性的。当某个流发生丢包时,只会阻塞这个流,其他流不会受到影响,因此不存在队头阻塞问题。这与 HTTP/2 不同,HTTP/2 只要某个流中的数据包丢失了,其他流也会因此受影响。
所以,QUIC 连接上的多个 Stream 之间并没有依赖,都是独立的,某个流发生丢包了,只会影响该流,其他流不受影响。
2、更快的连接建立
对于 HTTP/1 和 HTTP/2 协议,TCP 和 TLS 是分层的,分别属于内核实现的传输层、openssl 库实现的表示层,因此它们难以合并在一起,需要分批次来握手,先 TCP 握手,再 TLS 握手。
HTTP/3 在传输数据前虽然需要 QUIC 协议握手,但是这个握手过程只需要 1 RTT,握手的目的是为确认双方的「连接 ID」,连接迁移就是基于连接 ID 实现的。
但是 HTTP/3 的 QUIC 协议并不是与 TLS 分层,而是 QUIC 内部包含了 TLS,它在自己的帧会携带 TLS 里的“记录”,再加上 QUIC 使用的是 TLS/1.3,因此仅需 1 个 RTT 就可以「同时」完成建立连接与密钥协商,如下图:
甚至,在第二次连接的时候,应用数据包可以和 QUIC 握手信息(连接信息 + TLS 信息)一起发送,达到 0-RTT 的效果。
如下图右边部分,HTTP/3 当会话恢复时,有效负载数据与第一个数据包一起发送,可以做到 0-RTT(下图的右下角):
3、连接迁移
基于 TCP 传输协议的 HTTP 协议,由于是通过四元组(源 IP、源端口、目的 IP、目的端口)确定一条 TCP 连接。
那么当移动设备的网络从 4G 切换到 WIFI 时,意味着 IP 地址变化了,那么就必须要断开连接,然后重新建立连接。而建立连接的过程包含 TCP 三次握手和 TLS 四次握手的时延,以及 TCP 慢启动的减速过程,给用户的感觉就是网络突然卡顿了一下,因此连接的迁移成本是很高的。
而 QUIC 协议没有用四元组的方式来“绑定”连接,而是通过连接 ID 来标记通信的两个端点,客户端和服务器可以各自选择一组 ID 来标记自己,因此即使移动设备的网络变化后,导致 IP 地址变化了,只要仍保有上下文信息(比如连接 ID、TLS 密钥等),就可以“无缝”地复用原连接,消除重连的成本,没有丝毫卡顿感,达到了连接迁移的功能。
所以, QUIC 是一个在 UDP 之上的伪 TCP + TLS + HTTP/2 的多路复用的协议。
QUIC 是新协议,对于很多网络设备,根本不知道什么是 QUIC,只会当做 UDP,这样会出现新的问题,因为有的网络设备是会丢掉 UDP 包的,而 QUIC 是基于 UDP 实现的,那么如果网络设备无法识别这个是 QUIC 包,那么就会当作 UDP包,然后被丢弃。
一、沉默王二-JVM-JVM 核心知识点
1、Java 内存区域
1.2 程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。
字节码解释器通过改变程序计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要该计数器来完成。
每个线程都拥有一个独立的程序计数器,各个线程之间的计数器互不影响,独立存储。
1.3 虚拟机栈
虚拟机栈(Java Virtual Machine Stack)也是线程私有,它描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
方法从调用到结束就对应着一个栈帧从入栈到出栈的过程。在《Java 虚拟机规范》中,对该内存区域规定了两类异常:
- 如果线程请求的栈深度大于虚拟机所允许的栈深度,将抛出
StackOverflowError
异常; - 如果 Java 虚拟机栈的容量允许动态扩展,当栈扩展时如果无法申请到足够的内存会抛出
OutOfMemoryError
异常。
1.3 本地方法栈
本地方法栈(Native Method Stacks)与虚拟机栈类似,其区别在于:Java 虚拟机栈是为虚拟机执行 Java 方法(也就是字节码)服务的,而本地方法栈则是为 JVM 使用到的本地(Native)方法服务。
1.4 堆
堆(Java Heap)是虚拟机所管理的最大一块内存空间,它被所有线程所共享,用于存放对象实例。
Java 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为是连续的。Java 堆可以被实现成固定大小的,也可以是可扩展的。
当前大多数主流的虚拟机都是按照可扩展来实现的,即可以通过最大值参数 -Xmx
和最小值参数 -Xms
进行设定。
如果 Java 堆中没有足够的内存来完成对象实例分配,并且堆也无法再扩展时,Java 虚拟机将会抛出 OutOfMemoryError
异常。
1.5 方法区
方法区(Method Area)也是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、JIT 编译后的代码缓存等数据。
方法区也被称为 “非堆”,目的是与 Java 堆进行区分。《Java 虚拟机规范》规定,如果方法区无法满足新的内存分配需求时,将会抛出 OutOfMemoryError
异常。
JDK 8 以后的方法区实现已经不再是永久代(Permanent Generation)了,而是使用元空间(Metaspace)来实现。
运行时常量池(Runtime Constant Pool)也是方法区的一部分,用于存放常量池表(Constant Pool Table),常量池表中存放了编译期生成的各种符号字面量和符号引用。
JDK 8 以后的运行时常量池在元空间中。
2、 对象
2.1 对象的创建
当我们在代码中使用 new
关键字创建一个对象时,其在 JVM 中需要经过以下步骤:
2.1.1 类加载过程
当虚拟机遇到一条字节码指令 new
时,首先将去检查这个指令的参数是否能在常量池中定位到一个符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,就必须先执行相应的类加载过程。
2.1.2 分配内存
在类加载检查通过后,虚拟机需要给新生对象分配内存空间。根据 Java 堆是否规整,可以有以下两种分配方案:
①、指针碰撞:假设 Java 堆中内存是绝对规整的,所有使用的内存放在一边,所有未被使用的内存放在另外一边,中间以指针作为分界点指示器。
此时内存分配只是将指针向空闲方向偏移出对象大小的空间即可,这种方式被称为指针碰撞。
②、空闲列表:如果 Java 堆不是规整的,此时虚拟机需要维护一个列表,记录哪些内存块是可用的,哪些是不可用的。在进行内存分配时,只需要从该列表中选取出一块足够的内存空间划分给对象实例即可。
注:Java 堆是否规整取决于其采用的垃圾收集器是否带有空间压缩整理能力,前面讲过了。
除了分配方式外,由于对象创建在虚拟机中是一个非常频繁的行为,此时需要保证在并发环境下的线程安全:如果一个线程给对象 A 分配了内存空间,但指针还没来得及修改,此时就可能出现另外一个线程使用原来的指针来给对象 B 分配内存空间的情况。
想要解决这个问题有两个方案:
①、方式一:采用同步锁定,或采用 CAS 配上失败重试的方式来保证更新操作的原子性。
②、方式二:为每个线程在 Java 堆中预先分配一块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。
线程在进行内存分配时优先使用本地缓冲,当本地缓冲使用完成后,再向 Java 堆申请分配,此时 Java 堆采用同步锁定的方式来保证分配行为的线程安全。
2.1.3 对象头设置
将对象有关的元数据信息、对象的哈希码、分代年龄等信息存储到对象头中。
可以和 JIT 那节的内容关联起来。
2.1.4 对象初始化
调用对象的构造方法,即 Class 文件中的
来初始化对象,为相关字段赋值。
2.2 对象的内存布局
在 HotSpot 中,对象在堆内存中的存储布局可以划分为以下三个部分:
2.2.1 对象头 (Header)
对象头包括两部分信息:
- Mark Word:对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,官方统称为 Mark Word,我们曾在 synchronized 的四种锁状态讲过。
- 类型指针:对象指向它类型元数据的指针,Java 虚拟机通过这个指针来确定该对象是哪个类的实例。需要说明的是,并非所有的虚拟机都必须要在对象数据上保留类型指针,这取决于对象的访问定位方式。
2.2.2 实例数据 (Instance Data)
即我们在代码中定义的各种类型的字段,无论是从父类继承而来,还是子类中定义的都需要记录。
2.2.3 对齐填充 (Padding)
主要起占位符的作用。HotSpot 要求对象起始地址必须是 8 字节的整倍数,即间接要求了任何对象的大小都必须是 8 字节的整倍数。对象头部分在设计上就是 8 字节的整倍数,如果对象的实例数据不是 8 字节的整倍数,则由对齐填充进行补全。
2.3 对象的访问定位
对象创建后,Java 程序就可以通过栈上的 reference
(也就是引用)来操作堆上的具体对象。
《Java 虚拟机规范》规定 reference
是一个指向对象的引用,但并未规定其具体实现方式。主流的方式方式有以下两种:
- 句柄访问:Java 堆将划分出一块内存来作为句柄池,
reference
中存储的是对象的句柄地址,而句柄则包含了对象实例数据和类型数据的地址信息。 - 指针访问:
reference
中存储的直接就是对象地址,而对象的类型数据则由上文介绍的对象头中的类型指针来指定。
通过句柄访问对象:
通过直接指针访问对象:
句柄访问的优点在于对象移动时(垃圾收集时移动对象是非常普遍的行为)只需要改变句柄中实例数据的指针,而 reference
本身并不需要修改;
指针访问则反之,由于其 reference
中存储的直接就是对象地址,所以当对象移动时, reference
需要被修改。但针对只需要访问对象本身的场景,指针访问则可以减少一次定位开销。由于对象访问是一项非常频繁的操作,所以这类减少的效果会非常显著,基于这个原因,HotSpot 主要使用的是指针访问的方式。
3、垃圾收集机制
在 JVM 内存模型中,程序计数器、虚拟机栈、本地方法栈这 3 个区域都是线程私有的,会随着线程的结束而销毁,因此在这 3 个区域当中,无需过多考虑垃圾回收问题。垃圾回收问题主要发生在 Java 堆上。
在 Java 堆上,垃圾回收的主要内容是死亡的对象(不可能再被任何途径使用的对象)。
判断对象是否死亡有以下两种方法:
3.1 引用计数法
在对象中添加一个引用计数器,对象每次被引用时,该计数器加一;当引用失效时,计数器的值减一;只要计数器的值为零,则代表对应的对象不可能再被使用。该方法的缺点在于无法避免相互引用的问题:
java 代码解读复制代码objA.instance = objB
objB.instance = objA
objA = null;
objB = null;
System.gc();
如上所示,此时两个对象已经不能再被访问,但其互相持有对对方的引用,如果采用引用计数法,则两个对象都无法被回收。
3.2 可达性分析
但上面的代码在大多数虚拟机中都能被正确的回收,因为大多数主流的虚拟机都是采用的可达性分析方法来判断对象是否死亡。
可达性分析是通过一系列被称为 GC Roots
的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径被称为引用链(Reference Chain),如果某个对象到 GC Roots
间没有任何引用链相连,这代表 GC Roots
到该对象不可达, 此时证明该对象不可能再被使用。
在 Java 语言中,固定可作为 GC Roots
的对象包括以下几种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等;
- 在方法区(元空间)中类静态变量引用的对象,譬如 Java 类中引用类型的静态变量;
- 在方法区(元空间)中常量引用的对象,譬如字符串常量池(String Table)里的引用;
- 在本地方法栈中的 JNI(Native 方法)引用的对象;
- Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(如 NullPointException,OutOfMemoryError 等)及系统类加载器;
- 所有被同步锁(synchronized 关键字)持有的对象;
除了这些固定的 GC Roots
集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域的不同,还可能会有其他对象 “临时性” 地加入,共同构成完整的 GC Roots
集合。
3.3 对象引用
可达性分析是基于引用链进行判断的,在 JDK 1.2 之后,Java 将引用关系分为以下四类:
3.3.1 强引用 (Strongly Reference)
最传统的引用,如 Object obj = new Object()
。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
3.3.2 软引用 (Soft Reference)
用于描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出之前,会被列入回收范围内进行第二次回收,如果这次回收后还没有足够的内存,才会抛出内存溢出异常。
下面是一个使用 Java 中 SoftReference 类的示例代码:
java 代码解读复制代码class SoftReferenceExample {
public static void main(String[] args) {
// 创建一个强引用的对象
String strongReference = new String("二哥,我是个强引用");
// 创建一个软引用,指向上面的对象
SoftReference softReference = new SoftReference<>(strongReference);
// 干掉强引用
strongReference = null;
// 现在只有软引用指向 "二哥,我是个强引用" 对象
// 尝试通过软引用获取对象
String retrievedString = softReference.get();
System.out.println(retrievedString); // 输出 "二哥,我是个强引用"
// 强制进行垃圾回收,可能会清除软引用的对象
System.gc();
// 再次尝试通过软引用获取对象
retrievedString = softReference.get();
if (retrievedString != null) {
System.out.println(retrievedString);
} else {
System.out.println("软引用的对象已被垃圾回收");
}
}
}
这个例子中,我们首先创建了一个字符串对象的强引用,然后通过 SoftReference 创建了这个对象的软引用。在取消了强引用后,这个对象只剩下软引用。当我们尝试通过软引用获取对象时,如果对象还存在,软引用会返回它;如果对象已被垃圾收集器回收,则返回 null。
需要注意的是,第二次回收时,如果这次回收后还没有足够的内存,才会抛出内存溢出异常。这里的 “足够” 是指在抛出内存溢出异常之前,系统会进行最后一次尝试,如果这次回收后还是没有足够的内存,才会抛出内存溢出异常。
通常情况下,上面代码在执行 gc 后软引用不会被回收,因为此时内存还是足够的。
java代码解读复制代码二哥,我是个强引用 二哥,我是个强引用
3.3.3 弱引用 (Weak Reference)
用于描述那些非必须的对象,强度比软引用弱。被弱引用关联的对象只能生存到下一次垃圾收集发生时,无论当前内存是否足够,弱引用对象都会被回收。
来看这段代码:
java 代码解读复制代码class WeakReferenceExample {
public static void main(String[] args) {
// 创建一个强引用的对象
String strongReference = new String("二哥,我是强引用");
// 创建一个弱引用,指向上面的对象
WeakReference weakReference = new WeakReference<>(strongReference);
// 取消强引用
strongReference = null;
// 强制进行垃圾回收
System.gc();
// 尝试通过弱引用获取对象
String retrievedString = weakReference.get();
if (retrievedString != null) {
System.out.println(retrievedString);
} else {
System.out.println("弱引用的对象已被垃圾回收");
}
}
}
这个例子中,我们首先创建了一个字符串对象的强引用,然后通过 WeakReference 创建了这个对象的弱引用。在取消了强引用后,这个对象只剩下弱引用。当我们尝试通过弱引用获取对象时,如果对象还存在,弱引用会返回它;如果对象已被垃圾收集器回收,则返回 null。
运行结果就和软引用不一样了,gc 后弱引用被回收了。
java代码解读复制代码弱引用的对象已被垃圾回收
3.3.4 虚引用 (Phantom Reference)
最弱的引用关系。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被回收时收到一个系统通知。
虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾收集器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入到与之关联的引用队列中。
来看这个示例代码:
java 代码解读复制代码class PhantomReferenceExample {
public static void main(String[] args) {
// 创建一个强引用的对象
String strongReference = new String("二哥,我是强引用");
// 创建一个引用队列
ReferenceQueue referenceQueue = new ReferenceQueue<>();
// 创建一个虚引用,指向上面的对象,并与引用队列关联
PhantomReference phantomReference = new PhantomReference<>(strongReference, referenceQueue);
// 取消强引用
strongReference = null;
// 强制进行垃圾回收
System.gc();
// 检查引用队列,看是否有通知
if (referenceQueue.poll() != null) {
System.out.println("虚引用的对象已被垃圾回收,且收到了通知");
} else {
System.out.println("虚引用的对象未被垃圾回收,或未收到通知");
}
}
}
这个例子中,我们首先创建了一个字符串对象的强引用,然后通过 PhantomReference 创建了这个对象的虚引用,并与引用队列关联。在取消了强引用后,这个对象只剩下虚引用。当我们强制进行垃圾回收时,如果对象还存在,虚引用不会返回它;如果对象已被垃圾收集器回收,则返回 null。同时,如果对象被回收,虚引用会被加入到引用队列中。
运行结果如下:
java代码解读复制代码虚引用的对象已被垃圾回收,且收到了通知
3.4 对象真正死亡
要真正宣告一个对象死亡,需要经过至少两次标记过程:
①、如果对象在进行可达性分析后发现 GC Roots
不可达,将会进行第一次标记;
②、随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalized()
方法。
如果对象没有覆盖 finalized()
方法,或者 finalized()
已经被虚拟机调用过,这两种情况都会视为没有必要执行。
如果判定结果是有必要执行,此时对象会被放入名为 F-Queue
的队列,等待 Finalizer 线程执行其 finalized()
方法。
在这个过程中,收集器会进行第二次小规模的标记,如果对象在 finalized()
方法中重新将自己与引用链上的任何一个对象进行了关联,如将自己(this 关键字)赋值给某个类变量或者对象的成员变量,此时它就实现了自我拯救,则第二次标记会将其移除 “即将回收” 的集合,否则该对象就将被真正回收,走向死亡。
3.5 垃圾收集算法
3.5.1 分代收集理论
当前大多数虚拟机都遵循 “分代收集” 的理论进行设计,它建立在强弱两个分代假说下:
- 弱分代假说 (Weak Generational Hypothesis) :绝大多数对象都是朝生夕灭的。
- 强分代假说 (Strong Generational Hypothesis) :熬过越多次垃圾收集过程的对象就越难以消亡。
- 跨带引用假说 (Intergenerational Reference Hypothesis) :基于上面两条假说还可以得出的一条隐含推论:存在相互引用关系的两个对象,应该倾向于同时生存或者同时消亡。
强弱分代假说奠定了垃圾收集器的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄就是对象经历垃圾收集的次数)分配到不同的区域中进行存储。
之后如果一个区域中的对象都是朝生夕灭的,那么收集器只需要关注少量对象的存活而不是去标记那些大量将要被回收的对象,此时就能以较小的代价获取较大的空间。
最后再将难以消亡的对象集中到一块,根据强分代假说,它们是很难消亡的,因此虚拟机可以使用较低的频率进行回收,这就兼顾了时间和内存空间的开销。
3.5.2 回收类型
根据分代收集理论,收集范围可以分为以下几种类型:
①、部分收集 (Partial GC) :具体分为:
- 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
- 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
- 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
- 整堆收集 (Full GC) :收集整个 Java 堆和方法区。
3.5.3 标记-清除算法
它是最基础的垃圾收集算法,收集过程分为两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象;也可以反过来,标记存活对象,统一回收所有未被标记的对象。
它主要有以下两个缺点:
- 执行效率不稳定:如果 Java 堆上包含大量需要回收的对象,则需要进行大量标记和清除动作;
- 内存空间碎片化:标记清除后会产生大量不连续的空间,从而导致无法为大对象分配足够的连续内存。
3.5.4 标记-复制算法
标记-复制算法基于 ”半区复制“ 算法:它将可用内存按容量划分为大小相等的两块,每次只使用其中一块,当这一块的内存使用完了,就将还存活着的对象复制到另外一块,然后再把已经使用过的那块内存空间一次性清理掉。其优点在于避免了内存空间碎片化的问题,其缺点如下:
- 如果内存中多数对象都是存活的,这种算法将产生大量的复制开销;
- 浪费内存空间,内存空间变为了原有的一半。
基于新生代 “朝生夕灭” 的特点,大多数虚拟机都不会按照 1:1 的比例来进行内存划分,例如 HotSpot 会将内存空间划分为一块较大的 Eden
和两块较小的 Survivor
空间,它们之间的比例是 8:1:1 。
每次分配时只会使用 Eden
和其中的一块 Survivor
,发生垃圾回收时,只需要将存活的对象一次性复制到另外一块 Survivor
上,这样只有 10% 的内存空间会被浪费掉。
当 Survivor
空间不足以容纳一次 Minor GC
时,此时由其他内存区域(通常是老年代)来进行分配担保。
3.5.5 标记-整理算法
标记-整理算法是在标记完成后,让所有存活对象都向内存的一端移动,然后直接清理掉边界以外的内存。
其优点在于可以避免内存空间碎片化的问题,也可以充分利用内存空间;其缺点在于根据所使用的收集器的不同,在移动存活对象时可能要全程暂停用户程序:
二、小林-图解网络-HTTP相关问题
1、HTTP 特性
到目前为止,HTTP 常见到版本有 HTTP/1.1,HTTP/2.0,HTTP/3.0,不同版本的 HTTP 特性是不一样的。
1.1 HTTP/1.1 的优点有哪些?
HTTP 最突出的优点是「简单、灵活和易于扩展、应用广泛和跨平台」。
1. 简单
HTTP 基本的报文格式就是 header + body
,头部信息也是 key-value
简单文本的形式,易于理解,降低了学习和使用的门槛。
2. 灵活和易于扩展
HTTP 协议里的各类请求方法、URI/URL、状态码、头字段等每个组成要求都没有被固定死,都允许开发人员自定义和扩充。
同时 HTTP 由于是工作在应用层( OSI
第七层),则它下层可以随意变化,比如:
- HTTPS 就是在 HTTP 与 TCP 层之间增加了 SSL/TLS 安全传输层;
- HTTP/1.1 和 HTTP/2.0 传输协议使用的是 TCP 协议,而到了 HTTP/3.0 传输协议改用了 UDP 协议。
3. 应用广泛和跨平台
互联网发展至今,HTTP 的应用范围非常的广泛,从台式机的浏览器到手机上的各种 APP,从看新闻、刷贴吧到购物、理财、吃鸡,HTTP 的应用遍地开花,同时天然具有跨平台的优越性。
1.2 HTTP/1.1 的缺点有哪些?
HTTP 协议里有优缺点一体的双刃剑,分别是「无状态、明文传输」,同时还有一大缺点「不安全」。
1. 无状态双刃剑
无状态的好处,因为服务器不会去记忆 HTTP 的状态,所以不需要额外的资源来记录状态信息,这能减轻服务器的负担,能够把更多的 CPU 和内存用来对外提供服务。
无状态的坏处,既然服务器没有记忆能力,它在完成有关联性的操作时会非常麻烦。
例如登录->添加购物车->下单->结算->支付,这系列操作都要知道用户的身份才行。但服务器不知道这些请求是有关联的,每次都要问一遍身份信息。
这样每操作一次,都要验证信息,这样的购物体验还能愉快吗?别问,问就是酸爽!
对于无状态的问题,解法方案有很多种,其中比较简单的方式用 Cookie 技术。
Cookie
通过在请求和响应报文中写入 Cookie 信息来控制客户端的状态。
相当于,在客户端第一次请求后,服务器会下发一个装有客户信息的「小贴纸」,后续客户端请求服务器的时候,带上「小贴纸」,服务器就能认得了了,
2. 明文传输双刃剑
明文意味着在传输过程中的信息,是可方便阅读的,比如 Wireshark 抓包都可以直接肉眼查看,为我们调试工作带了极大的便利性。
但是这正是这样,HTTP 的所有信息都暴露在了光天化日下,相当于信息裸奔。在传输的漫长的过程中,信息的内容都毫无隐私可言,很容易就能被窃取,如果里面有你的账号密码信息,那你号没了。
3. 不安全
HTTP 比较严重的缺点就是不安全:
- 通信使用明文(不加密),内容可能会被窃听。比如,账号信息容易泄漏,那你号没了。
- 不验证通信方的身份,因此有可能遭遇伪装。比如,访问假的淘宝、拼多多,那你钱没了。
- 无法证明报文的完整性,所以有可能已遭篡改。比如,网页上植入垃圾广告,视觉污染,眼没了。
HTTP 的安全问题,可以用 HTTPS 的方式解决,也就是通过引入 SSL/TLS 层,使得在安全上达到了极致。
1.3 HTTP/1.1 的性能如何?
HTTP 协议是基于 TCP/IP,并且使用了「请求 - 应答」的通信模式,所以性能的关键就在这两点里。
1. 长连接
早期 HTTP/1.0 性能上的一个很大的问题,那就是每发起一个请求,都要新建一次 TCP 连接(三次握手),而且是串行请求,做了无谓的 TCP 连接建立和断开,增加了通信开销。
为了解决上述 TCP 连接问题,HTTP/1.1 提出了长连接的通信方式,也叫持久连接。这种方式的好处在于减少了 TCP 连接的重复建立和断开所造成的额外开销,减轻了服务器端的负载。
持久连接的特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。
当然,如果某个 HTTP 长连接超过一定时间没有任何数据交互,服务端就会主动断开这个连接。
2. 管道网络传输
HTTP/1.1 采用了长连接的方式,这使得管道(pipeline)网络传输成为了可能。
即可在同一个 TCP 连接里面,客户端可以发起多个请求,只要第一个请求发出去了,不必等其回来,就可以发第二个请求出去,可以减少整体的响应时间。
举例来说,客户端需要请求两个资源。以前的做法是,在同一个 TCP 连接里面,先发送 A 请求,然后等待服务器做出回应,收到后再发出 B 请求。那么,管道机制则是允许浏览器同时发出 A 请求和 B 请求,如下图:
但是服务器必须按照接收请求的顺序发送对这些管道化请求的响应。
如果服务端在处理 A 请求时耗时比较长,那么后续的请求的处理都会被阻塞住,这称为「队头堵塞」。
所以,HTTP/1.1 管道解决了请求的队头阻塞,但是没有解决响应的队头阻塞。
实际上 HTTP/1.1 管道化技术不是默认开启,而且浏览器基本都没有支持,所以后面所有文章讨论 HTTP/1.1 都是建立在没有使用管道化的前提。
3. 队头阻塞
「请求 - 应答」的模式会造成 HTTP 的性能问题。为什么呢?
因为当顺序发送的请求序列中的一个请求因为某种原因被阻塞时,在后面排队的所有请求也一同被阻塞了,会招致客户端一直请求不到数据,这也就是「队头阻塞」,好比上班的路上塞车。
总之 HTTP/1.1 的性能一般般,后续的 HTTP/2 和 HTTP/3 就是在优化 HTTP 的性能。
2、HTTP 与 HTTPS
2.1 HTTP 与 HTTPS 有哪些区别?
- HTTP 是超文本传输协议,信息是明文传输,存在安全风险的问题。HTTPS 则解决 HTTP 不安全的缺陷,在 TCP 和 HTTP 网络层之间加入了 SSL/TLS 安全协议,使得报文能够加密传输。
- HTTP 连接建立相对简单, TCP 三次握手之后便可进行 HTTP 的报文传输。而 HTTPS 在 TCP 三次握手之后,还需进行 SSL/TLS 的握手过程,才可进入加密报文传输。
- 两者的默认端口不一样,HTTP 默认端口号是 80,HTTPS 默认端口号是 443。
- HTTPS 协议需要向 CA(证书权威机构)申请数字证书,来保证服务器的身份是可信的。
2.2 HTTPS 解决了 HTTP 的哪些问题?
HTTP 由于是明文传输,所以安全上存在以下三个风险:
- 窃听风险,比如通信链路上可以获取通信内容,用户号容易没。
- 篡改风险,比如强制植入垃圾广告,视觉污染,用户眼容易瞎。
- 冒充风险,比如冒充淘宝网站,用户钱容易没。
HTTPS 在 HTTP 与 TCP 层之间加入了 SSL/TLS
协议,可以很好的解决了上述的风险:
- 信息加密:交互信息无法被窃取,但你的号会因为「自身忘记」账号而没。
- 校验机制:无法篡改通信内容,篡改了就不能正常显示,但百度「竞价排名」依然可以搜索垃圾广告。
- 身份证书:证明淘宝是真的淘宝网,但你的钱还是会因为「剁手」而没。
可见,只要自身不做「恶」,SSL/TLS 协议是能保证通信是安全的。
HTTPS 是如何解决上面的三个风险的?
- 混合加密的方式实现信息的机密性,解决了窃听的风险。
- 摘要算法的方式来实现完整性,它能够为数据生成独一无二的「指纹」,指纹用于校验数据的完整性,解决了篡改的风险。
- 将服务器公钥放入到数字证书中,解决了冒充的风险。
1. 混合加密
通过混合加密的方式可以保证信息的机密性,解决了窃听的风险。
HTTPS 采用的是对称加密和非对称加密结合的「混合加密」方式:
- 在通信建立前采用非对称加密的方式交换「会话秘钥」,后续就不再使用非对称加密。
- 在通信过程中全部使用对称加密的「会话秘钥」的方式加密明文数据。
采用「混合加密」的方式的原因:
- 对称加密只使用一个密钥,运算速度快,密钥必须保密,无法做到安全的密钥交换。
- 非对称加密使用两个密钥:公钥和私钥,公钥可以任意分发而私钥保密,解决了密钥交换问题但速度慢。
2. 摘要算法 + 数字签名
为了保证传输的内容不被篡改,我们需要对内容计算出一个「指纹」,然后同内容一起传输给对方。
对方收到后,先是对内容也计算出一个「指纹」,然后跟发送方发送的「指纹」做一个比较,如果「指纹」相同,说明内容没有被篡改,否则就可以判断出内容被篡改了。
那么,在计算机里会用摘要算法(哈希函数)来计算出内容的哈希值,也就是内容的「指纹」,这个哈希值是唯一的,且无法通过哈希值推导出内容。
通过哈希算法可以确保内容不会被篡改,但是并不能保证「内容 + 哈希值」不会被中间人替换,因为这里缺少对客户端收到的消息是否来源于服务端的证明。
举个例子,你想向老师请假,一般来说是要求由家长写一份请假理由并签名,老师才能允许你请假。
但是你有模仿你爸爸字迹的能力,你用你爸爸的字迹写了一份请假理由然后签上你爸爸的名字,老师一看到这个请假条,查看字迹和签名,就误以为是你爸爸写的,就会允许你请假。
那作为老师,要如何避免这种情况发生呢?现实生活中的,可以通过电话或视频来确认是否是由父母发出的请假,但是计算机里可没有这种操作。
那为了避免这种情况,计算机里会用非对称加密算法来解决,共有两个密钥:
- 一个是公钥,这个是可以公开给所有人的;
- 一个是私钥,这个必须由本人管理,不可泄露。
这两个密钥可以双向加解密的,比如可以用公钥加密内容,然后用私钥解密,也可以用私钥加密内容,公钥解密内容。
流程的不同,意味着目的也不相同:
- 公钥加密,私钥解密。这个目的是为了保证内容传输的安全,因为被公钥加密的内容,其他人是无法解密的,只有持有私钥的人,才能解密出实际的内容;
- 私钥加密,公钥解密。这个目的是为了保证消息不会被冒充,因为私钥是不可泄露的,如果公钥能正常解密出私钥加密的内容,就能证明这个消息是来源于持有私钥身份的人发送的。
一般我们不会用非对称加密来加密实际的传输内容,因为非对称加密的计算比较耗费性能的。
所以非对称加密的用途主要在于通过「私钥加密,公钥解密」的方式,来确认消息的身份,我们常说的数字签名算法,就是用的是这种方式,不过私钥加密内容不是内容本身,而是对内容的哈希值加密。
私钥是由服务端保管,然后服务端会向客户端颁发对应的公钥。如果客户端收到的信息,能被公钥解密,就说明该消息是由服务器发送的。
引入了数字签名算法后,你就无法模仿你爸爸的字迹来请假了,你爸爸手上持有着私钥,你老师持有着公钥。
这样只有用你爸爸手上的私钥才对请假条进行「签名」,老师通过公钥看能不能解出这个「签名」,如果能解出并且确认内容的完整性,就能证明是由你爸爸发起的请假条,这样老师才允许你请假,否则老师就不认。
3. 数字证书
前面我们知道:
- 可以通过哈希算法来保证消息的完整性;
- 可以通过数字签名来保证消息的来源可靠性(能确认消息是由持有私钥的一方发送的);
但是这还远远不够,还缺少身份验证的环节,万一公钥是被伪造的呢?
还是拿请假的例子,虽然你爸爸持有私钥,老师通过是否能用公钥解密来确认这个请假条是不是来源你父亲的。
但是我们还可以自己伪造出一对公私钥啊!
你找了个夜晚,偷偷把老师桌面上和你爸爸配对的公钥,换成了你的公钥,那么下次你在请假的时候,你继续模仿你爸爸的字迹写了个请假条,然后用你的私钥做个了「数字签名」。
但是老师并不知道自己的公钥被你替换过了,所以他还是按照往常一样用公钥解密,由于这个公钥和你的私钥是配对的,老师当然能用这个被替换的公钥解密出来,并且确认了内容的完整性,于是老师就会以为是你父亲写的请假条,又允许你请假了。
好家伙,为了一个请假,真的是斗智斗勇。
后面你的老师和父亲发现了你伪造公私钥的事情后,决定重新商量一个对策来应对你这个臭家伙。
正所谓魔高一丈,道高一尺。
既然伪造公私钥那么随意,所以你爸把他的公钥注册到警察局,警察局用他们自己的私钥对你父亲的公钥做了个数字签名,然后把你爸爸的「个人信息 + 公钥 + 数字签名」打包成一个数字证书,也就是说这个数字证书包含你爸爸的公钥。
这样,你爸爸如果因为家里确实有事要向老师帮你请假的时候,不仅会用自己的私钥对内容进行签名,还会把数字证书给到老师。
老师拿到了数字证书后,首先会去警察局验证这个数字证书是否合法,因为数字证书里有警察局的数字签名,警察局要验证证书合法性的时候,用自己的公钥解密,如果能解密成功,就说明这个数字证书是在警察局注册过的,就认为该数字证书是合法的,然后就会把数字证书里头的公钥(你爸爸的)给到老师。
由于通过警察局验证了数字证书是合法的,那么就能证明这个公钥就是你父亲的,于是老师就可以安心的用这个公钥解密出请假条,如果能解密出,就证明是你爸爸写的请假条。
正是通过了一个权威的机构来证明你爸爸的身份,所以你的伪造公私钥这个小伎俩就没用了。
在计算机里,这个权威的机构就是 CA (数字证书认证机构),将服务器公钥放在数字证书(由数字证书认证机构颁发)中,只要证书是可信的,公钥就是可信的。
数字证书的工作流程,我也画了一张图,方便大家理解:
通过数字证书的方式保证服务器公钥的身份,解决冒充的风险。
2.3 HTTPS 是如何建立连接的?其间交互了什么?
SSL/TLS 协议基本流程:
- 客户端向服务器索要并验证服务器的公钥。
- 双方协商生产「会话秘钥」。
- 双方采用「会话秘钥」进行加密通信。
前两步也就是 SSL/TLS 的建立过程,也就是 TLS 握手阶段。
TLS 的「握手阶段」涉及四次通信,使用不同的密钥交换算法,TLS 握手流程也会不一样的,现在常用的密钥交换算法有两种:RSA 算法 (opens new window)和 ECDHE 算法 (opens new window)。
基于 RSA 算法的 TLS 握手过程比较容易理解,所以这里先用这个给大家展示 TLS 握手过程,如下图:
TLS 协议建立的详细流程:
1. ClientHello
首先,由客户端向服务器发起加密通信请求,也就是 ClientHello
请求。
在这一步,客户端主要向服务器发送以下信息:
(1)客户端支持的 TLS 协议版本,如 TLS 1.2 版本。
(2)客户端生产的随机数(Client Random
),后面用于生成「会话秘钥」条件之一。
(3)客户端支持的密码套件列表,如 RSA 加密算法。
2. SeverHello
服务器收到客户端请求后,向客户端发出响应,也就是 SeverHello
。服务器回应的内容有如下内容:
(1)确认 TLS 协议版本,如果浏览器不支持,则关闭加密通信。
(2)服务器生产的随机数(Server Random
),也是后面用于生产「会话秘钥」条件之一。
(3)确认的密码套件列表,如 RSA 加密算法。
(4)服务器的数字证书。
3.客户端回应
客户端收到服务器的回应之后,首先通过浏览器或者操作系统中的 CA 公钥,确认服务器的数字证书的真实性。
如果证书没有问题,客户端会从数字证书中取出服务器的公钥,然后使用它加密报文,向服务器发送如下信息:
(1)一个随机数(pre-master key
)。该随机数会被服务器公钥加密。
(2)加密通信算法改变通知,表示随后的信息都将用「会话秘钥」加密通信。
(3)客户端握手结束通知,表示客户端的握手阶段已经结束。这一项同时把之前所有内容的发生的数据做个摘要,用来供服务端校验。
上面第一项的随机数是整个握手阶段的第三个随机数,会发给服务端,所以这个随机数客户端和服务端都是一样的。
服务器和客户端有了这三个随机数(Client Random、Server Random、pre-master key),接着就用双方协商的加密算法,各自生成本次通信的「会话秘钥」。
4. 服务器的最后回应
服务器收到客户端的第三个随机数(pre-master key
)之后,通过协商的加密算法,计算出本次通信的「会话秘钥」。
然后,向客户端发送最后的信息:
(1)加密通信算法改变通知,表示随后的信息都将用「会话秘钥」加密通信。
(2)服务器握手结束通知,表示服务器的握手阶段已经结束。这一项同时把之前所有内容的发生的数据做个摘要,用来供客户端校验。
至此,整个 TLS 的握手阶段全部结束。接下来,客户端与服务器进入加密通信,就完全是使用普通的 HTTP 协议,只不过用「会话秘钥」加密内容。
不过,基于 RSA 算法的 HTTPS 存在「前向安全」的问题:如果服务端的私钥泄漏了,过去被第三方截获的所有 TLS 通讯密文都会被破解。
为了解决这个问题,后面就出现了 ECDHE 密钥协商算法,我们现在大多数网站使用的正是 ECDHE 密钥协商算法。
客户端校验数字证书的流程是怎样的?
接下来,详细说一下实际中数字证书签发和验证流程。
如下图图所示,为数字证书签发和验证流程:
CA 签发证书的过程,如上图左边部分:
- 首先 CA 会把持有者的公钥、用途、颁发者、有效时间等信息打成一个包,然后对这些信息进行 Hash 计算,得到一个 Hash 值;
- 然后 CA 会使用自己的私钥将该 Hash 值加密,生成 Certificate Signature,也就是 CA 对证书做了签名;
- 最后将 Certificate Signature 添加在文件证书上,形成数字证书;
客户端校验服务端的数字证书的过程,如上图右边部分:
- 首先客户端会使用同样的 Hash 算法获取该证书的 Hash 值 H1;
- 通常浏览器和操作系统中集成了 CA 的公钥信息,浏览器收到证书后可以使用 CA 的公钥解密 Certificate Signature 内容,得到一个 Hash 值 H2 ;
- 最后比较 H1 和 H2,如果值相同,则为可信赖的证书,否则则认为证书不可信。
但事实上,证书的验证过程中还存在一个证书信任链的问题,因为我们向 CA 申请的证书一般不是根证书签发的,而是由中间证书签发的,比如百度的证书,从下图你可以看到,证书的层级有三级:
对于这种三级层级关系的证书的验证过程如下:
最开始客户端只信任根证书 GlobalSign Root CA 证书的,然后 “GlobalSign Root CA” 证书信任 “GlobalSign Organization Validation CA - SHA256 - G2” 证书,而 “GlobalSign Organization Validation CA - SHA256 - G2” 证书又信任 baidu.com 证书,于是客户端也信任 baidu.com 证书。
总括来说,由于用户信任 GlobalSign,所以由 GlobalSign 所担保的 baidu.com 可以被信任,另外由于用户信任操作系统或浏览器的软件商,所以由软件商预载了根证书的 GlobalSign 都可被信任。
这样的一层层地验证就构成了一条信任链路,整个证书信任链验证流程如下图所示:
最后一个问题,为什么需要证书链这么麻烦的流程?Root CA 为什么不直接颁发证书,而是要搞那么多中间层级呢?
这是为了确保根证书的绝对安全性,将根证书隔离地越严格越好,不然根证书如果失守了,那么整个信任链都会有问题。
2.4 HTTPS 的应用数据是如何保证完整性的?
TLS 在实现上分为握手协议和记录协议两层:
- TLS 握手协议就是我们前面说的 TLS 四次握手的过程,负责协商加密算法和生成对称密钥,后续用此密钥来保护应用程序数据(即 HTTP 数据);
- TLS 记录协议负责保护应用程序数据并验证其完整性和来源,所以对 HTTP 数据加密是使用记录协议;
TLS 记录协议主要负责消息(HTTP 数据)的压缩,加密及数据的认证,过程如下图:
具体过程如下:
- 首先,消息被分割成多个较短的片段,然后分别对每个片段进行压缩。
- 接下来,经过压缩的片段会被加上消息认证码(MAC 值,这个是通过哈希算法生成的),这是为了保证完整性,并进行数据的认证。通过附加消息认证码的 MAC 值,可以识别出篡改。与此同时,为了防止重放攻击,在计算消息认证码时,还加上了片段的编码。
- 再接下来,经过压缩的片段再加上消息认证码会一起通过对称密码进行加密。
- 最后,上述经过加密的数据再加上由数据类型、版本号、压缩后的长度组成的报头就是最终的报文数据。
记录协议完成后,最终的报文数据将传递到传输控制协议 (TCP) 层进行传输。
2.5 HTTPS 一定安全可靠吗?
之前有读者在字节面试的时候,被问到:HTTPS 一定安全可靠吗?
这个问题的场景是这样的:客户端通过浏览器向服务端发起 HTTPS 请求时,被「假基站」转发到了一个「中间人服务器」,于是客户端是和「中间人服务器」完成了 TLS 握手,然后这个「中间人服务器」再与真正的服务端完成 TLS 握手。
具体过程如下:
- 客户端向服务端发起 HTTPS 建立连接请求时,然后被「假基站」转发到了一个「中间人服务器」,接着中间人向服务端发起 HTTPS 建立连接请求,此时客户端与中间人进行 TLS 握手,中间人与服务端进行 TLS 握手;
- 在客户端与中间人进行 TLS 握手过程中,中间人会发送自己的公钥证书给客户端,客户端验证证书的真伪,然后从证书拿到公钥,并生成一个随机数,用公钥加密随机数发送给中间人,中间人使用私钥解密,得到随机数,此时双方都有随机数,然后通过算法生成对称加密密钥(A),后续客户端与中间人通信就用这个对称加密密钥来加密数据了。
- 在中间人与服务端进行 TLS 握手过程中,服务端会发送从 CA 机构签发的公钥证书给中间人,从证书拿到公钥,并生成一个随机数,用公钥加密随机数发送给服务端,服务端使用私钥解密,得到随机数,此时双方都有随机数,然后通过算法生成对称加密密钥(B),后续中间人与服务端通信就用这个对称加密密钥来加密数据了。
- 后续的通信过程中,中间人用对称加密密钥(A)解密客户端的 HTTPS 请求的数据,然后用对称加密密钥(B)加密 HTTPS 请求后,转发给服务端,接着服务端发送 HTTPS 响应数据给中间人,中间人用对称加密密钥(B)解密 HTTPS 响应数据,然后再用对称加密密钥(A)加密后,转发给客户端。
从客户端的角度看,其实并不知道网络中存在中间人服务器这个角色。那么中间人就可以解开浏览器发起的 HTTPS 请求里的数据,也可以解开服务端响应给浏览器的 HTTPS 响应数据。相当于,中间人能够 “偷看” 浏览器与服务端之间的 HTTPS 请求和响应的数据。
但是要发生这种场景是有前提的,前提是用户点击接受了中间人服务器的证书。
中间人服务器与客户端在 TLS 握手过程中,实际上发送了自己伪造的证书给浏览器,而这个伪造的证书是能被浏览器(客户端)识别出是非法的,于是就会提醒用户该证书存在问题。
如果用户执意点击「继续浏览此网站」,相当于用户接受了中间人伪造的证书,那么后续整个 HTTPS 通信都能被中间人监听了。
所以,这其实并不能说 HTTPS 不够安全,毕竟浏览器都已经提示证书有问题了,如果用户坚决要访问,那不能怪 HTTPS ,得怪自己手贱。
另外,如果你的电脑中毒了,被恶意导入了中间人的根证书,那么在验证中间人的证书的时候,由于你操作系统信任了中间人的根证书,那么等同于中间人的证书是合法的,这种情况下,浏览器是不会弹出证书存在问题的风险提醒的。
这其实也不关 HTTPS 的事情,是你电脑中毒了才导致 HTTPS 数据被中间人劫持的。
所以,HTTPS 协议本身到目前为止还是没有任何漏洞的,即使你成功进行中间人攻击,本质上是利用了客户端的漏洞(用户点击继续访问或者被恶意导入伪造的根证书),并不是 HTTPS 不够安全。
为什么抓包工具能截取 HTTPS 数据?
很多抓包工具 之所以可以明文看到 HTTPS 数据,工作原理与中间人一致的。
对于 HTTPS 连接来说,中间人要满足以下两点,才能实现真正的明文代理:
- 中间人,作为客户端与真实服务端建立连接这一步不会有问题,因为服务端不会校验客户端的身份;
- 中间人,作为服务端与真实客户端建立连接,这里会有客户端信任服务端的问题,也就是服务端必须有对应域名的私钥;
中间人要拿到私钥只能通过如下方式:
- 去网站服务端拿到私钥;
- 去CA处拿域名签发私钥;
- 自己签发证书,切要被浏览器信任;
不用解释,抓包工具只能使用第三种方式取得中间人的身份。
使用抓包工具进行 HTTPS 抓包的时候,需要在客户端安装 Fiddler 的根证书,这里实际上起认证中心(CA)的作用。
抓包工具能够抓包的关键是客户端会往系统受信任的根证书列表中导入抓包工具生成的证书,而这个证书会被浏览器信任,也就是抓包工具给自己创建了一个认证中心 CA,客户端拿着中间人签发的证书去中间人自己的 CA 去认证,当然认为这个证书是有效的。
如何避免被中间人抓取数据?
我们要保证自己电脑的安全,不要被病毒乘虚而入,而且也不要点击任何证书非法的网站,这样 HTTPS 数据就不会被中间人截取到了。
当然,我们还可以通过 HTTPS 双向认证来避免这种问题。
一般我们的 HTTPS 是单向认证,客户端只会验证了服务端的身份,但是服务端并不会验证客户端的身份。
如果用了双向认证方式,不仅客户端会验证服务端的身份,而且服务端也会验证客户端的身份。服务端一旦验证到请求自己的客户端为不可信任的,服务端就拒绝继续通信,客户端如果发现服务端为不可信任的,那么也中止通信。
3、HTTP/1.1、HTTP/2、HTTP/3 演变
3.1 HTTP/1.1 相比 HTTP/1.0 提高了什么性能?
HTTP/1.1 相比 HTTP/1.0 性能上的改进:
- 使用长连接的方式改善了 HTTP/1.0 短连接造成的性能开销。
- 支持管道(pipeline)网络传输,只要第一个请求发出去了,不必等其回来,就可以发第二个请求出去,可以减少整体的响应时间。
但 HTTP/1.1 还是有性能瓶颈:
- 请求 / 响应头部(Header)未经压缩就发送,首部信息越多延迟越大。只能压缩
Body
的部分; - 发送冗长的首部。每次互相发送相同的首部造成的浪费较多;
- 服务器是按请求的顺序响应的,如果服务器响应慢,会招致客户端一直请求不到数据,也就是队头阻塞;
- 没有请求优先级控制;
- 请求只能从客户端开始,服务器只能被动响应。
3.2 HTTP/2 做了什么优化?
HTTP/2 协议是基于 HTTPS 的,所以 HTTP/2 的安全性也是有保障的。
那 HTTP/2 相比 HTTP/1.1 性能上的改进:
- 头部压缩
- 二进制格式
- 并发传输
- 服务器主动推送资源
1. 头部压缩
HTTP/2 会压缩头(Header)如果你同时发出多个请求,他们的头是一样的或是相似的,那么,协议会帮你消除重复的部分。
这就是所谓的 HPACK
算法:在客户端和服务器同时维护一张头信息表,所有字段都会存入这个表,生成一个索引号,以后就不发送同样字段了,只发送索引号,这样就提高速度了。
2. 二进制格式
HTTP/2 不再像 HTTP/1.1 里的纯文本形式的报文,而是全面采用了二进制格式,头信息和数据体都是二进制,并且统称为帧(frame):头信息帧(Headers Frame)和数据帧(Data Frame) 。
这样虽然对人不友好,但是对计算机非常友好,因为计算机只懂二进制,那么收到报文后,无需再将明文的报文转成二进制,而是直接解析二进制报文,这增加了数据传输的效率。
比如状态码 200 ,在 HTTP/1.1 是用 '2''0''0' 三个字符来表示(二进制:00110010 00110000 00110000),共用了 3 个字节,如下图
在 HTTP/2 对于状态码 200 的二进制编码是 10001000,只用了 1 字节就能表示,相比于 HTTP/1.1 节省了 2 个字节,如下图:
Header: :status: 200 OK 的编码内容为:1000 1000,那么表达的含义是什么呢?
- 最前面的 1 标识该 Header 是静态表中已经存在的 KV。
- 在静态表里,“:status: 200 ok” 静态表编码是 8,二进制即是 1000。
因此,整体加起来就是 1000 1000。
3. 并发传输
我们都知道 HTTP/1.1 的实现是基于请求-响应模型的。同一个连接中,HTTP 完成一个事务(请求与响应),才能处理下一个事务,也就是说在发出请求等待响应的过程中,是没办法做其他事情的,如果响应迟迟不来,那么后续的请求是无法发送的,也造成了队头阻塞的问题。
而 HTTP/2 就很牛逼了,引出了 Stream 概念,多个 Stream 复用在一条 TCP 连接。
从上图可以看到,1 个 TCP 连接包含多个 Stream,Stream 里可以包含 1 个或多个 Message,Message 对应 HTTP/1 中的请求或响应,由 HTTP 头部和包体构成。Message 里包含一条或者多个 Frame,Frame 是 HTTP/2 最小单位,以二进制压缩格式存放 HTTP/1 中的内容(头部和包体)。
针对不同的 HTTP 请求用独一无二的 Stream ID 来区分,接收端可以通过 Stream ID 有序组装成 HTTP 消息,不同 Stream 的帧是可以乱序发送的,因此可以并发不同的 Stream ,也就是 HTTP/2 可以并行交错地发送请求和响应。
比如下图,服务端并行交错地发送了两个响应: Stream 1 和 Stream 3,这两个 Stream 都是跑在一个 TCP 连接上,客户端收到后,会根据相同的 Stream ID 有序组装成 HTTP 消息。
4、服务器推送
HTTP/2 还在一定程度上改善了传统的「请求 - 应答」工作模式,服务端不再是被动地响应,可以主动向客户端发送消息。
客户端和服务器双方都可以建立 Stream, Stream ID 也是有区别的,客户端建立的 Stream 必须是奇数号,而服务器建立的 Stream 必须是偶数号。
比如下图,Stream 1 是客户端向服务端请求的资源,属于客户端建立的 Stream,所以该 Stream 的 ID 是奇数(数字 1);Stream 2 和 4 都是服务端主动向客户端推送的资源,属于服务端建立的 Stream,所以这两个 Stream 的 ID 是偶数(数字 2 和 4)。
再比如,客户端通过 HTTP/1.1 请求从服务器那获取到了 HTML 文件,而 HTML 可能还需要依赖 CSS 来渲染页面,这时客户端还要再发起获取 CSS 文件的请求,需要两次消息往返,如下图左边部分:
如上图右边部分,在 HTTP/2 中,客户端在访问 HTML 时,服务器可以直接主动推送 CSS 文件,减少了消息传递的次数。
HTTP/2 有什么缺陷?
HTTP/2 通过 Stream 的并发能力,解决了 HTTP/1 队头阻塞的问题,看似很完美了,但是 HTTP/2 还是存在“队头阻塞”的问题,只不过问题不是在 HTTP 这一层面,而是在 TCP 这一层。
HTTP/2 是基于 TCP 协议来传输数据的,TCP 是字节流协议,TCP 层必须保证收到的字节数据是完整且连续的,这样内核才会将缓冲区里的数据返回给 HTTP 应用,那么当「前 1 个字节数据」没有到达时,后收到的字节数据只能存放在内核缓冲区里,只有等到这 1 个字节数据到达时,HTTP/2 应用层才能从内核中拿到数据,这就是 HTTP/2 队头阻塞问题。
举个例子,如下图:
图中发送方发送了很多个 packet,每个 packet 都有自己的序号,你可以认为是 TCP 的序列号,其中 packet 3 在网络中丢失了,即使 packet 4-6 被接收方收到后,由于内核中的 TCP 数据不是连续的,于是接收方的应用层就无法从内核中读取到,只有等到 packet 3 重传后,接收方的应用层才可以从内核中读取到数据,这就是 HTTP/2 的队头阻塞问题,是在 TCP 层面发生的。
所以,一旦发生了丢包现象,就会触发 TCP 的重传机制,这样在一个 TCP 连接中的所有的 HTTP 请求都必须等待这个丢了的包被重传回来。
3.3 HTTP/3 做了哪些优化?
前面我们知道了 HTTP/1.1 和 HTTP/2 都有队头阻塞的问题:
- HTTP/1.1 中的管道( pipeline)虽然解决了请求的队头阻塞,但是没有解决响应的队头阻塞,因为服务端需要按顺序响应收到的请求,如果服务端处理某个请求消耗的时间比较长,那么只能等响应完这个请求后, 才能处理下一个请求,这属于 HTTP 层队头阻塞。
- HTTP/2 虽然通过多个请求复用一个 TCP 连接解决了 HTTP 的队头阻塞 ,但是一旦发生丢包,就会阻塞住所有的 HTTP 请求,这属于 TCP 层队头阻塞。
HTTP/2 队头阻塞的问题是因为 TCP,所以 HTTP/3 把 HTTP 下层的 TCP 协议改成了 UDP!
UDP 发送是不管顺序,也不管丢包的,所以不会出现像 HTTP/2 队头阻塞的问题。大家都知道 UDP 是不可靠传输的,但基于 UDP 的 QUIC 协议 可以实现类似 TCP 的可靠性传输。
QUIC 有以下 3 个特点。
- 无队头阻塞
- 更快的连接建立
- 连接迁移
1、无队头阻塞
QUIC 协议也有类似 HTTP/2 Stream 与多路复用的概念,也是可以在同一条连接上并发传输多个 Stream,Stream 可以认为就是一条 HTTP 请求。
QUIC 有自己的一套机制可以保证传输的可靠性的。当某个流发生丢包时,只会阻塞这个流,其他流不会受到影响,因此不存在队头阻塞问题。这与 HTTP/2 不同,HTTP/2 只要某个流中的数据包丢失了,其他流也会因此受影响。
所以,QUIC 连接上的多个 Stream 之间并没有依赖,都是独立的,某个流发生丢包了,只会影响该流,其他流不受影响。
2、更快的连接建立
对于 HTTP/1 和 HTTP/2 协议,TCP 和 TLS 是分层的,分别属于内核实现的传输层、openssl 库实现的表示层,因此它们难以合并在一起,需要分批次来握手,先 TCP 握手,再 TLS 握手。
HTTP/3 在传输数据前虽然需要 QUIC 协议握手,但是这个握手过程只需要 1 RTT,握手的目的是为确认双方的「连接 ID」,连接迁移就是基于连接 ID 实现的。
但是 HTTP/3 的 QUIC 协议并不是与 TLS 分层,而是 QUIC 内部包含了 TLS,它在自己的帧会携带 TLS 里的“记录”,再加上 QUIC 使用的是 TLS/1.3,因此仅需 1 个 RTT 就可以「同时」完成建立连接与密钥协商,如下图:
甚至,在第二次连接的时候,应用数据包可以和 QUIC 握手信息(连接信息 + TLS 信息)一起发送,达到 0-RTT 的效果。
如下图右边部分,HTTP/3 当会话恢复时,有效负载数据与第一个数据包一起发送,可以做到 0-RTT(下图的右下角):
3、连接迁移
基于 TCP 传输协议的 HTTP 协议,由于是通过四元组(源 IP、源端口、目的 IP、目的端口)确定一条 TCP 连接。
那么当移动设备的网络从 4G 切换到 WIFI 时,意味着 IP 地址变化了,那么就必须要断开连接,然后重新建立连接。而建立连接的过程包含 TCP 三次握手和 TLS 四次握手的时延,以及 TCP 慢启动的减速过程,给用户的感觉就是网络突然卡顿了一下,因此连接的迁移成本是很高的。
而 QUIC 协议没有用四元组的方式来“绑定”连接,而是通过连接 ID 来标记通信的两个端点,客户端和服务器可以各自选择一组 ID 来标记自己,因此即使移动设备的网络变化后,导致 IP 地址变化了,只要仍保有上下文信息(比如连接 ID、TLS 密钥等),就可以“无缝”地复用原连接,消除重连的成本,没有丝毫卡顿感,达到了连接迁移的功能。
所以, QUIC 是一个在 UDP 之上的伪 TCP + TLS + HTTP/2 的多路复用的协议。
QUIC 是新协议,对于很多网络设备,根本不知道什么是 QUIC,只会当做 UDP,这样会出现新的问题,因为有的网络设备是会丢掉 UDP 包的,而 QUIC 是基于 UDP 实现的,那么如果网络设备无法识别这个是 QUIC 包,那么就会当作 UDP包,然后被丢弃。
一、沉默王二-JVM-JVM 核心知识点
1、Java 内存区域
1.2 程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。
字节码解释器通过改变程序计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要该计数器来完成。
每个线程都拥有一个独立的程序计数器,各个线程之间的计数器互不影响,独立存储。
1.3 虚拟机栈
虚拟机栈(Java Virtual Machine Stack)也是线程私有,它描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
方法从调用到结束就对应着一个栈帧从入栈到出栈的过程。在《Java 虚拟机规范》中,对该内存区域规定了两类异常:
- 如果线程请求的栈深度大于虚拟机所允许的栈深度,将抛出
StackOverflowError
异常; - 如果 Java 虚拟机栈的容量允许动态扩展,当栈扩展时如果无法申请到足够的内存会抛出
OutOfMemoryError
异常。
1.3 本地方法栈
本地方法栈(Native Method Stacks)与虚拟机栈类似,其区别在于:Java 虚拟机栈是为虚拟机执行 Java 方法(也就是字节码)服务的,而本地方法栈则是为 JVM 使用到的本地(Native)方法服务。
1.4 堆
堆(Java Heap)是虚拟机所管理的最大一块内存空间,它被所有线程所共享,用于存放对象实例。
Java 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为是连续的。Java 堆可以被实现成固定大小的,也可以是可扩展的。
当前大多数主流的虚拟机都是按照可扩展来实现的,即可以通过最大值参数 -Xmx
和最小值参数 -Xms
进行设定。
如果 Java 堆中没有足够的内存来完成对象实例分配,并且堆也无法再扩展时,Java 虚拟机将会抛出 OutOfMemoryError
异常。
1.5 方法区
方法区(Method Area)也是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、JIT 编译后的代码缓存等数据。
方法区也被称为 “非堆”,目的是与 Java 堆进行区分。《Java 虚拟机规范》规定,如果方法区无法满足新的内存分配需求时,将会抛出 OutOfMemoryError
异常。
JDK 8 以后的方法区实现已经不再是永久代(Permanent Generation)了,而是使用元空间(Metaspace)来实现。
运行时常量池(Runtime Constant Pool)也是方法区的一部分,用于存放常量池表(Constant Pool Table),常量池表中存放了编译期生成的各种符号字面量和符号引用。
JDK 8 以后的运行时常量池在元空间中。
2、 对象
2.1 对象的创建
当我们在代码中使用 new
关键字创建一个对象时,其在 JVM 中需要经过以下步骤:
2.1.1 类加载过程
当虚拟机遇到一条字节码指令 new
时,首先将去检查这个指令的参数是否能在常量池中定位到一个符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,就必须先执行相应的类加载过程。
2.1.2 分配内存
在类加载检查通过后,虚拟机需要给新生对象分配内存空间。根据 Java 堆是否规整,可以有以下两种分配方案:
①、指针碰撞:假设 Java 堆中内存是绝对规整的,所有使用的内存放在一边,所有未被使用的内存放在另外一边,中间以指针作为分界点指示器。
此时内存分配只是将指针向空闲方向偏移出对象大小的空间即可,这种方式被称为指针碰撞。
②、空闲列表:如果 Java 堆不是规整的,此时虚拟机需要维护一个列表,记录哪些内存块是可用的,哪些是不可用的。在进行内存分配时,只需要从该列表中选取出一块足够的内存空间划分给对象实例即可。
注:Java 堆是否规整取决于其采用的垃圾收集器是否带有空间压缩整理能力,前面讲过了。
除了分配方式外,由于对象创建在虚拟机中是一个非常频繁的行为,此时需要保证在并发环境下的线程安全:如果一个线程给对象 A 分配了内存空间,但指针还没来得及修改,此时就可能出现另外一个线程使用原来的指针来给对象 B 分配内存空间的情况。
想要解决这个问题有两个方案:
①、方式一:采用同步锁定,或采用 CAS 配上失败重试的方式来保证更新操作的原子性。
②、方式二:为每个线程在 Java 堆中预先分配一块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。
线程在进行内存分配时优先使用本地缓冲,当本地缓冲使用完成后,再向 Java 堆申请分配,此时 Java 堆采用同步锁定的方式来保证分配行为的线程安全。
2.1.3 对象头设置
将对象有关的元数据信息、对象的哈希码、分代年龄等信息存储到对象头中。
可以和 JIT 那节的内容关联起来。
2.1.4 对象初始化
调用对象的构造方法,即 Class 文件中的
来初始化对象,为相关字段赋值。
2.2 对象的内存布局
在 HotSpot 中,对象在堆内存中的存储布局可以划分为以下三个部分:
2.2.1 对象头 (Header)
对象头包括两部分信息:
- Mark Word:对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,官方统称为 Mark Word,我们曾在 synchronized 的四种锁状态讲过。
- 类型指针:对象指向它类型元数据的指针,Java 虚拟机通过这个指针来确定该对象是哪个类的实例。需要说明的是,并非所有的虚拟机都必须要在对象数据上保留类型指针,这取决于对象的访问定位方式。
2.2.2 实例数据 (Instance Data)
即我们在代码中定义的各种类型的字段,无论是从父类继承而来,还是子类中定义的都需要记录。
2.2.3 对齐填充 (Padding)
主要起占位符的作用。HotSpot 要求对象起始地址必须是 8 字节的整倍数,即间接要求了任何对象的大小都必须是 8 字节的整倍数。对象头部分在设计上就是 8 字节的整倍数,如果对象的实例数据不是 8 字节的整倍数,则由对齐填充进行补全。
2.3 对象的访问定位
对象创建后,Java 程序就可以通过栈上的 reference
(也就是引用)来操作堆上的具体对象。
《Java 虚拟机规范》规定 reference
是一个指向对象的引用,但并未规定其具体实现方式。主流的方式方式有以下两种:
- 句柄访问:Java 堆将划分出一块内存来作为句柄池,
reference
中存储的是对象的句柄地址,而句柄则包含了对象实例数据和类型数据的地址信息。 - 指针访问:
reference
中存储的直接就是对象地址,而对象的类型数据则由上文介绍的对象头中的类型指针来指定。
通过句柄访问对象:
通过直接指针访问对象:
句柄访问的优点在于对象移动时(垃圾收集时移动对象是非常普遍的行为)只需要改变句柄中实例数据的指针,而 reference
本身并不需要修改;
指针访问则反之,由于其 reference
中存储的直接就是对象地址,所以当对象移动时, reference
需要被修改。但针对只需要访问对象本身的场景,指针访问则可以减少一次定位开销。由于对象访问是一项非常频繁的操作,所以这类减少的效果会非常显著,基于这个原因,HotSpot 主要使用的是指针访问的方式。
3、垃圾收集机制
在 JVM 内存模型中,程序计数器、虚拟机栈、本地方法栈这 3 个区域都是线程私有的,会随着线程的结束而销毁,因此在这 3 个区域当中,无需过多考虑垃圾回收问题。垃圾回收问题主要发生在 Java 堆上。
在 Java 堆上,垃圾回收的主要内容是死亡的对象(不可能再被任何途径使用的对象)。
判断对象是否死亡有以下两种方法:
3.1 引用计数法
在对象中添加一个引用计数器,对象每次被引用时,该计数器加一;当引用失效时,计数器的值减一;只要计数器的值为零,则代表对应的对象不可能再被使用。该方法的缺点在于无法避免相互引用的问题:
java 代码解读复制代码objA.instance = objB
objB.instance = objA
objA = null;
objB = null;
System.gc();
如上所示,此时两个对象已经不能再被访问,但其互相持有对对方的引用,如果采用引用计数法,则两个对象都无法被回收。
3.2 可达性分析
但上面的代码在大多数虚拟机中都能被正确的回收,因为大多数主流的虚拟机都是采用的可达性分析方法来判断对象是否死亡。
可达性分析是通过一系列被称为 GC Roots
的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径被称为引用链(Reference Chain),如果某个对象到 GC Roots
间没有任何引用链相连,这代表 GC Roots
到该对象不可达, 此时证明该对象不可能再被使用。
在 Java 语言中,固定可作为 GC Roots
的对象包括以下几种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等;
- 在方法区(元空间)中类静态变量引用的对象,譬如 Java 类中引用类型的静态变量;
- 在方法区(元空间)中常量引用的对象,譬如字符串常量池(String Table)里的引用;
- 在本地方法栈中的 JNI(Native 方法)引用的对象;
- Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(如 NullPointException,OutOfMemoryError 等)及系统类加载器;
- 所有被同步锁(synchronized 关键字)持有的对象;
除了这些固定的 GC Roots
集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域的不同,还可能会有其他对象 “临时性” 地加入,共同构成完整的 GC Roots
集合。
3.3 对象引用
可达性分析是基于引用链进行判断的,在 JDK 1.2 之后,Java 将引用关系分为以下四类:
3.3.1 强引用 (Strongly Reference)
最传统的引用,如 Object obj = new Object()
。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
3.3.2 软引用 (Soft Reference)
用于描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出之前,会被列入回收范围内进行第二次回收,如果这次回收后还没有足够的内存,才会抛出内存溢出异常。
下面是一个使用 Java 中 SoftReference 类的示例代码:
java 代码解读复制代码class SoftReferenceExample {
public static void main(String[] args) {
// 创建一个强引用的对象
String strongReference = new String("二哥,我是个强引用");
// 创建一个软引用,指向上面的对象
SoftReference softReference = new SoftReference<>(strongReference);
// 干掉强引用
strongReference = null;
// 现在只有软引用指向 "二哥,我是个强引用" 对象
// 尝试通过软引用获取对象
String retrievedString = softReference.get();
System.out.println(retrievedString); // 输出 "二哥,我是个强引用"
// 强制进行垃圾回收,可能会清除软引用的对象
System.gc();
// 再次尝试通过软引用获取对象
retrievedString = softReference.get();
if (retrievedString != null) {
System.out.println(retrievedString);
} else {
System.out.println("软引用的对象已被垃圾回收");
}
}
}
这个例子中,我们首先创建了一个字符串对象的强引用,然后通过 SoftReference 创建了这个对象的软引用。在取消了强引用后,这个对象只剩下软引用。当我们尝试通过软引用获取对象时,如果对象还存在,软引用会返回它;如果对象已被垃圾收集器回收,则返回 null。
需要注意的是,第二次回收时,如果这次回收后还没有足够的内存,才会抛出内存溢出异常。这里的 “足够” 是指在抛出内存溢出异常之前,系统会进行最后一次尝试,如果这次回收后还是没有足够的内存,才会抛出内存溢出异常。
通常情况下,上面代码在执行 gc 后软引用不会被回收,因为此时内存还是足够的。
java代码解读复制代码二哥,我是个强引用 二哥,我是个强引用
3.3.3 弱引用 (Weak Reference)
用于描述那些非必须的对象,强度比软引用弱。被弱引用关联的对象只能生存到下一次垃圾收集发生时,无论当前内存是否足够,弱引用对象都会被回收。
来看这段代码:
java 代码解读复制代码class WeakReferenceExample {
public static void main(String[] args) {
// 创建一个强引用的对象
String strongReference = new String("二哥,我是强引用");
// 创建一个弱引用,指向上面的对象
WeakReference weakReference = new WeakReference<>(strongReference);
// 取消强引用
strongReference = null;
// 强制进行垃圾回收
System.gc();
// 尝试通过弱引用获取对象
String retrievedString = weakReference.get();
if (retrievedString != null) {
System.out.println(retrievedString);
} else {
System.out.println("弱引用的对象已被垃圾回收");
}
}
}
这个例子中,我们首先创建了一个字符串对象的强引用,然后通过 WeakReference 创建了这个对象的弱引用。在取消了强引用后,这个对象只剩下弱引用。当我们尝试通过弱引用获取对象时,如果对象还存在,弱引用会返回它;如果对象已被垃圾收集器回收,则返回 null。
运行结果就和软引用不一样了,gc 后弱引用被回收了。
java代码解读复制代码弱引用的对象已被垃圾回收
3.3.4 虚引用 (Phantom Reference)
最弱的引用关系。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被回收时收到一个系统通知。
虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾收集器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入到与之关联的引用队列中。
来看这个示例代码:
java 代码解读复制代码class PhantomReferenceExample {
public static void main(String[] args) {
// 创建一个强引用的对象
String strongReference = new String("二哥,我是强引用");
// 创建一个引用队列
ReferenceQueue referenceQueue = new ReferenceQueue<>();
// 创建一个虚引用,指向上面的对象,并与引用队列关联
PhantomReference phantomReference = new PhantomReference<>(strongReference, referenceQueue);
// 取消强引用
strongReference = null;
// 强制进行垃圾回收
System.gc();
// 检查引用队列,看是否有通知
if (referenceQueue.poll() != null) {
System.out.println("虚引用的对象已被垃圾回收,且收到了通知");
} else {
System.out.println("虚引用的对象未被垃圾回收,或未收到通知");
}
}
}
这个例子中,我们首先创建了一个字符串对象的强引用,然后通过 PhantomReference 创建了这个对象的虚引用,并与引用队列关联。在取消了强引用后,这个对象只剩下虚引用。当我们强制进行垃圾回收时,如果对象还存在,虚引用不会返回它;如果对象已被垃圾收集器回收,则返回 null。同时,如果对象被回收,虚引用会被加入到引用队列中。
运行结果如下:
java代码解读复制代码虚引用的对象已被垃圾回收,且收到了通知
3.4 对象真正死亡
要真正宣告一个对象死亡,需要经过至少两次标记过程:
①、如果对象在进行可达性分析后发现 GC Roots
不可达,将会进行第一次标记;
②、随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalized()
方法。
如果对象没有覆盖 finalized()
方法,或者 finalized()
已经被虚拟机调用过,这两种情况都会视为没有必要执行。
如果判定结果是有必要执行,此时对象会被放入名为 F-Queue
的队列,等待 Finalizer 线程执行其 finalized()
方法。
在这个过程中,收集器会进行第二次小规模的标记,如果对象在 finalized()
方法中重新将自己与引用链上的任何一个对象进行了关联,如将自己(this 关键字)赋值给某个类变量或者对象的成员变量,此时它就实现了自我拯救,则第二次标记会将其移除 “即将回收” 的集合,否则该对象就将被真正回收,走向死亡。
3.5 垃圾收集算法
3.5.1 分代收集理论
当前大多数虚拟机都遵循 “分代收集” 的理论进行设计,它建立在强弱两个分代假说下:
- 弱分代假说 (Weak Generational Hypothesis) :绝大多数对象都是朝生夕灭的。
- 强分代假说 (Strong Generational Hypothesis) :熬过越多次垃圾收集过程的对象就越难以消亡。
- 跨带引用假说 (Intergenerational Reference Hypothesis) :基于上面两条假说还可以得出的一条隐含推论:存在相互引用关系的两个对象,应该倾向于同时生存或者同时消亡。
强弱分代假说奠定了垃圾收集器的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄就是对象经历垃圾收集的次数)分配到不同的区域中进行存储。
之后如果一个区域中的对象都是朝生夕灭的,那么收集器只需要关注少量对象的存活而不是去标记那些大量将要被回收的对象,此时就能以较小的代价获取较大的空间。
最后再将难以消亡的对象集中到一块,根据强分代假说,它们是很难消亡的,因此虚拟机可以使用较低的频率进行回收,这就兼顾了时间和内存空间的开销。
3.5.2 回收类型
根据分代收集理论,收集范围可以分为以下几种类型:
①、部分收集 (Partial GC) :具体分为:
- 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
- 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
- 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
- 整堆收集 (Full GC) :收集整个 Java 堆和方法区。
3.5.3 标记-清除算法
它是最基础的垃圾收集算法,收集过程分为两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象;也可以反过来,标记存活对象,统一回收所有未被标记的对象。
它主要有以下两个缺点:
- 执行效率不稳定:如果 Java 堆上包含大量需要回收的对象,则需要进行大量标记和清除动作;
- 内存空间碎片化:标记清除后会产生大量不连续的空间,从而导致无法为大对象分配足够的连续内存。
3.5.4 标记-复制算法
标记-复制算法基于 ”半区复制“ 算法:它将可用内存按容量划分为大小相等的两块,每次只使用其中一块,当这一块的内存使用完了,就将还存活着的对象复制到另外一块,然后再把已经使用过的那块内存空间一次性清理掉。其优点在于避免了内存空间碎片化的问题,其缺点如下:
- 如果内存中多数对象都是存活的,这种算法将产生大量的复制开销;
- 浪费内存空间,内存空间变为了原有的一半。
基于新生代 “朝生夕灭” 的特点,大多数虚拟机都不会按照 1:1 的比例来进行内存划分,例如 HotSpot 会将内存空间划分为一块较大的 Eden
和两块较小的 Survivor
空间,它们之间的比例是 8:1:1 。
每次分配时只会使用 Eden
和其中的一块 Survivor
,发生垃圾回收时,只需要将存活的对象一次性复制到另外一块 Survivor
上,这样只有 10% 的内存空间会被浪费掉。
当 Survivor
空间不足以容纳一次 Minor GC
时,此时由其他内存区域(通常是老年代)来进行分配担保。
3.5.5 标记-整理算法
标记-整理算法是在标记完成后,让所有存活对象都向内存的一端移动,然后直接清理掉边界以外的内存。
其优点在于可以避免内存空间碎片化的问题,也可以充分利用内存空间;其缺点在于根据所使用的收集器的不同,在移动存活对象时可能要全程暂停用户程序:
二、小林-图解网络-HTTP相关问题
1、HTTP 特性
到目前为止,HTTP 常见到版本有 HTTP/1.1,HTTP/2.0,HTTP/3.0,不同版本的 HTTP 特性是不一样的。
1.1 HTTP/1.1 的优点有哪些?
HTTP 最突出的优点是「简单、灵活和易于扩展、应用广泛和跨平台」。
1. 简单
HTTP 基本的报文格式就是 header + body
,头部信息也是 key-value
简单文本的形式,易于理解,降低了学习和使用的门槛。
2. 灵活和易于扩展
HTTP 协议里的各类请求方法、URI/URL、状态码、头字段等每个组成要求都没有被固定死,都允许开发人员自定义和扩充。
同时 HTTP 由于是工作在应用层( OSI
第七层),则它下层可以随意变化,比如:
- HTTPS 就是在 HTTP 与 TCP 层之间增加了 SSL/TLS 安全传输层;
- HTTP/1.1 和 HTTP/2.0 传输协议使用的是 TCP 协议,而到了 HTTP/3.0 传输协议改用了 UDP 协议。
3. 应用广泛和跨平台
互联网发展至今,HTTP 的应用范围非常的广泛,从台式机的浏览器到手机上的各种 APP,从看新闻、刷贴吧到购物、理财、吃鸡,HTTP 的应用遍地开花,同时天然具有跨平台的优越性。
1.2 HTTP/1.1 的缺点有哪些?
HTTP 协议里有优缺点一体的双刃剑,分别是「无状态、明文传输」,同时还有一大缺点「不安全」。
1. 无状态双刃剑
无状态的好处,因为服务器不会去记忆 HTTP 的状态,所以不需要额外的资源来记录状态信息,这能减轻服务器的负担,能够把更多的 CPU 和内存用来对外提供服务。
无状态的坏处,既然服务器没有记忆能力,它在完成有关联性的操作时会非常麻烦。
例如登录->添加购物车->下单->结算->支付,这系列操作都要知道用户的身份才行。但服务器不知道这些请求是有关联的,每次都要问一遍身份信息。
这样每操作一次,都要验证信息,这样的购物体验还能愉快吗?别问,问就是酸爽!
对于无状态的问题,解法方案有很多种,其中比较简单的方式用 Cookie 技术。
Cookie
通过在请求和响应报文中写入 Cookie 信息来控制客户端的状态。
相当于,在客户端第一次请求后,服务器会下发一个装有客户信息的「小贴纸」,后续客户端请求服务器的时候,带上「小贴纸」,服务器就能认得了了,
2. 明文传输双刃剑
明文意味着在传输过程中的信息,是可方便阅读的,比如 Wireshark 抓包都可以直接肉眼查看,为我们调试工作带了极大的便利性。
但是这正是这样,HTTP 的所有信息都暴露在了光天化日下,相当于信息裸奔。在传输的漫长的过程中,信息的内容都毫无隐私可言,很容易就能被窃取,如果里面有你的账号密码信息,那你号没了。
3. 不安全
HTTP 比较严重的缺点就是不安全:
- 通信使用明文(不加密),内容可能会被窃听。比如,账号信息容易泄漏,那你号没了。
- 不验证通信方的身份,因此有可能遭遇伪装。比如,访问假的淘宝、拼多多,那你钱没了。
- 无法证明报文的完整性,所以有可能已遭篡改。比如,网页上植入垃圾广告,视觉污染,眼没了。
HTTP 的安全问题,可以用 HTTPS 的方式解决,也就是通过引入 SSL/TLS 层,使得在安全上达到了极致。
1.3 HTTP/1.1 的性能如何?
HTTP 协议是基于 TCP/IP,并且使用了「请求 - 应答」的通信模式,所以性能的关键就在这两点里。
1. 长连接
早期 HTTP/1.0 性能上的一个很大的问题,那就是每发起一个请求,都要新建一次 TCP 连接(三次握手),而且是串行请求,做了无谓的 TCP 连接建立和断开,增加了通信开销。
为了解决上述 TCP 连接问题,HTTP/1.1 提出了长连接的通信方式,也叫持久连接。这种方式的好处在于减少了 TCP 连接的重复建立和断开所造成的额外开销,减轻了服务器端的负载。
持久连接的特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。
当然,如果某个 HTTP 长连接超过一定时间没有任何数据交互,服务端就会主动断开这个连接。
2. 管道网络传输
HTTP/1.1 采用了长连接的方式,这使得管道(pipeline)网络传输成为了可能。
即可在同一个 TCP 连接里面,客户端可以发起多个请求,只要第一个请求发出去了,不必等其回来,就可以发第二个请求出去,可以减少整体的响应时间。
举例来说,客户端需要请求两个资源。以前的做法是,在同一个 TCP 连接里面,先发送 A 请求,然后等待服务器做出回应,收到后再发出 B 请求。那么,管道机制则是允许浏览器同时发出 A 请求和 B 请求,如下图:
但是服务器必须按照接收请求的顺序发送对这些管道化请求的响应。
如果服务端在处理 A 请求时耗时比较长,那么后续的请求的处理都会被阻塞住,这称为「队头堵塞」。
所以,HTTP/1.1 管道解决了请求的队头阻塞,但是没有解决响应的队头阻塞。
实际上 HTTP/1.1 管道化技术不是默认开启,而且浏览器基本都没有支持,所以后面所有文章讨论 HTTP/1.1 都是建立在没有使用管道化的前提。
3. 队头阻塞
「请求 - 应答」的模式会造成 HTTP 的性能问题。为什么呢?
因为当顺序发送的请求序列中的一个请求因为某种原因被阻塞时,在后面排队的所有请求也一同被阻塞了,会招致客户端一直请求不到数据,这也就是「队头阻塞」,好比上班的路上塞车。
总之 HTTP/1.1 的性能一般般,后续的 HTTP/2 和 HTTP/3 就是在优化 HTTP 的性能。
2、HTTP 与 HTTPS
2.1 HTTP 与 HTTPS 有哪些区别?
- HTTP 是超文本传输协议,信息是明文传输,存在安全风险的问题。HTTPS 则解决 HTTP 不安全的缺陷,在 TCP 和 HTTP 网络层之间加入了 SSL/TLS 安全协议,使得报文能够加密传输。
- HTTP 连接建立相对简单, TCP 三次握手之后便可进行 HTTP 的报文传输。而 HTTPS 在 TCP 三次握手之后,还需进行 SSL/TLS 的握手过程,才可进入加密报文传输。
- 两者的默认端口不一样,HTTP 默认端口号是 80,HTTPS 默认端口号是 443。
- HTTPS 协议需要向 CA(证书权威机构)申请数字证书,来保证服务器的身份是可信的。
2.2 HTTPS 解决了 HTTP 的哪些问题?
HTTP 由于是明文传输,所以安全上存在以下三个风险:
- 窃听风险,比如通信链路上可以获取通信内容,用户号容易没。
- 篡改风险,比如强制植入垃圾广告,视觉污染,用户眼容易瞎。
- 冒充风险,比如冒充淘宝网站,用户钱容易没。
HTTPS 在 HTTP 与 TCP 层之间加入了 SSL/TLS
协议,可以很好的解决了上述的风险:
- 信息加密:交互信息无法被窃取,但你的号会因为「自身忘记」账号而没。
- 校验机制:无法篡改通信内容,篡改了就不能正常显示,但百度「竞价排名」依然可以搜索垃圾广告。
- 身份证书:证明淘宝是真的淘宝网,但你的钱还是会因为「剁手」而没。
可见,只要自身不做「恶」,SSL/TLS 协议是能保证通信是安全的。
HTTPS 是如何解决上面的三个风险的?
- 混合加密的方式实现信息的机密性,解决了窃听的风险。
- 摘要算法的方式来实现完整性,它能够为数据生成独一无二的「指纹」,指纹用于校验数据的完整性,解决了篡改的风险。
- 将服务器公钥放入到数字证书中,解决了冒充的风险。
1. 混合加密
通过混合加密的方式可以保证信息的机密性,解决了窃听的风险。
HTTPS 采用的是对称加密和非对称加密结合的「混合加密」方式:
- 在通信建立前采用非对称加密的方式交换「会话秘钥」,后续就不再使用非对称加密。
- 在通信过程中全部使用对称加密的「会话秘钥」的方式加密明文数据。
采用「混合加密」的方式的原因:
- 对称加密只使用一个密钥,运算速度快,密钥必须保密,无法做到安全的密钥交换。
- 非对称加密使用两个密钥:公钥和私钥,公钥可以任意分发而私钥保密,解决了密钥交换问题但速度慢。
2. 摘要算法 + 数字签名
为了保证传输的内容不被篡改,我们需要对内容计算出一个「指纹」,然后同内容一起传输给对方。
对方收到后,先是对内容也计算出一个「指纹」,然后跟发送方发送的「指纹」做一个比较,如果「指纹」相同,说明内容没有被篡改,否则就可以判断出内容被篡改了。
那么,在计算机里会用摘要算法(哈希函数)来计算出内容的哈希值,也就是内容的「指纹」,这个哈希值是唯一的,且无法通过哈希值推导出内容。
通过哈希算法可以确保内容不会被篡改,但是并不能保证「内容 + 哈希值」不会被中间人替换,因为这里缺少对客户端收到的消息是否来源于服务端的证明。
举个例子,你想向老师请假,一般来说是要求由家长写一份请假理由并签名,老师才能允许你请假。
但是你有模仿你爸爸字迹的能力,你用你爸爸的字迹写了一份请假理由然后签上你爸爸的名字,老师一看到这个请假条,查看字迹和签名,就误以为是你爸爸写的,就会允许你请假。
那作为老师,要如何避免这种情况发生呢?现实生活中的,可以通过电话或视频来确认是否是由父母发出的请假,但是计算机里可没有这种操作。
那为了避免这种情况,计算机里会用非对称加密算法来解决,共有两个密钥:
- 一个是公钥,这个是可以公开给所有人的;
- 一个是私钥,这个必须由本人管理,不可泄露。
这两个密钥可以双向加解密的,比如可以用公钥加密内容,然后用私钥解密,也可以用私钥加密内容,公钥解密内容。
流程的不同,意味着目的也不相同:
- 公钥加密,私钥解密。这个目的是为了保证内容传输的安全,因为被公钥加密的内容,其他人是无法解密的,只有持有私钥的人,才能解密出实际的内容;
- 私钥加密,公钥解密。这个目的是为了保证消息不会被冒充,因为私钥是不可泄露的,如果公钥能正常解密出私钥加密的内容,就能证明这个消息是来源于持有私钥身份的人发送的。
一般我们不会用非对称加密来加密实际的传输内容,因为非对称加密的计算比较耗费性能的。
所以非对称加密的用途主要在于通过「私钥加密,公钥解密」的方式,来确认消息的身份,我们常说的数字签名算法,就是用的是这种方式,不过私钥加密内容不是内容本身,而是对内容的哈希值加密。
私钥是由服务端保管,然后服务端会向客户端颁发对应的公钥。如果客户端收到的信息,能被公钥解密,就说明该消息是由服务器发送的。
引入了数字签名算法后,你就无法模仿你爸爸的字迹来请假了,你爸爸手上持有着私钥,你老师持有着公钥。
这样只有用你爸爸手上的私钥才对请假条进行「签名」,老师通过公钥看能不能解出这个「签名」,如果能解出并且确认内容的完整性,就能证明是由你爸爸发起的请假条,这样老师才允许你请假,否则老师就不认。
3. 数字证书
前面我们知道:
- 可以通过哈希算法来保证消息的完整性;
- 可以通过数字签名来保证消息的来源可靠性(能确认消息是由持有私钥的一方发送的);
但是这还远远不够,还缺少身份验证的环节,万一公钥是被伪造的呢?
还是拿请假的例子,虽然你爸爸持有私钥,老师通过是否能用公钥解密来确认这个请假条是不是来源你父亲的。
但是我们还可以自己伪造出一对公私钥啊!
你找了个夜晚,偷偷把老师桌面上和你爸爸配对的公钥,换成了你的公钥,那么下次你在请假的时候,你继续模仿你爸爸的字迹写了个请假条,然后用你的私钥做个了「数字签名」。
但是老师并不知道自己的公钥被你替换过了,所以他还是按照往常一样用公钥解密,由于这个公钥和你的私钥是配对的,老师当然能用这个被替换的公钥解密出来,并且确认了内容的完整性,于是老师就会以为是你父亲写的请假条,又允许你请假了。
好家伙,为了一个请假,真的是斗智斗勇。
后面你的老师和父亲发现了你伪造公私钥的事情后,决定重新商量一个对策来应对你这个臭家伙。
正所谓魔高一丈,道高一尺。
既然伪造公私钥那么随意,所以你爸把他的公钥注册到警察局,警察局用他们自己的私钥对你父亲的公钥做了个数字签名,然后把你爸爸的「个人信息 + 公钥 + 数字签名」打包成一个数字证书,也就是说这个数字证书包含你爸爸的公钥。
这样,你爸爸如果因为家里确实有事要向老师帮你请假的时候,不仅会用自己的私钥对内容进行签名,还会把数字证书给到老师。
老师拿到了数字证书后,首先会去警察局验证这个数字证书是否合法,因为数字证书里有警察局的数字签名,警察局要验证证书合法性的时候,用自己的公钥解密,如果能解密成功,就说明这个数字证书是在警察局注册过的,就认为该数字证书是合法的,然后就会把数字证书里头的公钥(你爸爸的)给到老师。
由于通过警察局验证了数字证书是合法的,那么就能证明这个公钥就是你父亲的,于是老师就可以安心的用这个公钥解密出请假条,如果能解密出,就证明是你爸爸写的请假条。
正是通过了一个权威的机构来证明你爸爸的身份,所以你的伪造公私钥这个小伎俩就没用了。
在计算机里,这个权威的机构就是 CA (数字证书认证机构),将服务器公钥放在数字证书(由数字证书认证机构颁发)中,只要证书是可信的,公钥就是可信的。
数字证书的工作流程,我也画了一张图,方便大家理解:
通过数字证书的方式保证服务器公钥的身份,解决冒充的风险。
2.3 HTTPS 是如何建立连接的?其间交互了什么?
SSL/TLS 协议基本流程:
- 客户端向服务器索要并验证服务器的公钥。
- 双方协商生产「会话秘钥」。
- 双方采用「会话秘钥」进行加密通信。
前两步也就是 SSL/TLS 的建立过程,也就是 TLS 握手阶段。
TLS 的「握手阶段」涉及四次通信,使用不同的密钥交换算法,TLS 握手流程也会不一样的,现在常用的密钥交换算法有两种:RSA 算法 (opens new window)和 ECDHE 算法 (opens new window)。
基于 RSA 算法的 TLS 握手过程比较容易理解,所以这里先用这个给大家展示 TLS 握手过程,如下图:
TLS 协议建立的详细流程:
1. ClientHello
首先,由客户端向服务器发起加密通信请求,也就是 ClientHello
请求。
在这一步,客户端主要向服务器发送以下信息:
(1)客户端支持的 TLS 协议版本,如 TLS 1.2 版本。
(2)客户端生产的随机数(Client Random
),后面用于生成「会话秘钥」条件之一。
(3)客户端支持的密码套件列表,如 RSA 加密算法。
2. SeverHello
服务器收到客户端请求后,向客户端发出响应,也就是 SeverHello
。服务器回应的内容有如下内容:
(1)确认 TLS 协议版本,如果浏览器不支持,则关闭加密通信。
(2)服务器生产的随机数(Server Random
),也是后面用于生产「会话秘钥」条件之一。
(3)确认的密码套件列表,如 RSA 加密算法。
(4)服务器的数字证书。
3.客户端回应
客户端收到服务器的回应之后,首先通过浏览器或者操作系统中的 CA 公钥,确认服务器的数字证书的真实性。
如果证书没有问题,客户端会从数字证书中取出服务器的公钥,然后使用它加密报文,向服务器发送如下信息:
(1)一个随机数(pre-master key
)。该随机数会被服务器公钥加密。
(2)加密通信算法改变通知,表示随后的信息都将用「会话秘钥」加密通信。
(3)客户端握手结束通知,表示客户端的握手阶段已经结束。这一项同时把之前所有内容的发生的数据做个摘要,用来供服务端校验。
上面第一项的随机数是整个握手阶段的第三个随机数,会发给服务端,所以这个随机数客户端和服务端都是一样的。
服务器和客户端有了这三个随机数(Client Random、Server Random、pre-master key),接着就用双方协商的加密算法,各自生成本次通信的「会话秘钥」。
4. 服务器的最后回应
服务器收到客户端的第三个随机数(pre-master key
)之后,通过协商的加密算法,计算出本次通信的「会话秘钥」。
然后,向客户端发送最后的信息:
(1)加密通信算法改变通知,表示随后的信息都将用「会话秘钥」加密通信。
(2)服务器握手结束通知,表示服务器的握手阶段已经结束。这一项同时把之前所有内容的发生的数据做个摘要,用来供客户端校验。
至此,整个 TLS 的握手阶段全部结束。接下来,客户端与服务器进入加密通信,就完全是使用普通的 HTTP 协议,只不过用「会话秘钥」加密内容。
不过,基于 RSA 算法的 HTTPS 存在「前向安全」的问题:如果服务端的私钥泄漏了,过去被第三方截获的所有 TLS 通讯密文都会被破解。
为了解决这个问题,后面就出现了 ECDHE 密钥协商算法,我们现在大多数网站使用的正是 ECDHE 密钥协商算法。
客户端校验数字证书的流程是怎样的?
接下来,详细说一下实际中数字证书签发和验证流程。
如下图图所示,为数字证书签发和验证流程:
CA 签发证书的过程,如上图左边部分:
- 首先 CA 会把持有者的公钥、用途、颁发者、有效时间等信息打成一个包,然后对这些信息进行 Hash 计算,得到一个 Hash 值;
- 然后 CA 会使用自己的私钥将该 Hash 值加密,生成 Certificate Signature,也就是 CA 对证书做了签名;
- 最后将 Certificate Signature 添加在文件证书上,形成数字证书;
客户端校验服务端的数字证书的过程,如上图右边部分:
- 首先客户端会使用同样的 Hash 算法获取该证书的 Hash 值 H1;
- 通常浏览器和操作系统中集成了 CA 的公钥信息,浏览器收到证书后可以使用 CA 的公钥解密 Certificate Signature 内容,得到一个 Hash 值 H2 ;
- 最后比较 H1 和 H2,如果值相同,则为可信赖的证书,否则则认为证书不可信。
但事实上,证书的验证过程中还存在一个证书信任链的问题,因为我们向 CA 申请的证书一般不是根证书签发的,而是由中间证书签发的,比如百度的证书,从下图你可以看到,证书的层级有三级:
对于这种三级层级关系的证书的验证过程如下:
最开始客户端只信任根证书 GlobalSign Root CA 证书的,然后 “GlobalSign Root CA” 证书信任 “GlobalSign Organization Validation CA - SHA256 - G2” 证书,而 “GlobalSign Organization Validation CA - SHA256 - G2” 证书又信任 baidu.com 证书,于是客户端也信任 baidu.com 证书。
总括来说,由于用户信任 GlobalSign,所以由 GlobalSign 所担保的 baidu.com 可以被信任,另外由于用户信任操作系统或浏览器的软件商,所以由软件商预载了根证书的 GlobalSign 都可被信任。
这样的一层层地验证就构成了一条信任链路,整个证书信任链验证流程如下图所示:
最后一个问题,为什么需要证书链这么麻烦的流程?Root CA 为什么不直接颁发证书,而是要搞那么多中间层级呢?
这是为了确保根证书的绝对安全性,将根证书隔离地越严格越好,不然根证书如果失守了,那么整个信任链都会有问题。
2.4 HTTPS 的应用数据是如何保证完整性的?
TLS 在实现上分为握手协议和记录协议两层:
- TLS 握手协议就是我们前面说的 TLS 四次握手的过程,负责协商加密算法和生成对称密钥,后续用此密钥来保护应用程序数据(即 HTTP 数据);
- TLS 记录协议负责保护应用程序数据并验证其完整性和来源,所以对 HTTP 数据加密是使用记录协议;
TLS 记录协议主要负责消息(HTTP 数据)的压缩,加密及数据的认证,过程如下图:
具体过程如下:
- 首先,消息被分割成多个较短的片段,然后分别对每个片段进行压缩。
- 接下来,经过压缩的片段会被加上消息认证码(MAC 值,这个是通过哈希算法生成的),这是为了保证完整性,并进行数据的认证。通过附加消息认证码的 MAC 值,可以识别出篡改。与此同时,为了防止重放攻击,在计算消息认证码时,还加上了片段的编码。
- 再接下来,经过压缩的片段再加上消息认证码会一起通过对称密码进行加密。
- 最后,上述经过加密的数据再加上由数据类型、版本号、压缩后的长度组成的报头就是最终的报文数据。
记录协议完成后,最终的报文数据将传递到传输控制协议 (TCP) 层进行传输。
2.5 HTTPS 一定安全可靠吗?
之前有读者在字节面试的时候,被问到:HTTPS 一定安全可靠吗?
这个问题的场景是这样的:客户端通过浏览器向服务端发起 HTTPS 请求时,被「假基站」转发到了一个「中间人服务器」,于是客户端是和「中间人服务器」完成了 TLS 握手,然后这个「中间人服务器」再与真正的服务端完成 TLS 握手。
具体过程如下:
- 客户端向服务端发起 HTTPS 建立连接请求时,然后被「假基站」转发到了一个「中间人服务器」,接着中间人向服务端发起 HTTPS 建立连接请求,此时客户端与中间人进行 TLS 握手,中间人与服务端进行 TLS 握手;
- 在客户端与中间人进行 TLS 握手过程中,中间人会发送自己的公钥证书给客户端,客户端验证证书的真伪,然后从证书拿到公钥,并生成一个随机数,用公钥加密随机数发送给中间人,中间人使用私钥解密,得到随机数,此时双方都有随机数,然后通过算法生成对称加密密钥(A),后续客户端与中间人通信就用这个对称加密密钥来加密数据了。
- 在中间人与服务端进行 TLS 握手过程中,服务端会发送从 CA 机构签发的公钥证书给中间人,从证书拿到公钥,并生成一个随机数,用公钥加密随机数发送给服务端,服务端使用私钥解密,得到随机数,此时双方都有随机数,然后通过算法生成对称加密密钥(B),后续中间人与服务端通信就用这个对称加密密钥来加密数据了。
- 后续的通信过程中,中间人用对称加密密钥(A)解密客户端的 HTTPS 请求的数据,然后用对称加密密钥(B)加密 HTTPS 请求后,转发给服务端,接着服务端发送 HTTPS 响应数据给中间人,中间人用对称加密密钥(B)解密 HTTPS 响应数据,然后再用对称加密密钥(A)加密后,转发给客户端。
从客户端的角度看,其实并不知道网络中存在中间人服务器这个角色。那么中间人就可以解开浏览器发起的 HTTPS 请求里的数据,也可以解开服务端响应给浏览器的 HTTPS 响应数据。相当于,中间人能够 “偷看” 浏览器与服务端之间的 HTTPS 请求和响应的数据。
但是要发生这种场景是有前提的,前提是用户点击接受了中间人服务器的证书。
中间人服务器与客户端在 TLS 握手过程中,实际上发送了自己伪造的证书给浏览器,而这个伪造的证书是能被浏览器(客户端)识别出是非法的,于是就会提醒用户该证书存在问题。
如果用户执意点击「继续浏览此网站」,相当于用户接受了中间人伪造的证书,那么后续整个 HTTPS 通信都能被中间人监听了。
所以,这其实并不能说 HTTPS 不够安全,毕竟浏览器都已经提示证书有问题了,如果用户坚决要访问,那不能怪 HTTPS ,得怪自己手贱。
另外,如果你的电脑中毒了,被恶意导入了中间人的根证书,那么在验证中间人的证书的时候,由于你操作系统信任了中间人的根证书,那么等同于中间人的证书是合法的,这种情况下,浏览器是不会弹出证书存在问题的风险提醒的。
这其实也不关 HTTPS 的事情,是你电脑中毒了才导致 HTTPS 数据被中间人劫持的。
所以,HTTPS 协议本身到目前为止还是没有任何漏洞的,即使你成功进行中间人攻击,本质上是利用了客户端的漏洞(用户点击继续访问或者被恶意导入伪造的根证书),并不是 HTTPS 不够安全。
为什么抓包工具能截取 HTTPS 数据?
很多抓包工具 之所以可以明文看到 HTTPS 数据,工作原理与中间人一致的。
对于 HTTPS 连接来说,中间人要满足以下两点,才能实现真正的明文代理:
- 中间人,作为客户端与真实服务端建立连接这一步不会有问题,因为服务端不会校验客户端的身份;
- 中间人,作为服务端与真实客户端建立连接,这里会有客户端信任服务端的问题,也就是服务端必须有对应域名的私钥;
中间人要拿到私钥只能通过如下方式:
- 去网站服务端拿到私钥;
- 去CA处拿域名签发私钥;
- 自己签发证书,切要被浏览器信任;
不用解释,抓包工具只能使用第三种方式取得中间人的身份。
使用抓包工具进行 HTTPS 抓包的时候,需要在客户端安装 Fiddler 的根证书,这里实际上起认证中心(CA)的作用。
抓包工具能够抓包的关键是客户端会往系统受信任的根证书列表中导入抓包工具生成的证书,而这个证书会被浏览器信任,也就是抓包工具给自己创建了一个认证中心 CA,客户端拿着中间人签发的证书去中间人自己的 CA 去认证,当然认为这个证书是有效的。
如何避免被中间人抓取数据?
我们要保证自己电脑的安全,不要被病毒乘虚而入,而且也不要点击任何证书非法的网站,这样 HTTPS 数据就不会被中间人截取到了。
当然,我们还可以通过 HTTPS 双向认证来避免这种问题。
一般我们的 HTTPS 是单向认证,客户端只会验证了服务端的身份,但是服务端并不会验证客户端的身份。
如果用了双向认证方式,不仅客户端会验证服务端的身份,而且服务端也会验证客户端的身份。服务端一旦验证到请求自己的客户端为不可信任的,服务端就拒绝继续通信,客户端如果发现服务端为不可信任的,那么也中止通信。
3、HTTP/1.1、HTTP/2、HTTP/3 演变
3.1 HTTP/1.1 相比 HTTP/1.0 提高了什么性能?
HTTP/1.1 相比 HTTP/1.0 性能上的改进:
- 使用长连接的方式改善了 HTTP/1.0 短连接造成的性能开销。
- 支持管道(pipeline)网络传输,只要第一个请求发出去了,不必等其回来,就可以发第二个请求出去,可以减少整体的响应时间。
但 HTTP/1.1 还是有性能瓶颈:
- 请求 / 响应头部(Header)未经压缩就发送,首部信息越多延迟越大。只能压缩
Body
的部分; - 发送冗长的首部。每次互相发送相同的首部造成的浪费较多;
- 服务器是按请求的顺序响应的,如果服务器响应慢,会招致客户端一直请求不到数据,也就是队头阻塞;
- 没有请求优先级控制;
- 请求只能从客户端开始,服务器只能被动响应。
3.2 HTTP/2 做了什么优化?
HTTP/2 协议是基于 HTTPS 的,所以 HTTP/2 的安全性也是有保障的。
那 HTTP/2 相比 HTTP/1.1 性能上的改进:
- 头部压缩
- 二进制格式
- 并发传输
- 服务器主动推送资源
1. 头部压缩
HTTP/2 会压缩头(Header)如果你同时发出多个请求,他们的头是一样的或是相似的,那么,协议会帮你消除重复的部分。
这就是所谓的 HPACK
算法:在客户端和服务器同时维护一张头信息表,所有字段都会存入这个表,生成一个索引号,以后就不发送同样字段了,只发送索引号,这样就提高速度了。
2. 二进制格式
HTTP/2 不再像 HTTP/1.1 里的纯文本形式的报文,而是全面采用了二进制格式,头信息和数据体都是二进制,并且统称为帧(frame):头信息帧(Headers Frame)和数据帧(Data Frame) 。
这样虽然对人不友好,但是对计算机非常友好,因为计算机只懂二进制,那么收到报文后,无需再将明文的报文转成二进制,而是直接解析二进制报文,这增加了数据传输的效率。
比如状态码 200 ,在 HTTP/1.1 是用 '2''0''0' 三个字符来表示(二进制:00110010 00110000 00110000),共用了 3 个字节,如下图
在 HTTP/2 对于状态码 200 的二进制编码是 10001000,只用了 1 字节就能表示,相比于 HTTP/1.1 节省了 2 个字节,如下图:
Header: :status: 200 OK 的编码内容为:1000 1000,那么表达的含义是什么呢?
- 最前面的 1 标识该 Header 是静态表中已经存在的 KV。
- 在静态表里,“:status: 200 ok” 静态表编码是 8,二进制即是 1000。
因此,整体加起来就是 1000 1000。
3. 并发传输
我们都知道 HTTP/1.1 的实现是基于请求-响应模型的。同一个连接中,HTTP 完成一个事务(请求与响应),才能处理下一个事务,也就是说在发出请求等待响应的过程中,是没办法做其他事情的,如果响应迟迟不来,那么后续的请求是无法发送的,也造成了队头阻塞的问题。
而 HTTP/2 就很牛逼了,引出了 Stream 概念,多个 Stream 复用在一条 TCP 连接。
从上图可以看到,1 个 TCP 连接包含多个 Stream,Stream 里可以包含 1 个或多个 Message,Message 对应 HTTP/1 中的请求或响应,由 HTTP 头部和包体构成。Message 里包含一条或者多个 Frame,Frame 是 HTTP/2 最小单位,以二进制压缩格式存放 HTTP/1 中的内容(头部和包体)。
针对不同的 HTTP 请求用独一无二的 Stream ID 来区分,接收端可以通过 Stream ID 有序组装成 HTTP 消息,不同 Stream 的帧是可以乱序发送的,因此可以并发不同的 Stream ,也就是 HTTP/2 可以并行交错地发送请求和响应。
比如下图,服务端并行交错地发送了两个响应: Stream 1 和 Stream 3,这两个 Stream 都是跑在一个 TCP 连接上,客户端收到后,会根据相同的 Stream ID 有序组装成 HTTP 消息。
4、服务器推送
HTTP/2 还在一定程度上改善了传统的「请求 - 应答」工作模式,服务端不再是被动地响应,可以主动向客户端发送消息。
客户端和服务器双方都可以建立 Stream, Stream ID 也是有区别的,客户端建立的 Stream 必须是奇数号,而服务器建立的 Stream 必须是偶数号。
比如下图,Stream 1 是客户端向服务端请求的资源,属于客户端建立的 Stream,所以该 Stream 的 ID 是奇数(数字 1);Stream 2 和 4 都是服务端主动向客户端推送的资源,属于服务端建立的 Stream,所以这两个 Stream 的 ID 是偶数(数字 2 和 4)。
再比如,客户端通过 HTTP/1.1 请求从服务器那获取到了 HTML 文件,而 HTML 可能还需要依赖 CSS 来渲染页面,这时客户端还要再发起获取 CSS 文件的请求,需要两次消息往返,如下图左边部分:
如上图右边部分,在 HTTP/2 中,客户端在访问 HTML 时,服务器可以直接主动推送 CSS 文件,减少了消息传递的次数。
HTTP/2 有什么缺陷?
HTTP/2 通过 Stream 的并发能力,解决了 HTTP/1 队头阻塞的问题,看似很完美了,但是 HTTP/2 还是存在“队头阻塞”的问题,只不过问题不是在 HTTP 这一层面,而是在 TCP 这一层。
HTTP/2 是基于 TCP 协议来传输数据的,TCP 是字节流协议,TCP 层必须保证收到的字节数据是完整且连续的,这样内核才会将缓冲区里的数据返回给 HTTP 应用,那么当「前 1 个字节数据」没有到达时,后收到的字节数据只能存放在内核缓冲区里,只有等到这 1 个字节数据到达时,HTTP/2 应用层才能从内核中拿到数据,这就是 HTTP/2 队头阻塞问题。
举个例子,如下图:
图中发送方发送了很多个 packet,每个 packet 都有自己的序号,你可以认为是 TCP 的序列号,其中 packet 3 在网络中丢失了,即使 packet 4-6 被接收方收到后,由于内核中的 TCP 数据不是连续的,于是接收方的应用层就无法从内核中读取到,只有等到 packet 3 重传后,接收方的应用层才可以从内核中读取到数据,这就是 HTTP/2 的队头阻塞问题,是在 TCP 层面发生的。
所以,一旦发生了丢包现象,就会触发 TCP 的重传机制,这样在一个 TCP 连接中的所有的 HTTP 请求都必须等待这个丢了的包被重传回来。
3.3 HTTP/3 做了哪些优化?
前面我们知道了 HTTP/1.1 和 HTTP/2 都有队头阻塞的问题:
- HTTP/1.1 中的管道( pipeline)虽然解决了请求的队头阻塞,但是没有解决响应的队头阻塞,因为服务端需要按顺序响应收到的请求,如果服务端处理某个请求消耗的时间比较长,那么只能等响应完这个请求后, 才能处理下一个请求,这属于 HTTP 层队头阻塞。
- HTTP/2 虽然通过多个请求复用一个 TCP 连接解决了 HTTP 的队头阻塞 ,但是一旦发生丢包,就会阻塞住所有的 HTTP 请求,这属于 TCP 层队头阻塞。
HTTP/2 队头阻塞的问题是因为 TCP,所以 HTTP/3 把 HTTP 下层的 TCP 协议改成了 UDP!
UDP 发送是不管顺序,也不管丢包的,所以不会出现像 HTTP/2 队头阻塞的问题。大家都知道 UDP 是不可靠传输的,但基于 UDP 的 QUIC 协议 可以实现类似 TCP 的可靠性传输。
QUIC 有以下 3 个特点。
- 无队头阻塞
- 更快的连接建立
- 连接迁移
1、无队头阻塞
QUIC 协议也有类似 HTTP/2 Stream 与多路复用的概念,也是可以在同一条连接上并发传输多个 Stream,Stream 可以认为就是一条 HTTP 请求。
QUIC 有自己的一套机制可以保证传输的可靠性的。当某个流发生丢包时,只会阻塞这个流,其他流不会受到影响,因此不存在队头阻塞问题。这与 HTTP/2 不同,HTTP/2 只要某个流中的数据包丢失了,其他流也会因此受影响。
所以,QUIC 连接上的多个 Stream 之间并没有依赖,都是独立的,某个流发生丢包了,只会影响该流,其他流不受影响。
2、更快的连接建立
对于 HTTP/1 和 HTTP/2 协议,TCP 和 TLS 是分层的,分别属于内核实现的传输层、openssl 库实现的表示层,因此它们难以合并在一起,需要分批次来握手,先 TCP 握手,再 TLS 握手。
HTTP/3 在传输数据前虽然需要 QUIC 协议握手,但是这个握手过程只需要 1 RTT,握手的目的是为确认双方的「连接 ID」,连接迁移就是基于连接 ID 实现的。
但是 HTTP/3 的 QUIC 协议并不是与 TLS 分层,而是 QUIC 内部包含了 TLS,它在自己的帧会携带 TLS 里的“记录”,再加上 QUIC 使用的是 TLS/1.3,因此仅需 1 个 RTT 就可以「同时」完成建立连接与密钥协商,如下图:
甚至,在第二次连接的时候,应用数据包可以和 QUIC 握手信息(连接信息 + TLS 信息)一起发送,达到 0-RTT 的效果。
如下图右边部分,HTTP/3 当会话恢复时,有效负载数据与第一个数据包一起发送,可以做到 0-RTT(下图的右下角):
3、连接迁移
基于 TCP 传输协议的 HTTP 协议,由于是通过四元组(源 IP、源端口、目的 IP、目的端口)确定一条 TCP 连接。
那么当移动设备的网络从 4G 切换到 WIFI 时,意味着 IP 地址变化了,那么就必须要断开连接,然后重新建立连接。而建立连接的过程包含 TCP 三次握手和 TLS 四次握手的时延,以及 TCP 慢启动的减速过程,给用户的感觉就是网络突然卡顿了一下,因此连接的迁移成本是很高的。
而 QUIC 协议没有用四元组的方式来“绑定”连接,而是通过连接 ID 来标记通信的两个端点,客户端和服务器可以各自选择一组 ID 来标记自己,因此即使移动设备的网络变化后,导致 IP 地址变化了,只要仍保有上下文信息(比如连接 ID、TLS 密钥等),就可以“无缝”地复用原连接,消除重连的成本,没有丝毫卡顿感,达到了连接迁移的功能。
所以, QUIC 是一个在 UDP 之上的伪 TCP + TLS + HTTP/2 的多路复用的协议。
QUIC 是新协议,对于很多网络设备,根本不知道什么是 QUIC,只会当做 UDP,这样会出现新的问题,因为有的网络设备是会丢掉 UDP 包的,而 QUIC 是基于 UDP 实现的,那么如果网络设备无法识别这个是 QUIC 包,那么就会当作 UDP包,然后被丢弃。
一、沉默王二-JVM-JVM 核心知识点
1、Java 内存区域
1.2 程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。
字节码解释器通过改变程序计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要该计数器来完成。
每个线程都拥有一个独立的程序计数器,各个线程之间的计数器互不影响,独立存储。
1.3 虚拟机栈
虚拟机栈(Java Virtual Machine Stack)也是线程私有,它描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
方法从调用到结束就对应着一个栈帧从入栈到出栈的过程。在《Java 虚拟机规范》中,对该内存区域规定了两类异常:
- 如果线程请求的栈深度大于虚拟机所允许的栈深度,将抛出
StackOverflowError
异常; - 如果 Java 虚拟机栈的容量允许动态扩展,当栈扩展时如果无法申请到足够的内存会抛出
OutOfMemoryError
异常。
1.3 本地方法栈
本地方法栈(Native Method Stacks)与虚拟机栈类似,其区别在于:Java 虚拟机栈是为虚拟机执行 Java 方法(也就是字节码)服务的,而本地方法栈则是为 JVM 使用到的本地(Native)方法服务。
1.4 堆
堆(Java Heap)是虚拟机所管理的最大一块内存空间,它被所有线程所共享,用于存放对象实例。
Java 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为是连续的。Java 堆可以被实现成固定大小的,也可以是可扩展的。
当前大多数主流的虚拟机都是按照可扩展来实现的,即可以通过最大值参数 -Xmx
和最小值参数 -Xms
进行设定。
如果 Java 堆中没有足够的内存来完成对象实例分配,并且堆也无法再扩展时,Java 虚拟机将会抛出 OutOfMemoryError
异常。
1.5 方法区
方法区(Method Area)也是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、JIT 编译后的代码缓存等数据。
方法区也被称为 “非堆”,目的是与 Java 堆进行区分。《Java 虚拟机规范》规定,如果方法区无法满足新的内存分配需求时,将会抛出 OutOfMemoryError
异常。
JDK 8 以后的方法区实现已经不再是永久代(Permanent Generation)了,而是使用元空间(Metaspace)来实现。
运行时常量池(Runtime Constant Pool)也是方法区的一部分,用于存放常量池表(Constant Pool Table),常量池表中存放了编译期生成的各种符号字面量和符号引用。
JDK 8 以后的运行时常量池在元空间中。
2、 对象
2.1 对象的创建
当我们在代码中使用 new
关键字创建一个对象时,其在 JVM 中需要经过以下步骤:
2.1.1 类加载过程
当虚拟机遇到一条字节码指令 new
时,首先将去检查这个指令的参数是否能在常量池中定位到一个符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,就必须先执行相应的类加载过程。
2.1.2 分配内存
在类加载检查通过后,虚拟机需要给新生对象分配内存空间。根据 Java 堆是否规整,可以有以下两种分配方案:
①、指针碰撞:假设 Java 堆中内存是绝对规整的,所有使用的内存放在一边,所有未被使用的内存放在另外一边,中间以指针作为分界点指示器。
此时内存分配只是将指针向空闲方向偏移出对象大小的空间即可,这种方式被称为指针碰撞。
②、空闲列表:如果 Java 堆不是规整的,此时虚拟机需要维护一个列表,记录哪些内存块是可用的,哪些是不可用的。在进行内存分配时,只需要从该列表中选取出一块足够的内存空间划分给对象实例即可。
注:Java 堆是否规整取决于其采用的垃圾收集器是否带有空间压缩整理能力,前面讲过了。
除了分配方式外,由于对象创建在虚拟机中是一个非常频繁的行为,此时需要保证在并发环境下的线程安全:如果一个线程给对象 A 分配了内存空间,但指针还没来得及修改,此时就可能出现另外一个线程使用原来的指针来给对象 B 分配内存空间的情况。
想要解决这个问题有两个方案:
①、方式一:采用同步锁定,或采用 CAS 配上失败重试的方式来保证更新操作的原子性。
②、方式二:为每个线程在 Java 堆中预先分配一块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。
线程在进行内存分配时优先使用本地缓冲,当本地缓冲使用完成后,再向 Java 堆申请分配,此时 Java 堆采用同步锁定的方式来保证分配行为的线程安全。
2.1.3 对象头设置
将对象有关的元数据信息、对象的哈希码、分代年龄等信息存储到对象头中。
可以和 JIT 那节的内容关联起来。
2.1.4 对象初始化
调用对象的构造方法,即 Class 文件中的
来初始化对象,为相关字段赋值。
2.2 对象的内存布局
在 HotSpot 中,对象在堆内存中的存储布局可以划分为以下三个部分:
2.2.1 对象头 (Header)
对象头包括两部分信息:
- Mark Word:对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,官方统称为 Mark Word,我们曾在 synchronized 的四种锁状态讲过。
- 类型指针:对象指向它类型元数据的指针,Java 虚拟机通过这个指针来确定该对象是哪个类的实例。需要说明的是,并非所有的虚拟机都必须要在对象数据上保留类型指针,这取决于对象的访问定位方式。
2.2.2 实例数据 (Instance Data)
即我们在代码中定义的各种类型的字段,无论是从父类继承而来,还是子类中定义的都需要记录。
2.2.3 对齐填充 (Padding)
主要起占位符的作用。HotSpot 要求对象起始地址必须是 8 字节的整倍数,即间接要求了任何对象的大小都必须是 8 字节的整倍数。对象头部分在设计上就是 8 字节的整倍数,如果对象的实例数据不是 8 字节的整倍数,则由对齐填充进行补全。
2.3 对象的访问定位
对象创建后,Java 程序就可以通过栈上的 reference
(也就是引用)来操作堆上的具体对象。
《Java 虚拟机规范》规定 reference
是一个指向对象的引用,但并未规定其具体实现方式。主流的方式方式有以下两种:
- 句柄访问:Java 堆将划分出一块内存来作为句柄池,
reference
中存储的是对象的句柄地址,而句柄则包含了对象实例数据和类型数据的地址信息。 - 指针访问:
reference
中存储的直接就是对象地址,而对象的类型数据则由上文介绍的对象头中的类型指针来指定。
通过句柄访问对象:
通过直接指针访问对象:
句柄访问的优点在于对象移动时(垃圾收集时移动对象是非常普遍的行为)只需要改变句柄中实例数据的指针,而 reference
本身并不需要修改;
指针访问则反之,由于其 reference
中存储的直接就是对象地址,所以当对象移动时, reference
需要被修改。但针对只需要访问对象本身的场景,指针访问则可以减少一次定位开销。由于对象访问是一项非常频繁的操作,所以这类减少的效果会非常显著,基于这个原因,HotSpot 主要使用的是指针访问的方式。
3、垃圾收集机制
在 JVM 内存模型中,程序计数器、虚拟机栈、本地方法栈这 3 个区域都是线程私有的,会随着线程的结束而销毁,因此在这 3 个区域当中,无需过多考虑垃圾回收问题。垃圾回收问题主要发生在 Java 堆上。
在 Java 堆上,垃圾回收的主要内容是死亡的对象(不可能再被任何途径使用的对象)。
判断对象是否死亡有以下两种方法:
3.1 引用计数法
在对象中添加一个引用计数器,对象每次被引用时,该计数器加一;当引用失效时,计数器的值减一;只要计数器的值为零,则代表对应的对象不可能再被使用。该方法的缺点在于无法避免相互引用的问题:
java 代码解读复制代码objA.instance = objB
objB.instance = objA
objA = null;
objB = null;
System.gc();
如上所示,此时两个对象已经不能再被访问,但其互相持有对对方的引用,如果采用引用计数法,则两个对象都无法被回收。
3.2 可达性分析
但上面的代码在大多数虚拟机中都能被正确的回收,因为大多数主流的虚拟机都是采用的可达性分析方法来判断对象是否死亡。
可达性分析是通过一系列被称为 GC Roots
的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径被称为引用链(Reference Chain),如果某个对象到 GC Roots
间没有任何引用链相连,这代表 GC Roots
到该对象不可达, 此时证明该对象不可能再被使用。
在 Java 语言中,固定可作为 GC Roots
的对象包括以下几种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等;
- 在方法区(元空间)中类静态变量引用的对象,譬如 Java 类中引用类型的静态变量;
- 在方法区(元空间)中常量引用的对象,譬如字符串常量池(String Table)里的引用;
- 在本地方法栈中的 JNI(Native 方法)引用的对象;
- Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(如 NullPointException,OutOfMemoryError 等)及系统类加载器;
- 所有被同步锁(synchronized 关键字)持有的对象;
除了这些固定的 GC Roots
集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域的不同,还可能会有其他对象 “临时性” 地加入,共同构成完整的 GC Roots
集合。
3.3 对象引用
可达性分析是基于引用链进行判断的,在 JDK 1.2 之后,Java 将引用关系分为以下四类:
3.3.1 强引用 (Strongly Reference)
最传统的引用,如 Object obj = new Object()
。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
3.3.2 软引用 (Soft Reference)
用于描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出之前,会被列入回收范围内进行第二次回收,如果这次回收后还没有足够的内存,才会抛出内存溢出异常。
下面是一个使用 Java 中 SoftReference 类的示例代码:
java 代码解读复制代码class SoftReferenceExample {
public static void main(String[] args) {
// 创建一个强引用的对象
String strongReference = new String("二哥,我是个强引用");
// 创建一个软引用,指向上面的对象
SoftReference softReference = new SoftReference<>(strongReference);
// 干掉强引用
strongReference = null;
// 现在只有软引用指向 "二哥,我是个强引用" 对象
// 尝试通过软引用获取对象
String retrievedString = softReference.get();
System.out.println(retrievedString); // 输出 "二哥,我是个强引用"
// 强制进行垃圾回收,可能会清除软引用的对象
System.gc();
// 再次尝试通过软引用获取对象
retrievedString = softReference.get();
if (retrievedString != null) {
System.out.println(retrievedString);
} else {
System.out.println("软引用的对象已被垃圾回收");
}
}
}
这个例子中,我们首先创建了一个字符串对象的强引用,然后通过 SoftReference 创建了这个对象的软引用。在取消了强引用后,这个对象只剩下软引用。当我们尝试通过软引用获取对象时,如果对象还存在,软引用会返回它;如果对象已被垃圾收集器回收,则返回 null。
需要注意的是,第二次回收时,如果这次回收后还没有足够的内存,才会抛出内存溢出异常。这里的 “足够” 是指在抛出内存溢出异常之前,系统会进行最后一次尝试,如果这次回收后还是没有足够的内存,才会抛出内存溢出异常。
通常情况下,上面代码在执行 gc 后软引用不会被回收,因为此时内存还是足够的。
java代码解读复制代码二哥,我是个强引用 二哥,我是个强引用
3.3.3 弱引用 (Weak Reference)
用于描述那些非必须的对象,强度比软引用弱。被弱引用关联的对象只能生存到下一次垃圾收集发生时,无论当前内存是否足够,弱引用对象都会被回收。
来看这段代码:
java 代码解读复制代码class WeakReferenceExample {
public static void main(String[] args) {
// 创建一个强引用的对象
String strongReference = new String("二哥,我是强引用");
// 创建一个弱引用,指向上面的对象
WeakReference weakReference = new WeakReference<>(strongReference);
// 取消强引用
strongReference = null;
// 强制进行垃圾回收
System.gc();
// 尝试通过弱引用获取对象
String retrievedString = weakReference.get();
if (retrievedString != null) {
System.out.println(retrievedString);
} else {
System.out.println("弱引用的对象已被垃圾回收");
}
}
}
这个例子中,我们首先创建了一个字符串对象的强引用,然后通过 WeakReference 创建了这个对象的弱引用。在取消了强引用后,这个对象只剩下弱引用。当我们尝试通过弱引用获取对象时,如果对象还存在,弱引用会返回它;如果对象已被垃圾收集器回收,则返回 null。
运行结果就和软引用不一样了,gc 后弱引用被回收了。
java代码解读复制代码弱引用的对象已被垃圾回收
3.3.4 虚引用 (Phantom Reference)
最弱的引用关系。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被回收时收到一个系统通知。
虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾收集器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入到与之关联的引用队列中。
来看这个示例代码:
java 代码解读复制代码class PhantomReferenceExample {
public static void main(String[] args) {
// 创建一个强引用的对象
String strongReference = new String("二哥,我是强引用");
// 创建一个引用队列
ReferenceQueue referenceQueue = new ReferenceQueue<>();
// 创建一个虚引用,指向上面的对象,并与引用队列关联
PhantomReference phantomReference = new PhantomReference<>(strongReference, referenceQueue);
// 取消强引用
strongReference = null;
// 强制进行垃圾回收
System.gc();
// 检查引用队列,看是否有通知
if (referenceQueue.poll() != null) {
System.out.println("虚引用的对象已被垃圾回收,且收到了通知");
} else {
System.out.println("虚引用的对象未被垃圾回收,或未收到通知");
}
}
}
这个例子中,我们首先创建了一个字符串对象的强引用,然后通过 PhantomReference 创建了这个对象的虚引用,并与引用队列关联。在取消了强引用后,这个对象只剩下虚引用。当我们强制进行垃圾回收时,如果对象还存在,虚引用不会返回它;如果对象已被垃圾收集器回收,则返回 null。同时,如果对象被回收,虚引用会被加入到引用队列中。
运行结果如下:
java代码解读复制代码虚引用的对象已被垃圾回收,且收到了通知
3.4 对象真正死亡
要真正宣告一个对象死亡,需要经过至少两次标记过程:
①、如果对象在进行可达性分析后发现 GC Roots
不可达,将会进行第一次标记;
②、随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalized()
方法。
如果对象没有覆盖 finalized()
方法,或者 finalized()
已经被虚拟机调用过,这两种情况都会视为没有必要执行。
如果判定结果是有必要执行,此时对象会被放入名为 F-Queue
的队列,等待 Finalizer 线程执行其 finalized()
方法。
在这个过程中,收集器会进行第二次小规模的标记,如果对象在 finalized()
方法中重新将自己与引用链上的任何一个对象进行了关联,如将自己(this 关键字)赋值给某个类变量或者对象的成员变量,此时它就实现了自我拯救,则第二次标记会将其移除 “即将回收” 的集合,否则该对象就将被真正回收,走向死亡。
3.5 垃圾收集算法
3.5.1 分代收集理论
当前大多数虚拟机都遵循 “分代收集” 的理论进行设计,它建立在强弱两个分代假说下:
- 弱分代假说 (Weak Generational Hypothesis) :绝大多数对象都是朝生夕灭的。
- 强分代假说 (Strong Generational Hypothesis) :熬过越多次垃圾收集过程的对象就越难以消亡。
- 跨带引用假说 (Intergenerational Reference Hypothesis) :基于上面两条假说还可以得出的一条隐含推论:存在相互引用关系的两个对象,应该倾向于同时生存或者同时消亡。
强弱分代假说奠定了垃圾收集器的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄就是对象经历垃圾收集的次数)分配到不同的区域中进行存储。
之后如果一个区域中的对象都是朝生夕灭的,那么收集器只需要关注少量对象的存活而不是去标记那些大量将要被回收的对象,此时就能以较小的代价获取较大的空间。
最后再将难以消亡的对象集中到一块,根据强分代假说,它们是很难消亡的,因此虚拟机可以使用较低的频率进行回收,这就兼顾了时间和内存空间的开销。
3.5.2 回收类型
根据分代收集理论,收集范围可以分为以下几种类型:
①、部分收集 (Partial GC) :具体分为:
- 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
- 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
- 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
- 整堆收集 (Full GC) :收集整个 Java 堆和方法区。
3.5.3 标记-清除算法
它是最基础的垃圾收集算法,收集过程分为两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象;也可以反过来,标记存活对象,统一回收所有未被标记的对象。
它主要有以下两个缺点:
- 执行效率不稳定:如果 Java 堆上包含大量需要回收的对象,则需要进行大量标记和清除动作;
- 内存空间碎片化:标记清除后会产生大量不连续的空间,从而导致无法为大对象分配足够的连续内存。
3.5.4 标记-复制算法
标记-复制算法基于 ”半区复制“ 算法:它将可用内存按容量划分为大小相等的两块,每次只使用其中一块,当这一块的内存使用完了,就将还存活着的对象复制到另外一块,然后再把已经使用过的那块内存空间一次性清理掉。其优点在于避免了内存空间碎片化的问题,其缺点如下:
- 如果内存中多数对象都是存活的,这种算法将产生大量的复制开销;
- 浪费内存空间,内存空间变为了原有的一半。
基于新生代 “朝生夕灭” 的特点,大多数虚拟机都不会按照 1:1 的比例来进行内存划分,例如 HotSpot 会将内存空间划分为一块较大的 Eden
和两块较小的 Survivor
空间,它们之间的比例是 8:1:1 。
每次分配时只会使用 Eden
和其中的一块 Survivor
,发生垃圾回收时,只需要将存活的对象一次性复制到另外一块 Survivor
上,这样只有 10% 的内存空间会被浪费掉。
当 Survivor
空间不足以容纳一次 Minor GC
时,此时由其他内存区域(通常是老年代)来进行分配担保。
3.5.5 标记-整理算法
标记-整理算法是在标记完成后,让所有存活对象都向内存的一端移动,然后直接清理掉边界以外的内存。
其优点在于可以避免内存空间碎片化的问题,也可以充分利用内存空间;其缺点在于根据所使用的收集器的不同,在移动存活对象时可能要全程暂停用户程序:
二、小林-图解网络-HTTP相关问题
1、HTTP 特性
到目前为止,HTTP 常见到版本有 HTTP/1.1,HTTP/2.0,HTTP/3.0,不同版本的 HTTP 特性是不一样的。
1.1 HTTP/1.1 的优点有哪些?
HTTP 最突出的优点是「简单、灵活和易于扩展、应用广泛和跨平台」。
1. 简单
HTTP 基本的报文格式就是 header + body
,头部信息也是 key-value
简单文本的形式,易于理解,降低了学习和使用的门槛。
2. 灵活和易于扩展
HTTP 协议里的各类请求方法、URI/URL、状态码、头字段等每个组成要求都没有被固定死,都允许开发人员自定义和扩充。
同时 HTTP 由于是工作在应用层( OSI
第七层),则它下层可以随意变化,比如:
- HTTPS 就是在 HTTP 与 TCP 层之间增加了 SSL/TLS 安全传输层;
- HTTP/1.1 和 HTTP/2.0 传输协议使用的是 TCP 协议,而到了 HTTP/3.0 传输协议改用了 UDP 协议。
3. 应用广泛和跨平台
互联网发展至今,HTTP 的应用范围非常的广泛,从台式机的浏览器到手机上的各种 APP,从看新闻、刷贴吧到购物、理财、吃鸡,HTTP 的应用遍地开花,同时天然具有跨平台的优越性。
1.2 HTTP/1.1 的缺点有哪些?
HTTP 协议里有优缺点一体的双刃剑,分别是「无状态、明文传输」,同时还有一大缺点「不安全」。
1. 无状态双刃剑
无状态的好处,因为服务器不会去记忆 HTTP 的状态,所以不需要额外的资源来记录状态信息,这能减轻服务器的负担,能够把更多的 CPU 和内存用来对外提供服务。
无状态的坏处,既然服务器没有记忆能力,它在完成有关联性的操作时会非常麻烦。
例如登录->添加购物车->下单->结算->支付,这系列操作都要知道用户的身份才行。但服务器不知道这些请求是有关联的,每次都要问一遍身份信息。
这样每操作一次,都要验证信息,这样的购物体验还能愉快吗?别问,问就是酸爽!
对于无状态的问题,解法方案有很多种,其中比较简单的方式用 Cookie 技术。
Cookie
通过在请求和响应报文中写入 Cookie 信息来控制客户端的状态。
相当于,在客户端第一次请求后,服务器会下发一个装有客户信息的「小贴纸」,后续客户端请求服务器的时候,带上「小贴纸」,服务器就能认得了了,
2. 明文传输双刃剑
明文意味着在传输过程中的信息,是可方便阅读的,比如 Wireshark 抓包都可以直接肉眼查看,为我们调试工作带了极大的便利性。
但是这正是这样,HTTP 的所有信息都暴露在了光天化日下,相当于信息裸奔。在传输的漫长的过程中,信息的内容都毫无隐私可言,很容易就能被窃取,如果里面有你的账号密码信息,那你号没了。
3. 不安全
HTTP 比较严重的缺点就是不安全:
- 通信使用明文(不加密),内容可能会被窃听。比如,账号信息容易泄漏,那你号没了。
- 不验证通信方的身份,因此有可能遭遇伪装。比如,访问假的淘宝、拼多多,那你钱没了。
- 无法证明报文的完整性,所以有可能已遭篡改。比如,网页上植入垃圾广告,视觉污染,眼没了。
HTTP 的安全问题,可以用 HTTPS 的方式解决,也就是通过引入 SSL/TLS 层,使得在安全上达到了极致。
1.3 HTTP/1.1 的性能如何?
HTTP 协议是基于 TCP/IP,并且使用了「请求 - 应答」的通信模式,所以性能的关键就在这两点里。
1. 长连接
早期 HTTP/1.0 性能上的一个很大的问题,那就是每发起一个请求,都要新建一次 TCP 连接(三次握手),而且是串行请求,做了无谓的 TCP 连接建立和断开,增加了通信开销。
为了解决上述 TCP 连接问题,HTTP/1.1 提出了长连接的通信方式,也叫持久连接。这种方式的好处在于减少了 TCP 连接的重复建立和断开所造成的额外开销,减轻了服务器端的负载。
持久连接的特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。
当然,如果某个 HTTP 长连接超过一定时间没有任何数据交互,服务端就会主动断开这个连接。
2. 管道网络传输
HTTP/1.1 采用了长连接的方式,这使得管道(pipeline)网络传输成为了可能。
即可在同一个 TCP 连接里面,客户端可以发起多个请求,只要第一个请求发出去了,不必等其回来,就可以发第二个请求出去,可以减少整体的响应时间。
举例来说,客户端需要请求两个资源。以前的做法是,在同一个 TCP 连接里面,先发送 A 请求,然后等待服务器做出回应,收到后再发出 B 请求。那么,管道机制则是允许浏览器同时发出 A 请求和 B 请求,如下图:
但是服务器必须按照接收请求的顺序发送对这些管道化请求的响应。
如果服务端在处理 A 请求时耗时比较长,那么后续的请求的处理都会被阻塞住,这称为「队头堵塞」。
所以,HTTP/1.1 管道解决了请求的队头阻塞,但是没有解决响应的队头阻塞。
实际上 HTTP/1.1 管道化技术不是默认开启,而且浏览器基本都没有支持,所以后面所有文章讨论 HTTP/1.1 都是建立在没有使用管道化的前提。
3. 队头阻塞
「请求 - 应答」的模式会造成 HTTP 的性能问题。为什么呢?
因为当顺序发送的请求序列中的一个请求因为某种原因被阻塞时,在后面排队的所有请求也一同被阻塞了,会招致客户端一直请求不到数据,这也就是「队头阻塞」,好比上班的路上塞车。
总之 HTTP/1.1 的性能一般般,后续的 HTTP/2 和 HTTP/3 就是在优化 HTTP 的性能。
2、HTTP 与 HTTPS
2.1 HTTP 与 HTTPS 有哪些区别?
- HTTP 是超文本传输协议,信息是明文传输,存在安全风险的问题。HTTPS 则解决 HTTP 不安全的缺陷,在 TCP 和 HTTP 网络层之间加入了 SSL/TLS 安全协议,使得报文能够加密传输。
- HTTP 连接建立相对简单, TCP 三次握手之后便可进行 HTTP 的报文传输。而 HTTPS 在 TCP 三次握手之后,还需进行 SSL/TLS 的握手过程,才可进入加密报文传输。
- 两者的默认端口不一样,HTTP 默认端口号是 80,HTTPS 默认端口号是 443。
- HTTPS 协议需要向 CA(证书权威机构)申请数字证书,来保证服务器的身份是可信的。
2.2 HTTPS 解决了 HTTP 的哪些问题?
HTTP 由于是明文传输,所以安全上存在以下三个风险:
- 窃听风险,比如通信链路上可以获取通信内容,用户号容易没。
- 篡改风险,比如强制植入垃圾广告,视觉污染,用户眼容易瞎。
- 冒充风险,比如冒充淘宝网站,用户钱容易没。
HTTPS 在 HTTP 与 TCP 层之间加入了 SSL/TLS
协议,可以很好的解决了上述的风险:
- 信息加密:交互信息无法被窃取,但你的号会因为「自身忘记」账号而没。
- 校验机制:无法篡改通信内容,篡改了就不能正常显示,但百度「竞价排名」依然可以搜索垃圾广告。
- 身份证书:证明淘宝是真的淘宝网,但你的钱还是会因为「剁手」而没。
可见,只要自身不做「恶」,SSL/TLS 协议是能保证通信是安全的。
HTTPS 是如何解决上面的三个风险的?
- 混合加密的方式实现信息的机密性,解决了窃听的风险。
- 摘要算法的方式来实现完整性,它能够为数据生成独一无二的「指纹」,指纹用于校验数据的完整性,解决了篡改的风险。
- 将服务器公钥放入到数字证书中,解决了冒充的风险。
1. 混合加密
通过混合加密的方式可以保证信息的机密性,解决了窃听的风险。
HTTPS 采用的是对称加密和非对称加密结合的「混合加密」方式:
- 在通信建立前采用非对称加密的方式交换「会话秘钥」,后续就不再使用非对称加密。
- 在通信过程中全部使用对称加密的「会话秘钥」的方式加密明文数据。
采用「混合加密」的方式的原因:
- 对称加密只使用一个密钥,运算速度快,密钥必须保密,无法做到安全的密钥交换。
- 非对称加密使用两个密钥:公钥和私钥,公钥可以任意分发而私钥保密,解决了密钥交换问题但速度慢。
2. 摘要算法 + 数字签名
为了保证传输的内容不被篡改,我们需要对内容计算出一个「指纹」,然后同内容一起传输给对方。
对方收到后,先是对内容也计算出一个「指纹」,然后跟发送方发送的「指纹」做一个比较,如果「指纹」相同,说明内容没有被篡改,否则就可以判断出内容被篡改了。
那么,在计算机里会用摘要算法(哈希函数)来计算出内容的哈希值,也就是内容的「指纹」,这个哈希值是唯一的,且无法通过哈希值推导出内容。
通过哈希算法可以确保内容不会被篡改,但是并不能保证「内容 + 哈希值」不会被中间人替换,因为这里缺少对客户端收到的消息是否来源于服务端的证明。
举个例子,你想向老师请假,一般来说是要求由家长写一份请假理由并签名,老师才能允许你请假。
但是你有模仿你爸爸字迹的能力,你用你爸爸的字迹写了一份请假理由然后签上你爸爸的名字,老师一看到这个请假条,查看字迹和签名,就误以为是你爸爸写的,就会允许你请假。
那作为老师,要如何避免这种情况发生呢?现实生活中的,可以通过电话或视频来确认是否是由父母发出的请假,但是计算机里可没有这种操作。
那为了避免这种情况,计算机里会用非对称加密算法来解决,共有两个密钥:
- 一个是公钥,这个是可以公开给所有人的;
- 一个是私钥,这个必须由本人管理,不可泄露。
这两个密钥可以双向加解密的,比如可以用公钥加密内容,然后用私钥解密,也可以用私钥加密内容,公钥解密内容。
流程的不同,意味着目的也不相同:
- 公钥加密,私钥解密。这个目的是为了保证内容传输的安全,因为被公钥加密的内容,其他人是无法解密的,只有持有私钥的人,才能解密出实际的内容;
- 私钥加密,公钥解密。这个目的是为了保证消息不会被冒充,因为私钥是不可泄露的,如果公钥能正常解密出私钥加密的内容,就能证明这个消息是来源于持有私钥身份的人发送的。
一般我们不会用非对称加密来加密实际的传输内容,因为非对称加密的计算比较耗费性能的。
所以非对称加密的用途主要在于通过「私钥加密,公钥解密」的方式,来确认消息的身份,我们常说的数字签名算法,就是用的是这种方式,不过私钥加密内容不是内容本身,而是对内容的哈希值加密。
私钥是由服务端保管,然后服务端会向客户端颁发对应的公钥。如果客户端收到的信息,能被公钥解密,就说明该消息是由服务器发送的。
引入了数字签名算法后,你就无法模仿你爸爸的字迹来请假了,你爸爸手上持有着私钥,你老师持有着公钥。
这样只有用你爸爸手上的私钥才对请假条进行「签名」,老师通过公钥看能不能解出这个「签名」,如果能解出并且确认内容的完整性,就能证明是由你爸爸发起的请假条,这样老师才允许你请假,否则老师就不认。
3. 数字证书
前面我们知道:
- 可以通过哈希算法来保证消息的完整性;
- 可以通过数字签名来保证消息的来源可靠性(能确认消息是由持有私钥的一方发送的);
但是这还远远不够,还缺少身份验证的环节,万一公钥是被伪造的呢?
还是拿请假的例子,虽然你爸爸持有私钥,老师通过是否能用公钥解密来确认这个请假条是不是来源你父亲的。
但是我们还可以自己伪造出一对公私钥啊!
你找了个夜晚,偷偷把老师桌面上和你爸爸配对的公钥,换成了你的公钥,那么下次你在请假的时候,你继续模仿你爸爸的字迹写了个请假条,然后用你的私钥做个了「数字签名」。
但是老师并不知道自己的公钥被你替换过了,所以他还是按照往常一样用公钥解密,由于这个公钥和你的私钥是配对的,老师当然能用这个被替换的公钥解密出来,并且确认了内容的完整性,于是老师就会以为是你父亲写的请假条,又允许你请假了。
好家伙,为了一个请假,真的是斗智斗勇。
后面你的老师和父亲发现了你伪造公私钥的事情后,决定重新商量一个对策来应对你这个臭家伙。
正所谓魔高一丈,道高一尺。
既然伪造公私钥那么随意,所以你爸把他的公钥注册到警察局,警察局用他们自己的私钥对你父亲的公钥做了个数字签名,然后把你爸爸的「个人信息 + 公钥 + 数字签名」打包成一个数字证书,也就是说这个数字证书包含你爸爸的公钥。
这样,你爸爸如果因为家里确实有事要向老师帮你请假的时候,不仅会用自己的私钥对内容进行签名,还会把数字证书给到老师。
老师拿到了数字证书后,首先会去警察局验证这个数字证书是否合法,因为数字证书里有警察局的数字签名,警察局要验证证书合法性的时候,用自己的公钥解密,如果能解密成功,就说明这个数字证书是在警察局注册过的,就认为该数字证书是合法的,然后就会把数字证书里头的公钥(你爸爸的)给到老师。
由于通过警察局验证了数字证书是合法的,那么就能证明这个公钥就是你父亲的,于是老师就可以安心的用这个公钥解密出请假条,如果能解密出,就证明是你爸爸写的请假条。
正是通过了一个权威的机构来证明你爸爸的身份,所以你的伪造公私钥这个小伎俩就没用了。
在计算机里,这个权威的机构就是 CA (数字证书认证机构),将服务器公钥放在数字证书(由数字证书认证机构颁发)中,只要证书是可信的,公钥就是可信的。
数字证书的工作流程,我也画了一张图,方便大家理解:
通过数字证书的方式保证服务器公钥的身份,解决冒充的风险。
2.3 HTTPS 是如何建立连接的?其间交互了什么?
SSL/TLS 协议基本流程:
- 客户端向服务器索要并验证服务器的公钥。
- 双方协商生产「会话秘钥」。
- 双方采用「会话秘钥」进行加密通信。
前两步也就是 SSL/TLS 的建立过程,也就是 TLS 握手阶段。
TLS 的「握手阶段」涉及四次通信,使用不同的密钥交换算法,TLS 握手流程也会不一样的,现在常用的密钥交换算法有两种:RSA 算法 (opens new window)和 ECDHE 算法 (opens new window)。
基于 RSA 算法的 TLS 握手过程比较容易理解,所以这里先用这个给大家展示 TLS 握手过程,如下图:
TLS 协议建立的详细流程:
1. ClientHello
首先,由客户端向服务器发起加密通信请求,也就是 ClientHello
请求。
在这一步,客户端主要向服务器发送以下信息:
(1)客户端支持的 TLS 协议版本,如 TLS 1.2 版本。
(2)客户端生产的随机数(Client Random
),后面用于生成「会话秘钥」条件之一。
(3)客户端支持的密码套件列表,如 RSA 加密算法。
2. SeverHello
服务器收到客户端请求后,向客户端发出响应,也就是 SeverHello
。服务器回应的内容有如下内容:
(1)确认 TLS 协议版本,如果浏览器不支持,则关闭加密通信。
(2)服务器生产的随机数(Server Random
),也是后面用于生产「会话秘钥」条件之一。
(3)确认的密码套件列表,如 RSA 加密算法。
(4)服务器的数字证书。
3.客户端回应
客户端收到服务器的回应之后,首先通过浏览器或者操作系统中的 CA 公钥,确认服务器的数字证书的真实性。
如果证书没有问题,客户端会从数字证书中取出服务器的公钥,然后使用它加密报文,向服务器发送如下信息:
(1)一个随机数(pre-master key
)。该随机数会被服务器公钥加密。
(2)加密通信算法改变通知,表示随后的信息都将用「会话秘钥」加密通信。
(3)客户端握手结束通知,表示客户端的握手阶段已经结束。这一项同时把之前所有内容的发生的数据做个摘要,用来供服务端校验。
上面第一项的随机数是整个握手阶段的第三个随机数,会发给服务端,所以这个随机数客户端和服务端都是一样的。
服务器和客户端有了这三个随机数(Client Random、Server Random、pre-master key),接着就用双方协商的加密算法,各自生成本次通信的「会话秘钥」。
4. 服务器的最后回应
服务器收到客户端的第三个随机数(pre-master key
)之后,通过协商的加密算法,计算出本次通信的「会话秘钥」。
然后,向客户端发送最后的信息:
(1)加密通信算法改变通知,表示随后的信息都将用「会话秘钥」加密通信。
(2)服务器握手结束通知,表示服务器的握手阶段已经结束。这一项同时把之前所有内容的发生的数据做个摘要,用来供客户端校验。
至此,整个 TLS 的握手阶段全部结束。接下来,客户端与服务器进入加密通信,就完全是使用普通的 HTTP 协议,只不过用「会话秘钥」加密内容。
不过,基于 RSA 算法的 HTTPS 存在「前向安全」的问题:如果服务端的私钥泄漏了,过去被第三方截获的所有 TLS 通讯密文都会被破解。
为了解决这个问题,后面就出现了 ECDHE 密钥协商算法,我们现在大多数网站使用的正是 ECDHE 密钥协商算法。
客户端校验数字证书的流程是怎样的?
接下来,详细说一下实际中数字证书签发和验证流程。
如下图图所示,为数字证书签发和验证流程:
CA 签发证书的过程,如上图左边部分:
- 首先 CA 会把持有者的公钥、用途、颁发者、有效时间等信息打成一个包,然后对这些信息进行 Hash 计算,得到一个 Hash 值;
- 然后 CA 会使用自己的私钥将该 Hash 值加密,生成 Certificate Signature,也就是 CA 对证书做了签名;
- 最后将 Certificate Signature 添加在文件证书上,形成数字证书;
客户端校验服务端的数字证书的过程,如上图右边部分:
- 首先客户端会使用同样的 Hash 算法获取该证书的 Hash 值 H1;
- 通常浏览器和操作系统中集成了 CA 的公钥信息,浏览器收到证书后可以使用 CA 的公钥解密 Certificate Signature 内容,得到一个 Hash 值 H2 ;
- 最后比较 H1 和 H2,如果值相同,则为可信赖的证书,否则则认为证书不可信。
但事实上,证书的验证过程中还存在一个证书信任链的问题,因为我们向 CA 申请的证书一般不是根证书签发的,而是由中间证书签发的,比如百度的证书,从下图你可以看到,证书的层级有三级:
对于这种三级层级关系的证书的验证过程如下:
最开始客户端只信任根证书 GlobalSign Root CA 证书的,然后 “GlobalSign Root CA” 证书信任 “GlobalSign Organization Validation CA - SHA256 - G2” 证书,而 “GlobalSign Organization Validation CA - SHA256 - G2” 证书又信任 baidu.com 证书,于是客户端也信任 baidu.com 证书。
总括来说,由于用户信任 GlobalSign,所以由 GlobalSign 所担保的 baidu.com 可以被信任,另外由于用户信任操作系统或浏览器的软件商,所以由软件商预载了根证书的 GlobalSign 都可被信任。
这样的一层层地验证就构成了一条信任链路,整个证书信任链验证流程如下图所示:
最后一个问题,为什么需要证书链这么麻烦的流程?Root CA 为什么不直接颁发证书,而是要搞那么多中间层级呢?
这是为了确保根证书的绝对安全性,将根证书隔离地越严格越好,不然根证书如果失守了,那么整个信任链都会有问题。
2.4 HTTPS 的应用数据是如何保证完整性的?
TLS 在实现上分为握手协议和记录协议两层:
- TLS 握手协议就是我们前面说的 TLS 四次握手的过程,负责协商加密算法和生成对称密钥,后续用此密钥来保护应用程序数据(即 HTTP 数据);
- TLS 记录协议负责保护应用程序数据并验证其完整性和来源,所以对 HTTP 数据加密是使用记录协议;
TLS 记录协议主要负责消息(HTTP 数据)的压缩,加密及数据的认证,过程如下图:
具体过程如下:
- 首先,消息被分割成多个较短的片段,然后分别对每个片段进行压缩。
- 接下来,经过压缩的片段会被加上消息认证码(MAC 值,这个是通过哈希算法生成的),这是为了保证完整性,并进行数据的认证。通过附加消息认证码的 MAC 值,可以识别出篡改。与此同时,为了防止重放攻击,在计算消息认证码时,还加上了片段的编码。
- 再接下来,经过压缩的片段再加上消息认证码会一起通过对称密码进行加密。
- 最后,上述经过加密的数据再加上由数据类型、版本号、压缩后的长度组成的报头就是最终的报文数据。
记录协议完成后,最终的报文数据将传递到传输控制协议 (TCP) 层进行传输。
2.5 HTTPS 一定安全可靠吗?
之前有读者在字节面试的时候,被问到:HTTPS 一定安全可靠吗?
这个问题的场景是这样的:客户端通过浏览器向服务端发起 HTTPS 请求时,被「假基站」转发到了一个「中间人服务器」,于是客户端是和「中间人服务器」完成了 TLS 握手,然后这个「中间人服务器」再与真正的服务端完成 TLS 握手。
具体过程如下:
- 客户端向服务端发起 HTTPS 建立连接请求时,然后被「假基站」转发到了一个「中间人服务器」,接着中间人向服务端发起 HTTPS 建立连接请求,此时客户端与中间人进行 TLS 握手,中间人与服务端进行 TLS 握手;
- 在客户端与中间人进行 TLS 握手过程中,中间人会发送自己的公钥证书给客户端,客户端验证证书的真伪,然后从证书拿到公钥,并生成一个随机数,用公钥加密随机数发送给中间人,中间人使用私钥解密,得到随机数,此时双方都有随机数,然后通过算法生成对称加密密钥(A),后续客户端与中间人通信就用这个对称加密密钥来加密数据了。
- 在中间人与服务端进行 TLS 握手过程中,服务端会发送从 CA 机构签发的公钥证书给中间人,从证书拿到公钥,并生成一个随机数,用公钥加密随机数发送给服务端,服务端使用私钥解密,得到随机数,此时双方都有随机数,然后通过算法生成对称加密密钥(B),后续中间人与服务端通信就用这个对称加密密钥来加密数据了。
- 后续的通信过程中,中间人用对称加密密钥(A)解密客户端的 HTTPS 请求的数据,然后用对称加密密钥(B)加密 HTTPS 请求后,转发给服务端,接着服务端发送 HTTPS 响应数据给中间人,中间人用对称加密密钥(B)解密 HTTPS 响应数据,然后再用对称加密密钥(A)加密后,转发给客户端。
从客户端的角度看,其实并不知道网络中存在中间人服务器这个角色。那么中间人就可以解开浏览器发起的 HTTPS 请求里的数据,也可以解开服务端响应给浏览器的 HTTPS 响应数据。相当于,中间人能够 “偷看” 浏览器与服务端之间的 HTTPS 请求和响应的数据。
但是要发生这种场景是有前提的,前提是用户点击接受了中间人服务器的证书。
中间人服务器与客户端在 TLS 握手过程中,实际上发送了自己伪造的证书给浏览器,而这个伪造的证书是能被浏览器(客户端)识别出是非法的,于是就会提醒用户该证书存在问题。
如果用户执意点击「继续浏览此网站」,相当于用户接受了中间人伪造的证书,那么后续整个 HTTPS 通信都能被中间人监听了。
所以,这其实并不能说 HTTPS 不够安全,毕竟浏览器都已经提示证书有问题了,如果用户坚决要访问,那不能怪 HTTPS ,得怪自己手贱。
另外,如果你的电脑中毒了,被恶意导入了中间人的根证书,那么在验证中间人的证书的时候,由于你操作系统信任了中间人的根证书,那么等同于中间人的证书是合法的,这种情况下,浏览器是不会弹出证书存在问题的风险提醒的。
这其实也不关 HTTPS 的事情,是你电脑中毒了才导致 HTTPS 数据被中间人劫持的。
所以,HTTPS 协议本身到目前为止还是没有任何漏洞的,即使你成功进行中间人攻击,本质上是利用了客户端的漏洞(用户点击继续访问或者被恶意导入伪造的根证书),并不是 HTTPS 不够安全。
为什么抓包工具能截取 HTTPS 数据?
很多抓包工具 之所以可以明文看到 HTTPS 数据,工作原理与中间人一致的。
对于 HTTPS 连接来说,中间人要满足以下两点,才能实现真正的明文代理:
- 中间人,作为客户端与真实服务端建立连接这一步不会有问题,因为服务端不会校验客户端的身份;
- 中间人,作为服务端与真实客户端建立连接,这里会有客户端信任服务端的问题,也就是服务端必须有对应域名的私钥;
中间人要拿到私钥只能通过如下方式:
- 去网站服务端拿到私钥;
- 去CA处拿域名签发私钥;
- 自己签发证书,切要被浏览器信任;
不用解释,抓包工具只能使用第三种方式取得中间人的身份。
使用抓包工具进行 HTTPS 抓包的时候,需要在客户端安装 Fiddler 的根证书,这里实际上起认证中心(CA)的作用。
抓包工具能够抓包的关键是客户端会往系统受信任的根证书列表中导入抓包工具生成的证书,而这个证书会被浏览器信任,也就是抓包工具给自己创建了一个认证中心 CA,客户端拿着中间人签发的证书去中间人自己的 CA 去认证,当然认为这个证书是有效的。
如何避免被中间人抓取数据?
我们要保证自己电脑的安全,不要被病毒乘虚而入,而且也不要点击任何证书非法的网站,这样 HTTPS 数据就不会被中间人截取到了。
当然,我们还可以通过 HTTPS 双向认证来避免这种问题。
一般我们的 HTTPS 是单向认证,客户端只会验证了服务端的身份,但是服务端并不会验证客户端的身份。
如果用了双向认证方式,不仅客户端会验证服务端的身份,而且服务端也会验证客户端的身份。服务端一旦验证到请求自己的客户端为不可信任的,服务端就拒绝继续通信,客户端如果发现服务端为不可信任的,那么也中止通信。
3、HTTP/1.1、HTTP/2、HTTP/3 演变
3.1 HTTP/1.1 相比 HTTP/1.0 提高了什么性能?
HTTP/1.1 相比 HTTP/1.0 性能上的改进:
- 使用长连接的方式改善了 HTTP/1.0 短连接造成的性能开销。
- 支持管道(pipeline)网络传输,只要第一个请求发出去了,不必等其回来,就可以发第二个请求出去,可以减少整体的响应时间。
但 HTTP/1.1 还是有性能瓶颈:
- 请求 / 响应头部(Header)未经压缩就发送,首部信息越多延迟越大。只能压缩
Body
的部分; - 发送冗长的首部。每次互相发送相同的首部造成的浪费较多;
- 服务器是按请求的顺序响应的,如果服务器响应慢,会招致客户端一直请求不到数据,也就是队头阻塞;
- 没有请求优先级控制;
- 请求只能从客户端开始,服务器只能被动响应。
3.2 HTTP/2 做了什么优化?
HTTP/2 协议是基于 HTTPS 的,所以 HTTP/2 的安全性也是有保障的。
那 HTTP/2 相比 HTTP/1.1 性能上的改进:
- 头部压缩
- 二进制格式
- 并发传输
- 服务器主动推送资源
1. 头部压缩
HTTP/2 会压缩头(Header)如果你同时发出多个请求,他们的头是一样的或是相似的,那么,协议会帮你消除重复的部分。
这就是所谓的 HPACK
算法:在客户端和服务器同时维护一张头信息表,所有字段都会存入这个表,生成一个索引号,以后就不发送同样字段了,只发送索引号,这样就提高速度了。
2. 二进制格式
HTTP/2 不再像 HTTP/1.1 里的纯文本形式的报文,而是全面采用了二进制格式,头信息和数据体都是二进制,并且统称为帧(frame):头信息帧(Headers Frame)和数据帧(Data Frame) 。
这样虽然对人不友好,但是对计算机非常友好,因为计算机只懂二进制,那么收到报文后,无需再将明文的报文转成二进制,而是直接解析二进制报文,这增加了数据传输的效率。
比如状态码 200 ,在 HTTP/1.1 是用 '2''0''0' 三个字符来表示(二进制:00110010 00110000 00110000),共用了 3 个字节,如下图
在 HTTP/2 对于状态码 200 的二进制编码是 10001000,只用了 1 字节就能表示,相比于 HTTP/1.1 节省了 2 个字节,如下图:
Header: :status: 200 OK 的编码内容为:1000 1000,那么表达的含义是什么呢?
- 最前面的 1 标识该 Header 是静态表中已经存在的 KV。
- 在静态表里,“:status: 200 ok” 静态表编码是 8,二进制即是 1000。
因此,整体加起来就是 1000 1000。
3. 并发传输
我们都知道 HTTP/1.1 的实现是基于请求-响应模型的。同一个连接中,HTTP 完成一个事务(请求与响应),才能处理下一个事务,也就是说在发出请求等待响应的过程中,是没办法做其他事情的,如果响应迟迟不来,那么后续的请求是无法发送的,也造成了队头阻塞的问题。
而 HTTP/2 就很牛逼了,引出了 Stream 概念,多个 Stream 复用在一条 TCP 连接。
从上图可以看到,1 个 TCP 连接包含多个 Stream,Stream 里可以包含 1 个或多个 Message,Message 对应 HTTP/1 中的请求或响应,由 HTTP 头部和包体构成。Message 里包含一条或者多个 Frame,Frame 是 HTTP/2 最小单位,以二进制压缩格式存放 HTTP/1 中的内容(头部和包体)。
针对不同的 HTTP 请求用独一无二的 Stream ID 来区分,接收端可以通过 Stream ID 有序组装成 HTTP 消息,不同 Stream 的帧是可以乱序发送的,因此可以并发不同的 Stream ,也就是 HTTP/2 可以并行交错地发送请求和响应。
比如下图,服务端并行交错地发送了两个响应: Stream 1 和 Stream 3,这两个 Stream 都是跑在一个 TCP 连接上,客户端收到后,会根据相同的 Stream ID 有序组装成 HTTP 消息。
4、服务器推送
HTTP/2 还在一定程度上改善了传统的「请求 - 应答」工作模式,服务端不再是被动地响应,可以主动向客户端发送消息。
客户端和服务器双方都可以建立 Stream, Stream ID 也是有区别的,客户端建立的 Stream 必须是奇数号,而服务器建立的 Stream 必须是偶数号。
比如下图,Stream 1 是客户端向服务端请求的资源,属于客户端建立的 Stream,所以该 Stream 的 ID 是奇数(数字 1);Stream 2 和 4 都是服务端主动向客户端推送的资源,属于服务端建立的 Stream,所以这两个 Stream 的 ID 是偶数(数字 2 和 4)。
再比如,客户端通过 HTTP/1.1 请求从服务器那获取到了 HTML 文件,而 HTML 可能还需要依赖 CSS 来渲染页面,这时客户端还要再发起获取 CSS 文件的请求,需要两次消息往返,如下图左边部分:
如上图右边部分,在 HTTP/2 中,客户端在访问 HTML 时,服务器可以直接主动推送 CSS 文件,减少了消息传递的次数。
HTTP/2 有什么缺陷?
HTTP/2 通过 Stream 的并发能力,解决了 HTTP/1 队头阻塞的问题,看似很完美了,但是 HTTP/2 还是存在“队头阻塞”的问题,只不过问题不是在 HTTP 这一层面,而是在 TCP 这一层。
HTTP/2 是基于 TCP 协议来传输数据的,TCP 是字节流协议,TCP 层必须保证收到的字节数据是完整且连续的,这样内核才会将缓冲区里的数据返回给 HTTP 应用,那么当「前 1 个字节数据」没有到达时,后收到的字节数据只能存放在内核缓冲区里,只有等到这 1 个字节数据到达时,HTTP/2 应用层才能从内核中拿到数据,这就是 HTTP/2 队头阻塞问题。
举个例子,如下图:
图中发送方发送了很多个 packet,每个 packet 都有自己的序号,你可以认为是 TCP 的序列号,其中 packet 3 在网络中丢失了,即使 packet 4-6 被接收方收到后,由于内核中的 TCP 数据不是连续的,于是接收方的应用层就无法从内核中读取到,只有等到 packet 3 重传后,接收方的应用层才可以从内核中读取到数据,这就是 HTTP/2 的队头阻塞问题,是在 TCP 层面发生的。
所以,一旦发生了丢包现象,就会触发 TCP 的重传机制,这样在一个 TCP 连接中的所有的 HTTP 请求都必须等待这个丢了的包被重传回来。
3.3 HTTP/3 做了哪些优化?
前面我们知道了 HTTP/1.1 和 HTTP/2 都有队头阻塞的问题:
- HTTP/1.1 中的管道( pipeline)虽然解决了请求的队头阻塞,但是没有解决响应的队头阻塞,因为服务端需要按顺序响应收到的请求,如果服务端处理某个请求消耗的时间比较长,那么只能等响应完这个请求后, 才能处理下一个请求,这属于 HTTP 层队头阻塞。
- HTTP/2 虽然通过多个请求复用一个 TCP 连接解决了 HTTP 的队头阻塞 ,但是一旦发生丢包,就会阻塞住所有的 HTTP 请求,这属于 TCP 层队头阻塞。
HTTP/2 队头阻塞的问题是因为 TCP,所以 HTTP/3 把 HTTP 下层的 TCP 协议改成了 UDP!
UDP 发送是不管顺序,也不管丢包的,所以不会出现像 HTTP/2 队头阻塞的问题。大家都知道 UDP 是不可靠传输的,但基于 UDP 的 QUIC 协议 可以实现类似 TCP 的可靠性传输。
QUIC 有以下 3 个特点。
- 无队头阻塞
- 更快的连接建立
- 连接迁移
1、无队头阻塞
QUIC 协议也有类似 HTTP/2 Stream 与多路复用的概念,也是可以在同一条连接上并发传输多个 Stream,Stream 可以认为就是一条 HTTP 请求。
QUIC 有自己的一套机制可以保证传输的可靠性的。当某个流发生丢包时,只会阻塞这个流,其他流不会受到影响,因此不存在队头阻塞问题。这与 HTTP/2 不同,HTTP/2 只要某个流中的数据包丢失了,其他流也会因此受影响。
所以,QUIC 连接上的多个 Stream 之间并没有依赖,都是独立的,某个流发生丢包了,只会影响该流,其他流不受影响。
2、更快的连接建立
对于 HTTP/1 和 HTTP/2 协议,TCP 和 TLS 是分层的,分别属于内核实现的传输层、openssl 库实现的表示层,因此它们难以合并在一起,需要分批次来握手,先 TCP 握手,再 TLS 握手。
HTTP/3 在传输数据前虽然需要 QUIC 协议握手,但是这个握手过程只需要 1 RTT,握手的目的是为确认双方的「连接 ID」,连接迁移就是基于连接 ID 实现的。
但是 HTTP/3 的 QUIC 协议并不是与 TLS 分层,而是 QUIC 内部包含了 TLS,它在自己的帧会携带 TLS 里的“记录”,再加上 QUIC 使用的是 TLS/1.3,因此仅需 1 个 RTT 就可以「同时」完成建立连接与密钥协商,如下图:
甚至,在第二次连接的时候,应用数据包可以和 QUIC 握手信息(连接信息 + TLS 信息)一起发送,达到 0-RTT 的效果。
如下图右边部分,HTTP/3 当会话恢复时,有效负载数据与第一个数据包一起发送,可以做到 0-RTT(下图的右下角):
3、连接迁移
基于 TCP 传输协议的 HTTP 协议,由于是通过四元组(源 IP、源端口、目的 IP、目的端口)确定一条 TCP 连接。
那么当移动设备的网络从 4G 切换到 WIFI 时,意味着 IP 地址变化了,那么就必须要断开连接,然后重新建立连接。而建立连接的过程包含 TCP 三次握手和 TLS 四次握手的时延,以及 TCP 慢启动的减速过程,给用户的感觉就是网络突然卡顿了一下,因此连接的迁移成本是很高的。
而 QUIC 协议没有用四元组的方式来“绑定”连接,而是通过连接 ID 来标记通信的两个端点,客户端和服务器可以各自选择一组 ID 来标记自己,因此即使移动设备的网络变化后,导致 IP 地址变化了,只要仍保有上下文信息(比如连接 ID、TLS 密钥等),就可以“无缝”地复用原连接,消除重连的成本,没有丝毫卡顿感,达到了连接迁移的功能。
所以, QUIC 是一个在 UDP 之上的伪 TCP + TLS + HTTP/2 的多路复用的协议。
QUIC 是新协议,对于很多网络设备,根本不知道什么是 QUIC,只会当做 UDP,这样会出现新的问题,因为有的网络设备是会丢掉 UDP 包的,而 QUIC 是基于 UDP 实现的,那么如果网络设备无法识别这个是 QUIC 包,那么就会当作 UDP包,然后被丢弃。
评论记录:
回复评论: