chrome架构
问题1 只是打开了 1 个页,chrome启动了4个进程(在浏览器打开第一个页面的时候,且没有其他插件,音频的时候)
进程与线程的特点
- 线程是不能单独存在的,它是由进程来启动和管理的
- 一个进程就是一个程序的运行实例,是操作系统分配资源的最小单元,启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程
- 进程中的任意一线程执行出错,都会导致整个进程的崩溃。
- 线程之间可以共享进程中的数据,线程之间可以对自身进程中的公共数据进行读写操作
- 当一个进程关闭之后,操作系统会回收进程所占用的内存
- 进程之间的内容相互隔离,进程隔离是为保护操作系统中进程互不干扰的技术,每一个进程只能访问自己占有的数据,也就避免出现进程 A 写入数据到进程 B 的情况。正是因为进程之间的数据是严格隔离的,所以一个进程如果崩溃了,或者挂起了,是不会影响到其他进程的。如果进程之间需要进行数据的通信,这时候,就需要使用用于进程间通信(IPC)的机制了。(前端框架Electron)
现代化的浏览器进程架构
浏览器进程。 主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
渲染进程。 核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。
**GPU 进程。**其实,Chrome 刚开始发布的时候是没有 GPU 进程的。而 GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程。
**网络进程。**主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。
**插件进程。**主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。
因此打开 1 个页面至少需要 1 个网络进程、1 个浏览器进程、1 个 GPU 进程以及 1 个渲染进程,共 4 个;如果打开的页面有运行插件的话,还需要再加上 1 个插件进程。
process-per-site-instance策略
通常情况下是一个页面使用一个进程,但是,有一种情况,叫"同一站点(same-site)",具体地讲,我们将“同一站点”定义为根域名(例如,geekbang.org)加上协议(例如,https:// 或者http://),还包含了该根域名下的所有子域名和不同的端口,比如下面这三个:
1 | https://time.geekbang.org |
都是属于同一站点,因为它们的协议都是https,而根域名也都是geekbang.org。你也许了解同源策略,但是同一站点和同源策略还是存在一些不同地方,在这里你需要了解它们不是同一件事就行了。
Chrome的默认策略是,每个标签对应一个渲染进程。但是如果从一个页面打开了新页面,而新页面和当前页面属于同一站点时,那么新页面会复用父页面的渲染进程。官方把这个默认策略叫process-per-site-instance。
直白的讲,就是如果几个页面符合同一站点,那么他们将被分配到一个渲染进程里面去。
所以,这种情况下,一个页面崩溃了,会导致同一站点的页面同时崩溃,因为他们使用了同一个渲染进程。
在一个渲染进程里面,他们就会共享JS的执行环境,也就是说A页面可以直接在B页面中执行脚本。因为是同一家的站点,所以是有这个需求的。
iframe
如果页面里有iframe的话,iframe也会运行在单独的进程中,下图是我浏览器任务管理器的一个截图,图中的辅助框架应该就是指页面中的 iframe
标签对应的地址
未来面向服务的架构
为了解决这些问题,在 2016 年,Chrome 官方团队使用“面向服务的架构”(Services Oriented Architecture,简称SOA)的思想设计了新的 Chrome 架构。也就是说 Chrome 整体架构会朝向现代操作系统所采用的“面向服务的架构” 方向发展,原来的各种模块会被重构成独立的服务(Service),每个服务(Service)都可以在独立的进程中运行,访问服务(Service)必须使用定义好的接口,通过 IPC 来通信,从而构建一个更内聚、松耦合、易于维护和扩展的系统,更好实现 Chrome 简单、稳定、高速、安全的目标。Chrome 最终要把 UI、数据库、文件、设备、网络等模块重构为基础服务,类似操作系统底层服务,下面是 Chrome“面向服务的架构”的进程模型图。
浏览器中的HTTP协议
TCP和HTTP的关系,以及TCP三次握手四次挥手等,可以见我博客有关网络的文章。
浏览器的缓存机制
HTTP/1.1定义的 Cache-Control
头用来区分对缓存机制的支持情况, 请求头和响应头都支持这个属性。通过它提供的不同的值来定义缓存策略。
禁止进行缓存 Cache-Control: no-store
缓存中不得存储任何关于客户端请求和服务端响应的内容。每次由客户端发起的请求都会下载完整的响应内容。
强制确认缓存 Cache-Control: no-store
如下头部定义,此方式下,每次有请求发出时,缓存会将此请求发到服务器(该请求应该会带有与本地缓存相关的验证字段),服务器端会验证请求中所描述的缓存是否过期,若未过期(实际就是返回304),则缓存才使用本地缓存副本。
私有缓存和公共缓存 Cache-Control: private/public
“public” 指令表示该响应可以被任何中间人(译者注:比如中间代理、CDN等)缓存。若指定了"public",则一些通常不被中间人缓存的页面(译者注:因为默认是private)(比如 带有HTTP验证信息(帐号密码)的页面 或 某些特定状态码的页面),将会被其缓存。
缓存过期机制 Cache-Control: max-age=资源有效时间(s)
max-age是距离请求发起的时间的秒数。针对应用中那些不会改变的文件,通常可以手动设置一定的时长以保证缓存有效,例如图片、css、js等静态资源。
协商缓存
协商缓存主要涉及请求头设置中的,Etag和 Last-Modified。可以在响应头中设置
1 | etag: 'xxxxxx' |
etag:每个文件有一个,改动文件了就变了,就是个文件hash,每个文件唯一,就像用webpack打包的时候,每个资源都会有这个东西,如: app.js打包后变为 app.xxxx.js,加个唯一hash,也是为了解决缓存问题。
发请求-->本地判断资源是否过期-->过期-->请求服务器-->服务器对比资源是否真的过期-->没过期-->返回304状态码-->客户端使用缓存资源
(如果服务器资源已经过期,服务器会返回200)
浏览器端发起 HTTP 请求流程
构建请求
首先,浏览器构建请求行信息(如下所示),构建好后,浏览器准备发起网络请求。
1 | GET /index.html HTTP1.1 |
查找缓存
在真正发起网络请求之前,浏览器会先在浏览器缓存中查询是否有要请求的文件,具体缓存的操作,上文已经说过了,如果缓存失效或者没缓存,就会进入网络请求过程了。
准备 IP 地址和端口
浏览器会请求 DNS 返回域名对应的 IP。当然浏览器还提供了DNS 数据缓存服务,如果某个域名已经解析过了,那么浏览器会缓存解析的结果,以供下次查询时直接使用,这样也会减少一次网络请求
等待 TCP 队列
Chrome 有个机制,同一个域名同时最多只能建立 6 个 TCP 连接,如果在同一个域名下同时有 10 个请求发生,那么其中 4 个请求会进入排队等待状态,直至进行中的请求完成。
建立 TCP 连接
三次握手
发送 HTTP 请求
一旦建立了 TCP 连接,浏览器就可以和服务器进行通信了。而 HTTP 中的数据正是在这个通信过程中传输的。首先浏览器会向服务器发送请求行,它包括了请求方法、请求 URI(Uniform Resource Identifier)和 HTTP 版本协议。
服务器端处理 HTTP 请求流程
断开连接
四次挥手,通常情况下,一旦服务器向客户端返回了请求数据,它就要关闭 TCP 连接。不过如果浏览器或者服务器在其头信息中加入了Connection:Keep-Alive
。那么 TCP 连接在发送后将仍然保持打开状态,这样浏览器就可以继续通过同一个 TCP 连接发送请求。保持 TCP 连接可以省去下次请求时需要建立连接的时间,提升资源加载速度。比如,一个 Web 页面中内嵌的图片就都来自同一个 Web 站点,如果初始化了一个持久连接,你就可以复用该连接,以请求其他资源,而不需要重新再建立新的 TCP 连接。
浏览器渲染流程
可以参看我的博客 浏览器工作原理~渲染篇 和 浏览器工作原理~优化篇
看完上面的部分,你对浏览器应该已经有了一个大概的了解了,应该也知道从一个网址变成一个网页这些大概是经历了哪些流程。后面我们将介绍,浏览器具体每一块是如何工作的,以及怎么优化页面的显示
浏览器中的JS
这一块我应该只会大概的写一下,想要;了解具体的可以去看看,这个极客时间的专题,或者 GitHub上这个大佬的博客 的一到六节,感觉都是非常好的资料。
V8与JavaScript
前端的小伙伴应该都知道,JS在chrome中是通过V8引擎进行编译的。
JS内模型
对于JS语言本身来说,是一种动态的弱类型语言,意味着我们在定义一个变量时候,不需要告诉解析引擎这个变量的类型是什么,JS引擎在运行代码的时候,引擎自己计算出数据的类型。而且可以使用一个变量来保存不同类型的数据。
JavaScript的数据类型有8种,(基础数据类型是7种)。
对于 Boolean
Null
Undefined
Number
BigInt
String
Symbol
这七种数据基础类型是储存在内存的栈空间中的,而Object
这种引用数据类型是储存在堆空间中的。(此处的栈,和堆,指的是内存空间。注意和方法调用的时候的堆栈区分)。
在函数执行期间,对于储存在栈空间中的基础数据类型变量,是直接被直接赋值到函数的调用栈中,对于堆空间中的引用类型变量,把变量的地址赋值到函数的调用栈中。
所以我觉得 js 本身其实没有,堆内存和栈内存的区别,只有基础数据类型和引用数据类型的区别。
代码的两个阶段
对于JavaScript代码运行,分为代码的创建阶段
和 代码的执行阶段
,最常见的就是变量提升问题。对于一个JS函数。在代码创建阶段会提前把,函数中声明的变量都创建成undefined
值。再在代码的执行阶段,对undefined
进行赋值。
- 代码中的函数变量除外,函数变量都是直接在创建阶段赋值的
- S6没有变量提升的问题,不是因为没有代码的创建阶段,而是因为ES6 引入了一个暂时性死区的机制。
可以看下面那个例子函数 foo
,当代码运行到 var bar = foo()
时候。代码会先创建 myName
test
innerBar
三个变量。
在代码的执行阶段,会依次对这三个变量进行赋值。当代码执行到 return innerBar
的时候,此时三个变量已经赋值完成。
闭包机制
闭包就不介绍了,直接看下面这个函数吧。
1 | function foo() { |
正常逻辑我们再执行完bar = foo()
之后 代码应该执行完毕了。此时,应该无法访问到变量test,和myName了,因为在一个函数执行完了他内部的变量也应该被释放掉。但是根据函数的执行结果可以判断,变量依旧可以被正常的能被访问,这就是典型的闭包机制。
首先产生闭包是在函数的执行阶段,发现了某个子函数对自身变量有引用,就会在堆内存再创建一个闭包对象 Clourse(自身函数名)
,对于这个闭包对象的值,我认为储存的是本身函数调用的变量的指针,对于基础数据类型肯定都是直接复制。
(此处,有文档认为是把内存中的变量都复制了一遍到这个闭包对象中,然后函数运行结束的时候,函数里面的变量都被回收了,但是闭包堆内存没有被回收,因此产生了闭包现象。我没有读过V8源码,但是我觉得代码底层应该不会有这种没有意义的复制)
对于上面的例子也就是,当 foo
函数执行完 var innerBar =
之后。会生成 Closure (foo)
对象,然后在setName
和 getName
这两个对象上都绑定上生成的 Closure (foo)
。
最后在我们执行 bar.setName(" 极客")
和 bar.getName()
的时候 在setName函数的创建过程中,可以看到函数里面已经有了对象 ,Closure (foo)
,然后再在函数的执行阶段重复之前分析的函数执行阶段的赋值操作。
v8的垃圾回收(GC)
首先值得庆幸的是,V8是自动管理垃圾回收的。某个函数执行完成之后,指向该函数的函数指针(ESP)就会指向下一个函数,该函数的执行上下文会从堆内存销毁掉。
V8会把堆分层新生代和老生代 (代际假说),新生代收集器也称副垃圾收集器,老生代也称主垃圾收集器。新生代存放的都是生存时间短的对象,老生代中存放的都是生存时间久的对象。新生代区通常只支持1~8M的容量,老生区支持的容量会大很多。
垃圾收集器工作流程
- 标记空间中的活动对象和非活动对象,根据当前这个对象是否还被引用,也就是是否还在使用进行判断。
- 回收非活动对象所占据的内存。其实就是清理上一步中被标记的可回收对象。
- 内存整理,因为清除完可回收的之后,就好像一整块拼图中,你随机抠掉了几块后,会出现很多不连续的片段,因此为了方便后续程序,使用我们要进行内存整理。
新生代回收过程
新增的对象都会被放在新生代的对象区,然后经历,一标记,二回收,之后整理的时候是将对象区剩下的变量复制到空闲区,这样就得到了,空内存的对象区和有对象且内存连续的空闲区,再把此时的对象区,空闲区身份交换。继续写入新变量进行下一轮GC。(不得不说这里真的很佩服这个垃圾收集器的设计思路,身份交换的想法,能让新生代中的这两块区域无限重复使用下去)
如果有个对象经历了两轮垃圾回收,还在新生区,就会将此对象移入老生区(对象晋升策略)
老生区回收过程
主垃圾收集器,主要采用 标记-清除(Mark-Sweep) 的方式进行垃圾回收。标记清除的过程和之前差不多,但是不同的是,老生区不是通过复制对象来整理内存的,因为老生区内存大,对象多,复制整理会很耗时。老生区是在多次标记之后,将老生区的存活对象,朝着老生区的一段移动。然后直接一次性清除掉其他地方的对象。这一过程被称为 标记-整理 ,下面我画了一个大致流程。(假设按图中可以直接清除左边两列之外的列)
全停顿
因为JavaScript运行在V8的主线程之上,所以一旦执行了垃圾回收算法。都需要将正在执行的JavaScript代码暂停。等待垃圾回收完再执行,这种行为被称作 全停顿(Stop-The-World)
因为新生代本来内存小,变量少GC不会有太大影响。所以为了降低老生代GC造成的卡顿,V8把标记过程分成一个个子标记过程。同时让垃圾回收标记和JavaScript应用逻辑交替进行,直到标记移动完成,感觉这种整块舍弃的思路,清理起来应该挺快。主要耗时应该就是标记和移动(增量标记算法)。
V8的编译期和解释器
因为我们写的是高级语言,而机器只能识别二进制机器码,所以我们需要用解释器和编译器把我们写的代码翻译成机器码。按语言的执行流程,可以把编程语言分为编译期语言和解释型语言。
编译型语言 在程序执行之前,需要经过编译期的编译过程,并且编译之后会直接保留机器能读懂二进制文件,每次运行程序时,都可以直接运行二进制文件,不需要再次重新编译了.(C/C++、GO)
解释型语言 在每次运行时都需要通过解释器对程序进行动态的解释和执行。(Python,JavaScript)
V8是如何执行一段JavaScript代码
V8在执行过程中既有 解释器(lgnition) ,又有 解释器(TurboFan)
生成抽象语法树(AST)和执行上下文
源代码经过词法分析和与分析之后会生成抽象语法树(AST),推荐一个可以生成AST网站
1 | function log(){ |
AST的结构和代码结构非常相似,编译期或者解释器后续的工作依赖于AST,而不是源代码。在JavaScript中最典型的是Babel和ESLint。
Babel将ES6转成ES5代码的过程,就是先将ES6代码转成AST,然后再将ES6语法生成的AST转换成ES5的AST (Babel的代码库里有函数,能把ES6的代码复写成ES5的代码)
ESLint第一阶段是词法分析(tokenize),将一行行的源码拆解成一个个token。(语法上不可以再分的最小字符和字符串),图中 var
myName
=
" 极客时间
这四个都是四个token。
第二个阶段是语法分析(parse),作用是将上一步生成的token数据,根据语法规则转为AST。如果源码符合语法规则,会顺利生成Token,如果源码存在语法错误,这一步就会终止,并抛出一个"语法错误"。成功生成了AST后,V8就会生成该段代码的执行上下文。
生成字节码
解释器lgnition,可以转换成AST生成字节码,并解释执行字节码。字节码是介于AST和机器码之间的一种代码。比机器码占用的内存要少很多,字节码需要通过解释器将其转成机器码才能执行。
(这一段我猜测一下,之前的V8模型可能是,AST转换生成机器码,然后再执行机器码,就会出现机器码被储存在内存中的现象;但是现在是AST转换从字节码,在运行到某个字节码片段时,直接把字节码转成机器码然后执行,这个过程堆积在内存中的只是字节码,机器码一生成就会被消费掉,所以节约了内存)
执行代码
生成代码之后,到了代码的执行阶段。解释器会逐条消费字节码。在执行字节码的过程中,如果有经常被执行的字节码(热点代码 HotSpot)。也会被后台编译器(TurboFan)转换成更高效的机器码,以后再遇到这段代码时,直接运生成的行机器码即可。这种将解释器和编译器结合使用的技术称作即时编译(JIT)
本文是我看了李兵老师极客时间浏览器工作原理的专栏写的总结,文字和图片资料来源与极客时间,不得不说这个专题,作者的工作经历真的丰富。这篇博客大概概括的写了专栏的一至四节,this的指向性没有涉及(因为我觉得在es6的诞生后this的指向已经比较明确了)。