0%

Kafka

拓扑结构

ef35529683d3fe948250b8a4985450cb.png

  1. producer:
      消息生产者,发布消息到 kafka 集群的终端或服务。
  2. broker:
      kafka 集群中包含的服务器。
  3. topic:
      每条发布到 kafka 集群的消息属于的类别,即 kafka 是面向 topic 的。
  4. partition:
      partition 是物理上的概念,每个 topic 包含一个或多个 partition。kafka 分配的单位是 partition。
  5. consumer:
      从 kafka 集群中消费消息的终端或服务。
  6. Consumer group:
      high-level consumer API 中,每个 consumer 都属于一个 consumer group,每条消息只能被 consumer group 中的一个 Consumer 消费,但可以被多个 consumer group 消费。
  7. replica:
      partition 的副本,保障 partition 的高可用。
  8. leader:
      replica 中的一个角色, producer 和 consumer 只跟 leader 交互。
  9. follower:
      replica 中的一个角色,从 leader 中复制数据。
  10. controller:
      kafka 集群中的其中一个服务器,用来进行 leader election 以及 各种 failover。
  11. zookeeper:
      kafka 通过 zookeeper 来存储集群的 meta 信息。

zookeeper节点

eaec86120c4f10c45efdb6ca8d5b760b.png

producer

写入方式:

producer 采用 push 模式将消息发布到 broker,每条消息都被 append 到 patition 中,属于顺序写磁盘(顺序写磁盘效率比随机写内存要高,保障 kafka 吞吐率)。

分区路由:

producer 发送消息到 broker 时,会根据分区算法选择将其存储到哪一个 partition。其路由机制为:

  1. 指定了 patition,则直接使用;
  2. 未指定 patition 但指定 key,通过对 key 的 value 进行hash 选出一个 patition
  3. patition 和 key 都未指定,使用轮询选出一个 patition。

写入流程:

  1. producer 先从 zookeeper 的 “/brokers/…/state” 节点找到该 partition 的 leader
  2. producer 将消息发送给该 leader
  3. leader 将消息写入本地 log
  4. followers 从 leader pull 消息,写入本地 log 后 leader 发送 ACK
  5. leader 收到所有 ISR 中的 replica 的 ACK 后,增加 HW(high watermark,最后 commit 的 offset) 并向 producer 发送 ACK

producer delivery guarantee

一般情况下存在三种情况:

  1. At most once 消息可能会丢,但绝不会重复传输
  2. At least one 消息绝不会丢,但可能会重复传输
  3. Exactly once 每条消息肯定会被传输一次且仅传输一次

broker

存储方式:

物理上把 topic 分成一个或多个 patition(对应 server.properties 中的 num.partitions=3 配置),每个 patition 物理上对应一个文件夹(该文件夹存储该 patition 的所有消息和索引文件)

存储策略

无论消息是否被消费,kafka 都会保留所有消息。有两种策略可以删除旧数据:

  1. 基于时间:log.retention.hours=168
  2. 基于大小:log.retention.bytes=1073741824

HA

replication

同一个 partition 可能会有多个 replica(对应 server.properties 配置中的 default.replication.factor=N)。没有 replica 的情况下,一旦 broker 宕机,其上所有 patition 的数据都不可被消费,同时 producer 也不能再将数据存于其上的 patition。引入replication 之后,同一个 partition 可能会有多个 replica,而这时需要在这些 replica 之间选出一个 leader,producer 和 consumer 只与这个 leader 交互,其它 replica 作为 follower 从 leader 中复制数据。

Kafka 分配 Replica 的算法如下:

  1. 将所有 broker(假设共 n 个 broker)和待分配的 partition 排序
  2. 将第 i 个 partition 分配到第(i mod n)个 broker 上
  3. 将第 i 个 partition 的第 j 个 replica 分配到第((i + j) mode n)个 broker上

leader failover

当 partition 对应的 leader 宕机时,需要从 follower 中选举出新 leader。在选举新leader时,一个基本的原则是,新的 leader 必须拥有旧 leader commit 过的所有消息。

kafka 在 zookeeper 中(/brokers/…/state)动态维护了一个 ISR(in-sync replicas),由3.3节的写入流程可知 ISR 里面的所有 replica 都跟上了 leader,只有 ISR 里面的成员才能选为 leader。对于 f+1 个 replica,一个 partition 可以在容忍 f 个 replica 失效的情况下保证消息不丢失。

当所有 replica 都不工作时,有两种可行的方案:

  1. 等待 ISR 中的任一个 replica 活过来,并选它作为 leader。可保障数据不丢失,但时间可能相对较长。
  2. 选择第一个活过来的 replica(不一定是 ISR 成员)作为 leader。无法保障数据不丢失,但相对不可用时间较短。

