第九周课后练习
一.JVM 垃圾回收原理
1.1 运行时数据区域
JVM 会在执行 Java 程序的过程中把它管理的内存划分为若干个不同的数据区域。这些数据区域各有各的用处,各有各的创建与销毁时间,有的区域随着 JVM 进程的启动而存在,有的区域则依赖用户线程的启动和结束而创建与销毁。一般来说,JVM 所管理的内存将会包含以下几个运行时数据区域:
线程私有区域: 程序计数器、Java 虚拟机栈、本地方法栈
线程共享区域: Java 堆、方法区、运行时常量池
什么是线程私有?
由于 JVM 的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现,因此在任何一个确定的时刻,一个处理器(多核处理器则指的是一个内核)都只会执行一条线程中的指令。因此为了切换线程后能恢复到正确的执行位置,每条线程都需要独立的程序计数器,各条线程之间计数器互不影响,独立存储。我们就把类似这类区域称之为”线程私有”的内存。
程序计数器(线程私有)
程序计数器是一块比较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器。
如果当前的线程执行的是 Java 方法,程序计数器记录的是正在执行的虚拟机字节码指令地址(代码行数),如果执行的是本地 Native 方法,计数器值为空
程序计数器是唯一一个在 JVM 规范中没有 OOM(OutOfMemoryError,内存溢出)出现的区域。
Java 虚拟机栈(线程私有)
描述 Java 方法执行的内存模型:每个方法都会在虚拟机栈中创建一个栈帧用于存储局部变量表、操作数栈、方法出口等信息,每一个方法从调用直至执行完成的过程,就对应一个栈帧在虚拟机栈中入栈和出栈的过程。声明周期与线程相同。
局部变量表: 存放了编译器可知的各种基本数据类型(8 大基本数据类型)、对象引用。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在执行期间不会改变局部变量表大小。
方法的回收不是垃圾回收!!
此区域一共产生两种异常:
如果当前线程请求的栈深度 > 虚拟机栈深度(-Xss,设置栈大小),抛出 StackOverflow 异常
如果虚拟机栈在动态扩展时,无法申请到足够大的内存,抛出 OOM(OutOfMemoryError)异常
本地方法栈(线程私有)
服务的是本地方法 (native 方法),其余与虚拟机栈相同
HotSpot 中将本地方法栈与虚拟机栈合二为一
Java 堆(GC 堆,线程共享,垃圾回收的主要区域)
Java 堆在 JVM 启动时创建,存放的都是对象实例,JVM 要求所有对象实例以及数组都在 Java 堆上存放
Java 堆可以处于物理上不连续的区域,Java 堆一般来说都是可扩展的(-Xms:设置堆初始大小,-Xmx:设置堆最大值)
如果堆中没有足够内存来完成对象实例化并且无法再扩展。抛出 OOM。
方法区(线程共享,JDK1.8 以前称之为永久代,以后称之为元空间)
存储已被 JVM 加载的类信息、常量、静态变量
此区域也会进行垃圾回收,主要是针对常量池的回收以及类型卸载
方法区无法满足内存分配需求时,抛出 OOM
运行时常量池(线程共享,方法区一部分)
存放字面量以及符号引用
字面量:字符串、final 常量、基本数据类型的值
符号引用:类和结构的完全限定名、字段的名称和描述符、方法的名称和描述符
Java 堆溢出
Java 堆用于存储对象实例,只要不断的创建对象,并且保证 GC Roots 到对象之间有可达路径来避免来避免 GC 清除这些对象,那么在对象数量达到最大堆容量后就会产生内存溢出异常。
堆的 OOM 分为两种情况:内存泄漏、内存溢出
内存泄漏:泄漏对象无法被垃圾回收
内存溢出:该对象确实还存活,应该对比物理内存查看当前 Jav 堆是否还应扩容或者缩短对象存活时间
虚拟机栈和本地方法栈溢出
由于我们 HotSpot 虚拟机将虚拟机栈与本地方法栈合二为一,因此对于 HotSpot 来说,栈容量只需要由-Xss 参数来设置。
关于虚拟机栈会产生的两种异常:
如果线程请求的栈深度大于虚拟机所允许的最大深度,会抛出 StackOverFlow 异常
如果虚拟机在拓展栈时无法申请到足够的内存空间,则会抛出 OOM 异常
2. JVM 的垃圾回收机制
Java 堆中存放着几乎所有的对象实例,垃圾回收器在堆进行垃圾回收前,首先要判断这些对象那些还存活,那些已经“死去”。判断对象是否已“死”有如下几种算法:
2.1 引用计数法
引用计数法描述的算法为:给对象增加一个引用计数器,每当有一个地方引用它时,计数器就+1;当引用失效时,计数器就-1;任何时刻计数器为 0 的对象就是不能再被使用的,即对象已“死”。
引用计数法实现简单,判定效率也比较高,在大部分情况下都是一个比较好的算法。比如 Python 语言就是采用的引用计数法来进行内存管理的。
但是,在主流的 JVM 中没有选用引用计数法来管理内存,最主要的原因是引用计数法无法解决对象的循环引用问题。
2.12 可达性分析算法
在上面讲了, Java 并不采用引用计数法来判断对象是否已“死”,而采用“可达性分析”来判断对象是否存活(同样采用此法的还有 C#、Lisp-最早的一门采用动态内存分配的语言)。
此算法的核心思想:通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为“引用链”,当一个对象到 GC Roots 没有任何的引用链相连时(从 GC Roots 到这个对象不可达)时,证明此对象不可用。以下图为例:
对象 Object5 —Object7 之间虽然彼此还有联系,但是它们到 GC Roots 是不可达的,因此它们会被判定为可回收对象。
在 Java 语言中,可作为 GC Roots 的对象包含以下几种:
虚拟机栈(栈帧中的本地变量表)中引用的对象。
方法区中静态属性引用的对象
方法区中常量引用的对象
本地方法栈中(Native 方法)引用的对象
回收方法区
方法区(永久代)的垃圾回收主要收集两部分内容:废弃常量和无用类。
回收废弃常量和回收 Java 堆中的对象十分类似。以常量池中字面量(直接量)的回收为例,假如一个字符串"abc"怡景进入了常量池中,但是当前系统没有任何一个 String 对象引用常量池中的"abc"常量,也没有其他地方引用这个字面量,如果此时发生 GC 并且有必要的话,这个"abc"常量会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。
判定一个类是否是"无用类"则相对复杂很多。类需要同时满足下面三个条件才会被算是"无用的类"
1.该类的所有实例都已经被回收(即在 Java 堆中不存在任何该类的实例)
2.加载该类的 ClassLoader 已被回收
3.该类对应的 Class 对象没有任何其他地方被引用,无法在任何地方通过反射访问该类的方法
JVM 可以对同时满足上述 3 个条件的无用类进行回收,也仅仅是“可以”而不是必然。在大量使用反射、动态代理等场景都需要 JVM 具备类卸载的功能来防止永久代的溢出。
2.3 垃圾回收算法
2.3.1 标记-清除算法
“标记-清除”算法是最基础的收集算法。算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象(标记过程参见 1.2 可达性分析)。后续的收集算法都是基于这种思路并对其不足加以改进而已。
“标记-清除”算法的不足主要有两个:
效率问题:标记和清除这两个过程的效率都不高
空间问题:标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行中需要分配较大对象时,无法找到足够连续内存而不得不提前触发另一次垃圾收集。
2.3.2 复制算法(新生代回收算法)
“复制”算法是为了解决“标记-清除”的效率问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后再把已经使用过的内存区域一次清理掉。这样做的好处是每次都是对整个半区进行内存回收,内存分配时也就不需要考虑内存碎片等的复杂情况,只需要移动堆顶指针,按顺序分配即可。此算法实现简单,运行高效。算法的执行流程如下图:
现在的商用虚拟机(包括 HotSpot)都是采用这种收集算法来回收新生代
新生代中 98%的对象都是"朝生夕死"的,所以并不需要按照 1 : 1 的比例来划分内存空间,而是将内存(新生代内存)分为一块较大的 Eden(伊甸园)空间和两块较小的 Survivor(幸存者)空间,每次使用 Eden 和其中一块 Survivor(两个 Survivor 区域一个称为 From 区,另一个称为 To 区域)。当回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。
当 Survivor 空间不够用时,需要依赖其他内存(老年代)进行分配担保。
HotSpot 默认 Eden 与 Survivor 的大小比例是 8 : 1,也就是说 Eden:Survivor From : Survivor To = 8:1:1。所以每次新生代可用内存空间为整个新生代容量的 90%,而剩下的 10%用来存放回收后存活的对象。
HotSpot 实现的复制算法流程如下:
当 Eden 区满的时候,会触发第一次 Minor gc,把还活着的对象拷贝到 Survivor From 区;当 Eden 区再次出发 Minor gc 的时候,会扫描 Eden 区和 From 区,对两个区域进行垃圾回收,经过这次回收后还存活的对象,则直接复制到 To 区域,并将 Eden 区和 From 区清空。
当后续 Eden 区又发生 Minor gc 的时候,会对 Eden 区和 To 区进行垃圾回收,存活的对象复制到 From 区,并将 Eden 区和 To 区清空
部分对象会在 From 区域和 To 区域中复制来复制去,如此交换 15 次(由 JVM 参数 MaxTenuringThreshold 决定,这个参数默认是 15),最终如果还存活,就存入老年代。
2.3.3 标记整理算法(老年代回收算法)
复制收集算法在对象存活率较高时会进行比较多的复制操作,效率会变低。因此在老年代一般不能使用复制算法。
针对老年代的特点,提出了一种称之为“标记-整理算法”。标记过程仍与“标记-清除”过程一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象向一端移动,然后直接清理掉端边界以外的内存。流程图如下:
分代收集算法
当前 JVM 垃圾收集都采用的是"分代收集(Generational Collection)"算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为几块。
一般是把 Java 堆分为新生代和老年代。在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法;而老年代中对象存活率高、没有额外空间对它进行分配担保,就必须采用"标记-清理"或者"标记-整理"算法。
请问了解 Minor GC 和 Full GC 么,这两种 GC 有什么不一样吗?
Minor GC 又称为新生代 GC : 指的是发生在新生代的垃圾收集。因为 Java 对象大多都具备朝生夕灭的特性,因此 Minor GC(采用复制算法)非常频繁,一般回收速度也比较快。
Full GC 又称为老年代 GC 或者 Major GC : 指发生在老年代的垃圾收集。出现了 Major GC,经常会伴随至少一次的 Minor GC(并非绝对,在 Parallel Scavenge 收集器中就有直接进行 Full GC 的策略选择过程)。Major GC 的速度一般会比 Minor GC 慢 10 倍以上。
二 设计一个秒杀系统,主要的挑战和问题有哪些?核心的架构方案或者思路有哪些?
对现有网站业务造成冲击
秒杀活动只是网站营销的一个附加活动,这个活动具有时间短,并发访问量大的特点,如果和网站原有应用部署在一起,必然会对现有业务造成冲击,稍有不慎可能导致整个网站瘫痪。
解决方案:将秒杀系统独立部署,甚至使用独立域名,使其与网站完全隔离
。
高并发下的应用、数据库负载
用户在秒杀开始前,通过不停刷新浏览器页面以保证不会错过秒杀,这些请求如果按照一般的网站应用架构,访问应用服务器、连接数据库,会对应用服务器和数据库服务器造成负载压力。
解决方案:重新设计秒杀商品页面,不使用网站原来的商品详细页面,页面内容静态化,用户请求不需要经过应用服务
。
突然增加的网络及服务器带宽
假设商品页面大小 200K(主要是商品图片大小),那么需要的网络和服务器带宽是 2G(200K×10000),这些网络带宽是因为秒杀活动新增的,超过网站平时使用的带宽。
解决方案:因为秒杀新增的网络带宽,必须和运营商重新购买或者租借。为了减轻网站服务器的压力,需要将秒杀商品页面缓存在CDN,同样需要和CDN服务商临时租借新增的出口带宽
。
直接下单
秒杀的游戏规则是到了秒杀才能开始对商品下单购买,在此时间点之前,只能浏览商品信息,不能下单。而下单页面也是一个普通的 URL,如果得到这个 URL,不用等到秒杀开始就可以下单了。
解决方案:为了避免用户直接访问下单页面 URL,需要将改 URL 动态化,即使秒杀系统的开发者也无法在秒杀开始前访问下单页面的 URL。办法是在下单页面URL加入由服务器端生成的随机数作为参数,在秒杀开始的时候才能得到
。
如何控制秒杀商品页面购买按钮的点亮
购买按钮只有在秒杀开始的时候才能点亮,在此之前是灰色的。如果该页面是动态生成的,当然可以在服务器端构造响应页面输出,控制该按钮是灰色还 是点亮,但是为了减轻服务器端负载压力,更好地利用 CDN、反向代理等性能优化手段,该页面被设计为静态页面,缓存在 CDN、反向代理服务器上,甚至用户浏览器上。秒杀开始时,用户刷新页面,请求根本不会到达应用服务器。
解决方案:使用 JavaScript 脚本控制,在秒杀商品静态页面中加入一个JavaScript文件引用
,该 JavaScript 文件中包含 秒杀开始标志为否;当秒杀开始的时候生成一个新的 JavaScript 文件(文件名保持不变,只是内容不一样),更新秒杀开始标志为是,加入下单页面的URL及随机数参数(这个随机数只会产生一个,
这个 JavaScript 文件非常小,即使每次浏览器刷新都访问 JavaScript 文件服务器也不会对服务器集群和网络带宽造成太大压力。即所有人看到的URL都是同一个,
服务器端可以用redis这种分布式缓存服务器来保存随机数)
,并被用户浏览器加载,控制秒杀商品页面的展示。这个JavaScript文件的加载可以加上随机版本号(例如xx.js?v=32353823),这样就不会被浏览器、CDN和反向代理服务器缓存
。
如何只允许第一个提交的订单被发送到订单子系统
由于最终能够成功秒杀到商品的用户只有一个,因此需要在用户提交订单时,检查是否已经有订单提交。如果已经有订单提交成功,则需要更新 JavaScript 文件,更新秒杀开始标志为否,购买按钮变灰。事实上,由于最终能够成功提交订单的用户只有一个,为了减轻下单页面服务器的负载压力, 可以控制进入下单页面的入口,只有少数用户能进入下单页面,其他用户直接进入秒杀结束页面。
解决方案:假设下单服务器集群有 10 台服务器,每台服务器只接受最多 10 个下单请求。在还没有人提交订单成功之前,如果一台服务器已经有十单了,而有的一单都没处理,可能出现的用户体验不佳的场景是用户第一次点击购买按钮进入已结束页面,再刷新一下页面,有可能被一单都没有处理的服务器处理,进入了填写订单的页面,可以考虑通过cookie的方式来应对,符合一致性原则
。当然可以采用最少连接的负载均衡算法
,出现上述情况的概率大大降低。
如何进行下单前置检查
下单服务器检查本机已处理的下单请求数目:如果超过 10 条,直接返回已结束页面给用户;
如果未超过 10 条,则用户可进入填写订单及确认页面;检查全局已提交订单数目:
已超过秒杀商品总数,返回已结束页面给用户;
未超过秒杀商品总数,提交到子订单系统;
秒杀一般是定时上架
该功能实现方式很多。不过目前比较好的方式是:提前设定好商品的上架时间,用户可以在前台看到该商品,但是无法点击“立即购买”的按钮。但是需要考虑的是,有人可以绕过前端的限制,直接通过URL的方式发起购买
,这就需要在前台商品页面,以及 bug 页面到后端的数据库,都要进行时钟同步。越在后端控制,安全性越高。
定时秒杀的话,就要避免卖家在秒杀前对商品做编辑带来的不可预期的影响。这种特殊的变更需要多方面评估。一般禁止编辑,如需变更,可以走数据订正多的流程。
减库存的操作
有两种选择,一种是拍下减库存
另外一种是付款减库存
;目前采用的“拍下减库存”
的方式,拍下就是一瞬间的事,对用户体验会好些。
库存会带来“超卖”的问题:售出数量多于库存数量
由于库存并发更新的问题,导致在实际库存已经不足的情况下,库存依然在减,导致卖家的商品卖得件数超过秒杀的预期。方案:采用乐观锁
update auction_auctions set
quantity = #inQuantity#
where auction_id = #itemId# and quantity = #dbQuantity#
还有一种方式,会更好些,叫做尝试扣减库存,扣减库存成功才会进行下单逻辑:
update auction_auctions set
quantity = quantity-#count#
where auction_id = #itemId# and quantity >= #count#
11.秒杀器的应对
秒杀器一般下单个购买及其迅速,根据购买记录可以甄别出一部分。可以通过校验码达到一定的方法,这就要求校验码足够安全,不被破解,采用的方式有:秒杀专用验证码,电视公布验证码,秒杀答题
。
秒杀架构原则
尽量将请求拦截在系统上游
传统秒杀系统之所以挂,请求都压倒了后端数据层,数据读写锁冲突严重,并发高响应慢,几乎所有请求都超时,流量虽大,下单成功的有效流量甚小【一趟火车其实只有 2000 张票,200w 个人来买,基本没有人能买成功,请求有效率为 0】。
读多写少的常用多使用缓存
这是一个典型的读多写少
的应用场景【一趟火车其实只有 2000 张票,200w 个人来买,最多 2000 个人下单成功,其他人都是查询库存,写比例只有 0.1%,读比例占 99.9%】,非常适合使用缓存
。
秒杀架构设计
秒杀系统为秒杀而设计,不同于一般的网购行为,参与秒杀活动的用户更关心的是如何能快速刷新商品页面,在秒杀开始的时候抢先进入下单页面,而不是商品详情等用户体验细节,因此秒杀系统的页面设计应尽可能简单。
商品页面中的购买按钮只有在秒杀活动开始的时候才变亮,在此之前及秒杀商品卖出后,该按钮都是灰色的,不可以点击。
下单表单也尽可能简单,购买数量只能是一个且不可以修改,送货地址和付款方式都使用用户默认设置,没有默认也可以不填,允许等订单提交后修改;
只有第一个提交的订单发送给网站的订单子系统,其余用户提交订单后只能看到秒杀结束页面。
要做一个这样的秒杀系统,业务会分为两个阶段,第一个阶段是秒杀开始前某个时间到秒杀开始
, 这个阶段可以称之为准备阶段
,
用户在准备阶段等待秒杀; 第二个阶段就是秒杀开始到所有参与秒杀的用户获得秒杀结果
, 这个就称为秒杀阶段
吧。
前端层设计
首先要有一个展示秒杀商品的页面, 在这个页面上做一个秒杀活动开始的倒计时, 在准备阶段内用户会陆续打开这个秒杀的页面, 并且可能不停的刷新页面
。
这里需要考虑两个问题:
第一个是秒杀页面的展示
我们知道一个 html 页面还是比较大的,即使做了压缩,http头和内容的大小也可能高达数十K,加上其他的css, js,图片等资源
,如果同时有几千万人参与一个商品的抢购,一般机房带宽也就只有 1G~10G,网络带宽就极有可能成为瓶颈
,所以这个页面上各类静态资源首先应分开存放,
然后放到cdn节点上分散压力
,由于 CDN 节点遍布全国各地,能缓冲掉绝大部分的压力,而且还比机房带宽便宜。
2.第二个是倒计时
出于性能原因这个一般由 js 调用客户端本地时间,就有可能出现客户端时钟与服务器时钟不一致,另外服务器之间也是有可能出现时钟不一致。客户端与服务器时钟不一致可以采用客户端定时和服务器同步时间
,这里考虑一下性能问题,用于同步时间的接口由于不涉及到后端逻辑,只需要将当前web服务器的时间发送给客户端就可以了,因此速度很快
,就我以前测试的结果来看,一台标准的 web 服务器 2W+QPS 不会有问题,如果 100W 人同时刷,100W QPS 也只需要 50 台 web,一台硬件 LB 就可以了~,并且 web 服务器群是可以很容易的横向扩展的(LB+DNS 轮询),这个接口可以只返回一小段 json 格式的数据,而且可以优化一下减少不必要 cookie 和其他 http 头的信息,所以数据量不会很大,一般来说网络不会成为瓶颈,即使成为瓶颈也可以考虑多机房专线连通,加智能DNS的解决方案
;web 服务器之间时间不同步可以采用统一时间服务器的方式,比如每隔1分钟所有参与秒杀活动的web服务器就与时间服务器做一次时间同步
。
浏览器层请求拦截
(1)产品层面,用户点击“查询”或者“购票”后,按钮置灰,禁止用户重复提交请求;
(2)JS 层面,限制用户在 x 秒之内只能提交一次请求;
站点层设计
前端层的请求拦截,只能拦住小白用户(不过这是 99%的用户哟),高端的程序员根本不吃这一套,写个 for 循环,直接调用你后端的 http 请求,怎么整?
(1)同一个uid,限制访问频度
,做页面缓存,x 秒内到达站点层的请求,均返回同一页面
(2)同一个item的查询,例如手机车次
,做页面缓存,x 秒内到达站点层的请求,均返回同一页面
如此限流,又有 99%的流量会被拦截在站点层。
服务层设计
站点层的请求拦截,只能拦住普通程序员,高级黑客,假设他控制了 10w 台肉鸡(并且假设买票不需要实名认证),这下 uid 的限制不行了吧?怎么整?
(1)大哥,我是服务层,我清楚的知道小米只有 1 万部手机,我清楚的知道一列火车只有 2000 张车票,我透 10w 个请求去数据库有什么意义呢?对于写请求,做请求队列,
每次只透过有限的写请求去数据层,如果均成功再放下一批,如果库存不够则队列里的写请求全部返回“已售完”
;
(2)对于读请求,还用说么?cache来抗
,不管是 memcached 还是 redis,单机抗个每秒 10w 应该都是没什么问题的;
如此限流,只有非常少的写请求,和非常少的读缓存 mis 的请求会透到数据层去,又有 99.9%的请求被拦住了。
用户请求分发模块:使用 Nginx 或 Apache 将用户的请求分发到不同的机器上。
用户请求预处理模块:判断商品是不是还有剩余来决定是不是要处理该请求。
用户请求处理模块:把通过预处理的请求封装成事务提交给数据库,并返回是否成功。
数据库接口模块:该模块是数据库的唯一接口,负责与数据库交互,提供 RPC 接口供查询是否秒杀结束、剩余数量等信息。
评论