jvm 内存调优

本文仅对个人多年工作经验以及工作中处理的和jvm相关的问题调优所写一篇总结,全文将从类的加载机制、jvm四大组成、GC原理三个方面去讲解jvm。后面会针对介绍一些案例分析。

jvm是java最核心的部分。包含java的编译,加载,运行时内存管理,执行引擎,本地库接口。

class加载机制

java程序想要运行都需要将java文件编译成class文件,然后才能执行。那么jvm又是如何加载class文件的。本文将从四点出发介绍class的加载机制。

class文件加载时机

jvm加载class是按需加载的,不是一开始就加载所有程序依赖的所有class文件。而是按需加载,需要用到什么class时就加载哪些class。jvm有两种情况会加载class

  • 实例化bean时,比如spring ioc在Tomcat启动时就实例化了很多bean,这些bean对应的class就加载了。
  • 通过类名调用静态变量时加载(类名.class除外)

class加载过程

未命名文件 1.png

双亲委派机制

双亲委派机制是class加载的核心机制。jvm中有多个classloader,并且是classloader是有父子关系的,class在加载时,优先交给父类进行加载,父类找不到相关class然后子类classloader才会加载。双亲委派机制主要有以下两点作用

  • 安全,防止java核心api类被重写,比如我们自己写一个java.lang.String类,其实是无法被classloader加载的,因为父classloader已经能够加载。
  • 避免重复加载,父类加载过的class之类不需要再加载
  • 避免冲突,资源隔离(同一个class不同的classloader加载得到的class对象是不同的)

如何打破双亲委派机制:使用自定义classloader即可打破。比如中间件Tomcat、Jdbc、部分热部署框架都打破了双亲委派的加载机制。

注意:双亲委派机制中的父加载器,不是父子类的关系。其实是在classloader中有一个属性parent指向了父加载器,并且有些Classloader中parent是空的,比如ExtClassLoader的parent classloader就是空的,但是在双亲委派机制中,ExtClassLoader的父classloader是BootStrap ClassLoader

类对象解密

对象创建过程

  • 当虚拟机遇到一条new指令时,先去常量池检查对应的class类型有没有被加载过,如果没有先进行class类加载
  • 在类检查校验通过后,为新对象分配内存(堆内存中)
  • 给对象初始化
  • 对象设置,设置对象头哈希码、GC分代年龄元数据、是否偏向锁

对象的内存结构

未命名文件 2.png

classloader

对象访问地址定位

  • 句柄方式
    image.png
    jvm会在堆中划分一块句柄池,栈中的reference存储的是句柄池中句柄地址,reference是指向对象的一个引用。

句柄方式的好处在于当对象被移动时,reference不需要做任何改动。只需要改动句柄池中的句柄地址。但是多了一次间接访问

  • 直接指针
    image.png
    reference直接存储对象的地址。少一次间接访问的开销。
    直接指针的好处就是快,直接访问对象地址。

jvm四大组成部分

上述中介绍了class的加载机制,接下来我们要介绍class以及对象在jvm中是如何存储、管理、执行。

classloader

classloader是jvm中的一个核心模块,负责加载class,classloader按照作用可以划分为以下四类。
类加载器.png

jvm运行时数据

jvm最重要的就是运行时数据的管理,其中最核心就是GC。要了解GC原理就要了解jvm内存模型,jvm调优本质上看也是对jvm的调优。所以先讲jvm的内存模型。我们先来看一张大图,如下图所示。
JVM.png
一个java的应用程序可以说是一个jvm进程。而我们编写的java源码代码首先需要被编译成class文件才能被加载,加载class文件需要classloader。class字节码最终是在jvm进程中执行,jvm执行过程中需要内存,那么jvm是如何管理这些内存的。就是我们要重点讲解的jvm内存模型。

首先jvm在运行时数据区从作用域划分为两块。线程共享区和线程独占区。线程共享区是所有线程都能使用的区域。

线程共享区

线程共享区分方法区和堆内存。也是jvm内存占用最大的一块区域,因为这块区域在jvm中是线程共享的,所以这块数据其实是存在线程安全问题的。线程共享区也可以称为主内存。

