Freeman's Blog

一个菜鸡心血来潮搭建的个人博客

0%

  • [] CAP理论彻底理清
  • [] BASE
  • [] 常见中间件在CAP理论中的定位:
    • [] MySQL Cluster
    • [] Redis Cluster
    • [] Redis Sentinel
    • [] Zookeeper
    • [] Eureka
    • [] Nacos
    • [] HDFS(?)
  • [] 常见算法(优缺点及其原因
    • 2PC协议/3PC协议
    • Paxos简单情况
    • ZAB
    • Raft

多机器的时钟同步

  • 分布式环境下多台机器的时间流动可能是不一样的,需要一种机制来进行时钟同步,在多个机器间确定事件的发生顺序
  • 全序:任意两个元素都有二元关系,可比较
  • 偏序:只有部分元素可以比较,无法确定所有元素的准确顺序
  • 在单节点系统上维持全序关系是容易的,但是在分布式系统上维持全序关系需要付出代价。通信的代价是昂贵的,时间的同步是非常困难并且脆弱的。

全局时间

  • 理想世界,每个节点使用同一个有完美准确度的时钟

本地时间

逻辑时间戳

  • “逻辑时间(logical time)”而非真实时间来记录因果关系:使用计数器和通信机制来判断事件发生的顺序
  • 无法得知有关时间间隔的信息,也无法使用超时机制

向量时间戳

Simple Lamport Clock

进程工作 -> 计数器自增
发送消息 -> 携带计数器值
接收消息 -> 合并计数器

  • 偏序关系:若timestamp(a) < timestamp(b),则a可能在b之前发生,也可能无法比较。因为一个Lamport clock只能携带1条时间线的信息,因此可能发生“明明是同时发生的事件,却被排序”的情况。

Vector Clock

FLP impossiblity result

  • 假设
    • 节点只会fail by crashing,不存在拜占庭式错误
    • 网络是可靠的
    • 其它异步系统模型所需的关于时间的假设
      • 各个进程行进的速率是独立的
      • 没有上界
      • 没有可用的时钟
  • 结论
    • 即使在消息不会丢失,最多一个进程fail且只会fail by crashing的条件下,异步系统模型中也不存在能够解决共识问题(consensus problem)的确定性算法。
    • 假设这样的算法存在,则总能设计出一种执行这种算法的方式,通过delaying message来让系统remain undecided(“bivalent”)
    • 这个结论的重要意义是它强调了在异步系统模型的假设下(no uppder bound),设计解决共识问题的算法必须考虑一种折衷:要么放弃safety,要么放弃liveness。

CAP theorem

三类属性

  • 一致性Consistency:所有节点在同一时间都可以访问到同样的数据(see the same data)
  • 可用性Availability:节点的failure不影响没有fail的节点继续工作
  • 分区容错性Partition tolerance:即使出现了因为网络问题(Partition - communication break)导致的消息丢失,系统还是能继续工作
  • 最多只有两种属性能够同时满足。(事实上,这并不是严格的“3选2”问题,某些时候属性之间存在tradeoff)

三系统类型

  • CA:保证一致性可用性, full strict quorum protocols,例如two-phace commit(2PC)
    • 一种强一致性模型,无法区分节点不可用和网络问题,只能通过停止接收写操作来避免数据分歧
    • quorum protocol:基于鸽巢原理的算法,保证数据冗余和最终一致性
      • 分布式系统中的每一份数据拷贝对象都被赋予一票。每一个读操作获得的票数必须大于最小读票数(read quorum)(Vr),每个写操作获得的票数必须大于最小写票数(write quorum)(Vw)才能读或者写。如果系统有V票(意味着一个数据对象有V份冗余拷贝),那么最小读写票数(quorum)应满足如下限制:
        • Vr + Vw > V
        • Vw > V/2
      • 第一条规则保证了一个数据不会被同时读写。当一个写操作请求过来的时候,它必须要获得Vw个冗余拷贝的许可。而剩下的数量是V-Vw 不够Vr,因此不能再有读请求过来了。同理,当读请求已经获得了Vr个冗余拷贝的许可时,写请求就无法获得许可了。
      • 第二条规则保证了数据的串行化修改。一份数据的冗余拷贝不可能同时被两个写请求修改。
  • CP:保证一致性和分区容错性,majority quorum protocols,例如Paxos,Raft
    • 也是一种强一致性模型,通过对网络中断的两段实行不对称的行为来避免数据分歧。节点多的一侧可用,节点少的一侧不可用(属于哪种情况需要使用算法进行区分)。
  • AP:使用冲突化解(conflit resolution)的协议

导出结论

  • 应该积极考虑Partition Tolerance:“分布式系统必须保证分区容错性,基本上只能选择AP原则和CP原则”,“You can’t sacrifice partition tolerance”
  • 因此分布式系统中并不存在”CA”类别,当network partition发生时,分布式系统要么保证一致性,要么保证可用性。(单机系统可以存在CA类别,比如RDBMS)
  • 存在network partitions时,强一致性和高可用性存在冲突
  • 在一般情况下,强一致性和性能存在冲突。要保证强一致性,各个节点要在每个操作上进行通信和协商,这会导致很高的latency

强一致性与弱一致性模型

强一致性模型

  • 严格一致性: 每个进程的写操作对所有其它节点上的进程是瞬时可见的:在分布式系统中是难以实现的。
  • 可线性化一致性:对严格一致性的弱化。在可线性一致性的情况下,所有的操作似乎都按照与全局实时操作顺序一致的顺序原子地执行
  • 顺序一致性:在顺序一致性的情况下,所有操作似乎都是按照某种顺序原子地执行的,这种顺序与在单个节点上看到的顺序一致,并且在所有节点上都是相等的。

弱一致性模型

  • client-centric一致性模型:入了客户端或会话机制的一致性模型,保证客户不会访问到一个数据项的旧版本,通常可以在客户端增加额外的cache来实现
  • 最终一致性模型:如果停止对数据的值进行修改,经过”一段待定义的时间”后所有数据的副本都会达成一致得到某个相同的值。在此之前,多个数据的副本可能在”某些为定义的行为中”存在不一致的现象。
    • 因果一致性:如果A更新完数据通知了B,则B之后对数据的访问和修改必须基于A更新后的值。对于C则没有这样的限制。
    • Read your writes:节点A更新后总是能访问到自己更新过的最新值。

BASE理论

  • Basically Available: 基本可用。出现故障时,可能出现响应时间变慢或者部分功能降级,但是仍能提供服务。
  • Soft State: 软状态,允许系统中的数据存在中间状态,而不是要求多个节点的数据副本总是一致的。
  • Eventually Consistent: 最终一致性,软状态存在时间期限。时间期限过后应该保证所有的副本保持数据一致性。

分布式事务

  • 一种分布式共识的特殊情况
  • DBMS相较于文件系统的意义在于
    • 基于关系代数,实现对数据的一种结构化存储和结构化查询
    • 事务的支持 -> ACID

2PC:尽量保证强(?)一致性

流程:

  • 一阶段:
  1. 客户端向协调者发起事务。事务的协调者节点首先向所有的参与者节点发送Prepare请求。
  2. 接收到Prepare请求后,每一个参与者节点都会写入日志并各自执行与事务有关的数据更新(此时会对数据上锁),但是暂时不提交事务,而是想协调者节点返回“完成”消息。
  3. 协调者接收到所有参与者返回的消息后,分布式事务进入第二阶段。
  • 二阶段正常流程:
    • 成功流程:
      1. 如果协调者节点接收到的都是“可以准备提交事务”的消息,那么协调者节点会向所有参与者发送事务提交命令。
      2. 参与者接收到事务提交命令,进行事务的提交并释放锁资源。本地事务完成提交后,会向当前协调者返回“完成”消息。
      3. 协调者接收到所有事务参与者的反馈,分布式事务完成。
    • 失败流程:
      1. 在第一阶段协调者接收到某个参与者反馈的失败消息,说明该节点的事务执行不成功,必须回滚。
      2. 协调者节点向所有的参与者发送abort请求。接收到abort请求的参与者需要在本地进行事务的回滚操作。
    • 无论是成功还是失败,此时才会告知客户端事务执行的结果。

存在的问题

  • 一阶段中如果协调者发出Prepare请求,有可能收不到所有参与者的响应。此时可以引入重试-超时机制。如果超时,则直接回滚。
  • 二阶段第一步如果协调者发出提交请求或回滚请求,但是没有收到所有参与者的响应。则需要不断重发提交请求,直到收到全部协调者的响应。否则可能导致参与者节点的数据不一致。
  • 各个节点在事务执行过程中会持续占用数据库资源(在事务相关的数据上加锁),性能较差。
  • 协调者的单点故障问题
    • 发送Prepare请求之后协调者单点故障、发送提交事务/回滚事务之前单点故障、发送…之后单点故障…

3PC

流程

  • 一阶段同样发出Prepare请求,但是不让参与者立即执行事务,而是只需要返回是否能够准备。
  • 二阶段进行预提交,多出这一状态可以让参与者确认其它参与者都回应了协调者,表示可以进行后续事务。该阶段参与者会进行写日志和修改数据,不提交事务。
  • 三阶段进行事务提交,协调者发出提交事务请求,参与者响应。
  • 同样,不管哪一个阶段有参与者返回失败都会宣布事务失败。
  • 参与者超时:等待提交事务请求超时,参与者会直接提交事务(?)。而等待与提交命令超时的情况,则没有任何影响。

复制(Replication)

复制的时机来分类

同步复制

  • 请求 -> 阻塞,将操作复制到所有节点 -> 所有节点返回 -> 向客户端返回
  • 返回客户端之前状态变化会被系统中所有节点所知。
  • 性能差,耐用性保证强。

异步复制

  • 请求 -> 立即响应,将更新数据保存在本机 -> 发送异步复制消息,联系其他节点 其他节点更新它们的副本
  • 性能高,对网络延迟的容忍度高,耐用性保证差,如果某个修改在给客户端发送响应后,在成功复制到从节点之前丢失了,那这个修改就永远丢失了。
  • 高可用性,但是单纯的lazy approach无法保证错误发生时能够读出之前写入的内容

按照副本一致性的强弱分类

维护单一副本(prevent divergence,single copy)

  • 尽可能地表现得像单一系统
  • 保证只有一个活动副本
  • 保证所有副本的一致性
  • 例子:异步主从复制、同步主从复制、2PC、Multi-Paxos,3PC,Paxos with leader election

multi-master systems(risk divergence)

常见的复制算法:主从复制

  • 所有操作都在一个master server上进行,master server将所有操作序列化并形成local log,local log将被发送到所有backup server上形成replica
  • 丢失修改的两种情况
    • 异步复制导致:没来得及同步完成时master宕机,新slave成为master,旧master即使恢复了也会变成slave,无法将修改复制到其他节点
    • 脑裂:master和slave无法互相联系,联系不上master的slave选出了backup master,新旧master同时接收修改,导致丢失修改
      • 怎么解决?要求master必须与一定量的slave保持联系才可以接受写,否则拒绝写

主从复制下的一致性协议:2PC(2 Phase Commit)

  • 中心化的协议,需要单一的master/leader/coordinator角色
  • 过程 vote -> decision -> commit
  • 是一个CA算法,不保证Partition Tolerance

共识算法

Quorum机制

最极端的情况:Write All Read One

  • 当Client请求向某副本写数据时(更新数据),只有当所有的副本都更新成功之后,这次写操作才算成功,否则视为失败。
  • 写操作很脆弱,因为只要有一个副本更新失败,此次写操作就视为失败了。
  • 读操作很简单,因为,所有的副本更新成功,才视为更新成功,从而保证所有的副本一致。这样,只需要读任何一个副本上的数据即可。假设有N个副本,N-1个都宕机了,剩下的那个副本仍能提供读服务;但是只要有一个副本宕机了,写服务就不会成功。

W R N

  • 更新操作在W个副本中更新成功后才认为此次更新操作成功。更新后的数据为“成功提交”。
  • 对于读操作,至少要读R个副本才能读到此次更新的数据
  • W + R > N
  • 在保证强一致性的情况下,W越大,写操作的可用性越差,读操作的可用性越好,反之亦然。

如何判断是否是最新的?

一致性哈希

传统哈希方法的局限性

  • 通过哈希的方式确定某个数据或某个请求应该落到哪个节点。
  • 传统哈希方法在节点增加或减少的时候需要进行重哈希,这会导致大量key和节点的映射关系发生变化。 -> 如果是缓存的情况,此时会发生大量的缓存失效,造成缓存雪崩。

一致性哈希算法

特点

  • 保证了增加或减少服务器时让尽可能少的映射发生改变

原理

  • 一致性哈希环:分布范围是[0, 2^32 - 1]
  • 对象和服务器放置在同一个哈希环上。
  • 对于每个对象,在哈希环上顺时针查找举例这个对象的哈希值最近的服务器。将这一服务器确定为该对象所属的服务器。
  • 对于服务器增加的情况,只有在哈希环上位于新增的服务器和该服务器的上一台服务器之间的对象需要进行重新分配。
  • 对于服务器减少的情况,只有原本分配到该台服务器上的对象需要进行重新分配,其它节点不会受影响。
  • 虚拟节点:将每台物理服务器虚拟为一组虚拟服务器,将虚拟服务器配置到哈希环上,然后维护虚拟服务器到物理服务器的映射。

雪崩问题

  • 如果哈希环上的一个服务器故障,原本属于该服务器的请求或数据访问就会全部转移到哈希环上的下一个服务器,这会造成下一个服务器的负载瞬间上升,有可能导致下一个服务器故障。
  • 解决:在下一个服务器之前增加虚拟节点,分担下一个服务器的压力。

  • OS里进程持有的”资源”具体有哪些?
  • 进程切换需要完成哪些工作?(为什么说进程切换开销大?
  • 线程切换需要完成哪些工作?

操作系统基础概念

  • 管理计算机软硬件资源的程序
  • 屏蔽了底层硬件的复杂性,对裸机进行了扩展
  • 操作系统内核是OS的核心部分,负责进程调度(应用程序管理),内存管理,外部设备(硬件设备)管理,文件系统管理

系统调用

  • 用户态:用户态运行的进程可以直接读取用户程序的数据。
  • 内核态:内核态运行的进程可以不受限制地访问计算机的任何资源。
  • 用户的程序中凡是请求系统资源的操作都需要通过系统调用的方式向操作系统提出服务请求,由操作系统代为完成这些操作。

栈

  • 用途:记录当前任务的函数调用关系以便维护函数返回的跳转,保存当前任务的执行状态以完成任务切换。

用户态栈

  • 进程栈
  • 线程栈

内核态栈

  • 进程内核栈:进程通过系统调用陷入内核之后,有一个单独内核空间的栈,用以支持内核函数的调用。
    • 在内核态中可能会发生进程切换(例如开始IO)。不同线程可以同时进入内核态,因此要为不同的进程维护不同的内核栈完成来为不同的进程维护调用关系。
  • 中断栈:?

进程与线程

进程与线程的区别

  • 进程:是程序的一次运行,是对运行中的程序的描述。
  • 线程:也是对运行中的程序的描述,但是粒度小于进程。
  • 进程是资源分配的最小单位,线程是CPU执行时间分配的最小单位。
  • 同一进程下的多个线程可以共享进程的资源。
  • 资源:地址空间,快表,文件描述符(fd)

进程持有的资源究竟是什么

  • 进程映像:进程执行的上下文环境。是序列化的一个进程运行状态,主要用于进程调度和恢复。
    • 进程控制块(PCB)
      • 进程标识符
      • CPU通用寄存器的内容,例如PSW、PC
      • 进程调度的状态(ready、suspended、…)、调度优先级信息、进程等待的事件
      • 进程结构信息:子进程标识符…
      • 内存管理信息:页表、段表…
      • I/O状态信息:被分配的设备、I/O缓冲区地址…
    • 进程执行的程序(指令)
    • 进程执行时所用的数据
    • 进程栈:用户栈和内核栈
  • 内存区域(虚拟内存空间):包括了可执行代码、数据段、调用栈、堆内存
  • 被分配给进程的资源描述符,例如文件描述符fd
  • 处理器状态(处理器执行上下文),例如寄存器的内容

进程控制块与线程控制块?(Thread Control Block, TCB)

  • 线程控制块有与PCB相似的字段:
    • 寄存器值
    • 栈指针:记录调用该函数的上一个函数的返回地址。
    • 程序计数器
    • 调度状态
  • 线程控制块(可能)特有的内容
    • 线程标识符(ID)
    • 指向所属进程(控制块)的指针(??)

进程切换(上下文切换)的开销

  • 切换虚拟地址空间(加载下一个线程的页表):
    • 有寄存器保存页表的起始地址和页表的大小,进程切换时需要完成这部分内容的切换。
  • 切换内核栈
  • 切换硬件上下文。最显著的开销是保存寄存器中的内容

线程切换的开销

  • 不需要切换虚拟地址空间,其余与线程一致。

为什么进程切换比线程切换的开销大

  • 进程切换涉及到虚拟地址空间的切换,而线程切换不涉及这个切换,因为同一进程的不同线程使用相同的虚拟地址空间和地址映射规则。将虚拟地址转换为物理地址需要查找页表,而到内存中查找页表是较慢的过程,因此通常使用快表来加速页表查询。当进行进程切换后页表也要切换,此时快表的内容就会失效,地址转换就会变慢。

进程的7状态模型

创建、就绪、运行、阻塞、就绪挂起、阻塞挂起、结束

进程间通信方式

  • 各个进程有不同的地址空间,一个进程的内存空间对于另一个内存而言是无法访问的。因此进程间要交换数据需要通过内核,进程1将数据从用户空间拷贝到内核空间,然后进程2再从内核空间将数据读取走。

管道/匿名管道

  • 管道中数据只能向一个方向流动,需要双方交换数据时需要建立两个管道。
  • 只能用于父子进程或者兄弟进程之间进行通信
  • 单独构成一种独立的文件系统。管道对于管道两端的进程而言是一个文件,但它并不是普通的文件,不属于某种文件系统,只存在于内存中。
  • 本质是一个内核缓冲区,进程以FIFO的方式在这个缓冲区中存取数据。
  • 缓冲区的大小是有限的,需要进行生产/消费同步
  • 传送的是无格式字节流,通信双方需要约定

具名管道

  • 与匿名管道相比,提供了一个路径名与之关联,以具名管道文件的形式存在于系统中,因此不相关的进程只要可以访问该路径就能通过有名管道相互通信。
  • 严格遵循FIFO。
  • 有名管道的名字存放于文件系统,内容存放在内存中。

信号

  • Linux系统中进程通信的机制,信号可以在任何时候发给某一进程而不需要知道其状态。(相比之下,管道在创建/发送的时候可能需要对方进程的存在,否则就会阻塞)
  • 如果该进程当前并未处于执行状态,则该信号就有内核保存起来,直到该进程回复执行并传递给它为止。
  • 如果一个信号被进程设置为阻塞,则该信号的传递被延迟,直到其阻塞被取消是才被传递给进程。
  • 是在软件层次上对中断的一种模拟,是一种异步通信方式。

消息队列

  • 消息的链表,存在于内存中,由消息队列标识符标识。
  • 允许一个或多个进程向他写入或读取消息
  • 另外与管道不同的是,消息队列在某个进程往一个队列写入消息之前,并不需要另外某个进程在该队列上等待消息的到达。
  • 消息队列可以随机查询,可以按消息的类型读取

共享内存

  • 使得多个进程可以可以直接读写同一块内存空间,是最快的可用IPC形式。是针对其他通信机制运行效率较低而设计的。
  • 为了在多个进程间交换信息,内核专门留出了一块内存区,可以由需要访问的进程将其映射到自己的私有地址空间。进程就可以直接读写这一块内存而不需要进行数据的拷贝,从而大大提高效率。
  • 需要同步机制(如信号量)达到进程间同步和互斥

信号量

  • 信号量是一个计数器,用于多进程对共享数据的访问,信号量的意图在于进程间同步。
  • 对信号量值的测试和减1操作应该是原子的

Socket

  • socket是一种通信机制,使用这种机制,进程通信可以在单机上运行,也可以跨网络进行,即通过网络让不同计算机上的进程进行通信。

线程间通信方式

  • 用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。
  • 信号量:它允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访问此资源的最大线程数量。互斥量是一种特殊的信号量(最多允许一个线程访问资源)
  • volatile和synchronized
  • 事件(Event)、等待/通知操作:通过通知操作(wait/notify,await/signal)来保持线程同步。
  • 管道输入/输出流

进程调度算法

  • FCFS
  • SJF
  • 时间片轮转RR
  • 多级反馈队列:除了最低级的队列都使用FCFS,高优先级的队列中没有进程时才会选取低优先级队列中的进程,得到时间片并使用完毕的线程需要降到低一级的优先级队列。最低级的队列使用时间片轮转算法进行调度。
  • 优先级调度

内存管理

  • 内存的分配与回收、内存地址映射:将逻辑地址转换为物理地址。

内存管理的方式

  • 为每个进程分配单块连续的内存空间。-> 内存碎片
  • 页式内存管理:将主存分为大小相等且固定的页,按页为进程分配内存,内存空间可以是不连续的。减少了内存碎片,通过页表完成逻辑地址到物理地址的映射。
    • 逻辑地址:页号+业内位移
    • 页表:页号-块号
    • 页表寄存器:页表始地址、页表长度
  • 段式内存管理:将分配给进程的内存空间进行分段,每段赋予逻辑信息(主程序段、子程序段、数据段、(堆)栈段),即按程序本身的逻辑对程序所用的内存空间进行分段(便于内存共享,某些进程所用的一些内存空间可能内容是一样的,例如同一个程序多次运行产生的多个进程)。通过段表完成逻辑地址到物理地址的映射。
    • 逻辑地址:段号+段内位移
    • 段表:段始地址、段长度
    • 段表寄存器:段表始地址、段表长度
  • 段页式内存管理:先将主存进行分页,在此基础上定义段,每段包含若干页。
    • 等分内存:与页式存储管理对内存的分块一样
    • 作业或进程的地址空间与段式存储管理对作业地址空间的处理一样
    • 内存分配:以页为单位
    • 逻辑地址:段号、段内页号、页内位移 -> 物理地址:页框号、页内地址
      segmentPageMemory
    • 段表中的每一项都有自己的页表,记录了页表首地址和页表长度
    • 页表中记录对应的页号和页架号
    • 快表:段号、页号、页架号

页式内存管理中的快表

两个问题:

  • 地址转换要快
  • 地址空间大,要使用恰当的数据结构完成地址转换。
  • 快表:页表的一部分,放在cache中,命中时只需要一次访存即可完成内存访问。未命中时需要到内存中查询页表,至少需要两次才能完成内存访问。快表还可以利用程序的局部性原理,因此快表的性能很好。
  • 多级页表:避免把全部页表放进内存占用过多空间,不需要的页表可以置换到外存(硬盘)中。

页式内存管理和段式内存管理的区别和联系

  • 共同:
    • 都为了提高内存利用率,减少内存碎片
    • 都是离散存储的,进程使用的内存地址都可以不连续。
  • 区别:
    • 页的大小是固定的,由OS决定。段的大小取决于当前运行的程序。
    • 分页仅仅是为了满足OS更好地管理内存的需求。段通常是逻辑信息的单位,对用户程序进行分段可以满足用户分段装入/换出的需求。

逻辑地址和物理地址

  • 因为用户程序每次运行时被装入内存的位置有可能是不一样的,用户程序每次运行的进程可能都获得了不同的物理地址空间,因此用户程序声明地址时一般只使用统一的逻辑地址。物理地址就是真实物理内存的地址,是内存单元真正的地址。
  • 用户程序直接访问和修改真实内存地址可能会有意无意地破坏OS或者其它程序。
  • 使用统一的虚拟地址,用户编写程序时无需考虑程序被装入内存时的真实位置,不需要担心程序中编写的内存地址是否被其它程序所拥有,同时也不需要知道OS为进程分配的内存是否连续,内存管理的复杂性一定程度上可以通过使用逻辑地址对编写用户程序的程序员进行屏蔽。

虚拟内存

  • 内存与外存的速度与价格差距。
  • 通过 虚拟内存 可以让程序可以拥有超过系统物理内存大小的可用内存空间。另外,虚拟内存为每个进程提供了一个一致的、私有的地址空间,它让每个进程产生了一种自己在独享主存的错觉(每个进程拥有一片连续完整的内存空间)。这样会更加有效地管理内存并减少出错。
  • 理论支持:程序的局部性原理。
    • 时间局部性:如果程序中的某条指令一旦执行,不久以后该指令可能再次执行;如果某数据被访问过,不久以后该数据可能再次被访问。产生时间局部性的典型原因,是由于在程序中存在着大量的循环操作。
    • 空间局部性:空间局部性 :一旦程序访问了某个存储单元,在不久之后,其附近的存储单元也将被访问,即程序在一段时间内所访问的地址,可能集中在一定的范围之内,这是因为指令通常是顺序存放、顺序执行的,数据也一般是以向量、数组、表等形式簇聚存储的。
    • 基于局部性原理,在程序装入时,可以将程序的一部分装入内存,而将其他部分留在外存,就可以启动程序执行。由于外存往往比内存大很多,所以我们运行的软件的内存大小实际上是可以比计算机系统实际的内存大小大的。在程序执行过程中,当所访问的信息不在内存时,由操作系统将所需要的部分调入内存,然后继续执行程序。另一方面,操作系统将内存中暂时不使用的内容换到外存上,从而腾出空间存放将要调入内存的信息。这样,计算机好像为用户提供了一个比实际内存大的多的存储器——虚拟存储器。

虚拟内存技术的实现

  • 内存和外存(页表/段表、快表)
  • 缺页中断机构:如果需要执行的指令或需要的数据尚未在内存 -> 由处理器通知OS将页/段调入到内存,然后继续执行程序
  • 地址转换

请求分页存储管理

建立在分页管理之上,为了支持虚拟存储器功能而增加了请求调页功能和页面置换功能。请求分页是目前最常用的一种实现虚拟存储器的方法。请求分页存储管理系统中,在作业开始运行之前,仅装入当前要执行的部分页即可运行。假如在作业运行的过程中发现要访问的页面不在内存,则由处理器通知操作系统按照对应的页面置换算法将相应的页面调入到主存,同时操作系统也可以将暂时不用的页面置换到外存中。

请求分段存储管理

建立在分段存储管理之上,增加了请求调段功能、分段置换功能。请求分段储存管理方式就如同请求分页储存管理方式一样,在作业开始运行之前,仅装入当前要执行的部分段即可运行;在执行过程中,可使用请求调入中断动态装入要访问但又不在内存的程序段;当内存空间已满,而又需要装入新的段时,根据置换功能适当调出某个段,以便腾出空间而装入新的段。

请求段页式存储管理

页面置换算法

  • OPT:无法实现,衡量标准
  • FIFO
  • LRU
  • LFU:为每个页维护使用计数

CPU寻址

CPU需要将虚拟地址转换为物理地址才能访问到真正的物理内存。完成这一地址转换的硬件是CPU的内存管理单元(MMU)。

并发/多任务

进程与线程

  • Linux下(其它OS不知道)的内核级线程本质是轻量级进程,多个轻量级进程被OS进行调度的时候就当成进程进行调度,创建线程就像创建进程一样需要创建“进程控制块”之类的数据结构让OS感知其存在,不过在这个数据结构中可以标识这个轻量级进程所属的“组”(所属的进程)
    • 于是在同一个“组”中进行轻量级进程的切换的时候可以节省一些操作。
      • 内存寻址所需要的段表/页表/各种表应该是不同地址空间不一样的,每个进程的逻辑地址到物理地址应该有不同的映射规则。如果切换前后的轻量级进程属于同一个进程,那就不用进行页表切换了。至于快表中的内容在进程切换的时候是直接丢弃还是放进PCB之类的数据结构不得而知,但是在轻量级线程之间切换应该也不用对快表进行过多的操作了。
      • (除了地址空间和代码段,暂时想不出还有什么轻量级进程之间可以共享的资源,打开的文件描述符集合是可以在轻量级进程之间共享的吗?感觉应该可以,那么这些文件描述符需要进行互斥访问吗。)
    • 但是有些操作是省不了的
      • 栈内存区切换,但是由于同一进程内地址空间是一样的,改个指针应该没什么开销?
      • “运行上下文”的保存和切换,包括一些进程运行时放置在寄存器里的数据,最典型的是程序计数器
  • 对于让应用级线程和用户级线程进行一一对应的程序(HotSpot VM似乎就是这样的),在用户态创建一个线程需要进行系统调用让OS创建一个轻量级进程。进行线程切换等于让OS进行轻量级进程的切换。

协程

  • 协程的概念:在一个轻量级进程里创建多个用户级线程。要阻塞的操作(IO)尽量使用异步方式,不让整个轻量级进程被阻塞。在一个轻量级线程里维护协程共有的资源和协程私有的资源(可能存在的协程栈和协程计数器?)。
  • 更直观的理解:非抢占调度的用户级线程。在用户程序中,编程者更清楚何时应该发生运行上下文的切换(比如IO操作时),因此非抢占调度可以减少无意义的上下文切换次数(多个活跃线程互相抢占CPU时间)。
  • 主要的应用场景是IO操作密集的情况下。在Java中使用IO多路复用(NIO)一定程度上可以近似实现协程调度的效果。但是编写程序的用户需要自己维护程序运行的状态和上下文。如果语言层面上能够支持协程,可以以近似同步代码的方式编写包含(异步)IO操作的代码,代码的可读性更好,降低编写难度。
    • 对于一个有多步IO操作的业务逻辑,在其Handler中按照IO操作进行分割,并在Handler中保存状态。
    • 每当需要进行IO操作时,将当前执行状态保存,用状态标志变量表示当前业务逻辑的执行进度,将当前IO操作对应的事件连同当前Handler的实例注册到选择器中,然后当前worker线程即可退出。
    • 下一次选择器选中该IO操作对应的事件时,就可以从SelectionKey取出绑定过的Handler实例,而Handler实例中保存了执行状态,此时再将该Handler的处理方法提交到线程池中即可恢复业务的执行。
  • 用户程序需要维护协程调度器。
    • 要利用多核怎么办:多对多映射,创建多个轻量级进程,每个轻量级进程负责多个协程

Linux

文件系统

Inode

  • 保存文件元信息的数据结构:每一块的地址,文件所有者,创建时间,权限,大小
  • 索引可以是多级的,Inode中的地址存放的可能是次一级的Inode
  • Block:实际文件的内容。一个block只能包含一个文件的信息。

文件类型

  • 普通文件-
  • 目录文件d
  • 块设备文件b:随机读写,如磁盘
  • 字符设备文件c:字符流。/dev/null:所有内容被忽略
  • 管道文件p:一头流入另一头流出
  • 套接字s:socket

Some random shit

IO

  • 可以利用操作系统缓冲区。OS在内存的内核区域维护缓冲区,用户程序在进行IO操作的时候可以只将数据写入该缓冲区。然后再定期通过fsync等操作将缓冲区中的内容同步到硬盘上。
  • 也可以不利用缓冲区,在flag参数中设置O_DIRECT标志,让用户将内存中用户区域的数据直接写到外部设备。适用于用户程序自己维护缓冲区的情况。

Todos:

  • AQS
  • volatile
  • synchronized锁升级机制
  • monitor对象
  • ThreadLocal
  • 线程安全的懒汉式单例模式

虚假唤醒问题

  • await() / wait()需要放进循环中

线程池

  • 控制运行的线程数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超过了最大数量,超出数量的线程排队等候,等其它线程执行完毕,再从队列中取出任务来执行。

为什么要用

  • 线程复用,降低线程创建和销毁,降低资源消耗
  • 提高响应速度,任务到达时任务可以不需要等到线程创建就可以立即执行
  • 提高线程的可管理性,无限制地创建线程会消耗系统资源。使用线程池可以对线程进行统一分配和监控。

7大参数

  • corePoollSize
  • maximumPoolSize
  • keepAliveTime: 多余线程的存活时间
  • TimeUnit
  • workQueue
  • threadFactory
  • handler: 拒绝策略
    ThreadPool

工作顺序

工作队列满才会创建非核心线程,工作队列不满时不会将新任务提交给非核心线程。线程在一定时间(keepAliveTime)没接收到任务后就会被停止,最终线程池会收缩到corePoolSize的大小。
ThreadPoolWorking

4大拒绝策略

拒绝策略触发的时机:任务数大于corePoolSize时会将任务放入队列缓冲区,填满了缓冲区后会判断当前任务书是否大于maxPoolSize,如果小于会新建线程处理,大于时会触发拒绝策略。

  • CallerRunsPolicy(调用者运行):触发拒绝策略时只要线程池没有关闭就交给提交任务的当前线程处理。(不允许失败、并发量小的场景)
  • AbortPolicy(中止策略):触发拒绝策略时直接抛出拒绝执行的异常,打断当前的执行流程。
  • DiscardPolicy(丢弃策略):触发拒绝策略时悄悄丢弃最新提交的任务,不抛出任何异常。
  • DiscardOldestPolicy:抛弃队列中等待最久的任务,然后把当前任务加入队列中尝试再次提交当前任务。

synchronized关键字

锁的内存语义

  • 释放锁时,该线程对于的本地内存中的共享变量会刷新到主存中
  • 获取锁时,该线程的本地内存会被设置为无效,被monitor保护的临界区代码必须从主内存中读取共享变量。

synchronized的实现原理

什么是monitor

JVM基于进入和退出monitor对象来实现同步。同步代码块采用添加monitorenter和monitorexit实现,同步方法使用ACC_SYNCHRONIZED标记符实现。monitor对象存在于每个对象的对象头中。
线程执行monitorenter时,会尝试获取monitor的所有权。如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
线程执行monitorexit时,对monitor进行释放,此时线程必须是monitor的持有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

对象头

Java对象的内存布局:对象头、实例数据、对齐填充。对象头主要包括Mark Word标记字段和Class Pointer类型指针。

  • Class Pointer 是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
  • Mark Word 用于存储对象自身的运行时数据,比如哈希码、锁状态标识、GC年龄等信息。Mark Word里存储的数据会随着锁标志位的变化而变化。
    • 如果有线程需要取得对象锁,该线程需要保存一份对象的Mark Word的副本(用于恢复到无锁状态?)
    • 无锁(01):hashcode、GC分代年龄、是否是偏向锁(0)
    • 偏向锁(01): 偏向线程ID、偏向时间戳(epoch)、对象GC分代年龄、是否偏向锁(1)
    • 轻量级锁(00):指向栈中锁记录的指针
    • 重量级锁(10): 指向互斥量(重量级锁)的指针
    • 无锁状态会直接转到轻量级锁状态。可以通过-XX:+UseBiasedLocking控制是否启用偏向锁。
  • (Identity) HashCode(java.lang.Object.hashCode(),以懒加载形式计算Identity HashCode,调用该方法时才计算Identity HashCode)和GC分代年龄都是每个对象必须记录和维护的信息,如果该对象被线程获取了对象锁,Mark Word中就要保存和锁有关的信息,此时HashCode和GC分代年龄怎么保存?
    • 当一个对象已经计算过Identity HashCode, 该对象就无法进入偏向锁状态
    • 如果一个对象已经进入偏向锁状态,但是需要计算Identity HashCode,则它的偏向锁会被撤销,膨胀成重量级锁
    • 重量级锁中,ObjectMonitor中有字段可以保存下非加锁状态的Mark Word,也就可以保存该对象的Identity HashCode值。
    • 这样的优化是基于一个假定:只有很少一部分对象真的需要调用java.lang.Object.hashCode()来计算Identity HashCode。
      markword
  • 当前线程栈中存在Lock Record数据结构。
    lockrecord

锁的优化

  • 锁升级机制:无锁、偏向锁、轻量级锁、重量级锁
  • 锁的状态变化是单向的,不会降级,只会升级。

自旋锁

所谓自旋锁,就是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。
自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。
自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。

自适应自旋锁

自旋的次数不固定,由前一次在同一个锁上自旋的时间和锁的拥有者的状态来决定。线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

锁清除

JVM检测不到共享数据竞争时就会对这些同步锁进行锁清除。

锁粗化

锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。例如将循环内的加锁操作转移到循环外。

偏向锁

Question: 无锁状态怎么到偏向锁状态?

  • Ans:最初状态:ThreadID为空的可偏向状态,此时可以直接加偏向锁
  • 关闭偏向锁或解除偏向锁后:无锁状态,此时再次加锁会直接加轻量级锁
    在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。
  • 偏向锁阶段,偏向锁只会属于第一个取得它的线程。线程不会主动释放偏向锁。
  • 如果在偏向锁阶段,某个线程发现偏向锁的线程ID不是自己,就一定会进行锁升级。
    偏向锁是在单线程执行同步代码块时使用的机制,如果在多线程并发的环境下(即线程A尚未执行完同步代码块,线程B发起了申请锁的申请),则一定会转化为轻量级锁或者重量级锁。
    当一个线程访问同步块并获取锁时,会在对象头(Mark Word)和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程进入和退出同步块时不需要花费CAS操作来争夺锁资源,只需要检查是否为偏向锁、锁标识为以及ThreadID即可。
  1. 检测对象头的Mark Word字段判断是否为可偏向状态或已偏向状态
  2. 若为可偏向状态(锁状态为01,且当前Mark Word的Thread ID部分未被设置,即为空)(或者无锁状态?),则使用CAS尝试将Mark Word中的Thread ID设置为当前线程的ID。期望值:空,设置值:当前线程ID
  • 该步操作如果成功,则当前线程成功取得了偏向锁,可以执行同步代码块
  • 该步操作如果失败,说明已经有线程抢先取得了偏向锁,需要进行锁升级。
  1. 若为已偏向状态(锁状态为01,但当前Mark Word的Thread ID部分不为空),则判断线程ID是否为当前线程ID
  • 如果是,则可以执行同步代码块
  • 如果不是,说明该锁属于其他线程,需要判断当前是否能够进行重偏向。
    • 偏向锁时间戳epoch,代表了偏向锁的有效性。其所属的class信息中也保存一个epoch值cepoch
    • 达到全局安全点时,如果要对整个类的所有对象进行批量重偏向,则首先对class对象的cepoch进行增加操作,得到一个新的cepoch。
    • 然后扫描所有持有实例的线程栈,对被锁定的对象赋予新的cepoch值。
    • 如果一个对象的对象头中的epoch == cepoch,说明当前对象仍处于锁定状态。否则,如果epoch < cepoch,其它线程可以通过CAS对这个偏向锁进行竞争。
  • 如果能重偏向则通过CAS进行竞争,尝试将Mark Word中的Thread ID修改为自己的线程ID,否则需要进行锁升级。
  1. 偏向锁的升级:挂起持有锁的线程,首先进行偏向锁的撤销
  • 如果原来获取偏向锁的线程已经执行完了同步代码块,则原有的偏向锁无效。此时该对象会转换为不可偏向的无锁状态
  • 如果原本获取偏向锁的线程未执行完同步代码块,偏向锁依旧有效,此时对象会转换为被轻量级加锁的状态
  • 在当前线程的栈帧出创建Lock Record,将对象头的Mark Word复制到锁记录中(完成轻量级锁的加锁过程)。
    偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。
  • 只有一个线程一定可以获得偏向锁吗?
    • 不一定,首先要确定当前JVM参数打开了偏向锁
    • 其次,计算了Identity HashCode的对象无法进入偏向状态。

轻量级锁

当多个线程竞争偏向锁时就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

  1. 线程进入同步块时,如果同步对象锁状态为无锁状态(无锁和偏向锁都是01,但是无锁状态会将第三位置为0,表示当前对象不可偏向),虚拟机首先在当前线程的栈帧中建立一个名为Lock Record的空间,用于存储锁对象目前的Mark Word的拷贝。
  2. 将对象头中的Mark Word复制到Lock Record中。
  3. 复制成功后,虚拟机使用CAS操作尝试将对象Mark Word中的Lock Word(此时对象头Mark Word的内容)更新为指向当前当前线程Lock Record(线程栈帧中的空间)的指针,并将Lock Record里的owner指针指向对象Mark Word。
  4. 如果这个更新动作成功了,那么当前线程就拥有了该对象的锁。此时对象Mark Word的锁标志位被设置为00,表示对象处于轻量级锁定状态。
  5. 如果更新操作失败,JVM首先检查对象Mark Word中的Lock Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有此对象锁,可以进入同步块。否则进入自旋状态,如果自旋结束时仍未获得锁,轻量级锁就要升级为重量级锁,锁状态值变为10。
    对于轻量级锁,其性能提升的依据是 “对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。

重量级锁

使用监视器锁,其本质依赖操作系统提供的Mutex Lock,因此需要进行系统调用,即完成从用户态到内核态的转换,因此这个成本非常高。

Lock和Synchronized的区别

  • synchronized是关键字,由JVM提供支持,底层通过monitor对象来完成(monitorenter/monitorexit),wait/notify方法也依赖monitor对象,因此这两个方法也只能在synchronized代码块中才能正常调用。Lock是具体类,当然await和signal也需要持有锁的时候调用,否则会抛出IllegalMonitorStateException异常。
  • synchronized不需要手动释放锁,而Lock需要
  • synchronized不可中断,除非抛出异常或者正常运行完成
  • ReentrantLock可以被中断,synchronized在当前线程等待锁被阻塞时无法被中断。
    • 超时方法tryLock:可以立即返回,可以等待一个超时时间
    • lockInterruptibly(): 当前线程在等待过程中,其它线程对当前线程调用interrupt()可以中断当前线程,让当前线程优先响应中断异常`InterruptedExceptio
    • n`。
  • synchronized是非公平锁,Lock可以是公平的。
  • ReentrantLock可以绑定多个条件Condition,实现按照不同的需要分组唤醒线程,而synchronized只能随机唤醒一个线程或全部唤醒。

LockSupport

是什么?

  • 用于创建锁和其它同步类的基本线程阻塞原语
  • LockSupport为每个使用它的线程提供一个permit(类似于Semaphore),调用park方法时如果permit可用则立即返回并消耗这个permit,否则可能阻塞(类似于上限为1的信号量,重复unpark不会累积,最初调用park会直接阻塞,猜测初值应该为0)。调用unpark则可以释放permit。要保证对LockSupport的可靠使用,需要依靠volatile或原子变量来控制何时调用park或unpark。提供了阻塞和唤醒线程的功能。
  • park会在调用线程被中断(interrupted)的时候返回,也支持超时机制,也可能在任何时候没有任何原因地返回。因此一般在循环中调用park以重新检测条件。这可以被视为一种优化的忙等待机制。
  • 一般不直接使用而是用于构造高阶的同步机制。
  • 唤醒(unpark)可以在park之前进行,此时park不会被阻塞。

原理

  • 通过Unsafe类中的native方法实现
  • permit默认为0
  • 每次park都必须消耗一个permit(即使是连续调用),而每次调用unpark会将permit设置为1,但是上限为1,达到上限后不会累计。

AQS(AbstractQueuedSynchronizer)

是什么?

  • java.util.concurrent.locks.AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer
  • 用于构建锁和其它同步器组件,用户使用AQS来实现自定义同步器时只需要实现共享资源state的获取与释放方式即可。至于线程等待队列的维护,线程获取资源失败入队,线程被唤醒获取到资源最终出队等操作,AQS已经实现好了。AQS内置了FIFO队列来完成获取资源的线程的排队工作,并通过(原子的)int类型的变量来表示持有锁的状态。
  • ReentrantLock, CountDownLatch, ReentrantRewriteLock, Semaphore等类的实现都需要使用。
  • 屏蔽(封装)了同步状态管理、阻塞线程、排队等操作:如果共享资源被占用,就需要一定的阻塞-等待-唤醒机制来保证锁的分配。将暂时获取不到锁的线程加入队列中,这个队列就是AQS的抽象表现。请求资源的线程 -> 队列的结点。
  • 支持两种同步方式:共享式、独占式

实现

  • 虚拟双向链表实现的队列

  • 使用volatile的int类型成员变量state来表示同步状态。通过CAS完成对该状态的维护。

  • 基于模板方法模式,使用者继承该类需要重写指定的方法。然后将AQS组合在自定义同步组件的实现中,并调用其模板方法,模板方法会调用使用者重写的方法。

    • protected boolean tryAcquire(int arg): 独占式地获取同步状态(获取资源),成功返回true,否则返回false
    • protected boolean tryRelease(int arg): 独占式地释放同步状态(释放资源),等待中的其他线程此时有机会获取到同步状态
    • protected int tryAcquireShared(int arg): 共享式地获取同步状态,返回值大于等于0时成功,否则失败
    • protected boolean tryReleaseShared(int arg): 共享式地释放同步状态,成功true,失败false
    • protected boolean isHeldExclusively(): 是否在独占模式下被线程占用。
  • 在这些方法中用户可以使用AQS已经定义的对state的操作,例如compareAndSetState方法对state进行修改,使用getState来获取当前的state值。

  • 核心方法: acquireQueued

state

  • volatile的int类型成员变量。
    • EXCLUSIVE状态下: 0: 可用;>= 1: 占用中。
    • SHARED状态下: == 0:可用,但是没有剩余资源了;< 0:不可用;> 0:可用,并且还有剩余资源。
    • 这样的设计可以保证

AQS本身

  • 通过自旋等待
  • CLH队列的变种:双端队列
  • 对队列的原子操作:尾部入队,头部出队。当一个节点的前驱结点释放资源时,该节点会被通知。

Node节点

  • static final Node SHARED/static final Node EXCLUSIVE: 线程等待锁的模式:共享/独占
  • volatile int waitState成员变量:Node的等待状态
    • 0:初始化时的默认值
    • CANCELLED/1: 已经取消了对锁的请求
    • CONDITION/-2: 在等待队列中,等待唤醒
    • PROPAGATE/-3: SHARED状态下才会使用
    • SIGNAL/-1: 线程已经准备好了
  • volatile Node prev/volatile Node next: 前驱/后继节点
  • volatile Thread thread: 与当前节点关联的排队中的节点

核心方法

acquire

1
2
3
4
5
6
7
8
9
10
11
public final void acquire(int arg) {
// 1. 首先调用使用者重写的tryAcquire方法。如果返回true,说明获取同步状态成功,后面的逻辑不再执行。
// 如果返回false,说明获取同步状态失败,需要继续执行
// 2. 构造独占式同步节点,通过addWaiter方法将此节点添加到同步队列的尾部。此时可能有多个线程节点试图加入同步队列尾部,
// 需要保证线程安全。
// 3. 该节点在队列中尝试获取同步状态(acquireQueued),如果获取不到则阻塞节点线程(selfInterrput),直到被前驱结点唤醒
// 或者被中断
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
// 如果线程在等待资源的过程中被中断,它无法立即响应中断,只有在此时能够响应中断
selfInterrupt();
}

addWaiter方法:构造一个Node节点并添加到队列尾部

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);//构造结点
//指向尾结点tail
Node pred = tail;
// 如果尾结点不为空,CAS快速尝试在尾部添加,若CAS设置成功,返回;否则进入enq方法。
// 尾节点为空也需要进入enq方法
if (pred != null) {
node.prev = pred;
// CAS操作
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}

enq方法:如果队尾为空,通过CAS方法尝试创建一个节点作为头结点(和当前的为节点)。如果队列不为空,则通过CAS尝试将当前节点连接到尾节点。如果失败则一直自旋等待。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { //如果队列为空,创建结点,同时被head和tail引用
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {//cas设置尾结点,不成功就一直重试
t.next = node;
return t;
}
}
}
}

