首页 > 技术文章 > Java JVM

ruhuanxingyun 2019-08-01 14:01 原文

一、JVM内存结构

  1. 组成部分

    A. 程序计数器:当前线程执行字节码的位置指示器,字节码解析器的工作是通过改变这个计数器的值,来选取下一条需要执行的字节码指令,用于分支、循环、跳转、异常处理和线程恢复等功能,属于线程隔离数据区;

    B. Java虚拟机栈:保存局部变量(实例方法和静态方法中变量)、操作数栈、动态链接和方法出口等信息,属于线程隔离数据区;

    C. 本地方法栈:为JVM提供使用native方法的服务,属于线程隔离数据区;

    D. 堆内存:JVM中内存最大的一块,几乎所有的对象实例都在这里分配内存,也是垃圾回收的主要地方,属于线程共享数据区

    E. 方法区:用于已被虚拟机加载的类信息、常量、静态变量和编译后的代码等数据存储,属于线程共享数据区

 

二、堆(heap)内存

  1. 基本概述

    A. 一个JVM实例只存在一个堆内存,即一个进程对应一个堆,堆也是Java内存管理的核心区域;

    B. Java堆区在JVM启动的时候即被创建,其空间大小也就确定了,是JVM管理的最大、最重要的一块内存空间,其堆内存大小是可以调节的;

    C. 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的;

    D. 所有的线程共享Java堆,不过还可以划分线程私有的缓冲区(TLAB);

    E. 几乎所有的对象实例以及数组都应当在运行时分配在堆上;

    F. 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置;

    F. 在方法结束后,堆中的对象不会马上被移除,仅在垃圾收集的时候才会被移除,因为垃圾回收会产生STW现象;

    G. 堆是垃圾回收器(GC)执行垃圾回收的重点区域,注意大对象和频繁GC会导致性能瓶颈,而且栈中没有GC,只有入栈和出栈;

  2. 堆内存构成

    A. 现代垃圾收集器大部分都是基于分代收集理论设计;

    B. Java8以前堆内存逻辑上分为三部分:新生区(Young Generation Space) + 养老区(Tenure Generation Space) + 永久区(Permanent Space),堆空间一般指新生区和养老区,不包含永久区;

    C. Java8及之后堆内存逻辑上分为三部分:新生区 + 养老区 + 元空间(Meta Space),堆空间一般指新生区和养老区,不包含元空间

    D. 新生区(Young/New Generation Space)也叫新生代或年轻代,养老区(Old/Tenure Generation Space)也叫老年区或老年代,永久区(Perm Space)也叫永久代;

  3. 堆空间大小设置

    A. Java堆区用于存储Java对象实例,堆的大小在JVM启动时就已经设定好了,"-Xms"表示堆区的起始内存,默认值为物理内存大小的1/64,"-Xmx"表示堆区的最大内存,默认值为物理内存大小的1/4;

    B. 一旦堆区中的内存大小超过"-Xmx"指定的最大内存,将会抛出"OutOfMemoryError"错误;

    C. 通常将"-Xms"和"-Xmx"两个参数配置相同的值,其目的是为了能够在Java垃圾回收机制清理完堆区后不需要再重新分隔计算堆区的大小,从而提高性能;

  4. 年轻代与老年代

    A. 存储在JVM中的Java对象可以被划分为两类,一类是生命周期较短的瞬间对象,这类对象的创建和消亡都非常迅速;另一类是对象的生命周期非常长,在某些极端的情况下还能够与JVM的生命周期保持一致;

    B. Java堆区可以划分为年轻代(YoungGen)和老年代(OldGen),其中年轻代又可以划分为伊甸园(Eden)区、幸存者(Survivor)0区f和幸存者(Survivor)1区,有时也叫from区、to区,注意:Survivor0区和Survivor1区始终有一个是空的,主要是为了垃圾回收

    C. 配置新生代与老年代在堆结构的占比,默认值-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆区的1/3,这个参数一般不会去改变; 

    D. 在HotSpot中,Eden空间和两个Survivor空间默认占比为8:1:1,但是实际值为6:1:1,因为这是因为自适应机制,就算关闭"-XX:UserAdaptiveSizePolicy"也没用,如果需要为8:1:1,需要显示设置"-XX:SurvivorRatio"参数;

    E. 几乎所有的Java对象都是在Eden区被new出来的;

    F. 绝大部分的Java对象的销毁都是在新生代进行的,IBM公司的研究表明:新生代的80%的对象都是朝生夕死的;

    G. 可以使用"-Xmn"设置新生代的最大内存大小,一般这个值也不改变,就不用设置;

  5. 对象分配过程

    A. new的对象先放Eden区,此区域有大小限制;

    B. 当Eden区的空间填满时,程序又需要创建对象,JVM的垃圾回收机制将Eden区进行垃圾回收(Minor GC),将Eden区中的不再被其他对象所引用的对象进行销毁,再加载新的对象放在Eden区;

    C. 然后将Eden区中的剩余对象移动到幸存者0区;

    D. 如果再次触发垃圾回收,此时上次幸存下来的放到Survivor0区,如果没有回收就会放到Survivor1区;

    E. 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去Survivor1区;

    F. 当次数为15时就会放置老年代,可以通过设置"-XX;MaxTenuringThreshold"改变次数;

    G. 针对幸存者S0/S1的总结:复制之后有交换,谁空谁是to;

    H. 关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不再永久区/元空间收集。

  