方法区

方法区是一种定义或者说概念,早期在jdk中的实现机制是永久代,jdk8的实现方式是元空间区(可以通过jvm内存参数配置可以看出区别)。方法区一般也可以称代码区,因为方法区可以说说用来存代码的,而这些代码从官方语言来说明的话就是,编译后的代码、class的定义如类名、类的注解、类的方法、类的属性。还有常量、静态变量等。

堆内存

堆内存在jvm是最难管理的一部分数据,也是垃圾回收最难处理数据区。堆内存存储的数据。因为堆内存属于共享区,所以在堆内存中为对象分配内存是要加锁的。这也导致new一个对象的开销是比较大的。所以会有单例模式、对象缓存等设计。

public class Student {
    int age;
    String name="小明";
    Teacher teacher=new Teacher();
}
Student.class

Student.class类对象就是存在方法区,.class只是拿到了类对象的引用。Student stu=new Student();new Student()存在堆内存,而stu是拿到了new Student()的引用。

  • 堆的特点:
  1. 线程共享,程序启动时就创建
  2. 占用jvm内存最大的一块
  3. 存放对象实例
  4. GC的主要区域,包含新生代,老年代。
  • 堆的内存模型图
    堆内存模型.png
  1. 类对象被创建时,优先会使用eden分配内存,如果eden内存不够触发新生代GC。
  2. 新生代内存比例默认是:eden:s0:s1=8:1:1
  3. 新生代和老年代内存比例是:young:old=1/3:2/3
  • 什么时候新生代对象会进入老年代
  1. 存活区的类对象分代年龄超过15(jvm参数可配置)
  2. 大对象直接存取老年代,jvm参数可配置,一般还是1M。保证类的轻量以及单一职责原则。
  3. 动态年龄判断规则,存活区几个年龄对象累加大小>50%,比如年龄1+2+3对象总和超过50%,则把3以及以上对象都放到老年代。
  4. 空间担保机制。不细说
  • 常用的jvm堆内存调优参数
-Xmx 指定jvm堆的最大heap大小,如:-Xmx=2g
-Xms 指定jvm堆的最小heap大小,如:-Xms=2g
-XX:PretenureSizeThreshold=1000000  大对象大小,单位为字节
-XX:MaxTenuringThreshold=15 存活年龄上限,超过15对象放入老年代
–XX:SurvivorRatio=6 新生代中的eden:s0:s1比例,默认是8,如果是6相当于比例为6:2:2
-Xmn 指定堆内存新生代的内存大小,剩下的就是老年代,如-Xmn=1G

**以上参数仅描述常规参数,jvm中部分参数需要按照jvm的垃圾回收器来配置。
**

  • java默认的垃圾回收器
C:\Users\Administrator>java -XX:+PrintCommandLineFlags -version
-XX:InitialHeapSize=531924800 -XX:MaxHeapSize=8510796800 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC
java version "1.8.0_241"
Java(TM) SE Runtime Environment (build 1.8.0_241-b07)
Java HotSpot(TM) 64-Bit Server VM (build 25.241-b07, mixed mode)

UseParallelGC是jdk8的默认回收器,也就是新生代采用Parallel Scavenge。老年代采用Parallel Old

线程独占区

当我们 new Thread()时,jvm都会给这个线程分配一块独立内存。这块独立内存就代表一个线程。线程结束后,内存随之销毁。因为是独占区域,所以多线程之间的数据是无法相互访问的。线程独占区也是线程工作区。

程序计数器

虚拟机栈

  • 栈的由来
    虚拟机栈本质是描述java方法调用链路的实现。比如当java需要执行某一段代码逻辑时,首先会创建一个线程,线程的运行入口在run方法中,run方法会调用其他方法。如上图所示。
  1. 线程是用来执行方法的。方法如何执行取决于虚拟机栈
  2. 我们定义的常量、静态变量、类对象(不是类实例对象)都放在方法区是为了线程能够共享使用。
    如下图中整个方法的调用链路就是通过虚拟机栈来描述的。
    方法执行链路

