/images/avatar.png

消息队列

概述 消息队列(Message Queue,简称 MQ)是构建分布式互联网应用的基础设施,消息队列已经逐渐成为企业IT系统内部通信的核心手段。它具有低耦合、可靠投递、广播、流量控制、最终一致性等一系列功能,成为异步RPC的主要手段之一。 消息队列应用 使用场景 业务解耦 解耦是消息队列要解决的最本质问题。所谓解耦,简单点讲就是一个事务,只关心核心的流程。而需要依赖其他系统但不那么重要的事情,有通知即可,无需等待结果。换句话说,基于消息的模型,关心的是“通知”,而非“处理”。 比如在美团旅游,我们有一个产品中心,产品中心上游对接的是主站、移动后台、旅游供应链等各个数据源;下游对接的是筛选系统、API系统等展示系统。当上游的数据发生变更的时候,如果不使用消息系统,势必要调用我们的接口来更新数据,就特别依赖产品中心接口的稳定性和处理能力。但其实,作为旅游的产品中心,也许只有对于旅游自建供应链,产品中心更新成功才是他们关心的事情。而对于团购等外部系统,产品中心更新成功也好、失败也罢,并不是他们的职责所在。他们只需要保证在信息变更的时候通知到我们就好了。 而我们的下游,可能有更新索引、刷新缓存等一系列需求。对于产品中心来说,这也不是我们的职责所在。说白了,如果他们定时来拉取数据,也能保证数据的更新,只是实时性没有那么强。但使用接口方式去更新他们的数据,显然对于产品中心来说太过于“重量级”了,只需要发布一个产品ID变更的通知,由下游系统来处理,可能更为合理。 再举一个例子,对于我们的订单系统,订单最终支付成功之后可能需要给用户发送短信积分什么的,但其实这已经不是我们系统的核心流程了。如果外部系统速度偏慢(比如短信网关速度不好),那么主流程的时间会加长很多,用户肯定不希望点击支付过好几分钟才看到结果。那么我们只需要通知短信系统“我们支付成功了”,不一定非要等待它处理完成。 异步处理 多应用对消息队列中同一消息进行处理,应用间并发处理消息,相比串行处理,减少处理时间; 限流削峰,错峰流控 试想上下游对于事情的处理能力是不同的。比如,Web前端每秒承受上千万的请求,并不是什么神奇的事情,只需要加多一点机器,再搭建一些LVS负载均衡设备和Nginx等即可。但数据库的处理能力却十分有限,即使使用SSD加分库分表,单机的处理能力仍然在万级。由于成本的考虑,我们不能奢求数据库的机器数量追上前端。 这种问题同样存在于系统和系统之间,如短信系统可能由于短板效应,速度卡在网关上(每秒几百次请求),跟前端的并发量不是一个数量级。但用户晚上个半分钟左右收到短信,一般是不会有太大问题的。如果没有消息队列,两个系统之间通过协商、滑动窗口等复杂的方案也不是说不能实现。但系统复杂性指数级增长,势必在上游或者下游做存储,并且要处理定时、拥塞等一系列问题。而且每当有处理能力有差距的时候,都需要单独开发一套逻辑来维护这套逻辑。所以,利用中间系统转储两个系统的通信内容,并在下游系统有能力处理这些消息的时候,再处理这些消息,是一套相对较通用的方式。 总而言之,消息队列不是万能的。对于需要强事务保证而且延迟敏感的,RPC是优于消息队列的。 对于一些无关痛痒,或者对于别人非常重要但是对于自己不是那么关心的事情,可以利用消息队列去做。 支持最终一致性的消息队列,能够用来处理延迟不那么敏感的“分布式事务”场景,而且相对于笨重的分布式事务,可能是更优的处理方式。 当上下游系统处理能力存在差距的时候,利用消息队列做一个通用的“漏斗”。在下游有能力处理的时候,再进行分发。 如果下游有很多系统关心你的系统发出的通知的时候,果断地使用消息队列吧。 广泛应用于秒杀或抢购活动中,避免流量过大导致应用系统挂掉的情况; 最终一致性 最终一致性不是消息队列的必备特性,但确实可以依靠消息队列来做最终一致性的事情。 投递模式 点对点模式(Point-to-Point, Queue) Point-to-Point,点对点通信模型。PTP是基于队列(Queue)的,一个队列可以有多个生产者,和多个消费者。消息服务器按照收到消息的先后顺序,将消息放到队列中。队列中的每一条消息,只能由一个消费者进行消费,消费之后就会从队列中移除。 发布/订阅模式(publish/subscribe,topic) 每个消息可以有多个订阅者; 发布者和订阅者之间有时间上的依赖性。针对某个主题(Topic)的订阅者,它必须创建一个订阅者之后,才能消费发布者的消息。 为了消费消息,订阅者需要提前订阅该角色主题,并保持在线运行; Partition模型 生产者发送消息到某个Topic中时,最终选择其中一个Partition进行发送。你可以将Parition模型中的分区,理解为PTP模型的队列,不同的是,PTP模型中的队列存储的是所有的消息,而每个Partition只会存储部分数据。 对于消息者,此时多了一个消费者组的概念,Paritition会在同一个消费者组下的消费者中进行分配,每个消费者只消费分配给自己的Paritition。上图演示了不同的消费者可能会分配到不同数量的Paritition。 Paritition模式巧妙的将PTP模型和Pub/Sub模型结合在了一起: Transfer模型 Paritition模型中的消费者组概念很有用,同一个Topic下的消息可以由多个不同业务方进行消费,只要使用不同的消费者组即可,不同消费者组消费到的位置单独记录,互不影响。 但是,Paritition模型还是限制了消费者数量不能多于分区数。 设计一个简单的消息队列 一般来讲,设计消息队列的整体思路是先build一个整体的数据流,例如producer发送给broker,broker发送给consumer,consumer回复消费确认,broker删除/备份消息等。 利用RPC将数据流串起来。然后考虑RPC的高可用性,尽量做到无状态,方便水平扩展。 之后考虑如何承载消息堆积,然后在合适的时机投递消息,而处理堆积的最佳方式,就是存储,存储的选型需要综合考虑性能/可靠性和开发维护成本等诸多因素。 为了实现广播功能,我们必须要维护消费关系,可以利用zk/config server等保存消费关系。 在完成了上述几个功能后,消息队列基本就实现了。然后我们可以考虑一些高级特性,如可靠投递,事务特性,性能优化等。 下面我们会以设计消息队列时重点考虑的模块为主线,穿插灌输一些消息队列的特性实现方法,来具体分析设计实现一个消息队列时的方方面面。 RPC通信协议 刚才讲到,所谓消息队列,无外乎两次RPC加一次转储,当然需要消费端最终做消费确认的情况是三次RPC。既然是RPC,就必然牵扯出一系列话题,什么负载均衡啊、服务发现啊、通信协议啊、序列化协议啊,等等。在这一块,我的强烈建议是不要重复造轮子。利用公司现有的RPC框架:Thrift也好,Dubbo也好,或者是其他自定义的框架也好。因为消息队列的RPC,和普通的RPC没有本质区别。当然了,自主利用Memchached或者Redis协议重新写一套RPC框架并非不可(如MetaQ使用了自己封装的Gecko NIO框架,卡夫卡也用了类似的协议)。但实现成本和难度无疑倍增。排除对效率的极端要求,都可以使用现成的RPC框架。 简单来讲,服务端提供两个RPC服务,一个用来接收消息,一个用来确认消息收到。并且做到不管哪个server收到消息和确认消息,结果一致即可。当然这中间可能还涉及跨IDC的服务的问题。这里和RPC的原则是一致的,尽量优先选择本机房投递。你可能会问,如果producer和consumer本身就在两个机房了,怎么办?首先,broker必须保证感知的到所有consumer的存在。其次,producer尽量选择就近的机房就好了。 高可用 其实所有的高可用,是依赖于RPC和存储的高可用来做的。先来看RPC的高可用,美团的基于MTThrift的RPC框架,阿里的Dubbo等,其本身就具有服务自动发现,负载均衡等功能。而消息队列的高可用,只要保证broker接受消息和确认消息的接口是幂等的,并且consumer的几台机器处理消息是幂等的,这样就把消息队列的可用性,转交给RPC框架来处理了。 那么怎么保证幂等呢?最简单的方式莫过于共享存储。broker多机器共享一个DB或者一个分布式文件/kv系统,则处理消息自然是幂等的。就算有单点故障,其他节点可以立刻顶上。另外failover可以依赖定时任务的补偿,这是消息队列本身天然就可以支持的功能。存储系统本身的可用性我们不需要操太多心,放心大胆的交给DBA们吧! 对于不共享存储的队列,如Kafka使用分区加主备模式,就略微麻烦一些。需要保证每一个分区内的高可用性,也就是每一个分区至少要有一个主备且需要做数据的同步,关于这块HA的细节,可以参考下篇pull模型消息系统设计。 服务端承载消息堆积的能力 消息到达服务端如果不经过任何处理就到接收者了,broker就失去了它的意义。为了满足我们错峰/流控/最终可达等一系列需求,把消息存储下来,然后选择时机投递就显得是顺理成章的了。 只是这个存储可以做成很多方式。比如存储在内存里,存储在分布式KV里,存储在磁盘里,存储在数据库里等等。但归结起来,主要有持久化和非持久化两种。 持久化的形式能更大程度地保证消息的可靠性(如断电等不可抗外力),并且理论上能承载更大限度的消息堆积(外存的空间远大于内存)。 但并不是每种消息都需要持久化存储。很多消息对于投递性能的要求大于可靠性的要求,且数量极大(如日志)。这时候,消息不落地直接暂存内存,尝试几次failover,最终投递出去也未尝不可。 市面上的消息队列普遍两种形式都支持。当然具体的场景还要具体结合公司的业务来看。 存储子系统的选择 我们来看看如果需要数据落地的情况下各种存储子系统的选择。理论上,从速度来看,文件系统>分布式KV(持久化)>分布式文件系统>数据库,而可靠性却截然相反。还是要从支持的业务场景出发作出最合理的选择,如果你们的消息队列是用来支持支付/交易等对可靠性要求非常高,但对性能和量的要求没有这么高,而且没有时间精力专门做文件存储系统的研究,DB是最好的选择。 但是DB受制于IOPS,如果要求单broker 5位数以上的QPS性能,基于文件的存储是比较好的解决方案。整体上可以采用数据文件+索引文件的方式处理,具体这块的设计比较复杂,可以参考下篇的存储子系统设计。 分布式KV(如MongoDB,HBase)等,或者持久化的Redis,由于其编程接口较友好,性能也比较可观,如果在可靠性要求不是那么高的场景,也不失为一个不错的选择。 消费关系解析 现在我们的消息队列初步具备了转储消息的能力。下面一个重要的事情就是解析发送接收关系,进行正确的消息投递了。 市面上的消息队列定义了一堆让人晕头转向的名词,如JMS 规范中的Topic/Queue,Kafka里面的Topic/Partition/ConsumerGroup,RabbitMQ里面的Exchange等等。抛开现象看本质,无外乎是单播与广播的区别。所谓单播,就是点到点;而广播,是一点对多点。当然,对于互联网的大部分应用来说,组间广播、组内单播是最常见的情形。 消息需要通知到多个业务集群,而一个业务集群内有很多台机器,只要一台机器消费这个消息就可以了。 当然这不是绝对的,很多时候组内的广播也是有适用场景的,如本地缓存的更新等等。另外,消费关系除了组内组间,可能会有多级树状关系。这种情况太过于复杂,一般不列入考虑范围。所以,一般比较通用的设计是支持组间广播,不同的组注册不同的订阅。组内的不同机器,如果注册一个相同的ID,则单播;如果注册不同的ID(如IP地址+端口),则广播。 至于广播关系的维护,一般由于消息队列本身都是集群,所以都维护在公共存储上,如config server、zookeeper等。维护广播关系所要做的事情基本是一致的:

JVM调优

调优思想 为什么JVM调优 降本增效:调优的最终目的都是为了应用程序使用最小的硬件消耗来承载更大的吞吐量。JVM调优主要是针对垃圾收集器的收集性能进行优化令运行在虚拟机上的应用,能够使用更少的内存(Footprint),及更低的延迟(Latency),获取更大的吞吐量(Throughput)。 调优目标(不同应用场景的JVM调优量化目标是不一样的,这里的目标只一个参照模板) 堆内存使用率 <= 70%; 老年代内存使用率<= 70%; avg pause <= 1秒; Full GC 次数0 或 avg pause interval >= 24小时 ; 什么时候JVM调优 系统吞吐量下降与响应延迟(P99); Heap内存(老年代)持续上涨至出现OOM; Full GC 次数频繁; GC 停顿过长(超过1秒); 应用出现OutOfMemory 等内存异常; 应用中有使用本地缓存且占用大量内存空间; 调什么 内存分配 + 垃圾回收! 合理使用堆内存 GC高效回收占用的内存的垃圾对象 GC高效释放掉内存空间 调优的原则和目标如何确定? 优先原则:产品、架构、代码、数据库优先,JVM是不得已的最后的手段(大多数的Java应用不需要进行JVM优化) 观测性原则:发现问题解决问题,没问题不创造问题。 调优主要步骤 第一步:监控分析GC日志 第二步:判断JVM问题: 如果各项参数设置合理,系统没有超时日志出现,GC频率不高,GC耗时不高,那么没有必要进行GC优化 如果GC时间超过1秒,或者频繁GC,则必须优化。 第三步:确定调优目标 第四步:调整参数 调优一般是从满足程序的内存使用需求开始,之后是时间延迟需求,最后才是吞吐量要求,要基于这个步骤来不断优化,每一个步骤都是进行下一步的基础,不可逆行之。 第五步:对比调优前后差距 第六步:重复:1、2、3、4、5步骤 找到最佳JVM参数设置 第七步:应用JVM参数到应用服务器: 找到最合适的参数,将这些参数灰度发布一部分机器,观察一段时间。 如果,观察结果可以验证方案的价值,则进行全量发布! GC日志 参数配置 JVM调优典型参数设置: -Xms堆内存最小值 -Xmx堆内存最大值 -Xmn新生代内存的最大值 -Xss每个线程的栈内存 建议:在开发测试环境可以用Xms和Xmx设置最小值最大值,但是在线上生产环境,Xms和Xmx设置的值相同防止抖动; 如果想要确定JVM性能问题瓶颈,需要分析GC日志 -XX:+PrintGCDetails 开启GC日志创建更详细的GC日志,默认关闭 -XX:+PrintGCTimeStamps,-XX:+PrintGCDateStamps 开启GC时间提示, 开启时间便于我们更精确地判断几次GC操作之间的时两个参数的区别 时间戳是相对于0(依据JVM启动的时间)的值,而日期戳(date stamp)是实际的日期字符串 由于日期戳需要进行格式化,所以它的效率可能会受轻微的影响,不过这种操作并不频繁,它造成的影响也很难被我们感知。 -XX:+PrintHeapAtGC 打印堆的GC日志 -Xloggc:.