broker failover

  1. controller 在 zookeeper 的 /brokers/ids/[brokerId] 节点注册 Watcher,当 broker 宕机时 zookeeper 会 fire watch
  2. controller 从 /brokers/ids 节点读取可用broker
  3. controller决定set_p,该集合包含宕机 broker 上的所有 partition
  4. 对 set_p 中的每一个 partition
    4.1 从/brokers/topics/[topic]/partitions/[partition]/state 节点读取 ISR
    4.2 决定新 leader(如4.3节所描述)
    4.3 将新 leader、ISR、controller_epoch 和 leader_epoch 等信息写入 state 节点
  5. 通过 RPC 向相关 broker 发送 leaderAndISRRequest 命令

controller failover

当 controller 宕机时会触发 controller failover。每个 broker 都会在 zookeeper 的 “/controller” 节点注册 watcher,当 controller 宕机时 zookeeper 中的临时节点消失,所有存活的 broker 收到 fire 的通知,每个 broker 都尝试创建新的 controller path,只有一个竞选成功并当选为 controller。

当新的 controller 当选时,会触发 KafkaController.onControllerFailover 方法,在该方法中完成如下操作

  1. 读取并增加 Controller Epoch。
  2. 在 reassignedPartitions Patch(/admin/reassign_partitions) 上注册 watcher。
  3. 在 preferredReplicaElection Path(/admin/preferred_replica_election) 上注册 watcher。
  4. 通过 partitionStateMachine 在 broker Topics Patch(/brokers/topics) 上注册 watcher。
  5. 若 delete.topic.enable=true(默认值是 false),则 partitionStateMachine 在 Delete Topic Patch(/admin/delete_topics) 上注册 watcher。
  6. 通过 replicaStateMachine在 Broker Ids Patch(/brokers/ids)上注册Watch。
  7. 初始化 ControllerContext 对象,设置当前所有 topic,“活”着的 broker 列表,所有 partition 的 leader 及 ISR等。
  8. 启动 replicaStateMachine 和 partitionStateMachine。
  9. 将 brokerState 状态设置为 RunningAsController。
  10. 将每个 partition 的 Leadership 信息发送给所有“活”着的 broker。
  11. 若 auto.leader.rebalance.enable=true(默认值是true),则启动 partition-rebalance 线程。
  12. 若 delete.topic.enable=true 且Delete Topic Patch(/admin/delete_topics)中有值,则删除相应的Topic。

consumer

The high-level consumer API

high-level consumer API 提供了 consumer group 的语义,一个消息只能被 group 内的一个 consumer 所消费,且 consumer 消费消息时不关注 offset,最后一个 offset 由 zookeeper 保存。

使用 high-level consumer API 可以是多线程的应用,应当注意:

  1. 如果消费线程大于 patition 数量,则有些线程将收不到消息
  2. 如果 patition 数量大于线程数,则有些线程多收到多个 patition 的消息
  3. 如果一个线程消费多个 patition,则无法保证你收到的消息的顺序,而一个 patition 内的消息是有序的

The SimpleConsumer API

如果你想要对 patition 有更多的控制权,那就应该使用 SimpleConsumer API,比如:

  1. 多次读取一个消息
  2. 只消费一个 patition 中的部分消息
  3. 使用事务来保证一个消息仅被消费一次

但是使用此 API 时,partition、offset、broker、leader 等对你不再透明,需要自己去管理。你需要做大量的额外工作:

  1. 必须在应用程序中跟踪 offset,从而确定下一条应该消费哪条消息
  2. 应用程序需要通过程序获知每个 Partition 的 leader 是谁
  3. 需要处理 leader 的变更

consumer group

kafka 的分配单位是 patition。每个 consumer 都属于一个 group,一个 partition 只能被同一个 group 内的一个 consumer 所消费(也就保障了一个消息只能被 group 内的一个 consuemr 所消费),但是多个 group 可以同时消费这个 partition。

kafka 的设计目标之一就是同时实现离线处理和实时处理,根据这一特性,可以使用 spark/Storm 这些实时处理系统对消息在线处理,同时使用 Hadoop 批处理系统进行离线处理,还可以将数据备份到另一个数据中心,只需要保证这三者属于不同的 consumer group。

消费方式

consumer 采用 pull 模式从 broker 中读取数据。

push 模式很难适应消费速率不同的消费者,因为消息发送速率是由 broker 决定的。它的目标是尽可能以最快速度传递消息,但是这样很容易造成 consumer 来不及处理消息,典型的表现就是拒绝服务以及网络拥塞。而 pull 模式则可以根据 consumer 的消费能力以适当的速率消费消息。

对于 Kafka 而言,pull 模式更合适,它可简化 broker 的设计,consumer 可自主控制消费消息的速率,同时 consumer 可以自己控制消费方式——即可批量消费也可逐条消费,同时还能选择不同的提交方式从而实现不同的传输语义。

