垃圾回收 & 秒杀设计
请简述 JVM 垃圾回收原理
JVM实现了自动内存管理,由原来开发人员手动完成回收,变成了交给垃圾回收器来自动进行回收。
回收的对象:死亡对象所占用的堆空间;
那垃圾回收器需要做三件事:
i) 找到那些内存需要 GC ?
ii) 何时要执行GC ?
iii) 用哪种策略或算法执行GC ?
下面我们从这三件事来探讨JVM垃圾回收
1. 找到那些内存需要 GC - 标记要回收的对象
标记要回收的对象,目前主要是两种方式:
i) 引用计数:为每个对象增加一个引用计数器,当对象被赋值时+1,当指向某对象的引用被赋值为其他值时-1, 当计数为0时,就是死亡对象;但可能存在循环引用的问题;
ii) 可达性分析:将一系列GC Roots作为初始的存活对象合集(live set),从该集合出发,搜索能被改集合引用到的对象,加入该集合中,该过程为标记(mark)。最后,未能搜索到的为死亡对象,可回收。 JVM中一般使用该方式。
GC Roots可认为是:由堆外指向堆内的引用,包含:Java方法栈帧中局部变量;已加载类的静态变量;JNI handles; 已启动但未停止Java线程;
但有个问题:在多线程环境下,其他线程可能会更新已访问过的对象的引用,导致误报(将引用设为null) 或漏报(将引用设为未访问对象,要回收,导致严重JVM运行异常)
解决上面问题,引用stop-the-world, 停止其他非垃圾回收线程的工作,直到完成垃圾回收。也就造成了垃圾回收的暂停时间(GC pause), JVM通过安全点(safepoint)机制来实现的。等所有线程到达安全点,才允许请求stop-the-world线程进行独占工作;需注意:计数循环不插入安全点(减少暂停时间)
2. 何时执行GC - 分代回收
研究表明,大部分java对象只存活已小段时间,小部分存活下来的对象存活很长一段时间。这也造就JVM 分代回收的思想。
分代回收:将堆划分为新生代(存储新建的对象)、老年代(当存活时间够长,移到老年代)
不同代使用不同的算法:新生代可频繁采用耗时短的算法;触发老年代回收需全堆扫描,耗时不计成本;
何时执行GC呢?
i) 当Eden区空间耗尽时,会触发Minor GC(Young GC);
ii) 当老年代没有足够空间(堆空间耗尽)时,会触发 Major GC (Full GC) ;
3. 用哪种策略执行GC - 垃圾回收策略/回收方式
回收方式有多种,一般都是组合使用,回避各自的缺点:
i) 清除(sweep)
把死亡的对象标记为空闲内存,并记录在free list中;
缺点:内存碎片;分配效率低(先查free list, 再分配)
ii) 压缩(compact)
把存活的对象聚集到内存区域的起始位置,留下一片连续的内存空间,解决了内存碎片问题;
缺点:压缩算法开销大
iii) 复制(copy)
将内存区域分为两等分,from、to, 垃圾回收是,从from 复制到 to , 并进行指针交换;
缺点:堆内存利用率降低
Minor GC 过程解析
Java new一个对象时,在堆中划出一块内存,由于堆空间是线程共享,需同步,不然会出现两对象共用一段内存。JVM通过TLAB(Thread Local Allocation Buffer)来解决的,申请一段连续内存,作为线程私有的TLAB,需加锁维护两个指针:TLAB空余内存的起始位置,指向TLAB末尾的。new指令,可通过指针加法来实现,如果申请后指针大于TLAB末尾需重新申请内存。
当Eden区耗尽,触发Minor GC, 来收集新生代的垃圾,存活下来的会被送到survivor。survivor区有两个from、to, 两个大小一样,to为空的。Minor GC会把Eden区和survivor中from中存活的复制到to中,并交换指针。当复制次数大于15次或from区占用大于50%以上,移至老年代(Tenured)。Minor GC 不用对整个堆进行垃圾回收,但有一个问题,当老年代对象可能引用新生代的对象时,需要扫描老年代的引用,做了一次全堆扫描。
解决全堆扫描,JVM引入了卡表( Card Table):将堆划分为一个个大小为512字节的卡,并维护一个卡表,存储每张卡的一个标识位。 看是否存在指向新生代对象的引用,如果有,则认为是脏卡。不用全表扫描老年代,卡表中找脏卡,并加入minor GC 的 GC roots里,完成所有扫描后,会将脏卡中标识位清0。
哪怎么标识脏卡呢? 其实并不能确保脏卡中包含新生代对象的引用。两种jvm执行引擎,使用不同的方式:
i) 解释执行器:截获每个引用类型实例变量的写操作;
ii) 即时编译JIT: 机器码中需插入额外逻辑,就是写屏障,不会判断更新后引用是否指向新生代中的对象,宁可错杀,也不能放过。 但在高并发时,会存在虚共享的问题,通过在写屏障之前,先简单判断一下是否标识过。
虚共享:cpu缓存行是64字节,1个卡表标识为1字节,那缓存行包含 64*512字节=32KB, 多线程操作32KB内存进行引用更新,会存在缓存行回写,无效化,同步等问题,影响性能。
JVM 垃圾回收器
i) 新生代 GC:都是标记-复制方式,包含:Serial单线程;Parallel scavenge多线程,吞吐率高,不能与cms一起使用;Parallel New多线程;
ii) 老年代 GC:包含:Serial Old标记-压缩,单线程;Parallel Old标记-压缩,多线程;CMS标记-清除,并发,除少数stop-the-world之外可在应用程序运行过程中进行GC, G1出现,已废弃;
iii) G1 : 横跨新生代、老年代GC, 打乱堆结果,堆分为多个区域,每个区都可是Eden,Survivor, 老年代,标记-压缩算法,可在应用程序运行过程中并发GC, 可对每个区域进行细分回收,优先回收死亡对象较多的区域;
iv) ZGC:最新GC回收器,暂停时间小于10ms,低延迟,基于动态Region内存布局。使用了读屏障(对象访问AOP功能,看染色指针标识,如未移动,直接访问;如果移动,需自愈)、染色指针(在对象指针中的标记为Marked0, Marked1中设置白、黑、灰色)、内存多层映射技术(多个虚拟内存地址映射一个物理地址),实现可并发 标记-整理 算法。具体过程:并发标记(从初始标记到最终标记,会短暂停顿) ---> 并发预备重分配(收集要清理Region,组成重分配集) ---> 并发重分配(重分配集中存活对象复制到新Region,并维护转发表,完成读屏障) ---> 并发重映射(修改所有的引用,并合并下一次的并发标记中)
设计一个秒杀系统,主要的挑战和问题有哪些?核心的架构方案或者思路有哪些?
秒杀一类的活动,已成为现在互联网产品的一种常见的营销手段。包含活动形态有:商品秒杀、商品抢购、一元夺宝、群红包、抽奖、抢优惠券等等。其本质是:在非常短时间内,将一件或少量的商品分成多份进行购买的行为。我们从下面三个方面一起讨论下秒杀系统的设计:
1. 面临问题和挑战
一件或少量的商品在同一时间段内有非常多的用户进行巧夺,造成系统的服务器资源的紧张,进而可能引起服务器宕机。特点:商品物美价廉、活动广为人知、定时上架、瞬时售空、持续时间短。对应系统面临的问题和和挑战:
i) 对现有订单系统的影响:秒杀时,对现有系统的可用性、性能的影响;
ii) 瞬时高并发:造成系统网络带宽占满,服务器资源耗尽,甚至宕机;
iii) 防作弊:跳过秒杀页面直接到下单页面;
iv) 读写写少:用户再秒杀前不断刷新页面;
v) 资源冲突:共享资源在高并发情况下,出现超卖情况;
vi) 扩展性:并发的流量超过预估流程情况如何处理;
2. 解决问题思路
在不影响目前订单等系统的可用性和性能情况下,能快速实现秒杀的功能。主要思路:
i) 新建一个秒杀系统前置系统,并对接现有的订单等系统实现整个秒杀功能;
ii) 针对需求,预估并发数、吞吐量、响应时间,申请相应硬件资源(网络带宽、CDN、存储、计算)和人力资源;
iii) 秒杀前置系统针对上面遇到的挑战做针对性的设计:多级流量阀门、秒杀器JS脚本、页面静态化、设计简洁秒杀页面和逻辑;
iv) 对秒杀系统的容量提前规划,并有相应的扩容方案,并对涉及到订单相关系统进行扩容。
3. 核心的架构方案
i) 整体架构
ii) 多级流量阀门逻辑流程图
iii) 秒杀器JS脚本实现
后台定时生成新的JS脚本并推送到 脚本服务器, 前端使用随便版本号请求该JS脚本。
在该JS文件中加入秒杀是否开始的标识和下单页面URL中访问令牌参数token。
iv) 减库存实现
如果已有现存的订单系统,可以直接调用该订单系统的服务,因为通过多级阀门设计到订单系统的并发量并不高,但同时需对容量预估,做好扩容预案。
v) 前端页面及业务逻辑优化
秒杀的前端页面优化:图片压缩、合并、CDN缓存,CSS、JS精简,动静分离等;
业务逻辑优化:流程精简,使用缓存砍掉DB操作,兜底方案设计;
vi) 服务端容器优化
调优web服务器容器;
调优应用容器服务器;
vii) 扩容、应急预案
秒杀系统的容量规划,扩容方案;
现有下单等系统扩容;
服务降级、流量、熔断设计;
版权声明: 本文为 InfoQ 作者【dony.zhang】的原创文章。
原文链接:【http://xie.infoq.cn/article/6f494853a572fc7b26c98df46】。文章转载请联系作者。
评论