acquireQueued方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
final boolean acquireQueued(final Node node, int arg) {
// 当前线程是否成功获得锁
boolean failed = true;
try {
// 当前线程是否被阻塞过
boolean interrupted = false;
// 不停自旋
for (;;) {
// 找到当前结点的前驱结点
final Node p = node.predecessor();
// 如果前驱结点是头结点,才tryAcquire,其他结点是没有机会tryAcquire的。
// 因为有可能前驱节点唤醒了当前节点。在这种情况下,当前节点应该尝试进行资源获取。
if (p == head && tryAcquire(arg)) {
// 获取同步状态成功,将当前结点设置为头结点。
setHead(node);
p.next = null; // 方便GC
failed = false;
return interrupted;
}
// 如果没有获取到同步状态,通过shouldParkAfterFailedAcquire判断是否应该阻塞,parkAndCheckInterrupt用来阻塞线程
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}

shouldParkAfterFailedAcquire: 判断当前节点线程是否应该被阻塞,主要根据前驱节点(node.predecessor())的状态进行判断。

  • 如果前驱节点的状态为SIGNAL,说明前驱节点准备好要获得锁,当前线程可以安全地被LockSupport.park()
  • 如果前驱节点的状态为CANCELLED,说明前驱节点已经取消了对锁的获取,前驱节点已经是无效节点。需要在链表中从后往前遍历,找到一个非CANCELLED状态的节点,将当前线程的节点设置为它的后继节点。
  • 如果前驱节点为其它状态(CONDITION, PROPAGATE),则将前驱节点的状态通过CAS设置为SIGNAL。
  • parkAndCheckInterrupt:使用LockSupport.park(this)阻塞当前线程。并返回Thread.interrupted(),让外部方法判断当前线程是否需要进行中断响应。
    AQSAcquire

acquire方法的逻辑总结

  1. 首先调用用户实现的tryAcquire尝试获得同步状态,成功则直接返回,失败则继续
  2. 如果等待链表的头结点不为空,使用CAS方法构造一个节点加入同步队列中。如果头结点为空或CAS失败,则不停自旋重复这个过程直到成功。
  3. 加入队列后的节点线程进入自旋状态。如果前驱节点是头结点,则尝试获取同步状态。否则,判断当前节点是否可以阻塞。
  4. 如果前驱节点的状态为SIGNAL,则可以阻塞当前线程。其它情况下当前线程还不可以被阻塞,需要继续进行自旋。
  5. 如果前驱节点的状态为CANCELLED,从当前位置向前找到链表中的一个状态为SIGNAL的节点,将当前节点设为其后继节点。如果前驱节点的状态为其它状态,则将其状态设置为SIGNAL。完成这一设置之后,线程继续进行自旋。
  6. 当前线程在自选中获得了资源时,让当前线程对应的节点成为头节点。

release

线程释放同步状态的方法。

  1. 调用使用者重写的tryRelease方法,如果成功则调用unparkSuccessor唤醒其后继节点,如果失败则返回false
  2. unparkSuccessor首先会将当前节点的等待状态通过CAS设置为初始值0。如果当前节点的后继节点不为空且状态不为CANCELLED,调用LockSupport.unpark(node.next.thread)唤醒后继节点。否则,从尾向头寻找一个处于正常阻塞状态的节点(waitStatus <= 0),将其唤醒。

acquireShared

调用用户实现的tryAcquireShared来完成资源获取,根据返回值判断资源获取的操作是否成功
与acquire的不同:

  • status: 说明当前资源的占用情况
    • SHARED模式下,tryAcquireShared返回大于等于0的值即可说明资源获取成功。
  • AQS的共享锁本质上是允许不超过指定数量的多个线程同时执行。

怎么用

  • Lock接口的实现类:聚合一个队列同步器的子类(Sync extends AbstractQueuedSynchronizer)完成线程访问控制

案例 ReentrantLock

  • 尝试加锁 -> 加锁失败进入阻塞队列 -> 线程阻塞
  • 非公平加锁(lock()),对AQS的state进行CAS: compareAndSetState(0, 1)
    • 如果成功,则调用setExclusiveOwnerThread(thread),将当前线程设置为独占线程。
    • 否则调用acquire(int)准备进行同步。
  • tryAcquire(): 模板,由子类实现
    • 公平锁:先判断hasQueuedPredecessors()。
    • 如何实现可重入:
      • 让state作为当前的重入次数。
      • 当当前线程等于getExclusiveOwnerThread的结果时,可以判断是持有锁的线程重复获得锁。这个时候可以进行重入,让重入计数加1.

案例 ReentrantReadWriteLock

  • 获取读锁: tryAcquireShared,获取写锁:tryAcquired
  • state:高16位为读锁占用量,低16位为写锁占用量

volatile

  • 轻量级的同步机制

保证可见性

  • volatile保证了不同线程对共享变量操作的可见性。一个线程修改了volatile修饰的变量,当修改写回主内存时,另一个线程会立即看到最新的值。
  • 主内存和工作内存的一致性问题:写数据时通知其它CPU将该变量的缓存行设为无效状态,其它CPU需要读取时发现缓存失效,则需要从内存中重新读取。
    • 如何发现数据失效:嗅探总线上传播的数据检查当前缓存值是否过期。
  • volatile写的内存语义:对一个volatile变量进行写操作时,该线程对应的本地内存中的共享变量值会刷新到主内存,然后通知其它CPU缓存失效。
  • volatile读的内存语义:对一个volatile变量进行读操作时,该线程对应的本地内存会被置为无效,线程将会从主存中读取共享变量。
  • 内存语义的实现:内存屏障策略。

不保证原子性

  • 修改volatile变量需要将变量从主内存读取到线程的工作内存,在工作内存中修改变量值,然后将工作内存的变量值写回主内存。
  • 但是对单个volatile变量的读和赋值操作可以被视为有原子性。

禁止指令重排序

  • JMM规则下的问题:编译器/JIT/微处理器允许对每个线程内的代码进行重排序。
    • 单线程下的执行结果不能被改变:遵守as-if-serial语义
  • 而在每个线程内不存在数据依赖性的指令在全局的指令执行顺序中可能是存在数据依赖性的。
    • 举个例子
      1
      2
      3
      4
      5
      6
      7
      8
      9
      A = 0, B = 0
      Thread1: r2 = A -> B = 1
      Thread2: r1 = B -> A = 2

      执行顺序可能被重排为
      Thread1: B = 1 -> r2 = A
      Thread2: A = 2 -> r1 = B

      执行结果就可能变成r2 = 2, r1 = 1
      • 数据争用(data race):
        • 一个线程里有对一个变量的写操作
        • 另一个线程里有对同一个变量的读操作
        • 读写操作没有通过同步机制进行排序
  • volatile关键字实现了禁止指令重排序的优化,避免多线程环境下出现乱序执行的情况
  • 内存屏障:任何指令不能和内存屏障指令重排序
    • LoadLoad: 屏障前后的读操作不能重排序,屏障后的读操作执行前要保证屏障前的读操作读取完毕
    • StoreStore: 屏障后的写入操作执行前,要保证屏障前的写入操作对其它处理器可见
    • LoadStore:屏障后的数据写入操作被刷到内存前,要保证屏障前的读操作读取完毕
    • StoreLoad:屏障后的读取操作执行前,要保证屏障前的写入操作对其它处理器可见
  • volatile的实现:
    • volatile写:写指令之前插入StoreStore屏障,在写指令之后插入StoreLoad屏障
    • volatile读:读指令之前插入LoadLoad屏障,之后插入LoadStore屏障

线程安全的懒汉式单例模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Singleton {
// 如果不对instance字段加volatile修饰符会怎样?
// 对象构造并不是原子操作,它需要分配对象内存,调用构造器方法执行初始化,然后将对象引用赋值给变量。
// 但是上述三个步骤可能发生重排序,比如可以先将对象引用赋值给变量,然后调用构造器的构造方法进行初始化。
// 这样其它线程在检查instance是否为空时,能够判断instance是一个有指向的引用,因此判断instance != null
// 但是实际上对象并没有完成初始化,其它线程可能会拿到一个“半成品”
// volatile会制止该对象在初始化期间的指令重排序。
private volatile static Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
// 第一次检查,如果此时instance已经非空了,那单例肯定已经被创建,可以直接返回单例
if (instance == null) {
synchronized (Singleton.class) {
// 别的线程可能已经进入过一次synchronized代码块并对单例进行初始化,为了避免这种情况,我们需要进行二次检查
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

应用场景

  • 某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,比如boolean flag;或者作为触发器,实现轻量级同步。
  • volatile的读写操作不需要加锁,因此成本低。但是只能用作于属性。
  • volatile提供了可见性,任何一个线程对其的修改将立马对其他线程可见,volatile属性不会被线程缓存,始终从主存中读取。
  • volatile提供了happens-before保证,对volatile变量v的写入happens-before所有其他线程后续对v的读操作。

CAS(Compare and Swap)

  • 线程将工作内存中的内容写回主内存前先比较其期望值和主内存的真实值(比如,比较线程在读入时和当前主内存中变量的值是否相同),如果真实值和期望值相同,将新值写回主内存,否则表明该变量在当前线程读入工作内存后曾经被其他内存修改,不写回主内存。

Unsafe

  • Java无法直接访问底层系统,sun.misc.Unsafe相当于一个后门,可以直接直接操作特定内存的数据。
  • CAS是一条并发原语。比较是否为预期值的并决定是否更改的过程是原子的。
  • AtomicInteger.getAndIncrement(): 1. 先从主内存中直接读出变量值,2. 然后使用CAS判断内存中变量的当前值是否等于期望值(刚从主内存读出来的值),如果是,则将修改后的值写回主内存。注意,1是原子的,2是原子的,1+2整个操作并不是原子的。如果在2.中发现内存中的当前值不等于期望值,则**进行自旋等待(while一直循环)**。(这是一种乐观锁)

缺点?

  • 循环时间长,开销大。如果CAS失败会一直进行自旋,可能会对CPU带来较大的开销。
  • 只能保证一个共享变量的原子操作。如果需要对多个共享变量进行原子操作,必须加锁。
  • ABA问题:
    • 问题描述:
      • 线程A从主内存读取变量到工作内存中,此时变量值为A。读取完毕后线程A因为某些原因被挂起
      • 线程B从主内存读取同一个变量到工作内存中,变量值为A,通过CAS将主内存中的变量值修改为B
      • 线程B再次通过CAS将主内存中的变量值修改为B
      • 线程A恢复运行,希望将对变量的修改写入主内存,并且此时比较能够通过,线程A会认为变量并未经过修改
      • 虽然变量的值看上去并没有变化,但是此时数据的语义很可能已经发生变化。例如本来应该只发生一次的更改发生了多次。
    • 原子引用AtomicReference<V>: 让任意自定义类型实现类似AtomicInteger的功能
    • 如何规避ABA问题:新增版本号机制
      • 可以直接使用时间戳作为版本号:AtomicStampedReference<V>

线程同步工具

CountDownLatch

  • 一个同步辅助类,允许一个或多个线程等待另外一组线程完成工作。
  • 提供一个计数器,需要等待的线程会等待该计数器的值减小到0。提供线程安全的方法供其它线程调用countDown方法减少计数器的值。

实现原理

  • CountDownLatch内部包含了一个继承自AQS的Sync类实例。利用了AQS来实现共享锁。
  • 基本思路是在构造的时候将state设置为大于0的值count,相当于当前资源state被占用,此时调用await方法的线程尝试获取共享锁会失败。

await方法

  • 调用该方法时会尝试获取共享锁,当state == 0的时候能够成功获取到共享锁。在其他情况下,需要加入队列进行排队。

countDown方法

  • 通过CAS的方式对state进行赋值,让作为锁计数器的state变量减1。必须有count次方法调用才能让锁计数器减少到0。

CyclicBarrier

  • 一个同步辅助类,允许多个线程互相等待。
  • 主要借助ReentrantLock和Condition来实现。

线程安全集合

List

CopyOnWriteArrayList(写时复制)

  • 数据使用volatile的数组进行保存
  • 写时先获得锁,然后进行复制,在复制后的数组上进行写(并扩容),最后将原数组的引用指向新数组。

锁的类型

公平锁与非公平锁

  • 公平锁:FIFO(获取锁的顺序就是申请锁的顺序);非公平锁:获取锁的顺序不一定是申请锁的顺序,可能会造成优先级反转或者饥饿现象。
  • ReentrantLock默认是非公平锁,可以通过在构造函数中对fair参数传入true让实例成为公平锁。
    • 公平:FIFO
    • 非公平:直接尝试获得锁,如果尝试失败则才在队列中进行排队。有点:吞吐量大。synchronized是一种非公平锁。

可重入锁(递归锁)

  • 同一线程外层函数获得锁之后,内层递归函数仍然能获取该锁。线程可以进入任何一个它已经拥有的锁同步着的代码块。
  • 例如:对于同一个对象内的synchronized方法,当前线程已经进入了一个synchronized方法内,说明该线程已经获得当前对象的对象锁,因此该线程在释放该锁之前同样可以调用该对象的其它synchronized方法。
  • 最大的作用是避免死锁。
  • 实现:计数 -> 加锁和解锁的操作需要配对

自旋锁

  • 尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁。好处是(在等待时间可能不长时)减少线程上下文切换的消耗,缺点是循环会消耗CPU。

互斥锁/排他锁 共享锁

  • 读读相容,独写不相容,写写不相容

阻塞队列

  • 接口:BlockingQueue
  • 常见实现:
    BlockingQueue
  • SynchronousQueue

实现原理

LinkedBlockingQueue

  • 数据结构:有头结点的单链表
  • 两把锁:使用两个ReentrantLock对象,分别保证出队操作和入队操作的线程安全,入队操作和出队操作不会互斥
  • 两个Condition对象:保证队列中有元素才能出队,保证队列中有空闲位置才能入队
  • 一个AtomicInteger:以线程安全的方式维护队列中的元素数量
  • put方法
    1. 获取入队锁
    2. 如果当前队列已满,则在notFull条件下等待
    3. 完成入队操作,并调用AtomicInteger的方法增加元素个数
    4. 判断插入后队列是否已满,如果未满则对notFull调用signal让其它入队线程插入元素(为什么不是signalAll?)
    5. 释放入队锁
    6. 通知出队线程队列非空
  • take方法
    1. 获取出队锁
    2. 如果队列空,在notEmpty条件下等待
    3. 完成出队操作,减小元素数
    4. 队列非空则通知其它出队线程
    5. 通知入队线程队列未满

ArrayBlockingQueue

  • 为什么入队列和出队列公用一把锁了?
    • 因为直接使用数组,入队列和出队列操作开销小。如果两个操作分别用一把锁反而加锁解锁的开销大?

Map

ConcurrentHashMap

JDK 1.7

  • Segment<K, V>: 将每个HashMap的Entry进行分组。记录需要插入HashMap需要先定位插入的Segment,再在Segment中定位插入的位置。相当于将原本的每个Entry分组。该类继承于ReentrantLock,因此具有获得可重入锁的功能。
    • 存储结构上,首先是一个不可扩容的Segment数组。Segment数组的长度取决于并发度(concurrencyLevel),具体长度是大于concurrencyLevel的最近的2次幂。
    • 在每个Segment使用HashEntry数组来保存实际的键值对。该数组具有扩容机制,容量同样是2的幂。
  • 执行put操作的时候,先进行第一次hash来定位segment的位置,如果segment还没有初始化即通过CAS进行赋值。然后进行第二次hash操作,找到相应的HashEntry的位置。之后调用继承自ReentrantLock的可超时方法tryLock尝试获取该Segment的锁。如果成功则在Segment中进行插入或修改。如果不成功,则当前线程会以自旋的方式继续调用tryLock()尝试获得锁。当前线程自旋超过指定的次数就会被挂起,等待被唤醒。
    • 超过扩容阈值时需要对HashEntry数组进行扩容。扩容 -> rehash -> 头插法
  • 执行get操作不需要加锁。
  • 执行size操作时,由于可能存在并发的线程正在插入数据,因此得到的size可能不等于实际的size。
    • 先尝试不加锁多次计算ConcurrentHashMap的size,比较多次计算的结果,如果一致就认为当前没有并发的插入操作,计算结果是准确的。
    • 如果不一致,则为每个Segment加锁,然后计算size并返回。

JDK 1.8

  • 摒弃Segment的概念,直接使用 Node<K, V>数组,将Segment锁变为节点锁。
  • 执行put操作的时候,通过一次hash来定位Node的位置,如果该Node还没有初始化即通过CAS进行赋值。如果该Node已经初始化,则使用synchronized关键字来获得该Node的对象锁,成功获得锁后再在该Node中定位插入/修改的位置。
initTable: 对Node数组进行初始化
  • 使用sizeCtl来表示当前的初始化状态
    • -1: 当前正在进行初始化
    • -N: Node数组进行扩容时也使用该方法创建新数组,此时表示有N - 1个线程正在进行扩容(?)
    • 表示table的初始化大小(此时Node数组为空或长度为0)
    • 表示table的当前容量
  • 如果当前sizeCtl < 0,说明有其它线程正在进行CAS(或者是在进行初始化,或者是在进行数组扩容),此时直接让出CPU的使用权(Thread.yield())
  • 否则自己通过CAS将sizeCtl设置为-1,当Node数组为空时,创建新的Node数组。
put: 插入K-V对,并判断是否要进行扩容操作
  • 插入流程
  1. 通过key本身的hashCode计算出hash值,进而定位到桶的位置
  2. 如果桶为空,通过CAS的方式尝试创建一个新的Node插入桶,成功了则直接退出自旋,失败则继续自旋。
  3. 如果桶不为空,但是该桶的hash值为MOVED,需要进行扩容? -> helpTransfer
  4. 上述能够不加锁插入键值对的情况都不满足,需要使用synchronized代码块对该节点(入口节点)进行加锁,然后进行插入。
  • 如果是链表,找到key相等的节点,然后修改这个节点的value。如果找不到key相等的节点,则使用尾插法插入到链表中。
  • 如果是红黑树,使用红黑树的插入方法
  • 插入完成后退出
  1. 如果插入完成后链表节点数大于TREEIFY_THRESHOLD,需要判断是否要转化为红黑树
  • 如果当前数组长度小于MIN_TREEIFY_CAPACITY(64),则将数组扩容为原来的两倍,并调用transfer方法重新调整节点的位置
  1. 新增节点后,使用addCount 方法记录节点个数并检查是否需要扩容,数组个数达到阈值时会需要扩容,并触发transfer方法重新调整节点位置
  • 扩容与重哈希
  1. 新建一个长度为旧长度两倍的数组
  2. 初始化ForwardingNode节点的实例,保存新数组的引用。该实例作为占位节点。
  3. 处理当前桶中每个元素,
get
  1. 用同样的方法确定桶的位置
  2. 判断头结点的hash值情况
  • 如果头结点的hash值与当前查找的key相等,则判断头结点的key是否等于当前查找的key。如果相等则可以直接返回
  • 如果头结点的hash值为负数,说明当前数组正在扩容,或者当前桶的元素以红黑树形式存储。调用find进行查找。
  • 如果上述两个条件均不符合,说明当前节点是链表,在链表中查找。

ConcurrentLinkedQueue

  • 非阻塞的线程安全队列
  • 使用链表作为底层数据结构,使用CAS来实现线程安全(?)

BlockingQueue

ArrayBlockingQueue

ArrayBlockingQueue 是 BlockingQueue 接口的有界队列实现类,底层采用数组来实现。ArrayBlockingQueue 一旦创建,容量不能改变。其并发控制采用可重入锁来控制,不管是插入操作还是读取操作,都需要获取到锁才能进行操作。当队列容量满时,尝试将元素放入队列将导致操作阻塞;尝试从一个空队列中取一个元素也会同样阻塞。
ArrayBlockingQueue 默认情况下不能保证线程访问队列的公平性,所谓公平性是指严格按照线程等待的绝对时间顺序,即最先等待的线程能够最先访问到 ArrayBlockingQueue。而非公平性则是指访问 ArrayBlockingQueue 的顺序不是遵守严格的时间顺序,有可能存在,当 ArrayBlockingQueue 可以被访问时,长时间阻塞的线程依然无法访问到 ArrayBlockingQueue。如果保证公平性,通常会降低吞吐量。如果需要获得公平性的 ArrayBlockingQueue,可采用如下代码:

LinkedBlockingQueue

LinkedBlockingQueue 底层基于单向链表实现的阻塞队列,可以当做无界队列也可以当做有界队列来使用,同样满足 FIFO 的特性,与 ArrayBlockingQueue 相比起来具有更高的吞吐量,为了防止 LinkedBlockingQueue 容量迅速增,损耗大量内存。通常在创建 LinkedBlockingQueue 对象时,会指定其大小,如果未指定,容量等于 Integer.MAX_VALUE。

PriorityBlockingQueue

PriorityBlockingQueue 是一个支持优先级的无界阻塞队列。默认情况下元素采用自然顺序进行排序,也可以通过自定义类实现 compareTo() 方法来指定元素排序规则,或者初始化时通过构造器参数 Comparator 来指定排序规则。

PriorityBlockingQueue 并发控制采用的是 ReentrantLock,队列为无界队列(ArrayBlockingQueue 是有界队列,LinkedBlockingQueue 也可以通过在构造函数中传入 capacity 指定队列最大的容量,但是 PriorityBlockingQueue 只能指定初始的队列大小,后面插入元素的时候,如果空间不够的话会自动扩容)。

简单地说,它就是 PriorityQueue 的线程安全版本。不可以插入 null 值,同时,插入队列的对象必须是可比较大小的(comparable),否则报 ClassCastException 异常。它的插入操作 put 方法不会 block,因为它是无界队列(take 方法在队列为空的时候会阻塞)。

ThreadLocal

用途

为每个线程提供一份独有的副本变量,多个线程互不干扰。

实现

Thread类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals,即每个线程有一个自己的ThreadLocalMap。
该Map的元素是Entry extends WeakReference<ThreadLocal<?>>,即继承了ThreadLocal<?>的一个弱引用。每个线程在向ThreadLocal里放值的时候,其实都在向自己的ThreadLocalMap里存值。读也是以ThreadLocal作为引用,在自己的map里找对应的key,从而实现线程隔离。ThreadLocalMap类似于HashMap的结构,同样以数组结构存储数据。
threadLocal
ThreadLocal的set方法:用Thread.currentThread()通过getMap(Thread)方法取出当前线程对应的ThreadLocalMap。如果map为空则创建一个,如果不为空则将值加入map。

1
2
3
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}

由createMap方法可以看出,ThreadLocalMap是线程对象(Thread)的一个成员变量。

ThreadLocalMap使用的Hash算法,如何解决冲突

  • ThreadLocalMap使用和HashMap类似的定位方式:
    1
    int i = key.threadLocalHashCode & (len - 1);
    threadLocalHashCode用于对键值的插入位置进行定位。因为ThreadLocalMap的Key都是同一个类型(泛型类ThreadLocal<?>的弱引用),因此可以在ThreadLocal类中定义静态属性来生成优化的HashCode。每个ThreadLocal对象在初始化时都会使用nextHashCode静态方法来确定自己的HashCode。而nextHashCode使用AtomicInteger来实现线程安全的HashCode生成。(斐波那契散列乘数?)
  • ThreadLocalMap不使用链表来解决哈希冲突,而是使用线性探测法。如果当前位置已经存在Entry、Entry的key与当前key不相等且非空,则向后寻找直到找到第一个Key与当前key相等或为空的Entry。

为什么key要用ThreadLocal对象的弱引用

回忆弱引用的定义:下一次GC时如果只有弱引用,则该对象一定会被回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Entry的定义
/**
* The entries in this hash map extend WeakReference, using
* its main ref field as the key (which is always a
* ThreadLocal object). Note that null keys (i.e. entry.get()
* == null) mean that the key is no longer referenced, so the
* entry can be expunged from table. Such entries are referred to
* as "stale entries" in the code that follows.
*/
// 通过继承WeakRefernce<ThreadLocal>的方式实现"以ThreadLocal对象的弱引用作为Key"
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
// 此处完成WeakReference<ThreadLocal<?>>的初始化
super(k);
value = v;
}
}

// ThreadLocalMap内部使用Entry数组进行存储
//ThreadLocalMap构造方法
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
//内部成员数组,INITIAL_CAPACITY值为16的常量
table = new Entry[INITIAL_CAPACITY];
//位运算,结果与取模相同,计算出需要存放的位置
//threadLocalHashCode比较有趣
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}

当还有线程正在使用某个ThreadLocal对象时,这些线程的栈中会保留一个指向这一ThreadLocal对象的强引用。如果没有线程要使用这个ThreadLocal对象了(每个线程都将指向ThreadLocal对象的引用置为null),这个ThreadLocal对象就应该被垃圾回收(例如随着包含他的对象一起被回收)。
如果某个线程没有完全结束,那么这个线程对应的Thread对象也不会被GC。而Thread对象里存在对ThreadLocalMap的引用(强引用)。如果ThreadLocalMap的key是对ThreadLocal的强引用,只要使用过这个ThreadLocal对象的线程没有都结束(还存在Thread对象未被GC),根据可达性分析,ThreadLocal对象会被视为可达,因为ThreadLocalMap持有对ThreadLocal的强引用,此时ThreadLocal对象不会被GC。如果ThreadLocalMap持有的是对ThreadLocal的弱引用,则此时ThreadLocal对象只存在弱引用,可以被GC。

  • 说人话:使用弱引用的目的是使ThreadLocal对象在除了ThreadLocalMap之外没有任何引用的时候,能够被回收。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class MyResource {
// 为每个线程提供独有副本变量
public ThreadLocal<String> myThreadLocal;
// ...
}

class Task implements Runnable {
public void run() {
MyResource threadLocalResource;
// 线程在通过ThreadLocal对象获取独占的副本变量的时候,会持有ThreadLocal的强引用
ThreadLocal<String> ref = threadLocalResource.myThreadLocal;
String str = myThreadLocal.get();
// 所有线程都使用完该资源之后,就不会有Task类的对象持有ThreadLocal对象的强引用
// 当所没有任何对象持有MyResource的强引用时,ThreadLocal对象应该随着MyResource对象一起被回收
// 但是每个Thread类中会持有一个ThreadLocalMap, 而ThreadLocalMap如果以ThreadLocal对象的强引用作为Key
// 那么因为此时仍有可达对象保留强引用指向ThreadLocal对象,该ThreadLocal对象直到所有线程都退出
// Thread对象被回收之前都不会被回收
// 所以应该使用弱引用,当指向ThreadLocal的引用只剩弱引用时,说明到ThreadLocal的引用只剩下ThreadLocalMap的Key
// 没有任何实际使用的线程,可以让其被回收
}

public static void createTask() {
Thread thread = new Thread(new Task());
thread.start();
}
}

Entry的内存泄漏问题

  • ThreadLocalMap中的Key在什么时候、哪个阶段被GC?
    Key是ThreadLocal对象的弱引用,当所有类都不存在对ThreadLocal对象的强引用时(所有线程都使用完了这个ThreadLocal对象),ThreadLocal对象会被回收。
  • 至于阶段,可能要看情况?如果ThreadLocal对象一直有其他的强引用(我把它挂到某个生命周期很长的对象上,甚至可以挂到静态属性上),它是不是可以撑过垃圾回收?(因为它不止有ThreadLocalMap中的弱引用)。
  • ThreadLocalMap中的Value在什么时候被GC?
    即使ThreadLocal被GC,Entry中对value的引用是强引用,因此key被GC时value不会被GC,但这个value永远不会被访问到了。并且除非所有使用过这个ThreadLocal的线程都退出(Thread对象被回收),GC算法会把整个ThreadLocalMapGC,此时ThreadLocal才会被GC。Java为了尽可能减小这种内存泄漏的影响,在ThreadLocalMap的get方法、set方法和remove方法中会清除ThreadLocalMap中key为null的value。
    • set方法:replaceStaleEntry方法,插入时发现key为空时调用该方法替换key已经为空的value值
    • get方法:?
    • remove方法:只有手动调用remove方法才是最为保险的做法。该方法将当前线程中ThreadLocalMap的对应Key和Value都删除。

标准I/O

将IO数据先复制到内核缓冲区,然后从内核缓冲区复制到应用程序的地址空间。

5种IO模型

  • blocking/nonblocking:一直block到操作完成/内核缓冲区数据准备好时才开始block,否则立刻返回
  • sync/async:做IO operation时(内核缓冲区和应用程序的数据交换)是否会block

阻塞IO

blockingIO

同步非阻塞IO

nonblockingIO

IO多路复用

  • select/poll/epoll
  • multiplexing函数可以具有超时机制
  • 单进程可以处理多个IO
    IOmultiplexing

信号驱动IO

signalDrivenIO

异步IO

asyncIO

3个IO多路复用函数

select

  • 几乎全平台支持
  • 3个fd_set
  • 底层数据结构使用数组,能监视的文件描述存在最大限制
  • select返回后需要轮询所有的fd

poll

  • 一个pollfd指针
  • 没有最大数量限制(可能受OS的最大描述符数量限制)
  • 返回后也需要轮询所有的pollfd

epoll

  • 3个接口
    1
    2
    3
    4
    int epoll_create(int size);//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大
    // 只是一个初始大小
    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
    • epoll_create: 创建epoll句柄(一个fd),因此epoll会创建一个fd。
    • epoll_ctl: 可以对指定fd(fd)添加、删除、修改希望监听的事件(操作由op指定)。event指定监听事件,也可以设置当前epoll为水平触发和边缘触发。
    • epoll_wait: 等待epfd上的IO事件。最多返回maxevent个事件。返回值是事件触发数。
  • 水平触发LT:epoll_wait检测到描述符发事件发生时通知程序,程序可以不立即处理事件。如果不处理,下次调用该方法时会再次通知应用程序事件的发生
  • 垂直触发ET(edge trigger): 应用程序必须立即处理事件,不处理的话下次不会再通知。

Java 3种IO

BIO

listen就是直到返回才会完成

NIO

Selector: 多路复用选择器,将事件绑定到此处
Channel: 读写数据的通道,可以注册到Selector中并选择感兴趣的事件(read就绪、write就绪、accept就绪…)。注册后在Selector上调用select会选出所有就绪的Channel对应的SelectionKey。
SelectionKey: 表示一个Channel在一个Selector上的一次注册,可以向其中附加数据(比如附加Handler方法)
ByteBuffer:用于向Channel中读写数据的缓冲区。

AIO

  • TCP状态图
  • TIME_WAIT/CLOSE_WAIT大量出现是为什么,怎么解决
  • TCP为什么慢
  • HTTP/2
  • 能不能用UDP代替TCP(QUIC协议)

协议分层(TCP/IP协议栈)

  • 应用层(application-layer)的任务是通过应用进程间的交互来完成特定网络应用。应用层协议定义的是应用进程(进程:主机中正在运行的程序)间的通信和交互的规则。对于不同的网络应用需要不同的应用层协议。在互联网中应用层协议很多,如域名系统DNS,支持万维网应用的 HTTP协议,支持电子邮件的 SMTP协议等等。我们把应用层交互的数据单元称为报文。
  • 传输层(transport layer)的主要任务就是负责向两台主机进程之间的通信提供通用的数据传输服务。应用进程利用该服务传送应用层报文。
  • 运输层主要使用以下两种协议:
    • 传输控制协议 TCP(Transmission Control Protocol)–提供面向连接的,可靠的数据传输服务。
    • 用户数据协议 UDP(User Datagram Protocol)–提供无连接的,尽最大努力的数据传输服务(不保证数据传输的可靠性)。
  • 网络层/网际层:在 计算机网络中进行通信的两个计算机之间可能会经过很多个数据链路,也可能还要经过很多通信子网。网络层的任务就是选择合适的网间路由和交换结点, 确保数据及时传送。
  • 网络接口层(数据链路层/物理层):数据链路层:两台主机之间的数据传输,总是在一段一段的链路上传送的,这就需要使用专门的链路层的协议。 在两个相邻节点之间传送数据时,数据链路层将网络层交下来的 IP 数据报组装成帧,在两个相邻节点间的链路上传送帧。每一帧包括数据和必要的控制信息(如同步信息,地址信息,差错控制等)。物理层:实现相邻计算机节点之间比特流的透明传送,尽可能屏蔽掉具体传输介质和物理设备的差异。

TCP协议

  • 网络层只会向上提供简单灵活的,无连接的,尽最大努力交付的数据报服务。网络层不提供服务质量的承诺。不保证分组交付的时限所传送的分组可能出错,丢失,重复和失序。进程之间通信的可靠性由运输层负责

TCP状态转换

  • LISTEN: 等待远程TCP应用程序的连接建立请求
  • SYN_SENT: 发送连接请求(SYN)后等待来自远程端点的确认。
  • SYN_REVD: 当前端点已经接收到连接请求并发送确认。该端点正在等待最终确认(ACK),TCP第二次握手后服务端所处的状态。
  • ESTABLISHED: 客户端发出ACK后的状态,服务端接收到客户端ACK的状态,TCP连接完全建立。
  • FIN_WAIT_1: 发出FIN后,等待远程端点对FIN的ACK
  • FIN_WAIT_2: 收到远程端点对自己发出的FIN的ACK,等待来自远程TCP端点的连接中止请求(FIN)。
  • CLOSE_WAIT: 接受到远程端点发送的FIN,并发送ACK之后的状态
  • LAST_ACK: 对方向自己的连接已经关闭时,向对方发送FIN之后的状态
  • TIME_WAIT: 收到远程端点的FIN,向远程端点发送ACK,等待2MSL以确保远程端点收到ACK,随后关闭连接进入CLOSED状态
  • CLOSING: 发出FIN后,在等待远程端点对FIN的ACK时收到远程端点的FIN,进入该状态(同时关闭)。此时接收到远程端点的ACK会进入TIME_WAIT状态。
    TCPStateMachine

三次握手

过程

  • TCP的三次握手:
    1. 发起者 SYN seq = x
    2. 接受者收到SYN seq = x,发回ACK x + 1 SYN seq = y。此时接收者的TCP状态切换为SYN RECEIVED,创建一个子连接加入到SYN_RCVD队列(半连接队列)
    3. 发起者收到ACK x + 1 SYN seq = y,发回ACK y + 1 seq = x + 1
    4. 接受者收到ACK x + 1 seq = y + 1,将2中创建的子连接移动到ESTABLISHED队列(全连接队列,accept队列)
    5. 接受者端用户程序调用accept()时,将连接从ESTABLISHED队列取出

为什么需要三次握手

  • 总而言之,TCP双方建立连接时采用三次握手是为了感知双方的存在,同时确认双方的发送/接收能力是否正常。同时,还可以在这个阶段交换一些参数
  • 第一次握手:发起者发送SYN x,在本次握手无法确认任何事。而接受者收到SYN x可以确定发起者的发送能力正常,接受者的接收能力正常。
  • 第二次握手,接受者发送SYN y ACK x + 1,在本次接受者无法确认任何事,发起者收到SYN y ACK x + 1可以确定发起者的发送能力正常,发起者的接收能力正常,接收者的发送能力和接收能力都正常。此时发起者完成了对接受者的感知,而接受者尚未完成对发起者的感知。
  • 第三次握手,发起者发送ACK y + 1,接受者接收到ACK y + 1后可以确认发起者的接受能力正常,接受者的发送能力正常。至此接受者也完成了对发起者的感知。

为什么第二次握手需要设置SYN = 1

意义不同。接收端传回发送端所发送的ACK是为了告诉发送端端,接收到的信息确实就是所发送的信号了,这表明从发送端到服务端的通信是正常的。而回传SYN则是为了建立并确认从接收端到发送端的通信。

TCP vs UDP(TCP为什么慢)

  • TCP: 要连接,要释放,无广播。因为要确保可靠传输,需要有序传输,确认机制,流量控制(发送方和接收方的速度同步),拥塞控制(网络状态差时减少发送),增加了很多开销。
  • UDP:无连接,不需要确认,不提供可靠交付,即时性强

TCP如何保证可靠传输

  1. 应用数据被分割成 TCP 认为最适合发送的数据块。-> 粘包问题
  2. TCP 给发送的每一个包进行编号,接收方对数据包进行排序,把有序数据传送给应用层。(有序传输)
  3. 校验和: TCP 将保持它首部和数据的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错,TCP 将丢弃这个报文段和不确认收到此报文段。
  4. TCP 的接收端会丢弃重复的数据。
  5. 流量控制: TCP 连接的每一方都有固定大小的缓冲空间,TCP的接收端只允许发送端发送接收端缓冲区能接纳的数据。当接收方来不及处理发送方的数据,能提示发送方降低发送的速率,防止包丢失。TCP 使用的流量控制协议是可变大小的滑动窗口协议。 (TCP 利用滑动窗口实现流量控制)
  6. 拥塞控制: 当网络拥塞时,减少数据的发送。
  7. ARQ协议(自动重传请求): 也是为了实现可靠传输的,它的基本原理就是每发完一个分组就停止发送,等待对方确认。在收到确认后再发下一个分组。
  8. 超时重传: 当 TCP 发出一个段后,它启动一个定时器,等待目的端确认收到这个报文段。如果不能及时收到一个确认,将重发这个报文段。

ARQ协议:确认机制

  • 在OSI模型的数据链路层和传输层都使用的错误纠正协议之一。通过确认和超时两个机制在不可靠的基础上实现可靠的信息传输。

停止-等待ARQ

每发完一个分组就停止发送等待对方确认(等待ACK的序号)。过了一段时间后没有得到ACK确认就重发,直到收到确认。
优点是简单,缺点是信道利用率低,等待时间长。

  • 维护超时计时器,超时未确认则重传发送过的分组(自动重传ARQ)。如果收到重复分组则丢弃,但要再次发送确认。确认消息丢失或确认消息迟到都会导致重传,对重复的数据和重复的确认的处理是相同的,都是丢弃,重复数据会发送确认。

连续ARQ

  • 发送方维护一个发送窗口,发送窗口内的分组连续发送出去而不需要等待确认,接收方采用累计确认,确认按序到达的最后一个分组,表明在此之前的所有分组都正确地收到了。
  • 优点:信道利用率高
  • 缺点:有时接收方无法向发送方反映正确收到的所有分组的信息。发送方发送了 5条 消息,中间第三条丢失(3号),这时接收方只能对前两个发送确认。发送方无法知道后三个分组的下落,而只好把后三个全部重传一次。这也叫 Go-Back-N(回退 N),表示需要退回来重传已经发送过的 N 个消息。

TCP流量控制:滑动窗口

  • 流量控制:控制发送方的发送速率,保证接收方来得及接收。

TCP拥塞控制(4阶段)

在某段时间,若对网络中某一资源的需求超过了该资源所能提供的可用部分,网络的性能就要变坏。这种情况就叫拥塞。拥塞控制就是为了防止过多的数据注入到网络中,这样就可以使网络中的路由器或链路不致过载。拥塞控制是一个全局性的过程,涉及到所有的主机,所有的路由器,以及与降低网络传输性能有关的所有因素。(需要对比流量控制)
维护拥塞窗口,根据网络的拥塞程度动态变化,发送方将发送窗口调整为拥塞窗口和接收窗口中较小的一个。

  • 慢开始:慢开始算法的思路是当主机开始发送数据时,如果立即把大量数据字节注入到网络,那么可能会引起网络阻塞,因为现在还不知道网络的符合情况。经验表明,较好的方法是先探测一下,即由小到大逐渐增大发送窗口,也就是由小到大逐渐增大拥塞窗口数值。cwnd初始值为1,每收到一个ACK,就将拥塞窗口大小+1。这样每经过一个传播轮次,cwnd就会加倍。
  • 拥塞避免:慢开始阶段,窗口达到慢开始的阈值或发生丢包时,就会进入拥塞避免阶段,发送窗口立级减半。拥塞避免算法的思路是让拥塞窗口cwnd缓慢增大,即每收到一个ACK就把发送放的cwnd加1。此时cwnd会呈线性增长。
  • 快重传与快恢复(Fast Retransmit and Recovery,FRR):如果不使用快重传,数据包丢失时TCP将会使用定时器来要求传输暂停(等待ACK到来或超时重传),这段时间内没有新的或复制的数据包被发送。如果使用FRR,接收机接收到一个不按顺序的数据段,它会立即给发送机发送一个重复确认。如果发送机接收到三个重复确认,它会假定确认件指出的数据段丢失了,并立即重传这些丢失的数据段。有了 FRR,就不会因为重传时要求的暂停被耽误。
    • 在拥塞避免阶段,如果收到连续的3个重复的ACK,认为这个报文的下一个报文就丢失了,进入快重传阶段,立即重传丢失的数据段。进入快重传阶段
    • 此时对于接收端,要求接收端在收到第一个失序的报文段后立即发出重复确认,而不要等到自己要发数据时才进行捎带确认。
    • 快重传后进入快恢复阶段,将慢启动阈值修改为当前拥塞窗口值的一半,同时拥塞窗口值等于慢启动阈值,然后进入拥塞避免阶段。

四次挥手

过程

  • 客户端-发送一个 FIN,用来关闭客户端到服务器的数据传送
  • 服务器-收到这个 FIN,它发回一 个 ACK,确认序号为收到的序号加1 。和 SYN 一样,一个 FIN 将占用一个序号
  • 服务器-关闭与客户端的连接,发送一个FIN给客户端
  • 客户端-发回 ACK 报文确认,并将确认序号设置为收到序号加1。此时客户端会进入TIME-WAIT状态,等待2MSL(最长报文段寿命)
  • 服务端收到ACK报文确认后会直接关闭TCP连接,客户端等待2MSL后也会关闭TCP连接

为什么要等待2MSL

  • MSL(Maximum Segment Lifetime):最大报文段寿命。
  • 第一,防止客户端发送的最后一个ACK报文丢失。在这种情况下,服务端收不到客户端发送的最后一个ACK,会再次发送释放连接报文。
    • 其实这一目的并不十分重要,并且2MSL也并不是最保守的一个估计值。最保守的估计值应该是一直等待到对方的超时重传次数全部用完再加一个MSL。第一个目标虽然重要,但并不十分关键,因为既然已经到了关闭连接的最后一步,说明在这个TCP连接上的所有用户数据已经完成可靠传输,所以要不要完美的关闭这个连接其实已经不是那么关键了。
  • 第二,如果不等待2MSL(不存在TIME_WAIT)状态,假如双方关闭连接后又经过三次握手建立了一个新的连接,使用的IP地址和端口和这个先前的连接完全相同,并且原先的连接中还有数据报残存在网络中,这样残存的数据报有可能成功到达通信的某一方,通信的某一方有可能接收到上一个连接残存的数据。为了防止这一点,TCP定义了TIME_WAIT状态,让释放连接的一方的socket等待2MSL后再接受同一个socket(同一个四元组)建立连接的请求。这足以让两个方向上的旧数据都过期。
    • 回忆四次挥手的最后两次:
      1. TCP的一端发送FIN报文后如果收不到对端针对该FIN的ACK,则会反复多次重传FIN报文(应该回有一个超时时间
      2. 被动关闭(第二次发送FIN报文的一端)处于LAST-ACK状态在收到最后一个ACK之后不会发送任何报文,立即进入CLOSED状态。
      3. 主动关闭的一端在收到FIN报文后回复ACK并进入TIME-WAIT状态。
      4. 处于TIME_WAIT状态的一端在收到重传的FIN时会重新计时
    • 假设A刚对B发出的FIN发回ACK,进入了TIME_WAIT状态,而B正处于LAST_ACK状态。B在收到最后一个ACK之前会重传FIN直至超时。
      • 如果ACK在网络中丢失,B会一直重传FIN直到超时。如果B的FIN能被A接收,那么计时器重置,双方的状态都不变。如果B的FIN始终无法传达到A,那么A在经历2MSL后状态会变为CLOSED,B会重传FIN到超时,在此之前都处于LAST_ACK状态。处于LAST_ACK状态的B不会向A建立新的连接,自然也就没有化身连接的问题。
      • 如果ACK被B接收到,从A发送到B接收经历了时间t,则必有0 <= t <= MSL。最极端的情况是,当t = MSL时,B在收到ACK的瞬间重传了一次FIN,那么这个FIN需要被丢弃,让A再等一个MSL即可让这个FIN失效。因此A至少需要等2MSL。

为什么TCP的初始序列号(Sequence Number)需要是随机的

  • 为了防范针对TCP的攻击:IP欺骗
    • 首先回忆一下,TCP连接的双方都要生成一个初始序列号。
    • 对于一对相互信任的主机A和B,主机C如果知道了主机B的IP,在TCP初始序列号可预测的情况下,可以冒充主机B与主机A建立TCP连接
    • 具体流程: 主机C使用主机B的IP向主机A发送TCP SYN,主机A会将它的SYN + ACK发给主机B,主机B此时不会理会(因为不是它发起的连接)。此时主机C无从得知主机A发出的SYN + ACK的序列号,但是如果TCP初始序列号非随机(可预测),则主机C就可以直接构造出正确的序列号与主机A建立TCP连接。在建立之后主机A就会正常接收来自主机C的消息了。

TCP Backlog,netstat中的RECV-Q和SEND-Q

TCP的全连接队列和半连接队列

  • 半连接队列:SYN_RCVD队列
  • 全连接队列:accept队列

backlog参数会影响什么,怎么影响

  • backlog是系统调用listen中的一个参数,能够影响半连接队列和全连接队列的长度
  • 能够影响上述两个队列长度的还有/proc/sys/net/core/somaxconn, /proc/sys/net/ipv4/tcp_max_syn_backlog这两个参数
  • 全连接队列的长度上限为min(backlog, somaxconn)
  • 半连接队列的长度上限为roundup_pow_of_two(max(8, min(tcp_max_syn_backlog, min(backlog, somaxconn))) + 1),其中roundup_pow_of_two(n)是指大于等于n且最接近n的2的x次幂(返回2的x次幂的值)。
  • tcp_max_syn_backlog - INTEGER
    Maximal number of remembered connection requests, which have not
    received an acknowledgment from connecting client.
    The minimal value is 128 for low memory machines, and it will
    increase in proportion to the memory of machine.
    If server suffers from overload, try increasing this number.
    根据大意是指半连接队列的最大长度。
  • somaxconn - INTEGER
    Limit of socket listen() backlog, known in userspace as SOMAXCONN.
    Defaults to 128. See also tcp_max_syn_backlog for additional tuning
    for TCP sockets.
  • 那为什么实际计算队列长的时候那么奇怪呢(

netstat中的RECV-Q和SEND-Q

  • 连接建立(ESTABLISHED)时,RECV-Q指的是连接到该socket的用户程序还没有复制(到用户空间)的字节数,SEND-Q指的是远端主机还没有确认收到的字节数。
  • 在侦听状态中(LISTENING),RECV-Q指的是当前的SYN BACKLOG(since 2.6.18),SEND-Q指的是当前SYN BACKLOG的最大值。
  • 根据文档,SYN backlog就是半连接队列(

TCP粘包

  • TCP会对用户数据进行调整后封装发送。这样会导致用户多次发送的数据被封装在一个TCP报文中,或者用户一次发送的数据被拆分成多个TCP报文;或者先被发送的数据需要等待一段时间,才能跟后面被发送的数据一起组成报文被发送出去。因为TCP是面向流的协议,同时为了解决大量小报文的情况下包头比负载大导致传输性价比太低的问题,TCP会对用户数据进行重新调整。
  • 如果开发者需要解决数据被合并发送的问题,需要对应用层协议进行重新设计,例如让应用层协议数据以特定的标志开头,在应用层协议头表明该协议数据的长度,以便于在处理字节流时重新获取完整的包数据。
  • 如果开发者希望让数据立级发送而不是等待,可以设置TCP_NODELAY来尝试解决。

TIME_WAIT大量出现

确认

netstat -an | grep TIME_WAIT

可能的原因

  • TIME_WAIT只会出现在主动发起四次挥手的一方。原因之一可能是因为HTTP服务器没有设置keepalive,导致每发送一个响应就要断开连接。而每次断开连接都需要等待2MSL。
  • 还有谁用TCP连接?数据库…例如redis…MySQL…如果没有连接池,也可能大量出现这种状态

CLOSE_WAIT大量出现

可能的原因

  • CLOSE_WAIT出现在被动接受四次挥手的一方。具体出现在接受对方发送的FIN后发送出ACK之后,会转换到CLOSE_WAIT状态。如果一直保留在这个状态,说明当前端可能没有对连接进行close操作,导致一直保持半连接。

TCP SYN洪泛攻击(SYN Flood)

是什么

  • TCB(TCP传输控制块)是一个让OS感知TCP连接的数据结构。在半连接队列(SYN-RCVD队列,存在于内核)中,保存了已经收到SYN报文但是没有收到ACK报文的半连接。
  • 服务器在接受到TCP连接请求时会为这个半连接分配资源,用这些资源保存此次请求的一些信息,例如四元组(目的IP和端口,源IP和端口)和一些TCP参数。
  • backlog队列参数,限制TCB上限的数量。当该队列被占满时,服务器不再会响应新的请求,除非TCB能够被回收或从SYN-RCVD状态中被移除。
  • SYN Flood:发送大量的SYN数据报占满服务器的半连接队列。
    • 直接攻击:不伪装IP,不对SYN-ACK报文进行响应
    • IP地址欺骗:在报文头中填入其它的IP地址,这些主机不会响应SYN-ACK报文
    • 分布式攻击:使用网络中的多台主机进行SYN洪泛攻击。

表现为?

  • 在进入SYN_RCVD状态时,连接请求的接收方会向发起方发送SYN+ACK报文,并且有一个重传并等待超时的机制,因此SYN_RCVD状态会大量发生(netstat)?

解决方案

  • 适当增大半连接队列的最大容量。(backlog参数,linux系统中的backlog和somaxconn参数)
  • SYN Cookies
    • 目的:在不预分配资源的情况下,验证之后可能到达的ACK的有效性,保证这是一次完整的握手(要保证当前收到的ACK在之前的却发送过SYN,接受过从本服务器发出过的SYN + ACK),同时获得SYN报文中携带的TCP选项信息。
    • SYN Cookies让服务器在收到客户端的SYN报文时,不分配资源保存客户端信息,而是将这些信息保存在SYN + ACK的初始序列号和时间戳中。对正常的连接,在回复ACK时会将该序列号+1发送到服务器。服务器通过将该序列号-1即可还原出这些客户端信息,确认当前连接是经历三次握手建立的合法的连接。
    • Linux中的/proc/sys/net/ipv4/tcp_syncookies可以设置SYN Cookie的开关。
  • 设置防火墙/代理,当服务端响应SYN+ACK后代理或防火墙伪装客户端直接向服务端返回假的ACK,避免半连接队列过长。如果客户端能再发回ACK,说明这是一个合法的连接。如果代理等待一段时间后检测不到客户端发回的ACK,则向服务端发送RST释放TCP连接。

ARP

建立TCP连接与ARP的关系

应用接受用户提交的数据,触发TCP建立连接,TCP的第一个SYN报文通过connect函数到达IP层,IP层通过查询路由表:

  • 如果目的IP和自己在同一个网段:
    • 当IP层的ARP高速缓存表中存在目的IP对应的MAC地址时,则调用网络接口send函数(参数为IP Packet和目的MAC))将数据提交给网络接口,网络接口完成Ethernet Header + IP + CRC的封装,并发送出去;
    • 当IP层的ARP高速缓存表中不存在目的IP对应的MAC地址时,则IP层将TCP的SYN缓存下来,发送ARP广播请求目的IP的MAC,收到ARP应答之后,将应答之中的<IP地址,对应的MAC>对缓存在本地ARP高速缓存表中,然后完成TCP SYN的IP封装,调用网络接口send函数(参数为IP Packet和目的MAC))将数据提交给网络接口,网络接口完成Ethernet Header + IP + CRC的封装,并发送出去。
  • 如果目的IP地址和自己不在同一个网段,就需要将包发送给默认网关,这需要知道默认网关的MAC地址:
    • 当IP层的ARP高速缓存表中存在默认网关对应的MAC地址时,则调用网络接口send函数(参数为IP Packet和默认网关的MAC)将数据提交给网络接口,网络接口完成Ethernet Header + IP + CRC
    • 当IP层的ARP高速缓存表中不存在默认网关对应的MAC地址时,则IP层将TCP的SYN缓存下来,发送ARP广播请求默认网关的MAC,收到ARP应答之后,将应答之中的<默认网关地址,对应的MAC>对缓存在本地ARP高速缓存表中,然后完成TCP SYN的IP封装,调用网络接口send函数(参数为IP Packet和默认网关的MAC)将数据提交给网络接口,网络接口完成Ethernet Header + IP + CRC的封装,并发送出去。

ICMP

  • Internet控制消息协议,用于探测网络情况,主机是否可达等信息。

HTTP

输入URL -> 页面加载完成

总体

  1. DNS解析
  2. TCP连接
  3. 发送HTTP请求
  4. 服务器处理请求并返回HTTP响应报文
  5. 浏览器解析渲染页面
  6. 连接结束

DNS解析

  • 唯一标识是IP地址,但是IP地址不方便记忆,在用户友好和可用性中做权衡 -> 域名到IP地址的解析(DNS解析)
  • DNS解析:递归查询。
    • 使用UDP协议
    • (host文件?) -> 本地域名服务器 -> 根域名服务器(com, net, …) -> 顶级域名服务器 -> (主)域名服务器
  • DNS优化 -> DNS缓存
    • DNS存在多级缓存:浏览器缓存、系统缓存、路由器缓存、ISP(Internet Service Provider)服务器缓存、根域名服务器缓存、顶级域名服务器缓存、主域名服务器缓存
  • DNS负载均衡:让DNS返回一个合适的机器IP给用户。

TCP连接

HTTP请求

构建HTTP请求报文,通过TCP协议发送到服务器指定端口。HTTP -> 80,HTTPS -> 443

请求行

1
Method Request-URL HTTP-Version CRLF

报头

允许客户端和服务器之间传递HTTP报文时携带附加信息和关于(客户端)自身的信息。
常见请求报头

  • Cookie
  • With-Cridential

自定报头:一般以X-开头

请求正文

发送的数据。

HTTP响应

状态码

  • 1xx:指示信息–表示请求已接收,继续处理。

  • 2xx:成功–表示请求已被成功接收、理解、接受。

    • 200 OK
    • 202 Accepted
  • 3xx:重定向–要完成请求必须进行更进一步的操作。

    • 301 Moved Permanently:
    • 302 Moved Temporarily
    • 304 Not Modified:不包含消息体,客户端可以直接使用本地缓存的请求结果
  • 4xx:客户端错误–请求有语法错误或请求无法实现。

    • 400 Bad Request:语义有误、参数错误
    • 401 Unauthorized:需要进行用户验证
    • 403 Forbidden:拒绝执行,通常可以表示权限不足
    • 404 Not Found:指定的资源未找到
  • 5xx:服务器端错误–服务器未能实现合法的请求。

    • 500 Internal Server Error:一般是服务器源代码出错。

响应头

常见响应报头

  • expires:http1.0的缓存控制响应头,表示未来资源会过期的时间,用于实现强制缓存。过期前会在本地缓存数据库中读取信息,过期后则向服务器发送请求。
  • Cache-Control:HTTP1.1的缓存控制响应头,用于实现强制缓存(max-age=xxx)。no-cache使用协商缓存。no-store则禁用缓存
  • Last-Modified:http1.0的缓存控制响应头,用于实现协商缓存。第二次及之后的请求时浏览器会首先带上If-Modified-Since请求头去访问服务器,而服务器将其中携带的时间与资源修改时间匹配。若不一致,服务器返回新的资源并更新Last-Modified。若一致,则返回304状态码。
  • Etag:http1.1通过响应头的Etag字段(内容特征值)作为缓存标识。第一次请求时服务器将资源和Etag一并返回给浏览器。之后的请求时浏览器将Etag信息放到If-None-Match请求头去访问服务器。服务器收到请求后将服务器中的文件标识与浏览器发来的标识进行对比,如果不相同则返回新资源和新的Etag,否则返回304状态码。
  • Connection:设置为Keep-Alive可以告诉客户端本次HTTP请求结束后不需要关闭TCP连接,方便下次HTTP请求使用相同的TCP连接。

响应报文

返回的信息:HTML/CSS/JS/json…

HTTPS

  • HTTP是明文传输的。HTTPS为HTTP的内容进行加密:HTTP + SSL/TLS
  • 对称加密需要提前协商密钥,但是协商密钥的过程可能会被发现,密钥可能会泄露。
  • 使用非对称加密无法防范中间人攻击:非对称加密需要通信双方生成密钥对并且交换公钥,中间人在交换公钥的过程中可以截获通信一方的公钥,将自己的公钥发给另一方。这样,中间人可以用自己的私钥对通信双方的通信内容进行解密,同时生成假消息发送给通信双方。此时问题在于通信双方如何确认对方的身份,如何确定发送的消息没有经过中间人篡改。并且,非对称加密的性能消耗高,一直使用非对称加密会降低应用的性能表现。
  • 确定消息没有经过篡改:摘要算法(数字签名,单向哈希)
    • 甲方对消息明文使用单向哈希算法生成摘要,再使用自己的私钥对摘要进行加密,得到一个数字签名。
    • 乙方对消息明文使用相同的单向哈希算法生成摘要,再使用甲方的公钥对数字签名进行解密,得到又一个摘要。如果这两个摘要是相等的,说明接收到的消息没有受到中间人篡改。
    • 单向哈希算法可以进行协商。
    • 但是,如果中间人彻底冒充了通信双方,即甲乙双方持有的是中间人的公钥,中间人持有甲乙双方的公钥,使用摘要算法也无法阻止消息被窃取和篡改:甲方持有的是中间人的公钥,因此无论是使用自己的私钥进行加密,还是用获得的公钥(中间人的公钥)进行加密,中间人都可以完成对消息内容的解密。不仅如此,因为乙方持有的也是中间人的公钥,所以中间人可以对消息内容进行篡改,然后用相同的单向哈希算法和他自己的私钥伪造数字签名再发送给乙方。乙方无法判断它在和甲方还是中间人通信。
    • 还需要一种机制,证明通信双方没有被冒充,防止公钥被替换
  • 解决方案:证书中心(Certificate Authority,CA)
    • 让证书中心使用证书中心的私钥(保证了证书的来源是CA)对服务端的公钥和关于服务端的一些基本信息进行加密,生成数字证书。证书的内容包括证书颁发机构、服务端网址、用CA私钥加密后的服务端公钥,用CA私钥加密后的证书签名,证书签名是对服务端网址等服务端基本信息使用单向哈希算法生成的签名。
    • 在通信双方的公钥交换阶段,服务端直接返回证书,客户端收到证书后对证书的真伪进行验证。各大浏览器和OS已经维护了所有权威的证书机构的名称和公钥(至于非权威的,需要进行下载。。。比如12306),因此可以从本地找到对应的机构公钥,解密出证书签名。回想一下,这个证书签名是使用单向哈希算法对服务端的一些基本信息(比如服务端的域名)生成签名(摘要)得到的,因此客户端只需要使用同样的信息和同样的单向哈希算法计算出这个证书签名,并与解密得到的证书签名进行比对就可以确认证书的真伪了。
    • 客户端确认了公钥的来源,就可以用CA的公钥解密得到服务端的公钥,并使用服务端的公钥加密客户端生成的对称加密密钥,发送给服务端。
    • 最后,服务端得到了客户端生成的对称加密密钥,可以使用对称加密进行通信。
    • 在这个通信过程中,由于数字证书中的证书签名是使用CA的私钥进行加密的,中间人无法使用自己的相关信息生成一个假的证书签名,因为中间人没有CA的私钥。
      • 为什么需要证书签名?
        • 首先数字证书中的服务端网址是一个凭证:证明这个返回的数字证书确实是由客户端想要通信的服务端发回来的,公钥也的确是这个服务端的公钥
        • 那么怎么验证这个凭证的真伪?中间人偷偷把CA加密的服务端公钥换成自己的怎么办?

HTTP长连接

  • 在HTTP/1.0中默认使用短连接。也就是说,客户端和服务器每进行一次HTTP操作,就建立一次连接,任务结束就中断连接。当客户端浏览器访问的某个HTML或其他类型的Web页中包含有其他的Web资源(如JavaScript文件、图像文件、CSS文件等),每遇到这样一个Web资源,浏览器就会重新建立一个HTTP会话。

  • 而从HTTP/1.1起,默认使用长连接,用以保持连接特性。使用长连接的HTTP协议,会在响应头加入Connection: keep-alive

  • 在使用长连接的情况下,当一个网页打开完成后,客户端和服务器之间用于传输HTTP数据的TCP连接不会关闭,客户端再次访问这个服务器时,会继续使用这一条已经建立的连接。Keep-Alive不会永久保持连接,它有一个保持时间,可以在不同的服务器软件(如Apache)中设定这个时间。实现长连接需要客户端和服务端都支持长连接。HTTP协议的长连接和短连接,实质上是TCP协议的长连接和短连接。

  • TCP长连接:

    • 理论上,TCP连接可以一直保持下去。但是需要一种机制判断当前连接是否具有通信能力。
    • TCP keepalive 保活机制
      • 如果一段时间(tcp_keepalive_time)内某一连接不活跃,开启保活功能的一端会向对方发送一个保活探测报文。
      • 如果对方正常存活且连接有效,对端会对探测报文进行响应,发送端如果能收到报文则可以判断TCP连接正常,此时重置保活时间计数器。
      • 若由于网络原因或其他原因导致,发送端无法正常收到保活探测报文的响应。那么在一定探测时间间隔(tcp_keepalive_intvl)后,将继续发送保活探测报文。直到收到对端的响应,或者达到配置的探测循环次数上限(tcp_keepalive_probes)都没有收到对端响应,这时对端会被认为不可达,TCP连接随存在但已失效,需要将连接做中断处理。
  • HTTP长连接:

    • 开启keep-alive之后,服务器再响应后不会直接断开TCP连接,而是将TCP连接维持一段时间。在这段时间内,如果同一客户端再次发起HTTP请求,便可以复用此TCP连接向服务端发请求,并重置timeout时间计数器,再接下来一段时间还可以继续服用。如果一段时间后没有发送HTTP请求,则可以关闭TCP连接。
  • 为什么有了TCP keepalive还需要HTTP keep-alive?

    • 如果双方节点和网络不出问题,连接双方不主动释放连接的话,理论上TCP连接可以永远维持下去,而维护大量的TCP连接显然是非常消耗资源的。
    • 但是,什么时候维持连接是合适的,什么时候应该断开连接,TCP协议是不知道的,TCP只知道“能不能维持当前连接”,至于维持和断开连接的时机判断更取决于应用层,不同应用层协议对应的场景不同,可能会有不同的策略。

HTTP 1.0 vs 1.1

  1. HTTP1.0默认使用短连接,不复用TCP连接。HTTP/1.1默认使用长连接。有非流水线方式和流水线方式。流水线:客户端收到HTTP响应报文之前就可以发送新的请求报文。非流水线:一定要收到前一个响应才可以发送下一个请求。
  2. 新增错误状态响应码
  3. 缓存处理:更多可选的缓存头
  4. 带宽优化和网络连接的使用:range请求头,只请求资源的某个部分,返回码是206.

HTTP 2.0

  1. Server Push: 在clinet请求之前server就将资源发送给client。例如在clinet请求index.html时将js和css文件一并发送。

HTTP3.0/QUIC协议/能不能用UDP代替TCP

URI vs URL

  • URI:资源的唯一标识
  • URL:资源的唯一定位符,一种具体的URI,提供访问该资源的方式(即协议)

如何保存用户状态?

Cookie - Session

  • Session:在服务端记录用户状态。那么当一个连接到来时,服务器如何判断这个连接对应的是哪一个用户?-> 给客户端一个对应Session的Session ID,让客户端后续发送请求时携带这个Session ID。
  • 客户端要怎么保存这个Session ID -> 存放在Cookie中。
  • 不要再Cookie中保存敏感用户信息。

Some Random Shit

TCP的传输成功和失败是否对编程者可感知?

  • TCP编程接口的调用似乎只能让调用者知道”从用户缓冲区copy到内核缓冲区再copy到网络设备上是否成功”。对于这个数据报是否顺利到达似乎是不可知的。(比如是一次就发到并顺利收到Client的ACK,还是没发到Client/发到了Client但是Client的ACK丢了重传一万次直到传送成功/Timeout)。。
  • 个人猜想:对于后一种情况,触发超时后TCP连接会被关闭(或者重启的客户端会对服务器发出Reset报文让TCP连接关闭)
    • 关闭TCP连接不代表已经发出去的TCP报文段不会重传
  • 于是在应用层编程者需要额外加入一些机制保证应用层协议的可靠性
  • 例如HTTP,假如用户发送一个请求需要修改某些数据,HTTP服务器收到请求后先改数据后发送响应,万一此时网络出现故障,响应可能相当长一段时间无法到达客户端。客户端可能无法知道操作是否发送成功而导致发送重复请求,造成重复操作。
    • 可能的解决方案1:让HTTP服务器收到请求后向客户端发送消息告知已经收到请求,并要求客户端显式地发回一个响应。如果收不到这个响应则不进行操作(或者回滚已经执行的操作) -> 重复套娃,无穷尽也?
    • 可能的解决方案2:在应用层协议层面不进行处理,让使用应用层协议的应用程序通过某些机制保证操作的幂等(完全相同的请求发送多次与发送一次的效果等价,But how?)

腾讯csig智慧供应链:

JVM:

  • 运行时内存模型√
  • 垃圾回收机制:标记算法、回收算法√
  • 类加载机制√
  • 移动到老年代有哪几种情况√

JUC:

  • 线程池怎么实现的,底层有什么数据结构
  • synchronized的底层实现√
  • volatile是什么,跟synchronized的区别,volatile是不是锁√
  • CAS√

网络:

  • 浏览器中输入地址回车会发生什么√
  • TCP/IP协议栈有哪几层√

字节跳动:

Java SE

  • HashMap的实现√
  • 接口跟抽象类的区别(Java 8, default关键字)√

JVM

  • 老年代的垃圾回收出现的时机是什么√

数据库

  • SQL语句
  • 三大范式、BCNF范式√

手撕环节

  • 给定有n个元素一个数组,求和为x的所有可能性 Leetcode 40。(15:13-15:35, 总计用时22分钟)

东方财富

面向对象

  • 抽象 封装 继承 多态√

设计模式

  • 是什么?:解决一类问题的方式√
  • 用过什么?√
  • UML?

网络

  • TCP和UDP的区别,优缺点√
  • ICMP:控制信息
  • TCP为什么要等2MSL√

操作系统

  • 进程间通信的方式√
  • 线程间通信的方式
    • 共享内存
  • IO的几种方式√
  • 进程和线程的区别√
  • 多线程和多进程的区别√
  • 虚拟内存:交换分区√

数据库

  • ACID特性√
  • 索引的数据结构√
  • 怎么用的索引√
  • 表锁和行锁√
  • 内连接和外连接√

数据结构

  • 红黑树
  • 字典树?

JVM

  • 栈内存是什么
  • 值传递 -> 引用类型,引用的复制,引用的地址

Java SE

  • List、Set、Map
  • Java泛型
  • Java内存泄漏?
  • 对象复制:浅拷贝、深拷贝
  • Java native关键字
  • 反射
  • lambda表达式

Spring

  • 是什么
  • SpringMVC
  • Spring Bean

ZOOM

Java SE

  • 多态
  • 重载和重写
  • 静态代码块(static)
  • 静态内部类

多线程

  • 线程池的原理
  • 什么时候新建线程(最大线程数)
  • 线程状态转换
  • 进程中断发生了什么
  • 不响应中断?
    • 什么时候线程不会响应中断?
  • Lock和Synchronized的区别
  • Lock的实现原理
  • volatile了解吗
    • 做了什么(原理?)
    • 防止指令重排
    • volatile读:需要读取内存
    • volatile写:刷新到内存

设计模式

  • 策略模式

JVM

  • 运行时内存模型
  • 堆里有什么
  • CMS
  • JVM调优参数
  • 堆转储
  • Full GC(Major GC)的发生时机

数据库

  • 索引数据结构
    • B+树的数据结构
  • 索引失效?
    • 复合索引
  • 事务默认隔离级别
    • 默认隔离级别
    • 可重复读
  • MVCC
    • 隐藏字段:隐藏主键,上一版本,时间戳

Redis?

  • 如何实现分布式锁

网络

  • HTTP状态码
  • TCP
    • TIME_WAIT
    • CLOSE_WAIT

Spring

  • SpringAOP
    • 实现接口:JDK、不实现接口:CGLIB
  • Bean生命周期
    • Aware接口

SpringBoot

  • 自动装配的原理

JWT

WXG一面凉

  • 算法*3,2个easy,1个hard(二叉树序列化反序列化,其实不算hard)
  • HDFS文件的元信息
  • 跳表寻找第k元素
  • 设计高性能的K-V存储

TEG一面凉

  • HashMap….
  • GC算法:CMS、G1, CMS怎么解决内存碎片问题?
  • ThreadLocal是什么、怎么实现、为什么Key要用弱引用,弱引用什么时候被GC,是YoungGC还是FullGC
  • Java读取一个文件的过程(OS)
  • CountDownLatch -> 多线程重新复习
  • MySQL怎么保证事务的一致性(?)、原子性
  • MySQL执行语句的流程,如果执行过程被意外中断怎么办
  • MySQL查询慢怎么排查->慢查询日志,怎么分析慢查询日志
  • 线上项目怎么保证高可用
  • 一致性哈希,怎么解决雪崩问题
  • SpringBoot有什么用,和Spring、SpringMVC有什么关系
  • Spring怎么解决循环依赖问题

阿里优酷

  • Java HashMap和TreeMap的底层区别
  • Java HashMap和TreeMap的性能比较
  • Spring的IOC
  • MySQL的VARCHAR和CHAR的区别
  • 存储金额一般使用什么类型
  • MySQL日期,为什么有了时间戳还需要Date类型,能不能只留下其中一种?
  • Spring事务传播行为
  • 算法题: 求两个字符串的最长公共子串(要连续的子串)

CSIG一面

  • InnoDB和MyISAM的区别
  • HashMap和HashTable的区别
  • HashTable底层实现
  • 全文索引 是否用过,InnoDB,MyISAM
  • select和epoll的区别,epoll的好处
  • 死锁的四个必要条件
  • 死锁的解决办法
  • Redis不同类型的底层数据结构,Redis的K-V存储实现
  • Redis和memcache的区别
  • Redis为什么要用跳表而不用B+树,MySQL为什么用B+树而不用跳表
  • MySQL外键

阿里本地生活

  • 设计模式 -> 设计模式的分类?

美团团好货

*

字节Lark EA

  • 动态代理在Spring中的实现、应用
  • MySQL事务隔离级别
  • MVCC
  • HTTPS过程
  • 算法题: DP两道,第二题是打家劫舍和打家劫舍II

二面

  • 问实习经历
  • TCP为什么要四次挥手
  • DNS协议做什么用的,DNS协议的过程,DNS协议处于哪一层
  • ARP协议的作用,ARP协议处于那一层
  • 做题
    • 概率题:52张牌,抽5张,求同花和同花顺的概率
    • DP:目测难度mediumy

TLA+ ToolBox

项目总述

  • TLA+:TLA意为行为时序逻辑,TLA+则是一种形式化语言,以行为时序逻辑的方式对软件程序、算法或者协议进行高层次的建模,描述系统的行为,帮助开发人员对程序和算法有更清楚的认识,在系统设计阶段避免一些可能出现的错误。(对代码进行测试是一种发现编码错误的手段,但不是一种高效的对系统设计进行检查的手段)
  • TLA+的做法是使用基于状态的方式来描述系统的行为。用对所有状态变量的一次赋值来表示一个系统的全局状态。用状态转移前后状态变量的关系来表现所有可能出现的状态变化。于是系统的可能行为集合就可以被分为两个部分:所有可能的初始状态和所有可能的状态转移方式。
  • TLA+ Toolbox是形式化语言TLA+的集成开发环境,其中包括了模型检查器TLC。用户使用TLA+对系统建立的模型包括了系统的初始状态和所有可能的状态转换,实际上建立了整个系统的状态图。模型检查器TLC可以遍历用户系统对应的状态图,检查每个状态是否能够满足用户定义的一些约束,以此来帮助用户发现系统模型的潜在问题。
  • 个人负责的工作:提供TLC的运行时API,用户使用这些API可以在TLC运行时获取他们关心的一些状态信息,例如当前已经检查的模型深度和当前正在检查的状态。用户还可以使用这些API对运行中的TLC进行一些操作,例如让计算任务暂停,为当前任务创建检查点以便恢复执行等。这些API计划使用JMX进行提供。同时开发云计算功能模块:用户提供云服务提供商的token,TLC可以自动地使用云服务提供商的SDK创建云实例,将TLC主程序和用户编写的模型打包上传到云实例,在云实例上执行计算任务,并在计算任务完成时自动给用户发送邮件并关闭云实例。

实际应用场景

  • …

TLA+可以举个例子吗

  • TLA+ 比较常用的地方是用于精确地对并发系统或分布式算法进行建模

2PC协议的TLA实现

JMX?

  • 定义符合JMX规范的Java对象MBean,将其注册到Java内置的MBean服务器中让MBean服务器对其进行管理,即可暴露一些当前应用的运行时信息,提供一些可读写的字段,提供一些可以在运行时执行的操作。
  • 定义MBean一般是先定义一个XXXMBean接口,在接口中说明所有可以访问的属性和所有可以执行的方法,然后定义XXXMBean的实现类XXX,在实现类中提供实际的数据访问、执行实际的操作。
  • MBean的名称规范(ObjectName): 至少要包括一个域(domain)和一系列的键值对。Domain一般可以使用包名。
  • MXBean: 只会包含预定义的数据类型,因此任何客户端(包括远程客户端)都可以使用,而不需要引用特定的自定义类型。MXBean接口不要求实现类名与接口名有对应关系。
  • 如果要在MXBean里使用复杂类型,复杂类型的数据会被映射为简单类型。

项目实现细节

模型检查器的基本原理

  • 本质上,用户所编写的模型规定了系统的初始状态和所有可能的状态转换,相当于定义了一个状态图。模型检查器的原理大致是对这个状态图进行广度优先遍历,并检查每个状态是否出现不满足Safety属性。为了实现广度优先遍历,TLC需要维护2个数据结构:一个记录所有已经检查过的状态的集合,一个用于实现广度优先遍历的队列。TLC可以开启多个worker线程,worker线程对已检查过的状态集和状态队列的独写使用ReadWriteLock来保证线程安全。
  • 系统的一次完整执行称为一次系统的行为。而系统的一次完整执行通常是可以用一个条件来定义的(某些事情总要发生,Liveness属性)。如果一个执行trace一次都没有触发过termination,说明检查到了违反liveness属性的执行序列。

集合怎么实现?

队列怎么实现?

  • 队列实现为一个硬盘上的文件,开头和结尾保留一定量的元素在内存中。使用了专有的线程对队列开头和结尾的缓冲区进行预载。

提供什么运行时API?

  • 启动与暂停:
    • 暂停:设置队列的标志变量stop,worker线程调用入队列或出队列的方法使用synchornized关键字修饰,调用时必须检查标志变量的值,如果发现stop = true则会调用wait方法让自己挂起。
    • 重启:修改stop为false,并且调用notifyAll唤醒所有在队列上等待的线程
  • 单步调试:
    • 使用模型检查器的内部类解析用户编写的模型,生成所有的初始状态,让用户自行挑选下一状态并单步观察状态的变化。

云计算模块的基本原理

  • 让用户选择云服务的提供商并设置必要的认证凭据(ID, SECRET),云计算模块负责自动创建特定规格的实例,并通过SSH连接到云实例上,完成云实例的准备并下载运行所需要的依赖(例如JDK)。同时云计算模块会负责将用户在本地创建的模型文件与模型检查器的可执行jar文件打包上传到云实例上。模型检查器执行完毕后首先将执行结果的输出文件使用邮件发送到用户提供的邮件地址。
  • 主要负责为该模块添加阿里云支持,项目原本使用了jclouds屏蔽不同云服务提供商的接口差异,但是jclouds并未在正式版中提供阿里云支持,因此计划是尝试将阿里云的SDK进行包装和适配,尽可能地复用原有的代码。
  • 怎么发送邮件
    • 让用户提供邮件地址,可以通过邮件地址的@后的域名查询邮件地址对应的SMTP服务器域名。DNS服务器会返回对应的MX记录
    • 至于怎么知道哪台服务器才存放这个MX记录,我觉得只需要和查找A记录一样,先找到@后的域名所在的服务器就可以找到对应的MX记录了。
    • 或者可以使用阿里云的邮件推送服务
  • JVM调优:-XX:+UseParallelGC,使用

Nginx学习笔记

是什么?

  • 一个TCP/UDP的通用服务器和反向代理服务器,可以支持HTTP、HTTPS、SMTP等协议。
  • 可以实现负载均衡和URL重写
  • nginx有一个master进程和多个worker进程。master进程的主要目的是读取并应用配置文件,并维护worker进程。worker进程会负责实际的请求处理。

为什么要反向代理

  1. 保护了真实的Web服务器,外网只能看到反向代理服务器,而反向代理服务器上没有真实数据。
  2. 可以减少Web服务器压力。
  3. 请求的统一控制,包括权限控制和过滤规则等。
  4. 区分动态和静态可缓存的内容。
  5. 负载均衡
  6. 解决跨域问题。反向代理不是单纯的请求转发,而是会代理用户向代理下的节点服务器重新发起请求。
  7. 作为真实服务器的缓冲。

配置文件的结构

nginx由多个模块组成,而这些模块由配置文件中的指令进行控制。指令被分为简单指令(单纯的一条指令,分号结尾)和块级指令(由花括号{}包裹)`。如果一个块级指令能够将其它指令包裹在它的花括号中,那么他被称为一个上下文(context)。

基本操作

安装

在CentOS 7上安装nginx

1
sudo yum install yum-utils

创建/etc/yum.repos.d/nginx.repo文件,文件内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[nginx-stable]
name=nginx stable repo
baseurl=http://nginx.org/packages/centos/$releasever/$basearch/
gpgcheck=1
enabled=1
gpgkey=https://nginx.org/keys/nginx_signing.key
module_hotfixes=true

[nginx-mainline]
name=nginx mainline repo
baseurl=http://nginx.org/packages/mainline/centos/$releasever/$basearch/
gpgcheck=1
enabled=0
gpgkey=https://nginx.org/keys/nginx_signing.key
module_hotfixes=true

默认情况下,nginx packages使用nginx-stablerepository。如果要切换到mainline,则运行以下命令:

1
sudo yum-config-manager --enable nginx-mainline

随后,安装nginx

1
sudo yum install -y nginx

启动

1
nginx

使用-s参数控制nginx

1
ngingx -s SIGNAL
  • stop: 快速关闭
  • quit: graceful shutdown,会等待worker进程完成当前请求的处理。
  • reload: 重新加载配置文件,不必重启就可以让配置生效
    • 当master接收到这一信号时,它会检查新配置文件的语法正确性,并应用这一配置。如果这一步成功,master会启动新的worker进程并向旧的worker进程发送消息让他们关闭。否则,master会回滚更改并继续使用旧的配置来工作。旧的进程在接受到shutdown信息后会停止接受新请求,将当前的请求处理完后才退出。
  • reopen: 重新打开日志文件

提供静态内容

在配置文件中的http块级指令中编写server块级指令。在server中加入location块级指令。

1
2
3
4
5
6
7
8
9
http {
server {
# 定义URI匹配规则(最长匹配)
location / {
# 静态内容的根目录
root /data/www;
}
}
}

配置代理服务器

nginx的常见应用之一就是作为代理服务器,接受请求并将其转发给被代理的服务器,从被代理的服务器获取响应,然后将响应发送给客户端。配置方法是在location块级指令中加入proxy_pass指令。location指定的URI匹配可以编写正则表达式来表示更灵活的匹配规则,正则表达式以~开头

1
2
3
4
5
6
7
8
server {
location / {
proxy_pass http://localhost:8080;
}
location ~ \.(gif|jpg|png)$ {
root /data/images;
}
}

sendfile设置

sendfile on。高效文件传输模式(?),将tcp_nopush和tcp_nodelay设置为on防止网络阻塞(??)

配置负载均衡策略

  • 用于从upstream模块定义的上游服务器列表中选取一台服务器接受用户的请求。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    http {
    upstream myServer {
    server xxx.xxx.xxx.xxx:yyyy;
    server yyy.yyy.yyy.yyy:zzzz;
    server zzz.zzz.zzz.zzz:xxxx [upstream基本参数];
    }

    server {
    listen [端口];
    server_name [监听地址];
    location [正则] {
    root [根目录];
    index [默认页];
    proxy_pass myServer; # 将请求转发到myServer定义的服务器列表
    deny [拒绝的IP];
    allow [允许的IP];
    }
    }
    }

upstream基本参数

  • 对每台服务器的一些配置
  • fail_timeout, max_fails: 如果在fail_timeout时间内发往该服务器的请求失败了max_fail次,认为该服务器已经停机
  • fail_time:服务器被认为停机的时间长度
  • backup: 标记该服务器为备用服务器,出现停机时请求会发送到此处(hash、ip_hash和random负载均衡方法下该参数不可用)
  • down: 标记服务器永久停机
  • max_conns: 限制该服务器的最大同时活动连接数

轮询

上述的例子就是轮询负载均衡。轮询是upstream模块默认的负载均衡策略。每个请求按照时间顺序逐一分配到不同的服务器。

  • Nginx默认就是使用一个加权的Round-Robin负载均衡方法。

权重

为每台服务器配置权重,在轮询时为权重高的后端服务器分配更多请求。

  • a = 1, b = 2, c = 3
  • 加权轮询的顺序可能是{b, c, b, c, a, c}。这个加权序列应该尽量让相同的节点尽量分散。

ip_hash

每个请求按访问ip的hash结果分配,每个客户端固定访问一个后端服务器。可以使用该策略来实现粘性session。
如果对应的服务器不可用则会转发到其他服务器。

hash

可以指定key进行hash,进而为请求分配指定的服务器。

1
2
3
4
5
6
upstream hashups {
# ip_hash;
hash [key] [consistent]; # 加入consistent参数使用一致性哈希
server ...;
server ...;
}

Nginx限流

控制单个IP的请求处理频率

  • ngx_http_limit_req_module
  • 定义内存区用于存储IP地址访问信息,设置rate=xxr/s可以设置每秒只处理每个IP地址的xx个请求
  • 控制突发流量: burst=xx
  • 控制单个IP的请求处理频率。

控制并发连接数

  • ngx_http_limit_conn_module

限流的原理

漏桶算法

桶的大小固定,请求依序进入桶中,但是进入桶中的请求只会以恒定的速率被处理(漏水的速率恒定)。如果进入桶中的请求溢出桶外,则这部分请求不会被处理。

令牌桶算法

桶的大小固定,按固定的速度往桶中丢令牌,桶满之后后续的令牌都无法添加。取令牌时,桶中有令牌,则处理取令牌操作并处理真正的请求。否则取令牌操作将被拒绝,请求也不会被处理。
请求在队列中进行排队取令牌,无法取得令牌时请求不会被处理,需要进行等待或直接拒绝请求。
令牌桶算法允许一定程度的突发请求。

原理

Nginx为什么快?

  • 多进程 + IO多路复用。单个进程处理请求遇到再次需要进行IO的情况时,会直接向多路复用器增加新的事件,然后继续处理别的请求。
  • Nginx也使用反应器模式,主事件循环等待操作系统发出IO事件准备完成的信号,这样数据就可以从Socket读取到缓冲区。

反向代理流程

  1. 用户通过域名发出访问Web服务器的请求,该域名被DNS服务器解析为反向代理服务器的IP地址。
  2. 反向代理服务器接收用户的请求。
  3. 反向代理服务器在本地缓存中查找请求的内容,找到后直接把内容发给用户。
  4. 如果本地缓存中没有用户所请求的内容,反向代理服务器代替用户向源服务器请求同样的信息内容,并把信息内容发给用户。如果信息内容是缓存的,还会把它存到缓存中。

Nginx请求处理

通过配置文件中配置的规则将本次请求映射到一个location block。Location中配置的各个指令启动不同的模块去完成工作。

多进程模型

  • 一个Master进程,多个Worker进程。
  • Master进程根据配置建立需要listen的网络socket文件描述符,然后fork出多个worker进程。同时Master还负责在不停止的情况下加载配置文件。
  • Worker进程会使用epoll监听同一个Socket fd。连接到来时对所有worker进程来说socket fd都可读。此时为保证只有一个进程处理该事件,worker进程需要抢占互斥锁accept_mutex,只有抢到互斥锁的进程才会调用accept接受链接。

核心概念

Why Docker?

Docker镜像(Images)

基本概念

一个镜像是一个只读的模板,它包含了创建一个Docker容器的命令。要创建一个新的Docker镜像,编写Dockerfile然后build即可。
当运行一个Docker容器时,容器使用一个隔离的文件系统。这一文件系统由容器的镜像来提供。因为镜像包括了容器的文件系统,所以镜像必须包括运行一个应用所需要的所有东西,包括依赖、配置项、脚本、二进制文件等。镜像也包括容器的其它配置,比如环境变量,一个用于运行的默认命令,以及其它元信息。

层(layer)的概念

在Dockerfile的每条命令都会为镜像创建一个层(Layer)。当修改Dockerfile并rebuild一个镜像时,只有被修改的层需要rebuild。

Docker容器(Container)

基本概念

一个容器是一个镜像的可运行实例,是一个被松散地隔离开的进程。可以通过Docker CLI或Docker API来进行创建(create)、运行(start)、停止(stop)、移动(move)或删除(delete)。默认情况下,一个容器和其它容器以及宿主机是隔离的。一个容器由用于创建它的镜像以及创建时或运行时提供的配置选项来定义。

常用命令及工作流程

Docker镜像加速

对于Windows 10,在Docker Desktop里修改registry-mirrors即可。阿里云会提供Docker镜像加速服务(容器镜像服务)。

镜像管理

Build an Image

在Dockerfile的同一目录下运行docker build命令

1
docker build [-t IMAGE_NAME] [DOCKERFILE_DIRECTORY]

查看所有的Image

1
docker image ls

容器管理

Start a Container

1
2
3
4
5
docker run [OPTIONS] IMAGE [COMMAND] [ARGS]

# -p: 端口映射
# -d: detached mode
docker run -dp 3000:3000 getting-started

查看Container运行情况

1
docker ps

Stop a Container

1
docker stop CONTAINER_ID

Remove a Container

1
2
3
docker rm CONTAINER_ID
# Remove a running container
docker rm -f CONTAINER_ID

Docker容器卷(Container Volumes)

每个容器都持有自己的暂存空间来创建、存储、修改和删除文件,任何容器内的改变都不会影响其它容器。一般情况下容器被删除时容器内所有文件都不复存在。可以使用容器卷(Container Volumes)来让容器的特定系统路径连接回宿主机。如果一个容器内的目录被挂载,在这一目录中的修改可以在宿主机上观察到。同时如果我们在容器重启时挂载相同的目录,就能在容器中看到相同的文件。

具名容器卷(Named Volume)

可以认为这是一个数据桶,Docker会负责维护硬盘上的物理位置,用户只需要知道这个数据桶的名称,只要提供这个名称就能得到对应位置的正确数据。

创建卷

1
2
docker volume create VOLUME_NAME
# docker volume create todo-db

为容器挂载卷

1
2
3
4
docker run -v VOLUME_NAME:DIRECTORY IMAGE
# VOLUME_NAME: 卷名
# DIRECOTRY: 将卷挂在到容器内的指定目录
# docker run -dp 3000:3000 -v todo-db:/etc/todos getting-started

查看所有卷

1
docker volume ls

查看指定卷的详细信息

1
2
docker volume inspect VOLUME_NAME
# VOLUME_NAME: 卷名,可以通过docker volume ls找到

其中Mountpoint是数据在硬盘上存储的实际位置。

1
2
3
4
5
6
7
8
9
10
11
[
{
"CreatedAt": "2020-12-25T15:31:19+08:00",
"Driver": "local",
"Labels": {},
"Mountpoint": "/var/lib/docker/volumes/todo-db/_data",
"Name": "todo-db",
"Options": {},
"Scope": "local"
}
]

绑定挂载(Bind Mount)

使用绑定挂载可以控制宿主机上的具体挂载点,bind mount通常用于为容器内的应用提供额外数据。bind mount不支持容器内容写入宿主目录,也不支持volume driver。
应用场景:在本地设置开发环境。开发机器不需要安装所有的构建工具和环境,而是可以通过Docker构建一个可以分发的开发环境并分发给开发人员(包括数据库开发环境也可以进行分发,但是数据库开发环境应该使用named volume)。

Docker网络配置

容器之间的通信

只要容器处于同一个Docker网络内,就可以进行通信。

创建网络

1
2
3
4
5
6
docker network create [ARGS] NETWORK_NAME
# 可以在ARGS中提供各种参数
# --driver=bridge 指定网络类型
# --subnet=192.168.6.0/24 指定子网划分
# --ip-range=192.168.6.0/24 指定IP范围
# --gateway=192.168.6.1 指定网关, 可以不提供

查看当前Docker网络

  • 如果要查看当前所有的网络
    1
    2
    3
    docker network ls
    # 大概会出现四栏的表格:
    # NETWORK ID NAME DRIVER SCOPE
  • 如果要查看某个网络的详细情况(子网、默认网关等),可以使用inspect命令。可以看到连接到某一网络的所有容器信息,包括它们各自的IP地址。
    1
    docker network inspect [NETWORK ID | NAME]
  • 节选一段返回信息。如果不想让Docker容器直接通过iptables进行端口映射,或者iptables的端口映射出现问题,可以通过使用nginx反向代理等手段,让请求直接转发到 容器IP:端口 上
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    [
    {
    "Name": "bridge",
    "Id": "ca18bdeb0aea357f983a90012ce2245670597495d3bc824f4d86f2cf81f75800",
    "Created": "2020-12-24T21:53:47.388294816+08:00",
    "Scope": "local",
    "Driver": "bridge",
    "EnableIPv6": false,
    "IPAM": {
    "Driver": "default",
    "Options": null,
    "Config": [
    {
    "Subnet": "192.168.5.0/24",
    "Gateway": "192.168.5.1",
    "AuxiliaryAddresses": {
    "DefaultGatewayIPv4": "192.168.5.2"
    }
    }
    ]
    },
    "Internal": false,
    "Attachable": false,
    "Ingress": false,
    "ConfigFrom": {
    "Network": ""
    },
    "ConfigOnly": false,
    "Containers": {
    "a41a18d43c71e52843f07683d1f672e241afaa922510c46934da2bcd71caaf1e": {
    "Name": "vigilant_jang",
    "EndpointID": "fe6dcd7083dd9bc8484e77b96ecf7bb82b241d4d0b2069e7012a315fc09b48ef",
    "MacAddress": "02:42:c0:a8:05:03",
    "IPv4Address": "192.168.5.3/24",
    "IPv6Address": ""
    },
    "b484dc156df6f30de8f4c13fff4a8cfbe31177a475d66f69c6efb16dfa2af8f5": {
    "Name": "hardcore_goldberg",
    "EndpointID": "50a6883e77447d0e21765f42daacbcaa0f435eea558e5d670ff785bec75e97da",
    "MacAddress": "02:42:c0:a8:05:04",
    "IPv4Address": "192.168.5.4/24",
    "IPv6Address": ""
    }
    },
    "Options": {
    "com.docker.network.bridge.default_bridge": "true",
    "com.docker.network.bridge.enable_icc": "true",
    "com.docker.network.bridge.enable_ip_masquerade": "true",
    "com.docker.network.bridge.host_binding_ipv4": "0.0.0.0",
    "com.docker.network.bridge.name": "docker0",
    "com.docker.network.driver.mtu": "1500"
    },
    "Labels": {}
    }
    ]

在启动时让容器连接到指定网络

在执行docer run时提供--network参数即可。还可以提供--network-alias来为该容器在该网络上指定host名称,其他容器可以使用Docker提供的DNS服务,使用这一主机名来与该容器通信。

1
2
3
4
5
6
docker run -d \
--network todo-app --network-alias mysql \
-v todo-mysql-data:/var/lib/mysql \
-e MYSQL_ROOT_PASSWORD=secret \
-e MYSQL_DATABASE=todos \
mysql:5.7

Sort
164 基数排序+桶排序
179 自定义的比较规则
324 摆动排序
767 相邻不相同的排序
969 煎饼排序
912 手撕堆排序

DFS
85
114 二叉树展开为链表
207 课程表(拓扑排序)
542 01矩阵(BFS + DP):为什么可以只保留左上和右下?
980 不同路径III:如何DP?

BFS
127 BFS怎么加速?

二分查找
模板1注意点:等号取不取?
4
33 搜索旋转排序数组
162 寻找峰值
222 怎么用二分查找?
287 二分查找是一个比较通用的解法
300 贪心+二分查找
454
658
719

DP
剑指19
96
300

哈希表
710

位运算
136

剑指
35
33
38
41
43
45
49