consumer delivery guarantee

如果将 consumer 设置为 autocommit,consumer 一旦读到数据立即自动 commit。如果只讨论这一读取消息的过程,那 Kafka 确保了 Exactly once。

但实际使用中应用程序并非在 consumer 读取完数据就结束了,而是要进行进一步处理,而数据处理与 commit 的顺序在很大程度上决定了consumer delivery guarantee:

  1. 读完消息先 commit 再处理消息。
    这种模式下,如果 consumer 在 commit 后还没来得及处理消息就 crash 了,下次重新开始工作后就无法读到刚刚已提交而未处理的消息,这就对应于 At most once
  2. 读完消息先处理再 commit。
    这种模式下,如果在处理完消息之后 commit 之前 consumer crash 了,下次重新开始工作时还会处理刚刚未 commit 的消息,实际上该消息已经被处理过了。这就对应于 At least once。
  3. 如果一定要做到 Exactly once,就需要协调 offset 和实际操作的输出。
    精典的做法是引入两阶段提交。如果能让 offset 和操作输入存在同一个地方,会更简洁和通用。这种方式可能更好,因为许多输出系统可能不支持两阶段提交。比如,consumer 拿到数据后可能把数据放到 HDFS,如果把最新的 offset 和数据本身一起写到 HDFS,那就可以保证数据的输出和 offset 的更新要么都完成,要么都不完成,间接实现 Exactly once。(目前就 high-level API而言,offset 是存于Zookeeper 中的,无法存于HDFS,而SimpleConsuemr API的 offset 是由自己去维护的,可以将之存于 HDFS 中)

总之,Kafka 默认保证 At least once,并且允许通过设置 producer 异步提交来实现 At most once

consumer rebalance

当有 consumer 加入或退出、以及 partition 的改变(如 broker 加入或退出)时会触发 rebalance。consumer rebalance算法如下:

  1. 将目标 topic 下的所有 partirtion 排序,存于PT
  2. 对某 consumer group 下所有 consumer 排序,存于 CG,第 i 个consumer 记为 Ci
  3. N=size(PT)/size(CG),向上取整
  4. 解除 Ci 对原来分配的 partition 的消费权(i从0开始)
  5. 将第iN到(i+1)N-1个 partition 分配给 Ci

在 0.8.*版本,每个 consumer 都只负责调整自己所消费的 partition,为了保证整个consumer group 的一致性,当一个 consumer 触发了 rebalance 时,该 consumer group 内的其它所有其它 consumer 也应该同时触发 rebalance。这会导致以下几个问题:

  1. Herd effect
      任何 broker 或者 consumer 的增减都会触发所有的 consumer 的 rebalance
  2. Split Brain
      每个 consumer 分别单独通过 zookeeper 判断哪些 broker 和 consumer 宕机了,那么不同 consumer 在同一时刻从 zookeeper 看到的 view 就可能不一样,这是由 zookeeper 的特性决定的,这就会造成不正确的 reblance 尝试。
  3. 调整结果不可控
      所有的 consumer 都并不知道其它 consumer 的 rebalance 是否成功,这可能会导致 kafka 工作在一个不正确的状态。

基于以上问题,kafka 设计者考虑在0.9.*版本开始使用中心 coordinator 来控制 consumer rebalance,然后又从简便性和验证要求两方面考虑,计划在 consumer 客户端实现分配方案。

nsq区别

核心区别:

  1. kafka是pull,nsq是push
  2. nsq不固化数据

丢消息

刨根问底,kafka 到底会不会丢消息

生产者丢失消息

Kafka 通过配置 request.required.acks 属性来确认消息的生产:

  • 0 表示不进行消息接收是否成功的确认;不能保证消息是否发送成功,生成环境基本不会用。
  • 1 表示当 Leader 接收成功时确认;只要 Leader 存活就可以保证不丢失,保证了吞吐量。
  • -1 或者 all 表示 Leader 和 Follower 都接收成功时确认;可以最大限度保证消息不丢失,但是吞吐量低。

kafka producer 的参数 acks 的默认值为 1,所以默认的 producer 级别是 at least once,并不能 exactly once。

  • 如果 acks 配置为 0,发生网络抖动消息丢了,生产者不校验 ACK 自然就不知道丢了。
  • 如果 acks 配置为 1 保证 leader 不丢,但是如果 leader 挂了,恰好选了一个没有 ACK 的 follower,那也丢了。
  • all:保证 leader 和 follower 不丢,但是如果网络拥塞,没有收到 ACK,会有重复发的问题。

Kafka Broker 丢失消息

操作系统本身有一层缓存,叫做 Page Cache,当往磁盘文件写入的时候,系统会先将数据流写入缓存中,至于什么时候将缓存的数据写入文件中是由操作系统自行决定。