三、垃圾回收

  1. 内存可达性

    A. 引用计数法:原理是在此对象有个引用就增加一个计数,删除一个引用则减少一个计数,垃圾回收时,只收集计数为0的对象,缺点是无法处理循环引用问题;

  2. 垃圾回收算法

    A. 

    B. 

    C. 

  3. 标记清除算法(Mark-Sweep)

    A. 标记清除算法是一种非常基础和常见的垃圾收集算法;

    B. 执行过程:当堆中的有效空间被耗尽的时候,就会停止整个程序(STW),然后进行标记和清除操作,标记是指Collector从引用根节点开始遍历,标记所有被引用的对象,一般是在对象的Header中记录为可达对象,清除是指Collector对堆内存从头到尾进行线性遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收;

    C. 缺点:效率不算高,在GC的时候会发生STW,会导致用户体验差,另外清理出来的空闲内存是不连续的,会产生内存碎片,需要维护一个空闲列表;

    D. 清除的含义:这里的清除并不是置空,而是把需要清除的对象地址保存在空闲的地址列表里,下次有新对象需要加载时,判断垃圾的位置空间是否够,大小满足时就存放;

  4. 复制算法

    A. 执行过程:将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收;

    B. 优点:没有标记和清除过程,实现简单,运行高效,复制还能保证空间的连续性,因此不会产生内存碎片问题;

    C. 缺点:由于复制会导致需要两倍的内存空间,对于G1这种大量region的GC,复制的同时还需要维护region之间对象的引用关系;

    D. 特别注意:如果系统中的垃圾对象很多,复制算法就不太理想,因为它适合复制存活对象数量较少的情况,如新生代垃圾回收;

  5. 标记整理算法(Mark Compact)

    A. 标记整理算法等同于把标记清除算法执行完成后,再进行一次内存碎片整理;

    B. 执行过程:第一阶段和标记清除算法一样,从根节点开始标记所有被引用的对象,第二阶段将所有的存活对象压缩到内存的一端,按顺序排放,之后就清理边界外的所有空间;

    C. 优点:消除了标记清除算法中的内存区域分散问题,若要给新对象分配内存时,只需要持有一个起始内存地址即可,同时也消除了复制算法中,内存减半的代价;

    D. 缺点: 效率上低于复制算法,移动对象的同时,如果对象被其他对象引用,还需要调整引用的地址;

  6. 经典垃圾回收器

    A. 截止JDK8,一共有7个垃圾回收器,每个具有不同的特点,在使用的时候,需要根据具体的情况选用不同的垃圾收集器;

    B. 垃圾收集器对比