上图中最先执行完的方法是【方法3】,最后执行完的方法是【run()】
,最先调用的方法是【run()】,最后调用的方法是【run()】。所以方法调用链路的过程是先调用的后执行完,也就是先进后出机制,这种机制我们称为栈。所以线程的工作区我们称为虚拟机栈。

  • 栈的内存模型

栈的内存模型

public class Demo001 {
    public static void main(String[] args) {
        int age;
        String name = "小明";
        Student stu = new Student();
        stu.show();
    }
}

class Student {
    int age;
    String name = "小明";

    public String show() {
        return "hello:" + name;
    }
}
  • 虚拟机栈总结
    未虚拟机栈总结

本地方法栈

  1. 线程私有
  2. 和虚拟机栈类似,虚拟机栈是为了java提供服务,本地方法栈是为native方法提供服务

执行引擎

了解下即可
执行引擎

本地库接口

程序在执行之前先要把java代码转换成字节码(class文件),jvm首先需要把字节码通过一定的方式 类加载器(ClassLoader) 把文件加载到内存中 运行时数据区(Runtime Data Area) ,而字节码文件是jvm的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器 执行引擎(Execution Engine) 将字节码翻译成底层系统指令再交由CPU去执行,而这个过程中需要调用其他语言的接口 本地库接口(Native Interface)来实现整个程序的功能,这就是这4个主要组成部分的职责与功能。

GC原理

垃圾回收机制

垃圾回收算法有很多,不同算法适用于不用场景,比如jvm的新生代适合使用复制算法,垃圾回收器是对算法的一个实现,jvm中有多个垃圾回收器。

复制算法

此算法把内存空间划为两个相等的区域,每次只使用其中的一个区域,垃圾回收时,遍历当前使用区域,把还存活的对象复制到另外一个区域。jvm的新生代分为eden:s0:s1比例划分其中存活区中按照等比例划分为s0和s1就是为了方便复制。

  • 优缺点
  1. 存活对象少时效率高,不产生碎片
  2. 可用内存变为原来的一半,存活对象多时效率低

标记清除

此算法执行分两阶段:第一阶段从引用根节点开始标记所有被引的对象,第二阶段遍历整个堆,把未标记的对象清除。

  • 优缺点
  1. 相比于复制算法不浪费内存
  2. 会产生碎片,效率低,因为要扫描两次

标记整理

分两阶段,第一阶段从根节点开始标记所有被引用对象,第二阶段遍历整个堆,清除未标记的对象并且把存活对象“压缩”到堆的其中一块,按顺序排列。

  • 优缺点
  1. 避免了碎片问题和空间占用问题。
  2. 效率比复制算法低,因为要多维护一个链表使幸存对象连续。

分代回收

  • 新生代
  1. 复制算法一个Eden区、两个Survival区(From Survival、To Survival)(8:1:1)大多数情况下对象在Eden区中分配,当Eden区没有足够的空间时,发起一次Minor GC
  2. 大对象直接进入老年代
    大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。 JVM參数-XX:PretenureSizeThreshold可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在Serial和ParNew两个收集器下有效。Parallel Scavenge并不支持这个参数。如果必须使用此参数进行调优,可考虑 ParNew加CMS的收集器组合。 比如设置-XX:PretenureSizeThreshold=1000000 (单位是字节) -XX:+UseSerialGC,再执行程序会发现大对象直接进了老年代。

设置大对象限制主要是为了降低大对象分配内存时造成的内存复制的开销

  1. 长期存活的对象进入老年代,分代年龄

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age) 计数器。 如果对象在Eden出生并经过第一次Minor GC后仍然能够存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。 对象在Survivor中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。 对象晋升到老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold来设置。

对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁

  1. 动态对象年龄判断
    如果在Survivor空间中相同年龄所有对象大小的总和大于 Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。 例如Survivor区域里现在有一批对象, 年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n和n以上的对象都放入老年代。这个规则其实是希望那些可能长期存活的对象,尽早迸入老年代。 对象动态年龄判断机制一般是在minor gc之后触发的。
    Minor GC之后,如果检测到可能长期存活的对象,则让其尽早迸入老年代

  2. 老年代空间分配担保机制
    在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大 于,将尝试进行一次Minor GC,尽管这次MinorGC是有风险的;如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC。
    Minor GC之前做风险判断,是否允许担保失败,如不允许则改为Full GC

  • 老年代
    标记-清除/整理-算法Major GC/Full GC

  • 整个Java堆
    方法区/永久代/元空间,对永久代的回收主要包括废弃的常量和无用的类
    永久代和元空间的区别在于永久代位于JVM的方法区中,元空间并不在虚拟机内存中,而是使用本地内存。Full GC