Kafka 提供了一个参数 producer.type 来控制是不是主动 flush,如果 Kafka 写入到 mmap 之后就立即 flush 然后再返回 Producer 叫同步 (sync);写入 mmap 之后立即返回 Producer 不调用 flush 叫异步 (async)。

Kafka 通过多分区多副本机制中已经能最大限度保证数据不会丢失,如果数据已经写入系统 cache 中但是还没来得及刷入磁盘,此时突然机器宕机或者掉电那就丢了,当然这种情况很极端。

消费者丢失消息

  • 先 commit 再处理消息。如果在处理消息的时候异常了,但是 offset 已经提交了,这条消息对于该消费者来说就是丢失了,再也不会消费到了。
  • 先处理消息再 commit。如果在 commit 之前发生异常,下次还会消费到该消息,重复消费的问题可以通过业务保证消息幂等性来解决。

RocketMQ

持久化 & 多副本

RocketMQ默认配置为 3副本、异步复制、异步刷盘,类比kafka的的replica_factor=3, acks=leader;在这种配置之下,可靠性99.99%,可用性99.95%

同时可以针对特殊场景,提供同步刷盘、同步复制的集群,提供更高的可靠性,在6副本、同步复制、同步刷盘的配置下,消息可靠性可达9个9。相应的,写入延时会升高,qps、availability会降低

RocketMQ的消息默认保留48小时,由于存储模型的关系,这个配置是集群级别的,不像kafka是topic级别的。所以如果有特殊的需求,请联系@沈辉 视情况单独搭建集群

消息重试(Requeue)

kafka设计之初主要作为 log (特指类似binlog这种WAL)的传输总线,同一个partition内的有序是默认需求,这意味着消费失败的消息是不能重入的,否则消息的顺序性就被破坏了;用户需要自己打日志或者转储来处理消费失败的消息。

而在MQ领域,大多数场景下其实并不需要严格的顺序,对于消费失败的消息(可能是消费者下游短时间不可用、load高等),这种情况下用户希望消息能够重新投递到MQ,可以不用额外写代码处理错误的消息。RocketMQ 在非有序消费模式下,支持消息重试,类比NSQ的requeue机制,并且重试的消息具有和普通消息一样的持久化支持。

死信队列 (Dead Letter Queue)

当消息多次重试仍然消费失败,这种情况多半是消费者逻辑有问题,比如对于消息的某些字段没有兼容等;需要有地方能够转储这一批消息。RocketMQ提供了死信队列,消费者可以指定消息最大重试次数,当消息重试超过该次数,消息将会发往死信队列。待消费者的问题解决之后,可以从死信队列拉取这些消息统一处理。

延时消息

一些场景下,生产者发送消息成功后,希望delay一段时间消息再投递给消费者,比如创建一个订单之后30分钟内未支付则取消订单的场景。

NSQ支持ms级别精度的延时消息,其实现为内存里的一个priority queue,没有持久化;kafka不支持延时消息;当前版本的RocketMQ支持分级的时延,比如支持10s、30s、1min、5min、10min、15min、20min、30min等多个级别时延,足够覆盖大部分场景,并且有持久化。

需要任意时延的延迟消息用户,见: 延时消息用户文档

写入延迟保障

RocketMQ和kafka都是顺序写盘,充分利用page cache以获得低时延和吞吐。kafka的存储模型为每个partition都由若干segment(物理文件)组成的逻辑文件,当kafka集群上的topic/partition数量多了之后,kafka的顺序写可能会逐渐退化到随机写,写入延迟上涨;只能拆分集群,减少单集群上topic/partition数量。

而RocketMQ的存储模型与kafka不同,所有消息都写到一个CommitLog中即返回,再异步将offset、length信息dispatch到各Consume Queue中,所以是严格的顺序写,在topic数量很多的时候,page fault仍然维持在低水平,写入时延较为稳定。

在异步复制、异步刷盘下,写入延时的avg为10ms,pct99为20ms

基于时间回溯

比如30分钟前消费者下游故障,期间消费者全部异常,用户希望将消费进度回拨到30min前,达到”补”消息的效果。kafka需要集群版本在0.10以上才能支持,RocketMQ默认支持。

顺序/乱序消费

RocketMQ同时提供了顺序和乱序消费。 顺序的保障是,消息写到broker上的一个Consume Queue的顺序和消费者从该Consume Queue读取的顺序是一致的,多个producer并发写到一个Consume Queue的状况顺序没有保障,实际的顺序以broker收到的顺序为准。

一般有序消息都是同步发送的,也即msg n发送成功并且client收到写入成功的结果之后才会发送msg n+1,异步/并发的有序消息是个伪需求。