JVM调优相关工具与可调参数

JDK工具包 jps(JVM Process status tool):JVM进程状态工具,查看进程基本信息 jstat(JVM statistics monitoring tool): JVM统计监控工具,查看堆,GC详细信息,可以通过它查看运行时堆信息的相关情况。 jinfo(Java Configuration Info):查看配置参数信息,支持部分参数运行时修改 jinfo -flags pid(打印虚拟机 VM 参数) jmap(Java Memory Map):分析堆内存工具,dump堆内存快照 jmap -heap pid(显示Java堆详细信息:打印堆的摘要信息,包括使用的GC算法、堆配置信息和各内存区域内存使用信息) jhat(Java Heap Analysis Tool):堆内存dump文件解析工具 jstack(Java Stack Trace):jstack是Java虚拟机自带的一种堆栈跟踪工具,用于生成java虚拟机当前时刻的线程快照。 VisualVM(GUI):性能分析可视化工具 第三方工具 GCEasy 免费GC日志可视化分析Web工具:https://gceasy.io/ MAT:Memory Analyzer Tool 可视化内存分析工具 可以快捷、有效地帮助我们找到内存泄露,减少内存消耗分析工具。能帮助你查找内存泄漏和减少内存消耗。 GCViewer 开源的GC日志分析工具 Arthas Arthas 是一款线上监控诊断产品,通过全局视角实时查看应用 load、内存、gc、线程的状态信息,并能在不修改应用代码的情况下,对业务问题进行诊断,包括查看方法调用的出入参、异常,监测方法执行耗时,类加载信息等,大大提升线上问题排查效率。 Arthas 常见命令 dashboard jvm:查看当前 JVM 的信息 thread:查看当前 JVM 的线程堆栈信息, -b 选项可以一键检测死锁 -n 指定最忙的前N个线程并打印堆栈 trace:方法内部调用路径,并输出方法路径上的每个节点上耗时,服务间调用时间过长时使用 stack:输出当前方法被调用的调用路径 Jad:反编译指定已加载类的源码,反编译便于理解业务 logger:查看和修改 logger,可以动态更新日志级别 JVM参数 JVM主要分三种:标准参数、非标准参数、不稳定参数 标准参数:不会随着JVM变化而变化 * 以-开头,如:-version、-jar * 使用java -help查看 非标准参数:可能会随着JVM变化而变化,变化较小 * 以-X开头,如:-Xmx、-Xms、-Xmn、-Xss * 使用java –X查看 比较常见的非标准参数 -Xms堆最小值:默认值是总内存/64(且小于1G),默认情况下,当堆中可用内存小于40%(-XX: MinHeapFreeRatio调整)时,堆内存会开始增加,一直增加到-Xmx大小。 -Xmx堆最大值:默认值是总内存/64(且小于1G),如果Xms和Xmx都不设置,则两者大小会相同,默认情况下,当堆中可用内存大于70%时,堆内存会开始减少,一直减小到-Xms的大小; -Xmn新生代内存:默认是整堆的1/3,包括Eden区和两个Survivor区的总和,写法如: -Xmn1024,-Xmn1024k,-Xmn1024m,-Xmn1g 。新生代按整堆的比例分配,所以,此值不建议设置! -Xss线程栈内存:默认1M,一般来说是不需要改的。 打印GC日志:-Xloggc:file与-verbose:gc功能类似,只是将每次GC事件的相关情况记录到一个文件中。 -XX:NewRatio 设置新生代与老年代比值,-XX:NewRatio=4 表示新生代与老年代所占比例为1:4 ,新生代占比整个堆的五分之一。如果设置了-Xmn的情况下,该参数是不需要在设置的。 -XX:PermSize 设置持久代初始值,默认是物理内存的六十四分之一 -XX:MaxPermSize 设置持久代最大值,默认是物理内存的四分之一 -XX:MaxTenuringThreshold 新生代中对象存活次数,默认15。(若对象在eden区,经历一次MinorGC后还活着,则被移动到Survior区,年龄加1。以后,对象每次经历MinorGC,年龄都加1。达到阀值,则移入老年代) -XX:SurvivorRatio Eden区与Subrvivor区大小的比值,如果设置为8,两个Subrvivor区与一个Eden区的比值为2:8,一个Survivor区占整个新生代的十分之一 -XX:+UseFastAccessorMethods 原始类型快速优化 -XX:+AggressiveOpts 编译速度加快 -XX:PretenureSizeThreshold 对象超过多大值时直接在老年代中分配 不稳定参数:也是非标准的参数,主要用于JVM调优与Debug * 以-XX开头,如: -XX:+UseG1GC 、 -XX:+UseParallelGC、 -XX:+PrintGCDetails * 分三类:性能参数、行为参数、和调试参数 不稳定参数分为三类: 性能参数:用于JVM的性能调优和内存分配控制,如内存大小的设置; 行为参数:用于改变JVM的基础行为,如GC的方式和算法的选择; 调试参数:用于监控、打印、输出jvm的信息; 常用的不稳定参数: -XX:+UseSerialGC 配置串行收集器 -XX:+UseParallelGC 配置PS并行收集器 -XX:+UseParallelOldGC 配置PO并行收集器 -XX:+UseParNewGC 配置ParNew并行收集器 -XX:+UseConcMarkSweepGC 配置CMS并行收集器 -XX:+UseG1GC 配置G1并行收集器 -XX:+PrintGCDetails 配置开启GC日志打印 -XX:+PrintGCTimeStamps 配置开启打印GC时间戳 -XX:+PrintGCDateStamps 配置开启打印GC日期 -XX:+PrintHeapAtGC 配置开启在GC时,打印堆内存信息

