第九周
一、请简述 JVM 垃圾回收原理。
1、如何判断对象已“死”
Java 堆中存放着几乎所有的对象实例,垃圾回收器在堆进行垃圾回收前,首先要判断这些对象那些还存活,那些已经“死去”。判断对象是否已“死”有如下几种算法:
1.1 引用计数法
引用计数法描述的算法为:给对象增加一个引用计数器,每当有一个地方引用它时,计数器就+1;当引用失效时,计数器就-1;任何时刻计数器为 0 的对象就是不能再被使用的,即对象已“死”。
引用计数法实现简单,判定效率也比较高,在大部分情况下都是一个比较好的算法。比如 Python 语言就是采用的引用计数法来进行内存管理的。
但是,在主流的 JVM 中没有选用引用计数法来管理内存,最主要的原因是引用计数法无法解决对象的循环引用问题。
设置打印 GC:
测试回收:
从结果可以看出,GC 日志:17M->2M(20M) 8.152ms,意味着虚拟机并没有因为这两个对象互相引用就不回收他们。
1.2 可达性分析算法
在上面讲了,Java 并不采用引用计数法来判断对象是否已“死”,而采用“可达性分析”来判断对象是否存活;
此算法的核心思想:通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为“引用链”,当一个对象到 GC Roots 没有任何的引用链相连时(从 GC Roots 到这个对象不可达)时,证明此对象不可用。以下图为例:
一般来说,在 Java 语言中,可作为 GC Roots 的对象包含以下几种:
虚拟机栈(栈帧中的本地变量表)中引用的对象。
方法区中静态属性引用的对象
方法区中常量引用的对象
本地方法栈中(Native 方法)引用的对象
1.3 Java 中的引用
强引用: 强引用指的是在程序代码之中普遍存在的,类似于"Object obj = new Object()"这类的引用,只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象实例。
软引用: 软引用是用来描述一些还有用但是不是必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出之前,会把这些对象列入回收范围之中进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。在 JDK1.2 之后,提供了 SoftReference 类来实现软引用。
弱引用: 弱引用也是用来描述非必需对象的。但是它的强度要弱于软引用。被弱引用关联的对象只能生存到下一次垃圾回收发生之前。当垃圾回收器开始进行工作时,无论当前内容是否够用,都会回收掉只被弱引用关联的对象。在 JDK1.2 之后提供了 WeakReference 类来实现弱引用。
虚引用: 虚引用也被称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在 JDK1.2 之后,提供了 PhantomReference 类来实现虚引用。
1.4 最后的挣扎
即使在可达性分析算法中不可达的对象,也并非是“非死不可”,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。
第一次标记:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记;
第二次标记:第一次标记后接着会进行一次筛选,筛选的条件是此对象是否有必要执行finalize()
方法。在finalize()
方法中没有重新与引用链建立关联关系的,将被进行第二次标记。
第二次标记成功的对象将真的会被回收,如果对象在finalize()
方法中重新与引用链建立了关联关系,那么将会逃离本次回收,继续存活。
任何一个对象的finalize()方法都只会被系统自动调用一次,如果相同的对象在逃脱一次后又面临一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败。
2、回收方法区
方法区存储内容是否需要回收的判断不太一样。
方法区主要回收的内容有:废弃常量和无用的类。
对于废弃常量也可通过引用的可达性来判断,但是对于无用的类则需要同时满足下面 3 个条件:
该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例;
加载该类的
ClassLoader
已经被回收;该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
3、垃圾回收算法
3.1 标记-清除算法
标记-清除算法采用从根集合(GC Roots)进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收,如下图所示。标记-清除算法不需要进行对象的移动,只需对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片。
3.2 复制算法
复制算法的提出是为了克服句柄的开销和解决内存碎片的问题。它开始时把堆分成 一个对象 面和多个空闲面, 程序从对象面为对象分配空间,当对象满了,基于 copying 算法的垃圾 收集就从根集合(GC Roots)中扫描活动对象,并将每个 活动对象复制到空闲面(使得活动对象所占的内存之间没有空闲洞),这样空闲面变成了对象面,原来的对象面变成了空闲面,程序会在新的对象面中分配内存。
3.3 标记-整理算法
标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。具体流程见下图:
3.4 分代收集算法
分代收集算法是目前大部分 JVM 的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),在堆区之外还有一个代就是永久代(Permanet Generation)。老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。
3.4.1 年轻代(Young Generation)的回收算法
所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。
新生代内存按照 8:1:1 的比例分为一个 eden 区和两个 survivor(survivor0,survivor1)区。一个 Eden 区,两个 Survivor 区(一般而言)。大部分对象在 Eden 区中生成。回收时先将 eden 区存活对象复制到一个 survivor0 区,然后清空 eden 区,当这个 survivor0 区也存放满了时,则将 eden 区和 survivor0 区存活对象复制到另一个 survivor1 区,然后清空 eden 和这个 survivor0 区,此时 survivor0 区是空的,然后将 survivor0 区和 survivor1 区交换,即保持 survivor1 区为空, 如此往复。
当 survivor1 区不足以存放 eden 和 survivor0 的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次 Full GC,也就是新生代、老年代都进行回收。
新生代发生的 GC 也叫做 Minor GC,MinorGC 发生频率比较高(不一定等 Eden 区满了才触发)。
3.4.2 年老代(Old Generation)的回收算法
在年轻代中经历了 N 次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。
内存比新生代也大很多(大概比例是 1:2),当老年代内存满时触发 Major GC 即 Full GC,Full GC 发生频率比较低,老年代对象存活时间比较长,存活率标记高。
3.4.3 持久代(Permanent Generation)的回收算法
用于存放静态文件,如 Java 类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些 class,例如 Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类
4、常见的垃圾收集器
下面一张图是 HotSpot 虚拟机包含的所有收集器:
Serial 收集器(复制算法)
新生代单线程收集器,标记和清理都是单线程,优点是简单高效。是 client 级别默认的 GC 方式,可以通过-XX:+UseSerialGC
来强制指定。
Serial Old 收集器(标记-整理算法)
老年代单线程收集器,Serial 收集器的老年代版本。
ParNew 收集器(停止-复制算法)
新生代收集器,可以认为是 Serial 收集器的多线程版本,在多核 CPU 环境下有着比 Serial 更好的表现。
Parallel Scavenge 收集器(停止-复制算法)
并行收集器,追求高吞吐量,高效利用 CPU。吞吐量一般为 99%, 吞吐量= 用户线程时间/(用户线程时间+GC 线程时间)。适合后台应用等对交互相应要求不高的场景。是 server 级别默认采用的 GC 方式,可用-XX:+UseParallelGC
来强制指定,用-XX:ParallelGCThreads=4
来指定线程数。
Parallel Old 收集器(停止-复制算法)
Parallel Scavenge 收集器的老年代版本,并行收集器,吞吐量优先。
CMS(Concurrent Mark Sweep)收集器(标记-清理算法)
高并发、低停顿,追求最短 GC 回收停顿时间,cpu 占用比较高,响应时间快,停顿时间短,多核 cpu 追求高响应时间的选择。
G1 收集器
G1 是目前技术发展的最前沿成果之一,HotSpot 开发团队赋予它的使命是未来可以替换掉 JDK1.5 中发布的 CMS 收集器。
与 CMS 收集器相比 G1 收集器有以下特点:
1. 空间整合,G1 收集器采用标记整理算法,不会产生内存空间碎片。分配大对象时不会因为无法找到连续空间而提前触发下一次 GC。
2. 可预测停顿,这是 G1 的另一大优势,降低停顿时间是 G1 和 CMS 的共同关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 N 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒,这几乎已经是实时 Java(RTSJ)的垃圾收集器的特征了。
上面提到的垃圾收集器,收集的范围都是整个新生代或者老年代,而 G1 不再是这样。使用 G1 收集器时,Java 堆的内存布局与其他收集器有很大差别,它将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分(可以不连续)Region 的集合。
G1 的新生代收集跟 ParNew 类似,当新生代占用达到一定比例的时候,开始出发收集。
和 CMS 类似,G1 收集器收集老年代对象会有短暂停顿。步骤:
标记阶段,首先初始标记(Initial-Mark),这个阶段是停顿的(Stop the World Event),并且会触发一次普通 Mintor GC。对应 GC log:GC pause (young) (inital-mark)
Root Region Scanning,程序运行过程中会回收 survivor 区(存活到老年代),这一过程必须在 young GC 之前完成。
Concurrent Marking,在整个堆中进行并发标记(和应用程序并发执行),此过程可能被 young GC 中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那个这个区域会被立即回收(图中打 X)。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
4. Remark, 再标记,会有短暂停顿(STW)。再标记阶段是用来收集 并发标记阶段 产生新的垃圾(并发阶段和应用程序一同运行);G1 中采用了比 CMS 更快的初始快照算法:snapshot-at-the-beginning (SATB)。
5. Copy/Clean up,多线程清除失活对象,会有 STW。G1 将回收区域的存活对象拷贝到新区域,清除 Remember Sets,并发清空回收区域并把它返回到空闲区域链表中。
6. 复制/清除过程后。回收区域的活性对象已经被集中回收到深蓝色和深绿色区域。
5、GC 的触发
5.1 Young GC
一般情况下,当新对象生成,并且在 Eden 申请空间失败时,就会触发 Young GC,对 Eden 区域进行 GC,清除非存活对象,并且把尚且存活的对象移动到 Survivor 区。然后整理 Survivor 的两个区。这种方式的 GC 是对年轻代的 Eden 区进行,不会影响到年老代。因为大部分对象都是从 Eden 区开始的,同时 Eden 区不会分配的很大,所以 Eden 区的 GC 会频繁进行。因而,一般在这里需要使用速度快、效率高的算法,使 Eden 去能尽快空闲出来。
5.2 Full GC
对整个堆进行整理,包括 Young、Tenured 和 Perm。Full GC 因为需要对整个堆进行回收,所以比 Scavenge GC 要慢,因此应该尽可能减少 Full GC 的次数。在对 JVM 调优的过程中,很大一部分工作就是对于 Full GC 的调节。有如下原因可能导致 Full GC:
年老代(Tenured)被写满;
持久代(Perm)被写满;
System.gc()被显示调用;
上一次 GC 之后 Heap 的各域分配策略动态变化;
6、安全点
GC 的停顿主要来源于可达性分析上,程序执行时并非在所有地方都能停顿下来开始 GC,只有在到达安全点时才能暂停。
安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的——因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生安全点。
接下来的问题就在于,如何让程序在需要 GC 时都跑到安全点上停顿下来,大多数 JVM 的实现都是采用主动式中断的思想。
主动式中断的思想是当 GC 需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起,轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。
二、设计一个秒杀系统,主要的挑战和问题有哪些?核心的架构方案或者思路有哪些?
1.挑战
瞬间的高并发
短时间内多人同时抢购,瞬间的高并发;
带宽耗尽风险;
服务器被击溃;
秒杀器
秒杀前,不断刷新页面;
跳过秒杀页面,直接下单秒杀。
2.核心架构方案思路
静态化处理
采用 JS 自动更新技术将动态页面转换为静态页面;
并发控制,设置阀门
只允最前面的一部分用户进入秒杀系统,先到先得;
简化流程
砍掉不重要的分支流程,如下单页面的所有数据库查询;
以下单成功作为秒杀成功的标识,支付流程稍后处理即可;
前端优化
采用 YSLOW 原则提升页面相应速度。
三、总结
本周主要讲述了 JVM 的知识和秒杀系统的挑战与设计思路:
jvm 的架构;
jvm 内存模型;
java 字节码执行流程;
java 字节码翻译过程;
类加载器与双亲委派模型;
jvm 线程与堆栈;
垃圾回收机制;
常用的性能诊断工具 JPS jmap jstack jstat 等;
java 线程安全及 ThreadLocal;
java 内存泄漏及优化;
秒杀系统案例分析;
秒杀系统的挑战与瓶颈分析;
现实中秒杀事故讲解;
事故对策;
核心解决思路;
秒杀应用解决思路;
搏一搏单车变摩托,年薪千万手动滑稽。
版权声明: 本文为 InfoQ 作者【Geek_2b3614】的原创文章。
原文链接:【http://xie.infoq.cn/article/036904fae8b5ec9ef791427f2】。未经作者许可,禁止转载。
评论