垃圾收集器 分类 作用位置 使用算法 特点 适用场景
Serial 串行回收 年轻代 复制算法 响应速度优先 单CPU环下的客户端模式
ParNew 并行回收 年轻代 复制算法 响应速度优先 多CPU环境服务端模式,与CMS配合适用
Parallel 并行回收 年轻代 复制算法 吞吐量优先 后台运算而不需要太多的交互场景
Serial Old 串行回收 老年代 标记整理算法 响应速度优先 单CPU环下的客户端模式
Parallel Old 并行回收 老年代 标记整理算法 吞吐量优先 后台运算而不需要太多的交互场景
CMS 并行回收 老年代 标记清除算法 响应速度优先 互联网或B/S应用
G1 并行回收 年轻代和老年代 复制算法和标记整理算法 响应速度优先 面向服务端应用

  7. Serial和Serial Old回收器

    A. Serial收集器是最基本、历史最悠久的垃圾收集器了,在JDK1.3之前回收新生代唯一的选择,Serial作为HotSpot中客户端模式下的默认新生代垃圾收集器;

    B. Serial收集器采用复制算法、串行回收和"STW"机制方式执行内存回收;

    C. 除了年轻代之外,Serial收集器还提供用于执行老年代垃圾收集的Serial Old收集器,Serial Old收集器同样也采用了串行回收和"STW"机制,只不过内存回收算法使用的是标记整理算法;

    D. 这个收集器是一个单线程的收集器,但它的单线程的意义不仅仅表明它只会使用一个CPU或者一条收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停其他所有的工作线程,直到收集结束;

    E. 优势在于简单且高效,在HotSpot虚拟机中,使用-XX:+UseSerialGC参数可以指定新生代和老年代都使用串行收集器,对于交互较强的应用而言,这种垃圾收集器是不能接受的,所以在Java Web程序中一般不采用。

  8. ParNew回收器

    A. ParNew收集器是多线程的,只能处理新生代,是很多JVM运行在服务端模式下的默认垃圾收集器;

    B. ParNew除了采用并行回收的方式执行内存回收外,同样也是采用复制算法、"STW"机制;

    C. ParNew收集器运行在多CPU的环境下,可以充分利用CPU等物理核心资源,但是在单CPU下,效率却低于Serial收集器;

  9. Parallel收集器

    A. Parallel Scavenge收集器同样采用复制算法、并行回收和"STW"机制;

    B. Parallel Scavenge与ParNew的区别在于自使用调节策略(UserAdaptiveSizePolicy),它的目标是达到一个可控制的吞吐量;

    C. Parallel Old收集器是老年代收集器,采用标记整理算法,同样是基于并行回收和"STW"机制;

    D. 在JDK8中,Parallel作为默认的垃圾回收器,因为服务端模式下Parallel Scavenge和Parallel Old收集器的内存回收性能还挺好;

  10. CMS收集器

    A. CMS是第一个真正意义上的并发收集器,实现了让垃圾收集线程与用户线程同时工作,关注点是尽可能缩短垃圾收集时用户线程的停顿时间;

    B. CMS收集器作用于老年代的,其垃圾收集算法是采用标记清除算法,也采用"STW"机制,使用比较广泛;

    C. CMS收集器工作过程分四个阶段:初始标记阶段、并发标记、重新标记和并发清除;

    D. 在CMS回收过程中,应该确保应用程序用户线程有足够的空间,而且它是在当内存使用率达到一定阈值时,便开始进行回收;

    E. CMS的优点是并发收集和低延迟,但缺点是会产生内存碎片,且收集器对CPU资源敏感,另外它无法处理浮动垃圾;

  11. G1收集器 

    A. G1(Garbge First)是一个并行回收器,它把堆内存分割为很多不相关的区域(region),这些区域在物理上是不连续的;

    B. G1可以避免在整个Java堆中进行全区域的垃圾收集,根据各个区域里面的垃圾堆积的大小,通过在后台维护一个优先队列,每次根据允许的收集时间,优先回收价值最大的Region;

    C. G1是全功能的垃圾收集器,也是JDK9及后面默认的垃圾收集器,主要针对多核CPU和大容量的内存机器;

  12. 回收策略

    A. Minor GC:是指发生在新生代上的GC,Java对象大多存活时间很短,所有 Minor GC非常频繁,一般回收速度也非常快;

    B. Full GC:是指发生在老年代上的GC,老年代对象存活时间长,因此Full GC很少执行,执行速度会比Minor GC慢10倍以上;

    STW(Stop The World):是指在执行垃圾回收算法时,Java应用程序其他所有的线程都被挂起,导致产生一种全局暂停现象;

 

 