JVM-垃圾回收

为什么要垃圾回收 如果不进行垃圾收集,内存数据很快就会被占满 什么是垃圾 没有被引用的对象就是垃圾 如何找到垃圾 引用计数算法(Reference Counting) 当这个对象引用都消失了,消失一个计数减一,当引用都消失了,计数就会变为0。此时这个对象就会变成垃圾。 在堆内存中主要的引用关系有如下三种:单一引用、循环引用、无引用 引用计数算法不能解决循环引用问题。为了解决这个问题,JVM使用了根可达分析算法。 根可达算法(GCRoots Tracing) 又叫根搜索算法。在主流的商用程序语言中(Java和C#),都是使用根搜索算法判定对象是否存活的。 基本思路就是通过一系列的名为“GCRoot”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GCRoot没有任何引用链相连时,则证明此对象是不可用的,也就是不可达的。 可作GCRoots的对象 虚拟机栈中,栈帧的本地变量表引用的对象。 方法区中,类静态属性引用的对象。 方法区中,常量引用的对象。 本地方法栈中,JNl引用的对象。 回收过程 即使在可达性分析算法中不可达的对象,也并非是“非死不可”。被判定不可达的对象处于“缓刑”阶段。 要真正宣告死亡,至少要经历两次标记过程: 第一次标记:如果对象可达性分析后,发现没有与GC Roots相连接的引用链,那它将会被第一次标 记; 第二次标记:第一次标记后,接着会进行一次筛选。筛选条件:此对象是否有必要执行finalize() 方法。在 finalize() 方法中没有重新与引用链建立关联关系的,将被进行第二次标记。 第二次标记成功的对象将真的会被回收,如果失败则继续存活 对象引用 在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(StrongReference)、软引用(SoftReference)、弱引用(WeakReference)、虚引用(PhantomReference)四种,这四种引用强度依次逐渐减弱。 如何清除垃圾 标记清除算法(Mark-Sweep) 最基本的算法,主要分为标记和清除2个阶段。首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象 缺点 效率不高,标记和清除过程的效率都不高 空间碎片,会产生大量不连续的内存碎片,会导致大对象可能无法分配,提前触发GC 。 复制算法(Copying) 为解决效率。它将可用内存按容量划分为相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。 现在商业虚拟机都是采用这种收集算法来回收新生代,当回收时,将Eden和Survivor中还存活着的对象 拷贝到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor的空间。 HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存是会被“浪费”的。当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。 优缺点 优点:没有碎片化,所有的有用的空间都连接在一起,所有的空闲空间都连接在一起 缺点:存在空间浪费 标记-整理算法(Mark-Compact) 标记:标记出所有需要回收对象 清除:统一回收掉所有对象 整理:将所有存活对象向一端移动 老年代没有人担保,不能用复制回收算法。可以用标记-整理算法,标记过程仍然与“标记-清除”算法一样,然后让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存 优缺点 优点:空间没有浪费,没有内存碎片化问题 缺点:性能较低,因为除了拷贝对象以外,还需要对象内存空间进行压缩,所以性能较低。 分代回收(Generational Collection) 现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。 新生代,每次垃圾回收都有大量对象失去,选择复制算法,弱分代假说 老年代,对象存活率高,无人进行分配担保,就必须采用标记清除或者标记整理算法,**强分代假说 ** 方法区的回收 因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,因此在方法区上进行回收性价比不高。 主要是对常量池的回收和对类的卸载,在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载功能,以保证不会出现内存溢出。

JVM-一个对象的一生(出生、死亡与内涵)

内存分配的方法有两种:不同垃圾收集器不一样 指针碰撞(Bump the Pointer) 空闲列表(Free List) 分配方法 说明 收集器 指针碰撞(Bump thePointer) 内存地址是连续的(新生代) Serial 和 ParNew 收集器 比空闲列表(Free List) 内存地址不连续(老年代) 数字CMS 收集器和 Mark-Sweep 收集器 内存分配安全问题 虚拟机给A线程分配内存的过程中,指针未修改,此时B线程同时使用了该内存,有问题 处理方案 CAS乐观锁:JVM虚拟机采用CAS失败重试的方式保证更新操作的原子性 TLAB (Thread Local Allocation Buffer) 本地线程分配缓存,预分配 分配主流程:首先从TLAB里面分配,如果分配不到,再使用CAS从堆里面划分 对象内存分配流程 为对象分配内存是一件非常严谨和复杂的任务,JVM 的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法和内存回收算法密切相关,所以还需要考虑 GC 执行完内存回收后是否会在内存空间中产生内存碎片。 new 的对象先放在伊甸园区,此区有大小限制 当伊甸园的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区 然后将伊甸园中的剩余对象移动到幸存者 0 区 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者 0 区,如果没有回收,就会放到幸存者 1 区 如果再次经历垃圾回收,此时会重新放回幸存者 0 区,接着再去幸存者 1 区 什么时候才会去养老区呢? 默认是 15 次回收标记 在养老区,相对悠闲。当养老区内存不足时,再次触发 Major GC,进行养老区的内存清理 若养老区执行了 Major GC 之后发现依然无法进行对象的保存,就会产生 OOM 异常 对象内存分配 新生代:新对象大多数都默认进入新生代的Eden区 进入老年代的条件:四种情况 存活年龄太大,默认超过15次【-XX:MaxTenuringThreshold】 动态年龄判断:MinorGC之后,发现Survivor区中的一批对象的总大小大于了这块Survivor区的50%,那么就会将此时大于等于这批对象年龄最大值的所有对象,直接进入老年代。 举个栗子:Survivor区中有一批对象,年龄分别为年龄1+年龄2+年龄n的多个对象,对象总和大小超过了Survivor区域的50%,此时就会把年龄n及以上的对象都放入老年代。 为什么会这样?希望那些可能是长期存活的对象,尽早进入老年代。 -XX:TargetSurvivorRatio可以指定 大对象直接进入老年代:前提是Serial和ParNew收集器 举个栗子:字符串或数组 -XX:PretenureSizeThreshold 一般设置为1M 为什么会这样?为了避免大对象分配内存时的复制操作降低效率。避免了Eden和Survivor区的复制 MinorGC后,存活对象太多无法放入Survivor 空间担保机制 当新生代无法分配内存的时候,我们想把新生代的老对象转移到老年代,然后把新对象放入腾空的新生代。此种机制我们称之为内存担保。

JVM运行时数据区(JVM内存结构)

JVM 内存结构 栈是运行时的单位,而堆是存储的单位。(栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪)。 JVM运行时数据区 按照线程使用情况和职责分成两大类 线程独享 (程序执行区域) 虚拟机栈、本地方法栈、程序计数器 不需要垃圾回收 线程共享 (数据存储区域) 堆和方法区 存储类的静态数据和对象数据 需要垃圾回收 堆 Java堆在JVM启动时创建内存区域去实现对象、数组与运行时常量的内存分配,它是虚拟机管理最大的,也是垃圾回收的主要内存区域 。 堆内存划分 核心逻辑就是三大假说,基于程序运行情况进行不断的优化设计。 堆 堆内存为什么会存在新生代和老年代 分代收集理论:当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(GenerationalCollection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法 则,它建立在两个分代假说之上: 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。 这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。 如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间; 如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域。 这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。 内存模型变迁 JDK1.7 JDK1.7 Young 年轻区 :主要保存年轻对象,分为三部分,Eden区、两个Survivor区。 Tenured 年老区 :主要保存年长对象,当对象在Young复制转移一定的次数后,对象就会被转移到Tenured区。 Perm 永久区 :主要保存class、method、filed对象,这部份的空间一般不会溢出,除非一次性加载了很多的类,不过在涉及到热部署的应用服务器的时候,有时候会遇到OOM :PermGen space 的错误。 Virtual区: 最大内存和初始内存的差值,就是Virtual区。 JDK1.8 JDK1.8 由2部分组成,新生代(Eden + 2*Survivor ) + 年老代(OldGen ) JDK1.8中变化最大是,Perm永久区用Metaspace进行了替换 注意:Metaspace所占用的内存空间不是在虚拟机内部,而是在本地内存空间中。区别于JDK1.7 JDK1.9 JDK1.9 取消新生代、老年代的物理划分 将堆划分为若干个区域(Region),这些区域中包含了有逻辑上的新生代、老年代区域 虚拟机栈 栈帧(Stack Frame)是用于支持虚拟机进行方法执行的数据结构。 栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。 栈内存为线程私有的空间,每个线程都会创建私有的栈内存,生命周期与线程相同,每个Java方法在执行的时候都会创建一个栈帧(Stack Frame)。栈内存大小决定了方法调用的深度,栈内存过小则会导致方法调用的深度较小,如递归调用的次数较少。 JVM 直接对虚拟机栈的操作只有两个:每个方法执行,伴随着入栈(进栈/压栈),方法执行结束出栈。 栈不存在垃圾回收问题。 当前栈帧 一个线程中方法的调用链可能会很长,所以会有很多栈帧。只有位于JVM虚拟机栈栈顶的元素才是有效的,即称为当前栈帧,与这个栈帧相关连的方法称为当前方法,定义这个方法的类叫做当前类。

JVM类加载器

类加载器 启动类加载器(Bootstrap ClassLoader) 负责加载 JAVA_HOME\lib 目录的或通过-Xbootclasspath参数指定路径中的且被虚拟机认可(rt.jar)的类库 扩展类加载器(Extension ClassLoader) 负责加载 JAVA_HOME\lib\ext 目录或通过java.ext.dirs系统变量指定路径中的类库 应用程序类加载器(Application ClassLoader) 负责加载用户路径classpath上的类库 自定义类加载器(User ClassLoader) 加载应用之外的类文件 JVM类加载器 执行顺序 检查顺序是自底向上:加载过程中会先检查类是否被已加载,从Custom到BootStrap逐层检查,只要某个类加载器已加载就视为此类已加载,保证此类所有ClassLoader只加载一次 加载的顺序是自顶向下:也就是由上层来逐层尝试加载此类。 加载时机与过程 类加载的四时机: 遇到new、getStatic、putStatic、invokeStatic四条指令时 使用java.lang.reflect包方法时,对类进行反射调用 初始化一个类时,发现其父类还没初始化,要先初始化其父类 当虚拟机启动时,用户需要指定一个主类main,需要先将主类加载 一个类的一生 类的生命周期 类加载做了什么?主要做三件事 类全限定名称 → 二进制字节流加载class文件 字节流静态数据 → 方法区(永久代,元空间) 创建字节码Class对象 JVM类加载机制 全盘负责, 当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入 父类委托,先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类 缓存机制,缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效 双亲委派机制, 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。 类加载途径 jar/war jsp生成的class 数据库中的二进制字节流 网络中的二进制字节流 动态代理生成的二进制字节流 类加载途径 双亲委派模型与打破双亲委派 什么是双亲委派? 当一个类加载器收到类加载任务,会先交给其父类加载器去完成,因此最终加载任务都会传递到顶层的启动类加载器,只有当父类加载器无法完成加载任务时,才会尝试执行加载任务 为什么需要双亲委派呢? 主要考虑安全因素,双亲委派可以避免重复加载核心的类,当父类加载器已经加载了该类时,子类加载 器不会再去加载 比如:要加载位于rt.jar包中的类java.lang.Object,不管是哪个加载器加载,最终都委托给顶层的启动 类加载器进行加载,这样就可以保证使用不同的类加载器最终得到的都是同样的Object对象。 为什么还需要破坏双亲委派 在实际应用中,双亲委派解决了Java 基础类统一加载的问题,但是却存在着缺陷。JDK中的基础类作为典型的API被用户调用,但是也存在API调用用户代码的情况,典型的如:SPI代码。这种情况就需要打破双亲委派模式。 数据库驱动DriverManager。以Driver接口为例,Driver接口定义在JDK中,其**实现由各个数据库的服务商来提供,由系统类加载器加载。**这个时候就需要 启动类加载器来委托 子类来加载Driver实现,这就破坏了双亲委派。 如何破坏双亲委派 重写ClassLoader的loadClass方法 在 jdk 1.2 之前,那时候还没有双亲委派模型,不过已经有了 ClassLoader 这个抽象类,所以已经有人继承这个抽象类,重写 loadClass 方法来实现用户自定义类加载器。 而在 1.

JVM概览

JVM概览 什么是JVM?广义上的JVM是指一种规范,狭义上的JVM指的是Hotspot类的虚拟机实现 Java语言与JVM的关系:Java语言编写程序生成class字节码在JVM虚拟机里执行。其他语言也可以如Scala、Groovy 主要知识 JVM基本常识 类加载系统 运行时数据区(JVM 内存结构) 一个对象的一生(出生、死亡与内涵) GC垃圾收集器 JVM调优相关工具与可调参数 调优案例 JVM架构图 参考文章 https://www.pdai.tech/md/java/jvm/java-jvm-jmm.html https://www.pdai.tech/md/java/jvm/java-jvm-struct.html https://www.pdai.tech/md/java/jvm/java-jvm-x-introduce.html