对于消息没有顺序要求的场景,可以使用乱序消费,并发度为消费者实例数 * 线程/协程数。

长轮询

长轮询通过客户端和服务端的配合,达到主动权在客户端,同时也能保证数据的实时性;长轮询本质上也是轮询,只不过对普通的轮询做了优化处理,服务端在没有数据的时候并不是马上返回数据,会hold住请求,等待服务端有数据,或者一直没有数据超时处理,然后一直循环下去;下面看一下如何简单实现一个长轮询;

暂不提供什么

  1. 广播消费(TBD)
    即同一个consumer group内的所有消费者每一个实例都能收到全量消息;可以通过每个消费者使用独立的Consumer group达到效果。
  2. 事务消息 (TBD)
    事务消息指:Producer发消息和Producer的其他操作(如写DB)形成事务,如果其他操作Commit,则消息Consumer可见;如果其他操作Rollback,则消息也不会投递给Consumer。
    原生的RocketMQ的java SDK支持事务消息,现阶段其他语言暂不支持,后续会支持。

vs Kafka

技术选型:RocketMQ or Kafka

(1) 适用场景

Kafka适合日志处理;

RocketMQ适合业务处理。

(2) 性能

Kafka单机写入 TPS 号称在百万条/秒;

RocketMQ 大约在10万条/秒。

结论:追求性能的话,Kafka单机性能更高。

(3) 可靠性

RocketMQ支持异步/同步刷盘;异步/同步Replication;

Kafka使用异步刷盘方式,异步Replication。

结论:RocketMQ所支持的同步方式提升了数据的可靠性。

(4) 实时性

均支持pull长轮询,RocketMQ消息实时性更好

结论:RocketMQ 胜出。

(5) 支持的队列数

Kafka单机超过64个队列/分区,消息发送性能降低严重;

RocketMQ 单机支持最高5万个队列,性能稳定

结论:长远来看,RocketMQ 胜出,这也是适合业务处理的原因之一

(6) 消息顺序性

Kafka 某些配置下,支持消息顺序,但是一台Broker宕机后,就会产生消息乱序;

RocketMQ支持严格的消息顺序,在顺序消息场景下,一台Broker宕机后,

发送消息会失败,但是不会乱序;

结论:RocketMQ 胜出

(7)消费失败重试机制

Kafka消费失败不支持重试

RocketMQ消费失败支持定时重试,每次重试间隔时间顺延。

(8)定时/延时消息

Kafka不支持定时消息;

RocketMQ支持定时消息

(9)分布式事务消息

Kafka不支持分布式事务消息;

阿里云ONS支持分布式定时消息,未来开源版本的RocketMQ也有计划支持分布式事务消息

(10)消息查询机制

Kafka不支持消息查询

RocketMQ支持根据Message Id查询消息,也支持根据消息内容查询消息

(11)消息回溯

Kafka理论上可以按照Offset来回溯消息

RocketMQ支持按照时间来回溯消息,精度毫秒,例如从一天之前的某时某分某秒开始重新消费消息

Java线程池实现原理及其在美团业务中的实践

参数详解

核心线程数:corePoolSize

线程池中活跃的线程数,即使它们是空闲的,除非设置了allowCoreThreadTimeOut为true。allowCoreThreadTimeOut的值是控制核心线程数是否在没有任务时是否停止活跃的线程,当它的值为true时,在线程池没有任务时,所有的工作线程都会停止。

最大线程数:maximumPoolSize

线程池所允许存在的最大线程数。

多余线程存活时长:keepAliveTime

线程池中除核心线程数之外的线程(多余线程)的最大存活时间,如果在这个时间范围内,多余线程没有任务需要执行,则多余线程就会停止。(注意:多余线程数 = 最大线程数 - 核心线程数)

时间单位:unit

多余线程存活时间的单位,可以是分钟、秒、毫秒等。

任务队列:workQueue

线程池的任务队列,使用线程池执行任务时,任务会先提交到这个队列中,然后工作线程取出任务进行执行,当这个队列满了,线程池就会执行拒绝策略。

线程工厂:threadFactory

创建线程池的工厂,线程池将使用这个工厂来创建线程池,自定义线程工厂需要实现ThreadFactory接口。

拒绝执行处理器(也称拒绝策略):handler

当线程池无空闲线程,并且任务队列已满,此时将线程池将使用这个处理器来处理新提交的任务。

内存溢出

java.lang.OutOfMemoryError,是指程序在申请内存时,没有足够的内存空间供其使用,出现OutOfMemoryError。

产生该错误的原因主要包括:

  • JVM内存过小。
  • 程序不严密,产生了过多的垃圾。

