Fork me on GitHub
每天进步一小点

一切就是这么简单


  • 首页

  • 关于

  • 标签

  • 分类

  • 归档

RabbitMQ入门

发表于 2018-08-14 | 更新于 2019-04-25 | 分类于 MQ

应用场景说明

  • 首先我们以实际的用户下单支付场景为例,其业务主流程如下:
    • 用户提交订单 —> 生成订单 —> 发消息(短信&微信推送)—> 支付 —> 回调通知订单支付成功
  • 上面的流程我们可以拆分为两个模块:

    • 订单模块和支付模块。在订单模块中主流程为生成订单之前的过程,在支付模块中回调通知订单是用户支付完后,第三方支付渠道(比如微信)通知支付回调网关,支付回调网关调用订单模块的回调接口
  • 从上述分析我们知道:

    • 发消息如果是同步发送,则会影响主流程业务,比如发送消息慢,则会影响订单服务的性能,考虑到发送消息的状态不是强一致的,所以我们采用异步发送机制
    • 订单模块和支付模块存在依赖关系,这样就会出现,订单模块的回调通知服务接口修改了,则支付模块的代码也会跟着修改,反之也是一样;还有如果订单服务网络超时等异常了则支付的回调通知网关也势必受到影响,所以我们想办法让其解耦
  • 而 MQ 正好满足两大特性 异步 + 解耦 ,其中 RabbitMQ 被企业广泛使用

而 MQ 正好满足两大特性 异步 + 解耦 ,其中 RabbitMQ 被企业广泛使用

  • RabbitMQ(以下简称 RQ ) 是部署最广泛的开源的消息中间件,由 Erlang 语言开发
  • 在 RQ 中有几个比较重要的理论概念:

    • AMQP

      • 是一种消息传递协议,它要求客户端之间、及和消息中间件之间保持一致的消息进行通信
    • Connections

      • 访问连接,它是建立在可靠的 TCP 连接之上,比如当客户端断开连接时不立即关闭 TCP 连接
    • Channels

      • 信道,客户端之间消息通过信道传输,一个 Connection 共享多个信道,它的一个主要作用是避免客户端直接对 TCP 建立和关闭所消耗的系统资源代价,可以看出 RQ 从底层设计时就考虑了高性能的应用
    • Exchanges

      • 交换机,它指定消息发送到哪个队列;流程是生产者将消息发给Exchange, 然后 Exchange 通过不同的类型(主要包括 fanout 、direct、topic )发送到不同的队列
    • Queues

      • 队列,存储消息的具体位置
    • Products

      • 生产者,消息的发送者
    • Consumers

      • 消费者,它订阅注册到特定队列,队列将消息“推”给消费者进行消息处理

重要特性

  • 为了保证消息的可靠传输,RQ 提供了几个比较重要的特性,我们生产环境一般都会采用

    • 持久化机制

      • 运维侧:在配置 Exchange 和 Queue 时设置 Durability=Durable 避免突然宕机引起的消息丢失
    • Ack 机制

      • 客户端消费者侧:消费者在处理完消息后通知 RQ 从队列内存中删除消息,保证消息已被消费者接收到
    • Confirm 机制

      • 客户端生产者侧:生产者将消息发送到 RQ 然后写入到磁盘后通知生成者已收到生产者消息,保证生产者发送的消息不会丢失

        • 支持两种通知方式:

          • 同步方式,即每发一条消息生成者等待 RQ 确认后再继续发送消息

          • 异步方式,即生产者提供回调函数入口,生产者发送完消息后不等待 RQ 回应继续发送消息,RQ 会回调通知生产者是否收到消息,一般实际生产环境用此方式比较多

消息模型

  • RQ 支持灵活的消息模型,概要总结主要包括以下几种

  • 队列模型

    • 生产者直接将消息发送到队列,有一个消费者或多个消费者获取消息进行消费,如果是多个消费者则队列将采用轮询的方式分发消息到各个消费者,保证消息被均衡消费
      RabbitMQ
  • 发布订阅模型

    • 生产者将消息发送到交换机,队列绑定到交换机,消费者订阅队列消息进行消费,即 Exchange 的 fanout 类型
      RabbitMQ
  • 路由模型

    • 生产者将消息发送到交换机并指定路由 key,队列绑定到交换机,并设定好路由规则。消费者从匹配上路由 key 的队列里面获取到推送的消息,即 Exchange 的 direct 类型,和 topic 类型的区别是:topic 可以模糊匹配路由 key 值
      RabbitMQ

基于 Spring Boot 访问核心 API 说明

  • Queue 对象

    • name:字符串类型,队列名称,用户自定义名称,符合业务描述即可。
    • durable:布尔类型,是否持久化,比如服务重启队列不丢,生成环境建议设置为 true。
    • autoDelete:布尔类型,是否自动删除,比如最后一个消费者下线后,队列将自动删除,生成环境建议设置为 false
  • Exchange 对象

    • name:交换机名称。
    • durable:是否持久化,比如服务重启交换机不丢。
    • autoDelete:是否自动删除,比如最后一个绑定的队列被删除,交换机将自动删除
    • 子类:
      • FanoutExchange,无路由规则,发布订阅模型。
      • DirectExchange,有路由规则,匹配路由 key. 路由模型。
      • TopicExchange, 有路由规则,匹配路由 key,路由模型,并支持 key 的模糊匹配
  • AmqpTemplate 对象

    • 基于 AMQP 协议,同步发送和接收队列消息,生产者侧使用
    • 基于 AMQP 协议,同步发送和接收队列消息,生产者侧使用
      1
      2
      3
      4
      5
      6
      7
      8
      9
      convertAndSend(java.lang.Object message)

      发送消息并指定路由 Key:

      convertAndSend(java.lang.String routingKey, java.lang.Object message)

      接收消息并转换成 Java 对象:

      receiveAndConvert()
  • RabbitListener 对象

    • 通过指定队列或绑定关系监听消息,消费者侧使用
  • AsyncAmqpTemplate 对象

    • 基于 AMQP 协议,异步发送和接收队列消息,生产者侧使用
  • BindingBuilder 对象

    • 操作将队列绑定到 Exchange 上,并可以指定路由 Key 规则

简单代码实例

  • 这段代码很简单,就是发送和消费消息,这里交换机和队列绑定通过代码控制配置实现(为演示用,直接感受下)。建议生产环境使用管理系统来创建交换机、队列及绑定关系,因为可视化操作比代码里面维护更方便,即代码级别一般开发人员负责实现消费者、生产者;交换机和队列及绑定关系由运维人员负责配置管理维护,开发人员进行协助

  • 添加依赖

    1
    2
    3
    4
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>
  • yml配置或者properties配置

    1
    2
    3
    4
    5
    6
    spring:
    rabbitmq:
    host: 127.0.0.1
    port: 5672
    userename: admin
    password: 123456
  • 生产者代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @Autowired
    AmqpTemplate template;

    @GetMapping("send")
    public void send() {

    // 简单队列模式
    template.convertAndSend("queue", "hi RQ");

    // 路由模式
    //template.convertAndSend("exchange", "topic.sms", "hi RQ");

    }
  • 消费者代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    @Component
    public class Consumer {

    @RabbitListener(queues = "queue")
    public void handler(String str)
    {
    System.out.println(str);
    }

    @RabbitListener(queues = "topic.sms")
    public void handlerSms(String str)
    {
    System.out.println(str);
    }
    }
  • 生产者端配置类代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    @Configuration
    public class SenderConfig {

    @Bean
    public Queue queue() {
    return new Queue("queue",false,false,true);
    }

    @Bean(name="smsqueue")
    public Queue smsQueue() {
    return new Queue("topic.sms",false);
    }


    @Bean
    public TopicExchange exchange{
    return new TopicExchange("exchange");
    }

    @Bean
    Binding bindingExchangeForSms(Queue smsqueue,TopicExchange exchange){
    return BindingBuilder.bind(smsqueue).to(exchange).with("topic.sms");
    }
    }

监控管理 Web UI 简单介绍

  • 接下来让我们直观感受下 RQ 的管理界面,先上图:
    RabbitMQ

  • 创建交换机,配置交换机参数
    RabbitMQ

  • 创建队列,配置队列相关参数
    RabbitMQ

    • 其中参数,x-max-length 指定队列中最多包含多少条消息,想一下是不是可以在秒杀系统中使用呢?比如,一个单品 SKU,最多可以多少人下单成功,下单成功的订单都存放到队列中,那如果用户下单了没有支付咱们怎么办,那就配合下面这个参数使用

    • x-message-ttl 指定消息在没有消费之前在队列中最多生存多长时间,单位时间为毫秒,也就是说用户下单成功但超过 5 分钟没有支付,则该订单消息将自动从队列中删除,腾出空间让其它用户继续抢单即可

  • 将队列绑定到交换机上
    RabbitMQ

    • 其中如果你采用发布订阅模型,则路由 Key 可以为空,这个路由 Key 的作用前面已经讲过,我在这里再简要描述下,生产者发送消息指定路由 Key 到交换机,交换机根据路由 Key 匹配到不同的队列上,然后队列将消息推送给订阅了该队列消息的消费者进行消费处理

mysql事务级别

发表于 2018-08-12 | 更新于 2019-04-25 | 分类于 mysql

事务基本要素(ACID)

  • 原子性(Atomicity):事务开始后所有操作,要么全部做完,要么全部不做,不可能停滞在中间环节。事务执行过程中出错,会回滚到事务开始前的状态,所有的操作就像没有发生一样。也就是说事务是一个不可分割的整体,就像化学中学过的原子,是物质构成的基本单位。

  • 一致性(Consistency):事务开始前和结束后,数据库的完整性约束没有被破坏 。比如A向B转账,不可能A扣了钱,B却没收到。

  • 隔离性(Isolation):同一时间,只允许一个事务请求同一数据,不同的事务之间彼此没有任何干扰。比如A正在从一张银行卡中取钱,在A取钱的过程结束前,B不能向这张卡转账。

  • 持久性(Durability):事务完成后,事务对数据库的所有更新将被保存到数据库,不能回滚

事务并发问题

  • 脏读:事务A读取了事务B更新的数据,然后B回滚操作,那么A读取到的数据是脏数据

  • 不可重复读:事务 A 多次读取同一数据,事务 B 在事务A多次读取的过程中,对数据作了更新并提交,导致事务A多次读取同一数据时,结果 不一致。

  • 幻读:系统管理员A将数据库中所有学生的成绩从具体分数改为ABCDE等级,但是系统管理员B就在这个时候插入了一条具体分数的记录,当系统管理员A改结束后发现还有一条记录没有改过来,就好像发生了幻觉一样,这就叫幻读

  • 不可重复读的和幻读很容易混淆,不可重复读侧重于修改,幻读侧重于新增或删除。解决不可重复读的问题只需锁住满足条件的行,解决幻读需要锁表

mysql事务隔离级别