垃圾收集器分代模型

  • 新生代
  1. Serial简单高效,单线程,收集时暂停其他所有线程(STW)
  2. ParNew Serial的多线程版本,是首选新生代收集器,可配合CMS
  3. Parallel Scavenge以吞吐量为优先的多线程收集器,不支持CMS.
    吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值。 比如用户代码加上垃圾收集总共消耗了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
  • 老年代
  1. Serial Old,Serial的老年代版本,可作为CMS的后备预案
  2. Parallel Old,Parallel Scavenge的老年代版本,多线程,吞吐量优先
  3. CMS,并发收集、低停顿。适用于当今互联网网站或基于浏览器的B/S系统的服务器上,这类应用通常都较为关注服务的响应速度,尽量缩短系统的停顿时间,给用户更好的交互体验流程

CMS

  • 流程
  1. 初始标记:标记GC Roots能直接关联到的对象,速度很快,STW,为什么此时要有STW? 因为我们使用可达性分析算法查找RCRoot不可达的垃圾对象,如果期间不停止用户线程,方法执行完就会弹出栈帧,导致GCRoot根节点失效,之前标记的非垃圾的对象可能又变为垃圾对象。
  2. 并发标记:从GC Roots的直接关联对象开始遍历整个对象图,耗时长
  3. 重新标记:修正并发标记期间可能造成的改动,速度较慢,STW 略长
  4. 并发清除:清除标记阶段已经死亡的对象,此阶段和用户线程并发工作
  • 缺点
  1. 对CPU敏感,并发阶段虽然不会导致用户线程停顿,但却由于占用资源导致应用程序变慢,降低总吞吐量
  2. 由于CMS在清理时与用户线程并发,运行期间还伴随有新垃圾产生,有可能触发担保机制而产生较大停顿
  3. 基于标记清除算法,造成大量的空间碎片,导致没有足够的连续空间存放大对象,不得不提前触发Full GC

JDK8默认组合使用
Parallel Scavenge + Parallel Old(JDK1.8默认)

jvm 调优

以上介绍的都是一些理论知识,没有理论知识是无法对jvm进行调优的,但是仅有理论知识没有实战经验也是不可靠的,jvm调优更看重的是实战经验。遗憾的是本人也没啥实战经验。所以后续仅能从一些基本的参数,以及工具介绍去设想如果生产环境出问题了,如何利用工具以及jvm的一些参数设置定位问题并且解决问题。

调优参数

调优参数很重要,有些参数在调优过程中经常被使用,比如输出GC日志,oom时导出内存dump文件等。配置新生代内存,配置新生代内存比例,诶之栈空间大小等。最重要的还是在实战中的积累,加深对理论知识的理解。

标准参数,

-开头,通过 java 命令查看
java -version
java -help

非标准参数,

  • -X开头,通过 java -X more 查看
    -Xms200m -Xmx200m:最小堆和最大堆
    -Xmn60m:新生代
    -Xss1m:栈空间大小
    -Xloggc:gc.log
    为什么最小堆和最大堆设置一样大?
  1. 防止内存震荡(否则JVM会将堆内存进行扩容和缩容)
  2. 扩展和回缩需要大量的计算,影响程序的执行效率
  • -XX开头。通过 java -XX:+PrintFlagsFinal -version | less 命令查看
    -XX:+PrintGC
    -XX:+PrintGCDetails
    -XX:+PrintHeapAtGC
    -XX:+HeapDumpOnOutOfMemoryError
    -XX:HeapDumpPath=D:\dump2.hprof

本想写点demo案例讲解的,时间有限,先这样吧

# java   内存调优   jvm  

评论

公众号:mumuser

企鹅群:932154986

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×