解决方法:

  1. 增加JVM的内存大小
    对于tomcat容器,找到tomcat在电脑中的安装目录,进入这个目录,然后进入bin目录中,在window环境下找到bin目录中的catalina.bat,在linux环境下找到catalina.sh。
    编辑catalina.bat文件,找到JAVA_OPTS(具体来说是 set “JAVA_OPTS=%JAVA_OPTS% %LOGGING_MANAGER%”)这个选项的位置,这个参数是Java启动的时候,需要的启动参数。
    也可以在操作系统的环境变量中对JAVA_OPTS进行设置,因为tomcat在启动的时候,也会读取操作系统中的环境变量的值,进行加载。
    如果是修改了操作系统的环境变量,需要重启机器,再重启tomcat,如果修改的是tomcat配置文件,需要将配置文件保存,然后重启tomcat,设置就能生效了。
  2. 优化程序,释放垃圾
    主要思路就是避免程序体现上出现的情况。避免死循环,防止一次载入太多的数据,提高程序健壮型及时释放。因此,从根本上解决Java内存溢出的唯一方法就是修改程序,及时地释放没用的对象,释放内存空间。

内存泄漏

Memory Leak,是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。

在Java中,内存泄漏就是存在一些被分配的对象,这些对象有下面两个特点。

  1. 首先,这些对象是可达的,即在有向图中,存在通路可以与其相连;
  2. 其次,这些对象是无用的,即程序以后不会再使用这些对象。

排查方法:

jstat命令格式为:

jstat [ option vmid [interval[s|ms] [count]] ]

使用命令如下:

jstat -gcutil 20954 1000

意思是每1000毫秒查询一次,一直查。gcutil的意思是已使用空间站总空间的百分比。

知道Eden Survivor Old区大小,以及GC时间

jmap:

jmap命令格式:

jmap [ option ] vmid

使用命令如下:

jmap -histo:live 20954

jvm

内存结构

ea2f6e47123182c285fbba70ff1d6080.png

方法区和堆是所有线程共享的内存区域;而java栈、本地方法栈和程序员计数器是运行是线程私有的内存区域。

  • Java堆(Heap),是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
  • 方法区(Method Area),方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
  • 程序计数器(Program Counter Register),程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。
  • JVM栈(JVM Stacks),与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
  • 本地方法栈(Native Method Stacks),本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。

通常我们定义一个基本数据类型的变量,一个对象的引用,还有就是函数调用的现场保存都使用JVM中的栈空间;而通过new关键字和构造器创建的对象则放在堆空间,堆是垃圾收集器管理的主要区域,由于现在的垃圾收集器都采用分代收集算法,所以堆空间还可以细分为新生代和老生代,再具体一点可以分为Eden、Survivor(又可分为From Survivor和To Survivor)、Tenured;方法区和堆都是各个线程共享的内存区域,用于存储已经被JVM加载的类信息、常量、静态变量、JIT编译器编译后的代码等数据;程序中的字面量(literal)如直接书写的100、”hello”和常量都是放在常量池中,常量池是方法区的一部分,。栈空间操作起来最快但是栈很小,通常大量的对象都是放在堆空间,栈和堆的大小都可以通过JVM的启动参数来进行调整,栈空间用光了会引发StackOverflowError,而堆和常量池空间不足则会引发OutOfMemoryError。

类加载

类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。类的加载的最终产品是位于堆区中的Class对象,Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。

类加载器

39f288db5465391b1f8a4d472b0aedd1.png

  • 启动类加载器:Bootstrap ClassLoader,负责加载存放在JDK\jre\lib(JDK代表JDK的安装目录,下同)下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库
  • 扩展类加载器:Extension ClassLoader,该加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载DK\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器。
  • 应用程序类加载器:Application ClassLoader,该类加载器由sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器

类加载原理

JVM中类的装载是由类加载器(ClassLoader)和它的子类来实现的,Java中的类加载器是一个重要的Java运行时系统组件,它负责在运行时查找和装入类文件中的类。

由于Java的跨平台性,经过编译的Java源程序并不是一个可执行程序,而是一个或多个类文件。当Java程序需要使用某个类时,JVM会确保这个类已经被加载、连接(验证、准备和解析)和初始化。类的加载是指把类的.class文件中的数据读入到内存中,通常是创建一个字节数组读入.class文件,然后产生与所加载类对应的Class对象。加载完成后,Class对象还不完整,所以此时的类还不可用。当类被加载后就进入连接阶段,这一阶段包括验证、准备(为静态变量分配内存并设置默认的初始值)和解析(将符号引用替换为直接引用)三个步骤。最后JVM对类进行初始化,包括:

  1. 如果类存在直接的父类并且这个类还没有被初始化,那么就先初始化父类;
  2. 如果类中存在初始化语句,就依次执行这些初始化语句。

类的加载是由类加载器完成的,类加载器包括:根加载器(BootStrap)、扩展加载器(Extension)、系统加载器(System)和用户自定义类加载器(java.lang.ClassLoader的子类)。