1
2
3
4
5
事务隔离级别	               脏读	不可重复读	幻读
读未提交(read-uncommitted) 是 是 是
不可重复读(read-committed) 否 是 是
可重复读(repeatable-read 否 否 是
串行化(serializable) 否 否 否

synchronized实现原理

发表于 2018-08-04 | 更新于 2019-04-29 | 分类于 cocurrent

Synchronized含义

  • 关键字 synchronized 可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class Synchronized {

    public static void main(String[] args) {
    synchronized (Synchronized.class) {
    }
    method();
    }

    public static synchronized void method() {
    }
    }
  • 编译运行,然后使用命令:javap -v SynchronizedDemo.class
    synchronized

  • 大致可以看出,对于上述代码中的同步块 的实现是通过monitorenter和monitorexit 指令,而同步方法是依靠方法修饰符上的ACC_SYNCHRONIZED 来完成的

  • 上述的两种方式,无论采用的是哪一种方式,其本质是对一个对象的监视器(monitor) 进行获取,而这个获取过程是排他的,也就是说同一时刻只有一个线程获取到由synchronized所保护对象的监视器

  • synchronized 允许使用任何的一个对象作为同步的内容,因此任意一个对象都应该拥有自己的监视器(monitor),当这个对象由同步块或者这个对象的同步方法调用时,执行方法的线程必须先获取到该对象的监视器才能进入同步块或者同步方法,而没有获取到监视器(执行该方法)的线程将会被阻塞在同步块和同步方法的入口处,进入BLOCKED状态

  • 下图描述了对象、对象的监视器、同步队列和执行线程之间的关系:
    synchronized

  • 从上图中我们可以看到,任意线程对 Object(Object 由 synchronized 保护)的访问,首先要获得 Object 的监视器。如果获取失败,线程进入同步队列,线程状态变为BLOCKED。当访问 Object 的前驱(获得了锁的线程)释放了锁,则该释放操作唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取

Java 虚拟机对 synchronized 的优化

  • synchronized 相对于 volatile 是重量了很多,因此在以前很让人诟病,但是从 JDK 1.6 版本以后为了减少获得锁和释放锁带来的性能消耗而引入了偏向锁和轻量级锁,以及锁的存储结构和升级过程

  • 从 JDK 对synchronized的优化,可以看出 Java 虚拟机对锁优化所做出的努力,下边我们就分别学习一下什么是偏向锁、轻量级锁、重量级锁、自旋锁

  • 在理解这四种锁之前,我们先看一下synchronized锁的存放位置,synchronized 用的锁是存在 Java 对象头里的 ,如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即 32bit,如下图:
    synchronized

  • Java 对象头里的Mark Word里默认存储对象的 HashCode、分代年龄和锁标记位。32位 JVM 的Mark Word的默认存储结构如下图所示:
    synchronized
  • 在 Java SE 1.6 中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率
    synchronized

  • 下边分别研究一下这几个状态

偏向锁

  • 偏向锁是一种针对加锁操作的优化手段,他的核心思想是:如果一个线程获得了锁,那么锁就进入了偏向模式。当这个线程再次请求锁时,无需再做任何同步操作,这样就节省了大量有关锁申请的操作,从而提高了程序的性能

  • 偏向锁获取锁流程

    • 当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需简单地测试一下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁
    • 如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下 Mark Word 中偏向锁的标识是否设置成1(表示当前是偏向锁)
    • 如果没有设置,则使用 CAS 竞争锁
    • 具体流程图如下:
      synchronized

偏向锁升级为轻量锁

  • 对于只有一个线程访问的同步资源场景,锁的竞争不是很激烈,这时候使用偏向锁是一种很好的选择,因为连续多次极有可能是同一个线程请求相同的锁
  • 但是在锁竞争比较激烈的场景,最有可能的情况是每次不同的线程来请求相同的锁,这样的话偏向锁就会失效,倒不如不开启这种模式,幸运的是 Java 虚拟机提供了参数可以让我们有选择的设置是否开启偏向锁
  • 如果偏向锁失败,虚拟机并不会立即挂起线程,而是使用轻量级锁进行操作

轻量级锁

  • 如果偏向锁失败,虚拟机并不会立即挂起线程,而是使用轻量级锁进行操作。轻量级锁他只是简单的将对象头部作为指针,指向持有锁的线程堆栈的内部,来判断一个线程是否持有对象锁。如果线程获得轻量级锁成功,则可以顺利进入临界区。如果轻量级锁加锁失败,则表示其他线程抢先夺到锁,那么当前线程的轻量级锁就会膨胀为重量级锁

自旋锁

  • 轻量级锁就会膨胀为重量级锁后,虚拟机为了避免线程真实的在操作系统层面挂起,虚拟机还会在做最后的努力–自旋锁
  • 由于当前线程暂时无法获得锁,但是什么时候可以获得锁时一个未知数。也许在几个 CPU 时钟周期之后,就可以获得锁。如果是这样的话,直接把线程挂起肯定是一种得不偿失的选择,因此系统会在进行一次努力:他会假设在不就的将来,限额和从那个可以得到这把锁,因此虚拟机会让当前线程做几个空循环(这也就是自旋锁的意义),若经过几个空循环可以获取到锁则进入临界区,如果还是获取不到则系统会真正的挂起线程
  • 那么为什么锁的升级无法逆向那?
    • 这是因为,自旋锁无法预知到底会空循环几个时钟周期,并且会很消耗 CPU,为了避免这种无用的自旋操作,一旦锁升级为重量锁,就不会再恢复到轻量级锁,这也是为什么一旦升级无法降级的原因所在

三种锁的优缺点的对比

synchronized

Java 虚拟机对锁优化所做的努力

  • 从 Java 虚拟机在优化 synchronized 的时候引入了:偏向锁、轻量级锁、重量级锁以及自旋锁,都可以看出 Java 虚拟机通过各种方式,尽量减少获取所和释放锁所带来的性能消耗
  • 但这还不全是 Java 虚拟机锁做的努力,另外还有:锁消除 、 CAS 等等,更重要的还有一个无锁的概念

volatile实现原理

发表于 2018-08-03 | 更新于 2019-04-29 | 分类于 cocurrent

Volatile含义

  • 在Java多线程并发编程中,volatile关键词扮演着重要角色,它是轻量级的synchronized,在多处理器开发中保证了共享变量的“可见性”。“可见性”的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。如果一个字段被声明为volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。与synchronized不同,volatile变量不会引起线程上下文的切换和调度,在适合的场景下拥有更低的执行成本和更高的效率

CPU缓存

  • CPU缓存的出现主要是为了解决CPU运算速度与内存读写速度不匹配的矛盾,因为CPU运算速度要比内存读写速度快得多,举个例子:
    • 一次主内存的访问通常在几十到几百个时钟周期
    • 一次L1高速缓存的读写只需要1~2个时钟周期
    • 一次L2高速缓存的读写也只需要数十个时钟周期
  • 这种访问速度的显著差异,导致CPU可能会花费很长时间等待数据到来或把数据写入内存

  • 基于此,现在CPU大多数情况下读写都不会直接访问内存(CPU都没有连接到内存的管脚),取而代之的是CPU缓存,CPU缓存是位于CPU与内存之间的临时存储器,它的容量比内存小得多但是交换速度却比内存快得多。而缓存中的数据是内存中的一小部分数据,但这一小部分是短时间内CPU即将访问的,当CPU调用大量数据时,就可先从缓存中读取,从而加快读取速度

  • 按照读取顺序与CPU结合的紧密程度,CPU缓存可分为:

    • 一级缓存:简称L1 Cache,位于CPU内核的旁边,是与CPU结合最为紧密的CPU缓存。
    • 二级缓存:简称L2 Cache,分内部和外部两种芯片,内部芯片二级缓存运行速度与主频相同,外部芯片二级缓存运行速度则只有主频的一半。
    • 三级缓存:简称L3 Cache,部分高端CPU才有
  • 每一级缓存中所存储的数据全部都是下一级缓存中的一部分,这三种缓存的技术难度和制造成本是相对递减的,所以其容量也相对递增

  • 当CPU要读取一个数据时,首先从一级缓存中查找,如果没有再从二级缓存中查找,如果还是没有再从三级缓存中或内存中查找。一般来说每级缓存的命中率大概都有80%左右,也就是说全部数据量的80%都可以在一级缓存中找到,只剩下20%的总数据量才需要从二级缓存、三级缓存或内存中读取

CPU缓存带来的问题

  • 用一张图表示一下 CPU –> CPU缓存 –> 主内存 数据读取之间的关系:
    cache
  • 当系统运行时,CPU执行计算的过程如下:

    • 程序以及数据被加载到主内存
    • 指令和数据被加载到CPU缓存
    • CPU执行指令,把结果写到高速缓存
    • 高速缓存中的数据写回主内存
  • 如果服务器是单核CPU,那么这些步骤不会有任何的问题,但是如果服务器是多核CPU,那么问题来了,以Intel Core i7处理器的高速缓存概念模型为例(图片来自《深入理解计算机系
    cache

  • 试想下面一种情况:

    • 核0读取了一个字节,根据局部性原理,它相邻的字节同样被被读入核0的缓存
    • 核3做了上面同样的工作,这样核0与核3的缓存拥有同样的数据
    • 核0修改了那个字节,被修改后,那个字节被写回核0的缓存,但是该信息并没有写回主存
    • 核3访问该字节,由于核0并未将数据写回主存,数据不同步
  • 为了解决这一问题,CPU制造商规定了一个缓存一致性协议

缓存一致性协议

  • 每个CPU都有一级缓存,但是,我们却无法保证每个CPU的一级缓存数据都是一样的。 所以同一个程序,CPU进行切换的时候,切换前和切换后的数据可能会有不一致的情况。那么这个就是一个很大的问题了。 如何保证各个CPU缓存中的数据是一致的。就是CPU的缓存一致性问题

总线锁

  • 一种处理一致性问题的办法是使用Bus Locking(总线锁)。当一个CPU对其缓存中的数据进行操作的时候,往总线中发送一个Lock信号。 这个时候,所有CPU收到这个信号之后就不操作自己缓存中的对应数据了,当操作结束,释放锁以后,所有的CPU就去内存中获取最新数据更新。

  • 但是用锁的方式总是避不开性能问题。总线锁总是会导致CPU的性能下降。所以出现另外一种维护CPU缓存一致性的方式,MESI

MESI

  • MESI是保持一致性的协议。它的方法是在CPU缓存中保存一个标记位,这个标记位有四种状态:

    • M: Modify,修改缓存,当前CPU的缓存已经被修改了,即与内存中数据已经不一致了;
    • E: Exclusive,独占缓存,当前CPU的缓存和内存中数据保持一致,而且其他处理器并没有可使用的缓存数据;
    • S: Share,共享缓存,和内存保持一致的一份拷贝,多组缓存可以同时拥有针对同一内存地址的共享缓存段;
    • I: Invalid,失效缓存,这个说明CPU中的缓存已经不能使用了
  • CPU的读取遵循下面几点:

    • 如果缓存状态是I,那么就从内存中读取,否则就从缓存中直接读取。
    • 如果缓存处于M或E的CPU读取到其他CPU有读操作,就把自己的缓存写入到内存中,并将自己的状态设置为S。
    • 只有缓存状态是M或E的时候,CPU才可以修改缓存中的数据,修改后,缓存状态变为M
  • 这样,每个CPU都遵循上面的方式则CPU的效率就提高上来了

volatile两大作用

  • 保证内存可见性
  • 防止指令重排

redis入门实战

发表于 2018-08-03 | 更新于 2019-04-29 | 分类于 redis

Volatile含义

  • 在Java多线程并发编程中,volatile关键词扮演着重要角色,它是轻量级的synchronized,在多处理器开发中保证了共享变量的“可见性”。“可见性”的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。如果一个字段被声明为volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。与synchronized不同,volatile变量不会引起线程上下文的切换和调度,在适合的场景下拥有更低的执行成本和更高的效率

CPU缓存

  • CPU缓存的出现主要是为了解决CPU运算速度与内存读写速度不匹配的矛盾,因为CPU运算速度要比内存读写速度快得多,举个例子:
    • 一次主内存的访问通常在几十到几百个时钟周期
    • 一次L1高速缓存的读写只需要1~2个时钟周期
    • 一次L2高速缓存的读写也只需要数十个时钟周期
  • 这种访问速度的显著差异,导致CPU可能会花费很长时间等待数据到来或把数据写入内存

  • 基于此,现在CPU大多数情况下读写都不会直接访问内存(CPU都没有连接到内存的管脚),取而代之的是CPU缓存,CPU缓存是位于CPU与内存之间的临时存储器,它的容量比内存小得多但是交换速度却比内存快得多。而缓存中的数据是内存中的一小部分数据,但这一小部分是短时间内CPU即将访问的,当CPU调用大量数据时,就可先从缓存中读取,从而加快读取速度

  • 按照读取顺序与CPU结合的紧密程度,CPU缓存可分为:

    • 一级缓存:简称L1 Cache,位于CPU内核的旁边,是与CPU结合最为紧密的CPU缓存。
    • 二级缓存:简称L2 Cache,分内部和外部两种芯片,内部芯片二级缓存运行速度与主频相同,外部芯片二级缓存运行速度则只有主频的一半。
    • 三级缓存:简称L3 Cache,部分高端CPU才有
  • 每一级缓存中所存储的数据全部都是下一级缓存中的一部分,这三种缓存的技术难度和制造成本是相对递减的,所以其容量也相对递增

  • 当CPU要读取一个数据时,首先从一级缓存中查找,如果没有再从二级缓存中查找,如果还是没有再从三级缓存中或内存中查找。一般来说每级缓存的命中率大概都有80%左右,也就是说全部数据量的80%都可以在一级缓存中找到,只剩下20%的总数据量才需要从二级缓存、三级缓存或内存中读取

CPU缓存带来的问题

  • 用一张图表示一下 CPU –> CPU缓存 –> 主内存 数据读取之间的关系:
    cache
  • 当系统运行时,CPU执行计算的过程如下:

    • 程序以及数据被加载到主内存
    • 指令和数据被加载到CPU缓存
    • CPU执行指令,把结果写到高速缓存
    • 高速缓存中的数据写回主内存
  • 如果服务器是单核CPU,那么这些步骤不会有任何的问题,但是如果服务器是多核CPU,那么问题来了,以Intel Core i7处理器的高速缓存概念模型为例(图片来自《深入理解计算机系
    cache

  • 试想下面一种情况:

    • 核0读取了一个字节,根据局部性原理,它相邻的字节同样被被读入核0的缓存
    • 核3做了上面同样的工作,这样核0与核3的缓存拥有同样的数据
    • 核0修改了那个字节,被修改后,那个字节被写回核0的缓存,但是该信息并没有写回主存
    • 核3访问该字节,由于核0并未将数据写回主存,数据不同步
  • 为了解决这一问题,CPU制造商规定了一个缓存一致性协议

缓存一致性协议

  • 每个CPU都有一级缓存,但是,我们却无法保证每个CPU的一级缓存数据都是一样的。 所以同一个程序,CPU进行切换的时候,切换前和切换后的数据可能会有不一致的情况。那么这个就是一个很大的问题了。 如何保证各个CPU缓存中的数据是一致的。就是CPU的缓存一致性问题

总线锁

  • 一种处理一致性问题的办法是使用Bus Locking(总线锁)。当一个CPU对其缓存中的数据进行操作的时候,往总线中发送一个Lock信号。 这个时候,所有CPU收到这个信号之后就不操作自己缓存中的对应数据了,当操作结束,释放锁以后,所有的CPU就去内存中获取最新数据更新。

  • 但是用锁的方式总是避不开性能问题。总线锁总是会导致CPU的性能下降。所以出现另外一种维护CPU缓存一致性的方式,MESI

MESI

  • MESI是保持一致性的协议。它的方法是在CPU缓存中保存一个标记位,这个标记位有四种状态:

    • M: Modify,修改缓存,当前CPU的缓存已经被修改了,即与内存中数据已经不一致了;
    • E: Exclusive,独占缓存,当前CPU的缓存和内存中数据保持一致,而且其他处理器并没有可使用的缓存数据;
    • S: Share,共享缓存,和内存保持一致的一份拷贝,多组缓存可以同时拥有针对同一内存地址的共享缓存段;
    • I: Invalid,失效缓存,这个说明CPU中的缓存已经不能使用了
  • CPU的读取遵循下面几点:

    • 如果缓存状态是I,那么就从内存中读取,否则就从缓存中直接读取。
    • 如果缓存处于M或E的CPU读取到其他CPU有读操作,就把自己的缓存写入到内存中,并将自己的状态设置为S。
    • 只有缓存状态是M或E的时候,CPU才可以修改缓存中的数据,修改后,缓存状态变为M
  • 这样,每个CPU都遵循上面的方式则CPU的效率就提高上来了

volatile两大作用

  • 保证内存可见性
  • 防止指令重排

jvm调优工具说明

发表于 2018-07-12 | 更新于 2019-04-22 | 分类于 jvm

JVM调优工具

  • JVM配置以及调优是Java程序员进阶必须掌握的,一个优秀的Java程序员可以根据运行环境设置JVM参数,从而达到最优配置,合理充分的利用系统资源,避免生产环境发生一些如OOM的异常或者线程死锁、Java进程CPU消耗过高等问题

  • 注意!!!:使用的jdk版本是jdk8,查看本机的初始化参数:java -XX:+PrintFlagsInitial

jps

  • JVM Process Status Tool,显示指定系统内所有的HotSpot虚拟机进程

  • 命令格式

    • jps [options] [hostid]
    • option参数
    • -l : 输出主类全名或jar路径
    • -q : 只输出LVMID
    • -m : 输出JVM启动时传递给main()的参数
    • -v : 输出JVM启动时显示指定的JVM参数
    • 其中[option]、[hostid]参数也可以不写
  • 示例 jps -ml 或 jps -l -m
    jvmjps

jstat

  • jstat(JVM statistics Monitoring)是用于监视虚拟机运行时状态信息的命令,它可以显示出虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据

  • 命令格式

    • jstat [option] LVMID [interval] [count]
    • 参数
    • [option] : 操作参数
    • LVMID : 本地虚拟机进程ID
    • [interval] : 连续输出的时间间隔
    • [count] : 连续输出的次数

类加载统计

  • jstat
    • 显示字段含义
      • Loaded:加载class的数量
      • Bytes:所占用空间大小
      • Unloaded:未加载数量
      • Bytes:未加载占用空间
      • Time:时间

编译统计

  • jstat
    • 显示字段含义
      • Compiled:编译数量。
      • Failed:失败数量
      • Invalid:不可用数量
      • Time:时间
      • FailedType:失败类型
      • FailedMethod:失败的方法

垃圾回收统计

  • jstat
    • 显示字段含义
      • S0C:第一个幸存区的大小
      • S1C:第二个幸存区的大小
      • S0U:第一个幸存区的使用大小
      • S1U:第二个幸存区的使用大小
      • EC:伊甸园区的大小
      • EU:伊甸园区的使用大小
      • OC:老年代大小
      • OU:老年代使用大小
      • MC:方法区大小
      • MU:方法区使用大小
      • CCSC:压缩类空间大小
      • CCSU:压缩类空间使用大小
      • YGC:年轻代垃圾回收次数
      • YGCT:年轻代垃圾回收消耗时间
      • FGC:老年代垃圾回收次数
      • FGCT:老年代垃圾回收消耗时间
      • GCT:垃圾回收消耗总时间

堆内存统计

  • jstat
    • 显示字段含义
      • NGCMN:新生代最小容量
      • NGCMX:新生代最大容量
      • NGC:当前新生代容量
      • S0C:第一个幸存区大小
      • S1C:第二个幸存区的大小
      • EC:伊甸园区的大小
      • OGCMN:老年代最小容量
      • OGCMX:老年代最大容量
      • OGC:当前老年代大小
      • OC:当前老年代大小
      • MCMN:最小元数据容量
      • MCMX:最大元数据容量
      • MC:当前元数据空间大小
      • CCSMN:最小压缩类空间大小
      • CCSMX:最大压缩类空间大小
      • CCSC:当前压缩类空间大小
      • YGC:年轻代gc次数
      • FGC:老年代GC次数

新生代垃圾回收统计

  • jstat
    • 显示字段含义
      • S0C:第一个幸存区大小
      • S1C:第二个幸存区的大小
      • S0U:第一个幸存区的使用大小
      • S1U:第二个幸存区的使用大小
      • TT:对象在新生代存活的次数
      • MTT:对象在新生代存活的最大次数
      • DSS:期望的幸存区大小
      • EC:伊甸园区的大小
      • EU:伊甸园区的使用大小
      • YGC:年轻代垃圾回收次数
      • GCT:年轻代垃圾回收消耗时间

新生代内存统计

  • jstat
    • 显示字段含义
      • NGCMN:新生代最小容量
      • NGCMX:新生代最大容量
      • NGC:当前新生代容量
      • S0CMX:最大幸存1区大小
      • S0C:当前幸存1区大小
      • S1CMX:最大幸存2区大小
      • S1C:当前幸存2区大小
      • ECMX:最大伊甸园区大小
      • EC:当前伊甸园区大小
      • YGC:年轻代垃圾回收次数
      • FGC:老年代回收次数

老年代垃圾统计

  • jstat
    • 显示字段含义
      • MC:方法区大小
      • MU:方法区使用大小
      • CCSC:压缩类空间大小
      • CCSU:压缩类空间使用大小
      • OC:老年代大小
      • OU:老年代使用大小
      • YGC:年轻代垃圾回收次数
      • FGC:老年代垃圾回收次数
      • FGCT:老年代垃圾回收消耗时间
      • GCT:垃圾回收消耗总时间

老年代内存统计

  • jstat
    • 显示字段含义
      • OGCMN:老年代最小容量
      • OGCMX:老年代最大容量
      • OGC:当前老年代大小
      • OC:老年代大小
      • YGC:年轻代垃圾回收次数
      • FGC:老年代垃圾回收次数
      • FGCT:老年代垃圾回收消耗时间
      • GCT:垃圾回收消耗总时间

元数据空间统计

  • jstat -gcmetacapacity [LVMID]
  • jstat
    • 显示字段含义
      • MCMN: 最小元数据容量
      • MCMX:最大元数据容量
      • MC:当前元数据空间大小
      • CCSMN:最小压缩类空间大小
      • CCSMX:最大压缩类空间大小
      • CCSC:当前压缩类空间大小
      • YGC:年轻代垃圾回收次数
      • FGC:老年代垃圾回收次数
      • FGCT:老年代垃圾回收消耗时间
      • GCT:垃圾回收消耗总时间

总结垃圾回收统计

  • jstat -gcutil 17063 1s 10
  • jstat -gcutil 17063 1s #一直连续输出
  • jstat
    • 显示字段含义
      • S0:幸存1区当前使用比例
      • S1:幸存2区当前使用比例
      • E:伊甸园区使用比例
      • O:老年代使用比例
      • M:元数据区使用比例
      • CCS:压缩使用比例
      • YGC:年轻代垃圾回收次数
      • FGC:老年代垃圾回收次数
      • FGCT:老年代垃圾回收消耗时间
      • GCT:垃圾回收消耗总时间

JVM编译方法统计

  • jstat
    • 显示字段含义
      • Compiled:最近编译方法的数量
      • Size:最近编译方法的字节码数量
      • Type:最近编译方法的编译类型
      • Method:方法名标识

jmap

  • jmap(JVM Memory Map)命令用于生成heap dump文件
  • 如果不使用这个命令,还可以使用-XX:+HeapDumpOnOutOfMemoryError参数来让虚拟机出现OOM的时候·自动生成dump文件
  • jmap不仅能生成dump文件,还可以查询finalize执行队列、Java堆和永久代的详细信息,如当前使用率、当前使用的是哪种收集器等

  • 命令格式

    • jmap [option] LVMID
    • option参数
    • dump : 生成堆转储快照(会引发full GC)
    • finalizerinfo : 显示在F-Queue队列等待Finalizer线程执行finalizer方法的对象
    • heap : 显示Java堆详细信息
    • histo : 显示堆中对象的统计信息(会引发full GC)
    • permstat : to print permanent generation statistics
    • F : 当-dump没有响应时,强制生成dump快照
  • 导出整个JVM 中内存信息

    • jmap -dump:format=b,file=文件名 [pid] ;比如:jmap -dump:format=b,file=/Users/lyh/Desktop/dumplock.hprof 20705
    • 示例:jmap -histo pid 展示class的内存情况:
      jmap
    • 显示字段含义
      • instance:对象实例个数
      • bytes:总占用的字节数
      • class name:对应的就是 Class 文件里的 class 的标识
      • B 代表 byte
      • C 代表 char
      • D 代表 double
      • F 代表 float
      • I 代表 int
      • J 代表 long
      • Z 代表 boolean
      • 前边有 [ 代表数组, [I 就相当于 int[]
      • 对象用 [L+ 类名表示

jhat

  • jhat(JVM Heap Analysis Tool)命令是与jmap搭配使用,用来分析jmap生成的dump,jhat内置了一个微型的HTTP/HTML服务器,生成dump的分析结果后,可以在浏览器中查看。
  • 在此要注意,一般不会直接在服务器上进行分析,因为jhat是一个耗时并且耗费硬件资源的过程,一般把服务器生成的dump文件复制到本地或其他机器上进行分析

  • 命令格式

    • jhat [dump file]

jstack

  • jstack用于生成java虚拟机当前时刻的线程快照
  • 命令格式

    • jstack [ option ] pid
    • jstack [ option ] executable core
    • jstack [ option ] [server-id@]remote-hostname-or-IP
    • option参数:
    • -F : 当正常输出请求不被响应时,强制输出线程堆栈
    • -l : 除堆栈外,显示关于锁的附加信息
    • -m : 如果调用到本地方法的话,可以显示C/C++的堆栈
  • 示例 输出文件:jstack -l 17063 >1.txt

  • 线程dump的分析工具

    • IBM Thread and Monitor Dump Analyze for Java 一个小巧的Jar包,能方便的按状态,线程名称,线程停留的函数排序,快速浏览。
    • http://spotify.github.io/threaddump-analyzer Spotify提供的Web版在线分析工具,可以将锁或条件相关联的线程聚合到一起

jinfo

  • jinfo(JVM Configuration info)这个命令作用是实时查看和调整虚拟机运行参数。
  • 之前的jps -v口令只能查看到显示指定的参数,如果想要查看未被显示指定的参数的值就要使用jinfo口令
  • 命令格式
    • jinfo [option] [args] LVMID
    • option参数:
    • -flag : 输出指定args参数的值
    • -flags : 不需要args参数,输出所有JVM参数的值
    • -sysprops : 输出系统属性,等同于System.getProperties()

jvm类加载机制

发表于 2018-07-03 | 更新于 2019-04-22 | 分类于 jvm

类加载器过程

  • 从类被加载到虚拟机内存中开始,到卸御出内存为止,它的整个生命周期分为7个阶段,加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸御(Unloading)。其中验证、准备、解析三个部分统称为连接
    classLoad

加载

  • 在加载阶段,虚拟机主要完成三件事情:
    • 通过一个类的全限定名(比如 com.danny.framework.t)来获取定义该类的二进制流
    • 将这个字节流所代表的静态存储结构转化为方法区的运行时存储结构
    • 在内存中生成一个代表这个类的 java.lang.Class 对象,作为程序访问方法区中这个类的外部接口

验证

  • 验证的目的是为了确保 class 文件的字节流包含的内容符合虚拟机的要求,且不会危害虚拟机的安全
  • 文件格式验证:主要验证 class 文件中二进制字节流的格式,比如魔数是否已 0xCAFEBABY 开头、版本号是否正确等
  • 元数据验证:主要对字节码描述的信息进行语义分析,保证其符合 Java 语言规范,比如验证这个类是否有父类(java.lang.Object 除外),如果这个类不是抽象类,是否实现了父类或接口中没有实现的方法,等等
  • 字节码验证:字节码验证更为高级,通过数据流和控制流分析,确保程序是合法的、符合逻辑的
  • 符号引用验证:对类自身以外的信息进行匹配性校验,举个栗子,比如通过类的全限定名能否找到对应类、在类中能否找到字段名 / 方法名对应的字段 / 方法,如果符号引用验证失败,将抛出异常

准备

  • 正式为【类变量】分配内存并设置类变量【初始值】,这些变量所使用的内存都分配在方法区。注意分配内存的对象是“类变量”而不是实例变量,而且为其分配的是“初始值”,一般数值类型的初始值都为0,char类型的初始值为’\u0000’(常量池中一个表示Nul的字符串),boolean类型初始值为false,引用类型初始值为null
  • 但是加上final关键字比如public static final int value=123;在准备阶段会初始化value的值为123;

解析

  • 解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程
    • 符号引用:简单的理解就是字符串,比如引用一个类,java.util.ArrayList 这就是一个符号引用,字符串引用的对象不一定被加载
    • 直接引用:指针或者地址偏移量。引用对象一定在内存(已经加载)

初始化

  • 在准备阶段,已经为类变量赋了初始值,在初始化阶段,则根据程序员通过程序定制的主观计划去初始化类变量的和其他资源,也可以从另一个角度来理解:初始化阶段是执行类构造器 () 方法的过程,那 () 到底是什么呢?

  • 在准备阶段,已经为类变量赋了初始值,在初始化阶段,则根据程序员通过程序定制的主观计划去初始化类变量的和其他资源,也可以从另一个角度来理解:初始化阶段是执行类构造器 () 方法的过程,那 () 到底是什么呢?

  • 下面看段代码来理解下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    public class Parent {
    static {
    System.out.println("Parent-静态代码块执行");
    }

    public Parent() {
    System.out.println("Parent-构造方法执行");
    }

    {
    System.out.println("Parent-非静态代码块执行");
    }
    }

    public class Child extends Parent{
    private static int staticValue = 123;
    private int noStaticValue=456;

    static {
    System.out.println("Child-静态代码块执行");
    }

    public Child() {
    System.out.println("Child-构造方法执行");
    }

    {
    System.out.println("Child-非静态代码块执行");
    }

    public static void main(String[] args) {
    Child child = new Child();
    }
    }
  • 看下面的运行结果之前可以先猜测一下结果是什么,运行结果如下:

    1
    2
    3
    4
    5
    6
    Parent-静态代码块执行
    Child-静态代码块执行
    Parent-非静态代码块执行
    Parent-构造方法执行
    Child-非静态代码块执行
    Child-构造方法执行
  • 上面的例子中可以看到一个类从加载到实例化的过程中,静态代码块、构造方法、非静态代码块的加载顺序。无法看到静态变量和非静态变量初始化的时间,静态变量的初始化和静态代码块的执行都是在类的初始化阶段 (()) 完成,非静态变量和非静态代码块都是在实例的初始化阶段 (()) 完成

类加载器

类加载器的作用

  • 加载 class:类加载的加载阶段的第一个步骤,就是通过类加载器来完成的,类加载器的主要任务就是 “ 通过一个类的全限定名来获取描述此类的二进制字节流 ”,在这里,类加载器加载的二进制流并不一定要从 class 文件中获取,还可以从其他格式如zip文件中读取、从网络或数据库中读取、运行时动态生成、由其他文件生成(比如 jsp 生成 class 类文件)等

  • 从程序员的角度来看,类加载器动态加载class文件到虚拟机中,并生成一个 java.lang.Class 实例,每个实例都代表一个 java 类,可以根据该实例得到该类的信息,还可以通过newInstance()方法生成该类的一个对象

  • 确定类的唯一性:类加载器除了有加载类的作用,还有一个举足轻重的作用,对于每一个类,都需要由加载它的加载器和这个类本身共同确立这个类在 Java 虚拟机中的唯一性。也就是说,两个相同的类,只有是在同一个加载器加载的情况下才 “ 相等 ”,这里的 “ 相等 ” 是指代表类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果,也包括 instanceof 关键字对对象所属关系的判定结果

类加载器的分类

  • 以开发人员的角度来看,类加载器分为如下几种:启动类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)、应用程序类加载器(Application ClassLoader)和自定义类加载器(User ClassLoader),其中启动类加载器属于 JVM 的一部分,其他类加载器都用 java 实现,并且最终都继承自 java.lang.ClassLoader

  • 启动类加载器(Bootstrap ClassLoader)是由 C/C++ 编译而来的,看不到源码,所以在 java.lang.ClassLoader 源码中看到的 Bootstrap ClassLoader 的定义是 native 的 private native Class findBootstrapClass(String name);。启动类加载器主要负责加载 JAVA_HOME\lib 目录或者被 -Xbootclasspath 参数指定目录中的部分类,具体加载哪些类可以通过 System.getProperty(“sun.boot.class.path”) 来查看

  • 扩展类加载器(Extension ClassLoader)由 sun.misc.Launcher.ExtClassLoader 实现,负责加载 JAVA_HOME\lib\ext 目录或者被 java.ext.dirs 系统变量指定的路径中的所有类库,可以用通过 System.getProperty(“java.ext.dirs”) 来查看具体都加载哪些类

  • 应用程序类加载器(Application ClassLoader)由 sun.misc.Launcher.AppClassLoader 实现,负责加载用户类路径(我们通常指定的 classpath)上的类,如果程序中没有自定义类加载器,应用程序类加载器就是程序默认的类加载器

  • 自定义类加载器(User ClassLoader),JVM 提供的类加载器只能加载指定目录的类(jar 和 class),如果我们想从其他地方甚至网络上获取 class 文件,就需要自定义类加载器来实现,自定义类加载器主要都是通过继承 ClassLoader 或者它的子类来实现,但无论是通过继承 ClassLoader 还是它的子类,最终自定义类加载器的父加载器都是应用程序类加载器,因为不管调用哪个父类加载器,创建的对象都必须最终调用 java.lang.ClassLoader.getSystemClassLoader() 作为父加载器,getSystemClassLoader() 方法的返回值是 sun.misc.Launcher.AppClassLoader 即应用程序类加载器

ClassLoader 与双亲委派模型

  • 下面看一下类加载器 java.lang.ClassLoader 中的核心逻辑 loadClass() 方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
    {
    synchronized (getClassLoadingLock(name)) {
    // 检查该类是否已经加载过
    Class c = findLoadedClass(name);
    if (c == null) {
    long t0 = System.nanoTime();
    try {
    if (parent != null) {//如果父加载器不为空,就用父加载器加载类
    c = parent.loadClass(name, false);
    } else {//如果父加载器为空,就用启动类加载器加载类
    c = findBootstrapClassOrNull(name);
    }
    } catch (ClassNotFoundException e) {
    }

    if (c == null) {//如果上面用父加载器还没加载到类,就自己尝试加载
    long t1 = System.nanoTime();
    c = findClass(name);
    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
    sun.misc.PerfCounter.getFindClasses().increment();
    }
    }
    if (resolve) {
    resolveClass(c);
    }
    return c;
    }
    }
  • 这段代码的主要意思就是当一个类加载器加载类的时候,如果有父加载器就先尝试让父加载器加载,如果父加载器还有父加载器就一直往上抛,一直把类加载的任务交给启动类加载器,然后启动类加载器如果加载不到类就会抛出 ClassNotFoundException 异常,之后把类加载的任务往下抛,如下图:
    classLoad

  • 通过上图的类加载过程,就引出了一个比较重要的概念——双亲委派模型,如下图展示的层次关系,双亲委派模型要求除了顶层的启动类加载器之外,其他的类加载器都应该有一个父类加载器,但是这种父子关系并不是继承关系,而是像上面代码所示的组合关系
    classLoad

  • 双亲委派模型的工作过程是,如果一个类加载器收到了类加载的请求,它首先不会加载类,而是把这个请求委派给它上一层的父加载器,每层都如此,所以最终请求会传到启动类加载器,然后从启动类加载器开始尝试加载类,如果加载不到(要加载的类不在当前类加载器的加载范围),就让它的子类尝试加载,每层都是如此

  • 那么双亲委派模型有什么好处呢?最大的好处就是它让 Java 中的类跟类加载器一样有了 “ 优先级 ”。前面说到了对于每一个类,都需要由加载它的加载器和这个类本身共同确立这个类在 Java 虚拟机中的唯一性,比如 java.lang.Object 类(存放在 JAVA_HOME\lib\rt.jar 中),如果用户自己写了一个 java.lang.Object 类并且由自定义类加载器加载,那么在程序中是不是就是两个类?所以双亲委派模型对保证 Java 稳定运行至关重要

思维导图

classLoad

jvm性能调优

发表于 2018-06-29 | 更新于 2019-04-22 | 分类于 jvm

前言

  • JVM 调优目标:使用较小的内存占用来获得较高的吞吐量或者较低的延迟
  • 程序在上线前的测试或运行中有时会出现一些大大小小的 JVM 问题,比如 cpu load 过高、请求延迟、tps 降低等,甚至出现内存泄漏(每次垃圾收集使用的时间越来越长,垃圾收集频率越来越高,每次垃圾收集清理掉的垃圾数据越来越少)、内存溢出导致系统崩溃,因此需要对 JVM 进行调优,使得程序在正常运行的前提下,获得更高的用户体验和运行效率
  • 这里有几个比较重要的指标:

    • 内存占用:程序正常运行需要的内存大小。

    • 延迟:由于垃圾收集而引起的程序停顿时间。

    • 吞吐量:用户程序运行时间占用户程序和垃圾收集占用总时间的比值

  • 当然,和 CAP 原则一样,同时满足一个程序内存占用小、延迟低、高吞吐量是不可能的,程序的目标不同,调优时所考虑的方向也不同,在调优之前,必须要结合实际场景,有明确的的优化目标,找到性能瓶颈,对瓶颈有针对性的优化,最后进行测试,通过各种监控工具确认调优后的结果是否符合目标

JVM调优工具

  • 调优可以依赖、参考的数据有系统运行日志、堆栈错误信息、gc 日志、线程快照、堆转储快照等

    • 系统运行日志:系统运行日志就是在程序代码中打印出的日志,描述了代码级别的系统运行轨迹(执行的方法、入参、返回值等),一般系统出现问题,系统运行日志是首先要查看的日志
    • 堆栈错误信息:当系统出现异常后,可以根据堆栈信息初步定位问题所在,比如根据 java.lang.OutOfMemoryError: Java heap space 可以判断是堆内存溢出;根据 java.lang.StackOverflowError 可以判断是栈溢出;根据 java.lang.OutOfMemoryError: PermGen space 可以判断是方法区溢出等
    • GC 日志:程序启动时用 -XX:+PrintGCDetails 和 -Xloggc:/data/jvm/gc.log 可以在程序运行时把 gc 的详细过程记录下来,或者直接配置 -verbose:gc 参数把 gc 日志打印到控制台,通过记录的 gc 日志可以分析每块内存区域 gc 的频率、时间等,从而发现问题,进行有针对性的优化
    • 比如如下一段 GC 日志:

      1
      2
      3
      4
      2018-08-02T14:39:11.560-0800: 10.171: [GC [PSYoungGen: 30128K->4091K(30208K)] 51092K->50790K(98816K), 0.0140970 secs] [Times: user=0.02 sys=0.03, real=0.01 secs] 
      2018-08-02T14:39:11.574-0800: 10.185: [Full GC [PSYoungGen: 4091K->0K(30208K)] [ParOldGen: 46698K->50669K(68608K)] 50790K->50669K(98816K) [PSPermGen: 2635K->2634K(21504K)], 0.0160030 secs] [Times: user=0.03 sys=0.00, real=0.02 secs]
      2018-08-02T14:39:14.045-0800: 12.656: [GC [PSYoungGen: 14097K->4064K(30208K)] 64766K->64536K(98816K), 0.0117690 secs] [Times: user=0.02 sys=0.01, real=0.01 secs]
      2018-08-02T14:39:14.057-0800: 12.668: [Full GC [PSYoungGen: 4064K->0K(30208K)] [ParOldGen: 60471K->401K(68608K)] 64536K->401K(98816K) [PSPermGen: 2634K->2634K(21504K)], 0.0102020 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
      • 上面一共是 4 条 GC 日志,来看第一行日志,2018-08-02T14:39:11.560-0800 是精确到了毫秒级别的 UTC 通用标准时间格式,配置了 -XX:+PrintGCDateStamps 这个参数可以跟随gc日志打印出这种时间戳,10.171是从 JVM 启动到发生 gc 经过的秒数。第一行日志正文开头的 [GC 说明这次 GC 没有发生 Stop-The-World(用户线程停顿),第二行日志正文开头的 [Full GC 说明这次 GC 发生了 Stop-The-World,所以说,[GC 和 [Full GC 跟新生代和老年代没关系,和垃圾收集器的类型有关系,如果直接调用 System.gc(),将显示 [Full GC(System)

      • 接下来的 [PSYoungGen 、 [ParOldGen 表示 GC 发生的区域,具体显示什么名字也跟垃圾收集器有关,比如这里的 [PSYoungGen 表示 Parallel Scavenge 收集器,[ParOldGen 表示 Serial Old 收集器,此外,Serial 收集器显示 [DefNew,ParNew 收集器显示 [ParNew 等

      • 再往后的 30128K->4091K(30208K) 表示进行了这次 gc 后,该区域的内存使用空间由 30128K 减小到 4091K,总内存大小为 30208K

      • 每个区域 gc 描述后面的 51092K->50790K(98816K), 0.0140970 secs 进行了这次垃圾收集后,整个堆内存的内存使用空间由 51092K 减小到 50790K,整个堆内存总空间为 98816K,gc 耗时 0.0140970秒

    • 线程快照:顾名思义,根据线程快照可以看到线程在某一时刻的状态,当系统中可能存在请求超时、死循环、死锁等情况是,可以根据线程快照来进一步确定问题。通过执行虚拟机自带的“jstack pid”命令,可以dump出当前进程中线程的快照信息,更详细的使用和分析网上有很多例,这篇文章写到这里已经很长了就不过多叙述了,贴一篇博客供参考: http://www.cnblogs.com/kongzhongqijing/articles/3630264.html

    • 堆转储快照:程序启动时可以使用 -XX:+HeapDumpOnOutOfMemory 和 -XX:HeapDumpPath=/data/jvm/dumpfile.hprof,当程序发生内存溢出时,把当时的内存快照以文件形式进行转储(也可以直接用 jmap 命令转储程序运行时任意时刻的内存快照),事后对当时的内存使用情况进行分析

jvm自带调优工具说明

  • 用 jps(JVM process Status)可以查看虚拟机启动的所有进程、执行主类的全名、JVM启动参数,比如当执行了 JPSTest 类中的 main 方法后(main 方法持续执行),执行 jps -l可看到下面的JPSTest类的 pid 为 31354,加上 -v 参数还可以看到JVM启动参数

    1
    2
    3
    4
    5
    3265 
    32914 sun.tools.jps.Jps
    31353 org.jetbrains.jps.cmdline.Launcher
    31354 com.danny.test.code.jvm.JPSTest
    380
  • 用jstat(JVM Statistics Monitoring Tool)监视虚拟机信息 jstat -gc pid 500 10:每 500 毫秒打印一次 Java 堆状况(各个区的容量、使用容量、gc 时间等信息),打印 10 次

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    S0C    S1C    S0U    S1U      EC       EU        OC         OU       MC     MU    CCSC   CCSU   YGC     YGCT    FGC    FGCT     GCT   
    11264.0 11264.0 11202.7 0.0 11776.0 1154.3 68608.0 36238.7 - - - - 14 0.077 7 0.049 0.126
    11264.0 11264.0 11202.7 0.0 11776.0 4037.0 68608.0 36238.7 - - - - 14 0.077 7 0.049 0.126
    11264.0 11264.0 11202.7 0.0 11776.0 6604.5 68608.0 36238.7 - - - - 14 0.077 7 0.049 0.126
    11264.0 11264.0 11202.7 0.0 11776.0 9487.2 68608.0 36238.7 - - - - 14 0.077 7 0.049 0.126
    11264.0 11264.0 0.0 0.0 11776.0 258.1 68608.0 58983.4 - - - - 15 0.082 8 0.059 0.141
    11264.0 11264.0 0.0 0.0 11776.0 3076.8 68608.0 58983.4 - - - - 15 0.082 8 0.059 0.141
    11264.0 11264.0 0.0 0.0 11776.0 0.0 68608.0 390.0 - - - - 16 0.084 9 0.066 0.149
    11264.0 11264.0 0.0 0.0 11776.0 0.0 68608.0 390.0 - - - - 16 0.084 9 0.066 0.149
    11264.0 11264.0 0.0 0.0 11776.0 258.1 68608.0 390.0 - - - - 16 0.084 9 0.066 0.149
    11264.0 11264.0 0.0 0.0 11776.0 3012.8 68608.0 390.0 - - - - 16 0.084 9 0.066 0.149
  • jstat 还可以以其他角度监视各区内存大小、监视类装载信息等,具体可以 google jstat 的详细用法

  • 用 jmap(Memory Map for Java)查看堆内存信息 执行 jmap -histo pid 可以打印出当前堆中所有每个类的实例数量和内存占用,如下,class name 是每个类的类名([B 是 byte 类型,[C是 char 类型,[I 是 int 类型),bytes 是这个类的所有示例占用内存大小,instances 是这个类的实例数量

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    num     #instances         #bytes  class name
    ----------------------------------------------
    1: 2291 29274080 [B
    2: 15252 1961040 <methodKlass>
    3: 15252 1871400 <constMethodKlass>
    4: 18038 721520 java.util.TreeMap$Entry
    5: 6182 530088 [C
    6: 11391 273384 java.lang.Long
    7: 5576 267648 java.util.TreeMap
    8: 50 155872 [I
    9: 6124 146976 java.lang.String
    10: 3330 133200 java.util.LinkedHashMap$Entry
    11: 5544 133056 javax.management.openmbean.CompositeDataSupport
  • 执行 jmap -dump 可以转储堆内存快照到指定文件,比如执行:

    1
    jmap -dump:format=b,file=/data/jvm/dumpfile_jmap.hprof 3361
  • 利用 jconsole、jvisualvm 分析内存信息(各个区如 Eden、Survivor、Old 等内存变化情况),如果查看的是远程服务器的 JVM,程序启动需要加上如下参数:

    1
    2
    3
    4
    5
    "-Dcom.sun.management.jmxremote=true" 
    "-Djava.rmi.server.hostname=12.34.56.78"
    "-Dcom.sun.management.jmxremote.port=18181"
    "-Dcom.sun.management.jmxremote.authenticate=false"
    "-Dcom.sun.management.jmxremote.ssl=false"
    • 下图是 jconsole 界面,概览选项可以观测堆内存使用量、线程数、类加载数和 CPU 占用率;内存选项可以查看堆中各个区域的内存使用量和左下角的详细描述(内存大小、GC 情况等);线程选项可以查看当前 JVM 加载的线程,查看每个线程的堆栈信息,还可以检测死锁;VM 概要描述了虚拟机的各种详细参数
      jconsole

    • 下图是 jvisualvm 的界面,功能比 jconsole 略丰富一些,不过大部分功能都需要安装插件。

      概述跟 jconsole 的 VM 概要差不多,描述的是 jvm 的详细参数和程序启动参数;监视展示的和 jconsole 的概览界面差不多(CPU、堆/方法区、类加载、线程);线程和 jconsole 的线程界面差不多;抽样器可以展示当前占用内存的类的排行榜及其实例的个数;Visual GC 可以更丰富地展示当前各个区域的内存占用大小及历史信息(下图)
      jvisualvm

分析堆转储快照

  • 前面说到配置了 -XX:+HeapDumpOnOutOfMemory 参数可以在程序发生内存溢出时 dump 出当前的内存快照,也可以用 jmap 命令随时 dump 出当时内存状态的快照信息,dump 的内存快照一般是以 .hprof 为后缀的二进制格式文件

    • 可以直接用 jhat(JVM Heap Analysis Tool) 命令来分析内存快照,它的本质实际上内嵌了一个微型的服务器,可以通过浏览器来分析对应的内存快照,比如执行 jhat -port 9810 -J-Xmx4G /data/jvm/dumpfile_jmap.hprof 表示以 9810 端口启动 jhat 内嵌的服务器:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      Reading from /Users/dannyhoo/data/jvm/dumpfile_jmap.hprof...
      Dump file created Fri Aug 03 15:48:27 CST 2018
      Snapshot read, resolving...
      Resolving 276472 objects...
      Chasing references, expect 55 dots.......................................................
      Eliminating duplicate references.......................................................
      Snapshot resolved.
      Started HTTP server on port 9810
      Server is ready.
    • 在控制台可以看到服务器启动了,访问 http://127.0.0.1:9810/ 可以看到对快照中的每个类进行分析的结果(界面略 low),下图是我随便选择了一个类的信息,有这个类的父类,加载这个类的类加载器和占用的空间大小,下面还有这个类的每个实例(References)及其内存地址和大小,点进去会显示这个实例的一些成员变量等信息:
      jhat

    • jvisualvm 也可以分析内存快照,在 jvisualvm 菜单的 “ 文件 ” - “ 装入 ”,选择堆内存快照,快照中的信息就以图形界面展示出来了,如下,主要可以查看每个类占用的空间、实例的数量和实例的详情等:
      jvisualvmDump

JVM 调优经验

  • JVM 配置方面,一般情况可以先用默认配置(基本的一些初始参数可以保证一般的应用跑的比较稳定了),在测试中根据系统运行状况(会话并发情况、会话时间等),结合 gc 日志、内存监控、使用的垃圾收集器等进行合理的调整,当老年代内存过小时可能引起频繁 Full GC,当内存过大时 Full GC 时间会特别长

  • 那么 JVM 的配置比如新生代、老年代应该配置多大最合适呢?答案是不一定,调优就是找答案的过程,物理内存一定的情况下,新生代设置越大,老年代就越小,Full GC 频率就越高,但 Full GC 时间越短;相反新生代设置越小,老年代就越大,Full GC 频率就越低,但每次 Full GC 消耗的时间越大

  • 建议如下:

    • -Xms 和 -Xmx 的值设置成相等,堆大小默认为 -Xms 指定的大小,默认空闲堆内存小于 40% 时,JVM 会扩大堆到 -Xmx 指定的大小;空闲堆内存大于 70% 时,JVM 会减小堆到 -Xms 指定的大小。如果在 Full GC 后满足不了内存需求会动态调整,这个阶段比较耗费资源

    • 新生代尽量设置大一些,让对象在新生代多存活一段时间,每次 Minor GC 都要尽可能多的收集垃圾对象,防止或延迟对象进入老年代的机会,以减少应用程序发生 Full GC 的频率。

    • 老年代如果使用 CMS 收集器,新生代可以不用太大,因为 CMS 的并行收集速度也很快,收集过程比较耗时的并发标记和并发清除阶段都可以与用户线程并发执行。

    • 方法区大小的设置,1.6 之前的需要考虑系统运行时动态增加的常量、静态变量等,1.7 只要差不多能装下启动时和后期动态加载的类信息就行

  • 代码实现方面,性能出现问题比如程序等待、内存泄漏除了 JVM 配置可能存在问题,代码实现上也有很大关系:

    • 避免创建过大的对象及数组:过大的对象或数组在新生代没有足够空间容纳时会直接进入老年代,如果是短命的大对象,会提前出发 Full GC。

    • 避免同时加载大量数据,如一次从数据库中取出大量数据,或者一次从 Excel 中读取大量记录,可以分批读取,用完尽快清空引用。

    • 当集合中有对象的引用,这些对象使用完之后要尽快把集合中的引用清空,这些无用对象尽快回收避免进入老年代。

    • 可以在合适的场景(如实现缓存)采用软引用、弱引用,比如用软引用来为 ObjectA 分配实例:SoftReference objectA=new SoftReference(); 在发生内存溢出前,会将 objectA 列入回收范围进行二次回收,如果这次回收还没有足够内存,才会抛出内存溢出的异常

  • 避免产生死循环,产生死循环后,循环体内可能重复产生大量实例,导致内存空间被迅速占满

    • 尽量避免长时间等待外部资源(数据库、网络、设备资源等)的情况,缩小对象的生命周期,避免进入老年代,如果不能及时返回结果可以适当采用异步处理的方式等

常用JVM参数参考

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
参数	                            说明	                                                                                                          实例

-Xms 初始堆大小,默认物理内存的1/64 -Xms512M
-Xmx 最大堆大小,默认物理内存的1/4 -Xms2G
-Xmn 新生代内存大小,官方推荐为整个堆的3/8 -Xmn512M
-Xss 线程堆栈大小,jdk1.5及之后默认1M,之前默认256k -Xss512k
-XX:NewRatio=n 设置新生代和年老代的比值。如:为3,表示年轻代与年老代比值为1:3,年轻代占整个年轻代年老代和的1/4 -XX:NewRatio=3
-XX:SurvivorRatio=n 年轻代中Eden区与两个Survivor区的比值。注意Survivor区有两个。如:8,表示Eden:Survivor=8:1:1,一个Survivor区占整个年轻代的1/8 -XX:SurvivorRatio=8
-XX:PermSize=n 永久代初始值,默认为物理内存的1/64 -XX:PermSize=128M
-XX:MaxPermSize=n 永久代最大值,默认为物理内存的1/4 -XX:MaxPermSize=256M
-verbose:class 在控制台打印类加载信息
-verbose:gc 在控制台打印垃圾回收日志
-XX:+PrintGC 打印GC日志,内容简单
-XX:+PrintGCDetails 打印GC日志,内容详细
-XX:+PrintGCDateStamps 在GC日志中添加时间戳
-Xloggc:filename 指定gc日志路径 -Xloggc:/data/jvm/gc.log
-XX:+UseSerialGC 年轻代设置串行收集器Serial
-XX:+UseParallelGC 年轻代设置并行收集器Parallel Scavenge
-XX:ParallelGCThreads=n 设置Parallel Scavenge收集时使用的CPU数。并行收集线程数。 -XX:ParallelGCThreads=4
-XX:MaxGCPauseMillis=n 设置Parallel Scavenge回收的最大时间(毫秒) -XX:MaxGCPauseMillis=100
-XX:GCTimeRatio=n 设置Parallel Scavenge垃圾回收时间占程序运行时间的百分比。公式为1/(1+n) -XX:GCTimeRatio=19
-XX:+UseParallelOldGC 设置老年代为并行收集器ParallelOld收集器
-XX:+UseConcMarkSweepGC 设置老年代并发收集器CMS
-XX:+CMSIncrementalMode 设置CMS收集器为增量模式,适用于单CPU情况。

思维导图

性能调优

jvm垃圾回收分析

发表于 2018-06-27 | 更新于 2019-04-27 | 分类于 jvm

JVM垃圾回收

  • 垃圾回收,就是通过垃圾收集器把内存中没用的对象清理掉。垃圾回收涉及到的内容有:
    • 判断对象是否已死
    • 选择垃圾收集算法
    • 选择垃圾收集时间
    • 选择适当的垃圾收集器(已死对象)

判断对象是否已死

  • 判断对象是否已死就是找出哪些对象是已经死掉的,以后不会再用到的,就像地上有废纸、饮料瓶和百元大钞,扫地前要先判断出地上废纸和饮料瓶是垃圾,百元大钞不是垃圾。
    判断对象是否已死有引用计数算法和可达性分析算法

    • 引用计数算法

      • 给每一个对象添加一个引用计数器,每当有一个地方引用它时,计数器值加 1;每当有一个地方不再引用它时,计数器值减 1,这样只要计数器的值不为 0,
        就说明还有地方引用它,它就不是无用的对象。如下图,对象 2 有 1 个引用,它的引用计数器值为 1,对象 1有两个地方引用,它的引用计数器值为 2
        jvm

      • 这种方法看起来非常简单,但目前许多主流的虚拟机都没有选用这种算法来管理内存,原因就是当某些对象之间互相引用时,无法判断出这些对象是否已死,如下图,
        对象 1 和对象 2 都没有被堆外的变量引用,而是被对方互相引用,这时他们虽然没有用处了,但是引用计数器的值仍然是 1,无法判断他们是死对象,垃圾回收器也就无法回收
        jvm

    • 可达性分析算法

      • 了解可达性分析算法之前先了解一个概念——GC Roots,垃圾收集的起点,
        可以作为 GC Roots 的有虚拟机栈中本地变量表中引用的对象、方法区中静态属性引用的对象、方法区中常量引用的对象、本地方法栈中 JNI(Native 方法)引用的对象
      • 当一个对象到 GC Roots 没有任何引用链相连(GC Roots 到这个对象不可达)时,就说明此对象是不可用的,是死对象
      • 如下图:object1、object2、object3、object4 和 GC Roots 之间有可达路径,
        这些对象不会被回收,但 object5、object6、object7 到 GC Roots 之间没有可达路径,这些对象就被判了死刑
        jvm
      • 上面被判了死刑的对象(object5、object6、object7)并不是必死无疑,还有挽救的余地。
        进行可达性分析后对象和 GC Roots 之间没有引用链相连时,对象将会被进行一次标记,接着会判断如果对象没有覆盖 Object的finalize() 方法或者 finalize() 方法已经被虚拟机调用过
        那么它们就会被行刑(清除);如果对象覆盖了 finalize() 方法且还没有被调用,则会执行 finalize() 方法中的内容,所以在 finalize() 方法中如果重新与 GC Roots 引用链上的对象关联就可以拯救自己,
        但是一般不建议这么做,周志明老师也建议大家完全可以忘掉这个方法
    • 方法区回收

      • 上面说的都是对堆内存中对象的判断,方法区中主要回收的是废弃的常量和无用的类。
        判断常量是否废弃可以判断是否有地方引用这个常量,如果没有引用则为废弃的常量。

      • 判断类是否废弃需要同时满足如下条件:

        • 该类所有的实例已经被回收(堆中不存在任何该类的实例)。

        • 加载该类的 ClassLoader 已经被回收。

        • 该类对应的 java.lang.Class 对象在任何地方没有被引用(无法通过反射访问该类的方法)。

  • 总结思维导图
    jvm

常用垃圾回收算法

  • 常用的垃圾回收算法有三种:标记-清除算法、复制算法、标记-整理算法

    • 标记-清除算法:分为标记和清除两个阶段,首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象,如下图
    • 缺点:标记和清除两个过程效率都不高;标记清除之后会产生大量不连续的内存碎片
      jvm

    • 复制算法:把内存分为大小相等的两块,每次存储只用其中一块,当这一块用完了,就把存活的对象全部复制到另一块上,同时把使用过的这块内存空间全部清理掉,往复循环,如下图

    • 缺点:实际可使用的内存空间缩小为原来的一半,比较适合
      jvm

    • 标记-整理算法:先对可用的对象进行标记,然后所有被标记的对象向一段移动,最后清除可用对象边界以外的内存,如下图
      jvm

    • 分代收集算法:把堆内存分为新生代和老年代,新生代又分为 Eden 区、From Survivor 和 To Survivor。一般新生代中的对象基本上都是朝生夕灭的,每次只有少量对象存活,因此采用复制算法,只需要复制那些少量存活的对象就可以完成垃圾收集;老年代中的对象存活率较高,就采用标记-清除和标记-整理算法来进行回收
      jvm

    • 在这些区域的垃圾回收大概有如下几种情况:

      • 大多数情况下,新的对象都分配在Eden区,当 Eden 区没有空间进行分配时,将进行一次 Minor GC,清理 Eden 区中的无用对象。清理后,Eden 和 From Survivor 中的存活对象如果小于To Survivor 的可用空间则进入To Survivor,否则直接进入老年代);Eden 和 From Survivor 中还存活且能够进入 To Survivor 的对象年龄增加 1 岁(虚拟机为每个对象定义了一个年龄计数器,每执行一次 Minor GC 年龄加 1),当存活对象的年龄到达一定程度(默认 15 岁)后进入老年代,可以通过 -XX:MaxTenuringThreshold 来设置年龄的值
      • 当进行了 Minor GC 后,Eden 还不足以为新对象分配空间(那这个新对象肯定很大),新对象直接进入老年代
      • 占 To Survivor 空间一半以上且年龄相等的对象,大于等于该年龄的对象直接进入老年代,比如 Survivor 空间是 10M,有几个年龄为 4 的对象占用总空间已经超过 5M,则年龄大于等于 4 的对象都直接进入老年代,不需要等到 MaxTenuringThreshold 指定的岁数
      • 在进行 Minor GC 之前,会判断老年代最大连续可用空间是否大于新生代所有对象总空间,如果大于,说明 Minor GC 是安全的,否则会判断是否允许担保失败,如果允许,判断老年代最大连续可用空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则执行 Minor GC,否则执行 Full GC
      • 当在 java 代码里直接调用 System.gc() 时,会建议 JVM 进行 Full GC,但一般情况下都会触发 Full GC,一般不建议使用,尽量让虚拟机自己管理 GC 的策略
      • 永久代(方法区)中用于存放类信息,jdk1.6 及之前的版本永久代中还存储常量、静态变量等,当永久代的空间不足时,也会触发 Full GC,如果经过 Full GC 还无法满足永久代存放新数据的需求,就会抛出永久代的内存溢出异常
      • 大对象(需要大量连续内存的对象)例如很长的数组,会直接进入老年代,如果老年代没有足够的连续大空间来存放,则会进行 Full GC
  • 总结思维导图
    jvm

选择垃圾收集的时间

  • 当程序运行时,各种数据、对象、线程、内存等都时刻在发生变化,当下达垃圾收集命令后就立刻进行收集吗?肯定不是。这里来了解两个概念:安全点(safepoint)和安全区(safe region)

    • 安全点

      • 从线程角度看,安全点可以理解为是在代码执行过程中的一些特殊位置,当线程执行到安全点的时候,说明虚拟机当前的状态是安全的,如果有需要,可以在这里暂停用户线程。当垃圾收集时,如果需要暂停当前的用户线程,但用户线程当时没在安全点上,则应该等待这些线程执行到安全点再暂停
      • 举个例子,妈妈在扫地,儿子在吃西瓜(瓜皮会扔到地上),妈妈扫到儿子跟前时,儿子说:“妈妈等一下,让我吃完这块再扫。”儿子吃完这块西瓜把瓜皮扔到地上后就是一个安全点,妈妈可以继续扫地(垃圾收集器可以继续收集垃圾)。理论上,解释器的每条字节码的边界上都可以放一个安全点,实际上,安全点基本上以“是否具有让程序长时间执行的特征”为标准进行选定
    • 安全区

      • 安全点是相对于运行中的线程来说的,对于如sleep或blocked等状态的线程,收集器不会等待这些线程被分配CPU时间,这时候只要线程处于安全区中,就可以算是安全的。安全区就是在一段代码片段中,引用关系不会发生变化,可以看作是被扩展、拉长了的安全点
      • 还以上面的例子说明,妈妈在扫地,儿子在吃西瓜(瓜皮会扔到地上),妈妈扫到儿子跟前时,儿子说:“妈妈你继续扫地吧,我还得吃10分钟呢!”儿子吃瓜的这段时间就是安全区,妈妈可以继续扫地(垃圾收集器可以继续收集垃圾)
  • 垃圾回收过程
    jvm

常见垃圾收集器

  • 常见的垃圾收集器有如下几种:
    • 新生代收集器:Serial、ParNew、Parallel Scavenge。
    • 老年代收集器:Serial Old、CMS、Parallel Old
    • 堆内存垃圾收集器:G1
  • 每种垃圾收集器之间有连线,表示他们可以搭配使用
    jvm

    • Serial 收集器

      • Serial 是一款用于新生代的单线程收集器,采用复制算法进行垃圾收集。Serial 进行垃圾收集时,不仅只用一条线程执行垃圾收集工作,它在收集的同时,所有的用户线程必须暂停(Stop The World)
      • 就比如妈妈在家打扫卫生的时候,肯定不会边打扫边让儿子往地上乱扔纸屑,否则一边制造垃圾,一遍清理垃圾,这活啥时候也干不完
      • 如下是 Serial 收集器和 Serial Old 收集器结合进行垃圾收集的示意图,当用户线程都执行到安全点时,所有线程暂停执行,Serial 收集器以单线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行
        jvm
      • 适用场景:Client 模式(桌面应用);单核服务器;可以用 -XX:+UserSerialGC 来选择 Serial 作为新生代收集器
    • ParNew 收集器

      • ParNew 就是一个 Serial 的多线程版本,其它与Serial并无区别。ParNew 在单核 CPU 环境并不会比 Serial 收集器达到更好的效果,它默认开启的收集线程数和 CPU 数量一致,可以通过 -XX:ParallelGCThreads 来设置垃圾收集的线程数
      • 如下是 ParNew 收集器和 Serial Old 收集器结合进行垃圾收集的示意图,当用户线程都执行到安全点时,所有线程暂停执行,ParNew 收集器以多线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行
        jvm
      • 适用场景:多核服务器;与 CMS 收集器搭配使用。当使用 -XX:+UserConcMarkSweepGC 来选择 CMS 作为老年代收集器时,新生代收集器默认就是 ParNew,也可以用 -XX:+UseParNewGC 来指定使用 ParNew 作为新生代收集器
    • Parallel Scavenge 收集器

      • Parallel Scavenge 也是一款用于新生代的多线程收集器,与 ParNew 的不同之处是,ParNew 的目标是尽可能缩短垃圾收集时用户线程的停顿时间,Parallel Scavenge 的目标是达到一个可控制的吞吐量
      • 吞吐量就是 CPU 执行用户线程的的时间与 CPU 执行总时间的比值【吞吐量 = 运行用户代代码时间/(运行用户代码时间+垃圾收集时间)】,比如虚拟机一共运行了 100 分钟,其中垃圾收集花费了 1 分钟,那吞吐量就是 99% 。比如下面两个场景,垃圾收集器每 100 秒收集一次,每次停顿 10 秒,和垃圾收集器每 50 秒收集一次,每次停顿时间 7 秒,虽然后者每次停顿时间变短了,但是总体吞吐量变低了,CPU 总体利用率变低了

        收集频率                 每次停堆时间           吞吐量
        每100秒收集一次              10秒               91%
        每50秒收集一次                7秒               88%
        
      • 可以通过 -XX:MaxGCPauseMillis 来设置收集器尽可能在多长时间内完成内存回收,可以通过 -XX:GCTimeRatio 来精确控制吞吐量
      • 如下是 Parallel 收集器和 Parallel Old 收集器结合进行垃圾收集的示意图,在新生代,当用户线程都执行到安全点时,所有线程暂停执行,ParNew 收集器以多线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行;在老年代,当用户线程都执行到安全点时,所有线程暂停执行,Parallel Old 收集器以多线程,采用标记整理算法进行垃圾收集工作
      • -XX:+UseAdaptiveSizePolicy:这是一个开关参数,当这个参数打开后,就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数,只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后使用MaxGVPauseMillis参数或GCTimeRation参数给虚拟机设立一个优化目标
        jvm
      • 适用场景:注重吞吐量,高效利用 CPU,需要高效运算且不需要太多交互。
        可以使用 -XX:+UseParallelGC 来选择 Parallel Scavenge 作为新生代收集器,jdk7、jdk8 默认使用 Parallel Scavenge 作为新生代收集器
    • Serial Old 收集器

      • Serial Old 收集器是 Serial 的老年代版本,同样是一个单线程收集器,采用标记-整理算法
      • 如下图是 Serial 收集器和 Serial Old 收集器结合进行垃圾收集的示意图:
        jvm
      • 适用场景:Client 模式(桌面应用);单核服务器;与 Parallel Scavenge 收集器搭配;作为 CMS 收集器的后备预案
    • CMS(Concurrent Mark Sweep) 收集器

      • CMS 收集器是一种以最短回收停顿时间为目标的收集器,以 “ 最短用户线程停顿时间 ” 著称。整个垃圾收集过程分为 4 个步骤:

        • 初始标记:标记一下 GC Roots 能直接关联到的对象,速度较快
        • 并发标记:进行 GC Roots Tracing,标记出全部的垃圾对象,耗时较长
        • 重新标记:修正并发标记阶段引用户程序继续运行而导致变化的对象的标记记录,耗时较短
        • 并发清除:用标记-清除算法清除垃圾对象,耗时较长
      • 整个过程耗时最长的并发标记和并发清除都是和用户线程一起工作,所以从总体上来说,CMS 收集器垃圾收集可以看做是和用户线程并发执行的
        jvm

      • CMS 收集器也存在一些缺点:
        • 对 CPU 资源敏感:默认分配的垃圾收集线程数为(CPU 数+3)/4,随着 CPU 数量下降,占用 CPU 资源越多,吞吐量越小
        • 无法处理浮动垃圾:在并发清理阶段,由于用户线程还在运行,还会不断产生新的垃圾,CMS 收集器无法在当次收集中清除这部分垃圾。同时由于在垃圾收集阶段用户线程也在并发执行,CMS 收集器不能像其他收集器那样等老年代被填满时再进行收集,需要预留一部分空间提供用户线程运行使用。当 CMS 运行时,预留的内存空间无法满足用户线程的需要,就会出现 “ Concurrent Mode Failure ” 的错误,这时将会启动后备预案,临时用 Serial Old 来重新进行老年代的垃圾收集
        • 因为 CMS 是基于标记-清除算法,所以垃圾回收后会产生空间碎片,可以通过 -XX:UserCMSCompactAtFullCollection 开启碎片整理(默认开启),在 CMS 进行 Full GC 之前,会进行内存碎片的整理。还可以用 -XX:CMSFullGCsBeforeCompaction 设置执行多少次不压缩(不进行碎片整理)的 Full GC 之后,跟着来一次带压缩(碎片整理)的 Full GC
      • 适用场景:重视服务器响应速度,要求系统停顿时间最短。可以使用 -XX:+UserConMarkSweepGC 来选择 CMS 作为老年代收集器
    • Parallel Old 收集器

      • Parallel Old 收集器是 Parallel Scavenge 的老年代版本,是一个多线程收集器,采用标记-整理算法。可以与 Parallel Scavenge 收集器搭配,可以充分利用多核 CPU 的计算能力
        jvm
      • 适用场景:与Parallel Scavenge 收集器搭配使用;注重吞吐量。jdk7、jdk8 默认使用该收集器作为老年代收集器,使用 -XX:+UseParallelOldGC 来指定使用 Paralle Old 收集器
    • G1 收集器

      • G1 收集器是 jdk1.7 才正式引用的商用收集器,现在已经成为 jdk9 默认的收集器。前面几款收集器收集的范围都是新生代或者老年代,G1 进行垃圾收集的范围是整个堆内存,它采用 “ 化整为零 ” 的思路,把整个堆内存划分为多个大小相等的独立区域(Region),在 G1 收集器中还保留着新生代和老年代的概念,它们分别都是一部分 Region,如下图:
        jvm
      • 每一个方块就是一个区域,每个区域可能是 Eden、Survivor、老年代,每种区域的数量也不一定。JVM 启动时会自动设置每个区域的大小(1M ~ 32M,必须是 2 的次幂),最多可以设置 2048 个区域(即支持的最大堆内存为 32M*2048 = 64G),假如设置 -Xmx8g -Xms8g,则每个区域大小为 8g/2048=4M
      • 为了在 GC Roots Tracing 的时候避免扫描全堆,在每个 Region 中,都有一个 Remembered Set 来实时记录该区域内的引用类型数据与其他区域数据的引用关系(在前面的几款分代收集中,新生代、老年代中也有一个 Remembered Set 来实时记录与其他区域的引用关系),在标记时直接参考这些引用关系就可以知道这些对象是否应该被清除,而不用扫描全堆的数据
      • G1 收集器可以 “ 建立可预测的停顿时间模型 ”,它维护了一个列表用于记录每个 Region 回收的价值大小(回收后获得的空间大小以及回收所需时间的经验值),这样可以保证 G1 收集器在有限的时间内可以获得最大的回收效率
      • 如下图所示,G1 收集器收集器收集过程有初始标记、并发标记、最终标记、筛选回收,和 CMS 收集器前几步的收集过程很相似:
        jvm
        初始标记:标记出 GC Roots 直接关联的对象,这个阶段速度较快,需要停止用户线程,单线程执行
        • 并发标记:从 GC Root 开始对堆中的对象进行可达新分析,找出存活对象,这个阶段耗时较长,但可以和用户线程并发执行
        • 并发标记:从 GC Root 开始对堆中的对象进行可达新分析,找出存活对象,这个阶段耗时较长,但可以和用户线程并发执行
        • 筛选回收:筛选回收阶段会对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来指定回收计划(用最少的时间来回收包含垃圾最多的区域,这就是 Garbage First 的由来——第一时间清理垃圾最多的区块),这里为了提高回收效率,并没有采用和用户线程并发执行的方式,而是停顿用户线程
      • 适用场景:要求尽可能可控 GC 停顿时间;内存占用较大的应用。可以用 -XX:+UseG1GC 使用 G1 收集器,jdk9 默认使用 G1 收集器
    • 总结思维导图
      jvm

jvm内存溢出分析

发表于 2018-06-23 | 更新于 2019-04-20 | 分类于 jvm

堆内存溢出

  • 堆内存中主要存放对象、数组等,只要不断地创建这些对象,并且保证 GC Roots 到对象之间有可达路径来避免垃圾收集回收机制清除这些对象
    当这些对象所占空间超过最大堆容量时,就会产生 OutOfMemoryError 的异常。堆内存异常示例如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    /**
    * 设置最大堆最小堆:-Xms20m -Xmx20m
    * 运行时,不断在堆中创建OOMObject类的实例对象,且while执行结束之前,GC Roots(代码中的oomObjectList)到对象(每一个OOMObject对象)之间有可达路径,垃圾收集器就无法回收它们,最终导致内存溢出。
    */
    public class HeapOOM {
    static class OOMObject {
    }
    public static void main(String[] args) {
    List<OOMObject> oomObjectList = new ArrayList<>();
    while (true) {
    oomObjectList.add(new OOMObject());
    }
    }
    }
  • 运行后会报异常,在堆栈信息中可以看到

    java.lang.OutOfMemoryError: Java heap space 的信息,说明在堆内存空间产生内存溢出的异常。

    新产生的对象最初分配在新生代,新生代满后会进行一次 Minor GC,如果 Minor GC 后空间不足会把该对象和新生代满足条件的对象放入老年代,老年代空间不足时会进行 Full GC,之后如果空间还不足以存放新对象则抛出 OutOfMemoryError 异常。

    常见原因:内存中加载的数据过多如一次从数据库中取出过多数据;集合对对象引用过多且使用完后没有清空;代码中存在死循环或循环产生过多重复对象;堆内存分配不合理;网络连接问题、数据库问题等

虚拟机栈/本地方法栈溢出

  • StackOverflowError:当线程请求的栈的深度大于虚拟机所允许的最大深度,则抛出StackOverflowError,简单理解就是虚拟机栈中的栈帧数量过多(一个线程嵌套调用的方法数量过多)时,就会抛出StackOverflowError异常

    • 最常见就是方法无限递归调用
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      /**
      * 设置每个线程的栈大小:-Xss256k
      * 运行时,不断调用doSomething()方法,main线程不断创建栈帧并入栈,导致栈的深度越来越大,最终导致栈溢出。
      */
      public class StackSOF {
      private int stackLength=1;
      public void doSomething(){
      stackLength++;
      doSomething();
      }
      public static void main(String[] args) {
      StackSOF stackSOF=new StackSOF();
      try {
      stackSOF.doSomething();
      }catch (Throwable e){//注意捕获的是Throwable
      System.out.println("栈深度:"+stackSOF.stackLength);
      throw e;
      }
      }
      }
  • 上述代码执行后抛出:Exception in thread “Thread-0” java.lang.StackOverflowError 的异常

  • OutOfMemoryError:如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出 OutOfMemoryError

    • 我们可以这样理解,虚拟机中可以供栈占用的空间≈可用物理内存 - 最大堆内存 - 最大方法区内存,比如一台机器内存为 4G,系统和其他应用占用 2G,虚拟机可用的物理内存为 2G,最大堆内存为 1G,最大方法区内存为 512M
      那可供栈占有的内存大约就是 512M,假如我们设置每个线程栈的大小为 1M,那虚拟机中最多可以创建 512个线程,超过 512个线程再创建就没有空间可以给栈了,就报 OutOfMemoryError 异常了

    oomStack

    • 栈上能够产生 OutOfMemoryError 的示例如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    /**
    * 设置每个线程的栈大小:-Xss2m
    * 运行时,不断创建新的线程(且每个线程持续执行),每个线程对一个一个栈,最终没有多余的空间来为新的线程分配,导致OutOfMemoryError
    */
    public class StackOOM {
    private static int threadNum = 0;
    public void doSomething() {
    try {
    Thread.sleep(100000000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    }
    public static void main(String[] args) {
    final StackOOM stackOOM = new StackOOM();
    try {
    while (true) {
    threadNum++;
    Thread thread = new Thread(new Runnable() {
    @Override
    public void run() {
    stackOOM.doSomething();
    }
    });
    thread.start();
    }
    } catch (Throwable e) {
    System.out.println("目前活动线程数量:" + threadNum);
    throw e;
    }
    }
    }
    • 上述代码运行后会报异常,在堆栈信息中可以看到 java.lang.OutOfMemoryError: unable to create new native thread 的信息,无法创建新的线程,说明是在扩展栈的时候产生的内存溢出异常

    • 总结:在线程较少的时候,某个线程请求深度过大,会报 StackOverflow 异常,解决这种问题可以适当加大栈的深度(增加栈空间大小),也就是把 -Xss 的值设置大一些,但一般情况下是代码问题的可能性较大;在虚拟机产生线程时,无法为该线程申请栈空间了,会报 OutOfMemoryError 异常,解决这种问题可以适当减小栈的深度,也就是把 -Xss 的值设置小一些,每个线程占用的空间小了,总空间一定就能容纳更多的线程,但是操作系统对一个进程的线程数有限制,经验值在 3000~5000 左右。
      在 jdk1.5 之前 -Xss 默认是 256k,jdk1.5 之后默认是 1M,这个选项对系统硬性还是蛮大的,设置时要根据实际情况,谨慎操作

方法区溢出

  • 前面说到,方法区主要用于存储虚拟机加载的类信息、常量、静态变量,以及编译器编译后的代码等数据,所以方法区溢出的原因就是没有足够的内存来存放这些数据
  • 由于在 jdk1.6 之前字符串常量池是存在于方法区中的,所以基于 jdk1.6 之前的虚拟机,可以通过不断产生不一致的字符串(同时要保证和 GC Roots 之间保证有可达路径)来模拟方法区的 OutOfMemoryError 异常;但方法区还存储加载的类信息,
    所以基于 jdk1.7 的虚拟机,可以通过动态不断创建大量的类来模拟方法区溢出

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    /**
    * 设置方法区最大、最小空间:-XX:PermSize=10m -XX:MaxPermSize=10m
    * 运行时,通过cglib不断创建JavaMethodAreaOOM的子类,方法区中类信息越来越多,最终没有可以为新的类分配的内存导致内存溢出
    */
    public class JavaMethodAreaOOM {
    public static void main(final String[] args){
    try {
    while (true){
    Enhancer enhancer=new Enhancer();
    enhancer.setSuperclass(JavaMethodAreaOOM.class);
    enhancer.setUseCache(false);
    enhancer.setCallback(new MethodInterceptor() {
    @Override
    public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
    return methodProxy.invokeSuper(o,objects);
    }
    });
    enhancer.create();
    }
    }catch (Throwable t){
    t.printStackTrace();
    }
    }
    }
  • 上述代码运行后会报 java.lang.OutOfMemoryError: PermGen space 的异常,说明是在方法区出现了内存溢出的错误

本机直接内存溢出

  • 本机直接内存(DirectMemory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域,但 Java 中用到 NIO 相关操作时(比如 ByteBuffer 的 allocteDirect 方法申请的是本机直接内存),也可能会出现内存溢出的异常

  • DirectMemory容量可通过-XX:MaxDirectMemorySize指定,如果不指定,默认与java堆的最大值Xmx一样。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    /**
    * VM Args: -xmx20M -XX:MaxDirectMemorySize=10M
    */
    public class DirectMemoryOOM{

    private static final int _1MB = 1024 * 1024 ;

    public static void main(String [] args)throws Exception{

    Field unsafeFild = Unsafe.class.getDeclaredFields()[0];
    unsafeField.setAccessible(true);
    Unsafe unsafe = (Unsafe) unsafeFileld.get(null);
    while(true){
    unsafe.allocateMemory(_1MB);
    }
    }
    }
  • 上述代码运行后会报 java.lang.OutOfMemoryError的异常,说明是在直接内存出现了内存溢出的错误

总结

jvmStackTop

1234
Rick Liu

Rick Liu

33 日志
17 分类
17 标签
GitHub E-Mail
推荐阅读
  • 百度前端技术学院
近期文章
  • Spring IOC级联容器原理
  • Mac版本破解starUML
  • starUML入门使用
  • Leader选举原理
  • 面试问题总结
© 2019 Rick Liu
由 Hexo 强力驱动 v3.8.0
|
主题 – NexT.Mist v7.1.0