架构师训练营 第九周 学习总结
一. JVM虚拟机原理与编程优化
1. JVM架构
1.1 类加载器
JVM启动,程序开始执行时,负责将class字节码加载到JVM内存区域中
1.2 执行引擎
负责执行class文件中包含的字节码指令
1.3 本地方法库
主要是调用C或C++实现的本地方法及返回结果
1.4 运行时数据区域
方法区(Method Area)
是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
Java堆(Java Heap)
Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例。
Java虚拟机栈(Java Virtual Machine Stack)
虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
本地方法栈(Native Method Stacks)
与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。
程序计数器(Program Counter Register)
是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
2. Java 字节码
JVM虚拟机通过字节码文件实现java语言的跨平台特性。
Class文件是一组以8个字节为基础单位的二进制流,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据。当遇到需要占用8个字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8个字节进行存储,Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:“无符号数”和“表”。
3. 类加载器
双亲委派模型
启动类加载器 Bootsrap ClassLoader
它是最顶层的类加载器,是由C++编写而成, 已经内嵌到JVM中了。在JVM启动时会初始化该ClassLoader,它主要用来读取Java的核心类库JRE/lib/rt.jar中所有的class文件, 如果需要将自己写的类加载器加载请求委派给引导类加载器,那直接使用null代替即可。
扩展类加载器 Extension ClassLoader
负责加载\lib\ext目中的jar包。
应用程序类加载器 Application ClassLoader
是类加载器ClassLoader.getSystemClassLoader()方法的返回值,因此称为系统类加载器,负责加载用户路径上指定的类库。一般情况下是默认的类加载器。
自定义类加载器 Custom ClassLoader
负责加载用户自定义的jar包
4. 字节码执行引擎
4.1 概述
在Java虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观(Facade)。从外观来看,所有的Java虚拟机执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的过程,输出的是执行结果,下面将从概念模型的角度来讲解虚拟机的方法调用和字节码执行。
4.2 栈帧
栈帧(Stack Frame)
是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。这行信息会在编译时被写入到Class文件的方法表的Code属性中。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
局部变量表
局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。
操作数栈
操作数栈称为操作栈,它是一个后进先出的栈,当一个方法开始执行时,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指令往操作数栈中写入和读取内容,也就是入栈和出栈操作。举个例子,整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int类型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈。
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。我们知道,Class文件的常量池中有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数,这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
方法返回地址
当一个方法开始执行后,只有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者或者主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为“正常调用完成”(Normal Method Invocation Completion)。
另外一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为“异常调用完成(Abrupt Method Invocation Completion)”。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值的。
4.3 方法调用
方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还未涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作之一,但第7章中已经讲过,Class文件的编译过程中不包含传统程序语言编译的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(也就是之前说的直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂,某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
4.4 基于栈的指令集和基于寄存器的指令集
基于栈的指令集,因为它不直接依赖于寄存器,所以不受硬件的约束。它的主要缺点是执行速度相对会稍慢一些。所有主流物理机的指令集都是寄存器架构也从侧面印证了这一点。之所以速度慢,原因有两点:一是基于栈的指令集需要更多的指令数量,因为出栈和入栈本身就产生了相当多的指令;二是因为执行指令时会有频繁的入栈和出栈操作,频繁的栈访问也就意味着频繁的内存访问,相对于处理器而言,内存始终是执行速度的瓶颈。
5. 内存管理
5.1 CMS收集器
CMS(Concurrent Mark Swee)收集器是一种以获取最短回收停顿时间为目标的收集器。CMS 收集器仅作用于老年代的收集,采用“标记-清除”算法,它的运作过程分为 4 个步骤:
1 初始标记(CMS initial mark)
2 并发标记(CMS concurrent mark)
3 重新标记(CMS remark)
4 并发清除(CMS concurrent sweep)
其中,初始标记、重新标记这两个步骤仍然需要 Stop-the-world。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始阶段稍长一些,但远比并发标记的时间短。
CMS 以流水线方式拆分了收集周期,将耗时长的操作单元保持与应用线程并发执行。只将那些必需 STW 才能执行的操作单元单独拎出来,控制这些单元在恰当的时机运行,并能保证仅需短暂的时间就可以完成。这样,在整个收集周期内,只有两次短暂的暂停(初始标记和重新标记),达到了近似并发的目的。
CMS 收集器优点: 并发收集,低停顿。
CMS 收集器缺点:
CMS 收集器对 CPU 资源非常敏感;
CMS 收集器无法处理浮动垃圾;
CMS 收集器是基于“标记-清除”算法,该算法的缺点都有。
CMS 收集器之所以能够做到并发,根本原因在于采用基于“标记-清除”的算法并对算法过程进行了细粒度的分解。前面已经介绍过“标记-清除”算法将产生大量的内存碎片这对新生代来说是难以接受的,因此新生代的收集器并未提供 CMS 版本。
5.2 G1
并行与并发: G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU 来缩短 Stop-the-world 停顿的时间,部分其他收集器原来需要停顿 Java 线程执行的 GC 操作,G1 收集器仍然可以通过并发的方式让 Java 程序继续运行。
分代收集: 打破了原有的分代模型,将堆划分为一个个区域。
空间整合: 与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的。但无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。
可预测的停顿: 这是 G1 相对于 CMS 的一个优势,降低停顿时间是 G1 和 CMS 共同的关注点。
在 G1 之前的其他收集器进行收集的范围都是整个新生代或者老年代,而 G1 不再是这样。在堆的结构设计时,G1 打破了以往将收集范围固定在新生代或老年代的模式,G1 将堆分成许多相同大小的区域单元,每个单元称为 Region,Region 是一块地址连续的内存空间,G1 模块的组成如下图所示:
堆内存会被切分成为很多个固定大小的 Region,每个是连续范围的虚拟内存。堆内存中一个 Region 的大小可以通过-XX:G1HeapRegionSize参数指定,其区间最小为 1M、最大为 32M,默认把堆内存按照 2048 份均分。
每个 Region 被标记了 E、S、O 和 H,这些区域在逻辑上被映射为 Eden,Survivor 和老年代。存活的对象从一个区域转移(即复制或移动)到另一个区域,区域被设计为并行收集垃圾,可能会暂停所有应用线程。
如上图所示,区域可以分配到 Eden,Survivor 和老年代。此外,还有第四种类型,被称为巨型区域(Humongous Region)。Humongous 区域是为了那些存储超过 50% 标准 Region 大小的对象而设计的,它用来专门存放巨型对象。如果一个 H 区装不下一个巨型对象,那么 G1 会寻找连续的 H 分区来存储。为了能找到连续的 H 区,有时候不得不启动 Full GC。
G1 收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。G1 会通过一个合理的计算模型,计算出每个 Region 的收集成本并量化,这样一来,收集器在给定了“停顿”时间限制的情况下,总是能选择一组恰当的 Region 作为收集目标,让其收集开销满足这个限制条件,以此达到实时收集的目的。
G1 收集的运作过程大致如下:
初始标记(Initial Marking): 仅仅只是标记一下GC Roots能直接关联到的对象,并且修改 TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的 Region 中创建新对象,这阶段需要停顿线程,但耗时很短。
并发标记(Concurrent Marking): 是从GC Roots开始堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。
最终标记(Final Marking): 是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,这阶段需要停顿线程,但是可并行执行。
筛选回收(Live Data Counting and Evacuation): 首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。这个阶段也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。
G1 的 GC 模式可以分为两种,分别为:
Young GC: 在分配一般对象(非巨型对象)时,当所有 Eden 区域使用达到最大阀值并且无法申请足够内存时,会触发一次 YoungGC。每次 Young GC 会回收所有 Eden 以及 Survivor 区,并且将存活对象复制到 Old 区以及另一部分的 Survivor 区。
Mixed GC: 当越来越多的对象晋升到老年代时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即 Mixed GC,该算法并不是一个 Old GC,除了回收整个新生代,还会回收一部分的老年代,这里需要注意:是一部分老年代,而不是全部老年代,可以选择哪些 Old 区域进行收集,从而可以对垃圾回收的耗时时间进行控制。G1 没有 Full GC概念,需要 Full GC 时,调用 Serial Old GC 进行全堆扫描。
二. JAVA代码优化
1. 合理并谨慎使用多线程
启动线程数=[任务执行时间/(任务执行时间-IO等待时间)]*CPU核心数
2. 竞态条件与临界区
在同一程序中运行多个线程本身不会导致问题,问题在于多个线程访问同一个资源。当两个线程竞争一个资源时,如果对访问资源的顺序敏感,就称存在竞态条件。导致竞态条件发生的代码区称作为临界区。
在临界区中使用适当的同步就可以避免竞态条件。
3. 线程安全
局部变量
局部变量存储在自己的栈中。也就是说,局部变量永远也不会被多个线程共享。所以基础类型的局部变量是线程安全的。
局部对象的引用
如果在方法中创建的对象不会逃逸出该方法,那么他就是线程安全的。
对象成员
对象成员存储在堆上,如果两个线程同时更新同一个对象的同一个成员,那么这个代码就不是线程安全的。
4. Java内存泄漏
如果程序保留那些永远不再被使用的对象,这些对象将会占用并耗尽内存。
长生命周期对象
静态容器
缓存
合理使用线程池对象
复用线程或对象资源,避免在程序的生命周期中创建和删除大量的对象。
池管理算法(记录那些对象是空闲的,哪些对象真在使用)
对象内容清除(线程清空)
使用合适的JDK容器类
LinkList和ArrayList的区别及使用场景
HashMap的算法实现及应用场景
ConcurrentHashMap
缩短对象生命周期,加速垃圾回收
减少对象内存驻留时间
在使用时创建对象,用完释放。
创建对象的步骤。
使用非阻塞I/O通信
延迟写与提前读策略
异步阻塞IO通信
优先使用组合代替继承
减少对象耦合
避免太深的继承带来对象创建的性能损失。
合理使用单例模式
无状态对象
线程安全
虚拟化所有层次
计算机的任何问题都可以通过间接层来解决。
一致性Hash算法的虚拟化解决。
面向接口编程
7层网络协议
三. 秒杀
1. 影响
网路带宽耗尽
服务器停止响应
数据库瘫痪
2. 挑战
瞬间的的高并发
秒杀器
3. 对应手段
独立部署秒杀应用和现有的业务系统分离。
带宽准备。
采用静态化的页面展示商品。
限流
只让秒杀成功的商品进入订单系统,其他用户返回秒杀失败的页面。
秒杀器预防
只能到秒杀的那个时刻才能获取到秒杀的权限。
应急预案准备
备用服务器;网站功能降级,保证网站的基本可用性;万能出错页面。
版权声明: 本文为 InfoQ 作者【李君】的原创文章。
原文链接:【http://xie.infoq.cn/article/2137d29faceaa96b015edc69a】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论