从Java 2(JDK 1.2)开始,类加载过程采取了父亲委托机制(PDM)。PDM更好的保证了Java平台的安全性,在该机制中,JVM自带的Bootstrap是根加载器,其他的加载器都有且仅有一个父类加载器。类的加载首先请求父类加载器加载,父类加载器无能为力时才由其子类加载器自行加载。JVM不会向Java程序提供对Bootstrap的引用。下面是关于几个类加载器的说明:

  • Bootstrap:一般用本地代码实现,负责加载JVM基础核心类库(rt.jar);
  • Extension:从java.ext.dirs系统属性所指定的目录中加载类库,它的父加载器是Bootstrap;
  • System:又叫应用类加载器,其父类是Extension。它是应用最广泛的类加载器。它从环境变量classpath或者系统属性java.class.path所指定的目录中记载类,是用户自定义加载器的默认父加载器。

类生命周期

fd7020a8b641573f4f7b5e51dc2c4cc4.png

  • 加载,查找并加载类的二进制数据,在Java堆中也创建一个java.lang.Class类的对象
  • 连接,连接又包含三块内容:验证、准备、初始化。
    1. 验证,文件格式、元数据、字节码、符号引用验证;
    2. 准备,为类的静态变量分配内存,并将其初始化为默认值;
    3. 解析,把类中的符号引用转换为直接引用
  • 初始化,为类的静态变量赋予正确的初始值
  • 使用,new出对象程序中使用
  • 卸载,执行垃圾回收

对象

创建过程

  1. JVM遇到一条新建对象的指令时首先去检查这个指令的参数是否能在常量池中定义到一个类的符号引用。然后加载这个类
  2. 为对象分配内存。一种办法“指针碰撞”、一种办法“空闲列表”,最终常用的办法“本地线程缓冲分配(TLAB)”
  3. 将除对象头外的对象内存空间初始化为0
  4. 对对象头进行必要设置

对象组成

由三个部分组成,对象头、实例数据、对齐填充。

对象头由两部分组成

  1. 存储对象自身的运行时数据:哈希码、GC分代年龄、锁标识状态、线程持有的锁、偏向线程ID(一般占32/64 bit)。
  2. 指针类型,指向对象的类元数据类型(即对象代表哪个类)。如果是数组对象,则对象头中还有一部分用来记录数组长度。

实例数据用来存储对象真正的有效信息(包括父类继承下来的和自己定义的)

对齐填充:JVM要求对象起始地址必须是8字节的整数倍(8字节对齐)

垃圾回收

判断对象可以被回收

判断对象是否存活一般有两种方式:

  • 引用计数:每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。此方法简单,无法解决对象相互循环引用的问题。
  • 可达性分析(Reachability Analysis):从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,不可达对象。

引用分类

  • 强引用:GC时不会被回收
  • 软引用:描述有用但不是必须的对象,在发生内存溢出异常之前被回收
  • 弱引用:描述有用但不是必须的对象,在下一次GC时被回收
  • 虚引用(幽灵引用/幻影引用):无法通过虚引用获得对象,用PhantomReference实现虚引用,虚引用用来在GC时返回一个通知。

回收算法

GC最基础的算法有三种: 标记 -清除算法、复制算法、标记-压缩算法,我们常用的垃圾回收器一般都采用分代收集算法。

  • 标记 -清除算法,“标记-清除”(Mark-Sweep)算法,如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。
  • 复制算法,“复制”(Copying)的收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
  • 标记-压缩算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
  • 分代收集算法,“分代收集”(Generational Collection)算法,把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

分代

新生代(Young)

新生成的对象优先存放在新生代中,新生代对象朝生夕死,存活率很低,在新生代中,常规应用进行一次垃圾收集一般可以回收70% ~ 95% 的空间,回收效率很高。

HotSpot将新生代划分为三块,一块较大的Eden空间和两块较小的Survivor空间,默认比例为8:1:1。划分的目的是因为HotSpot采用复制算法来回收新生代,设置这个比例是为了充分利用内存空间,减少浪费。新生成的对象在Eden区分配(大对象除外,大对象直接进入老年代),当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。

GC开始时,对象只会存在于Eden区和From Survivor区,To Survivor区是空的(作为保留区域)。GC进行时,Eden区中所有存活的对象都会被复制到To Survivor区,而在From Survivor区中,仍存活的对象会根据它们的年龄值决定去向,年龄值达到年龄阀值(默认为15,新生代中的对象每熬过一轮垃圾回收,年龄值就加1,GC分代年龄存储在对象的header中)的对象会被移到老年代中,没有达到阀值的对象会被复制到To Survivor区。接着清空Eden区和From Survivor区,新生代中存活的对象都在To Survivor区。接着, From Survivor区和To Survivor区会交换它们的角色,也就是新的To Survivor区就是上次GC清空的From Survivor区,新的From Survivor区就是上次GC的To Survivor区,总之,不管怎样都会保证To Survivor区在一轮GC后是空的。GC时当To Survivor区没有足够的空间存放上一次新生代收集下来的存活对象时,需要依赖老年代进行分配担保,将这些对象存放在老年代中。