四、对象的内存布局(HotSpot虚拟机)

  1. 基本数据类型内存占用字节:boolean(1 byte)、byte(1 byte)、short(2 byte)、char(2 byte)、int(4 byte)、float(4 byte)、long(8 byte)、double(8 byte);

  2. 引用类型内存占用字节:除了本身之外,还存在一个指向它的引用(指针),指针占用的内存在64位虚拟机上是8 byte,默认开启指针压缩是4 byte;

  3. 存储区域划分

    注意:规定对象内存大小必须是8 byte的整数倍

    A. 对象头(Header)分两部分Markword和类型指针(Class Pointer):

      MarkWord:用于存储对象自身的运行时数据,如HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,

            普通对象头在32位系统上占用8字节,64位上占16字节;

      类型指针:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例

           默认32位JVM对指针进行了压缩,用4个字节存储,没压缩就是8字节,64位上压缩占8字节;

      如果对象是一个java数组,那么在对象头中还必须有一块用于记录数组长度的空间,占4字节。

    B. 实例数据(Instance Data):是对象真正存储的有效信息,如变量;

    C. 对齐填充(Padding):这个不是必然存在的,仅仅起到占位符的作用,没有对齐就补全,用于确保对象的总长度为8 byte的整数倍; 

  4. 对象大小估算:内存区域构成字节大小相加;

  5. Java体现

    A. Maven依赖

<!-- JOL 工具类API -->
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.14</version>
</dependency>

    B. 代码

package com.ruhuanxingyun.javabasic.sync;

import org.openjdk.jol.info.ClassLayout;

/**
 * @description: 对象内存布局
 * @author: ruphie
 * @date: Create in 2020/11/26 21:15
 * @company: ruhuanxingyun
 */
public class HelloJOL {

    public static void main(String[] args) {
        Object obj = new Object();
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());

        synchronized (obj) {
            System.out.println(ClassLayout.parseInstance(obj).toPrintable());
        }
    }

}

    C. 结果

 

五、优化

  1. 逃逸分析(Escape Analysis)分类

    A. 方法逃逸:指当一个对象在方法中被定义后,可能作为调用参数被外部方法所引用;

    B. 线程逃逸:指通过复制给类变量或者作为实例变量在其他线程中可以被访问到。

  2. 优化手段

    A. 栈上分配(Stack Allocation):

    B. 同步消除(Syschronization Elimination):

    C. 标量替换(Scalar Replacement):

    

五、工具

  1. jstack命令:查看完整信息,格式为jstack -l pid;   

2. jmap命令

  A. 生成java程序的堆转储快照dump文件

    jmap -dump:format=b,file=file_name pid

    -dump 堆到文件,file代表文件名,pid代表java进程id,另外dump的过程中JVM是暂时停止服务的,生产上所以要谨慎使用;

3. 内存分析工具(Eclipse MAT)

 

可参考:MAT介绍

    JVM相关知识思维导图

推荐阅读