JVM学习笔记
JVM的场景面试题
- 说一下JVM的内存模型, 有哪些区? 分别干什么的?
- Java8的内存分代改进
- 栈和堆的区别? 堆的结构? 为什么两个Survivor区?
- Eden和Survior的比例
- jvm内存分区, 为什么要有新生代和老年代?
- 什么时候对象会进入老年代?
- jvm的永久代会发生垃圾回收吗?
- 对象在JVM中是怎么存储的?
- 对象创建的六大步骤?
- 对象头信息里面有哪些东西?
- 强/弱/软/虚引用有什么区别? 具体使用场景是什么?
栈的面试题
- 举例栈溢出的情况
- 调整栈大小, 就能保证不出现溢出吗?
- 分配的栈内存越大越好吗?
- 垃圾回收是否会涉及虚拟机栈?
- 方法中定义的局部变量是否线程安全?
GC的大厂面试题
- JVM的GC算法有哪些, 目前的JDK版本采用什么回收算法
- G1回收器讲一下回收过程
- GC是什么? 为什么要有GC?
- GC的两种判断方法? CMS收集器和G1收集器的特点
- 分代回收说下
- 垃圾收集策略和算法及各自的优缺点
- 什么情况下触发垃圾回收
- system.gc()和runtime.gc()会做什么事情
- CMS回收停顿了几次, 为什么要停顿2次?
JVM调优工具
- jps: 查看本机java进程信息
- jstack: 打印线程的栈信息, 制作线程dump文件
- jmap: 打印内存映射, 制作堆dump文件
- jstst: 性能监控工具
- jhat: 内存分析工具
- jconsole: 简易的可视化控制台
- jvisualvm: 功能强大的控制台
Java内存模型
- Java内存模型
- JVM虚拟机规范定义的Java内存模型(Java Memory Model)来屏蔽各种硬件和操作系统的访问差异
- 实现Java程序在各个平台下能达到一致的内存访问效果
- 主内存和工作内存
- 所有的变量都存储在主内存, 每个线程有自己的工作内存
- 线程的工作内存中保存了被该线程使用到的变量和主内存副本拷贝
- 线程对变量的所有操作都在工作内存中进行, 而不能直接读写主内存中的变量
- volatile关键字
- 保证此变量对所有线程的可见性
- 禁止指令重排序优化
Java & JVM
- Java: 跨平台的语言 "write once, run anywhere", 字节码文件在不同操作系统的JVM上运行
- JVM : 跨语言的平台 Kotlin, Clojure, Groovy, Scala, Jython, JavaScript编译为字节码文件在JVM中运行
- Java不是最强大的语言, 但是JVM是最强大的虚拟机
JVM(Java虚拟机)
- 作用: 二进制字节码的运行环境, 负责装载字节码到其内部, 解释/编译为对应平台上的机器指令执行
- 特点: 一次编译, 到处运行; 自动内存管理和自动垃圾回收
- 位置: 在操作系统之上, 与硬件没有直接交互
- 整体结构
- 类装载子系统
- 运行时数据区: 堆, 方法区, Java栈, 本地方法栈, 程序计数器
- 执行引擎: 解释器, JIT编译器, 垃圾回收
- 本地方法接口/本机方法库
- 架构模型: 基于栈式架构
- 由于跨平台性的设计, Java的指令都是根据栈来设计的. 不同平台CPU架构不同, 所以不能设计为基于寄存器的
- 优点: 跨平台, 指令集小, 编译器容易实现
- 缺点: 性能下降(实现同样的功能需要更多的指令)
- 生命周期
- 启动: 通过引导类加载器创建一个初始类来完成的
- 执行: 执行Java程序
- 退出:
- 程序正常执行结束
- 程序执行过程中遇到异常或错误而终止
- 操作系统错误导致Java虚拟机进程终止
- 调用Runtime或System的exit方法
- JVM的实现
- Sun Classic VM: jdk1.0时提供, 只提供解释器(没有JIT编译器)
- Exact VM: jdk1.2时提供, 准确式内存管理(知道内存中某个位置数据的具体类型), 具备现代高性能虚拟机雏形
- HotSpot VM: jdk1.3时提供, 占据绝对的市场地位, 称霸武林
- 通过计数器找到最具编译价值代码, 触发即时编译或栈上替换
- 通过编译器与解释器协同工作, 在最优化的程序响应时间与最佳执行性能中取得平衡
- BEA的JRokcet
- 专用于服务器端应用: 不太关注程序的启动速度, 内部不包含解释器, 全部代码靠即时编译器编译后执行
- JMC套件(Java Misson Control): 极低的开销监控,管理和分析生产环境中的应用程序工具
- 2008年, BEA被Oracle收购, 大致在jdk8中将两大优秀虚拟机整合完成
- IBM的J9
- 2017年发布了开源的J9VM, 命名为OpenJ9, 交给Eclipse基金会管理
- KVM: JavaME产品线上, 简单轻量, 面向更低端的设备
- AzulVM: 与特定硬件平台绑定, 软硬件配合的专有虚拟机(超高性能)
- LiquidVM: 不需要操作系统支持, 直接运行在BEA公司自家的Hypervisor系统上
- Apache Harmony
- MicrosoftJVM
- TaobaoJVM
- 创建的GCIH(GC invisible heap)技术实现了off-heap: 即将生命周期较长的Java对象从heap中移到heap外, 并且GC不能管理GCIH内部的Java对象, 以此达到降低GC的回收频率和提升GC的回收效率目的
- GCIH中的对象还能够在多个Java虚拟机进程中共享
- DalvikVM:
- 谷歌开发的, 应用于安卓系统, 只能称为虚拟机, 不能称为Java虚拟机(因为没有遵循Java虚拟机规范)
- 基于寄存器架构
- 不能执行Java的class文件, 而是执行编译后的dex文件
- GraalVM: High-performance polyglot VM
- 高性能优化编译器: 优化的JIT编译器
- 本机镜像(Native Image): AOT编译器
- 多语言支持
类加载子系统
-
作用: 负责从文件系统或网络中加载class文件, 加载的类信息存放于方法区
-
来源(.class文件)
- 编译后的class文件
- 网络中获取的class文件, 典型场景: Web Applet
- 从zip包中读取: jar, war格式的基础
- 运行时计算生成, 使用最多的是动态代理技术
- 有其他文件生成, 典型场景: JSP应用
- 从加密文件中获取, 典型的防Class文件被反编译的保护措施
-
阶段
- 加载阶段: 双亲委派机制(避免类的重复加载 + 防止核心API被随意篡改)
- 引导类加载器: 加载java核心库(JAVA_HOME/jre/lib/rt.jar, resources.jar等), 出于安全考虑只加载java, javax, sun开头的类
- 扩展类加载器: 加载jre/lib/ext下的类库, 如果用户创建的jar放在此目录也会加载
- 系统类加载器: 也称为应用程序类加载器, 加载classpath下的类库
- 用户自定义类加载器: 继承抽象类ClassLoader, 重写loadClass方法
- 隔离加载类
- 修改类加载的方式
- 扩展加载源
- 防止源码泄露
- 链接阶段: 验证, 准备, 解析
- 验证: 文件格式, 元数据, 字节码, 符号引用
- 准备: 为类变量分配内存并设置默认初始值(零值)
- 解析: 将常量池中的符号引用转换为直接引用, 主要针对类或接口, 字段, 类方法, 接口方法, 方法类型等
- 初始化阶段: 执行类构造器方法
, 是javac编译器自动收集的类变量赋值和静态代码块语句
- 加载阶段: 双亲委派机制(避免类的重复加载 + 防止核心API被随意篡改)
-
获取ClassLoader的途径
- 方式1: 获取当前类的ClassLoader: clazz.getClassLoader()
- 方式2: 获取当前线程的ClassLoader: Thread.currentThread().getContextClassLoader()
- 方式3: 获取系统的ClassLoader: ClassLoader.getSystemClassLoader()
- 方式4: 获取调用者的ClassLoader: DriverManager.getCallerClassLoader()
-
JVM中两个class对象是否为同一个类的两个必要条件
- 类的全限定类名必须一致
- 加载这个类的ClassLoader(指ClassLoader实例对象)必须相同
-
Java程序对类的使用分为: 主动使用和被动使用
- 主动使用
- 创建类的实例(new)
- 访问类的静态变量, 包括读取和更新
- 调用类的静态方法
- 对某个类进行反射操作(class.forName)
- 初始化一个类的子类会导致父类的初始化
- 执行该类的main函数(Java虚拟机启动时被标明为启动类的类)
- jdk7开始提供的动态语言支持: java.lang.invoke.MethodHandle实例的解析结果. REF_getStatic, REF_putStatic, REF_invokeStatic句柄对应的类没有初始化, 则初始化
- 除了以上7种情况, 其他使用Java类的方式对类的被动使用, 都不会导致类的初始化
- 引用该类的静态字面量常量不会导致初始化
比如: public final static int number = 5
- 构造某个类的数组不会导致该类的初始化
比如: Student[] stus = new Student[10];
- 通过子类引用父类的静态字段, 为子类的被动使用, 不会导致子类的初始化
- 引用该类的静态字面量常量不会导致初始化
- 主动使用
运行时数据区(Runtime)
-
程序计数器: (PC寄存器/PC计数器/指令计数器)线程私有, 无GC, 无OOM
- 用来存储指向下一条指令的地址
- 是程序控制流的指示器, 分支/循环/跳转/异常处理/线程恢复等基础功能都依赖这个计数器完成
使用PC寄存器存储字节码指令地址有什么用?
因为CPU需要不停的切换各个线程, 当切换回来以后, 就得知道接着从哪开始继续执行
PC寄存器为什么会被设定为线程私有?
也是因为CPU需要不停的切换各个线程, 为了能够准确记录各个线程正在执行的当前字节码指令地址, 最好的办法就是为每一个线程分配一个PC寄存器 -
虚拟机栈: 线程私有, 无GC, 有OOM, 有栈溢出
- 栈与堆: 栈是运行时的单位, 堆是存储的单位
- 是什么: 每个线程在创建时都会创建一个虚拟机栈, 其内部保存一个个的栈帧, 对应着一次次的方法调用
- 生命周期: 与线程一致
- 作用: 主管Java程序的运行, 它保存方法的局部变量,部分结果并参与方法的调用和返回
- 栈中存储什么: 栈帧(Stack Frame)
- 栈帧的内部结构:
- 局部变量表(Local Variables)
- 定义为一个数字数组, 主要用于存储方法参数和定义在方法体内的局部变量
- 通过索引来访问
- 所需容量大小是在编译期间就确定的
- 最基本的存储单元是Slot(槽): 32位以内的类型只占据一个slot, 64位类型(long和double)占用两个槽
- 局部变量表中的变量也是重要的垃圾回收根节点, 只要被局部变量表中直接或间接引用的对象都不会被回收
- 类变量与局部变量的对比
类变量有两次初始化的机会,第一次是"准备"阶段设置零值, 另一次是在"初始化"阶段,设置代码中定义的初始值
和类变量初始化不同的是, 局部变量不存在系统初始化的过程, 意味着一旦定义了局部变量则必须人为的初始化, 否则编译不通过
- 操作数栈(Operand Stack) (或表达式栈)
- 方法执行过程中, 根据字节码指令往栈中写入数据或提取数据即入栈(push)和出栈(pop)
- 主要用于保存计算的中间结果, 同时作为计算过程中变量临时的存储空间
- 编译期就确定了明确的栈深度, 32bit占用一个栈深度, 64bit占用二个栈深度
- 操作数栈并非采用索引方式进行数据访问, 而是只能通过标准的入栈/出栈来完成数据访问
- 如果被调用的方法带有返回值的话, 其返回值将会被压入当前栈帧的操作数栈中, 并更新PC寄存器中下一条需要执行的字节码指令
- 我们说Java虚拟机的解释引擎是基于栈的执行引擎, 其中的栈指的就是操作数栈
- 栈顶缓存技术: 将栈顶元素全部缓存在物理CPU的寄存器中, 以此降低对内存的读/写次数, 提升执行引擎的执行效率
- 动态链接(Dynamic Linking) (或指向运行时常量池的方法引用)
- 为了将编译到字节码中的符号引用转换为调用方法的直接引用
- 方法的调用
- 静态链接(早期绑定): 目标方法在编译器可知, 非虚方法(静态方法, 私有方法, final方法, 实例构造器, 父类方法)
- 动态链接(晚期绑定): 被调用的方法在编译器无法被确定下来, 虚方法
- 虚方法表: 在类加载的链接阶段初始化完毕
- 方法返回地址(Return Address) (或方法正常退出或者异常退出的定义)
- 存放调用该方法的PC寄存器的值
- 一些附加信息
- 例如对程序调试提供支持的信息
- 局部变量表(Local Variables)
- 栈中可能出现的异常
- JVM规范允许Java栈的大小是动态的或者固定不变的
- 固定大小: StackOverflowError
- 动态扩展: OutOfMemoryError
- 设置栈内存大小: -Xss设置线程的最大栈空间, 栈的大小直接决定了函数调用的最大可达深度
- Linux/Mac/Solaric: 默认为1M
- Windows: 依赖虚拟内存
- JVM规范允许Java栈的大小是动态的或者固定不变的
-
本地方法栈: 线程私有
- 本地方法: Java调用非Java代码的接口, 初衷是融合C/C++程序
- 为什么要使用本地方法?
- 与Java环境外交互
- 与操作系统交互
- Java虚拟机栈管理Java方法的调用, 而本地方法栈用于管理本地方法的调用
- 当某个线程调用一个本地方法时, 就进入了一个全新的并且不再受虚拟机限制的世界. 它和虚拟机拥有同样的权限
-
堆: 线程共享
- 核心概述
- 一个JVM实例只存在一个堆内存, 堆也是Java内存管理的核心区域
- Java堆在JVM启动时被创建, 是JVM管理的最大一块内存空间
- 所有线程共享Java堆, 在这里还可以划分线程私有的缓冲区(TLAB)
- "几乎"所有的对象实例以及数组都在运行时分配在堆上
- 方法结束后堆中的对象不会马上被移除, 仅在垃圾收集时才会被移除
- 堆, 是GC执行垃圾回收的重点区域
- 内存细分: 现代垃圾收集器大部分基于分代收集理论设计
- 新生代 Young/New: Eden区和Survivor区(S0/S1)
- 老年代 Old/Tenure
- 永久区 Perm (jdk8改为元空间 Meta)
- 常见参数设置
- -Xms设置初始堆空间, 等价于 -XX:InitialHeapSize, 默认为物理内存的1/64
- -Xmx设置最大堆空间, 等价于 -XX:MaxHeapSize, 默认为物理内存的1/4
通常将-Xms和-Xmx两个参数配置相同的值, 目的是为了能够在Java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小, 从而提高性能
- -XX:+PrintGCDetails 打印GC详细信息
- -XX:+PrintFlagsInitial: 查看所有参数的默认初始值
- -XX:+PrintFlagsFinal: 查看所有参数的最终值
- -XX:NewRatio=2 默认新生代和老年代的占比为1:2
- -XX:SurvivorRatio=8 默认Eden空和S0/S1的空间占比为8:1:1
- -XX:+UseAdaptiveSizePolicy 默认打开自适应的内存分配策略
- -XX:MaxTenuringThreshold=15 默认15次垃圾回收后仍存活的对象到老年代
- -XX:+UseTLAB 默认开启TLAB
- -XX:TLABWasteTargetPercent 默认TLAB空间很小仅占Eden空间的1%
- -XX:MinHeapFreeRatio 最小堆空闲百分比, 默认40%
- -XX:MaxHeapFreeRatio 最大堆空闲百分比, 默认70%
- 对象分配过程
新对象申请时, 先看Eden区是否放的下, 放不下则执行YGC, 执行完毕后再看Eden是否放得下, 放不下(即超大对象)则看Old区是否放得下, 放不下执行FGC, 执行完毕后再看是否放得下, 放不下OOM
- 垃圾的部分收集和整堆收集
- Minor GC(Young GC): 只是新生代的垃圾收集
- Major GC(Old GC): 只是老年代的垃圾收集(目前只有CMS GC会有单独收集老年代的行为)
- Mixed GC: 收集整个新生代以及部分老年代的垃圾收集(目前只有G1 GC有这种行为)
- Full GC: 收集整个Java堆和方法区的垃圾收集
- 垃圾回收的触发条件
- Minor GC的触发条件: Eden区空间不足
- Major GC/Full GC的触发条件: Old区空间不足
- 一般Major GC速度比Minor GC慢10倍以上
- 出现Major GC经常会伴随至少1次的Minor GC
- Full GC的触发条件
- 调用System.gc(): 系统建议执行Full GC, 但是不必然执行
- 老年代空间不足
- 方法区空间不足
- 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
- 由Eden/S0向S1复制时, 对象大小大于S1可用内存, 则需要把该对象转到老年代, 且老年代可用内存小于该对象大小
- 为什么有TLAB(Thread Local Allocation Buffer)
堆区是线程共享区域, 为避免多个线程操作同一地址, 需要使用加锁机制, 进而影响分配速度.
从内存模型而不是垃圾收集的角度, 对Eden区继续进行划分, JVM为每个线程分配了一个私有缓存区域.
多线程同时分配内存时, 使用TLAB可以避免一系列的非线程安全问题, 同时能够提升内存分配的吞吐量, 称为快速分配策略 - 空间分配担保
在发生Minor GC之前, 虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间.
如果大于则此次Minor GC是安全的. 如果小于, 则虚拟机会查看-XX:HandlePromotionFailure设置值是否允许担保失败. 如果为true, 那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小. 如果大于则尝试进行一次Minor GC(但这次Minor GC依然是有风险的). 如果小于则改为进行一次Full GC. 如果为false则改为进行一次Full GC - 堆是分配对象存储的唯一选择吗
随着JIT编译器的发展与逃逸分析技术逐渐成功, 栈上分配与标量替换优化技术将导致一些微妙的变化. 所有的对象都分配到堆上也变得不那么"绝对"了. 如果经过逃逸分析(Escape Analysis)后发现, 一个对象并没有逃逸出方法的话, 那么就可能被优化为栈上分配.
- 逃逸分析: 编译器可以对代码做优化 ==> 开发中能用局部变量的就不要再方法外定义
- 栈上分配
- 同步省略
- 分离对象或标量替换
- 核心概述
-
方法区: (永久代或元空间,代码缓存)线程共享
- 实现: jdk7及以前为永久代, jdk8改为元空间, 原因如下
- 为永久代设置空间大小是很难确定的
- 对永久代进行调优是很困难的
- 永久代与元空间的最大区别: 元空间不在虚拟机设置的内存中, 而使用本地内存
- 方法区的大小
- -XX:Permsize 设置永久代初始大小, 默认为20.75M
- -XX:MaxPermSize 设置永久代最大空间, 32位机器默认为64M, 64位机器默认为82M
- -XX:MetaspaceSize 设置元空间初始大小, 默认为21M
- -XX:MaxMetaspaceSize 默认为-1, 即没有限制
- 方法区存储内容: 类型信息, 运行时常量池, 静态变量, 即时编译器编译后的代码缓存等
- 方法区的垃圾搜集主要回收两部分内容: 常量池中废弃的常量和不再使用的类型
- 实现: jdk7及以前为永久代, jdk8改为元空间, 原因如下
对象的实例化,内存布局与访问定位
-
对象的实例化
- 创建对象的方式
- new
- 最常见的new Xxx()
- 变形1: Xxx的静态方法
- 变形2: XxxBuilder/XxxFactory的静态方法
- Class的newInstance(): 反射的方式, 只能调用空参构造器, 权限必须为public
- Constructor的newInstance(Xxx): 反射的方式, 可以调用空参/带参构造器, 权限没有要求
- 使用clone: 当前类需要实现Cloneable接口的clone()方法
- 使用反序列化: 从文件/网络中获取一个对象的二进制流
- 第三方库Objenesis
- new
- 创建对象的步骤
- 判断对象对应的类是否加载/链接/初始化
- 为对象分配内存
- 内存规整: 指针碰撞
- 内存不规整: 虚拟机需要维护一个列表, 空闲列表分配
- 处理并发安全问题
- 每个线程预先分配一块TLAB
- 采用CAS失败重试, 区域加锁保证更新的原子性
- 属性的默认初始化(零值初始化), 保证对象实例字段在不赋值时可以直接使用
- 设置对象的对象头信息
- 执行init方法进行初始化: 显示初始化, 代码块中初始化, 构造器中初始化
- 创建对象的方式
-
对象的内存布局
- 对象头:
- 运行时元数据(Mark Word): 哈希值, GC分代年龄, 锁状态标志, 线程持有的锁, 偏向线程ID, 偏向时间戳
- 类型指针: 指向类元数据, 确定该对象所属的类型
- 说明: 如果是数组, 还需记录数组的长度
- 实例数据: 对象真正存储的有效信息, 包括程序代码中定义的各种类型字段(包括从父类继承下来的和本身拥有的字段)
- 对齐填充: 不是必须的, 也没特别含义, 仅仅起到占位符的作用
- 对象头:
-
对象的访问定位
- JVM如何通过栈帧中的对象引用访问到其内部的对象实例呢? 通过栈上reference访问
reference 栈帧 --> instanceOopDesc 堆区 --> InstanceKlass 方法区
- 对象访问方式
- 句柄访问: 好处为reference中存储稳定句柄地址, 对象被移动时只会改变句柄中实例数据指针即可
- 直接指针(Hotspot采用): 好处更快捷
- JVM如何通过栈帧中的对象引用访问到其内部的对象实例呢? 通过栈上reference访问
直接内存
- 概述
- 不是运行时数据区的一部分, 也不是虚拟机规范中定义的内存区域
- 直接内存是Java堆外的, 直接向系统申请的内存区间
- 来源于NIO, 通过存在堆中的DirectByteBuffer操作Native内存
- 通常, 访问直接内存的速度会优于Java堆, 即读写性能高
- 分配回收成本较高
- 不受JVM内存回收管理
- 难以监控: jvisualvm, jprofile等监控不到, jmap导出的堆文件也没有
- 也可能导致OutOfMemoryError
- 默认和-Xmx参数值一致, 可以通过MaxDirectMemorySize设置
执行引擎
- 概述: 虚拟机的执行引擎是由软件自行实现的, 能够执行那些不被硬件直接支持的指令集格式
- 作用: 将字节码指令解释/编译为对应平台上的本地机器指令(高级语言翻译为机器语言)
- 解释器: 对字节码采用逐行解释的方式执行(响应速度快)
- JIT编译器: 将源代码直接编译成和本地机器相关的机器语言(热点探测)
- 编译器
- 前端编译器: java --> class , Sun的javac, Eclipse JDT中的增量式编译器(ECJ)
- JIT编译器(后端运行期编译器): class --> 机器码, HotSpotVM的C1, C2编译器
- AOT编译器(静态提前编译器): java --> 机器码, GNU Compiler for the Java(GCJ), Excelsior JET
- 热点代码: 一个被多次调用的方法 或者 一个方法体内部循环次数较多的循环体
- 栈上替换: 由于这种编译方式发生在方法的执行过程中, 因此称为栈上替换 或 OSR(On Stack Replacement)
- 探测方式: 基于计数器的热点探测, HotSpotVM为每一个方法都建立2个不同类型的计数器
- 方法调用计数器(Invocation Counter): 统计方法的调用次数
- 回边计数器(Back Edge Counter): 统计循环体执行的循环次数
- 计数次数: 两个计数器之和, Client模式下为1500次, Server模式下为10000次, 就会触发JIT编译, 可通过-XX:CompileThreshold 调节
- 热度衰减: 默认方法调用计数器统计的是一段时间之内方法被调用次数. 当超过一定的时间限度, 如果方法的调用次数不足以提交给JIT编译器, 次数就会被减少一半, 这段时间称为半衰周期
- -XX:-UseCounterDelay: 可以关闭热度衰减
- -XX:CounterHalfLifeTime: 设置半衰周期的时间, 单位为秒
- 执行方式
- -Xint: 完全解释执行
- -Xcomp: 完全即时编译, 如果即时编译出现问题, 解释器会介入执行
- -Xmixed: 解释器 + 即时编译
- JIT编译器
- -client: C1编译器, 对字节码进行简单和可靠的优化, 耗时短, 更快的编译速度
- 方法内联: 将引用的函数代码编译到引用点处, 这样可以减少栈帧的生成, 减少参数传递以及跳转过程
- 去虚拟化: 对唯一的实现类进行内联
- 冗余消除: 在运行期间把一些不会执行的代码折叠掉
- -server: C2编译器, 耗时较长的优化以及激进优化, 但优化的代码执行效率更高
除了C1的优化策略外, 基于逃逸分析, 还有下面的优化
- 标量替换: 用标量值聚合对象的属性值
- 栈上分配: 对于未逃逸的对象分配对象在栈而不是堆
- 同步消除: 清除同步操作, 通常指 synchronized
- graal编译器: JDK10起新引入的全新的即时编译器
- -client: C1编译器, 对字节码进行简单和可靠的优化, 耗时短, 更快的编译速度
垃圾回收概述
- 垃圾: 在运行程序中没有任何指针指向的对象
- GC的作用区域: 堆和方法区
垃圾回收算法
-
标记阶段: 对象存活的判断
- 引用计数算法: Reference Counting
- 优点: 实现简单, 垃圾对象便于标识; 判断效率高, 回收没有延迟性
- 缺点:
- 空间上需要单独的字段存储计数器
- 时间上每次赋值都需要更新计数器
- 无法处理循环引用的情况
Python的垃圾回收采用的是引用计算算法. 通过弱引用weakref来解决循环引用问题
- 可达性分析算法: 根搜索算法, 追踪性垃圾收集 (Tracing Gargage Collection)
- GC Roots
- 虚拟机栈中引用的对象: 各个线程被调用的方法中使用到的参数,局部变量等
- 本地方法栈内JNI(通常说的本地方法)引用的对象
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象: 比如字符串常量池里的引用
- 所有被同步锁 synchronized 持有的对象
- Java虚拟机内部的引用: 基本数据类型对应的Class对象, 一些常驻的异常对象, 系统类加载器
- 反应Java虚拟机内部情况的JMXBean, JVMITI中注册的回调, 本地代码缓存等
除了以上固定的GC Roots集合外, 在分代收集或局部回收时, 其他区域也会"临时性"加入作为GC Roots
- GC Roots
- 引用计数算法: Reference Counting
-
清除阶段
- 标记-清除算法: Mark-Sweep
- 标记: 从引用根节点开始遍历, 标记所有被引用的对象(即可达对象)
- 清除: 堆内存从头到尾进行线性遍历, 没有被标记的对象则进行回收
此种方式清理出来的空闲内存是不连续的, 产生内存碎片. 需要维护一个空闲列表
此处的清除并不是真的置空, 而是把需要清除的对象地址保存在空闲的地址列表里. 下次有新对象需要加载时, 判断垃圾的位置空间是否够, 够就存放 - 复制算法: Copying
- 优点: 没有标记清除过程, 简单高效; 同时避免了"碎片"问题
- 缺点: 需要两倍的内存空间; 对于G1这种分拆成为大量region的GC, 复制而不是移动, 意味着GC需要维护region之间对象引用关系, 不管是内存占用或者时间开销也不小
核心思想: 将活着的内存空间分为两块, 每次只使用其中一块, 在垃圾回收时将正使用的内存中的存活对象复制到未被使用的内存块中, 之后清除正在使用的内存块中的所有对象, 交换两个内存的角色, 最后完成垃圾回收
- 标记-压缩算法: Mark-Compact (标记-整理算法)
- 标记: 同上
- 压缩: 将所有的存活对象压缩到内存的一端, 按顺序排放. 之后清理边界外所有空间
- 标记-清除算法: Mark-Sweep
-
对象的finalization机制
提供对象被销毁前之前的自定义处理逻辑. 垃圾回收之前, 总会先调用这个对象的finalize()方法
finalize()方法用于在对象被回收时进行资源释放, 这个方法只会被垃圾回收线程调用1次
此机制导致一个对象是否可回收, 至少经历2次标记过程 -
MAT与JProfiler的GC Roots溯源
-
分代收集算法
核心思想: 基于不同对象的生命周期是不一样的事实, 不同生命周期的对象采用不同的收集方式, 以便提高回收效率
- 年轻代: 区域小, 对象生命周期短, 存活率低 ==> 复制算法
- 老年代: 区域大, 对象生命周期长, 存活率高 ==> 标记-清除 或 标记-整理算法
-
增量收集算法
核心思想: 每次垃圾收集线程只收集一小片区域的内存空间, 接着切换到用户线程, 依次反复, 直到垃圾收集完成. (避免过长的STW, 缺点是由于线程切换和上下文转换的消耗, 使得垃圾回收总体成本上升, 造成系统吞吐量的下降)
-
分区算法
核心思想: 将整个堆空间划分成连续的不同小区间. 每个小区间独立使用, 独立回收, (可以控制一次回收多少个小空间)
垃圾回收相关概念
- System.gc()的理解: 提醒JVM执行Full GC, 但是不确定是否马上执行
- 内存溢出和内存泄漏
- 内存溢出: OOM, OutOfMemoryError, 没有空闲内存, 并且垃圾收集器也无法提供更多内存
- 原因1: Java虚拟机的堆内存设置不够
- 原因2: 代码中创建了大量大对象, 并且长时间不能被垃圾收集器收集(存在被引用)
- 内存泄漏: Memory Leak 严格来说, 对象不会再被程序用到了, 但是GC又不能回收他们的情况. 宽泛意义上还包括不太好的实现(或疏忽)导致对象的生命周期变得很长
- 举例1: 单例模式, 如果持有外部对象引用则这个外部对象不能被回收, 可能导致内存泄漏
- 举例2: 一些提供close的资源未关闭导致内存泄漏, 比如数据库连接, 网络连接, IO连接等
- 内存溢出: OOM, OutOfMemoryError, 没有空闲内存, 并且垃圾收集器也无法提供更多内存
- Stop The World
- 概念: 指GC事件发生过程中会产生应用程序的停顿. 停顿产生时整个应用程序线程都会被暂停, 没有任何响应, 有点像卡死的感觉, 这个停顿称为STW
- 比如: 可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿, 因为分析工作必须在能确保一致性的快照中进行
- 所有GC都有STW事件, STW是JVM在后台自动发起和自动完成的
- 并行和并发
- 程序的并发与并行
- 并发: Concurrent, 一个时间段中多个程序在同一个处理器上快速交替执行(互相抢占资源)
- 并行: Parallel, 一个以上CPU核心数量, 每个核心独立执行程序(互不抢占资源)
对比: 并发是多个事情在同一个时间段同时发生, 并行是多个事情在同一个时间点发生
- 垃圾回收的并行和并发
- 并行: 多个垃圾收集线程并行工作, 但此时用户线程仍处理等待状态
- 串行: 相较于并行的概念, 单线程执行; 如果内存不够则程序暂停进行垃圾回收, 回收完再启动程序线程
- 并发: 指用户线程和垃圾回收线程同时执行
- 程序的并发与并行
- 安全点和安全区域
- 安全点(Safe Point): 程序执行时并非在所有地方都能停顿下来开始GC, 只有在特定位置才可以, 这些位置称为"安全点"
安全点的选择很重要, 如果太少可能导致GC等待的时间太长, 如果太频繁可能导致运行时的性能问题. 大部分指令的执行时间都非常短暂, 通常根据"是否具有让程序长时间执行的特征"为标准. 比如: 选择一些执行时间较长的指令作为安全点, 如方法调用/循环跳转和异常跳转等
- 如何在GC发生时检查所有线程都跑到最近的安全点停顿下来呢?
- 抢先式中断: 首先中断所有线程, 如果还有线程不在安全点就恢复线程, 让线程跑到安全点(目前没有虚拟机采用了)
- 主动式中断: 设置一个中断标志, 各个线程运行到安全点时主动轮询这个标志. 如果中断标志为真, 则将自己进行中断挂起
- 安全区域(Safe Region): 安全点机制保证了程序运行时在较短时间就会遇到可进入GC的SafePoint. 但是如果程序"不执行"时(比如线程处于sleep或blocked), 此时线程无法响应JVM的中断请求, 此时就需要安全区域来解决
安全区域是指在一段代码片段中, 对象的引用关系不会发生变化, 这个区域的任何位置开始GC都是安全的. 也可以把安全区域看作扩展的安全点
实际执行时, 当线程运行到安全区域, 首先标识已经进入Safe Region, 如果这段时间内发生GC, JVM会忽略标识为Safe Region状态的线程; 当线程即将离开安全区域时, 会检查JVM是否已经完成GC, 如果完成了则继续运行, 否则线程必须等待直到收到可以安全离开Safe Region的信号为止
- 安全点(Safe Point): 程序执行时并非在所有地方都能停顿下来开始GC, 只有在特定位置才可以, 这些位置称为"安全点"
- 再谈引用
- 强引用: StrongReference 不能回收
- 软引用: SoftReference 内存不足才回收, 比如高速缓存
- 弱引用: WeakReference 发现即回收, 比如可有可无的缓存数据 (WeakHashMap)
- 虚引用: PhantomReference 随时可能回收, 唯一目的在于跟踪垃圾回收过程
- 终结器引用: FinalReference
垃圾回收器
-
GC分类
- GC线程数: 串行GC和并行GC
- 工作模式: 独占式和并发式
- 碎片处理: 压缩式和非压缩式
- 工作内存区间: 年轻代和老年代
-
GC性能指标: 在最大吞吐量优先的情况下, 降低停顿时间
- 暂停时间: 程序的工作线程被暂停的时间
- 吞吐量: 运行用户代码的时间占总时间的比例
- 垃圾收集开销: 垃圾收集时间占总时间的比例(吞吐量的补数)
- 收集频率: 相对于应用程序的执行, 收集操作的频率
- 内存占用: Java堆区所占的内存大小
- 快速: 一个垃圾对象从诞生到被回收所经历的时间
-
7款经典的垃圾回收器
-
串行: Serial, Serial Old
-
并行: ParNew, Parallel Scavenge, Parallel Old
-
并发: CMS, G1
-
新生代: Serial, ParNew, Parallel Scavenge
-
老年代: Serial Old, Parallel Old, CMS
-
整堆: G1
-
-
如何查看默认的垃圾收集器
- -XX:+PrintCommandLineFlags: 查看命令行相关参数(包括使用的垃圾收集器)
- jinfo -flag 相关垃圾收集器参数 进行ID
-
Serial: 串行回收(单线程收集器)
- 年轻代: Serial收集器采用复制算法, 串行回收和STW机制
- 老年代: Serial Old收集器采用标记-压缩算法, 串行回收和STW机制
- -XX:+UseSerialGC 年轻代Serial, 老年代Serial Old
-
ParNew: 并行回收(Serial收集器的多线程版本, New表示只能处理新生代)
- -XX:+UseParNewGC 标明新生代只用ParNewGC
- -XX:ParallelGCThreads 限制线程数量, 默认开启和CPU核心数相同的线程
-
Parallel: 吞吐量优先 (JDK8默认)
- 和ParNewGC不同, ParallelGC收集器的目标是达到一个可控制的吞吐量
- 和ParNewGC不同, ParallelGC收集器还具有自适应调节策略
- Parallel为老年代提供Parallel Old收集器
- -XX:+UseParallelGC 手动指定年轻代使用Parallel并行收集器
- -XX:+UseParallelOldGC 手动指定老年代使用Parallel Old并行收集器
以上两个参数, 默认开启一个另一个也会被开启(互相激活)
- -XX:ParallelGCThreads 设置年轻代并行收集器的线程数
默认情况下CPU数量<=8则为CPU数量; 否则为3+(5*CPU)/8
- -XX:MaxGCPauseMillis 设置GC最大停顿时间, 单位毫秒
- -XX:GCTimeRatio 设置垃圾收集时间占总时间的比例
- -XX:+UseAdaptiveSizePolicy 开启ParallelGC的自适应调节策略(默认开启)
这种模式下, 年轻代大小, Eden和Survior的比例, 晋升老年代的对象年龄等参数会被自动跳转, 以达到在堆大小/吞吐量和停顿时间之间的平衡点
-
CMS: 低延迟(Concurrent-Mark-Sweep)
- Jdk1.5推出的第一款真正意义上的并发收集器, 第一次实现了让垃圾收集线程和用户线程同时工作
- 关注点: 尽可能缩短垃圾收集时用户线程的停顿时间
- 工作原理:
- 初始标记: STW, 仅仅标记GC Roots能直接关联到的对象, 速度非常快
- 并发标记: 从GC Roots的直接关联对象开始遍历整个对象图的过程, 这个过程耗时较长但是不需要停止用户线程
- 重新标记: STW, 修正并发标记期间, 因用户线程继续运作到导致标记产生变动的那一部分对象的标记记录
- 并发清除: 清理删除掉标记阶段判断的垃圾对象, 释放内存空间. 由于不需要移动存储对象, 这个阶段也是可以与用户线程同时并发的
由于最耗时的并发标记和并发清除阶段都不需要暂停用户线程, 所以整体的回收是低停顿的. 由于在垃圾收集阶段用户线程没有中断, 所以在CMS回收过程中, 还应该确保应用程序用户线程有足够的内存. 因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集, 而是当堆内存使用率达到某一阈值时便开始进行回收, 以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行. 要是CMS运行期间预留的内存无法满足程序需要, 就会出现一次"Concurrent Mode Failure"失败, 这时虚拟机将启动后备方案; 临时启动Serial Old收集器来重新进行老年代的垃圾收集, 这样停顿时间就更长了
- 优点: 并发收集, 低延迟
- 缺点: 产生内存碎片; 对CPU资源非常敏感; 无法处理浮动垃圾
- -XX:+UseConcMarkSweepGC: 手动指定使用CMS收集器
开启该参数会自动将-XX:+UseParNewGC打开. 即 ParNew(新生代) + CMS(老年代) + Serial Old的组合
- -XX:CMSInitiatingOccupanyFraction 设置堆内存使用率的阈值, 一旦达到该阈值便开始进行垃圾回收
JDK5及以前默认为68%, JDK6及以上默认值为92%
如果内存增长缓慢可以设置一个稍大的值, 可以有效降低CMS的触发频率, 减少老年代回收的次数可以较为明显的改善应用程序的性能. 反之如果内存增长比较快则应降低该阈值, 以避免频繁触发老年代串行收集器. 该选项可以有效降低Full GC的执行次数 - -XX:+UseCMSCompactAtFullCollection 用于指定在执行完Full GC后对内存空间进行压缩, 以避免内存碎片的产生. 不过由于内存压缩整理过程无法并发执行, 所带来的问题就是停顿时间变得更长了
- -XX:CMSFullGCsBeforeCompaction 设置在执行多少次Full GC后对内存空间进行压缩整理
- -XX:ParallelCMSThreads 设置CMS的线程数量
JDK9中CMS被标记为废弃, JDK14中CMS已经删除
-
小结:
- 如果想要最小化使用内存和并行开销, 请选Serial GC
- 如果想要最大化应用程序的吞吐量, 请选Parallel GC
- 如果想要最小化中断或停顿时间, 请选CMS GC
-
G1: 区域化分代式 (JDK9默认)
- 背景: 业务越来越庞大复杂, 为了适应不断扩大的内存和不断增加的处理器数量, 进一步降低暂停时间, 同时兼顾良好的吞吐量
- 目标: 在延迟可控的情况下获得尽可能高的吞吐量, 且是全功能收集器(新生代+老年代)
- 场景: 配置多核CPU及大容量内存的机器, jdk7就正式可用, jdk9中成为默认
- 特点
- 并行: G1在回收期间, 可以有多个GC线程同时工作, 有效利用多核计算能力
- 并发: G1拥有与应用程序交替执行的能力, 部分工作可以和应用程序同时执行
- 分代收集
- 从分代上看, G1依然属于分代型垃圾回收器. 它会区分年轻代和老年代, 年轻代依然有Eden区和Survivor区. 但从堆结构上看, 它不要求整个Eden区, 年轻代, 老年代是连续的, 也不再坚持固定大小和固定数量
- 将堆空间分为若干个区域(region), 这些区域包含了逻辑上的年轻代和老年代
- 和之前的各类回收器不同, 同时兼顾年轻代和老年代
- 空间整合
- CMS: 标记-清除算法, 内存碎片, 若干次GC后进行一次碎片整理
- G1将内存划分为一个个region, 内存的回收以region作为基本单位. region之间是复制算法, 但整体上可看作标记-压缩算法. 两种算法都可以避免内存碎片. 这种特性有利于程序长时间运行, 分配大对象不会因为无法找到连续内存空间而提前触发下一次GC. 尤其是当Java堆非常大的时候, G1的优势更加明显
- E: Eden
- S: servivor
- O: old
- H: humongous 存储大对象
- 可预测的停顿时间模型(即: 软实时 soft real-time)
- 优先列表: 有计划的避免整堆垃圾收集, G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小及回收所需时间的经验值), 在后台维护一个优先列表, 每次根据允许的收集时间, 优先回收价值最大的Region
- 参数设置
-XX:+UseG1GC 启动G1收集器
-XX:G1HeapRegionSize 设置每个region的大小, 值是2的幂, 范围是1-32MB, 目标是根据最小的Java堆大小划分出约2048个区域, 默认是堆内存的1/2000. 所有的Region大小相同且在JVM生命周期内不会被改变
-XX:MaxGCPauseMillis 设置期望达到的最大GC停顿时间指标, 默认是200ms
-XX:ParallelGCThread 设置STW工作线程数的值, 最多设置为8
-XX:ConcGCThreads 设置并发标记的线程数, 建议为ParallelGCThread的1/4左右
-XX:InitiatingHeapOccupancyPercent 设置触发并发GC周期的Java堆占用率阈值, 超过此值就出发GC. 默认为45 - 使用: G1的设计原则就是简化JVM性能调优, 开发人员只需简单三步即可
- 开启G1垃圾收集器
- 设置堆的最大内存
- 设置最大的停顿时间
- 回收过程主要包括如下三个环节
- 年轻代GC (Young GC)
- 老年代并发标记过程 (Concurrent Marking)
- 混合回收 (Mixed GC)
- (如果需要, 单线程独占式高强度的Full GC还是继续存在的, 针对GC评估失败提供了一种保护机制, 即强力回收)
- Remembered Set 记忆集 与 Write Barrier 写屏障
-
一个对象被不同区域引用的问题
-
一个Region不可能是孤立的, 一个Region中的对象可能被其他任意region中对象引用
-
判断对象是否存活时, 是否需要扫描整个Java堆才能保证准确?
- 无论G1还是其他分代收集器, JVM都是使用RSet来避免全局扫描
- 每个Region都有一个对应的RSet
- 每次Reference类型数据写操作时都会产生一个Write Barrier暂时中断操作
- 然后检查将要写入对象的引用执行的对象是否和该Reference类型数据在不同的Region(其他收集器: 检查老年代是否引用了新生代对象)
- 如果不同, 通过CardTable把相关引用信息记录到引用指向对象所在region对象的RSet中
- 当进行垃圾搜集时, 在GC根节点的枚举范围加入RSet, 就可以保证不进行全局扫描也不会有遗漏
-
- 举例: 一个Web服务器, Java进行最大堆内存为4G, 每分钟响应1500个请求, 每45秒会新分配大约2G的内存. G1会每45秒进行一次年轻代回收, 每31个小时整个堆的使用率会达到45%, 会开始老年代并发标记过程, 标记完成后开始四到五次的混合回收
- G1回收过程一: 年轻代GC
- 扫描根
- 更新RSet: 处理dirty card queue中的card, 更新RSet, 以便RSet可以准确反映老年代对年轻代的引用
- 处理RSet: 识别被老年代对象指向Eden中的对象, 这些对象被认为是存活的对象
- 复制对象: 复制算法
- 处理引用: 处理软弱虚等引用
- G1回收过程二: 并发标记过程
- 初始标记: STW 标记从根节点直接可达的对象, 并且会触发一次年轻代GC
- 根区域扫描: 扫描survivor区直接可达的老年代区域对象, 并标记被引用的对象, 这个过程必须在YGC之前完成
- 并发标记: 在整个堆中进行并发标记(和应用程序并发执行), 此过程可能被YGC中断. 在并发标记阶段, 若发现区域对象中的所有对象都是垃圾, 那这个区域会被立即回收. 同时并发标记过程中, 会计算每个区域的对象活性(区域中存活对象的比例)
- 再次标记: STW 由于应用程序持续进行, 需要修正上一次的标记结果. G1中采用了比CMS更快的初始快照算法: snapshot-at-the-begining(SATB)
- 独占清理: STW 计算各个区域的存活对象和GC回收比例, 并进行排序, 识别可以混合回收的区域, 为下阶段做铺垫(此阶段并不会实际去做垃圾收集)
- 并发清理: 识别并清理完全空闲的区域
- G1回收过程三: 混合回收
当越来越多对象晋升到老年代时, 为了避免堆内存耗尽会触发一个混合的垃圾收集器, 即Mixed GC. 该算法并不是一个Old GC, 除了回收整个Young Region, 还会回收一部分的Old Region
- G1回收过程四(可选): Full GC, 可选原因如下
- Evacuation(回收阶段)的时候没有足够的to-space来存放晋升的对象
- 并发处理过程完成之前空间耗尽