老年代(Old)

在新生代中经历了多次(具体看虚拟机配置的阀值)GC后仍然存活下来的对象会进入老年代中。老年代中的对象生命周期较长,存活率比较高,在老年代中进行GC的频率相对而言较低,而且回收的速度也比较慢。

永久代(Permanent)

永久代存储类信息、常量、静态变量、即时编译器编译后的代码等数据,对这一区域而言,Java虚拟机规范指出可以不进行垃圾收集,一般而言不会进行垃圾回收。

垃圾回收不会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全垃圾回收(Full GC)。如果你仔细查看垃圾收集器的输出信息,就会发现永久代也是被回收的。这就是为什么正确的永久代大小对避免Full GC是非常重要的原因。请参考下Java8:从永久代到元数据区 (注:Java8中已经移除了永久代,新加了一个叫做元数据区的native内存区)

垃圾回收器

串行垃圾回收器

GC线程只有一个,它会暂停所有工作线程,一个一个内存区域来收集,不适合服务器环境。通过JVM命令-XX:+UseSerialGC可以使用串行垃圾回收器。串行回收器也有两种:1.Serial:只对新生代使用;2.Serial Old:只对老年代使用,采用的算法不一样(一般作为CMS的替补)

并行垃圾回收器

GC使用多线程进行垃圾回收。通过JVM命令-XX:+UseParallGC可以使用并行垃圾回收器。并行回收器有三种:1.ParNew,作用于新生代; 2.Parallel Scavenge 作用于新生代,但以吞吐量为主;3.Parallel Old,作用于老年代,也已吞吐量为主,配合2使用。

并发标记扫描垃圾回收器(CMS)

多线程,标记清理(Full GC的时候用)通过JVM命令 -XX:+UseConcMarkSweepGC使用, 主要用于老生代,策略为:

年老代只有两次短暂停,其他时间应用程序与收集线程并发的清除。采用两次短暂停来替代标记整理算法的长暂停,它的收集周期:  

初始标记(CMS-initial-mark) -> 并发标记(CMS-concurrent-mark) -> 重新标记(CMS-remark)-> 并发清除(CMS-concurrent-sweep) ->并发重设状态等待下次CMS的触发(CMS-concurrent-reset)。

它的主要适合场景是对响应时间的重要性需求大于对吞吐量的要求,能够承受垃圾回收线程和应用线程共享处理器资源,并且应用中存在比较多的长生命周期的对象的应用。但CMS收集算法在最为耗时的内存区域遍历时采用多线程并发操作,对于服务器CPU资源不够的情况下,其实对性能是没有提升的,反而会导致系统吞吐量的下降;

G1垃圾回收器

适用于堆内存很大的情况,它将对内存分割成不同的区域,并且并发的对其进行回收,回收后对剩余内存压缩,标记整理,服务器端适用。

缺点:

  • 相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint) 还是程序运行时的额外执行负载(overload) 都要比CMS要高。
    从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用,上则发挥其优势。平衡点在6一8GB之间。

对象分配规则

  • 对象优先分配在Eden区,如果Eden区没有足够的空间时,虚拟机执行一次Minor GC。
  • 大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。
  • 长期存活的对象进入老年代。虚拟机为每个对象定义了一个年龄计数器,如果对象经过了1次Minor GC那么对象会进入Survivor区,之后每经过一次Minor GC那么对象的年龄加1,知道达到阀值对象进入老年区。
  • 动态判断对象的年龄。如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代。
  • 空间分配担保。每次进行Minor GC时,JVM会计算Survivor区移至老年区的对象的平均大小,如果这个值大于老年区的剩余值大小则进行一次Full GC,如果小于检查HandlePromotionFailure设置,如果true则只进行Monitor GC,如果false则进行Full GC。

Minor GC与Full GC

新生代GC(Minor GC):Minor GC指发生在新生代的GC,因为新生代的Java对象大多都是朝生夕死,所以Minor GC非常频繁,一般回收速度也比较快。当Eden空间不足以为对象分配内存时,会触发Minor GC。

老年代GC(Full GC/Major GC):Full GC指发生在老年代的GC,出现了Full GC一般会伴随着至少一次的Minor GC(老年代的对象大部分是Minor GC过程中从新生代进入老年代),比如:分配担保失败。Full GC的速度一般会比Minor GC慢10倍以上。当老年代内存不足或者显式调用System.gc()方法时,会触发Full GC。